闭包:捕获其环境的匿名函数
Rust 的闭包是你可以保存在变量中或作为参数传递给其他函数的匿名函数。你可以在一个地方创建闭包,然后在其他地方调用闭包以在不同的上下文中进行评估。与函数不同,闭包可以捕获其定义范围内 的值。我们将演示这些闭包特性如何允许代码重用和行为定制。
使用闭包捕获环境
我们首先看看如何使用闭包来捕获它们定义环境中的值以供稍后使用。这是一个场景:我们的T恤公司偶尔会向我们邮件列表中的某人赠送一款独家限量版T恤作为促销。邮件列表中的人可以选择将他们最喜欢的颜色添加到他们的个人资料中。如果被选中免费获得T恤的人设置了他们最喜欢的颜色,他们就会得到那种颜色的T恤。如果这个人没有指定最喜欢的颜色,他们就会得到公司目前库存最多的那种颜色。
有许多方法可以实现这一点。对于这个例子,我们将使用一个名为 ShirtColor
的枚举,它有 Red
和 Blue
两个变体(为了简化,限制了可用的颜色数量)。我们用一个名为 Inventory
的结构体来表示公司的库存,该结构体有一个名为 shirts
的字段,其中包含一个 Vec<ShirtColor>
,表示当前库存中的衬衫颜色。定义在 Inventory
上的 giveaway
方法获取免费衬衫获奖者可选的衬衫颜色,并返回该人将获得的衬衫颜色。这个设置如清单 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-2 中的定义。在这个例子中,我们定义了一个闭包并将其存储在一个变量中,而不是像在示例 13-1 中那样在传递闭包作为参数的地方定义闭包。
添加了类型注解后,闭包的语法看起来更接近函数的语法。这里我们定义了一个将其参数加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
,我们将得到一个错误。
编译器给出了这个错误:
$ 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
的向量的不可变引用的闭包,因为它只需要一个不可变引用来打印值:
这个例子还说明了一个变量可以绑定到闭包定义,我们可以通过使用变量名和括号来调用闭包,就像变量名是一个函数名一样。
因为我们可以同时拥有 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
向量添加一个元素。闭包现在捕获一个可变引用:
这段代码编译、运行并打印:
$ 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,它在新线程中而不是主线程中打印向量:
我们创建一个新的线程,将一个闭包作为参数传递给线程。闭包体打印出列表。在清单 13-4 中,闭包只使用不可变引用捕获了 list
,因为这是打印 list
所需的最少访问权限。在这个例子中,即使闭包体仍然只需要一个不可变引用,我们也需要通过在闭包定义的开头放置 move
关键字来指定 list
应该被移动到闭包中。新线程可能在主线程的其余部分完成之前结束,或者主线程可能先结束。如果主线程保持对 list
的所有权但在新线程之前结束并释放了 list
,线程中的不可变引用将无效。因此,编译器要求将 list
移动到给新线程的闭包中,以使引用有效。尝试删除 move
关键字或在定义闭包后在主线程中使用 list
,看看你会得到什么编译错误!
将捕获的值移出闭包和 Fn
特性
一旦闭包从定义闭包的环境中捕获了一个引用或捕获了值的所有权(从而影响了什么,如果有的话,被移入闭包),闭包体中的代码定义了在闭包稍后被求值时引用或值会发生什么(从而影响了什么,如果有的话,被移出闭包)。闭包体可以执行以下任何操作:将捕获的值移出闭包,修改捕获的值,既不移出也不修改值,或者从一开始就从环境中捕获任何内容。
闭包捕获和处理环境中的值的方式会影响闭包实现哪些特质,而特质是函数和结构体指定它们可以使用什么类型的闭包的方式。闭包会根据闭包体如何处理值,自动实现这一个、两个或全部三个Fn
特质,以累加的方式。
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
的特征约束是 FnOnce() -> T
,这意味着 F
必须能够被调用一次,不接受任何参数,并返回一个 T
。在特征约束中使用 FnOnce
表示 unwrap_or_else
只会最多调用 f
一次。在 unwrap_or_else
的函数体中,我们可以看到如果 Option
是 Some
,f
不会被调用。如果 Option
是 None
,f
将被调用一次。因为所有闭包都实现了 FnOnce
,unwrap_or_else
接受所有三种类型的闭包,并且尽可能灵活。
注意:函数也可以实现所有三个 Fn
特性。如果我们所做的操作不需要从环境中捕获值,我们可以使用函数的名称而不是闭包,当我们需要实现 Fn
特性之一的东西时。例如,在 Option<Vec<T>>
值上,我们可以调用 unwrap_or_else(Vec::new)
来在值为 None
时获取一个新的空向量。
现在让我们看看定义在切片上的标准库方法sort_by_key
,看看它与unwrap_or_else
有何不同,以及为什么sort_by_key
使用FnMut
而不是FnOnce
作为特质边界。闭包接收一个参数,形式为当前考虑的切片项的引用,并返回一个可以排序的类型K
的值。当你想根据每个项的特定属性对切片进行排序时,此函数非常有用。在列表13-7中,我们有一个Rectangle
实例列表,并使用sort_by_key
按其width
属性从低到高对它们进行排序:
这段代码打印:
$ 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
不会捕获、修改或从其环境中移出任何内容,因此它满足特质约束要求。
相比之下,列表 13-8 显示了一个只实现了 FnOnce
特性的闭包示例,因为它从环境中移动了一个值。编译器不会让我们使用这个闭包与 sort_by_key
:
这是一个复杂且不正确的方法,试图计算对 list
进行排序时 sort_by_key
调用闭包的次数。这段代码试图通过将 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");
| ----- 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
计数器的可变引用,因此可以被调用多次:
Fn
特性在定义或使用函数或类型时非常重要,这些函数或类型会使用闭包。在下一节中,我们将讨论迭代器。许多迭代器方法接受闭包参数,因此在我们继续时,请记住这些闭包细节。