阶段二 · 类型转换与函数式

闭包与函数式

一句话总结

闭包是"能记住周围环境的匿名函数".
Rust 用 Fn,FnMut,FnOnce 三个特征,精确描述一个闭包对捕获变量做了什么--只读,要改,还是要吃掉.看懂这三者,就看懂了闭包的所有规则.

前置回顾

上一篇迭代器中,map,filter,fold 的参数都是闭包.本篇深入闭包本身:它如何捕获环境,编译器如何判断它属于哪一类,以及如何在函数签名中传递和返回它.

闭包是什么:带环境的函数

普通函数只能用自己的参数,闭包则能捕获定义它时所在作用域里的变量.

let factor = 3;

// 这个闭包"记住"了外部的 factor
let multiply = |x: i32| x * factor;

println!("{}", multiply(10)); // 30,闭包用到了它捕获的 factor

factor 既不是 multiply 的参数,也不是全局变量,却能在闭包体内使用--这就是"捕获环境".普通的 fn 函数做不到这一点,这是闭包与函数最本质的区别.

普通函数像一台标准化机器,只认投进去的原料(参数).
闭包则像一位带着自己工具箱上门的师傅,除了给的活儿,还能随手用上兜里早就装好的工具(捕获的变量).

三个特征:Fn / FnMut / FnOnce

闭包对捕获变量的使用方式有三个层级,Rust 用三个特征区分.编译器会根据闭包体内的行为自动推断它属于哪一类,通常不必手动标注.

特征 如何使用捕获变量 能否多次调用 类比
Fn 只读借用 &T 可任意多次 查阅工具箱,看完放回
FnMut 可变借用 &mut T 可多次(需 mut) 使用并改动工具箱
FnOnce 取走所有权 T 只能一次 把工具箱整个送出去
// Fn:只读捕获
let text = String::from("hi");
let printer = || println!("{text}"); // 只是读 text
printer();
printer(); // 可以反复调用

// FnMut:可变捕获
let mut count = 0;
let mut inc = || count += 1; // 修改 count,故闭包本身要 mut
inc();
inc(); // count 现在是 2

// FnOnce:取走所有权
let owned = String::from("bye");
let consume = move || drop(owned); // 把 owned 移进来并销毁
consume();
// consume(); // 编译错误:owned 已被消耗,只能调一次

三者是包含关系,不是平行关系

这三个特征层层嵌套:每个 Fn 闭包也是 FnMut,每个 FnMut 也是 FnOnce.

直觉上:能"只读使用"的,自然也满足"可变使用"和"消耗一次"的要求.因此函数参数若写 F: FnOnce(),约束最宽松,能接受全部三类闭包;写 F: Fn() 则最严格.定参数时按需选最宽松的那个.

捕获方式由"用法"决定

闭包默认以"够用就好"的最小权限捕获,能借用就借用,能只读就不可变.只有当闭包体内的操作要求更高权限时,捕获方式才升级.

闭包体内对变量做了什么?

├─ 只是读取 ──► 不可变借用 (&T) ──► Fn

├─ 修改它 ──► 可变借用 (&mut T) ──► FnMut

└─ 移动/消耗它 ──► 取得所有权 (T) ──► FnOnce

move 关键字:强制夺取所有权

有时即使闭包体内只是读取,也想让它取得变量所有权--最典型的场景是把闭包交给新线程或返回出函数.这时用 move 强制改变捕获方式.

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

// 不加 move:闭包借用 data.但若线程比 data 活得久就会编译失败
let handle = std::thread::spawn(move || {
println!("{data:?}"); // move 把 data 的所有权搬进线程
});
handle.join().unwrap();
// 此处 data 已被移走,不能再用

move 改变的是捕获方式,不是特征类别

这是高频误解.move 强制闭包按所有权捕获变量,但闭包属于 Fn / FnMut / FnOnce 中的哪一类,仍由闭包体如何使用这些变量决定.

例如 move || println!("{data:?}")data 移了进来,但体内只是只读,所以它依然是 Fn,可以反复调用."移进来"和"消耗掉"是两回事.

闭包作为函数参数

把闭包传进函数有两种写法,各有取舍.

// 写法一:泛型 + 特征约束.编译期单态化,零开销,但每种闭包生成一份代码
fn apply<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
f(x)
}

// 写法二:impl Trait 语法糖,等价于写法一,更简洁
fn apply2(f: impl Fn(i32) -> i32, x: i32) -> i32 {
f(x)
}

// 写法三:特征对象 dyn.运行时动态分发,有指针跳转开销,但代码体积小
fn apply3(f: &dyn Fn(i32) -> i32, x: i32) -> i32 {
f(x)
}

泛型 vs dyn 怎么选

泛型/impl Trait(静态分发):编译期为每种闭包生成专门代码,无运行时开销,是默认首选.
dyn Fn(动态分发):适合需要把不同闭包放进同一个容器(如 Vec<Box<dyn Fn()>>),或想避免泛型导致的代码膨胀时.

规则:能静态就静态,需要异质集合时才上 dyn.

返回闭包

从函数返回闭包是个经典难点,因为每个闭包都有独一无二,无法写出的匿名类型.解决办法是用 impl TraitBox<dyn>.

// 用 impl Trait 返回:简洁,适合返回单一确定的闭包
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

闭包捕获的是函数局部变量(这里是 n).函数返回后局部变量就销毁了,如果闭包还借用着它,就会留下悬垂引用--Rust 当然不允许.所以必须用 moven 的所有权搬进闭包,让它随闭包一起存活.

若多个分支返回不同闭包,impl Trait 行不通(类型不一致),需改用 Box<dyn Fn(...)>.

闭包与函数指针的关系

不捕获任何环境的闭包,可以自动退化成普通函数指针 fn.函数指针同时实现了三个 Fn 特征,所以凡能接受函数指针处,也能传闭包.

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

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

// 既能传函数指针,也能传闭包
let a: Vec<i32> = nums.iter().map(|&x| double(x)).collect();
let b: Vec<i32> = nums.iter().map(|&x| x * 2).collect();

快速回顾

  • 闭包 = 匿名函数 + 捕获环境,这是它区别于 fn 的本质
  • 三特征:Fn 只读,FnMut 可变,FnOnce 消耗,呈包含关系
  • 捕获方式由用法自动推断,按最小权限原则
  • move 强制按所有权捕获,但不直接决定特征类别
  • 参数优先静态分发(泛型/impl Trait),异质集合才用 dyn
  • 返回闭包用 impl Fn + move,多分支不同闭包用 Box<dyn>

动手练习

  1. 三种捕获方式:写三个闭包,分别只读,修改,消耗一个捕获的 String,尝试各调用两次,观察哪个会编译失败及报错原因.
  2. 返回闭包:实现 make_multiplier(n: i32) -> impl Fn(i32) -> i32,去掉 move 看编译器如何报错,理解为什么必须 move.
  3. 动态分发:把若干个 Box<dyn Fn(i32) -> i32> 放进一个 Vec,依次对同一输入调用,体会动态分发的用途.