Rust的Lambda — 闭包
对整型向量进行排序很容易:
integers.sort();
遗憾的是,当我们想对一些数据进行排序时,它们几乎从来都不是整型向量。例 如,对某种记录型数据来说,内置的 sort 方法一般不适用:
struct City {
name: String,
population: i64,
country: String,
...
}
fn sort_cities(cities: &mut Vec<City>) {
cities.sort(); // 出错:你到底想怎么排序?
}
报错说 City 没有实现 std::cmp::Ord
。我们需要指定排序顺序,如下所示:
/// 按照人口数量对城市进行排序的辅助函数
fn city_population_descending(city: &City) -> i64 {
-city.population
}
fn sort_cities(cities: &mut Vec<City>) {
cities.sort_by_key(city_population_descending); // 正确
}
辅助函数 city_population_descending 会接受 City 型记录并提取其键,该键是我们对数据进行排序时要依据的字段。
sort_by_key 方法会将这个取键函数作为参数
如果将辅助函数写成闭包(匿名函数表达式)则会更简洁:
fn sort_cities(cities: &mut Vec<City>) {
cities.sort_by_key(|city| -city.population);
}
它会接受一个参数 city 并 返回 -city.population。Rust 会从闭包的使用方式中推断出其参数类型和返 回类型。下面是标准库中接受闭包的其他例子。
这并不罕见:
- 像 map 和 filter 这样的 Iterator 方法,可用于处理序列数据。
- 像
thread::spawn
这样的线程 API,会启动一个新的系统线程。并发就是要将工作转移给其他线程,而闭包能方便地表示这些工作单元。 - 一些需要根据条件计算默认值的方法,比如 HashMap 条目的
or_insert_with
方法。此方法用于获取或创建 HashMap 中的条目,当默认值的计算成本很高时就要使用闭包。默认值会作为闭包传入,只有当不得不创建新条目时才会调用此闭包。
捕获变量
闭包可以使用属于其所在函数的数据:(参数)
// 根据任何其他的统计标准排序
fn sort_by_statistic(cities: &mut Vec<City>, stat: Statistic) {
cities.sort_by_key(|city| -city.get_statistic(stat));
}
闭包“捕获”了 stat
// 启动重新排列城市所在表行的动画
function startSortingAnimation(cities, stat) {
// 用来对表格进行排序的辅助函数
// 注意此函数引用了stat
function keyfn(city) {
return city.get_statistic(stat);
}
if (pendingSort)
pendingSort.cancel();
// 现在开始动画,把keyfn传给它
// 排序算法稍后会调用keyfn
pendingSort = new SortingAnimation(cities, keyfn);
}
闭包 keyfn 存储在了新的 SortingAnimation 对象中。当 startSortingAnimation 返回后就会调用它。通常,当一个函数返回时,它 的所有变量和参数都会超出作用域并被丢弃。但是在这里,JavaScript 引擎必须 以某种方式保留 stat,因为闭包会使用它。大多数 JavaScript 引擎的实现方式 是在堆中分配 stat 并等垃圾回收器稍后回收。
借用值的闭包
fn sort_by_statistic(cities: &mut Vec<City>, stat: Statistic) {
cities.sort_by_key(|city| -city.get_statistic(stat));
}
当 Rust 创建闭包时,会自动借入对 stat 的引用。这很合理, 因为闭包引用了 stat,所以闭包必须包含对 stat 的引用。
闭包同样遵循借用和生命周期的规则。特别是,由于闭包中包含对 stat 的引用,因此 Rust 不会让它的生命周期超出 stat。因为闭包只会在排序期间使用,所以这个例子是适用的。 简而言之,Rust 会使用生命周期而非垃圾回收来确保安全。Rust 的方式更快, 因为即使是最快的垃圾回收器在分配内存时也会比把 stat 保存在栈上慢,本例 中 Rust 就把 stat 保存在栈上。
“窃取”值的闭包
use std::thread;
fn start_sorting_thread(mut cities: Vec<City>, stat: Statistic)
-> thread::JoinHandle<Vec<City>>
{
let key_fn = |city: &City| -> i64 { -city.get_statistic(stat) }; // ->可以省略
thread::spawn(|| {
cities.sort_by_key(key_fn);
cities
})
}
thread::spawn 会接受一个闭包并 在新的系统线程中调用它。请注意 || 是闭包的空参数列表。
新线程会和调用者并行运行。当闭包返回时,新线程退出。(闭包的返回值会作 为 JoinHandle 值发送回调用线程。
但是编译不会通过:
因为 cities 也被不安全地共享了。简单来说, thread::spawn
创建的新线程无法保证在 cities 和 stat 被销毁之前在函数末尾完成其工作。
解决:要求 Rust 将 cities 和 stat移动到使用它们的闭包中,而不是借入对它们的引用。
fn start_sorting_thread(mut cities: Vec<City>, stat: Statistic)
-> thread::JoinHandle<Vec<City>>
{
let key_fn = move |city: &City| -> i64 { -city.get_statistic(stat) };
thread::spawn(move || {
cities.sort_by_key(key_fn);
cities
})
}
move 关键字会告诉 Rust,闭包并不是要借入它用到的变量,而是要“窃取”它们。
Rust 为闭包提供了两种从封闭作用域中获取数据的方法:移动和借用。
如果闭包要移动可复制类型的值 (如 i32),那么就会复制该值。因此,如果 Statistic 恰好是可复制类型【Clone】,那么即使在创建了要使用 stat 的 move 闭包之后,我们仍可以继续使用 stat。 不可复制类型的值(如 Vec)则确实会被移动。
在创建此闭包后,Rust 就不允许再通过 cities 访问它了。 实际上,在闭包将 cities 移动之后,此代码就不需要再使用它了。但 是,即使我们确实需要在此之后使用 cities,解决方法也很简单:可以要 求 Rust 克隆 cities 并将副本存储在另一个变量中。闭包将只会“窃取”其 中一个副本,即它所引用的那个副本。 通过遵循 Rust 的严格规则,我们收获线程安全。正是因为向量是被移动的,而不是跨线程共享的,所以我们知道旧线程肯定不会在新线程正在 修改向量的时候释放它。
函数与闭包的类型
可以将函数存储在变量【函数指针】中,也可 以使用所有常用的 Rust 语法来计算函数值:
let my_key_fn: fn(&City) -> i64 =
if user.prefs.by_population {
city_population_descending
} else {
city_monster_attack_risk_descending
};
cities.sort_by_key(my_key_fn);
结构体也可以有函数类型的字段。像 Vec 这样的泛型类型可以存储大量的函 数,只要它们共享同一个 fn 类型即可。而且函数值占用的空间很小,因为 fn 值就是函数机器码的内存地址,就像 C++ 中的函数指针一样。
/// 给定一份城市列表和一个测试函数,返回有多少个城市通过了测试
fn count_cities(cities: &Vec<City>, func: fn(&City) -> bool) -> usize
{
let mut count = 0;
for city in cities {
if func(city) {
count += 1;
}
}
count
}
/// 测试函数的示例。注意,此函数的类型是`fn(&City) -> bool`,
/// 与`count_cities` 的 `func`参数相同
fn has_monster_attacks(city: &City) -> bool {
city.monster_attack_risk > 0.0
}
// 有多少个城市存在被怪兽袭击的风险?
let n = count_cities(&my_cities, has_monster_attacks);
但是闭包与函数不是同一种类型
let limit = preferences.acceptable_monster_risk();
let n = count_cities(&my_cities, |city| city.monster_attack_risk > limit); // 错误:类型不匹配
第二个参数会导致类型错误。为了支持闭包,必须更改这个函数的类型签名:
where F: Fn(&City) -> bool
注意Fn是大写 表示接受函数和闭包
fn count_selected_cities<F>(cities: &Vec<City>, test_fn: F) -> usize
where F: Fn(&City) -> bool
{
let mut count = 0;
for city in cities {
if test_fn(city) {
count += 1;
}
}
count
}
新版本是泛型函数。只要 F 实现了特定的特型 Fn(&City) -> bool,该函数 就能接受任意 F 型的 test_fn。以单个 &City 为参数并返回 bool 值的所有 函数和大多数闭包会自动实现这个特型:
fn(&City) -> bool // fn类型(只接受函数)
Fn(&City) -> bool // Fn特型(既接受函数也接受闭包)
-> 和返回类型是可选的,如果省略,则返回类 型为 ()。
每个闭包都有自己的类型,因为闭包可以包含数据:从封闭作 用域中借用或“窃取”的值。这既可以是任意数量的变量,也可以是任意类型的组 合。所以每个闭包都有一个由编译器创建的特殊类型,大到足以容纳这些数据。 任何两个闭包的类型都不相同。但是每个闭包都会实现 Fn 特型
每个闭包都有自己的类型,所以使用闭包的代码通常都应该是泛型的
闭包性能
Rust 中闭包的设计目标是要快:比函数指针还要快,快到甚至可以在对性能敏感的热点代码中使用它们。
如果你熟悉 C++ 的 lambda 表达式,就会发现 Rust 闭包也一样快速而紧凑,但更安全。 在大多数语言中,闭包会在堆中分配内存、进行动态派发以及进行垃圾回收。因此,创建、调用和收集每一个闭包都会花费一点点额外的 CPU 时间。
更糟的是,闭包往往难以内联,而内联是编译器用来消除函数调用开销并实施大量其他优化的关键技术。总而言之,闭包在这些语言中确实慢到值得手动将它们从节奏紧凑的内层循环中去掉。
Rust 闭包则没有这些性能缺陷,闭包没有垃圾回收。与 Rust 中的其他所有类型 一样,除非你将闭包放在 Box、Vec
或其他容器中,否则它们不会被分配到堆 上。由于每个闭包都有不同的类型,因此 Rust 编译器只要知道你正在调用的闭包的类型,就可以内联该闭包的代码。
- 闭包 (a) 使用了上述两个变量。显然,我们正在寻找既有炸玉米饼(taco)又 有龙卷风(tornado)的城市。在内存中,这个闭包看起来像一个小型结构体,其中包含对其所用变量的引用。 请注意,这个闭包并不包含指向其代码的指针。只要 Rust 知道闭包的类型,就知道在调用此闭包时该运行哪些代码。
- 闭包 (b) 与闭包 (a) 完全相同,只不过它是一个 move 闭包,因此会包含值而非引用。
- 闭包 © 不会使用其环境中的任何变量。该结构体是空的,所以这个闭包根本不会占用任何内存。 通常,编译器会内联所有对闭包的调用,然后连图中所示的小结构体也优化掉。
闭包与安全
杀死闭包
let my_str = "hello".to_string();
let f = || drop(my_str);
调用 f 时,my_str 会被丢弃。
调用f两次会发生什么呢?类似C++ 编程中会触发未定义行为的经典错误:双重释放
f(); // 正确
f(); // 错误:使用了已移动的值
Rust 知道这个闭包不能调用两次。
FnOnce
Rust实现了一个不那么强大的特型 FnOnce,即只 能调用一次的闭包特型。【cpp -> std::call_once】
第一次调用 FnOnce 闭包时,闭包本身也会被消耗掉。这是因为 Fn 和 FnOnce 这两个特型是这样定义的:
// 无参数的`Fn`特型和`FnOnce`特型的伪代码
trait Fn() -> R {
fn call(&self) -> R;
}
trait FnOnce() -> R {
fn call_once(self) -> R;
}
正如算术表达式 a + b 是方法调用 Add::add(a, b) 的简写形式一样,Rust 也会将 closure() 视为前面示例中的两个特型方法【call call_once】之一的简写形式:
- 对于 Fn 闭包,closure() 会扩展为 closure.call()。此方法会通过引用获取 self,因此闭包不会被移动。
- 但是如果闭包只能安全地调用一次,那么 closure() 就会扩展为 closure.call_once()。该方法会按值获取 self, 因此这个闭包就会被消耗掉。
FnMut
FnMut 是一个 trait,用于描述可以捕获其环境变量的可变引用的闭包。这意味着这样的闭包可以在其内部修改捕获到的变量的值。
FnMut 与 Fn、FnOnce 的区别
Rust 中有三个主要的闭包 trait:Fn、FnMut 和 FnOnce。它们之间的区别在于对捕获的环境变量的引用类型和使用方式:
- Fn: 只能捕获环境变量的不可变引用,不能修改捕获到的变量。
- FnMut: 可以捕获环境变量的可变引用,可以在闭包内部修改捕获到的变量。
- FnOnce: 可以捕获环境变量的所有权,只能调用一次,调用后闭包就失效了。
Fn() 是 FnMut() 的子特型,而 FnMut() 是 FnOnce() 的子特型。 这使得 Fn 成了最严格且最强大的类别。FnMut 和 FnOnce 是更宽泛的类别, 其中包括某些具有使用限制的闭包。
为什么需要 FnMut?
- 修改状态: 当我们需要在闭包内部修改外部变量的值时,就需要使用 FnMut。
- 灵活的函数式编程: FnMut 提供了更多的灵活性,允许我们在函数式编程中进行一些有状态的操作。
FnMut 的应用场景
- 迭代器: 许多迭代器适配器(比如
map
、filter
)会产生新的迭代器,这些新的迭代器可能需要修改内部状态。 - 高阶函数: 将闭包作为参数传递给函数时,如果需要闭包修改外部状态,就需要使用 FnMut。
- 异步编程: 在异步编程中,闭包经常需要捕获外部状态,并异步地修改它们。
包含可变数据或可变引用的闭包
Rust 认为不可变值可以安全地跨线程共享,但是包含可变数据的不可变闭包不能安全共享——从多个线程调用这样的闭包可能会导致各种竞态条件,因为多个线程会试图同时读取和写入同一份数据。 Rust 还有另一类名为 FnMut 的闭包,也就是可写入的闭包。FnMut 闭包会通过可变引用来调用,其定义如下所示:
// `Fn`特型、`FnMut`特型和`FnOnce`特型的伪代码
trait Fn() -> R {
fn call(&self) -> R;
}
trait FnMut() -> R {
fn call_mut(&mut self) -> R;
}
trait FnOnce() -> R {
fn call_once(self) -> R;
}
任何需要对值进行可变访问但不会丢弃任何值的闭包都是 FnMut 闭包
fn call_twice<F>(closure: F) where F: Fn() {
closure();
closure();
}
let mut i = 0;
let incr = || {
i += 1; // incr借入了对i的一个可变引用
println!("Ding! i is now: {}", i);
};
call_twice(incr);
按照 call_twice 的调用方式,它会要求传入一个 Fn。由于 incr 是 FnMut 而非 Fn,因此上述代码无法通过编译。
对闭包的 Copy 与 Clone
就像能自动找出哪些闭包只能调用一次一样,Rust 也能找出哪些闭包可以实现 Copy 和 Clone,哪些则不可以实现。
闭包是表示包含它们捕获的变量的值(对于 move 闭包)或 对值的引用(对于非 move 闭包)的结构体。闭包的 Copy 规则和 Clone 规则 与常规结构体的规则是一样的。一个不修改变量的非 move 闭包只持有共享引用,这些引用既能 Clone 也能 Copy,所以闭包也能 Clone 和 Copy:
let y = 10;
let add1 = |x| x + y;
let add2 = add1; // 此闭包能`Copy`,所以……
assert_eq!(add1(add2(22)), 42); // ……可以调用它两次
一个会修改值的非 move 闭包在其内部表示中也可以有可变引用。可变引 用既不能 Clone,也不能 Copy,使用它们的闭包同样如此:
let mut x = 0;
let mut add_to_x = |n| { x += n; x };
let copy_of_add_to_x = add_to_x; // 这会进行移动而非复制
assert_eq!(add_to_x(copy_of_add_to_x(1)), 2); // 错误:使用了已移动出去的值
如果 move 闭包捕获的所有内容都能 Copy,那 它就能 Copy。如果 move 闭包捕获的所有内容都能 Clone
let mut greeting = String::from("Hello, ");
let greet = move |name| {
greeting.push_str(name); println!("{}", greeting);
};
greet.clone()("Alfred");
greet.clone()("Bruce");
回调
App::new()
.route("/", web::get().to(get_index))
.route("/gcd", web::post().to(post_gcd))
get_index 和 post_gcd 是我们在程序其他地方 使用 fn 关键字声明的函数名称,也可以在这里传入闭包:
App::new()
.route("/", web::get().to(|| {
HttpResponse::Ok()
.content_type("text/html")
.body("<title>GCD Calculator</title>...")
}))
.route("/gcd", web::post().to(|form: web::Form<GcdParameters>| {
HttpResponse::Ok()
.content_type("text/html")
.body(format!("The GCD of {} and {} is {}.",
form.n, form.m, gcd(form.n, form.m)))
}))
HTTP 请求和响应:
struct Request {
method: String,
url: String,
headers: HashMap<String, String>,
body: Vec<u8>
}
struct Response {
code: u32,
headers: HashMap<String, String>,
body: Vec<u8>
}
现在路由器所做的只是存储一个将 URL 映射到回调的表,以便按需调用正确的 回调。(为简单起见,只允许用户创建与单个 URL 精确匹配的路由。)
struct BasicRouter<C> where C: Fn(&Request) -> Response {
routes: HashMap<String, C>
}
impl<C> BasicRouter<C> where C: Fn(&Request) -> Response {
/// 创建一个空路由器
fn new() -> BasicRouter<C> {
BasicRouter { routes: HashMap::new() }
}
/// 给路由器添加一个路由
fn add_route(&mut self, url: &str, callback: C) {
self.routes.insert(url.to_string(), callback);
}
}
只给路由器添加一个路由,那么它是可以正常工作的:
let mut router = BasicRouter::new(); router.add_route("/", |_| get_form_response());
这段代码可以编译和运行。
如果再添加一个路由: router.add_route("/gcd", |req| get_gcd_response(req));
就会得到一些错误
所犯的错误在于如何定义 BasicRouter 类型:
struct BasicRouter<C> where C: Fn(&Request) -> Response {
routes: HashMap<String, C>
}
这里声明的每个 BasicRouter 都带有一个回调类型 C,并且 HashMap 中的所 有回调都是此类型的。
解决方案:因为要支持多种类型,所以需要使 用 Box 和特型对象:
type BoxedCallback = Box<dyn Fn(&Request) -> Response>;
struct BasicRouter {
routes: HashMap<String, BoxedCallback>
}
每个 Box 可以包含不同类型的闭包,因此单个 HashMap 可以包含各种回调。请 注意,类型参数 C 消失了。对此方法进行一些调整。
impl BasicRouter {
// 创建一个空路由器
fn new() -> BasicRouter {
BasicRouter { routes: HashMap::new() }
}
// 给路由器添加一个路由
fn add_route<C>(&mut self, url: &str, callback: C)
where C: Fn(&Request) -> Response + 'static {
self.routes.insert(url.to_string(), Box::new(callback));
}
}
注意 add_route 的类型签名中 C 的两个限界:特定的 Fn 特型和 'static 生命周期。Rust 要求我们添加这个 'static 限界。如果没有 它,那么对 Box::new(callback) 的调用就会出错,因为如果闭包包含对即将超出作用域的变量的已借用引用,那么存储闭包就是不安全的。
处理传入请求:
impl BasicRouter {
fn handle_request(&self, request: &Request) -> Response {
match self.routes.get(&request.url) {
None => not_found_response(),
Some(callback) => callback(request)
}
}
}
我们还可以写出此路由器的更省空间的版本:它并不 存储特型对象,而是使用函数指针或 fn 类型。
fn add_ten(x: u32) -> u32 {
x + 10
}
let fn_ptr: fn(u32) -> u32 = add_ten;
let eleven = fn_ptr(1); // 11
不从其环境中捕获任何内容的闭包与函数指针是一样的,因为它们不需 要保存有关捕获变量的任何额外信息。
持有函数指针的路由表如下所示:
struct FnPointerRouter {
routes: HashMap<String, fn(&Request) -> Response>
}
HashMap 只会为每个 String 键存储一个 usize 值,更关键的是, 没有 Box。除了 HashMap 自身,根本不存在动态分配
闭包具有独特的类型,因为每个闭包会捕获不同的变量,所以和别的语法元素一样,它们各自具有不同的大小。但是,如果闭包没有捕捉到任何东西,那就没有什么要存储的了。通过在接受回调的函数中使用 fn 指针,可 以限制调用者仅使用这些非捕获型闭包,以牺牲调用者的灵活性为代价,在接受回调的代码中换取一定的性能和灵活性。
高效使用闭包
在具有垃圾回收的语言中,你可以在闭包中使用局部变量,而无须考虑生命周期或所有权的问题。但如果没有垃圾回收,那么情况就不同了。一些在 Java、C# 和 JavaScript 中常见的设计模式如果不进行改变将无法在 Rust 中正常工作。
以模型-视图-控制器设计模式(简称 MVC)为例。对于用户界面的每个元素,MVC 框架都会创建 3 个对象:表示该 UI 元素状态的模型、 负责其外观的视图和处理用户交互的控制器。
多年来,MVC 模式已经出现了无数变体,但总体思路仍是 3 个对象以某种方式分担了 UI 的职责。 这就是问题所在。通常,每个对象都会直接或通过回调对其他对象中的一个或两个进行引用,每当 3 个对象中的一个对象发生变化时,它会通知其他两个对象,因此所有内容都会及时更新。哪个对象“拥有”其他对象之类的问题永远不会出现。
如果不进行更改,就无法在 Rust 中实现此模式。所有权必须明晰,循环引用也必须消除。模型和控制器不能相互直接引用。 Rust 的“激进赌注”是基于“必然存在好的替代设计”这个假设的。有时你可以通过 让每个闭包接受它需要的引用作为参数,来解决闭包所有权和生命周期的问题。 有时你可以为系统中的每个事物分配一个编号,并传递这些编号而不是传递引用。或者你可以实现 MVC 的众多变体之一,其中的对象并非都相互引用。或者 你可以将工具包建模为具有单向数据流的非 MVC 系统,比如 Facebook 的 Flux 架构