Rust编程
首发于Rust编程
闭包

闭包

什么是闭包 closure

闭包(closure),是一种匿名函数,并且具有“捕获”外部变量的能力。闭包有时候也称作 lambda 表达式。Rust中的闭包,基本语法如下所示:

fn main() {

  let add = | a :i32, b:i32 | -> i32 { return a + b; } ;

  let x = add(1,2);
  println!("result is {}", x);
}

对于以上闭包,可以看到,有两个参数,以两个|包围。执行语句包含在 {} 中。闭包的参数和返回值类型指定,与普通函数的语法相同。而闭包的参数和返回值类型都是可以省略的。因此,以上闭包可省略为

let add = |a , b| {return a + b;};

跟普通函数一样,返回值也可以使用语句块表达式完成,与 return 语句的作用一样,因此以上闭包可省略为

let add = |a, b| { a + b };

更近一步,如果闭包的语句体只包含一条语句,那么外层的大括号也可以省略;如果有多条语句,则不能省略。以上闭包可以进一步省略为

let add = |a, b| a + b;

closure看起来和普通函数很相似,然而,它们实际上有许多区别。首先,closure可以“捕获”外部环境变量,fn不可以。示例如下:

fn main() {
  let x = 1_i32;

  fn inner_add() -> i32 {
    x + 1
  }

  let x2 = inner_add();
  println!("result is {}", x2);
}

编译,结果出现编译错误:error: can't capture dynamic environment in a fn item; use the || { ... } closure form instead [E0434]。由此可见,函数inner_add是不能访问变量x的。那么根据编译器的提示,我们改为闭包试试:

fn main() {
  let x = 1_i32;

  let inner_add = || x + 1;

  let x2 = inner_add();
  println!("result is {}", x2);
}

这一次,编译通过。

而对于不需要捕获环境变量的场景,普通函数fn也可以当成closure使用。

fn main() {
  let option = Some(2);
  let new: Option<i32> = option.map(multiply2);
  println!("{:?}", new);

  fn multiply2(val: i32) -> i32{ val*2 }
}

在这个示例中,map方法的签名是:

fn map<U, F>(self, f: F) -> Option<U>
    where F: FnOnce(T) -> U

这里的 FnOnce 在下文中会详细解释。它在此处的含义是,f 是一个闭包参数,类型为 FnOnce(T) -> U,根据上下文类型推导,实际上是 FnOnce(i32)->i32。我们定义了一个普通函数,签名为 fn(i32) -> i32,也一样可以用于该参数中。如果我们用闭包来写,这样也可以:

let new: Option<i32> = option.map(|val| val * 2);

普通函数和闭包之间最大的区别是,普通函数不可以捕获环境变量,在这个例子中,虽然我们的 multiply2 函数定义在 main 函数体内,但是它无权访问 main 函数内的局部变量。其次,fn的使用可以出现在定义位置之前,看起来像是先使用,再声明,这是没问题的。而相对而言,closure更像是一个可以被“调用”的变量。它具有和变量同样的“生命周期”。

变量捕获

接下来我们研究一下 closure 背后的原理。Rust目前的closure实现,又叫做unboxed closure,它背后的原理与C++ 11的 lambda 非常相似。当一个closure创建的时候,编译器实质上帮我们生成了一个匿名struct,通过自动分析closure的内部逻辑,决定该结构体包括哪些数据,以及这些数据该如何初始化。

考虑以下例子:

fn main() {
  let x = 1_i32;
  let add_x = | a | x + a;
  let result = add_x( 5 );
  println!("result is {}", result);
}

我们来思考一下,如果不使用闭包,自己来实现以上逻辑,该怎么做。

struct Closure {
  inner1: i32
}

impl Closure {
  fn call(&self, a: i32) -> i32 {
    self.inner1 + a
  }
}

fn main() {
  let x = 1_i32;
  let add_x = Closure{ inner1: x};
  let result = add_x.call(5);
  println!("result is {}", result);
}

以上这个例子,我们模拟了一个闭包的原理,实际上Rust编译器就是用类似的手法来处理闭包语法的。对比一下使用闭包语法的版本和我们手工实现的版本,我们可以看到,创建闭包的时候,就相当于创建了一个结构体,我们把需要捕获的环境变量存到这个结构体中。闭包调用的时候,相当于调用了跟这个结构体相关的一个成员函数。

但是,还有几个问题没有解决,当编译器把闭包语法糖转换为普通的类型和函数调用的时候:

  1. 结构体内部的成员,应该用什么类型,如何初始化?应该用i32或是&i32还是&mut i32?
  2. call函数调用的时候self应该用什么类型?应该写self或是&self还是&mut self?


