Skip to content
Published at:

14. Closures — 闭包

闭包是 Rust 最具表现力的特性之一——它们是匿名函数,可以捕获其所在作用域中的变量。Rust 的闭包设计追求零成本抽象:闭包编译为普通的结构体,没有垃圾回收开销,大多数情况下可以完全内联。本章深入探讨闭包的语法、捕获语义、trait 层级以及在实际编程中的最佳实践。

为什么需要闭包

考虑一个排序场景:我们有一个城市列表,想按人口降序排列。Rust 的 Vec 提供了 sort_by_key 方法:

rust
#[derive(Debug)]
struct City {
    name: &'static str,
    population: i64,
}

fn sort_cities(cities: &mut Vec<City>) {
    cities.sort_by_key(|city| -city.population);
}

fn main() {
    let mut cities = vec![
        City { name: "北京", population: 21_540_000 },
        City { name: "上海", population: 24_870_000 },
        City { name: "广州", population: 18_670_000 },
    ];
    sort_cities(&mut cities);
    println!("{:?}", cities);
}

|city| -city.population 就是一个闭包——它捕获外部的计算逻辑,传递给 sort_by_key 来决定排序依据。闭包让代码更紧凑:你不需要写一个完整的命名函数来完成如此简单的操作。

标准库中大量接受闭包的 API:

rust
// Iterator 的 map 和 filter
let squares: Vec<i32> = (1..=10).map(|n| n * n).collect();
let evens: Vec<i32> = (1..=10).filter(|n| n % 2 == 0).collect();

// thread::spawn 接受闭包
std::thread::spawn(|| {
    println!("在新线程中运行");
});

// HashMap 的 or_insert_with
use std::collections::HashMap;
let mut map = HashMap::new();
map.entry("key").or_insert_with(|| 42);

闭包语法

Rust 闭包的基本语法是 |参数列表| 表达式

rust
// 最简形式:没有参数
let greeting = || println!("Hello, world!");

// 单个参数
let double = |x: i32| x * 2;

// 多个参数
let add = |a, b| a + b;

// 多行闭包体使用花括号
let complex = |x, y| {
    let sum = x + y;
    let diff = x - y;
    sum * diff
};

Rust 闭包的强大之处在于类型推断——通常你不需要标注参数类型。在下面的例子中,ab 的类型由调用上下文推断:

rust
let add = |a, b| a + b;

// 编译器从第一次调用推断:
// a: i32, b: i32, 返回值: i32
let result: i32 = add(1, 2);

// 下面这行编译错误——类型已经锁定为 i32
// let result: f64 = add(1.0, 2.0);  // 错误!

如果你必须显式写类型(例如消除歧义),也可以:

rust
let add = |a: i32, b: i32| -> i32 { a + b };

捕获变量

闭包可以捕获定义它们的作用域中的变量。Rust 根据闭包体如何使用这些变量自动选择最不具侵入性的捕获方式。

借用捕获(Borrow)

当闭包只读取捕获的变量时,Rust 借用一个共享引用:

rust
let name = "Alice".to_string();
let greeting = || println!("你好,{name}");

// 闭包捕获了 &String(共享引用)
greeting();  // 输出:你好,Alice
greeting();  // 可以多次调用

// name 仍然可用
println!("name 仍然是:{name}");

可变借用捕获(Mutable Borrow)

当闭包修改捕获的变量时,Rust 借用一个可变引用:

rust
let mut count = 0;
let mut increment = || {
    count += 1;
    println!("计数:{count}");
};

increment();  // 计数:1
increment();  // 计数:2

// 注意:count 被可变借用,此时不能再有其它引用
// let r = &count;  // 编译错误!count 已被可变借用

move 捕获(Move)

如果闭包的寿命可能比它捕获的变量更长,你需要用 move 关键字强制闭包获取变量的所有权

rust
let name = "Alice".to_string();

// move 强制闭包获取 name 的所有权
let consume = move || {
    println!("已获取:{name}");
};

consume();

// println!("{name}");  // 编译错误!name 已被移动进闭包

move 的典型场景——跨线程传递数据:

rust
use std::thread;

