Skip to content

第11章 闭包:匿名函数的编译器实现

"闭包不是魔法——它们是编译器帮你写的结构体。" —— 这是理解 Rust 闭包最核心的一句话。

本章要点

  • 每个闭包都会被编译器转化为一个唯一的匿名 struct,捕获的变量就是 struct 的字段
  • 三种捕获模式:不可变引用&T)、可变引用&mut T)、按值移动T
  • 编译器的捕获分析算法遵循最小权限原则——优先引用,必要时才 move
  • FnFnMutFnOnce 三个 trait 形成严格的层级关系,闭包实现哪个取决于它如何使用捕获的变量
  • move 关键字强制所有捕获变量按值捕获,但不改变闭包实现的 Fn trait 种类
  • 闭包的大小 = 所有捕获变量的大小之和(加 padding),不捕获任何变量的闭包是 ZST(零大小类型)
  • 不捕获变量的闭包可以被隐式转换为函数指针 fn()
  • Rust 闭包是零成本抽象——编译后的汇编与手写等价代码完全相同
  • Async 闭包将闭包与 async 机制结合,编译器为此引入了专门的 CoroutineClosure 处理路径

11.1 闭包的本质:编译器生成的匿名 struct

当你在 Rust 中写一个闭包时,编译器到底做了什么?很多语言(JavaScript、Python)的闭包通过运行时机制(堆分配、引用计数、垃圾回收)来捕获环境变量。Rust 的做法截然不同:编译器在编译期将每个闭包转化为一个匿名 struct 和对应的 trait 实现。没有堆分配,没有引用计数,没有运行时开销。

一个完整的例子

让我们从一个简单的闭包出发,完整展示编译器的转化过程:

rust
// 你写的代码
fn main() {
    let name = String::from("Rust");
    let greeting = String::from("Hello");
    let greet = |suffix: &str| {
        println!("{}, {}{}!", greeting, name, suffix);
    };
    greet("!");
    greet("!!");
}

编译器会将这个闭包转化为一个匿名 struct 和 trait 实现。概念上等价于:

rust
// 编译器做的事(概念等价,非实际生成的代码)
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(...) 或泛型约束来引用它。

rust
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)时可以精确知道调用哪个函数,从而实现完全的内联优化

11.2 三种 Fn trait:闭包的调用协议

FnFnMutFnOnce 三个 trait 定义了闭包如何被调用,核心区别在于 self 的接收方式。它们在 library/core/src/ops/function.rs 中的真实定义:

rust
// 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 —— 可以多次、并发调用
}

注意几个重要细节:

  1. 继承链Fn: FnMut: FnOnce。实现了 Fn 的闭包自动实现 FnMutFnOnce
  2. extern "rust-call" ABI:参数被打包为元组传递,这是 Rust 内部的调用约定。
  3. Args: Tuple 约束:参数类型必须是元组,这使得闭包可以接受任意数量的参数。

trait 层级关系与子类型逻辑

继承关系 Fn: FnMut: FnOnce 的逻辑非常自然:

  • 如果你能通过 &self(不可变引用)调用闭包,那你肯定也能通过 &mut self(可变引用)调用——因为 &T 可以被升级为 &mut T 的使用场景。
  • 如果你能通过 &mut self 调用闭包,那你肯定也能通过 self(按值)调用——因为拥有所有权意味着你可以做任何事。

标准库还为 &F&mut F 提供了 blanket 实现:如果 F: Fn,则 &F 也实现 Fn/FnMut/FnOnce;如果 F: FnMut,则 &mut F 也实现 FnMut/FnOnce。这使得闭包的引用也能直接被调用。

编译器如何选择 Fn trait

编译器选择 trait 的核心规则是:看闭包对捕获变量的最强操作

闭包中最强的操作实现的 traitself 类型可调用次数
不捕获变量 / 只读所有捕获变量Fn + FnMut + FnOnce&self无限次,可并发
修改某个捕获变量FnMut + FnOnce&mut self无限次,需独占
消耗某个捕获变量FnOnceself恰好一次

来看完整的代码示例:

rust
// 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

当闭包只读取捕获的变量时:

rust
// 你写的
let x = 42;
let read_x = || println!("{}", x);

// 编译器生成的(概念等价)
struct __closure_read_x<'a> {
    x: &'a i32,  // 不可变引用
}
// 实现 Fn trait

按可变引用捕获(&mut T

当闭包修改捕获的变量时:

rust
// 你写的
let mut count = 0;
let mut increment = || { count += 1; };

// 编译器生成的(概念等价)
struct __closure_increment<'a> {
    count: &'a mut i32,  // 可变引用
}
// 实现 FnMut trait(不是 Fn,因为需要修改 self 中的字段)

