0%

Rust 权威指南(12):函数式语言特性:迭代器与闭包

Rust 在设计过程中从许多现有语言和技术中获得启发,函数式编程 理念就是其中之一,它对 Rust 产生了非常显著的影响。常见的函数式风格编程中包括:将函数当做参数、将函数作为其他函数的返回值、将函数赋值给变量等等。闭包和迭代器是 Rust 受函数式编程语言启发而实现的功能,它们帮助 Rust 在清晰地表达出高层次抽象概念的同时兼顾底层性能。

闭包:能够捕获环境的匿名函数

Rust 中的闭包是一种可以存入变量或者作为参数传递给其他函数的匿名函数。和一般的函数不同,闭包可以从定义它的作用域中捕获值。如下展示了一个闭包的定义:

1
2
3
4
5
let expensive_closure = |num| {
println!("calculating slowly...")
thread::sleep(Duration::from_secs(2))
num
}
  • 为了定义闭包,需要以一对竖线 | 开始,并在竖线之间填写闭包的参数,多个参数之间以 , 隔开
  • 使用花括号来包裹闭包的函数体(单行表达式可以省略花括号)

调用闭包的方式类似于调用普通的函数:先指定存储闭包定义的变量名,再跟上一对包含传入参数的括号。

和 fn 定义的函数不同,闭包并不强制要求你标注参数和返回值的类型。Rust 之所以我们在函数定义中进行类型标注,是因为类型信息是暴露给用户的显式接口的一部分。严格定义接口有助于所有人对参数和返回值的类型取得明确共识。但是闭包并不会被用于这样的暴露接口:它们被存储在变量中,在使用时既不需要命名,也不会被暴露给代码库的用户。在这种限定环境下,编译器能够可靠地推断出闭包参数的类型以及返回值的类型,就像是编译器能够推断出大多数变量的类型一样。当然,为闭包手动添加类型标注也是可以的

闭包定义中的每一个参数及返回值都会被推导为对应的具体类型。试图使用两种不同的类型调用同一个需要类型推导的闭包会触发 类型不匹配 的编译错误。因为第一次类型推导时,相关的参数及返回值都推导为了相应的类型,之后再使用其他类型调用这一闭包时就会出错。

使用泛型参数和 Fn trait 来存储闭包

我们可以创建一个同时存放闭包以及闭包返回值的结构体。这个结构体只会在我们需要获得结果值时运行闭包,并将首次运行闭包时的结果缓存起来。这种模式一般叫做 记忆化惰性求值

为了将闭包存储在结构体中,必须明确指定闭包的类型。需要注意的是,每一个闭包实例都有它自己的匿名类型。即使两个闭包拥有完全相同的签名,它们的类型也被认为是不一样的**。为了在结构体、枚举或函数参数中使用闭包,需要使用泛型以及 trait 约束。

标准库提供了一系列 Fn trait,而所有闭包都至少实现了 Fn、FnMut 或 FnOnce 中的一个 trait。如下在 Fn 的 trait 约束中添加了代表闭包参数和闭包返回值的类型:

1
2
3
4
5
6
struct Cacher<T>
where T:Fn(u32) -> u32
{
calculation: T,
value: Option<u32>,
}

函数同样可以实现这 3 个 Fn trait。如果代码不需要从环境中捕获任何值,那么也可以使用实现了 Fn trait 的函数而不是闭包。

如下使用 Cacher 实现惰性求值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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);
},
}
}
}

闭包还有一项函数所不具备的功能,它们可以捕获自己所在的环境并访问自己被定义时的作用域中的变量。

1
2
3
4
5
6
7
8
fn main() {
let x = 4;

let equal_to_x = |z| z == x;
let y = 4;

assert!(equal_to_x(y));
}

当闭包从环境内捕获值时,它会使用额外的空间来存储这些值以便在闭包体内使用(因此也会产生额外的开销):闭包通过三种方式从他们的环境中捕获值,这和函数接收参数的 3 种方式是完全一致的:

  • 获取所有权
  • 可变借用
  • 不可变借用

这 3 种方式被分别编码在如下 3 种 Fn 系列的 trait 中:

  • FnOnce:意味着闭包可以从它的封闭作用域中,即闭包所处的环境中,消耗所捕获的变量。为了实现这一功能,闭包必须在定义时取得这些变量的所有权并将它们移动至闭包中
  • FnMut:可以从环境中可变地借用值并对它们进行修改
  • Fn:可以从环境中不可变地借用值

当创建闭包时,Rust 会基于闭包从环境中使用值的方式来自动推导出它需要使用的 trait。在大部分情况下,当你需要指定某一个 Fn 系列的 trait 时,可以先尝试使用 Fn trait,编译器会根据闭包体中的具体情况来告诉你是否需要使用 FnMut 或者 FnOnce。

可以在参数列表前添加 move 关键字来强制闭包获取环境中值的所有权。

使用迭代器处理元素序列

迭代器模式允许你依次为序列中的某一个元素执行某些任务。迭代器会在该过程中负责遍历每一个元素并决定序列何时结束。在 Rust 中迭代器是惰性的,所以创建迭代器后,除非你主动调用方法来消耗并使用迭代器,否则不会产生任何实际的效果。

如下代码通过 iter 方法创建一个迭代器:

1
2
3
let v1 = vec![1, 2, 3];

let v1_iter = v1.iter();

创建好迭代器后,可以有多种方式来使用迭代器,最常见的就是在 for 循环中使用迭代器:

1
2
3
4
5
6
7
let v1 = vec![1, 2, 3];

let v1_iter = v1.iter();

for val in v1_iter {
println!("Got: {}", val);
}

