CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • 第1章 系统程序员也能享受美好事物
  • 第2章 Rust概览
  • 第3章 基本类型
  • 第4章 所有权与移动
  • 第5章 引用
  • 第6章 表达式
  • 第7章 错误处理
  • 第8章 包和模块
  • 第9章 结构体
  • 第10章 枚举和模式
  • 第11章 特性与泛型
  • 第12章 运算符重载
  • 第13章 实用特性
  • 第14章 闭包
    • 捕获变量
      • 借用变量的闭包
      • 窃取变量的闭包
    • 函数和闭包类型
    • 闭包性能
    • 闭包与安全性
      • 会释放值的闭包
      • FnOnce
      • FnMut
      • 闭包的Copy和Clone
    • 回调函数
    • 有效使用闭包
  • 第15章 迭代器
  • 第16章 集合
  • 第17章 字符串和文本
  • 第18章 输入与输出
  • 第19章 并发
  • 第20章 异步编程
  • Rust编程指南
zhangxf
2025-03-10
目录

第14章 闭包

# 第14章 闭包

拯救环境!今天就创建一个闭包! ——科马克·弗拉纳根(Cormac Flanagan)

对整数向量进行排序很简单:

integers.sort();
1

然而,令人遗憾的是,当我们想要对某些数据进行排序时,这些数据很少是整数向量。通常我们拥有的是某种记录,而内置的排序方法通常无法直接使用:

struct City {
    name: String,
    population: i64,
    country: String,
   ...
}

fn sort_cities(cities: &mut Vec<City>) {
    cities.sort();  // 错误:你希望按什么方式对它们进行排序?
}
1
2
3
4
5
6
7
8
9
10

Rust会报错,提示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); // 正常
}
1
2
3
4
5
6
7
8

辅助函数city_population_descending接受一个City记录,并提取出键,即我们想要用于对数据进行排序的字段(它返回一个负数,是因为sort方法按升序排列数字,而我们希望按降序排列,即人口最多的城市排在前面)。sort_by_key方法将这个键函数作为参数。

这种方法可行,但将辅助函数写成闭包(一种匿名函数表达式)会更加简洁:

fn sort_cities(cities: &mut Vec<City>) {
    cities.sort_by_key(|city| -city.population);
}
1
2
3

这里的闭包是|city| -city.population。它接受一个参数city,并返回-city.population。Rust会根据闭包的使用方式推断其参数类型和返回类型。

标准库中其他接受闭包的功能示例包括:

  • 迭代器方法,如map和filter,用于处理顺序数据。我们将在第15章介绍这些方法。
  • 线程相关的API,如thread::spawn,它用于启动一个新的系统线程。并发编程就是将工作分配到其他线程,而闭包可以很方便地表示工作单元。我们将在第19章介绍这些特性。
  • 一些有条件地需要计算默认值的方法,比如HashMap条目的or_insert_with方法。这个方法用于在HashMap中获取或创建一个条目,当计算默认值的开销较大时会使用它。默认值通过闭包传入,只有在必须创建新条目时才会调用这个闭包。

当然,如今匿名函数随处可见,即使在最初没有匿名函数的语言,如Java、C#、Python和C++中也是如此。从现在开始,我们假设你之前已经见过匿名函数,并重点关注Rust闭包的独特之处。在本章中,你将学习三种类型的闭包,如何在标准库方法中使用闭包,闭包如何“捕获”其作用域中的变量,如何编写接受闭包作为参数的函数和方法,以及如何存储闭包以便稍后作为回调使用。我们还将解释Rust闭包的实现方式,以及它们比你想象中更快的原因。

# 捕获变量

闭包可以使用属于包含它的函数的数据。例如:

/// 根据不同的统计数据进行排序。
fn sort_by_statistic(cities: &mut Vec<City>, stat: Statistic) {
    cities.sort_by_key(|city| -city.get_statistic(stat));
}
1
2
3
4

