《Rust编程语言》

由史蒂夫·克拉布尼克(Steve Klabnik)、卡罗尔·尼科尔斯(Carol Nichols)和克里斯·克里乔(Chris Krycho)撰写,还有来自 Rust 社区(Rust Community)的贡献。

此版本的文本假定你正在使用Rust 1.85.0(于2025年2月17日发布)或更高版本, 并且在所有项目的Cargo.toml文件中设置 edition = "2024" 以配置它们使用Rust 2024版习语。 请参阅第1章的 安装 来安装或更新Rust。

HTML格式可在线获取,网址为 https://doc.rust-lang.org/stable/book/; 使用rustup安装Rust后也可离线获取;运行rustup doc --book即可打开。

也有几个社区 翻译 版本可供使用。

本书有平装本和电子书版本,可通过 No Starch Press 获取。

🚨 想要更具互动性的学习体验吗?试试另一个版本的 《Rust编程语言》书籍,该版本具备:测验、高亮标记、可视化展示等功能: https://rust-book.cs.brown.edu

前言

Rust 编程语言的核心理念一直是 赋能:无论你现在编写何种代码,Rust 都能赋予你更强的能力,让你在更广泛的领域中自信地编程,超越以往的限制。

以“系统级”工作为例,这类工作涉及内存管理、数据表示和并发等底层细节。传统上,这一编程领域被视为高深莫测,只有那些经过多年学习、掌握了如何避免其臭名昭著的陷阱的少数人才能涉足。即便如此,这些开发者也必须小心翼翼,以免代码出现漏洞、崩溃或数据损坏。

Rust 通过消除这些传统陷阱并提供一套友好且完善的工具,打破了这些障碍。需要“深入”底层控制的程序员可以使用 Rust,而无需承担传统的崩溃或安全漏洞风险,也不必学习那些难以捉摸的工具链细节。更棒的是,Rust 的设计旨在自然地引导你编写出高效且可靠的代码,无论是在速度还是内存使用方面。

已经从事底层编程的开发者可以利用 Rust 提升他们的目标。例如,在 Rust 中引入并行编程是一个相对低风险的操作:编译器会帮你捕捉经典的错误。你可以更有信心地对代码进行更激进的优化,而不用担心意外引入崩溃或漏洞。

但 Rust 并不局限于底层系统编程。它足够表达力强且易用,使得编写命令行应用、Web 服务器以及许多其他类型的代码都变得相当愉快 —— 你将在书中后面看到一些简单的示例。使用 Rust 可以让你培养跨领域的技能;你可以先通过编写 Web 应用来学习 Rust,然后将这些技能应用到树莓派等项目中。

本书充分展现了 Rust 赋能用户的潜力。这是一本友好且易于上手的书籍,旨在帮助你不仅提升对 Rust 的了解,还能增强你作为程序员的整体能力和信心。所以,潜心学习,准备好迎接新的知识 —— 欢迎加入 Rust 社区!

—— Nicholas Matsakis 和 Aaron Turon

简介

注:本书版本与 No Starch Press 出版的印刷版和电子书版《The Rust Programming Language》nsprust 相同。

欢迎阅读《The Rust Programming Language》,这是一本关于 Rust 的入门书籍。Rust 编程语言帮助你编写更快、更可靠的软件。在编程语言设计中,高层次的易用性和低层次的控制往往难以两全;Rust 挑战了这一矛盾。通过平衡强大的技术能力和出色的开发者体验,Rust 让你可以选择控制底层细节(如内存使用),而无需承担传统上与这种控制相关的麻烦。

Rust 适合谁

Rust 因多种原因对许多人来说都是理想的选择。让我们来看几个最重要的群体。

开发团队

Rust 已被证明是一种高效的工具,适用于具有不同系统编程知识水平的大型开发团队协作。底层代码容易出现各种微妙的错误,而在大多数其他语言中,这些错误只能通过广泛的测试和经验丰富的开发者的仔细代码审查来发现。在 Rust 中,编译器充当守门员的角色,拒绝编译包含这些难以捉摸的错误(包括并发错误)的代码。通过与编译器一起工作,团队可以专注于程序逻辑,而不是追逐错误。

Rust 还为系统编程世界带来了现代开发者工具:

  • Cargo,内置的依赖管理器和构建工具,使得在 Rust 生态系统中添加、编译和管理依赖变得轻松且一致。
  • Rustfmt 格式化工具确保开发者之间的一致编码风格。
  • rust-analyzer 为集成开发环境(IDE)提供代码补全和内联错误消息支持。

通过使用 Rust 生态系统中的这些和其他工具,开发者可以在编写系统级代码时保持高效。

学生

Rust 适合学生和那些对系统概念感兴趣的人。使用 Rust,许多人已经学习了操作系统开发等主题。社区非常欢迎学生,并乐于回答他们的问题。通过这本书等努力,Rust 团队希望让系统概念对更多人更加容易接触,特别是那些编程新手。

公司

数百家公司,无论大小,都在生产环境中使用 Rust 来完成各种任务,包括命令行工具、Web 服务、DevOps 工具、嵌入式设备、音频和视频分析与转码、加密货币、生物信息学、搜索引擎、物联网应用、机器学习,甚至 Firefox 浏览器的主要部分。

开源开发者

Rust 适合那些想要构建 Rust 编程语言、社区、开发者工具和库的人。我们非常欢迎你为 Rust 语言做贡献。

追求速度和稳定性的人

Rust 适合那些对语言的速度和稳定性有要求的人。这里所说的速度,既指 Rust 代码的运行速度,也指 Rust 让你编写程序的速度。Rust 编译器的检查通过功能添加和重构确保了稳定性。这与没有这些检查的语言中的脆弱遗留代码形成对比,开发者常常害怕修改这些代码。通过追求零成本抽象——编译成底层代码的高层特性,速度与手动编写的代码一样快——Rust 致力于使安全代码也是快速代码。

Rust 语言希望支持更多其他用户;这里提到的只是其中一些最大的利益相关者。总体而言,Rust 最大的抱负是通过提供安全性和生产力、速度和易用性,消除程序员几十年来所接受的权衡。尝试一下 Rust,看看它的选择是否适合你。

这本书适合谁

本书假设你已经用另一种编程语言编写过代码,但对具体是哪种语言不做任何假设。我们尽量让材料广泛适用于具有各种编程背景的人。我们不会花太多时间讨论编程是什么或如何思考编程。如果你完全是编程新手,阅读一本专门介绍编程的书籍会更有帮助。

如何使用这本书

一般来说,本书假设你是按顺序从前到后阅读的。后面的章节建立在前面章节的概念之上,前面的章节可能不会深入探讨某个特定主题,但会在后面的章节中重新讨论该主题。

本书中有两种类型的章节:概念章节和项目章节。在概念章节中,你将学习 Rust 的某个方面。在项目章节中,我们将一起构建小型程序,应用你迄今为止所学的知识。第 2、12 和 21 章是项目章节;其余的是概念章节。

第 1 章解释如何安装 Rust,如何编写“Hello, world!”程序,以及如何使用 Cargo,Rust 的包管理器和构建工具。第 2 章是对在 Rust 中编写程序的实践介绍,让你构建一个猜数游戏。这里我们高层次地介绍概念,后面的章节将提供更多细节。如果你想立即动手实践,第 2 章就是你的起点。第 3 章涵盖与其他编程语言类似的 Rust 特性,第 4 章你将学习 Rust 的所有权系统。如果你是一个特别细致的学习者,喜欢在继续下一个主题之前学习每一个细节,你可能会想跳过第 2 章,直接去第 3 章,当你想通过应用所学知识的项目时再返回第 2 章。

第 5 章讨论结构体和方法,第 6 章涵盖枚举、match 表达式和 if let 控制流构造。你将使用结构体和枚举在 Rust 中创建自定义类型。

在第 7 章,你将学习 Rust 的模块系统和组织代码及其公共应用程序编程接口(API)的隐私规则。第 8 章讨论标准库提供的一些常见集合数据结构,如向量、字符串和哈希映射。第 9 章探讨 Rust 的错误处理哲学和技术。

第 10 章深入研究泛型、特性和生命周期,这些特性让你能够定义适用于多种类型的代码。第 11 章全部关于测试,即使有 Rust 的安全保证,测试也是确保程序逻辑正确的必要手段。在第 12 章,我们将构建 grep 命令行工具的一个子集功能实现,用于在文件中搜索文本。为此,我们将使用前面章节讨论的许多概念。

第 13 章探讨闭包和迭代器:Rust 中来自函数式编程语言的特性。在第 14 章,我们将更深入地研究 Cargo,并讨论与他人共享库的最佳实践。第 15 章讨论标准库提供的智能指针及其功能所需的特性。

在第 16 章,我们将探讨不同的并发编程模型,并讨论 Rust 如何帮助你在多线程中无畏地编程。在第 17 章,我们在此基础上进一步探讨 Rust 的 async 和 await 语法,以及任务、未来和流,以及它们启用的轻量级并发模型。

第 18 章比较 Rust 惯用法与你可能熟悉的面向对象编程原则。第 19 章是关于模式和模式匹配的参考,这些是 Rust 程序中表达思想的强大方式。第 20 章包含一系列高级主题,包括不安全的 Rust、宏以及更多关于生命周期、特性、类型、函数和闭包的内容。

在第 21 章,我们将完成一个项目,在该项目中我们将实现一个低级别的多线程 Web 服务器!

最后,一些附录以更参考性的格式包含有关语言的有用信息。附录 A 涵盖 Rust 的关键字,附录 B 涵盖 Rust 的运算符和符号,附录 C 涵盖标准库提供的可派生特性,附录 D 涵盖一些有用的开发工具,附录 E 解释 Rust 版本。在 附录 F 中,你可以找到本书的翻译版本,在 附录 G 中,我们将讨论 Rust 是如何制作的以及什么是 nightly Rust。

阅读这本书没有错误的方式:如果你想跳过前面的内容,尽管去做!如果你遇到任何困惑,可能需要跳回到前面的章节。但无论如何,选择适合你的方式。

学习 Rust 过程中的一个重要部分是学习如何阅读编译器显示的错误消息:这些将引导你编写可运行的代码。因此,我们将提供许多无法编译的示例,并附上编译器在每种情况下显示的错误消息。请注意,如果你输入并运行一个随机示例,它可能无法编译!确保阅读周围的文本,看看你尝试运行的示例是否预期会出错。Ferris 也会帮助你区分不应该工作的代码:

Ferris含义
Ferris with a question mark此代码无法编译!
Ferris throwing up their hands此代码发生恐慌!
Ferris with one claw up, shrugging此代码未产生预期的行为。

在大多数情况下,我们会引导你找到任何无法编译的代码的正确版本。

源代码

生成本书的源文件可以在 GitHub 上找到。

开始入门

让我们开启你的 Rust 之旅吧!有很多东西要学,但每个旅程都有起点。在这一章中,我们将讨论:

  • 在 Linux、macOS 和 Windows 上安装 Rust
  • 编写一个打印 Hello, world! 的程序
  • 使用 cargo,Rust 的包管理器和构建系统

安装

第一步是安装 Rust。我们将通过 rustup 下载 Rust,这是一个用于管理 Rust 版本和相关工具的命令行工具。下载时需要互联网连接。

注意:如果由于某种原因你不想使用 rustup,请参阅 其他 Rust 安装方法页面 了解更多选项。

以下步骤将安装最新稳定版本的 Rust 编译器。Rust 的稳定性保证确保书中所有能够编译的示例将继续在更新的 Rust 版本中编译。由于 Rust 经常改进错误消息和警告,不同版本之间的输出可能会有细微差别。换句话说,使用这些步骤安装的任何更新的稳定版本 Rust 都应该能够按预期与本书内容一起工作。

命令行表示法

在本章和整本书中,我们将展示一些在终端中使用的命令。需要在终端中输入的行都以 $ 开头。你不需要输入 $ 字符;它是命令行提示符,表示每个命令的开始。不以 $ 开头的行通常显示前一个命令的输出。此外,特定于 PowerShell 的示例将使用 > 而不是 $

在 Linux 或 macOS 上安装 rustup

如果你使用的是 Linux 或 macOS,打开终端并输入以下命令:

$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

该命令下载一个脚本并启动 rustup 工具的安装,该工具将安装最新稳定版本的 Rust。系统可能会提示你输入密码。如果安装成功,将出现以下行:

Rust is installed now. Great!

你还需要一个 链接器,这是 Rust 用来将其编译输出合并到一个文件中的程序。很可能你已经有一个了。如果遇到链接器错误,你应该安装一个 C 编译器,它通常会包含一个链接器。C 编译器也很有用,因为一些常见的 Rust 包依赖于 C 代码,并且需要一个 C 编译器。

在 macOS 上,你可以通过运行以下命令来获取 C 编译器:

$ xcode-select --install

Linux 用户应根据其发行版的文档安装 GCC 或 Clang。例如,如果你使用 Ubuntu,可以安装 build-essential 包。

在 Windows 上安装 rustup

在 Windows 上,访问 https://www.rust-lang.org/tools/install 并按照安装 Rust 的说明操作。在安装过程中的某个时刻,系统会提示你安装 Visual Studio。这提供了编译程序所需的链接器和本地库。如果需要更多帮助,请参阅 https://rust-lang.github.io/rustup/installation/windows-msvc.html

本书的其余部分使用在 cmd.exe 和 PowerShell 中都能工作的命令。如果有特定差异,我们会解释使用哪一个。

故障排除

要检查 Rust 是否正确安装,打开一个 shell 并输入以下命令:

$ rustc --version

你应该看到最新稳定版本的版本号、提交哈希和提交日期,格式如下:

rustc x.y.z (abcabcabc yyyy-mm-dd)

如果你看到这些信息,说明 Rust 安装成功!如果没有看到这些信息,请按照以下方式检查 Rust 是否在你的 %PATH% 系统变量中。

在 Windows CMD 中,使用:

> echo %PATH%

在 PowerShell 中,使用:

> echo $env:Path

在 Linux 和 macOS 中,使用:

$ echo $PATH

如果这些都没问题但 Rust 仍然无法工作,你可以从多个地方获得帮助。了解如何在 社区页面 上联系其他 Rustaceans(我们给自己的昵称)。

更新和卸载

通过 rustup 安装 Rust 后,更新到新发布的版本非常简单。在你的 shell 中运行以下更新脚本:

$ rustup update

要从你的 shell 中卸载 Rust 和 rustup,运行以下卸载脚本:

$ rustup self uninstall

本地文档

Rust 的安装还包括一份本地文档副本,这样你就可以离线阅读。运行 rustup doc 在浏览器中打开本地文档。

每当标准库提供了某个类型或函数而你不确定它的作用或如何使用时,使用应用程序编程接口(API)文档来查找相关信息!

文本编辑器和集成开发环境

本书对用于编写 Rust 代码的工具没有任何假设。几乎任何文本编辑器都能完成这项工作!然而,许多文本编辑器和集成开发环境(IDE)都有内置的 Rust 支持。你可以在 Rust 网站的 工具页面 上随时找到一份相当最新的编辑器和 IDE 列表。

Hello, World!

现在你已经安装了 Rust,是时候编写你的第一个 Rust 程序了。学习一门新语言时,传统上会编写一个小程序,将文本 Hello, world! 打印到屏幕上,所以我们在这里也这样做!

注意:本书假设你对命令行有基本的熟悉程度。Rust 对你的编辑器或工具以及代码存放的位置没有特定要求,所以如果你更喜欢使用集成开发环境(IDE)而不是命令行,可以随意使用你喜欢的 IDE。许多 IDE 现在都有一定程度的 Rust 支持;请查阅 IDE 的文档以获取详细信息。Rust 团队一直在专注于通过 rust-analyzer 提供出色的 IDE 支持。有关更多详细信息,请参阅 附录 D

创建项目目录

首先,你需要创建一个目录来存放你的 Rust 代码。Rust 并不关心你的代码存放在哪里,但为了本书中的练习和项目,我们建议在你的主目录下创建一个 projects 目录,并将所有项目放在其中。

打开终端并输入以下命令来创建一个 projects 目录,并在 projects 目录内为 “Hello, world!” 项目创建一个目录。

对于 Linux、macOS 和 Windows 上的 PowerShell,输入以下命令:

$ mkdir ~/projects
$ cd ~/projects
$ mkdir hello_world
$ cd hello_world

对于 Windows CMD,输入以下命令:

> mkdir "%USERPROFILE%\projects"
> cd /d "%USERPROFILE%\projects"
> mkdir hello_world
> cd hello_world

编写和运行 Rust 程序

接下来,创建一个新的源文件并命名为 main.rs。Rust 文件总是以 .rs 扩展名结尾。如果你的文件名包含多个单词,约定使用下划线分隔它们。例如,使用 hello_world.rs 而不是 helloworld.rs

现在打开你刚刚创建的 main.rs 文件,并输入清单 1-1 中的代码。

fn main() {
    println!("Hello, world!");
}

保存文件并返回到你的终端窗口,确保你在 ~/projects/hello_world 目录中。在 Linux 或 macOS 上,输入以下命令来编译和运行该文件:

$ rustc main.rs
$ ./main
Hello, world!

在 Windows 上,输入命令 .\main.exe 而不是 ./main

> rustc main.rs
> .\main.exe
Hello, world!

无论你的操作系统是什么,字符串 Hello, world! 都应该打印到终端。如果你没有看到这个输出,请参考安装部分的 “故障排除” 部分以获取帮助。

如果 Hello, world! 确实打印出来了,恭喜你!你已经正式编写了一个 Rust 程序。这意味着你成为了一名 Rust 程序员——欢迎!

Rust 程序的剖析

让我们详细回顾一下这个 “Hello, world!” 程序。首先是第一部分:

fn main() {

}

这些行定义了一个名为 main 的函数。main 函数很特殊:它是每个可执行的 Rust 程序中首先运行的代码。这里,第一行声明了一个名为 main 的函数,该函数没有参数并且不返回任何内容。如果有参数,它们会放在括号 () 内。

函数体被包裹在 {} 中。Rust 要求所有函数体周围都有大括号。良好的风格是将左大括号放在与函数声明相同的行上,并在两者之间添加一个空格。

注意:如果你想在 Rust 项目中坚持一种标准风格,可以使用一个名为 rustfmt 的自动格式化工具来以特定的风格格式化你的代码(有关 rustfmt 的更多信息,请参阅 附录 D)。Rust 团队已将此工具包含在标准 Rust 发行版中,就像 rustc 一样,因此它应该已经安装在你的计算机上!

main 函数的主体包含以下代码:

#![allow(unused)]
fn main() {
println!("Hello, world!");
}

这一行完成了这个小程序的所有工作:它将文本打印到屏幕上。这里有三个重要的细节需要注意。

首先,println! 调用了 Rust 宏。如果它调用的是函数而不是宏,则会写成 println(没有 !)。我们将在第 20 章更详细地讨论 Rust 宏。目前,你只需要知道使用 ! 表示你在调用宏而不是普通函数,并且宏并不总是遵循与函数相同的规则。

其次,你看到了 "Hello, world!" 字符串。我们将这个字符串作为参数传递给 println!,然后字符串被打印到屏幕上。

第三,我们在行尾加上了分号 (;),这表示这个表达式结束了,下一个表达式即将开始。大多数 Rust 代码行都以分号结尾。

编译和运行是分开的步骤

你刚刚运行了一个新创建的程序,让我们来检查一下这个过程中的每个步骤。

在运行 Rust 程序之前,你必须使用 Rust 编译器编译它,方法是输入 rustc 命令并传递你的源文件名,如下所示:

$ rustc main.rs

如果你有 C 或 C++ 背景,你会注意到这类似于 gccclang。成功编译后,Rust 会输出一个二进制可执行文件。

在 Linux、macOS 和 Windows 上的 PowerShell 中,你可以通过在 shell 中输入 ls 命令来查看可执行文件:

$ ls
main  main.rs

在 Linux 和 macOS 上,你会看到两个文件。在 Windows 上使用 PowerShell 时,你会看到与使用 CMD 时相同的三个文件。在 Windows 上使用 CMD 时,你会输入以下命令:

> dir /B %= the /B option says to only show the file names =%
main.exe
main.pdb
main.rs

这会显示带有 .rs 扩展名的源代码文件、可执行文件(在 Windows 上是 main.exe,但在其他平台上都是 main),以及在使用 Windows 时,一个带有 .pdb 扩展名的包含调试信息的文件。从这里开始,你可以运行 mainmain.exe 文件,如下所示:

$ ./main # 或者在 Windows 上使用 .\main.exe

如果你的 main.rs 是你的 “Hello, world!” 程序,这一行会将 Hello, world! 打印到你的终端。

如果你更熟悉动态语言,如 Ruby、Python 或 JavaScript,你可能不习惯将编译和运行程序作为分开的步骤。Rust 是一种 预编译 语言,这意味着你可以编译一个程序并将可执行文件交给其他人,他们甚至可以在没有安装 Rust 的情况下运行它。如果你给某人一个 .rb.py.js 文件,他们需要分别安装 Ruby、Python 或 JavaScript 实现。但在这些语言中,你只需要一个命令就可以编译和运行你的程序。语言设计中的一切都是权衡。

仅使用 rustc 编译对于简单的程序来说是可以的,但随着项目的增长,你会希望管理所有的选项并使分享代码变得容易。接下来,我们将向你介绍 Cargo 工具,它将帮助你编写现实世界中的 Rust 程序。

Hello, Cargo!

Cargo 是 Rust 的构建系统和包管理器。大多数 Rust 开发者使用这个工具来管理他们的 Rust 项目,因为 Cargo 为你处理了许多任务,比如构建你的代码、下载你的代码所依赖的库,并构建这些库。(我们称你的代码所需的库为 依赖项。)

最简单的 Rust 程序,比如我们目前编写的这个,没有任何依赖项。如果我们使用 Cargo 构建“Hello, world!”项目,它只会使用 Cargo 处理代码构建的部分。随着你编写更复杂的 Rust 程序,你会添加依赖项,如果你使用 Cargo 启动项目,添加依赖项将会变得更加容易。

因为绝大多数 Rust 项目都使用 Cargo,本书的其余部分假设你也在使用 Cargo。如果你使用了“安装”部分讨论的官方安装程序,Cargo 会随 Rust 一起安装。如果你通过其他方式安装了 Rust,可以通过在终端中输入以下命令来检查是否安装了 Cargo:

$ cargo --version

如果你看到一个版本号,说明你已经安装了 Cargo!如果你看到一个错误,比如 command not found,请查看你安装方法的文档,以确定如何单独安装 Cargo。

使用 Cargo 创建项目

让我们使用 Cargo 创建一个新项目,并看看它与我们最初的“Hello, world!”项目有何不同。导航回你的 projects 目录(或你决定存储代码的任何地方)。然后,在任何操作系统上运行以下命令:

$ cargo new hello_cargo
$ cd hello_cargo

第一个命令创建了一个名为 hello_cargo 的新目录和项目。我们将项目命名为 hello_cargo,Cargo 会在同名目录中创建其文件。

进入 hello_cargo 目录并列出文件。你会看到 Cargo 为我们生成了两个文件和一个目录:一个 Cargo.toml 文件和一个包含 main.rs 文件的 src 目录。

它还初始化了一个新的 Git 仓库,并附带了一个 .gitignore 文件。如果你在现有的 Git 仓库中运行 cargo new,则不会生成 Git 文件;你可以通过使用 cargo new --vcs=git 来覆盖此行为。

注意:Git 是一个常见的版本控制系统。你可以通过使用 --vcs 标志来更改 cargo new 以使用不同的版本控制系统或不使用版本控制系统。运行 cargo new --help 查看可用的选项。

在你选择的文本编辑器中打开 Cargo.toml。它应该类似于 Listing 1-2 中的代码。

[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2024"

[dependencies]

这个文件采用 TOMLTom’s Obvious, Minimal Language)格式,这是 Cargo 的配置格式。

第一行 [package] 是一个部分标题,表示以下语句正在配置一个包。随着我们向这个文件添加更多信息,我们将添加其他部分。

接下来的三行设置了 Cargo 编译你的程序所需的配置信息:名称、版本和要使用的 Rust 版本。我们将在附录 E中讨论 edition 键。

最后一行 [dependencies] 是一个部分的开始,用于列出你的项目的任何依赖项。在 Rust 中,代码包被称为 crates。我们在这个项目中不需要任何其他 crate,但在第二章的第一个项目中会需要,所以我们将在那时使用这个依赖项部分。

现在打开 src/main.rs 并查看:

文件名: src/main.rs

fn main() {
    println!("Hello, world!");
}

Cargo 为你生成了一个“Hello, world!”程序,就像我们在 Listing 1-1 中编写的那个!到目前为止,我们的项目与 Cargo 生成的项目之间的区别在于,Cargo 将代码放在了 src 目录中,并且我们在顶层目录中有一个 Cargo.toml 配置文件。

Cargo 期望你的源文件位于 src 目录中。顶层项目目录仅用于 README 文件、许可证信息、配置文件以及任何与代码无关的内容。使用 Cargo 有助于你组织项目。每个东西都有其位置,每个东西都在其位置上。

如果你启动了一个不使用 Cargo 的项目,就像我们使用“Hello, world!”项目一样,你可以将其转换为使用 Cargo 的项目。将项目代码移动到 src 目录中,并创建一个适当的 Cargo.toml 文件。获取该 Cargo.toml 文件的一个简单方法是运行 cargo init,它会自动为你创建它。

构建和运行 Cargo 项目

现在让我们看看使用 Cargo 构建和运行“Hello, world!”程序时有什么不同!从你的 hello_cargo 目录中,通过输入以下命令来构建你的项目:

$ cargo build
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 2.85 secs

这个命令在 target/debug/hello_cargo(或在 Windows 上是 target\debug\hello_cargo.exe)中创建一个可执行文件,而不是在你的当前目录中。因为默认构建是调试构建,Cargo 将二进制文件放在名为 debug 的目录中。你可以使用以下命令运行可执行文件:

$ ./target/debug/hello_cargo # 或者在 Windows 上是 .\target\debug\hello_cargo.exe
Hello, world!

如果一切顺利,Hello, world! 应该会打印到终端。第一次运行 cargo build 还会导致 Cargo 在顶层创建一个新文件:Cargo.lock。这个文件跟踪项目中依赖项的确切版本。这个项目没有依赖项,所以文件有点稀疏。你永远不需要手动更改这个文件;Cargo 会为你管理其内容。

我们刚刚使用 cargo build 构建了一个项目,并使用 ./target/debug/hello_cargo 运行它,但我们也可以使用 cargo run 来编译代码,然后在一个命令中运行生成的可执行文件:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/hello_cargo`
Hello, world!

使用 cargo run 比记住运行 cargo build 然后使用二进制文件的完整路径更方便,因此大多数开发者使用 cargo run

注意,这次我们没有看到表明 Cargo 正在编译 hello_cargo 的输出。Cargo 发现文件没有改变,所以它没有重新构建,而是直接运行了二进制文件。如果你修改了源代码,Cargo 会在运行之前重新构建项目,你会看到以下输出:

$ cargo run
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.33 secs
     Running `target/debug/hello_cargo`
Hello, world!

Cargo 还提供了一个名为 cargo check 的命令。这个命令快速检查你的代码以确保它可以编译,但不会生成可执行文件:

$ cargo check
   Checking hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.32 secs

为什么你不需要可执行文件?通常,cargo checkcargo build 快得多,因为它跳过了生成可执行文件的步骤。如果你在编写代码时不断检查你的工作,使用 cargo check 将加快让你知道你的项目是否仍在编译的过程!因此,许多 Rust 开发者在编写程序时定期运行 cargo check,以确保它能够编译。然后,当他们准备好使用可执行文件时,他们会运行 cargo build

让我们回顾一下我们到目前为止学到的关于 Cargo 的内容:

  • 我们可以使用 cargo new 创建一个项目。
  • 我们可以使用 cargo build 构建一个项目。
  • 我们可以使用 cargo run 在一个步骤中构建并运行一个项目。
  • 我们可以使用 cargo check 构建一个项目而不生成二进制文件以检查错误。
  • Cargo 将构建结果存储在 target/debug 目录中,而不是与我们的代码保存在同一目录中。

使用 Cargo 的另一个优点是,无论你在哪个操作系统上工作,命令都是相同的。因此,在这一点上,我们将不再提供针对 Linux 和 macOS 与 Windows 的具体说明。

发布构建

当你的项目最终准备好发布时,你可以使用 cargo build --release 来编译它并进行优化。这个命令将在 target/release 而不是 target/debug 中创建一个可执行文件。优化使你的 Rust 代码运行得更快,但启用它们会延长程序的编译时间。这就是为什么有两种不同的配置文件:一种用于开发,当你希望快速且频繁地重新构建时;另一种用于构建最终程序,你将把它交给用户,不会重复构建,并且会尽可能快地运行。如果你在基准测试代码的运行时间,请确保运行 cargo build --release 并使用 target/release 中的可执行文件进行基准测试。

Cargo 作为惯例

对于简单的项目,Cargo 并没有比直接使用 rustc 提供太多价值,但随着你的程序变得更加复杂,它将证明其价值。一旦程序增长到多个文件或需要依赖项,让 Cargo 协调构建会容易得多。

尽管 hello_cargo 项目很简单,但它现在使用了许多你在 Rust 职业生涯中会使用的真实工具。事实上,要处理任何现有项目,你可以使用以下命令通过 Git 检出代码,切换到该项目的目录,并进行构建:

$ git clone example.org/someproject
$ cd someproject
$ cargo build

有关 Cargo 的更多信息,请查看其文档

总结

你已经开始了 Rust 之旅的精彩开端!在本章中,你学会了如何:

  • 使用 rustup 安装最新的稳定版 Rust
  • 更新到较新的 Rust 版本
  • 打开本地安装的文档
  • 直接使用 rustc 编写并运行“Hello, world!”程序
  • 使用 Cargo 的惯例创建并运行一个新项目

现在是构建一个更实质性的程序以习惯阅读和编写 Rust 代码的好时机。因此,在第二章中,我们将构建一个猜数字游戏程序。如果你更愿意从学习 Rust 中常见的编程概念开始,请参见第三章,然后返回第二章。

编写一个猜数字游戏

让我们通过一个实际项目来快速入门 Rust!本章将通过一个真实的程序向你介绍一些常见的 Rust 概念。你将学习到 letmatch、方法、关联函数、外部 crate 等等!在接下来的章节中,我们将更详细地探讨这些概念。在本章中,你只需练习基础知识。

我们将实现一个经典的初学者编程问题:猜数字游戏。它的工作原理如下:程序会生成一个 1 到 100 之间的随机整数。然后它会提示玩家输入一个猜测。输入猜测后,程序会提示猜测是太低还是太高。如果猜测正确,游戏将打印一条祝贺消息并退出。

设置一个新项目

要设置一个新项目,请进入你在第 1 章中创建的 projects 目录,并使用 Cargo 创建一个新项目,如下所示:

$ cargo new guessing_game
$ cd guessing_game

第一个命令 cargo new 将项目名称(guessing_game)作为第一个参数。第二个命令切换到新项目的目录。

查看生成的 Cargo.toml 文件:

文件名: Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2024"

[dependencies]

正如你在第 1 章中看到的,cargo new 会为你生成一个“Hello, world!”程序。查看 src/main.rs 文件:

文件名: src/main.rs

fn main() {
    println!("Hello, world!");
}

现在让我们使用 cargo run 命令编译并运行这个“Hello, world!”程序:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
     Running `file:///projects/guessing_game/target/debug/guessing_game`
Hello, world!

当你需要快速迭代项目时,run 命令非常有用,就像我们在这个游戏中要做的那样,快速测试每个迭代,然后再进行下一个迭代。

重新打开 src/main.rs 文件。你将在该文件中编写所有代码。

处理猜测

猜数字游戏程序的第一部分将要求用户输入,处理该输入,并检查输入是否符合预期格式。首先,我们将允许玩家输入一个猜测。将代码清单 2-1 中的代码输入到 src/main.rs 中。

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {}", guess);
}

这段代码包含了很多信息,所以我们逐行来看。为了获取用户输入并打印结果,我们需要将 io 输入/输出库引入作用域。io 库来自标准库,称为 std

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {}", guess);
}

默认情况下,Rust 在标准库中定义了一组项目,并将其引入每个程序的作用域。这个集合称为 prelude,你可以在标准库文档中查看其中的所有内容。

如果你想使用的类型不在 prelude 中,你必须使用 use 语句显式地将该类型引入作用域。使用 std::io 库为你提供了许多有用的功能,包括接受用户输入的能力。

正如你在第 1 章中看到的,main 函数是程序的入口点:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {}", guess);
}

fn 语法声明了一个新函数;括号 () 表示没有参数;而大括号 { 表示函数体的开始。

正如你在第 1 章中学到的,println! 是一个将字符串打印到屏幕的宏:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {}", guess);
}

这段代码打印了一个提示,说明游戏是什么,并要求用户输入。

使用变量存储值

接下来,我们将创建一个 变量 来存储用户输入,如下所示:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {}", guess);
}

现在程序变得有趣了!这短短的一行代码中有很多内容。我们使用 let 语句创建变量。这是另一个例子:

let apples = 5;

这行代码创建了一个名为 apples 的新变量,并将其绑定到值 5。在 Rust 中,变量默认是不可变的,这意味着一旦我们给变量赋值,该值就不会改变。我们将在第 3 章的“变量和可变性”部分详细讨论这个概念。要使变量可变,我们在变量名前添加 mut

let apples = 5; // 不可变
let mut bananas = 5; // 可变

注意:// 语法开始一个注释,直到行尾。Rust 会忽略注释中的所有内容。我们将在第 3 章中更详细地讨论注释。

回到猜数字游戏程序,你现在知道 let mut guess 将引入一个名为 guess 的可变变量。等号(=)告诉 Rust 我们现在要将某些东西绑定到变量上。等号的右边是 guess 绑定的值,它是调用 String::new 的结果,String::new 是一个返回 String 新实例的函数。String 是标准库提供的一种字符串类型,它是可增长的、UTF-8 编码的文本。

::new 行中的 :: 语法表示 newString 类型的关联函数。关联函数 是在类型上实现的函数,在这里是 String。这个 new 函数创建一个新的空字符串。你会在许多类型上找到 new 函数,因为它是创建某种新值的函数的常见名称。

总的来说,let mut guess = String::new(); 这行代码创建了一个可变变量,该变量当前绑定到一个新的、空的 String 实例。哇!

接收用户输入

回想一下,我们在程序的第一行使用 use std::io; 包含了标准库的输入/输出功能。现在我们将调用 io 模块中的 stdin 函数,这将允许我们处理用户输入:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {}", guess);
}

如果我们在程序开头没有使用 use std::io; 导入 io 库,我们仍然可以通过将函数调用写为 std::io::stdin 来使用该函数。stdin 函数返回一个 std::io::Stdin 的实例,这是一个表示终端标准输入句柄的类型。

接下来,.read_line(&mut guess) 这行代码在标准输入句柄上调用 read_line 方法以获取用户输入。我们还传递 &mut guess 作为 read_line 的参数,告诉它要将用户输入存储在哪个字符串中。read_line 的完整工作是获取用户输入到标准输入的任何内容,并将其附加到一个字符串中(而不覆盖其内容),因此我们将该字符串作为参数传递。字符串参数需要是可变的,以便方法可以更改字符串的内容。

& 表示这个参数是一个 引用,它让你可以让你代码的多个部分访问同一块数据,而不需要多次将数据复制到内存中。引用是一个复杂的功能,Rust 的主要优势之一是如何安全且容易地使用引用。你不需要了解很多细节来完成这个程序。现在,你只需要知道,像变量一样,引用默认是不可变的。因此,你需要写 &mut guess 而不是 &guess 来使其可变。(第 4 章将更详细地解释引用。)

使用 Result 处理潜在的错误

我们仍然在处理这行代码。我们现在讨论的是第三行文本,但请注意它仍然是单个逻辑代码行的一部分。接下来的部分是这个方法:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {}", guess);
}

我们可以将这行代码写成:

io::stdin().read_line(&mut guess).expect("Failed to read line");

然而,一行太长的代码难以阅读,所以最好将其分开。当你使用 .method_name() 语法调用方法时,通常明智的做法是引入换行符和其他空白符来帮助分解长行。现在让我们讨论这行代码的作用。

如前所述,read_line 将用户输入的任何内容放入我们传递给它的字符串中,但它也返回一个 Result 值。Result 是一个枚举,通常称为 enum,它是一种可以处于多种可能状态之一的类型。我们将每个可能的状态称为 变体

第 6 章将更详细地介绍枚举。这些 Result 类型的目的是编码错误处理信息。

Result 的变体是 OkErrOk 变体表示操作成功,并且它包含成功生成的值。Err 变体表示操作失败,并且它包含有关操作失败的方式或原因的信息。

Result 类型的值,像任何类型的值一样,都有定义在其上的方法。Result 的实例有一个 expect 方法,你可以调用它。如果这个 Result 实例是一个 Err 值,expect 将导致程序崩溃并显示你作为参数传递给 expect 的消息。如果 read_line 方法返回 Err,这很可能是由于底层操作系统的错误。如果这个 Result 实例是一个 Ok 值,expect 将获取 Ok 所持有的返回值并将其返回给你,以便你可以使用它。在这种情况下,该值是用户输入的字节数。

如果你不调用 expect,程序将编译,但你会收到一个警告:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
warning: unused `Result` that must be used
  --> src/main.rs:10:5
   |
10 |     io::stdin().read_line(&mut guess);
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
   |
10 |     let _ = io::stdin().read_line(&mut guess);
   |     +++++++

warning: `guessing_game` (bin "guessing_game") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.59s

Rust 警告你尚未使用从 read_line 返回的 Result 值,表明程序尚未处理可能的错误。

抑制警告的正确方法是实际编写错误处理代码,但在我们的情况下,我们只想在发生问题时使程序崩溃,所以我们可以使用 expect。你将在第 9 章中学习如何从错误中恢复。

使用 println! 占位符打印值

除了右大括号外,到目前为止的代码中只有一行需要讨论:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {}", guess);
}

这行代码打印现在包含用户输入的字符串。{} 大括号集是一个占位符:将 {} 想象为小螃蟹钳子,它们将一个值固定在适当的位置。当打印变量的值时,变量名可以放在大括号内。当打印表达式求值的结果时,将空的大括号放在格式字符串中,然后在格式字符串后面加上一个逗号分隔的表达式列表,以按顺序打印到每个空的大括号占位符中。在一个 println! 调用中打印变量和表达式结果如下所示:

#![allow(unused)]
fn main() {
let x = 5;
let y = 10;

println!("x = {x} and y + 2 = {}", y + 2);
}

这段代码将打印 x = 5 and y + 2 = 12

测试第一部分

让我们测试猜数字游戏的第一部分。使用 cargo run 运行它:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.44s
     Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6

此时,游戏的第一部分已经完成:我们从键盘获取输入并打印它。

生成一个秘密数字

接下来,我们需要生成一个用户将尝试猜测的秘密数字。秘密数字每次都应该不同,这样游戏才能多次玩得有趣。我们将使用 1 到 100 之间的随机数,这样游戏不会太难。Rust 的标准库中还没有包含随机数功能。然而,Rust 团队确实提供了一个 rand crate,它具有该功能。

使用 Crate 获取更多功能

记住,crate 是 Rust 源代码文件的集合。我们一直在构建的项目是一个 二进制 crate,它是一个可执行文件。rand crate 是一个 库 crate,它包含旨在用于其他程序的代码,不能单独执行。

Cargo 对外部 crate 的协调是 Cargo 真正闪耀的地方。在我们编写使用 rand 的代码之前,我们需要修改 Cargo.toml 文件以将 rand crate 添加为依赖项。现在打开该文件,并在 Cargo 为你创建的 [dependencies] 部分标题下添加以下行。确保按照我们在这里的方式指定 rand,并带有此版本号,否则本教程中的代码示例可能无法工作:

文件名: Cargo.toml

[dependencies]
rand = "0.8.5"

Cargo.toml 文件中,标题后面的所有内容都是该部分的一部分,直到另一个部分开始。在 [dependencies] 中,你告诉 Cargo 你的项目依赖哪些外部 crate 以及你需要这些 crate 的哪些版本。在这种情况下,我们使用语义版本说明符 0.8.5 指定 rand crate。Cargo 理解语义版本控制(有时称为 SemVer),这是编写版本号的标准。说明符 0.8.5 实际上是 ^0.8.5 的简写,这意味着任何至少为 0.8.5 但低于 0.9.0 的版本。

Cargo 认为这些版本具有与 0.8.5 版本兼容的公共 API,此规范确保你将获得最新的补丁版本,该版本仍将与本章中的代码一起编译。任何 0.9.0 或更高版本都不能保证具有与以下示例使用的 API 相同的 API。

现在,在不更改任何代码的情况下,让我们构建项目,如代码清单 2-2 所示。

$ cargo build
  Updating crates.io index
   Locking 15 packages to latest Rust 1.85.0 compatible versions
    Adding rand v0.8.5 (available: v0.9.0)
 Compiling proc-macro2 v1.0.93
 Compiling unicode-ident v1.0.17
 Compiling libc v0.2.170
 Compiling cfg-if v1.0.0
 Compiling byteorder v1.5.0
 Compiling getrandom v0.2.15
 Compiling rand_core v0.6.4
 Compiling quote v1.0.38
 Compiling syn v2.0.98
 Compiling zerocopy-derive v0.7.35
 Compiling zerocopy v0.7.35
 Compiling ppv-lite86 v0.2.20
 Compiling rand_chacha v0.3.1
 Compiling rand v0.8.5
 Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
  Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.48s

你可能会看到不同的版本号(但它们都将与代码兼容,这要归功于 SemVer!)和不同的行(取决于操作系统),并且行的顺序可能不同。

当我们包含外部依赖项时,Cargo 会从 注册表 中获取该依赖项所需的所有内容的最新版本,注册表是 Crates.io 数据的副本。Crates.io 是 Rust 生态系统中的人们发布他们的开源 Rust 项目供他人使用的地方。

更新注册表后,Cargo 检查 [dependencies] 部分并下载列出的任何尚未下载的 crate。在这种情况下,尽管我们只列出了 rand 作为依赖项,但 Cargo 还抓取了 rand 依赖的其他 crate 以使其工作。下载 crate 后,Rust 编译它们,然后使用可用的依赖项编译项目。

如果你立即再次运行 cargo build 而不做任何更改,除了 Finished 行外,你将不会得到任何输出。Cargo 知道它已经下载并编译了依赖项,并且你没有在 Cargo.toml 文件中更改它们的任何内容。Cargo 还知道你还没有更改代码的任何内容,所以它也不会重新编译代码。由于无事可做,它只是退出。

如果你打开 src/main.rs 文件,做一个微小的更改,然后保存并再次构建,你将只看到两行输出:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s

这些行显示 Cargo 仅更新了构建,因为你对 src/main.rs 文件进行了微小的更改。你的依赖项没有更改,所以 Cargo 知道它可以重用已经下载和编译的内容。

使用 Cargo.lock 文件确保可重现的构建

Cargo 有一个机制,确保你或其他人每次构建代码时都可以重建相同的工件:Cargo 将只使用你指定的依赖项版本,直到你另有指示。例如,假设下周 rand crate 的 0.8.6 版本发布,该版本包含一个重要的错误修复,但它也包含一个会破坏你代码的回归。为了处理这个问题,Rust 在你第一次运行 cargo build 时创建了 Cargo.lock 文件,所以我们现在在 guessing_game 目录中有这个文件。

当你第一次构建项目时,Cargo 会找出所有符合标准的依赖项版本,然后将它们写入 Cargo.lock 文件。当你将来构建项目时,Cargo 会看到 Cargo.lock 文件存在,并将使用其中指定的版本,而不是再次进行所有版本确定的工作。这让你可以自动获得可重现的构建。换句话说,由于 Cargo.lock 文件的存在,你的项目将保持在 0.8.5 版本,直到你明确升级。由于 Cargo.lock 文件对于可重现的构建很重要,它通常与项目中的其余代码一起检入源代码控制。

更新 Crate 以获取新版本

当你确实想要更新 crate 时,Cargo 提供了 update 命令,它将忽略 Cargo.lock 文件并找出 Cargo.toml 中符合你规范的所有最新版本。Cargo 然后将这些版本写入 Cargo.lock 文件。在这种情况下,Cargo 只会寻找大于 0.8.5 且小于 0.9.0 的版本。如果 rand crate 发布了两个新版本 0.8.6 和 0.9.0,如果你运行 cargo update,你将看到以下内容:

$ cargo update
    Updating crates.io index
     Locking 1 package to latest Rust 1.85.0 compatible version
    Updating rand v0.8.5 -> v0.8.6 (available: v0.9.0)

Cargo 忽略了 0.9.0 版本。此时,你还会注意到 Cargo.lock 文件中的变化,指出你现在使用的 rand crate 版本是 0.8.6。要使用 rand 0.9.0 版本或 0.9.x 系列的任何版本,你必须将 Cargo.toml 文件更新为如下所示:

[dependencies]
rand = "0.9.0"

下次你运行 cargo build 时,Cargo 将更新可用的 crate 注册表,并根据你指定的新版本重新评估你的 rand 要求。

关于 Cargo其生态系统 还有很多要说的,我们将在第 14 章讨论,但现在,这就是你需要知道的全部内容。Cargo 使得重用库变得非常容易,因此 Rustaceans 能够编写由多个包组装而成的小型项目。

生成随机数

让我们开始使用 rand 生成一个要猜测的数字。下一步是更新 src/main.rs,如代码清单 2-3 所示。

use std::io;
use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

首先我们添加了 use rand::Rng; 这一行。Rng trait 定义了随机数生成器实现的方法,这个 trait 必须在作用域内才能使用这些方法。第 10 章将详细介绍 trait。

接下来,我们在中间添加了两行。在第一行中,我们调用 rand::thread_rng 函数,它为我们提供了我们将要使用的特定随机数生成器:一个与当前执行线程本地相关并由操作系统播种的生成器。然后我们在随机数生成器上调用 gen_range 方法。这个方法由我们通过 use rand::Rng; 语句引入作用域的 Rng trait 定义。gen_range 方法接受一个范围表达式作为参数,并生成该范围内的随机数。我们在这里使用的范围表达式形式为 start..=end,并且在上下界都是包含的,所以我们需要指定 1..=100 来请求一个 1 到 100 之间的数字。

注意:你不会仅仅知道要使用哪些 trait 以及从 crate 中调用哪些方法和函数,所以每个 crate 都有使用说明的文档。Cargo 的另一个巧妙功能是运行 cargo doc --open 命令将在本地构建所有依赖项提供的文档,并在浏览器中打开它。如果你对 rand crate 中的其他功能感兴趣,例如,运行 cargo doc --open 并点击左侧边栏中的 rand

第二行新代码打印了秘密数字。这在开发程序时很有用,以便能够测试它,但我们将在最终版本中删除它。如果程序一开始就打印答案,那就不太像游戏了!

尝试运行程序几次:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 7
Please input your guess.
4
You guessed: 4

$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 83
Please input your guess.
5
You guessed: 5

你应该得到不同的随机数,并且它们都应该是 1 到 100 之间的数字。干得好!

比较猜测与秘密数字

现在我们有了用户输入和一个随机数,我们可以比较它们。这一步如代码清单 2-4 所示。请注意,这段代码还不能编译,我们将解释原因。

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    // --snip--
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

首先我们添加了另一个 use 语句,从标准库中引入了一个名为 std::cmp::Ordering 的类型。Ordering 类型是另一个枚举,具有 LessGreaterEqual 变体。这些是比较两个值时可能出现的三种结果。

然后我们在底部添加了五行新代码,使用 Ordering 类型。cmp 方法比较两个值,可以在任何可以比较的东西上调用。它接受一个你想要比较的东西的引用:在这里它比较 guesssecret_number。然后它返回我们通过 use 语句引入作用域的 Ordering 枚举的一个变体。我们使用 match 表达式根据 cmp 调用返回的 Ordering 变体决定接下来要做什么。

match 表达式由 分支 组成。一个分支由一个 模式 和如果 match 给定的值符合该分支的模式时应运行的代码组成。Rust 获取 match 给定的值,并依次查看每个分支的模式。模式和 match 结构是 Rust 的强大功能:它们让你表达代码可能遇到的各种情况,并确保你处理所有这些情况。这些功能将在第 6 章和第 19 章中详细介绍。

让我们通过我们在这里使用的 match 表达式来走一个例子。假设用户猜了 50,而这次随机生成的秘密数字是 38。

当代码比较 50 和 38 时,cmp 方法将返回 Ordering::Greater,因为 50 大于 38。match 表达式获取 Ordering::Greater 值并开始检查每个分支的模式。它查看第一个分支的模式 Ordering::Less,发现值 Ordering::Greater 不匹配 Ordering::Less,所以它忽略该分支中的代码并移动到下一个分支。下一个分支的模式是 Ordering::Greater,它确实匹配 Ordering::Greater!该分支中的关联代码将执行并打印 Too big! 到屏幕。match 表达式在第一次成功匹配后结束,所以在这种情况下它不会查看最后一个分支。

然而,代码清单 2-4 中的代码还不能编译。让我们试试:

$ cargo build
   Compiling libc v0.2.86
   Compiling getrandom v0.2.2
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.10
   Compiling rand_core v0.6.2
   Compiling rand_chacha v0.3.0
   Compiling rand v0.8.5
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
   --> src/main.rs:22:21
    |
22  |     match guess.cmp(&secret_number) {
    |                 --- ^^^^^^^^^^^^^^ expected `&String`, found `&{integer}`
    |                 |
    |                 arguments to this method are incorrect
    |
    = note: expected reference `&String`
               found reference `&{integer}`
note: method defined here
   --> file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/cmp.rs:964:8
    |
964 |     fn cmp(&self, other: &Self) -> Ordering;
    |        ^^^

For more information about this error, try `rustc --explain E0308`.
error: could not compile `guessing_game` (bin "guessing_game") due to 1 previous error

错误的核心是 类型不匹配。Rust 有一个强大的静态类型系统。然而,它也有类型推断。当我们写 let mut guess = String::new() 时,Rust 能够推断出 guess 应该是一个 String,并没有让我们写出类型。另一方面,secret_number 是一个数字类型。Rust 的几种数字类型可以具有 1 到 100 之间的值:i32,一个 32 位数字;u32,一个无符号的 32 位数字;i64,一个 64 位数字;以及其他。除非另有说明,Rust 默认为 i32,这是 secret_number 的类型,除非你在其他地方添加类型信息,导致 Rust 推断出不同的数字类型。错误的原因是 Rust 无法比较字符串和数字类型。

最终,我们希望将程序读取为输入的 String 转换为数字类型,以便我们可以将其与秘密数字进行数值比较。我们通过将这一行添加到 main 函数体中来做到这一点:

文件名: src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    // --snip--

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse().expect("Please type a number!");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

这行代码是:

let guess: u32 = guess.trim().parse().expect("Please type a number!");

我们创建了一个名为 guess 的变量。但是等等,程序不是已经有一个名为 guess 的变量吗?确实有,但 Rust 允许我们用一个新的值来遮蔽前一个 guess 的值。遮蔽 让我们可以重用 guess 变量名,而不是强迫我们创建两个唯一的变量,例如 guess_strguess。我们将在第 3 章中更详细地讨论这一点,但现在,知道这个功能通常在你想要将一个值从一种类型转换为另一种类型时使用。

我们将这个新变量绑定到表达式 guess.trim().parse()。表达式中的 guess 指的是原始的 guess 变量,它包含作为字符串的输入。String 实例上的 trim 方法将消除开头和结尾的任何空白,这是我们在将字符串转换为 u32 之前必须做的,因为 u32 只能包含数字数据。用户必须按 enter 来满足 read_line 并输入他们的猜测,这会在字符串中添加一个换行符。例如,如果用户输入 5 并按 enterguess 看起来像这样:5\n\n 代表“换行”。(在 Windows 上,按 enter 会导致回车和换行,\r\n。)trim 方法消除 \n\r\n,只留下 5

字符串上的 parse 方法 将字符串转换为另一种类型。在这里,我们使用它将字符串转换为数字。我们需要通过使用 let guess: u32 告诉 Rust 我们想要的精确数字类型。guess 后面的冒号(:)告诉 Rust 我们将注释变量的类型。Rust 有一些内置的数字类型;这里看到的 u32 是一个无符号的 32 位整数。它是一个小正数的良好默认选择。你将在第 3 章中了解其他数字类型。

此外,此示例程序中的 u32 注释以及与 secret_number 的比较意味着 Rust 将推断 secret_number 也应该是一个 u32。所以现在比较将在两个相同类型的值之间进行!

parse 方法只能处理可以逻辑上转换为数字的字符,因此很容易导致错误。例如,如果字符串包含 A👍%,则无法将其转换为数字。因为它可能会失败,parse 方法返回一个 Result 类型,就像 read_line 方法一样(之前在“使用 Result 处理潜在错误”中讨论过)。我们将以相同的方式处理这个 Result,再次使用 expect 方法。如果 parse 返回一个 Err Result 变体,因为它无法从字符串创建数字,expect 调用将使游戏崩溃并打印我们给它的消息。如果 parse 可以成功地将字符串转换为数字,它将返回 ResultOk 变体,expect 将返回 Ok 值中我们想要的数字。

现在让我们运行程序:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.26s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
  76
You guessed: 76
Too big!

很好!尽管在猜测之前添加了空格,程序仍然识别出用户猜了 76。运行程序几次以验证不同输入的不同行为:正确猜出数字,猜一个太大的数字,猜一个太小的数字。

我们现在已经完成了游戏的大部分工作,但用户只能猜一次。让我们通过添加一个循环来改变这一点!

使用循环允许多次猜测

loop 关键字创建了一个无限循环。我们将添加一个循环,给用户更多机会猜测数字:

文件名: src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    // --snip--

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        // --snip--


        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse().expect("Please type a number!");

        println!("You guessed: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => println!("You win!"),
        }
    }
}

如你所见,我们将从猜测输入提示开始的所有内容移入了一个循环。确保将循环内的每一行再缩进四个空格,然后再次运行程序。程序现在将永远要求另一个猜测,这实际上引入了一个新问题。用户似乎无法退出!

用户总是可以使用键盘快捷键 ctrl-c 中断程序。但还有另一种方法可以逃脱这个贪婪的怪物,正如在“比较猜测与秘密数字”中提到的 parse 讨论中提到的:如果用户输入一个非数字答案,程序将崩溃。我们可以利用这一点来允许用户退出,如下所示:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.23s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 59
Please input your guess.
45
You guessed: 45
Too small!
Please input your guess.
60
You guessed: 60
Too big!
Please input your guess.
59
You guessed: 59
You win!
Please input your guess.
quit

thread 'main' panicked at src/main.rs:28:47:
Please type a number!: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

输入 quit 将退出游戏,但你会注意到,输入任何其他非数字输入也会退出。这至少是不理想的;我们希望游戏在猜对数字时也停止。

在正确猜测后退出

让我们通过添加一个 break 语句来编程游戏在用户获胜时退出:

文件名: src/main.rs

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse().expect("Please type a number!");

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

You win! 之后添加 break 行,使程序在用户正确猜出秘密数字时退出循环。退出循环也意味着退出程序,因为循环是 main 的最后一部分。

处理无效输入

为了进一步完善游戏的行为,而不是在用户输入非数字时使程序崩溃,让我们让游戏忽略非数字,以便用户可以继续猜测。我们可以通过更改将 guessString 转换为 u32 的行来实现这一点,如代码清单 2-5 所示。

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        // --snip--

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

我们从 expect 调用切换到 match 表达式,以从在错误时崩溃转变为处理错误。记住 parse 返回一个 Result 类型,而 Result 是一个枚举,包含 OkErr 两个变体。我们在这里使用 match 表达式,就像我们在 cmp 方法的 Ordering 结果中所做的那样。

如果 parse 能够成功地将字符串转换为数字,它将返回一个包含结果数字的 Ok 值。那个 Ok 值将与第一个 match 臂的模式匹配,并且 match 表达式将直接返回 parse 产生的并放在 Ok 值中的 num 值。这个数字将正好出现在我们正在创建的新 guess 变量中我们希望的位置。

如果 parse 不能 将字符串转换为数字,它将返回一个包含有关错误更多信息的 Err 值。Err 值不匹配第一个 match 臂中的 Ok(num) 模式,但它确实匹配第二个臂中的 Err(_) 模式。下划线 _ 是一个通配符值;在这个例子中,我们说我们想要匹配所有 Err 值,不管它们内部包含什么信息。因此,程序将执行第二个臂的代码 continue,它告诉程序进入 loop 的下一次迭代并请求另一个猜测。因此,实际上,程序忽略了 parse 可能遇到的所有错误!

现在程序中的所有内容都应该按预期工作。让我们试试看:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 61
Please input your guess.
10
You guessed: 10
Too small!
Please input your guess.
99
You guessed: 99
Too big!
Please input your guess.
foo
Please input your guess.
61
You guessed: 61
You win!

太棒了!只需进行一个微小的最终调整,我们就能完成猜数字游戏。回想一下,程序仍然在打印秘密数字。这对测试很有用,但会破坏游戏的乐趣。让我们删除输出秘密数字的 println!。清单 2-6 显示了最终代码。

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

至此,你已经成功构建了猜数字游戏。恭喜你!

总结

这个项目是一个实践性的方式,向你介绍了许多新的 Rust 概念:letmatch、函数、外部 crate 的使用等等。在接下来的几章中,你将更详细地学习这些概念。第 3 章涵盖了大多数编程语言都有的概念,如变量、数据类型和函数,并展示了如何在 Rust 中使用它们。第 4 章探讨了所有权,这是 Rust 区别于其他语言的一个特性。第 5 章讨论了结构体和方法语法,第 6 章解释了枚举的工作原理。

常见编程概念

本章将介绍几乎所有编程语言中都会出现的概念,以及它们在 Rust 中是如何工作的。许多编程语言在核心部分都有很多共同点。本章中介绍的概念都不是 Rust 独有的,但我们会在 Rust 的上下文中讨论它们,并解释使用这些概念的惯例。

具体来说,你将学习变量、基本类型、函数、注释和控制流。这些基础知识将出现在每个 Rust 程序中,尽早学习它们将为你打下坚实的基础。

关键字

Rust 语言有一组 关键字,这些关键字仅供语言本身使用,就像其他语言一样。请记住,你不能将这些关键字用作变量或函数的名称。大多数关键字都有特殊的含义,你将在 Rust 程序中使用它们来完成各种任务;少数关键字目前没有与之关联的功能,但已被保留,以便将来可能添加到 Rust 中的功能使用。你可以在附录 A中找到关键字的列表。

变量与可变性

正如在“使用变量存储值”部分提到的,默认情况下,变量是不可变的。这是 Rust 提供的众多提示之一,旨在帮助你以利用 Rust 提供的安全性和简单并发性的方式编写代码。然而,你仍然可以选择使变量可变。让我们探讨一下 Rust 如何以及为何鼓励你优先选择不可变性,以及为什么有时你可能希望选择可变性。

当一个变量是不可变的时,一旦一个值被绑定到一个名称上,你就不能改变这个值。为了说明这一点,请在 projects 目录下使用 cargo new variables 生成一个名为 variables 的新项目。

然后,在你的新 variables 目录中,打开 src/main.rs 并将其代码替换为以下代码,这段代码目前还无法编译:

文件名: src/main.rs

fn main() {
    let x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is: {x}");
}

保存并使用 cargo run 运行程序。你应该会收到一个关于不可变性错误的错误消息,如下所示:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("The value of x is: {x}");
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
2 |     let mut x = 5;
  |         +++

For more information about this error, try `rustc --explain E0384`.
error: could not compile `variables` (bin "variables") due to 1 previous error

这个例子展示了编译器如何帮助你发现程序中的错误。编译器错误可能会让人沮丧,但它们实际上只是意味着你的程序还没有安全地执行你想要的操作;它们并不意味着你不是一个好的程序员!即使是经验丰富的 Rust 开发者仍然会遇到编译器错误。

你收到的错误消息是 不能对不可变变量 `x` 进行二次赋值,因为你尝试为不可变的 x 变量赋第二个值。

当我们尝试更改一个被指定为不可变的值时,获得编译时错误非常重要,因为这种情况可能会导致错误。如果我们代码的一部分假设一个值永远不会改变,而另一部分代码改变了这个值,那么代码的第一部分可能不会按预期执行。这种错误的根源在事后可能很难追踪,尤其是当第二段代码只是偶尔改变这个值时。Rust 编译器保证当你声明一个值不会改变时,它就真的不会改变,因此你不必自己跟踪它。这样,你的代码更容易推理。

但可变性有时非常有用,可以使代码更方便编写。尽管变量默认是不可变的,但你可以通过在变量名前添加 mut 来使它们可变,就像你在第 2 章中所做的那样。添加 mut 还可以向未来的代码读者传达意图,表明代码的其他部分将改变这个变量的值。

例如,让我们将 src/main.rs 改为以下内容:

文件名: src/main.rs

fn main() {
    let mut x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is: {x}");
}

现在当我们运行程序时,会得到以下输出:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/variables`
The value of x is: 5
The value of x is: 6

当使用 mut 时,我们允许将绑定到 x 的值从 5 更改为 6。最终,决定是否使用可变性取决于你,并取决于你认为在特定情况下哪种方式最清晰。

常量

与不可变变量类似,常量 是绑定到名称且不允许更改的值,但常量与变量之间有一些区别。

首先,你不能对常量使用 mut。常量不仅仅是默认不可变的——它们总是不可变的。你使用 const 关键字而不是 let 关键字来声明常量,并且必须注解值的类型。我们将在下一节“数据类型”中介绍类型和类型注解,所以现在不必担心细节。只需知道你必须始终注解类型。

常量可以在任何作用域中声明,包括全局作用域,这使得它们对于代码的多个部分需要了解的值非常有用。

最后一个区别是,常量只能设置为常量表达式,而不能设置为只能在运行时计算的结果。

以下是一个常量声明的示例:

#![allow(unused)]
fn main() {
const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;
}

常量的名称是 THREE_HOURS_IN_SECONDS,其值设置为 60(一分钟的秒数)乘以 60(一小时的分钟数)乘以 3(我们想要计算的小时数)的结果。Rust 的常量命名约定是使用全大写字母,单词之间用下划线分隔。编译器能够在编译时评估一组有限的操作,这使我们能够选择以一种更容易理解和验证的方式写出这个值,而不是将这个常量设置为值 10,800。有关在声明常量时可以使用的操作的更多信息,请参阅 Rust 参考中的常量评估部分

常量在程序的整个运行期间都是有效的,在其声明的作用域内。这个特性使得常量对于应用程序域中的值非常有用,这些值可能是程序的多个部分需要了解的,例如游戏中任何玩家允许获得的最大点数,或光速。

将程序中使用的硬编码值命名为常量有助于向未来的代码维护者传达该值的含义。此外,如果将来需要更新硬编码值,只需在代码中的一个地方进行更改即可。

变量遮蔽

正如你在第 2 章的猜谜游戏教程中看到的,你可以声明一个与先前变量同名的新变量。Rust 开发者说第一个变量被第二个变量遮蔽了,这意味着当你使用变量名时,编译器将看到第二个变量。实际上,第二个变量遮蔽了第一个变量,直到它自己被遮蔽或作用域结束为止。我们可以通过使用相同的变量名并重复使用 let 关键字来遮蔽一个变量,如下所示:

文件名: src/main.rs

fn main() {
    let x = 5;

    let x = x + 1;

    {
        let x = x * 2;
        println!("The value of x in the inner scope is: {x}");
    }

    println!("The value of x is: {x}");
}

这个程序首先将 x 绑定到值 5。然后它通过重复 let x = 创建一个新变量 x,取原始值并加 1,因此 x 的值变为 6。然后,在用大括号创建的内部作用域中,第三个 let 语句也遮蔽了 x 并创建了一个新变量,将前一个值乘以 2,使 x 的值为 12。当该作用域结束时,内部遮蔽结束,x 恢复为 6。当我们运行这个程序时,它将输出以下内容:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/variables`
The value of x in the inner scope is: 12
The value of x is: 6

遮蔽与将变量标记为 mut 不同,因为如果我们不小心尝试在不使用 let 关键字的情况下重新赋值给这个变量,我们会得到一个编译时错误。通过使用 let,我们可以对一个值进行一些转换,但在这些转换完成后,变量仍然是不可变的。

mut 和遮蔽之间的另一个区别是,因为当我们再次使用 let 关键字时,我们实际上是创建了一个新变量,所以我们可以改变值的类型,但重用相同的名称。例如,假设我们的程序要求用户通过输入空格字符来显示他们希望在某些文本之间有多少空格,然后我们希望将该输入存储为一个数字:

fn main() {
    let spaces = "   ";
    let spaces = spaces.len();
}

第一个 spaces 变量是字符串类型,第二个 spaces 变量是数字类型。遮蔽使我们不必想出不同的名称,例如 spaces_strspaces_num;相反,我们可以重用更简单的 spaces 名称。然而,如果我们尝试为此使用 mut,如下所示,我们将得到一个编译时错误:

fn main() {
    let mut spaces = "   ";
    spaces = spaces.len();
}

错误信息说我们不允许改变变量的类型:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0308]: mismatched types
 --> src/main.rs:3:14
  |
2 |     let mut spaces = "   ";
  |                      ----- expected due to this value
3 |     spaces = spaces.len();
  |              ^^^^^^^^^^^^ expected `&str`, found `usize`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `variables` (bin "variables") due to 1 previous error

现在我们已经探讨了变量如何工作,让我们看看它们可以具有的更多数据类型。

数据类型

Rust 中的每个值都有一个特定的数据类型,它告诉 Rust 正在指定哪种数据,以便它知道如何处理这些数据。我们将查看两种数据类型的子集:标量类型和复合类型。

请记住,Rust 是一种静态类型语言,这意味着它必须在编译时知道所有变量的类型。编译器通常可以根据值以及我们如何使用它来推断我们想要使用的类型。在某些情况下,可能会有多种类型,例如在第 2 章的“将猜测与秘密数字进行比较”部分中,我们使用 parseString 转换为数字类型时,我们必须添加类型注解,如下所示:

#![allow(unused)]
fn main() {
let guess: u32 = "42".parse().expect("不是数字!");
}

如果我们不添加前面代码中所示的 : u32 类型注解,Rust 将显示以下错误,这意味着编译器需要更多信息来了解我们想要使用的类型:

$ cargo build
   Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0284]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^        ----- type must be known at this point
  |
  = note: cannot satisfy `<_ as FromStr>::Err == _`
help: consider giving `guess` an explicit type
  |
2 |     let guess: /* Type */ = "42".parse().expect("Not a number!");
  |              ++++++++++++

For more information about this error, try `rustc --explain E0284`.
error: could not compile `no_type_annotations` (bin "no_type_annotations") due to 1 previous error

你会看到其他数据类型的不同类型注解。

标量类型

标量类型表示单个值。Rust 有四种主要的标量类型:整数、浮点数、布尔值和字符。你可能从其他编程语言中已经认识这些类型。让我们深入了解它们在 Rust 中的工作原理。

整数类型

整数是没有小数部分的数字。我们在第 2 章中使用了一个整数类型,即 u32 类型。这个类型声明表明它关联的值应该是一个无符号整数(有符号整数类型以 i 开头,而不是 u),占用 32 位空间。表 3-1 展示了 Rust 中的内置整数类型。我们可以使用这些变体中的任何一种来声明整数值的类型。

表 3-1: Rust 中的整数类型

长度有符号无符号
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
128-biti128u128
archisizeusize

每个变体都可以是有符号或无符号的,并且有一个明确的大小。有符号无符号指的是数字是否可以表示负数——换句话说,数字是否需要带有符号(有符号)或者它是否永远为正数,因此可以不带符号表示(无符号)。这就像在纸上写数字:当符号重要时,数字会带有加号或减号;然而,当可以安全地假设数字为正数时,它就不带符号。有符号数字使用二进制补码表示法存储。

每个有符号变体可以存储从 −(2n − 1) 到 2n − 1 − 1 的数字,其中 n 是该变体使用的位数。因此,i8 可以存储从 −(27) 到 27 − 1 的数字,即 −128 到 127。无符号变体可以存储从 0 到 2n − 1 的数字,因此 u8 可以存储从 0 到 28 − 1 的数字,即 0 到 255。

此外,isizeusize 类型取决于程序运行的计算机架构,这在表中表示为“arch”:如果你在 64 位架构上,则为 64 位;如果你在 32 位架构上,则为 32 位。

你可以使用表 3-2 中显示的任何形式编写整数字面量。请注意,可以表示多种数字类型的数字字面量允许使用类型后缀,例如 57u8,以指定类型。数字字面量还可以使用 _ 作为视觉分隔符,使数字更易于阅读,例如 1_000,它的值与指定 1000 相同。

表 3-2: Rust 中的整数字面量

数字字面量示例
十进制98_222
十六进制0xff
八进制0o77
二进制0b1111_0000
字节 (u8 专用)b'A'

那么如何知道使用哪种整数类型呢?如果你不确定,Rust 的默认值通常是一个不错的起点:整数类型默认为 i32。使用 isizeusize 的主要情况是在索引某种集合时。

整数溢出

假设你有一个类型为 u8 的变量,它可以存储 0 到 255 之间的值。如果你尝试将变量更改为超出该范围的值,例如 256,将会发生整数溢出,这可能导致两种行为之一。当你在调试模式下编译时,Rust 会包含整数溢出检查,如果发生这种行为,程序会在运行时panic。Rust 使用术语 panicking 来描述程序因错误而退出的情况;我们将在第 9 章的“使用 panic! 处理不可恢复的错误”部分详细讨论 panic。

当你在发布模式下使用 --release 标志编译时,Rust 不会包含导致 panic 的整数溢出检查。相反,如果发生溢出,Rust 会执行二进制补码回绕。简而言之,大于该类型可以存储的最大值的值会“回绕”到该类型可以存储的最小值。在 u8 的情况下,值 256 变为 0,值 257 变为 1,依此类推。程序不会 panic,但变量的值可能不是你期望的值。依赖整数溢出的回绕行为被认为是一个错误。

为了显式处理溢出的可能性,你可以使用标准库为原始数字类型提供的这些方法系列:

  • 使用 wrapping_* 方法在所有模式下进行回绕,例如 wrapping_add
  • 如果发生溢出,使用 checked_* 方法返回 None 值。
  • 使用 overflowing_* 方法返回值和布尔值,指示是否发生了溢出。
  • 使用 saturating_* 方法在值的最小值或最大值处饱和。

浮点类型

Rust 还有两种用于浮点数的原始类型,即带有小数点的数字。Rust 的浮点类型是 f32f64,分别为 32 位和 64 位大小。默认类型是 f64,因为在现代 CPU 上,它的速度与 f32 大致相同,但精度更高。所有浮点类型都是有符号的。

以下是一个展示浮点数实际使用的示例:

文件名: src/main.rs

fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

浮点数根据 IEEE-754 标准表示。

数值运算

Rust 支持所有数字类型的基本数学运算:加法、减法、乘法、除法和取余。整数除法向零舍入到最接近的整数。以下代码展示了如何在 let 语句中使用每种数值运算:

文件名: src/main.rs

fn main() {
    // addition
    let sum = 5 + 10;

    // subtraction
    let difference = 95.5 - 4.3;

    // multiplication
    let product = 4 * 30;

    // division
    let quotient = 56.7 / 32.2;
    let truncated = -5 / 3; // Results in -1

    // remainder
    let remainder = 43 % 5;
}

这些语句中的每个表达式都使用一个数学运算符并计算为单个值,然后绑定到一个变量。附录 B 包含了 Rust 提供的所有运算符的列表。

布尔类型

与大多数其他编程语言一样,Rust 中的布尔类型有两个可能的值:truefalse。布尔值的大小为一个字节。Rust 中的布尔类型使用 bool 指定。例如:

文件名: src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // with explicit type annotation
}

使用布尔值的主要方式是通过条件语句,例如 if 表达式。我们将在“控制流”部分介绍 if 表达式在 Rust 中的工作原理。

字符类型

Rust 的 char 类型是该语言最基本的字母类型。以下是一些声明 char 值的示例:

文件名: src/main.rs

fn main() {
    let c = 'z';
    let z: char = 'ℤ'; // with explicit type annotation
    let heart_eyed_cat = '😻';
}

请注意,我们使用单引号指定 char 字面量,而字符串字面量使用双引号。Rust 的 char 类型大小为四个字节,表示一个 Unicode 标量值,这意味着它可以表示的内容远不止 ASCII。重音字母;中文、日文和韩文字符;表情符号;以及零宽度空格都是 Rust 中的有效 char 值。Unicode 标量值的范围从 U+0000U+D7FFU+E000U+10FFFF 之间。然而,“字符”并不是 Unicode 中的一个真正概念,因此你对“字符”的人类直觉可能与 Rust 中的 char 不完全一致。我们将在第 8 章的“使用字符串存储 UTF-8 编码的文本”部分详细讨论这个话题。

复合类型

复合类型可以将多个值组合成一个类型。Rust 有两种原始复合类型:元组和数组。

元组类型

元组是一种将多个不同类型的值组合成一个复合类型的通用方式。元组具有固定长度:一旦声明,它们的大小就不能增长或缩小。

我们通过在括号内写入逗号分隔的值列表来创建元组。元组中的每个位置都有一个类型,元组中不同值的类型不必相同。我们在这个示例中添加了可选的类型注解:

文件名: src/main.rs

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

变量 tup 绑定到整个元组,因为元组被视为单个复合元素。要从元组中获取单个值,我们可以使用模式匹配来解构元组值,如下所示:

文件名: src/main.rs

fn main() {
    let tup = (500, 6.4, 1);

    let (x, y, z) = tup;

    println!("The value of y is: {y}");
}

这个程序首先创建一个元组并将其绑定到变量 tup。然后它使用 let 模式将 tup 分解为三个单独的变量 xyz。这被称为解构,因为它将单个元组分解为三个部分。最后,程序打印出 y 的值,即 6.4

我们还可以通过使用句点(.)后跟我们想要访问的值的索引来直接访问元组元素。例如:

文件名: src/main.rs

fn main() {
    let x: (i32, f64, u8) = (500, 6.4, 1);

    let five_hundred = x.0;

    let six_point_four = x.1;

    let one = x.2;
}

这个程序创建了元组 x,然后使用各自的索引访问元组的每个元素。与大多数编程语言一样,元组中的第一个索引是 0。

没有任何值的元组有一个特殊的名称,称为单元。这个值及其对应的类型都写为 (),表示一个空值或空返回类型。如果表达式不返回任何其他值,则隐式返回单元值。

数组类型

另一种拥有多个值集合的方式是使用数组。与元组不同,数组中的每个元素必须具有相同的类型。与某些其他语言中的数组不同,Rust 中的数组具有固定长度。

我们将数组中的值写为方括号内的逗号分隔列表:

文件名: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];
}

当你希望数据分配在栈上(与我们迄今为止看到的其他类型相同)而不是堆上时,数组非常有用(我们将在第 4 章中详细讨论栈和堆),或者当你希望确保始终具有固定数量的元素时。数组不如向量类型灵活。向量是标准库提供的一种类似的集合类型,它允许增长或缩小大小。如果你不确定是使用数组还是向量,很可能你应该使用向量。第 8 章将更详细地讨论向量。

然而,当你知道元素数量不需要改变时,数组更有用。例如,如果你在程序中使用月份的名称,你可能会使用数组而不是向量,因为你知道它总是包含 12 个元素:

#![allow(unused)]
fn main() {
let months = ["January", "February", "March", "April", "May", "June", "July",
              "August", "September", "October", "November", "December"];
}

你使用方括号编写数组的类型,其中包含每个元素的类型、分号以及数组中的元素数量,如下所示:

#![allow(unused)]
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}

在这里,i32 是每个元素的类型。分号后的数字 5 表示数组包含五个元素。

你还可以通过指定初始值、分号以及方括号中的数组长度来初始化数组,使每个元素包含相同的值,如下所示:

#![allow(unused)]
fn main() {
let a = [3; 5];
}

名为 a 的数组将包含 5 个元素,这些元素最初都将设置为值 3。这与编写 let a = [3, 3, 3, 3, 3]; 相同,但更简洁。

访问数组元素

数组是一个已知的、固定大小的单个内存块,可以分配在栈上。你可以使用索引访问数组的元素,如下所示:

文件名: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];

    let first = a[0];
    let second = a[1];
}

在这个示例中,名为 first 的变量将获得值 1,因为这是数组中索引 [0] 处的值。名为 second 的变量将从数组中索引 [1] 处获得值 2

无效的数组元素访问

让我们看看如果你尝试访问超出数组末尾的元素会发生什么。假设你运行这段代码,类似于第 2 章中的猜谜游戏,以从用户那里获取数组索引:

文件名: src/main.rs

use std::io;

fn main() {
    let a = [1, 2, 3, 4, 5];

    println!("Please enter an array index.");

    let mut index = String::new();

    io::stdin()
        .read_line(&mut index)
        .expect("Failed to read line");

    let index: usize = index
        .trim()
        .parse()
        .expect("Index entered was not a number");

    let element = a[index];

    println!("The value of the element at index {index} is: {element}");
}

这段代码成功编译。如果你使用 cargo run 运行这段代码并输入 01234,程序将打印出数组中该索引处的相应值。如果你输入超出数组末尾的数字,例如 10,你将看到如下输出:

thread 'main' panicked at src/main.rs:19:19:
index out of bounds: the len is 5 but the index is 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

程序在使用无效值进行索引操作时导致了运行时错误。程序退出了错误消息,并没有执行最后的 println! 语句。当你尝试使用索引访问元素时,Rust 会检查你指定的索引是否小于数组长度。如果索引大于或等于长度,Rust 会 panic。这种检查必须在运行时进行,尤其是在这种情况下,因为编译器不可能知道用户在运行代码时会输入什么值。

这是 Rust 内存安全原则的一个例子。在许多低级语言中,这种检查不会进行,当你提供不正确的索引时,可能会访问无效的内存。Rust 通过立即退出而不是允许内存访问并继续来保护你免受这种错误的影响。第 9 章将详细讨论 Rust 的错误处理以及如何编写既不会 panic 也不会允许无效内存访问的可读、安全的代码。

函数

函数在 Rust 代码中非常常见。你已经见过这门语言中最重要的函数之一:main 函数,它是许多程序的入口点。你也见过 fn 关键字,它允许你声明新的函数。

Rust 代码使用 蛇形命名法(snake case) 作为函数和变量名称的常规风格,其中所有字母都是小写,并使用下划线分隔单词。以下是一个包含函数定义示例的程序:

文件名: src/main.rs

fn main() {
    println!("Hello, world!");

    another_function();
}

fn another_function() {
    println!("Another function.");
}

我们在 Rust 中通过输入 fn 后跟函数名和一组括号来定义函数。大括号告诉编译器函数体的开始和结束位置。

我们可以通过输入函数名后跟一组括号来调用任何已定义的函数。因为 another_function 在程序中定义了,所以可以从 main 函数中调用它。注意,我们在源代码中 main 函数 之后 定义了 another_function;我们也可以在之前定义它。Rust 不关心你在何处定义函数,只要它们在调用者可见的范围内定义即可。

让我们启动一个名为 functions 的新二进制项目来进一步探索函数。将 another_function 示例放入 src/main.rs 并运行它。你应该会看到以下输出:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.28s
     Running `target/debug/functions`
Hello, world!
Another function.

这些行按照它们在 main 函数中出现的顺序执行。首先打印 “Hello, world!” 消息,然后调用 another_function 并打印其消息。

参数

我们可以定义带有 参数 的函数,这些参数是函数签名的一部分的特殊变量。当函数有参数时,你可以为这些参数提供具体的值。从技术上讲,具体的值被称为 实参,但在日常对话中,人们倾向于将 参数实参 这两个词互换使用,无论是函数定义中的变量还是调用函数时传入的具体值。

在这个版本的 another_function 中,我们添加了一个参数:

文件名: src/main.rs

fn main() {
    another_function(5);
}

fn another_function(x: i32) {
    println!("The value of x is: {x}");
}

尝试运行这个程序;你应该会得到以下输出:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.21s
     Running `target/debug/functions`
The value of x is: 5

another_function 的声明有一个名为 x 的参数。x 的类型被指定为 i32。当我们向 another_function 传递 5 时,println! 宏将 5 放在格式字符串中包含 x 的大括号对的位置。

在函数签名中,你 必须 声明每个参数的类型。这是 Rust 设计中的一个深思熟虑的决定:在函数定义中要求类型注释意味着编译器几乎不需要你在代码的其他地方使用它们来推断你想要的类型。如果编译器知道函数期望的类型,它还能提供更有帮助的错误消息。

当定义多个参数时,用逗号分隔参数声明,如下所示:

文件名: src/main.rs

fn main() {
    print_labeled_measurement(5, 'h');
}

fn print_labeled_measurement(value: i32, unit_label: char) {
    println!("The measurement is: {value}{unit_label}");
}

这个例子创建了一个名为 print_labeled_measurement 的函数,它有两个参数。第一个参数名为 value,类型为 i32。第二个参数名为 unit_label,类型为 char。然后函数打印包含 valueunit_label 的文本。

让我们尝试运行这段代码。将当前在 functions 项目的 src/main.rs 文件中的程序替换为前面的示例,并使用 cargo run 运行它:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/functions`
The measurement is: 5h

因为我们用 5 作为 value 的值,'h' 作为 unit_label 的值调用了函数,所以程序输出包含这些值。

语句和表达式

函数体由一系列语句组成,可以选择以表达式结尾。到目前为止,我们介绍的函数还没有包含结束表达式,但你已经看到过作为语句一部分的表达式。因为 Rust 是一种基于表达式的语言,这是一个需要理解的重要区别。其他语言没有相同的区别,所以让我们看看什么是语句和表达式,以及它们的区别如何影响函数体。

  • 语句 是执行某些操作但不返回值的指令。
  • 表达式 会计算出一个结果值。让我们看一些例子。

实际上我们已经使用过语句和表达式。使用 let 关键字创建变量并为其赋值是一个语句。在 Listing 3-1 中,let y = 6; 是一个语句。

fn main() {
    let y = 6;
}

函数定义也是语句;前面的整个例子本身就是一个语句。(正如我们将在下面看到的,调用 函数不是一个语句。)

语句不返回值。因此,你不能将一个 let 语句赋值给另一个变量,如下面的代码尝试做的;你会得到一个错误:

文件名: src/main.rs

fn main() {
    let x = (let y = 6);
}

当你运行这个程序时,你会得到的错误如下:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error: expected expression, found `let` statement
 --> src/main.rs:2:14
  |
2 |     let x = (let y = 6);
  |              ^^^
  |
  = note: only supported directly in conditions of `if` and `while` expressions

warning: unnecessary parentheses around assigned value
 --> src/main.rs:2:13
  |
2 |     let x = (let y = 6);
  |             ^         ^
  |
  = note: `#[warn(unused_parens)]` on by default
help: remove these parentheses
  |
2 -     let x = (let y = 6);
2 +     let x = let y = 6;
  |

warning: `functions` (bin "functions") generated 1 warning
error: could not compile `functions` (bin "functions") due to 1 previous error; 1 warning emitted

let y = 6 语句不返回值,所以 x 没有可以绑定的值。这与 C 和 Ruby 等其他语言中的情况不同,在这些语言中,赋值会返回赋值的值。在这些语言中,你可以写 x = y = 6,并使 xy 都具有值 6;但在 Rust 中不是这样。

表达式会计算出一个值,并构成你在 Rust 中编写的大部分代码。考虑一个数学运算,比如 5 + 6,这是一个计算为值 11 的表达式。表达式可以是语句的一部分:在 Listing 3-1 中,语句 let y = 6; 中的 6 是一个计算为值 6 的表达式。调用函数是一个表达式。调用宏是一个表达式。用大括号创建的新作用域块是一个表达式,例如:

文件名: src/main.rs

fn main() {
    let y = {
        let x = 3;
        x + 1
    };

    println!("The value of y is: {y}");
}

这个表达式:

{
    let x = 3;
    x + 1
}

是一个块,在这种情况下,它计算为 4。这个值作为 let 语句的一部分绑定到 y。注意,x + 1 行的末尾没有分号,这与目前为止你看到的大多数行不同。表达式不包括结束分号。如果你在表达式的末尾添加一个分号,你就把它变成了一个语句,然后它不会返回值。在你接下来探索函数返回值和表达式时,请记住这一点。

带有返回值的函数

函数可以向调用它们的代码返回值。我们不为返回值命名,但必须在箭头 (->) 后声明它们的类型。在 Rust 中,函数的返回值与函数体块中最后一个表达式的值同义。你可以通过使用 return 关键字并指定一个值来提前从函数返回,但大多数函数隐式返回最后一个表达式。以下是一个返回值的函数示例:

文件名: src/main.rs

fn five() -> i32 {
    5
}

fn main() {
    let x = five();

    println!("The value of x is: {x}");
}

five 函数中没有函数调用、宏,甚至没有 let 语句——只有数字 5 本身。这在 Rust 中是一个完全有效的函数。注意,函数的返回类型也被指定为 -> i32。尝试运行这段代码;输出应该如下所示:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/functions`
The value of x is: 5

five 函数中的 5 是函数的返回值,这就是为什么返回类型是 i32。让我们更详细地研究一下。有两个重要的部分:首先,行 let x = five(); 显示我们正在使用函数的返回值来初始化一个变量。因为函数 five 返回 5,所以这行代码等同于以下代码:

#![allow(unused)]
fn main() {
let x = 5;
}

其次,five 函数没有参数并定义了返回值的类型,但函数体是一个孤零零的 5,没有分号,因为它是一个我们想要返回其值的表达式。

让我们看另一个例子:

文件名: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {x}");
}

fn plus_one(x: i32) -> i32 {
    x + 1
}

运行这段代码将打印 The value of x is: 6。但如果我们在包含 x + 1 的行末尾加上一个分号,将其从表达式改为语句,我们会得到一个错误:

文件名: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {x}");
}

fn plus_one(x: i32) -> i32 {
    x + 1;
}

编译这段代码会产生一个错误,如下所示:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error[E0308]: mismatched types
 --> src/main.rs:7:24
  |
7 | fn plus_one(x: i32) -> i32 {
  |    --------            ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
8 |     x + 1;
  |          - help: remove this semicolon to return this value

For more information about this error, try `rustc --explain E0308`.
error: could not compile `functions` (bin "functions") due to 1 previous error

主要的错误信息 mismatched types 揭示了这段代码的核心问题。函数 plus_one 的定义表明它将返回一个 i32,但语句不会计算出一个值,这由 ()(单元类型)表示。因此,没有返回任何值,这与函数定义相矛盾,导致错误。在这个输出中,Rust 提供了一个可能帮助纠正这个问题的消息:它建议删除分号,这将修复错误。

注释

所有程序员都努力使他们的代码易于理解,但有时需要额外的解释。在这些情况下,程序员会在源代码中留下 注释,编译器会忽略这些注释,但阅读源代码的人可能会发现它们很有用。

这是一个简单的注释:

#![allow(unused)]
fn main() {
// hello, world
}

在 Rust 中,惯用的注释风格是以两个斜杠开始注释,注释会一直持续到行尾。对于跨越多行的注释,你需要在每一行都包含 //,像这样:

#![allow(unused)]
fn main() {
// 我们在这里做一些复杂的事情,复杂到需要
// 多行注释来解释!哇!希望这个注释能
// 解释清楚发生了什么。
}

注释也可以放在包含代码的行尾:

文件名: src/main.rs

fn main() {
    let lucky_number = 7; // I'm feeling lucky today
}

但你更常见的是看到它们以这种格式使用,注释位于它所注解的代码上方的一行:

文件名: src/main.rs

fn main() {
    // I'm feeling lucky today
    let lucky_number = 7;
}

Rust 还有另一种注释,称为文档注释,我们将在第 14 章的 “发布 Crate 到 Crates.io” 部分讨论。

控制流

根据条件是否为 true 来决定是否运行某些代码,以及在条件为 true 时重复运行某些代码,是大多数编程语言中的基本构建块。Rust 代码中最常见的控制执行流的构造是 if 表达式和循环。

if 表达式

if 表达式允许你根据条件来分支代码。你提供一个条件,然后声明:“如果这个条件满足,运行这段代码。如果条件不满足,则不运行这段代码。”

在你的 projects 目录下创建一个名为 branches 的新项目来探索 if 表达式。在 src/main.rs 文件中,输入以下代码:

文件名: src/main.rs

fn main() {
    let number = 3;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

所有的 if 表达式都以关键字 if 开头,后跟一个条件。在这个例子中,条件检查变量 number 的值是否小于 5。如果条件为 true,我们在大括号内放置要执行的代码块。与 if 表达式中的条件相关联的代码块有时被称为 分支,就像我们在第 2 章的 “Comparing the Guess to the Secret Number” 部分讨论的 match 表达式中的分支一样。

可选地,我们还可以包含一个 else 表达式,我们在这里选择了这样做,以便在条件评估为 false 时给程序提供一个替代的代码块来执行。如果你不提供 else 表达式并且条件为 false,程序将跳过 if 块并继续执行下一段代码。

尝试运行这段代码;你应该会看到以下输出:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
condition was true

让我们尝试将 number 的值更改为使条件为 false 的值,看看会发生什么:

fn main() {
    let number = 7;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

再次运行程序,并查看输出:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
condition was false

值得注意的是,这段代码中的条件 必须bool 类型。如果条件不是 bool 类型,我们会得到一个错误。例如,尝试运行以下代码:

文件名: src/main.rs

fn main() {
    let number = 3;

    if number {
        println!("number was three");
    }
}

这次 if 条件评估为 3,Rust 会抛出一个错误:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: mismatched types
 --> src/main.rs:4:8
  |
4 |     if number {
  |        ^^^^^^ expected `bool`, found integer

For more information about this error, try `rustc --explain E0308`.
error: could not compile `branches` (bin "branches") due to 1 previous error

错误表明 Rust 期望一个 bool 但得到了一个整数。与 Ruby 和 JavaScript 等语言不同,Rust 不会自动尝试将非布尔类型转换为布尔类型。你必须明确地始终为 if 提供一个布尔条件。例如,如果我们希望 if 代码块仅在数字不等于 0 时运行,我们可以将 if 表达式更改为以下内容:

文件名: src/main.rs

fn main() {
    let number = 3;

    if number != 0 {
        println!("number was something other than zero");
    }
}

运行这段代码将打印 number was something other than zero

使用 else if 处理多个条件

你可以通过组合 ifelseelse if 表达式中使用多个条件。例如:

文件名: src/main.rs

fn main() {
    let number = 6;

    if number % 4 == 0 {
        println!("number is divisible by 4");
    } else if number % 3 == 0 {
        println!("number is divisible by 3");
    } else if number % 2 == 0 {
        println!("number is divisible by 2");
    } else {
        println!("number is not divisible by 4, 3, or 2");
    }
}

这个程序有四种可能的路径。运行后,你应该会看到以下输出:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
number is divisible by 3

当这个程序执行时,它会依次检查每个 if 表达式,并执行第一个条件评估为 true 的代码块。注意,即使 6 可以被 2 整除,我们也不会看到输出 number is divisible by 2,也不会看到 else 块中的 number is not divisible by 4, 3, or 2 文本。这是因为 Rust 只执行第一个 true 条件的代码块,一旦找到一个,它甚至不会检查其余的。

使用过多的 else if 表达式会使代码变得混乱,所以如果你有多个条件,你可能需要重构你的代码。第 6 章将介绍一个强大的 Rust 分支构造 match,用于处理这些情况。

let 语句中使用 if

因为 if 是一个表达式,我们可以在 let 语句的右侧使用它,将结果赋值给一个变量,如 Listing 3-2 所示。

fn main() {
    let condition = true;
    let number = if condition { 5 } else { 6 };

    println!("The value of number is: {number}");
}

number 变量将根据 if 表达式的结果绑定到一个值。运行这段代码,看看会发生什么:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/branches`
The value of number is: 5

记住,代码块会评估为其中的最后一个表达式,数字本身也是表达式。在这种情况下,整个 if 表达式的值取决于哪个代码块被执行。这意味着 if 的每个分支的潜在结果值必须是相同的类型;在 Listing 3-2 中,if 分支和 else 分支的结果都是 i32 整数。如果类型不匹配,如下例所示,我们会得到一个错误:

文件名: src/main.rs

fn main() {
    let condition = true;

    let number = if condition { 5 } else { "six" };

    println!("The value of number is: {number}");
}

当我们尝试编译这段代码时,我们会得到一个错误。ifelse 分支的值类型不兼容,Rust 准确地指出了程序中问题的位置:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: `if` and `else` have incompatible types
 --> src/main.rs:4:44
  |
4 |     let number = if condition { 5 } else { "six" };
  |                                 -          ^^^^^ expected integer, found `&str`
  |                                 |
  |                                 expected because of this

For more information about this error, try `rustc --explain E0308`.
error: could not compile `branches` (bin "branches") due to 1 previous error

if 块中的表达式评估为一个整数,而 else 块中的表达式评估为一个字符串。这不会起作用,因为变量必须有一个单一的类型,Rust 需要在编译时明确知道 number 变量的类型。知道 number 的类型可以让编译器在我们使用 number 的任何地方验证类型是否有效。如果 number 的类型仅在运行时确定,Rust 将无法做到这一点;编译器将更加复杂,并且如果必须为任何变量跟踪多个假设类型,它将减少对代码的保证。

使用循环进行重复

多次执行一个代码块通常很有用。为此,Rust 提供了几种 循环,它们将循环体内的代码运行到末尾,然后立即从头开始。为了试验循环,让我们创建一个名为 loops 的新项目。

Rust 有三种循环:loopwhilefor。让我们逐一尝试。

使用 loop 重复代码

loop 关键字告诉 Rust 一遍又一遍地执行一个代码块,直到你明确告诉它停止。

例如,将你的 loops 目录中的 src/main.rs 文件更改为如下内容:

文件名: src/main.rs

fn main() {
    loop {
        println!("again!");
    }
}

当我们运行这个程序时,我们会看到 again! 一遍又一遍地连续打印,直到我们手动停止程序。大多数终端支持键盘快捷键 ctrl-c 来中断一个陷入无限循环的程序。试试看:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/loops`
again!
again!
again!
again!
^Cagain!

符号 ^C 表示你按下了 ctrl-c。你可能在 ^C 之后看到 again! 打印出来,也可能看不到,这取决于代码在接收到中断信号时处于循环的哪个位置。

幸运的是,Rust 还提供了一种通过代码跳出循环的方法。你可以在循环内放置 break 关键字,告诉程序何时停止执行循环。回想一下,我们在第 2 章的 “Quitting After a Correct Guess” 部分中在猜谜游戏中这样做了,当用户猜中正确数字时退出程序。

我们还在猜谜游戏中使用了 continue,它在循环中告诉程序跳过本次循环的剩余代码并进入下一次迭代。

从循环中返回值

loop 的一个用途是重试一个你知道可能会失败的操作,例如检查线程是否完成了它的工作。你可能还需要将该操作的结果传递出循环,以便在代码的其他部分使用。为此,你可以在用于停止循环的 break 表达式后面添加你想要返回的值;该值将从循环中返回,以便你可以使用它,如下所示:

fn main() {
    let mut counter = 0;

    let result = loop {
        counter += 1;

        if counter == 10 {
            break counter * 2;
        }
    };

    println!("The result is {result}");
}

在循环之前,我们声明了一个名为 counter 的变量并将其初始化为 0。然后我们声明了一个名为 result 的变量来保存从循环返回的值。在循环的每次迭代中,我们将 1 加到 counter 变量上,然后检查 counter 是否等于 10。当它等于 10 时,我们使用 break 关键字并返回值 counter * 2。在循环之后,我们使用分号结束赋值给 result 的语句。最后,我们打印 result 的值,在这个例子中是 20

你也可以从循环内部 return。虽然 break 只退出当前循环,但 return 总是退出当前函数。

使用循环标签消除多个循环之间的歧义

如果你有嵌套循环,breakcontinue 适用于此时最内层的循环。你可以选择在循环上指定一个 循环标签,然后你可以与 breakcontinue 一起使用,以指定这些关键字适用于带标签的循环而不是最内层的循环。循环标签必须以单引号开头。以下是一个带有两个嵌套循环的示例:

fn main() {
    let mut count = 0;
    'counting_up: loop {
        println!("count = {count}");
        let mut remaining = 10;

        loop {
            println!("remaining = {remaining}");
            if remaining == 9 {
                break;
            }
            if count == 2 {
                break 'counting_up;
            }
            remaining -= 1;
        }

        count += 1;
    }
    println!("End count = {count}");
}

外部循环带有标签 'counting_up,它将从 0 计数到 2。没有标签的内部循环将从 10 倒数到 9。第一个没有指定标签的 break 将只退出内部循环。break 'counting_up; 语句将退出外部循环。这段代码将打印:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running `target/debug/loops`
count = 0
remaining = 10
remaining = 9
count = 1
remaining = 10
remaining = 9
count = 2
remaining = 10
End count = 2

使用 while 进行条件循环

程序通常需要在循环中评估条件。当条件为 true 时,循环运行。当条件不再为 true 时,程序调用 break,停止循环。你可以使用 loopifelsebreak 的组合来实现这种行为;如果你愿意,现在可以在程序中尝试。然而,这种模式非常常见,以至于 Rust 有一个内置的语言构造,称为 while 循环。在 Listing 3-3 中,我们使用 while 循环程序三次,每次倒数,然后在循环后打印一条消息并退出。

fn main() {
    let mut number = 3;

    while number != 0 {
        println!("{number}!");

        number -= 1;
    }

    println!("LIFTOFF!!!");
}

这种构造消除了如果你使用 loopifelsebreak 时所需的许多嵌套,并且更清晰。当条件评估为 true 时,代码运行;否则,它退出循环。

使用 for 遍历集合

你也可以使用 while 构造来遍历集合的元素,例如数组。例如,Listing 3-4 中的循环打印数组 a 中的每个元素。

fn main() {
    let a = [10, 20, 30, 40, 50];
    let mut index = 0;

    while index < 5 {
        println!("the value is: {}", a[index]);

        index += 1;
    }
}

在这里,代码从索引 0 开始,遍历数组中的元素,直到达到数组的最后一个索引(即当 index < 5 不再为 true 时)。运行这段代码将打印数组中的每个元素:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.32s
     Running `target/debug/loops`
the value is: 10
the value is: 20
the value is: 30
the value is: 40
the value is: 50

所有五个数组值都出现在终端中,正如预期的那样。即使 index 在某个时候会达到 5,循环在尝试从数组中获取第六个值之前停止执行。

然而,这种方法容易出错;如果索引值或测试条件不正确,我们可能会导致程序 panic。例如,如果你将 a 数组的定义更改为有四个元素,但忘记将条件更新为 while index < 4,代码将会 panic。它也很慢,因为编译器在每次循环迭代时都会添加运行时代码来执行索引是否在数组范围内的条件检查。

作为一种更简洁的替代方案,你可以使用 for 循环并为集合中的每个项目执行一些代码。for 循环看起来像 Listing 3-5 中的代码。

fn main() {
    let a = [10, 20, 30, 40, 50];

    for element in a {
        println!("the value is: {element}");
    }
}

当我们运行这段代码时,我们将看到与 Listing 3-4 相同的输出。更重要的是,我们现在提高了代码的安全性,并消除了可能由于超出数组末尾或未达到足够远而遗漏某些项目而导致的错误。

使用 for 循环,如果你更改了数组中值的数量,你不需要记住更改任何其他代码,就像在 Listing 3-4 中使用的方法那样。

for 循环的安全性和简洁性使其成为 Rust 中最常用的循环构造。即使在你想要运行某些代码一定次数的情况下,如在 Listing 3-3 中使用 while 循环的倒计时示例中,大多数 Rust 开发者也会使用 for 循环。这样做的方法是使用标准库提供的 Range,它生成从一个数字开始并在另一个数字之前结束的所有数字序列。

以下是使用 for 循环和我们尚未讨论的另一个方法 rev 来反转范围的倒计时示例:

文件名: src/main.rs

fn main() {
    for number in (1..4).rev() {
        println!("{number}!");
    }
    println!("LIFTOFF!!!");
}

这段代码看起来更好,不是吗?

总结

你做到了!这是一个相当大的章节:你学习了变量、标量和复合数据类型、函数、注释、if 表达式和循环!为了练习本章讨论的概念,尝试构建程序来完成以下任务:

  • 在 Fahrenheit 和 Celsius 之间转换温度。
  • 生成第 n 个斐波那契数。
  • 打印圣诞颂歌 “The Twelve Days of Christmas” 的歌词,利用歌曲中的重复部分。

当你准备好继续前进时,我们将讨论 Rust 中一个在其他编程语言中 不常见 的概念:所有权。

理解所有权

所有权是 Rust 最独特的特性,它对语言的其他部分有着深远的影响。它使得 Rust 能够在不需要垃圾回收器的情况下保证内存安全,因此理解所有权的工作原理非常重要。在本章中,我们将讨论所有权以及几个相关的特性:借用、切片,以及 Rust 如何在内存中布局数据。

什么是所有权?

所有权 是一组规则,用于管理 Rust 程序如何管理内存。所有程序在运行时都需要管理它们使用计算机内存的方式。有些语言具有垃圾回收机制,在程序运行时定期查找不再使用的内存;而在其他语言中,程序员必须显式地分配和释放内存。Rust 采用了第三种方法:内存通过所有权系统进行管理,编译器会检查一组规则。如果违反了任何规则,程序将无法编译。所有权的特性不会在程序运行时减慢程序的速度。

由于所有权对许多程序员来说是一个新概念,确实需要一些时间来适应。好消息是,随着你对 Rust 和所有权系统规则的熟悉程度增加,你会发现自然而然地编写出安全且高效的代码。坚持下去!

当你理解了所有权,你就为理解 Rust 的独特特性打下了坚实的基础。在本章中,你将通过一些示例来学习所有权,这些示例专注于一个非常常见的数据结构:字符串。

栈和堆

许多编程语言并不要求你经常考虑栈和堆。但在像 Rust 这样的系统编程语言中,值是在栈上还是在堆上会影响语言的行为以及你为何需要做出某些决定。所有权的部分内容将在本章后面与栈和堆相关的内容中描述,因此这里先做一个简要的解释作为准备。

栈和堆都是代码在运行时可以使用的内存部分,但它们的结构不同。栈以获取值的顺序存储值,并以相反的顺序移除值。这被称为 后进先出。想象一下一叠盘子:当你添加更多盘子时,你把它们放在堆的顶部,当你需要一个盘子时,你从顶部取一个。从中间或底部添加或移除盘子效果不会那么好!添加数据被称为 压入栈,移除数据被称为 弹出栈。所有存储在栈上的数据必须具有已知的、固定的大小。在编译时大小未知或大小可能变化的数据必须存储在堆上。

堆的组织性较差:当你将数据放入堆时,你需要请求一定量的空间。内存分配器在堆中找到一个足够大的空位,将其标记为正在使用,并返回一个 指针,即该位置的地址。这个过程被称为 在堆上分配,有时简称为 分配(将值压入栈不被视为分配)。因为指向堆的指针是已知的、固定大小的,你可以将指针存储在栈上,但当你想要实际数据时,你必须跟随指针。想象一下在餐厅就座。当你进入时,你说明你们的人数,服务员会找到一个适合所有人的空桌子并带你过去。如果你们中有人迟到,他们可以询问你们坐在哪里来找到你。

将数据压入栈比在堆上分配要快,因为分配器永远不需要寻找存储新数据的地方;那个位置总是在栈的顶部。相比之下,在堆上分配空间需要更多的工作,因为分配器必须首先找到一个足够大的空间来容纳数据,然后进行簿记以准备下一次分配。

访问堆上的数据比访问栈上的数据要慢,因为你必须跟随指针才能到达那里。当代处理器在内存中跳转较少时速度更快。继续类比,想象一下餐厅的服务员从许多桌子接单。在移动到下一张桌子之前,先接完一张桌子的所有订单是最有效率的。从桌子 A 接一个订单,然后从桌子 B 接一个订单,然后再从桌子 A 接一个订单,再从桌子 B 接一个订单,这个过程会慢得多。同样,处理器在处理彼此靠近的数据(如在栈上)时比处理较远的数据(如在堆上)时能更好地完成工作。

当你的代码调用一个函数时,传递给函数的值(可能包括指向堆上数据的指针)和函数的局部变量会被压入栈。当函数结束时,这些值会从栈中弹出。

跟踪代码的哪些部分在使用堆上的哪些数据,最小化堆上重复数据的数量,以及清理堆上未使用的数据以避免空间耗尽,这些都是所有权要解决的问题。一旦你理解了所有权,你就不需要经常考虑栈和堆,但知道所有权的主要目的是管理堆数据可以帮助解释它为何以这种方式工作。

所有权规则

首先,让我们看一下所有权规则。在我们通过示例说明这些规则时,请牢记这些规则:

  • Rust 中的每个值都有一个 所有者
  • 一次只能有一个所有者。
  • 当所有者超出作用域时,值将被丢弃。

变量作用域

既然我们已经了解了基本的 Rust 语法,我们不会在示例中包含所有的 fn main() { 代码,所以如果你在跟随学习,请确保手动将以下示例放入 main 函数中。因此,我们的示例将更加简洁,让我们专注于实际细节而不是样板代码。

作为所有权的第一个示例,我们将查看一些变量的 作用域。作用域是程序中项目有效的范围。以以下变量为例:

#![allow(unused)]
fn main() {
let s = "hello";
}

变量 s 引用了一个字符串字面量,其中字符串的值被硬编码到我们的程序文本中。变量从声明点到当前 作用域 结束都是有效的。Listing 4-1 展示了一个程序,其中注释标注了变量 s 的有效范围。

fn main() {
    {                      // s is not valid here, it’s not yet declared
        let s = "hello";   // s is valid from this point forward

        // do stuff with s
    }                      // this scope is now over, and s is no longer valid
}

换句话说,这里有两个重要的时间点:

  • s 进入 作用域时,它是有效的。
  • 它保持有效直到它 超出 作用域。

此时,作用域和变量有效性的关系与其他编程语言中的类似。现在我们将在此基础上引入 String 类型。

String 类型

为了说明所有权的规则,我们需要一个比我们在第 3 章的 “数据类型” 部分中介绍的类型更复杂的数据类型。之前介绍的类型具有已知的大小,可以存储在栈上,并在其作用域结束时从栈中弹出,如果需要在不同作用域中使用相同的值,可以快速且轻松地复制以创建一个新的独立实例。但我们希望查看存储在堆上的数据,并探索 Rust 如何知道何时清理这些数据,而 String 类型就是一个很好的例子。

我们将专注于 String 与所有权相关的部分。这些方面也适用于其他复杂的数据类型,无论它们是由标准库提供的还是你自己创建的。我们将在 第 8 章 中更深入地讨论 String

我们已经见过字符串字面量,其中字符串值被硬编码到我们的程序中。字符串字面量很方便,但它们并不适合我们可能想要使用文本的每种情况。一个原因是它们是不可变的。另一个原因是我们编写代码时并非每个字符串值都是已知的:例如,如果我们想获取用户输入并存储它怎么办?对于这些情况,Rust 有第二种字符串类型,String。这种类型管理在堆上分配的数据,因此能够存储编译时未知大小的文本。你可以使用 from 函数从字符串字面量创建一个 String,如下所示:

#![allow(unused)]
fn main() {
let s = String::from("hello");
}

双冒号 :: 运算符允许我们将这个特定的 from 函数命名空间放在 String 类型下,而不是使用类似 string_from 的名称。我们将在第 5 章的 “方法语法” 部分以及在第 7 章的 “模块树中引用项的路径” 中讨论模块命名空间时更详细地讨论这种语法。

这种字符串 可以 被修改:

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() appends a literal to a String

    println!("{s}"); // This will print `hello, world!`
}

那么,这里的区别是什么?为什么 String 可以被修改而字面量不能?区别在于这两种类型如何处理内存。

内存和分配

在字符串字面量的情况下,我们在编译时就知道内容,因此文本直接硬编码到最终的可执行文件中。这就是为什么字符串字面量快速且高效。但这些属性仅来自字符串字面量的不可变性。不幸的是,我们不能为每个在编译时大小未知且大小可能在程序运行时变化的文本片段在二进制文件中放入一块内存。

对于 String 类型,为了支持可变的、可增长的文本片段,我们需要在堆上分配一块编译时未知大小的内存来保存内容。这意味着:

  • 必须在运行时从内存分配器请求内存。
  • 当我们使用完 String 时,需要有一种方法将内存返回给分配器。

第一部分由我们完成:当我们调用 String::from 时,它的实现会请求所需的内存。这在编程语言中几乎是普遍的。

然而,第二部分是不同的。在具有 垃圾回收器 (GC) 的语言中,GC 会跟踪并清理不再使用的内存,我们不需要考虑它。在大多数没有 GC 的语言中,我们有责任识别何时不再使用内存并调用代码显式释放它,就像我们请求它时一样。正确地做到这一点在历史上一直是一个困难的编程问题。如果我们忘记了,我们会浪费内存。如果我们过早地释放,我们会有一个无效的变量。如果我们释放两次,这也是一个错误。我们需要将一次 allocate 与一次 free 精确配对。

Rust 采取了不同的路径:一旦拥有内存的变量超出作用域,内存就会自动返回。以下是使用 String 而不是字符串字面量的 Listing 4-1 的作用域示例版本:

fn main() {
    {
        let s = String::from("hello"); // s is valid from this point forward

        // do stuff with s
    }                                  // this scope is now over, and s is no
                                       // longer valid
}

有一个自然的点可以让我们将 String 所需的内存返回给分配器:当 s 超出作用域时。当变量超出作用域时,Rust 会为我们调用一个特殊的函数。这个函数被称为 drop,它是 String 的作者可以放置代码以返回内存的地方。Rust 在右大括号处自动调用 drop

注意:在 C++ 中,这种在项目生命周期结束时释放资源的模式有时被称为 资源获取即初始化 (RAII)。如果你使用过 RAII 模式,Rust 中的 drop 函数会让你感到熟悉。

这种模式对 Rust 代码的编写方式有深远的影响。现在看起来可能很简单,但在更复杂的情况下,当我们希望多个变量使用我们在堆上分配的数据时,代码的行为可能会出乎意料。让我们现在探索一些这些情况。

变量与数据的交互:移动

在 Rust 中,多个变量可以以不同的方式与相同的数据交互。让我们看一个使用整数的示例,如 Listing 4-2 所示。

fn main() {
    let x = 5;
    let y = x;
}

我们可能猜到了这段代码的作用:“将值 5 绑定到 x;然后将 x 中的值复制并绑定到 y。” 我们现在有两个变量,xy,它们都等于 5。这确实是正在发生的事情,因为整数是具有已知固定大小的简单值,这两个 5 值被压入栈。

现在让我们看看 String 版本:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

这看起来非常相似,所以我们可能会假设它的工作方式相同:即第二行会复制 s1 中的值并将其绑定到 s2。但这并不是完全发生的事情。

看一下图 4-1,了解 String 在底层发生了什么。String 由三部分组成,如左侧所示:指向保存字符串内容的内存的指针、长度和容量。这组数据存储在栈上。右侧是堆上保存内容的内存。

两个表格:第一个表格包含 s1 在栈上的表示,包括其长度 (5)、容量 (5) 和指向第二个表格中第一个值的指针。第二个表格包含堆上字符串数据的表示,按字节排列。

图 4-1:绑定到 s1String"hello" 在内存中的表示

长度是 String 内容当前使用的内存量(以字节为单位)。容量是 String 从分配器获得的总内存量(以字节为单位)。长度和容量之间的差异很重要,但在此上下文中并不重要,所以现在可以忽略容量。

当我们把 s1 赋值给 s2 时,String 数据被复制,这意味着我们复制了栈上的指针、长度和容量。我们没有复制指针所指向的堆上的数据。换句话说,内存中的数据表示如图 4-2 所示。

三个表格:表格 s1 和 s2 分别表示这些字符串在栈上的表示,并且都指向堆上的相同字符串数据。

图 4-2:变量 s2 在内存中的表示,它复制了 s1 的指针、长度和容量

表示 像图 4-3 那样,如果 Rust 也复制堆数据,内存会是什么样子。如果 Rust 这样做,操作 s2 = s1 在运行时性能方面可能会非常昂贵,如果堆上的数据很大。

四个表格:两个表格表示 s1 和 s2 的栈数据,每个都指向堆上自己的字符串数据副本。

图 4-3:如果 Rust 也复制堆数据,s2 = s1 可能做的另一种可能性

早些时候,我们说过当变量超出作用域时,Rust 会自动调用 drop 函数并清理该变量的堆内存。但图 4-2 显示两个数据指针指向同一个位置。这是一个问题:当 s2s1 超出作用域时,它们都会尝试释放相同的内存。这被称为 双重释放 错误,是我们之前提到的内存安全错误之一。释放内存两次可能导致内存损坏,从而可能引发安全漏洞。

为了确保内存安全,在 let s2 = s1; 行之后,Rust 认为 s1 不再有效。因此,当 s1 超出作用域时,Rust 不需要释放任何东西。看看当你尝试在 s2 创建后使用 s1 时会发生什么;它不会工作:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}

你会得到一个类似这样的错误,因为 Rust 阻止你使用无效的引用:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:15
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{s1}, world!");
  |               ^^^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

如果你在其他语言中听说过 浅拷贝深拷贝 这两个术语,复制指针、长度和容量而不复制数据的概念可能听起来像是在进行浅拷贝。但由于 Rust 也使第一个变量无效,因此它被称为 移动 而不是浅拷贝。在这个例子中,我们会说 s1移动 到了 s2 中。所以,实际发生的情况如图 4-4 所示。

三个表格:表格 s1 和 s2 分别表示这些字符串在栈上的表示,并且都指向堆上的相同字符串数据。表格 s1 被灰显,因为 s1 不再有效;只有 s2 可以用来访问堆数据。

图 4-4:s1 无效化后的内存表示

这解决了我们的问题!只有 s2 有效,当它超出作用域时,它将独自释放内存,我们就完成了。

此外,这还隐含了一个设计选择:Rust 永远不会自动创建数据的“深”拷贝。因此,任何 自动 复制都可以假定在运行时性能方面是廉价的。

作用域与赋值

对于作用域、所有权和通过 drop 函数释放内存之间的关系,反之亦然。当你将一个全新的值赋给现有变量时,Rust 会立即调用 drop 并释放原始值的内存。例如,考虑以下代码:

fn main() {
    let mut s = String::from("hello");
    s = String::from("ahoy");

    println!("{s}, world!");
}

我们最初声明了一个变量 s 并将其绑定到一个值为 "hello"String。然后我们立即创建一个值为 "ahoy" 的新 String 并将其赋给 s。此时,没有任何东西引用堆上的原始值。

一个表格 s 表示栈上的字符串值,指向堆上的第二块字符串数据 (ahoy),原始字符串数据 (hello) 被灰显,因为它无法再被访问。

图 4-5:初始值被完全替换后的内存表示

因此,原始字符串立即超出作用域。Rust 将对其运行 drop 函数,其内存将立即被释放。当我们打印末尾的值时,它将是 "ahoy, world!"

变量与数据的交互:克隆

如果我们 确实 想要深度复制 String 的堆数据,而不仅仅是栈数据,我们可以使用一个常见的方法叫做 clone。我们将在第 5 章讨论方法语法,但由于方法是许多编程语言中的常见特性,你可能已经见过它们。

以下是 clone 方法的示例:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {s1}, s2 = {s2}");
}

这工作得很好,并明确产生了图 4-3 中所示的行为,其中堆数据 确实 被复制了。

当你看到 clone 调用时,你知道正在执行一些任意代码,并且该代码可能很昂贵。这是一个视觉指示器,表明正在发生一些不同的事情。

仅栈数据:复制

我们还没有讨论的另一个问题是。这段使用整数的代码——部分内容如 Listing 4-2 所示——是有效的:

fn main() {
    let x = 5;
    let y = x;

    println!("x = {x}, y = {y}");
}

但这段代码似乎与我们刚刚学到的内容相矛盾:我们没有调用 clone,但 x 仍然有效,并且没有被移动到 y

原因是像整数这样在编译时已知大小的类型完全存储在栈上,因此复制实际值很快。这意味着我们没有理由在创建变量 y 后阻止 x 保持有效。换句话说,这里没有深度和浅度复制的区别,因此调用 clone 不会与通常的浅度复制有任何不同,我们可以省略它。

Rust 有一个特殊的注解叫做 Copy 特性,我们可以将其放在存储在栈上的类型上,就像整数一样(我们将在 第 10 章 中更详细地讨论特性)。如果一个类型实现了 Copy 特性,使用它的变量不会移动,而是被简单地复制,使它们在赋值给另一个变量后仍然有效。

如果类型或其任何部分实现了 Drop 特性,Rust 不会让我们用 Copy 注解该类型。如果类型需要在值超出作用域时发生一些特殊的事情,而我们为该类型添加了 Copy 注解,我们将得到一个编译时错误。要了解如何将 Copy 注解添加到你的类型以实现该特性,请参阅附录 C 中的 “可派生特性”

那么,哪些类型实现了 Copy 特性?你可以查看给定类型的文档以确定,但作为一般规则,任何简单的标量值组都可以实现 Copy,而任何需要分配或某种形式的资源的东西都不能实现 Copy。以下是一些实现 Copy 的类型:

  • 所有整数类型,如 u32
  • 布尔类型 bool,值为 truefalse
  • 所有浮点类型,如 f64
  • 字符类型 char
  • 元组,如果它们只包含也实现 Copy 的类型。例如,(i32, i32) 实现了 Copy,但 (i32, String) 没有。

所有权与函数

将值传递给函数的机制与将值赋给变量的机制类似。将变量传递给函数会移动或复制,就像赋值一样。Listing 4-3 有一个带有注释的示例,显示了变量进入和退出作用域的位置。

fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // because i32 implements the Copy trait,
                                    // x does NOT move into the function,
    println!("{}", x);              // so it's okay to use x afterward

} // Here, x goes out of scope, then s. But because s's value was moved, nothing
  // special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{some_string}");
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{some_integer}");
} // Here, some_integer goes out of scope. Nothing special happens.

如果我们尝试在调用 takes_ownership 后使用 s,Rust 会抛出一个编译时错误。这些静态检查保护我们免于犯错。尝试在 main 中添加使用 sx 的代码,看看你可以在哪里使用它们,以及所有权规则在哪里阻止你这样做。

返回值与作用域

返回值也可以转移所有权。Listing 4-4 展示了一个返回某些值的函数示例,其注释与 Listing 4-3 中的类似。

fn main() {
    let s1 = gives_ownership();        // gives_ownership moves its return
                                       // value into s1

    let s2 = String::from("hello");    // s2 comes into scope

    let s3 = takes_and_gives_back(s2); // s2 is moved into
                                       // takes_and_gives_back, which also
                                       // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {       // gives_ownership will move its
                                       // return value into the function
                                       // that calls it

    let some_string = String::from("yours"); // some_string comes into scope

    some_string                        // some_string is returned and
                                       // moves out to the calling
                                       // function
}

// This function takes a String and returns a String.
fn takes_and_gives_back(a_string: String) -> String {
    // a_string comes into
    // scope

    a_string  // a_string is returned and moves out to the calling function
}

变量的所有权每次遵循相同的模式:将值赋给另一个变量会移动它。当包含堆上数据的变量超出作用域时,除非数据的所有权已转移到另一个变量,否则该值将被 drop 清理。

虽然这有效,但每次函数都获取所有权然后返回所有权有点繁琐。如果我们想让函数使用一个值但不获取所有权怎么办?我们传递的任何东西也需要传递回来,如果我们想再次使用它,这很烦人,此外我们可能还想返回函数体产生的任何数据。

Rust 确实允许我们使用元组返回多个值,如 Listing 4-5 所示。

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{s2}' is {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}

但对于一个应该很常见的概念来说,这太繁琐了,工作量也很大。幸运的是,Rust 有一个特性可以在不转移所有权的情况下使用值,称为 引用

引用与借用

在 Listing 4-5 中的元组代码的问题在于,我们必须将 String 返回给调用函数,以便在调用 calculate_length 后仍然可以使用 String,因为 String 被移动到了 calculate_length 中。相反,我们可以提供一个对 String 值的引用。引用类似于指针,它是一个地址,我们可以通过它访问存储在该地址的数据;这些数据由其他变量拥有。与指针不同的是,引用在其生命周期内保证指向某个特定类型的有效值。

下面是如何定义和使用一个 calculate_length 函数,该函数以引用作为参数,而不是获取值的所有权:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

首先,注意变量声明和函数返回值中的所有元组代码都消失了。其次,注意我们传递 &s1calculate_length,并且在函数定义中,我们使用 &String 而不是 String。这些 & 符号表示引用,它们允许你引用某个值而不获取其所有权。图 4-6 描绘了这一概念。

三个表:s 的表只包含指向 s1 表的指针。s1 的表包含 s1 的栈数据,并指向堆上的字符串数据。

图 4-6: &String s 指向 String s1 的示意图

注意:使用 & 进行引用的相反操作是解引用,它通过解引用操作符 * 完成。我们将在第 8 章看到解引用操作符的一些用法,并在第 15 章详细讨论解引用。

让我们仔细看看这里的函数调用:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

&s1 语法让我们创建一个引用,它引用 s1 的值但不拥有它。因为引用不拥有它,所以当引用停止使用时,它指向的值不会被丢弃。

同样,函数的签名使用 & 来表明参数 s 的类型是一个引用。让我们添加一些解释性注释:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize { // s is a reference to a String
    s.len()
} // Here, s goes out of scope. But because s does not have ownership of what
  // it refers to, the value is not dropped.

变量 s 的有效范围与任何函数参数的范围相同,但当 s 停止使用时,引用指向的值不会被丢弃,因为 s 没有所有权。当函数使用引用作为参数而不是实际值时,我们不需要返回值来归还所有权,因为我们从未拥有过所有权。

我们将创建引用的行为称为借用。就像在现实生活中,如果一个人拥有某样东西,你可以从他们那里借用它。当你用完时,你必须归还它。你不拥有它。

那么,如果我们尝试修改我们借用的东西会发生什么?试试 Listing 4-6 中的代码。剧透警告:它不会工作!

fn main() {
    let s = String::from("hello");

    change(&s);
}

fn change(some_string: &String) {
    some_string.push_str(", world");
}

以下是错误信息:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
 --> src/main.rs:8:5
  |
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
  |
help: consider changing this to be a mutable reference
  |
7 | fn change(some_string: &mut String) {
  |                         +++

For more information about this error, try `rustc --explain E0596`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

正如变量默认是不可变的一样,引用也是不可变的。我们不允许修改我们引用的东西。

可变引用

我们可以通过一些小的调整来修复 Listing 4-6 中的代码,以允许我们修改借用的值,使用可变引用

fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

首先我们将 s 改为 mut。然后我们在调用 change 函数时使用 &mut s 创建一个可变引用,并更新函数签名以接受一个可变引用 some_string: &mut String。这使得 change 函数将改变它借用的值变得非常清晰。

可变引用有一个很大的限制:如果你有一个值的可变引用,你就不能有其他对该值的引用。尝试创建两个对 s 的可变引用的代码将失败:

fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;

    println!("{}, {}", r1, r2);
}

以下是错误信息:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:5:14
  |
4 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
5 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
6 |
7 |     println!("{}, {}", r1, r2);
  |                        -- first borrow later used here

For more information about this error, try `rustc --explain E0499`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

这个错误说明这段代码无效,因为我们不能同时多次借用 s 作为可变引用。第一次可变借用是在 r1 中,并且必须持续到它在 println! 中使用为止,但在创建该可变引用和它的使用之间,我们尝试在 r2 中创建另一个可变引用,它借用了与 r1 相同的数据。

防止同时存在多个对同一数据的可变引用的限制允许进行改变,但以非常受控的方式进行。这是新 Rust 程序员经常遇到的问题,因为大多数语言允许你随时进行改变。拥有这种限制的好处是,Rust 可以在编译时防止数据竞争。数据竞争类似于竞态条件,当以下三种行为发生时就会发生:

  • 两个或更多指针同时访问同一数据。
  • 至少有一个指针用于写入数据。
  • 没有用于同步数据访问的机制。

数据竞争会导致未定义行为,并且在运行时尝试追踪它们时可能难以诊断和修复;Rust 通过拒绝编译带有数据竞争的代码来防止这个问题!

一如既往,我们可以使用花括号创建一个新的作用域,允许多个可变引用,只是不能同时存在:

fn main() {
    let mut s = String::from("hello");

    {
        let r1 = &mut s;
    } // r1 goes out of scope here, so we can make a new reference with no problems.

    let r2 = &mut s;
}

Rust 对结合可变和不可变引用执行了类似的规则。这段代码会导致错误:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    let r3 = &mut s; // BIG PROBLEM

    println!("{}, {}, and {}", r1, r2, r3);
}

以下是错误信息:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s; // no problem
  |              -- immutable borrow occurs here
5 |     let r2 = &s; // no problem
6 |     let r3 = &mut s; // BIG PROBLEM
  |              ^^^^^^ mutable borrow occurs here
7 |
8 |     println!("{}, {}, and {}", r1, r2, r3);
  |                                -- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

哇!我们也不能在有一个不可变引用的情况下拥有一个对同一值的可变引用。

不可变引用的用户不希望值突然在他们下面改变!然而,允许多个不可变引用,因为只读取数据的人没有能力影响其他人对数据的读取。

注意,引用的作用域从它被引入的地方开始,一直持续到最后一次使用该引用。例如,这段代码将编译,因为不可变引用的最后一次使用是在 println! 中,在引入可变引用之前:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    println!("{r1} and {r2}");
    // Variables r1 and r2 will not be used after this point.

    let r3 = &mut s; // no problem
    println!("{r3}");
}

不可变引用 r1r2 的作用域在它们最后一次使用的 println! 之后结束,这是在创建可变引用 r3 之前。这些作用域不重叠,所以这段代码是允许的:编译器可以判断出在作用域结束之前,引用不再被使用。

尽管借用错误有时可能令人沮丧,但请记住,这是 Rust 编译器在早期(在编译时而不是运行时)指出潜在错误,并准确地告诉你问题所在。这样你就不必追踪为什么你的数据不是你想象的那样。

悬垂引用

在有指针的语言中,很容易错误地创建一个悬垂指针——一个引用内存中可能已经分配给其他人的位置的指针——通过释放一些内存而保留指向该内存的指针。相比之下,在 Rust 中,编译器保证引用永远不会是悬垂引用:如果你有一个对某些数据的引用,编译器将确保数据不会在引用之前离开作用域。

让我们尝试创建一个悬垂引用,看看 Rust 如何通过编译时错误来防止它们:

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

以下是错误信息:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
 --> src/main.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
  |
5 | fn dangle() -> &'static String {
  |                 +++++++
help: instead, you are more likely to want to return an owned value
  |
5 - fn dangle() -> &String {
5 + fn dangle() -> String {
  |

error[E0515]: cannot return reference to local variable `s`
 --> src/main.rs:8:5
  |
8 |     &s
  |     ^^ returns a reference to data owned by the current function

Some errors have detailed explanations: E0106, E0515.
For more information about an error, try `rustc --explain E0106`.
error: could not compile `ownership` (bin "ownership") due to 2 previous errors

这个错误信息提到了我们尚未涉及的一个特性:生命周期。我们将在第 10 章详细讨论生命周期。但是,如果你忽略关于生命周期的部分,消息确实包含了为什么这段代码有问题的关键:

此函数的返回类型包含一个借用值,但没有值可供借用

让我们仔细看看 dangle 代码的每个阶段发生了什么:

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String { // dangle returns a reference to a String

    let s = String::from("hello"); // s is a new String

    &s // we return a reference to the String, s
} // Here, s goes out of scope, and is dropped, so its memory goes away.
  // Danger!

因为 s 是在 dangle 内部创建的,当 dangle 的代码执行完毕时,s 将被释放。但我们试图返回一个对它的引用。这意味着这个引用将指向一个无效的 String。这不行!Rust 不会让我们这样做。

这里的解决方案是直接返回 String

fn main() {
    let string = no_dangle();
}

fn no_dangle() -> String {
    let s = String::from("hello");

    s
}

这样就不会有任何问题。所有权被移出,没有任何东西被释放。

引用规则

让我们回顾一下我们讨论过的引用规则:

  • 在任意给定时间,你可以要么拥有一个可变引用,要么拥有任意数量的不可变引用。
  • 引用必须始终有效。

接下来,我们将看看另一种引用:切片。

切片类型

切片 允许你引用一个集合中的连续元素序列,而不是整个集合。切片是一种引用,因此它没有所有权。

这里有一个小的编程问题:编写一个函数,该函数接受一个由空格分隔的单词字符串,并返回该字符串中的第一个单词。如果函数在字符串中没有找到空格,那么整个字符串必须是一个单词,因此应该返回整个字符串。

让我们通过编写这个函数的签名来了解切片将解决的问题,而不使用切片:

fn first_word(s: &String) -> ?

first_word 函数有一个 &String 作为参数。我们不需要所有权,所以这没问题。(在惯用的 Rust 中,函数不会获取其参数的所有权,除非它们需要,随着我们继续深入,这一点会变得更加清晰!)但是我们应该返回什么?我们实际上没有办法谈论字符串的一部分。然而,我们可以返回单词末尾的索引,由空格表示。让我们尝试一下,如 Listing 4-7 所示。

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

因为我们需要逐个元素地遍历 String 并检查某个值是否为空格,所以我们将使用 as_bytes 方法将 String 转换为字节数组。

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

接下来,我们使用 iter 方法在字节数组上创建一个迭代器:

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

我们将在 第 13 章 中更详细地讨论迭代器。现在,只需知道 iter 是一个返回集合中每个元素的方法,而 enumerate 包装了 iter 的结果,并将每个元素作为元组的一部分返回。enumerate 返回的元组的第一个元素是索引,第二个元素是对元素的引用。这比我们自己计算索引更方便。

因为 enumerate 方法返回一个元组,我们可以使用模式来解构这个元组。我们将在 第 6 章 中更详细地讨论模式。在 for 循环中,我们指定一个模式,其中 i 是元组中的索引,&item 是元组中的单个字节。因为我们从 .iter().enumerate() 中获取了对元素的引用,所以我们在模式中使用 &

for 循环内部,我们使用字节字面量语法搜索表示空格的字节。如果我们找到一个空格,我们返回该位置。否则,我们使用 s.len() 返回字符串的长度。

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

我们现在有一种方法可以找到字符串中第一个单词的结尾索引,但有一个问题。我们返回的是一个单独的 usize,但它只在 &String 的上下文中有意义。换句话说,因为它是一个与 String 分离的值,所以不能保证它在将来仍然有效。考虑 Listing 4-8 中的程序,它使用了 Listing 4-7 中的 first_word 函数。

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s); // word will get the value 5

    s.clear(); // this empties the String, making it equal to ""

    // `word` still has the value `5` here, but `s` no longer has any content
    // that we could meaningfully use with the value `5`, so `word` is now
    // totally invalid!
}

这个程序在没有任何错误的情况下编译,如果我们在调用 s.clear() 之后使用 word,它也会这样做。因为 words 的状态完全无关,word 仍然包含值 5。我们可以使用该值 5 与变量 s 尝试提取第一个单词,但这将是一个错误,因为自从我们在 word 中保存 5 以来,s 的内容已经发生了变化。

担心 word 中的索引与 s 中的数据不同步是繁琐且容易出错的!如果我们编写一个 second_word 函数,管理这些索引会更加脆弱。它的签名必须如下所示:

fn second_word(s: &String) -> (usize, usize) {

现在我们正在跟踪一个起始索引和一个结束索引,我们有更多的值是从特定状态的数据计算出来的,但与那个状态完全没有关联。我们有三个不相关的变量需要保持同步。

幸运的是,Rust 有一个解决这个问题的方法:字符串切片。

字符串切片

字符串切片 是对 String 的一部分的引用,它看起来像这样:

fn main() {
    let s = String::from("hello world");

    let hello = &s[0..5];
    let world = &s[6..11];
}

hello 不是对整个 String 的引用,而是对 String 的一部分的引用,由额外的 [0..5] 指定。我们通过在括号内指定 [starting_index..ending_index] 来创建切片,其中 starting_index 是切片的第一个位置,ending_index 是切片最后一个位置的下一个位置。在内部,切片数据结构存储了切片的起始位置和长度,长度对应于 ending_index 减去 starting_index。因此,在 let world = &s[6..11]; 的情况下,world 将是一个切片,它包含一个指向 s 索引 6 处的字节的指针,长度值为 5

图 4-7 以图表形式展示了这一点。

三个表:一个表表示 s 的栈数据,它指向堆数据表中索引 0 处的字节,堆数据表包含字符串数据 "hello world"。第三个表表示切片 world 的栈数据,它的长度值为 5,并指向堆数据表的字节 6。

图 4-7: 字符串切片引用 String 的一部分

使用 Rust 的 .. 范围语法,如果你想从索引 0 开始,可以省略两个点之前的值。换句话说,以下两种写法是等价的:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2];
}

同样地,如果你的切片包括 String 的最后一个字节,你可以省略结尾的数字。这意味着以下两种写法是等价的:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];
}

你也可以省略两个值来获取整个字符串的切片。因此,以下两种写法是等价的:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];
}

注意:字符串切片的范围索引必须位于有效的 UTF-8 字符边界。如果你尝试在多字节字符的中间创建字符串切片,你的程序将会出错。为了介绍字符串切片,我们在本节中假设只使用 ASCII;关于 UTF-8 处理的更详细讨论在 第 8 章 的“使用字符串存储 UTF-8 编码文本”部分。

有了这些信息,让我们重写 first_word 以返回一个切片。表示“字符串切片”的类型写作 &str

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {}

我们以与 Listing 4-7 相同的方式获取单词的结尾索引,即查找第一个空格的出现。当我们找到一个空格时,我们返回一个字符串切片,使用字符串的起始位置和空格的索引作为起始和结束索引。

现在当我们调用 first_word 时,我们得到一个与底层数据绑定的单一值。该值由切片的起始点的引用和切片中的元素数量组成。

返回切片也适用于 second_word 函数:

fn second_word(s: &String) -> &str {

我们现在有一个更不容易出错的直接 API,因为编译器将确保对 String 的引用保持有效。还记得 Listing 4-8 中的程序中的错误吗?当我们获取第一个单词的结尾索引,然后清空字符串,使我们的索引无效时?那段代码在逻辑上是错误的,但没有立即显示任何错误。如果我们继续尝试使用清空字符串的第一个单词索引,问题会在以后出现。切片使这个错误不可能发生,并让我们更早地知道代码有问题。使用切片版本的 first_word 会抛出一个编译时错误:

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s);

    s.clear(); // error!

    println!("the first word is: {word}");
}

以下是编译器错误:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
  --> src/main.rs:18:5
   |
16 |     let word = first_word(&s);
   |                           -- immutable borrow occurs here
17 |
18 |     s.clear(); // error!
   |     ^^^^^^^^^ mutable borrow occurs here
19 |
20 |     println!("the first word is: {word}");
   |                                  ------ immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

回想一下借用规则,如果我们对某物有一个不可变引用,我们就不能同时获取一个可变引用。因为 clear 需要截断 String,它需要获取一个可变引用。clear 调用后的 println! 使用了 word 中的引用,因此不可变引用在此时必须仍然有效。Rust 不允许 clear 中的可变引用和 word 中的不可变引用同时存在,因此编译失败。Rust 不仅使我们的 API 更易于使用,而且还在编译时消除了整个类别的错误!

字符串字面量是切片

回想一下,我们讨论过字符串字面量存储在二进制文件中。现在我们知道切片,我们可以正确理解字符串字面量:

#![allow(unused)]
fn main() {
let s = "Hello, world!";
}

这里的 s 类型是 &str:它是一个指向二进制文件中特定位置的切片。这也是为什么字符串字面量是不可变的;&str 是一个不可变引用。

字符串切片作为参数

知道你可以获取字面量和 String 值的切片,这让我们对 first_word 的签名有了进一步的改进:

fn first_word(s: &String) -> &str {

更有经验的 Rustacean 会编写 Listing 4-9 中所示的签名,因为它允许我们在 &String 值和 &str 值上使用相同的函数。

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // `first_word` works on slices of `String`s, whether partial or whole.
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s.
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or
    // whole.
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

如果我们有一个字符串切片,我们可以直接传递它。如果我们有一个 String,我们可以传递 String 的切片或对 String 的引用。这种灵活性利用了 解引用强制转换,我们将在 第 15 章 的“函数和方法的隐式解引用强制转换”部分讨论这一特性。

定义一个函数来接受字符串切片而不是对 String 的引用,使我们的 API 更通用且更有用,而不会失去任何功能:

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // `first_word` works on slices of `String`s, whether partial or whole.
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s.
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or
    // whole.
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

其他切片

字符串切片,正如你可能想象的那样,是特定于字符串的。但也有一个更通用的切片类型。考虑这个数组:

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];
}

就像我们可能想引用字符串的一部分一样,我们可能想引用数组的一部分。我们可以这样做:

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];

assert_eq!(slice, &[2, 3]);
}

这个切片的类型是 &[i32]。它的工作方式与字符串切片相同,通过存储对第一个元素的引用和长度。你会为各种其他集合使用这种切片。我们将在第 8 章讨论向量时详细讨论这些集合。

总结

所有权、借用和切片的概念确保 Rust 程序在编译时的内存安全。Rust 语言让你像其他系统编程语言一样控制内存使用,但拥有数据的拥有者在超出作用域时自动清理数据,这意味着你不必编写和调试额外的代码来获得这种控制。

所有权影响 Rust 的许多其他部分的工作方式,因此我们将在本书的其余部分进一步讨论这些概念。让我们继续第 5 章,看看如何将数据分组到一个 struct 中。

使用结构体组织相关数据

结构体(struct)是一种自定义数据类型,它允许你将多个相关的值打包在一起,并为这些值命名,从而形成一个有意义的组合。如果你熟悉面向对象编程语言,_结构体_类似于对象的数据属性。在本章中,我们将比较元组和结构体,以便在你已经掌握的知识基础上进一步探讨,并展示在什么情况下结构体是更好的数据组织方式。

我们将演示如何定义和实例化结构体。我们还将讨论如何定义关联函数,特别是称为_方法_的关联函数,以指定与结构体类型相关的行为。结构体和枚举(在第6章中讨论)是创建程序中新类型的构建块,以便充分利用 Rust 的编译时类型检查。

定义和实例化结构体

结构体与元组类似,都是在“元组类型”部分讨论的,两者都包含多个相关的值。与元组一样,结构体的各个部分可以是不同的类型。与元组不同的是,在结构体中,你会为每个数据片段命名,以便清楚地知道这些值的含义。添加这些名称意味着结构体比元组更灵活:你不必依赖数据的顺序来指定或访问实例的值。

要定义一个结构体,我们使用关键字 struct 并为整个结构体命名。结构体的名称应描述被分组在一起的数据片段的重要性。然后,在大括号内,我们定义数据片段的名称和类型,这些数据片段称为 字段。例如,Listing 5-1 展示了一个存储用户账户信息的结构体。

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {}

在定义结构体之后,我们可以通过为每个字段指定具体的值来创建该结构体的 实例。我们通过声明结构体的名称来创建实例,然后添加包含 key: value 对的大括号,其中键是字段的名称,值是我们想要存储在这些字段中的数据。我们不必按照在结构体中声明字段的顺序来指定字段。换句话说,结构体定义就像是该类型的通用模板,而实例则用特定的数据填充该模板以创建该类型的值。例如,我们可以声明一个特定的用户,如 Listing 5-2 所示。

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        active: true,
        username: String::from("someusername123"),
        email: String::from("someone@example.com"),
        sign_in_count: 1,
    };
}

要从结构体中获取特定的值,我们使用点号表示法。例如,要访问此用户的电子邮件地址,我们使用 user1.email。如果实例是可变的,我们可以通过点号表示法并赋值给特定字段来更改值。Listing 5-3 展示了如何更改可变 User 实例中 email 字段的值。

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let mut user1 = User {
        active: true,
        username: String::from("someusername123"),
        email: String::from("someone@example.com"),
        sign_in_count: 1,
    };

    user1.email = String::from("anotheremail@example.com");
}

请注意,整个实例必须是可变的;Rust 不允许我们仅将某些字段标记为可变。与任何表达式一样,我们可以在函数体的最后一个表达式中构造一个新的结构体实例,以隐式返回该新实例。

Listing 5-4 展示了一个 build_user 函数,该函数返回一个具有给定电子邮件和用户名的 User 实例。active 字段的值为 truesign_in_count 的值为 1

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username: username,
        email: email,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("someone@example.com"),
        String::from("someusername123"),
    );
}

将函数参数命名为与结构体字段相同的名称是有意义的,但必须重复 emailusername 字段名称和变量有点繁琐。如果结构体有更多字段,重复每个名称会更加烦人。幸运的是,有一个方便的简写!

使用字段初始化简写

因为在 Listing 5-4 中参数名称和结构体字段名称完全相同,我们可以使用 字段初始化简写 语法来重写 build_user,使其行为完全相同,但不必重复 usernameemail,如 Listing 5-5 所示。

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username,
        email,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("someone@example.com"),
        String::from("someusername123"),
    );
}

在这里,我们正在创建一个 User 结构体的新实例,该结构体有一个名为 email 的字段。我们希望将 email 字段的值设置为 build_user 函数的 email 参数中的值。因为 email 字段和 email 参数具有相同的名称,我们只需要写 email 而不是 email: email

使用结构体更新语法从其他实例创建实例

通常,创建一个包含另一个实例的大部分值但更改某些值的新结构体实例非常有用。你可以使用 结构体更新语法 来实现这一点。

首先,在 Listing 5-6 中,我们展示了如何在没有更新语法的情况下常规地创建一个新的 User 实例 user2。我们为 email 设置一个新值,但其他值使用我们在 Listing 5-2 中创建的 user1 的值。

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    // --snip--

    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        active: user1.active,
        username: user1.username,
        email: String::from("another@example.com"),
        sign_in_count: user1.sign_in_count,
    };
}

使用结构体更新语法,我们可以用更少的代码实现相同的效果,如 Listing 5-7 所示。语法 .. 指定未显式设置的其余字段应与给定实例中的字段具有相同的值。

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    // --snip--

    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        email: String::from("another@example.com"),
        ..user1
    };
}

Listing 5-7 中的代码也在 user2 中创建了一个实例,该实例的 email 值不同,但 usernameactivesign_in_count 字段的值与 user1 相同。..user1 必须放在最后,以指定任何剩余的字段应从 user1 中的相应字段获取其值,但我们可以选择以任何顺序为任意多个字段指定值,而不管这些字段在结构体定义中的顺序如何。

请注意,结构体更新语法使用 = 类似于赋值;这是因为它会移动数据,就像我们在“变量和数据交互:移动”部分看到的那样。在这个例子中,我们在创建 user2 之后不能再使用 user1,因为 user1username 字段中的 String 被移动到了 user2 中。如果我们为 user2emailusername 都提供了新的 String 值,因此只使用了 user1activesign_in_count 值,那么在创建 user2 之后,user1 仍然有效。activesign_in_count 都是实现了 Copy trait 的类型,因此我们在“仅栈数据:复制”部分讨论的行为将适用。在这个例子中,我们仍然可以使用 user1.email,因为它的值 没有 被移出。

使用没有命名字段的元组结构体创建不同类型

Rust 还支持看起来类似于元组的结构体,称为 元组结构体。元组结构体具有结构体名称提供的额外含义,但没有与其字段关联的名称;相反,它们只有字段的类型。当你想要为整个元组命名并使元组成为与其他元组不同的类型时,元组结构体非常有用,而在常规结构体中为每个字段命名可能会显得冗长或多余。

要定义一个元组结构体,以 struct 关键字和结构体名称开头,后跟元组中的类型。例如,这里我们定义并使用两个名为 ColorPoint 的元组结构体:

struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

fn main() {
    let black = Color(0, 0, 0);
    let origin = Point(0, 0, 0);
}

请注意,blackorigin 值是不同类型的,因为它们是不同元组结构体的实例。你定义的每个结构体都是其自己的类型,即使结构体中的字段可能具有相同的类型。例如,一个接受 Color 类型参数的函数不能接受 Point 作为参数,即使这两种类型都由三个 i32 值组成。除此之外,元组结构体实例与元组类似,你可以将它们解构为其单独的部分,并且可以使用 . 后跟索引来访问单个值。与元组不同,元组结构体在解构时需要命名结构体的类型。例如,我们会写 let Point(x, y, z) = point

没有任何字段的类单元结构体

你也可以定义没有任何字段的结构体!这些被称为 类单元结构体,因为它们的行为类似于 (),即我们在“元组类型”部分提到的单元类型。当你需要在某个类型上实现 trait 但不想在该类型本身中存储任何数据时,类单元结构体非常有用。我们将在第 10 章讨论 trait。以下是一个声明和实例化名为 AlwaysEqual 的单元结构体的示例:

struct AlwaysEqual;

fn main() {
    let subject = AlwaysEqual;
}

要定义 AlwaysEqual,我们使用 struct 关键字,然后是我们想要的名称,然后是一个分号。不需要大括号或圆括号!然后我们可以以类似的方式在 subject 变量中获取 AlwaysEqual 的实例:使用我们定义的名称,不需要任何大括号或圆括号。想象一下,稍后我们将为这种类型实现行为,使得每个 AlwaysEqual 实例总是等于任何其他类型的实例,可能是为了测试目的而具有已知结果。我们不需要任何数据来实现这种行为!你将在第 10 章看到如何定义 trait 并在任何类型(包括类单元结构体)上实现它们。

结构体数据的所有权

在 Listing 5-1 的 User 结构体定义中,我们使用了拥有的 String 类型,而不是 &str 字符串切片类型。这是一个有意的选择,因为我们希望此结构体的每个实例都拥有其所有数据,并且这些数据在整个结构体有效期间都有效。

结构体也可以存储对其他地方拥有的数据的引用,但这样做需要使用 生命周期,这是 Rust 的一个特性,我们将在第 10 章讨论。生命周期确保结构体引用的数据在结构体有效期间有效。假设你尝试在结构体中存储一个引用而不指定生命周期,如下所示;这将无法工作:

struct User {
    active: bool,
    username: &str,
    email: &str,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        active: true,
        username: "someusername123",
        email: "someone@example.com",
        sign_in_count: 1,
    };
}

编译器会抱怨需要生命周期说明符:

$ cargo run
   Compiling structs v0.1.0 (file:///projects/structs)
error[E0106]: missing lifetime specifier
 --> src/main.rs:3:15
  |
3 |     username: &str,
  |               ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct User<'a> {
2 |     active: bool,
3 ~     username: &'a str,
  |

error[E0106]: missing lifetime specifier
 --> src/main.rs:4:12
  |
4 |     email: &str,
  |            ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct User<'a> {
2 |     active: bool,
3 |     username: &str,
4 ~     email: &'a str,
  |

For more information about this error, try `rustc --explain E0106`.
error: could not compile `structs` (bin "structs") due to 2 previous errors

在第 10 章,我们将讨论如何修复这些错误,以便你可以在结构体中存储引用,但现在,我们将使用拥有的类型(如 String)而不是引用(如 &str)来修复这些错误。

使用结构体的示例程序

为了理解我们何时可能想要使用结构体,让我们编写一个计算矩形面积的程序。我们将从使用单个变量开始,然后逐步重构程序,直到使用结构体为止。

让我们使用 Cargo 创建一个新的二进制项目,名为 rectangles,该项目将接收以像素为单位的矩形的宽度和高度,并计算矩形的面积。Listing 5-8 展示了一个简短的程序,展示了如何在我们的项目的 src/main.rs 中实现这一点。

fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}

现在,使用 cargo run 运行这个程序:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/rectangles`
The area of the rectangle is 1500 square pixels.

这段代码通过调用 area 函数并传入每个维度来成功计算出矩形的面积,但我们可以做更多的工作来使这段代码更加清晰和易读。

这段代码的问题在 area 函数的签名中显而易见:

fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}

area 函数应该计算一个矩形的面积,但我们编写的函数有两个参数,并且在程序的任何地方都不清楚这两个参数是相关的。将宽度和高度组合在一起会更易读和更易管理。我们已经在第 3 章的 “元组类型” 部分讨论过一种方法:使用元组。

使用元组进行重构

Listing 5-9 展示了我们程序的另一个版本,该版本使用了元组。

fn main() {
    let rect1 = (30, 50);

    println!(
        "The area of the rectangle is {} square pixels.",
        area(rect1)
    );
}

fn area(dimensions: (u32, u32)) -> u32 {
    dimensions.0 * dimensions.1
}

在某种程度上,这个程序更好。元组让我们增加了一些结构,现在我们只传递一个参数。但在另一种方式上,这个版本不太清晰:元组没有为它们的元素命名,所以我们必须通过索引访问元组的部分,这使得我们的计算不那么明显。

混淆宽度和高度对于面积计算来说并不重要,但如果我们想在屏幕上绘制矩形,那就很重要了!我们必须记住 width 是元组索引 0,而 height 是元组索引 1。如果其他人要使用我们的代码,这将更难理解和记住。因为我们没有在代码中传达数据的含义,所以现在更容易引入错误。

使用结构体进行重构:增加更多意义

我们使用结构体通过为数据添加标签来增加意义。我们可以将我们使用的元组转换为一个结构体,该结构体具有整体的名称以及部分的名称,如 Listing 5-10 所示。

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        area(&rect1)
    );
}

fn area(rectangle: &Rectangle) -> u32 {
    rectangle.width * rectangle.height
}

在这里,我们定义了一个结构体并将其命名为 Rectangle。在大括号内,我们将字段定义为 widthheight,它们的类型都是 u32。然后,在 main 中,我们创建了一个特定的 Rectangle 实例,其宽度为 30,高度为 50

我们的 area 函数现在定义了一个参数,我们将其命名为 rectangle,其类型是对 Rectangle 结构体实例的不可变借用。正如第 4 章提到的,我们希望借用结构体而不是获取其所有权。这样,main 保留其所有权并可以继续使用 rect1,这就是我们在函数签名和调用函数时使用 & 的原因。

area 函数访问 Rectangle 实例的 widthheight 字段(注意,访问借用结构体实例的字段不会移动字段值,这就是为什么你经常看到结构体的借用)。我们的 area 函数签名现在准确地表达了我们的意思:使用 Rectanglewidthheight 字段计算其面积。这传达了宽度和高度是相互关联的,并且它为值提供了描述性名称,而不是使用元组索引值 01。这是一个清晰度的胜利。

使用派生特性添加有用的功能

在调试程序时,能够打印 Rectangle 实例并查看其所有字段的值会很有用。Listing 5-11 尝试使用我们在前几章中使用过的 println!。然而,这不会起作用。

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {}", rect1);
}

当我们编译这段代码时,会得到以下核心错误信息:

error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`

println! 宏可以进行多种格式化,默认情况下,大括号告诉 println! 使用称为 Display 的格式化:输出直接供最终用户使用。我们目前见过的原始类型默认实现了 Display,因为只有一种方式可以向用户显示 1 或其他原始类型。但对于结构体,println! 应该如何格式化输出就不那么清楚了,因为有更多的显示可能性:你想要逗号吗?你想打印大括号吗?应该显示所有字段吗?由于这种歧义,Rust 不会尝试猜测我们想要什么,结构体也没有提供 Display 的实现供 println!{} 占位符使用。

如果我们继续阅读错误信息,我们会发现这个有用的提示:

   = help: the trait `std::fmt::Display` is not implemented for `Rectangle`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead

让我们试试看!println! 宏调用现在看起来像 println!("rect1 is {rect1:?}");。将 :? 放在大括号内告诉 println! 我们想要使用称为 Debug 的输出格式。Debug 特性使我们能够以对开发者有用的方式打印结构体,这样我们就可以在调试代码时看到它的值。

用这个更改编译代码。糟糕!我们仍然得到一个错误:

error[E0277]: `Rectangle` doesn't implement `Debug`

但再次,编译器给了我们一个有用的提示:

   = help: the trait `Debug` is not implemented for `Rectangle`
   = note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`

Rust 确实 包含了打印调试信息的功能,但我们必须显式选择才能使该功能对我们的结构体可用。为此,我们在结构体定义之前添加外部属性 #[derive(Debug)],如 Listing 5-12 所示。

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {rect1:?}");
}

现在当我们运行程序时,不会得到任何错误,并且会看到以下输出:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle { width: 30, height: 50 }

很好!这不是最漂亮的输出,但它显示了该实例的所有字段的值,这肯定会在调试时有所帮助。当我们有更大的结构体时,输出稍微易读一些会很有用;在这些情况下,我们可以在 println! 字符串中使用 {:#?} 而不是 {:?}。在这个例子中,使用 {:#?} 样式将输出以下内容:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle {
    width: 30,
    height: 50,
}

另一种使用 Debug 格式打印值的方法是使用 dbg!,它获取表达式的所有权(与 println! 不同,后者获取引用),打印 dbg! 宏调用在代码中发生的位置的文件和行号以及该表达式的结果值,并返回该值的所有权。

注意:调用 dbg! 宏会打印到标准错误控制台流(stderr),而不是 println!,后者打印到标准输出控制台流(stdout)。我们将在第 12 章的 “将错误消息写入标准错误而不是标准输出” 部分 中更多地讨论 stderrstdout

这里有一个例子,我们对分配给 width 字段的值以及 rect1 中整个结构体的值感兴趣:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let scale = 2;
    let rect1 = Rectangle {
        width: dbg!(30 * scale),
        height: 50,
    };

    dbg!(&rect1);
}

我们可以将 dbg! 放在表达式 30 * scale 周围,因为 dbg! 返回表达式值的所有权,width 字段将获得与没有 dbg! 调用时相同的值。我们不希望 dbg! 获取 rect1 的所有权,所以我们在下一个调用中使用 rect1 的引用。以下是这个例子的输出:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/rectangles`
[src/main.rs:10:16] 30 * scale = 60
[src/main.rs:14:5] &rect1 = Rectangle {
    width: 60,
    height: 50,
}

我们可以看到第一部分的输出来自 src/main.rs 的第 10 行,我们在那里调试表达式 30 * scale,其结果值为 60(为整数实现的 Debug 格式化只打印它们的值)。第 14 行的 dbg! 调用输出了 &rect1 的值,即 Rectangle 结构体。这个输出使用了 Rectangle 类型的漂亮 Debug 格式化。dbg! 宏在你试图弄清楚代码在做什么时非常有用!

除了 Debug 特性外,Rust 还为我们提供了许多特性,可以与 derive 属性一起使用,这些特性可以为我们的自定义类型添加有用的行为。这些特性及其行为列在 附录 C 中。我们将在第 10 章中介绍如何实现这些特性以及如何创建自己的特性。还有许多其他属性;更多信息请参见 Rust 参考中的 “属性” 部分

我们的 area 函数非常具体:它只计算矩形的面积。将这个行为与我们的 Rectangle 结构体更紧密地绑定在一起会很有帮助,因为它不适用于任何其他类型。让我们看看如何通过将 area 函数转换为定义在 Rectangle 类型上的 area 方法 来继续重构这段代码。

方法语法

方法 与函数类似:我们使用 fn 关键字和一个名称来声明它们,它们可以有参数和返回值,并且它们包含一些代码,当方法从其他地方调用时,这些代码会被执行。与函数不同的是,方法是在结构体(或枚举或 trait 对象,我们分别在第 6 章第 18 章中介绍)的上下文中定义的,并且它们的第一个参数总是 self,它表示调用该方法的结构体实例。

定义方法

让我们将 area 函数改为一个 Rectangle 结构体上的 area 方法,如 Listing 5-13 所示。

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        rect1.area()
    );
}

为了在 Rectangle 的上下文中定义函数,我们为 Rectangle 启动了一个 impl(实现)块。这个 impl 块中的所有内容都将与 Rectangle 类型相关联。然后我们将 area 函数移动到 impl 的大括号内,并将第一个(在这种情况下,也是唯一的)参数在签名和函数体中的所有地方改为 self。在 main 中,我们调用 area 函数并传递 rect1 作为参数的地方,我们可以改为使用 方法语法 来调用 Rectangle 实例上的 area 方法。方法语法跟在实例后面:我们添加一个点,后面跟着方法名、括号和任何参数。

area 的签名中,我们使用 &self 而不是 rectangle: &Rectangle&self 实际上是 self: &Self 的缩写。在 impl 块中,Self 类型是 impl 块所针对的类型的别名。方法的第一个参数必须是一个名为 selfSelf 类型的参数,因此 Rust 允许你在第一个参数位置只使用 self 来缩写这个参数。注意,我们仍然需要在 self 缩写前使用 & 来表示该方法借用 Self 实例,就像我们在 rectangle: &Rectangle 中所做的那样。方法可以获取 self 的所有权,不可变地借用 self,就像我们在这里所做的那样,或者可变地借用 self,就像它们可以处理任何其他参数一样。

我们在这里选择 &self 的原因与我们在函数版本中使用 &Rectangle 的原因相同:我们不想获取所有权,我们只想读取结构体中的数据,而不是写入数据。如果我们想要在方法执行过程中改变调用该方法的实例,我们会使用 &mut self 作为第一个参数。让一个方法通过仅使用 self 作为第一个参数来获取实例的所有权是罕见的;这种技术通常用于当方法将 self 转换为其他东西时,并且你希望在转换后阻止调用者使用原始实例。

使用方法而不是函数的主要原因,除了提供方法语法和不必在每个方法的签名中重复 self 的类型之外,是为了组织代码。我们将所有可以对类型的实例做的事情放在一个 impl 块中,而不是让未来的代码使用者在我们的库中到处搜索 Rectangle 的功能。

注意,我们可以选择给方法一个与结构体字段相同的名称。例如,我们可以在 Rectangle 上定义一个名为 width 的方法:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn width(&self) -> bool {
        self.width > 0
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    if rect1.width() {
        println!("The rectangle has a nonzero width; it is {}", rect1.width);
    }
}

在这里,我们选择让 width 方法在实例的 width 字段的值大于 0 时返回 true,如果值为 0 则返回 false:我们可以在同名方法中使用字段来实现任何目的。在 main 中,当我们在 rect1.width 后面加上括号时,Rust 知道我们指的是 width 方法。当我们不使用括号时,Rust 知道我们指的是 width 字段。

通常,但并非总是如此,当我们给方法一个与字段相同的名称时,我们希望它只返回字段中的值而不做其他事情。这样的方法被称为 getters,Rust 不会像其他一些语言那样自动为结构体字段实现它们。Getters 很有用,因为你可以将字段设为私有,但将方法设为公共,从而作为类型的公共 API 的一部分启用对该字段的只读访问。我们将在第 7 章中讨论什么是公共和私有,以及如何将字段或方法指定为公共或私有。

-> 操作符在哪里?

在 C 和 C++ 中,使用两个不同的操作符来调用方法:如果你直接在对象上调用方法,则使用 .;如果你在指向对象的指针上调用方法并需要先解引用指针,则使用 ->。换句话说,如果 object 是一个指针,object->something() 类似于 (*object).something()

Rust 没有与 -> 操作符等价的操作符;相反,Rust 有一个称为 自动引用和解引用 的功能。调用方法是 Rust 中少数几个具有这种行为的地方之一。

它的工作原理是:当你使用 object.something() 调用方法时,Rust 会自动添加 &&mut*,以便 object 与方法的签名匹配。换句话说,以下代码是相同的:

#![allow(unused)]
fn main() {
#[derive(Debug,Copy,Clone)]
struct Point {
    x: f64,
    y: f64,
}

impl Point {
   fn distance(&self, other: &Point) -> f64 {
       let x_squared = f64::powi(other.x - self.x, 2);
       let y_squared = f64::powi(other.y - self.y, 2);

       f64::sqrt(x_squared + y_squared)
   }
}
let p1 = Point { x: 0.0, y: 0.0 };
let p2 = Point { x: 5.0, y: 6.5 };
p1.distance(&p2);
(&p1).distance(&p2);
}

第一个看起来更简洁。这种自动引用行为之所以有效,是因为方法有一个明确的接收者——self 的类型。给定方法的接收者和名称,Rust 可以明确地确定方法是读取(&self)、修改(&mut self)还是消耗(self)。Rust 使方法接收者的借用隐式化,这是使所有权在实践中变得符合人体工程学的一个重要部分。

带有更多参数的方法

让我们通过在 Rectangle 结构体上实现第二个方法来练习使用方法。这次我们希望 Rectangle 的一个实例接受另一个 Rectangle 实例,并返回 true 如果第二个 Rectangle 可以完全包含在 self(第一个 Rectangle)中;否则,它应该返回 false。也就是说,一旦我们定义了 can_hold 方法,我们希望能够编写如 Listing 5-14 所示的程序。

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

预期的输出将如下所示,因为 rect2 的两个维度都小于 rect1 的维度,但 rect3rect1 更宽:

Can rect1 hold rect2? true
Can rect1 hold rect3? false

我们知道我们想要定义一个方法,所以它将在 impl Rectangle 块内。方法名将是 can_hold,并且它将接受另一个 Rectangle 的不可变借用作为参数。我们可以通过查看调用方法的代码来告诉参数的类型:rect1.can_hold(&rect2) 传入 &rect2,这是 rect2 的不可变借用,rect2Rectangle 的一个实例。这是有道理的,因为我们只需要读取 rect2(而不是写入,这意味着我们需要一个可变借用),并且我们希望 main 保留 rect2 的所有权,以便我们可以在调用 can_hold 方法后再次使用它。can_hold 的返回值将是一个布尔值,实现将检查 self 的宽度和高度是否分别大于另一个 Rectangle 的宽度和高度。让我们将新的 can_hold 方法添加到 Listing 5-13 的 impl 块中,如 Listing 5-15 所示。

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

当我们使用 Listing 5-14 中的 main 函数运行此代码时,我们将得到我们想要的输出。方法可以接受多个参数,我们在 self 参数之后将它们添加到签名中,这些参数的工作方式与函数中的参数相同。

关联函数

impl 块中定义的所有函数都称为 关联函数,因为它们与 impl 后面的类型相关联。我们可以定义没有 self 作为第一个参数的关联函数(因此它们不是方法),因为它们不需要类型的实例来工作。我们已经使用过一个这样的函数:String::from 函数,它定义在 String 类型上。

不是方法的关联函数通常用于构造函数,这些构造函数将返回结构体的新实例。这些函数通常被称为 new,但 new 不是一个特殊的名称,也不是语言内置的。例如,我们可以选择提供一个名为 square 的关联函数,它将有一个维度参数,并将其用作宽度和高度,从而更容易创建一个正方形的 Rectangle,而不必两次指定相同的值:

文件名: src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn square(size: u32) -> Self {
        Self {
            width: size,
            height: size,
        }
    }
}

fn main() {
    let sq = Rectangle::square(3);
}

函数返回类型和函数体中的 Self 关键字是 impl 关键字后面出现的类型的别名,在这种情况下是 Rectangle

要调用这个关联函数,我们使用结构体名称和 :: 语法;let sq = Rectangle::square(3); 就是一个例子。这个函数由结构体命名空间化::: 语法用于关联函数和模块创建的命名空间。我们将在第 7 章中讨论模块。

多个 impl

每个结构体允许有多个 impl 块。例如,Listing 5-15 等同于 Listing 5-16 所示的代码,其中每个方法都在自己的 impl 块中。

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

在这里没有理由将这些方法分离到多个 impl 块中,但这是有效的语法。我们将在第 10 章中看到一个多个 impl 块有用的案例,在那里我们讨论泛型类型和 trait。

总结

结构体允许你创建对你的领域有意义的自定义类型。通过使用结构体,你可以将相关的数据片段保持在一起,并为每个片段命名以使你的代码清晰。在 impl 块中,你可以定义与你的类型相关联的函数,而方法是一种关联函数,它允许你指定你的结构体实例的行为。

但结构体并不是你创建自定义类型的唯一方式:让我们转向 Rust 的枚举功能,为你的工具箱添加另一个工具。

枚举与模式匹配

在本章中,我们将探讨枚举(enumerations),也称为enums。枚举允许你通过列举其可能的变体(variants)来定义一个类型。首先,我们将定义并使用一个枚举,以展示枚举如何能够同时编码数据及其含义。接下来,我们将探索一个特别有用的枚举,称为Option,它表示一个值可以是某个东西或者什么都没有。然后,我们将看看match表达式中的模式匹配如何使得为枚举的不同值运行不同的代码变得容易。最后,我们将介绍if let结构,它是另一种方便且简洁的惯用法,用于在代码中处理枚举。

定义枚举

结构体提供了一种将相关字段和数据组合在一起的方式,例如带有 widthheightRectangle,而枚举则提供了一种表示一个值是可能的值集合中的一种的方式。例如,我们可能想说 Rectangle 是可能形状集合中的一种,该集合还包括 CircleTriangle。为了实现这一点,Rust 允许我们将这些可能性编码为枚举。

让我们来看一个我们可能希望在代码中表达的情况,并了解为什么在这种情况下枚举比结构体更有用且更合适。假设我们需要处理 IP 地址。目前,IP 地址有两个主要标准:版本四和版本六。因为我们的程序只会遇到这两种 IP 地址,所以我们可以枚举所有可能的变体,这也是枚举得名的原因。

任何 IP 地址要么是版本四,要么是版本六,但不能同时是两者。IP 地址的这一特性使得枚举数据结构非常合适,因为枚举值只能是其变体之一。版本四和版本六的地址从根本上说仍然是 IP 地址,因此当代码处理适用于任何类型 IP 地址的情况时,它们应该被视为同一类型。

我们可以通过定义一个 IpAddrKind 枚举并在其中列出 IP 地址可能的种类 V4V6 来表达这个概念。这些是枚举的变体:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

IpAddrKind 现在是一个自定义数据类型,我们可以在代码的其他地方使用它。

枚举值

我们可以像这样创建 IpAddrKind 的两个变体的实例:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

注意,枚举的变体在其标识符下命名空间化,我们使用双冒号来分隔两者。这很有用,因为现在 IpAddrKind::V4IpAddrKind::V6 都是同一类型:IpAddrKind。然后,我们可以定义一个接受任何 IpAddrKind 的函数:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

我们可以用任一变体调用这个函数:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

使用枚举还有更多优势。再想想我们的 IP 地址类型,目前我们没有办法存储实际的 IP 地址数据;我们只知道它的种类。鉴于你刚刚在第 5 章学习了结构体,你可能会想用结构体来解决这个问题,如 Listing 6-1 所示。

fn main() {
    enum IpAddrKind {
        V4,
        V6,
    }

    struct IpAddr {
        kind: IpAddrKind,
        address: String,
    }

    let home = IpAddr {
        kind: IpAddrKind::V4,
        address: String::from("127.0.0.1"),
    };

    let loopback = IpAddr {
        kind: IpAddrKind::V6,
        address: String::from("::1"),
    };
}

在这里,我们定义了一个结构体 IpAddr,它有两个字段:一个 kind 字段,类型为 IpAddrKind(我们之前定义的枚举),以及一个 address 字段,类型为 String。我们有两个这个结构体的实例。第一个是 home,它的 kind 值为 IpAddrKind::V4,并带有相关的地址数据 127.0.0.1。第二个实例是 loopback。它的 kind 值是 IpAddrKind 的另一个变体 V6,并带有地址 ::1。我们使用了一个结构体将 kindaddress 值捆绑在一起,所以现在变体与值相关联。

然而,仅使用枚举来表示相同的概念更加简洁:我们不必将枚举放在结构体中,而是可以直接将数据放入每个枚举变体中。这个新的 IpAddr 枚举定义表示 V4V6 变体都将具有关联的 String 值:

fn main() {
    enum IpAddr {
        V4(String),
        V6(String),
    }

    let home = IpAddr::V4(String::from("127.0.0.1"));

    let loopback = IpAddr::V6(String::from("::1"));
}

我们将数据直接附加到枚举的每个变体上,因此不需要额外的结构体。在这里,也更容易看到枚举工作的另一个细节:我们定义的每个枚举变体的名称也变成了一个构造枚举实例的函数。也就是说,IpAddr::V4() 是一个函数调用,它接受一个 String 参数并返回一个 IpAddr 类型的实例。我们自动获得了这个构造函数,作为定义枚举的结果。

使用枚举而不是结构体还有另一个优势:每个变体可以具有不同类型和数量的关联数据。版本四的 IP 地址总是有四个数值组件,其值在 0 到 255 之间。如果我们想将 V4 地址存储为四个 u8 值,但仍然将 V6 地址表示为一个 String 值,我们无法用结构体做到这一点。枚举可以轻松处理这种情况:

fn main() {
    enum IpAddr {
        V4(u8, u8, u8, u8),
        V6(String),
    }

    let home = IpAddr::V4(127, 0, 0, 1);

    let loopback = IpAddr::V6(String::from("::1"));
}

我们已经展示了多种定义数据结构来存储版本四和版本六 IP 地址的方法。然而,事实证明,存储 IP 地址并编码其类型是如此常见,以至于标准库中有一个我们可以使用的定义! 让我们看看标准库如何定义 IpAddr:它拥有我们定义和使用的确切枚举和变体,但它将地址数据嵌入到变体中,形式为两个不同的结构体,每个变体的定义方式不同:

#![allow(unused)]
fn main() {
struct Ipv4Addr {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}
}

这段代码说明你可以将任何类型的数据放入枚举变体中:字符串、数值类型或结构体,例如。你甚至可以包含另一个枚举!此外,标准库类型通常不会比你可能想出的复杂得多。

注意,即使标准库包含 IpAddr 的定义,我们仍然可以创建和使用我们自己的定义而不会冲突,因为我们没有将标准库的定义引入我们的作用域。我们将在第 7 章中详细讨论将类型引入作用域。

让我们看看 Listing 6-2 中的另一个枚举示例:这个枚举的变体中嵌入了多种类型。

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

这个枚举有四个变体,每个变体具有不同的类型:

  • Quit 没有任何关联的数据。
  • Move 有命名字段,就像结构体一样。
  • Write 包含一个 String
  • ChangeColor 包含三个 i32 值。

定义像 Listing 6-2 中这样的枚举变体类似于定义不同种类的结构体定义,只是枚举不使用 struct 关键字,并且所有变体都分组在 Message 类型下。以下结构体可以保存与前面枚举变体相同的数据:

struct QuitMessage; // unit struct
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct

fn main() {}

但如果我们使用不同的结构体,每个结构体都有自己的类型,我们就无法像使用 Listing 6-2 中定义的 Message 枚举那样轻松地定义一个函数来接受这些消息中的任何一种,因为 Message 是一个单一类型。

枚举和结构体之间还有一个相似之处:正如我们可以使用 impl 在结构体上定义方法一样,我们也可以在枚举上定义方法。这里有一个名为 call 的方法,我们可以在 Message 枚举上定义它:

fn main() {
    enum Message {
        Quit,
        Move { x: i32, y: i32 },
        Write(String),
        ChangeColor(i32, i32, i32),
    }

    impl Message {
        fn call(&self) {
            // method body would be defined here
        }
    }

    let m = Message::Write(String::from("hello"));
    m.call();
}

方法的主体将使用 self 来获取我们调用方法的值。在这个例子中,我们创建了一个变量 m,它的值是 Message::Write(String::from("hello")),这就是当 m.call() 运行时,call 方法主体中的 self 的值。

让我们看看标准库中另一个非常常见且有用的枚举:Option

Option 枚举及其相对于空值的优势

本节探讨了 Option 的案例研究,这是标准库定义的另一个枚举。Option 类型编码了一个非常常见的场景,即一个值可能是某个东西,也可能是空。

例如,如果你请求一个非空列表中的第一个项目,你会得到一个值。如果你请求一个空列表中的第一个项目,你会得到空。在类型系统中表达这个概念意味着编译器可以检查你是否处理了所有应该处理的情况;这个功能可以防止其他编程语言中非常常见的错误。

编程语言设计通常被认为是你包含哪些特性,但你排除的特性也很重要。Rust 没有许多其他语言中存在的空值特性。是一个表示没有值的值。在有空值的语言中,变量总是处于两种状态之一:空或非空。

在 2009 年的演讲“空引用:十亿美元的错误”中,空的发明者 Tony Hoare 这样说:

我称之为我的十亿美元错误。当时,我正在为面向对象语言中的引用设计第一个全面的类型系统。我的目标是确保所有引用的使用都应该是绝对安全的,由编译器自动执行检查。但我无法抗拒放入空引用的诱惑,仅仅因为它太容易实现了。这导致了无数的错误、漏洞和系统崩溃,可能在过去的四十年中造成了十亿美元的痛苦和损失。

空值的问题在于,如果你尝试将空值用作非空值,你会得到某种错误。因为这种空或非空的属性无处不在,所以很容易犯这种错误。

然而,空试图表达的概念仍然是有用的:空是一个由于某种原因当前无效或缺失的值。

问题并不在于概念本身,而在于特定的实现。因此,Rust 没有空值,但它有一个枚举可以编码值存在或缺失的概念。这个枚举是 Option<T>,它由标准库定义如下:

#![allow(unused)]
fn main() {
enum Option<T> {
    None,
    Some(T),
}
}

Option<T> 枚举非常有用,以至于它甚至被包含在预导入模块中;你不需要显式地将其引入作用域。它的变体也被包含在预导入模块中:你可以直接使用 SomeNone,而不需要 Option:: 前缀。Option<T> 枚举仍然只是一个常规的枚举,Some(T)None 仍然是 Option<T> 类型的变体。

<T> 语法是 Rust 的一个特性,我们还没有讨论过。它是一个泛型类型参数,我们将在第 10 章中更详细地介绍泛型。现在,你只需要知道 <T> 意味着 Option 枚举的 Some 变体可以保存一个任意类型的数据,并且每个用于替换 T 的具体类型都会使整个 Option<T> 类型成为不同的类型。以下是一些使用 Option 值来保存数字类型和字符类型的示例:

fn main() {
    let some_number = Some(5);
    let some_char = Some('e');

    let absent_number: Option<i32> = None;
}

some_number 的类型是 Option<i32>some_char 的类型是 Option<char>,这是一个不同的类型。Rust 可以推断这些类型,因为我们在 Some 变体中指定了一个值。对于 absent_number,Rust 要求我们注释整个 Option 类型:编译器无法仅通过查看 None 值来推断相应的 Some 变体将保存的类型。在这里,我们告诉 Rust 我们希望 absent_numberOption<i32> 类型。

当我们有一个 Some 值时,我们知道一个值是存在的,并且该值保存在 Some 中。当我们有一个 None 值时,在某种意义上它与空值相同:我们没有有效的值。那么为什么拥有 Option<T> 比拥有空值更好呢?

简而言之,因为 Option<T>T(其中 T 可以是任何类型)是不同的类型,编译器不会让我们像使用一个确定有效的值那样使用 Option<T> 值。例如,这段代码不会编译,因为它试图将一个 i8 添加到一个 Option<i8>

fn main() {
    let x: i8 = 5;
    let y: Option<i8> = Some(5);

    let sum = x + y;
}

如果我们运行这段代码,我们会得到类似这样的错误信息:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
 --> src/main.rs:5:17
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + Option<i8>`
  |
  = help: the trait `Add<Option<i8>>` is not implemented for `i8`
  = help: the following other types implement trait `Add<Rhs>`:
            `&i8` implements `Add<i8>`
            `&i8` implements `Add`
            `i8` implements `Add<&i8>`
            `i8` implements `Add`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` (bin "enums") due to 1 previous error

激烈!实际上,这个错误信息意味着 Rust 不知道如何将 i8Option<i8> 相加,因为它们是不同的类型。当我们在 Rust 中有一个像 i8 这样的类型的值时,编译器将确保我们总是有一个有效的值。我们可以自信地继续,而不必在使用该值之前检查空值。只有当我们有一个 Option<i8>(或我们正在处理的任何类型的值)时,我们才需要担心可能没有值,编译器将确保我们在使用该值之前处理这种情况。

换句话说,你必须将 Option<T> 转换为 T,然后才能对其执行 T 操作。通常,这有助于捕捉与空值相关的最常见问题之一:假设某些东西不是空值,而实际上它是。

消除错误假设非空值的风险有助于你在代码中更加自信。为了拥有一个可能为空的值,你必须通过将该值的类型设为 Option<T> 来显式选择加入。然后,当你使用该值时,你必须显式处理值为空的情况。在任何值类型不是 Option<T> 的地方,你可以安全地假设该值不是空值。这是 Rust 为了限制空值的普遍性并增加 Rust 代码的安全性而做出的有意设计决策。

那么当你有一个 Option<T> 类型的值时,如何从 Some 变体中获取 T 值以便使用该值呢?Option<T> 枚举有大量在各种情况下有用的方法;你可以在其文档中查看它们。熟悉 Option<T> 上的方法将在你使用 Rust 的过程中非常有用。

通常,为了使用 Option<T> 值,你希望有一些代码来处理每个变体。你希望有一些代码只在你有 Some(T) 值时运行,并且这段代码可以使用内部的 T。你希望有一些其他代码只在你有 None 值时运行,并且这段代码没有可用的 T 值。match 表达式是一个控制流结构,当与枚举一起使用时,它正好能做到这一点:它将根据枚举的变体运行不同的代码,并且该代码可以使用匹配值内部的数据。

match 控制流结构

Rust 有一个非常强大的控制流结构,称为 match,它允许你将一个值与一系列模式进行比较,然后根据匹配的模式执行代码。模式可以由字面值、变量名、通配符和许多其他内容组成;第 19 章 涵盖了所有不同类型的模式及其作用。match 的强大之处在于模式的表达能力以及编译器确保所有可能的情况都得到处理。

你可以将 match 表达式想象成一个硬币分拣机:硬币沿着轨道滑下,轨道上有各种大小的孔,每个硬币会从它遇到的第一个合适的孔中掉下去。同样地,值会依次通过 match 中的每个模式,并在第一个匹配的模式处“掉入”相关的代码块中执行。

说到硬币,让我们以它们为例使用 match!我们可以编写一个函数,该函数接受一个未知的美国硬币,并以类似于计数机的方式确定它是哪种硬币,并返回其价值(以美分为单位),如 Listing 6-3 所示。

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {}

让我们分解一下 value_in_cents 函数中的 match。首先我们列出 match 关键字,后跟一个表达式,在这个例子中是值 coin。这看起来与 if 使用的条件表达式非常相似,但有一个很大的区别:对于 if,条件需要求值为布尔值,但在这里它可以是任何类型。在这个例子中,coin 的类型是我们在第一行定义的 Coin 枚举。

接下来是 match 的分支。一个分支有两个部分:一个模式和一些代码。这里的第一个分支有一个模式,即值 Coin::Penny,然后是 => 运算符,它将模式与要运行的代码分开。在这个例子中,代码只是值 1。每个分支之间用逗号分隔。

match 表达式执行时,它会按顺序将结果值与每个分支的模式进行比较。如果模式与值匹配,则执行与该模式关联的代码。如果该模式不匹配值,则继续执行下一个分支,就像在硬币分拣机中一样。我们可以根据需要拥有任意数量的分支:在 Listing 6-3 中,我们的 match 有四个分支。

每个分支关联的代码是一个表达式,匹配分支中的表达式的结果值就是整个 match 表达式返回的值。

如果匹配分支的代码很短,我们通常不使用花括号,如 Listing 6-3 中每个分支只返回一个值的情况。如果你想在匹配分支中运行多行代码,则必须使用花括号,并且分支后的逗号是可选的。例如,以下代码在每次使用 Coin::Penny 调用方法时打印“Lucky penny!”,但仍然返回块的最后一个值 1

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => {
            println!("Lucky penny!");
            1
        }
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {}

绑定值的模式

匹配分支的另一个有用特性是它们可以绑定到与模式匹配的值的部分。这就是我们如何从枚举变体中提取值的方式。

举个例子,让我们更改一个枚举变体以在其中保存数据。从 1999 年到 2008 年,美国铸造了 25 美分硬币,每枚硬币的一面都有 50 个州的不同设计。没有其他硬币有州设计,所以只有 25 美分硬币有这个额外的价值。我们可以通过更改 Quarter 变体以包含一个 UsState 值来将此信息添加到我们的 enum 中,如 Listing 6-4 所示。

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {}

让我们想象一下,一个朋友正在尝试收集所有 50 个州的 25 美分硬币。当我们按硬币类型分类零钱时,我们还会喊出与每个 25 美分硬币相关的州名,这样如果我们的朋友没有这个州的硬币,他们可以将其添加到他们的收藏中。

在这段代码的 match 表达式中,我们向匹配 Coin::Quarter 变体值的模式添加了一个名为 state 的变量。当 Coin::Quarter 匹配时,state 变量将绑定到该 25 美分硬币的州值。然后我们可以在该分支的代码中使用 state,如下所示:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("State quarter from {state:?}!");
            25
        }
    }
}

fn main() {
    value_in_cents(Coin::Quarter(UsState::Alaska));
}

如果我们调用 value_in_cents(Coin::Quarter(UsState::Alaska))coin 将是 Coin::Quarter(UsState::Alaska)。当我们将该值与每个匹配分支进行比较时,直到我们到达 Coin::Quarter(state) 时才会匹配。此时,state 的绑定将是值 UsState::Alaska。然后我们可以在 println! 表达式中使用该绑定,从而从 QuarterCoin 枚举变体中提取出内部的州值。

使用 Option<T> 进行匹配

在上一节中,我们希望在处理 Option<T> 时从 Some 情况中提取出内部的 T 值;我们也可以像处理 Coin 枚举一样使用 match 来处理 Option<T>!我们不再比较硬币,而是比较 Option<T> 的变体,但 match 表达式的工作方式保持不变。

假设我们想编写一个函数,该函数接受一个 Option<i32>,如果其中有值,则将该值加 1。如果其中没有值,函数应返回 None 值,并且不尝试执行任何操作。

这个函数非常容易编写,多亏了 match,它将如 Listing 6-5 所示。

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

让我们更详细地检查 plus_one 的第一次执行。当我们调用 plus_one(five) 时,plus_one 函数体中的变量 x 将具有值 Some(5)。然后我们将其与每个匹配分支进行比较:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Some(5) 值与模式 None 不匹配,所以我们继续到下一个分支:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Some(5) 是否匹配 Some(i)?是的!我们有相同的变体。i 绑定到 Some 中包含的值,因此 i 取值为 5。然后执行匹配分支中的代码,因此我们将 i 的值加 1,并创建一个新的 Some 值,其中包含我们的总和 6

现在让我们考虑 Listing 6-5 中 plus_one 的第二次调用,其中 xNone。我们进入 match 并与第一个分支进行比较:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

它匹配!没有值可以加,因此程序停止并返回 => 右侧的 None 值。因为第一个分支匹配,所以不再比较其他分支。

match 和枚举结合在许多情况下都很有用。你会在 Rust 代码中经常看到这种模式:对枚举进行 match,将变量绑定到内部数据,然后根据它执行代码。一开始可能有点棘手,但一旦你习惯了,你会希望所有语言都有这个功能。它一直是用户的最爱。

匹配是穷尽的

我们需要讨论 match 的另一个方面:分支的模式必须覆盖所有可能性。考虑这个版本的 plus_one 函数,它有一个错误并且不会编译:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

我们没有处理 None 情况,因此这段代码会导致错误。幸运的是,Rust 知道如何捕获这个错误。如果我们尝试编译这段代码,我们会得到这个错误:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0004]: non-exhaustive patterns: `None` not covered
   --> src/main.rs:3:15
    |
3   |         match x {
    |               ^ pattern `None` not covered
    |
note: `Option<i32>` defined here
   --> file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/option.rs:572:1
    |
572 | pub enum Option<T> {
    | ^^^^^^^^^^^^^^^^^^
...
576 |     None,
    |     ---- not covered
    = note: the matched value is of type `Option<i32>`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
    |
4   ~             Some(i) => Some(i + 1),
5   ~             None => todo!(),
    |

For more information about this error, try `rustc --explain E0004`.
error: could not compile `enums` (bin "enums") due to 1 previous error

Rust 知道我们没有覆盖所有可能的情况,甚至知道我们忘记了哪个模式!Rust 中的匹配是穷尽的:我们必须穷尽所有可能性才能使代码有效。特别是在 Option<T> 的情况下,当 Rust 阻止我们忘记显式处理 None 情况时,它保护我们免于假设我们有值而实际上可能为空,从而使之前讨论的十亿美元错误成为不可能。

通配模式和 _ 占位符

使用枚举,我们还可以对几个特定值采取特殊操作,但对所有其他值采取一个默认操作。想象我们正在实现一个游戏,如果你掷出 3,你的玩家不会移动,而是获得一顶新帽子。如果你掷出 7,你的玩家会失去一顶帽子。对于所有其他值,你的玩家会在游戏板上移动相应数量的空格。以下是一个实现该逻辑的 match,其中骰子掷出的结果是硬编码的,而不是随机值,所有其他逻辑由没有函数体的函数表示,因为实际实现它们超出了本示例的范围:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        other => move_player(other),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
    fn move_player(num_spaces: u8) {}
}

对于前两个分支,模式是字面值 37。对于覆盖所有其他可能值的最后一个分支,模式是我们选择命名为 other 的变量。为 other 分支运行的代码通过将其传递给 move_player 函数来使用该变量。

这段代码可以编译,即使我们没有列出 u8 的所有可能值,因为最后一个模式将匹配所有未明确列出的值。这个通配模式满足了 match 必须是穷尽的要求。请注意,我们必须将通配分支放在最后,因为模式是按顺序评估的。如果我们把通配分支放在前面,其他分支将永远不会运行,所以如果我们添加分支到通配分支之后,Rust 会警告我们!

Rust 还有一个模式,当我们想要一个通配但不希望在通配模式中使用该值时可以使用:_ 是一个特殊的模式,它匹配任何值并且不绑定到该值。这告诉 Rust 我们不会使用该值,因此 Rust 不会警告我们有关未使用的变量。

让我们改变游戏的规则:现在,如果你掷出 3 或 7 以外的任何值,你必须重新掷骰子。我们不再需要使用通配值,因此我们可以将代码更改为使用 _ 而不是名为 other 的变量:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        _ => reroll(),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
    fn reroll() {}
}

这个示例也满足了穷尽性要求,因为我们在最后一个分支中明确忽略了所有其他值;我们没有忘记任何东西。

最后,我们再次改变游戏的规则,使得如果你掷出 3 或 7 以外的任何值,你的回合不会发生任何事情。我们可以通过使用单元值(我们在“元组类型” 部分提到的空元组类型)作为与 _ 分支关联的代码来表达这一点:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        _ => (),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
}

在这里,我们明确告诉 Rust,我们不会使用任何不匹配前面分支模式的值,并且在这种情况下我们不希望运行任何代码。

关于模式和匹配的更多内容,我们将在第 19 章 中介绍。现在,我们将继续讨论 if let 语法,它在 match 表达式有点冗长的情况下非常有用。

使用 if letlet else 简化控制流

if let 语法允许你将 iflet 结合成一个更简洁的方式来处理匹配某个模式的值,而忽略其他值。考虑 Listing 6-6 中的程序,它匹配 config_max 变量中的 Option<u8> 值,但只希望在值为 Some 变体时执行代码。

fn main() {
    let config_max = Some(3u8);
    match config_max {
        Some(max) => println!("The maximum is configured to be {max}"),
        _ => (),
    }
}

如果值是 Some,我们通过将值绑定到模式中的变量 max 来打印 Some 变体中的值。我们不想对 None 值做任何处理。为了满足 match 表达式,我们必须在处理一个变体后添加 _ => (),这是令人烦恼的样板代码。

相反,我们可以使用 if let 以更短的方式编写这段代码。以下代码的行为与 Listing 6-6 中的 match 相同:

fn main() {
    let config_max = Some(3u8);
    if let Some(max) = config_max {
        println!("The maximum is configured to be {max}");
    }
}

if let 语法接受一个模式和一个由等号分隔的表达式。它的工作方式与 match 相同,其中表达式被传递给 match,而模式是其第一个分支。在这种情况下,模式是 Some(max),而 max 绑定到 Some 内部的值。然后我们可以在 if let 块的主体中使用 max,就像我们在相应的 match 分支中使用 max 一样。if let 块中的代码仅在值匹配模式时运行。

使用 if let 意味着更少的输入、更少的缩进和更少的样板代码。然而,你失去了 match 强制执行的穷尽性检查。在 matchif let 之间选择取决于你在特定情况下的操作,以及获得简洁性是否是对失去穷尽性检查的适当权衡。

换句话说,你可以将 if let 视为 match 的语法糖,当值匹配一个模式时运行代码,然后忽略所有其他值。

我们可以在 if let 中包含一个 else。与 else 关联的代码块与 match 表达式中与 _ 情况关联的代码块相同,该 match 表达式等同于 if letelse。回想一下 Listing 6-4 中的 Coin 枚举定义,其中 Quarter 变体还包含一个 UsState 值。如果我们想要计算所有非 Quarter 硬币的同时还宣布 Quarter 的状态,我们可以使用 match 表达式,如下所示:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    match coin {
        Coin::Quarter(state) => println!("State quarter from {state:?}!"),
        _ => count += 1,
    }
}

或者我们可以使用 if letelse 表达式,如下所示:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    if let Coin::Quarter(state) = coin {
        println!("State quarter from {state:?}!");
    } else {
        count += 1;
    }
}

使用 let...else 保持在“快乐路径”上

一个常见的模式是在值存在时执行一些计算,否则返回默认值。继续我们带有 UsState 值的硬币示例,如果我们想根据 Quarter 上的州的年龄说一些有趣的话,我们可能会在 UsState 上引入一个方法来检查州的年龄,如下所示:

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    if let Coin::Quarter(state) = coin {
        if state.existed_in(1900) {
            Some(format!("{state:?} is pretty old, for America!"))
        } else {
            Some(format!("{state:?} is relatively new."))
        }
    } else {
        None
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

然后我们可能会使用 if let 来匹配硬币的类型,在条件的主体中引入一个 state 变量,如 Listing 6-7 所示。

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    if let Coin::Quarter(state) = coin {
        if state.existed_in(1900) {
            Some(format!("{state:?} is pretty old, for America!"))
        } else {
            Some(format!("{state:?} is relatively new."))
        }
    } else {
        None
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

这完成了任务,但它将工作推到了 if let 语句的主体中,如果要完成的工作更复杂,可能很难准确跟踪顶级分支之间的关系。我们还可以利用表达式产生值的事实,从 if let 中产生 state 或提前返回,如 Listing 6-8 所示。(你也可以用 match 做类似的事情。)

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    let state = if let Coin::Quarter(state) = coin {
        state
    } else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} is pretty old, for America!"))
    } else {
        Some(format!("{state:?} is relatively new."))
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

不过,这种方式也有点令人烦恼!if let 的一个分支产生一个值,而另一个分支则完全从函数返回。

为了使这种常见模式更易于表达,Rust 提供了 let...elselet...else 语法在左侧接受一个模式,在右侧接受一个表达式,与 if let 非常相似,但它没有 if 分支,只有一个 else 分支。如果模式匹配,它将把模式中的值绑定到外部作用域。如果模式不匹配,程序将进入 else 分支,该分支必须从函数返回。

在 Listing 6-9 中,你可以看到当使用 let...else 代替 if let 时,Listing 6-8 的样子。注意,它保持在函数主体的“快乐路径”上,而不像 if let 那样为两个分支显著不同的控制流。

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    let Coin::Quarter(state) = coin else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} is pretty old, for America!"))
    } else {
        Some(format!("{state:?} is relatively new."))
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

如果你遇到程序逻辑过于冗长而无法使用 match 表达的情况,请记住 if letlet...else 也在你的 Rust 工具箱中。

总结

我们现在已经介绍了如何使用枚举创建可以是一组枚举值之一的自定义类型。我们已经展示了标准库的 Option<T> 类型如何帮助你使用类型系统来防止错误。当枚举值内部包含数据时,你可以使用 matchif let 来提取和使用这些值,具体取决于你需要处理多少种情况。

你的 Rust 程序现在可以使用结构体和枚举来表达领域中的概念。创建自定义类型以在你的 API 中使用可以确保类型安全:编译器将确保你的函数只获取每个函数期望的类型的值。

为了向用户提供一个组织良好、易于使用且仅暴露用户所需内容的 API,我们现在转向 Rust 的模块。

使用包、Crate 和模块管理不断增长的项目

随着你编写大型程序,组织代码将变得越来越重要。通过将相关功能分组并将具有不同特性的代码分开,你将明确在哪里可以找到实现特定功能的代码,以及在哪里可以更改功能的工作方式。

到目前为止,我们编写的程序都位于一个文件中的一个模块中。随着项目的增长,你应该通过将代码拆分为多个模块,然后拆分为多个文件来组织代码。一个包可以包含多个二进制 crate 和一个可选的库 crate。随着包的增长,你可以将部分内容提取到单独的 crate 中,这些 crate 成为外部依赖项。本章将涵盖所有这些技术。对于由一组相互关联的包组成的大型项目,Cargo 提供了 工作空间,我们将在第 14 章的 “Cargo 工作空间” 中介绍。

我们还将讨论封装实现细节,这使你可以更高级别地重用代码:一旦你实现了一个操作,其他代码可以通过其公共接口调用你的代码,而无需了解实现的工作原理。你编写代码的方式定义了哪些部分是对其他代码公开的,哪些部分是保留更改权利的私有实现细节。这是另一种限制你需要记住的细节数量的方法。

一个相关的概念是作用域:代码编写的嵌套上下文有一组被定义为“在作用域内”的名称。在阅读、编写和编译代码时,程序员和编译器需要知道特定位置的特定名称是指变量、函数、结构体、枚举、模块、常量还是其他项,以及该项的含义。你可以创建作用域并更改哪些名称在作用域内或外。你不能在同一个作用域内有两个同名的项;有工具可以解决名称冲突。

Rust 有许多功能可以让你管理代码的组织方式,包括哪些细节是公开的,哪些细节是私有的,以及程序中每个作用域内的名称。这些功能有时统称为 模块系统,包括:

  • 包(Packages): Cargo 的一个功能,允许你构建、测试和共享 crate
  • Crate: 生成库或可执行文件的模块树
  • 模块(Modules)use: 让你控制路径的组织、作用域和隐私
  • 路径(Paths): 命名项(如结构体、函数或模块)的方式

在本章中,我们将涵盖所有这些功能,讨论它们如何相互作用,并解释如何使用它们来管理作用域。到最后,你应该对模块系统有扎实的理解,并能够像专业人士一样处理作用域!

包和Crate

我们将首先介绍模块系统中的包(packages)和crate。

一个 crate 是Rust编译器一次处理的最小代码单位。即使你运行的是 rustc 而不是 cargo,并且传递了一个单独的源代码文件(就像我们在第1章的“编写和运行Rust程序”中所做的那样),编译器也会将该文件视为一个crate。Crate可以包含模块,而这些模块可能定义在与crate一起编译的其他文件中,我们将在接下来的章节中看到这一点。

一个crate可以有两种形式之一:二进制crate或库crate。二进制crate 是可以编译成可执行文件的程序,例如命令行程序或服务器。每个二进制crate必须有一个名为 main 的函数,用于定义可执行文件运行时的行为。我们到目前为止创建的所有crate都是二进制crate。

库crate 没有 main 函数,也不会编译成可执行文件。相反,它们定义了旨在与多个项目共享的功能。例如,我们在第2章中使用的 rand crate提供了生成随机数的功能。大多数情况下,当Rustaceans提到“crate”时,他们指的是库crate,并且他们将“crate”与“库”这一通用编程概念互换使用。

crate根 是Rust编译器开始处理的源文件,它构成了你的crate的根模块(我们将在“定义模块以控制作用域和隐私”中深入解释模块)。

一个 是一个或多个crate的集合,提供一组功能。一个包包含一个 Cargo.toml 文件,该文件描述了如何构建这些crate。Cargo实际上是一个包,其中包含了你一直用来构建代码的命令行工具的二进制crate。Cargo包还包含一个库crate,二进制crate依赖于这个库crate。其他项目可以依赖于Cargo库crate,以使用Cargo命令行工具所使用的相同逻辑。

一个包可以包含任意数量的二进制crate,但最多只能包含一个库crate。一个包必须至少包含一个crate,无论是库crate还是二进制crate。

让我们来看看当我们创建一个包时会发生什么。首先我们输入命令 cargo new my-project

$ cargo new my-project
     Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs

在我们运行 cargo new my-project 之后,我们使用 ls 查看Cargo创建的内容。在项目目录中,有一个 Cargo.toml 文件,这表示我们有一个包。还有一个 src 目录,其中包含 main.rs。在你的文本编辑器中打开 Cargo.toml,注意其中没有提到 src/main.rs。Cargo遵循一个约定,即 src/main.rs 是与包同名的二进制crate的crate根。同样,Cargo知道如果包目录包含 src/lib.rs,则该包包含一个与包同名的库crate,并且 src/lib.rs 是其crate根。Cargo将crate根文件传递给 rustc 以构建库或二进制文件。

在这里,我们有一个只包含 src/main.rs 的包,这意味着它只包含一个名为 my-project 的二进制crate。如果一个包包含 src/main.rssrc/lib.rs,那么它有两个crate:一个二进制crate和一个库crate,两者都与包同名。一个包可以通过将文件放在 src/bin 目录中来拥有多个二进制crate:每个文件将是一个单独的二进制crate。

定义模块以控制作用域和隐私

在本节中,我们将讨论模块以及模块系统的其他部分,即 路径,它允许你为项命名;use 关键字,它将路径引入作用域;以及 pub 关键字,用于使项公开。我们还将讨论 as 关键字、外部包和 glob 操作符。

模块速查表

在我们深入了解模块和路径的细节之前,这里我们提供了一个关于模块、路径、use 关键字和 pub 关键字在编译器中如何工作的快速参考,以及大多数开发者如何组织他们的代码。我们将在本章中通过示例逐一解释这些规则,但这是一个很好的地方,可以作为模块如何工作的提醒。

  • 从 crate 根开始:当编译一个 crate 时,编译器首先在 crate 根文件(通常是库 crate 的 src/lib.rs 或二进制 crate 的 src/main.rs)中查找要编译的代码。
  • 声明模块:在 crate 根文件中,你可以声明新模块;假设你声明了一个“garden”模块,使用 mod garden;。编译器将在以下位置查找模块的代码:
    • 内联,在替换 mod garden 后面的分号的花括号内
    • 在文件 src/garden.rs
    • 在文件 src/garden/mod.rs
  • 声明子模块:在 crate 根文件之外的任何文件中,你可以声明子模块。例如,你可以在 src/garden.rs 中声明 mod vegetables;。编译器将在父模块命名的目录中的以下位置查找子模块的代码:
    • 内联,直接在 mod vegetables 后面,使用花括号而不是分号
    • 在文件 src/garden/vegetables.rs
    • 在文件 src/garden/vegetables/mod.rs
  • 模块中代码的路径:一旦模块成为你的 crate 的一部分,只要隐私规则允许,你就可以使用路径从该 crate 的其他任何地方引用该模块中的代码。例如,garden vegetables 模块中的 Asparagus 类型可以在 crate::garden::vegetables::Asparagus 找到。
  • 私有 vs. 公开:模块中的代码默认对其父模块是私有的。要使模块公开,请使用 pub mod 而不是 mod 来声明它。要使公共模块中的项也公开,请在它们的声明前使用 pub
  • use 关键字:在作用域内,use 关键字创建项的快捷方式,以减少长路径的重复。在任何可以引用 crate::garden::vegetables::Asparagus 的作用域中,你可以使用 use crate::garden::vegetables::Asparagus; 创建一个快捷方式,然后你只需要写 Asparagus 就可以在该作用域中使用该类型。

在这里,我们创建了一个名为 backyard 的二进制 crate,以说明这些规则。crate 的目录,也命名为 backyard,包含以下文件和目录:

backyard
├── Cargo.lock
├── Cargo.toml
└── src
    ├── garden
    │   └── vegetables.rs
    ├── garden.rs
    └── main.rs

在这种情况下,crate 根文件是 src/main.rs,它包含以下内容:

use crate::garden::vegetables::Asparagus;

pub mod garden;

fn main() {
    let plant = Asparagus {};
    println!("I'm growing {plant:?}!");
}

pub mod garden; 行告诉编译器包含它在 src/garden.rs 中找到的代码,即:

pub mod vegetables;

在这里,pub mod vegetables; 意味着 src/garden/vegetables.rs 中的代码也被包含。该代码是:

#[derive(Debug)]
pub struct Asparagus {}

现在让我们深入了解这些规则,并演示它们的实际应用!

在模块中分组相关代码

模块 让我们可以在 crate 内组织代码以提高可读性和易于重用。模块还允许我们控制项的 隐私,因为模块中的代码默认是私有的。私有项是内部实现细节,不对外部使用。我们可以选择使模块及其中的项公开,从而允许外部代码使用和依赖它们。

作为一个例子,让我们编写一个提供餐厅功能的库 crate。我们将定义函数的签名,但将其主体留空,以专注于代码的组织而不是餐厅的实现。

在餐饮业中,餐厅的某些部分被称为 前厅,其他部分被称为 后厨。前厅是顾客所在的地方;这包括接待员安排顾客座位、服务员接受订单和付款以及调酒师制作饮料的地方。后厨是厨师和厨师在厨房工作、洗碗工清理和管理员进行行政工作的地方。

为了以这种方式构建我们的 crate,我们可以将其功能组织到嵌套模块中。通过运行 cargo new restaurant --lib 创建一个名为 restaurant 的新库。然后将代码清单 7-1 中的代码输入到 src/lib.rs 中,以定义一些模块和函数签名;这段代码是前厅部分。

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}

        fn seat_at_table() {}
    }

    mod serving {
        fn take_order() {}

        fn serve_order() {}

        fn take_payment() {}
    }
}

我们使用 mod 关键字后跟模块名称(在本例中为 front_of_house)来定义一个模块。模块的主体然后放在花括号内。在模块内部,我们可以放置其他模块,如本例中的 hostingserving 模块。模块还可以包含其他项的定义,例如结构体、枚举、常量、特性以及如代码清单 7-1 中的函数。

通过使用模块,我们可以将相关的定义分组并命名它们之间的关系。使用此代码的程序员可以根据组导航代码,而不必阅读所有定义,从而更容易找到与他们相关的定义。向此代码添加新功能的程序员将知道将代码放在何处以保持程序的组织性。

之前,我们提到 src/main.rssrc/lib.rs 被称为 crate 根。它们之所以得名,是因为这两个文件中的任何一个的内容都形成了一个名为 crate 的模块,位于 crate 模块结构的根部,称为 模块树

代码清单 7-2 显示了代码清单 7-1 中结构的模块树。

crate
 └── front_of_house
     ├── hosting
     │   ├── add_to_waitlist
     │   └── seat_at_table
     └── serving
         ├── take_order
         ├── serve_order
         └── take_payment

这棵树显示了一些模块如何嵌套在其他模块中;例如,hosting 嵌套在 front_of_house 中。该树还显示了一些模块是 兄弟,这意味着它们在同一模块中定义;hostingserving 是在 front_of_house 中定义的兄弟。如果模块 A 包含在模块 B 中,我们说模块 A 是模块 B 的 子模块,模块 B 是模块 A 的 父模块。请注意,整个模块树都根植于名为 crate 的隐式模块下。

模块树可能会让你想起计算机上的文件系统目录树;这是一个非常恰当的比喻!就像文件系统中的目录一样,你使用模块来组织代码。就像目录中的文件一样,我们需要一种方法来找到我们的模块。

模块树中引用项的路径

为了告诉 Rust 在模块树中查找某个项的位置,我们使用路径,就像在文件系统中导航时使用路径一样。要调用一个函数,我们需要知道它的路径。

路径可以有两种形式:

  • 绝对路径 是从 crate 根开始的完整路径;对于外部 crate 的代码,绝对路径以 crate 名称开头,而对于当前 crate 的代码,它以字面量 crate 开头。
  • 相对路径 从当前模块开始,使用 selfsuper 或当前模块中的标识符。

绝对路径和相对路径后面都跟着一个或多个由双冒号 (::) 分隔的标识符。

回到 Listing 7-1,假设我们想调用 add_to_waitlist 函数。这相当于问:add_to_waitlist 函数的路径是什么?Listing 7-3 包含了 Listing 7-1,但删除了一些模块和函数。

我们将展示两种从 crate 根定义的新函数 eat_at_restaurant 中调用 add_to_waitlist 函数的方法。这些路径是正确的,但还有一个问题会阻止这个示例按原样编译。我们稍后会解释原因。

eat_at_restaurant 函数是我们库 crate 的公共 API 的一部分,因此我们用 pub 关键字标记它。在 “使用 pub 关键字暴露路径” 部分,我们将详细介绍 pub

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}

eat_at_restaurant 中第一次调用 add_to_waitlist 函数时,我们使用了绝对路径。add_to_waitlist 函数与 eat_at_restaurant 定义在同一个 crate 中,这意味着我们可以使用 crate 关键字来开始绝对路径。然后我们依次包含每个后续模块,直到找到 add_to_waitlist。你可以想象一个具有相同结构的文件系统:我们会指定路径 /front_of_house/hosting/add_to_waitlist 来运行 add_to_waitlist 程序;使用 crate 名称从 crate 根开始,就像在 shell 中使用 / 从文件系统根开始一样。

eat_at_restaurant 中第二次调用 add_to_waitlist 时,我们使用了相对路径。路径以 front_of_house 开头,这是与 eat_at_restaurant 定义在同一模块树级别的模块名称。这里的文件系统等效路径是使用路径 front_of_house/hosting/add_to_waitlist。以模块名称开头意味着路径是相对的。

选择使用相对路径还是绝对路径是基于项目的决定,这取决于你是否更有可能将项定义代码与使用该项的代码分开移动或一起移动。例如,如果我们将 front_of_house 模块和 eat_at_restaurant 函数移动到一个名为 customer_experience 的模块中,我们需要更新 add_to_waitlist 的绝对路径,但相对路径仍然有效。然而,如果我们将 eat_at_restaurant 函数单独移动到一个名为 dining 的模块中,add_to_waitlist 调用的绝对路径将保持不变,但相对路径需要更新。我们通常倾向于指定绝对路径,因为我们更有可能希望独立地移动代码定义和项调用。

让我们尝试编译 Listing 7-3,看看为什么它还不能编译!我们得到的错误如 Listing 7-4 所示。

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: module `hosting` is private
 --> src/lib.rs:9:28
  |
9 |     crate::front_of_house::hosting::add_to_waitlist();
  |                            ^^^^^^^  --------------- function `add_to_waitlist` is not publicly re-exported
  |                            |
  |                            private module
  |
note: the module `hosting` is defined here
 --> src/lib.rs:2:5
  |
2 |     mod hosting {
  |     ^^^^^^^^^^^

error[E0603]: module `hosting` is private
  --> src/lib.rs:12:21
   |
12 |     front_of_house::hosting::add_to_waitlist();
   |                     ^^^^^^^  --------------- function `add_to_waitlist` is not publicly re-exported
   |                     |
   |                     private module
   |
note: the module `hosting` is defined here
  --> src/lib.rs:2:5
   |
2  |     mod hosting {
   |     ^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` (lib) due to 2 previous errors

错误信息说 hosting 模块是私有的。换句话说,我们有 hosting 模块和 add_to_waitlist 函数的正确路径,但 Rust 不允许我们使用它们,因为它无法访问私有部分。在 Rust 中,所有项(函数、方法、结构体、枚举、模块和常量)默认情况下对父模块是私有的。如果你想使一个项(如函数或结构体)私有,你可以将其放在一个模块中。

父模块中的项不能使用子模块中的私有项,但子模块中的项可以使用其祖先模块中的项。这是因为子模块封装并隐藏了它们的实现细节,但子模块可以看到它们定义的上下文。继续我们的比喻,将隐私规则想象成餐厅的后台:那里发生的事情对餐厅顾客是私有的,但办公室经理可以看到并操作他们经营的餐厅中的一切。

Rust 选择让模块系统以这种方式工作,以便隐藏内部实现细节是默认行为。这样,你就知道可以更改内部代码的哪些部分而不会破坏外部代码。然而,Rust 确实给了你通过使用 pub 关键字将子模块代码的内部部分暴露给外部祖先模块的选项。

使用 pub 关键字暴露路径

让我们回到 Listing 7-4 中的错误,它告诉我们 hosting 模块是私有的。我们希望父模块中的 eat_at_restaurant 函数能够访问子模块中的 add_to_waitlist 函数,因此我们用 pub 关键字标记 hosting 模块,如 Listing 7-5 所示。

mod front_of_house {
    pub mod hosting {
        fn add_to_waitlist() {}
    }
}

// -- snip --
pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}

不幸的是,Listing 7-5 中的代码仍然导致编译器错误,如 Listing 7-6 所示。

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:10:37
   |
10 |     crate::front_of_house::hosting::add_to_waitlist();
   |                                     ^^^^^^^^^^^^^^^ private function
   |
note: the function `add_to_waitlist` is defined here
  --> src/lib.rs:3:9
   |
3  |         fn add_to_waitlist() {}
   |         ^^^^^^^^^^^^^^^^^^^^

error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:13:30
   |
13 |     front_of_house::hosting::add_to_waitlist();
   |                              ^^^^^^^^^^^^^^^ private function
   |
note: the function `add_to_waitlist` is defined here
  --> src/lib.rs:3:9
   |
3  |         fn add_to_waitlist() {}
   |         ^^^^^^^^^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` (lib) due to 2 previous errors

发生了什么?在 mod hosting 前面添加 pub 关键字使模块变为公共的。有了这个更改,如果我们能访问 front_of_house,我们就能访问 hosting。但 hosting内容仍然是私有的;使模块公共并不会使其内容变为公共。模块上的 pub 关键字只允许其祖先模块中的代码引用它,而不是访问其内部代码。因为模块是容器,仅使模块公共并不能做太多事情;我们需要进一步选择使模块中的一个或多个项也变为公共。

Listing 7-6 中的错误说 add_to_waitlist 函数是私有的。隐私规则适用于结构体、枚举、函数和方法以及模块。

让我们也通过在 add_to_waitlist 函数的定义前添加 pub 关键字使其变为公共的,如 Listing 7-7 所示。

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

// -- snip --
pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}

现在代码将编译!为了了解为什么添加 pub 关键字允许我们在 eat_at_restaurant 中使用这些路径,让我们看一下绝对路径和相对路径。

在绝对路径中,我们从 crate 开始,这是我们 crate 模块树的根。front_of_house 模块定义在 crate 根中。虽然 front_of_house 不是公共的,但因为 eat_at_restaurant 函数与 front_of_house 定义在同一个模块中(即 eat_at_restaurantfront_of_house 是兄弟),我们可以从 eat_at_restaurant 引用 front_of_house。接下来是标记为 pubhosting 模块。我们可以访问 hosting 的父模块,因此我们可以访问 hosting。最后,add_to_waitlist 函数标记为 pub,我们可以访问其父模块,因此这个函数调用是有效的!

在相对路径中,逻辑与绝对路径相同,除了第一步:路径不是从 crate 根开始,而是从 front_of_house 开始。front_of_house 模块与 eat_at_restaurant 定义在同一个模块中,因此从 eat_at_restaurant 定义的模块开始的相对路径是有效的。然后,因为 hostingadd_to_waitlist 标记为 pub,路径的其余部分也是有效的,这个函数调用是有效的!

如果你计划共享你的库 crate 以便其他项目可以使用你的代码,你的公共 API 是你与 crate 用户的合同,决定了他们如何与你的代码交互。有许多关于管理公共 API 更改的考虑,以使人们更容易依赖你的 crate。这些考虑超出了本书的范围;如果你对这个主题感兴趣,请参阅 The Rust API Guidelines

包含二进制文件和库的包的最佳实践

我们提到一个包可以同时包含一个 src/main.rs 二进制 crate 根和一个 src/lib.rs 库 crate 根,并且默认情况下两个 crate 都会使用包的名称。通常,包含库和二进制 crate 的包会在二进制 crate 中包含足够的代码来启动一个可执行文件,该可执行文件调用库 crate 中的代码。这使得其他项目可以受益于包提供的大部分功能,因为库 crate 的代码可以共享。

模块树应该在 src/lib.rs 中定义。然后,任何公共项都可以通过在路径前加上包的名称在二进制 crate 中使用。二进制 crate 成为库 crate 的用户,就像完全外部的 crate 使用库 crate 一样:它只能使用公共 API。这有助于你设计一个好的 API;你不仅是作者,你还是客户!

第 12 章 中,我们将通过一个包含二进制 crate 和库 crate 的命令行程序来演示这种组织实践。

使用 super 开始相对路径

我们可以通过在路径开头使用 super 来构造从父模块开始的相对路径,而不是从当前模块或 crate 根开始。这就像在文件系统路径中使用 .. 语法一样。使用 super 允许我们引用我们知道在父模块中的项,这可以使在模块树中重新排列模块时更容易,因为模块与父模块密切相关,但父模块可能有一天会被移动到模块树的其他位置。

考虑 Listing 7-8 中的代码,它模拟了厨师修复错误订单并亲自将其送到顾客手中的情况。back_of_house 模块中定义的 fix_incorrect_order 函数通过指定以 super 开头的路径调用父模块中定义的 deliver_order 函数。

fn deliver_order() {}

mod back_of_house {
    fn fix_incorrect_order() {
        cook_order();
        super::deliver_order();
    }

    fn cook_order() {}
}

fix_incorrect_order 函数在 back_of_house 模块中,因此我们可以使用 super 进入 back_of_house 的父模块,在本例中是 crate,即根。从那里,我们查找 deliver_order 并找到它。成功!我们认为 back_of_house 模块和 deliver_order 函数可能会保持彼此的关系,并在我们决定重新组织 crate 的模块树时一起移动。因此,我们使用了 super,以便将来如果这段代码被移动到不同的模块中,我们需要更新的代码会更少。

使结构体和枚举变为公共

我们也可以使用 pub 来将结构体和枚举标记为公共的,但在结构体和枚举中使用 pub 有一些额外的细节。如果我们在结构体定义前使用 pub,我们使结构体变为公共的,但结构体的字段仍然是私有的。我们可以逐个字段决定是否使其变为公共的。在 Listing 7-9 中,我们定义了一个公共的 back_of_house::Breakfast 结构体,其中 toast 字段是公共的,但 seasonal_fruit 字段是私有的。这模拟了餐厅中顾客可以选择餐点中的面包类型,但厨师根据季节和库存决定搭配的水果的情况。可用的水果变化很快,因此顾客不能选择水果,甚至看不到他们将得到哪种水果。

mod back_of_house {
    pub struct Breakfast {
        pub toast: String,
        seasonal_fruit: String,
    }

    impl Breakfast {
        pub fn summer(toast: &str) -> Breakfast {
            Breakfast {
                toast: String::from(toast),
                seasonal_fruit: String::from("peaches"),
            }
        }
    }
}

pub fn eat_at_restaurant() {
    // Order a breakfast in the summer with Rye toast.
    let mut meal = back_of_house::Breakfast::summer("Rye");
    // Change our mind about what bread we'd like.
    meal.toast = String::from("Wheat");
    println!("I'd like {} toast please", meal.toast);

    // The next line won't compile if we uncomment it; we're not allowed
    // to see or modify the seasonal fruit that comes with the meal.
    // meal.seasonal_fruit = String::from("blueberries");
}

因为 back_of_house::Breakfast 结构体中的 toast 字段是公共的,所以在 eat_at_restaurant 中我们可以使用点符号写入和读取 toast 字段。注意,我们不能在 eat_at_restaurant 中使用 seasonal_fruit 字段,因为 seasonal_fruit 是私有的。尝试取消注释修改 seasonal_fruit 字段值的行,看看你会得到什么错误!

另外,请注意,因为 back_of_house::Breakfast 有一个私有字段,结构体需要提供一个公共的关联函数来构造 Breakfast 的实例(我们在这里将其命名为 summer)。如果 Breakfast 没有这样的函数,我们就不能在 eat_at_restaurant 中创建 Breakfast 的实例,因为我们不能在 eat_at_restaurant 中设置私有字段 seasonal_fruit 的值。

相比之下,如果我们使枚举变为公共的,它的所有变体也会变为公共的。我们只需要在 enum 关键字前加上 pub,如 Listing 7-10 所示。

mod back_of_house {
    pub enum Appetizer {
        Soup,
        Salad,
    }
}

pub fn eat_at_restaurant() {
    let order1 = back_of_house::Appetizer::Soup;
    let order2 = back_of_house::Appetizer::Salad;
}

因为我们使 Appetizer 枚举变为公共的,所以我们可以在 eat_at_restaurant 中使用 SoupSalad 变体。

枚举的变体如果不公开就没有什么用处;如果必须在每种情况下都用 pub 注释所有枚举变体,那将非常烦人,因此枚举变体的默认行为是公开的。结构体通常在没有其字段公开的情况下也很有用,因此结构体字段遵循默认情况下所有内容都是私有的规则,除非用 pub 注释。

还有一种涉及 pub 的情况我们还没有介绍,这是我们最后一个模块系统特性:use 关键字。我们将首先单独介绍 use,然后展示如何将 pubuse 结合使用。

使用 use 关键字将路径引入作用域

每次调用函数时都写出路径可能会让人感到不便和重复。在 Listing 7-7 中,无论我们选择的是绝对路径还是相对路径来调用 add_to_waitlist 函数,每次我们想要调用 add_to_waitlist 时,都必须指定 front_of_househosting。幸运的是,有一种方法可以简化这个过程:我们可以使用 use 关键字一次性创建一个路径的快捷方式,然后在作用域内的其他地方使用这个更短的名字。

在 Listing 7-11 中,我们将 crate::front_of_house::hosting 模块引入到 eat_at_restaurant 函数的作用域中,这样我们只需要指定 hosting::add_to_waitlist 就可以在 eat_at_restaurant 中调用 add_to_waitlist 函数。

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}

在作用域中添加 use 和路径类似于在文件系统中创建一个符号链接。通过在 crate 根目录中添加 use crate::front_of_house::hostinghosting 现在在该作用域中是一个有效的名字,就像 hosting 模块被定义在 crate 根目录中一样。使用 use 引入作用域的路径也会像其他路径一样检查私有性。

需要注意的是,use 只会在其出现的作用域中创建快捷方式。Listing 7-12 将 eat_at_restaurant 函数移动到一个名为 customer 的新子模块中,这个子模块的作用域与 use 语句的作用域不同,因此函数体将无法编译。

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting;

mod customer {
    pub fn eat_at_restaurant() {
        hosting::add_to_waitlist();
    }
}

编译器错误显示,快捷方式在 customer 模块中不再有效:

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0433]: failed to resolve: use of undeclared crate or module `hosting`
  --> src/lib.rs:11:9
   |
11 |         hosting::add_to_waitlist();
   |         ^^^^^^^ use of undeclared crate or module `hosting`
   |
help: consider importing this module through its public re-export
   |
10 +     use crate::hosting;
   |

warning: unused import: `crate::front_of_house::hosting`
 --> src/lib.rs:7:5
  |
7 | use crate::front_of_house::hosting;
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

For more information about this error, try `rustc --explain E0433`.
warning: `restaurant` (lib) generated 1 warning
error: could not compile `restaurant` (lib) due to 1 previous error; 1 warning emitted

注意,还有一个警告提示 use 在其作用域中不再使用!要解决这个问题,可以将 use 也移动到 customer 模块中,或者在子模块 customer 中使用 super::hosting 引用父模块中的快捷方式。

创建惯用的 use 路径

在 Listing 7-11 中,你可能会好奇为什么我们指定了 use crate::front_of_house::hosting,然后在 eat_at_restaurant 中调用 hosting::add_to_waitlist,而不是直接将 use 路径一直延伸到 add_to_waitlist 函数,以达到相同的结果,如 Listing 7-13 所示。

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting::add_to_waitlist;

pub fn eat_at_restaurant() {
    add_to_waitlist();
}

尽管 Listing 7-11 和 Listing 7-13 完成了相同的任务,但 Listing 7-11 是使用 use 将函数引入作用域的惯用方式。使用 use 将函数的父模块引入作用域意味着我们在调用函数时必须指定父模块。在调用函数时指定父模块可以清楚地表明该函数不是在本地定义的,同时还能最大限度地减少完整路径的重复。Listing 7-13 中的代码不清楚 add_to_waitlist 是在哪里定义的。

另一方面,当使用 use 引入结构体、枚举和其他项时,惯用的做法是指定完整路径。Listing 7-14 展示了将标准库的 HashMap 结构体引入二进制 crate 作用域的惯用方式。

use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert(1, 2);
}

这种惯用方式背后没有特别的原因:这只是已经形成的惯例,人们已经习惯了以这种方式阅读和编写 Rust 代码。

这种惯用方式的例外情况是,如果我们使用 use 语句将两个同名的项引入作用域,因为 Rust 不允许这样做。Listing 7-15 展示了如何将两个同名但父模块不同的 Result 类型引入作用域,以及如何引用它们。

use std::fmt;
use std::io;

fn function1() -> fmt::Result {
    // --snip--
    Ok(())
}

fn function2() -> io::Result<()> {
    // --snip--
    Ok(())
}

如你所见,使用父模块可以区分这两个 Result 类型。如果我们指定 use std::fmt::Resultuse std::io::Result,我们将在同一作用域中有两个 Result 类型,Rust 将不知道我们使用 Result 时指的是哪一个。

使用 as 关键字提供新名称

使用 use 将两个同名类型引入同一作用域的另一种解决方案是:在路径之后,我们可以指定 as 和一个新的本地名称,或称为 别名。Listing 7-16 展示了通过使用 as 重命名其中一个 Result 类型来编写 Listing 7-15 中的代码的另一种方式。

use std::fmt::Result;
use std::io::Result as IoResult;

fn function1() -> Result {
    // --snip--
    Ok(())
}

fn function2() -> IoResult<()> {
    // --snip--
    Ok(())
}

在第二个 use 语句中,我们为 std::io::Result 类型选择了新名称 IoResult,这样它就不会与我们从 std::fmt 引入的 Result 冲突。Listing 7-15 和 Listing 7-16 都被认为是惯用的,所以选择权在你手中!

使用 pub use 重新导出名称

当我们使用 use 关键字将名称引入作用域时,新作用域中的名称是私有的。为了使调用我们代码的代码能够像在其作用域中定义的那样引用该名称,我们可以将 pubuse 结合起来。这种技术称为 重新导出,因为我们不仅将项引入作用域,还使其可供其他人引入他们的作用域。

Listing 7-17 展示了 Listing 7-11 中的代码,将根模块中的 use 改为 pub use

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}

在此更改之前,外部代码必须使用路径 restaurant::front_of_house::hosting::add_to_waitlist() 来调用 add_to_waitlist 函数,这也要求 front_of_house 模块被标记为 pub。现在,由于 pub use 从根模块重新导出了 hosting 模块,外部代码可以使用路径 restaurant::hosting::add_to_waitlist()

重新导出在你代码的内部结构与调用你代码的程序员对领域的思考方式不同时非常有用。例如,在这个餐厅的比喻中,经营餐厅的人会想到“前厅”和“后厨”。但来餐厅用餐的顾客可能不会用这些术语来思考餐厅的各个部分。通过 pub use,我们可以用一种结构编写代码,但暴露另一种结构。这样做可以使我们的库对编写库的程序员和调用库的程序员都组织良好。我们将在第 14 章的 “使用 pub use 导出方便的公共 API” 中看到另一个 pub use 的例子以及它如何影响你的 crate 文档。

使用外部包

在第 2 章中,我们编写了一个猜数字游戏项目,该项目使用了一个名为 rand 的外部包来获取随机数。要在我们的项目中使用 rand,我们在 Cargo.toml 中添加了这行代码:

rand = "0.8.5"

Cargo.toml 中添加 rand 作为依赖项会告诉 Cargo 从 crates.io 下载 rand 包及其所有依赖项,并使 rand 可用于我们的项目。

然后,为了将 rand 的定义引入我们的包的作用域,我们添加了一个以 crate 名称 rand 开头的 use 行,并列出了我们想要引入作用域的项。回想一下,在第 2 章的 “生成随机数” 中,我们将 Rng trait 引入作用域并调用了 rand::thread_rng 函数:

use std::io;
use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Rust 社区的成员已经在 crates.io 上提供了许多包,将其中任何一个引入你的包都涉及相同的步骤:在你的包的 Cargo.toml 文件中列出它们,并使用 use 将其 crate 中的项引入作用域。

需要注意的是,标准 std 库也是一个外部 crate。由于标准库是随 Rust 语言一起发布的,我们不需要更改 Cargo.toml 来包含 std。但我们确实需要使用 use 来将其中的项引入我们包的作用域。例如,对于 HashMap,我们会使用这行代码:

#![allow(unused)]
fn main() {
use std::collections::HashMap;
}

这是一个以 std 开头的绝对路径,std 是标准库 crate 的名称。

使用嵌套路径清理大量的 use 列表

如果我们使用同一 crate 或同一模块中定义的多个项,将每个项单独列在一行可能会占用文件中的大量垂直空间。例如,我们在 Listing 2-4 的猜数字游戏中有这两个 use 语句,将 std 中的项引入作用域:

use rand::Rng;
// --snip--
use std::cmp::Ordering;
use std::io;
// --snip--

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

相反,我们可以使用嵌套路径在一行中将相同的项引入作用域。我们通过指定路径的公共部分,后跟两个冒号,然后用花括号括起路径的不同部分来实现这一点,如 Listing 7-18 所示。

use rand::Rng;
// --snip--
use std::{cmp::Ordering, io};
// --snip--

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse().expect("Please type a number!");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

在较大的程序中,使用嵌套路径从同一 crate 或模块中引入许多项可以大大减少所需的单独 use 语句的数量!

我们可以在路径的任何级别使用嵌套路径,这在组合两个共享子路径的 use 语句时非常有用。例如,Listing 7-19 展示了两个 use 语句:一个将 std::io 引入作用域,另一个将 std::io::Write 引入作用域。

use std::io;
use std::io::Write;

这两个路径的公共部分是 std::io,这是第一个完整路径。要将这两个路径合并为一个 use 语句,我们可以在嵌套路径中使用 self,如 Listing 7-20 所示。

use std::io::{self, Write};

这行代码将 std::iostd::io::Write 引入作用域。

使用 Glob 运算符

如果我们想将路径中定义的所有公共项引入作用域,可以指定该路径后跟 * glob 运算符:

#![allow(unused)]
fn main() {
use std::collections::*;
}

这个 use 语句将 std::collections 中定义的所有公共项引入当前作用域。使用 glob 运算符时要小心!Glob 可能会使你在程序中使用的名称的来源变得难以辨认。

glob 运算符通常用于测试中,将测试中的所有内容引入 tests 模块;我们将在第 11 章的 “如何编写测试” 中讨论这一点。glob 运算符有时也作为 prelude 模式的一部分使用:有关该模式的更多信息,请参阅 标准库文档

将模块分离到不同的文件中

到目前为止,本章中的所有示例都是在一个文件中定义多个模块。当模块变得庞大时,你可能希望将它们的定义移动到单独的文件中,以使代码更易于导航。

例如,让我们从 Listing 7-17 中的代码开始,该代码包含多个餐厅模块。我们将模块提取到文件中,而不是将所有模块都定义在 crate 根文件中。在这种情况下,crate 根文件是 src/lib.rs,但这个过程也适用于 crate 根文件为 src/main.rs 的二进制 crate。

首先,我们将 front_of_house 模块提取到它自己的文件中。删除 front_of_house 模块大括号内的代码,只留下 mod front_of_house; 声明,这样 src/lib.rs 包含的代码如 Listing 7-21 所示。注意,在我们创建 src/front_of_house.rs 文件之前,这段代码不会编译通过。

mod front_of_house;

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}

接下来,将大括号内的代码放入一个名为 src/front_of_house.rs 的新文件中,如 Listing 7-22 所示。编译器知道要查找这个文件,因为它在 crate 根中遇到了名为 front_of_house 的模块声明。

pub mod hosting {
    pub fn add_to_waitlist() {}
}

注意,你只需要在模块树中使用 mod 声明加载文件 一次。一旦编译器知道该文件是项目的一部分(并且知道代码在模块树中的位置,因为你放置了 mod 语句),项目中的其他文件应该使用声明路径来引用加载文件的代码,如 “引用模块树中的项的路径” 部分所述。换句话说,mod 不是你在其他编程语言中可能见过的“包含”操作。

接下来,我们将 hosting 模块提取到它自己的文件中。这个过程有点不同,因为 hostingfront_of_house 的子模块,而不是根模块的子模块。我们将 hosting 的文件放在一个新目录中,该目录将根据其在模块树中的祖先命名,在这种情况下是 src/front_of_house

为了开始移动 hosting,我们将 src/front_of_house.rs 改为只包含 hosting 模块的声明:

pub mod hosting;

然后我们创建一个 src/front_of_house 目录和一个 hosting.rs 文件,以包含 hosting 模块中的定义:

pub fn add_to_waitlist() {}

如果我们将 hosting.rs 放在 src 目录中,编译器会期望 hosting.rs 代码位于 crate 根中声明的 hosting 模块中,而不是作为 front_of_house 模块的子模块声明。编译器用于检查哪些模块代码的规则意味着目录和文件更紧密地匹配模块树。

替代文件路径

到目前为止,我们已经介绍了 Rust 编译器使用的最惯用的文件路径,但 Rust 也支持一种较旧的文件路径风格。对于在 crate 根中声明的名为 front_of_house 的模块,编译器将在以下位置查找模块的代码:

  • src/front_of_house.rs(我们介绍的内容)
  • src/front_of_house/mod.rs(较旧的风格,仍然支持的路径)

对于作为 front_of_house 子模块的名为 hosting 的模块,编译器将在以下位置查找模块的代码:

  • src/front_of_house/hosting.rs(我们介绍的内容)
  • src/front_of_house/hosting/mod.rs(较旧的风格,仍然支持的路径)

如果你对同一个模块使用两种风格,你会得到一个编译器错误。在同一项目中对不同模块使用两种风格的混合是允许的,但可能会让导航你的项目的人感到困惑。

使用名为 mod.rs 的文件的主要缺点是,你的项目可能会以许多名为 mod.rs 的文件结束,当你在编辑器中同时打开它们时,这可能会让人感到困惑。

我们已经将每个模块的代码移动到单独的文件中,模块树保持不变。eat_at_restaurant 中的函数调用将无需任何修改即可工作,即使定义位于不同的文件中。这种技术允许你在模块大小增长时将它们移动到新文件中。

注意,src/lib.rs 中的 pub use crate::front_of_house::hosting 语句也没有改变,use 也不会对哪些文件作为 crate 的一部分编译产生影响。mod 关键字声明模块,Rust 会在与模块同名的文件中查找该模块的代码。

总结

Rust 允许你将一个包拆分为多个 crate,并将一个 crate 拆分为模块,以便你可以从一个模块中引用另一个模块中定义的项。你可以通过指定绝对或相对路径来实现这一点。这些路径可以通过 use 语句引入作用域,以便你可以在该作用域中多次使用较短的路径。模块代码默认是私有的,但你可以通过添加 pub 关键字使定义公开。

在下一章中,我们将介绍标准库中的一些集合数据结构,你可以在你整洁组织的代码中使用它们。

常见集合

Rust 的标准库包含了许多非常有用的数据结构,称为集合。大多数其他数据类型表示一个特定的值,但集合可以包含多个值。与内置的数组和元组类型不同,这些集合所指向的数据存储在堆上,这意味着数据的数量不需要在编译时已知,并且可以在程序运行时增长或缩小。每种集合都有不同的能力和成本,选择适合当前情况的集合是一项随着时间推移你将逐渐掌握的技能。在本章中,我们将讨论在 Rust 程序中经常使用的三种集合:

  • 向量(vector)允许你存储可变数量的值,这些值彼此相邻。
  • 字符串(string)是字符的集合。我们之前提到过 String 类型,但在本章中我们将深入讨论它。
  • 哈希映射(hash map)允许你将一个值与特定的键关联起来。它是更通用的数据结构映射(map)的一种特定实现。

要了解标准库提供的其他类型的集合,请参阅文档

我们将讨论如何创建和更新向量、字符串和哈希映射,以及它们各自的特点。

使用向量存储值列表

我们将要看的第一个集合类型是 Vec<T>,也称为 向量。向量允许你在一个数据结构中存储多个值,这些值在内存中是相邻存放的。向量只能存储相同类型的值。当你有一个项目列表时,它们非常有用,例如文件中的文本行或购物车中商品的价格。

创建一个新的向量

要创建一个新的空向量,我们可以调用 Vec::new 函数,如 Listing 8-1 所示。

fn main() {
    let v: Vec<i32> = Vec::new();
}

注意,我们在这里添加了类型注解。因为我们没有向这个向量中插入任何值,Rust 不知道我们打算存储什么类型的元素。这是一个重要的点。向量是使用泛型实现的;我们将在第 10 章中介绍如何在自己的类型中使用泛型。现在,只需知道标准库提供的 Vec<T> 类型可以保存任何类型。当我们创建一个向量来保存特定类型时,我们可以在尖括号中指定类型。在 Listing 8-1 中,我们告诉 Rust v 中的 Vec<T> 将保存 i32 类型的元素。

更常见的是,你会创建一个带有初始值的 Vec<T>,Rust 会推断出你想要存储的值的类型,因此你很少需要做这种类型注解。Rust 方便地提供了 vec! 宏,它将创建一个包含你提供的值的新向量。Listing 8-2 创建了一个新的 Vec<i32>,它保存了值 123。整数类型是 i32,因为这是默认的整数类型,正如我们在第 3 章的 “数据类型” 部分讨论的那样。

fn main() {
    let v = vec![1, 2, 3];
}

因为我们提供了初始的 i32 值,Rust 可以推断出 v 的类型是 Vec<i32>,因此类型注解是不必要的。接下来,我们将看看如何修改向量。

更新向量

要创建一个向量然后向其添加元素,我们可以使用 push 方法,如 Listing 8-3 所示。

fn main() {
    let mut v = Vec::new();

    v.push(5);
    v.push(6);
    v.push(7);
    v.push(8);
}

与任何变量一样,如果我们希望能够改变它的值,我们需要使用 mut 关键字使其可变,正如第 3 章讨论的那样。我们放入的数字都是 i32 类型,Rust 从数据中推断出这一点,因此我们不需要 Vec<i32> 注解。

读取向量中的元素

有两种方法可以引用存储在向量中的值:通过索引或使用 get 方法。在以下示例中,我们为这些函数返回的值的类型添加了注解,以便更清晰。

Listing 8-4 展示了访问向量中值的两种方法,使用索引语法和 get 方法。

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let third: &i32 = &v[2];
    println!("The third element is {third}");

    let third: Option<&i32> = v.get(2);
    match third {
        Some(third) => println!("The third element is {third}"),
        None => println!("There is no third element."),
    }
}

这里有几个细节需要注意。我们使用索引值 2 来获取第三个元素,因为向量是按数字索引的,从零开始。使用 &[] 会给我们一个对索引值处元素的引用。当我们使用 get 方法并将索引作为参数传递时,我们会得到一个 Option<&T>,我们可以将其与 match 一起使用。

Rust 提供了这两种引用元素的方式,以便你可以选择在尝试使用超出现有元素范围的索引值时程序的行为。例如,让我们看看当我们有一个包含五个元素的向量,然后我们尝试使用每种技术访问索引为 100 的元素时会发生什么,如 Listing 8-5 所示。

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let does_not_exist = &v[100];
    let does_not_exist = v.get(100);
}

当我们运行这段代码时,第一个 [] 方法将导致程序 panic,因为它引用了一个不存在的元素。这种方法最适合在你希望程序在尝试访问超出向量末尾的元素时崩溃的情况下使用。

get 方法传递一个超出向量范围的索引时,它会返回 None 而不会 panic。如果访问超出向量范围的元素在正常情况下偶尔会发生,你会使用这种方法。然后你的代码将具有处理 Some(&element)None 的逻辑,正如第 6 章讨论的那样。例如,索引可能来自用户输入的数字。如果他们不小心输入了一个过大的数字,程序得到了一个 None 值,你可以告诉用户当前向量中有多少项,并给他们另一个机会输入一个有效的值。这比由于输入错误而崩溃程序更友好!

当程序有一个有效的引用时,借用检查器会强制执行所有权和借用规则(在第 4 章中介绍),以确保此引用和任何其他对向量内容的引用保持有效。回想一下,规则规定你不能在同一作用域中同时拥有可变和不可变引用。这条规则适用于 Listing 8-6,我们持有一个对向量中第一个元素的不可变引用,并尝试在末尾添加一个元素。如果我们稍后在函数中尝试引用该元素,这个程序将无法工作。

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];

    let first = &v[0];

    v.push(6);

    println!("The first element is: {first}");
}

编译此代码将导致以下错误:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:5
  |
4 |     let first = &v[0];
  |                  - immutable borrow occurs here
5 |
6 |     v.push(6);
  |     ^^^^^^^^^ mutable borrow occurs here
7 |
8 |     println!("The first element is: {first}");
  |                                     ------- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `collections` (bin "collections") due to 1 previous error

Listing 8-6 中的代码看起来应该可以工作:为什么对第一个元素的引用会关心向量末尾的变化?这个错误是由于向量的工作方式:因为向量将值相邻地放在内存中,如果当前存储向量的位置没有足够的空间将所有元素相邻存放,那么在向量末尾添加一个新元素可能需要分配新的内存并将旧元素复制到新空间。在这种情况下,对第一个元素的引用将指向已释放的内存。借用规则防止程序陷入这种情况。

注意:有关 Vec<T> 类型的实现细节的更多信息,请参见 “The Rustonomicon”

遍历向量中的值

要依次访问向量中的每个元素,我们会遍历所有元素,而不是使用索引逐个访问。Listing 8-7 展示了如何使用 for 循环获取对 i32 值向量中每个元素的不可变引用并打印它们。

fn main() {
    let v = vec![100, 32, 57];
    for i in &v {
        println!("{i}");
    }
}

我们还可以遍历可变向量中每个元素的可变引用,以便对所有元素进行更改。Listing 8-8 中的 for 循环将为每个元素添加 50

fn main() {
    let mut v = vec![100, 32, 57];
    for i in &mut v {
        *i += 50;
    }
}

要更改可变引用所指向的值,我们必须使用 * 解引用运算符来获取 i 中的值,然后才能使用 += 运算符。我们将在第 15 章的 “跟随指针到值” 部分中更多地讨论解引用运算符。

遍历向量,无论是不可变的还是可变的,都是安全的,因为借用检查器的规则。如果我们尝试在 Listing 8-7 和 Listing 8-8 的 for 循环体中插入或删除项,我们将得到一个类似于 Listing 8-6 中代码的编译器错误。for 循环持有的对向量的引用防止了同时修改整个向量。

使用枚举存储多种类型

向量只能存储相同类型的值。这可能不方便;肯定有一些用例需要存储不同类型的项目列表。幸运的是,枚举的变体是在同一个枚举类型下定义的,所以当我们需要一个类型来表示不同类型的元素时,我们可以定义并使用一个枚举!

例如,假设我们想从电子表格的一行中获取值,其中该行的某些列包含整数,某些列包含浮点数,某些列包含字符串。我们可以定义一个枚举,其变体将保存不同的值类型,所有枚举变体将被视为同一类型:枚举的类型。然后我们可以创建一个向量来保存该枚举,从而最终保存不同的类型。我们在 Listing 8-9 中演示了这一点。

fn main() {
    enum SpreadsheetCell {
        Int(i32),
        Float(f64),
        Text(String),
    }

    let row = vec![
        SpreadsheetCell::Int(3),
        SpreadsheetCell::Text(String::from("blue")),
        SpreadsheetCell::Float(10.12),
    ];
}

Rust 需要在编译时知道向量中将包含哪些类型,以便确切知道堆上需要多少内存来存储每个元素。我们还必须明确允许在此向量中包含哪些类型。如果 Rust 允许向量保存任何类型,那么有一种或多种类型可能会导致对向量元素执行的操作出错。使用枚举加上 match 表达式意味着 Rust 将在编译时确保处理所有可能的情况,正如第 6 章讨论的那样。

如果你不知道程序在运行时将获得哪些类型的详尽集合来存储在向量中,枚举技术将不起作用。相反,你可以使用 trait 对象,我们将在第 18 章中介绍。

现在我们已经讨论了一些使用向量的最常见方法,请务必查看 API 文档,了解标准库在 Vec<T> 上定义的所有许多有用方法。例如,除了 push,还有一个 pop 方法可以移除并返回最后一个元素。

删除向量会删除其元素

与任何其他 struct 一样,当向量超出作用域时,它会被释放,如 Listing 8-10 所示。

fn main() {
    {
        let v = vec![1, 2, 3, 4];

        // do stuff with v
    } // <- v goes out of scope and is freed here
}

当向量被删除时,它的所有内容也会被删除,这意味着它保存的整数将被清理。借用检查器确保对向量内容的任何引用仅在向量本身有效时使用。

让我们继续讨论下一个集合类型:String

使用字符串存储 UTF-8 编码的文本

我们在第 4 章讨论过字符串,但现在我们将更深入地探讨它们。新 Rustacean 通常会在字符串上遇到困难,原因有三:Rust 倾向于暴露可能的错误、字符串是一种比许多程序员认为的更复杂的数据结构,以及 UTF-8。这些因素结合在一起,可能会让你从其他编程语言转过来时感到困难。

我们在集合的上下文中讨论字符串,因为字符串是作为字节集合实现的,再加上一些方法,以便在将这些字节解释为文本时提供有用的功能。在本节中,我们将讨论每个集合类型都有的 String 操作,例如创建、更新和读取。我们还将讨论 String 与其他集合的不同之处,即由于人和计算机对 String 数据的解释方式不同,索引到 String 中会变得复杂。

什么是字符串?

我们首先定义术语 字符串 的含义。Rust 在核心语言中只有一种字符串类型,即字符串切片 str,通常以借用的形式 &str 出现。在第 4 章中,我们讨论了 字符串切片,它们是对存储在其他地方的某些 UTF-8 编码字符串数据的引用。例如,字符串字面量存储在程序的二进制文件中,因此它们是字符串切片。

String 类型由 Rust 的标准库提供,而不是编码在核心语言中,它是一种可增长、可变、拥有所有权的 UTF-8 编码字符串类型。当 Rustacean 在 Rust 中提到“字符串”时,他们可能指的是 String 或字符串切片 &str 类型,而不仅仅是其中一种类型。尽管本节主要讨论 String,但这两种类型在 Rust 的标准库中都被大量使用,并且 String 和字符串切片都是 UTF-8 编码的。

创建一个新的字符串

许多与 Vec<T> 相同的操作也适用于 String,因为 String 实际上是作为字节向量的包装器实现的,具有一些额外的保证、限制和功能。一个与 Vec<T>String 工作方式相同的函数是用于创建实例的 new 函数,如 Listing 8-11 所示。

fn main() {
    let mut s = String::new();
}

这行代码创建了一个名为 s 的新空字符串,我们可以随后将数据加载到其中。通常,我们会有一些初始数据,我们希望用它来启动字符串。为此,我们使用 to_string 方法,该方法可用于任何实现了 Display trait 的类型,就像字符串字面量一样。Listing 8-12 展示了两个示例。

fn main() {
    let data = "initial contents";

    let s = data.to_string();

    // The method also works on a literal directly:
    let s = "initial contents".to_string();
}

这段代码创建了一个包含 initial contents 的字符串。

我们还可以使用 String::from 函数从字符串字面量创建 String。Listing 8-13 中的代码与 Listing 8-12 中使用 to_string 的代码等效。

fn main() {
    let s = String::from("initial contents");
}

因为字符串用于许多事情,所以我们可以使用许多不同的通用 API 来处理字符串,这为我们提供了很多选择。其中一些可能看起来冗余,但它们都有其用途!在这种情况下,String::fromto_string 做同样的事情,所以你选择哪一个取决于风格和可读性。

记住,字符串是 UTF-8 编码的,所以我们可以在其中包含任何正确编码的数据,如 Listing 8-14 所示。

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

所有这些都是有效的 String 值。

更新字符串

String 可以增长大小,其内容可以更改,就像 Vec<T> 的内容一样,如果你向其中推送更多数据。此外,你可以方便地使用 + 运算符或 format! 宏来连接 String 值。

使用 push_strpush 追加到字符串

我们可以使用 push_str 方法追加一个字符串切片来增长 String,如 Listing 8-15 所示。

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}

在这两行之后,s 将包含 foobarpush_str 方法接受一个字符串切片,因为我们不一定想要获取参数的所有权。例如,在 Listing 8-16 的代码中,我们希望在将其内容追加到 s1 后仍然能够使用 s2

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {s2}");
}

如果 push_str 方法获取了 s2 的所有权,我们将无法在最后一行打印其值。然而,这段代码按预期工作!

push 方法接受一个字符作为参数并将其添加到 String 中。Listing 8-17 使用 push 方法将字母 l 添加到 String 中。

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}

结果,s 将包含 lol

使用 + 运算符或 format! 宏进行连接

通常,你会想要组合两个现有的字符串。一种方法是使用 + 运算符,如 Listing 8-18 所示。

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used
}

字符串 s3 将包含 Hello, world!s1 在加法后不再有效的原因,以及我们使用 s2 的引用的原因,与我们在使用 + 运算符时调用的方法的签名有关。+ 运算符使用 add 方法,其签名如下所示:

fn add(self, s: &str) -> String {

在标准库中,你会看到 add 使用泛型和关联类型定义。在这里,我们替换了具体类型,这是当我们使用 String 值调用此方法时发生的情况。我们将在第 10 章讨论泛型。这个签名为我们提供了理解 + 运算符复杂部分所需的线索。

首先,s2 有一个 &,意味着我们将第二个字符串的 引用 添加到第一个字符串。这是因为 add 函数中的 s 参数:我们只能将 &str 添加到 String;我们不能将两个 String 值加在一起。但是等等——&s2 的类型是 &String,而不是 &str,正如 add 的第二个参数所指定的那样。那么为什么 Listing 8-18 能够编译通过呢?

我们能够在 add 调用中使用 &s2 的原因是编译器可以将 &String 参数强制转换为 &str。当我们调用 add 方法时,Rust 使用了一个 解引用强制转换,在这里将 &s2 转换为 &s2[..]。我们将在第 15 章更深入地讨论解引用强制转换。因为 add 不获取 s 参数的所有权,s2 在此操作后仍然是一个有效的 String

其次,我们可以在签名中看到 add 获取了 self 的所有权,因为 self 没有 &。这意味着 Listing 8-18 中的 s1 将被移动到 add 调用中,并且在之后不再有效。因此,尽管 let s3 = s1 + &s2; 看起来像是会复制两个字符串并创建一个新的字符串,但实际上这个语句获取了 s1 的所有权,追加了 s2 内容的副本,然后返回结果的所有权。换句话说,它看起来像是做了很多复制,但实际上并没有;实现比复制更高效。

如果我们需要连接多个字符串,+ 运算符的行为会变得笨拙:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = s1 + "-" + &s2 + "-" + &s3;
}

此时,s 将是 tic-tac-toe。由于所有的 +" 字符,很难看出发生了什么。对于更复杂的字符串组合,我们可以使用 format! 宏:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = format!("{s1}-{s2}-{s3}");
}

这段代码也将 s 设置为 tic-tac-toeformat! 宏的工作方式类似于 println!,但它不是将输出打印到屏幕上,而是返回一个包含内容的 String。使用 format! 的代码版本更容易阅读,并且 format! 宏生成的代码使用引用,因此此调用不会获取其任何参数的所有权。

字符串索引

在许多其他编程语言中,通过索引引用字符串中的单个字符是一种有效且常见的操作。然而,如果你尝试在 Rust 中使用索引语法访问 String 的部分内容,你会得到一个错误。考虑 Listing 8-19 中的无效代码。

fn main() {
    let s1 = String::from("hi");
    let h = s1[0];
}

这段代码将导致以下错误:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src/main.rs:3:16
  |
3 |     let h = s1[0];
  |                ^ string indices are ranges of `usize`
  |
  = note: you can use `.chars().nth()` or `.bytes().nth()`
          for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`
          but trait `SliceIndex<[_]>` is implemented for `usize`
  = help: for that trait implementation, expected `[_]`, found `str`
  = note: required for `String` to implement `Index<{integer}>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error

错误和说明告诉我们:Rust 字符串不支持索引。但为什么不支持呢?要回答这个问题,我们需要讨论 Rust 如何在内存中存储字符串。

内部表示

StringVec<u8> 的包装器。让我们看看 Listing 8-14 中一些正确编码的 UTF-8 示例字符串。首先,这个:

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

在这种情况下,len 将是 4,这意味着存储字符串 "Hola" 的向量长度为 4 字节。这些字母在 UTF-8 编码中每个占用一个字节。然而,以下行可能会让你感到惊讶(注意这个字符串以大写西里尔字母 Ze 开头,而不是数字 3):

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

如果你被问到字符串的长度是多少,你可能会说 12。事实上,Rust 的答案是 24:这是 UTF-8 编码“Здравствуйте”所需的字节数,因为该字符串中的每个 Unicode 标量值占用 2 字节的存储空间。因此,字符串字节的索引并不总是与有效的 Unicode 标量值相关联。为了演示,考虑以下无效的 Rust 代码:

let hello = "Здравствуйте";
let answer = &hello[0];

你已经知道 answer 不会是 З,第一个字母。当用 UTF-8 编码时,З 的第一个字节是 208,第二个字节是 151,所以 answer 实际上应该是 208,但 208 本身并不是一个有效的字符。返回 208 可能不是用户想要的,如果他们要求这个字符串的第一个字母;然而,这是 Rust 在字节索引 0 处唯一拥有的数据。用户通常不希望返回字节值,即使字符串只包含拉丁字母:如果 &"hi"[0] 是返回字节值的有效代码,它将返回 104,而不是 h

因此,答案是为了避免返回意外值并导致可能不会立即发现的错误,Rust 根本不编译此代码,并在开发过程的早期防止误解。

字节、标量值和字素簇!哦,天哪!

关于 UTF-8 的另一点是,实际上有三种相关的方式可以从 Rust 的角度来看待字符串:作为字节、标量值和字素簇(最接近我们称之为 字母 的东西)。

如果我们看一下用天城文书写的印地语单词“नमस्ते”,它存储为一个 u8 值的向量,看起来像这样:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

这是 18 字节,是计算机最终存储这些数据的方式。如果我们把它们看作 Unicode 标量值,也就是 Rust 的 char 类型,这些字节看起来像这样:

['न', 'म', 'स', '्', 'त', 'े']

这里有六个 char 值,但第四个和第六个不是字母:它们是单独没有意义的变音符号。最后,如果我们把它们看作字素簇,我们会得到一个人称之为组成印地语单词的四个字母:

["न", "म", "स्", "ते"]

Rust 提供了不同的方式来解释计算机存储的原始字符串数据,以便每个程序可以选择它需要的解释,无论数据是哪种人类语言。

Rust 不允许我们通过索引 String 来获取字符的最后一个原因是,索引操作预期总是以恒定时间(O(1))完成。但是,对于 String 来说,无法保证这种性能,因为 Rust 必须从头开始遍历内容到索引,以确定有多少有效字符。

字符串切片

索引到字符串中通常不是一个好主意,因为不清楚字符串索引操作的返回类型应该是什么:字节值、字符、字素簇还是字符串切片。因此,如果你确实需要使用索引来创建字符串切片,Rust 要求你更加明确。

你可以使用带有范围的 [] 来创建包含特定字节的字符串切片,而不是使用带有单个数字的 []

#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

let s = &hello[0..4];
}

在这里,s 将是一个包含字符串前四个字节的 &str。之前我们提到,这些字符中的每一个都是两个字节,这意味着 s 将是 Зд

如果我们尝试只切片一个字符的部分字节,比如 &hello[0..1],Rust 会在运行时 panic,就像在向量中访问无效索引一样:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/collections`

thread 'main' panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

在使用范围创建字符串切片时应该小心,因为这样做可能会导致程序崩溃。

遍历字符串的方法

操作字符串片段的最佳方式是明确你是想要字符还是字节。对于单个 Unicode 标量值,使用 chars 方法。在“Зд”上调用 chars 会分离并返回两个 char 类型的值,你可以遍历结果以访问每个元素:

#![allow(unused)]
fn main() {
for c in "Зд".chars() {
    println!("{c}");
}
}

这段代码将打印以下内容:

З
д

或者,bytes 方法返回每个原始字节,这可能适合你的领域:

#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
    println!("{b}");
}
}

这段代码将打印组成此字符串的四个字节:

208
151
208
180

但请记住,有效的 Unicode 标量值可能由多个字节组成。

从字符串中获取字素簇,如天城文脚本,是复杂的,因此标准库不提供此功能。如果你需要此功能,可以在 crates.io 上找到可用的 crate。

字符串并不简单

总结一下,字符串是复杂的。不同的编程语言对如何向程序员展示这种复杂性做出了不同的选择。Rust 选择将所有 Rust 程序的默认行为设置为正确处理 String 数据,这意味着程序员必须提前更多地考虑如何处理 UTF-8 数据。这种权衡暴露了字符串的复杂性,这在其他编程语言中并不明显,但它可以防止你在开发周期的后期处理涉及非 ASCII 字符的错误。

好消息是,标准库提供了许多基于 String&str 类型的功能,以帮助正确处理这些复杂情况。请务必查看文档,了解有用的方法,如 contains 用于在字符串中搜索,以及 replace 用于将字符串的一部分替换为另一个字符串。

让我们转向一些不那么复杂的东西:哈希映射!

使用哈希映射存储键值对

我们常见的集合中的最后一个是 哈希映射。类型 HashMap<K, V> 使用 哈希函数 存储键类型 K 到值类型 V 的映射,哈希函数决定了如何将这些键和值放入内存中。许多编程语言都支持这种数据结构,但它们通常使用不同的名称,例如 哈希映射对象哈希表字典关联数组,仅举几例。

当你不想通过索引(就像使用向量那样)来查找数据,而是想通过一个可以是任意类型的键来查找数据时,哈希映射非常有用。例如,在一个游戏中,你可以使用哈希映射来跟踪每个队伍的分数,其中每个键是队伍的名称,值是该队伍的分数。给定一个队伍名称,你可以检索其分数。

我们将在本节中介绍哈希映射的基本 API,但标准库在 HashMap<K, V> 上定义的函数中隐藏了更多好东西。一如既往,请查看标准库文档以获取更多信息。

创建一个新的哈希映射

创建一个空哈希映射的一种方法是使用 new 并通过 insert 添加元素。在 Listing 8-20 中,我们跟踪了两个队伍的分数,队伍名称分别为 BlueYellow。Blue 队从 10 分开始,Yellow 队从 50 分开始。

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);
}

注意,我们需要首先从标准库的集合部分 use HashMap。在我们常见的三种集合中,这种集合使用频率最低,因此它没有包含在预导入中自动引入的功能中。哈希映射在标准库中的支持也较少;例如,没有内置的宏来构造它们。

就像向量一样,哈希映射将其数据存储在堆上。这个 HashMap 的键类型为 String,值类型为 i32。与向量一样,哈希映射是同构的:所有的键必须具有相同的类型,所有的值也必须具有相同的类型。

访问哈希映射中的值

我们可以通过将键提供给 get 方法来获取哈希映射中的值,如 Listing 8-21 所示。

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    let team_name = String::from("Blue");
    let score = scores.get(&team_name).copied().unwrap_or(0);
}

在这里,score 将具有与 Blue 队伍关联的值,结果将是 10get 方法返回一个 Option<&V>;如果哈希映射中没有该键的值,get 将返回 None。这个程序通过调用 copied 来处理 Option,以获取一个 Option<i32> 而不是 Option<&i32>,然后使用 unwrap_orscore 设置为零,如果 scores 中没有该键的条目。

我们可以像处理向量一样,使用 for 循环遍历哈希映射中的每个键值对:

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    for (key, value) in &scores {
        println!("{key}: {value}");
    }
}

这段代码将以任意顺序打印每对键值:

Yellow: 50
Blue: 10

哈希映射和所有权

对于实现了 Copy trait 的类型,如 i32,值会被复制到哈希映射中。对于像 String 这样的拥有所有权的值,值将被移动,哈希映射将成为这些值的所有者,如 Listing 8-22 所示。

fn main() {
    use std::collections::HashMap;

    let field_name = String::from("Favorite color");
    let field_value = String::from("Blue");

    let mut map = HashMap::new();
    map.insert(field_name, field_value);
    // field_name and field_value are invalid at this point, try using them and
    // see what compiler error you get!
}

在将 field_namefield_value 变量通过 insert 调用移动到哈希映射后,我们无法再使用这些变量。

如果我们将值的引用插入哈希映射,这些值不会被移动到哈希映射中。引用所指向的值在哈希映射有效期间必须保持有效。我们将在第 10 章的 “使用生命周期验证引用” 中详细讨论这些问题。

更新哈希映射

尽管键值对的数量是可增长的,但每个唯一的键一次只能有一个与之关联的值(但反之则不然:例如,Blue 队和 Yellow 队都可以在 scores 哈希映射中存储值 10)。

当你想要更改哈希映射中的数据时,你必须决定如何处理键已经有值的情况。你可以用新值替换旧值,完全忽略旧值。你可以保留旧值并忽略新值,只有在键 没有 值时才添加新值。或者你可以将旧值和新值结合起来。让我们看看如何做到这些!

覆盖一个值

如果我们向哈希映射中插入一个键和一个值,然后用不同的值插入相同的键,与该键关联的值将被替换。尽管 Listing 8-23 中的代码调用了两次 insert,哈希映射将只包含一个键值对,因为我们两次都插入了 Blue 队的键的值。

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Blue"), 25);

    println!("{scores:?}");
}

这段代码将打印 {"Blue": 25}。原始值 10 已被覆盖。

仅在键不存在时插入键和值

通常,我们会检查哈希映射中是否已经存在某个键的值,然后采取以下操作:如果键存在于哈希映射中,现有值应保持不变;如果键不存在,则插入该键及其值。

哈希映射为此提供了一个特殊的 API,称为 entry,它将你要检查的键作为参数。entry 方法的返回值是一个名为 Entry 的枚举,它表示一个可能存在或不存在的值。假设我们想检查 Yellow 队的键是否有关联的值。如果没有,我们想插入值 50,Blue 队也是如此。使用 entry API,代码如 Listing 8-24 所示。

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();
    scores.insert(String::from("Blue"), 10);

    scores.entry(String::from("Yellow")).or_insert(50);
    scores.entry(String::from("Blue")).or_insert(50);

    println!("{scores:?}");
}

Entry 上的 or_insert 方法定义为:如果该键存在,则返回对应 Entry 键值的可变引用;如果不存在,则插入参数作为该键的新值,并返回新值的可变引用。这种技术比我们自己编写逻辑要简洁得多,而且与借用检查器的配合也更好。

运行 Listing 8-24 中的代码将打印 {"Yellow": 50, "Blue": 10}。第一次调用 entry 将插入 Yellow 队的键,值为 50,因为 Yellow 队还没有值。第二次调用 entry 不会改变哈希映射,因为 Blue 队已经有值 10

根据旧值更新值

哈希映射的另一个常见用例是查找键的值,然后根据旧值进行更新。例如,Listing 8-25 展示了计算一段文本中每个单词出现次数的代码。我们使用一个以单词为键的哈希映射,并递增值以跟踪我们看到该单词的次数。如果我们第一次看到一个单词,我们将首先插入值 0

fn main() {
    use std::collections::HashMap;

    let text = "hello world wonderful world";

    let mut map = HashMap::new();

    for word in text.split_whitespace() {
        let count = map.entry(word).or_insert(0);
        *count += 1;
    }

    println!("{map:?}");
}

这段代码将打印 {"world": 2, "hello": 1, "wonderful": 1}。你可能会看到相同的键值对以不同的顺序打印:回想一下 “访问哈希映射中的值”,遍历哈希映射的顺序是任意的。

split_whitespace 方法返回一个迭代器,该迭代器遍历 text 中由空白分隔的子切片。or_insert 方法返回指定键值的可变引用(&mut V)。在这里,我们将该可变引用存储在 count 变量中,因此为了赋值给该值,我们必须首先使用星号(*)解引用 count。可变引用在 for 循环结束时超出范围,因此所有这些更改都是安全的,并且符合借用规则。

哈希函数

默认情况下,HashMap 使用一种名为 SipHash 的哈希函数,它可以提供对涉及哈希表的拒绝服务(DoS)攻击的抵抗力1。这不是最快的哈希算法,但为了更好的安全性而牺牲性能是值得的。如果你分析代码并发现默认的哈希函数对你的用途来说太慢,你可以通过指定不同的哈希器来切换到另一个函数。哈希器 是实现 BuildHasher trait 的类型。我们将在 第 10 章 中讨论 trait 以及如何实现它们。你不必从头开始实现自己的哈希器;crates.io 上有其他 Rust 用户共享的库,提供了实现许多常见哈希算法的哈希器。

总结

向量、字符串和哈希映射将在你需要存储、访问和修改数据时提供大量必要的功能。以下是一些你现在应该能够解决的练习:

  1. 给定一个整数列表,使用向量并返回中位数(排序后位于中间位置的值)和众数(出现次数最多的值;哈希映射在这里会很有帮助)。
  2. 将字符串转换为拉丁猪文字。每个单词的第一个辅音被移动到单词的末尾并加上 ay,所以 first 变成 irst-fay。以元音开头的单词在末尾加上 hayapple 变成 apple-hay)。请记住 UTF-8 编码的细节!
  3. 使用哈希映射和向量,创建一个文本界面,允许用户将员工姓名添加到公司的部门中;例如,“Add Sally to Engineering” 或 “Add Amir to Sales”。然后让用户按部门或按部门排序检索公司中所有人员的列表。

标准库 API 文档描述了向量、字符串和哈希映射的方法,这些方法对这些练习会很有帮助!

我们正在进入更复杂的程序,其中操作可能会失败,因此现在是讨论错误处理的最佳时机。我们接下来会讨论这个问题!

错误处理

在软件中,错误是不可避免的,因此 Rust 提供了许多功能来处理出现错误的情况。在许多情况下,Rust 要求你在代码编译之前承认错误的可能性并采取一些措施。这一要求通过确保你在将代码部署到生产环境之前发现错误并适当地处理它们,从而使你的程序更加健壮!

Rust 将错误分为两大类:可恢复的不可恢复的 错误。对于可恢复的错误,例如 文件未找到 错误,我们很可能只想向用户报告问题并重试操作。不可恢复的错误总是 bug 的表现,例如尝试访问数组末尾之外的位置,因此我们希望立即停止程序。

大多数语言不区分这两种错误,并使用诸如异常之类的机制以相同的方式处理它们。Rust 没有异常。相反,它有用于可恢复错误的类型 Result<T, E> 和当程序遇到不可恢复错误时停止执行的 panic! 宏。本章首先介绍调用 panic!,然后讨论返回 Result<T, E> 值。此外,我们还将探讨在决定是尝试从错误中恢复还是停止执行时的考虑因素。

使用 panic! 处理不可恢复的错误

有时候,代码中会发生一些糟糕的事情,而你对此无能为力。在这种情况下,Rust 提供了 panic! 宏。实际上有两种方式可以引发 panic:通过执行某些操作导致代码 panic(例如访问数组超出其范围)或显式调用 panic! 宏。在这两种情况下,我们都会导致程序 panic。默认情况下,这些 panic 会打印失败消息,展开堆栈,清理堆栈并退出。通过环境变量,你还可以让 Rust 在 panic 发生时显示调用堆栈,以便更容易追踪 panic 的来源。

展开堆栈或在 panic 时中止

默认情况下,当 panic 发生时,程序会开始展开堆栈,这意味着 Rust 会回溯堆栈并清理它遇到的每个函数中的数据。然而,回溯和清理是一项繁重的工作。因此,Rust 允许你选择立即中止,这将结束程序而不进行清理。

程序使用的内存随后需要由操作系统清理。如果你的项目需要使生成的二进制文件尽可能小,你可以通过在 Cargo.toml 文件的适当 [profile] 部分添加 panic = 'abort' 来将 panic 时的行为从展开切换为中止。例如,如果你希望在发布模式下 panic 时中止,可以添加以下内容:

[profile.release]
panic = 'abort'

让我们尝试在一个简单的程序中调用 panic!

fn main() {
    panic!("crash and burn");
}

当你运行这个程序时,你会看到类似以下的输出:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/panic`

thread 'main' panicked at src/main.rs:2:5:
crash and burn
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

调用 panic! 会导致包含在最后两行中的错误消息。第一行显示了我们的 panic 消息以及 panic 发生的源代码位置:src/main.rs:2:5 表示它是 src/main.rs 文件的第二行第五个字符。

在这个例子中,指示的行是我们代码的一部分,如果我们查看该行,我们会看到 panic! 宏的调用。在其他情况下,panic! 调用可能位于我们代码调用的代码中,错误消息报告的文件名和行号将是调用 panic! 宏的代码,而不是最终导致 panic! 调用的我们的代码行。

我们可以使用 panic! 调用来源的函数回溯来找出导致问题的代码部分。为了理解如何使用 panic! 回溯,让我们看另一个例子,看看当 panic! 调用来自库时是什么样子,而不是我们的代码直接调用宏。Listing 9-1 中的代码尝试访问向量中超出有效索引范围的索引。

fn main() {
    let v = vec![1, 2, 3];

    v[99];
}

在这里,我们尝试访问向量的第 100 个元素(索引为 99,因为索引从零开始),但向量只有三个元素。在这种情况下,Rust 会 panic。使用 [] 应该返回一个元素,但如果你传递了一个无效的索引,Rust 在这里无法返回一个正确的元素。

在 C 语言中,尝试读取数据结构末尾之外的内容是未定义行为。你可能会得到内存中对应于该数据结构元素的位置上的任何内容,即使该内存不属于该结构。这被称为缓冲区越界读取,如果攻击者能够操纵索引以读取他们不应该被允许读取的数据结构之后存储的数据,可能会导致安全漏洞。

为了保护你的程序免受此类漏洞的影响,如果你尝试读取一个不存在的索引处的元素,Rust 会停止执行并拒绝继续。让我们试试看:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/panic`

thread 'main' panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

这个错误指向我们的 main.rs 文件的第 4 行,我们尝试访问向量 v 的索引 99

note: 行告诉我们,我们可以设置 RUST_BACKTRACE 环境变量来获取导致错误的确切回溯。回溯是到达这一点所调用的所有函数的列表。Rust 中的回溯与其他语言中的回溯一样:阅读回溯的关键是从顶部开始阅读,直到看到你编写的文件。那就是问题起源的地方。该点之前的行是你的代码调用的代码;该点之后的行是调用你的代码的代码。这些前后行可能包括 Rust 核心代码、标准库代码或你正在使用的 crate。让我们尝试通过将 RUST_BACKTRACE 环境变量设置为除 0 之外的任何值来获取回溯。Listing 9-2 显示了类似于你将看到的输出。

$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
stack backtrace:
   0: rust_begin_unwind
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/panicking.rs:692:5
   1: core::panicking::panic_fmt
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/core/src/panicking.rs:75:14
   2: core::panicking::panic_bounds_check
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/core/src/panicking.rs:273:5
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/slice/index.rs:274:10
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/slice/index.rs:16:9
   5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/alloc/src/vec/mod.rs:3361:9
   6: panic::main
             at ./src/main.rs:4:6
   7: core::ops::function::FnOnce::call_once
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

输出很多!你看到的输出可能会因操作系统和 Rust 版本而有所不同。为了获取包含这些信息的回溯,必须启用调试符号。在使用 cargo buildcargo run 而不带 --release 标志时,调试符号默认是启用的,就像我们这里的情况一样。

在 Listing 9-2 的输出中,回溯的第 6 行指向我们项目中导致问题的行:src/main.rs 的第 4 行。如果我们不希望程序 panic,我们应该从第一个提到我们编写的文件的行指向的位置开始调查。在 Listing 9-1 中,我们故意编写了会导致 panic 的代码,修复 panic 的方法是不要请求超出向量索引范围的元素。当你的代码在未来 panic 时,你需要弄清楚代码正在使用哪些值执行什么操作导致了 panic,以及代码应该做什么。

我们将在本章后面的 “To panic! or Not to panic! 部分再次讨论 panic! 以及何时应该和不应该使用 panic! 来处理错误条件。接下来,我们将看看如何使用 Result 从错误中恢复。

使用 Result 处理可恢复错误

大多数错误并不严重到需要程序完全停止。有时,当函数失败时,原因可能是你可以轻松解释并响应的。例如,如果你尝试打开一个文件,但该操作因为文件不存在而失败,你可能希望创建该文件而不是终止进程。

回想一下第2章中的“使用 Result 处理潜在失败”Result 枚举被定义为具有两个变体,OkErr,如下所示:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

TE 是泛型类型参数:我们将在第10章中更详细地讨论泛型。现在你需要知道的是,T 表示在 Ok 变体中成功情况下返回的值的类型,而 E 表示在 Err 变体中失败情况下返回的错误类型。因为 Result 有这些泛型类型参数,我们可以在许多不同的情况下使用 Result 类型及其定义的函数,其中我们想要返回的成功值和错误值可能不同。

让我们调用一个返回 Result 值的函数,因为该函数可能会失败。在 Listing 9-3 中,我们尝试打开一个文件。

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");
}

File::open 的返回类型是 Result<T, E>。泛型参数 TFile::open 的实现填充为成功值的类型,std::fs::File,这是一个文件句柄。错误值中使用的 E 类型是 std::io::Error。这个返回类型意味着调用 File::open 可能会成功并返回一个我们可以读取或写入的文件句柄。函数调用也可能会失败:例如,文件可能不存在,或者我们可能没有访问文件的权限。File::open 函数需要有一种方式来告诉我们它是否成功,并同时给我们文件句柄或错误信息。这正是 Result 枚举所传达的信息。

File::open 成功的情况下,变量 greeting_file_result 中的值将是一个包含文件句柄的 Ok 实例。在失败的情况下,greeting_file_result 中的值将是一个包含有关发生的错误类型更多信息的 Err 实例。

我们需要在 Listing 9-3 的代码中添加一些内容,以根据 File::open 返回的值采取不同的操作。Listing 9-4 展示了一种使用基本工具 match 表达式来处理 Result 的方法,我们在第6章讨论过 match 表达式。

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {error:?}"),
    };
}

请注意,与 Option 枚举一样,Result 枚举及其变体已经通过预导入模块引入作用域,因此我们不需要在 match 分支中指定 Result:: 前缀。

当结果是 Ok 时,此代码将返回 Ok 变体中的内部 file 值,然后我们将该文件句柄值分配给变量 greeting_file。在 match 之后,我们可以使用该文件句柄进行读取或写入。

match 的另一个分支处理我们从 File::open 得到 Err 值的情况。在这个例子中,我们选择调用 panic! 宏。如果当前目录中没有名为 hello.txt 的文件,并且我们运行此代码,我们将看到来自 panic! 宏的以下输出:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/error-handling`

thread 'main' panicked at src/main.rs:8:23:
Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

像往常一样,这个输出准确地告诉我们出了什么问题。

匹配不同的错误

Listing 9-4 中的代码无论 File::open 失败的原因是什么都会 panic!。然而,我们希望根据不同的失败原因采取不同的操作。如果 File::open 因为文件不存在而失败,我们希望创建该文件并返回新文件的句柄。如果 File::open 因为任何其他原因失败——例如,因为我们没有打开文件的权限——我们仍然希望代码像 Listing 9-4 中那样 panic!。为此,我们添加了一个内部的 match 表达式,如 Listing 9-5 所示。

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {e:?}"),
            },
            other_error => {
                panic!("Problem opening the file: {other_error:?}");
            }
        },
    };
}

File::openErr 变体中返回的值的类型是 io::Error,这是标准库提供的一个结构体。这个结构体有一个 kind 方法,我们可以调用它来获取一个 io::ErrorKind 值。io::ErrorKind 枚举由标准库提供,其变体表示可能由 io 操作导致的不同类型的错误。我们想要使用的变体是 ErrorKind::NotFound,它表示我们尝试打开的文件尚不存在。因此,我们在 greeting_file_result 上进行匹配,但我们也对 error.kind() 进行内部匹配。

我们在内部匹配中想要检查的条件是 error.kind() 返回的值是否是 ErrorKind 枚举的 NotFound 变体。如果是,我们尝试使用 File::create 创建文件。然而,因为 File::create 也可能失败,我们需要在内部 match 表达式中添加第二个分支。当文件无法创建时,会打印不同的错误消息。外部 match 的第二个分支保持不变,因此程序会在除文件缺失错误之外的任何错误上 panic。

使用 match 处理 Result<T, E> 的替代方案

这有很多 matchmatch 表达式非常有用,但也非常原始。在第13章中,你将学习闭包,它们可以与 Result<T, E> 上定义的许多方法一起使用。这些方法在处理 Result<T, E> 值时比使用 match 更简洁。

例如,下面是另一种编写与 Listing 9-5 相同逻辑的方式,这次使用闭包和 unwrap_or_else 方法:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Problem creating the file: {error:?}");
            })
        } else {
            panic!("Problem opening the file: {error:?}");
        }
    });
}

尽管这段代码的行为与 Listing 9-5 相同,但它不包含任何 match 表达式,并且更易于阅读。在你阅读第13章后,再回到这个例子,并在标准库文档中查找 unwrap_or_else 方法。当你处理错误时,许多这些方法可以清理大量的嵌套 match 表达式。

错误时 panic 的快捷方式:unwrapexpect

使用 match 效果很好,但它可能有点冗长,并且并不总是能很好地传达意图。Result<T, E> 类型定义了许多辅助方法来执行各种更具体的任务。unwrap 方法是一个快捷方法,其实现方式与我们编写的 match 表达式相同(如 Listing 9-4 所示)。如果 Result 值是 Ok 变体,unwrap 将返回 Ok 中的值。如果 ResultErr 变体,unwrap 将为我们调用 panic! 宏。以下是 unwrap 的实际示例:

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap();
}

如果我们运行此代码而没有 hello.txt 文件,我们将看到来自 unwrap 方法调用的 panic! 的错误消息:

thread 'main' panicked at src/main.rs:4:49:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }

类似地,expect 方法让我们可以选择 panic! 错误消息。使用 expect 而不是 unwrap 并提供良好的错误消息可以传达你的意图,并使追踪 panic 的来源更容易。expect 的语法如下:

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")
        .expect("hello.txt should be included in this project");
}

我们以与 unwrap 相同的方式使用 expect:返回文件句柄或调用 panic! 宏。expect 在其调用 panic! 时使用的错误消息将是我们传递给 expect 的参数,而不是 unwrap 使用的默认 panic! 消息。以下是它的样子:

thread 'main' panicked at src/main.rs:5:10:
hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "No such file or directory" }

在生产质量的代码中,大多数 Rust 开发者选择 expect 而不是 unwrap,并提供更多关于为什么操作预期总是成功的上下文。这样,如果你的假设被证明是错误的,你将有更多信息用于调试。

传播错误

当函数的实现调用可能失败的东西时,与其在函数本身内处理错误,不如将错误返回给调用代码,以便它可以决定如何处理。这被称为_传播_错误,并为调用代码提供了更多的控制权,因为调用代码可能有更多的信息或逻辑来决定如何处理错误,而不是你在代码上下文中可用的信息。

例如,Listing 9-6 展示了一个从文件中读取用户名的函数。如果文件不存在或无法读取,此函数将把这些错误返回给调用该函数的代码。

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}
}

这个函数可以用更短的方式编写,但我们将从手动完成大部分操作开始,以便探索错误处理;最后,我们将展示更短的方式。首先让我们看一下函数的返回类型:Result<String, io::Error>。这意味着函数返回一个类型为 Result<T, E> 的值,其中泛型参数 T 被具体类型 String 填充,泛型类型 E 被具体类型 io::Error 填充。

如果此函数成功且没有任何问题,调用此函数的代码将收到一个包含 StringOk 值——该函数从文件中读取的用户名。如果此函数遇到任何问题,调用代码将收到一个包含 io::Error 实例的 Err 值,该实例包含有关问题的更多信息。我们选择 io::Error 作为此函数的返回类型,因为这是我们在函数体中调用的两个可能失败的操作返回的错误值类型:File::open 函数和 read_to_string 方法。

函数体首先调用 File::open 函数。然后我们使用类似于 Listing 9-4 中的 match 处理 Result 值。如果 File::open 成功,模式变量 file 中的文件句柄将成为可变变量 username_file 中的值,函数继续执行。在 Err 情况下,我们使用 return 关键字提前从函数中返回,并将 File::open 的错误值(现在在模式变量 e 中)作为此函数的错误值返回给调用代码。

因此,如果我们在 username_file 中有一个文件句柄,函数然后创建一个新的 String 变量 username,并在 username_file 中的文件句柄上调用 read_to_string 方法,将文件内容读入 usernameread_to_string 方法也返回一个 Result,因为它可能会失败,即使 File::open 成功了。因此,我们需要另一个 match 来处理该 Result:如果 read_to_string 成功,那么我们的函数就成功了,我们返回现在在 username 中的文件中的用户名,包装在 Ok 中。如果 read_to_string 失败,我们以与处理 File::open 返回值时相同的方式返回错误值。然而,我们不需要显式地说 return,因为这是函数中的最后一个表达式。

调用此代码的代码将处理获取包含用户名的 Ok 值或包含 io::ErrorErr 值。由调用代码决定如何处理这些值。如果调用代码收到 Err 值,它可以调用 panic! 并崩溃程序,使用默认用户名,或者从文件以外的其他地方查找用户名,例如。我们没有足够的信息来了解调用代码实际想要做什么,因此我们将所有成功或错误信息向上传播,以便它适当地处理。

这种传播错误的模式在 Rust 中非常常见,以至于 Rust 提供了问号运算符 ? 来使这更容易。

传播错误的快捷方式:? 运算符

Listing 9-7 展示了 read_username_from_file 的一个实现,其功能与 Listing 9-6 相同,但此实现使用了 ? 运算符。

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username_file = File::open("hello.txt")?;
    let mut username = String::new();
    username_file.read_to_string(&mut username)?;
    Ok(username)
}
}

? 放在 Result 值之后,其定义方式与我们定义的 match 表达式几乎相同,用于处理 Listing 9-6 中的 Result 值。如果 Result 的值是 OkOk 中的值将从该表达式中返回,程序将继续执行。如果值是 ErrErr 将从整个函数中返回,就像我们使用了 return 关键字一样,因此错误值会传播给调用代码。

Listing 9-6 中的 match 表达式与 ? 运算符之间有一个区别:? 运算符调用的错误值会通过标准库中 From trait 定义的 from 函数进行转换,该函数用于将值从一种类型转换为另一种类型。当 ? 运算符调用 from 函数时,接收到的错误类型将转换为当前函数返回类型中定义的错误类型。这在一个函数返回一个错误类型以表示函数可能失败的所有方式时非常有用,即使部分可能因许多不同的原因而失败。

例如,我们可以将 Listing 9-7 中的 read_username_from_file 函数更改为返回我们定义的自定义错误类型 OurError。如果我们还定义了 impl From<io::Error> for OurError 以从 io::Error 构造 OurError 的实例,那么 read_username_from_file 函数体中的 ? 运算符调用将调用 from 并转换错误类型,而无需向函数添加更多代码。

在 Listing 9-7 的上下文中,File::open 调用末尾的 ? 将返回 Ok 中的值给变量 username_file。如果发生错误,? 运算符将提前从整个函数中返回,并将任何 Err 值返回给调用代码。同样的逻辑适用于 read_to_string 调用末尾的 ?

? 运算符消除了大量的样板代码,并使此函数的实现更简单。我们甚至可以通过在 ? 之后立即链接方法调用来进一步缩短此代码,如 Listing 9-8 所示。

#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();

    File::open("hello.txt")?.read_to_string(&mut username)?;

    Ok(username)
}
}

我们将 username 中新的 String 的创建移到了函数的开头;这部分没有改变。我们没有创建变量 username_file,而是将 read_to_string 的调用直接链接到 File::open("hello.txt")? 的结果上。我们仍然在 read_to_string 调用的末尾有一个 ?,并且当 File::openread_to_string 都成功时,我们仍然返回包含 usernameOk 值,而不是返回错误。功能再次与 Listing 9-6 和 Listing 9-7 相同;这只是编写它的另一种更符合人体工程学的方式。

Listing 9-9 展示了使用 fs::read_to_string 使此代码更短的方式。

#![allow(unused)]
fn main() {
use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}
}

将文件读入字符串是一个相当常见的操作,因此标准库提供了方便的 fs::read_to_string 函数,它打开文件,创建一个新的 String,读取文件内容,将内容放入该 String 中,并返回它。当然,使用 fs::read_to_string 并没有给我们机会解释所有的错误处理,所以我们首先以更长的方式完成了它。

? 运算符可以在哪里使用

? 运算符只能用于返回类型与 ? 使用的值兼容的函数。这是因为 ? 运算符被定义为从函数中提前返回值,其方式与我们定义的 match 表达式相同(如 Listing 9-6 所示)。在 Listing 9-6 中,match 使用的是 Result 值,而提前返回的分支返回的是 Err(e) 值。函数的返回类型必须是 Result,以便与此 return 兼容。

在 Listing 9-10 中,让我们看看如果我们在 main 函数中使用 ? 运算符,而返回类型与我们使用 ? 的值的类型不兼容时,我们会得到什么错误。

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")?;
}

此代码打开一个文件,这可能会失败。? 运算符跟随 File::open 返回的 Result 值,但此 main 函数的返回类型是 (),而不是 Result。当我们编译此代码时,我们会得到以下错误消息:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:4:48
  |
3 | fn main() {
  | --------- this function should return `Result` or `Option` to accept `?`
4 |     let greeting_file = File::open("hello.txt")?;
  |                                                ^ cannot use the `?` operator in a function that returns `()`
  |
  = help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`
help: consider adding return type
  |
3 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
4 |     let greeting_file = File::open("hello.txt")?;
5 +     Ok(())
  |

For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` (bin "error-handling") due to 1 previous error

此错误指出,我们只能在返回 ResultOption 或实现 FromResidual 的其他类型的函数中使用 ? 运算符。

要修复此错误,你有两个选择。一个选择是更改函数的返回类型,使其与你使用 ? 运算符的值兼容,只要你没有限制阻止这样做。另一个选择是使用 matchResult<T, E> 方法之一以适当的方式处理 Result<T, E>

错误消息还提到 ? 也可以与 Option<T> 值一起使用。与在 Result 上使用 ? 一样,你只能在返回 Option 的函数中对 Option 使用 ?。当在 Option<T> 上调用 ? 运算符时,其行为与在 Result<T, E> 上调用时的行为类似:如果值是 NoneNone 将从函数中提前返回。如果值是 SomeSome 中的值是表达式的结果值,函数继续执行。Listing 9-11 有一个函数示例,它查找给定文本中第一行的最后一个字符。

fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}

fn main() {
    assert_eq!(
        last_char_of_first_line("Hello, world\nHow are you today?"),
        Some('d')
    );

    assert_eq!(last_char_of_first_line(""), None);
    assert_eq!(last_char_of_first_line("\nhi"), None);
}

此函数返回 Option<char>,因为那里可能有一个字符,但也可能没有。此代码接受 text 字符串切片参数,并对其调用 lines 方法,该方法返回字符串中行的迭代器。因为此函数想要检查第一行,它调用迭代器上的 next 以获取迭代器中的第一个值。如果 text 是空字符串,此 next 调用将返回 None,在这种情况下,我们使用 ? 停止并从 last_char_of_first_line 返回 None。如果 text 不是空字符串,next 将返回一个包含 text 中第一行字符串切片的 Some 值。

? 提取字符串切片,我们可以对该字符串切片调用 chars 以获取其字符的迭代器。我们对第一行的最后一个字符感兴趣,因此我们调用 last 以返回迭代器中的最后一项。这是一个 Option,因为第一行可能是空字符串;例如,如果 text 以空行开头但在其他行上有字符,如 "\nhi"。然而,如果第一行上有最后一个字符,它将在 Some 变体中返回。中间的 ? 运算符为我们提供了一种简洁的方式来表达此逻辑,允许我们在一行中实现该函数。如果我们不能在 Option 上使用 ? 运算符,我们将不得不使用更多的方法调用或 match 表达式来实现此逻辑。

请注意,你可以在返回 Result 的函数中对 Result 使用 ? 运算符,也可以在返回 Option 的函数中对 Option 使用 ? 运算符,但你不能混用。? 运算符不会自动将 Result 转换为 Option,反之亦然;在这些情况下,你可以使用 Result 上的 ok 方法或 Option 上的 ok_or 方法显式进行转换。

到目前为止,我们使用的所有 main 函数都返回 ()main 函数是特殊的,因为它是可执行程序的入口点和退出点,并且对其返回类型有限制,以便程序按预期行为。

幸运的是,main 也可以返回 Result<(), E>。Listing 9-12 包含 Listing 9-10 中的代码,但我们将 main 的返回类型更改为 Result<(), Box<dyn Error>>,并在末尾添加了返回值 Ok(())。此代码现在将编译。

use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello.txt")?;

    Ok(())
}

Box<dyn Error> 类型是一个_特征对象_,我们将在第18章的“使用允许不同类型值的特征对象”中讨论它。现在,你可以将 Box<dyn Error> 理解为“任何类型的错误”。在 main 函数中使用 ? 运算符处理 Result 值是允许的,因为它允许任何 Err 值提前返回。即使此 main 函数体只会返回 std::io::Error 类型的错误,通过指定 Box<dyn Error>,即使向 main 函数体添加更多返回其他错误的代码,此签名也将继续正确。

main 函数返回 Result<(), E> 时,如果 main 返回 Ok(()),可执行文件将以 0 退出;如果 main 返回 Err 值,可执行文件将以非零值退出。用 C 编写的可执行文件在退出时返回整数:成功退出的程序返回整数 0,而错误的程序返回其他非零整数。Rust 也从可执行文件返回整数以与此约定兼容。

main 函数可以返回任何实现std::process::Termination trait的类型,该 trait 包含一个返回 ExitCodereport 函数。有关为你自己的类型实现 Termination trait 的更多信息,请参阅标准库文档。

现在我们已经讨论了调用 panic! 或返回 Result 的细节,让我们回到在哪些情况下使用哪种方法更合适的主题。

使用 panic! 还是不使用 panic!

那么,你该如何决定何时应该调用 panic!,何时应该返回 Result 呢?当代码 panic 时,是没有恢复的办法的。你可以在任何错误情况下调用 panic!,无论是否有恢复的可能,但这样你就代替调用代码做出了这个情况是不可恢复的决定。当你选择返回 Result 值时,你给了调用代码选择的机会。调用代码可以选择尝试以适合其情况的方式恢复,或者它可以决定在这种情况下 Err 值是不可恢复的,因此它可以调用 panic! 并将你的可恢复错误转换为不可恢复的错误。因此,返回 Result 是定义可能失败的函数时的默认选择。

在示例、原型代码和测试等情况下,编写 panic 的代码比返回 Result 更合适。让我们探讨一下原因,然后讨论编译器无法判断失败是不可能的,但你作为人类可以判断的情况。本章将以一些关于如何在库代码中决定是否 panic 的通用指南作为总结。

示例、原型代码和测试

当你编写示例来说明某个概念时,包含健壮的错误处理代码可能会使示例变得不那么清晰。在示例中,调用像 unwrap 这样可能会 panic 的方法是为了作为你想要应用程序处理错误的方式的占位符,这可能会根据代码的其他部分而有所不同。

同样,unwrapexpect 方法在原型设计时非常方便,在你准备好决定如何处理错误之前。它们在你的代码中留下了清晰的标记,以便在你准备好使程序更健壮时使用。

如果测试中的方法调用失败,你会希望整个测试失败,即使该方法不是正在测试的功能。因为 panic! 是测试标记为失败的方式,调用 unwrapexpect 正是应该发生的事情。

当你比编译器拥有更多信息时

当你有一些其他逻辑确保 Result 将有一个 Ok 值时,调用 unwrapexpect 也是合适的,但编译器不理解这种逻辑。你仍然需要处理一个 Result 值:无论你调用的操作在一般情况下都有可能失败,即使在你特定的情况下逻辑上是不可能的。如果你可以通过手动检查代码确保永远不会有一个 Err 变体,那么调用 unwrap 是完全可接受的,甚至在 expect 文本中记录你认为永远不会有一个 Err 变体的原因会更好。这里有一个例子:

fn main() {
    use std::net::IpAddr;

    let home: IpAddr = "127.0.0.1"
        .parse()
        .expect("Hardcoded IP address should be valid");
}

我们通过解析一个硬编码的字符串来创建一个 IpAddr 实例。我们可以看到 127.0.0.1 是一个有效的 IP 地址,所以在这里使用 expect 是可以接受的。然而,拥有一个硬编码的有效字符串并不会改变 parse 方法的返回类型:我们仍然会得到一个 Result 值,编译器仍然会让我们处理 Result,就好像 Err 变体是有可能的一样,因为编译器不够聪明,无法看到这个字符串始终是一个有效的 IP 地址。如果 IP 地址字符串来自用户而不是硬编码到程序中,因此确实有可能失败,我们肯定会希望以更健壮的方式处理 Result。提到这个 IP 地址是硬编码的假设会提示我们,如果将来需要从其他来源获取 IP 地址,我们需要将 expect 改为更好的错误处理代码。

错误处理指南

当你的代码可能进入不良状态时,建议让你的代码 panic。在这种情况下,不良状态 是指某些假设、保证、契约或不变量被破坏,例如当无效值、矛盾值或缺失值传递给你的代码时——再加上以下一个或多个条件:

  • 不良状态是意外的,而不是偶尔可能发生的事情,比如用户输入了错误格式的数据。
  • 此后的代码需要依赖不处于这种不良状态,而不是在每一步都检查问题。
  • 在你使用的类型中没有好的方法来编码这些信息。我们将在第 18 章的 “将状态和行为编码为类型” 中通过一个例子来说明我们的意思。

如果有人调用你的代码并传入没有意义的值,最好返回一个错误,以便库的用户可以决定在这种情况下他们想要做什么。然而,在继续下去可能不安全或有害的情况下,最好的选择可能是调用 panic! 并提醒使用你的库的人他们的代码中有 bug,以便他们在开发过程中修复它。同样,如果你调用的外部代码不在你的控制范围内,并且它返回了一个你无法修复的无效状态,panic! 通常是合适的。

然而,当失败是预期的时候,返回 Result 比调用 panic! 更合适。例如,解析器接收到格式错误的数据或 HTTP 请求返回一个表明你已经达到速率限制的状态。在这些情况下,返回 Result 表明失败是一个预期的可能性,调用代码必须决定如何处理。

当你的代码执行一个操作,如果使用无效值调用可能会使用户处于风险之中时,你的代码应该首先验证这些值是否有效,如果值无效则 panic。这主要是出于安全原因:尝试对无效数据进行操作可能会使你的代码暴露于漏洞中。这是标准库在你尝试越界内存访问时会调用 panic! 的主要原因:尝试访问不属于当前数据结构的内存是一个常见的安全问题。函数通常有契约:只有在输入满足特定要求时,它们的行为才能得到保证。当契约被违反时 panic 是有意义的,因为契约违反总是表明调用方的 bug,这不是你希望调用代码必须显式处理的一种错误。事实上,调用代码没有合理的方式来恢复;调用程序员需要修复代码。函数的契约,尤其是当违反会导致 panic 时,应该在函数的 API 文档中解释。

然而,在所有函数中进行大量错误检查会显得冗长且烦人。幸运的是,你可以使用 Rust 的类型系统(以及编译器进行的类型检查)来为你完成许多检查。如果你的函数有一个特定类型作为参数,你可以继续进行代码逻辑,知道编译器已经确保你有一个有效的值。例如,如果你有一个类型而不是 Option,你的程序期望有某些东西而不是什么都没有。然后你的代码不必处理 SomeNone 变体的两种情况:它只会有一个肯定有值的情况。尝试将空值传递给你的函数的代码甚至不会编译,因此你的函数不必在运行时检查这种情况。另一个例子是使用无符号整数类型,如 u32,它确保参数永远不会是负数。

创建自定义类型进行验证

让我们进一步利用 Rust 的类型系统来确保我们有一个有效的值,并看看如何创建一个用于验证的自定义类型。回想一下第 2 章中的猜数字游戏,我们的代码要求用户猜一个 1 到 100 之间的数字。我们在检查用户的猜测与我们的秘密数字之前从未验证过用户的猜测是否在这些数字之间;我们只验证了猜测是正数。在这种情况下,后果并不严重:我们的“太高”或“太低”的输出仍然是正确的。但引导用户进行有效的猜测并在用户猜测超出范围的数字时与用户输入字母时具有不同的行为将是一个有用的增强。

一种方法是解析猜测为 i32 而不是仅解析为 u32 以允许潜在的负数,然后添加一个检查以确保数字在范围内,如下所示:

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        // --snip--

        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: i32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        if guess < 1 || guess > 100 {
            println!("The secret number will be between 1 and 100.");
            continue;
        }

        match guess.cmp(&secret_number) {
            // --snip--
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

if 表达式检查我们的值是否超出范围,告诉用户问题,并调用 continue 开始循环的下一次迭代并要求另一个猜测。在 if 表达式之后,我们可以继续进行 guess 和秘密数字之间的比较,知道 guess 在 1 到 100 之间。

然而,这并不是一个理想的解决方案:如果绝对关键的是程序只在 1 到 100 之间的值上操作,并且它有许多函数有这个要求,那么在每个函数中进行这样的检查将是繁琐的(并且可能会影响性能)。

相反,我们可以创建一个新类型,并将验证放在一个函数中以创建该类型的实例,而不是到处重复验证。这样,函数在它们的签名中使用新类型是安全的,并且可以自信地使用它们接收到的值。清单 9-13 展示了一种定义 Guess 类型的方法,只有在 new 函数接收到 1 到 100 之间的值时才会创建 Guess 的实例。

#![allow(unused)]
fn main() {
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}

首先我们定义一个名为 Guess 的结构体,它有一个名为 value 的字段,该字段保存一个 i32。这是数字将存储的地方。

然后我们在 Guess 上实现一个名为 new 的关联函数,该函数创建 Guess 值的实例。new 函数被定义为一个名为 valuei32 类型的参数,并返回一个 Guessnew 函数体中的代码测试 value 以确保它在 1 到 100 之间。如果 value 没有通过这个测试,我们调用 panic!,这将提醒编写调用代码的程序员他们有一个需要修复的 bug,因为创建一个 value 超出这个范围的 Guess 将违反 Guess::new 所依赖的契约。Guess::new 可能 panic 的条件应该在其公开的 API 文档中讨论;我们将在第 14 章中介绍在 API 文档中指示 panic! 可能性的文档约定。如果 value 通过了测试,我们创建一个新的 Guess,其 value 字段设置为 value 参数,并返回 Guess

接下来,我们实现一个名为 value 的方法,该方法借用 self,没有其他参数,并返回一个 i32。这种类型的方法有时被称为getter,因为它的目的是从其字段中获取一些数据并返回它。这个公共方法是必要的,因为 Guess 结构体的 value 字段是私有的。value 字段是私有的很重要,这样使用 Guess 结构体的代码不允许直接设置 value:模块外的代码必须使用 Guess::new 函数来创建 Guess 的实例,从而确保没有一种方式可以让 Guess 拥有一个未通过 Guess::new 函数中的条件检查的 value

一个函数如果有一个参数或只返回 1 到 100 之间的数字,那么它可以在其签名中声明它接受或返回一个 Guess 而不是 i32,并且不需要在其主体中进行任何额外的检查。

总结

Rust 的错误处理功能旨在帮助你编写更健壮的代码。panic! 宏表示你的程序处于无法处理的状态,并让你告诉进程停止,而不是尝试继续使用无效或不正确的值。Result 枚举使用 Rust 的类型系统来指示操作可能会以你的代码可以恢复的方式失败。你可以使用 Result 来告诉调用你的代码的代码它需要处理潜在的成功或失败。在适当的情况下使用 panic!Result 将使你的代码在面对不可避免的问题时更加可靠。

现在你已经看到了标准库如何使用泛型与 OptionResult 枚举的有用方式,我们将讨论泛型的工作原理以及如何在你的代码中使用它们。

泛型、Trait 和生命周期

每种编程语言都有有效处理概念重复的工具。在 Rust 中,其中一个工具就是 泛型:具体类型或其他属性的抽象替代。我们可以在编译和运行代码时不知道泛型的具体内容的情况下,表达泛型的行为或它们与其他泛型的关系。

函数可以接受某些泛型类型的参数,而不是像 i32String 这样的具体类型,就像它们接受未知值的参数以在多个具体值上运行相同的代码一样。事实上,我们已经在第 6 章中使用过 Option<T>,在第 8 章中使用过 Vec<T>HashMap<K, V>,在第 9 章中使用过 Result<T, E>。在本章中,你将探索如何使用泛型定义自己的类型、函数和方法!

首先,我们将回顾如何提取函数以减少代码重复。然后,我们将使用相同的技术从两个仅在参数类型上不同的函数中创建一个泛型函数。我们还将解释如何在结构体和枚举定义中使用泛型类型。

然后,你将学习如何使用 trait 以泛型的方式定义行为。你可以将 trait 与泛型类型结合,以限制泛型类型只接受具有特定行为的类型,而不是任意类型。

最后,我们将讨论 生命周期:一种泛型,它向编译器提供有关引用如何相互关联的信息。生命周期允许我们向编译器提供足够的信息,以便它能够确保引用在更多情况下有效,而不是在没有我们帮助的情况下。

通过提取函数消除重复

泛型允许我们用代表多个类型的占位符替换特定类型,以消除代码重复。在深入泛型语法之前,让我们先看看如何通过提取函数来消除不涉及泛型类型的重复代码,该函数用代表多个值的占位符替换特定值。然后,我们将应用相同的技术来提取一个泛型函数!通过了解如何识别可以提取到函数中的重复代码,你将开始识别可以使用泛型的重复代码。

我们将从 Listing 10-1 中的简短程序开始,该程序在列表中查找最大的数字。

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");
    assert_eq!(*largest, 100);
}

我们将整数列表存储在变量 number_list 中,并将列表中第一个数字的引用存储在名为 largest 的变量中。然后,我们遍历列表中的所有数字,如果当前数字大于存储在 largest 中的数字,则替换该变量中的引用。然而,如果当前数字小于或等于到目前为止看到的最大数字,变量不会改变,代码继续处理列表中的下一个数字。在考虑了列表中的所有数字后,largest 应该引用最大的数字,在本例中为 100。

我们现在被要求在两个不同的数字列表中查找最大的数字。为此,我们可以选择复制 Listing 10-1 中的代码,并在程序的两个不同位置使用相同的逻辑,如 Listing 10-2 所示。

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");
}

尽管这段代码可以工作,但重复代码是繁琐且容易出错的。当我们想要更改代码时,还必须记住在多个地方更新代码。

为了消除这种重复,我们将通过定义一个函数来创建一个抽象,该函数对作为参数传递的任何整数列表进行操作。这个解决方案使我们的代码更清晰,并让我们抽象地表达在列表中查找最大数字的概念。

在 Listing 10-3 中,我们将查找最大数字的代码提取到一个名为 largest 的函数中。然后我们调用该函数来查找 Listing 10-2 中两个列表中的最大数字。我们也可以在未来对任何其他 i32 值列表使用该函数。

fn largest(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 100);

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let result = largest(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 6000);
}

largest 函数有一个名为 list 的参数,它代表我们可能传递给函数的任何具体 i32 值切片。因此,当我们调用该函数时,代码会在我们传递的特定值上运行。

总结一下,我们将代码从 Listing 10-2 更改为 Listing 10-3 的步骤如下:

  1. 识别重复代码。
  2. 将重复代码提取到函数体中,并在函数签名中指定该代码的输入和返回值。
  3. 更新重复代码的两个实例以调用函数。

接下来,我们将使用这些相同的步骤与泛型一起减少代码重复。就像函数体可以对抽象的 list 而不是特定值进行操作一样,泛型允许代码对抽象类型进行操作。

例如,假设我们有两个函数:一个在 i32 值切片中查找最大项,另一个在 char 值切片中查找最大项。我们如何消除这种重复?让我们找出答案!

泛型数据类型

我们使用泛型来为函数签名或结构体等项创建定义,然后可以将其用于许多不同的具体数据类型。首先,我们来看看如何使用泛型定义函数、结构体、枚举和方法。然后我们将讨论泛型如何影响代码性能。

在函数定义中

当定义一个使用泛型的函数时,我们将泛型放在函数的签名中,通常我们会在这里指定参数和返回值的数据类型。这样做使我们的代码更加灵活,并为函数的调用者提供更多功能,同时防止代码重复。

继续我们的 largest 函数,Listing 10-4 展示了两个函数,它们都在切片中查找最大值。然后我们将这些函数合并为一个使用泛型的单一函数。

fn largest_i32(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> &char {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 100);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&char_list);
    println!("The largest char is {result}");
    assert_eq!(*result, 'y');
}

largest_i32 函数是我们在 Listing 10-3 中提取的函数,它在切片中查找最大的 i32largest_char 函数在切片中查找最大的 char。函数体中的代码相同,因此我们通过在单一函数中引入泛型类型参数来消除重复。

为了在新函数中参数化类型,我们需要为类型参数命名,就像我们为函数的值参数命名一样。你可以使用任何标识符作为类型参数名称。但我们将使用 T,因为按照惯例,Rust 中的类型参数名称很短,通常只有一个字母,并且 Rust 的类型命名约定是驼峰式命名法。Ttype 的缩写,是大多数 Rust 程序员的默认选择。

当我们在函数体中使用参数时,我们必须在签名中声明参数名称,以便编译器知道该名称的含义。同样,当我们在函数签名中使用类型参数名称时,我们必须在使用之前声明类型参数名称。为了定义泛型 largest 函数,我们将类型名称声明放在尖括号 <> 中,放在函数名称和参数列表之间,如下所示:

fn largest<T>(list: &[T]) -> &T {

我们将这个定义理解为:函数 largest 对某个类型 T 是泛型的。该函数有一个名为 list 的参数,它是类型 T 的值的切片。largest 函数将返回对相同类型 T 的值的引用。

Listing 10-5 展示了使用泛型数据类型在其签名中的组合 largest 函数定义。该列表还展示了如何使用 i32 值或 char 值的切片调用该函数。请注意,此代码尚无法编译,但我们将在本章稍后修复它。

fn largest<T>(list: &[T]) -> &T {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {result}");

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {result}");
}

如果我们现在编译此代码,我们将得到以下错误:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T` with trait `PartialOrd`
  |
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
  |             ++++++++++++++++++++++

For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

帮助文本提到了 std::cmp::PartialOrd,这是一个 trait,我们将在下一节中讨论 trait。现在,只需知道此错误表明 largest 的函数体不适用于 T 可能的所有类型。因为我们希望在函数体中比较类型 T 的值,所以我们只能使用可以排序的类型。为了启用比较,标准库提供了 std::cmp::PartialOrd trait,你可以在类型上实现它(有关此 trait 的更多信息,请参见附录 C)。通过遵循帮助文本的建议,我们将 T 的有效类型限制为仅实现 PartialOrd 的类型,此示例将编译,因为标准库在 i32char 上都实现了 PartialOrd

在结构体定义中

我们还可以使用 <> 语法定义一个或多个字段使用泛型类型参数的结构体。Listing 10-6 定义了一个 Point<T> 结构体,用于保存任何类型的 xy 坐标值。

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}

在结构体定义中使用泛型的语法与函数定义中的语法类似。首先,我们在结构体名称后面的尖括号内声明类型参数的名称。然后我们在结构体定义中使用泛型类型,而不是指定具体的数据类型。

请注意,因为我们只使用了一个泛型类型来定义 Point<T>,所以这个定义表示 Point<T> 结构体对某个类型 T 是泛型的,并且字段 xy 都是相同的类型,无论该类型是什么。如果我们创建一个 Point<T> 实例,其中包含不同类型的值,如 Listing 10-7 所示,我们的代码将无法编译。

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}

在这个例子中,当我们将整数值 5 赋给 x 时,我们让编译器知道泛型类型 T 对于这个 Point<T> 实例将是一个整数。然后当我们为 y 指定 4.0 时,我们将其定义为与 x 相同的类型,我们将得到一个类型不匹配的错误,如下所示:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
 --> src/main.rs:7:38
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integer, found floating-point number

For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

为了定义一个 Point 结构体,其中 xy 都是泛型但可以有不同的类型,我们可以使用多个泛型类型参数。例如,在 Listing 10-8 中,我们将 Point 的定义更改为对类型 TU 是泛型的,其中 x 是类型 Ty 是类型 U

struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}

现在所有显示的 Point 实例都是允许的!你可以在定义中使用任意多个泛型类型参数,但使用过多会使代码难以阅读。如果你发现代码中需要大量泛型类型,这可能表明你的代码需要重构为更小的部分。

在枚举定义中

正如我们对结构体所做的那样,我们可以定义枚举以在其变体中保存泛型数据类型。让我们再看一下标准库提供的 Option<T> 枚举,我们在第 6 章中使用过它:

#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

现在这个定义应该对你来说更有意义了。如你所见,Option<T> 枚举对类型 T 是泛型的,并且有两个变体:Some,它保存一个类型 T 的值,以及一个 None 变体,它不保存任何值。通过使用 Option<T> 枚举,我们可以表达可选值的抽象概念,并且因为 Option<T> 是泛型的,所以无论可选值的类型是什么,我们都可以使用这个抽象。

枚举也可以使用多个泛型类型。我们在第 9 章中使用的 Result 枚举的定义就是一个例子:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

Result 枚举对两种类型 TE 是泛型的,并且有两个变体:Ok,它保存一个类型 T 的值,以及 Err,它保存一个类型 E 的值。这个定义使得在任何我们可能成功(返回某种类型 T 的值)或失败(返回某种类型 E 的错误)的操作中使用 Result 枚举变得方便。事实上,这就是我们在 Listing 9-3 中用来打开文件的内容,其中 T 在文件成功打开时被填充为类型 std::fs::File,而 E 在打开文件时遇到问题时被填充为类型 std::io::Error

当你识别出代码中有多个结构体或枚举定义仅在它们保存的值的类型上有所不同时,你可以通过使用泛型类型来避免重复。

在方法定义中

我们可以在结构体和枚举上实现方法(正如我们在第 5 章中所做的那样),并在它们的定义中也使用泛型类型。Listing 10-9 展示了我们在 Listing 10-6 中定义的 Point<T> 结构体,并在其上实现了一个名为 x 的方法。

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

在这里,我们在 Point<T> 上定义了一个名为 x 的方法,它返回对字段 x 中的数据的引用。

请注意,我们必须在 impl 之后声明 T,以便我们可以使用 T 来指定我们正在为类型 Point<T> 实现方法。通过在 impl 之后声明 T 为泛型类型,Rust 可以识别 Point 中尖括号内的类型是泛型类型而不是具体类型。我们可以为这个泛型参数选择一个与结构体定义中声明的泛型参数不同的名称,但使用相同的名称是惯例。如果你在 impl 中编写一个声明泛型类型的方法,那么无论最终替换泛型类型的具体类型是什么,该方法都将定义在该类型的任何实例上。

我们还可以在定义类型的方法时对泛型类型指定约束。例如,我们可以仅在 Point<f32> 实例上实现方法,而不是在任何泛型类型的 Point<T> 实例上实现方法。在 Listing 10-10 中,我们使用具体类型 f32,这意味着我们没有在 impl 之后声明任何类型。

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

这段代码意味着类型 Point<f32> 将有一个 distance_from_origin 方法;其他 Point<T> 实例,其中 T 不是 f32 类型,将没有定义此方法。该方法测量我们的点距离坐标 (0.0, 0.0) 的点有多远,并使用仅适用于浮点类型的数学运算。

结构体定义中的泛型类型参数并不总是与你在同一结构体的方法签名中使用的泛型类型参数相同。Listing 10-11 使用泛型类型 X1Y1 用于 Point 结构体,并使用 X2Y2 用于 mixup 方法签名,以使示例更清晰。该方法创建一个新的 Point 实例,其中 x 值来自 self Point(类型为 X1),y 值来自传入的 Point(类型为 Y2)。

struct Point<X1, Y1> {
    x: X1,
    y: Y1,
}

impl<X1, Y1> Point<X1, Y1> {
    fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };

    let p3 = p1.mixup(p2);

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}

main 中,我们定义了一个 Point,其中 x 是一个 i32(值为 5),y 是一个 f64(值为 10.4)。p2 变量是一个 Point 结构体,其中 x 是一个字符串切片(值为 "Hello"),y 是一个 char(值为 c)。在 p1 上调用 mixup 并传入 p2 会给我们 p3,它将有一个 i32 类型的 x,因为 x 来自 p1p3 变量将有一个 char 类型的 y,因为 y 来自 p2println! 宏调用将打印 p3.x = 5, p3.y = c

这个例子的目的是展示一种情况,其中一些泛型参数在 impl 中声明,而另一些在方法定义中声明。在这里,泛型参数 X1Y1impl 之后声明,因为它们与结构体定义一起使用。泛型参数 X2Y2fn mixup 之后声明,因为它们仅与方法相关。

使用泛型的代码性能

你可能想知道使用泛型类型参数时是否存在运行时成本。好消息是,使用泛型类型不会使你的程序比使用具体类型时运行得更慢。

Rust 通过在编译时对使用泛型的代码进行单态化来实现这一点。单态化 是通过填充编译时使用的具体类型将泛型代码转换为特定代码的过程。在这个过程中,编译器执行与我们创建泛型函数时相反的步骤:编译器查看所有调用泛型代码的地方,并为调用泛型代码的具体类型生成代码。

让我们通过使用标准库的泛型 Option<T> 枚举来看看这是如何工作的:

#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}

当 Rust 编译此代码时,它会执行单态化。在这个过程中,编译器读取在 Option<T> 实例中使用的值,并识别出两种 Option<T>:一种是 i32,另一种是 f64。因此,它将 Option<T> 的泛型定义扩展为专门针对 i32f64 的两个定义,从而用特定的定义替换泛型定义。

单态化后的代码看起来类似于以下内容(编译器使用与我们在此处使用的名称不同的名称以进行说明):

enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

泛型 Option<T> 被编译器创建的特定定义所替换。因为 Rust 将泛型代码编译为指定每个实例中类型的代码,所以我们使用泛型时不会产生运行时成本。当代码运行时,它的表现就像我们手动复制每个定义一样。单态化过程使 Rust 的泛型在运行时非常高效。

Traits: 定义共享行为

一个 trait 定义了特定类型所具有的功能,并且可以与其他类型共享。我们可以使用 traits 以抽象的方式定义共享行为。我们可以使用 trait bounds 来指定泛型类型可以是具有特定行为的任何类型。

注意:Traits 类似于其他语言中通常称为 接口 的特性,尽管有一些差异。

定义一个 Trait

类型的行为由我们可以在该类型上调用的方法组成。如果我们可以对所有类型调用相同的方法,那么不同的类型就共享相同的行为。Trait 定义是一种将方法签名组合在一起的方式,以定义实现某些目的所需的一组行为。

例如,假设我们有多个结构体,它们持有各种类型和数量的文本:一个 NewsArticle 结构体,它持有一个在特定位置提交的新闻故事,以及一个 SocialPost,它最多可以有 280 个字符,并带有指示它是新帖子、转发还是对另一个帖子的回复的元数据。

我们想要创建一个名为 aggregator 的媒体聚合器库 crate,它可以显示可能存储在 NewsArticleSocialPost 实例中的数据摘要。为此,我们需要每种类型的摘要,并且我们将通过在实例上调用 summarize 方法来请求该摘要。Listing 10-12 展示了一个公共 Summary trait 的定义,该 trait 表达了这种行为。

pub trait Summary {
    fn summarize(&self) -> String;
}

在这里,我们使用 trait 关键字声明一个 trait,然后是该 trait 的名称,在本例中为 Summary。我们还将该 trait 声明为 pub,以便依赖于该 crate 的其他 crate 也可以使用该 trait,正如我们将在几个示例中看到的那样。在大括号内,我们声明了描述实现该 trait 的类型的行为的方法签名,在本例中为 fn summarize(&self) -> String

在方法签名之后,我们没有在大括号内提供实现,而是使用分号。实现该 trait 的每个类型都必须为方法的主体提供自己的自定义行为。编译器将强制要求任何具有 Summary trait 的类型都必须具有使用此签名定义的 summarize 方法。

一个 trait 的主体可以有多个方法:方法签名每行列出一个,每行以分号结尾。

在类型上实现 Trait

现在我们已经定义了 Summary trait 方法的期望签名,我们可以在媒体聚合器中的类型上实现它。Listing 10-13 展示了在 NewsArticle 结构体上实现 Summary trait 的代码,该实现使用标题、作者和位置来创建 summarize 的返回值。对于 SocialPost 结构体,我们将 summarize 定义为用户名后跟帖子的全部文本,假设帖子内容已经限制在 280 个字符以内。

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

在类型上实现 trait 类似于实现常规方法。不同之处在于,在 impl 之后,我们放置要实现的 trait 名称,然后使用 for 关键字,然后指定我们要为其实现 trait 的类型的名称。在 impl 块内,我们放置 trait 定义所定义的方法签名。我们没有在每个签名后添加分号,而是使用大括号并填充方法主体,以指定我们希望该 trait 的方法在特定类型上具有的具体行为。

现在库已经在 NewsArticleSocialPost 上实现了 Summary trait,crate 的用户可以像调用常规方法一样在 NewsArticleSocialPost 实例上调用 trait 方法。唯一的区别是用户必须将 trait 和类型都引入作用域。以下是一个二进制 crate 如何使用我们的 aggregator 库 crate 的示例:

use aggregator::{SocialPost, Summary};

fn main() {
    let post = SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    };

    println!("1 new social post: {}", post.summarize());
}

此代码打印 1 new post: horse_ebooks: of course, as you probably already know, people

依赖于 aggregator crate 的其他 crate 也可以将 Summary trait 引入作用域,以在其自己的类型上实现 Summary。需要注意的是,我们只能在 trait 或类型(或两者)是我们 crate 本地的情况下在类型上实现 trait。例如,我们可以将标准库中的 Display trait 实现为 SocialPost 自定义类型的一部分,作为我们 aggregator crate 功能的一部分,因为 SocialPost 类型是我们 aggregator crate 本地的。我们还可以在我们的 aggregator crate 中为 Vec<T> 实现 Summary,因为 Summary trait 是我们 aggregator crate 本地的。

但我们不能在外部类型上实现外部 trait。例如,我们不能在我们的 aggregator crate 中为 Vec<T> 实现 Display trait,因为 DisplayVec<T> 都是在标准库中定义的,而不是我们 aggregator crate 本地的。此限制是称为 一致性 的属性的一部分,更具体地说是 孤儿规则,之所以这样命名是因为父类型不存在。此规则确保其他人的代码不会破坏你的代码,反之亦然。如果没有此规则,两个 crate 可以为同一类型实现相同的 trait,而 Rust 将不知道使用哪个实现。

默认实现

有时为 trait 中的部分或全部方法提供默认行为是有用的,而不是要求每个类型都实现所有方法。然后,当我们在特定类型上实现 trait 时,我们可以保留或覆盖每个方法的默认行为。

在 Listing 10-14 中,我们为 Summary trait 的 summarize 方法指定了一个默认字符串,而不是像在 Listing 10-12 中那样仅定义方法签名。

pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Read more...)")
    }
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

要使用默认实现来总结 NewsArticle 实例,我们指定一个空的 impl 块,即 impl Summary for NewsArticle {}

尽管我们不再直接在 NewsArticle 上定义 summarize 方法,但我们提供了默认实现并指定 NewsArticle 实现了 Summary trait。因此,我们仍然可以在 NewsArticle 实例上调用 summarize 方法,如下所示:

use aggregator::{self, NewsArticle, Summary};

fn main() {
    let article = NewsArticle {
        headline: String::from("Penguins win the Stanley Cup Championship!"),
        location: String::from("Pittsburgh, PA, USA"),
        author: String::from("Iceburgh"),
        content: String::from(
            "The Pittsburgh Penguins once again are the best \
             hockey team in the NHL.",
        ),
    };

    println!("New article available! {}", article.summarize());
}

此代码打印 New article available! (Read more...)

创建默认实现不需要我们更改 Listing 10-13 中 SocialPostSummary 实现的任何内容。原因是覆盖默认实现的语法与实现没有默认实现的 trait 方法的语法相同。

默认实现可以调用同一 trait 中的其他方法,即使这些其他方法没有默认实现。通过这种方式,trait 可以提供大量有用的功能,而只需要实现者指定其中的一小部分。例如,我们可以定义 Summary trait 具有一个需要实现的 summarize_author 方法,然后定义一个具有默认实现的 summarize 方法,该方法调用 summarize_author 方法:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

要使用此版本的 Summary,我们只需要在类型上实现 trait 时定义 summarize_author

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

在我们定义了 summarize_author 之后,我们可以在 SocialPost 结构体的实例上调用 summarizesummarize 的默认实现将调用我们提供的 summarize_author 定义。因为我们实现了 summarize_author,所以 Summary trait 已经为我们提供了 summarize 方法的行为,而无需我们编写更多代码。以下是它的样子:

use aggregator::{self, SocialPost, Summary};

fn main() {
    let post = SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    };

    println!("1 new social post: {}", post.summarize());
}

此代码打印 1 new post: (Read more from @horse_ebooks...)

请注意,无法从同一方法的覆盖实现中调用默认实现。

Traits 作为参数

现在你已经知道如何定义和实现 traits,我们可以探索如何使用 traits 来定义接受许多不同类型的函数。我们将使用在 Listing 10-13 中在 NewsArticleSocialPost 类型上实现的 Summary trait 来定义一个 notify 函数,该函数在其 item 参数上调用 summarize 方法,该参数是实现了 Summary trait 的某种类型。为此,我们使用 impl Trait 语法,如下所示:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

我们没有为 item 参数指定具体类型,而是指定了 impl 关键字和 trait 名称。此参数接受任何实现了指定 trait 的类型。在 notify 的主体中,我们可以调用 item 上来自 Summary trait 的任何方法,例如 summarize。我们可以调用 notify 并传入 NewsArticleSocialPost 的任何实例。使用任何其他类型(例如 Stringi32)调用该函数的代码将无法编译,因为这些类型没有实现 Summary

Trait Bound 语法

impl Trait 语法适用于简单的情况,但它实际上是称为 trait bound 的较长形式的语法糖;它看起来像这样:

pub fn notify<T: Summary>(item: &T) {
    println!("Breaking news! {}", item.summarize());
}

这种较长形式与上一节中的示例等效,但更冗长。我们将 trait bounds 放在泛型类型参数声明之后,冒号后面和尖括号内。

impl Trait 语法很方便,并且在简单情况下可以使代码更简洁,而完整的 trait bound 语法可以在其他情况下表达更复杂的逻辑。例如,我们可以有两个实现了 Summary 的参数。使用 impl Trait 语法看起来像这样:

pub fn notify(item1: &impl Summary, item2: &impl Summary) {

如果我们希望此函数允许 item1item2 具有不同的类型(只要两种类型都实现了 Summary),那么使用 impl Trait 是合适的。但是,如果我们希望强制两个参数具有相同的类型,则必须使用 trait bound,如下所示:

pub fn notify<T: Summary>(item1: &T, item2: &T) {

指定为 item1item2 参数类型的泛型类型 T 约束了该函数,使得传递给 item1item2 参数的具体类型必须相同。

使用 + 语法指定多个 Trait Bounds

我们还可以指定多个 trait bounds。假设我们希望 notifyitem 上使用显示格式以及 summarize:我们在 notify 定义中指定 item 必须同时实现 DisplaySummary。我们可以使用 + 语法来做到这一点:

pub fn notify(item: &(impl Summary + Display)) {

+ 语法也适用于泛型类型的 trait bounds:

pub fn notify<T: Summary + Display>(item: &T) {

指定了两个 trait bounds 后,notify 的主体可以调用 summarize 并使用 {} 格式化 item

使用 where 子句使 Trait Bounds 更清晰

使用过多的 trait bounds 有其缺点。每个泛型都有自己的 trait bounds,因此具有多个泛型类型参数的函数可能会在函数名称和其参数列表之间包含大量 trait bound 信息,使得函数签名难以阅读。出于这个原因,Rust 提供了在函数签名之后使用 where 子句指定 trait bounds 的替代语法。因此,与其这样写:

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {

我们可以使用 where 子句,如下所示:

fn some_function<T, U>(t: &T, u: &U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{
    unimplemented!()
}

此函数的签名不那么杂乱:函数名称、参数列表和返回类型紧密地放在一起,类似于没有大量 trait bounds 的函数。

返回实现 Traits 的类型

我们还可以在返回位置使用 impl Trait 语法来返回实现了某个 trait 的某种类型的值,如下所示:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable() -> impl Summary {
    SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    }
}

通过使用 impl Summary 作为返回类型,我们指定 returns_summarizable 函数返回某种实现了 Summary trait 的类型,而不命名具体类型。在这种情况下,returns_summarizable 返回一个 SocialPost,但调用此函数的代码不需要知道这一点。

仅通过它实现的 trait 指定返回类型的能力在闭包和迭代器的上下文中特别有用,我们将在第 13 章中介绍。闭包和迭代器创建的类型只有编译器知道或类型非常长。impl Trait 语法让你可以简洁地指定函数返回某种实现了 Iterator trait 的类型,而无需写出非常长的类型。

但是,只有在返回单一类型时才能使用 impl Trait。例如,此代码返回 NewsArticleSocialPost,并将返回类型指定为 impl Summary,这是不允许的:

pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable(switch: bool) -> impl Summary {
    if switch {
        NewsArticle {
            headline: String::from(
                "Penguins win the Stanley Cup Championship!",
            ),
            location: String::from("Pittsburgh, PA, USA"),
            author: String::from("Iceburgh"),
            content: String::from(
                "The Pittsburgh Penguins once again are the best \
                 hockey team in the NHL.",
            ),
        }
    } else {
        SocialPost {
            username: String::from("horse_ebooks"),
            content: String::from(
                "of course, as you probably already know, people",
            ),
            reply: false,
            repost: false,
        }
    }
}

由于编译器如何实现 impl Trait 语法的限制,返回 NewsArticleSocialPost 是不允许的。我们将在第 18 章的 “使用允许不同类型值的 Trait 对象” 部分介绍如何编写具有此行为的函数。

使用 Trait Bounds 有条件地实现方法

通过使用带有泛型类型参数的 impl 块的 trait bound,我们可以有条件地为实现了指定 trait 的类型实现方法。例如,Listing 10-15 中的 Pair<T> 类型总是实现 new 函数以返回 Pair<T> 的新实例(回想一下第 5 章的 “定义方法” 部分,Selfimpl 块的类型的别名,在本例中为 Pair<T>)。但在下一个 impl 块中,Pair<T> 仅在其内部类型 T 实现了允许比较的 PartialOrd trait 和允许打印的 Display trait 时才实现 cmp_display 方法。

use std::fmt::Display;

struct Pair<T> {
    x: T,
    y: T,
}

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}

我们还可以为任何实现了另一个 trait 的类型有条件地实现一个 trait。为满足 trait bounds 的任何类型实现 trait 的代码称为 blanket implementations,并且在 Rust 标准库中广泛使用。例如,标准库在任何实现了 Display trait 的类型上实现了 ToString trait。标准库中的 impl 块看起来类似于以下代码:

impl<T: Display> ToString for T {
    // --snip--
}

因为标准库有这个 blanket implementation,我们可以在任何实现了 Display trait 的类型上调用由 ToString trait 定义的 to_string 方法。例如,我们可以将整数转换为相应的 String 值,因为整数实现了 Display

#![allow(unused)]
fn main() {
let s = 3.to_string();
}

Blanket implementations 出现在 trait 文档的“Implementors”部分。

Traits 和 trait bounds 让我们可以使用泛型类型参数来减少重复代码,同时也向编译器指定我们希望泛型类型具有特定行为。然后,编译器可以使用 trait bound 信息来检查与我们代码一起使用的所有具体类型是否提供了正确的行为。在动态类型语言中,如果我们在未定义方法的类型上调用方法,我们会在运行时收到错误。但 Rust 将这些错误移到编译时,因此我们被迫在代码甚至能够运行之前修复问题。此外,我们不必编写在运行时检查行为的代码,因为我们已经在编译时进行了检查。这样做可以提高性能,而不必放弃泛型的灵活性。

使用生命周期验证引用

生命周期是另一种我们已经使用过的泛型。与确保类型具有我们想要的行为不同,生命周期确保引用在我们需要它们的时候是有效的。

在第四章的“引用与借用”部分中,我们没有讨论的一个细节是:Rust 中的每一个引用都有一个生命周期,也就是该引用有效的作用域。大多数时候,生命周期是隐式的并且可以被推断出来,就像大多数时候类型可以被推断出来一样。只有在可能有多种类型时,我们才必须显式地标注类型。类似地,当引用的生命周期可能以几种不同的方式相关联时,我们必须显式地标注生命周期。Rust 要求我们使用泛型生命周期参数来标注这些关系,以确保在运行时使用的实际引用一定是有效的。

生命周期标注甚至不是大多数其他编程语言都有的概念,所以这可能会让人感到陌生。尽管我们不会在本章中完整地讨论生命周期,但我们会讨论你可能会遇到的生命周期语法的一些常见方式,以便你能够熟悉这个概念。

使用生命周期防止悬垂引用

生命周期的主要目的是防止悬垂引用,悬垂引用会导致程序引用它不应该引用的数据。考虑一下 Listing 10-16 中的程序,它有一个外部作用域和一个内部作用域。

fn main() {
    let r;

    {
        let x = 5;
        r = &x;
    }

    println!("r: {r}");
}

注意:Listing 10-16、10-17 和 10-23 中的示例声明了变量但没有给它们初始值,所以变量名存在于外部作用域中。乍一看,这似乎与 Rust 没有空值的特性相冲突。然而,如果我们尝试在给变量赋值之前使用它,我们会得到一个编译时错误,这表明 Rust 确实不允许空值。

外部作用域声明了一个没有初始值的变量 r,而内部作用域声明了一个初始值为 5 的变量 x。在内部作用域中,我们尝试将 r 的值设置为 x 的引用。然后内部作用域结束,我们尝试打印 r 的值。这段代码无法编译,因为 r 所引用的值在我们尝试使用它之前已经离开了作用域。以下是错误信息:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `x` does not live long enough
 --> src/main.rs:6:13
  |
5 |         let x = 5;
  |             - binding `x` declared here
6 |         r = &x;
  |             ^^ borrowed value does not live long enough
7 |     }
  |     - `x` dropped here while still borrowed
8 |
9 |     println!("r: {r}");
  |                  --- borrow later used here

For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

错误信息指出变量 x “活得不够长”。原因是 x 在内部作用域结束时(第 7 行)将离开作用域。但是 r 在外部作用域中仍然是有效的;因为它的作用域更大,我们说它“活得更长”。如果 Rust 允许这段代码工作,r 将会引用 x 离开作用域时被释放的内存,而我们尝试对 r 做的任何事情都不会正确工作。那么 Rust 是如何确定这段代码是无效的呢?它使用了一个借用检查器。

借用检查器

Rust 编译器有一个借用检查器,它通过比较作用域来确定所有的借用是否有效。Listing 10-17 展示了与 Listing 10-16 相同的代码,但带有标注显示变量的生命周期。

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {r}");   //          |
}                         // ---------+

在这里,我们将 r 的生命周期标注为 'a,将 x 的生命周期标注为 'b。如你所见,内部的 'b 块比外部的 'a 生命周期块小得多。在编译时,Rust 比较这两个生命周期的大小,发现 r 的生命周期是 'a,但它引用的内存的生命周期是 'b。程序被拒绝,因为 'b'a 短:引用的主体没有引用活得长。

Listing 10-18 修复了代码,使其没有悬垂引用,并且可以无错误地编译。

fn main() {
    let x = 5;            // ----------+-- 'b
                          //           |
    let r = &x;           // --+-- 'a  |
                          //   |       |
    println!("r: {r}");   //   |       |
                          // --+       |
}                         // ----------+

在这里,x 的生命周期是 'b,在这种情况下比 'a 大。这意味着 r 可以引用 x,因为 Rust 知道 r 中的引用在 x 有效时总是有效的。

现在你知道了引用的生命周期在哪里以及 Rust 如何分析生命周期以确保引用始终有效,让我们在函数的上下文中探索参数的泛型生命周期和返回值。

函数中的泛型生命周期

我们将编写一个函数,返回两个字符串切片中较长的一个。这个函数将接受两个字符串切片并返回一个字符串切片。在我们实现了 longest 函数后,Listing 10-19 中的代码应该打印 The longest string is abcd

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

注意,我们希望函数接受字符串切片,它们是引用,而不是字符串,因为我们不希望 longest 函数获取其参数的所有权。有关为什么我们在 Listing 10-19 中使用的参数是我们想要的,请参阅第四章的“字符串切片作为参数”部分。

如果我们尝试像 Listing 10-20 中那样实现 longest 函数,它将无法编译。

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}

相反,我们会得到以下关于生命周期的错误:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0106]: missing lifetime specifier
 --> src/main.rs:9:33
  |
9 | fn longest(x: &str, y: &str) -> &str {
  |               ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
  |
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  |           ++++     ++          ++          ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

帮助文本揭示了返回类型需要一个泛型生命周期参数,因为 Rust 无法判断返回的引用是指向 x 还是 y。实际上,我们也不知道,因为这个函数体中的 if 块返回一个指向 x 的引用,而 else 块返回一个指向 y 的引用!

当我们定义这个函数时,我们不知道将传递给这个函数的具体值,所以我们不知道 if 情况还是 else 情况会执行。我们也不知道将传递的引用的具体生命周期,所以我们不能像在 Listing 10-17 和 10-18 中那样查看作用域来确定我们返回的引用是否总是有效。借用检查器也无法确定这一点,因为它不知道 xy 的生命周期与返回值的生命周期之间的关系。为了修复这个错误,我们将添加泛型生命周期参数来定义引用之间的关系,以便借用检查器可以执行其分析。

生命周期标注语法

生命周期标注不会改变任何引用的生命周期。相反,它们描述了多个引用的生命周期之间的关系,而不影响生命周期。就像函数可以在签名中指定泛型类型参数时接受任何类型一样,函数可以通过指定泛型生命周期参数来接受具有任何生命周期的引用。

生命周期标注的语法有点不寻常:生命周期参数的名称必须以撇号(')开头,并且通常都是小写且非常短,就像泛型类型一样。大多数人使用 'a 作为第一个生命周期标注。我们将生命周期参数标注放在引用的 & 之后,使用空格将标注与引用的类型分开。

以下是一些示例:一个没有生命周期参数的 i32 引用,一个具有生命周期参数 'ai32 引用,以及一个也具有生命周期 'a 的可变 i32 引用。

&i32        // 一个引用
&'a i32     // 带有显式生命周期的引用
&'a mut i32 // 带有显式生命周期的可变引用

单独一个生命周期标注本身并没有太多意义,因为标注的目的是告诉 Rust 多个引用的泛型生命周期参数之间的关系。让我们看看在 longest 函数的上下文中,生命周期标注是如何相互关联的。

函数签名中的生命周期标注

要在函数签名中使用生命周期标注,我们需要在函数名和参数列表之间的尖括号内声明泛型生命周期参数,就像我们处理泛型类型参数一样。

我们希望签名表达以下约束:返回的引用在参数都有效时是有效的。这是参数生命周期与返回值之间的关系。我们将生命周期命名为 'a,然后将其添加到每个引用中,如 Listing 10-21 所示。

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

这段代码应该可以编译,并且当我们将其与 Listing 10-19 中的 main 函数一起使用时,会产生我们想要的结果。

函数签名现在告诉 Rust,对于某个生命周期 'a,函数接受两个参数,这两个参数都是至少与生命周期 'a 一样长的字符串切片。函数签名还告诉 Rust,从函数返回的字符串切片至少与生命周期 'a 一样长。实际上,这意味着 longest 函数返回的引用的生命周期与函数参数所引用的值的生命周期中较短的那个相同。这些关系是我们希望 Rust 在分析这段代码时使用的。

请记住,当我们在函数签名中指定生命周期参数时,我们并没有改变任何传入或返回的值的生命周期。相反,我们指定的是借用检查器应该拒绝任何不遵守这些约束的值。注意,longest 函数不需要确切知道 xy 会活多久,只需要某个作用域可以替代 'a 来满足这个签名。

在函数中标注生命周期时,标注放在函数签名中,而不是函数体中。生命周期标注成为函数契约的一部分,就像签名中的类型一样。让函数签名包含生命周期契约意味着 Rust 编译器所做的分析可以更简单。如果函数标注或调用方式有问题,编译器错误可以更精确地指出我们代码中的问题和约束。如果 Rust 编译器对我们希望生命周期关系是什么做出更多推断,编译器可能只能指出我们代码中离问题原因很远的某个使用。

当我们向 longest 传递具体引用时,替代 'a 的具体生命周期是 x 的作用域与 y 的作用域重叠的部分。换句话说,泛型生命周期 'a 将获得等于 xy 的生命周期中较短的那个具体生命周期。因为我们用相同的生命周期参数 'a 标注了返回的引用,所以返回的引用在 xy 的生命周期中较短的那个期间也是有效的。

让我们看看通过传递具有不同具体生命周期的引用,生命周期标注如何限制 longest 函数。Listing 10-22 是一个简单的例子。

fn main() {
    let string1 = String::from("long string is long");

    {
        let string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {result}");
    }
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

在这个例子中,string1 在外部作用域结束之前有效,string2 在内部作用域结束之前有效,而 result 引用的内容在内部作用域结束之前有效。运行这段代码,你会看到借用检查器通过了;它将编译并打印 The longest string is long string is long

接下来,让我们尝试一个例子,展示 result 中的引用的生命周期必须是两个参数中较短的那个。我们将 result 变量的声明移到内部作用域之外,但将 result 变量的赋值留在 string2 的作用域内。然后我们将使用 resultprintln! 移到内部作用域之外,在内部作用域结束后。Listing 10-23 中的代码将无法编译。

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

当我们尝试编译这段代码时,会得到以下错误:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `string2` does not live long enough
 --> src/main.rs:6:44
  |
5 |         let string2 = String::from("xyz");
  |             ------- binding `string2` declared here
6 |         result = longest(string1.as_str(), string2.as_str());
  |                                            ^^^^^^^ borrowed value does not live long enough
7 |     }
  |     - `string2` dropped here while still borrowed
8 |     println!("The longest string is {result}");
  |                                     -------- borrow later used here

For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

错误显示,为了使 resultprintln! 语句有效,string2 需要一直有效到外部作用域结束。Rust 知道这一点,因为我们使用相同的生命周期参数 'a 标注了函数参数和返回值的生命周期。

作为人类,我们可以查看这段代码并看到 string1string2 长,因此 result 将包含一个指向 string1 的引用。因为 string1 还没有离开作用域,所以指向 string1 的引用在 println! 语句中仍然是有效的。然而,编译器无法看到在这种情况下引用是有效的。我们已经告诉 Rust,longest 函数返回的引用的生命周期与传入的引用的生命周期中较短的那个相同。因此,借用检查器不允许 Listing 10-23 中的代码,因为它可能有一个无效的引用。

尝试设计更多实验,改变传递给 longest 函数的引用的值和生命周期,以及返回的引用的使用方式。在编译之前,假设你的实验是否会通过借用检查器;然后检查你是否正确!

从生命周期的角度思考

你需要指定生命周期参数的方式取决于你的函数在做什么。例如,如果我们改变 longest 函数的实现,使其总是返回第一个参数而不是较长的字符串切片,我们就不需要在 y 参数上指定生命周期。以下代码将编译:

fn main() {
    let string1 = String::from("abcd");
    let string2 = "efghijklmnopqrstuvwxyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &str) -> &'a str {
    x
}

我们已经为参数 x 和返回类型指定了生命周期参数 'a,但没有为参数 y 指定,因为 y 的生命周期与 x 或返回值的生命周期没有任何关系。

当从函数返回引用时,返回类型的生命周期参数需要与其中一个参数的生命周期参数匹配。如果返回的引用指向其中一个参数,它必须指向在此函数中创建的值。然而,这将是一个悬垂引用,因为该值将在函数结束时离开作用域。考虑一下这个无法编译的 longest 函数的尝试实现:

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &str, y: &str) -> &'a str {
    let result = String::from("really long string");
    result.as_str()
}

在这里,尽管我们已经为返回类型指定了生命周期参数 'a,但这个实现将无法编译,因为返回值的生命周期与参数的生命周期没有任何关系。以下是我们得到的错误信息:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0515]: cannot return value referencing local variable `result`
  --> src/main.rs:11:5
   |
11 |     result.as_str()
   |     ------^^^^^^^^^
   |     |
   |     returns a value referencing data owned by the current function
   |     `result` is borrowed here

For more information about this error, try `rustc --explain E0515`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

问题是 resultlongest 函数结束时离开作用域并被清理。我们还试图从函数返回一个指向 result 的引用。我们无法指定会改变悬垂引用的生命周期参数,Rust 也不会让我们创建一个悬垂引用。在这种情况下,最好的修复方法是返回一个拥有所有权的数据类型而不是引用,这样调用函数就负责清理该值。

最终,生命周期语法是关于连接函数中各种参数和返回值的生命周期。一旦它们被连接起来,Rust 就有足够的信息来允许内存安全的操作,并禁止会创建悬垂指针或以其他方式违反内存安全的操作。

结构体定义中的生命周期标注

到目前为止,我们定义的结构体都持有拥有所有权的类型。我们可以定义持有引用的结构体,但在这种情况下,我们需要在结构体定义中的每个引用上添加生命周期标注。Listing 10-24 有一个名为 ImportantExcerpt 的结构体,它持有一个字符串切片。

struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

这个结构体有一个字段 part,它持有一个字符串切片,这是一个引用。与泛型数据类型一样,我们在结构体名称后的尖括号内声明泛型生命周期参数的名称,以便我们可以在结构体定义的主体中使用生命周期参数。这个标注意味着 ImportantExcerpt 的实例不能比它持有的 part 字段中的引用活得更长。

这里的 main 函数创建了一个 ImportantExcerpt 结构体的实例,它持有一个指向变量 novel 所拥有的 String 的第一个句子的引用。novel 中的数据在 ImportantExcerpt 实例创建之前就存在。此外,novelImportantExcerpt 离开作用域之后才会离开作用域,所以 ImportantExcerpt 实例中的引用是有效的。

生命周期省略

你已经了解到每个引用都有一个生命周期,并且你需要为使用引用的函数或结构体指定生命周期参数。然而,我们在 Listing 4-9 中有一个函数,如 Listing 10-25 所示,它在没有生命周期标注的情况下编译通过。

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // first_word works on slices of `String`s
    let word = first_word(&my_string[..]);

    let my_string_literal = "hello world";

    // first_word works on slices of string literals
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

这个函数在没有生命周期标注的情况下编译通过的原因是历史性的:在 Rust 的早期版本(1.0 之前),这段代码无法编译,因为每个引用都需要显式的生命周期。当时,函数签名会写成这样:

fn first_word<'a>(s: &'a str) -> &'a str {

在编写了大量 Rust 代码后,Rust 团队发现 Rust 程序员在特定情况下反复输入相同的生命周期标注。这些情况是可预测的,并且遵循一些确定的模式。开发者将这些模式编程到编译器的代码中,以便借用检查器可以在这些情况下推断生命周期,而不需要显式的标注。

这段 Rust 历史是相关的,因为未来可能会出现更多的确定性模式并被添加到编译器中。将来,甚至可能需要更少的生命周期标注。

编程到 Rust 引用分析中的模式被称为生命周期省略规则。这些不是程序员需要遵循的规则;它们是编译器会考虑的一组特定情况,如果你的代码符合这些情况,你就不需要显式地编写生命周期。

省略规则并不提供完整的推断。如果 Rust 应用规则后仍然存在关于引用生命周期的歧义,编译器不会猜测剩余引用的生命周期应该是什么。相反,编译器会给你一个错误,你可以通过添加生命周期标注来解决。

函数或方法参数上的生命周期被称为输入生命周期,而返回值上的生命周期被称为输出生命周期

编译器使用三条规则来在没有显式标注的情况下推断引用的生命周期。第一条规则适用于输入生命周期,第二条和第三条规则适用于输出生命周期。如果编译器在应用这三条规则后仍然有无法推断生命周期的引用,编译器将停止并报错。这些规则适用于 fn 定义以及 impl 块。

第一条规则是,编译器为每个是引用的参数分配一个生命周期参数。换句话说,一个有一个参数的函数得到一个生命周期参数:fn foo<'a>(x: &'a i32);一个有两个参数的函数得到两个独立的生命周期参数:fn foo<'a, 'b>(x: &'a i32, y: &'b i32);依此类推。

第二条规则是,如果只有一个输入生命周期参数,那么该生命周期被分配给所有输出生命周期参数:fn foo<'a>(x: &'a i32) -> &'a i32

第三条规则是,如果有多个输入生命周期参数,但其中一个参数是 &self&mut self,因为这是一个方法,那么 self 的生命周期被分配给所有输出生命周期参数。这第三条规则使得方法更易于阅读和编写,因为需要的符号更少。

让我们假装我们是编译器。我们将应用这些规则来推断 Listing 10-25 中 first_word 函数签名中引用的生命周期。签名开始时没有任何与引用相关的生命周期:

fn first_word(s: &str) -> &str {

然后编译器应用第一条规则,该规则指定每个参数都有自己的生命周期。我们通常将其称为 'a,所以现在签名是这样的:

fn first_word<'a>(s: &'a str) -> &str {

第二条规则适用,因为只有一个输入生命周期。第二条规则指定,一个输入参数的生命周期被分配给输出生命周期,所以签名现在是这样的:

fn first_word<'a>(s: &'a str) -> &'a str {

现在这个函数签名中的所有引用都有了生命周期,编译器可以继续其分析,而不需要程序员在这个函数签名中标注生命周期。

让我们看另一个例子,这次使用我们在 Listing 10-20 中开始使用的 longest 函数,它最初没有生命周期参数:

fn longest(x: &str, y: &str) -> &str {

让我们应用第一条规则:每个参数都有自己的生命周期。这次我们有两个参数而不是一个,所以我们有两个生命周期:

fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {

你可以看到第二条规则不适用,因为有多于一个输入生命周期。第三条规则也不适用,因为 longest 是一个函数而不是一个方法,所以没有任何参数是 self。在应用了所有三条规则后,我们仍然没有弄清楚返回类型的生命周期是什么。这就是为什么我们在尝试编译 Listing 10-20 中的代码时得到了一个错误:编译器应用了生命周期省略规则,但仍然无法推断出签名中所有引用的生命周期。

因为第三条规则实际上只适用于方法签名,我们接下来将在该上下文中查看生命周期,看看为什么第三条规则意味着我们不需要经常在方法签名中标注生命周期。

方法定义中的生命周期标注

当我们在具有生命周期的结构体上实现方法时,我们使用与泛型类型参数相同的语法,如 Listing 10-11 所示。我们声明和使用生命周期参数的位置取决于它们是与结构体字段相关还是与方法参数和返回值相关。

结构体字段的生命周期名称总是需要在 impl 关键字后声明,然后在结构体名称后使用,因为这些生命周期是结构体类型的一部分。

impl 块中的方法签名中,引用可能与结构体字段中的引用的生命周期相关,或者它们可能是独立的。此外,生命周期省略规则通常使得方法签名中不需要生命周期标注。让我们看一些使用我们在 Listing 10-24 中定义的 ImportantExcerpt 结构体的例子。

首先,我们将使用一个名为 level 的方法,它的唯一参数是对 self 的引用,并且它的返回值是一个 i32,它不是对任何东西的引用:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {announcement}");
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

impl 后的生命周期参数声明及其在类型名称后的使用是必需的,但我们不需要标注对 self 的引用的生命周期,因为第一条省略规则。

这里是一个第三条生命周期省略规则适用的例子:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {announcement}");
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

有两个输入生命周期,所以 Rust 应用第一条生命周期省略规则,并给 &selfannouncement 各自的生命周期。然后,因为其中一个参数是 &self,返回类型获得 &self 的生命周期,所有生命周期都已被考虑。

静态生命周期

我们需要讨论的一个特殊生命周期是 'static,它表示受影响的引用可以在整个程序的生命周期内存活。所有的字符串字面量都有 'static 生命周期,我们可以这样标注:

#![allow(unused)]
fn main() {
let s: &'static str = "I have a static lifetime.";
}

这个字符串的文本直接存储在程序的二进制文件中,始终可用。因此,所有字符串字面量的生命周期都是 'static

你可能会在错误信息中看到建议使用 'static 生命周期的提示。但在指定 'static 作为引用的生命周期之前,请考虑你拥有的引用是否真的会在整个程序的生命周期内存活,以及你是否希望它这样。大多数情况下,建议使用 'static 生命周期的错误信息是由于试图创建悬垂引用或可用生命周期不匹配。在这种情况下,解决方案是修复这些问题,而不是指定 'static 生命周期。

泛型类型参数、Trait 约束和生命周期一起使用

让我们简要地看一下在一个函数中指定泛型类型参数、Trait 约束和生命周期的语法!

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest_with_an_announcement(
        string1.as_str(),
        string2,
        "Today is someone's birthday!",
    );
    println!("The longest string is {result}");
}

use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,
{
    println!("Announcement! {ann}");
    if x.len() > y.len() { x } else { y }
}

这是 Listing 10-21 中的 longest 函数,它返回两个字符串切片中较长的一个。但现在它有一个名为 ann 的额外参数,类型为泛型 T,它可以由任何实现了 Display trait 的类型填充,如 where 子句所指定的。这个额外参数将使用 {} 打印,这就是为什么 Display trait 约束是必要的。因为生命周期是一种泛型,生命周期参数 'a 和泛型类型参数 T 的声明放在函数名后的尖括号内的同一个列表中。

总结

我们在本章中涵盖了很多内容!现在你知道了泛型类型参数、Trait 和 Trait 约束以及泛型生命周期参数,你已经准备好编写可以在许多不同情况下工作的无重复代码。泛型类型参数让你可以将代码应用于不同的类型。Trait 和 Trait 约束确保即使类型是泛型的,它们也会有代码所需的行为。你学会了如何使用生命周期标注来确保这种灵活的代码不会有任何悬垂引用。所有这些分析都在编译时进行,不会影响运行时性能!

信不信由你,我们讨论的主题还有很多内容:第 18 章讨论了 trait 对象,这是使用 trait 的另一种方式。还有一些涉及生命周期标注的更复杂场景,你只会在非常高级的场景中需要;对于这些,你应该阅读 Rust 参考手册。但接下来,你将学习如何在 Rust 中编写测试,以确保你的代码按预期工作。

编写自动化测试

在1972年的文章《谦逊的程序员》中,Edsger W. Dijkstra 提到:“程序测试可以非常有效地展示 bug 的存在,但它却无法证明 bug 的不存在。” 这并不意味着我们不应该尽可能多地进行测试!

我们程序的正确性是指代码按照我们的意图执行的程度。Rust 在设计时非常关注程序的正确性,但正确性是一个复杂的问题,不容易证明。Rust 的类型系统承担了很大一部分责任,但类型系统并不能捕捉到所有问题。因此,Rust 提供了编写自动化软件测试的支持。

假设我们编写了一个函数 add_two,它将传递给它的数字加 2。这个函数的签名接受一个整数作为参数,并返回一个整数作为结果。当我们实现并编译这个函数时,Rust 会进行所有你到目前为止学到的类型检查和借用检查,以确保我们不会传递一个 String 值或无效的引用给这个函数。但 Rust 无法 检查这个函数是否会完全按照我们的意图执行,即返回参数加 2 而不是参数加 10 或参数减 50!这就是测试发挥作用的地方。

我们可以编写测试来断言,例如,当我们向 add_two 函数传递 3 时,返回的值是 5。我们可以在每次修改代码时运行这些测试,以确保现有的正确行为没有发生变化。

测试是一项复杂的技能:虽然我们无法在一章中涵盖如何编写良好测试的所有细节,但在本章中,我们将讨论 Rust 测试设施的基本机制。我们将讨论编写测试时可用的注解和宏、运行测试时提供的默认行为和选项,以及如何将测试组织成单元测试和集成测试。

如何编写测试

测试是 Rust 函数,用于验证非测试代码是否按预期方式运行。测试函数的主体通常执行以下三个操作:

  • 设置任何需要的数据或状态。
  • 运行你想要测试的代码。
  • 断言结果是否符合预期。

让我们看看 Rust 提供的专门用于编写这些操作的测试功能,包括 test 属性、一些宏和 should_panic 属性。

测试函数的结构

最简单的 Rust 测试是一个用 test 属性注释的函数。属性是关于 Rust 代码片段的元数据;一个例子是我们在第 5 章中与结构体一起使用的 derive 属性。要将函数转换为测试函数,请在 fn 之前添加 #[test]。当你使用 cargo test 命令运行测试时,Rust 会构建一个测试运行器二进制文件,该文件运行注释的函数并报告每个测试函数是否通过或失败。

每当我们使用 Cargo 创建一个新的库项目时,都会自动生成一个包含测试函数的测试模块。该模块为你提供了一个编写测试的模板,这样你就不必每次开始新项目时都查找确切的结构和语法。你可以根据需要添加任意数量的额外测试函数和测试模块!

在我们实际测试任何代码之前,我们将通过实验模板测试来探索测试工作的一些方面。然后,我们将编写一些实际测试,调用我们编写的一些代码,并断言其行为是否正确。

让我们创建一个名为 adder 的新库项目,它将两个数字相加:

$ cargo new adder --lib
     Created library `adder` project
$ cd adder

你的 adder 库中的 src/lib.rs 文件内容应如 Listing 11-1 所示。

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

文件以一个示例 add 函数开头,这样我们就有了可以测试的内容。

现在,让我们只关注 it_works 函数。注意 #[test] 注释:此属性表示这是一个测试函数,因此测试运行器知道将此函数视为测试。我们可能还在 tests 模块中有非测试函数,以帮助设置常见场景或执行常见操作,因此我们始终需要指示哪些函数是测试。

示例函数体使用 assert_eq! 宏来断言 result(包含调用 add 的结果,参数为 2 和 2)等于 4。此断言作为典型测试格式的示例。让我们运行它,看看这个测试是否通过。

cargo test 命令运行我们项目中的所有测试,如 Listing 11-2 所示。

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
     Running unittests src/lib.rs (file:///projects/adder/target/debug/deps/adder-40313d497ef8f64e)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Cargo 编译并运行了测试。我们看到 running 1 test 这一行。下一行显示了生成的测试函数的名称,称为 tests::it_works,并且运行该测试的结果是 ok。总体摘要 test result: ok. 表示所有测试都通过了,1 passed; 0 failed 部分总结了通过或失败的测试数量。

可以将测试标记为忽略,以便在特定情况下不运行;我们将在本章后面的 “除非特别请求,否则忽略某些测试” 部分中介绍这一点。因为我们在这里没有这样做,所以摘要显示 0 ignored。我们还可以向 cargo test 命令传递一个参数,以仅运行名称与字符串匹配的测试;这称为 过滤,我们将在 “按名称运行测试子集” 部分中介绍。这里我们没有过滤正在运行的测试,因此摘要的末尾显示 0 filtered out

0 measured 统计信息用于衡量性能的基准测试。截至本文撰写时,基准测试仅在 nightly Rust 中可用。有关基准测试的更多信息,请参阅 基准测试文档

测试输出的下一部分从 Doc-tests adder 开始,用于任何文档测试的结果。我们还没有任何文档测试,但 Rust 可以编译出现在我们 API 文档中的任何代码示例。此功能有助于保持文档和代码同步!我们将在第 14 章的 “文档注释作为测试” 部分讨论如何编写文档测试。现在,我们将忽略 Doc-tests 输出。

让我们开始根据我们的需求自定义测试。首先,将 it_works 函数的名称更改为不同的名称,例如 exploration,如下所示:

文件名: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

然后再次运行 cargo test。输出现在显示 exploration 而不是 it_works

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.59s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::exploration ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

现在我们将添加另一个测试,但这次我们将使测试失败!当测试函数中的某些内容 panic 时,测试失败。每个测试都在一个新线程中运行,当主线程看到测试线程死亡时,测试被标记为失败。在第 9 章中,我们讨论了 panic 的最简单方法是调用 panic! 宏。输入一个新测试作为名为 another 的函数,因此你的 src/lib.rs 文件如 Listing 11-3 所示。

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}

再次使用 cargo test 运行测试。输出应如 Listing 11-4 所示,显示我们的 exploration 测试通过,而 another 失败。

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.72s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok

failures:

---- tests::another stdout ----

thread 'tests::another' panicked at src/lib.rs:17:9:
Make this test fail
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::another

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

ok 不同,test tests::another 行显示 FAILED。在单个结果和摘要之间出现了两个新部分:第一部分显示每个测试失败的详细原因。在这种情况下,我们得到 another 失败的详细信息,因为它在 src/lib.rs 文件的第 17 行 panicked at 'Make this test fail'。下一部分仅列出所有失败测试的名称,这在有很多测试和大量详细的失败测试输出时非常有用。我们可以使用失败测试的名称来仅运行该测试,以便更容易调试;我们将在 “控制测试的运行方式” 部分中讨论更多运行测试的方法。

摘要行在最后显示:总体而言,我们的测试结果是 FAILED。我们有一个测试通过,一个测试失败。

现在你已经看到了不同场景下的测试结果,让我们看看除了 panic! 之外,在测试中有用的一些宏。

使用 assert! 宏检查结果

标准库提供的 assert! 宏在你想要确保测试中的某个条件评估为 true 时非常有用。我们给 assert! 宏一个评估为布尔值的参数。如果值为 true,则什么都不发生,测试通过。如果值为 falseassert! 宏会调用 panic! 导致测试失败。使用 assert! 宏有助于我们检查代码是否按预期运行。

在第 5 章的 Listing 5-15 中,我们使用了一个 Rectangle 结构体和一个 can_hold 方法,它们在 Listing 11-5 中重复出现。让我们将此代码放入 src/lib.rs 文件中,然后使用 assert! 宏为其编写一些测试。

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

can_hold 方法返回一个布尔值,这意味着它是 assert! 宏的完美用例。在 Listing 11-6 中,我们编写了一个测试,通过创建一个宽度为 8、高度为 7 的 Rectangle 实例来测试 can_hold 方法,并断言它可以容纳另一个宽度为 5、高度为 1 的 Rectangle 实例。

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }
}

注意 tests 模块中的 use super::*; 行。tests 模块是一个常规模块,遵循我们在第 7 章的 “模块树中引用项的路径” 部分中介绍的可见性规则。因为 tests 模块是一个内部模块,我们需要将外部模块中的代码引入内部模块的作用域。我们在这里使用 glob,因此我们在外部模块中定义的任何内容都可用于此 tests 模块。

我们将测试命名为 larger_can_hold_smaller,并创建了我们需要的两个 Rectangle 实例。然后我们调用了 assert! 宏,并传递了调用 larger.can_hold(&smaller) 的结果。这个表达式应该返回 true,因此我们的测试应该通过。让我们看看结果!

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 1 test
test tests::larger_can_hold_smaller ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

它确实通过了!让我们添加另一个测试,这次断言较小的矩形不能容纳较大的矩形:

文件名: src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        // --snip--
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

因为在这种情况下 can_hold 函数的正确结果是 false,我们需要在将其传递给 assert! 宏之前否定该结果。因此,如果 can_hold 返回 false,我们的测试将通过:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

两个测试都通过了!现在让我们看看当我们在代码中引入一个 bug 时,我们的测试结果会发生什么。我们将 can_hold 方法的实现更改为在比较宽度时将大于号替换为小于号:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

// --snip--
impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width < other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

现在运行测试会产生以下结果:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok

failures:

---- tests::larger_can_hold_smaller stdout ----

thread 'tests::larger_can_hold_smaller' panicked at src/lib.rs:28:9:
assertion failed: larger.can_hold(&smaller)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::larger_can_hold_smaller

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

我们的测试捕获了 bug!因为 larger.width8,而 smaller.width5,所以 can_hold 中的宽度比较现在返回 false:8 不小于 5。

使用 assert_eq!assert_ne! 宏测试相等性

验证功能的一种常见方法是测试被测代码的结果与预期返回的值是否相等。你可以通过使用 assert! 宏并传递一个使用 == 运算符的表达式来实现这一点。然而,这是一个如此常见的测试,以至于标准库提供了一对宏——assert_eq!assert_ne!——来更方便地执行此测试。这些宏分别比较两个参数是否相等或不相等。如果断言失败,它们还会打印这两个值,这使得更容易看出 为什么 测试失败;相反,assert! 宏仅指示它得到了 == 表达式的 false 值,而不打印导致 false 的值。

在 Listing 11-7 中,我们编写了一个名为 add_two 的函数,它将 2 添加到其参数中,然后我们使用 assert_eq! 宏测试此函数。

pub fn add_two(a: usize) -> usize {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}

让我们检查它是否通过!

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

我们创建了一个名为 result 的变量,它保存了调用 add_two(2) 的结果。然后我们将 result4 作为参数传递给 assert_eq!。此测试的输出行是 test tests::it_adds_two ... okok 文本表示我们的测试通过了!

让我们在代码中引入一个 bug,看看 assert_eq! 在失败时的样子。将 add_two 函数的实现更改为添加 3

pub fn add_two(a: usize) -> usize {
    a + 3
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}

再次运行测试:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... FAILED

failures:

---- tests::it_adds_two stdout ----

thread 'tests::it_adds_two' panicked at src/lib.rs:12:9:
assertion `left == right` failed
  left: 5
 right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_adds_two

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

我们的测试捕获了 bug!it_adds_two 测试失败,消息告诉我们失败的断言是 assertion `left == right` failed,并显示了 leftright 的值。此消息帮助我们开始调试:left 参数,即调用 add_two(2) 的结果,是 5,而 right 参数是 4。你可以想象,当我们有很多测试时,这将特别有用。

请注意,在某些语言和测试框架中,相等断言函数的参数称为 expectedactual,并且我们指定参数的顺序很重要。然而,在 Rust 中,它们称为 leftright,并且我们指定预期值和代码生成值的顺序并不重要。我们可以将此测试中的断言写为 assert_eq!(add_two(2), result),这将导致相同的失败消息,显示 assertion failed: `(left == right)`

assert_ne! 宏在我们给它的两个值不相等时通过,在它们相等时失败。这个宏在我们不确定一个值 是什么,但我们知道它绝对 不应该 是什么时非常有用。例如,如果我们正在测试一个保证以某种方式改变其输入的函数,但输入改变的方式取决于我们运行测试的星期几,那么最好的断言可能是函数的输出不等于输入。

在底层,assert_eq!assert_ne! 宏分别使用 ==!= 运算符。当断言失败时,这些宏使用调试格式化打印它们的参数,这意味着被比较的值必须实现 PartialEqDebug 特性。所有原始类型和大多数标准库类型都实现了这些特性。对于你自己定义的结构体和枚举,你需要实现 PartialEq 来断言这些类型的相等性。你还需要实现 Debug 以在断言失败时打印值。因为这两个特性都是可派生的特性,如第 5 章的 Listing 5-12 中所述,这通常就像在你的结构体或枚举定义中添加 #[derive(PartialEq, Debug)] 注释一样简单。有关这些和其他可派生特性的更多详细信息,请参阅附录 C,“可派生特性,”

添加自定义失败消息

你还可以将自定义消息作为可选参数传递给 assert!assert_eq!assert_ne! 宏,以便与失败消息一起打印。在必需参数之后指定的任何参数都会传递给 format! 宏(在第 8 章的 “使用 + 运算符或 format! 宏进行连接” 中讨论),因此你可以传递一个包含 {} 占位符和要放入这些占位符的值的格式字符串。自定义消息有助于记录断言的含义;当测试失败时,你将更好地了解代码中的问题所在。

例如,假设我们有一个按名称问候人们的函数,我们想测试传递给函数的名称是否出现在输出中:

文件名: src/lib.rs

pub fn greeting(name: &str) -> String {
    format!("Hello {name}!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

这个程序的要求尚未达成一致,我们非常确定问候开头的 Hello 文本会改变。我们决定在需求改变时不想更新测试,因此我们不会检查 greeting 函数返回的值是否完全相等,而是断言输出包含输入参数的文本。

现在让我们通过更改 greeting 以排除 name 来在代码中引入一个 bug,看看默认的测试失败是什么样子:

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

运行此测试会产生以下结果:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----

thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
assertion failed: result.contains("Carol")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

这个结果仅指示断言失败以及断言所在的行。更有用的失败消息将打印 greeting 函数的值。让我们添加一个自定义失败消息,该消息由格式字符串和实际从 greeting 函数获得的值组成:

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(
            result.contains("Carol"),
            "Greeting did not contain name, value was `{result}`"
        );
    }
}

现在当我们运行测试时,我们将得到一个更有信息量的错误消息:

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.93s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----

thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
Greeting did not contain name, value was `Hello!`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

我们可以在测试输出中看到我们实际得到的值,这将帮助我们调试发生了什么,而不是我们预期会发生什么。

使用 should_panic 检查 panic

除了检查返回值外,检查我们的代码是否按预期处理错误条件也很重要。例如,考虑我们在第 9 章的 Listing 9-13 中创建的 Guess 类型。使用 Guess 的其他代码依赖于 Guess 实例将仅包含 1 到 100 之间的值的保证。我们可以编写一个测试,确保尝试创建超出该范围的 Guess 实例会导致 panic。

我们通过将 should_panic 属性添加到我们的测试函数中来实现这一点。如果函数内部的代码 panic,则测试通过;如果函数内部的代码没有 panic,则测试失败。

Listing 11-8 显示了一个测试,检查 Guess::new 的错误条件是否在我们预期时发生。

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

我们将 #[should_panic] 属性放在 #[test] 属性之后,并放在它适用的测试函数之前。让我们看看当这个测试通过时的结果:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests guessing_game

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

看起来不错!现在让我们通过在 new 函数中删除值大于 100 时 panic 的条件来在代码中引入一个 bug:

pub struct Guess {
    value: i32,
}

// --snip--
impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

当我们运行 Listing 11-8 中的测试时,它将失败:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----
note: test did not panic as expected

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

在这种情况下,我们没有得到非常有用的消息,但当我们查看测试函数时,我们看到它被注释为 #[should_panic]。我们得到的失败意味着测试函数中的代码没有导致 panic。

使用 should_panic 的测试可能不精确。即使测试因我们预期之外的原因 panic,should_panic 测试也会通过。为了使 should_panic 测试更精确,我们可以向 should_panic 属性添加一个可选的 expected 参数。测试工具将确保失败消息包含提供的文本。例如,考虑 Listing 11-9 中 Guess 的修改代码,其中 new 函数根据值是太小还是太大而 panic 并显示不同的消息。

pub struct Guess {
    value: i32,
}

// --snip--

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be greater than or equal to 1, got {value}."
            );
        } else if value > 100 {
            panic!(
                "Guess value must be less than or equal to 100, got {value}."
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

这个测试将通过,因为我们放在 should_panic 属性的 expected 参数中的值是 Guess::new 函数 panic 时消息的子字符串。我们可以指定我们期望的整个 panic 消息,在这种情况下是 Guess value must be less than or equal to 100, got 200。你选择指定多少取决于 panic 消息中有多少是唯一的或动态的,以及你希望测试有多精确。在这种情况下,panic 消息的子字符串足以确保测试函数中的代码执行 else if value > 100 情况。

为了看看当带有 expected 消息的 should_panic 测试失败时会发生什么,让我们再次在代码中引入一个 bug,交换 if value < 1else if value > 100 块的主体:

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be less than or equal to 100, got {value}."
            );
        } else if value > 100 {
            panic!(
                "Guess value must be greater than or equal to 1, got {value}."
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

这次当我们运行 should_panic 测试时,它将失败:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----

thread 'tests::greater_than_100' panicked at src/lib.rs:12:13:
Guess value must be greater than or equal to 1, got 200.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
      panic message: `"Guess value must be greater than or equal to 1, got 200."`,
 expected substring: `"less than or equal to 100"`

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

失败消息表明此测试确实如我们预期的那样 panic 了,但 panic 消息没有包含预期的字符串 less than or equal to 100。我们在此情况下得到的 panic 消息是 Guess value must be greater than or equal to 1, got 200. 现在我们可以开始找出我们的 bug 在哪里了!

在测试中使用 Result<T, E>

到目前为止,我们的测试在失败时都会 panic。我们也可以编写使用 Result<T, E> 的测试!以下是 Listing 11-1 中的测试,重写为使用 Result<T, E> 并返回 Err 而不是 panic:

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() -> Result<(), String> {
        let result = add(2, 2);

        if result == 4 {
            Ok(())
        } else {
            Err(String::from("two plus two does not equal four"))
        }
    }
}

it_works 函数现在具有 Result<(), String> 返回类型。在函数体中,我们不再调用 assert_eq! 宏,而是在测试通过时返回 Ok(()),在测试失败时返回包含 StringErr

编写测试以返回 Result<T, E> 使你能够在测试体中使用问号运算符,这可以方便地编写在其中的任何操作返回 Err 变体时应失败的测试。

你不能在返回 Result<T, E> 的测试上使用 #[should_panic] 注释。要断言操作返回 Err 变体,不要Result<T, E> 值上使用问号运算符。相反,使用 assert!(value.is_err())

现在你知道了几种编写测试的方法,让我们看看当我们运行测试时发生了什么,并探索我们可以与 cargo test 一起使用的不同选项。

控制测试的运行方式

就像 cargo run 会编译代码并运行生成的二进制文件一样,cargo test 会在测试模式下编译代码并运行生成的测试二进制文件。cargo test 生成的二进制文件的默认行为是并行运行所有测试,并捕获测试运行期间生成的输出,防止输出显示在终端上,从而更容易阅读与测试结果相关的输出。然而,你可以通过指定命令行选项来更改这种默认行为。

一些命令行选项传递给 cargo test,而另一些则传递给生成的测试二进制文件。为了区分这两种类型的参数,你需要列出传递给 cargo test 的参数,然后是分隔符 --,接着是传递给测试二进制文件的参数。运行 cargo test --help 会显示可以与 cargo test 一起使用的选项,而运行 cargo test -- --help 会显示可以在分隔符后使用的选项。这些选项也在 rustc 书“Tests” 部分 中有详细说明。

并行或连续运行测试

当你运行多个测试时,默认情况下它们会使用线程并行运行,这意味着它们会更快地完成运行,并且你会更快地得到反馈。由于测试是同时运行的,你必须确保你的测试不相互依赖,也不依赖于任何共享状态,包括共享环境,例如当前工作目录或环境变量。

例如,假设每个测试都运行一些代码,这些代码会在磁盘上创建一个名为 test-output.txt 的文件,并向该文件写入一些数据。然后每个测试读取该文件中的数据,并断言该文件包含一个特定值,这个值在每个测试中都是不同的。由于测试是同时运行的,一个测试可能会在另一个测试写入和读取文件之间的时间内覆盖该文件。然后第二个测试将失败,不是因为代码不正确,而是因为测试在并行运行时相互干扰。一个解决方案是确保每个测试写入不同的文件;另一个解决方案是一次只运行一个测试。

如果你不想并行运行测试,或者希望对使用的线程数进行更细粒度的控制,你可以向测试二进制文件传递 --test-threads 标志和你希望使用的线程数。看看以下示例:

$ cargo test -- --test-threads=1

我们将测试线程数设置为 1,告诉程序不要使用任何并行性。使用一个线程运行测试会比并行运行它们花费更长的时间,但如果测试共享状态,它们不会相互干扰。

显示函数输出

默认情况下,如果测试通过,Rust 的测试库会捕获打印到标准输出的任何内容。例如,如果我们在测试中调用 println! 并且测试通过,我们不会在终端中看到 println! 的输出;我们只会看到指示测试通过的行。如果测试失败,我们会在失败消息的其余部分中看到打印到标准输出的内容。

例如,Listing 11-10 有一个愚蠢的函数,它会打印其参数的值并返回 10,还有一个通过的测试和一个失败的测试。

fn prints_and_returns_10(a: i32) -> i32 {
    println!("I got the value {a}");
    10
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn this_test_will_pass() {
        let value = prints_and_returns_10(4);
        assert_eq!(value, 10);
    }

    #[test]
    fn this_test_will_fail() {
        let value = prints_and_returns_10(8);
        assert_eq!(value, 5);
    }
}

当我们使用 cargo test 运行这些测试时,我们会看到以下输出:

$ cargo test
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)

running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8

thread 'tests::this_test_will_fail' panicked at src/lib.rs:19:9:
assertion `left == right` failed
  left: 10
 right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

请注意,在此输出中我们看不到 I got the value 4,这是在通过的测试运行时打印的。该输出已被捕获。失败的测试的输出 I got the value 8 出现在测试摘要输出的部分中,该部分还显示了测试失败的原因。

如果我们还想看到通过测试的打印值,我们可以告诉 Rust 使用 --show-output 标志来显示成功测试的输出:

$ cargo test -- --show-output

当我们再次使用 --show-output 标志运行 Listing 11-10 中的测试时,我们会看到以下输出:

$ cargo test -- --show-output
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)

running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok

successes:

---- tests::this_test_will_pass stdout ----
I got the value 4


successes:
    tests::this_test_will_pass

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8

thread 'tests::this_test_will_fail' panicked at src/lib.rs:19:9:
assertion `left == right` failed
  left: 10
 right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

通过名称运行测试子集

有时,运行完整的测试套件可能需要很长时间。如果你正在处理特定区域的代码,你可能只想运行与该代码相关的测试。你可以通过将你想要运行的测试的名称作为参数传递给 cargo test 来选择要运行的测试。

为了演示如何运行测试子集,我们首先为 add_two 函数创建三个测试,如 Listing 11-11 所示,并选择要运行的测试。

pub fn add_two(a: usize) -> usize {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn add_two_and_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }

    #[test]
    fn add_three_and_two() {
        let result = add_two(3);
        assert_eq!(result, 5);
    }

    #[test]
    fn one_hundred() {
        let result = add_two(100);
        assert_eq!(result, 102);
    }
}

如果我们不传递任何参数运行测试,正如我们之前看到的,所有测试都会并行运行:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 3 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok
test tests::one_hundred ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

运行单个测试

我们可以将任何测试函数的名称传递给 cargo test 以仅运行该测试:

$ cargo test one_hundred
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.69s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::one_hundred ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s

只有名为 one_hundred 的测试运行了;其他两个测试不匹配该名称。测试输出通过在末尾显示 2 filtered out 让我们知道还有更多测试没有运行。

我们不能以这种方式指定多个测试的名称;只有传递给 cargo test 的第一个值会被使用。但是有一种方法可以运行多个测试。

过滤运行多个测试

我们可以指定测试名称的一部分,任何名称匹配该值的测试都将运行。例如,因为我们的两个测试名称包含 add,我们可以通过运行 cargo test add 来运行这两个测试:

$ cargo test add
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

这个命令运行了所有名称中包含 add 的测试,并过滤掉了名为 one_hundred 的测试。还要注意,测试所在的模块也成为测试名称的一部分,因此我们可以通过过滤模块名称来运行模块中的所有测试。

忽略某些测试除非特别请求

有时,一些特定的测试可能非常耗时,因此你可能希望在大多数 cargo test 运行中排除它们。与其列出所有你想要运行的测试作为参数,你可以使用 ignore 属性来注释这些耗时的测试以排除它们,如下所示:

Filename: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    #[ignore]
    fn expensive_test() {
        // code that takes an hour to run
    }
}

#[test] 之后,我们为要排除的测试添加 #[ignore] 行。现在当我们运行测试时,it_works 会运行,但 expensive_test 不会:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::expensive_test ... ignored
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

expensive_test 函数被列为 ignored。如果我们只想运行被忽略的测试,我们可以使用 cargo test -- --ignored

$ cargo test -- --ignored
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test expensive_test ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

通过控制哪些测试运行,你可以确保 cargo test 的结果会快速返回。当你处于一个检查 ignored 测试结果有意义并且有时间等待结果的时候,你可以运行 cargo test -- --ignored。如果你想运行所有测试,无论它们是否被忽略,你可以运行 cargo test -- --include-ignored

测试组织

正如本章开头提到的,测试是一个复杂的领域,不同的人使用不同的术语和组织方式。Rust 社区将测试分为两大类:单元测试和集成测试。单元测试通常较小且更集中,一次只测试一个模块,并且可以测试私有接口。集成测试则完全独立于你的库,它们像其他外部代码一样使用你的代码,只使用公共接口,并且可能在每个测试中涉及多个模块。

编写这两种测试对于确保库的各个部分单独和整体都能按预期工作非常重要。

单元测试

单元测试的目的是将代码的每个单元与其他代码隔离开来进行测试,以便快速定位代码是否按预期工作。你会将单元测试放在 src 目录中,与它们测试的代码放在同一个文件中。惯例是在每个文件中创建一个名为 tests 的模块来包含测试函数,并使用 cfg(test) 注解该模块。

测试模块和 #[cfg(test)]

tests 模块上的 #[cfg(test)] 注解告诉 Rust 只在运行 cargo test 时编译和运行测试代码,而不是在运行 cargo build 时。这样当你只想构建库时可以节省编译时间,并且由于测试代码不包含在编译结果中,可以节省编译产物的空间。你会看到,由于集成测试放在不同的目录中,它们不需要 #[cfg(test)] 注解。然而,由于单元测试与代码放在同一个文件中,你会使用 #[cfg(test)] 来指定它们不应包含在编译结果中。

回想一下,当我们在本章的第一节生成新的 adder 项目时,Cargo 为我们生成了以下代码:

文件名: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

在自动生成的 tests 模块上,属性 cfg 代表 配置,并告诉 Rust 只有在特定配置选项下才包含以下项。在这种情况下,配置选项是 test,这是 Rust 提供的用于编译和运行测试的选项。通过使用 cfg 属性,Cargo 只在我们主动运行 cargo test 时编译我们的测试代码。这包括可能在这个模块中的任何辅助函数,以及用 #[test] 注解的函数。

测试私有函数

测试社区内部对于是否应该直接测试私有函数存在争议,而其他语言使得测试私有函数变得困难或不可能。无论你遵循哪种测试理念,Rust 的隐私规则确实允许你测试私有函数。考虑 Listing 11-12 中的代码,其中包含私有函数 internal_adder

pub fn add_two(a: usize) -> usize {
    internal_adder(a, 2)
}

fn internal_adder(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn internal() {
        let result = internal_adder(2, 2);
        assert_eq!(result, 4);
    }
}

注意,internal_adder 函数没有标记为 pub。测试只是 Rust 代码,tests 模块只是另一个模块。正如我们在“模块树中引用项的路径”中讨论的那样,子模块中的项可以使用其祖先模块中的项。在这个测试中,我们使用 use super::*tests 模块的父模块的所有项引入作用域,然后测试可以调用 internal_adder。如果你认为不应该测试私有函数,Rust 中没有任何东西会强迫你这样做。

集成测试

在 Rust 中,集成测试完全独立于你的库。它们像其他代码一样使用你的库,这意味着它们只能调用库的公共 API 中的函数。它们的目的是测试库的许多部分是否能正确协同工作。单独工作正常的代码单元在集成时可能会出现问题,因此集成代码的测试覆盖也很重要。要创建集成测试,首先需要一个 tests 目录。

tests 目录

我们在项目目录的顶层创建一个 tests 目录,与 src 目录并列。Cargo 知道要在这个目录中查找集成测试文件。然后我们可以创建任意数量的测试文件,Cargo 会将每个文件编译为一个独立的 crate。

让我们创建一个集成测试。在 src/lib.rs 文件中仍然包含 Listing 11-12 的代码,创建一个 tests 目录,并创建一个名为 tests/integration_test.rs 的新文件。你的目录结构应该如下所示:

adder
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    └── integration_test.rs

将 Listing 11-13 中的代码输入到 tests/integration_test.rs 文件中。

use adder::add_two;

#[test]
fn it_adds_two() {
    let result = add_two(2);
    assert_eq!(result, 4);
}

tests 目录中的每个文件都是一个独立的 crate,因此我们需要将我们的库引入每个测试 crate 的作用域。为此,我们在代码顶部添加 use adder::add_two;,这在单元测试中是不需要的。

我们不需要在 tests/integration_test.rs 中使用 #[cfg(test)] 注解任何代码。Cargo 会特殊处理 tests 目录,并且只在我们运行 cargo test 时编译该目录中的文件。现在运行 cargo test

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.31s
     Running unittests src/lib.rs (target/debug/deps/adder-1082c4b063a8fbe6)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-1082c4b063a8fbe6)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

输出的三个部分包括单元测试、集成测试和文档测试。请注意,如果某个部分的任何测试失败,后续部分将不会运行。例如,如果单元测试失败,将不会有集成测试和文档测试的输出,因为这些测试只有在所有单元测试都通过时才会运行。

单元测试的第一部分与我们之前看到的一样:每个单元测试一行(我们在 Listing 11-12 中添加了一个名为 internal 的测试),然后是单元测试的总结行。

集成测试部分以 Running tests/integration_test.rs 开始。接下来是该集成测试中每个测试函数的一行,以及集成测试结果的总结行,紧接着是 Doc-tests adder 部分。

每个集成测试文件都有自己的部分,因此如果我们在 tests 目录中添加更多文件,将会有更多的集成测试部分。

我们仍然可以通过将测试函数的名称作为参数传递给 cargo test 来运行特定的集成测试函数。要运行特定集成测试文件中的所有测试,请使用 cargo test--test 参数,后跟文件名:

$ cargo test --test integration_test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.64s
     Running tests/integration_test.rs (target/debug/deps/integration_test-82e7799c1bc62298)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

此命令仅运行 tests/integration_test.rs 文件中的测试。

集成测试中的子模块

随着你添加更多的集成测试,你可能希望在 tests 目录中创建更多文件来帮助组织它们;例如,你可以按它们测试的功能对测试函数进行分组。如前所述,tests 目录中的每个文件都被编译为独立的 crate,这对于创建独立的作用域以更接近地模拟最终用户使用你的 crate 的方式非常有用。然而,这意味着 tests 目录中的文件与 src 目录中的文件行为不同,正如你在第 7 章中学到的关于如何将代码分离到模块和文件中的内容。

当你有一组辅助函数要在多个集成测试文件中使用,并尝试按照第 7 章中“将模块分离到不同文件”部分的步骤将它们提取到一个公共模块时,tests 目录文件的不同行为最为明显。例如,如果我们创建 tests/common.rs 并在其中放置一个名为 setup 的函数,我们可以向 setup 添加一些代码,以便从多个测试文件中的多个测试函数调用:

文件名: tests/common.rs

pub fn setup() {
    // setup code specific to your library's tests would go here
}

当我们再次运行测试时,我们会在测试输出中看到 common.rs 文件的新部分,即使该文件不包含任何测试函数,我们也没有从任何地方调用 setup 函数:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.89s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/common.rs (target/debug/deps/common-92948b65e88960b4)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-92948b65e88960b4)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

在测试结果中看到 common 并显示 running 0 tests 并不是我们想要的。我们只是想与其他集成测试文件共享一些代码。为了避免 common 出现在测试输出中,我们不会创建 tests/common.rs,而是创建 tests/common/mod.rs。项目目录现在如下所示:

├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── common
    │   └── mod.rs
    └── integration_test.rs

这是 Rust 也理解的旧命名约定,我们在第 7 章的“替代文件路径”中提到过。以这种方式命名文件告诉 Rust 不要将 common 模块视为集成测试文件。当我们将 setup 函数代码移动到 tests/common/mod.rs 并删除 tests/common.rs 文件时,测试输出中的部分将不再出现。tests 目录的子目录中的文件不会被编译为独立的 crate,也不会在测试输出中有部分。

在我们创建 tests/common/mod.rs 之后,我们可以从任何集成测试文件中将其作为模块使用。以下是从 tests/integration_test.rs 中的 it_adds_two 测试调用 setup 函数的示例:

文件名: tests/integration_test.rs

use adder::add_two;

mod common;

#[test]
fn it_adds_two() {
    common::setup();

    let result = add_two(2);
    assert_eq!(result, 4);
}

注意,mod common; 声明与我们在 Listing 7-21 中演示的模块声明相同。然后,在测试函数中,我们可以调用 common::setup() 函数。

二进制 crate 的集成测试

如果我们的项目是一个只包含 src/main.rs 文件而没有 src/lib.rs 文件的二进制 crate,我们无法在 tests 目录中创建集成测试,并使用 use 语句将 src/main.rs 文件中定义的函数引入作用域。只有库 crate 会暴露其他 crate 可以使用的函数;二进制 crate 旨在独立运行。

这是 Rust 项目提供二进制文件时通常有一个简单的 src/main.rs 文件调用 src/lib.rs 文件中的逻辑的原因之一。使用这种结构,集成测试可以通过 use 测试库 crate 以使重要功能可用。如果重要功能正常工作,src/main.rs 文件中的少量代码也会正常工作,而这少量代码不需要测试。

总结

Rust 的测试功能提供了一种指定代码应如何运行的方式,以确保即使在你进行更改时,它仍能按预期工作。单元测试分别测试库的不同部分,并且可以测试私有实现细节。集成测试检查库的许多部分是否能正确协同工作,并且它们使用库的公共 API 来测试代码,就像外部代码使用它一样。尽管 Rust 的类型系统和所有权规则有助于防止某些类型的错误,但测试对于减少与代码预期行为相关的逻辑错误仍然很重要。

让我们结合你在本章和之前章节中学到的知识来做一个项目吧!

一个 I/O 项目:构建命令行程序

本章是对你迄今为止学到的许多技能的回顾,并探索了一些更多的标准库功能。我们将构建一个与文件和命令行输入/输出交互的命令行工具,以练习你现在已经掌握的一些 Rust 概念。

Rust 的速度、安全性、单一二进制输出和跨平台支持使其成为创建命令行工具的理想语言,因此对于我们的项目,我们将制作一个经典命令行搜索工具 grep 的自定义版本(globally search a regular expression and print)。在最简单的用例中,grep 在指定文件中搜索指定的字符串。为此,grep 将文件路径和字符串作为其参数。然后它读取文件,找到包含字符串参数的行,并打印这些行。

在此过程中,我们将展示如何使我们的命令行工具使用许多其他命令行工具使用的终端功能。我们将读取环境变量的值,以允许用户配置工具的行为。我们还将错误消息打印到标准错误控制台流(stderr)而不是标准输出(stdout),以便例如用户可以将成功输出重定向到文件,同时仍然在屏幕上看到错误消息。

Rust 社区的一位成员 Andrew Gallant 已经创建了一个功能齐全、非常快速的 grep 版本,称为 ripgrep。相比之下,我们的版本将相当简单,但本章将为你提供一些背景知识,帮助你理解像 ripgrep 这样的实际项目。

我们的 grep 项目将结合你迄今为止学到的许多概念:

我们还将简要介绍闭包、迭代器和 trait 对象,这些内容将在第 13 章第 18 章中详细讨论。

接受命令行参数

让我们像往常一样使用 cargo new 创建一个新项目。我们将项目命名为 minigrep,以便与您系统上可能已经存在的 grep 工具区分开来。

$ cargo new minigrep
     Created binary (application) `minigrep` project
$ cd minigrep

第一个任务是让 minigrep 接受两个命令行参数:文件路径和要搜索的字符串。也就是说,我们希望能够在运行程序时使用 cargo run,两个连字符表示接下来的参数是传递给我们的程序而不是 cargo 的,一个要搜索的字符串,以及要搜索的文件路径,如下所示:

$ cargo run -- searchstring example-filename.txt

目前,由 cargo new 生成的程序无法处理我们传递给它的参数。crates.io 上的一些现有库可以帮助编写接受命令行参数的程序,但由于您刚刚学习这个概念,让我们自己实现这个功能。

读取参数值

为了使 minigrep 能够读取我们传递给它的命令行参数的值,我们需要使用 Rust 标准库中提供的 std::env::args 函数。这个函数返回传递给 minigrep 的命令行参数的迭代器。我们将在第 13 章中详细介绍迭代器。现在,您只需要了解关于迭代器的两个细节:迭代器生成一系列值,并且我们可以在迭代器上调用 collect 方法将其转换为包含迭代器生成的所有元素的集合,例如向量。

Listing 12-1 中的代码允许您的 minigrep 程序读取传递给它的任何命令行参数,然后将这些值收集到一个向量中。

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    dbg!(args);
}

首先,我们使用 use 语句将 std::env 模块引入作用域,以便可以使用它的 args 函数。请注意,std::env::args 函数嵌套在两个模块层级中。正如我们在第 7 章中讨论的那样,在所需函数嵌套在多个模块中的情况下,我们选择将父模块引入作用域,而不是直接引入函数。通过这样做,我们可以轻松使用 std::env 中的其他函数。这也比添加 use std::env::args 然后仅使用 args 调用函数更清晰,因为 args 可能很容易被误认为是当前模块中定义的函数。

args 函数和无效的 Unicode

请注意,如果任何参数包含无效的 Unicode,std::env::args 将会 panic。如果您的程序需要接受包含无效 Unicode 的参数,请改用 std::env::args_os。该函数返回一个生成 OsString 值而不是 String 值的迭代器。我们在这里选择使用 std::env::args 是为了简单起见,因为 OsString 值因平台而异,并且比 String 值更复杂。

main 的第一行,我们调用 env::args,并立即使用 collect 将迭代器转换为包含迭代器生成的所有值的向量。我们可以使用 collect 函数创建多种类型的集合,因此我们显式注释 args 的类型以指定我们希望得到一个字符串向量。尽管在 Rust 中很少需要注释类型,但 collect 是您经常需要注释的函数之一,因为 Rust 无法推断您想要的集合类型。

最后,我们使用调试宏打印向量。让我们先尝试运行代码,不带参数,然后带两个参数:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/minigrep`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
]
$ cargo run -- needle haystack
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.57s
     Running `target/debug/minigrep needle haystack`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
    "needle",
    "haystack",
]

请注意,向量中的第一个值是 "target/debug/minigrep",这是我们的二进制文件的名称。这与 C 语言中的参数列表行为相匹配,允许程序使用它们在执行时被调用的名称。通常,访问程序名称很方便,以防您想在消息中打印它或根据用于调用程序的命令行别名更改程序的行为。但出于本章的目的,我们将忽略它,只保存我们需要的两个参数。

将参数值保存在变量中

程序目前能够访问指定为命令行参数的值。现在我们需要将这两个参数的值保存在变量中,以便我们可以在程序的其余部分使用这些值。我们在 Listing 12-2 中这样做。

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let file_path = &args[2];

    println!("Searching for {query}");
    println!("In file {file_path}");
}

正如我们在打印向量时看到的那样,程序的名称占据了向量中的第一个值 args[0],因此我们从索引 1 开始获取参数。minigrep 接受的第一个参数是我们要搜索的字符串,因此我们将第一个参数的引用放入变量 query 中。第二个参数将是文件路径,因此我们将第二个参数的引用放入变量 file_path 中。

我们暂时打印这些变量的值,以证明代码按我们的预期工作。让我们再次运行这个程序,参数为 testsample.txt

$ cargo run -- test sample.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep test sample.txt`
Searching for test
In file sample.txt

太好了,程序正在工作!我们需要的参数值被保存到了正确的变量中。稍后我们将添加一些错误处理来处理某些潜在的错误情况,例如用户没有提供参数;现在,我们将忽略这种情况,转而添加文件读取功能。

读取文件

现在我们将添加功能来读取 file_path 参数中指定的文件。首先,我们需要一个示例文件来进行测试:我们将使用一个包含少量文本的文件,这些文本分布在多行中,并且有一些重复的单词。清单 12-3 中有一首 Emily Dickinson 的诗,非常适合用来测试!在你的项目的根目录下创建一个名为 poem.txt 的文件,并输入诗歌“I’m Nobody! Who are you?”

I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

有了文本后,编辑 src/main.rs 并添加代码来读取文件,如清单 12-4 所示。

use std::env;
use std::fs;

fn main() {
    // --snip--
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let file_path = &args[2];

    println!("Searching for {query}");
    println!("In file {file_path}");

    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

首先,我们通过 use 语句引入标准库的相关部分:我们需要 std::fs 来处理文件。

main 函数中,新的语句 fs::read_to_string 接受 file_path,打开该文件,并返回一个类型为 std::io::Result<String> 的值,其中包含文件的内容。

之后,我们再次添加一个临时的 println! 语句,用于在文件读取后打印 contents 的值,以便我们可以检查程序是否正常工作。

让我们用任意字符串作为第一个命令行参数(因为我们还没有实现搜索部分)和 poem.txt 文件作为第二个参数来运行这段代码:

$ cargo run -- the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

太好了!代码读取并打印了文件的内容。但是这段代码有一些缺陷。目前,main 函数有多个职责:通常,如果每个函数只负责一个功能,代码会更清晰、更容易维护。另一个问题是我们没有很好地处理错误。程序现在还很小,所以这些缺陷不是大问题,但随着程序的增长,干净地修复它们将变得更加困难。在开发程序时,尽早开始重构是一个好习惯,因为重构少量代码要容易得多。我们接下来会这样做。

重构以提高模块化和错误处理

为了改进我们的程序,我们将解决四个与程序结构和潜在错误处理相关的问题。首先,我们的 main 函数现在执行两个任务:解析参数和读取文件。随着程序的增长,main 函数处理的独立任务数量也会增加。随着函数职责的增加,它变得更加难以理解、测试和修改,而不会破坏其中的某一部分。最好将功能分离,使每个函数只负责一个任务。

这个问题也与第二个问题相关:尽管 queryfile_path 是我们程序的配置变量,但像 contents 这样的变量用于执行程序的逻辑。main 函数越长,我们需要引入作用域的变量就越多;作用域中的变量越多,跟踪每个变量的用途就越困难。最好将配置变量分组到一个结构中,以明确它们的用途。

第三个问题是我们使用 expect 在读取文件失败时打印错误消息,但错误消息只是打印 应该能够读取文件。读取文件可能会以多种方式失败:例如,文件可能丢失,或者我们可能没有权限打开它。目前,无论情况如何,我们都会为所有情况打印相同的错误消息,这不会给用户提供任何信息!

第四个问题是我们使用 expect 来处理错误,如果用户在没有指定足够参数的情况下运行我们的程序,他们会从 Rust 中得到一个 索引越界 的错误,这个错误并没有清楚地解释问题。最好将所有错误处理代码放在一个地方,这样未来的维护者只需在一个地方查阅代码,如果错误处理逻辑需要更改的话。将所有错误处理代码放在一个地方还将确保我们打印的消息对最终用户有意义。

让我们通过重构项目来解决这四个问题。

二进制项目的职责分离

将多个任务的职责分配给 main 函数的组织问题是许多二进制项目的常见问题。因此,Rust 社区开发了一些指导原则,用于在 main 函数变得庞大时拆分二进制程序的各个职责。这个过程包括以下步骤:

  • 将程序拆分为 main.rs 文件和 lib.rs 文件,并将程序的逻辑移动到 lib.rs 中。
  • 只要命令行解析逻辑很小,它可以保留在 main.rs 中。
  • 当命令行解析逻辑开始变得复杂时,将其从 main.rs 中提取出来并移动到 lib.rs 中。

在此过程之后,main 函数中保留的职责应限于以下内容:

  • 使用参数值调用命令行解析逻辑
  • 设置任何其他配置
  • 调用 lib.rs 中的 run 函数
  • 如果 run 返回错误,则处理错误

这种模式是关于职责分离的:main.rs 处理程序的运行,lib.rs 处理手头任务的所有逻辑。因为你不能直接测试 main 函数,这种结构允许你将所有程序逻辑移动到 lib.rs 中的函数中进行测试。保留在 main.rs 中的代码将足够小,可以通过阅读来验证其正确性。让我们按照这个过程重新编写我们的程序。

提取参数解析器

我们将提取解析参数的功能到一个函数中,main 将调用该函数以准备将命令行解析逻辑移动到 src/lib.rs 中。Listing 12-5 显示了 main 的新开始部分,它调用了一个新的函数 parse_config,我们暂时在 src/main.rs 中定义它。

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let (query, file_path) = parse_config(&args);

    // --snip--

    println!("Searching for {query}");
    println!("In file {file_path}");

    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let file_path = &args[2];

    (query, file_path)
}

我们仍然将命令行参数收集到一个向量中,但我们不再在 main 函数中将索引 1 处的参数值分配给变量 query,将索引 2 处的参数值分配给变量 file_path,而是将整个向量传递给 parse_config 函数。parse_config 函数然后包含确定哪个参数进入哪个变量的逻辑,并将值传递回 main。我们仍然在 main 中创建 queryfile_path 变量,但 main 不再负责确定命令行参数和变量之间的对应关系。

对于我们的小程序来说,这种重构可能看起来有些过度,但我们正在以小步增量进行重构。在进行此更改后,再次运行程序以验证参数解析是否仍然有效。经常检查你的进度有助于在问题发生时识别问题的原因。

分组配置值

我们可以再迈出一小步来进一步改进 parse_config 函数。目前,我们返回一个元组,但随后我们立即将该元组分解为单独的部分。这表明我们可能还没有正确的抽象。

另一个表明有改进空间的指标是 parse_configconfig 部分,这意味着我们返回的两个值是相关的,并且都是某个配置值的一部分。我们目前没有在数据结构中传达这种含义,除了将两个值分组到一个元组中;我们将把这两个值放入一个结构体中,并为每个结构体字段赋予一个有意义的名称。这样做将使未来的代码维护者更容易理解不同值之间的关系及其用途。

Listing 12-6 显示了对 parse_config 函数的改进。

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = parse_config(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    // --snip--

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let file_path = args[2].clone();

    Config { query, file_path }
}

我们添加了一个名为 Config 的结构体,定义了两个字段 queryfile_pathparse_config 的签名现在表明它返回一个 Config 值。在 parse_config 的主体中,我们过去返回引用 argsString 值的字符串切片,现在我们定义 Config 包含拥有的 String 值。main 中的 args 变量是参数值的所有者,并且只允许 parse_config 函数借用它们,这意味着如果 Config 试图获取 args 中值的所有权,我们将违反 Rust 的借用规则。

我们可以通过多种方式管理 String 数据;最简单但效率较低的方法是调用值的 clone 方法。这将为 Config 实例创建一个完整的数据副本,这比存储对字符串数据的引用需要更多的时间和内存。然而,克隆数据也使我们的代码非常直接,因为我们不必管理引用的生命周期;在这种情况下,放弃一点性能以获得简单性是一个值得的权衡。

使用 clone 的权衡

许多 Rust 程序员倾向于避免使用 clone 来解决所有权问题,因为它的运行时成本。在 第 13 章 中,你将学习如何在这种类型的情况下使用更高效的方法。但现在,复制一些字符串以继续取得进展是可以的,因为你只会进行一次这些复制,而且文件路径和查询字符串非常小。拥有一个稍微低效但可以工作的程序比在第一次尝试时过度优化代码要好。随着你对 Rust 的经验增加,从最有效的解决方案开始会更容易,但现在调用 clone 是完全可接受的。

我们已经更新了 main,使其将 parse_config 返回的 Config 实例放入一个名为 config 的变量中,并且我们更新了以前使用单独的 queryfile_path 变量的代码,使其现在使用 Config 结构体的字段。

现在我们的代码更清楚地传达了 queryfile_path 是相关的,并且它们的目的是配置程序的工作方式。任何使用这些值的代码都知道在 config 实例中查找它们,这些字段的名称表明了它们的用途。

Config 创建构造函数

到目前为止,我们已经从 main 中提取了负责解析命令行参数的逻辑,并将其放在 parse_config 函数中。这样做帮助我们看到了 queryfile_path 值是相关的,并且这种关系应该在我们的代码中传达。然后我们添加了一个 Config 结构体来命名 queryfile_path 的相关用途,并能够从 parse_config 函数中返回值的名称作为结构体字段名称。

现在 parse_config 函数的目的是创建一个 Config 实例,我们可以将 parse_config 从一个普通函数更改为一个与 Config 结构体关联的名为 new 的函数。进行此更改将使代码更符合习惯。我们可以通过调用 String::new 来创建标准库中的类型实例,例如 String。同样,通过将 parse_config 更改为与 Config 关联的 new 函数,我们将能够通过调用 Config::new 来创建 Config 的实例。Listing 12-7 显示了我们需要进行的更改。

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");

    // --snip--
}

// --snip--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}

我们已经更新了 main,将调用 parse_config 的地方改为调用 Config::new。我们将 parse_config 的名称更改为 new,并将其移动到一个 impl 块中,该块将 new 函数与 Config 关联起来。再次尝试编译此代码以确保其正常工作。

修复错误处理

现在我们将修复错误处理。回想一下,如果 args 向量包含少于三个项目,尝试访问索引 1 或索引 2 处的值将导致程序崩溃。尝试在不带任何参数的情况下运行程序;它将如下所示:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

thread 'main' panicked at src/main.rs:27:21:
index out of bounds: the len is 1 but the index is 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

index out of bounds: the len is 1 but the index is 1 这一行是面向程序员的错误消息。它不会帮助我们的最终用户理解他们应该做什么。现在让我们修复这个问题。

改进错误消息

在 Listing 12-8 中,我们在 new 函数中添加了一个检查,该检查将在访问索引 1 和索引 2 之前验证切片是否足够长。如果切片不够长,程序将崩溃并显示更好的错误消息。

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    // --snip--
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("not enough arguments");
        }
        // --snip--

        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}

这段代码类似于 我们在 Listing 9-13 中编写的 Guess::new 函数,当 value 参数超出有效值范围时,我们调用了 panic!。在这里,我们不是检查值的范围,而是检查 args 的长度是否至少为 3,并且函数的其余部分可以在此条件满足的情况下运行。如果 args 少于三个项目,此条件将为 true,我们将调用 panic! 宏以立即结束程序。

new 中添加这几行代码后,让我们再次运行程序,看看现在的错误是什么样子的:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

thread 'main' panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

这个输出更好:我们现在有一个合理的错误消息。然而,我们还有一些我们不希望提供给用户的额外信息。也许我们在 Listing 9-13 中使用的技术不是这里最好的选择:panic! 调用更适合编程问题而不是使用问题,正如第 9 章中讨论的那样。相反,我们将使用你在第 9 章中学到的另一种技术——返回一个 Result,表示成功或错误。

返回 Result 而不是调用 panic!

我们可以改为返回一个 Result 值,该值在成功情况下包含 Config 实例,并在错误情况下描述问题。我们还将函数名称从 new 更改为 build,因为许多程序员期望 new 函数永远不会失败。当 Config::buildmain 通信时,我们可以使用 Result 类型来指示存在问题。然后我们可以更改 main 以将 Err 变体转换为对用户更实用的错误,而不包含 panic! 调用引起的关于 thread 'main'RUST_BACKTRACE 的周围文本。

Listing 12-9 显示了我们需要对现在称为 Config::build 的函数的返回值和函数体进行的更改,以返回 Result。请注意,在我们更新 main 之前,这不会编译,我们将在下一个列表中更新 main

use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

我们的 build 函数返回一个 Result,在成功情况下包含 Config 实例,在错误情况下包含一个字符串字面量。我们的错误值将始终是具有 'static 生命周期的字符串字面量。

我们在函数体中做了两个更改:当用户没有传递足够的参数时,我们现在返回一个 Err 值,而不是调用 panic!,并且我们将 Config 返回值包装在 Ok 中。这些更改使函数符合其新的类型签名。

Config::build 返回 Err 值允许 main 函数处理从 build 函数返回的 Result 值,并在错误情况下更干净地退出进程。

调用 Config::build 并处理错误

为了处理错误情况并打印用户友好的消息,我们需要更新 main 以处理 Config::build 返回的 Result,如 Listing 12-10 所示。我们还将从 panic! 中移除退出命令行工具的责任,并手动实现它。非零退出状态是一个约定,用于向调用我们程序的进程发出信号,表示程序以错误状态退出。

use std::env;
use std::fs;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

在这个列表中,我们使用了一个我们还没有详细介绍的方法:unwrap_or_else,它由标准库在 Result<T, E> 上定义。使用 unwrap_or_else 允许我们定义一些自定义的非 panic! 错误处理。如果 Result 是一个 Ok 值,此方法的行为类似于 unwrap:它返回 Ok 包装的内部值。然而,如果值是一个 Err 值,此方法会调用我们定义并作为参数传递给 unwrap_or_else 的闭包中的代码。我们将在 第 13 章 中更详细地介绍闭包。现在,你只需要知道 unwrap_or_else 会将 Err 的内部值(在本例中是我们添加到 Listing 12-9 中的静态字符串 "not enough arguments")传递给出现在垂直管道之间的参数 err 中的闭包。然后,闭包中的代码可以在运行时使用 err 值。

我们添加了一个新的 use 行,将 process 从标准库引入作用域。在错误情况下运行的闭包中的代码只有两行:我们打印 err 值,然后调用 process::exitprocess::exit 函数将立即停止程序并返回作为退出状态代码传递的数字。这与我们在 Listing 12-8 中使用的基于 panic! 的处理类似,但我们不再获得所有额外的输出。让我们试试:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments

太好了!这个输出对我们的用户来说更友好。

main 中提取逻辑

现在我们已经完成了配置解析的重构,让我们转向程序的逻辑。正如我们在 “二进制项目的职责分离” 中所述,我们将提取一个名为 run 的函数,该函数将包含当前在 main 函数中的所有逻辑,这些逻辑不涉及设置配置或处理错误。完成后,main 将简洁且易于通过阅读验证,我们将能够为所有其他逻辑编写测试。

Listing 12-11 显示了提取的 run 函数。目前,我们只是进行小的增量改进,提取函数。我们仍然在 src/main.rs 中定义函数。

use std::env;
use std::fs;
use std::process;

fn main() {
    // --snip--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) {
    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

// --snip--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

run 函数现在包含 main 中从读取文件开始的所有剩余逻辑。run 函数将 Config 实例作为参数。

run 函数返回错误

将剩余的程序逻辑分离到 run 函数中后,我们可以改进错误处理,就像我们在 Listing 12-9 中对 Config::build 所做的那样。run 函数将在出现问题时返回 Result<T, E>,而不是通过调用 expect 让程序崩溃。这将使我们能够进一步将错误处理逻辑整合到 main 中,以用户友好的方式处理错误。Listing 12-12 显示了我们需要对 run 的签名和主体进行的更改。

use std::env;
use std::fs;
use std::process;
use std::error::Error;

// --snip--


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

我们在这里做了三个重要的更改。首先,我们将 run 函数的返回类型更改为 Result<(), Box<dyn Error>>。此函数以前返回单元类型 (),我们将其保留为 Ok 情况下返回的值。

对于错误类型,我们使用了 trait 对象 Box<dyn Error>(并且我们已经在顶部使用 use 语句将 std::error::Error 引入作用域)。我们将在 第 18 章 中介绍 trait 对象。现在,只需知道 Box<dyn Error> 意味着函数将返回一个实现了 Error trait 的类型,但我们不必指定返回值的具体类型。这使我们能够灵活地在不同的错误情况下返回不同类型的错误值。dyn 关键字是 dynamic 的缩写。

其次,我们删除了对 expect 的调用,转而使用 ? 操作符,正如我们在 第 9 章 中讨论的那样。? 操作符将在出现错误时从当前函数返回错误值,供调用者处理,而不是在错误时 panic!

第三,run 函数现在在成功情况下返回一个 Ok 值。我们在签名中将 run 函数的成功类型声明为 (),这意味着我们需要将单元类型值包装在 Ok 值中。这种 Ok(()) 语法一开始可能看起来有点奇怪,但像这样使用 () 是表示我们调用 run 仅为了其副作用的标准方式;它不返回我们需要使用的值。

当你运行此代码时,它将编译但会显示一个警告:

$ cargo run -- the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
  --> src/main.rs:19:5
   |
19 |     run(config);
   |     ^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
   |
19 |     let _ = run(config);
   |     +++++++

warning: `minigrep` (bin "minigrep") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.71s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

Rust 告诉我们我们的代码忽略了 Result 值,并且 Result 值可能表明发生了错误。但我们没有检查是否有错误,编译器提醒我们可能应该在这里添加一些错误处理代码!现在让我们解决这个问题。

main 中处理 run 返回的错误

我们将使用类似于我们在 Listing 12-10 中对 Config::build 使用的技术来检查错误并处理它们,但有一些细微差别:

文件名: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

fn main() {
    // --snip--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

我们使用 if let 而不是 unwrap_or_else 来检查 run 是否返回 Err 值,并在返回时调用 process::exit(1)run 函数不像 Config::build 那样返回我们想要 unwrap 的值。因为 run 在成功情况下返回 (),我们只关心检测错误,所以我们不需要 unwrap_or_else 来返回解包的值,这只会是 ()

if letunwrap_or_else 函数的主体在两种情况下是相同的:我们打印错误并退出。

将代码拆分为库 crate

我们的 minigrep 项目到目前为止看起来不错!现在我们将拆分 src/main.rs 文件,并将一些代码放入 src/lib.rs 文件中。这样,我们可以测试代码,并拥有一个职责更少的 src/main.rs 文件。

让我们将所有不在 main 函数中的代码从 src/main.rs 移动到 src/lib.rs

  • run 函数定义
  • 相关的 use 语句
  • Config 的定义
  • Config::build 函数定义

src/lib.rs 的内容应具有 Listing 12-13 中显示的签名(为了简洁起见,我们省略了函数的主体)。请注意,在我们修改 src/main.rs 之前,这不会编译,我们将在 Listing 12-14 中进行修改。

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        // --snip--
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    // --snip--
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

我们大量使用了 pub 关键字:在 Config 上、其字段和其 build 方法上,以及在 run 函数上。我们现在有一个库 crate,它有一个我们可以测试的公共 API!

现在我们需要将我们移动到 src/lib.rs 的代码引入二进制 crate 的作用域中,如 Listing 12-14 所示。

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    // --snip--
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    if let Err(e) = minigrep::run(config) {
        // --snip--
        println!("Application error: {e}");
        process::exit(1);
    }
}

我们添加了一个 use minigrep::Config 行,将 Config 类型从库 crate 引入二进制 crate 的作用域,并在 run 函数前加上我们的 crate 名称。现在所有功能应该都已连接并且应该可以正常工作。使用 cargo run 运行程序,确保一切正常。

哇!这真是很多工作,但我们为未来的成功奠定了基础。现在处理错误要容易得多,而且代码更加模块化。从这里开始,几乎所有的工作都将在 src/lib.rs 中完成。

让我们利用这种新发现的模块化来做一些在旧代码中很难但在新代码中很容易的事情:我们将编写一些测试!

使用测试驱动开发(TDD)开发库的功能

现在我们已经将逻辑提取到 src/lib.rs 中,并将参数收集和错误处理留在 src/main.rs 中,这样更容易为代码的核心功能编写测试。我们可以直接调用函数并传入各种参数,检查返回值,而不必从命令行调用二进制文件。

在本节中,我们将使用测试驱动开发(TDD)过程为 minigrep 程序添加搜索逻辑,步骤如下:

  1. 编写一个失败的测试,并运行它以确保它因你预期的原因而失败。
  2. 编写或修改足够的代码以使新测试通过。
  3. 重构你刚刚添加或更改的代码,并确保测试继续通过。
  4. 从步骤 1 重复!

尽管这只是编写软件的众多方法之一,但 TDD 可以帮助驱动代码设计。在编写使测试通过的代码之前编写测试,有助于在整个过程中保持高测试覆盖率。

我们将通过测试驱动的方式实现一个功能,该功能将在文件内容中搜索查询字符串,并生成与查询匹配的行列表。我们将在名为 search 的函数中添加此功能。

编写一个失败的测试

因为我们不再需要它们,所以让我们从 src/lib.rssrc/main.rs 中删除用于检查程序行为的 println! 语句。然后,在 src/lib.rs 中,我们将添加一个 tests 模块和一个测试函数,就像我们在第 11 章中所做的那样。测试函数指定了我们希望 search 函数具有的行为:它将接收一个查询和要搜索的文本,并返回仅包含查询的文本行。Listing 12-15 显示了这个测试,它目前还无法编译。

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

这个测试搜索字符串 "duct"。我们搜索的文本有三行,其中只有一行包含 "duct"(注意,开头的双引号后的反斜杠告诉 Rust 不要在这个字符串字面量的开头添加换行符)。我们断言从 search 函数返回的值仅包含我们期望的行。

我们还不能运行这个测试并观察它失败,因为测试甚至无法编译:search 函数还不存在!根据 TDD 原则,我们将添加足够的代码以使测试能够编译和运行,通过添加一个总是返回空向量的 search 函数定义,如 Listing 12-16 所示。然后测试应该能够编译并失败,因为空向量与包含行 "safe, fast, productive." 的向量不匹配。

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

注意,我们需要在 search 的签名中定义一个显式的生命周期 'a,并在 contents 参数和返回值中使用该生命周期。回想一下第 10 章,生命周期参数指定了哪个参数的生命周期与返回值的生命周期相关联。在这种情况下,我们指示返回的向量应包含引用 contents 参数切片的字符串切片(而不是 query 参数)。

换句话说,我们告诉 Rust,search 函数返回的数据将与传入 search 函数的 contents 参数中的数据一样长。这很重要!切片引用的数据需要有效,引用才能有效;如果编译器假设我们正在创建 query 的字符串切片而不是 contents,它将错误地进行安全检查。

如果我们忘记生命周期注解并尝试编译此函数,我们将得到以下错误:

$ cargo build
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
  --> src/lib.rs:28:51
   |
28 | pub fn search(query: &str, contents: &str) -> Vec<&str> {
   |                      ----            ----         ^ expected named lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `query` or `contents`
help: consider introducing a named lifetime parameter
   |
28 | pub fn search<'a>(query: &'a str, contents: &'a str) -> Vec<&'a str> {
   |              ++++         ++                 ++              ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep` (lib) due to 1 previous error

Rust 无法知道我们需要两个参数中的哪一个,所以我们需要明确告诉它。因为 contents 是包含我们所有文本的参数,并且我们想要返回与该文本匹配的部分,所以我们知道 contents 是应该使用生命周期语法与返回值连接的参数。

其他编程语言不要求你在签名中将参数与返回值连接起来,但这种做法会随着时间的推移变得更容易。你可能想将这个例子与第 10 章中的“使用生命周期验证引用”部分中的示例进行比较。

现在让我们运行测试:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.97s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... FAILED

failures:

---- tests::one_result stdout ----

thread 'tests::one_result' panicked at src/lib.rs:44:9:
assertion `left == right` failed
  left: ["safe, fast, productive."]
 right: []
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::one_result

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

很好,测试失败了,正如我们预期的那样。让我们让测试通过!

编写代码以使测试通过

目前,我们的测试失败了,因为我们总是返回一个空向量。为了修复这个问题并实现 search,我们的程序需要遵循以下步骤:

  1. 遍历内容的每一行。
  2. 检查该行是否包含我们的查询字符串。
  3. 如果包含,则将其添加到我们要返回的值列表中。
  4. 如果不包含,则不做任何操作。
  5. 返回匹配的结果列表。

让我们逐步完成每个步骤,从遍历行开始。

使用 lines 方法遍历行

Rust 有一个方便的方法来处理字符串的逐行迭代,恰当地命名为 lines,如 Listing 12-17 所示。请注意,这还无法编译。

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // do something with line
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

lines 方法返回一个迭代器。我们将在第 13 章中深入讨论迭代器,但回想一下,你在Listing 3-5中看到了这种使用迭代器的方式,在那里我们使用 for 循环和迭代器对集合中的每个项目运行一些代码。

在每行中搜索查询

接下来,我们将检查当前行是否包含我们的查询字符串。幸运的是,字符串有一个名为 contains 的有用方法可以为我们做到这一点!在 search 函数中添加对 contains 方法的调用,如 Listing 12-18 所示。请注意,这仍然无法编译。

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // do something with line
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

目前,我们正在构建功能。为了使代码能够编译,我们需要从函数体中返回一个值,正如我们在函数签名中所指示的那样。

存储匹配的行

为了完成这个函数,我们需要一种方法来存储我们要返回的匹配行。为此,我们可以在 for 循环之前创建一个可变的向量,并调用 push 方法将 line 存储在向量中。在 for 循环之后,我们返回向量,如 Listing 12-19 所示。

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

现在 search 函数应该只返回包含 query 的行,我们的测试应该通过。让我们运行测试:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

我们的测试通过了,所以我们知道它有效!

此时,我们可以考虑在保持测试通过以保持相同功能的同时,重构搜索函数的实现。搜索函数中的代码并不算太糟糕,但它没有利用迭代器的一些有用功能。我们将在第 13 章中回到这个例子,详细探讨迭代器,并看看如何改进它。

run 函数中使用 search 函数

现在 search 函数已经工作并通过了测试,我们需要从 run 函数中调用 search。我们需要将 config.query 值和 run 从文件中读取的 contents 传递给 search 函数。然后 run 将打印从 search 返回的每一行:

文件名: src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

我们仍然使用 for 循环来返回 search 中的每一行并打印它。

现在整个程序应该可以工作了!让我们试试看,首先用一个应该从 Emily Dickinson 的诗中返回一行的词:frog

$ cargo run -- frog poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/minigrep frog poem.txt`
How public, like a frog

很好!现在让我们尝试一个会匹配多行的词,比如 body

$ cargo run -- body poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!

最后,让我们确保当我们搜索一个不在诗中的词时,不会得到任何行,比如 monomorphization

$ cargo run -- monomorphization poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep monomorphization poem.txt`

太棒了!我们已经构建了自己的经典工具的迷你版本,并学到了很多关于如何构建应用程序的知识。我们还学到了一些关于文件输入和输出、生命周期、测试和命令行解析的知识。

为了完成这个项目,我们将简要演示如何使用环境变量以及如何打印到标准错误,这两者在编写命令行程序时都非常有用。

使用环境变量

我们将通过添加一个额外的功能来改进 minigrep:用户可以通过环境变量开启不区分大小写的搜索功能。我们可以将这个功能作为一个命令行选项,并要求用户在每次想要使用时都输入它,但通过将其作为环境变量,我们允许用户设置一次环境变量,并在该终端会话中的所有搜索都不区分大小写。

为不区分大小写的 search 函数编写失败的测试

我们首先添加一个新的 search_case_insensitive 函数,当环境变量有值时将调用该函数。我们将继续遵循 TDD 流程,因此第一步仍然是编写一个失败的测试。我们将为新的 search_case_insensitive 函数添加一个新测试,并将旧测试从 one_result 重命名为 case_sensitive,以明确两个测试之间的区别,如 Listing 12-20 所示。

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

注意,我们也编辑了旧测试的 contents。我们添加了一行文本 "Duct tape.",其中使用了大写的 D,当我们在区分大小写的方式下搜索时,它不应该匹配查询 "duct"。通过这种方式更改旧测试有助于确保我们不会意外破坏已经实现的区分大小写的搜索功能。这个测试现在应该通过,并且在我们处理不区分大小写的搜索时应该继续通过。

用于不区分大小写搜索的新测试使用 "rUsT" 作为查询。在我们即将添加的 search_case_insensitive 函数中,查询 "rUsT" 应该匹配包含大写 R"Rust:" 行,并且匹配 "Trust me." 行,尽管它们的字母大小写与查询不同。这是我们的失败测试,它将无法编译,因为我们还没有定义 search_case_insensitive 函数。你可以随意添加一个总是返回空向量的骨架实现,类似于我们在 Listing 12-16 中为 search 函数所做的,以查看测试编译并失败。

实现 search_case_insensitive 函数

search_case_insensitive 函数,如 Listing 12-21 所示,将与 search 函数几乎相同。唯一的区别是我们将 query 和每行 line 都转换为小写,这样无论输入参数的大小写如何,它们在检查行是否包含查询时都将具有相同的大小写。

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

首先,我们将 query 字符串转换为小写并将其存储在一个同名的新变量中,遮蔽了原始的 query。对查询调用 to_lowercase 是必要的,这样无论用户的查询是 "rust""RUST""Rust" 还是 "rUsT",我们都会将查询视为 "rust" 并且不区分大小写。虽然 to_lowercase 会处理基本的 Unicode,但它不会 100% 准确。如果我们在编写一个真正的应用程序,我们会希望在这里做更多的工作,但本节是关于环境变量的,而不是 Unicode,所以我们在这里就到此为止。

注意,query 现在是一个 String 而不是字符串切片,因为调用 to_lowercase 会创建新数据而不是引用现有数据。以查询 "rUsT" 为例:该字符串切片不包含小写的 ut 供我们使用,因此我们必须分配一个包含 "rust" 的新 String。当我们现在将 query 作为参数传递给 contains 方法时,我们需要添加一个 &,因为 contains 的签名定义为接受一个字符串切片。

接下来,我们在每行 line 上添加 to_lowercase 调用,将所有字符转换为小写。现在我们已经将 linequery 转换为小写,无论查询的大小写如何,我们都会找到匹配项。

让我们看看这个实现是否通过了测试:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.33s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 2 tests
test tests::case_insensitive ... ok
test tests::case_sensitive ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

太好了!它们通过了。现在,让我们从 run 函数中调用新的 search_case_insensitive 函数。首先,我们将在 Config 结构体中添加一个配置选项,以在区分大小写和不区分大小写的搜索之间切换。添加此字段将导致编译器错误,因为我们还没有在任何地方初始化此字段:

文件名: src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

我们添加了 ignore_case 字段,它保存一个布尔值。接下来,我们需要 run 函数检查 ignore_case 字段的值,并使用它来决定是调用 search 函数还是 search_case_insensitive 函数,如 Listing 12-22 所示。这仍然无法编译。

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

最后,我们需要检查环境变量。用于处理环境变量的函数位于标准库中的 env 模块中,因此我们将该模块引入到 src/lib.rs 的顶部。然后我们将使用 env 模块中的 var 函数来检查是否设置了名为 IGNORE_CASE 的环境变量,如 Listing 12-23 所示。

use std::env;
// --snip--

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

在这里,我们创建了一个新变量 ignore_case。为了设置它的值,我们调用 env::var 函数并将 IGNORE_CASE 环境变量的名称传递给它。env::var 函数返回一个 Result,如果环境变量设置为任何值,它将是一个包含环境变量值的成功 Ok 变体。如果环境变量未设置,它将返回 Err 变体。

我们使用 Result 上的 is_ok 方法来检查环境变量是否设置,这意味着程序应该执行不区分大小写的搜索。如果 IGNORE_CASE 环境变量未设置任何值,is_ok 将返回 false,程序将执行区分大小写的搜索。我们不关心环境变量的 ,只关心它是否设置,因此我们检查 is_ok 而不是使用 unwrapexpect 或我们在 Result 上看到的其他方法。

我们将 ignore_case 变量中的值传递给 Config 实例,以便 run 函数可以读取该值并决定是调用 search_case_insensitive 还是 search,正如我们在 Listing 12-22 中实现的那样。

让我们试试吧!首先,我们将在没有设置环境变量的情况下运行我们的程序,并使用查询 to,它应该匹配任何包含小写 to 的行:

$ cargo run -- to poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!

看起来仍然有效!现在让我们在设置 IGNORE_CASE1 的情况下运行程序,但使用相同的查询 to

$ IGNORE_CASE=1 cargo run -- to poem.txt

如果你使用的是 PowerShell,你需要将设置环境变量和运行程序作为单独的命令:

PS> $Env:IGNORE_CASE=1; cargo run -- to poem.txt

这将使 IGNORE_CASE 在你的 shell 会话的剩余时间内保持有效。可以使用 Remove-Item cmdlet 取消设置:

PS> Remove-Item Env:IGNORE_CASE

我们应该得到包含 to 的行,这些行可能包含大写字母:

Are you nobody, too?
How dreary to be somebody!
To tell your name the livelong day
To an admiring bog!

太好了,我们也得到了包含 To 的行!我们的 minigrep 程序现在可以通过环境变量控制进行不区分大小写的搜索。现在你知道如何管理使用命令行参数或环境变量设置的选项了。

一些程序允许同时使用参数 环境变量进行相同的配置。在这些情况下,程序决定哪一个优先。作为另一个练习,尝试通过命令行参数或环境变量控制大小写敏感性。如果程序运行时一个设置为区分大小写,另一个设置为忽略大小写,请决定命令行参数或环境变量应该优先。

std::env 模块包含许多用于处理环境变量的有用功能:查看其文档以了解可用的功能。

将错误信息写入标准错误而不是标准输出

目前,我们使用 println! 宏将所有输出写入终端。在大多数终端中,有两种类型的输出:标准输出stdout)用于一般信息,标准错误stderr)用于错误信息。这种区分使用户可以选择将程序的成功输出重定向到文件,但仍然将错误信息打印到屏幕上。

println! 宏只能打印到标准输出,因此我们需要使用其他方法来打印到标准错误。

检查错误信息写入的位置

首先,让我们观察一下 minigrep 打印的内容当前是如何写入标准输出的,包括我们希望写入标准错误的任何错误信息。我们将通过将标准输出流重定向到一个文件,同时故意引发一个错误来实现这一点。我们不会重定向标准错误流,因此发送到标准错误的任何内容将继续显示在屏幕上。

命令行程序应该将错误信息发送到标准错误流,这样即使我们将标准输出流重定向到文件,我们仍然可以在屏幕上看到错误信息。我们的程序目前表现不佳:我们将看到它将错误信息输出保存到文件中,而不是显示在屏幕上!

为了演示这种行为,我们将使用 > 和文件路径 output.txt 来运行程序,我们希望将标准输出流重定向到该文件。我们不会传递任何参数,这应该会导致一个错误:

$ cargo run > output.txt

> 语法告诉 shell 将标准输出的内容写入 output.txt 而不是屏幕。我们没有看到预期的错误信息打印到屏幕上,所以这意味着它一定已经写入了文件。以下是 output.txt 的内容:

Problem parsing arguments: not enough arguments

是的,我们的错误信息被打印到了标准输出。将这样的错误信息打印到标准错误会更有用,这样只有成功运行的数据才会写入文件。我们将对此进行更改。

将错误信息打印到标准错误

我们将使用 Listing 12-24 中的代码来更改错误信息的打印方式。由于我们在本章早些时候进行了重构,所有打印错误信息的代码都在一个函数 main 中。标准库提供了 eprintln! 宏,它可以打印到标准错误流,因此让我们将调用 println! 打印错误的两处地方改为使用 eprintln!

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

现在让我们再次以相同的方式运行程序,不传递任何参数,并使用 > 重定向标准输出:

$ cargo run > output.txt
Problem parsing arguments: not enough arguments

现在我们在屏幕上看到了错误信息,而 output.txt 中没有任何内容,这是我们对命令行程序的期望行为。

让我们再次运行程序,这次传递不会导致错误的参数,但仍然将标准输出重定向到文件,如下所示:

$ cargo run -- to poem.txt > output.txt

我们不会在终端上看到任何输出,而 output.txt 将包含我们的结果:

文件名: output.txt

Are you nobody, too?
How dreary to be somebody!

这表明我们现在适当地使用标准输出来处理成功输出,使用标准错误来处理错误输出。

总结

本章回顾了你到目前为止学到的一些主要概念,并介绍了如何在 Rust 中执行常见的 I/O 操作。通过使用命令行参数、文件、环境变量以及用于打印错误的 eprintln! 宏,你现在已经准备好编写命令行应用程序了。结合前几章的概念,你的代码将组织良好,有效地将数据存储在适当的数据结构中,优雅地处理错误,并经过良好的测试。

接下来,我们将探索一些受函数式语言影响的 Rust 特性:闭包和迭代器。

函数式语言特性:迭代器与闭包

Rust 的设计灵感来源于许多现有的语言和技术,其中一个重要的影响是函数式编程。函数式编程风格通常包括将函数作为值使用,例如将它们作为参数传递、从其他函数返回、赋值给变量以便稍后执行等等。

在本章中,我们不会讨论什么是函数式编程或什么不是函数式编程,而是会讨论 Rust 中一些与许多被称为函数式语言中的特性相似的功能。

更具体地说,我们将涵盖以下内容:

  • 闭包,一种可以存储在变量中的类似函数的构造
  • 迭代器,一种处理一系列元素的方式
  • 如何使用闭包和迭代器来改进第 12 章中的 I/O 项目
  • 闭包和迭代器的性能(剧透警告:它们比你想象的要快!)

我们已经介绍了一些其他 Rust 特性,例如模式匹配和枚举,这些特性也受到了函数式风格的影响。由于掌握闭包和迭代器是编写地道、高效的 Rust 代码的重要部分,我们将用整章来讨论它们。

闭包:可以捕获环境的匿名函数

Rust 的闭包是可以保存在变量中或作为参数传递给其他函数的匿名函数。你可以在一个地方创建闭包,然后在另一个地方调用它,以在不同的上下文中执行它。与函数不同,闭包可以从定义它们的作用域中捕获值。我们将展示这些闭包特性如何实现代码重用和行为定制。

使用闭包捕获环境

我们将首先研究如何使用闭包从它们定义的环境中捕获值以供以后使用。以下是场景:每隔一段时间,我们的 T 恤公司会向邮件列表中的某个人赠送一件独家限量版 T 恤作为促销活动。邮件列表中的人可以选择将他们的最喜欢的颜色添加到他们的个人资料中。如果被选中获得免费 T 恤的人设置了他们最喜欢的颜色,他们将获得该颜色的 T 恤。如果该人没有指定最喜欢的颜色,他们将获得公司当前库存最多的颜色。

有许多方法可以实现这一点。在这个例子中,我们将使用一个名为 ShirtColor 的枚举,它有 RedBlue 两个变体(为了简化,限制了可用的颜色数量)。我们用 Inventory 结构体表示公司的库存,该结构体有一个名为 shirts 的字段,包含一个 Vec<ShirtColor>,表示当前库存的 T 恤颜色。定义在 Inventory 上的 giveaway 方法获取免费 T 恤获奖者的可选 T 恤颜色偏好,并返回该人将获得的 T 恤颜色。此设置如代码清单 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 有两件蓝色 T 恤和一件红色 T 恤剩余,用于此次限量版促销活动。我们为有红色 T 恤偏好的用户和没有任何偏好的用户调用 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() 的闭包。标准库不需要了解我们定义的 InventoryShirtColor 类型,或者我们在此场景中想要使用的逻辑。闭包捕获了对 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_v3add_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 的向量的不可变引用,因为它只需要一个不可变引用来打印值:

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
   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 向量添加一个元素。闭包现在捕获了一个可变引用:

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
   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 的修改版本,以在新线程而不是主线程中打印向量:

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();
}

我们生成一个新线程,将闭包作为参数传递给线程运行。闭包主体打印出列表。在代码清单 13-4 中,闭包只使用不可变引用捕获了 list,因为这是打印它所需的最少访问量。在这个例子中,即使闭包主体仍然只需要一个不可变引用,我们需要通过在闭包定义的开头放置 move 关键字来指定 list 应该移动到闭包中。新线程可能在主线程的其余部分完成之前完成,或者主线程可能先完成。如果主线程保持对 list 的所有权但在新线程完成之前结束并丢弃 list,线程中的不可变引用将无效。因此,编译器要求将 list 移动到给新线程的闭包中,以便引用有效。尝试删除 move 关键字或在闭包定义后在主线程中使用 list,看看你会得到什么编译器错误!

将捕获的值移出闭包和 Fn 特性

一旦闭包从定义它的环境中捕获了引用或捕获了值的所有权(从而影响什么,如果有的话,被移动到闭包中),闭包主体中的代码定义了当闭包稍后被评估时引用或值会发生什么(从而影响什么,如果有的话,被移出闭包)。闭包主体可以执行以下任何操作:将捕获的值移出闭包、改变捕获的值、既不移动也不改变值,或者根本不从环境中捕获任何内容。

闭包从环境中捕获和处理值的方式会影响闭包实现的特性,而特性是函数和结构体可以指定它们可以使用哪些闭包的方式。闭包将根据闭包主体如何处理值自动实现一个、两个或所有三个 Fn 特性,以累加的方式:

  1. FnOnce 适用于可以调用一次的闭包。所有闭包至少实现此特性,因为所有闭包都可以被调用。将捕获的值移出其主体的闭包将仅实现 FnOnce 而不实现其他 Fn 特性,因为它只能被调用一次。
  2. FnMut 适用于不将捕获的值移出其主体但可能会改变捕获值的闭包。这些闭包可以被多次调用。
  3. 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 是表示 OptionSome 变体中值的类型的泛型类型。该类型 T 也是 unwrap_or_else 函数的返回类型:例如,在 Option<String> 上调用 unwrap_or_else 的代码将获得一个 String

接下来,注意 unwrap_or_else 函数有一个额外的泛型类型参数 FF 类型是名为 f 的参数的类型,它是我们在调用 unwrap_or_else 时提供的闭包。

在泛型类型 F 上指定的特性边界是 FnOnce() -> T,这意味着 F 必须能够被调用一次,不带参数,并返回一个 T。在特性边界中使用 FnOnce 表达了 unwrap_or_else 最多只会调用 f 一次的约束。在 unwrap_or_else 的主体中,我们可以看到如果 OptionSomef 不会被调用。如果 OptionNonef 将被调用一次。因为所有闭包都实现 FnOnceunwrap_or_else 接受所有三种闭包,并且尽可能灵活。

注意:如果我们想做的事情不需要从环境中捕获值,我们可以使用函数名而不是闭包。例如,我们可以在 Option<Vec<T>> 值上调用 unwrap_or_else(Vec::new),如果值为 None,则获得一个新的空向量。编译器会自动为函数定义实现适用的 Fn 特性。

现在让我们看看标准库中定义在切片上的 sort_by_key 方法,看看它与 unwrap_or_else 有何不同,以及为什么 sort_by_key 使用 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:#?}");
}

这段代码打印:

$ 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 一起使用:

#[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:#?}");
}

这是一种人为的、复杂的方式(不起作用)来尝试计算 sort_by_key 在排序 list 时调用闭包的次数。此代码试图通过将 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 计数器的可变引用,因此可以被多次调用:

#[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");
}

Fn 特性在定义或使用利用闭包的函数或类型时非常重要。在下一节中,我们将讨论迭代器。许多迭代器方法接受闭包参数,因此在我们继续时请记住这些闭包细节!

使用迭代器处理一系列项目

迭代器模式允许你依次对一系列项目执行某些任务。迭代器负责遍历每个项目的逻辑,并确定序列何时结束。当你使用迭代器时,你不需要自己重新实现这些逻辑。

在 Rust 中,迭代器是惰性的,这意味着在你调用消耗迭代器的方法之前,它们不会产生任何效果。例如,Listing 13-10 中的代码通过调用 Vec<T> 上定义的 iter 方法,在向量 v1 的项目上创建了一个迭代器。这段代码本身并没有做任何有用的事情。

fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();
}

迭代器存储在 v1_iter 变量中。一旦我们创建了一个迭代器,我们可以以多种方式使用它。在第 3 章的 Listing 3-5 中,我们使用 for 循环遍历数组,对其中的每个项目执行一些代码。在底层,这隐式地创建并消耗了一个迭代器,但直到现在我们才详细讨论它的工作原理。

在 Listing 13-11 的示例中,我们将迭代器的创建与 for 循环中的迭代器使用分离开来。当使用 v1_iter 中的迭代器调用 for 循环时,迭代器中的每个元素都会在循环的一次迭代中使用,并打印出每个值。

fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();

    for val in v1_iter {
        println!("Got: {val}");
    }
}

在没有标准库提供迭代器的语言中,你可能会通过从索引 0 开始一个变量,使用该变量索引向量以获取值,并在循环中递增变量值,直到达到向量中的项目总数来实现相同的功能。

迭代器为你处理所有这些逻辑,减少了你可能搞砸的重复代码。迭代器为你提供了更多的灵活性,可以将相同的逻辑用于许多不同类型的序列,而不仅仅是你可以索引的数据结构,比如向量。让我们看看迭代器是如何做到这一点的。

Iterator Trait 和 next 方法

所有迭代器都实现了一个名为 Iterator 的 trait,该 trait 定义在标准库中。该 trait 的定义如下:

#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;

    // 省略了带有默认实现的方法
}
}

注意,这个定义使用了一些新的语法:type ItemSelf::Item,它们定义了这个 trait 的关联类型。我们将在第 20 章详细讨论关联类型。现在,你只需要知道这段代码表示实现 Iterator trait 需要你同时定义一个 Item 类型,并且这个 Item 类型用于 next 方法的返回类型。换句话说,Item 类型将是迭代器返回的类型。

Iterator trait 只需要实现者定义一个方法:next 方法,它一次返回一个迭代器的项目,包装在 Some 中,当迭代结束时返回 None

我们可以直接在迭代器上调用 next 方法;Listing 13-12 展示了从向量创建的迭代器上重复调用 next 方法返回的值。

#[cfg(test)]
mod tests {
    #[test]
    fn iterator_demonstration() {
        let v1 = vec![1, 2, 3];

        let mut v1_iter = v1.iter();

        assert_eq!(v1_iter.next(), Some(&1));
        assert_eq!(v1_iter.next(), Some(&2));
        assert_eq!(v1_iter.next(), Some(&3));
        assert_eq!(v1_iter.next(), None);
    }
}

注意,我们需要将 v1_iter 设为可变:在迭代器上调用 next 方法会改变迭代器用于跟踪其在序列中位置的内部状态。换句话说,这段代码消耗或使用掉了迭代器。每次调用 next 都会消耗迭代器中的一个项目。当我们使用 for 循环时,不需要将 v1_iter 设为可变,因为循环获取了 v1_iter 的所有权并在幕后使其可变。

还要注意,我们从 next 调用中获取的值是对向量中值的不可变引用。iter 方法生成一个不可变引用的迭代器。如果我们想创建一个获取 v1 所有权并返回拥有值的迭代器,我们可以调用 into_iter 而不是 iter。类似地,如果我们想遍历可变引用,我们可以调用 iter_mut 而不是 iter

消耗迭代器的方法

Iterator trait 有许多不同的方法,标准库提供了默认实现;你可以通过查看标准库 API 文档中的 Iterator trait 来了解这些方法。其中一些方法在其定义中调用了 next 方法,这就是为什么在实现 Iterator trait 时需要实现 next 方法。

调用 next 的方法被称为消耗适配器,因为调用它们会消耗迭代器。一个例子是 sum 方法,它获取迭代器的所有权并通过重复调用 next 来遍历项目,从而消耗迭代器。在遍历过程中,它将每个项目添加到一个运行总数中,并在迭代完成时返回总数。Listing 13-13 展示了一个使用 sum 方法的测试。

#[cfg(test)]
mod tests {
    #[test]
    fn iterator_sum() {
        let v1 = vec![1, 2, 3];

        let v1_iter = v1.iter();

        let total: i32 = v1_iter.sum();

        assert_eq!(total, 6);
    }
}

在调用 sum 之后,我们不允许再使用 v1_iter,因为 sum 获取了我们调用它的迭代器的所有权。

生成其他迭代器的方法

迭代器适配器是定义在 Iterator trait 上的方法,它们不会消耗迭代器。相反,它们通过改变原始迭代器的某些方面来生成不同的迭代器。

Listing 13-14 展示了调用迭代器适配器方法 map 的示例,该方法接受一个闭包,在遍历每个项目时调用该闭包。map 方法返回一个新的迭代器,生成修改后的项目。这里的闭包创建了一个新的迭代器,其中向量中的每个项目都将增加 1:

fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    v1.iter().map(|x| x + 1);
}

然而,这段代码会产生一个警告:

$ cargo run
   Compiling iterators v0.1.0 (file:///projects/iterators)
warning: unused `Map` that must be used
 --> src/main.rs:4:5
  |
4 |     v1.iter().map(|x| x + 1);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: iterators are lazy and do nothing unless consumed
  = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
  |
4 |     let _ = v1.iter().map(|x| x + 1);
  |     +++++++

warning: `iterators` (bin "iterators") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.47s
     Running `target/debug/iterators`

Listing 13-14 中的代码没有做任何事情;我们指定的闭包从未被调用。警告提醒我们原因:迭代器适配器是惰性的,我们需要在这里消耗迭代器。

为了修复这个警告并消耗迭代器,我们将使用 collect 方法,我们在第 12 章的 Listing 12-1 中使用 env::args 时使用过这个方法。该方法消耗迭代器并将结果值收集到一个集合数据类型中。

在 Listing 13-15 中,我们将从 map 调用返回的迭代器遍历的结果收集到一个向量中。这个向量最终将包含原始向量中的每个项目,增加 1。

fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();

    assert_eq!(v2, vec![2, 3, 4]);
}

因为 map 接受一个闭包,我们可以指定要对每个项目执行的任何操作。这是一个很好的例子,展示了闭包如何让你自定义某些行为,同时重用 Iterator trait 提供的迭代行为。

你可以将多个迭代器适配器调用链接在一起,以可读的方式执行复杂的操作。但由于所有迭代器都是惰性的,你必须调用其中一个消耗适配器方法才能从迭代器适配器调用中获得结果。

使用捕获环境的闭包

许多迭代器适配器接受闭包作为参数,通常我们指定为迭代器适配器参数的闭包将是捕获其环境的闭包。

在这个例子中,我们将使用接受闭包的 filter 方法。闭包从迭代器中获取一个项目并返回一个 bool。如果闭包返回 true,则该值将包含在 filter 生成的迭代中。如果闭包返回 false,则该值将不会被包含。

在 Listing 13-16 中,我们使用 filter 和一个捕获其环境中 shoe_size 变量的闭包来遍历 Shoe 结构实例的集合。它将只返回指定尺寸的鞋子。

#[derive(PartialEq, Debug)]
struct Shoe {
    size: u32,
    style: String,
}

fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
    shoes.into_iter().filter(|s| s.size == shoe_size).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn filters_by_size() {
        let shoes = vec![
            Shoe {
                size: 10,
                style: String::from("sneaker"),
            },
            Shoe {
                size: 13,
                style: String::from("sandal"),
            },
            Shoe {
                size: 10,
                style: String::from("boot"),
            },
        ];

        let in_my_size = shoes_in_size(shoes, 10);

        assert_eq!(
            in_my_size,
            vec![
                Shoe {
                    size: 10,
                    style: String::from("sneaker")
                },
                Shoe {
                    size: 10,
                    style: String::from("boot")
                },
            ]
        );
    }
}

shoes_in_size 函数获取一个鞋子向量和一个鞋子尺寸作为参数。它返回一个只包含指定尺寸鞋子的向量。

shoes_in_size 的函数体中,我们调用 into_iter 来创建一个获取向量所有权的迭代器。然后我们调用 filter 来将该迭代器适配为一个新的迭代器,该迭代器只包含闭包返回 true 的元素。

闭包从环境中捕获 shoe_size 参数,并将其与每只鞋子的尺寸进行比较,只保留指定尺寸的鞋子。最后,调用 collect 将适配后的迭代器返回的值收集到一个向量中,该向量由函数返回。

测试显示,当我们调用 shoes_in_size 时,我们只得到与我们指定尺寸相同的鞋子。

改进我们的 I/O 项目

通过了解迭代器的新知识,我们可以改进第 12 章中的 I/O 项目,使用迭代器使代码中的某些部分更加清晰和简洁。让我们看看迭代器如何改进我们对 Config::build 函数和 search 函数的实现。

使用迭代器移除 clone

在 Listing 12-6 中,我们添加了代码,该代码获取 String 值的切片并通过索引切片并克隆值来创建 Config 结构体的实例,从而使 Config 结构体拥有这些值。在 Listing 13-17 中,我们重现了 Listing 12-23 中的 Config::build 函数的实现。

use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

当时,我们说不用担心低效的 clone 调用,因为我们将来会移除它们。现在就是那个时候了!

我们需要在这里使用 clone,因为参数 args 中有一个包含 String 元素的切片,但 build 函数并不拥有 args。为了返回 Config 实例的所有权,我们必须克隆 Configqueryfile_path 字段中的值,以便 Config 实例可以拥有这些值。

通过我们对迭代器的新知识,我们可以将 build 函数改为获取迭代器的所有权作为参数,而不是借用切片。我们将使用迭代器功能,而不是检查切片长度并索引特定位置的代码。这将澄清 Config::build 函数的作用,因为迭代器将访问这些值。

一旦 Config::build 获取了迭代器的所有权并停止使用借用的索引操作,我们就可以将迭代器中的 String 值移动到 Config 中,而不是调用 clone 并进行新的分配。

直接使用返回的迭代器

打开你的 I/O 项目的 src/main.rs 文件,它应该如下所示:

文件名: src/main.rs

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

我们首先将 Listing 12-24 中的 main 函数的开头改为 Listing 13-18 中的代码,这次使用迭代器。在我们更新 Config::build 之前,这不会编译。

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

env::args 函数返回一个迭代器!与其将迭代器值收集到一个向量中,然后将切片传递给 Config::build,现在我们直接将 env::args 返回的迭代器的所有权传递给 Config::build

接下来,我们需要更新 Config::build 的定义。在你的 I/O 项目的 src/lib.rs 文件中,让我们将 Config::build 的签名改为如 Listing 13-19 所示。这仍然不会编译,因为我们需要更新函数体。

use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        // --snip--
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

env::args 函数的标准库文档显示,它返回的迭代器类型是 std::env::Args,并且该类型实现了 Iterator trait 并返回 String 值。

我们更新了 Config::build 函数的签名,使参数 args 具有一个泛型类型,其 trait 约束为 impl Iterator<Item = String>,而不是 &[String]。我们在第 10 章的 “Traits as Parameters” 部分讨论的 impl Trait 语法的这种用法意味着 args 可以是任何实现 Iterator trait 并返回 String 项的类型。

因为我们正在获取 args 的所有权,并且我们将通过迭代它来改变 args,所以我们可以在 args 参数的规范中添加 mut 关键字以使其可变。

使用 Iterator Trait 方法代替索引

接下来,我们将修复 Config::build 的函数体。因为 args 实现了 Iterator trait,我们知道我们可以调用它的 next 方法!Listing 13-20 更新了 Listing 12-23 中的代码以使用 next 方法。

use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a query string"),
        };

        let file_path = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file path"),
        };

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

记住,env::args 返回值的第一个值是程序的名称。我们希望忽略它并获取下一个值,所以我们首先调用 next 并不处理返回值。然后我们调用 next 来获取我们想要放入 Configquery 字段的值。如果 next 返回 Some,我们使用 match 提取值。如果它返回 None,这意味着没有提供足够的参数,我们提前返回一个 Err 值。我们对 file_path 值做同样的事情。

使用迭代器适配器使代码更清晰

我们还可以在我们的 I/O 项目中的 search 函数中利用迭代器,该函数在 Listing 13-21 中重现,如 Listing 12-19 所示:

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

我们可以使用迭代器适配器方法以更简洁的方式编写此代码。这样做还可以避免使用可变的中间 results 向量。函数式编程风格倾向于最小化可变状态的数量,以使代码更清晰。移除可变状态可能会使未来的增强功能更容易实现,例如并行搜索,因为我们不必管理对 results 向量的并发访问。Listing 13-22 显示了这一变化:

use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a query string"),
        };

        let file_path = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file path"),
        };

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    contents
        .lines()
        .filter(|line| line.contains(query))
        .collect()
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

回想一下,search 函数的目的是返回 contents 中包含 query 的所有行。与 Listing 13-16 中的 filter 示例类似,此代码使用 filter 适配器仅保留 line.contains(query) 返回 true 的行。然后我们使用 collect 将匹配的行收集到另一个向量中。简单多了!你也可以在 search_case_insensitive 函数中做出相同的更改以使用迭代器方法。

在循环或迭代器之间选择

下一个逻辑问题是,你应该在自己的代码中选择哪种风格以及为什么:Listing 13-21 中的原始实现还是 Listing 13-22 中使用迭代器的版本。大多数 Rust 程序员更喜欢使用迭代器风格。起初有点难以掌握,但一旦你熟悉了各种迭代器适配器及其功能,迭代器可能会更容易理解。与其摆弄各种循环和构建新向量的细节,代码更关注循环的高级目标。这抽象出了一些常见的代码,因此更容易看到此代码特有的概念,例如迭代器中每个元素必须通过的过滤条件。

但这两个实现真的等价吗?直观的假设可能是低级循环会更快。让我们谈谈性能。

性能比较:循环 vs 迭代器

为了确定是使用循环还是迭代器,你需要知道哪种实现更快:带有显式 for 循环的 search 函数版本,还是使用迭代器的版本。

我们运行了一个基准测试,将阿瑟·柯南·道尔爵士的《福尔摩斯探案集》的全文加载到一个 String 中,并在内容中查找单词 the。以下是使用 for 循环的 search 版本和使用迭代器的版本的基准测试结果:

test bench_search_for  ... bench:  19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench:  19,234,900 ns/iter (+/- 657,200)

这两个实现的性能非常接近!我们不会在这里解释基准测试代码,因为重点不是证明这两个版本是等价的,而是为了大致了解这两个实现在性能上的比较。

为了进行更全面的基准测试,你应该使用不同大小的文本作为 contents,不同的单词和不同长度的单词作为 query,以及各种其他变体。重点是:迭代器虽然是一种高级抽象,但编译后的代码与你自己编写的低级代码大致相同。迭代器是 Rust 的零成本抽象之一,这意味着使用这种抽象不会带来额外的运行时开销。这与 C++ 的原始设计者和实现者 Bjarne Stroustrup 在《C++ 基础》(2012)中定义的零开销类似:

一般来说,C++ 实现遵循零开销原则:你不使用的东西,你不需要为之付出代价。更进一步:你使用的东西,你无法手工编写出更好的代码。

再举一个例子,以下代码取自一个音频解码器。解码算法使用线性预测数学操作,基于先前样本的线性函数来估计未来的值。这段代码使用了一个迭代器链来对作用域中的三个变量进行数学运算:一个数据 buffer 切片,一个包含 12 个 coefficients 的数组,以及一个用于在 qlp_shift 中移动数据的量。我们在这个例子中声明了这些变量,但没有给它们赋值;尽管这段代码在其上下文之外没有太多意义,但它仍然是一个简洁、真实的例子,展示了 Rust 如何将高级思想转换为低级代码。

let buffer: &mut [i32];
let coefficients: [i64; 12];
let qlp_shift: i16;

for i in 12..buffer.len() {
    let prediction = coefficients.iter()
                                 .zip(&buffer[i - 12..i])
                                 .map(|(&c, &s)| c * s as i64)
                                 .sum::<i64>() >> qlp_shift;
    let delta = buffer[i];
    buffer[i] = prediction as i32 + delta;
}

为了计算 prediction 的值,这段代码遍历了 coefficients 中的 12 个值,并使用 zip 方法将系数值与 buffer 中的前 12 个值配对。然后,对于每一对值,我们将它们相乘,将所有结果相加,并将总和向右移动 qlp_shift 位。

像音频解码器这样的应用程序通常最优先考虑性能。在这里,我们创建了一个迭代器,使用了两个适配器,然后消费了这个值。这段 Rust 代码会编译成什么样的汇编代码呢?截至目前,它编译后的汇编代码与你手工编写的汇编代码相同。完全没有对应于 coefficients 值迭代的循环:Rust 知道有 12 次迭代,因此它“展开”了循环。展开是一种优化,它移除了循环控制代码的开销,而是为循环的每次迭代生成重复的代码。

所有的系数都存储在寄存器中,这意味着访问这些值非常快。在运行时没有数组访问的边界检查。Rust 能够应用所有这些优化,使得生成的代码极其高效。现在你知道了这一点,你可以放心地使用迭代器和闭包!它们使代码看起来更高级,但不会因此带来运行时性能的损失。

总结

闭包和迭代器是受函数式编程语言思想启发的 Rust 特性。它们有助于 Rust 在保持低级性能的同时清晰地表达高级思想。闭包和迭代器的实现方式使得运行时性能不受影响。这是 Rust 努力提供零成本抽象的目标的一部分。

现在我们已经提高了 I/O 项目的表达能力,让我们来看看 cargo 的更多功能,这些功能将帮助我们与世界分享这个项目。

更多关于 Cargo 和 Crates.io 的内容

到目前为止,我们只使用了 Cargo 最基本的功能来构建、运行和测试我们的代码,但它还能做更多事情。在本章中,我们将讨论一些更高级的功能,向你展示如何做到以下几点:

  • 通过发布配置文件自定义构建
  • crates.io 上发布库
  • 使用工作区组织大型项目
  • crates.io 安装二进制文件
  • 使用自定义命令扩展 Cargo

Cargo 的功能远不止我们在本章中介绍的内容,因此要了解其所有功能的完整说明,请参阅 其文档

使用发布配置文件自定义构建

在 Rust 中,发布配置文件 是预定义且可自定义的配置文件,它们具有不同的配置,允许程序员对编译代码的各种选项进行更多控制。每个配置文件都是独立配置的。

Cargo 有两个主要的配置文件:dev 配置文件,当您运行 cargo build 时使用;release 配置文件,当您运行 cargo build --release 时使用。dev 配置文件为开发环境提供了良好的默认设置,而 release 配置文件则为发布构建提供了良好的默认设置。

这些配置文件的名称可能从构建输出中看起来很熟悉:

$ cargo build
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
$ cargo build --release
    Finished `release` profile [optimized] target(s) in 0.32s

devrelease 是编译器使用的不同配置文件。

Cargo 为每个配置文件提供了默认设置,当您在项目的 Cargo.toml 文件中没有显式添加任何 [profile.*] 部分时,这些默认设置将生效。通过为您想要自定义的任何配置文件添加 [profile.*] 部分,您可以覆盖默认设置的任何子集。例如,以下是 devrelease 配置文件的 opt-level 设置的默认值:

文件名: Cargo.toml

[profile.dev]
opt-level = 0

[profile.release]
opt-level = 3

opt-level 设置控制 Rust 对代码应用的优化级别,范围从 0 到 3。应用更多的优化会延长编译时间,因此如果您在开发过程中经常编译代码,您可能希望减少优化以加快编译速度,即使生成的代码运行速度较慢。因此,dev 的默认 opt-level0。当您准备发布代码时,最好花更多时间进行编译。您只需在发布模式下编译一次,但会多次运行编译后的程序,因此发布模式以更长的编译时间换取运行更快的代码。这就是为什么 release 配置文件的默认 opt-level3

您可以通过在 Cargo.toml 中添加不同的值来覆盖默认设置。例如,如果我们希望在开发配置文件中使用优化级别 1,我们可以将以下两行添加到项目的 Cargo.toml 文件中:

文件名: Cargo.toml

[profile.dev]
opt-level = 1

这段代码覆盖了默认的 0 设置。现在当我们运行 cargo build 时,Cargo 将使用 dev 配置文件的默认设置以及我们对 opt-level 的自定义设置。因为我们已将 opt-level 设置为 1,Cargo 将应用比默认更多的优化,但不会像发布构建那样多。

有关每个配置文件的完整配置选项和默认值的列表,请参阅 Cargo 的文档

发布 Crate 到 Crates.io

我们已经使用过 crates.io 上的包作为项目的依赖,但你也可以通过发布自己的包来与他人分享代码。crates.io 上的 crate 注册表会分发你包的源代码,因此它主要托管开源的代码。

Rust 和 Cargo 有一些功能可以让你的发布包更容易被他人找到和使用。接下来我们将讨论其中的一些功能,然后解释如何发布一个包。

编写有用的文档注释

准确地记录你的包将帮助其他用户了解如何以及何时使用它们,因此值得花时间编写文档。在第 3 章中,我们讨论了如何使用两个斜杠 // 来注释 Rust 代码。Rust 还有一种特殊的注释用于文档,称为文档注释,它会生成 HTML 文档。HTML 会显示文档注释的内容,这些内容是为那些想要了解如何使用你的 crate 而不是如何实现它的程序员准备的。

文档注释使用三个斜杠 /// 而不是两个,并支持 Markdown 语法来格式化文本。将文档注释放在它们所描述的项之前。Listing 14-1 展示了一个名为 my_crate 的 crate 中 add_one 函数的文档注释。

/// Adds one to the number given.
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}

在这里,我们描述了 add_one 函数的功能,开始了一个标题为 Examples 的部分,然后提供了演示如何使用 add_one 函数的代码。我们可以通过运行 cargo doc 从这个文档注释生成 HTML 文档。这个命令会运行 Rust 自带的 rustdoc 工具,并将生成的 HTML 文档放在 target/doc 目录中。

为了方便,运行 cargo doc --open 会为你当前的 crate 生成 HTML 文档(以及所有依赖项的文档),并在浏览器中打开结果。导航到 add_one 函数,你会看到文档注释中的文本是如何渲染的,如图 14-1 所示:

Rendered HTML documentation for the `add_one` function of `my_crate`

图 14-1: add_one 函数的 HTML 文档

常用的部分

我们在 Listing 14-1 中使用了 # Examples Markdown 标题来创建一个标题为 “Examples” 的 HTML 部分。以下是一些 crate 作者在文档中常用的其他部分:

  • Panics:函数可能会 panic 的场景。不希望程序 panic 的调用者应确保不会在这些情况下调用该函数。
  • Errors:如果函数返回 Result,描述可能发生的错误类型以及可能导致这些错误返回的条件对调用者有帮助,以便他们可以编写代码以不同方式处理不同类型的错误。
  • Safety:如果函数调用是 unsafe 的(我们将在第 20 章讨论不安全代码),应该有一个部分解释为什么函数是不安全的,并涵盖函数期望调用者维护的不变量。

大多数文档注释不需要所有这些部分,但这是一个很好的清单,提醒你用户会感兴趣的代码方面。

文档注释作为测试

在文档注释中添加示例代码块可以帮助演示如何使用你的库,这样做还有一个额外的好处:运行 cargo test 会将文档中的代码示例作为测试运行!没有什么比带有示例的文档更好的了。但没有什么比文档中的示例因为代码更改而无法工作更糟糕的了。如果我们使用 Listing 14-1 中的 add_one 函数的文档运行 cargo test,我们将在测试结果中看到一个如下所示的部分:

   Doc-tests my_crate

running 1 test
test src/lib.rs - add_one (line 5) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s

现在,如果我们更改函数或示例,使得示例中的 assert_eq! panic,然后再次运行 cargo test,我们会看到文档测试捕捉到了示例和代码不同步的情况!

注释包含的项

文档注释 //! 的风格将文档添加到包含注释的项,而不是注释后面的项。我们通常将这些文档注释放在 crate 根文件(通常是 src/lib.rs)或模块内部,以记录 crate 或模块的整体。

例如,要为包含 add_one 函数的 my_crate crate 添加描述其用途的文档,我们在 src/lib.rs 文件的开头添加以 //! 开头的文档注释,如 Listing 14-2 所示:

//! # My Crate
//!
//! `my_crate` is a collection of utilities to make performing certain
//! calculations more convenient.

/// Adds one to the number given.
// --snip--
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}

注意,在最后一行以 //! 开头的注释之后没有任何代码。因为我们以 //! 而不是 /// 开始注释,所以我们是在记录包含此注释的项,而不是注释后面的项。在这种情况下,该项是 src/lib.rs 文件,即 crate 根。这些注释描述了整个 crate。

当我们运行 cargo doc --open 时,这些注释将显示在 my_crate 文档的首页,位于 crate 中公共项的列表上方,如图 14-2 所示。

Rendered HTML documentation with a comment for the crate as a whole

图 14-2: my_crate 的渲染文档,包括描述 crate 整体的注释

项内的文档注释对于描述 crate 和模块特别有用。使用它们来解释容器的整体目的,以帮助用户理解 crate 的组织结构。

使用 pub use 导出方便的公共 API

发布 crate 时,公共 API 的结构是一个重要的考虑因素。使用你的 crate 的人对结构的熟悉程度不如你,如果你的 crate 有一个大的模块层次结构,他们可能会很难找到他们想要使用的部分。

在第 7 章中,我们介绍了如何使用 pub 关键字使项公开,并使用 use 关键字将项引入作用域。然而,你在开发 crate 时认为合理的结构可能对你的用户来说并不方便。你可能希望将结构组织成包含多个层次的层次结构,但想要使用你定义在层次结构深处的类型的人可能会很难发现该类型的存在。他们也可能对必须输入 use my_crate::some_module::another_module::UsefulType; 而不是 use my_crate::UsefulType; 感到烦恼。

好消息是,如果结构对其他人来说不方便使用,你不必重新安排内部组织:相反,你可以使用 pub use 重新导出项,以创建一个与私有结构不同的公共结构。重新导出将一个位置的公共项在另一个位置公开,就好像它是在另一个位置定义的一样。

例如,假设我们创建了一个名为 art 的库来建模艺术概念。在这个库中有两个模块:一个 kinds 模块包含两个枚举 PrimaryColorSecondaryColor,一个 utils 模块包含一个名为 mix 的函数,如 Listing 14-3 所示:

//! # Art
//!
//! A library for modeling artistic concepts.

pub mod kinds {
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    use crate::kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        // --snip--
        unimplemented!();
    }
}

图 14-3 显示了由 cargo doc 生成的此 crate 文档的首页:

Rendered documentation for the `art` crate that lists the `kinds` and `utils` modules

图 14-3: art crate 文档的首页,列出了 kindsutils 模块

请注意,PrimaryColorSecondaryColor 类型没有列在首页,mix 函数也没有。我们必须点击 kindsutils 才能看到它们。

另一个依赖此库的 crate 需要使用 use 语句将 art 中的项引入作用域,指定当前定义的模块结构。Listing 14-4 展示了一个使用 art crate 中的 PrimaryColormix 项的 crate 示例:

use art::kinds::PrimaryColor;
use art::utils::mix;

fn main() {
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}

Listing 14-4 中使用 art crate 的代码作者必须弄清楚 PrimaryColorkinds 模块中,而 mixutils 模块中。art crate 的模块结构对开发 art crate 的开发人员更相关,而不是使用它的人。内部结构不包含任何对试图理解如何使用 art crate 的人有用的信息,反而会引起混淆,因为使用它的开发人员必须弄清楚在哪里查找,并且必须在 use 语句中指定模块名称。

为了从公共 API 中移除内部组织,我们可以修改 Listing 14-3 中的 art crate 代码,添加 pub use 语句以在顶层重新导出项,如 Listing 14-5 所示:

//! # Art
//!
//! A library for modeling artistic concepts.

pub use self::kinds::PrimaryColor;
pub use self::kinds::SecondaryColor;
pub use self::utils::mix;

pub mod kinds {
    // --snip--
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    // --snip--
    use crate::kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        SecondaryColor::Orange
    }
}

cargo doc 为此 crate 生成的 API 文档现在将在首页列出并链接重新导出的项,如图 14-4 所示,使得 PrimaryColorSecondaryColor 类型以及 mix 函数更容易找到。

Rendered documentation for the `art` crate with the re-exports on the front page

图 14-4: art crate 文档的首页,列出了重新导出的项

art crate 的用户仍然可以看到并使用 Listing 14-3 中的内部结构,如 Listing 14-4 所示,或者他们可以使用 Listing 14-5 中更方便的结构,如 Listing 14-6 所示:

use art::PrimaryColor;
use art::mix;

fn main() {
    // --snip--
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}

在有许多嵌套模块的情况下,使用 pub use 在顶层重新导出类型可以显著改善使用 crate 的人的体验。pub use 的另一个常见用途是重新导出当前 crate 中依赖项的定义,以使该 crate 的定义成为你的 crate 公共 API 的一部分。

创建一个有用的公共 API 结构更像是一门艺术而不是科学,你可以通过迭代找到最适合用户的 API。选择 pub use 使你在如何内部组织 crate 方面具有灵活性,并将内部结构与呈现给用户的内容解耦。查看你安装的一些 crate 的代码,看看它们的内部结构是否与公共 API 不同。

设置 Crates.io 账户

在发布任何 crate 之前,你需要在 crates.io 上创建一个账户并获取一个 API 令牌。为此,访问 crates.io 的主页并通过 GitHub 账户登录。(目前 GitHub 账户是必需的,但未来该网站可能会支持其他创建账户的方式。)登录后,访问你的账户设置页面 https://crates.io/me/ 并获取你的 API 密钥。然后运行 cargo login 命令并在提示时粘贴你的 API 密钥,如下所示:

$ cargo login
abcdefghijklmnopqrstuvwxyz012345

此命令将通知 Cargo 你的 API 令牌并将其本地存储在 ~/.cargo/credentials 中。请注意,此令牌是秘密:不要与任何人分享。如果你因任何原因与任何人分享了它,你应该撤销它并在 crates.io 上生成一个新的令牌。

为新 Crate 添加元数据

假设你有一个想要发布的 crate。在发布之前,你需要在 crate 的 Cargo.toml 文件的 [package] 部分添加一些元数据。

你的 crate 需要一个唯一的名称。当你在本地开发 crate 时,你可以随意命名 crate。然而,crates.io 上的 crate 名称是按先到先得的原则分配的。一旦一个 crate 名称被占用,其他人就不能再发布同名的 crate。在尝试发布 crate 之前,搜索你想要使用的名称。如果该名称已被使用,你需要找到另一个名称并编辑 Cargo.toml 文件中 [package] 部分下的 name 字段以使用新名称进行发布,如下所示:

文件名: Cargo.toml

[package]
name = "guessing_game"

即使你选择了一个唯一的名称,当你运行 cargo publish 发布 crate 时,你会收到一个警告,然后是一个错误:

$ cargo publish
    Updating crates.io index
warning: manifest has no description, license, license-file, documentation, homepage or repository.
See https://doc.rust-lang.org/cargo/reference/manifest.html#package-metadata for more info.
--snip--
error: failed to publish to registry at https://crates.io

Caused by:
  the remote server responded with an error (status 400 Bad Request): missing or empty metadata fields: description, license. Please see https://doc.rust-lang.org/cargo/reference/manifest.html for more information on configuring these fields

这是因为你缺少一些关键信息:描述和许可证是必需的,以便人们知道你的 crate 是做什么的以及他们可以在什么条款下使用它。在 Cargo.toml 中,添加一个简短的描述,因为它会出现在搜索结果中。对于 license 字段,你需要提供一个许可证标识符值Linux 基金会的软件包数据交换 (SPDX) 列出了你可以用于此值的标识符。例如,要指定你使用 MIT 许可证发布你的 crate,添加 MIT 标识符:

文件名: Cargo.toml

[package]
name = "guessing_game"
license = "MIT"

如果你想使用 SPDX 中未列出的许可证,你需要将该许可证的文本放在一个文件中,将该文件包含在你的项目中,然后使用 license-file 指定该文件的名称,而不是使用 license 键。

关于哪种许可证适合你的项目的指导超出了本书的范围。许多 Rust 社区成员通过使用 MIT OR Apache-2.0 的双重许可证来发布他们的项目,与 Rust 相同。这种做法表明你也可以通过用 OR 分隔多个许可证标识符来为你的项目指定多个许可证。

有了唯一的名称、版本、描述和许可证后,准备发布的项目的 Cargo.toml 文件可能如下所示:

文件名: Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2024"
description = "A fun game where you guess what number the computer has chosen."
license = "MIT OR Apache-2.0"

[dependencies]

Cargo 的文档 描述了你可以指定的其他元数据,以确保其他人可以更容易地发现和使用你的 crate。

发布到 Crates.io

现在你已经创建了账户,保存了 API 令牌,选择了 crate 的名称,并指定了所需的元数据,你已经准备好发布了!发布 crate 会将特定版本上传到 crates.io 供他人使用。

要小心,因为发布是永久的。版本永远不能被覆盖,代码也不能被删除。crates.io 的一个主要目标是作为代码的永久存档,以便所有依赖 crates.io 上的 crate 的项目构建都能继续工作。允许删除版本将使实现这一目标变得不可能。然而,你可以发布的 crate 版本数量没有限制。

再次运行 cargo publish 命令。现在它应该会成功:

$ cargo publish
    Updating crates.io index
   Packaging guessing_game v0.1.0 (file:///projects/guessing_game)
   Verifying guessing_game v0.1.0 (file:///projects/guessing_game)
   Compiling guessing_game v0.1.0
(file:///projects/guessing_game/target/package/guessing_game-0.1.0)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.19s
   Uploading guessing_game v0.1.0 (file:///projects/guessing_game)

恭喜!你现在已经与 Rust 社区分享了你的代码,任何人都可以轻松地将你的 crate 添加为他们项目的依赖项。

发布现有 Crate 的新版本

当你对 crate 进行了更改并准备发布新版本时,你可以在 Cargo.toml 文件中更改 version 值并重新发布。使用 语义版本控制规则 根据你所做的更改类型决定下一个版本号。然后运行 cargo publish 上传新版本。

使用 cargo yank 从 Crates.io 弃用版本

虽然你不能删除以前版本的 crate,但你可以防止任何未来的项目将它们添加为新的依赖项。这在某个 crate 版本因某种原因损坏时非常有用。在这种情况下,Cargo 支持弃用 crate 版本。

弃用一个版本会阻止新项目依赖该版本,同时允许所有现有依赖它的项目继续使用。本质上,弃用意味着所有具有 Cargo.lock 的项目不会中断,并且任何未来生成的 Cargo.lock 文件都不会使用弃用的版本。

要弃用 crate 的一个版本,在你之前发布的 crate 的目录中运行 cargo yank 并指定你想要弃用的版本。例如,如果我们发布了一个名为 guessing_game 的 crate 版本 1.0.1 并想要弃用它,在 guessing_game 的项目目录中运行:

$ cargo yank --vers 1.0.1
    Updating crates.io index
        Yank guessing_game@1.0.1

通过在命令中添加 --undo,你还可以取消弃用并允许项目再次依赖某个版本:

$ cargo yank --vers 1.0.1 --undo
    Updating crates.io index
      Unyank guessing_game@1.0.1

弃用不会删除任何代码。例如,它不能删除意外上传的密钥。如果发生这种情况,你必须立即重置这些密钥。

Cargo 工作空间

在第 12 章中,我们构建了一个包含二进制 crate 和库 crate 的包。随着项目的发展,你可能会发现库 crate 变得越来越大,并且你希望将包进一步拆分为多个库 crate。Cargo 提供了一个称为 工作空间 的功能,可以帮助管理多个相关的包,这些包是同时开发的。

创建工作空间

工作空间 是一组共享同一个 Cargo.lock 和输出目录的包。让我们使用工作空间来创建一个项目——我们将使用简单的代码,以便我们可以专注于工作空间的结构。有多种方式来构建工作空间,因此我们只展示一种常见的方式。我们将创建一个包含一个二进制 crate 和两个库 crate 的工作空间。二进制 crate 将提供主要功能,并依赖于这两个库 crate。一个库将提供一个 add_one 函数,另一个库将提供一个 add_two 函数。这三个 crate 将属于同一个工作空间。我们首先为工作空间创建一个新目录:

$ mkdir add
$ cd add

接下来,在 add 目录中,我们创建 Cargo.toml 文件,该文件将配置整个工作空间。这个文件不会包含 [package] 部分。相反,它将从 [workspace] 部分开始,该部分允许我们向工作空间添加成员。我们还通过将 resolver 设置为 "3",确保在工作空间中使用最新版本的 Cargo 解析器算法。

文件名: Cargo.toml

[workspace]
resolver = "3"

接下来,我们将在 add 目录中通过运行 cargo new 来创建 adder 二进制 crate:

$ cargo new adder
    Creating binary (application) `adder` package
      Adding `adder` as member of workspace at `file:///projects/add`

在工作空间内运行 cargo new 还会自动将新创建的包添加到工作空间 Cargo.toml 中的 [workspace] 定义的 members 键中,如下所示:

[workspace]
resolver = "3"
members = ["adder"]

此时,我们可以通过运行 cargo build 来构建工作空间。你的 add 目录中的文件应该如下所示:

├── Cargo.lock
├── Cargo.toml
├── adder
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

工作空间在顶层有一个 target 目录,编译后的工件将放置在其中;adder 包没有自己的 target 目录。即使我们从 adder 目录内运行 cargo build,编译后的工件仍然会放在 add/target 中,而不是 add/adder/target。Cargo 这样构建工作空间的 target 目录是因为工作空间中的 crate 是相互依赖的。如果每个 crate 都有自己的 target 目录,每个 crate 将不得不重新编译工作空间中的其他 crate,以便将工件放在自己的 target 目录中。通过共享一个 target 目录,crate 可以避免不必要的重新构建。

在工作空间中创建第二个包

接下来,让我们在工作空间中创建另一个成员包,并将其命名为 add_one。生成一个名为 add_one 的新库 crate:

$ cargo new add_one --lib
    Creating library `add_one` package
      Adding `add_one` as member of workspace at `file:///projects/add`

顶层的 Cargo.toml 现在将在 members 列表中包含 add_one 路径:

文件名: Cargo.toml

[workspace]
resolver = "3"
members = ["adder", "add_one"]

你的 add 目录现在应该包含以下目录和文件:

├── Cargo.lock
├── Cargo.toml
├── add_one
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── adder
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

add_one/src/lib.rs 文件中,让我们添加一个 add_one 函数:

文件名: add_one/src/lib.rs

pub fn add_one(x: i32) -> i32 {
    x + 1
}

现在我们可以让 adder 包中的二进制文件依赖于包含我们库的 add_one 包。首先,我们需要在 adder/Cargo.toml 中添加对 add_one 的路径依赖。

文件名: adder/Cargo.toml

[dependencies]
add_one = { path = "../add_one" }

Cargo 不会假设工作空间中的 crate 会相互依赖,因此我们需要明确依赖关系。

接下来,让我们在 adder crate 中使用 add_one 函数(来自 add_one crate)。打开 adder/src/main.rs 文件,并将 main 函数修改为调用 add_one 函数,如代码清单 14-7 所示。

fn main() {
    let num = 10;
    println!("Hello, world! {num} plus one is {}!", add_one::add_one(num));
}

让我们通过在顶层的 add 目录中运行 cargo build 来构建工作空间!

$ cargo build
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.22s

要从 add 目录运行二进制 crate,我们可以使用 -p 参数和包名称来指定要运行的工作空间中的包:

$ cargo run -p adder
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/adder`
Hello, world! 10 plus one is 11!

这将运行 adder/src/main.rs 中的代码,该代码依赖于 add_one crate。

在工作空间中依赖外部包

请注意,工作空间在顶层只有一个 Cargo.lock 文件,而不是在每个 crate 的目录中都有一个 Cargo.lock。这确保了所有 crate 都使用相同版本的所有依赖项。如果我们将 rand 包添加到 adder/Cargo.tomladd_one/Cargo.toml 文件中,Cargo 会将这两个文件解析为 rand 的一个版本,并将其记录在唯一的 Cargo.lock 中。使工作空间中的所有 crate 使用相同的依赖项意味着这些 crate 将始终相互兼容。让我们将 rand crate 添加到 add_one/Cargo.toml 文件的 [dependencies] 部分,以便我们可以在 add_one crate 中使用 rand crate:

文件名: add_one/Cargo.toml

[dependencies]
rand = "0.8.5"

我们现在可以将 use rand; 添加到 add_one/src/lib.rs 文件中,并通过在 add 目录中运行 cargo build 来构建整个工作空间,这将引入并编译 rand crate。我们会收到一个警告,因为我们没有引用我们引入作用域的 rand

$ cargo build
    Updating crates.io index
  Downloaded rand v0.8.5
   --snip--
   Compiling rand v0.8.5
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
warning: unused import: `rand`
 --> add_one/src/lib.rs:1:5
  |
1 | use rand;
  |     ^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

warning: `add_one` (lib) generated 1 warning (run `cargo fix --lib -p add_one` to apply 1 suggestion)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s

顶层的 Cargo.lock 现在包含 add_onerand 的依赖信息。然而,即使 rand 在工作空间的某个地方被使用,我们也不能在工作空间的其他 crate 中使用它,除非我们也将 rand 添加到它们的 Cargo.toml 文件中。例如,如果我们将 use rand; 添加到 adder/src/main.rs 文件中,我们会得到一个错误:

$ cargo build
  --snip--
   Compiling adder v0.1.0 (file:///projects/add/adder)
error[E0432]: unresolved import `rand`
 --> adder/src/main.rs:2:5
  |
2 | use rand;
  |     ^^^^ no external crate `rand`

要解决这个问题,编辑 adder 包的 Cargo.toml 文件,并指示 rand 也是它的依赖项。构建 adder 包将把 rand 添加到 Cargo.lockadder 的依赖项列表中,但不会下载额外的 rand 副本。Cargo 将确保工作空间中使用 rand 包的每个包中的每个 crate 都使用相同的版本,只要它们指定了兼容的 rand 版本,从而节省空间并确保工作空间中的 crate 相互兼容。

如果工作空间中的 crate 指定了相同依赖项的不兼容版本,Cargo 将解析每个版本,但仍会尝试解析尽可能少的版本。

向工作空间添加测试

作为另一个增强功能,让我们在 add_one crate 中添加一个 add_one::add_one 函数的测试:

文件名: add_one/src/lib.rs

pub fn add_one(x: i32) -> i32 {
    x + 1
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        assert_eq!(3, add_one(2));
    }
}

现在在顶层的 add 目录中运行 cargo test。在像这样结构的工作空间中运行 cargo test 将运行工作空间中所有 crate 的测试:

$ cargo test
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.20s
     Running unittests src/lib.rs (target/debug/deps/add_one-93c49ee75dc46543)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/adder-3a47283c568d2b6a)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests add_one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

输出的第一部分显示 add_one crate 中的 it_works 测试通过了。下一部分显示在 adder crate 中找到了零个测试,最后一部分显示在 add_one crate 中找到了零个文档测试。

我们还可以通过使用 -p 标志并指定我们要测试的 crate 名称,从顶层目录运行工作空间中特定 crate 的测试:

$ cargo test -p add_one
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running unittests src/lib.rs (target/debug/deps/add_one-93c49ee75dc46543)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests add_one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

此输出显示 cargo test 只运行了 add_one crate 的测试,而没有运行 adder crate 的测试。

如果你将工作空间中的 crate 发布到 crates.io,工作空间中的每个 crate 都需要单独发布。与 cargo test 类似,我们可以通过使用 -p 标志并指定要发布的 crate 名称来发布工作空间中的特定 crate。

作为额外的练习,以类似于 add_one crate 的方式向此工作空间添加一个 add_two crate!

随着项目的增长,考虑使用工作空间:它使你能够处理更小、更易于理解的组件,而不是一大块代码。此外,如果 crate 经常同时更改,将它们保持在工作空间中可以使 crate 之间的协调更容易。

使用 cargo install 安装二进制文件

cargo install 命令允许你在本地安装和使用二进制 crate。这并不是为了取代系统包管理器;它的目的是为 Rust 开发者提供一种便捷的方式来安装其他人在 crates.io 上分享的工具。需要注意的是,你只能安装具有二进制目标的包。二进制目标 是指如果 crate 包含 src/main.rs 文件或指定为二进制的其他文件时创建的可运行程序,与之相对的是库目标,库目标本身不可运行,但适合包含在其他程序中。通常,crate 的 README 文件中会包含有关 crate 是库、具有二进制目标还是两者兼有的信息。

所有通过 cargo install 安装的二进制文件都存储在安装根目录的 bin 文件夹中。如果你使用 rustup.rs 安装了 Rust 并且没有进行任何自定义配置,那么这个目录将是 $HOME/.cargo/bin。确保该目录在你的 $PATH 中,以便能够运行通过 cargo install 安装的程序。

例如,在第 12 章中我们提到有一个名为 ripgrep 的 Rust 实现的 grep 工具,用于搜索文件。要安装 ripgrep,我们可以运行以下命令:

$ cargo install ripgrep
    Updating crates.io index
  Downloaded ripgrep v14.1.1
  Downloaded 1 crate (213.6 KB) in 0.40s
  Installing ripgrep v14.1.1
--snip--
   Compiling grep v0.3.2
    Finished `release` profile [optimized + debuginfo] target(s) in 6.73s
  Installing ~/.cargo/bin/rg
   Installed package `ripgrep v14.1.1` (executable `rg`)

输出的倒数第二行显示了安装的二进制文件的位置和名称,对于 ripgrep 来说,这个名称是 rg。只要安装目录在你的 $PATH 中,如前所述,你就可以运行 rg --help 并开始使用一个更快、更 Rust 化的工具来搜索文件!

使用自定义命令扩展 Cargo

Cargo 的设计允许你通过添加新的子命令来扩展它,而无需修改 Cargo 本身。如果你的 $PATH 中有一个名为 cargo-something 的二进制文件,你可以通过运行 cargo something 来像运行 Cargo 子命令一样运行它。这样的自定义命令也会在你运行 cargo --list 时列出。能够使用 cargo install 安装扩展并像内置的 Cargo 工具一样运行它们,是 Cargo 设计的一个非常方便的好处!

总结

通过 Cargo 和 crates.io 共享代码是使 Rust 生态系统适用于许多不同任务的一部分。Rust 的标准库小而稳定,但 crate 易于共享、使用和改进,并且它们的时间线与语言的时间线不同。不要害羞,将对你来说有用的代码分享到 crates.io 上;很可能它对其他人也有用!

智能指针

指针 是一个通用概念,指的是一个包含内存地址的变量。这个地址指向或“引用”其他数据。在 Rust 中,最常见的指针类型是引用,你在第 4 章中已经学习过。引用由 & 符号表示,并借用它们所指向的值。除了引用数据外,它们没有任何特殊功能,也没有额外的开销。

智能指针 则是一种数据结构,它们不仅像指针一样工作,还拥有额外的元数据和功能。智能指针的概念并不是 Rust 独有的:智能指针起源于 C++,并且也存在于其他语言中。Rust 标准库中定义了多种智能指针,它们提供了引用所不具备的功能。为了探索这一通用概念,我们将看几个不同的智能指针示例,包括一个_引用计数_智能指针类型。这种指针通过跟踪所有者的数量,使得数据可以有多个所有者,并在没有所有者时清理数据。

Rust 的所有权和借用概念使得引用和智能指针之间有一个额外的区别:引用只是借用数据,而在许多情况下,智能指针_拥有_它们所指向的数据。

尽管我们在之前没有这样称呼它们,但我们在本书中已经遇到过一些智能指针,包括第 8 章中的 StringVec<T>。这两种类型都被视为智能指针,因为它们拥有一些内存并允许你对其进行操作。它们还具有元数据和额外的功能或保证。例如,String 将其容量存储为元数据,并具有确保其数据始终是有效 UTF-8 的额外能力。

智能指针通常使用结构体来实现。与普通结构体不同,智能指针实现了 DerefDrop trait。Deref trait 允许智能指针结构体的实例像引用一样行为,因此你可以编写代码来处理引用或智能指针。Drop trait 允许你自定义智能指针实例超出作用域时运行的代码。在本章中,我们将讨论这两个 trait,并展示它们对智能指针的重要性。

鉴于智能指针模式是 Rust 中常用的通用设计模式,本章不会涵盖所有现有的智能指针。许多库都有自己的智能指针,你甚至可以编写自己的智能指针。我们将介绍标准库中最常见的智能指针:

  • Box<T>,用于在堆上分配值
  • Rc<T>,一种引用计数类型,允许多重所有权
  • Ref<T>RefMut<T>,通过 RefCell<T> 访问,这是一种在运行时而不是编译时强制执行借用规则的类型

此外,我们将介绍_内部可变性_模式,即不可变类型暴露一个用于改变内部值的 API。我们还将讨论_引用循环_:它们如何导致内存泄漏以及如何防止它们。

让我们开始吧!

使用 Box<T> 指向堆上的数据

最直接的智能指针是 box,其类型写作 Box<T>。Box 允许你将数据存储在堆上,而不是栈上。栈上保留的是指向堆数据的指针。回顾第 4 章以了解栈和堆的区别。

Box 除了将数据存储在堆上而不是栈上之外,没有性能开销。但它们也没有太多额外的功能。你通常会在以下情况下使用它们:

  • 当你有一个在编译时无法确定大小的类型,并且你希望在一个需要确切大小的上下文中使用该类型的值时
  • 当你有大量数据并希望转移所有权,但确保在转移时不会复制数据时
  • 当你希望拥有一个值,并且只关心它是一个实现了特定 trait 的类型,而不是特定类型时

我们将在 “使用 Box 启用递归类型” 中演示第一种情况。在第二种情况下,转移大量数据的所有权可能会花费很长时间,因为数据会在栈上来回复制。为了提高这种情况下的性能,我们可以将大量数据存储在堆上的 box 中。然后,只有少量的指针数据会在栈上来回复制,而它引用的数据则保留在堆上的一个位置。第三种情况被称为 trait 对象,第 18 章的 “使用允许不同类型值的 Trait 对象” 专门讨论了这个主题。因此,你在这里学到的内容将在那一节中再次应用!

使用 Box<T> 在堆上存储数据

在我们讨论 Box<T> 的堆存储用例之前,我们将介绍语法以及如何与存储在 Box<T> 中的值进行交互。

Listing 15-1 展示了如何使用 box 在堆上存储一个 i32 值。

fn main() {
    let b = Box::new(5);
    println!("b = {b}");
}

我们定义变量 b 的值为一个指向堆上分配的 5Box。这个程序将打印 b = 5;在这种情况下,我们可以像访问栈上的数据一样访问 box 中的数据。就像任何拥有所有权的值一样,当 box 超出作用域时,如 bmain 结束时,它将被释放。释放操作既针对 box(存储在栈上)也针对它指向的数据(存储在堆上)。

将单个值放在堆上并不是很有用,因此你不会经常以这种方式单独使用 box。在大多数情况下,将像单个 i32 这样的值存储在栈上(默认情况下它们存储在那里)更为合适。让我们看一个 box 允许我们定义类型的例子,如果没有 box,我们将无法定义这些类型。

使用 Box 启用递归类型

递归类型 的值可以将另一个相同类型的值作为自身的一部分。递归类型会带来问题,因为 Rust 需要在编译时知道一个类型占用了多少空间。然而,递归类型的值的嵌套理论上可以无限继续,因此 Rust 无法知道该值需要多少空间。由于 box 有已知的大小,我们可以通过在递归类型定义中插入一个 box 来启用递归类型。

作为递归类型的一个例子,让我们探索 cons list。这是函数式编程语言中常见的数据类型。我们将定义的 cons list 类型除了递归之外都很简单;因此,我们在这个例子中使用的概念在你遇到涉及递归类型的更复杂情况时也会有用。

关于 Cons List 的更多信息

cons list 是一种来自 Lisp 编程语言及其方言的数据结构,由嵌套的对组成,是 Lisp 版本的链表。它的名字来源于 Lisp 中的 cons 函数(construct function 的缩写),该函数从其两个参数构造一个新的对。通过对一个由值和对组成的对调用 cons,我们可以构造由递归对组成的 cons list。

例如,这是一个包含列表 1, 2, 3 的 cons list 的伪代码表示,每个对都用括号括起来:

(1, (2, (3, Nil)))

cons list 中的每个项目包含两个元素:当前项目的值和下一个项目。列表中的最后一个项目只包含一个名为 Nil 的值,没有下一个项目。cons list 是通过递归调用 cons 函数生成的。表示递归基本情况的标准名称是 Nil。请注意,这与第 6 章讨论的“null”或“nil”概念不同,后者是无效或缺失的值。

cons list 并不是 Rust 中常用的数据结构。大多数情况下,当你在 Rust 中有一个项目列表时,Vec<T> 是更好的选择。其他更复杂的递归数据类型在各种情况下 确实 有用,但通过在本章中从 cons list 开始,我们可以在没有太多干扰的情况下探索 box 如何让我们定义一个递归数据类型。

Listing 15-2 包含了一个 cons list 的枚举定义。请注意,这段代码还不能编译,因为 List 类型没有已知的大小,我们将演示这一点。

enum List {
    Cons(i32, List),
    Nil,
}

fn main() {}

注意:为了这个示例的目的,我们实现了一个只包含 i32 值的 cons list。我们可以使用泛型来实现它,如第 10 章所讨论的,以定义一个可以存储任何类型值的 cons list 类型。

使用 List 类型存储列表 1, 2, 3 将如 Listing 15-3 所示。

enum List {
    Cons(i32, List),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}

第一个 Cons 值持有 1 和另一个 List 值。这个 List 值是另一个 Cons 值,它持有 2 和另一个 List 值。这个 List 值又是一个 Cons 值,它持有 3 和一个 List 值,最后是 Nil,这是表示列表结束的非递归变体。

如果我们尝试编译 Listing 15-3 中的代码,我们将得到 Listing 15-4 中显示的错误。

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0072]: recursive type `List` has infinite size
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
2 |     Cons(i32, List),
  |               ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

error[E0391]: cycle detected when computing when `List` needs drop
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
  |
  = note: ...which immediately requires computing when `List` needs drop again
  = note: cycle used when computing whether `List` needs drop
  = note: see https://rustc-dev-guide.rust-lang.org/overview.html#queries and https://rustc-dev-guide.rust-lang.org/query.html for more information

Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 2 previous errors

错误显示这个类型“具有无限大小”。原因是我们定义了一个递归的 List 变体:它直接持有另一个自身的值。因此,Rust 无法确定存储一个 List 值需要多少空间。让我们分解一下为什么会出现这个错误。首先,我们将看看 Rust 如何决定存储一个非递归类型的值需要多少空间。

计算非递归类型的大小

回顾我们在第 6 章讨论枚举定义时在 Listing 6-2 中定义的 Message 枚举:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

为了确定为一个 Message 值分配多少空间,Rust 会遍历每个变体,看看哪个变体需要最多的空间。Rust 发现 Message::Quit 不需要任何空间,Message::Move 需要足够的空间来存储两个 i32 值,依此类推。因为只会使用一个变体,所以 Message 值所需的最大空间是存储其最大变体所需的空间。

与此形成对比的是,当 Rust 尝试确定像 Listing 15-2 中的 List 枚举这样的递归类型需要多少空间时会发生什么。编译器首先查看 Cons 变体,它持有一个 i32 类型的值和一个 List 类型的值。因此,Cons 需要等于 i32 的大小加上 List 的大小的空间。为了确定 List 类型需要多少内存,编译器查看变体,从 Cons 变体开始。Cons 变体持有一个 i32 类型的值和一个 List 类型的值,这个过程无限继续,如图 15-1 所示。

一个无限的 Cons list

图 15-1: 由无限 Cons 变体组成的无限 List

使用 Box<T> 获取具有已知大小的递归类型

因为 Rust 无法确定为递归定义的类型分配多少空间,编译器给出了一个包含有用建议的错误:

help: 插入一些间接性(例如,一个 `Box`、`Rc` 或 `&`)来打破循环
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

在这个建议中,间接性 意味着我们不应该直接存储一个值,而是应该通过存储一个指向该值的指针来改变数据结构。

因为 Box<T> 是一个指针,Rust 总是知道一个 Box<T> 需要多少空间:指针的大小不会根据它指向的数据量而变化。这意味着我们可以在 Cons 变体中放入一个 Box<T>,而不是直接放入另一个 List 值。Box<T> 将指向堆上的下一个 List 值,而不是在 Cons 变体内部。从概念上讲,我们仍然有一个列表,由持有其他列表的列表创建,但这个实现现在更像是将项目彼此相邻放置,而不是彼此嵌套。

我们可以将 Listing 15-2 中的 List 枚举定义和 Listing 15-3 中的 List 使用改为 Listing 15-5 中的代码,这将编译通过。

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}

Cons 变体需要一个 i32 的大小加上存储 box 指针数据的空间。Nil 变体不存储任何值,因此它需要的空间比 Cons 变体少。我们现在知道任何 List 值将占用一个 i32 的大小加上一个 box 指针数据的大小。通过使用 box,我们打破了无限的递归链,因此编译器可以确定存储一个 List 值需要多少空间。图 15-2 展示了 Cons 变体现在的样子。

一个有限的 Cons list

图 15-2: 一个不是无限大小的 List,因为 Cons 持有一个 Box

Box 只提供间接性和堆分配;它们没有其他特殊功能,比如我们将在其他智能指针类型中看到的功能。它们也没有这些特殊功能带来的性能开销,因此它们可以在像 cons list 这样的情况下有用,其中间接性是我们唯一需要的功能。我们将在第 18 章中看到更多 box 的用例。

Box<T> 类型是一个智能指针,因为它实现了 Deref trait,这允许 Box<T> 值像引用一样被处理。当 Box<T> 值超出作用域时,由于 Drop trait 的实现,box 指向的堆数据也会被清理。这两个 trait 对于我们将在本章其余部分讨论的其他智能指针类型提供的功能将更加重要。让我们更详细地探讨这两个 trait。

使用 Deref 将智能指针当作常规引用处理

实现 Deref 特性允许你自定义 解引用运算符 * 的行为(不要与乘法或通配符运算符混淆)。通过以某种方式实现 Deref,使得智能指针可以像常规引用一样被处理,你可以编写操作引用的代码,并将这些代码用于智能指针。

让我们首先看看解引用运算符如何与常规引用一起工作。然后我们将尝试定义一个行为类似于 Box<T> 的自定义类型,并看看为什么解引用运算符在我们新定义的类型上不能像引用一样工作。我们将探索如何通过实现 Deref 特性使智能指针能够以类似于引用的方式工作。然后我们将看看 Rust 的 解引用强制转换 功能,以及它如何让我们能够同时使用引用或智能指针。

注意:我们将要构建的 MyBox<T> 类型与真正的 Box<T> 有一个很大的区别:我们的版本不会将其数据存储在堆上。我们将这个示例的重点放在 Deref 上,因此数据实际存储的位置不如指针行为重要。

通过解引用运算符追踪指针到值

常规引用是一种指针,可以将指针视为指向存储在其他地方的值的箭头。在 Listing 15-6 中,我们创建了一个指向 i32 值的引用,然后使用解引用运算符来追踪引用到值。

fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

变量 x 持有一个 i325。我们将 y 设置为指向 x 的引用。我们可以断言 x 等于 5。然而,如果我们想对 y 中的值进行断言,我们必须使用 *y 来追踪引用到它指向的值(因此称为 解引用),以便编译器可以比较实际值。一旦我们解引用 y,我们就可以访问 y 指向的整数值,并将其与 5 进行比较。

如果我们尝试编写 assert_eq!(5, y);,我们会得到以下编译错误:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0277]: can't compare `{integer}` with `&{integer}`
 --> src/main.rs:6:5
  |
6 |     assert_eq!(5, y);
  |     ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
  |
  = help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
  = note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider dereferencing here
 --> file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/macros/mod.rs:46:35
  |
46|                 if !(*left_val == **right_val) {
  |                                   +

For more information about this error, try `rustc --explain E0277`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error

比较一个数字和一个数字的引用是不允许的,因为它们是不同的类型。我们必须使用解引用运算符来追踪引用到它指向的值。

像引用一样使用 Box<T>

我们可以重写 Listing 15-6 中的代码,使用 Box<T> 而不是引用;Listing 15-7 中在 Box<T> 上使用的解引用运算符与 Listing 15-6 中在引用上使用的解引用运算符功能相同:

fn main() {
    let x = 5;
    let y = Box::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

Listing 15-7 和 Listing 15-6 之间的主要区别在于,在这里我们将 y 设置为指向 x 的复制值的 box 实例,而不是指向 x 值的引用。在最后的断言中,我们可以使用解引用运算符来追踪 box 的指针,就像 y 是引用时一样。接下来,我们将通过定义我们自己的类型来探索 Box<T> 的特殊之处,它使我们能够使用解引用运算符。

定义我们自己的智能指针

让我们构建一个类似于标准库提供的 Box<T> 类型的智能指针,以体验智能指针默认情况下与引用的不同行为。然后我们将看看如何添加使用解引用运算符的能力。

Box<T> 类型最终被定义为一个只有一个元素的元组结构体,因此 Listing 15-8 以相同的方式定义了一个 MyBox<T> 类型。我们还将定义一个 new 函数,以匹配 Box<T> 上定义的 new 函数。

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {}

我们定义了一个名为 MyBox 的结构体,并声明了一个泛型参数 T,因为我们希望我们的类型能够持有任何类型的值。MyBox 类型是一个只有一个类型为 T 的元素的元组结构体。MyBox::new 函数接受一个类型为 T 的参数,并返回一个持有传入值的 MyBox 实例。

让我们尝试将 Listing 15-7 中的 main 函数添加到 Listing 15-8 中,并将其更改为使用我们定义的 MyBox<T> 类型,而不是 Box<T>。Listing 15-9 中的代码将无法编译,因为 Rust 不知道如何解引用 MyBox

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

以下是编译错误的结果:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
  --> src/main.rs:14:19
   |
14 |     assert_eq!(5, *y);
   |                   ^^

For more information about this error, try `rustc --explain E0614`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error

我们的 MyBox<T> 类型不能被解引用,因为我们还没有在我们的类型上实现这种能力。为了能够使用 * 运算符进行解引用,我们实现了 Deref 特性。

实现 Deref 特性

正如在 “在类型上实现特性” 中讨论的那样,要实现一个特性,我们需要为特性的必需方法提供实现。标准库提供的 Deref 特性要求我们实现一个名为 deref 的方法,该方法借用 self 并返回对内部数据的引用。Listing 15-10 包含一个 Deref 的实现,将其添加到 MyBox<T> 的定义中。

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

type Target = T; 语法为 Deref 特性定义了一个关联类型。关联类型是一种稍微不同的声明泛型参数的方式,但你现在不需要担心它们;我们将在第 20 章中更详细地介绍它们。

我们将 deref 方法的主体填充为 &self.0,以便 deref 返回我们想要使用 * 运算符访问的值的引用;回想一下 “使用没有命名字段的元组结构体创建不同类型”.0 访问元组结构体中的第一个值。Listing 15-9 中调用 *main 函数现在可以编译,并且断言通过!

没有 Deref 特性,编译器只能解引用 & 引用。deref 方法使编译器能够获取任何实现 Deref 的类型的值,并调用 deref 方法以获取它知道如何解引用的 & 引用。

当我们在 Listing 15-9 中输入 *y 时,Rust 实际上在幕后运行了以下代码:

*(y.deref())

Rust 将 * 运算符替换为对 deref 方法的调用,然后是一个普通的解引用,因此我们不必考虑是否需要调用 deref 方法。这个 Rust 特性让我们编写的代码无论我们拥有常规引用还是实现 Deref 的类型,都能以相同的方式工作。

deref 方法返回对值的引用,以及 *(y.deref()) 中括号外的普通解引用仍然是必要的,这与所有权系统有关。如果 deref 方法直接返回值而不是对值的引用,则该值将从 self 中移出。在这种情况下,我们不希望获取 MyBox<T> 内部值的所有权,或者在我们使用解引用运算符的大多数情况下都不希望这样做。

请注意,每次我们在代码中使用 * 时,* 运算符都会被替换为对 deref 方法的调用,然后是对 * 运算符的一次调用。因为 * 运算符的替换不会无限递归,所以我们最终会得到类型为 i32 的数据,这与 Listing 15-9 中的 assert_eq! 中的 5 匹配。

使用函数和方法进行隐式解引用强制转换

解引用强制转换 将实现了 Deref 特性的类型的引用转换为另一种类型的引用。例如,解引用强制转换可以将 &String 转换为 &str,因为 String 实现了 Deref 特性,使其返回 &str。解引用强制转换是 Rust 对函数和方法参数执行的一种便利操作,并且仅适用于实现了 Deref 特性的类型。当我们传递对特定类型值的引用作为参数给函数或方法时,如果该参数类型与函数或方法定义中的参数类型不匹配,Rust 会自动执行解引用强制转换。对 deref 方法的一系列调用将我们提供的类型转换为参数所需的类型。

Rust 添加了解引用强制转换,以便编写函数和方法调用的程序员不需要添加那么多显式的引用和解引用操作符 &*。解引用强制转换功能还让我们能够编写更多可以同时适用于引用或智能指针的代码。

要查看解引用强制转换的实际效果,让我们使用 Listing 15-8 中定义的 MyBox<T> 类型以及我们在 Listing 15-10 中添加的 Deref 实现。Listing 15-11 显示了一个具有字符串切片参数的函数的定义。

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {}

我们可以使用字符串切片作为参数调用 hello 函数,例如 hello("Rust");。解引用强制转换使得可以使用 MyBox<String> 类型的值的引用调用 hello,如 Listing 15-12 所示。

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&m);
}

在这里,我们使用参数 &m 调用 hello 函数,&m 是对 MyBox<String> 值的引用。因为我们在 Listing 15-10 中为 MyBox<T> 实现了 Deref 特性,Rust 可以通过调用 deref&MyBox<String> 转换为 &String。标准库提供了 String 上的 Deref 实现,它返回一个字符串切片,这在 Deref 的 API 文档中有说明。Rust 再次调用 deref&String 转换为 &str,这与 hello 函数的定义匹配。

如果 Rust 没有实现解引用强制转换,我们将不得不编写 Listing 15-13 中的代码,而不是 Listing 15-12 中的代码,以使用 &MyBox<String> 类型的值调用 hello

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&(*m)[..]);
}

(*m)MyBox<String> 解引用为 String。然后 &[..] 获取 String 的字符串切片,该切片等于整个字符串以匹配 hello 的签名。没有解引用强制转换的代码更难阅读、编写和理解,因为涉及所有这些符号。解引用强制转换允许 Rust 自动为我们处理这些转换。

当为相关类型定义了 Deref 特性时,Rust 将分析类型并根据需要多次使用 Deref::deref 以获取与参数类型匹配的引用。Deref::deref 需要插入的次数在编译时解决,因此利用解引用强制转换不会带来运行时开销!

解引用强制转换与可变性的交互

类似于你如何使用 Deref 特性覆盖不可变引用上的 * 运算符,你可以使用 DerefMut 特性覆盖可变引用上的 * 运算符。

Rust 在发现类型和特性实现时会在三种情况下执行解引用强制转换:

  1. &T&U,当 T: Deref<Target=U>
  2. &mut T&mut U,当 T: DerefMut<Target=U>
  3. &mut T&U,当 T: Deref<Target=U>

前两种情况相同,只是第二种情况实现了可变性。第一种情况说明,如果你有一个 &T,并且 T 实现了 Deref 到某个类型 U,你可以透明地获得一个 &U。第二种情况说明,相同的解引用强制转换也适用于可变引用。

第三种情况更复杂:Rust 还会将可变引用强制转换为不可变引用。但反过来是不可能的:不可变引用永远不会强制转换为可变引用。由于借用规则,如果你有一个可变引用,那么该可变引用必须是该数据的唯一引用(否则,程序将无法编译)。将一个可变引用转换为一个不可变引用永远不会违反借用规则。将一个不可变引用转换为一个可变引用将要求初始的不可变引用是该数据的唯一不可变引用,但借用规则并不保证这一点。因此,Rust 不能假设将不可变引用转换为可变引用是可能的。

使用 Drop Trait 在清理时运行代码

智能指针模式的第二个重要 trait 是 Drop,它允许你自定义当一个值即将离开作用域时会发生什么。你可以为任何类型实现 Drop trait,并且该代码可以用于释放文件或网络连接等资源。

我们在智能指针的上下文中介绍 Drop,因为在实现智能指针时几乎总是会用到 Drop trait 的功能。例如,当 Box<T> 被丢弃时,它将释放该 box 指向的堆上的空间。

在某些语言中,对于某些类型,程序员必须在每次使用完这些类型的实例后调用代码来释放内存或资源。例如,文件句柄、套接字和锁。如果他们忘记了,系统可能会过载并崩溃。在 Rust 中,你可以指定每当一个值离开作用域时运行特定的代码,编译器会自动插入这段代码。因此,你不需要小心地在程序中每个特定类型实例使用完毕的地方放置清理代码——你仍然不会泄漏资源!

你通过实现 Drop trait 来指定当一个值离开作用域时要运行的代码。Drop trait 要求你实现一个名为 drop 的方法,该方法接受一个对 self 的可变引用。为了查看 Rust 何时调用 drop,我们现在用 println! 语句来实现 drop

Listing 15-14 展示了一个 CustomSmartPointer 结构体,其唯一的自定义功能是当实例离开作用域时会打印 Dropping CustomSmartPointer!,以展示 Rust 何时运行 drop 方法。

struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("my stuff"),
    };
    let d = CustomSmartPointer {
        data: String::from("other stuff"),
    };
    println!("CustomSmartPointers created.");
}

Drop trait 包含在预导入模块中,所以我们不需要手动引入作用域。我们在 CustomSmartPointer 上实现了 Drop trait,并为 drop 方法提供了一个调用 println! 的实现。drop 方法的主体是你希望在类型的实例离开作用域时运行的任何逻辑。我们在这里打印一些文本来直观地展示 Rust 何时调用 drop

main 函数中,我们创建了两个 CustomSmartPointer 的实例,然后打印 CustomSmartPointers created。在 main 函数的末尾,我们的 CustomSmartPointer 实例将离开作用域,Rust 将调用我们放在 drop 方法中的代码,打印出最终的消息。注意,我们不需要显式调用 drop 方法。

当我们运行这个程序时,我们将看到以下输出:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running `target/debug/drop-example`
CustomSmartPointers created.
Dropping CustomSmartPointer with data `other stuff`!
Dropping CustomSmartPointer with data `my stuff`!

Rust 在我们的实例离开作用域时自动为我们调用了 drop,调用了我们指定的代码。变量以它们创建的顺序相反的顺序被丢弃,所以 dc 之前被丢弃。这个例子的目的是给你一个关于 drop 方法如何工作的直观指南;通常你会指定你的类型需要运行的清理代码,而不是打印消息。

不幸的是,禁用自动 drop 功能并不简单。通常不需要禁用 dropDrop trait 的整个意义在于它是自动处理的。然而,有时你可能希望提前清理一个值。一个例子是当你使用管理锁的智能指针时:你可能希望强制调用释放锁的 drop 方法,以便同一作用域中的其他代码可以获取锁。Rust 不允许你手动调用 Drop trait 的 drop 方法;相反,如果你想强制在作用域结束之前丢弃一个值,你必须调用标准库提供的 std::mem::drop 函数。

如果我们尝试通过修改 Listing 15-14 中的 main 函数来手动调用 Drop trait 的 drop 方法,如 Listing 15-15 所示,我们将得到一个编译器错误。

struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("some data"),
    };
    println!("CustomSmartPointer created.");
    c.drop();
    println!("CustomSmartPointer dropped before the end of main.");
}

当我们尝试编译这段代码时,我们将得到以下错误:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
error[E0040]: explicit use of destructor method
  --> src/main.rs:16:7
   |
16 |     c.drop();
   |       ^^^^ explicit destructor calls not allowed
   |
help: consider using `drop` function
   |
16 |     drop(c);
   |     +++++ ~

For more information about this error, try `rustc --explain E0040`.
error: could not compile `drop-example` (bin "drop-example") due to 1 previous error

这个错误消息指出我们不允许显式调用 drop。错误消息使用了术语 destructor,这是清理实例的函数的通用编程术语。destructor 类似于创建实例的 constructor。Rust 中的 drop 函数是一个特定的 destructor。

Rust 不允许我们显式调用 drop,因为 Rust 仍然会在 main 结束时自动调用 drop。这将导致 double free 错误,因为 Rust 将尝试两次清理同一个值。

我们不能禁用当一个值离开作用域时自动插入的 drop,也不能显式调用 drop 方法。因此,如果我们需要强制提前清理一个值,我们使用 std::mem::drop 函数。

std::mem::drop 函数与 Drop trait 中的 drop 方法不同。我们通过传递我们想要强制丢弃的值作为参数来调用它。该函数在预导入模块中,因此我们可以修改 Listing 15-15 中的 main 函数来调用 drop 函数,如 Listing 15-16 所示。

struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("some data"),
    };
    println!("CustomSmartPointer created.");
    drop(c);
    println!("CustomSmartPointer dropped before the end of main.");
}

运行这段代码将打印以下内容:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/drop-example`
CustomSmartPointer created.
Dropping CustomSmartPointer with data `some data`!
CustomSmartPointer dropped before the end of main.

文本 Dropping CustomSmartPointer with data `some data`!CustomSmartPointer created.CustomSmartPointer dropped before the end of main. 文本之间打印,表明 drop 方法代码在该点被调用来丢弃 c

你可以以多种方式使用 Drop trait 实现中指定的代码来使清理变得方便和安全:例如,你可以使用它来创建自己的内存分配器!有了 Drop trait 和 Rust 的所有权系统,你不必记住清理,因为 Rust 会自动完成。

你也不必担心由于意外清理仍在使用的值而导致的问题:确保引用始终有效的所有权系统也确保 drop 只在值不再被使用时调用一次。

现在我们已经研究了 Box<T> 和一些智能指针的特性,让我们看看标准库中定义的其他一些智能指针。

Rc<T>,引用计数的智能指针

在大多数情况下,所有权是明确的:你可以确切地知道哪个变量拥有某个值。然而,有些情况下,一个值可能会有多个所有者。例如,在图数据结构中,多个边可能指向同一个节点,而这个节点在概念上是由所有指向它的边共同拥有的。除非没有任何边指向该节点,否则该节点不应该被清理。

你必须通过使用 Rust 类型 Rc<T> 来显式地启用多重所有权,Rc<T>reference counting(引用计数)的缩写。Rc<T> 类型会跟踪一个值的引用数量,以确定该值是否仍在被使用。如果某个值的引用数量为零,那么该值可以被清理,而不会导致任何引用失效。

你可以把 Rc<T> 想象成家庭房间里的电视。当一个人进入房间看电视时,他们会打开电视。其他人也可以进入房间并观看电视。当最后一个人离开房间时,他们会关闭电视,因为电视不再被使用。如果有人在其他人还在看电视时关闭电视,那么剩下的观众会感到非常不满!

当我们希望在堆上分配一些数据,以便程序的多个部分可以读取,并且我们无法在编译时确定哪个部分会最后使用这些数据时,我们会使用 Rc<T> 类型。如果我们知道哪个部分会最后使用数据,我们可以直接让该部分成为数据的所有者,并在编译时强制执行正常的所有权规则。

需要注意的是,Rc<T> 仅适用于单线程场景。当我们在第 16 章讨论并发时,我们会介绍如何在多线程程序中进行引用计数。

使用 Rc<T> 共享数据

让我们回到 Listing 15-5 中的 cons 列表示例。回想一下,我们使用 Box<T> 定义了它。这次,我们将创建两个列表,它们共同拥有第三个列表的所有权。从概念上讲,这类似于图 15-3。

两个列表共享第三个列表的所有权

图 15-3:两个列表 bc,共享第三个列表 a 的所有权

我们将创建一个包含 510 的列表 a。然后我们再创建两个列表:b3 开头,c4 开头。bc 列表随后都会继续到包含 510 的第一个列表 a。换句话说,这两个列表将共享包含 510 的第一个列表。

尝试使用我们定义的 ListBox<T> 来实现这个场景是行不通的,如 Listing 15-17 所示:

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
    let b = Cons(3, Box::new(a));
    let c = Cons(4, Box::new(a));
}

当我们编译这段代码时,会得到以下错误:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0382]: use of moved value: `a`
  --> src/main.rs:11:30
   |
9  |     let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
   |         - move occurs because `a` has type `List`, which does not implement the `Copy` trait
10 |     let b = Cons(3, Box::new(a));
   |                              - value moved here
11 |     let c = Cons(4, Box::new(a));
   |                              ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `cons-list` (bin "cons-list") due to 1 previous error

Cons 变体拥有它们所持有的数据,因此当我们创建列表 b 时,a 被移动到 b 中,b 拥有了 a。然后,当我们在创建 c 时再次使用 a,这是不允许的,因为 a 已经被移动了。

我们可以修改 Cons 的定义,使其持有引用而不是数据,但这样我们就必须指定生命周期参数。通过指定生命周期参数,我们就是在指定列表中的每个元素至少要与整个列表一样长。这在 Listing 15-17 中的元素和列表中是成立的,但并非在所有场景中都成立。

相反,我们将修改 List 的定义,使用 Rc<T> 代替 Box<T>,如 Listing 15-18 所示。每个 Cons 变体现在将持有一个值和一个指向 ListRc<T>。当我们创建 b 时,不再获取 a 的所有权,而是克隆 a 所持有的 Rc<List>,从而将引用计数从 1 增加到 2,并让 ab 共享该 Rc<List> 中的数据的所有权。在创建 c 时,我们也会克隆 a,将引用计数从 2 增加到 3。每次我们调用 Rc::clone 时,Rc<List> 内部数据的引用计数都会增加,除非引用计数为零,否则数据不会被清理。

enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    let b = Cons(3, Rc::clone(&a));
    let c = Cons(4, Rc::clone(&a));
}

我们需要添加一个 use 语句将 Rc<T> 引入作用域,因为它不在预导入模块中。在 main 函数中,我们创建包含 5 和 10 的列表,并将其存储在 a 的一个新的 Rc<List> 中。然后当我们创建 bc 时,我们调用 Rc::clone 函数,并将 a 中的 Rc<List> 的引用作为参数传递。

我们可以调用 a.clone() 而不是 Rc::clone(&a),但 Rust 的惯例是在这种情况下使用 Rc::cloneRc::clone 的实现不会像大多数类型的 clone 实现那样对所有数据进行深拷贝。调用 Rc::clone 只会增加引用计数,这不会花费太多时间。数据的深拷贝可能会花费很多时间。通过使用 Rc::clone 进行引用计数,我们可以在视觉上区分深拷贝类型的克隆和增加引用计数的克隆。在代码中寻找性能问题时,我们只需要考虑深拷贝类型的克隆,而可以忽略对 Rc::clone 的调用。

克隆 Rc<T> 会增加引用计数

让我们修改 Listing 15-18 中的工作示例,以便我们可以看到在创建和丢弃对 aRc<List> 的引用时,引用计数的变化。

在 Listing 15-19 中,我们将修改 main 函数,使其在列表 c 周围有一个内部作用域;然后我们可以看到当 c 离开作用域时,引用计数如何变化。

enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    println!("count after creating a = {}", Rc::strong_count(&a));
    let b = Cons(3, Rc::clone(&a));
    println!("count after creating b = {}", Rc::strong_count(&a));
    {
        let c = Cons(4, Rc::clone(&a));
        println!("count after creating c = {}", Rc::strong_count(&a));
    }
    println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}

在程序中引用计数变化的每个点,我们都会打印引用计数,这是通过调用 Rc::strong_count 函数获得的。这个函数名为 strong_count 而不是 count,因为 Rc<T> 类型还有一个 weak_count;我们将在 “使用 Weak<T> 防止引用循环” 中看到 weak_count 的用途。

这段代码会打印以下内容:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.45s
     Running `target/debug/cons-list`
count after creating a = 1
count after creating b = 2
count after creating c = 3
count after c goes out of scope = 2

我们可以看到,a 中的 Rc<List> 初始引用计数为 1;然后每次我们调用 clone 时,计数都会增加 1。当 c 离开作用域时,计数减少 1。我们不需要调用函数来减少引用计数,就像我们需要调用 Rc::clone 来增加引用计数一样:Drop trait 的实现会在 Rc<T> 值离开作用域时自动减少引用计数。

在这个示例中我们看不到的是,当 bamain 函数结束时离开作用域时,计数变为 0,Rc<List> 被完全清理。使用 Rc<T> 允许一个值有多个所有者,而计数确保只要任何所有者仍然存在,该值就保持有效。

通过不可变引用,Rc<T> 允许你在程序的多个部分之间共享数据以供只读。如果 Rc<T> 也允许你拥有多个可变引用,你可能会违反第 4 章讨论的借用规则之一:对同一位置的多个可变借用可能导致数据竞争和不一致。但能够改变数据是非常有用的!在下一节中,我们将讨论内部可变性模式以及你可以与 Rc<T> 结合使用的 RefCell<T> 类型,以应对这种不可变性限制。

RefCell<T> 和内部可变性模式

内部可变性 是 Rust 中的一种设计模式,它允许你在数据存在不可变引用的情况下仍然能够修改数据;通常情况下,这种行为是被借用规则所禁止的。为了修改数据,该模式在数据结构内部使用了 unsafe 代码来绕过 Rust 通常的修改和借用规则。unsafe 代码向编译器表明我们正在手动检查这些规则,而不是依赖编译器为我们检查;我们将在第 20 章详细讨论 unsafe 代码。

我们只能在确保运行时遵守借用规则的情况下使用内部可变性模式的类型,尽管编译器无法保证这一点。涉及的 unsafe 代码随后被包装在一个安全的 API 中,而外部类型仍然是不可变的。

让我们通过查看遵循内部可变性模式的 RefCell<T> 类型来探索这个概念。

使用 RefCell<T> 在运行时强制执行借用规则

Rc<T> 不同,RefCell<T> 类型表示对其持有的数据的单一所有权。那么 RefCell<T>Box<T> 这样的类型有什么不同呢?回想一下你在第 4 章学到的借用规则:

  • 在任何给定时间,你可以拥有 要么 一个可变引用,要么任意数量的不可变引用(但不能同时拥有两者)。
  • 引用必须始终有效。

对于引用和 Box<T>,借用规则的不变量是在编译时强制执行的。而对于 RefCell<T>,这些不变量是在 运行时 强制执行的。对于引用,如果你违反了这些规则,你会得到一个编译错误。对于 RefCell<T>,如果你违反了这些规则,你的程序将会 panic 并退出。

在编译时检查借用规则的好处是错误会在开发过程中更早地被捕获,并且不会影响运行时性能,因为所有的分析都在之前完成了。出于这些原因,在大多数情况下,编译时检查借用规则是最佳选择,这也是 Rust 的默认行为。

在运行时检查借用规则的好处是某些内存安全的场景会被允许,而这些场景在编译时检查中会被禁止。静态分析,如 Rust 编译器,本质上是保守的。代码的某些属性无法通过分析代码来检测:最著名的例子是停机问题,这超出了本书的范围,但这是一个有趣的研究主题。

由于某些分析是不可能的,如果 Rust 编译器不能确定代码符合所有权规则,它可能会拒绝一个正确的程序;在这种情况下,它是保守的。如果 Rust 接受了一个错误的程序,用户将无法信任 Rust 所做的保证。然而,如果 Rust 拒绝了一个正确的程序,程序员会感到不便,但不会发生灾难性的事情。RefCell<T> 类型在你确信代码遵循借用规则但编译器无法理解和保证这一点时非常有用。

Rc<T> 类似,RefCell<T> 仅用于单线程场景,如果你尝试在多线程上下文中使用它,将会得到一个编译时错误。我们将在第 16 章讨论如何在多线程程序中获得 RefCell<T> 的功能。

以下是选择 Box<T>Rc<T>RefCell<T> 的原因总结:

  • Rc<T> 允许多个所有者拥有相同的数据;Box<T>RefCell<T> 有单一所有者。
  • Box<T> 允许在编译时检查不可变或可变借用;Rc<T> 只允许在编译时检查不可变借用;RefCell<T> 允许在运行时检查不可变或可变借用。
  • 因为 RefCell<T> 允许在运行时检查可变借用,所以即使 RefCell<T> 是不可变的,你也可以修改 RefCell<T> 内部的值。

修改不可变值内部的值是 内部可变性 模式。让我们看看内部可变性有用的情况,并探讨它是如何实现的。

内部可变性:对不可变值的可变借用

借用规则的一个后果是,当你有一个不可变的值时,你不能可变地借用它。例如,这段代码无法编译:

fn main() {
    let x = 5;
    let y = &mut x;
}

如果你尝试编译这段代码,你会得到以下错误:

$ cargo run
   Compiling borrowing v0.1.0 (file:///projects/borrowing)
error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable
 --> src/main.rs:3:13
  |
3 |     let y = &mut x;
  |             ^^^^^^ cannot borrow as mutable
  |
help: consider changing this to be mutable
  |
2 |     let mut x = 5;
  |         +++

For more information about this error, try `rustc --explain E0596`.
error: could not compile `borrowing` (bin "borrowing") due to 1 previous error

然而,在某些情况下,一个值在其方法中能够自我修改,但对其他代码来说看起来是不可变的,这将非常有用。值的方法之外的代码将无法修改该值。使用 RefCell<T> 是一种获得内部可变性能力的方法,但 RefCell<T> 并不能完全绕过借用规则:编译器中的借用检查器允许这种内部可变性,而借用规则是在运行时检查的。如果你违反了这些规则,你会得到一个 panic! 而不是编译错误。

让我们通过一个实际的例子来使用 RefCell<T> 来修改一个不可变的值,并看看为什么这很有用。

内部可变性的用例:模拟对象

有时在测试中,程序员会使用一个类型来代替另一个类型,以便观察特定的行为并断言其实现是否正确。这个占位符类型被称为 测试替身。可以将其想象为电影制作中的替身演员,一个人代替演员来完成一个特别棘手的场景。测试替身在我们运行测试时代表其他类型。模拟对象 是特定类型的测试替身,它们记录测试期间发生的事情,以便你可以断言发生了正确的操作。

Rust 没有像其他语言那样的对象,Rust 的标准库中也没有像其他语言那样内置模拟对象功能。然而,你绝对可以创建一个结构体,它将起到与模拟对象相同的作用。

这是我们将测试的场景:我们将创建一个库,用于跟踪一个值与最大值的接近程度,并根据当前值接近最大值的程度发送消息。例如,这个库可以用于跟踪用户允许的 API 调用次数的配额。

我们的库只提供跟踪值与最大值的接近程度以及在什么时间发送什么消息的功能。使用我们库的应用程序需要提供发送消息的机制:应用程序可以将消息放入应用程序中,发送电子邮件,发送短信,或者做其他事情。库不需要知道这些细节。它只需要一个实现了我们将提供的 Messenger trait 的东西。Listing 15-20 显示了库代码。

pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

这段代码的一个重要部分是 Messenger trait 有一个名为 send 的方法,它接受对 self 的不可变引用和消息的文本。这个 trait 是我们的模拟对象需要实现的接口,以便模拟对象可以像真实对象一样使用。另一个重要部分是我们想要测试 LimitTracker 上的 set_value 方法的行为。我们可以改变传递给 value 参数的内容,但 set_value 不会返回任何东西供我们进行断言。我们希望能够说,如果我们创建了一个 LimitTracker,它使用实现了 Messenger trait 的东西和一个特定的 max 值,当我们传递不同的 value 值时,messenger 会被告知发送适当的消息。

我们需要一个模拟对象,当调用 send 时,它不会发送电子邮件或短信,而只会跟踪它被告知要发送的消息。我们可以创建一个模拟对象的新实例,创建一个使用模拟对象的 LimitTracker,调用 LimitTracker 上的 set_value 方法,然后检查模拟对象是否有我们期望的消息。Listing 15-21 展示了一个尝试实现模拟对象的代码,但借用检查器不允许这样做。

pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    struct MockMessenger {
        sent_messages: Vec<String>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: vec![],
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.len(), 1);
    }
}

这个测试代码定义了一个 MockMessenger 结构体,它有一个 sent_messages 字段,其中包含一个 String 值的 Vec,用于跟踪它被告知要发送的消息。我们还定义了一个关联函数 new,以便方便地创建新的 MockMessenger 值,这些值从一个空的消息列表开始。然后我们为 MockMessenger 实现了 Messenger trait,以便我们可以将 MockMessenger 提供给 LimitTracker。在 send 方法的定义中,我们将作为参数传递的消息存储在 MockMessengersent_messages 列表中。

在测试中,我们测试当 LimitTracker 被告知将 value 设置为超过 max 值的 75% 时会发生什么。首先,我们创建一个新的 MockMessenger,它将从一个空的消息列表开始。然后我们创建一个新的 LimitTracker,并给它一个新的 MockMessenger 的引用和一个 max 值为 100。我们调用 LimitTracker 上的 set_value 方法,传递一个值为 80,这超过了 100 的 75%。然后我们断言 MockMessenger 正在跟踪的消息列表现在应该有一条消息。

然而,这个测试有一个问题,如下所示:

$ cargo test
   Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
error[E0596]: cannot borrow `self.sent_messages` as mutable, as it is behind a `&` reference
  --> src/lib.rs:58:13
   |
58 |             self.sent_messages.push(String::from(message));
   |             ^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be borrowed as mutable
   |
help: consider changing this to be a mutable reference in the `impl` method and the `trait` definition
   |
2  ~     fn send(&mut self, msg: &str);
3  | }
...
56 |     impl Messenger for MockMessenger {
57 ~         fn send(&mut self, message: &str) {
   |

For more information about this error, try `rustc --explain E0596`.
error: could not compile `limit-tracker` (lib test) due to 1 previous error
warning: build failed, waiting for other jobs to finish...

我们无法修改 MockMessenger 来跟踪消息,因为 send 方法接受对 self 的不可变引用。我们也不能接受错误文本中的建议,在 impl 方法和 trait 定义中都使用 &mut self。我们不想仅仅为了测试而改变 Messenger trait。相反,我们需要找到一种方法,使我们的测试代码能够与现有设计正确工作。

这是一个内部可变性可以发挥作用的情况!我们将 sent_messages 存储在 RefCell<T> 中,然后 send 方法将能够修改 sent_messages 以存储我们看到的消息。Listing 15-22 展示了这一点。

pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: RefCell::new(vec![]),
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.borrow_mut().push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        // --snip--
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}

sent_messages 字段现在是 RefCell<Vec<String>> 类型,而不是 Vec<String>。在 new 函数中,我们创建了一个新的 RefCell<Vec<String>> 实例,包装了一个空向量。

对于 send 方法的实现,第一个参数仍然是对 self 的不可变借用,这与 trait 定义匹配。我们调用 self.sent_messages 中的 RefCell<Vec<String>>borrow_mut 方法,以获取对 RefCell<Vec<String>> 内部值的可变引用,即向量。然后我们可以对向量的可变引用调用 push 来跟踪测试期间发送的消息。

我们必须做的最后一个更改是在断言中:为了查看内部向量中有多少项,我们调用 RefCell<Vec<String>>borrow 方法以获取对向量的不可变引用。

现在你已经看到了如何使用 RefCell<T>,让我们深入了解它的工作原理!

使用 RefCell<T> 在运行时跟踪借用

在创建不可变和可变引用时,我们分别使用 &&mut 语法。对于 RefCell<T>,我们使用 borrowborrow_mut 方法,它们是 RefCell<T> 的安全 API 的一部分。borrow 方法返回智能指针类型 Ref<T>,而 borrow_mut 返回智能指针类型 RefMut<T>。这两种类型都实现了 Deref,所以我们可以像对待常规引用一样对待它们。

RefCell<T> 跟踪当前有多少 Ref<T>RefMut<T> 智能指针是活动的。每次我们调用 borrow 时,RefCell<T> 都会增加其活动不可变借用的计数。当 Ref<T> 值超出范围时,不可变借用的计数会减少 1。就像编译时的借用规则一样,RefCell<T> 允许我们在任何时候拥有多个不可变借用或一个可变借用。

如果我们试图违反这些规则,与引用不同,我们不会得到编译错误,而是 RefCell<T> 的实现会在运行时 panic。Listing 15-23 展示了 Listing 15-22 中 send 实现的修改。我们故意尝试在同一作用域内创建两个活动的可变借用,以说明 RefCell<T> 会在运行时阻止我们这样做。

pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: RefCell::new(vec![]),
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            let mut one_borrow = self.sent_messages.borrow_mut();
            let mut two_borrow = self.sent_messages.borrow_mut();

            one_borrow.push(String::from(message));
            two_borrow.push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}

我们为 borrow_mut 返回的 RefMut<T> 智能指针创建了一个变量 one_borrow。然后我们以同样的方式在变量 two_borrow 中创建了另一个可变借用。这使得在同一作用域内有两个可变引用,这是不允许的。当我们运行库的测试时,Listing 15-23 中的代码将编译通过,但测试会失败:

$ cargo test
   Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/limit_tracker-e599811fa246dbde)

running 1 test
test tests::it_sends_an_over_75_percent_warning_message ... FAILED

failures:

---- tests::it_sends_an_over_75_percent_warning_message stdout ----

thread 'tests::it_sends_an_over_75_percent_warning_message' panicked at src/lib.rs:60:53:
already borrowed: BorrowMutError
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_sends_an_over_75_percent_warning_message

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

注意,代码 panic 并显示消息 already borrowed: BorrowMutError。这是 RefCell<T> 在运行时处理借用规则违规的方式。

选择在运行时而不是编译时捕获借用错误,正如我们在这里所做的,意味着你可能会在开发过程的后期发现代码中的错误:可能直到你的代码部署到生产环境时才发现。此外,由于在运行时而不是编译时跟踪借用,你的代码会遭受一点运行时性能损失。然而,使用 RefCell<T> 使得编写一个模拟对象成为可能,该对象可以在只允许不可变值的上下文中修改自身以跟踪它看到的消息。尽管有这些权衡,你仍然可以使用 RefCell<T> 来获得比常规引用提供的更多功能。

使用 Rc<T>RefCell<T> 允许多个所有者拥有可变数据

使用 RefCell<T> 的一种常见方式是与 Rc<T> 结合使用。回想一下,Rc<T> 允许你拥有某些数据的多个所有者,但它只提供对该数据的不可变访问。如果你有一个持有 RefCell<T>Rc<T>,你可以获得一个可以有多个所有者 并且 你可以修改的值!

例如,回想一下 Listing 15-18 中的 cons 列表示例,我们使用 Rc<T> 允许多个列表共享另一个列表的所有权。因为 Rc<T> 只持有不可变的值,所以我们一旦创建了列表中的值,就无法更改它们。让我们添加 RefCell<T> 以改变列表中的值。Listing 15-24 展示了通过在 Cons 定义中使用 RefCell<T>,我们可以修改所有列表中的值。

#[derive(Debug)]
enum List {
    Cons(Rc<RefCell<i32>>, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

fn main() {
    let value = Rc::new(RefCell::new(5));

    let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));

    let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
    let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));

    *value.borrow_mut() += 10;

    println!("a after = {a:?}");
    println!("b after = {b:?}");
    println!("c after = {c:?}");
}

我们创建了一个 Rc<RefCell<i32>> 的实例,并将其存储在一个名为 value 的变量中,以便稍后可以直接访问它。然后我们在 a 中创建了一个 List,其中包含一个持有 valueCons 变体。我们需要克隆 value,以便 avalue 都拥有内部值 5 的所有权,而不是将所有权从 value 转移到 a 或让 avalue 借用。

我们将列表 a 包装在 Rc<T> 中,以便当我们创建列表 bc 时,它们都可以引用 a,正如我们在 Listing 15-18 中所做的那样。

在我们创建了 abc 中的列表之后,我们想要将 value 中的值增加 10。我们通过调用 value 上的 borrow_mut 来实现这一点,它使用了我们在第 5 章中讨论的自动解引用功能(-> 运算符在哪里?”)来解引用 Rc<T> 到内部的 RefCell<T> 值。borrow_mut 方法返回一个 RefMut<T> 智能指针,我们使用解引用操作符来改变内部值。

当我们打印 abc 时,我们可以看到它们都有修改后的值 15 而不是 5

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.63s
     Running `target/debug/cons-list`
a after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil))

这个技巧非常巧妙!通过使用 RefCell<T>,我们有一个表面上不可变的 List 值。但我们可以使用 RefCell<T> 上的方法来访问其内部可变性,以便在需要时修改我们的数据。借用规则的运行时检查保护我们免受数据竞争的影响,有时为了数据结构的灵活性而牺牲一点速度是值得的。请注意,RefCell<T> 不适用于多线程代码!Mutex<T>RefCell<T> 的线程安全版本,我们将在第 16 章讨论 Mutex<T>

引用循环会导致内存泄漏

Rust 的内存安全保证使得意外创建永远不会被清理的内存(称为 内存泄漏)变得困难,但并非不可能。完全防止内存泄漏并不是 Rust 的保证之一,这意味着 Rust 中的内存泄漏是内存安全的。我们可以通过使用 Rc<T>RefCell<T> 看到 Rust 允许内存泄漏:可以创建循环引用,其中项目相互引用。这会导致内存泄漏,因为循环中每个项目的引用计数永远不会达到 0,因此这些值永远不会被丢弃。

创建引用循环

让我们看看引用循环是如何发生的以及如何防止它,从 List 枚举的定义和 tail 方法开始,如 Listing 15-25 所示。

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

fn main() {}

我们使用了 Listing 15-5 中 List 定义的另一个变体。Cons 变体中的第二个元素现在是 RefCell<Rc<List>>,这意味着我们希望在 Cons 变体中修改 List 值所指向的内容,而不是像在 Listing 15-24 中那样修改 i32 值。我们还添加了一个 tail 方法,以便在拥有 Cons 变体时方便地访问第二个项目。

在 Listing 15-26 中,我们添加了一个 main 函数,它使用了 Listing 15-25 中的定义。这段代码在 a 中创建了一个列表,并在 b 中创建了一个指向 a 中列表的列表。然后它修改 a 中的列表以指向 b,从而创建了一个引用循环。在这个过程中,有 println! 语句显示各个点的引用计数。

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

fn main() {
    let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));

    println!("a initial rc count = {}", Rc::strong_count(&a));
    println!("a next item = {:?}", a.tail());

    let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));

    println!("a rc count after b creation = {}", Rc::strong_count(&a));
    println!("b initial rc count = {}", Rc::strong_count(&b));
    println!("b next item = {:?}", b.tail());

    if let Some(link) = a.tail() {
        *link.borrow_mut() = Rc::clone(&b);
    }

    println!("b rc count after changing a = {}", Rc::strong_count(&b));
    println!("a rc count after changing a = {}", Rc::strong_count(&a));

    // Uncomment the next line to see that we have a cycle;
    // it will overflow the stack.
    // println!("a next item = {:?}", a.tail());
}

我们创建了一个 Rc<List> 实例,其中包含一个 List 值,存储在变量 a 中,初始列表为 5, Nil。然后我们创建了另一个 Rc<List> 实例,其中包含另一个 List 值,存储在变量 b 中,该值包含 10 并指向 a 中的列表。

我们修改 a 使其指向 b 而不是 Nil,从而创建了一个循环。我们通过使用 tail 方法获取 aRefCell<Rc<List>> 的引用,并将其存储在变量 link 中。然后我们使用 RefCell<Rc<List>> 上的 borrow_mut 方法将内部的值从包含 Nil 值的 Rc<List> 更改为 b 中的 Rc<List>

当我们运行这段代码时,暂时将最后一个 println! 注释掉,我们将得到以下输出:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.53s
     Running `target/debug/cons-list`
a initial rc count = 1
a next item = Some(RefCell { value: Nil })
a rc count after b creation = 2
b initial rc count = 1
b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b rc count after changing a = 2
a rc count after changing a = 2

在我们修改 a 中的列表以指向 b 之后,ab 中的 Rc<List> 实例的引用计数都为 2。在 main 函数的末尾,Rust 丢弃了变量 b,这将 bRc<List> 实例的引用计数从 2 减少到 1。此时,Rc<List> 在堆上的内存不会被丢弃,因为它的引用计数是 1,而不是 0。然后 Rust 丢弃 a,这将 aRc<List> 实例的引用计数从 2 减少到 1。这个实例的内存也不能被丢弃,因为另一个 Rc<List> 实例仍然引用它。分配给列表的内存将永远无法被回收。为了可视化这个引用循环,我们创建了图 15-4 中的图表。

Reference cycle of lists

图 15-4: 列表 ab 相互指向的引用循环

如果你取消注释最后一个 println! 并运行程序,Rust 将尝试打印这个循环,a 指向 bb 指向 a,依此类推,直到栈溢出。

与真实世界的程序相比,在这个示例中创建引用循环的后果并不是非常严重:在我们创建引用循环后,程序立即结束。然而,如果一个更复杂的程序在循环中分配了大量内存并长时间持有它,程序将使用比所需更多的内存,并可能使系统不堪重负,导致可用内存耗尽。

创建引用循环并不容易,但也不是不可能的。如果你有包含 Rc<T> 值的 RefCell<T> 值或类似的内部可变性和引用计数的嵌套组合类型,你必须确保不会创建循环;你不能依赖 Rust 来捕获它们。创建引用循环将是程序中的逻辑错误,你应该使用自动化测试、代码审查和其他软件开发实践来最小化这种错误。

另一种避免引用循环的解决方案是重新组织你的数据结构,使某些引用表示所有权,而某些引用不表示所有权。因此,你可以拥有由一些所有权关系和一些非所有权关系组成的循环,只有所有权关系会影响值是否可以被丢弃。在 Listing 15-25 中,我们总是希望 Cons 变体拥有它们的列表,因此重新组织数据结构是不可能的。让我们看一个使用由父节点和子节点组成的图的示例,看看非所有权关系何时是防止引用循环的适当方式。

使用 Weak<T> 防止引用循环

到目前为止,我们已经展示了调用 Rc::clone 会增加 Rc<T> 实例的 strong_count,并且只有当 strong_count 为 0 时,Rc<T> 实例才会被清理。你还可以通过调用 Rc::downgrade 并传递一个 Rc<T> 的引用来创建对 Rc<T> 实例中值的 弱引用。强引用是你共享 Rc<T> 实例所有权的方式。弱引用不表示所有权关系,它们的计数不会影响 Rc<T> 实例何时被清理。它们不会导致引用循环,因为一旦涉及的值的强引用计数为 0,任何涉及一些弱引用的循环都会被打破。

当你调用 Rc::downgrade 时,你会得到一个类型为 Weak<T> 的智能指针。调用 Rc::downgrade 不会将 Rc<T> 实例中的 strong_count 增加 1,而是将 weak_count 增加 1。Rc<T> 类型使用 weak_count 来跟踪有多少 Weak<T> 引用存在,类似于 strong_count。不同的是,weak_count 不需要为 0 才能清理 Rc<T> 实例。

因为 Weak<T> 引用的值可能已经被丢弃,所以要对 Weak<T> 指向的值做任何事情,你必须确保该值仍然存在。通过调用 Weak<T> 实例上的 upgrade 方法来实现这一点,该方法将返回一个 Option<Rc<T>>。如果 Rc<T> 值尚未被丢弃,你将得到一个 Some 结果;如果 Rc<T> 值已被丢弃,你将得到一个 None 结果。因为 upgrade 返回一个 Option<Rc<T>>,Rust 将确保处理 SomeNone 的情况,并且不会出现无效指针。

作为一个示例,我们将创建一个树,其项目知道它们的子项目 它们的父项目,而不是使用一个项目只知道下一个项目的列表。

创建一个树数据结构:一个带有子节点的 Node

首先,我们将构建一个树,其中的节点知道它们的子节点。我们将创建一个名为 Node 的结构体,它持有自己的 i32 值以及对其子节点 Node 值的引用:

文件名: src/main.rs

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}

我们希望 Node 拥有其子节点,并且我们希望与变量共享该所有权,以便我们可以直接访问树中的每个 Node。为此,我们将 Vec<T> 项定义为 Rc<Node> 类型的值。我们还希望修改哪些节点是另一个节点的子节点,因此我们在 children 中有一个 RefCell<T> 包裹着 Vec<Rc<Node>>

接下来,我们将使用我们的结构体定义并创建一个名为 leafNode 实例,其值为 3 且没有子节点,以及另一个名为 branch 的实例,其值为 5leaf 作为其子节点之一,如 Listing 15-27 所示。

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}

我们克隆 leaf 中的 Rc<Node> 并将其存储在 branch 中,这意味着 leaf 中的 Node 现在有两个所有者:leafbranch。我们可以通过 branch.childrenbranch 访问 leaf,但没有办法从 leaf 访问 branch。原因是 leaf 没有对 branch 的引用,也不知道它们有关系。我们希望 leaf 知道 branch 是它的父节点。我们接下来会这样做。

从子节点添加对父节点的引用

为了使子节点知道其父节点,我们需要在我们的 Node 结构体定义中添加一个 parent 字段。问题在于决定 parent 的类型应该是什么。我们知道它不能包含 Rc<T>,因为这将创建一个引用循环,leaf.parent 指向 branchbranch.children 指向 leaf,这将导致它们的 strong_count 值永远不会为 0。

从另一个角度考虑关系,父节点应该拥有其子节点:如果父节点被丢弃,其子节点也应该被丢弃。然而,子节点不应该拥有其父节点:如果我们丢弃一个子节点,父节点应该仍然存在。这是一个弱引用的情况!

因此,我们将 parent 的类型改为使用 Weak<T>,具体来说是一个 RefCell<Weak<Node>>。现在我们的 Node 结构体定义如下:

文件名: src/main.rs

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}

一个节点将能够引用其父节点,但不拥有其父节点。在 Listing 15-28 中,我们更新 main 以使用这个新定义,以便 leaf 节点将有一种方式引用其父节点 branch

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}

创建 leaf 节点看起来与 Listing 15-27 类似,除了 parent 字段:leaf 开始时没有父节点,因此我们创建一个新的空的 Weak<Node> 引用实例。

此时,当我们尝试通过使用 upgrade 方法获取 leaf 的父节点的引用时,我们得到一个 None 值。我们在第一个 println! 语句的输出中看到这一点:

leaf parent = None

当我们创建 branch 节点时,它也会在 parent 字段中有一个新的 Weak<Node> 引用,因为 branch 没有父节点。我们仍然将 leaf 作为 branch 的子节点之一。一旦我们有了 branch 中的 Node 实例,我们就可以修改 leaf 以赋予它一个 Weak<Node> 引用指向其父节点。我们使用 leafparent 字段中的 RefCell<Weak<Node>> 上的 borrow_mut 方法,然后我们使用 Rc::downgrade 函数从 branch 中的 Rc<Node> 创建一个 Weak<Node> 引用指向 branch

当我们再次打印 leaf 的父节点时,这次我们将得到一个 Some 变体,其中包含 branch:现在 leaf 可以访问其父节点了!当我们打印 leaf 时,我们也避免了像在 Listing 15-26 中那样最终导致栈溢出的循环;Weak<Node> 引用被打印为 (Weak)

leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) },
children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) },
children: RefCell { value: [] } }] } })

没有无限输出表明这段代码没有创建引用循环。我们还可以通过查看调用 Rc::strong_countRc::weak_count 得到的值来判断这一点。

可视化 strong_countweak_count 的变化

让我们通过创建一个新的内部作用域并将 branch 的创建移到该作用域中,来看看 Rc<Node> 实例的 strong_countweak_count 值是如何变化的。通过这样做,我们可以看到当 branch 被创建并在它超出作用域时被丢弃时会发生什么。修改如 Listing 15-29 所示。

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );

    {
        let branch = Rc::new(Node {
            value: 5,
            parent: RefCell::new(Weak::new()),
            children: RefCell::new(vec![Rc::clone(&leaf)]),
        });

        *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

        println!(
            "branch strong = {}, weak = {}",
            Rc::strong_count(&branch),
            Rc::weak_count(&branch),
        );

        println!(
            "leaf strong = {}, weak = {}",
            Rc::strong_count(&leaf),
            Rc::weak_count(&leaf),
        );
    }

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );
}

leaf 创建后,它的 Rc<Node> 的强引用计数为 1,弱引用计数为 0。在内部作用域中,我们创建 branch 并将其与 leaf 关联,此时当我们打印计数时,branch 中的 Rc<Node> 的强引用计数为 1,弱引用计数为 1(因为 leaf.parent 指向 branch,使用 Weak<Node>)。当我们打印 leaf 的计数时,我们将看到它的强引用计数为 2,因为 branch 现在有一个存储在 branch.children 中的 leafRc<Node> 的克隆,但弱引用计数仍为 0。

当内部作用域结束时,branch 超出作用域,Rc<Node> 的强引用计数减少到 0,因此它的 Node 被丢弃。leaf.parent 的弱引用计数为 1 并不影响 Node 是否被丢弃,因此我们不会出现任何内存泄漏!

如果我们在作用域结束后尝试访问 leaf 的父节点,我们将再次得到 None。在程序结束时,leaf 中的 Rc<Node> 的强引用计数为 1,弱引用计数为 0,因为变量 leaf 现在再次是 Rc<Node> 的唯一引用。

所有管理计数和值丢弃的逻辑都内置在 Rc<T>Weak<T> 以及它们的 Drop trait 实现中。通过在 Node 的定义中指定从子节点到父节点的关系应该是 Weak<T> 引用,你能够拥有父节点指向子节点和子节点指向父节点的关系,而不会创建引用循环和内存泄漏。

总结

本章介绍了如何使用智能指针来做出与 Rust 默认使用常规引用不同的保证和权衡。Box<T> 类型具有已知的大小并指向堆上分配的数据。Rc<T> 类型跟踪堆上数据的引用计数,以便数据可以有多个所有者。RefCell<T> 类型通过其内部可变性为我们提供了一个类型,当我们需要一个不可变类型但需要更改该类型的内部值时可以使用它;它还在运行时而不是编译时强制执行借用规则。

还讨论了 DerefDrop trait,它们启用了智能指针的许多功能。我们探讨了可能导致内存泄漏的引用循环以及如何使用 Weak<T> 防止它们。

如果本章引起了你的兴趣,并且你想实现自己的智能指针,请查看 “The Rustonomicon” 以获取更多有用的信息。

接下来,我们将讨论 Rust 中的并发性。你甚至会学到一些新的智能指针。

无畏并发

安全高效地处理并发编程是 Rust 的另一个主要目标。并发编程,即程序的不同部分独立执行,以及并行编程,即程序的不同部分同时执行,随着越来越多的计算机利用其多处理器的优势,变得越来越重要。历史上,在这些上下文中的编程一直很困难且容易出错。Rust 希望改变这一点。

最初,Rust 团队认为确保内存安全和防止并发问题是两个需要不同方法解决的独立挑战。随着时间的推移,团队发现所有权和类型系统是帮助管理内存安全并发问题的一套强大工具!通过利用所有权和类型检查,许多并发错误在 Rust 中是编译时错误,而不是运行时错误。因此,与其让你花费大量时间尝试重现运行时并发错误发生的确切情况,错误的代码将拒绝编译并显示解释问题的错误。因此,你可以在编写代码时修复它,而不是在代码已经发布到生产环境后才可能修复。我们将 Rust 的这一方面称为无畏并发。无畏并发允许你编写没有细微错误的代码,并且易于重构而不会引入新的错误。

注意:为了简单起见,我们将许多问题称为并发,而不是更精确地说并发和/或并行。在本章中,请在我们使用并发时,心理上替换为并发和/或并行。在下一章中,当区别更为重要时,我们会更加具体。

许多语言对它们提供的处理并发问题的解决方案持教条态度。例如,Erlang 在消息传递并发方面具有优雅的功能,但在线程之间共享状态的方式却晦涩难懂。对于高级语言来说,仅支持可能的解决方案的一个子集是合理的策略,因为高级语言承诺通过放弃一些控制来获得抽象的好处。然而,低级语言期望在任何给定情况下提供最佳性能的解决方案,并且对硬件的抽象较少。因此,Rust 提供了多种工具,以适合你情况和需求的方式对问题进行建模。

以下是本章将涵盖的主题:

  • 如何创建线程以同时运行多个代码片段
  • 消息传递并发,其中通道在线程之间发送消息
  • 共享状态并发,其中多个线程可以访问某些数据
  • SyncSend trait,它们将 Rust 的并发保证扩展到用户定义的类型以及标准库提供的类型

使用线程同时运行代码

在大多数现代操作系统中,执行的程序代码运行在一个进程中,操作系统会同时管理多个进程。在一个程序中,你也可以有独立的部分同时运行。运行这些独立部分的功能称为线程。例如,一个 Web 服务器可以有多个线程,以便能够同时响应多个请求。

将程序中的计算拆分为多个线程以同时运行多个任务可以提高性能,但也会增加复杂性。由于线程可以同时运行,因此无法保证不同线程上的代码部分的执行顺序。这可能会导致以下问题:

  • 竞态条件:线程以不一致的顺序访问数据或资源。
  • 死锁:两个线程互相等待,导致两个线程都无法继续执行。
  • 难以复现和修复的 bug:某些情况下才会发生的 bug,难以可靠地复现和修复。

Rust 试图减轻使用线程的负面影响,但在多线程上下文中编程仍然需要仔细思考,并且需要一个与单线程程序中不同的代码结构。

编程语言以几种不同的方式实现线程,许多操作系统提供了语言可以调用的 API 来创建新线程。Rust 标准库使用1:1线程实现模型,即程序为每个语言线程使用一个操作系统线程。有一些 crate 实现了其他线程模型,这些模型在 1:1 模型的基础上做出了不同的权衡。(Rust 的异步系统,我们将在下一章中看到,也提供了另一种并发方法。)

使用 spawn 创建新线程

要创建一个新线程,我们调用 thread::spawn 函数并传递一个闭包(我们在第 13 章讨论过闭包),闭包中包含我们希望在新线程中运行的代码。Listing 16-1 中的示例在主线程中打印一些文本,并在新线程中打印其他文本:

use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

请注意,当 Rust 程序的主线程完成时,所有生成的线程都会被关闭,无论它们是否已完成运行。这个程序的输出可能每次都会有所不同,但看起来会类似于以下内容:

hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!

thread::sleep 的调用会强制线程停止执行一小段时间,从而允许其他线程运行。线程可能会轮流执行,但这并不能保证:这取决于操作系统如何调度线程。在这个运行中,主线程首先打印,尽管生成线程的打印语句在代码中首先出现。而且,尽管我们告诉生成线程打印直到 i9,但它只打印到 5,主线程就关闭了。

如果你运行此代码并只看到主线程的输出,或者没有看到任何重叠,请尝试增加范围中的数字,以创建更多机会让操作系统在线程之间切换。

使用 join 句柄等待所有线程完成

Listing 16-1 中的代码不仅由于主线程结束而大多数情况下会提前停止生成线程,而且由于无法保证线程运行的顺序,我们也无法保证生成线程会运行!

我们可以通过将 thread::spawn 的返回值保存在一个变量中来修复生成线程不运行或提前结束的问题。thread::spawn 的返回类型是 JoinHandle<T>JoinHandle<T> 是一个拥有值,当我们对其调用 join 方法时,它将等待其线程完成。Listing 16-2 展示了如何使用我们在 Listing 16-1 中创建的线程的 JoinHandle<T>,并调用 join 以确保生成线程在 main 退出之前完成。

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}

在句柄上调用 join 会阻塞当前运行的线程,直到句柄所代表的线程终止。阻塞一个线程意味着该线程被阻止执行工作或退出。因为我们将 join 的调用放在了主线程的 for 循环之后,运行 Listing 16-2 应该会产生类似于以下的输出:

hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!

两个线程继续交替执行,但主线程由于调用了 handle.join() 而等待,直到生成线程完成后才会结束。

但是,让我们看看如果我们将 handle.join() 移到 main 中的 for 循环之前会发生什么,如下所示:

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    handle.join().unwrap();

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

主线程将等待生成线程完成,然后运行其 for 循环,因此输出将不再交错,如下所示:

hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!

一些小细节,比如 join 的调用位置,可能会影响你的线程是否同时运行。

在线程中使用 move 闭包

我们经常将 move 关键字与传递给 thread::spawn 的闭包一起使用,因为闭包将获取其使用环境中的值的所有权,从而将这些值的所有权从一个线程转移到另一个线程。在第 13 章的“使用闭包捕获环境”中,我们讨论了 move 在闭包上下文中的使用。现在,我们将更多地关注 movethread::spawn 之间的交互。

请注意,在 Listing 16-1 中,我们传递给 thread::spawn 的闭包没有参数:我们没有在主线程中使用任何数据来生成线程的代码。要在生成线程中使用主线程中的数据,生成线程的闭包必须捕获它需要的值。Listing 16-3 展示了尝试在主线程中创建一个向量并在生成线程中使用它。然而,这还不会起作用,你马上就会看到。

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}

闭包使用了 v,因此它将捕获 v 并使其成为闭包环境的一部分。因为 thread::spawn 在新线程中运行此闭包,我们应该能够在该新线程中访问 v。但是当我们编译这个示例时,会得到以下错误:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Here's a vector: {v:?}");
  |                                     - `v` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:18
  |
6 |       let handle = thread::spawn(|| {
  |  __________________^
7 | |         println!("Here's a vector: {v:?}");
8 | |     });
  | |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

For more information about this error, try `rustc --explain E0373`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Rust 推断如何捕获 v,因为 println! 只需要 v 的引用,闭包尝试借用 v。然而,有一个问题:Rust 无法知道生成线程将运行多长时间,因此它不知道 v 的引用是否始终有效。

Listing 16-4 提供了一个更有可能出现 v 的引用无效的场景:

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    drop(v); // oh no!

    handle.join().unwrap();
}

如果 Rust 允许我们运行此代码,生成线程可能会立即被放到后台而不运行。生成线程内部有一个对 v 的引用,但主线程立即使用我们在第 15 章讨论的 drop 函数丢弃了 v。然后,当生成线程开始执行时,v 不再有效,因此对它的引用也无效。哦不!

要修复 Listing 16-3 中的编译器错误,我们可以使用错误消息的建议:

help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

通过在闭包前添加 move 关键字,我们强制闭包获取它使用的值的所有权,而不是让 Rust 推断它应该借用这些值。Listing 16-5 中显示的 Listing 16-3 的修改将按我们的意图编译并运行。

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}

我们可能会尝试用同样的方法来修复 Listing 16-4 中的代码,其中主线程调用了 drop,使用 move 闭包。然而,这个修复方法不会奏效,因为 Listing 16-4 试图做的事情由于不同的原因而被禁止。如果我们在闭包中添加 move,我们将 v 移动到闭包的环境中,并且我们不能再在主线程中调用 drop。我们会得到以下编译器错误:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0382]: use of moved value: `v`
  --> src/main.rs:10:10
   |
4  |     let v = vec![1, 2, 3];
   |         - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
5  |
6  |     let handle = thread::spawn(move || {
   |                                ------- value moved into closure here
7  |         println!("Here's a vector: {v:?}");
   |                                     - variable moved due to use in closure
...
10 |     drop(v); // oh no!
   |          ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Rust 的所有权规则再次拯救了我们!我们在 Listing 16-3 中得到的错误是因为 Rust 保守地只借用 v 给线程,这意味着主线程理论上可能会使生成线程的引用无效。通过告诉 Rust 将 v 的所有权移动到生成线程,我们向 Rust 保证主线程不会再使用 v。如果我们以同样的方式修改 Listing 16-4,当我们尝试在主线程中使用 v 时,我们就违反了所有权规则。move 关键字覆盖了 Rust 保守的默认借用行为;它不允许我们违反所有权规则。

现在我们已经介绍了线程是什么以及线程 API 提供的方法,让我们看看一些可以使用线程的场景。

使用消息传递在线程间传输数据

一种越来越流行的确保安全并发的方式是消息传递,即线程或参与者通过相互发送包含数据的消息来进行通信。以下是来自 Go 语言文档 的一句口号:“不要通过共享内存来通信;相反,通过通信来共享内存。”

为了实现消息发送并发,Rust 的标准库提供了通道的实现。通道 是一种通用的编程概念,数据通过它从一个线程发送到另一个线程。

你可以将编程中的通道想象成一条有方向的水道,比如溪流或河流。如果你将像橡皮鸭这样的东西放入河流中,它会顺流而下,最终到达水道的尽头。

通道有两个部分:发送端和接收端。发送端是上游位置,你将橡皮鸭放入河流的地方,而接收端是橡皮鸭最终到达的下游位置。代码的一部分调用发送端的方法来发送数据,另一部分检查接收端以获取到达的消息。如果发送端或接收端中的任何一个被丢弃,通道就被认为是关闭的。

在这里,我们将逐步构建一个程序,其中一个线程生成值并通过通道发送它们,另一个线程接收这些值并打印出来。我们将使用通道在线程之间发送简单的值来说明这一特性。一旦你熟悉了这项技术,你可以将通道用于任何需要相互通信的线程,例如聊天系统或一个系统中多个线程执行部分计算并将结果发送到一个线程进行汇总。

首先,在 Listing 16-6 中,我们将创建一个通道,但不对它做任何操作。请注意,这还不会编译,因为 Rust 无法确定我们希望通过通道发送什么类型的值。

文件名: src/main.rs

use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
}

Listing 16-6: 创建一个通道并将其两端分配给 txrx

我们使用 mpsc::channel 函数创建一个新通道;mpsc 代表多生产者,单消费者。简而言之,Rust 标准库实现通道的方式意味着一个通道可以有多个发送端来产生值,但只有一个接收端来消费这些值。想象一下多条溪流汇入一条大河:任何溪流中发送的东西最终都会汇入一条河流。我们现在从一个生产者开始,但当我们让这个例子工作时,我们会添加多个生产者。

mpsc::channel 函数返回一个元组,第一个元素是发送端——发送器,第二个元素是接收端——接收器。缩写 txrx 传统上在许多领域中分别用于表示发送器接收器,因此我们这样命名变量以指示每一端。我们使用带有模式的 let 语句来解构元组;我们将在第 19 章讨论 let 语句中模式的使用和解构。现在,知道使用 let 语句这种方式是提取 mpsc::channel 返回的元组部分的便捷方法。

让我们将发送端移动到一个生成的线程中,并让它发送一个字符串,这样生成的线程就可以与主线程通信,如 Listing 16-7 所示。这就像将橡皮鸭放入上游的河流中,或者从一个线程发送聊天消息到另一个线程。

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });
}

再次,我们使用 thread::spawn 创建一个新线程,然后使用 movetx 移动到闭包中,这样生成的线程就拥有了 tx。生成的线程需要拥有发送器才能通过通道发送消息。

发送器有一个 send 方法,它接受我们想要发送的值。send 方法返回一个 Result<T, E> 类型,因此如果接收器已经被丢弃并且没有地方发送值,发送操作将返回一个错误。在这个例子中,我们调用 unwrap 以便在出现错误时 panic。但在实际应用中,我们会正确处理它:回到第 9 章回顾正确的错误处理策略。

在 Listing 16-8 中,我们将在主线程中从接收器获取值。这就像从河流的尽头取出橡皮鸭,或者接收一条聊天消息。

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("Got: {received}");
}

接收器有两个有用的方法:recvtry_recv。我们使用 recv,它是接收的缩写,它将阻塞主线程的执行并等待直到有值通过通道发送。一旦有值发送,recv 将在一个 Result<T, E> 中返回它。当发送器关闭时,recv 将返回一个错误,表示不再有值到来。

try_recv 方法不会阻塞,而是立即返回一个 Result<T, E>:如果有消息可用,则返回一个包含消息的 Ok 值,如果这次没有消息,则返回一个 Err 值。如果这个线程在等待消息时有其他工作要做,使用 try_recv 是有用的:我们可以编写一个循环,每隔一段时间调用 try_recv,如果有消息则处理它,否则做其他工作一段时间,直到再次检查。

在这个例子中,我们为了简单起见使用了 recv;主线程除了等待消息外没有其他工作要做,因此阻塞主线程是合适的。

当我们运行 Listing 16-8 中的代码时,我们将看到从主线程打印的值:

Got: hi

完美!

通道和所有权转移

所有权规则在消息发送中起着至关重要的作用,因为它们帮助你编写安全的并发代码。防止并发编程中的错误是在整个 Rust 程序中考虑所有权的优势。让我们做一个实验来展示通道和所有权如何一起工作以防止问题:我们将尝试在生成的线程中使用一个 val 值,我们将其发送到通道之后。尝试编译 Listing 16-9 中的代码,看看为什么这段代码不被允许。

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
        println!("val is {val}");
    });

    let received = rx.recv().unwrap();
    println!("Got: {received}");
}

在这里,我们尝试在通过 tx.sendval 发送到通道后打印它。允许这样做将是一个坏主意:一旦值被发送到另一个线程,该线程可能会在我们尝试再次使用该值之前修改或丢弃它。潜在情况下,另一个线程的修改可能会导致错误或由于数据不一致或不存在而产生意外结果。然而,如果我们尝试编译 Listing 16-9 中的代码,Rust 会给我们一个错误:

$ cargo run
   Compiling message-passing v0.1.0 (file:///projects/message-passing)
error[E0382]: borrow of moved value: `val`
  --> src/main.rs:10:26
   |
8  |         let val = String::from("hi");
   |             --- move occurs because `val` has type `String`, which does not implement the `Copy` trait
9  |         tx.send(val).unwrap();
   |                 --- value moved here
10 |         println!("val is {val}");
   |                          ^^^^^ value borrowed here after move
   |
   = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0382`.
error: could not compile `message-passing` (bin "message-passing") due to 1 previous error

我们的并发错误导致了编译时错误。send 函数获取其参数的所有权,当值被移动时,接收器获取它的所有权。这阻止了我们在发送值后意外地再次使用它;所有权系统检查一切是否正常。

发送多个值并观察接收器等待

Listing 16-8 中的代码编译并运行了,但它没有清楚地展示两个独立的线程通过通道进行通信。在 Listing 16-10 中,我们做了一些修改,将证明 Listing 16-8 中的代码是并发运行的:生成的线程现在将发送多条消息,并在每条消息之间暂停一秒钟。

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {received}");
    }
}

这次,生成的线程有一个字符串向量,我们希望将其发送到主线程。我们遍历它们,逐个发送,并通过调用 thread::sleep 函数暂停一秒钟。

在主线程中,我们不再显式调用 recv 函数:相反,我们将 rx 视为一个迭代器。对于每个接收到的值,我们打印它。当通道关闭时,迭代将结束。

运行 Listing 16-10 中的代码时,你应该看到以下输出,每行之间有一秒钟的暂停:

Got: hi
Got: from
Got: the
Got: thread

因为我们在主线程的 for 循环中没有暂停或延迟的代码,我们可以看出主线程正在等待从生成的线程接收值。

通过克隆发送器创建多个生产者

之前我们提到 mpsc多生产者,单消费者的缩写。让我们使用 mpsc 并扩展 Listing 16-10 中的代码,创建多个线程,这些线程都向同一个接收器发送值。我们可以通过克隆发送器来实现这一点,如 Listing 16-11 所示。

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    // --snip--

    let (tx, rx) = mpsc::channel();

    let tx1 = tx.clone();
    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx1.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    thread::spawn(move || {
        let vals = vec![
            String::from("more"),
            String::from("messages"),
            String::from("for"),
            String::from("you"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {received}");
    }

    // --snip--
}

这次,在我们创建第一个生成的线程之前,我们在发送器上调用 clone。这将给我们一个新的发送器,我们可以将其传递给第一个生成的线程。我们将原始发送器传递给第二个生成的线程。这给了我们两个线程,每个线程向同一个接收器发送不同的消息。

当你运行代码时,你的输出应该看起来像这样:

Got: hi
Got: more
Got: from
Got: messages
Got: for
Got: the
Got: thread
Got: you

你可能会看到值的顺序不同,这取决于你的系统。这就是并发既有趣又困难的原因。如果你尝试在不同的线程中给 thread::sleep 不同的值,每次运行都会更加不确定,并产生不同的输出。

现在我们已经了解了通道的工作原理,让我们看看另一种并发方法。

共享状态的并发

消息传递是处理并发的一种好方法,但它并不是唯一的方法。另一种方法是让多个线程访问相同的共享数据。再次考虑 Go 语言文档中的口号部分:“不要通过共享内存来通信。”

通过共享内存进行通信会是什么样子?此外,为什么消息传递的爱好者会警告不要使用内存共享?

在某种程度上,任何编程语言中的通道都类似于单一所有权,因为一旦你将一个值传递到通道中,你就不应该再使用该值。共享内存的并发类似于多重所有权:多个线程可以同时访问相同的内存位置。正如你在第 15 章中看到的,智能指针使得多重所有权成为可能,多重所有权会增加复杂性,因为这些不同的所有者需要管理。Rust 的类型系统和所有权规则极大地帮助了正确管理这些所有者。举个例子,让我们看看互斥锁(Mutex),它是共享内存中更常见的并发原语之一。

使用互斥锁允许一次只有一个线程访问数据

Mutexmutual exclusion(互斥)的缩写,意思是互斥锁在任何给定时间只允许一个线程访问某些数据。要访问互斥锁中的数据,线程必须首先通过请求获取互斥锁的来发出信号。锁是互斥锁的一部分数据结构,用于跟踪当前谁拥有对数据的独占访问权限。因此,互斥锁被描述为通过锁定系统保护它所持有的数据。

互斥锁以难以使用而闻名,因为你必须记住两条规则:

  1. 在使用数据之前,你必须尝试获取锁。
  2. 当你使用完互斥锁保护的数据后,你必须解锁数据,以便其他线程可以获取锁。

对于互斥锁的现实比喻,想象一下会议上的小组讨论,只有一个麦克风。在小组成员发言之前,他们必须请求或发出信号表示他们想要使用麦克风。当他们拿到麦克风后,他们可以随心所欲地发言,然后将麦克风交给下一个请求发言的小组成员。如果一个小组成员在发言结束后忘记交出麦克风,其他人就无法发言。如果共享麦克风的管理出现问题,小组讨论将无法按计划进行!

互斥锁的管理可能非常棘手,这就是为什么很多人对通道如此热衷。然而,得益于 Rust 的类型系统和所有权规则,你不会在锁定和解锁上出错。

Mutex<T> 的 API

作为如何使用互斥锁的示例,让我们首先在单线程上下文中使用互斥锁,如 Listing 16-12 所示。

use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }

    println!("m = {m:?}");
}

与许多类型一样,我们使用关联函数 new 创建一个 Mutex<T>。要访问互斥锁中的数据,我们使用 lock 方法获取锁。这个调用会阻塞当前线程,直到轮到我们获取锁为止。

如果另一个持有锁的线程 panic 了,lock 调用将失败。在这种情况下,没有人能够获取锁,因此我们选择 unwrap,并让这个线程在这种情况下 panic。

在我们获取锁之后,我们可以将返回值(在本例中命名为 num)视为对内部数据的可变引用。类型系统确保我们在使用 m 中的值之前获取锁。m 的类型是 Mutex<i32>,而不是 i32,因此我们必须调用 lock 才能使用 i32 值。我们不能忘记;类型系统不会让我们以其他方式访问内部的 i32

正如你可能怀疑的那样,Mutex<T> 是一个智能指针。更准确地说,lock 调用返回一个名为 MutexGuard 的智能指针,它被包装在一个 LockResult 中,我们通过调用 unwrap 来处理它。MutexGuard 智能指针实现了 Deref 以指向我们的内部数据;智能指针还有一个 Drop 实现,当 MutexGuard 超出作用域时,它会自动释放锁,这发生在内部作用域结束时。因此,我们不会冒险忘记释放锁并阻止其他线程使用互斥锁,因为锁的释放是自动发生的。

在释放锁之后,我们可以打印互斥锁的值,并看到我们能够将内部的 i32 更改为 6。

在多个线程之间共享 Mutex<T>

现在让我们尝试使用 Mutex<T> 在多个线程之间共享一个值。我们将启动 10 个线程,并让每个线程将计数器值增加 1,因此计数器从 0 变为 10。Listing 16-13 中的示例将出现编译器错误,我们将使用该错误来了解更多关于使用 Mutex<T> 的知识,以及 Rust 如何帮助我们正确使用它。

use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];

    for _ in 0..10 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

我们创建一个 counter 变量来保存 Mutex<T> 中的 i32,就像我们在 Listing 16-12 中所做的那样。接下来,我们通过迭代一个数字范围来创建 10 个线程。我们使用 thread::spawn 并给所有线程相同的闭包:一个将 counter 移动到线程中,通过调用 lock 方法获取 Mutex<T> 的锁,然后将互斥锁中的值增加 1。当一个线程运行完它的闭包后,num 将超出作用域并释放锁,以便另一个线程可以获取它。

在主线程中,我们收集所有的 join 句柄。然后,就像我们在 Listing 16-2 中所做的那样,我们对每个句柄调用 join 以确保所有线程都完成。此时,主线程将获取锁并打印此程序的结果。

我们暗示这个示例不会编译。现在让我们找出原因!

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0382]: borrow of moved value: `counter`
  --> src/main.rs:21:29
   |
5  |     let counter = Mutex::new(0);
   |         ------- move occurs because `counter` has type `Mutex<i32>`, which does not implement the `Copy` trait
...
8  |     for _ in 0..10 {
   |     -------------- inside of this loop
9  |         let handle = thread::spawn(move || {
   |                                    ------- value moved into closure here, in previous iteration of loop
...
21 |     println!("Result: {}", *counter.lock().unwrap());
   |                             ^^^^^^^ value borrowed here after move
   |
help: consider moving the expression out of the loop so it is only moved once
   |
8  ~     let mut value = counter.lock();
9  ~     for _ in 0..10 {
10 |         let handle = thread::spawn(move || {
11 ~             let mut num = value.unwrap();
   |

For more information about this error, try `rustc --explain E0382`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error

错误消息指出,counter 值在上一次循环迭代中被移动了。Rust 告诉我们,我们不能将锁 counter 的所有权移动到多个线程中。让我们使用第 15 章中讨论的多重所有权方法来修复编译器错误。

多线程中的多重所有权

在第 15 章中,我们通过使用智能指针 Rc<T> 创建一个引用计数值来将值赋予多个所有者。让我们在这里做同样的事情,看看会发生什么。我们将在 Listing 16-14 中将 Mutex<T> 包装在 Rc<T> 中,并在将所有权移动到线程之前克隆 Rc<T>

use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Rc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Rc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

再次编译,我们得到了……不同的错误!编译器教会了我们很多。

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely
   --> src/main.rs:11:36
    |
11  |           let handle = thread::spawn(move || {
    |                        ------------- ^------
    |                        |             |
    |  ______________________|_____________within this `{closure@src/main.rs:11:36: 11:43}`
    | |                      |
    | |                      required by a bound introduced by this call
12  | |             let mut num = counter.lock().unwrap();
13  | |
14  | |             *num += 1;
15  | |         });
    | |_________^ `Rc<Mutex<i32>>` cannot be sent between threads safely
    |
    = help: within `{closure@src/main.rs:11:36: 11:43}`, the trait `Send` is not implemented for `Rc<Mutex<i32>>`
note: required because it's used within this closure
   --> src/main.rs:11:36
    |
11  |         let handle = thread::spawn(move || {
    |                                    ^^^^^^^
note: required by a bound in `spawn`
   --> file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/std/src/thread/mod.rs:731:8
    |
728 | pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    |        ----- required by a bound in this function
...
731 |     F: Send + 'static,
    |        ^^^^ required by this bound in `spawn`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error

哇,这个错误信息非常冗长!以下是需要关注的重要部分:`Rc<Mutex<i32>>` 不能安全地在线程之间发送。编译器还告诉我们原因:trait `Send` 没有为 `Rc<Mutex<i32>>` 实现。我们将在下一节讨论 Send:它是确保我们与线程一起使用的类型适用于并发情况的 trait 之一。

不幸的是,Rc<T> 不能安全地跨线程共享。当 Rc<T> 管理引用计数时,它会在每次调用 clone 时增加计数,并在每个克隆被丢弃时减少计数。但它没有使用任何并发原语来确保对计数的更改不会被另一个线程中断。这可能导致错误的计数——微妙的错误,进而可能导致内存泄漏或在我们使用完值之前将其丢弃。我们需要的是一个与 Rc<T> 完全相同的类型,但它以线程安全的方式更改引用计数。

使用 Arc<T> 进行原子引用计数

幸运的是,Arc<T> 是一个类似于 Rc<T> 的类型,可以在并发情况下安全使用。a 代表原子,意味着它是一个原子引用计数类型。原子是另一种并发原语,我们不会在这里详细讨论:有关更多详细信息,请参阅标准库文档中的 std::sync::atomic。在这一点上,你只需要知道原子像原始类型一样工作,但可以安全地跨线程共享。

你可能会想知道为什么所有原始类型都不是原子的,以及为什么标准库类型默认不使用 Arc<T>。原因是线程安全会带来性能损失,你只在你真正需要时才愿意支付。如果你只是在一个线程内对值执行操作,如果你的代码不必强制执行原子提供的保证,它可以运行得更快。

让我们回到我们的示例:Arc<T>Rc<T> 具有相同的 API,因此我们通过更改 use 行、new 调用和 clone 调用来修复我们的程序。Listing 16-15 中的代码最终将编译并运行。

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

此代码将打印以下内容:

Result: 10

我们做到了!我们从 0 数到 10,这可能看起来不是很令人印象深刻,但它教会了我们很多关于 Mutex<T> 和线程安全的知识。你也可以使用此程序的结构来执行比仅仅递增计数器更复杂的操作。使用这种策略,你可以将计算分成独立的部分,将这些部分分配到线程中,然后使用 Mutex<T> 让每个线程用其部分更新最终结果。

请注意,如果你正在执行简单的数值操作,标准库的 std::sync::atomic 模块 提供了比 Mutex<T> 更简单的类型。这些类型提供了对原始类型的安全、并发、原子访问。我们选择在此示例中使用 Mutex<T> 和原始类型,以便我们可以专注于 Mutex<T> 的工作原理。

RefCell<T>/Rc<T>Mutex<T>/Arc<T> 之间的相似之处

你可能已经注意到 counter 是不可变的,但我们可以获得对其内部值的可变引用;这意味着 Mutex<T> 提供了内部可变性,就像 Cell 系列一样。就像我们在第 15 章中使用 RefCell<T> 来允许我们在 Rc<T> 内部改变内容一样,我们使用 Mutex<T> 来改变 Arc<T> 内部的内容。

另一个需要注意的细节是,当你使用 Mutex<T> 时,Rust 无法保护你免受所有类型的逻辑错误。回想一下第 15 章,使用 Rc<T> 会带来创建引用循环的风险,其中两个 Rc<T> 值相互引用,导致内存泄漏。同样,Mutex<T> 会带来创建死锁的风险。当操作需要锁定两个资源并且两个线程各自获取了一个锁时,就会发生死锁,导致它们永远等待对方。如果你对死锁感兴趣,尝试创建一个有死锁的 Rust 程序;然后研究任何语言中互斥锁的死锁缓解策略,并尝试在 Rust 中实现它们。标准库 API 文档中的 Mutex<T>MutexGuard 提供了有用的信息。

我们将在本章的最后讨论 SendSync trait,以及如何将它们与自定义类型一起使用。

使用 SendSync Trait 实现可扩展的并发性

有趣的是,到目前为止,我们在本章中讨论的几乎所有并发特性都是标准库的一部分,而不是语言本身。处理并发的选择不仅限于语言或标准库;你可以编写自己的并发特性,或者使用其他人编写的特性。

然而,嵌入在语言中而不是标准库中的关键并发概念是 std::marker trait SendSync

使用 Send 允许在线程之间转移所有权

Send 标记 trait 表示实现了 Send 的类型的值的所有权可以在线程之间转移。几乎所有的 Rust 类型都是 Send,但有一些例外,包括 Rc<T>:它不能实现 Send,因为如果你克隆了一个 Rc<T> 值并尝试将克隆的所有权转移到另一个线程,两个线程可能会同时更新引用计数。因此,Rc<T> 被实现用于单线程场景,以避免线程安全的性能开销。

因此,Rust 的类型系统和 trait 约束确保你不会意外地不安全地将 Rc<T> 值跨线程发送。当我们在 Listing 16-14 中尝试这样做时,我们得到了错误 the trait Send is not implemented for Rc<Mutex<i32>>。当我们切换到实现了 SendArc<T> 时,代码编译通过。

任何完全由 Send 类型组成的类型也会自动标记为 Send。几乎所有原始类型都是 Send,除了裸指针,我们将在第 20 章讨论。

使用 Sync 允许多线程访问

Sync 标记 trait 表示实现了 Sync 的类型可以安全地从多个线程引用。换句话说,如果 &T(对 T 的不可变引用)实现了 Send,那么任何类型 T 都实现了 Sync,这意味着引用可以安全地发送到另一个线程。与 Send 类似,所有原始类型都实现了 Sync,完全由实现了 Sync 的类型组成的类型也实现了 Sync

智能指针 Rc<T> 也不实现 Sync,原因与它不实现 Send 相同。RefCell<T> 类型(我们在第 15 章讨论过)及其相关的 Cell<T> 类型家族也不实现 SyncRefCell<T> 在运行时进行的借用检查实现不是线程安全的。智能指针 Mutex<T> 实现了 Sync,可以用于在多个线程之间共享访问,正如你在 “Sharing a Mutex<T> Between Multiple Threads” 中看到的那样。

手动实现 SendSync 是不安全的

因为完全由实现了 SendSync trait 的其他类型组成的类型也会自动实现 SendSync,所以我们不必手动实现这些 trait。作为标记 trait,它们甚至没有任何方法需要实现。它们只是用于强制执行与并发相关的不变量。

手动实现这些 trait 涉及到实现不安全的 Rust 代码。我们将在第 20 章讨论使用不安全的 Rust 代码;现在,重要的信息是,构建不由 SendSync 部分组成的新的并发类型需要仔细思考以维护安全保证。“The Rustonomicon” 提供了更多关于这些保证以及如何维护它们的信息。

总结

这并不是你在本书中最后一次看到并发:下一章将专注于异步编程,而第 21 章的项目将在一个比这里讨论的小例子更现实的情况下使用本章的概念。

如前所述,由于 Rust 处理并发的方式很少是语言的一部分,许多并发解决方案都是作为 crate 实现的。这些解决方案比标准库发展得更快,因此请务必在线搜索当前最先进的 crate 以用于多线程场景。

Rust 标准库提供了用于消息传递的通道和智能指针类型,如 Mutex<T>Arc<T>,它们在并发上下文中使用是安全的。类型系统和借用检查器确保使用这些解决方案的代码不会出现数据竞争或无效引用。一旦你的代码编译通过,你可以放心它将在多个线程上愉快地运行,而不会出现其他语言中常见的难以追踪的错误。并发编程不再是一个令人恐惧的概念:勇敢地前进,让你的程序并发起来吧!

异步编程基础:Async、Await、Futures 和 Streams

我们让计算机执行的许多操作可能需要一段时间才能完成。如果我们能在等待这些长时间运行的操作完成的同时做其他事情,那将是非常好的。现代计算机提供了两种同时处理多个操作的技术:并行性和并发性。然而,一旦我们开始编写涉及并行或并发操作的程序,我们很快就会遇到_异步编程_固有的新挑战,即操作可能不会按照它们启动的顺序依次完成。本章在第16章使用线程进行并行和并发的基础上,介绍了一种替代的异步编程方法:Rust的Futures、Streams、支持它们的asyncawait语法,以及管理和协调异步操作的工具。

让我们考虑一个例子。假设你正在导出一个你创建的家庭庆祝视频,这个操作可能需要几分钟到几小时不等。视频导出将尽可能多地使用CPU和GPU资源。如果你只有一个CPU核心,并且你的操作系统在导出完成之前不会暂停它——也就是说,如果它以_同步_方式执行导出——那么在该任务运行时,你将无法在计算机上做其他任何事情。这将是一个非常令人沮丧的体验。幸运的是,你的计算机操作系统可以并且确实会频繁地中断导出,以便让你同时完成其他工作。

现在假设你正在下载别人分享的视频,这也可能需要一段时间,但不会占用那么多CPU时间。在这种情况下,CPU必须等待数据从网络到达。虽然你可以在数据开始到达时开始读取数据,但所有数据到达可能需要一些时间。即使所有数据都已到达,如果视频非常大,加载所有数据可能至少需要一两秒钟。这可能听起来不多,但对于现代处理器来说,这是一个非常长的时间,因为它每秒可以执行数十亿次操作。同样,你的操作系统会隐式地中断你的程序,以允许CPU在等待网络调用完成时执行其他工作。

视频导出是一个_CPU密集型_或_计算密集型_操作的例子。它受限于CPU或GPU内的潜在数据处理速度,以及它可以为该操作分配多少速度。视频下载是一个_IO密集型_操作的例子,因为它受限于计算机的_输入和输出_速度;它只能以数据通过网络发送的速度进行。

在这两个例子中,操作系统的隐式中断提供了一种并发性。然而,这种并发性只发生在整个程序的层面上:操作系统中断一个程序以让其他程序完成工作。在许多情况下,因为我们比操作系统更细致地理解我们的程序,所以我们可以发现操作系统看不到的并发机会。

例如,如果我们正在构建一个管理文件下载的工具,我们应该能够编写我们的程序,以便启动一个下载不会锁定UI,用户应该能够同时启动多个下载。然而,许多与网络交互的操作系统API是_阻塞_的;也就是说,它们会阻塞程序的进度,直到它们处理的数据完全准备好。

注意:如果你仔细想想,这就是_大多数_函数调用的工作方式。然而,术语_阻塞_通常保留用于与文件、网络或计算机上其他资源交互的函数调用,因为这些情况下,单个程序将从操作的_非_阻塞中受益。

我们可以通过为每个文件下载生成一个专用线程来避免阻塞主线程。然而,这些线程的开销最终会成为一个问题。如果调用一开始就不阻塞,那将更可取。如果我们能以与阻塞代码相同的直接风格编写代码,那也将更好,类似于这样:

let data = fetch_data_from(url).await;
println!("{data}");

这正是Rust的_async_(_异步_的缩写)抽象给我们的。在本章中,你将学习所有关于async的内容,我们将涵盖以下主题:

  • 如何使用Rust的asyncawait语法
  • 如何使用async模型解决我们在第16章中看到的相同挑战
  • 多线程和async如何提供互补的解决方案,在许多情况下可以结合使用

在我们看到async在实践中如何工作之前,我们需要稍微绕道讨论并行性和并发性之间的区别。

并行性和并发性

到目前为止,我们将并行性和并发性视为基本可以互换的。现在我们需要更精确地区分它们,因为在我们开始工作时,这些差异将会显现出来。

考虑团队在软件项目上分配工作的不同方式。你可以为单个成员分配多个任务,为每个成员分配一个任务,或者混合使用这两种方法。

当一个人在完成任何任务之前处理多个不同的任务时,这就是_并发性_。也许你在计算机上检查了两个不同的项目,当你在一个项目上感到无聊或卡住时,你会切换到另一个项目。你只是一个人,所以你不能同时在两个任务上取得进展,但你可以多任务处理,通过切换任务一次在一个任务上取得进展(见图17-1)。

一个带有标签为任务A和任务B的方框的图表,里面有代表子任务的菱形。有箭头从A1指向B1,B1指向A2,A2指向B2,B2指向A3,A3指向A4,A4指向B3。子任务之间的箭头在任务A和任务B之间的方框之间交叉。
图17-1:一个并发的工作流,在任务A和任务B之间切换

当团队通过让每个成员承担一个任务并单独工作时,这就是_并行性_。团队中的每个人可以同时取得进展(见图17-2)。

一个带有标签为任务A和任务B的方框的图表,里面有代表子任务的菱形。有箭头从A1指向A2,A2指向A3,A3指向A4,B1指向B2,B2指向B3。任务A和任务B之间的方框之间没有箭头交叉。
图17-2:一个并行的工作流,任务A和任务B上的工作独立进行

在这两种工作流中,你可能需要在不同任务之间进行协调。也许你_认为_分配给一个人的任务与团队中其他人的工作完全独立,但实际上它需要团队中的另一个人先完成他们的任务。有些工作可以并行完成,但有些工作实际上是_串行_的:它只能按顺序发生,一个接一个,如图17-3所示。

一个带有标签为任务A和任务B的方框的图表,里面有代表子任务的菱形。有箭头从A1指向A2,A2指向一对像“暂停”符号的粗垂直线,从该符号指向A3,B1指向B2,B2指向B3,B3位于该符号下方,B3指向A3,B3指向B4。
图17-3:一个部分并行的工作流,任务A和任务B上的工作独立进行,直到任务A3被任务B3的结果阻塞。

同样,你可能会意识到你的一个任务依赖于你的另一个任务。现在你的并发工作也变成了串行。

并行性和并发性也可以相互交叉。如果你了解到一个同事在你完成你的一个任务之前被卡住了,你可能会集中所有精力在该任务上以“解锁”你的同事。你和你的同事不再能够并行工作,你也不再能够并发地处理自己的任务。

同样的基本动态在软件和硬件中也适用。在具有单个CPU核心的机器上,CPU一次只能执行一个操作,但它仍然可以并发工作。使用线程、进程和async等工具,计算机可以暂停一个活动并切换到其他活动,最终再回到第一个活动。在具有多个CPU核心的机器上,它还可以并行工作。一个核心可以执行一个任务,而另一个核心执行一个完全不相关的任务,这些操作实际上同时发生。

在Rust中使用async时,我们总是在处理并发性。根据硬件、操作系统和我们使用的async运行时(稍后会详细介绍async运行时),这种并发性可能还会在底层使用并行性。

现在,让我们深入了解Rust中的异步编程实际上是如何工作的。

Futures 和 Async 语法

Rust 中异步编程的关键元素是 futures 和 Rust 的 asyncawait 关键字。

一个 future 是一个可能现在还没有准备好,但在未来的某个时刻会准备好的值。(这个概念在许多语言中都有出现,有时使用其他名称,如 taskpromise。)Rust 提供了一个 Future trait 作为构建块,以便不同的异步操作可以使用不同的数据结构实现,但具有共同的接口。在 Rust 中,futures 是实现 Future trait 的类型。每个 future 都持有关于已经取得的进展以及“准备好”意味着什么的信息。

你可以将 async 关键字应用于代码块和函数,以指定它们可以被中断和恢复。在 async 块或 async 函数中,你可以使用 await 关键字来 等待一个 future(即等待它变为准备好)。在 async 块或函数中等待 future 的任何地方都是该 async 块或函数可以暂停和恢复的潜在点。检查 future 以查看其值是否可用的过程称为 轮询

其他一些语言,如 C# 和 JavaScript,也使用 asyncawait 关键字进行异步编程。如果你熟悉这些语言,你可能会注意到 Rust 在处理方式上的一些显著差异,包括它如何处理语法。这是有充分理由的,我们稍后会看到!

在编写异步 Rust 时,我们大多数时候使用 asyncawait 关键字。Rust 将它们编译成使用 Future trait 的等效代码,就像它将 for 循环编译成使用 Iterator trait 的等效代码一样。由于 Rust 提供了 Future trait,你还可以在需要时为自己的数据类型实现它。我们将在本章中看到的许多函数返回具有自己 Future 实现的类型。我们将在本章末尾回到 trait 的定义,并深入探讨它的工作原理,但这些细节足以让我们继续前进。

这一切可能感觉有点抽象,所以让我们编写我们的第一个异步程序:一个小型网页抓取器。我们将从命令行传入两个 URL,并发地获取它们,并返回先完成的结果。这个例子会有一些新的语法,但不用担心——我们会逐步解释你需要知道的一切。

我们的第一个异步程序

为了将本章的重点放在学习异步而不是处理生态系统的各个部分上,我们创建了 trpl crate(trpl 是“The Rust Programming Language”的缩写)。它重新导出了你需要的所有类型、trait 和函数,主要来自 futurestokio crate。futures crate 是 Rust 异步代码实验的官方场所,实际上 Future trait 最初就是在这里设计的。Tokio 是当今 Rust 中最广泛使用的异步运行时,尤其是对于 Web 应用程序。还有其他优秀的运行时,它们可能更适合你的需求。我们在 trpl 中使用 tokio crate,因为它经过了良好的测试并被广泛使用。

在某些情况下,trpl 还会重命名或包装原始 API,以让你专注于本章相关的细节。如果你想了解 crate 的功能,我们鼓励你查看 其源代码。你将能够看到每个重新导出的 crate 来自哪里,并且我们留下了大量注释来解释 crate 的功能。

创建一个名为 hello-async 的新二进制项目,并将 trpl crate 添加为依赖项:

$ cargo new hello-async
$ cd hello-async
$ cargo add trpl

现在我们可以使用 trpl 提供的各种组件来编写我们的第一个异步程序。我们将构建一个小型命令行工具,它获取两个网页,从每个网页中提取 <title> 元素,并打印出先完成整个过程的页面的标题。

定义 page_title 函数

让我们首先编写一个函数,它接受一个页面 URL 作为参数,向它发出请求,并返回标题元素的文本(见 Listing 17-1)。

extern crate trpl; // required for mdbook test

fn main() {
    // TODO: we'll add this next!
}

use trpl::Html;

async fn page_title(url: &str) -> Option<String> {
    let response = trpl::get(url).await;
    let response_text = response.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}

首先,我们定义一个名为 page_title 的函数,并用 async 关键字标记它。然后我们使用 trpl::get 函数获取传入的 URL,并使用 await 关键字等待响应。为了获取响应的文本,我们调用它的 text 方法,并再次使用 await 关键字等待它。这两个步骤都是异步的。对于 get 函数,我们必须等待服务器发送回其响应的第一部分,其中包括 HTTP 头、cookie 等,并且可以与响应体分开传递。特别是如果响应体非常大,可能需要一些时间才能全部到达。因为我们必须等待 整个 响应到达,所以 text 方法也是异步的。

我们必须显式地等待这两个 futures,因为 Rust 中的 futures 是 惰性 的:它们在你使用 await 关键字要求它们之前不会做任何事情。(事实上,如果你不使用 future,Rust 会显示一个编译器警告。)这可能会让你想起第 13 章中关于迭代器的讨论,在 使用迭代器处理一系列项目 部分。迭代器在你调用它们的 next 方法之前不会做任何事情——无论是直接调用还是通过使用 for 循环或 map 等方法间接调用。同样,futures 在你明确要求它们之前也不会做任何事情。这种惰性允许 Rust 避免在真正需要之前运行异步代码。

注意:这与我们在前一章中使用 thread::spawn使用 spawn 创建新线程 中看到的行为不同,在那里我们传递给另一个线程的闭包立即开始运行。这也与许多其他语言处理异步的方式不同。但对于 Rust 来说,能够提供其性能保证是很重要的,就像它对迭代器所做的那样。

一旦我们有了 response_text,我们就可以使用 Html::parse 将其解析为 Html 类型的实例。现在我们有了一个数据类型,可以用来将 HTML 作为更丰富的数据结构进行处理。特别是,我们可以使用 select_first 方法来查找给定 CSS 选择器的第一个实例。通过传递字符串 "title",我们将获得文档中的第一个 <title> 元素(如果有的话)。因为可能没有任何匹配的元素,select_first 返回一个 Option<ElementRef>。最后,我们使用 Option::map 方法,它允许我们在 Option 中有项目时对其进行处理,如果没有则不做任何事情。(我们也可以在这里使用 match 表达式,但 map 更符合习惯。)在我们提供给 map 的函数体中,我们调用 title_elementinner_html 来获取其内容,这是一个 String。当一切完成后,我们得到了一个 Option<String>

请注意,Rust 的 await 关键字位于你正在等待的表达式 之后,而不是之前。也就是说,它是一个 后缀 关键字。如果你在其他语言中使用过 async,这可能会与你习惯的不同,但在 Rust 中,它使得方法链更加容易使用。因此,我们可以将 page_url_for 的函数体更改为将 trpl::gettext 函数调用与 await 链接在一起,如 Listing 17-2 所示。

extern crate trpl; // required for mdbook test

use trpl::Html;

fn main() {
    // TODO: we'll add this next!
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}

这样,我们就成功编写了我们的第一个异步函数!在我们添加一些代码到 main 中调用它之前,让我们再讨论一下我们编写的内容及其含义。

当 Rust 看到一个用 async 关键字标记的块时,它会将其编译成一个独特的、匿名的数据类型,该类型实现了 Future trait。当 Rust 看到一个用 async 标记的函数时,它会将其编译成一个非异步函数,其主体是一个异步块。异步函数的返回类型是编译器为该异步块创建的匿名数据类型的类型。

因此,编写 async fn 等同于编写一个返回 future 的函数。对于编译器来说,像 Listing 17-1 中的 async fn page_title 这样的函数定义等同于一个非异步函数,定义如下:

#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test
use std::future::Future;
use trpl::Html;

fn page_title(url: &str) -> impl Future<Output = Option<String>> {
    async move {
        let text = trpl::get(url).await.text().await;
        Html::parse(&text)
            .select_first("title")
            .map(|title| title.inner_html())
    }
}
}

让我们逐步了解转换后的版本的每个部分:

  • 它使用了我们在第 10 章 “Traits as Parameters” 部分讨论的 impl Trait 语法。
  • 返回的 trait 是一个 Future,其关联类型为 Output。请注意,Output 类型是 Option<String>,这与 async fn 版本的 page_title 的原始返回类型相同。
  • 原始函数体中调用的所有代码都包装在一个 async move 块中。记住,块是表达式。这个整个块是从函数返回的表达式。
  • 这个异步块生成一个类型为 Option<String> 的值,如前所述。该值与返回类型中的 Output 类型匹配。这就像你见过的其他块一样。
  • 新的函数体是一个 async move 块,因为它使用了 url 参数。(我们将在本章后面更多地讨论 asyncasync move。)

现在我们可以在 main 中调用 page_title

确定单个页面的标题

首先,我们只获取一个页面的标题。在 Listing 17-3 中,我们遵循与第 12 章中相同的模式,在 接受命令行参数 部分获取命令行参数。然后我们将第一个 URL 传递给 page_title 并等待结果。因为 future 生成的值是一个 Option<String>,所以我们使用 match 表达式来打印不同的消息,以考虑页面是否有 <title>

extern crate trpl; // required for mdbook test

use trpl::Html;

async fn main() {
    let args: Vec<String> = std::env::args().collect();
    let url = &args[1];
    match page_title(url).await {
        Some(title) => println!("The title for {url} was {title}"),
        None => println!("{url} had no title"),
    }
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}

不幸的是,这段代码无法编译。我们唯一可以使用 await 关键字的地方是在异步函数或块中,而 Rust 不允许我们将特殊的 main 函数标记为 async

error[E0752]: `main` function is not allowed to be `async`
 --> src/main.rs:6:1
  |
6 | async fn main() {
  | ^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`

main 不能标记为 async 的原因是异步代码需要一个 运行时:一个管理异步代码执行细节的 Rust crate。程序的 main 函数可以 初始化 一个运行时,但它本身不是一个运行时。(我们稍后会看到更多关于为什么是这样的原因。)每个执行异步代码的 Rust 程序至少有一个地方设置运行时并执行 futures。

大多数支持异步的语言都捆绑了一个运行时,但 Rust 没有。相反,有许多不同的异步运行时可用,每个运行时都根据其目标用例做出不同的权衡。例如,具有许多 CPU 核心和大量 RAM 的高吞吐量 Web 服务器与具有单核、少量 RAM 且没有堆分配能力的微控制器有着非常不同的需求。提供这些运行时的 crate 通常还提供常见功能的异步版本,如文件或网络 I/O。

在这里,以及本章的其余部分,我们将使用 trpl crate 中的 run 函数,它接受一个 future 作为参数并运行它直到完成。在幕后,调用 run 会设置一个运行时,用于运行传入的 future。一旦 future 完成,run 返回 future 生成的任何值。

我们可以直接将 page_title 返回的 future 传递给 run,一旦它完成,我们就可以匹配生成的 Option<String>,就像我们在 Listing 17-3 中尝试做的那样。然而,对于本章中的大多数示例(以及现实世界中的大多数异步代码),我们将不仅仅做一个异步函数调用,所以我们将传递一个 async 块并显式地等待 page_title 调用的结果,如 Listing 17-4 所示。

extern crate trpl; // required for mdbook test

use trpl::Html;

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::run(async {
        let url = &args[1];
        match page_title(url).await {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
    })
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}

当我们运行这段代码时,我们得到了最初预期的行为:

$ cargo run -- https://www.rust-lang.org
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.05s
     Running `target/debug/async_await 'https://www.rust-lang.org'`
The title for https://www.rust-lang.org was
            Rust Programming Language

终于,我们有了一些可以工作的异步代码!但在我们添加代码来让两个站点竞争之前,让我们简要地回到 futures 的工作原理。

每个 等待点——即代码使用 await 关键字的地方——代表一个将控制权交还给运行时的位置。为了实现这一点,Rust 需要跟踪异步块中涉及的状态,以便运行时可以启动其他工作,然后在准备好时返回并尝试推进第一个工作。这是一个不可见的状态机,就像你编写了一个枚举来在每个等待点保存当前状态一样:

#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test

enum PageTitleFuture<'a> {
    Initial { url: &'a str },
    GetAwaitPoint { url: &'a str },
    TextAwaitPoint { response: trpl::Response },
}
}

手动编写代码来在每种状态之间转换将是繁琐且容易出错的,特别是当你需要添加更多功能和更多状态时。幸运的是,Rust 编译器为异步代码自动创建并管理状态机数据结构。围绕数据结构的正常借用和所有权规则仍然适用,幸运的是,编译器还为我们处理这些检查并提供有用的错误消息。我们将在本章后面处理其中的一些。

最终,必须有东西执行这个状态机,而这个东西就是运行时。(这就是为什么你在查看运行时时可能会遇到 执行器 的引用:执行器是运行时负责执行异步代码的部分。)

现在你可以看到为什么编译器在 Listing 17-3 中阻止我们将 main 本身变成一个异步函数。如果 main 是一个异步函数,那么其他东西需要管理 main 返回的 future 的状态机,但 main 是程序的起点!相反,我们在 main 中调用 trpl::run 函数来设置一个运行时,并运行 async 块返回的 future 直到它完成。

注意:一些运行时提供了宏,因此你可以 编写 一个异步 main 函数。这些宏将 async fn main() { ... } 重写为一个普通的 fn main,它执行与我们在 Listing 17-5 中手动执行的相同操作:调用一个函数,以 trpl::run 的方式运行一个 future 直到完成。

现在让我们将这些部分放在一起,看看如何编写并发代码。

让我们的两个 URL 竞争

在 Listing 17-5 中,我们调用 page_title 并传入从命令行传入的两个不同 URL,让它们竞争。

extern crate trpl; // required for mdbook test

use trpl::{Either, Html};

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::run(async {
        let title_fut_1 = page_title(&args[1]);
        let title_fut_2 = page_title(&args[2]);

        let (url, maybe_title) =
            match trpl::race(title_fut_1, title_fut_2).await {
                Either::Left(left) => left,
                Either::Right(right) => right,
            };

        println!("{url} returned first");
        match maybe_title {
            Some(title) => println!("Its page title is: '{title}'"),
            None => println!("Its title could not be parsed."),
        }
    })
}

async fn page_title(url: &str) -> (&str, Option<String>) {
    let text = trpl::get(url).await.text().await;
    let title = Html::parse(&text)
        .select_first("title")
        .map(|title| title.inner_html());
    (url, title)
}

我们首先为每个用户提供的 URL 调用 page_title。我们将生成的 futures 保存为 title_fut_1title_fut_2。记住,这些 futures 现在还没有做任何事情,因为 futures 是惰性的,我们还没有等待它们。然后我们将 futures 传递给 trpl::race,它返回一个值来指示传递给它的 futures 中哪个先完成。

注意:在幕后,race 是建立在更通用的函数 select 之上的,你会在现实世界的 Rust 代码中更频繁地遇到它。select 函数可以做很多 trpl::race 函数无法做到的事情,但它也有一些我们目前可以跳过的额外复杂性。

任何一个 future 都可以合法地“获胜”,所以返回一个 Result 是没有意义的。相反,race 返回一个我们以前没有见过的类型,trpl::EitherEither 类型与 Result 有些相似,因为它有两个情况。与 Result 不同的是,Either 中没有成功或失败的概念。相反,它使用 LeftRight 来表示“一个或另一个”:

#![allow(unused)]
fn main() {
enum Either<A, B> {
    Left(A),
    Right(B),
}
}

如果第一个参数获胜,race 函数返回 Left 并带有该 future 的输出;如果第二个 future 参数获胜,则返回 Right 并带有该 future 的输出。这与调用函数时参数的顺序相匹配:第一个参数在第二个参数的左边。

我们还更新了 page_title 以返回传入的相同 URL。这样,如果先返回的页面没有可解析的 <title>,我们仍然可以打印一个有意义的消息。有了这些信息,我们通过更新 println! 输出来指示哪个 URL 先完成以及该网页的 <title>(如果有的话)。

你现在已经构建了一个小型的工作网页抓取器!选择几个 URL 并运行命令行工具。你可能会发现某些站点始终比其他站点更快,而在其他情况下,较快的站点在每次运行时都会有所不同。更重要的是,你已经学会了使用 futures 的基础知识,所以现在我们可以更深入地探讨异步编程的更多内容。

使用 Async 实现并发

在本节中,我们将把 async 应用到一些与第 16 章中使用线程解决的并发挑战相同的问题上。因为我们已经在那里讨论了很多关键概念,所以本节我们将重点放在线程和 futures 之间的区别上。

在许多情况下,使用 async 进行并发操作的 API 与使用线程的 API 非常相似。在其他情况下,它们最终会有很大的不同。即使线程和 async 的 API 看起来相似,它们的行为通常也不同——而且它们的性能特征几乎总是不同。

使用 spawn_task 创建新任务

使用 Spawn 创建新线程 中,我们解决的第一个操作是在两个独立的线程上进行计数。让我们使用 async 来做同样的事情。trpl crate 提供了一个 spawn_task 函数,看起来与 thread::spawn API 非常相似,以及一个 sleep 函数,它是 thread::sleep API 的 async 版本。我们可以一起使用这些函数来实现计数示例,如 Listing 17-6 所示。

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        trpl::spawn_task(async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        });

        for i in 1..5 {
            println!("hi number {i} from the second task!");
            trpl::sleep(Duration::from_millis(500)).await;
        }
    });
}

作为我们的起点,我们使用 trpl::run 设置 main 函数,以便我们的顶层函数可以是 async 的。

注意:从本章的这一点开始,每个示例都将包含与 main 中的 trpl::run 完全相同的包装代码,因此我们通常会像处理 main 一样跳过它。不要忘记在你的代码中包含它!

然后我们在该块中编写两个循环,每个循环包含一个 trpl::sleep 调用,它在发送下一条消息之前等待半秒(500 毫秒)。我们将一个循环放在 trpl::spawn_task 的主体中,另一个放在顶层的 for 循环中。我们还在 sleep 调用之后添加了一个 await

这段代码的行为与基于线程的实现类似——包括当你运行它时,可能会在终端中看到消息以不同的顺序出现:

hi number 1 from the second task!
hi number 1 from the first task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!

这个版本在主 async 块中的 for 循环完成后立即停止,因为由 spawn_task 生成的任务在 main 函数结束时被关闭。如果你希望它一直运行到任务完成,你需要使用一个 join 句柄来等待第一个任务完成。对于线程,我们使用 join 方法来“阻塞”直到线程运行完毕。在 Listing 17-7 中,我们可以使用 await 来做同样的事情,因为任务句柄本身就是一个 future。它的 Output 类型是一个 Result,所以我们在等待它之后还要解包它。

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let handle = trpl::spawn_task(async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        });

        for i in 1..5 {
            println!("hi number {i} from the second task!");
            trpl::sleep(Duration::from_millis(500)).await;
        }

        handle.await.unwrap();
    });
}

这个更新后的版本会一直运行,直到两个循环都完成。

hi number 1 from the second task!
hi number 1 from the first task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!
hi number 6 from the first task!
hi number 7 from the first task!
hi number 8 from the first task!
hi number 9 from the first task!

到目前为止,看起来 async 和线程给了我们相同的基本结果,只是语法不同:使用 await 而不是在 join 句柄上调用 join,并且等待 sleep 调用。

更大的区别是我们不需要生成另一个操作系统线程来执行此操作。事实上,我们甚至不需要在这里生成任务。因为 async 块编译为匿名的 futures,我们可以将每个循环放在一个 async 块中,并使用 trpl::join 函数让运行时将它们都运行到完成。

使用 join 句柄等待所有线程完成 一节中,我们展示了如何在调用 std::thread::spawn 时返回的 JoinHandle 类型上使用 join 方法。trpl::join 函数类似,但用于 futures。当你给它两个 futures 时,它会生成一个新的 future,其输出是一个元组,包含你传入的每个 future 的输出,一旦它们完成。因此,在 Listing 17-8 中,我们使用 trpl::join 来等待 fut1fut2 完成。我们等待 fut1fut2,而是等待由 trpl::join 生成的新 future。我们忽略输出,因为它只是一个包含两个单元值的元组。

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let fut1 = async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let fut2 = async {
            for i in 1..5 {
                println!("hi number {i} from the second task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        trpl::join(fut1, fut2).await;
    });
}

当我们运行这个时,我们看到两个 futures 都运行到完成:

hi number 1 from the first task!
hi number 1 from the second task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!
hi number 6 from the first task!
hi number 7 from the first task!
hi number 8 from the first task!
hi number 9 from the first task!

现在,你每次都会看到完全相同的顺序,这与我们在线程中看到的情况非常不同。这是因为 trpl::join 函数是公平的,意味着它平等地检查每个 future,交替进行,并且如果一个 future 准备好了,它不会让另一个 future 抢先。对于线程,操作系统决定检查哪个线程以及让它运行多长时间。对于 async Rust,运行时决定检查哪个任务。(实际上,细节会变得复杂,因为 async 运行时可能在底层使用操作系统线程作为其管理并发的一部分,因此保证公平性对运行时来说可能是更多的工作——但它仍然是可能的!)运行时不必保证任何给定操作的公平性,它们通常提供不同的 API 来让你选择是否需要公平性。

尝试一些这些变体来等待 futures,看看它们会做什么:

  • 移除一个或两个循环周围的 async 块。
  • 在定义每个 async 块后立即等待它。
  • 只将第一个循环包装在 async 块中,并在第二个循环的主体之后等待生成的 future。

作为一个额外的挑战,看看你是否能在运行代码之前预测每种情况下的输出!

使用消息传递在两个任务上进行计数

在 futures 之间共享数据也会很熟悉:我们将再次使用消息传递,但这次使用 async 版本的类型和函数。我们将采取与 使用消息传递在线程之间传输数据 中略有不同的路径,以说明基于线程和基于 futures 的并发之间的一些关键区别。在 Listing 17-9 中,我们将从一个单一的 async 块开始——像我们生成一个单独的线程那样生成一个单独的任务。

extern crate trpl; // required for mdbook test

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let val = String::from("hi");
        tx.send(val).unwrap();

        let received = rx.recv().await.unwrap();
        println!("Got: {received}");
    });
}

在这里,我们使用 trpl::channel,这是一个 async 版本的多生产者、单消费者通道 API,我们在第 16 章中与线程一起使用过。async 版本的 API 与基于线程的版本只有一点不同:它使用一个可变的而不是不可变的接收器 rx,并且它的 recv 方法生成一个我们需要等待的 future,而不是直接生成值。现在我们可以从发送者向接收者发送消息。注意,我们不需要生成一个单独的线程甚至任务;我们只需要等待 rx.recv 调用。

std::mpsc::channel 中的同步 Receiver::recv 方法会阻塞,直到它收到消息。trpl::Receiver::recv 方法不会阻塞,因为它是 async 的。它不会阻塞,而是将控制权交还给运行时,直到收到消息或通道的发送端关闭。相比之下,我们不会等待 send 调用,因为它不会阻塞。它不需要阻塞,因为我们发送到的通道是无界的。

注意:因为所有这些 async 代码都在 trpl::run 调用中的 async 块中运行,所以其中的所有内容都可以避免阻塞。然而,外部的代码会在 run 函数返回时阻塞。这就是 trpl::run 函数的全部意义:它让你选择在哪里阻塞某些 async 代码,从而在哪里在同步和异步代码之间进行转换。在大多数 async 运行时中,run 实际上被称为 block_on,正是因为这个原因。

注意这个例子中的两件事。首先,消息会立即到达。其次,尽管我们在这里使用了一个 future,但还没有并发。列表中的所有内容都是按顺序发生的,就像没有 futures 参与一样。

让我们通过发送一系列消息并在它们之间休眠来解决第一部分,如 Listing 17-10 所示。

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("future"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            trpl::sleep(Duration::from_millis(500)).await;
        }

        while let Some(value) = rx.recv().await {
            println!("received '{value}'");
        }
    });
}

除了发送消息外,我们还需要接收它们。在这种情况下,因为我们知道有多少消息会进来,我们可以手动调用 rx.recv().await 四次。然而,在现实世界中,我们通常会等待一些未知数量的消息,所以我们需要一直等待,直到我们确定没有更多的消息。

在 Listing 16-10 中,我们使用了一个 for 循环来处理从同步通道接收的所有项目。Rust 还没有办法编写一个 for 循环来遍历一个异步系列的项目,所以我们需要使用一个我们之前没有见过的循环:while let 条件循环。这是我们在 使用 if letlet else 进行简洁控制流 一节中看到的 if let 构造的循环版本。只要它指定的模式继续匹配值,循环就会继续执行。

rx.recv 调用生成一个 future,我们等待它。运行时将暂停 future,直到它准备好。一旦消息到达,future 将解析为 Some(message),每次消息到达时都会这样。当通道关闭时,无论是否有任何消息到达,future 将解析为 None,表示没有更多的值,因此我们应该停止轮询——即停止等待。

while let 循环将所有这些结合在一起。如果调用 rx.recv().await 的结果是 Some(message),我们可以访问消息并在循环体中使用它,就像我们可以使用 if let 一样。如果结果是 None,循环结束。每次循环完成时,它都会再次到达等待点,因此运行时再次暂停它,直到另一条消息到达。

代码现在成功地发送和接收了所有消息。不幸的是,仍然有几个问题。首先,消息不会以半秒的间隔到达。它们会在我们启动程序 2 秒(2000 毫秒)后一次性到达。其次,这个程序永远不会退出!相反,它会永远等待新消息。你需要使用 ctrl-c 来关闭它。

让我们首先检查为什么消息会在完整延迟后一次性到达,而不是在每条消息之间延迟。在给定的 async 块中,代码中 await 关键字出现的顺序也是程序运行时它们执行的顺序。

Listing 17-10 中只有一个 async 块,所以其中的所有内容都是线性运行的。仍然没有并发。所有的 tx.send 调用都会发生,穿插着所有的 trpl::sleep 调用及其相关的等待点。然后 while let 循环才会通过 recv 调用的任何等待点。

为了获得我们想要的行为,即每条消息之间的睡眠延迟,我们需要将 txrx 操作放在它们自己的 async 块中,如 Listing 17-11 所示。然后运行时可以使用 trpl::join 分别执行它们,就像在计数示例中一样。再次强调,我们等待调用 trpl::join 的结果,而不是单独的 futures。如果我们按顺序等待单独的 futures,我们最终会回到顺序流程——这正是我们想做的事情。

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx_fut = async {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        trpl::join(tx_fut, rx_fut).await;
    });
}

在 Listing 17-11 的更新代码中,消息以 500 毫秒的间隔打印,而不是在 2 秒后一次性到达。

然而,程序仍然不会退出,因为 while let 循环与 trpl::join 的交互方式:

  • trpl::join 返回的 future 只有在传递给它的两个 futures 都完成后才会完成。
  • tx future 在发送完 vals 中的最后一条消息并完成休眠后完成。
  • rx future 只有在 while let 循环结束后才会完成。
  • while let 循环只有在等待 rx.recv 产生 None 时才会结束。
  • 等待 rx.recv 只有在通道的另一端关闭时才会返回 None
  • 通道只有在调用 rx.close 或发送端 tx 被丢弃时才会关闭。
  • 我们没有在任何地方调用 rx.close,并且 tx 只有在传递给 trpl::run 的最外层 async 块结束时才会被丢弃。
  • 该块无法结束,因为它被阻塞在 trpl::join 完成上,这让我们回到了这个列表的顶部。

我们可以通过调用 rx.close 来手动关闭 rx,但这没有多大意义。在处理一些任意数量的消息后停止会使程序关闭,但我们可能会错过消息。我们需要其他方法来确保 tx 在函数结束之前被丢弃。

现在,我们发送消息的 async 块只借用 tx,因为发送消息不需要所有权,但如果我们可以将 tx 移动到该 async 块中,它将在该块结束时被丢弃。在第 13 章的 捕获引用或移动所有权 一节中,你学习了如何使用 move 关键字与闭包,并且如第 16 章的 使用 move 闭包与线程 一节中所讨论的,我们在使用线程时经常需要将数据移动到闭包中。同样的基本动态适用于 async 块,因此 move 关键字与 async 块一起使用,就像它与闭包一起使用一样。

在 Listing 17-12 中,我们将用于发送消息的块从 async 更改为 async move。当我们运行这个版本的代码时,它会在最后一条消息发送和接收后优雅地关闭。

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        trpl::join(tx_fut, rx_fut).await;
    });
}

这个 async 通道也是一个多生产者通道,所以如果我们想从多个 futures 发送消息,我们可以调用 clone 来复制 tx,如 Listing 17-13 所示。

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(1500)).await;
            }
        };

        trpl::join3(tx1_fut, tx_fut, rx_fut).await;
    });
}

首先,我们克隆 tx,在第一个 async 块之外创建 tx1。我们像之前对 tx 一样将 tx1 移动到该块中。然后,稍后,我们将原始的 tx 移动到一个新的 async 块中,在那里我们以稍慢的延迟发送更多消息。我们碰巧将这个新的 async 块放在接收消息的 async 块之后,但它也可以放在前面。关键是 futures 被等待的顺序,而不是它们被创建的顺序。

发送消息的两个 async 块都需要是 async move 块,以便 txtx1 在这些块完成时被丢弃。否则,我们将回到我们开始的无限循环中。最后,我们从 trpl::join 切换到 trpl::join3 来处理额外的 future。

现在我们看到来自两个发送 futures 的所有消息,并且因为发送 futures 在发送后使用略有不同的延迟,消息也会以这些不同的间隔接收。

received 'hi'
received 'more'
received 'from'
received 'the'
received 'messages'
received 'future'
received 'for'
received 'you'

这是一个好的开始,但它将我们限制在只有少数 futures:两个使用 join,或三个使用 join3。让我们看看我们如何可能处理更多的 futures。

处理任意数量的 Futures

在前一节中,当我们从使用两个 futures 切换到使用三个 futures 时,我们也必须从使用 join 切换到使用 join3。每次我们改变要连接的 futures 数量时,都必须调用不同的函数,这可能会很烦人。幸运的是,我们有一个宏形式的 join,可以向它传递任意数量的参数。它还会自己处理等待 futures 的过程。因此,我们可以重写代码,使用 join! 而不是 join3,如 Listing 17-14 所示。

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        trpl::join!(tx1_fut, tx_fut, rx_fut);
    });
}

这绝对比在 joinjoin3join4 等之间切换要好得多!然而,即使这种宏形式也仅在我们提前知道 futures 的数量时才有效。在现实世界的 Rust 中,将 futures 推入集合中,然后等待其中一些或所有 futures 完成是一种常见的模式。

要检查集合中的所有 futures,我们需要遍历并连接 所有 的 futures。trpl::join_all 函数接受任何实现了 Iterator trait 的类型,你在 The Iterator Trait and the next Method 第 13 章中已经学过,所以它似乎正是我们需要的。让我们尝试将我们的 futures 放入一个向量中,并用 join_all 替换 join!,如 Listing 17-15 所示。

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures = vec![tx1_fut, rx_fut, tx_fut];

        trpl::join_all(futures).await;
    });
}

不幸的是,这段代码无法编译。相反,我们得到了以下错误:

error[E0308]: mismatched types
  --> src/main.rs:45:37
   |
10 |         let tx1_fut = async move {
   |                       ---------- the expected `async` block
...
24 |         let rx_fut = async {
   |                      ----- the found `async` block
...
45 |         let futures = vec![tx1_fut, rx_fut, tx_fut];
   |                                     ^^^^^^ expected `async` block, found a different `async` block
   |
   = note: expected `async` block `{async block@src/main.rs:10:23: 10:33}`
              found `async` block `{async block@src/main.rs:24:22: 24:27}`
   = note: no two async blocks, even if identical, have the same type
   = help: consider pinning your async block and casting it to a trait object

这可能会让人感到惊讶。毕竟,这些 async 块都没有返回任何内容,所以每个块都会生成一个 Future<Output = ()>。请记住,Future 是一个 trait,编译器会为每个 async 块创建一个唯一的枚举。你不能将两个不同的手写结构体放入 Vec 中,同样的规则也适用于编译器生成的不同枚举。

为了使这项工作正常进行,我们需要使用 trait 对象,就像我们在第 12 章的 “Returning Errors from the run function” 中所做的那样。(我们将在第 18 章详细讨论 trait 对象。)使用 trait 对象可以让我们将这些类型生成的匿名 futures 视为相同类型,因为它们都实现了 Future trait。

注意:在第 8 章的 Using an Enum to Store Multiple Values 部分中,我们讨论了另一种在 Vec 中包含多种类型的方法:使用枚举来表示向量中可能出现的每种类型。不过,我们不能在这里这样做。一方面,我们无法命名这些不同的类型,因为它们是匿名的。另一方面,我们首先使用向量和 join_all 的原因是为了能够处理一个动态的 futures 集合,我们只关心它们具有相同的输出类型。

我们首先将 vec! 中的每个 future 包装在 Box::new 中,如 Listing 17-16 所示。

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures =
            vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];

        trpl::join_all(futures).await;
    });
}

不幸的是,这段代码仍然无法编译。事实上,我们在第二个和第三个 Box::new 调用中得到了与之前相同的基本错误,以及一些涉及 Unpin trait 的新错误。我们稍后再讨论 Unpin 错误。首先,让我们通过显式注释 futures 变量的类型来修复 Box::new 调用中的类型错误(见 Listing 17-17)。

extern crate trpl; // required for mdbook test

use std::{future::Future, time::Duration};

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures: Vec<Box<dyn Future<Output = ()>>> =
            vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];

        trpl::join_all(futures).await;
    });
}

这个类型声明有点复杂,所以让我们逐步分析一下:

  1. 最内层的类型是 future 本身。我们通过写 Future<Output = ()> 显式地指出 future 的输出是单元类型 ()
  2. 然后我们用 dyn 注释 trait,将其标记为动态的。
  3. 整个 trait 引用被包装在一个 Box 中。
  4. 最后,我们显式声明 futures 是一个包含这些项的 Vec

这已经带来了很大的不同。现在当我们运行编译器时,我们只得到了提到 Unpin 的错误。尽管有三个错误,但它们的内容非常相似。

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
   --> src/main.rs:49:24
    |
49  |         trpl::join_all(futures).await;
    |         -------------- ^^^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
    |         |
    |         required by a bound introduced by this call
    |
    = note: consider using the `pin!` macro
            consider using `Box::pin` if you need to access the pinned value outside of the current scope
    = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `join_all`
   --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:105:14
    |
102 | pub fn join_all<I>(iter: I) -> JoinAll<I::Item>
    |        -------- required by a bound in this function
...
105 |     I::Item: Future,
    |              ^^^^^^ required by this bound in `join_all`

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
  --> src/main.rs:49:9
   |
49 |         trpl::join_all(futures).await;
   |         ^^^^^^^^^^^^^^^^^^^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
  --> src/main.rs:49:33
   |
49 |         trpl::join_all(futures).await;
   |                                 ^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `async_await` (bin "async_await") due to 3 previous errors

这有很多内容需要消化,所以让我们分解一下。消息的第一部分告诉我们,第一个 async 块(src/main.rs:8:23: 20:10)没有实现 Unpin trait,并建议使用 pin!Box::pin 来解决它。在本章的后面,我们将深入探讨一些关于 PinUnpin 的更多细节。不过,现在我们可以按照编译器的建议来解决问题。在 Listing 17-18 中,我们首先更新 futures 的类型注释,用 Pin 包装每个 Box。其次,我们使用 Box::pin 来固定 futures 本身。

extern crate trpl; // required for mdbook test

use std::{
    future::Future,
    pin::{Pin, pin},
    time::Duration,
};

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = pin!(async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        });

        let rx_fut = pin!(async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        });

        let tx_fut = pin!(async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        });

        let futures: Vec<Pin<Box<dyn Future<Output = ()>>>> =
            vec![Box::pin(tx1_fut), Box::pin(rx_fut), Box::pin(tx_fut)];

        trpl::join_all(futures).await;
    });
}

如果我们编译并运行这段代码,最终会得到我们期望的输出:

received 'hi'
received 'more'
received 'from'
received 'messages'
received 'the'
received 'for'
received 'future'
received 'you'

终于成功了!

这里还有一些值得探索的地方。一方面,使用 Pin<Box<T>> 会带来一些开销,因为我们将这些 futures 放在堆上使用 Box——而我们这样做只是为了对齐类型。毕竟,我们实际上并不 需要 堆分配:这些 futures 是特定于这个函数的。如前所述,Pin 本身是一个包装类型,因此我们可以在 Vec 中获得单一类型的好处——这是我们最初使用 Box 的原因——而不需要进行堆分配。我们可以直接使用 Pin 与每个 future,使用 std::pin::pin 宏。

然而,我们仍然必须显式地声明固定引用的类型;否则,Rust 仍然不知道如何将这些解释为动态 trait 对象,而这正是我们在 Vec 中需要的。因此,我们在定义每个 future 时使用 pin!,并将 futures 定义为一个包含固定可变引用的动态 future 类型的 Vec,如 Listing 17-19 所示。

extern crate trpl; // required for mdbook test

use std::{
    future::Future,
    pin::{Pin, pin},
    time::Duration,
};

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = pin!(async move {
            // --snip--
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        });

        let rx_fut = pin!(async {
            // --snip--
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        });

        let tx_fut = pin!(async move {
            // --snip--
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        });

        let futures: Vec<Pin<&mut dyn Future<Output = ()>>> =
            vec![tx1_fut, rx_fut, tx_fut];

        trpl::join_all(futures).await;
    });
}

我们通过忽略我们可能有不同的 Output 类型的事实走到了这一步。例如,在 Listing 17-20 中,a 的匿名 future 实现了 Future<Output = u32>b 的匿名 future 实现了 Future<Output = &str>c 的匿名 future 实现了 Future<Output = bool>

extern crate trpl; // required for mdbook test

fn main() {
    trpl::run(async {
        let a = async { 1u32 };
        let b = async { "Hello!" };
        let c = async { true };

        let (a_result, b_result, c_result) = trpl::join!(a, b, c);
        println!("{a_result}, {b_result}, {c_result}");
    });
}

我们可以使用 trpl::join! 来等待它们,因为它允许我们传入多个 future 类型并生成这些类型的元组。我们 不能 使用 trpl::join_all,因为它要求传入的所有 futures 具有相同的类型。记住,这个错误正是我们开始这段 Pin 冒险的原因!

这是一个根本性的权衡:我们可以使用 join_all 处理动态数量的 futures,只要它们都具有相同的类型,或者我们可以使用 join 函数或 join! 宏处理固定数量的 futures,即使它们具有不同的类型。这与我们在 Rust 中处理任何其他类型时面临的情况相同。Futures 并不特殊,尽管我们有一些很好的语法来处理它们,这是一件好事。

竞速 Futures

当我们使用 join 系列函数和宏“连接”futures 时,我们要求 所有 的 futures 都完成后才能继续。然而,有时我们只需要 一些 futures 完成就可以继续——类似于将一个 future 与另一个 future 竞速。

在 Listing 17-21 中,我们再次使用 trpl::race 来运行两个 futures,slowfast,相互竞速。

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let slow = async {
            println!("'slow' started.");
            trpl::sleep(Duration::from_millis(100)).await;
            println!("'slow' finished.");
        };

        let fast = async {
            println!("'fast' started.");
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'fast' finished.");
        };

        trpl::race(slow, fast).await;
    });
}

每个 future 在开始运行时打印一条消息,通过调用并等待 sleep 暂停一段时间,然后在完成时打印另一条消息。然后我们将 slowfast 传递给 trpl::race 并等待其中一个完成。(这里的结果并不太令人惊讶:fast 赢了。)与我们在 “Our First Async Program” 中使用 race 时不同,我们在这里忽略了它返回的 Either 实例,因为所有有趣的行为都发生在 async 块的主体中。

请注意,如果你翻转 race 参数的顺序,“started”消息的顺序会改变,即使 fast future 总是先完成。这是因为这个特定的 race 函数的实现是不公平的。它总是按照传递参数的顺序运行 futures。其他实现 公平的,会随机选择先轮询哪个 future。无论我们使用的 race 实现是否公平,一个 future 都会在其主体中的第一个 await 点之前运行。

回想一下 Our First Async Program,在每个 await 点,Rust 都会给运行时一个机会暂停任务并切换到另一个任务,如果被等待的 future 还没有准备好。反之亦然:Rust 在 await 点暂停 async 块并将控制权交还给运行时。await 点之间的所有内容都是同步的。

这意味着如果你在 async 块中做了一堆工作而没有 await 点,那么这个 future 将阻止任何其他 futures 取得进展。你有时可能会听到这种情况被称为一个 future 饿死 其他 futures。在某些情况下,这可能不是什么大问题。然而,如果你正在进行某种昂贵的设置或长时间运行的工作,或者如果你有一个 future 将无限期地继续执行某个特定任务,你需要考虑何时何地将控制权交还给运行时。

同样地,如果你有长时间运行的阻塞操作,async 可以是一个有用的工具,用于提供程序不同部分之间相互关联的方式。

但是 如何 在这些情况下将控制权交还给运行时呢?

将控制权交还给运行时

让我们模拟一个长时间运行的操作。Listing 17-22 引入了一个 slow 函数。

extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        // We will call `slow` here later
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}

这段代码使用 std::thread::sleep 而不是 trpl::sleep,因此调用 slow 会阻塞当前线程一段时间。我们可以使用 slow 来模拟现实世界中既长时间运行又阻塞的操作。

在 Listing 17-23 中,我们使用 slow 来模拟在一对 futures 中执行这种 CPU 密集型工作。

extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        let a = async {
            println!("'a' started.");
            slow("a", 30);
            slow("a", 10);
            slow("a", 20);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            slow("b", 10);
            slow("b", 15);
            slow("b", 350);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'b' finished.");
        };

        trpl::race(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}

开始时,每个 future 只有在执行了一堆慢操作后才会将控制权交还给运行时。如果你运行这段代码,你会看到以下输出:

'a' started.
'a' ran for 30ms
'a' ran for 10ms
'a' ran for 20ms
'b' started.
'b' ran for 75ms
'b' ran for 10ms
'b' ran for 15ms
'b' ran for 350ms
'a' finished.

与我们之前的示例一样,race 仍然在 a 完成后立即完成。不过,这两个 futures 之间没有交错。a future 在 trpl::sleep 调用被等待之前完成了所有工作,然后 b future 在它自己的 trpl::sleep 调用被等待之前完成了所有工作,最后 a future 完成。为了让两个 futures 在它们的慢任务之间取得进展,我们需要 await 点,以便我们可以将控制权交还给运行时。这意味着我们需要一些可以 await 的东西!

我们已经在 Listing 17-23 中看到了这种控制权移交的发生:如果我们移除 a future 末尾的 trpl::sleep,它将在 b future 运行 之前 完成。让我们尝试使用 sleep 函数作为让操作交替取得进展的起点,如 Listing 17-24 所示。

extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        let one_ms = Duration::from_millis(1);

        let a = async {
            println!("'a' started.");
            slow("a", 30);
            trpl::sleep(one_ms).await;
            slow("a", 10);
            trpl::sleep(one_ms).await;
            slow("a", 20);
            trpl::sleep(one_ms).await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            trpl::sleep(one_ms).await;
            slow("b", 10);
            trpl::sleep(one_ms).await;
            slow("b", 15);
            trpl::sleep(one_ms).await;
            slow("b", 350);
            trpl::sleep(one_ms).await;
            println!("'b' finished.");
        };

        trpl::race(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}

在 Listing 17-24 中,我们在每次调用 slow 之间添加了 trpl::sleep 调用和 await 点。现在两个 futures 的工作是交替进行的:

'a' started.
'a' ran for 30ms
'b' started.
'b' ran for 75ms
'a' ran for 10ms
'b' ran for 10ms
'a' ran for 20ms
'b' ran for 15ms
'a' finished.

a future 仍然在将控制权交给 b 之前运行了一段时间,因为它在调用 trpl::sleep 之前调用了 slow,但之后 futures 每次遇到 await 点时都会交替进行。在这种情况下,我们在每次调用 slow 之后都这样做,但我们可以以对我们最有意义的方式分解工作。

不过,我们并不真的想在这里 睡眠:我们想尽可能快地取得进展。我们只需要将控制权交还给运行时。我们可以直接使用 yield_now 函数来实现这一点。在 Listing 17-25 中,我们将所有这些 sleep 调用替换为 yield_now

extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        let a = async {
            println!("'a' started.");
            slow("a", 30);
            trpl::yield_now().await;
            slow("a", 10);
            trpl::yield_now().await;
            slow("a", 20);
            trpl::yield_now().await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            trpl::yield_now().await;
            slow("b", 10);
            trpl::yield_now().await;
            slow("b", 15);
            trpl::yield_now().await;
            slow("b", 350);
            trpl::yield_now().await;
            println!("'b' finished.");
        };

        trpl::race(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}

这段代码既更清晰地表达了实际意图,又比使用 sleep 快得多,因为像 sleep 使用的计时器通常对它们的粒度有限制。例如,我们使用的 sleep 版本即使我们传递给它一个纳秒的 Duration,也总是会至少睡眠一毫秒。再次强调,现代计算机 非常快:它们在一毫秒内可以做很多事情!

你可以通过设置一个小型基准测试来亲自看到这一点,例如 Listing 17-26 中的基准测试。(这不是一个特别严格的性能测试方法,但它足以显示这里的差异。)

extern crate trpl; // required for mdbook test

use std::time::{Duration, Instant};

fn main() {
    trpl::run(async {
        let one_ns = Duration::from_nanos(1);
        let start = Instant::now();
        async {
            for _ in 1..1000 {
                trpl::sleep(one_ns).await;
            }
        }
        .await;
        let time = Instant::now() - start;
        println!(
            "'sleep' version finished after {} seconds.",
            time.as_secs_f32()
        );

        let start = Instant::now();
        async {
            for _ in 1..1000 {
                trpl::yield_now().await;
            }
        }
        .await;
        let time = Instant::now() - start;
        println!(
            "'yield' version finished after {} seconds.",
            time.as_secs_f32()
        );
    });
}

在这里,我们跳过了所有的状态打印,传递了一个一纳秒的 Durationtrpl::sleep,并让每个 future 自己运行,没有在 futures 之间切换。然后我们运行 1,000 次迭代,看看使用 trpl::sleep 的 future 与使用 trpl::yield_now 的 future 相比需要多长时间。

使用 yield_now 的版本 快得多

这意味着 async 即使对于计算密集型任务也可能有用,具体取决于你的程序在做什么,因为它为构建程序不同部分之间的关系提供了一个有用的工具。这是一种 协作式多任务处理,其中每个 future 都有权通过 await 点决定何时移交控制权。因此,每个 future 也有责任避免阻塞太长时间。在一些基于 Rust 的嵌入式操作系统中,这是 唯一 的多任务处理方式!

在现实世界的代码中,你通常不会在每一行上都交替函数调用和 await 点。虽然以这种方式移交控制权相对便宜,但它并不是免费的。在许多情况下,尝试分解计算密集型任务可能会使其显著变慢,因此有时为了 整体 性能,最好让操作短暂阻塞。始终测量以查看代码的实际性能瓶颈是什么。不过,如果你 确实 看到大量工作以串行方式发生,而你期望它们并发发生,那么记住这种底层动态是很重要的!

构建我们自己的 Async 抽象

我们还可以将 futures 组合在一起以创建新的模式。例如,我们可以使用我们已经拥有的 async 构建块来构建一个 timeout 函数。当我们完成后,结果将是另一个构建块,我们可以用它来创建更多的 async 抽象。

Listing 17-27 展示了我们期望这个 timeout 如何与一个慢 future 一起工作。

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let slow = async {
            trpl::sleep(Duration::from_millis(100)).await;
            "I finished!"
        };

        match timeout(slow, Duration::from_millis(10)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

让我们实现这个功能!首先,让我们考虑一下 timeout 的 API:

  • 它本身需要是一个 async 函数,以便我们可以 await 它。
  • 它的第一个参数应该是一个要运行的 future。我们可以将其设为泛型,以允许它与任何 future 一起工作。
  • 它的第二个参数将是最大等待时间。如果我们使用 Duration,这将使其易于传递给 trpl::sleep
  • 它应该返回一个 Result。如果 future 成功完成,Result 将是 Ok,包含 future 生成的值。如果超时先发生,Result 将是 Err,包含超时等待的时间。

Listing 17-28 展示了这个声明。

extern crate trpl; // required for mdbook test

use std::{future::Future, time::Duration};

fn main() {
    trpl::run(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_millis(10)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

async fn timeout<F: Future>(
    future_to_try: F,
    max_time: Duration,
) -> Result<F::Output, Duration> {
    // Here is where our implementation will go!
}

这满足了我们对类型的目标。现在让我们考虑一下我们需要的 行为:我们希望将传入的 future 与持续时间竞速。我们可以使用 trpl::sleep 从持续时间中创建一个计时器 future,并使用 trpl::race 来运行这个计时器与调用者传递的 future。

我们还知道 race 是不公平的,按照传递参数的顺序轮询参数。因此,我们首先将 future_to_try 传递给 race,以便即使 max_time 是一个非常短的持续时间,它也有机会完成。如果 future_to_try 先完成,race 将返回 Left,包含 future_to_try 的输出。如果 timer 先完成,race 将返回 Right,包含计时器的输出 ()

在 Listing 17-29 中,我们匹配了 trpl::race 的 await 结果。

extern crate trpl; // required for mdbook test

use std::{future::Future, time::Duration};

use trpl::Either;

// --snip--

fn main() {
    trpl::run(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_secs(2)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

async fn timeout<F: Future>(
    future_to_try: F,
    max_time: Duration,
) -> Result<F::Output, Duration> {
    match trpl::race(future_to_try, trpl::sleep(max_time)).await {
        Either::Left(output) => Ok(output),
        Either::Right(_) => Err(max_time),
    }
}

如果 future_to_try 成功并且我们得到了 Left(output),我们返回 Ok(output)。如果 sleep 计时器先超时并且我们得到了 Right(()),我们用 _ 忽略 () 并返回 Err(max_time)

有了这个,我们就有了一个由两个其他 async 助手构建的工作 timeout。如果我们运行我们的代码,它将在超时后打印失败模式:

Failed after 2 seconds

因为 futures 可以与其他 futures 组合,你可以使用较小的 async 构建块构建非常强大的工具。例如,你可以使用相同的方法将超时与重试结合起来,然后将其与网络调用等操作一起使用(本章开头的示例之一)。

在实践中,你通常会直接使用 asyncawait,其次使用诸如 joinjoin_allrace 等函数和宏。你只需要偶尔使用 pin 来与这些 API 一起使用 futures。

我们现在已经看到了多种同时处理多个 futures 的方法。接下来,我们将看看如何通过 streams 在一段时间内按顺序处理多个 futures。不过,这里还有一些你可能想先考虑的事情:

  • 我们使用 Vecjoin_all 来等待一组 futures 中的所有 futures 完成。你如何使用 Vec 来按顺序处理一组 futures?这样做的权衡是什么?

  • 看看 futures crate 中的 futures::stream::FuturesUnordered 类型。使用它与使用 Vec 有什么不同?(不用担心它来自 crate 的 stream 部分;它与任何 futures 集合一起工作得很好。)

Streams: 顺序中的 Futures

到目前为止,本章我们主要讨论的是单个的 future。唯一的例外是我们使用的异步通道。回想一下我们在本章的“消息传递”部分中如何使用异步通道的接收器。异步的 recv 方法会随着时间的推移产生一系列的项目。这是一个更通用的模式,称为 stream(流)。

我们在第 13 章中看到了一个项目序列,当时我们研究了 Iterator trait 在The Iterator Trait and the next Method部分中的内容,但迭代器和异步通道接收器之间有两个区别。第一个区别是时间:迭代器是同步的,而通道接收器是异步的。第二个区别是 API。当直接使用 Iterator 时,我们调用它的同步 next 方法。而对于 trpl::Receiver 流,我们调用的是异步的 recv 方法。除此之外,这些 API 感觉非常相似,而这种相似性并非巧合。流就像是异步形式的迭代。虽然 trpl::Receiver 专门等待接收消息,但通用的流 API 要广泛得多:它提供了像 Iterator 一样的下一个项目,但是是异步的。

Rust 中迭代器和流之间的相似性意味着我们实际上可以从任何迭代器创建一个流。与迭代器一样,我们可以通过调用流的 next 方法并等待输出来处理流,如 Listing 17-30 所示。

extern crate trpl; // required for mdbook test

fn main() {
    trpl::run(async {
        let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        let iter = values.iter().map(|n| n * 2);
        let mut stream = trpl::stream_from_iter(iter);

        while let Some(value) = stream.next().await {
            println!("The value was: {value}");
        }
    });
}

我们从一个数字数组开始,将其转换为迭代器,然后调用 map 方法将所有值加倍。然后我们使用 trpl::stream_from_iter 函数将迭代器转换为流。接下来,我们使用 while let 循环遍历流中的项目。

不幸的是,当我们尝试运行代码时,它无法编译,而是报告没有可用的 next 方法:

error[E0599]: no method named `next` found for struct `Iter` in the current scope
  --> src/main.rs:10:40
   |
10 |         while let Some(value) = stream.next().await {
   |                                        ^^^^
   |
   = note: the full type name has been written to 'file:///projects/async-await/target/debug/deps/async_await-575db3dd3197d257.long-type-14490787947592691573.txt'
   = note: consider using `--verbose` to print the full type name to the console
   = help: items from traits can only be used if the trait is in scope
help: the following traits which provide `next` are implemented but not in scope; perhaps you want to import one of them
   |
1  + use crate::trpl::StreamExt;
   |
1  + use futures_util::stream::stream::StreamExt;
   |
1  + use std::iter::Iterator;
   |
1  + use std::str::pattern::Searcher;
   |
help: there is a method `try_next` with a similar name
   |
10 |         while let Some(value) = stream.try_next().await {
   |                                        ~~~~~~~~

正如输出所解释的那样,编译器错误的原因是我们需要在作用域中有正确的 trait 才能使用 next 方法。根据我们目前的讨论,你可能会合理地期望这个 trait 是 Stream,但它实际上是 StreamExtExtextension 的缩写,是 Rust 社区中用于扩展一个 trait 的常见模式。

我们将在本章末尾更详细地解释 StreamStreamExt trait,但现在你只需要知道 Stream trait 定义了一个低级别的接口,有效地结合了 IteratorFuture trait。StreamExtStream 之上提供了一组更高级别的 API,包括 next 方法以及其他类似于 Iterator trait 提供的实用方法。StreamStreamExt 尚未成为 Rust 标准库的一部分,但大多数生态系统 crate 都使用相同的定义。

修复编译器错误的方法是添加一个 use 语句来引入 trpl::StreamExt,如 Listing 17-31 所示。

extern crate trpl; // required for mdbook test

use trpl::StreamExt;

fn main() {
    trpl::run(async {
        let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        let iter = values.iter().map(|n| n * 2);
        let mut stream = trpl::stream_from_iter(iter);

        while let Some(value) = stream.next().await {
            println!("The value was: {value}");
        }
    });
}

将这些部分组合在一起后,这段代码按我们想要的方式工作!更重要的是,现在我们有了 StreamExt 在作用域中,我们可以使用它的所有实用方法,就像使用迭代器一样。例如,在 Listing 17-32 中,我们使用 filter 方法过滤掉所有不是三或五的倍数的项目。

extern crate trpl; // required for mdbook test

use trpl::StreamExt;

fn main() {
    trpl::run(async {
        let values = 1..101;
        let iter = values.map(|n| n * 2);
        let stream = trpl::stream_from_iter(iter);

        let mut filtered =
            stream.filter(|value| value % 3 == 0 || value % 5 == 0);

        while let Some(value) = filtered.next().await {
            println!("The value was: {value}");
        }
    });
}

当然,这并不是很有趣,因为我们可以使用普通的迭代器来完成同样的操作,而不需要任何异步操作。让我们看看流独有的功能。

组合流

许多概念自然表示为流:队列中可用的项目、从文件系统中逐步拉取的数据块(当完整数据集太大而无法放入计算机内存时),或随着时间的推移通过网络到达的数据。因为流是 future,我们可以将它们与任何其他类型的 future 结合使用,并以有趣的方式组合它们。例如,我们可以批量处理事件以避免触发过多的网络调用,为长时间运行的操作序列设置超时,或限制用户界面事件以避免不必要的工作。

让我们首先构建一个小消息流,作为我们从 WebSocket 或其他实时通信协议中可能看到的数据流的替代品,如 Listing 17-33 所示。

extern crate trpl; // required for mdbook test

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages = get_messages();

        while let Some(message) = messages.next().await {
            println!("{message}");
        }
    });
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
    for message in messages {
        tx.send(format!("Message: '{message}'")).unwrap();
    }

    ReceiverStream::new(rx)
}

首先,我们创建一个名为 get_messages 的函数,返回 impl Stream<Item = String>。在实现中,我们创建一个异步通道,循环遍历英文字母表的前 10 个字母,并将它们发送到通道中。

我们还使用了一个新类型:ReceiverStream,它将 trpl::channelrx 接收器转换为具有 next 方法的 Stream。回到 main 函数中,我们使用 while let 循环打印流中的所有消息。

当我们运行这段代码时,我们得到了预期的结果:

Message: 'a'
Message: 'b'
Message: 'c'
Message: 'd'
Message: 'e'
Message: 'f'
Message: 'g'
Message: 'h'
Message: 'i'
Message: 'j'

同样,我们可以使用常规的 Receiver API 甚至常规的 Iterator API 来完成此操作,所以让我们添加一个需要流的功能:为流中的每个项目添加超时,并对我们发出的项目添加延迟,如 Listing 17-34 所示。

extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};
use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages =
            pin!(get_messages().timeout(Duration::from_millis(200)));

        while let Some(result) = messages.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
    for message in messages {
        tx.send(format!("Message: '{message}'")).unwrap();
    }

    ReceiverStream::new(rx)
}

我们首先使用 timeout 方法为流添加超时,该方法来自 StreamExt trait。然后我们更新 while let 循环的主体,因为流现在返回一个 ResultOk 变体表示消息按时到达;Err 变体表示在消息到达之前超时已过。我们对该结果进行 match 匹配,并在成功接收到消息时打印消息,或在超时时打印通知。最后,请注意我们在应用超时后将消息固定,因为超时助手生成的流需要固定才能被轮询。

然而,由于消息之间没有延迟,这个超时不会改变程序的行为。让我们为发送的消息添加可变延迟,如 Listing 17-35 所示。

extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages =
            pin!(get_messages().timeout(Duration::from_millis(200)));

        while let Some(result) = messages.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

get_messages 中,我们使用 enumerate 迭代器方法与 messages 数组一起使用,以便我们可以获取每个项目的索引以及项目本身。然后我们对偶数索引的项目应用 100 毫秒的延迟,对奇数索引的项目应用 300 毫秒的延迟,以模拟我们在现实世界中可能看到的消息流的不同延迟。因为我们的超时是 200 毫秒,这应该会影响一半的消息。

为了在 get_messages 函数中在消息之间睡眠而不阻塞,我们需要使用异步。然而,我们不能将 get_messages 本身变成异步函数,因为那样我们会返回一个 Future<Output = Stream<Item = String>> 而不是 Stream<Item = String>>。调用者将不得不等待 get_messages 本身才能访问流。但请记住:在给定的 future 中,所有事情都是线性发生的;并发发生在 future 之间。等待 get_messages 将要求它在返回接收器流之前发送所有消息,包括每个消息之间的睡眠延迟。结果,超时将毫无用处。流本身不会有延迟;它们都会在流可用之前发生。

相反,我们将 get_messages 保留为返回流的常规函数,并生成一个任务来处理异步的 sleep 调用。

注意:以这种方式调用 spawn_task 是有效的,因为我们已经设置了运行时;如果我们没有设置,它将导致 panic。其他实现选择了不同的权衡:它们可能会生成一个新的运行时并避免 panic,但最终会有一些额外的开销,或者它们可能根本不提供独立生成任务的方式而不引用运行时。确保你知道你的运行时选择了什么权衡,并相应地编写代码!

现在我们的代码有了更有趣的结果。在每对消息之间,会出现一个 Problem: Elapsed(()) 错误。

Message: 'a'
Problem: Elapsed(())
Message: 'b'
Message: 'c'
Problem: Elapsed(())
Message: 'd'
Message: 'e'
Problem: Elapsed(())
Message: 'f'
Message: 'g'
Problem: Elapsed(())
Message: 'h'
Message: 'i'
Problem: Elapsed(())
Message: 'j'

超时并不会阻止消息最终到达。我们仍然会收到所有原始消息,因为我们的通道是 无界的:它可以容纳尽可能多的消息,只要内存允许。如果消息在超时之前没有到达,我们的流处理程序将处理这种情况,但当它再次轮询流时,消息可能已经到达。

如果需要,你可以通过使用其他类型的通道或其他类型的流来获得不同的行为。让我们通过将时间间隔流与消息流结合起来,看看其中的一个实际应用。

合并流

首先,让我们创建另一个流,如果我们直接运行它,它将每毫秒发出一个项目。为了简单起见,我们可以使用 sleep 函数在延迟后发送消息,并将其与我们在 get_messages 中使用的从通道创建流的方法结合起来。不同的是,这次我们将发送经过的间隔计数,因此返回类型将是 impl Stream<Item = u32>,我们可以将该函数称为 get_intervals(见 Listing 17-36)。

extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages =
            pin!(get_messages().timeout(Duration::from_millis(200)));

        while let Some(result) = messages.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

我们首先在任务中定义一个 count。(我们也可以在任务外部定义它,但限制任何给定变量的范围更清晰。)然后我们创建一个无限循环。循环的每次迭代都会异步睡眠一毫秒,增加计数,然后将其发送到通道中。因为这一切都包装在 spawn_task 创建的任务中,所以所有内容——包括无限循环——都会随着运行时一起被清理。

这种无限循环在异步 Rust 中相当常见:许多程序需要无限期地运行。使用异步,只要每次循环迭代中至少有一个 await 点,这不会阻塞任何其他内容。

现在,回到我们的主函数的异步块中,我们可以尝试合并 messagesintervals 流,如 Listing 17-37 所示。

extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals();
        let merged = messages.merge(intervals);

        while let Some(result) = merged.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

我们首先调用 get_intervals。然后我们使用 merge 方法合并 messagesintervals 流,该方法将多个流组合成一个流,只要源流中的项目可用,就会生成项目,而不强加任何特定的顺序。最后,我们循环遍历该组合流,而不是 messages

此时,messagesintervals 都不需要被固定或可变,因为两者都将被组合到单个 merged 流中。然而,这个 merge 调用无法编译!(while let 循环中的 next 调用也无法编译,但我们会回到这个问题。)这是因为两个流具有不同的类型。messages 流的类型是 Timeout<impl Stream<Item = String>>,其中 Timeout 是为 timeout 调用实现 Stream 的类型。intervals 流的类型是 impl Stream<Item = u32>。要合并这两个流,我们需要将其中一个转换为与另一个匹配。我们将重新处理 intervals 流,因为 messages 已经是我们想要的基本格式,并且必须处理超时错误(见 Listing 17-38)。

extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval: {count}"))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

首先,我们可以使用 map 辅助方法将 intervals 转换为字符串。其次,我们需要匹配 messagesTimeout。因为我们实际上并不希望 intervals 有超时,所以我们可以创建一个比其他持续时间更长的超时。在这里,我们使用 Duration::from_secs(10) 创建一个 10 秒的超时。最后,我们需要使 stream 可变,以便 while let 循环的 next 调用可以遍历流,并将其固定以便安全地执行此操作。这使我们几乎达到了我们需要的位置。一切类型检查都通过了。如果你运行这个,会有两个问题。首先,它永远不会停止!你需要使用 ctrl-c 来停止它。其次,来自英文字母表的消息将淹没在所有间隔计数器消息中:

--snip--
Interval: 38
Interval: 39
Interval: 40
Message: 'a'
Interval: 41
Interval: 42
Interval: 43
--snip--

Listing 17-39 展示了一种解决最后两个问题的方法。

extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval: {count}"))
            .throttle(Duration::from_millis(100))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals).take(20);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

首先,我们在 intervals 流上使用 throttle 方法,以免它压倒 messages 流。Throttling 是一种限制函数调用速率的方式——或者在这种情况下,限制流被轮询的频率。每 100 毫秒一次应该足够了,因为我们的消息大约每 100 毫秒到达一次。

为了限制我们从流中接受的项目数量,我们将 take 方法应用于 merged 流,因为我们希望限制最终输出,而不仅仅是其中一个流。

现在当我们运行程序时,它在从流中拉取 20 个项目后停止,并且间隔不会压倒消息。我们也不会得到 Interval: 100Interval: 200 等等,而是得到 Interval: 1Interval: 2 等等——即使我们有一个可以每毫秒生成一个事件的源流。这是因为 throttle 调用生成了一个包装原始流的新流,以便原始流仅在节流速率下被轮询,而不是其“原生”速率。我们没有一堆未处理的间隔消息被选择忽略。相反,我们一开始就没有生成那些间隔消息!这是 Rust 的 future 固有的“惰性”再次发挥作用,允许我们选择性能特征。

Interval: 1
Message: 'a'
Interval: 2
Interval: 3
Problem: Elapsed(())
Interval: 4
Message: 'b'
Interval: 5
Message: 'c'
Interval: 6
Interval: 7
Problem: Elapsed(())
Interval: 8
Message: 'd'
Interval: 9
Message: 'e'
Interval: 10
Interval: 11
Problem: Elapsed(())
Interval: 12

我们还需要处理最后一件事:错误!对于这两个基于通道的流,当通道的另一端关闭时,send 调用可能会失败——这只是运行时执行组成流的 future 的方式。到目前为止,我们通过调用 unwrap 忽略了这种可能性,但在一个行为良好的应用程序中,我们应该显式地处理错误,至少通过结束循环以便我们不再尝试发送任何消息。Listing 17-40 展示了一个简单的错误策略:打印问题然后从循环中 break

extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval #{count}"))
            .throttle(Duration::from_millis(500))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals).take(20);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(item) => println!("{item}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    });
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];

        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            if let Err(send_error) = tx.send(format!("Message: '{message}'")) {
                eprintln!("Cannot send message '{message}': {send_error}");
                break;
            }
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;

            if let Err(send_error) = tx.send(count) {
                eprintln!("Could not send interval {count}: {send_error}");
                break;
            };
        }
    });

    ReceiverStream::new(rx)
}

通常,处理消息发送错误的正确方式会有所不同;只需确保你有一个策略。

现在我们已经看到了很多异步的实际应用,让我们退一步,深入了解 Rust 如何使异步工作的 FutureStream 和其他关键 trait 的一些细节。

深入探讨异步特性

在本章中,我们以各种方式使用了 FuturePinUnpinStreamStreamExt 特性。不过,到目前为止,我们避免深入探讨它们的工作原理或它们如何协同工作,这对于日常的 Rust 工作来说大多数情况下是没问题的。然而,有时你会遇到需要理解更多细节的情况。在本节中,我们将深入探讨这些细节,以帮助你在这些情况下解决问题,但仍然将 真正 的深入探讨留给其他文档。

Future 特性

让我们首先仔细看看 Future 特性的工作原理。以下是 Rust 对其的定义:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

这个特性定义包含了一些新的类型以及一些我们之前没有见过的语法,所以让我们逐部分解析这个定义。

首先,Future 的关联类型 Output 表示 future 解析后的结果。这与 Iterator 特性的 Item 关联类型类似。其次,Future 还有一个 poll 方法,它为其 self 参数接受一个特殊的 Pin 引用,并接受一个对 Context 类型的可变引用,返回一个 Poll<Self::Output>。我们稍后会讨论 PinContext。现在,让我们关注这个方法返回的内容,即 Poll 类型:

#![allow(unused)]
fn main() {
enum Poll<T> {
    Ready(T),
    Pending,
}
}

这个 Poll 类型类似于 Option。它有一个带有值的变体 Ready(T),以及一个没有值的变体 Pending。不过,Poll 的含义与 Option 大不相同!Pending 变体表示 future 仍有工作要做,因此调用者需要稍后再检查。Ready 变体表示 future 已完成其工作,并且 T 值可用。

注意:对于大多数 future,调用者不应在 future 返回 Ready 后再次调用 poll。许多 future 在准备就绪后再次轮询时会 panic。可以安全地再次轮询的 future 会在其文档中明确说明这一点。这与 Iterator::next 的行为类似。

当你看到使用 await 的代码时,Rust 在底层将其编译为调用 poll 的代码。如果你回顾一下 Listing 17-4,我们在其中打印出单个 URL 的页面标题,Rust 会将其编译为类似(尽管不完全相同)以下内容:

match page_title(url).poll() {
    Ready(page_title) => match page_title {
        Some(title) => println!("The title for {url} was {title}"),
        None => println!("{url} had no title"),
    }
    Pending => {
        // 但这里应该放什么呢?
    }
}

当 future 仍然是 Pending 时,我们应该做什么?我们需要某种方式一次又一次地尝试,直到 future 最终准备就绪。换句话说,我们需要一个循环:

let mut page_title_fut = page_title(url);
loop {
    match page_title_fut.poll() {
        Ready(value) => match page_title {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
        Pending => {
            // 继续
        }
    }
}

如果 Rust 将其编译为完全相同的代码,那么每个 await 都会阻塞——这与我们的初衷完全相反!相反,Rust 确保循环可以将控制权交给可以暂停此 future 的工作以处理其他 future,然后再稍后检查此 future。正如我们所看到的,这个“东西”就是异步运行时,这种调度和协调工作是它的主要职责之一。

在本章的前面部分,我们描述了等待 rx.recv 的情况。recv 调用返回一个 future,而等待 future 会轮询它。我们注意到,运行时会在 future 准备好时暂停它,直到它准备好返回 Some(message) 或当通道关闭时返回 None。通过我们对 Future 特性(特别是 Future::poll)的深入理解,我们可以看到这是如何工作的。当 future 返回 Poll::Pending 时,运行时知道 future 尚未准备好。相反,当 poll 返回 Poll::Ready(Some(message))Poll::Ready(None) 时,运行时知道 future 已经 准备好并推进它。

运行时的具体实现细节超出了本书的范围,但关键是要了解 future 的基本机制:运行时 轮询 它负责的每个 future,当 future 尚未准备好时将其放回睡眠状态。

PinUnpin 特性

当我们在 Listing 17-16 中引入 pinning 的概念时,我们遇到了一个非常棘手的错误消息。以下是该错误消息的相关部分:

error[E0277]: `{async block@src/main.rs:10:23: 10:33}` cannot be unpinned
  --> src/main.rs:48:33
   |
48 |         trpl::join_all(futures).await;
   |                                 ^^^^^ the trait `Unpin` is not implemented for `{async block@src/main.rs:10:23: 10:33}`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<{async block@src/main.rs:10:23: 10:33}>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

这个错误消息不仅告诉我们我们需要 pin 这些值,还解释了为什么需要 pinning。trpl::join_all 函数返回一个名为 JoinAll 的结构体。该结构体泛型化了一个类型 F,该类型被约束为实现 Future 特性。直接使用 await 等待 future 会隐式地 pin 该 future。这就是为什么我们不需要在我们想要等待 future 的地方到处使用 pin!

然而,我们在这里并不是直接等待一个 future。相反,我们通过将一组 future 传递给 join_all 函数来构造一个新的 future JoinAlljoin_all 的签名要求集合中的项的类型都实现 Future 特性,而 Box<T> 只有在它包装的 T 是实现 Unpin 特性的 future 时才实现 Future

这需要消化很多内容!为了真正理解它,让我们进一步深入了解 Future 特性的实际工作原理,特别是围绕 pinning 的部分。

再次查看 Future 特性的定义:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    // 必需的方法
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

cx 参数及其 Context 类型是运行时实际知道何时检查任何给定 future 的关键,同时仍然保持惰性。同样,具体的工作原理超出了本章的范围,你通常只需要在编写自定义 Future 实现时考虑这一点。我们将重点关注 self 的类型,因为这是我们第一次看到 self 带有类型注解的方法。self 的类型注解与其他函数参数的类型注解类似,但有两个关键区别:

  • 它告诉 Rust self 必须是什么类型才能调用该方法。

  • 它不能是任何类型。它仅限于该方法实现的类型、对该类型的引用或智能指针,或者包装对该类型引用的 Pin

我们将在 第 18 章 中看到更多关于这种语法的内容。现在,只需要知道如果我们想要轮询一个 future 以检查它是 Pending 还是 Ready(Output),我们需要一个 Pin 包装的可变引用类型。

Pin 是指针类型(如 &&mutBoxRc)的包装器。(技术上,Pin 适用于实现 DerefDerefMut 特性的类型,但这实际上等同于仅适用于指针。)Pin 本身不是指针,也没有像 RcArc 那样具有引用计数的行为;它纯粹是编译器用来强制执行指针使用约束的工具。

回想一下,await 是通过调用 poll 实现的,这开始解释了我们之前看到的错误消息,但那是关于 Unpin 的,而不是 Pin。那么 PinUnpin 究竟有什么关系,为什么 Future 需要 selfPin 类型才能调用 poll

记得本章前面提到的,future 中的一系列 await 点被编译成一个状态机,编译器确保该状态机遵循 Rust 的所有正常规则,包括借用和所有权。为了实现这一点,Rust 会查看从一个 await 点到下一个 await 点或 async 块结束之间需要哪些数据。然后它在编译后的状态机中创建相应的变体。每个变体都获得它需要的访问权限,以使用该部分源代码中的数据,无论是通过获取该数据的所有权还是通过获取对该数据的可变或不可变引用。

到目前为止,一切顺利:如果我们在给定的 async 块中关于所有权或引用有任何错误,借用检查器会告诉我们。当我们想要移动与该块对应的 future 时——比如将其移动到 Vec 中以传递给 join_all——事情就变得棘手了。

当我们移动一个 future 时——无论是通过将其推入数据结构以用作 join_all 的迭代器,还是通过从函数中返回它——这实际上意味着移动 Rust 为我们创建的状态机。与 Rust 中的大多数其他类型不同,Rust 为 async 块创建的 future 可能会在任何给定变体的字段中引用自身,如图 17-4 中的简化图示所示。

一个单列三行的表格,表示一个 future,fut1,前两行有数据值 0 和 1,第三行有一个箭头指向第二行,表示 future 内部的引用。
图 17-4:自引用数据类型。

然而,默认情况下,任何具有对自身引用的对象在移动时都是不安全的,因为引用始终指向它们所引用的实际内存地址(见图 17-5)。如果你移动数据结构本身,这些内部引用将指向旧位置。然而,该内存位置现在无效。一方面,当你对数据结构进行更改时,它的值不会更新。另一方面——更重要的是——计算机现在可以自由地将该内存用于其他用途!你可能会在以后读取完全不相关的数据。

两个表格,描绘了两个 future,fut1 和 fut2,每个都有一个列和三行,表示将 future 从 fut1 移动到 fut2 的结果。第一个,fut1,被灰显,每个索引中有一个问号,表示未知的内存。第二个,fut2,第一行和第二行有 0 和 1,第三行有一个箭头指向 fut1 的第二行,表示一个指针,它引用了 future 移动前的旧内存位置。
图 17-5:移动自引用数据类型的不安全结果

理论上,Rust 编译器可以尝试在每次移动对象时更新每个引用,但这可能会增加很多性能开销,特别是如果需要更新整个引用网络。如果我们能确保所讨论的数据结构 不会在内存中移动,我们就不必更新任何引用。这正是 Rust 的借用检查器所要求的:在安全代码中,它阻止你移动任何具有活动引用的项目。

Pin 在此基础上为我们提供了我们所需的保证。当我们通过将指向该值的指针包装在 Pin 中来 pin 一个值时,它就不能再移动了。因此,如果你有 Pin<Box<SomeType>>,你实际上是在 pin SomeType 值,而不是 Box 指针。图 17-6 说明了这个过程。

三个并排排列的盒子。第一个标记为“Pin”,第二个标记为“b1”,第三个标记为“pinned”。在“pinned”中是一个标记为“fut”的表格,有一个列;它表示一个 future,每个数据结构部分都有一个单元格。它的第一个单元格有值“0”,第二个单元格有一个箭头指向第四个也是最后一个单元格,其中包含值“1”,第三个单元格有虚线和省略号,表示数据结构可能还有其他部分。总的来说,“fut”表格表示一个自引用的 future。一个箭头从标记为“Pin”的盒子出发,穿过标记为“b1”的盒子,终止在“pinned”盒子内的“fut”表格中。
图 17-6:Pin 一个指向自引用 future 类型的 `Box`。

事实上,Box 指针仍然可以自由移动。记住:我们关心的是确保最终被引用的数据保持在原位。如果指针移动,但它指向的数据在同一个位置,如图 17-7 所示,就没有潜在的问题。作为一个独立的练习,查看这些类型的文档以及 std::pin 模块,并尝试弄清楚如何使用 Pin 包装 Box 来实现这一点。)关键是自引用类型本身不能移动,因为它仍然被 pin。

四个盒子大致排列成三列,与之前的图表相同,但第二列有变化。现在第二列有两个盒子,标记为“b1”和“b2”,“b1”被灰显,箭头从“Pin”穿过“b2”而不是“b1”,表示指针已从“b1”移动到“b2”,但“pinned”中的数据没有移动。
图 17-7:移动指向自引用 future 类型的 `Box`。

然而,大多数类型在移动时是完全安全的,即使它们恰好位于 Pin 包装器后面。我们只需要在项目具有内部引用时考虑 pinning。原始值(如数字和布尔值)是安全的,因为它们显然没有任何内部引用。你在 Rust 中通常使用的大多数类型也是如此。例如,你可以移动 Vec 而不必担心。根据我们目前所看到的,如果你有一个 Pin<Vec<String>>,你必须通过 Pin 提供的安全但限制性的 API 来完成所有操作,即使 Vec<String> 在没有其他引用的情况下总是可以安全移动。我们需要一种方法来告诉编译器在这种情况下移动项目是没问题的——这就是 Unpin 的用武之地。

Unpin 是一个标记特性,类似于我们在第 16 章中看到的 SendSync 特性,因此它本身没有任何功能。标记特性仅用于告诉编译器在特定上下文中使用实现给定特性的类型是安全的。Unpin 通知编译器给定类型 不需要 维护有关该值是否可以安全移动的任何保证。

SendSync 一样,编译器会自动为所有可以证明安全的类型实现 Unpin。一个特殊情况,再次类似于 SendSync,是 Unpin 为某个类型实现的情况。表示这种情况的符号是 impl !Unpin for SomeType,其中 SomeType 是需要维护这些保证以在 Pin 中使用指向该类型的指针时保持安全的类型的名称。

换句话说,关于 PinUnpin 之间的关系,有两件事需要记住。首先,Unpin 是“正常”情况,而 !Unpin 是特殊情况。其次,类型是否实现 Unpin!Unpin 在你使用像 Pin<&mut SomeType> 这样的 pinned 指针时才重要。

为了具体说明这一点,考虑一个 String:它有长度和组成它的 Unicode 字符。我们可以将 String 包装在 Pin 中,如图 17-8 所示。然而,String 会自动实现 Unpin,Rust 中的大多数其他类型也是如此。

并发工作流
图 17-8:Pin 一个 `String`;虚线表示 `String` 实现了 `Unpin` 特性,因此没有被 pin。

因此,我们可以做一些如果 String 实现 !Unpin 而不是 Unpin 时是非法的操作,例如在内存中的完全相同的位置用一个字符串替换另一个字符串,如图 17-9 所示。这不会违反 Pin 的契约,因为 String 没有任何使其在移动时不安全的内部引用!这正是为什么它实现 Unpin 而不是 !Unpin

并发工作流
图 17-9:在内存中用完全不同的 `String` 替换 `String`。

现在我们已经了解了足够多的内容,可以理解 Listing 17-17 中 join_all 调用报告的错误。我们最初尝试将 async 块生成的 future 移动到 Vec<Box<dyn Future<Output = ()>>> 中,但正如我们所看到的,这些 future 可能具有内部引用,因此它们不实现 Unpin。它们需要被 pin,然后我们可以将 Pin 类型传递给 Vec,确信 future 中的底层数据 不会 被移动。

PinUnpin 主要用于构建低级库,或者当你构建运行时本身时,而不是用于日常的 Rust 代码。然而,当你在错误消息中看到这些特性时,现在你将更好地了解如何修复你的代码!

注意:PinUnpin 的这种组合使得在 Rust 中安全地实现一类复杂的类型成为可能,否则这些类型将因为自引用而变得具有挑战性。需要 Pin 的类型在今天的异步 Rust 中最常见,但偶尔你可能也会在其他上下文中看到它们。

PinUnpin 的具体工作原理以及它们需要遵守的规则在 std::pin 的 API 文档中有详细说明,所以如果你有兴趣了解更多,这是一个很好的起点。

如果你想更深入地了解底层工作原理,请参阅 Asynchronous Programming in Rust第 2 章第 4 章

Stream 特性

现在你已经对 FuturePinUnpin 特性有了更深入的理解,我们可以将注意力转向 Stream 特性。正如你在本章前面学到的,stream 类似于异步迭代器。然而,与 IteratorFuture 不同,Stream 在撰写本文时还没有在标准库中定义,但 futures crate 中有一个非常常见的定义在整个生态系统中使用。

让我们在查看 Stream 特性如何将它们结合在一起之前,回顾一下 IteratorFuture 特性的定义。从 Iterator 中,我们有序列的概念:它的 next 方法提供了一个 Option<Self::Item>。从 Future 中,我们有随时间准备就绪的概念:它的 poll 方法提供了一个 Poll<Self::Output>。为了表示随时间准备就绪的项目序列,我们定义了一个 Stream 特性,将这些特性结合在一起:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;

    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>
    ) -> Poll<Option<Self::Item>>;
}
}

Stream 特性定义了一个名为 Item 的关联类型,表示 stream 生成的项目的类型。这与 Iterator 类似,其中可能有零到多个项目,而与 Future 不同,后者总是有一个 Output,即使它是单元类型 ()

Stream 还定义了一个方法来获取这些项目。我们称之为 poll_next,以明确它像 Future::poll 一样进行轮询,并像 Iterator::next 一样生成一系列项目。它的返回类型结合了 PollOption。外部类型是 Poll,因为它必须像 future 一样检查是否准备就绪。内部类型是 Option,因为它需要像迭代器一样发出信号,表示是否有更多消息。

类似于这个定义的内容很可能会成为 Rust 标准库的一部分。与此同时,它是大多数运行时工具包的一部分,因此你可以依赖它,我们接下来介绍的所有内容通常都适用!

在我们看到的流式处理部分的示例中,我们没有使用 poll_next Stream,而是使用了 nextStreamExt。当然,我们可以通过手动编写自己的 Stream 状态机直接使用 poll_next API,就像我们可以通过 poll 方法直接使用 future 一样。不过,使用 await 要好得多,而 StreamExt 特性提供了 next 方法,因此我们可以这样做:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;
    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Option<Self::Item>>;
}

trait StreamExt: Stream {
    async fn next(&mut self) -> Option<Self::Item>
    where
        Self: Unpin;

    // other methods...
}
}

注意:我们在本章前面使用的实际定义看起来与此略有不同,因为它支持尚未支持在特性中使用 async 函数的 Rust 版本。因此,它看起来像这样:

fn next(&mut self) -> Next<'_, Self> where Self: Unpin;

这个 Next 类型是一个实现 Futurestruct,并允许我们使用 Next<'_, Self> 命名对 self 的引用的生命周期,以便 await 可以使用此方法。

StreamExt 特性也是所有有趣方法的家园,这些方法可用于流。StreamExt 会自动为每个实现 Stream 的类型实现,但这些特性是分开定义的,以便社区可以在不影响基础特性的情况下迭代便利 API。

trpl crate 中使用的 StreamExt 版本中,该特性不仅定义了 next 方法,还提供了 next 的默认实现,该实现正确处理了调用 Stream::poll_next 的细节。这意味着即使你需要编写自己的流式数据类型,你 只需要 实现 Stream,然后任何使用你的数据类型的人都可以自动使用 StreamExt 及其方法。

这就是我们将要介绍的这些特性的低级细节的全部内容。总结一下,让我们考虑一下 future(包括 stream)、任务和线程是如何协同工作的!

综合应用:Futures、Tasks 和 Threads

正如我们在第16章中所见,线程提供了一种并发的方法。在本章中,我们看到了另一种方法:使用 asyncfuturesstreams。如果你在犹豫何时选择哪种方法,答案是:视情况而定!在许多情况下,选择并不是线程 async,而是线程 async

许多操作系统已经提供了基于线程的并发模型数十年,因此许多编程语言也支持它们。然而,这些模型并非没有权衡。在许多操作系统上,每个线程都会占用相当一部分内存,并且启动和关闭时会有一些开销。线程也只有在你的操作系统和硬件支持它们时才是一个选项。与主流的桌面和移动计算机不同,一些嵌入式系统根本没有操作系统,因此它们也没有线程。

async 模型提供了一组不同的——最终是互补的——权衡。在 async 模型中,并发操作不需要自己的线程。相反,它们可以在任务上运行,就像我们在 streams 部分中使用 trpl::spawn_task 从同步函数中启动工作一样。任务类似于线程,但它不是由操作系统管理,而是由库级代码管理:运行时。

在上一节中,我们看到可以通过使用 async 通道并生成一个可以从同步代码调用的 async 任务来构建一个流。我们可以用线程做完全相同的事情。在 Listing 17-40 中,我们使用了 trpl::spawn_tasktrpl::sleep。在 Listing 17-41 中,我们在 get_intervals 函数中用标准库中的 thread::spawnthread::sleep API 替换了它们。

extern crate trpl; // required for mdbook test

use std::{pin::pin, thread, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval #{count}"))
            .throttle(Duration::from_millis(500))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals).take(20);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(item) => println!("{item}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    });
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];

        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            if let Err(send_error) = tx.send(format!("Message: '{message}'")) {
                eprintln!("Cannot send message '{message}': {send_error}");
                break;
            }
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    // This is *not* `trpl::spawn` but `std::thread::spawn`!
    thread::spawn(move || {
        let mut count = 0;
        loop {
            // Likewise, this is *not* `trpl::sleep` but `std::thread::sleep`!
            thread::sleep(Duration::from_millis(1));
            count += 1;

            if let Err(send_error) = tx.send(count) {
                eprintln!("Could not send interval {count}: {send_error}");
                break;
            };
        }
    });

    ReceiverStream::new(rx)
}

如果你运行这段代码,输出与 Listing 17-40 完全相同。并且注意从调用代码的角度来看,这里的变化是多么小。更重要的是,即使我们的一个函数在运行时生成了一个 async 任务,而另一个函数生成了一个操作系统线程,生成的流并没有受到这些差异的影响。

尽管它们有相似之处,但这两种方法的行为非常不同,尽管在这个非常简单的示例中我们可能很难测量出来。我们可以在任何现代个人计算机上生成数百万个 async 任务。如果我们尝试用线程来做同样的事情,我们实际上会耗尽内存!

然而,这些 API 如此相似是有原因的。线程作为一组同步操作的边界;并发可以在线程之间进行。任务作为一组异步操作的边界;并发可以在任务之间和任务内部进行,因为任务可以在其主体中的 futures 之间切换。最后,futures 是 Rust 中最细粒度的并发单元,每个 future 可能代表其他 futures 的树。运行时——特别是它的执行器——管理任务,任务管理 futures。在这方面,任务类似于轻量级的、由运行时管理的线程,具有由运行时而不是操作系统管理的额外功能。

这并不意味着 async 任务总是比线程更好(反之亦然)。在某些方面,使用线程进行并发比使用 async 进行并发更简单。这可以是一个优势,也可以是一个劣势。线程有点“发射后不管”;它们没有原生的 future 等价物,因此它们只是运行到完成,除了操作系统本身之外不会被中断。也就是说,它们没有像 futures 那样内置的 任务内并发 支持。Rust 中的线程也没有取消机制——这个话题我们在本章中没有明确讨论,但隐含在我们每次结束一个 future 时,它的状态都会被正确清理的事实中。

这些限制也使线程比 futures 更难组合。例如,使用线程构建像我们在本章前面构建的 timeoutthrottle 方法这样的辅助工具要困难得多。futures 是更丰富的数据结构,这意味着它们可以更自然地组合在一起,正如我们所看到的。

因此,任务为我们提供了对 futures 的额外控制,允许我们选择在哪里以及如何将它们分组。事实证明,线程和任务通常可以很好地协同工作,因为任务可以(至少在某些运行时中)在线程之间移动。事实上,我们一直在使用的运行时——包括 spawn_blockingspawn_task 函数——默认是多线程的!许多运行时使用一种称为 工作窃取 的方法,根据线程当前的利用率在线程之间透明地移动任务,以提高系统的整体性能。这种方法实际上需要线程 任务,因此也需要 futures

在考虑何时使用哪种方法时,请考虑以下经验法则:

  • 如果工作是非常可并行的,例如处理一堆可以分别处理的数据,线程是更好的选择。
  • 如果工作是非常并发的,例如处理来自一堆不同来源的消息,这些消息可能以不同的间隔或不同的速率到达,async 是更好的选择。

如果你需要并行性和并发性,你不必在线程和 async 之间做出选择。你可以自由地同时使用它们,让每种方法发挥其最佳作用。例如,Listing 17-42 展示了在现实世界的 Rust 代码中这种混合的常见示例。

extern crate trpl; // for mdbook test

use std::{thread, time::Duration};

fn main() {
    let (tx, mut rx) = trpl::channel();

    thread::spawn(move || {
        for i in 1..11 {
            tx.send(i).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    trpl::run(async {
        while let Some(message) = rx.recv().await {
            println!("{message}");
        }
    });
}

我们首先创建一个 async 通道,然后生成一个线程,该线程拥有通道的发送端。在线程中,我们发送数字 1 到 10,每次发送之间休眠一秒钟。最后,我们运行一个使用 async 块创建的 future,并将其传递给 trpl::run,就像我们在本章中所做的那样。在该 future 中,我们等待这些消息,就像我们在其他消息传递示例中所看到的那样。

回到本章开头的场景,想象一下使用专用线程运行一组视频编码任务(因为视频编码是计算密集型的),但使用 async 通道通知 UI 这些操作已完成。在现实世界的用例中,有无数这样的组合示例。

总结

这并不是你在本书中最后一次看到并发。第21章中的项目将在比这里讨论的更现实的情况下应用这些概念,并更直接地比较使用线程与任务解决问题的方法。

无论你选择哪种方法,Rust 都为你提供了编写安全、快速、并发代码所需的工具——无论是用于高吞吐量的 Web 服务器还是嵌入式操作系统。

接下来,我们将讨论在 Rust 程序变大时,如何以惯用的方式建模问题和构建解决方案。此外,我们还将讨论 Rust 的惯用法与你可能熟悉的面向对象编程中的惯用法之间的关系。

面向对象编程特性

面向对象编程(OOP)是一种程序建模的方式。对象作为一个编程概念是在20世纪60年代的Simula编程语言中引入的。这些对象影响了Alan Kay的编程架构,其中对象之间通过消息传递进行通信。为了描述这种架构,他在1967年创造了术语“面向对象编程”。有许多相互竞争的定义描述了什么是OOP,根据其中一些定义,Rust是面向对象的,但根据其他定义,它则不是。在本章中,我们将探讨一些通常被认为是面向对象的特性,以及这些特性如何转化为Rust的惯用语法。然后,我们将展示如何在Rust中实现一个面向对象的设计模式,并讨论这样做与利用Rust的一些优势来实现解决方案之间的权衡。

面向对象语言的特性

在编程社区中,关于一门语言必须具备哪些特性才能被视为面向对象,并没有达成共识。Rust 受到了许多编程范式的影响,包括面向对象编程(OOP);例如,我们在第13章探讨了来自函数式编程的特性。可以说,OOP 语言共享某些共同的特性,即对象、封装和继承。让我们来看看这些特性各自意味着什么,以及 Rust 是否支持它们。

对象包含数据和行为

Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 所著的《设计模式:可复用面向对象软件的元素》(Addison-Wesley,1994)一书,俗称《四人帮》书,是一本面向对象设计模式的目录。它以这种方式定义了 OOP:

面向对象程序由对象组成。对象 封装了数据以及操作这些数据的程序。这些程序通常被称为 方法操作

根据这个定义,Rust 是面向对象的:结构体和枚举包含数据,而 impl 块为结构体和枚举提供了方法。尽管带有方法的结构体和枚举不被称为对象,但根据四人帮对对象的定义,它们提供了相同的功能。

封装隐藏实现细节

另一个通常与 OOP 相关的概念是 封装,这意味着使用对象的代码无法访问对象的实现细节。因此,与对象交互的唯一方式是通过其公共 API;使用对象的代码不应该能够直接访问对象的内部并更改数据或行为。这使得程序员可以在不需要更改使用对象的代码的情况下,更改和重构对象的内部实现。

我们在第7章讨论了如何控制封装:我们可以使用 pub 关键字来决定代码中的哪些模块、类型、函数和方法应该是公共的,而默认情况下其他所有内容都是私有的。例如,我们可以定义一个结构体 AveragedCollection,它有一个包含 i32 值向量的字段。该结构体还可以有一个包含向量中值的平均值的字段,这意味着不需要在每次需要时计算平均值。换句话说,AveragedCollection 会为我们缓存计算出的平均值。清单18-1展示了 AveragedCollection 结构体的定义:

pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

该结构体被标记为 pub,以便其他代码可以使用它,但结构体中的字段仍然是私有的。在这种情况下,这一点很重要,因为我们希望确保每当向列表中添加或删除值时,平均值也会更新。我们通过在结构体上实现 addremoveaverage 方法来实现这一点,如清单18-2所示:

pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

impl AveragedCollection {
    pub fn add(&mut self, value: i32) {
        self.list.push(value);
        self.update_average();
    }

    pub fn remove(&mut self) -> Option<i32> {
        let result = self.list.pop();
        match result {
            Some(value) => {
                self.update_average();
                Some(value)
            }
            None => None,
        }
    }

    pub fn average(&self) -> f64 {
        self.average
    }

    fn update_average(&mut self) {
        let total: i32 = self.list.iter().sum();
        self.average = total as f64 / self.list.len() as f64;
    }
}

公共方法 addremoveaverage 是访问或修改 AveragedCollection 实例中数据的唯一方式。当使用 add 方法向 list 添加项目或使用 remove 方法删除项目时,每个方法的实现都会调用私有的 update_average 方法,该方法负责更新 average 字段。

我们将 listaverage 字段保持为私有,这样外部代码就无法直接向 list 字段添加或删除项目;否则,当 list 发生变化时,average 字段可能会变得不同步。average 方法返回 average 字段中的值,允许外部代码读取 average 但不能修改它。

由于我们已经封装了 AveragedCollection 结构体的实现细节,因此我们可以在未来轻松更改某些方面,例如数据结构。例如,我们可以使用 HashSet<i32> 而不是 Vec<i32> 作为 list 字段。只要 addremoveaverage 公共方法的签名保持不变,使用 AveragedCollection 的代码就不需要更改。如果我们使 list 变为公共的,情况就不一定是这样了:HashSet<i32>Vec<i32> 有不同的添加和删除项目的方法,因此如果外部代码直接修改 list,则可能需要更改。

如果封装是一门语言被视为面向对象的必要条件,那么 Rust 满足这一要求。代码中不同部分是否使用 pub 的选项使得实现细节的封装成为可能。

继承作为类型系统和代码共享

继承 是一种机制,通过它,一个对象可以从另一个对象的定义中继承元素,从而获得父对象的数据和行为,而无需再次定义它们。

如果一门语言必须具备继承才能被视为面向对象,那么 Rust 不是这样的语言。在不使用宏的情况下,无法定义一个继承父结构体字段和方法实现的结构体。

然而,如果你习惯于在编程工具箱中使用继承,你可以根据最初选择继承的原因,在 Rust 中使用其他解决方案。

选择继承主要有两个原因。一个是为了代码的重用:你可以为一种类型实现特定的行为,而继承使你能够将该实现重用于不同的类型。在 Rust 代码中,你可以通过默认 trait 方法实现来有限地做到这一点,正如我们在清单10-14中看到的,当时我们在 Summary trait 上添加了 summarize 方法的默认实现。任何实现 Summary trait 的类型都会自动拥有 summarize 方法,而无需编写额外的代码。这类似于父类具有方法的实现,而继承的子类也具有该方法的实现。我们还可以在实现 Summary trait 时覆盖 summarize 方法的默认实现,这类似于子类覆盖从父类继承的方法的实现。

使用继承的另一个原因与类型系统有关:为了使子类型可以在与父类型相同的地方使用。这也被称为 多态性,这意味着如果多个对象共享某些特征,你可以在运行时将它们相互替换。

多态性

对许多人来说,多态性与继承是同义词。但它实际上是一个更广泛的概念,指的是可以处理多种类型数据的代码。对于继承来说,这些类型通常是子类。

Rust 使用泛型来抽象不同的可能类型,并使用 trait 约束来对这些类型必须提供的内容施加限制。这有时被称为 有界参数多态性

作为一种编程设计解决方案,继承最近在许多编程语言中不再受欢迎,因为它经常面临共享过多代码的风险。子类不应该总是共享其父类的所有特征,但在继承中会这样做。这可能会使程序的设计变得不那么灵活。它还引入了在子类上调用没有意义或导致错误的方法的可能性,因为这些方法不适用于子类。此外,某些语言只允许单一继承(意味着子类只能继承一个类),进一步限制了程序设计的灵活性。

出于这些原因,Rust 采取了不同的方法,使用 trait 对象而不是继承。让我们看看 trait 对象如何在 Rust 中实现多态性。

使用允许不同类型值的 Trait 对象

在第 8 章中,我们提到向量(vector)的一个限制是它们只能存储单一类型的元素。我们在 Listing 8-9 中创建了一个变通方案,定义了一个 SpreadsheetCell 枚举,它有变体来保存整数、浮点数和文本。这意味着我们可以在每个单元格中存储不同类型的数据,并且仍然有一个表示一行单元格的向量。当我们的可互换项是编译时已知的一组固定类型时,这是一个非常好的解决方案。

然而,有时我们希望库的用户能够扩展在特定情况下有效的类型集。为了展示如何实现这一点,我们将创建一个图形用户界面(GUI)工具示例,该工具会遍历一个项目列表,并在每个项目上调用 draw 方法将其绘制到屏幕上——这是 GUI 工具的常见技术。我们将创建一个名为 gui 的库 crate,其中包含 GUI 库的结构。这个 crate 可能包含一些供人们使用的类型,例如 ButtonTextField。此外,gui 用户可能希望创建他们自己的可绘制类型:例如,一个程序员可能会添加一个 Image,而另一个程序员可能会添加一个 SelectBox

我们不会为这个示例实现一个完整的 GUI 库,但会展示这些部分如何组合在一起。在编写库时,我们无法知道并定义所有其他程序员可能想要创建的类型。但我们确实知道 gui 需要跟踪许多不同类型的值,并且它需要在这些不同类型的值上调用 draw 方法。它不需要确切知道调用 draw 方法时会发生什么,只需要知道该值将具有我们可以调用的该方法。

在具有继承的语言中,我们可能会定义一个名为 Component 的类,该类具有一个名为 draw 的方法。其他类,如 ButtonImageSelectBox,将从 Component 继承,从而继承 draw 方法。它们可以各自覆盖 draw 方法以定义它们的自定义行为,但框架可以将所有这些类型视为 Component 实例并调用它们的 draw 方法。但由于 Rust 没有继承,我们需要另一种方式来构建 gui 库,以允许用户使用新类型扩展它。

为通用行为定义 Trait

为了实现我们希望 gui 具有的行为,我们将定义一个名为 Draw 的 trait,该 trait 将有一个名为 draw 的方法。然后我们可以定义一个接受 trait 对象的向量。trait 对象 既指向实现我们指定 trait 的类型的实例,也指向在运行时用于查找该类型上的 trait 方法的表。我们通过指定某种指针(例如 & 引用或 Box<T> 智能指针)来创建 trait 对象,然后是 dyn 关键字,最后指定相关的 trait。(我们将在第 20 章的 “动态大小类型和 Sized Trait” 中讨论 trait 对象必须使用指针的原因。)我们可以使用 trait 对象来代替泛型或具体类型。无论我们在哪里使用 trait 对象,Rust 的类型系统都会在编译时确保在该上下文中使用的任何值都实现了 trait 对象的 trait。因此,我们不需要在编译时知道所有可能的类型。

我们提到过,在 Rust 中,我们避免将结构体和枚举称为“对象”,以将它们与其他语言的对象区分开来。在结构体或枚举中,结构体字段中的数据与 impl 块中的行为是分开的,而在其他语言中,数据和行为组合成一个概念通常被称为对象。然而,trait 对象 确实 更类似于其他语言中的对象,因为它们结合了数据和行为。但 trait 对象与传统对象的不同之处在于,我们不能向 trait 对象添加数据。trait 对象不像其他语言中的对象那样普遍有用:它们的特定目的是允许跨通用行为进行抽象。

Listing 18-3 展示了如何定义一个名为 Draw 的 trait,该 trait 有一个名为 draw 的方法。

pub trait Draw {
    fn draw(&self);
}

这种语法应该从我们在第 10 章讨论如何定义 trait 时看起来很熟悉。接下来是一些新语法:Listing 18-4 定义了一个名为 Screen 的结构体,它包含一个名为 components 的向量。这个向量的类型是 Box<dyn Draw>,这是一个 trait 对象;它是 Box 中实现 Draw trait 的任何类型的替身。

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

Screen 结构体上,我们将定义一个名为 run 的方法,该方法将在其 components 中的每个组件上调用 draw 方法,如 Listing 18-5 所示。

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

这与使用带有 trait 约束的泛型类型参数定义结构体不同。泛型类型参数一次只能替换为一个具体类型,而 trait 对象允许在运行时用多个具体类型填充 trait 对象。例如,我们可以使用泛型类型和 trait 约束来定义 Screen 结构体,如 Listing 18-6 所示:

pub trait Draw {
    fn draw(&self);
}

pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

impl<T> Screen<T>
where
    T: Draw,
{
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

这将我们限制为具有所有组件类型为 Button 或所有组件类型为 TextFieldScreen 实例。如果你只会有同质集合,使用泛型和 trait 约束是更可取的,因为这些定义将在编译时单态化为使用具体类型。

另一方面,使用 trait 对象的方法,一个 Screen 实例可以包含一个 Vec<T>,其中包含 Box<Button>Box<TextField>。让我们看看这是如何工作的,然后我们将讨论运行时性能的影响。

实现 Trait

现在我们将添加一些实现 Draw trait 的类型。我们将提供 Button 类型。再次强调,实际实现 GUI 库超出了本书的范围,因此 draw 方法在其主体中不会有任何有用的实现。为了想象实现可能是什么样子,Button 结构体可能有 widthheightlabel 字段,如 Listing 18-7 所示:

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // code to actually draw a button
    }
}

Button 上的 widthheightlabel 字段将与其他组件上的字段不同;例如,TextField 类型可能有这些相同的字段加上一个 placeholder 字段。我们想要在屏幕上绘制的每种类型都将实现 Draw trait,但将在 draw 方法中使用不同的代码来定义如何绘制该特定类型,如 Button 在这里所做的那样(没有实际的 GUI 代码,如前所述)。例如,Button 类型可能有一个额外的 impl 块,其中包含与用户点击按钮时发生的情况相关的方法。这些方法不适用于像 TextField 这样的类型。

如果使用我们库的人决定实现一个具有 widthheightoptions 字段的 SelectBox 结构体,他们将在 SelectBox 类型上实现 Draw trait,如 Listing 18-8 所示。

use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

fn main() {}

我们库的用户现在可以编写他们的 main 函数来创建一个 Screen 实例。他们可以向 Screen 实例添加一个 SelectBox 和一个 Button,方法是将每个放入 Box<T> 中以成为 trait 对象。然后他们可以调用 Screen 实例上的 run 方法,该方法将在每个组件上调用 draw。Listing 18-9 展示了这个实现:

use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

use gui::{Button, Screen};

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No"),
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };

    screen.run();
}

当我们编写库时,我们不知道有人可能会添加 SelectBox 类型,但我们的 Screen 实现能够操作新类型并绘制它,因为 SelectBox 实现了 Draw trait,这意味着它实现了 draw 方法。

这个概念——只关心值响应的消息而不是值的具体类型——类似于动态类型语言中的 鸭子类型 概念:如果它走起来像鸭子,叫起来像鸭子,那么它一定是鸭子!在 Listing 18-5 中 Screen 上的 run 实现中,run 不需要知道每个组件的具体类型是什么。它不检查组件是 Button 还是 SelectBox 的实例,它只是在组件上调用 draw 方法。通过指定 Box<dyn Draw> 作为 components 向量中值的类型,我们定义了 Screen 需要我们可以调用 draw 方法的值。

使用 trait 对象和 Rust 的类型系统编写类似于使用鸭子类型的代码的优势在于,我们不必在运行时检查值是否实现了特定方法,也不必担心如果值没有实现方法但我们仍然调用它时会出现错误。如果值没有实现 trait 对象所需的 trait,Rust 将不会编译我们的代码。

例如,Listing 18-10 展示了如果我们尝试创建一个以 String 作为组件的 Screen 会发生什么。

use gui::Screen;

fn main() {
    let screen = Screen {
        components: vec![Box::new(String::from("Hi"))],
    };

    screen.run();
}

我们会得到这个错误,因为 String 没有实现 Draw trait:

$ cargo run
   Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
 --> src/main.rs:5:26
  |
5 |         components: vec![Box::new(String::from("Hi"))],
  |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
  |
  = help: the trait `Draw` is implemented for `Button`
  = note: required for the cast from `Box<String>` to `Box<dyn Draw>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error

这个错误让我们知道,我们要么向 Screen 传递了我们不打算传递的东西,因此应该传递不同的类型,要么我们应该在 String 上实现 Draw,以便 Screen 能够在其上调用 draw

Trait 对象执行动态分派

回想一下第 10 章中 “使用泛型的代码性能” 中我们对编译器对泛型执行的单态化过程的讨论:编译器为我们使用的每个具体类型生成函数和方法的非泛型实现。单态化产生的代码执行的是 静态分派,这是编译器在编译时知道你在调用哪个方法的情况。这与 动态分派 相反,动态分派是编译器在编译时无法确定你在调用哪个方法的情况。在动态分派的情况下,编译器会生成代码,在运行时确定要调用哪个方法。

当我们使用 trait 对象时,Rust 必须使用动态分派。编译器不知道可能与使用 trait 对象的代码一起使用的所有类型,因此它不知道要调用哪个类型上的哪个方法。相反,在运行时,Rust 使用 trait 对象内部的指针来确定要调用哪个方法。这种查找会产生运行时成本,而静态分派不会产生这种成本。动态分派还阻止编译器选择内联方法的代码,这反过来又阻止了一些优化,Rust 有一些关于在哪里可以使用和不能使用动态分派的规则,称为 dyn 兼容性。这些规则超出了本次讨论的范围,但你可以在 参考 中阅读更多关于它们的信息。然而,我们在 Listing 18-5 中编写的代码确实获得了额外的灵活性,并且能够在 Listing 18-9 中支持,因此这是一个需要考虑的权衡。

实现面向对象的设计模式

状态模式 是一种面向对象的设计模式。该模式的核心在于我们定义了一组值可以拥有的内部状态。这些状态由一组 状态对象 表示,值的行为会根据其状态而变化。我们将通过一个博客文章结构的示例来演示,该结构有一个字段来保存其状态,状态对象可以是“草稿”、“审核中”或“已发布”中的一种。

状态对象共享功能:在 Rust 中,我们使用结构体和 trait 而不是对象和继承。每个状态对象负责自己的行为,并控制何时应该转换为另一个状态。持有状态对象的值对状态的不同行为或状态之间的转换时机一无所知。

使用状态模式的好处是,当程序的需求发生变化时,我们不需要修改持有状态的值的代码或使用该值的代码。我们只需要更新其中一个状态对象的代码来改变其规则,或者可能添加更多的状态对象。

首先,我们将以更传统的面向对象方式实现状态模式,然后我们将使用一种更符合 Rust 风格的方法。让我们逐步实现一个使用状态模式的博客文章工作流。

最终的功能将如下所示:

  1. 博客文章从空草稿开始。
  2. 当草稿完成后,请求对文章进行审核。
  3. 当文章被批准后,它将被发布。
  4. 只有已发布的博客文章会返回要打印的内容,因此未批准的文章不会意外发布。

任何其他尝试对文章进行的更改都应该无效。例如,如果我们尝试在请求审核之前批准一篇草稿博客文章,文章应保持为未发布的草稿。

Listing 18-11 展示了这个工作流的代码形式:这是我们将在一个名为 blog 的库 crate 中实现的 API 的示例用法。由于我们还没有实现 blog crate,这段代码目前还无法编译。

use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}

我们希望允许用户使用 Post::new 创建一个新的草稿博客文章。我们希望允许向博客文章添加文本。如果我们在批准之前立即尝试获取文章的内容,我们不应该得到任何文本,因为文章仍然是草稿。我们在代码中添加了 assert_eq! 用于演示目的。一个优秀的单元测试将是断言草稿博客文章从 content 方法返回一个空字符串,但我们不会为这个示例编写测试。

接下来,我们希望启用对文章的审核请求,并且希望在等待审核时 content 返回一个空字符串。当文章获得批准后,它应该被发布,这意味着当调用 content 时,文章的文本将被返回。

请注意,我们从 crate 中交互的唯一类型是 Post 类型。该类型将使用状态模式,并将持有一个值,该值将是表示文章可能处于的各种状态的三个状态对象之一——草稿、审核中或已发布。从一个状态到另一个状态的转换将在 Post 类型内部管理。状态的变化是对我们库的用户在 Post 实例上调用的方法的响应,但他们不必直接管理状态的变化。此外,用户不会在状态上犯错,例如在文章被审核之前发布它。

定义 Post 并在草稿状态下创建新实例

让我们开始实现这个库!我们知道我们需要一个公共的 Post 结构体来保存一些内容,因此我们将从结构体的定义和一个关联的公共 new 函数开始,以创建一个 Post 实例,如 Listing 18-12 所示。我们还将创建一个私有的 State trait,它将定义所有 Post 的状态对象必须具有的行为。

然后,Post 将在名为 state 的私有字段中持有一个 Box<dyn State> 的 trait 对象,该字段位于 Option<T> 中,以保存状态对象。稍后你会看到为什么需要 Option<T>

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
}

trait State {}

struct Draft {}

impl State for Draft {}

State trait 定义了不同文章状态共享的行为。状态对象是 DraftPendingReviewPublished,它们都将实现 State trait。目前,该 trait 没有任何方法,我们将从定义 Draft 状态开始,因为这是我们希望文章开始的状态。

当我们创建一个新的 Post 时,我们将其 state 字段设置为一个 Some 值,该值持有一个 Box。这个 Box 指向 Draft 结构体的一个新实例。这确保了每当我们创建一个新的 Post 实例时,它都将以草稿状态开始。由于 Poststate 字段是私有的,因此无法以任何其他状态创建 Post!在 Post::new 函数中,我们将 content 字段设置为一个新的空 String

存储文章内容的文本

我们在 Listing 18-11 中看到,我们希望能够调用一个名为 add_text 的方法,并传递一个 &str,然后将其添加为博客文章的文本内容。我们将其实现为一个方法,而不是将 content 字段公开为 pub,以便稍后我们可以实现一个方法来控制如何读取 content 字段的数据。add_text 方法非常简单,因此让我们将 Listing 18-13 中的实现添加到 impl Post 块中。

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

trait State {}

struct Draft {}

impl State for Draft {}

add_text 方法接受一个对 self 的可变引用,因为我们正在更改调用 add_textPost 实例。然后我们在 content 中的 String 上调用 push_str,并传递 text 参数以添加到保存的 content 中。这种行为不依赖于文章所处的状态,因此它不是状态模式的一部分。add_text 方法根本不与 state 字段交互,但它是我们希望支持的行为的一部分。

确保草稿文章的内容为空

即使在我们调用了 add_text 并向文章添加了一些内容之后,我们仍然希望 content 方法返回一个空字符串切片,因为文章仍然处于草稿状态,如 Listing 18-11 的第 7 行所示。现在,让我们用最简单的实现来满足这个要求:始终返回一个空字符串切片。稍后,当我们实现了更改文章状态以便可以发布的功能时,我们将更改这一点。到目前为止,文章只能处于草稿状态,因此文章内容应始终为空。Listing 18-14 展示了这个占位符实现。

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }
}

trait State {}

struct Draft {}

impl State for Draft {}

通过添加这个 content 方法,Listing 18-11 中到第 7 行的所有内容都按预期工作。

请求审核会更改文章的状态

接下来,我们需要添加请求审核文章的功能,这应该将其状态从 Draft 更改为 PendingReview。Listing 18-15 展示了这段代码。

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

我们为 Post 提供了一个名为 request_review 的公共方法,该方法将接受一个对 self 的可变引用。然后我们在 Post 的当前状态上调用一个内部的 request_review 方法,这个第二个 request_review 方法会消耗当前状态并返回一个新状态。

我们将 request_review 方法添加到 State trait 中;所有实现该 trait 的类型现在都需要实现 request_review 方法。请注意,方法的第一个参数不是 self&self&mut self,而是 self: Box<Self>。这种语法意味着该方法仅在调用持有该类型的 Box 时有效。这种语法会获取 Box<Self> 的所有权,使旧状态无效,以便 Post 的状态值可以转换为新状态。

为了消耗旧状态,request_review 方法需要获取状态值的所有权。这就是 Poststate 字段中的 Option 的用武之地:我们调用 take 方法从 state 字段中取出 Some 值,并在其位置留下一个 None,因为 Rust 不允许我们在结构体中有未填充的字段。这让我们可以将 state 值移出 Post,而不是借用它。然后我们将把文章的 state 值设置为这个操作的结果。

我们需要暂时将 state 设置为 None,而不是直接使用类似 self.state = self.state.request_review(); 的代码来设置它,以获取 state 值的所有权。这确保了 Post 在我们将其转换为新状态后不能使用旧的 state 值。

Draft 上的 request_review 方法返回一个新的、装箱的 PendingReview 结构体实例,该结构体表示文章等待审核时的状态。PendingReview 结构体也实现了 request_review 方法,但不进行任何转换。相反,它返回自身,因为当我们在已经处于 PendingReview 状态的文章上请求审核时,它应该保持在 PendingReview 状态。

现在我们可以开始看到状态模式的优势:无论 Poststate 值是什么,request_review 方法都是相同的。每个状态都负责自己的规则。

我们将 Post 上的 content 方法保持不变,返回一个空字符串切片。我们现在可以让 Post 处于 PendingReview 状态以及 Draft 状态,但我们希望在 PendingReview 状态下具有相同的行为。Listing 18-11 现在可以工作到第 10 行!

添加 approve 以更改 content 的行为

approve 方法将与 request_review 方法类似:它将把 state 设置为当前状态在被批准时应具有的值,如 Listing 18-16 所示:

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

我们将 approve 方法添加到 State trait 中,并添加一个新的结构体来实现 State,即 Published 状态。

PendingReview 上的 request_review 类似,如果我们在 Draft 上调用 approve 方法,它将没有任何效果,因为 approve 将返回 self。当我们在 PendingReview 上调用 approve 时,它返回一个新的、装箱的 Published 结构体实例。Published 结构体实现了 State trait,并且对于 request_review 方法和 approve 方法,它都返回自身,因为在这些情况下,文章应保持在 Published 状态。

现在我们需要更新 Post 上的 content 方法。我们希望从 content 返回的值取决于 Post 的当前状态,因此我们将让 Post 委托给其 state 上定义的 content 方法,如 Listing 18-17 所示:

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }
    // --snip--

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

因为目标是将所有这些规则保留在实现 State 的结构体中,所以我们调用 state 值上的 content 方法,并将文章实例(即 self)作为参数传递。然后我们返回使用 state 值上的 content 方法返回的值。

我们在 Option 上调用 as_ref 方法,因为我们想要 Option 内部值的引用,而不是该值的所有权。因为 state 是一个 Option<Box<dyn State>>,当我们调用 as_ref 时,会返回一个 Option<&Box<dyn State>>。如果我们不调用 as_ref,我们会得到一个错误,因为我们不能将 state 从函数参数的 &self 中移出。

然后我们调用 unwrap 方法,我们知道它永远不会 panic,因为我们知道 Post 上的方法确保在方法完成后 state 将始终包含一个 Some 值。这是我们在第 9 章中讨论的 “编译器无法理解的情况下你有更多信息” 的情况之一,我们知道 None 值永远不可能,即使编译器无法理解这一点。

此时,当我们在 &Box<dyn State> 上调用 content 时,解引用强制转换将对 &Box 生效,因此 content 方法最终将在实现 State trait 的类型上调用。这意味着我们需要将 content 添加到 State trait 定义中,这就是我们将根据我们拥有的状态来决定返回什么内容的逻辑所在,如 Listing 18-18 所示:

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        ""
    }
}

// --snip--

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}

我们为 content 方法添加了一个默认实现,返回一个空字符串切片。这意味着我们不需要在 DraftPendingReview 结构体上实现 contentPublished 结构体将覆盖 content 方法并返回 post.content 中的值。

请注意,我们需要在这个方法上添加生命周期注解,正如我们在第 10 章中讨论的那样。我们正在将 post 的引用作为参数,并返回该 post 的一部分的引用,因此返回的引用的生命周期与 post 参数的生命周期相关。

我们完成了——Listing 18-11 中的所有内容现在都可以工作了!我们已经使用状态模式实现了博客文章工作流的规则。与规则相关的逻辑位于状态对象中,而不是分散在 Post 中。

为什么不使用枚举?

你可能想知道为什么我们不使用一个枚举,将不同的文章状态作为变体。这当然是一个可能的解决方案;尝试一下并比较最终结果,看看你更喜欢哪个!使用枚举的一个缺点是,每个检查枚举值的地方都需要一个 match 表达式或类似的东西来处理每个可能的变体。这可能会比这个 trait 对象解决方案更重复。

状态模式的权衡

我们已经展示了 Rust 能够实现面向对象的状态模式,以封装文章在每个状态下应具有的不同行为。Post 上的方法对不同的行为一无所知。我们组织代码的方式是,我们只需要查看一个地方就可以知道已发布文章的不同行为方式:Published 结构体上的 State trait 的实现。

如果我们创建一个不使用状态模式的替代实现,我们可能会在 Post 上的方法中甚至是在检查文章状态并更改行为的 main 代码中使用 match 表达式。这意味着我们将不得不在多个地方查看以了解文章处于发布状态的所有含义!随着我们添加更多的状态,这种情况只会增加:每个 match 表达式都需要另一个分支。

使用状态模式,Post 方法和我们使用 Post 的地方不需要 match 表达式,要添加一个新状态,我们只需要添加一个新结构体并在该结构体上实现 trait 方法。

使用状态模式的实现很容易扩展以添加更多功能。要查看使用状态模式的代码的简单维护性,请尝试以下一些建议:

  • 添加一个 reject 方法,将文章的状态从 PendingReview 更改回 Draft
  • 要求在状态更改为 Published 之前调用两次 approve
  • 仅允许用户在文章处于 Draft 状态时添加文本内容。提示:让状态对象负责可能更改的内容,但不负责修改 Post

状态模式的一个缺点是,由于状态实现了状态之间的转换,一些状态是相互耦合的。如果我们在 PendingReviewPublished 之间添加另一个状态,例如 Scheduled,我们将不得不更改 PendingReview 中的代码以转换到 Scheduled 而不是 Published。如果 PendingReview 不需要随着新状态的添加而更改,那么工作量会少一些,但这将意味着切换到另一种设计模式。

另一个缺点是我们重复了一些逻辑。为了消除一些重复,我们可能会尝试为 State trait 上的 request_reviewapprove 方法提供默认实现,返回 self;然而,这不会起作用:当使用 State 作为 trait 对象时,trait 不知道具体的 self 到底是什么,因此返回类型在编译时是未知的。(这是前面提到的 dyn 兼容性规则之一。)

其他重复包括 Postrequest_reviewapprove 方法的类似实现。这两种方法都委托给 Optionstate 字段值的相同方法的实现,并将 state 字段的新值设置为结果。如果我们在 Post 上有许多遵循这种模式的方法,我们可能会考虑定义一个宏来消除重复(参见第 20 章中的 “宏”)。

通过完全按照面向对象语言定义的状态模式实现,我们没有充分利用 Rust 的优势。让我们看看我们可以对 blog crate 进行哪些更改,以使无效状态和转换成为编译时错误。

将状态和行为编码为类型

我们将向你展示如何重新思考状态模式以获得不同的权衡。与其完全封装状态和转换以使外部代码对它们一无所知,不如将状态编码为不同的类型。因此,Rust 的类型检查系统将通过发出编译器错误来防止在只允许发布文章的地方使用草稿文章。

让我们考虑 Listing 18-11 中 main 的第一部分:

use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}

我们仍然允许使用 Post::new 创建处于草稿状态的新文章,并允许向文章的内容添加文本。但我们不会让草稿文章有一个返回空字符串的 content 方法,而是让草稿文章根本没有 content 方法。这样,如果我们尝试获取草稿文章的内容,我们会得到一个编译器错误,告诉我们该方法不存在。因此,我们不可能在生产中意外显示草稿文章的内容,因为该代码甚至无法编译。Listing 18-19 展示了 Post 结构体和 DraftPost 结构体的定义,以及每个结构体上的方法。

pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

PostDraftPost 结构体都有一个私有的 content 字段,用于存储博客文章的文本。结构体不再有 state 字段,因为我们将状态的编码移到了结构体的类型中。Post 结构体将表示已发布的文章,并且它有一个 content 方法,返回 content

我们仍然有一个 Post::new 函数,但它不返回 Post 的实例,而是返回 DraftPost 的实例。因为 content 是私有的,并且没有任何函数返回 Post,所以现在不可能创建 Post 的实例。

DraftPost 结构体有一个 add_text 方法,因此我们可以像以前一样向 content 添加文本,但请注意,DraftPost 没有定义 content 方法!因此,现在程序确保所有文章都以草稿文章开始,并且草稿文章的内容不可用于显示。任何绕过这些约束的尝试都会导致编译器错误。

将转换实现为转换为不同类型

那么我们如何获得已发布的文章呢?我们希望强制执行规则,即草稿文章必须经过审核和批准才能发布。处于待审核状态的文章仍然不应显示任何内容。让我们通过添加另一个结构体 PendingReviewPost 来实现这些约束,定义 DraftPost 上的 request_review 方法以返回 PendingReviewPost,并定义 PendingReviewPost 上的 approve 方法以返回 Post,如 Listing 18-20 所示。

pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    // --snip--
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn request_review(self) -> PendingReviewPost {
        PendingReviewPost {
            content: self.content,
        }
    }
}

pub struct PendingReviewPost {
    content: String,
}

impl PendingReviewPost {
    pub fn approve(self) -> Post {
        Post {
            content: self.content,
        }
    }
}

request_reviewapprove 方法获取 self 的所有权,从而消耗 DraftPostPendingReviewPost 实例,并将它们分别转换为 PendingReviewPost 和已发布的 Post。这样,我们在调用 request_review 后就不会有任何遗留的 DraftPost 实例,依此类推。PendingReviewPost 结构体没有定义 content 方法,因此尝试读取其内容会导致编译器错误,就像 DraftPost 一样。因为唯一获得具有 content 方法的已发布 Post 实例的方法是调用 PendingReviewPost 上的 approve 方法,而唯一获得 PendingReviewPost 的方法是调用 DraftPost 上的 request_review 方法,我们现在已将博客文章工作流编码到类型系统中。

但我们还需要对 main 进行一些小的更改。request_reviewapprove 方法返回新实例而不是修改它们所调用的结构体,因此我们需要添加更多的 let post = 影子赋值来保存返回的实例。我们也不能让关于草稿和待审核文章内容的断言为空字符串,我们也不需要它们:我们不能再编译尝试使用这些状态下文章内容的代码。更新后的 main 代码如 Listing 18-21 所示。

use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");

    let post = post.request_review();

    let post = post.approve();

    assert_eq!("I ate a salad for lunch today", post.content());
}

我们需要对 main 进行的更改以重新分配 post 意味着这个实现不再完全遵循面向对象的状态模式:状态之间的转换不再完全封装在 Post 实现中。然而,我们的收获是,由于类型系统和编译时发生的类型检查,无效状态现在是不可能的!这确保了某些错误,例如显示未发布文章的内容,将在它们进入生产环境之前被发现。

尝试在本节开头建议的任务,看看你对这个版本代码设计的看法。请注意,在这个设计中,一些任务可能已经完成。

我们已经看到,尽管 Rust 能够实现面向对象的设计模式,但其他模式,例如将状态编码到类型系统中,在 Rust 中也是可用的。这些模式有不同的权衡。尽管你可能非常熟悉面向对象的模式,但重新思考问题以利用 Rust 的特性可以带来好处,例如在编译时防止某些错误。由于某些特性(如所有权),面向对象的模式在 Rust 中并不总是最佳解决方案。

总结

无论你在阅读本章后是否认为 Rust 是一种面向对象的语言,你现在知道你可以使用 trait 对象在 Rust 中获得一些面向对象的特性。动态调度可以为你的代码提供一些灵活性,以换取一点运行时性能。你可以使用这种灵活性来实现有助于代码可维护性的面向对象模式。Rust 还具有其他特性,如所有权,这是面向对象语言所没有的。面向对象的模式并不总是利用 Rust 优势的最佳方式,但它是一个可用的选项。

接下来,我们将看看模式,这是 Rust 的另一个特性,提供了很大的灵活性。我们在整本书中简要地看过它们,但还没有看到它们的全部能力。让我们开始吧!

模式与匹配

模式 是 Rust 中的一种特殊语法,用于匹配类型的结构,无论是复杂的还是简单的。将模式与 match 表达式和其他结构结合使用,可以让你更好地控制程序的控制流。模式由以下一些组合构成:

  • 字面量
  • 解构的数组、枚举、结构体或元组
  • 变量
  • 通配符
  • 占位符

一些示例模式包括 x(a, 3)Some(Color::Red)。在模式有效的上下文中,这些组件描述了数据的形状。然后,我们的程序将值与模式进行匹配,以确定它是否具有正确的数据形状,从而继续运行特定的代码片段。

要使用模式,我们将其与某个值进行比较。如果模式与值匹配,我们就在代码中使用值的部分。回想一下第 6 章中使用模式的 match 表达式,例如硬币分类机的例子。如果值符合模式的形状,我们可以使用命名的部分。如果不符合,与模式关联的代码将不会运行。

本章是关于模式相关内容的参考。我们将介绍使用模式的有效位置、可反驳模式与不可反驳模式之间的区别,以及你可能会看到的不同类型的模式语法。在本章结束时,你将了解如何使用模式以清晰的方式表达许多概念。

所有可以使用模式的地方

模式在 Rust 中出现在许多地方,你可能已经在不知不觉中大量使用了它们!本节讨论了模式有效的所有地方。

match 分支

正如在第 6 章中讨论的那样,我们在 match 表达式的分支中使用模式。形式上,match 表达式被定义为关键字 match、一个要匹配的值,以及一个或多个由模式和表达式组成的分支,如果值匹配该分支的模式,则运行该表达式,如下所示:

match VALUE {
    PATTERN => EXPRESSION,
    PATTERN => EXPRESSION,
    PATTERN => EXPRESSION,
}

例如,这里是来自 Listing 6-5 的 match 表达式,它匹配变量 x 中的 Option<i32> 值:

match x {
    None => None,
    Some(i) => Some(i + 1),
}

这个 match 表达式中的模式是每个箭头左侧的 NoneSome(i)

match 表达式的一个要求是它们必须是穷尽的,即 match 表达式中的所有可能性都必须被覆盖。确保覆盖所有可能性的一种方法是为最后一个分支提供一个通配符模式:例如,一个匹配任何值的变量名永远不会失败,因此可以覆盖所有剩余的情况。

特定的模式 _ 将匹配任何内容,但它永远不会绑定到变量,因此它经常用于最后一个 match 分支。例如,当你想忽略任何未指定的值时,_ 模式非常有用。我们将在本章后面的“忽略模式中的值”部分更详细地介绍 _ 模式。

条件 if let 表达式

在第 6 章中,我们讨论了如何使用 if let 表达式,主要是作为一种更短的写法来替代只匹配一个情况的 match 表达式。可选地,if let 可以有一个对应的 else,包含在 if let 中的模式不匹配时要运行的代码。

Listing 19-1 展示了混合使用 if letelse ifelse if let 表达式也是可能的。这样做比 match 表达式提供了更多的灵活性,因为 match 表达式只能表达一个值与模式的比较。此外,Rust 不要求一系列 if letelse ifelse if let 分支中的条件彼此相关。

Listing 19-1 中的代码根据对几个条件的检查来确定背景颜色。在这个例子中,我们创建了具有硬编码值的变量,这些值可能是真实程序从用户输入中接收到的。

fn main() {
    let favorite_color: Option<&str> = None;
    let is_tuesday = false;
    let age: Result<u8, _> = "34".parse();

    if let Some(color) = favorite_color {
        println!("Using your favorite color, {color}, as the background");
    } else if is_tuesday {
        println!("Tuesday is green day!");
    } else if let Ok(age) = age {
        if age > 30 {
            println!("Using purple as the background color");
        } else {
            println!("Using orange as the background color");
        }
    } else {
        println!("Using blue as the background color");
    }
}

如果用户指定了最喜欢的颜色,则该颜色将用作背景。如果没有指定最喜欢的颜色并且今天是星期二,则背景颜色为绿色。否则,如果用户将他们的年龄指定为字符串并且我们可以成功将其解析为数字,则颜色将根据数字的值是紫色或橙色。如果这些条件都不适用,则背景颜色为蓝色。

这种条件结构使我们能够支持复杂的需求。使用我们这里的硬编码值,这个例子将打印 Using purple as the background color

你可以看到 if let 也可以引入新的变量,这些变量会以与 match 分支相同的方式遮蔽现有变量:if let Ok(age) = age 这一行引入了一个新的 age 变量,该变量包含 Ok 变体中的值,遮蔽了现有的 age 变量。这意味着我们需要将 if age > 30 条件放在该块中:我们不能将这两个条件组合成 if let Ok(age) = age && age > 30。我们想要与 30 比较的新 age 在花括号开始的新作用域之前是无效的。

使用 if let 表达式的缺点是编译器不会检查穷尽性,而 match 表达式会检查。如果我们省略了最后一个 else 块,因此错过了一些情况的处理,编译器不会提醒我们可能的逻辑错误。

while let 条件循环

if let 结构类似,while let 条件循环允许 while 循环在模式继续匹配时运行。在 Listing 19-2 中,我们展示了一个 while let 循环,它等待线程之间发送的消息,但在这种情况下检查的是 Result 而不是 Option

fn main() {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        for val in [1, 2, 3] {
            tx.send(val).unwrap();
        }
    });

    while let Ok(value) = rx.recv() {
        println!("{value}");
    }
}

这个例子打印 12,然后是 3recv 方法从通道的接收端取出第一条消息并返回一个 Ok(value)。当我们第一次在第 16 章中看到 recv 时,我们直接解包了错误,或者使用 for 循环将其作为迭代器进行交互。如 Listing 19-2 所示,我们也可以使用 while let,因为只要发送者存在,recv 方法每次收到消息时都会返回 Ok,然后在发送端断开连接时产生一个 Err

for 循环

for 循环中,紧跟在关键字 for 后面的值是一个模式。例如,在 for x in y 中,x 是模式。Listing 19-3 演示了如何在 for 循环中使用模式来解构或分解元组作为 for 循环的一部分。

fn main() {
    let v = vec!['a', 'b', 'c'];

    for (index, value) in v.iter().enumerate() {
        println!("{value} is at index {index}");
    }
}

Listing 19-3 中的代码将打印以下内容:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.52s
     Running `target/debug/patterns`
a is at index 0
b is at index 1
c is at index 2

我们使用 enumerate 方法调整迭代器,使其生成一个值及其索引,放入一个元组中。生成的第一个值是元组 (0, 'a')。当这个值与模式 (index, value) 匹配时,index 将是 0value 将是 'a',打印输出的第一行。

let 语句

在本章之前,我们只明确讨论了在 matchif let 中使用模式,但实际上,我们在其他地方也使用了模式,包括在 let 语句中。例如,考虑这个简单的变量赋值:

#![allow(unused)]
fn main() {
let x = 5;
}

每次你使用这样的 let 语句时,你都在使用模式,尽管你可能没有意识到!更正式地说,let 语句看起来像这样:

let PATTERN = EXPRESSION;

在像 let x = 5; 这样的语句中,_PATTERN_ 槽中的变量名只是模式的一种特别简单的形式。Rust 将表达式与模式进行比较,并分配它找到的任何名称。因此,在 let x = 5; 的例子中,x 是一个模式,意思是“将匹配这里的内容绑定到变量 x”。因为名称 x 是整个模式,所以这个模式实际上意味着“将所有内容绑定到变量 x,无论值是什么。”

为了更清楚地看到 let 的模式匹配方面,考虑 Listing 19-4,它使用 let 模式来解构一个元组。

fn main() {
    let (x, y, z) = (1, 2, 3);
}

在这里,我们将一个元组与模式匹配。Rust 将值 (1, 2, 3) 与模式 (x, y, z) 进行比较,并看到值匹配模式,因为两者的元素数量相同,所以 Rust 将 1 绑定到 x2 绑定到 y3 绑定到 z。你可以将这个元组模式视为嵌套了三个单独的变量模式。

如果模式中的元素数量与元组中的元素数量不匹配,整体类型将不匹配,我们将得到一个编译器错误。例如,Listing 19-5 展示了一个尝试将具有三个元素的元组解构为两个变量的例子,这将无法工作。

fn main() {
    let (x, y) = (1, 2, 3);
}

尝试编译此代码会导致以下类型错误:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error[E0308]: mismatched types
 --> src/main.rs:2:9
  |
2 |     let (x, y) = (1, 2, 3);
  |         ^^^^^^   --------- this expression has type `({integer}, {integer}, {integer})`
  |         |
  |         expected a tuple with 3 elements, found one with 2 elements
  |
  = note: expected tuple `({integer}, {integer}, {integer})`
             found tuple `(_, _)`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `patterns` (bin "patterns") due to 1 previous error

要修复错误,我们可以使用 _.. 忽略元组中的一个或多个值,正如你将在“忽略模式中的值”部分看到的那样。如果问题是模式中的变量太多,解决方案是通过删除变量使类型匹配,使变量数量等于元组中的元素数量。

函数参数

函数参数也可以是模式。Listing 19-6 中的代码声明了一个名为 foo 的函数,它接受一个名为 xi32 类型参数,现在应该看起来很熟悉。

fn foo(x: i32) {
    // code goes here
}

fn main() {}

x 部分是一个模式!就像我们在 let 中所做的那样,我们可以在函数的参数中将元组与模式匹配。Listing 19-7 在将元组传递给函数时拆分元组中的值。

fn print_coordinates(&(x, y): &(i32, i32)) {
    println!("Current location: ({x}, {y})");
}

fn main() {
    let point = (3, 5);
    print_coordinates(&point);
}

这段代码打印 Current location: (3, 5)。值 &(3, 5) 与模式 &(x, y) 匹配,因此 x 是值 3y 是值 5

我们也可以在闭包参数列表中以与函数参数列表相同的方式使用模式,因为闭包与函数类似,正如第 13 章中讨论的那样。

到目前为止,你已经看到了几种使用模式的方式,但模式在我们可以使用它们的每个地方的工作方式并不相同。在某些地方,模式必须是不可反驳的;在其他情况下,它们可以是可反驳的。我们接下来将讨论这两个概念。

可反驳性:模式是否会匹配失败

模式有两种形式:可反驳的(refutable)和不可反驳的(irrefutable)。能够匹配任何可能传递的值的模式是不可反驳的。例如,let x = 5; 语句中的 x 就是不可反驳的,因为 x 可以匹配任何值,因此不会匹配失败。而对于某些可能的值会匹配失败的模式是可反驳的。例如,if let Some(x) = a_value 表达式中的 Some(x) 就是可反驳的,因为如果 a_value 变量中的值是 None 而不是 Some,那么 Some(x) 模式将不会匹配。

函数参数、let 语句和 for 循环只能接受不可反驳的模式,因为当值不匹配时,程序无法做任何有意义的事情。if letwhile let 表达式以及 let...else 语句可以接受可反驳和不可反驳的模式,但编译器会对不可反驳的模式发出警告,因为它们的目的是处理可能的失败:条件语句的功能在于它能够根据成功或失败执行不同的操作。

一般来说,你不需要担心可反驳和不可反驳模式之间的区别;然而,你需要熟悉可反驳性的概念,以便在错误信息中看到它时能够做出反应。在这些情况下,你需要根据代码的预期行为更改模式或使用模式的结构。

让我们来看一个例子,看看当我们尝试在 Rust 要求不可反驳模式的地方使用可反驳模式时会发生什么,反之亦然。Listing 19-8 展示了一个 let 语句,但对于模式,我们指定了 Some(x),这是一个可反驳的模式。正如你所料,这段代码将无法编译。

fn main() {
    let some_option_value: Option<i32> = None;
    let Some(x) = some_option_value;
}

如果 some_option_valueNone 值,它将无法匹配 Some(x) 模式,这意味着该模式是可反驳的。然而,let 语句只能接受不可反驳的模式,因为代码无法对 None 值做任何有效的操作。在编译时,Rust 会抱怨我们试图在需要不可反驳模式的地方使用可反驳模式:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error[E0005]: refutable pattern in local binding
 --> src/main.rs:3:9
  |
3 |     let Some(x) = some_option_value;
  |         ^^^^^^^ pattern `None` not covered
  |
  = note: `let` bindings require an "irrefutable pattern", like a `struct` or an `enum` with only one variant
  = note: for more information, visit https://doc.rust-lang.org/book/ch19-02-refutability.html
  = note: the matched value is of type `Option<i32>`
help: you might want to use `let else` to handle the variant that isn't matched
  |
3 |     let Some(x) = some_option_value else { todo!() };
  |                                     ++++++++++++++++

For more information about this error, try `rustc --explain E0005`.
error: could not compile `patterns` (bin "patterns") due to 1 previous error

因为我们没有(也无法!)用 Some(x) 模式覆盖所有可能的值,Rust 正确地生成了一个编译错误。

如果我们在需要不可反驳模式的地方使用了可反驳模式,我们可以通过更改使用模式的代码来修复它:我们可以使用 if let 而不是 let。然后,如果模式不匹配,代码将跳过花括号中的代码,从而使其能够继续有效执行。Listing 19-9 展示了如何修复 Listing 19-8 中的代码。

fn main() {
    let some_option_value: Option<i32> = None;
    let Some(x) = some_option_value else {
        return;
    };
}

我们为代码提供了一个出路!这段代码现在完全有效。然而,如果我们给 if let 提供一个不可反驳的模式(一个总是会匹配的模式),例如 x,如 Listing 19-10 所示,编译器会发出警告。

fn main() {
    let x = 5 else {
        return;
    };
}

Rust 抱怨说,使用 if let 与不可反驳模式没有意义:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
warning: irrefutable `if let` pattern
 --> src/main.rs:2:8
  |
2 |     if let x = 5 {
  |        ^^^^^^^^^
  |
  = note: this pattern will always match, so the `if let` is useless
  = help: consider replacing the `if let` with a `let`
  = note: `#[warn(irrefutable_let_patterns)]` on by default

warning: `patterns` (bin "patterns") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.39s
     Running `target/debug/patterns`
5

因此,match 分支必须使用可反驳的模式,除了最后一个分支,它应该使用不可反驳的模式来匹配所有剩余的值。Rust 允许我们在只有一个分支的 match 中使用不可反驳的模式,但这种语法并不特别有用,可以用更简单的 let 语句代替。

现在你已经知道了在哪里使用模式以及可反驳和不可反驳模式之间的区别,让我们来介绍所有可以用来创建模式的语法。

模式语法

在本节中,我们将收集所有在模式中有效的语法,并讨论为什么以及何时可能想要使用每种语法。

匹配字面量

正如你在第6章中看到的,你可以直接将模式与字面量匹配。以下代码给出了一些示例:

fn main() {
    let x = 1;

    match x {
        1 => println!("one"),
        2 => println!("two"),
        3 => println!("three"),
        _ => println!("anything"),
    }
}

这段代码会打印 one,因为 x 的值是 1。当你希望代码在获得特定具体值时采取行动时,这种语法非常有用。

匹配命名变量

命名变量是不可反驳的模式,它们可以匹配任何值,我们在本书中已经多次使用它们。然而,当你在 matchif letwhile let 表达式中使用命名变量时,会出现一个复杂情况。因为这些表达式都会开启一个新的作用域,所以作为模式一部分声明的变量会遮蔽外部同名的变量,就像所有变量一样。在 Listing 19-11 中,我们声明了一个名为 x 的变量,其值为 Some(5),以及一个名为 y 的变量,其值为 10。然后我们在 x 的值上创建了一个 match 表达式。查看匹配分支中的模式以及最后的 println!,尝试在运行此代码或继续阅读之前弄清楚代码将打印什么。

fn main() {
    let x = Some(5);
    let y = 10;

    match x {
        Some(50) => println!("Got 50"),
        Some(y) => println!("Matched, y = {y}"),
        _ => println!("Default case, x = {x:?}"),
    }

    println!("at the end: x = {x:?}, y = {y}");
}

让我们逐步分析 match 表达式运行时的过程。第一个匹配分支中的模式与 x 的定义值不匹配,因此代码继续执行。

第二个匹配分支中的模式引入了一个名为 y 的新变量,它将匹配 Some 值中的任何值。因为我们在 match 表达式内部的新作用域中,所以这是一个新的 y 变量,而不是我们在开始时声明的值为 10y。这个新的 y 绑定将匹配 Some 中的任何值,这正是我们在 x 中拥有的。因此,这个新的 y 绑定到 xSome 的内部值。该值是 5,所以该分支的表达式执行并打印 Matched, y = 5

如果 xNone 而不是 Some(5),前两个分支中的模式都不会匹配,因此值会匹配到下划线。我们没有在下划线分支的模式中引入 x 变量,所以表达式中的 x 仍然是未被遮蔽的外部 x。在这种假设情况下,match 会打印 Default case, x = None

match 表达式结束时,其作用域结束,内部的 y 的作用域也随之结束。最后的 println! 产生 at the end: x = Some(5), y = 10

要创建一个比较外部 xy 值的 match 表达式,而不是引入一个遮蔽现有 y 变量的新变量,我们需要使用匹配守卫条件。我们将在稍后的 “使用匹配守卫的额外条件” 中讨论匹配守卫。

多个模式

你可以使用 | 语法匹配多个模式,这是模式的 运算符。例如,在以下代码中,我们将 x 的值与匹配分支进行匹配,第一个分支有一个 选项,意味着如果 x 的值匹配该分支中的任何一个值,该分支的代码将运行:

fn main() {
    let x = 1;

    match x {
        1 | 2 => println!("one or two"),
        3 => println!("three"),
        _ => println!("anything"),
    }
}

这段代码会打印 one or two

使用 ..= 匹配值范围

..= 语法允许我们匹配一个包含范围的值。在以下代码中,当模式匹配给定范围内的任何值时,该分支将执行:

fn main() {
    let x = 5;

    match x {
        1..=5 => println!("one through five"),
        _ => println!("something else"),
    }
}

如果 x12345,第一个分支将匹配。这种语法比使用 | 运算符表达相同的意思更方便;如果我们使用 |,我们必须指定 1 | 2 | 3 | 4 | 5。指定一个范围要短得多,特别是如果我们想匹配,比如说,1 到 1,000 之间的任何数字!

编译器在编译时检查范围是否为空,因为 Rust 只能判断 char 和数值类型的范围是否为空,所以范围只允许用于数值或 char 值。

以下是一个使用 char 值范围的示例:

fn main() {
    let x = 'c';

    match x {
        'a'..='j' => println!("early ASCII letter"),
        'k'..='z' => println!("late ASCII letter"),
        _ => println!("something else"),
    }
}

Rust 可以判断 'c' 在第一个模式的范围内,并打印 early ASCII letter

解构以分解值

我们还可以使用模式来解构结构体、枚举和元组,以使用这些值的不同部分。让我们逐步分析每个值。

解构结构体

Listing 19-12 展示了一个 Point 结构体,它有两个字段 xy,我们可以使用 let 语句中的模式将其分解。

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    let Point { x: a, y: b } = p;
    assert_eq!(0, a);
    assert_eq!(7, b);
}

这段代码创建了变量 ab,它们分别匹配 p 结构体的 xy 字段的值。这个示例表明,模式中的变量名不必与结构体的字段名匹配。然而,通常会将变量名与字段名匹配,以便更容易记住哪些变量来自哪些字段。由于这种常见用法,并且因为写 let Point { x: x, y: y } = p; 包含大量重复,Rust 为匹配结构体字段的模式提供了一种简写形式:你只需要列出结构体字段的名称,模式创建的变量将具有相同的名称。Listing 19-13 的行为与 Listing 19-12 中的代码相同,但在 let 模式中创建的变量是 xy,而不是 ab

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    let Point { x, y } = p;
    assert_eq!(0, x);
    assert_eq!(7, y);
}

这段代码创建了变量 xy,它们分别匹配 p 变量的 xy 字段。结果是变量 xy 包含来自 p 结构体的值。

我们还可以在结构体模式中使用字面值作为部分模式,而不是为所有字段创建变量。这样做允许我们在创建变量以解构其他字段的同时,测试某些字段的特定值。

在 Listing 19-14 中,我们有一个 match 表达式,它将 Point 值分为三种情况:直接位于 x 轴上的点(当 y = 0 时为真)、位于 y 轴上的点(x = 0)或不在任何轴上的点。

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    match p {
        Point { x, y: 0 } => println!("On the x axis at {x}"),
        Point { x: 0, y } => println!("On the y axis at {y}"),
        Point { x, y } => {
            println!("On neither axis: ({x}, {y})");
        }
    }
}

第一个分支将通过指定 y 字段的值与字面量 0 匹配来匹配任何位于 x 轴上的点。该模式仍然创建一个 x 变量,我们可以在该分支的代码中使用它。

类似地,第二个分支通过指定 x 字段的值与 0 匹配来匹配任何位于 y 轴上的点,并为 y 字段的值创建一个变量 y。第三个分支没有指定任何字面量,因此它匹配任何其他 Point,并为 xy 字段创建变量。

在这个例子中,值 p 通过 x 包含 0 匹配第二个分支,因此这段代码将打印 On the y axis at 7

记住,match 表达式在找到第一个匹配的模式后就会停止检查分支,所以即使 Point { x: 0, y: 0} 位于 x 轴和 y 轴上,这段代码也只会打印 On the x axis at 0

解构枚举

我们在本书中已经解构过枚举(例如 Listing 6-5),但我们还没有明确讨论过解构枚举的模式与枚举内存储的数据定义方式相对应。作为一个例子,在 Listing 19-15 中,我们使用了 Listing 6-2 中的 Message 枚举,并编写了一个 match,其中的模式将解构每个内部值。

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {
    let msg = Message::ChangeColor(0, 160, 255);

    match msg {
        Message::Quit => {
            println!("The Quit variant has no data to destructure.");
        }
        Message::Move { x, y } => {
            println!("Move in the x direction {x} and in the y direction {y}");
        }
        Message::Write(text) => {
            println!("Text message: {text}");
        }
        Message::ChangeColor(r, g, b) => {
            println!("Change color to red {r}, green {g}, and blue {b}");
        }
    }
}

这段代码将打印 Change color to red 0, green 160, and blue 255。尝试更改 msg 的值以查看其他分支的代码运行。

对于没有任何数据的枚举变体,如 Message::Quit,我们无法进一步解构该值。我们只能匹配字面量 Message::Quit 值,并且该模式中没有变量。

对于类似结构体的枚举变体,如 Message::Move,我们可以使用类似于我们指定匹配结构体的模式。在变体名称之后,我们放置花括号,然后列出带有变量的字段,以便我们在该分支的代码中使用这些部分。这里我们使用了 Listing 19-13 中的简写形式。

对于类似元组的枚举变体,如 Message::Write,它包含一个元素的元组,以及 Message::ChangeColor,它包含三个元素的元组,模式类似于我们指定匹配元组的模式。模式中的变量数量必须与我们匹配的变体中的元素数量相匹配。

解构嵌套结构体和枚举

到目前为止,我们的示例都是匹配一层深度的结构体或枚举,但匹配也可以处理嵌套项!例如,我们可以重构 Listing 19-15 中的代码,以支持 ChangeColor 消息中的 RGB 和 HSV 颜色,如 Listing 19-16 所示。

enum Color {
    Rgb(i32, i32, i32),
    Hsv(i32, i32, i32),
}

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(Color),
}

fn main() {
    let msg = Message::ChangeColor(Color::Hsv(0, 160, 255));

    match msg {
        Message::ChangeColor(Color::Rgb(r, g, b)) => {
            println!("Change color to red {r}, green {g}, and blue {b}");
        }
        Message::ChangeColor(Color::Hsv(h, s, v)) => {
            println!("Change color to hue {h}, saturation {s}, value {v}");
        }
        _ => (),
    }
}

match 表达式中的第一个分支的模式匹配一个包含 Color::Rgb 变体的 Message::ChangeColor 枚举变体;然后模式绑定到三个内部的 i32 值。第二个分支的模式也匹配一个 Message::ChangeColor 枚举变体,但内部枚举匹配 Color::Hsv。我们可以在一个 match 表达式中指定这些复杂的条件,即使涉及两个枚举。

解构结构体和元组

我们可以以更复杂的方式混合、匹配和嵌套解构模式。以下示例展示了一个复杂的解构,我们在元组中嵌套结构体和元组,并解构出所有原始值:

fn main() {
    struct Point {
        x: i32,
        y: i32,
    }

    let ((feet, inches), Point { x, y }) = ((3, 10), Point { x: 3, y: -10 });
}

这段代码让我们将复杂类型分解为其组成部分,以便我们可以分别使用我们感兴趣的值。

使用模式进行解构是一种方便的方式,可以分别使用值的各个部分,例如结构体中的每个字段的值。

忽略模式中的值

你已经看到,有时忽略模式中的值是有用的,例如在 match 的最后一个分支中,以获得一个不执行任何操作但涵盖所有剩余可能值的 catchall。有几种方法可以忽略模式中的整个值或部分值:使用 _ 模式(你已经见过)、在另一个模式中使用 _ 模式、使用以下划线开头的名称,或使用 .. 忽略值的剩余部分。让我们探讨如何以及为什么要使用这些模式。

使用 _ 忽略整个值

我们已经使用下划线作为通配符模式,它将匹配任何值但不绑定到该值。这在 match 表达式的最后一个分支中特别有用,但我们也可以在任何模式中使用它,包括函数参数,如 Listing 19-17 所示。

fn foo(_: i32, y: i32) {
    println!("This code only uses the y parameter: {y}");
}

fn main() {
    foo(3, 4);
}

这段代码将完全忽略作为第一个参数传递的值 3,并打印 This code only uses the y parameter: 4

在大多数情况下,当你不再需要某个特定的函数参数时,你会更改签名,使其不包含未使用的参数。忽略函数参数在某些情况下特别有用,例如,当你实现一个需要特定类型签名的 trait 时,但你的实现中的函数体不需要其中一个参数。然后你可以避免收到关于未使用函数参数的编译器警告,就像你使用名称时那样。

使用嵌套 _ 忽略部分值

我们还可以在另一个模式中使用 _ 来忽略值的部分,例如,当我们只想测试值的部分但不需要在相应的代码中使用其他部分时。Listing 19-18 显示了负责管理设置值的代码。业务要求是用户不应被允许覆盖设置的现有自定义值,但如果当前未设置,则可以取消设置并为其赋值。

fn main() {
    let mut setting_value = Some(5);
    let new_setting_value = Some(10);

    match (setting_value, new_setting_value) {
        (Some(_), Some(_)) => {
            println!("Can't overwrite an existing customized value");
        }
        _ => {
            setting_value = new_setting_value;
        }
    }

    println!("setting is {setting_value:?}");
}

这段代码将打印 Can't overwrite an existing customized value,然后打印 setting is Some(5)。在第一个匹配分支中,我们不需要匹配或使用 Some 变体中的值,但我们确实需要测试 setting_valuenew_setting_value 都是 Some 变体的情况。在这种情况下,我们打印不更改 setting_value 的原因,并且它不会被更改。

在所有其他情况下(如果 setting_valuenew_setting_valueNone),由第二个分支中的 _ 模式表示,我们希望允许 new_setting_value 成为 setting_value

我们还可以在一个模式中的多个位置使用下划线来忽略特定值。Listing 19-19 展示了忽略五元素元组中的第二个和第四个值的示例。

fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (first, _, third, _, fifth) => {
            println!("Some numbers: {first}, {third}, {fifth}");
        }
    }
}

这段代码将打印 Some numbers: 2, 8, 32,并且值 416 将被忽略。

通过以下划线开头的名称忽略未使用的变量

如果你创建了一个变量但没有在任何地方使用它,Rust 通常会发出警告,因为未使用的变量可能是一个错误。然而,有时能够创建一个你暂时不会使用的变量是有用的,例如当你进行原型设计或刚刚开始一个项目时。在这种情况下,你可以通过以下划线开头的变量名称告诉 Rust 不要警告你未使用的变量。在 Listing 19-20 中,我们创建了两个未使用的变量,但当我们编译这段代码时,我们应该只收到其中一个变量的警告。

fn main() {
    let _x = 5;
    let y = 10;
}

在这里,我们收到了关于未使用变量 y 的警告,但没有收到关于未使用 _x 的警告。

请注意,仅使用 _ 和使用以下划线开头的名称之间存在细微差别。语法 _x 仍然将值绑定到变量,而 _ 根本不绑定。为了展示这种区别的重要性,Listing 19-21 将为我们提供一个错误。

fn main() {
    let s = Some(String::from("Hello!"));

    if let Some(_s) = s {
        println!("found a string");
    }

    println!("{s:?}");
}

我们会收到一个错误,因为 s 值仍然会被移动到 _s,这阻止了我们再次使用 s。然而,单独使用下划线不会绑定到值。Listing 19-22 将编译而没有任何错误,因为 s 不会被移动到 _

fn main() {
    let s = Some(String::from("Hello!"));

    if let Some(_) = s {
        println!("found a string");
    }

    println!("{s:?}");
}

这段代码可以正常工作,因为我们从未将 s 绑定到任何东西;它没有被移动。

使用 .. 忽略值的剩余部分

对于具有多个部分的值,我们可以使用 .. 语法来使用特定部分并忽略其余部分,避免为每个忽略的值列出下划线。.. 模式忽略我们在模式的其余部分中未明确匹配的任何部分。在 Listing 19-23 中,我们有一个 Point 结构体,它保存了三维空间中的坐标。在 match 表达式中,我们只想操作 x 坐标并忽略 yz 字段中的值。

fn main() {
    struct Point {
        x: i32,
        y: i32,
        z: i32,
    }

    let origin = Point { x: 0, y: 0, z: 0 };

    match origin {
        Point { x, .. } => println!("x is {x}"),
    }
}

我们列出了 x 值,然后只包含了 .. 模式。这比必须列出 y: _z: _ 要快得多,特别是当我们在处理具有许多字段的结构体时,只有一两个字段是相关的。

.. 语法将根据需要扩展到尽可能多的值。Listing 19-24 展示了如何将 .. 与元组一起使用。

fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (first, .., last) => {
            println!("Some numbers: {first}, {last}");
        }
    }
}

在这段代码中,第一个和最后一个值分别与 firstlast 匹配。.. 将匹配并忽略中间的所有内容。

然而,使用 .. 必须是无歧义的。如果不清楚哪些值用于匹配,哪些应该被忽略,Rust 会给我们一个错误。Listing 19-25 展示了以歧义方式使用 .. 的示例,因此它不会编译。

fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (.., second, ..) => {
            println!("Some numbers: {second}")
        },
    }
}

当我们编译这个示例时,我们会得到这个错误:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error: `..` can only be used once per tuple pattern
 --> src/main.rs:5:22
  |
5 |         (.., second, ..) => {
  |          --          ^^ can only be used once per tuple pattern
  |          |
  |          previously used here

error: could not compile `patterns` (bin "patterns") due to 1 previous error

Rust 无法确定在匹配 second 之前要忽略元组中的多少个值,以及之后要忽略多少个值。这段代码可能意味着我们想忽略 2,将 second 绑定到 4,然后忽略 81632;或者我们想忽略 24,将 second 绑定到 8,然后忽略 1632;等等。变量名 second 对 Rust 没有任何特殊意义,因此我们得到一个编译器错误,因为在这种方式下使用 .. 是歧义的。

使用匹配守卫的额外条件

匹配守卫 是一个额外的 if 条件,在 match 分支的模式之后指定,该条件也必须匹配才能选择该分支。匹配守卫对于表达比单独模式更复杂的想法非常有用。请注意,它们仅在 match 表达式中可用,而在 if letwhile let 表达式中不可用。

条件可以使用模式中创建的变量。Listing 19-26 展示了一个 match,其中第一个分支的模式为 Some(x),并且还有一个匹配守卫 if x % 2 == 0(如果数字是偶数,则为 true)。

fn main() {
    let num = Some(4);

    match num {
        Some(x) if x % 2 == 0 => println!("The number {x} is even"),
        Some(x) => println!("The number {x} is odd"),
        None => (),
    }
}

这个示例将打印 The number 4 is even。当 num 与第一个分支中的模式进行比较时,它匹配,因为 Some(4) 匹配 Some(x)。然后匹配守卫检查 x 除以 2 的余数是否等于 0,因为它是,所以选择了第一个分支。

如果 numSome(5) 而不是 Some(4),第一个分支中的匹配守卫将是 false,因为 5 除以 2 的余数是 1,不等于 0。Rust 将转到第二个分支,该分支匹配,因为第二个分支没有匹配守卫,因此匹配任何 Some 变体。

没有办法在模式中表达 if x % 2 == 0 条件,因此匹配守卫为我们提供了表达这种逻辑的能力。这种额外表达能力的缺点是,当涉及匹配守卫表达式时,编译器不会尝试检查穷尽性。

在 Listing 19-11 中,我们提到我们可以使用匹配守卫来解决我们的模式遮蔽问题。回想一下,我们在 match 表达式的模式中创建了一个新变量,而不是使用 match 外部的变量。这个新变量意味着我们无法测试外部变量的值。Listing 19-27 展示了我们如何使用匹配守卫来解决这个问题。

fn main() {
    let x = Some(5);
    let y = 10;

    match x {
        Some(50) => println!("Got 50"),
        Some(n) if n == y => println!("Matched, n = {n}"),
        _ => println!("Default case, x = {x:?}"),
    }

    println!("at the end: x = {x:?}, y = {y}");
}

这段代码现在将打印 Default case, x = Some(5)。第二个匹配分支中的模式没有引入一个会遮蔽外部 y 的新变量 y,这意味着我们可以在匹配守卫中使用外部 y。我们没有将模式指定为 Some(y),这会遮蔽外部 y,而是指定为 Some(n)。这创建了一个新变量 n,它不会遮蔽任何东西,因为外部没有 n 变量。

匹配守卫 if n == y 不是一个模式,因此不会引入新变量。这个 y 外部的 y 而不是遮蔽它的新 y,我们可以通过比较 ny 来查找与外部 y 具有相同值的值。

你还可以在匹配守卫中使用 运算符 | 来指定多个模式;匹配守卫条件将应用于所有模式。Listing 19-28 展示了将使用 | 的模式与匹配守卫结合时的优先级。这个示例的重要部分是 if y 匹配守卫适用于 45 6,即使看起来 if y 只适用于 6

fn main() {
    let x = 4;
    let y = false;

    match x {
        4 | 5 | 6 if y => println!("yes"),
        _ => println!("no"),
    }
}

匹配条件规定,只有当 x 的值等于 456 并且 ytrue 时,该分支才匹配。当这段代码运行时,第一个分支的模式匹配,因为 x4,但匹配守卫 if yfalse,因此第一个分支未被选择。代码继续到第二个分支,该分支匹配,因此程序打印 no。原因是 if 条件适用于整个模式 4 | 5 | 6,而不仅仅是最后一个值 6。换句话说,匹配守卫相对于模式的优先级行为如下:

(4 | 5 | 6) if y => ...

而不是:

4 | 5 | (6 if y) => ...

运行代码后,优先级行为显而易见:如果匹配守卫仅适用于使用 | 运算符指定的值列表中的最后一个值,则该分支将匹配,程序将打印 yes

@ 绑定

at 运算符 @ 允许我们在测试值是否匹配模式的同时创建一个变量来保存该值。在 Listing 19-29 中,我们想测试 Message::Helloid 字段是否在 3..=7 范围内。我们还希望将该值绑定到变量 id_variable,以便我们可以在与该分支相关的代码中使用它。我们可以将这个变量命名为 id,与字段同名,但在这个示例中,我们将使用不同的名称。

fn main() {
    enum Message {
        Hello { id: i32 },
    }

    let msg = Message::Hello { id: 5 };

    match msg {
        Message::Hello {
            id: id_variable @ 3..=7,
        } => println!("Found an id in range: {id_variable}"),
        Message::Hello { id: 10..=12 } => {
            println!("Found an id in another range")
        }
        Message::Hello { id } => println!("Found some other id: {id}"),
    }
}

这个示例将打印 Found an id in range: 5。通过在范围 3..=7 之前指定 id_variable @,我们捕获了匹配该范围的任何值,同时测试该值是否匹配范围模式。

在第二个分支中,我们只在模式中指定了一个范围,与该分支相关的代码没有包含 id 字段实际值的变量。id 字段的值可能是 10、11 或 12,但与该模式相关的代码不知道它是哪一个。模式代码无法使用 id 字段的值,因为我们没有将 id 值保存在变量中。

在最后一个分支中,我们指定了一个没有范围的变量,我们确实有一个变量 id 可以在该分支的代码中使用。原因是我们使用了结构体字段简写语法。但我们没有像前两个分支那样对 id 字段中的值应用任何测试:任何值都会匹配这个模式。

使用 @ 让我们可以在一个模式中测试一个值并将其保存在变量中。

总结

Rust 的模式在区分不同类型的数据时非常有用。当在 match 表达式中使用时,Rust 确保你的模式涵盖了所有可能的值,否则你的程序将无法编译。let 语句和函数参数中的模式使这些结构更有用,能够在将值分解为较小部分的同时将这些部分分配给变量。我们可以创建简单或复杂的模式来满足我们的需求。

接下来,作为本书的倒数第二章,我们将看看 Rust 各种功能的一些高级方面。

高级特性

到目前为止,你已经学习了 Rust 编程语言中最常用的部分。在我们进入第 21 章进行另一个项目之前,我们将探讨一些你可能偶尔会遇到但不一定每天使用的语言特性。当你遇到任何未知的内容时,可以将本章作为参考。这里介绍的特性在非常特定的情况下非常有用。尽管你可能不会经常使用它们,但我们希望确保你对 Rust 提供的所有特性有一个全面的了解。

在本章中,我们将涵盖以下内容:

  • 不安全的 Rust:如何退出 Rust 的一些保证,并手动承担维护这些保证的责任
  • 高级 trait:关联类型、默认类型参数、完全限定语法、supertraits 以及与 trait 相关的新类型模式
  • 高级类型:更多关于新类型模式、类型别名、never 类型和动态大小类型的内容
  • 高级函数和闭包:函数指针和返回闭包
  • :在编译时定义更多代码的方式

这是一系列 Rust 特性,总有一款适合你!让我们开始吧!

不安全的 Rust

到目前为止,我们讨论的所有代码都在编译时强制执行了 Rust 的内存安全保证。然而,Rust 内部还隐藏着另一种语言,它不强制执行这些内存安全保证:它被称为不安全的 Rust,其工作方式与常规 Rust 一样,但为我们提供了额外的超能力。

不安全的 Rust 存在的原因是,静态分析本质上是保守的。当编译器试图确定代码是否遵守这些保证时,拒绝一些有效的程序比接受一些无效的程序更好。尽管代码可能没问题,但如果 Rust 编译器没有足够的信息来确信,它就会拒绝该代码。在这种情况下,你可以使用不安全的代码来告诉编译器:“相信我,我知道我在做什么。”不过要注意,使用不安全的 Rust 是有风险的:如果你不正确使用不安全的代码,可能会因为内存不安全而出现问题,例如空指针解引用。

Rust 有不安全的另一面的另一个原因是,底层的计算机硬件本质上就是不安全的。如果 Rust 不允许你进行不安全的操作,你将无法完成某些任务。Rust 需要允许你进行低级系统编程,例如直接与操作系统交互,甚至编写自己的操作系统。进行低级系统编程是该语言的目标之一。让我们来探索一下我们可以用不安全的 Rust 做什么以及如何做。

不安全的超能力

要切换到不安全的 Rust,请使用 unsafe 关键字,然后开始一个新的代码块,其中包含不安全的代码。你可以在不安全的 Rust 中执行五个操作,这些操作在安全的 Rust 中是不允许的,我们称之为不安全的超能力。这些超能力包括:

  • 解引用裸指针
  • 调用不安全的函数或方法
  • 访问或修改可变的静态变量
  • 实现不安全的 trait
  • 访问 union 的字段

重要的是要理解,unsafe 并不会关闭借用检查器或禁用 Rust 的其他安全检查:如果你在不安全的代码中使用引用,它仍然会被检查。unsafe 关键字只是让你能够访问这五个特性,然后编译器不会对这些特性进行内存安全检查。在不安全的代码块中,你仍然会获得一定程度的安全性。

此外,unsafe 并不意味着代码块中的代码必然危险或一定会出现内存安全问题:其意图是作为程序员,你将确保 unsafe 块中的代码以有效的方式访问内存。

人都会犯错,错误也会发生,但通过要求这五个不安全的操作必须在标记为 unsafe 的块中执行,你将知道任何与内存安全相关的错误都必须出现在 unsafe 块中。保持 unsafe 块尽可能小;当你调查内存错误时,你会感激这一点。

为了尽可能隔离不安全的代码,最好将这些代码封装在一个安全的抽象中,并提供一个安全的 API,我们将在本章后面讨论不安全的函数和方法时探讨这一点。标准库的部分内容是通过对经过审计的不安全代码进行安全抽象来实现的。将不安全的代码封装在安全抽象中可以防止 unsafe 的使用泄漏到所有你可能希望使用 unsafe 代码实现功能的地方,因为使用安全抽象是安全的。

让我们依次看看这五个不安全的超能力。我们还将探讨一些为不安全代码提供安全接口的抽象。

解引用裸指针

在第 4 章的“悬垂引用”中,我们提到编译器确保引用始终有效。不安全的 Rust 有两个新类型,称为裸指针,它们类似于引用。与引用一样,裸指针可以是不可变的或可变的,分别写为 *const T*mut T。星号不是解引用操作符;它是类型名称的一部分。在裸指针的上下文中,不可变意味着指针在解引用后不能直接赋值。

与引用和智能指针不同,裸指针:

  • 允许忽略借用规则,可以同时拥有不可变和可变指针,或多个可变指针指向同一位置
  • 不保证指向有效的内存
  • 允许为空
  • 不实现任何自动清理

通过选择不让 Rust 强制执行这些保证,你可以放弃有保证的安全性,以换取更高的性能或与 Rust 的保证不适用的其他语言或硬件进行交互的能力。

Listing 20-1 展示了如何创建一个不可变和一个可变的裸指针。

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;
}

注意,我们在这个代码中没有包含 unsafe 关键字。我们可以在安全代码中创建裸指针;只是不能在 unsafe 块之外解引用裸指针,稍后你会看到。

我们使用裸借用操作符创建了裸指针:&raw const num 创建了一个 *const i32 不可变裸指针,&raw mut num 创建了一个 *mut i32 可变裸指针。因为我们直接从局部变量创建它们,所以我们知道这些特定的裸指针是有效的,但我们不能对任何裸指针都做出这种假设。

为了演示这一点,接下来我们将创建一个裸指针,其有效性我们无法确定,使用 as 来转换值而不是使用裸借用操作符。Listing 20-2 展示了如何创建一个指向内存中任意位置的裸指针。尝试使用任意内存是未定义的:该地址可能有数据,也可能没有,编译器可能会优化代码,使其不访问内存,或者程序可能会因段错误而终止。通常,没有好的理由编写这样的代码,尤其是在可以使用裸借用操作符的情况下,但这是可能的。

fn main() {
    let address = 0x012345usize;
    let r = address as *const i32;
}

回想一下,我们可以在安全代码中创建裸指针,但我们不能解引用裸指针并读取指向的数据。在 Listing 20-3 中,我们在需要 unsafe 块的裸指针上使用解引用操作符 *

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;

    unsafe {
        println!("r1 is: {}", *r1);
        println!("r2 is: {}", *r2);
    }
}

创建指针不会造成任何伤害;只有当我们尝试访问它指向的值时,我们才可能会处理一个无效的值。

还要注意,在 Listing 20-1 和 20-3 中,我们创建了 *const i32*mut i32 裸指针,它们都指向存储 num 的同一内存位置。如果我们尝试创建对 num 的不可变和可变引用,代码将无法编译,因为 Rust 的所有权规则不允许在存在不可变引用的同时存在可变引用。使用裸指针,我们可以创建一个可变指针和一个不可变指针指向同一位置,并通过可变指针更改数据,这可能会导致数据竞争。要小心!

既然有这么多危险,为什么还要使用裸指针呢?一个主要的用例是与 C 代码交互,你将在下一节“调用不安全的函数或方法”中看到。另一个用例是构建借用检查器无法理解的安全抽象。我们将介绍不安全的函数,然后看一个使用不安全代码的安全抽象示例。

调用不安全的函数或方法

你可以在不安全的块中执行的第二种操作是调用不安全的函数。不安全的函数和方法看起来与常规函数和方法完全一样,但它们在定义前多了一个 unsafe。在这个上下文中,unsafe 关键字表示该函数有一些我们在调用时需要遵守的要求,因为 Rust 无法保证我们已经满足这些要求。通过在 unsafe 块中调用不安全的函数,我们表示我们已经阅读了该函数的文档,并承担了遵守函数契约的责任。

这里有一个名为 dangerous 的不安全函数,它的函数体中没有做任何事情:

fn main() {
    unsafe fn dangerous() {}

    unsafe {
        dangerous();
    }
}

我们必须在单独的 unsafe 块中调用 dangerous 函数。如果我们尝试在没有 unsafe 块的情况下调用 dangerous,我们会得到一个错误:

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0133]: call to unsafe function `dangerous` is unsafe and requires unsafe block
 --> src/main.rs:4:5
  |
4 |     dangerous();
  |     ^^^^^^^^^^^ call to unsafe function
  |
  = note: consult the function's documentation for information on how to avoid undefined behavior

For more information about this error, try `rustc --explain E0133`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error

使用 unsafe 块,我们向 Rust 断言我们已经阅读了函数的文档,理解了如何正确使用它,并验证了我们正在履行函数的契约。

要在不安全函数的函数体中执行不安全的操作,你仍然需要使用 unsafe 块,就像在常规函数中一样,如果你忘记了,编译器会警告你。这有助于保持 unsafe 块尽可能小,因为可能不需要在整个函数体中都使用不安全的操作。

在不安全代码上创建安全抽象

仅仅因为一个函数包含不安全的代码并不意味着我们需要将整个函数标记为不安全的。事实上,将不安全的代码封装在安全函数中是一种常见的抽象。作为一个例子,让我们研究一下标准库中的 split_at_mut 函数,它需要一些不安全的代码。我们将探讨如何实现它。这个安全方法定义在可变切片上:它接受一个切片,并通过在给定的索引处拆分切片将其分成两个。Listing 20-4 展示了如何使用 split_at_mut

fn main() {
    let mut v = vec![1, 2, 3, 4, 5, 6];

    let r = &mut v[..];

    let (a, b) = r.split_at_mut(3);

    assert_eq!(a, &mut [1, 2, 3]);
    assert_eq!(b, &mut [4, 5, 6]);
}

我们不能仅使用安全的 Rust 实现这个函数。尝试可能看起来像 Listing 20-5,但它不会编译。为了简单起见,我们将 split_at_mut 实现为一个函数而不是方法,并且只针对 i32 值的切片而不是泛型类型 T

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();

    assert!(mid <= len);

    (&mut values[..mid], &mut values[mid..])
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}

这个函数首先获取切片的长度。然后它通过检查给定的索引是否小于或等于长度来断言该索引在切片内。这个断言意味着如果我们传递一个大于长度的索引来拆分切片,函数将在尝试使用该索引之前 panic。

然后我们返回一个元组中的两个可变切片:一个从原始切片的开头到 mid 索引,另一个从 mid 到切片的末尾。

当我们尝试编译 Listing 20-5 中的代码时,我们会得到一个错误。

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0499]: cannot borrow `*values` as mutable more than once at a time
 --> src/main.rs:6:31
  |
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
  |                         - let's call the lifetime of this reference `'1`
...
6 |     (&mut values[..mid], &mut values[mid..])
  |     --------------------------^^^^^^--------
  |     |     |                   |
  |     |     |                   second mutable borrow occurs here
  |     |     first mutable borrow occurs here
  |     returning this value requires that `*values` is borrowed for `'1`
  |
  = help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices

For more information about this error, try `rustc --explain E0499`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error

Rust 的借用检查器无法理解我们正在借用切片的不同部分;它只知道我们正在从同一个切片中借用两次。借用切片的不同部分在本质上是没问题的,因为这两个切片不重叠,但 Rust 不够聪明,无法理解这一点。当我们知道代码没问题,但 Rust 不知道时,就该使用不安全的代码了。

Listing 20-6 展示了如何使用 unsafe 块、裸指针和一些对不安全函数的调用来使 split_at_mut 的实现工作。

use std::slice;

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    let ptr = values.as_mut_ptr();

    assert!(mid <= len);

    unsafe {
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}

回想一下第 4 章中的“切片类型”,切片是指向某些数据的指针和切片的长度。我们使用 len 方法获取切片的长度,使用 as_mut_ptr 方法访问切片的裸指针。在这种情况下,因为我们有一个 i32 值的可变切片,as_mut_ptr 返回一个类型为 *mut i32 的裸指针,我们将其存储在变量 ptr 中。

我们保留了 mid 索引在切片内的断言。然后我们进入不安全的代码:slice::from_raw_parts_mut 函数接受一个裸指针和一个长度,并创建一个切片。我们使用它来创建一个从 ptr 开始且长度为 mid 的切片。然后我们在 ptr 上调用 add 方法,参数为 mid,以获取一个从 mid 开始的裸指针,并使用该指针和 mid 之后的剩余项数作为长度创建一个切片。

slice::from_raw_parts_mut 函数是不安全的,因为它接受一个裸指针,并且必须相信这个指针是有效的。裸指针上的 add 方法也是不安全的,因为它必须相信偏移位置也是一个有效的指针。因此,我们必须在调用 slice::from_raw_parts_mutadd 时使用 unsafe 块。通过查看代码并添加 mid 必须小于或等于 len 的断言,我们可以确定 unsafe 块中使用的所有裸指针都是指向切片内数据的有效指针。这是一个可接受且适当的使用 unsafe 的方式。

注意,我们不需要将生成的 split_at_mut 函数标记为 unsafe,我们可以从安全的 Rust 中调用这个函数。我们已经创建了一个安全抽象,通过使用不安全代码的函数实现,以安全的方式使用不安全代码,因为它只从该函数可以访问的数据中创建有效的指针。

相比之下,Listing 20-7 中使用 slice::from_raw_parts_mut 的代码在使用切片时可能会崩溃。这段代码接受一个任意的内存位置并创建一个长度为 10,000 的切片。

fn main() {
    use std::slice;

    let address = 0x01234usize;
    let r = address as *mut i32;

    let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
}

我们不拥有这个任意位置的内存,也没有保证这段代码创建的切片包含有效的 i32 值。尝试将 values 当作一个有效的切片使用会导致未定义行为。

使用 extern 函数调用外部代码

有时,你的 Rust 代码可能需要与用另一种语言编写的代码进行交互。为此,Rust 提供了 extern 关键字,用于创建和使用外部函数接口(FFI)。FFI 是一种编程语言定义函数并允许另一种(外部的)编程语言调用这些函数的方式。

Listing 20-8 演示了如何设置与 C 标准库中的 abs 函数的集成。在 extern 块中声明的函数通常从 Rust 代码中调用是不安全的,因此 extern 块也必须标记为 unsafe。原因是其他语言不强制执行 Rust 的规则和保证,Rust 也无法检查它们,因此程序员有责任确保安全。

unsafe extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}

unsafe extern "C" 块中,我们列出了我们想要调用的另一种语言的外部函数的名称和签名。"C" 部分定义了外部函数使用的应用程序二进制接口(ABI):ABI 定义了如何在汇编级别调用函数。"C" ABI 是最常见的,遵循 C 编程语言的 ABI。

unsafe extern 块中声明的每个项都是隐式 unsafe 的。然而,一些 FFI 函数安全调用的。例如,C 标准库中的 abs 函数没有任何内存安全考虑,我们知道它可以与任何 i32 一起调用。在这种情况下,我们可以使用 safe 关键字来表示这个特定的函数是安全调用的,即使它在 unsafe extern 块中。一旦我们做出这个更改,调用它就不再需要 unsafe 块,如 Listing 20-9 所示。

unsafe extern "C" {
    safe fn abs(input: i32) -> i32;
}

fn main() {
    println!("Absolute value of -3 according to C: {}", abs(-3));
}

将函数标记为 safe 并不会使其变得安全!相反,这是你向 Rust 做出的承诺,即它是安全的。你仍然有责任确保这个承诺得到遵守!

从其他语言调用 Rust 函数

我们也可以使用 extern 创建一个接口,允许其他语言调用 Rust 函数。我们不需要创建整个 extern 块,而是在相关函数的 fn 关键字前添加 extern 关键字并指定要使用的 ABI。我们还需要添加一个 #[unsafe(no_mangle)] 注解,告诉 Rust 编译器不要对这个函数的名称进行混淆。混淆是编译器将我们给函数的名称更改为包含更多信息的名称,以供编译过程的其他部分使用,但人类可读性较差。每种编程语言编译器对名称的混淆方式略有不同,因此为了让 Rust 函数能够被其他语言调用,我们必须禁用 Rust 编译器的名称混淆。这是不安全的,因为如果没有内置的混淆,库之间可能会发生名称冲突,因此我们有责任确保我们选择的名称在不混淆的情况下是安全导出的。

在以下示例中,我们使 call_from_c 函数可以从 C 代码中访问,在将其编译为共享库并从 C 中链接后:

#![allow(unused)]
fn main() {
#[unsafe(no_mangle)]
pub extern "C" fn call_from_c() {
    println!("Just called a Rust function from C!");
}
}

这种 extern 的使用仅在属性中需要 unsafe,而不是在 extern 块中。

访问或修改可变的静态变量

在本书中,我们还没有讨论过全局变量,Rust 确实支持全局变量,但它们可能会与 Rust 的所有权规则产生问题。如果两个线程访问同一个可变的全局变量,可能会导致数据竞争。

在 Rust 中,全局变量称为静态变量。Listing 20-10 展示了一个以字符串切片为值的静态变量的声明和使用示例。

static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("name is: {HELLO_WORLD}");
}

静态变量类似于我们在第 3 章的“常量”中讨论的常量。静态变量的名称通常使用 SCREAMING_SNAKE_CASE 命名约定。静态变量只能存储具有 'static 生命周期的引用,这意味着 Rust 编译器可以推断出生命周期,我们不需要显式注解它。访问不可变的静态变量是安全的。

不可变静态变量和常量之间的一个微妙区别是,静态变量的值在内存中有一个固定的地址。使用该值将始终访问相同的数据。另一方面,常量允许在每次使用时复制其数据。另一个区别是静态变量可以是可变的。访问和修改可变的静态变量是不安全的。Listing 20-11 展示了如何声明、访问和修改一个名为 COUNTER 的可变静态变量。

static mut COUNTER: u32 = 0;

/// SAFETY: Calling this from more than a single thread at a time is undefined
/// behavior, so you *must* guarantee you only call it from a single thread at
/// a time.
unsafe fn add_to_count(inc: u32) {
    unsafe {
        COUNTER += inc;
    }
}

fn main() {
    unsafe {
        // SAFETY: This is only called from a single thread in `main`.
        add_to_count(3);
        println!("COUNTER: {}", *(&raw const COUNTER));
    }
}

与常规变量一样,我们使用 mut 关键字指定可变性。任何读取或写入 COUNTER 的代码都必须在 unsafe 块中。这段代码可以编译并打印 COUNTER: 3,正如我们所期望的那样,因为它是单线程的。如果有多个线程访问 COUNTER,可能会导致数据竞争,因此这是未定义行为。因此,我们需要将整个函数标记为 unsafe,并记录安全限制,以便任何调用该函数的人都知道他们可以安全地做什么和不做什么。

每当我们编写一个不安全的函数时,习惯上写一个以 SAFETY 开头的注释,解释调用者需要做什么才能安全地调用该函数。同样,每当我们执行不安全的操作时,习惯上写一个以 SAFETY 开头的注释,解释如何遵守安全规则。

此外,编译器不允许你创建对可变静态变量的引用。你只能通过使用裸借用操作符创建的裸指针来访问它。这包括在引用被隐式创建的情况下,例如在此代码列表中的 println! 中使用时。要求对静态可变变量的引用只能通过裸指针创建,这有助于使使用它们的安全要求更加明显。

对于全局可访问的可变数据,很难确保没有数据竞争,这就是为什么 Rust 认为可变的静态变量是不安全的。在可能的情况下,最好使用我们在第 16 章中讨论的并发技术和线程安全的智能指针,以便编译器检查来自不同线程的数据访问是否安全。

实现不安全的 Trait

我们可以使用 unsafe 来实现一个不安全的 trait。当 trait 的至少一个方法有一些编译器无法验证的不变量时,该 trait 是不安全的。我们通过在 trait 前添加 unsafe 关键字并将 trait 的实现也标记为 unsafe 来声明一个 trait 是不安全的,如 Listing 20-12 所示。

unsafe trait Foo {
    // methods go here
}

unsafe impl Foo for i32 {
    // method implementations go here
}

fn main() {}

通过使用 unsafe impl,我们承诺我们将遵守编译器无法验证的不变量。

作为一个例子,回想一下我们在第 16 章的“使用 SyncSend trait 扩展并发”中讨论的 SyncSend 标记 trait:如果我们的类型完全由其他实现了 SendSync 的类型组成,编译器会自动实现这些 trait。如果我们实现一个包含未实现 SendSync 的类型(例如裸指针)的类型,并且我们希望将该类型标记为 SendSync,我们必须使用 unsafe。Rust 无法验证我们的类型是否遵守可以安全地跨线程发送或从多个线程访问的保证;因此,我们需要手动进行这些检查,并使用 unsafe 表示。

访问 union 的字段

只能在 unsafe 中执行的最后一个操作是访问 union 的字段。union 类似于 struct,但在特定实例中只使用一个声明的字段。union 主要用于与 C 代码中的 union 进行交互。访问 union 字段是不安全的,因为 Rust 无法保证当前存储在 union 实例中的数据类型。你可以在 Rust 参考 中了解更多关于 union 的信息。

使用 Miri 检查不安全代码

在编写不安全代码时,你可能希望检查你编写的代码是否实际上是安全和正确的。最好的方法之一是使用 Miri,这是一个官方的 Rust 工具,用于检测未定义行为。虽然借用检查器是一个在编译时工作的静态工具,但 Miri 是一个在运行时工作的动态工具。它通过运行你的程序或其测试套件来检查你的代码,并在你违反它理解的 Rust 工作规则时检测到。

使用 Miri 需要 Rust 的 nightly 版本(我们在附录 G:Rust 的构建和“Nightly Rust”中讨论更多)。你可以通过输入 rustup +nightly component add miri 来安装 Rust 的 nightly 版本和 Miri 工具。这不会改变你的项目使用的 Rust 版本;它只是将该工具添加到你的系统中,以便你可以在需要时使用它。你可以通过输入 cargo +nightly miri runcargo +nightly miri test 在项目上运行 Miri。

为了了解这有多有帮助,考虑当我们对 Listing 20-11 运行它时会发生什么。

$ cargo +nightly miri run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
     Running `file:///home/.rustup/toolchains/nightly/bin/cargo-miri runner target/miri/debug/unsafe-example`
COUNTER: 3

Miri 正确地警告我们,我们有对可变数据的共享引用。在这里,Miri 只发出警告,因为在这种情况下这并不保证是未定义行为,它也没有告诉我们如何修复问题。但至少我们知道存在未定义行为的风险,并可以思考如何使代码安全。在某些情况下,Miri 还可以检测到明显的错误——肯定错误的代码模式——并提出如何修复这些错误的建议。

Miri 并不能捕捉到你在编写不安全代码时可能犯的所有错误。Miri 是一个动态分析工具,因此它只能捕捉实际运行的代码中的问题。这意味着你需要将其与良好的测试技术结合使用,以增加对你编写的不安全代码的信心。Miri 也不涵盖你的代码可能不健全的每一种方式。

换句话说:如果 Miri 确实捕捉到了问题,你知道有一个 bug,但仅仅因为 Miri 没有捕捉到 bug 并不意味着没有问题。它可以捕捉到很多问题。尝试在本章的其他不安全代码示例上运行它,看看它会说什么!

你可以在 Miri 的 GitHub 仓库 中了解更多关于 Miri 的信息。

何时使用不安全代码

使用 unsafe 来使用刚刚讨论的五个超能力并不是错误的,甚至不会受到批评,但正确使用 unsafe 代码更棘手,因为编译器无法帮助维护内存安全。当你有理由使用 unsafe 代码时,你可以这样做,并且显式的 unsafe 注释使得在问题发生时更容易追踪问题的根源。每当你编写不安全代码时,你可以使用 Miri 来帮助你更有信心地认为你编写的代码遵守了 Rust 的规则。

要更深入地探索如何有效地使用不安全的 Rust,请阅读 Rust 的官方指南 Rustonomicon

高级特性

我们首先在第 10 章的 “Traits: 定义共享行为” 中介绍了 traits,但我们没有讨论更高级的细节。现在你对 Rust 有了更多的了解,我们可以深入探讨这些细节。

关联类型

关联类型 将类型占位符与 trait 连接起来,使得 trait 方法定义可以在其签名中使用这些占位符类型。trait 的实现者将为特定实现指定要使用的具体类型,而不是占位符类型。这样,我们可以定义一个使用某些类型的 trait,而不需要在实现 trait 之前确切知道这些类型是什么。

我们在本章中描述的大多数高级特性都是很少需要的。关联类型介于两者之间:它们比本书其余部分解释的特性使用得更少,但比本章讨论的许多其他特性更常见。

一个带有关联类型的 trait 的例子是标准库提供的 Iterator trait。关联类型名为 Item,代表实现 Iterator trait 的类型正在迭代的值的类型。Iterator trait 的定义如 Listing 20-13 所示。

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}

类型 Item 是一个占位符,next 方法的定义显示它将返回类型为 Option<Self::Item> 的值。Iterator trait 的实现者将为 Item 指定具体类型,next 方法将返回包含该具体类型值的 Option

关联类型可能看起来与泛型类似,因为后者允许我们定义一个函数而不指定它可以处理的类型。为了检查这两个概念之间的区别,我们将查看一个名为 Counter 的类型上实现 Iterator trait 的例子,该实现指定 Item 类型为 u32

struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        // --snip--
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

这种语法似乎与泛型类似。那么为什么不直接使用泛型定义 Iterator trait,如 Listing 20-14 所示呢?

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}

区别在于,当使用泛型时,如 Listing 20-14 所示,我们必须在每个实现中注解类型;因为我们也可以为 Counter 实现 Iterator<String> 或任何其他类型,所以我们可以为 Counter 实现多个 Iterator。换句话说,当一个 trait 有一个泛型参数时,它可以为一个类型多次实现,每次改变泛型类型参数的具体类型。当我们在 Counter 上使用 next 方法时,我们必须提供类型注解来指示我们要使用哪个 Iterator 实现。

使用关联类型时,我们不需要注解类型,因为我们不能为一个类型多次实现一个 trait。在 Listing 20-13 中使用关联类型的定义中,我们只能选择一次 Item 的类型,因为只能有一个 impl Iterator for Counter。我们不必在每次调用 Counter 上的 next 时指定我们想要一个 u32 值的迭代器。

关联类型也成为 trait 契约的一部分:trait 的实现者必须提供一个类型来代替关联类型占位符。关联类型通常有一个描述类型将如何使用的名称,并且在 API 文档中记录关联类型是一个好的做法。

默认泛型类型参数和运算符重载

当我们使用泛型类型参数时,我们可以为泛型类型指定一个默认的具体类型。这消除了 trait 实现者指定具体类型的需要,如果默认类型适用的话。你可以在声明泛型类型时使用 <PlaceholderType=ConcreteType> 语法指定默认类型。

一个非常有用的技术是 运算符重载,在这种情况下,你可以自定义运算符(如 +)在特定情况下的行为。

Rust 不允许你创建自己的运算符或重载任意运算符。但你可以通过实现与运算符相关的 traits 来重载 std::ops 中列出的操作和相应的 traits。例如,在 Listing 20-15 中,我们重载了 + 运算符以将两个 Point 实例相加。我们通过在 Point 结构体上实现 Add trait 来实现这一点。

use std::ops::Add;

#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    assert_eq!(
        Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
        Point { x: 3, y: 3 }
    );
}

add 方法将两个 Point 实例的 x 值和 y 值相加以创建一个新的 PointAdd trait 有一个名为 Output 的关联类型,它决定了 add 方法返回的类型。

此代码中的默认泛型类型在 Add trait 中。以下是它的定义:

#![allow(unused)]
fn main() {
trait Add<Rhs=Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}
}

这段代码应该看起来很熟悉:一个带有一个方法和一个关联类型的 trait。新部分是 Rhs=Self:这种语法称为 默认类型参数Rhs 泛型类型参数(“right-hand side” 的缩写)定义了 add 方法中 rhs 参数的类型。如果我们在实现 Add trait 时不指定 Rhs 的具体类型,Rhs 的类型将默认为 Self,即我们正在实现 Add 的类型。

当我们为 Point 实现 Add 时,我们使用了 Rhs 的默认值,因为我们想要将两个 Point 实例相加。让我们看一个实现 Add trait 的例子,其中我们希望自定义 Rhs 类型而不是使用默认值。

我们有两个结构体,MillimetersMeters,它们以不同的单位保存值。这种将现有类型包装在另一个结构体中的薄包装称为 newtype 模式,我们将在 “使用 Newtype 模式在外部类型上实现外部 Traits” 部分中更详细地描述。我们希望将毫米值与米值相加,并让 Add 的实现正确地进行转换。我们可以为 Millimeters 实现 Add,并将 Meters 作为 Rhs,如 Listing 20-16 所示。

use std::ops::Add;

struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
    type Output = Millimeters;

    fn add(self, other: Meters) -> Millimeters {
        Millimeters(self.0 + (other.0 * 1000))
    }
}

要将 MillimetersMeters 相加,我们指定 impl Add<Meters> 来设置 Rhs 类型参数的值,而不是使用默认的 Self

你将以两种主要方式使用默认类型参数:

  1. 扩展类型而不破坏现有代码
  2. 允许在大多数用户不需要的特定情况下进行自定义

标准库的 Add trait 是第二个目的的一个例子:通常,你会将两个相同类型的值相加,但 Add trait 提供了超越这一点的自定义能力。在 Add trait 定义中使用默认类型参数意味着大多数时候你不需要指定额外的参数。换句话说,不需要一些实现样板,使得使用 trait 更容易。

第一个目的与第二个目的类似,但方向相反:如果你想向现有 trait 添加类型参数,可以为其提供默认值,以允许扩展 trait 的功能而不破坏现有的实现代码。

消除同名方法之间的歧义

Rust 中没有任何东西阻止一个 trait 拥有与另一个 trait 的方法同名的方法,Rust 也不阻止你在一个类型上同时实现这两个 trait。还可以直接在类型上实现与 trait 方法同名的方法。

当调用同名方法时,你需要告诉 Rust 你想要使用哪一个。考虑 Listing 20-17 中的代码,我们定义了两个 trait,PilotWizard,它们都有一个名为 fly 的方法。然后我们在已经实现了 fly 方法的 Human 类型上实现了这两个 trait。每个 fly 方法都做不同的事情。

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {}

当我们在 Human 实例上调用 fly 时,编译器默认调用直接在该类型上实现的方法,如 Listing 20-18 所示。

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    person.fly();
}

运行此代码将打印 *waving arms furiously*,显示 Rust 调用了直接在 Human 上实现的 fly 方法。

要调用 Pilot trait 或 Wizard trait 的 fly 方法,我们需要使用更明确的语法来指定我们指的是哪个 fly 方法。Listing 20-19 演示了这种语法。

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    Pilot::fly(&person);
    Wizard::fly(&person);
    person.fly();
}

在方法名称前指定 trait 名称向 Rust 澄清了我们想要调用哪个 fly 实现。我们也可以写 Human::fly(&person),这与我们在 Listing 20-19 中使用的 person.fly() 等效,但如果不需要消除歧义,这样写会稍微长一些。

运行此代码将打印以下内容:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.46s
     Running `target/debug/traits-example`
This is your captain speaking.
Up!
*waving arms furiously*

因为 fly 方法有一个 self 参数,如果我们有两个 类型 都实现了一个 trait,Rust 可以根据 self 的类型推断出要使用哪个 trait 的实现。

然而,不是方法的关联函数没有 self 参数。当有多个类型或 trait 定义了具有相同函数名称的非方法函数时,Rust 并不总是知道你要指的是哪个类型,除非你使用 完全限定语法。例如,在 Listing 20-20 中,我们为一个动物收容所创建了一个 trait,该收容所希望将所有小狗命名为 Spot。我们创建了一个带有关联非方法函数 baby_nameAnimal trait。Animal trait 为结构体 Dog 实现,我们在 Dog 上也直接提供了一个关联的非方法函数 baby_name

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Dog::baby_name());
}

我们在 Dog 上定义的 baby_name 关联函数中实现了将所有小狗命名为 Spot 的代码。Dog 类型还实现了 Animal trait,该 trait 描述了所有动物的特征。小狗被称为 puppy,这在 Dog 上实现的 Animal trait 的 baby_name 关联函数中表达。

main 中,我们调用 Dog::baby_name 函数,该函数直接调用在 Dog 上定义的关联函数。此代码打印以下内容:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.54s
     Running `target/debug/traits-example`
A baby dog is called a Spot

这个输出不是我们想要的。我们想要调用我们在 Dog 上实现的 Animal trait 的 baby_name 函数,以便代码打印 A baby dog is called a puppy。我们在 Listing 20-19 中使用的指定 trait 名称的技术在这里没有帮助;如果我们将 main 改为 Listing 20-21 中的代码,我们将得到一个编译错误。

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Animal::baby_name());
}

因为 Animal::baby_name 没有 self 参数,并且可能有其他类型实现了 Animal trait,Rust 无法确定我们要使用哪个 Animal::baby_name 实现。我们将得到以下编译器错误:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
  --> src/main.rs:20:43
   |
2  |     fn baby_name() -> String;
   |     ------------------------- `Animal::baby_name` defined here
...
20 |     println!("A baby dog is called a {}", Animal::baby_name());
   |                                           ^^^^^^^^^^^^^^^^^^^ cannot call associated function of trait
   |
help: use the fully-qualified path to the only available implementation
   |
20 |     println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
   |                                           +++++++       +

For more information about this error, try `rustc --explain E0790`.
error: could not compile `traits-example` (bin "traits-example") due to 1 previous error

为了消除歧义并告诉 Rust 我们要使用 Dog 上的 Animal 实现,而不是其他类型的 Animal 实现,我们需要使用完全限定语法。Listing 20-22 演示了如何使用完全限定语法。

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}

我们在尖括号内为 Rust 提供了一个类型注解,指示我们要通过将 Dog 类型视为 Animal 来调用 Animal trait 的 baby_name 方法。此代码现在将打印我们想要的内容:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/traits-example`
A baby dog is called a puppy

一般来说,完全限定语法定义如下:

<Type as Trait>::function(receiver_if_method, next_arg, ...);

对于不是方法的关联函数,不会有 receiver:只有其他参数的列表。你可以在调用函数或方法时使用完全限定语法。然而,你可以省略 Rust 可以从程序中的其他信息推断出的任何部分。只有在有多个实现使用相同名称并且 Rust 需要帮助来识别你要调用哪个实现的情况下,你才需要使用这种更详细的语法。

使用 Supertraits

有时你可能会编写一个依赖于另一个 trait 的 trait 定义:为了让一个类型实现第一个 trait,你希望要求该类型也实现第二个 trait。你会这样做,以便你的 trait 定义可以使用第二个 trait 的关联项。你的 trait 定义所依赖的 trait 称为你的 trait 的 supertrait

例如,假设我们想创建一个 OutlinePrint trait,它有一个 outline_print 方法,该方法将打印一个给定的值,格式化为用星号框起来的形式。也就是说,给定一个实现了标准库 trait DisplayPoint 结构体,结果为 (x, y),当我们调用 outline_print 时,对于一个 x1y3Point 实例,它应该打印以下内容:

**********
*        *
* (1, 3) *
*        *
**********

outline_print 方法的实现中,我们希望使用 Display trait 的功能。因此,我们需要指定 OutlinePrint trait 仅适用于也实现 Display 并提供 OutlinePrint 所需功能的类型。我们可以在 trait 定义中通过指定 OutlinePrint: Display 来做到这一点。这种技术类似于向 trait 添加 trait 绑定。Listing 20-23 展示了 OutlinePrint trait 的实现。

use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

fn main() {}

因为我们指定了 OutlinePrint 需要 Display trait,所以我们可以使用为任何实现 Display 的类型自动实现的 to_string 函数。如果我们尝试在不添加冒号并在 trait 名称后指定 Display trait 的情况下使用 to_string,我们会得到一个错误,指出在当前作用域中没有为类型 &Self 找到名为 to_string 的方法。

让我们看看当我们尝试在一个没有实现 Display 的类型(如 Point 结构体)上实现 OutlinePrint 时会发生什么:

use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

我们得到一个错误,指出需要 Display 但未实现:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:20:23
   |
20 | impl OutlinePrint for Point {}
   |                       ^^^^^ `Point` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Point`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint`
  --> src/main.rs:3:21
   |
3  | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint`

error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:24:7
   |
24 |     p.outline_print();
   |       ^^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Point`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint::outline_print`
  --> src/main.rs:3:21
   |
3  | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint::outline_print`
4  |     fn outline_print(&self) {
   |        ------------- required by a bound in this associated function

For more information about this error, try `rustc --explain E0277`.
error: could not compile `traits-example` (bin "traits-example") due to 2 previous errors

要修复此问题,我们在 Point 上实现 Display 并满足 OutlinePrint 所需的约束,如下所示:

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

use std::fmt;

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

然后,在 Point 上实现 OutlinePrint trait 将成功编译,我们可以在 Point 实例上调用 outline_print 以在星号框中显示它。

使用 Newtype 模式在外部类型上实现外部 Traits

在第 10 章的 “在类型上实现 Trait” 中,我们提到了孤儿规则,该规则规定我们只能在类型上实现 trait,如果 trait 或类型,或两者都是我们 crate 本地的。可以使用 newtype 模式 绕过此限制,该模式涉及在元组结构体中创建一个新类型。(我们在第 5 章的 “使用没有命名字段的元组结构体创建不同类型” 中介绍了元组结构体。)元组结构体将有一个字段,并且是我们想要实现 trait 的类型的薄包装。然后包装类型是我们 crate 本地的,我们可以在包装类型上实现 trait。Newtype 是一个源自 Haskell 编程语言的术语。使用此模式没有运行时性能损失,并且包装类型在编译时被省略。

例如,假设我们想在 Vec<T> 上实现 Display,孤儿规则阻止我们直接这样做,因为 Display trait 和 Vec<T> 类型是在我们 crate 之外定义的。我们可以创建一个 Wrapper 结构体,它持有 Vec<T> 的实例;然后我们可以在 Wrapper 上实现 Display 并使用 Vec<T> 值,如 Listing 20-24 所示。

use std::fmt;

struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let w = Wrapper(vec![String::from("hello"), String::from("world")]);
    println!("w = {w}");
}

Display 的实现使用 self.0 访问内部的 Vec<T>,因为 Wrapper 是一个元组结构体,Vec<T> 是元组中索引为 0 的项。然后我们可以在 Wrapper 上使用 Display trait 的功能。

使用此技术的缺点是 Wrapper 是一个新类型,因此它没有它所持有的值的方法。我们必须直接在 Wrapper 上实现 Vec<T> 的所有方法,以便这些方法委托给 self.0,这将允许我们像对待 Vec<T> 一样对待 Wrapper。如果我们希望新类型具有内部类型的所有方法,实现 Deref trait 以返回内部类型将是一个解决方案(我们在第 15 章的 “使用 Deref trait 将智能指针视为常规引用” 中讨论了实现 Deref trait)。如果我们不希望 Wrapper 类型具有内部类型的所有方法——例如,为了限制 Wrapper 类型的行为——我们必须手动实现我们确实想要的方法。

即使不涉及 traits,这种 newtype 模式也很有用。让我们转换焦点,看看一些与 Rust 类型系统交互的高级方法。

高级类型

Rust 的类型系统有一些我们之前提到但尚未讨论的特性。我们将从讨论 newtype 模式开始,探讨为什么 newtype 作为类型是有用的。然后我们将转向类型别名,这是一个与 newtype 类似但语义略有不同的特性。我们还将讨论 ! 类型和动态大小类型。

使用 Newtype 模式实现类型安全和抽象

本节假设你已经阅读了前面的章节 “使用 Newtype 模式在外部类型上实现外部特性。” newtype 模式在我们目前讨论的任务之外也非常有用,包括静态地确保值永远不会混淆,并指示值的单位。你在 Listing 20-16 中看到了使用 newtype 来指示单位的示例:回想一下,MillimetersMeters 结构体将 u32 值包装在 newtype 中。如果我们编写一个参数类型为 Millimeters 的函数,我们将无法编译一个意外地尝试使用 Meters 类型的值或普通的 u32 值调用该函数的程序。

我们还可以使用 newtype 模式来抽象掉类型的某些实现细节:新类型可以公开一个与私有内部类型的 API 不同的公共 API。

Newtype 还可以隐藏内部实现。例如,我们可以提供一个 People 类型来包装一个 HashMap<i32, String>,它存储与名称相关联的人的 ID。使用 People 的代码只会与我们提供的公共 API 交互,例如向 People 集合添加名称字符串的方法;该代码不需要知道我们在内部为名称分配了一个 i32 ID。newtype 模式是一种轻量级的方式来实现封装以隐藏实现细节,我们在第 18 章的 “隐藏实现细节的封装” 中讨论过。

使用类型别名创建类型同义词

Rust 提供了声明 类型别名 的能力,以便为现有类型提供另一个名称。为此,我们使用 type 关键字。例如,我们可以创建别名 Kilometersi32,如下所示:

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

    println!("x + y = {}", x + y);
}

现在,别名 Kilometersi32同义词;与我们在 Listing 20-16 中创建的 MillimetersMeters 类型不同,Kilometers 不是一个单独的、新的类型。具有 Kilometers 类型的值将与 i32 类型的值相同对待:

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

    println!("x + y = {}", x + y);
}

因为 Kilometersi32 是相同的类型,我们可以将这两种类型的值相加,并且我们可以将 Kilometers 值传递给接受 i32 参数的函数。然而,使用这种方法,我们不会得到之前讨论的 newtype 模式所带来的类型检查的好处。换句话说,如果我们在某处混淆了 Kilometersi32 值,编译器不会给我们错误。

类型同义词的主要用例是减少重复。例如,我们可能有一个冗长的类型,如下所示:

Box<dyn Fn() + Send + 'static>

在函数签名和代码中的类型注释中到处写这个冗长的类型可能会很繁琐且容易出错。想象一下,有一个项目充满了像 Listing 20-25 中的代码。

fn main() {
    let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));

    fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
        // --snip--
    }

    fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
        // --snip--
        Box::new(|| ())
    }
}

类型别名通过减少重复使代码更易于管理。在 Listing 20-26 中,我们为冗长的类型引入了一个名为 Thunk 的别名,并可以用较短的别名 Thunk 替换所有使用该类型的地方。

fn main() {
    type Thunk = Box<dyn Fn() + Send + 'static>;

    let f: Thunk = Box::new(|| println!("hi"));

    fn takes_long_type(f: Thunk) {
        // --snip--
    }

    fn returns_long_type() -> Thunk {
        // --snip--
        Box::new(|| ())
    }
}

这段代码更容易阅读和编写!为类型别名选择一个有意义的名称可以帮助传达你的意图(thunk 是一个稍后评估的代码的词,因此它是一个存储闭包的合适名称)。

类型别名也常用于 Result<T, E> 类型以减少重复。考虑标准库中的 std::io 模块。I/O 操作通常返回一个 Result<T, E> 来处理操作失败的情况。这个库有一个 std::io::Error 结构体,表示所有可能的 I/O 错误。std::io 中的许多函数将返回 Result<T, E>,其中 Estd::io::Error,例如 Write 特性中的这些函数:

use std::fmt;
use std::io::Error;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
    fn flush(&mut self) -> Result<(), Error>;

    fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}

Result<..., Error> 重复了很多次。因此,std::io 有这个类型别名声明:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

因为这个声明在 std::io 模块中,我们可以使用完全限定的别名 std::io::Result<T>;也就是说,一个 Result<T, E>,其中 E 被填充为 std::io::ErrorWrite 特性的函数签名最终看起来像这样:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

类型别名在两个方面有帮助:它使代码更容易编写 并且 它为我们提供了一个跨所有 std::io 的一致接口。因为它是一个别名,它只是另一个 Result<T, E>,这意味着我们可以使用任何适用于 Result<T, E> 的方法,以及像 ? 操作符这样的特殊语法。

永不返回的 Never 类型

Rust 有一个名为 ! 的特殊类型,在类型理论术语中称为 空类型,因为它没有值。我们更喜欢称它为 never 类型,因为它在函数永远不会返回时代表返回类型。以下是一个示例:

fn bar() -> ! {
    // --snip--
    panic!();
}

这段代码被解读为“函数 bar 返回 never。”返回 never 的函数称为 发散函数。我们无法创建 ! 类型的值,因此 bar 永远不可能返回。

但是,一个你永远无法创建值的类型有什么用呢?回想一下 Listing 2-5 中的代码,数字猜谜游戏的一部分;我们在 Listing 20-27 中重现了其中的一部分。

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        // --snip--

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

当时,我们跳过了这段代码中的一些细节。在第 6 章的 match 控制流操作符” 中,我们讨论了 match 分支必须都返回相同的类型。因此,例如,以下代码不起作用:

fn main() {
    let guess = "3";
    let guess = match guess.trim().parse() {
        Ok(_) => 5,
        Err(_) => "hello",
    };
}

这段代码中的 guess 类型必须是一个整数 一个字符串,而 Rust 要求 guess 只有一个类型。那么 continue 返回什么?我们如何在 Listing 20-27 中从一个分支返回 u32 并让另一个分支以 continue 结尾?

正如你可能已经猜到的,continue 有一个 ! 值。也就是说,当 Rust 计算 guess 的类型时,它会查看两个 match 分支,前者有一个 u32 值,后者有一个 ! 值。因为 ! 永远不会有值,Rust 决定 guess 的类型是 u32

描述这种行为的形式化方式是,类型为 ! 的表达式可以被强制转换为任何其他类型。我们被允许以 continue 结束这个 match 分支,因为 continue 不会返回值;相反,它将控制权移回循环的顶部,因此在 Err 情况下,我们永远不会为 guess 赋值。

never 类型与 panic! 宏也很有用。回想一下我们在 Option<T> 值上调用的 unwrap 函数,以生成一个值或 panic,其定义如下:

enum Option<T> {
    Some(T),
    None,
}

use crate::Option::*;

impl<T> Option<T> {
    pub fn unwrap(self) -> T {
        match self {
            Some(val) => val,
            None => panic!("called `Option::unwrap()` on a `None` value"),
        }
    }
}

在这段代码中,发生了与 Listing 20-27 中的 match 相同的事情:Rust 看到 val 的类型是 T,而 panic! 的类型是 !,因此整个 match 表达式的结果是 T。这段代码有效是因为 panic! 不会产生值;它结束了程序。在 None 情况下,我们不会从 unwrap 返回值,因此这段代码是有效的。

最后一个具有 ! 类型的表达式是 loop

fn main() {
    print!("forever ");

    loop {
        print!("and ever ");
    }
}

在这里,循环永远不会结束,因此 ! 是表达式的值。然而,如果我们包含一个 break,这将不再成立,因为循环将在到达 break 时终止。

动态大小类型和 Sized 特性

Rust 需要知道其类型的某些细节,例如为特定类型的值分配多少空间。这使得其类型系统的一个角落在一开始有点令人困惑:动态大小类型 的概念。有时称为 DSTs未大小类型,这些类型让我们可以编写使用值的代码,这些值的大小我们只能在运行时知道。

让我们深入了解一个名为 str 的动态大小类型的细节,我们在整本书中一直在使用它。没错,不是 &str,而是 str 本身,是一个 DST。我们无法知道字符串的长度直到运行时,这意味着我们无法创建类型为 str 的变量,也无法接受类型为 str 的参数。考虑以下代码,它不起作用:

fn main() {
    let s1: str = "Hello there!";
    let s2: str = "How's it going?";
}

Rust 需要知道为特定类型的任何值分配多少内存,并且一个类型的所有值必须使用相同数量的内存。如果 Rust 允许我们编写这段代码,这两个 str 值将需要占用相同数量的空间。但它们有不同的长度:s1 需要 12 字节的存储空间,而 s2 需要 15 字节。这就是为什么不可能创建一个持有动态大小类型的变量。

那么我们该怎么办?在这种情况下,你已经知道答案:我们将 s1s2 的类型改为 &str 而不是 str。回想一下第 4 章的 “字符串切片”,切片数据结构只存储切片的起始位置和长度。因此,尽管 &T 是一个存储 T 所在内存地址的单个值,&str两个 值:str 的地址和它的长度。因此,我们可以在编译时知道 &str 值的大小:它是 usize 长度的两倍。也就是说,我们总是知道 &str 的大小,无论它引用的字符串有多长。一般来说,这是在 Rust 中使用动态大小类型的方式:它们有一个额外的元数据位,用于存储动态信息的大小。动态大小类型的黄金法则是,我们必须始终将动态大小类型的值放在某种指针后面。

我们可以将 str 与各种指针结合使用:例如,Box<str>Rc<str>。事实上,你以前见过这个,但使用了一个不同的动态大小类型:特性。每个特性都是一个动态大小类型,我们可以通过使用特性的名称来引用它。在第 18 章的 “使用允许不同类型值的特性对象” 中,我们提到要使用特性作为特性对象,我们必须将它们放在指针后面,例如 &dyn TraitBox<dyn Trait>Rc<dyn Trait> 也可以工作)。

为了处理动态大小类型(DSTs),Rust 提供了 Sized trait 来确定一个类型的大小是否在编译时已知。这个 trait 会自动为所有在编译时已知大小的类型实现。此外,Rust 隐式地为每个泛型函数添加了 Sized 的约束。也就是说,像这样的泛型函数定义:

fn generic<T>(t: T) {
    // --snip--
}

实际上会被视为我们写了这样的代码:

fn generic<T: Sized>(t: T) {
    // --snip--
}

默认情况下,泛型函数只会作用于那些在编译时已知大小的类型。然而,你可以使用以下特殊语法来放宽这个限制:

fn generic<T: ?Sized>(t: &T) {
    // --snip--
}

?Sized 的 trait 约束意味着“T 可能是 Sized,也可能不是 Sized”,这种表示法覆盖了泛型类型必须在编译时已知大小的默认行为。这种含义的 ?Trait 语法仅适用于 Sized,不适用于其他 trait。

还要注意,我们将 t 参数的类型从 T 切换为 &T。因为类型可能不是 Sized,我们需要在某种指针后面使用它。在这种情况下,我们选择了引用。

接下来,我们将讨论函数和闭包!

高级函数与闭包

本节探讨了一些与函数和闭包相关的高级特性,包括函数指针和返回闭包。

函数指针

我们已经讨论过如何将闭包传递给函数;你也可以将常规函数传递给函数!当你想要传递一个已经定义的函数而不是定义一个新的闭包时,这种技术非常有用。函数会被强制转换为 fn 类型(小写的 f),不要与 Fn 闭包特征混淆。fn 类型被称为函数指针。通过函数指针传递函数允许你将函数作为参数传递给其他函数。

指定参数为函数指针的语法与闭包的语法类似,如 Listing 20-28 所示,我们定义了一个函数 add_one,它将参数加 1。函数 do_twice 接受两个参数:一个是指向任何接受 i32 参数并返回 i32 的函数的函数指针,另一个是 i32 值。do_twice 函数调用函数 f 两次,传递 arg 值,然后将两次函数调用的结果相加。main 函数使用 add_one5 作为参数调用 do_twice

fn add_one(x: i32) -> i32 {
    x + 1
}

fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
    f(arg) + f(arg)
}

fn main() {
    let answer = do_twice(add_one, 5);

    println!("The answer is: {answer}");
}

这段代码会打印出 The answer is: 12。我们指定 do_twice 中的参数 f 是一个接受一个 i32 类型参数并返回 i32fn。然后我们可以在 do_twice 的函数体中调用 f。在 main 中,我们可以将函数名 add_one 作为第一个参数传递给 do_twice

与闭包不同,fn 是一个类型而不是一个特征,因此我们直接指定 fn 作为参数类型,而不是声明一个泛型类型参数并使用 Fn 特征作为特征约束。

函数指针实现了所有三个闭包特征(FnFnMutFnOnce),这意味着你总是可以将函数指针作为参数传递给期望闭包的函数。最好使用泛型类型和闭包特征之一来编写函数,这样你的函数可以接受函数或闭包。

话虽如此,当你只想接受 fn 而不接受闭包时,一个例子是与没有闭包的外部代码交互:C 函数可以接受函数作为参数,但 C 没有闭包。

作为一个可以使用内联定义的闭包或命名函数的例子,让我们看看标准库中 Iterator 特征提供的 map 方法的使用。为了使用 map 方法将数字向量转换为字符串向量,我们可以使用闭包,如 Listing 20-29 所示。

fn main() {
    let list_of_numbers = vec![1, 2, 3];
    let list_of_strings: Vec<String> =
        list_of_numbers.iter().map(|i| i.to_string()).collect();
}

或者我们可以将函数名作为 map 的参数,而不是闭包。Listing 20-30 展示了这种情况。

fn main() {
    let list_of_numbers = vec![1, 2, 3];
    let list_of_strings: Vec<String> =
        list_of_numbers.iter().map(ToString::to_string).collect();
}

注意,我们必须使用我们在 “高级特征” 中讨论的完全限定语法,因为有多个名为 to_string 的函数可用。

在这里,我们使用了 ToString 特征中定义的 to_string 函数,标准库为任何实现了 Display 的类型实现了这个特征。

回想一下第 6 章中的 “枚举值”,我们定义的每个枚举变体的名称也会成为一个初始化函数。我们可以将这些初始化函数作为实现闭包特征的函数指针,这意味着我们可以将初始化函数指定为接受闭包的方法的参数,如 Listing 20-31 所示。

fn main() {
    enum Status {
        Value(u32),
        Stop,
    }

    let list_of_statuses: Vec<Status> = (0u32..20).map(Status::Value).collect();
}

在这里,我们通过使用 Status::Value 的初始化函数,在 map 调用的范围内为每个 u32 值创建 Status::Value 实例。有些人喜欢这种风格,有些人喜欢使用闭包。它们编译为相同的代码,所以使用你觉得更清晰的风格。

返回闭包

闭包由特征表示,这意味着你不能直接返回闭包。在大多数情况下,你可能想要返回一个特征,你可以使用实现该特征的具体类型作为函数的返回值。然而,对于闭包,你通常不能这样做,因为它们没有可返回的具体类型。例如,如果闭包从其作用域中捕获了任何值,你就不允许使用函数指针 fn 作为返回类型。

相反,你通常会使用我们在第 10 章中学到的 impl Trait 语法。你可以返回任何函数类型,使用 FnFnOnceFnMut。例如,Listing 20-32 中的代码将正常工作。

#![allow(unused)]
fn main() {
fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}
}

然而,正如我们在第 13 章的 “闭包类型推断和注解” 中指出的那样,每个闭包也是其自己独特的类型。如果你需要处理具有相同签名但不同实现的多个函数,你将需要为它们使用特征对象。考虑如果你编写如 Listing 20-33 所示的代码会发生什么。

fn main() {
    let handlers = vec![returns_closure(), returns_initialized_closure(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}

fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
    move |x| x + init
}

这里我们有两个函数,returns_closurereturns_initialized_closure,它们都返回 impl Fn(i32) -> i32。注意,它们返回的闭包是不同的,尽管它们实现了相同的类型。如果我们尝试编译这个代码,Rust 会告诉我们它不会工作:

$ cargo build
   Compiling functions-example v0.1.0 (file:///projects/functions-example)
    error[E0308]: mismatched types
    --> src/main.rs:4:9
    |
    4  |         returns_initialized_closure(123)
    |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected opaque type, found a different opaque type
    ...
    12 | fn returns_closure() -> impl Fn(i32) -> i32 {
    |                         ------------------- the expected opaque type
    ...
    16 | fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
    |                                              ------------------- the found opaque type
    |
    = note: expected opaque type `impl Fn(i32) -> i32` (opaque type at <src/main.rs:12:25>)
                found opaque type `impl Fn(i32) -> i32` (opaque type at <src/main.rs:16:46>)
    = note: distinct uses of `impl Trait` result in different opaque types

    For more information about this error, try `rustc --explain E0308`.
    error: could not compile `functions-example` (bin "functions-example") due to 1 previous error

错误信息告诉我们,每当我们返回一个 impl Trait 时,Rust 会创建一个独特的不透明类型,这是一种我们无法看到 Rust 为我们构建的细节的类型。因此,即使这些函数都返回实现了相同特征 Fn(i32) -> i32 的闭包,Rust 为每个生成的类型是不同的。(这与 Rust 为不同的异步块生成不同的具体类型类似,即使它们具有相同的输出类型,正如我们在第 17 章的 “处理任意数量的 Futures” 中看到的那样。我们已经多次看到这个问题的解决方案:我们可以使用特征对象,如 Listing 20-34 所示。

fn main() {
    let handlers = vec![returns_closure(), returns_initialized_closure(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
    Box::new(|x| x + 1)
}

fn returns_initialized_closure(init: i32) -> Box<dyn Fn(i32) -> i32> {
    Box::new(move |x| x + init)
}

这段代码将正常编译。有关特征对象的更多信息,请参阅第 18 章的 “使用允许不同类型值的特征对象” 部分。

接下来,让我们看看宏!

我们在本书中已经使用了像 println! 这样的宏,但还没有完全探讨宏是什么以及它是如何工作的。术语 指的是 Rust 中的一系列特性:使用 macro_rules!声明式 宏和三种 过程式 宏:

  • 自定义 #[derive] 宏,用于指定通过 derive 属性在结构体和枚举上添加的代码
  • 类属性宏,用于定义可用于任何项的自定义属性
  • 类函数宏,看起来像函数调用,但操作的是作为参数传递的标记

我们将依次讨论这些宏,但首先,让我们看看为什么在已经有函数的情况下还需要宏。

宏与函数的区别

从根本上说,宏是一种编写代码的方式,它会生成其他代码,这被称为 元编程。在附录 C 中,我们讨论了 derive 属性,它会为你生成各种 trait 的实现。我们在书中也使用了 println!vec! 宏。所有这些宏都会 展开 以生成比你手动编写的代码更多的代码。

元编程对于减少你需要编写和维护的代码量非常有用,这也是函数的作用之一。然而,宏有一些函数不具备的额外能力。

函数的签名必须声明函数的参数数量和类型。而宏可以接受可变数量的参数:我们可以用一个参数调用 println!("hello"),也可以用两个参数调用 println!("hello {}", name)。此外,宏在编译器解释代码的含义之前就已经展开了,因此宏可以在给定的类型上实现 trait。函数则不行,因为函数是在运行时调用的,而 trait 需要在编译时实现。

实现宏而不是函数的缺点是宏定义比函数定义更复杂,因为你是在编写 Rust 代码来生成 Rust 代码。由于这种间接性,宏定义通常比函数定义更难阅读、理解和维护。

宏和函数之间的另一个重要区别是,你必须在文件中调用宏之前定义宏或将宏引入作用域,而函数则可以在任何地方定义并在任何地方调用。

使用 macro_rules! 进行通用元编程的声明式宏

Rust 中最广泛使用的宏形式是 声明式宏。这些宏有时也被称为“示例宏”、“macro_rules! 宏”或简称为“宏”。声明式宏的核心是允许你编写类似于 Rust match 表达式的代码。正如第 6 章所讨论的,match 表达式是一种控制结构,它接受一个表达式,将表达式的结果值与模式进行比较,然后运行与匹配模式相关的代码。宏也会将一个值与模式进行比较,这些模式与特定的代码相关联:在这种情况下,值是传递给宏的 Rust 源代码字面量;模式与该源代码的结构进行比较;当匹配时,与每个模式相关的代码会替换传递给宏的代码。这一切都发生在编译期间。

要定义一个宏,你可以使用 macro_rules! 结构。让我们通过查看 vec! 宏的定义来探索如何使用 macro_rules!。第 8 章介绍了如何使用 vec! 宏来创建一个包含特定值的新向量。例如,以下宏创建了一个包含三个整数的新向量:

#![allow(unused)]
fn main() {
let v: Vec<u32> = vec![1, 2, 3];
}

我们也可以使用 vec! 宏来创建一个包含两个整数的向量或一个包含五个字符串切片的向量。我们无法使用函数来完成同样的操作,因为我们无法提前知道值的数量或类型。

Listing 20-35 展示了 vec! 宏的一个简化版本的定义。

#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}

注意:标准库中 vec! 宏的实际定义包含了预先分配正确内存的代码。为了简化示例,我们没有包含这段代码。

#[macro_export] 注解表示每当定义宏的 crate 被引入作用域时,这个宏应该可用。如果没有这个注解,宏将无法被引入作用域。

然后我们以 macro_rules! 和宏的名称(不带 感叹号)开始宏定义。在这个例子中,名称是 vec,后面跟着表示宏定义主体的花括号。

vec! 主体中的结构与 match 表达式的结构类似。这里我们有一个模式为 ( $( $x:expr ),* ) 的分支,后面跟着 => 和与该模式相关的代码块。如果模式匹配,相关的代码块将被生成。由于这是该宏中唯一的模式,因此只有一种有效的匹配方式;任何其他模式都会导致错误。更复杂的宏会有多个分支。

宏定义中的有效模式语法与第 19 章中介绍的模式语法不同,因为宏模式是与 Rust 代码结构匹配的,而不是与值匹配。让我们逐步解释 Listing 20-29 中的模式片段;完整的宏模式语法请参阅 Rust 参考手册

首先,我们使用一组括号来包围整个模式。我们使用美元符号 ($) 来声明宏系统中的一个变量,该变量将包含与模式匹配的 Rust 代码。美元符号清楚地表明这是一个宏变量,而不是普通的 Rust 变量。接下来是一组括号,用于捕获与括号内模式匹配的值,以便在替换代码中使用。在 $() 内部是 $x:expr,它匹配任何 Rust 表达式并将表达式命名为 $x

$() 后面的逗号表示在匹配 $() 内代码的每个实例之间必须出现一个字面逗号分隔符。* 指定该模式匹配零个或多个 * 前面的内容。

当我们使用 vec![1, 2, 3]; 调用这个宏时,$x 模式会与三个表达式 123 匹配三次。

现在让我们看看与该分支相关的代码主体中的模式:temp_vec.push()$()* 内生成,用于每个匹配 $() 的部分,根据模式匹配的次数生成零次或多次。$x 被替换为每个匹配的表达式。当我们使用 vec![1, 2, 3]; 调用这个宏时,生成的代码将替换这个宏调用,如下所示:

{
    let mut temp_vec = Vec::new();
    temp_vec.push(1);
    temp_vec.push(2);
    temp_vec.push(3);
    temp_vec
}

我们已经定义了一个宏,它可以接受任意数量的任意类型的参数,并可以生成代码来创建一个包含指定元素的向量。

要了解更多关于如何编写宏的信息,请查阅在线文档或其他资源,例如由 Daniel Keep 开始并由 Lukas Wirth 继续编写的 “The Little Book of Rust Macros”

用于从属性生成代码的过程式宏

第二种形式的宏是过程式宏,它的行为更像一个函数(并且是一种过程)。过程式宏 接受一些代码作为输入,对这些代码进行操作,并生成一些代码作为输出,而不是像声明式宏那样匹配模式并用其他代码替换代码。过程式宏有三种:自定义 derive、类属性和类函数宏,它们的工作方式类似。

在创建过程式宏时,定义必须位于具有特殊 crate 类型的独立 crate 中。这是由于复杂的技术原因,我们希望在未来消除这一限制。在 Listing 20-36 中,我们展示了如何定义一个过程式宏,其中 some_attribute 是使用特定宏变体的占位符。

use proc_macro;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}

定义过程式宏的函数接受一个 TokenStream 作为输入,并生成一个 TokenStream 作为输出。TokenStream 类型由 Rust 附带的 proc_macro crate 定义,表示一系列标记。这是宏的核心:宏操作的源代码构成了输入的 TokenStream,宏生成的代码是输出的 TokenStream。该函数还附加了一个属性,用于指定我们正在创建的过程式宏的类型。我们可以在同一个 crate 中拥有多种类型的过程式宏。

让我们看看不同类型的过程式宏。我们将从自定义 derive 宏开始,然后解释其他形式的不同之处。

如何编写自定义 derive

让我们创建一个名为 hello_macro 的 crate,它定义了一个名为 HelloMacro 的 trait,并带有一个关联函数 hello_macro。与其让用户为每个类型实现 HelloMacro trait,我们将提供一个过程式宏,以便用户可以使用 #[derive(HelloMacro)] 注解他们的类型,从而获得 hello_macro 函数的默认实现。默认实现将打印 Hello, Macro! My name is TypeName!,其中 TypeName 是定义该 trait 的类型的名称。换句话说,我们将编写一个 crate,使其他程序员能够使用我们的 crate 编写如 Listing 20-37 所示的代码。

use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
}

当我们完成后,这段代码将打印 Hello, Macro! My name is Pancakes!。第一步是创建一个新的库 crate,如下所示:

$ cargo new hello_macro --lib

接下来,我们将定义 HelloMacro trait 及其关联函数:

pub trait HelloMacro {
    fn hello_macro();
}

我们有一个 trait 及其函数。此时,我们的 crate 用户可以实现该 trait 以实现所需的功能,如 Listing 20-39 所示。

use hello_macro::HelloMacro;

struct Pancakes;

impl HelloMacro for Pancakes {
    fn hello_macro() {
        println!("Hello, Macro! My name is Pancakes!");
    }
}

fn main() {
    Pancakes::hello_macro();
}

然而,他们需要为每个想要使用 hello_macro 的类型编写实现块;我们希望避免他们做这项工作。

此外,我们还无法为 hello_macro 函数提供默认实现,该实现将打印实现该 trait 的类型的名称:Rust 没有反射功能,因此无法在运行时查找类型的名称。我们需要一个宏来在编译时生成代码。

下一步是定义过程式宏。在撰写本文时,过程式宏需要位于它们自己的 crate 中。最终,这一限制可能会被取消。构建 crate 和宏 crate 的约定如下:对于名为 foo 的 crate,自定义 derive 过程式宏 crate 称为 foo_derive。让我们在 hello_macro 项目中启动一个名为 hello_macro_derive 的新 crate:

$ cargo new hello_macro_derive --lib

我们的两个 crate 紧密相关,因此我们在 hello_macro crate 的目录中创建过程式宏 crate。如果我们更改 hello_macro 中的 trait 定义,我们也必须更改 hello_macro_derive 中的过程式宏实现。这两个 crate 需要分别发布,使用这些 crate 的程序员需要将两者都添加为依赖项并将它们引入作用域。我们可以让 hello_macro crate 使用 hello_macro_derive 作为依赖项并重新导出过程式宏代码。然而,我们构建项目的方式使得程序员即使不需要 derive 功能也可以使用 hello_macro

我们需要将 hello_macro_derive crate 声明为过程式宏 crate。我们还需要 synquote crate 的功能,正如你稍后将看到的,因此我们需要将它们添加为依赖项。将以下内容添加到 hello_macro_deriveCargo.toml 文件中:

[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"

要开始定义过程式宏,请将 Listing 20-40 中的代码放入 hello_macro_derive crate 的 src/lib.rs 文件中。请注意,这段代码在我们添加 impl_hello_macro 函数的定义之前不会编译。

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate.
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation.
    impl_hello_macro(&ast)
}

请注意,我们将代码拆分为 hello_macro_derive 函数,它负责解析 TokenStream,以及 impl_hello_macro 函数,它负责转换语法树:这使得编写过程式宏更加方便。外部函数(在本例中为 hello_macro_derive)的代码几乎适用于你看到或创建的每个过程式宏 crate。你在内部函数(在本例中为 impl_hello_macro)主体中指定的代码将根据你的过程式宏的目的而有所不同。

我们引入了三个新的 crate:proc_macrosynquoteproc_macro crate 随 Rust 一起提供,因此我们不需要将其添加到 Cargo.toml 中的依赖项中。proc_macro crate 是编译器的 API,允许我们从代码中读取和操作 Rust 代码。

syn crate 将 Rust 代码从字符串解析为我们可以操作的数据结构。quote crate 将 syn 数据结构转换回 Rust 代码。这些 crate 使得解析我们可能想要处理的任何类型的 Rust 代码变得更加简单:编写一个完整的 Rust 代码解析器并非易事。

当我们的库用户在类型上指定 #[derive(HelloMacro)] 时,将调用 hello_macro_derive 函数。这是可能的,因为我们在这里用 proc_macro_derive 注解了 hello_macro_derive 函数,并指定了名称 HelloMacro,它与我们的 trait 名称匹配;这是大多数过程式宏遵循的约定。

hello_macro_derive 函数首先将 inputTokenStream 转换为我们可以解释和执行操作的数据结构。这就是 syn 发挥作用的地方。syn 中的 parse 函数接受一个 TokenStream 并返回一个表示解析后的 Rust 代码的 DeriveInput 结构体。Listing 20-40 展示了我们从解析 struct Pancakes; 字符串得到的 DeriveInput 结构体的相关部分。

DeriveInput {
    // --snip--

    ident: Ident {
        ident: "Pancakes",
        span: #0 bytes(95..103)
    },
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Unit,
            semi_token: Some(
                Semi
            )
        }
    )
}

这个结构体的字段显示我们解析的 Rust 代码是一个名为 Pancakes 的单元结构体。这个结构体上有更多字段用于描述各种 Rust 代码;有关更多信息,请参阅 syn 文档中的 DeriveInput

很快我们将定义 impl_hello_macro 函数,这是我们构建要包含的新 Rust 代码的地方。但在我们这样做之前,请注意我们的 derive 宏的输出也是一个 TokenStream。返回的 TokenStream 被添加到我们的 crate 用户编写的代码中,因此当他们编译他们的 crate 时,他们将获得我们在修改后的 TokenStream 中提供的额外功能。

你可能已经注意到,我们在这里调用 unwrap 来使 hello_macro_derive 函数在 syn::parse 函数调用失败时 panic。我们的过程式宏必须在错误时 panic,因为 proc_macro_derive 函数必须返回 TokenStream 而不是 Result 以符合过程式宏 API。我们通过使用 unwrap 简化了这个示例;在生产代码中,你应该通过使用 panic!expect 提供更具体的错误信息。

现在我们已经有了将注解的 Rust 代码从 TokenStream 转换为 DeriveInput 实例的代码,让我们生成在注解类型上实现 HelloMacro trait 的代码,如 Listing 20-42 所示。

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation
    impl_hello_macro(&ast)
}

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let generated = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello, Macro! My name is {}!", stringify!(#name));
            }
        }
    };
    generated.into()
}

我们使用 ast.ident 获取包含注解类型名称(标识符)的 Ident 结构体实例。Listing 20-33 中的结构体显示,当我们在 Listing 20-31 中的代码上运行 impl_hello_macro 函数时,我们得到的 ident 将具有值为 "Pancakes"ident 字段。因此,Listing 20-34 中的 name 变量将包含一个 Ident 结构体实例,当打印时,它将是字符串 "Pancakes",即 Listing 20-37 中结构体的名称。

quote! 宏允许我们定义我们想要返回的 Rust 代码。编译器期望与 quote! 宏执行的直接结果不同的东西,因此我们需要将其转换为 TokenStream。我们通过调用 into 方法来实现这一点,该方法消耗这个中间表示并返回所需的 TokenStream 类型的值。

quote! 宏还提供了一些非常酷的模板机制:我们可以输入 #namequote! 将用变量 name 中的值替换它。你甚至可以做一些类似于常规宏工作的重复操作。查看 the quote crate 的文档 以获取详细介绍。

我们希望我们的过程式宏为用户注解的类型生成 HelloMacro trait 的实现,我们可以通过使用 #name 来获取。trait 实现有一个函数 hello_macro,其主体包含我们想要提供的功能:打印 Hello, Macro! My name is,然后是注解类型的名称。

这里使用的 stringify! 宏是 Rust 内置的。它接受一个 Rust 表达式,例如 1 + 2,并在编译时将表达式转换为字符串字面量,例如 "1 + 2"。这与 format!println! 宏不同,后者会评估表达式然后将结果转换为 String#name 输入可能是一个要逐字打印的表达式,因此我们使用 stringify!。使用 stringify! 还可以通过在编译时将 #name 转换为字符串字面量来节省分配。

此时,cargo build 应该在 hello_macrohello_macro_derive 中成功完成。让我们将这些 crate 连接到 Listing 20-31 中的代码,看看过程式宏的实际效果!在你的 projects 目录中使用 cargo new pancakes 创建一个新的二进制项目。我们需要在 pancakes crate 的 Cargo.toml 中添加 hello_macrohello_macro_derive 作为依赖项。如果你将 hello_macrohello_macro_derive 的版本发布到 crates.io,它们将是常规依赖项;如果没有,你可以将它们指定为 path 依赖项,如下所示:

hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }

将 Listing 20-37 中的代码放入 src/main.rs,并运行 cargo run:它应该打印 Hello, Macro! My name is Pancakes! 过程式宏的 HelloMacro trait 实现被包含在内,而 pancakes crate 不需要实现它;#[derive(HelloMacro)] 添加了 trait 实现。

接下来,让我们探讨其他类型的过程式宏与自定义 derive 宏的不同之处。

类属性宏

类属性宏类似于自定义 derive 宏,但它们不是为 derive 属性生成代码,而是允许你创建新的属性。它们也更灵活:derive 仅适用于结构体和枚举;属性可以应用于其他项,例如函数。以下是一个使用类属性宏的示例。假设你有一个名为 route 的属性,在使用 Web 应用程序框架时用于注解函数:

#[route(GET, "/")]
fn index() {

这个 #[route] 属性将由框架定义为过程式宏。宏定义函数的签名将如下所示:

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {

在这里,我们有两个 TokenStream 类型的参数。第一个用于属性的内容:GET, "/" 部分。第二个是属性附加的项的主体:在本例中,fn index() {} 和函数的其余主体。

除此之外,类属性宏的工作方式与自定义 derive 宏相同:你创建一个具有 proc-macro crate 类型的 crate,并实现一个生成你想要的代码的函数!

类函数宏

类函数宏定义了看起来像函数调用的宏。与 macro_rules! 宏类似,它们比函数更灵活;例如,它们可以接受未知数量的参数。然而,macro_rules! 宏只能使用我们在 “使用 macro_rules! 进行通用元编程的声明式宏” 中讨论的类似匹配的语法定义。类函数宏接受一个 TokenStream 参数,并且它们的定义使用 Rust 代码操作该 TokenStream,就像其他两种过程式宏一样。类函数宏的一个示例是 sql! 宏,它可能像这样调用:

let sql = sql!(SELECT * FROM posts WHERE id=1);

这个宏将解析其中的 SQL 语句并检查其语法是否正确,这比 macro_rules! 宏所能做的处理要复杂得多。sql! 宏将像这样定义:

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {

这个定义类似于自定义 derive 宏的签名:我们接收括号内的标记并返回我们想要生成的代码。

总结

哇!现在你工具箱中有一些 Rust 特性,你可能不会经常使用,但你会知道它们在非常特殊的情况下是可用的。我们介绍了几个复杂的主题,以便当你在错误消息建议或其他人的代码中遇到它们时,你能够识别这些概念和语法。使用本章作为参考来指导你找到解决方案。

接下来,我们将把我们在整本书中讨论的所有内容付诸实践,并再做一个项目!

最终项目:构建一个多线程的 Web 服务器

这是一段漫长的旅程,但我们终于来到了本书的结尾。在这一章中,我们将一起构建一个项目,以展示我们在最后几章中涵盖的一些概念,并回顾一些早期的课程。

对于我们的最终项目,我们将制作一个 Web 服务器,它在 Web 浏览器中显示“hello”,并看起来像图 21-1。

hello from rust

图 21-1:我们的最终共享项目

这是我们构建 Web 服务器的计划:

  1. 学习一些关于 TCP 和 HTTP 的知识。
  2. 在套接字上监听 TCP 连接。
  3. 解析少量的 HTTP 请求。
  4. 创建一个适当的 HTTP 响应。
  5. 使用线程池提高服务器的吞吐量。

在我们开始之前,我们应该提到两个细节。首先,我们将使用的方法并不是用 Rust 构建 Web 服务器的最佳方式。社区成员已经在 crates.io 上发布了许多生产就绪的 crate,这些 crate 提供了比我们将要构建的更完整的 Web 服务器和线程池实现。然而,我们在本章中的意图是帮助你学习,而不是走捷径。因为 Rust 是一种系统编程语言,我们可以选择我们想要工作的抽象级别,并且可以深入到比其他语言可能或实际更低的级别。

其次,我们在这里不会使用 asyncawait。构建一个线程池本身就是一个足够大的挑战,不需要再添加构建异步运行时的复杂性!然而,我们会注意到 asyncawait 可能适用于我们在本章中看到的某些问题。最终,正如我们在第 17 章中提到的,许多异步运行时使用线程池来管理工作。

因此,我们将手动编写基本的 HTTP 服务器和线程池,以便你可以学习未来可能使用的 crate 背后的通用思想和技术。

构建一个单线程的 Web 服务器

我们将从构建一个单线程的 Web 服务器开始。在开始之前,让我们先快速了解一下构建 Web 服务器所涉及的协议。这些协议的细节超出了本书的范围,但简要概述将为你提供所需的信息。

Web 服务器涉及的两个主要协议是超文本传输协议HTTP)和传输控制协议TCP)。这两种协议都是请求-响应协议,意味着客户端发起请求,服务器监听请求并向客户端提供响应。这些请求和响应的内容由协议定义。

TCP 是较低级别的协议,它描述了信息如何从一个服务器传递到另一个服务器的细节,但没有指定这些信息的内容。HTTP 建立在 TCP 之上,定义了请求和响应的内容。从技术上讲,HTTP 可以与其他协议一起使用,但在绝大多数情况下,HTTP 通过 TCP 发送其数据。我们将处理 TCP 和 HTTP 请求和响应的原始字节。

监听 TCP 连接

我们的 Web 服务器需要监听 TCP 连接,因此这是我们首先要处理的部分。标准库提供了一个 std::net 模块,让我们可以做到这一点。让我们以通常的方式创建一个新项目:

$ cargo new hello
     Created binary (application) `hello` project
$ cd hello

现在在 src/main.rs 中输入代码,如 Listing 21-1 所示。这段代码将在本地地址 127.0.0.1:7878 上监听传入的 TCP 流。当它接收到一个传入的流时,它将打印 Connection established!

use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        println!("Connection established!");
    }
}

使用 TcpListener,我们可以在地址 127.0.0.1:7878 上监听 TCP 连接。在地址中,冒号前的部分是代表你计算机的 IP 地址(这在每台计算机上都是相同的,并不特指作者的计算机),而 7878 是端口。我们选择这个端口有两个原因:HTTP 通常不接受这个端口,因此我们的服务器不太可能与你机器上运行的其他 Web 服务器冲突,而且 7878 在电话键盘上拼写为 rust

在这个场景中,bind 函数的工作方式类似于 new 函数,它将返回一个新的 TcpListener 实例。这个函数被称为 bind,因为在网络编程中,连接到端口以进行监听被称为“绑定到端口”。

bind 函数返回一个 Result<T, E>,这表明绑定可能会失败。例如,连接到端口 80 需要管理员权限(非管理员只能监听高于 1023 的端口),因此如果我们尝试在没有管理员权限的情况下连接到端口 80,绑定将失败。例如,如果我们运行两个程序实例并让两个程序监听同一个端口,绑定也会失败。因为我们正在编写一个用于学习的基本服务器,所以我们不会担心处理这些类型的错误;相反,我们使用 unwrap 在发生错误时停止程序。

TcpListener 上的 incoming 方法返回一个迭代器,它为我们提供了一系列流(更具体地说,是类型为 TcpStream 的流)。单个表示客户端和服务器之间的开放连接。连接是客户端连接到服务器、服务器生成响应以及服务器关闭连接的完整请求和响应过程的名称。因此,我们将从 TcpStream 中读取客户端发送的内容,然后将我们的响应写入流中以将数据发送回客户端。总的来说,这个 for 循环将依次处理每个连接,并为我们生成一系列流来处理。

目前,我们对流的处理包括调用 unwrap 以在流出现任何错误时终止程序;如果没有错误,程序将打印一条消息。我们将在下一个 Listing 中为成功的情况添加更多功能。当客户端连接到服务器时,我们可能会从 incoming 方法收到错误的原因是,我们实际上并不是在迭代连接。相反,我们是在迭代连接尝试。连接可能由于多种原因而不成功,其中许多原因是特定于操作系统的。例如,许多操作系统对它们可以支持的并发开放连接数量有限制;超过该数量的新连接尝试将产生错误,直到一些开放连接被关闭。

让我们尝试运行这段代码!在终端中调用 cargo run,然后在 Web 浏览器中加载 127.0.0.1:7878。浏览器应该会显示类似“连接重置”的错误消息,因为服务器当前没有返回任何数据。但是当你查看终端时,你应该会看到几条消息,这些消息是在浏览器连接到服务器时打印的!

     Running `target/debug/hello`
Connection established!
Connection established!
Connection established!

有时你会看到一条浏览器请求打印了多条消息;原因可能是浏览器正在请求页面以及其他资源,例如出现在浏览器标签中的 favicon.ico 图标。

也可能是浏览器多次尝试连接到服务器,因为服务器没有返回任何数据。当 stream 在循环结束时超出范围并被丢弃时,连接会作为 drop 实现的一部分关闭。浏览器有时会通过重试来处理关闭的连接,因为问题可能是暂时的。重要的是,我们已经成功获取了 TCP 连接的句柄!

记得在运行特定版本的代码后按 ctrl-c 停止程序。然后在每次代码更改后通过调用 cargo run 命令重新启动程序,以确保你运行的是最新的代码。

读取请求

让我们实现从浏览器读取请求的功能!为了将获取连接和处理连接的操作分开,我们将为处理连接启动一个新函数。在这个新的 handle_connection 函数中,我们将从 TCP 流中读取数据并打印出来,以便我们可以看到浏览器发送的数据。将代码更改为 Listing 21-2 所示。

use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    println!("Request: {http_request:#?}");
}

我们将 std::io::preludestd::io::BufReader 引入作用域,以获取允许我们从流中读取和写入的特性和类型。在 main 函数的 for 循环中,我们现在调用新的 handle_connection 函数并将 stream 传递给它,而不是打印一条消息说我们建立了连接。

handle_connection 函数中,我们创建了一个新的 BufReader 实例,它包装了对 stream 的引用。BufReader 通过为我们管理对 std::io::Read 特性方法的调用来添加缓冲。

我们创建了一个名为 http_request 的变量来收集浏览器发送到我们服务器的请求行。我们通过添加 Vec<_> 类型注解来表明我们希望将这些行收集到一个向量中。

BufReader 实现了 std::io::BufRead 特性,该特性提供了 lines 方法。lines 方法通过每当看到换行字节时拆分数据流来返回一个 Result<String, std::io::Error> 的迭代器。为了获取每个 String,我们映射并 unwrap 每个 Result。如果数据不是有效的 UTF-8 或者从流中读取时出现问题,Result 可能是一个错误。再次强调,生产程序应该更优雅地处理这些错误,但为了简单起见,我们选择在错误情况下停止程序。

浏览器通过连续发送两个换行字符来指示 HTTP 请求的结束,因此为了从流中获取一个请求,我们获取行直到得到一个空字符串。一旦我们将这些行收集到向量中,我们就会使用漂亮的调试格式打印它们,以便我们可以查看 Web 浏览器发送给我们服务器的指令。

让我们尝试这段代码!启动程序并在 Web 浏览器中再次发出请求。请注意,我们仍然会在浏览器中收到错误页面,但我们的程序在终端中的输出现在应该类似于以下内容:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/hello`
Request: [
    "GET / HTTP/1.1",
    "Host: 127.0.0.1:7878",
    "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0",
    "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
    "Accept-Language: en-US,en;q=0.5",
    "Accept-Encoding: gzip, deflate, br",
    "DNT: 1",
    "Connection: keep-alive",
    "Upgrade-Insecure-Requests: 1",
    "Sec-Fetch-Dest: document",
    "Sec-Fetch-Mode: navigate",
    "Sec-Fetch-Site: none",
    "Sec-Fetch-User: ?1",
    "Cache-Control: max-age=0",
]

根据你的浏览器,你可能会得到稍微不同的输出。现在我们正在打印请求数据,我们可以通过查看请求第一行中 GET 之后的路径来了解为什么我们会从一个浏览器请求中获得多个连接。如果重复的连接都在请求 /,我们知道浏览器正在尝试重复获取 /,因为它没有从我们的程序中获得响应。

让我们分解这个请求数据,以了解浏览器对我们的程序提出了什么要求。

深入了解 HTTP 请求

HTTP 是一个基于文本的协议,请求的格式如下:

Method Request-URI HTTP-Version CRLF
headers CRLF
message-body

第一行是请求行,它包含有关客户端请求的信息。请求行的第一部分指示正在使用的方法,例如 GETPOST,它描述了客户端如何发出此请求。我们的客户端使用了 GET 请求,这意味着它正在请求信息。

请求行的下一部分是 /,它指示客户端请求的统一资源标识符URI):URI 几乎与统一资源定位符URL)相同,但不完全相同。URI 和 URL 之间的区别对于本章的目的并不重要,但 HTTP 规范使用术语 URI,因此我们可以在这里将 URL 替换为 URI

最后一部分是客户端使用的 HTTP 版本,然后请求行以 CRLF 序列结束。(CRLF 代表回车换行,这是打字机时代的术语!)CRLF 序列也可以写成 \r\n,其中 \r 是回车,\n 是换行。CRLF 序列将请求行与请求数据的其余部分分开。请注意,当打印 CRLF 时,我们看到的是新行的开始,而不是 \r\n

查看我们从运行程序到目前为止收到的请求行数据,我们看到 GET 是方法,/ 是请求 URI,HTTP/1.1 是版本。

在请求行之后,从 Host: 开始的其余行是标头。GET 请求没有主体。

尝试从不同的浏览器发出请求或请求不同的地址,例如 127.0.0.1:7878/test,以查看请求数据如何变化。

现在我们知道浏览器在请求什么,让我们发送一些数据回去!

编写响应

我们将实现发送数据以响应客户端请求的功能。响应的格式如下:

HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body

第一行是状态行,它包含响应中使用的 HTTP 版本、总结请求结果的数字状态代码以及提供状态代码文本描述的原因短语。在 CRLF 序列之后是任何标头,另一个 CRLF 序列,以及响应的主体。

以下是一个使用 HTTP 版本 1.1、状态代码为 200、原因短语为 OK、没有标头且没有主体的示例响应:

HTTP/1.1 200 OK\r\n\r\n

状态代码 200 是标准的成功响应。文本是一个微小的成功 HTTP 响应。让我们将这个写入流中作为我们对成功请求的响应!从 handle_connection 函数中删除打印请求数据的 println!,并将其替换为 Listing 21-3 中的代码。

use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let response = "HTTP/1.1 200 OK\r\n\r\n";

    stream.write_all(response.as_bytes()).unwrap();
}

第一行定义了 response 变量,它保存了成功消息的数据。然后我们在 response 上调用 as_bytes 将字符串数据转换为字节。stream 上的 write_all 方法接受一个 &[u8] 并将这些字节直接发送到连接中。因为 write_all 操作可能会失败,所以我们像以前一样对任何错误结果使用 unwrap。再次强调,在实际应用程序中,你应该在这里添加错误处理。

通过这些更改,让我们运行我们的代码并发出请求。我们不再向终端打印任何数据,因此除了 Cargo 的输出外,我们不会看到任何输出。当你在 Web 浏览器中加载 127.0.0.1:7878 时,你应该得到一个空白页面而不是错误。你已经手动编码接收 HTTP 请求并发送响应!

返回真实的 HTML

让我们实现返回不仅仅是空白页面的功能。在你的项目目录的根目录中创建新文件 hello.html,而不是在 src 目录中。你可以输入任何你想要的 HTML;Listing 21-4 显示了一种可能性。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Hello!</h1>
    <p>Hi from Rust</p>
  </body>
</html>

这是一个带有标题和一些文本的最小 HTML5 文档。为了在收到请求时从服务器返回此内容,我们将修改 handle_connection,如 Listing 21-5 所示,以读取 HTML 文件,将其作为主体添加到响应中并发送。

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};
// --snip--

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let status_line = "HTTP/1.1 200 OK";
    let contents = fs::read_to_string("hello.html").unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

我们将 fs 添加到 use 语句中,以将标准库的文件系统模块引入作用域。读取文件内容到字符串的代码应该看起来很熟悉;我们在 Listing 12-4 中读取文件内容时使用了它。

接下来,我们使用 format! 将文件的内容添加为成功响应的主体。为了确保有效的 HTTP 响应,我们添加了 Content-Length 标头,它设置为响应主体的大小,在这种情况下是 hello.html 的大小。

使用 cargo run 运行此代码,并在浏览器中加载 127.0.0.1:7878;你应该会看到你的 HTML 被渲染!

目前,我们忽略了 http_request 中的请求数据,只是无条件地发送回 HTML 文件的内容。这意味着如果你在浏览器中请求 127.0.0.1:7878/something-else,你仍然会得到相同的 HTML 响应。目前,我们的服务器非常有限,没有做大多数 Web 服务器所做的事情。我们希望根据请求自定义我们的响应,并且只对格式良好的 / 请求发送回 HTML 文件。

验证请求并选择性响应

目前,我们的 Web 服务器将返回文件中的 HTML,无论客户端请求什么。让我们添加功能来检查浏览器是否在请求 /,然后再返回 HTML 文件,并在浏览器请求其他内容时返回错误。为此,我们需要修改 handle_connection,如 Listing 21-6 所示。这段新代码将接收到的请求内容与我们已知的 / 请求进行比较,并添加 ifelse 块以不同方式处理请求。

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}
// --snip--

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    } else {
        // some other request
    }
}

我们只会查看 HTTP 请求的第一行,因此我们调用 next 来获取迭代器中的第一项,而不是将整个请求读入向量。第一个 unwrap 处理 Option,如果迭代器没有项,则停止程序。第二个 unwrap 处理 Result,其效果与 Listing 21-2 中添加的 map 中的 unwrap 相同。

接下来,我们检查 request_line 是否等于 / 路径的 GET 请求行。如果是,if 块返回我们的 HTML 文件的内容。

如果 request_line 不等于 / 路径的 GET 请求行,这意味着我们收到了其他请求。我们稍后将在 else 块中添加代码以响应所有其他请求。

现在运行此代码并请求 127.0.0.1:7878;你应该会得到 hello.html 中的 HTML。如果你发出任何其他请求,例如 127.0.0.1:7878/something-else,你将得到一个连接错误,就像你在运行 Listing 21-1 和 Listing 21-2 中的代码时看到的那样。

现在让我们将 Listing 21-7 中的代码添加到 else 块中,以返回状态代码为 404 的响应,该响应表示未找到请求的内容。我们还将返回一些 HTML,以便在浏览器中呈现给最终用户的响应页面。

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    // --snip--
    } else {
        let status_line = "HTTP/1.1 404 NOT FOUND";
        let contents = fs::read_to_string("404.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    }
}

在这里,我们的响应有一个状态行,状态代码为 404,原因短语为 NOT FOUND。响应的主体将是文件 404.html 中的 HTML。你需要在 hello.html 旁边创建一个 404.html 文件作为错误页面;再次随意使用任何你想要的 HTML 或使用 Listing 21-8 中的示例 HTML。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Oops!</h1>
    <p>Sorry, I don't know what you're asking for.</p>
  </body>
</html>

通过这些更改,再次运行你的服务器。请求 127.0.0.1:7878 应该返回 hello.html 的内容,而任何其他请求,如 127.0.0.1:7878/foo,应该返回 404.html 中的错误 HTML。

一点重构

目前,ifelse 块中有很多重复:它们都在读取文件并将文件的内容写入流中。唯一的区别是状态行和文件名。让我们通过将这些差异提取到单独的 ifelse 行中来使代码更简洁,这些行将状态行和文件名的值分配给变量;然后我们可以在代码中无条件地使用这些变量来读取文件并写入响应。Listing 21-9 显示了替换大 ifelse 块后的结果代码。

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}
// --snip--

fn handle_connection(mut stream: TcpStream) {
    // --snip--
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
        ("HTTP/1.1 200 OK", "hello.html")
    } else {
        ("HTTP/1.1 404 NOT FOUND", "404.html")
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

现在 ifelse 块只返回状态行和文件名的适当值;然后我们使用解构将这些值分配给 status_linefilename,如第 19 章中讨论的那样。

之前重复的代码现在位于 ifelse 块之外,并使用 status_linefilename 变量。这使得更容易看到两种情况之间的差异,并且这意味着如果我们想更改文件读取和响应写入的工作方式,我们只有一个地方可以更新代码。Listing 21-9 中的代码行为将与 Listing 21-7 中的代码行为相同。

太棒了!我们现在有一个大约 40 行 Rust 代码的简单 Web 服务器,它响应一个请求并返回一个内容页面,并响应所有其他请求并返回 404 响应。

目前,我们的服务器在单线程中运行,这意味着它一次只能处理一个请求。让我们通过模拟一些慢请求来检查这如何成为一个问题。然后我们将修复它,以便我们的服务器可以同时处理多个请求。

将我们的单线程服务器转变为多线程服务器

目前,服务器会依次处理每个请求,这意味着在处理完第一个连接之前,它不会处理第二个连接。如果服务器收到越来越多的请求,这种串行执行将变得越来越不理想。如果服务器收到一个需要很长时间处理的请求,即使新的请求可以快速处理,后续请求也必须等待长时间请求完成。我们需要解决这个问题,但首先我们来看一下实际的问题。

在当前服务器实现中模拟慢请求

我们将看看一个处理缓慢的请求如何影响我们当前服务器实现中的其他请求。Listing 21-10 实现了对 /sleep 请求的处理,模拟了一个慢响应,导致服务器在响应前睡眠五秒钟。

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};
// --snip--

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    // --snip--

    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    // --snip--

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

我们现在从 if 切换到了 match,因为我们有三个情况。我们需要显式地匹配 request_line 的一个切片,以便与字符串字面值进行模式匹配;match 不会像相等方法那样自动引用和解引用。

第一个分支与 Listing 21-9 中的 if 块相同。第二个分支匹配 /sleep 请求。当收到该请求时,服务器将在渲染成功的 HTML 页面之前睡眠五秒钟。第三个分支与 Listing 21-9 中的 else 块相同。

你可以看到我们的服务器是多么原始:真正的库会以更简洁的方式处理多个请求的识别!

使用 cargo run 启动服务器。然后打开两个浏览器窗口:一个用于 http://127.0.0.1:7878/,另一个用于 http://127.0.0.1:7878/sleep。如果你像之前一样多次输入 / URI,你会看到它快速响应。但如果你输入 /sleep 然后加载 /,你会看到 / 会等待 sleep 完成五秒的睡眠后再加载。

我们可以使用多种技术来避免请求在慢请求后堆积,包括像我们在第17章中那样使用异步;我们将实现的是线程池。

使用线程池提高吞吐量

线程池 是一组生成的线程,它们等待并准备处理任务。当程序收到新任务时,它会将池中的一个线程分配给该任务,该线程将处理该任务。池中的其余线程可用于处理第一个线程处理任务时传入的其他任务。当第一个线程处理完其任务后,它会返回到空闲线程池中,准备处理新任务。线程池允许你并发处理连接,从而提高服务器的吞吐量。

我们将限制池中的线程数量,以防止 DoS 攻击;如果我们的程序为每个传入的请求创建一个新线程,那么有人向我们的服务器发出1000万次请求可能会通过耗尽我们服务器的所有资源并使请求处理停止来造成混乱。

因此,我们将让池中等待的线程数量固定。传入的请求被发送到池中进行处理。池将维护一个传入请求的队列。池中的每个线程将从该队列中弹出一个请求,处理该请求,然后向队列请求另一个请求。通过这种设计,我们可以并发处理最多 N 个请求,其中 N 是线程的数量。如果每个线程都在响应一个长时间运行的请求,后续请求仍然可能在队列中堆积,但我们已经增加了在达到该点之前可以处理的长时间运行请求的数量。

这种技术只是提高 Web 服务器吞吐量的众多方法之一。你可能探索的其他选项包括 fork/join 模型、单线程异步 I/O 模型和多线程异步 I/O 模型。如果你对这个主题感兴趣,可以阅读更多关于其他解决方案的内容,并尝试实现它们;对于像 Rust 这样的低级语言,所有这些选项都是可能的。

在我们开始实现线程池之前,让我们讨论一下使用池应该是什么样子。当你尝试设计代码时,首先编写客户端接口可以帮助指导你的设计。编写代码的 API,使其结构符合你希望调用的方式;然后在该结构中实现功能,而不是先实现功能再设计公共 API。

类似于我们在第12章的项目中使用测试驱动开发的方式,我们将在这里使用编译器驱动开发。我们将编写调用我们想要的函数的代码,然后查看编译器的错误,以确定接下来应该更改什么以使代码工作。然而,在此之前,我们将探讨我们不打算作为起点的技术。

为每个请求生成一个线程

首先,让我们探讨一下如果我们的代码为每个连接创建一个新线程,它可能是什么样子。如前所述,由于可能生成无限数量的线程的问题,这不是我们的最终计划,但它是首先获得一个工作的多线程服务器的起点。然后我们将添加线程池作为改进,对比这两种解决方案会更容易。Listing 21-11 显示了在 for 循环中生成一个新线程以处理每个流的 main 的更改。

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        thread::spawn(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

正如你在第16章中学到的,thread::spawn 将创建一个新线程,然后在新线程中运行闭包中的代码。如果你运行此代码并在浏览器中加载 /sleep,然后在另外两个浏览器标签中加载 /,你确实会看到 / 的请求不必等待 /sleep 完成。然而,正如我们提到的,这最终会压倒系统,因为你将无限制地创建新线程。

你可能还记得第17章,这正是 async 和 await 真正闪耀的地方!在我们构建线程池时,请记住这一点,并思考使用 async 时事情会有什么不同或相同。

创建有限数量的线程

我们希望我们的线程池以类似、熟悉的方式工作,以便从线程切换到线程池不需要对使用我们 API 的代码进行大的更改。Listing 21-12 显示了我们希望使用的 ThreadPool 结构的假设接口,而不是 thread::spawn

use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

我们使用 ThreadPool::new 创建一个具有可配置数量线程的新线程池,在本例中为四个。然后,在 for 循环中,pool.execute 的接口与 thread::spawn 类似,因为它接受一个闭包,池应该为每个流运行该闭包。我们需要实现 pool.execute,以便它接受闭包并将其交给池中的一个线程运行。这段代码还不能编译,但我们会尝试,以便编译器可以指导我们如何修复它。

使用编译器驱动开发构建 ThreadPool

src/main.rs 中进行 Listing 21-12 中的更改,然后让我们使用 cargo check 的编译器错误来驱动我们的开发。这是我们得到的第一个错误:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0433]: failed to resolve: use of undeclared type `ThreadPool`
  --> src/main.rs:11:16
   |
11 |     let pool = ThreadPool::new(4);
   |                ^^^^^^^^^^ use of undeclared type `ThreadPool`

For more information about this error, try `rustc --explain E0433`.
error: could not compile `hello` (bin "hello") due to 1 previous error

太好了!这个错误告诉我们我们需要一个 ThreadPool 类型或模块,所以我们现在就构建一个。我们的 ThreadPool 实现将独立于我们的 Web 服务器所做的工作类型。因此,让我们将 hello crate 从二进制 crate 切换为库 crate,以保存我们的 ThreadPool 实现。在更改为库 crate 后,我们还可以使用单独的线程池库来处理我们想要使用线程池完成的任何工作,而不仅仅是处理 Web 请求。

创建一个 src/lib.rs 文件,其中包含以下内容,这是我们目前可以拥有的最简单的 ThreadPool 结构定义:

pub struct ThreadPool;

然后编辑 main.rs 文件,通过将以下代码添加到 src/main.rs 的顶部,将 ThreadPool 从库 crate 引入作用域:

use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

这段代码仍然无法工作,但让我们再次检查它以获取我们需要解决的下一个错误:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0599]: no function or associated item named `new` found for struct `ThreadPool` in the current scope
  --> src/main.rs:12:28
   |
12 |     let pool = ThreadPool::new(4);
   |                            ^^^ function or associated item not found in `ThreadPool`

For more information about this error, try `rustc --explain E0599`.
error: could not compile `hello` (bin "hello") due to 1 previous error

这个错误表明接下来我们需要为 ThreadPool 创建一个名为 new 的关联函数。我们还知道 new 需要有一个可以接受 4 作为参数的参数,并且应该返回一个 ThreadPool 实例。让我们实现最简单的 new 函数,该函数将具有这些特征:

pub struct ThreadPool;

impl ThreadPool {
    pub fn new(size: usize) -> ThreadPool {
        ThreadPool
    }
}

我们选择 usize 作为 size 参数的类型,因为我们知道负数的线程没有意义。我们还知道我们将使用这个 4 作为线程集合中的元素数量,这正是 usize 类型的用途,正如第3章中的 “整数类型” 所讨论的那样。

让我们再次检查代码:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0599]: no method named `execute` found for struct `ThreadPool` in the current scope
  --> src/main.rs:17:14
   |
17 |         pool.execute(|| {
   |         -----^^^^^^^ method not found in `ThreadPool`

For more information about this error, try `rustc --explain E0599`.
error: could not compile `hello` (bin "hello") due to 1 previous error

现在错误发生是因为我们没有 ThreadPool 上的 execute 方法。回想一下 “创建有限数量的线程”,我们决定我们的线程池应该有一个类似于 thread::spawn 的接口。此外,我们将实现 execute 函数,以便它接受给定的闭包并将其交给池中的一个空闲线程运行。

我们将在 ThreadPool 上定义 execute 方法,以闭包作为参数。回想一下第13章中的 “将捕获的值移出闭包和 Fn 特性”,我们可以使用三种不同的特性来接受闭包作为参数:FnFnMutFnOnce。我们需要决定在这里使用哪种闭包。我们知道我们最终会做一些类似于标准库 thread::spawn 实现的事情,所以我们可以查看 thread::spawn 的签名对其参数的限制。文档向我们展示了以下内容:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    where
        F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static,

F 类型参数是我们在这里关心的;T 类型参数与返回值有关,我们不关心它。我们可以看到 spawn 使用 FnOnce 作为 F 的特性限制。这可能也是我们想要的,因为我们最终会将 execute 中得到的参数传递给 spawn。我们可以进一步确信 FnOnce 是我们想要使用的特性,因为运行请求的线程只会执行该请求的闭包一次,这与 FnOnce 中的 Once 相匹配。

F 类型参数还具有 Send 特性限制和生命周期限制 'static,这在我们的情况下很有用:我们需要 Send 将闭包从一个线程转移到另一个线程,并且需要 'static 因为我们不知道线程执行需要多长时间。让我们在 ThreadPool 上创建一个 execute 方法,该方法将接受一个类型为 F 的通用参数,并具有这些限制:

pub struct ThreadPool;

impl ThreadPool {
    // --snip--
    pub fn new(size: usize) -> ThreadPool {
        ThreadPool
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

我们仍然在 FnOnce 之后使用 (),因为这个 FnOnce 表示一个不接受参数并返回单元类型 () 的闭包。就像函数定义一样,返回类型可以从签名中省略,但即使我们没有参数,我们仍然需要括号。

再次强调,这是 execute 方法的最简单实现:它什么都不做,但我们只是试图让我们的代码编译。让我们再次检查它:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.24s

它编译了!但请注意,如果你尝试 cargo run 并在浏览器中发出请求,你会看到我们在本章开头看到的浏览器中的错误。我们的库实际上还没有调用传递给 execute 的闭包!

注意:你可能会听到关于具有严格编译器的语言(如 Haskell 和 Rust)的说法:“如果代码编译,它就能工作。”但这种说法并不普遍正确。我们的项目编译了,但它绝对没有做任何事情!如果我们正在构建一个真正的、完整的项目,这将是一个开始编写单元测试以检查代码是否编译 并且 具有我们想要的行为的好时机。

考虑:如果我们打算执行一个 future 而不是闭包,这里会有什么不同?

new 中验证线程数量

我们没有对 newexecute 的参数做任何事情。让我们用我们想要的行为实现这些函数的主体。首先,让我们考虑 new。之前我们为 size 参数选择了一个无符号类型,因为具有负数的线程池没有意义。然而,具有零个线程的池也没有意义,但零是一个完全有效的 usize。我们将在返回 ThreadPool 实例之前添加代码来检查 size 是否大于零,并通过使用 assert! 宏让程序在接收到零时 panic,如 Listing 21-13 所示。

pub struct ThreadPool;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        ThreadPool
    }

    // --snip--
    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

我们还为我们的 ThreadPool 添加了一些文档注释。请注意,我们遵循了良好的文档实践,添加了一个部分来指出我们的函数可能 panic 的情况,如第14章所讨论的。尝试运行 cargo doc --open 并点击 ThreadPool 结构以查看为 new 生成的文档!

与其像我们在这里所做的那样添加 assert! 宏,我们可以将 new 更改为 build 并返回一个 Result,就像我们在 I/O 项目中的 Config::build 中所做的那样(Listing 12-9)。但在这种情况下,我们决定尝试创建一个没有任何线程的线程池应该是一个不可恢复的错误。如果你有雄心壮志,尝试编写一个名为 build 的函数,其签名如下,以与 new 函数进行比较:

pub fn build(size: usize) -> Result<ThreadPool, PoolCreationError> {

创建存储线程的空间

现在我们有了一个方法来知道我们有一个有效的线程数量来存储在池中,我们可以在返回结构之前创建这些线程并将它们存储在 ThreadPool 结构中。但是我们如何“存储”一个线程?让我们再看一下 thread::spawn 的签名:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    where
        F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static,

spawn 函数返回一个 JoinHandle<T>,其中 T 是闭包返回的类型。让我们也尝试使用 JoinHandle,看看会发生什么。在我们的情况下,我们传递给线程池的闭包将处理连接并且不返回任何内容,因此 T 将是单元类型 ()

Listing 21-14 中的代码将编译但尚未创建任何线程。我们更改了 ThreadPool 的定义,以保存一个 thread::JoinHandle<()> 实例的向量,用 size 的容量初始化了向量,设置了一个 for 循环来运行一些代码以创建线程,并返回一个包含它们的 ThreadPool 实例。

use std::thread;

pub struct ThreadPool {
    threads: Vec<thread::JoinHandle<()>>,
}

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut threads = Vec::with_capacity(size);

        for _ in 0..size {
            // create some threads and store them in the vector
        }

        ThreadPool { threads }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

我们将 std::thread 引入库 crate 的作用域,因为我们在 ThreadPool 中使用 thread::JoinHandle 作为向量中的项的类型。

一旦接收到有效的 size,我们的 ThreadPool 就会创建一个可以容纳 size 个项的新向量。with_capacity 函数执行与 Vec::new 相同的任务,但有一个重要的区别:它预先分配向量中的空间。因为我们知道我们需要在向量中存储 size 个元素,所以预先进行此分配比使用 Vec::new 稍微更高效,后者在插入元素时会调整自身大小。

当你再次运行 cargo check 时,它应该会成功。

一个 Worker 结构负责将代码从 ThreadPool 发送到线程

我们在 Listing 21-14 的 for 循环中留下了一个关于创建线程的注释。在这里,我们将看看我们如何实际创建线程。标准库提供了 thread::spawn 作为创建线程的方式,thread::spawn 期望在创建线程时立即获得一些代码来运行。然而,在我们的情况下,我们想要创建线程并让它们 等待 我们稍后发送的代码。标准库的线程实现不包括任何方法来做到这一点;我们必须手动实现它。

我们将通过引入一个新的数据结构来实现这种行为,该数据结构位于 ThreadPool 和线程之间,将管理这种新行为。我们将这个数据结构称为 Worker,这是池实现中的一个常见术语。Worker 会拾取需要运行的代码并在 Worker 的线程中运行该代码。

想象一下在餐厅厨房工作的人:工人们等待顾客的订单,然后他们负责接受这些订单并完成它们。

我们不会在线程池中存储 JoinHandle<()> 实例的向量,而是存储 Worker 结构的实例。每个 Worker 将存储一个 JoinHandle<()> 实例。然后我们将在 Worker 上实现一个方法,该方法将接受要运行的代码的闭包并将其发送到已经运行的线程以执行。我们还将为每个 Worker 提供一个 id,以便在记录或调试时区分池中的不同 Worker 实例。

以下是当我们创建 ThreadPool 时将发生的新过程。在我们以这种方式设置 Worker 之后,我们将实现将闭包发送到线程的代码:

  1. 定义一个 Worker 结构,它包含一个 id 和一个 JoinHandle<()>
  2. ThreadPool 更改为保存 Worker 实例的向量。
  3. 定义一个 Worker::new 函数,它接受一个 id 数字并返回一个包含 id 和一个使用空闭包生成的线程的 Worker 实例。
  4. ThreadPool::new 中,使用 for 循环计数器生成一个 id,使用该 id 创建一个新的 Worker,并将该 worker 存储在向量中。

如果你愿意接受挑战,尝试在查看 Listing 21-15 中的代码之前自己实现这些更改。

准备好了吗?以下是 Listing 21-15,其中包含一种进行上述修改的方式。

use std::thread;

pub struct ThreadPool {
    workers: Vec<Worker>,
}

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool { workers }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker { id, thread }
    }
}

我们将 ThreadPool 上的字段名称从 threads 更改为 workers,因为它现在保存的是 Worker 实例而不是 JoinHandle<()> 实例。我们使用 for 循环中的计数器作为 Worker::new 的参数,并将每个新的 Worker 存储在名为 workers 的向量中。

外部代码(如我们在 src/main.rs 中的服务器)不需要知道有关在 ThreadPool 中使用 Worker 结构的实现细节,因此我们将 Worker 结构及其 new 函数设为私有。Worker::new 函数使用我们给它的 id 并存储一个 JoinHandle<()> 实例,该实例是通过使用空闭包生成一个新线程创建的。

注意:如果操作系统无法创建线程,因为系统资源不足,thread::spawn 会 panic。这将导致我们的整个服务器 panic,即使某些线程的创建可能成功。为了简单起见,这种行为是可以的,但在生产线程池实现中,你可能希望使用 std::thread::Builder 及其 spawn 方法,该方法返回 Result 而不是 panic。

这段代码将编译并存储我们指定为 ThreadPool::new 参数的 Worker 实例数量。但我们 仍然 没有处理我们在 execute 中得到的闭包。让我们看看接下来如何做到这一点。

通过通道向线程发送请求

我们将解决的下一个问题是,传递给 thread::spawn 的闭包绝对什么都不做。目前,我们在 execute 方法中得到了我们想要执行的闭包。但我们需要在创建 ThreadPool 期间创建每个 Worker 时给 thread::spawn 一个闭包来运行。

我们希望我们刚刚创建的 Worker 结构从 ThreadPool 持有的队列中获取要运行的代码,并将该代码发送到其线程以运行。

我们在第16章中学到的通道——一种在两个线程之间通信的简单方式——将非常适合这个用例。我们将使用一个通道作为作业队列,execute 将从 ThreadPoolWorker 实例发送一个作业,该作业将发送作业到其线程。以下是计划:

  1. ThreadPool 将创建一个通道并保留发送者。
  2. 每个 Worker 将保留接收者。
  3. 我们将创建一个新的 Job 结构,它将保存我们想要通过通道发送的闭包。
  4. execute 方法将通过发送者发送它想要执行的作业。
  5. 在其线程中,Worker 将循环遍历其接收者并执行它接收到的任何作业的闭包。

让我们从在 ThreadPool::new 中创建一个通道并让 ThreadPool 实例保留发送者开始,如 Listing 21-16 所示。Job 结构目前不保存任何内容,但将是我们通过通道发送的项的类型。

use std::{sync::mpsc, thread};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool { workers, sender }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker { id, thread }
    }
}

ThreadPool::new 中,我们创建了我们的新通道,并让池保留发送者。这将成功编译。

让我们尝试在创建通道时将通道的接收者传递给每个 Worker。我们知道我们希望在 Worker 实例生成的线程中使用接收者,因此我们将在闭包中引用 receiver 参数。Listing 21-17 中的代码还不能完全编译。

use std::{sync::mpsc, thread};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, receiver));
        }

        ThreadPool { workers, sender }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

// --snip--


struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}

我们做了一些小而直接的更改:我们将接收者传递给 Worker::new,然后我们在闭包中使用它。

当我们尝试检查这段代码时,我们得到了这个错误:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0382]: use of moved value: `receiver`
  --> src/lib.rs:26:42
   |
21 |         let (sender, receiver) = mpsc::channel();
   |                      -------- move occurs because `receiver` has type `std::sync::mpsc::Receiver<Job>`, which does not implement the `Copy` trait
...
25 |         for id in 0..size {
   |         ----------------- inside of this loop
26 |             workers.push(Worker::new(id, receiver));
   |                                          ^^^^^^^^ value moved here, in previous iteration of loop
   |
note: consider changing this parameter type in method `new` to borrow instead if owning the value isn't necessary
  --> src/lib.rs:47:33
   |
47 |     fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
   |        --- in this method       ^^^^^^^^^^^^^^^^^^^ this parameter takes ownership of the value
help: consider moving the expression out of the loop so it is only moved once
   |
25 ~         let mut value = Worker::new(id, receiver);
26 ~         for id in 0..size {
27 ~             workers.push(value);
   |

For more information about this error, try `rustc --explain E0382`.
error: could not compile `hello` (lib) due to 1 previous error

代码试图将 receiver 传递给多个 Worker 实例。这不会起作用,正如你从第16章回忆的那样:Rust 提供的通道实现是多个 生产者,单个 消费者。这意味着我们不能仅仅克隆通道的消费端来修复这段代码。我们也不想多次向多个消费者发送消息;我们希望有一个消息列表,多个 Worker 实例,以便每个消息只被处理一次。

此外,从通道队列中取出一个作业涉及修改 receiver,因此线程需要一种安全的方式来共享和修改 receiver;否则,我们可能会遇到竞争条件(如第16章所述)。

回想一下第16章中讨论的线程安全智能指针:为了跨多个线程共享所有权并允许线程修改值,我们需要使用 Arc<Mutex<T>>Arc 类型将允许多个 Worker 实例拥有接收者,而 Mutex 将确保一次只有一个 Worker 从接收者获取作业。Listing 21-18 显示了我们需要的更改。

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};
// --snip--

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

// --snip--

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        // --snip--
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}

ThreadPool::new 中,我们将接收者放入 ArcMutex 中。对于每个新的 Worker,我们克隆 Arc 以增加引用计数,以便 Worker 实例可以共享接收者的所有权。

通过这些更改,代码编译了!我们快完成了!

实现 execute 方法

让我们最终实现 ThreadPool 上的 execute 方法。我们还将把 Job 从结构更改为类型别名,用于保存 execute 接收到的闭包类型的特征对象。正如第20章中的 “使用类型别名创建类型同义词” 所讨论的那样,类型别名允许我们缩短长类型以便于使用。请看 Listing 21-19。

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

// --snip--

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

// --snip--

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}

在创建了一个新的 Job 实例后,使用我们在 execute 中得到的闭包,我们将该作业发送到通道的发送端。我们在 send 上调用 unwrap 以处理发送失败的情况。如果例如我们停止所有线程执行,这意味着接收端已停止接收新消息,则可能会发生这种情况。目前,我们无法停止线程执行:只要池存在,我们的线程就会继续执行。我们使用 unwrap 的原因是我们知道失败的情况不会发生,但编译器不知道。

但我们还没有完全完成!在 Worker 中,我们传递给 thread::spawn 的闭包仍然只 引用 通道的接收端。相反,我们需要闭包永远循环,向通道的接收端请求作业并在获得作业时运行它。让我们对 Worker::new 进行 Listing 21-20 中所示的更改。

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}

在这里,我们首先调用 receiver 上的 lock 以获取互斥锁,然后我们调用 unwrap 以在出现任何错误时 panic。获取锁可能会失败,如果互斥锁处于 中毒 状态,这可能发生在某个线程在持有锁时 panic 而不是释放锁的情况下。在这种情况下,调用 unwrap 让此线程 panic 是正确的操作。你可以将此 unwrap 更改为带有对你有意义的错误消息的 expect

如果我们获得了互斥锁上的锁,我们调用 recv 从通道接收一个 Job。最后的 unwrap 也移过了这里的任何错误,如果持有发送者的线程已关闭,则可能会发生这种情况,类似于如果接收者关闭,send 方法返回 Err 的情况。

recv 的调用会阻塞,因此如果还没有作业,当前线程将等待直到有作业可用。Mutex<T> 确保一次只有一个 Worker 线程尝试请求作业。

我们的线程池现在处于工作状态!给它一个 cargo run 并发出一些请求:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
warning: field `workers` is never read
 --> src/lib.rs:7:5
  |
6 | pub struct ThreadPool {
  |            ---------- field in this struct
7 |     workers: Vec<Worker>,
  |     ^^^^^^^
  |
  = note: `#[warn(dead_code)]` on by default

warning: fields `id` and `thread` are never read
  --> src/lib.rs:48:5
   |
47 | struct Worker {
   |        ------ fields in this struct
48 |     id: usize,
   |     ^^
49 |     thread: thread::JoinHandle<()>,
   |     ^^^^^^

warning: `hello` (lib) generated 2 warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.91s
     Running `target/debug/hello`
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.

成功!我们现在有一个异步执行连接的线程池。创建的线程永远不会超过四个,因此如果服务器收到大量请求,我们的系统不会过载。如果我们向 /sleep 发出请求,服务器将能够通过让另一个线程运行它们来服务其他请求。

注意:如果你在多个浏览器窗口中同时打开 /sleep,它们可能会以五秒的间隔一次加载一个。一些 Web 浏览器会出于缓存原因顺序执行相同请求的多个实例。这个限制不是由我们的 Web 服务器引起的。

这是一个暂停并考虑如果我们使用 futures 而不是闭包来执行工作,Listing 21-18、21-19 和 21-20 中的代码会有什么不同的好时机。哪些类型会改变?方法签名会有什么不同,如果有的话?代码的哪些部分会保持不变?

在学习了第17章和第18章中的 while let 循环后,你可能想知道为什么我们没有将工作线程代码写成 Listing 21-21 中所示的样子。

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}
// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            while let Ok(job) = receiver.lock().unwrap().recv() {
                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}

这段代码编译并运行,但不会导致所需的线程行为:慢请求仍然会导致其他请求等待处理。原因有些微妙:Mutex 结构没有公共的 unlock 方法,因为锁的所有权基于 lock 方法返回的 LockResult<MutexGuard<T>> 中的 MutexGuard<T> 的生命周期。在编译时,借用检查器可以强制执行规则,即除非我们持有锁,否则不能访问由 Mutex 保护的资源。然而,如果我们不注意 MutexGuard<T> 的生命周期,这种实现也可能导致锁被持有时间比预期长。

Listing 21-20 中使用 let job = receiver.lock().unwrap().recv().unwrap(); 的代码有效,因为使用 let,等号右侧表达式中的任何临时值都会在 let 语句结束时立即丢弃。然而,while let(以及 if letmatch)不会丢弃临时值,直到关联的块结束。在 Listing 21-21 中,锁在调用 job() 期间保持持有,这意味着其他 Worker 实例无法接收作业。

优雅关闭与清理

在 Listing 21-20 中的代码通过使用线程池异步地响应请求,正如我们所期望的那样。我们收到了一些关于 workersidthread 字段的警告,这些字段我们没有直接使用,提醒我们没有清理任何东西。当我们使用不太优雅的 ctrl-c 方法来停止主线程时,所有其他线程也会立即停止,即使它们正在处理请求。

接下来,我们将实现 Drop trait 来在线程池中的每个线程上调用 join,以便它们可以在关闭之前完成正在处理的请求。然后我们将实现一种方法来告诉线程它们应该停止接受新请求并关闭。为了看到这段代码的实际效果,我们将修改我们的服务器,使其在优雅关闭线程池之前只接受两个请求。

在我们进行的过程中需要注意的一点是:这些都不会影响处理执行闭包的代码部分,因此如果我们为异步运行时使用线程池,这里的一切都将保持不变。

ThreadPool 上实现 Drop Trait

让我们从在线程池上实现 Drop 开始。当线程池被丢弃时,我们的线程应该全部 join 以确保它们完成工作。Listing 21-22 展示了 Drop 实现的第一次尝试;这段代码还不能完全工作。

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}

首先,我们遍历线程池中的每个 workers。我们使用 &mut 因为 self 是一个可变引用,并且我们还需要能够改变 worker。对于每个 worker,我们打印一条消息,说明这个特定的 Worker 实例正在关闭,然后我们对该 Worker 实例的线程调用 join。如果调用 join 失败,我们使用 unwrap 使 Rust 恐慌并进入不优雅的关闭。

当我们编译这段代码时,会得到以下错误:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0507]: cannot move out of `worker.thread` which is behind a mutable reference
    --> src/lib.rs:52:13
     |
52   |             worker.thread.join().unwrap();
     |             ^^^^^^^^^^^^^ ------ `worker.thread` moved due to this method call
     |             |
     |             move occurs because `worker.thread` has type `JoinHandle<()>`, which does not implement the `Copy` trait
     |
note: `JoinHandle::<T>::join` takes ownership of the receiver `self`, which moves `worker.thread`
    --> file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/std/src/thread/mod.rs:1876:17
     |
1876 |     pub fn join(self) -> Result<T> {
     |                 ^^^^

For more information about this error, try `rustc --explain E0507`.
error: could not compile `hello` (lib) due to 1 previous error

错误告诉我们不能调用 join,因为我们只有每个 worker 的可变借用,而 join 需要拥有其参数的所有权。为了解决这个问题,我们需要将线程从拥有 threadWorker 实例中移出,以便 join 可以消费线程。一种方法是采用我们在 Listing 18-15 中使用的相同方法。如果 Worker 持有一个 Option<thread::JoinHandle<()>>,我们可以调用 Option 上的 take 方法将值从 Some 变体中移出,并在其位置留下一个 None 变体。换句话说,一个正在运行的 Worker 会在 thread 中有一个 Some 变体,当我们想要清理一个 Worker 时,我们会用 None 替换 Some,这样 Worker 就不会有线程可以运行。

然而,唯一会出现这种情况的时候是在丢弃 Worker 时。作为交换,我们必须在任何访问 worker.thread 的地方处理 Option<thread::JoinHandle<()>>。惯用的 Rust 会大量使用 Option,但当你发现自己将你知道总是存在的东西包装在 Option 中作为这样的变通方法时,最好寻找替代方法。它们可以使你的代码更简洁且更不容易出错。

在这种情况下,存在一个更好的替代方法:Vec::drain 方法。它接受一个范围参数来指定要从 Vec 中移除哪些项,并返回这些项的迭代器。传递 .. 范围语法将移除 Vec 中的每个值。

所以我们需要像这样更新 ThreadPooldrop 实现:

#![allow(unused)]
fn main() {
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
}

这解决了编译器错误,并且不需要对我们的代码进行任何其他更改。

向线程发出信号以停止监听任务

随着我们进行的所有更改,我们的代码在没有任何警告的情况下编译。然而,坏消息是这段代码还没有按照我们想要的方式工作。关键在于 Worker 实例的线程运行的闭包中的逻辑:目前,我们调用 join,但这不会关闭线程,因为它们会永远循环寻找任务。如果我们尝试使用当前的 drop 实现来丢弃 ThreadPool,主线程将永远阻塞,等待第一个线程完成。

要解决这个问题,我们需要在 ThreadPooldrop 实现中进行更改,然后在 Worker 循环中进行更改。

首先,我们将更改 ThreadPooldrop 实现,在等待线程完成之前显式丢弃 sender。Listing 21-23 显示了对 ThreadPool 的更改,以显式丢弃 sender。与线程不同,这里我们确实需要使用 Option 来能够使用 Option::takesenderThreadPool 中移出。

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}
// --snip--

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        // --snip--

        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}

丢弃 sender 会关闭通道,这表明不会再发送消息。当这种情况发生时,Worker 实例在无限循环中执行的所有 recv 调用都将返回一个错误。在 Listing 21-24 中,我们更改了 Worker 循环,以便在这种情况下优雅地退出循环,这意味着当 ThreadPooldrop 实现调用 join 时,线程将完成。

use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let message = receiver.lock().unwrap().recv();

                match message {
                    Ok(job) => {
                        println!("Worker {id} got a job; executing.");

                        job();
                    }
                    Err(_) => {
                        println!("Worker {id} disconnected; shutting down.");
                        break;
                    }
                }
            }
        });

        Worker { id, thread }
    }
}

为了看到这段代码的实际效果,让我们修改 main,使其在优雅关闭服务器之前只接受两个请求,如 Listing 21-25 所示。

use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }

    println!("Shutting down.");
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

你不会希望一个真实世界的 Web 服务器在只服务两个请求后就关闭。这段代码只是演示了优雅关闭和清理功能正常工作。

take 方法定义在 Iterator trait 中,并将迭代限制为最多前两项。ThreadPool 将在 main 结束时超出作用域,并且 drop 实现将运行。

使用 cargo run 启动服务器,并发出三个请求。第三个请求应该会出错,并且在你的终端中你应该会看到类似于以下的输出:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/hello`
Worker 0 got a job; executing.
Shutting down.
Shutting down worker 0
Worker 3 got a job; executing.
Worker 1 disconnected; shutting down.
Worker 2 disconnected; shutting down.
Worker 3 disconnected; shutting down.
Worker 0 disconnected; shutting down.
Shutting down worker 1
Shutting down worker 2
Shutting down worker 3

你可能会看到不同的 Worker ID 和消息打印顺序。我们可以从消息中看到这段代码是如何工作的:Worker 实例 0 和 3 获得了前两个请求。服务器在第二个连接后停止接受连接,并且 ThreadPool 上的 Drop 实现甚至在 Worker 3 开始其工作之前就开始执行。丢弃 sender 会断开所有 Worker 实例的连接并告诉它们关闭。每个 Worker 实例在断开连接时都会打印一条消息,然后线程池调用 join 等待每个 Worker 线程完成。

注意这个特定执行的一个有趣方面:ThreadPool 丢弃了 sender,并且在任何 Worker 收到错误之前,我们尝试 join Worker 0。Worker 0 还没有从 recv 收到错误,所以主线程阻塞等待 Worker 0 完成。与此同时,Worker 3 收到了一个任务,然后所有线程都收到了一个错误。当 Worker 0 完成时,主线程等待其余的 Worker 实例完成。那时,它们都已经退出了循环并停止了。

恭喜!我们现在已经完成了我们的项目;我们有一个使用线程池异步响应的基本 Web 服务器。我们能够执行服务器的优雅关闭,这会清理池中的所有线程。

以下是完整代码供参考:

use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }

    println!("Shutting down.");
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            if let Some(thread) = worker.thread.take() {
                thread.join().unwrap();
            }
        }
    }
}

struct Worker {
    id: usize,
    thread: Option<thread::JoinHandle<()>>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let message = receiver.lock().unwrap().recv();

                match message {
                    Ok(job) => {
                        println!("Worker {id} got a job; executing.");

                        job();
                    }
                    Err(_) => {
                        println!("Worker {id} disconnected; shutting down.");
                        break;
                    }
                }
            }
        });

        Worker {
            id,
            thread: Some(thread),
        }
    }
}

我们还可以做更多的事情!如果你想继续增强这个项目,这里有一些想法:

  • ThreadPool 及其公共方法添加更多文档。
  • 添加库功能的测试。
  • unwrap 的调用更改为更健壮的错误处理。
  • 使用 ThreadPool 执行除服务 Web 请求之外的其他任务。
  • crates.io 上找到一个线程池 crate,并使用该 crate 实现一个类似的 Web 服务器。然后将其 API 和健壮性与我们实现的线程池进行比较。

总结

干得好!你已经完成了本书的学习!我们感谢你加入我们的 Rust 之旅。你现在已经准备好实现自己的 Rust 项目并帮助其他人的项目。请记住,有一个欢迎的 Rustaceans 社区,他们很乐意帮助你在 Rust 旅程中遇到的任何挑战。

附录

以下部分包含了一些在您的 Rust 学习旅程中可能会用到的参考资料。

附录 A: 关键字

以下列表包含了 Rust 语言当前或将来保留的关键字。因此,它们不能用作标识符(除非作为原始标识符,我们将在“原始标识符”部分讨论)。标识符是函数、变量、参数、结构体字段、模块、crate、常量、宏、静态值、属性、类型、特质或生命周期的名称。

当前使用的关键字

以下是当前使用的关键字列表,并描述了它们的功能。

  • as - 执行原始类型转换,消除包含项的特定特质的歧义,或在 use 语句中重命名项
  • async - 返回一个 Future 而不是阻塞当前线程
  • await - 暂停执行直到 Future 的结果准备好
  • break - 立即退出循环
  • const - 定义常量项或常量原始指针
  • continue - 继续到下一个循环迭代
  • crate - 在模块路径中,指代 crate 根目录
  • dyn - 动态分派到特质对象
  • else - ifif let 控制流结构的回退
  • enum - 定义一个枚举
  • extern - 链接外部函数或变量
  • false - 布尔值 false 字面量
  • fn - 定义一个函数或函数指针类型
  • for - 遍历迭代器中的项,实现一个特质,或指定一个更高级的生命周期
  • if - 根据条件表达式的结果进行分支
  • impl - 实现固有或特质功能
  • in - for 循环语法的一部分
  • let - 绑定一个变量
  • loop - 无条件循环
  • match - 将值与模式匹配
  • mod - 定义一个模块
  • move - 使闭包获取其所有捕获的所有权
  • mut - 表示引用、原始指针或模式绑定中的可变性
  • pub - 表示结构体字段、impl 块或模块中的公共可见性
  • ref - 通过引用绑定
  • return - 从函数返回
  • Self - 我们正在定义或实现的类型的别名
  • self - 方法主体或当前模块
  • static - 全局变量或持续整个程序执行的生命周期
  • struct - 定义一个结构体
  • super - 当前模块的父模块
  • trait - 定义一个特质
  • true - 布尔值 true 字面量
  • type - 定义一个类型别名或关联类型
  • union - 定义一个 union;仅在 union 声明中作为关键字使用
  • unsafe - 表示不安全的代码、函数、特质或实现
  • use - 将符号引入作用域;为泛型和生命周期边界指定精确的捕获
  • where - 表示约束类型的子句
  • while - 根据表达式的结果有条件地循环

保留供将来使用的关键字

以下关键字目前没有任何功能,但 Rust 保留它们以供将来可能使用。

  • abstract
  • become
  • box
  • do
  • final
  • gen
  • macro
  • override
  • priv
  • try
  • typeof
  • unsized
  • virtual
  • yield

原始标识符

原始标识符 是一种语法,允许你在通常不允许的地方使用关键字。你可以通过在关键字前加上 r# 来使用原始标识符。

例如,match 是一个关键字。如果你尝试编译以下使用 match 作为名称的函数:

文件名: src/main.rs

fn match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}

你会得到以下错误:

error: expected identifier, found keyword `match`
 --> src/main.rs:4:4
  |
4 | fn match(needle: &str, haystack: &str) -> bool {
  |    ^^^^^ expected identifier, found keyword

错误显示你不能使用关键字 match 作为函数标识符。要使用 match 作为函数名,你需要使用原始标识符语法,如下所示:

文件名: src/main.rs

fn r#match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}

fn main() {
    assert!(r#match("foo", "foobar"));
}

这段代码将无错误地编译。注意函数定义中的 r# 前缀以及在 main 中调用函数时也使用了 r#

原始标识符允许你选择任何单词作为标识符,即使该单词恰好是保留关键字。这为我们提供了更多选择标识符名称的自由,也让我们能够与这些单词不是关键字的语言编写的程序集成。此外,原始标识符允许你使用与你的 crate 使用的 Rust 版本不同的库。例如,try 在 2015 版本中不是关键字,但在 2018、2021 和 2024 版本中是关键字。如果你依赖一个使用 2015 版本编写的库,并且该库有一个 try 函数,你将需要使用原始标识符语法,在这种情况下是 r#try,以便在后续版本中从你的代码中调用该函数。有关版本的更多信息,请参见 附录 E

附录 B: 运算符和符号

本附录包含了 Rust 语法的词汇表,包括运算符和其他符号,这些符号可以单独出现,也可以出现在路径、泛型、trait 约束、宏、属性、注释、元组和括号的上下文中。

运算符

表 B-1 包含了 Rust 中的运算符,展示了运算符在上下文中的使用示例、简要说明以及该运算符是否可重载。如果运算符可重载,还会列出用于重载该运算符的相关 trait。

表 B-1: 运算符

运算符示例说明可重载?
!ident!(...), ident!{...}, ident![...]宏扩展
!!expr按位或逻辑取反Not
!=expr != expr不等比较PartialEq
%expr % expr算术取余Rem
%=var %= expr算术取余并赋值RemAssign
&&expr, &mut expr借用
&&type, &mut type, &'a type, &'a mut type借用指针类型
&expr & expr按位与BitAnd
&=var &= expr按位与并赋值BitAndAssign
&&expr && expr短路逻辑与
*expr * expr算术乘法Mul
*=var *= expr算术乘法并赋值MulAssign
**expr解引用Deref
**const type, *mut type裸指针
+trait + trait, 'a + trait复合类型约束
+expr + expr算术加法Add
+=var += expr算术加法并赋值AddAssign
,expr, expr参数和元素分隔符
-- expr算术取反Neg
-expr - expr算术减法Sub
-=var -= expr算术减法并赋值SubAssign
->fn(...) -> type, |...| -> type函数和闭包返回类型
.expr.ident字段访问
.expr.ident(expr, ...)方法调用
.expr.0, expr.1, etc.元组索引
...., expr.., ..expr, expr..expr右开区间字面量PartialOrd
..=..=expr, expr..=expr右闭区间字面量PartialOrd
....expr结构体字面量更新语法
..variant(x, ..), struct_type { x, .. }“以及其他”模式绑定
...expr...expr(已弃用,使用 ..= 代替) 在模式中:闭区间模式
/expr / expr算术除法Div
/=var /= expr算术除法并赋值DivAssign
:pat: type, ident: type约束
:ident: expr结构体字段初始化
:'a: loop {...}循环标签
;expr;语句和项终止符
;[...; len]固定大小数组语法的一部分
<<expr << expr左移Shl
<<=var <<= expr左移并赋值ShlAssign
<expr < expr小于比较PartialOrd
<=expr <= expr小于或等于比较PartialOrd
=var = expr, ident = type赋值/等价
==expr == expr相等比较PartialEq
=>pat => expr匹配分支语法的一部分
>expr > expr大于比较PartialOrd
>=expr >= expr大于或等于比较PartialOrd
>>expr >> expr右移Shr
>>=var >>= expr右移并赋值ShrAssign
@ident @ pat模式绑定
^expr ^ expr按位异或BitXor
^=var ^= expr按位异或并赋值BitXorAssign
|pat | pat模式替代
|expr | expr按位或BitOr
|=var |= expr按位或并赋值BitOrAssign
||expr || expr短路逻辑或
?expr?错误传播

非运算符符号

以下列表包含了所有不作为运算符使用的符号;也就是说,它们的行为不像函数或方法调用。

表 B-2 展示了单独出现并在多种上下文中有效的符号。

表 B-2: 独立语法

符号说明
'ident命名生命周期或循环标签
...u8, ...i32, ...f64, ...usize, etc.特定类型的数字字面量
"..."字符串字面量
r"...", r#"..."#, r##"..."##, etc.原始字符串字面量,转义字符不被处理
b"..."字节字符串字面量;构造字节数组而不是字符串
br"...", br#"..."#, br##"..."##, etc.原始字节字符串字面量,原始和字节字符串字面量的组合
'...'字符字面量
b'...'ASCII 字节字面量
|...| expr闭包
!总是空的底类型,用于发散函数
_“忽略”模式绑定;也用于使整数字面量更可读

表 B-3 展示了在模块层次结构中路径上下文中出现的符号。

表 B-3: 路径相关语法

符号说明
ident::ident命名空间路径
::path相对于外部预导入的路径,所有其他 crate 的根路径(即显式绝对路径,包括 crate 名称)
self::path相对于当前模块的路径(即显式相对路径)。
super::path相对于当前模块的父模块的路径
type::ident, <type as trait>::ident关联常量、函数和类型
<type>::...无法直接命名的类型的关联项(例如,<&T>::..., <[T]>::..., 等)
trait::method(...)通过命名定义方法的 trait 来消除方法调用的歧义
type::method(...)通过命名定义方法的类型来消除方法调用的歧义
<type as trait>::method(...)通过命名 trait 和类型来消除方法调用的歧义

表 B-4 展示了在使用泛型类型参数的上下文中出现的符号。

表 B-4: 泛型

符号说明
path<...>指定类型中的泛型类型参数(例如,Vec<u8>
path::<...>, method::<...>在表达式中指定泛型类型、函数或方法的参数;通常称为 turbofish(例如,"42".parse::<i32>()
fn ident<...> ...定义泛型函数
struct ident<...> ...定义泛型结构体
enum ident<...> ...定义泛型枚举
impl<...> ...定义泛型实现
for<...> type高阶生命周期约束
type<ident=type>一个泛型类型,其中一个或多个关联类型有特定赋值(例如,Iterator<Item=T>

表 B-5 展示了在使用 trait 约束泛型类型参数的上下文中出现的符号。

表 B-5: Trait 约束

符号说明
T: U泛型参数 T 约束为实现 U 的类型
T: 'a泛型类型 T 必须比生命周期 'a 长(意味着该类型不能包含任何生命周期短于 'a 的引用)
T: 'static泛型类型 T 不包含除 'static 之外的借用引用
'b: 'a泛型生命周期 'b 必须比生命周期 'a
T: ?Sized允许泛型类型参数为动态大小类型
'a + trait, trait + trait复合类型约束

表 B-6 展示了在调用或定义宏以及指定项属性的上下文中出现的符号。

表 B-6: 宏和属性

符号说明
#[meta]外部属性
#![meta]内部属性
$ident宏替换
$ident:kind宏捕获
$(…)…宏重复
ident!(...), ident!{...}, ident![...]宏调用

表 B-7 展示了创建注释的符号。

表 B-7: 注释

符号说明
//行注释
//!内部行文档注释
///外部行文档注释
/*...*/块注释
/*!...*/内部块文档注释
/**...*/外部块文档注释

表 B-8 展示了使用括号的上下文。

表 B-8: 括号

符号说明
()空元组(即单元),既是字面量也是类型
(expr)括号表达式
(expr,)单元素元组表达式
(type,)单元素元组类型
(expr, ...)元组表达式
(type, ...)元组类型
expr(expr, ...)函数调用表达式;也用于初始化元组 struct 和元组 enum 变体

表 B-9 展示了使用花括号的上下文。

表 B-9: 花括号

上下文说明
{...}块表达式
Type {...}struct 字面量

表 B-10 展示了使用方括号的上下文。

表 B-10: 方括号

上下文说明
[...]数组字面量
[expr; len]包含 lenexpr 的数组字面量
[type; len]包含 lentype 实例的数组类型
expr[expr]集合索引。可重载(Index, IndexMut
expr[..], expr[a..], expr[..b], expr[a..b]集合索引假装为集合切片,使用 Range, RangeFrom, RangeTo, 或 RangeFull 作为“索引”

附录 C: 可派生的 Traits

在本书的多个地方,我们讨论了 derive 属性,你可以将其应用于结构体或枚举定义。derive 属性会生成代码,这些代码将在你使用 derive 语法注释的类型上实现一个具有默认实现的 trait。

在本附录中,我们提供了标准库中所有可以与 derive 一起使用的 trait 的参考。每个部分涵盖以下内容:

  • 派生该 trait 将启用的操作符和方法
  • derive 提供的 trait 实现的功能
  • 实现该 trait 对类型的意义
  • 允许或不允许实现该 trait 的条件
  • 需要该 trait 的操作示例

如果你希望从 derive 属性提供的功能中获得不同的行为,请查阅标准库文档以获取有关如何手动实现它们的详细信息。

这里列出的 trait 是标准库中定义的唯一可以通过 derive 在你的类型上实现的 trait。标准库中定义的其他 trait 没有合理的默认行为,因此你需要根据你试图实现的目标来手动实现它们。

一个不能派生的 trait 示例是 Display,它处理最终用户的格式化。你应该始终考虑向最终用户显示类型的适当方式。最终用户应该被允许看到类型的哪些部分?他们会发现哪些部分相关?数据的哪种格式对他们最相关?Rust 编译器没有这种洞察力,因此无法为你提供适当的默认行为。

本附录中提供的可派生 trait 列表并不全面:库可以为它们自己的 trait 实现 derive,使得你可以使用 derive 的 trait 列表真正开放。实现 derive 涉及使用过程宏,这在第 20 章的“宏”部分中有详细介绍。

Debug 用于程序员输出

Debug trait 允许在格式化字符串中进行调试格式化,你可以在 {} 占位符中添加 :? 来指示。

Debug trait 允许你打印类型的实例以进行调试,因此你和其他使用你的类型的程序员可以在程序执行的特定点检查实例。

例如,Debug trait 在使用 assert_eq! 宏时是必需的。如果相等断言失败,此宏会打印作为参数给出的实例的值,以便程序员可以看到为什么这两个实例不相等。

PartialEqEq 用于相等比较

PartialEq trait 允许你比较类型的实例以检查相等性,并启用 ==!= 操作符。

派生 PartialEq 会实现 eq 方法。当在结构体上派生 PartialEq 时,两个实例只有在所有字段都相等时才相等,如果任何字段不相等,则实例不相等。当在枚举上派生时,每个变体等于自身,而不等于其他变体。

例如,PartialEq trait 在使用 assert_eq! 宏时是必需的,该宏需要能够比较两个类型的实例是否相等。

Eq trait 没有方法。它的目的是表明对于注释类型的每个值,该值等于自身。Eq trait 只能应用于也实现了 PartialEq 的类型,尽管并非所有实现了 PartialEq 的类型都可以实现 Eq。一个例子是浮点数类型:浮点数的实现规定,两个非数字(NaN)值的实例彼此不相等。

一个需要 Eq 的例子是在 HashMap<K, V> 中存储键,以便 HashMap<K, V> 可以判断两个键是否相同。

PartialOrdOrd 用于排序比较

PartialOrd trait 允许你比较类型的实例以进行排序。实现了 PartialOrd 的类型可以与 <><=>= 操作符一起使用。你只能将 PartialOrd trait 应用于也实现了 PartialEq 的类型。

派生 PartialOrd 会实现 partial_cmp 方法,该方法返回一个 Option<Ordering>,当给定的值无法产生排序时,它将返回 None。一个无法产生排序的值的例子是浮点数的非数字(NaN)值。即使该类型的大多数值可以比较,使用任何浮点数与 NaN 浮点值调用 partial_cmp 都会返回 None

当在结构体上派生时,PartialOrd 通过按结构体定义中字段出现的顺序比较每个字段的值来比较两个实例。当在枚举上派生时,枚举定义中较早声明的变体被认为小于后面列出的变体。

例如,PartialOrd trait 在使用 rand crate 中的 gen_range 方法时是必需的,该方法生成由范围表达式指定的范围内的随机值。

Ord trait 允许你知道对于注释类型的任何两个值,将存在一个有效的排序。Ord trait 实现了 cmp 方法,该方法返回一个 Ordering 而不是 Option<Ordering>,因为总是会存在一个有效的排序。你只能将 Ord trait 应用于也实现了 PartialOrdEq 的类型(而 Eq 需要 PartialEq)。当在结构体和枚举上派生时,cmp 的行为与 PartialOrd 的派生实现中的 partial_cmp 相同。

一个需要 Ord 的例子是在 BTreeSet<T> 中存储值,这是一个根据值的排序顺序存储数据的数据结构。

CloneCopy 用于复制值

Clone trait 允许你显式创建一个值的深拷贝,复制过程可能涉及运行任意代码和复制堆数据。有关 Clone 的更多信息,请参见第 4 章中的“变量和数据的交互:Clone”

派生 Clone 会实现 clone 方法,当为整个类型实现时,它会调用类型的每个部分的 clone 方法。这意味着类型中的所有字段或值也必须实现 Clone 才能派生 Clone

一个需要 Clone 的例子是在切片上调用 to_vec 方法。切片不拥有它包含的类型实例,但从 to_vec 返回的向量需要拥有其实例,因此 to_vec 会对每个项目调用 clone。因此,切片中存储的类型必须实现 Clone

Copy trait 允许你通过仅复制存储在栈上的位来复制值;不需要运行任意代码。有关 Copy 的更多信息,请参见第 4 章中的“仅栈数据:Copy”

Copy trait 没有定义任何方法,以防止程序员重载这些方法并违反不运行任意代码的假设。这样,所有程序员都可以假设复制一个值会非常快。

你可以在任何其所有部分都实现了 Copy 的类型上派生 Copy。实现了 Copy 的类型也必须实现 Clone,因为实现了 Copy 的类型有一个简单的 Clone 实现,执行与 Copy 相同的任务。

Copy trait 很少需要;实现了 Copy 的类型有可用的优化,这意味着你不必调用 clone,从而使代码更简洁。

使用 Copy 可以完成的所有操作,你也可以使用 Clone 完成,但代码可能会更慢或必须在某些地方使用 clone

Hash 用于将值映射到固定大小的值

Hash trait 允许你获取一个任意大小的类型的实例,并使用哈希函数将该实例映射到一个固定大小的值。派生 Hash 会实现 hash 方法。hash 方法的派生实现会结合调用类型每个部分的 hash 方法的结果,这意味着所有字段或值也必须实现 Hash 才能派生 Hash

一个需要 Hash 的例子是在 HashMap<K, V> 中存储键以高效地存储数据。

Default 用于默认值

Default trait 允许你为类型创建默认值。派生 Default 会实现 default 函数。default 函数的派生实现会调用类型每个部分的 default 函数,这意味着类型中的所有字段或值也必须实现 Default 才能派生 Default

Default::default 函数通常与第 5 章中讨论的结构体更新语法结合使用。你可以自定义结构体的几个字段,然后使用 ..Default::default() 为其余字段设置并使用默认值。

例如,当你在 Option<T> 实例上使用 unwrap_or_default 方法时,Default trait 是必需的。如果 Option<T>Noneunwrap_or_default 方法将返回存储在 Option<T> 中的类型 TDefault::default 结果。

附录 D - 有用的开发工具

在本附录中,我们将讨论 Rust 项目提供的一些有用的开发工具。我们将介绍自动格式化、快速应用警告修复的方法、代码检查工具(linter)以及与 IDE 的集成。

使用 rustfmt 进行自动格式化

rustfmt 工具会根据社区代码风格重新格式化你的代码。许多协作项目使用 rustfmt 来避免在编写 Rust 代码时关于使用哪种风格的争论:每个人都使用该工具来格式化他们的代码。

Rust 安装默认包含 rustfmt,因此你的系统上应该已经安装了 rustfmtcargo-fmt 这两个程序。这两个命令类似于 rustccargo,其中 rustfmt 允许更细粒度的控制,而 cargo-fmt 理解使用 Cargo 的项目的约定。要格式化任何 Cargo 项目,请输入以下命令:

$ cargo fmt

运行此命令会重新格式化当前 crate 中的所有 Rust 代码。这只会改变代码风格,而不会改变代码的语义。

这个命令为你提供了 rustfmtcargo-fmt,类似于 Rust 为你提供了 rustccargo。要格式化任何 Cargo 项目,请输入以下命令:

$ cargo fmt

运行此命令会重新格式化当前 crate 中的所有 Rust 代码。这只会改变代码风格,而不会改变代码的语义。有关 rustfmt 的更多信息,请参阅其文档

使用 rustfix 修复代码

rustfix 工具包含在 Rust 安装中,可以自动修复那些有明确修复方法的编译器警告,这些修复方法很可能是你想要的。你可能之前已经见过编译器警告。例如,考虑以下代码:

文件名: src/main.rs

fn main() {
    let mut x = 42;
    println!("{x}");
}

在这里,我们将变量 x 定义为可变的,但实际上我们从未改变它。Rust 对此发出警告:

$ cargo build
   Compiling myprogram v0.1.0 (file:///projects/myprogram)
warning: variable does not need to be mutable
 --> src/main.rs:2:9
  |
2 |     let mut x = 0;
  |         ----^
  |         |
  |         help: remove this `mut`
  |
  = note: `#[warn(unused_mut)]` on by default

警告建议我们移除 mut 关键字。我们可以通过运行 cargo fix 命令自动应用该建议:

$ cargo fix
    Checking myprogram v0.1.0 (file:///projects/myprogram)
      Fixing src/main.rs (1 fix)
    Finished dev [unoptimized + debuginfo] target(s) in 0.59s

当我们再次查看 src/main.rs 时,我们会发现 cargo fix 已经更改了代码:

文件名: src/main.rs

fn main() {
    let x = 42;
    println!("{x}");
}

现在 x 变量是不可变的,警告也不再出现。

你还可以使用 cargo fix 命令在不同 Rust 版本之间迁移代码。版本迁移在附录 E 中有详细介绍。

使用 Clippy 进行更多代码检查

Clippy 工具是一个代码检查工具集合,用于分析你的代码,以便你可以捕获常见错误并改进你的 Rust 代码。Clippy 包含在标准的 Rust 安装中。

要在任何 Cargo 项目上运行 Clippy 的代码检查,请输入以下命令:

$ cargo clippy

例如,假设你编写了一个使用数学常数(如 pi)近似值的程序,如下所示:

fn main() {
    let x = 3.1415;
    let r = 8.0;
    println!("the area of the circle is {}", x * r * r);
}

在这个项目上运行 cargo clippy 会导致以下错误:

error: approximate value of `f{32, 64}::consts::PI` found
 --> src/main.rs:2:13
  |
2 |     let x = 3.1415;
  |             ^^^^^^
  |
  = note: `#[deny(clippy::approx_constant)]` on by default
  = help: consider using the constant directly
  = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#approx_constant

这个错误告诉你 Rust 已经定义了一个更精确的 PI 常量,如果你的程序使用这个常量会更正确。然后你会将代码更改为使用 PI 常量。以下代码不会导致 Clippy 产生任何错误或警告:

fn main() {
    let x = std::f64::consts::PI;
    let r = 8.0;
    println!("the area of the circle is {}", x * r * r);
}

有关 Clippy 的更多信息,请参阅其文档

使用 rust-analyzer 进行 IDE 集成

为了帮助 IDE 集成,Rust 社区推荐使用 rust-analyzer。这个工具是一组以编译器为中心的实用程序,支持 Language Server Protocol,这是一个用于 IDE 和编程语言之间通信的规范。不同的客户端可以使用 rust-analyzer,例如 Visual Studio Code 的 Rust 分析器插件

访问 rust-analyzer 项目的主页 获取安装说明,然后在你的特定 IDE 中安装语言服务器支持。你的 IDE 将获得自动补全、跳转到定义和内联错误等功能。

附录 E - 版本

在第一章中,你看到 cargo new 向你的 Cargo.toml 文件中添加了一些关于版本的元数据。本附录将讨论这意味着什么!

Rust 语言和编译器有一个六周的发布周期,这意味着用户可以不断获得新功能。其他编程语言发布较大的更改频率较低;Rust 则更频繁地发布较小的更新。经过一段时间后,所有这些微小的变化会累积起来。但从一个版本到另一个版本,可能很难回顾并说:“哇,从 Rust 1.10 到 Rust 1.31,Rust 变化真大!”

大约每三年,Rust 团队会发布一个新的 Rust 版本。每个版本将已实现的功能整合到一个清晰的包中,并附带完全更新的文档和工具。新版本作为常规六周发布周期的一部分发布。

版本对不同的人有不同的目的:

  • 对于活跃的 Rust 用户,新版本将增量变化整合到一个易于理解的包中。
  • 对于非用户,新版本标志着一些重大进展的实现,这可能使 Rust 值得再次关注。
  • 对于开发 Rust 的人来说,新版本为整个项目提供了一个集结号。

在撰写本文时,有四个 Rust 版本可用:Rust 2015、Rust 2018、Rust 2021 和 Rust 2024。本书使用 Rust 2024 版本的惯用法编写。

Cargo.toml 中的 edition 键指示编译器应该为你的代码使用哪个版本。如果该键不存在,Rust 会出于向后兼容的原因使用 2015 作为版本值。

每个项目可以选择使用默认的 2015 版本以外的版本。版本可能包含不兼容的更改,例如包含一个与代码中的标识符冲突的新关键字。然而,除非你选择加入这些更改,否则即使你升级了使用的 Rust 编译器版本,你的代码仍将继续编译。

所有 Rust 编译器版本都支持在该编译器发布之前存在的任何版本,并且它们可以将任何支持的版本的 crate 链接在一起。版本更改仅影响编译器最初解析代码的方式。因此,如果你使用的是 Rust 2015,而你的一个依赖项使用的是 Rust 2018,你的项目将能够编译并使用该依赖项。反之亦然,如果你的项目使用 Rust 2018,而依赖项使用 Rust 2015,同样可以正常工作。

需要明确的是:大多数功能在所有版本中都可用。使用任何 Rust 版本的开发者将继续看到新稳定版本发布时的改进。然而,在某些情况下,主要是当添加新关键字时,一些新功能可能仅在较新的版本中可用。如果你想利用这些功能,你需要切换版本。

有关更多详细信息,版本指南 是一本完整的关于版本的书,它列举了版本之间的差异,并解释了如何通过 cargo fix 自动将你的代码升级到新版本。

附录 F: 本书的翻译

对于非英语的资源。大多数仍在进行中;请参阅 翻译标签 以帮助或告知我们新的翻译!

附录 G - Rust 的开发方式与 “Nightly Rust”

本附录将介绍 Rust 的开发方式以及这对你作为 Rust 开发者的影响。

稳定而不停滞

作为一门编程语言,Rust 非常重视代码的稳定性。我们希望 Rust 成为你可以依赖的坚实基础,如果事物不断变化,那将是不可能的。同时,如果我们不能尝试新功能,我们可能直到发布后才发现重要的缺陷,那时我们已经无法再做出改变。

我们解决这个问题的方法称为“稳定而不停滞”,我们的指导原则是:你永远不必担心升级到新的稳定版 Rust。每次升级都应该是无痛的,同时还应该带来新功能、更少的错误和更快的编译时间。

火车模型:发布渠道与搭乘火车

Rust 的开发遵循“火车时刻表”模型。也就是说,所有的开发工作都在 Rust 代码库的 master 分支上进行。发布遵循软件发布火车模型,这种模型曾被 Cisco IOS 和其他软件项目使用。Rust 有三个发布渠道:

  • Nightly(每日构建版)
  • Beta(测试版)
  • Stable(稳定版)

大多数 Rust 开发者主要使用稳定版,但那些想要尝试实验性新功能的人可能会使用每日构建版或测试版。

以下是一个开发和发布过程的示例:假设 Rust 团队正在开发 Rust 1.5 的发布。该版本于 2015 年 12 月发布,但我们将使用这个版本来提供现实的版本号。一个新功能被添加到 Rust 中:一个新的提交被合并到 master 分支。每天晚上,都会生成一个新的每日构建版 Rust。每天都是发布日,这些发布由我们的发布基础设施自动创建。因此,随着时间的推移,我们的发布看起来像这样,每晚一次:

nightly: * - - * - - *

每六周,就是准备新发布的时候了!Rust 代码库的 beta 分支从每日构建版使用的 master 分支中分离出来。现在,有两个发布:

nightly: * - - * - - *
                     |
beta:                *

大多数 Rust 用户不会主动使用测试版,但会在他们的 CI 系统中针对测试版进行测试,以帮助 Rust 发现可能的回归问题。与此同时,每晚仍然会有一个每日构建版发布:

nightly: * - - * - - * - - * - - *
                     |
beta:                *

假设发现了一个回归问题。幸好我们在回归问题潜入稳定版之前有时间测试测试版!修复被应用到 master 分支,因此每日构建版被修复,然后修复被反向移植到 beta 分支,并生成一个新的测试版发布:

nightly: * - - * - - * - - * - - * - - *
                     |
beta:                * - - - - - - - - *

在第一个测试版创建六周后,就是稳定版发布的时候了!stable 分支从 beta 分支中分离出来:

nightly: * - - * - - * - - * - - * - - * - * - *
                     |
beta:                * - - - - - - - - *
                                       |
stable:                                *

万岁!Rust 1.5 完成了!然而,我们忘记了一件事:因为六周已经过去,我们还需要一个 下一个 版本 Rust 1.6 的新测试版。因此,在 stablebeta 分离出来后,下一个版本的 beta 再次从 nightly 分离出来:

nightly: * - - * - - * - - * - - * - - * - * - *
                     |                         |
beta:                * - - - - - - - - *       *
                                       |
stable:                                *

这被称为“火车模型”,因为每六周,一个发布“离开车站”,但在成为稳定版之前,它仍然需要经过测试版的旅程。

Rust 每六周发布一次,像时钟一样准确。如果你知道一个 Rust 版本的发布日期,你就可以知道下一个版本的发布日期:六周后。每六周发布一次的一个好处是,下一班火车很快就会到来。如果某个功能恰好错过了某个特定的发布,不用担心:另一个发布很快就会到来!这有助于减少在接近发布截止日期时偷偷加入可能未完善功能的压力。

得益于这个过程,你总是可以查看下一个 Rust 版本,并亲自验证升级是否容易:如果测试版发布没有按预期工作,你可以向团队报告,并在下一个稳定版发布之前修复它!测试版发布中的问题相对较少,但 rustc 仍然是一个软件,错误确实存在。

维护时间

Rust 项目支持最新的稳定版本。当一个新的稳定版本发布时,旧版本将达到其生命周期结束(EOL)。这意味着每个版本支持六周。

不稳定功能

这种发布模型还有一个问题:不稳定功能。Rust 使用一种称为“功能标志”的技术来确定在给定版本中启用了哪些功能。如果一个新功能正在积极开发中,它会被合并到 master 分支,因此也会出现在每日构建版中,但位于一个 功能标志 后面。如果你作为用户希望尝试这个正在开发中的功能,你可以这样做,但你必须使用 Rust 的每日构建版,并在源代码中使用适当的标志来选择加入。

如果你使用的是 Rust 的测试版或稳定版,你不能使用任何功能标志。这是我们在宣布功能稳定之前获得实际使用经验的关键。那些希望尝试最新功能的人可以这样做,而那些希望获得稳定体验的人可以坚持使用稳定版,并知道他们的代码不会崩溃。稳定而不停滞。

本书只包含关于稳定功能的信息,因为正在开发中的功能仍在变化,而且它们在这本书编写时和它们在稳定版中启用时肯定会有所不同。你可以在网上找到仅适用于每日构建版功能的文档。

Rustup 和 Rust Nightly 的作用

Rustup 使得在不同 Rust 发布渠道之间切换变得容易,无论是在全局还是每个项目的基础上。默认情况下,你将安装稳定版 Rust。例如,要安装每日构建版:

$ rustup toolchain install nightly

你还可以查看你安装的所有 工具链(Rust 的发布版本及其相关组件)。以下是你的一位作者在 Windows 电脑上的示例:

> rustup toolchain list
stable-x86_64-pc-windows-msvc (default)
beta-x86_64-pc-windows-msvc
nightly-x86_64-pc-windows-msvc

如你所见,稳定工具链是默认的。大多数 Rust 用户大多数时间都使用稳定版。你可能希望大多数时间使用稳定版,但在特定项目上使用每日构建版,因为你关心某个前沿功能。为此,你可以在该项目的目录中使用 rustup override 来设置每日构建版工具链,以便 rustup 在该目录中使用:

$ cd ~/projects/needs-nightly
$ rustup override set nightly

现在,每次你在 ~/projects/needs-nightly 目录中调用 rustccargo 时,rustup 将确保你使用的是每日构建版 Rust,而不是默认的稳定版 Rust。当你有很多 Rust 项目时,这非常方便!

RFC 流程与团队

那么你如何了解这些新功能呢?Rust 的开发模型遵循 请求评论(RFC)流程。如果你想改进 Rust,你可以写一份提案,称为 RFC。

任何人都可以写 RFC 来改进 Rust,提案由 Rust 团队审查和讨论,该团队由许多主题子团队组成。你可以在 Rust 的网站 上找到团队的完整列表,其中包括项目的每个领域的团队:语言设计、编译器实现、基础设施、文档等。适当的团队会阅读提案和评论,写下他们自己的评论,最终达成共识,决定是否接受或拒绝该功能。

如果功能被接受,Rust 代码库上会打开一个问题,然后有人可以实现它。实现它的人很可能不是最初提出该功能的人!当实现准备好时,它会合并到 master 分支,并位于一个功能标志后面,正如我们在 “不稳定功能” 部分讨论的那样。

经过一段时间,一旦使用每日构建版的 Rust 开发者能够尝试新功能,团队成员将讨论该功能,它在每日构建版中的表现如何,并决定是否应该将其纳入稳定版 Rust。如果决定继续推进,功能标志将被移除,该功能现在被认为是稳定的!它将搭乘火车进入新的稳定版 Rust 发布。