Rust-闭包:可以捕获环境的匿名函数

  Rust的 闭包(closures) 是可以保存进变量或作为参数传递给其它函数的匿名函数。可以在一个地方创建闭包,然后在不同的上下文中执行闭包运算。不同于函数,闭包允许捕获调用者作用域中的值。我们将学习闭包的这些功能如何复用代码和自定义行为。

使用闭包创建行为的抽象

让我们试一个存储稍后要执行的闭包的示例。 其间我们会学习闭包的语法、类型推断和trait。

考虑一下这个假定的场景:我们在一个通过 app 生成自定义健身计划的初创企业工作。其后端使用 Rust 编写,而生成健身计划的算法需要考虑很多不同的因素,比如用户的年龄、身体质量指数(Body Mass Index)、用户喜好、最近的健身活动和用户指定的强度系数。本例中实际的算法并不重要,重要的是这个计算只花费几秒钟。我们只希望在需要时调用算法,并且只希望调用一次,这样就不会让用户等得太久。

我们将通过调用simulated_expensive_calculation函数来模拟调用假定的算法,如下所示,它会失印出calculating slowly...,等待两秒,并接着返回传递给它的数字:

fn simulated_expensive_calculation(intensity: u32)->u32 {
    println!("calculating slowing...");
    thread::sleep(Duration::from_secs(2));
    intensity
}

接下来,main函数中将会包含本例健身app中的重要部分。这代表当用户请求健身计划时app会调用的代码。因为与app前端的交互与闭包的使用并不相关,所以我们将硬编码代表程序输入的值并打印输出。

所需的输入有这些:

  • 一个来自用户的intensity数字,请求健身计划时指定,它代表用户喜好低强度还是高强度健身。
  • 一个随机数,其会在健身计划中生成变化。

程序的输出将会是建议的锻练计划。

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;
    generate_workout(
        simulated_user_specified_value,
        simulated_random_number
    );
}

main函数使用了generate_workout函数的模拟用户输入和模拟随机数输入。generate_workout函数包含本例中我们最关心的app业务逻辑。

fn generate_workout(intencity: u32, random_number: u32) {
    let expensive_result = simulated_expensive_calculation(intencity);
    if intencity < 25 {
        println!("Today, do {} pushups!", expensive_result);
        println!("Next, do {} situps!", expensive_result);
    } else {
        if random_number == 3 {
            println!("take a break today! Remember to stay hydrated!");
        } else {
            println!("Today, run for {} minutes!", expensive_result);
        }
    }
}

以上函数里,现在所有情况下都需要调用函数并等待结果,包括那个完全不需要这一结果的内部if块。

我们希望能够在程序的一个位置指定某些代码,并只在程序的某处实际需要结果的时候执行这些代码。这正是闭包的用武之地。

重构使用闭包储存代码

不同于总是在if块之前调用simulated_expensive_calculation函数并储存其结果,我们可以定义一个闭包并将其储存在变量中,如下所示,实际上可以选择将整个simulated_expensive_calculation函数体移动到这里引入的闭包中:

let expensive_closure = |num| {
        println!("calculating slowing...");
        thread::sleep(Duration::from_secs(2));
        num
    };

定义一个闭包并储存到变量expensive_closure中。

闭包定义是expensive_closure赋值的=之后的部分。闭包的定义以一对竖线( | )开始,在竖线中指定闭包的参数;之所以选择这个语法是因为它与Smalltalk和Ruby的闭包定义类似。这个闭包有一个参数num;如果有多于一个参数,可以使用逗号分隔,比如 | param1, param2 | 

参数之后是存放闭包体的大括号--如果闭包体只有一行则大括号是可以省略的。大括号之后闭包的结尾,需要用于let语句的分号。因为闭包体的最后一行没有分号(正如函数体一样),所以闭包体(num)最后一行的返回值作为调用闭包时的返回值。