这里的闭包使用了stat,它由包含它的函数sort_by_statistic所拥有。我们说闭包“捕获”了stat。这是闭包的经典特性之一,Rust自然也支持;但在Rust中,这个特性有一些需要注意的地方。

在大多数支持闭包的语言中,垃圾回收起着重要作用。例如,考虑下面这段JavaScript代码:

// 启动一个动画,重新排列城市表格中的行。
function startSortingAnimation(cities, stat) {
    // 用于对表格进行排序的辅助函数。
    // 注意这个函数引用了stat。
    function keyfn(city) {
        return city.get_statistic(stat);
    }
    if (pendingSort)
        pendingSort.cancel();
    // 现在启动一个动画,将keyfn传递给它。
    // 排序算法稍后会调用keyfn。
    pendingSort = new SortingAnimation(cities, keyfn);
}
1
2
3
4
5
6
7
8
9
10
11
12
13

闭包keyfn存储在新的SortingAnimation对象中,它会在startSortingAnimation返回后被调用。通常,当一个函数返回时,它的所有变量和参数都会超出作用域并被丢弃。但在这里,JavaScript引擎必须以某种方式保留stat,因为闭包使用了它。大多数JavaScript引擎通过在堆中分配stat并让垃圾回收器稍后回收它来实现这一点。

Rust没有垃圾回收机制,那么它是如何工作的呢?为了回答这个问题,我们来看两个例子。

# 借用变量的闭包

首先,让我们重复本节开头的例子:

/// 根据不同的统计数据进行排序。
fn sort_by_statistic(cities: &mut Vec<City>, stat: Statistic) {
    cities.sort_by_key(|city| -city.get_statistic(stat));
}
1
2
3
4

在这种情况下,当Rust创建闭包时,它会自动借用一个指向stat的引用。这是合理的:闭包引用了stat,所以它必须有一个对stat的引用。

其余部分很简单。闭包遵循我们在第5章中描述的借用和生命周期规则。特别地,由于闭包包含一个对stat的引用,Rust不会让闭包的生命周期超过stat。由于闭包只在排序期间使用,所以这个例子没有问题。

简而言之,Rust通过使用生命周期而不是垃圾回收来确保安全性。Rust的方式更快:即使是快速的垃圾回收器分配,也会比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
    })
}
1
2
3
4
5
6
7
8
9

这更类似于我们之前的JavaScript示例:thread::spawn接受一个闭包,并在一个新的系统线程中调用它。注意,||是闭包的空参数列表。

新线程与调用者并行运行。当闭包返回时,新线程退出(闭包的返回值作为JoinHandle值返回给调用线程。我们将在第19章介绍这一点)。

同样,闭包key_fn包含一个对stat的引用。但这一次,Rust无法保证这个引用的使用是安全的。因此,Rust会拒绝这个程序:

error[E0373]: closure may outlive the current function, but it borrows `stat`,
which is owned by the current function
--> closures_sort_thread.rs:33:18
|
33 | let key_fn = |city: &City| -> i64 { -
^^^^^^^^^^^^^^^^^^^^                       ^^^^
city|.get_statistic(stat) };
|              |                                       `stat`
is borrowed here
|              may outlive borrowed value `stat`
1
2
3
4
5
6
7
8
9
10

实际上,这里有两个问题,因为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
    })
}
1
2
3
4
5
6
7

我们所做的唯一更改是在两个闭包前面都添加了move关键字。move关键字告诉Rust,闭包不会借用它使用的变量,而是窃取它们。

第一个闭包key_fn获取stat的所有权。然后,第二个闭包获取cities和key_fn的所有权。

