进阶:绕开借用检查器的正当手段

迭代器与闭包

它在解决什么

这两样东西在 Rust 里是绑在一起的:迭代器的每个适配器都吃一个闭包, 而闭包最常见的用处就是喂给迭代器。

从 JS / Java Stream / Python 过来的人对 map / filter 不陌生, 所以这一篇同样只讲不一样的地方:惰性的可见后果、 三个 Fn trait 的区别、以及 move 什么时候是必需的。

🚨 惰性:不消费就什么都不会发生

这是最容易踩的一条,因为在别的语言里未必如此。

看这条示例的输出顺序:

还没开始        ← 先印的是这句
处理 1
处理 2
处理 3
[2, 4, 6]

map 里那个 println! 在声明那一行一个都没跑 —— 它们全都推迟到了 collect() 那一刻。

对照项干脆不 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 顶部)。能说的是语义:迭代器链不引入额外的中间集合。