迭代器

理解Rust迭代器的所有权语义、惰性适配器、短路消费、错误传播,以及自定义Iterator的正确契约。

难度 进阶 时长 标准深度约12分钟
版本 Rust 1.98
what

迭代器(iterator)通过反复返回Option<Item>逐项产出值,并把遍历、转换与聚合组合成一条处理管道。

trap

map和filter等适配器采用惰性求值;没有消费者时,闭包根本不会运行,而into_iter()还可能移动原集合。

fix

先根据所有权选择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()或valuesT移动集合并逐项交出元素消费输入

表中的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随后不能再使用。

ownership.rs
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从未进入管道。

lazy_pipeline.rs
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。

fallible_collect.rs
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: false

Result<Vec<i32>, _>同时确定了成功值的集合类型,并让错误类型保持由parse推断。 真实程序通常会返回或处理parsed中的具体错误; 这里只打印布尔值,是为了让示例输出不依赖错误调试格式的细节。

实现一个有限迭代器

自定义迭代器把尚未产出的状态放在结构体里,并为next定义单步转换。 这个票据编号迭代器使用半开区间[start, end); 到达end后保持结束状态,所以后续调用仍返回None。

custom_iterator.rs
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道找错题

前置内容 所有权闭包
下一篇 集合 From into 即将上线 Error handling 即将上线 借用规则
复制为Markdown 面试题库 在GitHub上编辑 报告错误 讲清楚了吗?