Comprehensive Rust 教程:深入理解 Rust 闭包三大特征(Fn / FnMut / FnOnce)与函数指针
【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust
闭包是 Rust 中最灵活也最常被误解的语言特性之一:闭包拥有编译器生成的匿名类型,却能通过Fn、FnMut、FnOnce三个标准库特征被泛型代码统一调用。本篇以 Google Android 团队开源的 Rust 课程(Comprehensive Rust)中 src/closures/traits.md 为骨架,结合课程配套的 Log Filter 练习 与参考答案,系统讲解三大闭包特征的区别、子类型关系、捕获方式(共享引用 / 独占引用 / move)以及函数指针的转换规则,帮助你掌握「闭包参数到底该声明成哪个特征」这一 API 设计核心问题。
为什么闭包类型无法被命名
在 Rust 中,闭包(closure,又称 lambda 表达式)的类型是匿名的、无法被写出来的。每次写出|x| x + 1,编译器都会生成一个独一无二的内部类型,它甚至带有自动推断的捕获字段。这就是为什么我们无法像声明fn foo(...)那样去声明一个闭包变量——你只能写:
let double_it = |n| n * 2; // 类型由编译器推断,无法手写为了让泛型代码能够接收并调用这些匿名类型,标准库提供了三个"可调用"特征(callable traits):Fn、FnMut和FnOnce。任何闭包都实现了这三个特征中的至少一个,具体的实现取决于它捕获了什么、以及如何捕获。
与之相对,fn(..) -> T(注意小写fn)表示函数指针(function pointer):它可以指向一个普通函数的地址,也可以指向一个什么都不捕获的闭包。函数指针类型是具体、可命名的,但约束也更强——只有"无捕获"的闭包才能转换为函数指针。
三大特征逐个拆解
课程原文 src/closures/traits.md 给出了一个能一次性演示三种捕获形态的完整示例,我们逐段分析。
FnOnce:可以调用一次的特征
FnOnce是三者中最宽松的基准特征。实现它的闭包只能被调用一次——调用本身会"消费"(consume)这个闭包,以及所有被按值捕获(move 捕获)的变量。
fn apply_and_log( func: impl FnOnce(&'static str) -> String, func_name: &'static str, input: &'static str, ) { println!("Calling {func_name}({input}): {}", func(input)) } fn main() { let suffix = "-itis"; let add_suffix = |x| format!("{x}{suffix}"); apply_and_log(&add_suffix, "add_suffix", "senior"); apply_and_log(&add_suffix, "add_suffix", "appendix"); }这里apply_and_log的参数被声明为impl FnOnce(&'static str) -> String,意味着调用方可以传入任意可调用一次的函数对象——这是对调用方最宽松的接口设计。函数体内的func(input)即触发一次调用,由于FnOnce只保证能调用一次,编译器允许它消费捕获值。
FnMut:可以重复调用,但会修改捕获状态
当一个闭包修改了它捕获的变量,它就至少是FnMut。FnMut通过独占引用(exclusive reference)访问闭包对象,因此可以被反复调用,但不能并发调用。
fn main() { let mut v = Vec::new(); let mut accumulate = |x| { v.push(x); v.join("/") }; apply_and_log(&mut accumulate, "accumulate", "red"); apply_and_log(&mut accumulate, "accumulate", "green"); apply_and_log(&mut accumulate, "accumulate", "blue"); }注意这里两件事:
accumulate修改了捕获的v,因此它实现的是FnMut;- 调用方必须使用
&mut accumulate(独占引用)才能调用它,而apply_and_log之所以能接受&mut accumulate,是因为**FnMut是FnOnce的子类型**——它满足FnOnce的"能调用一次"的要求。
当三次调用结束后,v中的内容依次为["red"]、["red", "green"]、["red", "green", "blue"],返回值分别是"red"、"red/green"、"red/green/blue",闭包在多次调用之间保持了内部状态。
Fn:可以共享引用调用,甚至并发调用
Fn是最强的特征:实现它的闭包既不消费也不修改捕获值,只需一个共享引用(shared reference)即可调用。因此它可以被无限次调用,甚至安全地并发调用(例如多线程同时调用)。
fn main() { let suffix = "-itis"; let add_suffix = |x| format!("{x}{suffix}"); apply_and_log(&add_suffix, "add_suffix", "senior"); apply_and_log(&add_suffix, "add_suffix", "appendix"); }上面的add_suffix只以共享引用的方式捕获了suffix,所以它实现Fn。注意调用时传入的是&add_suffix(共享引用),这就是Fn与FnMut在调用方式上的直观差别。
演示 FnOnce 的"一次性消费"
fn main() { let mut v = Vec::new(); let mut accumulate = |x| { v.push(x); v.join("/") }; apply_and_log(&mut accumulate, "accumulate", "red"); apply_and_log(&mut accumulate, "accumulate", "green"); apply_and_log(&mut accumulate, "accumulate", "blue"); let take_and_reverse = |prefix| { let mut acc = String::from(prefix); acc.push_str(&v.into_iter().rev().collect::<Vec<_>>().join("/")); acc }; apply_and_log(take_and_reverse, "take_and_reverse", "reversed: "); }take_and_reverse的闭包体内执行了v.into_iter()——这把v按值移动进了闭包,因此它只实现FnOnce。它被直接按值传给apply_and_log(没有加&),调用一次后闭包连同它捕获的v一起被消费。输出为reversed: blue/green/red(从后往前拼接,正是rev()的结果)。
子类型关系与特征自动推导
课程原文用一个简洁的结论概括了三者的关系:
FnMut是FnOnce的子类型;Fn是FnMut与FnOnce的子类型。
也就是说:凡是需要FnOnce的地方,都可以传入FnMut;凡是需要FnMut或FnOnce的地方,都可以传入Fn。这是一条单向的"能力降级"链:Fn(能力最强,调用方式限制最小)⊂FnMut⊂FnOnce(能力最弱,但限制最多)。
编译器根据闭包实际捕获的内容自动推导它实现哪个(些)特征:
- 只读捕获(共享引用)→ 实现
Fn(同时自然实现FnMut、FnOnce); - 修改捕获(独占引用)→ 实现
FnMut(同时实现FnOnce); - 按值消费捕获(move)→ 只能实现
FnOnce。
此外,编译器还会自动推断Copy与Clone:不捕获任何可变状态的闭包(如add_suffix)可以Copy;捕获了String等类型的闭包(如take_and_reverse)则是Clone。而函数指针(对fn项的引用)始终实现Copy和Fn。
设计建议:该声明哪个特征
这是本节最实战的部分。课程给出了两条方向相反的规则:
写函数(接收闭包的一方)时:能小则小。
当定义一个接收闭包的函数时,如果只调用一次就用
FnOnce;否则用FnMut;实在不行才用Fn。
理由:声明越宽松的特征,调用方越灵活——调用方可以用Fn、FnMut或FnOnce任意形态的闭包满足FnOnce参数,但只有Fn形态的闭包能满足Fn参数。impl FnOnce(...)是最开放的接口签名。
持有闭包(作为调用方)时:能大则大。
当你手里有一个闭包时,最灵活的形式是
Fn(可以传给任意一个接受三种特征之一的消费者),其次是FnMut,最不灵活的是FnOnce。
原因正好反过来:一个Fn闭包可以交给任何消费者,而一个FnOnce闭包只能交给接受FnOnce的消费者。
实战验证:Log Filter 练习中的 Fn 边界
为了让这些原则落地,课程安排了「Log Filter」练习(见 src/closures/exercise.md 与参考答案 src/closures/solution.md)。练习要求基于早上的泛型Logger,实现一个用闭包过滤日志消息的Filter:
pub trait Logger { fn log(&self, verbosity: u8, message: &str); } struct Filter<L, P> { inner: L, predicate: P, } impl<L, P> Filter<L, P> where L: Logger, P: Fn(u8, &str) -> bool, { fn new(inner: L, predicate: P) -> Self { Self { inner, predicate } } } impl<L, P> Logger for Filter<L, P> where L: Logger, P: Fn(u8, &str) -> bool, { fn log(&self, verbosity: u8, message: &str) { if (self.predicate)(verbosity, message) { self.inner.log(verbosity, message); } } } fn main() { let logger = Filter::new(StderrLogger, |_verbosity, msg| msg.contains("yikes")); logger.log(5, "FYI"); logger.log(1, "yikes, something went wrong"); logger.log(2, "uhoh"); }这个练习从三个层面印证了本节的知识点:
- 闭包必须用泛型参数存储:
Filter<L, P>中的P是闭包的匿名类型占位。因为每个闭包类型都独一无二且无法命名,所以只能通过泛型(或Box<dyn Fn..>特征对象)来持有。 - 为什么边界必须是
Fn:Logger::log接收的是&self,在log内部我们只能不可变地访问self.predicate。若把边界改成FnMut,就必须把log改成&mut self,这与Logger特征签名冲突;若改成FnOnce,则每次log调用都会消费掉 predicate,整条 Logger 只能记录一条消息。只有Fn能同时满足"重复调用"与"不可变访问"两个约束。这也是上一节"能小则小"原则的反向印证:这里的log(&self)签名强迫我们选择最严格但最安全可行的Fn。 - 调用字段闭包的括号细节:
(self.predicate)(verbosity, message)中包围self.predicate的括号是必需的,否则编译器会把它解析成调用一个名为predicate的方法,而不是调用这个字段本身。注意new的impl块也带上边界有助于类型推断。
运行时输出只有一条消息verbosity=1: yikes, something went wrong,因为 predicate|_verbosity, msg| msg.contains("yikes")过滤掉了不含 "yikes" 的日志。
闭包捕获机制速览
要完整理解三大特征,还需明白捕获的默认规则(详见课程的 src/closures/capturing.md):
- 闭包默认按引用捕获环境变量,且会为每个变量选择最低要求的访问形式:优先共享引用,其次独占引用,最后才按值移动(move)。
- 如果闭包体修改了捕获变量(如
max_value += 1),它就会被捕获为独占引用,特征随之升级为FnMut。 - 使用
move关键字(写作move |v| ..)可以强制按值捕获,这在闭包需要活得比捕获值更久(涉及生命周期)时非常有用——例如在std::thread::spawn(move || ...)中把变量移入新线程,这在课程的并发章节(如 src/concurrency/channels/bounded.md)中被大量使用。 - 不捕获任何变量的闭包可以零成本转换为函数指针
fn(..) -> T(参考 src/closures/syntax.md),此时它就实现了Fn与Copy。
小结
| 特征 | 捕获形态 | 调用方式 | 可调用次数 | 并发安全 |
|---|---|---|---|---|
FnOnce | 按值(move)消费捕获 | 按值传入 | 仅一次 | —— |
FnMut | 独占引用修改捕获 | &mut | 多次 | 不可并发 |
Fn | 共享引用只读捕获 | & | 无限次 | 可并发 |
记忆要点:写函数收闭包选FnOnce→FnMut→Fn的顺序从宽到严;持有闭包交出去选Fn最灵活。这一章是后续学习Iterator、thread::spawn、异步任务(tokiospawn 要求Send + 'static)等高级特性的基石——理解了捕获与特征推导,你就理解了为什么某些闭包能跨线程、某些不能、某些只能调用一次。
课程中这一节属于 Day 2 的闭包模块(完整目录见 src/SUMMARY.md),与syntax.md、capturing.md、exercise.md共同构成一套完整的闭包教学单元。
【免费下载链接】comprehensive-rustThis is the Rust course used by the Android team at Google. It provides you the material to quickly teach Rust.项目地址: https://gitcode.com/GitHub_Trending/co/comprehensive-rust
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考