Rust惑点启示系列(八):奇形怪状的Rust闭包

发布时间:2024-10-16 21:33
最后更新:2024-10-16 22:47
所属分类:
Rust

闭包也是Rust中使用量很大的数据结构了,闭包的使用可以给程序的逻辑处理过程带来很多便利,但是也带来了不少的复杂性。Rust中的闭包类型有三种,这相对其他语言来说就已经十分复杂了,也让急于上手的人一时间摸不到头脑。本文尝试对闭包和语句块的部分特性做一些讨论。

本文是《Rust惑点启示笔记》系列文章中的第八篇。这个系列的文章主要计划对Rust语言使用过程中经常会出现的一些容易迷惑的惑点进行一个启发式的讨论和记录。

本系列专题还有以下文章:

  1. Rust惑点启示系列(一):避免随意使用Clone
  2. Rust惑点启示系列(二):从函数中返回一些东西
  3. Rust惑点启示系列(三):引用的生命期从来就不够长
  4. Rust惑点启示系列(四):到处都是的大括号
  5. Rust惑点启示系列(五):工具类型太多了
  6. Rust惑点启示系列(六):如何下手编写一个函数
  7. Rust惑点启示系列(七):使用全局变量和单例
  8. Rust惑点启示系列(八):奇形怪状的Rust闭包

不同类型的闭包

在学习完闭包以后,会知道闭包的类型有FnFnMutFnOce三种。

对于这三种类型的闭包,很多教程都是从闭包的特性角度去介绍的。但是往往会让人看着一脸懵。比如FnOnce就被描述为会消耗掉所捕获的值的闭包,而且闭包本身也会被消耗掉;FnMut的描述是包含可修改数据的闭包。那么仅看这些描述,能否在实际编码中正确的选择闭包的类型?

比如在闭包所处的环境中,既有不可变引用,也有可变引用,甚至还有可以转移所有权进入闭包的值,那么这个闭包需要选择什么类型呢?

其实看一下标准库的文档,可以看出来FnFnMutFnOnce之间的区别。对于Fn,标准库文档中有这样一句描述:

Instances of Fn can be called repeatedly without mutating state.

我相信大多数教程可能更多的关注了这句话的前半部分,也就是Fn是可以多次调用的。但是Fn的另一个重要特性是这句话的后半句:不会修改状态。所以Fn的特点是使用不可变引用捕获其上下文中的内容,这样才能保证Fn闭包中是不会对上下文做出任何修改动作的,而且也不会把上下文中值的所有权转移进闭包里。

对于FnMut,标准库文档中的描述是:

Instances of FnMut can be called repeatedly and may mutate state.

根据这句描述,FnMut的确是一个会包含可修改数据的闭包。但是这句话的实际意义是FnMut是会修改它所在的上下文环境的,因为FnMut中是可以做出修改数据的操作的,也就是说FnMut是可以以可变引用的方式捕获上下文内容的。

对于FnOnce,标准库文档中的描述是:

Instances of FnOnce can be called, but might not be callable multiple times. Because of this, if the only thing known about a type is that it implements FnOnce, it can only be called once.

标准库的文档并没有很明确的说明FnOnce具有如何捕获上下文的能力,但是这个描述的后面还有半句话:

FnOnce is implemented automatically by closures that might consume captured variables,

注意这是半句话。这半句话提到了自动实现了FnOnce的闭包可能会消费掉捕获到的变量,所以结合FnFnMut是扩展自FnOnceFn是扩展自FnMut的)的,可以想到,FnOnce会转移上下文中变量的所有权进入到闭包,所以这样才能导致FnOnce只能够被调用一次。

所以这三个闭包的类型总结起来可形成了下面一个对比的表。

Fn FnMut FnOnce
调用能力 多次调用 多次调用 单次调用
捕获变量方式 不可变借用 可变借用 转移所有权
完成捕获的方式 &T &mut T T
对上下文的修改 无修改 有修改 消耗上下文
使用场景 无副作用的回调函数 需要改变上下文的回调函数 用于一次性操作任务或闭包
可接受替代实例 Fn FnFnMut