let cities = vec![
    "北京".to_string(),
    "上海".to_string(),
    "广州".to_string(),
];

// spawn 要求闭包为 'static,因此必须 move
let handle = thread::spawn(move || {
    // cities 的所有权被移入新线程
    for city in &cities {
        println!("处理城市:{city}");
    }
});

handle.join().unwrap();
// cities 不再可访问

move 并不禁止捕获变量被借用——如果 move 闭包捕获了一个引用,它获取的是那个引用的所有权(即拷贝引用本身),而不是引用指向的数据:

rust
let data = vec![1, 2, 3];
let borrow = &data;  // borrow 是 &Vec<i32>

let closure = move || {
    // 闭包获取了 borrow(引用)的所有权,而不是 data
    println!("{:?}", borrow);
};

闭包 Trait 层级:FnOnce、FnMut、Fn

每个闭包都实现了标准库中三个 trait 中的一个或多个。这三个 trait 构成了一个层级关系:

Fn (可以无限次调用,只借用不可变引用)
  └── FnMut (可以多次调用,借用可变引用)
       └── FnOnce (至少可以调用一次,可能消耗捕获值)

换句话说:每个 Fn 也是 FnMut;每个 FnMut 也是 FnOnce

FnOnce

FnOnce 是可以被调用一次的闭包。它的 call_once 方法消耗 self(获取闭包的所有权):

rust
pub trait FnOnce<Args> {
    type Output;
    fn call_once(self, args: Args) -> Self::Output;
}

一个典型的 FnOnce 闭包消耗它捕获的值:

rust
let name = "Alice".to_string();

// 这个闭包将 name 移出自己(消耗了捕获的值)
let consume = || {
    let s = name;  // 将 name 移出闭包
    println!("已消耗:{s}");
};

// consume() 只能调用一次
// consume.call_once()——内部调用
// 第二次调用会编译失败
consume();
// consume();  // 编译错误!

所有闭包都实现了 FnOnce——因为任何闭包都至少可以被调用一次。

FnMut

FnMut 是可以被多次调用且可能修改捕获变量的闭包。它接受 &mut self

rust
pub trait FnMut<Args>: FnOnce<Args> {
    fn call_mut(&mut self, args: Args) -> Self::Output;
}
rust
let mut counter = 0;

let mut increment = || {
    counter += 1;
    counter
};

assert_eq!(increment(), 1);
assert_eq!(increment(), 2);
assert_eq!(increment(), 3);
// increment 实现了 FnMut——多次调用,每次修改 counter

重要细节:FnMut 闭包必须是 mut 声明的(let mut closure = ||...),因为调用它需要 &mut self

Fn

Fn 是可以无限次调用且不修改捕获变量的闭包。它接受 &self

rust
pub trait Fn<Args>: FnMut<Args> {
    fn call(&self, args: Args) -> Self::Output;
}
rust
let factor = 3;
let triple = |n| n * factor;  // 只读取 factor,不修改

assert_eq!(triple(5), 15);
assert_eq!(triple(10), 30);
// triple 实现了 Fn——可以安全地多次共享调用

Fn 闭包等同于函数指针,但更灵活。它们不消耗也不修改捕获的变量。

Trait 对捕获模式的对应关系

闭包实现哪个 trait 由它如何捕获变量决定:

捕获方式实现的 trait
只读取(共享引用)Fn + FnMut + FnOnce
修改(可变引用)FnMut + FnOnce
消耗(移动所有权)只实现 FnOnce
不捕获任何变量Fn + FnMut + FnOnce

需要哪个 trait 完全由闭包自身的行为决定——你不需要手动标注。

闭包作为函数参数

使用泛型和 trait 约束接受闭包作为参数:

rust
// 接受 FnOnce——最宽松,只能调用一次
fn apply_once<F>(f: F) -> i32
where
    F: FnOnce() -> i32,
{
    f()
}

// 接受 FnMut——可以调用多次,可修改状态
fn apply_mut<F>(mut f: F) -> i32
where
    F: FnMut(i32) -> i32,
{
    let a = f(1);
    let b = f(2);
    a + b
}

