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

本文将以(自认为)最通俗易懂的方式讲述 Rust 中的闭包与环境变量所有权的关系。在现存的类似中文教程中,要么语言表述歧义太大,逻辑上难以理清;要么试图把事情总结得过于复杂。实际上闭包对于环境变量所有权的处理规则是非常简单的。

阅读本文需要的基础: Rust 变量的所有权、引用与借用、函数。

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,084评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,623评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,450评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,322评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,370评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,274评论 1 300
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,126评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,980评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,414评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,599评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,773评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,470评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,080评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,713评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,852评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,865评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,689评论 2 354

推荐阅读更多精彩内容