闭包
Rust的闭包是匿名函数,您可以将其保存在变量中或作为参数传递给其他函数。您可以在一个地方创建闭包,然后调用该闭包以在不同的上下文中对其进行评估。与函数不同,闭包可以从定义它们的作用域中捕获值。我们将演示这些闭包功能如何实现代码重用和行为自定义。
创建带有闭包的行为抽象
让我们来研究一个情况的示例,在这种情况下,存储闭包以供以后执行非常有用。在此过程中,我们将讨论闭包的语法,类型推断和特征。
考虑这种假设情况:我们在一家初创公司工作,该初创公司正在制作应用程序以生成自定义锻炼锻炼计划。后端使用Rust编写,生成锻炼计划的算法考虑了许多因素,例如应用程序用户的年龄,体重指数,锻炼偏好,近期锻炼以及他们指定的强度数字。在此示例中,实际使用的算法并不重要;重要的是此计算需要几秒钟。我们只想在需要时才调用此算法,并且只调用一次即可,这样就不会使用户等待过多的时间。
我们将使用simulated_expensive_calculation清单13-1中所示的函数来模拟调用此假设算法,该函数 将print calculating slowly…,等待两秒钟,然后返回传入的任何数字。
文件名:src / main.rs
use std::thread;
use std::time::Duration;
fn simulated_expensive_calculation(intensity: u32) -> u32 {
println!(“calculating slowly…”);
thread::sleep(Duration::from_secs(2));
intensity
}
清单13-1:代表一个假设计算的函数,该函数需要大约2秒钟的时间才能运行
接下来是该main功能,其中包含锻炼应用程序对本示例很重要的部分。此功能代表当用户要求锻炼计划时应用将调用的代码。由于与应用程序前端的交互与闭包的使用无关,因此我们将对表示程序输入的值进行硬编码并输出输出。
所需的输入是这些:
来自用户的强度编号,在用户请求锻炼时指定,以指示他们是要进行低强度锻炼还是进行高强度锻炼一个随机数,将在锻炼计划中产生一些变化
输出将是建议的锻炼计划。清单13-2显示了main 我们将使用的函数。
文件名:src / main.rs
fn main() {
let simulated_user_specified_value = 10;
let simulated_random_number = 7;
generate_workout(simulated_user_specified_value, simulated_random_number);
}
清单13-2:main具有硬编码值的函数,用于模拟用户输入和随机数生成
为了简单起见,我们已将变量硬编码simulated_user_specified_value为10,并将变量硬编码simulated_random_number为7;在实际程序中,我们将从应用程序前端获取强度数,并使用 rand板条箱生成随机数,就像在第2章的Guessing Game示例中所做的那样。该main函数调用generate_workout带有输入值。
现在我们有了上下文,让我们进入算法。generate_workout清单13-3中的函数 包含了在此示例中我们最关注的应用程序的业务逻辑。此示例中的其余代码更改将对此函数进行。
文件名:src / main.rs
fn generate_workout(intensity: u32, random_number: u32) {
if intensity < 25 {
println!(
“Today, do {} pushups!”,
simulated_expensive_calculation(intensity)
);
println!(
“Next, do {} situps!”,
simulated_expensive_calculation(intensity)
);
} else {
if random_number == 3 {
println!(“Take a break today! Remember to stay hydrated!”);
} else {
println!(
“Today, run for {} minutes!”,
simulated_expensive_calculation(intensity)
);
}
}
}
fn generate_workout(intensity: u32, random_number: u32) {
if intensity < 25 {
println!(
“Today, do {} pushups!”,
simulated_expensive_calculation(intensity)
);
println!(
“Next, do {} situps!”,
simulated_expensive_calculation(intensity)
);
} else {
if random_number == 3 {
println!(“Take a break today! Remember to stay hydrated!”);
} else {
println!(
“Today, run for {} minutes!”,
simulated_expensive_calculation(intensity)
);
}
}
}
清单13-3:根据输入和对simulated_expensive_calculation 函数的调用打印锻炼计划的业务逻辑
清单13-3中的代码多次调用了慢速计算函数。第一个代码if块调用simulated_expensive_calculation两次,if 内部else的代码块根本不调用它,第二个else案例中的代码一次调用它。
该generate_workout功能的期望行为是首先检查用户是要进行低强度锻炼(由小于25的数字表示)还是进行高强度锻炼(由25或更大的数字表示)。
低强度锻炼计划会根据我们正在模拟的复杂算法,推荐许多俯卧撑和仰卧起坐。
如果用户想要进行高强度的锻炼,则还有一些其他逻辑:如果应用程序生成的随机数的值恰好为3,则该应用程序将建议您休息和补水。否则,用户将基于复杂算法获得数分钟的运行时间。
该代码以企业现在想要的方式工作,但是让我们说,数据科学团队决定我们需要simulated_expensive_calculation在将来对函数调用的方式进行一些更改 。为了在发生这些更改时简化更新,我们希望重构此代码,以便它simulated_expensive_calculation仅调用 一次函数。我们还希望减少当前不必要地调用该函数两次的位置,而不在该过程中添加对该函数的任何其他调用。也就是说,如果不需要结果,我们不想调用它,而我们仍然只想调用一次。
使用函数重构
我们可以通过多种方式重组锻炼计划。首先,我们将尝试将对simulated_expensive_calculation 函数的重复调用提取到一个变量中,如清单13-4所示。
文件名:src / main.rs
fn generate_workout(intensity: u32, random_number: u32) {
let expensive_result = simulated_expensive_calculation(intensity);
if intensity < 25 {
println!(“Today, do {} pushups!”, expensive_result);
println!(“Next, do {} situps!”, expensive_result);
} else {
if random_number == 3 {
println!(“Take a break today! Remember to stay hydrated!”);
} else {
println!(“Today, run for {} minutes!”, expensive_result);
}
}
}
清单13-4:将调用提取 simulated_expensive_calculation到一个地方并将结果存储在 expensive_result变量中
此更改统一了所有调用,simulated_expensive_calculation并解决了第一个if块不必要地两次调用该函数的问题。不幸的是,我们现在调用此函数并在所有情况下都等待结果,其中包括if根本不使用结果值的内部块。
我们希望在程序中的一个位置定义代码,但仅在实际需要结果的地方执行该代码。这是关闭的用例!
闭包重构以存储代码
而不是总是调用simulated_expensive_calculation该函数之前if块,我们可以定义一个封闭和存储封闭在一个变量,而不是存储函数调用的结果,如清单13-5英寸 实际上,我们可以simulated_expensive_calculation在此处介绍的闭包内移动整个主体。
文件名:src / main.rs
let expensive_closure = |num| {
println!(“calculating slowly…”);
thread::sleep(Duration::from_secs(2));
num
};
清单13-5:定义一个闭包并将其存储在 expensive_closure变量中
闭包定义位于后面,=以将其分配给变量 expensive_closure。为了定义一个闭合,我们从一对垂直管道(|)开始,在其中我们指定闭合的参数。选择该语法是因为它与Smalltalk和Ruby中的闭包定义相似。此闭包有一个名为的参数num:如果我们有多个参数,则将它们用逗号分隔,例如|param1, param2|。
在参数之后,我们放置用于括起闭包主体的花括号,如果闭包主体是单个表达式,则这些括号是可选的。结束符在大括号之后,需要用分号来完成 let语句。从闭包主体(num)的最后一行返回的值将是在被调用时从闭包返回的值,因为该行不以分号结尾;就像在功能主体中一样。
请注意,此let语句意味着expensive_closure包含匿名函数的 定义,而不是调用匿名函数的结果值。回想一下我们使用闭包的原因,因为我们想定义代码以便在某一时刻调用,存储该代码并在以后一点调用;现在,我们要调用的代码存储在中expensive_closure。
定义闭包后,我们可以更改if块中的代码以调用闭包以执行代码并获取结果值。我们像函数一样调用闭包:我们指定保存闭包定义的变量名,并在其后加上包含要使用的参数值的括号,如清单13-6所示。
文件名:src / main.rs
fn generate_workout(intensity: u32, random_number: u32) {
let expensive_closure = |num| {
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)
);
}
}
}
清单13-6:调用expensive_closure我们定义的
现在,仅在一个地方调用了昂贵的计算,而我们仅在需要结果的地方执行该代码。
但是,我们重新引入了清单13-3中的一个问题:在第一个if块中,我们仍然两次调用闭包,这将两次调用昂贵的代码,并使用户等待所需的时间两次。我们可以通过在该if块本地创建一个变量来保存调用闭包的结果来解决此问题,但是闭包为我们提供了另一种解决方案。我们将稍后讨论该解决方案。但是首先让我们讨论一下为什么闭包定义中没有类型注释以及闭包所涉及的特征。
闭包类型推断和注释
闭包不需要像fn函数一样注释参数的类型或返回值。函数必须使用类型注释,因为它们是向用户公开的显式接口的一部分。严格定义此接口对于确保每个人都同意函数使用和返回哪种类型的值很重要。但是闭包并没有在这样的公开接口中使用:它们存储在变量中,并且在未命名它们并将其暴露给我们库的用户的情况下使用。
闭包通常是简短的,并且仅在狭窄的背景下而不是在任意情况下才有意义。在这些有限的上下文中,编译器能够可靠地推断出参数的类型和返回类型,类似于其能够推断大多数变量的类型的方式。
使程序员对这些小的匿名函数中的类型进行注释会很烦人,并且在很大程度上已经浪费了编译器已经可用的信息。
与变量一样,如果我们要增加明确性和清晰度,则可以添加类型注释,但要付出比严格必要的更为冗长的代价。注释清单13-5中定义的闭包的类型看起来像清单13-7中所示的定义。
文件名:src / main.rs
let expensive_closure = |num: u32| -> u32 {
println!(“calculating slowly…”);
thread::sleep(Duration::from_secs(2));
num
};
清单13-7:在闭包中添加参数的可选类型注释和返回值类型
添加类型注释后,闭包的语法看起来与函数的语法更相似。以下是对该函数定义的语法的垂直比较,该函数的参数加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能够进行编译,因为将从类型的使用中推断出类型。
闭包定义将为每个参数及其返回值推断一个具体类型。例如,清单13-8显示了一个简短闭包的定义,该闭包仅返回它作为参数接收的值。除了出于本示例的目的,此闭包不是很有用。请注意,我们没有在定义中添加任何类型注释:如果我们随后尝试调用闭包两次,String第一次使用a作为参数,第二次使用a作为参数u32,则会收到错误消息。
文件名:src / main.rs
let example_closure = |x| x;
let s = example_closure(String::from(“hello”));
let n = example_closure(5);
清单13-8:尝试调用其类型由两种不同类型推断的闭包
编译器给我们这个错误:
$ 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 struct `std::string::String`, found integer
| help: try using a conversion method: `5.to_string()`
error: aborting due to previous error
For more information about this error, try `rustc –explain E0308`.
error: could not compile `closure-example`.
To learn more, run the command again with –verbose.
第一次example_closure使用该String值调用时,编译器将推断x闭包的类型和返回类型为String。然后将这些类型锁定到中的闭包中example_closure,如果尝试对同一闭包使用其他类型,则会收到类型错误。
使用通用参数和Fn特征存储闭包
让我们回到我们的锻炼生成应用程序。在清单13-6中,我们的代码仍在调用昂贵的计算闭包,而不是需要多次。解决此问题的一种方法是将昂贵的闭包结果保存在变量中以供重用,并在需要结果的每个位置使用该变量,而不是再次调用闭包。但是,此方法可能导致大量重复代码。
幸运的是,我们可以使用另一种解决方案。我们可以创建一个结构,该结构将保存闭包以及调用闭包的结果值。该结构仅在需要结果值时才执行闭包,并且将缓存结果值,因此我们的其余代码不必负责保存和重用结果。您可能将此模式称为 记忆或惰性评估。
要创建一个保存闭包的结构,我们需要指定闭包的类型,因为结构定义需要知道其每个字段的类型。每个闭包实例都有其自己唯一的匿名类型:也就是说,即使两个闭包具有相同的签名,它们的类型仍被认为是不同的。要定义使用闭包的结构,枚举或函数参数,请使用第10章中讨论的泛型和特征边界。
这些Fn特征由标准库提供。所有封闭装置实现性状的至少一个:Fn,FnMut或FnOnce。我们将在“封闭性捕获环境”部分中讨论这些特征之间的区别;在此示例中,我们可以使用Fn特征。
我们在Fn特征绑定中添加类型以表示参数的类型,并返回闭包必须匹配此特征绑定的值。在这种情况下,我们的闭包具有type参数u32并返回a u32,因此我们指定的特征范围为Fn(u32) -> u32。
清单13-9显示了Cacher包含闭包和可选结果值的结构的定义。
文件名:src / main.rs
struct Cacher<T>
where
T: Fn(u32) -> u32,
{
calculation: T,
value: Option<u32>,
}
清单13-9:定义一个Cacher结构,该结构中包含一个闭包,calculation并包含一个可选结果value
该Cacher结构具有calculation泛型类型的字段T。特质界限使用特质来T指定它是闭包Fn。我们要存储在calculation字段中的任何闭包都必须具有一个u32 参数(在括号后指定Fn),并且必须返回 u32(在后面指定->)。
注意:函数也可以实现所有三个Fn特征。如果我们想要做的事情不需要从环境中获取值,则可以使用函数而不是闭包,因为在闭包中我们需要实现Fn特征的东西 。
该value字段是类型Option<u32>。在执行关闭之前, value将为None。当使用a的代码Cacher要求关闭的结果时,Cacher它将在那时执行关闭并将结果存储Some在value字段的变量中。然后,如果代码再次要求关闭的结果,而不是再次执行关闭,则 Cacher它将返回Some变量中保存的结果。
value清单13-10中定义了我们刚刚描述的领域的逻辑。
文件名:src / main.rs
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);
v
}
}
}
}
清单13-10:的缓存逻辑 Cacher
我们要Cacher管理struct字段的值,而不是让调用代码潜在地直接更改这些字段中的值,因此这些字段是私有的。
该Cacher::new函数采用通用参数T,我们将其定义为具有与Cacherstruct相同的特征绑定。然后Cacher::new 返回一个Cacher实例,该实例保存该calculation字段中指定的闭包和该 字段中的None值value,因为我们尚未执行闭包。
当调用代码需要评估闭包的结果时,它将直接调用该value方法,而不是直接调用闭包。这个方法检查我们是否已经有结果值self.value的Some; 如果这样做,它将返回内的值,Some而无需再次执行关闭操作。
如果self.value为None,则代码调用存储在中的闭包 self.calculation,将结果保存以self.value供将来使用,并返回该值。
清单13-11显示了如何Cacher在generate_workout清单13-6中的函数中使用此结构 。
文件名:src / main.rs
fn generate_workout(intensity: u32, random_number: u32) {
let mut expensive_result = Cacher::new(|num| {
println!(“calculating slowly…”);
thread::sleep(Duration::from_secs(2));
num
});
if intensity < 25 {
println!(“Today, do {} pushups!”, expensive_result.value(intensity));
println!(“Next, do {} situps!”, expensive_result.value(intensity));
} else {
if random_number == 3 {
println!(“Take a break today! Remember to stay hydrated!”);
} else {
println!(
“Today, run for {} minutes!”,
expensive_result.value(intensity)
);
}
}
}
清单13-11:Cacher在generate_workout 函数中使用来抽象出缓存逻辑
我们没有直接将闭包保存在变量中,而是保存了Cacher包含闭包的新实例 。然后,在每个需要结果的地方,我们value在Cacher实例上调用方法。我们可以value 根据需要多次调用该方法,也可以根本不调用该方法,而昂贵的计算最多可以运行一次。
尝试使用main清单13-2中的函数运行该程序。更改simulated_user_specified_value和simulated_random_number 变量中的值,以验证在variablesif和else block中的所有情况下calculating slowly…,仅在需要时才出现一次。在 Cacher采取必要的逻辑的谨慎,以确保我们不是要求昂贵的计算比我们更需要这样generate_workout可以专注于业务逻辑。
Cacher实施的局限性
缓存值是一种普遍有用的行为,我们可能希望在代码的其他部分使用不同的闭包。但是,当前的实现存在两个问题,Cacher这将使在不同环境中重用它变得困难。
第一个问题是,Cacher例如假定它总是会得到相同值的参数arg的value方法。也就是说,的测试 Cacher将失败:
#[test]
fn call_with_different_values() {
let mut c = Cacher::new(|a| a);
let v1 = c.value(1);
let v2 = c.value(2);
assert_eq!(v2, 2);
}
此测试Cacher使用闭包创建一个新实例,该闭包返回传递给它的值。我们value在此Cacher实例上调用方法,该方法的 arg值是1,然后是arg2,并且期望value对arg值2的调用 将返回2。
使用Cacher清单13-9和清单13-10中的实现运行此测试,该测试将失败,assert_eq!并显示以下消息:
$ cargo test
Compiling cacher v0.1.0 (file:///projects/cacher)
Finished test [unoptimized + debuginfo] target(s) in 0.72s
Running target/debug/deps/cacher-4116485fb32b3fff
running 1 test
test tests::call_with_different_values … FAILED
failures:
—- tests::call_with_different_values stdout —-
thread main panicked at assertion failed: `(left == right)`
left: `1`,
right: `2`, src/lib.rs:43:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.
failures:
tests::call_with_different_values
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
error: test failed, to rerun pass –lib
问题在于,我们第一次c.value用1调用时,Cacher 实例保存Some(1)在中self.value。此后,无论我们传递给该value方法什么,它将始终返回1。
尝试修改Cacher以保存哈希映射,而不是单个值。哈希图的键将arg是传入的值,哈希图的值将是对该键调用闭包的结果。该函数将不查看是否self.value直接具有aSome或None值, 而是在哈希图中value查找,arg并返回值(如果存在)。如果不存在,Cacher它将调用闭包并将结果值保存在与其arg值关联的哈希图中。
当前Cacher实现的第二个问题是它仅接受采用类型为一个参数u32并返回a的闭包u32。例如,我们可能想要缓存采用字符串切片并返回usize值的闭包结果 。要解决此问题,请尝试引入更多通用参数以增加Cacher功能的灵活性。
封闭地捕获环境
在锻炼生成器示例中,我们仅将闭包用作嵌入式匿名函数。但是,闭包具有函数所没有的其他功能:它们可以捕获环境并从定义它们的作用域访问变量。
清单13-12给出了一个存储在equal_to_x变量中的闭包示例,该闭包使用x闭包周围环境中的变量。
文件名:src / main.rs
fn main() {
let x = 4;
let equal_to_x = |z| z == x;
let y = 4;
assert!(equal_to_x(y));
}
清单13-12:一个封闭的示例,该封闭在其封闭范围内引用了一个变量
在这里,即使x不是的参数之一equal_to_x, equal_to_x也允许闭包使用在x定义的同一作用域中定义的变量equal_to_x。
我们不能对函数做同样的事情。如果我们尝试下面的示例,我们的代码将无法编译:
文件名:src / main.rs
fn main() {
let x = 4;
fn equal_to_x(z: i32) -> bool {
z == x
}
let y = 4;
assert!(equal_to_x(y));
}
我们收到一个错误:
$ cargo run
Compiling equal-to-x v0.1.0 (file:///projects/equal-to-x)
error[E0434]: cant capture dynamic environment in a fn item
–> src/main.rs:5:14
|
5 | z == x
| ^
|
= help: use the `|| { … }` closure form instead
error: aborting due to previous error
For more information about this error, try `rustc –explain E0434`.
error: could not compile `equal-to-x`.
To learn more, run the command again with –verbose.
编译器甚至提醒我们这仅适用于闭包!
当闭包从其环境中捕获值时,它将使用内存来存储要在闭包主体中使用的值。这种内存使用是开销,在更常见的情况下,我们想要执行不捕获其环境的代码,我们不愿意为此付出代价。由于永远不允许函数捕获其环境,因此定义和使用函数绝不会产生这种开销。
闭包可以通过三种方式从其环境中捕获值,它们直接映射到函数可以采用参数的三种方式:获得所有权,可变借入和不可变借入。这些被编码Fn为以下三个特征:
FnOnce消耗从其封闭范围(称为封闭环境)捕获的变量。要使用捕获的变量,闭包必须拥有这些变量的所有权,并在定义闭包时将其移入闭包。Once名称的一部分表示以下事实:闭包不能多次获取同一变量的所有权,因此只能调用一次。FnMut 可以改变环境,因为它可变地借入了价值。Fn 一成不变地从环境中借用价值。
创建闭包时,Rust根据闭包如何使用环境中的值来推断要使用的特征。FnOnce 之所以实现所有闭包,是因为它们至少可以被调用一次。不会移动捕获变量的FnMut闭包也会实现,不需要可变访问捕获变量的闭包也会实现Fn。在清单13-12中, equal_to_x闭包x不可变地借用(equal_to_x也具有Fn特征),因为闭包的主体只需要读取中的值x。
如果要强制闭包对其在环境中使用的值拥有所有权,则可以move在参数列表之前使用关键字。将闭包传递到新线程以移动数据,使其归新线程所有时,此技术最有用。
move在讨论并发时,第16章将提供更多的闭包示例。现在,这是清单13-12中的代码move ,在闭包定义中添加了关键字,并使用了向量而不是整数,因为可以复制而不是移动整数;请注意,此代码尚未编译。
文件名:src / main.rs
fn main() {
let x = vec![1, 2, 3];
let equal_to_x = move |z| z == x;
println!(“cant use x here: {:?}”, x);
let y = vec![1, 2, 3];
assert!(equal_to_x(y));
}
我们收到以下错误:
$ cargo run
Compiling equal-to-x v0.1.0 (file:///projects/equal-to-x)
error[E0382]: borrow of moved value: `x`
–> src/main.rs:6:40
|
2 | let x = vec![1, 2, 3];
| – move occurs because `x` has type `std::vec::Vec<i32>`, which does not implement the `Copy` trait
3 |
4 | let equal_to_x = move |z| z == x;
| ——– – variable moved due to use in closure
| |
| value moved into closure here
5 |
6 | println!(“cant use x here: {:?}”, x);
| ^ value borrowed here after move
error: aborting due to previous error
For more information about this error, try `rustc –explain E0382`.
error: could not compile `equal-to-x`.
To learn more, run the command again with –verbose.
x定义闭包后,该值将移入闭包中,因为我们添加了move关键字。然后x,该闭包具有的所有权,并且main 不允许x在该println!语句中使用它。删除 println!将修复此示例。
在大多数情况下,指定一个Fn特征边界时,您可以从开始,Fn然后编译器会根据需要FnMut或FnOnce根据闭包主体中的情况告诉您。
为了说明可以捕获其环境的闭包用作函数参数的情况,让我们继续下一个主题:迭代器。
暂无评论内容