< 返回我的博客

北海 发表于 2023-04-14 18:56

Tags:Fn、FnMut、FnOnce

Fn、FnMut、FnOnce的困惑

初闻这三父子,可能会觉得没什么回事,没啥难的。再在实战中遇到这三父子,竟被其折磨的发狂,明明一个FnMut声明的,死活加move也不是,不加move也不是。。唯有感慨一句Rust编译器为何如此不给面子,匆匆再回首查阅各种Fn/FnMut/FnOnce的介绍文档,一脸雾水。。

经过一番恶战(恶补),终于对Fn/FnMut/FnOnce有了个系统的正确认知,在此总结分享下,也希望能帮后面的小伙伴少走些弯路。

在此之前,还是有必要再回顾一下Fn、FnMut、FnOnce的特征:

  • FnOnce约束是call_once(self),只能调用一次,一旦调用,Closure将丧失所有权
  • FnMut是call_mut(&mut self),能调用多次,每次调用Closure的内部状态会变化
  • Fn是call(&self),能多次调用,每次调用Closure不变

看起来挺简单的,搭配上迷惑的move,遇到各类的捕获变量,在实战时挑战就接踵而来了...

本文根据博主自己的经验总结一套如何判定使用Fn/FnMut/FnOnce的方法:

窍门:先确定闭包捕获变量存储区的特性,再分析闭包函数行为如何使用捕获变量的。

Fn/FnMut/FnOnce三父子是闭包,所谓闭包是有一块内存区域,保存着捕获变量,捕获变量是按不可变引用,还是按可变引用,或者所有权转移的方式,对闭包的特性起着很大的决定性作用。然后,复制语义和移动语义的所有权转移的影响是不同的,有时候转移了所有权闭包反而更自由了。最后,闭包捕获变量存储区的特性是确定了,就看闭包行为如何使用这些闭包捕获变量了。

  • 如果是不可变引用的方式捕获的,那肯定是Fn了
  • 如果是可变引用捕获的,可能是FnMut,也可能是Fn,得再看闭包行为
    • 如果闭包行为只是“不可变引用”式的使用捕获变量,那还是Fn(话说回来,这就退化成不可变引用捕获了)
    • 如果闭包行为改变了捕获变量,那就是FnMut
  • 如果是所有权转移捕获的,可能是FnOnce,也可能是FnMut,也可能是Fn
    • 如果捕获的是复制语义的变量,是Fn
    • 如果捕获的是移动语义的变量,在看闭包行为
      • 如果闭包行为没消费转移走所有权,那就还是Fn/FnMut
      • 如果闭包行为消费转移走了所有权,那才是FnOnce

须知,所有权的捕获方式,并非move关键字就能决定的,这是迷惑之一。没move关键字,闭包怎么个捕获的规则,也是迷惑之一。

不可否认,首先还是得看有没有move关键字,有move关键字肯定是强制所有权转移,就算是i32,也会被转移进来;其次,还得看闭包行为,就算没move关键字,也可能是所有权转移捕获。move关键字其实并没有让Fn/FnMut/FnOnce三父子的区别更清晰易辩,反而要进一步借助捕获变量类型、闭包行为层层加码分析才行,让三父子的区分变得更加扑所迷离,迷之又迷。

若没有move关键字,自动判定变量的捕获方式根据如下规则:

  1. 若捕获变量是复制语义,即实现了Copy,比如i32,则按引用捕获,可变引用还是不可变引用,看闭包对该变量的使用行为;
  2. 若捕获变量是移动语义,即没实现Copy,再看闭包行为
  • 2.1. 若是没消费变量所有权,就算是改变了变量,都按引用捕获
  • 2.2. 若是消费了变量所有权,则按所有权捕获

从规则1和2.1看,没move关键字,不轻易捕获所有权,这是编译器默认行为;此时用了move关键字,才是捕获所有权。从规则2.2看,说明move对捕获所有权并非必要,没move也能捕获所有权。以上说明move关键字重点在于体现出一个“强制”的意思。下面再列几个常见误区,详解下。

误解一:认为没move关键字,就一定是引用捕获

本身捕获也是隐式捕获,如果没用move关键字,那就是引用捕获?那可不一定,move关键字在闭包前的真正作用是强制采用所有权捕获,关键在强制二字,相当于显式地告知要move;反过来讲,如果没有move关键字,也有可能是所有权捕获的。比如:

