理解 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 函数的传入参数有三种形式

  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,表示此闭包只能执行一次。
  2. FnMut :表示此闭包调用时会对环境变量进行可变借用,可能会修改环境变量
  3. Fn : 表示此闭包调用时会对环境变量进行不可变借用,不会修改环境变量

并且,一个闭包可以同时实现多个 traits。比如实现了 Fn 的闭包也一定实现了 FnOnce (后续解释)。

上面是从“对环境变量如何处理所有权” 来解释三个 traits,大部分教程也是这么写,但个人并不推荐完全按这样去理解。因为上述表述中,三个 traits 看起来是互不重叠的(实际并非如此),导致可能会出现这样的疑问:

“实现了 Fn 的闭包说是对环境变量进行了不可变借用,那怎么还能同时实现 FnOnce ,去获取环境变量的所有权呢?到底是仅仅借用了,还是获取了所有权呢?”

但是看三个 traits 的源代码,可以直接回答上述问题:是不可变借用。虽然确实也实现了 FnOnce ,但并没有调用 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

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 所有权被转移
}

2. 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);
}

3. 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;
}

这是非常合理的,对应着借用的规则

在同一时间点,对于同一个变量,要么只能有一个可变借用(FnMut),要么只能有多个不可变借用(Fn)。

至于 FnOnce,访问权限这么大,还想 Copy?

一些建议

如果不知道到底应该标注哪一个 trait,建议先标注 Fn ( 权限最小的 trait),由编译器提示后再进行修改。

另外,闭包的所有权部分并不推荐背书,尤其不推荐总结为正交规则。三个 traits 的区别与联系在代码层面非常简单且容易分析,总结为正交规则反而是把简单的事情复杂化,而且难记。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值