闭包与迭代器:函数式写法

从闭包捕获与 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 的边界又该怎么划。


参考来源

相关推荐
喵了几个咪4 小时前
Go 写业务,Rust 扛底盘:一套可落地的混合架构
微服务·架构·golang·rust·多租户·gowind·rushwind
对象存储与RustFS19 小时前
升级 RustFS 二进制不停机:一条一条换,留一条退路
后端·rust·开源
感谢地心引力1 天前
来试试我做的音乐播放器吧
ai·rust·音乐·navidrome·music·tarui
蚂蚁背大象1 天前
RocketMQ-Rust 1.0.0 发布:用 Rust 做消息队列,这次有哪些变化?
rust·开源·rocketmq
喵个咪1 天前
RushWind Admin — 用 Rust 写的企业级中后台,开源了
后端·rust·开源
喵个咪1 天前
RushWind Admin — 契约驱动:203 条路由零手写的工程化拆解
后端·rust·开源
喵个咪1 天前
Go 写业务,Rust 扛底盘:一套可落地的混合架构
后端·rust·go
孙启超1 天前
【AI开发之Rust】第 22 课:一键多平台与工程收尾 —— CI、发布检查与结课
开发语言·后端·rust
对象存储与RustFS2 天前
自建对象存储的第一个决定:单机就够,还是必须上分布式
后端·rust·开源