let s = "hello".to_string();
let c = || {
    println!("s is {:?}", s);
    s // 有这行后,主动对s的所有权进行了消费转移,就对s进行了所有权捕获;
      // 没这行则会变成不可变引用捕获
};

误解二:认为有了move关键字,所有权捕获型的,就一定是FnOnce

不一定,还得看捕获的类型,复制语义和移动语义的大不相同:

  • 如果move的是一个i32,那闭包里存个i32,复制语义的,多次调用也没问题,俨然一个Fn
  • 如果move的本身是一个引用,那就看闭包行为会不会改变它,改变它了,是一个FnMut
let mut x = 5;
let mut c1 = move || x += 1;  // 有move却是复制捕获,闭包更自由了,能多次调用还是随便Copy,Fn
let mut c2 = || x += 1; // 没move,默认可变引用捕获,FnMut
  • 如果move的是一个所有权对象,如String,那也得看闭包行为怎么用这个对象,是只读式的,还是消费掉所有权
let s = "hello".to_string();
let c = move || println!("s is {:?}", s); // 虽然是所有权捕获,但依然可以多次调用,Fn
c();
c();

误解三:对闭包变量使用mut修饰,就认为是FnMut

let s = "hello".to_string();
let mut c = || println!("s is {:?}", s);  // 除了编译器会提供一个多余的mut告警,c还是Fn

误解四:认为Fn/FnMut/FnOnce跟Copy/Clone有关系

Fn/FnMut/FnOnce只是限定了可否重复调用,以何种方式调用,即调用时是否改变了闭包存储区的状态。至于能不能Copy/Clone,是另外一回事。

Fn/FnMut/FnOnce是否实现了Copy/Clone,完全看闭包捕获变量存储区的特征,把它当成一个结构体,就清楚了。

let s = "hello".to_string();
let c1 = move || println!("{s}");  // Fn,但没有实现Copy,因闭包捕获变量存储区有所有权

let mut x = 5;
let mut c = || x += 1; // FnMut,但没实现Copy,因为可变引用,要满足**可变不共享**规则

误解五:认为能够满足FnOnce限定的就一定是FnOnce

有时函数参数要求是一个FnOnce的闭包,一看能传递进去当参数,就以为该闭包是FnOnce,其实并不是。

本文一开始称Fn/FnMut/FnOnce为三父子,就是他们之间的“继承”关系:Fn 继承于 FuMut 继承于 FnOnce,所以:

  • 任何闭包,都可以满足FnOnce限定,因为Fn/FnMut都可以调用多次,更不怕FnOnce只调用一次了。只是以FnOnce调用一次,会消耗掉闭包所有权,只有Fn闭包必须是具备Copy/Clone特质的才可能没丢掉所有权!
// 以FnOnce的方式,调用FnMut的内部实现模拟
fn call_once(self, ...) {
    call_mut(&mut self, ...);
}
  • Fn也同样满足FnMut限定,因为Fn本身不改变捕获变量,更不怕以&mut self的方式调用了

以为上手了,好戏才刚刚开始..

以上还仅仅是捕获一个变量的,如果是闭包同时捕获多个变量呢?如果捕获多个变量,还使用move关键字,那所有变量都被转移了所有权,情况正慢慢地变得不可控。。但又只想一部分变量转移所有权进闭包,一部分是可变引用捕获,一部分是不可变引用捕获,又该如何?

Rust的确是折磨人的一把好手。好人做到底,送一套解决方案吧:显式地先声明好变量的引用,再move进去这些引用,可保变量不全给转移所有权进去。

let s1 = "hello ".to_string();
let s2 = "world".to_string();

let s2_ref = &s2;  // 关键一句,不像C++ lambda可以主动选择怎么捕获,只能在闭包前先声明好了
let c = move || s1 + s2_ref;

Tip: 以上有任何理解不正确的地方,欢迎大家指正

评论区

写评论
laris 2023-10-17 10:24

昨晚8群大半夜讨论了一晚上,找到一个比文字更容易理解的视频。

一文了解rust中的闭包(下)_哔哩哔哩_bilibili

作者 北海 2023-04-27 17:57

👍

--
👇
苦瓜小仔: RA 已经可以计算哪种 Fn* 了:https://github.com/rust-lang/rust-analyzer/pull/14470

苦瓜小仔 2023-04-24 09:57

RA 已经可以计算哪种 Fn* 了:https://github.com/rust-lang/rust-analyzer/pull/14470

jerryshell 2023-04-18 18:49

感谢分享,耐心看完了,很有收获

1 共 4 条评论, 1 页