因此,Rust为闭包提供了两种从包含作用域获取数据的方式:移动和借用。实际上,除此之外没有更多需要说明的了;闭包遵循我们在第4章和第5章中已经介绍过的移动和借用规则。以下是几个具体示例:

  • 就像在Rust语言的其他地方一样,如果闭包要移动一个可复制类型(如i32)的值,它会复制这个值。所以,如果Statistic碰巧是一个可复制类型,即使在创建了使用它的move闭包之后,我们仍然可以使用stat。
  • 不可复制类型(如Vec<City>)的值确实会被移动:前面的代码通过move闭包将cities转移到了新线程中。在创建闭包之后,Rust不允许我们再通过名称访问cities。
  • 碰巧在这个代码中,在闭包移动cities之后我们不需要再使用它。不过,如果确实需要使用,解决方法也很简单:我们可以告诉Rust克隆cities,并将副本存储在另一个变量中。闭包只会窃取其中一个副本,无论它引用的是哪个副本。

通过接受Rust的严格规则,我们获得了重要的东西:线程安全性。正是因为向量被移动,而不是在多个线程之间共享,我们才知道旧线程不会在新线程修改向量时释放它。

# 函数和闭包类型

在本章中,我们看到函数和闭包都可以作为值来使用。自然地,这意味着它们有类型。例如:

fn city_population_descending(city: &City) -> i64 {
    -city.population
}
1
2
3

这个函数接受一个参数(&City)并返回一个i64。它的类型是fn(&City) -> i64。

你可以对函数进行与其他值相同的操作。你可以将它们存储在变量中。你可以使用所有常见的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);
1
2
3
4
5
6
7

结构体可以有函数类型的字段。像Vec这样的泛型类型可以存储大量函数,只要它们都具有相同的fn类型。而且函数值占用空间很小:一个fn值是函数机器码的内存地址,就像C++中的函数指针一样。

一个函数可以接受另一个函数作为参数。例如:

/// 给定一个城市列表和一个测试函数,返回通过测试的城市数量。
fn count_selected_cities(cities: &Vec<City>, test_fn: fn(&City) -> bool) -> usize {
    let mut count = 0;
    for city in cities {
        if test_fn(city) {
            count += 1;
        }
    }
    count
}

/// 一个测试函数的示例。注意这个函数的类型是`fn(&City) -> bool`,与`count_selected_cities`的`test_fn`参数类型相同。
fn has_monster_attacks(city: &City) -> bool {
    city.monster_attack_risk > 0.0
}

// 有多少个城市有怪物袭击的风险?
let n = count_selected_cities(&my_cities, has_monster_attacks);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

如果你熟悉C/C++中的函数指针,你会发现Rust的函数值与之完全相同。

了解了这些之后,你可能会惊讶地发现闭包的类型与函数并不相同:

let limit = preferences.acceptable_monster_risk();
let n = count_selected_cities(
    &my_cities,
    |city| city.monster_attack_risk > limit);  // 错误:类型不匹配
1
2
3
4

第二个参数导致了类型错误。为了支持闭包,我们必须更改这个函数的类型签名。它需要如下所示:

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
}
1
2
3
4
5
6
7
8
9
10
11
12

我们只更改了count_selected_cities的类型签名,而没有更改其函数体。新版本是泛型的。它接受任何类型为F的test_fn,只要F实现了特殊特性Fn(&City) -> bool。所有接受单个&City作为参数并返回布尔值的函数和大多数闭包都会自动实现这个特性:

fn(&City) -> bool             // fn类型(仅函数)
Fn(&City) -> bool            // Fn特性(函数和闭包都适用)
1
2

这种特殊语法是内置在语言中的。->和返回类型是可选的;如果省略,返回类型为()。

新版本的count_selected_cities既接受函数也接受闭包:

count_selected_cities(
    &my_cities,
    has_monster_attacks); // 正常
count_selected_cities(
    &my_cities,
    |city| city.monster_attack_risk > limit);  // 也正常
1
2
3
4
5
6

为什么我们的第一次尝试不起作用呢?嗯,闭包是可调用的,但它不是fn类型。闭包|city| city.monster_attack_risk > limit有它自己的类型,不是fn类型。

