深入 Rust 迭代器动机:从 C 风格 for 循环到零开销抽象的迭代模型
【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust
导读
本文以 Google Android 团队 Rust 课程(comprehensive-rust)中 迭代器动机 一节为骨架,系统讲解 Rust 迭代器(Iterator)的设计动机:为什么 Rust 要把"遍历一个数组"所需的状态、终止条件、状态更新逻辑、元素取值逻辑统一打包成一个对象。读者读完本文将掌握:手动遍历的四个必要要素、C 风格 for 循环与 Rust 迭代器模型的对应关系、Iterator/IntoIteratortrait 的底层实现原理,以及迭代器适配器方法链与collect的实战组合用法。
一、遍历一个数组需要什么:四个必要条件
任何对集合的遍历,无论语言和写法如何,本质上都逃不开以下四件事:
- 状态(State):记录"当前迭代进行到哪一步",例如一个下标
index; - 终止条件(Condition):判断迭代何时结束;
- 状态更新(Update):每一轮循环结束时如何推进状态(例如
i += 1); - 取值逻辑(Fetch):利用当前状态取出本轮要处理的元素。
以 C 风格的for循环为例,这四个要素被分散写在循环结构的三个位置里:
for (int i = 0; i < array_len; i += 1) { int elem = array[i]; }其中int i = 0是状态初始化,i < array_len是终止条件,i += 1是状态更新,array[i]是取值逻辑。这种写法直观,但存在一个结构性问题:遍历逻辑与业务处理逻辑耦合在同一个循环体里,且遍历细节(下标管理、边界判断)散落在各处,难以复用和组合。
而在 Rust 中,我们把这四样东西捆绑(bundle)成一个对象,这个对象就是迭代器(iterator)。这正是 src/iterators/motivation.md 一节的核心理念:先用读者熟悉的 C 风格for循环建立"遍历需要状态加逻辑"的直觉,再引出"迭代器就是把这些打包起来"的抽象。
1.1 没有 C 风格 for 循环的 Rust:用 while 表达同一件事
Rust 语言本身没有C 风格的for (int i = 0; ...)循环语法。但同一套逻辑可以直接用while循环原样表达出来,这恰好可以直观地对比"分散的四要素"与"打包的迭代器":
# // Copyright 2024 Google LLC # // SPDX-License-Identifier: Apache-2.0 # let array = [2, 4, 6, 8]; let mut i = 0; while i < array.len() { let elem = array[i]; i += 1; }可以看到:let mut i = 0承担状态职责,i < array.len()是终止条件,i += 1更新状态,array[i]取值。这正是下一节Iteratortrait 中next方法内部要做的事——只不过把这份"家务活"收进了迭代器自己的结构体里。
1.2 扩展视角:指针版本的 C/C++ 遍历
除了下标法,C/C++ 还允许用首尾指针实现遍历,以指针比较作为终止条件:
for (int *ptr = array; ptr < array + len; ptr += 1) { int elem = *ptr; }这个版本值得特别说明:Rust 标准库中 slice 与数组的迭代器,底层正是以这种方式工作的——通过指向切片首尾的指针来推进和判断结束,而不是逐次做下标运算(唯一的差别是它在 Rust 中被实现为一种迭代器)。这为后文"为什么标准库迭代器可以消除边界检查"埋下伏笔。
二、Iteratortrait:迭代器的契约与手动实现
把"状态 + 逻辑"打包成对象之后,就需要一个统一的接口来描述"这个对象如何产生一个值序列"——这就是Iterator)。
Iteratortrait 的核心只有一个抽象方法next,它同时回答了"何时结束"(返回None)与"下一个值是什么"(返回Some(item)):
# // Copyright 2023 Google LLC # // SPDX-License-Identifier: Apache-2.0 # struct SliceIter<'s> { slice: &'s [i32], i: usize, } impl<'s> Iterator for SliceIter<'s> { type Item = &'s i32; fn next(&mut self) -> Option<Self::Item> { if self.i == self.slice.len() { None } else { let next = &self.slice[self.i]; self.i += 1; Some(next) } } } fn main() { let slice = &[2, 4, 6, 8]; let iter = SliceIter { slice, i: 0 }; for elem in iter { dbg!(elem); } }对比第一节的while版本可以发现:i == slice.len()就是原终止条件,self.i += 1就是状态更新,&self.slice[self.i]就是取值逻辑。SliceIter完整复刻了 C 风格 for 循环的全部逻辑,只是把它们收纳进了结构体与next方法内部。同时,这个例子也是一个"包含引用的结构体",因此必须书写生命周期标注's,是理解结构体生命周期的一个绝佳样例。
2.1 迭代器是惰性的(Lazy)
从源码实现可以清晰看出:SliceIter { slice, i: 0 }只是初始化了一个结构体,构造迭代器本身不执行任何遍历工作;所有工作都推迟到next被调用时才发生。这就是迭代器的惰性(lazy)特性,它让"描述一次遍历"与"真正执行遍历"解耦,是后续各种适配器方法能够零开销组合的基础。
2.2 迭代器不必是有限的
next返回None才代表结束,因此一个永远产生值的迭代器是完全合法的。例如半开区间0..会一直向后推进,直到整数溢出为止(届时标准库实现会通过内部检查使其在 debug 与 release 模式下都安全地结束)。
2.3 标准库的真实实现:slice::Iter
课堂上动手实现的SliceIter是对标准库slice::Iter的教学简化版。两者的关键差异是:标准库版本底层使用指针(指向切片首尾)而非下标,从而消除每次取值的边界检查(bounds check)。这印证了 1.2 节的说法,也解释了"越复杂的组合迭代器依然可以编译出与手写命令式循环同等高效的代码"(详见第四节)。
三、IntoIterator:让 for 循环跑起来的那条 trait
Iterator描述的是"拿到迭代器之后怎么迭代",而IntoIterator)。
IntoIterator的实现者必须声明两个关联类型:
Item:要迭代的元素类型,例如i32;IntoIter:into_iter方法返回的迭代器类型。
注意IntoIter与Item是绑定的:该迭代器的Iterator::Item必须与IntoIterator::Item一致,即它必须产出Option<Item>。
下面是一个为自定义Grid类型实现IntoIterator的完整示例,它按行主序产出所有 (x, y) 坐标组合:
# // Copyright 2023 Google LLC # // SPDX-License-Identifier: Apache-2.0 # struct Grid { x_coords: Vec<u32>, y_coords: Vec<u32>, } impl IntoIterator for Grid { type Item = (u32, u32); type IntoIter = GridIter; fn into_iter(self) -> GridIter { GridIter { grid: self, i: 0, j: 0 } } } struct GridIter { grid: Grid, i: usize, j: usize, } impl Iterator for GridIter { type Item = (u32, u32); fn next(&mut self) -> Option<(u32, u32)> { if self.i >= self.grid.x_coords.len() { self.i = 0; self.j += 1; if self.j >= self.grid.y_coords.len() { return None; } } let res = Some((self.grid.x_coords[self.i], self.grid.y_coords[self.j])); self.i += 1; res } } fn main() { let grid = Grid { x_coords: vec![3, 5, 7, 9], y_coords: vec![10, 20, 30, 40] }; for (x, y) in grid { println!("point = {x}, {y}"); } }3.1 为什么some_vec.next()不存在
IntoIterator由Vec<T>、&Vec<T>、&[T]、区间(range)等集合类型实现。这正是for i in some_vec { .. }能直接工作的原因,同时也是some_vec.next()不存在的原因——Vec本身不是Iterator,它只是"可以被转换成迭代器"的IntoIterator。
3.2 所有权陷阱:into_iter会消费self
试着在main中对同一个grid迭代两次,会编译失败。原因在于IntoIterator::into_iter按值接收self,即取得所有权。标准库类型同样如此:for e in some_vector会消费some_vector并迭代其拥有的元素;如果只想借用,应写for e in &some_vector,迭代元素的引用。
对应的修复方式是再为&Grid实现IntoIterator,并创建一个按引用迭代的GridRefIter(&Grid的Item相应变为&(u32, u32)),从而支持多次迭代。
四、70+ 适配器方法与collect:把遍历变成函数式管道
Iteratortrait 的价值不止于next:它还提供了70 多个辅助方法(详见 src/iterators/helpers.md),可以组合出定制化的遍历行为。
4.1 方法链示例:filter → map → sum
# // Copyright 2024 Google LLC # // SPDX-License-Identifier: Apache-2.0 # fn main() { let result: i32 = (1..=10) // Create a range from 1 to 10 .filter(|x| x % 2 == 0) // Keep only even numbers .map(|x| x * x) // Square each number .sum(); // Sum up all the squared numbers println!("The sum of squares of even numbers from 1 to 10 is: {}", result); }这些辅助方法可分为两类:
- 适配器方法(iterator adapter methods):如
map、filter,接收原迭代器、产出一个行为不同的新迭代器(保持惰性,不立即求值); - 消费方法(consuming methods):如
sum、count,会把迭代器里的元素全部拉出来再计算。
由于方法设计为可链式调用(chaining),你可以像搭管道一样拼出恰好满足需求的定制迭代器。更重要的是性能:Rust 的迭代器组合经过 LLVM 优化后,即使串联大量适配器,也能生成与等价命令式实现同等高效的机器码——零成本抽象。
4.2collect:把迭代器变回集合
适配器链的终点通常是collect(详见 src/iterators/collect.md),它把一个Iterator构建成一个具体集合:
# // Copyright 2024 Google LLC # // SPDX-License-Identifier: Apache-2.0 # fn main() { let primes = vec![2, 3, 5, 7]; let prime_squares = primes.into_iter().map(|p| p * p).collect::<Vec<_>>(); println!("prime_squares: {prime_squares:?}"); }任意迭代器都可以收集为Vec、VecDeque或HashSet;产出键值对(二元组)的迭代器还能收集为HashMap和BTreeMap。指定返回集合类型有两种写法:
- turbofish 形式:
some_iterator.collect::<COLLECTION_TYPE>(),上例中的_让编译器推断Vec的元素类型; - 类型推断形式:
let prime_squares: Vec<_> = some_iterator.collect();。
之所以collect常常需要类型标注,是因为它对返回类型B是泛型的,编译器难以在多数场景自行推断。
4.3 背后的机制:FromIterator
如果学生好奇collect是如何工作的,答案是FromIteratortrait——它定义了每种集合如何从迭代器构建。除Vec、HashMap等基础实现外,还有一些特殊实现,例如能把Iterator<Item = Result<V, E>>直接转换成Result<Vec<V>, E>(遇错即停并返回首个错误)。
五、实战验证:练习offset_differences与仓库内测试
为了把上述概念落到可运行、可验证的代码上,本课程配套了练习 src/iterators/exercise.md,要求只用一个迭代器表达式完成任务并通过全部单元测试;完整答案位于 src/iterators/solution.md,其源码在 src/iterators/exercise.rs 中可见。
题目定义如下:计算values中相隔offset的元素之差,且从末尾回绕到开头,即结果第n项为values[(n+offset)%len] - values[n]。经典解法把"回绕"翻译为"无限循环 + 跳过前 offset 个":
// 摘自 src/iterators/exercise.rs(ANCHOR: solution) fn offset_differences(offset: usize, values: Vec<i32>) -> Vec<i32> { let a = values.iter(); let b = values.iter().cycle().skip(offset); a.zip(b).map(|(a, b)| *b - *a).collect() }这条一行管道同时用到了本文学过的多个知识点:
.iter():通过IntoIterator拿到按引用迭代的迭代器;.cycle():无限迭代器(2.2 节所说的"不必有限"在这里就是生产力)——把values的引用无限循环重复;.skip(offset):适配器方法,跳过前offset个元素,等效于按offset偏移的起始位置;.zip(b):把两个迭代器按位置配对,实现(n+offset)%len与n的同步对齐;.map(|(a, b)| *b - *a):计算差值;.collect():收集回Vec<i32>。
练习自带的单元测试(同样位于 src/iterators/exercise.rs 的unit-tests锚点)覆盖了多种场景,可作为正确性基准:
| 测试函数 | 覆盖场景 | 代表性断言 |
|---|---|---|
test_offset_one | 偏移 1 的基本情况 | offset_differences(1, vec![1, 3, 5, 7]) == vec |
test_larger_offsets | 偏移 2/3/4/5,含偏移超过长度 | 偏移 4 时结果为全零(自我相减) |
test_degenerate_cases | 单元素与空数组等退化情况 | 单元素结果为vec![0],空数组结果为vec![] |
5.1 本地运行方式
src/iterators/Cargo.toml将练习文件配置为一个名为iterators、库名为offset_differences的 crate(edition = "2024",publish = false)。在该目录执行cargo test即可运行上述单元测试,验证解法的正确性;直接在 playground 中复制代码亦可得到同样结果。
六、总结:从"分散的四要素"到"统一的迭代模型"
回看本文开头提出的四个必要条件——状态、终止条件、状态更新、取值逻辑:
- C 风格 for 循环把四者分散在循环头与循环体中,直观但难以复用;
- Rust 迭代器通过
Iteratortrait 的next方法把四者打包进一个对象,并通过IntoIterator让for循环自动创建迭代器; - 适配器方法链 +
collect把"如何遍历"抽象成可组合、惰性、零开销的管道,offset_differences一行解法正是这套模型的缩影。
理解了"动机",也就理解了Iterator一切后续便利的根基:惰性、无限、可组合、可优化,都源于"遍历只是状态与逻辑的打包"。本课程的后续章节 Iterator Helper Methods、collect、IntoIterator 及 练习 均围绕这一模型展开,可以在完整课程 SUMMARY.md 中按顺序继续深入学习。
【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考