闭包:捕获其环境的匿名函数
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);
| --------------- ^- help: try using a conversion method: `.to_string()`
| | |
| | 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;
| ^
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 的不可变引用,因为它只需要不可变引用来打印值
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
Locking 1 package to latest compatible version
Adding closure-example v0.1.0 (/Users/chris/dev/rust-lang/book/tmp/listings/ch13-functional-features/listing-13-04)
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 添加一个元素。闭包现在捕获一个可变引用
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
Locking 1 package to latest compatible version
Adding closure-example v0.1.0 (/Users/chris/dev/rust-lang/book/tmp/listings/ch13-functional-features/listing-13-05)
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 的修改版本,以在新线程而不是主线程中打印 vector
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
移动到闭包中。新线程可能会在主线程的其余部分完成之前完成,或者主线程可能会先完成。如果主线程保持 list
的所有权但在新线程执行之前结束并丢弃 list
,则线程中的不可变引用将无效。因此,编译器要求将 list
移动到提供给新线程的闭包中,以便引用有效。尝试删除 move
关键字或在定义闭包后在主线程中使用 list
,以查看您收到的编译器错误!
将捕获的值移出闭包和 Fn
Trait
一旦闭包捕获了引用或从定义闭包的环境中捕获了值的所有权(从而影响了移动到闭包中的内容,如果有的话),闭包主体中的代码就会定义在稍后评估闭包时对引用或值发生的情况(从而影响了移动出闭包的内容,如果有的话)。闭包主体可以执行以下任何操作:将捕获的值移出闭包,改变捕获的值,既不移动也不改变值,或者从一开始就不从环境中捕获任何内容。
闭包捕获和处理来自环境的值的方式会影响闭包实现的 trait,而 trait 是函数和结构体如何指定它们可以使用哪些类型的闭包的方式。闭包将自动实现这些 Fn
trait 中的一个、两个或全部三个,以累加的方式,具体取决于闭包的主体如何处理值
FnOnce
适用于可以调用一次的闭包。所有闭包都至少实现此 trait,因为所有闭包都可以调用。将其捕获的值移出其主体的闭包将仅实现FnOnce
,而不会实现其他任何Fn
trait,因为它只能被调用一次。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。如果我们想做的事情不需要从环境中捕获值,我们可以在需要实现 Fn
trait 之一的东西的地方使用函数名称而不是闭包。例如,在 Option<Vec<T>>
值上,我们可以调用 unwrap_or_else(Vec::new)
以在值为 None
时获取一个新的空 vector。
现在让我们看一下切片上定义的标准库方法 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
vector 中来完成此计数。闭包捕获 value
,然后通过将 value
的所有权转移到 sort_operations
vector 来将 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");
| ----- captured outer variable
16 |
17 | list.sort_by_key(|r| {
| --- captured by this `FnMut` closure
18 | sort_operations.push(value);
| ^^^^^ move occurs because `value` has type `String`, which does not implement the `Copy` trait
|
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 make_a_cloner(s_ref: &str) -> impl Fn() -> String {
move || s_ref.to_string()
}
但是,此程序被编译器拒绝,并显示以下错误
error[E0700]: hidden type for `impl Fn() -> String` captures lifetime that does not appear in bounds
--> test.rs:2:5
|
1 | fn make_a_cloner(s_ref: &str) -> impl Fn() -> String {
| ---- hidden type `[closure@test.rs:2:5: 2:12]` captures the anonymous lifetime defined here
2 | move || s_ref.to_string()
| ^^^^^^^^^^^^^^^^^^^^^^^^^
此错误可能有点令人困惑。什么是隐藏类型?为什么它捕获生命周期?为什么该生命周期需要出现在边界中?
要回答这些问题,让我们首先看看如果 Rust 允许 make_a_cloner
编译会发生什么。然后我们可以编写以下不安全程序
让我们跟踪执行。在调用 make_a_cloner(&s_own)
后,在 L1,我们得到一个闭包 cloner
。在闭包内部是其环境,引用 s_ref
。但是,如果允许我们在 L2 处丢弃 s_own
,则会使 cloner
无效,因为其环境包含指向已释放内存的指针。然后调用 cloner()
将导致使用后释放。
回到原始的类型错误,问题是我们需要告诉 Rust 从 make_a_cloner
返回的闭包的生存期不得超过 s_ref
。 我们可以使用生命周期参数显式地做到这一点,如下所示
#![allow(unused)] fn main() { // vvvv vv vvvv fn make_a_cloner<'a>(s_ref: &'a str) -> impl Fn() -> String + 'a { move || s_ref.to_string() } }
这些更改表示:s_ref
是一个生存期为 'a
的字符串引用。将 + 'a
添加到返回类型的 trait 边界表示闭包的生存期不得超过 'a
。因此,Rust 推断出此函数现在是安全的。如果我们尝试像以前一样不安全地使用它
Rust 识别到,只要 make_a_cloner
正在使用,就不能丢弃 s_own
。这反映在权限中:调用 make_a_cloner
后,s_own
失去了 O 权限。因此,Rust 使用以下错误拒绝此程序
error[E0505]: cannot move out of `s_own` because it is borrowed
--> test.rs:9:6
|
8 | let cloner = make_a_cloner(&s_own);
| ------ borrow of `s_own` occurs here
9 | drop(s_own);
| ^^^^^ move out of `s_own` occurs here
10 | cloner();
| ------ borrow later used here
现在回到最初令人困惑的错误:“隐藏类型”的闭包捕获了具有有限生命周期的 s_ref
。返回类型从未提及此生命周期,因此 Rust 无法推断出 make_a_cloner
是安全的。但是,如果我们明确表示闭包捕获了 s_ref
的生命周期,那么我们的函数就可以编译了。
请注意,我们可以使用 生命周期省略 规则使函数类型更简洁。我们可以删除 <'a>
泛型,只要我们保留一个指示返回的闭包依赖于某个生命周期的指示符,就像这样
#![allow(unused)] fn main() { fn make_a_cloner(s_ref: &str) -> impl Fn() -> String + '_ { move || s_ref.to_string() } }
总而言之,在定义或使用利用闭包的函数或类型时,Fn
trait 非常重要。在下一节中,我们将讨论迭代器。许多迭代器方法采用闭包参数,因此在我们继续学习时,请牢记这些闭包细节!