实际上,你编写的每个闭包都有自己的类型,因为闭包可能包含数据:从包含作用域借用或窃取的值。这些数据可以是任意数量的变量,并且可以是任意类型的组合。所以每个闭包都有一个由编译器创建的临时类型,其大小足以容纳这些数据。没有两个闭包的类型是完全相同的。但是每个闭包都实现了一个Fn特性;我们示例中的闭包实现了Fn(&City) -> i64。

由于每个闭包都有自己的类型,处理闭包的代码通常需要是泛型的,就像count_selected_cities一样。每次都详细写出泛型类型有点麻烦,但要了解这种设计的优点,继续阅读就知道了。

# 闭包性能

Rust的闭包设计得非常高效:比函数指针更快,甚至快到在对性能要求极高的代码中也可以使用。如果你熟悉C++中的lambda表达式,你会发现Rust闭包同样高效且紧凑,但更安全。

在大多数语言中,闭包在堆上分配内存,采用动态调度,并通过垃圾回收机制管理。因此,创建、调用和回收每个闭包都会消耗少量额外的CPU时间。更糟糕的是,闭包往往会阻止内联(inlining),而内联是编译器用于消除函数调用开销并实现一系列其他优化的关键技术。总体而言,在这些语言中,闭包的速度较慢,以至于在紧密的内部循环中手动去除闭包是有意义的。

Rust闭包没有这些性能缺点。它们不依赖垃圾回收。与Rust中的其他内容一样,除非将它们放入Box、Vec或其他容器中,否则它们不会在堆上分配内存。而且,由于每个闭包都有独特的类型,只要Rust编译器知道你正在调用的闭包的类型,它就可以将该特定闭包的代码内联。这使得在紧密循环中使用闭包是可行的,Rust程序也经常这样做,你将在第15章中看到。

图14-1展示了Rust闭包在内存中的布局。在图的顶部,我们展示了闭包将引用的几个局部变量:一个字符串food和一个简单的枚举weather,其数值恰好为27。 img 图14-1 闭包在内存中的布局

闭包(a)使用了这两个变量。显然,我们在寻找既供应玉米卷又有龙卷风的城市。在内存中,这个闭包看起来像一个小结构体,包含对它使用的变量的引用。

请注意,它不包含指向其代码的指针!这没有必要:只要Rust知道闭包的类型,它就知道在调用闭包时应该运行哪段代码。

闭包(b)完全相同,只是它是一个move闭包,所以它包含的值是变量本身而不是引用。

闭包(c)不使用其环境中的任何变量。这个结构体是空的,所以这个闭包根本不占用任何内存。

如图所示,这些闭包占用的空间不大。但在实际中,即使是这几个字节也并非总是必需的。通常,编译器可以将对闭包的所有调用内联,然后甚至图中所示的小结构体也会被优化掉。

在 “回调函数” 中,我们将展示如何在堆中分配闭包并使用特性对象动态调用它们。这样做会稍微慢一些,但仍然与其他特性对象方法一样快。

# 闭包与安全性

到目前为止,在本章中我们讨论了Rust如何确保闭包在从周围代码借用或移动变量时遵守语言的安全规则。但还有一些不太明显的影响。在本节中,我们将进一步解释当闭包丢弃或修改捕获的值时会发生什么。

# 会释放值的闭包

我们已经见识过借用值的闭包和窃取值的闭包;出现会导致值被释放的闭包只是时间问题。

当然,“释放(kill)” 并不是一个准确的术语。在Rust中,我们使用drop来释放值。最直接的方法是调用drop():

let my_str = "hello".to_string();
let f = || drop(my_str);
1
2

当调用f时,my_str会被释放。那么如果我们调用它两次会发生什么呢?

f();
f();
1
2

让我们仔细思考一下。第一次调用f时,它释放了my_str,这意味着存储字符串的内存被释放并返回给系统。第二次调用f时,同样的事情会再次发生。这是一个双重释放(double free),是C++编程中的经典错误,会引发未定义行为。

在Rust中,两次释放一个String同样是个坏主意。幸运的是,Rust不会这么容易被欺骗:

f();  // 正常
f();  // 错误:使用已移动的值
1
2

