Rust 程序设计语言
由 Steve Klabnik、Carol Nichols 和 Chris Krycho 编写,Rust 社区参与贡献
本书假定你使用的是 Rust 1.90.0(发布于 2025-09-18)或更高版本,并在所有项目的 Cargo.toml 文件中设置了 edition = "2024" 以使用 Rust 2024 版本的惯用写法。请参阅第一章的“安装“部分了解安装或更新 Rust 的说明,参阅附录 E 了解版本相关信息。
本书的 HTML 版本可在线阅读:https://doc.rust-lang.org/stable/book/,也可通过 rustup 安装的 Rust 离线阅读;运行 rustup doc --book 即可打开。
社区还提供了多个翻译版本。
本书也有 No Starch Press 出版的纸质版和电子书。
🚨 想要更具互动性的学习体验?试试 Rust Book 的另一个版本,包含测验、高亮、可视化等更多功能:https://rust-book.cs.brown.edu
前言
Rust 编程语言在短短几年间走过了漫长的道路——从一个由少数爱好者组成的小型新兴社区孕育而生,到成为世界上最受喜爱、需求最旺盛的编程语言之一。回顾过去,Rust 凭借其强大的能力和巨大的潜力在系统编程领域崭露头角,这是必然的趋势。而真正出乎意料的,是全球范围内对 Rust 的兴趣和创新热情如此迅猛地蔓延——它渗透到各个开源社区,并推动了跨行业的大规模采用。
时至今日,要解释这股兴趣和采用的爆发式增长,只需看看 Rust 提供的那些出色特性就够了。谁不想同时拥有内存安全、以及高性能、以及友好的编译器、以及优秀的工具链,还有众多其他出色的特性呢?今天你所看到的 Rust 语言,融合了多年的系统编程研究成果与一个充满活力和热情的社区的实践智慧。这门语言的设计有着明确的目标,打磨精心而细致,为开发者提供了一个能更轻松地编写安全、高效、可靠代码的工具。
然而,真正让 Rust 与众不同的,是它致力于赋能你——用户——去实现你的目标。这是一门希望你成功的语言,而赋能这一理念贯穿于构建、维护和推广这门语言的社区核心之中。自本书上一版问世以来,Rust 已进一步发展为一门真正全球化且备受信赖的语言。Rust 项目现在得到了 Rust 基金会的有力支持,基金会还投资于关键举措,以确保 Rust 的安全性、稳定性和可持续性。
本版《Rust 程序设计语言》是一次全面的更新,反映了这门语言多年来的演进,并提供了宝贵的新内容。但它不仅仅是一本关于语法和库的指南——它更是一份邀请,邀请你加入一个重视质量、性能和精心设计的社区。无论你是初次探索 Rust 的资深开发者,还是希望精进技能的 Rust 老手,本版都能为你提供有价值的内容。
Rust 的旅程是一段关于协作、学习和迭代的历程。这门语言及其生态系统的成长,直接反映了其背后那个充满活力、多元化的社区。从核心语言设计者到普通贡献者,数以千计的开发者的贡献,使 Rust 成为了如此独特而强大的工具。翻开这本书,你不仅仅是在学习一门新的编程语言——你正在加入一场让软件变得更好、更安全、更令人愉悦的运动。
欢迎来到 Rust 社区!
- Bec Rumbul,Rust 基金会执行董事
引言
注意:本书的这一版与 No Starch Press 出版的纸质版和电子版 The Rust Programming Language 内容一致。
欢迎阅读《Rust 程序设计语言》,这是一本 Rust 入门书籍。Rust 编程语言帮助你编写更快、更可靠的软件。在编程语言设计中,高层的易用性和底层的控制力往往难以兼得,而 Rust 正是要挑战这一矛盾。通过平衡强大的技术能力与出色的开发体验,Rust 让你能够控制底层细节(例如内存使用),同时免去传统上与这类控制相伴的种种麻烦。
谁适合使用 Rust
Rust 适合许多人,原因各不相同。下面我们来看几个最重要的群体。
开发团队
Rust 已被证明是大型开发团队协作的高效工具,团队成员的系统编程经验水平各异也不成问题。底层代码容易出现各种隐蔽的 bug,在大多数其他语言中,只能通过大量测试和经验丰富的开发者仔细审查代码来发现这些问题。在 Rust 中,编译器扮演了守门人的角色,它会拒绝编译包含这类隐蔽 bug(包括并发 bug)的代码。借助编译器的协助,团队可以将时间集中在程序逻辑上,而不是疲于追踪 bug。
Rust 还为系统编程领域带来了现代化的开发工具:
- Cargo 是内置的依赖管理器和构建工具,它让添加、编译和管理依赖变得轻松且一致,贯穿整个 Rust 生态系统。
rustfmt格式化工具确保开发者之间保持一致的代码风格。- Rust Language Server 为集成开发环境(IDE)提供了代码补全和内联错误提示等功能。
通过使用 Rust 生态系统中的这些工具及其他工具,开发者在编写系统级代码时也能保持高效。
学生
Rust 适合学生以及对系统概念感兴趣的学习者。许多人通过 Rust 学习了操作系统开发等主题。Rust 社区非常热情友好,乐于解答学生的问题。通过本书等努力,Rust 团队希望让更多人——尤其是编程新手——能够接触到系统概念。
企业
数百家大大小小的企业在生产环境中使用 Rust 完成各种任务,包括命令行工具、Web 服务、DevOps 工具、嵌入式设备、音视频分析与转码、加密货币、生物信息学、搜索引擎、物联网应用、机器学习,甚至 Firefox 浏览器的核心部分。
开源开发者
Rust 欢迎那些希望参与构建 Rust 编程语言、社区、开发工具和库的人。我们非常期待你为 Rust 语言做出贡献。
重视速度与稳定性的人
Rust 适合追求语言速度和稳定性的人。这里的速度,既指 Rust 代码的运行速度,也指 Rust 让你编写程序的效率。Rust 编译器的检查机制通过功能添加和重构来确保稳定性。这与那些缺乏此类检查的语言中脆弱的遗留代码形成了鲜明对比——开发者往往不敢修改那些代码。通过追求零成本抽象(zero-cost abstractions)——将高层特性编译为与手写代码一样快的底层代码——Rust 致力于让安全的代码同时也是高效的代码。
Rust 语言也希望能支持更多其他类型的用户,这里提到的只是其中一些最重要的群体。总的来说,Rust 最大的愿景是消除程序员数十年来不得不接受的那些取舍,同时提供安全与生产力、速度与易用性。试试 Rust,看看它的设计选择是否适合你。
本书适合谁
本书假设你已经用其他编程语言写过代码,但不限定是哪一种语言。我们尽力让内容对来自各种编程背景的读者都易于理解。我们不会花大量篇幅讨论编程是什么或如何思考编程。如果你完全没有编程经验,建议先阅读一本专门的编程入门书籍。
如何使用本书
总体而言,本书假设你按照从前到后的顺序阅读。后面的章节建立在前面章节的概念之上,前面的章节可能不会深入某个主题的细节,但会在后续章节中重新讨论。
本书包含两类章节:概念章节和项目章节。在概念章节中,你将学习 Rust 的某个方面。在项目章节中,我们将一起构建小程序,应用你已经学到的知识。第 2 章、第 12 章和第 21 章是项目章节,其余都是概念章节。
第 1 章介绍如何安装 Rust、如何编写一个“Hello, world!“程序,以及如何使用 Rust 的包管理器和构建工具 Cargo。第 2 章是 Rust 编程的实战入门,你将构建一个猜数字游戏。我们会在较高层面介绍一些概念,后续章节会提供更多细节。如果你想立刻动手实践,第 2 章正是为此而设。如果你是一个特别严谨的学习者,喜欢在继续之前掌握每个细节,那么你可以跳过第 2 章,直接进入第 3 章——它涵盖了与其他编程语言类似的 Rust 特性;之后你可以回到第 2 章,用学到的知识来完成项目。
在第 4 章中,你将学习 Rust 的所有权(ownership)系统。第 5 章讨论结构体和方法。第 6 章涵盖枚举、match 表达式,以及 if let 和 let...else 控制流结构。你将使用结构体和枚举来创建自定义类型。
在第 7 章中,你将学习 Rust 的模块系统以及用于组织代码和公共应用程序编程接口(API)的私有性规则。第 8 章讨论标准库提供的一些常用集合数据结构:vector、字符串和哈希 map。第 9 章探讨 Rust 的错误处理理念和技术。
第 10 章深入泛型(generics)、trait 和生命周期(lifetimes),它们赋予你定义适用于多种类型的代码的能力。第 11 章全面介绍测试,即使有 Rust 的安全保障,测试仍然是确保程序逻辑正确的必要手段。在第 12 章中,我们将自己实现 grep 命令行工具的部分功能——在文件中搜索文本。为此,我们将运用前面章节讨论过的许多概念。
第 13 章探讨闭包(closures)和迭代器(iterators):这些是 Rust 中来自函数式编程语言的特性。在第 14 章中,我们将更深入地了解 Cargo,并讨论与他人共享库的最佳实践。第 15 章讨论标准库提供的智能指针(smart pointers)以及实现其功能的 trait。
在第 16 章中,我们将介绍不同的并发编程模型,并讨论 Rust 如何帮助你无畏地进行多线程编程。在第 17 章中,我们在此基础上探索 Rust 的 async 和 await 语法,以及任务、future 和流(streams),还有它们所支持的轻量级并发模型。
第 18 章探讨 Rust 的惯用写法与你可能熟悉的面向对象编程原则之间的对比。第 19 章是关于模式和模式匹配的参考,它们是在 Rust 程序中表达思想的强大方式。第 20 章包含一系列高级主题,涵盖不安全 Rust(unsafe Rust)、宏(macros),以及更多关于生命周期、trait、类型、函数和闭包的内容。
在第 21 章中,我们将完成一个项目——实现一个底层的多线程 Web 服务器!
最后,一些附录以类似参考手册的格式包含了关于该语言的实用信息。附录 A 涵盖 Rust 的关键字,附录 B 涵盖 Rust 的运算符和符号,附录 C 涵盖标准库提供的可派生 trait,附录 D 涵盖一些实用的开发工具,附录 E 解释 Rust 的版本(editions)。在附录 F 中,你可以找到本书的翻译版本,在附录 G 中我们将介绍 Rust 是如何开发的以及什么是 nightly Rust。
阅读本书没有固定的方式:如果你想跳着读,尽管去做!如果遇到困惑,你可能需要跳回前面的章节。但怎样适合你就怎样来。
学习 Rust 过程中很重要的一点是学会阅读编译器显示的错误信息:它们会引导你写出正确的代码。因此,我们会提供许多无法编译的示例,以及编译器在各种情况下会显示的错误信息。请注意,如果你随意输入并运行某个示例,它可能无法编译!请务必阅读周围的文字,看看你尝试运行的示例是否本来就会报错。在大多数情况下,我们会引导你找到无法编译的代码的正确版本。Ferris 也会帮助你区分那些本来就不能正常工作的代码:
| Ferris | 含义 |
|---|---|
| 这段代码无法编译! | |
| 这段代码会 panic! | |
| 这段代码不会产生预期的行为。 |
在大多数情况下,我们会引导你找到无法编译的代码的正确版本。
源代码
生成本书的源文件可以在 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!
你还需要一个链接器(linker),它是 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 仍然无法正常工作,有很多地方可以获取帮助。你可以在社区页面上了解如何与其他 Rustacean(我们对自己的昵称)取得联系。
更新与卸载
通过 rustup 安装 Rust 后,更新到最新发布的版本非常简单。在 shell 中运行以下更新命令:
$ rustup update
要卸载 Rust 和 rustup,在 shell 中运行以下卸载命令:
$ rustup self uninstall
阅读本地文档
安装 Rust 时还会附带一份本地文档副本,方便你离线阅读。运行 rustup doc 即可在浏览器中打开本地文档。
当标准库提供了某个类型或函数,而你不确定它的用途或用法时,可以查阅应用程序编程接口(API)文档来了解!
使用文本编辑器和 IDE
本书不对你使用什么工具来编写 Rust 代码做任何假设。几乎任何文本编辑器都能胜任!不过,许多文本编辑器和集成开发环境(IDE)都内置了对 Rust 的支持。你可以在 Rust 官网的工具页面上找到一份相当完整的编辑器和 IDE 列表。
离线使用本书
在一些示例中,我们会用到标准库之外的 Rust 包。要完成这些示例,你需要联网,或者提前下载好这些依赖。要提前下载依赖,可以运行以下命令。(我们会在后面详细解释 cargo 是什么以及每条命令的作用。)
$ cargo new get-dependencies
$ cd get-dependencies
$ cargo add rand@0.8.5 trpl@0.2.0
这会缓存这些包的下载内容,这样你之后就不需要再次下载了。运行完这条命令后,你不需要保留 get-dependencies 文件夹。如果你已经运行了这条命令,就可以在本书后续的所有 cargo 命令中使用 --offline 标志,以使用这些缓存版本而不是尝试联网下载。
Hello, World!
Hello, World!
既然已经安装好了 Rust,是时候编写你的第一个 Rust 程序了。学习一门新语言时,编写一个在屏幕上打印 Hello, world! 的小程序是一项传统,我们也不例外!
注意:本书假设你对命令行有基本的了解。Rust 对你使用什么编辑器、工具或代码存放位置没有特殊要求,所以如果你更喜欢使用 IDE 而非命令行,请随意使用你喜欢的 IDE。目前许多 IDE 都已提供了一定程度的 Rust 支持,详情请查阅相应 IDE 的文档。Rust 团队一直致力于通过
rust-analyzer提供出色的 IDE 支持。更多细节请参阅附录 D。
创建项目目录
首先,创建一个目录来存放你的 Rust 代码。Rust 并不关心你的代码存放在哪里,但对于本书中的练习和项目,我们建议在你的主目录下创建一个 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!");
}
Hello, world! 的程序保存文件,回到终端窗口,确保在 ~/projects/hello_world 目录下。在 Linux 或 macOS 上,输入以下命令来编译并运行文件:
$ rustc main.rs
$ ./main
Hello, world!
在 Windows 上,输入 .\main 而不是 ./main:
> rustc main.rs
> .\main
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 宏(macro)。如果调用的是函数,则应写成 println(不带 !)。Rust 宏是一种编写能生成代码的代码的方式,用于扩展 Rust 语法,我们将在第 20 章中详细讨论。目前你只需要知道,使用 ! 意味着你调用的是宏而不是普通函数,并且宏不一定遵循与函数相同的规则。
第二,你看到了 "Hello, world!" 字符串。我们将这个字符串作为参数传递给 println!,然后字符串就被打印到了屏幕上。
第三,我们用分号(;)结束这一行,表示这个表达式(expression)已经结束,下一个表达式可以开始了。大多数 Rust 代码行都以分号结尾。
编译与运行
你刚刚运行了一个新创建的程序,让我们来逐步分析这个过程。
在运行 Rust 程序之前,你必须使用 Rust 编译器来编译它,输入 rustc 命令并传入源文件名,像这样:
$ rustc main.rs
如果你有 C 或 C++ 背景,你会注意到这与 gcc 或 clang 类似。编译成功后,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 的调试信息文件。从这里,你可以运行 main 或 main.exe 文件,像这样:
$ ./main # or .\main on Windows
如果你的 main.rs 是 “Hello, world!” 程序,这行命令会在终端打印 Hello, world!。
如果你更熟悉动态语言,如 Ruby、Python 或 JavaScript,你可能不习惯将编译和运行作为两个独立的步骤。Rust 是一种预编译(ahead-of-time compiled)语言,这意味着你可以编译一个程序,然后把可执行文件交给别人,即使他们没有安装 Rust 也能运行。如果你给别人一个 .rb、.py 或 .js 文件,他们需要分别安装 Ruby、Python 或 JavaScript 的实现。但在那些语言中,你只需要一条命令就能编译并运行程序。一切都是语言设计中的权衡。
仅使用 rustc 编译对于简单程序来说没问题,但随着项目的增长,你会希望管理所有选项并方便地共享代码。接下来,我们将介绍 Cargo 工具,它将帮助你编写真实的 Rust 程序。
Hello, Cargo!
Hello, Cargo!
Cargo 是 Rust 的构建系统和包管理器。大多数 Rustacean 都使用这个工具来管理他们的 Rust 项目,因为 Cargo 会帮你处理很多任务,比如构建代码、下载代码所依赖的库,以及编译这些库。(我们把代码所需的库称为依赖(dependencies)。)
最简单的 Rust 程序,比如我们目前写的这个,没有任何依赖。如果我们用 Cargo 来构建 “Hello, world!” 项目,它只会用到 Cargo 中负责构建代码的那部分功能。随着你编写更复杂的 Rust 程序,你会添加依赖,而如果你一开始就使用 Cargo 来创建项目,添加依赖会方便得多。
由于绝大多数 Rust 项目都使用 Cargo,本书后续内容也假定你在使用 Cargo。如果你使用了“安装”部分介绍的官方安装器,Cargo 已经随 Rust 一起安装好了。如果你通过其他方式安装了 Rust,可以在终端中输入以下命令来检查 Cargo 是否已安装:
$ cargo --version
如果你看到了版本号,说明已经安装好了!如果看到类似 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 文件,以及一个 src 目录,里面有一个 main.rs 文件。
它还初始化了一个新的 Git 仓库,并附带一个 .gitignore 文件。如果你在一个已有的 Git 仓库中运行 cargo new,则不会生成 Git 文件;你可以使用 cargo new --vcs=git 来覆盖这一行为。
注意:Git 是一个常用的版本控制系统。你可以通过
--vcs参数让cargo new使用其他版本控制系统,或者不使用版本控制系统。运行cargo new --help查看可用选项。
用你喜欢的文本编辑器打开 Cargo.toml。它的内容应该类似于示例 1-2 中的代码。
[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2024"
[dependencies]
cargo new 生成的 Cargo.toml 的内容这个文件使用 TOML(Tom’s Obvious, Minimal Language)格式,这是 Cargo 的配置格式。
第一行 [package] 是一个段落标题,表示接下来的语句用于配置一个包。随着我们向这个文件添加更多信息,还会添加其他段落。
接下来的三行设置了 Cargo 编译程序所需的配置信息:程序的名称、版本,以及要使用的 Rust 版次。我们将在附录 E 中讨论 edition 键。
最后一行 [dependencies] 是一个段落的开始,用于列出项目的所有依赖。在 Rust 中,代码包被称为 crate。这个项目不需要其他 crate,但在第 2 章的第一个项目中会用到,届时我们会使用这个依赖段落。
现在打开 src/main.rs 看一看:
文件名:src/main.rs
fn main() {
println!("Hello, world!");
}
Cargo 为你生成了一个 “Hello, world!” 程序,和我们在示例 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 # or .\target\debug\hello_cargo.exe on Windows
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 check 比 cargo build 快得多,因为它跳过了生成可执行文件的步骤。如果你在编写代码的过程中不断检查自己的工作,使用 cargo check 可以加快确认项目是否仍能编译的过程!因此,许多 Rustacean 在编写程序时会定期运行 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 代码读写的好时机。所以在第 2 章中,我们将构建一个猜数字游戏程序。如果你更想先了解 Rust 中常见编程概念的工作方式,请参阅第 3 章,然后再回到第 2 章。
编写一个猜数字游戏
让我们一起动手做一个项目来快速上手 Rust!本章将通过一个实际程序来介绍一些常见的 Rust 概念。你将学到 let、match、方法、关联函数(associated function)、外部 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 `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),你可以在标准库文档中查看其中的所有内容。
如果你想使用的类型不在预导入中,就需要使用 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}");
}
这段代码打印了一个提示,说明这是什么游戏并请求用户输入。
使用变量存储值
接下来,我们将创建一个变量(variable)来存储用户输入,如下所示:
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 中,变量默认是不可变的(immutable),这意味着一旦我们给变量赋值,该值就不会改变。我们将在第 3 章的“变量与可变性”部分详细讨论这个概念。要使变量可变,我们在变量名前加上 mut:
let apples = 5; // immutable
let mut bananas = 5; // mutable
注意:
//语法开始一个注释,一直持续到行尾。Rust 会忽略注释中的所有内容。我们将在第 3 章中更详细地讨论注释。
回到猜数字游戏程序,你现在知道 let mut guess 会引入一个名为 guess 的可变变量。等号(=)告诉 Rust 我们现在要将某个值绑定到这个变量上。等号右边是 guess 所绑定的值,即调用 String::new 的结果——一个返回 String 新实例的函数。String 是标准库提供的字符串类型,是一种可增长的、UTF-8 编码的文本。
::new 这一行中的 :: 语法表明 new 是 String 类型的一个关联函数(associated function)。关联函数是在类型上实现的函数,在这里就是 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 的完整工作是将用户在标准输入中键入的内容追加到一个字符串中(不会覆盖其内容),因此我们将该字符串作为参数传入。这个字符串参数需要是可变的,这样方法才能修改字符串的内容。
& 表示这个参数是一个引用(reference),它提供了一种方式,让代码的多个部分可以访问同一块数据,而无需将数据多次复制到内存中。引用是一个复杂的特性,而 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 是一个枚举(enumeration),通常称为 enum,它是一种可以处于多种可能状态之一的类型。我们将每种可能的状态称为一个变体(variant)。
第 6 章将更详细地介绍枚举。这些 Result 类型的目的是编码错误处理信息。
Result 的变体是 Ok 和 Err。Ok 变体表示操作成功,其中包含成功生成的值。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(binary crate),即一个可执行文件。rand crate 是一个库 crate(library 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。
现在,在不修改任何代码的情况下,让我们构建项目,如示例 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
rand crate 作为依赖后运行 cargo build 的输出你可能会看到不同的版本号(但由于 SemVer,它们都与代码兼容!)和不同的行(取决于操作系统),而且行的顺序可能不同。
当我们引入一个外部依赖时,Cargo 会从注册表(registry)获取该依赖所需的所有最新版本,注册表是 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 有一种机制,确保你或任何其他人每次构建代码时都能重建相同的产物:Cargo 只会使用你指定的依赖版本,除非你另行指示。例如,假设下周 rand crate 发布了 0.8.6 版本,该版本包含一个重要的 bug 修复,但同时也包含一个会破坏你代码的回归问题。为了处理这种情况,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.999.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.999.0)
Cargo 忽略了 0.999.0 版本。此时,你还会注意到 Cargo.lock 文件中的变化,记录了你现在使用的 rand crate 版本是 0.8.6。要使用 rand 版本 0.999.0 或 0.999.x 系列中的任何版本,你需要将 Cargo.toml 文件更新为如下内容(不要实际做这个更改,因为以下示例假设你使用的是 rand 0.8):
[dependencies]
rand = "0.999.0"
下次你运行 cargo build 时,Cargo 会更新可用 crate 的注册表,并根据你指定的新版本重新评估你的 rand 需求。
关于 Cargo 及其生态系统还有很多内容可以讲,我们将在第 14 章中讨论,但目前你只需要了解这些。Cargo 使得复用库变得非常容易,因此 Rustacean 们能够编写由多个包组装而成的小型项目。
生成随机数
让我们开始使用 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命令会在本地构建所有依赖提供的文档并在浏览器中打开。例如,如果你对randcrate 的其他功能感兴趣,可以运行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 std::cmp::Ordering;
use std::io;
use rand::Rng;
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 类型是另一个枚举,有 Less、Greater 和 Equal 三个变体。这是比较两个值时可能出现的三种结果。
然后,我们在底部添加了五行新代码来使用 Ordering 类型。cmp 方法比较两个值,可以在任何可比较的值上调用。它接受一个你想要与之比较的值的引用:这里是将 guess 与 secret_number 进行比较。然后它返回我们通过 use 语句引入作用域的 Ordering 枚举的一个变体。我们使用 match 表达式,根据用 guess 和 secret_number 的值调用 cmp 返回的 Ordering 变体来决定下一步做什么。
一个 match 表达式由多个分支(arm)组成。一个分支包含一个用于匹配的模式(pattern),以及当给 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:23:21
|
23 | 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
--> /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/core/src/cmp.rs:979:8
For more information about this error, try `rustc --explain E0308`.
error: could not compile `guessing_game` (bin "guessing_game") due to 1 previous error
错误的核心是存在类型不匹配(mismatched types)。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 std::cmp::Ordering;
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.");
// --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 允许我们用一个新值遮蔽(shadow)之前的 guess 值。遮蔽让我们可以复用 guess 变量名,而不必创建两个不同的变量,比如 guess_str 和 guess。我们将在第 3 章中更详细地介绍这个特性,但现在只需知道,当你想将一个值从一种类型转换为另一种类型时,经常会用到这个特性。
我们将这个新变量绑定到表达式 guess.trim().parse()。表达式中的 guess 指的是包含输入字符串的原始 guess 变量。String 实例上的 trim 方法会去除开头和结尾的空白字符,在将字符串转换为只能包含数字数据的 u32 之前,我们必须这样做。用户必须按 enter 键来满足 read_line 并输入猜测,这会在字符串中添加一个换行符。例如,如果用户输入 5 并按 enter,guess 看起来是这样的: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 能成功将字符串转换为数字,它会返回 Result 的 Ok 变体,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 std::cmp::Ordering;
use std::io;
use rand::Rng;
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 std::cmp::Ordering;
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}");
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 的最后一部分。
处理无效输入
为了进一步完善游戏的行为,当用户输入非数字时,我们不让程序崩溃,而是让游戏忽略非数字输入,以便用户可以继续猜测。我们可以通过修改将 guess 从 String 转换为 u32 的那一行来实现,如示例 2-5 所示。
use std::cmp::Ordering;
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}");
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 是一个拥有 Ok 和 Err 变体的枚举。我们在这里使用 match 表达式,就像处理 cmp 方法返回的 Ordering 结果一样。
如果 parse 能够成功地将字符串转换为数字,它会返回一个包含结果数字的 Ok 值。这个 Ok 值会匹配第一个分支的模式,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 std::cmp::Ordering;
use std::io;
use rand::Rng;
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 概念:let、match、函数、外部 crate 的使用等等。在接下来的几章中,你将更详细地学习这些概念。第 3 章涵盖了大多数编程语言都有的概念,如变量、数据类型和函数,并展示如何在 Rust 中使用它们。第 4 章探讨所有权(ownership),这是 Rust 区别于其他语言的特性。第 5 章讨论结构体和方法语法,第 6 章解释枚举的工作原理。
常见编程概念
本章涵盖几乎所有编程语言中都会出现的概念,以及它们在 Rust 中的工作方式。许多编程语言在核心层面有很多共同之处。本章介绍的概念没有一个是 Rust 独有的,但我们会在 Rust 的语境下讨论它们,并解释使用这些概念的相关约定。
具体来说,你将学习变量、基本类型、函数、注释和控制流。这些基础知识将出现在每一个 Rust 程序中,尽早学习它们会为你打下坚实的基础。
关键字
Rust 语言有一组保留的 关键字(keywords),只能由语言本身使用,这一点与其他语言类似。请记住,你不能将这些词用作变量名或函数名。大多数关键字都有特殊含义,你将在 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
这个例子展示了编译器如何帮助你发现程序中的错误。编译器错误可能令人沮丧,但实际上它们只是意味着你的程序还没有安全地完成你想让它做的事情;它们并_不_意味着你不是一个好程序员!有经验的 Rustacean 同样会遇到编译器错误。
你收到了错误信息 cannot assign twice to immutable variable `x`(不能对不可变变量 x 进行二次赋值),因为你试图给不可变变量 x 赋第二个值。
当我们尝试修改一个被指定为不可变的值时,能够在编译时得到错误是很重要的,因为这种情况恰恰可能导致 bug。如果代码的一部分基于某个值永远不会改变的假设来运行,而代码的另一部分却修改了该值,那么第一部分代码就可能无法按照设计意图工作。这类 bug 的原因在事后往往难以追踪,尤其是当第二段代码只是_偶尔_修改该值时。Rust 编译器保证了当你声明一个值不会改变时,它就真的不会改变,这样你就不必自己去追踪它了。因此你的代码更容易推理。
但可变性也非常有用,能让代码编写起来更加方便。虽然变量默认是不可变的,但你可以像第 2 章中那样,在变量名前加上 mut 使其成为可变的。添加 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。最终,是否使用可变性取决于你自己,取决于你认为在特定情况下怎样最清晰。
声明常量
与不可变变量类似,常量(constants)也是绑定到名称上且不允许更改的值,但常量和变量之间有一些区别。
首先,常量不允许使用 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 章的猜数字游戏教程中所见,你可以声明一个与之前变量同名的新变量。Rustacean 们说第一个变量被第二个变量_遮蔽_(shadowed)了,这意味着当你使用该变量名时,编译器看到的是第二个变量。实际上,第二个变量遮盖了第一个,将所有对该变量名的使用都指向自己,直到它自身被遮蔽或作用域结束。我们可以通过使用相同的变量名并重复使用 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_str 和 spaces_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 中的每一个值都属于某种数据类型(data type),这告诉 Rust 数据的具体形式,从而让它知道如何处理这些数据。我们将介绍两大类数据类型:标量类型和复合类型。
请记住,Rust 是一门静态类型(statically typed)语言,这意味着在编译期就必须知道所有变量的类型。编译器通常可以根据值及其使用方式推断出我们想要的类型。当存在多种可能的类型时,比如第 2 章“比较猜测的数字与秘密数字”部分中使用 parse 将 String 转换为数值类型时,就必须添加类型注解,像这样:
#![allow(unused)]
fn main() {
let guess: u32 = "42".parse().expect("Not a number!");
}
如果不添加上面代码中的 : 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
后续你会看到其他数据类型的各种类型注解。
标量类型
标量(scalar)类型表示单个值。Rust 有四种基本的标量类型:整数、浮点数、布尔值和字符。你可能在其他编程语言中见过它们。下面来看看它们在 Rust 中是如何工作的。
整数类型
整数(integer)是没有小数部分的数字。我们在第 2 章使用过一种整数类型 u32。这个类型声明表明,它关联的值应该是一个占用 32 位空间的无符号整数(有符号整数类型以 i 开头,而不是 u)。表 3-1 展示了 Rust 内置的整数类型。我们可以使用其中任何一种来声明整数值的类型。
表 3-1:Rust 中的整数类型
| 长度 | 有符号 | 无符号 |
|---|---|---|
| 8 位 | i8 | u8 |
| 16 位 | i16 | u16 |
| 32 位 | i32 | u32 |
| 64 位 | i64 | u64 |
| 128 位 | i128 | u128 |
| 取决于架构 | isize | usize |
每种变体要么是有符号的,要么是无符号的,并且有明确的大小。有符号和无符号指的是数字是否可能为负数——换句话说,数字是否需要带一个符号(有符号),还是只会是正数因而可以不带符号来表示(无符号)。这就像在纸上写数字一样:当符号很重要时,数字会带上加号或减号;而当可以确定数字是正数时,就不需要写符号。有符号数使用二进制补码表示法存储。
每种有符号变体可以存储从 −(2n − 1) 到 2n − 1 − 1 的数字(含两端),其中 n 是该变体使用的位数。因此 i8 可以存储从 −(27) 到 27 − 1 的数字,即 −128 到 127。无符号变体可以存储从 0 到 2n − 1 的数字,所以 u8 可以存储从 0 到 28 − 1 的数字,即 0 到 255。
此外,isize 和 usize 类型取决于程序运行所在计算机的架构:在 64 位架构上是 64 位,在 32 位架构上是 32 位。
你可以用表 3-2 中所示的任何形式来编写整数字面量。注意,可以是多种数值类型的字面量允许添加类型后缀来指定类型,例如 57u8。数字字面量还可以使用 _ 作为视觉分隔符以便于阅读,例如 1_000,它与 1000 的值相同。
表 3-2:Rust 中的整数字面量
| 数字字面量 | 示例 |
|---|---|
| 十进制 | 98_222 |
| 十六进制 | 0xff |
| 八进制 | 0o77 |
| 二进制 | 0b1111_0000 |
字节(仅限 u8) | b'A' |
那么如何知道该使用哪种整数类型呢?如果你不确定,Rust 的默认值通常是不错的起点:整数类型默认为 i32。需要使用 isize 或 usize 的主要场景是对某种集合进行索引。
整数溢出
假设你有一个 u8 类型的变量,它可以存储 0 到 255 之间的值。如果你试图将变量改为超出该范围的值,比如 256,就会发生整数溢出(integer overflow),这可能导致两种行为之一。在调试模式下编译时,Rust 会包含整数溢出检查,如果发生溢出,程序会在运行时 panic。Rust 使用 panicking 这个术语来表示程序因错误而退出;我们将在第 9 章的“用 panic! 处理不可恢复的错误”部分更深入地讨论 panic。
在使用 --release 标志的发布模式下编译时,Rust 不会包含导致 panic 的整数溢出检查。相反,如果发生溢出,Rust 会执行二进制补码环绕(two’s complement wrapping)。简而言之,超过类型最大值的值会“环绕“到该类型能容纳的最小值。以 u8 为例,值 256 变为 0,值 257 变为 1,依此类推。程序不会 panic,但变量的值可能不是你期望的。依赖整数溢出的环绕行为被视为一种错误。
要显式处理溢出的可能性,可以使用标准库为基本数值类型提供的以下系列方法:
- 使用
wrapping_*方法在所有模式下进行环绕运算,例如wrapping_add。 - 使用
checked_*方法,如果发生溢出则返回None值。 - 使用
overflowing_*方法,返回值和一个表示是否发生溢出的布尔值。 - 使用
saturating_*方法,在值的最小值或最大值处饱和。
浮点类型
Rust 还有两种基本的浮点数(floating-point number)类型,即带小数点的数字。Rust 的浮点类型是 f32 和 f64,分别占 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 中的布尔类型有两个可能的值:true 和 false。布尔值占一个字节。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 类型占 4 个字节,表示一个 Unicode 标量值,这意味着它能表示的远不止 ASCII。带重音的字母、中文、日文和韩文字符、emoji 以及零宽空格在 Rust 中都是有效的 char 值。Unicode 标量值的范围是 U+0000 到 U+D7FF 和 U+E000 到 U+10FFFF(含两端)。不过,“字符“在 Unicode 中并不是一个真正的概念,所以你对“字符“的直觉理解可能与 Rust 中 char 的含义不完全一致。我们将在第 8 章的“使用字符串存储 UTF-8 编码的文本”中详细讨论这个话题。
复合类型
复合类型(compound type)可以将多个值组合成一个类型。Rust 有两种基本的复合类型:元组和数组。
元组类型
元组(tuple)是将多个不同类型的值组合成一个复合类型的通用方式。元组有固定的长度:一旦声明,就不能增长或缩小。
我们通过在圆括号内写一个逗号分隔的值列表来创建元组。元组中每个位置都有一个类型,各个值的类型不必相同。下面的示例中我们添加了可选的类型注解:
文件名: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 拆分为三个独立的变量 x、y 和 z。这叫做解构(destructuring),因为它将单个元组拆成了三个部分。最后,程序打印出 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。
没有任何值的元组有一个特殊的名字叫单元(unit)。这个值及其对应的类型都写作 (),表示一个空值或空的返回类型。如果表达式不返回任何其他值,就会隐式返回单元值。
数组类型
另一种包含多个值的集合是数组(array)。与元组不同,数组中的每个元素必须是相同的类型。与某些其他语言中的数组不同,Rust 中的数组长度是固定的。
我们将值写在方括号内,用逗号分隔,来创建数组:
文件名:src/main.rs
fn main() {
let a = [1, 2, 3, 4, 5];
}
当你希望数据分配在栈上而不是堆上时(我们将在第 4 章更详细地讨论栈和堆),或者当你希望确保始终有固定数量的元素时,数组非常有用。不过数组不如 vector 类型灵活。vector 是标准库提供的类似集合类型,它可以增长或缩小,因为其内容存储在堆上。如果你不确定该用数组还是 vector,大概率应该使用 vector。第 8 章会更详细地讨论 vector。
然而,当你知道元素数量不需要改变时,数组更加实用。例如,如果你在程序中使用月份名称,你可能会使用数组而不是 vector,因为你知道它总是包含 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 运行并输入 0、1、2、3 或 4,程序会打印出数组中对应索引处的值。如果你输入一个超出数组末尾的数字,比如 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 函数内部调用它。注意,我们在源代码中将 another_function 定义在 main 函数之后;当然也可以定义在它之前。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 并打印它的消息。
参数
我们可以定义带有参数(parameters)的函数,参数是作为函数签名一部分的特殊变量。当函数有参数时,你可以为这些参数提供具体的值。严格来说,这些具体的值叫做实参(arguments),但在日常交流中,人们倾向于将形参(parameter)和实参(argument)这两个词互换使用,既可以指函数定义中的变量,也可以指调用函数时传入的具体值。
在这个版本的 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。当我们将 5 传入 another_function 时,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。然后该函数打印包含 value 和 unit_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 的值,所以程序输出包含了这些值。
语句和表达式
函数体由一系列语句(statements)组成,并可以选择以一个表达式(expression)结尾。到目前为止,我们介绍的函数还没有包含结尾表达式,但你已经见过作为语句一部分的表达式了。因为 Rust 是一门基于表达式的语言,所以理解这一区别很重要。其他语言没有同样的区分,所以让我们来看看什么是语句和表达式,以及它们的区别如何影响函数体。
- 语句(Statements)是执行某些操作但不返回值的指令。
- 表达式(Expressions)会计算并产生一个值。
让我们来看一些例子。
实际上我们已经使用过语句和表达式了。使用 let 关键字创建变量并为其赋值就是一条语句。在示例 3-1 中,let y = 6; 就是一条语句。
fn main() {
let y = 6;
}
main 函数声明函数定义也是语句;上面的整个示例本身就是一条语句。(不过我们很快会看到,调用函数不是语句。)
语句不返回值。因此,你不能将一条 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,使 x 和 y 都拥有值 6;但在 Rust 中不是这样的。
表达式会计算出一个值,并且构成了你在 Rust 中编写的大部分代码。考虑一个数学运算,比如 5 + 6,这是一个计算结果为 11 的表达式。表达式可以是语句的一部分:在示例 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 提供了一条可能有助于修正此问题的信息:它建议删除分号,这样就能修复这个错误。
注释
注释
所有程序员都力求让自己的代码易于理解,但有时候需要额外的解释说明。在这种情况下,程序员会在源代码中留下_注释(comments)_,编译器会忽略这些注释,但阅读源代码的人可能会觉得它们很有用。
下面是一个简单的注释:
#![allow(unused)]
fn main() {
// hello, world
}
在 Rust 中,惯用的注释风格是以两个斜杠开始,注释持续到该行的末尾。对于超过一行的注释,需要在每一行都加上 //,像这样:
#![allow(unused)]
fn main() {
// So we're doing something complicated here, long enough that we need
// multiple lines of comments to do it! Whew! Hopefully, this comment will
// explain what's going on.
}
注释也可以放在包含代码的行的末尾:
文件名: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 还有另一种注释,即文档注释(documentation comments),我们将在第 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 表达式中的条件相关联的代码块有时被称为分支(arm),就像我们在第 2 章“比较猜测的数字与秘密数字”部分讨论的 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 处理多个条件
你可以通过将 if 和 else 组合成 else 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 语句的右侧使用它来将结果赋给一个变量,如示例 3-2 所示。
fn main() {
let condition = true;
let number = if condition { 5 } else { 6 };
println!("The value of number is: {number}");
}
if 表达式的结果赋给一个变量变量 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 的每个分支可能产生的结果值必须是相同的类型;在示例 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}");
}
当我们尝试编译这段代码时,会得到一个错误。if 和 else 分支的值类型不兼容,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 提供了几种循环(loop),它们会执行循环体内的代码直到结尾,然后立即从头开始。为了试验循环,让我们创建一个名为 loops 的新项目。
Rust 有三种循环:loop、while 和 for。让我们逐一尝试。
使用 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 章“猜对后退出”部分就这样做过,当用户猜对数字赢得游戏时退出程序。
我们在猜数字游戏中还使用了 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 的变量来保存循环返回的值。在循环的每次迭代中,我们给 counter 变量加 1,然后检查 counter 是否等于 10。当等于 10 时,我们使用 break 关键字并带上值 counter * 2。循环之后,我们用分号结束将值赋给 result 的语句。最后,我们打印 result 中的值,本例中为 20。
你也可以在循环内部使用 return。break 只退出当前循环,而 return 始终退出当前函数。
使用循环标签消除多个循环之间的歧义
如果存在嵌套循环,break 和 continue 作用于此处最内层的循环。你可以选择在循环上指定一个循环标签(loop label),然后将该标签与 break 或 continue 一起使用,以指定这些关键字作用于带标签的循环而非最内层循环。循环标签必须以单引号开头。下面是一个包含两个嵌套循环的示例:
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 停止循环。使用 loop、if、else 和 break 的组合可以实现这种行为;如果你愿意,现在就可以在程序中尝试一下。然而,这种模式非常常见,所以 Rust 为此提供了一个内置的语言结构,称为 while 循环。在示例 3-3 中,我们使用 while 让程序循环三次,每次倒计数,然后在循环结束后打印一条消息并退出。
fn main() {
let mut number = 3;
while number != 0 {
println!("{number}!");
number -= 1;
}
println!("LIFTOFF!!!");
}
while 循环在条件求值为 true 时运行代码这种结构消除了使用 loop、if、else 和 break 时所需的大量嵌套,而且更加清晰。当条件求值为 true 时代码运行;否则退出循环。
使用 for 遍历集合
你可以选择使用 while 结构来遍历集合的元素,比如数组。例如,示例 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;
}
}
while 循环遍历集合中的每个元素这里,代码对数组中的元素进行计数。它从索引 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 循环看起来像示例 3-5 中的代码。
fn main() {
let a = [10, 20, 30, 40, 50];
for element in a {
println!("the value is: {element}");
}
}
for 循环遍历集合中的每个元素当我们运行这段代码时,会看到与示例 3-4 相同的输出。更重要的是,我们现在提高了代码的安全性,消除了因超出数组末尾或遍历不够远而遗漏某些元素所可能导致的 bug。for 循环生成的机器码也可能更高效,因为不需要在每次迭代时将索引与数组长度进行比较。
使用 for 循环,如果你改变了数组中值的数量,不需要像示例 3-4 中的方法那样记得修改其他代码。
for 循环的安全性和简洁性使其成为 Rust 中最常用的循环结构。即使在你想要运行某段代码特定次数的情况下,比如示例 3-3 中使用 while 循环的倒计时示例,大多数 Rustacean 也会使用 for 循环。实现方式是使用标准库提供的 Range,它会按顺序生成从一个数字开始到另一个数字之前结束的所有数字。
下面是使用 for 循环和我们尚未介绍的 rev 方法来反转范围后的倒计时效果:
文件名:src/main.rs
fn main() {
for number in (1..4).rev() {
println!("{number}!");
}
println!("LIFTOFF!!!");
}
这段代码是不是更好看了?
总结
你做到了!这是相当长的一章:你学习了变量、标量和复合数据类型、函数、注释、if 表达式和循环!为了练习本章讨论的概念,尝试编写程序来完成以下任务:
- 在华氏温度和摄氏温度之间进行转换。
- 生成第 n 个斐波那契数。
- 打印圣诞颂歌“The Twelve Days of Christmas“的歌词,利用歌曲中的重复部分。
当你准备好继续前进时,我们将讨论 Rust 中一个在其他编程语言中不常见的概念:所有权(ownership)。
理解所有权
所有权(ownership)是 Rust 最独特的特性,它对语言的其余部分有着深远的影响。所有权机制使 Rust 无需垃圾回收器即可保证内存安全,因此理解所有权的工作原理非常重要。在本章中,我们将讨论所有权以及几个相关特性:借用(borrowing)、切片(slice),以及 Rust 如何在内存中布局数据。
什么是所有权?
什么是所有权?
所有权(ownership)是一组规则,用于管理 Rust 程序的内存使用方式。所有程序在运行时都必须管理其使用计算机内存的方式。有些语言通过垃圾回收机制在程序运行时不断寻找不再使用的内存;另一些语言则要求程序员显式地分配和释放内存。Rust 采用了第三种方式:通过所有权系统管理内存,编译器在编译时会检查一系列规则。如果违反了任何规则,程序将无法编译。所有权的任何特性都不会在程序运行时拖慢速度。
因为所有权对许多程序员来说是一个全新的概念,确实需要一些时间来适应。好消息是,随着你对 Rust 和所有权系统规则的经验越来越丰富,你会越来越自然地编写出安全且高效的代码。坚持下去!
当你理解了所有权,你就拥有了理解 Rust 独特特性的坚实基础。在本章中,你将通过一些围绕非常常见的数据结构——字符串——的示例来学习所有权。
栈与堆
许多编程语言不需要你经常考虑栈(stack)和堆(heap)的问题。但在像 Rust 这样的系统编程语言中,值位于栈上还是堆上会影响语言的行为方式,也会影响你必须做出的某些决策。本章后面会结合栈和堆来描述所有权的部分内容,这里先做一个简要的说明。
栈和堆都是代码在运行时可以使用的内存区域,但它们的组织方式不同。栈按照获取值的顺序存储,并以相反的顺序移除值。这被称为后进先出(last in, first out, LIFO)。想象一叠盘子:当你添加更多盘子时,你把它们放在最上面;当你需要一个盘子时,你从最上面取一个。从中间或底部添加或移除盘子就不太方便了!添加数据叫做入栈(pushing onto the stack),移除数据叫做出栈(popping off the stack)。栈上存储的所有数据都必须具有已知的固定大小。在编译时大小未知或大小可能变化的数据必须存储在堆上。
堆的组织性较差:当你把数据放到堆上时,你请求一定量的空间。内存分配器在堆中找到一块足够大的空闲区域,将其标记为已使用,并返回一个指针(pointer),即该位置的地址。这个过程叫做在堆上分配(allocating on the heap),有时简称为分配(allocating)(将值压入栈不被视为分配)。因为指向堆的指针是已知的固定大小,你可以将指针存储在栈上,但当你需要实际数据时,必须通过指针去访问。想象一下在餐厅就座的场景:当你进入餐厅时,你说明你们一行有几个人,服务员找到一张能容纳所有人的空桌子并带你们过去。如果你们中有人迟到了,他们可以询问你们坐在哪里来找到你们。
入栈比在堆上分配更快,因为分配器不需要搜索存储新数据的位置——那个位置总是在栈顶。相比之下,在堆上分配空间需要更多工作,因为分配器必须先找到一块足够大的空间来存放数据,然后进行记录以准备下一次分配。
访问堆上的数据通常比访问栈上的数据慢,因为你必须通过指针才能到达那里。现代处理器在内存中跳转越少就越快。继续用餐厅的类比,想象一个服务员在许多桌子之间接受点单。最高效的方式是在一张桌子上接完所有点单后再去下一张桌子。先从 A 桌接一个点单,再从 B 桌接一个,然后再回到 A 桌,再去 B 桌,这样的过程会慢得多。同样道理,处理器处理彼此靠近的数据(如栈上的数据)时效率更高,而处理彼此较远的数据(如堆上的数据)时效率较低。
当你的代码调用一个函数时,传递给函数的值(可能包括指向堆上数据的指针)和函数的局部变量会被压入栈中。当函数结束时,这些值会从栈中弹出。
跟踪代码的哪些部分正在使用堆上的哪些数据、最小化堆上的重复数据量、以及清理堆上不再使用的数据以避免空间耗尽——这些都是所有权要解决的问题。一旦你理解了所有权,你就不需要经常考虑栈和堆了。但了解所有权的主要目的是管理堆数据,有助于解释它为什么以这种方式工作。
所有权规则
首先,让我们看一下所有权规则。在我们学习后面的示例时,请牢记这些规则:
- Rust 中的每一个值都有一个所有者(owner)。
- 值在任一时刻有且只有一个所有者。
- 当所有者离开作用域时,值将被丢弃。
变量作用域
既然我们已经掌握了基本的 Rust 语法,就不会在示例中包含所有的 fn main() { 代码了,所以如果你在跟着操作,请确保手动将以下示例放入 main 函数中。这样我们的示例会更简洁一些,让我们能够专注于实际的细节而非样板代码。
作为所有权的第一个示例,我们来看一些变量的作用域。*作用域(scope)*是一个项在程序中有效的范围。看下面这个变量:
#![allow(unused)]
fn main() {
let s = "hello";
}
变量 s 引用了一个字符串字面值,其中字符串的值被硬编码到程序的文本中。这个变量从声明的位置开始直到当前作用域结束都是有效的。示例 4-1 展示了一个带有注释的程序,标注了变量 s 有效的位置。
fn main() {
{ // s is not valid here, since 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 时,它的实现会请求所需的内存。这在编程语言中几乎是通用的做法。
然而,第二部分有所不同。在有*垃圾回收器(garbage collector, GC)*的语言中,GC 会跟踪并清理不再使用的内存,我们不需要操心。在大多数没有 GC 的语言中,识别内存何时不再使用并调用代码显式释放它是我们的责任,就像请求内存时一样。正确地做到这一点历来是一个困难的编程问题。如果忘记了,就会浪费内存。如果释放得太早,就会产生无效变量。如果释放了两次,那也是一个 bug。我们需要精确地将一次 allocate 与一次 free 配对。
Rust 采取了不同的路径:一旦拥有内存的变量离开作用域,内存就会自动归还。下面是示例 4-1 中作用域示例的一个版本,使用 String 代替字符串字面值:
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++ 中,这种在项的生命周期结束时释放资源的模式有时被称为资源获取即初始化(Resource Acquisition Is Initialization, RAII)。如果你使用过 RAII 模式,那么 Rust 中的
drop函数对你来说会很熟悉。
这种模式对 Rust 代码的编写方式有着深远的影响。现在看起来可能很简单,但在更复杂的情况下——当我们希望多个变量使用我们在堆上分配的数据时——代码的行为可能会出乎意料。让我们来探索其中一些情况。
变量与数据交互的方式:移动
在 Rust 中,多个变量可以以不同的方式与同一数据交互。示例 4-2 展示了一个使用整数的例子。
fn main() {
let x = 5;
let y = x;
}
x 的整数值赋给 y我们大概能猜到这段代码在做什么:“将值 5 绑定到 x;然后复制 x 中的值并将其绑定到 y。“现在我们有了两个变量 x 和 y,它们都等于 5。事实确实如此,因为整数是具有已知固定大小的简单值,这两个 5 值都被压入了栈中。
现在让我们看看 String 版本:
fn main() {
let s1 = String::from("hello");
let s2 = s1;
}
这看起来非常相似,所以我们可能会假设它的工作方式相同:即第二行会复制 s1 中的值并将其绑定到 s2。但实际情况并非如此。
看一下图 4-1,了解 String 底层发生了什么。String 由三部分组成,如左侧所示:一个指向存放字符串内容的内存的指针、一个长度和一个容量。这组数据存储在栈上。右侧是堆上存放内容的内存。
图 4-1:将值 "hello" 绑定给 s1 的 String 在内存中的表示
长度是 String 的内容当前使用的内存量(以字节为单位)。容量是 String 从分配器获得的总内存量(以字节为单位)。长度和容量之间的区别很重要,但在当前上下文中并不重要,所以现在可以忽略容量。
当我们将 s1 赋值给 s2 时,String 的数据被复制了,这意味着我们复制了栈上的指针、长度和容量。我们并没有复制指针所指向的堆上的数据。换句话说,内存中的数据表示如图 4-2 所示。
图 4-2:变量 s2 拥有 s1 的指针、长度和容量的副本时的内存表示
这个表示不像图 4-3 那样,如果 Rust 同时复制了堆上的数据,内存就会是那个样子。如果 Rust 这样做了,当堆上的数据很大时,s2 = s1 操作在运行时性能上可能会非常昂贵。
图 4-3:如果 Rust 同时复制堆数据,s2 = s1 可能的另一种表示
前面我们说过,当变量离开作用域时,Rust 会自动调用 drop 函数并清理该变量的堆内存。但图 4-2 显示两个数据指针指向了同一个位置。这就有问题了:当 s2 和 s1 离开作用域时,它们都会尝试释放相同的内存。这被称为*二次释放(double free)*错误,是我们之前提到的内存安全 bug 之一。释放内存两次可能导致内存损坏,进而可能导致安全漏洞。
为了确保内存安全,在 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:16
|
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
如果你在使用其他语言时听说过浅拷贝(shallow copy)和深拷贝(deep copy)这两个术语,那么只复制指针、长度和容量而不复制数据的概念听起来可能像是浅拷贝。但因为 Rust 同时使第一个变量失效了,所以它不叫浅拷贝,而是被称为移动(move)。在这个例子中,我们会说 s1 被移动到了 s2 中。所以实际发生的情况如图 4-4 所示。
图 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。此时,没有任何东西引用堆上的原始值了。图 4-5 展示了此时栈和堆上的数据:
图 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 的调用时,你就知道某些任意代码正在被执行,而且这些代码可能开销较大。它是一个视觉上的提示,表明这里发生了一些不同的事情。
只在栈上的数据:Copy
还有一个细节我们还没有讨论。这段使用整数的代码——其中一部分在示例 4-2 中展示过——可以正常工作且是有效的:
fn main() {
let x = 5;
let y = x;
println!("x = {x}, y = {y}");
}
但这段代码似乎与我们刚学到的内容矛盾:我们没有调用 clone,但 x 仍然有效,并没有被移动到 y 中。
原因是像整数这样在编译时具有已知大小的类型完全存储在栈上,所以复制实际值的速度很快。这意味着我们没有理由在创建变量 y 之后让 x 失效。换句话说,这里深拷贝和浅拷贝没有区别,所以调用 clone 不会与通常的浅拷贝有任何不同,我们可以省略它。
Rust 有一个叫做 Copy trait 的特殊注解,可以用在像整数这样存储在栈上的类型上(我们将在第 10 章中更多地讨论 trait)。如果一个类型实现了 Copy trait,使用它的变量不会移动,而是会被简单地复制,使得赋值给另一个变量后原变量仍然有效。
如果一个类型或其任何部分实现了 Drop trait,Rust 不允许我们给该类型添加 Copy 注解。如果该类型在值离开作用域时需要执行某些特殊操作,而我们又给它添加了 Copy 注解,就会得到一个编译时错误。要了解如何为你的类型添加 Copy 注解以实现该 trait,请参阅附录 C 中的“可派生的 trait”。
那么,哪些类型实现了 Copy trait 呢?你可以查看给定类型的文档来确认,但作为一般规则,任何一组简单标量值都可以实现 Copy,而任何需要分配内存或属于某种资源的类型都不能实现 Copy。以下是一些实现了 Copy 的类型:
- 所有整数类型,如
u32。 - 布尔类型
bool,值为true和false。 - 所有浮点类型,如
f64。 - 字符类型
char。 - 元组,当且仅当其包含的类型也都实现了
Copy时。例如,(i32, i32)实现了Copy,但(i32, String)没有。
所有权与函数
将值传递给函数的机制与将值赋给变量的机制类似。将变量传递给函数会发生移动或复制,就像赋值一样。示例 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,
// so it's okay to use x afterward.
} // Here, x goes out of scope, then s. However, 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 中添加使用 s 和 x 的代码,看看在哪里可以使用它们,以及所有权规则在哪里阻止你这样做。
返回值与作用域
返回值也可以转移所有权。示例 4-4 展示了一个返回某些值的函数示例,带有与示例 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 允许我们使用元组返回多个值,如示例 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 提供了一个无需转移所有权就能使用值的特性:引用(references)。
引用与借用
引用与借用
示例 4-5 中元组代码的问题在于,我们必须将 String 返回给调用函数,这样在调用 calculate_length 之后才能继续使用这个 String,因为 String 已经被移动到了 calculate_length 中。作为替代,我们可以提供一个指向 String 值的引用(reference)。引用类似于指针,它是一个地址,我们可以通过它访问存储在该地址的数据;这些数据由其他变量拥有。与指针不同的是,引用在其生命周期内保证指向某个特定类型的有效值。
下面展示如何定义和使用一个 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()
}
首先,注意变量声明和函数返回值中所有的元组代码都消失了。其次,注意我们将 &s1 传入 calculate_length,并且在函数定义中,我们接受的是 &String 而不是 String。这些 & 符号代表引用,它们允许你引用某个值而不获取其所有权。图 4-6 展示了这个概念。
图 4-6:&String s 指向 String s1 的示意图
注意:与使用
&进行引用相反的操作是 解引用(dereferencing),使用解引用运算符*来完成。我们将在第 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 String is not dropped.
变量 s 有效的作用域与任何函数参数的作用域相同,但当 s 停止使用时,引用所指向的值不会被丢弃,因为 s 没有所有权。当函数使用引用而不是实际值作为参数时,我们不需要返回值来归还所有权,因为我们从未拥有过所有权。
我们将创建引用的行为称为 借用(borrowing)。就像在现实生活中,如果一个人拥有某样东西,你可以从他那里借用。用完之后,你必须归还。你并不拥有它。
那么,如果我们尝试修改借用的内容会怎样呢?试试示例 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
正如变量默认是不可变的,引用也是如此。我们不允许修改引用所指向的内容。
可变引用
我们可以通过一些小改动来修复示例 4-6 中的代码,使其允许我们修改借用的值,这就是使用 可变引用(mutable reference):
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 中创建另一个借用相同数据的可变引用。
这个限制以一种非常受控的方式允许修改,但防止同一数据在同一时间被多个可变引用访问。这是很多 Rust 新手会感到困扰的地方,因为大多数语言允许你随时进行修改。这个限制的好处是 Rust 可以在编译时就防止数据竞争(data race)。数据竞争 类似于竞态条件,当以下三种行为同时发生时就会产生:
- 两个或更多指针同时访问同一数据。
- 至少有一个指针正在写入数据。
- 没有同步数据访问的机制。
数据竞争会导致未定义行为,在运行时尝试追踪它们时可能难以诊断和修复;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!("{r1}, {r2}, and {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!("{r1}, {r2}, and {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}");
}
不可变引用 r1 和 r2 的作用域在 println! 之后结束,也就是它们最后一次被使用的地方,这在可变引用 r3 创建之前。这些作用域没有重叠,所以这段代码是允许的:编译器可以判断出引用在作用域结束之前的某个点已经不再被使用了。
尽管借用错误有时可能令人沮丧,但请记住,这是 Rust 编译器在早期(编译时而非运行时)就指出了潜在的 bug,并准确地告诉你问题出在哪里。这样你就不必去追踪为什么你的数据不是你以为的那样了。
悬垂引用
在使用指针的语言中,很容易错误地创建一个 悬垂指针(dangling pointer)——一个引用了内存中某个位置的指针,而该内存可能已经被释放并分配给了其他人。在 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 {
|
For more information about this error, try `rustc --explain E0106`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
这个错误信息涉及一个我们尚未介绍的特性:生命周期(lifetime)。我们将在第 10 章详细讨论生命周期。但是,如果你忽略关于生命周期的部分,这条信息确实包含了这段代码为什么有问题的关键:
this function's return type contains a borrowed value, but there is no value
for it to be borrowed from
让我们仔细看看 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
}
这样就没有任何问题了。所有权被移出,没有任何东西被释放。
引用的规则
让我们回顾一下我们讨论过的关于引用的内容:
- 在任意给定时刻,你 要么 只能有一个可变引用,要么 只能有任意数量的不可变引用。
- 引用必须始终有效。
接下来,我们将看看另一种不同类型的引用:切片(slice)。
切片类型
切片类型
切片(slice)允许你引用一个集合中连续的元素序列。切片是一种引用,因此它没有所有权。
这里有一个小的编程问题:编写一个函数,该函数接受一个由空格分隔的单词组成的字符串,并返回在该字符串中找到的第一个单词。如果函数在字符串中没有找到空格,则整个字符串一定是一个单词,此时应返回整个字符串。
注意:为了介绍切片,本节假设只处理 ASCII 字符;关于 UTF-8 处理的更全面讨论,请参阅第 8 章的“使用字符串存储 UTF-8 编码的文本”部分。
让我们来思考一下,在不使用切片的情况下,这个函数的签名应该怎么写,以此来理解切片将要解决的问题:
fn first_word(s: &String) -> ?
first_word 函数有一个类型为 &String 的参数。我们不需要所有权,所以这样做没问题。(在惯用的 Rust 中,函数不会获取其参数的所有权,除非确实需要,其原因会随着我们继续学习而变得清晰。)但是我们应该返回什么呢?我们实际上没有办法表达字符串的一部分。不过,我们可以返回单词末尾的索引,即空格所在的位置。让我们试试这个方法,如示例 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 参数的字节索引值的 first_word 函数因为我们需要逐个检查 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 分离的值,所以无法保证它在将来仍然有效。考虑一下示例 4-8 中的程序,它使用了示例 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!
}
first_word 函数的结果,然后更改 String 的内容这个程序编译时不会产生任何错误,而且如果我们在调用 s.clear() 之后使用 word,也同样不会报错。因为 word 与 s 的状态完全没有关联,word 仍然包含值 5。我们可以尝试用值 5 配合变量 s 来提取第一个单词,但这将是一个 bug,因为自从我们将 5 保存到 word 之后,s 的内容已经改变了。
不得不担心 word 中的索引与 s 中的数据不同步,这既繁琐又容易出错!如果我们再编写一个 second_word 函数,管理这些索引会更加脆弱。它的签名将不得不是这样的:
fn second_word(s: &String) -> (usize, usize) {
现在我们要跟踪一个起始索引和一个结束索引,而且我们有更多从特定状态的数据中计算出来、却完全不与该状态绑定的值。我们有三个不相关的变量需要保持同步。
幸运的是,Rust 为这个问题提供了一个解决方案:字符串切片。
字符串切片
字符串切片(string slice)是对 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 用图表展示了这一点。
图 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 字符边界上。如果你尝试在一个多字节字符的中间创建字符串切片,程序将会报错退出。
了解了这些信息之后,让我们重写 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() {}
我们用与示例 4-7 相同的方式获取单词末尾的索引,即查找第一个空格的出现位置。当找到一个空格时,我们使用字符串的起始位置和空格的索引作为起始和结束索引来返回一个字符串切片。
现在当我们调用 first_word 时,会得到一个与底层数据绑定的单一值。这个值由切片起始点的引用和切片中元素的数量组成。
返回切片同样适用于 second_word 函数:
fn second_word(s: &String) -> &str {
我们现在有了一个简洁明了的 API,而且更不容易出错,因为编译器会确保对 String 的引用始终有效。还记得示例 4-8 中的那个 bug 吗?当时我们获取了第一个单词末尾的索引,然后清空了字符串,导致索引失效。那段代码在逻辑上是不正确的,但并没有立即显示任何错误。如果我们继续尝试对一个已清空的字符串使用第一个单词的索引,问题才会在后面暴露出来。切片使这种 bug 变得不可能发生,并且能让我们更早地发现代码中的问题。使用切片版本的 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 会编写如示例 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);
}
s 参数的类型改为字符串切片来改进 first_word 函数如果我们有一个字符串切片,可以直接传递它。如果我们有一个 String,可以传递该 String 的切片或对 String 的引用。这种灵活性利用了 deref 强制转换(deref coercions)的特性,我们将在第 15 章的“在函数和方法中使用 Deref 强制转换”部分介绍。
将函数定义为接受字符串切片而不是 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 章讨论 vector 时详细介绍这些集合。
总结
所有权、借用和切片这些概念确保了 Rust 程序在编译时的内存安全。Rust 语言让你像其他系统编程语言一样控制内存使用,但数据的所有者在离开作用域时自动清理数据,这意味着你不必编写和调试额外的代码来实现这种控制。
所有权影响着 Rust 许多其他部分的工作方式,因此我们将在本书的其余部分继续讨论这些概念。让我们进入第 5 章,看看如何将多个数据组合到一个 struct 中。
使用结构体组织关联数据
结构体(struct)是一种自定义数据类型,它允许你将多个相关的值打包在一起并为其命名,从而组成一个有意义的数据组合。如果你熟悉面向对象的语言,结构体就类似于对象中的数据属性。在本章中,我们会将元组与结构体进行比较和对照,在你已有知识的基础上,展示结构体在何时是更好的数据组织方式。
我们将演示如何定义和实例化结构体,还会讨论如何定义关联函数(associated functions),特别是被称为方法(methods)的那类关联函数,用于指定与结构体类型相关联的行为。结构体和枚举(将在第六章讨论)是在你的程序领域中创建新类型的基本构件,能够充分利用 Rust 的编译时类型检查。
定义和实例化结构体
定义并实例化结构体
结构体和我们在“元组类型”小节中讨论过的元组类似,它们都包含多个相关的值。和元组一样,结构体的各个部分可以是不同的类型。但与元组不同的是,在结构体中你需要为每个数据片段命名,从而清楚地表明各个值的含义。有了这些名称,结构体比元组更加灵活:你不必依赖数据的顺序来指定或访问实例中的值。
要定义一个结构体,我们使用 struct 关键字并为整个结构体命名。结构体的名称应当描述被组合在一起的数据片段的意义。然后,在花括号内,我们定义每个数据片段的名称和类型,我们称之为字段(field)。例如,示例 5-1 展示了一个存储用户账户信息的结构体。
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}
fn main() {}
User 结构体定义定义了结构体之后,要使用它就需要创建该结构体的一个实例(instance),为每个字段指定具体的值。创建实例时,先写出结构体的名称,然后加上花括号,里面包含 key: value 键值对,其中键是字段的名称,值是我们想要存储在这些字段中的数据。字段的顺序不必与结构体定义中声明的顺序一致。换句话说,结构体定义就像是该类型的通用模板,而实例则用特定的数据填充这个模板来创建该类型的值。例如,我们可以像示例 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,
};
}
User 结构体的实例要从结构体中获取某个特定的值,可以使用点号表示法。例如,要访问这个用户的电子邮件地址,可以使用 user1.email。如果实例是可变的,我们可以通过点号表示法对某个特定字段进行赋值来修改它的值。示例 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");
}
User 实例中 email 字段的值注意,整个实例必须是可变的;Rust 不允许我们仅将某些字段标记为可变。和任何表达式一样,我们可以在函数体的最后一个表达式中构造结构体的新实例,从而隐式地返回这个新实例。
示例 5-4 展示了一个 build_user 函数,它接受电子邮件和用户名作为参数,返回一个 User 实例。active 字段的值为 true,sign_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"),
);
}
User 实例的 build_user 函数将函数参数命名为与结构体字段相同的名称是合理的,但不得不重复书写 email 和 username 字段名和变量名就有些繁琐了。如果结构体有更多字段,重复每个名称会更加烦人。好在有一种便捷的简写语法!
使用字段初始化简写语法
因为在示例 5-4 中参数名与结构体字段名完全相同,我们可以使用字段初始化简写(field init shorthand)语法来重写 build_user,使其行为完全一致,但无需重复书写 username 和 email,如示例 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"),
);
}
username 和 email 参数与结构体字段同名,build_user 函数使用了字段初始化简写这里我们创建了 User 结构体的一个新实例,该结构体有一个名为 email 的字段。我们想将 email 字段的值设置为 build_user 函数的 email 参数的值。因为 email 字段和 email 参数同名,所以只需写 email 而不必写 email: email。
使用结构体更新语法创建实例
有时候,创建一个新的结构体实例时,大部分值来自另一个同类型的实例,只修改其中一些值,这是很常见的需求。你可以使用结构体更新语法(struct update syntax)来实现。
首先,示例 5-6 展示了不使用更新语法,以常规方式在 user2 中创建一个新的 User 实例。我们为 email 设置了新值,其他字段则使用示例 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,
};
}
user1 的大部分值创建一个新的 User 实例使用结构体更新语法,我们可以用更少的代码达到相同的效果,如示例 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
};
}
User 实例设置新的 email 值,同时使用 user1 的其余值示例 5-7 中的代码同样在 user2 中创建了一个实例,它的 email 值不同,但 username、active 和 sign_in_count 字段的值与 user1 相同。..user1 必须放在最后,用于指定其余字段应从 user1 的对应字段获取值,但我们可以按任意顺序为任意数量的字段指定值,与结构体定义中字段的顺序无关。
注意,结构体更新语法使用了 =,就像赋值一样;这是因为它会移动数据,正如我们在“变量与数据交互的方式(一):移动”小节中看到的那样。在这个例子中,创建 user2 之后我们就不能再使用 user1 了,因为 user1 的 username 字段中的 String 已经被移动到了 user2 中。如果我们为 user2 的 email 和 username 都赋予了新的 String 值,从而只使用了 user1 的 active 和 sign_in_count 值,那么在创建 user2 之后 user1 仍然是有效的。active 和 sign_in_count 的类型都实现了 Copy trait,所以我们在“只在栈上的数据:拷贝”小节中讨论的行为在这里适用。在这个例子中我们也仍然可以使用 user1.email,因为它的值并没有被移出 user1。
使用没有命名字段的元组结构体来创建不同的类型
Rust 还支持一种看起来类似于元组的结构体,称为元组结构体(tuple struct)。元组结构体拥有结构体名称所赋予的额外含义,但其字段没有名称;它们只有字段的类型。当你想给整个元组一个名称,使其成为与其他元组不同的类型,同时像普通结构体那样为每个字段命名又显得冗余时,元组结构体就很有用。
要定义元组结构体,以 struct 关键字和结构体名称开头,后跟元组中的类型。例如,这里我们定义并使用了两个名为 Color 和 Point 的元组结构体:
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);
fn main() {
let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);
}
注意,black 和 origin 是不同的类型,因为它们是不同元组结构体的实例。你定义的每个结构体都是自己独有的类型,即使结构体中的字段可能具有相同的类型。例如,一个接受 Color 类型参数的函数不能接受 Point 作为参数,即使这两个类型都由三个 i32 值组成。除此之外,元组结构体实例的行为与元组类似:你可以将它们解构为单独的部分,也可以使用 . 后跟索引来访问单个值。与元组不同的是,解构元组结构体时需要指明结构体的类型名称。例如,我们可以写 let Point(x, y, z) = origin; 来将 origin 点中的值解构到名为 x、y 和 z 的变量中。
没有任何字段的类单元结构体
你也可以定义没有任何字段的结构体!它们被称为类单元结构体(unit-like struct),因为它们的行为类似于 (),即我们在“元组类型”小节中提到的单元类型。当你需要在某个类型上实现 trait 但又不需要在类型中存储任何数据时,类单元结构体就很有用。我们将在第 10 章讨论 trait。下面是一个声明并实例化名为 AlwaysEqual 的单元结构体的例子:
struct AlwaysEqual;
fn main() {
let subject = AlwaysEqual;
}
要定义 AlwaysEqual,我们使用 struct 关键字、我们想要的名称,然后加一个分号。不需要花括号或圆括号!然后,我们可以用类似的方式在 subject 变量中获取 AlwaysEqual 的一个实例:使用我们定义的名称,不需要任何花括号或圆括号。想象一下,以后我们将为这个类型实现某种行为,使得 AlwaysEqual 的每个实例始终等于任何其他类型的每个实例,也许是为了在测试中获得已知的结果。实现这种行为不需要任何数据!你将在第 10 章看到如何定义 trait 并在任何类型上实现它们,包括类单元结构体。
结构体数据的所有权
在示例 5-1 的 User 结构体定义中,我们使用了拥有所有权的 String 类型而不是 &str 字符串切片类型。这是一个刻意的选择,因为我们希望这个结构体的每个实例都拥有其所有数据,并且只要整个结构体有效,这些数据就有效。
结构体也可以存储对其他数据的引用,但这需要用到生命周期(lifetime),这是一个我们将在第 10 章讨论的 Rust 特性。生命周期确保结构体引用的数据在结构体有效期间始终有效。假设你尝试在结构体中存储引用而不指定生命周期,如下面 src/main.rs 中所示;这是行不通的:
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 的新二进制项目,它将接收以像素为单位的长方形宽度和高度,并计算长方形的面积。示例 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 章的“元组类型”部分讨论过一种实现方式:使用元组。
使用元组进行重构
示例 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。如果其他人使用我们的代码,他们更难弄清楚并记住这一点。因为我们没有在代码中传达数据的含义,所以现在更容易引入错误。
使用结构体进行重构
我们使用结构体通过标注数据来增加含义。我们可以将正在使用的元组转换为一个结构体,为整体和各个部分都赋予名称,如示例 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 结构体这里我们定义了一个结构体并命名为 Rectangle。在花括号内,我们将字段定义为 width 和 height,它们的类型都是 u32。然后,在 main 中,我们创建了一个宽度为 30、高度为 50 的 Rectangle 特定实例。
我们的 area 函数现在只有一个参数,我们将其命名为 rectangle,其类型是 Rectangle 结构体实例的不可变借用。正如第 4 章所提到的,我们希望借用结构体而不是获取其所有权。这样,main 就保留了所有权,可以继续使用 rect1,这也是我们在函数签名和调用函数时使用 & 的原因。
area 函数访问 Rectangle 实例的 width 和 height 字段(注意,访问借用的结构体实例的字段不会移动字段值,这就是你经常看到结构体借用的原因)。现在 area 的函数签名准确地表达了我们的意图:使用 Rectangle 的 width 和 height 字段来计算其面积。这传达了宽度和高度是相互关联的,并为这些值提供了描述性的名称,而不是使用元组的索引值 0 和 1。这在清晰度上是一个胜利。
通过派生 trait 增加功能
在调试程序时,如果能打印 Rectangle 的实例并查看其所有字段的值,那将非常有用。示例 5-11 尝试像前面章节中那样使用 println! 宏。但这行不通。
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {rect1}");
}
Rectangle 实例编译这段代码时,我们会得到一个错误,其核心信息如下:
error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`
println! 宏可以执行多种格式化操作,默认情况下,花括号告诉 println! 使用名为 Display 的格式化方式:面向最终用户的直接输出。我们之前见过的基本类型默认都实现了 Display,因为向用户展示 1 或其他基本类型只有一种方式。但对于结构体,println! 应该如何格式化输出就不那么明确了,因为有更多的显示可能性:要不要逗号?要不要打印花括号?是否应该显示所有字段?由于这种歧义性,Rust 不会尝试猜测我们的意图,结构体也没有提供 Display 的实现来配合 println! 和 {} 占位符使用。
如果我们继续阅读错误信息,会发现这条有用的提示:
| |`Rectangle` cannot be formatted with the default formatter
| required by this formatting parameter
让我们试试看!现在 println! 宏调用将变为 println!("rect1 is {rect1:?}");。在花括号内放置 :? 说明符会告诉 println! 我们想要使用名为 Debug 的输出格式。Debug trait 使我们能够以对开发者有用的方式打印结构体,以便在调试代码时查看其值。
使用这个更改编译代码。糟糕!我们仍然得到一个错误:
error[E0277]: `Rectangle` doesn't implement `Debug`
不过,编译器再次给了我们一条有用的提示:
| required by this formatting parameter
|
Rust 确实包含了打印调试信息的功能,但我们必须显式地选择启用,才能让结构体使用该功能。为此,我们在结构体定义之前添加外部属性 #[derive(Debug)],如示例 5-12 所示。
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {rect1:?}");
}
Debug trait,并使用调试格式打印 Rectangle 实例现在运行程序,不会再有任何错误,我们将看到如下输出:
$ 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 章的“将错误信息重定向到标准错误“部分中详细讨论stderr和stdout。
下面是一个示例,我们对赋给 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 格式化只打印它们的值)。src/main.rs 第 14 行的 dbg! 调用输出了 &rect1 的值,即 Rectangle 结构体。这个输出使用了 Rectangle 类型的美化 Debug 格式。当你试图弄清楚代码在做什么时,dbg! 宏会非常有帮助!
除了 Debug trait 之外,Rust 还提供了许多可以通过 derive 属性使用的 trait,它们能为我们的自定义类型添加有用的行为。这些 trait 及其行为列在附录 C 中。我们将在第 10 章介绍如何通过自定义行为来实现这些 trait,以及如何创建你自己的 trait。除了 derive 之外还有许多其他属性;更多信息请参阅 Rust 参考手册的“属性“部分。
我们的 area 函数非常专一:它只计算长方形的面积。如果能将这个行为更紧密地与 Rectangle 结构体关联起来会很有帮助,因为它不适用于任何其他类型。让我们看看如何继续重构这段代码,将 area 函数转变为定义在 Rectangle 类型上的 area 方法。
方法
方法
方法与函数类似:我们使用 fn 关键字和名称来声明它们,它们可以有参数和返回值,并且包含一些在方法被调用时运行的代码。与函数不同的是,方法是在结构体(或枚举、trait 对象,我们分别在第 6 章和第 18 章中介绍)的上下文中定义的,并且它们的第一个参数始终是 self,代表调用该方法的结构体实例。
方法语法
让我们把以 Rectangle 实例作为参数的 area 函数,改为定义在 Rectangle 结构体上的 area 方法,如示例 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 结构体上定义 area 方法为了在 Rectangle 的上下文中定义函数,我们为 Rectangle 开启一个 impl(implementation,实现)块。这个 impl 块中的所有内容都将与 Rectangle 类型相关联。然后我们将 area 函数移到 impl 花括号内,并将签名中的第一个(在本例中也是唯一的)参数以及函数体中的所有对应位置改为 self。在 main 中,之前我们调用 area 函数并将 rect1 作为参数传入,现在可以改用方法语法来调用 Rectangle 实例上的 area 方法。方法语法跟在实例后面:我们添加一个点号,后跟方法名、圆括号以及任何参数。
在 area 的签名中,我们使用 &self 而不是 rectangle: &Rectangle。&self 实际上是 self: &Self 的缩写。在 impl 块中,Self 类型是 impl 块所针对的类型的别名。方法的第一个参数必须是名为 self 的 Self 类型参数,因此 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 字段。
通常(但并非总是),当我们给方法取与字段相同的名称时,我们希望它只返回字段中的值而不做其他事情。这样的方法被称为 getter,Rust 不会像某些其他语言那样为结构体字段自动实现 getter。getter 很有用,因为你可以将字段设为私有,但将方法设为公有,从而在类型的公有 API 中实现对该字段的只读访问。我们将在第 7 章中讨论什么是公有和私有,以及如何将字段或方法指定为公有或私有。
-> 运算符到哪去了?
在 C 和 C++ 中,调用方法使用两种不同的运算符:如果直接在对象上调用方法,使用 .;如果在对象的指针上调用方法并且需要先解引用指针,则使用 ->。换句话说,如果 object 是一个指针,object->something() 类似于 (*object).something()。
Rust 没有与 -> 运算符等价的东西;相反,Rust 有一个叫做自动引用和解引用(automatic referencing and dereferencing)的特性。调用方法是 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 实例,如果第二个 Rectangle 能完全容纳在 self(第一个 Rectangle)内则返回 true;否则返回 false。也就是说,一旦我们定义了 can_hold 方法,我们希望能够编写如示例 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));
}
can_hold 方法预期输出如下,因为 rect2 的两个维度都小于 rect1,而 rect3 比 rect1 更宽:
Can rect1 hold rect2? true
Can rect1 hold rect3? false
我们知道要定义一个方法,所以它将位于 impl Rectangle 块中。方法名为 can_hold,它将接受另一个 Rectangle 的不可变借用作为参数。通过查看调用该方法的代码,我们可以判断参数的类型:rect1.can_hold(&rect2) 传入了 &rect2,即 rect2(一个 Rectangle 实例)的不可变借用。这是合理的,因为我们只需要读取 rect2(而不是写入,那样就需要可变借用了),并且我们希望 main 保留 rect2 的所有权,以便在调用 can_hold 方法之后还能继续使用它。can_hold 的返回值将是一个布尔值,其实现将检查 self 的宽度和高度是否分别大于另一个 Rectangle 的宽度和高度。让我们将新的 can_hold 方法添加到示例 5-13 的 impl 块中,如示例 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));
}
Rectangle 上实现 can_hold 方法,它接受另一个 Rectangle 实例作为参数当我们使用示例 5-14 中的 main 函数运行这段代码时,将得到期望的输出。方法可以接受多个参数,我们在 self 参数之后将它们添加到签名中,这些参数的工作方式与函数中的参数完全相同。
关联函数
所有在 impl 块中定义的函数都被称为关联函数(associated functions),因为它们与 impl 后面命名的类型相关联。我们可以定义不以 self 作为第一个参数的关联函数(因此不是方法),因为它们不需要该类型的实例来工作。我们已经使用过一个这样的函数:定义在 String 类型上的 String::from 函数。
不是方法的关联函数通常用作构造函数,返回结构体的新实例。这些函数通常被命名为 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 块。例如,示例 5-15 等价于示例 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 块重写示例 5-15在这里没有理由将这些方法分到多个 impl 块中,但这是合法的语法。我们将在第 10 章讨论泛型和 trait 时看到多个 impl 块有用的场景。
总结
结构体让你可以创建对你的领域有意义的自定义类型。通过使用结构体,你可以将相关联的数据片段彼此连接起来,并为每个片段命名以使代码更加清晰。在 impl 块中,你可以定义与类型相关联的函数,而方法是一种让你指定结构体实例行为的关联函数。
但结构体并不是创建自定义类型的唯一方式:让我们转向 Rust 的枚举特性,为你的工具箱再添一件利器。
枚举与模式匹配
本章我们将学习枚举(enumerations),也常简称为 enums。枚举允许你通过列举所有可能的成员来定义一个类型。首先,我们会定义并使用一个枚举,展示枚举如何将含义与数据一起编码。接着,我们会探索一个特别有用的枚举 Option,它表示一个值要么有值,要么没有值。然后,我们会看看 match 表达式中的模式匹配如何让我们针对枚举的不同成员轻松执行不同的代码。最后,我们会介绍 if let 这个简洁方便的语法结构,它是处理代码中枚举的另一种惯用方式。
定义枚举
定义枚举
结构体提供了一种将相关字段和数据组合在一起的方式,比如带有 width 和 height 的 Rectangle;而枚举则提供了一种表达“某个值是一组可能值之一“的方式。例如,我们可能想表达 Rectangle 是一组可能的形状之一,这组形状还包括 Circle 和 Triangle。为此,Rust 允许我们将这些可能性编码为一个枚举。
让我们看一个可能需要用代码来表达的场景,来理解为什么在这种情况下枚举比结构体更有用、更合适。假设我们需要处理 IP 地址。目前,IP 地址有两个主要的标准:IPv4 和 IPv6。因为我们的程序只会遇到这两种可能的 IP 地址,所以可以 枚举(enumerate) 出所有可能的变体,这也是枚举名称的由来。
任何一个 IP 地址要么是 IPv4 地址,要么是 IPv6 地址,不可能同时属于两者。IP 地址的这一特性使得枚举数据结构非常适合这个场景,因为一个枚举值只能是其变体之一。IPv4 和 IPv6 地址本质上都是 IP 地址,所以当代码处理适用于任何类型 IP 地址的场景时,它们应该被视为同一类型。
我们可以通过定义一个 IpAddrKind 枚举并列出 IP 地址可能的类型 V4 和 V6 来在代码中表达这个概念。这些就是枚举的变体:
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::V4 和 IpAddrKind::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 章学习了结构体,你可能会想用结构体来解决这个问题,如示例 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"),
};
}
struct 存储 IP 地址的数据和 IpAddrKind 变体这里我们定义了一个结构体 IpAddr,它有两个字段:一个是 IpAddrKind 类型(我们之前定义的枚举)的 kind 字段,另一个是 String 类型的 address 字段。我们创建了这个结构体的两个实例。第一个是 home,它的 kind 值为 IpAddrKind::V4,关联的地址数据是 127.0.0.1。第二个实例是 loopback,它的 kind 值是 IpAddrKind 的另一个变体 V6,关联的地址是 ::1。我们用结构体将 kind 和 address 值捆绑在一起,这样变体就与值关联起来了。
然而,仅用枚举来表达同样的概念会更加简洁:我们可以将数据直接放入每个枚举变体中,而不是将枚举放在结构体里。这个新的 IpAddr 枚举定义表明 V4 和 V6 变体都将关联 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 类型的实例。定义枚举时,我们自动获得了这个构造函数。
使用枚举而非结构体还有另一个优势:每个变体可以拥有不同类型和数量的关联数据。IPv4 地址总是由四个取值在 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"));
}
我们已经展示了几种定义数据结构来存储 IPv4 和 IPv6 地址的方式。然而事实上,存储 IP 地址并编码其类型是如此常见,以至于标准库已经提供了一个可以直接使用的定义!让我们看看标准库是如何定义 IpAddr 的。它拥有与我们定义和使用的完全相同的枚举和变体,但它将地址数据以两个不同结构体的形式嵌入到变体中,每个变体的结构体定义各不相同:
#![allow(unused)]
fn main() {
struct Ipv4Addr {
// --snip--
}
struct Ipv6Addr {
// --snip--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
}
这段代码说明你可以在枚举变体中放入任何类型的数据:字符串、数字类型或结构体等等。你甚至可以包含另一个枚举!此外,标准库的类型通常也没有比你自己想出来的复杂多少。
注意,尽管标准库包含了 IpAddr 的定义,我们仍然可以创建和使用自己的定义而不会产生冲突,因为我们没有将标准库的定义引入到我们的作用域中。我们将在第 7 章详细讨论如何将类型引入作用域。
让我们看看示例 6-2 中的另一个枚举:这个枚举的变体中嵌入了多种不同的类型。
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn main() {}
Message 枚举,其各变体分别存储不同数量和类型的值这个枚举有四个携带不同类型数据的变体:
Quit:没有任何关联数据Move:有命名字段,类似于结构体Write:包含一个StringChangeColor:包含三个i32值
定义一个如示例 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() {}
但如果我们使用不同的结构体——每个结构体都有自己的类型——我们就不能像使用示例 6-2 中定义的 Message 枚举那样轻松地定义一个接受所有这些消息类型的函数,因为 Message 枚举是单一类型。
枚举和结构体还有一个相似之处:就像我们可以使用 impl 为结构体定义方法一样,我们也可以为枚举定义方法。下面是一个我们可以在 Message 枚举上定义的名为 call 的方法:
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 来获取调用该方法的值。在这个例子中,我们创建了一个值为 Message::Write(String::from("hello")) 的变量 m,这就是当 m.call() 运行时 call 方法体中 self 的值。
让我们看看标准库中另一个非常常见且有用的枚举:Option。
Option 枚举
本节将探讨 Option 的案例研究,它是标准库定义的另一个枚举。Option 类型编码了一个非常常见的场景:一个值可能是某个东西,也可能什么都没有。
例如,如果你请求一个非空列表的第一个元素,你会得到一个值。如果你请求一个空列表的第一个元素,你什么也得不到。用类型系统来表达这个概念意味着编译器可以检查你是否处理了所有应该处理的情况;这个功能可以防止在其他编程语言中极为常见的 bug。
编程语言的设计通常从包含哪些特性的角度来考虑,但排除哪些特性同样重要。Rust 没有许多其他语言都有的空值(null)特性。空值(Null) 是一个表示“此处没有值“的值。在有空值的语言中,变量总是处于两种状态之一:空或非空。
Tony Hoare,空值的发明者,在他 2009 年的演讲“空引用:价值十亿美元的错误“中这样说道:
我称之为我的十亿美元错误。当时我正在为一门面向对象语言设计第一个全面的引用类型系统。我的目标是确保所有引用的使用都是绝对安全的,由编译器自动执行检查。但我无法抵抗诱惑,加入了空引用,仅仅因为它太容易实现了。这导致了无数的错误、漏洞和系统崩溃,在过去四十年中可能造成了十亿美元的损失。
空值的问题在于,如果你试图将一个空值当作非空值来使用,就会得到某种错误。由于这种空或非空的属性无处不在,犯这类错误极其容易。
然而,空值试图表达的概念仍然是有用的:空值表示一个因某种原因当前无效或不存在的值。
问题其实不在于概念本身,而在于具体的实现方式。因此,Rust 没有空值,但它有一个枚举可以编码值存在或不存在的概念。这个枚举就是 Option<T>,它在标准库中的定义如下:
#![allow(unused)]
fn main() {
enum Option<T> {
None,
Some(T),
}
}
Option<T> 枚举非常有用,它甚至被包含在了 prelude 中;你不需要显式地将它引入作用域。它的变体也包含在 prelude 中:你可以直接使用 Some 和 None,而不需要 Option:: 前缀。Option<T> 仍然只是一个普通的枚举,Some(T) 和 None 仍然是 Option<T> 类型的变体。
<T> 语法是我们尚未讨论的 Rust 特性。它是一个泛型(generics)类型参数,我们将在第 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_number 的类型是 Option<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 不知道如何将 i8 和 Option<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 控制流结构
match 控制流结构
Rust 有一个极为强大的控制流结构叫做 match,它允许你将一个值与一系列模式进行比较,然后根据匹配的模式执行相应的代码。模式可以由字面量值、变量名、通配符和许多其他内容组成;第 19 章涵盖了所有不同种类的模式及其作用。match 的强大之处在于模式的表达力,以及编译器会确认所有可能的情况都已被处理。
可以把 match 表达式想象成一台硬币分拣机:硬币沿着轨道滑下,轨道上有各种大小的孔,每枚硬币会掉入它遇到的第一个合适的孔中。同样地,值会依次通过 match 中的每个模式,在第一个“匹配“的模式处,值会落入相关联的代码块中执行。
说到硬币,让我们用它来作为 match 的示例!我们可以编写一个函数,接受一枚未知的美国硬币,以类似于计数机的方式确定它是哪种硬币,并返回其面值(以美分为单位),如示例 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() {}
match 表达式让我们来分解 value_in_cents 函数中的 match。首先,我们写下 match 关键字,后跟一个表达式,在本例中是值 coin。这看起来与 if 使用的条件表达式非常相似,但有一个很大的区别:使用 if 时,条件需要求值为布尔值,而这里可以是任何类型。本例中 coin 的类型是我们在第一行定义的 Coin 枚举。
接下来是 match 的分支(arm)。一个分支有两个部分:一个模式和一些代码。这里的第一个分支的模式是值 Coin::Penny,然后是 => 运算符,它将模式和要运行的代码分隔开。这个分支中的代码只是值 1。每个分支之间用逗号分隔。
当 match 表达式执行时,它会按顺序将结果值与每个分支的模式进行比较。如果模式匹配了该值,则执行与该模式关联的代码。如果该模式不匹配,则继续执行下一个分支,就像硬币分拣机一样。我们可以拥有任意多个分支:在示例 6-3 中,我们的 match 有四个分支。
与每个分支关联的代码是一个表达式,匹配分支中表达式的结果值就是整个 match 表达式的返回值。
如果匹配分支的代码很短,我们通常不使用花括号,就像示例 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 中,如示例 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() {}
Coin 枚举,其中 Quarter 成员还持有一个 UsState 值假设一个朋友正在尝试收集所有 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! 表达式中使用该绑定,从而从 Coin 枚举的 Quarter 成员中获取内部的州值。
匹配 Option<T>
在上一节中,我们想从 Option<T> 的 Some 情况中获取内部的 T 值;我们同样可以使用 match 来处理 Option<T>,就像处理 Coin 枚举一样!我们不再比较硬币,而是比较 Option<T> 的成员,但 match 表达式的工作方式保持不变。
假设我们想编写一个函数,接受一个 Option<i32>,如果内部有值,就将该值加 1。如果内部没有值,函数应返回 None 值,不尝试执行任何操作。
得益于 match,这个函数非常容易编写,如示例 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);
}
Option<i32> 使用 match 表达式的函数让我们更详细地检查 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,并用总计值 6 创建一个新的 Some 值。
现在让我们考虑示例 6-5 中 plus_one 的第二次调用,此时 x 是 None。我们进入 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 的另一个方面:分支的模式必须覆盖所有可能性。考虑以下这个有 bug 且无法编译的 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 的情况,所以这段代码会导致 bug。幸运的是,这是 Rust 能够捕获的 bug。如果我们尝试编译这段代码,会得到以下错误:
$ 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
--> /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/core/src/option.rs:593:1
::: /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/core/src/option.rs:597:5
|
= note: 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 中的匹配是穷尽的(exhaustive):我们必须穷举所有可能性,代码才能有效。特别是在 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) {}
}
对于前两个分支,模式是字面量值 3 和 7。对于覆盖所有其他可能值的最后一个分支,模式是我们选择命名为 other 的变量。为 other 分支运行的代码通过将该变量传递给 move_player 函数来使用它。
即使我们没有列出 u8 可能具有的所有值,这段代码也能编译,因为最后一个模式将匹配所有未被特别列出的值。这个通配(catch-all)模式满足了 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 let 和 let...else 的简洁控制流
使用 if let 和 let...else 实现简洁控制流
if let 语法让你可以将 if 和 let 组合成一种更简洁的方式,来处理匹配某个模式的值,同时忽略其余的情况。考虑示例 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 时执行代码的 match如果值是 Some,我们通过在模式中将值绑定到变量 max 来打印出 Some 变体中的值。我们不想对 None 值做任何处理。为了满足 match 表达式的要求,我们不得不在只处理一个变体之后添加 _ => (),这是一段烦人的样板代码。
换一种方式,我们可以使用 if let 来更简短地编写这段代码。以下代码的行为与示例 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 所强制的穷尽性检查,它能确保你不会遗漏任何情况。选择 match 还是 if let 取决于你在特定场景中要做什么,以及用简洁性换取穷尽性检查是否是合适的取舍。
换句话说,你可以把 if let 看作 match 的语法糖,它在值匹配某个模式时运行代码,然后忽略所有其他值。
我们可以在 if let 中包含一个 else。与 else 搭配的代码块等同于与 match 表达式中 _ 分支搭配的代码块,而这个 match 表达式就等价于 if let 和 else。回忆一下示例 6-4 中 Coin 枚举的定义,其中 Quarter 变体还持有一个 UsState 值。如果我们想要统计所有非 25 美分硬币的数量,同时报告 25 美分硬币所属的州,可以使用 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 let 和 else 表达式,像这样:
#[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 值的硬币的例子,如果我们想根据 25 美分硬币上的州有多古老来说些有趣的话,我们可以在 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 变量,如示例 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 内的条件语句来检查某个州是否在 1900 年就已存在这样确实能完成任务,但它把工作推到了 if let 语句的主体内部。如果要做的工作更复杂,可能就很难看清顶层分支之间的关系了。我们也可以利用表达式会产生值这一特性,要么从 if let 中产生 state,要么提前返回,如示例 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 来产生一个值或提前返回不过这样读起来也有点别扭!if let 的一个分支产生一个值,而另一个分支则直接从函数返回。
为了让这种常见模式更优雅地表达,Rust 提供了 let...else。let...else 语法在左侧接受一个模式,在右侧接受一个表达式,与 if let 非常相似,但它没有 if 分支,只有 else 分支。如果模式匹配成功,它会在外层作用域中绑定模式中的值。如果模式_不_匹配,程序将进入 else 分支,而该分支必须从函数返回。
在示例 6-9 中,你可以看到使用 let...else 替代 if let 后,示例 6-8 的代码变成了什么样子。
#[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}");
}
}
let...else 来使函数的控制流更清晰注意,这样代码就保持在函数主体的“快乐路径“上了,不会像 if let 那样让两个分支有截然不同的控制流。
如果你遇到程序的逻辑用 match 来表达过于冗长的情况,记住 if let 和 let...else 也是你 Rust 工具箱中的好帮手。
总结
我们已经介绍了如何使用枚举来创建可以是一组枚举值之一的自定义类型。我们展示了标准库的 Option<T> 类型如何帮助你利用类型系统来防止错误。当枚举值内部包含数据时,你可以使用 match 或 if let 来提取和使用这些值,具体取决于你需要处理多少种情况。
你的 Rust 程序现在可以使用结构体和枚举来表达领域中的概念了。创建自定义类型用于你的 API 可以确保类型安全:编译器会确保你的函数只接收到每个函数所期望的类型的值。
为了向用户提供一个组织良好、易于使用且只暴露用户所需内容的 API,接下来让我们转向 Rust 的模块系统。
包、Crate 和模块
随着你编写的程序规模越来越大,组织代码将变得愈发重要。通过将相关功能分组,并将具有不同特性的代码分离开来,你可以清晰地知道到哪里去找实现某个特定功能的代码,以及到哪里去修改某个功能的行为。
到目前为止,我们编写的程序都在一个文件的一个模块中。随着项目的增长,你应该通过将代码拆分为多个模块、再拆分为多个文件来组织代码。一个包(package)可以包含多个二进制 crate,以及一个可选的库 crate。随着包的增长,你可以将其中的部分提取为独立的 crate,使其成为外部依赖。本章将涵盖所有这些技术。对于由一组相互关联、共同演进的包组成的超大型项目,Cargo 提供了工作空间(workspace)功能,我们将在第 14 章的“Cargo 工作空间”中介绍。
我们还将讨论封装实现细节,这使你能够在更高层次上复用代码:一旦你实现了某个操作,其他代码就可以通过其公共接口来调用你的代码,而无需了解实现的内部工作原理。你编写代码的方式决定了哪些部分是公开的、可供其他代码使用,哪些部分是私有的实现细节、你保留随时修改的权利。这是另一种减少你需要记在脑中的细节数量的方法。
一个相关的概念是作用域(scope):代码所处的嵌套上下文中有一组被定义为“在作用域内“的名称。在阅读、编写和编译代码时,程序员和编译器都需要知道某个特定位置的特定名称是指变量、函数、结构体、枚举、模块、常量还是其他条目,以及该条目的含义。你可以创建作用域,并改变哪些名称在作用域内或作用域外。同一个作用域中不能有两个同名的条目;不过有一些工具可以用来解决名称冲突。
Rust 提供了一系列功能,让你能够管理代码的组织结构,包括哪些细节是公开的、哪些细节是私有的,以及程序中每个作用域里有哪些名称。这些功能有时被统称为模块系统(module system),包括:
- 包(Package):Cargo 的一个功能,让你构建、测试和分享 crate
- Crate:一个由模块组成的树形结构,可以生成库或可执行文件
- 模块(Module)和 use:让你控制路径的组织结构、作用域和私有性
- 路径(Path):一种命名条目的方式,例如结构体、函数或模块
在本章中,我们将涵盖所有这些功能,讨论它们之间如何交互,并解释如何使用它们来管理作用域。读完本章后,你应该对模块系统有扎实的理解,并能够熟练地使用作用域!
包和 crate
包和 Crate
我们要介绍的模块系统的第一部分是包(package)和 crate。
crate 是 Rust 编译器一次处理的最小代码单元。即使你运行的是 rustc 而不是 cargo,并且只传入一个源代码文件(就像我们在第 1 章“Rust 程序基础”中所做的那样),编译器也会将该文件视为一个 crate。Crate 可以包含模块,而这些模块可以定义在其他文件中,并与该 crate 一起编译,我们将在接下来的章节中看到这一点。
Crate 有两种形式:二进制 crate 和库 crate。二进制 crate(binary crate)是可以编译为可执行文件并运行的程序,例如命令行程序或服务器。每个二进制 crate 都必须有一个名为 main 的函数,用于定义可执行文件运行时的行为。到目前为止,我们创建的所有 crate 都是二进制 crate。
库 crate(library crate)没有 main 函数,也不会编译为可执行文件。它们定义的功能旨在与多个项目共享。例如,我们在第 2 章中使用的 rand crate 提供了生成随机数的功能。大多数时候,Rustacean 说“crate“时指的就是库 crate,他们将“crate“与通用编程概念中的“库“(library)互换使用。
crate 根(crate root)是一个源文件,Rust 编译器从它开始编译,并构成你的 crate 的根模块(我们将在“使用模块控制作用域和私有性”中深入讲解模块)。
包(package)是一个或多个 crate 的集合,提供一组功能。包中包含一个 Cargo.toml 文件,描述如何构建这些 crate。Cargo 本身实际上就是一个包,其中包含你一直用来构建代码的命令行工具的二进制 crate。Cargo 包还包含一个库 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 创建了什么。在 my-project 目录中,有一个 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.rs 和 src/lib.rs,那么它有两个 crate:一个二进制 crate 和一个库 crate,两者都与包同名。一个包可以通过在 src/bin 目录下放置文件来拥有多个二进制 crate:每个文件都将是一个单独的二进制 crate。
使用模块控制作用域和私有性
使用模块控制作用域和私有性
在本节中,我们将讨论模块以及模块系统的其他部分,即允许你为项命名的路径(path);将路径引入作用域的 use 关键字;以及使项变为公有的 pub 关键字。我们还将讨论 as 关键字、外部包和 glob 运算符。
模块速查表
在深入了解模块和路径的细节之前,这里提供一个关于模块、路径、use 关键字和 pub 关键字在编译器中如何工作,以及大多数开发者如何组织代码的快速参考。我们将在本章中逐一介绍这些规则的示例,但这是一个很好的参考,可以帮助你回忆模块的工作方式。
- 从 crate 根开始:编译 crate 时,编译器首先在 crate 根文件(通常库 crate 是 src/lib.rs,二进制 crate 是 src/main.rs)中查找要编译的代码。
- 声明模块:在 crate 根文件中,你可以声明新模块;比如你用
mod garden;声明了一个“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找到。 - 私有与公有:模块内的代码默认对其父模块是私有的。要使模块公有,请使用
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。我们将定义函数的签名但留空函数体,以便专注于代码的组织而非餐厅的实现。
在餐饮业中,餐厅的某些部分被称为前台(front of house),其他部分被称为后台(back of house)。前台是顾客所在的区域;这包括领位员安排顾客就座、服务员接受点单和收款、以及调酒师调制饮品的地方。后台是厨师在厨房工作、洗碗工清洁餐具、以及经理处理行政事务的地方。
为了以这种方式组织我们的 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() {}
}
}
front_of_house 模块,这些模块又包含函数我们使用 mod 关键字后跟模块名称来定义模块(本例中为 front_of_house)。模块的主体放在花括号内。在模块内部,我们可以放置其他模块,如本例中的 hosting 和 serving 模块。模块还可以包含其他项的定义,如结构体、枚举、常量、trait,以及如示例 7-1 中的函数。
通过使用模块,我们可以将相关的定义组织在一起,并说明它们为什么相关。使用这段代码的程序员可以根据分组来导航代码,而不必通读所有定义,从而更容易找到与他们相关的定义。向这段代码添加新功能的程序员也会知道应该把代码放在哪里,以保持程序的组织性。
前面我们提到 src/main.rs 和 src/lib.rs 被称为 crate 根。之所以这样命名,是因为这两个文件中任何一个的内容都会在 crate 模块结构的根部形成一个名为 crate 的模块,这个结构被称为模块树(module tree)。
示例 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 内部。这棵树还展示了一些模块是兄弟(sibling)关系,意味着它们定义在同一个模块中;hosting 和 serving 是定义在 front_of_house 中的兄弟模块。如果模块 A 包含在模块 B 内部,我们说模块 A 是模块 B 的子模块,模块 B 是模块 A 的父模块。注意,整个模块树的根是名为 crate 的隐式模块。
模块树可能会让你联想到计算机上文件系统的目录树;这是一个非常恰当的类比!就像文件系统中的目录一样,你使用模块来组织代码。就像目录中的文件一样,我们需要一种方法来找到我们的模块。
用路径引用模块树中的项
引用模块树中条目的路径
为了告诉 Rust 在模块树中的哪里可以找到一个条目,我们使用路径,就像在文件系统中导航时使用路径一样。要调用一个函数,我们需要知道它的路径。
路径有两种形式:
- 绝对路径(absolute path)是从 crate 根开始的完整路径;对于来自外部 crate 的代码,绝对路径以 crate 名称开头;对于来自当前 crate 的代码,则以字面量
crate开头。 - 相对路径(relative path)从当前模块开始,使用
self、super或当前模块中的标识符。
绝对路径和相对路径后面都跟着一个或多个由双冒号(::)分隔的标识符。
回到示例 7-1,假设我们想调用 add_to_waitlist 函数。这等同于在问:add_to_waitlist 函数的路径是什么?示例 7-3 包含了示例 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();
}
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 调用的绝对路径将保持不变,但相对路径则需要更新。我们通常倾向于使用绝对路径,因为我们更可能希望独立地移动代码定义和条目调用。
让我们尝试编译示例 7-3,看看为什么它还不能编译!我们得到的错误如示例 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 关键字暴露路径
让我们回到示例 7-4 中告诉我们 hosting 模块是私有的那个错误。我们希望父模块中的 eat_at_restaurant 函数能够访问子模块中的 add_to_waitlist 函数,因此我们用 pub 关键字标记 hosting 模块,如示例 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();
}
hosting 模块声明为 pub 以便在 eat_at_restaurant 中使用不幸的是,示例 7-5 中的代码仍然会产生编译器错误,如示例 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 关键字只是让其祖先模块中的代码可以引用它,而不是访问其内部代码。因为模块是容器,仅仅将模块设为公有并没有太大用处;我们还需要进一步选择将模块内的一个或多个条目也设为公有。
示例 7-6 中的错误表明 add_to_waitlist 函数是私有的。私有性规则适用于结构体、枚举、函数和方法,也适用于模块。
让我们也通过在 add_to_waitlist 函数定义前添加 pub 关键字来将其设为公有,如示例 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();
}
mod hosting 和 fn add_to_waitlist 添加 pub 关键字,使我们可以从 eat_at_restaurant 调用该函数现在代码可以编译了!为了理解为什么添加 pub 关键字后我们就能在 eat_at_restaurant 中使用这些路径(就私有性规则而言),让我们来看看绝对路径和相对路径。
在绝对路径中,我们从 crate 开始,即我们 crate 模块树的根。front_of_house 模块定义在 crate 根中。虽然 front_of_house 不是公有的,但因为 eat_at_restaurant 函数与 front_of_house 定义在同一个模块中(也就是说,eat_at_restaurant 和 front_of_house 是兄弟),我们可以从 eat_at_restaurant 引用 front_of_house。接下来是标记了 pub 的 hosting 模块。我们可以访问 hosting 的父模块,所以我们可以访问 hosting。最后,add_to_waitlist 函数标记了 pub,而且我们可以访问它的父模块,所以这个函数调用是有效的!
在相对路径中,逻辑与绝对路径相同,只是第一步不同:路径不是从 crate 根开始,而是从 front_of_house 开始。front_of_house 模块与 eat_at_restaurant 定义在同一个模块中,所以从 eat_at_restaurant 所在模块开始的相对路径是有效的。然后,因为 hosting 和 add_to_waitlist 都标记了 pub,路径的其余部分也是有效的,这个函数调用是合法的!
如果你计划分享你的库 crate 以便其他项目可以使用你的代码,那么你的公共 API 就是你与 crate 用户之间的契约,决定了他们如何与你的代码交互。围绕管理公共 API 的变更有许多考量,以便让人们更容易依赖你的 crate。这些考量超出了本书的范围;如果你对这个话题感兴趣,请参阅 Rust API 指南。
同时包含二进制和库的包的最佳实践
我们提到过,一个包可以同时包含一个 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 允许我们引用一个我们知道在父模块中的条目,当模块与父模块密切相关但父模块将来可能会被移到模块树的其他位置时,这可以使重新组织模块树更加容易。
考虑示例 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() {}
}
super 开头的相对路径调用函数fix_incorrect_order 函数在 back_of_house 模块中,所以我们可以使用 super 进入 back_of_house 的父模块,在这个例子中就是 crate,即根模块。从那里,我们查找 deliver_order 并找到了它。成功!我们认为 back_of_house 模块和 deliver_order 函数很可能会保持彼此之间的关系,并且如果我们决定重新组织 crate 的模块树,它们会一起移动。因此,我们使用了 super,这样如果这段代码被移到不同的模块中,将来需要更新代码的地方会更少。
将结构体和枚举设为公有
我们也可以使用 pub 将结构体和枚举指定为公有,但 pub 与结构体和枚举一起使用时有一些额外的细节。如果我们在结构体定义前使用 pub,我们会使结构体公有,但结构体的字段仍然是私有的。我们可以逐个决定每个字段是否公有。在示例 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,如示例 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 中使用 Soup 和 Salad 变体。
枚举的变体如果不是公有的,枚举就不太有用;如果每次都必须为所有枚举变体标注 pub 会很烦人,所以枚举变体默认就是公有的。结构体通常在其字段不公有的情况下也很有用,所以结构体字段遵循默认一切都是私有的通用规则,除非用 pub 标注。
还有一种涉及 pub 的情况我们尚未介绍,那就是我们最后一个模块系统功能:use 关键字。我们将先单独介绍 use,然后展示如何组合使用 pub 和 use。
使用 use 关键字将路径引入作用域
使用 use 关键字将路径引入作用域
每次调用函数都要写出完整路径,未免让人觉得不便且重复。在示例 7-7 中,无论我们选择 add_to_waitlist 函数的绝对路径还是相对路径,每次调用时都必须指定 front_of_house 和 hosting。好在有一种简化方式:我们可以使用 use 关键字为路径创建一个快捷方式,然后在作用域内的其他地方使用更短的名称。
在示例 7-11 中,我们将 crate::front_of_house::hosting 模块引入了 eat_at_restaurant 函数的作用域,这样在 eat_at_restaurant 中调用 add_to_waitlist 函数时,只需指定 hosting::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 将模块引入作用域在作用域中添加 use 和路径,类似于在文件系统中创建符号链接。通过在 crate 根中添加 use crate::front_of_house::hosting,hosting 就成为该作用域中的有效名称,就好像 hosting 模块是在 crate 根中定义的一样。通过 use 引入作用域的路径同样会检查私有性,与其他路径一样。
注意,use 只在其所在的特定作用域内创建快捷方式。示例 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();
}
}
use 语句只在其所在的作用域内有效。编译器错误表明,快捷方式在 customer 模块内不再适用:
$ cargo build
Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0433]: failed to resolve: use of unresolved module or unlinked crate `hosting`
--> src/lib.rs:11:9
|
11 | hosting::add_to_waitlist();
| ^^^^^^^ use of unresolved module or unlinked crate `hosting`
|
= help: if you wanted to use a crate named `hosting`, use `cargo add hosting` to add it to your `Cargo.toml`
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 路径
在示例 7-11 中,你可能会疑惑:为什么我们指定的是 use crate::front_of_house::hosting,然后在 eat_at_restaurant 中调用 hosting::add_to_waitlist,而不是将 use 路径一直写到 add_to_waitlist 函数本身来达到同样的效果呢?如示例 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();
}
use 将 add_to_waitlist 函数引入作用域,这不是惯用写法虽然示例 7-11 和示例 7-13 完成的是同样的任务,但示例 7-11 才是使用 use 将函数引入作用域的惯用方式。通过 use 将函数的父模块引入作用域,意味着我们在调用函数时必须指定父模块。在调用函数时指定父模块,可以清楚地表明该函数不是本地定义的,同时又最大限度地减少了完整路径的重复。而示例 7-13 中的代码则不清楚 add_to_waitlist 是在哪里定义的。
另一方面,当使用 use 引入结构体、枚举和其他项时,惯用做法是指定完整路径。示例 7-14 展示了将标准库的 HashMap 结构体引入二进制 crate 作用域的惯用方式。
use std::collections::HashMap;
fn main() {
let mut map = HashMap::new();
map.insert(1, 2);
}
HashMap 引入作用域这个惯例背后没有什么特别的原因:这只是已经形成的约定,大家已经习惯了以这种方式阅读和编写 Rust 代码。
这个惯例的例外情况是:如果我们要用 use 语句将两个同名的项引入作用域,因为 Rust 不允许这样做。示例 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::Result 和 use std::io::Result,那么同一作用域中就会有两个 Result 类型,Rust 就无法知道我们使用 Result 时指的是哪一个。
使用 as 关键字提供新名称
使用 use 将两个同名类型引入同一作用域还有另一种解决方案:在路径之后,我们可以指定 as 和一个新的本地名称,即类型的别名(alias)。示例 7-16 展示了另一种编写示例 7-15 代码的方式,通过 as 重命名了两个 Result 类型中的一个。
use std::fmt::Result;
use std::io::Result as IoResult;
fn function1() -> Result {
// --snip--
Ok(())
}
fn function2() -> IoResult<()> {
// --snip--
Ok(())
}
as 关键字重命名引入作用域的类型在第二个 use 语句中,我们为 std::io::Result 类型选择了新名称 IoResult,这样就不会与同样引入作用域的 std::fmt 中的 Result 冲突。示例 7-15 和示例 7-16 都是惯用写法,选择哪种由你决定!
使用 pub use 重导出名称
当我们使用 use 关键字将名称引入作用域时,该名称在新作用域中是私有的。为了让外部代码也能引用该名称,就好像它是在该作用域中定义的一样,我们可以将 pub 和 use 组合使用。这种技术被称为重导出(re-exporting),因为我们不仅将一个项引入了作用域,还使该项可以被其他代码引入到它们的作用域中。
示例 7-17 展示了将示例 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();
}
pub use 使名称可以从新的作用域被任何代码使用在此更改之前,外部代码需要使用路径 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 章的“导出方便的公有 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(标准库 crate 的名称)开头的绝对路径。
使用嵌套路径清理 use 列表
如果我们要使用同一个 crate 或同一个模块中定义的多个项,逐行列出每个项会占用文件中大量的纵向空间。例如,在示例 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!"),
}
}
我们可以改用嵌套路径在一行中将相同的项引入作用域。做法是指定路径的公共部分,后跟两个冒号,然后用花括号括起路径中不同的部分,如示例 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 语句时非常有用。例如,示例 7-19 展示了两个 use 语句:一个将 std::io 引入作用域,另一个将 std::io::Write 引入作用域。
use std::io;
use std::io::Write;
use 语句,其中一个是另一个的子路径这两个路径的公共部分是 std::io,这也是第一个路径的完整形式。要将这两个路径合并为一个 use 语句,我们可以在嵌套路径中使用 self,如示例 7-20 所示。
use std::io::{self, Write};
use 语句这一行将 std::io 和 std::io::Write 同时引入了作用域。
使用 glob 运算符导入所有项
如果我们想将一个路径中定义的所有公有项都引入作用域,可以在路径后面加上 * glob 运算符:
#![allow(unused)]
fn main() {
use std::collections::*;
}
这个 use 语句将 std::collections 中定义的所有公有项引入当前作用域。使用 glob 运算符时要小心!glob 会使我们更难分辨作用域中有哪些名称,以及程序中使用的某个名称是在哪里定义的。此外,如果依赖更改了其定义,你导入的内容也会随之改变,这可能导致在升级依赖时出现编译错误——例如,当依赖新增了一个与你在同一作用域中的定义同名的项时。
glob 运算符常用于测试场景,将所有待测试的内容引入 tests 模块;我们将在第 11 章的“如何编写测试”中讨论这一点。glob 运算符有时也作为 prelude 模式的一部分使用:更多关于该模式的信息,请参阅标准库文档。
将模块拆分到不同文件
将模块拆分到不同文件
到目前为止,本章中的所有示例都在一个文件中定义了多个模块。当模块变大时,你可能希望将它们的定义移到单独的文件中,以便更容易导航代码。
例如,让我们从示例 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 就包含如示例 7-21 所示的代码。注意,在我们创建示例 7-22 中的 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();
}
front_of_house 模块,其主体将在 src/front_of_house.rs 中接下来,将花括号内的代码放入一个名为 src/front_of_house.rs 的新文件中,如示例 7-22 所示。编译器知道要查找这个文件,因为它在 crate 根中遇到了名为 front_of_house 的模块声明。
pub mod hosting {
pub fn add_to_waitlist() {}
}
front_of_house 模块内部的定义注意,你只需要在模块树中使用 mod 声明加载文件一次。一旦编译器知道该文件是项目的一部分(并且根据你放置 mod 语句的位置知道代码在模块树中的位置),项目中的其他文件应该使用路径来引用已加载文件的代码,如“引用模块项的路径”部分所述。换句话说,mod 不是你在其他编程语言中可能见过的“include“操作。
接下来,我们将 hosting 模块也提取到它自己的文件中。过程略有不同,因为 hosting 是 front_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(较旧的风格,仍然支持的路径)
对于名为 hosting 的 front_of_house 子模块,编译器会在以下位置查找模块的代码:
- 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 的标准库包含许多非常有用的数据结构,称为集合(collections)。大多数其他数据类型表示一个特定的值,而集合可以包含多个值。与内置的数组和元组类型不同,这些集合指向的数据存储在堆上,这意味着数据量不需要在编译时确定,并且可以随着程序运行而增长或缩小。每种集合都有不同的能力和开销,根据当前情况选择合适的集合是你会随着时间逐渐培养的技能。在本章中,我们将讨论 Rust 程序中非常常用的三种集合:
- vector 允许你将多个值依次存储在一起。
- 字符串(string)是字符的集合。我们之前提到过
String类型,但在本章中我们将深入讨论它。 - 哈希映射(hash map)允许你将值与特定的键关联起来。它是更通用的数据结构映射(map)的一种特定实现。
要了解标准库提供的其他类型的集合,请参阅文档。
我们将讨论如何创建和更新 vector、字符串和哈希映射,以及每种集合的特别之处。
使用 Vector 存储值列表
使用 Vector 存储值列表
我们要看的第一个集合类型是 Vec<T>,也称为 vector。Vector 允许你在单个数据结构中存储多个值,所有值在内存中彼此相邻排列。Vector 只能存储相同类型的值。当你有一组条目的列表时,它们非常有用,例如文件中的文本行或购物车中商品的价格。
创建新的 Vector
要创建一个新的空 vector,我们调用 Vec::new 函数,如示例 8-1 所示。
fn main() {
let v: Vec<i32> = Vec::new();
}
i32 类型的值注意这里我们添加了类型标注。因为我们没有向这个 vector 中插入任何值,Rust 不知道我们打算存储什么类型的元素。这是一个重要的点。Vector 是使用泛型(generics)实现的;我们将在第 10 章介绍如何在自己的类型中使用泛型。现在你只需要知道,标准库提供的 Vec<T> 类型可以存储任何类型。当我们创建一个用于存储特定类型的 vector 时,可以在尖括号中指定类型。在示例 8-1 中,我们告诉 Rust,v 中的 Vec<T> 将存储 i32 类型的元素。
更常见的情况是,你会用初始值创建 Vec<T>,Rust 会推断出你想存储的值的类型,所以你很少需要做这种类型标注。Rust 提供了便捷的 vec! 宏,它会创建一个包含你给定值的新 vector。示例 8-2 创建了一个包含值 1、2 和 3 的新 Vec<i32>。整数类型是 i32,因为这是默认的整数类型,正如我们在第 3 章的“数据类型”部分讨论的那样。
fn main() {
let v = vec![1, 2, 3];
}
因为我们给出了初始的 i32 值,Rust 可以推断出 v 的类型是 Vec<i32>,所以类型标注不是必需的。接下来,我们来看看如何修改 vector。
更新 Vector
要创建一个 vector 然后向其中添加元素,可以使用 push 方法,如示例 8-3 所示。
fn main() {
let mut v = Vec::new();
v.push(5);
v.push(6);
v.push(7);
v.push(8);
}
push 方法向 vector 添加值与任何变量一样,如果我们想要能够改变它的值,需要使用 mut 关键字使其可变,正如第 3 章所讨论的。我们放入的数字都是 i32 类型,Rust 从数据中推断出了这一点,所以我们不需要 Vec<i32> 标注。
读取 Vector 的元素
有两种方式可以引用 vector 中存储的值:通过索引或使用 get 方法。在下面的示例中,我们标注了从这些函数返回的值的类型,以便更加清晰。
示例 8-4 展示了访问 vector 中值的两种方法:索引语法和 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."),
}
}
get 方法访问 vector 中的元素这里有几个细节需要注意。我们使用索引值 2 来获取第三个元素,因为 vector 使用从零开始的数字索引。使用 & 和 [] 会给我们一个该索引位置元素的引用。当我们使用 get 方法并传入索引作为参数时,我们得到一个 Option<&T>,可以与 match 一起使用。
Rust 提供了这两种引用元素的方式,以便你可以选择当尝试使用超出现有元素范围的索引值时程序的行为。举个例子,让我们看看当我们有一个包含五个元素的 vector,然后尝试用每种方法访问索引 100 处的元素时会发生什么,如示例 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,因为它引用了一个不存在的元素。当你希望程序在尝试访问 vector 末尾之后的元素时崩溃,这个方法最为适用。
当 get 方法接收到一个超出 vector 范围的索引时,它会返回 None 而不会 panic。如果在正常情况下偶尔可能会访问超出 vector 范围的元素,你应该使用这个方法。你的代码随后将包含处理 Some(&element) 或 None 的逻辑,正如第 6 章所讨论的。例如,索引可能来自用户输入的数字。如果他们不小心输入了一个过大的数字,程序得到了 None 值,你可以告诉用户当前 vector 中有多少个元素,并给他们另一次输入有效值的机会。这比因为一个输入错误就让程序崩溃要友好得多!
当程序拥有一个有效的引用时,借用检查器会执行所有权和借用规则(在第 4 章中介绍)来确保这个引用以及对 vector 内容的任何其他引用保持有效。回忆一下那条规则:你不能在同一作用域中同时拥有可变引用和不可变引用。这条规则适用于示例 8-6,在那里我们持有一个对 vector 中第一个元素的不可变引用,并尝试向末尾添加一个元素。如果我们还试图在函数后面引用那个元素,这个程序将无法工作。
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
示例 8-6 中的代码看起来应该可以工作:为什么对第一个元素的引用要关心 vector 末尾的变化呢?这个错误是由于 vector 的工作方式导致的:因为 vector 将值在内存中彼此相邻存储,如果在 vector 当前存储位置没有足够的空间将所有元素放在一起,向 vector 末尾添加新元素可能需要分配新的内存并将旧元素复制到新空间。在这种情况下,对第一个元素的引用将指向已释放的内存。借用规则防止程序陷入这种情况。
注意:关于
Vec<T>类型的更多实现细节,请参阅 “The Rustonomicon”。
遍历 Vector 中的值
要依次访问 vector 中的每个元素,我们会遍历所有元素,而不是使用索引逐个访问。示例 8-7 展示了如何使用 for 循环获取 i32 值的 vector 中每个元素的不可变引用并打印它们。
fn main() {
let v = vec![100, 32, 57];
for i in &v {
println!("{i}");
}
}
for 循环遍历元素来打印 vector 中的每个元素我们也可以遍历可变 vector 中每个元素的可变引用,以便对所有元素进行修改。示例 8-8 中的 for 循环会给每个元素加上 50。
fn main() {
let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50;
}
}
要修改可变引用所指向的值,我们必须使用 * 解引用运算符获取 i 中的值,然后才能使用 += 运算符。我们将在第 15 章的“追踪指针指向的值”部分更多地讨论解引用运算符。
无论是不可变遍历还是可变遍历 vector,都是安全的,这得益于借用检查器的规则。如果我们试图在示例 8-7 和示例 8-8 的 for 循环体中插入或删除元素,我们会得到一个类似于示例 8-6 中代码所产生的编译器错误。for 循环持有的对 vector 的引用会阻止对整个 vector 的同时修改。
使用枚举存储多种类型
Vector 只能存储相同类型的值。这可能会带来不便;确实存在需要存储不同类型元素列表的场景。幸运的是,枚举的变体定义在同一个枚举类型下,所以当我们需要用一个类型来表示不同类型的元素时,可以定义并使用一个枚举!
例如,假设我们想从电子表格的一行中获取值,其中该行的某些列包含整数,某些包含浮点数,某些包含字符串。我们可以定义一个枚举,其变体将持有不同的值类型,所有枚举变体都被视为同一类型:即该枚举的类型。然后我们可以创建一个 vector 来存储该枚举,从而最终存储不同的类型。我们在示例 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 需要在编译时知道 vector 中会有哪些类型,这样它才能确切地知道在堆上需要多少内存来存储每个元素。我们还必须明确这个 vector 中允许哪些类型。如果 Rust 允许 vector 存储任何类型,那么一个或多个类型可能会导致对 vector 元素执行的操作出错。使用枚举加上 match 表达式意味着 Rust 将在编译时确保处理了每种可能的情况,正如第 6 章所讨论的。
如果你不知道程序在运行时会获取哪些类型来存储在 vector 中,枚举技术就不适用了。相反,你可以使用 trait 对象,我们将在第 18 章中介绍。
现在我们已经讨论了使用 vector 的一些最常见方式,请务必查阅 API 文档以了解标准库在 Vec<T> 上定义的所有有用方法。例如,除了 push 之外,pop 方法会移除并返回最后一个元素。
丢弃 Vector 时也会丢弃其元素
与任何其他 struct 一样,vector 在离开作用域时会被释放,如示例 8-10 所示。
fn main() {
{
let v = vec![1, 2, 3, 4];
// do stuff with v
} // <- v goes out of scope and is freed here
}
当 vector 被丢弃时,它的所有内容也会被丢弃,这意味着它持有的整数将被清理。借用检查器确保对 vector 内容的任何引用只在 vector 本身有效时才被使用。
让我们继续看下一个集合类型:String!
使用字符串存储 UTF-8 编码的文本
使用字符串存储 UTF-8 编码的文本
我们在第四章讨论过字符串,现在来更深入地了解它。Rust 新手通常会在字符串上遇到困难,原因有三:Rust 倾向于暴露可能的错误、字符串这种数据结构比许多程序员想象的更复杂,以及 UTF-8 编码。当你从其他编程语言转过来时,这些因素结合在一起会让人觉得很棘手。
我们在集合的语境下讨论字符串,是因为字符串本质上是字节的集合,外加一些在将这些字节解释为文本时提供有用功能的方法。在本节中,我们将讨论 String 上那些每种集合类型都有的操作,比如创建、更新和读取。我们还将讨论 String 与其他集合的不同之处,特别是由于人和计算机对 String 数据的解读方式不同,对 String 进行索引会变得很复杂。
什么是字符串
我们先来明确“字符串“这个术语的含义。Rust 的核心语言中只有一种字符串类型,即字符串切片(string slice)str,通常以借用形式 &str 出现。在第四章中,我们讨论过字符串切片,它是对存储在其他地方的 UTF-8 编码字符串数据的引用。例如,字符串字面值存储在程序的二进制文件中,因此它们是字符串切片。
String 类型由 Rust 标准库提供,而非内置于核心语言中,它是一种可增长、可变、拥有所有权的 UTF-8 编码字符串类型。当 Rustacean 提到 Rust 中的“字符串“时,他们可能指的是 String 或字符串切片 &str 类型,而不仅仅是其中一种。虽然本节主要讨论 String,但这两种类型在 Rust 标准库中都被大量使用,并且 String 和字符串切片都是 UTF-8 编码的。
新建字符串
许多可用于 Vec<T> 的操作同样适用于 String,因为 String 实际上是对字节向量的封装,附加了一些额外的保证、限制和功能。一个在 Vec<T> 和 String 上工作方式相同的函数例子是用于创建实例的 new 函数,如示例 8-11 所示。
fn main() {
let mut s = String::new();
}
String这行代码创建了一个名为 s 的新空字符串,之后我们可以向其中加载数据。通常我们会有一些初始数据来初始化字符串。为此,我们使用 to_string 方法,该方法可用于任何实现了 Display trait 的类型,字符串字面值就实现了该 trait。示例 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();
}
to_string 方法从字符串字面值创建 String这段代码创建了一个包含 initial contents 的字符串。
我们也可以使用 String::from 函数从字符串字面值创建 String。示例 8-13 中的代码等价于示例 8-12 中使用 to_string 的代码。
fn main() {
let s = String::from("initial contents");
}
String::from 函数从字符串字面值创建 String因为字符串的用途非常广泛,我们可以使用许多不同的泛型 API 来操作字符串,这给了我们很多选择。其中一些看起来可能是多余的,但它们都有各自的用武之地!在这个例子中,String::from 和 to_string 做的是同样的事情,所以选择哪个取决于风格和可读性偏好。
请记住,字符串是 UTF-8 编码的,所以我们可以在其中包含任何正确编码的数据,如示例 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_str 或 push 追加
我们可以使用 push_str 方法来追加一个字符串切片,从而使 String 增长,如示例 8-15 所示。
fn main() {
let mut s = String::from("foo");
s.push_str("bar");
}
push_str 方法向 String 追加字符串切片执行这两行代码后,s 将包含 foobar。push_str 方法接受字符串切片作为参数,因为我们不一定需要获取参数的所有权。例如,在示例 8-16 的代码中,我们希望在将 s2 的内容追加到 s1 之后仍然能够使用 s2。
fn main() {
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {s2}");
}
String 后继续使用该字符串切片如果 push_str 方法获取了 s2 的所有权,我们就无法在最后一行打印它的值了。不过,这段代码如我们所期望的那样正常工作!
push 方法接受一个单独的字符作为参数,并将其添加到 String 中。示例 8-17 使用 push 方法向 String 添加字母 l。
fn main() {
let mut s = String::from("lo");
s.push('l');
}
push 向 String 值添加一个字符执行后,s 将包含 lol。
使用 + 运算符或 format! 宏拼接
通常你会想要将两个已有的字符串组合在一起。一种方法是使用 + 运算符,如示例 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
}
+ 运算符将两个 String 值组合为一个新的 String 值字符串 s3 将包含 Hello, world!。s1 在相加后不再有效的原因,以及我们使用 s2 的引用的原因,都与使用 + 运算符时调用的方法签名有关。+ 运算符使用了 add 方法,其签名大致如下:
fn add(self, s: &str) -> String {
在标准库中,你会看到 add 是使用泛型和关联类型定义的。这里我们替换为了具体类型,这就是用 String 值调用此方法时实际发生的情况。我们将在第十章讨论泛型。这个签名为我们提供了理解 + 运算符棘手之处所需的线索。
首先,s2 前面有一个 &,意味着我们将第二个字符串的引用与第一个字符串相加。这是因为 add 函数中的 s 参数:我们只能将字符串切片加到 String 上,不能将两个 String 值直接相加。但是等等——&s2 的类型是 &String,而不是 add 第二个参数所指定的 &str。那么为什么示例 8-18 能够编译呢?
我们之所以能在 add 调用中使用 &s2,是因为编译器可以将 &String 参数强制转换(coerce)为 &str。当我们调用 add 方法时,Rust 使用了解引用强制转换(deref coercion),在这里将 &s2 转换为 &s2[..]。我们将在第十五章更深入地讨论解引用强制转换。因为 add 没有获取 s 参数的所有权,所以 s2 在此操作后仍然是一个有效的 String。
其次,我们可以从签名中看到 add 获取了 self 的所有权,因为 self 前面没有 &。这意味着示例 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-toe。format! 宏的工作方式类似于 println!,但它不是将输出打印到屏幕上,而是返回一个包含内容的 String。使用 format! 的代码版本更易于阅读,而且 format! 宏生成的代码使用引用,因此这个调用不会获取任何参数的所有权。
索引字符串
在许多其他编程语言中,通过索引引用字符串中的单个字符是有效且常见的操作。然而,如果你尝试在 Rust 中使用索引语法访问 String 的部分内容,你会得到一个错误。请看示例 8-19 中的无效代码。
fn main() {
let s1 = String::from("hi");
let h = s1[0];
}
String 使用索引语法这段代码会产生如下错误:
$ 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`
|
= help: the trait `SliceIndex<str>` is not implemented for `{integer}`
= 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 following other types implement trait `SliceIndex<T>`:
`usize` implements `SliceIndex<ByteStr>`
`usize` implements `SliceIndex<[T]>`
= 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 如何在内存中存储字符串。
内部表示
String 是对 Vec<u8> 的封装。让我们看看示例 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 编码中都占 1 个字节。然而,下面这行可能会让你感到意外(注意这个字符串以大写的西里尔字母 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。
因此,答案是:为了避免返回意外的值并导致可能不会立即被发现的 bug,Rust 根本不编译这段代码,从而在开发过程的早期就防止了误解。
字节、标量值和字形簇
关于 UTF-8 的另一个要点是,从 Rust 的角度来看,实际上有三种相关的方式来理解字符串:字节、标量值和字形簇(grapheme clusters,最接近我们所说的“字母“的概念)。
如果我们看用天城文书写的印地语单词 “नमस्ते”,它以 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,包含字符串的前 4 个字节。前面我们提到过,这些字符每个占 2 个字节,这意味着 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}");
}
}
这段代码将打印组成这个字符串的 4 个字节:
208
151
208
180
但请务必记住,有效的 Unicode 标量值可能由多个字节组成。
从字符串中获取字形簇(如天城文)是很复杂的,因此标准库没有提供这个功能。如果你需要这个功能,可以在 crates.io 上找到相关的 crate。
字符串并不简单
总而言之,字符串是复杂的。不同的编程语言在如何向程序员呈现这种复杂性方面做出了不同的选择。Rust 选择将正确处理 String 数据作为所有 Rust 程序的默认行为,这意味着程序员必须在前期投入更多精力来处理 UTF-8 数据。这种权衡暴露了比其他编程语言中更多的字符串复杂性,但它可以防止你在开发后期处理涉及非 ASCII 字符的错误。
好消息是,标准库基于 String 和 &str 类型提供了大量功能来帮助正确处理这些复杂情况。请务必查阅文档,了解诸如用于在字符串中搜索的 contains 和用于将字符串的一部分替换为另一个字符串的 replace 等实用方法。
让我们转向一个稍微简单一点的话题:哈希 map!
使用哈希 Map 存储键值对
使用哈希 map 存储键值对
我们常用集合的最后一个是哈希 map(hash map)。HashMap<K, V> 类型使用哈希函数(hashing function)将类型为 K 的键映射到类型为 V 的值,哈希函数决定了键值对在内存中的存储方式。很多编程语言都支持这种数据结构,只是名称各异,比如哈希(hash)、映射(map)、对象(object)、哈希表(hash table)、字典(dictionary)或关联数组(associative array)等。
当你希望不通过索引(像 vector 那样),而是通过任意类型的键来查找数据时,哈希 map 就非常有用了。例如在一个游戏中,你可以用哈希 map 来记录每支队伍的得分,其中键是队伍名称,值是对应的分数。给定一个队伍名称,就能查到它的得分。
本节我们将介绍哈希 map 的基本 API,不过标准库中 HashMap<K, V> 上定义的函数还有很多实用功能等待你去发掘。一如既往,请查阅标准库文档以获取更多信息。
创建一个新的哈希 map
创建空哈希 map 的一种方式是使用 new,然后通过 insert 添加元素。在示例 8-20 中,我们记录了 Blue 和 Yellow 两支队伍的得分。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。在三种常用集合中,哈希 map 的使用频率最低,因此它没有被包含在预导入(prelude)自动引入作用域的功能中。标准库对哈希 map 的支持也相对较少,例如没有内置的宏来构造它。
和 vector 一样,哈希 map 将数据存储在堆上。这个 HashMap 的键类型是 String,值类型是 i32。与 vector 类似,哈希 map 是同构的:所有的键必须是相同类型,所有的值也必须是相同类型。
访问哈希 map 中的值
我们可以通过向 get 方法提供键来从哈希 map 中获取值,如示例 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 队关联的值,结果为 10。get 方法返回一个 Option<&V>;如果哈希 map 中没有该键对应的值,get 会返回 None。这段程序通过调用 copied 将 Option<&i32> 转换为 Option<i32>,然后调用 unwrap_or 在 scores 中没有该键的条目时将 score 设为零来处理 Option。
我们可以用 for 循环以类似遍历 vector 的方式来遍历哈希 map 中的每个键值对:
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
哈希 map 与所有权
对于实现了 Copy trait 的类型(如 i32),值会被复制进哈希 map。而对于拥有所有权的值(如 String),值会被移动,哈希 map 将成为这些值的所有者,如示例 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!
}
在调用 insert 将 field_name 和 field_value 移入哈希 map 之后,我们就无法再使用这两个变量了。
如果我们将值的引用插入哈希 map,值本身不会被移入哈希 map。引用所指向的值必须至少在哈希 map 有效期间保持有效。我们将在第 10 章的“使用生命周期验证引用”部分详细讨论这些问题。
更新哈希 map
虽然键值对的数量是可增长的,但每个唯一的键在同一时刻只能关联一个值(反过来则不然:例如 Blue 队和 Yellow 队可以在 scores 哈希 map 中都存储值 10)。
当你想要修改哈希 map 中的数据时,必须决定如何处理键已经有值的情况。你可以用新值替换旧值,完全忽略旧值;也可以保留旧值而忽略新值,仅在键没有值时才插入新值;还可以将旧值和新值组合起来。让我们逐一看看这些做法!
覆盖一个值
如果我们向哈希 map 插入一个键和值,然后用不同的值再次插入相同的键,那么该键关联的值会被替换。即使示例 8-23 中的代码调用了两次 insert,哈希 map 也只会包含一个键值对,因为我们两次都是为 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 已经被覆盖了。
仅在键不存在时插入键值对
一个常见的需求是检查某个键是否已经存在于哈希 map 中,然后根据情况采取不同的操作:如果键已经存在,保持现有值不变;如果键不存在,则插入该键和对应的值。
哈希 map 为此提供了一个专门的 API,叫做 entry,它接受你想要检查的键作为参数。entry 方法的返回值是一个名为 Entry 的枚举,表示一个可能存在也可能不存在的值。假设我们想检查 Yellow 队的键是否有关联的值,如果没有,就插入值 50,Blue 队也是一样。使用 entry API,代码如示例 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 方法仅在键没有值时才插入Entry 上的 or_insert 方法被定义为:如果对应的 Entry 键存在,就返回该值的可变引用;如果不存在,就将参数作为新值插入,并返回新值的可变引用。这种方式比我们自己编写逻辑要简洁得多,而且与借用检查器配合得更好。
运行示例 8-24 中的代码会打印 {"Yellow": 50, "Blue": 10}。第一次调用 entry 会插入 Yellow 队的键和值 50,因为 Yellow 队还没有值。第二次调用 entry 不会修改哈希 map,因为 Blue 队已经有了值 10。
根据旧值更新
哈希 map 的另一个常见用法是查找某个键的值,然后基于旧值进行更新。例如,示例 8-25 展示了统计文本中每个单词出现次数的代码。我们使用哈希 map,以单词作为键,递增计数值来记录每个单词出现的次数。如果是第一次遇到某个单词,就先插入值 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}。你可能会看到键值对以不同的顺序打印出来:回顾“访问哈希 map 中的值”部分,遍历哈希 map 的顺序是任意的。
split_whitespace 方法返回一个迭代器,遍历 text 中以空白字符分隔的子切片。or_insert 方法返回指定键对应值的可变引用(&mut V)。这里我们将这个可变引用存储在 count 变量中,因此要对该值赋值,必须先使用星号(*)解引用 count。这个可变引用在 for 循环结束时离开作用域,所以所有这些修改都是安全的,符合借用规则。
哈希函数
HashMap 默认使用一种叫做 SipHash 的哈希函数,它能够抵御涉及哈希表的拒绝服务(DoS)攻击1。这并不是最快的哈希算法,但为了更好的安全性而牺牲一些性能是值得的。如果你分析代码后发现默认的哈希函数对你的场景来说太慢了,可以通过指定不同的哈希器(hasher)来切换到另一种函数。哈希器是一个实现了 BuildHasher trait 的类型。我们将在第 10 章讨论 trait 以及如何实现它们。你不一定需要从头实现自己的哈希器;crates.io 上有其他 Rust 用户分享的库,提供了许多常见哈希算法的哈希器实现。
总结
vector、字符串和哈希 map 在程序中需要存储、访问和修改数据时提供了大量必要的功能。以下是一些你现在应该有能力解决的练习:
- 给定一个整数列表,使用 vector 返回列表的中位数(排序后位于中间位置的值)和众数(出现次数最多的值;这里哈希 map 会很有用)。
- 将字符串转换为 Pig Latin。每个单词的第一个辅音字母被移到单词末尾并加上 ay,所以 first 变成 irst-fay。以元音字母开头的单词则在末尾加上 hay(apple 变成 apple-hay)。请注意 UTF-8 编码的相关细节!
- 使用哈希 map 和 vector,创建一个文本界面来允许用户向公司的部门中添加员工姓名。例如,“Add Sally to Engineering” 或 “Add Amir to Sales”。然后让用户获取某个部门所有人员的列表,或者按部门分类、按字母顺序排列的公司全体人员列表。
标准库 API 文档描述了 vector、字符串和哈希 map 上的方法,这些方法对完成上述练习会很有帮助!
我们正在进入更复杂的程序领域,其中操作可能会失败,所以现在是讨论错误处理的好时机。接下来我们就来讨论这个话题!
错误处理
错误是软件中不可避免的现实,因此 Rust 提供了许多功能来处理出错的情况。在很多情况下,Rust 要求你在代码编译之前就承认错误发生的可能性并采取某些措施。这一要求通过确保你在将代码部署到生产环境之前就发现并妥善处理错误,使你的程序更加健壮!
Rust 将错误分为两大类:可恢复的(recoverable)错误和不可恢复的(unrecoverable)错误。对于可恢复错误,例如文件未找到错误,我们通常只想向用户报告问题并重试操作。不可恢复错误总是 bug 的症状,例如尝试访问数组末尾之后的位置,因此我们希望立即停止程序。
大多数语言不区分这两种错误,而是用异常等机制以相同的方式处理它们。Rust 没有异常。相反,它使用 Result<T, E> 类型处理可恢复错误,使用 panic! 宏在程序遇到不可恢复错误时停止执行。本章首先介绍如何调用 panic!,然后讨论如何返回 Result<T, E> 值。此外,我们还将探讨在决定是尝试从错误中恢复还是停止执行时需要考虑的因素。
用 panic! 处理不可恢复的错误
用 panic! 处理不可恢复的错误
有时候代码中会发生糟糕的事情,而你对此无能为力。在这些情况下,Rust 提供了 panic! 宏。在实践中有两种方式会导致 panic:执行某个会导致代码 panic 的操作(例如访问数组越界),或者显式调用 panic! 宏。在这两种情况下,我们都会在程序中引发 panic。默认情况下,这些 panic 会打印一条失败信息,展开(unwind)并清理栈,然后退出。通过设置环境变量,你还可以让 Rust 在 panic 发生时显示调用栈,以便更容易追踪 panic 的来源。
对 Panic 进行栈展开或终止
默认情况下,当 panic 发生时,程序开始展开(unwinding),这意味着 Rust 会沿着栈往回走,清理它遇到的每个函数的数据。然而,这种回溯和清理工作量很大。因此 Rust 允许你选择另一种方式:立即终止(aborting),即不清理就结束程序。
程序使用的内存随后需要由操作系统来清理。如果你的项目需要使生成的二进制文件尽可能小,可以通过在 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! 调用来源的函数回溯(backtrace)来找出导致问题的代码部分。为了理解如何使用 panic! 回溯,让我们看另一个例子,看看当 panic! 调用来自库代码(由于我们代码中的 bug)而不是直接来自我们的代码调用宏时是什么样的。示例 9-1 中的代码尝试访问 vector 中超出有效索引范围的索引。
fn main() {
let v = vec![1, 2, 3];
v[99];
}
panic! 调用这里,我们尝试访问 vector 的第 100 个元素(索引为 99,因为索引从零开始),但 vector 只有三个元素。在这种情况下,Rust 会 panic。使用 [] 应该返回一个元素,但如果你传入了一个无效的索引,Rust 在这里无法返回一个正确的元素。
在 C 语言中,尝试读取数据结构末尾之后的内容是未定义行为。你可能会得到内存中对应于该数据结构中那个元素位置的任何值,即使该内存并不属于该结构。这被称为缓冲区越读(buffer overread),如果攻击者能够操纵索引来读取存储在数据结构之后的、本不应被允许访问的数据,就可能导致安全漏洞。
为了保护你的程序免受这类漏洞的影响,如果你尝试读取一个不存在的索引处的元素,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 环境变量来获取导致错误的完整回溯。回溯(backtrace)是到达当前位置所调用的所有函数的列表。Rust 中的回溯与其他语言中的一样:阅读回溯的关键是从顶部开始读,直到看到你编写的文件。那就是问题的起源位置。该位置上方的行是你的代码调用的代码;下方的行是调用你代码的代码。这些前后的行可能包括核心 Rust 代码、标准库代码或你使用的 crate。让我们通过将 RUST_BACKTRACE 环境变量设置为除 0 以外的任何值来获取回溯。示例 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_BACKTRACE 环境变量时,panic! 调用生成的回溯信息输出内容很多!你看到的确切输出可能因操作系统和 Rust 版本而异。要获取包含这些信息的回溯,必须启用调试符号。在使用 cargo build 或 cargo run 且不带 --release 标志时,调试符号默认是启用的,就像我们这里一样。
在示例 9-2 的输出中,回溯的第 6 行指向了我们项目中导致问题的那一行:src/main.rs 的第 4 行。如果我们不希望程序 panic,应该从第一个提到我们编写的文件的行所指向的位置开始调查。在示例 9-1 中,我们故意编写了会 panic 的代码,修复 panic 的方法是不要请求超出 vector 索引范围的元素。当你的代码将来发生 panic 时,你需要弄清楚代码对什么值执行了什么操作导致了 panic,以及代码应该怎么做。
我们将在本章后面的“要不要 panic!”部分回到 panic!,讨论什么时候应该和不应该使用 panic! 来处理错误条件。接下来,我们将看看如何使用 Result 从错误中恢复。
用 Result 处理可恢复的错误
用 Result 处理可恢复的错误
大多数错误并没有严重到需要程序完全停止运行的程度。有时候一个函数失败了,其原因是你可以轻松理解并做出应对的。例如,如果你尝试打开一个文件但操作失败了,原因是文件不存在,你可能想要创建这个文件而不是终止进程。
回忆一下第 2 章 “使用 Result 处理潜在的失败” 中提到的,Result 枚举定义了两个变体:Ok 和 Err,如下所示:
#![allow(unused)]
fn main() {
enum Result<T, E> {
Ok(T),
Err(E),
}
}
T 和 E 是泛型(generics)类型参数:我们将在第 10 章详细讨论泛型。你现在需要知道的是,T 代表操作成功时 Ok 变体中返回值的类型,而 E 代表操作失败时 Err 变体中返回的错误类型。因为 Result 拥有这些泛型类型参数,我们可以在许多不同的场景中使用 Result 类型及其上定义的函数,这些场景中成功值和错误值的类型可能各不相同。
让我们调用一个返回 Result 值的函数,因为该函数可能会失败。在示例 9-3 中,我们尝试打开一个文件。
use std::fs::File;
fn main() {
let greeting_file_result = File::open("hello.txt");
}
File::open 的返回类型是 Result<T, E>。泛型参数 T 已经被 File::open 的实现填充为成功值的类型 std::fs::File,即一个文件句柄。错误值中使用的 E 类型是 std::io::Error。这个返回类型意味着对 File::open 的调用可能成功并返回一个可供读写的文件句柄,也可能失败:例如,文件可能不存在,或者我们可能没有访问该文件的权限。File::open 函数需要有一种方式来告诉我们它是成功还是失败了,同时给我们提供文件句柄或错误信息。这正是 Result 枚举所传达的信息。
当 File::open 成功时,变量 greeting_file_result 中的值将是一个包含文件句柄的 Ok 实例。当它失败时,greeting_file_result 中的值将是一个包含更多错误信息的 Err 实例。
我们需要在示例 9-3 的代码基础上,根据 File::open 返回的值采取不同的操作。示例 9-4 展示了一种使用基本工具——我们在第 6 章讨论过的 match 表达式——来处理 Result 的方式。
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:?}"),
};
}
match 表达式处理可能返回的 Result 变体注意,与 Option 枚举一样,Result 枚举及其变体已经通过 prelude 引入了作用域,所以我们不需要在 match 分支中的 Ok 和 Err 变体前指定 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
和往常一样,这个输出准确地告诉了我们哪里出了问题。
匹配不同的错误
示例 9-4 中的代码不管 File::open 因为什么原因失败都会 panic!。然而,我们希望针对不同的失败原因采取不同的操作。如果 File::open 因为文件不存在而失败,我们想要创建文件并返回新文件的句柄。如果 File::open 因为其他原因失败——例如,因为我们没有打开文件的权限——我们仍然希望代码像示例 9-4 那样 panic!。为此,我们添加了一个内层 match 表达式,如示例 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:?}"),
},
_ => {
panic!("Problem opening the file: {error:?}");
}
},
};
}
File::open 在 Err 变体中返回的值类型是 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> 的替代方案
match 用得真多!match 表达式非常有用,但也非常原始。在第 13 章中,你将学习闭包(closures),它与 Result<T, E> 上定义的许多方法配合使用。在代码中处理 Result<T, E> 值时,这些方法可以比使用 match 更加简洁。
例如,这是另一种编写与示例 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:?}");
}
});
}
虽然这段代码的行为与示例 9-5 相同,但它不包含任何 match 表达式,读起来更加清晰。在阅读完第 13 章之后,回来看看这个例子,并在标准库文档中查阅 unwrap_or_else 方法。当你处理错误时,还有更多这样的方法可以帮你简化大量嵌套的 match 表达式。
错误时 panic 的快捷方式
使用 match 已经足够好用了,但它可能有点冗长,而且并不总能很好地传达意图。Result<T, E> 类型上定义了许多辅助方法来执行各种更具体的任务。unwrap 方法是一个快捷方法,其实现方式与我们在示例 9-4 中编写的 match 表达式一样。如果 Result 值是 Ok 变体,unwrap 会返回 Ok 中的值。如果 Result 是 Err 变体,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");
}
我们使用 expect 的方式与 unwrap 相同:返回文件句柄或调用 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" }
在生产级别的代码中,大多数 Rustacean 会选择 expect 而不是 unwrap,并给出更多关于为什么该操作应该总是成功的上下文信息。这样,如果你的假设被证明是错误的,你就有更多的信息可用于调试。
传播错误
当一个函数的实现中调用了可能会失败的操作时,除了在函数内部处理错误之外,你还可以将错误返回给调用代码,让它来决定如何处理。这被称为传播(propagating)错误,它将更多的控制权交给调用代码,因为调用代码可能拥有更多的信息或逻辑来决定应该如何处理错误,而这些信息在你的代码上下文中可能并不具备。
例如,示例 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),
}
}
}
match 将错误返回给调用代码的函数这个函数可以用更简短的方式来编写,但我们先手动完成大部分工作以便探索错误处理;最后,我们会展示更简短的方式。让我们先看看函数的返回类型:Result<String, io::Error>。这意味着该函数返回一个 Result<T, E> 类型的值,其中泛型参数 T 被填充为具体类型 String,泛型类型 E 被填充为具体类型 io::Error。
如果这个函数没有遇到任何问题就成功了,调用这个函数的代码将收到一个包含 String 的 Ok 值——即这个函数从文件中读取到的 username。如果这个函数遇到了任何问题,调用代码将收到一个包含 io::Error 实例的 Err 值,其中包含了关于问题的更多信息。我们选择 io::Error 作为这个函数的返回类型,是因为它恰好是这个函数体中可能失败的两个操作——File::open 函数和 read_to_string 方法——所返回的错误值类型。
函数体首先调用 File::open 函数。然后,我们用一个类似于示例 9-4 中的 match 来处理 Result 值。如果 File::open 成功了,模式变量 file 中的文件句柄就成为可变变量 username_file 的值,函数继续执行。在 Err 的情况下,我们不调用 panic!,而是使用 return 关键字提前从整个函数返回,并将来自 File::open 的错误值(现在在模式变量 e 中)作为这个函数的错误值传回给调用代码。
所以,如果我们在 username_file 中有了文件句柄,函数接着在变量 username 中创建一个新的 String,并对 username_file 中的文件句柄调用 read_to_string 方法,将文件内容读入 username。read_to_string 方法也返回一个 Result,因为即使 File::open 成功了,它也可能失败。所以我们需要另一个 match 来处理这个 Result:如果 read_to_string 成功了,那么我们的函数就成功了,我们将文件中的用户名(现在在 username 中)包装在 Ok 中返回。如果 read_to_string 失败了,我们以与处理 File::open 返回值的 match 相同的方式返回错误值。不过,我们不需要显式地写 return,因为这是函数中的最后一个表达式。
调用这段代码的代码随后将处理获得的 Ok 值(包含用户名)或 Err 值(包含 io::Error)。调用代码来决定如何处理这些值。如果调用代码得到一个 Err 值,它可以调用 panic! 使程序崩溃,可以使用默认用户名,也可以从文件以外的地方查找用户名,等等。我们没有足够的信息来了解调用代码实际上想要做什么,所以我们将所有的成功或错误信息向上传播,让它来适当地处理。
这种传播错误的模式在 Rust 中非常常见,因此 Rust 提供了问号运算符 ? 来简化这一过程。
? 运算符快捷方式
示例 9-7 展示了 read_username_from_file 的一个实现,它与示例 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 值之后的 ? 的工作方式几乎与我们在示例 9-6 中定义的用来处理 Result 值的 match 表达式一样。如果 Result 的值是 Ok,Ok 中的值将从这个表达式返回,程序继续执行。如果值是 Err,Err 将从整个函数返回,就好像我们使用了 return 关键字一样,这样错误值就传播给了调用代码。
示例 9-6 中的 match 表达式与 ? 运算符之间有一个区别:? 运算符所调用的错误值会经过 from 函数的处理,该函数定义在标准库的 From trait 中,用于将值从一种类型转换为另一种类型。当 ? 运算符调用 from 函数时,收到的错误类型会被转换为当前函数返回类型中定义的错误类型。当一个函数返回一种错误类型来表示函数可能失败的所有方式时,这非常有用,即使其中各部分可能因为许多不同的原因而失败。
例如,我们可以将示例 9-7 中的 read_username_from_file 函数改为返回一个我们定义的名为 OurError 的自定义错误类型。如果我们还定义了 impl From<io::Error> for OurError 来从 io::Error 构造 OurError 的实例,那么 read_username_from_file 函数体中的 ? 运算符调用就会调用 from 并转换错误类型,而无需在函数中添加任何额外的代码。
在示例 9-7 的上下文中,File::open 调用末尾的 ? 会将 Ok 中的值返回给变量 username_file。如果发生错误,? 运算符会提前从整个函数返回,并将任何 Err 值传给调用代码。同样的逻辑也适用于 read_to_string 调用末尾的 ?。
? 运算符消除了大量样板代码,使这个函数的实现更加简洁。我们甚至可以通过在 ? 之后立即链式调用方法来进一步缩短代码,如示例 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::open 和 read_to_string 都成功时,我们仍然返回包含 username 的 Ok 值,而不是返回错误。功能与示例 9-6 和示例 9-7 相同;这只是一种不同的、更符合人体工程学的写法。
示例 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 而不是先打开再读取文件将文件内容读取到字符串中是一个相当常见的操作,因此标准库提供了便捷的 fs::read_to_string 函数,它会打开文件、创建一个新的 String、读取文件内容、将内容放入那个 String 并返回它。当然,使用 fs::read_to_string 没有给我们解释所有错误处理的机会,所以我们先用了较长的方式。
哪里可以使用 ? 运算符
? 运算符只能用在返回类型与 ? 所作用的值兼容的函数中。这是因为 ? 运算符被定义为从函数中提前返回一个值,与我们在示例 9-6 中定义的 match 表达式的方式相同。在示例 9-6 中,match 使用的是 Result 值,提前返回的分支返回了一个 Err(e) 值。函数的返回类型必须是 Result,这样才能与这个 return 兼容。
在示例 9-10 中,让我们看看如果在返回类型与我们使用 ? 的值的类型不兼容的 main 函数中使用 ? 运算符,会得到什么错误。
use std::fs::File;
fn main() {
let greeting_file = File::open("hello.txt")?;
}
() 的 main 函数中使用 ? 将无法编译这段代码打开一个文件,这可能会失败。? 运算符跟在 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: 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
这个错误指出我们只能在返回 Result、Option 或其他实现了 FromResidual 的类型的函数中使用 ? 运算符。
要修复这个错误,你有两个选择。一个选择是修改函数的返回类型,使其与你使用 ? 运算符的值兼容,只要没有限制阻止你这样做。另一个选择是使用 match 或 Result<T, E> 的某个方法,以适当的方式处理 Result<T, E>。
错误信息还提到 ? 也可以用于 Option<T> 值。与在 Result 上使用 ? 一样,你只能在返回 Option 的函数中对 Option 使用 ?。在 Option<T> 上调用 ? 运算符时的行为与在 Result<T, E> 上调用时类似:如果值是 None,None 会在此处从函数提前返回。如果值是 Some,Some 中的值就是表达式的结果值,函数继续执行。示例 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<T> 值上使用 ? 运算符这个函数返回 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>。示例 9-12 使用了示例 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(())
}
main 改为返回 Result<(), E> 允许在 Result 值上使用 ? 运算符Box<dyn Error> 类型是一个 trait 对象,我们将在第 18 章的 “使用 trait 对象来抽象共同行为” 中讨论。目前,你可以将 Box<dyn Error> 理解为“任何类型的错误“。在错误类型为 Box<dyn Error> 的 main 函数中对 Result 值使用 ? 是允许的,因为它允许任何 Err 值提前返回。即使这个 main 函数体只会返回 std::io::Error 类型的错误,但通过指定 Box<dyn Error>,即使在 main 函数体中添加了返回其他错误的代码,这个签名仍然是正确的。
当 main 函数返回 Result<(), E> 时,如果 main 返回 Ok(()),可执行文件将以 0 值退出;如果 main 返回 Err 值,则以非零值退出。用 C 语言编写的可执行文件在退出时返回整数:成功退出的程序返回整数 0,出错的程序返回某个非 0 的整数。Rust 也从可执行文件返回整数,以兼容这一惯例。
main 函数可以返回任何实现了 std::process::Termination trait 的类型,该 trait 包含一个返回 ExitCode 的 report 函数。请查阅标准库文档以获取关于为你自己的类型实现 Termination trait 的更多信息。
现在我们已经讨论了调用 panic! 或返回 Result 的细节,让我们回到如何决定在哪些情况下使用哪种方式的话题。
要不要 panic!
该用 panic! 还是不用 panic!
那么,你该如何决定何时应该调用 panic!,何时应该返回 Result 呢?当代码 panic 时,就没有恢复的可能了。你可以对任何错误情况都调用 panic!,不管是否有可能恢复,但这样你就替调用者做出了“这个情况不可恢复“的决定。当你选择返回 Result 值时,你把选择权交给了调用代码。调用代码可以选择以适合其场景的方式尝试恢复,也可以认定这个 Err 值是不可恢复的,从而调用 panic! 将你的可恢复错误变成不可恢复错误。因此,当你定义一个可能失败的函数时,返回 Result 是一个好的默认选择。
在示例代码、原型代码和测试中,使用 panic 比返回 Result 更合适。接下来我们探讨一下原因,然后讨论编译器无法判断失败不可能发生、但你作为开发者可以判断的情况。本章最后会给出一些关于在库代码中是否应该 panic 的通用指导原则。
示例代码、原型代码和测试
当你编写示例来阐述某个概念时,同时包含健壮的错误处理代码会使示例变得不够清晰。在示例中,大家都理解调用像 unwrap 这样可能 panic 的方法只是一个占位符,代表你希望应用程序处理错误的方式,具体方式会因代码的其余部分而异。
类似地,在原型开发阶段,当你还没准备好决定如何处理错误时,unwrap 和 expect 方法非常方便。它们在代码中留下了清晰的标记,等你准备好让程序更加健壮时就可以处理它们。
如果在测试中某个方法调用失败了,你会希望整个测试都失败,即使该方法并不是被测试的功能。因为 panic! 正是将测试标记为失败的方式,所以调用 unwrap 或 expect 正是应该做的事情。
当你比编译器掌握更多信息时
当你有其他逻辑能确保 Result 一定是 Ok 值,但编译器无法理解这个逻辑时,调用 expect 也是合适的。你仍然需要处理一个 Result 值:你调用的操作在一般情况下仍然有可能失败,即使在你的特定场景中逻辑上不可能失败。如果你通过手动检查代码能确保永远不会出现 Err 变体,那么调用 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 改为更好的错误处理代码。
错误处理指导原则
当你的代码有可能进入无效状态(bad state)时,建议让代码 panic。在这个语境中,无效状态是指某些假设、保证、契约或不变量被打破了,例如无效的值、矛盾的值或缺失的值传递给了你的代码——并且满足以下一个或多个条件:
- 这个无效状态是非预期的,而不是偶尔可能发生的事情,比如用户输入了错误格式的数据。
- 此后的代码需要依赖于不处于这种无效状态,而不是在每一步都检查这个问题。
- 没有好的方式将这个信息编码到你使用的类型中。我们将在第 18 章的“将状态和行为编码为类型”部分通过一个例子来说明这一点。
如果别人调用你的代码并传入了无意义的值,最好是返回一个错误,让库的使用者决定在这种情况下该怎么做。然而,在继续执行可能不安全或有害的情况下,最好的选择可能是调用 panic!,提醒使用你的库的人注意他们代码中的 bug,以便他们在开发过程中修复它。类似地,如果你调用了不受你控制的外部代码,而它返回了你无法修复的无效状态,调用 panic! 通常也是合适的。
然而,当失败是可预期的,返回 Result 比调用 panic! 更合适。例如,解析器收到了格式错误的数据,或者 HTTP 请求返回了表示你触发了速率限制的状态码。在这些情况下,返回 Result 表明失败是一种可预期的可能性,调用代码必须决定如何处理。
当你的代码对使用无效值进行操作可能会让用户面临风险时,代码应该先验证值是否有效,如果值无效则 panic。这主要是出于安全考虑:尝试操作无效数据可能会暴露代码的漏洞。这也是标准库在你尝试越界访问内存时会调用 panic! 的主要原因:尝试访问不属于当前数据结构的内存是一个常见的安全问题。函数通常有契约(contracts):只有在输入满足特定要求时,其行为才是有保证的。当契约被违反时 panic 是合理的,因为契约违反总是意味着调用方的 bug,而且这不是你希望调用代码去显式处理的那种错误。实际上,调用代码没有合理的方式来恢复;需要修复代码的是调用方的程序员。函数的契约,特别是当违反契约会导致 panic 时,应该在函数的 API 文档中加以说明。
然而,在所有函数中都进行大量的错误检查会很冗长且烦人。幸运的是,你可以利用 Rust 的类型系统(以及编译器执行的类型检查)来为你完成许多检查。如果你的函数以某个特定类型作为参数,你就可以在编译器已经确保你拥有一个有效值的前提下继续编写代码逻辑。例如,如果你使用的是一个具体类型而不是 Option,那么你的程序期望的是有值而不是无值。这样你的代码就不需要处理 Some 和 None 两种情况:它只有一种情况,即确定有一个值。试图向你的函数传入空值的代码甚至无法通过编译,所以你的函数在运行时不需要检查这种情况。另一个例子是使用无符号整数类型如 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 在 1 到 100 之间的前提下,继续进行 guess 与秘密数字的比较。
然而,这并不是一个理想的解决方案:如果程序绝对必须只操作 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 类型注意这段代码位于 src/guessing_game.rs 中,它依赖于在 src/lib.rs 中添加模块声明 mod guessing_game;,这里我们没有展示。在这个新模块的文件中,我们定义了一个名为 Guess 的结构体,它有一个名为 value 的 i32 类型字段。数字将存储在这里。
然后,我们在 Guess 上实现了一个名为 new 的关联函数,用于创建 Guess 值的实例。new 函数定义了一个 i32 类型的 value 参数,并返回一个 Guess。new 函数体中的代码会测试 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 的 value 不可能未经 Guess::new 函数中的条件检查。
一个参数或返回值只能是 1 到 100 之间数字的函数,就可以在其签名中声明它接受或返回 Guess 而不是 i32,这样就不需要在函数体中做任何额外的检查了。
总结
Rust 的错误处理功能旨在帮助你编写更健壮的代码。panic! 宏表示你的程序处于一个它无法处理的状态,并让你告诉进程停止运行,而不是尝试使用无效或不正确的值继续执行。Result 枚举利用 Rust 的类型系统来表明操作可能会以一种你的代码可以恢复的方式失败。你可以使用 Result 来告诉调用你代码的代码,它需要处理潜在的成功或失败。在适当的场景中使用 panic! 和 Result 会使你的代码在面对不可避免的问题时更加可靠。
既然你已经看到了标准库如何通过 Option 和 Result 枚举来使用泛型,我们接下来将讨论泛型的工作原理以及如何在你的代码中使用它们。
泛型、trait 和生命周期
每种编程语言都有高效处理概念重复的工具。在 Rust 中,泛型(generics)就是这样一种工具:它是具体类型或其他属性的抽象替代。我们可以描述泛型的行为,或者泛型与其他泛型之间的关系,而无需在编写代码时知道它们在编译和运行时会被替换为什么具体类型。
函数的参数可以是某种泛型类型,而不是像 i32 或 String 这样的具体类型,就像函数接受未知值的参数来对多个具体值运行相同的代码一样。事实上,我们在第六章的 Option<T>、第八章的 Vec<T> 和 HashMap<K, V>,以及第九章的 Result<T, E> 中已经使用过泛型了。在本章中,你将学习如何用泛型定义自己的类型、函数和方法!
首先,我们将回顾如何通过提取函数来减少代码重复。然后,用同样的思路,从两个仅参数类型不同的函数中提取出一个泛型函数。我们还会讲解如何在结构体和枚举的定义中使用泛型。
接着,你将学习如何使用 trait 以泛型的方式定义行为。你可以将 trait 与泛型类型结合使用,将泛型约束为只接受具有特定行为的类型,而不是任意类型。
最后,我们将讨论_生命周期_(lifetime):一种特殊的泛型,它向编译器提供引用之间相互关系的信息。生命周期让我们能够向编译器提供足够的借用值信息,使得编译器能在更多场景下确保引用的有效性——这比没有生命周期标注时能覆盖的场景要多得多。
通过提取函数来消除重复
泛型允许我们用一个代表多种类型的占位符来替代具体类型,从而消除代码重复。在深入泛型语法之前,我们先来看看一种不涉及泛型的消除重复的方式——提取函数,用一个代表多个值的占位符替代具体的值。然后,我们将用同样的思路来提取泛型函数!通过学习如何识别可以提取为函数的重复代码,你也将开始学会识别可以使用泛型的重复代码。
我们从示例 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。
现在我们的任务是在两个不同的数字列表中分别找出最大值。为此,我们可以选择复制示例 10-1 中的代码,在程序的两个不同位置使用相同的逻辑,如示例 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}");
}
虽然这段代码能正常工作,但复制代码既繁琐又容易出错。而且当我们想修改逻辑时,还得记住在多个地方同时更新代码。
为了消除这种重复,我们可以定义一个函数,让它接受任意整数列表作为参数,从而创建一个抽象。这个方案使代码更加清晰,并让我们能够以抽象的方式表达“找出列表中最大数字“这一概念。
在示例 10-3 中,我们将找出最大数字的代码提取到一个名为 largest 的函数中。然后调用该函数来查找示例 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 值切片。因此,当我们调用这个函数时,代码会在我们传入的具体值上运行。
总结一下,我们从示例 10-2 到示例 10-3 所经历的步骤如下:
- 识别重复的代码。
- 将重复的代码提取到函数体中,并在函数签名中指定代码的输入和返回值。
- 将两处重复代码替换为对该函数的调用。
接下来,我们将用同样的步骤来使用泛型以减少代码重复。就像函数体可以操作抽象的 list 而不是具体的值一样,泛型允许代码操作抽象的类型。
例如,假设我们有两个函数:一个在 i32 值的切片中找出最大项,另一个在 char 值的切片中找出最大项。我们该如何消除这种重复呢?让我们来一探究竟!
泛型数据类型
泛型数据类型
我们使用泛型(generics)来创建函数签名或结构体等条目的定义,然后可以将这些定义用于许多不同的具体数据类型。首先让我们看看如何使用泛型来定义函数、结构体、枚举和方法。接着,我们将讨论泛型对代码性能的影响。
在函数定义中使用泛型
当定义一个使用泛型的函数时,我们将泛型放在函数签名中通常用于指定参数和返回值数据类型的位置。这样做使我们的代码更加灵活,为函数的调用者提供更多功能,同时避免代码重复。
继续我们的 largest 函数,示例 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 函数就是我们在示例 10-3 中提取出来的那个,它在切片中查找最大的 i32。largest_char 函数在切片中查找最大的 char。这两个函数体的代码完全相同,所以让我们通过在一个函数中引入泛型类型参数来消除这种重复。
要在一个新的函数中参数化类型,我们需要为类型参数命名,就像为函数的值参数命名一样。你可以使用任何标识符作为类型参数名,但我们会使用 T,因为按照惯例,Rust 中的类型参数名很短,通常只有一个字母,并且 Rust 的类型命名约定是大驼峰命名法(UpperCamelCase)。T 是 type 的缩写,是大多数 Rust 程序员的默认选择。
当我们在函数体中使用一个参数时,必须在签名中声明该参数名,以便编译器知道这个名称的含义。类似地,当我们在函数签名中使用类型参数名时,必须在使用之前声明它。要定义泛型 largest 函数,我们将类型名称声明放在尖括号 <> 中,位于函数名和参数列表之间,如下所示:
fn largest<T>(list: &[T]) -> &T {
我们可以这样理解这个定义:“函数 largest 对某个类型 T 是泛型的。“这个函数有一个名为 list 的参数,它是类型 T 的值的切片。largest 函数将返回一个对相同类型 T 的值的引用。
示例 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}");
}
largest 函数;目前还不能编译如果现在就编译这段代码,我们会得到如下错误:
$ 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)。为了修复示例 10-5,我们可以按照帮助文本的建议,将 T 的有效类型限制为仅实现了 PartialOrd 的类型。这样示例就能编译了,因为标准库在 i32 和 char 上都实现了 PartialOrd。
在结构体定义中使用泛型
我们同样可以使用 <> 语法来定义结构体,使其在一个或多个字段中使用泛型类型参数。示例 10-6 定义了一个 Point<T> 结构体,用于保存任意类型的 x 和 y 坐标值。
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 };
}
T 的 x 和 y 值的 Point<T> 结构体在结构体定义中使用泛型的语法与在函数定义中使用的语法类似。首先,在结构体名称后面的尖括号中声明类型参数的名称。然后在结构体定义中使用泛型类型来替代原本需要指定具体数据类型的位置。
注意,因为我们只使用了一个泛型类型来定义 Point<T>,这个定义表明 Point<T> 结构体对某个类型 T 是泛型的,并且字段 x 和 y 都是 相同的类型,无论该类型具体是什么。如果我们创建一个具有不同类型值的 Point<T> 实例,如示例 10-7 所示,代码将无法编译。
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
x 和 y 必须是相同类型,因为它们都具有相同的泛型数据类型 T。在这个例子中,当我们将整数值 5 赋给 x 时,编译器就知道了这个 Point<T> 实例的泛型类型 T 是整数。然后当我们为 y 指定 4.0 时——而 y 被定义为与 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
要定义一个 x 和 y 都是泛型但可以具有不同类型的 Point 结构体,我们可以使用多个泛型类型参数。例如,在示例 10-8 中,我们将 Point 的定义改为对类型 T 和 U 泛型,其中 x 的类型为 T,y 的类型为 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<T, U>,使得 x 和 y 可以是不同类型的值现在所有展示的 Point 实例都是合法的了!你可以在定义中使用任意多个泛型类型参数,但使用过多会使代码难以阅读。如果你发现代码中需要大量泛型类型,这可能意味着你的代码需要重构为更小的部分。
在枚举定义中使用泛型
与结构体类似,我们可以定义枚举来在其变体中保存泛型数据类型。让我们再看一下标准库提供的 Option<T> 枚举,我们在第六章中使用过它:
#![allow(unused)]
fn main() {
enum Option<T> {
Some(T),
None,
}
}
现在这个定义对你来说应该更容易理解了。如你所见,Option<T> 枚举对类型 T 是泛型的,它有两个变体:Some 保存一个类型为 T 的值,None 变体不保存任何值。通过使用 Option<T> 枚举,我们可以表达可选值这一抽象概念,并且因为 Option<T> 是泛型的,无论可选值的类型是什么,我们都可以使用这个抽象。
枚举也可以使用多个泛型类型。我们在第九章中使用的 Result 枚举的定义就是一个例子:
#![allow(unused)]
fn main() {
enum Result<T, E> {
Ok(T),
Err(E),
}
}
Result 枚举对两个类型 T 和 E 是泛型的,它有两个变体:Ok 保存一个类型为 T 的值,Err 保存一个类型为 E 的值。这个定义使得 Result 枚举可以方便地用于任何可能成功(返回某个类型 T 的值)或失败(返回某个类型 E 的错误)的操作。实际上,这正是我们在示例 9-3 中打开文件时所使用的,当文件成功打开时 T 被填充为 std::fs::File 类型,当打开文件出现问题时 E 被填充为 std::io::Error 类型。
当你发现代码中有多个结构体或枚举定义仅在所保存的值的类型上有所不同时,就可以通过使用泛型类型来避免重复。
在方法定义中使用泛型
我们可以在结构体和枚举上实现方法(如第五章所做的那样),并在方法定义中使用泛型类型。示例 10-9 展示了我们在示例 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 的方法,它将返回对类型为 T 的 x 字段的引用这里我们在 Point<T> 上定义了一个名为 x 的方法,它返回对字段 x 中数据的引用。
注意,我们必须在 impl 后面声明 T,这样才能在 Point<T> 类型上实现方法时使用 T。通过在 impl 后面将 T 声明为泛型类型,Rust 能够识别出 Point 尖括号中的类型是泛型类型而非具体类型。我们可以为这个泛型参数选择一个与结构体定义中声明的泛型参数不同的名称,但使用相同的名称是惯例。如果你在声明了泛型类型的 impl 中编写方法,该方法将被定义在该类型的任何实例上,无论最终用什么具体类型替换泛型类型。
在定义类型上的方法时,我们还可以对泛型类型指定约束。例如,我们可以只在 Point<f32> 实例上实现方法,而不是在任意泛型类型的 Point<T> 实例上。在示例 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());
}
T 为特定具体类型的结构体的 impl 块这段代码意味着 Point<f32> 类型将拥有一个 distance_from_origin 方法;而 T 不是 f32 类型的其他 Point<T> 实例则不会定义此方法。该方法计算我们的点到坐标 (0.0, 0.0) 处的点的距离,并使用了仅对浮点类型可用的数学运算。
结构体定义中的泛型类型参数并不总是与该结构体方法签名中使用的泛型类型参数相同。示例 10-11 为 Point 结构体使用了泛型类型 X1 和 Y1,为 mixup 方法签名使用了 X2 和 Y2,以使示例更加清晰。该方法创建一个新的 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 中,我们定义了一个 x 为 i32(值为 5)、y 为 f64(值为 10.4)的 Point。变量 p2 是一个 x 为字符串切片(值为 "Hello")、y 为 char(值为 c)的 Point 结构体。在 p1 上调用 mixup 并传入参数 p2 得到 p3,它的 x 是 i32 类型,因为 x 来自 p1。p3 的 y 是 char 类型,因为 y 来自 p2。println! 宏调用将打印 p3.x = 5, p3.y = c。
这个例子的目的是展示一种场景:某些泛型参数用 impl 声明,而另一些用方法定义声明。这里,泛型参数 X1 和 Y1 在 impl 后面声明,因为它们属于结构体定义。泛型参数 X2 和 Y2 在 fn mixup 后面声明,因为它们只与该方法相关。
使用泛型的代码性能
你可能会好奇使用泛型类型参数是否会有运行时开销。好消息是,使用泛型类型不会使你的程序比使用具体类型运行得更慢。
Rust 通过在编译时对使用泛型的代码进行单态化(monomorphization)来实现这一点。单态化 是将泛型代码转换为特定代码的过程,即在编译时填入实际使用的具体类型。在这个过程中,编译器所做的工作与我们在示例 10-5 中创建泛型函数的步骤相反:编译器查看所有调用泛型代码的地方,并为泛型代码被调用时所使用的具体类型生成代码。
让我们通过标准库的泛型 Option<T> 枚举来看看这是如何工作的:
#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}
当 Rust 编译这段代码时,它会执行单态化。在这个过程中,编译器读取 Option<T> 实例中使用的值,并识别出两种 Option<T>:一种是 i32,另一种是 f64。因此,它将 Option<T> 的泛型定义展开为两个针对 i32 和 f64 的特化定义,从而用具体的定义替换了泛型定义。
单态化后的代码看起来类似于以下内容(编译器使用的名称与我们这里用于说明的名称不同):
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 的泛型在运行时极其高效。
使用 trait 定义共享行为
使用 Trait 定义共同行为
trait 定义了某个特定类型所具有的、且能与其他类型共享的功能。我们可以使用 trait 以抽象的方式定义共同行为。我们还可以使用 trait bounds(trait 约束)来指定泛型类型可以是任何具有特定行为的类型。
注意:trait 类似于其他语言中常被称为接口(interfaces)的功能,尽管有一些不同之处。
定义 Trait
一个类型的行为由我们能在该类型上调用的方法组成。如果我们能在不同类型上调用相同的方法,那么这些类型就共享了相同的行为。trait 定义是一种将方法签名组合在一起的方式,用于定义实现某种目的所必需的一组行为。
例如,假设我们有多个结构体,它们持有不同种类和数量的文本:NewsArticle 结构体持有在某个特定地点发布的新闻报道,而 SocialPost 最多可以包含 280 个字符的内容,以及表示它是新帖子、转发还是对另一条帖子的回复的元数据。
我们想要创建一个名为 aggregator 的媒体聚合库 crate,它能够显示可能存储在 NewsArticle 或 SocialPost 实例中的数据摘要。为此,我们需要每个类型提供摘要,我们将通过在实例上调用 summarize 方法来请求该摘要。示例 10-12 展示了一个表达此行为的公有 Summary trait 的定义。
pub trait Summary {
fn summarize(&self) -> String;
}
summarize 方法提供行为的 Summary trait这里,我们使用 trait 关键字和 trait 的名称来声明一个 trait,在本例中是 Summary。我们还将该 trait 声明为 pub,这样依赖于本 crate 的其他 crate 也能使用这个 trait,我们将在后面的示例中看到这一点。在花括号内,我们声明了实现此 trait 的类型所需的方法签名,在本例中是 fn summarize(&self) -> String。
在方法签名之后,我们使用分号而不是在花括号中提供实现。实现此 trait 的每个类型都必须为方法体提供自己的自定义行为。编译器会确保任何具有 Summary trait 的类型都将拥有与此签名完全一致的 summarize 方法。
一个 trait 的体中可以有多个方法:方法签名每行列出一个,每行以分号结尾。
为类型实现 Trait
现在我们已经定义了 Summary trait 方法的期望签名,接下来可以在媒体聚合器中的类型上实现它了。示例 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)
}
}
NewsArticle 和 SocialPost 类型上实现 Summary trait在类型上实现 trait 类似于实现常规方法。不同之处在于,在 impl 之后,我们放置想要实现的 trait 名称,然后使用 for 关键字,再指定要为其实现 trait 的类型名称。在 impl 块内,我们放入 trait 定义中的方法签名。我们不再在每个签名后加分号,而是使用花括号并在方法体中填入我们希望该 trait 的方法在特定类型上具有的具体行为。
现在库已经在 NewsArticle 和 SocialPost 上实现了 Summary trait,crate 的用户可以像调用常规方法一样在 NewsArticle 和 SocialPost 的实例上调用 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 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。例如,我们可以在 aggregator crate 中为自定义类型 SocialPost 实现标准库的 Display trait,因为类型 SocialPost 是 aggregator crate 的本地类型。我们也可以在 aggregator crate 中为 Vec<T> 实现 Summary,因为 trait Summary 是 aggregator crate 的本地 trait。
但是我们不能为外部类型实现外部 trait。例如,我们不能在 aggregator crate 中为 Vec<T> 实现 Display trait,因为 Display 和 Vec<T> 都定义在标准库中,不属于我们的 aggregator crate。这个限制是一种被称为一致性(coherence)的属性的一部分,更具体地说是孤儿规则(orphan rule),之所以这样命名是因为父类型不存在。这条规则确保了其他人的代码不会破坏你的代码,反之亦然。如果没有这条规则,两个 crate 可以为同一类型实现同一 trait,Rust 就不知道该使用哪个实现了。
使用默认实现
有时为 trait 中的某些或所有方法提供默认行为是很有用的,而不是要求每个类型都实现所有方法。然后,当我们在特定类型上实现 trait 时,可以保留或覆盖每个方法的默认行为。
在示例 10-14 中,我们为 Summary trait 的 summarize 方法指定了一个默认字符串,而不是像示例 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)
}
}
summarize 方法默认实现的 Summary trait要使用默认实现来对 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...)。
创建默认实现并不需要我们修改示例 10-13 中 SocialPost 上 Summary 的实现。原因是覆盖默认实现的语法与实现没有默认实现的 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 结构体的实例上调用 summarize,summarize 的默认实现会调用我们提供的 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 post: {}", post.summarize());
}
这段代码会打印 1 new post: (Read more from @horse_ebooks...)。
注意,无法从同一方法的覆盖实现中调用该方法的默认实现。
将 Trait 作为参数
现在你已经知道如何定义和实现 trait,我们可以探索如何使用 trait 来定义接受多种不同类型的函数。我们将使用在示例 10-13 中为 NewsArticle 和 SocialPost 类型实现的 Summary trait 来定义一个 notify 函数,该函数在其 item 参数上调用 summarize 方法,而 item 参数是某个实现了 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 并传入任何 NewsArticle 或 SocialPost 的实例。如果用其他类型(如 String 或 i32)调用该函数则无法编译,因为这些类型没有实现 Summary。
Trait Bound 语法
impl Trait 语法适用于简单的情况,但它实际上是一种更长形式的语法糖,这种更长的形式被称为 trait bound(trait 约束);它看起来像这样:
pub fn notify<T: Summary>(item: &T) {
println!("Breaking news! {}", item.summarize());
}
这种更长的形式与前一节中的示例等价,但更加冗长。我们将 trait bound 与泛型类型参数的声明放在一起,位于冒号之后、尖括号之内。
impl Trait 语法很方便,在简单情况下使代码更加简洁,而完整的 trait bound 语法则能在其他情况下表达更多的复杂性。例如,我们可以有两个实现了 Summary 的参数。使用 impl Trait 语法看起来像这样:
pub fn notify(item1: &impl Summary, item2: &impl Summary) {
如果我们希望此函数允许 item1 和 item2 具有不同的类型(只要两个类型都实现了 Summary),使用 impl Trait 是合适的。但如果我们想要强制两个参数具有相同的类型,就必须使用 trait bound,像这样:
pub fn notify<T: Summary>(item1: &T, item2: &T) {
指定为 item1 和 item2 参数类型的泛型类型 T 约束了该函数,使得作为 item1 和 item2 参数传入的值的具体类型必须相同。
通过 + 语法指定多个 Trait Bound
我们还可以指定多个 trait bound。假设我们希望 notify 在 item 上既能使用显示格式化,又能使用 summarize:我们在 notify 的定义中指定 item 必须同时实现 Display 和 Summary。我们可以使用 + 语法来实现:
pub fn notify(item: &(impl Summary + Display)) {
+ 语法同样适用于泛型类型上的 trait bound:
pub fn notify<T: Summary + Display>(item: &T) {
指定了这两个 trait bound 后,notify 的函数体就可以调用 summarize 并使用 `来格式化item` 了。
通过 where 从句使 Trait Bound 更清晰
使用过多的 trait bound 也有缺点。每个泛型都有自己的 trait bound,因此有多个泛型类型参数的函数可能会在函数名和参数列表之间包含大量的 trait bound 信息,使得函数签名难以阅读。为此,Rust 提供了另一种语法,允许在函数签名之后的 where 从句中指定 trait bound。所以,与其这样写:
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 bound 的函数。
返回实现了 Trait 的类型
我们也可以在返回值位置使用 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。例如,下面这段代码将返回类型指定为 impl Summary,但返回的可能是 NewsArticle 或 SocialPost,这是行不通的:
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 语法的实现方式的限制,不允许返回 NewsArticle 或 SocialPost 中的任意一个。我们将在第 18 章的“使用 Trait 对象来抽象不同类型的共同行为”一节中介绍如何编写具有此行为的函数。
使用 Trait Bound 有条件地实现方法
通过在使用泛型类型参数的 impl 块中使用 trait bound,我们可以有条件地为实现了指定 trait 的类型实现方法。例如,示例 10-15 中的类型 Pair<T> 总是实现 new 函数来返回一个新的 Pair<T> 实例(回忆一下第 5 章“方法语法”一节中提到的 Self 是 impl 块所针对类型的类型别名,在本例中是 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 bound 的任何类型实现 trait 被称为覆盖实现(blanket implementations),这在 Rust 标准库中被广泛使用。例如,标准库为任何实现了 Display trait 的类型实现了 ToString trait。标准库中的 impl 块类似于如下代码:
impl<T: Display> ToString for T {
// --snip--
}
因为标准库有这个覆盖实现,我们可以在任何实现了 Display trait 的类型上调用 ToString trait 定义的 to_string 方法。例如,我们可以将整数转换为对应的 String 值,因为整数实现了 Display:
#![allow(unused)]
fn main() {
let s = 3.to_string();
}
覆盖实现出现在 trait 文档的“Implementors“部分中。
trait 和 trait bound 让我们能够使用泛型类型参数来减少重复代码,同时向编译器指明我们希望泛型类型具有特定的行为。编译器随后可以利用 trait bound 信息来检查我们代码中使用的所有具体类型是否提供了正确的行为。在动态类型语言中,如果我们在一个没有定义某方法的类型上调用该方法,会在运行时得到一个错误。但 Rust 将这些错误移到了编译时,迫使我们在代码能够运行之前就修复问题。此外,我们不必编写在运行时检查行为的代码,因为我们已经在编译时进行了检查。这样做在不放弃泛型灵活性的前提下提升了性能。
使用生命周期验证引用
使用生命周期验证引用
生命周期(lifetime)是另一种我们已经在使用的泛型。与确保类型具有我们期望的行为不同,生命周期确保引用在我们需要的时候始终有效。
在第四章“引用与借用”部分中,有一个细节我们没有讨论:Rust 中的每个引用都有一个生命周期,即该引用保持有效的作用域。大多数时候,生命周期是隐式的、可以被推断的,就像大多数时候类型也是可以被推断的一样。只有当存在多种可能的类型时,我们才需要标注类型。类似地,当引用的生命周期可能以不同方式相互关联时,我们就必须标注生命周期。Rust 要求我们使用泛型生命周期参数来标注这些关系,以确保运行时实际使用的引用一定是有效的。
标注生命周期的概念在大多数其他编程语言中并不存在,所以这会让人感到陌生。虽然本章不会完整地覆盖生命周期的所有内容,但我们会讨论你可能遇到生命周期语法的常见场景,帮助你熟悉这个概念。
悬垂引用
生命周期的主要目标是防止悬垂引用(dangling references)。如果允许悬垂引用存在,程序就会引用到并非其预期引用的数据。考虑示例 10-16 中的程序,它有一个外部作用域和一个内部作用域。
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r: {r}");
}
注意:示例 10-16、10-17 和 10-23 中声明了没有初始值的变量,因此变量名存在于外部作用域中。乍一看,这似乎与 Rust 没有空值(null)的设计相矛盾。然而,如果我们尝试在赋值之前使用变量,就会得到一个编译时错误,这说明 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 “存活时间不够长”。原因是当内部作用域在第 7 行结束时,x 就离开了作用域。但 r 在外部作用域中仍然有效;因为它的作用域更大,我们说它“存活得更久“。如果 Rust 允许这段代码运行,r 将会引用 x 离开作用域时已被释放的内存,我们对 r 做的任何操作都不会正确工作。那么,Rust 是如何判定这段代码无效的呢?它使用了借用检查器。
借用检查器
Rust 编译器有一个借用检查器(borrow checker),它通过比较作用域来判断所有借用是否有效。示例 10-17 展示了与示例 10-16 相同的代码,但添加了变量生命周期的标注。
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {r}"); // |
} // ---------+
r 和 x 的生命周期标注,分别命名为 'a 和 'b这里,我们用 'a 标注了 r 的生命周期,用 'b 标注了 x 的生命周期。如你所见,内部的 'b 块比外部的 'a 生命周期块小得多。在编译时,Rust 比较两个生命周期的大小,发现 r 的生命周期是 'a,但它引用的内存的生命周期是 'b。程序被拒绝,因为 'b 比 'a 短:引用的对象存活时间没有引用本身长。
示例 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 函数之后,示例 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 函数来查找两个字符串切片中较长者的 main 函数注意我们希望函数接受字符串切片(即引用)而不是字符串,因为我们不希望 longest 函数获取其参数的所有权。关于为什么示例 10-19 中使用这些参数的更多讨论,请参阅第四章的“字符串切片作为参数”部分。
如果我们尝试按示例 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 }
}
longest 函数的一个实现,返回两个字符串切片中较长者,但尚无法编译我们会得到以下关于生命周期的错误:
$ 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 分支会执行。我们也不知道传入引用的具体生命周期,所以无法像示例 10-17 和 10-18 那样通过查看作用域来判断返回的引用是否始终有效。借用检查器也无法判断,因为它不知道 x 和 y 的生命周期与返回值的生命周期之间的关系。为了修复这个错误,我们将添加泛型生命周期参数来定义引用之间的关系,以便借用检查器能够进行分析。
生命周期标注语法
生命周期标注不会改变任何引用的存活时长。相反,它们描述了多个引用的生命周期之间的关系,而不影响实际的生命周期。正如函数在签名中指定泛型类型参数后可以接受任何类型一样,函数在指定泛型生命周期参数后也可以接受具有任何生命周期的引用。
生命周期标注有一种略微特殊的语法:生命周期参数的名称必须以撇号(')开头,通常全部小写且非常短,就像泛型类型一样。大多数人使用 'a 作为第一个生命周期标注的名称。我们将生命周期参数标注放在引用的 & 之后,用一个空格将标注与引用的类型分开。
下面是一些例子——一个没有生命周期参数的 i32 引用、一个带有名为 'a 的生命周期参数的 i32 引用,以及一个同样带有生命周期 'a 的 i32 可变引用:
&i32 // a reference
&'a i32 // a reference with an explicit lifetime
&'a mut i32 // a mutable reference with an explicit lifetime
单独一个生命周期标注本身没有太大意义,因为标注的目的是告诉 Rust 多个引用的泛型生命周期参数之间如何相互关联。让我们在 longest 函数的上下文中看看生命周期标注是如何相互关联的。
函数签名中的生命周期标注
要在函数签名中使用生命周期标注,需要在函数名和参数列表之间的尖括号内声明泛型生命周期参数,就像声明泛型类型参数一样。
我们希望签名表达以下约束:只要两个参数都有效,返回的引用就有效。这就是参数生命周期和返回值之间的关系。我们将生命周期命名为 'a,然后将其添加到每个引用上,如示例 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 }
}
longest 函数定义,指定签名中所有引用必须具有相同的生命周期 'a这段代码应该能够编译,并在与示例 10-19 中的 main 函数一起使用时产生我们期望的结果。
函数签名现在告诉 Rust,对于某个生命周期 'a,函数接受两个参数,它们都是至少与生命周期 'a 存活一样长的字符串切片。函数签名还告诉 Rust,从函数返回的字符串切片将至少与生命周期 'a 存活一样长。实际上,这意味着 longest 函数返回的引用的生命周期,等于传入参数所引用的值的生命周期中较小的那个。这些关系正是我们希望 Rust 在分析这段代码时使用的。
记住,当我们在函数签名中指定生命周期参数时,我们并没有改变任何传入或返回值的生命周期。相反,我们是在指定借用检查器应该拒绝任何不满足这些约束的值。注意 longest 函数不需要确切知道 x 和 y 会存活多久,只需要知道有某个作用域可以替代 'a 来满足这个签名。
在函数中标注生命周期时,标注放在函数签名中,而不是函数体中。生命周期标注成为函数契约的一部分,就像签名中的类型一样。让函数签名包含生命周期契约意味着 Rust 编译器的分析可以更简单。如果函数的标注方式或调用方式有问题,编译器错误可以更精确地指出代码中的问题和约束。相反,如果 Rust 编译器对生命周期关系做更多推断,编译器可能只能指出距离问题根源很远的代码使用处。
当我们向 longest 传入具体的引用时,替代 'a 的具体生命周期是 x 的作用域与 y 的作用域重叠的部分。换句话说,泛型生命周期 'a 将获得等于 x 和 y 的生命周期中较小者的具体生命周期。因为我们用相同的生命周期参数 'a 标注了返回的引用,所以返回的引用在 x 和 y 的生命周期中较小者的范围内也是有效的。
让我们看看生命周期标注如何通过传入具有不同具体生命周期的引用来约束 longest 函数。示例 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 }
}
longest 函数处理具有不同具体生命周期的 String 值的引用在这个例子中,string1 在外部作用域结束前都有效,string2 在内部作用域结束前有效,而 result 引用的内容在内部作用域结束前有效。运行这段代码,你会看到借用检查器通过了检查;它能编译并打印 The longest string is long string is long。
接下来,让我们试一个例子来说明 result 中引用的生命周期必须是两个参数中较小的那个生命周期。我们将 result 变量的声明移到内部作用域之外,但将赋值留在包含 string2 的内部作用域中。然后,我们将使用 result 的 println! 移到内部作用域之外、内部作用域结束之后。示例 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 }
}
string2 离开作用域后使用 result当我们尝试编译这段代码时,会得到以下错误:
$ 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
错误表明,要使 result 在 println! 语句中有效,string2 需要在外部作用域结束前一直有效。Rust 之所以知道这一点,是因为我们用相同的生命周期参数 'a 标注了函数参数和返回值的生命周期。
作为人类,我们可以看出 string1 比 string2 长,因此 result 将包含一个指向 string1 的引用。因为 string1 还没有离开作用域,所以对 string1 的引用在 println! 语句中仍然有效。然而,编译器在这种情况下无法看出引用是有效的。我们已经告诉 Rust,longest 函数返回的引用的生命周期等于传入引用的生命周期中较小的那个。因此,借用检查器不允许示例 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
问题在于 result 在 longest 函数结束时离开作用域并被清理。我们还试图从函数返回一个指向 result 的引用。没有任何方式可以通过指定生命周期参数来改变这个悬垂引用的问题,Rust 也不会允许我们创建悬垂引用。在这种情况下,最好的修复方法是返回一个拥有所有权的数据类型而不是引用,这样调用函数就负责清理该值。
归根结底,生命周期语法就是将函数的各个参数和返回值的生命周期关联起来。一旦它们关联起来,Rust 就有了足够的信息来允许内存安全的操作,并拒绝那些会创建悬垂指针或以其他方式违反内存安全的操作。
结构体定义中的生命周期标注
到目前为止,我们定义的结构体都持有拥有所有权的类型。我们也可以定义持有引用的结构体,但在这种情况下,需要为结构体定义中的每个引用添加生命周期标注。示例 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 实例创建之前就已经存在。此外,novel 在 ImportantExcerpt 离开作用域之后才离开作用域,所以 ImportantExcerpt 实例中的引用是有效的。
生命周期省略
你已经了解到每个引用都有一个生命周期,并且需要为使用引用的函数或结构体指定生命周期参数。然而,我们在示例 4-9 中有一个函数(在示例 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 引用分析中的这些模式被称为生命周期省略规则(lifetime elision rules)。这些不是程序员需要遵循的规则;它们是编译器会考虑的一组特定情况,如果你的代码符合这些情况,就不需要显式编写生命周期。
省略规则并不提供完整的推断。如果在 Rust 应用规则之后,引用的生命周期仍然存在歧义,编译器不会猜测剩余引用的生命周期应该是什么。编译器会给出一个错误,你可以通过添加生命周期标注来解决。
函数或方法参数上的生命周期被称为输入生命周期(input lifetimes),返回值上的生命周期被称为输出生命周期(output lifetimes)。
编译器使用三条规则来推断没有显式标注时引用的生命周期。第一条规则适用于输入生命周期,第二条和第三条规则适用于输出生命周期。如果编译器应用完三条规则后仍有无法确定生命周期的引用,编译器将报错。这些规则适用于 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 的生命周期会被赋给所有输出生命周期参数。第三条规则使得方法的读写更加简洁,因为需要的符号更少。
让我们假装自己是编译器。我们将应用这些规则来推断示例 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 {
现在这个函数签名中的所有引用都有了生命周期,编译器可以继续分析,而不需要程序员标注这个函数签名中的生命周期。
让我们再看另一个例子,这次使用我们在示例 10-20 中开始处理时没有生命周期参数的 longest 函数:
fn longest(x: &str, y: &str) -> &str {
应用第一条规则:每个参数获得自己的生命周期。这次我们有两个参数而不是一个,所以有两个生命周期:
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
可以看到第二条规则不适用,因为有多个输入生命周期。第三条规则也不适用,因为 longest 是一个函数而不是方法,所以没有参数是 self。在应用完所有三条规则之后,我们仍然没有确定返回类型的生命周期。这就是为什么我们在尝试编译示例 10-20 中的代码时会得到错误:编译器应用了生命周期省略规则,但仍然无法确定签名中所有引用的生命周期。
因为第三条规则实际上只适用于方法签名,接下来我们将在方法的上下文中讨论生命周期,看看为什么第三条规则意味着我们通常不需要在方法签名中标注生命周期。
方法定义中的生命周期标注
当我们在带有生命周期的结构体上实现方法时,使用的语法与示例 10-11 中泛型类型参数的语法相同。声明和使用生命周期参数的位置取决于它们是与结构体字段相关还是与方法参数和返回值相关。
结构体字段的生命周期名称总是需要在 impl 关键字之后声明,然后在结构体名称之后使用,因为这些生命周期是结构体类型的一部分。
在 impl 块内的方法签名中,引用可能与结构体字段中引用的生命周期绑定,也可能是独立的。此外,生命周期省略规则通常使得方法签名中不需要生命周期标注。让我们看一些使用示例 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 应用第一条生命周期省略规则,给 &self 和 announcement 各自分配生命周期。然后,因为其中一个参数是 &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 }
}
这是示例 10-21 中返回两个字符串切片中较长者的 longest 函数。但现在它多了一个名为 ann 的参数,其泛型类型为 T,可以填入任何实现了 Display trait 的类型(由 where 子句指定)。这个额外的参数将使用 {} 打印,因此需要 Display trait 约束。因为生命周期是一种泛型,所以生命周期参数 'a 和泛型类型参数 T 放在函数名后面的同一个尖括号列表中。
总结
本章涵盖了很多内容!现在你已经了解了泛型类型参数、trait 和 trait 约束,以及泛型生命周期参数,你已经准备好编写在许多不同场景下都能工作且没有重复的代码了。泛型类型参数让你可以将代码应用于不同的类型。trait 和 trait 约束确保即使类型是泛型的,它们也具有代码所需的行为。你学会了如何使用生命周期标注来确保这些灵活的代码不会产生任何悬垂引用。而所有这些分析都发生在编译时,不会影响运行时性能!
信不信由你,本章讨论的主题还有更多内容可以学习:第十八章讨论 trait 对象,这是使用 trait 的另一种方式。还有一些更复杂的涉及生命周期标注的场景,你只会在非常高级的情况下才需要用到;关于这些内容,你应该阅读 Rust 参考手册。接下来,你将学习如何在 Rust 中编写测试,以确保你的代码按预期工作。
编写自动化测试
Edsger W. Dijkstra 在 1972 年的论文《谦逊的程序员》(“The Humble Programmer”)中说道:“程序测试可以是一种非常有效的发现 bug 的方式,但它对于证明 bug 不存在则无能为力。“这并不意味着我们不应该尽可能多地进行测试!
程序的正确性(correctness)是指代码在多大程度上做到了我们期望它做的事情。Rust 在设计时就高度关注程序的正确性,但正确性是一个复杂的问题,不容易证明。Rust 的类型系统承担了很大一部分保障工作,但类型系统并不能捕获所有问题。因此,Rust 内置了对编写自动化软件测试的支持。
假设我们编写了一个 add_two 函数,它将传入的数字加 2。这个函数的签名接受一个整数作为参数,并返回一个整数作为结果。当我们实现并编译这个函数时,Rust 会执行你之前学过的所有类型检查和借用检查,以确保我们不会向这个函数传递 String 值或无效的引用。但 Rust 无法检查这个函数是否确实做到了我们期望的事情——即返回参数加 2,而不是参数加 10 或参数减 50!这就是测试发挥作用的地方。
我们可以编写测试来断言,例如,当我们将 3 传递给 add_two 函数时,返回值是 5。每当我们修改代码时,都可以运行这些测试,以确保任何已有的正确行为没有被改变。
测试是一项复杂的技能:虽然我们无法在一章中涵盖如何编写好测试的每一个细节,但在本章中我们将讨论 Rust 测试功能的机制。我们会介绍编写测试时可用的注解和宏、运行测试时提供的默认行为和选项,以及如何将测试组织为单元测试和集成测试。
如何编写测试
如何编写测试
测试(test)是一种 Rust 函数,用于验证非测试代码是否按预期方式运行。测试函数体通常执行以下三个操作:
- 设置所需的数据或状态。
- 运行要测试的代码。
- 断言结果是否符合预期。
让我们来看看 Rust 专门为编写测试提供的特性,包括 test 属性、一些宏,以及 should_panic 属性。
测试函数的结构
最简单的情况下,Rust 中的测试就是一个标注了 test 属性的函数。属性(attribute)是关于 Rust 代码片段的元数据;第五章中我们在结构体上使用的 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);
}
}
cargo new 自动生成的代码文件开头有一个示例 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 (target/debug/deps/adder-01ad14159ff659ab)
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 命令传递参数,只运行名称匹配某个字符串的测试;这称为过滤(filtering),我们将在“按名称运行部分测试”部分介绍。这里我们没有过滤要运行的测试,所以摘要末尾显示 0 filtered out。
0 measured 统计的是衡量性能的基准测试(benchmark test)。截至本文撰写时,基准测试仅在 nightly 版本的 Rust 中可用。请参阅基准测试的文档了解更多信息。
测试输出的下一部分从 Doc-tests adder 开始,是文档测试的结果。我们目前还没有任何文档测试,但 Rust 可以编译出现在 API 文档中的任何代码示例。这个特性有助于保持文档和代码的同步!我们将在第十四章的“文档注释作为测试”部分讨论如何编写文档测试。现在,我们先忽略 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 时,测试就会失败。每个测试都在一个新线程中运行,当主线程检测到某个测试线程已终止时,该测试就会被标记为失败。在第九章中,我们讨论过引发 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");
}
}
panic! 宏而失败使用 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`
test tests::another 这一行显示的不是 ok,而是 FAILED。在单个测试结果和摘要之间出现了两个新的部分:第一部分显示每个测试失败的详细原因。在这个例子中,我们得到的详细信息是 tests::another 因为在 src/lib.rs 文件第 17 行 panic 并输出了消息 Make this test fail 而失败。下一部分仅列出所有失败测试的名称,这在有大量测试和大量详细失败输出时非常有用。我们可以使用失败测试的名称来单独运行该测试,以便更容易地调试;我们将在“控制测试的运行方式”部分详细讨论运行测试的方式。
摘要行显示在最后:总体测试结果为 FAILED。我们有一个测试通过,一个测试失败。
现在你已经了解了不同场景下测试结果的样子,让我们来看看除 panic! 之外在测试中有用的一些宏。
使用 assert! 检查结果
标准库提供的 assert! 宏在你想要确保测试中某个条件求值为 true 时非常有用。我们给 assert! 宏传递一个求值为布尔值的参数。如果值为 true,则什么也不会发生,测试通过。如果值为 false,assert! 宏会调用 panic! 导致测试失败。使用 assert! 宏可以帮助我们检查代码是否按预期方式运行。
在第五章的 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
}
}
Rectangle 结构体及其 can_hold 方法can_hold 方法返回一个布尔值,这意味着它是 assert! 宏的完美用例。在 Listing 11-6 中,我们编写了一个测试来验证 can_hold 方法:创建一个宽度为 8、高度为 7 的 Rectangle 实例,并断言它可以容纳另一个宽度为 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));
}
}
can_hold 的用例,检查较大的矩形确实能容纳较小的矩形注意 tests 模块内的 use super::*; 这一行。tests 模块是一个遵循常规可见性规则的普通模块,我们在第七章的“引用模块项目树中项的路径”部分介绍过这些规则。因为 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.width 是 8 而 smaller.width 是 5,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: u64) -> u64 {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
}
assert_eq! 宏测试函数 add_two让我们检查一下它是否通过!
$ 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) 的结果。然后,我们将 result 和 4 作为参数传递给 assert_eq! 宏。这个测试的输出行是 test tests::it_adds_two ... ok,ok 文本表明我们的测试通过了!
让我们在代码中引入一个 bug,看看 assert_eq! 失败时是什么样子。将 add_two 函数的实现改为加 3:
pub fn add_two(a: u64) -> u64 {
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!tests::it_adds_two 测试失败了,消息告诉我们失败的断言是 left == right,以及 left 和 right 的值分别是什么。这条消息帮助我们开始调试:left 参数(即调用 add_two(2) 的结果)是 5,而 right 参数是 4。可以想象,当有大量测试在运行时,这会特别有帮助。
注意,在某些语言和测试框架中,相等断言函数的参数被称为 expected 和 actual,并且指定参数的顺序很重要。然而在 Rust 中,它们被称为 left 和 right,我们指定期望值和代码产生的值的顺序并不重要。我们可以将这个测试中的断言写成 assert_eq!(4, result),这会产生相同的失败消息,显示 assertion `left == right` failed。
assert_ne! 宏在我们给它的两个值不相等时通过,相等时失败。这个宏在我们不确定一个值会是什么,但知道它绝对不应该是什么的情况下最为有用。例如,如果我们正在测试一个保证会以某种方式改变其输入的函数,但输入被改变的方式取决于我们运行测试的星期几,那么最好的断言可能是函数的输出不等于输入。
在底层,assert_eq! 和 assert_ne! 宏分别使用 == 和 != 运算符。当断言失败时,这些宏会使用调试格式打印它们的参数,这意味着被比较的值必须实现 PartialEq 和 Debug trait。所有基本类型和大部分标准库类型都实现了这些 trait。对于你自己定义的结构体和枚举,你需要实现 PartialEq 来断言这些类型的相等性。你还需要实现 Debug 以便在断言失败时打印值。因为这两个 trait 都是可派生的 trait,如第五章 Listing 5-12 中所述,通常只需在结构体或枚举定义上添加 #[derive(PartialEq, Debug)] 标注即可。有关这些和其他可派生 trait 的更多详情,请参阅附录 C “可派生的 trait”。
添加自定义失败消息
你还可以向 assert!、assert_eq! 和 assert_ne! 宏添加自定义消息,作为可选参数与失败消息一起打印。在必需参数之后指定的任何参数都会传递给 format! 宏(在第八章的“使用 + 运算符或 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
除了检查返回值之外,检查我们的代码是否按预期处理错误条件也很重要。例如,考虑我们在第九章 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);
}
}
panic!我们将 #[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 at src/lib.rs:21:8
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);
}
}
panic! 时 panic 消息包含指定子字符串这个测试会通过,因为我们放在 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 测试失败时会怎样,让我们再次通过交换 if value < 1 和 else if value > 100 代码块的主体来引入一个 bug:
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(()),在测试失败时返回一个包含 String 的 Err。
编写返回 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! 的输出;我们只会看到表示测试通过的那一行。如果测试失败了,我们则会看到打印到标准输出的所有内容,以及其余的失败信息。
举个例子,示例 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);
}
}
println! 的函数当我们使用 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 则出现在测试摘要输出部分,同时还显示了测试失败的原因。
如果我们也想看到通过的测试的打印值,可以使用 --show-output 告诉 Rust 同时显示成功测试的输出:
$ cargo test -- --show-output
当我们使用 --show-output 标志再次运行示例 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 函数创建三个测试,如示例 11-11 所示,然后选择运行其中的部分测试。
pub fn add_two(a: u64) -> u64 {
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 属性来标注那些耗时的测试以将其排除,如下所示:
文件名: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 tests::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 社区将测试分为两大类:单元测试(unit tests)和集成测试(integration tests)。单元测试小而专注,每次单独测试一个模块,并且可以测试私有接口。集成测试则完全位于你的库外部,以与其他外部代码相同的方式使用你的代码,只使用公有接口,并且每个测试可能会涉及多个模块。
编写这两种测试对于确保你的库的各个部分能够独立地和协同地按预期工作都很重要。
单元测试
单元测试的目的是将每个代码单元与其余代码隔离开来进行测试,以便快速定位代码在哪里正常工作、在哪里不正常。你需要将单元测试放在 src 目录下的每个文件中,与它们所测试的代码放在一起。惯例是在每个文件中创建一个名为 tests 的模块来包含测试函数,并使用 cfg(test) 来标注这个模块。
tests 模块和 #[cfg(test)]
tests 模块上的 #[cfg(test)] 注解告诉 Rust 只在运行 cargo test 时才编译和运行测试代码,而在运行 cargo build 时不这样做。这在你只想构建库的时候节省了编译时间,也因为测试没有被包含在内而节省了编译产物的空间。你会看到,由于集成测试放在不同的目录中,它们不需要 #[cfg(test)] 注解。然而,由于单元测试与代码放在同一个文件中,你需要使用 #[cfg(test)] 来指定它们不应被包含在编译结果中。
回忆一下,当我们在本章第一节生成新的 adder 项目时,Cargo 为我们生成了如下代码:
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);
}
}
在自动生成的 tests 模块上,cfg 属性代表 configuration(配置),它告诉 Rust 只有在给定特定配置选项时才应包含后面的项。在这种情况下,配置选项是 test,这是 Rust 为编译和运行测试而提供的。通过使用 cfg 属性,Cargo 只在我们主动使用 cargo test 运行测试时才编译测试代码。这包括该模块中可能存在的任何辅助函数,以及用 #[test] 标注的函数。
测试私有函数
在测试社区中,关于是否应该直接测试私有函数存在争论,而且其他语言使得测试私有函数变得困难甚至不可能。无论你遵循哪种测试理念,Rust 的隐私规则确实允许你测试私有函数。考虑示例 11-12 中包含私有函数 internal_adder 的代码。
pub fn add_two(a: u64) -> u64 {
internal_adder(a, 2)
}
fn internal_adder(left: u64, right: u64) -> u64 {
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 文件中仍然保留示例 11-12 的代码,创建一个 tests 目录,并新建一个名为 tests/integration_test.rs 的文件。你的目录结构应该如下所示:
adder
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── tests
└── integration_test.rs
将示例 11-13 中的代码输入到 tests/integration_test.rs 文件中。
use adder::add_two;
#[test]
fn it_adds_two() {
let result = add_two(2);
assert_eq!(result, 4);
}
adder crate 中函数的集成测试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
输出的三个部分包括单元测试、集成测试和文档测试。注意,如果某个部分中的任何测试失败了,后续部分将不会运行。例如,如果一个单元测试失败了,就不会有集成测试和文档测试的输出,因为这些测试只有在所有单元测试都通过时才会运行。
单元测试部分与我们之前看到的一样:每个单元测试一行(我们在示例 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 中添加一些希望从多个测试文件中的多个测试函数调用的代码:
Filename: 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 函数的示例:
Filename: 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; 声明与我们在示例 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 的类型系统和所有权规则有助于防止某些类型的 bug,但测试对于减少与代码预期行为相关的逻辑 bug 仍然很重要。
让我们结合你在本章和之前章节中学到的知识,来做一个项目吧!
一个 I/O 项目:构建命令行程序
本章是对你目前所学众多技能的一次回顾,同时也会探索标准库的更多功能。我们将构建一个与文件和命令行输入/输出交互的命令行工具,来实践你已经掌握的一些 Rust 概念。
Rust 的速度、安全性、单二进制文件输出以及跨平台支持,使其成为创建命令行工具的理想语言。因此在我们的项目中,我们将实现自己版本的经典命令行搜索工具 grep(globally search a regular expression and print,即全局正则表达式搜索与打印)。在最简单的使用场景中,grep 在指定文件中搜索指定的字符串。为此,grep 接受一个文件路径和一个字符串作为参数,然后读取文件,找到文件中包含该字符串参数的行,并将这些行打印出来。
在此过程中,我们将展示如何让命令行工具使用许多其他命令行工具都会用到的终端功能。我们将读取环境变量的值,以允许用户配置工具的行为。我们还会将错误信息打印到标准错误输出流(stderr)而非标准输出(stdout),这样用户就可以将成功的输出重定向到文件,同时仍然能在屏幕上看到错误信息。
Rust 社区成员 Andrew Gallant 已经创建了一个功能完备、速度极快的 grep 版本,名为 ripgrep。相比之下,我们的版本会相当简单,但本章将为你提供理解像 ripgrep 这样的真实项目所需的背景知识。
我们的 grep 项目将综合运用你目前学到的多个概念:
我们还会简要介绍闭包(closure)、迭代器(iterator)和 trait 对象,第十三章和第十八章将详细讲解这些内容。
接受命令行参数
接受命令行参数
让我们像往常一样使用 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 的命令行参数的迭代器(iterator)。我们将在第十三章中全面介绍迭代器。现在,你只需要了解关于迭代器的两个要点:迭代器会产生一系列值,我们可以对迭代器调用 collect 方法将其转换为一个集合,比如包含迭代器所产生的所有元素的 vector。
示例 12-1 中的代码让你的 minigrep 程序能够读取传递给它的所有命令行参数,然后将这些值收集到一个 vector 中。
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
dbg!(args);
}
首先,我们通过 use 语句将 std::env 模块引入作用域,以便使用它的 args 函数。注意 std::env::args 函数嵌套在两层模块中。正如我们在第七章中讨论的那样,当所需函数嵌套在多个模块中时,我们选择将父模块引入作用域而非直接引入函数本身。这样做的好处是可以方便地使用 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 将迭代器转换为一个包含迭代器所产生的所有值的 vector。我们可以使用 collect 函数来创建多种类型的集合,因此我们显式标注了 args 的类型,以指定我们想要一个字符串 vector。虽然在 Rust 中很少需要标注类型,但 collect 是一个经常需要标注的函数,因为 Rust 无法推断出你想要的集合类型。
最后,我们使用调试宏打印这个 vector。让我们先不带参数运行代码,然后再带两个参数试试:
$ 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",
]
注意 vector 中的第一个值是 "target/debug/minigrep",这是我们二进制文件的名称。这与 C 语言中参数列表的行为一致,让程序可以在执行过程中使用调用它时所用的名称。如果你想在消息中打印程序名称,或者根据调用程序时使用的命令行别名来改变程序行为,访问程序名称通常是很方便的。但就本章的目的而言,我们将忽略它,只保存我们需要的两个参数。
将参数值保存到变量中
目前程序已经能够访问命令行参数中指定的值了。现在我们需要将这两个参数的值保存到变量中,以便在程序的其余部分使用。我们在示例 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}");
}
正如我们打印 vector 时所看到的,程序名称占据了 vector 中索引 args[0] 处的第一个值,所以我们从索引 1 开始获取参数。minigrep 接受的第一个参数是要搜索的字符串,因此我们将第一个参数的引用存入变量 query。第二个参数是文件路径,因此我们将第二个参数的引用存入变量 file_path。
我们临时打印这些变量的值,以验证代码按预期工作。让我们再次使用参数 test 和 sample.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 函数处理的独立任务数量也会增加。当一个函数承担的职责越来越多时,它就越难以理解、越难以测试,也越难在不破坏其某个部分的情况下进行修改。最好将功能分离开来,使每个函数只负责一个任务。
这个问题也与第二个问题相关:虽然 query 和 file_path 是程序的配置变量,但像 contents 这样的变量是用来执行程序逻辑的。main 函数越长,我们需要引入作用域的变量就越多;作用域中的变量越多,就越难追踪每个变量的用途。最好将配置变量组合到一个结构体中,以明确它们的用途。
第三个问题是,我们使用 expect 在读取文件失败时打印错误信息,但错误信息只是打印了 Should have been able to read the file。读取文件可能因多种原因失败:例如,文件可能不存在,或者我们可能没有权限打开它。目前,无论什么情况,我们都会打印相同的错误信息,这不会给用户提供任何有用的信息!
第四,我们使用 expect 来处理错误,如果用户在运行程序时没有指定足够的参数,他们会从 Rust 得到一个 index out of bounds 错误,这并不能清楚地解释问题所在。最好将所有错误处理代码放在一个地方,这样未来的维护者只需要在一个地方查看代码,就能了解错误处理逻辑是否需要修改。将所有错误处理代码放在一个地方还能确保我们打印的信息对最终用户是有意义的。
让我们通过重构项目来解决这四个问题。
分离二进制项目的关注点
将多个任务的职责分配给 main 函数,这个组织问题在许多二进制项目中都很常见。因此,许多 Rust 程序员发现,当 main 函数开始变得庞大时,将二进制程序的不同关注点分离开来是很有用的。这个过程包含以下步骤:
- 将程序拆分为 main.rs 文件和 lib.rs 文件,并将程序的逻辑移到 lib.rs 中。
- 只要命令行解析逻辑较小,它就可以留在
main函数中。 - 当命令行解析逻辑开始变得复杂时,将其从
main函数中提取到其他函数或类型中。
经过这个过程后,留在 main 函数中的职责应该限于以下几项:
- 使用参数值调用命令行解析逻辑
- 设置任何其他配置
- 调用 lib.rs 中的
run函数 - 如果
run返回错误,则处理该错误
这个模式的核心是关注点分离:main.rs 负责运行程序,而 lib.rs 负责处理手头任务的所有逻辑。因为你无法直接测试 main 函数,所以这种结构让你可以通过将所有程序逻辑移出 main 函数来进行测试。留在 main 函数中的代码将足够小,可以通过阅读来验证其正确性。让我们按照这个过程来重构我们的程序。
提取参数解析器
我们将把解析参数的功能提取到一个函数中,main 将调用这个函数。示例 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 中提取 parse_config 函数我们仍然将命令行参数收集到一个向量中,但不再在 main 函数中将索引 1 处的参数值赋给变量 query、将索引 2 处的参数值赋给变量 file_path,而是将整个向量传递给 parse_config 函数。parse_config 函数随后包含了确定哪个参数对应哪个变量的逻辑,并将值传回 main。我们仍然在 main 中创建 query 和 file_path 变量,但 main 不再负责确定命令行参数和变量之间的对应关系。
对于我们这个小程序来说,这次重构可能看起来有些过度,但我们是在以小而渐进的步骤进行重构。做完这个改动后,再次运行程序以验证参数解析仍然正常工作。经常检查进度是个好习惯,这有助于在问题出现时找到原因。
组合配置值
我们可以再迈出一小步来进一步改进 parse_config 函数。目前,我们返回的是一个元组,但随后又立即将元组拆分为单独的部分。这表明我们可能还没有找到正确的抽象。
另一个表明还有改进空间的迹象是 parse_config 中的 config 部分,它暗示我们返回的两个值是相关的,并且都是一个配置值的组成部分。目前我们除了将两个值组合成元组之外,并没有在数据结构中传达这层含义;我们将改为把两个值放入一个结构体中,并给每个结构体字段一个有意义的名称。这样做将使未来的代码维护者更容易理解不同值之间的关系以及它们的用途。
示例 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 }
}
parse_config 以返回 Config 结构体的实例我们新增了一个名为 Config 的结构体,定义了 query 和 file_path 两个字段。parse_config 的签名现在表明它返回一个 Config 值。在 parse_config 的函数体中,我们之前返回的是引用 args 中 String 值的字符串切片,现在我们将 Config 定义为包含拥有所有权的 String 值。main 中的 args 变量是参数值的所有者,只是让 parse_config 函数借用它们,这意味着如果 Config 试图获取 args 中值的所有权,就会违反 Rust 的借用规则。
管理 String 数据有多种方式;最简单的(虽然有些低效)方法是对值调用 clone 方法。这会为 Config 实例创建数据的完整副本以供其拥有,这比存储字符串数据的引用需要更多的时间和内存。然而,克隆数据也使我们的代码非常直观,因为我们不必管理引用的生命周期(lifetime);在这种情况下,牺牲一点性能来换取简洁性是值得的。
使用 clone 的权衡
许多 Rustacean 倾向于避免使用 clone 来解决所有权问题,因为它有运行时开销。在第 13 章中,你将学习如何在这类情况下使用更高效的方法。但现在,复制几个字符串来继续推进是没问题的,因为你只会复制一次,而且文件路径和查询字符串都非常小。拥有一个稍微低效但能工作的程序,比在第一次编写时就试图过度优化代码要好。随着你对 Rust 越来越有经验,从最高效的方案开始会变得更容易,但现在调用 clone 是完全可以接受的。
我们更新了 main,将 parse_config 返回的 Config 实例放入名为 config 的变量中,并更新了之前使用单独的 query 和 file_path 变量的代码,改为使用 Config 结构体上的字段。
现在我们的代码更清楚地表达了 query 和 file_path 是相关的,它们的用途是配置程序的工作方式。任何使用这些值的代码都知道在 config 实例中以其用途命名的字段中找到它们。
为 Config 创建构造函数
到目前为止,我们已经将负责解析命令行参数的逻辑从 main 中提取出来,放到了 parse_config 函数中。这样做帮助我们看到 query 和 file_path 值是相关的,这种关系应该在代码中体现出来。然后我们添加了一个 Config 结构体来命名 query 和 file_path 的相关用途,并能够从 parse_config 函数中以结构体字段名的形式返回这些值的名称。
那么,既然 parse_config 函数的目的是创建一个 Config 实例,我们可以将 parse_config 从一个普通函数改为与 Config 结构体关联的名为 new 的函数。这个改动将使代码更加地道。我们可以通过调用 String::new 来创建标准库中类型的实例,如 String。类似地,通过将 parse_config 改为与 Config 关联的 new 函数,我们就能通过调用 Config::new 来创建 Config 的实例。示例 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 }
}
}
parse_config 改为 Config::new我们更新了 main,将之前调用 parse_config 的地方改为调用 Config::new。我们将 parse_config 的名称改为 new,并将其移到一个 impl 块中,这样就将 new 函数与 Config 关联起来了。再次尝试编译这段代码,确保它能正常工作。
修复错误处理
现在我们来修复错误处理。回忆一下,如果向量包含的元素少于三个,尝试访问 args 向量中索引 1 或索引 2 处的值会导致程序 panic。试着不带任何参数运行程序;输出将如下所示:
$ 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 这行是给程序员看的错误信息。它无法帮助最终用户理解他们应该怎么做。让我们现在来修复这个问题。
改进错误信息
在示例 12-8 中,我们在 new 函数中添加了一个检查,在访问索引 1 和索引 2 之前验证切片是否足够长。如果切片不够长,程序会 panic 并显示一条更好的错误信息。
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 }
}
}
这段代码类似于我们在示例 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
这个输出好多了:我们现在有了一条合理的错误信息。然而,我们也有一些不想展示给用户的多余信息。也许我们在示例 9-13 中使用的技术并不是这里最好的选择:调用 panic! 更适合编程问题而非使用问题,正如第 9 章中讨论的那样。相反,我们将使用你在第 9 章中学到的另一种技术——返回一个 Result来表示成功或错误。
返回 Result 而不是调用 panic!
我们可以改为返回一个 Result 值,在成功时包含一个 Config 实例,在错误时描述问题。我们还将把函数名从 new 改为 build,因为许多程序员期望 new 函数永远不会失败。当 Config::build 与 main 通信时,我们可以使用 Result 类型来表示出现了问题。然后,我们可以修改 main,将 Err 变体转换为对用户更实用的错误信息,而不会出现调用 panic! 时产生的关于 thread 'main' 和 RUST_BACKTRACE 的周围文本。
示例 12-9 展示了我们需要对现在称为 Config::build 的函数的返回值和函数体所做的改动。注意,在我们同时更新 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 })
}
}
Config::build 返回 Result我们的 build 函数在成功时返回一个包含 Config 实例的 Result,在错误时返回一个字符串字面值。我们的错误值始终是具有 'static 生命周期的字符串字面值。
我们在函数体中做了两处改动:当用户没有传递足够的参数时,我们不再调用 panic!,而是返回一个 Err 值,并且我们将 Config 返回值包装在了 Ok 中。这些改动使函数符合其新的类型签名。
从 Config::build 返回 Err 值允许 main 函数处理 build 函数返回的 Result 值,并在错误情况下更干净地退出进程。
调用 Config::build 并处理错误
为了处理错误情况并打印用户友好的信息,我们需要更新 main 来处理 Config::build 返回的 Result,如示例 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 })
}
}
Config 失败则以错误码退出在这个示例中,我们使用了一个尚未详细介绍的方法:unwrap_or_else,它由标准库定义在 Result<T, E> 上。使用 unwrap_or_else 允许我们定义一些自定义的、非 panic! 的错误处理。如果 Result 是 Ok 值,这个方法的行为类似于 unwrap:它返回 Ok 包装的内部值。然而,如果值是 Err,这个方法会调用闭包(closure)中的代码,闭包是我们定义并作为参数传递给 unwrap_or_else 的匿名函数。我们将在第 13 章中更详细地介绍闭包。现在,你只需要知道 unwrap_or_else 会将 Err 的内部值——在本例中是我们在示例 12-9 中添加的静态字符串 "not enough arguments"——传递给闭包中出现在竖线之间的参数 err。闭包中的代码随后可以在运行时使用 err 值。
我们新增了一行 use 来将标准库中的 process 引入作用域。在错误情况下运行的闭包代码只有两行:我们打印 err 值,然后调用 process::exit。process::exit 函数会立即停止程序,并将传入的数字作为退出状态码返回。这类似于我们在示例 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 函数将变得简洁,易于通过检查来验证,并且我们将能够为所有其他逻辑编写测试。
示例 12-11 展示了提取 run 函数这一小而渐进的改进。
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 函数run 函数现在包含了 main 中从读取文件开始的所有剩余逻辑。run 函数接受 Config 实例作为参数。
从 run 返回错误
将剩余的程序逻辑分离到 run 函数中之后,我们可以像在示例 12-9 中对 Config::build 所做的那样改进错误处理。run 函数将在出错时返回 Result<T, E>,而不是通过调用 expect 让程序 panic。这将让我们进一步把错误处理逻辑整合到 main 中,以用户友好的方式处理。示例 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我们在这里做了三个重要的改动。首先,我们将 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 返回的错误
我们将使用类似于示例 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 返回 Config 实例那样返回一个我们想要 unwrap 的值。因为 run 在成功时返回 (),我们只关心检测错误,所以不需要 unwrap_or_else 来返回解包后的值,那只会是 ()。
if let 和 unwrap_or_else 函数的函数体在两种情况下是相同的:我们打印错误并退出。
将代码拆分为库 Crate
我们的 minigrep 项目目前看起来不错!现在我们将拆分 src/main.rs 文件,把一些代码放入 src/lib.rs 文件中。这样,我们就可以测试代码,并且让 src/main.rs 文件承担更少的职责。
让我们将负责搜索文本的代码定义在 src/lib.rs 中而不是 src/main.rs 中,这样我们(或任何使用我们 minigrep 库的人)就可以在比 minigrep 二进制程序更多的上下文中调用搜索函数。
首先,让我们在 src/lib.rs 中定义 search 函数的签名,如示例 12-13 所示,函数体调用 unimplemented! 宏。我们将在填充实现时更详细地解释签名。
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
unimplemented!();
}
search 函数我们在函数定义上使用了 pub 关键字,将 search 指定为库 crate 公共 API 的一部分。现在我们有了一个可以从二进制 crate 中使用并且可以测试的库 crate!
现在我们需要将 src/lib.rs 中定义的代码引入二进制 crate src/main.rs 的作用域并调用它,如示例 12-14 所示。
use std::env;
use std::error::Error;
use std::fs;
use std::process;
// --snip--
use minigrep::search;
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);
});
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
// --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 })
}
}
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(())
}
minigrep 库 crate 的 search 函数我们添加了一行 use minigrep::search 来将 search 函数从库 crate 引入二进制 crate 的作用域。然后,在 run 函数中,我们不再打印文件内容,而是调用 search 函数并将 config.query 值和 contents 作为参数传递。接着,run 使用 for 循环打印 search 返回的每一行匹配结果。这也是一个好时机来移除 main 函数中显示查询字符串和文件路径的 println! 调用,这样我们的程序就只打印搜索结果(如果没有错误发生的话)。
注意,搜索函数会将所有结果收集到一个向量中并返回,然后才进行打印。在搜索大文件时,这种实现可能会导致结果显示较慢,因为结果不是在找到时就打印的;我们将在第 13 章中讨论使用迭代器(iterator)来解决这个问题的可能方式。
呼!这是一项大工程,但我们为未来的成功奠定了基础。现在处理错误要容易得多,而且我们使代码更加模块化了。从现在开始,几乎所有的工作都将在 src/lib.rs 中完成。
让我们利用这种新获得的模块化优势,做一些用旧代码很难做到但用新代码很容易做到的事情:我们来编写一些测试!
通过测试驱动开发增加功能
通过测试驱动开发添加功能
现在我们已经将搜索逻辑提取到了 src/lib.rs 中,与 main 函数分离开来,编写核心功能的测试就容易多了。我们可以直接用各种参数调用函数并检查返回值,而无需从命令行调用二进制文件。
在本节中,我们将使用测试驱动开发(TDD)流程为 minigrep 程序添加搜索逻辑,步骤如下:
- 编写一个会失败的测试,运行它以确保它因你预期的原因而失败。
- 编写或修改刚好足够的代码使新测试通过。
- 重构你刚刚添加或修改的代码,并确保测试仍然通过。
- 从步骤 1 重新开始!
虽然 TDD 只是众多软件编写方式中的一种,但它有助于驱动代码设计。在编写使测试通过的代码之前先编写测试,有助于在整个过程中保持较高的测试覆盖率。
我们将用测试驱动的方式来实现在文件内容中搜索查询字符串并生成匹配行列表的功能。我们将在一个名为 search 的函数中添加这个功能。
编写一个失败的测试
在 src/lib.rs 中,我们将添加一个包含测试函数的 tests 模块,就像我们在第十一章中所做的那样。测试函数指定了我们希望 search 函数具有的行为:它接受一个查询字符串和要搜索的文本,并只返回文本中包含查询字符串的行。示例 12-15 展示了这个测试。
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
unimplemented!();
}
// --snip--
#[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 函数功能创建一个失败的测试这个测试搜索字符串 "duct"。我们要搜索的文本有三行,其中只有一行包含 "duct"(注意,开头双引号后面的反斜杠告诉 Rust 不要在这个字符串字面量的内容开头放置换行符)。我们断言 search 函数的返回值只包含我们期望的那一行。
如果运行这个测试,它目前会失败,因为 unimplemented! 宏会 panic 并显示“not implemented“消息。按照 TDD 原则,我们先迈出一小步,只添加刚好足够的代码,使调用函数时不会 panic——定义 search 函数始终返回一个空 vector,如示例 12-16 所示。然后测试应该能编译但会失败,因为空 vector 与包含 "safe, fast, productive." 这一行的 vector 不匹配。
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 函数使其调用时不会 panic现在让我们讨论一下为什么需要在 search 的签名中定义一个显式生命周期 'a,并将该生命周期用于 contents 参数和返回值。回忆一下第十章中提到的,生命周期参数指定了哪个参数的生命周期与返回值的生命周期相关联。在这里,我们表明返回的 vector 应该包含引用 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:1:51
|
1 | 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
|
1 | 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 才是应该通过生命周期语法与返回值关联的参数。
其他编程语言不要求你在签名中将参数与返回值关联起来,但随着时间的推移,这种做法会变得越来越自然。你可能想将这个例子与第十章“通过生命周期验证引用”部分中的例子进行对比。
编写代码使测试通过
目前,我们的测试会失败,因为我们总是返回一个空 vector。要修复这个问题并实现 search,我们的程序需要遵循以下步骤:
- 遍历内容的每一行。
- 检查该行是否包含我们的查询字符串。
- 如果包含,将其添加到我们要返回的值列表中。
- 如果不包含,什么也不做。
- 返回匹配的结果列表。
让我们逐步完成每个步骤,从遍历各行开始。
使用 lines 方法遍历各行
Rust 有一个很实用的方法来处理字符串的逐行迭代,它的名字就叫 lines,用法如示例 12-17 所示。注意这段代码还无法编译。
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));
}
}
contents 中的每一行lines 方法返回一个迭代器。我们将在第十三章中深入讨论迭代器。但回忆一下,你在示例 3-5中见过这种使用迭代器的方式,我们在那里用 for 循环配合迭代器对集合中的每个元素执行一些代码。
在每行中搜索查询字符串
接下来,我们将检查当前行是否包含查询字符串。幸运的是,字符串有一个名为 contains 的实用方法可以帮我们完成这个任务!在 search 函数中添加对 contains 方法的调用,如示例 12-18 所示。注意这段代码仍然无法编译。
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));
}
}
query 中的字符串目前,我们正在逐步构建功能。为了让代码能够编译,我们需要从函数体中返回一个值,正如我们在函数签名中所承诺的那样。
存储匹配的行
为了完成这个函数,我们需要一种方式来存储要返回的匹配行。为此,我们可以在 for 循环之前创建一个可变的 vector,并调用 push 方法将 line 存入 vector 中。在 for 循环之后,返回这个 vector,如示例 12-19 所示。
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
测试通过了,说明它能正常工作!
此时,我们可以考虑在保持测试通过的前提下重构搜索函数的实现,以维持相同的功能。搜索函数中的代码还不错,但它没有利用迭代器的一些实用特性。我们将在第十三章中回到这个例子,届时我们将深入探讨迭代器,并看看如何改进它。
现在整个程序应该可以工作了!让我们试一试,首先用一个应该从 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 添加一个额外的功能:一个通过环境变量开启的大小写不敏感搜索选项。我们可以将这个功能做成命令行选项,要求用户每次使用时都输入,但将其设为环境变量后,用户只需设置一次环境变量,就可以在该终端会话中进行大小写不敏感的搜索。
为大小写不敏感搜索编写一个失败的测试
我们首先在 minigrep 库中添加一个新的 search_case_insensitive 函数,当环境变量有值时将调用该函数。我们将继续遵循 TDD 流程,所以第一步仍然是编写一个失败的测试。我们将为新的 search_case_insensitive 函数添加一个新测试,并将旧测试从 one_result 重命名为 case_sensitive,以明确两个测试之间的区别,如示例 12-20 所示。
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" 应该匹配包含 "Rust:" 的行(大写 R)以及 "Trust me." 这一行,即使它们的大小写与查询不同。这是我们的失败测试,由于我们还没有定义 search_case_insensitive 函数,它将无法编译。你可以像我们在示例 12-16 中为 search 函数所做的那样,添加一个始终返回空 vector 的骨架实现,以查看测试编译并失败的情况。
实现 search_case_insensitive 函数
search_case_insensitive 函数如示例 12-21 所示,与 search 函数几乎相同。唯一的区别是我们会将 query 和每一行 line 都转换为小写,这样无论输入参数的大小写如何,在检查该行是否包含查询字符串时它们都是相同的大小写。
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)
);
}
}
search_case_insensitive 函数,在比较之前将查询字符串和行都转换为小写首先,我们将 query 字符串转换为小写并存储在一个同名的新变量中,遮蔽了原来的 query。对查询字符串调用 to_lowercase 是必要的,这样无论用户的查询是 "rust"、"RUST"、"Rust" 还是 "rUsT",我们都会将查询视为 "rust",从而实现大小写不敏感。虽然 to_lowercase 能处理基本的 Unicode,但不会百分之百准确。如果我们在编写一个真正的应用程序,这里需要做更多工作,但本节的重点是环境变量而非 Unicode,所以我们就此打住。
注意 query 现在是一个 String 而非字符串切片,因为调用 to_lowercase 会创建新数据而非引用现有数据。以查询 "rUsT" 为例:这个字符串切片中并不包含小写的 u 或 t 供我们使用,所以我们必须分配一个包含 "rust" 的新 String。现在当我们将 query 作为参数传递给 contains 方法时,需要添加一个 & 符号,因为 contains 的签名定义为接受一个字符串切片。
接下来,我们对每一行 line 也调用 to_lowercase 将所有字符转换为小写。现在我们已经将 line 和 query 都转换为小写,无论查询的大小写如何,都能找到匹配项。
让我们看看这个实现能否通过测试:
$ 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/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
// --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);
});
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
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 })
}
}
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(())
}
我们添加了一个保存布尔值的 ignore_case 字段。接下来,我们需要让 run 函数检查 ignore_case 字段的值,并据此决定是调用 search 函数还是 search_case_insensitive 函数,如示例 12-22 所示。这段代码仍然无法编译。
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
// --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);
});
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
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 })
}
}
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(())
}
config.ignore_case 的值调用 search 或 search_case_insensitive最后,我们需要检查环境变量。处理环境变量的函数位于标准库的 env 模块中,该模块已经在 src/main.rs 的顶部引入了作用域。我们将使用 env 模块中的 var 函数来检查名为 IGNORE_CASE 的环境变量是否设置了任何值,如示例 12-23 所示。
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
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);
});
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
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();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
query,
file_path,
ignore_case,
})
}
}
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(())
}
IGNORE_CASE 的环境变量是否有任何值这里我们创建了一个新变量 ignore_case。为了设置它的值,我们调用 env::var 函数并传入 IGNORE_CASE 环境变量的名称。env::var 函数返回一个 Result:如果环境变量被设置为任何值,它将返回包含该环境变量值的成功 Ok 变体;如果环境变量未设置,则返回 Err 变体。
我们对 Result 使用 is_ok 方法来检查环境变量是否已设置,这意味着程序应该进行大小写不敏感搜索。如果 IGNORE_CASE 环境变量没有被设置为任何值,is_ok 将返回 false,程序将执行大小写敏感搜索。我们不关心环境变量的值,只关心它是否被设置,所以我们使用 is_ok 而非 unwrap、expect 或我们在 Result 上见过的其他方法。
我们将 ignore_case 变量的值传递给 Config 实例,这样 run 函数就可以读取该值并决定是调用 search_case_insensitive 还是 search,正如我们在示例 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_CASE 设置为 1,但使用相同的查询 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
没错,我们的错误信息被打印到了标准输出。像这样的错误信息打印到标准错误会更有用,这样只有成功运行的数据才会写入文件。我们来修改这一点。
将错误信息打印到标准错误
我们将使用示例 12-24 中的代码来修改错误信息的打印方式。由于我们在本章前面进行了重构,所有打印错误信息的代码都在 main 函数中。标准库提供了 eprintln! 宏,它会打印到标准错误流,因此让我们把之前使用 println! 打印错误的两处改为使用 eprintln!。
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
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) = run(config) {
eprintln!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
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();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
query,
file_path,
ignore_case,
})
}
}
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(())
}
eprintln! 将错误信息写入标准错误而不是标准输出现在让我们以同样的方式再次运行程序,不传递任何参数并使用 > 重定向标准输出:
$ 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 特性:闭包(closures)和迭代器(iterators)。
函数式语言特性:迭代器与闭包
Rust 的设计从许多现有语言和技术中汲取了灵感,其中一个重要的影响来自函数式编程(functional programming)。函数式风格的编程通常包括将函数作为值来使用——将它们作为参数传递给其他函数、从函数中返回它们、将它们赋值给变量以便稍后执行,等等。
在本章中,我们不会讨论函数式编程是什么或不是什么的问题,而是介绍 Rust 中一些与许多常被称为函数式的语言中类似的特性。
具体来说,我们将涵盖:
- 闭包(Closures),一种可以存储在变量中的类函数结构
- 迭代器(Iterators),一种处理一系列元素的方式
- 如何使用闭包和迭代器来改进第 12 章的 I/O 项目
- 闭包和迭代器的性能(剧透:它们比你想象的要快!)
我们已经介绍了一些同样受函数式风格影响的 Rust 特性,比如模式匹配和枚举。由于掌握闭包和迭代器是编写快速、地道的 Rust 代码的重要组成部分,我们将用整章的篇幅来讲解它们。
闭包
闭包
Rust 的闭包(closures)是可以保存在变量中或作为参数传递给其他函数的匿名函数。你可以在一个地方创建闭包,然后在不同的上下文中调用它。与函数不同,闭包可以捕获其定义所在作用域中的值。我们将展示闭包的这些特性如何实现代码复用和行为定制。
捕获环境
我们首先来看看如何使用闭包来捕获定义它们的环境中的值以供后续使用。场景如下:我们的 T 恤公司时不时会向邮件列表中的某人赠送一件独家限量版 T 恤作为促销活动。邮件列表中的人可以选择在个人资料中添加自己喜欢的颜色。如果被选中获得免费 T 恤的人设置了喜欢的颜色,他们就会得到那个颜色的 T 恤。如果这个人没有指定喜欢的颜色,他们就会得到公司目前库存最多的那个颜色。
有很多方式可以实现这个功能。在这个例子中,我们将使用一个名为 ShirtColor 的枚举,它有 Red 和 Blue 两个变体(为了简单起见,限制了可用颜色的数量)。我们用一个 Inventory 结构体来表示公司的库存,它有一个名为 shirts 的字段,包含一个 Vec<ShirtColor> 来表示当前库存的 T 恤颜色。定义在 Inventory 上的 giveaway 方法获取免费 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() 的闭包。标准库不需要了解我们定义的 Inventory 或 ShirtColor 类型,也不需要了解我们在这个场景中想要使用的逻辑。闭包捕获了对 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_v3 和 add_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);
| --------------- ^ 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;
| ^
help: try using a conversion method
|
5 | let n = example_closure(5.to_string());
| ++++++++++++
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 的 vector 的不可变引用,因为它只需要不可变引用就能打印值。
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 vector 中添加一个元素。闭包现在捕获的是一个可变引用。
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,在新线程而不是主线程中打印 vector。
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();
}
move 强制闭包获取线程中 list 的所有权我们生成了一个新线程,将一个闭包作为参数传递给线程来运行。闭包体打印出列表。在示例 13-4 中,闭包只使用不可变引用捕获了 list,因为这是打印它所需的最少访问权限。在这个例子中,即使闭包体仍然只需要不可变引用,我们也需要通过在闭包定义的开头放置 move 关键字来指定 list 应该被移动到闭包中。如果主线程在对新线程调用 join 之前执行了更多操作,新线程可能在主线程的其余部分完成之前结束,或者主线程可能先结束。如果主线程保持了 list 的所有权但在新线程之前结束并丢弃了 list,那么线程中的不可变引用将会无效。因此,编译器要求将 list 移动到传递给新线程的闭包中,以使引用有效。试着去掉 move 关键字,或者在闭包定义之后在主线程中使用 list,看看你会得到什么编译器错误!
将捕获的值移出闭包
一旦闭包从定义它的环境中捕获了引用或获取了值的所有权(从而影响了什么被移 入 闭包),闭包体中的代码定义了当闭包稍后被执行时对引用或值会发生什么(从而影响了什么被移 出 闭包)。
闭包体可以做以下任何事情:将捕获的值移出闭包、修改捕获的值、既不移动也不修改值,或者一开始就不从环境中捕获任何东西。
闭包捕获和处理环境中值的方式影响闭包实现哪些 trait,而 trait 是函数和结构体指定它们可以使用哪种闭包的方式。闭包会自动以累加的方式实现以下一个、两个或全部三个 Fn trait,具体取决于闭包体如何处理这些值:
FnOnce适用于可以被调用一次的闭包。所有闭包至少实现了这个 trait,因为所有闭包都可以被调用。如果一个闭包将捕获的值移出了其闭包体,那么它只会实现FnOnce而不会实现其他Fntrait,因为它只能被调用一次。FnMut适用于不会将捕获的值移出闭包体但可能会修改捕获值的闭包。这些闭包可以被调用多次。Fn适用于不会将捕获的值移出闭包体也不会修改捕获值的闭包,以及不从环境中捕获任何东西的闭包。这些闭包可以在不改变其环境的情况下被多次调用,这在诸如多次并发调用闭包等场景中非常重要。
让我们来看看示例 13-1 中使用的 Option<T> 上的 unwrap_or_else 方法的定义:
impl<T> Option<T> {
pub fn unwrap_or_else<F>(self, f: F) -> T
where
F: FnOnce() -> T
{
match self {
Some(x) => x,
None => f(),
}
}
}
回忆一下,T 是表示 Option 的 Some 变体中值的类型的泛型类型。类型 T 也是 unwrap_or_else 函数的返回类型:例如,在 Option<String> 上调用 unwrap_or_else 的代码将得到一个 String。
接下来,注意 unwrap_or_else 函数有一个额外的泛型类型参数 F。F 类型是名为 f 的参数的类型,也就是我们在调用 unwrap_or_else 时提供的闭包。
泛型类型 F 上指定的 trait 约束是 FnOnce() -> T,这意味着 F 必须能够被调用一次、不接受参数并返回一个 T。在 trait 约束中使用 FnOnce 表达了 unwrap_or_else 不会调用 f 超过一次的约束。在 unwrap_or_else 的函数体中,我们可以看到如果 Option 是 Some,f 不会被调用。如果 Option 是 None,f 将被调用一次。因为所有闭包都实现了 FnOnce,unwrap_or_else 接受所有三种闭包,尽可能地灵活。
注意:如果我们想做的事情不需要从环境中捕获值,可以在需要实现某个
Fntrait 的地方使用函数名而不是闭包。例如,对于Option<Vec<T>>值,我们可以调用unwrap_or_else(Vec::new)来在值为None时获取一个新的空 vector。编译器会自动为函数定义实现适用的Fntrait。
现在让我们来看看定义在切片上的标准库方法 sort_by_key,看看它与 unwrap_or_else 有何不同,以及为什么 sort_by_key 使用 FnMut 而不是 FnOnce 作为 trait 约束。闭包接受一个参数,是对切片中当前正在考虑的元素的引用,并返回一个可以排序的 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:#?}");
}
sort_by_key 按宽度对矩形排序这段代码打印:
$ 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 不会从环境中捕获、修改或移出任何东西,所以它满足 trait 约束要求。
相比之下,示例 13-8 展示了一个只实现 FnOnce trait 的闭包的例子,因为它将一个值移出了环境。编译器不允许我们将这个闭包用于 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:#?}");
}
FnOnce 闭包用于 sort_by_key这是一种刻意设计的、迂回的方式(而且行不通),试图在排序 list 时计算 sort_by_key 调用闭包的次数。这段代码试图通过将 value——一个来自闭包环境的 String——推入 sort_operations vector 来实现计数。闭包捕获了 value,然后通过将 value 的所有权转移给 sort_operations vector 来将 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");
| ----- ------------------------------ move occurs because `value` has type `String`, which does not implement the `Copy` trait
| |
| captured outer variable
16 |
17 | list.sort_by_key(|r| {
| --- captured by this `FnMut` closure
18 | sort_operations.push(value);
| ^^^^^ `value` is moved here
|
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");
}
FnMut 闭包用于 sort_by_keyFn trait 在定义或使用利用闭包的函数或类型时非常重要。在下一节中,我们将讨论迭代器。许多迭代器方法都接受闭包参数,所以在继续学习时请记住这些闭包的细节!
使用迭代器处理一系列元素
使用迭代器处理一系列元素
迭代器模式允许你依次对一个序列中的元素执行某些操作。迭代器负责遍历每个元素以及判断序列何时结束的逻辑。使用迭代器时,你无需自己重新实现这些逻辑。
在 Rust 中,迭代器是 惰性的(lazy),这意味着在你调用消费迭代器的方法之前,迭代器不会产生任何效果。例如,示例 13-10 中的代码通过调用 Vec<T> 上定义的 iter 方法,在 vector v1 的元素上创建了一个迭代器。这段代码本身并没有做任何有用的事情。
fn main() {
let v1 = vec![1, 2, 3];
let v1_iter = v1.iter();
}
迭代器被存储在 v1_iter 变量中。一旦创建了迭代器,我们可以用多种方式来使用它。在示例 3-5 中,我们使用 for 循环遍历一个数组,对其中的每个元素执行一些代码。在底层,这实际上隐式地创建并消费了一个迭代器,但在此之前我们一直没有深入探讨其工作原理。
在示例 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}");
}
}
for 循环中使用迭代器在标准库中没有提供迭代器的语言中,你可能会通过以下方式实现相同的功能:从索引 0 开始一个变量,用该变量索引 vector 来获取值,然后在循环中递增该变量的值,直到达到 vector 中元素的总数。
迭代器为你处理了所有这些逻辑,减少了你可能搞砸的重复代码。迭代器让你能够更灵活地将相同的逻辑用于多种不同类型的序列,而不仅仅是像 vector 这样可以通过索引访问的数据结构。让我们来看看迭代器是如何做到这一点的。
Iterator Trait 和 next 方法
所有迭代器都实现了标准库中定义的一个名为 Iterator 的 trait。该 trait 的定义如下:
#![allow(unused)]
fn main() {
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// methods with default implementations elided
}
}
注意这个定义使用了一些新语法:type Item 和 Self::Item,它们定义了该 trait 的一个 关联类型(associated type)。我们将在第 20 章深入讨论关联类型。现在你只需要知道,这段代码表明实现 Iterator trait 要求你同时定义一个 Item 类型,而这个 Item 类型用于 next 方法的返回类型。换句话说,Item 类型将是迭代器返回的元素类型。
Iterator trait 只要求实现者定义一个方法:next 方法,它每次返回迭代器中的一个元素,包裹在 Some 中;当迭代结束时,返回 None。
我们可以直接在迭代器上调用 next 方法;示例 13-12 展示了对从 vector 创建的迭代器反复调用 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);
}
}
next 方法注意我们需要将 v1_iter 声明为可变的:在迭代器上调用 next 方法会改变迭代器内部用于跟踪序列位置的状态。换句话说,这段代码 消费(consume)了迭代器,或者说用尽了它。每次调用 next 都会从迭代器中消费一个元素。而当我们使用 for 循环时,不需要将 v1_iter 声明为可变的,因为循环获取了 v1_iter 的所有权,并在幕后将其变为可变的。
还要注意,从 next 调用中获得的值是 vector 中值的不可变引用。iter 方法生成的是一个不可变引用的迭代器。如果我们想创建一个获取 v1 所有权并返回拥有所有权的值的迭代器,可以调用 into_iter 而不是 iter。类似地,如果我们想遍历可变引用,可以调用 iter_mut 而不是 iter。
消费迭代器的方法
Iterator trait 有许多由标准库提供默认实现的方法;你可以在标准库 API 文档中查阅 Iterator trait 来了解这些方法。其中一些方法在其定义中调用了 next 方法,这就是为什么实现 Iterator trait 时必须实现 next 方法的原因。
调用 next 的方法被称为 消费适配器(consuming adapters),因为调用它们会用尽迭代器。一个例子是 sum 方法,它获取迭代器的所有权,并通过反复调用 next 来遍历所有元素,从而消费迭代器。在遍历过程中,它将每个元素累加到一个总和中,并在迭代完成时返回该总和。示例 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 方法获取迭代器中所有元素的总和在调用 sum 之后,我们就不能再使用 v1_iter 了,因为 sum 获取了我们调用它的迭代器的所有权。
产生其他迭代器的方法
迭代器适配器(iterator adapters)是定义在 Iterator trait 上的方法,它们不会消费迭代器,而是通过改变原始迭代器的某些方面来产生不同的迭代器。
示例 13-14 展示了一个调用迭代器适配器方法 map 的例子,它接受一个闭包,在遍历元素时对每个元素调用该闭包。map 方法返回一个新的迭代器,产生修改后的元素。这里的闭包创建了一个新的迭代器,其中 vector 的每个元素都会加 1。
fn main() {
let v1: Vec<i32> = vec![1, 2, 3];
v1.iter().map(|x| x + 1);
}
map 来创建一个新的迭代器不过,这段代码会产生一个警告:
$ 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`
示例 13-14 中的代码实际上什么也没做;我们指定的闭包从未被调用。这个警告提醒了我们原因:迭代器适配器是惰性的,我们需要在这里消费迭代器。
为了修复这个警告并消费迭代器,我们将使用 collect 方法,我们在示例 12-1 中曾与 env::args 一起使用过它。这个方法消费迭代器,并将结果值收集到一个集合数据类型中。
在示例 13-15 中,我们将调用 map 返回的迭代器遍历的结果收集到一个 vector 中。这个 vector 最终将包含原始 vector 中每个元素加 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 方法创建一个新的迭代器,然后调用 collect 方法消费新的迭代器并创建一个 vector因为 map 接受一个闭包,所以我们可以指定任何想要对每个元素执行的操作。这是一个很好的例子,展示了闭包如何让你自定义某些行为,同时复用 Iterator trait 提供的迭代行为。
你可以链式调用多个迭代器适配器来以可读的方式执行复杂操作。但由于所有迭代器都是惰性的,你必须调用一个消费适配器方法才能从迭代器适配器的调用中获得结果。
捕获环境的闭包
许多迭代器适配器接受闭包作为参数,而我们指定给迭代器适配器的闭包通常是捕获其环境的闭包。
在这个例子中,我们将使用接受一个闭包的 filter 方法。该闭包从迭代器中获取一个元素并返回一个 bool。如果闭包返回 true,该值将被包含在 filter 产生的迭代中。如果闭包返回 false,该值将不会被包含。
在示例 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")
},
]
);
}
}
filter 方法和一个捕获 shoe_size 的闭包shoes_in_size 函数获取一个鞋子 vector 和一个鞋码作为参数。它返回一个只包含指定尺码鞋子的 vector。
在 shoes_in_size 的函数体中,我们调用 into_iter 来创建一个获取 vector 所有权的迭代器。然后调用 filter 将该迭代器适配为一个新的迭代器,只包含闭包返回 true 的元素。
闭包从环境中捕获了 shoe_size 参数,并将其与每只鞋的尺码进行比较,只保留指定尺码的鞋子。最后,调用 collect 将适配后的迭代器返回的值收集到一个 vector 中,由函数返回。
测试表明,当我们调用 shoes_in_size 时,只会得到与我们指定的值相同尺码的鞋子。
改进 I/O 项目
改进 I/O 项目
有了关于迭代器的新知识,我们可以使用迭代器来改进第十二章的 I/O 项目,使代码更加清晰简洁。让我们看看迭代器如何改进 Config::build 函数和 search 函数的实现。
使用迭代器消除 clone
在示例 12-6 中,我们添加的代码接收一个 String 值的切片,并通过索引切片和克隆值来创建 Config 结构体的实例,从而让 Config 结构体拥有这些值的所有权。在示例 13-17 中,我们重新展示了示例 12-23 中 Config::build 函数的实现。
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
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);
});
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
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();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
query,
file_path,
ignore_case,
})
}
}
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(())
}
Config::build 函数的复现当时我们说过不必担心低效的 clone 调用,因为将来会移除它们。现在就是时候了!
这里之所以需要 clone,是因为参数 args 中有一个包含 String 元素的切片,但 build 函数并不拥有 args 的所有权。为了返回 Config 实例的所有权,我们不得不克隆 Config 的 query 和 file_path 字段中的值,这样 Config 实例才能拥有这些值。
有了关于迭代器的新知识,我们可以将 build 函数改为接收一个迭代器的所有权作为参数,而不是借用一个切片。我们将使用迭代器的功能来替代检查切片长度和按索引访问特定位置的代码。这将使 Config::build 函数的意图更加清晰,因为迭代器会自行访问这些值。
一旦 Config::build 获取了迭代器的所有权,不再使用借用的索引操作,我们就可以将 String 值从迭代器移动到 Config 中,而不必调用 clone 来进行新的分配。
直接使用返回的迭代器
打开你的 I/O 项目的 src/main.rs 文件,它应该看起来像这样:
文件名:src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
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) = run(config) {
eprintln!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
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();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
query,
file_path,
ignore_case,
})
}
}
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(())
}
我们首先将示例 12-24 中 main 函数的开头改为示例 13-18 中的代码,这次使用了迭代器。在我们同时更新 Config::build 之前,这段代码还无法编译。
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
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) = run(config) {
eprintln!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
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();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
query,
file_path,
ignore_case,
})
}
}
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(())
}
env::args 的返回值传递给 Config::buildenv::args 函数返回一个迭代器!与其将迭代器的值收集到一个 vector 中再传递切片给 Config::build,现在我们直接将 env::args 返回的迭代器的所有权传递给 Config::build。
接下来,我们需要更新 Config::build 的定义。让我们将 Config::build 的签名改为示例 13-19 的样子。这仍然无法编译,因为我们还需要更新函数体。
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
fn main() {
let config = Config::build(env::args()).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {err}");
process::exit(1);
});
if let Err(e) = run(config) {
eprintln!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
impl Config {
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,
})
}
}
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(())
}
Config::build 的签名以接收一个迭代器env::args 函数的标准库文档显示,它返回的迭代器类型是 std::env::Args,该类型实现了 Iterator trait 并返回 String 值。
我们更新了 Config::build 函数的签名,使参数 args 具有泛型类型,其 trait 约束为 impl Iterator<Item = String> 而不是 &[String]。我们在第十章“trait 作为参数”部分讨论过的 impl Trait 语法的这种用法意味着 args 可以是任何实现了 Iterator trait 并返回 String 项的类型。
因为我们获取了 args 的所有权,并且将通过迭代来改变 args,所以我们可以在 args 参数的声明中添加 mut 关键字使其可变。
使用 Iterator trait 的方法
接下来,我们来修改 Config::build 的函数体。因为 args 实现了 Iterator trait,我们知道可以对它调用 next 方法!示例 13-20 将示例 12-23 中的代码更新为使用 next 方法。
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
fn main() {
let config = Config::build(env::args()).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {err}");
process::exit(1);
});
if let Err(e) = run(config) {
eprintln!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
impl Config {
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,
})
}
}
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(())
}
Config::build 的函数体以使用迭代器方法请记住,env::args 返回值中的第一个值是程序的名称。我们想要忽略它并获取下一个值,所以首先调用 next 并对返回值不做任何处理。然后,我们调用 next 来获取要放入 Config 的 query 字段中的值。如果 next 返回 Some,我们使用 match 来提取值。如果它返回 None,则意味着提供的参数不够,我们提前返回一个 Err 值。对 file_path 值也做同样的处理。
使用迭代器适配器使代码更清晰
我们还可以在 I/O 项目的 search 函数中利用迭代器。示例 13-21 重新展示了示例 12-19 中该函数的实现。
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 函数的实现我们可以使用迭代器适配器方法以更简洁的方式编写这段代码。这样做还可以避免使用可变的中间变量 results vector。函数式编程风格倾向于最小化可变状态的使用,以使代码更清晰。移除可变状态可能还有助于未来实现并行搜索的增强,因为我们不必管理对 results vector 的并发访问。示例 13-22 展示了这一改动。
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 函数的实现中使用迭代器适配器方法回忆一下,search 函数的目的是返回 contents 中所有包含 query 的行。与示例 13-16 中的 filter 示例类似,这段代码使用 filter 适配器只保留 line.contains(query) 返回 true 的行。然后我们用 collect 将匹配的行收集到另一个 vector 中。简洁多了!你也可以对 search_case_insensitive 函数做同样的改动,使用迭代器方法。
作为进一步的改进,可以让 search 函数返回一个迭代器,方法是移除 collect 调用并将返回类型改为 impl Iterator<Item = &'a str>,使函数本身成为一个迭代器适配器。注意你还需要更新测试!在做出这个改动前后,使用你的 minigrep 工具搜索一个大文件来观察行为上的差异。在改动之前,程序在收集完所有结果之后才会打印,但改动之后,结果会在找到每一行匹配时就立即打印,因为 run 函数中的 for 循环能够利用迭代器的惰性求值特性。
选择循环还是迭代器
接下来一个自然的问题是,在你自己的代码中应该选择哪种风格以及为什么:示例 13-21 中的原始实现,还是示例 13-22 中使用迭代器的版本(假设我们在返回之前收集所有结果,而不是返回迭代器)。大多数 Rust 程序员倾向于使用迭代器风格。刚开始时确实有点难以掌握,但一旦你熟悉了各种迭代器适配器及其功能,迭代器就会变得更容易理解。与其摆弄循环的各种细节和构建新的 vector,代码可以专注于循环的高层目标。这将一些常见的代码抽象出去,从而更容易看到这段代码特有的概念,比如迭代器中每个元素必须通过的过滤条件。
但这两种实现真的等价吗?直觉上可能会认为更底层的循环会更快。让我们来谈谈性能。
循环与迭代器的性能比较
循环与迭代器的性能比较
要决定使用循环还是迭代器,你需要知道哪种实现更快:使用显式 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 的零成本抽象(zero-cost abstractions)之一,这意味着使用这种抽象不会带来额外的运行时开销。这类似于 C++ 的最初设计者和实现者 Bjarne Stroustrup 在 2012 年 ETAPS 主题演讲“Foundations of C++“中对零开销的定义:
总的来说,C++ 的实现遵循零开销原则:你不使用的东西,不需要为之付出代价。更进一步:你使用的东西,你无法手写出更好的代码。
在很多情况下,使用迭代器的 Rust 代码会编译成与你手写的汇编代码相同的结果。循环展开(loop unrolling)和消除数组访问的边界检查等优化都会被应用,使得生成的代码极其高效。现在你知道了这一点,就可以放心地使用迭代器和闭包了!它们让代码看起来更高层,但不会因此带来运行时性能损失。
总结
闭包和迭代器是 Rust 受函数式编程语言思想启发而提供的特性。它们使 Rust 能够以低层级的性能清晰地表达高层级的思想。闭包和迭代器的实现方式确保了运行时性能不受影响。这是 Rust 致力于提供零成本抽象这一目标的一部分。
既然我们已经改进了 I/O 项目的表达能力,接下来让我们看看 cargo 的更多功能,这些功能将帮助我们与世界分享项目。
更多关于 Cargo 和 Crates.io 的内容
到目前为止,我们只使用了 Cargo 最基本的功能来构建、运行和测试代码,但它能做的远不止这些。在本章中,我们将讨论它的一些更高级的功能,向你展示如何完成以下操作:
- 通过发布配置(release profiles)自定义构建。
- 在 crates.io 上发布库。
- 使用工作空间(workspaces)组织大型项目。
- 从 crates.io 安装二进制文件。
- 使用自定义命令扩展 Cargo。
Cargo 的功能远不止本章所涵盖的内容,要了解其所有功能的完整说明,请参阅 Cargo 文档。
使用发布配置自定义构建
使用发布配置自定义构建
在 Rust 中,发布配置(release profiles)是预定义的、可自定义的配置方案,包含不同的编译选项,让程序员能够更好地控制代码编译的各种细节。每个配置方案都是独立配置的。
Cargo 有两个主要的配置方案:运行 cargo build 时使用的 dev 配置,以及运行 cargo build --release 时使用的 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
dev 和 release 就是编译器使用的不同配置方案。
当项目的 Cargo.toml 文件中没有显式添加任何 [profile.*] 部分时,Cargo 会为每个配置方案使用默认设置。通过为你想要自定义的配置方案添加 [profile.*] 部分,可以覆盖默认设置的任意子集。例如,以下是 dev 和 release 配置中 opt-level 设置的默认值:
Filename: Cargo.toml
[profile.dev]
opt-level = 0
[profile.release]
opt-level = 3
opt-level 设置控制 Rust 对代码应用的优化级别,取值范围是 0 到 3。应用更多优化会延长编译时间,因此在开发阶段如果需要频繁编译代码,你会希望减少优化以加快编译速度,即使生成的代码运行得慢一些。所以 dev 的默认 opt-level 是 0。当你准备发布代码时,最好花更多时间在编译上。你只需要在发布模式下编译一次,但编译后的程序会运行很多次,因此发布模式用更长的编译时间换取运行更快的代码。这就是 release 配置的默认 opt-level 为 3 的原因。
你可以在 Cargo.toml 中为某个设置指定不同的值来覆盖默认设置。例如,如果我们想在开发配置中使用优化级别 1,可以在项目的 Cargo.toml 文件中添加以下两行:
Filename: Cargo.toml
[profile.dev]
opt-level = 1
这段代码覆盖了默认的 0 设置。现在当我们运行 cargo build 时,Cargo 会使用 dev 配置的默认值加上我们对 opt-level 的自定义设置。因为我们将 opt-level 设为 1,Cargo 会应用比默认更多的优化,但不会像发布构建那样多。
关于每个配置方案的完整配置选项列表和默认值,请参阅 Cargo 的文档。
将 crate 发布到 Crates.io
发布 crate 到 Crates.io
我们已经使用过 crates.io 上的包作为项目的依赖,但你也可以通过发布自己的包来与其他人分享代码。crates.io 上的 crate 注册中心会分发你的包的源代码,因此它主要托管开源代码。
Rust 和 Cargo 提供了一些特性,让你发布的包更容易被他人发现和使用。接下来我们将介绍其中一些特性,然后讲解如何发布一个包。
编写有用的文档注释
准确地为你的包编写文档将帮助其他用户了解如何以及何时使用它们,因此花时间编写文档是值得的。在第三章中,我们讨论了如何使用两个斜杠 // 来注释 Rust 代码。Rust 还有一种专门用于文档的注释,通常称为文档注释(documentation comment),它会生成 HTML 文档。这些 HTML 文档展示公有 API 项的文档注释内容,面向那些想要了解如何使用你的 crate 的程序员,而不是关注你的 crate 是如何实现的。
文档注释使用三个斜杠 /// 而不是两个,并且支持 Markdown 语法来格式化文本。文档注释放在被注释项的前面。示例 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 的文档(以及所有依赖的文档),然后在浏览器中打开结果。导航到 add_one 函数,你会看到文档注释中的文本是如何渲染的,如图 14-1 所示。
图 14-1:add_one 函数的 HTML 文档
常用的文档小节
我们在示例 14-1 中使用了 # Examples Markdown 标题来创建一个标题为“Examples“的 HTML 小节。以下是 crate 作者在文档中常用的一些其他小节:
- Panics:记录函数可能会 panic 的场景。不希望程序 panic 的调用者应确保不在这些情况下调用该函数。
- Errors:如果函数返回
Result,描述可能出现的错误类型以及在什么条件下会返回这些错误,这对调用者很有帮助,以便他们能够用不同的方式处理不同类型的错误。 - Safety:如果函数调用是
unsafe的(我们将在第二十章讨论不安全代码),应该有一个小节解释为什么该函数是不安全的,并说明函数期望调用者维护的不变量(invariants)。
大多数文档注释不需要所有这些小节,但这是一个很好的检查清单,提醒你用户可能会关心代码的哪些方面。
文档注释作为测试
在文档注释中添加示例代码块可以帮助演示如何使用你的库,而且还有一个额外的好处:运行 cargo test 会将文档中的代码示例作为测试运行!没有什么比带有示例的文档更好的了。但也没有什么比因为代码变更而导致示例失效更糟糕的了。如果我们对示例 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 文件的开头添加以 //! 开头的文档注释,如示例 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
}
my_crate crate 的文档注意,以 //! 开头的最后一行之后没有任何代码。因为我们使用的是 //! 而不是 ///,所以我们是在为包含此注释的项编写文档,而不是为此注释之后的项编写文档。在这种情况下,包含此注释的项是 src/lib.rs 文件,也就是 crate 根。这些注释描述的是整个 crate。
当我们运行 cargo doc --open 时,这些注释会显示在 my_crate 文档的首页上,位于 crate 中公有项列表的上方,如图 14-2 所示。
项内部的文档注释对于描述 crate 和模块特别有用。使用它们来解释容器的整体用途,帮助用户理解 crate 的组织结构。
图 14-2:my_crate 的渲染文档,包括描述整个 crate 的注释
使用 pub use 导出方便的公有 API
发布 crate 时,公有 API 的结构是一个重要的考量因素。使用你的 crate 的人不如你熟悉其结构,如果你的 crate 有很深的模块层级,他们可能很难找到想要使用的部分。
在第七章中,我们介绍了如何使用 pub 关键字将项设为公有,以及如何使用 use 关键字将项引入作用域。然而,你在开发 crate 时觉得合理的结构对用户来说可能并不方便。你可能希望将结构体组织在包含多个层级的层次结构中,但想要使用你定义在深层结构中的类型的人可能很难发现这些类型的存在。他们可能还会觉得输入 use my_crate::some_module::another_module::UsefulType; 很烦人,而更希望输入 use my_crate::UsefulType;。
好消息是,即使内部结构对其他人从外部库使用起来不方便,你也不必重新组织内部结构:你可以使用 pub use 重新导出项,创建一个与私有结构不同的公有结构。重新导出(re-exporting)会将一个公有项从一个位置公开到另一个位置,就好像它是在另一个位置定义的一样。
例如,假设我们创建了一个名为 art 的库来建模艺术概念。在这个库中有两个模块:一个 kinds 模块包含两个枚举 PrimaryColor 和 SecondaryColor,一个 utils 模块包含一个名为 mix 的函数,如示例 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!();
}
}
kinds 和 utils 模块中的 art 库图 14-3 展示了 cargo doc 为这个 crate 生成的文档首页。
图 14-3:art 的文档首页,列出了 kinds 和 utils 模块
注意 PrimaryColor 和 SecondaryColor 类型没有列在首页上,mix 函数也没有。我们必须点击 kinds 和 utils 才能看到它们。
另一个依赖此库的 crate 需要使用 use 语句将 art 中的项引入作用域,并指定当前定义的模块结构。示例 14-4 展示了一个使用 art crate 中 PrimaryColor 和 mix 项的 crate 示例。
use art::kinds::PrimaryColor;
use art::utils::mix;
fn main() {
let red = PrimaryColor::Red;
let yellow = PrimaryColor::Yellow;
mix(red, yellow);
}
art crate 的项并导出其内部结构的 crate示例 14-4 中使用 art crate 的代码的作者必须弄清楚 PrimaryColor 在 kinds 模块中,而 mix 在 utils 模块中。art crate 的模块结构对于开发 art crate 的人来说比对使用它的人更有意义。内部结构对于试图理解如何使用 art crate 的人来说没有提供任何有用的信息,反而造成了困惑,因为使用者必须弄清楚去哪里找,还必须在 use 语句中指定模块名。
为了从公有 API 中移除内部组织结构,我们可以修改示例 14-3 中的 art crate 代码,添加 pub use 语句在顶层重新导出这些项,如示例 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
}
}
pub use 语句来重新导出项cargo doc 为这个 crate 生成的 API 文档现在会在首页列出并链接重新导出的项,如图 14-4 所示,使得 PrimaryColor 和 SecondaryColor 类型以及 mix 函数更容易被找到。
图 14-4:art 的文档首页,列出了重新导出的项
art crate 的用户仍然可以像示例 14-4 那样查看和使用示例 14-3 中的内部结构,也可以使用示例 14-5 中更方便的结构,如示例 14-6 所示。
use art::PrimaryColor;
use art::mix;
fn main() {
// --snip--
let red = PrimaryColor::Red;
let yellow = PrimaryColor::Yellow;
mix(red, yellow);
}
art crate 重新导出项的程序在有很多嵌套模块的情况下,使用 pub use 在顶层重新导出类型可以显著改善使用该 crate 的人的体验。pub use 的另一个常见用途是重新导出当前 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
这个命令会将你的 API 令牌告知 Cargo,并将其存储在本地的 ~/.cargo/credentials.toml 文件中。注意,这个令牌是机密信息:不要与任何人分享。如果你因为任何原因将其分享给了他人,应该立即撤销它并在 crates.io 上生成一个新的令牌。
为新 crate 添加元数据
假设你有一个想要发布的 crate。在发布之前,你需要在 crate 的 Cargo.toml 文件的 [package] 部分添加一些元数据。
你的 crate 需要一个唯一的名称。当你在本地开发 crate 时,可以随意命名。但是 crates.io 上的 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 中,添加一两句话的描述,因为它会出现在搜索结果中与你的 crate 一起显示。对于 license 字段,你需要提供一个许可证标识符值。Linux 基金会的软件包数据交换(SPDX)列出了可用于此值的标识符。例如,要指定你的 crate 使用 MIT 许可证,添加 MIT 标识符:
文件名:Cargo.toml
[package]
name = "guessing_game"
license = "MIT"
如果你想使用 SPDX 中没有的许可证,你需要将该许可证的文本放在一个文件中,将该文件包含在你的项目中,然后使用 license-file 来指定该文件的名称,而不是使用 license 键。
关于哪种许可证适合你的项目,这超出了本书的范围。Rust 社区中的许多人以与 Rust 相同的方式为他们的项目授权,使用 MIT OR Apache-2.0 双重许可证。这种做法表明你也可以通过 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)
Packaged 6 files, 1.2KiB (895.0B compressed)
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)
Uploaded guessing_game v0.1.0 to registry `crates-io`
note: waiting for `guessing_game v0.1.0` to be available at registry
`crates-io`.
You may press ctrl-c to skip waiting; the crate should be available shortly.
Published guessing_game v0.1.0 at registry `crates-io`
恭喜!你现在已经与 Rust 社区分享了你的代码,任何人都可以轻松地将你的 crate 添加为他们项目的依赖。
发布现有 crate 的新版本
当你对 crate 进行了修改并准备发布新版本时,修改 Cargo.toml 文件中指定的 version 值并重新发布。根据你所做的更改类型,使用语义化版本规则来决定合适的下一个版本号。然后运行 cargo publish 来上传新版本。
使用 cargo yank 弃用 Crates.io 上的版本
虽然你不能删除 crate 的旧版本,但你可以阻止未来的项目将其添加为新的依赖。这在某个 crate 版本因某种原因而损坏时很有用。在这种情况下,Cargo 支持撤回(yanking)一个 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 工作空间
Cargo 工作空间
在第 12 章中,我们构建了一个包含二进制 crate 和库 crate 的包。随着项目的发展,你可能会发现库 crate 越来越大,你想要将包进一步拆分为多个库 crate。Cargo 提供了一个叫做工作空间(workspaces)的功能,可以帮助管理多个相互关联、协同开发的包。
创建工作空间
工作空间(workspace)是一组共享同一个 Cargo.lock 和输出目录的包。让我们用工作空间来创建一个项目——我们会使用简单的代码,以便专注于工作空间的结构。组织工作空间有多种方式,我们只展示其中一种常见的方式。我们的工作空间将包含一个二进制 crate 和两个库 crate。二进制 crate 提供主要功能,并依赖于这两个库 crate。其中一个库 crate 提供 add_one 函数,另一个库 crate 提供 add_two 函数。这三个 crate 将属于同一个工作空间。我们先为工作空间创建一个新目录:
$ mkdir add
$ cd add
接下来,在 add 目录中创建 Cargo.toml 文件来配置整个工作空间。这个文件不会有 [package] 部分,而是以 [workspace] 部分开头,这样我们就可以向工作空间添加成员。我们还特意在工作空间中将 resolver 的值设置为 "3",以使用 Cargo 最新最好的解析算法:
Filename: Cargo.toml
[workspace]
resolver = "3"
接下来,我们在 add 目录中运行 cargo new 来创建 adder 二进制 crate:
$ cargo new adder
Created 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
Created library `add_one` package
Adding `add_one` as member of workspace at `file:///projects/add`
顶层的 Cargo.toml 现在会在 members 列表中包含 add_one 路径:
Filename: 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 函数:
Filename: add_one/src/lib.rs
pub fn add_one(x: i32) -> i32 {
x + 1
}
现在我们可以让包含二进制 crate 的 adder 包依赖包含库的 add_one 包。首先,需要在 adder/Cargo.toml 中添加对 add_one 的路径依赖。
Filename: adder/Cargo.toml
[dependencies]
add_one = { path = "../add_one" }
Cargo 不会假设工作空间中的 crate 之间存在相互依赖关系,所以我们需要显式声明依赖关系。
接下来,在 adder crate 中使用 add_one crate 的 add_one 函数。打开 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));
}
adder crate 中使用 add_one 库 crate让我们在顶层 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,可以通过 cargo run 的 -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 的目录中各有一个。这确保了所有 crate 使用相同版本的所有依赖。如果我们将 rand 包添加到 adder/Cargo.toml 和 add_one/Cargo.toml 文件中,Cargo 会将它们解析为同一个版本的 rand,并记录在唯一的 Cargo.lock 中。让工作空间中的所有 crate 使用相同的依赖,意味着这些 crate 之间始终是兼容的。让我们在 add_one/Cargo.toml 文件的 [dependencies] 部分添加 rand crate,以便在 add_one crate 中使用它:
Filename: add_one/Cargo.toml
[dependencies]
rand = "0.8.5"
现在我们可以在 add_one/src/lib.rs 文件中添加 use rand;,然后在 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_one 对 rand 的依赖信息。然而,即使 rand 在工作空间的某处被使用了,我们也不能在工作空间的其他 crate 中使用它,除非也将 rand 添加到它们的 Cargo.toml 文件中。例如,如果我们在 adder 包的 adder/src/main.rs 文件中添加 use rand;,会得到一个错误:
$ 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.lock 中 adder 的依赖列表中,但不会额外下载 rand 的副本。Cargo 会确保工作空间中每个包里使用 rand 包的 crate 都使用相同的版本,只要它们指定了兼容的 rand 版本,这既节省了空间,也确保了工作空间中的 crate 之间相互兼容。
如果工作空间中的 crate 指定了同一依赖的不兼容版本,Cargo 会分别解析它们,但仍会尽量减少解析的版本数量。
为工作空间添加测试
作为另一个改进,让我们在 add_one crate 中为 add_one::add_one 函数添加一个测试:
Filename: 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 安装二进制文件
cargo install 命令允许你在本地安装和使用二进制 crate。这并不是要替代系统包管理器,而是为 Rust 开发者提供一种便捷的方式来安装其他人在 crates.io 上分享的工具。注意,你只能安装具有二进制目标(binary target)的包。二进制目标(binary target)是指当 crate 拥有 src/main.rs 文件或其他被指定为二进制文件时所创建的可运行程序,与之相对的是库目标(library target),库目标本身不能独立运行,但适合被包含在其他程序中。通常,crate 会在 README 文件中说明该 crate 是一个库、具有二进制目标,还是两者兼有。
所有通过 cargo install 安装的二进制文件都存储在安装根目录的 bin 文件夹中。如果你使用 rustup.rs 安装的 Rust 且没有任何自定义配置,这个目录将是 $HOME/.cargo/bin。请确保该目录在你的 $PATH 中,这样你才能运行通过 cargo install 安装的程序。
例如,在第 12 章中我们提到过,有一个用 Rust 实现的 grep 工具叫做 ripgrep,用于搜索文件。要安装 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 上分享对你有用的代码——它很可能对其他人同样有用!
智能指针
指针(pointer)是一个通用概念,指的是包含内存地址的变量。这个地址引用,或者说“指向“另一些数据。Rust 中最常见的指针是引用(reference),你已经在第四章中学习过了。引用以 & 符号表示,会借用它们所指向的值。除了引用数据之外,引用没有任何特殊功能,也没有额外开销。
另一方面,智能指针(smart pointers)是一类数据结构,它们的行为类似于指针,但拥有额外的元数据和功能。智能指针的概念并非 Rust 独有:智能指针起源于 C++,也存在于其他语言中。Rust 在标准库中定义了多种智能指针,它们提供了超出引用所能提供的功能。为了探索这一通用概念,我们将看几个不同的智能指针示例,包括引用计数(reference counting)智能指针类型。这种指针通过记录所有者的数量来允许数据拥有多个所有者,并在没有任何所有者时清理数据。
在 Rust 中,由于所有权(ownership)和借用(borrowing)的概念,引用和智能指针之间还有一个额外的区别:引用只是借用数据,而在很多情况下,智能指针拥有它们所指向的数据。
智能指针通常使用结构体来实现。与普通结构体不同的是,智能指针实现了 Deref 和 Drop trait。Deref trait 使智能指针结构体的实例能够像引用一样使用,这样你编写的代码既可以用于引用,也可以用于智能指针。Drop trait 允许你自定义当智能指针实例离开作用域时运行的代码。在本章中,我们将讨论这两个 trait,并说明它们对智能指针的重要性。
鉴于智能指针模式是 Rust 中经常使用的通用设计模式,本章不会涵盖所有现有的智能指针。许多库都有自己的智能指针,你甚至可以编写自己的智能指针。我们将介绍标准库中最常用的智能指针:
Box<T>,用于在堆上分配值Rc<T>,一种引用计数类型,允许多重所有权Ref<T>和RefMut<T>,通过RefCell<T>访问,这是一种在运行时而非编译时执行借用规则的类型
此外,我们还将介绍内部可变性(interior mutability)模式,在这种模式中,一个不可变类型暴露出用于修改其内部值的 API。我们还将讨论引用循环(reference cycles):它们如何导致内存泄漏,以及如何防止引用循环。
让我们开始吧!
使用 Box<T> 指向堆上的数据
使用 Box<T> 指向堆上的数据
最简单直接的智能指针是 box,其类型写作 Box<T>。Box 允许你将数据存储在堆(heap)上而非栈(stack)上,留在栈上的则是指向堆数据的指针。关于栈和堆的区别,可以回顾第 4 章的内容。
Box 除了将数据存储在堆上而非栈上之外,没有额外的性能开销。不过它也没有太多额外的功能。你最常在以下场景中使用它们:
- 当你有一个在编译时无法确定大小的类型,而你又想在要求确切大小的上下文中使用该类型的值时
- 当你有大量数据,想要转移所有权同时确保数据不会被复制时
- 当你想拥有一个值,且只关心它实现了某个特定 trait 而不关心其具体类型时
我们将在“使用 Box 实现递归类型”一节中演示第一种情况。对于第二种情况,转移大量数据的所有权可能会花费较长时间,因为数据会在栈上被复制。为了提升性能,我们可以将大量数据存储在堆上的 box 中。这样,只有少量的指针数据在栈上被复制,而它所引用的数据则保持在堆上的同一位置不动。第三种情况被称为 trait 对象(trait object),第 18 章的“使用 trait 对象来抽象不同类型的共同行为”专门讨论了这个主题。所以你在这里学到的内容,将在那一节中再次用到!
在堆上存储数据
在讨论 Box<T> 的堆存储用例之前,我们先介绍其语法以及如何与存储在 Box<T> 中的值进行交互。
示例 15-1 展示了如何使用 box 在堆上存储一个 i32 值。
fn main() {
let b = Box::new(5);
println!("b = {b}");
}
i32 值我们定义了变量 b,其值是一个指向值 5 的 Box,而 5 被分配在堆上。这个程序会打印 b = 5;在这种情况下,我们可以像访问栈上数据一样访问 box 中的数据。和任何拥有所有权的值一样,当 box 离开作用域时——就像 b 在 main 函数末尾那样——它会被释放。释放同时发生在 box 本身(存储在栈上)和它所指向的数据(存储在堆上)。
将单个值放在堆上并不是很有用,所以你不会经常单独以这种方式使用 box。将像单个 i32 这样的值放在栈上——它们默认就存储在那里——在大多数情况下更为合适。让我们来看一个如果没有 box 就无法定义某些类型的场景。
使用 Box 实现递归类型
递归类型(recursive type)的值可以包含另一个同类型的值作为自身的一部分。递归类型带来了一个问题,因为 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 如何让我们定义递归数据类型。
示例 15-2 包含了一个 cons list 的枚举定义。注意这段代码还无法编译,因为 List 类型没有已知的大小,我们稍后会演示这一点。
enum List {
Cons(i32, List),
Nil,
}
fn main() {}
i32 值的 cons list 数据结构注意:我们在这个例子中实现的 cons list 只存储
i32值。我们本可以使用泛型来实现它,正如第 10 章讨论的那样,这样就能定义一个可以存储任意类型值的 cons list 类型。
使用 List 类型来存储列表 1, 2, 3 的代码如示例 15-3 所示。
enum List {
Cons(i32, List),
Nil,
}
// --snip--
use crate::List::{Cons, Nil};
fn main() {
let list = Cons(1, Cons(2, Cons(3, Nil)));
}
List 枚举来存储列表 1, 2, 3第一个 Cons 值存储了 1 和另一个 List 值。这个 List 值是另一个 Cons 值,存储了 2 和又一个 List 值。这个 List 值又是一个 Cons 值,存储了 3 和一个 List 值,最后这个 List 值是 Nil,即表示列表结束的非递归变体。
如果我们尝试编译示例 15-3 中的代码,会得到如示例 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 章讨论枚举定义时在示例 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 尝试确定像示例 15-2 中 List 枚举这样的递归类型需要多少空间时的情况进行对比。编译器首先查看 Cons 变体,它持有一个 i32 类型的值和一个 List 类型的值。因此,Cons 需要的空间等于一个 i32 的大小加上一个 List 的大小。为了计算 List 类型需要多少内存,编译器查看其变体,从 Cons 变体开始。Cons 变体持有一个 i32 类型的值和一个 List 类型的值,这个过程会无限继续下去,如图 15-1 所示。
图 15-1:由无限个 Cons 变体组成的无限 List
使递归类型具有已知大小
因为 Rust 无法计算出递归定义的类型需要分配多少空间,编译器给出了一个包含有用建议的错误信息:
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Cons(i32, Box<List>),
| ++++ +
在这个建议中,间接引用(indirection)意味着我们不直接存储一个值,而是应该修改数据结构,通过存储一个指向该值的指针来间接地存储它。
因为 Box<T> 是一个指针,Rust 始终知道一个 Box<T> 需要多少空间:指针的大小不会因为它所指向的数据量而改变。这意味着我们可以在 Cons 变体中放入一个 Box<T>,而不是直接放入另一个 List 值。Box<T> 将指向下一个 List 值,该值将位于堆上而非 Cons 变体内部。从概念上讲,我们仍然有一个由列表嵌套列表创建的列表,但这种实现方式现在更像是将各项并排放置,而非嵌套在彼此内部。
我们可以将示例 15-2 中 List 枚举的定义和示例 15-3 中 List 的用法修改为示例 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))))));
}
Box<T> 的 List 定义,以获得已知大小Cons 变体需要一个 i32 的大小加上存储 box 指针数据的空间。Nil 变体不存储任何值,因此它比 Cons 变体需要更少的栈空间。我们现在知道任何 List 值都将占用一个 i32 的大小加上一个 box 指针数据的大小。通过使用 box,我们打破了无限递归链,编译器就能计算出存储一个 List 值所需的大小了。图 15-2 展示了现在 Cons 变体的样子。
图 15-2:不再是无限大小的 List,因为 Cons 持有的是一个 Box
Box 只提供了间接引用和堆分配功能;它们没有其他特殊能力,比如我们将在其他智能指针类型中看到的那些。它们也没有这些特殊能力带来的性能开销,因此在像 cons list 这样只需要间接引用功能的场景中非常有用。我们将在第 18 章中看到更多 box 的使用场景。
Box<T> 类型是智能指针,因为它实现了 Deref trait,这使得 Box<T> 的值可以像引用一样被使用。当一个 Box<T> 值离开作用域时,由于 Drop trait 的实现,box 所指向的堆数据也会被清理。这两个 trait 对于本章其余部分将讨论的其他智能指针类型所提供的功能更加重要。让我们更详细地探索这两个 trait。
像常规引用一样使用智能指针
像常规引用一样使用智能指针
实现 Deref trait 允许你自定义解引用运算符(dereference operator)* 的行为(不要与乘法运算符或通配符运算符混淆)。通过以一种使智能指针能被当作常规引用来对待的方式实现 Deref,你可以编写操作引用的代码,并将该代码同样用于智能指针。
让我们首先看看解引用运算符如何与常规引用配合工作。然后,我们将尝试定义一个行为类似于 Box<T> 的自定义类型,看看为什么解引用运算符在我们新定义的类型上不能像引用那样工作。我们将探索如何通过实现 Deref trait 使智能指针能够以类似引用的方式工作。最后,我们将了解 Rust 的解引用强制转换(deref coercion)特性,以及它如何让我们既能使用引用也能使用智能指针。
通过引用追踪值
常规引用是一种指针,理解指针的一种方式是将其看作指向存储在其他位置的值的箭头。在示例 15-6 中,我们创建了一个 i32 值的引用,然后使用解引用运算符来追踪引用到达值。
fn main() {
let x = 5;
let y = &x;
assert_eq!(5, x);
assert_eq!(5, *y);
}
i32 值的引用变量 x 持有一个 i32 值 5。我们将 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)
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>
我们可以将示例 15-6 中的代码改写为使用 Box<T> 而不是引用;示例 15-7 中对 Box<T> 使用的解引用运算符与示例 15-6 中对引用使用的解引用运算符功能相同。
fn main() {
let x = 5;
let y = Box::new(x);
assert_eq!(5, x);
assert_eq!(5, *y);
}
Box<i32> 使用解引用运算符示例 15-7 与示例 15-6 的主要区别在于,这里我们将 y 设置为一个指向 x 值的拷贝的 box 实例,而不是指向 x 值的引用。在最后的断言中,我们可以使用解引用运算符来追踪 box 的指针,就像 y 是引用时所做的那样。接下来,我们将通过定义自己的 box 类型来探索 Box<T> 的特殊之处——是什么使我们能够对其使用解引用运算符。
定义自己的智能指针
让我们构建一个类似于标准库提供的 Box<T> 类型的包装类型,来体验智能指针类型在默认情况下与引用的行为有何不同。然后,我们将了解如何添加使用解引用运算符的能力。
注意:我们即将构建的
MyBox<T>类型与真正的Box<T>有一个很大的区别:我们的版本不会将数据存储在堆上。这个示例的重点是Deref,因此数据实际存储在哪里不如类似指针的行为重要。
Box<T> 类型最终被定义为一个包含一个元素的元组结构体,所以示例 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 类型是一个包含一个 T 类型元素的元组结构体。MyBox::new 函数接受一个 T 类型的参数,并返回一个持有传入值的 MyBox 实例。
让我们尝试将示例 15-7 中的 main 函数添加到示例 15-8 中,并将其改为使用我们定义的 MyBox<T> 类型而不是 Box<T>。示例 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);
}
Box<T> 相同的方式使用 MyBox<T>以下是编译错误的结果:
$ 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);
| ^^ can't be dereferenced
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 trait。
实现 Deref Trait
如第 10 章“在类型上实现 trait”中所讨论的,要实现一个 trait,我们需要为该 trait 的必需方法提供实现。标准库提供的 Deref trait 要求我们实现一个名为 deref 的方法,该方法借用 self 并返回一个指向内部数据的引用。示例 15-10 包含了一个添加到 MyBox<T> 定义上的 Deref 实现。
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);
}
MyBox<T> 上实现 Dereftype Target = T; 语法定义了 Deref trait 使用的关联类型。关联类型是一种略有不同的声明泛型参数的方式,但你现在不需要担心它们;我们将在第 20 章中更详细地介绍。
我们在 deref 方法体中填入了 &self.0,这样 deref 就会返回一个指向我们想用 * 运算符访问的值的引用;回忆一下第 5 章“使用元组结构体创建不同类型”中提到的,.0 访问元组结构体中的第一个值。示例 15-9 中对 MyBox<T> 值调用 * 的 main 函数现在可以编译了,并且断言也通过了!
没有 Deref trait 的话,编译器只能解引用 & 引用。deref 方法赋予了编译器这样的能力:对于任何实现了 Deref 的类型的值,调用 deref 方法即可获得一个它知道如何解引用的引用。
当我们在示例 15-9 中输入 *y 时,Rust 在幕后实际运行的是这样的代码:
*(y.deref())
Rust 将 * 运算符替换为先调用 deref 方法再进行普通解引用的操作,这样我们就不必考虑是否需要调用 deref 方法了。Rust 的这个特性让我们可以编写功能相同的代码,无论我们使用的是常规引用还是实现了 Deref 的类型。
deref 方法返回值的引用,以及 *(y.deref()) 中括号外的普通解引用仍然必要,这与所有权系统有关。如果 deref 方法直接返回值而不是值的引用,该值就会被移出 self。在这种情况下以及大多数使用解引用运算符的情况下,我们并不想获取 MyBox<T> 内部值的所有权。
注意,每次我们在代码中使用 * 时,* 运算符都会被替换为先调用 deref 方法再调用一次 * 运算符,且仅替换一次。因为 * 运算符的替换不会无限递归,我们最终会得到 i32 类型的数据,它与示例 15-9 中 assert_eq! 里的 5 相匹配。
在函数和方法中使用解引用强制转换
解引用强制转换(deref coercion)将实现了 Deref trait 的类型的引用转换为另一种类型的引用。例如,解引用强制转换可以将 &String 转换为 &str,因为 String 实现了 Deref trait 并返回 &str。解引用强制转换是 Rust 对函数和方法的参数执行的一种便利操作,它仅适用于实现了 Deref trait 的类型。当我们将某个特定类型的值的引用作为参数传递给函数或方法,而该引用的类型与函数或方法定义中的参数类型不匹配时,解引用强制转换会自动发生。一系列 deref 方法的调用会将我们提供的类型转换为参数所需的类型。
Rust 之所以加入解引用强制转换,是为了让编写函数和方法调用的程序员不必添加那么多显式的 & 和 * 来进行引用和解引用操作。解引用强制转换特性还让我们可以编写同时适用于引用和智能指针的代码。
为了实际看到解引用强制转换的效果,让我们使用示例 15-8 中定义的 MyBox<T> 类型以及示例 15-10 中添加的 Deref 实现。示例 15-11 展示了一个具有字符串切片参数的函数定义。
fn hello(name: &str) {
println!("Hello, {name}!");
}
fn main() {}
hello 函数,其参数 name 的类型为 &str我们可以用一个字符串切片作为参数来调用 hello 函数,例如 hello("Rust");。解引用强制转换使得用 MyBox<String> 类型值的引用来调用 hello 成为可能,如示例 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);
}
MyBox<String> 值的引用调用 hello,这得益于解引用强制转换这里我们用参数 &m 调用 hello 函数,&m 是一个 MyBox<String> 值的引用。因为我们在示例 15-10 中为 MyBox<T> 实现了 Deref trait,Rust 可以通过调用 deref 将 &MyBox<String> 转换为 &String。标准库为 String 提供了 Deref 的实现,它返回一个字符串切片,这在 Deref 的 API 文档中有说明。Rust 再次调用 deref 将 &String 转换为 &str,这就与 hello 函数的定义匹配了。
如果 Rust 没有实现解引用强制转换,我们就必须编写示例 15-13 中的代码来代替示例 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 trait 时,Rust 会分析这些类型并根据需要多次调用 Deref::deref 以获得匹配参数类型的引用。需要插入 Deref::deref 的次数在编译时就已确定,因此利用解引用强制转换不会带来任何运行时开销!
解引用强制转换与可变引用的交互
类似于使用 Deref trait 重载不可变引用的 * 运算符,你可以使用 DerefMut trait 重载可变引用的 * 运算符。
Rust 在发现类型和 trait 实现满足以下三种情况时会执行解引用强制转换:
- 当
T: Deref<Target=U>时,从&T到&U - 当
T: DerefMut<Target=U>时,从&mut T到&mut U - 当
T: Deref<Target=U>时,从&mut T到&U
前两种情况除了第二种涉及可变性之外是相同的。第一种情况表明,如果你有一个 &T,且 T 实现了到某个类型 U 的 Deref,你可以透明地获得一个 &U。第二种情况表明,对于可变引用也会发生同样的解引用强制转换。
第三种情况比较微妙:Rust 也会将可变引用强制转换为不可变引用。但反过来是不可能的:不可变引用永远不会被强制转换为可变引用。根据借用规则,如果你有一个可变引用,那么该可变引用必须是对该数据的唯一引用(否则程序无法编译)。将一个可变引用转换为一个不可变引用永远不会违反借用规则。而将一个不可变引用转换为可变引用则要求该不可变引用是对该数据的唯一不可变引用,但借用规则无法保证这一点。因此,Rust 无法假设将不可变引用转换为可变引用是可行的。
使用 Drop trait 在清理时运行代码
使用 Drop Trait 运行清理代码
对智能指针模式而言,第二个重要的 trait 是 Drop,它允许你自定义当值即将离开作用域时的行为。你可以为任何类型实现 Drop trait,这些代码可用于释放文件或网络连接等资源。
我们在智能指针的上下文中介绍 Drop,是因为 Drop trait 的功能几乎总是在实现智能指针时使用。例如,当 Box<T> 被丢弃时,它会释放 box 所指向的堆上空间。
在某些语言中,对于某些类型,程序员每次使用完这些类型的实例后都必须手动调用代码来释放内存或资源。例如文件句柄、套接字和锁。如果程序员忘记了,系统可能会过载并崩溃。在 Rust 中,你可以指定每当值离开作用域时运行一段特定的代码,编译器会自动插入这些代码。因此,你无需在程序中到处小心翼翼地放置清理代码——你仍然不会泄漏资源!
你可以通过实现 Drop trait 来指定值离开作用域时要运行的代码。Drop trait 要求你实现一个名为 drop 的方法,它接受一个 self 的可变引用。为了观察 Rust 何时调用 drop,我们先用 println! 语句来实现 drop。
示例 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 包含在 prelude 中,所以我们无需将其引入作用域。我们在 CustomSmartPointer 上实现了 Drop trait,并提供了一个调用 println! 的 drop 方法实现。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,执行了我们指定的代码。变量以其创建顺序的相反顺序被丢弃,所以 d 在 c 之前被丢弃。这个例子的目的是让你直观地了解 drop 方法的工作方式;通常你会指定类型需要运行的清理代码,而不是打印消息。
遗憾的是,禁用自动 drop 功能并不简单。通常也不需要禁用它;Drop trait 的核心意义就在于它会被自动处理。然而,有时你可能希望提前清理某个值。一个例子是使用管理锁的智能指针时:你可能希望强制调用释放锁的 drop 方法,以便同一作用域中的其他代码可以获取该锁。Rust 不允许你手动调用 Drop trait 的 drop 方法;如果你想在值离开作用域之前强制丢弃它,需要调用标准库提供的 std::mem::drop 函数。
如果我们尝试通过修改示例 15-14 中的 main 函数来手动调用 Drop trait 的 drop 方法,将无法通过编译,如示例 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");
}
Drop trait 的 drop 方法来提前清理当我们尝试编译这段代码时,会得到如下错误:
$ 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 - c.drop();
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),这是清理实例的函数的通用编程术语。析构函数与构造函数(constructor)相对应,构造函数用于创建实例。Rust 中的 drop 函数就是一种特定的析构函数。
Rust 不允许我们显式调用 drop,因为 Rust 仍然会在 main 末尾自动对该值调用 drop。这会导致双重释放(double free)错误,因为 Rust 会尝试清理同一个值两次。
我们既不能禁用值离开作用域时自动插入的 drop,也不能显式调用 drop 方法。所以,如果我们需要强制提前清理一个值,可以使用 std::mem::drop 函数。
std::mem::drop 函数不同于 Drop trait 中的 drop 方法。我们通过将想要强制丢弃的值作为参数传递给它来调用。该函数位于 prelude 中,所以我们可以修改示例 15-15 中的 main 来调用 drop 函数,如示例 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");
}
std::mem::drop 来在值离开作用域之前显式丢弃它运行这段代码会打印如下内容:
$ 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>:引用计数智能指针
Rc<T>,引用计数智能指针
在大多数情况下,所有权是明确的:你清楚地知道哪个变量拥有某个值。然而,有些情况下一个值可能会有多个所有者。例如,在图数据结构中,多条边可能指向同一个节点,而这个节点在概念上被所有指向它的边所拥有。一个节点只有在没有任何边指向它、也就是没有任何所有者时,才应该被清理。
你需要使用 Rust 的 Rc<T> 类型来显式地启用多重所有权,Rc<T> 是 引用计数(reference counting)的缩写。Rc<T> 类型会追踪一个值的引用数量,以此判断该值是否仍在使用中。如果一个值的引用数量为零,那么这个值就可以被安全地清理,而不会导致任何引用失效。
可以把 Rc<T> 想象成客厅里的一台电视。当一个人进来看电视时,他会打开电视。其他人也可以进来一起看。当最后一个人离开房间时,他会关掉电视,因为电视已经没人看了。如果有人在其他人还在看的时候就把电视关了,剩下的人肯定会不高兴!
当我们希望在堆上分配一些数据供程序的多个部分读取,而又无法在编译时确定哪个部分会最后使用完这些数据时,就可以使用 Rc<T> 类型。如果我们能确定哪个部分最后使用完,那只需让那个部分成为数据的所有者就好了,编译时的常规所有权规则就会生效。
注意 Rc<T> 只适用于单线程场景。当我们在第 16 章讨论并发时,会介绍如何在多线程程序中进行引用计数。
共享数据
让我们回到示例 15-5 中的 cons list 例子。回忆一下,我们当时使用 Box<T> 来定义它。这次,我们将创建两个列表,它们共享第三个列表的所有权。从概念上看,这类似于图 15-3。
图 15-3:两个列表 b 和 c 共享第三个列表 a 的所有权
我们将创建一个包含 5 和 10 的列表 a。然后再创建两个列表:b 以 3 开头,c 以 4 开头。b 和 c 两个列表接下来都会连接到包含 5 和 10 的第一个列表 a。换句话说,两个列表将共享包含 5 和 10 的第一个列表。
尝试使用 Box<T> 定义的 List 来实现这个场景是行不通的,如示例 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));
}
Box<T> 的两个列表无法共享第三个列表的所有权编译这段代码时,我们会得到如下错误:
$ 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
|
note: if `List` implemented `Clone`, you could clone the value
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^ consider implementing `Clone` for this type
...
10 | let b = Cons(3, Box::new(a));
| - you could clone this value
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 的定义改为持有引用,但那样就必须指定生命周期参数。通过指定生命周期参数,我们将要求列表中的每个元素至少与整个列表存活一样长。对于示例 15-17 中的元素和列表来说确实如此,但并非所有场景都是这样。
我们换一种方式,将 List 的定义改为使用 Rc<T> 来代替 Box<T>,如示例 15-18 所示。现在每个 Cons 变体将持有一个值和一个指向 List 的 Rc<T>。当我们创建 b 时,不再获取 a 的所有权,而是克隆 a 持有的 Rc<List>,从而将引用计数从一增加到二,让 a 和 b 共享该 Rc<List> 中数据的所有权。创建 c 时我们也会克隆 a,将引用计数从二增加到三。每次调用 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));
}
Rc<T> 定义的 List我们需要添加一条 use 语句将 Rc<T> 引入作用域,因为它不在 prelude 中。在 main 中,我们创建了包含 5 和 10 的列表,并将其存储在 a 中的一个新 Rc<List> 里。然后,当我们创建 b 和 c 时,调用 Rc::clone 函数并传入 a 中 Rc<List> 的引用作为参数。
我们也可以调用 a.clone() 而不是 Rc::clone(&a),但 Rust 的惯例是在这种情况下使用 Rc::clone。Rc::clone 的实现不会像大多数类型的 clone 实现那样对所有数据进行深拷贝。调用 Rc::clone 只会增加引用计数,这不会花费太多时间。而数据的深拷贝可能会非常耗时。通过使用 Rc::clone 进行引用计数,我们可以在视觉上区分深拷贝类型的克隆和增加引用计数类型的克隆。在排查代码中的性能问题时,我们只需要关注深拷贝的克隆,而可以忽略对 Rc::clone 的调用。
克隆 Rc<T> 会增加引用计数
让我们修改示例 15-18 中的工作示例,以便观察在创建和丢弃 a 中 Rc<List> 的引用时,引用计数是如何变化的。
在示例 15-19 中,我们将修改 main,在列表 c 周围添加一个内部作用域;这样我们就能看到当 c 离开作用域时引用计数是如何变化的。
enum List {
Cons(i32, Rc<List>),
Nil,
}
use crate::List::{Cons, Nil};
use std::rc::Rc;
// --snip--
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> 值离开作用域时自动减少引用计数。
在这个例子中我们看不到的是,当 b 和 a 在 main 末尾离开作用域时,计数变为 0,Rc<List> 被完全清理。使用 Rc<T> 允许一个值拥有多个所有者,而引用计数确保只要任何一个所有者仍然存在,该值就保持有效。
通过不可变引用,Rc<T> 允许你在程序的多个部分之间共享只读数据。如果 Rc<T> 也允许你拥有多个可变引用,你可能会违反第 4 章讨论的借用规则之一:对同一位置的多个可变借用可能导致数据竞争和不一致。但是能够修改数据是非常有用的!在下一节中,我们将讨论内部可变性模式和 RefCell<T> 类型,你可以将它与 Rc<T> 结合使用来突破这个不可变性限制。
RefCell<T> 与内部可变性模式
RefCell<T> 与内部可变性模式
内部可变性(interior mutability)是 Rust 中的一种设计模式,它允许你在持有不可变引用的情况下修改数据;通常情况下,借用规则不允许这样做。为了实现数据的修改,该模式在数据结构内部使用 unsafe 代码来绕过 Rust 通常的可变性和借用规则。不安全代码向编译器表明,我们将手动检查这些规则,而不是依赖编译器来检查;我们将在第 20 章更详细地讨论不安全代码。
只有当我们能确保借用规则在运行时会被遵守时,才能使用采用内部可变性模式的类型,即使编译器无法保证这一点。其中涉及的 unsafe 代码被封装在安全的 API 中,而外部类型仍然是不可变的。
让我们通过研究遵循内部可变性模式的 RefCell<T> 类型来探索这个概念。
在运行时强制执行借用规则
与 Rc<T> 不同,RefCell<T> 类型代表其持有数据的单一所有权。那么,RefCell<T> 与 Box<T> 这样的类型有什么不同呢?回忆一下你在第 4 章学到的借用规则:
- 在任意给定时刻,你只能拥有一个可变引用或任意数量的不可变引用(二者不可兼得)。
- 引用必须始终有效。
对于引用和 Box<T>,借用规则的不变性在编译时强制执行。而对于 RefCell<T>,这些不变性在运行时强制执行。对于引用,如果你违反了这些规则,会得到一个编译器错误。而对于 RefCell<T>,如果你违反了这些规则,程序会 panic 并退出。
在编译时检查借用规则的优势在于,错误能在开发过程中更早被发现,并且不会对运行时性能产生影响,因为所有分析都在编译阶段完成了。因此,在大多数情况下,在编译时检查借用规则是最佳选择,这也是 Rust 的默认行为。
在运行时检查借用规则的优势在于,某些内存安全的场景得以被允许,而这些场景在编译时检查中会被拒绝。静态分析,比如 Rust 编译器,本质上是保守的。代码的某些属性通过分析代码是不可能检测到的:最著名的例子就是停机问题(Halting Problem),这超出了本书的范围,但它是一个值得研究的有趣话题。
因为某些分析是不可能完成的,如果 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>是不可变的,你也可以修改其内部的值。
在不可变值内部修改值就是内部可变性模式。让我们看一个内部可变性有用的场景,并探讨它是如何实现的。
使用内部可变性
借用规则的一个推论是,当你有一个不可变值时,你不能对它进行可变借用。例如,以下代码无法编译:
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> 修改一个不可变值,并了解为什么这样做是有用的。
使用 Mock 对象进行测试
有时在测试中,程序员会用一个类型来替代另一个类型,以便观察特定的行为并断言其实现是正确的。这种占位类型被称为测试替身(test double)。可以把它想象成电影拍摄中的替身演员,由一个人代替演员来完成特别复杂的场景。测试替身在运行测试时代替其他类型。Mock 对象是特定类型的测试替身,它记录测试过程中发生的事情,以便你可以断言正确的操作已经执行。
Rust 没有像其他语言那样的对象概念,Rust 也没有像某些其他语言那样在标准库中内置 mock 对象功能。不过,你完全可以创建一个结构体来实现与 mock 对象相同的目的。
下面是我们要测试的场景:我们将创建一个库,用于跟踪某个值与最大值的接近程度,并根据当前值与最大值的比例发送消息。例如,这个库可以用来跟踪用户的 API 调用配额使用情况。
我们的库只提供跟踪值与最大值接近程度的功能,以及在什么时候应该发送什么消息。使用这个库的应用程序需要自行提供发送消息的机制:应用程序可以直接向用户显示消息、发送电子邮件、发送短信或执行其他操作。库不需要知道这些细节。它只需要一个实现了我们提供的 Messenger trait 的东西。示例 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 就是我们的 mock 对象需要实现的接口,这样 mock 就可以像真实对象一样使用。另一个重要的部分是,我们想要测试 LimitTracker 上 set_value 方法的行为。我们可以改变传入的 value 参数值,但 set_value 没有返回任何东西供我们进行断言。我们希望能够验证:如果我们用一个实现了 Messenger trait 的东西和一个特定的 max 值创建了 LimitTracker,当我们传入不同的 value 值时,messenger 会被告知发送相应的消息。
我们需要一个 mock 对象,它在我们调用 send 时不会真的发送电子邮件或短信,而只是记录它被告知要发送的消息。我们可以创建一个 mock 对象的新实例,创建一个使用该 mock 对象的 LimitTracker,调用 LimitTracker 上的 set_value 方法,然后检查 mock 对象是否有我们期望的消息。示例 15-21 展示了一个尝试实现这样的 mock 对象的代码,但借用检查器不允许这样做。
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这段测试代码定义了一个 MockMessenger 结构体,它有一个 sent_messages 字段,类型为 Vec<String>,用于记录它被告知要发送的消息。我们还定义了一个关联函数 new,方便创建以空消息列表开始的新 MockMessenger 值。然后我们为 MockMessenger 实现了 Messenger trait,这样就可以将 MockMessenger 传给 LimitTracker。在 send 方法的定义中,我们将传入的消息作为参数存储到 MockMessenger 的 sent_messages 列表中。
在测试中,我们测试的是当 LimitTracker 被告知将 value 设置为超过 max 值 75% 的某个值时会发生什么。首先,我们创建一个新的 MockMessenger,它以空消息列表开始。然后,我们创建一个新的 LimitTracker,并传入新 MockMessenger 的引用和 max 值 100。我们用值 80 调用 LimitTracker 的 set_value 方法,这超过了 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
我们无法修改 MockMessenger 来记录消息,因为 send 方法接受的是 self 的不可变引用。我们也不能采纳错误信息中的建议,在 impl 方法和 trait 定义中都使用 &mut self。我们不想仅仅为了测试而修改 Messenger trait。相反,我们需要找到一种方法,让我们的测试代码在现有设计下正确工作。
这正是内部可变性可以帮忙的场景!我们将 sent_messages 存储在 RefCell<T> 中,这样 send 方法就能修改 sent_messages 来存储我们看到的消息。示例 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);
}
}
RefCell<T> 在外部值被视为不可变的情况下修改内部值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>,让我们深入了解它的工作原理!
在运行时跟踪借用
当创建不可变和可变引用时,我们分别使用 & 和 &mut 语法。对于 RefCell<T>,我们使用 borrow 和 borrow_mut 方法,它们是 RefCell<T> 安全 API 的一部分。borrow 方法返回智能指针类型 Ref<T>,borrow_mut 返回智能指针类型 RefMut<T>。这两个类型都实现了 Deref,所以我们可以像对待普通引用一样对待它们。
RefCell<T> 会跟踪当前有多少个 Ref<T> 和 RefMut<T> 智能指针处于活跃状态。每次调用 borrow 时,RefCell<T> 会将不可变借用的活跃计数加 1。当一个 Ref<T> 值离开作用域时,不可变借用的计数减 1。就像编译时的借用规则一样,RefCell<T> 在任何时刻都只允许拥有多个不可变借用或一个可变借用。
如果我们尝试违反这些规则,与使用引用时会得到编译器错误不同,RefCell<T> 的实现会在运行时 panic。示例 15-23 展示了对示例 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);
}
}
RefCell<T> 会 panic我们为从 borrow_mut 返回的 RefMut<T> 智能指针创建了一个变量 one_borrow。然后,我们以同样的方式在变量 two_borrow 中创建了另一个可变借用。这在同一作用域中产生了两个可变引用,这是不允许的。当我们运行库的测试时,示例 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:
RefCell already borrowed
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> 使得编写一个能够修改自身以记录所见消息的 mock 对象成为可能,而你是在一个只允许不可变值的上下文中使用它。尽管 RefCell<T> 有这些权衡,你仍然可以使用它来获得比普通引用更多的功能。
允许可变数据有多个所有者
RefCell<T> 的一个常见用法是与 Rc<T> 结合使用。回忆一下,Rc<T> 允许某些数据有多个所有者,但它只提供对数据的不可变访问。如果你有一个持有 RefCell<T> 的 Rc<T>,你就可以得到一个既能有多个所有者又能修改的值!
例如,回忆一下示例 15-18 中的 cons list 例子,我们使用 Rc<T> 来允许多个列表共享另一个列表的所有权。因为 Rc<T> 只持有不可变值,所以一旦创建了列表中的值,就无法再修改它们。让我们加入 RefCell<T> 来获得修改列表中值的能力。示例 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>> 创建一个可以修改的 List我们创建了一个 Rc<RefCell<i32>> 的实例,并将其存储在名为 value 的变量中,以便稍后可以直接访问它。然后,我们在 a 中创建了一个包含 value 的 Cons 变体的 List。我们需要克隆 value,这样 a 和 value 都拥有内部值 5 的所有权,而不是将所有权从 value 转移到 a,也不是让 a 从 value 借用。
我们将列表 a 包装在 Rc<T> 中,这样当我们创建列表 b 和 c 时,它们都可以引用 a,就像我们在示例 15-18 中所做的那样。
在创建了 a、b 和 c 中的列表之后,我们想要将 value 中的值加 10。我们通过对 value 调用 borrow_mut 来实现这一点,这里使用了我们在第 5 章“-> 运算符到哪去了?”中讨论的自动解引用功能,将 Rc<T> 解引用到内部的 RefCell<T> 值。borrow_mut 方法返回一个 RefMut<T> 智能指针,我们对其使用解引用运算符来修改内部值。
当我们打印 a、b 和 c 时,可以看到它们都有修改后的值 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 的内存安全保证使得意外创建永远不会被清理的内存(即 内存泄漏(memory leak))变得困难,但并非不可能。完全防止内存泄漏并不是 Rust 的保证之一,这意味着内存泄漏在 Rust 中是内存安全的。我们可以看到,通过使用 Rc<T> 和 RefCell<T>,Rust 允许内存泄漏的发生:可以创建各项互相引用形成循环的引用。这会造成内存泄漏,因为循环中每一项的引用计数永远不会达到 0,值也永远不会被丢弃。
创建引用循环
让我们看看引用循环是如何发生的以及如何防止它,首先从示例 15-25 中 List 枚举的定义和 tail 方法开始。
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() {}
RefCell<T> 的 cons list 定义,以便我们可以修改 Cons 变体所引用的内容我们使用了示例 15-5 中 List 定义的另一个变体。Cons 变体中的第二个元素现在是 RefCell<Rc<List>>,这意味着不同于示例 15-24 中修改 i32 值的做法,我们希望修改 Cons 变体所指向的 List 值。我们还添加了一个 tail 方法,以便在有 Cons 变体时能方便地访问第二个元素。
在示例 15-26 中,我们添加了一个使用示例 15-25 中定义的 main 函数。这段代码在 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());
}
List 值的引用循环我们创建了一个 Rc<List> 实例,在变量 a 中存放了一个初始列表 5, Nil 的 List 值。然后创建了另一个 Rc<List> 实例,在变量 b 中存放了包含值 10 并指向 a 中列表的另一个 List 值。
我们修改 a 使其指向 b 而不是 Nil,从而创建了一个循环。我们通过使用 tail 方法获取 a 中 RefCell<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 之后,a 和 b 中 Rc<List> 实例的引用计数都是 2。在 main 的末尾,Rust 丢弃变量 b,这将 b 的 Rc<List> 实例的引用计数从 2 减少到 1。此时 Rc<List> 在堆上的内存不会被释放,因为其引用计数是 1 而不是 0。然后 Rust 丢弃 a,这同样将 a 的 Rc<List> 实例的引用计数从 2 减少到 1。这个实例的内存也无法被释放,因为另一个 Rc<List> 实例仍然引用着它。分配给列表的内存将永远无法被回收。为了可视化这个引用循环,我们创建了图 15-4 所示的示意图。
图 15-4:列表 a 和 b 互相指向形成的引用循环
如果你取消最后一个 println! 的注释并运行程序,Rust 会尝试打印这个循环,a 指向 b,b 又指向 a,如此反复,直到栈溢出。
与实际程序相比,在这个例子中创建引用循环的后果并不严重:在我们创建引用循环之后,程序就结束了。然而,如果一个更复杂的程序在循环中分配了大量内存并长时间持有,程序将使用比实际需要更多的内存,并可能压垮系统,导致可用内存耗尽。
创建引用循环并不容易做到,但也不是不可能。如果你有包含 Rc<T> 值的 RefCell<T> 值,或者类似的具有内部可变性和引用计数的嵌套类型组合,你必须确保不会创建循环;你不能依赖 Rust 来捕获它们。创建引用循环是程序中的一个逻辑错误,你应该使用自动化测试、代码审查和其他软件开发实践来将其最小化。
避免引用循环的另一个解决方案是重新组织数据结构,使得一些引用表达所有权而另一些引用不表达所有权。这样,你可以拥有由一些所有权关系和一些非所有权关系组成的循环,而只有所有权关系会影响值是否可以被丢弃。在示例 15-25 中,我们总是希望 Cons 变体拥有其列表,所以重新组织数据结构是不可能的。让我们看一个使用由父节点和子节点组成的图的例子,来了解非所有权关系何时是防止引用循环的合适方式。
使用 Weak<T> 避免引用循环
到目前为止,我们已经演示了调用 Rc::clone 会增加 Rc<T> 实例的 strong_count,而 Rc<T> 实例只有在其 strong_count 为 0 时才会被清理。你也可以通过调用 Rc::downgrade 并传入 Rc<T> 的引用来创建对 Rc<T> 实例中值的弱引用(weak reference)。强引用(strong references)是你共享 Rc<T> 实例所有权的方式。弱引用(weak references)不表达所有权关系,它们的计数不会影响 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 会确保 Some 和 None 两种情况都被处理,所以不会出现无效指针。
作为示例,我们将创建一棵树,其中的节点不仅知道自己的子节点,还知道自己的父节点,而不是使用只知道下一项的列表。
创建树形数据结构
首先,我们将构建一棵树,其节点知道自己的子节点。我们将创建一个名为 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>>。
接下来,我们将使用这个结构体定义,创建一个名为 leaf 的 Node 实例(值为 3,没有子节点),以及另一个名为 branch 的实例(值为 5,leaf 作为其子节点之一),如示例 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 节点和一个以 leaf 作为子节点的 branch 节点我们克隆了 leaf 中的 Rc<Node> 并将其存储在 branch 中,这意味着 leaf 中的 Node 现在有两个所有者:leaf 和 branch。我们可以通过 branch.children 从 branch 访问到 leaf,但无法从 leaf 访问到 branch。原因是 leaf 没有对 branch 的引用,也不知道它们之间存在关联。我们希望 leaf 知道 branch 是它的父节点。接下来我们就来实现这一点。
增加从子节点到父节点的引用
为了让子节点知道它的父节点,我们需要在 Node 结构体定义中添加一个 parent 字段。问题在于决定 parent 的类型应该是什么。我们知道它不能包含 Rc<T>,因为那样会创建一个引用循环:leaf.parent 指向 branch,而 branch.children 指向 leaf,这会导致它们的 strong_count 值永远不会为 0。
从另一个角度思考这些关系,父节点应该拥有其子节点:如果父节点被丢弃,其子节点也应该被丢弃。然而,子节点不应该拥有其父节点:如果我们丢弃一个子节点,父节点应该仍然存在。这正是弱引用的用武之地!
因此,我们将 parent 的类型设为 Weak<T> 而不是 Rc<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());
}
一个节点将能够引用其父节点,但不拥有其父节点。在示例 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 节点,持有对其父节点 branch 的弱引用创建 leaf 节点的方式与示例 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> 引用。我们使用 leaf 的 parent 字段中 RefCell<Weak<Node>> 上的 borrow_mut 方法,然后使用 Rc::downgrade 函数从 branch 中的 Rc<Node> 创建一个指向 branch 的 Weak<Node> 引用。
当我们再次打印 leaf 的父节点时,这次我们会得到一个持有 branch 的 Some 变体:现在 leaf 可以访问它的父节点了!当我们打印 leaf 时,我们也避免了像示例 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_count 和 Rc::weak_count 得到的值来确认这一点。
可视化 strong_count 和 weak_count 的变化
让我们看看 Rc<Node> 实例的 strong_count 和 weak_count 值是如何变化的,方法是创建一个新的内部作用域并将 branch 的创建移入该作用域。这样我们就可以看到 branch 被创建然后在离开作用域时被丢弃会发生什么。修改如示例 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),
);
}
branch 并检查强引用和弱引用计数leaf 创建之后,其 Rc<Node> 的强引用计数为 1,弱引用计数为 0。在内部作用域中,我们创建了 branch 并将其与 leaf 关联,此时当我们打印计数时,branch 中的 Rc<Node> 的强引用计数为 1,弱引用计数为 1(因为 leaf.parent 通过 Weak<Node> 指向了 branch)。当我们打印 leaf 的计数时,会看到它的强引用计数为 2,因为 branch 现在在 branch.children 中存储了 leaf 的 Rc<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> 类型及其内部可变性为我们提供了一种类型,当我们需要一个不可变类型但又需要改变其内部值时可以使用它;它还在运行时而非编译时强制执行借用规则。
我们还讨论了 Deref 和 Drop trait,它们实现了智能指针的许多功能。我们探索了可能导致内存泄漏的引用循环,以及如何使用 Weak<T> 来防止它们。
如果本章引起了你的兴趣,并且你想实现自己的智能指针,请查阅 “The Rustonomicon” 以获取更多有用的信息。
接下来,我们将讨论 Rust 中的并发。你甚至会学到一些新的智能指针。
无畏并发
安全且高效地处理并发编程是 Rust 的另一个主要目标。并发编程(concurrent programming),即程序的不同部分独立执行;以及并行编程(parallel programming),即程序的不同部分同时执行——随着越来越多的计算机利用其多核处理器,这两者变得日益重要。从历史上看,在这些场景下编程一直是困难且容易出错的。Rust 希望改变这一现状。
最初,Rust 团队认为确保内存安全和防止并发问题是两个需要用不同方法解决的独立挑战。随着时间的推移,团队发现所有权和类型系统是一套强大的工具,能够帮助管理内存安全和并发问题!通过利用所有权和类型检查,许多并发错误在 Rust 中是编译时错误而非运行时错误。因此,你不必花费大量时间去重现运行时并发 bug 出现的确切条件,不正确的代码会直接拒绝编译并给出解释问题的错误信息。这样,你可以在编写代码时就修复问题,而不是在代码部署到生产环境之后才发现。我们将 Rust 的这一特性称为无畏并发(fearless concurrency)。无畏并发让你能够编写没有隐蔽 bug 的代码,并且在重构时不会引入新的 bug。
注意:为了简洁起见,我们将许多问题统称为并发问题,而不是更精确地说并发和/或并行。在本章中,当我们使用并发一词时,请在心中将其替换为并发和/或并行。在下一章中,由于区分这两者更为重要,我们会更加精确地表述。
许多语言在处理并发问题时提供的解决方案是教条式的。例如,Erlang 拥有优雅的消息传递并发功能,但在线程之间共享状态方面只有晦涩的方式。只支持可能解决方案的一个子集,对于高级语言来说是一种合理的策略,因为高级语言通过放弃一些控制权来换取抽象所带来的好处。然而,低级语言则被期望在任何给定场景下都能提供性能最优的解决方案,并且对硬件的抽象更少。因此,Rust 提供了多种工具,让你能够以适合自身场景和需求的方式来建模问题。
以下是本章将要涵盖的主题:
- 如何创建线程来同时运行多段代码
- 消息传递(message-passing)并发,其中通道(channel)在线程之间发送消息
- 共享状态(shared-state)并发,其中多个线程可以访问同一份数据
Sync和Sendtrait,它们将 Rust 的并发保证扩展到用户自定义类型以及标准库提供的类型
使用线程同时运行代码
使用线程同时运行代码
在大多数当前的操作系统中,已执行程序的代码运行在一个进程(process)中,操作系统会同时管理多个进程。在程序内部,你也可以拥有同时运行的独立部分。运行这些独立部分的功能被称为线程(thread)。例如,一个 Web 服务器可以拥有多个线程,这样它就能同时响应多个请求。
将程序中的计算拆分到多个线程中以同时运行多个任务可以提高性能,但这也增加了复杂性。因为线程可以同时运行,所以无法保证不同线程上的代码的执行顺序。这可能导致以下问题:
- 竞态条件(race condition),即多个线程以不一致的顺序访问数据或资源
- 死锁(deadlock),即两个线程互相等待对方,导致双方都无法继续执行
- 只在特定情况下才会出现的 bug,难以可靠地重现和修复
Rust 试图减轻使用线程带来的负面影响,但在多线程环境中编程仍然需要仔细思考,并且需要与单线程程序不同的代码结构。
编程语言以几种不同的方式实现线程,许多操作系统提供了可供编程语言调用的 API 来创建新线程。Rust 标准库使用 1:1 线程模型,即程序为每个语言线程使用一个操作系统线程。也有一些 crate 实现了其他线程模型,这些模型与 1:1 模型有不同的取舍。(Rust 的异步系统——我们将在下一章中看到——也提供了另一种并发方式。)
使用 spawn 创建新线程
要创建一个新线程,我们调用 thread::spawn 函数并传递一个闭包(我们在第 13 章讨论过闭包),其中包含我们想在新线程中运行的代码。示例 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 会强制线程暂停执行一小段时间,从而允许其他线程运行。这些线程可能会轮流执行,但这并不能保证:这取决于操作系统如何调度线程。在这次运行中,主线程先打印了,尽管新创建线程的打印语句在代码中出现得更早。而且,虽然我们让新创建的线程打印到 i 为 9,但它只打印到了 5,因为主线程就已经结束了。
如果你运行这段代码时只看到了主线程的输出,或者没有看到交替输出,可以尝试增大范围中的数字,为操作系统在线程之间切换创造更多机会。
等待所有线程完成
示例 16-1 中的代码不仅会因为主线程结束而导致新创建的线程大多数时候被提前终止,而且由于无法保证线程的运行顺序,我们甚至不能保证新创建的线程会被执行!
我们可以通过将 thread::spawn 的返回值保存在一个变量中来解决新创建的线程不运行或提前结束的问题。thread::spawn 的返回类型是 JoinHandle<T>。JoinHandle<T> 是一个拥有所有权的值,当我们对其调用 join 方法时,它会等待对应的线程完成。示例 16-2 展示了如何使用示例 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();
}
thread::spawn 返回的 JoinHandle<T>,以确保线程运行完成对 handle 调用 join 会阻塞当前正在运行的线程,直到 handle 所代表的线程终止。阻塞(blocking)一个线程意味着阻止该线程执行工作或退出。因为我们将 join 的调用放在了主线程的 for 循环之后,运行示例 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。现在我们将更多地关注 move 和 thread::spawn 之间的交互。
注意在示例 16-1 中,我们传递给 thread::spawn 的闭包没有接受任何参数:我们没有在新创建线程的代码中使用主线程的任何数据。要在新创建的线程中使用主线程的数据,新创建线程的闭包必须捕获它需要的值。示例 16-3 展示了一个尝试在主线程中创建 vector 并在新创建的线程中使用它的例子。不过,这还不能工作,你马上就会看到原因。
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 的引用是否始终有效。
示例 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();
}
v 的引用,但主线程丢弃了 v如果 Rust 允许我们运行这段代码,新创建的线程有可能会被立即放到后台而根本不运行。新创建的线程内部持有 v 的引用,但主线程使用我们在第 15 章讨论过的 drop 函数立即丢弃了 v。然后,当新创建的线程开始执行时,v 已经不再有效,所以对它的引用也是无效的。糟糕!
要修复示例 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 推断它应该借用这些值。示例 16-5 展示了对示例 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();
}
move 关键字强制闭包获取它所使用的值的所有权我们可能会想用同样的方法来修复示例 16-4 中主线程调用了 drop 的代码,即使用 move 闭包。然而,这个修复不会奏效,因为示例 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
|
help: consider cloning the value before moving it into the closure
|
6 ~ let value = v.clone();
7 ~ let handle = thread::spawn(move || {
8 ~ println!("Here's a vector: {value:?}");
|
For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` (bin "threads") due to 1 previous error
Rust 的所有权规则再次拯救了我们!示例 16-3 中的代码报错是因为 Rust 采取了保守策略,只为线程借用 v,这意味着主线程理论上可能会使新创建线程的引用失效。通过告诉 Rust 将 v 的所有权移动到新创建的线程,我们向 Rust 保证主线程不会再使用 v。如果我们以同样的方式修改示例 16-4,那么当我们尝试在主线程中使用 v 时,就违反了所有权规则。move 关键字覆盖了 Rust 保守的默认借用行为;但它不允许我们违反所有权规则。
现在我们已经了解了什么是线程以及线程 API 提供的方法,让我们来看看一些可以使用线程的场景。
使用消息传递在线程间传输数据
使用消息传递在线程间传输数据
一种日益流行的确保安全并发的方法是消息传递(message passing),即线程或 actor 通过互相发送包含数据的消息来进行通信。以下是 Go 语言文档中的一句口号:“不要通过共享内存来通信;而是通过通信来共享内存。”
为了实现消息发送式的并发,Rust 标准库提供了通道(channel)的实现。通道是一个通用的编程概念,通过它可以将数据从一个线程发送到另一个线程。
你可以把编程中的通道想象成一条有方向的水道,比如一条小溪或河流。如果你把一只橡皮鸭放入河中,它会顺流而下到达水道的尽头。
通道有两个部分:发送端(transmitter)和接收端(receiver)。发送端是你把橡皮鸭放入河流的上游位置,接收端是橡皮鸭最终到达的下游位置。代码的一部分调用发送端的方法来发送数据,另一部分则检查接收端是否有消息到达。当发送端或接收端中的任何一个被丢弃时,通道就被认为是关闭的。
接下来,我们将逐步构建一个程序:一个线程生成值并通过通道发送,另一个线程接收这些值并打印出来。我们将通过通道在线程之间发送简单的值来演示这个功能。一旦你熟悉了这项技术,就可以将通道用于任何需要相互通信的线程,例如聊天系统或多个线程各自执行部分计算并将结果发送给一个汇总线程的系统。
首先,在示例 16-6 中,我们将创建一个通道但不对它做任何操作。注意这还不能编译,因为 Rust 无法判断我们想通过通道发送什么类型的值。
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel();
}
tx 和 rx我们使用 mpsc::channel 函数创建一个新通道;mpsc 代表多生产者,单消费者(multiple producer, single consumer)。简而言之,Rust 标准库实现通道的方式意味着一个通道可以有多个产生值的发送端,但只能有一个消费这些值的接收端。想象多条小溪汇入一条大河:任何一条小溪中送出的东西最终都会到达同一条大河中。我们先从单个生产者开始,等这个例子运行起来后再添加多个生产者。
mpsc::channel 函数返回一个元组,第一个元素是发送端——即发送器(transmitter),第二个元素是接收端——即接收器(receiver)。缩写 tx 和 rx 在许多领域中传统上分别用于表示发送器和接收器,因此我们用这些名称来命名变量以表示各自的端。我们使用了带有模式的 let 语句来解构元组;我们将在第 19 章讨论 let 语句中模式的使用和解构。目前只需知道,以这种方式使用 let 语句是提取 mpsc::channel 返回的元组各部分的便捷方法。
让我们将发送端移动到一个新创建的线程中,让它发送一个字符串,这样新创建的线程就能与主线程通信了,如示例 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();
});
}
tx 移动到新创建的线程中并发送 "hi"同样,我们使用 thread::spawn 创建一个新线程,然后使用 move 将 tx 移动到闭包中,这样新创建的线程就拥有了 tx。新创建的线程需要拥有发送器才能通过通道发送消息。
发送器有一个 send 方法,接受我们想要发送的值。send 方法返回一个 Result<T, E> 类型,所以如果接收端已经被丢弃,没有地方可以发送值,发送操作就会返回一个错误。在这个例子中,我们调用 unwrap 在出错时 panic。但在实际应用中,我们应该正确处理它:回顾第 9 章了解正确的错误处理策略。
在示例 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}");
}
"hi" 并打印它接收器有两个有用的方法:recv 和 try_recv。我们使用的是 recv,它是 receive 的缩写,它会阻塞主线程的执行并等待,直到有值通过通道发送过来。一旦有值被发送,recv 会将其包装在 Result<T, E> 中返回。当发送器关闭时,recv 会返回一个错误,表示不会再有更多的值到来。
try_recv 方法不会阻塞,而是立即返回一个 Result<T, E>:如果有消息可用则返回包含消息的 Ok 值,如果此时没有任何消息则返回 Err 值。如果线程在等待消息的同时还有其他工作要做,使用 try_recv 就很有用:我们可以编写一个循环,每隔一段时间调用一次 try_recv,有消息时处理消息,否则做一会儿其他工作,然后再次检查。
在这个例子中,为了简单起见我们使用了 recv;主线程除了等待消息之外没有其他工作要做,所以阻塞主线程是合适的。
当我们运行示例 16-8 中的代码时,我们会看到主线程打印出的值:
Got: hi
很好!
通过通道转移所有权
所有权规则在消息发送中扮演着至关重要的角色,因为它们帮助你编写安全的并发代码。在整个 Rust 程序中思考所有权的好处就是能够防止并发编程中的错误。让我们做一个实验来展示通道和所有权如何协同工作以防止问题:我们将尝试在新创建的线程中,在通过通道发送 val 值之后再使用它。尝试编译示例 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}");
}
val 之后尝试使用它这里,我们尝试在通过 tx.send 将 val 发送到通道之后打印它。允许这样做是一个坏主意:一旦值被发送到另一个线程,那个线程可能会在我们再次使用该值之前修改或丢弃它。其他线程的修改可能会由于数据不一致或不存在而导致错误或意外结果。然而,如果我们尝试编译示例 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:27
|
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 函数获取其参数的所有权,当值被移动后,接收端就获取了它的所有权。这阻止了我们在发送后意外地再次使用该值;所有权系统会检查一切是否正确。
发送多个值
示例 16-8 中的代码可以编译和运行,但它没有清楚地展示两个独立的线程正在通过通道互相通信。
在示例 16-10 中,我们做了一些修改来证明示例 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}");
}
}
这次,新创建的线程有一个字符串 vector,我们想将它们发送到主线程。我们遍历它们,逐个发送,并通过调用 thread::sleep 函数并传入一秒的 Duration 值来在每次发送之间暂停。
在主线程中,我们不再显式调用 recv 函数:而是将 rx 当作迭代器使用。对于每个接收到的值,我们将其打印出来。当通道关闭时,迭代将结束。
运行示例 16-10 中的代码时,你应该会看到以下输出,每行之间有一秒的停顿:
Got: hi
Got: from
Got: the
Got: thread
因为主线程的 for 循环中没有任何暂停或延迟的代码,所以我们可以看出主线程是在等待从新创建的线程接收值。
创建多个生产者
之前我们提到 mpsc 是 multiple producer, single consumer 的缩写。让我们使用 mpsc 来扩展示例 16-10 中的代码,创建多个线程,它们都向同一个接收端发送值。我们可以通过克隆发送器来实现,如示例 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),它是共享内存中最常见的并发原语之一。
使用互斥器控制访问
互斥器(mutex)是 mutual exclusion(互斥)的缩写,即互斥器在任意时刻只允许一个线程访问某些数据。要访问互斥器中的数据,线程必须首先发出信号表明它想要访问,即请求获取互斥器的锁(lock)。锁是互斥器的一部分,是一种数据结构,用于跟踪当前谁拥有数据的独占访问权。因此,互斥器被描述为通过锁系统来守护(guarding)其持有的数据。
互斥器以难以使用而闻名,因为你必须记住两条规则:
- 在使用数据之前,必须先尝试获取锁。
- 当你使用完互斥器守护的数据后,必须解锁数据,以便其他线程可以获取锁。
用一个现实世界的比喻来理解互斥器:想象一场只有一个麦克风的会议小组讨论。在小组成员发言之前,他们必须请求或示意想要使用麦克风。当他们拿到麦克风后,可以想说多久就说多久,然后将麦克风交给下一位请求发言的小组成员。如果一位小组成员在发言结束后忘记交出麦克风,其他人就无法发言了。如果共享麦克风的管理出了问题,小组讨论就无法按计划进行!
互斥器的管理可能极其复杂,这也是为什么很多人热衷于使用通道的原因。然而,得益于 Rust 的类型系统和所有权规则,你不可能在加锁和解锁上犯错。
Mutex<T> 的 API
作为如何使用互斥器的示例,让我们先在单线程上下文中使用互斥器,如示例 16-12 所示。
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 6;
}
println!("m = {m:?}");
}
Mutex<T> 的 API与许多类型一样,我们使用关联函数 new 来创建一个 Mutex<T>。要访问互斥器内部的数据,我们使用 lock 方法来获取锁。这个调用会阻塞当前线程,使其在轮到我们持有锁之前无法做任何工作。
如果持有锁的另一个线程发生了 panic,lock 调用就会失败。在这种情况下,没有人能够再获取锁,所以我们选择了 unwrap,让当前线程在遇到这种情况时也 panic。
获取锁之后,我们可以将返回值(在这里命名为 num)当作内部数据的可变引用来使用。类型系统确保我们在使用 m 中的值之前获取锁。m 的类型是 Mutex<i32> 而不是 i32,所以我们必须调用 lock 才能使用 i32 值。我们不会忘记这一点,因为类型系统不会允许我们以其他方式访问内部的 i32。
lock 调用返回一个名为 MutexGuard 的类型,它被包装在一个 LockResult 中,我们通过调用 unwrap 来处理它。MutexGuard 类型实现了 Deref,指向内部数据;它还实现了 Drop,当 MutexGuard 离开作用域时会自动释放锁,这发生在内部作用域的末尾。因此,我们不会有忘记释放锁而阻塞其他线程使用互斥器的风险,因为锁的释放是自动发生的。
释放锁之后,我们可以打印互斥器的值,可以看到我们成功地将内部的 i32 值改为了 6。
共享 Mutex<T> 的访问
现在让我们尝试使用 Mutex<T> 在多个线程之间共享一个值。我们将启动 10 个线程,让每个线程将计数器的值加 1,这样计数器就会从 0 变为 10。示例 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());
}
Mutex<T> 守护的计数器我们创建了一个 counter 变量来在 Mutex<T> 中存放一个 i32 值,就像示例 16-12 中那样。接着,我们通过遍历一个数字范围来创建 10 个线程。我们使用 thread::spawn 并给所有线程传入相同的闭包:将计数器移入线程,通过调用 lock 方法获取 Mutex<T> 上的锁,然后将互斥器中的值加 1。当线程执行完闭包后,num 会离开作用域并释放锁,这样另一个线程就可以获取它了。
在主线程中,我们收集了所有的 join 句柄。然后,就像示例 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 `std::sync::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> 来创建引用计数值,从而让一个值拥有多个所有者。让我们在这里做同样的事情,看看会发生什么。我们将在示例 16-14 中用 Rc<T> 包装 Mutex<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());
}
Rc<T> 来允许多个线程拥有 Mutex<T>再次编译,我们得到了……不同的错误!编译器教会了我们很多:
$ cargo run
Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<std::sync::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<std::sync::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<std::sync::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`
--> /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/std/src/thread/mod.rs:723:1
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>>` cannot be sent between threads safely(Rc<Mutex<i32>> 不能在线程间安全地发送)。编译器还告诉了我们原因:the trait `Send` is not implemented for `Rc<Mutex<i32>>`(Rc<Mutex<i32>> 没有实现 Send trait)。我们将在下一节讨论 Send:它是确保我们在线程中使用的类型适用于并发场景的 trait 之一。
不幸的是,Rc<T> 在跨线程共享时并不安全。当 Rc<T> 管理引用计数时,它在每次调用 clone 时增加计数,在每个克隆被丢弃时减少计数。但它没有使用任何并发原语来确保计数的修改不会被另一个线程打断。这可能导致计数错误——这种微妙的 bug 可能进而导致内存泄漏或值在我们使用完之前就被丢弃。我们需要的是一个与 Rc<T> 完全相同,但以线程安全的方式修改引用计数的类型。
使用 Arc<T> 进行原子引用计数
幸运的是,Arc<T> 正是一个类似于 Rc<T> 但可以安全地用于并发场景的类型。其中的 a 代表原子(atomic),意味着它是一个原子引用计数(atomically reference-counted)类型。原子类型是一种额外的并发原语,我们不会在这里详细介绍:请参阅标准库文档中的 std::sync::atomic 以了解更多细节。此时你只需要知道,原子类型的工作方式类似于基本类型,但可以安全地在线程间共享。
你可能会想,为什么不是所有基本类型都是原子的,为什么标准库类型不默认使用 Arc<T> 呢?原因在于线程安全会带来性能开销,而你只想在确实需要时才付出这个代价。如果你只是在单线程中对值进行操作,不需要强制执行原子类型提供的保证,代码可以运行得更快。
让我们回到之前的示例:Arc<T> 和 Rc<T> 拥有相同的 API,所以我们只需修改 use 行、new 调用和 clone 调用即可修复程序。示例 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());
}
Arc<T> 包装 Mutex<T> 以便在多个线程间共享所有权这段代码会打印如下内容:
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> 内部的内容。
另一个值得注意的细节是,Rust 无法保护你免受使用 Mutex<T> 时的所有逻辑错误。回忆一下第 15 章,使用 Rc<T> 存在创建循环引用的风险,即两个 Rc<T> 值相互引用,从而导致内存泄漏。类似地,Mutex<T> 也存在创建死锁(deadlock)的风险。当一个操作需要锁定两个资源,而两个线程各自持有其中一个锁时,就会导致它们永远互相等待。如果你对死锁感兴趣,可以尝试编写一个会产生死锁的 Rust 程序;然后研究任何语言中互斥器的死锁缓解策略,并尝试在 Rust 中实现它们。标准库中 Mutex<T> 和 MutexGuard 的 API 文档提供了有用的信息。
我们将以讨论 Send 和 Sync trait 以及如何将它们用于自定义类型来结束本章。
使用 Send 和 Sync 的可扩展并发
使用 Send 和 Sync 实现可扩展的并发
有趣的是,本章到目前为止讨论的几乎所有并发特性都属于标准库的一部分,而非语言本身。处理并发的方式并不局限于语言或标准库;你完全可以编写自己的并发功能,或者使用他人编写的并发功能。
然而,有两个并发概念是内嵌在语言中而非标准库中的:std::marker 中的 Send 和 Sync trait。
在线程间转移所有权
Send 标记 trait 表明实现了 Send 的类型的值的所有权可以在线程间转移。几乎所有的 Rust 类型都实现了 Send,但也有一些例外,包括 Rc<T>:它不能实现 Send,因为如果你克隆了一个 Rc<T> 值并尝试将克隆的所有权转移到另一个线程,两个线程可能会同时更新引用计数。因此,Rc<T> 被设计为用于单线程场景,在这种场景下你不需要付出线程安全的性能开销。
所以,Rust 的类型系统和 trait 约束确保了你永远不会意外地将 Rc<T> 值不安全地跨线程传递。当我们在示例 16-14 中尝试这样做时,得到了错误 the trait `Send` is not implemented for `Rc<Mutex<i32>>`。当我们切换到实现了 Send 的 Arc<T> 后,代码就能编译通过了。
任何完全由 Send 类型组成的类型也会被自动标记为 Send。几乎所有原始类型都是 Send 的,除了裸指针(raw pointer),我们将在第 20 章讨论它。
从多个线程访问
Sync 标记 trait 表明实现了 Sync 的类型可以安全地被多个线程引用。换句话说,对于任意类型 T,如果 &T(T 的不可变引用)实现了 Send,那么 T 就实现了 Sync,这意味着该引用可以安全地发送到另一个线程。与 Send 类似,所有原始类型都实现了 Sync,完全由实现了 Sync 的类型组成的类型也自动实现 Sync。
智能指针 Rc<T> 同样没有实现 Sync,原因与它没有实现 Send 相同。RefCell<T> 类型(我们在第 15 章讨论过)以及相关的 Cell<T> 系列类型也没有实现 Sync。RefCell<T> 在运行时执行的借用检查不是线程安全的。智能指针 Mutex<T> 实现了 Sync,可以用于在多个线程间共享访问,正如你在“共享访问 Mutex<T>”中所看到的那样。
手动实现 Send 和 Sync 是不安全的
因为完全由实现了 Send 和 Sync trait 的类型组成的类型也会自动实现 Send 和 Sync,所以我们不需要手动实现这些 trait。作为标记 trait,它们甚至没有任何需要实现的方法。它们只是用于强制保证与并发相关的不变性。
手动实现这些 trait 涉及编写不安全的 Rust 代码。我们将在第 20 章讨论如何使用不安全的 Rust 代码;目前重要的是,构建不由 Send 和 Sync 部分组成的新并发类型需要仔细思考以维护安全保证。“Rustonomicon” 中有更多关于这些保证以及如何维护它们的信息。
总结
这不是你在本书中最后一次看到并发内容:下一章将专注于异步编程,而第 21 章的项目将在比这里讨论的小示例更加实际的场景中使用本章的概念。
如前所述,由于 Rust 处理并发的方式中只有很少一部分属于语言本身,许多并发解决方案都以 crate 的形式实现。它们的发展速度比标准库更快,所以请务必在网上搜索当前最先进的 crate,以便在多线程场景中使用。
Rust 标准库提供了用于消息传递的通道,以及像 Mutex<T> 和 Arc<T> 这样可以安全地在并发环境中使用的智能指针类型。类型系统和借用检查器确保使用这些方案的代码不会出现数据竞争或无效引用。一旦你的代码能够编译通过,你就可以放心它能在多线程环境下正常运行,而不会出现其他语言中常见的那些难以追踪的 bug。并发编程不再是一个令人畏惧的概念:放手去让你的程序并发运行吧,无所畏惧!
异步编程基础:Async、Await、Future 和 Stream
我们要求计算机执行的许多操作都需要一定时间才能完成。如果能在等待这些长时间运行的进程完成时做些别的事情,那就太好了。现代计算机提供了两种同时处理多个操作的技术:并行(parallelism)和并发(concurrency)。然而,我们的程序逻辑大多是以线性方式编写的。我们希望能够指定程序应执行的操作以及函数可以暂停、让程序其他部分运行的时机,而无需预先精确指定每段代码的运行顺序和方式。异步编程(asynchronous programming)就是这样一种抽象,它让我们能够用潜在的暂停点和最终结果来表达代码,并为我们处理协调的细节。
本章在第 16 章使用线程实现并行和并发的基础上,引入了另一种编写代码的方式:Rust 的 future、stream,以及 async 和 await 语法。这些特性让我们能够表达操作如何异步执行,而第三方 crate 则实现了异步运行时——管理和协调异步操作执行的代码。
我们来看一个例子。假设你正在导出一个家庭聚会的视频,这个操作可能需要几分钟到几小时不等。视频导出会尽可能多地占用 CPU 和 GPU 资源。如果你只有一个 CPU 核心,而且操作系统在导出完成之前不会暂停它——也就是说,它_同步地_执行导出——那么在任务运行期间你就无法在电脑上做任何其他事情。这将是一种非常令人沮丧的体验。幸运的是,你的计算机操作系统能够(而且确实会)以不可见的方式频繁中断导出操作,让你同时完成其他工作。
现在假设你正在下载别人分享的视频,这也需要一些时间,但不会占用太多 CPU 时间。在这种情况下,CPU 需要等待数据从网络到达。虽然数据开始到达后你就可以开始读取,但所有数据全部到达可能还需要一些时间。即使数据全部到达,如果视频很大,加载所有数据也至少需要一两秒。这听起来可能不算什么,但对于每秒能执行数十亿次操作的现代处理器来说,这是非常漫长的时间。同样,你的操作系统会以不可见的方式中断你的程序,让 CPU 在等待网络调用完成时执行其他工作。
视频导出是 CPU 密集型(CPU-bound)或_计算密集型_(compute-bound)操作的一个例子。它受限于 CPU 或 GPU 的数据处理速度,以及能分配给该操作多少处理能力。视频下载则是 I/O 密集型(I/O-bound)操作的一个例子,因为它受限于计算机_输入和输出_的速度——数据只能以网络传输的速度到达。
在这两个例子中,操作系统的不可见中断提供了一种并发形式。不过,这种并发只发生在整个程序的层面:操作系统中断一个程序来让其他程序完成工作。在很多情况下,由于我们对自己的程序比操作系统理解得更细致,我们能发现操作系统看不到的并发机会。
例如,如果我们正在构建一个管理文件下载的工具,应该能够让程序在启动一个下载时不会锁住 UI,用户也应该能够同时启动多个下载。然而,许多用于网络交互的操作系统 API 是_阻塞的_(blocking)——也就是说,它们会阻塞程序的执行,直到处理的数据完全就绪。
注意:如果你仔细想想,_大多数_函数调用其实都是这样工作的。不过,“阻塞“这个术语通常专门用于那些与文件、网络或计算机上其他资源交互的函数调用,因为在这些场景下,程序确实能从_非阻塞_操作中获益。
我们可以通过为每个文件下载生成一个专用线程来避免阻塞主线程。然而,这些线程所使用的系统资源开销最终会成为问题。如果调用本身就不阻塞会更好——我们可以定义一系列希望程序完成的任务,然后让运行时选择最佳的顺序和方式来执行它们。
这正是 Rust 的 async(_异步_的缩写)抽象所提供的能力。在本章中,你将学习 async 的方方面面,我们将涵盖以下主题:
- 如何使用 Rust 的
async和await语法,以及如何通过运行时执行异步函数 - 如何使用异步模型来解决我们在第 16 章中遇到的一些相同挑战
- 多线程和异步如何提供互补的解决方案,在许多情况下可以结合使用
不过,在我们了解异步在实践中如何工作之前,需要先简短地讨论一下并行和并发之间的区别。
并行与并发
到目前为止,我们基本上把并行和并发当作可以互换的概念。现在我们需要更精确地区分它们,因为随着我们开始工作,这些差异会变得重要。
考虑一下团队在软件项目中分配工作的不同方式。你可以给一个成员分配多个任务,也可以给每个成员分配一个任务,或者混合使用这两种方式。
当一个人在多个不同任务之间切换,在任何一个任务完成之前就开始处理其他任务,这就是_并发_。一种实现并发的方式类似于在电脑上同时检出两个不同的项目,当你对一个项目感到厌倦或卡住时,就切换到另一个项目。你只是一个人,所以不可能在完全相同的时刻同时推进两个任务,但你可以通过在它们之间切换来多任务处理,一次推进一个任务(见图 17-1)。
当团队将一组任务分配给每个成员各自独立完成一个任务时,这就是_并行_。团队中的每个人都可以在完全相同的时刻取得进展(见图 17-2)。
在这两种工作流中,你可能都需要在不同任务之间进行协调。也许你以为分配给某个人的任务与其他人的工作完全独立,但实际上它需要团队中另一个人先完成他们的任务。有些工作可以并行完成,但有些实际上是_串行的_(serial):只能按顺序一个接一个地进行,如图 17-3 所示。
<img src=“img/trpl17-03.svg” class=“center” alt=“A diagram with stacked boxes labeled Task A and Task B, with diamonds in them representing subtasks. In Task A, arrows point from A1 to A2, from A2 to a pair of thick vertical lines like a “pause” symbol, and from that symbol to A3. In task B, arrows point from B1 to B2, from B2 to B3, from B3 to A3, and from B3 to B4.“ />
同样,你可能会发现自己的某个任务依赖于自己的另一个任务。这样你的并发工作也变成了串行的。
并行和并发也可以相互交叉。如果你得知一位同事在等你完成你的某个任务后才能继续,你可能会把所有精力集中在那个任务上来“解除“同事的阻塞。这时你和你的同事不再能并行工作,而你自己也不再能并发地处理自己的多个任务了。
同样的基本动态也适用于软件和硬件。在只有单个 CPU 核心的机器上,CPU 一次只能执行一个操作,但它仍然可以并发工作。通过使用线程、进程和 async 等工具,计算机可以暂停一个活动并切换到其他活动,最终再切换回最初的活动。在拥有多个 CPU 核心的机器上,它还可以并行工作。一个核心可以执行一个任务,而另一个核心执行一个完全无关的任务,这些操作实际上是在同一时刻发生的。
在 Rust 中运行异步代码通常是以并发方式进行的。根据硬件、操作系统和我们使用的异步运行时(稍后会详细介绍异步运行时),这种并发在底层也可能使用并行。
现在,让我们深入了解 Rust 中的异步编程实际上是如何工作的。
Future 与异步语法
Future 与异步语法
Rust 异步编程的关键要素是 future 以及 Rust 的 async 和 await 关键字。
future 是一个现在可能还没有准备好、但将来某个时刻会准备好的值。(这个概念在许多语言中都有出现,有时使用其他名称,如 task 或 promise。)Rust 提供了 Future trait 作为构建基础,使得不同的异步操作可以用不同的数据结构来实现,但共享统一的接口。在 Rust 中,future 是实现了 Future trait 的类型。每个 future 都持有自己的进度信息以及“准备好“的含义。
你可以将 async 关键字应用于代码块和函数,以指定它们可以被中断和恢复。在异步代码块或异步函数内部,你可以使用 await 关键字来 等待一个 future(即等待它变为就绪状态)。在异步代码块或函数中,每个等待 future 的位置都是该代码块或函数可能暂停和恢复的潜在点。检查一个 future 的值是否已经可用的过程称为 轮询(polling)。
其他一些语言,如 C# 和 JavaScript,也使用 async 和 await 关键字进行异步编程。如果你熟悉这些语言,你可能会注意到 Rust 在语法处理上有一些显著的不同。这是有充分理由的,我们后面会看到!
在编写异步 Rust 代码时,我们大部分时间都在使用 async 和 await 关键字。Rust 会将它们编译为使用 Future trait 的等效代码,就像它将 for 循环编译为使用 Iterator trait 的等效代码一样。因为 Rust 提供了 Future trait,你也可以在需要时为自己的数据类型实现它。我们在本章中看到的许多函数都返回带有自己 Future 实现的类型。我们将在本章末尾回到该 trait 的定义,并深入探讨它的工作原理,但目前这些细节已经足够让我们继续前进了。
这些内容可能感觉有点抽象,所以让我们来编写第一个异步程序:一个小型网页抓取器。我们将从命令行传入两个 URL,并发地获取它们,然后返回先完成的那个的结果。这个示例会有不少新语法,但别担心——我们会在过程中解释你需要知道的一切。
我们的第一个异步程序
为了让本章的重点放在学习异步上,而不是纠结于生态系统的各个部分,我们创建了 trpl crate(trpl 是 “The Rust Programming Language” 的缩写)。它重新导出了你需要的所有类型、trait 和函数,主要来自 futures 和 tokio 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 作为参数,向该 URL 发起请求,并返回 <title> 元素的文本(见示例 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| title.inner_html())
}
首先,我们定义了一个名为 page_title 的函数,并用 async 关键字标记它。然后我们使用 trpl::get 函数获取传入的 URL,并使用 await 关键字等待响应。为了获取响应的文本内容,我们调用其 text 方法,并再次使用 await 关键字等待它。这两个步骤都是异步的。对于 get 函数,我们需要等待服务器发回响应的第一部分,其中包括 HTTP 头、cookie 等,这些可以与响应体分开传送。特别是当响应体非常大时,接收全部内容可能需要一些时间。因为我们必须等待 整个 响应到达,所以 text 方法也是异步的。
我们必须显式地等待这两个 future,因为 Rust 中的 future 是 惰性的:在你使用 await 关键字要求它们执行之前,它们不会做任何事情。(实际上,如果你不使用一个 future,Rust 会显示编译器警告。)这可能会让你想起第 13 章“使用迭代器处理元素系列”一节中关于迭代器的讨论。迭代器在你调用它们的 next 方法之前什么也不做——无论是直接调用还是通过使用 for 循环或底层调用 next 的 map 等方法。同样,future 在你显式要求之前也什么都不做。这种惰性特性允许 Rust 在实际需要之前避免运行异步代码。
注意:这与我们在第 16 章“使用 spawn 创建新线程”一节中使用
thread::spawn时看到的行为不同,在那里我们传递给另一个线程的闭包会立即开始运行。这也与许多其他语言处理异步的方式不同。但对于 Rust 来说,能够提供其性能保证是很重要的,就像迭代器一样。
一旦我们有了 response_text,就可以使用 Html::parse 将其解析为 Html 类型的实例。我们现在拥有的不再是原始字符串,而是一个可以用来将 HTML 作为更丰富的数据结构进行操作的数据类型。特别是,我们可以使用 select_first 方法来查找给定 CSS 选择器的第一个实例。通过传入字符串 "title",我们将获取文档中的第一个 <title> 元素(如果有的话)。因为可能没有任何匹配的元素,select_first 返回一个 Option<ElementRef>。最后,我们使用 Option::map 方法,它让我们在 Option 中有值时对其进行操作,没有值时则什么也不做。(我们也可以在这里使用 match 表达式,但 map 更符合惯用写法。)在我们提供给 map 的函数体中,我们对 title 调用 inner_html 来获取其内容,这是一个 String。最终,我们得到一个 Option<String>。
注意 Rust 的 await 关键字放在你要等待的表达式 之后,而不是之前。也就是说,它是一个 后缀 关键字。如果你在其他语言中使用过 async,这可能与你习惯的不同,但在 Rust 中这使得方法链式调用更加方便。因此,我们可以将 page_title 的函数体改为将 trpl::get 和 text 函数调用用 await 链接在一起,如示例 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| title.inner_html())
}
await 关键字进行链式调用这样,我们就成功编写了第一个异步函数!在我们向 main 中添加代码来调用它之前,让我们再多谈谈我们写了什么以及它意味着什么。
当 Rust 看到一个用 async 关键字标记的 代码块 时,它会将其编译为一个实现了 Future trait 的唯一匿名数据类型。当 Rust 看到一个用 async 标记的 函数 时,它会将其编译为一个非异步函数,该函数的函数体是一个异步代码块。异步函数的返回类型是编译器为该异步代码块创建的匿名数据类型。
因此,编写 async fn 等同于编写一个返回返回类型的 future 的函数。对编译器来说,示例 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 章“trait 作为参数”一节中讨论过的
impl Trait语法。 - 返回值实现了
Futuretrait,其关联类型为Output。注意Output类型是Option<String>,与page_title的async fn版本的原始返回类型相同。 - 原始函数体中调用的所有代码都被包装在一个
async move块中。记住,代码块是表达式。整个代码块就是函数返回的表达式。 - 这个异步代码块产生一个类型为
Option<String>的值,如上所述。该值与返回类型中的Output类型匹配。这与你见过的其他代码块一样。 - 新的函数体是一个
async move块,因为它使用了url参数。(我们将在本章后面更多地讨论async与async move的区别。)
现在我们可以在 main 中调用 page_title 了。
使用运行时执行异步函数
首先,我们将获取单个页面的标题,如示例 17-3 所示。不过,这段代码还无法编译。
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| title.inner_html())
}
main 中调用 page_title 函数,使用用户提供的参数我们遵循了第 12 章“接受命令行参数”一节中获取命令行参数的相同模式。然后我们将 URL 参数传递给 page_title 并等待结果。因为 future 产生的值是一个 Option<String>,我们使用 match 表达式来打印不同的消息,以处理页面是否有 <title> 的情况。
我们唯一能使用 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 的原因是异步代码需要一个 运行时(runtime):一个管理异步代码执行细节的 Rust crate。程序的 main 函数可以 初始化 一个运行时,但它本身并不是一个运行时。(我们稍后会看到更多关于为什么如此的原因。)每个执行异步代码的 Rust 程序都至少有一个设置运行时来执行 future 的地方。
大多数支持异步的语言都捆绑了一个运行时,但 Rust 没有。相反,有许多不同的异步运行时可用,每个都做出了适合其目标用例的不同权衡。例如,一个拥有多个 CPU 核心和大量 RAM 的高吞吐量 Web 服务器与一个只有单核、少量 RAM 且没有堆分配能力的微控制器有着截然不同的需求。提供这些运行时的 crate 通常还提供常见功能的异步版本,如文件或网络 I/O。
在这里以及本章的其余部分,我们将使用 trpl crate 中的 block_on 函数,它接受一个 future 作为参数,并阻塞当前线程直到该 future 运行完成。在底层,调用 block_on 会使用 tokio crate 设置一个运行时来运行传入的 future(trpl crate 的 block_on 行为与其他运行时 crate 的 block_on 函数类似)。一旦 future 完成,block_on 就返回该 future 产生的值。
我们可以将 page_title 返回的 future 直接传递给 block_on,然后在完成后对结果 Option<String> 进行匹配,就像我们在示例 17-3 中尝试做的那样。但是,对于本章中的大多数示例(以及现实世界中的大多数异步代码),我们不会只调用一个异步函数,所以我们将传入一个 async 块,并显式地等待 page_title 调用的结果,如示例 17-4 所示。
extern crate trpl; // required for mdbook test
use trpl::Html;
fn main() {
let args: Vec<String> = std::env::args().collect();
trpl::block_on(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| title.inner_html())
}
trpl::block_on 等待一个异步代码块当我们运行这段代码时,我们得到了最初期望的行为:
$ 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
终于——我们有了一些可以工作的异步代码!但在我们添加代码让两个网站互相竞速之前,让我们简要回顾一下 future 的工作原理。
每个 等待点(await point)——即代码中使用 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 编译器会自动为异步代码创建和管理状态机数据结构。围绕数据结构的正常借用和所有权规则仍然适用,而且编译器也会帮我们检查这些规则并提供有用的错误信息。我们将在本章后面处理其中的一些情况。
最终,必须有某个东西来执行这个状态机,而这个东西就是运行时。(这就是为什么你在查阅运行时相关资料时可能会遇到 执行器(executor) 这个术语:执行器是运行时中负责执行异步代码的部分。)
现在你可以理解为什么编译器在示例 17-3 中阻止我们将 main 本身变成异步函数了。如果 main 是一个异步函数,就需要其他东西来管理 main 返回的 future 的状态机,但 main 是程序的起点!相反,我们在 main 中调用 trpl::block_on 函数来设置运行时,并运行 async 块返回的 future 直到它完成。
注意:一些运行时提供了宏,使你 可以 编写异步的
main函数。这些宏将async fn main() { ... }重写为普通的fn main,其作用与我们在示例 17-4 中手动做的一样:调用一个函数来运行 future 直到完成,就像trpl::block_on那样。
现在让我们把这些部分组合起来,看看如何编写并发代码。
让两个 URL 并发竞速
在示例 17-5 中,我们用从命令行传入的两个不同 URL 调用 page_title,通过选择先完成的 future 来让它们竞速。
extern crate trpl; // required for mdbook test
use trpl::{Either, Html};
fn main() {
let args: Vec<String> = std::env::args().collect();
trpl::block_on(async {
let title_fut_1 = page_title(&args[1]);
let title_fut_2 = page_title(&args[2]);
let (url, maybe_title) =
match trpl::select(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 was: '{title}'"),
None => println!("It had no title."),
}
})
}
async fn page_title(url: &str) -> (&str, Option<String>) {
let response_text = trpl::get(url).await.text().await;
let title = Html::parse(&response_text)
.select_first("title")
.map(|title| title.inner_html());
(url, title)
}
page_title 获取两个 URL,看哪个先返回我们首先为每个用户提供的 URL 调用 page_title。我们将得到的 future 保存为 title_fut_1 和 title_fut_2。记住,这些 future 还不会做任何事情,因为 future 是惰性的,我们还没有等待它们。然后我们将这些 future 传递给 trpl::select,它返回一个值来指示传入的哪个 future 先完成。
注意:在底层,
trpl::select构建在futurescrate 中定义的更通用的select函数之上。futurescrate 的select函数可以做很多trpl::select函数做不到的事情,但它也有一些额外的复杂性,我们现在可以跳过。
两个 future 都可以合理地“获胜“,所以返回 Result 没有意义。相反,trpl::select 返回一个我们之前没见过的类型:trpl::Either。Either 类型有点类似于 Result,因为它也有两个变体。但与 Result 不同的是,Either 中没有内置成功或失败的概念。相反,它使用 Left 和 Right 来表示“一个或另一个“:
#![allow(unused)]
fn main() {
enum Either<A, B> {
Left(A),
Right(B),
}
}
select 函数在第一个参数获胜时返回包含该 future 输出的 Left,在 第二个 参数获胜时返回包含第二个 future 输出的 Right。这与调用函数时参数出现的顺序一致:第一个参数在第二个参数的左边。
我们还更新了 page_title 以返回传入的相同 URL。这样,如果先返回的页面没有可以解析的 <title>,我们仍然可以打印一条有意义的消息。有了这些信息,我们最后更新 println! 输出,以指示哪个 URL 先完成,以及该 URL 对应网页的 <title>(如果有的话)是什么。
你现在已经构建了一个小型可工作的网页抓取器!选择几个 URL 并运行这个命令行工具。你可能会发现某些网站始终比其他网站快,而在其他情况下,哪个网站更快会因运行而异。更重要的是,你已经学会了使用 future 的基础知识,现在我们可以更深入地探索异步能做什么了。
使用异步实现并发
使用异步实现并发
在本节中,我们将把异步应用于第十六章中使用线程处理过的一些并发挑战。因为我们已经在那里讨论了许多关键概念,所以本节将重点关注线程和 future 之间的不同之处。
在许多情况下,使用异步进行并发编程的 API 与使用线程的 API 非常相似。而在另一些情况下,它们最终会有很大不同。即使线程和异步之间的 API 看起来 相似,它们通常也有不同的行为——而且几乎总是有不同的性能特征。
使用 spawn_task 创建新任务
我们在第十六章“使用 spawn 创建新线程”一节中处理的第一个操作是在两个独立的线程上进行计数。让我们使用异步来做同样的事情。trpl crate 提供了一个 spawn_task 函数,它看起来与 thread::spawn API 非常相似,还有一个 sleep 函数,它是 thread::sleep API 的异步版本。我们可以将它们结合使用来实现计数示例,如示例 17-6 所示。
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(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::block_on 设置 main 函数,这样我们的顶层函数就可以是异步的。
注意:从本章这里开始,每个示例都会在
main中包含完全相同的trpl::block_on包装代码,所以我们通常会像省略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!
这个版本在主异步代码块中的 for 循环结束后就会停止,因为 spawn_task 生成的任务会在 main 函数结束时被关闭。如果你希望它一直运行到任务完成,你需要使用 join 句柄来等待第一个任务完成。对于线程,我们使用 join 方法来“阻塞“直到线程运行完毕。在示例 17-7 中,我们可以使用 await 来做同样的事情,因为任务句柄本身就是一个 future。它的 Output 类型是 Result,所以我们在 await 之后还要对它进行 unwrap。
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(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();
});
}
await 和 join 句柄来将任务运行到完成这个更新后的版本会运行到 两个 循环都结束:
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!
到目前为止,异步和线程看起来给出了类似的结果,只是语法不同:使用 await 而不是在 join 句柄上调用 join,以及 await sleep 调用。
更大的区别在于我们不需要生成另一个操作系统线程来做这件事。实际上,我们甚至不需要在这里生成一个任务。因为异步代码块会编译为匿名 future,我们可以将每个循环放在一个异步代码块中,然后让运行时使用 trpl::join 函数将它们都运行到完成。
在第十六章“等待所有线程完成”一节中,我们展示了如何在调用 std::thread::spawn 返回的 JoinHandle 类型上使用 join 方法。trpl::join 函数与之类似,但用于 future。当你给它两个 future 时,它会产生一个新的 future,其输出是一个包含你传入的每个 future 的输出的元组,在它们 都 完成之后。因此,在示例 17-8 中,我们使用 trpl::join 来等待 fut1 和 fut2 都完成。我们 不 await fut1 和 fut2,而是 await trpl::join 产生的新 future。我们忽略输出,因为它只是一个包含两个单元值的元组。
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(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;
});
}
trpl::join 来 await 两个匿名 future当我们运行这段代码时,可以看到两个 future 都运行到了完成:
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!
现在,你每次都会看到完全相同的顺序,这与我们在线程和示例 17-7 中使用 trpl::spawn_task 时看到的非常不同。这是因为 trpl::join 函数是 公平的,意味着它会同等频率地检查每个 future,在它们之间交替执行,如果另一个已经就绪就不会让其中一个抢先执行。对于线程,操作系统决定检查哪个线程以及让它运行多长时间。对于异步 Rust,运行时决定检查哪个任务。(实际上,细节会更复杂,因为异步运行时可能在底层使用操作系统线程作为管理并发的一部分,所以保证公平性对运行时来说可能需要更多工作——但这仍然是可能的!)运行时不必为任何给定操作保证公平性,它们通常提供不同的 API 来让你选择是否需要公平性。
尝试这些 await future 的变体,看看它们会做什么:
- 从其中一个或两个循环中移除异步代码块。
- 在定义每个异步代码块后立即 await 它。
- 只将第一个循环包装在异步代码块中,并在第二个循环的主体之后 await 结果 future。
作为额外的挑战,看看你能否在运行代码 之前 弄清楚每种情况下的输出是什么!
使用消息传递在两个任务之间发送数据
在 future 之间共享数据也会很熟悉:我们将再次使用消息传递,但这次使用异步版本的类型和函数。我们将采取与第十六章“使用消息传递在线程间传输数据”一节中略有不同的路径,以说明基于线程和基于 future 的并发之间的一些关键区别。在示例 17-9 中,我们将从只有一个异步代码块开始——不 像我们生成单独线程那样生成单独的任务。
extern crate trpl; // required for mdbook test
fn main() {
trpl::block_on(async {
let (tx, mut rx) = trpl::channel();
let val = String::from("hi");
tx.send(val).unwrap();
let received = rx.recv().await.unwrap();
println!("received '{received}'");
});
}
tx 和 rx这里我们使用 trpl::channel,这是我们在第十六章中与线程一起使用的多生产者、单消费者通道 API 的异步版本。异步版本的 API 与基于线程的版本只有一点不同:它使用可变的而非不可变的接收者 rx,并且它的 recv 方法产生一个需要 await 的 future,而不是直接产生值。现在我们可以从发送者向接收者发送消息了。注意,我们不需要生成单独的线程甚至任务;我们只需要 await rx.recv 调用即可。
std::mpsc::channel 中的同步 Receiver::recv 方法会阻塞直到收到消息。trpl::Receiver::recv 方法则不会,因为它是异步的。它不会阻塞,而是将控制权交还给运行时,直到收到消息或通道的发送端关闭。相比之下,我们不 await send 调用,因为它不会阻塞。它不需要阻塞,因为我们发送消息的通道是无界的。
注意:因为所有这些异步代码都运行在
trpl::block_on调用中的异步代码块里,其中的所有内容都可以避免阻塞。然而,外部 的代码会在block_on函数返回时阻塞。这正是trpl::block_on函数的意义所在:它让你 选择 在哪里阻塞某组异步代码,从而在哪里进行同步和异步代码之间的转换。
注意这个示例的两点。首先,消息会立即到达。其次,虽然我们在这里使用了 future,但还没有并发。列表中的所有内容都按顺序执行,就像没有涉及 future 一样。
让我们先解决第一个问题,发送一系列消息并在它们之间休眠,如示例 17-10 所示。
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(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}'");
}
});
}
await 休眠除了发送消息之外,我们还需要接收它们。在这种情况下,因为我们知道有多少条消息要来,我们可以手动调用 rx.recv().await 四次。但在现实世界中,我们通常会等待某个 未知 数量的消息,所以我们需要一直等待,直到确定没有更多消息为止。
在示例 16-10 中,我们使用 for 循环来处理从同步通道接收到的所有项。然而,Rust 目前还没有办法对 异步产生的 一系列项使用 for 循环,所以我们需要使用一种之前没见过的循环:while let 条件循环。这是我们在第六章“使用 if let 和 let...else 实现简洁控制流”一节中看到的 if let 结构的循环版本。只要它指定的模式继续匹配值,循环就会继续执行。
rx.recv 调用产生一个 future,我们对其进行 await。运行时会暂停该 future 直到它就绪。一旦消息到达,future 将解析为 Some(message),每次消息到达时都是如此。当通道关闭时,无论是否有消息到达过,future 将解析为 None,表示没有更多的值,因此我们应该停止轮询——也就是停止 await。
while let 循环将所有这些整合在一起。如果调用 rx.recv().await 的结果是 Some(message),我们就可以访问消息并在循环体中使用它,就像使用 if let 一样。如果结果是 None,循环就结束了。每次循环完成时,它都会再次到达 await 点,所以运行时会再次暂停它,直到另一条消息到达。
代码现在成功地发送和接收了所有消息。不幸的是,仍然有几个问题。首先,消息不是每隔半秒到达的。它们在程序启动 2 秒(2,000 毫秒)后一次性全部到达。其次,这个程序永远不会退出!相反,它会永远等待新消息。你需要使用 ctrl-C 来关闭它。
单个异步代码块中的代码按顺序执行
让我们先来看看为什么消息在完整延迟之后一次性全部到达,而不是在每条消息之间有延迟地到达。在给定的异步代码块中,await 关键字在代码中出现的顺序也是程序运行时它们被执行的顺序。
示例 17-10 中只有一个异步代码块,所以其中的所有内容都按顺序运行。仍然没有并发。所有的 tx.send 调用都会执行,中间穿插着所有的 trpl::sleep 调用及其关联的 await 点。只有在那之后,while let 循环才会开始处理 recv 调用上的任何 await 点。
为了获得我们想要的行为——即休眠延迟发生在每条消息之间——我们需要将 tx 和 rx 操作放在各自的异步代码块中,如示例 17-11 所示。然后运行时可以使用 trpl::join 分别执行它们,就像在示例 17-8 中一样。再次强调,我们 await 的是调用 trpl::join 的结果,而不是各个单独的 future。如果我们按顺序 await 各个 future,我们最终又会回到顺序流——这正是我们试图 避免 的。
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(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;
});
}
send 和 recv 分离到各自的 async 代码块中,并 await 这些代码块的 future使用示例 17-11 中更新后的代码,消息会每隔 500 毫秒打印一次,而不是在 2 秒后一股脑全部出现。
将所有权移入异步代码块
然而,程序仍然永远不会退出,因为 while let 循环与 trpl::join 的交互方式:
trpl::join返回的 future 只有在传入的 两个 future 都完成后才会完成。tx_futfuture 在发送完vals中的最后一条消息并完成休眠后就完成了。rx_futfuture 在while let循环结束之前不会完成。while let循环在 awaitrx.recv产生None之前不会结束。- await
rx.recv只有在通道的另一端关闭时才会返回None。 - 通道只有在我们调用
rx.close或发送端tx被丢弃时才会关闭。 - 我们没有在任何地方调用
rx.close,而tx在传递给trpl::block_on的最外层异步代码块结束之前不会被丢弃。 - 该代码块无法结束,因为它被
trpl::join的完成所阻塞,这又把我们带回了这个列表的顶部。
目前,发送消息的异步代码块只是 借用 了 tx,因为发送消息不需要所有权,但如果我们能将 tx 移动 到该异步代码块中,它就会在该代码块结束时被丢弃。在第十三章“捕获引用或移动所有权”一节中,你学习了如何在闭包中使用 move 关键字,而且正如第十六章“在线程中使用 move 闭包”一节中所讨论的,在使用线程时我们经常需要将数据移动到闭包中。同样的基本原理也适用于异步代码块,所以 move 关键字在异步代码块中的工作方式与在闭包中相同。
在示例 17-12 中,我们将用于发送消息的代码块从 async 改为 async move。
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(async {
let (tx, mut rx) = trpl::channel();
let tx_fut = async move {
// --snip--
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;
});
}
当我们运行 这个 版本的代码时,它会在最后一条消息发送和接收后优雅地关闭。接下来,让我们看看如果要从多个 future 发送数据需要做哪些改变。
使用 join! 宏连接多个 Future
这个异步通道也是一个多生产者通道,所以如果我们想从多个 future 发送消息,可以对 tx 调用 clone,如示例 17-13 所示。
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(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::join!(tx1_fut, tx_fut, rx_fut);
});
}
首先,我们在第一个异步代码块外部克隆 tx,创建 tx1。我们像之前对 tx 所做的那样将 tx1 移入该代码块。然后,稍后我们将原始的 tx 移入一个 新的 异步代码块,在那里以稍慢的延迟发送更多消息。我们碰巧将这个新的异步代码块放在接收消息的异步代码块之后,但放在它之前也同样可以。关键在于 future 被 await 的顺序,而不是它们被创建的顺序。
两个发送消息的异步代码块都需要是 async move 代码块,这样 tx 和 tx1 才会在这些代码块完成时被丢弃。否则,我们又会回到最初的无限循环中。
最后,我们从 trpl::join 切换到 trpl::join! 来处理额外的 future:join! 宏可以 await 任意数量的 future,只要我们在编译时知道 future 的数量。我们将在本章后面讨论如何 await 一个数量未知的 future 集合。
现在我们可以看到来自两个发送 future 的所有消息,因为发送 future 在发送后使用了略有不同的延迟,消息也以这些不同的间隔被接收:
received 'hi'
received 'more'
received 'from'
received 'the'
received 'messages'
received 'future'
received 'for'
received 'you'
我们已经探讨了如何使用消息传递在 future 之间发送数据、异步代码块中的代码如何按顺序运行、如何将所有权移入异步代码块,以及如何连接多个 future。接下来,让我们讨论如何以及为什么告诉运行时它可以切换到另一个任务。
处理任意数量的 Future
让出控制权给运行时
回忆一下“我们的第一个异步程序”一节中提到的,在每个 await 点,Rust 都会给运行时一个机会来暂停当前任务并切换到另一个任务(如果被等待的 future 尚未就绪)。反过来也是如此:Rust 只会在 await 点暂停异步块并将控制权交还给运行时。await 点之间的所有代码都是同步执行的。
这意味着,如果你在一个异步块中执行大量工作而没有 await 点,那么这个 future 将阻塞其他所有 future 的推进。你可能有时会听到这被称为一个 future 饿死(starving)了其他 future。在某些情况下,这可能不是什么大问题。但是,如果你正在进行某种昂贵的初始化或长时间运行的工作,或者你有一个会无限期持续执行某项任务的 future,你就需要考虑何时何地将控制权交还给运行时。
让我们模拟一个长时间运行的操作来说明饥饿问题,然后探讨如何解决它。示例 17-14 引入了一个 slow 函数。
extern crate trpl; // required for mdbook test
use std::{thread, time::Duration};
fn main() {
trpl::block_on(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");
}
thread::sleep 模拟慢操作这段代码使用 std::thread::sleep 而不是 trpl::sleep,这样调用 slow 就会阻塞当前线程若干毫秒。我们可以用 slow 来模拟那些既耗时又阻塞的真实操作。
在示例 17-15 中,我们使用 slow 来模拟在一对 future 中执行这类 CPU 密集型工作。
extern crate trpl; // required for mdbook test
use std::{thread, time::Duration};
fn main() {
trpl::block_on(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::select(a, b).await;
});
}
fn slow(name: &str, ms: u64) {
thread::sleep(Duration::from_millis(ms));
println!("'{name}' ran for {ms}ms");
}
slow 函数模拟慢操作每个 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.
和示例 17-5 中我们使用 trpl::select 竞争两个 URL 获取的 future 一样,select 仍然在 a 完成后就结束了。但两个 future 中对 slow 的调用之间并没有交替执行。a future 会一直执行它的所有工作,直到 trpl::sleep 调用被 await,然后 b future 才会执行它的所有工作,直到它自己的 trpl::sleep 调用被 await,最后 a future 完成。为了让两个 future 都能在各自的慢任务之间取得进展,我们需要 await 点来将控制权交还给运行时。这意味着我们需要一些可以 await 的东西!
我们在示例 17-15 中已经可以看到这种交接的发生:如果我们移除 a future 末尾的 trpl::sleep,它将在 b future 完全没有运行的情况下就完成了。让我们尝试使用 trpl::sleep 函数作为起点,让操作能够交替推进,如示例 17-16 所示。
extern crate trpl; // required for mdbook test
use std::{thread, time::Duration};
fn main() {
trpl::block_on(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::select(a, b).await;
});
}
fn slow(name: &str, ms: u64) {
thread::sleep(Duration::from_millis(ms));
println!("'{name}' ran for {ms}ms");
}
trpl::sleep 让操作交替推进我们在每次调用 slow 之间添加了带有 await 点的 trpl::sleep 调用。现在两个 future 的工作是交替进行的:
'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,但在那之后,每当其中一个 future 遇到 await 点时,它们就会来回切换。在这个例子中,我们在每次调用 slow 之后都这样做了,但我们可以按照任何对我们最有意义的方式来拆分工作。
不过,我们并不是真的想在这里 休眠:我们想尽可能快地推进。我们只需要将控制权交还给运行时。我们可以直接使用 trpl::yield_now 函数来做到这一点。在示例 17-17 中,我们将所有的 trpl::sleep 调用替换为 trpl::yield_now。
extern crate trpl; // required for mdbook test
use std::{thread, time::Duration};
fn main() {
trpl::block_on(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::select(a, b).await;
});
}
fn slow(name: &str, ms: u64) {
thread::sleep(Duration::from_millis(ms));
println!("'{name}' ran for {ms}ms");
}
yield_now 让操作交替推进这段代码不仅更清楚地表达了实际意图,而且可能比使用 sleep 快得多,因为像 sleep 使用的那种定时器通常对精度有限制。我们使用的 sleep 版本,即使传入一纳秒的 Duration,也至少会休眠一毫秒。再说一次,现代计算机非常_快_:一毫秒内可以做很多事情!
这意味着异步即使对于计算密集型任务也可能是有用的,这取决于你的程序还在做什么,因为它提供了一种有用的工具来组织程序不同部分之间的关系(但代价是异步状态机的开销)。这是一种_协作式多任务_(cooperative multitasking)的形式,其中每个 future 都有权通过 await 点来决定何时交出控制权。因此,每个 future 也有责任避免阻塞太长时间。在一些基于 Rust 的嵌入式操作系统中,这是_唯一_的多任务形式!
在实际代码中,你通常不会在每一行都交替使用函数调用和 await 点。虽然以这种方式让出控制权的开销相对较小,但并非零成本。在许多情况下,试图拆分一个计算密集型任务可能会使其显著变慢,所以有时候让一个操作短暂阻塞对_整体_性能反而更好。始终通过测量来确定代码的实际性能瓶颈在哪里。不过,如果你_确实_看到很多本应并发执行的工作在串行执行,那么理解这个底层机制就很重要了!
构建自定义异步抽象
我们还可以将 future 组合在一起来创建新的模式。例如,我们可以使用已有的异步构建块来构建一个 timeout 函数。完成后,这个结果将成为另一个构建块,我们可以用它来创建更多的异步抽象。
示例 17-18 展示了我们期望这个 timeout 如何与一个慢 future 配合工作。
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(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())
}
}
});
}
timeout 为慢操作设置时间限制让我们来实现它!首先,让我们思考一下 timeout 的 API:
- 它本身需要是一个异步函数,这样我们才能 await 它。
- 它的第一个参数应该是要运行的 future。我们可以使用泛型来让它适用于任何 future。
- 它的第二个参数是等待的最长时间。如果使用
Duration,就可以方便地传递给trpl::sleep。 - 它应该返回一个
Result。如果 future 成功完成,Result将是Ok,包含 future 产生的值。如果超时先到期,Result将是Err,包含超时等待的时长。
示例 17-19 展示了这个声明。
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(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> {
// Here is where our implementation will go!
}
timeout 的签名这满足了我们对类型的要求。现在让我们思考一下需要的_行为_:我们想让传入的 future 与时长进行竞争。我们可以使用 trpl::sleep 从时长创建一个定时器 future,然后使用 trpl::select 将该定时器与调用者传入的 future 一起运行。
在示例 17-20 中,我们通过对 trpl::select 的返回结果进行匹配来实现 timeout。
extern crate trpl; // required for mdbook test
use std::time::Duration;
use trpl::Either;
// --snip--
fn main() {
trpl::block_on(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::select(future_to_try, trpl::sleep(max_time)).await {
Either::Left(output) => Ok(output),
Either::Right(_) => Err(max_time),
}
}
select 和 sleep 定义 timeouttrpl::select 的实现不是公平的:它总是按照参数传入的顺序来轮询(其他 select 实现会随机选择先轮询哪个参数)。因此,我们将 future_to_try 作为第一个参数传给 select,这样即使 max_time 是一个非常短的时长,它也有机会完成。如果 future_to_try 先完成,select 将返回 Left,包含 future_to_try 的输出。如果 timer 先完成,select 将返回 Right,包含定时器的输出 ()。
如果 future_to_try 成功了,我们得到 Left(output),就返回 Ok(output)。如果休眠定时器先到期,我们得到 Right(()),就用 _ 忽略 (),转而返回 Err(max_time)。
这样,我们就用两个其他的异步辅助工具构建了一个可用的 timeout。如果运行我们的代码,它将在超时后打印失败信息:
Failed after 2 seconds
因为 future 可以与其他 future 组合,所以你可以使用较小的异步构建块来构建非常强大的工具。例如,你可以使用同样的方法将超时与重试结合起来,然后再将它们与网络调用等操作结合使用(比如示例 17-5 中的那些)。
在实践中,你通常会直接使用 async 和 await,其次才会使用 select 等函数和 join! 等宏来控制最外层 future 的执行方式。
我们现在已经看到了多种同时处理多个 future 的方式。接下来,我们将看看如何使用_流_(streams)来处理随时间推移的一系列 future。
Stream:序列化的 Future
流(Stream):序列化的 Future
回忆一下本章前面“消息传递”一节中我们如何使用异步通道的接收端。异步 recv 方法会随时间推移产生一系列元素。这是一种更通用的模式的实例,称为流(stream)。许多概念天然适合用流来表示:队列中逐渐可用的元素、文件系统中因完整数据集太大而无法一次性放入内存时逐块拉取的数据,或者随时间从网络到达的数据。因为流本身就是 future,我们可以将它们与任何其他类型的 future 配合使用,并以有趣的方式组合它们。例如,我们可以批量处理事件以避免触发过多的网络调用,为长时间运行的操作序列设置超时,或者对用户界面事件进行节流以避免不必要的工作。
我们在第 13 章“Iterator Trait 和 next 方法”一节中已经见过元素序列,当时我们学习了 Iterator trait。但迭代器和异步通道接收端之间有两个区别。第一个区别是时间:迭代器是同步的,而通道接收端是异步的。第二个区别是 API。直接使用 Iterator 时,我们调用其同步的 next 方法。而对于 trpl::Receiver 流,我们调用的是异步的 recv 方法。除此之外,这些 API 的使用感受非常相似,这种相似性并非巧合。流就像是异步形式的迭代。不过,trpl::Receiver 专门用于等待接收消息,而通用的流 API 要广泛得多:它像 Iterator 一样提供下一个元素,但以异步的方式进行。
Rust 中迭代器和流之间的相似性意味着我们实际上可以从任何迭代器创建流。与迭代器一样,我们可以通过调用流的 next 方法然后 await 其输出来使用流,如示例 17-21 所示(该代码暂时还无法编译)。
extern crate trpl; // required for mdbook test
fn main() {
trpl::block_on(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 `tokio_stream::iter::Iter` in the current scope
--> src/main.rs:10:40
|
10 | while let Some(value) = stream.next().await {
| ^^^^
|
= 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,但实际上是 StreamExt。Ext 是 extension(扩展)的缩写,这是 Rust 社区中用一个 trait 扩展另一个 trait 的常见模式。
Stream trait 定义了一个底层接口,它实际上结合了 Iterator 和 Future trait。StreamExt 在 Stream 之上提供了一组更高级的 API,包括 next 方法以及其他类似于 Iterator trait 所提供的实用方法。Stream 和 StreamExt 目前还不是 Rust 标准库的一部分,但大多数生态系统中的 crate 使用类似的定义。
修复编译器错误的方法是添加一条 trpl::StreamExt 的 use 语句,如示例 17-22 所示。
extern crate trpl; // required for mdbook test
use trpl::StreamExt;
fn main() {
trpl::block_on(async {
let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// --snip--
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 引入了作用域,就可以使用它的所有实用方法了,就像使用迭代器一样。
深入了解异步相关的 trait
深入了解异步相关的 trait
在本章中,我们以各种方式使用了 Future、Stream 和 StreamExt trait。不过到目前为止,我们一直避免深入探讨它们的工作原理以及它们之间的关系,这在日常 Rust 开发中通常是没问题的。但有时候,你会遇到需要更深入理解这些 trait 细节的场景,同时还需要了解 Pin 类型和 Unpin trait。在本节中,我们将深入到足以应对这些场景的程度,而将 真正 深层次的探讨留给其他文档。
Future trait
让我们先来仔细看看 Future trait 是如何工作的。以下是 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>;
}
}
这个 trait 定义包含了一些新类型以及我们之前没见过的语法,让我们逐一解析。
首先,Future 的关联类型 Output 表示 future 解析后的结果。这类似于 Iterator trait 的 Item 关联类型。其次,Future 有一个 poll 方法,它的 self 参数接受一个特殊的 Pin 引用,还有一个 Context 类型的可变引用,返回值是 Poll<Self::Output>。稍后我们会详细讨论 Pin 和 Context。现在,让我们先关注方法的返回值——Poll 类型:
#![allow(unused)]
fn main() {
pub enum Poll<T> {
Ready(T),
Pending,
}
}
Poll 类型类似于 Option。它有一个包含值的变体 Ready(T),和一个不包含值的变体 Pending。不过 Poll 的含义与 Option 截然不同!Pending 变体表示 future 仍有工作要做,因此调用者需要稍后再次检查。Ready 变体表示 Future 已经完成了它的工作,T 值已经可用。
注意:通常很少需要直接调用
poll,但如果确实需要,请记住对于大多数 future,在 future 返回Ready之后不应再次调用poll。许多 future 在变为就绪状态后再次被轮询会 panic。可以安全地再次轮询的 future 会在其文档中明确说明。这类似于Iterator::next的行为。
当你看到使用 await 的代码时,Rust 会在底层将其编译为调用 poll 的代码。如果你回顾示例 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 => {
// continue
}
}
}
但如果 Rust 真的编译成这样的代码,那么每个 await 都会是阻塞的——这恰恰与我们的目标相反!相反,Rust 确保循环可以将控制权交给某个东西,这个东西可以暂停当前 future 的工作,转而处理其他 future,然后稍后再回来检查这个 future。正如我们所见,这个“某个东西“就是异步运行时,而调度和协调工作正是它的主要职责之一。
在“通过消息传递在两个任务之间发送数据”一节中,我们描述了等待 rx.recv 的过程。recv 调用返回一个 future,await 这个 future 就会轮询它。我们提到运行时会暂停 future,直到它准备好返回 Some(message) 或在通道关闭时返回 None。通过对 Future trait,特别是 Future::poll 的深入理解,我们可以看到这是如何工作的。当 future 返回 Poll::Pending 时,运行时就知道它还没有准备好。相反,当 poll 返回 Poll::Ready(Some(message)) 或 Poll::Ready(None) 时,运行时就知道 future 已经 准备好了,并推进它的执行。
运行时具体如何做到这一点超出了本书的范围,但关键是理解 future 的基本机制:运行时 轮询 它负责的每个 future,当 future 尚未就绪时将其重新置于休眠状态。
Pin 类型和 Unpin trait
回顾示例 17-13,我们使用 trpl::join! 宏来 await 三个 future。然而,拥有一个包含若干 future 的集合(如 vector)是很常见的,而且其中 future 的数量在运行时才能确定。让我们将示例 17-13 修改为示例 17-23 中的代码,将三个 future 放入一个 vector 中,并调用 trpl::join_all 函数——不过这段代码还无法编译。
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(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 {
// --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<Box<dyn Future<Output = ()>>> =
vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];
trpl::join_all(futures).await;
});
}
我们将每个 future 放入 Box 中使其成为 trait 对象,就像我们在第 12 章“从 run 返回错误“一节中所做的那样。(我们将在第 18 章详细介绍 trait 对象。)使用 trait 对象可以让我们将这些类型产生的匿名 future 视为相同的类型,因为它们都实现了 Future trait。
这可能令人意外。毕竟,这些 async 块都没有返回任何值,所以每个都产生一个 Future<Output = ()>。但请记住,Future 是一个 trait,编译器会为每个 async 块创建一个唯一的枚举,即使它们的输出类型相同。就像你不能把两个不同的手写结构体放入一个 Vec 一样,你也不能混合编译器生成的枚举。
然后我们将 future 集合传递给 trpl::join_all 函数并 await 结果。然而,这段代码无法编译;以下是错误信息的相关部分。
error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
--> src/main.rs:48:33
|
48 | 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`
这条错误信息告诉我们应该使用 pin! 宏来 固定(pin) 这些值,也就是将它们放入 Pin 类型中,以保证这些值不会在内存中被移动。错误信息说需要固定是因为 dyn Future<Output = ()> 需要实现 Unpin trait,而它目前没有实现。
trpl::join_all 函数返回一个名为 JoinAll 的结构体。该结构体对类型 F 是泛型的,F 被约束为实现 Future trait。直接使用 await 来 await 一个 future 会隐式地固定该 future。这就是为什么我们不需要在每个想要 await future 的地方都使用 pin!。
然而,这里我们并不是直接 await 一个 future。相反,我们通过将 future 集合传递给 join_all 函数来构造一个新的 future——JoinAll。join_all 的签名要求集合中元素的类型都实现 Future trait,而 Box<T> 只有在它包装的 T 是一个实现了 Unpin trait 的 future 时才实现 Future。
这些内容确实不少!为了真正理解它,让我们更深入地了解 Future trait 的实际工作方式,特别是关于固定的部分。再看一下 Future trait 的定义:
#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};
pub trait Future {
type Output;
// Required method
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 是对指针类型(如 &、&mut、Box 和 Rc)的包装器。(从技术上讲,Pin 适用于实现了 Deref 或 DerefMut trait 的类型,但这实际上等同于只适用于引用和智能指针。)Pin 本身不是指针,也不像 Rc 和 Arc 那样具有引用计数等自身行为;它纯粹是编译器用来强制执行指针使用约束的工具。
回想一下 await 是通过调用 poll 来实现的,这开始解释我们之前看到的错误信息了,但那个错误是关于 Unpin 而不是 Pin 的。那么 Pin 和 Unpin 到底是什么关系?为什么 Future 需要 self 在 Pin 类型中才能调用 poll?
回忆本章前面的内容,future 中的一系列 await 点会被编译成一个状态机,编译器会确保该状态机遵循 Rust 关于安全性的所有常规规则,包括借用和所有权。为了实现这一点,Rust 会查看从一个 await 点到下一个 await 点(或 async 块的末尾)之间需要哪些数据,然后在编译后的状态机中创建相应的变体。每个变体获得它在该段源代码中所需的数据访问权限,无论是通过获取数据的所有权,还是通过获取可变或不可变引用。
到目前为止一切顺利:如果我们在某个 async 块中的所有权或引用方面犯了错误,借用检查器会告诉我们。但当我们想要移动与该块对应的 future 时——比如将它移入 Vec 以传递给 join_all——事情就变得棘手了。
当我们移动一个 future 时——无论是将它推入数据结构以便与 join_all 一起用作迭代器,还是从函数中返回它——实际上意味着移动 Rust 为我们创建的状态机。与 Rust 中大多数其他类型不同,Rust 为 async 块创建的 future 可能会在任何给定变体的字段中包含对自身的引用,如图 17-4 的简化示意图所示。
默认情况下,任何包含对自身引用的对象移动起来都是不安全的,因为引用总是指向它们所引用内容的实际内存地址(见图 17-5)。如果你移动了数据结构本身,那些内部引用将仍然指向旧的位置。然而,那个内存位置现在已经无效了。一方面,当你对数据结构进行更改时,它的值不会被更新。另一方面——更重要的是——计算机现在可以自由地将那块内存用于其他用途!你可能最终会读到完全不相关的数据。
理论上,Rust 编译器可以在对象每次被移动时尝试更新所有引用,但这可能会带来大量的性能开销,尤其是当需要更新一整张引用网络时。如果我们能确保相关的数据结构 不会在内存中移动,就不需要更新任何引用了。这正是 Rust 借用检查器的用武之地:在安全代码中,它会阻止你移动任何有活跃引用指向它的项。
Pin 在此基础上提供了我们所需的精确保证。当我们通过将指向某个值的指针包装在 Pin 中来 固定 该值时,它就不能再被移动了。因此,如果你有 Pin<Box<SomeType>>,你实际上固定的是 SomeType 值,而 不是 Box 指针。图 17-6 展示了这个过程。
<img alt=“Three boxes laid out side by side. The first is labeled “Pin”, the second “b1”, and the third “pinned”. Within “pinned” is a table labeled “fut”, with a single column; it represents a future with cells for each part of the data structure. Its first cell has the value “0”, its second cell has an arrow coming out of it and pointing to the fourth and final cell, which has the value “1” in it, and the third cell has dashed lines and an ellipsis to indicate there may be other parts to the data structure. All together, the “fut” table represents a future which is self-referential. An arrow leaves the box labeled “Pin”, goes through the box labeled “b1” and terminates inside the “pinned” box at the “fut” table.“ src=“img/trpl17-06.svg” class=“center” />
实际上,Box 指针仍然可以自由移动。记住:我们关心的是确保最终被引用的数据保持在原位。如果指针移动了,但它指向的数据 仍在同一位置,如图 17-7 所示,就不会有潜在问题。(作为一个独立练习,查看这些类型以及 std::pin 模块的文档,试着弄清楚如何用 Pin 包装 Box 来实现这一点。)关键在于自引用类型本身不能移动,因为它仍然是被固定的。
<img alt=“Four boxes laid out in three rough columns, identical to the previous diagram with a change to the second column. Now there are two boxes in the second column, labeled “b1” and “b2”, “b1” is grayed out, and the arrow from “Pin” goes through “b2” instead of “b1”, indicating that the pointer has moved from “b1” to “b2”, but the data in “pinned” has not moved.“ src=“img/trpl17-07.svg” class=“center” />
然而,大多数类型移动起来是完全安全的,即使它们恰好在 Pin 指针后面。我们只有在项具有内部引用时才需要考虑固定。原始值(如数字和布尔值)是安全的,因为它们显然没有任何内部引用。你在 Rust 中通常使用的大多数类型也是如此。例如,你可以随意移动一个 Vec 而不用担心。根据我们目前所了解的,如果你有一个 Pin<Vec<String>>,即使 Vec<String> 在没有其他引用指向它时总是可以安全移动的,你也必须通过 Pin 提供的安全但受限的 API 来完成所有操作。我们需要一种方式来告诉编译器在这种情况下移动项是没问题的——这就是 Unpin 发挥作用的地方。
Unpin 是一个标记 trait,类似于我们在第 16 章中看到的 Send 和 Sync trait,因此它本身没有任何功能。标记 trait 的存在只是为了告诉编译器,在特定上下文中使用实现了该 trait 的类型是安全的。Unpin 告知编译器,给定类型 不 需要维护关于其值是否可以安全移动的任何保证。
与 Send 和 Sync 一样,编译器会为所有能证明安全的类型自动实现 Unpin。一个特殊情况,同样类似于 Send 和 Sync,是某个类型 没有 实现 Unpin。其表示法为 impl !Unpin for SomeType,其中 SomeType 是一个在使用指向该类型的指针位于 Pin 中时 确实 需要维护安全保证的类型名称。
换句话说,关于 Pin 和 Unpin 的关系,有两点需要记住。首先,Unpin 是“正常“情况,而 !Unpin 是特殊情况。其次,一个类型是否实现 Unpin 或 !Unpin 只 在你使用指向该类型的固定指针(如 Pin<&mut SomeType>)时才重要。
为了更具体地理解,想想 String:它有一个长度和组成它的 Unicode 字符。我们可以将 String 包装在 Pin 中,如图 17-8 所示。然而,String 自动实现了 Unpin,Rust 中的大多数其他类型也是如此。
<img alt=“A box labeled “Pin” on the left with an arrow going from it to a box labeled “String” on the right. The “String” box contains the data 5usize, representing the length of the string, and the letters “h”, “e”, “l”, “l”, and “o” representing the characters of the string “hello” stored in this String instance. A dotted rectangle surrounds the “String” box and its label, but not the “Pin” box.“ src=“img/trpl17-08.svg” class=“center” />
因此,我们可以做一些如果 String 实现了 !Unpin 就会非法的操作,比如在内存中的同一位置用另一个字符串替换它,如图 17-9 所示。这不会违反 Pin 的契约,因为 String 没有使其移动不安全的内部引用。这正是它实现 Unpin 而非 !Unpin 的原因。
<img alt=“The same “hello” string data from the previous example, now labeled “s1” and grayed out. The “Pin” box from the previous example now points to a different String instance, one that is labeled “s2”, is valid, has a length of 7usize, and contains the characters of the string “goodbye”. s2 is surrounded by a dotted rectangle because it, too, implements the Unpin trait.“ src=“img/trpl17-09.svg” class=“center” />
现在我们已经了解了足够的知识来理解示例 17-23 中 join_all 调用报告的错误。我们最初尝试将 async 块产生的 future 移入 Vec<Box<dyn Future<Output = ()>>>,但正如我们所见,这些 future 可能包含内部引用,因此它们不会自动实现 Unpin。一旦我们固定它们,就可以将生成的 Pin 类型放入 Vec 中,确信 future 中的底层数据 不会 被移动。示例 17-24 展示了如何通过在定义三个 future 的地方调用 pin! 宏并调整 trait 对象类型来修复代码。
extern crate trpl; // required for mdbook test
use std::pin::{Pin, pin};
// --snip--
use std::time::Duration;
fn main() {
trpl::block_on(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;
});
}
这个示例现在可以编译并运行了,我们可以在运行时向 vector 中添加或移除 future,然后将它们全部 join。
Pin 和 Unpin 对于构建底层库或运行时本身来说最为重要,而不是日常 Rust 代码。不过,当你在错误信息中看到这些 trait 时,你现在会更清楚如何修复代码了!
注意:
Pin和Unpin的这种组合使得在 Rust 中安全地实现一整类复杂类型成为可能,否则这些类型会因为自引用而难以实现。需要Pin的类型目前最常出现在异步 Rust 中,但偶尔你也会在其他上下文中看到它们。
Pin和Unpin的工作原理细节以及它们需要遵守的规则,在std::pin的 API 文档中有详尽的介绍,如果你有兴趣了解更多,那是一个很好的起点。如果你想更深入地了解底层工作原理,请参阅 Asynchronous Programming in Rust 的第 2 章和第 4 章。
Stream trait
现在你对 Future、Pin 和 Unpin trait 有了更深入的理解,我们可以将注意力转向 Stream trait。正如你在本章前面所学到的,流(stream)类似于异步迭代器。然而,与 Iterator 和 Future 不同,截至本文撰写时,Stream 在标准库中还没有定义,但 futures crate 中有一个非常通用的定义,在整个生态系统中广泛使用。
让我们在查看 Stream trait 如何将它们融合在一起之前,先回顾一下 Iterator 和 Future trait 的定义。从 Iterator,我们有序列的概念:它的 next 方法提供一个 Option<Self::Item>。从 Future,我们有随时间就绪的概念:它的 poll 方法提供一个 Poll<Self::Output>。为了表示一个随时间逐渐就绪的项序列,我们定义了一个将这些特性结合在一起的 Stream trait:
#![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 trait 定义了一个名为 Item 的关联类型,表示流产生的项的类型。这类似于 Iterator,其中可能有零到多个项,而不像 Future 总是只有一个 Output,即使它是单元类型 ()。
Stream 还定义了一个获取这些项的方法。我们称之为 poll_next,以明确它像 Future::poll 一样进行轮询,并像 Iterator::next 一样产生一系列项。它的返回类型将 Poll 和 Option 组合在一起。外层类型是 Poll,因为它需要像 future 一样检查就绪状态。内层类型是 Option,因为它需要像迭代器一样发出是否还有更多消息的信号。
与此非常类似的定义很可能最终会成为 Rust 标准库的一部分。与此同时,它是大多数运行时工具包的一部分,所以你可以放心使用它,接下来我们介绍的所有内容通常都适用!
不过,在“流:按序列处理的 Future”一节的示例中,我们既没有使用 poll_next 也 没有使用 Stream,而是使用了 next 和 StreamExt。当然,我们 可以 通过手写 Stream 状态机来直接使用 poll_next API,就像我们 可以 通过 poll 方法直接操作 future 一样。但使用 await 要方便得多,而 StreamExt trait 提供了 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...
}
}
注意:本章前面使用的实际定义与这里略有不同,因为它需要支持尚不支持在 trait 中使用异步函数的 Rust 版本。因此,它看起来像这样:
fn next(&mut self) -> Next<'_, Self> where Self: Unpin;那个
Next类型是一个实现了Future的struct,它允许我们用Next<'_, Self>来命名对self的引用的生命周期,这样await就可以与这个方法一起使用了。
StreamExt trait 也是所有可用于流的有趣方法的所在地。StreamExt 会自动为每个实现了 Stream 的类型实现,但这两个 trait 是分开定义的,以便社区可以在不影响基础 trait 的情况下迭代便利 API。
在 trpl crate 使用的 StreamExt 版本中,该 trait 不仅定义了 next 方法,还提供了 next 的默认实现,正确处理了调用 Stream::poll_next 的细节。这意味着即使你需要编写自己的流数据类型,你也 只 需要实现 Stream,然后任何使用你的数据类型的人都可以自动使用 StreamExt 及其方法。
以上就是我们要介绍的关于这些 trait 底层细节的全部内容。最后,让我们来看看 future(包括流)、任务和线程是如何协同工作的!
Future、任务和线程
融会贯通:Future、任务与线程
正如我们在第十六章中所见,线程是实现并发的一种方式。在本章中我们又见识了另一种方式:使用 async 配合 future 和流。如果你在犹豫何时该选择哪种方式,答案是:视情况而定!而且在很多场景下,选择并非线程或 async 二选一,而是线程与 async 兼而用之。
许多操作系统提供基于线程的并发模型已有数十年之久,许多编程语言也因此支持线程。然而,线程模型并非没有代价。在很多操作系统上,每个线程都会占用相当多的内存。而且线程只有在操作系统和硬件支持的情况下才可用。与主流的桌面和移动计算机不同,某些嵌入式系统根本没有操作系统,因此也就没有线程可用。
async 模型提供了一组不同的——也是最终互补的——权衡取舍。在 async 模型中,并发操作不需要各自拥有独立的线程,而是可以运行在任务(task)上,就像我们在流那一节中使用 trpl::spawn_task 从同步函数中启动工作一样。任务类似于线程,但它不是由操作系统管理的,而是由库级别的代码——即运行时(runtime)——来管理。
创建线程和创建任务的 API 如此相似是有原因的。线程充当一组同步操作的边界;并发发生在线程之间。任务则充当一组异步操作的边界;并发既可以发生在任务之间,也可以发生在任务内部,因为一个任务可以在其内部的多个 future 之间切换。最后,future 是 Rust 最细粒度的并发单元,每个 future 可能代表一棵由其他 future 组成的树。运行时——具体来说是它的执行器(executor)——管理任务,而任务管理 future。从这个角度看,任务类似于轻量级的、由运行时管理的线程,并且由于是运行时而非操作系统来管理,它们还具备额外的能力。
这并不意味着 async 任务总是优于线程(反之亦然)。使用线程实现并发在某些方面比使用 async 实现并发的编程模型更简单,这既可以是优势也可以是劣势。线程在某种程度上是“即发即忘“的;它们没有与 future 对等的原生概念,因此它们只是一直运行到完成,除非被操作系统本身中断。
事实上,线程和任务往往能很好地协同工作,因为任务(至少在某些运行时中)可以在线程之间迁移。实际上,我们一直在使用的运行时——包括 spawn_blocking 和 spawn_task 函数——默认就是多线程的!许多运行时使用一种称为工作窃取(work stealing)的策略,根据线程当前的利用情况,在线程之间透明地迁移任务,以提升系统的整体性能。这种策略实际上同时需要线程和任务,因此也需要 future。
在考虑使用哪种方式时,可以参考以下经验法则:
- 如果工作是高度可并行化的(即 CPU 密集型),例如处理一大批数据且每个部分可以独立处理,那么线程是更好的选择。
- 如果工作是高度并发的(即 I/O 密集型),例如处理来自许多不同来源的消息,这些消息可能以不同的间隔或不同的速率到达,那么 async 是更好的选择。
如果你同时需要并行性和并发性,不必在线程和 async 之间二选一。你可以自由地将它们结合使用,让各自发挥所长。例如,示例 17-25 展示了在实际 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::block_on(async {
while let Some(message) = rx.recv().await {
println!("{message}");
}
});
}
我们首先创建一个 async 通道,然后使用 move 关键字创建一个线程,让该线程获取通道发送端的所有权。在线程内部,我们发送数字 1 到 10,每次发送之间休眠一秒。最后,我们运行一个通过 async 块创建的 future,并将其传递给 trpl::block_on,就像本章中一直做的那样。在这个 future 中,我们等待接收这些消息,就像之前看到的其他消息传递示例一样。
回到本章开头提到的场景:假设你要使用专用线程运行一组视频编码任务(因为视频编码是计算密集型的),然后通过 async 通道通知 UI 这些操作已完成。在实际应用中,这类组合使用的例子不胜枚举。
总结
这并不是你在本书中最后一次见到并发。第二十一章中的项目将在比这里讨论的简单示例更贴近实际的场景中应用这些概念,并更直接地比较使用线程与使用任务和 future 来解决问题的异同。
无论你选择哪种方式,Rust 都为你提供了编写安全、高效的并发代码所需的工具——无论是高吞吐量的 Web 服务器还是嵌入式操作系统。
接下来,我们将讨论随着 Rust 程序规模增长,如何以惯用的方式对问题建模和组织解决方案。此外,我们还将讨论 Rust 的惯用模式与你可能熟悉的面向对象编程之间的关系。
面向对象编程特性
面向对象编程(Object-Oriented Programming,OOP)是一种程序建模方式。对象(object)作为编程概念最早在 20 世纪 60 年代的 Simula 编程语言中被引入。这些对象影响了 Alan Kay 的编程架构,在该架构中对象之间通过消息传递进行交互。为了描述这种架构,他在 1967 年创造了面向对象编程(object-oriented programming)这一术语。关于 OOP 的定义众说纷纭,按照某些定义,Rust 是面向对象的;而按照另一些定义,它又不是。在本章中,我们将探讨一些通常被认为是面向对象的特性,以及这些特性如何对应到地道的 Rust 代码中。然后我们会展示如何在 Rust 中实现一个面向对象的设计模式,并讨论这样做与利用 Rust 自身优势来实现替代方案之间的权衡取舍。
面向对象语言的特征
面向对象语言的特征
关于一门语言必须具备哪些特性才能被视为面向对象的,编程社区并没有达成共识。Rust 受到了许多编程范式的影响,其中包括面向对象编程(OOP);例如,我们在第 13 章探讨了来自函数式编程的特性。可以说,面向对象语言通常具有某些共同特征——即对象、封装和继承。让我们逐一看看这些特征的含义,以及 Rust 是否支持它们。
对象包含数据和行为
Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 合著的《设计模式:可复用面向对象软件的基础》(Addison-Wesley,1994)一书,通常被称为“四人帮“(The Gang of Four)之书,是一本面向对象设计模式的目录。书中对面向对象编程的定义如下:
面向对象的程序由对象组成。对象将数据和操作数据的过程打包在一起。这些过程通常被称为方法或操作。
按照这个定义,Rust 是面向对象的:结构体和枚举拥有数据,而 impl 块为结构体和枚举提供了方法。尽管带有方法的结构体和枚举并不被称为对象,但根据“四人帮“对对象的定义,它们提供了相同的功能。
封装隐藏了实现细节
面向对象编程中另一个常见的概念是封装(encapsulation),它意味着对象的实现细节对使用该对象的代码不可访问。因此,与对象交互的唯一方式是通过其公共 API;使用对象的代码不应该能够深入对象内部直接修改数据或行为。这使得程序员可以修改和重构对象的内部实现,而无需改动使用该对象的代码。
我们在第 7 章讨论了如何控制封装:可以使用 pub 关键字来决定代码中哪些模块、类型、函数和方法应该是公开的,而默认情况下其他所有内容都是私有的。例如,我们可以定义一个 AveragedCollection 结构体,其中包含一个存储 i32 值的 vector 字段。该结构体还可以有一个字段来存储 vector 中值的平均值,这样就不必在每次需要时都重新计算平均值。换句话说,AveragedCollection 会为我们缓存计算好的平均值。示例 18-1 展示了 AveragedCollection 结构体的定义。
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
AveragedCollection 结构体,维护一个整数列表以及集合中元素的平均值该结构体被标记为 pub,这样其他代码就可以使用它,但结构体内部的字段仍然是私有的。这一点在此场景中很重要,因为我们希望确保每当列表中添加或移除一个值时,平均值也会随之更新。我们通过在结构体上实现 add、remove 和 average 方法来做到这一点,如示例 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;
}
}
AveragedCollection 上实现公共方法 add、remove 和 average公共方法 add、remove 和 average 是访问或修改 AveragedCollection 实例中数据的唯一途径。当使用 add 方法向 list 添加元素或使用 remove 方法移除元素时,每个方法的实现都会调用私有的 update_average 方法来同步更新 average 字段。
我们将 list 和 average 字段保持为私有,这样外部代码就无法直接向 list 字段添加或移除元素;否则,当 list 发生变化时,average 字段可能会与之不同步。average 方法返回 average 字段中的值,允许外部代码读取平均值但不能修改它。
由于我们封装了 AveragedCollection 结构体的实现细节,将来可以轻松地更改某些方面,比如数据结构。例如,我们可以将 list 字段从 Vec<i32> 改为 HashSet<i32>。只要 add、remove 和 average 这些公共方法的签名保持不变,使用 AveragedCollection 的代码就无需修改。如果我们将 list 设为公开的,情况就不一定如此了:HashSet<i32> 和 Vec<i32> 有不同的添加和移除元素的方法,因此如果外部代码直接修改 list,就很可能需要做出相应的改动。
如果封装是一门语言被视为面向对象的必要条件,那么 Rust 满足这一要求。对代码的不同部分选择使用或不使用 pub,就能实现对实现细节的封装。
继承作为类型系统与代码共享机制
继承(inheritance)是一种机制,通过它一个对象可以继承另一个对象定义中的元素,从而获得父对象的数据和行为,而无需重新定义它们。
如果一门语言必须具有继承才能被视为面向对象的,那么 Rust 就不是这样的语言。在 Rust 中,没有办法在不使用宏的情况下定义一个继承父结构体字段和方法实现的结构体。
然而,如果你习惯了在编程工具箱中使用继承,你可以在 Rust 中使用其他方案,具体取决于你最初使用继承的原因。
选择继承主要有两个原因。第一个是代码复用:你可以为某个类型实现特定的行为,而继承使你能够为另一个类型复用该实现。在 Rust 中,你可以通过默认 trait 方法实现来有限度地做到这一点,正如我们在示例 10-14 中为 Summary trait 添加 summarize 方法的默认实现时所看到的那样。任何实现了 Summary trait 的类型都可以直接使用 summarize 方法,而无需编写额外的代码。这类似于父类拥有一个方法的实现,而继承的子类也拥有该方法的实现。我们还可以在实现 Summary trait 时覆盖 summarize 方法的默认实现,这类似于子类覆盖从父类继承的方法实现。
使用继承的另一个原因与类型系统有关:使子类型能够在与父类型相同的位置使用。这也被称为多态(polymorphism),意味着如果多个对象共享某些特征,它们可以在运行时相互替换。
多态
对许多人来说,多态就是继承的同义词。但它实际上是一个更通用的概念,指的是能够处理多种类型数据的代码。对于继承而言,这些类型通常是子类。
Rust 则使用泛型(generics)来抽象不同的可能类型,并使用 trait 约束来限定这些类型必须提供的功能。这有时被称为有界参数多态(bounded parametric polymorphism)。
Rust 选择了一组不同的权衡方案,没有提供继承。继承往往有共享过多代码的风险。子类不应该总是共享父类的所有特征,但使用继承时就会如此。这会使程序的设计变得不够灵活。它还引入了在子类上调用不合理或会导致错误的方法的可能性,因为这些方法并不适用于该子类。此外,有些语言只允许单继承(即一个子类只能继承自一个类),这进一步限制了程序设计的灵活性。
出于这些原因,Rust 采用了不同的方式,使用 trait 对象而非继承来实现运行时多态。让我们来看看 trait 对象是如何工作的。
使用 trait 对象抽象共享行为
使用 trait 对象来抽象共同行为
在第 8 章中,我们提到过 vector 的一个限制是它只能存储同一种类型的元素。我们在示例 8-9 中创建了一个变通方案,定义了一个 SpreadsheetCell 枚举,其变体可以持有整数、浮点数和文本。这意味着我们可以在每个单元格中存储不同类型的数据,同时仍然拥有一个代表一行单元格的 vector。当我们在编译时就知道可互换的项是一组固定类型时,这是一个非常好的解决方案。
然而,有时我们希望库的用户能够扩展在特定场景下有效的类型集合。为了展示如何实现这一点,我们将创建一个示例图形用户界面(GUI)工具,它遍历一个项目列表,对每个项目调用 draw 方法将其绘制到屏幕上——这是 GUI 工具的常见技术。我们将创建一个名为 gui 的库 crate,其中包含一个 GUI 库的结构。这个 crate 可能包含一些供人们使用的类型,例如 Button 或 TextField。此外,gui 的用户还希望创建自己的可绘制类型:例如,一个程序员可能会添加 Image,另一个可能会添加 SelectBox。
在编写这个库时,我们无法知道和定义其他程序员可能想要创建的所有类型。但我们知道 gui 需要跟踪许多不同类型的值,并且需要对每个不同类型的值调用 draw 方法。它不需要确切知道调用 draw 方法时会发生什么,只需要知道该值有这个方法可供调用。
在有继承的语言中,我们可能会定义一个名为 Component 的类,其上有一个名为 draw 的方法。其他类,如 Button、Image 和 SelectBox,会继承 Component 从而继承 draw 方法。它们可以各自重写 draw 方法来定义自己的自定义行为,但框架可以将所有类型视为 Component 实例并对它们调用 draw。但由于 Rust 没有继承,我们需要另一种方式来组织 gui 库,以允许用户创建与库兼容的新类型。
定义共同行为的 trait
为了实现我们希望 gui 具有的行为,我们将定义一个名为 Draw 的 trait,其中有一个名为 draw 的方法。然后,我们可以定义一个接受 trait 对象的 vector。trait 对象(trait object)同时指向一个实现了指定 trait 的类型实例,以及一个用于在运行时查找该类型上 trait 方法的表。我们通过指定某种指针(如引用或 Box<T> 智能指针),然后加上 dyn 关键字,再指定相关的 trait 来创建 trait 对象。(我们将在第 20 章的“动态大小类型与 Sized trait”中讨论 trait 对象必须使用指针的原因。)我们可以使用 trait 对象来代替泛型或具体类型。无论在哪里使用 trait 对象,Rust 的类型系统都会在编译时确保在该上下文中使用的任何值都实现了 trait 对象的 trait。因此,我们不需要在编译时知道所有可能的类型。
我们之前提到过,在 Rust 中,我们避免将结构体和枚举称为“对象“,以区别于其他语言中的对象。在结构体或枚举中,结构体字段中的数据和 impl 块中的行为是分开的,而在其他语言中,数据和行为组合成一个概念通常被称为对象。trait 对象与其他语言中的对象不同,因为我们不能向 trait 对象添加数据。trait 对象不像其他语言中的对象那样通用:它们的特定用途是允许对共同行为进行抽象。
示例 18-3 展示了如何定义一个名为 Draw 的 trait,其中有一个名为 draw 的方法。
pub trait Draw {
fn draw(&self);
}
Draw trait这个语法应该很熟悉,我们在第 10 章讨论过如何定义 trait。接下来是一些新语法:示例 18-4 定义了一个名为 Screen 的结构体,它持有一个名为 components 的 vector。这个 vector 的类型是 Box<dyn Draw>,这是一个 trait 对象;它是 Box 中任何实现了 Draw trait 的类型的替身。
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
Screen 结构体,其 components 字段持有一个实现了 Draw trait 的 trait 对象的 vector在 Screen 结构体上,我们将定义一个名为 run 的方法,它会对每个 components 调用 draw 方法,如示例 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();
}
}
}
Screen 上的 run 方法,对每个组件调用 draw 方法这与定义一个使用带有 trait 约束的泛型类型参数的结构体的工作方式不同。泛型类型参数一次只能替换为一个具体类型,而 trait 对象允许在运行时用多个具体类型来填充 trait 对象。例如,我们可以使用泛型和 trait 约束来定义 Screen 结构体,如示例 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();
}
}
}
Screen 结构体及其 run 方法的替代实现这将限制我们的 Screen 实例只能拥有一个全部是 Button 类型或全部是 TextField 类型的组件列表。如果你只需要同质集合,使用泛型和 trait 约束是更好的选择,因为定义会在编译时被单态化以使用具体类型。
另一方面,使用 trait 对象的方法,一个 Screen 实例可以持有一个同时包含 Box<Button> 和 Box<TextField> 的 Vec<T>。让我们看看这是如何工作的,然后讨论其运行时性能影响。
实现 trait
现在我们将添加一些实现 Draw trait 的类型。我们将提供 Button 类型。实际实现一个 GUI 库超出了本书的范围,所以 draw 方法的主体不会有任何有用的实现。为了想象实现可能是什么样子,Button 结构体可能有 width、height 和 label 字段,如示例 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
}
}
Draw trait 的 Button 结构体Button 上的 width、height 和 label 字段会与其他组件的字段不同;例如,TextField 类型可能有这些相同的字段外加一个 placeholder 字段。我们想要在屏幕上绘制的每个类型都会实现 Draw trait,但会在 draw 方法中使用不同的代码来定义如何绘制该特定类型,就像这里的 Button 一样(没有实际的 GUI 代码,如前所述)。例如,Button 类型可能有一个额外的 impl 块,包含与用户点击按钮时发生的事情相关的方法。这类方法不适用于 TextField 等类型。
如果使用我们库的人决定实现一个具有 width、height 和 options 字段的 SelectBox 结构体,他们也会在 SelectBox 类型上实现 Draw trait,如示例 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() {}
gui 并在 SelectBox 结构体上实现 Draw trait我们库的用户现在可以编写他们的 main 函数来创建一个 Screen 实例。他们可以通过将 SelectBox 和 Button 各自放入 Box<T> 使其成为 trait 对象,然后添加到 Screen 实例中。接着他们可以在 Screen 实例上调用 run 方法,这会对每个组件调用 draw。示例 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 方法。
这个概念——只关心值响应的消息而不关心值的具体类型——类似于动态类型语言中鸭子类型(duck typing)的概念:如果它走起来像鸭子,叫起来也像鸭子,那它就是鸭子!在示例 18-5 中 Screen 的 run 实现中,run 不需要知道每个组件的具体类型是什么。它不检查组件是 Button 还是 SelectBox 的实例,只是对组件调用 draw 方法。通过指定 Box<dyn Draw> 作为 components vector 中值的类型,我们定义了 Screen 需要那些可以调用 draw 方法的值。
使用 trait 对象和 Rust 的类型系统来编写类似于鸭子类型的代码的优势在于,我们永远不必在运行时检查一个值是否实现了特定方法,也不必担心在值没有实现某个方法时调用它而产生错误。如果值没有实现 trait 对象所需的 trait,Rust 不会编译我们的代码。
例如,示例 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 了。
执行动态分发
回顾第 10 章“使用泛型的代码性能”中关于编译器对泛型执行的单态化过程的讨论:编译器为我们用来替代泛型类型参数的每个具体类型生成非泛型的函数和方法实现。单态化产生的代码执行的是静态分发(static dispatch),即编译器在编译时就知道你调用的是哪个方法。这与动态分发(dynamic dispatch)相对,动态分发是编译器在编译时无法确定你调用的是哪个方法。在动态分发的情况下,编译器生成的代码会在运行时确定应该调用哪个方法。
当我们使用 trait 对象时,Rust 必须使用动态分发。编译器不知道所有可能与使用 trait 对象的代码一起使用的类型,因此它不知道应该调用哪个类型上实现的哪个方法。相反,在运行时,Rust 使用 trait 对象内部的指针来确定要调用哪个方法。这种查找会产生静态分发不会有的运行时开销。动态分发还阻止编译器选择内联方法的代码,这反过来又阻止了一些优化,而且 Rust 对于在哪里可以使用和不可以使用动态分发有一些规则,称为 dyn 兼容性(dyn compatibility)。这些规则超出了本次讨论的范围,但你可以在参考手册中阅读更多相关内容。不过,我们确实在示例 18-5 中编写的代码和示例 18-9 中支持的代码中获得了额外的灵活性,所以这是一个需要权衡的取舍。
实现面向对象设计模式
实现面向对象设计模式
状态模式(state pattern)是一种面向对象设计模式。该模式的核心在于:我们定义一个值在内部可以拥有的一组状态。这些状态由一组状态对象来表示,而值的行为会根据其状态而改变。我们将通过一个博客文章结构体的示例来演示,它有一个字段用于保存其状态,该状态对象来自“草稿“、“审核中“或“已发布“这一组状态。
状态对象共享功能:当然,在 Rust 中我们使用结构体和 trait 而非对象和继承。每个状态对象负责自身的行为,以及管理何时应该转换到另一个状态。持有状态对象的值对各状态的不同行为以及何时在状态之间转换一无所知。
使用状态模式的优势在于,当程序的业务需求发生变化时,我们不需要修改持有状态的值的代码,也不需要修改使用该值的代码。我们只需要更新某个状态对象内部的代码来改变其规则,或者增加更多的状态对象。
首先,我们将以更传统的面向对象方式来实现状态模式。然后,我们将使用一种在 Rust 中更自然的方式。让我们逐步实现一个使用状态模式的博客文章工作流。
最终的功能如下:
- 博客文章从一篇空白草稿开始。
- 草稿完成后,请求对文章进行审核。
- 文章通过审核后,它就会被发布。
- 只有已发布的博客文章才会返回可打印的内容,这样未通过审核的文章就不会被意外发布。
对文章尝试的任何其他更改都不应产生效果。例如,如果我们在请求审核之前就尝试批准一篇草稿博客文章,该文章应该保持为未发布的草稿状态。
尝试传统的面向对象风格
解决同一个问题的代码组织方式有无数种,每种都有不同的取舍。本节的实现采用更传统的面向对象风格,这在 Rust 中是可以实现的,但并没有利用 Rust 的一些优势。稍后,我们将展示一种不同的方案,它仍然使用面向对象设计模式,但其结构方式对于有面向对象经验的程序员来说可能看起来不太熟悉。我们将比较这两种方案,以体验用不同于其他语言的方式设计 Rust 代码时的取舍。
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());
}
blog crate 具有的期望行为的代码我们希望允许用户使用 Post::new 创建一篇新的草稿博客文章。我们希望允许向博客文章中添加文本。如果我们在审批之前立即尝试获取文章内容,不应该得到任何文本,因为文章仍然是草稿。我们在代码中添加了 assert_eq! 用于演示目的。一个优秀的单元测试应该断言草稿博客文章的 content 方法返回空字符串,但我们不打算为这个示例编写测试。
接下来,我们希望能够请求对文章进行审核,并且希望在等待审核期间 content 返回空字符串。当文章获得批准后,它应该被发布,这意味着调用 content 时将返回文章的文本。
注意,我们从 crate 中交互的唯一类型是 Post 类型。这个类型将使用状态模式,并持有一个值,该值将是表示文章可能处于的各种状态的三个状态对象之一——草稿、审核中或已发布。从一个状态到另一个状态的转换将在 Post 类型内部管理。状态的改变是响应库用户在 Post 实例上调用的方法而发生的,但用户不必直接管理状态变化。同时,用户也不会在状态上犯错,比如在审核之前就发布文章。
定义 Post 并创建草稿状态的新实例
让我们开始实现这个库!我们知道需要一个公有的 Post 结构体来保存一些内容,所以我们先从结构体的定义和一个关联的公有 new 函数来创建 Post 实例开始,如 Listing 18-12 所示。我们还将创建一个私有的 State trait,它将定义所有 Post 的状态对象必须具有的行为。
然后,Post 将在一个名为 state 的私有字段中,在 Option<T> 内部持有一个 Box<dyn State> trait 对象来保存状态对象。稍后你就会明白为什么需要 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 {}
Post 结构体的定义、创建新 Post 实例的 new 函数、State trait 和 Draft 结构体State trait 定义了不同文章状态共享的行为。状态对象有 Draft、PendingReview 和 Published,它们都将实现 State trait。目前,该 trait 还没有任何方法,我们先只定义 Draft 状态,因为这是我们希望文章开始时所处的状态。
当我们创建新的 Post 时,将其 state 字段设置为一个持有 Box 的 Some 值。这个 Box 指向 Draft 结构体的一个新实例。这确保了每当我们创建新的 Post 实例时,它都会以草稿状态开始。因为 Post 的 state 字段是私有的,所以没有办法创建处于其他状态的 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 方法来向文章的 content 添加文本add_text 方法接受一个 self 的可变引用,因为我们正在修改调用 add_text 的 Post 实例。然后我们在 content 中的 String 上调用 push_str,并传入 text 参数来添加到已保存的 content 中。这个行为不依赖于文章所处的状态,所以它不是状态模式的一部分。add_text 方法完全不与 state 字段交互,但它是我们想要支持的行为的一部分。
确保草稿文章的内容为空
即使我们已经调用了 add_text 并向文章添加了一些内容,我们仍然希望 content 方法返回一个空字符串切片,因为文章仍处于草稿状态,如 Listing 18-11 中第一个 assert_eq! 所示。现在,让我们用能满足这个需求的最简单方式来实现 content 方法:始终返回一个空字符串切片。等我们实现了改变文章状态使其可以发布的功能后,再来修改它。到目前为止,文章只能处于草稿状态,所以文章内容应该始终为空。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 {}
Post 的 content 方法添加一个始终返回空字符串切片的占位实现添加了这个 content 方法后,Listing 18-11 中直到第一个 assert_eq! 的所有内容都能按预期工作。
请求审核以改变文章的状态
接下来,我们需要添加请求审核文章的功能,这应该将其状态从 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 和 State trait 上实现 request_review 方法我们给 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 方法需要获取状态值的所有权。这就是 Post 的 state 字段中 Option 的用武之地:我们调用 take 方法将 Some 值从 state 字段中取出,并在原处留下一个 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 状态。
现在我们可以开始看到状态模式的优势了:Post 上的 request_review 方法无论其 state 值是什么都是一样的。每个状态负责自己的规则。
我们将保持 Post 上的 content 方法不变,仍然返回空字符串切片。现在我们可以让 Post 处于 PendingReview 状态以及 Draft 状态,但我们希望在 PendingReview 状态下有相同的行为。Listing 18-11 现在可以工作到第二个 assert_eq! 调用了!
添加 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
}
}
Post 和 State trait 上实现 approve 方法我们将 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
}
}
Post 上的 content 方法,将其委托给 State 上的 content 方法因为目标是将所有这些规则保留在实现 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
}
}
State trait 添加 content 方法我们为 content 方法添加了一个默认实现,返回空字符串切片。这意味着我们不需要在 Draft 和 PendingReview 结构体上实现 content。Published 结构体将覆盖 content 方法并返回 post.content 中的值。虽然这很方便,但让 State 上的 content 方法来决定 Post 的内容,模糊了 State 和 Post 各自职责之间的界限。
注意,我们需要在这个方法上添加生命周期标注,正如我们在第 10 章中讨论的那样。我们接受一个 post 的引用作为参数,并返回该 post 的一部分的引用,所以返回引用的生命周期与 post 参数的生命周期相关。
大功告成——Listing 18-11 的全部内容现在都能工作了!我们已经用博客文章工作流的规则实现了状态模式。与规则相关的逻辑存在于状态对象中,而不是分散在 Post 各处。
为什么不用枚举?
你可能一直在想,为什么我们不用一个枚举,将不同的文章状态作为变体。这当然是一种可行的方案;试试看并比较最终结果,看看你更喜欢哪种!使用枚举的一个缺点是,每个检查枚举值的地方都需要一个 match 表达式或类似的结构来处理每个可能的变体。这可能比 trait 对象方案更加重复。
评估状态模式
我们已经展示了 Rust 能够实现面向对象的状态模式,以封装文章在每个状态下应具有的不同行为。Post 上的方法对各种行为一无所知。按照我们组织代码的方式,我们只需要在一个地方查看就能知道已发布文章的不同行为方式:Published 结构体上 State trait 的实现。
如果我们创建一个不使用状态模式的替代实现,我们可能会在 Post 的方法中使用 match 表达式,甚至在 main 代码中检查文章的状态并在那些地方改变行为。那意味着我们必须在多个地方查看才能理解文章处于已发布状态的所有含义。
使用状态模式,Post 的方法和使用 Post 的地方都不需要 match 表达式,而且要添加新状态,我们只需要添加一个新结构体并在一个地方为该结构体实现 trait 方法即可。
使用状态模式的实现很容易扩展以添加更多功能。为了体会维护使用状态模式的代码有多简单,试试以下几个建议:
- 添加一个
reject方法,将文章的状态从PendingReview变回Draft。 - 要求调用两次
approve才能将状态变为Published。 - 只允许用户在文章处于
Draft状态时添加文本内容。提示:让状态对象负责决定内容可能发生什么变化,但不负责修改Post。
状态模式的一个缺点是,由于状态实现了状态之间的转换,一些状态之间是相互耦合的。如果我们在 PendingReview 和 Published 之间添加另一个状态,比如 Scheduled,我们就必须修改 PendingReview 中的代码,使其转换到 Scheduled 而不是 Published。如果 PendingReview 不需要因为新增状态而改变就好了,但那意味着需要切换到另一种设计模式。
另一个缺点是我们重复了一些逻辑。为了消除部分重复,我们可能会尝试为 State trait 上的 request_review 和 approve 方法创建返回 self 的默认实现。然而,这行不通:当将 State 用作 trait 对象时,trait 并不知道具体的 self 到底是什么类型,所以返回类型在编译时是未知的。(这是前面提到的 dyn 兼容性规则之一。)
其他重复之处包括 Post 上 request_review 和 approve 方法的相似实现。两个方法都对 Post 的 state 字段使用 Option::take,如果 state 是 Some,就委托给被包装值的同名方法实现,并将 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);
}
}
content 方法的 Post 和没有 content 方法的 DraftPostPost 和 DraftPost 结构体都有一个私有的 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,
}
}
}
DraftPost 上调用 request_review 创建的 PendingReviewPost,以及将 PendingReviewPost 转变为已发布 Post 的 approve 方法request_review 和 approve 方法获取 self 的所有权,从而消费 DraftPost 和 PendingReviewPost 实例,并分别将它们转换为 PendingReviewPost 和已发布的 Post。这样,在我们对 DraftPost 调用 request_review 之后,就不会有任何残留的 DraftPost 实例,以此类推。PendingReviewPost 结构体没有定义 content 方法,所以尝试读取其内容会导致编译器错误,就像 DraftPost 一样。因为获得一个定义了 content 方法的已发布 Post 实例的唯一方式是在 PendingReviewPost 上调用 approve 方法,而获得 PendingReviewPost 的唯一方式是在 DraftPost 上调用 request_review 方法,我们现在已经将博客文章工作流编码到了类型系统中。
但我们也必须对 main 做一些小改动。request_review 和 approve 方法返回新实例而不是修改它们被调用的结构体,所以我们需要添加更多的 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 的修改,以使用博客文章工作流的新实现我们需要对 main 做的重新赋值 post 的改动意味着,这个实现不再完全遵循面向对象的状态模式了:状态之间的转换不再完全封装在 Post 的实现内部。然而,我们的收获是,由于类型系统和编译时的类型检查,无效状态现在变得不可能了!这确保了某些 bug,比如显示未发布文章的内容,会在它们进入生产环境之前就被发现。
试试在 Listing 18-21 之后的 blog crate 上完成本节开头建议的那些任务,看看你对这个版本代码的设计有什么看法。注意,其中一些任务在这个设计中可能已经完成了。
我们已经看到,尽管 Rust 能够实现面向对象设计模式,但其他模式,比如将状态编码到类型系统中,在 Rust 中同样可用。这些模式有不同的取舍。虽然你可能非常熟悉面向对象模式,但重新思考问题以利用 Rust 的特性可以带来好处,比如在编译时就防止某些 bug。由于所有权等面向对象语言所没有的特性,面向对象模式并不总是 Rust 中的最佳方案。
总结
无论你在阅读本章之后是否认为 Rust 是一门面向对象的语言,你现在都知道可以使用 trait 对象在 Rust 中获得一些面向对象的特性。动态分发可以为你的代码提供一些灵活性,但需要以少量运行时性能为代价。你可以利用这种灵活性来实现有助于代码可维护性的面向对象模式。Rust 还有其他面向对象语言所没有的特性,比如所有权。面向对象模式并不总是利用 Rust 优势的最佳方式,但它是一个可用的选项。
接下来,我们将学习模式(pattern),这是 Rust 的另一个提供大量灵活性的特性。我们在全书中已经简要地接触过它们,但还没有看到它们的全部能力。让我们开始吧!
模式和匹配
模式(patterns)是 Rust 中一种特殊的语法,用于匹配类型的结构,无论是复杂类型还是简单类型。将模式与 match 表达式及其他构造结合使用,可以更好地控制程序的控制流。一个模式由以下内容的某种组合构成:
- 字面量
- 解构的数组、枚举、结构体或元组
- 变量
- 通配符
- 占位符
一些模式的例子包括 x、(a, 3) 和 Some(Color::Red)。在模式有效的上下文中,这些组件描述了数据的形状。程序接着将值与模式进行匹配,以判断是否具有正确的数据形状,从而继续运行某段特定的代码。
要使用模式,我们将其与某个值进行比较。如果模式与值匹配,就在代码中使用该值的各个部分。回忆一下第六章中使用模式的 match 表达式,例如硬币分拣机的例子。如果值符合模式的形状,就可以使用其中的命名部分。如果不符合,与该模式关联的代码就不会运行。
本章是关于模式的全面参考。我们将介绍使用模式的有效位置、可反驳(refutable)与不可反驳(irrefutable)模式之间的区别,以及你可能会见到的各种模式语法。读完本章后,你将学会如何使用模式来清晰地表达许多概念。
所有可以使用模式的位置
所有可以使用模式的位置
模式(pattern)在 Rust 中随处可见,而你其实已经在不知不觉中大量使用了它们!本节将讨论所有可以使用模式的位置。
match 分支
如第六章所述,我们在 match 表达式的分支中使用模式。从形式上讲,match 表达式的定义是:关键字 match、一个要匹配的值,以及一个或多个匹配分支——每个分支由一个模式和一个表达式组成,当值与该分支的模式匹配时就会执行对应的表达式,如下所示:
match VALUE {
PATTERN => EXPRESSION,
PATTERN => EXPRESSION,
PATTERN => EXPRESSION,
}
例如,下面是示例 6-5 中的 match 表达式,它匹配变量 x 中的 Option<i32> 值:
match x {
None => None,
Some(i) => Some(i + 1),
}
这个 match 表达式中的模式是每个箭头左边的 None 和 Some(i)。
match 表达式的一个要求是必须穷尽(exhaustive)所有可能性,即 match 表达式中值的所有可能情况都必须被覆盖。确保覆盖所有可能性的一种方法是在最后一个分支使用一个通配模式:例如,一个匹配任意值的变量名永远不会失败,因此可以覆盖所有剩余情况。
特殊模式 _ 可以匹配任何值,但它不会绑定到变量,因此常用于最后一个匹配分支。例如,当你想忽略所有未指定的值时,_ 模式就很有用。我们将在本章后面的“忽略模式中的值”部分更详细地介绍 _ 模式。
let 语句
在本章之前,我们只明确讨论过在 match 和 if let 中使用模式,但实际上我们在其他地方也用过模式,包括 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 的模式匹配特性,考虑示例 19-1,它使用 let 中的模式来解构一个元组。
fn main() {
let (x, y, z) = (1, 2, 3);
}
这里我们将一个元组与一个模式进行匹配。Rust 将值 (1, 2, 3) 与模式 (x, y, z) 进行比较,发现值与模式匹配——也就是说,两者的元素数量相同——于是 Rust 将 1 绑定到 x,2 绑定到 y,3 绑定到 z。你可以把这个元组模式看作是三个独立的变量模式嵌套在一起。
如果模式中的元素数量与元组中的元素数量不匹配,整体类型就不匹配,我们会得到一个编译器错误。例如,示例 19-2 展示了尝试将一个包含三个元素的元组解构为两个变量的情况,这是行不通的。
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
要修复这个错误,我们可以使用 _ 或 .. 来忽略元组中的一个或多个值,正如你将在“忽略模式中的值”部分看到的那样。如果问题是模式中变量太多,解决方案是通过移除变量来使类型匹配,使变量数量等于元组中的元素数量。
条件 if let 表达式
在第六章中,我们讨论了如何使用 if let 表达式,它主要是编写只匹配一种情况的 match 的简写方式。if let 可以有一个对应的 else,当 if let 中的模式不匹配时执行 else 中的代码。
示例 19-3 展示了还可以混合使用 if let、else if 和 else if let 表达式。这样做比 match 表达式更灵活,因为 match 只能将一个值与模式进行比较。此外,Rust 并不要求一系列 if let、else if 和 else if let 分支中的条件彼此相关。
示例 19-3 中的代码根据一系列条件检查来决定将背景设置为什么颜色。在这个例子中,我们创建了带有硬编码值的变量,而实际程序中这些值可能来自用户输入。
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");
}
}
if let、else if、else if let 和 else如果用户指定了最喜欢的颜色,就用该颜色作为背景。如果没有指定最喜欢的颜色且今天是星期二,背景颜色就是绿色。否则,如果用户以字符串形式指定了年龄并且我们能成功将其解析为数字,颜色就是紫色或橙色,取决于数字的值。如果以上条件都不满足,背景颜色就是蓝色。
这种条件结构让我们能够支持复杂的需求。使用这里的硬编码值,这个例子会打印 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 循环在模式持续匹配时一直运行。示例 19-4 展示了一个 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}");
}
}
while let 循环在 rx.recv() 返回 Ok 时持续打印值这个例子会打印 1、2,然后是 3。recv 方法从通道的接收端取出第一条消息并返回 Ok(value)。当我们在第十六章首次看到 recv 时,我们直接对错误进行了解包,或者通过 for 循环将其作为迭代器使用。但如示例 19-4 所示,我们也可以使用 while let,因为每当有消息到达时 recv 方法就返回 Ok(只要发送端存在),而一旦发送端断开连接就会产生 Err。
for 循环
在 for 循环中,紧跟在关键字 for 后面的值就是一个模式。例如,在 for x in y 中,x 就是模式。示例 19-5 演示了如何在 for 循环中使用模式来解构(或拆开)一个元组。
fn main() {
let v = vec!['a', 'b', 'c'];
for (index, value) in v.iter().enumerate() {
println!("{value} is at index {index}");
}
}
for 循环中使用模式来解构元组示例 19-5 中的代码会打印如下内容:
$ 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 将是 0,value 将是 'a',从而打印出输出的第一行。
函数参数
函数参数也可以是模式。示例 19-6 中的代码声明了一个名为 foo 的函数,它接受一个类型为 i32 的参数 x,现在看起来应该很熟悉了。
fn foo(x: i32) {
// code goes here
}
fn main() {}
x 部分就是一个模式!正如我们对 let 所做的那样,我们可以在函数参数中将元组与模式进行匹配。示例 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 的值是 3,y 的值是 5。
我们也可以在闭包参数列表中使用模式,方式与函数参数列表相同,因为闭包与函数类似,正如第十三章所讨论的那样。
至此,你已经看到了使用模式的多种方式,但模式在每个可以使用它们的地方并不是以相同的方式工作的。在某些地方,模式必须是不可反驳的(irrefutable);而在另一些情况下,它们可以是可反驳的(refutable)。接下来我们将讨论这两个概念。
可反驳性:模式是否可能匹配失败
可反驳性:模式是否可能匹配失败
模式有两种形式:可反驳的(refutable)和不可反驳的(irrefutable)。对于任何可能传入的值都能匹配成功的模式是不可反驳的。例如 let x = 5; 语句中的 x,因为 x 可以匹配任何值,所以不可能匹配失败。而对于某些可能的值会匹配失败的模式则是可反驳的。例如 if let Some(x) = a_value 表达式中的 Some(x),如果 a_value 变量中的值是 None 而不是 Some,那么 Some(x) 模式就无法匹配。
函数参数、let 语句和 for 循环只能接受不可反驳的模式,因为当值不匹配时,程序无法执行任何有意义的操作。if let 和 while let 表达式以及 let...else 语句则同时接受可反驳和不可反驳的模式,不过编译器会对不可反驳的模式发出警告,因为根据定义,它们本就是用来处理可能失败的情况的:条件语句的意义就在于能够根据匹配成功或失败来执行不同的操作。
通常你不需要担心可反驳和不可反驳模式之间的区别;但你确实需要熟悉可反驳性的概念,这样当你在错误信息中看到它时才能做出正确的应对。在这些情况下,你需要根据代码的预期行为,修改模式本身或者修改使用模式的语法结构。
让我们来看一个例子,看看当我们尝试在 Rust 要求不可反驳模式的地方使用可反驳模式时会发生什么,反之亦然。示例 19-8 展示了一个 let 语句,但我们为模式指定了 Some(x)——一个可反驳的模式。如你所料,这段代码无法编译。
fn main() {
let some_option_value: Option<i32> = None;
let Some(x) = some_option_value;
}
let 中使用可反驳模式如果 some_option_value 的值是 None,它就无法匹配 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 理所当然地产生了编译错误。
如果我们在需要不可反驳模式的地方使用了可反驳模式,可以通过修改使用模式的代码来修复:不使用 let,而是使用 let...else。这样,如果模式不匹配,花括号中的代码就会处理该值。示例 19-9 展示了如何修复示例 19-8 中的代码。
fn main() {
let some_option_value: Option<i32> = None;
let Some(x) = some_option_value else {
return;
};
}
let...else 和代码块来处理可反驳模式,而非使用 let我们给代码提供了一条出路!这段代码现在完全合法了,不过这也意味着我们不能在不收到警告的情况下使用不可反驳模式。如果我们给 let...else 一个总是能匹配的模式,比如 x,如示例 19-10 所示,编译器会发出警告。
fn main() {
let x = 5 else {
return;
};
}
let...else 中使用不可反驳模式Rust 会提示在 let...else 中使用不可反驳模式是没有意义的:
$ cargo run
Compiling patterns v0.1.0 (file:///projects/patterns)
warning: irrefutable `let...else` pattern
--> src/main.rs:2:5
|
2 | let x = 5 else {
| ^^^^^^^^^
|
= note: this pattern will always match, so the `else` clause is useless
= help: consider removing the `else` clause
= 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`
因此,match 的分支必须使用可反驳模式,最后一个分支除外——它应该使用不可反驳模式来匹配所有剩余的值。Rust 允许我们在只有一个分支的 match 中使用不可反驳模式,但这种语法并不是特别有用,完全可以用更简单的 let 语句来替代。
现在你已经知道了在哪里使用模式以及可反驳模式与不可反驳模式之间的区别,接下来让我们来介绍可以用于创建模式的所有语法。
模式语法
模式语法
在本节中,我们将汇总所有在模式中有效的语法,并讨论为什么以及何时你可能会用到它们。
匹配字面量
如你在第六章中所见,可以直接用模式匹配字面量。以下代码给出了一些示例:
fn main() {
let x = 1;
match x {
1 => println!("one"),
2 => println!("two"),
3 => println!("three"),
_ => println!("anything"),
}
}
这段代码会打印 one,因为 x 的值是 1。当你希望代码在获得某个特定具体值时执行某个操作时,这种语法非常有用。
匹配命名变量
命名变量是不可反驳的模式,可以匹配任何值,我们在本书中已经多次使用过。然而,在 match、if let 或 while let 表达式中使用命名变量时会有一个复杂之处。由于这些表达式都会开启一个新的作用域,在表达式内部作为模式一部分声明的变量会遮蔽(shadow)外部同名的变量,这与所有变量的行为一致。在示例 19-11 中,我们声明了一个值为 Some(5) 的变量 x 和一个值为 10 的变量 y。然后我们对 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 表达式,其中一个分支引入了一个新变量,遮蔽了已有的变量 y让我们逐步分析 match 表达式运行时发生了什么。第一个匹配分支的模式与 x 的定义值不匹配,所以代码继续执行。
第二个匹配分支引入了一个新变量 y,它会匹配 Some 中的任何值。因为我们处于 match 表达式内部的新作用域中,这是一个新的 y 变量,而不是我们在开头声明的值为 10 的那个 y。这个新的 y 绑定会匹配 Some 中的任何值,而 x 正是一个 Some 值。因此,这个新的 y 绑定到了 x 中 Some 的内部值。该值是 5,所以这个分支的表达式执行并打印 Matched, y = 5。
如果 x 是 None 而不是 Some(5),前两个分支的模式都不会匹配,值将匹配到下划线分支。我们没有在下划线分支的模式中引入 x 变量,所以表达式中的 x 仍然是未被遮蔽的外部 x。在这种假设情况下,match 会打印 Default case, x = None。
当 match 表达式执行完毕后,其作用域结束,内部 y 的作用域也随之结束。最后的 println! 输出 at the end: x = Some(5), y = 10。
要创建一个比较外部 x 和 y 值的 match 表达式,而不是引入一个遮蔽已有 y 的新变量,我们需要使用匹配守卫条件。我们将在后面的“使用匹配守卫添加额外条件”一节中讨论匹配守卫。
匹配多个模式
在 match 表达式中,可以使用 | 语法匹配多个模式,| 是模式的或运算符。例如,在下面的代码中,我们将 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"),
}
}
如果 x 是 1、2、3、4 或 5,第一个分支就会匹配。与使用 | 运算符表达相同意思相比,这种语法更加方便;如果使用 |,我们就得写成 1 | 2 | 3 | 4 | 5。指定范围要简短得多,特别是当我们想匹配比如 1 到 1000 之间的任何数字时!
编译器会在编译时检查范围是否为空,而 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。
解构以分解值
我们还可以使用模式来解构结构体、枚举和元组,以使用这些值的不同部分。让我们逐一介绍。
结构体
示例 19-12 展示了一个包含两个字段 x 和 y 的 Point 结构体,我们可以通过 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);
}
这段代码创建了变量 a 和 b,分别匹配 p 结构体的 x 和 y 字段的值。这个例子表明模式中的变量名不必与结构体的字段名相同。不过,通常会让变量名与字段名一致,以便更容易记住哪个变量来自哪个字段。由于这种用法很常见,而且写成 let Point { x: x, y: y } = p; 包含大量重复,Rust 为匹配结构体字段的模式提供了简写形式:只需列出结构体字段的名称,模式创建的变量就会具有相同的名称。示例 19-13 的行为与示例 19-12 中的代码相同,但 let 模式中创建的变量是 x 和 y,而不是 a 和 b。
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);
}
这段代码创建了变量 x 和 y,分别匹配 p 变量的 x 和 y 字段。结果是变量 x 和 y 包含了 p 结构体中的值。
我们也可以在结构体模式中使用字面量值进行解构,而不是为所有字段创建变量。这样做允许我们测试某些字段是否为特定值,同时为其他字段创建变量来解构。
在示例 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,并为 x 和 y 字段都创建了变量。
在这个例子中,值 p 匹配第二个分支,因为 x 包含 0,所以这段代码会打印 On the y axis at 7。
请记住,match 表达式在找到第一个匹配的模式后就会停止检查后续分支,所以即使 Point { x: 0, y: 0 } 同时在 x 轴和 y 轴上,这段代码也只会打印 On the x axis at 0。
枚举
我们在本书中已经解构过枚举(例如第六章的示例 6-5),但还没有明确讨论过解构枚举的模式与枚举中数据的定义方式是对应的。作为示例,在示例 19-15 中,我们使用示例 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,我们可以使用类似于匹配结构体的模式。在变体名称之后,我们放置花括号并列出带有变量的字段,以便将各部分拆解出来在该分支的代码中使用。这里我们使用了与示例 19-13 中相同的简写形式。
对于类似元组的枚举变体,如持有一个元素的元组的 Message::Write 和持有三个元素的元组的 Message::ChangeColor,其模式类似于匹配元组的模式。模式中的变量数量必须与我们匹配的变体中的元素数量一致。
嵌套的结构体和枚举
到目前为止,我们的示例都是匹配一层深度的结构体或枚举,但匹配也可以作用于嵌套的项!例如,我们可以重构示例 19-15 中的代码,在 ChangeColor 消息中支持 RGB 和 HSV 颜色,如示例 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 的最后一个分支中,获得一个不做任何事情但能涵盖所有剩余可能值的通配分支。有几种方式可以忽略模式中的整个值或部分值:使用 _ 模式(你已经见过)、在另一个模式中使用 _ 模式、使用以下划线开头的名称、或者使用 .. 来忽略值的剩余部分。让我们来探索如何以及为什么使用这些模式。
使用 _ 忽略整个值
我们已经使用过下划线作为通配模式,它可以匹配任何值但不绑定到该值。这在 match 表达式的最后一个分支中特别有用,但我们也可以在任何模式中使用它,包括函数参数,如示例 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 时需要特定的类型签名,但你的实现中函数体不需要其中某个参数。这样可以避免编译器发出未使用函数参数的警告,而如果使用一个名称则会触发警告。
使用嵌套的 _ 忽略值的部分
我们也可以在另一个模式内部使用 _ 来只忽略值的一部分,例如当我们只想测试值的一部分,而在要运行的相应代码中不需要其他部分时。示例 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:?}");
}
Some 内部值时,在匹配 Some 变体的模式中使用下划线这段代码会打印 Can't overwrite an existing customized value,然后打印 setting is Some(5)。在第一个匹配分支中,我们不需要匹配或使用任何一个 Some 变体内部的值,但我们确实需要测试 setting_value 和 new_setting_value 都是 Some 变体的情况。在这种情况下,我们打印不修改 setting_value 的原因,并且它不会被修改。
在所有其他情况下(如果 setting_value 或 new_setting_value 中任一为 None),由第二个分支中的 _ 模式表示,我们希望允许 new_setting_value 成为 setting_value。
我们也可以在一个模式中的多个位置使用下划线来忽略特定的值。示例 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,值 4 和 16 会被忽略。
通过以 _ 开头的名称忽略未使用的变量
如果你创建了一个变量但没有在任何地方使用它,Rust 通常会发出警告,因为未使用的变量可能是一个 bug。然而,有时能够创建一个暂时不会使用的变量是很有用的,例如当你在做原型开发或刚开始一个项目时。在这种情况下,你可以通过让变量名以下划线开头来告诉 Rust 不要警告你这个未使用的变量。在示例 19-20 中,我们创建了两个未使用的变量,但编译这段代码时,我们应该只会收到关于其中一个的警告。
fn main() {
let _x = 5;
let y = 10;
}
这里我们收到了关于未使用变量 y 的警告,但没有收到关于 _x 的警告。
注意,只使用 _ 和使用以下划线开头的名称之间有一个微妙的区别。语法 _x 仍然会将值绑定到变量,而 _ 则完全不绑定。为了展示这个区别的重要性,示例 19-21 会给我们一个错误。
fn main() {
let s = Some(String::from("Hello!"));
if let Some(_s) = s {
println!("found a string");
}
println!("{s:?}");
}
我们会收到一个错误,因为 s 的值仍然会被移动到 _s 中,这阻止了我们再次使用 s。然而,单独使用下划线永远不会绑定到值。示例 19-22 可以编译通过,因为 s 不会被移动到 _ 中。
fn main() {
let s = Some(String::from("Hello!"));
if let Some(_) = s {
println!("found a string");
}
println!("{s:?}");
}
这段代码完全没有问题,因为我们从未将 s 绑定到任何东西;它没有被移动。
使用 .. 忽略值的剩余部分
对于有很多部分的值,我们可以使用 .. 语法来只使用特定部分而忽略其余部分,从而避免为每个被忽略的值列出下划线。.. 模式会忽略我们在模式其余部分中没有显式匹配的任何部分。在示例 19-23 中,我们有一个在三维空间中保存坐标的 Point 结构体。在 match 表达式中,我们只想操作 x 坐标,而忽略 y 和 z 字段的值。
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}"),
}
}
.. 忽略 Point 中除 x 以外的所有字段我们列出了 x 的值,然后只包含了 .. 模式。这比必须列出 y: _ 和 z: _ 要快捷得多,特别是当我们处理有很多字段的结构体而只有一两个字段相关时。
.. 语法会扩展为所需数量的值。示例 19-24 展示了如何在元组中使用 ..。
fn main() {
let numbers = (2, 4, 8, 16, 32);
match numbers {
(first, .., last) => {
println!("Some numbers: {first}, {last}");
}
}
}
在这段代码中,第一个和最后一个值分别与 first 和 last 匹配。.. 会匹配并忽略中间的所有值。
然而,使用 .. 必须是无歧义的。如果不清楚哪些值用于匹配、哪些应该被忽略,Rust 会给出错误。示例 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,然后忽略 8、16 和 32;也可能意味着我们想忽略 2 和 4,将 second 绑定到 8,然后忽略 16 和 32;等等。变量名 second 对 Rust 来说没有任何特殊含义,所以我们会得到一个编译器错误,因为在两个位置使用 .. 是有歧义的。
使用匹配守卫添加额外条件
匹配守卫(match guard)是在 match 分支的模式之后指定的额外 if 条件,该条件也必须匹配才能选择该分支。匹配守卫对于表达比单独模式更复杂的逻辑非常有用。不过请注意,匹配守卫只能在 match 表达式中使用,不能在 if let 或 while let 表达式中使用。
条件可以使用模式中创建的变量。示例 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,因为确实如此,所以选择了第一个分支。
如果 num 是 Some(5),第一个分支中的匹配守卫将为 false,因为 5 除以 2 的余数是 1,不等于 0。Rust 接着会转到第二个分支,该分支会匹配,因为第二个分支没有匹配守卫,因此匹配任何 Some 变体。
无法在模式内部表达 if x % 2 == 0 这个条件,所以匹配守卫赋予了我们表达这种逻辑的能力。这种额外表达力的缺点是,当涉及匹配守卫表达式时,编译器不会尝试检查穷尽性。
在讨论示例 19-11 时,我们提到可以使用匹配守卫来解决模式遮蔽问题。回忆一下,我们在 match 表达式的模式中创建了一个新变量,而不是使用 match 外部的变量。那个新变量意味着我们无法针对外部变量的值进行测试。示例 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(n) 而不是 Some(y)(后者会遮蔽外部的 y)。这创建了一个新变量 n,它不会遮蔽任何东西,因为 match 外部没有 n 变量。
匹配守卫 if n == y 不是一个模式,因此不会引入新变量。这里的 y 就是外部的 y,而不是一个新的遮蔽 y,我们可以通过比较 n 和 y 来查找与外部 y 具有相同值的值。
你也可以在匹配守卫中使用或运算符 | 来指定多个模式;匹配守卫条件将应用于所有模式。示例 19-28 展示了将使用 | 的模式与匹配守卫组合时的优先级。这个例子的重要之处在于,if y 匹配守卫应用于 4、5 和 6,即使看起来 if y 只应用于 6。
fn main() {
let x = 4;
let y = false;
match x {
4 | 5 | 6 if y => println!("yes"),
_ => println!("no"),
}
}
匹配条件表明该分支只在 x 的值等于 4、5 或 6 并且 y 为 true 时才匹配。当这段代码运行时,第一个分支的模式匹配了,因为 x 是 4,但匹配守卫 if y 为 false,所以第一个分支没有被选择。代码转到第二个分支,它匹配了,程序打印 no。原因是 if 条件应用于整个模式 4 | 5 | 6,而不仅仅是最后一个值 6。换句话说,匹配守卫相对于模式的优先级行为如下:
(4 | 5 | 6) if y => ...
而不是这样:
4 | 5 | (6 if y) => ...
运行代码后,优先级行为就很明显了:如果匹配守卫只应用于使用 | 运算符指定的值列表中的最后一个值,那么该分支就会匹配,程序就会打印 yes。
使用 @ 绑定
at 运算符 @ 允许我们在测试一个值是否匹配模式的同时,创建一个保存该值的变量。在示例 19-29 中,我们想测试 Message::Hello 的 id 字段是否在 3..=7 范围内。我们还想将该值绑定到变量 id,以便在与该分支关联的代码中使用它。
fn main() {
enum Message {
Hello { id: i32 },
}
let msg = Message::Hello { id: 5 };
match msg {
Message::Hello { id: id @ 3..=7 } => {
println!("Found an id in range: {id}")
}
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 @,我们在测试值是否匹配范围模式的同时,将匹配到的值捕获到了名为 id 的变量中。
在第二个分支中,模式里只指定了一个范围,与该分支关联的代码没有一个包含 id 字段实际值的变量。id 字段的值可能是 10、11 或 12,但与该模式对应的代码不知道具体是哪个。模式代码无法使用 id 字段的值,因为我们没有将 id 值保存到变量中。
在最后一个分支中,我们指定了一个没有范围的变量,此时我们确实可以在该分支的代码中使用名为 id 的变量中的值。原因是我们使用了结构体字段简写语法。但我们没有像前两个分支那样对 id 字段的值应用任何测试:任何值都会匹配这个模式。
使用 @ 可以让我们在一个模式中同时测试一个值并将其保存到变量中。
总结
Rust 的模式在区分不同类型的数据方面非常有用。当在 match 表达式中使用时,Rust 会确保你的模式覆盖了每一个可能的值,否则程序将无法编译。let 语句和函数参数中的模式使这些构造更加有用,能够将值解构为更小的部分并将这些部分赋值给变量。我们可以创建简单或复杂的模式来满足我们的需求。
接下来,在本书倒数第二章中,我们将探讨 Rust 各种特性的一些高级方面。
高级特性
到目前为止,你已经学习了 Rust 编程语言中最常用的部分。在第 21 章开始另一个项目之前,我们先来看看你可能偶尔会遇到但不一定每天都会用到的一些语言特性。当你碰到任何不熟悉的内容时,可以把本章当作参考。这里介绍的特性在一些非常特定的场景下很有用。虽然你可能不会经常用到它们,但我们希望确保你了解 Rust 所提供的全部特性。
本章将涵盖以下内容:
- 不安全 Rust(Unsafe Rust):如何选择退出 Rust 的某些安全保证,并由你自己负责手动维护这些保证
- 高级 trait:关联类型、默认类型参数、完全限定语法、超级 trait(supertrait),以及与 trait 相关的 newtype 模式
- 高级类型:更多关于 newtype 模式、类型别名、never 类型和动态大小类型的内容
- 高级函数和闭包:函数指针与返回闭包
- 宏(Macros):在编译时定义能生成更多代码的代码的方式
这是 Rust 特性的一场盛宴,每个人都能从中找到感兴趣的内容!让我们开始吧!
不安全的 Rust
不安全的 Rust
到目前为止,我们讨论的所有代码都在编译时强制执行了 Rust 的内存安全保证。然而,Rust 内部还隐藏着另一种语言,它不强制执行这些内存安全保证:这就是所谓的不安全 Rust(unsafe Rust),它的工作方式与常规 Rust 一样,但赋予了我们额外的超能力。
不安全 Rust 之所以存在,是因为静态分析本质上是保守的。当编译器试图判断代码是否遵守了安全保证时,拒绝一些合法的程序总比接受一些非法的程序要好。虽然代码可能没有问题,但如果 Rust 编译器没有足够的信息来确认,它就会拒绝该代码。在这种情况下,你可以使用不安全代码来告诉编译器:“相信我,我知道自己在做什么。“但请注意,使用不安全 Rust 的风险由你自己承担:如果不安全代码使用不当,就可能出现内存不安全导致的问题,例如空指针解引用。
Rust 拥有不安全的另一面还有一个原因,那就是底层计算机硬件本质上就是不安全的。如果 Rust 不允许你执行不安全操作,你就无法完成某些任务。Rust 需要允许你进行底层系统编程,例如直接与操作系统交互,甚至编写自己的操作系统。底层系统编程正是这门语言的目标之一。接下来让我们探索不安全 Rust 能做什么以及如何使用它。
执行不安全的超能力
要切换到不安全 Rust,请使用 unsafe 关键字,然后开始一个包含不安全代码的新代码块。在不安全 Rust 中,你可以执行五种在安全 Rust 中无法执行的操作,我们称之为不安全超能力(unsafe superpowers)。这些超能力包括:
- 解引用裸指针。
- 调用不安全的函数或方法。
- 访问或修改可变的静态变量。
- 实现不安全的 trait。
- 访问
union的字段。
理解这一点很重要:unsafe 并不会关闭借用检查器或禁用 Rust 的其他安全检查。如果你在不安全代码中使用引用,它仍然会被检查。unsafe 关键字只是让你能够访问上述五种特性,而这些特性不会被编译器进行内存安全检查。你在不安全代码块中仍然能获得一定程度的安全保障。
此外,unsafe 并不意味着代码块中的代码一定是危险的,或者一定会出现内存安全问题。其意图是,作为程序员,你将确保 unsafe 代码块中的代码以合法的方式访问内存。
人都会犯错,错误在所难免,但通过要求这五种不安全操作必须放在标注了 unsafe 的代码块中,你就能知道任何与内存安全相关的错误一定在 unsafe 代码块内。保持 unsafe 代码块尽可能小,这样在排查内存错误时你会感到庆幸。
为了尽可能隔离不安全代码,最好将这类代码封装在安全的抽象中,并提供安全的 API。我们将在本章后面讨论不安全函数和方法时详细介绍这一点。标准库的部分功能就是作为经过审计的不安全代码之上的安全抽象来实现的。将不安全代码包装在安全抽象中,可以防止 unsafe 的使用泄漏到所有你或你的用户可能想要使用 unsafe 代码实现的功能的地方,因为使用安全抽象本身是安全的。
让我们依次看看这五种不安全超能力。我们还将介绍一些为不安全代码提供安全接口的抽象。
解引用裸指针
在第四章的“悬垂引用”部分,我们提到编译器会确保引用始终有效。不安全 Rust 有两种新的类型,称为裸指针(raw pointers),它们类似于引用。与引用一样,裸指针可以是不可变的或可变的,分别写作 *const T 和 *mut T。这里的星号不是解引用运算符,而是类型名称的一部分。在裸指针的上下文中,不可变意味着指针在被解引用后不能直接赋值。
与引用和智能指针不同,裸指针:
- 允许忽略借用规则,可以同时拥有指向同一位置的不可变和可变指针,或者多个可变指针
- 不保证指向有效的内存
- 允许为空
- 不实现任何自动清理
通过放弃让 Rust 强制执行这些保证,你可以用安全保障来换取更高的性能,或者与另一种语言或硬件进行交互——在这些场景中 Rust 的保证并不适用。
示例 20-1 展示了如何创建一个不可变和一个可变的裸指针。
fn main() {
let mut num = 5;
let r1 = &raw const num;
let r2 = &raw mut num;
}
注意这段代码中没有包含 unsafe 关键字。我们可以在安全代码中创建裸指针,只是不能在不安全代码块之外解引用裸指针,稍后你就会看到。
我们使用裸借用运算符创建了裸指针:&raw const num 创建了一个 *const i32 类型的不可变裸指针,&raw mut num 创建了一个 *mut i32 类型的可变裸指针。因为我们直接从局部变量创建了它们,所以我们知道这些特定的裸指针是有效的,但不能对任意裸指针都做这样的假设。
为了演示这一点,接下来我们将创建一个无法确定其有效性的裸指针,使用 as 关键字进行类型转换而不是使用裸借用运算符。示例 20-2 展示了如何创建一个指向内存中任意位置的裸指针。尝试使用任意内存是未定义行为:该地址处可能有数据也可能没有,编译器可能会优化代码使得没有内存访问,或者程序可能因段错误而终止。通常没有充分的理由编写这样的代码,特别是在可以使用裸借用运算符的情况下,但这确实是可能的。
fn main() {
let address = 0x012345usize;
let r = address as *const i32;
}
回忆一下,我们可以在安全代码中创建裸指针,但不能解引用裸指针并读取其指向的数据。在示例 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);
}
}
unsafe 代码块中解引用裸指针创建指针本身不会造成任何危害;只有当我们尝试访问它所指向的值时,才可能遇到无效值的问题。
还要注意,在示例 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 代码块,就像在常规函数中一样,如果你忘记了,编译器会发出警告。这有助于我们保持 unsafe 代码块尽可能小,因为不安全操作可能并不需要覆盖整个函数体。
创建不安全代码之上的安全抽象
仅仅因为函数包含不安全代码,并不意味着我们需要将整个函数标记为不安全的。事实上,将不安全代码包装在安全函数中是一种常见的抽象方式。作为示例,让我们研究标准库中的 split_at_mut 函数,它需要用到一些不安全代码。我们将探索如何实现它。这个安全方法定义在可变切片上:它接受一个切片,并通过在给定索引处分割,将其变为两个切片。示例 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]);
}
split_at_mut 函数仅使用安全 Rust 无法实现这个函数。一种尝试可能如示例 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);
}
split_at_mut这个函数首先获取切片的总长度。然后通过检查参数给定的索引是否小于或等于长度,来断言该索引在切片范围内。这个断言意味着,如果我们传入一个大于长度的索引来分割切片,函数会在尝试使用该索引之前 panic。
接着,我们在一个元组中返回两个可变切片:一个从原始切片的开头到 mid 索引,另一个从 mid 到切片的末尾。
当我们尝试编译示例 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 不知道时,就该使用不安全代码了。
示例 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);
}
split_at_mut 函数的实现中使用不安全代码回忆第四章“切片类型”部分,切片是一个指向某些数据的指针加上切片的长度。我们使用 len 方法获取切片的长度,使用 as_mut_ptr 方法访问切片的裸指针。在这个例子中,因为我们有一个 i32 值的可变切片,as_mut_ptr 返回一个类型为 *mut i32 的裸指针,我们将其存储在变量 ptr 中。
我们保留了 mid 索引在切片范围内的断言。然后是不安全代码:slice::from_raw_parts_mut 函数接受一个裸指针和一个长度,并创建一个切片。我们用这个函数创建了一个从 ptr 开始、长度为 mid 的切片。然后我们以 mid 为参数调用 ptr 上的 add 方法来获取一个从 mid 开始的裸指针,并使用该指针和 mid 之后剩余元素的数量作为长度来创建一个切片。
slice::from_raw_parts_mut 函数是不安全的,因为它接受一个裸指针,并且必须信任该指针是有效的。裸指针上的 add 方法也是不安全的,因为它必须信任偏移位置也是一个有效的指针。因此,我们必须在 slice::from_raw_parts_mut 和 add 的调用周围放置 unsafe 代码块才能调用它们。通过查看代码并添加 mid 必须小于或等于 len 的断言,我们可以确定 unsafe 代码块中使用的所有裸指针都是指向切片内数据的有效指针。这是 unsafe 的一种可接受且恰当的用法。
注意我们不需要将最终的 split_at_mut 函数标记为 unsafe,并且可以从安全 Rust 中调用这个函数。我们创建了一个对不安全代码的安全抽象,其实现以安全的方式使用了 unsafe 代码,因为它只从该函数有权访问的数据中创建有效的指针。
相比之下,示例 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 关键字,用于创建和使用外部函数接口(Foreign Function Interface,FFI),这是一种编程语言定义函数并允许不同(外部)编程语言调用这些函数的方式。
示例 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));
}
}
extern 函数在 unsafe extern "C" 块中,我们列出了想要调用的来自其他语言的外部函数的名称和签名。"C" 部分定义了外部函数使用的应用程序二进制接口(application binary interface,ABI):ABI 定义了如何在汇编层面调用函数。"C" ABI 是最常见的,遵循 C 编程语言的 ABI。关于 Rust 支持的所有 ABI 的信息,可以在 Rust 参考手册中找到。
在 unsafe extern 块中声明的每个条目都隐式地是不安全的。然而,有些 FFI 函数确实可以安全调用。例如,C 标准库中的 abs 函数没有任何内存安全方面的顾虑,并且我们知道它可以用任何 i32 来调用。在这种情况下,我们可以使用 safe 关键字来声明这个特定的函数是安全可调用的,即使它在 unsafe extern 块中。一旦做了这个更改,调用它就不再需要 unsafe 代码块了,如示例 20-9 所示。
unsafe extern "C" {
safe fn abs(input: i32) -> i32;
}
fn main() {
println!("Absolute value of -3 according to C: {}", abs(-3));
}
unsafe extern 块中显式地将函数标记为 safe 并安全地调用它将函数标记为 safe 并不会使其本质上变得安全!相反,这就像你对 Rust 做出的一个承诺,保证它是安全的。确保这个承诺得到遵守仍然是你的责任!
从其他语言调用 Rust 函数
我们也可以使用 extern 来创建一个允许其他语言调用 Rust 函数的接口。我们不需要创建整个 extern 块,而是在相关函数的 fn 关键字之前添加 extern 关键字并指定要使用的 ABI。我们还需要添加 #[unsafe(no_mangle)] 注解来告诉 Rust 编译器不要修改(mangle)这个函数的名称。名称修改(Mangling)是编译器将我们给函数起的名称更改为包含更多信息的不同名称的过程,这些信息供编译过程的其他部分使用,但对人类来说可读性较差。每种编程语言的编译器对名称的修改方式略有不同,因此为了让 Rust 函数能被其他语言命名,我们必须禁用 Rust 编译器的名称修改。这是不安全的,因为在没有内置名称修改的情况下,不同库之间可能会出现名称冲突,所以确保导出的名称安全是我们的责任。
在下面的例子中,我们让 call_from_c 函数在编译为共享库并从 C 链接后,可以从 C 代码中访问:
#[unsafe(no_mangle)]
pub extern "C" fn call_from_c() {
println!("Just called a Rust function from C!");
}
这种 extern 的用法只需要在属性中使用 unsafe,而不需要在 extern 块上使用。
访问或修改可变的静态变量
在本书中,我们还没有讨论过全局变量。Rust 确实支持全局变量,但它们与 Rust 的所有权规则可能会产生冲突。如果两个线程访问同一个可变全局变量,就可能导致数据竞争。
在 Rust 中,全局变量被称为静态(static)变量。示例 20-10 展示了一个以字符串切片作为值的静态变量的声明和使用示例。
static HELLO_WORLD: &str = "Hello, world!";
fn main() {
println!("value is: {HELLO_WORLD}");
}
静态变量类似于常量,我们在第三章“声明常量”部分讨论过。按照惯例,静态变量的名称使用 SCREAMING_SNAKE_CASE 格式。静态变量只能存储具有 'static 生命周期的引用,这意味着 Rust 编译器可以自行推断生命周期,我们不需要显式标注。访问不可变的静态变量是安全的。
常量和不可变静态变量之间有一个微妙的区别:静态变量中的值在内存中有一个固定的地址。使用该值时总是访问相同的数据。而常量则允许在使用时复制其数据。另一个区别是静态变量可以是可变的。访问和修改可变静态变量是不安全的(unsafe)。示例 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 代码块中。示例 20-11 中的代码可以编译并打印 COUNTER: 3,正如我们所期望的那样,因为它是单线程的。如果多个线程访问 COUNTER,很可能会导致数据竞争,因此这是未定义行为。所以我们需要将整个函数标记为 unsafe,并记录安全限制,以便任何调用该函数的人知道哪些操作是安全的,哪些不是。
每当我们编写不安全函数时,惯例是编写一个以 SAFETY 开头的注释,解释调用者需要做什么才能安全地调用该函数。同样,每当我们执行不安全操作时,惯例是编写一个以 SAFETY 开头的注释,解释安全规则是如何被遵守的。
此外,编译器默认会通过编译器 lint 拒绝任何创建可变静态变量引用的尝试。你必须通过添加 #[allow(static_mut_refs)] 注解来显式选择退出该 lint 的保护,或者通过裸借用运算符创建的裸指针来访问可变静态变量。这包括引用被隐式创建的情况,例如在这段代码清单中的 println! 中使用时。要求通过裸指针创建对静态可变变量的引用,有助于使其安全要求更加明显。
对于全局可访问的可变数据,很难确保不存在数据竞争,这就是为什么 Rust 认为可变静态变量是不安全的。在可能的情况下,最好使用我们在第十六章中讨论的并发技术和线程安全的智能指针,这样编译器就能检查来自不同线程的数据访问是否安全。
实现不安全的 trait
我们可以使用 unsafe 来实现不安全的 trait。当一个 trait 的至少一个方法具有编译器无法验证的不变量时,该 trait 就是不安全的。我们通过在 trait 前添加 unsafe 关键字来声明一个 trait 是不安全的,并将该 trait 的实现也标记为 unsafe,如示例 20-12 所示。
unsafe trait Foo {
// methods go here
}
unsafe impl Foo for i32 {
// method implementations go here
}
fn main() {}
通过使用 unsafe impl,我们承诺将遵守编译器无法验证的不变量。
举个例子,回忆一下我们在第十六章“Send 和 Sync 的可扩展并发”部分讨论的 Send 和 Sync 标记 trait:如果我们的类型完全由实现了 Send 和 Sync 的其他类型组成,编译器会自动实现这些 trait。如果我们实现了一个包含未实现 Send 或 Sync 的类型(如裸指针)的类型,并且我们想将该类型标记为 Send 或 Sync,就必须使用 unsafe。Rust 无法验证我们的类型是否满足可以安全地跨线程发送或从多个线程访问的保证;因此,我们需要手动进行这些检查,并用 unsafe 来表明这一点。
访问联合体的字段
只能与 unsafe 一起使用的最后一种操作是访问联合体(union)的字段。联合体(union)类似于 struct,但在特定实例中同一时间只使用一个已声明的字段。联合体主要用于与 C 代码中的联合体进行交互。访问联合体的字段是不安全的,因为 Rust 无法保证当前存储在联合体实例中的数据类型。你可以在 Rust 参考手册中了解更多关于联合体的信息。
使用 Miri 检查不安全代码
编写不安全代码时,你可能想检查所写的代码是否确实安全且正确。最好的方法之一是使用 Miri,这是一个用于检测未定义行为的官方 Rust 工具。借用检查器是一个在编译时工作的静态工具,而 Miri 是一个在运行时工作的动态工具。它通过运行你的程序或测试套件来检查代码,并在你违反它所理解的 Rust 工作规则时进行检测。
使用 Miri 需要 Rust 的 nightly 版本(我们在附录 G:Rust 是如何构建的以及 “Nightly Rust”中有更多介绍)。你可以通过输入 rustup +nightly component add miri 来安装 Rust 的 nightly 版本和 Miri 工具。这不会改变你的项目使用的 Rust 版本;它只是将该工具添加到你的系统中,以便你在需要时使用。你可以通过输入 cargo +nightly miri run 或 cargo +nightly miri test 在项目上运行 Miri。
为了展示 Miri 有多大帮助,让我们看看对示例 20-7 运行它时会发生什么。
$ 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`
warning: integer-to-pointer cast
--> src/main.rs:5:13
|
5 | let r = address as *mut i32;
| ^^^^^^^^^^^^^^^^^^^ integer-to-pointer cast
|
= help: this program is using integer-to-pointer casts or (equivalently) `ptr::with_exposed_provenance`, which means that Miri might miss pointer bugs in this program
= help: see https://doc.rust-lang.org/nightly/std/ptr/fn.with_exposed_provenance.html for more details on that operation
= help: to ensure that Miri does not miss bugs in your program, use Strict Provenance APIs (https://doc.rust-lang.org/nightly/std/ptr/index.html#strict-provenance, https://crates.io/crates/sptr) instead
= help: you can then set `MIRIFLAGS=-Zmiri-strict-provenance` to ensure you are not relying on `with_exposed_provenance` semantics
= help: alternatively, `MIRIFLAGS=-Zmiri-permissive-provenance` disables this warning
= note: BACKTRACE:
= note: inside `main` at src/main.rs:5:13: 5:32
error: Undefined Behavior: pointer not dereferenceable: pointer must be dereferenceable for 40000 bytes, but got 0x1234[noalloc] which is a dangling pointer (it has no provenance)
--> src/main.rs:7:35
|
7 | let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here
|
= help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
= help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
= note: BACKTRACE:
= note: inside `main` at src/main.rs:7:35: 7:70
note: some details are omitted, run with `MIRIFLAGS=-Zmiri-backtrace=full` for a verbose backtrace
error: aborting due to 1 previous error; 1 warning emitted
Miri 正确地警告我们将整数转换为指针可能有问题,但 Miri 无法确定是否存在问题,因为它不知道该指针的来源。然后,Miri 在示例 20-7 存在未定义行为的地方返回了一个错误,因为我们有一个悬垂指针。感谢 Miri,我们现在知道存在未定义行为的风险,可以思考如何使代码变得安全。在某些情况下,Miri 甚至可以提供修复错误的建议。
Miri 并不能捕获你在编写不安全代码时可能犯的所有错误。Miri 是一个动态分析工具,所以它只能捕获实际运行的代码中的问题。这意味着你需要将它与良好的测试技术结合使用,以增强对所编写的不安全代码的信心。Miri 也不能覆盖代码可能不健全的所有方式。
换句话说:如果 Miri 确实捕获了一个问题,你就知道存在 bug,但仅仅因为 Miri 没有捕获 bug 并不意味着不存在问题。不过它确实能捕获很多问题。试着在本章其他不安全代码的示例上运行它,看看它会怎么说!
你可以在 Miri 的 GitHub 仓库中了解更多关于 Miri 的信息。
正确使用不安全代码
使用 unsafe 来执行刚才讨论的五种超能力并没有错,也不会受到非议,但要让 unsafe 代码正确运行确实更加棘手,因为编译器无法帮助维护内存安全。当你有理由使用 unsafe 代码时,你可以这样做,而显式的 unsafe 标注使得在问题发生时更容易追踪问题的根源。每当你编写不安全代码时,都可以使用 Miri 来帮助你更有信心地确认代码遵守了 Rust 的规则。
要更深入地探索如何有效地使用不安全 Rust,请阅读 Rust 的官方 unsafe 指南 The Rustonomicon。
高级 trait
高级 trait
我们在第十章的“定义共享行为的 trait” 部分首次介绍了 trait,但没有讨论更高级的细节。现在你对 Rust 有了更深入的了解,我们可以深入探讨这些细节了。
使用关联类型定义 trait
关联类型(associated types)将一个类型占位符与 trait 关联起来,使得 trait 的方法签名中可以使用这些占位符类型。trait 的实现者将为特定实现指定占位符类型所对应的具体类型。这样,我们就可以定义一个使用某些类型的 trait,而无需在实现该 trait 之前确切知道这些类型是什么。
我们已经将本章中的大多数高级特性描述为很少需要的。关联类型介于两者之间:它们的使用频率低于本书其余部分介绍的特性,但高于本章讨论的许多其他特性。
一个带有关联类型的 trait 的例子是标准库提供的 Iterator trait。其关联类型名为 Item,代表实现 Iterator trait 的类型所迭代的值的类型。Iterator trait 的定义如示例 20-13 所示。
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
Iterator trait 的定义,它有一个关联类型 ItemItem 类型是一个占位符,next 方法的定义表明它将返回 Option<Self::Item> 类型的值。Iterator trait 的实现者将为 Item 指定具体类型,而 next 方法将返回一个包含该具体类型值的 Option。
关联类型可能看起来与泛型(generics)概念类似,后者也允许我们定义一个函数而不指定它能处理的类型。为了理解这两个概念之间的区别,我们来看一个在名为 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 呢?如示例 20-14 所示。
pub trait Iterator<T> {
fn next(&mut self) -> Option<T>;
}
Iterator trait 假想定义区别在于,当使用泛型时(如示例 20-14),我们必须在每个实现中标注类型;因为我们也可以实现 Iterator<String> for Counter 或任何其他类型,所以可以为 Counter 提供多个 Iterator 的实现。换句话说,当一个 trait 有泛型参数时,可以为一个类型多次实现该 trait,每次改变泛型类型参数的具体类型。当我们在 Counter 上使用 next 方法时,就必须提供类型注解来指明我们想使用哪个 Iterator 实现。
而使用关联类型时,我们不需要标注类型,因为不能为一个类型多次实现同一个 trait。在示例 20-13 中使用关联类型的定义里,我们只能选择一次 Item 的类型,因为只能有一个 impl Iterator for Counter。我们不必在每次调用 Counter 的 next 方法时都指定我们想要一个 u32 值的迭代器。
关联类型也成为 trait 契约的一部分:trait 的实现者必须提供一个类型来替代关联类型占位符。关联类型通常有一个描述该类型用途的名称,在 API 文档中记录关联类型是一个好的实践。
默认泛型类型参数和运算符重载
当我们使用泛型类型参数时,可以为泛型类型指定一个默认的具体类型。这样,如果默认类型可用,trait 的实现者就无需指定具体类型。声明泛型类型时,使用 <PlaceholderType=ConcreteType> 语法来指定默认类型。
这种技术非常有用的一个典型场景是运算符重载(operator overloading),即在特定情况下自定义运算符(如 +)的行为。
Rust 不允许你创建自己的运算符或重载任意运算符。但你可以通过实现 std::ops 中列出的运算符对应的 trait 来重载这些运算和相应的 trait。例如,在示例 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 trait 来重载 Point 实例的 + 运算符add 方法将两个 Point 实例的 x 值和 y 值分别相加,创建一个新的 Point。Add 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:这种语法称为默认类型参数(default type parameters)。Rhs 泛型类型参数(“right-hand side” 的缩写,即“右侧“)定义了 add 方法中 rhs 参数的类型。如果我们在实现 Add trait 时没有为 Rhs 指定具体类型,Rhs 的类型将默认为 Self,也就是我们正在实现 Add 的那个类型。
当我们为 Point 实现 Add 时,使用了 Rhs 的默认值,因为我们想要将两个 Point 实例相加。让我们来看一个实现 Add trait 时自定义 Rhs 类型而不使用默认值的例子。
我们有两个结构体 Millimeters 和 Meters,分别持有不同单位的值。这种将已有类型薄薄地包装在另一个结构体中的做法被称为新类型模式(newtype pattern),我们将在“使用新类型模式实现外部 trait” 部分更详细地介绍。我们想要将毫米值与米值相加,并让 Add 的实现正确地进行转换。我们可以为 Millimeters 实现 Add,并将 Meters 作为 Rhs,如示例 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))
}
}
Millimeters 上实现 Add trait,以便将 Millimeters 和 Meters 相加为了将 Millimeters 和 Meters 相加,我们指定 impl Add<Meters> 来设置 Rhs 类型参数的值,而不是使用默认的 Self。
默认类型参数主要用于两种场景:
- 在不破坏现有代码的情况下扩展类型
- 允许在大多数用户不需要的特定场景中进行自定义
标准库的 Add trait 就是第二种用途的例子:通常你会将两个相同类型的值相加,但 Add trait 提供了超越这一点的自定义能力。在 Add trait 定义中使用默认类型参数意味着大多数时候你不需要指定额外的参数。换句话说,省去了一些实现样板代码,使得使用该 trait 更加方便。
第一种用途与第二种类似,但方向相反:如果你想为现有 trait 添加一个类型参数,可以给它一个默认值,这样就能在不破坏现有实现代码的情况下扩展 trait 的功能。
消除同名方法的歧义
Rust 并不阻止一个 trait 拥有与另一个 trait 同名的方法,也不阻止你在同一个类型上实现这两个 trait。甚至可以直接在类型上实现一个与 trait 方法同名的方法。
当调用同名方法时,你需要告诉 Rust 你想使用哪一个。考虑示例 20-17 中的代码,我们定义了两个 trait Pilot 和 Wizard,它们都有一个名为 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() {}
fly 方法的 trait 并在 Human 类型上实现,同时直接在 Human 上也实现了 fly 方法当我们在 Human 实例上调用 fly 时,编译器默认调用直接实现在该类型上的方法,如示例 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();
}
Human 实例上调用 fly运行这段代码会打印出 *waving arms furiously*,表明 Rust 调用了直接实现在 Human 上的 fly 方法。
要调用 Pilot trait 或 Wizard trait 中的 fly 方法,我们需要使用更明确的语法来指定我们想调用哪个 fly 方法。示例 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();
}
fly 方法在方法名前指定 trait 名称可以向 Rust 明确我们想调用哪个 fly 实现。我们也可以写成 Human::fly(&person),这等价于示例 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 并不总能知道你指的是哪个类型,除非你使用完全限定语法(fully qualified syntax)。例如,在示例 20-20 中,我们为一个动物收容所创建了一个 trait,希望将所有小狗命名为 Spot。我们创建了一个 Animal trait,其中有一个关联的非方法函数 baby_name。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。我们在示例 20-19 中使用的指定 trait 名称的技巧在这里不起作用;如果我们将 main 改为示例 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 trait 的 baby_name 函数,但 Rust 不知道该使用哪个实现因为 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 实现,我们需要使用完全限定语法。示例 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());
}
Dog 上实现的 Animal trait 的 baby_name 函数我们在尖括号中为 Rust 提供了类型注解,表明我们想调用 Dog 类型作为 Animal 时的 baby_name 方法,也就是说我们希望在这次函数调用中将 Dog 类型视为 Animal。这段代码现在会打印出我们想要的内容:
$ 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 需要帮助来识别你想调用哪个实现时,才需要使用这种更冗长的语法。
使用 supertrait
有时你可能会编写一个依赖于另一个 trait 的 trait 定义:要让一个类型实现第一个 trait,你希望要求该类型也实现第二个 trait。这样做是为了让你的 trait 定义能够使用第二个 trait 的关联项。你的 trait 定义所依赖的 trait 被称为你的 trait 的 supertrait。
例如,假设我们想创建一个 OutlinePrint trait,其中有一个 outline_print 方法,它会将给定的值格式化后用星号框起来打印。也就是说,给定一个实现了标准库 Display trait 并输出 (x, y) 的 Point 结构体,当我们在一个 x 为 1、y 为 3 的 Point 实例上调用 outline_print 时,它应该打印以下内容:
**********
* *
* (1, 3) *
* *
**********
在 outline_print 方法的实现中,我们想使用 Display trait 的功能。因此,我们需要指定 OutlinePrint trait 只对同时实现了 Display 的类型有效,并提供 OutlinePrint 所需的功能。我们可以在 trait 定义中通过指定 OutlinePrint: Display 来做到这一点。这种技术类似于为 trait 添加 trait 约束。示例 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() {}
Display 功能的 OutlinePrint trait因为我们指定了 OutlinePrint 需要 Display trait,所以我们可以使用 to_string 函数,该函数会为任何实现了 Display 的类型自动实现。如果我们尝试在不添加冒号和 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 {}
| ^^^^^ the trait `std::fmt::Display` is not implemented for `Point`
|
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();
| ^^^^^^^^^^^^^ the trait `std::fmt::Display` is not implemented for `Point`
|
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 来将其显示在星号边框中。
使用新类型模式实现外部 trait
在第十章的“在类型上实现 trait” 部分,我们提到了孤儿规则(orphan rule),它规定只有当 trait 或类型(或两者)属于本地 crate 时,才允许在该类型上实现该 trait。使用新类型模式(newtype pattern)可以绕过这个限制,它涉及在元组结构体中创建一个新类型。(我们在第五章的“使用元组结构体创建不同类型” 部分介绍了元组结构体。)这个元组结构体只有一个字段,是我们想要实现 trait 的类型的薄包装。然后,包装类型属于本地 crate,我们就可以在包装类型上实现 trait。Newtype 这个术语源自 Haskell 编程语言。使用这种模式没有运行时性能损耗,包装类型在编译时会被消除。
举个例子,假设我们想在 Vec<T> 上实现 Display,但孤儿规则阻止我们直接这样做,因为 Display trait 和 Vec<T> 类型都定义在我们的 crate 之外。我们可以创建一个持有 Vec<T> 实例的 Wrapper 结构体;然后在 Wrapper 上实现 Display 并使用 Vec<T> 的值,如示例 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}");
}
Vec<String> 的 Wrapper 类型以实现 DisplayDisplay 的实现使用 self.0 来访问内部的 Vec<T>,因为 Wrapper 是一个元组结构体,而 Vec<T> 是元组中索引为 0 的项。然后我们就可以在 Wrapper 上使用 Display trait 的功能了。
使用这种技术的缺点是 Wrapper 是一个新类型,所以它没有其内部值的方法。我们必须直接在 Wrapper 上实现 Vec<T> 的所有方法,让这些方法委托给 self.0,这样就能像对待 Vec<T> 一样对待 Wrapper。如果我们希望新类型拥有内部类型的所有方法,可以在 Wrapper 上实现 Deref trait 来返回内部类型(我们在第十五章的“像常规引用一样对待智能指针” 部分讨论了实现 Deref trait)。如果我们不希望 Wrapper 类型拥有内部类型的所有方法——例如,为了限制 Wrapper 类型的行为——我们就只需手动实现我们想要的方法。
即使不涉及 trait,这种新类型模式也很有用。让我们转换视角,来看一些与 Rust 类型系统交互的高级方式。
高级类型
高级类型
Rust 的类型系统有一些我们之前提到过但尚未深入讨论的特性。我们将从整体上讨论 newtype 模式开始,探讨它作为类型为何有用。接着,我们将转向类型别名(type alias),这是一个与 newtype 类似但语义略有不同的特性。我们还会讨论 ! 类型和动态大小类型(dynamically sized types)。
使用 Newtype 模式实现类型安全和抽象
本节假设你已经阅读了前面的“使用 Newtype 模式实现外部 Trait” 一节。newtype 模式在我们之前讨论的用途之外还有其他用处,包括静态地确保值不会被混淆,以及标明值的单位。你在示例 20-16 中看到了使用 newtype 来标明单位的例子:回忆一下,Millimeters 和 Meters 结构体将 u32 值包装在 newtype 中。如果我们编写了一个参数类型为 Millimeters 的函数,就无法编译一个意外地使用 Meters 类型或普通 u32 值来调用该函数的程序。
我们还可以使用 newtype 模式来抽象掉类型的某些实现细节:新类型可以暴露一个与内部私有类型不同的公有 API。
Newtype 还可以隐藏内部实现。例如,我们可以提供一个 People 类型来包装一个 HashMap<i32, String>,用于存储人员 ID 与姓名的关联。使用 People 的代码只需与我们提供的公有 API 交互,比如一个向 People 集合中添加姓名字符串的方法;该代码不需要知道我们在内部为姓名分配了 i32 类型的 ID。newtype 模式是一种实现封装以隐藏实现细节的轻量级方式,我们在第 18 章的“封装隐藏了实现细节”一节中讨论过封装。
类型同义词与类型别名
Rust 提供了声明类型别名(type alias)的能力,可以为现有类型赋予另一个名称。为此我们使用 type 关键字。例如,我们可以像这样为 i32 创建别名 Kilometers:
fn main() {
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
}
现在别名 Kilometers 是 i32 的同义词(synonym);与我们在示例 20-16 中创建的 Millimeters 和 Meters 类型不同,Kilometers 并不是一个独立的新类型。类型为 Kilometers 的值将被视为与 i32 类型的值完全相同:
fn main() {
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
}
因为 Kilometers 和 i32 是同一类型,我们可以将两种类型的值相加,也可以将 Kilometers 值传递给接受 i32 参数的函数。然而,使用这种方式,我们无法获得前面讨论的 newtype 模式所带来的类型检查优势。换句话说,如果我们在某处混淆了 Kilometers 和 i32 的值,编译器不会给出错误。
类型同义词的主要用途是减少重复。例如,我们可能有一个很长的类型,像这样:
Box<dyn Fn() + Send + 'static>
在函数签名和类型标注中到处书写这个冗长的类型既烦琐又容易出错。想象一下项目中到处都是像示例 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(|| ())
}
}
类型别名通过减少重复使代码更易于管理。在示例 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 以减少重复这段代码更容易阅读和编写了!为类型别名选择一个有意义的名称也有助于传达你的意图(thunk 是一个表示“稍后求值的代码“的术语,因此它是存储闭包的恰当名称)。
类型别名也常与 Result<T, E> 类型一起使用以减少重复。考虑标准库中的 std::io 模块。I/O 操作通常返回一个 Result<T, E> 来处理操作失败的情况。标准库中有一个 std::io::Error 结构体,表示所有可能的 I/O 错误。std::io 中的许多函数会返回 Result<T, E>,其中 E 为 std::io::Error,例如 Write trait 中的这些函数:
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>;也就是说,这是一个将 E 填充为 std::io::Error 的 Result<T, E>。Write trait 的函数签名最终看起来像这样:
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 有一个特殊的类型 !,在类型理论术语中被称为空类型(empty type),因为它没有任何值。我们更倾向于称它为 never 类型,因为它在函数永不返回时充当返回类型。下面是一个例子:
fn bar() -> ! {
// --snip--
panic!();
}
这段代码读作“函数 bar 永不返回“。永不返回的函数被称为发散函数(diverging functions)。我们无法创建 ! 类型的值,因此 bar 永远不可能返回。
但是,一个永远无法创建值的类型有什么用呢?回忆一下示例 2-5 中的代码,那是猜数字游戏的一部分;我们在示例 20-27 中重新展示了其中一段。
use std::cmp::Ordering;
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}");
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;
}
}
}
}
continue 结尾的分支的 match当时我们跳过了这段代码中的一些细节。在第 6 章的“match 控制流结构”一节中,我们讨论过 match 的各个分支必须返回相同的类型。因此,例如下面的代码是行不通的:
fn main() {
let guess = "3";
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
}
这段代码中 guess 的类型必须既是整数又是字符串,而 Rust 要求 guess 只能有一种类型。那么 continue 返回什么呢?在示例 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"),
}
}
}
在这段代码中,发生的事情与示例 20-27 中的 match 相同:Rust 看到 val 的类型是 T,而 panic! 的类型是 !,因此整个 match 表达式的结果类型是 T。这段代码能够工作,因为 panic! 不产生值;它终止了程序。在 None 的情况下,我们不会从 unwrap 返回值,所以这段代码是合法的。
最后一个类型为 ! 的表达式是 loop:
fn main() {
print!("forever ");
loop {
print!("and ever ");
}
}
这里循环永远不会结束,所以 ! 是该表达式的值。然而,如果我们加入了 break,情况就不同了,因为循环会在执行到 break 时终止。
动态大小类型与 Sized Trait
Rust 需要了解其类型的某些细节,例如为特定类型的值分配多少空间。这使得其类型系统的一个角落初看起来有些令人困惑:动态大小类型(dynamically sized types)的概念。这些类型有时也被称为 DST 或不定大小类型(unsized types),它们允许我们编写使用只能在运行时才能知道大小的值的代码。
让我们深入了解一个名为 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 字节。这就是为什么无法创建一个持有动态大小类型的变量。
那么我们该怎么办呢?在这种情况下,你已经知道答案了:我们将 s1 和 s2 的类型设为字符串切片(&str)而不是 str。回忆一下第 4 章“字符串切片”一节,切片数据结构只存储起始位置和切片的长度。因此,虽然 &T 是一个存储 T 所在内存地址的单一值,但字符串切片是两个值:str 的地址和它的长度。这样,我们可以在编译时知道字符串切片值的大小:它是 usize 长度的两倍。也就是说,无论它引用的字符串有多长,我们总是知道字符串切片的大小。一般来说,这就是 Rust 中使用动态大小类型的方式:它们有一个额外的元数据来存储动态信息的大小。动态大小类型的黄金法则是:我们必须始终将动态大小类型的值放在某种指针之后。
我们可以将 str 与各种指针组合使用:例如 Box<str> 或 Rc<str>。事实上,你之前已经见过这种用法,只不过是用在另一种动态大小类型上:trait。每个 trait 都是一个动态大小类型,我们可以通过 trait 的名称来引用它。在第 18 章的“使用 Trait 对象来抽象共同行为”一节中,我们提到过要将 trait 用作 trait 对象,必须将它们放在指针之后,例如 &dyn Trait 或 Box<dyn Trait>(Rc<dyn Trait> 也可以)。
为了处理 DST,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 的“,这个标注覆盖了泛型类型必须在编译时具有已知大小的默认行为。具有这种含义的 ?Trait 语法只适用于 Sized,不适用于其他任何 trait。
还要注意,我们将参数 t 的类型从 T 改为了 &T。因为该类型可能不是 Sized 的,所以我们需要在某种指针之后使用它。在这个例子中,我们选择了引用。
接下来,我们将讨论函数和闭包!
高级函数与闭包
高级函数与闭包
本节将探讨一些与函数和闭包相关的高级特性,包括函数指针和返回闭包。
函数指针
我们已经讨论过如何将闭包传递给函数;其实你也可以将普通函数传递给函数!当你想传递一个已经定义好的函数而不是重新定义一个闭包时,这种技巧非常有用。函数会被强制转换为 fn 类型(注意是小写的 f),不要与 Fn 闭包 trait 混淆。fn 类型被称为函数指针(function pointer)。通过函数指针传递函数,可以让你将函数作为参数传递给其他函数。
指定参数为函数指针的语法与闭包类似,如示例 20-28 所示。这里我们定义了一个函数 add_one,它将参数加 1。函数 do_twice 接受两个参数:一个函数指针,指向任何接受 i32 参数并返回 i32 的函数,以及一个 i32 值。do_twice 函数调用 f 两次,每次传入 arg 值,然后将两次函数调用的结果相加。main 函数以 add_one 和 5 作为参数调用 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}");
}
fn 类型接受函数指针作为参数这段代码会打印 The answer is: 12。我们指定 do_twice 中的参数 f 是一个 fn,它接受一个 i32 类型的参数并返回一个 i32。然后我们可以在 do_twice 的函数体中调用 f。在 main 中,我们可以将函数名 add_one 作为第一个参数传递给 do_twice。
与闭包不同,fn 是一个类型而不是一个 trait,因此我们直接将 fn 指定为参数类型,而不是声明一个以 Fn trait 作为 trait bound 的泛型类型参数。
函数指针实现了所有三个闭包 trait(Fn、FnMut 和 FnOnce),这意味着你总是可以将函数指针作为参数传递给期望接收闭包的函数。最佳实践是使用泛型类型和闭包 trait 之一来编写函数,这样你的函数既能接受函数也能接受闭包。
话虽如此,有一种情况你可能只想接受 fn 而不是闭包,那就是与没有闭包的外部代码交互时:C 函数可以接受函数作为参数,但 C 语言没有闭包。
作为一个既可以使用内联闭包也可以使用具名函数的例子,让我们看看标准库中 Iterator trait 提供的 map 方法的用法。要使用 map 方法将一个数字向量转换为字符串向量,我们可以使用闭包,如示例 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 方法将数字转换为字符串或者,我们也可以将一个函数作为 map 的参数来代替闭包。示例 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();
}
String::to_string 函数配合 map 方法将数字转换为字符串注意,这里我们必须使用在“高级 trait” 一节中讨论过的完全限定语法,因为有多个名为 to_string 的函数可用。
这里我们使用的是 ToString trait 中定义的 to_string 函数,标准库为所有实现了 Display 的类型都实现了该 trait。
回忆一下第 6 章“枚举值” 一节的内容,我们定义的每个枚举变体的名称也会成为一个初始化函数。我们可以将这些初始化函数用作实现了闭包 trait 的函数指针,这意味着我们可以将初始化函数指定为接受闭包的方法的参数,如示例 20-31 所示。
fn main() {
enum Status {
Value(u32),
Stop,
}
let list_of_statuses: Vec<Status> = (0u32..20).map(Status::Value).collect();
}
map 方法从数字创建 Status 实例这里我们通过使用 Status::Value 的初始化函数,对 map 所调用的范围中的每个 u32 值创建了 Status::Value 实例。有些人喜欢这种风格,有些人则更喜欢使用闭包。它们会编译成相同的代码,所以请使用你觉得更清晰的风格。
返回闭包
闭包由 trait 表示,这意味着你不能直接返回闭包。在大多数需要返回 trait 的场景中,你可以改用实现了该 trait 的具体类型作为函数的返回值。但是,对于闭包你通常无法这样做,因为它们没有可返回的具体类型;例如,如果闭包从其作用域中捕获了值,你就不能使用函数指针 fn 作为返回类型。
相反,你通常会使用我们在第 10 章学到的 impl Trait 语法。你可以使用 Fn、FnOnce 和 FnMut 来返回任何函数类型。例如,示例 20-32 中的代码可以正常编译。
#![allow(unused)]
fn main() {
fn returns_closure() -> impl Fn(i32) -> i32 {
|x| x + 1
}
}
impl Trait 语法从函数返回闭包然而,正如我们在第 13 章“闭包类型推断和标注” 一节中提到的,每个闭包也是其自身独特的类型。如果你需要处理多个具有相同签名但不同实现的函数,就需要为它们使用 trait 对象。考虑一下如果你编写如示例 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
}
impl Fn 类型的函数所定义的闭包 Vec<T>这里有两个函数 returns_closure 和 returns_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:2:44
|
2 | let handlers = vec![returns_closure(), returns_initialized_closure(123)];
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected opaque type, found a different opaque type
...
9 | fn returns_closure() -> impl Fn(i32) -> i32 {
| ------------------- the expected opaque type
...
13 | fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
| ------------------- the found opaque type
|
= note: expected opaque type `impl Fn(i32) -> i32`
found opaque type `impl Fn(i32) -> i32`
= 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 会创建一个唯一的不透明类型(opaque type),这是一种我们无法看到 Rust 为我们构造的内部细节的类型,我们也无法猜测 Rust 会生成什么类型来自己编写。因此,即使这些函数返回的闭包实现了相同的 trait Fn(i32) -> i32,Rust 为每个闭包生成的不透明类型也是不同的。(这类似于 Rust 为不同的异步块生成不同的具体类型,即使它们具有相同的输出类型,正如我们在第 17 章“Pin 类型和 Unpin trait” 中看到的那样。)我们已经多次见过这个问题的解决方案:使用 trait 对象,如示例 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)
}
Box<dyn Fn> 的函数所定义的闭包 Vec<T>,使它们具有相同的类型这段代码可以正常编译。关于 trait 对象的更多内容,请参阅第 18 章“使用 trait 对象来抽象共同行为” 一节。
接下来,让我们看看宏!
宏
宏
我们在本书中一直使用像 println! 这样的宏,但还没有完整地探讨宏是什么以及它是如何工作的。宏(macro)这个术语指的是 Rust 中的一系列特性——使用 macro_rules! 的声明式宏(declarative macros)以及三种过程宏(procedural macros):
- 自定义
#[derive]宏,用于在结构体和枚举上通过derive属性自动添加代码 - 类属性宏(attribute-like macros),用于定义可用于任何条目的自定义属性
- 类函数宏(function-like macros),看起来像函数调用,但操作的是作为参数传入的 token
我们将依次介绍这些内容,但首先,让我们看看既然已经有了函数,为什么还需要宏。
宏和函数的区别
从根本上说,宏是一种编写代码来生成其他代码的方式,这被称为元编程(metaprogramming)。在附录 C 中,我们讨论了 derive 属性,它可以为你自动生成各种 trait 的实现。我们在本书中还使用了 println! 和 vec! 宏。所有这些宏都会展开(expand)以生成比你手动编写的更多的代码。
元编程对于减少你需要编写和维护的代码量非常有用,这也是函数的作用之一。然而,宏拥有一些函数所不具备的额外能力。
函数签名必须声明函数的参数数量和类型。而宏可以接受可变数量的参数:我们可以用一个参数调用 println!("hello"),也可以用两个参数调用 println!("hello {}", name)。此外,宏在编译器解释代码含义之前就会展开,因此宏可以做到一些函数做不到的事情,例如在给定类型上实现一个 trait。函数则不行,因为函数在运行时才被调用,而 trait 需要在编译时实现。
使用宏而非函数的缺点是,宏定义比函数定义更复杂,因为你是在编写生成 Rust 代码的 Rust 代码。由于这种间接性,宏定义通常比函数定义更难阅读、理解和维护。
宏和函数之间还有一个重要区别:在文件中调用宏之前,必须先定义宏或将其引入作用域,而函数则可以在任何地方定义、在任何地方调用。
用于通用元编程的声明式宏
Rust 中最广泛使用的宏形式是声明式宏(declarative macro)。它们有时也被称为“示例宏“(macros by example)、“macro_rules! 宏“或简称“宏”。声明式宏的核心是允许你编写类似于 Rust match 表达式的东西。正如第六章所讨论的,match 表达式是一种控制结构,它接受一个表达式,将表达式的结果值与模式进行比较,然后运行与匹配模式关联的代码。宏也是将一个值与关联了特定代码的模式进行比较:在这种情况下,值是传递给宏的字面 Rust 源代码;模式与该源代码的结构进行比较;每个模式关联的代码在匹配时替换传递给宏的代码。这一切都发生在编译期间。
要定义一个宏,需要使用 macro_rules! 构造。让我们通过查看 vec! 宏的定义来探索如何使用 macro_rules!。第八章介绍了如何使用 vec! 宏来创建一个包含特定值的新向量。例如,下面的宏创建了一个包含三个整数的新向量:
#![allow(unused)]
fn main() {
let v: Vec<u32> = vec![1, 2, 3];
}
我们也可以使用 vec! 宏来创建一个包含两个整数的向量或一个包含五个字符串切片的向量。我们无法使用函数来做同样的事情,因为我们事先不知道值的数量或类型。
示例 20-35 展示了 vec! 宏的一个略微简化的定义。
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
vec! 宏定义的简化版本注意:标准库中
vec!宏的实际定义包含了预先分配正确内存量的代码。那部分代码是一种优化,为了使示例更简单,我们在这里没有包含它。
#[macro_export] 注解表示,只要定义了该宏的 crate 被引入作用域,这个宏就应该可用。没有这个注解,宏就无法被引入作用域。
然后我们用 macro_rules! 和要定义的宏的名称(不带感叹号)来开始宏定义。在本例中,名称是 vec,后面跟着花括号,表示宏定义的主体。
vec! 主体中的结构类似于 match 表达式的结构。这里我们有一个分支,其模式为 ( $( $x:expr ),* ),后面跟着 => 和与该模式关联的代码块。如果模式匹配成功,关联的代码块将被生成。鉴于这是该宏中唯一的模式,因此只有一种有效的匹配方式;任何其他模式都会导致错误。更复杂的宏会有多个分支。
宏定义中有效的模式语法与第十九章介绍的模式语法不同,因为宏模式是与 Rust 代码结构而非值进行匹配的。让我们逐步解析示例 20-29 中的模式片段的含义;完整的宏模式语法请参阅 Rust 参考手册。
首先,我们使用一对圆括号来包含整个模式。我们使用美元符号($)在宏系统中声明一个变量,该变量将包含与模式匹配的 Rust 代码。美元符号明确表示这是一个宏变量,而非普通的 Rust 变量。接下来是一对圆括号,用于捕获与括号内模式匹配的值,以便在替换代码中使用。在 $() 内部是 $x:expr,它匹配任何 Rust 表达式并将该表达式命名为 $x。
$() 后面的逗号表示,在匹配 $() 中代码的每个实例之间,必须出现一个字面逗号分隔符。* 指定该模式匹配零个或多个 * 之前的内容。
当我们用 vec![1, 2, 3]; 调用这个宏时,$x 模式与三个表达式 1、2 和 3 分别匹配了三次。
现在让我们看看与这个分支关联的代码主体中的模式:$()* 内的 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”。
用于从属性生成代码的过程宏
第二种形式的宏是过程宏(procedural macro),它的行为更像函数(也是一种过程)。过程宏接受一些代码作为输入,对这些代码进行操作,然后产生一些代码作为输出,而不是像声明式宏那样匹配模式并用其他代码替换。三种过程宏分别是自定义 derive、类属性和类函数,它们的工作方式都类似。
创建过程宏时,其定义必须位于一个具有特殊 crate 类型的独立 crate 中。这是出于复杂的技术原因,我们希望将来能消除这一限制。在示例 20-36 中,我们展示了如何定义一个过程宏,其中 some_attribute 是使用特定宏类型的占位符。
use proc_macro::TokenStream;
#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
定义过程宏的函数接受一个 TokenStream 作为输入,并产生一个 TokenStream 作为输出。TokenStream 类型由 Rust 自带的 proc_macro crate 定义,表示一个 token 序列。这就是宏的核心:宏所操作的源代码构成了输入 TokenStream,宏产生的代码就是输出 TokenStream。该函数还附加了一个属性,用于指定我们正在创建哪种过程宏。同一个 crate 中可以有多种过程宏。
让我们看看不同种类的过程宏。我们将从自定义 derive 宏开始,然后解释其他形式的细微差别。
自定义 derive 宏
让我们创建一个名为 hello_macro 的 crate,其中定义一个名为 HelloMacro 的 trait,该 trait 有一个名为 hello_macro 的关联函数。我们不想让用户为每个类型都手动实现 HelloMacro trait,而是提供一个过程宏,让用户可以通过 #[derive(HelloMacro)] 注解来获得 hello_macro 函数的默认实现。默认实现将打印 Hello, Macro! My name is TypeName!,其中 TypeName 是定义了该 trait 的类型的名称。换句话说,我们将编写一个 crate,使其他程序员能够使用我们的 crate 编写如示例 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
接下来,在示例 20-38 中,我们将定义 HelloMacro trait 及其关联函数。
pub trait HelloMacro {
fn hello_macro();
}
derive 宏使用我们有了一个 trait 和它的函数。此时,crate 的用户可以自己实现该 trait 来达到期望的功能,如示例 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();
}
HelloMacro trait 的话会是什么样子然而,他们需要为每个想要使用 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。我们还需要 syn 和 quote crate 的功能,稍后你就会看到,因此需要将它们添加为依赖。将以下内容添加到 hello_macro_derive 的 Cargo.toml 文件中:
[lib]
proc-macro = true
[dependencies]
syn = "2.0"
quote = "1.0"
要开始定义过程宏,请将示例 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 函数和 impl_hello_macro 函数,前者负责解析 TokenStream,后者负责转换语法树:这使得编写过程宏更加方便。外层函数(本例中的 hello_macro_derive)的代码在你见到或创建的几乎每个过程宏 crate 中都是相同的。内层函数(本例中的 impl_hello_macro)的函数体中指定的代码则会因过程宏的用途不同而不同。
我们引入了三个新的 crate:proc_macro、syn 和 quote。proc_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 函数首先将 input 从 TokenStream 转换为一个我们可以解释和操作的数据结构。这就是 syn 发挥作用的地方。syn 中的 parse 函数接受一个 TokenStream 并返回一个表示解析后 Rust 代码的 DeriveInput 结构体。示例 20-41 展示了解析 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
)
}
)
}
DeriveInput 实例这个结构体的字段表明我们解析的 Rust 代码是一个单元结构体,其 ident(标识符,即名称)为 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 的代码,如示例 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()
}
HelloMacro trait我们通过 ast.ident 获取了一个包含被注解类型名称(标识符)的 Ident 结构体实例。示例 20-41 中的结构体表明,当我们对示例 20-37 中的代码运行 impl_hello_macro 函数时,得到的 ident 的 ident 字段值为 "Pancakes"。因此,示例 20-42 中的 name 变量将包含一个 Ident 结构体实例,打印时将是字符串 "Pancakes",即示例 20-37 中结构体的名称。
quote! 宏让我们定义想要返回的 Rust 代码。编译器期望的东西与 quote! 宏执行的直接结果不同,因此我们需要将其转换为 TokenStream。我们通过调用 into 方法来完成这一转换,它会消费这个中间表示并返回所需的 TokenStream 类型的值。
quote! 宏还提供了一些非常酷的模板机制:我们可以输入 #name,quote! 就会用变量 name 中的值替换它。你甚至可以做一些类似于常规宏工作方式的重复操作。详细介绍请查阅 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_macro 和 hello_macro_derive 中都成功完成。让我们将这些 crate 与示例 20-37 中的代码连接起来,看看过程宏的实际效果!在你的 projects 目录中使用 cargo new pancakes 创建一个新的二进制项目。我们需要在 pancakes crate 的 Cargo.toml 中将 hello_macro 和 hello_macro_derive 添加为依赖。如果你将自己的 hello_macro 和 hello_macro_derive 版本发布到 crates.io,它们将是常规依赖;如果没有,你可以将它们指定为 path 依赖,如下所示:
[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }
将示例 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! 宏只能使用我们在前面“用于通用元编程的声明式宏”一节中讨论的类 match 语法来定义。类函数宏接受一个 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 宏的签名类似:我们接收括号内的 token,并返回我们想要生成的代码。
总结
呼!现在你的工具箱中又多了一些可能不会经常使用的 Rust 特性,但你会知道它们在非常特定的情况下是可用的。我们介绍了几个复杂的主题,这样当你在错误消息建议或其他人的代码中遇到它们时,就能够识别这些概念和语法。可以将本章作为参考,指引你找到解决方案。
接下来,我们将把本书中讨论的所有内容付诸实践,再做一个项目!
最终项目:构建多线程 Web 服务器
这是一段漫长的旅程,但我们终于到达了本书的尾声。在本章中,我们将一起构建最后一个项目,来演示最后几章中涵盖的一些概念,同时回顾一些早期的内容。
作为最终项目,我们将构建一个在浏览器中显示 “Hello!” 的 Web 服务器,效果如图 21-1 所示。
以下是我们构建 Web 服务器的计划:
- 了解一些 TCP 和 HTTP 的基础知识。
- 在一个套接字(socket)上监听 TCP 连接。
- 解析少量的 HTTP 请求。
- 创建合适的 HTTP 响应。
- 通过线程池提升服务器的吞吐量。
<img alt=“Screenshot of a web browser visiting the address 127.0.0.1:8080 displaying a webpage with the text content “Hello! Hi from Rust”“ src=“img/trpl21-01.png” class=“center” style=“width: 50%;” />
图 21-1:我们最终的共享项目
在开始之前,有两点需要说明。首先,我们这里使用的方法并不是用 Rust 构建 Web 服务器的最佳方式。社区成员已经在 crates.io 上发布了许多生产级别的 crate,它们提供了比我们将要构建的更完善的 Web 服务器和线程池实现。然而,本章的目的是帮助你学习,而不是走捷径。因为 Rust 是一门系统编程语言,我们可以选择想要使用的抽象层级,并且可以深入到比其他语言更底层的级别。
其次,我们在这里不会使用 async 和 await。构建线程池本身就是一个足够大的挑战,无需再加上构建异步运行时!不过,我们会指出 async 和 await 如何适用于本章中遇到的一些相同问题。正如我们在第 17 章中提到的,许多异步运行时底层都使用线程池来管理工作。
因此,我们将手动编写基本的 HTTP 服务器和线程池,以便你能学习到将来可能使用的那些 crate 背后的通用思想和技术。
构建单线程 Web 服务器
构建单线程 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 中输入示例 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>,表示绑定可能会失败。例如,如果我们运行了两个程序实例,就会有两个程序监听同一个端口。因为我们只是出于学习目的编写一个基础服务器,所以不会去处理这类错误;我们使用 unwrap 在发生错误时直接停止程序。
TcpListener 上的 incoming 方法返回一个迭代器,它给出一系列流(更具体地说,是 TcpStream 类型的流)。单个流(stream)代表客户端和服务器之间的一个打开的连接。连接(connection)是指完整的请求和响应过程:客户端连接到服务器,服务器生成响应,然后服务器关闭连接。因此,我们将从 TcpStream 中读取客户端发送的内容,然后将响应写入流中以将数据发送回客户端。总体而言,这个 for 循环将依次处理每个连接,并为我们生成一系列需要处理的流。
目前,我们对流的处理包括:如果流有任何错误,就调用 unwrap 终止程序;如果没有错误,程序就打印一条消息。我们将在下一个示例中为成功的情况添加更多功能。当客户端连接到服务器时,我们可能从 incoming 方法收到错误,这是因为我们实际上并不是在遍历连接,而是在遍历连接尝试。连接可能因为多种原因而不成功,其中许多是操作系统特定的。例如,许多操作系统对同时打开的连接数有限制;超过该数量的新连接尝试将产生错误,直到一些已打开的连接被关闭。
让我们试试运行这段代码!在终端中执行 cargo run,然后在 Web 浏览器中加载 127.0.0.1:7878。浏览器应该会显示类似“连接被重置“的错误消息,因为服务器目前没有发送任何数据。但当你查看终端时,应该能看到浏览器连接到服务器时打印的几条消息!
Running `target/debug/hello`
Connection established!
Connection established!
Connection established!
有时你会看到一个浏览器请求打印了多条消息;原因可能是浏览器在请求页面的同时还请求了其他资源,比如浏览器标签页中显示的 favicon.ico 图标。
也可能是因为服务器没有响应任何数据,浏览器尝试多次连接服务器。当 stream 在循环末尾离开作用域并被丢弃时,连接会作为 drop 实现的一部分被关闭。浏览器有时会通过重试来处理关闭的连接,因为问题可能是暂时的。
浏览器有时还会在不发送任何请求的情况下打开多个到服务器的连接,以便后续发送请求时能更快地完成。在这种情况下,我们的服务器会看到每个连接,无论该连接上是否有任何请求。许多基于 Chrome 的浏览器版本都会这样做;你可以通过使用隐私浏览模式或使用其他浏览器来禁用这种优化。
重要的是,我们已经成功获取了一个 TCP 连接的句柄!
记得在运行完某个版本的代码后按 ctrl-C 停止程序。然后,在每次修改代码后通过执行 cargo run 命令重新启动程序,以确保你运行的是最新的代码。
读取请求
让我们来实现从浏览器读取请求的功能!为了将获取连接和对连接执行操作这两个关注点分离开来,我们将启动一个新函数来处理连接。在这个新的 handle_connection 函数中,我们将从 TCP 流中读取数据并打印出来,以便查看浏览器发送的数据。将代码修改为示例 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:#?}");
}
TcpStream 中读取数据并打印我们将 std::io::BufReader 和 std::io::prelude 引入作用域,以获取用于读写流的 trait 和类型。在 main 函数的 for 循环中,我们不再打印表示建立了连接的消息,而是调用新的 handle_connection 函数并将 stream 传递给它。
在 handle_connection 函数中,我们创建了一个新的 BufReader 实例来包装 stream 的引用。BufReader 通过替我们管理对 std::io::Read trait 方法的调用来添加缓冲。
我们创建了一个名为 http_request 的变量来收集浏览器发送到服务器的请求行。通过添加 Vec<_> 类型注解,我们表明希望将这些行收集到一个 vector 中。
BufReader 实现了 std::io::BufRead trait,该 trait 提供了 lines 方法。lines 方法通过在遇到换行字节时分割数据流,返回一个 Result<String, std::io::Error> 的迭代器。为了获取每个 String,我们对每个 Result 进行 map 和 unwrap。如果数据不是有效的 UTF-8 编码,或者从流中读取时出现问题,Result 可能是一个错误。同样,生产环境的程序应该更优雅地处理这些错误,但为了简单起见,我们选择在出错时停止程序。
浏览器通过连续发送两个换行符来表示 HTTP 请求的结束,因此为了从流中获取一个请求,我们持续获取行直到遇到空字符串。将这些行收集到 vector 中后,我们使用美化的调试格式打印它们,以便查看 Web 浏览器发送给服务器的指令。
让我们试试这段代码!启动程序并在 Web 浏览器中再次发起请求。注意,我们仍然会在浏览器中看到错误页面,但程序在终端中的输出现在看起来类似这样:
$ cargo run
Compiling hello v0.1.0 (file:///projects/hello)
Finished `dev` profile [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
第一行是请求行(request line),包含客户端请求的相关信息。请求行的第一部分表示所使用的方法,例如 GET 或 POST,描述了客户端发起请求的方式。我们的客户端使用了 GET 请求,表示它在请求信息。
请求行的下一部分是 /,表示客户端请求的统一资源标识符(URI):URI 与统一资源定位符(URL)几乎相同,但不完全一样。URI 和 URL 之间的区别对于本章的目的并不重要,但 HTTP 规范使用的是 URI 这个术语,所以我们可以在这里将 URL 和 URI 视为同义词。
最后一部分是客户端使用的 HTTP 版本,然后请求行以 CRLF 序列结束。(CRLF 代表回车(carriage return)和换行(line feed),这些术语来自打字机时代!)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
第一行是状态行(status line),包含响应中使用的 HTTP 版本、一个总结请求结果的数字状态码,以及一个提供状态码文本描述的原因短语。在 CRLF 序列之后是任意数量的响应头、另一个 CRLF 序列,以及响应体。
下面是一个使用 HTTP 1.1 版本、状态码为 200、原因短语为 OK、没有响应头和响应体的示例响应:
HTTP/1.1 200 OK\r\n\r\n
状态码 200 是标准的成功响应。这段文本是一个极简的成功 HTTP 响应。让我们将它作为成功请求的响应写入流中!在 handle_connection 函数中,移除之前打印请求数据的 println!,替换为示例 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
让我们实现返回不仅仅是空白页面的功能。在项目目录的根目录(而不是 src 目录)下创建新文件 hello.html。你可以输入任何你想要的 HTML;示例 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 文档。为了在收到请求时从服务器返回这个文件,我们将按照示例 21-5 所示修改 handle_connection,读取 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();
}
我们在 use 语句中添加了 fs,将标准库的文件系统模块引入作用域。将文件内容读取为字符串的代码应该很眼熟;我们在第 12 章示例 12-4 中为 I/O 项目读取文件内容时用过它。
接下来,我们使用 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,如示例 21-6 所示。这段新代码将收到的请求内容与我们已知的 / 请求格式进行比较,并添加 if 和 else 块来区别处理不同的请求。
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 请求的第一行,因此不再将整个请求读入 vector,而是调用 next 来获取迭代器的第一个元素。第一个 unwrap 处理 Option,如果迭代器没有元素则停止程序。第二个 unwrap 处理 Result,效果与示例 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,你会看到类似运行示例 21-1 和示例 21-2 中代码时的连接错误。
现在让我们将示例 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,或者使用示例 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。
重构
目前,if 和 else 块中有大量重复代码:它们都在读取文件并将文件内容写入流。唯一的区别是状态行和文件名。让我们将这些差异提取到单独的 if 和 else 行中,将状态行和文件名的值赋给变量,使代码更加简洁;然后在读取文件和写入响应的代码中无条件地使用这些变量。示例 21-9 展示了替换大段 if 和 else 块后的代码。
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();
}
if 和 else 块,使其只包含两种情况之间不同的代码现在 if 和 else 块只在元组中返回状态行和文件名的适当值;然后我们使用 let 语句中的模式通过解构将这两个值分别赋给 status_line 和 filename,如第 19 章所讨论的那样。
之前重复的代码现在位于 if 和 else 块之外,使用 status_line 和 filename 变量。这使得两种情况之间的差异更容易看出,也意味着如果我们想要更改文件读取和响应写入的工作方式,只需要在一个地方更新代码。示例 21-9 中代码的行为与示例 21-7 中的完全相同。
我们现在用大约 40 行 Rust 代码实现了一个简单的 Web 服务器,它对一个请求返回一个内容页面,对所有其他请求返回 404 响应。
目前,我们的服务器运行在单线程中,这意味着它一次只能处理一个请求。让我们通过模拟一些慢请求来看看这会如何成为问题。然后,我们将修复它,使服务器能够同时处理多个请求。
从单线程到多线程服务器
从单线程服务器到多线程服务器
目前,服务器会依次处理每个请求,这意味着在第一个连接处理完成之前,它不会处理第二个连接。如果服务器收到越来越多的请求,这种串行执行方式的效率就会越来越低。如果服务器收到一个需要长时间处理的请求,后续的请求就必须等待这个耗时请求完成,即使新请求本身可以很快处理完毕。我们需要解决这个问题,但首先让我们来实际观察一下这个问题。
模拟慢请求
我们来看看一个处理缓慢的请求会如何影响当前服务器实现中的其他请求。示例 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 不会像相等性方法那样自动进行引用和解引用。
第一个分支与示例 21-9 中的 if 代码块相同。第二个分支匹配对 /sleep 的请求。当收到该请求时,服务器会先休眠五秒,然后再渲染成功的 HTML 页面。第三个分支与示例 21-9 中的 else 代码块相同。
可以看到我们的服务器有多么原始:真正的库会以更简洁的方式处理多种请求的识别!
使用 cargo run 启动服务器。然后打开两个浏览器窗口:一个访问 http://127.0.0.1:7878,另一个访问 http://127.0.0.1:7878/sleep。如果像之前一样多次访问 / URI,你会看到它响应很快。但如果先访问 /sleep 然后再加载 /,你会看到 / 会一直等到 sleep 完成整整五秒的休眠后才加载。
有多种技术可以避免请求在慢请求后面排队等待,包括像第 17 章那样使用 async;我们将要实现的是线程池(thread pool)。
使用线程池提高吞吐量
线程池(thread pool)是一组预先创建好的、随时准备处理任务的线程。当程序收到一个新任务时,它会将池中的一个线程分配给该任务,该线程将处理这个任务。池中剩余的线程可以处理在第一个线程处理期间到来的其他任务。当第一个线程处理完任务后,它会返回到空闲线程池中,准备处理新任务。线程池允许你并发地处理连接,从而提高服务器的吞吐量。
我们会将池中的线程数量限制为一个较小的数字,以防止 DoS 攻击;如果让程序为每个请求都创建一个新线程,那么有人向服务器发送一千万个请求就可能耗尽服务器的所有资源,导致请求处理陷入停滞。
因此,我们不会无限制地创建线程,而是让固定数量的线程在池中等待。到来的请求会被发送到池中进行处理。池会维护一个传入请求的队列。池中的每个线程会从队列中取出一个请求,处理该请求,然后再向队列请求下一个任务。通过这种设计,我们可以并发处理最多 N 个请求,其中 N 是线程的数量。如果每个线程都在响应一个长时间运行的请求,后续请求仍然可能在队列中积压,但我们已经提高了在达到积压之前能够处理的长时间运行请求的数量。
这种技术只是提高 Web 服务器吞吐量的众多方法之一。你可能还想探索的其他方案包括 fork/join 模型、单线程异步 I/O 模型和多线程异步 I/O 模型。如果你对这个话题感兴趣,可以阅读更多关于其他解决方案的资料并尝试实现它们;对于像 Rust 这样的底层语言,所有这些方案都是可行的。
在开始实现线程池之前,让我们先讨论一下使用线程池应该是什么样子的。当你尝试设计代码时,先编写客户端接口有助于指导你的设计。先编写你希望调用的代码 API,使其结构符合你想要的调用方式;然后在该结构内实现功能,而不是先实现功能再设计公共 API。
类似于我们在第 12 章的项目中使用测试驱动开发的方式,这里我们将使用编译器驱动开发。我们先编写调用所需函数的代码,然后查看编译器的错误来确定接下来应该修改什么以使代码正常工作。不过在此之前,我们先来探索一种我们不会采用的技术作为起点。
为每个请求创建一个线程
首先,让我们看看如果为每个连接都创建一个新线程,代码会是什么样子。如前所述,由于可能会无限制地创建线程,这不是我们的最终方案,但它是一个起点,可以先得到一个可工作的多线程服务器。然后我们再添加线程池作为改进,这样对比两种方案也更容易。
示例 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 的代码做大量修改。示例 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 接口我们使用 ThreadPool::new 来创建一个具有可配置线程数量的新线程池,这里是四个。然后,在 for 循环中,pool.execute 具有与 thread::spawn 类似的接口,它接受一个闭包,池会将其交给某个线程来运行。我们需要实现 pool.execute,使其接受闭包并将其交给池中的线程来运行。这段代码还无法编译,但我们会尝试编译,让编译器指导我们如何修复。
使用编译器驱动开发构建 ThreadPool
对 src/main.rs 做示例 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 章“将捕获的值移出闭包”一节,我们可以使用三种不同的 trait 来接受闭包作为参数:Fn、FnMut 和 FnOnce。我们需要决定这里使用哪种闭包。我们知道最终会做类似于标准库 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 的 trait 约束。这可能也是我们想要的,因为我们最终会将 execute 中获得的参数传递给 spawn。我们可以进一步确信 FnOnce 是我们想要使用的 trait,因为运行请求的线程只会执行该请求的闭包一次,这与 FnOnce 中的 Once 相匹配。
F 类型参数还有 trait 约束 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 中验证线程数量
我们没有对 new 和 execute 的参数做任何处理。让我们用我们想要的行为来实现这些函数的函数体。首先,让我们考虑 new。之前我们为 size 参数选择了无符号类型,因为线程数量为负数没有意义。然而,线程数量为零同样没有意义,但零是一个完全合法的 usize 值。我们将添加代码来检查 size 是否大于零,然后再返回 ThreadPool 实例,并在收到零时使用 assert! 宏让程序 panic,如示例 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::new,当 size 为零时 panic我们还用文档注释为 ThreadPool 添加了一些文档。注意我们遵循了良好的文档实践,添加了一个说明函数可能 panic 的情况的部分,如第 14 章所讨论的。尝试运行 cargo doc --open 并点击 ThreadPool 结构体,看看为 new 生成的文档是什么样的!
除了像这里这样添加 assert! 宏,我们还可以将 new 改为 build 并返回一个 Result,就像我们在第 12 章示例 12-9 中的 I/O 项目中对 Config::build 所做的那样。但在这种情况下,我们决定尝试创建一个没有任何线程的线程池应该是一个不可恢复的错误。如果你有兴趣挑战一下,可以尝试编写一个具有以下签名的 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 将是单元类型 ()。
示例 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,
{
}
}
ThreadPool 创建一个向量来存放线程我们在库 crate 中引入了 std::thread,因为我们在 ThreadPool 的向量中使用 thread::JoinHandle 作为元素类型。
一旦收到有效的 size,我们的 ThreadPool 就会创建一个可以容纳 size 个元素的新向量。with_capacity 函数执行与 Vec::new 相同的任务,但有一个重要区别:它会预先在向量中分配空间。因为我们知道需要在向量中存储 size 个元素,预先分配比使用 Vec::new(在插入元素时自行调整大小)稍微高效一些。
当你再次运行 cargo check 时,应该会成功。
从 ThreadPool 向线程发送代码
我们在示例 21-14 的 for 循环中留了一个关于创建线程的注释。这里我们来看看如何实际创建线程。标准库提供了 thread::spawn 来创建线程,thread::spawn 期望在线程创建时就获得线程应该运行的代码。然而在我们的场景中,我们希望创建线程后让它们等待我们稍后发送的代码。标准库的线程实现不包含这种功能;我们需要手动实现它。
我们将通过在 ThreadPool 和线程之间引入一个新的数据结构来管理这种新行为,我们称之为 Worker,这是池化实现中的常用术语。Worker 会取出需要运行的代码并在其线程中运行。
想象一下在餐厅厨房工作的人:工人们等待顾客的订单到来,然后负责接单并完成订单。
我们不再在线程池中存储 JoinHandle<()> 实例的向量,而是存储 Worker 结构体的实例。每个 Worker 会存储一个 JoinHandle<()> 实例。然后我们会在 Worker 上实现一个方法,该方法接受要运行的代码闭包并将其发送给已经运行的线程来执行。我们还会给每个 Worker 一个 id,以便在日志记录或调试时区分池中不同的 Worker 实例。
以下是创建 ThreadPool 时将要发生的新流程。在以这种方式设置好 Worker 之后,我们将实现将闭包发送给线程的代码:
- 定义一个
Worker结构体,持有一个id和一个JoinHandle<()>。 - 修改
ThreadPool使其持有一个Worker实例的向量。 - 定义一个
Worker::new函数,接受一个id数字并返回一个Worker实例,该实例持有id和一个用空闭包创建的线程。 - 在
ThreadPool::new中,使用for循环计数器生成id,用该id创建一个新的Worker,并将Worker存储在向量中。
如果你想挑战一下,可以在查看示例 21-15 中的代码之前,尝试自己实现这些修改。
准备好了吗?以下是示例 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 使其持有 Worker 实例而非直接持有线程我们将 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及其返回Result的spawn方法。
这段代码可以编译,并且会存储我们指定给 ThreadPool::new 的 Worker 实例数量。但我们仍然没有处理在 execute 中获得的闭包。接下来让我们看看如何做到这一点。
通过通道向线程发送请求
接下来我们要解决的问题是,传递给 thread::spawn 的闭包什么都没做。目前,我们在 execute 方法中获得了想要执行的闭包。但我们需要在创建 ThreadPool 期间创建每个 Worker 时,给 thread::spawn 一个要运行的闭包。
我们希望刚创建的 Worker 结构体从 ThreadPool 持有的队列中获取要运行的代码,并将该代码发送给其线程来运行。
我们在第 16 章中学到的通道——一种在两个线程之间通信的简单方式——非常适合这个用例。我们将使用通道作为任务队列,execute 会从 ThreadPool 向 Worker 实例发送任务,Worker 再将任务发送给其线程。以下是计划:
ThreadPool创建一个通道并持有发送端。- 每个
Worker持有接收端。 - 我们创建一个新的
Job结构体来持有要通过通道发送的闭包。 execute方法通过发送端发送想要执行的任务。- 在其线程中,
Worker会循环接收端并执行收到的任务的闭包。
让我们从在 ThreadPool::new 中创建通道并让 ThreadPool 实例持有发送端开始,如示例 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 以存储传输 Job 实例的通道发送端在 ThreadPool::new 中,我们创建了新的通道,并让池持有发送端。这段代码可以成功编译。
让我们尝试在线程池创建通道时将接收端传递给每个 Worker。我们知道要在 Worker 实例创建的线程中使用接收端,所以我们将在闭包中引用 receiver 参数。示例 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我们做了一些小而直接的修改:将接收端传入 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 从接收端获取任务。示例 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 }
}
}
Arc 和 Mutex 在 Worker 实例之间共享接收端在 ThreadPool::new 中,我们将接收端放入 Arc 和 Mutex 中。对于每个新的 Worker,我们克隆 Arc 以增加引用计数,这样 Worker 实例就可以共享接收端的所有权。
通过这些修改,代码可以编译了!我们快要完成了!
实现 execute 方法
让我们最终实现 ThreadPool 上的 execute 方法。我们还将把 Job 从结构体改为一个 trait 对象的类型别名,该 trait 对象持有 execute 接收的闭包类型。如第 20 章“类型同义词和类型别名”一节所讨论的,类型别名允许我们将长类型缩短以便于使用。请看示例 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 }
}
}
Box 创建 Job 类型别名,然后将任务通过通道发送在使用 execute 中获得的闭包创建新的 Job 实例后,我们将该任务通过通道的发送端发送出去。我们对 send 调用了 unwrap,以防发送失败。例如,如果我们停止了所有线程的执行,接收端就会停止接收新消息,此时发送就会失败。目前我们无法停止线程的执行:只要池存在,线程就会继续执行。我们使用 unwrap 是因为我们知道失败的情况不会发生,但编译器并不知道这一点。
但我们还没有完全完成!在 Worker 中,传递给 thread::spawn 的闭包仍然只是引用了通道的接收端。相反,我们需要闭包永远循环,向通道的接收端请求任务,并在收到任务时执行。让我们对 Worker::new 做示例 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 }
}
}
Worker 实例的线程中接收并执行任务这里,我们首先对 receiver 调用 lock 来获取互斥锁,然后调用 unwrap 在出错时 panic。如果互斥锁处于中毒(poisoned)状态,获取锁可能会失败——当其他线程在持有锁时 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 服务器造成的。
现在是暂停思考的好时机:如果我们使用 future 而不是闭包来完成工作,示例 21-18、21-19 和 21-20 中的代码会有什么不同?哪些类型会改变?方法签名会有什么不同(如果有的话)?哪些部分的代码会保持不变?
在学习了第 17 章和第 19 章中的 while let 循环之后,你可能会想为什么我们没有像示例 21-21 那样编写 Worker 线程的代码。
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 }
}
}
while let 的 Worker::new 替代实现这段代码可以编译和运行,但不会产生期望的线程行为:慢请求仍然会导致其他请求等待处理。原因比较微妙:Mutex 结构体没有公共的 unlock 方法,因为锁的所有权基于 lock 方法返回的 LockResult<MutexGuard<T>> 中 MutexGuard<T> 的生命周期。在编译时,借用检查器可以强制执行这样的规则:除非持有锁,否则不能访问由 Mutex 保护的资源。然而,如果我们不注意 MutexGuard<T> 的生命周期,这种实现也可能导致锁被持有的时间超出预期。
示例 21-20 中使用 let job = receiver.lock().unwrap().recv().unwrap(); 的代码之所以有效,是因为使用 let 时,等号右侧表达式中使用的任何临时值会在 let 语句结束时立即被丢弃。然而,while let(以及 if let 和 match)不会在关联代码块结束之前丢弃临时值。在示例 21-21 中,锁在整个 job() 调用期间都被持有,这意味着其他 Worker 实例无法接收任务。
优雅停机与清理
优雅停机与清理
示例 21-20 中的代码通过线程池异步地响应请求,正如我们所期望的那样。我们会收到一些关于 workers、id 和 thread 字段的警告,提醒我们没有直接使用它们,这意味着我们没有做任何清理工作。当我们使用不太优雅的 ctrl-C 方式终止主线程时,所有其他线程也会立即停止,即使它们正在处理请求。
接下来,我们将为线程池实现 Drop trait,对池中的每个线程调用 join,使它们能在关闭前完成正在处理的请求。然后,我们将实现一种方式来通知线程停止接受新请求并关闭。为了验证这段代码的效果,我们将修改服务器,使其只接受两个请求后就优雅地关闭线程池。
在我们继续之前,有一点需要注意:这些改动都不会影响执行闭包的那部分代码,所以即使我们将线程池用于异步运行时,这里的所有内容也是一样的。
为 ThreadPool 实现 Drop Trait
让我们从为线程池实现 Drop 开始。当线程池被丢弃时,所有线程都应该 join 以确保它们完成工作。示例 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 panic 并进入非优雅关闭。
以下是编译这段代码时得到的错误:
$ 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`
--> /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/std/src/thread/mod.rs:1921:17
For more information about this error, try `rustc --explain E0507`.
error: could not compile `hello` (lib) due to 1 previous error
这个错误告诉我们不能调用 join,因为我们只有每个 worker 的可变借用,而 join 需要获取其参数的所有权。为了解决这个问题,我们需要将线程从拥有 thread 的 Worker 实例中移出,这样 join 才能消费该线程。我们在示例 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 方法。它接受一个范围参数来指定要从向量中移除哪些元素,并返回这些元素的迭代器。传入 .. 范围语法将移除向量中的 所有 值。
因此,我们需要像这样更新 ThreadPool 的 drop 实现:
#![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 }
}
}
}
这解决了编译器错误,并且不需要对代码做其他任何修改。需要注意的是,因为 drop 可能在 panic 时被调用,此时 unwrap 也可能 panic 并导致双重 panic,这会立即崩溃程序并终止所有正在进行的清理工作。对于示例程序来说这没问题,但不建议在生产代码中这样做。
向线程发送信号使其停止监听任务
经过我们所做的所有修改,代码可以无警告地编译了。不过坏消息是,这段代码还不能按我们期望的方式运行。关键在于 Worker 实例的线程所运行的闭包中的逻辑:目前我们调用了 join,但这并不会关闭线程,因为它们会永远 loop 来寻找任务。如果我们尝试用当前的 drop 实现来丢弃 ThreadPool,主线程将永远阻塞,等待第一个线程完成。
为了解决这个问题,我们需要修改 ThreadPool 的 drop 实现,然后修改 Worker 的循环。
首先,我们修改 ThreadPool 的 drop 实现,在等待线程完成之前显式地丢弃 sender。示例 21-23 展示了对 ThreadPool 的修改,显式地丢弃 sender。与线程不同,这里我们 确实 需要使用 Option,以便通过 Option::take 将 sender 从 ThreadPool 中移出。
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 }
}
}
Worker 线程之前显式丢弃 sender丢弃 sender 会关闭通道,这表示不会再发送更多消息。当这种情况发生时,Worker 实例在无限循环中对 recv 的所有调用都将返回一个错误。在示例 21-24 中,我们修改 Worker 的循环,使其在这种情况下优雅地退出循环,这意味着当 ThreadPool 的 drop 实现对线程调用 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 }
}
}
recv 返回错误时显式跳出循环为了验证这段代码的效果,让我们修改 main 函数,使服务器只接受两个请求后就优雅地关闭,如示例 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,用它来实现一个类似的 Web 服务器。然后将其 API 和健壮性与我们实现的线程池进行比较。
总结
做得好!你已经读完了整本书!感谢你加入我们的 Rust 之旅。你现在已经准备好实现自己的 Rust 项目,并帮助其他人的项目了。请记住,有一个热情好客的 Rustacean 社区,他们很乐意帮助你在 Rust 旅程中遇到的任何挑战。
附录
以下章节包含一些参考资料,你可能会在 Rust 学习之旅中发现它们很有用。
A - 关键字
附录 A:关键字
下面的列表包含了 Rust 语言当前或将来会使用的保留关键字。因此,它们不能用作标识符(除非使用原始标识符,我们将在“原始标识符”一节中讨论)。所谓_标识符_,是指函数、变量、参数、结构体字段、模块、crate、常量、宏、静态值、属性、类型、trait 或生命周期的名称。
当前在用的关键字
以下是当前在用的关键字列表及其功能说明。
as:执行基本类型转换,消除包含某个项的特定 trait 的歧义,或者在use语句中重命名项。async:返回一个Future而不是阻塞当前线程。await:挂起执行,直到Future的结果就绪。break:立即退出循环。const:定义常量项或常量裸指针。continue:继续下一次循环迭代。crate:在模块路径中,指代 crate 根。dyn:对 trait 对象进行动态分发。else:作为if和if let控制流结构的后备分支。enum:定义一个枚举。extern:链接外部函数或变量。false:布尔值 false 字面量。fn:定义函数或函数指针类型。for:遍历迭代器中的元素、实现 trait,或者指定高阶生命周期。if:根据条件表达式的结果进行分支。impl:实现固有功能或 trait 功能。in:for循环语法的一部分。let:绑定一个变量。loop:无条件循环。match:将一个值与模式进行匹配。mod:定义一个模块。move:使闭包获取其所有捕获变量的所有权。mut:在引用、裸指针或模式绑定中表示可变性。pub:在结构体字段、impl块或模块中表示公有可见性。ref:通过引用绑定。return:从函数返回。Self:当前正在定义或实现的类型的类型别名。self:方法的主体,或当前模块。static:全局变量,或持续整个程序执行期间的生命周期。struct:定义一个结构体。super:当前模块的父模块。trait:定义一个 trait。true:布尔值 true 字面量。type:定义类型别名或关联类型。union:定义一个联合体;仅在联合体声明中用作关键字。unsafe:表示不安全的代码、函数、trait 或实现。use:将符号引入作用域。where:表示约束类型的从句。while:根据表达式的结果进行条件循环。
为将来保留的关键字
以下关键字目前还没有任何功能,但被 Rust 保留以备将来使用:
abstractbecomeboxdofinalgenmacrooverrideprivtrytypeofunsizedvirtualyield
原始标识符
原始标识符(raw identifiers)是一种语法,允许你在通常不允许使用关键字的地方使用关键字。使用原始标识符的方法是在关键字前加上 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"));
}
这段代码可以正常编译。注意函数定义和 main 中调用函数时,函数名都带有 r# 前缀。
原始标识符允许你使用任何你选择的单词作为标识符,即使该单词恰好是保留关键字。这给了我们更大的自由来选择标识符名称,也让我们能够与使用其他语言编写的程序集成——在那些语言中,这些单词并不是关键字。此外,原始标识符还允许你使用与你的 crate 不同 Rust 版本编写的库。例如,try 在 2015 版本中不是关键字,但在 2018、2021 和 2024 版本中是关键字。如果你依赖一个使用 2015 版本编写的库,而该库有一个 try 函数,那么你需要使用原始标识符语法(在这种情况下是 r#try)来从后续版本的代码中调用该函数。有关版本的更多信息,请参阅附录 E。
B - 运算符与符号
附录 B:运算符与符号
本附录包含 Rust 语法的术语表,涵盖了运算符以及其他单独出现或出现在路径、泛型、trait 约束、宏、属性、注释、元组和括号上下文中的符号。
运算符
表 B-1 列出了 Rust 中的运算符、运算符在上下文中的使用示例、简短说明,以及该运算符是否可重载。如果运算符可以重载,则会列出用于重载该运算符的相关 trait。
Table B-1: Operators
| 运算符 | 示例 | 说明 | 可重载? |
|---|---|---|---|
! | 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, and so on | 元组索引 | |
.. | .., 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 | match 分支语法的一部分 | |
> | 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 展示了独立出现的符号,它们在多种位置都是有效的。
Table B-2: Stand-alone Syntax
| 符号 | 说明 |
|---|---|
'ident | 命名生命周期或循环标签 |
数字后紧跟 u8、i32、f64、usize 等 | 特定类型的数字字面量 |
"..." | 字符串字面量 |
r"..."、r#"..."#、r##"..."## 等 | 原始字符串字面量;不处理转义字符 |
b"..." | 字节字符串字面量;构造字节数组而非字符串 |
br"..."、br#"..."#、br##"..."## 等 | 原始字节字符串字面量;原始字符串与字节字符串的组合 |
'...' | 字符字面量 |
b'...' | ASCII 字节字面量 |
|…| expr | 闭包 |
! | 发散函数的永远为空的底部类型 |
_ | “忽略“模式绑定;也用于使整数字面量更易读 |
表 B-3 展示了出现在模块层级路径上下文中的符号。
Table B-3: Path-Related Syntax
| 符号 | 说明 |
|---|---|
ident::ident | 命名空间路径 |
::path | 相对于 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 展示了出现在使用泛型类型参数上下文中的符号。
Table B-4: Generics
| 符号 | 说明 |
|---|---|
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 约束来约束泛型类型参数上下文中的符号。
Table B-5: Trait Bound Constraints
| 符号 | 说明 |
|---|---|
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 展示了出现在调用或定义宏以及在项上指定属性的上下文中的符号。
Table B-6: Macros and Attributes
| 符号 | 说明 |
|---|---|
#[meta] | 外部属性 |
#![meta] | 内部属性 |
$ident | 宏替换 |
$ident:kind | 宏元变量 |
$(...)... | 宏重复 |
ident!(...), ident!{...}, ident![...] | 宏调用 |
表 B-7 展示了创建注释的符号。
Table B-7: Comments
| 符号 | 说明 |
|---|---|
// | 行注释 |
//! | 内部行文档注释 |
/// | 外部行文档注释 |
/*...*/ | 块注释 |
/*!...*/ | 内部块文档注释 |
/**...*/ | 外部块文档注释 |
表 B-8 展示了使用圆括号的上下文。
Table B-8: Parentheses
| 符号 | 说明 |
|---|---|
() | 空元组(也称为 unit),既是字面量也是类型 |
(expr) | 带括号的表达式 |
(expr,) | 单元素元组表达式 |
(type,) | 单元素元组类型 |
(expr, ...) | 元组表达式 |
(type, ...) | 元组类型 |
expr(expr, ...) | 函数调用表达式;也用于初始化元组 struct 和元组 enum 变体 |
表 B-9 展示了使用花括号的上下文。
Table B-9: Curly Brackets
| 上下文 | 说明 |
|---|---|
{...} | 块表达式 |
Type {...} | 结构体字面量 |
表 B-10 展示了使用方括号的上下文。
Table B-10: Square Brackets
| 上下文 | 说明 |
|---|---|
[...] | 数组字面量 |
[expr; len] | 包含 len 个 expr 副本的数组字面量 |
[type; len] | 包含 len 个 type 实例的数组类型 |
expr[expr] | 集合索引;可重载(Index、IndexMut) |
expr[..], expr[a..], expr[..b], expr[a..b] | 伪装成集合切片的集合索引,使用 Range、RangeFrom、RangeTo 或 RangeFull 作为“索引“ |
C - 可派生的 trait
附录 C:可派生的 trait
在本书的各个章节中,我们讨论过 derive 属性,它可以应用于结构体或枚举的定义。derive 属性会生成代码,在你用 derive 语法标注的类型上,以默认实现的方式实现对应的 trait。
在本附录中,我们提供了标准库中所有可以与 derive 一起使用的 trait 的参考。每个小节涵盖:
- 派生该 trait 将启用哪些运算符和方法
derive提供的 trait 实现做了什么- 实现该 trait 对类型意味着什么
- 允许或不允许实现该 trait 的条件
- 需要该 trait 的操作示例
如果你希望获得与 derive 属性所提供的不同的行为,请查阅标准库文档中每个 trait 的详细信息,了解如何手动实现它们。
这里列出的 trait 是标准库中唯一可以通过 derive 在你的类型上实现的 trait。标准库中定义的其他 trait 没有合理的默认行为,因此需要你以符合自身目标的方式来实现它们。
一个不能被派生的 trait 的例子是 Display,它处理面向最终用户的格式化。你应该始终考虑向最终用户展示一个类型的恰当方式。最终用户应该被允许看到类型的哪些部分?他们会觉得哪些部分是相关的?什么样的数据格式对他们最有意义?Rust 编译器没有这种洞察力,因此无法为你提供合适的默认行为。
本附录中提供的可派生 trait 列表并不是详尽无遗的:库可以为自己的 trait 实现 derive,使得可以使用 derive 的 trait 列表真正是开放式的。实现 derive 涉及使用过程宏(procedural macro),这在第 20 章的“自定义 derive 宏”部分有介绍。
用于程序员输出的 Debug
Debug trait 启用格式化字符串中的调试格式化,你可以通过在 {} 占位符中添加 :? 来使用它。
Debug trait 允许你以调试为目的打印类型的实例,这样你和使用你类型的其他程序员就可以在程序执行的某个特定点检查实例的内容。
Debug trait 是必需的,例如在使用 assert_eq! 宏时。如果相等断言失败,这个宏会打印作为参数传入的实例的值,以便程序员可以看到为什么两个实例不相等。
用于相等比较的 PartialEq 和 Eq
PartialEq trait 允许你比较类型的实例以检查是否相等,并启用 == 和 != 运算符的使用。
派生 PartialEq 会实现 eq 方法。当在结构体上派生 PartialEq 时,只有当_所有_字段都相等时两个实例才相等,只要有_任何_字段不相等则实例不相等。当在枚举上派生时,每个变体等于自身,不等于其他变体。
PartialEq trait 是必需的,例如在使用 assert_eq! 宏时,该宏需要能够比较两个类型实例是否相等。
Eq trait 没有方法。它的作用是表明对于被标注类型的每一个值,该值都等于自身。Eq trait 只能应用于同时实现了 PartialEq 的类型,但并非所有实现了 PartialEq 的类型都能实现 Eq。浮点数类型就是一个例子:浮点数的实现规定,两个非数值(NaN)实例彼此不相等。
需要 Eq 的一个例子是 HashMap<K, V> 中的键,这样 HashMap<K, V> 才能判断两个键是否相同。
用于排序比较的 PartialOrd 和 Ord
PartialOrd trait 允许你比较类型的实例以进行排序。实现了 PartialOrd 的类型可以使用 <、>、<= 和 >= 运算符。你只能在同时实现了 PartialEq 的类型上应用 PartialOrd trait。
派生 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>,因为有效的排序总是存在的。你只能在同时实现了 PartialOrd 和 Eq(而 Eq 又要求 PartialEq)的类型上应用 Ord trait。当在结构体和枚举上派生时,cmp 的行为与 PartialOrd 的派生实现中 partial_cmp 的行为相同。
需要 Ord 的一个例子是在 BTreeSet<T> 中存储值,这是一种根据值的排序顺序来存储数据的数据结构。
用于复制值的 Clone 和 Copy
Clone trait 允许你显式地创建一个值的深拷贝,复制过程可能涉及运行任意代码和复制堆上的数据。有关 Clone 的更多信息,请参阅第 4 章的“变量与数据交互的方式:克隆”部分。
派生 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() 为其余字段设置和使用默认值。
Default trait 在你对 Option<T> 实例使用 unwrap_or_default 方法时是必需的。如果 Option<T> 是 None,unwrap_or_default 方法将返回存储在 Option<T> 中的类型 T 的 Default::default 的结果。
D - 实用开发工具
附录 D:实用的开发工具
在本附录中,我们将介绍 Rust 项目提供的一些实用开发工具。我们会了解自动格式化、快速修复警告的方法、代码检查工具,以及与 IDE 的集成。
使用 rustfmt 自动格式化
rustfmt 工具会按照社区代码风格重新格式化你的代码。许多协作项目都使用 rustfmt 来避免编写 Rust 代码时关于代码风格的争论:每个人都使用该工具来格式化代码。
安装 Rust 时默认包含 rustfmt,所以你的系统上应该已经有 rustfmt 和 cargo-fmt 这两个程序了。这两个命令类似于 rustc 和 cargo 的关系:rustfmt 提供更细粒度的控制,而 cargo-fmt 能理解使用 Cargo 的项目的约定。要格式化任何 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 关键字。我们可以使用 rustfix 工具,通过运行 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 工具是一组代码检查规则(lint)的集合,用于分析你的代码,帮助你发现常见错误并改进 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 analyzer 插件。
请访问 rust-analyzer 项目的主页获取安装说明,然后在你使用的 IDE 中安装语言服务器支持。你的 IDE 将获得自动补全、跳转到定义和内联错误提示等功能。
E - 版本
附录 E:版本
在第 1 章中,你看到 cargo new 会在 Cargo.toml 文件中添加一些关于版本(edition)的元数据。本附录将解释这意味着什么!
Rust 语言和编译器采用六周一次的发布周期,这意味着用户可以持续获得新功能。其他编程语言通常间隔较长时间才发布较大的更新;而 Rust 则更频繁地发布较小的更新。一段时间之后,所有这些微小的变化会积少成多。但从一个版本到另一个版本,很难回过头来说:“哇,从 Rust 1.10 到 Rust 1.31,Rust 变化真大!”
大约每三年,Rust 团队会发布一个新的 Rust 版本(edition)。每个版本会将已经落地的功能整合成一个清晰的包,并配有完整更新的文档和工具。新版本作为常规六周发布流程的一部分进行发布。
版本对不同的人有不同的意义:
- 对于活跃的 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 版本的开发者都会随着新的稳定版本发布而持续看到改进。然而,在某些情况下,主要是当新关键字被添加时,一些新功能可能只在较新的版本中可用。如果你想利用这些功能,就需要切换版本。
更多详情请参阅 Rust 版本指南。这是一本完整的书,列举了各版本之间的差异,并解释了如何通过 cargo fix 自动将代码升级到新版本。
F - 本书的翻译版本
附录 F:本书的翻译版本
如需英语以外的语言资源,请参阅以下列表。大多数翻译仍在进行中;请查看 Translations 标签来提供帮助或告知我们新的翻译项目!
- Português(巴西葡萄牙语)
- Português(葡萄牙葡萄牙语)
- 简体中文: KaiserY/trpl-zh-cn, gnu4cn/rust-lang-Zh_CN
- 正體中文
- Українська(乌克兰语)
- Español, alternate, Español por RustLangES(西班牙语)
- Русский(俄语)
- 한국어(韩语)
- 日本語(日语)
- Français(法语)
- Polski(波兰语)
- Cebuano(宿务语)
- Tagalog(他加禄语)
- Esperanto(世界语)
- ελληνική(希腊语)
- Svenska(瑞典语)
- Farsi, Persian (FA)(波斯语)
- Deutsch(德语)
- हिंदी(印地语)
- ไทย(泰语)
- Danske(丹麦语)
- O’zbek(乌兹别克语)
- Tiếng Việt(越南语)
- Italiano(意大利语)
- বাংলা(孟加拉语)
G - Rust 的开发方式与 "Nightly Rust"
附录 G - Rust 是如何开发的与 “Nightly Rust”
本附录介绍 Rust 是如何开发的,以及这对作为 Rust 开发者的你有什么影响。
稳定而不停滞
作为一门语言,Rust 非常注重代码的稳定性。我们希望 Rust 成为你可以依赖的坚实基础,如果一切都在不断变化,那就不可能做到这一点。但与此同时,如果我们不能尝试新功能,我们可能要到发布之后才能发现重要的缺陷,而那时已经无法再做更改了。
我们对这个问题的解决方案被称为“稳定而不停滞“(stability without stagnation),我们的指导原则是:你永远不必担心升级到新版本的稳定版 Rust。每次升级都应该是无痛的,同时还能为你带来新功能、更少的 bug 和更快的编译速度。
呜呜!发布通道与列车模型
Rust 的开发基于列车时刻表运作。也就是说,所有开发工作都在 Rust 仓库的 main 分支上进行。发布遵循软件发布列车模型,这一模型曾被 Cisco IOS 和其他软件项目所采用。Rust 有三个发布通道:
- Nightly(每夜版)
- Beta(测试版)
- Stable(稳定版)
大多数 Rust 开发者主要使用稳定版通道,但想要尝试实验性新功能的人可以使用每夜版或测试版。
下面举例说明开发和发布流程是如何运作的:假设 Rust 团队正在准备 Rust 1.5 的发布。该版本于 2015 年 12 月发布,但它能为我们提供真实的版本号。一个新功能被添加到 Rust 中:一个新的提交合入了 main 分支。每天晚上,都会生成一个新的每夜版 Rust。每一天都是发布日,这些发布由我们的发布基础设施自动创建。随着时间推移,我们的发布看起来像这样,每晚一次:
nightly: * - - * - - *
每六周,就到了准备新版本发布的时候了!Rust 仓库的 beta 分支从每夜版使用的 main 分支上分叉出来。现在有两个发布版本:
nightly: * - - * - - *
|
beta: *
大多数 Rust 用户不会主动使用测试版,但会在他们的 CI 系统中针对测试版进行测试,以帮助 Rust 发现可能的回归问题。与此同时,每晚仍然会有一个每夜版发布:
nightly: * - - * - - * - - * - - *
|
beta: *
假设发现了一个回归问题。幸好我们在回归问题潜入稳定版之前有时间测试测试版!修复被应用到 main 分支,这样每夜版就修复了,然后修复被回移(backport)到 beta 分支,并生成一个新的测试版发布:
nightly: * - - * - - * - - * - - * - - *
|
beta: * - - - - - - - - *
在第一个测试版创建六周后,稳定版就该发布了!stable 分支从 beta 分支产生:
nightly: * - - * - - * - - * - - * - - * - * - *
|
beta: * - - - - - - - - *
|
stable: *
好极了!Rust 1.5 完成了!然而,我们忘了一件事:因为六周已经过去了,我们还需要一个下一个版本 Rust 1.6 的新测试版。所以在 stable 从 beta 分叉之后,下一个版本的 beta 又从 nightly 分叉出来:
nightly: * - - * - - * - - * - - * - - * - * - *
| |
beta: * - - - - - - - - * *
|
stable: *
这被称为“列车模型“,因为每六周,一个版本“驶出车站“,但在到达稳定版之前,仍然需要经过测试版通道的旅程。
Rust 每六周发布一次,像时钟一样准时。如果你知道某个 Rust 版本的发布日期,就能知道下一个版本的日期:六周之后。每六周发布一次的一个好处是,下一班列车很快就会到来。如果某个功能恰好错过了某次发布,不必担心:很快就会有下一次!这有助于减轻在发布截止日期前匆忙塞入可能尚未完善的功能的压力。
得益于这个流程,你始终可以查看 Rust 的下一个构建版本,并亲自验证升级是否容易:如果测试版没有按预期工作,你可以向团队报告,并在下一个稳定版发布之前得到修复!测试版中出现问题的情况相对少见,但 rustc 毕竟也是一个软件,bug 总是存在的。
维护时间
Rust 项目只支持最新的稳定版本。当新的稳定版本发布时,旧版本就到达了其生命周期终点(EOL)。这意味着每个版本的支持期为六周。
不稳定功能
这个发布模型还有一个要点:不稳定功能(unstable features)。Rust 使用一种称为“功能标志“(feature flags)的技术来决定在给定版本中启用哪些功能。如果一个新功能正在积极开发中,它会合入 main 分支,因此也会出现在每夜版中,但会被置于一个功能标志之后。如果你作为用户希望尝试这个正在开发中的功能,你可以这样做,但你必须使用每夜版 Rust,并在源代码中添加相应的标志来选择启用。
如果你使用的是测试版或稳定版 Rust,则无法使用任何功能标志。这是让我们在将新功能宣布为永久稳定之前获得实际使用经验的关键。那些希望尝试前沿功能的人可以选择启用,而那些想要稳如磐石的体验的人可以继续使用稳定版,并且知道他们的代码不会出问题。稳定而不停滞。
本书只包含稳定功能的信息,因为正在开发中的功能仍在变化,而且从本书编写时到它们在稳定版中启用时,肯定会有所不同。你可以在网上找到仅限每夜版功能的文档。
Rustup 与 Rust Nightly 的角色
Rustup 使得在不同的 Rust 发布通道之间切换变得很容易,无论是全局切换还是按项目切换。默认情况下,你安装的是稳定版 Rust。例如,要安装每夜版:
$ rustup toolchain install nightly
你也可以用 rustup 查看所有已安装的工具链(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 目录中调用 rustc 或 cargo 时,rustup 都会确保你使用的是每夜版 Rust,而不是默认的稳定版 Rust。当你有很多 Rust 项目时,这非常方便!
RFC 流程与团队
那么你如何了解这些新功能呢?Rust 的开发模型遵循一个征求意见(RFC)流程。如果你希望改进 Rust,可以撰写一份提案,称为 RFC。
任何人都可以编写 RFC 来改进 Rust,这些提案会由 Rust 团队审阅和讨论,团队由许多主题子团队组成。Rust 网站上有完整的团队列表,涵盖项目的各个领域:语言设计、编译器实现、基础设施、文档等。相应的团队会阅读提案和评论,撰写自己的意见,最终就接受或拒绝该功能达成共识。
如果功能被接受,就会在 Rust 仓库中开一个 issue,然后有人来实现它。实现它的人很可能不是最初提出该功能的人!当实现准备就绪后,它会合入 main 分支,并被置于一个功能门控(feature gate)之后,正如我们在“不稳定功能”一节中讨论的那样。
经过一段时间,当使用每夜版的 Rust 开发者们已经能够试用新功能后,团队成员会讨论该功能在每夜版上的表现,并决定它是否应该进入稳定版 Rust。如果决定推进,功能门控就会被移除,该功能就被视为稳定的了!它会搭乘列车进入新的稳定版 Rust。