《Rust 编译器原理》完整目录
- 前言
- 第1章 编译管线全景:从源码到机器码的完整旅程
- 第2章 所有权系统:编译期内存管理的核心机制
- 第3章 借用检查器:编译器如何证明内存安全
- 第4章 生命周期:编译器如何推断引用的有效范围
- 第5章 内存布局:编译器如何排列数据
- 第6章 单态化:泛型的编译期展开
- 第7章 Trait 静态分发:零成本抽象的编译器实现
- 第8章 Trait Object 与虚表:运行时多态的内存布局
- 第9章 async/await:状态机的编译器变换
- 第10章 Pin、Waker 与 Future:异步运行时的三大支柱
- 第11章 闭包:匿名函数的编译器实现(当前)
- 第12章 unsafe:安全抽象的逃生舱
- 第13章 FFI:与 C 世界的桥梁
- 第14章 宏系统:编译期的元编程引擎
- 第15章 MIR 优化:编译器的中间表示与优化管线
- 第16章 LLVM 代码生成:从 MIR 到机器码
- 第17章 增量编译:让重编译只做必要的事
- 第18章 设计哲学与架构决策
第11章 闭包:匿名函数的编译器实现
"闭包不是魔法——它们是编译器帮你写的结构体。" —— 这是理解 Rust 闭包最核心的一句话。
:::tip 本章要点
- 每个闭包都会被编译器转化为一个唯一的匿名 struct,捕获的变量就是 struct 的字段
- 三种捕获模式:不可变引用(
&T)、可变引用(&mut T)、按值移动(T) - 编译器的捕获分析算法遵循最小权限原则——优先引用,必要时才 move
Fn、FnMut、FnOnce三个 trait 形成严格的层级关系,闭包实现哪个取决于它如何使用捕获的变量move关键字强制所有捕获变量按值捕获,但不改变闭包实现的 Fn trait 种类- 闭包的大小 = 所有捕获变量的大小之和(加 padding),不捕获任何变量的闭包是 ZST(零大小类型)
- 不捕获变量的闭包可以被隐式转换为函数指针
fn() - Rust 闭包是零成本抽象——编译后的汇编与手写等价代码完全相同
- Async 闭包将闭包与 async 机制结合,编译器为此引入了专门的
CoroutineClosure处理路径 :::
11.1 闭包的本质:编译器生成的匿名 struct
当你在 Rust 中写一个闭包时,编译器到底做了什么?很多语言(JavaScript、Python)的闭包通过运行时机制(堆分配、引用计数、垃圾回收)来捕获环境变量。Rust 的做法截然不同:编译器在编译期将每个闭包转化为一个匿名 struct 和对应的 trait 实现。没有堆分配,没有引用计数,没有运行时开销。
一个完整的例子
让我们从一个简单的闭包出发,完整展示编译器的转化过程:
// 你写的代码
fn main() {
let name = String::from("Rust");
let greeting = String::from("Hello");
let greet = |suffix: &str| {
println!("{}, {}{}!", greeting, name, suffix);
};
greet("!");
greet("!!");
}
编译器会将这个闭包转化为一个匿名 struct 和 trait 实现。概念上等价于:
// 编译器做的事(概念等价,非实际生成的代码)
struct __closure_greet<'a> {
greeting: &'a String, // 只读 → 不可变引用
name: &'a String, // 只读 → 不可变引用
}
impl<'a> Fn<(&str,)> for __closure_greet<'a> {
extern "rust-call" fn call(&self, (suffix,): (&str,)) -> () {
println!("{}, {}{}!", self.greeting, self.name, suffix);
}
}
// 因为 Fn: FnMut: FnOnce,编译器还会自动生成
// FnMut 和 FnOnce 的实现(委托给 Fn::call)
fn main() {
let name = String::from("Rust");
let greeting = String::from("Hello");
let greet = __closure_greet { greeting: &greeting, name: &name };
Fn::call(&greet, ("!",));
Fn::call(&greet, ("!!",));
}
每个闭包都有唯一类型
一个极其重要的设计决策:每个闭包表达式都会产生一个独一无二的类型。即使两个闭包的签名完全相同,它们的类型也不同。这就是为什么闭包类型无法被直接写出来——你只能用 impl Fn(...) 或泛型约束来引用它。
let a = |x: i32| x + 1;
let b = |x: i32| x + 1;
// a 和 b 的类型不同!
// 你不能写 let c: ??? = a; 因为类型名是编译器内部生成的
// 只能通过 trait 约束来引用:
fn apply(f: impl Fn(i32) -> i32, x: i32) -> i32 { f(x) }
这个设计有深远的性能意义:因为每个闭包类型是唯一的,编译器在单态化(monomorphization)时可以精确知道调用哪个函数,从而实现完全的内联优化。
flowchart LR
A["闭包表达式<br/><code>|x| x + captured</code>"] --> B["编译器分析"]
B --> C["匿名 struct<br/>字段 = 捕获的变量"]
B --> D["impl Fn/FnMut/FnOnce<br/>call 方法 = 闭包体"]
B --> E["唯一类型名<br/>不可被源码引用"]
B --> F["单态化<br/>调用点可内联"]
style A fill:#3b82f6,color:#fff,stroke:none
style B fill:#8b5cf6,color:#fff,stroke:none
style C fill:#10b981,color:#fff,stroke:none
style D fill:#10b981,color:#fff,stroke:none
style E fill:#f59e0b,color:#fff,stroke:none
style F fill:#ef4444,color:#fff,stroke:none
11.2 三种 Fn trait:闭包的调用协议
Fn、FnMut、FnOnce 三个 trait 定义了闭包如何被调用,核心区别在于 self 的接收方式。它们在 library/core/src/ops/function.rs 中的真实定义:
// library/core/src/ops/function.rs(精简后的核心定义)
pub trait FnOnce<Args: Tuple> {
type Output;
extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
// ^^^^
// 消耗 self —— 闭包被移动,只能调用一次
}
pub trait FnMut<Args: Tuple>: FnOnce<Args> {
extern "rust-call" fn call_mut(&mut self, args: Args) -> Self::Output;
// ^^^^^^^^^
// 可变借用 self —— 可以多次调用,但需要独占访问
}
pub trait Fn<Args: Tuple>: FnMut<Args> {
extern "rust-call" fn call(&self, args: Args) -> Self::Output;
// ^^^^^
// 不可变借用 self —— 可以多次、并发调用
}
注意几个重要细节:
- 继承链:
Fn: FnMut: FnOnce。实现了Fn的闭包自动实现FnMut和FnOnce。 extern "rust-call"ABI:参数被打包为元组传递,这是 Rust 内部的调用约定。Args: Tuple约束:参数类型必须是元组,这使得闭包可以接受任意数量的参数。
trait 层级关系与子类型逻辑
继承关系 Fn: FnMut: FnOnce 的逻辑非常自然:
- 如果你能通过
&self(不可变引用)调用闭包,那你肯定也能通过&mut self(可变引用)调用——因为&T可以被升级为&mut T的使用场景。 - 如果你能通过
&mut self调用闭包,那你肯定也能通过self(按值)调用——因为拥有所有权意味着你可以做任何事。
graph TB
FnOnce["<b>FnOnce</b><br/><code>call_once(self)</code><br/>至少能调用一次<br/>消耗所有权"]
FnMut["<b>FnMut: FnOnce</b><br/><code>call_mut(&mut self)</code><br/>可以多次调用<br/>需要独占访问"]
Fn["<b>Fn: FnMut</b><br/><code>call(&self)</code><br/>可以多次并发调用<br/>最宽松的约束"]
Fn -->|"继承"| FnMut
FnMut -->|"继承"| FnOnce
S1["消耗捕获变量的闭包<br/>例: || drop(name)<br/>只实现 FnOnce"]
S2["修改捕获变量的闭包<br/>例: || count += 1<br/>实现 FnMut + FnOnce"]
S3["只读捕获变量的闭包<br/>例: || println!("{}", x)<br/>实现 Fn + FnMut + FnOnce"]
S4["不捕获变量的闭包<br/>例: |x| x + 1<br/>实现 Fn + FnMut + FnOnce"]
S1 -.-> FnOnce
S2 -.-> FnMut
S3 -.-> Fn
S4 -.-> Fn
style FnOnce fill:#ef4444,color:#fff,stroke:none
style FnMut fill:#f59e0b,color:#fff,stroke:none
style Fn fill:#10b981,color:#fff,stroke:none
style S1 fill:#fecaca,color:#333,stroke:none
style S2 fill:#fef3c7,color:#333,stroke:none
style S3 fill:#d1fae5,color:#333,stroke:none
style S4 fill:#d1fae5,color:#333,stroke:none
标准库还为 &F 和 &mut F 提供了 blanket 实现:如果 F: Fn,则 &F 也实现 Fn/FnMut/FnOnce;如果 F: FnMut,则 &mut F 也实现 FnMut/FnOnce。这使得闭包的引用也能直接被调用。
编译器如何选择 Fn trait
编译器选择 trait 的核心规则是:看闭包对捕获变量的最强操作。
| 闭包中最强的操作 | 实现的 trait | self 类型 | 可调用次数 |
|---|---|---|---|
| 不捕获变量 / 只读所有捕获变量 | Fn + FnMut + FnOnce | &self | 无限次,可并发 |
| 修改某个捕获变量 | FnMut + FnOnce | &mut self | 无限次,需独占 |
| 消耗某个捕获变量 | 仅 FnOnce | self | 恰好一次 |
来看完整的代码示例:
// Fn:只读捕获的变量(通过 &self 调用)
let x = 10;
let fn_closure = || println!("{}", x);
fn_closure(); // 可以调用
fn_closure(); // 可以再次调用
// 甚至可以并发调用(因为 &self 允许共享)
// FnMut:修改捕获的变量(通过 &mut self 调用)
let mut count = 0;
let mut fn_mut_closure = || { count += 1; };
fn_mut_closure(); // count = 1
fn_mut_closure(); // count = 2
// 可以多次调用,但不能并发
// FnOnce:消耗捕获的变量(通过 self 调用)
let name = String::from("Rust");
let fn_once_closure = || { drop(name); };
fn_once_closure(); // name 被 drop 了
// fn_once_closure(); // 编译错误!闭包已被消耗
11.3 捕获模式:编译器如何决定怎么捕获变量
三种捕获模式
编译器为每个被捕获的变量独立选择捕获模式。这三种模式直接对应闭包 struct 中字段的类型:
按不可变引用捕获(&T)
当闭包只读取捕获的变量时:
// 你写的
let x = 42;
let read_x = || println!("{}", x);
// 编译器生成的(概念等价)
struct __closure_read_x<'a> {
x: &'a i32, // 不可变引用
}
// 实现 Fn trait
按可变引用捕获(&mut T)
当闭包修改捕获的变量时:
// 你写的
let mut count = 0;
let mut increment = || { count += 1; };
// 编译器生成的(概念等价)
struct __closure_increment<'a> {
count: &'a mut i32, // 可变引用
}
// 实现 FnMut trait(不是 Fn,因为需要修改 self 中的字段)
按值移动捕获(T)
当闭包消耗捕获的变量,或使用了 move 关键字时:
// 你写的
let name = String::from("Rust");
let consume = || { drop(name); };
// 编译器生成的(概念等价)
struct __closure_consume {
name: String, // 按值持有,所有权被转移
}
// 只实现 FnOnce(因为 drop 消耗了 name)
混合捕获与精确捕获
一个闭包可以对不同变量使用不同的捕获模式。编译器为每个变量独立选择:
let name = String::from("Alice");
let mut age = 30;
let birthday = || {
println!("Happy birthday, {}!", name); // name: &String(只读)
age += 1; // age: &mut i32(修改)
};
// struct { name: &String, age: &mut i32 } — 实现 FnMut
从 Rust 2021 edition 开始,编译器可以做字段级的精确捕获(RFC 2229)。例如,闭包 || p.x += 1 只会捕获 p.x,而不是整个 p。这个特性在编译器中通过 compute_min_captures 函数实现(后面详细分析)。
11.4 编译器中的捕获分析算法
现在让我们深入 rustc 的源码,看看编译器是如何分析闭包捕获的。整个过程分为多个阶段。
第一阶段:类型检查入口(closure.rs)
当编译器遇到闭包表达式时,入口函数是 check_expr_closure(rustc_hir_typeck/src/closure.rs)。它的核心工作是:
- 从上下文推断闭包的签名和 kind(
deduce_closure_signature) - 确定闭包的函数签名(
sig_of_closure) - 创建闭包类型,其中
closure_kind_ty和tupled_upvars_ty暂时都是类型变量 - 对闭包体进行类型检查
关键观察:在这个阶段,Fn/FnMut/FnOnce 的选择和捕获变量的类型都尚未确定——它们将在后续的 upvar 分析阶段被填入。
第二阶段:捕获信息收集(upvar.rs)
闭包体被类型检查之后,编译器进入 upvar 分析阶段。核心入口是 analyze_closure,它完成四步工作:
// compiler/rustc_hir_typeck/src/upvar.rs(精简)
fn analyze_closure(&self, ..., body: &'tcx hir::Body<'tcx>, capture_clause: hir::CaptureBy) {
// 1. 使用 ExprUseVisitor 遍历闭包体,收集每个外部变量的使用方式
let mut delegate = InferBorrowKind { fcx: &closure_fcx, closure_def_id, ... };
euv::ExprUseVisitor::new(&closure_fcx, &mut delegate).consume_body(body);
// 2. 处理收集到的捕获信息,推断闭包 kind (Fn/FnMut/FnOnce)
let (capture_information, closure_kind, origin) = self
.process_collected_capture_information(capture_clause, &delegate.capture_information);
// 3. 计算最小捕获集合(Rust 2021 精确捕获)
self.compute_min_captures(closure_def_id, capture_information, span);
// 4. 统一类型变量——将推断结果填入之前创建的类型变量
let final_upvar_tys = self.final_upvar_tys(closure_def_id);
let final_tupled_upvars_type = Ty::new_tup(self.tcx, &final_upvar_tys);
self.demand_suptype(span, args.tupled_upvars_ty(), final_tupled_upvars_type);
}
第三阶段:借用种类的格分析
process_collected_capture_information 是决定闭包 kind 的关键函数。它遍历所有捕获信息,根据捕获方式推断闭包的最终 kind:
// compiler/rustc_hir_typeck/src/upvar.rs(精简)
fn process_collected_capture_information(
&self,
capture_clause: hir::CaptureBy,
capture_information: &InferredCaptureInformation<'tcx>,
) -> (InferredCaptureInformation<'tcx>, ty::ClosureKind, ...) {
// 从最宽松的 Fn 开始
let mut closure_kind = ty::ClosureKind::LATTICE_BOTTOM; // = Fn
let processed = capture_information.iter().cloned().map(|(place, mut info)| {
// 应用精度限制规则
let (place, capture_kind) = restrict_capture_precision(place, info.capture_kind);
let (place, capture_kind) = truncate_capture_for_optimization(place, capture_kind);
// 根据捕获方式"升级"闭包 kind
let updated = match capture_kind {
ty::UpvarCapture::ByValue => match closure_kind {
ty::ClosureKind::Fn | ty::ClosureKind::FnMut => {
// 按值捕获 → 升级到 FnOnce
(ty::ClosureKind::FnOnce, Some((usage_span, place.clone())))
}
ty::ClosureKind::FnOnce => (closure_kind, origin.take()),
},
ty::UpvarCapture::ByRef(ty::BorrowKind::Mutable | ty::BorrowKind::UniqueImmutable) => {
match closure_kind {
ty::ClosureKind::Fn => {
// 可变引用 → 升级到 FnMut
(ty::ClosureKind::FnMut, Some((usage_span, place.clone())))
}
_ => (closure_kind, origin.take()),
}
},
_ => (closure_kind, origin.take()), // 不可变引用不改变 kind
};
closure_kind = updated.0;
// 根据 capture_clause(move/ref)调整捕获方式
let (place, capture_kind) = match capture_clause {
hir::CaptureBy::Value { .. } => adjust_for_move_closure(place, capture_kind),
hir::CaptureBy::Ref => adjust_for_non_move_closure(place, capture_kind),
};
info.capture_kind = capture_kind;
(place, info)
}).collect();
(processed, closure_kind, origin)
}
这里有一个关键的设计:借用种类形成一个格(lattice)。编译器从最宽松的开始(Fn),随着分析每个捕获变量的使用方式,逐步"升级"到更严格的种类。升级路径是:
Fn → FnMut → FnOnce
一旦升级就不会降级——如果任何一个捕获变量需要按值移动,整个闭包就只能是 FnOnce。
flowchart TD
Start["开始分析<br/>closure_kind = Fn"] --> Loop["遍历每个捕获变量"]
Loop --> Check{"变量的使用方式?"}
Check -->|"只读 (&T)"| NoChange["closure_kind 不变"]
Check -->|"可变引用 (&mut T)"| UpMut{"当前 kind?"}
Check -->|"按值移动 (T)"| UpOnce{"当前 kind?"}
UpMut -->|"Fn"| SetMut["closure_kind = FnMut"]
UpMut -->|"FnMut/FnOnce"| NoChange2["closure_kind 不变"]
UpOnce -->|"Fn/FnMut"| SetOnce["closure_kind = FnOnce"]
UpOnce -->|"FnOnce"| NoChange3["closure_kind 不变"]
NoChange --> More{"还有变量?"}
NoChange2 --> More
NoChange3 --> More
SetMut --> More
SetOnce --> More
More -->|"是"| Loop
More -->|"否"| End["确定最终 closure_kind"]
style Start fill:#3b82f6,color:#fff,stroke:none
style End fill:#10b981,color:#fff,stroke:none
style SetMut fill:#f59e0b,color:#fff,stroke:none
style SetOnce fill:#ef4444,color:#fff,stroke:none
第四阶段:最小捕获计算
compute_min_captures 是 Rust 2021 精确捕获的核心。以 upvar.rs 中的注释为例:
// 输入(收集到的所有捕获信息):
// Place(s, []) -> ByRef(ImmBorrow) // println!("{s:?}")
// Place(p, [Field(x)]) -> ByRef(MutBorrow) // p.x += 10
// Place(p, [Field(y)]) -> ByRef(ImmBorrow) // println!("{}", p.y)
// Place(p, []) -> ByRef(ImmBorrow) // println!("{p:?}")
// Place(s, []) -> ByValue // drop(s)
//
// 输出(最小捕获集合):
// s -> Place(s, []) ByValue // ByValue 胜出
// p -> Place(p, []) ByRef(MutBorrow) // 祖先合并,MutBorrow 胜出
算法逻辑:对于同一根变量的多个 Place,如果其中一个是另一个的祖先,则保留祖先,并将后代的捕获方式合并上去(取更强的)。编译器中的 UpvarCapture 枚举定义了三种捕获方式:ByValue、ByUse(use 闭包专用)、ByRef(BorrowKind)。
11.5 闭包的内存布局
理解了编译器如何分析捕获之后,让我们看看闭包在内存中的实际布局。
基本布局规则
闭包 struct 的字段就是捕获的变量,布局遵循 Rust 的标准 struct 布局规则(包括对齐和 padding):
use std::mem::{size_of_val, align_of_val};
let a = 0u8; // 1 字节
let b = 0u64; // 8 字节
let c = 0u32; // 4 字节
let s = String::from("hello"); // 24 字节(ptr+len+cap)
// 不捕获任何变量
let c0 = || 42;
println!("size={}, align={}", size_of_val(&c0), align_of_val(&c0));
// size=0, align=1 —— ZST!
// 捕获一个引用
let c1 = || println!("{}", a);
println!("size={}", size_of_val(&c1)); // 8 —— 一个指针
// 捕获两个引用
let c2 = || println!("{} {}", a, b);
println!("size={}", size_of_val(&c2)); // 16 —— 两个指针
// 按值捕获 String
let c3 = move || println!("{}", s);
println!("size={}", size_of_val(&c3)); // 24 —— String 的大小
// 混合捕获(引用 + 值)
let x = 42u32;
let y = String::from("world");
let c4 = move || println!("{} {}", x, y);
println!("size={}", size_of_val(&c4));
// 32 —— u32(4) + padding(4) + String(24) = 32
// 注意:编译器会对字段重排以优化布局
ZST 闭包:零大小的奇迹
不捕获任何变量的闭包是零大小类型(ZST)。这是一个非常重要的优化:
let c = |x: i32| x * 2;
assert_eq!(std::mem::size_of_val(&c), 0);
// ZST 意味着:
// 1. 不占用任何栈空间
// 2. 编译器知道调用点唯一对应一个函数
// 3. 可以完全内联
在迭代器链中,这个特性至关重要:
let sum: i32 = (0..1000)
.filter(|x| x % 2 == 0) // 闭包1:ZST(不捕获变量)
.map(|x| x * x) // 闭包2:ZST(不捕获变量)
.sum();
// 整个链条中没有任何闭包占用内存。
// 编译器将所有闭包内联,生成一个紧凑的循环。
11.6 move 闭包:强制按值捕获
move 关键字将所有捕获的变量从引用模式改为按值模式。这是 Rust 中最容易被误解的特性之一。
move 不影响 Fn trait 种类
move 影响的是捕获方式,不是调用方式。 一个 move 闭包如果只读取捕获的变量,它依然实现 Fn:
// 没有 move
let x = 42;
let c1 = || println!("{}", x);
// c1 的 struct: { x: &i32 } 大小:8 字节
// c1 实现 Fn
// 有 move
let x = 42;
let c2 = move || println!("{}", x);
// c2 的 struct: { x: i32 } 大小:4 字节
// c2 仍然实现 Fn!因为它只是读取 self.x,不需要 &mut self
编译器中的 move 处理
在 process_collected_capture_information 中,move 闭包的处理通过 adjust_for_move_closure 完成:
// 简化的逻辑
let (place, capture_kind) = match capture_clause {
hir::CaptureBy::Value { .. } => {
// move 闭包:所有引用捕获都变成按值捕获
adjust_for_move_closure(place, capture_kind)
// ByRef(ImmBorrow) → ByValue
// ByRef(MutBorrow) → ByValue
// ByValue → ByValue(不变)
},
hir::CaptureBy::Ref => {
// 非 move 闭包:保持原样
adjust_for_non_move_closure(place, capture_kind)
},
};
但注意:move 只改变捕获方式,不改变闭包 kind 的推断。kind 的推断在 adjust_for_move_closure 之前就已经完成了。
move 的典型用途
1. 延长变量生命周期
最常见的用途是让闭包拥有捕获变量的所有权,从而让闭包能活得比原始变量更久:
fn spawn_greeting() -> impl Fn() {
let name = String::from("Rust");
// 没有 move 会编译失败:name 的引用活不过函数返回
move || println!("Hello, {}!", name)
// name 被移入闭包,闭包拥有 name 的所有权
}
2. 跨线程传递
std::thread::spawn 要求闭包实现 Send + 'static,通常需要 move:
let data = vec![1, 2, 3];
std::thread::spawn(move || {
println!("{:?}", data); // data 被移入闭包,满足 'static 约束
});
3. Copy 类型的 move
对于 Copy 类型,move 实际上是复制而不是移动。let x = 42; let c = move || x; 之后 x 仍然可用。
11.7 闭包与函数指针:类型擦除与转换
fn() 与 Fn() 的区别
fn() 是函数指针类型,Fn() 是 trait。这两者有本质区别:
// fn() — 函数指针,固定大小(一个指针),没有环境
let fp: fn(i32) -> i32 = |x| x + 1;
// impl Fn() — trait 约束,每个闭包有自己的类型
fn apply(f: impl Fn(i32) -> i32, x: i32) -> i32 { f(x) }
// dyn Fn() — trait 对象,通过 vtable 动态分发
fn apply_dyn(f: &dyn Fn(i32) -> i32, x: i32) -> i32 { f(x) }
| 类型 | 大小 | 分发方式 | 能捕获环境? | 能内联? |
|---|---|---|---|---|
fn(T) -> U | 8 字节(一个指针) | 间接调用 | 否 | 难以内联 |
impl Fn(T) -> U | 闭包 struct 大小 | 静态(直接调用) | 是 | 能内联 |
&dyn Fn(T) -> U | 16 字节(胖指针) | 动态(vtable) | 是 | 不能内联 |
Box<dyn Fn(T) -> U> | 16 字节(胖指针) | 动态(vtable) | 是 | 不能内联 |
闭包到函数指针的隐式转换
Rust 有一条特殊的转换规则:不捕获任何变量的闭包可以被隐式转换为函数指针。
// 不捕获变量的闭包可以转换为 fn()
let closure = |x: i32| x * 2;
let fp: fn(i32) -> i32 = closure; // 隐式转换
// 捕获了变量的闭包不能转换
let y = 10;
let closure_with_capture = |x: i32| x + y;
// let fp: fn(i32) -> i32 = closure_with_capture; // 编译错误!
这个转换在编译器中是通过 coercion 机制实现的。当编译器检测到一个不捕获变量的闭包被用在期望 fn() 的地方时,它会插入一个 coercion,将闭包类型转换为函数指针。
这也是 ZST 闭包的一个有趣推论:因为不捕获变量的闭包是零大小的,它不需要任何"环境"数据,所以可以安全地退化为一个普通的函数指针。
dyn Fn 与 vtable
当你需要在运行时存储不同类型的闭包时,就需要使用 trait 对象(dyn Fn)。这引入了 vtable 间接调用:
// 静态分发:编译器知道具体类型,可以内联
fn call_static(f: impl Fn(i32) -> i32, x: i32) -> i32 {
f(x) // 直接调用,可内联
}
// 动态分发:通过 vtable 间接调用
fn call_dynamic(f: &dyn Fn(i32) -> i32, x: i32) -> i32 {
f(x) // 通过 vtable 查找函数指针,不可内联
}
// 在实际代码中的使用场景——存储多种不同的闭包
struct EventSystem {
// 每个事件可以有不同的处理闭包
handlers: Vec<Box<dyn Fn(&str)>>,
}
impl EventSystem {
fn trigger(&self, event: &str) {
for handler in &self.handlers {
handler(event); // 动态分发
}
}
}
Box<dyn Fn(...)> 的内存布局是两个指针:一个指向堆上的闭包 struct 数据,一个指向 vtable。vtable 中包含了 call 函数的指针、析构函数指针和大小/对齐信息。
11.8 闭包的零成本抽象:汇编级证明
Rust 声称闭包是"零成本抽象"——让我们用汇编来证明这一点。
对比实验:闭包 vs 手写代码
考虑这两段功能等价的代码:
// 版本1:使用闭包的迭代器链
pub fn sum_squares_closure(n: i32) -> i32 {
(0..n).filter(|x| x % 2 == 0).map(|x| x * x).sum()
}
// 版本2:手写循环
pub fn sum_squares_manual(n: i32) -> i32 {
let mut sum = 0;
let mut i = 0;
while i < n {
if i % 2 == 0 {
sum += i * i;
}
i += 1;
}
sum
}
使用 cargo build --release 编译后,两个版本生成的汇编完全相同——都是一个紧凑的循环,没有任何函数调用。编译器完成了:单态化(泛型参数具体化为闭包唯一类型)、内联(ZST 闭包的 call 方法被内联)、迭代器融合(整个链被融合为一个循环)、消除间接调用。
为什么是零成本的?
关键在于编译器的三个设计决策:
- 每个闭包类型唯一:编译器在单态化时精确知道调用哪个函数
- ZST 不占空间:不捕获变量的闭包不需要传递任何额外数据
extern "rust-call"ABI:编译器控制调用约定,可以自由优化
使用 dyn Fn 时零成本不再成立——vtable 间接调用阻止了内联优化。在性能敏感的代码中,应优先使用泛型(impl Fn)。
11.9 闭包的存储与返回
闭包作为 struct 字段
存储闭包时,必须在静态分发和动态分发之间选择:
// 静态分发:零开销,但每个不同闭包产生不同的 Button 类型
struct Button<F: Fn()> { label: String, on_click: F }
// 动态分发:可以存储不同类型的闭包,但有堆分配+vtable开销
struct ButtonDyn { label: String, on_click: Box<dyn Fn()> }
选择原则:闭包类型编译期确定且单一用泛型;需要运行时多态用 Box<dyn Fn>;只需临时借用用 &dyn Fn。
返回闭包
// impl Trait:静态分发,可内联。move 是必须的——否则引用局部变量会悬空
fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
move |x| x + n
}
// Box<dyn Fn>:动态分发,可以根据条件返回不同闭包
fn make_op(op: &str) -> Box<dyn Fn(i32, i32) -> i32> {
match op {
"add" => Box::new(|a, b| a + b),
"mul" => Box::new(|a, b| a * b),
_ => Box::new(|_, _| 0),
}
}
11.10 Async 闭包:闭包与异步的结合
Rust 1.85 (2025年2月) 稳定了 async closures,这是闭包机制与 async/await 的深度融合。
在 async 闭包之前,|url| async move { reqwest::get(&url).await } 实际上是一个返回 Future 的同步闭包。每次调用都创建新的 async block,需要独立捕获变量,无法优雅地引用环境。
async 闭包的语法和语义
// 新语法:async closure
let fetch = async |url: String| {
reqwest::get(&url).await
};
// 可以多次调用
fetch("https://example.com".into()).await;
fetch("https://rust-lang.org".into()).await;
编译器中的实现
在编译器中,async 闭包通过 CoroutineClosure 来表示。在 check_expr_closure 中有专门的处理路径:
// compiler/rustc_hir_typeck/src/closure.rs(简化)
match closure.kind {
hir::ClosureKind::Closure => {
// 普通闭包:直接生成 Closure 类型
Ty::new_closure(tcx, expr_def_id, closure_args.args)
}
hir::ClosureKind::CoroutineClosure(kind) => {
// async 闭包:生成 CoroutineClosure 类型
// 这个类型包含额外的信息:
// - closure_kind_ty: Fn/FnMut/FnOnce
// - coroutine_captures_by_ref_ty: 内部协程引用捕获的类型
// - signature_parts_ty: 包含 resume_ty, yield_ty, return_ty
Ty::new_coroutine_closure(tcx, expr_def_id, closure_args.args)
}
hir::ClosureKind::Coroutine(kind) => {
// 协程(async block, gen block 等)
Ty::new_coroutine(tcx, expr_def_id, coroutine_args.args)
}
}
Async 闭包的复杂性在于它需要同时处理两层结构:
- 外层闭包:捕获环境变量
- 内层协程:async 执行体
当外层闭包被调用时,它创建一个内层协程。这个协程需要能够访问外层闭包捕获的变量。编译器需要确保:
- 如果闭包是
Fn(可以被多次调用),内层协程应该借用外层闭包的捕获变量 - 如果闭包是
FnOnce,内层协程可以移动外层闭包的捕获变量
AsyncFn trait 族
与 Fn/FnMut/FnOnce 对应,async 闭包有 AsyncFn/AsyncFnMut/AsyncFnOnce trait:
// 概念上的定义(实际实现更复杂)
trait AsyncFnOnce<Args> {
type Output;
async fn async_call_once(self, args: Args) -> Self::Output;
}
trait AsyncFnMut<Args>: AsyncFnOnce<Args> {
async fn async_call_mut(&mut self, args: Args) -> Self::Output;
}
trait AsyncFn<Args>: AsyncFnMut<Args> {
async fn async_call(&self, args: Args) -> Self::Output;
}
11.11 编译器中的完整闭包处理流程
让我们总结编译器处理闭包的完整流程,从源码到最终的机器码:
flowchart TD
A["源码中的闭包表达式<br/><code>|args| body</code>"] --> B["HIR lowering<br/>生成 hir::Closure 节点"]
B --> C["类型检查入口<br/>check_expr_closure()"]
C --> D["签名推断<br/>deduce_closure_signature()"]
D --> E["闭包体类型检查<br/>check_fn()"]
E --> F["捕获分析入口<br/>analyze_closure()"]
F --> G["ExprUseVisitor<br/>遍历闭包体,收集变量使用信息"]
G --> H["process_collected_capture_information<br/>推断 ClosureKind (Fn/FnMut/FnOnce)"]
H --> I["compute_min_captures<br/>计算最小捕获集合(Rust 2021 精确捕获)"]
I --> J["final_upvar_tys<br/>确定每个捕获变量的最终类型"]
J --> K["统一类型变量<br/>demand_suptype / demand_eqtype"]
K --> L["MIR 构建<br/>将闭包 struct 的构造和方法调用转化为 MIR"]
L --> M["单态化<br/>为每个闭包类型生成独立的代码"]
M --> N["LLVM 代码生成<br/>内联优化,生成最终机器码"]
style A fill:#3b82f6,color:#fff,stroke:none
style F fill:#8b5cf6,color:#fff,stroke:none
style H fill:#f59e0b,color:#fff,stroke:none
style I fill:#f59e0b,color:#fff,stroke:none
style N fill:#10b981,color:#fff,stroke:none
关键数据结构与借用格
编译器中涉及的核心数据结构:hir::Closure(HIR 层闭包表示)、ty::ClosureArgs(闭包类型参数,含 kind_ty/sig/upvars_ty)、ty::UpvarCapture(捕获方式枚举)、ty::CapturedPlace(完整捕获信息)、InferBorrowKind(ExprUseVisitor 的委托)。
借用种类形成格结构(upvar.rs 开头注释):ImmBorrow -> UniqueImmBorrow -> MutBorrow,对应 ClosureKind 的 Fn -> FnMut -> FnOnce。每个变量从最弱开始,逐步升级。
11.12 常见误区与陷阱
误区一:move 闭包只能调用一次
let name = String::from("Rust");
let greet = move || println!("Hello, {}!", name);
greet(); // 第一次调用
greet(); // 第二次调用——完全合法!
// move 只影响捕获方式,不影响 Fn trait
// 因为 println! 只读 name,所以 greet 实现 Fn
误区二:闭包总是比函数调用慢
// 通过泛型传递的闭包——零开销
fn apply<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 { f(x) }
let result = apply(|x| x * 2, 21);
// 编译后完全等价于 let result = 21 * 2;
误区三:同签名的闭包可以互相赋值
let mut f = |x: i32| x + 1;
// f = |x: i32| x + 2; // 编译错误!两个闭包是不同的类型
// 如果需要重新赋值,使用 trait 对象
let mut f: Box<dyn Fn(i32) -> i32> = Box::new(|x| x + 1);
f = Box::new(|x| x + 2); // 可以,因为类型是 Box<dyn Fn(...)>
误区四:闭包捕获整个变量
Rust 2021 中,闭包只捕获使用到的字段。|| println!("{}", config.name) 只捕获 config.name,不影响 config.debug。
11.13 本章小结
本章深入探讨了 Rust 闭包的编译器实现。核心要点:
闭包的本质:每个闭包被编译为一个唯一类型的匿名 struct,捕获的变量成为字段,闭包体成为 Fn/FnMut/FnOnce trait 的方法实现。
捕获分析:编译器在 rustc_hir_typeck/src/upvar.rs 中通过 ExprUseVisitor 遍历闭包体,收集每个变量的使用方式,然后通过格(lattice)结构逐步"升级"借用种类。Rust 2021 引入了字段级精确捕获,通过 compute_min_captures 计算最小捕获集合。
Fn trait 层级:Fn: FnMut: FnOnce 形成严格的继承链。编译器根据闭包对捕获变量的最强操作自动选择实现哪个 trait。move 关键字只影响捕获方式(引用 vs 值),不影响 trait 种类。
零成本抽象:通过唯一类型 + 单态化 + 内联的组合,闭包在编译后与手写代码生成完全相同的汇编。只有使用 dyn Fn 时才引入 vtable 间接调用的开销。
Async 闭包:将闭包机制与协程结合,编译器通过 CoroutineClosure 处理两层结构(外层闭包 + 内层协程),并引入了 AsyncFn/AsyncFnMut/AsyncFnOnce trait 族。
理解了闭包的编译器实现,你就会明白 Rust 的一个核心设计哲学:将高级语言的便利(匿名函数、捕获环境、类型推断)编译为低级语言的效率(精确大小的 struct、静态分发的调用、零额外开销)。下一章,我们将进入 unsafe 的领地——看看编译器在哪里停止检查,以及为什么有些事情必须由程序员来保证。