第十一章 迭代器与闭包:Rust 把”优雅”和”高效”焊在了一起

你有没有过这种感觉:写一段遍历、变换、再收集的逻辑,用 for 循环写得手腕发酸,换成函数式那一套又总担心”这么写会不会慢”?在 Rust 里,这两件事能不能既要又要?

很多从别的语言过来的兄弟,第一次看到 Rust 里一长串 iter().map().filter().collect(),下意识的反应是:“这不就是 Python 的列表推导、或者 Java 的 Stream 嘛,层层包装肯定有开销吧?” 我当年也这么想。后来被一段汇编打了脸——编译器把那串”花里胡哨”的链式调用,优化得跟手写的 for 循环一模一样,连影子开销都没有。

我是墨叔,泡机房二十年,见过太多”为了好看牺牲性能”的妥协。Rust 最让我服气的一点,就是它压根不让你做这种妥协:你写出来的优雅代码,在运行的时候,就是高效的代码。这一章我们拆开看,闭包怎么抓环境、迭代器为什么”懒”、以及”零成本抽象”到底是不是一句营销话术。

一、闭包:带着环境走的匿名函数

先说闭包(closure)。你大概用过别的语言里的 lambda:一个能当值传来传去的小函数。Rust 的闭包特殊在哪?它能捕获它诞生时所在作用域里的变量。普通函数做不到这点——函数定义在外面,它不认你身边的局部变量。闭包却把”它出生的那个环境”一起带走了。

打个比方。餐厅后厨里,一个厨师(闭包)不只是会”切菜”这个动作,他还记得自己负责的是 3 号灶台、手边那把刀是王师傅的。他离开后厨(被传到别处去执行),这些信息还跟着他。普通函数就像一个被临时雇来、不认识任何人、只按你口头指令干活的散工——他不带任何”上下文”。

let base = 10;
// 闭包捕获了 base,带进了自己的"环境"
let add_base = |x: i32| x + base;
println!("{}", add_base(5)); // 输出 15

注意 base 不是传进来的参数,是闭包从外面抓进来的。这种”抓环境”的能力,是闭包和函数最本质的不同。

二、Fn / FnMut / FnOnce:闭包怎么”吃”环境

你肯定要问:闭包抓变量,是抓来读、还是抓来改、还是干脆把变量”吃掉”?Rust 用三个 trait 把这事分清楚了,而且绝大部分时候你根本不用写,编译器自己推断。但墨叔建议你必须知道,因为报错的时候它就用这三个词跟你说话。

  • Fn:闭包只读环境里的变量,不改也不吞。可以反复调用无数次。像后厨厨师只读一下”今天特价菜”的黑板,看多少次都不影响黑板。
  • FnMut:闭包会修改环境里的变量。每次调用可能改变捕获的状态。像厨师每做完一道菜,就把自己”已完成单数”的计数器加一。
  • FnOnce:闭包会把环境里的变量消耗掉(move 走),所以它只能被调用一次。像厨师把唯一的那瓶招牌酱料直接倒进锅里——这瓶酱料用一次就没了,你没法再调用他做第二份。
// FnMut:闭包修改了捕获的 count
let mut count = 0;
let mut bump = || {
    count += 1; // 改了外部变量
    count
};
bump();
bump();
println!("{}", count); // 输出 2
 
// FnOnce:闭包把 s 吞了,只能调一次
let s = String::from("hello");
let consume = move || s; // 捕获并消费
let taken = consume();
println!("{}", taken);

为什么要有这三个区分?因为 Rust 必须知道:这个闭包在调用时,会对它抓来的变量动什么手脚,才能判断”把闭包传来传去”安全不安全。一个只读环境的闭包,你可以放心地多线程共享;一个会改环境的闭包,就得小心别让多个地方同时改。这套区分不是为难你,是把”闭包与环境的交互”在编译期钉死。

如果你写过 C++ 的 lambda,这里的心智要重装:C++ 让你手动写 [=]、[&]、[std::move] 去声明捕获方式,写错了是运行时的事;Rust 反过来,你不写它自己推理,推理出来发现不安全就直接不让你编过。一个是”你声明、编译器信你”,一个是”编译器查你、不达标不放行”。

三、迭代器:一个”懒”到极致的承诺

现在说迭代器(iterator)。Rust 的迭代器核心就一句话:它是惰性的(lazy)。你不让我”消费”它,我就啥也不干,一个元素都不算。

什么叫惰性?你写 v.iter().map(|x| x * 2),这一长串在你说”我要结果”之前,一个乘法都没发生。它只是在内存里搭了一根”管道”,描述”将来要怎么处理数据”。只有当你调用一个消费型适配器(比如 collect、sum、for 循环遍历),管道才被通上电,数据才哗啦啦流过每一个环节。