Rust知道这个闭包不能被调用两次。

一个只能被调用一次的闭包可能看起来很不寻常,但在本书中我们一直在讨论所有权和生命周期。值被消耗(即被移动)的概念是Rust的核心概念之一。闭包在这方面与其他内容的原理是一样的。

# FnOnce

让我们再试一次,看能否让Rust两次释放一个String。这次,我们使用这个泛型函数:

fn call_twice<F>(closure: F)
where
    F: Fn()
{
    closure();
    closure();
}
1
2
3
4
5
6
7

这个泛型函数可以接受任何实现了Fn()特性的闭包,即不带参数且返回值为()的闭包(与函数一样,如果返回类型是(),则可以省略;Fn()是Fn() -> ()的简写)。

现在,如果我们将这个不安全的闭包传递给这个泛型函数会发生什么呢?

let my_str = "hello".to_string();
let f = || drop(my_str);
call_twice(f);
1
2
3

同样,闭包在被调用时会释放my_str。调用两次就会导致双重释放。但Rust再次没有被欺骗:

error[E0525]: expected a closure that implements the `Fn` trait,
but
this closure only implements `FnOnce`
--> closures_twice.rs:12:13
|
8 | let f = || drop(my_str);
| ^^^^^^^^------^
| | |
| | closure is `FnOnce` because it moves the
variable `my_str`
| | out of its environment
| this closure implements `FnOnce`, not `Fn`
9 | call_twice(f);
| ---------- the requirement to implement `Fn` derives from
here
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

这个错误信息让我们对Rust如何处理 “会释放值的闭包” 有了更多了解。它们本可以被完全禁止在语言中使用,但清理闭包有时很有用。因此,Rust限制了它们的使用。像f这样会释放值的闭包不允许拥有Fn特性。从字面上看,它们根本不是Fn。它们实现了一个功能较弱的特性FnOnce,即只能被调用一次的闭包的特性。

第一次调用FnOnce闭包时,闭包本身就会被消耗掉。就好像Fn和FnOnce这两个特性是这样定义的:

// 无参数的`Fn`和`FnOnce`特性的伪代码
trait Fn() -> R {
    fn call(&self) -> R;
}

trait FnOnce() -> R {
    fn call_once(self) -> R;
}
1
2
3
4
5
6
7
8

就像算术表达式a + b是方法调用Add::add(a, b)的简写一样,Rust将closure()视为前面示例中两个特性方法之一的简写。对于Fn闭包,closure()展开为closure.call()。这个方法通过引用获取self,所以闭包不会被移动。但是,如果闭包只安全地调用一次,那么closure()会展开为closure.call_once()。这个方法按值获取self,所以闭包会被消耗掉。

当然,我们在这里故意使用drop()来制造麻烦。在实践中,你大多是意外陷入这种情况。这种情况并不经常发生,但偶尔你会编写一些闭包代码,无意中消耗了一个值:

let dict = produce_glossary();
let debug_dump_dict = || {
    for (key, value) in dict { // 哎呀!
        println!("{:?} - {:?}", key, value);
    }
};
1
2
3
4
5
6

然后,当你多次调用debug_dump_dict()时,你会得到这样的错误信息:

error[E0382]: use of moved value: `debug_dump_dict`
--> closures_debug_dump_dict.rs:18:5
|
19 | debug_dump_dict();
| ----------------- `debug_dump_dict` moved due to this
call
20 | debug_dump_dict();
| ^^^^^^^^^^^^^^^ value used here after move
|
note: closure cannot be invoked more than once because it moves
the variable
`dict` out of its environment
--> src/main.rs:13:29
|
13 | for (key, value) in dict {
| ^^^^
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

为了调试这个问题,我们必须弄清楚为什么这个闭包是FnOnce。这里正在被消耗的值是哪个呢?编译器很贴心地指出是dict,在这种情况下它是我们唯一引用的变量。啊,问题出在这里:我们直接迭代dict,从而消耗了它。我们应该迭代&dict,而不是直接迭代dict,这样才能通过引用访问值:

let debug_dump_dict = || {
    for (key, value) in &dict { // 不会消耗dict
        println!("{:?} - {:?}", key, value);
    }
};
1
2
3
4
5

这样就修复了错误;这个函数现在是一个Fn闭包,可以被调用任意次数。

# 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;
}
1
2
3
4
5
6
7
8
9
10
11
12