按值移动捕获(T

当闭包消耗捕获的变量,或使用了 move 关键字时:

rust
// 你写的
let name = String::from("Rust");
let consume = || { drop(name); };

// 编译器生成的(概念等价)
struct __closure_consume {
    name: String,  // 按值持有,所有权被转移
}
// 只实现 FnOnce(因为 drop 消耗了 name)

混合捕获与精确捕获

一个闭包可以对不同变量使用不同的捕获模式。编译器为每个变量独立选择:

rust
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_closurerustc_hir_typeck/src/closure.rs)。它的核心工作是:

  1. 从上下文推断闭包的签名和 kind(deduce_closure_signature
  2. 确定闭包的函数签名(sig_of_closure
  3. 创建闭包类型,其中 closure_kind_tytupled_upvars_ty 暂时都是类型变量
  4. 对闭包体进行类型检查

关键观察:在这个阶段,Fn/FnMut/FnOnce 的选择和捕获变量的类型都尚未确定——它们将在后续的 upvar 分析阶段被填入。

第二阶段:捕获信息收集(upvar.rs)

闭包体被类型检查之后,编译器进入 upvar 分析阶段。核心入口是 analyze_closure,它完成四步工作:

rust
// 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:

rust
// 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

第四阶段:最小捕获计算

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 枚举定义了三种捕获方式:ByValueByUse(use 闭包专用)、ByRef(BorrowKind)

11.5 闭包的内存布局

理解了编译器如何分析捕获之后,让我们看看闭包在内存中的实际布局。

基本布局规则

闭包 struct 的字段就是捕获的变量,布局遵循 Rust 的标准 struct 布局规则(包括对齐和 padding):

rust
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)。这是一个非常重要的优化:

rust
let c = |x: i32| x * 2;

assert_eq!(std::mem::size_of_val(&c), 0);

// ZST 意味着:
// 1. 不占用任何栈空间
// 2. 编译器知道调用点唯一对应一个函数
// 3. 可以完全内联

在迭代器链中,这个特性至关重要:

rust
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

rust
// 没有 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 完成:

rust
// 简化的逻辑
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. 延长变量生命周期

最常见的用途是让闭包拥有捕获变量的所有权,从而让闭包能活得比原始变量更久:

rust
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

rust
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。这两者有本质区别:

rust
// 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) -> U8 字节(一个指针)间接调用难以内联
impl Fn(T) -> U闭包 struct 大小静态(直接调用)能内联
&dyn Fn(T) -> U16 字节(胖指针)动态(vtable)不能内联
Box<dyn Fn(T) -> U>16 字节(胖指针)动态(vtable)不能内联

闭包到函数指针的隐式转换

Rust 有一条特殊的转换规则:不捕获任何变量的闭包可以被隐式转换为函数指针

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 间接调用:

rust
// 静态分发:编译器知道具体类型,可以内联
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 手写代码

考虑这两段功能等价的代码:

rust
// 版本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 方法被内联)、迭代器融合(整个链被融合为一个循环)、消除间接调用。

为什么是零成本的?

关键在于编译器的三个设计决策:

  1. 每个闭包类型唯一:编译器在单态化时精确知道调用哪个函数
  2. ZST 不占空间:不捕获变量的闭包不需要传递任何额外数据
  3. extern "rust-call" ABI:编译器控制调用约定,可以自由优化

使用 dyn Fn 时零成本不再成立——vtable 间接调用阻止了内联优化。在性能敏感的代码中,应优先使用泛型(impl Fn)。

11.9 闭包的存储与返回

闭包作为 struct 字段

存储闭包时,必须在静态分发和动态分发之间选择:

rust
// 静态分发:零开销,但每个不同闭包产生不同的 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

返回闭包

rust
// 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 闭包的语法和语义

rust
// 新语法: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 中有专门的处理路径:

rust
// 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 闭包的复杂性在于它需要同时处理两层结构:

  1. 外层闭包:捕获环境变量
  2. 内层协程:async 执行体

当外层闭包被调用时,它创建一个内层协程。这个协程需要能够访问外层闭包捕获的变量。编译器需要确保:

  • 如果闭包是 Fn(可以被多次调用),内层协程应该借用外层闭包的捕获变量
  • 如果闭包是 FnOnce,内层协程可以移动外层闭包的捕获变量

AsyncFn trait 族

Fn/FnMut/FnOnce 对应,async 闭包有 AsyncFn/AsyncFnMut/AsyncFnOnce trait:

rust
// 概念上的定义(实际实现更复杂)
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 编译器中的完整闭包处理流程

让我们总结编译器处理闭包的完整流程,从源码到最终的机器码:

关键数据结构与借用格

编译器中涉及的核心数据结构: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 闭包只能调用一次

rust
let name = String::from("Rust");
let greet = move || println!("Hello, {}!", name);
greet();   // 第一次调用
greet();   // 第二次调用——完全合法!
// move 只影响捕获方式,不影响 Fn trait
// 因为 println! 只读 name,所以 greet 实现 Fn

误区二:闭包总是比函数调用慢

rust
// 通过泛型传递的闭包——零开销
fn apply<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 { f(x) }
let result = apply(|x| x * 2, 21);
// 编译后完全等价于 let result = 21 * 2;

误区三:同签名的闭包可以互相赋值

rust
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 的领地——看看编译器在哪里停止检查,以及为什么有些事情必须由程序员来保证。

基于 VitePress 构建