还是餐厅的例子。你给后厨下单:把每盘菜切小(.map)、只留辣的(.filter)、装进这个保温箱(.collect)。厨师不会在你念菜单的功夫就真去切三百盘菜——他等你说了”好,现在开始做”才动手。这个”等你发令”就是惰性。好处是什么?如果最后你改主意说”其实只要数一下有多少盘辣的”(count),那前面”切小""装箱”的步骤根本不用执行,省掉一堆无用功。

let v = vec![1, 2, 3, 4];
// 到这里,啥都没算
let iter = v.iter().map(|x| x * 2).filter(|x| x % 3 == 0);
// collect 才是"消费",管道才通电
let result: Vec<i32> = iter.collect();
println!("{:?}", result); // 输出 [6],只有 2*3=6 满足

关键点:map 和 filter 这类叫”迭代器适配器”,它们返回新的迭代器,本身零计算;collect、sum、for 这类叫”消费适配器”,才真正驱动计算。 你写代码时把这个分清楚,就不会纳闷”我明明写了变换怎么没生效”。

四、链式:把管道一节节接起来

迭代器真正迷人的是链式。你把一个适配器接一个,数据像水流一样依次经过每一关。这种写法的好处不只是好看,更是把”数据怎么变”这件事,从上到下顺着读一遍就全懂了,不用在脑子里维护一堆中间变量。

let nums = vec![1, 2, 3, 4, 5, 6];
let sum_of_doubled_evens: i32 = nums
    .iter()
    .filter(|&&x| x % 2 == 0)   // 只要偶数:2,4,6
    .map(|&x| x * 2)            // 翻倍:4,8,12
    .sum();                     // 求和:24
println!("{}", sum_of_doubled_evens);

你品这个顺序:filter 先把偶数筛出来,map 再翻倍,sum 最后收口。每一节都只关心”我这一小步要干嘛”,组合起来却是一整条清晰的处理流水线。这比手写一个 for 循环里面套 if、再维护一个累加器,在可读性上高出一个身位——而且,下面就要说,性能上一点没亏。

五、零成本抽象:编译器把”优雅”翻译成”高效”

现在到了最硬核也最反直觉的地方。你写的那串惰性迭代器,运行的时候真的和手写 for 循环一样快吗?答案是:一样快,一字不差。

所谓”零成本抽象”(zero-cost abstraction),Rust(和 C++ 模板一样)奉行一句信条:

你用抽象语法写代码,得到的运行时表现,不比你手写底层循环差。抽象不向你收”智商税”。

原理是这样的。iter() 返回的迭代器是个薄得不能再薄的结构,map/filter 只是把它包了一层。编译器做单态化(把泛型实例化成具体类型),再做内联(把闭包体直接嵌进循环体),最后优化器一看:哦,这不就是一个从前往后扫一遍数组、边扫边算的循环嘛。于是它把那层层的包装全拆了,生成和你手写 for 循环几乎相同的机器码。

也就是说——你享受了函数式代码的清晰,却没付任何性能代价。 这在很多语言里是做不到的:那些语言的”高级写法”背后往往真有一堆临时对象、虚调用、装箱。Rust 能免单,是因为它的抽象大多在编译期就被”抹平”了。

// 这种写法
let doubled: Vec<i32> = v.iter().map(|x| x * 2).collect();
// 编译后,等价于你写的这个手写循环(概念上):
let mut doubled = Vec::with_capacity(v.len());
for &x in &v {
    doubled.push(x * 2);
}

当然墨叔得提醒一句实话:绝大部分时候迭代器确实零成本,但如果你在链里塞了闭包逃逸、dyn 动态分发、或者 collect 到会反复扩容的大容器,优化器偶尔会有它的难处。但作为工程直觉,请牢牢记住:“写迭代器风格”在 Rust 里既是优雅,也是高效,不是二选一。 别再为了”怕慢”而退回去写又臭又长的 for 了。

六、iter / iter_mut / into_iter:遍历的三种姿态

讲迭代器不能漏掉一个初学者必混的点:同一个集合,iter()、iter_mut()、into_iter() 三种遍历,拿到的东西天差地别。这恰恰又回到所有权的老命题上。

  • iter():给你不可变借用 &T。你只能看,不能改,也不会消耗集合本身。像隔着玻璃罩看展品。
  • iter_mut():给你可变借用 &mut T。你能改里面的元素,但集合还是原主人的,遍历完还在。
  • into_iter():把集合所有权吃掉,迭代出来的就是 T 本身。遍历完原集合没了,因为元素一个个被 move 走了。
let mut v = vec![1, 2, 3];
 
for x in v.iter() {          // x: &i32,只读
    println!("{}", x);
}
 
for x in v.iter_mut() {      // x: &mut i32,可改
    *x += 10;
}
 
for x in v.into_iter() {     // x: i32,拿走了所有权
    println!("{}", x);
}
// 这里 v 已经没了,不能再使用

