进阶:绕开借用检查器的正当手段
迭代器与闭包
它在解决什么
这两样东西在 Rust 里是绑在一起的:迭代器的每个适配器都吃一个闭包, 而闭包最常见的用处就是喂给迭代器。
从 JS / Java Stream / Python 过来的人对 map / filter 不陌生,
所以这一篇同样只讲不一样的地方:惰性的可见后果、
三个 Fn trait 的区别、以及 move 什么时候是必需的。
🚨 惰性:不消费就什么都不会发生
这是最容易踩的一条,因为在别的语言里未必如此。
还没开始 ← 先印的是这句
处理 1
处理 2
处理 3
[2, 4, 6]
map 里那个 println! 在声明那一行一个都没跑 ——
它们全都推迟到了 collect() 那一刻。
⇒ 迭代器链只是在搭一个配方。真正跑起来要靠消费者:
| 消费者 | 得到 |
|---|---|
collect() |
一个集合(类型由左边的标注决定) |
sum() / count() / max() |
一个值 |
for x in it |
逐个 |
for_each(f) |
逐个,但写成链式 |
⚠️ 实际后果:v.iter().map(|x| println!("{x}")); 这一行什么都不做,
而编译器只会给一个「未使用」的警告。想执行就得用 for_each 或者 for。
iter / iter_mut / into_iter
三个名字对应三种所有权关系,和借用那一章完全对得上:
| 写法 | 每个元素是 | 容器 |
|---|---|---|
v.iter() |
&T |
还在 |
v.iter_mut() |
&mut T |
还在,能改 |
v.into_iter() |
T |
被吃掉了 |
用了 into_iter() 之后再用容器就是 E0382。
⇒ 记法:名字里带 into 就是要所有权。标准库里到处如此
(into_inner、into_bytes、into_boxed_slice…)。
📌 for x in &v / for x in &mut v / for x in v 分别是上面三个的语法糖。
三个 Fn trait
闭包实现哪个 trait 不是你声明的,是编译器按它对环境做了什么推出来的:
| trait | 闭包对捕获的变量做了什么 | 能调几次 |
|---|---|---|
Fn |
只读 | 多次 |
FnMut |
要改 | 多次 |
FnOnce |
消耗掉了(比如把它移出去) | 一次 |
它们是包含关系:Fn ⊂ FnMut ⊂ FnOnce。
一个 Fn 闭包可以喂给要求 FnMut 的地方,反过来不行 ——
对照项那个改了 count 的闭包只是 FnMut,塞不进要求 Fn 的参数里:
error[E0594]: cannot assign to `count`, as it is a captured variable in a `Fn` closure
| fn call_twice<F: Fn() -> i32>(f: F) -> i32 { f() + f() }
| - change this to accept `FnMut` instead of `Fn`
⭐ 注意最后那一行 —— 编译器直接指着函数签名告诉你该怎么改。
⇒ 写参数时的判据:能写 Fn 就写 Fn —— 限制最松的那个对调用方最友好。
需要调用方改状态才放宽到 FnMut,需要消耗掉才用 FnOnce。
move:把环境搬进去
默认情况下闭包借用环境。加 move 就变成拿走所有权,
之后原变量就不能用了(E0382)。
⇒ 什么时候必须写:闭包要活得比当前作用域久的时候 —— 丢给线程、存进结构体、当返回值。不写的话编译器会直接提醒你。
迭代器链和手写循环
两边算出同一个数。语义上它们是等价的:
迭代器链不会引入额外的中间集合,filter 不会先生成一个新 Vec 再给 map。
⚠️ 但「性能也一样」这句话本站不做断言 —— 那需要测, 而同一份代码在不同机器上能给出相反的结论。 (本板块一个耗时数字都不给,理由见闸门那边。)
能说的只有语义。至于可读性,那是风格问题:
- 链式在「一串变换」时更清楚;
- 手写循环在「中途要 break、要提前返回、要同时改好几个变量」时更清楚。
常用适配器速查
| 写法 | 干什么 |
|---|---|
map(f) / filter(p) |
变换 / 筛选 |
enumerate() |
带上下标 |
zip(other) |
两个迭代器配对 |
take(n) / skip(n) |
取前 n 个 / 跳过前 n 个 |
chain(other) |
首尾相接 |
flat_map(f) |
map 之后展平一层 |
fold(init, f) |
折叠成一个值 |
any(p) / all(p) |
存在 / 全部 —— 短路 |
find(p) / position(p) |
找第一个 —— 短路 |
⭐ 标准库里这七十多个方法全是默认方法,
只要实现一个 next 就全都有了 —— 见泛型与 trait 那篇。
自己写一个迭代器的成本因此低得出奇。
下一步
最后一篇:《并发与 Send / Sync》。 「无畏并发」这句口号的实际含义是数据竞争是编译错误 —— 而它用的不是什么新机制,就是所有权和借用那两套规则。
全部篇目见 Rust 教程首页。
本篇示例
下面每一条都是完整的、能单独编译的程序,由npm run test:rust 在每次构建前用真的 rustc 跑一遍。 「编译不过、报 E0382」这种话在这里是被验证过的断言。 报错原文和对照项的结果由同一道闸门自动回写,会随工具链更新,但不作为断言。
🚨 迭代器是**惰性**的:不 collect 就什么都不会发生
fn main() {
let v = vec![1, 2, 3];
let it = v.iter().map(|x| {
println!("处理 {x}");
x * 2
});
println!("还没开始");
let out: Vec<i32> = it.collect();
println!("{out:?}");
}编译通过 · 输出 "还没开始\n处理 1\n处理 2\n处理 3\n[2, 4, 6]\n"
对照:把 `collect()` 那一步整个去掉
fn main() {
let v = vec![1, 2, 3];
let _it = v.iter().map(|x| {
println!("处理 {x}");
x * 2
});
println!("还没开始");
}编译通过,输出:"还没开始\n"
⭐ 看输出顺序:「还没开始」印在三条「处理」**之前** —— 说明 `map` 那一步在声明时一个元素都没碰。对照项干脆不 collect,于是**一条「处理」都没打印**。⇒ 迭代器链只是在搭一个配方,真正跑起来要靠 `collect` / `sum` / `for` 这类**消费者**。
`iter` / `iter_mut` / `into_iter` 的分工
fn main() {
let mut v = vec![1, 2, 3];
let s: i32 = v.iter().sum();
for x in v.iter_mut() {
*x *= 10;
}
let owned: Vec<i32> = v.into_iter().filter(|x| *x > 10).collect();
println!("{s} {owned:?}");
}编译通过 · 输出 "6 [20, 30]\n"
对照:在 `into_iter()` 之后再用 `v`
fn main() {
let mut v = vec![1, 2, 3];
let s: i32 = v.iter().sum();
for x in v.iter_mut() {
*x *= 10;
}
let owned: Vec<i32> = v.into_iter().filter(|x| *x > 10).collect();
println!("{s} {owned:?} {}", v.len());
}编译不过:error[E0382]
三个名字对应三种所有权关系,和[借用那一章](/rust/ownership/borrow/)完全对得上:`iter()` 给 `&T`、`iter_mut()` 给 `&mut T`、`into_iter()` **把容器吃掉**给 `T`。对照项之后再用 `v` 就是 `E0382`。⇒ 记法:**名字里有 `into` 就是要所有权**,标准库里到处如此。
三个 `Fn` trait:由闭包**怎么用环境**决定
fn call_twice<F: Fn() -> i32>(f: F) -> i32 {
f() + f()
}
fn main() {
let base = 10;
println!("{}", call_twice(|| base));
}编译通过 · 输出 "20\n"
对照:让闭包去修改环境里的变量
fn call_twice<F: Fn() -> i32>(f: F) -> i32 {
f() + f()
}
fn main() {
let mut count = 0;
println!("{}", call_twice(|| {
count += 1;
count
}));
}编译不过:error[E0594]
闭包实现哪个 trait **不是你声明的,是编译器按它对环境做了什么推出来的**:只读就是 `Fn`、要改是 `FnMut`、要消耗掉是 `FnOnce`。对照项那个闭包改了 `count`,于是它只是 `FnMut`,塞不进要求 `Fn` 的参数里 —— 报的是 `E0594: cannot assign to `count`, as it is a captured variable in a `Fn` closure`,而且编译器直接指着函数签名说「change this to accept `FnMut` instead of `Fn`」。⇒ 参数上写哪个:**能写 `Fn` 就写 `Fn`(限制最松的调用方最好用)**,需要调用方改状态才放宽到 `FnMut`。
`move` 闭包:把环境搬进去
fn main() {
let s = String::from("hi");
let f = move || s.len();
println!("{}", f());
}编译通过 · 输出 "2\n"
对照:闭包之后再用一次 `s`
fn main() {
let s = String::from("hi");
let f = move || s.len();
println!("{} {}", f(), s.len());
}编译不过:error[E0382]
`move` 的意思是「把用到的环境变量**所有权**搬进闭包」,所以对照项之后再用 `s` 就是 `E0382`。⇒ 什么时候必须写:**闭包要活得比当前作用域久**的时候 —— 丢给线程、存进结构体、当返回值。不写的话编译器会提醒你。
迭代器链和手写循环是等价的
fn main() {
let v = vec![1, 2, 3, 4, 5];
let a: i32 = v.iter().filter(|x| *x % 2 == 0).map(|x| x * x).sum();
let mut b = 0;
for x in &v {
if x % 2 == 0 {
b += x * x;
}
}
println!("{a} {b}");
}编译通过 · 输出 "20 20\n"
对照:把 `sum()` 换成 `count()`
fn main() {
let v = vec![1, 2, 3, 4, 5];
let a = v.iter().filter(|x| *x % 2 == 0).map(|x| x * x).count();
let mut b = 0;
for x in &v {
if x % 2 == 0 {
b += x * x;
}
}
println!("{a} {b}");
}编译通过,输出:"2 20\n"
两边算出同一个数(20)。⚠️ 但**「性能也一样」这句话本站不做断言** —— 那需要测,而同一份代码在不同机器上能给出相反的结论(本板块一个耗时数字都不给,理由见 rust-examples.ts 顶部)。能说的是语义:迭代器链不引入额外的中间集合。