理解了这两个问题的答案,就能完全理解了Rust的闭包的原理。

关于第一个问题,Rust主要是通过分析外部变量在闭包中的使用方式,通过一系列的规则自动推导出来的。主要规则如下:

  1. 如果一个外部变量在闭包中,只通过借用指针&使用,那么这个变量就可通过引用&的方式捕获;
  2. 如果一个外部变量在闭包中,通过& mut指针使用过,那么这个变量就需要使用&mut的方式捕获;
  3. 如果一个外部变量中闭包中,通过move的方式使用过,那么这个变量就需要使用“by value”的方式捕获。

简单点总结,就是说规则是,在保证能编译通过的情况下,编译器会自动选择一种,对外部影响最小的类型存储。对于被捕获的类型为T的外部变量,在匿名结构体中的存储方式选择为,尽可能先选择 &T 类型,其次选择 &mut T 类型,最后选择 T 类型。示例如下:

struct T(i32);

fn by_value(_: T) {}
fn by_mut(_: &mut T) {}
fn by_ref(_: &T) {}

fn main() {
  let x: T = T(1);
  let y: T = T(2);
  let mut z: T = T(3);

  let closure = || {
    by_value(x);
    by_ref(&y);
    by_mut(&mut z);
  };

  closure();
}

对于以上闭包,捕获了外部的三个变量x y z。其中,y只通过&T的方式使用了;z通过&mut T的方式被使用了;x通过T的方式被使用了。因此,编译器会根据这些信息,自动生成结构类似这样的匿名结构体:

struct ClosureEnvironment<'y, 'z> {
  x: T,
  y: &'y T,
  z: &'z mut T,
}

move关键字

以上变量捕获的规则都是针对只做为局部变量的闭包而准备的。而有些时候,我们的闭包的生命周期可能会超过一个函数的范围。比如,我们可以将此闭包存储到某个数据结构中,在当前函数返回之后继续使用。这样一来,就可能出现更复杂的情况,在闭包被创建的时候,它通过引用的方式捕获了某些局部变量,而在闭包被调用的时候,它所指向的一些外部变量已经被释放了。示例如下:

fn make_adder(x: i32) -> Box<Fn(i32) -> i32> {
    Box::new(|y| x + y)
}

fn main() {
    let f = make_adder(3);

    println!("{}", f(1)); // 4
    println!("{}", f(10)); // 13
}

大家可以看到,函数make_adder中有一个局部变量x,按照前面所述的规则,它被闭包所捕获,而且应该使用引用&的方式。而闭包则作为了函数返回值被传递出去了。于是,闭包被调用的时候,它内部的引用所指向的内容,已经被释放了。这种情况,应该会出现典型的野指针问题,属于内存不安全的范畴。幸运的是,该程序在Rust中根本无法编译通过,错误信息为:error: closure may outlive the current function, but it borrowsx, which is owned by the current function [E0373]。信息提示非常清晰,我们又可以感谢Rust帮我们发现了一个问题。

那么这种情况,我们应该怎么写才对呢?这里要介绍一个新的关键字 move,用于修饰一个 closure。示例如下:

fn make_adder(x: i32) -> Box<Fn(i32) -> i32> {
    Box::new(move |y| x + y)
    // 注意这里 ^
}

加上 move 关键字后,所有的变量捕获,全部使用by value的方式。也就是说,编译器生成的匿名结构体,内部看起来像是这样的:

struct ClosureEnvironment {
  x: TYPE1,  //
  y: TYPE2,  // 这里没有 &TYPE,&mut TYPE,所有被捕获的外部变量一律move进闭包
  z: TYPE3,  //
}

所以,move 关键字可以改变闭包捕获变量的方式,一般用于闭包需要传递到函数外部 (escaping closure) 的情况。

Fn/FnMut/FnOnce

外部变量捕获的问题解决了,我们再看看第二个问题,闭包被调用的方式。我们注意到,闭包被调用的时候,不需要执行某个成员函数,而是采用类似函数调用的语法来执行。这是因为它自动实现了编译器提供的几个特殊的 trait,Fn或者 FnMut 或者 FnOnce。

它们的定义如下:

pub trait FnOnce<Args> {
    type Output;
    extern "rust-call" fn call_once(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 Fn<Args> : FnMut<Args> {
    extern "rust-call" fn call(&self, args: Args) -> Self::Output;
}

这几个 trait 主要区别在于,调用的时候,self 参数的类型。FnOnce被调用的时候,self是通过move的方式传递的,因此它被调用之后,这个闭包的生命周期就已经结束了,它只能被调用一次;FnMut被调用的时候,self是 &mut Self 类型,有能力修改外部的环境变量;Fn被调用的时候,self 是 &Self 类型,只有读取环境变量的能力。

目前这几个 trait 还处于 unstable 状态,在目前的稳定版编译器中,我们不能针对自定义的类型实现这几个trait,只能在nightly版本中,开启#![feature(fn_traits)]功能。

那么,对于一个闭包,编译器是如何选择impl哪个trait呢? 答案是,编译器会都尝试一遍,实现能让程序编译通过的那几个。闭包调用的时候,尽可能先调用fn call(&self, args:Args)函数,其次尝试调用fn call_mut(&self, args:Args)函数,最后尝试调用fn call_onece(self, args:Args)函数。

示例如下:

fn main() {
    let v: Vec<i32> = vec![];
    let c = || drop(v);
    c();
}

对于上例,drop函数的签名是,fn drop<T>(_x: T),它接受的参数类型是T。因此,在闭包中使用该函数,会导致外部变量v通过move的方式捕获。编译器为该闭包自动生成的匿名类型类似这样:

struct ClosureEnvironment {
  v: Vec<i32> // 这里不是引用
}

对于这样的一个结构体,我们来尝试一下实现一下FnMut trait:

impl FnMut<Vec<i32>> for ClosureEnvironment {
  extern "rust-call" fn call_mut(&mut self, args: Args) -> Self::Output {
    drop(self.v)
  }
}

当然,这是编译不过的,函数体内需要一个Self类型,但是函数参数只提供了&mut Self类型。因此,编译器不会为这个闭包实现FnMut trait。唯一能实现的trait就只剩下了FnOnce。

这个闭包被调用的时候,当然就会调用call_once这个方法。我们知道,fn call_once(self, arg:Args)这个函数调用的时候,self参数是move进入函数体的,会“吃掉”self变量。在此函数调用后,这个闭包的生命周期就结束了。所以,对于FnOnce类型的闭包,它只能被调用一次。FnOnce也是得名于此。我们自己试一下:

fn main() {
  let v: Vec<i32> = vec![];
  let c = || drop(v); // 闭包使用捕获变量的方式,决定了这个闭包的类型。它只实现了`FnOnce trait`。
  c();
  c(); // 再调用一次试试?编译错误 use of moved value: `c`。`c`是怎么被move走的?
}

编译器在处理上面这段代码的时候,做了一个类似这样的展开:

fn main() {
  struct ClosureEnvironment {
    _v: Vec<i32>
  }
  let v: Vec<i32> = vec![];
  let c = ClosureEnvironment { _v: v }; // v move 进了c的成员中
  c.call_once(); // c move 进了 call_once 方法中
  c.call_once(); // c 的生命周期已经结束了,这里的调用会发生编译错误
}

同样的道理,我们试试Fn的情况:

fn main() {
    let v: Vec<i32> = vec![1,2,3];
    let c = || for i in &v { println!("{}", i); };
    c();
    c();
}

可以看到,上面这个闭包捕获的环境变量,在使用的时候只需要&Vec<i32>类型即可。因此它能实现Fn trait。闭包在被调用的时候,执行的是fn call(&self)函数。所以,调用多次也是没问题的。

我们如果给上面的程序添加上move关键字,也依然可以通过:

fn main() {
    let v: Vec<i32> = vec![1,2,3];
    let c = move || for i in &v { println!("{}", i); };
    c();
    c();
}

可以看到,move关键字,只是影响了环境变量被捕获的方式。第三行,创建闭包的时候,变量v被move进入了闭包中。第四行,闭包调用的时候,根据推断规则,依然使用的fn call(&self)函数,因此闭包变量c可以多次调用。

闭包与泛型

我们已经知道,闭包背后是依靠trait来实现的。但是闭包相关的trait跟其它的trait相比,语法上有特殊之处。比如说,我们想让闭包作为一个参数,传递到函数中,可以这么写:

fn call_with_closure<F>(some_closure: F) -> i32
    where F : Fn(i32) -> i32 {
    some_closure(1)
}

fn main() {
  let answer = call_with_closure(|x| x + 2);
  println!("{}", answer);
}

其中泛型参数F的约束条件是F: Fn(i32) -> i32,这里Fn(i32) -> i32是针对闭包设计的专门的语法,而不是像普通trait那样使用Fn<i32, i32>这个样子来写。除了语法之外,Fn FnMut FnOnce其它方面都跟普通的泛型一致。

请大家一定要注意的是,每个闭包,编译器都会为它生成一个匿名结构体类型。即使两个闭包的参数和返回值一致,它们也是 完全不同的两个类型,只是都实现了同一个trait而已。下面我们用一个示例演示:

fn main() {
// 同一个变量绑定了两次。
  let mut closure = |x : i32| -> i32 { x + 2 };
  closure = |x: i32| -> i32 { x - 2 } ;
  println!("{}", closure());
}

编译,结果出错,错误信息为:

error: mismatched types:
 expected `[closure@temp.rs:3:21: 3:47]`,
    found `[closure@temp.rs:4:13: 4:38]`
(expected closure,
    found a different closure) [E0308]

可以看到,我们用同一个变量来绑定两个闭包的时候,发生了类型错误,请大家牢牢记住,不同的闭包是不同的类型。

既然如此,跟普通的trait一样,如果我们需要向函数中传递闭包,有两种方式:

  1. 通过泛型的方式。这种方式会为不同的闭包参数类型生成不同版本的函数,实现静态分派。
  2. 通过trait object的方式。这种方式会将闭包box进入堆内存中,向函数传递一个胖指针,实现运行期动态分派。

关于动态分派和静态分派的内容,将在下篇文章中详细说明。此处只做一个简单示例如下:

fn static_dispatch<F>(closure: &F)  // 这里是泛型参数。对于每个不同类型的参数,编译器将会生成不同版本的函数。
    where F: Fn(i32) -> i32
{
    println!("static dispatch {}", closure(42));
}

fn dynamic_dispatch(closure: &Fn(i32)->i32) // 这里是 `trait object`。`Box<Fn(i32)->i32>`也算`trait object`。
{
   println!("dynamic dispatch {}", closure(42));
}

fn main() {
   let closure1 = | x | x * 2;
   let closure2 = | x | x * 3;
   fn function_ptr(x: i32)->i32 { x * 4 };

   static_dispatch(&closure1);
   static_dispatch(&closure2);
   static_dispatch(&function_ptr); // 普通`fn`函数也实现了`Fn trait`,它可以与此参数类型匹配。`fn`不可以捕获外部变量。

   dynamic_dispatch(&closure1);
   dynamic_dispatch(&closure2);
   dynamic_dispatch(&function_ptr);
}

如果我们希望一个闭包作为函数的返回值。那么就不能使用泛型的方式了。因为泛型类型不在参数中出现,而仅仅在返回类型中出现的话,会要求在调用的时候显式指定类型,编译器才能完成类型推导。可是调用方根本无法指定具体类型,因为闭包类型是匿名类型,用户无法显式指定。所以这样的写法是编译不过的:

fn test<F>() -> F
    where F: Fn(i32)->i32
{
    return | i | i * 2;
}

fn main() {
    let closure = test();
}

所以,有人提出了一份 RFC,希望加入一个新的语法 fn test() -> impl Fn(i32)->i32,这样的语法糖在很多地方可以简化代码的编写。然而这项改变需要考虑的问题太多,暂时还没能达成完全一致。所以,目前来说,唯一的方式就是把闭包装箱进入堆内存中,使用Box< Fn(i32)->i32 >这种类型返回。

fn test() -> Box< Fn(i32)->i32 >
{
    let c = | i: i32 | i * 2;
    Box::new(c)
}

fn main() {
    let closure = test();
    let r = closure(2);
    println!("{}", r);
}

但是,这样做,又会有另外一个问题。那就是,Box 类型和 FnOnce 类型之间的配合不够默契。怎么回事呢?假如我们有一个 Box<FnOnce()> 这样的类型,调用它的时候,会发生编译错误。假设编译器为当前闭包生成的匿名类型名字是 ClosureEnv,那么 Box<ClosureEnv> 类型在调用 call_once 方法的时候,self 的实际参数类型是 Box<Self>,而函数签名要求的形式参数类型却是 Self,会发生类型不匹配问题。

上面用 Box<Fn()> 的情况不会出问题,是因为这种情况下,根据自动 Deref 原则,实际参数类型是 Box<Self> 类型,而形式参数类型是 &Self 类型的话,可以发生自动转换。所以不会有什么问题。

为了处理这样的一个情况,Rust team 又临时性的设计了一个补丁 std::boxed::FnBox trait,专门用于处理这样的情况。假如你需要用 Box<FnOnce()> 类型,请暂时改用 Box<FnBox()> 类型。将来,把上面所说的这个问题比较好的解决掉之后,这个临时性的 trait 就会被删掉。

闭包与生命周期


(高阶生命周期的例子有误,现删除,待以后补充)。


本文同步发布在微信公众号:Rust编程,欢迎关注。

编辑于 2017-11-02

文章被以下专栏收录