阶段四 · 集合与函数式

闭包

一句话总结

闭包(closure)是能捕获周围变量的匿名函数.它根据"如何使用捕获的变量"自动归入三个 trait:Fn(只读借用),FnMut(可变借用),FnOnce(取得所有权).
move 关键字强制闭包夺取捕获变量的所有权,是把数据交给线程/任务的关键.

闭包基础

第 09 篇里 |&n| n * 2 就是闭包.它像匿名函数,但更简洁,且能捕获定义处周围的变量:

let factor = 10;
let multiply = |x: i32| x * factor; // 捕获了外部的 factor
println!("{}", multiply(5)); // 50

// 类型多可省略,由编译器推断
let add = |a, b| a + b;
println!("{}", add(2, 3)); // 5

Rust 闭包约等于 Go 的 func(x int) int { return x * factor },但写法精简到 |x| x * factor,参数和返回类型多数可推断,无需显式标注.
"捕获周围变量"这点两者一致.

捕获:闭包如何"记住"环境

闭包能访问定义它的作用域里的变量,这叫捕获.关键问题是:它以什么方式捕获?--是借用,还是夺走所有权?Rust 的规则是"按需捕获":编译器根据闭包体内如何使用变量,自动选择最宽松的方式.

let s = String::from("hello");

// 只读使用 → 不可变借用捕获
let print_it = || println!("{s}");
print_it();
println!("{s}"); // s 仍可用,因为只是被借用

let mut count = 0;
// 修改使用 → 可变借用捕获
let mut increment = || count += 1;
increment();
increment();
println!("{count}"); // 2

三个 trait:Fn,FnMut,FnOnce

正因捕获方式不同,闭包被自动归类为三种 trait 之一.这决定了它能被调用几次,能传给哪些接口:

Trait 捕获方式 能调用几次 直觉
Fn 不可变借用 &T 多次 只读取捕获的变量
FnMut 可变借用 &mut T 多次 会修改捕获的变量
FnOnce 取得所有权 T 仅一次 消耗捕获的变量

三者是包含关系,不是互斥

它们层层包含:能当 Fn 用的闭包,也满足 FnMutFnOnce;能当 FnMut 用的也满足 FnOnce.

所以接收 FnOnce 的接口最宽容(什么闭包都收),接收 Fn 的最严格(只收只读闭包).设计接收闭包的函数时,约束选最宽松的那个即可.通常不必手动标注这些 trait--编译器会自动推断闭包属于哪类.

move:强制夺取所有权

默认按需捕获(能借用就借用).但有时需要闭包夺取捕获变量的所有权--最典型的是把闭包交给一个比当前作用域活得更久的地方(新线程,异步任务).这时用 move:

let data = vec![1, 2, 3];

// move:强制把 data 的所有权移入闭包
let owns_data = move || println!("{:?}", data);
owns_data();
// 这里不能再用 data -- 它已被移入闭包

承上启下:move 是多线程的主角

为什么需要 move?设想把闭包传给一个新线程--线程可能比创建它的函数活得久,如果闭包只是借用外部变量,等原变量被释放,闭包里的引用就悬空了.

move 让闭包带走数据的所有权,从而能独立存活.在「Rust 进阶 · 生命周期与并发」分类里,thread::spawn(move || ...) 会是天天写的模式.

闭包作为函数参数

把闭包当参数,是迭代器适配器(map,filter)以及大量 API 的基础.用泛型 + trait 约束接收:

// 接收一个 Fn(i32) -> i32 的闭包
fn apply<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
f(x)
}

let double = |n| n * 2;
println!("{}", apply(double, 5)); // 10

// 也可用 impl Trait 写法(第 12 篇会讲)
fn apply2(f: impl Fn(i32) -> i32, x: i32) -> i32 {
f(x)
}

返回闭包

函数也能返回闭包.由于闭包的具体类型无法写出(每个闭包都是独一无二的匿名类型),返回时用 impl Fn(...):

// 返回一个"加 n"的闭包
fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
move |x| x + n // move 把 n 移入闭包,使其能脱离函数独立存活
}

let add5 = make_adder(5);
println!("{}", add5(10)); // 15

为什么返回闭包要 move

make_adder 返回后,局部变量 n 本会被释放.若闭包只借用 n,返回的闭包就会持有悬垂引用.

moven 的所有权搬进闭包,闭包带着自己的 n 离开函数--这与上面"交给线程"是同一个道理:闭包要活得比定义它的作用域久,就得拥有数据.

闭包 vs 普通函数

普通函数(函数指针 fn)不能捕获环境,闭包可以.当一个 API 接收闭包时,也可以传一个不捕获任何变量的普通函数--它能自动适配:

fn double(x: i32) -> i32 { x * 2 }

let v = vec![1, 2, 3];
// map 既能收闭包,也能收函数名
let a: Vec<i32> = v.iter().map(|&x| x * 2).collect(); // 闭包
let b: Vec<i32> = v.iter().map(|&x| double(x)).collect(); // 调用普通函数

快速回顾

  • 闭包:能捕获周围变量的匿名函数,语法 |参数| 表达式,类型多可推断.
  • 按需捕获:编译器据用法自动选择不可变借用/可变借用/取所有权.
  • 三个 trait:Fn(只读)包含 FnMut(改)包含 FnOnce(消耗);接口约束选最宽松者.
  • move:强制夺取捕获变量所有权,用于把闭包交给线程/任务或从函数返回.
  • 作为参数/返回值:参数用 F: Fn(...)impl Fn(...);返回用 impl Fn(...)(常配 move).
  • 与普通函数:函数不能捕获环境,但可传给接收闭包的接口.

动手练习

  1. Fn 与 FnMut:写一个捕获外部变量的只读闭包和一个修改外部变量的闭包,分别对应 FnFnMut.
  2. move 验证:用 move 把一个 Vec 移入闭包,验证移动后原变量不可用.
  3. 泛型接收闭包:写函数 apply<F: Fn(i32)->i32>(f: F, x: i32),传入闭包和普通函数各调用一次.
  4. 返回闭包:写一个 make_multiplier(n),返回 impl Fn(i32)->i32,解释为何需要 move.
  5. 迭代器中的闭包:对一个向量用 iter().filter(闭包).map(闭包).collect(),体会闭包作为迭代器适配器参数的用法.