< 返回我的博客

爱国的张浩予 发表于 2022-03-06 19:01

Tags:closure

原创:以新视角,解读【闭包】

附有丰富的 [例程]

概要

  1. rust【闭包】在内存里被保存为【结构体】。
  2. 闭包不同于函数之处就是:闭包能够捕获【外部变量】为已所用。
  3. 闭包捕获【外部变量】会(条件地)导致【外部变量】在闭包外就不能再被访问了。
    • 对应正文中提到的【捕获方式】决定【外部变量】生命周期。
  4. 闭包业务代码使用【外部变量】也会(条件地)导致【闭包】自身只能被执行一次。
    • 对应正文中提到的【处理方式】决定【闭包】的执行次数。

概括地讲,我这篇文章就是总结了上述(3)(4)项中提到的“条件”关系于一张表格,并基于该表格展开论述。

准备知识【闭包是以什么样的数据结构被管理】

  1. 在代码编译过程中,每遇到一个【闭包】定义(比如,let test = || println!("生成类 + 实例化 + 变量绑定 一条龙服务");),rustc就会为该【闭包】连续做如下几件事情
    1. 生成一个全新的、匿名的、实现了Fn / FnMut / FnOnce trait之一的struct(类型)--- 下文皆称其为【闭包struct
    2. 立即实例化此【闭包struct】的一个实例。
    3. 将该【闭包struct】实例绑定给【变量绑定语句】等号=左侧的具名变量(比如,上面例子中的test)。
      • 变量里保存的是【闭包struct】的实例instance,而不是【闭包struct】的类型type
      • 所以,一旦【闭包】被定义,那么由该【闭包】所捕获的【外部变量】就已经被“锁定”了。即,【闭包】对其【外部变量】生命周期的“负面”影响是从 【闭包】被定义的那个时间点就开始了,而不是从【闭包】被第一次调用执行时算起的(这个timing要更晚一些)[例程1]
  2. 被生成【闭包struct】的【成员方法Fn::call / FnMut::call_mut / FnOnce::call_once】封装了【闭包】要执行的业务逻辑。
  3. 被生成【闭包struct】的若干【字段】保存了被【闭包】【捕获】的外部变量。而具体内容
    • 既可以是外部变量的引用 --- 按【引用】捕获。
    • 也可能是外部变量的值 --- 按【值】捕获,也被称为“捕获【外部变量】【所有权】”。

小结: 因为,在不同的代码上下文中,

  • 闭包捕获的外部变量不同,
  • 闭包内定义的业务逻辑代码也不一样,

所以,每个【闭包】皆对应于一个独一无二的且匿名的struct类型。而所有【闭包struct】的共同点就是:

  • 它们至少都实现了FnOnce trait

  • 它们可实现Fn / FnMut / FnOnce trait中的多个。比如,

    • FnMut + FnOnce trait
    • Fn + FnMut + FnOnce trait
  • 它们都是Sized(,编译时即明确了大小),而不是DST

  • auto trait扩散规则】也适用于【闭包】和【外部变量】之间的关系。即,只有当由【闭包】捕获的每一个【外部变量】皆是Copy / Clone / Send / Send trait,【闭包】自身才会是Copy / Clone / Send / Send trait的,因为被捕获的【外部变量】会被保存为【闭包struct】的字段。额外的闭包类型规则也有:

    image

    所以,【闭包struct】既有可能支持多实例(Copy / Clone trait),同时也有潜力在多个线程之间来回传输(Send / Send trait)。

知识点澄清

为了表述更简洁,我含糊过了一个重要的知识点:Fn trait继承自FnMut traitFnMut trait又继承自FnOnce trait。因此,教条地讲,所有的【闭包】都直接或间接地实现了FnOnce trait。在此篇文章内,这个知识点先按下不表。

上干货

虽然Rust Programming Language权威指南是以【闭包】对【外部变量】【捕获方式】的分类为切入点,来讲解【闭包】,但是我发现:若完全依赖这套解释标准,我对某些【闭包】代码的理解会遇到不自恰的尴尬。为了避免“思维-凑数”,我摸索了一套辅助手段来帮助解读【闭包】代码。该方法是以【闭包】业务程序对【外部变量】【处理方式】的分类为起点,进而判断【闭包】的行为特性。

首先,【捕获方式】影响的是【闭包】【外部变量】的生命周期。即,

  1. 在【闭包】生存期内,被捕获的【外部变量】在【闭包】外是否还可以被
    • 【只读-借入】
    • 【可修改-借入】
    • 【所有权-转移】
  2. 在【闭包】被释放drop之后,【外部变量】是否可恢复被
    • 【只读-借入】
    • 【可修改-借入】
    • 【所有权-转移】

其次,【处理方式】描述的是【闭包】业务程序如何使用【外部变量】(是借入,还是所有权转移)。它的影响范围更广,包括:

  1. 对外,决定了【捕获方式】。间接影响了【外部变量】的生命周期。
  2. 对内,决定了该【闭包】能够被调用与执行的次数。

