likes
comments
collection
share

Rust 闭包 Closure

作者站长头像
站长
· 阅读数 5

闭包这个词语由来已久,被广泛用于函数式编程语言中,各种现代化的编程语言也都不约而同地把闭包作为核心特性纳入到语言设计中来。闭包是一种匿名函数,可以赋值给变量也可以作为参数传递给其它函数,不同于函数的是,它允许捕获调用者作用域中的值(捕获环境中的自由变量)。换句话说,闭包是由函数和与其相关的引用环境组合而成的实体

Rust 闭包 Closure

fn main() {
   let x = 1;
   let sum = | y | x + y;

    assert_eq!(3, sum(2));
}

上面的代码展示了非常简单的闭包 sum,它拥有一个入参 y,同时捕获了作用域中的 x 的值,因此调用 sum(2) 意味着将 2(参数 y)跟 1(x)进行相加,最终返回它们的和:3

可以看到 sum 非常符合闭包的定义:可以赋值给变量,允许捕获调用者作用域中的值。

Rust闭包与函数最大的不同就是参数通过 |parm1| 的形式进行声明,如果是多个参数就是 |parm1, parm2, ... |,闭包的形式定义如下:

//闭包的形式定义
|parm1, parm2, ... | {
	语句1;
	语句2;
	返回表达式
}
//如果只有一个返回表达式,定义可以简化为
|parm1| 返回表达式

闭包: |parm1| 代表传入参数,-> 后面代表返加值,{} 大括号里代表函数体

闭包由一个结构体组成,当引用了外部的自由变量时就是有大小的,并且引用的是指针;如果没有引用外部自由变量,就是一个空的结构体,大小就是0。所有闭包名称都是唯一的。Rust 闭包底层是用结构体实现的。那么闭包是如何找到函数的?实际上是在内部直接写死了函数指针的地址,这是在编译期完成的操作。

Rust 中的闭包耦合了泛型、生命周期和所有权,所有这些都是为了保证程序运行时的安全和效率

闭包的类型推导

开发者必须手动为函数的所有参数和返回值指定类型,因为函数通常作为API提供给用户,但是闭包并不会作为API对外提供,因此可以享受编译器的类型推导能力而无需标注参数和返回值的类型。虽然编译器会对闭包进行类型推导,但是当推导出一种类型后,就会一直使用该类型,当传入其他类型参数时就会报错。

// 同一功能的函数和闭包实现形式:
fn add_one_v1 (x: i32) -> i32 { x + 1 }
let add_one_v2 = |x: i32| -> i32 { x + 1 };  // 与函数最像
let add_one_v3 = |x| { x + 1 };  // 省略参数和返回值
let add_one_v4 = |x| x + 1;  // 省略花括号对

可以看出第一行的函数和后面的闭包其实在形式上是非常接近的,同时三种不同的闭包也展示了三种不同的使用方式:省略参数、返回值类型和花括号对。

虽然类型推导很好用,但是它不是泛型,编译器会为闭包定义中的每个参数和返回值推断一个具体类型,当编译器推导出一种类型后,它就会一直使用该类型

闭包捕获变量的方式

当闭包从环境中捕获一个值时,会分配内存去存储这些值。对于有些场景来说,这种额外的内存分配会成为一种负担。

闭包捕获变量有三种方式,恰好对应函数参数的三种传入方式:转移所有权、可变借用、不可变借用。与此相对应的Fn特征也有三种:FnOnceFnMut、和 Fn。闭包会根据函数体中如何使用被捕获的变量决定用哪种Fn特征。

1. FnOnce

FnOnce类型的闭包会拿走被捕获变量的所有权Once 说明闭包只能运行一次。仅实现 FnOnce 特征的闭包在调用时会转移所有权,所以不能对已失去所有权的闭包变量进行二次调用。

但是如果闭包实现了 Copy 特征,那么调用时使用的将是拷贝,并没有发生所有权的转移,所以可以多次调用。 比如:

fn main() {
    let x = vec![1, 2, 3];
    fn_once(|z| {z == x.len()});
}

// 此处声明闭包func实现了 Copy,闭包没有发生所有权转移所有func调用两次不崩溃
fn fn_once<F: FnOnce(usize) -> bool + Copy> (func: F) {
    println!("{}", func(3)); // true
    println!("{}", func(4)); // false
}

如果想强制转移变量的所有权(重点理解捕获变量x所有权与闭包func所有权的区别)到闭包内部,可以在参数列表前添加 move 关键字,通常要求闭包生命周期大于捕获变量声明周期场景,如将闭包返回或移入其他线程。比如:

use std::thread;

fn main() { 
  let v = vec![1, 2, 3];
  let handle = thread::spawn(move || { // move 关键字转移环境中的变量v到线程内部
      println!("Here's a vector: {:?}", v);
  });
  handle.join().unwrap();
}

2. FnMut

FnMut 表示以可变引用(&mut T)的方式捕获了环境中的值,因此可以修改该值。要实现可变借用捕获变量,需要将该闭包声明为可变类型,把闭包当做一个普通变量。可变类型闭包式例:

fn main() {
    let mut s = String::new();
    let mut update_string = |str| s.push_str(str); // 闭包仅对环境中变量s进行了可变借用:&mut s
    update_string("hello"); 
    println!("{:?}", s); // "hello"
    
    let update_string = |str| s.push_str(str);  // 闭包对环境中变量s进行了可变借用:&mut s,即闭包是FnMut特征 
    exec(update_string); // 将 `FnMut` 闭包作为函数传入参数示例:
    println!("{:?}", s); // "helloworld"
}

// 期望函数传参进来的闭包实现的是`FnMut`
fn exec<'a, F: FnMut(&'a str)> (mut func: F)  {
    func("world") 
}

3. Fn

Fn 表示以不可变引用(&T)的方式捕获环境中的值。 下为示例;

fn main() {
    let s = "hello, ".to_string();
    let update_string =  |str| println!("{},{}",s,str);  // 闭包对环境中变量s进行了不可变借用,相当于&s,即闭包是Fn特征的
    exec(update_string);
    println!("{:?}",s);
}
// 期望传参进来一个Fn特征的闭包
fn exec<'a, F: Fn(String) -> ()>(func: F)  {  // 对于不可变借用闭包,仅将其标记为Fn特征即可
    func("world".to_string())
}

可以在闭包的参数列表前使用move关键字,这样将强制闭包以获取所有权的方式捕获其环境中的变量,不过使用了 move 的闭包依然可能实现了 Fn 和 FnMut,因为一个闭包实现了哪种 Fn 特征取决于该闭包内部如何使用被捕获的变量,而不是取决于闭包如何捕获它们。使用的 move 关键字,强调的就是“闭包如何捕获变量”。

fn main() {
    let s = String::new();
    let update_string =  move || println!("{}",s);
    
    exec(update_string);
}

fn exec<F: FnOnce()>(f: F)  {
    f()
}

例如上面的代码中,闭包不仅仅实现了 FnOnce 特征,还实现了 Fn 特征(因为该闭包对于s的使用仅仅是不可变引用),因此将代码中的 FnOnce 修改为 Fn 也是可以编译通过的。

实际上,一个闭包并不仅仅实现了某一种 Fn trait,其规则如下:

  • 所有的闭包都自动实现了 FnOnce trait,因此任何一个闭包都至少可以被调用一次;
  • 没有移出所捕获变量的所有权的闭包自动实现了 FnMut trait;
  • 不需要对捕获变量进行改变的闭包自动实现了 Fn trait。

用一段代码来简单诠释上述规则:

fn main() {
    let s = String::new();
    let update_string =  || println!("{}",s); // 闭包进行了不可变借用,没有移出变量所有权,不改变变量

    exec(update_string);
    exec1(update_string);
    exec2(update_string);
}