另外,需要注意,iter 方法生成的是一个不可变引用的迭代器,我们通过 next 取得的值是指向元素的不可变引用。如果需要返回元素本身( 即取得元素所有权)的迭代器,可以使用 into_iter 方法,如果需要可变引用的迭代器,可以使用 iter_mut 方法。

Iterator trait

所有的迭代器都实现了定义于标准库中的 Iterator trait。该 trait 的定义类似于下面这样:

1
2
3
4
pub trait Iterator {
type Item;
fn next(&mut self)-> Option<Self::Item>;
}

为了实现 Iterator trait,需要定义一个具体的 Item 类型,而这个 Item 类型会被用作 next 方法的返回值类型。next 方法会在被调用时返回一个包裹在 Some 中的迭代器元素,并且在迭代结束时返回 None。

可以直接在迭代器上调用 next 方法,需要注意调用 next 方法改变了迭代器内部用来序列位置的状态,所以迭代器变量本身需要是可变的。那为什么上面 for 循环中又不要求 v1_iter 是可变的呢?这是因为循环取得了 v1_iter 的所有权并在内部使得它可变。

消耗迭代器的方法

那些调用 next 的方法被称为 消耗适配器,因为它们同样消耗了迭代器本身。以 sum 方法为例,该方法会获取迭代器的所有权并反复调用 next 来遍历元素,进而导致迭代器被消耗。

1
2
3
4
5
6
7
8
9
10
#[test]
fn iterator_sum() {
let v1 = vec![1, 2, 3];

let v1_iter = v1.iter();

let total: i32 = v1_iter.sum();

assert_eq!(total, 6);
}

由于在 sum 调用过程中取得了 v1_iter 迭代器的所有权,所以该迭代器无法继续被后续代码使用。

生成其他迭代器的方法

Iterator trait 还定义了一些被称为 迭代器适配器 的方法,可以将已有的迭代器转换成其他不同类型的迭代器。可以链式地调用多个迭代器适配器完成一些复杂的操作。

如下是一个示例:

1
2
3
4
5
fn main() {
let v1: Vec<i32> = vec![1, 2, 3];
let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();
assert_eq!(v2, vec![2, 3, 4]);
}

map 接受一个闭包作为参数,我们可以对每个元素指定要执行的任何操作。该示例演示了如何使用 Iterator trait 提供的迭代功能,又能通过闭包来自定义部分具体行为。

使用闭包捕获环境

如下是一个 filter 迭代器适配器 的示例,演示了如何使用闭包来捕获环境:

1
2
3
4
5
6
7
8
9
10
11
#[derive(PartialEq, Debug)]
struct Shoe {
size: u32,
style: String,
}

fn shoes_in_my_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
shoes.into_iter()
.filter(|s| s.size == shoe_size)
.collect()
}

这里使用 into_iter() 创建一个可以获取动态数组所有权的迭代器。接着利用 filter 来将该迭代器适配成一个新的迭代器,新的迭代器只会包含那些 闭包返回值为 true 的元素。闭包从环境中捕获了 shoe_size 参数。

使用 Iterator trait 来创建自定义迭代器

如下演示了如何通过实现 Iterator trait 来创建拥有自定义行为的迭代器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
struct Counter {
count: u32,
}

impl Counter {
fn new() -> Counter {
Counter {count: 0}
}
}

impl Iterator for Counter {
type Item = u32;

fn next(&mut self) -> Option<Self::Item> {
self.count += 1;

if self.count < 6 {
Some(self.count)
} else {
None
}
}
}

#[test]
fn calling_next_directly() {
let mut counter = Counter::new();

assert_eq!(counter.next(), Some(1));
assert_eq!(counter.next(), Some(2));
assert_eq!(counter.next(), Some(3));
assert_eq!(counter.next(), Some(4));
assert_eq!(counter.next(), Some(5));
assert_eq!(counter.next(), None);
}

我们只需要提供 next() 方法的定义便可以使用标准库中那些拥有默认实现的 Iterator trait 的方法,因为这些方法都依赖于 next 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#[test]
fn calling_next_directly() {
let mut counter = Counter::new();

assert_eq!(counter.next(), Some(1));
assert_eq!(counter.next(), Some(2));
assert_eq!(counter.next(), Some(3));
assert_eq!(counter.next(), Some(4));
assert_eq!(counter.next(), Some(5));
assert_eq!(counter.next(), None);

let counter2 = Counter::new();
let sum: u32 = counter2.skip(1).map(|x| x).sum();
assert_eq!(sum, 14);
}

改进 I/O 项目

上一篇文章里的 I/O 项目里我们使用到了 env::args(),它其实就是返回一个迭代器。所以与其将迭代器产生的值收集至动态数组后再作为切片传入 Config::new 中,不如选择直接传递迭代器本身。

另外,search() 函数的代码也可以通过使用迭代器而更加清晰:

1
2
3
4
5
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
contents.lines()
.filter(|line| line.contains(query))
.collect()
}

函数式编程风格倾向于在程序中最小化可变状态的数量来使得代码更加清晰。消除可变状态也可以使我们可以在未来通过并行化来提升搜索效率。迭代器可以让开发者专注于高层的业务逻辑,而不必陷入编写循环、维护中间变量这些具体的细节中。**通过高层抽象去消除一些惯例化的模版代码,可以让代码的重点逻辑更加突出。

比较循环和迭代器的性能

尽管迭代器是一种高层次的抽象,但是它在编译后生成了与写底层代码几乎一样的产物。迭代器是 Rust 语言中的一种零开销抽象(zero-cost abstraction),这意味着在使用这些抽象时不会引入额外的运行时开销。

我们完全可以无所畏惧地使用迭代器和闭包,它们既能够让代码在观感上高层次的抽象,又不会因此带来任何运行时性能损失。