// 接受 Fn——可以共享调用
fn apply<F>(f: F) -> i32
where
    F: Fn(i32) -> i32,
{
    f(1) + f(2)
}

使用 impl Trait 语法可以简化签名:

rust
fn call_twice(f: impl Fn(i32) -> i32, value: i32) -> i32 {
    f(f(value))
}

let add_one = |x| x + 1;
let double = |x| x * 2;

assert_eq!(call_twice(add_one, 0), 2);   // (0+1)+1 = 2
assert_eq!(call_twice(double, 2), 8);    // (2*2)*2 = 8

选择哪个 trait 约束的原则:让你的函数尽可能限制少。只调用一次就用 FnOnce,多次调用就用 FnMut,需要并发调用就用 Fn

返回闭包

从函数返回闭包需要注意:不同闭包有不同的匿名类型(大小未知),必须用 Box<dyn Fn(...)> 返回 trait 对象:

rust
fn make_adder(x: i32) -> Box<dyn Fn(i32) -> i32> {
    Box::new(move |y| x + y)
}

let add_five = make_adder(5);
assert_eq!(add_five(3), 8);
assert_eq!(add_five(10), 15);

move 在这里是必要的——x 是函数参数,函数返回后 x 会被释放,闭包必须获取 x 的所有权。

当闭包不捕获变量时,可以直接返回函数指针 fn

rust
fn make_function() -> fn(i32) -> i32 {
    |x| x * 2  // 不捕获任何变量,可以返回为 fn 指针
}

let f = make_function();
assert_eq!(f(5), 10);

闭包 vs 函数

方面函数 (fn)闭包
类型函数指针,大小已知匿名结构体,每个闭包类型不同
捕获变量不能捕获可以捕获
作为参数fn(T) -> Uimpl Fn(T) -> UBox<dyn Fn>
返回简单(函数指针)需要 Box<dyn Fn>
泛型实例化不参与(指针大小固定)每个闭包一个单态化版本

何时使用闭包:

  • 需要捕获上下文变量时——闭包是唯一选择
  • 短小的回调函数——闭包更简洁
  • 配合迭代器适配器——闭包是惯用法

何时使用函数:

  • 需要命名和复用时
  • 作为 trait 的关联函数时
  • 需要从函数返回时不希望堆分配时
  • 需要传递给 FFI(C 函数不认 Rust 闭包)

性能:零成本抽象

Rust 闭包的运行时性能几乎等同于手写的循环或函数调用。背后原理:

  1. 闭包编译为结构体:每个闭包被编译为一个匿名结构体,捕获的变量是该结构体的字段
  2. 调用即方法调用:调用闭包等价于调用该结构体的 .call() 方法
  3. 编译器内联:大多数情况下编译器会将闭包调用完全内联
rust
// 你写的代码
let factor = 2;
let doubled: Vec<i32> = (1..=5).map(|x| x * factor).collect();

// 编译器大致生成的等价代码
struct AnonymousClosure { factor: i32 }
impl AnonymousClosure {
    fn call(&self, x: i32) -> i32 { x * self.factor }
}
// ... map 适配器持有 AnonymousClosure,每次 next 时调用 .call()

闭包的内存分配:

  • 不捕获变量的闭包:零大小,可以强制转换为 fn 函数指针
  • 捕获引用的闭包:大小的就是引用的大小(8 字节)
  • 捕获多个/大值的闭包:大小的就是所有捕获值的大小之和
  • 只有在装箱(Box<dyn Fn>)或用 Vec 收集时才涉及堆分配

Copy 与 Clone 对闭包

闭包的 Copy/Clone 行为与普通结构体一致:

rust
let s = "hello";  // &str 是 Copy
let c1 = || println!("{s}");  // 只捕获了 Copy 类型
let c2 = c1;  // c1 被复制(Copy),而非移动
c1();  // 仍然可用
c2();  // 也可用

let name = String::from("world");
let c3 = || println!("{name}");  // 捕获了 String(非 Copy)
let c4 = c3;  // c3 被移动
// c3();  // 编译错误!c3 已被移动

// move 闭包且所有捕获值都是 Copy 时,闭包也是 Copy
let n = 42;
let c5 = move || n;  // n 是 Copy,所以 c5 也是 Copy

