理解 Rust 闭包与环境变量所有权
本文将以(自认为)最简单易懂的方式讲述 Rust 中的闭包与环境变量所有权的关系。在现存的类似中文教程中,要么语言表述歧义太大,逻辑上难以理清;要么试图把事情总结得过于复杂。实际上闭包对于环境变量所有权的处理规则是非常简单的。
阅读本文需要的基础: Rust 变量的所有权、引用与借用、函数、traits。
什么是 Rust 的闭包
Rust 中的闭包是一种函数。与 Rust 普通函数不同,它可以捕获函数外部的变量并使用。
基本语法:|参数列表| {函数体}
fn main() {
let x = 1;
let sum = |y: i32| { x + y }; // 说明: 闭包 sum 接收一个参数 y,且捕获前面的 x = 1, 返回 x + y
println!("{}", sum(99)); // 输出 100
let sum2 = |y :i32| x + y + 1; // 也可以省略花括号
println!("{}", sum2(99)); // 输出 101
}
说明: 闭包 sum 接收一个参数 y,返回 x + y。其中 x 是第一行定义的
let x = 1;
,为闭包外部的变量。
像 x
这样在定义在闭包外部、可被闭包直接访问的变量,我们称为“环境变量”。
闭包中环境变量的所有权
有 rust 基础的人应该知道,普通的 rust 函数的传入参数有三种形式
- 所有权 move(默认行为)。
- 可变借用,形式为
&mut param
- 不可变借用 ,形式为
¶m
上述为 rust 所有权基础知识,不再赘述。
普通的 rust 函数可以使用参数,但无法使用环境变量。闭包则加上了 捕获当前环境变量 的功能。
捕获当前环境变量 仅仅是指闭包 “知道有哪些环境变量”。闭包在使用环境变量时,依然可能会对环境变量执行三种操作:
- 所有权 move
- 可变借用
- 不可变借用
具体是执行了哪种操作呢?这个问题就比较复杂了,我们可以从上面的例子出发。
回顾上面的例子,对于环境变量 x
,首先排除了所有权 move。
let x = 1;
let sum = |y: i32| { x + y }; // 使用了 x
println!("{}", sum(99)); // 输出 100
let sum2 = |y :i32| x + y + 1; // 再次使用了 x
println!("{}", sum2(99)); // 输出 101
说明:
x
在 sum1 中使用后,还能在 sum2 中再次使用,说明x
所有权没有 move。
实际上,上述例子的 x
在闭包中是作为 不可变借用 使用的,因为这个闭包实现了 Fn
trait。
闭包的三种 traits
闭包是一种函数,它的三种 traits 恰好对应了三种处理所有权的方式。
三种 traits 如下(划重点,请背下来):
FnOnce
:表示此闭包调用时会获取环境变量所有权(所有权 move)。因此取名FnOnce
,表示此闭包只能执行一次,因为再次执行时,环境变量可能由于之前所有权 move 过,已经没法用了。FnMut
:表示此闭包调用时会对环境变量进行可变借用,可能会修改环境变量Fn
: 表示此闭包调用时会对环境变量进行不可变借用,不会修改环境变量
并且,一个闭包可以同时实现多个 traits。比如实现了 Fn
的闭包也一定实现了 FnOnce
(后续解释)。
上面是从“对环境变量如何处理所有权” 来解释三个 traits,大部分教程也是这么写,但个人并不推荐完全按这样去理解。因为上述表述中,三个 traits 看起来是互不重叠的(实际并非如此),导致可能会出现这样的疑问:
“实现了
Fn
的闭包说是对环境变量进行了不可变借用,那怎么还能同时实现FnOnce
,去获取环境变量的所有权呢?到底是仅仅进行不可变借用,还是获取了所有权呢?”
但是看三个 traits 的源代码,可以直接回答上述问题:是不可变借用。虽然确实也实现了 FnOnce
(所有权 move) ,但并没有调用 FnOnce
的 call 函数,而是调用了 Fn
(不可变借用) 的 call 函数。
pub trait Fn<Args> : FnMut<Args> {
extern "rust-call" fn call(&self, args: Args) -> Self::Output;
}
pub trait FnMut<Args> : FnOnce<Args> {
extern "rust-call" fn call_mut(&mut self, args: Args) -> Self::Output;
}
pub trait FnOnce<Args> {
type Output;
extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
}
分析:如果
FnOnce
的 call 函数被调用,则直接传入了self
,也就是获取了当前的环境变量的所有权,自然运行一次后回被销毁。而Fn
的 call 函数传入的是不可变借用&self
。
并且会发现, Fn
的前提是实现了 FnMut
, FnMut
的前提是实现了 FnOnce
。
- 从继承关系来讲:
Fn
继承FnMut
继承FnOnce
- 从访问变量的权限范围来讲:
Fn
<FnMut
<FnOnce
也可以说,闭包就算实现了 FnOnce
也不一定会用到所有权 move,因为可能还实现了 Fn
,那么环境变量的所有权会按 Fn
处理。
由于上述继承关系,如果定义一个普通函数,参数需要传入 FnOnce
,实际上也可以传入 Fn
。
fn fn_once<F>(func: F)
where
F: FnOnce(usize) -> bool, // 传入闭包
{
println!("{}", func(3));
}
fn main() {
let x = vec![1, 2, 3];
let closure = |z|{z == x.len()}; // 此闭包实现了 Fn、 FnMut 和 FnOnce
fn_once(closure); // Fn 可传入标注为 FnOnce 的参数
println!("{:?}", x); // x 还能用,所有权没转移
let closure2 = move |z|{z == x.len()}; // 此闭包只实现了 FnOnce,因为 x 被强制转移所有权到闭包内部
fn_once(closure2); // 传入 FnOnce
println!("{:?}", x); // 报错,x 已经没了
}
说明:fn_once 需要接收
FnOnce
的闭包作为参数,但传入Fn
也是合理的,编译器也会按照Fn
的调用方式处理为不可变借用,并不会因为标注着FnOnce
而变成所有权 move。
闭包对所有权的处理并不会随着标注改变,标注仅仅是为了取悦编译器 ——鲁迅
闭包实现三种 traits
上述例子中,直接标注了闭包实现了三种 traits,但并没有具体说明为什么这么写就是实现了三种 traits。这是本节需要说明的内容。
闭包实现 traits 是隐式的。也就是说,你不用(也没法)标注这个闭包是实现的哪个 traits。具体实现了哪些 traits 是根据你的闭包写法决定的。
- 实现
FnOnce
所有的闭包都自动实现了 FnOnce
。不用特别做什么。
但更普遍的情况是,定义闭包时会顺带实现 Fn
或者 FnMut
。如果想要只实现 FnOnce
,不要实现另外两个,需要用 move
。这个关键字会强制转移所有权,使闭包无法满足 FnMut
和 Fn
的条件。
- 例:只实现了
FnOnce
的闭包
fn main() {
let x = [1,2,3];
let closure2 = move |z|{z == x.len()}; // 只实现了 FnOnce,所有权转移
closure2(2);
println!("{:?}", x); // 报错,x 所有权被转移
}
- 实现
FnMut
在闭包中修改外部变量,即实现了 FnMut
(自然也实现了 FnOnce
),同时没有实现 Fn
。
fn main() {
let mut x = vec![1,2,3];
let mut closure = ||{x.push(4);}; // 修改了外部的 x, 实现了 FnMut, x 所有权没有转移
closure();
println!("{:?}", x);
}
- 实现
Fn
在闭包中访问外部变量,不做任何修改,即实现了 Fn
(自然也实现了 FnMut
和 FnOnce
)。
fn main() {
let s = String::new();
let update_string = || println!("{}",s); // 访问外部的 s, 实现了 Fn
exec(update_string);
exec1(update_string);
exec2(update_string);
}
fn exec<F: FnOnce()>(f: F) { // Fn 也可以传到 FnOnce 类型
f() // 调用的是 Fn,所有权不会转移
}
fn exec1<F: FnMut()>(mut f: F) { // Fn 也可以传到 FnMut 类型
f()
}
fn exec2<F: Fn()>(f: F) {
f()
}
闭包自身的所有权
上述讨论的是闭包对于环境变量的所有权处理。那闭包自己呢?当闭包自己作为变量被传来传去时,是 Copy 还是所有权 Move?
答案是,Fn
是 Copy,FnMut
和 FnOnce
是所有权 Move。
fn main() {
let x = vec![1,2,3];
let closure = |z:usize|{ z == x.len()}; // 实现了 Fn
outter(closure); // 通过
outter(closure); // 通过
let closure2 = |z:usize|{ x.push(4);z == x.len()}; // 实现了 FnMut
outter(closure2); // 通过
outter(closure2); // 报错, closure2 的所有权已被转移
}
fn outter<T>(mut func: T)
where T: FnMut(usize) -> bool { // Fn 可以传到 FnMut 标注的参数上
let a = func;
}
这是非常合理的,对应着 Rust 借用的规则
在同一时间点,对于同一个变量,要么只能有一个可变借用(FnMut),要么只能有多个不可变借用(Fn)。
至于 FnOnce
,对环境变量的访问权限这么大,还想 Copy?只能是所有权 move。
一些建议
如果遇到函数的参数也是一个函数,需要标注 trait 的场景,又不知道到底应该标注哪一个 trait,建议先标注 Fn ( 权限最小的 trait),由编译器提示后再进行修改。
另外,闭包的所有权部分并不推荐背书,尤其不推荐总结为正交规则。三个 traits 的区别与联系在代码层面非常简单且容易分析,总结为正交规则反而是把简单的事情复杂化,而且难记。
如果仍然难懂,可评论提出,后续改进。