接着,【处理方式】【捕获方式】【〔外部变量〕生命周期】【〔闭包〕执行次数】,这四个要素之间的相互关系可概括为:

  1. 【处理方式】决定【捕获方式】
  2. 【捕获方式】决定【外部变量】生命周期
  3. 【处理方式】决定【闭包】的调用执行次数。
  4. move关键字能开启“后门”:绕过【处理方式】强制设置【捕获方式】,定制【外部变量】生命周期

更形象、详细的描述可被展开为如下表格: 概念核心

对上表内脚注 [1] [2] [3] [4] 的展开解释如下:

  1. [1] 【闭包】的生命周期是从【闭包】被定义开始,直至该【闭包】被最后一次调用执行后,立即结束。

  2. [2] 【闭包】【可修改-借入】【外部变量】要求【闭包struct】实例被以let mut绑定至变量。这是由rust【继承可修改】语言特性决定的。即,若要修改某个struct的字段值,那么该字段所属的struct实例自身必须是可修改的。在这个场景下,被捕获【外部变量】的【可修改-引用】就是【闭包struct】的一个字段。[例程2]

  3. [3] 在【闭包】内,对【外部变量】执行【所有权-转移】的判定标准是:

    1. 必有条件:

      • 【外部变量】是!Copy trait值。

      题外话,为了开启泛型的!Trait语法,需要在程序首行前注入元属性:#![feature(negative_impls)]

    2. 三择一条件:

      • 要么,将该【外部变量】被绑定给【闭包】内的另一个变量,而不使用&, &mut, let ref,let ref mut [例程3]
      • 要么,将该【外部变量】被作为实参传递给某个(以【所有权】变量为入参的)函数调用 [例程4]
      • 要么,调用该【外部变量】实例上的“消耗型consuming”成员方法,从而“消费掉“实例变量自身 [例程5]

      题外话,若【外部变量】是Copy trait值的话,上述三类操作仅会取走【外部变量】值的【复本】,而不是触发变量的【所以权-转移】。

  4. [4] 在【闭包】内,对【外部变量】执行【可修改-借入】的判定

    • 标准一(对应按【唯一只读引用】捕获):[例程6]
      1. 【外部变量】是可修改引用。比如,let x = &mut b;
      2. 【闭包struct】实例被使用let mut绑定至可修改变量。
      3. 在【闭包】业务程序内,对【外部变量】去引用后,重新赋值。比如,*x = true;
    • 标准二(对应按【可修改引用】捕获):[例程7]
      1. 【外部变量】被使用let mut定义为可修改
      2. 【闭包struct】实例被使用let mut绑定至可修改变量。
      3. 在【闭包】业务程序内,对【外部变量】重新赋值

然后,既然已经有【处理方式】决定【捕获方式】的设定,那你是否曾经质疑过move关键字开“后门”的必要性?在如下两个场景里,我们还真需要move强制指定【闭包】对【外部变量】的【捕获方式】。

  1. 被跨线程执行的【闭包】。例如,

    • A线程定义一个【闭包】
    • 将该【闭包】与其捕获的【外部变量】传递给B线程执行。

    在这个场景下,所有的【外部变量】都必须从A线程全量搬移到B线程(变量的【所有权】也就同时被转移了),以避免多线程数据竞争。

  2. 被高阶函数返回的【闭包】[例程7]

    • 当高阶函数执行结束时,高阶函数体内定义的所有局部变量会随着函数在【栈】内的【帧】一起被释放掉。
    • 这会导致【闭包】按【引用】捕获的全部【外部变量】都变成【野指针】。即,【闭包】活着,但【闭包】依赖的外部环境没了。多尴尬,人还在,家没了!

    在这个场景下,【闭包】必须把它所依赖的【外部变量】一起转移走,无论在【闭包】业务代码里是仅只【引用】借入变量,还是“消费掉”变量【所有权】。

最后,我推荐对【闭包】代码解读的思维步骤如下:

  1. 先看【闭包】定义是否有move关键字前缀。
    • 若有,则说明:即便【闭包struct】实例的生命周期结束,我们也不能对其【外部变量】做任何的操作了。
  2. 再看【闭包struct】实例是否被let mut绑定给 可修改变量
    • 若是,则说明:在【闭包struct】实例的生命周期内,我们不能对其【外部变量】做任何的操作了。
  3. 接着,精读【闭包】业务代码。分析【闭包】是否对【外部变量】做了任何的【所有权-转移】操作。
    • 若执行了【所有权-转移】处理,则说明:即便【闭包struct】实例的生命周期结束,我们也不能对其【外部变量】做任何的操作了。
  4. 若上面三个条件均不成立,那么
    • 在【闭包struct】实例的生命周期内,【外部变量】可读(只读-借入)
    • 在【闭包struct】实例的生命周期结束后,我们便可恢复对【外部变量】的完全访问能力。

评论区

写评论
c5soft 2023-03-17 10:34

最后总结那段写得很到位,还通俗易懂。前面的内容有点绕,可能需要进一步展开,结合代码才能完全消化。

ooopSnake 2022-03-08 10:01

写的棒极了,讲解的细致,👍🏻

dlhxzb 2022-03-07 14:00

奈斯!学习了

1 共 3 条评论, 1 页