任何需要对值进行可变访问但不会释放任何值的闭包都是FnMut闭包。例如:

let mut i = 0;
let incr = || {
    i += 1; // incr借用了对i的可变引用
    println!("Ding! i is now: {}", i);
};
call_twice(incr);
1
2
3
4
5
6

按照我们之前编写的call_twice函数,它需要一个Fn闭包。由于incr是FnMut闭包而不是Fn闭包,这段代码无法编译。

不过,有一个简单的解决方法。为了理解这个解决方法,让我们回顾一下并总结你所学的关于Rust闭包的三类。

  • Fn是一类闭包和函数,你可以不受限制地多次调用它们。这个最高级别的类别还包括所有fn函数。
  • FnMut是一类闭包,如果闭包本身被声明为可变的,就可以多次调用。
  • FnOnce是一类闭包,如果调用者拥有该闭包,就可以调用一次。

每个Fn闭包都满足FnMut的要求,每个FnMut闭包都满足FnOnce的要求。如图14-2所示,它们不是三个独立的类别。相反,Fn()是FnMut()的子特性,而FnMut()是FnOnce()的子特性。这使得Fn成为最具排他性且功能最强大的类别。FnMut和FnOnce是更宽泛的类别,包括使用上有限制的闭包。 img 图14-2 三类闭包的维恩图

现在我们已经梳理清楚了这些知识,很明显,为了接受尽可能广泛的闭包,我们的call_twice函数实际上应该接受所有FnMut闭包,如下所示:

fn call_twice<F>(mut closure: F)
where
    F: FnMut()
{
    closure();
    closure();
}
1
2
3
4
5
6
7

第一行的约束从F: Fn()变为了F: FnMut()。通过这个更改,我们仍然可以接受所有Fn闭包,并且还可以对会修改变量的闭包使用call_twice:

let mut i = 0;
call_twice(|| i += 1);  // 正常!
assert_eq!(i, 2);
1
2
3

# 闭包的Copy和Clone

就像Rust会自动判断哪些闭包只能被调用一次一样,它也可以判断哪些闭包可以实现Copy和Clone,哪些不能。

正如我们前面解释的,闭包被表示为结构体,其中包含它们捕获的变量的值(对于move闭包)或引用(对于非move闭包)。闭包上的Copy和Clone规则与普通结构体的Copy和Clone规则相同。一个不修改变量的非move闭包只持有共享引用,而共享引用既是Clone又是Copy的,所以这样的闭包也是Clone和Copy的:

let y = 10;
let add_y = |x| x + y;
let copy_of_add_y = add_y;                // 这个闭包是`Copy`的,所以...
assert_eq!(add_y(copy_of_add_y(22)), 42); //  ...我们可以调用这两个闭包
1
2
3
4

另一方面,一个会修改变量的非move闭包在其内部表示中包含可变引用。可变引用既不是Clone也不是Copy的,所以使用可变引用的闭包也不是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); // 错误:使用已移动的值
1
2
3
4

对于move闭包,规则更简单。如果move闭包捕获的所有内容都是Copy的,那么它就是Copy的。如果它捕获的所有内容都是Clone的,那么它就是Clone的。例如:

let mut greeting = String::from("Hello, ");
let greet = move |name| {
    greeting.push_str(name);
    println!("{}", greeting);
};
greet.clone()("Alfred");
greet.clone()("Bruce");
1
2
3
4
5
6
7

这种.clone()(...)语法有点奇怪,但它只是表示我们克隆闭包,然后调用克隆的闭包。这个程序的输出是:

Hello, Alfred
Hello, Bruce
1
2

当greeting在greet中被使用时,它被移动到了内部表示greet的结构体中,因为这是一个move闭包。所以,当我们克隆greet时,它内部的所有内容也会被克隆。有两个greeting的副本,在调用greet的克隆时会分别被修改。这本身可能用处不大,但当你需要将同一个闭包传递给多个函数时,它会非常有帮助。

# 回调函数

许多库将回调函数作为其API的一部分:用户提供函数,供库在之后调用。实际上,在本书中你已经见过一些这样的API。在第2章中,我们使用actix-web框架编写了一个简单的Web服务器。该程序的一个重要部分是路由器,如下所示:

App::new()
   .route("/", web::get().to(get_index))
   .route("/gcd", web::post().to(post_gcd))
1
2
3

路由器的作用是将来自互联网的传入请求路由到处理特定类型请求的Rust代码部分。在这个例子中,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)))
    }))
1
2
3
4
5
6
7
8
9
10
11

这是因为actix-web被编写为接受任何线程安全的Fn作为参数。

我们在自己的程序中如何做到这一点呢?让我们尝试从头开始编写一个非常简单的路由器,不使用actix-web中的任何代码。我们可以先声明一些类型来表示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>
}
1
2
3
4
5
6
7
8
9
10
11
12

现在,路由器的工作就是简单地存储一个将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);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

不幸的是,我们犯了一个错误。你注意到了吗?

只要我们只向这个路由器添加一条路由,它就能正常工作:

let mut router = BasicRouter::new();
router.add_route("/", |_| get_form_response());
1
2

这段代码可以编译并运行。但不幸的是,如果我们再添加一条路由:

router.add_route("/gcd", |req| get_gcd_response(req));
1

就会得到错误:

error[E0308]: mismatched types
--> closures_bad_router.rs:41:30
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^
41 |                 router.add_route("/gcd", |req| get_gcd_response(req));
|                               expected closure, found a different closure
|
= note: expected type `[closure@closures_bad_router.rs:40:27: 40:50]`
found type `[closure@closures_bad_router.rs:41:30: 41:57]`
note: no two closures, even if identical, have the same type
help: consider boxing your closure and/or using it as a trait object
1
2
3
4
5
6
7
8
9
10
11

我们的错误在于定义BasicRouter类型的方式:

struct BasicRouter<C>
where
    C: Fn(&Request) -> Response
{
    routes: HashMap<String, C>
}
1
2
3
4
5
6

我们无意中声明了每个BasicRouter都有一个单一的回调类型C,并且HashMap中的所有回调都是该类型。在 “该使用哪种方式” 中,我们展示了一个Salad类型,它也有同样的问题:

struct Salad<V: Vegetable> {
    veggies: Vec<V>
}
1
2
3

这里的解决方案与Salad的一样:由于我们希望支持多种类型,所以需要使用装箱和特性对象:

type BoxedCallback = Box<dyn Fn(&Request) -> Response>;
struct BasicRouter {
    routes: HashMap<String, BoxedCallback>
}
1
2
3
4

每个装箱后的闭包可以包含不同类型的闭包,所以单个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));
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

注意: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)
        }
    }
}
1
2
3
4
5
6
7
8

在牺牲一些灵活性的情况下,我们还可以编写一个更节省空间的路由器版本,它不存储特性对象,而是使用函数指针,即fn类型。这些类型,如fn(u32) -> u32,与闭包的行为很相似:

fn add_ten(x: u32) -> u32 {
    x + 10
}

let fn_ptr: fn(u32) -> u32 = add_ten;
let eleven = fn_ptr(1); // 11
1
2
3
4
5
6

实际上,不捕获环境中任何内容的闭包与函数指针是相同的,因为它们不需要保存关于捕获变量的任何额外信息。如果你在绑定或函数签名中指定了合适的fn类型,编译器会很乐意让你以这种方式使用它们:

let closure_ptr: fn(u32) -> u32 = |x| x + 1;
let two = closure_ptr(1); // 2
1
2

