深入理解 Rust 闭包

闭包是一个很常见的语言特性,很多语言都有,也都很容易理解,Rust 闭包因为 所有权&生命周期 的存在,导致存在 FnOnceFnMutFn 三种 Trait,增加了一些理解上的复杂性,尤其是存在一些反直觉的情况,本文想探讨此问题。

Rust 闭包实际是一种 匿名结构体,一旦声明,编译器就会创建一个新的 结构体,这个 结构体 会包含所有捕获的 自由变量,这个 结构体 不可见因此无法被其它地方使用。

Rust 闭包因为受到 所有权借用 规则的限制,闭包在捕获 自由变量 时,会根据闭包体的操作为 闭包结构体 实现不同的 TraitFnOnce FnMut Fn,理解为什么会需要这三种 Trait 及其关联关系 是理解并掌握 Rust 闭包的关键一环。如果没有理解其本质,实际使用时会是比较迷惑的。三个 Trait 的区别如下:

  1. FnOnce:闭包调用时会消耗 self(获取所有权),只能调用一次。典型场景:闭包将捕获的变量移动(move)到其他地方。
  2. FnMut:闭包调用时需要 &mut self,可以修改捕获的变量,可以被调用多次。
  3. Fn:闭包调用时只需要 &self,不修改捕获的变量,可以被调用多次。

需要注意的是,这三个 Trait 描述的是闭包被调用时的行为,而不是闭包捕获变量的方式。闭包如何捕获变量(按值、按不可变引用、按可变引用)由编译器根据闭包体的使用情况自动推断,而实现哪个 Trait 则取决于闭包体对捕获变量的操作。

接下来,尝试从 Trait 实现及底层权限模型角度,分析其本质。

以下为三个 Trait 的定义及一段简单的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
pub trait FnOnce<Args>
where
Args: Tuple,
{
type Output;
// Required method
extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
}

pub trait FnMut<Args>: FnOnce<Args>
where
Args: Tuple,
{
// Required method
extern "rust-call" fn call_mut(&mut self, args: Args) -> Self::Output;
}

pub trait Fn<Args>: FnMut<Args>
where
Args: Tuple,
{
// Required method
extern "rust-call" fn call(&self, args: Args) -> Self::Output;
}

Ref:

再看一段示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
fn call_fnonce<F: FnOnce()>(f: F) {
f();
}

fn call_fnmut<F: FnMut()>(mut f: F) {
f();
}

fn call_fn<F: Fn()>(f: F) {
f();
}

fn main() {
let x = 10;
let sss = String::from("hello world");

let fn_closure = || println!("Fn: {}, {}", x, sss); // Fn 闭包

// 自动转换:Fn → FnMut → FnOnce
call_fn(fn_closure); // ✅ Fn 作为 Fn
call_fnmut(fn_closure); // ✅ Fn 作为 FnMut
call_fnonce(fn_closure); // ✅ Fn 作为 FnOnce

fn_closure(); // ✅ Fn 可以再次被调用

// FnMut 闭包
let mut y = 5;
let mut fnmut_closure = || {
y += 1;
println!("FnMut: {}", y);
};

call_fnmut(&mut fnmut_closure); // ✅ FnMut 作为 FnMut
call_fnonce(&mut fnmut_closure); // ✅ FnMut 作为 FnOnce

fnmut_closure(); // ✅ 因为使用了引用传递,闭包本身未被移动,仍可调用

// call_fn(&mut fnmut_closure); // ❌ FnMut 不能作为 Fn
}

从以上 Trait 定义上来看,Fn 依赖 FnMut,而 FnMut 又依赖 FnOnce,如果一个闭包被编译器归类为 Fn,那么编译器也会同时为它实现 FnMutFnOnce

这个依赖关系意味着 Rust 编译器 会为 fn_closure 闭包同时实现 FnFnMutFnOnce 三个 Trait,也就是 fn_closure 对应的结构体实现了 call call_mutcall_once 三个关联函数。

这里会令人感到困惑,因为 fn_closure 的实现代码以只读的方式捕获了变量 x 并没有以可变引用或转移所有权方式捕获变量,为什么要为其实现 FnMutFnOnce,只实现 Fn 不就够了吗?

