从闭包捕获与 Fn/FnMut/FnOnce 讲到迭代器的惰性求值,说清链条为什么会被编译成一条循环
上一篇讲 dyn Trait 时,我们把"具体类型什么时候确定"这个问题摊开算了一遍账。现在换一个方向:从这一篇开始转向"用起来顺手"的那一侧。
Rust 里到处都是这样的代码:
rust
let total: u32 = items.iter().filter(|x| x.price > 100).map(|x| x.price).sum();
写惯了 Java Stream 或 Python 生成器的人,第一反应是"这写法很眼熟",Rust中这种写法叫闭包。
闭包是一个装着捕获变量的匿名结构体
闭包的语法你已经不陌生:|x| x + 1。它和普通函数的区别只有一条------闭包能捕获定义它的作用域里的变量。所以 unwrap_or_else(|| self.most_stocked()) 能工作,而一个普通函数做不到这件事,因为标准库根本不知道 Inventory 这个类型的存在。
捕获方式有三种,The Rust Book 的措辞值得照抄:闭包捕获值的方式正好对应函数接收参数的三种方式------不可变借用、可变借用、取得所有权,具体用哪一种,由闭包体对这些值做了什么决定。
rust
let list = vec![1, 2, 3];
let only_read = || println!("{:?}", list); // 不可变借用
let mut pushes = || list.push(4); // 可变借用 → 需要 let mut pushes
第二行里,闭包体要改 list,所以它捕获的是 list 的可变引用。这直接触发你在借用篇学过的那条规则:闭包定义之后、被调用之前,list 不能再被别的借用碰,因为可变借用是独占的。Book 特意提醒了一个新手常踩的坑------在闭包定义和调用之间插一句 println!("{:?}", list) 会编译不过,原因就是此时 list 正被可变借用。
换个角度看会更清楚。Rust Reference 说:闭包类型大致等价于一个装着捕获值的结构体 。它给的示意大致是这样------一个捕获了 rect.left_top 整体、以及 rect.right_bottom.x 这一个字段的闭包,会被翻译成:
rust
struct Closure<'a> {
left_top: &'a mut Point,
right_bottom_x: &'a mut i32, // 只捕获了一个字段,不是整个 Point
}
这不是类比,是编译器实际做的事:闭包是个普通的 struct,捕获的值就是它的字段,调用它等于调用这个 struct 上的一个方法。既然它是个 struct,它当然是一个 具体类型------而具体类型意味着后面可以单态化,这一点是整篇的伏笔。
Fn、FnMut、FnOnce:由"做了什么"决定,而不是"怎么捕获"
闭包类型不止一个字段的形状,它还自动实现若干 trait。规则是叠加的:
- 所有闭包都实现
FnOnce------任何闭包至少能被调用一次。 - 闭包体 不把捕获值移出去 ,就再实现
FnMut,表示可以被可变借用着反复调用。 - 既不移动也 不修改 捕获值,就再实现
Fn,表示可以被共享引用反复调用。
三个名字读起来别扭,换成参数类型就直白了:Fn 相当于闭包内部只拿着 &T,FnMut 拿着 &mut T,FnOnce 拿着 T。和之前篇中的 &self / &mut self / self 是同一套层次,只不过这里约束的是闭包本身。
为什么标准库要区分这三者?因为签名要如实表达"我会调用你几次"。Option::unwrap_or_else 的签名是 F: FnOnce() -> T,原因是它 最多只调一次 ------Some 的情况下根本不会调 f,None 时才调一次。用 FnOnce 当约束是这里最宽松、最灵活的选择。反过来,slice::sort_by_key 的约束是 FnMut,因为它对每个元素都调一次闭包,必须能反复调。这就是判断标准的全部:接收方要调用几次,就用哪个 bound。
这里有个反直觉的点值得单独说,因为很多人第一次都会猜错:move 不改变闭包实现哪个 trait 。Rust Reference 的原话是------trait 由闭包 对捕获值做了什么 决定,而不是它 怎么捕获 。所以一个 move 闭包完全可以是 Fn:
rust
let s = String::from("hi");
let f = move || println!("{s}"); // 按值捕获 → 仍是 Fn、FnMut、FnOnce 全实现
f();
f(); // 能调两次,因为闭包里只读了 s,没有把它移出去
move 管的是"捕获时把值搬进闭包",不是"闭包只能用一次"。它真正的用途是让闭包活得比定义它的作用域更久------返回值里的闭包、传给 thread::spawn 的闭包,都需要它。
迭代器是惰性的状态机
Iterator 这个 trait 你已经熟悉了。上一篇讲过它只要求实现一个方法:
rust
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
其余几百个方法全是默认实现,统统建立在 next 上。所以有了 next,你就白拿 map、filter、zip、sum------这是 trait 篇那条规则的直接后果。
接下来的行为是理解一切的关键:在 Rust 中,迭代器是惰性的,除非你调用会消耗它的方法把它用掉,否则它什么效果也没有。
rust
let v = vec![1, 2, 3];
v.iter().map(|x| x + 1); // 什么都不发生
这行代码编译时会给出 unused Map that must be used 警告------注意这个提示的机制,就是错误处理篇里 Result 上那个 #[must_use]。警告产生的原因是:map 这样的 适配器 (adapter)不消耗迭代器,而是返回一个新的迭代器;真正推进计算的只有 sum、collect、for 这类 消耗型适配器 (consuming adapter)。所以 map 里那个闭包在上面这段代码里从来没被调用过。
for 循环是这个机制最常见的外壳。它隐含地创建并消耗一个迭代器,反复调 next 直到 None。所以下面两种写法是同一件事:
rust
for x in &v { println!("{x}"); }
let mut it = v.iter();
while let Some(x) = it.next() { println!("{x}"); }
顺便解决三个经常被混淆的入口,它们的区别正好是所有权篇讲过的三种关系:iter() 产生 &T(不可变借用)、iter_mut() 产生 &mut T(可变借用)、into_iter() 产生 T(拿走所有权)。所以 for x in v 和 for x in &v 在所有权上的含义完全不同。
链条为什么会被编译成一条循环
现在把前面两块拼起来。items.iter().filter(...).map(...).sum() 在源码里是三个嵌套的抽象,但对编译器来说:闭包是具体的匿名类型,迭代器适配器也是具体的结构体(Filter<Map<Iter<'_, Item>, C1>, C2> 这种嵌套类型),全部是编译期已知的 具体类型。
于是泛型篇讲过的单态化在这里接管一切:每个具体类型生成一份专门代码,next 调用被内联,闭包的调用也被内联------调到再也不能调为止。链条塌缩之后,剩下的就是一个循环。
迭代器虽然是高层抽象,但编译出来的代码和你自己写的低层代码大致相同,是 Rust 的零成本抽象之一,使用这种抽象不带来额外的运行时开销。
和 Java、Python 对一下账
Java 的 Stream 同样惰性:中间操作不执行,遇到终止操作才跑。所以"惰性"不是 Rust 独有的。差别在每一级链条的成本构成上:Stream 的每一级是一个接口调用,运行时按实际类型分发;流里的元素是对象引用,遍历 int 要么装箱成 Integer(除非用 IntStream)。JIT 的热点优化能把相当一部分补回来,但那取决于运行时的类型画像,属于"可能被优化掉",而不是编译期给定的结果。
Python 更彻底。生成器确实做到了惰性与增量(PEP 255 引入的 yield 就是为这件事设计的),但 CPython 里每个生成器是一个持有自己帧对象的对象,每次 yield 都要从求值循环里出来、再进去;类型错误只能在运行时显现。链式写法并没有错,只是每一层都要经过解释器。
Rust 的闭包和迭代器,代价是有的------它照旧从运行时挪到了编译期:多一份单态化拷贝就多一份编译时间和二进制体积,这就是泛型篇末尾提到的那笔账(嵌入式固件里同一个方法出现 59 份拷贝)。
下一篇换到工程结构那一侧:当代码不再是单个 main.rs,模块(mod)和包(crate)怎么组织,pub 的边界又该怎么划。
参考来源
- The Rust Book 第 13.1 章「Closures」--- 三种捕获方式对应函数的三种参数形式、可变借用期间的借用冲突、Fn/FnMut/FnOnce 的叠加关系与 unwrap_or_else、sort_by_key 的 bound 选择:https://doc.rust-lang.org/book/ch13-01-closures.html
- The Rust Reference: Closure types --- 闭包类型约等于装着捕获值的结构体、四种捕获模式、精确捕获路径与 Edition 2021 的变化、move 不改变实现的 trait:https://doc.rust-lang.org/stable/reference/types/closure.html
- The Rust Book 第 13.2 章「Processing a Series of Items with Iterators」--- Iterator 只需实现 next、惰性求值、for 循环的隐含消耗、iter/iter_mut/into_iter 的区别、map 未消耗时的警告:https://doc.rust-lang.org/book/ch13-02-iterators.html
- The Rust Book 第 13.4 章「Comparing Performance: Loops vs. Iterators」--- 福尔摩斯全文搜索的 benchmark 数据、循环展开与边界检查消除、Stroustrup 的零开销原则引文:https://doc.rust-lang.org/book/ch13-04-performance.html
- std::ops::Fn 文档 --- Fn 的自动实现条件、FnMut/FnOnce 是它的父 trait、何时该用哪个作为约束:https://doc.rust-lang.org/std/ops/trait.Fn.html
- Rust By Example「Closures as input parameters」--- Fn/FnMut/FnOnce 按限制递减排列及各自的捕获含义:https://doc.rust-lang.org/rust-by-example/fn/closures/input_parameters.html