你品这个设计的连贯性:连”怎么遍历”这种小事,Rust 都没脱离所有权这套语言的根。读、改、消费,三种意图三种方法,编译器逼你想清楚”我到底只是看看,还是要改,还是连容器都不要了”。别小看这点约束,它让”我遍历的时候不小心把集合弄丢了”这类 bug 在编译期就消失。墨叔的经验:写遍历时先问自己一句”我要的是借用还是所有权”,选错了编译器立刻教你做人,比运行时才发现问题省太多事。

七、闭包 + 迭代器:为什么要放一章里讲

你大概发现了,这一章把闭包和迭代器绑在一起讲。因为它们是天作之合:迭代器的每个适配器(map、filter、for_each)吃的就是闭包。闭包负责”每个元素怎么变”,迭代器负责”元素从哪来、流到哪去”。把这两个东西一起用熟了,你才真正踏入 Rust 写”地道代码”的门。

所以回头看开篇那个问题:“优雅和高效能不能既要又要?” 在 Rust 的迭代器与闭包这里,答案是肯定的——而且这不是语言施恩,是它从设计第一天就把”零成本抽象”焊死在骨子里的结果。

【思考题】

  1. 闭包的 Fn / FnMut / FnOnce 三兄弟,本质上在回答”闭包对捕获的变量动了什么手脚”。现在请你推一下:如果一个闭包把捕获的 String 通过 move 吞掉了,它会被推断成哪一个 trait?为什么这种闭包”只能调用一次”,而 Fn 闭包”可以调用一万次”?从”环境里的资源还在不在”这个角度讲通。

  2. 迭代器是惰性的。假设我写了 v.iter().map(|x| x * 2).filter(|x| *x > 5),但后面什么消费适配器都没接,直接函数结束。请问这段变换对 v 里的数据造成了什么影响?一个元素都没算,还是算了一半?结合”惰性”的概念解释为什么。

  3. 墨叔说”零成本抽象”意味着迭代器写法不比手写 for 循环慢。请你想清楚一件事:既然结果一样快,那迭代器相比 for 循环,到底”赚”在了哪里、“可能亏”在了哪里?(提示:赚在可读性和组合性,亏在……你能想到什么极端情况让优化器为难吗?不要求确定答案,推你的思路即可。)

【参考答案】

  1. 把捕获的 String 通过 move 吞掉的闭包,会被推断成 FnOnce。道理在”资源的所有权去哪了”:闭包体里一旦把那个 String 消费掉(比如返回它、或者把它塞进别的结构),String 的所有权就从环境里被”搬走”了——环境里那个变量从此是空的、无效的。所以这种闭包第一次调用就把 String 拿走了;你再调第二次,它已经没有 String 可拿了,逻辑上根本不成立,因此编译器只允许它调用一次。反观 Fn 闭包,它只是”看一眼”环境里的变量(只读借用),变量从头到尾都在、完好无损,所以你爱调多少次调多少次,环境不会被耗尽。一句话总结:FnOnce 吃掉了环境里的资源,资源一次性没了,自然只能吃一次;Fn 只借用、不消耗,所以可重复。

  2. 答案是:一个元素都没算,v 里的数据原封不动,连一次乘法都没执行。 因为 map 和 filter 都是”迭代器适配器”,它们返回的还是迭代器,本身不触发任何计算——它们只是在内存里搭好”将来要怎么做”的管道描述。真正让水流动起来的是消费适配器(collect、sum、for 遍历)。你这段只有适配器、没有消费端,就像你把后厨的菜单写好了却没喊”开始做”,厨师一动不动。这恰恰是惰性的价值:没被消费的工作,就根本不发生,不浪费一丁点 CPU。如果哪天你只想要”数量”不要”结果”,直接 .count() 消费,那 map 里的翻倍计算压根不会跑——省下的就是实打实的性能。

  3. 迭代器”赚”在两点:第一,可读性——一条链从上到下把”数据怎么流转”讲清楚,不用在脑子里维护一堆中间变量和索引;第二,组合性——你可以像搭积木一样把小节逻辑拼起来,每个适配器都是纯函数式的小单元,易测易换。它”可能亏”的地方,老实说在日常代码里基本遇不到,但在极端情况下优化器会有难处:比如闭包捕获了大结构又发生了跨函数逃逸、链里混入了 Box<dyn Fn> 这类动态分发(虚调用没法内联)、或者 collect 到会反复扩容的容器导致多次重新分配。不过墨叔要强调:这些是”理论上的边角”,不是你日常写业务代码的常态。工程上的结论是——默认放心用迭代器,你拿到的几乎永远是”又清晰又快”的双赢,别因为”怕慢”而退回面条式的 for 循环。真正性能敏感的热路径,你再去 profiling,而不是靠猜。

小结

闭包是带着环境走的匿名函数,靠 Fn/FnMut/FnOnce 三特质说清它对环境”只读、改、还是吞”;迭代器是懒到极致的管道,不消费就不算;二者一结合,配上 Rust 把抽象在编译期抹平的零成本机制,让你写出来的优雅代码,跑起来就是高效的循环。