SANSUI'S BLOG

系统外观
分类标签
RSS
Sansui 2023
All rights reserved
人活着就是为了卡卡西

理解 Rust 闭包与环境变量所有权

6 月 14 日, 2022

本文将以(自认为)最简单易懂的方式讲述 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 函数的传入参数有三种形式

  1. 所有权 move(默认行为)。
  2. 可变借用,形式为 &mut param
  3. 不可变借用 ,形式为 &param

上述为 rust 所有权基础知识,不再赘述。

普通的 rust 函数可以使用参数,但无法使用环境变量。闭包则加上了 捕获当前环境变量 的功能。

捕获当前环境变量 仅仅是指闭包 “知道有哪些环境变量”。闭包在使用环境变量时,依然可能会对环境变量执行三种操作:

  1. 所有权 move
  2. 可变借用
  3. 不可变借用

具体是执行了哪种操作呢?这个问题就比较复杂了,我们可以从上面的例子出发。

回顾上面的例子,对于环境变量 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 如下(划重点,请背下来):

  1. FnOnce:表示此闭包调用时会获取环境变量所有权(所有权 move)。因此取名 FnOnce,表示此闭包只能执行一次,因为再次执行时,环境变量可能由于之前所有权 move 过,已经没法用了。
  2. FnMut :表示此闭包调用时会对环境变量进行可变借用,可能会修改环境变量
  3. 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 是根据你的闭包写法决定的。

  1. 实现FnOnce

所有的闭包都自动实现了 FnOnce 。不用特别做什么。

但更普遍的情况是,定义闭包时会顺带实现 Fn 或者 FnMut 。如果想要只实现 FnOnce,不要实现另外两个,需要用 move 。这个关键字会强制转移所有权,使闭包无法满足 FnMutFn 的条件。

  • 例:只实现了 FnOnce 的闭包
fn main() {
    let x = [1,2,3];
    
    let closure2 = move |z|{z == x.len()}; // 只实现了 FnOnce,所有权转移
    closure2(2);
    
    println!("{:?}", x); // 报错,x 所有权被转移
}
  1. 实现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);
}
  1. 实现Fn

在闭包中访问外部变量,不做任何修改,即实现了 Fn (自然也实现了 FnMutFnOnce)。

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,FnMutFnOnce 是所有权 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 的区别与联系在代码层面非常简单且容易分析,总结为正交规则反而是把简单的事情复杂化,而且难记。

如果仍然难懂,可评论提出,后续改进。

更新于 2022-06-14 08:00