14. Closures — 闭包
闭包是 Rust 最具表现力的特性之一——它们是匿名函数,可以捕获其所在作用域中的变量。Rust 的闭包设计追求零成本抽象:闭包编译为普通的结构体,没有垃圾回收开销,大多数情况下可以完全内联。本章深入探讨闭包的语法、捕获语义、trait 层级以及在实际编程中的最佳实践。
为什么需要闭包
考虑一个排序场景:我们有一个城市列表,想按人口降序排列。Rust 的 Vec 提供了 sort_by_key 方法:
#[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:
// 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 闭包的基本语法是 |参数列表| 表达式:
// 最简形式:没有参数
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 闭包的强大之处在于类型推断——通常你不需要标注参数类型。在下面的例子中,a 和 b 的类型由调用上下文推断:
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); // 错误!如果你必须显式写类型(例如消除歧义),也可以:
let add = |a: i32, b: i32| -> i32 { a + b };捕获变量
闭包可以捕获定义它们的作用域中的变量。Rust 根据闭包体如何使用这些变量自动选择最不具侵入性的捕获方式。
借用捕获(Borrow)
当闭包只读取捕获的变量时,Rust 借用一个共享引用:
let name = "Alice".to_string();
let greeting = || println!("你好,{name}");
// 闭包捕获了 &String(共享引用)
greeting(); // 输出:你好,Alice
greeting(); // 可以多次调用
// name 仍然可用
println!("name 仍然是:{name}");可变借用捕获(Mutable Borrow)
当闭包修改捕获的变量时,Rust 借用一个可变引用:
let mut count = 0;
let mut increment = || {
count += 1;
println!("计数:{count}");
};
increment(); // 计数:1
increment(); // 计数:2
// 注意:count 被可变借用,此时不能再有其它引用
// let r = &count; // 编译错误!count 已被可变借用move 捕获(Move)
如果闭包的寿命可能比它捕获的变量更长,你需要用 move 关键字强制闭包获取变量的所有权:
let name = "Alice".to_string();
// move 强制闭包获取 name 的所有权
let consume = move || {
println!("已获取:{name}");
};
consume();
// println!("{name}"); // 编译错误!name 已被移动进闭包move 的典型场景——跨线程传递数据:
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 闭包捕获了一个引用,它获取的是那个引用的所有权(即拷贝引用本身),而不是引用指向的数据:
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(获取闭包的所有权):
pub trait FnOnce<Args> {
type Output;
fn call_once(self, args: Args) -> Self::Output;
}一个典型的 FnOnce 闭包消耗它捕获的值:
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:
pub trait FnMut<Args>: FnOnce<Args> {
fn call_mut(&mut self, args: Args) -> Self::Output;
}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:
pub trait Fn<Args>: FnMut<Args> {
fn call(&self, args: Args) -> Self::Output;
}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 约束接受闭包作为参数:
// 接受 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 语法可以简化签名:
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 对象:
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:
fn make_function() -> fn(i32) -> i32 {
|x| x * 2 // 不捕获任何变量,可以返回为 fn 指针
}
let f = make_function();
assert_eq!(f(5), 10);闭包 vs 函数
| 方面 | 函数 (fn) | 闭包 |
|---|---|---|
| 类型 | 函数指针,大小已知 | 匿名结构体,每个闭包类型不同 |
| 捕获变量 | 不能捕获 | 可以捕获 |
| 作为参数 | fn(T) -> U | impl Fn(T) -> U 或 Box<dyn Fn> |
| 返回 | 简单(函数指针) | 需要 Box<dyn Fn> |
| 泛型实例化 | 不参与(指针大小固定) | 每个闭包一个单态化版本 |
何时使用闭包:
- 需要捕获上下文变量时——闭包是唯一选择
- 短小的回调函数——闭包更简洁
- 配合迭代器适配器——闭包是惯用法
何时使用函数:
- 需要命名和复用时
- 作为 trait 的关联函数时
- 需要从函数返回时不希望堆分配时
- 需要传递给 FFI(C 函数不认 Rust 闭包)
性能:零成本抽象
Rust 闭包的运行时性能几乎等同于手写的循环或函数调用。背后原理:
- 闭包编译为结构体:每个闭包被编译为一个匿名结构体,捕获的变量是该结构体的字段
- 调用即方法调用:调用闭包等价于调用该结构体的
.call()方法 - 编译器内联:大多数情况下编译器会将闭包调用完全内联
// 你写的代码
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 行为与普通结构体一致:
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(...)>:
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 必须是同质函数(不能捕获变量),灵活度大大降低。
实战案例
惰性求值
HashMap 的 entry API 配合 or_insert_with 实现惰性计算:
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 方法链
Option 的 map、and_then、unwrap_or_else 等方法都接受闭包:
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 实现临时的资源管理:
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,FnMut是FnOnce(消耗性调用)的子 trait。选择参数约束时应遵循“最宽松原则”。 - 零成本抽象 闭包编译为普通结构体,调用等价于方法调用,编译器可完全内联。仅在
Box<dyn Fn>或Vec收集时才涉及堆分配。 - 异质存储 用
Box<dyn Fn(...) + 'static>可以在运行时存储不同类型的闭包,实现回调注册、事件分发等模式。函数指针fn不支持捕获,但更轻量。