这里困惑的来源两点:

  1. fn_closure 闭包实现代码 与 编译器为其生成的 Trait 是不对应的,额外实现了另外两个 Trait
  2. fn_closure 闭包可以传递给要求 FnMutFnOnce 参数的函数,为什么一个捕获只读引用的闭包 Fn 要允许被当做捕获可变引用的闭包 FnMut 使用?或者需要转移所有权的闭包 FnOnce 使用?只读 为什么可以传给 可变的?可变的 为什么可以传给 可移动?

要想理解这个问题,可以从编译器如何实现 FnFnMutFnOnce 这三个 Trait 的调用方法入手。

对于以下包含闭包的代码:

1
2
let x = 10;
let fn_closure = || println!("Fn: {}", x);

编译器会为 fn_closure 生出成类似如下的闭包结构体和 Trait 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 编译器生成的闭包结构
struct Closure {
x: i32, // 捕获 x 的副本(i32 实现了 Copy)
}

impl Fn<()> for Closure {
fn call(&self, _args: ()) {
println!("Fn: {}", self.x); // 只读访问
}
}

impl FnMut<()> for Closure {
fn call_mut(&mut self, args: ()) {
self.call(args) // 只是转发到 Fn::call
}
}

impl FnOnce<()> for Closure {
type Output = ();

fn call_once(self, args: ()) -> () {
self.call(args) // 只是转发到 Fn::call
}
}

Ref: https://doc.rust-lang.org/src/core/ops/function.rs.html#253

从以上实现中,可以看到 call_mutcall_once 方法只是简单地转发调用到 call 方法上,最终 call 方法只读访问了捕获的变量 x。从 Rust 的所有权和借用规则来看,这样的设计实现是满足相关约束的。

  • call(&self):通过不可变引用调用,完全安全
  • call_mut(&mut self):即使有可变引用,闭包也不会修改任何东西,所以安全
  • call_once(self):即使获取所有权,闭包也不会被”消耗”,可以安全调用

从调用方法的转发实现可以看到:call_mutcall_once 都转发到 call,转发方向是 FnMutFnFnOnceFn

这里与通常理解的方向是相反的,根据定义,Fn 包含的能力是最全的,这里很容易用 “类型转换” 的方式理解:Fn 可以 “类型转换” 成 FnMutFn
实际上,这种理解是错误的,这里并不存在 “类型转换”,并且这样 “类型转换” 方式理解的方向是相反的,这也是为什么这里容易产生困惑的原因。

本质:约束与包含

为了彻底消除这种反直觉的困惑,我们需要调整观察的视角:不要把 FnFnMutFnOnce 理解为闭包拥有的 “能力强度”,而应该理解为对调用者施加的 “约束强度”

  • Fn:约束最严格。调用者必须保证 “不仅能调用,而且调用后闭包必须保持原样,状态不能变,所有权不能丢”。
  • FnOnce:约束最宽松。调用者唯一需要保证的是 “可以调用它”,至于调用后闭包还在不在,调用者不关心。

这是一种包含关系,而不是等级关系:

  • 所有满足 Fn 约束(不可变借用调用)的闭包,天然满足 FnMut 约束(因为不可变借用是一种特殊的可变借用,即 “不修改的可变借用”)。
  • 所有满足 FnMut 约束(可变借用调用)的闭包,天然满足 FnOnce 约束(因为借用是所有权转移的一种特殊情况,即 “借用后归还”)。

因此:FnFnMutFnOnce

这就解释了为什么编译器会自动为 Fn 实现 FnMutFnOnce:这不是 “类型转换”,而是 “子集归属”

就像现实生活中的例子:

  • Fn (能跑马拉松):要求身体素质极高。
  • FnMut (能散步):要求身体素质一般。
  • FnOnce (能活着):要求最低。

一个能跑马拉松的人(Fn),虽然他拥有最高的能力,但他完全可以去散步(当做 FnMut 用),也完全可以只是活着(当做 FnOnce 用)。你不需要对他进行“改造”或“转换”,因为他本来就是一个能散步、能活着的人。


查看源文件  编辑源文件