写点什么

Rust 从 0 到 1- 函数式编程 - 闭包

用户头像
关注
发布于: 10 分钟前
Rust从0到1-函数式编程-闭包

Rust 在设计上从很多现有的语言和技术中获取过启发。其中一个重要的影响就是函数式编程(functional programming)。在函数式编程风格的语言中通常会将函数看作是值,可以作为参数值,返回值或者赋值给变量等等。

在这里我们不讨论到底什么是函数式编程,而是展示 Rust 与其它被认为是函数式编程的语言的一些相似的功能特性。具体来说,我们会讨论以下三个方面:

  • 闭包(Closures)

  • 迭代器(Iterators)

  • 它们的性能

除此之外,Rust 还有其它一些受函数式风格影响的功能,如模式匹配和枚举,这些在前面的章节已经讨论过。掌握闭包和迭代器是编写地道,高性能的 Rust 代码的重要能力之一。下面我们首先讨论闭包。

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

使用闭包抽象行为

我们假设这样一种场景:我们在一家初创公司工作,其主要产品是一个用于生成自定义健身计划的 app,后端使用 Rust 编写,生成健身计划的算法依据很多因素,包括年龄、体质指数、运动偏好、最近的训练和设置的强度系数等。假设这个计算需要花费数秒的时间,在这里我们忽略算法本身的细节。我们希望只在需要时调用算法,并且只调用一次,尽量减少用户的等待时间。参看下面的例子,simulated_expensive_calculation 是这个耗时的算法函数:

use std::thread;use std::time::Duration;
fn simulated_expensive_calculation(intensity: u32) -> u32 { println!("calculating slowly..."); thread::sleep(Duration::from_secs(2)); intensity}
复制代码

接下来是 main 函数,它将会模拟用户生成健身计划时 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 函数里硬编码了 simulated_user_specified_value 和 simulated_random_number 的值。最后,让我们来编写生成计划的主逻辑:

fn generate_workout(intensity: u32, random_number: u32) {    if intensity < 25 {        println!(            "Today, do {} pushups!",            simulated_expensive_calculation(intensity)        );        println!(            "Next, do {} situps!",            simulated_expensive_calculation(intensity)        );    } else {        if random_number == 3 {            println!("Take a break today! Remember to stay hydrated!");        } else {            println!(                "Today, run for {} minutes!",                simulated_expensive_calculation(intensity)            );        }    }}
复制代码

上例中我们多处调用了比较耗时的计算函数 simulated_expensive_calculation 。现在这份代码可以基本满足我们的需求,但让我们假设数据科学部门告诉我们将来调用 simulated_expensive_calculation 的方式可能会做修改。为了便于以后对代码的修改,我们将重构现在的代码,只调用 simulated_expensive_calculation 一次,这样只要将来只要修改一处就可以了。同时我们还希望去除对函数的重复调用,尽量较少对耗时函数的调用,如果不需要调用,最好是一次都不调用。

使用函数重构

重构的方法有很多种。我们首先尝试的是将对 simulated_expensive_calculation 函数的重复调用提取到一个变量中:

fn generate_workout(intensity: u32, random_number: u32) {    let expensive_result = simulated_expensive_calculation(intensity);
if intensity < 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); } }}
复制代码

上例中将对 simulated_expensive_calculation 函数的调用提取到了一个位置,将结果储存在变量 expensive_result 中,以供后续使用。这样我们就只需要调用 simulated_expensive_calculation 函数一次,解决了第一个 if 块中两次调用了函数的问题。但是造成了一个新问题,即在我们的逻辑中有一部分并不需要调用这个函数,但是现在所有的情况下都需要调用函数并等待结果,这不是我们所希望的。下面让我们来看看闭包!

使用闭包重构

与将调用 simulated_expensive_calculation 函数的结果储存到变量不同,我们可以利用闭包并将函数体储存在变量中(匿名函数):

let expensive_closure = |num| {    println!("calculating slowly...");    thread::sleep(Duration::from_secs(2));    num};
复制代码

闭包的定义以一对竖线开始,竖线中间是闭包的参数 | [parameters...] | (官方说选择这种语法的原因是与 Smalltalk 和 Ruby 的闭包定义类似)。在例子中,我们的闭包有一个参数 num,如果有多个参数,可以使用逗号分隔,譬如 |param1, param2|。另外,需要注意的是,如果函数体只有一行,那么大括号可以省略不写。现在我们的 expensive_closure 变量就包含了一个匿名函数的定义,而不是调用匿名函数的结果。

定义了闭包之后,我们就可以在 if 块中调用闭包以执行代码并获取结果值。参考下面的例子:

fn generate_workout(intensity: u32, random_number: u32) {    let expensive_closure = |num| {        println!("calculating slowly...");        thread::sleep(Duration::from_secs(2));        num    };
if intensity < 25 { println!("Today, do {} pushups!", expensive_closure(intensity)); println!("Next, do {} situps!", expensive_closure(intensity)); } else { if random_number == 3 { println!("Take a break today! Remember to stay hydrated!"); } else { println!( "Today, run for {} minutes!", expensive_closure(intensity) ); } }}
复制代码

虽然在上面的例子中,只会在需要结果的时候执行计算。然而,我们又重新引入了在第一个 if 块中调用了两次函数的问题,这会使用户的等待时间变长。我们可以通过在 if 块中创建一个变量存放闭包调用的结果来解决这个问题,不过闭包提供有另外一种解决方案。我们稍后会讨论,让我们先讨论一下为何闭包定义中没有声明类型,以及闭包所涉及的 traits。

类型推断

闭包不需要我们像普通函数那样声明参数和返回值的类型。普通的函数定义中需声明类型是因为他们是暴露给用户的接口的一部分,需要清晰明白,严格的接口定义对于保证所有人对函数的参数和返回值的类型理解一致来说很重要。但是闭包并不需要这么做,因为它们储存在变量中使用,是匿名的,也不会暴露给外部调用。

闭包通常比较短并且仅应用于“狭义”的上下文中(narrow context,我理解是局部的较小范围的上下文)。有了这些限制,编译器就能推断出参数和返回值的类型,就像推断变量的类型一样。因此,没必要强制在短小的匿名函数中声明类型,这样会更加简单,也减少很多重复的工作。如果我们仍然希望明确的声明类型,也是可以做到的,和普通的函数很类似,参考下面的例子:

let expensive_closure = |num: u32| -> u32 {    println!("calculating slowly...");    thread::sleep(Duration::from_secs(2));    num};
复制代码

下面的例子是对普通函数以及有相同行为的不同闭包语法的纵向对比:

fn  add_one_v1   (x: u32) -> u32 { x + 1 }let add_one_v2 = |x: u32| -> u32 { x + 1 };let add_one_v3 = |x|             { x + 1 };let add_one_v4 = |x|               x + 1  ;
复制代码

第一行是一个普通函数定义;第二行是一个完整声明了类型的闭包定义;第三行是通常我们使用的闭包定义,省略了类型声明;第四行去掉了大括号,因为函数体只有一行。它们都是有效的闭包定义,并具有相同的行为。调用 add_one_v3 和 add_one_v4 的前提是可以编译通过,因为类型是通过它们使用的上下文推断出来的。

闭包定义会通过推断得到每个参数和返回值的具体类型。注意如果我们尝试使用不同类型的数据调用闭包两次,譬如,第一次使用 String 作为参数,第二次使用 u32,则发生错误:

let example_closure = |x| x;
let s = example_closure(String::from("hello"));let n = example_closure(5);
复制代码

上面的例子会产生类似下面的编译错误:

$ cargo run   Compiling closure-example v0.1.0 (file:///projects/closure-example)error[E0308]: mismatched types --> src/main.rs:5:29  |5 |     let n = example_closure(5);  |                             ^  |                             |  |                             expected struct `String`, found integer  |                             help: try using a conversion method: `5.to_string()`
error: aborting due to previous error
For more information about this error, try `rustc --explain E0308`.error: could not compile `closure-example`
To learn more, run the command again with --verbose.
复制代码

第一次使用 String 类型数据作为参数调用 example_closure 时,编译器推断闭包的参数和返回值的类型为 String。这些类型会被被锁定为闭包 example_closure 的类型,之后如果我们尝试对同一闭包使用不同类型的数据则会得到类似上面的类型错误(mismatched types)。

使用泛型和 Fn trait 存储闭包

回到前面我们使用闭包重构的例子,仍然调用了 2 次比较耗时的闭包。我们说过其中一个解决方案是讲计算结果存储在局部变量中,但是这样仍然可能会需要在很多地方重复保存计算的结果。现在我们来看看使用闭包的另一个解决方案。我们可以创建一个存放闭包以及其返回值的结构体,它只会在需要时执行闭包,并会缓存返回值,这样后续再次使用时就可以复用缓存的值,这种模式被称为“ 记忆(memoization)”或“懒计算(lazy evaluation)”。

为了将闭包存放到结构体中,我们需要指定闭包的类型,因为结构体需要知道其每一个字段的类型。每一个闭包实例都是唯一的匿名类型:也就是说,即使两个闭包完全相同,他们仍然被认为是不同的类型。我们使用泛型和 trait bound 来定义使用闭包的结构体、枚举或函数参数。

Fn traits 由标准库提供。所有的闭包都实现了以下 traits 中的至少一个: Fn、FnMut 和 FnOnce。后面我们会讨论这些 traits 的区别;在当前的例子中可以使用 Fn trait。我们在 Fn trait bound 增加类型声明用来约束闭包的参数和返回值类型(在当前的例子中是 u32),参考下面的例子:

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

上例中,结构体 Cacher 有一个泛型字段 calculation,类型是 T。T 的 trait bound 指定了 T 是一个实现了 Fn trait 的闭包,并且其参数和返回值都必须是 u32 类型。

注意:函数也可以实现我们说的三个 Fn traits。如果在我们的应用场景中不需要用到外部环境中的值(可以获得其所处作用域的变量),可以使用实现了 Fn trait 的函数来替代闭包。
复制代码

字段 value 是 Option<u32> 类型。我们用它来存储闭包执行的结果,在此之前,它的值是 None。如果代码再次请求闭包的执行结果,这时我们不再执行闭包,而是直接返回存放在 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::new 函数返回一个在 calculation 字段中存放了闭包和在 value 字段中存放了 None 值的 Cacher 实例。当调用 value 方法获取闭包执行的结果时,这个方法会检查 self.value 是否是一个包含结果值的 Some;如果是,就直接返回 Some 中的值而不会再次执行闭包。如果 self.value 是 None,则会调用 self.calculation 中的闭包进行计算,并将结果保存到 self.value ,最后返回结果值。下面我们看看在 generate_workout 函数中如何使用 Cacher 结构体:

fn generate_workout(intensity: u32, random_number: u32) {    let mut expensive_result = Cacher::new(|num| {        println!("calculating slowly...");        thread::sleep(Duration::from_secs(2));        num    });
if intensity < 25 { println!("Today, do {} pushups!", expensive_result.value(intensity)); println!("Next, do {} situps!", expensive_result.value(intensity)); } else { if random_number == 3 { println!("Take a break today! Remember to stay hydrated!"); } else { println!( "Today, run for {} minutes!", expensive_result.value(intensity) ); } }}
复制代码

我们可以尝试使用在 main 函数来运行这段程序,并改变 simulated_user_specified_value 和 simulated_random_number 变量中的值来验证在所有逻辑分支下 if 和 else 块中,闭包中打印的 calculating slowly... 只会在需要调用时出现并且只会出现一次。

Cacher 实现的局限

缓存通常来说很有用,我们可能希望在代码中其它使用闭包的地方也使用缓存。但是,目前我们实现的 Cacher 还存在两个问题,可能导致在其它场景下难以适用。

第一个问题是目前 Cacher 的实现假设带有参数 arg 的 value 方法总是会返回相同的值(第一次调用的结果)。我们参考下面这个测试:

#[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);}
复制代码

上面的例子中,我们期望当参数为 1 的时候结果为 1,而当参数为 2 的时候结果为 2 。但当我们实际运行测试的时候会得到类似下面的结果:

$ cargo test   Compiling cacher v0.1.0 (file:///projects/cacher)    Finished test [unoptimized + debuginfo] target(s) in 0.72s     Running target/debug/deps/cacher-4116485fb32b3fff
running 1 testtest tests::call_with_different_values ... FAILED
failures:
---- tests::call_with_different_values stdout ----thread 'main' panicked at 'assertion failed: `(left == right)` left: `1`, right: `2`', src/lib.rs:43:9note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

failures: tests::call_with_different_values
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass '--lib'
复制代码

测试失败了!原因是是第一次使用参数 1 调用 c.value 时,Cacher 实例将 Some(1) 赋值给 self.value,之后,无论传递什么值,c.value 将总是会返回 1。

怎么解决呢?简单来说,我们可以尝试修改 Cacher 存放一个 hashmap,key 是参数 arg 的值,value 则是其对应的调用闭包计算的结果值。然后,每次调用我们根据使用 arg 的值作为 key 去 hashmap 中查找值,如果找到的话就返回其对应的值。如果不存在,Cacher 则调用闭包并保存新的结果。在实际应用中我们还需考虑内存占用等问题。

第二个问题是 Cacher  的应用被限制为只接受获取一个 u32 值并返回一个 u32 值的闭包。对于这个问题我们可以通过引入更多泛型参数来增加 Cacher 灵活性。

获取所在环境

在前面的例子中,我们只将闭包作为内联匿名函数来使用。此外,闭包还有一个函数所没有的功能:它能获取所在环境并访问其所在作用域中定义的变量。参考下面的例子,一个储存在 equal_to_x 变量中的闭包使用了外部变量 x:

fn main() {    let x = 4;
let equal_to_x = |z| z == x;
let y = 4;
assert!(equal_to_x(y));}
复制代码

上面的例子中变量 x 并不是 equal_to_x 的一个参数,但是我们在闭包中也可以使用变量 x,因为它与 equal_to_x 处于相同的作用域。函数则无法做到,参考下面的例子:

fn main() {    let x = 4;
fn equal_to_x(z: i32) -> bool { z == x }
let y = 4;
assert!(equal_to_x(y));}
复制代码

我们会得到类似下面的编译错误:

$ cargo run   Compiling equal-to-x v0.1.0 (file:///projects/equal-to-x)error[E0434]: can't capture dynamic environment in a fn item --> src/main.rs:5:14  |5 |         z == x  |              ^  |  = help: use the `|| { ... }` closure form instead
error: aborting due to previous error
For more information about this error, try `rustc --explain E0434`.error: could not compile `equal-to-x`
To learn more, run the command again with --verbose.
复制代码

当闭包从所在的环境获取值时,这个值会存储在内存中,以供在闭包体中使用。这种内存的使用方式与一般场景下我们不需要在闭包来获取环境中的值时想比会产生额外的开销。而函数由于不允许获取环境中的值,因此不存在这个问题。

闭包可以通过三种方式获取其所在环境中的值,与函数的三种获取参数的方式相对应:即所有权,可变引用和不可变引用。这三种方式对应如下三个 Fn traits:

  • FnOnce 从所在作用域获得变量(也就是其所在的环境,环境这个词总感觉别扭,感觉就是定义闭包时其所在的作用域)。为了获得变量,闭包必须获取其所有权并在定义闭包时将其移动进闭包。从名字中的 Once 也可以看出闭包不能多次获得相同变量的所有权,所以它只能被调用一次。

  • FnMut 获取可变的引用

  • Fn 获取不可变的引用

当我们创建一个闭包时,Rust 会根据其如何使用环境中的变量来推断应该使用哪个 trait。由于所有闭包都可以被调用至少一次,所以所有闭包都实现了 FnOnce ;那些没有获取所有权的闭包会同时实现 FnMut ,而只进行只读访问的闭包则会再额外实现 Fn (我理解为实现了 Fn 的闭包肯定实现了 FnMut,实现了 FnMut 的闭包,肯定实现了 FnOnce)。上面的例子中,equal_to_x z 对应的闭包使用不可变的方式引用了变量 x(因此 equal_to_x 实现了 Fn trait),因为闭包体只需要读取 x 的值,而不会进行修改。

如果我们希望强制闭包获取环境中值的所有权,可以使用 move 关键字。这个最适用于在将闭包传递给一个新线程以便新线程获得数据所有权的场景。后面讨论“并发”的章节会展示更多使用关键字 move 的例子,作为演示我们先看看下面的例子:

fn main() {    let x = vec![1, 2, 3];
let equal_to_x = move |z| z == x;
println!("can't use x here: {:?}", x);
let y = vec![1, 2, 3];
assert!(equal_to_x(y));}
复制代码

上面的例子还不能编译通过,会产生类似下面的错误:

$ cargo run   Compiling equal-to-x v0.1.0 (file:///projects/equal-to-x)error[E0382]: borrow of moved value: `x` --> src/main.rs:6:40  |2 |     let x = vec![1, 2, 3];  |         - move occurs because `x` has type `Vec<i32>`, which does not implement the `Copy` trait3 | 4 |     let equal_to_x = move |z| z == x;  |                      --------      - variable moved due to use in closure  |                      |  |                      value moved into closure here5 | 6 |     println!("can't use x here: {:?}", x);  |                                        ^ value borrowed here after move
error: aborting due to previous error
For more information about this error, try `rustc --explain E0382`.error: could not compile `equal-to-x`
To learn more, run the command again with --verbose.
复制代码

x 被移动进了闭包,因为闭包使用了 move 关键字。因此闭包获取了 x 的所有权,那么 main 中后面的 println! 语句就无法再使用 x 了。删除 println! 语句就可以编译通过了。

注意,使用了 move 关键字的闭包可能也同时实现了 Fn 或 FnMut,这是由于闭包实现哪些 traits 是由闭包体中如何使用这些值决定的,而不是获得这些值的方式决定的,而 move 仅是用来指定获得环境中值的方式。
复制代码

大部分情况下需要指定一个 trait bound 的时候,可以先从 Fn 开始,编译器会根据闭包体中的实现告诉我们是否需要 FnMut 或 FnOnce。

发布于: 10 分钟前阅读数: 2
用户头像

关注

公众号"山 顽石"欢迎大家关注;) 2021.03.23 加入

IT老兵,关注Rust、Java、前端、架构、大数据、AI、算法等;平时喜欢美食、旅游、宠物、健身、篮球、乒乓球等。希望能和大家交流分享。

评论

发布
暂无评论
Rust从0到1-函数式编程-闭包