第13章 函数式语言特性:迭代器与闭包
Rust 的设计从许多现有的语言和技术中汲取灵感,其中一个重要影响就是函数式编程(functional programming)。以函数式风格编程通常会把函数当作值来使用,比如将它们作为参数传递、作为其他函数的返回值返回、赋给变量以便稍后执行,等等。
本章不会去争论函数式编程到底算什么或不算什么,而是会讨论 Rust 中一些与许多常被称为函数式语言的特性相似的功能。
更具体地,我们将要涉及:
- 闭包(Closures),一种可以存储在变量中的类似函数的结构
- 迭代器(Iterators),一种处理一系列元素的方式
- 如何使用闭包和迭代器来改进第十二章的 I/O 项目
- 闭包和迭代器的性能(剧透警告: 它们的速度超乎你的想象!)
我们已经介绍过 Rust 中另外一些受函数式风格影响的功能,比如模式匹配和枚举。因为掌握闭包和迭代器是编写快速、惯用 Rust 代码的重要组成部分,所以我们会专门用整整一章来讲解它们。
闭包:可以捕获其环境的匿名函数
闭包
Rust 的 闭包(closures)是可以保存在变量中或作为参数传递给其他函数的匿名函数。你可以在一个地方创建闭包,然后在不同的上下文中执行闭包运算。不同于函数,闭包允许捕获其被定义时所在作用域中的值。我们将展示这些闭包特性如何支持代码复用和行为定制。
捕获环境
我们首先了解如何通过闭包捕获定义它的环境中的值以便之后使用。考虑如下场景:我们的 T 恤公司偶尔会向邮件列表中的某位成员赠送一件限量版的独家 T 恤作为促销。邮件列表中的成员可以选择将他们的喜爱的颜色添加到个人信息中。如果被选中的成员设置了喜爱的颜色,他们将获得那个颜色的 T 恤。如果他没有设置喜爱的颜色,他们会获赠公司当前库存最多的颜色的款式。
有很多种方式来实现这一点。例如,使用有 Red 和 Blue 两个变体的 ShirtColor 枚举(出于简单考虑限定为两种颜色)。我们使用 Inventory 结构体来代表公司的库存,它有一个类型为 Vec<ShirtColor> 的 shirts 字段表示库存中的衬衫的颜色。Inventory 上定义的 giveaway 方法获取免费衬衫得主所喜爱的颜色(如有),并返回其获得的衬衫的颜色。初始代码如示例 13-1 所示:
文件名:src/main.rs
#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor {
Red,
Blue,
}
struct Inventory {
shirts: Vec<ShirtColor>,
}
impl Inventory {
fn giveaway(&self, user_preference: Option<ShirtColor>) -> ShirtColor {
user_preference.unwrap_or_else(|| self.most_stocked())
}
fn most_stocked(&self) -> ShirtColor {
let mut num_red = 0;
let mut num_blue = 0;
for color in &self.shirts {
match color {
ShirtColor::Red => num_red += 1,
ShirtColor::Blue => num_blue += 1,
}
}
if num_red > num_blue {
ShirtColor::Red
} else {
ShirtColor::Blue
}
}
}
fn main() {
let store = Inventory {
shirts: vec![ShirtColor::Blue, ShirtColor::Red, ShirtColor::Blue],
};
let user_pref1 = Some(ShirtColor::Red);
let giveaway1 = store.giveaway(user_pref1);
println!(
"The user with preference {:?} gets {:?}",
user_pref1, giveaway1
);
let user_pref2 = None;
let giveaway2 = store.giveaway(user_pref2);
println!(
"The user with preference {:?} gets {:?}",
user_pref2, giveaway2
);
}
示例 13-1:衬衫公司赠送场景
main 函数中定义的 store 还剩下两件蓝衬衫和一件红衬衫,可以在限量版促销活动中赠送。我们通过调用 giveaway 方法,为一个期望红衬衫的用户和一个没有特定偏好的用户进行赠送。
再次强调,这段代码有多种实现方式。这里为了专注于闭包,我们继续使用已经学习过的概念,除了 giveaway 方法体中使用了闭包。在 giveaway 方法中,我们将用户偏好作为 Option<ShirtColor> 类型的参数获取,并在 user_preference 上调用 unwrap_or_else 方法。Option<T> 上的 unwrap_or_else 方法由标准库定义。它接受一个无参闭包作为参数,该闭包返回一个 T 类型的值(与 Option<T> 的 Some 变体中存储的值类型相同,这里是 ShirtColor)。如果 Option<T> 是 Some 变体,则 unwrap_or_else 返回 Some 中的值。如果 Option<T> 是 None 变体,则 unwrap_or_else 调用闭包并返回闭包的返回值。
我们将闭包表达式 || self.most_stocked() 作为 unwrap_or_else 的参数。这是一个本身不获取参数的闭包(如果闭包有参数,它们会出现在两道竖杠之间)。闭包体调用了 self.most_stocked()。我们在这里定义了闭包,而 unwrap_or_else 的实现会在之后需要其结果的时候执行闭包。
运行代码会打印出:
$ cargo run
Compiling shirt-company v0.1.0 (file:///projects/shirt-company)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
Running `target/debug/shirt-company`
The user with preference Some(Red) gets Red
The user with preference None gets Blue
这里有一个有趣的地方是,我们传递了一个闭包,该闭包会在当前的 Inventory 实例上调用 self.most_stocked() 方法。标准库不需要了解我们定义的 Inventory 或 ShirtColor 类型,也不需要了解我们在这个场景中要使用的逻辑。闭包捕获了对 self(即 Inventory 实例)的不可变引用,并将其与我们指定的代码一起传递给 unwrap_or_else 方法。相比之下,函数无法以这种方式捕获其环境。
推断和注解闭包类型
函数与闭包还有更多区别。闭包通常不要求像 fn 函数那样对参数和返回值进行类型注解。函数需要类型注解是因为这些类型是暴露给用户的显式接口的一部分。严格定义这些接口对于确保所有人对函数使用和返回值的类型达成一致理解非常重要。与此相比,闭包并不用于这样暴露在外的接口:它们储存在变量中并被使用,不用命名它们或暴露给库的用户调用。
闭包通常较短,并且只与特定的上下文相关,而不是适用于任意情境。在这些有限的上下文中,编译器可以推断参数和返回值的类型,类似于它推断大多数变量类型的方式(尽管在某些罕见的情况下,编译器也需要闭包的类型注解)。
类似于变量,如果我们希望增加代码的明确性和清晰度,可以添加类型注解,但代价是会使代码变得比严格必要的更冗长。为示例 13-1 中定义的闭包标注类型看起来如示例 13-2 中的定义一样。这个例子中,我们定义了一个闭包并将它保存在变量中,而不是像示例 13-1 那样在传参的地方定义它。
文件名:src/main.rs
use std::thread;
use std::time::Duration;
fn generate_workout(intensity: u32, random_number: u32) {
let expensive_closure = |num: u32| -> u32 {
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)
);
}
}
}
fn main() {
let simulated_user_specified_value = 10;
let simulated_random_number = 7;
generate_workout(simulated_user_specified_value, simulated_random_number);
}
示例 13-2:为闭包的参数和返回值增加可选的类型注解
有了类型注解,闭包的语法看起来就更像函数的语法了。如下是一个对其参数加一的函数的定义与拥有相同行为闭包语法的纵向对比。这里增加了一些空格来对齐相应部分。这展示了除了使用管道符(|,pipes)以及一些可选语法外,闭包语法与函数语法有多么地相似:
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 能够编译的必要条件,因为类型将从其用法中推断出来。这类似于 let v = Vec::new();,Rust 需要类型注解或是某种类型的值被插入到 Vec 中,才能推断其类型。
对于闭包定义,编译器会为每个参数和返回值推断出一个具体类型。例如,示例 13-3 展示了一个简短的闭包定义,该闭包仅仅返回作为参数接收到的值。除了作为示例用途外,这个闭包并不是很实用。注意这个定义没有增加任何类型注解。因为没有类型注解,我们可以使用任意类型来调用这个闭包,我们在这里第一次调用时使用了 String 类型。但是如果我们接着尝试使用整数来调用 example_closure,就会得到一个错误。
文件名:src/main.rs
fn main() {
let example_closure = |x| x;
let s = example_closure(String::from("hello"));
let n = example_closure(5);
}
示例 13-3:尝试调用一个被推断为两个不同类型的闭包
编译器给出如下错误:
$ 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 `String`, found integer
| |
| arguments to this function are incorrect
|
note: expected because the closure was earlier called with an argument of type `String`
--> src/main.rs:4:29
|
4 | let s = example_closure(String::from("hello"));
| --------------- ^^^^^^^^^^^^^^^^^^^^^ expected because this argument is of type `String`
| |
| in this closure call
note: closure parameter defined here
--> src/main.rs:2:28
|
2 | let example_closure = |x| x;
| ^
help: try using a conversion method
|
5 | let n = example_closure(5.to_string());
| ++++++++++++
For more information about this error, try `rustc --explain E0308`.
error: could not compile `closure-example` (bin "closure-example") due to 1 previous error
第一次使用 String 值调用 example_closure 时,编译器推断出 x 的类型以及闭包的返回类型为 String。接着这些类型被锁定进闭包 example_closure 中,如果尝试对同一闭包使用不同类型则就会得到类型错误。
捕获引用或移动所有权
闭包可以通过三种方式捕获其环境中的值,它们直接对应到函数获取参数的三种方式:不可变借用、可变借用和获取所有权。闭包将根据函数体中对捕获值的操作来决定使用哪种方式。
在示例 13-4 中定义了一个捕获名为 list 的 vector 的不可变引用的闭包,因为只需不可变引用就能打印其值:
文件名:src/main.rs
fn main() {
let list = vec![1, 2, 3];
println!("Before defining closure: {list:?}");
let only_borrows = || println!("From closure: {list:?}");
println!("Before calling closure: {list:?}");
only_borrows();
println!("After calling closure: {list:?}");
}
示例 13-4:定义并调用一个捕获不可变引用的闭包
这个示例也展示了变量可以绑定一个闭包定义,并且我们可以像使用函数名一样,使用变量名和括号来调用该闭包。
因为同时可以有多个 list 的不可变引用,所以在闭包定义之前,闭包定义之后调用之前,闭包调用之后代码仍然可以访问 list。该代码可以编译、运行并输出:
$ cargo run
Compiling closure-example v0.1.0 (file:///projects/closure-example)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
Before calling closure: [1, 2, 3]
From closure: [1, 2, 3]
After calling closure: [1, 2, 3]
接下来在示例 13-5 中,我们修改闭包体让它向 list vector 增加一个元素。闭包现在捕获一个可变引用:
文件名:src/main.rs
fn main() {
let mut list = vec![1, 2, 3];
println!("Before defining closure: {list:?}");
let mut borrows_mutably = || list.push(7);
borrows_mutably();
println!("After calling closure: {list:?}");
}
示例 13-5:定义并调用一个捕获可变引用的闭包
代码可以编译、运行并打印:
$ cargo run
Compiling closure-example v0.1.0 (file:///projects/closure-example)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
After calling closure: [1, 2, 3, 7]
注意在 borrows_mutably 闭包的定义和调用之间不再有 println!,这是因为当 borrows_mutably 被定义时,它捕获了对 list 的可变引用。闭包在被调用后就不再被使用,这时可变借用结束。因为当可变借用存在时不允许有其它的借用,所以在闭包定义和调用之间不能有不可变引用来进行打印。可以尝试在这里添加 println! 看看你会得到什么报错信息!
即使闭包体不严格需要所有权,如果希望强制闭包获取它在环境中所使用的值的所有权,可以在参数列表前使用 move 关键字。
当将闭包传递到一个新的线程时,这个技巧特别有用,因为它将数据的所有权移动到新线程中。我们将在第十六章讨论并发时详细讨论线程以及为什么你可能需要使用它们。不过现在,我们先简要探索一下如何使用需要 move 关键字的闭包来生成一个新线程。示例 13-6 展示了如何修改示例 13-4,以便在一个新线程中而不是在主线程中打印 vector:
文件名:src/main.rs
use std::thread;
fn main() {
let list = vec![1, 2, 3];
println!("Before defining closure: {list:?}");
thread::spawn(move || println!("From thread: {list:?}"))
.join()
.unwrap();
}
示例 13-6:使用 move 来强制闭包为线程获取 list 的所有权
我们生成了一个新的线程,并给这个线程传递一个闭包作为参数来运行,闭包体打印出列表。在示例 13-4 中,闭包仅通过不可变引用捕获了 list,因为这是打印列表所需的最小访问权限。这个例子中,尽管闭包体依然只需要不可变引用,我们还是在闭包定义前写上 move 关键字,以确保 list 被移动到闭包中。新线程可能在主线程剩余部分执行完前执行完,也可能在主线程执行完之后执行完。如果主线程维护了 list 的所有权但却在新线程之前结束并且丢弃了 list,则在线程中的不可变引用将失效。因此,编译器要求 list 被移动到在新线程中运行的闭包中,这样引用就是有效的。试着移除 move 关键字,或者在闭包定义后在主线程中使用 list,看看你会得到什么编译器报错!
将捕获的值移出闭包
一旦闭包捕获了定义它的环境中的某个值的引用或所有权(也就影响了什么会被移进闭包,如有),闭包体中的代码则决定了在稍后执行闭包时,这些引用或值将如何处理(也就影响了什么会被移出闭包,如有)。闭包体可以执行以下任一操作:将一个捕获的值移出闭包,修改捕获的值,既不移动也不修改值,或者一开始就不从环境中捕获任何值。
闭包捕获和处理环境中的值的方式会影响闭包实现哪些 trait,而 trait 是函数和结构体指定它们可以使用哪些类型闭包的方式。根据闭包体如何处理这些值,闭包会自动、渐进地实现一个、两个或全部三个 Fn trait。
FnOnce适用于至少可以被调用一次的闭包。因为任何闭包都应当可以被调用,所以所有闭包都必须实现这个 trait。一个会将捕获的值移出闭包自身的闭包只能实现FnOnce而无法实现另外两个Fntrait,因为这种闭包最多只能被调用一次。FnMut适用于不会将捕获的值移出闭包自身但可能会修改捕获的值的闭包。这类闭包可以被调用多次。Fn适用于既不会将捕获的值移出闭包自身也不会修改捕获的值的闭包,以及不从环境中捕获任何值的闭包。这类闭包可以被调用多次而不改变环境,在并发多次调用闭包等场景中十分重要。
让我们来看示例 13-1 中使用的在 Option<T> 上的 unwrap_or_else 方法的定义:
impl<T> Option<T> {
pub fn unwrap_or_else<F>(self, f: F) -> T
where
F: FnOnce() -> T
{
match self {
Some(x) => x,
None => f(),
}
}
}
回忆一下,T 是表示 Option 中 Some 变体中的值的类型的泛型。类型 T 也是 unwrap_or_else 函数的返回值类型:举例来说,在 Option<String> 上调用 unwrap_or_else 会得到一个 String。
接着注意到 unwrap_or_else 函数有额外的泛型参数 F。F 是参数 f 的类型,f 是调用 unwrap_or_else 时提供的闭包。
泛型 F 的 trait 约束是 FnOnce() -> T,这意味着 F 必须能够被调用一次,没有参数并返回一个 T。在 trait 约束中使用 FnOnce 表示 unwrap_or_else 最多只会调用 f 一次。在 unwrap_or_else 的函数体中可以看到,如果 Option 是 Some,f 不会被调用。如果 Option 是 None,f 将会被调用一次。由于所有的闭包都实现了 FnOnce,unwrap_or_else 接受所有三种类型的闭包,灵活性达到极致。
注意:如果我们要做的事情不需要从环境中捕获值,则可以在需要某种实现了
Fntrait 的东西时使用函数而不是闭包。举个例子,可以在Option<Vec<T>>的值上调用unwrap_or_else(Vec::new),以便在值为None时获取一个新的空的 vector。编译器会自动为函数定义实现适用的Fntrait。
现在让我们来看定义在 slice 上的标准库方法 sort_by_key,看看它与 unwrap_or_else 的区别,以及为什么 sort_by_key 使用 FnMut 而不是 FnOnce 作为 trait 约束。这个闭包以一个 slice 中当前被考虑的元素的引用作为参数,并返回一个可以排序的 K 类型的值。当你想按照 slice 中每个元素的某个属性进行排序时,这个函数非常有用。在示例 13-7 中,我们有一个 Rectangle 实例的列表,并使用 sort_by_key 按 Rectangle 的 width 属性对它们从低到高排序:
文件名:src/main.rs
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let mut list = [
Rectangle { width: 10, height: 1 },
Rectangle { width: 3, height: 5 },
Rectangle { width: 7, height: 12 },
];
list.sort_by_key(|r| r.width);
println!("{list:#?}");
}
示例 13-7:使用 sort_by_key 对长方形按宽度排序
代码输出:
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
Running `target/debug/rectangles`
[
Rectangle {
width: 3,
height: 5,
},
Rectangle {
width: 7,
height: 12,
},
Rectangle {
width: 10,
height: 1,
},
]
sort_by_key 被定义为接收一个 FnMut 闭包的原因是它会多次调用这个闭包:对 slice 中的每个元素调用一次。闭包 |r| r.width 不捕获、修改或将任何东西移出它的环境,所以它满足 trait 约束的要求。
相比之下,示例 13-8 展示了一个只实现了 FnOnce trait 的闭包的例子,因为它从环境中移出了一个值。编译器不允许我们在 sort_by_key 中使用这个闭包:
文件名:src/main.rs
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let mut list = [
Rectangle { width: 10, height: 1 },
Rectangle { width: 3, height: 5 },
Rectangle { width: 7, height: 12 },
];
let mut sort_operations = vec![];
let value = String::from("closure called");
list.sort_by_key(|r| {
sort_operations.push(value);
r.width
});
println!("{list:#?}");
}
示例 13-8:尝试在 sort_by_key 上使用一个 FnOnce 闭包
这是一个刻意构造的、复杂且无效的方式,试图统计在对 list 进行排序时 sort_by_key 调用闭包的次数。该代码试图通过将闭包环境中的 value(一个 String)插入 sort_operations vector 来实现计数。闭包捕获了 value,然后通过将 value 的所有权转移给 sort_operations vector 的方式将其移出闭包。这个闭包只能被调用一次;尝试第二次调用它将无法工作,因为这时 value 已经不在闭包的环境中,无法被再次插入 sort_operations 中!因而,这个闭包只实现了 FnOnce。当我们尝试编译此代码时,会出现错误提示:value 不能从闭包中移出,因为闭包必须实现 FnMut:
$ cargo run
Compiling rectangles v0.1.0 (file:///projects/rectangles)
error[E0507]: cannot move out of `value`, a captured variable in an `FnMut` closure
--> src/main.rs:18:30
|
15 | let value = String::from("closure called");
| ----- ------------------------------ move occurs because `value` has type `String`, which does not implement the `Copy` trait
| |
| captured outer variable
16 |
17 | list.sort_by_key(|r| {
| --- captured by this `FnMut` closure
18 | sort_operations.push(value);
| ^^^^^ `value` is moved here
|
help: consider cloning the value if the performance cost is acceptable
|
18 | sort_operations.push(value.clone());
| ++++++++
For more information about this error, try `rustc --explain E0507`.
error: could not compile `rectangles` (bin "rectangles") due to 1 previous error
报错指向了闭包体中将 value 移出环境的那一行。要修复此问题,我们需要修改闭包体,使其不会将值移出环境。在环境中维护一个计数器,并在闭包体中递增其值,是计算闭包被调用次数的一个更直观的方法。示例 13-9 中的闭包可以在 sort_by_key 中使用,因为它只捕获了 num_sort_operations 计数器的可变引用,因此可以被多次调用:
文件名:src/main.rs
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let mut list = [
Rectangle { width: 10, height: 1 },
Rectangle { width: 3, height: 5 },
Rectangle { width: 7, height: 12 },
];
let mut num_sort_operations = 0;
list.sort_by_key(|r| {
num_sort_operations += 1;
r.width
});
println!("{list:#?}, sorted in {num_sort_operations} operations");
}
示例 13-9:允许在 sort_by_key 上使用一个 FnMut 闭包
当定义或使用涉及闭包的函数或类型时,Fn trait 十分重要。在下个小节中,我们将讨论迭代器。许多迭代器方法都接收闭包参数,因此在继续前,请记住这些闭包的细节!
使用迭代器处理元素序列
使用迭代器处理元素序列
迭代器模式允许你依次对一个序列中的项执行某些操作。迭代器(iterator)负责遍历序列中的每一项并确定序列何时结束的逻辑。使用迭代器时,你无需自己重新实现这些逻辑。
在 Rust 中,迭代器是惰性的(lazy),这意味着在调用消费迭代器的方法之前不会执行任何操作。例如,示例 13-10 中的代码通过调用定义于 Vec<T> 上的 iter 方法在一个 vector v1 上创建了一个迭代器。这段代码本身并没有执行任何有用的操作。
fn main() {
let v1 = vec![1, 2, 3];
let v1_iter = v1.iter();
}
示例 13-10:创建一个迭代器
迭代器被储存在 v1_iter 变量中。一旦创建迭代器之后,可以选择用多种方式利用它。在第三章的示例 3-5 中,我们使用 for 循环来遍历一个数组并在每一个项上执行了一些代码。在底层它隐式地创建并接着消费了一个迭代器,不过直到现在我们都一笔带过了它具体是如何工作的。
示例 13-11 中的例子将迭代器的创建和 for 循环中的使用分开。当 for 循环使用 v1_iter 中的迭代器时,迭代器中的每一个元素都会用于循环的一次迭代,并打印出每个值。
fn main() {
let v1 = vec![1, 2, 3];
let v1_iter = v1.iter();
for val in v1_iter {
println!("Got: {val}");
}
}
示例 13-11:在一个 for 循环中使用迭代器
在标准库中没有提供迭代器的语言中,我们可能会使用一个从 0 开始索引的变量,使用这个变量索引 vector 中的值,并循环增加其值直到达到 vector 中的元素总量,以实现相同的功能。
迭代器为我们处理了所有这些逻辑,这减少了重复代码并消除了潜在的混乱。另外,迭代器的实现方式提供了对多种不同的序列使用相同逻辑的灵活性,而不仅仅是像 vector 这样可索引的数据结构。让我们看看迭代器是如何做到这些的。
Iterator trait 和 next 方法
迭代器都实现了名为 Iterator 的定义于标准库的 trait。这个 trait 的定义看起来像这样:
#![allow(unused)]
fn main() {
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// 此处省略了方法的默认实现
}
}
注意这里有一个我们还未讲到的新语法:type Item 和 Self::Item,它们定义了 trait 的关联类型(associated type)。第二十章会深入讲解关联类型,不过现在只需知道这段代码表明实现 Iterator trait 要求同时定义一个 Item 类型,这个 Item 类型被用作 next 方法的返回值类型。换句话说,Item 类型将是迭代器返回元素的类型。
Iterator trait 仅要求实现者定义一个方法:next 方法,该方法每次返回迭代器中的一个项,封装在 Some 中,并且当迭代完成时,返回 None。
可以直接调用迭代器的 next 方法;示例 13-12 展示了对由 vector 创建的迭代器重复调用 next 方法时返回的值。
文件名:src/lib.rs
#[cfg(test)]
mod tests {
#[test]
fn iterator_demonstration() {
let v1 = vec![1, 2, 3];
let mut v1_iter = v1.iter();
assert_eq!(v1_iter.next(), Some(&1));
assert_eq!(v1_iter.next(), Some(&2));
assert_eq!(v1_iter.next(), Some(&3));
assert_eq!(v1_iter.next(), None);
}
}
示例 13-12:在迭代器上(直接)调用 next 方法
注意我们需要将 v1_iter 声明为可变的:在迭代器上调用 next 方法会改变迭代器内部的状态,该状态用于跟踪迭代器在序列中的位置。换句话说,代码消费(consume)了,或者说用尽了迭代器。每一次 next 调用都会从迭代器中消费一个项。使用 for 循环时无需使 v1_iter 可变因为 for 循环会获取 v1_iter 的所有权并在后台使 v1_iter 可变。
还需要注意的是,从 next 调用中获取的值是对 vector 中值的不可变引用。iter 方法生成一个不可变引用的迭代器。如果我们需要一个获取 v1 所有权并返回拥有所有权的迭代器,则可以调用 into_iter 而不是 iter。类似地,如果我们希望迭代可变引用,可以调用 iter_mut 而不是 iter。
消费迭代器的方法
Iterator trait 有一系列不同的由标准库提供默认实现的方法;你可以在 Iterator trait 的标准库 API 文档中找到所有这些方法。一些方法在其定义中调用了 next 方法,这也就是为什么在实现 Iterator trait 时要求实现 next 方法的原因。
这些调用 next 方法的方法被称为消费适配器(consuming adaptors),因为调用它们会消耗迭代器。一个消费适配器的例子是 sum 方法,这个方法获取迭代器的所有权并反复调用 next 来遍历迭代器,从而消费迭代器。在遍历过程中,它将每个项累加到一个运行时总和中,并在迭代完成时返回这个总和。示例 13-13 有一个展示 sum 方法使用的测试:
文件名:src/lib.rs
#[cfg(test)]
mod tests {
#[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);
}
}
示例 13-13:调用 sum 方法获取迭代器所有项的总和
调用 sum 之后不再允许使用 v1_iter 因为调用 sum 时它会获取迭代器的所有权。
产生其他迭代器的方法
Iterator trait 中定义了另一类方法,被称为迭代器适配器(iterator adaptors),它们不会消耗当前的迭代器,而是通过改变原始迭代器的某些方面来生成不同的迭代器。
示例 13-14 展示了一个调用迭代器适配器方法 map 的例子,该方法使用一个闭包对每个元素进行操作。map 方法返回一个新的迭代器,该迭代器生成经过修改的元素。这里的闭包创建了一个新的迭代器,其中 vector 中的每个元素都被加 1。
文件名:src/main.rs
fn main() {
let v1: Vec<i32> = vec![1, 2, 3];
v1.iter().map(|x| x + 1);
}
示例 13-14:调用迭代器适配器 map 来创建一个新迭代器
不过这些代码会产生一个警告:
$ cargo run
Compiling iterators v0.1.0 (file:///projects/iterators)
warning: unused `Map` that must be used
--> src/main.rs:4:5
|
4 | v1.iter().map(|x| x + 1);
| ^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: iterators are lazy and do nothing unless consumed
= note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
|
4 | let _ = v1.iter().map(|x| x + 1);
| +++++++
warning: `iterators` (bin "iterators") generated 1 warning
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.47s
Running `target/debug/iterators`
示例 13-14 中的代码实际上并没有做任何事;所指定的闭包从未被调用过。警告提醒了我们原因所在:迭代器适配器是惰性的,因此我们需要在此处消费迭代器。
为了修复这个警告并消费迭代器,我们将使用第十二章示例 12-1 结合 env::args 使用的 collect 方法。这个方法消费迭代器并将结果收集到一个集合数据类型中。
在示例 13-15 中,我们将遍历由 map 调用生成的迭代器结果收集到一个 vector 中。这个 vector 将包含原始 vector 中每个元素加 1 的结果。
文件名:src/main.rs
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]);
}
示例 13-15:调用 map 方法创建一个新迭代器,接着调用 collect 方法消费新迭代器并创建一个 vector
由于 map 接受一个闭包,因此我们可以指定希望在每个元素上执行的任何操作。这是一个很好的例子,展示了如何通过闭包来自定义某些行为,同时复用 Iterator trait 提供的迭代行为。
可以链式调用多个迭代器适配器来以一种易读的方式进行复杂的操作。不过因为所有的迭代器都是惰性的,你必须调用一个消费适配器方法,才能从这些迭代器适配器的调用中获取结果。
使用捕获其环境的闭包
很多迭代器适配器接受闭包作为参数,而我们通常会指定捕获其环境的闭包作为迭代器适配器的参数。
作为一个例子,我们使用 filter 方法来获取一个闭包。该闭包从迭代器中获取一项并返回一个 bool。如果闭包返回 true,其值将会包含在 filter 提供的新迭代器中。如果闭包返回 false,其值不会被包含。
示例 13-16 中使用 filter 和一个捕获环境中变量 shoe_size 的闭包来遍历一个 Shoe 结构体集合。它只会返回指定鞋码的鞋子。
文件名:src/lib.rs
#[derive(PartialEq, Debug)]
struct Shoe {
size: u32,
style: String,
}
fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
shoes.into_iter().filter(|s| s.size == shoe_size).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filters_by_size() {
let shoes = vec![
Shoe {
size: 10,
style: String::from("sneaker"),
},
Shoe {
size: 13,
style: String::from("sandal"),
},
Shoe {
size: 10,
style: String::from("boot"),
},
];
let in_my_size = shoes_in_size(shoes, 10);
assert_eq!(
in_my_size,
vec![
Shoe {
size: 10,
style: String::from("sneaker")
},
Shoe {
size: 10,
style: String::from("boot")
},
]
);
}
}
示例 13-16:使用 filter 方法和一个捕获 shoe_size 的闭包
shoes_in_size 函数获取一个鞋子 vector 的所有权和一个鞋码作为参数。它返回一个只包含指定鞋码的鞋子的 vector。
shoes_in_size 函数体中调用了 into_iter 来创建一个获取 vector 所有权的迭代器。接着调用 filter 将这个迭代器适配成一个只含有那些闭包返回 true 的元素的新迭代器。
闭包从环境中捕获了 shoe_size 变量并使用其值与每一只鞋的大小作比较,只保留指定鞋码的鞋子。最终,调用 collect 将迭代器适配器返回的值收集进一个 vector 并返回。
这个测试展示当调用 shoes_in_size 时,返回的只会是与我们指定的鞋码相同的鞋子。
改进之前的 I/O 项目
改进 I/O 项目
掌握了这些关于迭代器的新知识后,我们可以使用迭代器来改进第十二章中 I/O 项目的实现来使得代码更简洁明了。接下来,让我们看看迭代器如何改进 Config::build 函数和 search 函数的实现。
使用迭代器消除 clone
在示例 12-6 中,我们增加了一些代码获取一个 String 类型的 slice 并创建一个 Config 结构体的实例,它们索引 slice 中的值并克隆这些值以便 Config 结构体可以拥有这些值。在示例 13-17 中重现了第十二章结尾示例 12-23 中 Config::build 函数的实现:
文件名:src/lib.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {err}");
process::exit(1);
});
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
query,
file_path,
ignore_case,
})
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
let results = if config.ignore_case {
search_case_insensitive(&config.query, &contents)
} else {
search(&config.query, &contents)
};
for line in results {
println!("{line}");
}
Ok(())
}
示例 13-17:重现示例 12-23 的 Config::build 函数
当时我们说过不必担心低效的 clone 调用,因为我们以后会将其移除。好吧,就是现在!
起初这里需要 clone 的原因是参数 args 中有一个 String 元素的 slice,而 build 函数并不拥有 args。为了能够返回 Config 实例的所有权,我们不得不克隆 Config 中字段 query 和 file_path 的值,这样 Config 实例就能拥有这些值。
在学习了迭代器之后,我们可以将 build 函数改为获取一个有所有权的迭代器作为参数,而不是借用 slice。我们将使用迭代器功能代替之前检查 slice 长度和索引特定位置的代码。这样可以更清晰地表达 Config::build 函数的操作,因为迭代器会负责访问这些值。
一旦 Config::build 获取了迭代器的所有权并不再使用借用的索引操作,就可以将迭代器中的 String 值移动到 Config 中,而不是调用 clone 分配新的空间。
直接使用返回的迭代器
打开 I/O 项目的 src/main.rs 文件,它看起来应该像这样:
文件名:src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {err}");
process::exit(1);
});
// --snip--
if let Err(e) = run(config) {
eprintln!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
query,
file_path,
ignore_case,
})
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
let results = if config.ignore_case {
search_case_insensitive(&config.query, &contents)
} else {
search(&config.query, &contents)
};
for line in results {
println!("{line}");
}
Ok(())
}
首先我们修改第十二章结尾示例 12-24 中的 main 函数的开头为示例 13-18 中的代码。在更新 Config::build 之前这些代码还不能编译:
文件名:src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
fn main() {
let config = Config::build(env::args()).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {err}");
process::exit(1);
});
// --snip--
if let Err(e) = run(config) {
eprintln!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
query,
file_path,
ignore_case,
})
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
let results = if config.ignore_case {
search_case_insensitive(&config.query, &contents)
} else {
search(&config.query, &contents)
};
for line in results {
println!("{line}");
}
Ok(())
}
示例 13-18:将 env::args 的返回值传递给 Config::build
env::args 函数返回一个迭代器!不同于将迭代器的值收集到一个 vector 中接着传递一个 slice 给 Config::build,现在我们直接将 env::args 返回的迭代器的所有权传递给 Config::build。
接下来需要更新 Config::build 的定义。在 I/O 项目的 src/lib.rs 中,将 Config::build 的签名改为如示例 13-19 所示。这仍然不能编译因为我们还需更新函数体。
文件名:src/lib.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
fn main() {
let config = Config::build(env::args()).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {err}");
process::exit(1);
});
if let Err(e) = run(config) {
eprintln!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
impl Config {
fn build(
mut args: impl Iterator<Item = String>,
) -> Result<Config, &'static str> {
// --snip--
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
query,
file_path,
ignore_case,
})
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
let results = if config.ignore_case {
search_case_insensitive(&config.query, &contents)
} else {
search(&config.query, &contents)
};
for line in results {
println!("{line}");
}
Ok(())
}
示例 13-19:以迭代器作为参数更新 Config::build 的签名
env::args 函数的标准库文档显示,它返回的迭代器的类型为 std::env::Args,并且这个类型实现了 Iterator trait 并返回 String 值。
我们已经更新了 Config::build 函数的签名,因此参数 args 有一个带有 trait 约束 impl Iterator<Item = String> 的泛型类型,而不是 &[String]。这里用到了第十章“trait 作为参数”部分讨论过的 impl Trait 语法,这意味着 args 可以是任何实现了 Iterator trait 并返回 String 项(item)的类型。
由于我们获取了 args 的所有权,并且将通过迭代来修改 args,因此我们可以在 args 参数的声明中添加 mut 关键字,使其可变。
使用 Iterator trait 方法
接下来,我们将修改 Config::build 的函数体。因为 args 实现了 Iterator trait,因此我们知道可以对其调用 next 方法!示例 13-20 更新了示例 12-23 中的代码,以使用 next 方法:
文件名:src/lib.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
fn main() {
let config = Config::build(env::args()).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {err}");
process::exit(1);
});
if let Err(e) = run(config) {
eprintln!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
impl Config {
fn build(
mut args: impl Iterator<Item = String>,
) -> Result<Config, &'static str> {
args.next();
let query = match args.next() {
Some(arg) => arg,
None => return Err("Didn't get a query string"),
};
let file_path = match args.next() {
Some(arg) => arg,
None => return Err("Didn't get a file path"),
};
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
query,
file_path,
ignore_case,
})
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
let results = if config.ignore_case {
search_case_insensitive(&config.query, &contents)
} else {
search(&config.query, &contents)
};
for line in results {
println!("{line}");
}
Ok(())
}
示例 13-20:修改 Config::build 的函数体来使用迭代器方法
请记住 env::args 返回值的第一个值是程序的名称。我们希望忽略它并获取下一个值,所以首先调用 next 且不对其返回值做任何操作。然后,我们再次调用 next 来获取要放入 Config 结构体的 query 字段的值。如果 next 返回 Some,使用 match 来提取其值。如果它返回 None,则意味着没有提供足够的参数并通过 Err 值提早返回。我们对 file_path 的值也进行同样的操作。
使用迭代器适配器简化代码
I/O 项目中其他可以利用迭代器的地方是 search 函数,示例 13-21 中重现了第十二章结尾示例 12-19 中此函数的定义:
文件名:src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
}
示例 13-21:示例 12-19 中 search 函数的定义
可以通过使用迭代器适配器方法来编写更简明的代码。这样做还可以避免使用一个可变的中间 results vector。函数式编程风格倾向于最小化可变状态的数量来使代码更清晰。去除可变状态可能会使未来的并行搜索优化变得更容易,因为我们不必管理对 results vector 的并发访问。示例 13-22 展示了这一变化:
文件名:src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
contents
.lines()
.filter(|line| line.contains(query))
.collect()
}
pub fn search_case_insensitive<'a>(
query: &str,
contents: &'a str,
) -> Vec<&'a str> {
let query = query.to_lowercase();
let mut results = Vec::new();
for line in contents.lines() {
if line.to_lowercase().contains(&query) {
results.push(line);
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn case_sensitive() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
#[test]
fn case_insensitive() {
let query = "rUsT";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";
assert_eq!(
vec!["Rust:", "Trust me."],
search_case_insensitive(query, contents)
);
}
}
示例 13-22:在 search 函数实现中使用迭代器适配器
回忆一下,search 函数的目的是返回所有 contents 中包含 query 的行。类似于示例 13-16 中的 filter 例子,这段代码使用 filter 适配器来保留 line.contains(query) 返回 true 的行。接着使用 collect 将匹配行收集到另一个 vector 中。这样就容易多了!尝试对 search_case_insensitive 函数做出同样的使用迭代器方法的修改吧。
在循环和迭代器之间选择
接下来的逻辑问题就是在代码中应该选择哪种风格,以及原因:是使用示例 13-21 中的原始实现还是使用示例 13-22 中使用迭代器的版本?大部分 Rust 程序员倾向于使用迭代器风格。开始这有点难以掌握,不过一旦你对不同迭代器的工作方式有了感觉之后,迭代器反而更容易理解。相比摆弄不同的循环并创建新 vector,(迭代器)代码则更关注循环的高层次目的。这抽象掉那些老生常谈的代码,这样就更容易看清代码所特有的概念,比如迭代器中每个元素必须满足的过滤条件。
不过这两种实现真的完全等价吗?直觉上的假设是更底层的循环会更快一些。让我们聊聊性能吧。
性能比较:循环对迭代器
性能:循环 VS 迭代器
为了决定是否使用循环或迭代器,你需要了解哪个实现更快:使用显式 for 循环的 search 函数版本,还是使用迭代器的版本。
我们进行了一个基准测试,将阿瑟·柯南·道尔的《福尔摩斯探案集》全文加载到一个 String 中,并在内容中查找单词 the。以下是使用 for 循环版本和使用迭代器版本的 search 函数的基准测试结果:
test bench_search_for ... bench: 19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench: 19,234,900 ns/iter (+/- 657,200)
两种实现的性能相近!这里我们不会解释性能测试代码,因为重点并不是证明这两个版本完全等价,而是大致感受一下它们在性能上的对比。
对于更全面的基准测试,你应该尝试把不同大小的文本作为 contents、把不同的单词和不同长度的单词作为 query,再结合其他各种变化一起测试。重点在于:迭代器虽然是高级抽象,但编译出的代码和你手写底层代码时大致相同。迭代器是 Rust 的零成本抽象(zero-cost abstractions)之一,也就是说使用这种抽象不会带来额外的运行时开销。这和 C++ 的最初设计者与实现者 Bjarne Stroustrup 在 2012 年 ETAPS 主题演讲《Foundations of C++》中对零开销的定义类似:
In general, C++ implementations obey the zero-overhead principle: What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better.
总的来说,C++ 的实现遵循了零开销原则:不使用的功能无需为其付出代价;而已经使用的功能,也不可能通过手写代码做得更好。
在很多情况下,使用迭代器的 Rust 代码会编译成与你手写出来相同的汇编。像循环展开、消除数组访问边界检查这样的优化都会生效,并让最终生成的代码极其高效。现在你已经知道这一点了,就可以放心使用迭代器和闭包:它们让代码看起来更高层,但不会因此带来运行时性能损失。
总结
闭包和迭代器是 Rust 受函数式编程语言理念启发而来的特性。它们有助于 Rust 以低层性能清晰地表达高层概念。闭包和迭代器的实现不会影响运行时性能,这正是 Rust 致力于提供零成本抽象这一目标的一部分。
现在我们已经改进了 I/O 项目的表达力,接下来看看 cargo 的更多功能,它们会帮助我们把项目分享给全世界。