与捕获变量的闭包不同,这些函数指针只占用一个usize大小的空间。

函数指针也可以用于实现我们自己的动态调度,而不是像使用Box<dyn Fn()>那样依赖编译器:

struct FnPointerRouter {
    routes: HashMap<String, fn(&Request) -> Response>
}
1
2
3

这里,HashMap为每个String只存储一个usize,关键是没有Box。除了HashMap本身,根本没有动态分配。当然,方法也需要调整:

impl FnPointerRouter {
    // 创建一个空路由器。
    fn new() -> FnPointerRouter {
        FnPointerRouter { routes: HashMap::new() }
    }

    // 向路由器添加一条路由。
    fn add_route(&mut self, url: &str, callback: fn(&Request) -> Response) {
        self.routes.insert(url.to_string(), callback);
    }
}
1
2
3
4
5
6
7
8
9
10
11

如图14-1所示,闭包具有独特的类型,因为每个闭包捕获不同的变量,所以除其他因素外,它们的大小也各不相同。但是,如果它们不捕获任何内容,就没有什么需要存储的。在接受回调函数的函数中使用fn指针,你可以限制调用者只使用这些不捕获变量的闭包,在使用回调函数的代码中获得一些性能和灵活性,同时以牺牲API用户的灵活性为代价。

# 有效使用闭包

如我们所见,Rust的闭包与大多数其他语言中的闭包不同。最大的区别在于,在有垃圾回收(GC)的语言中,你可以在闭包中使用局部变量,而无需考虑生命周期或所有权问题。但在没有GC的Rust中,情况就不同了。一些在Java、C#和JavaScript中常见的设计模式,在不做修改的情况下无法在Rust中使用。

例如,以模型 - 视图 - 控制器设计模式(简称MVC)为例,如图14-3所示。对于用户界面的每个元素,MVC框架会创建三个对象:一个表示该UI元素状态的模型,一个负责其外观的视图,以及一个处理用户交互的控制器。多年来,已经实现了无数种MVC的变体,但总体思路是这三个对象以某种方式分担UI的职责。

问题在于,通常每个对象都直接或通过回调函数持有对其他一个或两个对象的引用,如图14-3所示。每当其中一个对象发生变化时,它会通知其他对象,以便所有内容都能及时更新。但在这种模式中,从来不会涉及哪个对象 “拥有” 其他对象的问题。 img 图14-3 模型 - 视图 - 控制器设计模式

在Rust中,如果不做一些更改,就无法实现这种模式。必须明确所有权,并且必须消除引用循环。模型和控制器不能相互直接引用。

Rust大胆地认为存在更好的替代设计。有时,你可以通过让每个闭包接收其所需的引用作为参数,来解决闭包所有权和生命周期的问题。有时,你可以为系统中的每个事物分配一个编号,并传递这些编号而不是引用。或者,你可以实现MVC的众多变体之一,其中对象之间并非都相互引用。或者,你可以仿照具有单向数据流的非MVC系统(如Facebook的Flux架构,如图14-4所示)来设计你的工具包。 img

图14-4 Flux架构,MVC的替代方案

简而言之,如果你试图使用Rust闭包构建一个 “对象的海洋”,你将会遇到困难。但有其他替代方案。在这种情况下,软件工程作为一门学科似乎已经倾向于这些替代方案,因为它们更简单。

在下一章中,我们将转向一个闭包真正发挥优势的主题。我们将编写一种代码,充分利用Rust闭包的简洁、快速和高效,这种代码编写起来有趣,易于阅读,并且非常实用。接下来:Rust迭代器。

编辑 (opens new window)
上次更新: 2025/03/20, 19:44:38
第13章 实用特性
第15章 迭代器

← 第13章 实用特性 第15章 迭代器→

最近更新
01
C++语言面试问题集锦 目录与说明
03-27
02
第四章 Lambda函数
03-27
03
第二章 关键字static及其不同用法
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式