闭包
Rust 的闭包是可以保存在变量中或作为参数传递给其他函数的匿名函数。你可以在一个地方创建闭包,然后在另一个地方调用它,以便在不同的上下文中对其求值。与函数不同,闭包可以捕获其定义所在作用域中的值。我们将演示这些闭包特性如何实现代码复用和行为定制。
捕获环境
我们首先研究如何使用闭包从其定义的环境中捕获值以供稍后使用。以下是场景:我们的 T 恤公司会时不时地向邮件列表中的某人赠送一件独家限量版衬衫作为促销。邮件列表中的人可以选择性地将自己喜欢的颜色添加到个人资料中。如果被选中获得免费衬衫的人设置了喜欢的颜色,他们将得到该颜色的衬衫。如果此人未指定喜欢的颜色,他们将得到公司当前库存最多的颜色。
有多种方式可以实现这一点。在本例中,我们将使用一个名为 ShirtColor 的枚举,它有 Red 和 Blue 两个变体(为简单起见,限制了可用颜色的数量)。我们用一个 Inventory 结构体来表示公司的库存,该结构体有一个名为 shirts 的字段,包含一个 Vec<ShirtColor>,表示当前库存的衬衫颜色。在 Inventory 上定义的 giveaway 方法获取免费衬衫获得者的可选衬衫颜色偏好,并返回该人将获得的衬衫颜色。此设置如清单 13-1 所示。
#[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
);
}
在 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-2 中所示的定义。在本例中,我们定义一个闭包并将其存储在变量中,而不是像在清单 13-1 中那样在将其作为参数传递的位置定义闭包。
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);
}
添加类型注解后,闭包的语法看起来更接近函数的语法。这里,为了比较,我们定义了一个将参数加 1 的函数和一个具有相同行为的闭包。我们添加了一些空格来对齐相关部分。这说明闭包语法与函数语法相似,只是使用了竖线以及语法的可选程度:
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(); 需要类型注解或向 Vec 中插入某种类型的值,Rust 才能推断出类型。
对于闭包定义,编译器将为其每个参数和返回值推断出一个具体类型。例如,清单 13-3 显示了一个简短闭包的定义,它只返回其作为参数接收的值。这个闭包除了用于本示例外没有太大用处。请注意,我们没有在定义中添加任何类型注解。因为没有类型注解,我们可以用任何类型调用闭包,这里我们第一次用 String 调用了它。如果然后我们尝试用整数调用 example_closure,就会得到一个错误。
fn main() {
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 `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 的向量的不可变引用,因为它只需要一个不可变引用来打印值。
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:?}");
}
此示例还说明了变量可以绑定到闭包定义,之后我们可以像使用变量名作为函数名一样,使用变量名和括号来调用闭包。
因为我们可以同时拥有对 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]
Next, in Listing 13-5, we change the closure body so that it adds an element to
the list vector. The closure now captures a mutable reference.
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:?}");
}
此代码编译、运行并打印:
$ 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 关键字。
这种技术主要在将闭包传递给新线程以移动数据使其由新线程拥有时很有用。我们将在第 16 章讨论并发时详细讨论线程以及为什么要使用它们,但现在,让我们简要地探索使用一个需要 move 关键字的闭包来生成新线程。清单 13-6 显示了清单 13-4 的修改版本,在新线程中而不是在主线程中打印向量。
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();
}
move 强制线程的闭包获取 list 的所有权我们生成一个新线程,给该线程一个闭包作为参数来运行。闭包体打印出列表。在清单 13-4 中,闭包仅使用不可变引用捕获 list,因为这是打印它所需的对 list 的最少访问权限。在本例中,即使闭包体仍然只需要一个不可变引用,我们也需要通过在闭包定义开头放置 move 关键字来指定 list 应该被移动到闭包中。如果主线程在对新线程调用 join 之前执行了更多操作,新线程可能会在主线程其余部分完成之前完成,或者主线程可能先完成。如果主线程保持对 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 的参数的类型,它是我们调用 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 接受所有三种闭包,并且尽可能灵活。
注意:如果我们想做的事情不需要从环境中捕获值,我们可以在需要实现某个 Fn trait 的地方使用函数名而不是闭包。例如,在一个 Option<Vec<T>> 值上,我们可以调用 unwrap_or_else(Vec::new) 来在值为 None 时获得一个新的空向量。编译器会自动为函数定义实现适用的任何 Fn trait。
现在让我们看看标准库中定义在切片上的 sort_by_key 方法,看看它与 unwrap_or_else 有何不同,以及为什么 sort_by_key 在 trait 约束中使用 FnMut 而不是 FnOnce。该闭包以对当前正在考虑的切片中项目的引用的形式获取一个参数,并返回一个可排序的 K 类型的值。当你想按每个项目的特定属性对切片进行排序时,此函数很有用。在清单 13-7 中,我们有一个 Rectangle 实例列表,我们使用 sort_by_key 按它们的 width 属性从低到高排序。
#[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:#?}");
}
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 闭包的原因是它会多次调用该闭包:对切片中的每个项目调用一次。闭包 |r| r.width 不会从其环境中捕获、变更或移出任何东西,因此它满足 trait 约束要求。
相比之下,清单 13-8 显示了一个只实现 FnOnce trait 的闭包示例,因为它将一个值移出了环境。编译器不会让我们将此闭包与 sort_by_key 一起使用。
#[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:#?}");
}
FnOnce 闭包与 sort_by_key 一起使用这是一种人为的、迂回的方式(而且行不通),试图计算 sort_by_key 在排序 list 时调用闭包的次数。此代码试图通过将 value——一个来自闭包环境的 String——推入 sort_operations 向量来进行计数。该闭包捕获 value,然后通过将 value 的所有权转移给 sort_operations 向量来将 value 移出闭包。此闭包只能被调用一次;尝试第二次调用它不会工作,因为 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: `Fn` and `FnMut` closures require captured values to be able to be consumed multiple times, but `FnOnce` closures may consume them only once
--> /rustc/2d8144b7880597b6e6d3dfd63a9a9efae3f533d3/library/alloc/src/slice.rs:249:11
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 计数器的可变引用,因此可以被多次调用。
#[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");
}
FnMut 闭包与 sort_by_key 一起使用是允许的。Fn trait 在定义或使用利用闭包的函数或类型时非常重要。在下一节中,我们将讨论迭代器。许多迭代器方法接受闭包参数,因此在我们继续时请记住这些闭包细节!