迭代器(iterator)通过反复返回Option<Item>逐项产出值,并把遍历、转换与聚合组合成一条处理管道。
map和filter等适配器采用惰性求值;没有消费者时,闭包根本不会运行,而into_iter()还可能移动原集合。
先根据所有权选择iter()、iter_mut()或into_iter(),再明确使用collect、find、sum或for驱动管道。
是什么,为什么存在
迭代器(iterator) 是一个有状态的值,
每次调用next(&mut self)都返回下一个元素,或在没有元素时返回None。
它把“怎样取得下一个元素”封装在统一接口后面,所以调用方不必知道数据来自数组、范围、集合还是按需计算。
Rust的Iterator trait只有一个必须实现的方法next,并通过关联类型Item声明产出类型。
其余大多数方法都有默认实现:map、filter等方法构造新迭代器,
collect、fold和find等方法则驱动迭代并产生结果。
迭代器解决的不只是循环语法重复。 它让转换步骤可以组合,让编译器持续跟踪每一步的元素类型与所有权, 也允许消费者在得到足够信息后立刻停止,而不必先构造完整的中间集合。
你会在Vec、切片、范围、字符串的字符或行、映射的键值视图,以及许多标准库返回值中遇到迭代器。
实现IntoIterator的 可迭代对象(iterable) 还能直接出现在for循环里。
for item in value会先把value转成迭代器,再反复调用next。
这里的“惰性”是求值时机,不是异步执行或后台并行。
适配器只保存下一步需要的状态和闭包;真正请求元素的仍是当前线程中的消费者。
如果需要并行处理,那是Rayon等其他抽象负责的事情,不是标准Iterator自动提供的能力。
工作原理
Iterator与IntoIterator
Iterator表示已经可以产出元素的游标式状态;IntoIterator表示某个值能生成这样的状态。
一个类型可能为拥有型值、共享引用和可变引用分别实现IntoIterator,
因此同一个for语法可以对应移动、只读借用或可变借用。
以Vec<T>为例,常用入口表达三种不同意图:
| 入口 | 常见Item | 对原集合的影响 | 适用意图 |
|---|---|---|---|
values.iter()或&values | &T | 共享借用,之后仍可使用集合 | 只读观察 |
values.iter_mut()或&mut values | &mut T | 独占借用,可就地修改元素 | 原地更新 |
values.into_iter()或values | T | 移动集合并逐项交出元素 | 消费输入 |
表中的Item是Vec<T>的典型情况,不是所有类型的全局定律。
数组、映射和自定义类型可以选择符合自身语义的实现。
阅读泛型API时,应看它要求Iterator还是IntoIterator,以及实际的Item类型。
适配器与消费者
迭代器适配器(iterator adapter) 接收一个迭代器并返回另一个迭代器。
map改变元素,filter按条件保留元素,enumerate加入索引,
zip配对两个来源,take限制最多产出多少项。
终结操作(terminal operation) 会请求元素并产生最终观察结果。
Rust文档常把这类方法称为consuming adapters,因为它们取得迭代器的所有权,
或者通过&mut self推进其状态。collect、sum、count、fold、any和find都属于这一侧。
适配器链的求值方向不是“先把所有元素映射完,再把所有结果过滤完”。
消费者每请求一项,数据就按链的顺序穿过相关闭包;若这一项被过滤掉,消费者继续请求下一项。
没有显式collect时通常也没有中间Vec。
Option与迭代器状态
next需要&mut self,因为取得一项通常会改变当前位置。
返回Some(item)表示本次成功产出一项;返回None表示本次没有下一项。
这让空迭代器和迭代结束都能用同一种类型安全的方式表示。
许多消费者会短路。
find找到第一项就返回,any遇到第一个true就返回,
而对Result进行collect时,第一个Err会结束收集。
短路后,尚未请求的元素不会执行前面的闭包。
消费者之后是否还能继续使用迭代器,取决于方法怎样接收self。
find、any和nth接收&mut self,因此命名的迭代器会停在已经消费的位置;
collect和count接收self,会把迭代器整个移动进去。
collect的目标类型
collect本身没有固定返回Vec。
它依靠目标类型的FromIterator实现决定怎样组装元素,
所以你通常要用变量类型、函数返回类型或collect::<Vec<_>>()告诉编译器目标是什么。
这种分派也解释了为什么Iterator<Item = Result<T, E>>可以收集成Result<Vec<T>, E>。
外层Result的FromIterator实现会依次处理每一项:全部为Ok时得到向量,
遇到第一个Err时立即返回该错误。
示例
按意图选择所有权
第一个例子用同一个向量展示共享借用和可变借用,再用另一个向量展示拥有型迭代。
iter()让求和只读取元素;iter_mut()暂时独占每个元素;
into_iter()则把每个String移入转换闭包,原来的labels随后不能再使用。
fn main() {
let mut scores = vec![72, 88, 91];
let total: i32 = scores.iter().sum();
println!("total: {total}");
scores.iter_mut().for_each(|score| *score += 1);
println!("adjusted: {scores:?}");
let labels = vec![String::from("draft"), String::from("ready")];
let upper: Vec<String> = labels
.into_iter()
.map(|label| label.to_uppercase())
.collect();
println!("owned: {upper:?}");
}total: 251
adjusted: [73, 89, 92]
owned: ["DRAFT", "READY"]类型标注Vec<String>为collect提供目标类型。
如果后续还要使用labels,可以改用labels.iter()并接受&String,
但最终需要拥有型字符串时仍要明确克隆或创建新字符串;借用不会凭空变成所有权。
观察惰性与短路
inspect适合在学习或诊断时观察实际经过管道的元素。
下面的find只需要第一个大于20的偶数平方,
所以读取到8并得到64后就停止,11与14从未进入管道。
fn main() {
let readings = [2, 5, 8, 11, 14];
let first_large_even_square = readings
.iter()
.copied()
.inspect(|reading| println!("checked: {reading}"))
.filter(|reading| reading % 2 == 0)
.map(|reading| reading * reading)
.find(|square| *square > 20);
println!("result: {first_large_even_square:?}");
}checked: 2
checked: 5
checked: 8
result: Some(64)这里先调用copied(),把&i32转成i32,后续闭包就不需要处理多层引用。
inspect自己仍是惰性适配器;它之所以打印,是因为末尾的find请求了元素。
生产代码不应依赖inspect完成必要业务副作用,因为重排或短路都会改变执行次数。
让错误停止收集
当坏输入必须使整个操作失败时,应保留Result并直接收集,
而不是把.ok()交给filter_map后静默丢掉错误。
下面的第三个字符串不会解析,因为第二个字符串已经产生Err。
fn main() {
let readings = ["18", "bad", "24"];
let parsed: Result<Vec<i32>, _> = readings
.into_iter()
.map(|text| {
println!("parsing: {text}");
text.parse::<i32>()
})
.collect();
println!("success: {}", parsed.is_ok());
}parsing: 18
parsing: bad
success: falseResult<Vec<i32>, _>同时确定了成功值的集合类型,并让错误类型保持由parse推断。
真实程序通常会返回或处理parsed中的具体错误;
这里只打印布尔值,是为了让示例输出不依赖错误调试格式的细节。
实现一个有限迭代器
自定义迭代器把尚未产出的状态放在结构体里,并为next定义单步转换。
这个票据编号迭代器使用半开区间[start, end);
到达end后保持结束状态,所以后续调用仍返回None。
struct TicketIds {
next: u32,
end: u32,
}
impl TicketIds {
fn new(start: u32, end: u32) -> Self {
Self { next: start, end }
}
}
impl Iterator for TicketIds {
type Item = String;
fn next(&mut self) -> Option<Self::Item> {
if self.next >= self.end {
return None;
}
let id = format!("T-{:03}", self.next);
self.next += 1;
Some(id)
}
fn size_hint(&self) -> (usize, Option<usize>) {
let remaining = self.end.saturating_sub(self.next) as usize;
(remaining, Some(remaining))
}
}
fn main() {
let ids: Vec<String> = TicketIds::new(7, 10).collect();
println!("{ids:?}");
}["T-007", "T-008", "T-009"]只要实现Iterator,这个类型就自动获得map、take、collect等默认方法。
它的size_hint给出精确剩余项数,帮助收集器规划容量;
这个提示必须随next更新,不能把最初长度当成永久答案。
陷阱
深入理解:适配器类型与契约
每条链都有具体类型
每次调用map或filter都会返回不同的适配器结构体,
其中保存上游迭代器和闭包。链可以很长,但它在编译期仍有一个具体嵌套类型;
闭包类型也由编译器生成,并不需要堆分配成trait object。
函数只在内部使用管道时,通常让编译器推断完整类型最清楚。
API需要返回管道时,impl Iterator<Item = T> + '_可以隐藏具体适配器名字,
同时保留静态分派。只有运行时确实要在多种不同迭代器实现之间选择时,才考虑Box<dyn Iterator<Item = T>>。
返回迭代器时必须把借用关系写进接口。
如果管道从参数切片借用元素,返回值不能比该切片活得更久;
若闭包要拥有配置,常用move把配置移动进闭包,但move不会把上游借用的数据变成拥有型数据。
None之后的行为
基础Iterator契约不保证某次返回None后永远只返回None。
大多数集合迭代器自然满足这一性质,但某些自定义迭代器可能在之后再次产出项目。
需要稳定结束语义的泛型算法可以调用fuse(),得到实现FusedIterator的包装器。
实现自定义迭代器时,持续结束通常最不意外,前面的TicketIds也是如此。
如果恢复产出本来就是领域语义,就应在类型名和文档中明确说明,
不能依赖调用者猜测一次None是暂时缺项还是永久结束。
size_hint不是长度承诺
size_hint()返回剩余项数的下界和可选上界。
下界不能超过实际剩余项数;存在上界时,实际剩余项数也不能超过它。
一般调用方只能把提示用于容量规划,不能据此跳过正确性检查。
过滤器通常无法提前知道有多少项通过谓词,因此下界可能是0,
上界则沿用上游最多还能提供多少项。ExactSizeIterator表达更强的精确长度契约,
只应在实现能够一直维持准确剩余长度时提供。
错误的size_hint对普通安全代码不应造成内存不安全,
但会导致错误预分配、糟糕性能或违反更强trait的逻辑契约。
更新自定义next、nth或双端迭代逻辑时,要同步检查剩余长度计算。
消费方式决定可观察行为
同一条惰性链可以由不同消费者以不同方式驱动。
collect请求直到结束或错误,take(n).collect()最多请求n项,
find与any则在条件满足时停止。闭包中的日志、计数器或外部写入因而属于消费策略的一部分。
这也是为何应把必要副作用放在明确的循环或边界操作中。 纯转换闭包更容易重排、短路和测试; 如果副作用确实必要,就在测试中断言调用次数与顺序,而不只断言最终集合。
常见消费者表达的是不同契约:
| 消费者 | 结果 | 空输入 | 是否可短路 |
|---|---|---|---|
collect::<Vec<_>>() | 所有元素组成的向量 | 空向量 | 通常否 |
collect::<Result<Vec<_>, _>>() | 成功向量或首个错误 | Ok([]) | 遇到Err时是 |
find(predicate) | Option<Item> | None | 找到时是 |
fold(initial, step) | 累加状态 | 返回initial | 默认否 |
try_fold(initial, step) | 可失败的累加状态 | 返回成功的initial | 遇到失败控制流时是 |
当一条生成的链难以解释时,先写出来源、每一步Item类型和消费者。
如果仍需在一个闭包里同时解析、过滤、修改外部状态并映射,
改成命名循环往往更容易审查;使用迭代器不是把所有逻辑压进一行的要求。
延伸阅读
4个问题 · 1道输出预测题 · 1道找错题