FnFnMut的亚特征,所以在需要一个FnMut的地方是可以传入Fn实例,FnMutFnOnce的亚特征,所以在需要一个FnOnce的地方是可以传入FnMut实例的,也是可以传入Fn实例的。这个是比较好理解的,FnOnce是会捕获上下文中变量的所有权的,但是FnMutFn捕获的都是可变引用和不可变引用,所需的访问权限小于所有权转移,所以FnOnce的特性是可以满足FnMutFn的需要的。相同的道理也可以用在FnMutFn的关系上。

如果在FnMut中使用&T捕获上下文中的变量,那么这个闭包的特性就跟Fn是一样的,即便闭包的类型是FnMut,也不会捕获变量的可变引用,也就是说FnMut同时支持&T&mut T两种捕获方式,但是实际使用的时候并不会额外捕获。这个原理在FnOnce处也是适用的,即FnOnce同时支持&T&mut TT的捕获方式。

如何选择所需要使用的闭包类型

对于编码时闭包类型的选择,标准库文档中也给出了推荐。

Use FnOnce as a bound when you want to accept a parameter of function-like type and only need to call it once. If you need to call the parameter repeatedly, use FnMut as a bound; if you also need it to not mutate state, use Fn.

这个推荐的意思是,如果你计划这个闭包只被调用一次,那么可以使用FnOnce作为闭包参数类型的约束。如果需要重复调用这个闭包,那么可以使用FnMut作为参数类型的约束。如果还不想让这个闭包修改上下文中的内容,那么可以使用Fn作为约束。

标准库文档的推荐已经说的非常明确了。不过也可以根据上文中对照表中的对于上下文的修改能力来决定使用何种类型的闭包。原则上选择自己代码中所需权限最小的闭包类型即可。

很多闭包为什么要使用move

在很多调用闭包上可能会看到这样的语法:move || {}。不少教程中的解释是显式声明闭包会转移上下文中变量的所有权。其实这个解释并不是很准确,如果看完了上面三种闭包类型的区别,应该可以想到,move关键字是显式强制声明使用FnOnce类型的闭包了。也就是说此时在闭包中使用的一定是所有权转移后的变量。

例如以下示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
fn main() {
  let x = Stirng::new("Hello");

  let cs_fn = || {
    println!("{}", x); // x在闭包里仅仅是使用了,所以其实际类型是&String。
  };

  let cs_once = move || {
    println!("{}", x); // x被转移到了闭包中,所以x的实际类型是String。
  };
  println!("{}", x); // 这里已经无法访问x了,因为其所有权已经被转移到了闭包中。
  cs_once(); // 此时x已经被转移到了闭包中,此时cs_once闭包还是拥有x的所有权的。
  cs_fn(); // 此时调用仅捕获不可变引用的闭包是无法成功的。
}
闭包中没有使用的变量,是不会被闭包捕获的。而且在没有move关键字的情况下,闭包会根据对于上下文变量的使用方式来决定对上下文变量的捕获方式。

语句块也可以使用move

语句块也是一个表达式,自然也可以捕获变量的所有权,所以使用move来修饰一个语句块,也是显式声明语句块将会将所用到的变量的所有权转移到语句块中。

在语句块上使用move关键字很常见,尤其是在构建一个异步任务或者多线程任务时,因为此时需要确保所有必要的变量都被正确捕获并传输到异步任务或者线程中。

如何避免闭包捕获变量所有权

看到这里,如何避免闭包捕获变量所有权的方法就很显而易见了。以下几种方法在编码时如何选择取舍,需要看程序的需要。

  1. 尽量不要使用move关键字捕获变量的所有权。
  2. 尽量避免使用可能产生转移所有权的函数。
  3. 提前准备变量的引用计数智能指针类型,传递智能指针而不是变量。

索引标签
Rust
所有权转移
闭包