使用 trait 对象存储异质闭包

当你需要在一个容器中存储不同类型的闭包时(例如回调注册表),使用 Box<dyn Fn(...)>

rust
struct Router {
    routes: Vec<(
        String,
        Box<dyn Fn(&Request) -> Response>,
    )>,
}

struct Request { path: String }
struct Response { status: u16, body: String }

impl Router {
    fn new() -> Self {
        Router { routes: Vec::new() }
    }

    fn add_route<F>(&mut self, path: &str, handler: F)
    where
        F: Fn(&Request) -> Response + 'static,
    {
        self.routes.push((
            path.to_string(),
            Box::new(handler),
        ));
    }

    fn handle(&self, req: &Request) -> Option<Response> {
        for (path, handler) in &self.routes {
            if req.path.starts_with(path) {
                return Some(handler(req));
            }
        }
        None
    }
}

fn main() {
    let mut router = Router::new();

    router.add_route("/api/users", |req| Response {
        status: 200,
        body: format!("用户列表 from {}", req.path),
    });

    router.add_route("/api/health", |_req| Response {
        status: 200,
        body: "OK".into(),
    });

    let req = Request { path: "/api/users/123".into() };
    let resp = router.handle(&req).unwrap();
    assert_eq!(resp.status, 200);
}

对比:如果用函数指针 fn(&Request) -> Response,则所有 handler 必须是同质函数(不能捕获变量),灵活度大大降低。

实战案例

惰性求值

HashMapentry API 配合 or_insert_with 实现惰性计算:

rust
use std::collections::HashMap;

fn main() {
    let mut word_counts: HashMap<String, usize> = HashMap::new();
    let text = "the quick brown fox jumps over the lazy dog";

    for word in text.split_whitespace() {
        // 只在 key 不存在时才插入默认值 0
        word_counts.entry(word.to_string())
            .and_modify(|count| *count += 1)
            .or_insert(1);
    }

    for (word, count) in &word_counts {
        println!("{word}: {count}");
    }
}

Option 方法链

Optionmapand_thenunwrap_or_else 等方法都接受闭包:

rust
fn parse_config(value: Option<&str>) -> Option<u32> {
    value
        .map(|s| s.trim())
        .filter(|s| !s.is_empty())
        .and_then(|s| s.parse::<u32>().ok())
        .map(|n| n * 1000)
}

assert_eq!(parse_config(Some("42")), Some(42000));
assert_eq!(parse_config(Some("")), None);
assert_eq!(parse_config(None), None);

资源管理的 RAII 模式

闭包可以配合 Drop 实现临时的资源管理:

rust
fn with_temp_dir<F, T>(f: F) -> std::io::Result<T>
where
    F: FnOnce(&std::path::Path) -> std::io::Result<T>,
{
    let dir = std::env::temp_dir().join(format!("rust_work_{}", std::process::id()));
    std::fs::create_dir(&dir)?;

    // RAII: 离开时自动清理
    struct Cleanup(std::path::PathBuf);
    impl Drop for Cleanup {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }
    let _guard = Cleanup(dir.clone());

    f(&dir)
}

小结

  • 闭包语法 |params| body 紧凑而强大,类型由编译器自动推断。move 关键字强制闭包获取捕获变量的所有权。
  • 捕获模式 由 Rust 根据闭包体自动选择:共享引用、可变引用或移动所有权。这是 Rust 所有权系统在闭包上的体现。
  • Fn 层级 三个 trait 描述了闭包如何消费捕获变量:Fn(共享调用,最安全)是 FnMut(可变调用)的子 trait,FnMutFnOnce(消耗性调用)的子 trait。选择参数约束时应遵循“最宽松原则”。
  • 零成本抽象 闭包编译为普通结构体,调用等价于方法调用,编译器可完全内联。仅在 Box<dyn Fn>Vec 收集时才涉及堆分配。
  • 异质存储Box<dyn Fn(...) + 'static> 可以在运行时存储不同类型的闭包,实现回调注册、事件分发等模式。函数指针 fn 不支持捕获,但更轻量。