fn exec<F: FnOnce()>(f: F)  { // 规则1: 所有闭包自动实现FnOnce
    f()
}

fn exec1<F: FnMut()>(mut f: F)  {// 规则2: 没有移出捕获变量所有的闭包自动实现FnMut
    f()
}

fn exec2<F: Fn()>(f: F)  {// 规则3: 不需要对捕获变量进行改变的自动实现Fn
    f()
}

虽然,闭包只是对 s 进行了不可变借用,实际上,它可以适用于任何一种 Fn trait;

一个移出所有权的例子:

fn main() {
    let mut s = String::new();
    let update_string = |str| -> String { s.push_str(str); s }; // 移出了s所有权
    exec(update_string);
}

fn exec<'a, F: FnMut(&'a str) -> String>(mut f: F) {  // 错误,实现了FnOnce
    f("hello");
}

示例代码中,闭包从捕获环境中移出了变量s的所有权,并进行改变,因此闭包只实现了 FnOnce,未实现 FnMutFn

我们来看看这三个特征的简化版源码:

pub trait Fn<Args> : FnMut<Args> { // Fn 实现了 FnMut
    extern "rust-call" fn call(&self, args: Args) -> Self::Output;
}

pub trait FnMut<Args> : FnOnce<Args> { // FnMut 实现了 FnOnce
    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;
}

从源码中还能看出:Fn 获取 &selfFnMut 获取 &mut self,而 FnOnce 获取 self

结论:Fn 的前提是实现 FnMutFnMut 的前提是实现 FnOnce,因此要实现 Fn 就要同时实现 FnMutFnOnce。 在实际项目中,建议先使用 Fn ,然后编译器会告诉你正误以及该如何选择

闭包作为函数返回值

Rust要求函数的参数和返回类型,必须有固定的内存大小。绝大部分类型都有固定的大小,但是不包括trait,因为编译器无法知道其真实类型或具体大小,因此编译器会提示使用 impl 关键字,如:

fn return_closure(x: i32) -> impl Fn(i32) -> i32 {
    let num = 5;

    if x > 1{
        move |x| x + num
    } else {
        move |x| x - num
    }
}

但是上面代码不能通过,因为两个分支返回了不同的闭包类型。即使是签名相同的闭包,类型也是不同的。可以用 Box 实现特征对象:

fn factory(x:i32) -> Box<dyn Fn(i32) -> i32> {  // dyn是申明trait对象类型的关键字
    let num = 5;

    if x > 1{
        Box::new(move |x| x + num)
    } else {
        Box::new(move |x| x - num)
    }
}

闭包类型占用内存的大小

如果把闭包理解成一个结构体,闭包所捕获的变量相当于结构体中的字段,则闭包的大小就和闭包参数,闭包代码的局部变量都没有关系,而只与闭包从其环境中捕获的变量有关。

总结

Rust 的 闭包closures)是可以保存在一个变量中或作为参数传递给其他函数的匿名函数,通俗的说就是一个可以从环境中捕获变量的匿名函数。可以在一个地方创建闭包,然后在不同的上下文中执行闭包运算。不同于函数,闭包允许捕获被定义时所在作用域中的值。

闭包捕获变量的方式分为三类:不可变引用(&T)、可变引用(&mut T)和值(T),与此相对应的 trait 也有三种:FnFnMut、和 FnOnce

  • Fn -- 变量不可变借用,只读不允许修改闭包的内部数据,闭包可调用多次
  • FnMut -- 变量可变借用,允许在执行时修改闭包的内部数据,闭包可调用多次
  • FnOnce -- 变量移动,获得所有权,闭包只可调用一次

注:对于闭包 || a.x + 1 ,2018的实现是捕获整个结构体a,但是现在只捕获所需要用的 x。这个特性会导致一些对象在不同时间点被释放(dropped),或是影响了闭包是否实现 Send 或 Clone trait,所以 cargo 会插入语句 let _ = &a 引用完整结构体来修复这个问题。这个变动其实很大,细节可以参考官方文档。

参考