注意这个let语句意味着expensive_closure包含一个匿名函数的定义,不是调用匿名函数的返回值。想一下使用闭包的原因是我们需要在一个位置定义代码,储存代码,并在之后的位置实际调用它;期望调用的代码现在储存在expensive_closure中。

定义了闭包之后,可以改变if块中的代码来调用闭包以执行代码并获取结果值。调用闭包类似于调用函数;指定存放闭包定义的变量名并后跟包含期望使用的参数的括号,如下所示:

fn generate_workout(intencity: u32, random_number: u32) {
    let expensive_closure = |num| {
        println!("calculating slowing...");
        thread::sleep(Duration::from_secs(2));
        num
    };

    if intencity < 25 {
        println!("Today, do {} pushups!", expensive_closure(intencity));
        println!("Next, do {} situps!", expensive_closure(intencity));
    } else {
        if random_number == 3 {
            println!("take a break today! Remember to stay hydrated!");
        } else {
            println!("Today, run for {} minutes!", expensive_closure(intencity));
        }
    }
}

 现在耗时的计算只在一个地方被调用,并只会在需要结果的时候执行该代码。

闭包类型推断和注解

闭包不要求像fn函数那样在参数和返回值上注明类型。函数中需要类型注解是因为他们是暴露给用户的显式接口的一部分。严格的定义这些接口对于保证所有人都认同函数使用和返回值的类型来说是很重要的。但是闭包并不用于这样暴露在外的接口:他们储存在变量中并被使用,不用命名他们或暴露给库的用户调用。

闭包通常很短,并只关联于小范围的上下文而非任意情境。在这些有限制的上下文中,编译器能可靠的推断参数和返回值的类型,类似于它是如何能够推断大部分变量的类型一样。

强制在这些小的匿名函数中注明类型是很恼人的,并且与编译器已知的信息存在大量的重复。

类型似变量,如果相比严格的必要性你更希望增加明确性并变得罗嗦,可以选择增加类型注解:

    let expensive_closure = |num: u32| {
        println!("calculating slowing...");
        thread::sleep(Duration::from_secs(2));
        num
    };

使用带有泛型和Fn trait的闭包

回到我们的健身计划生成app,在上面的代码仍然把慢计算闭包调用了比所需要更多的次数。解决这个问题的一个方法是在全部代码中的每一个需要多个慢计算闭包结果的地方,可以将结果保存进变量以供复用,这样就可以使用变量而不是再次调用闭包。但是这样就会有很多重复的保存结果变量的地方。

幸运的是,还有另一个可用的方案。可以创建一个存放闭包和调用闭包结果的结构体。该结构体只会在需要结果时执行闭包,并会缓存结果值,这样余下的代码就不必再负责保存结果并可以复用该值。这种模式被称为 memoization 或 lazy evaluation (惰性求值)。

为了让结构体存放闭包,我们需要指定闭包的类型,因为结构体定义需要知道其每一个字段的类型。每一个闭包实例有其它自已独有的匿名类型:也就是说,即便两个闭包有着相同的签名,他们的类型仍然可以被认为是不同。为了定义使用闭包的结构体、枚举或函数参数,我们可以使用泛型和trait bound。

Fn系统trait由标准库提供。所有的闭包都实现了trait FnFnMutFnOnce 中的一个。

为了满足Fn trait bound 我们增加了代表闭包包必须的参数和返回值类型的类型。在以下例子中,闭包有一个u32的参数并返回一个u32,这样所指定的trait bound就是 Fn(32)->u32。

以下代码展示了闭包和一个Option结果值的Cacher结构体的定义:

struct Cacher<T>
    where T: Fn(u32) -> u32
{
    calculation:T,
    value:Option<u32>,
}

定义一个Cacher结构体来在calculation中存放闭包并在value中存放Option值。

结构体Cacher有一个泛型T的字段calculation。T的trait bound指定了T是一个使用Fn的闭包。任何我们希望储存到Cacher实例的calculation字段的闭包必须有一个u32参数(由Fn之后的括号的内容指定)并必须返回一个u32(由->之后的内容)。

注意:函数也都实现了这个Fn trait。如果不需要捕获环境中的值,则可以使用实现了Fn trait的函数而不是闭包。

字段value是Option<u32>类型的。在执行闭包之前,value将是None。如果使用Cacher的代码请求闭包的结果,这时会执行闭包并将结果储存在value字段的Some成员中。接着如果代码再次请求闭包的结果,这时不再执行闭包,而是会返回存放在Some成员中的结果。

impl<T> Cacher<T>
    where T: Fn(u32) -> u32
{
    fn new(calculation: T) -> Cacher<T> {
        Cacher {
            calculation,
            value: None,
        }
    }

    fn value(&mut self, arg: u32) -> u32 {
        match self.value {
            Some(v) => v,
            None => {
                let v = (self.calculation)(arg);
                self.value = Some(v);
                v
            },
        }
    }
}

以上是Cacher的缓存逻辑。

Cacher结构体的字希是私有的,因为我们希望Cacher管理这些值而不是任由调用代码潜在的直接改变他们。

Cacher::new函数获取一个泛型参数T,它定义于impl块上下文中并与Cacher结构体有着相同的trait bound。Cacher::new返回一个在calculation字段中存放了指定闭包和在value字段中存放了None值的Cacher实例,因为我们还没执行闭包。

当调用代码需要闭包的执行结果时,不同于直接调用闭包,它会调用value方法。这个方法会检查self.value是否已经有了一个Some的结果值;如果有,它返回Some中的值并不会再次执行闭包。

如果self.value是None,则会调用self.calculation中储存的闭包,将结果保存到self.value以便将来使用,并同时返回结果值。

以下在generate_workout函数中利用Cacher结构体来抽象出缓存逻辑:

fn generate_workout(intencity: u32, random_number: u32) {
    let mut expensive_closure =  Cacher::new(|num: u32| {
        println!("calculating slowing...");
        thread::sleep(Duration::from_secs(2));
        num
    });

    if intencity < 25 {
        println!("Today, do {} pushups!", expensive_closure.value(intencity));
        println!("Next, do {} situps!", expensive_closure.value(intencity));
    } else {
        if random_number == 3 {
            println!("take a break today! Remember to stay hydrated!");
        } else {
            println!("Today, run for {} minutes!", expensive_closure.value(intencity));
        }
    }
}

不同于直接将闭包保存进一个变量,我们保存一个新的Cacher实例来存放闭包。接着,在每一个需要结果的地方,调用Cacher实例的value方法。可以调用value方法任意多次,或者一次也不调用,而慢计算最多只会运行一次。

我们尝试改变simulated_user_specified_value和simulated_random_number变量中的值来验证在所有情况下在多个if和else块中,闭包打印的calculation slowly...只会在需要时出现并只会出现一次。Cacher负责确保不会调用超过所需的慢计算所需的逻辑,这样generate_workout就可以专注业务逻辑了。

Cacher实现的限制 

值缓存是一种更加广泛的实用行为,我们可能希望在代码中的其它闭包中也使用他们。然而,目前Cacher的实现存在两个小问题,这使得在不同上下文中的使用变得很困难。

第一个问题是Cacher实例假设对于value方法的任何arg参数值总是会返回相同的值。也就是说,这个Cacher的测试会失败:

#[test]
fn call_with_different_values(){
    let mut c = Cacher::new(|a| a);
    let v1 = c.value(1);
    let v2 = c.value(2);
    assert_eq!(v2,2)
}

这个测试使用返回传递给它的值的闭包创建了一个新的Cacher实例。使用为1的arg和为2的arg调用Cacher实例的value方法,同时我们期望使用为2的arg调用value会返回2。

以上测试会在assert_eq!失败并显示如下信息:

thread 'call_with_different_values' panicked at 'assertion failed: `(left == right)`
  left: `1`,
 right: `2`', src/main.rs:66:5

 这里的问题是第一次使用 1 调用 c.value,Cacher 实例将 Some(1) 保存进 self.value。在这之后,无论传递什么值调用 value,它总是会返回 1。

 尝试修改 Cacher 存放一个哈希 map 而不是单独一个值。哈希 map 的 key 将是传递进来的 arg 值,而 value 则是对应 key 调用闭包的结果值。相比之前检查 self.value 直接是 Some 还是 None 值,现在 value 函数会在哈希 map 中寻找 arg,如果找到的话就返回其对应的值。如果不存在,Cacher 会调用闭包并将结果值保存在哈希 map 对应 arg 值的位置。

 当前 Cacher 实现的第二个问题是它的应用被限制为只接受获取一个 u32 值并返回一个 u32 值的闭包。比如说,我们可能需要能够缓存一个获取字符串 slice 并返回 usize 值的闭包的结果。请尝试引入更多泛型参数来增加 Cacher 功能的灵活性。

闭包会捕获其环境

在健身计划生成器的例子中,我们只将闭包作为内联匿名函数来使用。不过闭包还有另一个函数所没有的功能:他们可以捕获其环境并访问其被定义的作用域的变量。

以下示例有一个储存在equal_to_x变量中闭包的例子,它使用了闭包环境中的变量x:

    let x = 4;
    let equal_to_x = |z| z == x;
    let y = 4;
    assert!(equal_to_x(y));

 这里,即便x并不是equal_to_x的一个参数,equal_to_x闭包也被允许使用变量x,因为它与equal_to_x定义于相同的作用域。

函数则不能做到同样的事,如下例子,它并不能编译:

    let x = 4;
    fn euqal_to_x(z: i32) -> bool { z == x }
    let y = 4;
    assert!(equal_to_x(y));

会得到一个错误:

error[E0434]: can't capture dynamic environment in a fn item
  --> src/main.rs:18:35
   |
18 |     fn euqal_to_x(z:i32)->bool{z==x}
   |                                   ^
   |
   = help: use the `|| { ... }` closure form instead

编译器还会提示我们这只能用于闭包。

 当闭包从环境中捕获一个值,闭包会在闭包体中储存这个值以供使用。这会使用内存并产生额外的开销,在更一般的场景中,当我们不需要闭包来捕获环境时,我们不希望产生这些开销。因为函数从末允许捕获环境,定义和使用函数也不从不会有这些额外开销。

闭包可以通过三种方式捕获其环境,他们直接对应函数的三种获取参数方式:获取所有权,可变借用和不可变借用。这三种捕获值的方式被编码为如下三个Fn trait:

  • FnOnce 消费从周围作用域捕获的变量,闭包周围的作用域被称为其环境,environment。为了消费捕获取的变量,闭包必须获取其所用权并在定义闭包时将其移动进闭包。其名称的 Once 部分代表了闭包不能多次获取相同变量的所有权的事实,所以它只能被调用一次。
  • FnMut 获取可变的借用值所以可以改变其环境
  • Fn 从其环境获取不可变的借用值

当创建一个闭包时,Rust根据其如何使用环境中变量来推断我们希望如何引用环境。

由于所有闭包都可以被调用至少一次,所以所有闭包都实现了FnOnce那些并没有移动被捕获变量的所有权到闭包内的闭包也实现了FnMut,而不需要对被捕获的变量进行可变访问的闭包则也实现了Fn

equal_to_x闭包不可变的借用了x(所以equal_to_x具有Fn trait),因为闭包体只需要读取x的值。

如果你希望强制闭包获取其使用的环境值的所有权,可以在参数列表前使用move关键字。这个技巧在将闭包传递给新线程以便将数据移动到新线程中时最为实用。

posted @ 2021-09-27 13:18  johnny_zhao  阅读(436)  评论(0编辑  收藏  举报