精通-Rust-第二版-全-

精通 Rust 第二版(全)

原文:annas-archive.org/md5/f8130c086c32d8a769f8fc5df1fb7bb7

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书关于 Rust,这是一种赋予你构建各种软件系统能力的编程语言,从底层嵌入式软件到动态 Web 应用。Rust 速度快、可靠、安全。它提供的性能和安全保证达到甚至超过了 C 和 C++,同时仍然是一种具有相对较低入门门槛的现代语言。Rust 致力于渐进式改进,结合其活跃而友好的社区,为该语言带来了光明的未来。

Rust 并非设计成一种全新的语言,也不试图重新发明轮子。相反,它是一种语言,它识别了隐藏在从未被广泛采用的研发原型语言中的独特理念。它将这些理念汇集在一起,形成了一个连贯的整体,并提供了一种实用的语言,让你能够构建安全的软件系统,同时仍然保持高效。

本书面向对象

本书面向熟悉其他命令式语言但新接触 Rust 的初学者和中级程序员。它假设你至少熟悉一种命令式编程语言,如 C、C++或 Python。了解函数式编程不是必需的,但了解其基本概念是好的。我们确保解释我们从这些语言中引入的任何概念或想法。

本书涵盖内容

第一章,Rust 入门,简要介绍了 Rust 的历史及其设计背后的动机,并涵盖了基本语言语法。本章以涵盖所有语言特性的练习结束。

第二章,使用 Cargo 管理项目,展示了 Rust 如何通过其专用的包管理器组织大型项目。这为后续章节奠定了基础。它还涵盖了与 Visual Studio Code 编辑器的集成。

第三章,测试、文档和基准测试,探讨了内置的测试框架,编写单元测试、集成测试,以及如何在 Rust 中编写文档。我们还涵盖了 Rust 代码的基准测试功能。稍后,作为最后的练习,我们将构建一个包含文档和测试的完整 crate。

第四章,类型、泛型和特质,探讨了 Rust 的表达式类型系统,并通过构建复数库来解释使用类型系统的各种方法。

第五章,内存管理和安全性,从内存管理的动机和传统低级编程语言中与内存相关的各种陷阱开始,然后转向解释 Rust 独特的编译时内存管理理念。我们还解释了 Rust 中各种智能指针类型。

第六章,错误处理,从错误处理的动机开始,探讨了其他语言中不同的错误处理模型。然后,章节检查了 Rust 的错误处理策略和类型,在探索非恢复性情况下的错误处理之前。章节最后介绍了一个实现自定义错误类型的库。

第七章,高级概念,更详细地探讨了之前章节中介绍的一些概念。它提供了关于 Rust 提供的某些类型系统抽象的底层模型的详细信息。

第八章,并发,探讨了 Rust 的标准库中的并发模型和 API,并教你如何构建没有数据竞争的高度并发程序。

第九章,使用宏进行元编程,检查了如何使用 Rust 强大且高级的宏结构编写生成代码的代码,并通过构建这两种类型的宏来概述语言的表达式宏和过程宏。

第十章,不安全 Rust 和外部函数接口,探讨了 Rust 的不安全模式以及与其他语言交互时提供的 API。示例包括从 Python、Node.js 和 C 等语言调用 Rust,以及探讨如何从其他语言调用 Rust。

第十一章,日志记录,解释了为什么日志记录是软件开发中的重要实践,回答了为什么我们需要日志框架,并探讨了 Rust 生态系统提供的可以用于帮助将日志集成到应用程序中的 crate。

第十二章,Rust 中的网络编程:同步和异步 I/O,简要介绍了网络编程。在了解了基础知识之后,章节涵盖了构建一个可以与官方 Redis 客户端通信的 Redis 服务器。最后,章节解释了如何使用标准库网络原语以及 Tokio 和 futures crate。

第十三章,使用 Rust 构建 Web 应用程序,首先探讨了 HTTP 协议,并使用 hyper crate 构建了一个简单的 URL 缩短服务器,随后使用 reqwest crate 构建了一个 URL 缩短客户端。最后,我们探讨了 actix-web,这是一个高性能的异步 Web 应用程序框架,用于构建书签 API 服务器。

第十四章,在 Rust 中使用数据库交互,从对数据库后端应用程序需求的分析开始,进而探讨 Rust 生态系统中可用于与各种数据库后端(如 SQLite 和 PostgreSQL)交互的可用 crate。本章还探讨了名为 diesel 的类型安全 ORM crate。随后,它介绍了如何将上一章中构建的 bookmarks API 服务器集成到数据库支持中,使用 diesel 进行集成。

第十五章,使用 WebAssembly 在 Web 上使用 Rust,解释了 WebAssembly 是什么以及开发者如何使用它。然后,我们继续探讨 Rust 生态系统中可用的 crate,并使用 Rust 和 WebAssembly 构建一个实时 markdown 编辑器 Web 应用程序。

第十六章,使用 Rust 构建桌面应用程序,解释了如何使用 GTK 框架使用 Rust 构建桌面应用程序。我们将构建一个简单的 hacker-news 桌面客户端。

第十七章,调试,探讨了使用 GDB 调试 Rust 代码,并展示了如何将 GDB 与 Visual Studio Code 编辑器集成。

充分利用本书

要真正掌握本书的内容,建议您写下示例代码,并尝试修改代码以熟悉 Rust 的错误消息,这样它们可以指导您编写正确的程序。

本书没有特定的硬件要求,任何具有至少 1 GB RAM 和相当新版的 Linux 操作系统的系统都适用。本书中的所有代码示例和项目都是在运行 Ubuntu 16.04 的 Linux 机器上开发的。Rust 还提供了对其他操作系统平台的一流支持,包括 macOS、BSD 和 Windows 的最新版本,因此所有代码示例也应该在这些平台上编译和运行良好。

下载示例代码文件

您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择支持选项卡。

  3. 点击代码下载与勘误。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

下载文件后,请确保使用最新版本的软件解压缩或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Mastering-RUST-Second-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。如果您在编译代码示例时遇到任何问题,请随时在 GitHub 上提出问题。

我们还有其他来自我们丰富的图书和视频目录的代码包,可在 github.com/PacktPublishing/ 上找到。查看它们吧!

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“项目位于Chapter08/目录下的名为threads_demo的文件夹中。”

代码块设置如下:

fn main() {
    println!("Hello Rust!");
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

[dependencies]
serde = "1.0.8"
crossbeam = "0.6.0"
typenum = "1.10.0"

任何命令行输入或输出都按以下方式编写:

$ rustc main.rs
$ cargo build

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”

警告或重要注意事项显示如下。

小技巧和技巧显示如下。

联系我们

读者反馈始终欢迎。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并将邮件发送至 customercare@packtpub.com.

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一错误。请访问 www.packt.com/submit-errata,选择您的书籍,点击勘误表提交表单链接,并输入详细信息。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称。请通过 copyright@packt.com 联系我们,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com.

评论

请留下评论。一旦您阅读并使用过这本书,为何不在您购买它的网站上留下评论?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解更多关于 Packt 的信息,请访问 packt.com.

第一章:Rust 入门

学习一门新语言就像建造一座房子——基础需要牢固。使用一门能改变你思考代码方式的语言,一开始总是需要更多的努力,并且重要的是要意识到这一点。然而,最终的结果是,你可以通过这些新发现的概念和工具来转变你的思维方式。

本章将带你快速了解 Rust 的设计理念,其语法和类型系统的概述。我们假设你具备主流语言(如 C、C++或 Python)的基本知识,以及围绕面向对象编程的思想。每个部分都将包含示例代码及其解释。将有大量的代码示例和编译器的输出,帮助你熟悉这门语言。我们还将简要介绍这门语言的历史以及它如何持续发展。

熟悉一门新语言需要毅力、耐心和实践。我强烈建议所有读者手动编写代码,而不是复制粘贴这里列出的代码示例。编写和调试 Rust 代码的最好部分是来自编译器的精确且有用的错误信息,Rust 社区通常喜欢称之为错误驱动开发。在这本书的整个过程中,我们将频繁地看到这些错误,以了解编译器是如何看待我们的代码的。

在本章中,我们将涵盖以下主题:

  • Rust 是什么,为什么你应该关心?

  • 安装 Rust 编译器和工具链

  • 语言及其语法的简要概述

  • 最后的练习,我们将把所学的内容综合运用

Rust 是什么,为什么你应该关心?

"Rust 是来自过去的科技,用来拯救未来免于自身的问题。"

*                                                                                                                                                                                                                                                                                                                                                                                                                                               - 格雷顿·霍华德*

Rust 是一种快速、并发、安全且赋予力量的编程语言,最初由 Graydon Hoare 在 2006 年启动并开发。现在它是一个开源语言,主要由来自 Mozilla 的团队开发,同时得到了许多开源人士的合作。第一个稳定版本 1.0 在 2015 年 5 月发布。该项目始于缓解 C++ 在 gecko 中使用时出现的内存安全问题。Gecko 是 Mozilla Firefox 浏览器中使用的浏览器引擎。C++ 并非易驯的语言,其并发抽象容易被误用。由于 Gecko 使用 C++,在 2009 年和 2011 年尝试并行化其 级联样式表CSS)解析代码以利用现代并行 CPU 时失败了。这是因为并发的 C++ 代码难以维护和推理。在众多开发者共同协作于 gecko 的庞大代码库时,用 C++ 编写并发代码并非易事。为了逐步消除 C++ 的痛苦部分,Rust 诞生了,随之而来的是 Servo,一个从头开始创建浏览器引擎的新研究项目。Servo 项目通过使用最前沿的语言特性为语言团队提供反馈,反过来,这些特性又影响了语言的发展。大约在 2017 年 11 月,Servo 项目的部分内容,尤其是 stylo 项目(一个用 Rust 编写的并行 CSS 解析器),开始随最新的 Firefox 版本(Project Quantum)发布,这在如此短的时间内是一项了不起的成就。Servo 的最终目标是逐步用其组件替换 gecko 中的组件。

Rust 吸收了许多语言的灵感,其中最值得注意的是 Cyclone(C 语言的安全方言)在基于区域的内存管理技术方面的理念;C++ 在 RAII 原则方面,以及 Haskell 在类型系统、错误处理类型和类型类方面。

RAII 代表 资源获取即初始化,这是一种范式,表明资源必须在对象的初始化期间获取,并在它们的析构函数被调用或它们被释放时必须释放。

该语言具有非常小的运行时,不需要垃圾回收,并且默认优先使用栈分配(相对于堆分配的额外开销)来分配任何在程序中声明的值。我们将在 第五章 中解释所有这些内容,内存管理和安全性。Rust 编译器 rustc 最初是用 Ocaml(一种函数式语言)编写的,并在 2011 年成为自宿主编译器,那时它已经用自己编写了。

自宿主编译器是指通过编译其自己的源代码来构建编译器。这个过程被称为编译器的引导。编译器自己的源代码作为编译器的一个非常好的测试用例。

Rust 语言在 GitHub 上公开开发,地址为 github.com/rust-lang/rust,并且以较快的速度持续进化。新特性通过社区驱动的请求评论RFC)流程添加到语言中,任何人都可以提出新的语言特性。这些特性随后在 RFC 文档中详细描述。然后,对 RFC 进行共识寻求,如果达成一致,则开始该特性的实施阶段。实施后的特性将由社区进行审查,最终在经过用户在夜间版本中的多次测试后,合并到主分支。从社区获取反馈对于语言的进化至关重要。每六周,编译器会发布一个新的稳定版本。除了快速移动的增量更新外,Rust 还具有版本的概念,旨在为语言提供综合更新。这包括工具、文档、其生态系统,以及逐步引入任何破坏性变更。到目前为止,已经有两个版本:Rust 2015,该版本侧重于稳定性,以及Rust 2018,这是撰写本书时的当前版本,侧重于生产力。

尽管 Rust 是一种通用多范式语言,但它旨在系统编程领域,在这个领域 C 和 C++一直占据主导地位。这意味着你可以用它来编写操作系统、游戏引擎以及许多性能关键型应用。同时,它也足够表达,你可以用它构建高性能的 Web 应用、网络服务、类型安全的数据库对象关系映射器ORM)库,并且可以通过编译成 WebAssembly 在 Web 上运行。Rust 在构建用于嵌入式平台的安全关键型、实时应用方面也获得了相当的关注,例如基于 Arm 的 Cortex-M 的微控制器,目前这个领域主要被 C 所主导。Rust 在各种领域中的应用范围——它表现得相当出色——在单一编程语言中是非常罕见的。此外,像 Cloudflare、Dropbox、Chuckfish、npm 等知名公司已经在他们的高风险项目中将其用于生产。

Rust 被描述为一种静态和强类型语言。静态属性意味着编译器在编译时对所有变量及其类型都有信息,并在编译时进行大部分检查,只在运行时进行非常少的类型检查。其强类型特性意味着它不允许类型之间的自动转换,例如,一个指向整数的变量不能在代码的后续部分被改变为指向字符串。例如,在弱类型语言如 JavaScript 中,你可以轻松地做类似以下操作:two = "2"; two = 2 + two;。JavaScript 在运行时会将 2 的类型弱化为字符串,从而将 22 作为字符串存储在 two 中,这与你的意图完全相反,并且毫无意义。在 Rust 中,相同的代码,即 let mut two = "2"; two = 2 + two;,将在编译时被捕获,并抛出以下错误:cannot add &strto``。这一特性使得代码重构更加安全,并在编译时而不是在运行时捕获大多数错误。

使用 Rust 编写的程序既具有很高的表达性,又具有高性能,这意味着你可以拥有类似于高阶函数和惰性迭代器等高级函数式语言的大多数特性,同时它编译后的代码效率又像 C/C++ 程序一样高效。支撑其许多设计决策的核心理念是编译时内存安全、无畏并发和零成本抽象。让我们详细阐述这些观点。

编译时内存安全:Rust 编译器可以在编译时跟踪程序中拥有资源的变量,并且这一切都不需要垃圾回收器来完成。

资源可以是内存地址、一个持有值的变量、共享内存引用、文件句柄、网络套接字或数据库连接句柄。

这意味着你不会在运行时遇到指针使用后释放、重复释放或悬垂指针等臭名昭著的问题。Rust 中的引用类型(在它们前面有 & 的类型)隐式地关联着一个生命周期标签(例如 `'foo'),有时程序员还会显式地对其进行注释。通过生命周期,编译器可以跟踪代码中可以安全使用引用的地方,如果发现非法使用,则在编译时报告错误。为了实现这一点,Rust 通过在引用上使用这些生命周期标签来运行借用/引用检查算法,确保你永远不能访问已经被释放的内存地址。它还确保在某个变量使用指针时,你不能释放该指针。我们将在第五章“内存管理和安全性”中详细介绍这一点。

零成本抽象:编程全部关于管理复杂性,而良好的抽象有助于简化这一点。让我们通过 Rust 和 Kotlin(一个针对 Java 虚拟机JVM)的语言,它允许我们编写高级代码,易于阅读和推理)中抽象的一个优秀例子来探讨抽象。我们将比较 Kotlin 的流和 Rust 的迭代器在操作数字列表时的表现,并对比 Rust 提供的零成本抽象原则。这里的抽象是能够使用接受其他方法作为参数的方法来根据条件过滤数字,而不使用手动循环。在这里使用 Kotlin 是因为它与 Rust 的视觉相似性。代码相当简单易懂,我们的目标是给出一个高级解释。我们将略过代码的细节,因为整个例子的主要目的是理解零成本属性。

首先,让我们看看 Kotlin 中的代码(以下代码可以在线运行:try.kotlinlang.org):

1\. import java.util.stream.Collectors
2\. 
3\. fun main(args: Array<String>) {
5\.     // Create a stream of numbers
6\.     val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10).stream()
7\.     val evens = numbers.filter { it -> it % 2 == 0 } 
8\.     val evenSquares = evens.map { it -> it * it }  
9\.     val result = evenSquares.collect(Collectors.toList())
10\.    println(result)       // prints [4,16,36,64,100]
11\.    
12\.    println(evens)
13\.    println(evenSquares)
14\. }

我们创建一个数字流(第 6 行)并调用一系列方法(filtermap)来转换元素,只收集偶数的平方。这些方法可以接受一个闭包或函数(即第 8 行的 it -> it * it)来转换集合中的每个元素。在函数式风格的语言中,当我们对流/迭代器调用这些方法时,对于每个这样的调用,语言都会创建一个中间对象来保持与正在执行的操作相关的任何状态或元数据。因此,evensevenSquares 将是两个不同的中间对象,它们在 JVM 堆上分配。在堆上分配东西会产生内存开销。这就是我们在 Kotlin 中必须支付的抽象额外成本!

当我们打印 evensevenSquares 的值时,我们确实得到不同的对象,如下所示:

java.util.stream.ReferencePipeline$Head@51521cc1

java.util.stream.ReferencePipeline$3@1b4fb997

@ 符号后面的十六进制值是在 JVM 上对象的哈希码。由于哈希码不同,它们是不同的对象。

在 Rust 中,我们做同样的事情(以下代码可以在线运行:gist.github.com/rust-play/e0572da05d999cfb6eb802d003b33ffa):

1\. fn main() {
2\.     let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10].into_iter();
3\.     let evens = numbers.filter(|x| *x % 2 == 0);
4\.     let even_squares = evens.clone().map(|x| x * x);
5\.     let result = even_squares.clone().collect::<Vec<_>>();
6\.     println!("{:?}", result);      // prints [4,16,36,64,100]
7\.     println!("{:?}\n{:?}", evens, even_squares);
8\. }

略过细节,在第 2 行我们调用 vec![] 来在堆上创建一个数字列表,然后调用 into_iter() 来使其成为数字的迭代器/流。into_iter() 方法从一个集合(在这里,Vec <i32> 是一个有符号 32 位整数的列表)创建一个包装迭代器类型,IntoIter([1,2,3,4,5,6,7,8,9,10])。这个迭代器类型引用原始数字列表。然后我们执行过滤和映射转换(第 3 和 4 行),就像我们在 Kotlin 中做的那样。第 7 和 8 行打印 evenseven_squares 的类型,如下(为了简洁起见,省略了一些细节):

evens:  Filter { iter: IntoIter( <numbers> ) } 
even_squares:  Map { iter: Filter { iter: IntoIter( <numbers> ) }}

中间对象FilterMap是包装类型(不在堆上分配),它们本身是包装器,持有对第 2 行原始数字列表的引用。在第 4 行和第 5 行调用filtermap时创建的包装器结构之间没有任何指针间接引用,并且不产生堆分配开销,这与 Kotlin 的情况不同。所有这些都归结为高效的汇编代码,这与手动使用循环编写的版本相当。

无畏并发:当我们说 Rust 是并发安全的,我们的意思是该语言具有应用程序编程接口API)和抽象,使得编写正确且安全的并发代码变得非常容易。与 C++相比,编写并发代码时出错的可能性相当高。在 C++中对多个线程的数据访问进行同步时,你需要负责每次进入临界区时调用mutex.lock(),并在退出该区域时调用mutex.unlock()

// C++

mutex.lock();                         // Mutex locked, good to go 
 // Do super critical stuff
mutex.unlock();                       // We're done

临界区:这是一组需要原子执行的指令/语句。在这里,“原子”意味着没有其他线程可以中断临界区中当前正在执行的线程,并且在临界区代码执行期间,任何线程都不会感知到中间值。

在一个大型代码库中,许多开发者协作编写代码,你可能会忘记在从多个线程访问共享对象之前调用mutex.lock(),这可能导致数据竞争。在其他情况下,你可能会忘记解锁mutex,导致其他想要访问数据的线程饥饿。

Rust 对此有不同的看法。在这里,你将数据包裹在Mutex类型中,以确保从多个线程对数据进行同步可变访问:

// Rust

use std::sync::Mutex;

fn main() {
    let value = Mutex::new(23);
    *value.lock().unwrap() += 1;   // modify
}                                  // unlocks here automatically

在前面的代码中,我们在对 value 调用 lock() 之后能够修改数据。Rust 使用保护共享数据本身而不是代码的概念。与 C++ 不同,与 Mutex 和受保护数据的交互不是独立的。你不能在不调用 Mutex 类型的 lock 的情况下访问内部数据。那么释放锁呢?好吧,调用 lock() 会返回一个名为 MutexGuard 的东西,当变量超出作用域时会自动释放锁。这是 Rust 提供的许多安全并发抽象之一。我们将在 第八章 中详细介绍它们,即 并发。另一个新颖的想法是标记特性概念,它在编译时验证并确保并发代码中对数据的同步和安全的访问。特性在 第四章 中详细描述,类型、泛型和特性。类型使用标记特性 SendSync 进行注释,以指示它们是否可以安全地发送到线程或线程之间共享。当程序向线程发送值时,编译器会检查该值是否实现了所需的标记特性,如果未实现,则禁止使用该值。这样,Rust 允许你编写无需担心的并发代码,在编译时编译器会捕获多线程代码中的错误。编写并发代码已经很困难了。使用 C/C++,它变得更加困难且晦涩。CPU 的时钟频率并没有增加;相反,我们增加了更多的核心。因此,并发编程是未来的方向。Rust 使编写并发代码变得轻而易举,并降低了许多人编写安全并发代码的门槛。

Rust 还采用了 C++ 的 RAII 习惯用法进行资源初始化。这种技术基本上将资源的生命周期与对象的生存周期绑定在一起,而堆分配类型的释放则是通过 drop 方法来完成的,该方法由 drop 特性提供。当变量超出作用域时,它会自动调用。它还用 ResultOption 类型替换了空指针的概念,我们将在 第六章 中详细介绍,即 错误处理。这意味着 Rust 不允许代码中出现空/未定义的值,除非通过外部函数接口与其他语言交互,或者在使用不安全代码时。该语言还强调组合优于继承,并具有一个特性系统,该系统由数据类型实现,类似于 Haskell 类型类,也称为增强版的 Java 接口。Rust 中的特性是其许多功能的支柱,正如我们将在接下来的章节中看到的。

最后但同样重要的是,Rust 的社区非常活跃和友好,该语言有全面的文档,可以在 doc.rust-lang.org 找到。连续三年(2016、2017 和 2018 年),Stack Overflow 的开发者调查将 Rust 评为最受欢迎的编程语言,因此可以说整个编程社区都非常关注它。总的来说,如果你旨在编写高性能软件,同时享受许多现代语言特性和一个出色的社区,那么你应该关注 Rust!

安装 Rust 编译器和工具链

Rust 工具链有两个主要组件:编译器 rustc 和包管理器 cargo,它有助于管理 Rust 项目。工具链有三个发布渠道:

  • Nightly:来自主开发分支的每日成功构建。这包含了所有最新的功能,其中许多是不稳定的。

  • Beta:每六周发布一次。从一个新的 beta 分支中提取。它只包含标记为稳定的特性。

  • Stable:每六周发布一次。之前的 beta 分支成为新的稳定发布。

鼓励开发者使用稳定发布渠道。然而,nightly 版本提供了前沿特性,某些库和程序需要它。你可以使用 rustup 轻松切换到 nightly 工具链。我们稍后将看到如何做到这一点。

使用 rustup.rs

Rustup 是一个工具,用于在所有支持的平台上安装 Rust 编译器。为了使不同平台上的开发者更容易下载和使用该语言,Rust 团队开发了 rustup。它是一个用 Rust 编写的命令行工具,提供了一种简单的方式来安装编译器的预构建二进制文件和用于交叉编译的标准库的二进制构建。它还可以安装其他组件,例如 Rust 源代码、文档、Rust 格式化工具rustfmt)、Rust 语言服务器RLS 用于 IDEs),以及其他开发者工具,并且它在所有平台上运行,包括 Windows。

从他们的官方页面 rustup.rs 可以看到,安装工具链的推荐方法是运行以下命令:

curl https://sh.rustup.rs -sSf | sh

默认情况下,安装程序会安装 Rust 编译器的稳定版本、其包管理器 Cargo 以及语言的标准化库文档,以便可以离线查看。这些默认安装在 ~/.cargo 目录下。Rustup 还会更新你的 PATH 环境变量,使其指向此目录。

以下是在 Ubuntu 16.04 上运行先前命令的截图:

图片

如果你需要对你的安装进行任何更改,请选择 2。然而,默认设置对我们来说已经足够好了,所以我们将继续选择 1。以下是安装后的输出:

图片

Rustup 还具有其他功能,例如通过运行 rustup update 更新工具链到最新版本。它也可以通过 rustup self update 更新自身。它还提供了特定目录的工具链配置。默认工具链被全局设置为安装的工具链,在大多数情况下是稳定版工具链。你可以通过调用 rustup show 来查看默认的工具链。如果你想为你的某个项目使用最新的夜间版工具链,你可以通过运行 rustup override set nightly 告诉 rustup 为特定目录切换到夜间版。如果出于某种原因,有人想使用工具链的旧版本或降级(比如,2016-06-03 的夜间构建),如果运行 rustup install nightly-2016-06-03,rustup 也可以下载它,然后通过 override 子命令设置相同的版本。有关 rustup 的更多信息,请参阅 github.com/rust-lang-nursery/rustup.rs

注意:本书中的所有代码示例和项目都是基于编译器版本 rustc 1.32.0 (9fda7c223 2019-01-16)

现在,你应该已经拥有了编译和运行 Rust 编写的程序所需的一切。让我们开始 Rust 之旅吧!

语言之旅

对于基本语言特性,Rust 并没有偏离你在其他语言中习惯的内容太远。在高级层面,一个 Rust 程序被组织成模块,其中根模块包含一个 main() 函数。对于可执行文件,根模块通常是 main.rs 文件,而对于库,则是 lib.rs 文件。在一个模块内部,你可以定义函数、导入库、定义类型、创建常量、编写测试和宏,甚至创建嵌套模块。我们将逐一了解它们,但让我们从基础开始。这是一个简单的 Rust 程序,用于问候你:

// greet.rs

1\. use std::env;
2\. 
3\. fn main() {
4\.    let name = env::args().skip(1).next();
5\.    match name {
6\.       Some(n) => println!("Hi there ! {}", n),
7\.       None => panic!("Didn't receive any name ?")
8\.    }
9\. }

让我们编译并运行这个程序。将其写入名为 greet.rs 的文件中,并使用文件名运行 rustc,然后将你的名字作为参数传递。我传递了名字 Ferris,这是 Rust 的非官方吉祥物,在我的机器上得到了以下输出:

图片

太棒了!它问候了 Ferris。让我们逐行浏览这个程序。

在第 1 行,我们从 std crate(库被称为 crate)中导入了一个名为 env 的模块。std 是 Rust 的标准库。在第 3 行,我们有我们常用的 main 函数。然后,在第 4 行,我们调用 env 模块中的 args() 函数,该函数返回一个迭代器(序列),其中包含传递给我们的程序的参数。由于第一个参数包含我们的程序名称,我们想要跳过它,所以我们调用 skip 并传入一个数字,这表示我们要跳过多少个元素(1)。由于迭代器在 Rust 中是惰性的,不会预先计算任何东西,因此我们必须明确要求它提供下一个元素,所以我们调用 next(),它返回一个名为 Option 的枚举类型。这可以是 Some(value) 值或 None 值,因为用户可能会忘记提供参数。

在第 5 行,我们使用 Rust 的强大 match 表达式对变量 name 进行操作,并检查它是否是 Some(n)None 值。matchif else 构造类似,但更强大。在第 6 行,当它是 Some(n) 时,我们调用 println!(),传入我们的内部字符串变量 n(当使用 match 表达式时,它会自动声明),然后问候我们的用户。println! 调用不是一个函数,而是一个 (它们都以 ! 结尾)。最后,在第 7 行,如果它是枚举的 None 变体,我们只是 panic!()(另一个宏),这会导致程序终止,并留下一个错误消息。

println! 宏,正如我们所见,接受一个字符串,该字符串可以使用 "{}" 语法包含用于项的占位符。这些字符串被称为 格式字符串,而字符串中的 "{}" 被称为 格式说明符。对于打印简单类型,如原始类型,我们可以使用 "{}" 格式说明符,而对于其他类型,我们使用 "{:?}" 格式说明符。不过,这里还有一些细节。当 println! 遇到格式说明符,即 "{}",以及相应的替换值时,它会调用该值上的一个方法,该方法返回其字符串表示形式。这个方法是 trait 的一部分。对于 "{}" 说明符,它调用 Display trait 中的方法,而对于 "{:?}",它调用 Debug trait 中的方法。后者主要用于调试,而前者用于显示数据类型的人类可读输出。这与 Java 中的 toString() 方法有些相似。在开发过程中,你通常需要打印数据类型以进行调试。当使用 "{:?}" 说明符时,如果这些方法在类型上不可用,我们则需要在该类型上添加一个 #[derive(Debug)] 属性来获取这些方法。我们将在后续章节中详细解释属性,但预计你将在未来的代码示例中看到这一点。我们还将回顾第九章中的 println! 宏,使用宏进行元编程

手动运行 rustc 并非真实程序的正确做法,但对于本章中的这些小程序来说,这样做是可以的。在后续章节中,我们将使用 Rust 的包管理器来构建和运行我们的程序。除了在本地运行编译器之外,还可以使用官方在线编译器 Rust playground 来运行代码示例,该编译器可以在 play.rust-lang.org 找到。以下是我机器上的截图:

Rust playground 还支持导入和使用外部库,以便在尝试示例程序时使用。

在前面的示例中,我们得到了一个基本的 Rust 程序的高级概述,但没有深入所有细节和语法。在下一节中,我们将分别解释语言特性和它们的语法。以下解释旨在为您提供足够的背景知识,以便您能够快速开始编写 Rust 程序,而无需详尽地了解所有用例。为了简洁起见,每个部分还包含对解释这些概念的章节的引用。此外,Rust 文档页面 doc.rust-lang.org/std/index.html 将帮助您深入了解,并且具有内置的搜索功能,非常易于阅读。鼓励您积极搜索以下章节中解释的任何结构。这将帮助您更好地理解您正在学习的概念。

本章中的所有代码示例都可以在本书的 GitHub 仓库中找到 (PacktPublishing/Mastering-RUST-Second-Edition)。对于本章,它们位于 第一章,Rust 入门 目录 – 本书其余章节也遵循相同的约定。

一些代码文件被故意设计为无法编译,这样您就可以在编译器的帮助下自己修复它们。

话虽如此,让我们从 Rust 中的基本原始类型开始。

原始类型

Rust 有以下内置原始类型:

  • bool: 这些是常规布尔值,可以是 truefalse

  • char: 字符,例如 e

  • 整数类型:这些类型以位宽为特征。Rust 支持宽度高达 128 位的整数:

    有符号 无符号
    i8 u8
    i16 u16
    i32 u32
    i64 u64
    i128 u128
  • isize: 指针大小的有符号整数类型。在 32 位 CPU 上相当于 i32,在 64 位 CPU 上相当于 i64

  • usize: 指针大小的无符号整数类型。在 32 位 CPU 上相当于 i32,在 64 位 CPU 上相当于 i64

  • f32: 32 位浮点类型。实现了 IEEE 754 浮点表示标准。

  • f64: 64 位浮点类型。

  • [T; N]:一个固定大小的数组,对于元素类型 T 和非负编译时常量大小 N。

  • [T]:任何类型 T 的连续序列的动态大小视图。

  • str:字符串切片,主要用于引用,即 &str

  • (T, U, ..):一个有限序列,其中 T 和 U 可以是不同类型。

  • fn(i32) -> i32:一个接受 i32 并返回 i32 的函数。函数也有类型。

声明变量和不可变性

变量允许我们存储一个值,并在代码中稍后轻松地引用它。在 Rust 中,我们使用 let 关键字来声明变量。我们已经在上一节的 greet.rs 示例中看到了它的身影。在主流的命令式语言,如 C 或 Python 中,初始化变量并不会阻止你将其重新赋值给其他值。Rust 在这里与主流做法不同,它默认使变量不可变,也就是说,你初始化变量后不能将其赋值给其他值。如果你需要变量稍后指向其他东西(同一类型),你需要在它前面加上 mut 关键字。Rust 要求你尽可能明确地表达你的意图。考虑以下代码:

// variables.rs

fn main() {
    let target = "world";
    let mut greeting = "Hello";
    println!("{}, {}", greeting, target);
    greeting = "How are you doing";
    target = "mate";
    println!("{}, {}", greeting, target);
}

我们声明了两个变量,targetgreetingtarget 是一个不可变绑定,而 greeting 前面有一个 mut,使其成为一个可变绑定。如果我们运行这个程序,则会得到以下错误:

图片

如前述错误信息所示,Rust 不允许你再次对 target 进行赋值。为了使这个程序编译,我们需要在 let 语句中的 target 前面添加 mut,然后再次编译和运行它。以下是你运行程序时的输出:

$ rustc variables.rs
$ ./variables
Hello, world
How are you doing, mate

let 在 Rust 中做的不只是赋值变量。它是一个模式匹配语句。在 第七章 的 高级概念 中,我们将更详细地探讨 let。接下来,我们将看看函数。

函数

函数将一系列指令抽象成命名实体,可以在稍后通过其他代码调用,有助于管理复杂性。我们已经在我们的 greet.rs 程序中使用了一个函数,即 main 函数。让我们看看我们如何定义另一个函数:

// functions.rs

fn add(a: u64, b: u64) -> u64 {
    a + b
}

fn main() {
    let a: u64 = 17;
    let b = 3;
    let result = add(a, b);
    println!("Result {}", result);
}

在前面的代码中,我们创建了一个名为 add 的新函数。使用 fn 关键字来创建函数,后面跟着其名称 add,括号内的参数 ab,以及花括号 {} 内的函数体。参数的类型位于冒号 : 的右侧。函数的返回类型使用 -> 符号指定,后面跟着类型 u64,如果函数没有返回值,则可以省略。函数也有类型。我们的 add 函数类型表示为 fn(u64, u64) -> u64。它们也可以存储在变量中,并传递给其他函数。

如果你查看add函数的主体,我们不需要return关键字来返回a + b,就像在其他语言中那样。最后一个表达式会自动返回。然而,我们确实有return关键字可用于提前返回。函数基本上是返回值的表达式,默认情况下返回值类型为()(单元)类型,类似于 C/C++中的void返回类型。它们也可以在其他函数内声明。这种用例是当你有一个在函数内部的功能(比如foo)难以作为语句序列进行推理时。在这种情况下,可以在局部函数bar中提取这些行,然后bar在父函数foo内部定义。

main函数中,我们使用let关键字声明了两个变量ab。与b一样,我们甚至可以省略指定类型,因为 Rust 在大多数情况下能够通过检查你的代码来推断变量的类型。这也适用于result,它是一个u64值。这个特性有助于防止类型签名杂乱,并提高代码的可读性,尤其是在你的类型嵌套在具有长名称的其他类型内部时。

Rust 的类型推断基于 Hindley Milner 类型系统。它是一组规则和算法,使编程语言能够进行类型推断。这是一种高效的类型推断方法,以线性时间执行,使得对大型程序进行类型检查变得实用。

我们也可以有修改其参数的函数。考虑以下代码:

// function_mut.rs

fn increase_by(mut val: u32, how_much: u32) {
    val += how_much;
    println!("You made {} points", val);
}

fn main() {
    let score = 2048;
    increase_by(score, 30);
}

我们声明了一个score变量,其值为2048,并调用了increase_by函数,将score和值30作为第二个参数传递。在increase_by函数中,我们指定第一个参数为mut val,表示该参数应被视为可变的,这允许从函数内部修改变量。我们的increase_by函数修改了val绑定并打印了值。以下是运行程序时的输出:

$ rustc function_mut.rs 
$ ./function_mut 
You made 2078 points

接下来,让我们看看闭包。

闭包

Rust 也支持闭包。闭包类似于函数,但它们具有更多关于它们声明时的环境或作用域的信息。虽然函数与它们的名字相关联,但闭包是无名的定义,但可以赋值给变量。Rust 类型推断的另一个优点是,在大多数情况下,你可以指定闭包的参数而不需要它们的类型。这里是最简单的闭包示例:let my_closure = || ();。我们定义了一个无参数的闭包,它什么也不做。我们可以通过调用my_closure()来调用它,就像函数一样。两个垂直条||包含闭包的参数(如果有),例如|a, b|。当 Rust 无法确定正确的类型时,有时需要指定参数的类型(|a: u32|)。像函数一样,闭包也可以存储在变量中,稍后调用或传递给其他函数。然而,闭包的主体可以是一行表达式,也可以是一对花括号,用于多行表达式。一个更复杂的闭包可能如下所示:

// closures.rs

fn main() {
    let doubler = |x| x * 2;
    let value = 5;
    let twice = doubler(value);
    println!("{} doubled is {}", value, twice);

    let big_closure = |b, c| {
        let z = b + c;
        z * twice
    };

    let some_number = big_closure(1, 2);
    println!("Result from closure: {}", some_number);
}

在前面的代码中,我们定义了两个闭包:doublerbig_closuredoubler将给定的值加倍;在这种情况下,它从父作用域或环境(即main函数)传递value。同样,在big_closure中,我们使用其环境中的变量twice。这个闭包在花括号内有多行表达式,并且需要以分号结束,以便我们可以将其赋值给big_closure变量。稍后,我们调用big_closure,传递1, 2,并打印some_number

闭包的主要用途是作为高阶函数的参数。高阶函数是一种接受另一个函数或闭包作为其参数的函数。例如,标准库中的thread::spawn函数接受一个闭包,在其中你可以编写你想要在另一个线程中运行的代码。另一个闭包提供便利抽象的例子是当你有一个操作集合(如Vec)的函数,而你想要根据某些条件过滤项目时。Rust 的Iterator特质有一个名为filter的方法,它接受一个闭包作为参数。这个闭包由用户定义,它返回truefalse,具体取决于用户想要如何过滤集合中的项目。我们将在第七章高级概念中更深入地了解闭包。

字符串

字符串是任何编程语言中最常用的数据类型之一。在 Rust 中,它们通常以两种形式存在:&str类型(发音为stir)和String类型。Rust 字符串保证是有效的 UTF-8 编码字节序列。它们不像 C 字符串那样以空字符终止,并且可以在它们之间包含空字节。以下程序展示了这两种类型的作用:

// strings.rs

fn main() {
    let question = "How are you ?";            // a &str type
    let person: String = "Bob".to_string();
    let namaste = String::from("नमस्ते");        // unicodes yay!

    println!("{}! {} {}", namaste, question, person);
}

在前面的代码中,personnamasteString类型,而question&str类型。你可以用多种方式创建String类型。字符串是在堆上分配的,而&str类型通常是现有字符串的指针,这些字符串可能位于栈上、堆上或在编译对象的代码段的数据段中。&是一个用于创建任何类型的指针的运算符。在初始化前面的代码中的字符串后,我们使用println!宏通过格式字符串将它们一起打印出来。这就是字符串的非常基础的知识。字符串将在第七章,“高级概念”中详细讲解。

条件语句和决策

条件语句在其他语言中也有类似的结构。它们遵循类似于 C 语言的if {} else {}结构:

// if_else.rs

fn main() {
    let rust_is_awesome = true;
    if rust_is_awesome {
        println!("Indeed");
    } else {
        println!("Well, you should try Rust !");
    }
}

在 Rust 中,if结构不是一个语句,而是一个表达式。在一般的编程术语中,语句不会返回任何值,但表达式会。这种区别意味着 Rust 中的if else条件语句总是返回一个值。这个值可能是一个空的()单元类型,也可能是一个实际值。括号内最后一行剩余的内容将成为if else表达式的返回值。重要的是要注意,ifelse分支应该有相同的返回类型。此外,我们不需要在if条件表达式周围使用括号,正如你可以在前面的代码中看到的那样。我们甚至可以将if else块的值赋给一个变量:

// if_assign.rs

fn main() {
    let result = if 1 == 2 { 
        "Wait, what ?" 
    } else { 
        "Rust makes sense" 
    };

    println!("You know what ? {}.", result);
}

当将if else表达式返回的值赋值时,我们需要在它们后面加上分号。例如,if { ...是一个表达式,而let是一个期望我们在末尾加上分号的语句。在赋值的情况下,如果我们从前面的代码中移除else {}块,编译器会抛出一个错误,如下所示:

图片

如果没有else块,当if条件评估为false时,结果将是()result变量将有两个可能的值,即()&str。Rust 不允许在一个变量中存储多个类型。因此,在这种情况下,我们需要if {}else {}块返回相同类型的值。此外,在条件分支中添加分号会改变代码的含义。在以下代码中,在if块中的字符串后添加分号,编译器会将其解释为你想要丢弃该值:

// if_else_no_value.rs

fn main() { 
    let result = if 1 == 2 { 
        "Nothing makes sense"; 
    } else { 
        "Sanity reigns"; 
    };

    println!("Result of computation: {:?}", result); 
}

在这种情况下,结果将是一个空的(),这就是为什么我们不得不稍微改变println!表达式(即{:?});这种类型不能以常规方式打印出来。现在,对于更复杂的多值决策;Rust 还有一个称为match表达式的强大结构,我们将在下一节中探讨。

匹配表达式

Rust 的 match 表达式非常易于使用。它基本上是 C 语言的 switch 语句的强化版,允许你根据变量的值做出决策,并且它具有先进的过滤能力。以下是一个使用匹配表达式的程序:

// match_expression.rs

fn req_status() -> u32 {
    200
}

fn main() {
    let status = req_status();
    match status {
        200 => println!("Success"),
        404 => println!("Not Found"),
        other => {
            println!("Request failed with code: {}", other);
            // get response from cache
        }
    }
}

在前面的代码中,我们有一个返回模拟 HTTP 请求状态码 200req_status 函数,我们在 main 中调用它并将结果赋值给 status。然后我们使用 match 关键字匹配这个值,后面跟着我们想要检查的变量(status),然后是一对大括号。在大括号内,我们写表达式——这些被称为 匹配分支。这些分支代表了被匹配变量可能取的值。每个匹配分支是通过写出变量的可能值,然后跟一个 =>,再然后是右侧的表达式来编写的。在右侧,你可以有一个单行表达式或一个在大括号 {} 内的多行表达式。当写成单行表达式时,它们需要用逗号分隔。此外,每个匹配分支必须返回相同的类型。在这种情况下,每个匹配分支返回 Unit 类型 ()

match 表达式的一个很好的特性或你可以称之为保证是,我们必须对所有可能的值进行穷举匹配。在我们的例子中,这将是列出所有直到 i32 最大值的数字。然而,实际上这是不可能的,所以 Rust 允许我们通过使用一个 catch all 变量(这里,它是 other)或一个 _(下划线)来忽略其余的可能性,如果我们想忽略这个值。当有多个可能的值时,匹配表达式是做出关于值的决策的主要方式,而且它们写起来非常简洁。像 if else 表达式一样,当用分号分隔时,匹配表达式的返回值也可以在 let 语句中分配给一个变量,并且所有匹配分支返回相同的类型。

循环

在 Rust 中,重复操作可以通过三种结构实现,即 loopwhilefor。在所有这些结构中,我们都有常用的 continuebreak 关键字,分别允许你跳过循环或从循环中退出。以下是一个使用 loop 的例子,它与 C 语言的 while(true) 等效:

// loops.rs 

fn main() { 
    let mut x = 1024;
    loop { 
        if x < 0 { 
            break; 
        } 
        println!("{} more runs to go", x); 
        x -= 1; 
    } 
}

loop 表示一个无限循环。在前面的代码中,我们简单地递减 x 的值,直到它达到条件 x < 0,在那里我们退出循环。在 Rust 中使用 loop 的一个额外特性是能够给 loop 块加上一个标签。这可以在你有两个或更多嵌套循环,并且想要从任何一个循环中退出,而不仅仅是紧邻 break 语句的循环时使用。以下是一个使用循环标签退出 loop 的例子:

// loop_labels.rs

fn silly_sub(a: i32, b: i32) -> i32 {
    let mut result = 0;
    'increment: loop {
        if result == a {
            let mut dec = b;
            'decrement: loop {
                if dec == 0 {
                    // breaks directly out of 'increment loop
                    break 'increment;
                } else {
                    result -= 1;
                    dec -= 1;
                }
            }
        } else {
            result += 1;
        }
    }
    result
}

fn main() {
    let a = 10;
    let b = 4;
    let result = silly_sub(a, b);
    println!("{} minus {} is {}", a, b, result);
}

在前面的代码中,我们正在进行一个非常低效的减法操作,只是为了演示在嵌套循环中使用标签的用法。在内层'decrement标签中,当dec等于0时,我们可以传递一个标签给break(这里,这是'increment'),然后跳出外层的'increment'循环。

现在,让我们看看while循环。这里没有太多花哨的东西:

// while.rs 

fn main() { 
    let mut x = 1000; 
    while x > 0 { 
        println!("{} more runs to go", x); 
        x -= 1;     
    }
}

Rust 也有一个for关键字,与其他语言的 for 循环类似,但在实现上却大不相同。Rust 的for实际上是一个更强大的重复构造的语法糖,称为迭代器。我们将在第七章,高级概念中更详细地讨论它们。简单来说,Rust 中的 for 循环只适用于可以转换为迭代器的类型。其中一种类型是Range类型。Range类型可以指代一个数字范围,例如(0..10)。它们可以用在 for 循环中,如下所示:

// for_loops.rs

fn main() {
    // does not include 10
    print!("Normal ranges: ");
    for i in 0..10 {
        print!("{},", i);
    }

    println!();       // just a newline
    print!("Inclusive ranges: ");
    // counts till 10
    for i in 0..=10 {
        print!("{},", i);
    }
}

除了正常的范围语法,即0..10,它不包括10之外,Rust 还有一个包含范围语法0..=10,它迭代直到10,如第二个for循环所示。现在,让我们继续讨论用户定义的数据类型。

用户定义类型

正如其名所示,用户定义类型是由您定义的类型。这些可以由几个类型组成。它们可能是一个原始类型的包装,或者是由几个用户定义类型组成的组合。它们有三种形式:结构体、枚举和联合体,或者更常见地称为结构体枚举联合体。它们允许您轻松地表达您的数据。用户定义类型的命名约定遵循驼峰式风格。结构体和枚举比 C 语言的结构体和枚举更强大,而 Rust 中的联合体与 C 语言非常接近,主要是为了与 C 代码库交互。我们将在本节中介绍结构体和枚举,而联合体将在第七章,高级概念中介绍。

结构体

在 Rust 中,我们可以声明三种结构体形式。其中最简单的是单元结构体,它使用struct关键字编写,后面跟着其名称和结尾的分号。以下代码示例定义了一个单元结构体:

// unit_struct.rs

struct Dummy;

fn main() {
    let value = Dummy;
}

在前面的代码中,我们定义了一个名为Dummy的单元结构体。在main函数中,我们可以仅使用其名称来初始化此类型。value现在包含了一个Dummy类型的实例,并且是一个零大小值。单元结构体在运行时不会占用任何大小,因为它们没有与之关联的数据。单元结构体的用途非常有限。它们可以用来模拟没有数据或状态关联的实体。另一个用途是使用它们来表示错误类型,其中结构体本身足以理解错误,而不需要对其描述。另一个用途是在状态机实现中表示状态。接下来,让我们看看结构体的第二种形式。

结构体的第二种形式是元组结构体,它关联着数据。在这里,各个字段没有命名,而是通过在定义中的位置来引用。假设你正在编写一个用于图形应用程序的颜色转换/计算库,并想在代码中表示RGB颜色值。我们可以这样表示我们的Color类型和相关项:

// tuple_struct.rs 

struct Color(u8, u8, u8);

fn main() {
    let white = Color(255, 255, 255);

    // You can pull them out by index
    let red = white.0;
    let green = white.1;
    let blue = white.2;

    println!("Red value: {}", red);
    println!("Green value: {}", green);
    println!("Blue value: {}\n", blue);

    let orange = Color(255, 165, 0);

    // You can also destructure the fields directly
    let Color(r, g, b) = orange;
    println!("R: {}, G: {}, B: {} (orange)", r, g, b);

    // Can also ignore fields while destructuring
    let Color(r, _, b) = orange;
}

在前面的代码中,Color(u8, u8, u8)是一个元组结构体,它被创建并存储在white中。然后我们使用white.0语法访问white中的单个颜色组件。可以通过variable.<index>语法访问元组结构体内的字段,其中index指的是字段在结构体中的位置,从0开始。另一种访问结构体单个字段的方法是使用let语句解构结构体。在第二部分,我们创建了一个颜色orange。随后,我们编写了let语句,左侧是Color(r, g, b),右侧是我们的orange。这导致orange中的三个字段被存储在rgb变量中。rgb的类型也会自动为我们推断。

当需要建模具有不到四个或五个属性的数据时,元组结构体是一个理想的选择。超过这个数量会阻碍可读性和推理。对于具有超过三个字段的情况,建议使用类似 C 的结构体,这是第三种形式,也是最常用的形式。考虑以下代码:

// structs.rs

struct Player {
    name: String,
    iq: u8,
    friends: u8,
    score: u16
}

fn bump_player_score(mut player: Player, score: u16) {
    player.score += 120;
    println!("Updated player stats:");
    println!("Name: {}", player.name);
    println!("IQ: {}", player.iq);
    println!("Friends: {}", player.friends);
    println!("Score: {}", player.score);
}

fn main() {
    let name = "Alice".to_string();
    let player = Player { name,
                          iq: 171,
                          friends: 134,
                          score: 1129 };

   bump_player_score(player, 120);
}

在前面的代码中,结构体是以与元组结构体相同的方式创建的,即通过写出struct关键字后跟结构体的名称。然而,它们以大括号开始,并且它们的字段声明是有名的。在大括号内,我们可以将字段写成field: type逗号分隔的对。创建结构体实例也很简单;我们写出Player,然后跟上一对大括号,其中包含逗号分隔的字段初始化。当我们从与字段名称相同的变量初始化字段时,我们可以使用字段初始化简写功能,正如前面代码中的name字段那样。然后我们可以通过使用struct.field_name语法轻松访问创建的实例的字段。在前面的代码中,我们还有一个名为bump_player_score的函数,它接受结构体Player作为参数。默认情况下,函数参数是不可变的,所以当我们想要修改玩家的分数时,我们需要在我们的函数中将参数更改为mut player,这允许我们修改其任何字段。在结构体上有mut意味着所有字段都具有可变性。

使用结构体而不是元组结构体的优点在于我们可以以任何顺序初始化字段。它还允许我们为字段提供有意义的名称。作为旁注,结构体的大小仅仅是其各个字段成员的总和,以及如果需要的话,任何数据对齐填充。它们与它们没有任何额外的元数据大小开销相关联。接下来,让我们看看枚举,也称为枚举。

枚举

当你需要对一个可以有多种类型的对象进行建模时,枚举是最佳选择。它们通过使用enum关键字,后跟枚举名称,然后是一对花括号来创建。在花括号内,我们可以写出该类型的所有可能性,这些被称为变体。这些变体可以带或不带包含在其中的数据定义,包含的数据可以是任何原始类型、结构体、元组结构体,甚至是枚举。然而,在递归情况下,当你有一个枚举Foo和一个包含Foo的变体时,这个变体需要位于一个指针类型(如BoxRc等)之后,以避免有递归无限类型定义。因为枚举也可以在栈上创建,所以它们需要有一个预定的尺寸,无限类型定义使得在编译时无法确定大小。现在,让我们看看如何创建一个枚举:

// enums.rs

enum Direction { 
    N, 
    E, 
    S, 
    W
}

enum PlayerAction {
    Move {
        direction: Direction,
        speed: u8
    },
    Wait, 
    Attack(Direction)   
}

fn main() {
    let simulated_player_action = PlayerAction::Move {
        direction: Direction::N,
        speed: 2,
    };
    match simulated_player_action {
        PlayerAction::Wait => println!("Player wants to wait"),
        PlayerAction::Move { direction, speed } => {
          println!("Player wants to move in direction {:?} with speed {}",
                direction, speed)
        }
        PlayerAction::Attack(direction) => {
            println!("Player wants to attack direction {:?}", direction)
        }
    };
}

上述代码定义了两种枚举类型:DirectionPlayerAction。然后我们通过选择任何变体,例如Direction::NPlayerAction::Wait,使用双冒号::在它们之间创建它们的实例。请注意,我们不能有一个未初始化的枚举,它必须是其中一个变体。给定一个枚举值,要查看枚举实例具有哪个变体,我们使用模式匹配通过使用match表达式。当我们对枚举进行匹配时,我们可以通过将变量放在字段如PlayerAction::Attack(direction)中的direction等字段的位置来直接解构变体的内容,这反过来意味着我们可以在我们的匹配分支中使用它们。

正如你在我们之前的Direction枚举中看到的那样,我们有一个#[derive(Debug)]注解。这是一个属性,它允许Direction实例使用println!()中的{:?}格式字符串进行打印。这是通过从名为Debug的特质生成方法来完成的。编译器会告诉我们是否缺少Debug特质,并给出有关如何修复的建议,因此我们需要在那里使用这个属性:

从函数式程序员的视角来看,结构和枚举也被称为代数数据类型(ADTs),因为它们可以表示的可能值的范围可以用代数规则来表示。例如,枚举被称为求和类型,因为它可以持有的值范围基本上是其变体值范围的求和,而结构被称为积类型,因为其可能的值范围是其各个字段值范围的笛卡尔积。在一般讨论它们时,我们有时会称它们为 ADTs。

类型上的函数和方法

没有行为的类型可能有限制,通常我们希望在类型上有函数或方法,这样我们就可以返回它们的新实例而不是手动构建它们,或者这样我们就有能力操作用户定义类型的字段。我们可以通过impl 来实现这一点,这可以理解为为类型提供实现。我们可以为所有用户定义类型或任何包装类型提供实现。首先,让我们看看如何为结构编写实现。

结构上的 Impl 块

我们可以通过两种功能向先前定义的Player结构添加行为:一个类似于构造函数的函数,它接受一个名称并设置Person中剩余字段的默认值,以及Person的 getter 和 setter 方法来设置朋友数量:

// struct_methods.rs

struct Player {
    name: String,
    iq: u8,
    friends: u8
}

impl Player {
    fn with_name(name: &str) -> Player {
        Player {
            name: name.to_string(),
            iq: 100,
            friends: 100
        }
    }

    fn get_friends(&self) -> u8 {
        self.friends
    }

    fn set_friends(&mut self, count: u8) {
        self.friends = count;
    }
}

fn main() {
    let mut player = Player::with_name("Dave");
    player.set_friends(23);
    println!("{}'s friends count: {}", player.name, player.get_friends());
    // another way to call instance methods.
    let _ = Player::get_friends(&player);
}

我们使用impl关键字,后跟我们要实现方法的类型,然后是花括号。在花括号内,我们可以编写两种方法:

  • 关联方法:没有self类型作为其第一个参数的方法。with_name方法被称为关联方法,因为它没有self作为第一个参数。它类似于面向对象语言中的静态方法。这些方法在类型本身上可用,并且不需要类型的实例来调用它们。关联方法通过在方法名前加上结构名和双冒号来调用,如下所示:
      Player::with_name("Dave");
  • 实例方法:以self值作为其第一个参数的函数。这里的self符号与 Python 中的self类似,指向实现该方法的实例(在这里,这是Player)。因此,get_friends()方法只能在已经创建的结构实例上调用:
      let player = Player::with_name("Dave");
      player.get_friends();

如果我们使用关联方法语法调用get_friends,即Player::get_friends(),编译器会给出以下错误:

图片

这里的错误具有误导性,但它表明实例方法基本上是与self作为第一个参数关联的方法,并且instance.foo()是一种语法糖。这意味着我们也可以这样调用它:Player::get_friends(&player);。在这个调用中,我们向方法传递了一个Player实例,即&self&player

在类型上,我们可以实现三种实例方法的变体:

  • self 作为第一个参数。在这种情况下,调用此方法不会允许你在之后使用该类型。

  • &self 作为第一个参数。此方法仅提供对类型实例的读取访问。

  • &mut self 作为第一个参数。此方法提供了对类型实例的可变访问。

我们的 set_friends 方法是一个 &mut self 方法,它允许我们修改 player 的字段。我们需要在 self 前面使用 & 操作符,这意味着 self 在方法执行期间被借用,这正是我们想要的。如果没有使用连字符,调用者会将所有权移动到方法中,这意味着值在 get_friends 返回后会进行去分配,我们就无法再使用 Player 实例了。如果你对术语移动和借用还不理解,不要担心,我们将在第五章,内存管理和安全性中解释所有这些内容。

现在,让我们来看一下枚举的实现。

枚举的 Impl 块

我们也可以为枚举提供实现。例如,考虑一个用 Rust 编写的支付库,它公开了一个名为 pay 的单一 API:

// enum_methods.rs

enum PaymentMode {
    Debit,
    Credit,
    Paypal
}

// Bunch of dummy payment handlers

fn pay_by_credit(amt: u64) {
    println!("Processing credit payment of {}", amt);
}
fn pay_by_debit(amt: u64) {
    println!("Processing debit payment of {}", amt);
}
fn paypal_redirect(amt: u64) {
    println!("Redirecting to paypal for amount: {}", amt);
}

impl PaymentMode {
    fn pay(&self, amount: u64) {
        match self {
            PaymentMode::Debit => pay_by_debit(amount),
            PaymentMode::Credit => pay_by_credit(amount),
            PaymentMode::Paypal => paypal_redirect(amount)
        }
    }
}

fn get_saved_payment_mode() -> PaymentMode {
    PaymentMode::Debit
}

fn main() {
    let payment_mode = get_saved_payment_mode();
    payment_mode.pay(512);
}

前面的代码中有一个名为 get_saved_payment_mode() 的方法,它返回一个用户的保存支付方式。这可以是信用卡借记卡Paypal。这最好用枚举(enum)来建模,其中不同的支付方式可以作为其变体添加。然后库为我们提供了一个单一的 pay() 方法,我们可以方便地提供要支付的金额。此方法确定枚举的哪个变体,并相应地调度到正确的支付服务提供商,而无需库消费者担心检查使用哪种支付方式。

枚举也广泛用于建模状态机,当与匹配语句结合使用时,它们使得状态转换代码非常简洁易写。它们还用于建模自定义错误类型。当枚举变体没有与之关联的数据时,它们可以像 C 枚举一样使用,其中变体隐式地具有从 0 开始的整数值,但也可以手动标记为整型(isize)值。这在与外部 C 库交互时很有用。

模块、导入和使用语句

语言通常提供一种方法将大型代码库拆分成多个文件以管理复杂性。Java 遵循每个.java文件一个公共类的约定,而 C++则提供了头文件和包含语句。Rust 也不例外,它为我们提供了模块。模块是命名空间或组织 Rust 程序代码的一种方式。为了在组织代码时提供灵活性,有多种创建模块的方法。模块是一个复杂的概念,为了本节的简洁性,我们将只突出使用它们的重要方面。模块的详细内容在第二章,使用 Cargo 管理项目中有所介绍。以下是关于 Rust 中模块的关键要点:

  • 每个 Rust 程序都需要有一个根模块。在可执行文件中,通常是main.rs文件,而对于库来说,则是lib.rs

  • 模块可以在其他模块内部声明,也可以组织成文件和目录。

  • 为了让编译器了解我们的模块,我们需要使用mod关键字来声明它,就像在根模块中使用mod my_module;一样。

  • 要使用模块内的任何项目,我们需要使用use关键字,以及模块的名称。这被称为将项目引入作用域。

  • 在模块内部定义的项目默认是私有的,你需要使用pub关键字来将它们暴露给消费者。

这就是模块的简要介绍。模块的一些高级特性也在第七章,高级概念中有所涉及。接下来,让我们看看标准库中可用的常用集合类型。

集合

通常情况下,你的程序需要处理多个数据实例。为此,我们有了集合类型。根据你的需求和数据在内存中的位置,Rust 提供了许多内置类型来存储数据集合。首先,我们有数组元组。然后,在标准库中,我们有动态集合类型,我们将介绍最常用的几种,即向量(项目列表)和映射(键/值项)。然后,我们还有集合类型的引用,称为切片,它基本上是某个其他变量拥有的连续数据片段的视图。让我们先从数组开始。

数组

数组有一个固定长度,可以存储相同类型的项目。它们用[T, N]表示,其中T是任何类型,N是数组中的元素数量。数组的大小不能是变量,而必须是字面量usize值:

// arrays.rs

fn main() { 
    let numbers: [u8; 10] = [1, 2, 3, 4, 5, 7, 8, 9, 10, 11]; 
    let floats = [0.1f64, 0.2, 0.3]; 

    println!("Number: {}", numbers[5]);
    println!("Float: {}", floats[2]);
}

在前面的代码中,我们声明了一个数组numbers,它包含10个元素,我们在左侧指定了类型。在第二个数组floats中,我们将类型指定为第一个数组项的后缀,即0.1f64。这是指定类型的另一种方式。接下来,让我们看看元组。

元组

元组与数组的不同之处在于,数组中的元素必须具有相同的类型,而元组中的项可以是不同类型的混合。它们是异构集合,用于存储不同类型的数据很有用。当从函数返回多个值时也可以使用。考虑以下使用元组的代码:

// tuples.rs

fn main() { 
    let num_and_str: (u8, &str) = (40, "Have a good day!");
    println!("{:?}", num_and_str);
    let (num, string) = num_and_str;
    println!("From tuple: Number: {}, String: {}", num, string);
}

在前面的代码中,num_and_str 是一个包含两个元素的元组,(u8, &str)。我们还可以从已声明的元组中提取值到单独的变量中。在打印元组后,我们在下一行将其解构为 numstring 变量,它们的类型会自动推断。这相当方便。

向量

向量类似于数组,但它们的长度或内容不需要预先知道,并且可以根据需要增长。它们在堆上分配。可以通过调用 Vec::new 构造函数或使用 vec![] 宏来创建:

// vec.rs

fn main() {
    let mut numbers_vec: Vec<u8> = Vec::new(); 
    numbers_vec.push(1); 
    numbers_vec.push(2); 

    let mut vec_with_macro = vec![1]; 
    vec_with_macro.push(2);
    let _ = vec_with_macro.pop();    // value ignored with `_`

    let message = if numbers_vec == vec_with_macro {
        "They are equal"
    } else {
        "Nah! They look different to me"
    };

    println!("{} {:?} {:?}", message, numbers_vec, vec_with_macro); 
}

在前面的代码中,我们以不同的方式创建了两个向量,numbers_vecvec_with_macro。我们可以使用 push() 方法向向量中添加元素,并使用 pop() 方法移除元素。如果你访问它们的文档页面,还可以探索更多方法:doc.rust-lang.org/std/vec/struct.Vec.html。向量也可以使用 for 循环语法进行迭代,因为它们也实现了 Iterator 特性。

哈希映射

Rust 还为我们提供了映射,可以用来存储键值数据。它们来自 std::collections 模块,并命名为 HashMap。它们通过 HashMap::new 构造函数创建:

// hashmaps.rs

use std::collections::HashMap; 

fn main() { 
    let mut fruits = HashMap::new(); 
    fruits.insert("apple", 3);
    fruits.insert("mango", 6);
    fruits.insert("orange", 2);
    fruits.insert("avocado", 7);
    for (k, v) in &fruits {
        println!("I got {} {}", v, k);
    }

    fruits.remove("orange");
    let old_avocado = fruits["avocado"];
    fruits.insert("avocado", old_avocado + 5);
    println!("\nI now have {} avocados", fruits["avocado"]);
}

在前面的代码中,我们创建了一个名为 fruits 的新 HashMap。然后我们使用 insert 方法将一些水果及其数量插入到我们的 fruits 映射中。随后,我们使用 for 循环遍历键值对,其中我们通过 &fruits 获取水果映射的引用,因为我们只想读取键和值。默认情况下,值将被 for 循环消耗。在这种情况下,for 循环返回一个包含两个字段的元组((k,v))。还有单独的 keys()values() 方法可用于分别遍历键和值。HashMap 类型中用于哈希键的哈希算法基于 Robin hood 开放寻址方案,但可以根据用例和性能替换为自定义哈希器。这就说完了。

接下来,让我们看看切片。

切片

切片是一种通用的方式来获取集合类型的一个视图。大多数用例是为了获取对集合类型中某个范围的只读访问。切片基本上是一个指针或引用,它指向由某个其他变量拥有的现有集合类型中的连续范围。在底层,切片是现有数据中的胖指针,这些数据位于栈或堆的某个位置。通过胖指针,意味着它们还包含它们指向的元素数量信息,以及数据指针。

切片用 &[T] 表示,其中 T 是任何类型。在用法上,它们与数组非常相似:

// slices.rs

fn main() {
    let mut numbers: [u8; 4] = [1, 2, 3, 4];
    {
        let all: &[u8] = &numbers[..];
        println!("All of them: {:?}", all);
    }

    {
        let first_two: &mut [u8] = &mut numbers[0..2];
        first_two[0] = 100;
        first_two[1] = 99;
    }

    println!("Look ma! I can modify through slices: {:?}", numbers);
}

在前面的代码中,我们有一个 numbers 数组,它是一个栈分配的值。然后我们使用 &numbers[..] 语法从数组 numbers 中取一个切片,并将其存储在 all 中,其类型为 &[u8]。末尾的 [..] 表示我们想要获取整个集合的切片。这里我们需要 &,因为我们不能有裸值的切片——只能位于指针之后。这是因为切片是无大小类型。我们将在第七章,高级概念中详细讲解它们。我们也可以提供范围([0..2])来从任何中间位置或全部获取切片。切片也可以被可变地获取。first_two 是一个可变切片,通过它可以修改原始的 numbers 数组。

对于敏锐的观察者来说,你可以看到在取切片时,我们使用了额外的成对花括号。它们的存在是为了隔离代码,这些代码获取切片的可变引用而不是不可变引用。没有它们,代码将无法编译。这些概念将在第五章,内存管理和安全性中更加清晰。

注意&str 类型也属于切片类型(一个 [u8])。与其他字节切片的区别在于,它们保证是 UTF-8 编码。切片也可以从 Vec 或 String 中获取。

接下来,让我们看看迭代器。

迭代器

迭代器是一种提供对集合类型元素进行高效操作的构造。尽管它不是一个新概念,但在许多命令式语言中,迭代器被实现为从列表或映射等集合类型构造的对象。例如,Python 的 iter(some_list) 或 C++ 的 vector.begin() 是从现有集合构造迭代器的方法。迭代器最初存在的主要动机是它们提供了一种比使用手动 for 循环更高层次的抽象,手动 for 循环很容易出现“少一个”的错误。另一个优点是迭代器不会在内存中读取整个集合,并且是惰性的。惰性意味着迭代器仅在需要时才会评估或访问集合中的元素。迭代器还可以与多个转换操作链式使用,例如根据条件过滤元素,并且只有在需要时才会评估这些转换。当需要访问这些元素时,迭代器提供了一个 next() 方法,该方法尝试从集合中读取下一个元素。这发生在迭代器评估计算链时。

在 Rust 中,任何实现了 Iterator 特性的类型都是迭代器。这种类型可以用于 for 循环中遍历其元素。它们为大多数标准库集合类型(如 VectorHashMapBTreeMap 等)实现了迭代器,也可以为它们自己的类型实现迭代器。

注意:只有当类型具有集合,如语义时,才需要实现 Iterator 特性。例如,为 () 单位类型实现迭代器特性是没有意义的。

在 Rust 中,迭代器是任何实现了 Iterator 特性的类型。这种类型可以用于 for 循环中遍历其元素。它们为大多数标准库集合类型(如 VectorHashMapBTreeMap 等)实现了迭代器,也可以为它们自己的类型实现迭代器。在 Rust 中,for 循环被转换为对正在迭代的对象的 next 调用的正常 match 表达式。此外,我们可以通过在它们上调用 iter()into_iter() 来将大多数集合类型转换为迭代器。关于迭代器的信息就这么多——现在,我们可以着手解决以下练习。我们将深入研究迭代器,并在第七章 高级概念 中实现一个迭代器。

练习 – 修复单词计数器

带着基础知识,现在是时候将我们的知识付诸实践了!在这里,我们有一个程序,它计算文本文件中单词的实例,该文件作为参数传递给它。它几乎完成了,但有几个编译器捕获的错误和一些微妙的错误。以下是我们的不完整程序:

// word_counter.rs

use std::env;
use std::fs::File;
use std::io::prelude::BufRead;
use std::io::BufReader;

#[derive(Debug)]
struct WordCounter(HashMap<String, u64>);

impl WordCounter {
    fn new() -> WordCounter {
        WordCounter(HashMap::new());
    }

    fn increment(word: &str) {
        let key = word.to_string();
        let count = self.0.entry(key).or_insert(0);
        *count += 1;
    }

    fn display(self) {
        for (key, value) in self.0.iter() {
            println!("{}: {}", key, value);
        }
    }
}

fn main() {
    let arguments: Vec<String> = env::args().collect();
    let filename = arguments[1];
    println!("Processing file: {}", filename);

    let file = File::open(filenam).expect("Could not open file");
    let reader = BufReader::new(file);

    let mut word_counter = WordCounter::new();

    for line in reader.lines() {
        let line = line.expect("Could not read line");
        let words = line.split(" ");
        for word in words {
            if word == "" {
                continue
            } else {
                word_counter.increment(word);
            }
        }
    }

    word_counter.display();
}

尝试将程序输入到一个文件中;尝试在编译器的帮助下编译并修复所有错误。一次修复一个错误,并通过重新编译代码从编译器那里获取反馈。这个练习的目的,除了涵盖本章的主题外,还在于让你更加熟悉编译器给出的错误信息,这是了解编译器及其如何分析你的代码的重要心理锻炼。你可能会惊讶地看到编译器在帮助你从代码中移除错误方面是多么的聪明。

一旦你完成代码的修复,这里有一些练习供你尝试,以便你能够进一步锻炼你的能力:

  • WordCounterdisplay方法添加一个filter参数,以便根据计数过滤输出。换句话说,只有当值大于过滤值时才显示键/值对。

  • 由于 HashMap 存储其值是随机的,所以每次运行程序时输出也是随机的。尝试对输出进行排序。HashMap 的values方法可能很有用。

  • 看一下display方法的self参数。如果你在self之前移除&运算符会发生什么?

概述

本章我们涵盖了如此多的主题。我们了解了一些关于 Rust 的历史和语言背后的动机。我们对它的设计原则和语言的基本特性进行了简要的介绍。我们还瞥见了 Rust 如何通过其类型系统提供丰富的抽象。我们学习了如何安装语言工具链,以及如何使用rustc构建和运行简单的示例程序。

在下一章中,我们将探讨使用 Rust 的专用包管理器构建 Rust 应用程序和库的标准方法,并设置我们的 Rust 开发环境,包括代码编辑器,这将为本书中所有后续练习和项目提供基础。

第二章:使用 Cargo 管理项目

现在我们已经熟悉了语言以及如何编写基本程序,我们将提升到在 Rust 中编写实用项目的水平。对于可以包含在单个文件中的简单程序,手动编译和构建它们并不是什么大问题。然而,在现实世界中,程序被分割成多个文件以管理复杂性,并且依赖于其他库。手动编译所有源文件并将它们链接在一起变得非常复杂。对于大型项目,手动方式不是可扩展的解决方案,因为可能有数百个文件及其依赖项。幸运的是,有工具可以自动化构建大型软件项目——包管理器。本章将探讨 Rust 如何使用其专门的包管理器管理大型项目,以及它为开发者提供了哪些功能来增强他们的开发体验。我们将涵盖以下主题:

  • 包管理器

  • 模块

  • Cargo 包管理器和 crates(库)作为编译单元

  • 创建和构建项目

  • 运行测试

  • 货物子命令和第三方二进制文件的安装

  • Visual Studio 代码中的编辑器集成和设置

作为最后的练习,我们将创建 imgtool,这是一个简单的命令行工具,可以使用库从命令行旋转图像,并使用 Cargo 构建和运行我们的程序。我们有很多内容要介绍,所以让我们深入探讨吧!

包管理器

“高效开发的关键是犯有趣的错误。”

——汤姆·洛夫

真实世界的软件代码库通常组织成多个文件,并且会有许多依赖项,这就需要一个专门的工具来管理它们。包管理器是一类命令行工具,有助于管理具有多个依赖项的大型项目。如果你来自 Node.js 背景,你一定熟悉 npm/yarn,如果你来自 Go 语言,那么熟悉 go 工具。它们执行了分析项目、下载依赖项的正确版本、检查版本冲突、编译和链接源文件等所有繁重的工作。

低级语言(如 C/C++)的问题在于它们默认不提供专门的包管理器。C/C++社区长期以来一直在使用 GNU make 工具,这是一个语言无关的构建系统,具有晦涩的语法,这让许多开发者望而却步。make 的问题在于它不知道你的 C/C++源文件中包含了哪些头文件,因此必须手动提供这些信息。它没有内置的下载外部依赖项的支持,也不知道你正在运行的平台。幸运的是,这种情况并不适用于 Rust,因为它提供了一个专门的包管理器,它对正在管理的项目有更多的上下文信息。以下是对 Cargo 的介绍,Rust 的包管理器,它使得构建和维护 Rust 项目变得容易。但首先,我们需要更深入地了解 Rust 的模块系统。

模块

在我们探索更多关于 Cargo 之前,我们需要熟悉 Rust 如何组织我们的代码。我们在上一章中简要地看到了模块。在这里,我们将详细讲解它们。每个 Rust 程序都以根模块开始。如果你正在创建一个库,你的根模块是 lib.rs 文件。如果你正在创建一个可执行文件,根模块是任何包含 main 函数的文件,通常是 main.rs。当你的代码变得很大时,Rust 允许你将其拆分为模块。为了在组织项目时提供灵活性,有多种创建模块的方法。

嵌套模块

创建模块的最简单方法是使用现有模块中的 mod {} 块。考虑以下代码:

// mod_within.rs

mod food {
    struct Cake;
    struct Smoothie;
    struct Pizza;
}

fn main() {
    let eatable = Cake;
}

我们创建了一个名为 food 的内部模块。要在现有模块中创建模块,我们使用 mod 关键字,后跟模块名称 food,然后是一对大括号。在大括号内,我们可以声明任何类型的项,甚至嵌套模块。在我们的 food 模块中,我们声明了三个结构体:Cake、Smoothie 和 Pizza。然后在 main 中,我们使用路径语法 food::Cake 从 food 模块创建一个 Cake 实例。让我们编译这个程序:

图片

奇怪!编译器看不到任何 Cake 类型被定义。让我们按照编译器说的去做,添加 use food::Cake:

// mod_within.rs

mod food {
    struct Cake;
    struct Smoothie;
    struct Pizza;
}

use food::Cake;

fn main() {
    let eatable = Cake;
}

我们添加了 use food::Cake;。要使用模块中的任何项目,我们必须添加一个 use 声明。让我们再试一次:

图片

我们又得到了一个错误,说 Cake 是私有的。这让我们想到了模块的一个重要方面,即提供隐私。模块内的项目默认是私有的。要使用模块中的任何项目,我们需要将项目引入作用域。这是一个两步的过程。首先,我们需要通过在项目声明前加上 pub 关键字来使项目本身公开。其次,要使用该项目,我们需要添加一个 use 语句,就像我们之前使用 use food::Cake 一样。

use 关键字之后是模块中的项目路径。模块中任何项目的路径都使用路径语法指定,它使用两个双冒号(::)分隔项目名称。路径语法通常以模块名称开始,用于导入项目,尽管它也用于导入某些类型的单个字段,例如枚举。

让我们的 Cake 公开:

// mod_within.rs

mod food {
    pub struct Cake;
    struct Smoothie;
    struct Pizza;
}

use food::Cake;

fn main() {
    let eatable = Cake;
}

我们在 Cake 结构体前添加了 pub 并通过 use food::Cake 在根模块中使用它。有了这些更改,我们的代码可以编译。现在可能还不清楚为什么需要创建如此嵌套的模块,但当我们编写第三章测试、文档和基准测试中的测试时,我们会看到它们是如何被使用的。

文件作为模块

模块也可以作为文件创建。例如,对于名为 foo 的目录中的 main.rs 文件,我们可以在与 foo 同一目录中创建一个名为 bar 的模块作为文件 foo/bar.rs。然后在 main.rs 中,我们需要告诉编译器这个模块,即通过 mod foo; 声明模块。当使用基于文件的模块时,这是一个额外的步骤。为了演示如何使用文件作为模块,我们创建了一个名为 modules_demo 的目录,其结构如下:

+ modules_demo
└── foo.rs
└── main.rs

我们的 foo.rs 包含一个结构体 Bar,以及它的 impl 块:

// modules_demo/foo.rs

pub struct Bar;

impl Bar {
    pub fn init() {
        println!("Bar type initialized");
    }
}

我们想在 main.rs 中使用这个模块。我们的 main.rs 包含以下代码:

// modules_demo/main.rs

mod foo;

use crate::foo::Bar;

fn main() {
    let _bar = Bar::init();
}

我们使用 mod foo; 声明我们的模块,foo。然后,我们通过编写 use crate::foo::Bar. 来使用模块中的 Bar 结构体。注意在 use crate::foo::Bar; 中的 crate 前缀。根据你使用的不同前缀,有三种方式来使用模块中的项目:

绝对导入:

  • crate: 一个绝对导入前缀,指向当前 crate 的根。在前面代码中,这将是指根模块,即 main.rs 文件。crate 关键字之后的所有内容都是从根模块解析的。

相对导入:

  • self: 一个相对导入前缀,指向当前模块中的项目。当任何代码想要引用其包含的模块时,会使用它,例如,使用 self::foo::Bar;。这通常用于从子模块重新导出项目以便在父模块中使用。

  • super: 一个相对导入前缀,可以用来使用和导入父模块中的项目。例如,测试模块这样的子模块会使用它来从父模块导入项目。例如,如果模块 bar 想要访问其父模块 foo 中的项目 Foo,它会在模块 bar 中将其导入为 use super::foo::Foo;。

创建模块的第三种方式是将它们组织成目录。

目录作为模块

我们也可以创建一个表示模块的目录。这种方法允许我们在文件和目录层次结构中拥有模块内的子模块。假设我们有一个名为 my_program 的目录,它有一个名为 foo 的模块作为文件 foo.rs。它包含一个名为 Bar 的类型以及 foo 的功能。随着时间的推移,Bar API 的数量不断增加,我们希望将它们作为子模块分离。我们可以使用基于目录的模块来模拟这个用例。

为了演示如何将模块作为目录创建,我们在名为 my_program 的目录中创建了一个程序。它有一个在 main.rs 中的入口点和一个名为 foo 的目录。这个目录现在包含一个名为 bar.rs 的子模块。

以下是我 _program 目录的结构:

+ my_program
└── foo/
    └── bar.rs
└── foo.rs
└── main.rs

为了让 Rust 了解 bar,我们还需要在目录 foo/ 旁边创建一个名为 foo.rs 的同级文件。foo.rs 文件将包含在目录 foo/ 中创建的任何子模块的 mod 声明(这里为 bar.rs)。

我们的 bar.rs 包含以下内容:

// my_program/foo/bar.rs

pub struct Bar;

impl Bar {
    pub fn hello() {
        println!("Hello from Bar !");
    }
}

我们有一个单元结构体 Bar,它有一个关联的方法 hello。我们想在 main.rs 中使用这个 API。

注意:在较旧的 Rust 2015 版本中,子模块不需要在 foo 目录旁边有一个 foo.rs 的同级文件,而是可以在 foo 中使用 mod.rs 文件来告知编译器该目录是一个模块。这两种方法在 Rust 2018 版本中都得到了支持。

接下来,我们的 foo.rs 包含以下代码:

// my_program/foo.rs

mod bar;
pub use self::bar::Bar;

pub fn do_foo() {
    println!("Hi from foo!");
}

我们添加了对模块 bar 的声明。随后,我们从模块 bar 中重新导出了项目 Bar。这要求 Bar 被定义为 pub。pub use 部分是我们从子模块重新导出项目以便在父模块中可用的方式。在这里,我们使用了 self 关键字来引用模块本身。重新导出主要是编写 use 语句时的便利步骤,有助于在导入隐藏在嵌套子模块中的项目时减少混乱。

self 是相对导入的关键字。虽然鼓励使用 crate 进行绝对导入,但在父模块中从子模块重新导出项目时,使用 self 会更加简洁。

最后 main.rs 使用了这两个模块:

// my_program/main.rs

mod foo;

use foo::Bar;

fn main() {
    foo::do_foo();
    Bar::hello();
}

我们的 main.rs 声明了 foo 然后导入了结构体 Bar。然后我们调用了 foo 的方法 do_foo,也调用了 Bar 上的 hello。

模块远不止表面看起来那么简单,因此我们在 第七章 中介绍了关于它们的详细信息,高级概念。在探索了模块之后,让我们继续了解 Cargo。

Cargo 和 crates

当项目变得庞大时,通常的做法是将代码重构为更小、更易于管理的单元,如模块或库。你还需要工具来渲染项目的文档,包括如何构建以及它依赖哪些库。此外,为了支持开发者可以与社区分享库的语言生态系统,如今通常需要一个在线注册表。

Cargo 是一个工具,它赋予你做所有这些事情的能力,crates.io 是托管库的集中地点。用 Rust 编写的库称为 crate,crates.io 为开发者提供托管服务。通常,crate 可以来自三个来源:本地目录、在线 Git 仓库如 GitHub,或托管 crate 注册表如 crates.io。Cargo 支持来自所有这些来源的 crate。

让我们看看 Cargo 的实际应用。如果你已经按照前一章所述运行了 rustup,那么你将已经安装了 cargo 和 rustc。要查看我们可用的命令,我们可以不带任何参数运行 cargo:

图片

它显示了一个我们可以使用的常见命令列表,以及一些标志。让我们使用 new 子命令来创建一个新的 Cargo 项目。

创建新的 Cargo 项目

cargo new 命令创建一个以目录形式存在的新项目名称。我们可以通过在 cargo 和子命令之间添加 help 标志来获取任何子命令的更多上下文。我们可以通过运行 cargo help new 来查看 new 子命令的文档,如下面的截图所示:

图片

默认情况下,cargo new 创建一个二进制项目;创建库项目时必须使用 --lib 参数。让我们通过输入 cargo new imgtool 并查看它创建的目录结构来试一试:

图片

Cargo 创建了一些启动文件,Cargo.toml 和 src/main.rs,其中 main 函数打印出 Hello World!对于二进制 crate(可执行文件),Cargo 创建一个 src/main.rs 文件,而对于库 crate,它则在 src/ 目录下创建 src/lib.rs。

Cargo 还为新项目初始化了 Git 仓库,并使用了一些常规默认设置,例如通过 .gitignore 文件防止目标目录被提交,以及对于二进制 crate 提交 Cargo.lock 文件,而对于库 crate 则忽略它。默认的版本控制系统(VCS)是 Git,可以通过传递 --vcs 标志给 Cargo 来更改(对于 mercurial 使用 --vcs hg)。截至目前,Cargo 支持 Git、hg(mercurial)、pijul(用 Rust 编写的版本控制系统)和 fossil。如果我们想修改这种默认行为,可以通过传递 --vcs none 来指示 Cargo 在创建我们的项目时不要配置任何 vcs。

让我们来看看我们创建的 imgtool 项目的 Cargo.toml 文件。此文件定义了项目的元数据和依赖项。它也被称为项目的清单文件:

[package]
name = "imgtool"
version = "0.1.0"
authors = ["creativcoders@gmail.com"]
edition = "2018"

[dependencies]  

这是新项目所需的最低限度的 Cargo.toml 清单文件。它使用 TOML 配置语言,代表 Tom's Obvious Minimal Language。它是一种由 Tom Preston-Werner 创建的文件格式。它让人联想到标准的 .INI 文件,但向其中添加了几个数据类型,这使得它成为配置文件的理想现代格式,并且比 YAML 或 JSON 更简单。我们现在将保持此文件最小化,并在以后添加内容。

货物和依赖项

对于依赖于其他库的项目,包管理器必须找到项目中的所有直接依赖项以及任何间接依赖项,然后编译并将它们链接到项目中。包管理器不仅仅是促进依赖项解析的工具;它们还应确保项目的可预测和可重复构建。在我们介绍如何构建和运行我们的项目之前,让我们讨论 Cargo 如何管理依赖项并确保可重复构建。

使用 Cargo 管理的 Rust 项目通过两个文件来完成所有魔法:Cargo.toml(之前已介绍)是开发者写入依赖项及其所需版本(如 1.3.*)的 SemVer 语法文件的文件,以及一个名为 Cargo.lock 的锁文件,该文件在构建项目时由 Cargo 生成,其中包含所有直接依赖项和任何间接依赖项的绝对版本(如 1.3.15)。此锁文件确保二进制包的重复构建。Cargo 通过引用此锁文件来最小化对项目进行任何进一步更改时所需的工作。因此,建议二进制包将其 .lock 文件包含在他们的存储库中,而库包可以是无状态的,不需要包含它。

可以使用 cargo update 命令更新依赖项。这将更新所有依赖项。如果要更新单个依赖项,我们可以使用 cargo update -p 。如果您更新单个包的版本,Cargo 会确保只更新与该包在 Cargo.lock 文件中相关的部分,并保留其他版本不变。

Cargo 遵循语义版本控制系统(SemVer),其中您的库版本以 major.minor.patch 格式指定。这些可以描述如下:

  • 主版本:只有当项目进行新的破坏性更改(包括错误修复)时才会增加。

  • 小版本:只有在新功能以向后兼容的方式添加时才会增加。

  • 补丁版本:只有以向后兼容的方式修复错误且没有添加新功能时才会增加。

例如,您可能想在项目中包含序列化库 serde。在撰写本书时,serde 的最新版本是 1.0.85,您可能只关心主版本号。因此,您将 serde = "1" 作为依赖项(在 SemVer 格式中表示为 1.x.x)写入 Cargo.toml,Cargo 将为您解决它,并在锁文件中将它固定为 1.0.85。下次您使用 cargo update 命令更新 Cargo.lock 时,此版本可能会升级到 1.x.x 匹配中的最新版本。如果您不太关心,只想获取包的最新发布版本,您可以使用 * 作为版本,但这不是推荐的做法,因为它会影响构建的可重复性,您可能会拉入具有破坏性更改的主版本。以 * 作为依赖项版本发布包也是禁止的。

在此基础上,让我们看看 cargo build 命令,该命令用于编译、链接和构建我们的项目。此命令对您的项目执行以下操作:

  • 如果您还没有 Cargo.lock 文件,则为您运行 cargo update,并将 Cargo.toml 中的确切版本放入锁文件中

  • 下载所有在 Cargo.lock 中已解析的依赖项

  • 构建所有这些依赖项

  • 构建你的项目并将其与依赖项链接

默认情况下,cargo buildtarget/debug/ 目录下创建项目的调试版本。可以通过传递 --release 标志来创建在 target/release/ 目录下的优化版本,用于生产代码。调试版本提供更快的构建时间,缩短了反馈循环,而生产构建则稍微慢一些,因为编译器会对源代码执行更多的优化遍历。在开发过程中,你需要有更短的修复-编译-检查的反馈时间。为此,可以使用 cargo check 命令,这将导致更短的编译时间。它基本上跳过了编译器的代码生成部分,并且只运行源代码的前端阶段,即在编译器中的解析和语义分析。另一个命令是 cargo run,它具有双重功能。它首先运行 cargo build,然后在 target/debug/ 目录下运行你的程序。对于构建/运行发布版本,可以使用 cargo run --release .。在 imgtool/ 目录下运行 Cargo run 后,我们得到以下输出:

图片

使用 Cargo 运行测试

Cargo 还支持运行测试和基准测试。深入测试和基准测试在第三章,测试、文档和基准测试中有详细说明。在本节中,我们将简要介绍如何使用 Cargo 运行测试。我们将为库 crate 编写测试。为了完成本节,让我们通过运行以下命令创建一个 crate:cargo new myexponent --lib

图片

库 crate 与二进制 crate 类似。区别在于,我们不是使用 src/main.rs 和作为入口点的 main 函数,而是使用 src/lib.rs 和一个简单的测试函数 it_works,该函数带有 #[test] 注解。我们可以立即使用 cargo test 运行 it_works 测试函数并查看它是否通过:

图片

现在,让我们尝试使用 Cargo 进行一些测试驱动开发TDD)。我们将通过添加一个 pow 函数来扩展这个库,用户可以使用这个函数来计算给定数字的指数。我们将为这个函数编写一个测试,该测试最初失败,然后填充实现直到它通过。以下是新的 src/lib.rs,其中包含没有实现的 pow 函数:

// myexponent/src/lib.rs

fn pow(base: i64, exponent: usize) > i64 { 
    unimplemented!();
} 

#[cfg(test)] 
mod tests { 
    use super::pow; 
    #[test] 
    fn minus_two_raised_three_is_minus_eight() { 
        assert_eq!(pow(-2, 3), -8); 
    }
}

目前不必担心细节。我们已经创建了一个单独的 pow 函数,它接受一个 i64 类型的基数和一个正指数 usize,并返回一个被提升到指数的数字。在 mod tests {中,我们有一个名为 minus_two_raised_three_is_minus_eight 的测试函数,它执行单个断言。assert_eq!宏检查传递给它的两个值的相等性。如果左边的参数等于右边的参数,则断言通过;否则,它会抛出一个错误,编译器会报告失败的测试。如果我们运行 cargo test,由于那里有一个 unimplemented!()宏调用,单元测试显然会因 pow 调用而失败:

图片

简而言之,unimplemented!()只是一个方便的宏,用于标记未完成的代码或你打算稍后实现的代码,但希望编译器仍然编译它而不报类型错误。内部,它会调用 panic!并传递消息“尚未实现”。它可以用于实现特质多个方法的情况。例如,你开始实现一个方法,但还没有计划其他方法的实现。在编译时,如果你只是放置了一个带有空体的函数,你会得到其他未实现方法的编译错误。对于这些方法,我们可以在它们内部放置一个 unimplemented!()宏调用,只是为了让类型检查器满意并为我们编译,并在运行时转移错误。我们将在第九章“使用宏进行元编程”中查看更多这样的方便宏。

现在,让我们通过实现一个快速且简单的 pow 函数版本来解决这个问题,并再次尝试:

// myexponent/src/lib.rs

pub fn pow(base: i64, exponent: usize) -> i64 {
    let mut res = 1;
    if exponent == 0 {
        return 1;
    }
    for _ in 0..exponent {
        res *= base as i64;
    }
    res
}

运行 Cargo test 会得到以下输出:

图片

这次,测试通过了。嗯,这就是基础。我们将在第三章“测试、文档和基准测试”中进行更多测试。

使用 Cargo 运行示例

为了使用户能够快速开始使用你的 crate,将如何使用你的 crate 的代码示例传达给用户是一个好习惯。Cargo 标准化了这个做法,这意味着你可以在项目根目录下添加一个 examples/目录,其中可以包含一个或多个.rs 文件,包含一个 main 函数,展示你的 crate 的示例用法。

你可以使用 cargo run --examples <file_name>来运行 examples/目录下的代码,其中文件名没有.rs 扩展名。为了演示这一点,我们为 myexponent crate 添加了一个 example/目录,包含一个名为 basic.rs 的文件:

// myexponent/examples/basic.rs

use myexponent::pow;

fn main() {
    println!("8 raised to 2 is {}", pow(8, 2));
}

在 examples/目录下,我们从 myexponent crate 中导入了 pow 函数。以下是在运行 cargo run --example basic 后的输出:

图片

Cargo 工作空间

随着时间的推移,你的项目已经变得相当大。现在,你正在考虑是否可以将代码的公共部分作为单独的 crate 分开,以帮助管理复杂性。嗯,Cargo 工作区允许你做到这一点。工作区的概念是它们允许你在目录中本地拥有 crate,这些 crate 可以共享相同的 Cargo.lock 文件和公共目标或输出目录。为了演示这一点,我们将创建一个新的项目,该项目包含 Cargo 工作区。工作区只是一个包含 Cargo.toml 文件的目录。它没有任何[package]部分,但在其中有一个[workspace]部分。让我们创建一个新的目录名为 workspace_demo,并添加如下所示的 Cargo.toml 文件:

mkdir workspace_demo
cd workspace_demo && touch Cargo.toml

然后我们将工作区部分添加到我们的 Cargo.toml 文件中:

# worspace_demo/Cargo.toml

[workspace]
members = ["my_crate", "app"]

在[工作区]中,members 键是工作区目录内 crate 的列表。现在,在 workspace_demo 目录中,我们将创建两个 crate:my_crate,一个库 crate 和 app,一个使用 my_crate 的二进制 crate。

为了保持简单,my_crate 有一个公共 API,它只是打印一条问候消息:

// workspace_demo/my_crate/lib.rs

pub fn greet() {
    println!("Hi from my_crate");
}

现在,从我们的 app crate 中,我们有 main 函数,它调用 my_crate 的 greet 函数:

// workspace_demo/app/main.rs

fn main() {
    my_crate::greet();
}

然而,我们需要让 Cargo 知道我们的 my_crate 依赖项。由于 my_crate 是一个本地 crate,我们需要在 app 的 Cargo.toml 文件中将它指定为路径依赖项,如下所示:

# workspace_demo/app/Cargo.toml

[package]
name = "app"
version = "0.1.0"
authors = ["creativcoder"]
edition = "2018"

[dependencies]
my_crate = { path = "../my_crate" }

现在,当我们运行 cargo build 时,二进制文件将在 workspace_demo 目录的目标目录中生成。相应地,我们可以在 workspace_demo 目录中添加多个本地 crate。现在,如果我们想从 crates.io 添加第三方依赖项,我们需要在所有需要它的 crate 中添加它。然而,在 cargo build 期间,Cargo 确保在 Cargo.lock 文件中只有一个版本的该依赖项。这确保第三方依赖项不会被重建和重复。

扩展 Cargo 和工具

Cargo 也可以扩展以集成外部工具以增强开发体验。它被设计成尽可能可扩展。开发者可以创建命令行工具,Cargo 可以通过简单的 cargo binary-name 语法调用它们。在本节中,我们将查看一些这些工具。

子命令和 Cargo 安装

Cargo 的自定义命令属于子命令类别。这些工具通常是来自 crates.io、GitHub 或本地项目目录的二进制文件,可以通过使用 cargo install 或仅在本地 Cargo 项目中使用 cargo install 来安装。cargo-watch 工具就是一个这样的例子。

cargo-watch

Cargo-watch 通过在后台自动构建你的项目来帮助你缩短修复、编译、运行周期。默认情况下,这仅运行 Rust 的类型检查器(cargo check 命令),并且不经过代码生成阶段(这需要时间)并缩短编译时间。也可以使用-x 标志提供自定义命令来代替 cargo check。

我们可以通过运行 cargo install cargo-watch 来安装 cargo-watch,然后在任何 Cargo 项目中,我们可以通过调用 cargo watch 来运行它。现在,每当我们对项目进行更改时,cargo-watch 都会在后台运行 cargo check,并为我们重新编译项目。在下面的代码中,我们犯了一个拼写错误并进行了纠正,cargo-watch 为我们重新编译了项目:

如果您熟悉 Node.js 生态系统中的 watchman 或 nodemon 软件包,这将是一个非常相似的经历。

cargo-edit

cargo-edit 子命令用于自动将依赖项添加到您的 Cargo.toml 文件中。它可以添加所有类型的依赖项,包括开发依赖项和构建依赖项,还允许您添加任何依赖项的特定版本。您可以通过运行 cargo install cargo-edit 来安装它。此子命令提供了四个命令:cargo add、cargo rm、cargo edit 和 cargo upgrade。

cargo-deb

这是另一个有用的社区开发子命令,可以创建 Debian 软件包 (.deb),以便在 Debian Linux 上轻松分发 Rust 可执行文件。我们可以通过运行 cargo install cargo-deb 来安装它。我们将在本章末尾使用此工具将我们的 imgtool 命令行可执行文件打包成 .deb 软件包。

cargo-outdated

此命令显示您 Cargo 项目中的过时 crate 依赖项。您可以通过运行 cargo install cargo-outdated 来安装它。一旦安装,您可以在项目目录中运行 cargo outdated 来查看过时的 crate(如果有)。

现在,这些子命令与 Cargo 无缝协作的方式是,开发者使用命名约定创建这些二进制 crate,例如 cargo-[cmd],当您安装那个二进制 crate 时,Cargo 会将安装的二进制文件暴露给您的 $PATH 变量,然后可以通过 cargo 来调用。这是一个简单而有效的方法,Cargo 已经采用这种方法来通过社区开发的子命令扩展自身。还有许多其他类似的 Cargo 扩展。您可以在 github.com/rust-lang/cargo/wiki/Third-party-cargo-subcommands 找到所有社区精选的子命令列表。

cargo install 也用于安装任何在 Rust 中开发的二进制 crate 或可执行文件/应用程序。它们默认安装在 /home/<用户>/.cargo/bin/ 目录中。我们将使用它来安装我们的 imgtool 应用程序——我们将在本章末尾构建它——使其在系统范围内可用。

使用 clippy 检查代码

代码风格检查是一种有助于维护库的质量并使其遵循标准编码习惯和规范的实践。在 Rust 生态系统中的事实上的代码风格检查工具是 clippy。Clippy 为我们提供了一组 lint(截至本书编写时约为 291 个),以确保 Rust 代码的高质量。在本节中,我们将安装 clippy 并在我们的 libawesome 库上尝试它,向其中添加一些虚拟代码,并查看 clippy 提出的建议。在项目中使用 clippy 有多种方式,但我们将使用 cargo clippy 子命令方式,因为它很简单。Clippy 可以对代码进行分析,因为它是一个编译器插件,并且可以访问大量编译器的内部 API。

要使用 clippy,我们需要通过运行 rustup component add clippy 来安装它。如果您还没有安装,rustup 会为您安装。现在,为了展示 clippy 如何指出我们代码中的不良风格,我们在 myexponent crate 的 pow 函数中的 if 条件内放置了一些虚拟语句,如下所示:

// myexponent/src/lib.rs

fn pow(base: i64, exponent: usize) -> i64 {
    /////////////////// Dummy code for clippy demo
    let x = true;
    if x == true {

    }
    ///////////////////
    let mut res = 1;
    ...
}

添加了这些行后,通过在 myexponent 目录中运行 cargo clippy,我们得到了以下输出:

图片

太好了!Clippy 找到了一种常见的代码风格,它是多余的,即检查布尔值是 true 或 false。或者,我们也可以直接将前面的 if 条件写成 if x {}。Clippy 还会进行许多其他检查,其中一些甚至可以指出您代码中的潜在错误,例如 rust-lang-nursery.github.io/rust-clippy/master/index.html#absurd_extreme_comparisons。要查看所有可用的 lint 和配置 clippy 的各种方式,请访问 github.com/rust-lang/rust-clippy

探索清单文件 - Cargo.toml

Cargo 严重依赖于项目的清单文件,即 Cargo.toml 文件,以获取有关项目的各种信息。让我们更详细地看看这个文件的结构和它可以包含的项目。如您之前所见,cargo new 创建了一个几乎为空的清单文件,只包含必要的字段,以便项目可以构建。每个清单文件都分为几个部分,指定了项目的不同属性。我们将查看在中等大小的 Cargo 项目清单文件中通常可以找到的部分。以下是一个来自更大应用程序的假想 Cargo.toml 文件:

# cargo_manifest_example/Cargo.toml
# We can write comments with `#` within a manifest file

[package]
name = "cargo-metadata-example"
version = "1.2.3"
description = "An example of Cargo metadata"
documentation = "https://docs.rs/dummy_crate"
license = "MIT"
readme = "README.md"
keywords = ["example", "cargo", "mastering"]
authors = ["Jack Daniels <jack@danie.ls>", "Iddie Ezzard <iddie@ezzy>"]
build = "build.rs"
edition = "2018"

[package.metadata.settings]
default-data-path = "/var/lib/example"

[features]
default=["mysql"]

[build-dependencies]
syntex = "⁰.58"

[dependencies]
serde = "1.0"
serde_json = "1.0"
time = { git = "https://github.com/rust-lang/time", branch = "master" }
mysql = { version = "1.2", optional = true }
sqlite = { version = "2.5", optional = true }

让我们回顾一下尚未解释的部分,从 [package] 部分开始:

  • 描述:它包含有关项目的较长的自由格式文本字段。

  • 许可证:它包含软件许可证标识符,如 spdx.org/licenses/ 中列出的。

  • 读取文件:它允许您链接到项目存储库中的文件。这应该显示为项目介绍的入口点。

  • documentation: 如果是库 crate,它包含 crate 的文档链接。

  • keywords: 它是一个单词列表,有助于通过搜索引擎或通过 crates.io 网站发现您的项目。

  • authors: 列出了项目的关键作者。

  • build: 它定义了一段 Rust 代码(通常是 build.rs),在程序的其他部分编译之前编译并运行。这通常用于生成代码或构建 crate 所依赖的本地库。

  • edition: 此键指定在编译项目时要使用哪个版本。在我们的例子中,我们使用的是 2018 版本。之前的版本是 2015,如果没有 edition 键存在,则假定它是默认版本。注意:使用 2018 版本创建的项目是向后兼容的,这意味着它们也可以将 2015 的 crate 作为依赖项。

接下来是 [package.metadata.settings]。通常,Cargo 会对其不认识的键和部分进行抱怨,但包含元数据的部分是一个例外。它们被 Cargo 忽略,因此可以用于您项目所需的任何可配置的键/值对。

[features]、[dependencies] 和 [build-dependencies] 部分相互关联。依赖项可以通过版本号声明,如 SemVer 指南中所述:

serde = "1.0" 

这意味着 serde 是一个强制依赖项,我们想要最新版本,1.0.*。实际版本将在 Cargo.lock 中固定。

使用 caret 符号可以扩展 Cargo 允许查找的版本范围:

syntex = "⁰.58" 

这里,我们表示我们想要最新的主要版本,0..,它必须至少是 0.58.*。

Cargo 还允许您直接将依赖项指定到 Git 仓库,前提是仓库是一个由 Cargo 创建的项目,并且遵循 Cargo 预期的目录结构。我们可以这样指定来自 GitHub 的依赖项:

time = { git = "https://github.com/rust-lang/time", branch = "master" } 

这也适用于其他在线 Git 仓库,如 GitLab。同样,实际的版本(或 Git 的情况下的更改集修订版)将由 cargo update 命令在 Cargo.lock 中固定。

清单还有两个可选依赖项,mysql 和 sqlite:

mysql = { version = "1.2", optional = true } 
sqlite = { version = "2.5", optional = true } 

这意味着程序可以在不依赖任何一方的情况下构建。[features] 部分包含默认功能的列表:

 default = ["mysql"] 

这意味着如果您在构建程序时没有手动覆盖功能集,则只会拉入 mysql,而不是 sqlite。功能的一个示例用法是当您的库有某些优化调整时。然而,在嵌入式平台上这将是昂贵的,因此库作者可以将它们作为功能发布,这样它们只会在有能力的系统上可用。另一个例子是当您构建一个命令行应用程序,并提供一个作为额外功能的 GUI 前端时。

这就是如何使用 Cargo.toml 清单文件描述 Cargo 项目的快速简要概述。关于如何使用 Cargo 配置项目还有很多可以探索的。更多信息请查看doc.rust-lang.org/cargo/reference/manifest.html

设置 Rust 开发环境

Rust 对大多数代码编辑器都有良好的支持,无论是 vim、Emacs、intellij IDE、Sublime、Atom 还是 Visual Studio Code。Cargo 也受到这些编辑器的良好支持,生态系统中有几个工具可以增强体验,如下所示:

  • rustfmt:它根据 Rust 风格指南中提到的约定格式化代码。

  • clippy:它会警告你常见的错误和潜在的代码问题。Clippy 依赖于标记为不稳定的编译器插件,因此它仅适用于 nightly Rust。使用 rustup,你可以轻松切换到 nightly。

  • racer:它可以查询 Rust 标准库,并提供代码补全和工具提示。

在上述编辑器中,Intellij IDE 和 Visual Studio Code(vscode)提供了最成熟的 IDE 体验。在本章中,我们将介绍如何设置 vscode 的开发环境,因为它更易于访问且更轻量级。对于 vscode,Rust 社区有一个名为 rls-vscode 的扩展,我们将在本节中安装它。此扩展由 Rust 语言服务器(RLS)组成,它内部使用了我们之前列出的许多工具。我们将使用 Visual Studio Code 1.23.1(d0182c341)和 Ubuntu 16.04 来设置它。

安装 vscode 超出了本书的范围。你可以在操作系统的软件仓库中查找,并访问code.visualstudio.com获取更多信息。

让我们实际打开本章开头创建的 imgtool 应用程序在 vscode 中:

cd imgtool
code .            # opens the current directory in vscode

一旦我们打开我们的项目,vscode 会自动识别我们的项目为 Rust 项目,并给我们推荐下载 vscode 扩展。它看起来可能像这样:

图片

如果你没有收到推荐,你总是可以在左上角的搜索栏中输入 Rust。然后我们可以点击安装,并在扩展页面按“重新加载”,这将重启 vscode 并使其在我们的项目中可用:

图片

下次你打开项目中 main.rs 文件并开始输入时,扩展程序会启动并提示你安装任何与 Rust 相关的缺失工具链,你可以点击安装。然后它开始下载该工具链:

图片

几分钟后,状态将发生变化,如下所示:

图片

现在,我们可以开始了。

注意:由于 RLS 还处于预览阶段,您在首次安装时可能会遇到 RLS 卡住的情况。通过重新启动 vscode 并在删除它之后重新安装 RLS,应该可以工作。如果不行,请随意在其 GitHub 页面 rls-vscode 上提出问题。

在我们的 imgtool 项目打开的情况下,让我们看看当尝试导入一个模块时 RLS 的响应:

如您所见,它为 Rust 标准库中 fs 模块中可用的项目执行自动完成。最后,让我们看看 RLS 是如何为我们处理代码格式的。让我们将所有代码放在同一行上以演示这一点:

让我们保存文件。然后我们可以使用 Ctrl + Shift + I 或 Ctrl + Shift + P 并选择 Format Document。这将立即格式化文档,并在您点击保存时对您的代码运行 cargo check:

关于其他代码编辑器的更多信息,Rust 有一个名为 areweideyet.com 的页面,列出了所有编辑器的状态,包括类别,显示了它们对该语言的支持程度。请务必查看它们!

现在,让我们继续实现我们的 imgtool 应用程序。

使用 Cargo 构建项目 – imgtool

现在,我们相当了解如何使用 Cargo 管理项目。为了深入理解这些概念,我们将构建一个使用第三方 crate 的命令行应用程序。这个练习的全部目的是熟悉使用第三方 crate 构建项目的常规工作流程,因此我们将跳过我们编写代码的许多细节。尽管如此,我们鼓励您查看代码中使用的 API 的文档。

我们将使用一个名为 image 的 crate,它来自 crates.io。这个 crate 提供了各种图像处理 API。我们的命令行应用程序将很简单;它将接受一个图像文件的路径作为参数,每次运行时都将其旋转 90 度,并将其写回同一文件。

我们将进入之前创建的 imgtool 目录。首先,我们需要告诉 Cargo 我们想要使用 image crate。我们可以使用 cargo add image@0.19.0 命令从命令行添加版本为 0.19.0 的 image crate。以下是我们的更新后的 Cargo.toml 文件:

[package]
name = "imgtool"
version = "0.1.0"
authors = ["creativcoder"]
edition = "2018"

[dependencies]
image = "0.19.0"

然后,我们将调用 cargo build。这将从 crates.io 拉取 image crate 及其依赖项,最后编译我们的项目。一旦完成,我们就可以在 main.rs 文件中使用它了。对于我们的应用程序,我们将提供一个图像路径作为参数。在我们的 main.rs 文件中,我们想要读取此图像的路径:

// imgtool/src/main.rs

use std::env;
use std::path::Path;

fn main() {
    let image_path = env::args().skip(1).next().unwrap();
    let path = Path::new(&image_path);
}

首先,我们通过从 env 模块中调用 args() 函数来读取传递给 imgtool 的参数。这返回一个字符串,作为图像文件的路径。然后我们获取图像路径并从中创建一个 Path 实例。接下来是来自 image crate 的旋转功能。请注意,如果你正在运行 Rust 2015 版本,你需要一个额外的 extern crate image; 声明在 main.rs 的顶部,以便你能够访问 image crate 的 API。在 Rust 2018 版本中,这不再需要:

// imgtool/src/main.rs

use std::env;
use std::path::Path;

fn main() {
    let image_path = env::args().skip(1).next().unwrap();
    let path = Path::new(&image_path);
    let img = image::open(path).unwrap();
    let rotated = img.rotate90();
    rotated.save(path).unwrap();
}

从 image crate 中,我们使用 open 函数打开我们的图片并将其存储在 img 中。然后我们在 img 上调用 rotate90。这返回一个旋转后的图像缓冲区,我们通过调用 save 并传递路径将其保存回原始图像路径。前述代码中的大多数函数调用都返回一个名为 Result 的包装值,因此我们在 Result 值上调用 unwrap() 来告诉编译器我们不在乎函数调用是否失败,假设它已经成功,我们只想从 Result 类型中获取包装的值。我们将在第六章“错误处理”中学习关于 Result 类型以及适当的错误处理方法。对于演示,在项目的 asset 文件夹下,你可以找到一个名为 Ferris 的螃蟹的图片(assets/ferris.png)。在运行代码之前,我们将看到以下图片:

图片

现在是时候使用这张图片作为参数来运行我们的应用程序了。现在,你有两种方式可以运行 imgtool 二进制文件并传递图片作为参数:

  • 通过先运行 cargo build,然后手动调用二进制文件作为 ./target/debug/imgtool assets/ferris.png 来执行。

  • 通过直接运行 cargo run -- assets/ferris.png。双横线标志着 Cargo 自身参数的结束。之后的所有内容都将传递给我们的可执行文件(这里,这是 imgtool)。

运行 cargo run -- assets/ferris.png 后,我们可以看到 Ferris 翻了个跟头:

图片

太好了!我们的应用程序运行正常。我们现在可以通过在 imgtool 目录中运行 cargo install 来安装我们的工具,然后从终端的任何位置使用它。此外,如果你使用的是 Ubuntu,我们可以使用 cargo deb 子命令来创建一个 deb 包,以便你可以将其分发给其他用户。运行 cargo deb 命令会产生一个 .deb 文件,如下面的截图所示:

图片

现在,是你探索与前面代码相关内容的时候了:

  • 使用 Rust 标准库文档doc.rust-lang.org/std/来了解 Rust 中的 unwrap() 函数及其可用类型。

  • 在标准库文档中查找 Path 类型,看看你是否可以修改程序以不覆盖文件,而是创建一个新文件,其新文件后缀为 _rotated。

  • 使用图像 crate(docs.rs/image)的文档页面中的搜索栏,尝试查找具有不同角度的其他旋转方法,并修改代码以使用它们。

摘要

在本章中,我们了解了标准的 Rust 构建工具 Cargo。我们简要地了解了如何使用 Cargo 初始化、构建和运行测试。我们还探索了 Cargo 之外的工具,这些工具可以使开发体验更加流畅和高效,例如 RLS 和 clippy。我们看到了如何通过安装 RLS 扩展来将这些工具与 Visual Studio Code 编辑器集成。最后,我们创建了一个小型的 CLI 工具,通过使用 Cargo 中的第三方 crate 来操作图像。

在下一章中,我们将讨论测试、文档化和基准测试我们的代码。

第三章:测试、文档和基准

在本章中,我们将继续学习 Cargo,了解如何编写测试,如何编写代码文档,以及如何使用基准测试来衡量代码的性能。然后,我们将运用这些技能来构建一个简单的 crate,模拟逻辑门,为您提供编写单元测试、集成测试以及文档测试的端到端体验。

在本章中,我们将涵盖以下主题:

  • 测试的动机

  • 组织测试和测试原语

  • 单元测试和集成测试

  • 文档测试

  • 基准测试

  • 使用 Travis CI 进行持续集成

测试的动机

“那些不可能的事情只是需要更长的时间。” **

软件系统就像带有小齿轮和齿轮的机器。如果任何一个单独的齿轮出现故障,整个机器很可能以不可靠的方式运行。在软件中,这些单独的齿轮是函数、模块或您使用的任何库。对软件系统个别组件的功能测试是维护高质量代码的有效和实用方法。它不能证明不存在错误,但它有助于在将代码部署到生产环境中建立信心,并在项目需要长期维护时保持代码库的稳定性。此外,没有单元测试,软件的大规模重构是难以进行的。在软件中智能和平衡地使用单元测试的好处是深远的。在实现阶段,编写良好的单元测试成为软件组件的非正式规范。在维护阶段,现有的单元测试作为防止代码库回归的 harness,鼓励立即修复。在像 Rust 这样的编译语言中,这甚至更好,因为由于编译器的有用错误诊断,涉及回归的单元测试(如果有)将更加有指导性。

单元测试的另一个良好副作用是它们鼓励程序员编写模块化代码,这些代码主要依赖于输入参数,即无状态函数。这使程序员远离编写依赖于全局可变状态的代码。编写依赖于全局可变状态的测试是困难的。此外,仅仅思考为一段代码编写测试的行为就帮助程序员找出实现中的愚蠢错误。它们还作为非常好的文档,帮助任何试图理解代码库不同部分如何相互交互的新手。

我们可以得出的结论是,测试对于任何软件项目都是不可或缺的。现在,让我们来看看如何在 Rust 中编写测试,首先从学习组织测试开始!

组织测试

在开发软件时,我们通常编写两种基本的测试:单元测试和集成测试。它们服务于不同的目的,并且与被测试的代码库的交互方式也不同。单元测试总是旨在轻量级,测试单个组件,以便开发者可以经常运行它们,从而提供更短的反馈循环,而集成测试则是重量级的,旨在模拟现实世界场景,基于其环境和规范进行断言。Rust 的内置测试框架为我们提供了编写和组织这些测试的合理默认值:

  • 单元测试: 单元测试通常在包含要测试的代码的同一模块中编写。当这些测试的数量增加时,它们会被组织成一个实体,作为一个嵌套模块。通常,在当前模块中创建一个子模块,按照惯例命名为tests,并在其上添加#[cfg(test)]属性,然后将所有与测试相关的函数放入其中。这个属性简单地告诉编译器在运行cargo test时包含测试模块中的代码。稍后我们将详细介绍属性。

  • 集成测试: 集成测试在 crate 根部的tests/目录中单独编写。它们被编写成好像测试是正在测试的 crate 的消费者。tests/目录中的任何.rs文件都可以添加一个use声明来引入需要测试的任何公共 API。

要编写上述任何一种测试,我们需要熟悉一些测试原语。

测试原语

Rust 的内置测试框架基于一些原语,主要由属性和宏组成。在我们编写任何实际的测试之前,了解如何有效地使用它们是非常重要的。

属性

属性是 Rust 代码中项目的一个注释。项目是 crate 中的顶级语言结构,如函数、模块、结构体、枚举和常量声明,以及其他仅在 crate 根定义的东西。属性通常是编译器内置的,但也可以通过编译器插件由用户创建。它们指示编译器为它们下面的项目或模块注入额外的代码或意义。我们将在第七章,高级概念中详细介绍这些内容。为了保持话题的连贯性,我们在这里将讨论两种属性形式:

  • #[<name>]: 这适用于每个项目,通常出现在它们的定义上方。例如,Rust 中的测试函数使用#[test]属性进行标注。这表示该函数被视为测试框架的一部分。

  • #![<name>]: 这适用于整个 crate。注意那里有一个额外的!。它通常位于 crate 根部的顶部。

如果我们正在创建一个库 crate,crate 根通常是lib.rs,而当我们创建一个二进制 crate 时,crate 根将是main.rs文件。

除了#[cfg(test)]这样的属性形式之外,还有其他形式的属性,这些属性在模块内编写测试时使用。这个属性被添加到测试模块的顶部,以提示编译器有条件地编译模块,但仅在测试模式下编译代码时才这样做。属性不仅限于在测试代码中使用;在 Rust 中它们被广泛使用。我们将在接下来的章节中看到更多关于它们的例子。

断言宏

在测试中,当给定一个测试用例时,我们试图断言我们的软件组件在给定输入范围内的预期行为。通常,语言提供称为断言函数的函数来执行这些断言。Rust 为我们提供了断言函数,作为宏实现,帮助我们实现相同的功能。让我们看看一些常用的例子:

      assert!(true);
      assert!(a == b, "{} was not equal to {}", a, b);
  • assert!:这是最简单的断言宏,它接受一个布尔值进行断言。如果值为false,则测试会崩溃,显示失败发生的行。它还可以接受一个格式字符串,后跟相应数量的变量,用于提供自定义错误消息:
      let a = 23;
      let b = 87;
      assert_eq!(a, b, "{} and {} are not equal", a, b);
  • assert_eq!:它接受两个值,如果它们不相等则失败。它还可以接受一个格式字符串,用于自定义错误消息。

  • assert_ne!:这与assert_eq!类似,因为它接受两个值,但只有在值不相等时才进行断言。

  • debug_assert!:这与assert!类似。调试断言宏也可以用于除测试代码之外的代码。这主要用于代码中,以断言在运行时应该保持的任何合同或不变量。这些断言仅在调试构建中有效,并在以调试模式运行时帮助捕获断言违规。当代码以优化模式编译时,这些宏调用将被完全忽略并优化为无操作。还有类似的变体,如debug_assert_eq!debug_assert_ne!,它们的工作方式与assert!类宏相同。

要比较这些断言宏内的值,Rust 依赖于特质。例如,assert!(a == b)中的==实际上变成了一个方法调用,a.eq(&b),它返回一个bool值。eq方法来自PartialEq特质。Rust 中的大多数内置类型都实现了PartialEqEq特质,以便它们可以被比较。这些特质的细节以及PartialEqEq之间的区别在第四章,“类型、泛型和特质”中进行了讨论。

对于用户定义的类型,我们需要实现这些特质。幸运的是,Rust 为我们提供了一个方便的宏,称为 derive,它接受一个或多个要实现的特质名称。它可以通过在用户定义类型上放置 #[derive(Eq, PartialEq)] 注解来使用。注意括号内的特质名称。Derive 是一个过程宏,它简单地为出现在其上的类型生成 impl 块的代码,并实现特质的方 法或任何关联函数。我们将在第九章 宏编程中讨论这些宏。

除了这些,让我们开始编写一些测试!

单元测试

通常,单元测试是一个实例化应用程序的小部分并独立于代码库的其他部分验证其行为的函数在 Rust 中,单元测试通常在模块内编写。理想情况下,它们应该只旨在覆盖模块的功能和接口。

第一个单元测试

以下是我们非常第一个单元测试:

// first_unit_test.rs

#[test] 
fn basic_test() { 
    assert!(true);
}

单元测试是以函数的形式编写的,并带有 #[test] 属性。前面的 basic_test 函数中没有什么复杂的内容。我们有一个基本的 assert! 调用,传递 true。为了更好的组织,你也可以创建一个名为 tests 的子模块(按照惯例),并将所有相关的测试代码放在其中。

运行测试

我们运行这个测试的方式是通过以测试模式编译我们的代码。编译器会忽略测试注解函数的编译,除非被指示以测试模式构建。这可以通过在编译测试代码时传递 --test 标志给 rustc 来实现。之后,可以通过简单地执行编译后的二进制文件来运行测试。对于前面的测试,我们将通过运行以下命令以测试模式编译它:

rustc --test first_unit_test.rs

使用 --test 标志,rustc 会放置一个带有一些测试框架代码的 main 函数,并将所有定义的测试函数作为线程并行调用。默认情况下,所有测试都是并行运行的,除非通过环境变量 RUST_TEST_THREADS=1 指示。这意味着如果我们想以单线程模式运行前面的测试,我们可以通过 RUST_TEST_THREADS=1 ./first_unit_test 来执行。

现在,Cargo 已经支持运行测试,所有这些通常都是通过调用 cargo test 命令来内部完成的。这个命令会为我们编译并运行带有测试注解的函数。在接下来的示例中,我们将主要使用 Cargo 来运行我们的测试。

隔离测试代码

当我们的测试变得复杂时,可能会有一些额外的辅助方法,我们可能会创建,这些方法仅在测试代码的上下文中使用。在这种情况下,将测试相关代码与实际代码隔离是有益的。我们可以通过将所有测试相关代码封装在一个模块中,并在其上放置 #[cfg(test)] 注解来实现这一点。

#[cfg(...)] 属性中的 cfg 通常用于条件编译,并不仅限于测试代码。它可以包括或排除不同架构或配置标志的代码。在这里,配置标志是 test。你可能还记得,上一章中的测试已经使用了这种形式。这有一个优点,即你的测试代码只有在运行 cargo test 时才会被编译并包含在编译的二进制文件中,否则会被忽略。

假设你想为测试程序生成测试数据,但没有理由在发布构建中包含那段代码。让我们通过运行 cargo new unit_test --lib 来创建一个项目来演示这一点。在 lib.rs 中,我们定义了一些测试和函数:

// unit_test/src/lib.rs

// function we want to test
fn sum(a: i8, b: i8) -> i8 {
    a + b
}

#[cfg(test)]
mod tests {
    fn sum_inputs_outputs() -> Vec<((i8, i8), i8)> {
        vec![((1, 1), 2), ((0, 0), 0), ((2, -2), 0)]
    }

    #[test]
    fn test_sums() {
        for (input, output) in sum_inputs_outputs() {
            assert_eq!(crate::sum(input.0, input.1), output);
        }
    }
}

我们可以通过运行 cargo test 来运行这些测试。让我们回顾一下前面的代码。我们在 sum_inputs_outputs 函数中生成已知输入和输出对,该函数被 test_sums 函数使用。#[test] 属性使 test_sums 函数不包含在我们的发布编译中。然而,sum_inputs_outputs 没有标记为 #[test],如果它在 tests 模块外部声明,它将被包含在编译中。通过使用 #[cfg(test)]mod tests {} 子模块,并将所有测试代码及其相关函数封装在这个模块中,我们得到了保持代码和生成的二进制文件清洁的好处,不包含测试代码。

我们还将 sum 函数定义为私有,没有使用 pub 可见性修饰符,这意味着模块内的单元测试也允许你测试私有函数和方法。非常方便!

失败测试

在某些测试用例中,你可能希望你的 API 方法根据某些输入失败,并且希望测试框架断言这种失败。Rust 提供了一个名为 #[should_panic] 的属性来实现这一点。以下是一个引发 panic 并使用此属性的测试用例:

// panic_test.rs

#[test]
#[should_panic]
fn this_panics() {
    assert_eq!(1, 2);
}

#[should_panic] 属性可以与 #[test] 属性一起使用,表示运行 this_panics 函数应该导致不可恢复的失败,这在 Rust 中被称为 panic

忽略测试

编写测试的另一个有用属性是 #[ignore]。如果你的测试代码非常复杂,使用 #[ignore] 注解可以让测试运行器在运行 cargo test 时忽略这些测试函数。然后你可以选择单独运行这些测试,通过向测试运行器或 cargo test 命令提供 --ignored 参数来实现。以下是一个包含愚蠢循环的代码示例,当使用 cargo test 运行时,默认会被忽略:

// silly_loop.rs

pub fn silly_loop() {
    for _ in 1..1_000_000_000 {};
}

#[cfg(test)]
mod tests {
    #[test]
    #[ignore]
    pub fn test_silly_loop() {
        ::silly_loop();
    }
}

注意 test_silly_loop 测试函数上的 #[ignore] 属性。以下是忽略测试的输出:

注意:也可以通过向 Cargo 提供测试函数名来运行单个测试,例如,cargo test some_test_func

集成测试

虽然单元测试可以测试你的 crate 的私有接口和单个模块,但集成测试更像是一种黑盒测试,旨在从消费者的角度测试 crate 公共接口的端到端使用。在编写代码方面,编写集成测试和单元测试之间没有太大的区别。唯一的区别在于目录结构,并且需要将项目公开,这已经由 crate 的设计者公开了。

第一次集成测试

如我们之前所述,Rust 期望所有集成测试都位于tests/目录中。tests/目录中的文件被编译为似乎它们是单独的二进制 crate,同时使用我们正在测试的库。对于以下示例,我们将通过运行cargo new integration_test --lib创建一个新的 crate,与之前的单元测试具有相同的函数sum,但现在我们添加了一个tests/目录,其中定义了一个如下所示的集成测试函数:

// integration_test/tests/sum.rs

use integration_test::sum;

#[test]
fn sum_test() { 
    assert_eq!(sum(6, 8), 14); 
} 

我们首先将函数sum引入作用域。其次,我们有一个名为sum_test的函数,它调用sum并断言返回值。当我们尝试运行cargo test时,我们遇到了以下错误:

清理资源图片

这个错误看起来是合理的。我们希望我们的 crate 的用户使用sum函数,但在我们的 crate 中,我们默认将其定义为私有函数。因此,在sum函数前添加pub修饰符并运行cargo test后,我们的测试再次变为绿色:

错误图片

这里是我们integration_test示例 crate 的目录树视图:

. 
├── Cargo.lock 
├── Cargo.toml 
├── src 
│   └── lib.rs 
└── tests 
    └── sum.rs 

作为集成测试的一个例子,这非常简单,但它的要点是,当我们编写集成测试时,我们使用正在测试的 crate,就像任何其他库的用户使用它一样。

共享通用代码

正如集成测试通常所做的那样,我们需要在运行测试之前放置一些设置和清理相关的代码。通常,我们希望这些代码在tests/目录下的所有文件中共享。为了共享代码,我们可以通过创建一个共享通用代码的目录,或者使用一个名为foo.rs的模块,并在我们的集成测试文件中通过放置一个mod声明来声明我们依赖于它。因此,在我们的前一个tests/目录中,我们添加了一个名为common.rs的模块,其中包含两个名为setupteardown的函数:

// integration_test/tests/common.rs

pub fn setup() {
    println!("Setting up fixtures");
}

pub fn teardown() {
    println!("Tearing down");
}

在我们的两个函数中,我们可以包含任何类型的固定相关代码。考虑一下,如果你有一个依赖于文本文件存在的集成测试。在我们的setup函数中,我们可以创建文本文件,而在我们的teardown函数中,我们可以通过删除文件来清理资源。

要在我们的tests/sum.rs集成测试代码中使用这些函数,我们按照如下方式添加mod声明:

// integration_test/tests/sum.rs

use integration_test::sum;

mod common;

use common::{setup, teardown};

#[test]
fn sum_test() { 
    assert_eq!(sum(6, 8), 14); 
}

#[test]
fn test_with_fixture() {
    setup();
    assert_eq!(sum(7, 14), 21);
    teardown();
}

我们添加了另一个函数,test_with_fixture,它包括对setupteardown的调用。我们可以通过cargo test test_with_fixture运行这个测试。正如你可能从输出中注意到的,我们在setupteardown函数内部看不到任何println!调用。这是因为默认情况下,测试框架会隐藏或捕获测试函数内的打印语句,以使测试结果更整洁,并且只显示测试框架的输出。如果我们想在测试中查看打印语句,我们可以通过cargo test test_with_fixture -- --nocapture运行测试,这将给出以下输出:

我们现在可以看到我们的打印语句了。我们需要在cargo test test_with_fixture -- --nocapture中使用--,因为我们实际上想将--nocapture标志传递给我们的测试运行器。--标记了cargo本身的参数结束,并且任何随后的参数都传递给由 cargo 调用的二进制文件,即我们的带有测试框架的编译二进制文件。

集成测试就到这里。在本章结束时,我们将创建一个项目,在那里我们可以看到单元测试和集成测试协同工作。接下来,我们将学习如何编写 Rust 代码的文档,这是软件开发中一个被忽视但相当重要的部分。

文档

文档是任何开源软件面向程序员社区广泛采用的一个非常关键的部分。虽然你的代码,应该是可读的,告诉你它是如何工作的,但文档应该告诉你设计决策的原因和如何以及公共 API 的示例用法。一个良好的文档,带有全面的README.md页面,可以大大提高你项目的可发现性。

Rust 社区非常重视文档,并在各个级别提供了工具,使编写代码文档变得容易。它还使文档对用户来说易于展示和消费。对于编写文档,它支持 Markdown 方言。Markdown 是一种非常流行的标记语言,并且现在是编写文档的标准。Rust 有一个专门的工具叫做rustdoc,它可以解析 Markdown 文档注释,将它们转换为 HTML,并生成美丽且可搜索的文档页面。

编写文档

要编写文档,我们有特殊的符号来标记文档注释的开始(以下称为 doc 注释)。文档的编写方式与编写注释类似,但与普通注释相比,它们被处理得不同,并且由 rustdoc 解析。doc 注释分为两个级别,并使用不同的符号来标记 doc 注释的开始:

  • 项目级别:这些注释是针对模块内的项目,如结构体、枚举声明、函数、特质常量等。它们应该出现在项目上方。对于单行注释,它们以///开头,而多行注释以/**开头,以*/结尾。

  • 模块级别:这些是在根级别出现的注释,即main.rslib.rs或任何其他模块,并使用//!来标记行注释的开始——或者使用/*!来标记多行注释——在结束前使用*/。它们适合提供对 crate 的一般概述和示例用法。

在文档注释中,你可以使用常规的 Markdown 语法来编写文档。它还支持在反引号(```rs`let a = 23;````)内编写有效的 Rust 代码,这将成为文档测试的一部分。

之前用于编写注释的符号实际上是#[doc="your doc comment"]属性的语法糖。这些被称为文档属性。当 rustdoc 解析////**行时,它会将它们转换为这些文档属性。或者,你也可以使用这些文档属性来编写文档。

生成和查看文档

要生成文档,我们可以在项目目录中使用cargo doc命令。它会在target/doc/目录下生成包含许多 HTML 文件和预定义样式的文档。默认情况下,它还会为 crate 的依赖项生成文档。我们可以通过运行cargo doc --no-deps来告诉 Cargo 忽略为依赖项生成文档。

要查看文档,可以在target/doc目录内导航以启动一个 HTTP 服务器。Python 的简单 HTTP 服务器在这里很有用。然而,还有更好的方法!将--open选项传递给cargo doc将直接在默认浏览器中打开文档页面。

cargo doc可以与cargo watch结合使用,以在编写文档并获得对项目上任何文档更改的实时反馈时获得无缝体验。

托管文档

在你的文档生成后,你需要将其托管在某个地方供公众查看和使用。这里有三种可能性:

  • docs.rs:托管在crates.io上的 crate 会自动生成并托管在docs.rs上的文档页面。

  • GitHub pages:如果你的 crate 在 GitHub 上,你可以将其文档托管在gh-pages分支上。

  • 外部网站:你可以管理自己的 Web 服务器来托管文档。Rust 的标准库文档就是这样一个很好的例子:doc.rust-lang.org/std/

作为附加说明,如果你的项目文档超过两到三页,并且需要详细的介绍,那么有一个更好的选项来生成类似书籍的文档。这是通过使用mdbook项目来实现的。有关更多信息,请查看他们的 GitHub 页面github.com/rust-lang-nursery/mdBook

文档属性

我们提到,我们编写的文档注释会被转换成文档属性形式。除了这些,还有其他文档属性可以调整生成的文档页面,这些属性可以在 crate 级别或项目级别应用。它们被写成#[doc(key = value)]的形式。以下是一些最有用的文档属性:

crate 级属性:

  • #![doc(html_logo_url = "image url")]: 允许您在文档页面的左上角添加一个标志。

  • #![doc(html_root_url = "https://docs.rs/slotmap/0.2.1")]: 允许您设置文档页面的 URL。

  • #![doc(html_playground_url = "https://play.rust-lang.org/")]: 允许您在文档中的代码示例附近放置一个运行按钮,以便您可以直接在在线 Rust playground 中运行它。

项目级属性:

  • #[doc(hidden)]: 假设您已经为公开函数foo编写了文档,作为自己的笔记。然而,您不希望您的消费者查看这些文档。您可以使用此属性告诉 rustdoc 忽略为foo生成文档。

  • #[doc(include)]: 这可以用来包含来自其他文件的文档。如果文档真的很长,这有助于您将文档与代码分离。

对于更多此类属性,请访问doc.rust-lang.org/beta/rustdoc/the-doc-attribute.html

文档测试

在为 crate 的公开 API 编写任何文档时包含代码示例通常是一个好习惯。尽管如此,维护这些示例也存在一个注意事项。您的代码可能会更改,您可能会忘记更新示例。文档测试(doctests)就是为了提醒您更新示例代码。Rust 允许您在文档注释中嵌入反引号内的代码。然后 Cargo 可以运行嵌入在文档中的示例代码,并将其视为单元测试套件的一部分。这意味着文档示例会在您运行单元测试时运行,迫使您更新它们。这非常神奇!

文档测试也是通过 Cargo 执行的。我们创建了一个名为doctest_demo的项目来展示文档测试。在lib.rs中,我们有以下代码:

// doctest_demo/src/lib.rs

//! This crate provides functionality for adding things
//!
//! # Examples
//! ```

//! use doctest_demo::sum;

//!

//! let work_a = 4;

//! let work_b = 34;

//! let total_work = sum(work_a, work_b);

//! ```rs

/// Sum two arguments
///
/// # Examples
///
/// ```

/// assert_eq!(doctest_demo::sum(1, 1), 2);

/// ```rs
pub fn sum(a: i8, b: i8) -> i8 {
    a + b
}

如您所见,模块级和函数级文档测试之间的区别不大。它们的使用方式几乎相同。只是模块级文档测试显示了 crate 的整体使用情况,覆盖了多个 API 界面,而函数级文档测试仅覆盖它们出现的特定函数。

当您运行cargo test时,文档测试会与其他所有测试一起运行。以下是我们运行doctest_demo crate 中的cargo test时的输出:

基准测试

当业务需求发生变化,并且你的程序需要更高效地执行时,首先要采取的步骤是找出程序中缓慢的区域。你如何判断瓶颈在哪里?你可以通过在各个预期的范围或输入量级上测量程序的单个部分来了解。这被称为基准测试你的代码。基准测试通常在开发的最后阶段进行(但不必如此),以提供关于代码中性能陷阱的见解。

对于一个程序,有多种方式进行基准测试。最简单的方法是使用 Unix 工具 time 来测量你更改后的程序执行时间。但这并不提供精确的微级洞察。Rust 为我们提供了一个内置的微基准测试框架。通过微基准测试,我们指的是它可以用来独立基准测试代码的各个部分,并且不受外部因素的影响。然而,这也意味着我们不应该仅仅依赖于微基准测试,因为现实世界的结果可能会被扭曲。因此,微基准测试通常随后会进行代码的剖析和宏基准测试。尽管如此,微基准测试通常是提高你代码性能的起点,因为各个部分对程序的总体运行时间贡献很大。

在本节中,我们将讨论 Rust 提供的用于执行微基准测试的内置工具。Rust 从开发初期就降低了编写基准测试代码的门槛,而不是作为最后的手段。运行基准测试的方式与运行测试类似,但使用的是cargo bench命令。

内置的微基准测试工具

Rust 的内置基准测试框架通过运行多次迭代来衡量代码的性能,并报告操作的平均时间。这由以下两点促成:

  • 函数上的#[bench]注解。这表示该函数是一个基准测试。

  • 内部编译器 crate libtest,其中包含一个Bencher类型,基准测试函数使用它来运行多次相同的基准测试代码。此类型位于编译器内部的test crate 下。

现在,我们将编写并运行一个简单的基准测试。让我们通过运行cargo new --lib bench_example来创建一个新的 Cargo 项目。对于这个项目,不需要对Cargo.toml进行任何更改。src/lib.rs的内容如下:

// bench_example/src/lib.rs

#![feature(test)]
extern crate test;

use test::Bencher;

pub fn do_nothing_slowly() {
    print!(".");
    for _ in 1..10_000_000 {};
}

pub fn do_nothing_fast() {
}

#[bench]
fn bench_nothing_slowly(b: &mut Bencher) {
    b.iter(|| do_nothing_slowly());
}

#[bench]
fn bench_nothing_fast(b: &mut Bencher) {
    b.iter(|| do_nothing_fast());
}

注意,我们必须使用external crate声明和#[feature(test)]属性来指定内部 crate testextern声明对于编译器内部的 crate 是必需的。在编译器的未来版本中,这可能不再需要,你将能够像正常 crate 一样use它们。

如果我们通过运行cargo bench来运行基准测试,我们将看到以下内容:

不幸的是,基准测试是一个不稳定的功能,所以我们将不得不使用夜间编译器来执行这些测试。幸运的是,使用 rustup 在 Rust 编译器的不同发布渠道之间切换很容易。首先,我们将通过运行 rustup update nightly 来确保夜间编译器已安装。然后,在我们的 bench_example 目录中,我们将通过运行 rustup override set nightly 来覆盖此目录的默认工具链。现在,运行 cargo bench 将给出以下输出:

这些是每次迭代的纳秒数,括号内的数字显示了每次运行之间的变化。我们的较慢实现运行速度相当慢且变化很大(如大的 +/- 变化所示)。

在我们标记为 #[bench] 的函数内部,iter 的参数是一个无参数的闭包。如果闭包有参数,它们将位于 || 内。这本质上意味着 iter 被传递了一个可以被基准测试重复运行的函数。我们在函数中打印一个点,这样 Rust 就不会优化掉空循环。如果没有 println!(),那么编译器就会将循环优化为一个无操作,我们会得到错误的结果。有方法可以绕过这个问题,这是通过使用 test 模块中的 black_box 函数来实现的。然而,即使使用了那个函数,也不能保证优化器不会优化你的代码。现在,我们也有其他第三方解决方案来在稳定版 Rust 上运行基准测试。

在稳定版 Rust 上的基准测试

Rust 提供的内置基准测试框架是不稳定的,但幸运的是,有一些社区开发的基准测试 crate 可以在稳定版 Rust 上工作。我们将在这里探索的一个流行的 crate 是 criterion-rs。这个 crate 设计得易于使用,同时提供有关基准测试代码的详细信息。它还维护了上次运行的状

为了演示如何使用这个 crate,我们将创建一个新的 crate,名为 cargo new criterion_demo --lib。我们将 criterion crate 添加到 Cargo.toml 中的 dev-dependencies 部分作为依赖项:

[dev-dependencies]
criterion = "0.1"

[[bench]]
name = "fibonacci"
harness = false

我们还添加了一个新的部分,称为 [[bench]],它指示 cargo 我们有一个名为 fibonacci 的新基准测试,并且它不使用内置的基准测试工具(harness = false),因为我们正在使用 criterion crate 的测试工具。

现在,在 src/lib.rs 中,我们有一个计算第 n 个 fibonacci 数(初始值为 n[0] = 0n[1] = 1)的函数的快速和慢速版本:

// criterion_demo/src/lib.rs

pub fn slow_fibonacci(nth: usize) -> u64 {
    if nth <= 1 {
        return nth as u64;   
    } else {
        return slow_fibonacci(nth - 1) + slow_fibonacci(nth - 2);
    }
}

pub fn fast_fibonacci(nth: usize) -> u64 {
    let mut a = 0;
    let mut b = 1;
    let mut c = 0;
    for _ in 1..nth {
        c = a + b;
        a = b;
        b = c;
    }
    c
}

fast_fibonacci 是获取第 n 个斐波那契数的自底向上的迭代解决方案,而 slow_fibonacci 版本则是较慢的递归版本。现在,criterion-rs 要求我们将基准测试放在一个 benches/ 目录中,我们在 crate 根目录下创建了它。在 benches/ 目录中,我们还创建了一个名为 fibonacci.rs 的文件,它与 Cargo.toml 中的 [[bench]] 下的名称相匹配。其内容如下:

// criterion_demo/benches/fibonacci.rs

#[macro_use]
extern crate criterion;
extern crate criterion_demo;

use criterion_demo::{fast_fibonacci, slow_fibonacci};
use criterion::Criterion;

fn fibonacci_benchmark(c: &mut Criterion) {
    c.bench_function("fibonacci 8", |b| b.iter(|| slow_fibonacci(8)));
}

criterion_group!(fib_bench, fibonacci_benchmark);
criterion_main!(fib_bench);

这里有很多事情在进行中!在上面的代码中,我们首先声明了所需的 crate 并导入了我们需要基准测试的 fibonacci 函数(fast_fibonaccislow_fibonacci)。此外,在 extern crate criterion 上有一个 #[macro_use] 属性,这意味着要使用来自 crate 的任何宏,我们需要使用此属性来选择它,因为它们默认没有暴露。这类似于一个 use 语句,它用于暴露模块项。

现在,criterion 有一个概念叫做基准测试组,它可以包含相关的基准测试代码。相应地,我们创建了一个名为 fibonacci_benchmark 的函数,然后将其传递给 criterion_group! 宏。这给这个基准测试组分配了一个名为 fib_bench 的名称。fibonacci_benchmark 函数接收一个对 criterion 对象的可变引用,该对象持有我们的基准测试运行的状态。这暴露了一个名为 bench_function 的方法,我们使用它将我们的基准测试代码传递到一个具有给定名称的闭包中(上面是 fibonacci 8)。然后,我们需要创建主要的基准测试 harness,它使用 criterion_main! 生成带有 main 函数的代码来运行所有这些,在传递我们的基准测试组 fib_bench 之前。现在,是时候运行 cargo bench 并在闭包中包含第一个 slow_fibonacci 函数了。我们得到以下输出:

图片

我们可以看到,我们的 fibonacci 函数的递归版本平均运行时间约为 106.95 纳秒。现在,在同一个基准测试闭包中,如果我们用 fast_fibonacci 替换 slow_fibonacci 并再次运行 cargo bench,我们将得到以下输出:

图片

太棒了!fast_fibonacci 版本平均运行时间仅为 7.8460 纳秒。这是显而易见的,但真正令人兴奋的是详细的基准测试报告,它还显示了一个人性化的消息:性能已提升。criterion 能够显示这个回归报告的原因是它维护了基准测试运行的先前状态,并使用它们的历史记录来报告性能的变化。

编写和测试一个 crate – 逻辑门模拟器

带着所有这些知识,让我们开始我们的逻辑门模拟 crate。通过运行cargo new logic_gates --lib来创建一个新的项目。从实现为函数的原始门开始,例如andxor等,我们将为这些门编写单元测试。随后,我们将通过实现使用我们的原始门的半加器来编写集成测试。在这个过程中,我们还将为我们的 crate 编写文档。

首先,我们将从一些单元测试开始。以下是初始的 crate 代码的完整内容:

//! This is a logic gates simulation crate built to demonstrate writing unit tests and integration tests

// logic_gates/src/lib.rs

pub fn and(a: u8, b: u8) -> u8 {
    unimplemented!()
}

pub fn xor(a: u8, b: u8) -> u8 {
    unimplemented!()
}

#[cfg(test)]
mod tests {
    use crate::{xor, and};
    #[test]
    fn test_and() {
        assert_eq!(1, and(1, 1));
        assert_eq!(0, and(0, 1));
        assert_eq!(0, and(1, 0));
        assert_eq!(0, and(0, 0));
    }

    #[test]
    fn test_xor() {
        assert_eq!(1, xor(1, 0));
        assert_eq!(0, xor(0, 0));
        assert_eq!(0, xor(1, 1));
        assert_eq!(1, xor(0, 1));
    }
}

我们从两个逻辑门andxor开始,这些门已经作为函数实现。我们还有针对它们的测试用例,当运行时因为它们尚未实现而失败。注意,为了表示比特01,我们使用u8,因为 Rust 没有原生的类型来表示比特。现在,让我们填写它们的实现,以及一些文档:

/// Implements a boolean `and` gate taking as input two bits and returns a bit as output
pub fn and(a: u8, b: u8) -> u8 {
    match (a, b) {
        (1, 1) => 1,
        _ => 0
    }
}

/// Implements a boolean `xor` gate taking as input two bits and returning a bit as output
pub fn xor(a: u8, b: u8) -> u8 {
    match (a, b) {
        (1, 0) | (0, 1) => 1,
        _ => 0
    }
}

在前面的代码中,我们只是使用 match 表达式表达了andxor门的真值表。我们可以看到 match 表达式在表达我们的逻辑时是多么简洁。现在,我们可以通过运行cargo test来运行测试:

图片

全部绿色!我们现在可以使用这些门实现半加器来编写集成测试。半加器完美地适合作为集成测试示例,因为它在组件被一起使用时测试了我们的 crate 的各个部分。在tests/目录下,我们将创建一个名为half_adder.rs的文件,其中包含以下代码:

// logic_gates/tests/half_adder.rs

use logic_gates::{and, xor};

pub type Sum = u8;
pub type Carry = u8;

pub fn half_adder_input_output() -> Vec<((u8, u8), (Sum, Carry))> { 
    vec![
        ((0, 0), (0, 0)), 
        ((0, 1), (1, 0)), 
        ((1, 0), (1, 0)), 
        ((1, 1), (0, 1)), 
    ] 
}

/// This function implements a half adder using primitive gates
fn half_adder(a: u8, b: u8) -> (Sum, Carry) {
    (xor(a, b), and(a, b))
}

#[test]
fn one_bit_adder() {
    for (inn, out) in half_adder_input_output() {
        let (a, b) = inn;
        println("Testing: {}, {} -> {}", a, b, out);
        assert_eq!(half_adder(a, b), out);
    }
}

在前面的代码中,我们导入了我们的原始门函数xorand。随后,我们有类似pub type Sum = u8的东西,这被称为类型别名。它们在以下情况下很有帮助:当你有一个每次都难以书写的类型,或者当你有具有复杂签名的类型时。它为我们原始类型提供了一个新名称,纯粹是为了可读性和消除歧义;它对 Rust 分析这些类型的方式没有影响。然后我们在half_adder_input_output函数中使用SumCarry,该函数实现了半加器的真值表。这是一个方便的辅助函数,用于测试随后的half_adder函数。这个函数接受两个单比特输入,并从它们中计算出SumCarry,然后作为(Sum, Carry)元组返回。进一步,我们有我们的one_bit_adder集成测试函数,其中我们遍历半加器输入输出对,并对half_adder的输出进行断言。通过运行cargo test,我们得到以下输出:

图片

太棒了!让我们也通过运行 cargo doc --open 为我们的 crate 生成文档。--open 标志会在浏览器中为我们打开页面。为了自定义我们的文档,我们还将向 crate 文档页面添加一个图标。为此,我们需要在 lib.rs 的顶部添加以下属性:

#![doc(html_logo_url = "https://d30y9cdsu7xlg0.cloudfront.net/png/411962-200.png")]

生成后,文档页面看起来是这样的:

图片

这太棒了!我们在测试之旅上已经走了很长的路。接下来,让我们看看自动化测试套件的方面。

与 Travis CI 进行持续集成

在大型软件系统中,对于代码的每一次更改,我们通常都希望自动运行我们的单元测试和集成测试。此外,在协作项目中,手动方式显然不切实际。幸运的是,持续集成是一种旨在自动化软件开发这些方面的实践。Travis CI 是一个公共持续集成服务,允许您根据事件钩子在云中自动运行您项目的测试。事件钩子的一个例子是在推送新提交时。

Travis 通常用于自动化运行构建和测试以及报告失败的构建,但也可以用于创建发布版本,甚至部署到预发布或生产环境。在本节中,我们将关注 Travis 的一个方面,即为我们项目执行自动测试。GitHub 已经集成了 Travis,可以为我们项目的每个新提交运行测试。为了实现这一点,我们需要以下内容:

  • 我们在 GitHub 上的项目

  • 通过使用 GitHub 登录创建的 Travis 账户

  • 您的项目已在 Travis 中启用构建

  • 在您的仓库根目录下的 .travis.yml 文件,告诉 Travis 要运行什么

第一步是前往 travis-ci.org/ 并使用您的 GitHub 凭据登录。从那里,我们可以在 Travis 中添加我们的 GitHub 仓库。Travis 对 Rust 项目有良好的原生支持,并持续更新其 Rust 编译器。它为 Rust 项目提供了一个基本的 .travis.yml 文件,如下所示:

language: rust 
rust: 
  - stable 
  - beta 
  - nightly 
matrix: 
  allow_failures: 
  - rust: nightly 

Rust 项目还建议针对 beta 和 nightly 频道进行测试,但您可以选择通过删除相应的行来仅针对单个版本进行目标。此推荐设置在所有三个版本上运行测试,但允许快速移动的 nightly 编译器失败。

在您的仓库中拥有这个 .travis.yml 文件,GitHub 将在您每次推送代码并运行测试时通知 Travis CI。我们还可以将构建状态徽章附加到仓库的 README.md 文件中,当测试通过时显示绿色徽章,当测试失败时显示红色徽章。

让我们将 Travis 与我们的 logic_gates crate 集成。为此,我们必须在 crate 根目录下添加一个 .travis.yml 文件。以下是为 .travis.yml 文件的内容:

language: rust
rust:
  - stable
  - beta
  - nightly
matrix:
  allow_failures:
    - rust: nightly
  fast_finish: true
cache: cargo

script:
  - cargo build --verbose
  - cargo test --verbose

将此推送到 GitHub 后,我们接下来需要在他们的页面上启用 Travis 以用于我们的项目,如下所示:

图片

之前的截图来自我的 TravisCI 账户。现在,我们将通过向我们的logic_gates仓库添加一个简单的README.md文件来提交一个 commit,以触发 Travis 构建运行器。在此过程中,我们还将向README.md文件添加一个构建徽章,以便向消费者展示我们仓库的状态。为此,我们将点击右侧的构建通过徽章:

图片

这将打开一个包含徽章链接的弹出菜单:

图片

我们将复制此链接并将其添加到README.md文件的顶部,如下所示:

[![Build Status](https://travis-ci.org/$USERNAME/$REPO_NAME.svg?branch=master)](https://travis-ci.org/creativcoder/logic_gates)

您需要将$USERNAME$REPO_NAME替换为您自己的详细信息。

在此更改并提交README.md文件之后,我们将开始看到 Travis 构建开始并成功:

图片

太棒了!如果您更有雄心,您还可以尝试在 GitHub 仓库的gh-pages分支上托管logic_gates crate 的文档。您可以通过使用可在github.com/roblabla/cargo-travis找到的cargo-travis项目来实现这一点。

对于一个更通用的 CI 设置,它涵盖了主要平台,您可以使用信任项目提供的模板,该模板可在github.com/japaric/trust找到。

最后,为了在crates.io上发布您的 crate,您可以按照 Cargo 参考文档中给出的说明操作:doc.rust-lang.org/cargo/reference/publishing.html

摘要

在本章中,我们熟悉了使用rustccargo工具编写单元测试、集成测试、文档测试和基准测试。然后,我们实现了一个逻辑门模拟 crate,并体验了整个 crate 开发工作流程。之后,我们学习了如何将 Travis CI 集成到我们的 GitHub 项目中。

在下一章中,我们将探讨 Rust 的类型系统以及如何在编译时使用它来在我们的程序中表达正确的语义。

第四章:类型、泛型和特性

Rust 的类型系统是语言的一个显著特点。在本章中,我们将详细介绍语言的一些显著方面,如特性、泛型和如何使用它们来编写表达性代码。我们还将探索一些有助于编写惯用 Rust 库的标准库特性。期待本章中有许多有趣的内容!

我们将涵盖以下主题:

  • 类型系统和它们的重要性

  • 泛型编程

  • 使用特性增强类型

  • 探索标准库特性

  • 组合特性和泛型以编写表达性代码

类型系统和它们的重要性

“发送时要保守,接受时要宽容。” —— 约翰·波斯尔

为什么我们需要在语言中使用类型?这是一个很好的问题,可以作为理解编程语言中类型系统的动机。作为程序员,我们知道为计算机编写的程序在最低级别上以 0 和 1 的组合形式表示为二进制。事实上,最早的计算机必须手动用机器码编程。最终,程序员意识到这非常容易出错、繁琐且耗时。对于人类来说,在二进制级别操作和推理这些实体并不实用。后来,在 20 世纪 50 年代,编程社区提出了机器码助记符,这变成了我们今天所知道的汇编语言。在此之后,编程语言开始出现,它们编译成汇编代码,允许程序员编写人类可读且易于计算机编译成机器码的代码。然而,我们人类所使用的语言可能相当含糊,因此需要制定一套规则和约束来传达在用类似人类语言编写的计算机程序中可能和不可能的内容,即语义。这引出了类型和类型系统的概念。

类型是一组可能值的命名集合。例如,u8是一个只能包含从 0 到 255 的正值的类型。类型为我们提供了一种方式来弥合底层表示和我们对这些实体创建的心理模型之间的差距。除此之外,类型还为我们提供了一种表达意图、行为和约束的方式。它们定义了我们可以用类型做什么,不能做什么。例如,将字符串类型的值添加到数字类型的值是未定义的。从类型出发,语言设计者构建了类型系统,这是一组规则,它规定了在编程语言中不同类型如何相互交互。它们作为推理程序的工具,并有助于确保我们的程序按规范正确运行。类型系统根据其表达能力进行分类,这仅仅意味着你可以用类型系统表达你的逻辑,以及程序中的不变性。例如,Haskell 这种高级语言有一个非常表达性的类型系统,而 C 这种低级语言为我们提供了非常少的基于类型的抽象。Rust 试图在这两个极端之间划一条细线。

Rust 的类型系统在很大程度上受到了函数式语言如 Ocaml 和 Haskell 的启发,它们有诸如枚举和结构体这样的 ADT(抽象数据类型),特质(类似于 Haskell 的类型类),以及错误处理类型(OptionResult)。这个类型系统被描述为一个强类型系统,这仅仅意味着它在编译时执行更多的类型检查,而不是在运行时抛出它们。此外,类型系统是静态的,这意味着例如绑定到整数值的变量不能在之后改变为指向字符串。这些特性使得程序健壮,很少在运行时破坏不变性,但代价是编写程序需要程序员进行一些规划和思考。Rust 试图在设计程序时在你的盘子里放更多的规划,这可能会让一些寻求快速原型化的程序员感到沮丧。然而,从长期维护软件系统的角度来看,这是一件好事。

把这些放在一边,让我们先来探索 Rust 的类型系统是如何使代码重用成为可能的。

泛型

从高级编程语言的开端起,追求更好的抽象一直是语言设计者努力的目标。因此,许多关于代码重用的想法应运而生。其中第一个就是函数。函数允许你将一系列指令封装在可以稍后多次调用的命名实体中,并且可以选择性地为每次调用接受任何参数。它们降低了代码的复杂性并提高了可读性。然而,函数只能带你走这么远。如果你有一个名为avg的函数,它计算给定整数列表的平均值,后来你有一个用例需要计算浮点值列表的平均值,那么通常的解决方案是创建一个新的函数来从浮点值列表中计算平均值。如果你还想接受双精度值列表呢?我们可能需要再次编写另一个函数。反复编写只有参数不同的相同函数是程序员宝贵时间的浪费。为了减少这种重复,语言设计者希望有一种方法来表达代码,使得avg函数可以以接受多种类型的方式编写,从而产生了泛型编程,或称为泛型。泛型编程的一个特征是函数可以接受多种类型,还有其他地方可以使用泛型。我们将在本节中探讨所有这些内容。

泛型编程是一种仅在静态类型编程语言中适用的技术。它们最初出现在 ML 语言中,这是一种静态类型函数式语言。像 Python 这样的动态语言使用鸭子类型,API 根据对象能做什么而不是它们是什么来处理参数,因此它们不依赖于泛型。泛型是语言设计特征的一部分,它使得代码重用和不要重复自己(DRY)原则成为可能。使用这种技术,你可以编写带有类型占位符的算法、函数、方法和类型,并在这些类型上指定一个类型变量(通常用单个字母表示,通常是TKV),告诉编译器在代码实例化时填充实际类型。这些类型被称为泛型类型或项。类型上的单个字母符号,如T,被称为泛型类型参数。当你使用或实例化任何泛型项时,它们会被替换为具体的类型,例如u32

注意:通过替换,我们是指每次使用泛型项与具体类型结合时,在编译时都会生成一个具有类型变量T的专用代码副本,其中T会被替换为具体类型。在编译时生成具有具体类型的专用函数的过程称为单态化,这是执行多态函数相反的过程。

让我们看看 Rust 标准库中的一些现有通用类型。

标准库中的Vec<T>类型是一个定义为以下内容的通用类型:

pub struct Vec<T> {
    buf: RawVec<T>,
    len: usize,
}

我们可以看到Vec的类型签名在其名称之后包含一个类型参数T,由一对尖括号< >包围。其成员字段buf也是一个通用类型,因此Vec本身也必须是泛型的。如果我们没有在泛型类型Vec<T>上使用T,即使在其buf字段上有T,我们也会得到以下错误:

error[E0412]: cannot find type `T` in this scope

这个T需要成为Vec类型定义的一部分。因此,当我们表示Vec时,我们总是使用Vec<T>来表示泛型,或者当我们知道具体的类型时,使用Vec<u64>。接下来,让我们看看如何创建我们自己的通用类型。

创建通用类型

Rust 允许我们声明许多泛型,如结构体、枚举、函数、特质、方法和实现块。它们共同的一点是泛型类型参数由< >括号分隔。在其中,你可以放置任意数量的逗号分隔的泛型类型参数。让我们通过查看泛型函数的创建方法来了解如何创建泛型。

泛型函数

要创建一个泛型函数,我们将泛型类型参数直接放置在函数名称之后和括号之前,如下所示:

// generic_function.rs

fn give_me<T>(value: T) {
    let _ = value;
}

fn main() {
    let a = "generics";
    let b = 1024;
    give_me(a);
    give_me(b);
}

在前面的代码中,give_me是一个泛型函数,其名称后有<T>value参数是类型T。在main中,我们可以用任何参数调用此函数。在编译过程中,我们的编译对象文件将包含此函数的两个专门副本。我们可以通过使用nm命令来确认这一点,如下所示:

nm是 GNU binutils 包中的一个实用工具,用于查看编译对象文件中的符号。通过将我们的二进制文件传递给nm,我们可以使用管道和 grep 来搜索我们的give_me函数的前缀。正如你所见,我们有两个函数副本,它们后面附加了随机 ID 以区分它们。其中一个接受&str,另一个接受i32,因为有两个具有不同参数的调用。

泛型函数是一种以低成本实现多态代码幻觉的方法。我说幻觉,因为编译后,它都是具有具体类型参数的重复代码。尽管如此,它们也有一个缺点,那就是由于代码重复,编译后的对象文件大小增加。这与使用的具体类型数量成正比。在后面的章节中,当我们到达特质时,我们将看到多态的真正形式,即特质对象。尽管如此,泛型多态在大多数情况下仍然是首选的,因为它没有运行时开销,就像特质对象一样。特质对象应该只在泛型无法满足解决方案和需要将多种类型一起存储在集合中的情况下使用。当我们到达特质对象时,我们将看到那些示例。接下来,我们将看看我们如何使我们的结构体和枚举泛型化。我们首先将探索如何声明它们。创建和使用这些类型的内容将在后面的章节中介绍。

泛型类型

泛型结构体:我们可以像这样泛型地声明元组结构体和普通结构体:

// generic_struct.rs

struct GenericStruct<T>(T);

struct Container<T> {
    item: T
}

fn main() {
    // stuff
}

泛型结构体在结构体名称之后包含泛型类型参数,如前述代码所示。有了这个,无论我们在代码的任何地方引用这个结构体,我们都需要一起输入 <T> 部分,以及类型。

泛型枚举:同样,我们也可以创建泛型枚举:

// generic_enum.rs

enum Transmission<T> {
    Signal(T),
    NoSignal
}

fn main() {
    // stuff
}

我们的 Transmission 枚举有一个名为 Signal 的变体,它包含一个泛型值,还有一个名为 NoSignal 的变体,它是一个无值变体。

泛型实现

我们也可以为我们的泛型类型编写 impl 块,但由于额外的泛型类型参数,这会变得冗长,正如我们将看到的。让我们在 Container<T> 结构体上实现一个 new() 方法:

// generic_struct_impl.rs

struct Container<T> {
    item: T
}

impl Container<T> {
    fn new(item: T) -> Self {
        Container { item }
    }
}

fn main() {
    // stuff
}

让我们编译这个:

错误信息无法找到我们的泛型类型 T。在为任何泛型类型编写 impl 块时,我们需要在使用它之前声明泛型类型参数。T 就像是一个变量——一个类型变量——我们需要声明它。因此,我们需要通过在 impl 后面添加 <T> 来稍微修改实现块,如下所示:

impl<T> Container<T> {
    fn new(item: T) -> Self {
        Container { item }
    }
}

随着这个更改,前面的代码可以编译。之前的 impl 块基本上意味着我们正在为出现在 Container<T> 中的所有类型 T 实现这些方法。这个 impl 块是一个泛型实现。因此,每个生成的具体 Container 都将具有这些方法。现在,我们也可以为 Container<T> 编写一个更具体的 impl 块,通过将任何具体类型放在 T 的位置。这将看起来像这样:

impl Container<u32> {
    fn sum(item: u32) -> Self {
        Container { item }
    }
}

在前面的代码中,我们实现了一个名为sum的方法,这个方法只存在于Container<u32>类型上。在这里,由于存在具体的类型u32,我们不需要在impl后面加上<T>。这是impl块另一个很好的特性,它允许你通过独立实现方法来专门化泛型类型。

使用泛型

现在,我们实例化或使用泛型类型的方式也与其非泛型对应物略有不同。每次我们实例化它们时,编译器都需要知道在它们的类型签名中将T替换为具体类型,从而为单态化泛型代码提供类型信息。大多数情况下,具体类型是根据类型的实例化或泛型函数中调用接受具体类型的方法来推断的。在罕见的情况下,我们需要通过使用尖括号操作符(::<>)将具体类型显式地替换为泛型类型来帮助编译器。我们将在稍后看到它是如何使用的。

让我们看看实例化泛型类型Vec<T>的情况。在没有任何类型签名的情况下,以下代码无法编译:

// creating_generic_vec.rs

fn main() {
    let a = Vec::new();
}

编译前面的代码,会得到以下错误:

图片

这是因为编译器不知道类型a会包含什么,直到我们手动指定它或调用它的一个方法,从而传递一个具体值。这在上面的代码片段中有所体现:

// using_generic_vec.rs

fn main() {
    // providing a type
    let v1: Vec<u8> = Vec::new();

    // or calling method
    let mut v2 = Vec::new();
    v2.push(2);    // v2 is now Vec<i32>

    // or using turbofish
    let v3 = Vec::<u8>::new();    // not so readable
}

在第二个代码片段中,我们将v1的类型指定为u8Vec,并且它编译正常。另一种方法,就像v2一样,是调用一个接受任何具体类型的方法。在push方法调用之后,编译器可以推断出v2Vec<i32>。创建Vec的另一种方法是使用尖括号操作符,就像前面代码中的v3绑定一样。

泛型函数中的尖括号操作符出现在函数名之后和括号之前。另一个例子是std::str模块中的泛型parse函数。parse可以从字符串中解析值,许多类型能够从中解析,例如i32f64usize等,因此它是一个泛型类型。所以,当使用parse时,你确实需要使用尖括号操作符,如下所示:

// using_generic_func.rs

use std::str;

fn main() {
    let num_from_str = str::parse::<u8>("34").unwrap();
    println!("Parsed number {}", num_from_str);
}

需要注意的是,只有实现了FromStr接口或特质的类型才能传递给parse函数。u8有一个FromStr的实现,因此我们能够在前面的代码中解析它。parse函数使用FromStr特质来限制可以传递给它的类型。在探索特质之后,我们将了解如何混合泛型和特质。

在掌握泛型概念的基础上,让我们关注 Rust 中最常见的一个特性——特质!

使用特质抽象行为

从多态和代码重用的角度来看,通常将类型的共享行为和共同属性从它们自身分离出来,在代码中只保留独特的属性是一个好主意。这样做,我们允许不同的类型通过这些共同属性相互关联,这使得我们可以为更通用或包容的 API 编程,在参数方面。这意味着我们可以接受具有这些共享属性的类型,而不会局限于某一特定类型。

在像 Java 或 C# 这样的面向对象语言中,接口传达了相同的概念,我们可以定义许多类型可以实现的共享行为。例如,我们不必有多个 sort 函数,这些函数接受整数值的列表,以及其他接受字符串值列表的函数,我们可以有一个单一的 sort 函数,它可以接受实现了 ComparableComparator 接口的项的列表。这允许我们将任何 Comparable 的东西传递给我们的 sort 函数。

Rust 也有一个类似但功能强大的结构,称为 特质。Rust 中有许多特质的形态,我们将简要地查看它们以及我们如何与之交互。此外,当特质与泛型结合使用时,我们可以限制传递给我们的 API 的参数范围。当我们更多地了解特质边界时,我们将看到这是如何发生的。

特质

特质是一个定义了一组合同或共享行为的项,类型可以选择实现。特质本身不可用,旨在由类型实现。特质有建立不同类型之间关系的能力。它们是许多语言特性的骨干,如闭包、运算符、智能指针、循环、编译时数据竞争检查等等。Rust 中许多高级语言特性归结为某些类型调用它们实现的特质方法。话虽如此,让我们看看如何在 Rust 中定义和使用特质!

假设我们正在模拟一个简单的媒体播放器应用程序,它可以播放音频和视频文件。对于这个演示,我们将通过运行 cargo new super_player 来创建一个项目。为了传达特质的概念并使这个例子简单化,在我们的 main.rs 文件中,我们将音频和视频媒体表示为具有媒体名称的元组结构体 String,如下所示:

// super_player/src/main.rs

struct Audio(String);
struct Video(String);

fn main() {
    // stuff
}

现在,至少,AudioVideo 结构体都需要有一个 playpause 方法。这是它们共有的功能。这是一个很好的机会让我们在这里使用一个特质。在这里,我们将在一个名为 media.rs 的单独模块中定义一个名为 Playable 的特质,如下所示:

// super_player/src/media.rs

trait Playable {
    fn play(&self);
    fn pause() {
        println!("Paused");
    }
}

我们使用trait关键字来创建一个特性,后面跟着其名称和一对大括号。在大括号内,我们可以提供零个或多个方法,任何实现该特性的类型都应该实现这些方法。我们还可以在特性中定义常量,所有实现者都可以共享这些常量。实现者可以是任何结构体(struct)、枚举(enum)、原始类型(primitive)、函数、闭包(closure),甚至是另一个特性。

你可能已经注意到了play方法的签名;它接受一个符号的引用self,但没有方法体,并以分号结束。self只是一个类型别名,指向正在实现特性的类型Self。我们将在第七章高级概念中详细讨论这些内容。这意味着特性内的方法类似于 Java 中的抽象方法。实现这个特性并定义函数的具体实现取决于类型。然而,在特性内声明的函数也可以有默认实现,就像前面代码中的pause函数一样。pause不接受self,因此它类似于一个不需要实现者实例即可调用的静态方法。

在一个特性(trait)中,我们可以有两种方法:

  • 关联方法:这些方法可以直接在实现特性的类型上使用,不需要该类型的实例来调用它们。它们也被称为主流语言中的静态方法,例如,标准库中FromStr特性的from_str方法。它为String实现,因此允许你通过调用String::from_str("foo")&str创建一个String

  • 实例方法:这些方法的第一参数是self。这些方法仅在实现该特性的类型的实例上可用。self指向实现该特性的类型的实例。它可以是三种类型:self方法,在调用时消耗实例;&self方法,只有对实例成员(如果有)的读取访问权限;以及&mut self方法,对其实例成员有可变访问权限,可以修改它们,甚至可以用另一个实例替换它们。例如,标准库中AsRef特性的as_ref方法是一个实例方法,它接受&self,并且旨在由可以转换为引用或指针的类型实现。当我们到达第五章内存管理和安全性时,我们将涵盖引用和这些方法类型签名中的&&mut部分。

现在,我们将像这样在我们的AudioVideo类型上实现前面的Playable特性:

// super_player/src/main.rs

struct Audio(String);
struct Video(String);

impl Playable for Audio {
    fn play(&self) {
        println!("Now playing: {}", self.0);
    }
}

impl Playable for Video {
    fn play(&self) {
        println!("Now playing: {}", self.0);
    }
}

fn main() {
    println!("Super player!");
}

我们使用impl关键字后跟特质名称,然后是for关键字和我们要为其实现特质的类型,之后是一对大括号。在这些大括号内,我们必须提供方法的实现,并且可以选择覆盖特质中存在的任何默认实现。让我们编译这段代码:

图片

前面的错误突出了特质的 重要特性:特质默认是私有的。为了能被其他模块或跨 crate 使用,它们需要被公开。这需要两个步骤。首先,我们需要将我们的特质暴露给外界。为此,我们需要在Playable特质声明前加上pub关键字:

// super_player/src/media.rs

pub trait Playable {
    fn play(&self);
    fn pause() {
        println!("Paused");
    }
}

在我们暴露了我们的特质之后,我们需要使用use关键字将特质引入我们想要使用特质的模块的作用域。这将允许我们调用其方法,如下所示:

// super_player/src/main.rs

mod media;

struct Audio(String);
struct Video(String);

impl Playable for Audio {
    fn play(&self) {
        println!("Now playing: {}", self.0);
    }
}

impl Playable for Video {
    fn play(&self) {
        println!("Now playing: {}", self.0);
    }
}

fn main() {
    println!("Super player!");
    let audio = Audio("ambient_music.mp3".to_string());
    let video = Video("big_buck_bunny.mkv".to_string());
    audio.play();
    video.play();
}

这样,我们就可以播放我们的音频和视频媒体:

图片

这与任何实际的媒体播放器实现都相去甚远,但我们的目标是探索特质的使用场景。

特质也可以在其声明中指定它们依赖于其他特质;这是一个称为特质继承的功能。我们可以这样声明继承的特质:

// trait_inheritance.rs

trait Vehicle {
    fn get_price(&self) -> u64;
}

trait Car: Vehicle {
    fn model(&self) -> String;
}

struct TeslaRoadster {
    model: String,
    release_date: u16
}

impl TeslaRoadster {
    fn new(model: &str, release_date: u16) -> Self {
        Self { model: model.to_string(), release_date }
    }
}

impl Car for TeslaRoadster {
    fn model(&self) -> String {
        "Tesla Roadster I".to_string()
    }
}

fn main() {
    let my_roadster = TeslaRoadster::new("Tesla Roadster II", 2020);
    println!("{} is priced at ${}", my_roadster.model, my_roadster.get_price());
}

在前面的代码中,我们声明了两个特质:一个更通用的Vehicle特质和一个依赖于Vehicle的更具体的Car特质。由于TeslaRoadster是一辆车,我们为它实现了Car特质。注意TeslaRoadsternew方法的主体,它使用Self作为返回类型。这也会被用来替换我们从new返回的TeslaRoadster实例。Self只是特质 impl 块内实现类型的便利别名。它也可以用来创建其他类型,如元组结构和枚举,以及匹配表达式。让我们尝试编译这段代码:

图片

看到那个错误吗?在其定义中,Car特质指定了任何实现该特质的类型也必须实现Vehicle特质,Car: Vehicle。我们没有为我们的TeslaRoadster实现Vehicle,Rust 为我们捕获并报告了它。因此,我们必须像这样实现Vehicle特质:

// trait_inheritance.rs

impl Vehicle for TeslaRoadster {
    fn get_price(&self) -> u64 {
        200_000
    }
}

在这个实现满足后,我们的程序编译良好,以下是其输出:

 Tesla Roadster II is priced at $200000

get_price方法中的200_200中的下划线是一个方便的语法,用于创建可读的数字字面量。

作为面向对象语言的类比,特质及其实现类似于接口和实现这些接口的类。然而,需要注意的是,特质与接口非常不同:

  • 尽管特性在 Rust 中具有一种继承形式,但实现却没有。这意味着可以声明一个名为 Panda 的特性,它要求实现 Panda 的类型实现另一个名为 KungFu 的特性。然而,这些类型本身并没有任何继承形式。因此,而不是使用对象继承,使用类型组合,这依赖于特性继承来在代码中建模任何现实世界的实体。

  • 你可以在任何地方编写特性实现块,而不需要访问实际类型。

  • 你也可以在任何类型上实现自己的特性,从内置的基本类型到泛型类型。

  • 你不能像在 Java 中将接口作为返回类型一样隐式地拥有函数中的返回类型作为特性。你必须返回一个称为特性对象的东西,并且执行此操作的语法是明确的。当我们到达特性对象时,我们将看到如何做到这一点。

特性的多种形式

在前面的例子中,我们瞥见了特性的最简单形式。但特性比表面看起来要复杂得多。当你开始在更大的代码库中与特性交互时,你会遇到它们的不同形式。根据程序复杂性和要解决的问题,简单的特性形式可能不适合。Rust 为我们提供了其他形式的特性,这些特性很好地模拟了问题。我们将查看一些标准库特性,并尝试对它们进行分类,以便我们有一个很好的想法在何时使用什么。

标记特性

std::marker 模块中定义的特性被称为标记特性。这些特性没有任何方法,只是简单地声明了它们的名称,并且体为空。标准库中的例子包括 CopySendSync。它们被称为标记特性,因为它们被用来简单地标记一个类型属于特定的家族,以获得一些编译时保证。标准库中的两个例子是 SendSync 特性,这些特性在适当的时候由语言自动实现,并传达了哪些值是可以在线程间安全发送和共享的。我们将在第八章并发中了解更多关于它们的信息。

简单特性

这可能是特性定义可能的最简单形式。我们已经在特性的介绍中讨论了这一点:

trait Foo {
    fn foo();
}

标准库中的一个例子是 Default 特性,它为可以初始化为默认值的类型实现。它在doc.rust-lang.org/std/default/trait.Default.html上有文档说明。

泛型特性

特性也可以是泛型的。这在需要为广泛的各种类型实现特性时很有用:

pub trait From<T> {
    fn from(T) -> Self;
}

两个这样的例子是From<T>Into<T>特质,它们允许从类型到类型T的转换以及相反的转换。当这些特质用作函数参数的特质界限时,它们的使用变得尤为突出。我们将在稍后看到特质界限是什么以及它们是如何工作的。然而,当泛型特质声明了三个或四个泛型类型时,它们可能会变得相当冗长。对于这些情况,我们有关联类型特质。

关联类型特质

trait Foo {
    type Out;
    fn get_value(self) -> Self::Out;
}

由于它们能够在特质内声明关联类型,如前面代码中Foo声明中的Out类型,这些特质是泛型特质的更好替代品。它们具有更简洁的类型签名。它们的优点在于,在实现中,它们允许我们一次性声明关联类型,并在任何特质方法或函数中将Self::Out用作返回类型或参数类型。这消除了与泛型特质相同的情况下的类型冗余。关联类型特质的一个最佳例子是Iterator特质,它用于遍历自定义类型的值。其文档可以在doc.rust-lang.org/std/iter/trait.Iterator.html找到。当我们到达第八章高级主题时,我们将更深入地探讨迭代器。

继承的特质

我们已经在trait_inheritance.rs代码示例中看到了这些特质。与 Rust 中的类型不同,特质可以具有继承关系,例如:

trait Bar {
    fn bar();
}

trait Foo: Bar {
    fn foo();
}

在前面的代码片段中,我们声明了一个特质,名为Foo,它依赖于一个超特质,名为BarFoo的定义要求在为你的类型实现Foo时,必须实现Bar。标准库中的一个例子是Copy特质,它要求类型也实现Clone特质。

使用泛型特质 - 特质界限

现在我们对泛型和特质有了相当的了解,我们可以探索如何将它们结合起来,在编译时表达更多关于我们接口的信息。考虑以下代码:

// trait_bound_intro.rs

struct Game;
struct Enemy;
struct Hero;

impl Game {
    fn load<T>(&self, entity: T) {
        entity.init();
    }
}

fn main() {
    let game = Game;
    game.load(Enemy);
    game.load(Hero);
}

在前面的代码中,我们有一个泛型函数load,它位于我们的Game类型上,可以接受任何游戏实体并通过在所有类型的T上调用init()来将其加载到我们的游戏世界中。然而,这个例子由于以下错误而无法编译:

因此,一个接受任何类型T的泛型函数无法默认知道或假设T上存在init方法。如果它确实存在,那么它就根本不是泛型,而只能接受具有init()方法上的类型。所以,有一种方法可以让我们让编译器知道这一点,并使用 traits 约束load可以接受的一组类型。这就是 trait 边界发挥作用的地方。我们可以定义一个名为Loadable的 trait,并在我们的EnemyHero类型上实现它。随后,我们必须在我们的泛型类型声明旁边放置几个符号来指定 trait。我们称这为 trait 边界。代码的更改如下:

// trait_bounds_intro_fixed.rs

struct Game;
struct Enemy;
struct Hero;

trait Loadable {
    fn init(&self);
}

impl Loadable for Enemy {
    fn init(&self) {
        println!("Enemy loaded");
    }
}

impl Loadable for Hero {
    fn init(&self) {
        println!("Hero loaded");
    }
}

impl Game {
    fn load<T: Loadable>(&self, entity: T) {
        entity.init();
    }
}

fn main() {
    let game = Game;
    game.load(Enemy);
    game.load(Hero);
}

在这段新代码中,我们为EnemyHero都实现了Loadable,并且我们还按如下方式修改了load方法:

fn load<T: Loadable>(&self, entity: T) { .. }

注意: Loadable部分。这就是我们指定 trait 边界的做法。Trait 边界允许我们约束泛型 API 可以接受的参数范围。在泛型项上指定 trait 边界类似于我们为变量指定类型,但在这里变量是泛型类型T,而类型是某个 trait,例如T: SomeTrait。在定义泛型函数时,trait 边界几乎是必需的。如果定义了一个没有任何 trait 边界的泛型函数,那么我们不能调用任何方法,因为 Rust 不知道为给定方法使用哪种实现。它需要知道T是否有foo方法,以便对代码进行单态化。看看另一个例子:

// trait_bounds_basics.rs

fn add_thing<T>(fst: T, snd: T) {
    let _ = fst + snd;
}

fn main() {
    add_thing(2, 2);
}

我们有一个名为add_thing的方法,它可以添加任何类型T。如果我们编译前面的代码片段,它将无法编译并给出以下错误:

它表示在T上添加一个Add trait 边界。这样做的原因是加法操作由Add trait 决定,它是泛型的,不同类型有不同的实现,甚至可能返回完全不同的类型。这意味着 Rust 需要我们的帮助来为我们注解这一点。在这里,我们需要像这样修改我们的函数定义:

// trait_bound_basics_fixed.rs

use std::ops::Add;

fn add_thing<T: Add>(fst: T, snd: T) {
    let _ = fst + snd;
}

fn main() {
    add_thing(2, 2);
}

我们在T之后添加了: Add,随着这个更改,我们的代码可以编译了。现在,根据在定义带有 trait 边界的泛型项时类型签名变得有多复杂,有两种方式来指定 trait 和边界:

泛型之间的操作

fn show_me<T: Display>(val: T) {
    // can use {} format string now, because of Display bound
    println!("{}", val);
}

这是指定泛型项上 trait 边界的最常见语法。我们读取前面的函数如下:show_me是一个接受任何实现了Displaytrait 的类型的方法。这是在泛型函数的类型签名长度较小时声明 trait 边界的常用语法。此语法在指定类型上的 trait 边界时也适用。现在,让我们看看指定 trait 边界的第二种方式。

使用 where 子句:

当任何泛型项的类型签名太大而无法在一行中显示时,使用此语法。例如,标准库的 std::str 模块中有一个 parse 方法,其签名如下:

pub fn parse<F>(&self) -> Result<F, <F as FromStr>::Err>
where F: FromStr { ... }

注意到 where F: FromStr 部分。这告诉我们我们的 F 类型必须实现 FromStr 特性。where 子句将特征界限从函数签名中解耦,使其易于阅读。

看过如何编写特征界限后,了解我们可以在哪里指定这些界限是很重要的。特征界限适用于所有可以使用泛型的地方。

类型上的特征界限

我们也可以在类型上指定特征界限:

// trait_bounds_types.rs

use std::fmt::Display;

struct Foo<T: Display> {
    bar: T
}

// or

struct Bar<F> where F: Display {
    inner: F
}

fn main() {}

然而,对类型使用特征界限是不推荐的,因为它对类型本身施加了限制。通常,我们希望类型尽可能泛化,允许我们使用任何类型创建实例,并且通过在函数或方法中使用特征界限来限制它们的行为。

泛型函数和 impl 块上的特征界限

这是最常见的使用特征界限的地方。我们可以在函数上指定特征界限,也可以在泛型实现上指定,如下面的示例所示:

// trait_bounds_functions.rs

use std::fmt::Debug;

trait Eatable {
    fn eat(&self);
}

#[derive(Debug)]
struct Food<T>(T);

#[derive(Debug)]
struct Apple;

impl<T> Eatable for Food<T> where T: Debug {
    fn eat(&self) {
        println!("Eating {:?}", self);
    }
}

fn eat<T>(val: T) where T: Eatable {
    val.eat();
}

fn main() {
    let apple = Food(Apple);
    eat(apple);
}

我们有一个泛型类型 Food 和一个特定的食物类型 Apple,我们将它放入 Food 实例中,并将其绑定到变量 apple。接下来,我们调用泛型方法 eat,传递 apple。查看 eat 方法的签名,类型 T 必须是 Eatable。为了使 apple 可食用,我们为 Food 实现了 Eatable 特性,并指定我们的类型必须是 Debug 以使其可以在我们的方法中打印到控制台。这是一个愚蠢的例子,但演示了这一概念。

使用 + 符号组合特征界限

我们也可以使用 + 符号为泛型类型指定多个特征界限。让我们看看标准库中 HashMap 类型的 impl 块:

impl<K: Hash + Eq, V> HashMap<K, V, RandomState>

在这里,我们可以看到 K,表示 HashMap 键的类型,必须实现 Eq 特性,以及 Hash 特性。

我们还可以组合特性来创建一个新的特性,代表所有这些特性:

// traits_composition.rs

trait Eat {
    fn eat(&self) {
        println!("eat");
    }
}
trait Code {
    fn code(&self) {
        println!("code");
    }
}
trait Sleep {
    fn sleep(&self) {
        println!("sleep");
    }
}

trait Programmer : Eat + Code + Sleep {
    fn animate(&self) {
        self.eat();
        self.code();
        self.sleep();
        println!("repeat!");
    }
}

struct Bob;
impl Programmer for Bob {}
impl Eat for Bob {}
impl Code for Bob {}
impl Sleep for Bob {}

fn main() {
    Bob.animate();
}

在前面的代码中,我们创建了一个新的特性 Programmer,它是三个特性 EatCodeSleep 的组合。这样,我们对类型施加了约束,因此如果类型 T 实现 Programmer,它必须实现所有其他特性。运行代码产生以下输出:

eat
code
sleep
repeat!

使用 impl 特性语法的特征界限

声明特征界限的另一种语法是 impl 特性语法,这是编译器最近添加的一个特性。使用这种语法,您也可以编写具有特征界限的泛型函数,如下所示:

// impl_trait_syntax.rs

use std::fmt::Display;

fn show_me(val: impl Display) {
    println!("{}", val);
}

fn main() {
    show_me("Trait bounds are awesome");
}

我们不是指定T: Display,而是直接使用impl Display。这是impl特质语法。在需要返回复杂或无法表示的类型的情况下,例如从函数返回闭包,这提供了优势。如果没有这种语法,你必须通过使用Box智能指针类型将其放在指针后面来返回它,这涉及到堆分配。闭包在底层被实现为实现了多个特质的结构体。其中一个是Fn(T) -> U特质。因此,使用impl特质语法,现在我们可以编写函数,在其中我们可以写出如下内容:

// impl_trait_closure.rs

fn lazy_adder(a:u32, b: u32) -> impl Fn() -> u32 {
    move || a + b
}

fn main() {
    let add_later = lazy_adder(1024, 2048);
    println!("{:?}", add_later());
}

在前面的代码中,我们创建了一个函数,名为lazy_adder,它接受两个数字并返回一个闭包,该闭包可以将两个数字相加。然后我们调用lazy_adder,传入两个数字。这将在add_later中创建一个闭包,但不会立即执行它。在main中,我们在println!宏中调用了add_later。我们甚至可以在两个地方都使用这种语法,如下所示:

// impl_trait_both.rs

use std::fmt::Display;

fn surround_with_braces(val: impl Display) -> impl Display {
    format!("{{{}}}", val)
}

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

surround_with_braces接受任何实现了Display特质的类型,并返回一个被{}包围的字符串。在这里,我们的返回类型都是impl Display

多余的大括号是用来避免大括号本身的特殊含义,因为在字符串格式化中{}有特殊的字符串插值意义。

对于特质界限的impl语法,主要推荐用作函数的返回类型。在参数位置使用它意味着我们无法使用 turbofish 运算符。如果某些依赖代码使用 turbofish 运算符调用你的 crate 中的某个方法,这可能会导致 API 不兼容。它只应在没有具体类型可用的情况下使用,例如闭包的情况。

探索标准库特质

Rust 的标准库中有很多内置的特质。Rust 中的大部分语法糖都归因于特质。这些特质还为 crate 作者提供了一个很好的基线,他们可以在其库上提供一个惯用的接口。在本节中,我们将探讨标准库特质的一些抽象和便利之处,这些特质可以增强 crate 作者和消费者的体验。我们将从库作者的视角出发,创建一个提供复杂数字类型支持的库。这个例子很好地介绍了如果你创建自己的 crate 时必须实现的常见特质。

我们将通过运行cargo new complex --lib来创建一个新的项目。首先,我们需要将我们的复杂数字表示为一个类型。我们将使用一个结构体来完成这个任务。我们的复杂数字结构体有两个字段:复杂数字的实部虚部。以下是它的定义方式:

// complex/src/lib.rs

struct Complex<T> {
    // Real part
    re: T,
    // Complex part
    im: T
}

我们使其泛型化于T,因为reim都可以是浮点数或整数值。为了使这种类型有任何用途,我们希望有创建其实例的方法。通常的做法是实现关联方法new,其中我们传递reim的值。如果我们还想用默认值(比如re = 0im = 0)初始化一个复数值怎么办?为此,我们有一个名为Default的特性。对于用户定义的类型,实现Default非常简单;我们只需在Complex结构体上放置一个#[derive(Default)]属性,就可以自动为它实现Default特性。

注意:Default只能为那些其成员和字段本身实现Default的结构体、枚举或联合体实现。

现在,我们的更新代码带有new方法和Default注解,看起来是这样的:

// complex/src/lib.rs

#[derive(Default)]
struct Complex<T> {
    // Real part
    re: T,
    // Complex part
    im: T
}

impl<T> Complex<T> {
    fn new(re: T, im: T) -> Self {
        Complex { re, im }
    }
}

#[cfg(test)]
mod tests {
    use Complex;
    #[test]
    fn complex_basics() {
        let first = Complex::new(3,5);
        let second: Complex<i32> = Complex::default();
        assert_eq!(first.re, 3);
        assert_eq!(first.im, 5);
        assert!(second.re == second.im);
    }
}

我们还在tests模块底部添加了一个简单的初始化测试用例。#[derive(Default)]属性的功能是通过一个过程宏实现的,它可以自动为出现的类型实现特性。这种自动推导要求任何自定义类型的字段,如结构体或枚举,也必须自己实现Default特性。使用它们推导特性仅适用于结构体、枚举和联合体。我们将在第九章“使用宏进行元编程”中查看如何编写自己的推导过程宏。此外,函数new并不是一个特殊的构造函数(如果你熟悉具有构造函数的语言),而只是一个社区采用的常规名称,用作创建类型新实例的方法名。

现在,在我们深入研究更复杂的特性实现之前,我们需要自动推导一些更多的内置特性,这将帮助我们实现更高级的功能。让我们看看其中的一些:

  • Debug: 我们之前已经见过这个了。正如其名所示,这个特性帮助类型在控制台上以调试为目的进行打印。在复合类型的情况下,类型将以类似 JSON 的格式打印,包含花括号和括号,如果类型是字符串,则包含引号。这在 Rust 中的大多数内置类型中都已实现。

  • PartialEqEq:这些特性允许两个项目进行比较以确定它们是否相等。对于我们的复数类型,只有PartialEq是有意义的,因为当我们的复数类型包含f32f64值时,我们无法比较它们,因为Eq没有为f32f64值实现。PartialEq定义了部分排序,而Eq要求完全排序。对于浮点数,完全排序是未定义的,因为NaN不等于NaN。"NaN"是浮点数类型中的一个类型,它表示结果未定义的操作,例如0.0 / 0.0

  • CopyClone:这些特质定义了类型如何被复制。我们在第六章中有一个单独的部分来介绍它们,内存管理和安全性。简而言之,当在任何自定义类型上自动推导时,这些特质允许你从实例创建一个新的副本,无论是当Copy被实现时隐式地,还是当Clone被实现时通过调用clone()来显式地。请注意,Copy特质依赖于类型上实现了Clone

在这些解释之后,我们将为这些内置特质添加自动推导,如下所示:

#[derive(Default, Debug, PartialEq, Copy, Clone)]
struct Complex<T> {
    // Real part
    re: T,
    // Complex part
    im: T
}

接下来,让我们增强我们的Complex<T>类型,以便在使用方面有更好的用户体验。我们将实现的一些额外特质(不分先后)如下:

  • 来自std::ops模块的Add特质,它将使我们能够使用+运算符来添加Complex类型

  • 来自std::convert模块的IntoFrom特质,它将赋予我们从其他类型创建Complex类型的能力

  • Display特质将使我们能够打印出我们Complex类型的人类可读版本

让我们从Add特质的实现开始。它在doc.rust-lang.org/std/ops/trait.Add.html有文档说明,特质的声明如下:

pub trait Add<RHS = Self> { 
    type Output; 
    fn add(self, rhs: RHS) -> Self::Output; 
} 

让我们逐行分析它:

  • pub trait Add<RHS = Self>表示Add是一个具有泛型类型RHS的特质,默认设置为Self。在这里,Self是一个别名,用于在特质内部引用实现者,在我们的例子中是Complex。这是一个在特质内部引用实现者的方便方式。

  • Output是一个需要由实现者声明的关联类型。

  • fn add(self, rhs: RHS) -> Self::OutputAdd特质提供的核心功能,并且是每次我们在两个实现类型之间使用+运算符时调用的方法。它是一个实例方法,通过值传递self,并接受一个参数rhs,在特质定义中为RHS。在我们的例子中,+运算符两边的左右手边默认为同一类型,但当我们编写impl块时,RHS可以被更改为任何其他类型。例如,我们可以有一个实现,它将MeterCentimeter类型相加。在这种情况下,我们将在我们的impl块中写RHS=Centimeter。最后,它表示add方法必须返回我们在第二行用Self::Output语法声明的Output类型。

好的,让我们尝试实现它。以下是代码,以及相应的测试:

// complex/src/lib.rs

use std::ops::Add;

#[derive(Default, Debug, PartialEq, Copy, Clone)]
struct Complex<T> {
    // Real part
    re: T,
    // Complex part
    im: T
}

impl<T> Complex<T> {
    fn new(re: T, im: T) -> Self {
        Complex { re, im }
    }
}

impl<T: Add<T, Output=T>> Add for Complex<T> { 
    type Output = Complex<T>; 
    fn add(self, rhs: Complex<T>) -> Self::Output { 
        Complex { re: self.re + rhs.re, im: self.im + rhs.im } 
    } 
}

#[cfg(test)]
mod tests {
    use Complex;
    #[test]
    fn complex_basics() {
        let first = Complex::new(3,5);
        let second: Complex<i32> = Complex::default();
    }

    fn complex_addition() {
        let a = Complex::new(1,-2);
        let b = Complex::default();
        let res = a + b;
        assert_eq!(res, a);
    }
}

让我们深入到Complex<T>impl块:

impl<T: Add<T, Output=T> Add for Complex<T>

Addimpl块看起来更复杂。让我们逐个分析:

  • impl<T: Add<T, Output=T>>部分表示我们正在为泛型类型T实现Add,其中T实现了Add<T, Output=T><T, Output=T>部分表示Add特质的实现必须具有相同的输入和输出类型。

  • Add for Complex<T> 表示我们正在为 Complex<T> 类型实现 Add 特质。

  • T: Add 必须实现 Add 特质。如果不实现,我们就不能在它上面使用 + 操作符。

然后是 From 特质。如果我们能够从内置的原始类型(如包含实部和虚部的两个元素的元组)构建 Complex 类型,那将很方便。我们可以通过实现 From 特质来实现这一点。这个特质定义了一个 from 方法,为我们提供了在类型之间进行转换的通用方式。它的文档可以在 doc.rust-lang.org/std/convert/trait.From.html 找到。

下面是特质定义:

pub trait From<T> { 
    fn from(self) -> T;
} 

这比之前的要简单一些。它是一个泛型特质,其中 T 指定了要转换的类型。当我们实现它时,我们只需要用我们想要实现它的类型替换 T 并实现 from 方法。然后,我们就可以在我们的类型上使用这个方法。以下是一个将我们的 Complex 值转换为两个元素的元组类型的实现,这是 Rust 中原生已知的:

// complex/src/lib.rs

// previous code omitted for brevity

use std::convert::From;

impl<T> From<(T, T)> for Complex<T> { 
    fn from(value: (T, T)) -> Complex<T> { 
        Complex { re: value.0, im: value.1 }
    } 
}

// other impls omitted

#[cfg(test)]
mod tests {

    // other tests

     use Complex;
     #[test]
     fn complex_from() {
         let a = (2345, 456);
         let complex = Complex::from(a);
         assert_eq!(complex.re, 2345);
         assert_eq!(complex.im, 456);
     }
}

让我们看看这个的 impl 行。这与 Add 特质类似,但不同之处在于我们不需要通过任何特殊的输出类型来约束我们的泛型,因为 From 没有这个要求:

impl<T> From<(T, T)> for Complex<T> { 
    fn from(value: (T, T)) -> Complex<T> { 
        Complex { re: value.0, im: value.1 }
    } 
}

第一个 <T> 是泛型类型 T 的声明,第二个和第三个是它的使用。我们是从 (T, T) 类型创建它的。

最后,为了能让用户以数学符号的形式查看复杂类型,我们应该实现 Display 特质。它在 doc.rust-lang.org/std/fmt/trait.Display.html 中有文档说明,以下是特质的类型签名:

pub trait Display { 
    fn fmt(&self, &mut Formatter) -> Result<(), Error>; 
}

以下代码展示了 Complex<T> 类型的 Display 实现:

// complex/src/lib.rs

// previous code omitted for brevity

use std::fmt::{Formatter, Display, Result};

impl<T: Display> Display for Complex<T> {
    fn fmt(&self, f: &mut Formatter) -> Result { 
        write!(f, "{} + {}i", self.re, self.im)
    } 
} 

#[cfg(test)]
mod tests {

    // other tests

    use Complex;
    #[test]
    fn complex_display() {
        let my_imaginary = Complex::new(2345,456);
        println!("{}", my_imaginary);
    }
}

Display 特质有一个 fmt 方法,它接受一个 Formatter 类型,我们使用 write! 宏将其写入。像之前一样,因为我们的 Complex<T> 类型同时使用泛型类型 reim 字段,我们需要指定它也必须满足 Display 特质。

运行 cargo test -- --nocapture,我们得到以下输出:

我们可以看到我们的复杂数型以可读的格式 2345 + 456i 打印出来,并且所有的测试都是绿色的。接下来,让我们看看多态的概念以及 Rust 特质如何建模这一点。

使用特质对象实现真正的多态

Rust 通过特殊形式的类型实现特质,允许一种真正的多态形式。这些被称为 特质对象。在我们解释 Rust 如何使用特质对象实现多态之前,我们需要理解 分发 的概念。

分发

分发是从面向对象编程范式中的一个概念出现的,主要是在其一个称为多态的特征的上下文中。在 OOP 的上下文中,当 API 是泛型或接受实现接口的参数时,它必须确定在传递给 API 的类型实例上调用哪个方法实现。在多态上下文中进行的方法解析过程称为分发,调用方法称为分发。在支持多态的主流语言中,分发可能以下列两种方式之一发生:

  • 静态分发:当要调用的方法在编译时确定时,它被称为静态分发或早期绑定。使用方法的签名来决定要调用的方法,所有这些都是在编译时决定的。在 Rust 中,泛型表现出这种分发形式,因为尽管泛型函数可以接受许多参数,但在编译时会产生一个具有该具体类型的函数的专用副本。

  • 动态分发:在面向对象的语言中,有时方法调用只能在运行时决定。这是因为具体类型是隐藏的,只有接口方法可以调用。在 Java 中,当一个函数有一个参数时,这种情况就出现了,这个参数被称为接口。这种场景只能通过动态分发来处理。在动态分发中,方法通过遍历接口的实现列表从vtable中动态确定,并调用该方法。vtable是一个指向每个类型实现方法的函数指针列表。这因为方法调用中额外的指针间接引用而有一些开销。

让我们接下来探索特性对象。

特性对象

现在,直到这一点,我们主要看到特性被用于静态分发上下文中,我们在泛型 API 中指定了特性界限。然而,我们还有另一种创建多态 API 的方法,其中我们可以指定参数为实现了特性而不是泛型或具体类型的东西。这种类型,指定为实现了特性 API,被称为特性对象。特性对象类似于 C++的虚方法。特性对象实现为一个胖指针,是一个无大小类型,这意味着它们只能用于引用(&)之后。我们在第七章,高级概念中解释了无大小类型。特性对象的胖指针的第一个指针指向与对象关联的实际数据,而第二个指针指向一个虚表(vtable),这是一个结构,为对象的每个方法持有固定偏移量处的函数指针。

特性对象是 Rust 执行动态分派的方式,其中我们没有实际的实体类型信息。方法解析是通过跳转到 vtable 并调用适当的方法来完成的。特性对象的一个用例是,它们允许你在可以具有多个类型的集合上操作,但在运行时有一个额外的指针间接引用。为了说明这一点,考虑以下程序:

// trait_objects.rs

use std::fmt::Debug;

#[derive(Debug)]
struct Square(f32);
#[derive(Debug)]
struct Rectangle(f32, f32);

trait Area: Debug {
    fn get_area(&self) -> f32; 
}

impl Area for Square {
    fn get_area(&self) -> f32 {
        self.0 * self.0
    }
}

impl Area for Rectangle {
    fn get_area(&self) -> f32 {
        self.0 * self.1
    }
}

fn main() {
    let shapes: Vec<&dyn Area> = vec![&Square(3f32), &Rectangle(4f32, 2f32)];
    for s in shapes {
        println!("{:?}", s);
    }
}

如您所见,形状的元素类型为 &dyn Area,这是一种表示为特性的类型。特性对象通过 dyn Area 表示,表示它是指向 Area 特性某个实现的指针。以特性对象形式存在的类型允许你在集合类型(如 Vec)中存储不同的类型。在前面的例子中,SquareRectangle 被隐式转换为特性对象,因为我们向它们推送了引用。我们还可以通过手动转换来使类型成为特性对象。这是一个高级案例,并且仅在编译器无法自行将类型转换为特性对象时使用。请注意,我们只能创建在编译时已知大小的类型的特性对象。dyn Trait 是一个无大小类型,只能作为引用创建。我们还可以通过将它们放在其他指针类型(如 BoxRcArc 等)之后来创建特性对象。

在较旧的 Rust 2015 版本中,特性对象仅被称为特性的名称,对于一个特性对象 dyn Foo,它被表示为 Foo。这种语法很令人困惑,并且在最新的 2018 版本中已被弃用。

在以下代码中,我们展示了如何将 dyn Trait 作为函数的参数使用:

// dyn_trait.rs

use std::fmt::Display;

fn show_me(item: &dyn Display) {
    println!("{}", item);
}

fn main() {
    show_me(&"Hello trait object");
}

特性,连同泛型一起,提供了两种代码复用方式,要么通过单态化(早期绑定)要么通过运行时多态(晚期绑定)。何时使用哪种方式取决于上下文和所讨论的应用需求。通常,错误类型会被倾向于动态分派,因为它们被认为是很少被执行的代码路径。单态化对于小型用例来说可能很有用,但它的缺点是引入了代码膨胀和重复,这影响了缓存行并增加了二进制文件大小。然而,在这两种选项中,除非有对二进制文件大小的硬性约束,否则静态分派应该是首选的。

摘要

类型是任何静态类型语言最美丽的方面之一。它们允许你在编译时表达很多内容。本章可能不是本书中最先进的章节,但内容可能是最重的。我们现在对代码的不同复用方式有了实际了解。我们还了解了强大的特性以及 Rust 标准库如何大量使用它们。

在下一章中,我们将学习程序如何使用内存以及 Rust 如何提供编译时内存管理。

第五章:内存管理和安全

对于任何使用底层编程语言的人来说,理解内存管理是一个基本概念。底层语言没有内置的垃圾回收器等自动内存回收解决方案,管理程序使用的内存是程序员的职责。了解程序中内存的使用位置和方式,使程序员能够构建高效且安全的软件系统。许多底层软件中的错误都是由于不正确地处理内存造成的。有时,这是程序员的错误。其他时候,这是所使用的编程语言的副作用,例如 C 和 C++,它们因软件中的许多内存漏洞报告而臭名昭著。Rust 为内存管理提供了一个更好的编译时解决方案。除非你明确打算这样做,否则它很难编写会泄漏内存的软件!使用 Rust 进行了一定程度开发的程序员最终会意识到,它不鼓励不良编程实践,并指导程序员编写使用内存安全且高效的软件。

在本章中,我们将深入了解 Rust 如何驯服程序中资源使用的内存的细节。我们将简要介绍进程、内存分配、内存管理和我们所说的内存安全。然后,我们将了解 Rust 提供的内存安全模型,并理解使其能够在编译时跟踪内存使用量的概念。我们将看到如何使用特性来控制类型在内存中的位置以及它们何时被释放。我们还将深入研究各种智能指针类型,它们为管理程序中的资源提供了抽象。

本章涵盖的主题如下:

  • 程序和内存

  • 内存分配和安全

  • 内存管理

  • 栈和堆

  • 安全三合一——所有权、借用和生命周期

  • 智能指针类型

程序和内存

"如果你愿意限制你方法的灵活性,你几乎总能做得更好。"

约翰·卡马克

为了理解内存及其管理,我们需要对操作系统如何运行程序以及允许它为需求使用内存的机制有一个大致的了解。

每个程序都需要内存来运行,无论是你喜欢的命令行工具还是复杂的流处理服务,它们的内存需求差异很大。在主要的操作系统实现中,正在执行的程序被实现为一个进程。进程是程序的运行实例。当我们 Linux 中的 shell 中执行./my_program或在 Windows 上双击my_program.exe时,操作系统将my_program作为进程加载到内存中并开始执行,与其他进程一起,给它分配 CPU 和内存的一部分。它为进程分配其自己的虚拟地址空间,这个地址空间与其他进程的虚拟地址空间不同,并且具有自己的内存视图。

在进程的生命周期中,它使用了大量的系统资源。首先,它需要内存来存储自己的指令,然后它需要空间来存储在指令执行期间运行时请求的资源,然后它需要一个方法来跟踪函数调用、任何局部变量以及返回到上一个调用函数的地址。其中一些内存需求可以在编译时提前决定,例如在变量中存储原始类型,而其他一些只能在运行时满足,例如创建动态数据类型如Vec<String>。由于内存需求的各个层级,以及出于安全考虑,进程对内存的视图被划分为称为内存布局的区域。

这里,我们有一个进程内存布局的大致表示:

这个布局根据它们存储的数据类型和提供的功能被划分为不同的区域。我们关注的几个主要部分如下:

  • 文本段:这部分包含要执行的实际代码,在编译的二进制文件中。文本段是一个只读段,任何用户代码都禁止修改它。这样做可能会导致程序崩溃。

  • 数据段:这部分进一步划分为子段,即初始化数据段和未初始化数据段,这在历史上被称为块起始符号(BSS),它包含程序中声明的所有全局和静态值。未初始化的值在加载到内存时被初始化为零。

  • 栈段:这个段用于存储任何局部变量和函数的返回地址。所有大小已知且程序创建的任何临时/中间变量都隐式地存储在栈上。

  • 堆段:这个段用于存储任何在运行时大小未知且可以变化的动态分配的数据。当我们希望值在函数声明之外持续存在时,这是理想的分配位置。

程序如何使用内存?

因此,我们知道一个进程有一个专门用于其执行的内存块。但是,它是如何访问这个内存来执行其任务的呢?出于安全和故障隔离的目的,进程不允许直接访问物理内存。相反,它使用虚拟内存,该内存由操作系统通过一个称为 的内存数据结构映射到实际的物理内存,这些 被维护在 页表 中。进程必须从操作系统请求内存以供其使用,它得到的是一个虚拟地址,该地址在内部映射到 RAM 中的物理地址。出于性能考虑,内存是以块的形式请求和处理的。当进程访问虚拟内存时,内存管理单元执行从虚拟到物理内存的实际转换。

进程从操作系统获取内存的整个过程被称为 内存分配。进程通过使用 系统调用 从操作系统请求一块内存,操作系统标记该内存块为该进程使用。当进程完成对内存的使用后,它必须将内存标记为空闲,以便其他进程可以使用。这被称为内存的 释放。主要的操作系统实现通过系统调用(如 Linux 中的 brksbrk)提供抽象,这些是直接与操作系统内核通信的函数,可以分配进程请求的内存。但这些内核级函数非常低级,因此它们被系统库(如 glibc 库)进一步抽象,该库是 Linux 中的 C 语言标准库,包括 POSIX API 的实现,它简化了从 C 语言进行低级操作系统交互。

POSIX 是 Portable Operating System Interface 的缩写,这是一个由理查德·斯托尔曼提出的术语。它是一组随着标准化 Unix-like 操作系统应提供哪些功能、它们应向 C 等语言暴露哪些低级 API、它们应包含哪些命令行工具以及许多其他方面的需求而出现的标准。

Glibc 还提供了一组内存分配器 API,暴露了如 malloccallocrealloc 等用于分配内存的函数,以及 free 函数用于释放内存。尽管我们有一个相当高级的内存分配/释放 API,但在使用低级编程语言时,我们仍然需要自己管理内存。

内存管理及其种类

您电脑中的 RAM 是一种有限的资源,并且被所有正在运行的应用程序共享。当程序完成执行其指令后,它应该释放任何使用的内存,以便操作系统可以回收并分配给其他进程。当我们谈论内存管理时,我们关注的突出方面之一是已使用内存的回收以及它是如何发生的。不同语言在释放已使用内存时所需的管理级别不同。直到 1990 年代中期,大多数编程语言都依赖于手动内存管理,这要求程序员在代码中调用内存分配器 API,如mallocfree来分别分配和释放内存。大约在 1959 年,约翰·麦卡锡Lisp的创造者,发明了垃圾回收器GC),这是一种自动内存管理形式,而 Lisp 是第一个使用这种技术的语言。GC 作为一个守护线程作为运行程序的一部分运行,并分析程序中不再被任何变量引用的内存,并在程序执行的一定时间点自动释放它。

然而,低级语言没有内置 GC,因为它引入了非确定性以及由于 GC 线程在后台运行而产生的运行时开销,这有时会暂停程序的执行。这种暂停有时会达到毫秒级的延迟。这可能会违反系统软件的严格时间和空间限制。低级语言将程序员置于手动管理内存的控制之下。然而,像 C++和 Rust 这样的语言通过类型系统抽象,如智能指针,从程序员那里分担了一些负担,我们将在本章后面讨论。

考虑到语言之间的差异,我们可以将这些语言使用的内存管理策略分为三个类别:

  • 手动C具有这种形式的内存管理,其中程序员完全负责在代码使用内存后调用free。C++通过智能指针在一定程度上自动化了这一点,其中free调用被放置在类的析构函数方法定义中。Rust 也有智能指针,我们将在本章后面讨论。

  • 自动:具有这种形式内存管理的语言包括一个额外的运行时线程,即垃圾回收器,它作为守护线程与程序并行运行。大多数基于虚拟机的动态语言,如 Python、Java、C#和 Ruby,都依赖于自动内存管理。自动内存管理是这些语言编写代码容易的一个原因。

  • 半自动:像 Swift 这样的语言属于这一类别。它们作为运行时的一部分没有内置专门的 GC,但提供了一种引用计数类型,可以在细粒度级别自动管理内存。Rust 也提供了引用计数类型Rc<T>Arc<T>。我们将在本章后面解释智能指针时详细介绍它们。

内存分配方法

在运行时,一个进程中的内存分配要么发生在上,要么发生在上。它们是程序执行期间用于存储值的存储位置。在本节中,我们将探讨这两种分配方法。

栈用于存储生命周期短暂的值,其大小在编译时已知,是函数调用及其相关上下文的理想存储位置,一旦函数返回,这些上下文就需要消失。堆用于任何需要在函数调用之外持续存在的对象。如第一章“入门指南”中所述,Rust 默认偏好栈分配。你创建并绑定到变量的任何值或类型的实例默认存储在栈上。在堆上存储是显式的,并且通过使用智能指针类型来完成,这些类型将在本章后面进行解释。

每当我们调用一个函数或方法时,栈被用来为在函数内部创建的值分配空间。你函数中的所有let绑定都存储在栈上,要么作为值本身,要么作为指向堆上内存位置的指针。这些值构成了活动函数的栈帧。栈帧是栈中存储函数调用上下文的逻辑内存块。这个上下文可能包括函数参数、局部变量、返回地址以及需要在函数返回后恢复的任何已保存寄存器的值。随着越来越多的函数被调用,它们对应的栈帧被推入栈中。一旦函数返回,与该函数对应的栈帧就会消失,以及在该帧中声明的所有值。

这些值按照它们声明的相反顺序被移除,遵循后进先出LIFO)的顺序。

在栈上进行分配很快,因为在这里分配和释放内存只需要一个 CPU 指令:增加/减少栈帧指针。栈帧指针(esp)是一个 CPU 寄存器,它始终指向栈顶。随着函数的调用或返回,栈帧指针会不断更新。当函数返回时,通过将栈帧指针恢复到进入函数之前的位置,其栈帧被丢弃。使用栈是一种临时内存分配策略,但由于其简单性,它在释放已用内存方面是可靠的。然而,栈的同一属性使其不适合需要超出当前栈帧的持久值的场景。

下面是一段代码,大致展示了在程序中函数调用期间栈是如何更新的:

// stack_basics.rs

fn double_of(b: i32) -> i32 {
    let x = 2 * b;
    x
}

fn main() {
    let a = 12;
    let result = double_of(a);
}

我们将使用一个空数组[]来表示这个程序的栈状态。让我们通过对这个程序进行一次模拟运行来探索栈的内容。我们还将使用[]来表示父栈中的栈帧。当程序运行时,以下步骤将按顺序发生:

  1. main函数被调用时,它创建了一个栈帧,其中包含aresult(初始化为零)。此时栈的内容为[[a=12, result=0]]

  2. 接下来,调用double_of函数,并在栈上推入一个新的栈帧以保存其局部值。此时栈的内容为[[a=12, result=0], [b=12, temp_double=2*x, x=0]]temp_double是一个由编译器创建的临时变量,用于存储2 * x的结果,然后将其分配给在double_of函数中声明的变量x。然后这个x被返回给调用者,即我们的main函数。

  3. 一旦double_of函数返回,它的栈帧将从栈中弹出,此时栈的内容变为[[a=12, result=24]]

  4. 随后,main函数结束,其栈帧被弹出,栈变为空:[]

然而,这里还有更多细节。我们只是提供了一个关于函数调用及其与栈内存交互的非常高级的概述。现在,如果我们只有局部值,它们只在函数调用期间有效,这将非常有限。虽然栈简单且强大,但要实用,程序还需要更持久的变量,而这正是堆的作用。

堆栈

堆用于更复杂和动态的内存分配需求。程序可能在某个时刻在堆上分配内存,也可能在另一个时刻释放它,这些点之间不需要有严格的界限,就像栈内存那样。在栈分配的情况下,你将得到值的确定性的分配和释放。此外,堆中的值可能在其分配的函数之外存活,并且可能稍后被其他函数释放。在这种情况下,代码未能调用free,因此它可能根本不会被释放,这是最坏的情况。

不同的语言以不同的方式使用堆内存。在动态语言如 Python*中,一切都是对象,并且默认情况下它们在堆上分配。在 C 中,我们使用手动malloc调用在堆上分配内存,而在 C++中,我们使用new关键字进行分配。为了释放内存,我们需要在 C 中调用free,在 C++中调用delete。为了避免手动delete调用,程序员通常使用unique_ptrshared_ptr等智能指针类型。这些智能指针类型有析构函数,当它们在内部超出作用域时会被调用,并执行delete。这种管理内存的范式称为 RAII 原则,并由 C++普及。

RAII 代表资源获取即初始化;一种建议资源必须在对象的初始化期间获取,并在它们被释放或调用析构函数时释放的范式。

Rust 也有类似于 C++管理堆内存的抽象。在这里,在堆上分配内存的唯一方式是通过智能指针类型。Rust 中的智能指针类型实现了Drop特质,它指定了值使用的内存应该如何被释放,并且在语义上与 C++中的析构函数类似。除非有人编写了自己的自定义智能指针类型,否则您永远不需要在它们的类型上实现Drop。关于Drop特质的更多内容将在单独的部分中介绍。

为了在堆上分配内存,语言依赖于专门的内存分配器,这些分配器隐藏了所有底层细节,如在对齐内存上分配内存、维护空闲内存块以减少系统调用开销、以及在分配内存和其他优化时减少碎片化。对于编译程序,编译器 rustc 本身使用 jemalloc 分配器,而由 Rust 构建的库和二进制文件使用系统分配器。在 Linux 上,将是 glibc 内存分配器 API。jemalloc 是一个用于多线程环境的有效分配器库,它大大减少了 Rust 程序的构建时间。虽然编译器使用了 jemalloc,但它不会被用 Rust 构建的应用程序使用,因为它会增加二进制文件的大小。因此,编译的二进制文件和库默认总是使用系统分配器。

Rust 还有一个可插拔的分配器设计,可以使用系统分配器或任何实现了std::alloc模块中的GlobalAlloc特质的用户实现分配器。这通常通过#[global_allocator]属性实现,可以将它放在任何类型上以声明它为一个分配器。

注意:如果您有一个用例希望在自己的程序中使用 jemalloc crate,您可以使用crates.io/crates/jemallocator crate。

在 Rust 中,大多数大小事先未知的动态类型都在堆上分配。这排除了原始类型。例如,创建String内部会在堆上分配:

let s = String::new("foo");

String::new在堆上分配一个Vec<u8>并返回对其的引用。这个引用绑定到变量s上,该变量在栈上分配。堆中的字符串在s的作用域内存在。当s超出作用域时,Vec<u8>从堆上解除分配,并且其drop方法作为Drop实现的一部分被调用。对于需要将原始类型分配到堆上的罕见情况,你可以使用Box<T>类型,它是一种泛型智能指针类型。

在下一节中,让我们看看使用像 C 这样的没有自动内存管理舒适性的语言时的陷阱。

内存管理陷阱

在具有 GC(垃圾回收)的语言中,处理内存的问题从程序员那里抽象出来。你在代码中声明和使用变量,它们如何被解除分配是实现的细节,你不必担心。另一方面,像 C/C++这样的低级系统编程语言并没有从程序员那里隐藏这些细节,并且提供了几乎没有任何安全性。在这里,程序员被赋予了通过手动释放调用解除内存的责任。现在,如果我们看看与内存管理相关的软件中的大多数通用漏洞和暴露CVEs),它表明我们人类在这方面并不擅长!程序员可以通过错误地分配和解除分配值来创建难以调试的错误,甚至可能忘记解除分配已使用的内存,或者非法地取消引用指针。在 C 中,没有任何东西阻止你从一个整数创建一个指针并在某个地方取消引用它,结果只是程序稍后崩溃。此外,由于编译器的检查最少,很容易在 C 中创建漏洞。

最令人担忧的情况是释放堆分配的数据。堆内存需要谨慎使用。如果未释放,堆中的值在程序的生命周期内可能会永远存在,并最终可能导致内核中的“内存不足”(OOM)杀手终止程序。在运行时,代码中的错误或开发者的错误也可能导致程序忘记释放内存,或者访问超出其内存布局范围的内存部分,或者在受保护代码段中取消引用内存地址。当这种情况发生时,进程会从内核接收到陷阱指令,这就是你看到的“段错误”错误消息,随后进程被终止。因此,我们必须确保进程及其与内存的交互需要是安全的!要么我们作为程序员需要对我们自己的mallocfree调用保持批判性的警觉,要么使用内存安全的语言来为我们处理这些细节。

内存安全

但我们所说的程序内存安全是什么意思呢?内存安全是这样一个概念:您的程序永远不会触及它不应该触及的内存位置,并且您的程序中声明的变量不能指向无效的内存,并且在所有代码路径中保持有效。换句话说,安全性基本上归结为在您的程序中指针始终具有有效的引用,并且指针操作不会导致未定义行为。未定义行为是程序进入了一种编译器没有考虑到的情况,因为编译器规范没有明确说明这种情况会发生什么。

C 中未定义行为的一个例子是访问越界和未初始化的数组元素:

// uninitialized_reads.c

#include <stdio.h>
int main() { 
    int values[5]; 
    for (int i = 0; i < 5; i++) 
        printf("%d ", values[i]); 
}

在前述代码中,我们有一个包含 5 个元素的数组,并循环打印数组中的值。使用 gcc -o main uninitialized_reads.c && ./main 运行此程序会得到以下输出:

4195840 0 4195488 0 609963056

在您的机器上,这可能会打印任何值,甚至可能打印一个指令的地址,这可以被利用。这是一个未定义行为,其中可能发生任何事情。您的程序可能会立即崩溃,这是最好的情况,因为您当时就能知道它。它也可能继续工作,破坏程序可能后来会给出错误输出的任何内部状态。

C++ 中内存安全违规的另一个例子是迭代器失效问题:

// iterator_invalidation.cpp

#include <iostream>
#include <vector>

int main() {   
    std::vector <int> v{1, 5, 10, 15, 20}; 
    for (auto it=v.begin();it!=v.end();it++) 
        if ((*it) == 5) 
            v.push_back(-1); 

    for (auto it=v.begin();it!=v.end();it++) 
        std::cout << (*it) << " "; 

    return 0;     
}

在这段 C++ 代码中,我们创建了一个整数向量 v,并尝试在 for 循环中使用一个名为 it 的迭代器进行迭代。前述代码的问题在于,我们有一个指向 vit 迭代器指针,同时我们在迭代并推入 v

现在,由于向量的实现方式,当它们的容量达到其容量时,它们会在内存中重新分配到其他位置。当这种情况发生时,这将使 it 指针指向某个垃圾值,这被称为迭代器失效问题,因为指针现在指向了无效的内存。

C 中内存不安全的另一个例子是缓冲区溢出。以下是一个简单的代码片段来展示这个概念:

// buffer_overflow.c

int main() { 
     char buf[3]; 
     buf[0] = 'a'; 
     buf[1] = 'b'; 
     buf[2] = 'c'; 
     buf[3] = 'd'; 
}

这段代码可以正常编译,甚至在没有错误的情况下运行,但最后的赋值操作超出了分配的缓冲区,可能覆盖了地址中的其他数据或指令。此外,专门定制的恶意输入值,适应于架构和环境,可能导致任意代码执行。这类错误在实际代码中以不那么明显的方式发生,导致了影响全球企业的漏洞。在最近的 gcc 编译器版本中,这被检测为堆栈破坏攻击,gcc 通过发送 SIGABRT(中止)信号来停止程序。

内存安全漏洞会导致内存泄漏、以段错误形式出现的硬崩溃,或者在最坏的情况下,安全漏洞。为了在 C 语言中创建正确且安全的程序,程序员必须谨慎地放置free调用,以确保在完成内存使用后正确释放内存。现代 C++通过提供智能指针类型来防止与手动内存管理相关的一些问题,但这并不能完全消除这些问题。基于虚拟机的语言(Java 的 JVM 是最突出的例子)使用垃圾回收来消除整个类别的内存安全问题。虽然 Rust 没有内置的 GC,但它依赖于语言内建的 RAII(Resource Acquisition Is Initialization)机制,根据变量的作用域自动释放使用过的内存,这使得它比 C 或 C++更安全。它为我们提供了多种细粒度的抽象,我们可以根据需要选择,并且只为我们使用的部分付费。要了解 Rust 中这一切是如何工作的,让我们探索帮助 Rust 在编译时为程序员提供内存管理的原则。

内存安全的三角

我们接下来要探讨的概念是 Rust 内存安全的核心原则及其零成本抽象原则。它们使 Rust 能够在编译时检测程序中的内存安全违规,在资源作用域结束时自动释放资源,以及更多。我们把这些概念称为所有权、借用和生命周期。所有权有点像核心原则,而借用和生命周期是语言类型系统的扩展,在不同的代码上下文中强制执行和有时放宽所有权原则,以确保编译时内存管理。让我们详细阐述这些想法。

所有权

程序中资源真正所有者的概念在不同的语言中有所不同。在这里,我们集体指代任何在堆或栈上持有值的变量,或者持有打开的文件描述符、数据库连接套接字、网络套接字等类似事物的变量。从它们存在到程序完成使用,它们都占用一些内存。作为资源所有者的重要责任之一是,要明智地释放它们使用的内存,因为不能在适当的位置和时间执行清理操作可能会导致内存泄漏。

当在 Python 等动态语言中编程时,对于list对象可以有多个所有者或别名,你可以使用指向该对象的许多变量之一向列表中添加或删除项目。变量不需要关心释放对象使用的内存,因为 GC 会处理这个问题,一旦所有对对象的引用都消失了,它就会释放内存。

对于像 C/C++这样的编译型语言,在智能指针出现之前,库对 API 的调用者或被调用者负责在代码完成后释放资源持有不同的观点。这些观点存在是因为在这些语言中,编译器不强制执行所有权。在 C++中,仍然有可能因为不使用智能指针而出错。在 C++中,有多个变量指向堆上的值是完全正常的(尽管我们建议不要这样做),这被称为别名。程序员会遇到各种不良影响,因为拥有多个指向资源的指针或别名的灵活性,其中一个就是 C++中的迭代器失效问题,我们之前已经解释过。具体来说,当给定作用域中至少有一个可变别名指向资源,而其他别名是不可变时,就会产生问题。

相反,Rust 试图在程序中引入关于值所有权的适当语义。Rust 的所有权规则声明以下原则:

  • 当你使用let语句创建一个值或资源并将其分配给一个变量时,该变量成为资源的所有者

  • 当值从一个变量重新分配到另一个变量时,值的所有权转移到另一个变量,而旧的变量将无法进一步使用

  • 值和变量在其作用域结束时会被释放

要点在于 Rust 中的值只有一个所有者,即创建它们的变量。这个原则很简单,但其影响却让来自其他语言的程序员感到惊讶。考虑以下代码,它以最基本的形式展示了所有权原则:

// ownership_basics.rs

#[derive(Debug)]
struct Foo(u32);

fn main() {
    let foo = Foo(2048);
    let bar = foo;
    println!("Foo is {:?}", foo);
    println!("Bar is {:?}", bar);
}

我们创建了两个变量,foobar,它们指向一个Foo实例。对于熟悉主流命令式语言且允许值有多个所有者的程序员来说,我们预期这个程序可以顺利编译。但在 Rust 中,我们在编译时遇到了以下错误:

图片

在这里,我们创建了一个 Foo 实例并将其分配给 foo 变量。根据所有权规则,foo 现在是 Foo 实例的所有者。在下一行,我们将 foo 分配给 bar。在 main 中的第二行执行时,bar 成为 Foo 实例的新所有者,而旧的 foo 现在是一个废弃的变量,在移动之后任何地方都不能使用。这可以从第三行的 println! 调用中看出。Rust 默认情况下,每次我们将变量分配给其他变量或从变量中读取时,都会移动变量指向的值。所有权规则防止你拥有多个修改值的访问点,这可能导致在允许对值有多个可变别名的单线程上下文中出现使用后释放的情况。一个经典的例子是 C++ 中的迭代器失效问题。现在,为了分析值何时超出作用域,所有权规则也考虑了变量的作用域。让我们接下来了解作用域。

作用域简要介绍

在我们进一步探讨所有权之前,我们需要简要了解作用域,如果你了解 C 语言,这可能会让你感到熟悉,但我们将在这里以 Rust 的上下文回顾它,因为所有权与作用域协同工作。所以,作用域不过是一个变量和值存在的环境。你声明的每个变量都与一个作用域相关联。作用域在代码中由花括号 {} 表示。每当使用 块表达式 时,就会创建一个作用域,即任何以花括号 {} 开始和结束的表达式。此外,作用域可以嵌套在彼此内部,并且可以访问父作用域中的项目,但不能反过来。

这里有一些代码示例,展示了多个作用域和值:

// scopes.rs

fn main() { 
    let level_0_str = String::from("foo"); 
    {  
        let level_1_number = 9; 
        { 
            let mut level_2_vector = vec![1, 2, 3];
            level_2_vector.push(level_1_number);    // can access
        } // level_2_vector goes out of scope here 

        level_2_vector.push(4);    // no longer exists
    } // level_1_number goes out of scope here
} // level_0_str goes out of scope here

为了帮助解释这一点,我们将假设我们的作用域是编号的,从 0 开始。基于这个假设,我们创建了具有 level_x 前缀的变量名。让我们逐行运行前面的代码。由于函数可以创建新的作用域,main 函数引入了一个根作用域级别 0,其中定义了 level_0_str。在级别 0 作用域内部,我们创建了一个新的作用域,即级别 1,它包含一个裸块 {},其中包含变量 level_1_number。在级别 1 内部,我们创建另一个块表达式,这成为级别 2 作用域。在级别 2 中,我们声明另一个变量 level_2_vector,我们将 level_1_number 推送到它,这来自父作用域,即级别 1。最后,当代码到达 } 的末尾时,所有值都被销毁,相应的作用域也随之结束。一旦作用域结束,我们就不能使用其中定义的任何值。

在推理所有权规则时,作用域是一个需要记住的重要属性。它们也被用来推理借用和生命周期,正如我们稍后将会看到的。当一个作用域结束时,任何拥有值的变量都会运行代码来释放该值,并且它本身在作用域外使用时也变得无效。特别是对于堆分配的值,在作用域结束的 } 之前放置一个 drop 方法。这类似于在 C 中调用 free 函数,但在这里它是隐式的,可以节省程序员忘记释放值的麻烦。drop 方法来自 Drop 特性,它在 Rust 中为大多数堆分配类型实现,使得自动释放资源变得轻而易举。

学习了作用域的概念后,让我们来看一个与之前在 ownership_basics.rs 中看到的例子类似的例子,但这次,我们将使用一个原始值:

// ownership_primitives.rs

fn main() {
    let foo = 4623;
    let bar = foo;
    println!("{:?} {:?}", foo, bar); 
}

尝试编译并运行这个程序。你可能会有惊喜,因为这个程序编译和运行得很好。这是怎么回事?在程序中,4623 的所有权并没有从 foo 移动到 bar,而是 bar 获得了 4623 的一个单独的副本。这看起来像是 Rust 中对原始类型进行了特殊处理,它们被复制而不是移动。这意味着在 Rust 中,根据我们使用的类型,所有权的语义是不同的,这引出了移动和复制语义的概念。

移动和复制语义

在 Rust 中,变量绑定默认具有移动语义。但这是什么意思呢?为了理解这一点,我们需要思考变量在程序中的使用方式。我们创建值或资源并将它们分配给变量,以便在程序中稍后轻松引用它们。这些变量是指向值所在内存位置的指针。现在,对变量的操作,如读取、赋值、加法、将它们传递给函数等,可以具有不同的语义或意义,这涉及到变量所指向的值是如何访问的。在静态类型语言中,这些语义被广泛分类为移动语义和复制语义。让我们定义两者。

移动语义:当一个值通过变量访问或重新赋值给变量时被移动到接收项,则该值表现出移动语义。由于 Rust 的 affine 类型系统,Rust 默认具有移动语义。affine 类型系统的一个突出特点是值或资源只能使用一次,Rust 通过所有权规则展示了这一特性。

复制语义:当一个值通过变量赋值或访问时默认进行位复制(例如,在通过变量赋值或访问时),则该值表现出复制语义。这意味着该值可以被使用任意次数,并且每个值都是全新的。

这些语义对 C++ 社区的成员来说很熟悉。C++ 默认具有复制语义。移动语义是在 C++11 版本中后来添加的。

Rust 中的移动语义有时可能会有限制。幸运的是,通过实现Copy特性,可以改变类型的行为以遵循复制语义。这默认适用于原始类型和其他栈上数据类型,这就是为什么之前使用原始类型的代码能够正常工作。考虑以下尝试显式使类型Copy的代码片段:

// making_copy_types.rs

#[derive(Copy, Debug)]
struct Dummy;

fn main() {
    let a = Dummy;
    let b = a;
    println!("{}", a);
    println!("{}", b);
}

在编译这个程序时,我们得到以下错误:

图片

真是很有趣!看起来Copy依赖于Clone特性。这是因为Copy在标准库中的定义如下:

pub trait Copy: Clone { }

CloneCopy的超特性,任何实现了Copy的类型也必须实现Clone。我们可以通过在派生注解中添加Clone特性来使这个例子编译通过:

// making_copy_types_fixed.rs

#[derive(Copy, Clone, Debug)]
struct Dummy;

fn main() {
    let a = Dummy;
    let b = a;
    println!("{}", a);
    println!("{}", b);
}

程序现在运行正常了。但是,关于CloneCopy之间的区别还不是非常清楚。让我们接下来区分一下它们。

通过特性复制类型

CopyClone特性传达了当它们在代码中使用时类型如何被复制的概念。

Copy

Copy特性通常用于可以在栈上完全表示的类型。也就是说,它们没有任何部分是存在于堆上的。如果是这样的话,Copy操作将会很重,因为它还需要遍历堆来复制值。这直接影响了=赋值操作符的工作方式。如果一个类型实现了Copy,从一个变量到另一个变量的赋值就会隐式地复制数据。

Copy是一个自动特性,它自动应用于大多数栈数据类型,如原始类型和不可变引用,即&TCopy复制类型的方式与 C 语言中的memcpy函数非常相似,后者用于按位复制值。对于用户定义的类型,Copy默认不实现,因为 Rust 希望明确复制,并且开发者必须选择实现该特性。Copy在任何人想要在他们的类型上实现Copy时也依赖于Clone特性。

没有实现Copy的类型有Vec<T>String和可变引用。为了复制这些值,我们使用更明确的Clone特性。

Clone

Clone特性用于显式复制,并附带一个clone方法,类型可以通过实现该方法来获得自身的副本。Clone特性的定义如下:

pub trait Clone {
    fn clone(&self) -> Self;
}

它有一个名为clone的方法,该方法接受接收者的不可变引用,即&self,并返回相同类型的新值。用户定义的类型或任何需要提供自我复制能力的包装类型都应该通过实现clone方法来实现Clone特性。

但与Copy类型不同,其中赋值隐式复制值,要复制一个Clone值,我们必须显式调用clone方法。clone方法是一种更通用的复制机制,而Copy是它的一个特例,它始终是位复制。像StringVec这样的重复制项,只实现了Clone特质。智能指针类型也实现了Clone特质,其中它们只是复制指针和额外的元数据,如引用计数,同时指向相同的堆数据。

这是我们能够决定如何复制类型的一个例子,Clone特质给了我们这种灵活性。

下面是一个演示使用Clone来复制类型的程序:

// explicit_copy.rs

#[derive(Clone, Debug)]
struct Dummy {
    items: u32
}

fn main() {
    let a = Dummy { items: 54 };
    let b = a.clone();
    println!("a: {:?}, b: {:?}", a, b);
}

我们在derive属性中添加了一个Clone。有了这个,我们就可以在a上调用clone来获取它的新副本。

现在,你可能想知道何时应该实现这两种类型之一。以下是一些指导原则。

在何时实现Copy类型:

小值可以仅以下列方式表示在堆栈上:

  • 如果类型只依赖于其他实现了Copy的类型;则对该类型隐式实现了Copy特质。

  • Copy特质隐式影响了赋值运算符=的工作方式。由于它如何影响赋值运算符,因此决定是否使用Copy特质来创建自己的外部可见类型需要一些考虑。如果在开发的早期阶段你的类型是Copy,之后你移除了它,这将影响所有该类型值被赋值的地方。你可能会以这种方式轻易地破坏一个 API。

在何时实现Clone类型:

  • Clone特质仅声明了一个clone方法,需要显式调用。

  • 如果你的类型还包含作为其表示一部分的堆上的值,那么选择实现Clone,这会明确告诉用户还将克隆堆数据。

  • 如果你正在实现一个如引用计数这样的智能指针类型,你应该在你的类型上实现Clone,以仅复制堆栈上的指针。

现在我们已经了解了CopyClone的基础知识,让我们继续看看所有权如何影响代码的各个地方。

行动中的所有权

除了let绑定示例之外,你还会在其他地方找到所有权的应用,识别这些地方以及编译器给出的错误是非常重要的。

函数: 如果你向函数传递参数,相同的所有权规则同样适用:

// ownership_functions.rs

fn take_the_n(n: u8) { }

fn take_the_s(s: String) { }

fn main() { 
    let n = 5; 
    let s = String::from("string"); 

    take_the_n(n); 
    take_the_s(s); 

    println!("n is {}", n); 
    println!("s is {}", s); 
} 

编译失败的方式类似:

图片

String没有实现Copy特质,因此值的所有权在take_the_s函数内部移动。当该函数返回时,值的范围结束,并在s上调用drop,从而释放s使用的堆内存。因此,在函数调用之后,s不能再使用。然而,由于String实现了Clone,我们可以在函数调用位置添加一个.clone()调用,使我们的代码工作:

take_the_s(s.clone());

我们的take_the_nu8(一个原始类型)实现Copy时工作正常。

这意味着,在将移动类型传递给函数后,我们不能再使用该值。如果我们想使用该值,我们必须克隆类型并将副本发送到函数。现在,如果我们只需要读取变量s的访问权限,另一种我们可以使此代码工作的方式是将字符串s返回到main。这看起来像这样:

// ownership_functions_back.rs

fn take_the_n(n: u8) { }

fn take_the_s(s: String) -> String {
    println!("inside function {}", s);
    s
}

fn main() { 
    let n = 5; 
    let s = String::from("string"); 

    take_the_n(n); 
    let s = take_the_s(s); 

    println!("n is {}", n); 
    println!("s is {}", s); 
} 

我们给take_the_s函数添加了一个返回类型,并将传递的字符串s返回给调用者。在main中,我们接收它到s。有了这个,main中的最后一行代码就可以工作了。

匹配表达式:在匹配表达式内部,默认情况下也会移动移动类型,如下述代码所示:

// ownership_match.rs

#[derive(Debug)]
enum Food {
    Cake,
    Pizza,
    Salad
}

#[derive(Debug)]
struct Bag {
    food: Food
}

fn main() {
    let bag = Bag { food: Food::Cake };
    match bag.food {
        Food::Cake => println!("I got cake"),
        a => println!("I got {:?}", a)
    }

    println!("{:?}", bag);
}

在前面的代码中,我们创建了一个Bag实例并将其赋值给bag。接下来,我们匹配其food字段并打印一些文本。稍后,我们使用println!打印bag。在编译时,我们得到以下错误:

图片

如您所清晰阅读的,错误信息表明bag已经被match表达式中的a变量移动和消耗。这使bag变量无法再用于其他用途。当我们学习到借用概念时,我们将看到如何使此代码工作。

方法:在impl块内部,任何以self作为第一个参数的方法都获取被调用方法上的值的所有权。这意味着在您对值调用方法后,您不能再使用该值。这在下述代码中显示:

// ownership_methods.rs

struct Item(u32);

impl Item {
    fn new() -> Self {
        Item(1024)
    }

    fn take_item(self) {
        // does nothing
    } 
}

fn main() {
    let it = Item::new();
    it.take_item();
    println!("{}", it.0);
}

编译时,我们得到以下错误:

图片

take_item是一个实例方法,它以self作为第一个参数。在其调用之后,it被移动到方法内部,并在函数作用域结束时被释放。我们不能再使用it。当我们学习到借用概念时,我们将使此代码工作。

闭包中的所有权:闭包中也会发生类似的事情。考虑以下代码片段:

// ownership_closures.rs

#[derive(Debug)]
struct Foo;

fn main() {
    let a = Foo;

    let closure = || {
        let b = a;    
    };

    println!("{:?}", a);
}

如您所猜测的,Foo的所有权在闭包内部默认通过赋值移动到b,我们不能再访问a。编译前面的代码时,我们得到以下输出:

图片

要获取a的一个副本,我们可以在闭包内部调用a.clone()并将其赋值给b,或者像这样在闭包前放置一个移动关键字:

    let closure = move || {
        let b = a;    
    };

这将使我们的程序编译成功。

注意:闭包根据变量在闭包中的使用方式以不同的方式获取值。

通过这些观察,我们可以看出所有权规则可能相当限制性,因为它只允许我们使用一种类型一次。如果一个函数只需要对值进行读取访问,那么我们或者需要从函数中再次返回该值,或者在其传递给函数之前克隆它。如果该类型没有实现 Clone,那么后者可能不可行。克隆类型可能看起来像是一个绕过所有权原则的简单方法,但它违背了零成本承诺的整个目的,因为 Clone 总是复制类型,这可能会调用内存分配器 API,这是一个涉及系统调用的昂贵操作。

在移动语义和所有权规则生效的情况下,很快就会难以在 Rust 中编写程序。幸运的是,我们有借用和引用类型的概念,这些概念放松了规则强加的限制,但仍然在编译时保持所有权保证。

借用

借用的概念是为了绕过所有权规则的限制。在借用下,你不会获取值的所有权,但只需借用数据直到你需要为止。这是通过借用值来实现的,即获取一个值的引用。要借用一个值,我们在变量前放置 & 操作符,即 地址 操作符。我们可以在 Rust 中以两种方式借用值。

不可变借用:当我们在一个类型前使用 & 操作符时,我们创建了一个对该类型的不可变引用。我们可以使用借用重写所有权部分中的先前示例:

// borrowing_basics.rs

#[derive(Debug)]
struct Foo(u32);

fn main() {
    let foo = Foo;
    let bar = &foo;
    println!("Foo is {:?}", foo);
    println!("Bar is {:?}", bar);
}

这次,程序可以编译,因为 main 中的第二行已经改为这样:

    let bar = &foo;

注意变量 foo 前面的 & 符号。我们正在借用 foo 并将借用赋给 barbar 的类型是 &Foo,这是一个引用类型。作为一个不可变引用,我们不能从 bar 中修改 Foo 内部的值。

可变借用:可以使用 &mut 操作符获取值的可变借用。有了可变借用,你可以修改值。考虑以下代码:

// mutable_borrow.rs

fn main() {
    let a = String::from("Owned string");
    let a_ref = &mut a;
    a_ref.push('!');
}

这里,我们有一个声明为 aString 实例。我们还使用 &mut a 创建了对它的可变引用 b。这不会将 a 移动到 b,而只是以可变方式借用它。然后我们向字符串中推入一个 '!' 字符。让我们编译这个程序:

图片

我们有一个错误。编译器说我们无法以可变方式借用 a。这是因为可变借用要求拥有变量的本身也用 mut 关键字声明。这应该是显而易见的,因为我们不能修改一个位于不可变绑定后面的东西。因此,我们将 a 的声明改为如下:

let mut a = String::from("Owned string");

这使得程序可以编译。在这里,a 是一个指向堆分配值的栈变量,而 a_ref 是对 a 所拥有的值的可变引用。a_ref 可以修改 String 值,但它不能丢弃该值,因为它不是所有者。如果 a 在获取引用的行之前被丢弃,则借用将无效。

现在,我们在先前的程序末尾添加一个 println! 来打印修改后的 a

// exclusive_borrow.rs

fn main() {
    let mut a = String::from("Owned string");
    let a_ref = &mut a;
    a_ref.push('!');
    println!("{}", a);
}

编译此代码会给我们以下错误:

图片

Rust 禁止这样做,因此借用值作为可变借用,因为 a_ref 已经在作用域中存在。这突出了借用的重要规则之一。一旦一个值被可变借用,我们就不可以对其有其他任何借用。甚至不能是不可变借用。在探讨了借用之后,让我们强调 Rust 中确切的借用规则。

借用规则

与所有权规则类似,我们也有借用规则,这些规则通过引用来维护单一所有权语义。这些规则如下:

  • 一个引用不能比它所引用的内容活得久。这是显而易见的,因为如果它活得久,它就会引用垃圾值。

  • 如果有一个对值的可变引用,那么在该作用域内不允许有其他引用,无论是可变引用还是不可变引用,指向相同的值。可变引用是独占借用。

  • 如果没有对某个事物的可变引用,那么在该作用域内允许有任意数量的对该相同值的不可变引用。

Rust 中的借用规则是由编译器中的一个组件——借用检查器——分析的。Rust 社区幽默地称处理借用错误为与借用检查器作斗争。

现在我们已经熟悉了这些规则,让我们看看如果我们违反这些规则与借用检查器作斗争会发生什么。

借用动作

当我们违反借用检查器时,Rust 的关于借用规则的错误诊断非常有助于我们。在接下来的几个例子中,我们将看到它们在各种上下文中的应用。

函数中的借用:正如你之前看到的,在函数调用时移动所有权,如果你只是读取值,这并没有太多意义,而且非常有限。你在调用函数后无法使用该变量。我们不是通过值传递参数,而是可以通过引用传递。我们可以将所有权部分中展示的先前代码示例修改为通过借用传递,如下所示:

// borrowing_functions.rs

fn take_the_n(n: &mut u8) {
    *n += 2;
}

fn take_the_s(s: &mut String) {
    s.push_str("ing");
}

fn main() {
    let mut n = 5;
    let mut s = String::from("Borrow");

    take_the_n(&mut n);
    take_the_s(&mut s);

    println!("n changed to {}", n);
    println!("s changed to {}", s);
}

在前面的代码中,take_the_stake_the_n 现在获取可变引用。因此,我们需要在我们的代码中修改三件事。首先,变量绑定必须变为可变的:

let mut s = String::from("Borrow"); 

第二,我们的函数变为以下形式:

fn take_the_s(n: &mut String) { 
    s.push_str("ing"); 
} 

第三,调用站点也需要更改为以下形式:

    take_the_s(&mut s); 

再次,我们可以看到 Rust 中的所有内容都是显式的。由于多个线程的介入,可变性在 Rust 代码中非常明显,这是显而易见的。

match 中的借用:在 match 表达式中,默认情况下值会被移动,除非它是 Copy 类型。以下代码,在先前关于所有权的章节中展示,通过在 match 表达式的 arms 中借用编译:

// borrowing_match.rs

#[derive(Debug)]
enum Food {
    Cake,
    Pizza,
    Salad
}

#[derive(Debug)]
struct Bag {
    food: Food
}

fn main() {
    let bag = Bag { food: Food::Cake };
    match bag.food {
        Food::Cake => println!("I got cake"),
        ref a => println!("I got {:?}", a)
    }

    println!("{:?}", bag);
}

我们对前面的代码做了一些轻微的修改,这可能与你在所有权部分看到的代码相似。对于第二个匹配分支,我们在 a 前加了 ref 前缀。ref 关键字是一个关键字,它可以通过引用它们而不是按值捕获它们来匹配项。通过这个修改,我们的代码可以编译。

从函数返回引用:在下面的代码示例中,有一个尝试从函数内部声明的值返回引用的函数:

// return_func_ref.rs

fn get_a_borrowed_value() -> &u8 { 
    let x = 1; 
    &x 
}

fn main() {
    let value = get_a_borrowed_value(); 
}

这段代码未能通过借用检查器,我们遇到了以下错误:

错误信息表明我们缺少生命周期指定符。这并不能很好地解释我们代码中的问题。这就是我们需要熟悉生命周期概念的地方,我们将在下一节中介绍。在此之前,让我们详细说明基于借用规则我们可以有哪些类型的函数。

基于借用的方法类型

借用规则还规定了如何定义类型上的固有方法和来自特质的实例方法。以下是如何接收实例的说明,从最不限制到最限制:

  • &self 方法:这些方法只能对其成员进行不可变访问

  • &mut self 方法:这些方法以可变方式借用 self 实例

  • self 方法:这些方法在调用时拥有实例的所有权,并且其类型不可用于后续调用

在用户定义类型的情况下,相同的借用规则也适用于其字段成员。

注意:除非你故意编写一个应该在结束时移动或丢弃 self 的方法,否则始终优先选择不可变借用方法,即,将 &self 作为第一个参数。

生命周期

Rust 编译时内存安全谜题的第三部分是关于生命周期以及用于在代码中指定生命周期的相关语法标注的概念。在本节中,我们将通过简化基本概念来解释生命周期。

当我们通过初始化值来声明一个变量时,该变量具有一个生命周期,超出这个生命周期后,使用它就不再有效。在一般编程术语中,变量的生命周期是指代码中变量指向有效内存的区域。如果你曾经用 C 语言编程,你应该非常清楚变量生命周期的案例:每次使用 malloc 分配变量时,它都应该有一个所有者,并且该所有者应该可靠地决定何时结束该变量的生命周期以及何时释放内存。但最糟糕的是,C 编译器并不强制执行这一点;相反,这是程序员的职责。

对于在栈上分配的数据,我们可以通过查看代码轻松地推理出变量是否存活。然而,对于堆分配的值,这并不清楚。Rust 中的生命周期是一个具体的构造,而不是像 C 那样是一个概念性的想法。它们执行与程序员手动执行相同的分析,即通过检查值的范围及其引用的任何变量。

当谈论 Rust 中的生命周期时,你只需要在拥有引用时处理它们。Rust 中的所有引用都附带隐式生命周期信息。生命周期定义了引用相对于值的原始所有者的存活时间以及引用作用域的范围。大多数情况下,它是隐式的,编译器通过查看代码来确定变量的生命周期。但在某些情况下,编译器无法做到,然后它需要我们的帮助,或者更确切地说,它要求你指定你的意图。

到目前为止,我们已经在之前的代码示例中轻松地处理了引用和借用,但让我们看看当我们尝试编译以下代码时会发生什么:

// lifetime_basics.rs

struct SomeRef<T> {
    part: &T
}

fn main() {
    let a = SomeRef { part: &43 };
}

这段代码非常简单。我们有一个 SomeRef 结构体,它存储了对泛型类型 T 的引用。在 main 中,我们创建了这个结构体的一个实例,用对 i32 的引用初始化 part 字段,即 &43

编译时会给出以下错误:

图片

在这种情况下,编译器要求我们输入一个称为生命周期参数的东西。生命周期参数与泛型类型参数非常相似。当一个泛型类型 T 表示任何类型时,生命周期参数表示引用有效的区域或范围。它只是为了让编译器在代码被借用检查器分析时,用实际的区域信息填充。

生命周期纯粹是一个编译时构造,它帮助编译器确定引用在作用域内可以使用的范围,并确保它遵循借用规则。它可以跟踪诸如引用的来源以及它们是否比借用值存活时间更长等问题。Rust 中的生命周期确保引用不会比它指向的值存活时间更长。生命周期不是开发者会使用的东西,而是编译器使用并推理引用有效性的东西。

生命周期参数

对于编译器无法通过检查代码来确定值的生命周期的案例,我们需要通过在代码中使用一些注解来告诉 Rust。为了与标识符区分开来,生命周期注解通过在字母前加上一个奇特的符号 ' 来表示。因此,为了使我们的上一个示例与参数编译,我们在 StructRef 上添加了生命周期注解,如下所示:

// using_lifetimes.rs

struct SomeRef<'a, T> {
    part: &'a T
}

fn main() {
    let _a = SomeRef { part: &43 };
}

生命周期由一个'符号后跟任何有效的标识符序列表示。但按照惯例,Rust 代码中使用的生命周期参数通常是'a'b'c。如果你在一个类型上有多个生命周期,你可以使用更长的描述性生命周期名称,如'ctx'reader'writer等。它们与泛型类型参数以相同的位置和方式声明。

我们看到了生命周期作为后续解决有效引用的泛型参数的例子,但有一个具有具体值的生命周期。以下代码展示了这一点:

// static_lifetime.rs

fn main() {
    let _a: &'static str = "I live forever";
}

static生命周期意味着这些引用在整个程序运行期间都是有效的。所有 Rust 中的字面量字符串都具有'static生命周期,并且它们被放置在编译后的目标代码的数据段中。

生命周期省略和规则

每当在函数或类型定义中出现引用时,都会涉及生命周期。大多数时候,你不需要在代码中使用显式的生命周期标注,因为编译器很聪明,可以在编译时根据大量信息为你推断它们。

换句话说,这两个函数签名是相同的:

fn func_one(x: &u8) → &u8 { .. }

fn func_two<'a>(x: &'a u8) → &'a u8 { .. }

在通常情况下,编译器已经省略了func_one的生命周期参数,我们不需要将其写成func_two

但编译器只能在受限的位置省略生命周期,并且存在省略的规则。在我们讨论这些规则之前,我们需要讨论输入和输出生命周期。这些只在涉及接受引用的函数时讨论。

输入生命周期:函数参数上的引用生命周期标注被称为输入生命周期。

输出生命周期:函数返回值上的引用生命周期标注被称为输出生命周期。

需要注意的是,任何输出生命周期都源于输入生命周期。我们不能有一个独立且与输入生命周期不同的输出生命周期。它只能是一个小于或等于输出生命周期的生命周期。

以下是在省略生命周期时遵循的规则:

  • 如果输入生命周期只包含单个引用,则假定输出生命周期与输入生命周期相同

  • 对于涉及self&mut self的方法,输入生命周期被推断为&self参数

但有时在模糊的情况下,编译器不会尝试假设任何事情。考虑以下代码:

// explicit_lifetimes.rs

fn foo(a: &str, b: &str) -> &str {
    b
}

fn main() {
    let a = "Hello";
    let b = "World";
    let c = foo(a, b);
}

在前面的代码中,RefItem存储了对任何类型T的引用。在这种情况下,返回值的生命周期并不明显,因为涉及两个输入引用。但有时,编译器无法确定引用的生命周期,这时它需要我们的帮助,并要求我们指定生命周期参数。考虑以下无法编译的代码:

图片

前面的程序无法编译,因为 Rust 无法推断出返回值的生命周期,它需要我们的帮助。

现在,在许多地方,当 Rust 无法为我们推断出生命周期时,我们必须指定它们:

  • 函数签名

  • 结构体和结构体字段

  • impl 块

用户定义类型中的生命周期

如果一个结构定义的字段是任何类型的引用,我们需要明确指定这些引用将存活多长时间。语法与函数签名类似:我们首先在结构体行上声明生命周期名称,然后在字段中使用它们。

这是最简单的语法形式:

//  lifetime_struct.rs

struct Number<'a> { 
    num: &'a u8 
}

fn main() {
    let _n = Number {num: &545}; 
}

Number的定义与num的引用的生命周期相同。

impl 块中的生命周期

当我们为具有引用的结构体创建impl块时,我们需要再次重复生命周期声明和定义。例如,如果我们为之前定义的结构体Foo创建了一个实现,语法将看起来像这样:

// lifetime_impls.rs

#[derive(Debug)]
struct Number<'a> { 
    num: &'a u8 
}

impl<'a> Number<'a> { 
    fn get_num(&self) -> &'a u8 { 
        self.num 
    }  
    fn set_num(&mut self, new_number: &'a u8) { 
        self.num = new_number 
    }
}

fn main() {
    let a = 10;
    let mut num = Number { num: &a };
    num.set_num(&23);
    println!("{:?}", num.get_num());
}

在大多数这些情况下,这是从类型本身推断出来的,然后我们可以省略带有<'_>语法的签名。

多个生命周期

就像泛型类型参数一样,如果我们有多个具有不同生命周期的引用,我们可以指定多个生命周期。然而,当你必须在代码中处理多个生命周期时,这可能会变得很复杂。大多数时候,我们可以在我们的结构体或任何函数中只使用一个生命周期。但有些情况下,我们需要超过一个生命周期注解。例如,假设我们正在构建一个解码库,它可以根据模式和一个给定的编码字节流解析二进制文件。我们有一个Decoder对象,它引用了一个Schema对象和一个Reader类型。我们的解码器定义可能看起来像这样:

// multiple_lifetimes.rs

struct Decoder<'a, 'b, S, R> {
    schema: &'a S,
    reader: &'b R
}

fn main() {}

在前面的定义中,我们完全可能从网络获取Reader,而Schema是本地的,因此它们在代码中的生命周期可能不同。当我们为这个Decoder提供实现时,我们可以通过生命周期子类型化来指定与它的关系,我们将在下一节中解释。

生命周期子类型

我们可以指定生命周期之间的关系,这指定了两个引用是否可以在同一位置使用。继续我们的Decoder结构体示例,我们可以在impl块中指定生命周期的相互关系,如下所示:

// lifetime_subtyping.rs

struct Decoder<'a, 'b, S, R> {
    schema: &'a S,
    reader: &'b R
}

impl<'a, 'b, S, R> Decoder<'a, 'b, S, R>
where 'a: 'b {

}

fn main() {
    let a: Vec<u8> = vec![];
    let b: Vec<u8> = vec![];
    let decoder = Decoder {schema: &a, reader: &b};
}

我们在 impl 块中使用 where 子句指定了关系:'a: 'b。这可以读作生命周期'a'b长,换句话说'b不应该比'a活得久。

在泛型类型上指定生命周期界限

除了使用特质来约束泛型函数可以接受的数据类型之外,我们还可以使用生命周期注解来约束泛型类型参数。例如,考虑我们有一个日志库,其中Logger对象定义如下:

// lifetime_bounds.rs

enum Level {
    Error
}

struct Logger<'a>(&'a str, Level);

fn configure_logger<T>(_t: T) where T: Send + 'static {
    // configure the logger here
}

fn main() {
    let name = "Global";
    let log1 = Logger(name, Level::Error);
    configure_logger(log1);
}

在前面的代码中,我们有一个名为Logger的结构体,它包含其名称和一个Level枚举。我们还有一个名为configure_logger的泛型函数,它接受一个受Send + 'static约束的类型T。在main函数中,我们创建了一个具有'static生命周期的字符串"Global"的 logger,并调用configure_logger函数,将类型传递给它。

除了Send绑定,它表示这个线程可以被发送到其他线程之外,我们还规定类型必须具有'static的生命周期。假设我们要使用一个 Logger,它引用了一个生命周期较短的字符串,如下所示:

// lifetime_bounds_short.rs

enum Level {
    Error
}

struct Logger<'a>(&'a str, Level);

fn configure_logger<T>(_t: T) where T: Send + 'static {
    // configure the logger here
}

fn main() {
    let other = String::from("Local");
    let log2 = Logger(&other, Level::Error);
    configure_logger(&log2);
}

这将导致以下错误:

图片

错误信息明确指出,借用的值必须对'static生命周期有效,但我们传递了一个名为'a的字符串,它从main函数中继承,其生命周期比'static短。

在掌握了生命周期概念之后,让我们重新审视 Rust 中的指针类型。

Rust 中的指针类型

如果我们不包括指针的讨论,我们的内存管理故事将是不完整的,因为指针是任何底层语言中操作内存的主要方式。指针仅仅是指向进程地址空间中内存位置的变量。在 Rust 中,我们处理三种类型的指针。

引用 – 安全指针

这些指针在借用部分已经熟悉了。引用类似于 C 中的指针,但它们会进行正确性检查。它们永远不会是空指针,并且始终指向由任何变量拥有的某些数据。它们指向的数据可以在栈上、堆上或二进制的数据段中。它们使用&&mut操作符创建。这些操作符在类型T前缀时,创建了一个引用类型,对于不可变引用表示为&T,对于可变引用表示为&mut T。让我们再次回顾这些内容:

  • &T:这是一个指向类型T的不可变引用。&T指针是一个Copy类型,这意味着你可以有多个对值T的不可变引用。如果你将它赋给另一个变量,你得到一个指向相同数据的指针的副本。将引用赋给另一个引用,如&&T,也是可以的。

  • &mut T:这是一个指向类型T的不可变指针。由于借用规则,在任何作用域内,你不能有两个对值T的可变引用。这意味着&mut T类型不实现Copy特质。它们也不能被发送到线程。

原始指针

这些指针有一个奇特的类型签名,即以*为前缀,这同时也是解引用操作符。它们主要用于不安全代码。需要使用不安全块来解引用它们。Rust 中有两种原始指针:

  • *const T:这是一个指向类型T的不可变原始指针。它们也是Copy类型。它们类似于&T,只是*const T也可以是空指针。

  • *mut T:这是一个指向值T的可变原始指针,它是非Copy的。

作为附加说明,一个引用可以被转换为原始指针,如下面的代码所示:

let a = &56;
let a_raw_ptr = a as *const u32;
// or
let b = &mut 5634.3;
let b_mut_ptr = b as *mut T;

然而,我们不能将&T转换为*mut T,因为这会违反只允许一个可变借用规则的借用规则。

对于可变引用,我们可以将它们转换为*mut T,甚至*const T,这被称为指针弱化,因为我们从更强大的指针&mut T转换到更弱的*const T指针。对于不可变引用,我们只能将它们转换为*const T

然而,解引用原始指针是一个不安全操作。当我们到达第十章第十章,不安全 Rust 和外部函数接口时,我们将看到原始指针的用途。

智能指针

管理原始指针是非常不安全的,开发者在使用它们时需要非常小心。无知的用法可能导致内存泄漏、悬垂引用和大型代码库中的双重释放等问题。为了减轻这些问题,我们可以使用智能指针,这是由 C++普及的。

Rust 也有许多种类的智能指针。它们被称为智能,因为它们还与额外的元数据和代码相关联,这些代码在它们创建或销毁时执行。当智能指针超出作用域时能够自动释放底层资源是使用智能指针的主要原因之一。

智能指针的大部分智能都来自于两个特质,称为Drop特质和Deref特质。在我们探索 Rust 中可用的智能指针类型之前,让我们详细了解这些特质。

Drop

这是我们在多次提到的一个特质,它自动释放当值超出作用域时使用的资源。Drop特质类似于在其他语言中你可能会称之为对象析构器的方法。它包含一个单一的方法,drop,当对象超出作用域时会被调用。该方法接受一个&mut self作为参数。使用drop释放值是按照后进先出的顺序进行的。也就是说,最后构建的会被最先销毁。以下代码说明了这一点:

// drop.rs

struct Character {
    name: String,
}

impl Drop for Character {
    fn drop(&mut self) {
        println!("{} went away", self.name)
    }
}

fn main() {
    let steve = Character {
        name: "Steve".into(),
    };
    let john = Character {
        name: "John".into(),
    };
}

输出如下:

图片

如果需要,将清理代码放在你自己的结构体中的drop方法是一个理想的地方。对于清理不那么明显确定性的类型,例如使用引用计数值或垃圾收集器时,它特别方便。当我们实例化任何实现Drop的值(任何堆分配的类型)时,Rust 编译器在编译后在每个作用域结束时插入drop方法调用。因此,我们不需要手动调用这些实例的drop。这种基于作用域的自动回收是受到 C++的 RAII 原则的启发。

Deref 和 DerefMut

为了提供与普通指针类似的行为,即能够解引用指向的基本类型的调用方法,智能指针类型通常实现 Deref 特性,这允许我们使用 * 解引用运算符与这些类型一起使用。虽然 Deref 给你只读访问,但还有一个 DerefMut,它可以给你对基本类型的可变引用。Deref 有以下类型签名:

pub trait Deref {
    type Target: ?Sized;
    fn deref(&self) -> &Self::Target;
}

它定义了一个名为 Deref 的单个方法,该方法通过引用接收 self 并返回对基本类型的不可变引用。这与 Rust 的解引用强制转换特性相结合,减少了你需要编写的代码量。解引用强制转换是指类型自动从一种引用类型转换为另一种引用类型。我们将在 第七章,高级概念 中探讨它们。

智能指针类型

标准库中的一些智能指针类型如下:

  • Box<T>: 这提供了堆分配的最简单形式。Box 类型拥有其内部的价值,因此可以用于在结构体内部持有值或从函数中返回它们。

  • Rc<T>: 这用于引用计数。每当有人获取一个新的引用时,它会增加计数器,当有人释放引用时,它会减少计数器。当计数器达到零时,值将被丢弃。

  • Arc<T>: 这用于原子引用计数。这与前面的类型类似,但具有原子性以保证多线程安全。

  • Cell<T>: 这为我们提供了实现 Copy 特性的类型的内部可变性。换句话说,我们获得了获取对某物多个可变引用的可能性。

  • RefCell<T>: 这为我们提供了类型的内部可变性,而不需要 Copy 特性。它使用运行时锁定以确保安全性。

Box

标准库中的泛型类型 Box 给我们提供了在堆上分配值的简单方法。它简单地声明为标准库中的元组结构体,并包装它接收到的任何类型并将其放在堆上。如果你熟悉来自其他语言(如 Java 中的 Integer 类的包装整数)的装箱和拆箱概念,这提供了类似的抽象。Box 类型的所有权语义取决于包装的类型。如果底层类型是 Copy,则 Box 实例变为可复制,否则默认移动。

要使用 Box 创建类型 T 的堆分配值,我们只需调用相关的 new 方法,传入值。创建包装类型 TBox 值会返回 Box 实例,这是一个指向堆上 T 的栈指针,它是在堆上分配的。以下示例展示了如何使用 Box

// box_basics.rs

fn box_ref<T>(b: T) -> Box<T> {
    let a = b;
    Box::new(a)
}

struct Foo;

fn main() {
    let boxed_one = Box::new(Foo);
    let unboxed_one = *boxed_one;
    box_ref(unboxed_one);
}

在我们的主函数中,我们通过调用 Box::new(Foo)boxed_one 中创建了一个堆分配的值。

Box 类型可以在以下情况下使用:

  • 它可以用来创建递归类型定义。例如,这里有一个表示单链表节点的 Node 类型:
// recursive_type.rs

struct Node {
    data: u32,
    next: Option<Node>
}

fn main() {
    let a = Node { data: 33, next: None };
}

在编译时,我们遇到了这个错误:

图片

我们不能有这种 Node 类型的定义,因为next有一个指向自身的类型。如果允许这种定义,编译器将无法结束对 Node 定义的分析,因为它会不断评估直到耗尽内存。这可以通过以下代码片段更好地说明:

struct Node {
    data: u32,
    next: Some(Node {
              data: u32,
              next: Node {
                        data: u32,
                        next: ...
                    }
          })
}

对 Node 定义的评估将持续进行,直到编译器耗尽内存。此外,由于每块数据在编译时都需要有一个静态已知的大小,这在 Rust 中是一个不可表示的类型。我们需要将next字段变成一个具有固定大小的类型。我们可以通过将next放在指针后面来实现这一点,因为指针始终是固定大小的。如果你看到错误信息,编译器会使用 Box 类型。我们新的Node定义将如下改变:

struct Node {
    data: u32,
    next: Option<Box<Node>>
}

当定义需要隐藏在Sized间接引用背后的递归类型时,也会使用Box类型。因此,一个由包含指向自身引用的变体组成的枚举可以使用Box类型在以下情况下将变体隐藏起来:

  • 当你需要将类型存储为特质对象时

  • 当你需要将函数存储在集合中时

引用计数智能指针

所有权规则允许在给定作用域中同时只有一个所有者。然而,有些情况下你需要将类型与多个变量共享。例如,在一个 GUI 库中,每个子小部件都需要对其父容器小部件的引用,以便进行诸如根据用户的调整大小事件来布局子小部件等操作。虽然生命周期允许你通过将父节点存储为&'a Parent(例如)从子节点引用父节点,但它通常受值的生命周期'a的限制。一旦作用域结束,你的引用就无效了。在这种情况下,我们需要更灵活的方法,这就需要使用引用计数类型。这些智能指针类型提供了程序中值的共享所有权。

引用计数类型允许在细粒度上进行垃圾回收。在这种方法中,智能指针类型允许你拥有多个对包装值的引用。内部,智能指针通过引用计数器(以下简称 refcount)来跟踪它已经分配了多少个引用并且这些引用是否仍然活跃,引用计数器只是一个整数值。当引用包装智能指针值的变量超出作用域时,refcount 值会递减。一旦所有对对象的引用都消失了并且 refcount 达到0,值就会被释放。这就是引用计数指针通常是如何工作的。

Rust 为我们提供了两种引用计数指针类型:

  • Rc<T>:这主要用于单线程环境

  • Arc<T>旨在在多线程环境中使用

让我们在这里探索单线程变体。我们将在第八章并发中访问其多线程对应物。

Rc

当我们与Rc类型交互时,其内部会发生以下变化:

  • 当你通过调用Clone()为新Rc获取一个新的共享引用时,Rc会递增其内部引用计数。Rc 内部使用 Cell 类型来处理引用计数

  • 当引用超出作用域时,它会递减

  • 当所有共享引用超出作用域时,引用计数变为零。此时,Rc的最后一个 drop 调用执行其释放操作

使用引用计数容器使我们能够在实现中拥有更多的灵活性:我们可以分发我们值的副本,就像它是新副本一样,而无需精确跟踪引用何时超出作用域。这并不意味着我们可以可变地别名内部值。

Rc<T>主要通过两种方法使用:

  • 静态方法Rc::new创建一个新的引用计数容器。

  • clone方法递增强引用计数并分发一个新的Rc<T>

Rc内部保持两种引用:强引用(Rc<T>)和弱引用(Weak<T>)。两者都记录已分发每种类型引用的数量,但只有当强引用计数达到零时,值才会被释放。这样做的原因是,数据结构的实现可能需要多次指向同一事物。例如,树结构的实现可能既有对子节点的引用也有对父节点的引用,但为每个引用递增计数是不正确的,会导致引用周期。以下图示说明了引用周期的情况:

在前面的图中,我们有两个变量var1var2,它们引用两个资源Obj1Obj2。除此之外,Obj1还有一个对Obj2的引用,而Obj2也有一个对Obj1的引用。当var1var2超出作用域时,Obj1Obj2的引用计数都是2,它们的引用计数达到1时不会释放,因为它们仍然相互引用。

可以使用弱引用来打破引用周期。例如,一个链表可能以这种方式实现,即它通过引用计数来维护对下一个项目和前一个项目的链接。更好的方法是使用强引用对一个方向和弱引用对另一个方向。

让我们看看这可能如何工作。以下是最小实现可能最不实用但最好学习的结构,单链表的一个示例:

// linked_list.rs

use std::rc::Rc; 

#[derive(Debug)] 
struct LinkedList<T> { 
    head: Option<Rc<Node<T>>> 
} 

#[derive(Debug)] 
struct Node<T> { 
    next: Option<Rc<Node<T>>>, 
    data: T 
} 

impl<T> LinkedList<T> { 
    fn new() -> Self { 
        LinkedList { head: None } 
    } 

    fn append(&self, data: T) -> Self { 
        LinkedList { 
            head: Some(Rc::new(Node { 
                data: data, 
                next: self.head.clone() 
            })) 
        } 
    } 
} 

fn main() { 
    let list_of_nums = LinkedList::new().append(1).append(2); 
    println!("nums: {:?}", list_of_nums); 

    let list_of_strs = LinkedList::new().append("foo").append("bar"); 
    println!("strs: {:?}", list_of_strs); 
}

链表由两个结构体组成:LinkedList 提供了对列表第一个元素的引用和列表的公共 API,而 Node 包含实际的元素。注意我们是如何在每次添加时使用 Rc 并克隆下一个数据指针的。让我们来看看在添加情况中发生了什么:

  1. LinkedList::new() 给我们一个新的列表。头部是 None

  2. 我们向列表中添加 1。现在头部是包含数据 1 的节点,而 next 是前一个头部:None

  3. 我们向列表中添加 2。现在头部是包含数据 2 的节点,而 next 是前一个头部,即包含数据 1 的节点。

println! 的调试输出确认了这一点:

nums: LinkedList { head: Some(Node { next: Some(Node { next: None, data: 1 }), data: 2 }) }
strs: LinkedList { head: Some(Node { next: Some(Node { next: None, data: "foo" }), data: "bar" }) }

这是一种相当函数式的结构形式;每次添加都是通过在头部添加数据来完成的,这意味着我们不需要玩弄引用,实际的列表引用可以保持不可变。如果我们想要保持这种简单的结构,但仍然有一个双向链表,那么这会有所改变,因为那时我们实际上必须改变现有的结构。

你可以使用 downgrade 方法将 Rc<T> 类型降级为 Weak<T> 类型,同样,使用 upgrade 方法可以将 Weak<T> 类型转换为 Rc<T>。降级方法总是有效的。相比之下,当对一个弱引用调用 upgrade 时,实际的值可能已经被丢弃,在这种情况下,你会得到一个 None

因此,让我们向前一个节点添加一个弱指针:

// rc_weak.rs

use std::rc::Rc; 
use std::rc::Weak; 

#[derive(Debug)] 
struct LinkedList<T> { 
    head: Option<Rc<LinkedListNode<T>>> 
} 

#[derive(Debug)] 
struct LinkedListNode<T> { 
    next: Option<Rc<LinkedListNode<T>>>, 
    prev: Option<Weak<LinkedListNode<T>>>, 
    data: T 
} 

impl<T> LinkedList<T> { 
    fn new() -> Self { 
        LinkedList { head: None } 
    } 

    fn append(&mut self, data: T) -> Self { 
        let new_node = Rc::new(LinkedListNode { 
            data: data, 
            next: self.head.clone(), 
            prev: None 
        }); 

        match self.head.clone() { 
            Some(node) => { 
                node.prev = Some(Rc::downgrade(&new_node)); 
            }, 
            None => { 
            } 
        } 

        LinkedList { 
            head: Some(new_node) 
        } 
    } 
} 

fn main() { 
    let list_of_nums = LinkedList::new().append(1).append(2).append(3); 
    println!("nums: {:?}", list_of_nums); 
}

append 方法增加了一些;我们现在需要在返回新创建的头部之前更新当前头部的上一个节点。这几乎足够了,但还不够。编译器不允许我们执行无效的操作:

我们可以让 append 方法接受 self 的可变引用,但这意味着我们只能在所有节点的绑定都是可变的时才能向列表中添加元素,这迫使整个结构都必须是可变的。我们真正想要的是一种方法,只让整个结构中的一小部分可变,幸运的是,我们可以通过单个 RefCell 来实现这一点。

  1. RefCell 添加 use
        use std::cell::RefCell; 
  1. LinkedListNode 的前一个字段包裹在 RefCell 中:
        // rc_3.rs
        #[derive(Debug)] 
        struct LinkedListNode<T> { 
            next: Option<Rc<LinkedListNode<T>>>, 
            prev: RefCell<Option<Weak<LinkedListNode<T>>>>, 
            data: T 
        }
  1. 我们将 append 方法更改为创建一个新的 RefCell 并通过 RefCell 的可变借用更新前一个引用:
    // rc_3.rs

    fn append(&mut self, data: T) -> Self { 
        let new_node = Rc::new(LinkedListNode { 
            data: data, 
            next: self.head.Clone(), 
            prev: RefCell::new(None) 
        }); 

        match self.head.Clone() { 
            Some(node) => { 
                let mut prev = node.prev.borrow_mut(); 
                *prev = Some(Rc::downgrade(&new_node)); 
            }, 
            None => { 
            } 
        } 

        LinkedList { 
            head: Some(new_node) 
        } 
    } 
} 

在使用 RefCell 借用的时候,仔细思考我们是否以安全的方式使用它是良好的实践,因为那里的错误可能会导致运行时恐慌。然而,在这个实现中,我们可以很容易地看到我们只有一个单一的借用,并且关闭块会立即丢弃它。

除了共享所有权之外,我们还可以通过 Rust 的内部可变性概念在运行时获得共享可变性,这些概念由特殊的包装智能指针类型建模。

内部可变性

正如我们之前看到的,Rust 通过允许在任何给定作用域内只有一个可变引用来在编译时保护我们免受指针别名问题的影响。然而,有些情况下这变得过于严格,使得我们知道是安全的代码因为严格的借用检查而无法通过编译器。对于这些情况,一种解决方案是将借用检查从编译时移至运行时,这是通过内部可变性实现的。在我们讨论启用内部可变性的类型之前,我们需要了解内部可变性和继承可变性的概念:

  • 继承的可变性:这是当你对某个结构体取&mut引用时默认获得的可变性。这也意味着你可以修改该结构体的任何字段。

  • 内部可变性:在这种可变性中,即使你有对某些类型的&SomeStruct引用,如果你有Cell<T>RefCell<T>类型的字段,你也可以修改其字段。

内部可变性允许稍微弯曲借用规则,但也把负担放在程序员身上,以确保在运行时没有两个可变借用。这些类型将多个可变引用的检测从编译时移至运行时,如果存在对值的两个可变引用,则会引发 panic。内部可变性通常用于当你想向用户提供不可变 API,尽管 API 内部有可变部分时。

Cell<T>

考虑这个程序,其中我们有一个要求使用两个对该bag的可变引用来修改bag的需求:

// without_cell.rs

use std::cell::Cell; 

#[derive(Debug)]
struct Bag { 
    item: Box<u32>
} 

fn main() { 
    let mut bag = Cell::new(Bag { item: Box::new(1) }); 
    let hand1 = &mut bag;
    let hand2 = &mut bag;
    *hand1 = Cell::new(Bag {item: Box::new(2)});
    *hand2 = Cell::new(Bag {item: Box::new(2)});
}

但,当然,由于借用检查规则,这无法编译:

图片

我们可以通过将bag值封装在Cell中来使这可行。我们的代码更新如下:

// cell.rs

use std::cell::Cell; 

#[derive(Debug)]
struct Bag { 
    item: Box<u32>
} 

fn main() { 
    let bag = Cell::new(Bag { item: Box::new(1) }); 
    let hand1 = &bag;
    let hand2 = &bag;
    hand1.set(Bag { item: Box::new(2)}); 
    hand2.set(Bag { item: Box::new(3)});
}

这会按预期工作,唯一的额外成本是你必须写得多一些。然而,额外的运行时成本为零,对可变事物的引用仍然不可变。

Cell<T>类型是一个智能指针类型,它使值即使在不可变引用之后也能具有可变性。它以非常小的开销提供这种功能,并且 API 很小:

  • Cell::new方法允许你通过传递任何类型T来创建Cell类型的新实例。

  • getget方法允许你复制单元格中的值。此方法仅在包装类型TCopy时可用。

  • set:允许你修改内部值,即使在不可变引用之后。

RefCell<T>

如果你需要为非Copy类型添加类似Cell的功能,可以使用RefCell类型。它使用与借用相似的模式进行读写,但将检查移至运行时,这很方便但并非零成本。RefCell提供对值的引用,而不是像Cell类型那样按值返回。以下是一个示例程序:

// refcell_basics.rs

use std::cell::RefCell; 

#[derive(Debug)]
struct Bag { 
    item: Box<u32>
} 

fn main() { 
    let bag = RefCell::new(Bag { item: Box::new(1) }); 
    let hand1 = &bag;
    let hand2 = &bag;
    *hand1.borrow_mut() = Bag { item: Box::new(2)}; 
    *hand2.borrow_mut() = Bag { item: Box::new(3)};
    let borrowed = hand1.borrow();
    println!("{:?}", borrowed);
}

如您所见,尽管 baghand1hand2 被声明为不可变变量,我们仍然可以从它们那里借用 bag 的可变引用。为了修改包中的项目,我们在 hand1hand2 上调用了 borrow_mut。稍后,我们以不可变的方式借用它并打印其内容。

RefCell 类型为我们提供了以下两个借用方法:

  • borrow 方法获取一个新的不可变引用

  • borrow_mut 方法获取一个新的可变引用

现在,如果我们尝试在同一个作用域中调用这两个方法:通过将前面代码的最后一行改为以下内容:

println!("{:?} {:?}", hand1.borrow(), hand1.borrow_mut());

运行程序后,我们可以看到以下内容:

thread 'main' panicked at 'already borrowed: BorrowMutError', src/libcore/result.rs:1009:5
note: Run with `RUST_BACKTRACE=1` for a backtrace.

运行时崩溃!这是因为相同的拥有唯一可变访问权的规则。但是,对于 RefCell 来说,这是在运行时检查的。对于这种情况,必须显式使用裸块来分离借用,或者使用 drop 方法来丢弃引用。

注意:Cell 和 RefCell 类型不是线程安全的。这仅仅意味着 Rust 不会允许你在多个线程中共享这些类型。

内部可变性的用途

在上一节中,关于使用 CellRefCell 的示例被简化了,你很可能在真实代码中不需要以那种形式使用它们。让我们看看这些类型会给我们带来的一些实际好处。

正如我们之前提到的,绑定的可变性不是细粒度的;一个值要么是不可变的,要么是可变的,如果它是结构体或枚举,这包括它的所有字段。CellRefCell 可以将不可变的东西转换成可变的东西,这样我们就可以将不可变结构体的部分定义为可变的。

以下代码段通过添加两个整数和一个 sum 方法来增强结构体,以缓存 sum 的答案,如果存在则返回缓存的值:

// cell_cache.rs

use std::cell::Cell; 

struct Point { 
    x: u8, 
    y: u8, 
    cached_sum: Cell<Option<u8>> 
} 

impl Point { 
    fn sum(&self) -> u8 { 
        match self.cached_sum.get() { 
            Some(sum) => { 
                println!("Got from cache: {}", sum); 
                sum 
            }, 
            None => { 
                let new_sum = self.x + self.y; 
                self.cached_sum.set(Some(new_sum)); 
                println!("Set cache: {}", new_sum); 
                new_sum 
            } 
        } 
    } 
} 

fn main() { 
    let p = Point { x: 8, y: 9, cached_sum: Cell::new(None) }; 
    println!("Summed result: {}", p.sum()); 
    println!("Summed result: {}", p.sum()); 
}

以下是这个程序的输出:

图片

摘要

Rust 采用低级系统编程方法进行内存管理,承诺 C 类型的性能,有时甚至更好。它通过使用所有权、生命周期和借用语义,无需垃圾回收器来实现这一点。我们在本节中涵盖了大量内容,这对于新 Rustacean 可能是最难掌握的主题。这就是熟悉 Rust 的人喜欢称自己为 Rustacean 的原因,而你正接近成为其中的一员!在编译时掌握这种所有权思维方式的转变需要一点时间,但学习这些概念的投资将转化为具有小内存足迹的可靠软件。

我们下一章将关注 Rust 中如何处理可能出错的情况。那里见!

第六章:错误处理

在本章中,我们将探讨 Rust 中如何处理可能性和意外情况,了解将错误作为类型的错误处理,并查看如何设计与错误类型良好组合的接口。我们的目标是涵盖前两种错误场景,因为它们在我们控制之下,并且语言通常提供处理这些错误的机制。如果发生致命错误,我们的程序将被操作系统内核终止,因此我们对此没有太多控制权。

在本章中,我们将涵盖以下主题:

  • 错误处理序言

  • 使用OptionResult类型从错误中恢复

  • OptionResult的组合方法

  • 错误传播

  • 非恢复性错误

  • 自定义错误和Error特质

错误处理序言

"从那时起,当计算机出现问题时,我们就说它里面有虫子(bugs)。"

  • Grace Hopper

编写在预期条件下表现良好的程序是一个好的开始。当程序遇到意外情况时,它才会真正具有挑战性。适当的错误处理是软件开发中一个重要但常常被忽视的实践。一般来说,大多数错误处理都分为三类:

  • 可恢复性错误,由于用户和环境与程序交互而预期会发生,例如,找不到文件错误或数字解析错误。

  • 非恢复性错误,违反了程序的合约或不变性,例如,索引越界或除以零。

  • 立即终止程序的致命错误。这种情况包括内存耗尽和栈溢出。

在现实世界中编程往往意味着处理错误。例如,包括网络应用程序中的恶意输入、网络客户端的连接失败、文件系统损坏以及数值应用程序中的整数溢出错误。如果没有错误处理,程序在遇到意外情况时会崩溃或被操作系统终止。大多数情况下,这不是我们希望程序在意外情况下表现出的行为。例如,考虑一个实时流处理服务,由于解析发送畸形消息的客户端的消息失败,在某个时间点无法从客户端接收消息。如果我们没有处理这种错误的方法,我们的服务每次遇到解析错误时都会终止。从可用性的角度来看,这并不好,这绝对不是网络应用程序的特征。服务处理这种情况的理想方式是捕获错误,采取行动,将错误日志传递给日志聚合服务以供后续分析,并继续从其他客户端接收消息。这就是可恢复的错误处理方式出现的时候,这通常是建模错误处理的实际方法。在这种情况下,语言的错误处理结构使程序员能够拦截错误并采取行动,从而避免程序被终止。

在处理错误时,两种相当流行的范式是返回码和异常。C 语言采用了返回码模型。这是一种非常简单的错误处理形式,其中函数使用整数作为返回值来表示操作是否成功或失败。许多 C 函数在发生错误时返回-1NULL。对于系统调用的错误,C 语言在失败时设置全局errno变量。但是,作为一个全局变量,没有任何东西可以阻止你在程序的任何地方修改errno变量。然后程序员需要检查这个错误值并处理它。通常,这会变得非常晦涩难懂,容易出错,并且不是一个非常灵活的解决方案。编译器也不会警告我们忘记检查返回值,除非你使用静态分析工具。

处理错误的另一种方法是使用异常。高级编程语言,如 Java 和 C#,都使用这种错误处理形式。在这种范式下,可能失败的代码应该被包裹在一个try {}块中,并且try{}块内的任何失败都必须在catch {}块中捕获(理想情况下,catch块紧随try块之后)。但是,异常也有其缺点。抛出异常代价高昂,因为程序必须回滚堆栈,找到适当的异常处理器,并运行相关的代码。为了避免这种开销,程序员通常会采用防御性代码风格,检查可能抛出异常的代码,然后继续前进。此外,许多语言中异常的实现存在缺陷,因为它允许无知的程序员使用基类异常(如 Java 中的throwable)的捕获所有块来吞没异常,如果他们只是记录并忽略异常,这可能导致程序状态的不一致性。在这些语言中,程序员无法通过查看代码来判断一个方法是否可能抛出异常,除非他们使用带有已检查异常的方法。这使得程序员编写安全代码变得困难。因此,程序员通常需要依赖方法的文档(如果有的话)来确定它们是否可能抛出异常。

相反,Rust 拥抱基于类型的错误处理,这在函数式语言如 OCaml 和 Haskell 中可以看到,同时它也类似于 C 的错误代码返回模型。但在 RUST 中,返回值是正确的错误类型,并且可以由用户定义。该语言类型系统强制在编译时处理错误状态。如果你了解 Haskell,它与其MaybeEither类型非常相似;Rust 只是给它们起了不同的名字,即对于可恢复错误,有OptionResult;对于不可恢复错误,有一个称为panic的机制,它是一种严格的错误处理策略,建议在程序中存在错误或违反不变性时将其作为最后的手段使用。

为什么 Rust 选择了这种错误处理形式?正如我们之前所说,异常及其相关的堆栈回滚具有开销。这与 Rust 的核心哲学——零运行时成本相悖。其次,异常风格的错误处理,如通常实现的那样,允许通过捕获所有异常处理器来忽略这些错误。这可能导致程序状态的不一致性,这与 Rust 的安全性原则相违背。

除去前言,让我们深入探讨一些可恢复的错误处理策略!

可恢复的错误

正如我们之前所说的,Rust 中的错误处理大部分是通过两种泛型类型 OptionResult 来完成的。它们作为包装类型,意味着建议那些可能失败的 API 通过将这些值放入这些类型中来返回实际值。这些类型是通过枚举和泛型的组合构建的。作为枚举,它们能够存储成功状态和错误状态,而泛型允许它们在编译时进行特殊化,以便在任一状态下存储任何值。这些类型还附带了许多方便的方法(通常称为 组合子)*,允许你轻松地消费、组合或转换内部值。关于 OptionResult 类型的一件事是,它们是标准库中的普通类型,这意味着它们不是编译器内置的,编译器会以不同的方式处理它们。任何人都可以利用枚举和泛型的力量创建类似的错误抽象。让我们通过首先查看最简单的一个来开始探索它们,那就是 Option

Option

在具有空值概念的语言中,程序员会采用一种防御性代码风格来对任何可能为空的值执行操作。以 Kotlin/Java 为例,它看起来像这样:

// kotlin pseudocode

val container = collection.get("some_id")

if (container != null) {
    container.process_item();
} else {
    // no luck
}

首先,我们检查 container 是否不是 null,然后调用其上的 process_item。如果我们忘记进行空安全检查,当我们尝试调用 container.process_item() 时,将会遇到臭名昭著的 NullPointerException - 你只有在运行时抛出异常时才会知道这一点。另一个缺点是,仅通过查看代码,我们无法立即推断出 container 是否为 null。为了防止这种情况,代码库需要撒上这些空检查,这在很大程度上阻碍了其可读性。

Rust 没有空值的概念,这被著名地引用为 Tony Hoare 的十亿美元错误,他在 1965 年的 ALGOL W 语言中引入了 null 引用。在 Rust 中,可能失败并希望表示缺失值的 API 应返回 Option。这种错误类型适用于任何我们的 API,除了成功值外,还想要表示值的缺失。简单来说,它与可空值非常相似,但在这里,null 检查是显式的,并且由类型系统在编译时强制执行。

Option 具有以下类型签名:

pub enum Option<T> {
    /// No value
    None,
    /// Some value `T`
    Some(T),
}

这是一个具有两种变体的枚举,并且对 T 是泛型的。我们通过使用 let wrapped_i32 = Some(2);let empty: Option<i32> = None; 来创建一个 Option 值。

成功的操作可以使用Some(T)变量存储任何值T,或者使用None变量表示在失败状态下值是null。尽管我们不太可能显式地创建None值,但当我们需要创建一个None值时,我们需要在左侧指定类型,因为 Rust 无法从右侧推断类型。我们也可以使用turbofish操作符在右侧初始化它,例如None::<i32>;,但指定左侧的类型被认为是 Rust 的惯用代码。

如您可能已经注意到的,我们没有通过完整的语法创建Option值,即不是Option::Some(2),而是直接作为Some(2)。这是因为它的两个变体都被自动从std包(Rust 的标准库包)作为预导入模块的一部分重新导出(doc.rust-lang.org/std/prelude/)。预导入模块包含从标准库中重新导出的大多数常用类型、函数和任何模块。这些重新导出是std包提供的一个便利。没有它们,我们每次需要使用这些常用类型时都必须编写完整的语法。因此,这允许我们直接通过变体实例化Option值。这也适用于Result类型。

因此,创建它们很容易,但当你与Option值交互时,它们看起来是什么样子呢?从标准库中,我们有HashMap类型的get方法,它返回一个Option

// using_options.rs

use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert("one", 1);
    map.insert("two", 2);

    let value = map.get("one");
    let incremented_value = value + 1;
}

在这里,我们创建了一个新的HashMap map,其键为&str类型,值为i32类型,并且稍后我们检索了"one"键对应的值并将其赋值给value。编译后,我们得到了以下错误信息:

图片

我们为什么不能给我们的value加上1呢?作为一个熟悉命令式语言的开发者,我们期望map.get()在键存在时返回一个i32类型的值,否则返回null。但在这里,value是一个Option<&i32>get()方法返回一个Option<&T>,而不是内部值(一个&i32),因为也有可能我们找不到我们想要的键,所以get在这种情况下可以返回None。然而,它给出的是一个误导性的错误信息,因为 Rust 不知道如何将一个i32加到一个Option<&i32>上,因为这两个类型之间不存在Add特质的实现。然而,对于两个i32或者两个&i32,这种实现确实存在。

因此,要给我们的value加上1,我们需要从Option中提取i32。在这里,我们可以看到 Rust 的显式错误处理行为开始发挥作用。我们只能在检查map.get()Some变体还是None变体之后与内部的i32值交互。

为了检查变体,我们有两种方法;其中一种是模式匹配或if let

// using_options_match.rs

use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert("one", 1);
    map.insert("two", 2);

    let incremented_value = match map.get("one") {
        Some(val) => val + 1,
        None => 0
    };

    println!("{}", incremented_value);
}

使用这种方法,我们匹配 map.get() 的返回值,并根据变体采取行动。在 None 的情况下,我们简单地给 incremented_value 赋值为 0。另一种我们可以这样做的方式是使用 if let

let incremented_value = if let Some(v) = map.get("one") {
    v + 1
} else {
    0
};

这种方法适用于我们只对值的某个变体感兴趣,并希望对其他变体执行常见操作的情况。在这种情况下,if let 表达式会更加简洁。

解包: 另一种不那么安全的方法是在 Option 上使用解包方法,即 unwrap()expect() 方法。调用这些方法如果 OptionSome,则会提取内部值;但如果它是 None,则会引发恐慌。这些方法只有在我们确实确定 Option 值确实是 Some 值时才推荐使用:

// using_options_unwrap.rs

use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert("one", 1);
    map.insert("two", 2);
    let incremented_value = map.get("three").unwrap() + 1;
    println!("{}", incremented_value);
}

执行前面的代码会导致恐慌,显示以下消息,因为我们没有为 three 键提供任何值,所以解包了一个 None 值:

thread 'main' panicked at 'called `Option::unwrap()` on a `None` value', libcore/option.rs:345:21
note: Run with `RUST_BACKTRACE=1` for a backtrace.

在这两种方法中,expect() 更受欢迎,因为它允许你传递一个字符串作为消息在恐慌时打印,并显示源文件中恐慌发生的确切行号,而 unwrap() 不允许你将调试消息作为参数传递,并显示 Optionunwrap() 方法在标准库源文件中定义的行号,这并不太有帮助。这些方法也存在于 Result 类型上。

接下来,让我们看看 Result 类型。

结果

ResultOption 类似,但增加了存储任意错误值并带有更多错误上下文的优势,而不是仅仅存储 None。当我们需要让用户知道操作失败的原因时,这种类型是合适的。以下是 Result 的类型签名:

enum Result<T, E> {
   Ok(T), 
   Err(E), 
} 

它有两个变体,两者都是泛型的。Ok(T) 是我们在成功状态下使用的变体,可以放入任何值 T;而 Err(E) 是我们在错误状态下使用的变体,可以放入任何错误值 E。我们可以这样创建它们:

// create_result.rs

fn main() {
    let my_result = Ok(64);
    let my_err = Err("oh no!");
}

然而,这不会编译,并且我们收到以下错误消息:

由于 Result 有两个泛型变体,而我们只为 my_resultOk 变体提供了具体的类型;它不知道 E 的具体类型。对于 my_err 值也是类似的。我们需要为两者都指定具体的类型,如下所示:

// create_result_fixed.rs

fn main() {
    let _my_result: Result<_, ()> = Ok(64);
    // or
    let _my_result = Ok::<_, ()>(64);

    // similarly we create Err variants

    let _my_err = Err::<(), f32>(345.3);
    let _other_err: Result<bool, String> = Err("Wait, what ?".to_string());
}

在创建 rgw Ok 变体值的第一个例子中,我们使用 () 来指定 Err 变体的类型 E。在代码片段的第二部分,我们以类似的方式创建了 Err 变体的值,这次指定了 Ok 变体的具体类型。在明显的情况下,我们可以使用下划线让 Rust 为我们推断类型。

接下来,我们将看到如何与 Result 值进行交互。标准库中的许多文件操作 API 返回 Result 类型,因为可能存在不同的失败原因,如文件未找到、目录不存在和权限错误。这些可以放入 Err 变体中,让用户知道确切的原因。对于演示,我们将尝试打开一个文件,将其内容读入一个 String,并打印内容,如下面的代码片段所示:

// result_basics.rs

use std::fs::File;
use std::io::Read;
use std::path::Path;

fn main() {
    let path = Path::new("data.txt");
    let file = File::open(&path);
    let mut s = String::new();
    file.read_to_string(&mut s);
    println!("Message: {}", s);
}

这就是编译器做出的响应:

图片

我们通过从 File 调用 open 并提供我们的路径到 data.txt(一个不存在的文件)来创建一个新的文件。当我们对 file 调用 read_to_string 并尝试将其读入 s 时,我们得到前面的错误。检查错误信息,看起来 file 的类型是 Result<File, Error>。根据其文档,open 方法定义如下:

fn open<P: AsRef<Path>>(path: P) -> Result<File> 

对于敏锐的观察者来说,可能存在一个混淆的来源,因为它看起来 Result 缺少错误变体的泛型 E 类型,但它只是被类型别名隐藏了。如果我们查看 std::io 模块中的 type 别名定义,它定义如下:

type Result<T> = Result<T, std::io::Error>; 

因此,它被与一个常见的错误类型 std::io::Error. 进行了类型别名。这是因为标准库中的许多 API 都使用这个作为错误类型。这是类型别名的一个好处,我们可以从我们的类型签名中提取出公共部分。把那个技巧放在一边,为了能够在我们的 file 上调用 read_to_string 方法,我们需要提取内部的 File 实例,即对变体进行模式匹配。通过这样做,前面的代码会发生变化,如下所示:

// result_basics_fixed.rs

use std::fs::File;
use std::io::Read;
use std::path::Path;

fn main() {
    let path = Path::new("data.txt");
    let mut file = match File::open(&path) {
        Ok(file) => file,
        Err(err) => panic!("Error while opening file: {}", err),
    };

    let mut s = String::new();
    file.read_to_string(&mut s);
    println!("Message: {}", s);
}

在这里,我们做了两个更改。首先,我们使 file 变量可变。为什么?因为 read_to_string 函数的签名如下:

fn read_to_string(&mut self, buf: &mut String) -> Result<usize>

第一个参数是 &mut self,这意味着我们调用此方法的实例需要是可变的,因为读取文件会改变文件句柄的内部指针。其次,我们处理了两种变体,在 Ok 情况下,如果一切顺利,我们返回实际的 File 对象,但在得到 Err 值时崩溃并显示错误信息。

通过这个更改,让我们编译并运行这个程序:

图片

这会导致恐慌,因为我们目录中没有名为 data.txt 的文件。尝试创建一个具有相同名称的文件,并在其中放入任意文本,然后再次运行此程序以查看它成功。不过,首先,让我们处理那个警告。警告总是代码质量差的标志,我们理想情况下希望没有警告。警告存在是因为 File::read_to_string(来自 Read 特质的函数)返回一个 Result<usize> 类型的值。Rust 会在函数调用的返回值被忽略时警告你。在这里,Result<usize> 中的 usize 值告诉我们有多少字节被读入字符串。

我们有两种处理这个警告的方法:

  • 对于 read_to_string 方法返回的 Result 值,像以前一样处理 OkErr 两种情况。

  • 将返回值赋给一个特殊变量 _下划线),这样编译器就知道我们想要忽略这个值。

对于我们不在乎值的情况,我们可以使用第二种方法,因此 read_to_string 行的更改如下:

let _ = file.read_to_string(&mut s);

经过这次修改,代码编译时没有警告。然而,你应该处理返回值,并尽量不使用通配符下划线变量。

Option/Result 上的组合子

由于 OptionResult 是包装类型,唯一安全地与其内部值交互的方式是通过模式匹配或 if let。这种使用匹配然后对内部值进行操作的范式是一个非常常见的操作,因此每次都必须编写它们变得非常繁琐。幸运的是,这些包装类型自带了许多辅助方法,也称为组合子,允许你轻松地操作内部值。

这些是泛型方法,根据使用情况有很多种。一些方法作用于成功值,例如 Ok(T)/Some(T),而另一些方法作用于失败值,例如 Err(E)/None。一些方法会展开并提取内部值,而另一些方法则只修改内部值,保留包装类型的结构。

注意:在本节中,当我们谈论成功值时,通常指的是 Ok(T)/Some(T) 变体,当我们谈论失败值时,通常指的是 Err(T)/None 变体。

常见组合子

让我们看看 OptionResult 类型都可用的一些有用的组合子:

map:此方法允许你将成功值 T 转换为另一个值 U。以下是对 Option 类型的 map 方法的类型签名:

pub fn map<U, F>(self, f: F) -> Option<U>
where F: FnOnce(T) -> U {
    match self {
        Some(x) => Some(f(x)),
        None => None,
    }
}

以下是对 Result 类型的签名:

pub fn map<U, F>(self, f: F) -> Option<U>
where F: FnOnce(T) -> U {
    match self {
        Ok(t) => Ok(f(t)),
        Err(e) => Err(e)
    }
}

这种方法的类型签名可以读作如下:map 是一个在 UF 上的泛型方法,并且按值接收 self。然后它接收一个参数 f,其类型为 F,并返回一个 Option<U>,其中 F 被限制在 FnOnce 特性中,该特性有一个输入参数 T 和一个返回类型 U。哇!这真是一大堆话。

让我们使这个概念更容易理解。关于map方法,有两部分需要理解。首先,它接受一个名为self的参数,这意味着调用此方法时,所调用的值会被消耗。其次,它接受一个类型为F的参数,这是一个提供给map的闭包,它告诉map如何将T转换为U。闭包被泛型表示为F,而where子句说明FFnOnce(T) -> U。这是一种仅适用于闭包的特殊类型特质,因此具有类似于(T) -> U签名的函数。FnOnce前缀仅仅意味着这个闭包会拥有输入参数T的所有权,这意味着我们只能用T作为参数调用这个闭包一次,因为T在调用时会被消耗。我们将在第七章,高级概念中更深入地探讨闭包。如果值是一个失败值,map方法不会做任何事情。

使用组合子

使用map方法很简单:

// using_map.rs

fn get_nth(items: &Vec<usize>, nth: usize) -> Option<usize> {
    if nth < items.len() {
        Some(items[nth])
    } else {
        None
    }
}

fn double(val: usize) -> usize {
    val * val
}

fn main() {
    let items = vec![7, 6, 4, 3, 5, 3, 10, 3, 2, 4];
    println!("{}", items.len());
    let doubled = get_nth(&items, 4).map(double);
    println!("{:?}", doubled);
}

在前面的代码中,我们有一个名为get_nth的方法,它从Vec<usize>中给出第nth个元素,如果找不到则返回None。然后我们有一个用例,我们想要将值加倍。我们可以使用map方法对get_nth的返回值进行操作,传入我们之前定义的double函数。或者,我们也可以提供一个内联编写的闭包,如下所示:

let doubled = get_nth(&items, 10).map(|v| v * v);

这是一种非常简洁的链式操作方法!这比使用matchif let更简洁。

前面对map方法的解释在很大程度上适用于我们将要查看的下一组方法,因此我们将跳过解释它们的类型签名,因为逐个解释它们对我们来说会太嘈杂。相反,我们只需简要解释这些方法提供的功能。我们鼓励您通过参考它们的文档来阅读并熟悉它们的类型签名:

  • map_err:此方法仅作用于Result类型,并允许将失败值从E转换为其他类型H,但仅当值是Err值时。map_err未定义在Option类型上,因为对None做任何事情都是没有意义的。

  • and_then:在失败值的情况下,这个方法会原样返回值,但在成功值的情况下,它会接受一个闭包作为第二个参数,该闭包作用于包装的值并返回包装的类型。当你需要依次对内部值进行转换时,这很有用。

  • unwrap_or:此方法提取内部成功值,如果它是一个失败值,则返回一个默认值。您将其作为第二个参数提供。

  • unwrap_or_else:此方法与前面的方法作用相同,但在失败值的情况下,通过接受一个闭包作为第二个参数来计算不同的值。

  • as_ref: 此方法将内部值转换为引用并返回包装值,即 Option<&T>Result<&T, &E>

  • or/ or_else: 这些方法如果是一个成功值,则按原样返回值,或者返回一个作为第二个参数提供的替代 Ok/Some 值。or_else 接受一个闭包,在其中您需要返回一个成功值。

  • as_mut: 此方法将内部值转换为可变引用并返回包装值,即 Option<&mut T>Result<&mut T, &mut E>

还有许多是 OptionResult 类型特有的。

转换 Option 和 Result 之间

我们还有可以将一种包装类型转换为另一种类型的方法,这取决于您如何使用您的 API 组合这些值。在以下情况下,它们变得非常有用,即当我们与第三方 crate 交互时,我们有一个作为 Option 的值,但我们使用的 crate 的方法接受一个作为类型的 Result,如下所示:

  • ok_or: 此方法通过接受一个错误值作为第二个参数,将 Option 值转换为 Result 值。与此类似的一个变体是 ok_or_else 方法,应该优先选择此方法,因为它通过接受一个闭包来懒计算值。

  • ok: 此方法将一个 Result 转换为一个 Option,消耗 self,并丢弃 Err 值。

提前返回和 ? 操作符

这是我们与 Result 类型交互时相当常见的一种模式。该模式如下:当我们有一个成功值时,我们立即想要提取它,但当我们有一个错误值时,我们想要提前返回并传播错误给调用者。为了说明这种模式,我们将使用以下代码片段,它使用通常的 match 表达式来对 Result 类型进行操作:

// result_common_pattern.rs

use std::string::FromUtf8Error;

fn str_upper_match(str: Vec<u8>) -> Result<String, FromUtf8Error> { 
    let ret = match String::from_utf8(str) { 
        Ok(str) => str.to_uppercase(), 
        Err(err) => return Err(err) 
    }; 

    println!("Conversion succeeded: {}", ret); 
    Ok(ret) 
}

fn main() {
    let invalid_str = str_upper_match(vec![197, 198]);
    println!("{:?}", invalid_str);
}

? 操作符抽象了这种模式,使得能够以更简洁的方式编写 bytes_to_str 方法:

// using_question_operator.rs

use std::string::FromUtf8Error;

fn str_upper_concise(str: Vec<u8>) -> Result<String, FromUtf8Error> { 
    let ret = String::from_utf8(str).map(|s| s.to_uppercase())?;
    println!("Conversion succeeded: {}", ret);
    Ok(ret) 
}

fn main() {
    let valid_str = str_upper_concise(vec![121, 97, 89]);
    println!("{:?}", valid_str);
}

如果您有一系列返回 Result/Option 的方法调用,其中每个操作符的失败应意味着整个操作失败,则此操作符会变得更加优雅。例如,我们可以将创建文件并将其写入的操作整个写为如下:

let _ = File::create("foo.txt")?.write_all(b"Hello world!")?;

它几乎可以替代 try! 宏,它执行与 ? 在编译器中实现之前相同的事情。现在,? 是它的替代品,但有一些计划使其更通用,并可用于其他情况。

技巧提示main 函数还允许您返回 Result 类型。具体来说,它允许您返回实现 Termination 特性的类型。这意味着我们也可以将 main 写为如下:

// main_result.rs

fn main() -> Result<(), &'static str> {
    let s = vec!["apple", "mango", "banana"];
    let fourth = s.get(4).ok_or("I got only 3 fruits")?;
    Ok(())
}

接下来,让我们继续处理不可恢复的错误。

不可恢复的错误

当处于执行阶段的代码遇到错误或其变体被违反时,如果被忽略,它有可能以意想不到的方式破坏程序状态。由于它们不一致的程序状态,这些情况被认为是不可恢复的,因为这可能导致后续出现错误的输出或意外行为。这意味着从这些情况中恢复的最佳方法是采用 fail-stop 方法,以避免间接损害其他部分或系统。对于这类情况,Rust 为我们提供了一个称为panic的机制,它会在被调用的线程上终止线程,而不会影响任何其他线程。如果主线程是面临恐慌的那个,则程序会以非零退出代码101终止。如果是子线程,恐慌不会传播到父线程,而是在线程边界处停止。一个线程中的 panic 不会影响其他线程,并且是隔离的,除非它们在某个共享数据上的互斥锁上造成破坏;它是由相同的panic!机制实现的宏。

当调用panic!时,引发恐慌的线程开始从它被调用的地方开始回溯函数调用栈,一直到线程的入口点。它还会为在此过程中调用的所有函数生成堆栈跟踪或回溯,就像异常一样。但在这个情况下,它不需要寻找任何异常处理器,因为在 Rust 中它们不存在。回溯是在清理或释放资源的同时向上移动函数调用链的过程。这些资源可以是栈分配的或堆分配的。栈分配的资源一旦函数结束就会自动释放。对于指向堆分配资源的变量,Rust 会调用它们的drop方法,从而释放资源占用的内存。这种清理是必要的,以避免内存泄漏。除了显式调用panic的代码外,Result/Option错误类型也会在失败的值上调用panic,即Err/Nonepanic也是用于单元测试中失败断言的选择,并且鼓励使用#[should_panic]属性通过 panic 来失败测试。

对于在主线程上发生恐慌的单线程代码,回溯并不提供太多好处,因为操作系统在进程终止后会回收所有内存。幸运的是,有选项可以关闭panic中的回溯,这在嵌入式系统等平台上可能是必需的,在这些平台上,我们有一个主线程执行所有工作,而回溯是一个昂贵的操作,并且用处不大。

为了找出导致恐慌的调用序列,我们可以通过运行任何引发恐慌的程序并从我们的命令行 shell 设置RUST_BACKTRACE=1环境变量来查看线程的回溯。以下是一个例子,其中我们有两个线程,它们都发生了恐慌:

// panic_unwinding.rs

use std::thread;

fn alice() -> thread::JoinHandle<()> {
    thread::spawn(move || {
        bob();
    })
}

fn bob() {
    malice();
}

fn malice() {
    panic!("malice is panicking!");
}

fn main() {
    let child = alice();
    let _ = child.join();

    bob();
    println!("This is unreachable code");
}

alice使用thread::spawn创建一个新的线程,并在闭包中调用bobbob调用malice,然后引发 panic。main也调用bob,它也会引发 panic。

下面是运行此程序后的输出:

图片

我们通过调用join()来连接线程,并期望子线程一切顺利,这显然不是事实。我们得到了两个回溯,一个是子线程中发生的 panic,另一个是从主线程调用bob时的回溯。

如果你需要更细粒度地控制线程中 panic 的展开处理,可以使用std::panic::catch_unwind函数。尽管推荐通过Option/Result机制来处理错误,但你可以在工作线程中处理致命错误;你可以通过恢复任何违反的不变量,让工作线程死亡,然后重新启动它们。然而,catch_unwind并不能阻止 panic——它只允许你自定义与 panic 相关的展开行为。在 Rust 程序中,不建议使用catch_unwind作为一般的错误处理方法。

catch_unwind函数接受一个闭包并处理其中发生的任何 panic。以下是它的类型签名:

fn catch_unwind<F: FnOnce() -> R + UnwindSafe, R>(f: F) -> Result<R> 

如你所见,catch_unwind的返回值有一个额外的约束,UnwindSafe。这意味着闭包中的变量必须是异常安全的,大多数类型都是,但值得注意的是可变引用(&mut T)。一个值是异常安全的,如果抛出异常的代码不能导致该值处于不一致的状态。这意味着闭包内的代码不能自身调用panic!()

下面是一个使用catch_unwind的简单示例:

// catch_unwind.rs

use std::panic; 

fn main() { 
    panic::catch_unwind(|| { 
        panic!("Panicking!"); 
    }).ok();

    println!("Survived that panic."); 
}

下面是运行前面程序后的输出:

图片

如你所见,catch_unwind并不能阻止 panic 的发生;它只是停止与 panic 线程相关的展开。再次提醒,catch_unwind不是 Rust 中错误管理的推荐方法。它不能保证捕获所有 panic,例如终止程序的 panic。在 Rust 代码与其他语言(如 C)通信的情况下,需要捕获 panic 展开,因为向 C 代码展开是未定义行为。在这些情况下,程序员必须处理展开并按照 C 的期望返回一个错误代码。然后程序可以通过使用同一panic模块中的resume_unwind函数来继续展开。

对于极少数情况下,默认的 panic 展开行为可能变得过于昂贵,例如在编写微控制器程序时,有一个编译器标志可以配置为将所有 panic 转换为 abort。为此,你的项目的Cargo.toml需要在profile.release部分下有如下属性:

[profile.release]
panic = "abort"

用户友好的 panic

正如我们在前面的代码中所看到的,panic 消息和回溯可能非常晦涩难懂,但并不一定如此。如果你是一个命令行工具的作者,human_panic 是社区中的一个 crate,它用人类可读的消息替换了冗长、晦涩的 panic 消息。它还将回溯写入文件,以便用户将其报告给工具作者。有关 human_panic 的更多信息可以在项目仓库页面上找到:github.com/rust-clique/human-panic

自定义错误和 Error 特性

一个具有多种功能的非平凡项目通常会被分散到多个模块中。有组织地提供针对特定模块的错误消息和信息对用户来说更有信息量。Rust 允许我们创建自定义错误类型,这可以帮助我们从应用程序中获得更细粒度的错误报告。如果没有针对我们项目的特定自定义错误,我们可能不得不使用标准库中的现有错误类型,这些类型可能与我们 API 的操作不相关,并且当我们的模块中的操作出错时,不会向用户提供精确的信息。

在具有异常的语言中,例如 Java,创建自定义异常的方式是通过从基类 Exception 继承并重写其方法和成员变量。虽然 Rust 没有类型级别的继承,但它有特性继承,并提供给我们 Error 特性,任何类型都可以实现,使得该类型成为自定义错误类型。现在,当使用 Box<dyn Error> 作为返回 Result 的函数的返回类型时,可以将这种类型与现有的标准库错误类型组合。以下是 Error 特性的类型签名:

pub trait Error: Debug + Display {
    fn description(&self) -> &str { ... }
    fn cause(&self) -> Option<&dyn Error> { ... }
}

要创建我们自己的错误类型,该类型必须实现 Error 特性。如果我们查看特性的定义,它还要求我们为我们的类型实现 DebugDisplay 特性。description 方法返回一个字符串切片引用,这是一个描述错误内容的可读形式。cause 方法返回一个可选的 Error 特性对象引用,表示错误的一个可能低级原因。自定义错误类型的 cause 方法允许你从源头获取错误链的信息,使得精确记录错误成为可能。例如,让我们以一个 HTTP 查询作为可失败操作的例子。我们假设的库有一个 get 方法可以执行 GET 请求。查询可能会因为很多不同的原因而失败:

  • DNS 查询可能会因为网络故障或地址不正确而失败

  • 实际的数据包传输可能会失败

  • 数据可能被正确接收,但接收到的 HTTP 标头可能有错误,等等

如果是第一种情况,我们可能会想象有三个错误级别,通过 cause 字段链接在一起:

  • UDP 连接因网络故障而失败(cause = None

  • DNS 查询因 UDP 连接失败而失败(cause = UDPError

  • GET 查询因 DNS 查询失败而失败(cause = DNSError

当开发者想要知道失败的根源时,cause 方法非常有用。

现在,为了演示如何在项目中集成自定义错误类型,我们使用 cargo 创建了一个名为 todolist_parser 的 crate,该 crate 提供了一个 API,用于从文本文件中解析待办事项列表。待办事项的解析可能会以不同的方式失败,例如文件未找到、待办事项为空,或者因为它包含非文本字符。我们将使用自定义错误类型来模拟这些情况。在 src/error.rs 中,我们定义了以下错误类型:

// todolist_parser/src/error.rs

use std::error::Error;
use std::fmt;
use std::fmt::Display;

#[derive(Debug)]
pub enum ParseErr {
    Malformed,
    Empty
}

#[derive(Debug)]
pub struct ReadErr {
    pub child_err: Box<dyn Error>
}

// Required by error trait
impl Display for ReadErr {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Failed reading todo file")
    }
}

// Required by error trait
impl Display for ParseErr {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Todo list parsing failed")
    }
}

impl Error for ReadErr {
    fn description(&self) -> &str {
        "Todolist read failed: "
    }

    fn cause(&self) -> Option<&dyn Error> {
        Some(&*self.child_err)
    }
}

impl Error for ParseErr {
    fn description(&self) -> &str {
        "Todolist parse failed: "
    }

    fn cause(&self) -> Option<&Error> {
        None
    }
}

到目前为止,我们正在模拟两种非常基本的错误:

  • 将待办事项列表建模为 ReadErr 并读取失败

  • 将待办事项建模为 ParseErr 并解析失败,ParseErr 有两个变体,它可能因文件为空或文件包含非文本/二进制符号而失败,这意味着它是 Malformed

在此之后,我们实现了 Error 特性和所需的超特性 DisplayDebuglib.rs 包含所需的解析方法,以及 TodoList 结构体的声明,如下所示:

// todolist_parser/src/lib.rs

//! This crate provides an API to parse list of todos

use std::fs::read_to_string;
use std::path::Path;

mod error;
use error::ParseErr;
use error::ReadErr;

use std::error::Error;

/// This struct contains a list of todos parsed as a Vec<String>
#[derive(Debug)]
pub struct TodoList {
    tasks: Vec<String>,
}

impl TodoList {
    pub fn get_todos<P>(path: P) -> Result<TodoList, Box<dyn Error>>
    where
    P: AsRef<Path>, {
        let read_todos: Result<String, Box<dyn Error>> = read_todos(path);
        let parsed_todos = parse_todos(&read_todos?)?;
        Ok(parsed_todos)
    }
}

pub fn read_todos<P>(path: P) -> Result<String, Box<dyn Error>>
where
    P: AsRef<Path>,
{
    let raw_todos = read_to_string(path)
        .map_err(|e| ReadErr {
            child_err: Box::new(e),
        })?;
    Ok(raw_todos)
}

pub fn parse_todos(todo_str: &str) -> Result<TodoList, Box<dyn Error>> {
    let mut tasks: Vec<String> = vec![];
    for i in todo_str.lines() {
        tasks.push(i.to_string());
    }
    if tasks.is_empty() {
        Err(ParseErr::Empty.into())
    } else {
        Ok(TodoList { tasks })
    }
}

我们有两个顶级函数,read_todosparse_todos,它们通过 TodoListget_todos 方法被调用。

examples/basics.rs 下,我们有 TodoList 的一个示例用法,如下所示:

// todolist_parser/examples/basics.rs

extern crate todolist_parser;

use todolist_parser::TodoList;

fn main() {
    let todos = TodoList::get_todos("examples/todos");
    match todos {
        Ok(list) => println!("{:?}", list),
        Err(e) => {
            println!("{}", e.description());
            println!("{:?}", e)
        }
    }
}

如果我们通过 cargo run --example basics 命令运行 basics.rs 示例,我们得到以下输出:

如果你查看被打印的错误值,它将实际的错误原因包装在 ReadErr 值中。

Rust 提供了相当不错的内置功能来定义自定义错误类型。如果你正在编写自己的 crate,你应该定义自己的错误类型,以便更容易进行调试。然而,为所有类型实现 Error 特性通常会变得冗余且耗时。幸运的是,我们有一个来自 Rust 社区的 crate,名为 failure (github.com/rust-lang-nursery/failure),它可以自动化自定义错误类型的创建,以及通过使用过程宏自动派生的必要特性的实现。如果你更有雄心,我们鼓励你重构这个库以使用 failure crate。

摘要

在本章中,我们了解到,Rust 中的错误处理是显式的:可能失败的运算通过 ResultOption 泛型类型返回两个部分的结果。你必须以某种方式处理错误,要么通过使用 match 语句解包 Result/Option 值,要么使用组合方法。应避免在错误类型上解包。相反,使用组合器或 match 表达式采取适当的行动,或者通过使用 ? 操作符将错误传播给调用者。当编程错误如此致命以至于恢复成为不可能时,进行恐慌是可以接受的。恐慌大多是不可恢复的,这意味着它们会崩溃你的线程。它们的默认行为是回溯,这可能很昂贵,如果程序不想有这种开销,可以将其关闭。建议在传达错误时尽可能详细,并鼓励作者在他们的 crate 中使用自定义错误类型。

在下一章中,我们将介绍语言的一些高级特性,并探索类型系统的更多内部机制。

第七章:高级概念

在前几章中我们学到的许多概念确实值得密切关注,这样我们才能欣赏 Rust 的设计。学习这些高级主题也将帮助你在需要理解复杂代码库时更进一步。这些概念在你想要创建提供惯用 Rust API 的库时也非常有用。

本章我们将涵盖以下主题:

  • 类型系统小贴士

  • 字符串

  • 迭代器

  • 闭包

  • 模块

类型系统小贴士

"算法必须被看到才能被相信"

唐纳德·克努特

在我们进入本章更密集的主题之前,我们将首先讨论静态类型编程语言中的一些类型系统小贴士,重点关注 Rust。其中一些主题你可能已经从 第一章 Rust 入门 中熟悉,但我们将在这里深入探讨细节。

块和表达式

尽管 Rust 是语句和表达式的混合体,但它主要是一种面向表达式的语言。这意味着大多数结构都是返回值的表达式。它也是一种使用类似 C 的花括号 {} 来在程序中为变量引入新作用域的语言。在我们更深入地讨论这些概念之前,让我们先把这些概念弄清楚。

块表达式(以下简称块)是任何以 { 开始并以 } 结束的项。在 Rust 中,它们包括 if else 表达式、match 表达式、while 循环、循环、裸 {} 块、函数、方法和闭包,并且它们都返回一个值,即表达式的最后一行。如果你在最后一行表达式后放置一个分号,块表达式默认返回单位类型 () 的值。

与块相关的一个概念是作用域。每当创建一个新的块时,都会引入一个作用域。当我们创建一个新的块并在其中创建任何变量绑定时,这些绑定被限制在该作用域内,并且对它们的引用仅在作用域范围内有效。这就像为变量提供了一个新的生活环境,与其他变量隔离开来。在 Rust 中,函数、impl 块、裸块、if else 表达式、match 表达式、函数和闭包等项引入了新的作用域。在块/作用域内,我们可以声明结构体、枚举、模块、特性和它们的实现,甚至块。每个 Rust 程序都以一个根作用域开始,这是由 main 函数引入的作用域。在其中,可以创建许多嵌套的作用域。main 作用域成为所有内部声明的父作用域。考虑以下片段:

// scopes.rs

fn main() {
    let mut b = 4;
    {
        let mut a = 34 + b;
        a += 1;
    }

    b = a;   
}

我们使用裸块{}引入一个新的内部作用域,并创建了一个变量a。在作用域结束时,我们试图将b赋值给来自内部作用域的a的值。Rust 会在编译时抛出一个错误,说“在这个作用域中找不到值a”。main的父作用域对a一无所知,因为它来自内部作用域。这种作用域的特性有时也用于控制我们希望引用保持有效的时长,正如我们在第五章,“内存管理和安全性”中看到的。

但是内部作用域可以访问其父作用域中的值。正因为如此,我们可以在内部作用域中编写34 + b

现在我们来谈谈表达式。我们可以从它们返回值的属性以及它们在所有分支中都必须具有相同类型的属性中受益。这导致代码非常简洁。例如,考虑以下片段:

// block_expr.rs

fn main() {
    // using bare blocks to do multiple things at once
    let precompute = {
        let a = (-34i64).abs();
        let b = 345i64.pow(3);
        let c = 3;
        a + b + c
    };

    // match expressions
    let result_msg = match precompute {
        42 => "done",
        a if a % 2 == 0 => "continue",
        _ => panic!("Oh no !")
    };

    println!("{}", result_msg);
}

我们可以使用裸块将几行代码组合在一起,并在末尾隐式返回a + b + c表达式的值到precompute,如前所述。匹配表达式也可以直接从其匹配分支中赋值和返回值。

注意:与 C 语言中的switch语句类似,Rust 中的匹配分支不会受到 C 代码中导致大量错误的case fall through副作用的影响。

C 语言的switch情况要求在switch块中的每个case语句在执行该case中的代码后退出时都必须有一个break。如果没有break,那么随后的任何case语句也会被执行,这被称为 fall-through 行为。另一方面,匹配表达式保证只评估一个匹配分支。

If else表达式提供了相同的简洁性:

// if_expr.rs

fn compute(i: i32) -> i32 {
    2 * i
}

fn main() {
    let result_msg = "done";

    // if expression assignments
    let result = if result_msg == "done" {
        let some_work = compute(8);
        let stuff = compute(4);
        compute(2) + stuff // last expression gets assigned to result
    } else {
        compute(1)
    };

    println!("{}", result);
}

在基于语句的语言如 Python*中,你会为前面的片段编写如下内容:

result = None
if (state == "continue"):
    let stuff = work()
    result = compute_next_result() + stuff
else:
    result = compute_last_result()

在 Python 代码中,我们必须先声明result,然后在 if else 分支中进行单独的赋值。在这里,Rust 更简洁,赋值是 if else 表达式的结果。此外,在 Python 中,你可能会忘记在任一分支中为变量赋值,变量可能被未初始化。如果你从if块返回并赋值,而else块中遗漏或返回了不同的类型,Rust 将在编译时报告。

作为附加说明,Rust 还支持声明未初始化的变量:

fn main() {
    let mut a: i32;
    println!("{:?}", a);    // error
    a = 23;
    println!("{:?}", a);    // fine now
}

但它们在使用之前必须被初始化。如果尝试从后来读取未初始化的变量,Rust 将禁止这样做,并在编译时报告变量必须被初始化:

   Compiling playground v0.0.1 (file:///playground)
error[E0381]: use of possibly uninitialized variable: `a`
 --> src/main.rs:7:22
  |
7 |     println!("{:?}", a);
  |                      ^ use of possibly uninitialized `a`

令牌语句

在第一章《Rust 入门》中,我们简要介绍了let,它用于创建新的变量绑定——但实际上let不仅仅是这样。实际上,let是一个模式匹配语句。模式匹配是一种在函数式语言(如 Haskell)中主要看到的构造,它允许我们根据值的内部结构来操作和做出决策,或者可以用来从代数数据类型中提取值。我们之前已经

let a = 23;
let mut b = 403;

我们的第一行是let的最简单形式,它声明了一个不可变的变量绑定,a。在第二行,我们在let关键字后面有mut用于bmutlet模式的一部分,在这个例子中将b可变地绑定到i32类型。mut使得b可以再次绑定到其他i32类型。另一个在let中较少见的关键字是ref关键字。现在,我们通常使用&运算符来创建任何值的引用/指针。创建任何值的引用的另一种方式是使用letref关键字。为了说明refmut,我们有一个代码片段:

// let_ref_mut.rs

#[derive(Debug)]
struct Items(u32);

fn main() {
    let items = Items(2);
    let items_ptr = &items;
    let ref items_ref = items;

    assert_eq!(items_ptr as *const Items, items_ref as *const Items);

    let mut a = Items(20);
    // using scope to limit the mutation of `a` within this block by b
    {
        // can take a mutable reference like this too
        let ref mut b = a; // same as: let b = &mut a;
        b.0 += 25;
    }

    println!("{:?}", items);

    println!("{:?}", a);   // without the above scope
                           // this does not compile. Try removing the scope
}

在这里,items_ref是通过常用的&运算符创建的引用。下一行也使用ref创建了指向相同items值的items_ref引用。我们可以通过随后的assert_eq!调用确认,这两个指针变量指向相同的items值。将*const Items转换为原始指针类型用于比较两个指针是否指向相同的内存位置,其中*const ItemsItems的原始指针类型。此外,通过将refmut结合,如代码的第二行末尾所示,我们可以得到对任何所有权的可变引用,而不仅仅是使用&mut运算符的常规方式。但是,我们必须使用内部作用域来从b修改a

使用模式匹配的语言不仅限于在=的左侧有标识符,还可以有引用类型结构的模式。所以,let为我们提供的另一个便利是能够从代数数据类型的字段中提取值,例如结构体或枚举作为新变量。这里,我们有一个代码片段来演示这一点:

// destructure_struct.rs

enum Food {
    Pizza,
    Salad
}

enum PaymentMode {
    Bitcoin,
    Credit
}

struct Order {
    count: u8,
    item: Food,
    payment: PaymentMode
}

fn main() {
    let food_order = Order { count: 2,
                             item: Food::Salad,
                             payment: PaymentMode::Credit };

    // let can pattern match inner fields into new variables
    let Order { count, item, .. } = food_order;
}

在这里,我们创建了一个 Order 的实例,它绑定到 food_order。假设我们通过某个方法调用得到了 food_order,并且我们想要访问 countitem 的值。我们可以直接使用 let 提取单个字段,countitem,它们成为新的变量,持有 Order 实例中相应的字段值。这从技术上讲称为 let 的解构语法。变量的解构方式取决于右侧的值是一个不可变引用、可变引用、所有者值,或者我们如何使用 refmut 模式在左侧引用它。在前面的代码中,它被值捕获,因为 food_order 拥有 Order 实例,并且我们在左侧没有使用任何 refmut 关键字匹配成员。如果我们想要通过不可变引用解构成员,我们可以在 food_order 前面放置一个 & 符号,或者使用 refmut 代替:

let Order { count, item, .. } = &food_order;
// or
let Order { ref count, ref item, .. } = food_order;

第一种风格通常更受欢迎,因为它更简洁。如果我们想要有一个可变引用,我们必须在将 food_order 本身设置为可变之后放置 &mut

let mut food_order = Foo { count: 2,
                           item: Food::Salad,
                           payment: PaymentMode::Credit };
let Order { count, item, .. } = &mut food_order;

我们可以通过使用 .. 来忽略我们不关心的字段,如代码所示。此外,let 解构的一个轻微限制是我们不能自由选择单个字段的可变性。所有变量都必须具有相同的可变性——要么全部不可变,要么全部可变。请注意,ref 通常不用于声明变量绑定,它主要在匹配表达式中使用,在这些表达式中我们想要通过引用匹配值,因为 & 操作符在匹配分支中不起作用,如这里所示:

// match_ref.rs

struct Person(String);

fn main() {
    let a = Person("Richard Feynman".to_string());
    match a {
        Person(&name) => println!("{} was a great physicist !", name),
         _ => panic!("Oh no !")
    }

    let b = a;
}

如果我们想要通过不可变引用使用 Person 结构体的内部值,我们的直觉可能会说在匹配分支中使用类似 Person(&name) 的方式来通过引用进行匹配。但在编译时我们得到了这个错误:

这导致了一个误导性的错误,因为 &name 是从 name 中创建了一个引用(& 是一个操作符),编译器认为我们想要匹配 Person(&String),但实际上 a 的值是 Person(String)。因此,在这种情况下,必须使用 ref 来将其解构为引用。为了使其编译通过,我们相应地将左侧的代码更改为 Person(ref name)

解构语法也适用于枚举类型:

// destructure_enum.rs

enum Container {
    Item(u64),
    Empty
}

fn main() {
    let maybe_item = Container::Item(0u64);
    let has_item = if let Container::Item(0) = maybe_item {
        true
    } else {
        false
    };
}

在这里,我们有 maybe_item 作为 Container 枚举。通过结合 if let 和模式匹配,我们可以使用 if let <解构模式> = 表达式 {} 语法有条件地将值赋给 has_item 变量。

解构语法也可以用在函数参数中。例如,在自定义类型的情况下,如一个结构体在作为函数参数使用时:

// destructure_func_param.rs

struct Container {
    items_count: u32
}

fn increment_item(Container {mut items_count}: &mut Container) {
    items_count += 1;
}

fn calculate_cost(Container {items_count}: &Container) -> u32 {
    let rate = 67;
    rate * items_count
}

fn main() {
    let mut container = Container {
        items_count: 10
    };

    increment_item(&mut container);
    let total_cost = calculate_cost(&container);
    println!("Total cost: {}", total_cost);
}

在这里,calculate_cost函数有一个参数,它被解构为一个结构体,字段绑定到items_count变量。如果我们想要可变地解构,我们可以在成员字段之前添加mut关键字,就像increment_item函数那样。

可反驳模式:可反驳模式是let模式,其中左右两侧在模式匹配中不兼容,在这些情况下,必须使用穷尽匹配表达式。到目前为止,我们看到的let模式的所有形式都是不可反驳模式。不可反驳意味着它们能够作为有效的模式正确匹配'='右侧的值。

但有时,由于无效的模式,使用let进行模式匹配可能会失败,例如,当匹配具有两个变体(Item(u64)Empty)的枚举Container时:

// refutable_pattern.rs

enum Container {
    Item(u64),
    Empty
}

fn main() {
    let mut item = Container::Item(56);
    let Container::Item(it) = item;
}

理想情况下,我们期望it在从item解构后存储56作为值。如果我们尝试编译这个,我们会得到以下:

这个匹配失败的原因是因为Container有两个变体,Item(u64)Empty。即使我们知道item包含Item变体,let模式也不能依赖这个事实,因为如果item是可变的,一些代码可以在之后将其分配为Empty变体,这将使解构成为未定义的操作。我们必须涵盖所有可能的情况。直接针对单个变体的解构违反了穷尽模式匹配的语义,因此我们的匹配失败。

循环作为表达式

在 Rust 中,循环也是一个表达式,当我们从中跳出时默认返回()。这个含义是loop也可以用来通过break给变量赋值。例如,它可以用于类似以下的情况:

// loop_expr.rs

fn main() {
    let mut i = 0;
    let counter = loop {
        i += 1;
        if i == 10 {
            break i;
        }
    };
    println!("{}", counter);
}

break关键字之后,我们包括我们想要返回的值,并且当循环结束时(如果有的话),这个值会被分配给counter变量。这在循环结束后在循环内部赋值任何变量的值并需要在之后使用它的情况下非常有用。

数字类型的类型清晰度和符号区分

虽然主流语言区分了诸如整数、双精度浮点数和字节等数值原语,但许多新的语言,如 Golang,已经开始在有符号和无符号数值类型之间添加区分。Rust 也遵循同样的步伐,通过区分有符号和无符号数值类型,将它们作为完全不同的类型提供。从类型检查的角度来看,这为我们程序增加了另一层安全性。这允许我们编写精确指定其要求的代码。例如,考虑一个数据库连接池结构体:

struct ConnectionPool {
    pool_count: usize
}

对于提供包含有符号和无符号值的通用整数类型的语言,你会指定 pool_count 的类型为整数,它也可以存储负值。pool_count 为负值没有意义。使用 Rust,我们可以通过使用无符号类型(如 u32usize)在代码中清楚地指定这一点。

关于原始类型,还有一个需要注意的方面是,Rust 在算术操作中混合有符号和无符号类型时不会执行自动转换。你必须明确这一点并手动转换值。C/C++ 中一个未预期的自动转换的例子如下:

#include <iostream>
int main(int argc, const char * argv[]) {
    uint foo = 5;
    int bar = 6;
    auto difference = foo - bar;
    std::cout << difference;
    return 0;
}

上述代码打印 4294967295。在这里,从 foobar 减去时,差异不会是 -1;相反,C++ 会自行其是,而不需要程序员的同意。int(有符号整数)自动转换为 uint(无符号整数),并包装到 uint 的最大值 4294967295。这段代码在这里继续运行而不会抱怨下溢。

在 Rust 中翻译相同的程序,我们得到以下结果:

// safe_arithmetic.rs

fn main() {
    let foo: u32 = 5;
    let bar: i32 = 6;
    let difference = foo - bar;
  println!("{}", difference);
}

以下将是输出结果:

Rust 不会编译这段代码,会显示错误信息。你必须根据你的意图显式地转换其中一个值。此外,如果我们对两个无符号或有符号类型执行溢出/下溢操作,Rust 将在 debug 模式下 panic!() 并终止你的程序。在发布模式下构建时,它执行包装算术。

通过包装算术,我们是指将 1 加到 255(一个 u8)将得到 0

在调试模式下引发恐慌是正确的行为,因为如果允许这样的任意值传播到代码的其他部分,它们可能会污染你的业务逻辑,并在程序中引入更难以追踪的 bug。因此,在这些情况下,当用户意外执行溢出/下溢操作并且这在调试模式下被捕获时,使用失败停止的方法更好。当程序员想要在算术操作中允许包装语义时,他们可以选择忽略恐慌并继续在发布模式下编译。这是语言为你提供的安全性的另一个方面。

类型推断

类型推断在静态类型语言中很有用,因为它使得代码更容易编写、维护和重构。Rust 的类型系统可以在你不指定它们的情况下确定字段、方法、局部变量和大多数泛型类型参数的类型。在底层,编译器的一个组件,即类型检查器,使用 Hindley Milner 类型推断算法来决定局部变量的类型。它是一组基于表达式使用情况建立类型的规则。因此,它可以基于环境和类型的使用方式推断类型。以下是一个这样的例子:

let mut v = vec![];
v.push(2);    // can figure type of `v` now to be of Vec<i32>

只有在初始化向量的第一行中,Rust 的类型检查器不确定v的类型应该是什么。只有当它到达下一行,v.push(2)时,它才知道v的类型是Vec<i32>。现在v的类型被固定为Vec<i32>

如果我们添加另一行,v.push(2.4f32);,那么编译器将因为类型不匹配而报错,因为它已经从上一行推断出它应该是Vec<i32>类型。但有时,类型检查器无法在复杂情况下推断出变量的类型。但是,通过程序员的帮助,类型检查器能够推断类型。例如,对于下一个片段,我们读取一个名为foo.txt的文件,其中包含一些文本,并以字节的形式读取它:

// type_inference_iterator.rs

use std::fs::File;
use std::io::Read;

fn main() {
    let file = File::open("foo.txt").unwrap();
    let bytes = file.bytes().collect();
}

编译这个代码会给我们这个错误:

错误图

迭代器上的collect方法基本上是一个aggregator方法。我们将在本章后面讨论迭代器。它收集到的结果类型可以是任何集合类型。它可以是LinkedListVecDequeVec。Rust 不知道程序员意图是什么,由于这种歧义,它需要我们的帮助。我们在main的第二行做了以下更改:

 let bytes: Vec<Result<u8, _>> = file.bytes().collect();

调用bytes()返回Result<u8, std::io::Error>。在添加一些类型提示以说明要收集到什么(在这里,Vec)之后,程序可以正常编译。注意Result错误变体上的_。对于 Rust 来说,这足以提示我们需要一个u8ResultVec。其余的,它能够自己推断出来——Result中的错误类型需要是std::io::Error类型。它能够推断出来,因为没有这种歧义。它从bytes()方法签名中获取信息。非常聪明!

类型别名

类型别名不是 Rust 独有的特性。C 语言有typedef关键字,而 Kotlin 有typealias关键字用于相同的目的。它们的存在是为了使你的代码更易于阅读,并移除在静态类型语言中经常积累的类型签名冗余,例如,如果你有一个来自你的 crate 的 API,返回一个Result类型,包装一个复杂对象,如下所示:

// type_alias.rs

pub struct ParsedPayload<T> {
    inner: T
}

pub struct ParseError<E> {
    inner: E
}

pub fn parse_payload<T, E>(stream: &[u8]) -> Result<ParsedPayload<T>, ParseError<E>> {
    unimplemented!();
}

fn main() {
    // todo
}

如你所见,对于某些方法,例如parse_payload,类型签名变得太大,无法在一行中显示。而且,每次使用时都必须输入Result<ParsedPayload<T>, ParseError<E>>,这变得很繁琐。如果我们能通过一个更简单的名字来引用这个类型会怎样?这正是类型别名的作用。它们使我们能够给具有复杂类型签名的类型赋予另一个(更简单)的名字。

因此,我们可以给parse_payload的返回类型起一个别名,如下所示:

// added a type alias
type ParserResult<T, E> = Result<ParsedPayload<T>, ParseError<E>>;

// and modify parse_payload function as:
pub fn parse_payload<T, E>(stream: &[u8]) -> ParserResult<T, E> {
    unimplemented!();
}

如果我们以后想更改实际的内部类型,这将使代码更易于管理。我们也可以为任何简单类型创建类型别名:

type MyString = String;

因此,现在我们可以在任何使用String.的地方使用MyString。但这并不意味着MyString是不同类型的。在编译期间,这只是一个替换/展开到原始类型的操作。当为泛型类型创建类型别名时,类型别名也需要一个泛型类型参数(T)。因此,将Vec<Result<Option<T>>>别名为以下内容:

type SomethingComplex<T> = Vec<Result<Option<T>>>;

假设你的类型中有一个生命周期,就像SuperComplexParser<'a>一样:

struct SuperComplexParser<'s> {
    stream: &'a [u8]
}

type Parser<'s> = SuperComplexParser<'s>;

在为它们创建类型别名时,我们还需要指定生命周期,就像Parser类型别名的情况一样。

在这些类型系统的小优点之外,让我们再次谈谈字符串!

字符串

在第一章《Rust 入门》中,我们提到字符串有两种类型。在本节中,我们将更清晰地介绍字符串、它们的特性以及它们与其他语言中字符串的区别。

虽然其他语言在字符串类型上有相当直接的故事,但 Rust 中的String类型是处理起来既复杂又困难的一种类型。正如我们所知,Rust 区分值是在堆上还是在栈上分配的。因此,Rust 中有两种字符串:拥有字符串(String)和借用字符串(&str)。让我们来探讨这两种。

拥有字符串 – String

String类型来自标准库,是一个堆分配的 UTF-8 编码的字节序列。在底层,它们只是Vec<u8>,但具有仅适用于字符串的额外方法。它们是拥有类型,这意味着持有String值的变量是其所有者。你通常会发现在多种方式下可以创建String类型,如下面的代码所示:

// strings.rs

fn main() {
    let a: String = "Hello".to_string();    
    let b = String::from("Hello");
    let c = "World".to_owned();
    let d = c.clone();
}

在前面的代码中,我们以四种不同的方式创建了四个字符串。它们都创建了相同的字符串类型,并且具有相同的性能特征。第一个变量a通过调用to_string方法创建字符串,该方法来自ToString特质,并带有字符串字面量"Hello"。像"Hello"这样的字符串字面量本身也有&str类型。我们将在介绍字符串的借用版本时解释它们。然后,我们通过调用String上的关联方法from创建了另一个字符串b。第三个字符串c是通过调用ToOwned特质中的特质方法to_owned创建的,该特质为&str类型(字面字符串)实现。第四个字符串d是通过克隆现有的字符串c创建的。创建字符串的第四种方式是一个昂贵的操作,我们应该避免它,因为它涉及到通过迭代底层的字节进行复制。

由于 String 是在堆上分配的,它可以被修改并在运行时增长。这意味着在操作字符串时,它们有一个相关的开销,因为它们可能会在添加字节时被重新分配。堆分配是一个相对昂贵的操作,但幸运的是,Vec 的分配方式(容量加倍)意味着这种成本被分摊到使用中。

字符串在标准库中也有许多方便的方法。以下是一些重要的方法:

  • String::new() 分配一个空的 String 类型。

  • String::from(s: &str) 分配一个新的 String 类型,并从字符串切片中填充它。

  • String::with_capacity(capacity: usize) 分配一个具有预分配大小的空 String 类型。当你事先知道字符串的大小的时候,这是高效的。

  • String::from_utf8(vec: Vec<u8>) 尝试从 bytestring 分配一个新的 String 类型。参数的内容必须是 UTF-8,否则将失败。它返回 Result 包装类型。

  • 字符串实例上的 len() 方法会给你 String 类型的长度,考虑到 Unicode。例如,包含单词 String 类型长度为两个,尽管它在内存中占用三个字节。

  • push(ch: char)push_str(string: &str) 方法向字符串中添加一个字符或一个字符串切片。

这当然不是一个详尽的列表。所有操作的完整列表可以在 doc.rust-lang.org/std/string/struct.String.html 找到。

下面是一个使用所有上述方法的示例:

// string_apis.rs

fn main() { 
    let mut empty_string = String::new(); 
    let empty_string_with_capacity = String::with_capacity(50); 
    let string_from_bytestring: String = String::from_utf8(vec![82, 85, 83,
    84]).expect("Creating String from bytestring failed"); 

    println!("Length of the empty string is {}", empty_string.len()); 
    println!("Length of the empty string with capacity is {}",
    empty_string_with_capacity.len()); 
    println!("Length of the string from a bytestring is {}",
    string_from_bytestring.len()); 

    println!("Bytestring says {}", string_from_bytestring); 

    empty_string.push('1'); 
    println!("1) Empty string now contains {}", empty_string); 
    empty_string.push_str("2345"); 
    println!("2) Empty string now contains {}", empty_string); 
    println!("Length of the previously empty string is now {}",
    empty_string.len()); 
} 

在探索了 String 之后,让我们看看字符串的借用版本,即字符串切片或 &str 类型。

借用的字符串 – &str

我们还可以有字符串作为引用,称为字符串切片。这些用 &str 表示(发音为 stir),它是 str 类型的引用。与 String 类型相比,str 是编译器已知的一个内置类型,不是标准库中的东西。字符串切片默认创建为 &str——指向一个 UTF-8 编码的字节序列的指针。我们不能创建和使用裸 str 类型的值,因为它代表一个连续的 UTF-8 编码字节的序列,大小有限但未知。它们在技术上被称为无尺寸类型。我们将在本章后面解释无尺寸类型。

str 只能作为引用类型创建。假设我们尝试通过提供左侧的类型签名强制创建一个 str 类型:

// str_type.rs

fn main() {
    let message: str = "Wait, but why ?";
}

我们会遇到一个令人困惑的错误:

图片

它说:所有局部变量都必须有一个静态已知的大小。这基本上意味着我们使用let语句定义的每个局部变量都需要有一个大小,因为它们是在栈上分配的,而栈的大小是固定的。正如我们所知,所有变量声明要么作为值本身,要么作为指向堆分配类型的指针放在栈上。所有栈分配的值都需要有一个适当的大小已知,因此str无法初始化。

str基本上意味着一个固定大小的字符串序列,它与它所在的位置无关。它可以是堆分配字符串的引用,或者它可以是位于进程数据段上的&'static str字符串,它在整个程序运行期间都存在,这就是'static生命周期所表示的。

然而,我们可以创建一个str的借用版本,例如&str,这是我们在编写字符串字面量时默认创建的。因此,字符串切片仅在指针——&str——的背后被创建和使用。作为一个引用,它们也根据它们所拥有的变量的作用域有不同的生命周期。其中之一是'static生命周期,这是字符串字面量的生命周期。

字符串字面量是你在双引号内声明的任何字符序列。例如,我们这样创建它们:

// borrowed_strings.rs

fn get_str_literal() -> &'static str {
    "from function"
}

fn main() {
    let my_str = "This is borrowed";
    let from_func = get_str_literal();
    println!("{} {}", my_str, from_func);
}

在前面的代码中,我们有一个get_str_literal函数,它返回一个字符串字面量。我们还在main函数中创建了一个字符串字面量my_strmy_strget_str_literal返回的字符串具有类型&'static str'static生命周期注解表示字符串在整个程序运行期间都存在。&前缀表示它是指向字符串字面量的指针,而str是无大小类型。你遇到的任何其他&str类型都是堆上任何拥有String类型的借用字符串切片。一旦创建,&str类型就不能被修改,因为它们默认是不可变的。

我们还可以获取字符串的可变切片,类型变为&mut str,尽管除了标准库中的几个方法之外,很少以这种形式使用它们。&str类型是在传递字符串时推荐的类型,无论是传递给函数还是传递给其他变量。

字符串的切片和切块

Rust 中的所有字符串默认都保证是 UTF-8,并且 Rust 中字符串类型的索引方式与其他语言中的使用方式不同。让我们尝试访问字符串的各个字符:

// strings_indexing.rs

fn main() {
    let hello = String::from("Hello");
    let first_char = hello[0];
}

在编译这个程序时,我们得到以下错误:

图片

这不是一条很有帮助的信息。但它指的是一个名为Index的特质。Index特质是在可以通过索引操作符[]使用usize类型作为索引值的集合类型上实现的。字符串是有效的 UTF-8 编码的字节序列,一个字节并不等同于一个字符。在 UTF-8 中,一个字符也可能由多个字节表示。因此,索引在字符串上不适用。

相反,我们可以有字符串切片。这可以按照以下方式完成:

// string_range_slice.rs

fn main() {
    let my_str = String::from("Strings are cool");
    let first_three = &my_str[0..3];
    println!("{:?}", first_three);
}

但是,就像所有索引操作一样,如果起始索引或结束索引不在有效的char边界上,这会导致程序崩溃。

另一种迭代字符串中所有字符的方法是使用chars()方法,它将字符串转换为一个字符迭代器。让我们将我们的代码更改为使用chars

// strings_chars.rs

fn main() {
    let hello = String::from("Hello");
    for c in hello.chars() {
        println!("{}", c);
    }
}

chars方法返回字符串在适当的 Unicode 边界处的字符。我们也可以调用其他迭代器方法来跳过或获取字符的范围。

在函数中使用字符串

将字符串切片传递给函数是一种既自然又高效的编程方式。以下是一个例子:

// string_slices_func.rs

fn say_hello(to_whom: &str) { 
    println!("Hey {}!", to_whom) 
} 

fn main() { 
    let string_slice: &'static str = "you"; 
    let string: String = string_slice.into(); 
    say_hello(string_slice); 
    say_hello(&string); 
} 

对于敏锐的观察者来说,say_hello方法也可以与&String类型一起工作。内部,&String会自动转换为&str,这是由于为&String&str实现的类型转换特质Deref。这是因为Stringstr类型实现了Deref

这里,你可以看到为什么我之前强调了这一点。字符串切片不仅可以作为实际字符串切片引用的输入参数,也可以作为String引用的输入参数!所以,再次强调:如果你需要将字符串传递给你的函数,请使用字符串切片,&str

连接字符串

在处理 Rust 中的字符串时,另一个容易混淆的地方是连接两个字符串。在其他语言中,连接两个字符串有非常直观的语法。你只需这样做"Foo" + "Bar",你就能得到"FooBar"。但在 Rust 中并非如此:

// string_concat.rs

fn main() {
    let a = "Foo";
    let b = "Bar";
    let c = a + b;
}

如果我们编译这段代码,我们会得到以下错误:

图片

这里的错误信息非常有帮助。连接操作是一个两步的过程。首先,你需要分配一个字符串,然后遍历这两个字符串,将它们的字节复制到新分配的字符串中。因此,这里涉及到了隐式的堆分配,隐藏在+操作符后面。Rust 不鼓励隐式的堆分配。相反,编译器建议我们可以通过显式地将第一个字符串变为拥有字符串来连接两个字符串字面量。因此,我们的代码会像这样改变:

// string_concat.rs

fn main() {
    let foo = "Foo";
    let bar = "Bar";
    let baz = foo.to_string() + bar;
}

因此,我们通过调用to_string()方法将foo的类型定义为String。这次更改后,我们的代码可以编译。

String&str之间的主要区别在于&str被编译器原生识别,而String是标准库中的一个自定义类型。你可以在Vec<u8>之上实现自己的类似String的抽象。

当使用&strString时应该注意什么?

对于刚开始接触 Rust 的程序员来说,常常会困惑于该使用哪一个。好吧,最好的做法是在可能的情况下使用接受&str类型的 API,因为当字符串已经分配在某处时,你只需通过引用该字符串就可以节省复制和分配的成本。在程序中传递&str几乎是不需要成本的:它几乎不产生分配成本,也不进行内存复制。

全局值

除了变量和类型声明之外,Rust 还允许我们定义可以在程序中的任何地方访问的全局值。它们的命名约定是每个字母都大写。这些分为两种:常量和静态值。还有常量函数,可以用来初始化这些全局值。让我们首先来探讨常量。

常量

全局值的第一种形式是常量。下面是如何定义一个常量的示例:

// constants.rs

const HEADER: &'static [u8; 4] = b"Obj\0"; 

fn main() {
    println!("{:?}", HEADER);
}

我们使用const关键字来创建常量。由于常量不是使用let关键字声明的,因此在创建它们时指定类型是必须的。现在,我们可以在原本使用字节字面量Obj\的地方使用HEADERb""是一种方便的语法,用于创建&'static [u8; n]类型的字节序列,就像对固定大小字节数组的'static引用。常量代表具体的值,并且与它们没有关联的内存位置。它们在使用的任何地方都会内联。

静态值

静态值是真正的全局值,因为它们有一个固定的内存位置,并且在整个程序中作为一个单独的实例存在。这些也可以被设置为可变的。然而,由于全局变量是那里最糟糕的 bug 的滋生地,所以有一些安全机制。对静态值的读取和写入都必须在unsafe {}块内进行。下面是如何创建和使用静态值的示例:

// statics.rs

static mut BAZ: u32 = 4; 
static FOO: u8 = 9; 

fn main() {
    unsafe {
        println!("baz is {}", BAZ);
        BAZ = 42;
        println!("baz is now {}", BAZ);
        println!("foo is {}", FOO);
    }
}

在代码中,我们已经声明了两个静态值BAZFOO。我们使用static关键字来创建它们,并明确指定类型。如果我们想使它们可变,我们在static之后添加mut关键字。静态值不像常量那样内联。当我们读取或写入静态值时,我们需要使用unsafe块。静态值通常与同步原语结合使用,以实现任何类型的线程安全。它们也用于实现全局锁以及与 C 库集成。

通常,如果你不需要依赖于静态值的单例属性和预定义的内存位置,只是想要一个具体的值,你应该优先使用consts。它们允许编译器进行更好的优化,并且使用起来更直接。

编译时函数 – const fn

我们还可以定义在编译时评估其参数的常量函数。这意味着const值声明可以有一个来自const函数调用的值。const函数是纯函数,必须是可再现的。这意味着它们不能接受任何类型的可变参数。它们也不能包含动态操作,如堆分配。它们可以在非const位置调用,在那里它们的行为就像普通函数一样。但是,当它们在const上下文中调用时,它们将在编译时进行评估。以下是如何定义const函数的示例:

// const_fns.rs

const fn salt(a: u32) -> u32 {
    0xDEADBEEF ^ a
}

const CHECKSUM: u32 = salt(23);

fn main() {
    println!("{}", CHECKSUM);
}

在代码中,我们定义了一个const函数,名为salt,它接受一个u32值作为参数,并与十六进制值0xDEADBEEF进行xor操作。const函数对于可以在编译时执行的操作非常有用。例如,假设你正在编写一个二进制文件解析器,并且需要读取文件的前四个字节作为解析器的初始化和验证步骤。以下代码演示了如何在运行时完成这一操作:

// const_fn_file.rs

const fn read_header(a: &[u8]) -> (u8, u8, u8, u8) {
    (a[0], a[1], a[2], a[3])
}

const FILE_HEADER: (u8,u8,u8,u8) = read_header(include_bytes!("./const_fn_file.rs"));

fn main() {
    println!("{:?}", FILE_HEADER);
}

在代码中,read_header函数使用include_bytes!宏接收一个文件作为字节数组,该宏也会在编译时读取文件。然后我们从其中提取4个字节,并返回一个包含四个元素的元组。如果没有const函数,所有这些操作都会在运行时完成。

使用lazy_static!宏的动态静态值

正如我们所见,全局值只能声明为在初始化时非动态且在编译时在栈上有已知大小的类型。例如,你不能将HashMap作为静态值创建,因为它需要堆分配。幸运的是,我们可以使用名为lazy_static的第三方 crate 将HashMap和其他动态集合类型(如Vec)作为全局静态值。这个 crate 公开了lazy_static!宏,它可以用来初始化任何可以从程序中的任何地方全局访问的动态类型。以下是如何初始化一个可以从多个线程中修改的Vec的示例:

// lazy_static_demo

use std::sync::Mutex;

lazy_static! {
    static ref ITEMS: Mutex<Vec<u64>> = {
        let mut v = vec![];
        v.push(9);
        v.push(2);
        v.push(1);
        Mutex::new(v)
    }
}

lazy_static!宏内部声明的项需要实现Sync特质。这意味着如果我们想要一个可变的静态变量,我们必须使用MutexRwLock等多线程类型,而不是RefCell。我们将在第八章中解释这些类型,并发。我们将在未来的章节中频繁使用这个宏。前往 crate 仓库了解如何使用lazy_static的更多信息。

迭代器

我们在第一章“Rust 入门”中简要介绍了迭代器。回顾一下,迭代器是任何可以以三种方式遍历集合类型元素的普通类型:通过self&self&mut self。虽然它们不是一个新概念,主流语言如 C++和 Python 已经有了它们,但在 Rust 中,由于它们以关联类型特质的形态出现,可能会让人一开始感到惊讶。迭代器在处理集合类型时在惯用的 Rust 代码中非常频繁地被使用。

为了理解它们是如何工作的,让我们看看std::iter模块中Iterator特质的定义:

pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    // other default methods omitted
}

Iterator特质是一个关联类型特质,它强制为任何实现类型定义两个项目。第一个是关联类型Item,它指定了迭代器产生的项。第二个是next方法,每次我们需要从正在迭代的类型中读取值时都会调用它。还有一些其他的方法我们没有在这里列出,因为它们有默认实现。要使类型可迭代,我们只需要指定Item类型并实现next方法,所有其他具有默认实现的方法都将对类型可用。以这种方式,迭代器是一个非常强大的抽象。您可以在doc.rust-lang.org/std/iter/trait.Iterator.html看到完整的默认方法集。

Iterator特质有一个兄弟特质叫做IntoIterator,它由想要转换为迭代器的类型实现。它提供了一个into_iter方法,该方法通过self接收实现类型,并消耗该类型的元素。

让我们为自定义类型实现Iterator特质。如果您的数据类型不是集合,请确定您想要遍历的数据类型中的内容。然后,创建一个包装结构体来持有迭代器的任何状态。通常,我们会发现迭代器是为某些包装类型实现的,这些包装类型通过所有者或不可变或可变引用引用集合类型的元素。将类型转换为迭代器的这些方法也遵循了惯例的命名:

  • iter()通过引用获取元素。

  • iter_mut()获取元素的可变引用。

  • into_iter()获取值的所有权,并在迭代完成后消耗实际类型。原始集合将无法访问。

实现Iterator特质的类型可以用在for循环中,并且底层,项目的next方法会被调用。考虑以下所示的for循环:

for i in 0..20 {
    // do stuff
}

上述代码将被简化如下:

let a = Range(..);
while let Some(i) = a.next() {
    // do stuff
}

它将反复调用a.next()直到匹配一个Some(i)变体。当它匹配None时,迭代停止。

实现自定义迭代器

为了更深入地理解迭代器,我们将实现一个生成质数的迭代器,这些质数有一个用户可定制的上限。首先,让我们明确我们需要从我们的迭代器中获得的 API 期望:

// custom_iterator.rs

use std::usize;

struct Primes {
    limit: usize
}

fn main() {
    let primes = Primes::new(100);
    for i in primes.iter() {
        println!("{}", i);
    }
}

因此,我们有一个名为 Primes 的类型,我们可以使用 new 方法实例化它,提供要生成的质数的上限。我们可以在这个实例上调用 iter() 来将其转换为迭代器类型,然后可以在 for 循环中使用它。有了这些,让我们添加 newiter 方法:

// custom_iterator.rs

impl Primes {
    fn iter(&self) -> PrimesIter {
        PrimesIter {
            index: 2,
            computed: compute_primes(self.limit)
        }
    }

    fn new(limit: usize) -> Primes {
        Primes { limit }
    }
}

iter 方法通过 &self 接收 Primes 类型并返回一个包含两个字段的 PrimesIter 类型:index 字段存储向量中的 index,以及一个 computed 字段,用于存储预先计算好的质数向量。compute_primes 方法定义如下:

// custom_iterator.rs

fn compute_primes(limit: usize) -> Vec<bool> {
    let mut sieve = vec![true; limit];
    let mut m = 2;
    while m * m < limit {
        if sieve[m] {
            for i in (m * 2..limit).step_by(m) {
                sieve[i] = false;
            }
        }
        m += 1;
    }
    sieve
}

此函数实现了埃拉托斯特尼筛法算法,用于高效地生成给定限制内的质数。接下来是 PrimesIter 结构体的定义及其 Iterator 实现的定义:

// custom_iterator.rs

struct PrimesIter {
    index: usize,
    computed: Vec<bool>
}

impl Iterator for PrimesIter {
    type Item = usize;
    fn next(&mut self) -> Option<Self::Item> {
        loop {
            self.index += 1;
            if self.index > self.computed.len() - 1 {
                return None;
            } else if self.computed[self.index] {
                return Some(self.index);
            } else {
                continue
            }
        }
    }
}

next 方法中,我们循环并获取 self.indexself.computed Vec 中的值如果是 true,则下一个质数。如果我们超过了 computed Vec 中的元素,则返回 None 以表示我们已经完成。以下是包含生成 100 个质数的主函数的完整代码:

// custom_iterator.rs

use std::usize;

struct Primes {
    limit: usize
}

fn compute_primes(limit: usize) -> Vec<bool> {
    let mut sieve = vec![true; limit];
    let mut m = 2;
    while m * m < limit {
        if sieve[m] {
            for i in (m * 2..limit).step_by(m) {
                sieve[i] = false;
            }
        }
        m += 1;
    }
    sieve
}

impl Primes {
    fn iter(&self) -> PrimesIter {
        PrimesIter {
            index: 2,
            computed: compute_primes(self.limit)
        }
    }

    fn new(limit: usize) -> Primes {
        Primes { limit }
    }
}

struct PrimesIter {
    index: usize,
    computed: Vec<bool>
}

impl Iterator for PrimesIter {
    type Item = usize;
    fn next(&mut self) -> Option<Self::Item> {
        loop {
            self.index += 1;
            if self.index > self.computed.len() - 1 {
                return None;
            } else if self.computed[self.index] {
                return Some(self.index);
            } else {
                continue
            }
        }
    }
}

fn main() {
    let primes = Primes::new(100);
    for i in primes.iter() {
        print!("{},", i);
    }
}

我们得到了以下输出:

3,5,7,11,13,17,19,23,29,31,37,41,43,47,53,59,61,67,71,73,79,83,89,97

太好了!除了 Vec 之外,标准库中还有许多实现了 Iterator 特质的类型,例如 HashMapBTreeMapVecDeque

高级类型

在本节中,我们将查看 Rust 中的一些高级类型。让我们首先从无大小类型开始。

无大小类型

无大小类型是在尝试创建 str 类型的变量时首次遇到的类型类别。我们知道我们只能在 &str 等引用的后面创建和使用字符串引用。让我们看看如果我们尝试创建 str 类型会得到什么错误信息:

// unsized_types.rs

fn main() {
    let a: str = "2048";
}

编译时我们遇到了以下错误:

图片

默认情况下,Rust 创建了一个静态引用类型的 str,即 'static str。错误信息提到,所有局部变量——在栈上生存的值——必须在编译时具有静态已知的大小。这是因为栈内存是有限的,我们不能有无限或动态大小的类型。同样,还有其他无大小类型的实例:

  • [T]:这是一个类型 T 的切片。它们只能用作 &[T]&mut [T]

  • dyn Trait:这是一个特质对象。它们只能用作 &dyn Trait&mut dyn Trait 类型。

  • 任何以无大小类型作为其最后一个字段的 struct 也被视为无大小类型。

  • str,我们已经探讨了。str 内部只是一个 [u8],但增加了字节是有效的 UTF-8 的保证。

函数类型

Rust 中的函数也有一个具体的类型,它们在参数类型和arity(即它们接受的参数数量)方面有所不同,例如以下示例:

// function_types.rs

fn add_two(a: u32, b: u32) -> u32 {
    a + b
}

fn main() {
    let my_func = add_two;
    let res = my_func(3, 4);
    println!("{:?}", res);
}

Rust 中的函数是一等公民。这意味着它们可以被存储在变量中,或者传递给其他函数,或者从函数中返回。前面的代码声明了一个名为add_two的函数,我们将它存储在my_func中,稍后用34调用它。

函数类型不要与Fn闭包混淆,因为它们两者的类型签名前缀都是fn

Never 类型!和发散函数

我们使用了一个名为unimplemented!()的宏,它有助于让编译器忽略任何未实现的功能,并编译我们的代码。这是因为未实现宏返回一个称为 never 类型的东西,表示为!

Unions

为了与 C 代码互操作,Rust 还支持union类型,它直接映射到 C 联合。联合类型在读取时是不安全的。让我们看看如何创建和与之交互的示例:

// unions.rs

#[repr(C)]
union Metric {
    rounded: u32,
    precise: f32,
}

fn main() {
    let mut a = Metric { rounded: 323 };
    unsafe {
        println!("{}", a.rounded);
    }
    unsafe {
        println!("{}", a.precise);
    }
    a.precise = 33.3;
    unsafe {
        println!("{}", a.precise);
    }
}

我们创建了一个联合类型Metric,它有两个字段roundedprecise,代表某种测量。在main函数中,我们在变量a中初始化了它的一个实例。

我们只能初始化其中一个变量,否则编译器会显示以下消息:

error: union expressions should have exactly one field
  --> unions.rs:10:17
   |
10 |     let mut a = Metric { rounded: 323, precise:23.0 };

我们还必须使用不安全块来打印联合的字段。编译并运行前面的代码会给我们以下输出:

323
0.000000000000000000000000000000000000000000453
33.3

如您所见,我们为未初始化的字段precise得到了一个垃圾值。在撰写本书时,联合类型仅允许Copy类型作为其字段。它们与所有字段共享相同的内存空间,就像 C 联合一样。

Cow

Cow 是一种智能指针类型,提供了两种字符串版本。它代表写时克隆。它有以下类型签名:

pub enum Cow<'a, B> where B: 'a + ToOwned + 'a + ?Sized,  {
    Borrowed(&'a B),
    Owned(<B as ToOwned>::Owned),
}

首先,我们有两种变体:

  • Borrowed 表示某些类型 B 的借用版本。这个 B 必须实现ToOwned特质。

  • 此外,还有一个所有者变体,它包含类型的所有者版本。

这种类型适用于需要避免不必要的分配的情况。一个现实世界的例子是名为serde_json的 JSON 解析器 crate。

Advanced traits

在本节中,我们将讨论一些在处理复杂代码库时非常重要的高级特质。

Sized 和?Sized

Sized特质是一个标记特质,表示在编译时已知大小的类型。它在 Rust 中的大多数类型上实现,除了未定义大小的类型。所有类型参数在其定义中都有一个隐式的Sized特质约束。我们还可以使用在特质前的?运算符指定可选特质约束,但截至本书撰写时,?运算符与特质仅适用于标记特质。它可能在未来扩展到其他类型。

Borrow 和 AsRef

这些是特殊的特质,它们携带了从任何类型构造出某种类型的能力。

ToOwned

这个特性是为了实现可以转换为拥有版本类型的。例如,&str类型为String实现了这个特性。这意味着&str类型有一个名为to_owned()的方法,可以将它转换为String类型,这是一个拥有类型的。

From 和 Into

要将一种类型转换为另一种类型,我们拥有FromInto特性。这两个特性的有趣之处在于,我们只需要实现From特性,就可以免费获得Into特性的实现,这是因为以下实现方式:

#[stable(feature = "rust1", since = "1.0.0")]
impl<T, U> Into<U> for T where U: From<T> {
    fn into(self) -> U {
        U::from(self)
    }
}

特性对象和对象安全性

对象安全性是一组规则和限制,不允许构造特性对象。考虑以下代码:

// object_safety.rs

trait Foo {
    fn foo();
}

fn generic(val: &Foo) {

}

fn main() {

}

编译时我们得到以下错误:

图片

这引出了对象安全性的概念,它是一组禁止从特性创建特性对象的限制。在这个例子中,由于我们的类型没有自我引用,因此不可能从中创建特性对象。在这种情况下,要将任何类型转换为特性对象,类型上的方法需要是一个实例——一个通过引用传递self的实例。因此,我们将特性方法声明foo更改为以下内容:

trait Foo {
    fn foo(&self);
}

这使得编译器感到满意。

通用函数调用语法

有时候,你使用的是一个具有与其实现特性相同方法集的类型。在这些情况下,Rust 为我们提供了统一的函数调用语法,它可以用于调用类型本身或来自特性的方法。考虑以下代码:

// ufcs.rs

trait Driver {
    fn drive(&self) {
        println!("Driver's driving!");
    }
}

struct MyCar;

impl MyCar {
    fn drive(&self) {
        println!("I'm driving!");
    }
}

impl Driver for MyCar {}

fn main() {
    let car = MyCar;
    car.drive();
}

前面的代码有两个同名的方法,drive。其中一个是一个固有方法,另一个来自特性Driver。如果我们编译并运行这个程序,我们会得到以下输出:

I'm driving

好吧,如果我们想调用Driver特性的drive方法怎么办?类型上的固有方法比具有相同名称的其他方法具有更高的优先级。要调用特性方法,我们可以使用通用函数调用语法UFCS)。

特性规则

特性也有特殊的属性和限制,这些属性和限制在你使用它们时非常重要。

在特性的上下文中,类型系统的一个重要属性是特性一致性规则。特性一致性的理念是,对于实现了该特性的类型,应该恰好有一个特性的实现。这一点应该是相当明显的,因为如果有两个实现,那么在两者之间选择就会产生歧义。

另一个可能会让许多人感到困惑的特性规则是孤儿规则。简单来说,孤儿规则指出我们不能在外部类型上实现外部特性。

用另一种方式来说,要么如果你在实现外部类型上的东西,特性必须由你定义,要么如果你在实现外部特性,你的类型应该由你定义。这排除了在跨 crate 的重叠特性实现中发生冲突的可能性。

深入了解闭包

如我们所知,闭包是函数的更高级版本。它们也是一等函数,这意味着它们可以被放入变量中,或者作为函数的参数传递,甚至可以从函数中返回。但使它们与函数区别开来的是,它们还知道它们被声明的环境,并且可以引用其环境中的任何变量。它们从环境中引用变量的方式取决于变量在闭包内部的使用方式。

默认情况下,闭包会以尽可能灵活的方式尝试捕获变量。只有当程序员需要以某种特定方式捕获值时,他们才会强制执行程序员的意图。除非我们看到不同类型的闭包在实际操作中的表现,否则这不会有什么意义。闭包底层是匿名结构体,实现了三个特性,这些特性代表了闭包如何访问其环境。我们接下来将查看这三个特性(从最不限制到最限制)。

FnOnce闭包

只进行读取访问的闭包实现了Fn特性。它们访问的任何值都是引用类型(&T)。这是闭包默认的借用模式。考虑以下代码:

// fn_closure.rs

fn main() {
    let a = String::from("Hey!");
    let fn_closure = || {
        println!("Closure says: {}", a);    
    };
    fn_closure();
    println!("Main says: {}", a);
}

编译后我们得到以下输出:

Closure says: Hey!
Main says: Hey!

即使在调用闭包之后,a变量仍然可访问,因为闭包通过引用使用了a

FnMut闭包

当编译器确定闭包会修改从环境引用的值时,闭包实现了FnMut特性。将相同的代码进行适配,我们得到以下内容:

// fn_mut_closure.rs

fn main() {
    let mut a = String::from("Hey!");
    let fn_mut_closure = || {
        a.push_str("Alice");    
    };
    fn_mut_closure();
    println!("Main says: {}", a);
}

之前的闭包将"Alice"字符串添加到a上。fn_mut_closure会修改其环境。

FnOnce闭包

捕获它们从环境中读取的数据的所有权的闭包使用FnOnce实现。这个名字意味着这个闭包只能调用一次,因此变量也只可用一次。这是构建和使用闭包最不推荐的方式,因为你不能在之后使用引用的变量:

// fn_once.rs

fn main() {
    let mut a = Box::new(23);
    let call_me = || {
        let c = a;
    };

    call_me();
    call_me();
}

这会失败,并出现以下错误:

图片

但有一些用例中,FnOnce闭包是唯一适用的闭包。一个这样的例子是标准库中用于创建新线程的thread::spawn方法。

结构体、枚举和特性中的常量

结构体、枚举和特质定义也可以提供具有常量字段成员。它们可以在需要在这些成员之间共享常量的情况下使用。以一个场景为例,我们有一个 Circle 特质,它旨在由不同的圆形形状类型实现。我们可以在 Circle 特质中添加一个 PI 常量,它可以被任何具有 area 属性并依赖于 PI 值来计算面积的类型共享:

// trait_constants.rs

trait Circular {
    const PI: f64 = 3.14;
    fn area(&self) -> f64;
}

struct Circle {
    rad: f64
}

impl Circular for Circle {
    fn area(&self) -> f64 {
        Circle::PI * self.rad * self.rad
    }
}

fn main() {
    let c_one = Circle { rad: 4.2 };
    let c_two = Circle { rad: 75.2 };
    println!("Area of circle one: {}", c_one.area());
    println!("Area of circle two: {}", c_two.area());
}

我们也可以在结构体和枚举中使用常量:

// enum_struct_consts.rs

enum Item {
    One,
    Two
}

struct Food {
    Cake,
    Chocolate
}

impl Item {
    const DEFAULT_COUNT: u32 = 34;
}

impl Food {
    const FAVORITE_FOOD: &str = "Cake";
}

fn main() {

}

接下来,让我们看看模块的一些高级特性。

模块、路径和导入

Rust 在如何组织我们的项目方面为我们提供了很多灵活性,正如我们在第二章 使用 Cargo 管理项目中看到的。在这里,我们将探讨模块的一些高级特性以及引入更多代码隐私的不同方法。

导入

我们也可以从模块中导入嵌套的项目。这有助于减少导入所占用的空间。考虑以下代码:

// nested_imports.rs

use std::sync::{Mutex, Arc, mpsc::channel};

fn main() {
    let (tx, rx) = channel();
}

重新导出

重新导出允许用户有选择性地从模块中公开项目。当我们使用 OptionResult 类型时,我们已经使用了重新导出的便利性。重新导出还有助于减少在创建包含许多子模块的嵌套目录的模块时需要编写的导入路径。

例如,这里我们有一个名为 bar.rs 的子模块,来自我们创建的名为 reexports 的 cargo 项目:

// reexports_demo/src/foo/bar.rs

pub struct Bar;

Bar 是位于 src/foo/bar.rs 下的公开结构体模块。如果用户想在他们的代码中使用 Bar,他们必须编写如下内容:

// reexports_demo/src/main.rs

use foo::bar::Bar;

fn main() {

}

上面的 use 语句相当冗长。当你在项目中有很多嵌套子模块时,这会变得尴尬且重复。相反,我们可以从 bar 模块重新导出 Bar 到我们的 crate 根目录,如下所示,在我们的 foo.rs 中:

// reexports_demo/src/foo.rs

mod bar;
pub use bar::Bar;

要重新导出,我们使用 pub use 关键字。现在我们可以轻松地使用 Bar,以及使用 foo::Bar

默认情况下,Rust 推荐在根模块中使用 绝对导入。绝对导入是从 crate 关键字开始的,而 相对导入 是使用 self 关键字进行的。当将子模块重新导出到父模块时,我们可能会从相对导入中受益,因为使用绝对导入会变得很长且冗余。

选择性隐私

Rust 中项目的隐私性从模块级别开始。作为库的作者,为了从模块中向用户公开事物,我们使用 pub 关键字。但是,有些项目我们只想向 crate 内部的其他模块公开,而不是向用户公开。在这种情况下,我们可以使用 pub(crate) 修饰符来修饰项目,这允许项目仅在本 crate 内部公开。

考虑以下代码:

// pub_crate.rs

fn main() {

}

高级匹配模式和守卫

在本节中,我们将探讨一些 match 和 let 模式的先进用法。首先,让我们看看 match。

匹配守卫

我们也可以在臂上使用匹配守卫(if code > 400 || code <= 500)来匹配值的一个子集。它们以一个if表达式开始。

高级let解构

我们有以下复杂的数据,我们想要与之匹配:

// complex_destructure.rs

enum Foo {
    One, Two, Three
}

enum Bar(Foo);

struct Dummy {
    inner: Bar
}

struct ComplexStruct {
    obj: Dummy
}

fn get_complex_struct() -> ComplexStruct {
    ComplexStruct {
        obj: Dummy { inner: Bar(Foo::Three) }
    }
}

fn main() {
    let a = get_complex_struct();
}

类型转换与强制转换

类型转换是一种将类型降级或升级到其他类型的机制。当类型转换隐式发生时,它被称为强制转换。Rust 还允许在各个级别进行类型转换。最明显的候选者是原始数值类型。你可能需要将u8类型转换为u64,或者截断i64i32。为了执行简单的转换,我们使用as关键字,如下所示:

let a = 34u8;
let b = a as u64;

不仅限于原始类型——也支持在高级类型中进行类型转换。如果我们想要将类型的引用转换为其实例化该特定特质的特质对象,我们也可以这样做。所以我们可以做如下操作:

// cast_trait_object.rs

use std::fmt::Display;

fn show_me(item: &Display) {
    println!("{}", item);
}

fn main() {
    let a = "hello".to_string();
    let b = &a;
    show_me(b);
    // let c = b as &Display;
}

支持各种指针类型的其他类别的类型转换:

  • *mut T转换为*const T。另一种方法在安全 Rust 中是禁止的,并需要一个unsafe

  • &T转换为*const T和相反

还有另一种称为transmutes的显式且不安全的类型转换版本,因为它不安全,所以在你不知道后果的情况下使用它是非常危险的。当无知地使用时,它会使你陷入类似于在 C 语言中从整数创建指针的情况。

类型与内存

在本节中,我们将探讨编程语言中类型的一些方面和低级细节,如果你是编写系统软件且关心性能的人,这些内容是重要的。

内存对齐

这是内存管理方面的一些方面,你很少需要关心,除非性能是一个严格的要求。由于内存和处理器之间的数据访问延迟,当处理器从内存访问数据时,它是以块的形式进行的,而不是逐字节。这是为了帮助减少内存访问次数。这个块大小称为 CPU 的内存访问粒度。通常,块大小是一个字(32 位)、两个字、四个字等,它们取决于目标架构。由于这种访问粒度,数据驻留在内存中,对齐到字大小的倍数是期望的。如果不是这种情况,那么 CPU 必须读取数据,然后对数据位进行左移或右移操作,并丢弃不需要的数据来读取特定的值。这浪费了 CPU 周期。在大多数情况下,编译器足够聪明,可以为我们找出数据对齐,但在某些情况下,我们需要告诉它。有两个重要的术语我们需要理解:

  • 字大小:字大小是指微处理器作为一个单位处理的数据位数。

  • 内存访问粒度:CPU 从内存总线访问的最小数据块称为内存访问粒度。

所有编程语言中的数据类型都具有大小和对齐。原始类型对齐等于它们的大小。因此,通常所有原始类型都是对齐的,CPU 对这些类型的对齐读取没有问题。但是,当我们创建自定义数据类型时,编译器通常会在我们的结构字段之间插入填充,如果它们没有对齐,以允许 CPU 以对齐的方式访问内存。

在了解数据类型的大小和对齐之后,让我们探索标准库中的std::mem模块,它允许我们内省数据类型及其大小。

探索std::mem模块

关于类型及其在内存中的大小,标准库中的mem模块为我们提供了方便的 API 来检查类型的大小和对齐,以及初始化原始内存的功能。其中相当多的函数都是不安全的,并且只有在程序员知道自己在做什么时才能使用。我们将限制我们的探索到这些 API:

  • size_of返回通过泛型类型提供的类型的大小

  • size_of_val返回作为引用提供的值的大小

由于这些方法是泛型的,因此它们旨在使用涡轮鱼::<>运算符调用。我们实际上并没有将这些方法作为一个类型参数传递;我们只是明确地针对一个类型调用它们。如果我们对前面一些泛型类型的零成本声明持怀疑态度,我们可以使用这些函数来检查开销。让我们看看 Rust 中一些类型的大小:

// mem_introspection.rs

use std::cell::Cell; 
use std::cell::RefCell; 
use std::rc::Rc; 

fn main() { 
    println!("type u8: {}", std::mem::size_of::<u8>()); 
    println!("type f64: {}", std::mem::size_of::<f64>()); 
    println!("value 4u8:  {}", std::mem::size_of_val(&4u8)); 
    println!("value 4:  {}", std::mem::size_of_val(&4)); 
    println!("value 'a': {}", std::mem::size_of_val(&'a')); 

    println!("value \"Hello World\" as a static str slice: {}", std::mem::size_of_val("Hello World")); 
    println!("value \"Hello World\" as a String: {}", std::mem::size_of_val("Hello World").to_string()); 

    println!("Cell(4)): {}", std::mem::size_of_val(&Cell::new(84))); 
    println!("RefCell(4)): {}", std::mem::size_of_val(&RefCell::new(4))); 

    println!("Rc(4): {}", std::mem::size_of_val(&Rc::new(4))); 
    println!("Rc<RefCell(8)>): {}", std::mem::size_of_val(&Rc::new(RefCell::new(4)))); 
}

另一个需要注意的重要观察是各种指针的大小。考虑以下代码:

// pointer_layouts.rs

trait Position {}

struct Coordinates(f64, f64);

impl Position for Coordinates {}

fn main() {
    let val = Coordinates(1.0, 2.0);
    let ref_: &Coordinates = &val;
    let pos_ref: &Position = &val as &Position;
    let ptr:       *const Coordinates = &val as *const Coordinates;
    let pos_ptr: *const Position  = &val as *const Position;

    println!("ref_: {}", std::mem::size_of_val(&ref_));
    println!("ptr: {}", std::mem::size_of_val(&ptr));
    println!("val: {}", std::mem::size_of_val(&val));
    println!("pos_ref: {}", std::mem::size_of_val(&pos_ref));
    println!("pos_ptr: {}", std::mem::size_of_val(&pos_ptr));
}

我们以多种方式创建指向Coordinate结构的指针,并通过将它们转换为不同类型的指针来打印它们的大小。编译并运行上面的代码,我们得到以下输出:

ref_: 8
ptr: 8
val: 16
pos_ref: 16
pos_ptr: 16

这清楚地表明,特性和特质的引用是胖指针,其大小是正常指针的两倍。

使用serde进行序列化和反序列化

序列化和反序列化是理解任何需要以紧凑方式传输或存储数据的应用程序的重要概念。序列化是将内存中的数据类型转换为一系列字节的进程,而反序列化则是其相反过程,意味着它可以读取数据。许多编程语言都提供了将数据结构转换为一系列字节的支撑。关于serde的美丽之处在于,它在编译时生成任何受支持的类型的序列化,这主要依赖于过程宏。在大多数情况下,使用serde进行序列化和反序列化是一个零成本操作。

在这个演示中,我们将探索serde包以序列化和反序列化一个用户定义的类型。让我们通过运行cargo new serde_demo并使用以下内容在Cargo.toml中创建一个新的项目:

# serde_demo/Cargo.toml

[dependencies]
serde = "1.0.84"
serde_derive = "1.0.84"
serde_json = "1.0.36"

以下是main.rs中的内容:


serde_demo/src/main.rs

use serde_derive::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize)]
struct Foo {
    a: String,
    b: u64
}

impl Foo {
    fn new(a: &str, b: u64) -> Self {
        Self {
            a: a.to_string(),
            b
        }
    }
}

fn main() {
    let foo_json = serde_json::to_string(Foo::new("It's that simple", 101)).unwrap();
    println!("{:?}", foo_json);
    let foo_value: Foo = serde_json::from_str(foo_json).unwrap();
    println!("{:?}", foo_value);
}

要将任何原生数据类型序列化为类似 JSON 的格式,我们只需在我们的类型上放置一个 derive 注解,正如我们的结构体 Foo 的情况。

serde 支持许多作为 crate 实现的序列化器。流行的例子包括 serde_jsonbincodeTOML。更多支持的格式可以在:github.com/TyOverBy/bincode 找到。这些序列化实现者,例如 serde_json crate,提供了 to_string 等方法来转换

摘要

在本章中,我们详细介绍了 Rust 类型系统的某些高级特性。我们了解了各种特性,这些特性使得编写 Rust 代码更加人性化。我们还看到了高级的模式匹配结构。最后,我们探讨了 serde crate,它在执行数据序列化方面速度极快。下一章将介绍如何使用并发同时做多件事情。

第八章:并发

当今的软件很少被编写为顺序执行任务。今天,能够编写能够同时做很多事情并且正确执行这些事情的程序更为重要。随着晶体管变得越来越小,由于晶体管中的量子效应,计算机架构师无法通过提高 CPU 时钟频率来扩展 CPU 的性能。这导致更多的关注点转向构建采用多个核心的并发 CPU 架构。随着这种转变,开发者需要编写高度并发的应用程序来维持当摩尔定律生效时所获得的免费性能提升。

但编写并发代码是困难的,那些不提供更好抽象的语言使得情况变得更糟。Rust 试图在这个领域使事情变得更好、更安全。在本章中,我们将探讨使 Rust 能够为开发者提供无畏并发、允许他们以安全的方式轻松表达程序的概念和原语。

本章涵盖的主题如下:

  • 程序执行模型

  • 并发及其相关陷阱

  • 线程作为并发单元

  • Rust 如何提供线程安全性

  • Rust 中的并发原语

  • 其他并发库

程序执行模型

"一个不断发展的系统,除非进行工作来减少它,否则会增加其复杂性。"

  • 梅尔·莱曼

在 20 世纪 60 年代初,在多任务处理甚至还是一个概念之前,为计算机编写的程序局限于顺序执行模型,它们能够按照时间顺序依次执行指令。这主要是因为当时硬件在处理指令方面的限制。当我们从真空管转向晶体管,再到集成电路时,现代计算机为支持程序中的多个执行点打开了可能性。那些需要等待一个指令执行完毕才能执行下一个指令的顺序编程模型已经过去了。今天,计算机能够同时做很多事情并且正确地完成这些事情的情况更为常见。

现代计算机模型了一个并发执行模型,其中一组指令可以在不同的时间间隔内独立执行。在这个模型中,指令不需要相互等待,几乎同时运行,除非它们需要共享或协调某些数据。如果你观察现代软件,它做很多事情看起来似乎是同时发生的,如下面的例子所示:

  • 即使桌面应用程序在后台连接到网络,其用户界面仍然可以正常工作

  • 一个游戏同时更新成千上万的实体状态,同时在后台播放音轨并保持一致的帧率

  • 一个科学计算密集型程序会将计算分割开来,以便充分利用机器上的所有核心

  • 一个 Web 服务器一次处理多个请求以最大化吞吐量

这些是一些非常有说服力的例子,推动了将我们的程序建模为并发进程的需求。但并发究竟意味着什么呢?在下一节中,我们将对其进行定义。

并发

程序能够同时管理多件事情并给人一种它们同时发生的错觉,这种能力被称为并发,这样的程序被称为并发程序。并发允许你以某种方式结构化你的程序,使其在可以将问题分解为多个子问题时运行得更快。当谈论并发时,另一个术语并行性经常被提及,了解这两个术语之间的区别很重要,因为这两个术语的使用往往重叠。并行性是指每个任务在非重叠的时间段内同时在不同的 CPU 核心上运行。以下图表说明了并发和并行之间的区别:

图片

用另一种方式来说,并发是关于结构化你的程序以一次管理多件事情,而并行是关于将你的程序放在多个核心上以增加它在一定时间内完成的工作量。根据这个定义,可以得出结论,当并发做得正确时,可以更好地利用 CPU,而并行可能在所有情况下都不一定如此。如果你的程序并行运行但只处理一个专用任务,你并不会获得很多吞吐量。这就是说,当一个并发程序被设计在多个核心上运行时,我们可以获得两者的最佳效果。

通常,操作系统在较低级别已经提供了对并发的支持,开发者主要针对编程语言提供的较高层抽象进行编程。在底层支持之上,有不同并发的途径。

并发方法

我们使用并发来卸载程序的一部分以独立运行。有时,这些部分可能相互依赖,并朝着共同的目标前进,或者它们可能是令人尴尬的并行,这是一个用来指代可以分解为独立无状态任务的问题的术语,例如,并行转换图像中的每个像素。因此,使程序并发的途径取决于我们利用并发的层次以及我们试图解决的问题的性质。在下一节中,我们将讨论可用的并发方法。

基于内核

随着多任务处理成为常态,现代操作系统需要处理多个进程。因此,你的操作系统内核已经提供了编写并发程序的原始方法,以下是一种形式:

  • 进程: 在这种方法中,我们可以通过生成它们自己的独立副本来运行程序的不同部分。在 Linux 上,这可以通过使用fork系统调用实现。为了与生成的进程通信任何数据,可以使用各种进程间通信(IPC)设施,如管道和 FIFOs。基于进程的并发提供了诸如故障隔离等特性,但也存在启动整个新进程的开销。在操作系统耗尽内存并杀死它们之前,可以生成的进程数量是有限的。基于进程的并发在 Python 的 multiprocessing 模块中可以看到。

  • 线程: 内部运行的进程只是线程,具体称为主线程。一个进程可以启动或生成一个或多个线程。线程是可调度执行的最小单元。每个进程都以主线程开始。除此之外,它还可以使用操作系统提供的 API 生成额外的线程。为了允许程序员使用线程,大多数语言在其标准库中都提供了线程 API。与进程相比,它们更轻量级。线程与父进程共享相同的地址空间。它们不需要在内核的进程控制块(PCB)中有一个单独的条目,每次我们生成一个新进程时,该条目都会更新。但是,在进程内部驯服多个线程是一个挑战,因为与进程不同,它们与父进程和其他子线程共享地址空间,并且由于线程的调度由操作系统决定,我们无法依赖线程执行的顺序或它们将读取或写入的内存。当我们从单线程程序过渡到多线程程序时,这些操作突然变得难以推理。

注意: 线程和进程的实现因操作系统而异。在 Linux 下,内核将它们视为相同,除了线程在内核中没有自己的进程控制块条目,并且它们与父进程和任何其他子线程共享地址空间。

用户级

基于进程和线程的并发受我们能够生成多少个的限制。一个更轻量级且更高效的替代方案是使用用户空间线程,通常被称为绿色线程。它们首次出现在 Java 中,代号为green,这个名字一直沿用至今。其他语言如 Go(goroutines)和 Erlang 也有绿色线程。使用绿色线程的主要动机是减少使用基于进程和线程的并发带来的开销。绿色线程生成和使用的开销非常小,比线程占用更少的空间。例如,在 Go 中,goroutine 仅占用 4 KiB 的空间,而线程通常占用 8MB。

用户空间线程作为语言运行时的一部分进行管理和调度。运行时是任何额外的记录或管理代码,它随着你运行的每个程序执行。这可能是你的垃圾收集器或线程调度器。内部上,用户空间线程是在原生操作系统线程之上实现的。Rust 在 1.0 版本之前有绿色线程,但后来在语言稳定发布之前被移除。拥有绿色线程可能会偏离 Rust 的保证及其无运行时成本的原则。

用户空间并发更高效,但在其实施过程中却难以做到正确。然而,基于线程的并发是一种经过验证和测试的方法,自从多进程操作系统出现以来就非常流行,并且是并发的首选方法。大多数主流语言都提供了线程 API,允许用户创建线程并轻松地将代码的一部分卸载以进行独立执行。

在程序中利用并发遵循一个多步骤的过程。首先,我们需要确定问题中可以独立运行的部分。然后,我们需要寻找协调多个子任务的方法,这些子任务被分割以实现共同的目标。在这个过程中,线程可能还需要共享数据,并且需要同步来访问或写入共享数据。尽管并发带来了许多好处,但开发者还需要关注和计划一系列新的挑战和范式。在下一节中,我们将讨论并发的陷阱。

陷阱

并发的优势很多,但它也带来了一大堆复杂性和陷阱,我们必须应对。编写并发程序时可能会遇到以下问题:

  • 竞态条件:由于线程是由操作系统调度的,我们无法决定线程访问共享数据的顺序和方式。在多线程代码中,一个常见的用例是从多个线程更新全局状态。这遵循三个步骤——读取、修改和写入。如果这三个操作不是由线程原子性地执行,我们可能会遇到竞态条件。

如果一组操作以不可分割的方式一起执行,则该组操作是原子的。为了使一组操作成为原子操作,它必须在执行过程中不被抢占。它必须完全执行或不执行。

如果两个线程同时尝试更新同一内存位置上的值,它们可能会覆盖彼此的值,只有其中一个更新会被写入内存,或者值可能根本不会更新。这是一个经典的竞态条件示例。这两个线程都在没有彼此协调的情况下竞争更新值。这导致其他问题,如数据竞争。

  • 数据竞争:当多个线程试图同时写入内存中某个特定位置的数据时,很难预测将写入哪些值。内存中的最终结果也可能是垃圾值。数据竞争是竞争条件的结果,因为任何线程都必须以原子方式执行读取-修改-更新操作,以确保任何线程都能读取或写入一致的数据。

  • 内存不安全和未定义行为:竞争条件也可能导致未定义的行为。考虑以下伪代码:

// Thread A

Node get(List list) {
    if (list.head != NULL) {
        return list.head
    }
}

// Thread B
list.head = NULL

我们有两个线程,A 和 B,它们对链表进行操作。Thread A 尝试检索链表的头部。为了安全地执行此操作,它首先检查链表的头部不是 NULL,然后返回它。Thread B 将链表的头部设置为 NULL 值。这两个线程几乎同时运行,并且可能被操作系统以不同的顺序调度。例如,在一个执行实例中,Thread A 首先运行并断言 list.head 不是 NULL。紧接着,Thread A 被操作系统抢占,Thread B 被调度运行。现在,Thread Blist.head 设置为 NULL。随后,当 Thread A 再次获得运行机会时,它将尝试返回 list.head,这是一个 NULL 值。这将导致在读取 list.head 时发生段错误。在这种情况下,由于这些操作的顺序没有得到维护,发生了内存不安全。

对于之前提到的问题有一个常见的解决方案——同步或序列化对共享数据或代码的访问,或者确保线程原子地运行关键部分。这是通过使用同步原语,如互斥锁、信号量或条件变量来实现的。但即使使用这些原语也可能导致其他问题,如死锁。

死锁:除了竞争条件之外,线程面临的另一个问题是,在持有资源锁的同时,资源被耗尽。死锁是一种情况,其中线程 A 持有资源 a 并等待资源 b。另一个线程 B 持有资源 b 并等待资源 a。以下图表描述了这种情况:

死锁很难检测,但可以通过正确地获取锁来解决。在前面的例子中,如果线程 A 和线程 B 都试图首先获取锁,我们可以确保锁被正确释放。

在探讨了优势和陷阱之后,让我们来看看 Rust 提供的 API,用于编写并发程序。

Rust 中的并发

Rust 的并发原语依赖于原生操作系统线程。它通过标准库中的 std::thread 模块提供线程 API。在本节中,我们将从如何创建线程以并发执行任务的基本知识开始。在随后的章节中,我们将探讨线程如何相互共享数据。

线程基础

正如我们所说,每个程序都以主线程开始。要从程序的任何地方创建一个独立的执行点,主线程可以创建一个新的线程,该线程成为其子线程。子线程可以进一步创建自己的线程。让我们看看一个使用线程的最简单方式的 Rust 并发程序:

// thread_basics.rs

use std::thread;

fn main() {
    thread::spawn(|| {
        println!("Thread!");
        "Much concurrent, such wow!".to_string()
    });
    print!("Hello ");
}

main 中,我们调用 thread 模块中的 spawn 函数,该函数接受一个无参数闭包作为参数。在这个闭包中,我们可以编写任何我们想要以单独线程执行并发代码。在我们的闭包中,我们只是打印一些文本并返回 String。编译并运行这个程序,我们得到以下输出:

$ rustc thread_basics.rs
$ ./thread_basics
Hello

奇怪!我们只看到了 "Hello" 被打印出来。子线程中的 println!("Thread"); 发生了什么?对 spawn 的调用创建了线程并立即返回,线程开始并发执行,而不阻塞其后的指令。子线程处于分离状态。在子线程有机会运行其代码之前,程序到达了 print!("Hello"); 语句,并在从 main 返回时退出程序。因此,子线程中的代码根本不会执行。要允许子线程执行其代码,我们需要在子线程上等待。为此,我们需要首先将 spawn 返回的值赋给一个变量:

let child = thread::spawn(|| {
    print!("Thread!");
    String::from("Much concurrent, such wow!")
});

spawn 函数返回一个 JoinHandle 类型,我们将其存储在 child 变量中。这种类型是子线程的句柄,可以用来连接线程——换句话说,等待其终止。如果我们忽略线程的 JoinHandle 类型,就没有办法等待线程。继续我们的代码,我们在退出 main 之前在子线程上调用 join 方法,如下所示:

let value = child.join().expect("Failed joining child thread");

调用 join 会阻塞当前线程,并在执行 join 调用之后的任何代码行之前等待子线程完成。它返回一个 Result 值。由于我们知道这个线程不会崩溃,我们调用 expect 来解包 Result 类型,从而得到字符串。如果线程正在将自己连接或发生死锁,连接线程可能会失败,在这种情况下,它返回一个包含传递给 panic! 调用的值的 Err 变体。然而,在这种情况下,返回的值是 Any 类型,必须将其向下转换为适当的类型。我们的更新代码如下:

// thread_basics_join.rs

use std::thread;

fn main() {
    let child = thread::spawn(|| {
        println!("Thread!");
        String::from("Much concurrent, such wow!")
    });

    print!("Hello ");
    let value = child.join().expect("Failed joining child thread");
    println!("{}", value);
}

这是程序的输出:

$ ./thread_basics_join
Hello Thread!
Much concurrent, such wow!

太棒了!我们编写了第一个并发的 hello world 程序。让我们探索 thread 模块中的其他 API。

自定义线程

我们还有可以用来通过设置线程属性(如名称或堆栈大小)来配置线程的 API。为此,我们有 thread 模块中的 Builder 类型。以下是一个创建线程并使用 Builder 类型启动它的简单程序:

// customize_threads.rs

use std::thread::Builder;

fn main() {
    let my_thread = Builder::new().name("Worker Thread".to_string())
                                  .stack_size(1024 * 4);
    let handle = my_thread.spawn(|| {
        panic!("Oops!");
    });
    let child_status = handle.unwrap().join();
    println!("Child status: {}", child_status);
}

在前面的代码中,我们使用Builder::new方法,然后调用namestack_size方法分别为我们的线程添加名称和堆栈大小。然后我们在my_thread上调用spawn,这会消耗构建器实例并创建线程。这次,在我们的闭包中,我们使用panic!并传递一个"Oops"消息。以下是程序的输出:

$ ./customize_threads 
thread 'Worker Thread' panicked at 'Oops!', customize_threads.rs:9:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.
Child status: Err(Any)

我们可以看到线程具有我们给它起的相同名称——"Worker Thread"。注意返回的"Child status"消息是一个Any类型。线程中 panic 调用返回的值是Any类型,必须向下转换为特定类型。这就是关于创建线程的基础。

但前面代码示例中创建的线程并没有做什么。我们使用并发来解决可以分解为多个子任务的问题。在简单的情况下,这些子任务彼此独立,例如并行地对图像的每个像素应用过滤器。在其他情况下,线程中运行的子任务可能需要协调一些共享数据。

它们也可能在计算中做出贡献,其最终结果取决于线程的个别结果,例如,从多个线程中分块下载文件并将其传递给父管理线程。其他问题可能依赖于共享状态,例如 HTTP 客户端向需要更新数据库的服务器发送POST请求。在这里,数据库是所有线程共有的共享状态。这些都是并发的一些最常见用例,并且线程能够相互之间以及与父线程之间共享或传递数据是非常重要的。

让我们提高一下难度,看看我们如何在子线程中访问父线程中的现有数据。

从线程中访问数据

一个不与父线程通信或访问父线程数据的线程并没有什么作用。让我们以一个非常常见的模式为例,使用多个线程并发访问列表中的项目以执行一些计算。考虑以下代码:

// thread_read.rs

use std::thread;

fn main() {
    let nums = vec![0, 1, 2, 3, 4];
    for n in 0..5 {
        thread::spawn(|| {
            println!("{}", nums[n]);
        });
    }
}

在前面的代码中,我们在values中有5个数字,并创建了5个线程,其中每个线程都访问values中的数据。让我们编译这个程序:

图片

有趣!如果你从借用角度考虑,这个错误是有意义的。nums来自主线程。当我们创建一个线程时,它不一定在父线程之前退出,甚至可能比它存活得更久。当父线程返回时,nums变量消失了,它指向的Vec也被释放了。如果 Rust 允许前面的代码,子线程可以访问nums,它可能在main返回后具有一些垃圾值,并且它将经历段错误。

如果你查看编译器的帮助信息,它会建议我们在闭包内部移动或捕获nums。这样,从main中引用的nums变量就被移动到closure内部,它将不会在main线程中可用。

这里是使用move关键字将值从父线程移动到其子线程的代码:

// thread_moves.rs

use std::thread;

fn main() {
    let my_str = String::from("Damn you borrow checker!");
    let _ = thread::spawn(move || {
        println!("In thread: {}", my_str);
    });
    println!("In main: {}", my_str);
}

在前面的代码中,我们试图再次访问my_str。这会失败,并出现以下错误:

如前述错误信息所示,使用move后,你无法再次使用数据,即使我们只是在子线程中读取my_str。在这里,我们同样被编译器所拯救。如果子线程释放了数据,而我们从main中访问my_str,我们将访问一个已释放的值,这是一个使用后释放的问题。

正如你所见,在多线程环境中,所有权和借用规则同样适用。这是其设计中一个新颖的方面,它不需要额外的结构来强制执行正确的并发代码。但是,我们如何实现从线程访问数据的先前的用例呢?因为线程更有可能比它们的父线程存活时间更长,所以我们不能在线程中有引用。相反,Rust 为我们提供了同步原语,允许我们在线程之间安全地共享和通信数据。让我们来探索这些原语。这些类型通常根据需求分层组合,你只需为所使用的部分付费。

基于线程的并发模型

我们主要使用线程来执行可以分解为子问题的任务,其中线程可能需要相互通信或共享数据。现在,以线程模型为基础,有不同方式来构建我们的程序和控制对共享数据的访问。并发模型指定了多个线程如何相互作用以及它们如何随时间和空间(在这里是内存)的推移进行进展。

Rust 不偏好任何有偏见的并发模型,并让开发者根据他们试图通过第三方 crate 解决的问题使用自己的模型。因此,存在其他并发模型,包括在actixcrate 中实现的 actor 模型。还有其他模型,例如由rayoncrate 实现的 work stealing 并发模型。然后,还有crossbeamcrate,它允许并发线程从它们的父堆栈帧共享数据,并保证在父堆栈释放之前返回。

Rust 提供了两种流行的内置并发模型:通过同步共享数据和通过消息传递共享数据。

共享状态模型

使用共享状态将值传递给线程是最常用的方法,而实现这一点的同步原语存在于大多数主流语言中。同步原语是允许多个线程以线程安全的方式访问或操作一个值的类型或语言构造。Rust 也有许多同步原语,我们可以将它们包装在类型周围以使它们线程安全。

正如我们在上一节中看到的,我们不能从多个线程共享访问任何值。这里我们需要共享所有权。在第五章中,我们在内存管理和安全性部分介绍了Rc类型,它可以提供值的共享所有权。让我们尝试使用这个类型与我们的从多个线程读取数据的先前示例一起使用:

// thread_rc.rs

use std::thread;
use std::rc::Rc;

fn main() {
    let nums = Rc::new(vec![0, 1, 2, 3, 4]);
    let mut childs = vec![];
    for n in 0..5 {
        let ns = nums.clone();
        let c = thread::spawn(|| {
            println!("{}", ns[n]);
        });
        childs.push(c);
    }

    for c in childs {
        c.join().unwrap();
    }
}

这会导致以下错误:

图片

Rust 在这里也帮了我们。这是因为,正如之前提到的,Rc类型不是线程安全的,因为引用计数更新操作不是原子的。我们只能在单线程代码中使用Rc。如果我们想在多线程环境中拥有相同类型的共享所有权,我们可以使用Arc类型,它就像Rc一样,但具有原子引用计数能力。

使用 Arc 的共享所有权

之前的代码可以通过以下方式与多线程的Arc类型一起工作:

// thread_arc.rs

use std::thread;
use std::sync::Arc;

fn main() {
    let nums = Arc::new(vec![0, 1, 2, 3, 4]);
    let mut childs = vec![];
    for n in 0..5 {
        let ns = Arc::clone(&nums);
        let c = thread::spawn(move || {
            println!("{}", ns[n]);
        });

        childs.push(c);
    }

    for c in childs {
        c.join().unwrap();
    }
} 

在之前的代码中,我们只是将向量的包装器从Rc替换为Arc类型。另一个变化是,在我们从子线程引用nums之前,我们需要使用Arc::clone()对其进行克隆,这给我们一个拥有Arc<Vec<i32>>值的所有权,它指向相同的Vec。有了这个变化,我们的程序可以编译并提供对共享Vec的安全访问,以下为输出结果:

$ rustc thread_arc.rs
$./thread_arc
0
2
1
3
4

现在,在多线程代码中,另一个用例是从多个线程中修改共享值。让我们看看如何做到这一点。

从线程中修改共享数据

我们将查看一个示例程序,其中五个线程将数据推送到共享的Vec。以下程序尝试做同样的事情:

// thread_mut.rs

use std::thread;
use std::sync::Arc;

fn main() {
    let mut nums = Arc::new(vec![]);
    for n in 0..5 {
        let mut ns = nums.clone();
        thread::spawn(move || {
            nums.push(n);
        });
    }
}

我们有相同的numsArc包装。但我们不能修改它,因为编译器给出了以下错误:

图片

这不起作用,因为克隆Arc会提供对内部值的不可变引用。要从多个线程中修改数据,我们需要使用提供共享可变性的类型,就像RefCell一样。但与Rc类似,RefCell不能在多个线程中使用。相反,我们需要使用它们的线程安全变体,如MutexRwLock包装类型。让我们接下来探索它们。

Mutex

当需要安全地访问共享资源时,可以使用互斥锁(mutex)来提供访问。互斥锁(Mutex)是“互斥”的缩写,是一种广泛使用的同步原语,用于确保代码一次只由一个线程执行。一般来说,mutex 是一个保护对象,线程通过获取它来保护那些打算由多个线程共享或修改的数据。它通过锁定值来禁止一次从多个线程访问一个值。如果一个线程已经对 mutex 类型有了锁,那么其他线程将无法运行相同的代码,直到持有锁的线程完成操作。

标准库中的 std::sync 模块包含 Mutex 类型,允许以线程安全的方式从线程中修改数据。

以下代码示例展示了如何从单个子线程中使用 Mutex 类型:

// mutex_basics.rs

use std::sync::Mutex;
use std::thread;

fn main() {
    let m = Mutex::new(0);
    let c = thread::spawn(move || {
        {
            *m.lock().unwrap() += 1;
        }
        let updated = *m.lock().unwrap();
        updated
    });
    let updated = c.join().unwrap();
    println!("{:?}", updated);
}

运行此代码按预期工作。但是,当多个线程尝试以 Mutex 访问值时,这不会工作,因为 Mutex 不提供共享可变。要允许从多个线程中修改 Mutex 内部的值,我们需要将其组合到 Arc 类型中。让我们看看如何做到这一点。

使用 Arc 和 Mutex 共享可变

在探索了单线程上下文中 Mutex 的基础知识后,我们将重新审视上一节中的示例。以下代码使用 Arc 包装的 Mutex 从多个线程修改一个值:

// arc_mutex.rs

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let vec = Arc::new(Mutex::new(vec![]));
    let mut childs = vec![];
    for i in 0..5 {
        let mut v = vec.clone();
        let t = thread::spawn(move || {
            let mut v = v.lock().unwrap(); 
            v.push(i);
        });
        childs.push(t);
    }

    for c in childs {
        c.join().unwrap();
    }

    println!("{:?}", vec);
}

在前面的代码中,我们在 m 中创建了一个 Mutex 值。然后我们启动一个线程。你的机器上的输出可能会有所不同。

在互斥锁上调用 lock 将阻塞其他线程调用 lock,直到锁释放。因此,我们很重要的一点是要以细粒度的方式组织我们的代码。编译和运行此代码将给出以下输出:

$ rustc arc_mutex.rs
$ ./arc_mutex Mutex { data: [0,1,2,3,4] }

Mutex 还有一个类似的替代品,即 RwLock 类型,它对锁的类型有更多的了解,并且在读取比写入更频繁时可以提供更好的性能。让我们接下来探索它。

RwLock

虽然 Mutex 对于大多数用例来说都很好,但对于一些多线程场景,读取操作比写入操作更频繁。在这种情况下,我们可以使用 RwLock 类型,它也提供了共享可变,但可以在更细粒度的层面上做到这一点。RwLock 代表读者-写入者锁。使用 RwLock,我们可以同时有多个读者,但在给定的作用域内只有一个写入者。这比 Mutex 更好,因为 Mutex 对线程想要访问的类型一无所知。使用 RwLock

RwLock 公开了两种方法:

  • read:为线程提供读取访问。可以有多个读取调用。

  • write:为线程提供对包装类型的写入数据的独占访问。RwLock 实例可以有一个写入访问。

下面是一个示例程序,演示了使用 RwLock 而不是 Mutex

// thread_rwlock.rs

use std::sync::RwLock;
use std::thread;

fn main() {
    let m = RwLock::new(5);
    let c = thread::spawn(move || {
        {
            *m.write().unwrap() += 1;
        }
        let updated = *m.read().unwrap();
        updated
    });
    let updated = c.join().unwrap();
    println!("{:?}", updated);
}

但在某些系统(如 Linux)上的RwLock存在写者饥饿问题。这是一种当读者持续访问共享资源时,写线程从未有机会访问共享资源的情况。

通过消息传递进行通信

线程还可以通过一个更高级的抽象,称为消息传递,相互通信。这种线程通信模型消除了用户使用显式锁的需求。

标准库的std::sync::mpsc模块提供了一个无锁的多生产者、单订阅者队列,它作为想要相互通信的线程的共享消息队列。mpsc模块标准库有两种类型的通道:

  • channel:这是一个异步、无限缓冲区通道。

  • sync_channel:这是一个同步、有界缓冲区通道。

通道可以用来将数据从一个线程发送到另一个线程。让我们首先看看异步通道。

异步通道

这里是一个简单的生产者-消费者系统的例子,其中主线程产生值0, 1, ..., 9,而派生的线程打印它们:

// async_channels.rs

use std::thread;
use std::sync::mpsc::channel;

fn main() {
    let (tx, rx) = channel();
    let join_handle = thread::spawn(move || {
        while let Ok(n) = rx.recv() {
            println!("Received {}", n);
        }
    });

    for i in 0..10 {
        tx.send(i).unwrap();
    }

    join_handle.join().unwrap();
}

我们首先调用channel方法。这个方法返回两个值,txrxtx是发送端,类型为Sender<T>,而rx是接收端,类型为Receiver<T>。它们的名称只是一个约定,你可以将它们命名为任何东西。通常,你会在代码库中看到这些名称,因为它们简洁易写。

接下来,我们派生一个线程,它将从rx端接收值:

    let join_handle = thread::spawn(move || {
        // Keep receiving in a loop, until tx is dropped!
        while let Ok(n) = rx.recv() { // Note: `recv()` always blocks
            println!("Received {}", n);
        }
    });

我们使用while let循环。当tx被丢弃时,这个循环将接收到Err。丢弃发生在main返回时。

在前面的代码中,首先,为了创建mpsc队列,我们调用channel函数,它返回给我们Sender<T>Receiver<T>

Sender<T>是一个Clone类型,这意味着它可以被传递给多个线程,允许它们将消息发送到共享队列。

多生产者,单消费者mpsc)方法提供了多个写者但只有一个读者。这两个功能都返回一对通用类型:发送者和接收者。发送者可以用来将新事物推入通道,而接收者可以用来从通道中获取事物。发送者实现了Clone特质,而接收者则没有。

默认的异步通道中,send方法永远不会阻塞。这是因为通道缓冲区是无限的,所以总有空间。当然,它并不是真正无限的,只是概念上如此:如果你向通道发送了几十亿字节而没有接收任何东西,你的系统可能会耗尽内存。

同步通道

同步通道有一个有界缓冲区,当它满时,send方法会阻塞,直到通道中有更多空间。其用法与异步通道非常相似:

// sync_channels.rs

use std::thread; 
use std::sync::mpsc; 

fn main() { 
    let (tx, rx) = mpsc::sync_channel(1);
    let tx_clone = tx.clone();

    let _ = tx.send(0);

    thread::spawn(move || { 
        let _ = tx.send(1);
    }); 

    thread::spawn(move || {
        let _ = tx_clone.send(2);
    }); 

    println!("Received {} via the channel", rx.recv().unwrap());
    println!("Received {} via the channel", rx.recv().unwrap());
    println!("Received {} via the channel", rx.recv().unwrap());
    println!("Received {:?} via the channel", rx.recv());
}

同步通道的大小是 1,这意味着通道中不能有超过一个的项目。在这种情况下,任何在第一个发送之后的发送调用都会阻塞。然而,在前面的代码中,我们没有遇到阻塞(至少,是长阻塞),因为两个发送线程在后台工作,主线程可以接收它而不会在 send 调用上阻塞。对于这两种通道类型,如果通道为空,recv 调用将返回一个 Err 值。

Rust 中的线程安全

在上一节中,我们看到了编译器如何阻止我们共享数据。如果一个子线程以可变方式访问数据,它会被移动,因为 Rust 不允许它在父线程中使用,因为子线程可能会释放它,导致主线程中出现悬垂指针解引用。让我们来探讨线程安全的理念以及 Rust 的类型系统是如何实现这一点的。

什么是线程安全?

线程安全是类型或代码片段的一个属性,当由多个线程执行或访问时,不会导致意外行为。它指的是数据在读取时保持一致性,而在多个线程写入时保持安全。

Rust 只能保护你免受数据竞争的影响。它不旨在防止死锁,因为死锁很难检测。相反,它将这项工作委托给第三方 crate,如 parking_lot crate。

Rust 有一种新颖的方法来防止数据竞争。大多数线程安全的部分已经嵌入到 spawn 方法的类型签名中。让我们看看它的类型签名:

fn spawn<F, T>(f: F) -> JoinHandle<T>
    where F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static

这是一个看起来很吓人的类型签名。让我们通过解释每个部分的意义来让它不那么吓人。

spawn 是一个泛型函数,它对 FT 进行泛型化,并接受一个参数 f,返回一个名为 JoinHandle<T> 的泛型类型。随后,where 子句指定了多个特性约束:

  • F: FnOnce() -> T:这意味着 F 实现了一个只能调用一次的闭包。换句话说,f 是一个闭包,它以值的方式接受所有内容,并将环境中的引用项移动。

  • F: Send + 'static:这意味着闭包必须是 Send,并且必须具有 'static 生命周期,这意味着闭包环境中引用的任何类型也必须是 Send,并且必须在整个程序运行期间存在。

  • T: Send + 'static:闭包的返回类型 T 也必须实现 Send + 'static 特性。

如我们所知,Send 是一个标记特性。它仅仅用作类型级别的标记,表示该值可以安全地在线程间传递;大多数类型都是 Send。没有实现 Send 的类型包括指针、引用等。此外,Send 是一个自动特性或自动推导的特性,只要适用。复合数据类型,如结构体,如果其所有字段都是 Send,则实现 Send

线程安全特性

线程安全是这样一个想法,即如果你有想要从多个线程中读取的数据,对该值的任何读取或写入操作都不会导致不一致的结果。更新一个值的问题,即使是像 a += 1 这样的简单增加操作,它大致上可以转化为三个步骤——load increment store。可以安全更新的数据意味着应该被封装在线程安全类型,如 ArcMutex 中,以确保程序中的数据一致性。

在 Rust 中,你可以通过类型签名在编译时确保可以安全地在线程中使用和引用的类型。这些保证是通过特性实现的,即 SendSync 特性。

Send

Send 类型可以安全地发送到多个线程。这暗示了该类型是一个 move 类型。不是 Send 的类型是指针类型,例如 &T,除非 TSync

Send 特性在标准库的 std::marker 模块中有以下类型签名:

pub unsafe auto trait Send { }

在其定义中有三个重要的事项需要注意:首先,它是一个标记特性,没有主体或项目。其次,它以 auto 关键字为前缀,因为它在大多数适当的情况下是隐式实现的。第三,它是一个不安全的特性,因为 Rust 想确保开发者明确选择并确保他们的类型内置了线程安全的同步。

Sync

Sync 特性具有类似的方法签名:

pub unsafe auto trait Sync { }

这个特性表示实现了这个特性的类型可以在线程之间安全共享。如果某物是 Sync,那么它的引用,换句话说,&TSend。这意味着我们可以将它传递给多个线程。

使用演员模型进行并发

另一个与消息传递模型相当相似的并发模型是演员模型。演员模型随着电信行业流行的函数式编程语言 Erlang 的流行而变得流行,Erlang 以其健壮性和默认的分布式特性而闻名。

演员模型是一个概念模型,它使用称为演员的实体在类型级别实现并发。它最初由卡尔·爱迪·休伊特在 1973 年提出。它消除了对锁和同步的需求,并为系统中的并发引入提供了一种更干净的方式。演员模型由三件事组成:

  • 演员:这是演员模型中的一个核心原语。每个演员由其地址组成,我们可以通过这个地址向演员的邮箱发送消息,邮箱只是一个存储已接收消息的队列。队列通常是 先进先出FIFO)队列。演员的地址是必要的,这样其他演员才能向它发送消息。监督演员可以创建子演员,这些子演员可以创建其他子演员。

  • 消息:演员只通过消息进行通信。它们由演员异步处理。actix-web 框架提供了一个用于异步包装中同步操作的优雅包装器。

在 Rust 中,我们有实现 actor 模型的actixcrate。actixcrate 使用了 tokio 和 futures crate,我们将在第十二章中介绍,即《Rust 网络编程》。该 crate 的核心对象是 Arbiter 类型,它简单地说是一个在下面 spawn 一个事件循环的线程,并提供一个作为Addr类型的事件循环句柄。一旦创建,我们可以使用这个句柄向 actor 发送消息。

actix中,创建 actor 遵循一个简单的步骤:创建一个类型,定义一个消息,并为 actor 类型实现消息的处理程序。一旦完成这些,我们就可以创建 actor 并将它们 spawn 到创建的仲裁者之一中。

每个 actor 都在一个仲裁者中运行。

当我们创建 actor 时,它们不会立即执行。只有当我们将这些 actor 放入仲裁者线程中时,它们才开始执行。

为了使代码示例简单,并展示如何在 actix 中设置 actor 并运行它们,我们将创建一个可以相加两个数字的 actor。让我们通过运行cargo new actor_demo来创建一个新的项目,并在Cargo.toml中包含以下依赖项:

# actor_demo/Cargo.toml

[dependencies]
actix = "0.7.9"
futures = "0.1.25"
tokio = "0.1.15"

我们的main.rs包含以下代码:

// actor_demo/src/main.rs

use actix::prelude::*;
use tokio::timer::Delay;
use std::time::Duration;
use std::time::Instant;
use futures::future::Future;
use futures::future;

struct Add(u32, u32);

impl Message for Add {
    type Result = Result<u32, ()>;
}

struct Adder;

impl Actor for Adder {
    type Context = SyncContext<Self>;
}

impl Handler<Add> for Adder {
    type Result = Result<u32, ()>;

    fn handle(&mut self, msg: Add, _: &mut Self::Context) -> Self::Result {
        let sum = msg.0 + msg.0;
        println!("Computed: {} + {} = {}",msg.0, msg.1, sum);
        Ok(msg.0 + msg.1)
    }
}

fn main() {
    System::run(|| {
        let addr = SyncArbiter::start(3, || Adder);
        for n in 5..10 {
            addr.do_send(Add(n, n+1));
        }

        tokio::spawn(futures::lazy(|| {
            Delay::new(Instant::now() + Duration::from_secs(1)).then(|_| {
                System::current().stop();
                future::ok::<(),()>(())
            })
        }));
    });
}

在前面的代码中,我们创建了一个名为Adder的 actor。这个 actor 可以发送和接收类型为Add的消息。这是一个封装了要相加的两个数字的元组结构体。为了允许Adder接收和处理Add消息,我们为Adder参数化Add消息类型实现了Handler特质。在Handler实现中,我们打印出正在执行的计算,并返回给定数字的和。

在此之后,在main中,我们首先通过调用其run方法创建一个Systemactor,该方法接受一个闭包。在闭包内部,我们通过调用其start方法启动一个带有3个线程的SyncArbiter。这创建了 3 个准备接收消息的 actor。它返回一个Addr类型,这是一个事件循环的句柄,我们可以向Adderactor 实例发送消息。然后我们向我们的仲裁者地址addr发送 5 条消息。由于 System::run 是一个无限运行的父事件循环,我们在 1 秒的延迟后 spawn 一个 future 来停止 System actor。我们可以忽略这部分代码的细节,因为它只是以异步方式关闭 System actor。

有了这些,让我们运行这个程序:

$ cargo run
Running `target/debug/actor_demo`
Computed: 5 + 6 = 10
Computed: 6 + 7 = 12
Computed: 7 + 8 = 14
Computed: 8 + 9 = 16
Computed: 9 + 10 = 18

actixcrate 类似,Rust 生态系统中有其他 crate 实现了适用于不同用例的各种并发模型。

其他 crate

除了actix之外,我们还有一个名为rayon的 crate,它是一个基于工作窃取的数据并行库,使得编写并发代码变得非常简单。

另一个值得提到的 crate 是crossbeam,它允许编写可以访问其父堆栈帧数据的并发代码,并保证在父堆栈帧消失之前终止。

parking_lot 是另一个提供比标准库中现有的并发原语更快替代方案的 crate。如果你有一个标准库中的 MutexRwLock 性能不足的使用场景,那么你可以使用这个 crate 来获得显著的加速。

摘要

实际上非常令人惊讶的是,同样的所有权原则,它防止了单线程上下文中的内存安全违规,在与标记特质组合时也适用于多线程上下文。Rust 提供了简单且安全的 ergonomics,以最小的运行时成本将并发集成到你的应用程序中。在本章中,我们学习了如何使用 Rust 标准库提供的 threads API,并了解了在并发上下文中复制和移动类型是如何工作的。我们涵盖了通道、原子引用计数类型 Arc,以及如何使用 ArcMutex 一起,还探讨了并发的 actor 模型。

在下一章中,我们将深入探讨元编程,它完全是关于从代码生成代码的。

第九章:使用宏进行元编程

元编程是一个改变你对程序中指令和数据看法的概念。它允许你通过将指令视为其他任何数据片段来生成新的代码。许多语言都支持元编程,例如 Lisp 的宏、C 的 #define 结构和 Python 的元类。Rust 也不例外,它提供了许多形式的元编程,我们将在本章中探讨。

在本章中,我们将探讨以下主题:

  • 什么是元编程?

  • Rust 中的宏及其形式

  • 声明式宏、宏变量和类型

  • 重复结构

  • 过程式宏

  • 宏的使用案例

  • 可用的宏库

什么是元编程?

"Lisp 不仅仅是一种语言,它是一种建筑材料。"

– 阿兰·凯

任何程序,无论使用何种语言,都包含两个实体:数据和操纵数据指令。程序通常的流程主要关注数据的操纵。然而,指令的问题在于,一旦你编写了它们,就像它们已经被刻在石头上一样,因此它们是不可变的。如果我们能够将指令视为数据并使用代码生成新的指令,那就更有助于实现。元编程正是提供了这样的功能!

这是一种编程技术,你可以编写具有生成新代码能力的代码。根据语言的不同,它可以通过两种方式来处理:在运行时或在编译时。运行时元编程在动态语言如 Python、JavaScript 和 Lisp 中可用。对于编译语言,由于这些语言在编译时对程序进行编译,因此无法在运行时生成指令。然而,你可以在编译时生成代码,这正是 C 宏所提供的。Rust 也提供了编译时代码生成能力,这些能力比 C 宏更强大和可靠。

在许多语言中,元编程结构通常由总称表示,对于某些语言来说,这是一个内置特性。对于其他语言,它们作为单独的编译阶段提供。一般来说,宏接受任意序列的代码作为输入,并输出有效的代码,这些代码可以被语言编译或执行,并与其他代码一起。宏的输入不需要是有效的语法,你可以自由地为宏输入定义自己的自定义语法。此外,调用宏的方式以及定义它们的语法在不同的语言中是不同的。例如,C 宏在预处理阶段工作,它读取以#define开始的标签,并在将源文件转发给编译器之前将其展开。在这里,展开意味着通过用提供给宏的输入替换来生成代码。另一方面,Lisp 提供了类似于函数的宏,这些宏是用defmacro(一个宏本身)定义的,它接受正在创建的宏的名称和一个或多个参数,并返回新的 Lisp 代码。然而,C 和 Lisp 宏缺少一个被称为卫生性的属性。它们在展开时是非卫生的,这意味着它们可以捕获并干扰宏之外的定义的代码,这可能导致在代码的某些位置调用宏时出现意外的行为和逻辑错误。

为了展示缺乏卫生性的问题,我们将以一个 C 宏为例。这些宏只是简单地复制/粘贴代码并进行简单的变量替换,并且不具备上下文意识。用 C 编写的宏在意义上不是卫生的,因为它们可以引用任何地方定义的变量,只要这些变量在宏调用位置的作用域内。例如,以下是一个在 C 中定义的SWITCH宏,它可以交换两个值,但在交换过程中无意中修改了其他值:

// c_macros.c

#include <stdio.h> 

#define SWITCH(a, b) { temp = b; b = a; a = temp; } 

int main() { 
    int x=1; 
    int y=2; 
    int temp = 3; 

    SWITCH(x, y); 
    printf("x is now %d. y is now %d. temp is now %d\n", x, y, temp); 
}

使用gcc c_macros.c -o macro && ./macro编译此程序会得到以下输出:

x is now 2\. y is now 1\. temp is now 2

在前面的代码中,除非我们在SWITCH宏内部声明自己的temp变量,否则main中的原始temp变量会被SWITCH宏的展开所修改。这种非卫生性使得 C 宏不可靠且脆弱,除非采取特殊预防措施,例如在宏内部使用不同的temp变量名称,否则很容易造成混乱。

与此相反,Rust 宏是卫生的,并且比仅仅执行简单的字符串替换和展开更具有上下文意识。它们知道在宏内部引用的变量的作用域,并且不会影响已经在外部声明的标识符。考虑以下 Rust 程序,它试图实现我们之前使用的宏:

// c_macros_rust.rs

macro_rules! switch {
    ($a:expr, $b:expr) => {
        temp = $b; $b = $a; $a = temp;
    };
}

fn main() { 
    let x = 1; 
    let y = 2; 
    let temp = 3;
    switch!(x, y);
}

在前面的代码中,我们创建了一个名为switch!的宏,并在main中使用两个值xy调用了它。我们将跳过对宏定义的细节解释,因为我们将在这章的后面详细讨论它们。

然而,令我们惊讶的是,这不能编译,并且会失败,错误信息如下:

图片

从错误信息来看,我们的 switch! 宏对在 main 中声明的 temp 变量一无所知。正如我们所看到的,Rust 宏在处理时不会像 C 宏那样从其环境中捕获变量。即使它能够这样做,我们也会因为 temp 在前面的程序中被声明为不可变而避免修改。真 neat!

在我们开始编写更多这样的 Rust 宏之前,了解何时为你的问题使用基于宏的解决方案,何时不使用它是很重要的!

何时使用和何时不使用 Rust 宏

使用宏的一个优点是它们不像函数那样急于评估它们的参数,这是除了函数之外使用宏的动机之一。

通过急于评估,我们指的是像 foo(bar(2)) 这样的函数调用将首先评估 bar(2),然后将它的值传递给 foo。相反,这是一个懒评估,这就是你在迭代器中看到的情况。

一个一般的经验法则是,当函数无法提供所需的解决方案时,或者你有相当重复的代码时,或者你需要检查你的类型结构并在编译时生成代码时,可以使用宏。从实际用例中举例,Rust 宏在许多情况下都被使用,例如以下情况:

  • 通过创建自定义领域特定语言DSLs)来增强语言语法

  • 编写编译时序列化代码,就像 serde 所做的那样

  • 将计算移动到编译时,从而减少运行时开销

  • 编写样板测试代码和自动化测试用例

  • 提供零成本日志抽象,如 log crate

同时,宏应该谨慎使用,因为它们会使代码难以维护和推理,因为它们在元级别工作,而且并不是很多开发者会感到舒适地使用它们。它们使代码更难阅读,从可维护性的角度来看,可读性应该始终优先。此外,宏的过度使用可能会导致性能惩罚,因为会产生大量的重复代码生成,这会影响 CPU 指令缓存。

Rust 中的宏及其类型

Rust 宏在程序编译成二进制对象文件之前就完成了它们的代码生成魔法。它们接收输入,称为令牌树,并在解析的第二遍结束时,在抽象语法树AST)构建过程中进行展开。这些都是编译器领域的术语,需要一些解释,所以让我们来解释一下。为了理解宏是如何工作的,我们需要熟悉编译器如何处理源代码以理解程序。这将帮助我们理解宏如何处理其输入,以及当我们使用它们不正确时它们产生的错误信息。我们只涵盖与我们对宏理解相关的部分。

首先,编译器逐字节读取源代码并将字符分组为有意义的块,这些块被称为令牌。这是通过编译器的一个组件完成的,通常被称为分词器。因此,a + 3 * 6表达式被转换为"a", "+", "3", "*", "6",这是一个令牌序列。其他令牌可以是fn关键字、任何标识符、括号{} ()、赋值运算符=等等。这些令牌在宏的术语中被称为令牌树。还有一些可以分组其他令牌的令牌树,例如"(", ")", "}", "{"。现在,在这个阶段,令牌序列本身并不传达任何关于如何处理和解释程序的意义。为此,我们需要一个解析器

解析器将这个扁平的令牌流转换为层次结构,指导编译器如何解释程序。令牌树被传递给解析器,它构建一个内存中的程序表示,称为抽象语法树。例如,我们的令牌序列a + 3 * 6,它是一个表达式,当a的值为2时,可以评估为20

然而,除非我们分离运算符的优先级(即*+之前),并以树结构表示它们,否则编译器不知道如何正确评估这个表达式,如下面的图所示:

图片

当我们将表达式表示为代码中的树结构,使得乘法发生在加法之前时,我们可以对这个树进行后序遍历以正确评估表达式。因此,根据这个解释,我们的宏展开在这里是如何定位的?Rust 宏在抽象语法树构建的第二阶段结束时被解析,这是一个名称解析发生的阶段。名称解析是查找表达式定义中变量在作用域中存在性的阶段。在前面的表达式中,将进行对a变量的名称解析。现在,如果前述表达式中的a变量从宏调用(如let a = foo!(2 + 0);)中分配了一个值,那么解析器会在进行名称解析之前展开宏。名称解析阶段会捕捉程序中的错误,例如使用不在作用域中的变量。然而,还有比这更复杂的情况。

这意味着 Rust 宏是上下文感知的,并且根据宏展开的内容,它们只能出现在语言语法定义的支持位置。例如,你无法在项目级别(即在模块内)编写let语句。

语法定义了编写程序的有效方式,就像口语中的语法指导构建有意义的句子一样。对于那些好奇的人,Rust 的语法定义在doc.rust-lang.org/grammar.html

我们已经多次看到的一个宏实例是 println! 宏。它被实现为一个宏,因为它允许 Rust 在编译时检查其参数是否有效,以及传递给它的字符串插值变量数量是否正确。使用宏打印字符串的另一个优点是,它允许我们尽可能多地传递参数给 println!,如果它被实现为一个常规函数,这是不可能的。这是因为 Rust 不支持函数的可变参数。考虑以下示例:

println("The result of 1 + 1 is {}", 1 + 1); 
println!("The result of 1 + 1 is {}"); 

如您所知,第二种形式将在编译时失败,因为它缺少与格式字符串匹配的参数。这是在编译时报告的。因此,它比 C 的 printf 函数要安全得多,后者可能导致内存漏洞,如格式字符串攻击。println! 宏的另一个特性是我们可以自定义如何在字符串中打印值:

// print_formatting.rs

use std::collections::HashMap;

fn main() {
    let a = 3669732608;
    println!("{:p}", &a);
    println!("{:x}", a);

    // pretty printing
    let mut map = HashMap::new();
    map.insert("foo", "bar");
    println!("{:#?}", map);
}

在前面的代码中,我们可以通过 "{:p}""{:x}" 分别打印出存储在 a 中的值的内存地址和十六进制表示。这些被称为格式说明符。我们还可以使用 println! 中的 "{:#?}" 格式说明符以更类似于 JSON 的格式打印非原始类型。让我们编译并运行前面的程序:

error[E0277]: the trait bound `{integer}: std::fmt::Pointer` is not satisfied
 --> print_formatting.rs:7:22
  |
7 |     println!("{:p}", a);
  |                      ^ the trait `std::fmt::Pointer` is not implemented for `{integer}`

好的,我们遇到了一个错误。正如你可能已经注意到的,在第一个 println! 宏调用中,我们试图使用 "{:p}" 说明符打印 a 的地址,但提到的变量是一个数字。我们需要将一个引用,如 &a,传递给格式说明符。有了这个更改,前面的程序就可以编译了。所有这些格式化和检查字符串插值中适当值的工作都是在编译时完成的,这要归功于宏作为解析阶段的一部分的实现。

宏的类型

Rust 中存在不同形式的宏。一些允许你像函数一样调用它们,而另一些则允许你根据编译时条件有条件地包含代码。另一类宏允许你在编译时在方法上实现特质。它们可以大致分为两种形式:

  • 声明式宏:这是最简单的宏形式。它们使用 macro_rules! 创建,而 macro_rules! 本身也是一个宏。它们提供了调用函数相同的易用性,但通过末尾的 ! 可以轻松区分。它们是编写项目中小型宏的首选方法。定义它们的语法与编写匹配表达式的方式非常相似。它们被称为声明式,因为您已经拥有一个迷你领域特定语言(DSL),包括已识别的令牌类型和重复构造,您可以使用它们声明性地表达您想要生成的代码。您不需要编写如何生成代码,因为这由 DSL 处理。

  • 过程宏:过程宏是宏的更高级形式,它提供了对代码操作和生成的完全控制。这些宏不附带任何 DSL 支持,并且是过程性的,这意味着你必须编写代码来指定对于给定的标记树输入,代码应该如何生成或转换。缺点是它们实现起来比较复杂,需要一点对编译器内部结构和程序在编译器内存中表示的理解。虽然macro_rules!可以在项目的任何地方定义,但到目前为止,过程宏必须作为具有Cargo.toml中特殊属性proc-macro = true的独立 crate 创建。

使用macro_rules!创建你的第一个宏

让我们从声明式宏开始,首先使用macro_rules!宏构建一个。Rust 已经有一个println!宏,用于将内容打印到标准输出。然而,它没有用于从标准输入读取的等效宏。要从标准输入读取,你必须编写如下内容:

let mut input = String::new();
io::stdin().read_line(&mut input).unwrap();

这些代码行可以通过宏轻松抽象出来。我们将我们的宏命名为scanline!。以下是展示我们如何使用此宏的代码:

// first_macro.rs

fn main() {
    let mut input = String::new();
    scanline!(input);
    println!("{:?}", input);
}

我们希望能够创建一个String实例,并将其直接传递给scanline!,该函数会处理从标准输入读取的所有细节。如果我们通过运行rustc first_macro.rs来编译前面的代码,我们会得到以下错误:

error: cannot find macro `scanline!` in this scope
 --> first_macro.rs:5:5
  |
5 |     scanline!(input);
  |     ^^^^^^^^

error: aborting due to previous error

rustc找不到scanline!宏,因为我们还没有定义它,所以让我们先定义它:

// first_macro.rs

use std::io::stdin;

// A convenient macro to read input as string into a buffer
macro_rules! scanline {
    ($x:expr) => ({
        stdin().read_line(&mut $x).unwrap();
        $x.trim();
    });
}

要创建scanline!宏,我们使用macro_rules!宏,后跟宏名称scanline!,然后是一对花括号。在花括号内,我们有类似于匹配臂的东西。这些被称为匹配规则。每个匹配规则由三个部分组成。第一部分是模式匹配器,即($x:expr)部分,后面跟着一个=>,然后是代码生成块,它可以由(){}或甚至[]来界定。当有多个规则要匹配时,匹配规则必须以分号结束。

在前面的代码中,左边的符号($x:expr)在括号内是规则,其中$x是一个标记树变量,需要在冒号:后面指定类型,它是一个expr标记树类型。它们的语法类似于我们指定函数参数的方式。当我们用任何标记序列作为输入调用scanline!宏时,它会被捕获在$x中,并在代码生成块的右侧以相同的变量引用。expr标记类型意味着这个宏只能接受表达式。我们稍后会介绍macro_rules!接受的其它类型的标记。在代码生成块中,我们有要生成的多行代码,所以我们有一个成对的括号,它们用于处理多行表达式。匹配规则以分号结束。如果我们需要生成单行代码,我们也可以省略括号。我们想要的生成代码如下:

io::stdin().read_line(&mut $x).unwrap();

注意,read_line接受的东西看起来不像一个标识符的正确可变引用,即它是一个&mut $x$x会被替换为我们传递给宏的实际表达式。就是这样;我们刚刚编写了我们的第一个宏!完整的代码如下:

// first_macro.rs

use std::io;

// A convenient macro to read input as string into a buffer
macro_rules! scanline {
    ($x:expr) => ({
        io::stdin().read_line(&mut $x).unwrap();
    });
}

fn main() {
    let mut input = String::new();
    scanline!(input);
    println!("I read: {:?}", input);
}

main函数中,我们首先创建我们的input字符串,它将存储用户输入的内容。接下来,我们调用scanline!宏,并传递input变量。在这个宏内部,它被引用为$x,正如我们在前面的定义中看到的。当调用scanline时,当编译器看到调用时,它会用以下内容替换它:

io::stdin().read_line(&mut input).unwrap();

这里是运行前面代码并从标准输入输入字符串Alice的输出:

$ Alice
I read: "Alice\n"

在代码生成之后,编译器还会检查生成的代码是否有意义。例如,如果我们用不在匹配规则中考虑的其他项调用scanline!(比如传递一个fn关键字,如scanline!(fn)),我们会得到以下错误:

此外,即使我们传递一个表达式(比如,2),它在传递给这个宏时是有效的(因为它也是一个expr),但在这种上下文中没有意义,Rust 也会捕获并报告如下:

这很棒!现在,我们也可以给我们的宏添加多个匹配规则。所以,让我们添加一个空规则,覆盖我们只想让scanline!为我们分配String的情况,从stdin读取,并返回字符串。要添加一个新规则,我们修改代码如下:

// first_macro.rs

macro_rules! scanline {
    ($x:expr) => ({
        io::stdin().read_line(&mut $x).unwrap();
    });
    () => ({
        let mut s = String::new();
        stdin().read_line(&mut s).unwrap();
        s
    });
}

我们添加了一个空匹配规则``() => {}。在大括号内,我们生成了一堆代码,首先在s中创建一个String实例,调用read_line,并传递&mut s。最后,我们将s返回给调用者。现在,我们可以调用我们的scanline!而不需要一个预先分配的String`缓冲区:

// first_macro.rs

fn main() {
    let mut input = String::new();
    scanline!(input);
    println!("Hi {}",input);
    let a = scanline!();
    println!("Hi {}", a);
}

重要的是要注意,我们无法在函数外部调用此宏。例如,模块根部的 scanline! 调用将失败,因为在 mod {} 声明内编写 let 语句是不合法的。

标准库中的内置宏

除了 println! 之外,标准库中还有其他一些有用的宏,它们是通过使用 macro_rules! 宏实现的。了解它们将帮助我们欣赏使用宏的地方和情况,这样既不会牺牲可读性,又是一个更干净的解决方案。

其中一些宏如下:

  • dbg!:这个宏允许你打印带有其值的表达式的值。这个宏移动传递给它的任何内容,所以如果你只想提供对其类型的读取访问,你需要传递这个宏的引用。它作为一个在运行时跟踪表达式的实用宏。

  • compile_error!:这个宏可以在编译时从代码中报告错误。当你构建自己的宏并想向用户报告任何语法或语义错误时,这是一个很有用的宏。

  • concat!:这个宏可以用来连接传递给它的任意数量的字面量,并返回连接后的字面量作为 &'static str

  • env!:这个宏在编译时检查环境变量。在许多语言中,从环境变量中访问值主要是运行时完成的。在 Rust 中,通过使用这个宏,你可以在编译时解析环境变量。请注意,当找不到定义的变量时,此方法会引发恐慌,因此这个宏的安全版本是 option_env! 宏。

  • eprint!eprintln!:这与 println! 类似,但将消息输出到标准错误流。

  • include_bytes!:这个宏可以用作快速读取文件为字节数组的快捷方式,例如&'static [u8; N]。传递给它的文件路径是相对于调用此宏的当前文件解析的。

  • stringify!:如果你想要获取类型或令牌的文本表示作为字符串,这个宏很有用。当我们编写自己的过程宏时,我们会使用这个宏。

如果你想探索标准库中可用的完整宏集,可以在doc.rust-lang.org/std/#macros找到。

macro_rules! 令牌类型

在我们构建更复杂的宏之前,熟悉 macro_rules! 可以接受的合法输入非常重要。由于 macro_rules! 在语法层面工作,它需要为用户提供对这些语法元素的访问,并区分可以在宏中包含的内容以及我们如何与之交互。

以下是一些重要的令牌树类型,您可以将它们作为输入传递给宏:

  • block:这是一个语句序列。我们已经在调试示例中使用了 block。它匹配任何由花括号分隔的语句序列,就像我们之前使用的那样:
{ silly; things; } 

这个块包含了 sillythings 两个语句。

  • expr: 这匹配任何表达式,例如:

    • 1

    • x + 1

    • if x == 4 { 1 } else { 2 }

  • ident: 这匹配一个标识符。标识符是任何非关键字(如 iflet)的 Unicode 字符串。作为例外,Rust 中单独的下划线字符不是标识符。以下是一些标识符的示例:

    • x

    • long_identifier

    • SomeSortOfAStructType

  • item: 这匹配一个项目。模块级别的对象被识别为项目 这包括函数、使用声明、类型定义等。以下是一些示例:

    • use std::io;

    • fn main() { println!("hello") }

    • const X: usize = 8;

当然,这些不必是一行代码。main 函数可以是一个单独的项目,即使它跨越了多行。

  • meta: 一个 meta 项。属性内部的参数称为元项,由 meta 捕获。属性本身看起来如下:

    • #![foo]

    • #[baz]

    • #[foo(bar)]

    • #[foo(bar="baz")]

    • 元项是括号内的东西。因此,对于前面的每个属性,相应的元项如下:

      • foo

      • baz

      • foo(baz)

      • foo(bar="baz")

  • pat: 这是一个模式。匹配表达式在每行的左侧都有模式,由 pat 捕获。以下是一些示例:

    • 1

    • "x"

    • t

    • *t

    • Some(t)

    • 1 | 2 | 3

    • 1 ... 3

    • _

  • path: 它匹配一个有资格的名称 路径是有资格的名称,即带有命名空间附加的名称。它们与标识符非常相似,除了它们允许在名称中使用双冒号,因为它们表示路径。以下是一些示例:

    • foo

    • foo::bar

    • Foo

    • Foo::Bar::baz

这在需要捕获某些类型的路径以便稍后在代码生成中使用时很有用,例如在用路径别名复杂类型时。

  • stmt: 这是一个语句。语句类似于表达式,但 stmt 可以接受更多模式。以下是一些示例:

    • let x = 1

    • 1

    • foo

    • 1+2

与第一个示例相比,let x = 1 不会被 expr 接受。

  • tt: 这是一个令牌树,它是一系列其他令牌。tt 关键字捕获单个令牌树。令牌树要么是一个单独的令牌(如 1+"foo bar"),要么是任何花括号 (), [], 或 {} 包围的几个令牌。以下是一些示例:

    • foo

    • { bar; if x == 2 { 3 } else { 4 }; baz }

    • { bar; fi x == 2 ( 3 ] ulse ) 4 {; baz }

如您所见,令牌树内部的元素不必具有语义意义;它们只需是令牌的序列。具体来说,不匹配的是两个或更多未括在花括号内的令牌(如 1 + 2)。这是 macro_rules! 可以捕获的最一般代码或令牌序列。

  • ty: 这是一个 Rust 类型。ty 关键字捕获看起来像类型的对象。以下是一些示例:

    • u32

    • u33

    • String

在宏展开阶段,不会进行任何语义检查以确定类型实际上是一个类型,所以 "u33""u32" 都会被接受。然而,一旦代码生成并进入语义分析阶段,类型就会被检查,并给出错误信息“error: expected type, found u33``”。这用于当你生成创建函数或实现类型上特质的方法的代码时。

  • vis:这代表一个可见性修饰符。它捕获可见性修饰符 pubpub(crate) 等。当你生成模块级别的代码并需要在传递给宏的代码片段中捕获隐私修饰符时,这很有用。

  • lifetime:标识一个生命周期,如 'a'ctx'foo 等。

  • literal:可以是任何标记,例如字符串字面量如 "foo" 或标识符如 bar

宏中的重复

除了标记树类型之外,我们还需要一种方法来重复生成代码的某些部分。标准库中的一个实际例子是 vec![] 宏,它依赖于重复来产生变长参数的错觉,并允许你以以下任何一种方式创建 Vec:

vec![1, 2, 3];
vec![9, 8, 7, 6, 5, 4];

让我们看看 vec! 是如何做到这一点的。以下是标准库中 vec 的 macro_rules! 定义:

macro_rules! vec {
    ($elem:expr; $n:expr) => (
        $crate::vec::from_elem($elem, $n)
    );
    ($($x:expr),*) => (
        <[_]>::into_vec(box [$($x),*])
    );
    ($($x:expr,)*) => (vec![$($x),*])
}

通过忽略 => 右侧的细节,并关注左侧最后两个匹配规则,我们可以在这条规则中看到一些新的内容:

($($x:expr),*)
($($x:expr,)*)

这些是重复规则。重复模式规则如下:

  • pattern$($var:type)*。注意 $()*。为了便于引用,我们将它们称为重复器。此外,让我们将内部的 ($x:expr) 表示为 X。重复器有三种形式:

    • *,表示重复需要发生零次或多次

    • +,表示重复至少需要发生一次或更多次

    • ?,表示标记最多重复一次

重复器也可以包括额外的字面字符,这些字符可以是重复的一部分。在 vec! 的情况下,有逗号字符,我们需要支持它以在宏调用中区分 Vec 中的每个元素。

在第一个匹配规则中,逗号字符位于 X 之后。这允许使用如 vec![1, 2, 3,] 这样的表达式。

第二个匹配规则中,逗号位于元素之后的 X 内。这是一个典型的情况,可以匹配如 1, 2, 3 这样的序列。我们在这里需要两个规则,因为第一个规则无法处理没有尾随逗号的情况,而这通常是常见的情况。此外,vec! 中的模式使用 *,这意味着 vec![] 也是宏的一个允许的调用。如果使用 +,则不会。

现在,让我们看看捕获的重复规则如何在代码生成块中的右侧传递。在第二个匹配规则中,vec! 宏只是使用相同的语法将它们转发到一个 Box 类型:

($($x:expr),*) => (<[_]>::into_vec(box [$($x),*])); 

我们可以在左侧的标记树变量声明和右侧的使用之间看到的唯一区别是,右侧不包括标记变量的类型(expr)。第三个匹配规则只是依赖于第二个规则的代码生成块,并调用vec![$($x),*],从而改变逗号的位置并再次调用它。这意味着我们也可以在宏内部调用宏,这是一个非常强大的功能。所有这些都可以达到相当元级别的程度,你应该尽可能追求更简单、可维护的宏。

现在,让我们看看如何构建一个使用重复的宏。

一个更复杂的宏——编写 HashMap 初始化的领域特定语言(DSL)

带着重复和标记树类型的知识,让我们使用macro_rules!中的重复来构建一些实用的东西。在本节中,我们将构建一个 crate,它公开一个宏,允许你创建如下所示的HashMap

let my_map = map! {
    1 => 2,
    2 => 3
};

与手动调用HashMap::new()后跟一个或多个insert调用相比,这更加简洁和易读。让我们通过运行cargo new macro_map --lib并使用macro_rules!的初始块来创建一个新的cargo项目:

// macro_map/lib.rs

#[macro_export]
macro_rules! map {
    // todo
}

由于我们希望用户使用我们的宏,我们需要在这个宏定义上添加#[macro_export]属性。默认情况下,宏在模块中是私有的,这与其他项目类似。我们将我们的宏命名为map!,因为我们正在构建自己的语法来初始化HashMap,我们将采用k => v语法,其中k是 HashMap 中的键,v是值。以下是我们在map! {}中的实现:

macro_rules! map {
    ( $( $k:expr => $v:expr ),* ) => {
        {
            let mut map = ::std::collections::HashMap::new();
            $(
                map.insert($k, $v);
            )*
            map
        }
    };
}

让我们来理解这里的匹配规则。首先,我们将检查内部部分,它是( $k:expr => $v:expr )。让我们将这个规则部分称为Y。所以,Y捕获我们的键k和值v字面量,它们之间用=>连接,并以expr表示。围绕Y的部分是($(Y),*),表示Y可以重复零次或多次,由逗号分隔。在大括号内的匹配规则右侧,我们首先创建一个HashMap实例。然后,我们编写重复器$()*,其中包含我们的map.insert($k, $v)代码片段,它将重复与我们的宏输入相同的次数。

让我们快速为它编写一个测试:

// macro_map/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn test_map_macro() {
        let a = map! {
            "1" => 1,
            "2" => 2
        };

        assert_eq!(a["1"], 1);
        assert_eq!(a["2"], 2);
    }
}

通过运行cargo test,我们得到以下输出:

running 1 test
test tests::test_map_macro ... ok

太好了!我们的测试通过了,现在我们可以使用我们闪亮的新map!宏方便地初始化HashMap了!

宏用例——编写测试

在编写单元测试用例时,宏的使用相当频繁。假设你正在编写一个 HTTP 客户端库,并且想要测试你的客户端在多种 HTTP 动词(如 GETPOST)以及各种不同的 URL 上的表现。通常,你会为每种请求类型和 URL 创建函数。然而,有一种更好的方法来做这件事。使用宏,你可以通过构建一个用于执行测试的小型 DSL(领域特定语言)来减少测试时间,这个 DSL 便于阅读,并且在编译时也可以进行类型检查。为了演示这一点,让我们通过运行 cargo new http_tester --lib 创建一个新的 crate,其中包含我们的宏定义。这个宏实现了一种小型语言,用于描述简单的 HTTP GET/POST 测试到 URL。以下是这种语言的一个示例:

http://duckduckgo.com GET => 200
http://httpbin.org/post POST => 200, "key" => "value"

第一行向 duckduckgo.com 发送 GET 请求,并期望返回代码为 200(状态正常)。第二行向 httpbin.org 发送 POST 请求,并带有 "key"="value" 的表单参数,使用自定义语法。它也期望返回代码为 200。这非常简单,但对于演示目的来说是足够的。

我们假设我们的库已经实现,并将使用一个名为 reqwest 的 HTTP 请求库。我们将在 Cargo.toml 文件中添加对 reqwest 的依赖:

# http_tester/Cargo.toml

[dependencies]
reqwest = "0.9.5"

这里是 lib.rs:

// http_tester/src/lib.rs

#[macro_export]
macro_rules! http_test { 
    ($url:tt GET => $code:expr) => { 
        let request = reqwest::get($url).unwrap(); 
        println!("Testing GET {} => {}", $url, $code);
        assert_eq!(request.status().as_u16(), $code); 
    }; 
    ($url:tt POST => $code:expr, $($k:expr => $v:expr),*) => {
        let params = [$(($k, $v),)*];
        let client = reqwest::Client::new();
        let res = client.post($url)
            .form(&params)
            .send().unwrap();
        println!("Testing POST {} => {}", $url, $code); 
        assert_eq!(res.status().as_u16(), $code);
    };
}

#[cfg(test)]
mod tests {
    #[test]
    fn test_http_verbs() {
        http_test!("http://duckduckgo.com" GET => 200);
        http_test!("http://httpbin.org/post" POST => 200, "hello" => "world", "foo" => "bar");
    }
}

在宏定义中,我们只是匹配规则,这是 GETPOST 被视为字面标记的地方。在分支中,我们创建我们的请求客户端,并断言由宏接收到的输入返回的状态码。POST 测试用例还有一个用于提供查询参数的自定义语法,如 key => value,这些参数被收集到 params 变量中。然后,这些参数被传递到 reqwest::post 构建方法的 form 方法中。当我们到达第十三章 构建 Web 应用程序时,我们将更深入地探讨请求库。

让我们运行 cargo test 看看输出结果:

running 1 test
test tests::test_http_verbs ... ok

抽时间思考一下在这里使用宏的好处。这也可以作为一个带有 #[test] 注解的函数调用的实现,但即使在这个基本形式中,宏也有几个好处。一个好处是 HTTP 动词在编译时进行检查,我们的测试现在更加声明式。如果我们尝试使用未考虑到的测试用例(比如 HTTP DELETE)调用宏,我们会得到以下错误:

error: no rules expected the token `DELETE`

除了用于枚举测试用例之外,宏还可以用于根据某些外部环境状态(如数据库表、日期和时间等)生成 Rust 代码。它们可以用来装饰结构体,在编译时为它们生成任意代码,或者创建新的 linter 插件以进行额外的静态分析,这些分析是 Rust 编译器本身不支持的。一个很好的例子是 clippy lint 工具,我们之前已经使用过。宏还可以用来生成调用本地 C 库的代码。我们将在第十章中看到它是如何发生的,不安全 Rust 和外部函数接口

练习

如果你已经觉得宏很有用,这里有一些练习供你尝试,以便你可以进一步探索宏:

  1. 编写一个宏,它接受以下语言:
        language = HELLO recipient;
        recipient = <String>;

例如,以下字符串将在这个语言中是可接受的:

        HELLO world!
        HELLO Rustaceans!

让宏生成一个针对收件人的问候语。

  1. 编写一个宏,它接受任意数量的元素,并以字面字符串的形式输出一个无序列表,例如,html_list!([1, 2]) => <ul><li>1/<li><li>2</li></ul>.

过程宏

当你的代码生成逻辑变得复杂时,声明性宏可能会变得难以阅读和维护,因为你需要用自己定义的 DSL 来操作标记。与使用 macro_rules! 相比,有更好、更灵活的方法。对于复杂问题,你可以利用过程宏,因为它们更适合编写非平凡的东西。它们适用于需要完全控制代码生成的场景。

这些宏作为函数实现。这些函数接收宏输入作为 TokenStream 类型,并在编译时经过任何转换后返回生成的代码作为 TokenStream。为了将一个函数标记为过程宏,我们需要用 #[proc_macro] 属性来注释它。在撰写本书时,过程宏有三种形式,它们根据如何调用进行分类:

  • 函数式进程宏: 这些使用函数上的 #[proc_macro] 属性。lazy_static 库中的 lazy_static 宏使用函数式宏。

  • 类似属性的进程宏: 这些使用函数上的 #[proc_macro_attribute] 属性。wasm-bindgen 库中的 #[wasm-bindgen] 属性使用这种形式的宏。

  • 推导过程宏: 这些使用 #[proc_macro_derive]。在大多数 Rust 库中,如 serde,这些宏是最频繁实现的宏。由于引入它们的 RFC 名称,它们也被称为 推导宏宏 1.1

在撰写这本书的时候,过程宏 API 在使用 TokenStream 上非常有限,所以我们需要使用第三方 crate,如 synquote,将输入解析为 Rust 代码数据结构,然后根据你的需要进行分析,以生成代码。此外,过程宏需要作为一个单独的 crate 创建,并带有特殊的 crate 属性 proc-macro = true,这在 Cargo.toml 中指定。要使用宏,我们可以像其他 crate 一样在 Cargo.toml 的依赖项中指定它,并通过 use 语句导入宏。

在所有三种形式中,derive 宏是过程宏最广泛使用的形式。我们将在下一节深入探讨它们。

Derive 宏

我们已经看到,我们可以在任何结构体、枚举或联合类型上写 #[derive(Copy, Debug)] 来为它实现 CopyDebug 特性,但这种自动推导功能仅限于编译器中的一些内置特性。使用 derive 宏或宏 1.1,你可以在任何结构体、枚举或联合类型上推导出你自己的自定义特性,从而减少了你需要手动编写的样板代码量。这看起来可能像是一个利基用例,但它是最常用的过程宏形式,高性能的 crate,如 serdediesel 都使用它。derive 宏仅适用于如结构体、枚举或联合这样的数据类型。为在类型上实现特性创建自定义 derive 宏需要以下步骤:

  1. 首先,你需要你的类型以及你想要在类型上实现的特性。这些可以来自任何 crate,无论是本地定义的还是第三方定义的,只要其中之一必须由你定义,因为孤儿规则。

  2. 接下来,我们需要创建一个新的 crate,并在 Cargo.toml 中将 proc-macro 属性设置为 true。这标志着这个 crate 是一个过程宏 crate。这样做是因为过程宏需要生活在它们自己的 crate 中,按照当前实现。这种作为 crate 的分离可能在将来会改变。

  3. 然后,在这个 crate 中,我们需要创建一个带有 proc_macro_derive 属性的函数。我们将 Foo 特性名称作为参数传递给 proc_macro_derive 属性。这个函数是我们写 #[derive(Foo)] 在任何 structenumunion 上时会被调用的函数。

只有带有 proc_macro_derive 属性的函数才能从这个 crate 中导出。

然而,直到我们在实际代码中看到这一切,所有这些才显得有些模糊。因此,让我们构建自己的 derive 宏 crate。我们将要构建的宏能够将任何给定的结构体转换为动态的键值映射,例如 BTreeMap<String, String>。选择 BTreeMap 是为了在字段上有一个排序的迭代,这与 HashMap 不同,尽管你也可以使用哈希表。

我们还将使用两个包,synquote,这将允许我们将代码解析为方便的数据结构,我们可以检查和操作它。我们将为这个项目构建三个包。首先,我们将通过运行 cargo new into_map_demo 创建一个二进制包,它使用我们的库包和派生宏包。以下是我们 Cargo.toml 文件中的依赖项:

# into_map_demo/Cargo.toml

[dependencies]
into_map = { path = "into_map" }
into_map_derive = { path = "into_map_derive" }

之前的 into_mapinto_map_derive 包被指定为本地包,作为路径依赖项。然而,我们还没有它们,所以让我们在同一个目录下通过运行以下命令来创建它们:

  • cargo new into_map:这个包将包含我们的特性作为单独的库

  • cargo new into_map_derive:这是我们派生宏包

现在,让我们检查我们的 main.rs 文件,它包含以下初始代码:

// into_map_demo/src/main.rs

use into_map_derive::IntoMap;

#[derive(IntoMap)]
struct User {
    name: String,
    id: usize,
    active: bool
}

fn main() {
    let my_bar = User { name: "Alice".to_string(), id: 35, active: false };
    let map = my_bar.into_map();
    println!("{:?}", map);
}

在前面的代码中,我们使用 #[derive(IntoMap)] 注释了 User 结构体。#[derive(IntoMap)] 将调用来自 into_map_derive 包的过程宏。由于我们还没有实现 IntoMap 派生宏,所以这不会编译。然而,这显示了作为此包的消费者我们希望如何使用宏。接下来,让我们看看 into_map 包的 lib.rs 文件中有什么:

// into_map_demo/into_map/src/lib.rs

use std::collections::BTreeMap;

pub trait IntoMap {
    fn into_map(&self) -> BTreeMap<String, String>;
}

我们的 lib.rs 文件仅包含一个 IntoMap 特性定义,该定义有一个名为 into_map 的方法,它接受对 self 的引用并返回一个 BTreeMap<String, String>。我们希望通过我们的派生宏为我们的 User 结构体派生 IntoMap 特性。

让我们接下来检查我们的 into_map_derive 包。在这个包中,我们在 Cargo.toml 中有以下依赖项:

# into_map_demo/into_map_derive/src/Cargo.toml

[lib]
proc-macro = true

[dependencies]
syn = { version = "0.15.22", features = ["extra-traits"] }
quote = "0.6.10"
into_map = { path="../into_map" }

正如我们之前提到的,我们使用 proc-macro 属性将 [lib] 部分注释为 true。我们还使用了 synquote,因为它们帮助我们从 TokenStream 实例解析 Rust 代码。syn 包创建了一个内存中的数据结构,称为 AST,它表示一段 Rust 代码。然后我们可以使用这个结构来检查我们的源代码并程序化地提取信息。quote 包是 syn 包的补充,因为它允许你在提供的 quote! 宏内生成 Rust 代码,并允许你从 syn 数据类型中替换值。我们还依赖于 into_map 包,我们从其中将 IntoMap 特性引入到我们的宏定义的作用域内。

我们希望这个宏生成的代码看起来可能如下所示:

impl IntoMap for User {
    fn into_map(&self) -> BTreeMap<String, String> {
        let mut map = BTreeMap::new();
        map.insert("name".to_string(), self.name.to_string());
        map.insert("id".to_string(), self.id.to_string());
        map.insert("active".to_string(), self.active.to_string());
        map
    }
}

我们希望在 User 结构体上实现 into_map 方法,但我们希望它是自动为我们生成的。对于具有许多字段的结构体,手动编码这种情况相当繁琐。在这种情况下,派生宏非常有帮助。让我们看看一个实现示例。

在高层次上,into_map_derive包中的代码生成分为两个阶段。在第一阶段,我们遍历结构体的字段,收集将项插入BTreeMap的代码。生成的insert代码标记将看起来像这样:

map.insert(field_name, field_value);

这将被收集到一个向量中。在第二阶段,我们取所有生成的insert代码标记,并将它们扩展成另一个标记序列,这是User结构体的impl块。

让我们从lib.rs中的实现开始探索:

// into_map_demo/into_map_derive/src/lib.rs

extern crate proc_macro;
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields};

#[proc_macro_derive(IntoMap)]
pub fn into_map_derive(input: TokenStream) -> TokenStream {
    let mut insert_tokens = vec![];
    let parsed_input: DeriveInput = parse_macro_input!(input);
    let struct_name = parsed_input.ident;
    match parsed_input.data {
        Data::Struct(s) => {
            if let Fields::Named(named_fields) = s.fields {
                let a = named_fields.named;
                for i in a {
                    let field = i.ident.unwrap();
                    let insert_token = quote! {
                        map.insert(
                            stringify!(#field).to_string(),
                            self.#field.to_string()
                        );
                    };
                    insert_tokens.push(insert_token);
                }
            }
        }
        other => panic!("IntoMap is not yet implemented for: {:?}", other),
    }

哇,这有很多看起来很奇怪的代码!让我们逐行分析。首先,我们的into_map_derive函数被#[proc_macro_derive(IntoMap)]属性注释。我们可以给这个函数任何名字。这个函数接收一个TokenStream作为输入,这将是我们User结构体的声明。然后我们创建一个insert_tokens列表来存储我们的输入标记,这是实际代码生成的一部分。我们稍后会解释这一点。

我们随后从syn包中调用parse_macro_input!宏,传入input标记流。这使我们从parsed_input变量中获取一个DeriveInput实例。parsed_input代表我们的User结构定义作为一个标记数据结构。从那里,我们使用parsed_input.ident字段提取结构名称。接下来,我们匹配parsed_input.data字段,它返回它是什么类型的项:结构体、枚举或联合。

为了使我们的实现更简单,我们只为结构体实现IntoMap特质,所以我们只匹配当parsed_input.dataData::Struct(s)时。内部的s再次是一个表示构成结构定义的项的结构体。我们感兴趣的是s有哪些字段,特别是命名字段,所以我们使用if let来特别匹配。在if块内部,我们获取我们结构体所有字段的引用,然后遍历它们。对于每个字段,我们使用来自quote包的quote!宏为我们的btree映射生成插入代码:

map.insert(
    stringify!(#field).to_string(),
    self.#field.to_string()
);
insert_tokens.push(insert_token);

注意#field符号。在quote!宏内部,我们可以有模板变量,这些变量将在生成的代码中用它们的值替换。在这种情况下,#field被替换为我们结构体中存在的任何字段。首先,我们使用stringify!宏将#field转换为字符串字面量,这是来自syn包的Ident类型。然后我们将这个生成的代码块推入insert_tokens向量。

随后,我们进入代码生成的最终阶段:

    let tokens = quote! {
        use std::collections::BTreeMap;
        use into_map::IntoMap;

        impl IntoMap for #struct_name {
            /// Converts the given struct into a dynamic map
            fn into_map(&self) -> BTreeMap<String, String> {
                let mut map = BTreeMap::new();
                #(#insert_tokens)*
                map
            }
        }
    };

    proc_macro::TokenStream::from(tokens)
}

在这里,我们终于生成了我们结构的最终impl块。在quote!块内,无论我们写什么,都会被原样生成,包括缩进和代码注释。首先,我们导入BtreeMap类型和IntoMap特质。然后,我们有IntoMap实现。在其中,我们创建我们的map,并展开我们在代码生成的第一阶段收集到的insert_tokens。在这里,外部的#()*重复器告诉quote!宏重复相同的代码零次或多次。对于如我们的insert_tokens这样的可迭代项,这将重复其中的所有项。这会生成将结构体中的字段名和字段值插入到map中的代码。最后,我们取存储在tokens变量中的整个实现代码,通过调用TokenStream::from(tokens)将其作为TokenStream返回。就这样!让我们在main.rs中尝试这个宏:

// into_map_demo/src/main.rs

use into_map_derive::IntoMap;

#[derive(IntoMap)]
struct User {
    name: String,
    id: usize,
    active: bool
}

fn main() {
    let my_bar = User { name: "Alice".to_string(), id: 35, active: false };
    let map = my_bar.into_map();
    println!("{:?}", map);
}

运行cargo run给出以下输出:

{"active": "false", "id": "35", "name": "Alice"}

太好了,它工作了。接下来,让我们看看我们如何调试宏。

调试宏

在开发复杂宏时,大多数情况下你需要方法来分析你的代码是如何扩展到宏输入的。你可以在你想看到生成代码的地方使用println!panic!,但这是一种非常原始的调试方式。尽管如此,还有更好的方法。Rust 社区为我们提供了一个名为cargo-expand的子命令。这个子命令是由 David Tonlay 在github.com/dtolnay/cargo-expand开发的,他也是synquotecrate 的作者。这个命令内部调用夜间编译器的标志-Zunstable-options --pretty=expanded,但子命令的设计是这样的,它不需要你手动切换到夜间工具链,因为它会自动找到并切换。为了演示这个命令,我们将以我们的IntoMap派生宏为例,并观察它为我们生成了什么代码。通过切换到目录并运行cargo expand,我们得到以下输出:

图片

如您所见,底部的impl块是由IntoMap派生宏生成的。cargo-expand还包括了格式化并带有语法高亮的输出。这个命令对于编写复杂宏的人来说是一个必备的工具。

有用的过程宏 crate

由于过程宏可以作为 crate 分发,因此有很多新兴的有用宏 crate 可用,可以在crates.io上找到。使用它们可以大大减少你需要编写的用于生成 Rust 代码的样板代码。以下是一些例子:

  • derive-new:派生宏为结构体提供了一个默认的全字段构造函数,并且相当可定制。

  • derive-more:一个绕过限制的 derive 宏,这个限制是我们对一个已经有很多特质自动实现的类型进行包装,但失去了为它创建自己的类型包装的能力。这个 crate 帮助我们为这些包装类型提供相同的一组特质。

  • lazy_static:这个 crate 提供了一个类似函数的过程宏 lazy_static!,其中你可以声明需要动态初始化类型的 static 值。例如,你可以声明一个配置对象作为 HashMap,并且可以在整个代码库中全局访问它。

摘要

在本章中,我们介绍了 Rust 的元编程能力,并探讨了多种宏。最常用的宏是 macro_rules!,它是一个声明式宏。声明式宏在抽象语法树级别上工作,这意味着它们不支持任意扩展,但要求宏扩展在 AST 中是良好形成的。对于更复杂的使用案例,你可以使用过程宏,这样你就可以完全控制对输入的处理和生成所需的代码。我们还探讨了使用 cargo 子命令 cargo-expand 调试宏的方法。

宏确实是一个强大的工具,但并不是应该被过度使用的东西。只有当更常见的抽象机制,如函数、特性和泛型,不足以解决手头的问题时,我们才应该转向宏。此外,宏会使代码对代码库的新手来说更难以阅读,应该避免使用。话虽如此,它们在编写测试用例条件时非常有用,并且被开发者广泛使用。

在下一章中,我们将一瞥 Rust 的另一面,即不推荐但如果你想要 Rust 与不同语言交互时不可避免的不安全部分。

第十章:不安全的 Rust 和外部函数接口

Rust 是一种具有两种模式的语言:安全模式(默认模式)和不安全模式。在安全模式下,你将获得各种安全特性来保护你免受严重错误的影响,但有时你需要摆脱编译器提供的安全束缚,获得额外的控制级别。一个用例是与其他语言(如 C 语言)交互,这可能会非常不安全。在本章中,你将了解当 Rust 需要与其他语言交互时需要做哪些额外的工作,以及如何使用不安全模式来促进并明确这种交互。

本章将涵盖以下主题:

  • 理解安全和不安全模式

  • Rust 中不安全的操作

  • 外部函数接口,与 C 语言通信以及反之

  • 使用 PyO3 与 Python 交互

  • 使用 Neon 与 Node.js 交互

什么是真正的安全和不安全?

“你可以这样做,但你最好知道你在做什么。”

  • Rustacean

当我们谈论编程语言中的安全性时,这是一个跨越不同级别的属性。一种语言可以是内存安全的、类型安全的,或者它可以具有并发安全。内存安全意味着程序不会写入禁止的内存地址,也不会访问无效的内存。类型安全意味着程序不允许你将数字赋值给字符串变量,并且这个检查发生在编译时,而并发安全意味着程序在多个线程执行并修改共享状态时不会导致竞争条件。如果一个语言本身提供了所有这些级别的安全性,那么它就被认为是安全的。更普遍地说,如果一个程序在所有可能的执行和所有可能的输入下都能给出正确的输出,不会导致崩溃,并且不会损坏或破坏其内部或外部状态,那么这个程序被认为是安全的。在 Rust 的安全模式下,这确实是正确的!

一个不安全的程序是指在运行时违反了不变性或触发了未定义行为的程序。这些不安全的效果可能仅限于函数内部,也可能在程序中作为全局状态传播开来。其中一些是由程序员自己造成的,例如逻辑错误,而另一些则可能是由于所使用的编译器实现的副作用,有时甚至来自语言规范本身。不变性是在程序执行过程中,所有代码路径上必须始终为真的条件。最简单的例子是,指向堆上对象的指针在代码的某个部分内不应为空。如果这个不变性被打破,依赖于该指针的代码可能会取消引用它并发生崩溃。像 C/C++这样的语言以及基于它们的语言是不安全的,因为编译器规范中将相当多的操作归类为未定义行为。未定义行为是在程序中遇到编译器规范未指定在较低级别发生什么的情况的效果,你可以自由地假设任何事情都可能发生。未定义行为的一个例子是使用未初始化的变量。考虑以下 C 代码:

// both_true_false.c

int main(void) {
    bool var;
    if (var) {
        fputs("var is true!\n");
    }
    if (!var) {
        fputs("var is false!\n");
    }
    return 0;
}

这个程序的输出在不同的 C 编译器实现中可能不同,因为使用未初始化的变量是一个未定义的操作。在某些启用了某些优化的 C 编译器上,你甚至可能会得到以下输出:

var is true
var is false

在生产环境中看到你的代码采取这种不可预测的代码路径是不希望看到的。C 中未定义行为的另一个例子是超出大小为n的数组末尾写入。当写入发生到内存中的n + 1偏移量时,程序可能会崩溃或修改随机的内存位置。在最佳情况下,程序会立即崩溃,你会了解到这一点。在最坏的情况下,程序可能会继续运行,但可能会后来损坏代码的其他部分并给出错误的结果。C 中的未定义行为最初存在是为了允许编译器优化代码以获得性能,并假设某些边缘情况永远不会发生,因此不添加错误检查代码来处理这些情况,以避免与错误处理相关的开销。如果可以将未定义行为转换为编译时错误那就太好了,但有时在编译时检测这些行为可能会变得资源密集,因此不这样做可以保持编译器实现简单。

现在,当 Rust 需要与这些语言交互时,它对这些语言中函数调用和类型在较低级别如何表示了解得非常有限,并且由于未定义行为可能在意想不到的地方发生,它回避了所有这些陷阱,并提供了一个特殊的unsafe {}块来与来自其他语言的元素交互。在unsafe模式下,你获得了一些额外的能力来做一些在 C/C++中会被视为未定义行为的事情。然而,权力越大,责任越大。一个在其代码中使用unsafe的开发者必须小心在unsafe块中执行的操作。在 Rust 的unsafe模式下,责任在你身上。Rust 信任程序员保持操作的安全性。幸运的是,这个unsafe特性是以非常受控的方式提供的,并且通过阅读代码很容易识别,因为unsafe代码总是用unsafe关键字或unsafe {}块进行注释。这与 C 语言不同,在 C 语言中,大多数事情都可能是不安全的。

现在,重要的是要提到,虽然 Rust 提供了保护你免受程序中主要不安全情况的方法,但也有 Rust 无法救你出来的情况,即使你编写的程序是安全的。这些是存在如下逻辑错误的情况:

  • 一个程序使用浮点数来表示货币。然而,浮点数并不精确,会导致舍入误差。这种错误在一定程度上是可以预测的(因为,给定相同的输入,它总是以相同的方式表现出来)并且容易修复。这是一个逻辑和实现错误,Rust 对此类错误不提供保护。

  • 控制航天器的程序使用原始数字作为函数中的参数来计算距离度量。然而,一个库可能提供了一个 API,其中距离是以公制系统解释的,而用户可能提供英制系统的数字,从而导致无效的测量。1999 年,在 NASA 的火星气候轨道器航天器中就发生了类似的错误,造成了近 1250 万美元的损失。Rust 虽然不能完全保护你免犯此类错误,但借助枚举和 newtype 模式等类型系统抽象,我们可以将不同的单位彼此隔离,并限制 API 的表面仅限于有效操作,从而使这种错误的可能性大大降低。

  • 一个程序在没有适当的锁定机制的情况下从多个线程写入共享数据。错误以不可预测的方式表现出来,找到它可能非常困难,因为它是非确定性的。在这种情况下,Rust 通过其所有权和借用规则完全保护你免受数据竞争的影响,这些规则也适用于并发代码,但它不能为你检测死锁。

  • 一个程序通过指针访问一个对象,在某些情况下,这个指针是空指针,导致程序崩溃。在安全模式下,Rust 完全保护您免受空指针的影响。然而,当使用不安全模式时,程序员必须确保来自其他语言的指针操作是安全的。

Rust 的不安全特性在程序员比编译器更了解情况,并且必须在其代码中实现一些复杂部分的情况下也是必需的,在这些情况下,编译时所有权规则变得过于严格,并阻碍了操作。例如,假设有一个需要将字节序列转换为String值的情况,并且您知道您的Vec<u8>是一个有效的 UTF-8 序列。在这种情况下,您可以直接使用不安全的String::from_utf_unchecked方法,而不是通常的安全String::from_utf8方法,以绕过在from_utf8方法中检查有效 UTF-8 的额外开销,并可以略微提高速度。此外,在进行底层嵌入式系统开发或任何与操作系统内核交互的程序时,您需要切换到不安全模式。然而,并非所有内容都需要不安全模式,并且有几个操作是 Rust 编译器视为不安全的。它们如下:

  • 更新可变静态变量

  • 解引用原始指针,例如*const T*mut T

  • 调用不安全函数

  • 从联合类型中读取值

  • extern块中调用声明的函数——来自其他语言的项

在上述情况下,一些内存安全保证被放宽,但借用检查器在这些操作中仍然活跃,并且所有的作用域和所有权规则仍然适用。Rust 关于不安全的文档doc.rust-lang.org/stable/reference/unsafety.html区分了被认为是未定义的和不是不安全的。当您执行上述操作时,为了容易区分,Rust 要求您使用unsafe关键字。它只允许少数地方被标记为unsafe,如下所示:

  • 函数和方法

  • 不安全块表达式,例如unsafe {}

  • 特性

  • 实现块

不安全函数和块

让我们来看看不安全函数和块,从不安全函数开始:

// unsafe_function.rs

fn get_value(i: *const i32) -> i32 { 
    *i
}

fn main() {
    let foo = &1024 as *const i32;
    let _bar = get_value(foo);
}

我们定义了一个get_value函数,它接受一个指向i32值的指针,它通过解引用简单地返回指向的值。在main中,我们将foo传递给get_value,它是1024转换为*const i32i32值的引用。如果我们尝试运行这个程序,编译器会显示以下信息:

正如我们之前所说的,我们需要一个unsafe函数或块来解引用原始指针。让我们按照第一个建议,在我们的函数前添加unsafe

unsafe fn get_value(i: *const i32) -> i32 { 
    *i
}

现在,让我们再次尝试运行这个程序:

有趣!我们消除了get_value函数上的错误,但现在在main函数的call位置显示了另一个错误。调用不安全函数需要我们在其中包裹一个unsafe块。这是因为除了 Rust 的不安全函数之外,不安全函数还可以是声明在extern块中的其他语言的函数。这些函数可能或可能不会返回调用者期望的值,或者返回一个完全损坏的值。因此,在调用不安全函数时,我们需要unsafe块。我们修改我们的代码,在unsafe块中调用get_value,如下所示:

fn main() {
    let foo = &1024 as *const i32;
    let bar = unsafe { get_value(foo) };
}

unsafe块是表达式,所以我们从get_value之后移除分号,并将其移到unsafe块之外,这样get_value的返回值就被分配给了bar。有了这个变化,我们的程序就可以编译了。

不安全函数的行为与普通函数类似,但它在其中允许上述操作,并且将你的函数声明为unsafe会使它无法从普通的安全函数中调用。然而,我们也可以将get_value写成另一种方式:

fn get_value(i: *const i32) -> i32 { 
    unsafe { 
        *i 
    }
}

这看起来与之前相似,但有一个显著的变化。我们将unsafe关键字从函数签名移动到了一个内部的unsafe块中。现在,函数执行相同的非安全操作,但将其封装在一个看起来就像普通的安全函数的函数中。现在,这个函数可以在调用者侧不需要不安全块的情况下被调用。这种技术通常用于提供看起来安全的库接口,尽管它们在内部执行不安全操作。显然,如果你这样做,你应该特别小心确保unsafe块是正确的。标准库中有相当多的 API 使用这种在unsafe块中隐藏操作,同时在表面上提供安全 API 的模式。例如,String类型的insert方法,它将字符ch插入到指定的索引idx处,定义如下:

// https://doc.rust-lang.org/src/alloc/string.rs.html#1277-1285

pub fn insert(&mut self, idx: usize, ch: char) {
    assert!(self.is_char_boundary(idx));
    let mut bits = [0; 4];
    let bits = ch.encode_utf8(&mut bits).as_bytes();
    unsafe {
        self.insert_bytes(idx, bits);
    }
}

首先,它会检查传递给它的idx是否位于 UTF-8 编码的代码点序列的开始或结束位置。然后,它将传递给它的ch编码为一系列字节。最后,它在unsafe块中调用一个不安全的方法insert_bytes,传递idxbits

标准库中有许多类似的 API,它们的实现方式相似,它们在内部依赖于不安全块,要么是为了获得速度提升,要么是因为它们需要可变访问值的各个部分,因为所有权阻碍了这种情况。

现在,如果我们从之前的代码片段中调用我们的get_value函数,并传递一个数字作为参数,然后将其转换为指针,你就可以猜到接下来会发生什么:

unsafe_function(4 as *const i32); 

运行这段代码会得到以下输出:

这是一个明显的段错误信息!从这个观察结果中可以得出的结论是,即使unsafe函数在外观上看起来是安全的,如果用户提供了格式不正确的值,它也可能被无意或故意地误用。因此,如果你需要从你的库中公开一个不安全的 API,其中操作的安全性依赖于用户提供的参数,作者应该清楚地记录这一点,以确保他们没有传递无效的值,并且应该用unsafe标记函数,而不是在内部使用unsafe块。

unsafe块后面的安全包装函数实际上不应该暴露给消费者,而应该主要用于在库中隐藏实现细节,就像许多标准库 API 实现的情况一样。如果你不确定你是否已经成功创建了一个围绕不安全部分的安全包装,你应该将这个函数标记为unsafe

不安全特性和实现

除了函数之外,特性也可以被标记为不安全。我们为什么需要不安全特性并不明显。不安全特性最初存在的一个主要动机是标记那些不能发送到或在不同线程之间共享的类型。这是通过不安全的SendSync标记特性实现的。这些类型也是自动特性,这意味着在适当的时候,它们会被实现为标准库中的大多数类型。然而,它们也被明确地排除在某些类型之外,例如Rc<T>Rc<T>没有原子引用计数机制,如果它实现了Sync并在多个线程中使用,我们可能会得到错误的类型引用计数,这可能导致提前释放和悬挂指针。将SendSync标记为unsafe将责任放在了开发者身上,即只有当他们在自定义类型中实现了适当的同步时,才应该实现它。SendSync被标记为unsafe是因为为没有明确的语义来描述类型在从多个线程中突变时的行为的类型实现它们是不正确的。

将特性标记为不安全的另一个动机是通过类型族封装可能具有未定义行为的操作。正如我们之前提到的,特性本质上用于指定实现类型必须遵守的契约。现在,假设你的类型包含来自 FFI 边界的实体,即包含 C 字符串引用的字段,并且你有许多这样的类型。在这种情况下,我们可以通过使用不安全特性来抽象这些类型的操作,然后我们可以有一个泛型接口,它接受实现此不安全特性的类型。Rust 标准库中的一个这样的例子是Searcher特性,它是Pattern特性的关联类型,定义在doc.rust-lang.org/std/str/pattern/trait.Pattern.htmlSearcher特性是一个不安全特性,它抽象了从给定字节序列中搜索项的概念。Searcher的一个实现者是CharSearcher结构体。将其标记为unsafe消除了Pattern特性在有效 UTF-8 字节边界上检查有效切片的负担,并且可以在字符串匹配中带来一些性能提升。

在了解了不安全特性的动机之后,让我们看看我们如何定义和使用不安全特性。将特性标记为不安全并不会使你的方法变得不安全。我们可以有不安全特性,它们有安全的方法。反之亦然;我们可以有一个安全的特性,它可以在其中包含不安全的方法,但这并不表示该特性是不安全的。不安全特性与函数的表示方式相同,只需在它们前面加上unsafe关键字:

// unsafe_trait_and_impl.rs

struct MyType;

unsafe trait UnsafeTrait { 
    unsafe fn unsafe_func(&self);
    fn safe_func(&self) {
        println!("Things are fine here!");
    }
}

trait SafeTrait {
    unsafe fn look_before_you_call(&self);
}

unsafe impl UnsafeTrait for MyType {
    unsafe fn unsafe_func(&self) {
        println!("Highly unsafe");
    }
}

impl SafeTrait for MyType {
    unsafe fn look_before_you_call(&self) {
        println!("Something unsafe!");
    }
}

fn main() {
    let my_type = MyType;
    my_type.safe_func();
    unsafe {
        my_type.look_before_you_call();
    }
}

在前面的代码中,我们有许多包含不安全特性和方法的变体。首先,我们有两种特性声明:UnsafeTrait,这是一个不安全的特性,而SafeTrait是安全的。我们还有一个名为MyType的单例结构体,它实现了这两个特性。正如你所见,不安全特性需要unsafe前缀来实现MyType,这样让实现者知道他们必须遵守特性所期望的契约。在MyType上的SafeTrait的第二种实现中,我们有一个需要在unsafe块内调用的不安全方法,正如我们在main函数中所见。

在接下来的章节中,我们将探讨一些语言以及 Rust 如何与它们交互。Rust 提供的相关 API 和抽象,用于在语言之间安全地双向通信,通常被称为外部函数接口FFI)。作为标准库的一部分,Rust 为我们提供了内置的 FFI 抽象。在这些抽象之上构建的包装库提供了无缝的跨语言交互。

从 Rust 调用 C 代码

首先,我们将看看一个从 Rust 调用 C 代码的例子。我们将创建一个新的二进制包,从该包中我们将调用定义在单独的 C 文件中的我们的 C 函数。让我们通过运行 cargo new c_from_rust 来创建一个新的项目。在目录内,我们还将添加我们的 C 源文件,即 mystrlen.c 文件,其内部代码如下:

// c_from_rust/mystrlen.c

unsigned int mystrlen(char *str) { 
    unsigned int c; 
    for (c = 0; *str != '\0'; c++, *str++); 
    return c; 
} 

它包含一个简单的函数,mystrlen,该函数返回传递给它的字符串的长度。我们希望从 Rust 中调用 mystrlen。为此,我们需要将这个 C 源文件编译成一个静态库。在下一节中,我们还有一个例子,其中我们将介绍如何动态链接到共享库。我们将在 Cargo.toml 文件中将 cc 包用作构建依赖项:

# c_from_rust/Cargo.toml

[build-dependencies]
cc = "1.0"

cc 包负责编译和链接我们的 C 源文件与我们的二进制文件,并使用正确的链接器标志。为了指定我们的构建命令,我们需要在包根目录下放置一个 build.rs 文件,其内容如下:

// c_from_rust/build.rs

fn main() {
    cc::Build::new().file("mystrlen.c")
                    .static_flag(true)
                    .compile("mystrlen");
}

我们创建了一个新的 Build 实例,并在给我们的静态对象文件命名之前,将 C 源文件名和静态标志设置为 true 传递给 compile 方法。Cargo 在编译任何项目文件之前,都会运行任何 build.rs 文件的内容。在运行 build.rs 中的代码时,cc 包会自动在 C 库中追加传统的 lib 前缀,因此我们的编译后的静态库生成在 target/debug/build/c_from_rust-5c739ceca32833c2/out/libmystrlen.a

现在,我们还需要让 Rust 知道我们的 mystrlen 函数的存在。我们通过使用 extern 块来实现,这样我们就可以指定来自其他语言的项。我们的 main.rs 文件如下:

// c_from_rust/src/main.rs

use std::os::raw::{c_char, c_uint};
use std::ffi::CString; 

extern "C" { 
    fn mystrlen(str: *const c_char) -> c_uint; 
}

fn main() { 
    let c_string = CString::new("C From Rust").expect("failed"); 
    let count = unsafe { 
        mystrlen(c_string.as_ptr()) 
    }; 
    println!("c_string's length is {}", count);
}

我们从 std::os::raw 模块中导入了一些与原始 C 类型兼容的类型,并且它们的名称接近它们的 C 对应类型。对于数值类型,类型前的单个字母表示该类型是否是无符号的。例如,无符号整数定义为 c_uint。在我们的 extern 声明 mystrlen 中,我们以 *const c_char 作为输入,这相当于 C 中的 char *,并以 c_uint 作为输出,这映射到 C 中的 unsigned int。我们还从 std::ffi 模块中导入了 CString 类型,因为我们需要将一个与 C 兼容的字符串传递给 mystrlen 函数。std::ffi 模块包含了一些常用的实用工具和类型,使得跨语言交互变得容易。

如您可能已注意到,在extern块中,我们跟随着一个字符串"C"。这个"C"指定了我们希望编译器的代码生成器符合 C ABI(cdecl),以便函数调用约定与从 C 进行的函数调用完全一致。应用二进制接口(*ABI)基本上是一套规则和约定,它规定了在较低级别如何表示和处理类型和函数。函数调用约定是 ABI 规范的一个方面。它与库消费者对 API 的含义非常相似。在函数的上下文中,API 指定了你可以从库中调用的函数,而 ABI 指定了调用函数的较低级机制。调用约定定义了诸如函数参数是存储在寄存器中还是堆栈上,以及调用者在函数返回时是否清除寄存器/堆栈状态,以及其他细节。我们也可以忽略指定这一点,因为"C"cdecl)是 Rust 中extern块中声明的项目的默认 ABI。cdecl是一种大多数 C 编译器用于函数调用的调用约定。Rust 还支持其他 ABI,如fastcallcdeclwin64等,并且需要根据目标平台在extern块之后指定。

在我们的main函数中,我们使用std::ffi模块中的一个特殊版本的CString字符串,因为 C 中的字符串是空终止的,而 Rust 中的字符串不是。CString为我们执行所有检查,以提供一个与 C 兼容的字符串版本,其中字符串中间没有空0字节字符,并确保结尾字节是一个0字节。ffi模块包含两种主要的字符串类型:

  • std::ffi::CStr表示一个类似于&str的借用 C 字符串。它可以用来引用在 C 中创建的字符串。

  • std::ffi::CString表示一个与外国 C 函数兼容的所有权字符串。它通常用于将字符串从 Rust 代码传递到外国 C 函数。

由于我们希望从 Rust 一侧将字符串传递到我们刚刚定义的函数,所以我们在这里使用了CString类型。随后,我们在一个不安全块中调用mystrlen,将c_string作为指针传递。然后我们将字符串长度打印到标准输出。

现在,我们只需要运行cargo run。我们得到以下输出:

图片

cc 包会自动确定要调用的正确 C 编译器。在我们的例子中,在 Ubuntu 上,它会自动调用 gcc 来链接我们的 C 库。现在,这里有一些需要改进的地方。首先,我们必须在一个 unsafe 块中调用函数,这很尴尬,因为我们知道这并不是不安全的。我们知道我们的 C 实现是可靠的,至少对于这个小的函数来说是这样。其次,如果 CString 创建失败,我们会引发 panic。为了解决这个问题,我们可以创建一个安全的包装器函数。在简单形式下,这意味着创建一个函数,在该函数中调用外部函数,并将其放在 unsafe 块内部:

fn safe_mystrlen(str: &str) -> Option<u32> { 
    let c_string = match CString::new(str) { 
        Ok(c) => c, 
        Err(_) => return None 
    };

    unsafe { 
        Some(mystrlen(c_string.as_ptr())) 
    } 
} 

我们的 safe_mystrlen 函数现在返回一个 Option,如果 CString 创建失败,则返回 None,然后调用包裹在 unsafe 块中的 mystrlen,并作为 Some 返回。调用 safe_mystrlen 的感觉就像调用任何其他 Rust 函数一样。如果可能的话,建议围绕外部函数创建安全的包装器,确保在 unsafe 块内部发生的所有异常情况都得到妥善处理,这样库的用户就不会在他们的代码中使用 unsafe

从 C 调用 Rust 代码

如我们在上一节所述,当 Rust 库通过 extern 块将它们的函数暴露给其他语言时,它们默认暴露 C ABI (cdecl)。因此,从 C 调用 Rust 代码变得非常无缝。我们将通过一个从 C 程序调用 Rust 代码的例子来查看。让我们通过运行 cargo new rust_from_c --lib 来创建一个 cargo 项目。

在我们的 Cargo.toml 文件中,我们有以下项目:

# rust_from_c/Cargo.toml

[package]
name = "rust_from_c"
version = "0.1.0"
authors = ["Rahul Sharma <creativcoders@gmail.com>"]
edition = "2018"

[lib]
name = "stringutils"
crate-type = ["cdylib"]

[lib] 部分,我们指定了 crate 为 cdylib,这表示我们想要生成一个可动态加载的库,这在 Linux 中更常见地被称为共享对象文件(.so)。我们为我们的 stringutils 库指定了一个显式的名称,这将用于创建共享对象文件。

现在,让我们看看我们在 lib.rs 中的实现:

// rust_from_c/src/lib.rs

use std::ffi::CStr;
use std::os::raw::c_char;

#[repr(C)]
pub enum Order {
    Gt,
    Lt,
    Eq
}

#[no_mangle]
pub extern "C" fn compare_str(a: *const c_char, b: *const c_char) -> Order {
    let a = unsafe { CStr::from_ptr(a).to_bytes() };
    let b = unsafe { CStr::from_ptr(b).to_bytes() };
    if a > b {
        Order::Gt
    } else if a < b {
        Order::Lt
    } else {
        Order::Eq
    }
}

我们有一个单独的函数,compare_str。我们使用extern关键字将其暴露给 C 语言,然后指定编译器为生成适当的代码指定"C" ABI。我们还需要添加一个#[no_mangle]属性,因为 Rust 默认情况下会为函数名添加随机字符以防止模块和 crate 之间类型和函数名称冲突。这被称为名称混淆。如果没有这个属性,我们就无法通过compare_str这个名字来调用我们的函数。我们的函数按照字典顺序比较传递给它的两个 C 字符串,并相应地返回一个枚举,Order,它有三个变体:Gt(大于)、Lt(小于)和Eq(等于)。正如你可能已经注意到的,枚举定义有一个#[repr(C)]属性。因为这个枚举是要返回到 C 端的,我们希望它以与 C 枚举相同的方式表示。repr属性允许我们做到这一点。在 C 端,我们将得到一个uint_32类型作为这个函数的返回类型,因为 Rust 和 C 中枚举变体都是以 4 字节表示的。请注意,在撰写这本书的时候,Rust 对于具有关联数据的枚举遵循与 C 枚举相同的数据布局。然而,这可能在将来发生变化。

现在,让我们创建一个名为main.c的文件,它使用我们从 Rust 暴露的函数:

// rust_from_c/main.c

#include <stdint.h>
#include <stdio.h>

int32_t compare_str(const char* value, const char* substr);

int main() {
    printf("%d\n", compare_str("amanda", "brian"));
    return 0;
}

我们像任何正常的原型声明一样声明了compare_str函数的原型。随后,我们在main中调用了compare_str,传递了我们的两个字符串值。请注意,如果我们传递的是在堆上分配的字符串,我们还需要从 C 端释放它。在这种情况下,我们传递的是一个 C 字符串字面量,它位于进程的数据段中,所以我们不需要进行任何释放调用。现在,我们将创建一个简单的Makefile,它构建我们的stringutils crate,并编译和链接我们的main.c文件:

# rust_from_c/Makefile

main:
    cargo build
    gcc main.c -L ./target/debug -lstringutils -o main

现在,我们可以运行make来构建我们的 crate,然后通过首先设置我们的LD_LIBRARY_PATH指向我们的生成的libstringutils.so所在的位置来运行main。之后,我们可以这样运行main

$ export LD_LIBRARY_PATH=./target/debug
$ ./main

这给我们一个输出1,这是 Rust 端的Order枚举中Lt变体的值。从这个例子中我们可以得到的启示是,当你从 C/C++或其他在 Rust 中有支持 ABI 的语言调用 Rust 函数时,我们不能将 Rust 特定的数据类型传递到 FFI 边界。例如,传递带有关联数据的OptionResult类型是没有意义的,因为 C 无法解释和从中提取值,因为它没有知道这些数据的方式。在这种情况下,我们需要将原始值作为函数的返回类型传递到 C 端,或者将我们的 Rust 类型转换为 C 可以理解的形式。

现在,让我们考虑之前从 Rust 调用 C 代码的案例。按照手动方式,我们需要为所有在头文件中声明的 API 编写 extern 声明。如果能自动完成这项工作那就太好了。接下来,我们将看看如何实现这一点!

在 Rust 中使用外部 C/C++ 库

考虑到过去三十年中编写的软件数量,大量的系统软件是用 C/C++ 编写的。你可能会想链接到一个用 C/C++ 编写的现有库,用于 Rust 的项目中,尽管在 Rust 中重写一切(尽管是可取的)对于复杂项目来说并不实际。但与此同时,为这些库编写手动 FFI 绑定也是痛苦且容易出错的。幸运的是,我们有工具可以自动生成 C/C++ 库的绑定。对于这个演示,Rust 端所需的代码比之前从 Rust 调用 C/C++ 代码的例子要简单得多,因为这次我们将使用一个叫做 bindgen 的整洁的 crate,它可以从 C/C++ 库中自动生成 FFI 绑定。如果你想要集成一个具有大量 API 的复杂库,bindgen 是推荐的工具。手动编写这些绑定可能会非常容易出错,而 bindgen 通过自动化这个过程来帮助我们。我们将使用这个 crate 为一个简单的 C 库 levenshtein.c 生成绑定,该库可以在 github.com/wooorm/levenshtein.c 找到,它用于找到两个字符串之间的最小编辑距离。编辑距离被广泛应用于各种应用中,如模糊字符串匹配、自然语言处理和拼写检查器。无论如何,让我们通过运行 cargo new edit_distance --lib 来创建我们的 cargo 项目。

在使用 bindgen 之前,我们需要安装一些依赖项,因为 bindgen 需要它们:

$ apt-get install llvm-3.9-dev libclang-3.9-dev clang-3.9

接下来,在我们的 Cargo.toml 文件中,我们将添加对 bindgencc crate 的 build 依赖项:

# edit_distance/Cargo.toml

[build-dependencies]
bindgen = "0.43.0"
cc = "1.0"

bindgen crate 将用于从 levenshtein.h 头文件生成绑定,而 cc crate 将用于将我们的库编译为共享对象,以便我们可以从 Rust 中使用它。我们的库相关文件位于 crate 根目录下的 lib 文件夹中。

接下来,我们将创建一个 build.rs 文件,该文件将在编译任何源文件之前运行。它将完成两件事:首先,它将编译 levenshtein.c 到一个共享对象(.so)文件,其次,它将为 levenshtein.h 文件中定义的 API 生成绑定:

// edit_distance/build.rs

use std::path::PathBuf;

fn main() {
    println!("cargo:rustc-rerun-if-changed=.");
    println!("cargo:rustc-link-search=.");
    println!("cargo:rustc-link-lib=levenshtein");

    cc::Build::new()
        .file("lib/levenshtein.c")
        .out_dir(".")
        .compile("levenshtein.so");

    let bindings = bindgen::Builder::default()
        .header("lib/levenshtein.h")
        .generate()
        .expect("Unable to generate bindings");

    let out_path = PathBuf::from("./src/");
    bindings.write_to_file(out_path.join("bindings.rs")).expect("Couldn't write bindings!");
}

在前面的代码中,我们告诉 Cargo 我们的库搜索路径是当前目录,我们正在链接的库名为 levenshtein。我们还告诉 Cargo,如果当前目录中的任何文件发生变化,则重新运行 build.rs 中的代码:

println!("cargo:rustc-rerun-if-changed=.");
println!("cargo:rustc-link-search=.");
println!("cargo:rustc-link-lib=levenshtein");

在此之后,我们通过创建一个新的 Build 实例来为我们的库创建一个编译管道,并为 file 方法提供适当的 C 源文件。我们还设置了输出目录为 out_dir,并将我们的库名称设置为 compile 方法:

cc::Build::new().file("lib/levenshtein.c")
                .out_dir(".")
                .compile("levenshtein");

接下来,我们创建一个 bindgen Builder实例,传递我们的头文件位置,调用generate(),然后在调用write_to_file之前将其写入一个bindings.rs文件:

let bindings = bindgen::Builder::default().header("lib/levenshtein.h")
                                          .generate()
                                          .expect("Unable to generate bindings");

现在,当我们运行cargo build时,一个bindings.rs文件将在src/下生成。正如我们之前提到的,对于所有暴露 FFI 绑定的库来说,提供一个安全包装是一个好的实践。因此,在src/lib.rs下,我们将创建一个名为levenshtein_safe的函数,该函数封装了从bindings.rs中的不安全函数:

// edit_distance/src/lib.rs

mod bindings;

use crate::bindings::levenshtein;
use std::ffi::CString;

pub fn levenshtein_safe(a: &str, b: &str) -> u32 {
    let a = CString::new(a).unwrap();
    let b = CString::new(b).unwrap();
    let distance = unsafe { levenshtein(a.as_ptr(), b.as_ptr()) };
    distance
}

我们从bindings.rs导入不安全函数,将其封装在我们的levenshtein_safe函数中,并在一个unsafe块中调用我们的levenshtein函数,传递与 C 兼容的字符串。现在是测试我们的levenshtein_safe函数的时候了。我们将在我们的 crate 根目录下的examples/目录中创建一个basic.rs文件,其代码如下:

// edit_distance/examples/basic.rs

use edit_distance::levenshtein_safe;

fn main() {
    let a = "foo";
    let b = "fooo";
    assert_eq!(1, levenshtein_safe(a, b));
}

我们可以使用cargo run --example basic来运行它,并且应该看到没有断言失败,因为从levenshtein_safe调用中得到的值应该是1。现在,对于这类 crate,建议在它们后面添加后缀sys,这样只包含 FFI 绑定。crates.io上的大多数 crate 都遵循这个约定。这简要介绍了如何使用 bindgen 来自动化跨语言交互。如果您想要类似自动化反向 FFI 绑定,例如 Rust 在 C 中,还有一个名为cbindgen的等效项目,位于github.com/eqrion/cbindgen,它可以生成 Rust crate 的 C 头文件。例如,Webrender使用这个 crate 将其 API 暴露给其他语言。鉴于 C 的遗产,它是编程语言的通用语言,Rust 对其提供了第一级支持。许多其他语言也调用 C。这意味着您的 Rust 代码可以从所有以 C 为目标的其他语言中调用。让我们让其他语言与 Rust 通信。

使用 PyO3 创建原生 Python 扩展

在本节中,我们将看到 Python 也可以调用 Rust 代码。Python 社区一直是诸如 numpy、lxml、opencv 等原生模块的重量级用户,其中大部分都有其底层实现是在 C 或 C++中。将 Rust 作为原生 C/C++模块的替代品,对于许多 Python 项目来说,在速度和安全方面都是一个主要优势。对于演示,我们将构建一个在 Rust 中实现的原生 Python 模块。我们将使用pyo3,这是一个流行的项目,它为 Python 解释器提供 Rust 绑定,并隐藏所有底层细节,从而提供了一个非常直观的 API。该项目位于 GitHub 上github.com/PyO3/pyo3。它支持 Python 2 和 Python 3 版本。pyo3是一个快速发展的目标,在撰写本书时仅在 nightly 版本上工作。因此,我们将使用pyo3的一个特定版本,即0.4.1,以及 Rust 编译器的特定 nightly 版本。

通过运行 cargo new word_suffix --lib 来创建一个新的货物项目。这个库包将暴露一个名为 word_suffix 的 Python 模块,其中包含一个名为 find_words 的单个函数,该函数接受一个以逗号分隔的单词字符串,并返回所有以给定后缀结尾的单词。一旦我们构建了我们的模块,我们就能像导入普通 Python 模块一样导入这个模块。

在我们开始实现之前,我们需要切换到为这个项目特定的 nightly Rust 工具链,即 rustc 1.30.0-nightly (33b923fd4 2018-08-18)。我们可以通过在当前目录(word_suffix/)中运行 rustup override set nightly-2018-08-19 来覆盖工具链,以使用这个特定的 nightly 版本。

为了开始,我们将在我们的 Cargo.toml 文件中指定我们的依赖项:

# word_suffix/Cargo.toml

[package]
name = "word_suffix"
version = "0.1.0"
authors = ["Rahul Sharma <creativcoders@gmail.com>"]

[dependencies]
pyo3 = "0.4"

[lib]
crate-type = ["cdylib"]

我们在这里添加了我们的唯一依赖项 pyo3。如您所见,在 [lib] 部分,我们指定了 crate-typecdylib,这意味着生成的库类似于 C 共享库(Linux 中的 .so),Python 已经知道如何调用它。

现在,让我们在我们的 lib.rs 文件中开始实现:

// word_suffix/src/lib.rs

//! A demo python module in Rust that can extract words
//! from a comma seperated string of words that ends with the given suffix

#[macro_use]
extern crate pyo3;
use pyo3::prelude::*;

/// This module is a python module implemented in Rust.
#[pymodinit]
fn word_suffix(_py: Python, module: &PyModule) -> PyResult<()> {
    module.add_function(wrap_function!(find_words))?;
    Ok(())
}

#[pyfunction]
fn find_words(src: &str, suffix: &str) -> PyResult<Vec<String>> {
    let mut v = vec![];
    let filtered = src.split(",").filter_map(|s| {
        let trimmed = s.trim();
        if trimmed.ends_with(&suffix) {
            Some(trimmed.to_owned())
        } else {
            None
        }
    });

    for s in filtered {
        v.push(s);
    }
    Ok(v)
}

首先,我们导入了我们的 pyo3 包,以及来自 prelude 模块的 Python 相关类型。然后,我们定义了一个 word_suffix 函数,并使用 #[pymodinit] 属性对其进行注释。这成为我们的 Python 模块,我们可以在任何 .py 文件中导入它。此函数接收两个参数。第一个参数是 Python,这是一个标记类型,对于 pyo3 中的大多数 Python 相关操作都是必需的。这用于指示特定操作会修改 Python 解释器状态。第二个参数是 PyModule 实例,它表示一个 Python 模块对象。通过此实例,我们添加我们的 find_words 函数,通过调用 add_function 并在 wrap_function 宏中包装它来添加。wrap_function 宏对提供的 Rust 函数进行一些操作,将其转换为 Python 兼容的函数。

接下来是我们的 find_words 函数,这是这里的重要部分。我们使用 #[pyfunction] 属性将其包装起来,该属性对函数的参数和返回类型进行转换,使其与 Python 函数兼容。我们的 find_words 实现很简单。首先,我们创建一个向量 v 来保存过滤后的单词列表。然后,我们通过在 "," 上分割 src 字符串,然后进行 filtermap 操作来过滤我们的 src 字符串。split(",") 调用返回一个迭代器,我们对它调用 filter_map 方法。此方法接收一个包含分割单词 s 的闭包作为参数。我们首先通过调用 s.trim()s 中移除任何空白字符,然后检查它是否 ends_with 我们提供的后缀字符串。如果是,它将 trimmed 转换为拥有 StringSome;否则,它返回 None。然后,我们遍历所有过滤后的单词(如果有),将它们推送到我们的 v 中,并返回它。

解释到此为止,现在是时候构建我们的 Python 模块了。为此,我们有 pyo3-pack:来自同一 pyo3 项目的另一个工具,它自动化了制作本机 Python 模块的全过程。此工具还具有将构建的包发布到 Python 包索引PyPI)的能力。让我们通过运行 cargo install pyo3-pack 来安装 pyo3-pack。现在,我们可以生成一个 Python 轮子(.whl)包,然后使用 pyo3-pack develop 在本地安装该包。但在我们这样做之前,我们需要处于一个 Python 虚拟环境中,因为 py3-pack develop 命令要求这样做。

我们可以通过运行以下代码来创建我们的虚拟环境:

virtualenv -p /usr/bin/python3.5 test_word_suffix 

我们在这里使用 Python 3.5。之后,我们需要通过运行以下代码来激活我们的环境:

source test_word_suffix/bin/activate

如果你还没有安装 pipvirtualenv,你可以通过运行以下代码来安装它们:

sudo apt-get install python3-pip
sudo pip3 install virtualenv

现在,我们可以运行 pyo3-pack develop,它为 Python 2 和 Python 3 版本创建 wheel 文件,并在我们的虚拟环境中本地安装它们。

现在,我们将在 word_suffix 目录下创建一个简单的 main.py 文件,并导入此模块以查看我们是否可以使用我们的模块:

# word_suffix/main.py

import word_suffix

print(word_suffix.find_words("Baz,Jazz,Mash,Splash,Squash", "sh"))

通过 python main.py 运行它,我们得到以下输出:

太好了!这是一个非常简单的例子,尽管如此。对于复杂的情况,你需要了解很多细节。要了解更多关于 pyo3 的信息,请访问他们出色的指南 pyo3.rs

在 Rust 中为 Node.js 创建本机扩展

有时候,JavaScript 在 Node.js 运行时的性能不足以满足需求,因此开发者会转向其他底层语言来创建本机 Node.js 模块。通常,C 和 C++ 被用作这些本机模块的实现语言。Rust 也可以通过与 C 和 Python 相同的 FFI 抽象来创建本机 Node.js 模块。在本节中,我们将探讨这些 FFI 抽象的高级包装器,即由 Mozilla 的 Dave Herman 创建的 neon 项目。

Neon 项目是一套工具和粘合代码,它使 Node.js 开发者的生活变得更轻松,允许他们在 Rust 中编写本机 Node.js 模块,并在他们的 JavaScript 代码中无缝使用它们。该项目位于 github.com/neon-bindings/neon。它部分是用 JavaScript 编写的:neon-cli 包中有一个名为 neon 的命令行工具,一个 JavaScript 端支持库,以及一个 Rust 端支持库。Node.js 本身对加载本机模块有很好的支持,而 neon 就使用了同样的支持。

在下面的演示中,我们将使用 Rust 作为 npm 包构建一个本机 Node.js 模块,并暴露一个可以计算给定单词在文本块中出现的次数的函数。然后我们将导入这个包,并在 main.js 文件中测试暴露的函数。这个演示需要安装 Node.js(版本 v11.0.0)及其包管理器 npm(版本 6.4.1)。如果您还没有安装 Node.js 和 npm,请访问 www.digitalocean.com/community/tutorials/how-to-install-node-js-on-ubuntu-16-04 来设置它们。安装完成后,您需要使用 npm 通过运行以下命令来安装 neon-cli 工具:

npm install --global neon-cli 

由于我们希望这个工具可以在全球范围内使用,以便从任何地方创建新项目,我们传递了 --global 标志。neon-cli 工具用于创建一个包含骨架 neon 支持的 Node.js 项目。一旦安装,我们通过运行 neon new native_counter 来创建我们的项目,这将提示输入项目的基本信息,如下面的截图所示:

图片

这里是此命令为我们创建的目录结构:

 native_counter tree
.
├── lib
│   └── index.js
├── native
│   ├── build.rs
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── package.json
└── README.md

neon 为我们创建的项目结构是与通常的 lib 目录和 package.json 相同的 npm 包结构。除了 Node.js 包结构之外,它还在 native 目录下为我们创建了一个 cargo 项目,其中包含一些初始代码。让我们看看这个目录的内容,从 Cargo.toml 开始:

# native_counter/native/Cargo.toml

[package]
name = "native_counter"
version = "0.1.0"
authors = ["Rahul Sharma <creativcoders@gmail.com>"]
license = "MIT"
build = "build.rs"
exclude = ["artifacts.json", "index.node"]

[lib]
name = "native_counter"
crate-type = ["dylib"]

[build-dependencies]
neon-build = "0.2.0"

[dependencies]
neon = "0.2.0"

需要注意的突出事项是 [lib] 部分,它指定了 crate 类型为 dylib,这意味着我们需要 Rust 来创建共享库。在根目录级别还有一个自动生成的 build.rs 文件,它通过在内部调用 neon_build::setup() 来进行一些初始构建环境配置。接下来,我们将从我们的 lib.rs 文件中移除现有代码,并添加以下代码:

// native_counter/native/src/lib.rs

#[macro_use]
extern crate neon;

use neon::prelude::*;

fn count_words(mut cx: FunctionContext) -> JsResult<JsNumber> {
    let text = cx.argument::<JsString>(0)?.value();
    let word = cx.argument::<JsString>(1)?.value();
    Ok(cx.number(text.split(" ").filter(|s| s == &word).count() as f64))
}

register_module!(mut m, { 
    m.export_function("count_words", count_words)?;
    Ok(())
});

首先,我们导入 neon crate,以及宏和 prelude 模块中的所有项。随后,我们定义一个函数 count_words,它接受一个 FunctionContext 实例。这个实例包含有关被调用的活动函数的信息,例如参数列表、参数长度、this 绑定和其他细节。我们期望调用者向我们的 count_words 函数传递两个参数。首先,文本,其次,在文本中搜索的单词。这些值通过在 cx 实例上调用 argument 方法并传入相应的索引来提取。我们还使用 turbofish 运算符请求它提供一个 JsString 类型的值。在返回的 JsString 实例上,我们调用 value 方法来获取 Rust String 值。

在我们完成提取参数之后,我们使用空白字符分割我们的文本,并且只过滤出包含给定word的块,在迭代器链上调用count()来计算匹配出现的数量:

text.split(" ").filter(|s| s == &word).count()

count()返回usize。然而,由于cx上的number方法对Into<f64>特质的限制,我们需要将usize转换为f64。一旦我们这样做,我们就用对cx.number()的调用将这个表达式包装起来,这会创建一个 JavaScript 兼容的JsNumber类型。我们的count_words方法返回一个JsResult<JsNumber>类型,因为访问参数可能会失败,返回适当的JavaScript类型也可能失败。JsResult类型中的这个错误变体代表从 JavaScript 端抛出的任何异常。

接下来,我们使用register_module!宏将我们的count_words函数注册。这个宏获取对ModuleContext实例的可变引用,m。使用这个实例,我们通过调用export_function方法来导出我们的函数,将函数名称作为字符串传递,并将实际函数类型作为第二个参数。

现在,这是我们的更新后的index.js文件的内容:

// native_counter/lib/index.js

var word_counter = require('../native');
module.exports = word_counter.count_words;

由于index.js是 npm 包的根,我们必须在我们的本地模块中导入它,并且必须使用module.exports直接在模块的根处导出函数。现在,我们可以使用以下代码构建我们的模块:

neon build

一旦包构建完成,我们可以在native_counter目录中创建一个简单的main.js文件来测试它,代码如下:

// native_counter/main.js

var count_words_func = require('.');
var wc = count_words_func("A test text to test native module", "test");
console.log(wc);

我们将通过运行以下代码来运行这个文件:

node main.js

这给我们一个输出2。这就结束了我们让 Rust 和其他语言相互交流的精彩旅程。结果证明,Rust 在这方面的交互非常流畅。在其他语言不理解 Rust 的复杂数据类型的情况下,存在一些粗糙的边缘,这是可以预料的,因为每种语言在实现上都有所不同。

摘要

Rust 为我们提供了方便的 FFI 抽象,以便与不同的语言进行接口交互,并且对 C 语言有第一级支持,因为它为标记为extern的函数暴露了 C ABI(cdecl)。因此,它是一个为许多 C/C++库创建绑定的好候选者。这一点的突出例子是使用 C++实现的 SpiderMonkey JavaScript 引擎,它在 Servo 项目中使用。Servo 引擎通过bindgen crate 生成的绑定调用 C++。

但是,当我们跨越语言边界进行交互时,一种语言所具有的语言构造和数据表示并不需要与另一种语言相匹配。因此,我们需要在 Rust 代码中添加额外的注释,以及不安全块,以便让编译器了解我们的意图。我们在使用#[repr(C)]属性时看到了这一点。外部函数接口FFI),就像许多其他 Rust 特性一样,是无成本的,这意味着当链接来自其他语言的代码时,所产生的是最小的运行时成本。我们查看了一下 Python 和 Node.js,它们为这些低级 FFI 抽象提供了很好的包装库。对于没有此类包装库的语言,可以通过使用 Rust 标准库提供的裸 FFI API 来实现与其他语言的接口。

到目前为止的目标是涵盖语言的核心主题,我希望你对大多数核心语言特性已经熟悉。接下来的章节将涵盖各种 Rust 框架和库的案例研究,并将主要面向将 Rust 应用于实际项目。

第十一章:日志

日志是软件开发生命周期中一个重要但常被忽视的实践。它通常是在面对潜在无效状态和错误积累的后果时才被作为事后考虑而集成。任何适度规模的项目都应该在开发初期就具备日志支持。

在本章中,我们将了解为什么在应用程序中设置日志很重要,日志框架的需求,如何处理日志,以及 Rust 生态系统中可用的 crate,以使程序员能够利用日志在他们的应用程序中的力量。

在本章中,我们将涵盖以下主题:

  • 什么是日志以及为什么我们需要它?

  • 日志框架的需求

  • 日志框架及其功能

  • 探索 Rust 中的日志 crate

什么是日志以及为什么我们需要它?

“一般来说,一个程序除非有话要说,否则不应该说任何话。” —— 肯尼根和普劳格

在我们讨论日志的重要性之前,让我们定义这个术语,以便我们有一个更好的背景。日志是在运行时让应用程序将其活动记录到任何输出中的实践,其中单个记录被称为事件日志或简单地称为日志。这通常与一个时间戳相关联,描述事件发生的时间。事件可以是任何改变程序内部或外部状态的事情。日志可以帮助你在一段时间内了解应用程序的运行时行为,或者在调试错误时获得更多关于应用程序状态的信息。它们还用于生成用于商业目的的分析报告。也就是说,日志为用户提供的有用程度主要取决于应用程序和消费者的需求。

现在,在一个没有任何类型日志集成的应用程序中,我们了解程序在运行时行为的选择有限。我们可以使用外部工具,如 Linux 中的htop来监控我们的程序,但这只提供了从外部看程序的角度,并且关于内部信息提供的信息有限。

在程序运行期间从程序内部获取的信息对于调试目的很有用,或者可以用于运行时性能分析。在我们的程序发生致命故障的情况下,我们可以了解程序崩溃时的位置。至少,程序会留下堆栈跟踪,从而提供一些关于程序出错位置的上下文。然而,有一些类别的错误和事件不会立即引起问题,但后来会变成致命错误,尤其是在长时间运行的系统中。在这些情况下,事件日志可以帮助快速缩小程序中的问题范围。这就是为什么在程序中添加日志功能变得极其有帮助。

从日志中受益并需要依赖事件日志的系统包括网络服务、网络服务器、流处理服务以及类似的长时间运行系统。在这些系统中,单个事件日志与随时间推移产生的后续日志相结合,当由日志聚合服务摄取并进行分析时,可以提供有关系统的有用统计数据。

对于像购物网站这样的商业应用程序,你可以利用日志分析来获得业务洞察,从而带来更好的销售。在网络服务器中,你可以找到有用的活动日志来跟踪对服务器进行的任何恶意尝试,例如分布式拒绝服务(DDoS)攻击。开发者可以通过从收集的 API 请求日志中获得请求-响应延迟数据来评估他们的 Web API 端点的性能。

日志还充当重要的调试上下文,并可以在调试会话中进行根本原因分析时最小化所花费的时间,在调试会话中,你有时间限制来修复生产中发生的问题。

有时,日志是唯一的方法,因为并非总是有调试器可用或适用。这通常是在分布式系统和多线程应用程序中。任何在这些系统中进行过相当数量开发的人都非常清楚为什么日志是软件开发流程中如此重要的一个部分。

有三类用户从应用日志实践中获得巨大益处:

  • 系统管理员:他们需要监控服务器日志以发现任何故障,例如硬盘崩溃或网络故障。

  • 开发者:在开发过程中,将日志集成到项目中可以大大减少开发时间,并且以后可以用来深入了解用户如何使用他们的应用程序。

  • 网络安全团队:在远程服务器遭受任何攻击的情况下,安全人员从日志中受益匪浅,因为他们可以通过追踪受攻击服务器记录的事件日志来了解某种攻击是如何进行的。

作为软件开发实践中的功能组件,并在长期内提供巨大价值,将日志集成到系统中需要专门的框架,我们将在下一节中看到原因。

对日志框架的需求

我们现在知道日志为什么很重要。然而,接下来的问题是,我们如何在应用程序中集成日志功能?让应用程序记录事件最简单、最直接的方法是在代码中需要的地方添加一些打印语句。这样,我们就可以轻松地将事件日志输出到我们的终端控制台的标准输出,这样我们的工作就完成了,但还有更多值得期待。在许多情况下,我们还想让我们的日志在稍后进行分析时保持持久。因此,如果我们想将打印语句的输出收集到文件中,我们必须寻找额外的途径,例如使用 shell 输出重定向功能将输出管道到文件,这基本上是使用不同的工具来实现将日志从我们的应用程序输出到不同输出的目标。结果证明,这种方法存在局限性。

你无法过滤或关闭不需要为特定模块记录日志的打印语句。为此,你必须注释掉它们或删除它们并重新部署你的服务。另一个限制是,当你的日志命令变得很大时,你必须编写和维护用于收集多个输出的日志的 shell 脚本。所有这些都很快变得难以控制且难以维护。使用打印语句是一种快速而低效的日志记录实践,并且不是一个非常可扩展的解决方案。我们需要的是一个更好、更可定制的应用程序日志架构。可扩展且更干净的方法是拥有一个专门的记录器,它可以消除所有这些限制,这就是为什么存在日志框架的原因。除了基本的日志需求之外,这些框架还提供了诸如达到一定大小限制时的日志文件轮换、设置日志频率、按模块进行细粒度日志配置等附加功能。

日志框架及其关键特性

主流语言提供了各种各样的日志框架。其中一些值得提及的包括来自 Java 的Log4j,来自 C#的Serilog,以及来自 Node.js 的Bunyan。自这些框架的普及以来,从它们的使用案例来看,日志框架应该提供给用户哪些功能有相似之处。以下是最理想的属性,日志框架应该具备:

  • 快速:日志框架必须确保在日志记录时不会执行昂贵的操作,并且应该能够尽可能少地使用 CPU 周期来高效地处理。例如,在 Java 中,如果你的日志语句包含许多需要调用to_string()方法的对象,只是为了在日志消息中内联对象,那么这被认为是一种低效的做法。

  • 可配置的输出:只有将日志消息记录到标准输出是非常有限的。它们仅在 shell 会话中保留,您需要手动将日志粘贴到文件中才能稍后使用。日志框架应该提供支持多个输出的能力,例如文件或甚至网络套接字。

  • 日志级别:日志框架的显著特征是能够控制记录什么和何时记录,这使得它们与基于打印的常规日志记录区分开来。这通常是通过使用日志级别的概念实现的。日志级别是一个可配置的过滤器,通常实现为一个在发送日志输出之前检查的类型。级别通常按以下顺序排列,从最低优先级到最高优先级:

    • 错误:此级别适用于记录关键事件以及可能导致应用程序输出无效的事件。

    • 警告:此级别适用于已经采取措施的事件,但也希望在事件频繁发生时能够采取后续行动。

    • 信息:此级别可用于正常事件,例如打印应用程序版本、用户登录、连接成功消息等。

    • 调试:正如其名所示,这用于支持调试。在调试时,它有助于监控变量的值以及它们在不同代码路径中的操作。

    • 跟踪:当您想要算法或您编写的任何非平凡函数的逐步执行时,使用此级别。带有参数和返回值的函数调用可以作为跟踪日志放置。

这些名称在不同的框架中可能会有细微差别,但它们所表示的优先级大多相同。在主要的日志框架中,这些级别是在日志记录器初始化时设置的,任何后续的日志调用都会检查设置的级别,并根据该级别过滤日志。例如,调用Logger.set_level(INFO)Logger对象将允许记录所有高于Info级别的日志,而忽略DebugTrace日志。

  • 日志过滤:应该能够轻松地仅记录代码中需要的地方,并根据事件的严重性/重要性关闭其他日志。

  • 日志轮转:当将日志记录到文件时,长期记录会填满磁盘空间。日志框架应该提供限制日志文件大小和允许删除旧日志文件的功能。

  • 异步日志记录:主线程上的日志调用可能会阻塞主代码的执行。尽管高效的日志记录器会尽可能少地执行,但它仍然会在实际代码之间进行阻塞 I/O 调用。因此,最好将大多数日志调用卸载到专门的日志记录器线程。

  • 日志消息属性:另一个值得提及的是发送到日志 API 的日志消息上的属性。至少,日志框架应该为日志消息提供以下属性:

    • 时间戳:事件发生的时间

    • 日志严重性:消息的重要性,例如错误、警告、信息、调试等

    • 事件位置:事件发生的源代码中的位置

    • 消息:描述发生了什么的实际事件消息

根据这些特性,日志框架在日志记录方面的方法有所不同。让我们接下来探讨它们。

日志记录方法

在应用程序中集成日志时,我们需要决定要记录哪些信息以及它们应该有多详细。如果日志太多,我们就会失去在噪声中轻松找到相关信息的能力;如果日志消息不够,我们就有可能错过那个重要的事件。我们还需要考虑如何组织日志消息中的信息,以便稍后更容易搜索和分析。这些问题导致日志框架大致分为两类:非结构化日志和结构化日志。

非结构化日志

处理日志的通常方法是将事件记录为普通字符串,并将任何必需的值转换为字符串后放入日志消息中。这种日志记录形式称为非结构化日志,因为日志消息中的信息没有任何预定义的结构或顺序。非结构化日志对于大多数用例来说效果很好,但也有其缺点。

收集日志消息后,一个常见的用例是在稍后的时间点搜索特定事件的日志。然而,从日志集合中检索非结构化日志可能会很痛苦。非结构化日志消息的问题在于它们没有任何可预测的格式,对于日志聚合服务来说,使用简单的文本匹配查询来筛选所有原始日志消息会变得非常耗费资源。你需要编写匹配文本块的正则表达式,或者从命令行中 grep 它们以获取特定事件。随着日志数量的增加,这种方法最终会成为从日志文件中获取有用信息的瓶颈。另一种方法是记录具有预定义结构的日志消息,为此我们有了结构化日志。

结构化日志

结构化日志是无结构日志的一个可扩展且更好的替代方案。正如其名所示,结构化日志为日志消息定义了结构和格式,并且保证每个日志消息都具有这种格式。这种格式的优点是,对于日志聚合服务来说,构建搜索索引并呈现任何特定事件给用户变得非常容易,无论他们有多少条消息。有许多结构化日志框架,如 C#中的 Serilog,它们提供了对结构化日志的支持。这些框架提供了一个基于插件的日志输出抽象,称为Sinks。Sinks 是您将日志发送到何处的方式。一个 Sink 可以是您的终端、文件、数据库或日志聚合服务,如 logstash。

结构化日志框架知道如何将某个对象序列化,并且可以以适当的格式进行序列化。它们还通过提供基于组件的分层日志输出来自动化日志消息的格式化。结构化日志的缺点是,在将它们集成到应用程序中时可能会有些耗时,因为你必须事先决定日志的层次结构和格式。

在选择结构化日志和无结构日志之间,通常需要权衡。日志量大的复杂项目可以从结构化日志中受益,因为它们可以从模块中获得语义化和高效可搜索的日志,而小型到中等规模的项目可以使用无结构日志。最终,应该由应用程序的需求来决定您如何将日志集成到应用程序中。在下一节中,我们将探讨几个无结构日志框架以及 Rust 中的结构化日志框架,您可以使用这些框架使您的应用程序记录事件。

Rust 中的日志

Rust 有相当多的灵活和广泛的日志解决方案。与其他语言的流行日志框架一样,这里的日志生态系统分为两部分:

  • 日志外观:这部分由log crate 实现,提供了一个与实现无关的日志 API。虽然其他框架将日志 API 实现为某些对象上的函数或方法,但 log crate 为我们提供了基于宏的日志 API,这些 API 按日志级别分类,以将事件记录到配置的日志输出中。

  • 日志实现:这些是由社区开发的 crate,它们提供了实际的日志实现,包括输出去向和实现方式。有许多这样的 crate,例如env_loggersimple_loggerlog4rsfern。我们稍后会访问其中的一些。属于这一类别的 crate 仅适用于二进制 crate,即可执行文件。

这种关注点分离,即日志 API 与日志输出到输出机制之间的底层机制,是为了让开发者不需要更改代码中的日志语句,并且可以轻松地根据需要交换底层日志实现。

log – Rust 的日志门面

log crate 来自 GitHub 上的 rust-lang nursery 组织,并由社区在 github.com/rust-lang-nursery/log 进行管理。它为不同日志级别(如 error!warn!info!debug!trace!)提供了独立的宏,按照优先级从高到低排序。这些宏是此 crate 的消费者主要交互点。它们内部调用此 crate 中的 log! 宏,该宏负责所有账簿工作,例如检查日志级别和格式化日志消息。此 crate 的核心组件是 log trait,其他后端 crate 实现此 trait。该 trait 定义了日志记录器所需的操作,并具有其他 API,例如检查是否启用了日志记录或刷新任何缓冲的日志。

log crate 还提供了一个名为 STATIC_MAX_LEVEL 的最大日志级别常量,可以在编译时配置项目范围。使用此常量,您可以使用 cargo 功能标志静态地设置应用程序的日志级别,这允许在编译时过滤应用程序及其所有依赖项的日志。这些级别过滤器可以在 Cargo.toml 中分别针对调试和发布构建设置:max_level_<LEVEL>(调试)和 release_max_level_<LEVEL>(发布)。在二进制项目中,您可以指定对 log crate 的依赖项,并使用编译时日志级别如下:

[dependencies]
log = "0.4.6", features = ["release_max_level_error", "max_level_debug"] }

将此常量设置为所需值是一个好习惯,因为默认情况下,级别设置为 Off。它还允许日志宏优化掉任何禁用级别的日志调用。库应仅链接到 log crate,而不是任何日志实现 crate,因为二进制 crate 应该控制要记录的内容以及如何记录。仅在使用此 crate 的应用程序中,不会产生任何日志输出,因为您需要使用 env_loggerlog4rs 等日志 crate 与之配合使用。

要查看 log crate 的实际应用,我们将通过运行 cargo new user_auth --lib 并在 Cargo.toml 文件中添加 log 作为依赖项来构建一个库 crate:

# user_auth/Cargo.toml

[dependencies]
log = "0.4.6"

此 crate 模拟了一个虚拟的用户登录 API。我们的 lib.rs 文件有一个 User 结构体,该结构体有一个名为 sign_in 的方法:

// user_auth/lib.rs

use log::{info, error};

pub struct User {
    name: String,
    pass: String
}

impl User {
    pub fn new(name: &str, pass: &str) -> Self {
        User {name: name.to_string(), pass: pass.to_string()}
    }

    pub fn sign_in(&self, pass: &str) {
        if pass != self.pass {
            info!("Signing in user: {}", self.name);
        } else {
            error!("Login failed for user: {}", self.name);
        }
    }
}

sign_in 方法中,我们对登录成功或失败进行了几次日志调用。我们将使用这个库 crate 与一个创建 User 实例并调用 sign_in 方法的二进制 crate 一起使用。由于依赖于 log crate 本身不会产生任何日志输出,我们将使用 env_logger 作为此示例的日志后端。让我们首先探索 env_logger

env_logger

env_logger是一个简单的记录实现,允许您通过RUST_LOG环境变量控制日志输出到stdoutstderr。这个环境变量的值是逗号分隔的记录器字符串,对应于模块名称和日志级别。为了演示env_logger,我们将通过运行cargo new env_logger_demo并指定logenv_logger和我们在上一节中创建的user_auth库的依赖项来创建一个新的二进制 crate。以下是我们的Cargo.toml文件:

# env_logger_demo/Cargo.toml

[dependencies]
env_logger = "0.6.0"
user_auth = { path = "../user_auth" }
log = { version = "0.4.6", features = ["release_max_level_error", "max_level_trace"] }

这是我们的main.rs文件:

// env_logger_demo/src/main.rs

use log::debug;

use user_auth::User;

fn main() {
    env_logger::init();
    debug!("env logger demo started");
    let user = User::new("bob", "super_sekret");
    user.sign_in("super_secret");
    user.sign_in("super_sekret");
}

我们创建我们的User实例并调用sign_in,传入我们的密码。第一次登录尝试是失败的,这将记录为错误。我们可以通过设置RUST_LOG环境变量,然后执行cargo run来运行它:

RUST_LOG=user_auth=info,env_logger_demo=info cargo run

我们将user_authcrate 的日志设置为info及其以上级别,而来自我们的env_logger_democrate 的日志设置为debug及其以上级别。

运行此命令会给我们以下输出:

RUST_LOG接受RUST_LOG=path::to_module=log_level[,]模式,其中path::to_module指定了记录器,应该是一个以 crate 名称为基本路径的任何模块。log_level是记录 crate 中定义的任何日志级别。结尾的[,]表示我们可以有任意多个这样的记录器指定,用逗号分隔。

运行前面程序的另一种方法是,通过在代码本身中设置环境变量,使用标准库中的env模块的set_var方法:

std::env::set_var("RUST_LOG", "user_auth=info,env_logger_demo=info cargo run");
env_logger::init();

这会产生与之前相同的输出。接下来,让我们看看一个更复杂且高度可配置的记录 crate。

log4rs

如其名所示,log4rscrate 受到了 Java 中流行的log4j库的启发。这个 crate 比env_logger更强大,允许通过 YAML 文件进行细粒度的记录器配置。

我们将构建两个 crate 来演示通过log4rscrate 集成记录。一个将是库 crate,cargo new my_lib --lib,另一个将是我们的二进制 crate,cargo new my_app,它使用my_lib。一个名为log4rs_demo的 cargo 工作空间目录包含这两个 crate。

我们的my_libcrate 在lib.rs文件中有以下内容:

// log4rs_demo/my_lib/lib.rs

use log::debug;

pub struct Config;

impl Config {
    pub fn load_global_config() {
        debug!("Configuration files loaded");
    }
}

它有一个名为Config的结构体,有一个名为load_global_config的模拟方法,在调试级别记录一条消息。接下来,我们的my_appcrate 在main.rs文件中有以下内容:

// log4rs_demo/my_app/src/main.rs

use log::error;

use my_lib::Config;

fn main() {
    log4rs::init_file("config/log4rs.yaml", Default::default()).unwrap();
    error!("Sample app v{}", env!("CARGO_PKG_VERSION"));
    Config::load_global_config();
}

在前面的代码中,我们通过init_file方法初始化我们的log4rs记录器,传入log4rs.yaml配置文件的路径。接下来,我们记录一个模拟的错误消息,从而打印出应用程序版本。随后,我们调用load_global_config,它记录了另一条消息。以下就是log4rs.yaml配置文件的内容:

# log4rs_demo/config/log4rs.yaml

refresh_rate: 5 seconds

root:
  level: error
  appenders:
    - stdout
appenders:
  stdout:
    kind: console
  my_lib_append:
    kind: file
    path: "log/my_lib.log"
    encoder:
      pattern: "{d} - {m}{n}"

loggers:
  my_lib:
    level: debug
    appenders:
      - my_lib_append

让我们逐行分析。第一行 refresh_rate 指定了 log4rs 重新加载配置文件的时间间隔,以便对文件中进行的任何更改进行会计。这意味着我们可以修改 YAML 文件中的任何值,log4rs 将会动态地为我们重新配置其日志记录器。然后,我们有 root 日志记录器,它是所有日志记录器的父级。我们指定默认级别为 error,附加器为 stdout,它定义在其下方。

接下来是 appenders 部分。附加器是日志的去向。我们指定了两个附加器:stdout,它是 console 类型,以及 my_lib_append,它是一个 file 附加器,在 encoder 部分包括有关文件路径和要使用的日志模式的详细信息。

接下来是 日志记录器 部分,我们可以根据不同的级别定义基于库或模块的日志记录器。我们定义了一个名为 my_lib 的日志记录器,它对应于我们的 my_lib 库,具有 debug 级别和 my_lib_append 作为附加器。这意味着来自 my_lib 库的所有日志都将发送到 my_lib.log 文件,这是由 my_lib_append 附加器指定的。

log4rs_demo 目录下运行 cargo run,我们得到以下输出:

图片

这是对 log4rs 的简要介绍。如果你想了解更多关于配置这些日志的信息,请访问文档页面 docs.rs/log4rs

使用 slog 进行结构化日志记录

所述的所有库都非常有用,并且适用于大多数用例,但它们不支持结构化日志。在本节中,我们将看到如何使用 slog 库将结构化日志集成到我们的应用程序中,slog 是 Rust 生态系统中的少数几个流行的结构化日志库之一。为此演示,我们将通过运行 cargo new slog_demo 创建一个新的项目,这模拟了一个射击游戏。

我们需要在 Cargo.toml 文件中添加以下依赖项:

# slog_demo/Cargo.toml

[dependencies]
rand = "0.5.5"
slog = "2.4.1"
slog-async = "2.3.0"
slog-json = "2.2.0"

slog 框架非常适合中等到大型的项目,在这些项目中,模块之间存在大量的交互,因为它有助于集成详细的日志,以便对事件进行长期监控。它基于提供应用中的分层和可组合的日志配置以及允许进行语义事件日志记录的理念。在 slog 下有两个重要的概念,你需要了解它们才能成功使用这个库:日志记录器排水口。日志记录器对象用于记录事件,而排水口是一个抽象,指定了日志消息的去向以及如何到达那里。这可以是标准输出、一个文件或一个网络套接字。排水口类似于你在 C# 的 Serilog 框架中所称的 Sink

我们的演示模拟了基于实体行为的游戏事件。这些实体在游戏中具有父子关系,我们可以通过slog框架的结构化日志配置轻松地在它们中附加分层日志功能。当我们查看代码时,我们将了解这一点。在根级别,我们有Game实例,对于它,我们可以定义一个根日志记录器,为我们的日志消息提供一个基线上下文,例如游戏名称和版本。因此,我们将创建一个与Game实例关联的根日志记录器。接下来,我们有PlayerEnemy类型,它们是Game的子实体。这些成为根日志记录器的子日志记录器。然后,我们有敌人和玩家的武器,它们成为玩家的子日志记录器和敌人的日志记录器。正如你所见,设置slog比我们之前查看的框架要复杂一些。

除了slog作为基本 crate 外,我们还在我们的演示中使用了以下 crate:

  • slog-async: 提供了一个异步日志输出,将日志调用与主线程解耦。

  • slog-json: 一个将消息输出到任何Writer的 JSON 输出。在这个演示中,我们将使用stdout()作为Writer实例。

让我们看看我们的main.rs文件:

// slog_demo/main.rs

#[macro_use]
extern crate slog;

mod enemy;
mod player;
mod weapon;

use rand::Rng;
use std::thread;
use slog::Drain;
use slog::Logger;
use slog_async::Async;
use std::time::Duration;
use crate::player::Player;
use crate::enemy::Enemy;

pub trait PlayingCharacter {
    fn shoot(&self);
}

struct Game {
    logger: Logger,
    player: Player,
    enemy: Enemy
}

impl Game {
    fn simulate(&mut self) {
        info!(self.logger, "Launching game!");
        let enemy_or_player: Vec<&dyn PlayingCharacter> = vec![&self.enemy, &self.player];
        loop {
            let mut rng = rand::thread_rng();
            let a = rng.gen_range(500, 1000);
            thread::sleep(Duration::from_millis(a));
            let player = enemy_or_player[{
                if a % 2 == 0 {1} else {0}
            }];
            player.shoot();
        }
    }
}

在前面的代码中,我们有一系列use语句,然后是我们的PlayingCharacter特质,它由我们的PlayerEnemy结构体实现。我们的Game结构体有一个simulate方法,它简单地循环并随机休眠,从而在调用shoot方法之前随机选择玩家或敌人。让我们继续查看同一文件:

// slog_demo/src/main.rs

fn main() {
    let drain = slog_json::Json::new(std::io::stdout()).add_default_keys()
                                                       .build()
                                                       .fuse();
    let async_drain = Async::new(drain).build().fuse();
    let game_info = format!("v{}", env!("CARGO_PKG_VERSION"));
    let root_log_context = o!("Super Cool Game" => game_info);
    let root_logger = Logger::root(async_drain, root_log_context);
    let mut game = Game { logger: root_logger.clone(),
                          player: Player::new(&root_logger, "Bob"),
                          enemy: Enemy::new(&root_logger, "Malice") };
    game.simulate()
}

main中,我们首先使用slog_json::Json创建我们的drain,它可以将消息作为 JSON 对象记录,然后将其传递给另一个Async输出,它将所有日志调用卸载到单独的线程。然后,我们通过传递我们的drain和用于日志消息的初始上下文使用方便的o!宏创建我们的root_logger。在这个宏中,我们简单地使用CARGO_PKG_VERSION环境变量打印我们游戏的名字和版本。接下来,我们的Game结构体接受我们的根日志记录器和enemy以及player实例。我们将根日志记录器的引用传递给PlayerEnemy实例,它们使用它创建自己的子日志记录器。然后,我们在游戏实例上调用simulate

以下是player.rs的内容:

// slog_demo/src/player.rs

use slog::Logger;

use weapon::PlasmaCannon;
use PlayingCharacter;

pub struct Player {
    name: String,
    logger: Logger,
    weapon: PlasmaCannon
}

impl Player {
    pub fn new(logger: &Logger, name: &str) -> Self {
        let player_log = logger.new(o!("Player" => format!("{}", name)));
        let weapon_log = player_log.new(o!("PlasmaCannon" => "M435"));
        Self {
            name: name.to_string(),
            logger: player_log,
            weapon: PlasmaCannon(weapon_log),
        }
    }
}

在这里,我们的Player上的new方法获取根logger,然后使用o!宏添加自己的上下文。我们还创建了一个weapon的日志记录器,并将玩家日志记录器传递给它,它添加了自己的信息,例如武器的 ID。最后,我们返回配置好的Player实例:

impl PlayingCharacter for Player {
    fn shoot(&self) {
        info!(self.logger, "{} shooting with {}", self.name, self.weapon);
        self.weapon.fire();
    }
}

我们还为我们的Player实现了PlayingCharacter特质。

接下来是enemy.rs文件,它与player.rs中的所有内容相同:

// slog_demo/src/enemy.rs

use weapon::RailGun;
use PlayingCharacter;
use slog::Logger;

pub struct Enemy {
    name: String,
    logger: Logger,
    weapon: RailGun
}

impl Enemy {
    pub fn new(logger: &Logger, name: &str) -> Self {
        let enemy_log = logger.new(o!("Enemy" => format!("{}", name)));
        let weapon_log = enemy_log.new(o!("RailGun" => "S12"));
        Self { 
            name: name.to_string(),
            logger: enemy_log,
            weapon: RailGun(weapon_log)
        }
    }
}

impl PlayingCharacter for Enemy {
    fn shoot(&self) {
        warn!(self.logger, "{} shooting with {}", self.name, self.weapon);
        self.weapon.fire();
    }
}

然后,我们有weapon.rs文件,它包含两个被敌人和玩家实例使用的武器:

// slog_demo/src/weapon.rs

use slog::Logger;
use std::fmt;

#[derive(Debug)]
pub struct PlasmaCannon(pub Logger);

impl PlasmaCannon {
    pub fn fire(&self) {
        info!(self.0, "Pew Pew !!");
    }
}

#[derive(Debug)]
pub struct RailGun(pub Logger);

impl RailGun {
    pub fn fire(&self) {
        info!(self.0, "Swoosh !!");
    }
}

impl fmt::Display for PlasmaCannon {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, stringify!(PlasmaCannon))
    }
}

impl fmt::Display for RailGun {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, stringify!(RailGun))
    }
}

这就是我们的游戏模拟所需的所有内容。现在我们可以通过调用cargo run来运行它。以下是我机器上的输出:

图片

如您所见,我们的游戏实体发送日志消息,然后借助slog及其 drains 进行格式化并输出为 JSON。与之前使用的 JSON drain 类似,社区为slog构建了许多这样的 drains。我们可以有一个将日志消息直接输出到日志聚合服务的 drain,该服务知道如何处理 JSON 数据,并且可以轻松地对它们进行索引,以便高效地检索日志。slog的可插拔和可组合特性使其在日志解决方案中脱颖而出。通过这个演示,我们已经到达了 Rust 中日志记录故事的结尾。然而,还有其他更多有趣的日志框架供您探索,您可以在www.arewewebyet.org/topics/logging/找到它们。

摘要

在本章中,我们学习了在软件开发中日志记录的重要性以及接近它的方法,包括在选择日志框架时应该寻找哪些特性。我们还了解了非结构化和结构化日志,它们的优缺点,并探讨了 Rust 生态系统中的可用 crate,以将日志集成到我们的应用程序中。

下一章将介绍网络编程,我们将探讨 Rust 提供的内置功能和 crate,以创建能够相互通信的高效应用程序。

第十二章:Rust 网络编程

在本章中,我们将探讨 Rust 在网络编程方面能提供什么。我们将从通过构建一个简单的 Redis 克隆来探索标准库中的现有网络原语开始。这将帮助我们熟悉默认的同步网络 I/O 模型及其局限性。接下来,我们将解释在处理大规模网络 I/O 时异步性为何是一种更好的方法。在这个过程中,我们将了解 Rust 生态系统提供的抽象,用于构建异步网络应用程序,并使用第三方 crate 重构我们的 Redis 服务器,使其异步化。

本章将涵盖以下主题:

  • 网络编程序曲

  • 同步网络 I/O

  • 构建简单的 Redis 服务器

  • 异步网络 I/O

  • futurestokiocrate 的介绍

网络编程序曲

"一个程序就像一首诗:你不写下来就无法创作一首诗。"

E. W. Dijkstra

通过构建一种机器可以在互联网上相互通信的媒介是一项复杂的任务。互联网上有不同种类的设备进行通信,运行着不同的操作系统和不同版本的应用程序,它们需要一套共同认可的规则来相互交换信息。这些通信规则被称为网络协议,设备之间发送给对方的信息被称为网络数据包。

为了分离各种方面的关注点,如可靠性、可发现性和封装性,这些协议被划分为层,高层协议堆叠在底层之上。每个网络数据包都由所有这些层的信息组成。如今,现代操作系统已经预装了网络协议栈的实现。在这个实现中,每一层都为其上层提供支持。

在最低层,我们有物理层和数据链路层协议,用于指定如何在互联网上的节点之间通过电线传输数据包,以及它们如何在计算机的网络卡中进出。这一层的协议是以太网和令牌环协议。在其之上,我们有 IP 层,它使用称为 IP 地址的唯一 ID 概念来识别互联网上的节点。在 IP 层之上,我们有传输层,这是一个在互联网上的两个进程之间提供点对点交付的协议。TCP 和 UDP 等协议存在于这一层。在传输层之上,我们有 HTTP 和 FTP 等应用层协议,它们都用于构建丰富的应用程序。这允许实现更高层次的通信,例如在移动设备上运行的聊天应用程序。整个协议栈协同工作,以促进互联网上运行的计算机应用程序之间的这些复杂交互。

随着设备通过互联网相互连接并共享信息,分布式应用程序架构开始普及。出现了两种模型:去中心化模型,通常被称为对等网络模型,以及中心化模型,通常被称为客户端-服务器模型。后者在两种模型中更为常见。在本章中,我们将重点关注构建网络应用程序的客户端-服务器模型,特别是在传输层。

在主要操作系统中,网络堆栈的传输层通过名为套接字的一系列 API 向开发者暴露。它包括一组接口,用于在两个进程之间建立通信链路。套接字允许你在两个进程之间,无论是本地还是远程,来回传递数据,而不需要开发者了解底层网络协议。

Socket API 的根源在于伯克利软件发行版(BSD),这是第一个在 1983 年提供带有套接字 API 的网络堆栈实现的操作系统。它现在仍然是主要操作系统网络堆栈的参考实现。在类 Unix 系统中,套接字遵循“一切皆文件”的哲学,并暴露出文件描述符 API。这意味着可以从套接字读取和写入数据,就像文件一样。

套接字是文件描述符(一个整数),指向由内核管理的进程的描述符表。描述符表包含文件描述符到文件条目结构的映射,该结构包含发送到套接字的数据的实际缓冲区。

Socket API 主要在 TCP/IP 层工作。在这一层,我们创建的套接字根据不同的级别进行分类:

  • 协议:根据协议的不同,我们可以拥有 TCP 套接字或 UDP 套接字。TCP 是一种有状态的流式协议,它能够以可靠的方式传递消息,而 UDP 则是一种无状态且不可靠的协议。

  • 通信类型:根据我们是否与同一台机器上的进程或远程机器上的进程进行通信,我们可以使用互联网套接字或 Unix 域套接字。互联网套接字用于在远程机器上的进程之间交换消息。它由一个 IP 地址和一个端口号的元组表示。两个想要远程通信的进程必须使用 IP 套接字。Unix 域套接字用于在同一台机器上运行的进程之间的通信。在这里,它使用一个文件系统路径而不是 IP 地址-端口号对。例如,数据库使用 Unix 域套接字来公开连接端点。

  • I/O 模型:根据我们如何将数据读入或写入套接字,我们可以创建两种类型的套接字:阻塞套接字和非阻塞套接字。

现在我们对套接字有了更多的了解,让我们更深入地探讨一下客户端-服务器模型。在这个网络模型中,设置两台机器相互通信的常规流程遵循以下过程:服务器创建一个套接字并将其绑定到一个 IP 地址-端口号对,然后指定一个协议,可以是 TCP 或 UDP。然后,它开始监听来自客户端的连接。另一方面,客户端创建一个连接套接字并连接到指定的 IP 地址和端口。在 Unix 中,进程可以使用socket系统调用创建套接字。这个调用会返回一个文件描述符,程序可以使用它来对客户端或服务器执行读写调用。

Rust 在标准库中为我们提供了net模块。这个模块包含了上述传输层上的网络原语。对于通过 TCP 进行通信,我们有TcpStreamTcpListener类型。对于通过 UDP 进行通信,我们有UdpSocket类型。net模块还提供了表示 IP 地址的正确数据类型,并支持 v4 和 v6 版本。

构建可靠的网络应用程序需要考虑多个因素。如果你对在消息交换过程中丢失少量数据包没有异议,可以选择 UDP 套接字,但如果你不能容忍数据包丢失或希望消息按顺序交付,则必须使用 TCP 套接字。UDP 协议速度快,出现较晚,是为了满足需要最小延迟交付数据包且可以处理少量数据包丢失的需求。例如,视频聊天应用程序使用 UDP,但如果视频流中丢失几个帧,你并不会特别受到影响。UDP 用于那些可以容忍无交付保证的情况。在本章中,我们将重点关注 TCP 套接字,因为它是大多数需要可靠性的网络应用程序最常用的协议。

另一个需要考虑的因素是,你的应用程序在服务客户端方面有多好、有多高效。从技术角度来看,这相当于选择套接字的 I/O 模型。

I/O 是输入/输出的缩写,在这个上下文中,它是一个总称,简单地表示将字节读取和写入套接字。

在阻塞和非阻塞套接字之间进行选择会改变其架构,我们编写代码的方式,以及它如何扩展到客户端。阻塞套接字为你提供了一个同步 I/O模型,而非阻塞套接字则允许你进行异步 I/O。在实现 Socket API 的平台(如 Unix)上,套接字默认以阻塞模式创建。这意味着在主要的网络堆栈中,默认的 I/O 模型遵循同步模型。接下来,让我们探讨这两种模型。

同步网络 I/O

正如我们之前所说的,默认情况下,套接字是以阻塞模式创建的。在阻塞模式下,服务器是同步的,这意味着套接字上的每次读写调用都会阻塞并等待完成。如果另一个客户端尝试连接到服务器,它需要等待服务器完成对前一个客户端的服务。也就是说,直到 TCP 读写缓冲区填满,您的应用程序会在相应的 I/O 操作上阻塞,任何新的客户端连接都必须等待缓冲区再次为空并填满。

TCP 协议在内核级别实现了自己的读写缓冲区,除了应用程序维护自己的任何缓冲区之外。

Rust 的标准库网络原语为套接字提供了相同的同步 API。为了看到这个模型在行动中的样子,我们将实现一个比回声服务器更复杂的东西。我们将构建 Redis 的一个简化版本。Redis 是一个数据结构服务器,通常用作内存数据存储。Redis 客户端和服务器使用 RESP 协议,这是一个简单的基于行的协议。虽然该协议对 TCP 或 UDP 是中立的,但 Redis 实现主要使用 TCP 协议。TCP 是一个有状态的基于流的协议,服务器和客户端无法识别从套接字中读取多少字节来构建一个协议消息。为了解决这个问题,大多数协议遵循使用长度字节,然后是相同长度的有效载荷字节的模式。

RESP 协议中的消息类似于 TCP 中大多数基于行的协议,初始字节是一个标记字节,后面跟着有效载荷的长度,然后是有效载荷本身。消息以一个终止标记字节结束。RESP 协议支持各种类型的消息,从简单的字符串、整数、数组到大量字符串等等。在 RESP 协议中,消息以\r\n字节序列结束。例如,服务器从客户端发送的成功消息编码并发送为+OK\r\n(不带引号)。+表示成功回复,然后是字符串。命令以\r\n结束。为了指示查询是否失败,Redis 服务器以-Nil\r\n回复。

getset等命令作为大量字符串的数组发送。例如,一个get foo命令将如下发送:

*2\r\n$3\r\nget\r\n$3\r\nfoo\r\n

在前面的消息中,*2表示我们有一个包含2个命令的数组,并由\r\n分隔。随后,$3表示我们有一个长度为3的字符串,即GET命令后跟一个表示字符串foo$3。命令以\r\n结束。这就是 RESP 协议的基础。我们不必担心解析 RESP 消息的低级细节,因为我们将会使用一个名为resp的 crate 的分支来解析来自客户端的字节流,并将其转换为有效的 RESP 消息。

构建一个同步的 Redis 服务器

为了使这个例子简短且易于理解,我们的 Redis 克隆将实现 RESP 协议的一个非常小的子集,并且只能处理 SETGET 调用。我们将使用官方的 redis-cli,它是官方 Redis 软件包的一部分,来对我们的服务器进行查询。要使用 redis-cli,我们可以在 Ubuntu 上运行 apt-get install redis-server 来安装 Redis。

通过运行 cargo new rudis_sync 并在我们的 Cargo.toml 文件中添加以下依赖项来创建一个新的项目:

rudis_sync/Cargo.toml

[dependencies]
lazy_static = "1.2.0"
resp = { git = "https://github.com/creativcoder/resp" }

我们将我们的项目命名为 rudis_sync。我们依赖于两个 crate:

  • lazy_static:我们将使用这个 crate 来存储我们的内存数据库。

  • resp:这是一个位于我的 GitHub 仓库上的 forked crate。我们将使用它来解析来自客户端的字节流。

为了使实现更容易理解,rudis_sync 具有非常少的错误处理集成。当你完成代码的实验后,我们鼓励你实现更好的错误处理策略。

让我们从 main.rs 文件的内容开始:

// rudis_sync/src/main.rs

use lazy_static::lazy_static;
use resp::Decoder;
use std::collections::HashMap;
use std::env;
use std::io::{BufReader, Write};
use std::net::Shutdown;
use std::net::{TcpListener, TcpStream};
use std::sync::Mutex;
use std::thread;

mod commands;
use crate::commands::process_client_request;

type STORE = Mutex<HashMap<String, String>>;

lazy_static! {
    static ref RUDIS_DB: STORE = Mutex::new(HashMap::new());
}

fn main() {
    let addr = env::args()
        .skip(1)
        .next()
        .unwrap_or("127.0.0.1:6378".to_owned());

    let listener = TcpListener::bind(&addr).unwrap();
    println!("rudis_sync listening on {} ...", addr);

    for stream in listener.incoming() {
        let stream = stream.unwrap();
        println!("New connection from: {:?}", stream);
        handle_client(stream);
    }
}

我们有一系列导入,随后是一个在 lazy_static! 块中声明的内存 RUDIS_DB hashmap。我们正在使用这个作为内存数据库来存储客户端发送的键值对。在我们的 main 函数中,我们从用户提供的参数创建一个监听地址 addr 或使用 127.0.0.1:6378 作为默认值。然后我们通过调用关联的 bind 方法并传递 addr 来创建一个 TcpListener 实例。稍后,我们在 listener 上调用 incoming 方法,它返回一个新客户端连接的迭代器。对于每个客户端连接 stream,它属于 TcpStream 类型(客户端套接字),我们调用 handle_client 函数,并传入 stream

在同一文件中,handle_client 函数负责解析从客户端发送的查询,这些查询将是 GETSET 查询之一:

// rudis_sync/src/main.rs

fn handle_client(stream: TcpStream) {
    let mut stream = BufReader::new(stream);
    let decoder = Decoder::new(&mut stream).decode();
    match decoder {
        Ok(v) => {
            let reply = process_client_request(v);
            stream.get_mut().write_all(&reply).unwrap();
        }
        Err(e) => {
            println!("Invalid command: {:?}", e);
            let _ = stream.get_mut().shutdown(Shutdown::Both);
        }
    };
}

handle_client 函数接收 stream 变量中的客户端 TcpStream 套接字。我们将我们的客户端 stream 包装在一个 BufReader 中,然后将其作为可变引用传递给 resp crate 中的 Decoder::new 方法。Decoderstream 中读取字节以创建一个 RESP Value 类型。然后我们有一个匹配块来检查我们的解码是否成功。如果失败,我们打印一条错误消息,并通过调用 shutdown() 并请求使用 Shutdown::Both 值关闭客户端套接字连接的读取部分和写入部分来关闭套接字。shutdown 方法需要一个可变引用,所以我们在此之前调用 get_mut()。在实际实现中,显然需要优雅地处理这个错误。

如果解码成功,我们调用 process_client_request,它返回要发送回客户端的 reply。我们通过在客户端 stream 上调用 write_all 将此 reply 写入客户端。process_client_request 函数在 commands.rs 中定义如下:

// rudis_sync/src/commands.rs

use crate::RUDIS_DB;
use resp::Value;

pub fn process_client_request(decoded_msg: Value) -> Vec<u8> {
    let reply = if let Value::Array(v) = decoded_msg {
        match &v[0] {
            Value::Bulk(ref s) if s == "GET" || s == "get" => handle_get(v),
            Value::Bulk(ref s) if s == "SET" || s == "set" => handle_set(v),
            other => unimplemented!("{:?} is not supported as of now", other),
        }
    } else {
        Err(Value::Error("Invalid Command".to_string()))
    };

    match reply {
        Ok(r) | Err(r) => r.encode(),
    }
}

此函数接收解码后的Value并在解析的查询上匹配它。在我们的实现中,我们期望客户端发送一个大量字符串的数组,所以我们匹配Value::ArrayValue变体,使用if let并将数组存储在v中。如果在if分支中匹配为一个Array值,我们取该数组并匹配v中的第一个条目,这将是我们命令的类型,即GETSET。这同样是一个Value::Bulk变体,它将命令作为字符串包装。

我们将内部字符串的引用记为s,并且只有当字符串的值为GETSET时才进行匹配。在GET的情况下,我们调用handle_get函数,传递v数组,而在SET的情况下,我们调用handle_set函数。在else分支中,我们简单地向客户端发送一个带有描述invalid CommandValue::Error回复。

两个分支返回的值分配给reply变量。然后它被匹配为内部类型r,并通过调用其上的encode方法将其转换为Vec<u8>,然后从函数返回。

我们的handle_sethandle_get函数定义在同一文件中,如下所示:

// rudis_sync/src/commands.rs

use crate::RUDIS_DB;
use resp::Value;

pub fn handle_get(v: Vec<Value>) -> Result<Value, Value> {
    let v = v.iter().skip(1).collect::<Vec<_>>();
    if v.is_empty() {
        return Err(Value::Error("Expected 1 argument for GET command".to_string()))
    }
    let db_ref = RUDIS_DB.lock().unwrap();
    let reply = if let Value::Bulk(ref s) = &v[0] {
        db_ref.get(s).map(|e| Value::Bulk(e.to_string())).unwrap_or(Value::Null)
    } else {
        Value::Null
    };
    Ok(reply)
}

pub fn handle_set(v: Vec<Value>) -> Result<Value, Value> {
    let v = v.iter().skip(1).collect::<Vec<_>>();
    if v.is_empty() || v.len() < 2 {
        return Err(Value::Error("Expected 2 arguments for SET command".to_string()))
    }
    match (&v[0], &v[1]) {
        (Value::Bulk(k), Value::Bulk(v)) => {
            let _ = RUDIS_DB
                .lock()
                .unwrap()
                .insert(k.to_string(), v.to_string());
        }
        _ => unimplemented!("SET not implemented for {:?}", v),
    }

    Ok(Value::String("OK".to_string()))
}

handle_get()函数中,我们首先检查GET命令的查询中是否没有键存在,并返回错误信息。接下来,我们在v[0]上匹配,它是GET命令的键,并检查它是否存在于我们的数据库中。如果存在,我们使用 map 组合器将其包装在Value::Bulk中,否则我们返回一个Value::Null回复:

db_ref.get(s).map(|e| Value::Bulk(e.to_string())).unwrap_or(Value::Null)

然后我们将其存储在reply变量中,并以Result类型返回它,即Ok(reply)

handle_set中发生类似的事情,如果我们没有足够的参数传递给SET命令,我们就退出。接下来,我们使用&v[0]&v[1]匹配我们的键和值,并将其插入到RUDIS_DB中。作为对SET查询的确认,我们回复Ok

在我们的process_client_request函数中,一旦我们创建了回复字节,我们就匹配Result类型,并通过调用encode()将其转换为Vec<u8>,然后写入客户端。随着这个过程的结束,现在是时候用官方的redis-cli工具测试我们的客户端了。我们将通过调用redis-cli -p 6378来运行它:

在前面的会话中,我们进行了一些GETSET查询,并期望从rudis_sync得到回复。此外,这是我们的新连接的rudis_server的输出日志:

但我们服务器的问题是我们必须等待初始客户端完成服务。为了演示这一点,我们将在处理新客户端连接的for循环中引入一点延迟:

    for stream in listener.incoming() {
        let stream = stream.unwrap();
        println!("New connection from: {:?}", stream);
        handle_client(stream);
        thread::sleep(Duration::from_millis(3000));
    }

sleep调用模拟了请求处理中的延迟。为了看到延迟,我们将几乎同时启动两个客户端,其中一个客户端发出SET请求,另一个客户端对同一键发出GET请求。以下是执行SET请求的第一个客户端:

这是我们的第二个客户端,它在相同的键foo上执行GET请求:

正如您所看到的,第二个客户端不得不等待近三秒钟才能收到第二个GET响应。

由于其本质,当需要同时处理超过 100,000(比如说)个客户端时,同步模式就会成为瓶颈,每个客户端的处理时间各不相同。为了解决这个问题,通常需要为每个客户端连接创建一个线程。每当建立新的客户端连接时,我们就会创建一个新线程,并将handle_client调用从主线程卸载,这样主线程就可以接受其他客户端连接。我们可以在main函数中通过一行代码实现这一点,如下所示:

    for stream in listener.incoming() {
        let stream = stream.unwrap();
        println!("New connection from: {:?}", stream); thread::spawn(|| handle_client(stream));
    }

这消除了我们服务器的阻塞特性,但引入了每次接收到新的客户端连接时都要创建新线程的开销。首先,有创建线程的开销,其次,线程之间的上下文切换时间又增加了额外的开销。

正如我们所见,我们的rudis_sync服务器按预期工作。但很快它将因为机器能处理的线程数量而成为瓶颈。这种处理连接的线程模型在互联网开始获得更广泛的受众,越来越多的客户端连接到互联网成为常态之前工作得很好。然而,如今情况不同了,我们需要能够同时处理数百万请求的高效服务器。实际上,我们可以在更基础的层面上解决处理更多客户端的问题,即通过使用非阻塞套接字。让我们接下来探讨它们。

异步网络 I/O

正如我们在rudis_sync服务器实现中看到的,在给定时间段内处理多个客户端时,同步 I/O 模型可能成为主要瓶颈。必须使用线程来处理更多客户端。然而,有一种更好的方法来扩展我们的服务器。我们不必处理套接字的阻塞特性,我们可以使套接字非阻塞。使用非阻塞套接字,任何读取、写入或连接操作,在套接字上都会立即返回,无论操作是否成功完成,也就是说,如果读取和写入缓冲区部分填满,它们不会阻塞调用代码。这就是异步 I/O 模型,因为没有客户端需要等待其请求完成,而是稍后会被通知请求的完成或失败。

与线程相比,异步模型非常高效,但它给我们的代码增加了更多复杂性。在这个模型中,由于对套接字的初始读取或写入调用不太可能成功,我们需要在稍后时间再次尝试感兴趣的运算。在套接字上重试运算的过程称为轮询。我们需要不时轮询套接字,以查看我们的读取/写入/连接操作是否可以完成,并维护我们迄今为止已读取或写入的字节数的状态。在大量传入套接字连接的情况下,使用非阻塞套接字意味着必须处理轮询和维护状态。这很快就会变成一个复杂的状态机。此外,轮询是一个非常低效的操作。即使我们没有套接字上的任何事件。尽管如此,还有更好的方法。

在基于 Unix 的平台,套接字的轮询机制是通过pollselect系统调用完成的,这些调用在所有 Unix 平台上都可用。Linux 除了它们之外,还有一个更好的epollAPI。我们不必自己轮询套接字,这是一个低效的操作,这些 API 可以告诉我们套接字何时准备好读取或写入。与 poll 和 select 在每个请求的套接字上运行一个 for 循环不同,epollO(1)的复杂度通知任何感兴趣的方有新的套接字事件。

异步 I/O 模型允许你处理比同步模型多得多的套接字数量,因为我们是在小块操作中进行的,并且快速切换到服务其他客户端。另一个效率是,我们不需要创建线程,因为所有操作都在单个线程中完成。

要使用非阻塞套接字编写异步网络应用程序,Rust 生态系统中有几个高质量的 crate。

Rust 中的异步抽象

异步网络 I/O 具有优势,但以原始形式编程它们是困难的。幸运的是,Rust 通过第三方 crate 为我们提供了方便的抽象,用于处理异步 I/O。它减轻了开发者处理非阻塞套接字和底层套接字轮询机制时的大部分复杂状态机处理。可用的底层抽象 crate 中有两个是futuresmiocrate。让我们简要了解一下它们。

Mio

当与非阻塞套接字一起工作时,我们需要一种方法来检查套接字是否已准备好所需的操作。当我们有成千上万个或更多的套接字要管理时,情况会更糟。我们可以使用非常低效的方法,通过运行循环、检查套接字状态,并在准备好后执行操作。但还有更好的方法来做这件事。在 Unix 中,我们有了 poll 系统调用,你可以向它提供你想要监控事件的一组文件描述符。然后它被 select 系统调用所取代,这稍微改进了事情。然而,selectpoll 都不可扩展,因为它们在底层基本上是循环,随着更多套接字被添加到其监控列表中,迭代时间呈线性增长。

在 Linux 下,随后出现了 epoll,这是当前最有效率的文件描述符多路复用 API。它被大多数想要进行异步 I/O 的网络和 I/O 应用程序所使用。其他平台也有类似的抽象,例如 macOS 和 BSD 中的 kqueue。在 Windows 上,我们有 IO Completion Ports (IOCP)

正是这些低级抽象,mio 才进行了抽象,为所有这些 I/O 多路复用 API 提供了一个跨平台的、高度高效的接口。Mio 是一个非常底层的库,但它提供了一种方便的方式来设置用于套接字事件的反应器。它提供了与标准库相同的网络原语,例如 TcpStream 类型,但这些默认是非阻塞的。

Futures

mio 的套接字轮询状态机中玩弄并不方便。为了提供一个可以由应用程序开发者使用的更高级别的 API,我们有了 futures 包。futures 包提供了一个名为 Future 的特质,这是包的核心组件。future 代表了一种计算,它目前不可用,但可能以后会可用。让我们看看 Future 特质的类型签名,以了解更多信息:

pub trait Future {
    type Item;
    type Error;
    fn poll(&mut self) -> Poll<Self::Item, Self::Error>;
}

Future 是一个关联的类型特质,它定义了两种类型:一个表示 Future 将要解析到的值的 Item 类型,以及一个指定未来将失败时错误类型的 Error 类型。它们与标准库中的 Result 类型非常相似,但它们不是立即获取结果,而是不立即计算。

单独的 Future 值不能用来构建异步应用程序。你需要某种类型的反应器和事件循环来推进未来的完成。按照设计,它们成功返回值或失败返回错误的方式只有通过轮询。这个操作由称为 poll 的单个要求方法表示。poll 方法指定了如何推进未来。一个未来可以由几个东西组成,一个接一个地链在一起。要推进一个未来,我们需要一个反应器和事件循环实现,这由 tokio 包提供。

Tokio

结合上述两种抽象,以及工作窃取调度器、事件循环和计时器实现,我们得到了tokio crate,它为驱动这些未来完成提供了运行时。使用tokio框架,你可以启动许多未来,并使它们并发运行。

tokio crate 的诞生是为了提供一个通用的解决方案,用于构建健壮且高性能的异步网络应用程序,这些应用程序对协议不敏感,但提供了适用于所有网络应用程序中常见模式的抽象。从技术上来说,tokio crate 是一个运行时,由线程池、事件循环和基于 mio 的 I/O 事件反应器组成。当我们提到运行时时,意味着使用 tokio 开发的每个 Web 应用程序都将运行上述组件作为应用程序的一部分。

在 tokio 框架中,未来在任务内部运行。任务类似于用户空间线程或绿色线程。执行者负责调度任务以执行。

当一个未来没有要解析的数据,或者在一个TcpStream客户端读取的情况下等待数据到达套接字时,它返回一个NotReady状态。但是,在这样做的同时,它还需要向反应器注册兴趣,以便在服务器上收到任何新数据的通知。

当创建一个未来时,不会执行任何工作。为了使未来定义的工作发生,必须将未来提交给执行者。在 tokio 中,任务是以用户级线程的形式执行未来的。在其poll方法的实现中,任务必须安排自己以便稍后进行轮询,以防无法取得进展。为此,它必须将其任务处理器传递给反应器线程。在 Linux 的情况下,反应器是 mio 这个 crate。

构建异步 Redis 服务器

现在我们已经熟悉了 Rust 生态系统提供的异步 I/O 解决方案,是时候回顾我们的 Redis 服务器实现了。我们将使用tokiofutures crate 将我们的rudis_sync服务器移植到异步版本。与任何异步代码一样,一开始使用futurestokio可能会感到困惑,并且可能需要时间来适应其 API。然而,我们将尽力使这里的内容易于理解。让我们通过运行cargo new rudis_async并使用以下依赖项在Cargo.toml中创建我们的项目开始:

# rudis_async/Cargo.toml

[dependencies]
tokio = "0.1.13"
futures = "0.1.25"
lazy_static = "1.2.0"
resp = { git = "https://github.com/creativcoder/resp" }
tokio-codec = "0.1.1"
bytes = "0.4.11"

我们在这里使用了一堆 crate:

  • futures:为处理异步代码提供了一个更清晰的抽象。

  • tokio:封装 mio 并提供异步代码的运行时。

  • lazy_static:允许我们创建一个可以修改的动态全局变量。

  • resp:一个可以解析 Redis 协议消息的 crate。

  • tokio-codec:这允许你将网络中的字节流转换为给定的类型,该类型根据指定的编解码器被解析为确定的消息。在 tokio 生态系统中,编解码器将字节流转换为解析后的消息,称为

  • bytes:这是与 tokio codec 一起使用,以高效地将字节流转换为给定的 Frame

我们在 main.rs 中的初始代码遵循类似的结构:

// rudis_async/src/main.rs

mod codec;
use crate::codec::RespCodec;

use lazy_static::lazy_static;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Mutex;
use tokio::net::TcpListener;
use tokio::net::TcpStream;
use tokio::prelude::*;
use tokio_codec::Decoder;
use std::env;

mod commands;
use crate::commands::process_client_request;

lazy_static! {
    static ref RUDIS_DB: Mutex<HashMap<String, String>> = Mutex::new(HashMap::new());
}

我们有一系列导入和 lazy_static! 块中的相同 RUDIS_DB。然后我们有我们的 main 函数:

// rudis_async/main.rs

fn main() -> Result<(), Box<std::error::Error>> {
    let addr = env::args()
        .skip(1)
        .next()
        .unwrap_or("127.0.0.1:6378".to_owned());
    let addr = addr.parse::<SocketAddr>()?;

    let listener = TcpListener::bind(&addr)?;
    println!("rudis_async listening on: {}", addr);

    let server_future = listener
        .incoming()
        .map_err(|e| println!("failed to accept socket; error = {:?}", e))
        .for_each(handle_client);

    tokio::run(server_future);
    Ok(())
}

我们解析传递给参数的字符串或使用默认地址 127.0.0.1:6378。然后我们使用 addr 创建一个新的 TcpListener 实例。这返回给我们一个存储在 listener 中的 future。然后我们通过调用 incoming 在这个 future 上调用 for_each,它接受一个闭包并在其上调用 handle_client。这个 future 被存储为 server_future。最后,我们通过传递 server_future 调用 tokio::run,这创建了一个主要的 tokio 任务并安排了 future 的执行。

在同一文件中,我们的 handle_client 函数定义如下:

// rudis_async/src/main.rs

fn handle_client(client: TcpStream) -> Result<(), ()> {
    let (tx, rx) = RespCodec.framed(client).split();
    let reply = rx.and_then(process_client_request);
    let task = tx.send_all(reply).then(|res| {
        if let Err(e) = res {
            eprintln!("failed to process connection; error = {:?}", e);
        }
        Ok(())
    });

    tokio::spawn(task);
    Ok(())
}

handle_client 函数中,我们首先将我们的 TcpStream 分割成一个写入器(tx)和一个读取器(rx)部分,首先将流转换为带有 RespCodec 的 framed future,通过调用 RudisFrame 上的 framed 接收客户端连接并将其转换为 framed future。随后,我们调用它上的 split 方法,该方法将帧转换为 StreamSink future。这仅仅给我们提供了 txrx 来从客户端套接字进行读写。然而,当我们读取它时,我们得到解码后的消息。当我们向 tx 写入任何内容时,我们写入编码的字节序列。

rx 上,我们调用 and_then 并传递 process_client_request 函数,该函数将 future 解析为解码后的帧。然后我们获取写入器部分 tx,并使用 reply 调用 send_all。然后我们通过调用 tokio::spawn 来启动 future 任务。

在我们的 codec.rs 文件中,我们定义了 RudisFrame,它实现了来自 tokio-codec crate 的 EncoderDecoder traits:

// rudis_async/src/codec.rs

use std::io;
use bytes::BytesMut;
use tokio_codec::{Decoder, Encoder};
use resp::{Value, Decoder as RespDecoder};
use std::io::BufReader;
use std::str;

pub struct RespCodec;

impl Encoder for RespCodec {
    type Item = Vec<u8>;
    type Error = io::Error;

    fn encode(&mut self, msg: Vec<u8>, buf: &mut BytesMut) -> io::Result<()> {
        buf.reserve(msg.len());
        buf.extend(msg);
        Ok(())
    }
}

impl Decoder for RespCodec {
    type Item = Value;
    type Error = io::Error;

    fn decode(&mut self, buf: &mut BytesMut) -> io::Result<Option<Value>> {
        let s = if let Some(n) = buf.iter().rposition(|b| *b == b'\n') {
            let client_query = buf.split_to(n + 1);

            match str::from_utf8(&client_query.as_ref()) {
                Ok(s) => s.to_string(),
                Err(_) => return Err(io::Error::new(io::ErrorKind::Other, "invalid string")),
            }
        } else {
            return Ok(None);
        };

        if let Ok(v) = RespDecoder::new(&mut BufReader::new(s.as_bytes())).decode() {
            Ok(Some(v))
        } else {
            Ok(None)
        }
    }
}

Decoder 实现指定了如何将传入的字节解析为 resp::Value 类型,而 Encoder trait 指定了如何将 resp::Value 编码为发送到客户端的字节流。

我们的 commands.rs 文件实现与之前相同,所以我们将跳过那部分。话虽如此,让我们通过运行 cargo run 来尝试我们的新服务器:

图片

使用官方的 redis-cli 客户端,我们可以通过运行以下命令连接到我们的服务器:

$ redis-cli -p 6378

下面是一个运行 redis-clirudis_async 服务器进行会话的示例:

图片

摘要

Rust 非常适合提供高性能、质量和安全性,适用于网络应用程序。虽然内置原语非常适合同步应用程序模型,但对于异步 I/O,Rust 提供了丰富的库和良好的文档 API,这有助于你构建高性能应用程序。

在下一章中,我们将提升网络协议栈,并学习如何使用 Rust 构建 Web 应用程序。

第十三章:使用 Rust 构建 Web 应用程序

在本章中,我们将探讨使用 Rust 构建网络应用。我们将了解使用静态类型系统构建网络应用时的好处,以及编译语言的速度。我们还将探索 Rust 的强类型 HTTP 库,并通过练习构建一个 URL 缩短器。在此之后,我们将研究一个非常流行的框架 Actix-web,并使用它构建一个书签 API 服务器。

本章将涵盖以下主题:

  • Rust 中的网络应用

  • 使用 Hyper Crate 构建 URL 缩短器

  • 网络框架的需求

  • 理解 Actix-web 框架

  • 使用 Actix-web 构建 HTTP Rest API

Rust 中的网络应用

"程序最重要的属性是它是否实现了用户的意图。"

C. A. R. 霍尔

低级语言通常难以同时让开发者使用它编写网络应用并提供动态语言那样的高级人机工程学。然而,使用 Rust 则恰恰相反。用 Rust 开发网络应用与动态语言如 Ruby 或 Python 的体验相似,这得益于其高级抽象。

尽管在动态语言中开发的网络应用可以走得很远,但许多开发者发现,当他们的代码库达到大约 10 万行代码时,他们开始看到动态语言的脆弱本质。随着你做的每一个小改动,你都需要有测试来告诉你应用程序的哪些部分受到影响。随着应用程序的增长,在测试和更新方面,它变成了一个打地鼠的情况。

在像 Rust 这样的静态类型语言中构建网络应用是另一种层次的经验。在这里,你可以在代码编译时进行检查,从而大大减少你需要编写的单元测试数量。你也不需要像动态语言那样运行时(如运行 GC 的解释器)的开销。使用静态类型语言编写的网络应用可以编译为一个单一的静态二进制文件,只需最少的设置即可部署。此外,你从类型系统中获得速度和准确性的保证,并且在代码重构期间,编译器提供了大量帮助。Rust 给你所有这些保证,同时保持了动态语言的高层感觉。

网络应用主要位于应用层协议之上,并使用 HTTP 协议进行通信。HTTP 是一种无状态协议,其中每个消息要么是客户端或服务器发送的请求,要么是响应。HTTP 协议中的消息由头部和有效负载组成。头部提供了 HTTP 消息的上下文信息,例如其来源或有效负载的长度,而有效负载包含实际数据。HTTP 是一种基于文本的协议,我们通常使用库来执行将字符串解析为适当的 HTTP 消息的繁重工作。这些库进一步用于在其之上构建高级抽象,例如 Web 框架。

在 Rust 中,我们使用hypercrate 来使用 HTTP,我们将在下一节中对其进行探讨。

使用 Hyper 的强类型 HTTP

hypercrate 可以解析 HTTP 消息,并具有优雅的设计,专注于强类型 API。它被设计为原始 HTTP 请求的类型安全抽象,与 HTTP 库中的常见主题相反:将一切描述为字符串。例如,Hyper 中的 HTTP 状态码被定义为枚举,例如,类型StatusCode。对于几乎所有可以强类型化的内容,例如 HTTP 方法、MIME 类型、HTTP 头部等,都是如此。

Hyper 将客户端和服务器功能分别拆分为独立的模块。客户端允许你使用可配置的请求体、头部和其他低级配置来构建和发送 HTTP 请求。服务器端允许你打开监听套接字并将其请求处理器附加到它。然而,它不包括任何请求路由处理器实现——这留给了 Web 框架。它被设计成作为构建更高层次 Web 框架的基础 crate。它底层使用相同的tokiofutures异步抽象,因此性能非常出色。

在其核心,Hyper 具有Service特质概念:

pub trait Service {
    type ReqBody: Payload;
    type ResBody: Payload;
    type Error: Into<Box<dyn StdError + Send + Sync>>;
    type Future: Future<Item = Response<Self::ResBody>, Error = Self::Error>;
    fn call(&mut self, req: Request<Self::ReqBody>) -> Self::Future;
}

Service特质代表一种处理来自任何客户端发送的 HTTP 请求并返回Response(一个 future)的类型。该特质的核心 API,类型需要实现的是call方法,它接受一个参数化泛型类型BodyRequest,并返回一个解析为参数化关联类型ResBodyResponseFuture。我们不需要手动实现这个特质,因为 Hyper 包含一系列可以为你实现Service特质的工厂方法。你只需要提供一个函数,该函数接受 HTTP 请求并返回响应。

在下一节中,我们将探讨 hyper 的客户端和服务器 API。让我们从从头开始构建 URL 缩短器来探索服务器 API。

Hyper 服务器 API——构建 URL 缩短器

在本节中,我们将构建一个 URL 缩短服务器,该服务器公开一个/shorten端点。此端点接受一个包含要缩短的 URL 的POST请求。让我们通过运行cargo new hyperurl并使用以下依赖项在Cargo.toml中启动一个新的项目:

# hyperurl/Cargo.toml

[dependencies]
hyper = "0.12.17"
serde_json = "1.0.33"
futures = "0.1.25"
lazy_static = "1.2.0"
rust-crypto = "0.2.36"
log = "0.4"
pretty_env_logger = "0.3"

我们将命名我们的 URL 缩短服务器为hyperurl。URL 缩短服务是一种提供为任何给定 URL 创建更短 URL 的功能的服务。当你有一个非常长的 URL 时,与某人分享它变得很麻烦。今天存在许多 URL 缩短服务,例如bit.ly。如果你使用过 Twitter,用户在推文中经常使用短 URL,以节省空间。

这是我们的main.rs中的初始实现:

// hyperurl/src/main.rs

use log::{info, error};
use std::env;

use hyper::Server;
use hyper::service::service_fn;

use hyper::rt::{self, Future};

mod shortener;
mod service;
use crate::service::url_service;

fn main() {
    env::set_var("RUST_LOG","hyperurl=info");
    pretty_env_logger::init();

    let addr = "127.0.0.1:3002".parse().unwrap();
    let server = Server::bind(&addr)
        .serve(|| service_fn(url_service))
        .map_err(|e| error!("server error: {}", e));
    info!("URL shortener listening on http://{}", addr);
    rt::run(server);
}

main中,我们创建了一个Server实例,并将其绑定到我们的回环地址和端口号字符串"127.0.0.1:3002"。这返回了一个 builder 实例,我们在其中调用serve,然后传递实现Service特质的函数url_service。函数url_serviceRequest映射到Response的 future。service_fn是一个具有以下签名的工厂函数:

pub fn service_fn<F, R, S>(f: F) -> ServiceFn<F, R> where
    F: Fn(Request<R>) -> S,
    S: IntoFuture,

如您所见,F 需要是一个 Fn 闭包,

我们的url_service函数实现了Service特质。接下来,让我们看看service.rs中的代码:

// hyperurl/src/service.rs

use std::sync::RwLock;
use std::collections::HashMap;
use std::sync::{Arc};
use std::str;
use hyper::Request;
use hyper::{Body, Response};
use hyper::rt::{Future, Stream};

use lazy_static::lazy_static;

use crate::shortener::shorten_url;

type UrlDb = Arc<RwLock<HashMap<String, String>>>;
type BoxFut = Box<Future<Item = Response<Body>, Error = hyper::Error> + Send>;

lazy_static! {
    static ref SHORT_URLS: UrlDb = Arc::new(RwLock::new(HashMap::new()));
}

pub(crate) fn url_service(req: Request<Body>) -> BoxFut {
    let reply = req.into_body().concat2().map(move |chunk| {
        let c = chunk.iter().cloned().collect::<Vec<u8>>();
        let url_to_shorten = str::from_utf8(&c).unwrap();
        let shortened_url = shorten_url(url_to_shorten);
        SHORT_URLS.write().unwrap().insert(shortened_url, url_to_shorten.to_string());
        let a = &*SHORT_URLS.read().unwrap();
        Response::new(Body::from(format!("{:#?}", a)))
    });

    Box::new(reply)
}

此模块公开一个名为url_service的单个函数,它实现了Service特质。我们的url_service方法通过接受一个Request<Body>类型的 req 并返回一个位于Box之后的 future 来实现call方法。

接下来是我们的shortener模块:

// hyperurl/src/shortener.rs

use crypto::digest::Digest;
use crypto::sha2::Sha256;

pub(crate) fn shorten_url(url: &str) -> String {
    let mut sha = Sha256::new();
    sha.input_str(url);
    let mut s = sha.result_str();
    s.truncate(5);
    format!("https://u.rl/{}", s)
}

我们的shorten_url函数接受一个要缩短的 URL 作为&str。然后它计算 URL 的 SHA-256 哈希值并将其截断为长度为五的字符串。这显然不是真正的 URL 缩短器的工作方式,也不是一个可扩展的解决方案。然而,对于我们的演示目的来说,这已经足够了。

让我们试一试:

我们的服务器正在运行。在这个时候,我们可以通过 curl 发送 POST 请求。我们将通过构建一个用于向此服务器发送 URL 以缩短的命令行客户端来实现这一点。

虽然 Hyper 推荐用于复杂的 HTTP 应用程序,但每次创建处理程序服务、注册它并在运行时中运行它都相当繁琐。通常,为了构建需要执行几个GET请求的小型工具,如 CLI 应用程序,这会显得有些过度。幸运的是,我们有一个名为reqwest的 hyper 的包装器,它具有自己的观点。正如其名所示,它受到了 Python 的 Requests 库的启发。我们将使用它来构建我们的 hyperurl 客户端,该客户端发送 URL 缩短请求。

hyper 作为客户端 – 构建 URL 缩短客户端

现在我们已经准备好了 URL 缩短服务,让我们探索 hyper 的客户端。尽管我们可以构建一个用于缩短 URL 的 Web UI,但我们将保持简单,并构建一个 命令行界面 (CLI) 工具。CLI 可以用来传递任何需要缩短的 URL。作为回应,我们将从我们的 hyperurl 服务器获得缩短后的 URL。

虽然 hyper 推荐用于构建复杂网络应用程序,但每次需要创建处理程序服务、注册它并在运行时实例中运行它时,都需要进行大量设置。当构建较小的工具,如需要执行几个 GET 请求的 CLI 应用程序时,所有这些步骤都显得过于繁琐。幸运的是,我们有一个方便的 hyper 包装 crate,名为 reqwest,它抽象了 hyper 的客户端 API。正如其名所示,它受到了 Python 的 Requests 库的启发。

让我们通过在 Cargo.toml 文件中添加以下依赖项来运行 cargo new shorten 创建一个新的项目:

# shorten/Cargo.toml

[dependencies]
quicli = "0.4"
structopt = "0.2"
reqwest = "0.9"
serde = "1"

为了构建 CLI 工具,我们将使用 quicli 框架,这是一个高质量 crate 的集合,有助于构建 CLI 工具。structopt crate 与 quicli 一起使用,而 serde crate 则用于 structopt crate 的 derive 宏。为了向我们的 hyperurl 服务器发送 POST 请求,我们将使用 reqwest crate。

我们的 main.rs 文件内部有以下代码:

// shorten/src/main.rs

use quicli::prelude::*;
use structopt::StructOpt;

const CONN_ADDR: &str = "127.0.0.1:3002";

/// This is a small CLI tool to shorten urls using the hyperurl
/// url shortening service
#[derive(Debug, StructOpt)]
struct Cli {
    /// The url to shorten
    #[structopt(long = "url", short = "u")]
    url: String,
    // Setting logging for this CLI tool
    #[structopt(flatten)]
    verbosity: Verbosity,
}

fn main() -> CliResult {
    let args = Cli::from_args();
    println!("Shortening: {}", args.url);
    let client = reqwest::Client::new();
    let mut res = client
        .post(&format!("http://{}/shorten", CONN_ADDR))
        .body(args.url)
        .send()?;
    let a: String = res.text().unwrap();
    println!("http://{}", a);
    Ok(())
}

在我们的 hyperurl 服务器仍然运行的情况下,我们将打开一个新的终端窗口,并使用 cargo run -- --url https://rust-lang.org 调用 shorten:

图片

让我们转到浏览器,使用缩短后的 URL,即 http://127.0.0.1:3002/abf27

图片

在探索了 hyper 之后,让我们提高一点层次。在下一节中,我们将探索基于 actix 包中 actor 模型实现的快速网络应用程序框架 Actix-web。但是,首先让我们谈谈为什么我们需要网络框架。

网络框架

在我们开始探索 actix-web 之前,我们需要了解一些动机,即为什么我们最初需要网络框架。正如我们许多人所知,网络是一个复杂且不断发展的空间。在编写网络应用程序时,有许多细节需要处理。你需要设置路由规则和认证策略。除此之外,随着应用程序的发展,还有一些最佳实践和类似的模式,如果你不使用网络框架,你将不得不重复实现。

每次自己构建网络应用程序时,都需要重新发明这些网络应用程序的基础属性,这相当繁琐。一个具体的例子是,当你在应用程序中提供不同的路由时。在一个从头开始构建的网络应用程序中,你必须从请求中解析资源路径,对其进行一些匹配,并对请求采取行动。网络框架通过提供 DSLs 自动化路由和路由处理程序的匹配,允许你以更干净的方式配置路由规则。网络框架还抽象了围绕构建网络应用程序的所有最佳实践、常见模式和惯例,为开发者提供一个先发优势,使他们能够专注于业务逻辑,而不是重新发明已经解决的问题的解决方案。

Rust 社区最近出现了很多正在开发中的网络框架,如 Tower、Tide、Rocket、actix-web、Gotham 等。在撰写本书时,功能最丰富且最活跃的框架是 Rocket 和 actix-web。虽然 Rocket 非常简洁且是一个完善的框架,但它需要 Rust 编译器的 nightly 版本。不过,随着 Rocket 所依赖的 API 的稳定,这个限制很快就会被移除。目前它的直接竞争对手是 actix-web,它运行在稳定的 Rust 上,并且与 Rocket 框架提供的用户体验非常接近。接下来我们将介绍 actix-web

Actix-web 基础

Actix-web 框架建立在 actix crate 实现的 actor 模型之上,我们已经在第七章“高级概念”中介绍过。Actix-web 自称是一个小巧、快速且实用的 HTTP 网络框架。它主要是一个异步框架,内部依赖于 tokio 和 futures crate,同时也提供了同步 API,并且这两个 API 可以无缝组合。

使用 actix-web 编写的任何网络应用程序的入口点是 App 结构体。在 App 实例上,我们可以配置各种路由处理程序和中间件。我们还可以使用任何需要跨请求/响应维护的状态初始化我们的 AppApp 上提供的路由处理程序实现了 Handler 特性,它们只是将请求映射到响应的函数。它们还可以包括请求过滤器,根据谓词禁止对特定路由的访问。

Actix-web 内部会启动多个工作线程,每个线程都有自己的 tokio 运行时。

基本概念介绍完毕,现在让我们直接进入主题,通过 Actix-web 实现一个 REST API 服务器。

使用 Actix-web 构建 Bookmarks API

我们将创建一个 REST API 服务器,允许您存储您希望稍后阅读的任何博客或网站的收藏夹和链接。我们将我们的服务器命名为 linksnap 让我们通过运行 cargo new linksnap 创建一个新的项目。在这个实现中,我们不会为发送到我们的 API 的任何链接使用数据库进行持久化,而将简单地使用内存中的 HashMap 来存储我们的条目。这意味着每次我们的服务器重启时,所有存储的收藏夹都将被删除。在 第十四章,在 Rust 中与数据库交互,我们将集成数据库与 linksnap,这将允许我们持久化收藏夹。

linksnap/ 目录下,Cargo.toml 中有以下内容:

# linksnap/Cargo.toml

[dependencies]
actix = "0.7"
actix-web = "0.7"
futures = "0.1"
env_logger = "0.5"
bytes = "0.4"
serde = "1.0.80"
serde_json = "1.0.33"
serde_derive = "1.0.80"
url = "1.7.2"
log = "0.4.6"
chrono = "0.4.6"

我们将在我们的 API 服务器中实现以下端点:

  • /links 是一个 GET 方法,用于检索服务器上存储的所有链接列表。

  • /add 是一个 POST 方法,用于存储链接条目并返回一个类型为 LinkId 的响应。这可以用来从服务器上删除链接。

  • /rm 是一个 DELETE 方法,用于删除具有给定 LinkId 的链接。

我们将我们的服务器实现分为三个模块:

  • links:此模块提供了 LinksLink 类型,分别代表链接集合和单个链接。

  • route_handlers:此模块包含我们所有的路由处理器。

  • state:此模块包含了一个 actor 的实现以及它可以在我们的 Db 结构体上接收的所有消息。

从用户请求到 actor 的示例流程在 /links 端点如下:

图片

让我们通过查看 main.rs 中的内容来了解实现:

// linksnap/src/main.rs

mod links;
mod route_handlers;
mod state;

use std::env;
use log::info;
use crate::state::State;
use crate::route_handlers::{index, links, add_link, rm_link};
use actix_web::middleware::Logger;
use actix_web::{http, server, App};

fn init_env() {
    env::set_var("RUST_LOG", "linksnap=info");
    env::set_var("RUST_BACKTRACE", "1");
    env_logger::init();
    info!("Starting http server: 127.0.0.1:8080");
}

fn main() {
    init_env();
    let system = actix::System::new("linksnap");
    let state = State::init();

    let web_app = move || {
        App::with_state(state.clone())
            .middleware(Logger::default())
            .route("/", http::Method::GET, index)
            .route("/links", http::Method::GET, links)
            .route("/add", http::Method::POST, add_link)
            .route("/rm", http::Method::DELETE, rm_link)
    };

    server::new(web_app).bind("127.0.0.1:8080").unwrap().start();
    let _ = system.run();
}

main 中,我们首先调用 init_env,这为我们设置从服务器获取日志的环境,打开 RUST_BACKTRACE 变量以打印任何错误的详细跟踪,并通过调用 env_logger::init() 初始化我们的日志记录器。然后我们创建我们的系统 actor,它是 actor 模型中所有 actor 的父 actor。然后我们通过调用 State::init() 创建我们的服务器状态并将其存储在 state 中。这将在 state.rs 中封装我们的内存数据库 actor 类型 Db。我们稍后会详细介绍。

然后,我们通过调用 App::with_state 在闭包中创建我们的 App 实例,从而传递我们的应用程序 state 的克隆。这里的 clone 调用很重要,因为我们需要在多个 actix 工作线程之间共享单个共享状态。Actix-web 内部使用新的 App 实例启动多个线程来处理请求,并且每次调用此状态都将有自己的应用程序状态副本。如果我们不共享单个真相来源的引用,那么每个 App 都将有自己的 HashMap 条目副本,这是我们不想看到的。

然后,我们通过传递一个 Logger 来使用 middleware 方法将我们的 App 链接起来。这样,当客户端击中我们配置的任何一个端点时,都会记录任何请求。然后我们添加了一堆 route 方法调用。route 方法接受一个作为字符串的 HTTP 路径,一个 HTTP 方法,以及一个将 HttpRequest 映射到 HttpResponsehandler 函数。我们稍后会探讨 handler 函数。

在配置并存储 web_app 实例后,我们将其传递给 server::new(),然后将其绑定到地址字符串 "127.0.0.1:8080"。然后我们调用 start 来在一个新的 Arbiter 实例中启动应用,这只是一个新的线程。根据 actix,Arbiter 是运行 actors 并可以访问事件循环的线程。最后,我们通过调用 system.run() 来运行我们的系统 actor。run 方法内部启动一个 tokio 运行时并启动所有 arbiter 线程。

接下来,让我们看看 route_handlers.rs 中的路由处理器。此模块定义了我们服务器实现中可用的所有类型的路由:

// linksnap/src/route_handlers.rs

use actix_web::{Error, HttpRequest, HttpResponse};

use crate::state::{AddLink, GetLinks, RmLink};
use crate::State;
use actix_web::AsyncResponder;
use actix_web::FromRequest;
use actix_web::HttpMessage;
use actix_web::Query;
use futures::Future;

type ResponseFuture = Box<Future<Item = HttpResponse, Error = Error>>;

macro_rules! server_err {
    ($msg:expr) => {
        Err(actix_web::error::ErrorInternalServerError($msg))
    };
}

首先,我们有一堆导入,然后定义了几个辅助类型。ResponseFuture 是一个方便的类型别名,用于一个解析为 HttpResponse 的装箱 Future。然后我们有一个名为 server_err! 的辅助宏,它返回一个带有给定描述的 actix_web::error 类型。我们使用这个宏作为在客户端请求处理失败时返回错误的方便方式。

接下来,我们拥有处理 / 端点 get 请求的最简单路由处理器:

linksnap/src/route_handlers.rs

pub fn index(_req: HttpRequest<State>) -> HttpResponse {
    HttpResponse::from("Welcome to Linksnap API server")
}

index 函数接受一个 HttpRequest 并简单地返回一个由字符串构造的 HttpResponseHttpRequest 类型可以参数化任何类型。默认情况下,它是一个 ()。对于我们的路由处理器,我们将其参数化为我们的 State 类型。这个 State 封装了我们的内存数据库,该数据库作为 actor 实现。StateAddr<Db> 的包装器,它是我们的 Db actor 的地址。

这是一个对我们内存数据库的引用。我们将使用它来向我们的内存数据库发送消息以插入、删除或获取链接。我们稍后会探讨这些 API。让我们看看同一文件中的其他处理器:

// linksnap/src/route_handlers.rs

pub fn add_link(req: HttpRequest<State>) -> ResponseFuture {
    req.json()
        .from_err()
        .and_then(move |link: AddLink| {
            let state = req.state().get();
            state.send(link).from_err().and_then(|e| match e {
                Ok(_) => Ok(HttpResponse::Ok().finish()),
                Err(_) => server_err!("Failed to add link"),
            })
        })
        .responder()
}

我们的 add_link 函数处理添加链接的 POST 请求。此处理器期望一个具有以下格式的 JSON 主体:

{
    title: "Title of the link or bookmark",
    url: "The URL of the link"
}

在这个函数中,我们首先通过调用req.json()获取请求体作为 JSON。这返回一个 future。然后我们使用from_err方法将来自 json 方法的任何错误映射到与 actix 兼容的错误。json方法可以从请求的有效负载中提取类型化信息,因此返回一个JsonBody<T> future。这个T由下一个方法链and_then推断为AddLink,我们将解析的值发送到我们的Db演员。向我们的演员发送消息可能会失败,所以如果发生这种情况,我们再次匹配返回的值。在Ok的情况下,我们回复一个空的 HTTP 成功响应,否则我们使用server_err!宏传递错误描述来失败。

接下来,我们有一个"/links"端点:

// linksnap/src/route_handlers.rs

pub fn links(req: HttpRequest<State>) -> ResponseFuture {
    let state = &req.state().get();
    state
        .send(GetLinks)
        .from_err()
        .and_then(|res| match res {
            Ok(res) => Ok(HttpResponse::Ok().body(res)),
            Err(_) => server_err!("Failed to retrieve links"),
        })
        .responder()
}

links处理程序简单地发送一个GetLinks消息到Db演员,并在使用body方法将其发送回客户端之前返回接收到的响应。然后我们有我们的rm_link处理程序,其定义如下:

// linksnap/src/route_handlers.rs

pub fn rm_link(req: HttpRequest<State>) -> ResponseFuture {
    let params: Query<RmLink> = Query::extract(&req).unwrap();
    let state = &req.state().get();
    state
        .send(RmLink { id: params.id })
        .from_err()
        .and_then(|e| match e {
            Ok(e) => Ok(HttpResponse::Ok().body(format!("{}", e))),
            Err(_) => server_err!("Failed to remove link"),
        })
        .responder()
}

要删除一个链接,我们需要将链接 ID(一个i32)作为查询参数传递。rm_link方法使用方便的Query::extract方法将查询参数提取到RmLink类型中,该方法接受一个HttpRequest实例。接下来,我们获取对Db演员的引用,并向其发送一个带有 ID 的RmLink消息。我们通过使用HttpRespnsebody方法构造返回的字符串来返回回复。

这里是我们的StateDb类型在state.rs中的定义:

// linksnap/src/state.rs

use actix::Actor;
use actix::SyncContext;
use actix::Message;
use actix::Handler;
use actix_web::{error, Error};
use std::sync::{Arc, Mutex};
use crate::links::Links;
use actix::Addr;
use serde_derive::{Serialize, Deserialize};
use actix::SyncArbiter;

const DB_THREADS: usize = 3;

#[derive(Clone)]
pub struct Db {
    pub inner: Arc<Mutex<Links>>
}

impl Db {
    pub fn new(s: Arc<Mutex<Links>>) -> Db {
        Db { inner: s }
    }
}

impl Actor for Db {
    type Context = SyncContext<Self>;
}

#[derive(Clone)]
pub struct State {
    pub inner: Addr<Db>
}

impl State {
    pub fn init() -> Self {
        let state = Arc::new(Mutex::new(Links::new()));
        let state = SyncArbiter::start(DB_THREADS, move || Db::new(state.clone()));
        let state = State {
            inner: state
        };
        state
    }

    pub fn get(&self) -> &Addr<Db> {
        &self.inner
    }
}

首先,我们将DB_THREADS设置为3这个值,这是我们任意选择的。我们将通过一个线程池来向内存数据库发送请求。在这种情况下,我们也可以使用一个普通的演员,但由于我们将在第十四章“使用 Rust 与数据库交互”中将其与数据库集成,我们选择了SyncArbiter线程。

接下来,我们有一个Db结构体定义,它将Links类型封装在一个线程安全的包装器Arc<Mutex<Links>>中。然后我们在其上实现Actor特质,其中我们指定关联类型ContextSyncContext<Self>

然后,我们有一个State结构体定义,它是一个Addr<Db>,即对Db演员实例的句柄。我们还在State上定义了两个方法 - init用于创建一个新的State实例,get用于返回对Db演员句柄的引用。

接下来,我们有一系列将要发送到我们的Db演员的消息类型。我们的Db是一个演员,将接收三个消息:

GetLinks:这是由/links路由处理程序发送的,用于检索服务器上存储的所有链接。它定义如下:

// linksnap/src/state.rs

pub struct GetLinks;

impl Message for GetLinks {
    type Result = Result<String, Error>;
}

impl Handler<GetLinks> for Db {
    type Result = Result<String, Error>;
    fn handle(&mut self, _new_link: GetLinks, _: &mut Self::Context) -> Self::Result {
        Ok(self.inner.lock().unwrap().links())
    }
}

首先是 GetLinks 消息,它是由 /links 路由处理器发送到 Db actor 的。为了使这成为一个 actor 消息,我们将为它实现 Message 特性。Message 特性定义了一个关联类型 Result,这是消息处理器的返回类型。接下来,我们为 Db actor 实现了参数化于消息 GetLinksHandler 特性。

// linksnap/src/state.rs

pub struct GetLinks;

impl Message for GetLinks {
    type Result = Result<String, Error>;
}

impl Handler<GetLinks> for Db {
    type Result = Result<String, Error>;
    fn handle(&mut self, _new_link: GetLinks, _: &mut Self::Context) -> Self::Result {
        Ok(self.inner.lock().unwrap().links())
    }
}

我们为它实现了 Message 特性,它返回所有链接的字符串作为响应。

AddLink:这是由 /add 路由处理器在客户端发送的任何新链接时发送的。它定义如下:

// linksnap/src/state.rs

#[derive(Debug, Serialize, Deserialize)]
pub struct AddLink {
    pub title: String,
    pub url: String
}

impl Message for AddLink {
    type Result = Result<(), Error>;
}

impl Handler<AddLink> for Db {
    type Result = Result<(), Error>;

    fn handle(&mut self, new_link: AddLink, _: &mut Self::Context) -> Self::Result {
        let mut db_ref = self.inner.lock().unwrap();
        db_ref.add_link(new_link);
        Ok(())
    }
}

AddLink 类型具有双重功能。当实现了 SerializeDeserialize 特性时,它作为一个可以从 add_link 路由的入站 json 响应体中提取的类型。其次,它还实现了 Message 特性,我们可以将其发送到我们的 Db actor。

RmLink:这是由 /rm 路由处理器发送的。它定义如下:

// linksnap/src/state.rs

#[derive(Serialize, Deserialize)]
pub struct RmLink {
    pub id: LinkId,
}

impl Message for RmLink {
    type Result = Result<usize, Error>;
}

impl Handler<RmLink> for Db {
    type Result = Result<usize, Error>;
    fn handle(&mut self, link: RmLink, _: &mut Self::Context) -> Self::Result {
        let db_ref = self.get_conn()?;
        Link::rm_link(link.id, db_ref.deref())
            .map_err(|_| error::ErrorInternalServerError("Failed to remove links"))
    }
}

这是当想要删除链接条目时发送的消息。它接收 RmLink 消息并将其转发

我们可以使用以下 curl 命令插入一个链接:

curl --header "Content-Type: application/json" \
 --request POST \ 
 --data '{"title":"rust blog","url":"https://rust-lang.org"}' \
 127.0.0.1:8080/add

要查看插入的链接,我们可以发出:

curl 127.0.0.1:8080/links

要删除一个链接,给定其 Id,我们可以使用 curl 发送一个 DELETE 请求,如下所示:

curl -X DELETE 127.0.0.1:8080/rm?id=1

摘要

在本章中,我们探讨了使用 Rust 构建 Web 应用程序的很多内容,以及鉴于我们可用的优质 crate,开始使用是多么容易。作为一个编译型语言,用 Rust 编写的 Web 应用程序通常比用动态语言编写的其他框架小得多。大多数 Web 框架空间由可以占用大量 CPU 但资源效率不高的解释型动态语言主导。然而,人们使用它们,因为使用它们编写 Web 应用程序非常方便。

用 Rust 编写的 Web 应用程序在运行时占用的空间要小得多。Rust 在运行时也占用更少的内存,因为不需要解释器,这与动态语言的情况一样。使用 Rust,你可以同时获得动态语言的感受和 C 语言的性能。这对 Web 来说是一笔大交易。

在下一章中,我们将探讨 Rust 如何与数据库通信,并通过使用名为 diesel 的类型安全 对象关系映射器 (ORM) 库来为我们的 read_list 服务器添加数据持久性。

第十四章:使用 Rust 与数据库交互

在本章中,我们将讨论为什么数据库对现代应用程序至关重要。我们将介绍 Rust 生态系统中的几个 crate,这些 crate 允许与数据库交互。然后,我们将继续讨论我们在上一章中开发的 linksnap API 服务器,并通过一个方便的库将其数据库支持集成进去。这将使我们能够持久化发送到我们 API 的新书签。

在本章中,我们将涵盖以下主题:

  • 使用 rusqlite 进行 SQLite 集成

  • 使用 Rust 与 PostgreSQL 交互

  • 数据库连接池

  • 使用 diesel crate 进行对象关系映射

  • 将 diesel 与 linksnap API 服务器集成

我们为什么需要数据持久化?

“有时,优雅的实现仅仅是一个函数。不是一个方法。不是一个类。不是一个框架。仅仅是一个函数。” —— 约翰·卡马克

现代应用程序通常数据密集。正如许多人所说,数据是新石油。数据库支持的服务无处不在,从社交游戏到云存储,再到电子商务,以及医疗保健等等。所有这些服务都需要正确地存储和检索数据。它们存储的数据必须易于检索,并且必须保证一致性和持久性。

数据库是构建基于数据的应用程序并提供用户期望的强大基础的解决方案。在我们用 Rust 构建涉及数据库的任何东西之前,我们需要了解一些基础知识。一般来说,数据库是一组表。表是数据组织的单元。

组织到表中的数据仅适用于关系数据库。其他数据库,如 NoSQL 和基于图的数据库,使用更灵活的文档模型来存储和组织数据。

数据被组织成逻辑实体,称为表。表通常代表现实世界中的实体。这些实体可以具有各种属性,这些属性在表中取代了列的位置。这些实体还可以与其他实体有联系。一个表中的列可以引用另一个表中的列。对任何数据库的更改都通过一个特定的 DSL(领域特定语言)执行,称为结构化查询语言(SQL)。SQL 查询还允许您使用查询子句(如 JOIN)将查询扩展到多个表。这些都是基础知识。用户与数据库支持的应用程序交互的常见模式是CRUD模式,即创建、读取、更新和删除。这些是用户在应用程序中对数据库执行的最常见操作。

SQL 是一种声明式的方法,用于在数据库上执行事务。事务是一组对数据库的修改,这些修改必须原子性地发生,或者在中间出现任何故障时根本不发生。在任何应用程序中编写数据库事务的直观方法是使用原始 SQL 查询。然而,有一种更好的方法来做这件事,它被称为对象关系映射(ORM)。这是一种使用本地语言抽象和类型访问数据库的技术,这些类型几乎与 SQL 语法和语义一一对应。语言提供了用于与 SQL 对话的高级库,允许你用它们的本地语言编写查询,然后这些查询被翻译成原始 SQL 查询。在传统的面向对象语言中,你的对象变成了会说话的 SQL 对象。这些库被称为对象关系映射器。许多这些库存在于主流语言中,如 Java 的 Hibernate、Ruby 的 Active Record、Python 的 SQLAlchemy 等。使用 ORM 可以减少在使用原始 SQL 查询时出现任何错误的可能性。然而,ORM 也受到无法将自身完全映射到语言的对象模型和数据库模型的限制。因此,ORM 库应尽量减少它们在交互数据库时提供的抽象量,并将一些部分留给原始 SQL 查询。

Rust 生态系统提供了许多高质量解决方案来管理和构建持久化应用程序。我们将在下一节中探讨其中的一些。

SQLite

SQLite 是一个非常轻量级的嵌入式数据库。它不需要特殊的数据库管理系统,你就可以使用它。SQLite 创建的数据库可以表示为文件或内存中的数据库,你不需要连接到外部远程端点或本地套接字连接来使用数据库。它服务于与传统客户端-服务器数据库引擎(如 MySQL 或 PostgreSQL)不同的目标受众,并且是应用需要本地存储数据但又要安全且高效检索的用例的首选解决方案。Android 平台是 SQLite 的重度使用者,允许移动应用程序在应用程序内存储用户的偏好或配置。它也被许多需要存储任何类型状态并保证持久性的桌面应用程序所使用。

Rust 社区为我们提供了连接和与 SQLite 数据库交互的几种选择。我们将选择 rusqlite crate,该 crate 可在 crates.io 上找到,网址为 crates.io/crates/rusqlite。此 crate 支持 SQLite 版本 3.6.8 及以上。其 API 不能被视为 ORM,但可以被视为 ORM 提供的中间层抽象,因为它有助于隐藏实际 SQLite API 的许多细节。与许多其他关系型数据库系统相比,SQLite 的类型系统是动态的。这意味着列没有类型,但每个单独的值都有类型。技术上,SQLite 将存储类和数据类型分开,但这主要是实现细节,我们可以简单地从类型的角度思考,而不会离真相太远。

rusqlite crate 提供了 FromSqlToSql 特性,用于在 SQLite 和 Rust 类型之间转换对象。它还提供了以下开箱即用的实现,用于大多数标准库类型和原语:

描述 SQLite Rust
空值 NULL rusqlite::types::Null
1、2、3、4、6 或 8 字节有符号整数 INTEGER i32(可能截断)和 i64
8 字节 IEEE 浮点数 REAL f64
UTF-8、UTF-16BE 或 UTF-16LE 字符串 TEXT String&str
字节字符串 BLOB Vec<u8>&[u8]

在掌握了 rusqlite crate 的基础知识之后,让我们看看它是如何应用的。

我们将通过运行 cargo new rusqlite_demo 来创建一个新的项目。我们的程序从标准输入接收一个格式正确的逗号分隔值(CSV)格式的书籍列表,将其存储在 SQLite 中,然后使用 SQL 查询的过滤器检索数据子集。首先,让我们创建我们的表创建和删除查询以及我们的 Book 结构体,它将存储从查询中检索到的数据:

// rusqlite_demo/src/main.rs

use std::io;
use std::io::BufRead;

use rusqlite::Error;
use rusqlite::{Connection, NO_PARAMS};

const CREATE_TABLE: &str = "CREATE TABLE books 
                            (id INTEGER PRIMARY KEY,
                            title TEXT NOT NULL,
                            author TEXT NOT NULL,
                            year INTEGER NOT NULL)";

const DROP_TABLE: &str = "DROP TABLE IF EXISTS books";

#[derive(Debug)]
struct Book {
    id: u32,
    title: String,
    author: String,
    year: u16
}

我们定义了两个常量,CREATE_TABLEDROP_TABLE,分别包含创建 books 表和删除它的原始 SQL 查询。然后我们有书籍结构体,它包含以下字段:

  • id: 它作为主键使用,可以在将书籍插入我们的书籍表中时区分不同的书籍

  • title: 书籍的标题

  • author: 该书的作者

  • year: 出版年份

接下来,让我们看看我们的 main 函数:

// rusqlite_demo/src/main.rs

fn main() {
    let conn = Connection::open("./books").unwrap();
    init_database(&conn);
    insert(&conn);
    query(&conn);
}

首先,我们通过调用 Connection::open 并提供一个路径,"./books",来创建当前目录中的数据库,从而打开我们的 SQLite 数据库连接。接下来,我们调用 init_database(),传递对 conn 的引用,它如下定义:

fn init_database(conn: &Connection) {
    conn.execute(CREATE_TABLE, NO_PARAMS).unwrap();
}

然后,我们调用 insert 方法,传递我们的 conn。最后,我们调用 query 方法,查询我们的 books 数据库。

这是我们的 insert 函数方法:

fn insert(conn: &Connection) {
    let stdin = io::stdin();
    let lines = stdin.lock().lines();
    for line in lines {
        let elems = line.unwrap();
        let elems: Vec<&str> = elems.split(",").collect();
        if elems.len() == 4 {
            let _ = conn.execute(
                "INSERT INTO books (id, title, author, year) VALUES (?1, ?2, ?3, ?4)",
                &[&elems[0], &elems[1], &elems[2], &elems[3]],
            );
        }
    }
}

insert中,我们首先锁定stdout,然后遍历行。每行用逗号分隔。随后,我们在conn上调用execute方法,传入一个插入查询字符串。在查询字符串中,我们使用模板变量?1?2等,其对应的值是从elems向量中取出的。如果收集到的元素数量达到4,我们使用原始 SQL 查询插入书籍,并为模板变量提供elems Vec中的相应值。

接下来,我们的query函数定义如下:

fn query(conn: &Connection) {
    let mut stmt = conn
        .prepare("SELECT id, title, author, year FROM books WHERE year >= ?1")
        .unwrap();
    let movie_iter = stmt
        .query_map(&[&2013], |row| Book {
            id: row.get(0),
            title: row.get(1),
            author: row.get(2),
            year: row.get(3),
        })
        .unwrap();

    for movie in movie_iter.filter_map(extract_ok) {
        println!("Found book {:?}", movie);
    }
}

查询函数接收conn参数,我们在其上调用prepare方法,传入原始的 SQL 查询字符串。在这里,我们正在过滤那些出版年份大于给定年份的书籍。我们将这个查询存储在stmt中。接下来,我们在这个类型上调用query_map,传入一个只包含数字2013的数组引用,这个数字代表我们要过滤书籍的年份。正如你所见,这里的 API 有点不自然。query_map的第二个参数是一个闭包,它是一个Row类型。在闭包内部,我们从row实例中提取相应的字段,并从中构建一个Book实例。这返回一个迭代器,我们将其存储在movie_iter中。最后,我们遍历movie_iter,使用extract_ok辅助方法过滤任何失败的值。这个方法定义如下:

fn extract_ok(p: Result<Book, Error>) -> Option<Book> {
    if p.is_ok() {
        Some(p.unwrap())
    } else {
        None
    }
}

然后,我们打印书籍。完整的代码如下:

// rusqlite_demo/src/main.rs

use std::io;
use std::io::BufRead;

use rusqlite::Error;
use rusqlite::{Connection, NO_PARAMS};

const CREATE_TABLE: &str = "CREATE TABLE IF NOT EXISTS books 
                            (id INTEGER PRIMARY KEY,
                            title TEXT NOT NULL,
                            author TEXT NOT NULL,
                            year INTEGER NOT NULL)";

#[derive(Debug)]
struct Book {
    id: u32,
    title: String,
    author: String,
    year: u16,
}

fn extract_ok(p: Result<Book, Error>) -> Option<Book> {
    if p.is_ok() {
        Some(p.unwrap())
    } else {
        None
    }
}

fn insert(conn: &Connection) {
    let stdin = io::stdin();
    let lines = stdin.lock().lines();
    for line in lines {
        let elems = line.unwrap();
        let elems: Vec<&str> = elems.split(",").collect();
        if elems.len() > 2 {
            let _ = conn.execute(
                "INSERT INTO books (id, title, author, year) VALUES (?1, ?2, ?3, ?4)",
                &[&elems[0], &elems[1], &elems[2], &elems[3]],
            );
        }
    }
}

fn init_database(conn: &Connection) {
    conn.execute(CREATE_TABLE, NO_PARAMS).unwrap();
}

fn query(conn: &Connection) {
    let mut stmt = conn
        .prepare("SELECT id, title, author, year FROM books WHERE year >= ?1")
        .unwrap();
    let movie_iter = stmt
        .query_map(&[&2013], |row| Book {
            id: row.get(0),
            title: row.get(1),
            author: row.get(2),
            year: row.get(3),
        })
        .unwrap();

    for movie in movie_iter.filter_map(extract_ok) {
        println!("Found book {:?}", movie);
    }
}

fn main() {
    let conn = Connection::open("./books").unwrap();
    init_database(&conn);
    insert(&conn);
    query(&conn);
}

我们在同一目录下还有一个 books.csv 文件。我们可以通过以下命令运行它:

cargo run < books.csv

下面是程序运行后的输出:

这远非一个代表性的真实世界数据库支持的应用程序,它只是为了演示库的使用。一个真实世界的应用程序不会从标准输入读取,查询例程会有更好的错误处理。

这只是一个简要的演示,说明如何使用rusqlitecrate 通过 Rust 使用 SQLite 数据库。API 不是非常强类型,但这是我们目前唯一的选择。接下来,我们将看看 SQLite 的“大哥”——PostgreSQL 数据库管理系统。

PostgreSQL

虽然 SQLite 适合原型设计和简单的用例,但一个真正的关系型数据库管理系统可以使开发者的生活更加轻松。这样一个复杂的数据库系统是 PostgreSQL。要在 Rust 中集成 PostgreSQL,我们可以在crates.io上找到postgrescrate。它是一个本地的 Rust 客户端,这意味着它不依赖于 C 库,而是在 Rust 中实现了整个协议。如果 API 看起来与rusqlitecrate 相似,这是故意的;SQLite 客户端的 API 实际上是基于 PostgreSQL 客户端的。postgrescrate 支持一些 PostgreSQL 的独特功能,如位向量、时间字段、JSON 支持和 UUID。

在本节中,我们将通过创建一个初始化 PostgreSQL 数据库并在数据库上进行一些插入和查询的示例程序来探索与 PostgreSQL 的交互。我们假设您已经在系统上设置了数据库。本例中使用的 PostgreSQL 版本是 9.5。

要安装 PostgreSQL 数据库系统,以下 DigitalOcean 文章推荐:www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-16-04

Postgres 自带一个名为psql的命令行工具,可以用来运行查询、检查表、管理角色、查看系统信息等等。您可以通过在 psql 提示符内运行以下命令来查看您系统上运行的 PostgreSQL 版本。首先,我们将通过运行以下命令来启动psql

$ sudo -u postgres psql

一旦我们进入 psql,我们就在提示符下运行以下命令:

postgres=# SELECT version();

运行上述命令,会得到以下输出:

PostgreSQL 9.5.14 on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 5.4.0-6ubuntu1~16.04.10) 5.4.0 20160609, 64-bit

为了使这个例子更简单,我们将重用我们在rusqlite演示中使用的相同的书籍数据。在以下示例中,我们将使用默认的"postgres"用户,密码为"postgres"。您需要将以下示例修改以匹配您的新用户。让我们通过运行cargo new postgres_demo来启动一个新的项目。以下是我们的Cargo.toml中的依赖项:

# postgres_demo/Cargo.toml

[dependencies]
postgres = "0.15.2"
serde = { version = "1.0.82"}
serde_derive = "1.0.82"
serde_json = "1.0.33"

让我们逐行查看main.rs中的代码:

// postgres_demo/src/main.rs

use postgres::{Connection, TlsMode};

const DROP_TABLE: &str = "DROP TABLE IF EXISTS books";
const CONNECTION: &str = "postgres://postgres:postgres@localhost:5432";
const CREATE_TABLE: &str = "CREATE TABLE IF NOT EXISTS books 
                            (id SERIAL PRIMARY KEY,
                            title VARCHAR NOT NULL,
                            author VARCHAR NOT NULL,
                            year SERIAL)";

#[derive(Debug)]
struct Book {
    id: i32,
    title: String,
    author: String,
    year: i32
}

fn reset_db(conn: &Connection) {
    let _ = conn.execute(DROP_TABLE, &[]).unwrap();
    let _ = conn.execute(CREATE_TABLE, &[]).unwrap();
}

我们有一系列用于连接数据库以及创建和删除书籍表的字符串常量。接下来是我们的main函数:

// postgres_demo/src/main.rs

fn main() {
    let conn = Connection::connect(CONNECTION, TlsMode::None).unwrap();
    reset_db(&conn);

    let book = Book {
        id: 3,
        title: "A programmers introduction to mathematics".to_string(),
        author: "Dr. Jeremy Kun".to_string(),
        year: 2018
    };

    conn.execute("INSERT INTO books (id, title, author, year) VALUES ($1, $2, $3, $4)",
                 &[&book.id, &book.title, &book.author, &book.year]).unwrap();

    for row in &conn.query("SELECT id, title, author, year FROM books", &[]).unwrap() {
        let book = Book {
            id: row.get(0),
            title: row.get(1),
            author: row.get(2),
            year: row.get(3)
        };
        println!("{:?}", book);
    }
}

由于我们在这里没有使用 ORM,而是一个低级接口,我们需要手动将值解包到数据库查询中。让我们运行这个程序:

这是程序的输出,以及随后在psql中对表的查询,以显示其内容:

首先,我们在 psql 提示符下使用\dt命令列出我们的数据库。之后,我们使用查询,即"select * from books"

这就是使用 Rust 与 PostgreSQL 交互的基础。接下来,让我们探索如何通过使用连接池的概念来提高我们的数据库查询效率。

使用 r2d2 进行连接池

每次新事务发生时都打开和关闭数据库连接很快就会成为瓶颈。通常,打开数据库连接是一个昂贵的操作。这主要是因为需要在两端的套接字连接上创建所需的 TCP 握手。如果数据库托管在远程服务器上,这通常是情况,开销就更加昂贵。如果我们能够为发送到我们数据库的每个后续请求重用连接,我们可能会大大减少延迟。缓解这种开销的有效方法之一是采用数据库连接池。当进程需要新的连接时,它从连接池中获取现有的连接。当进程完成与数据库的必要操作后,这个连接句柄就会回到池中以便以后使用。

在 Rust 中,我们有r2d2crate,它利用特质提供了一种通用的方式来维护各种数据库的连接池。它提供了各种后端作为子 crate,并支持 PostgreSQL、Redis、MySQL、MongoDB、SQLite 以及一些其他已知的数据库系统。r2d2的架构由两部分组成:一个通用部分和一个特定后端部分。后端代码通过实现 r2d2 的ManageConnection特质,并为特定后端添加连接管理器来附加到通用部分。特质如下:

pub trait ManageConnection: Send + Sync + 'static {
    type Connection: Send + 'static;
    type Error: Error + 'static;
    fn connect(&self) -> Result<Self::Connection, Self::Error>;
    fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error>;
    fn has_broken(&self, conn: &mut Self::Connection) -> bool;
}

查看特质定义,我们需要指定一个Connection类型,它必须是Send'static,以及一个Error类型。我们还有三个方法:connectis_validhas_brokenconnect方法返回来自底层后端 crate 的Connection类型;例如,对于 Postgres 后端,它将是postgres::Connection类型。Error类型是一个枚举,它指定了在连接阶段或检查连接有效性期间可能发生的所有可能的Error情况。

为了演示目的,我们将查看如何使用r2d2crate,首先检查如何使用池连接到 PostgreSQL。我们将从上一节中的代码开始,并修改它以使用连接池,其中我们从8个线程中执行 SQL 查询。

下面是使用r2d2-postgres后端 crate 的池化和线程化实现的完整代码:

// r2d2_demo/src/main.rs

use std::thread;
use r2d2_postgres::{TlsMode, PostgresConnectionManager};
use std::time::Duration;

const DROP_TABLE: &str = "DROP TABLE IF EXISTS books";

const CREATE_TABLE: &str = "CREATE TABLE IF NOT EXISTS books 
                            (id SERIAL PRIMARY KEY,
                            title VARCHAR NOT NULL,
                            author VARCHAR NOT NULL,
                            year SERIAL)";

#[derive(Debug)]
struct Book {
    id: i32,
    title: String,
    author: String,
    year: i32
}

fn main() {
    let manager = PostgresConnectionManager::new("postgres://postgres:postgres@localhost:5432",
                                                 TlsMode::None).unwrap();
    let pool = r2d2::Pool::new(manager).unwrap();
    let conn = pool.get().unwrap();

    let _ = conn.execute(DROP_TABLE, &[]).unwrap();
    let _ = conn.execute(CREATE_TABLE, &[]).unwrap();

    thread::spawn(move || {
        let book = Book {
            id: 3,
            title: "A programmers introduction to mathematics".to_string(),
            author: "Dr. Jeremy Kun".to_string(),
            year: 2018
        };
        conn.execute("INSERT INTO books (id, title, author, year) VALUES ($1, $2, $3, $4)",
                    &[&book.id, &book.title, &book.author, &book.year]).unwrap();                                         
    });

    thread::sleep(Duration::from_millis(100));
    for _ in 0..8 {
        let conn = pool.get().unwrap();
        thread::spawn(move || {
            for row in &conn.query("SELECT id, title, author, year FROM books", &[]).unwrap() {
                let book = Book {
                    id: row.get(0),
                    title: row.get(1),
                    author: row.get(2),
                    year: row.get(3)
                };
                println!("{:?}", book);
            }
        });
    }
}

代码与上一个示例相比相当直接,除了我们现在会启动 8 个线程来对我们的数据库执行选择查询。池的大小被配置为8,这意味着SELECT查询线程可以通过重用连接来并发执行 8 个查询。

到目前为止,我们主要使用原始 SQL 查询从 Rust 与数据库交互。但是,有一个更方便的强类型方法,通过一个名为 diesel 的 ORM 库与数据库交互。让我们接下来探索一下。

Postgres 和 diesel ORM

使用低级数据库库和原始 SQL 查询编写复杂的应用程序是犯许多错误的原因。Diesel 是一个 Rust 的 ORM(对象关系映射器)和查询构建器。它大量使用过程宏。它在编译时检测大多数数据库交互错误,并且在大多数情况下能够生成非常高效的代码,有时甚至能超越 C 语言的底层访问。这是因为它能够将通常在运行时进行的检查移动到编译时。在撰写本文时,diesel 支持开箱即用的 PostgreSQL、MySQL 和 SQLite。

我们将把数据库支持集成到我们在 第十三章,使用 Rust 构建 Web 应用程序 中开发的 linksnap 服务器中。我们将使用 diesel 以类型安全的方式与我们的 postgres 数据库通信。我们将从 第十三章,使用 Rust 构建 Web 应用程序 中复制 linksnap 项目,并将其重命名为 linksnap_v2。我们不会详细介绍完整的源代码,只会介绍影响数据库与 diesel 集成的部分。其余的代码库与上一章完全相同。

diesel 项目由许多组件组成。首先,我们有一个名为 diesel-cli 的命令行工具,它可以自动化创建数据库和执行任何必要的数据库迁移的过程。

现在,在我们开始实现与数据库通信的例程之前,我们需要安装 diesel-cli 工具,这将设置我们的数据库及其内部的表。我们可以通过运行以下命令来安装它:

cargo install diesel_cli --no-default-features --features postgres

我们只使用此 CLI 工具的 postgres 功能和 --features 标志。Cargo 将获取并构建 diesel_cli 及其依赖项,并将其安装到用户 Cargo 的默认二进制位置,通常是 ~/.cargo/bin/ 目录。

在我们的 linksnap_v2 目录中,我们将在目录根部的 .env 文件中添加数据库的连接 URL,其内容如下:

DATABASE_URL=postgres://postgres:postgres@localhost/linksnap

我们在 postgres 中的数据库名为 linksnap,用户名和密码都是 postgres。这绝对不是访问数据库的安全方式,建议您在生产环境中使用最佳安全实践来设置您的 postgres 数据库。

我们还需要在 Cargo.toml 文件中添加 diesel 作为依赖项,以及 dotenv 库。dotenv 包通过 dotfiles 处理本地配置。以下是我们的 Cargo.toml 文件:

# linksnap_v2/Cargo.toml

[dependencies]
actix = "0.7"
actix-web = "0.7"

futures = "0.1"
env_logger = "0.5"
bytes = "0.4"
serde = "1.0.80"
serde_json = "1.0.33"
serde_derive = "1.0.80"
url = "1.7.2"
lazy_static = "1.2.0"
log = "0.4.6"
chrono = { version="0.4" }
diesel = { version = "1.3.3", features = ["extras", "postgres", "r2d2"] }
dotenv = "0.13.0"

注意 diesel 包中我们使用的 "postgres""r2d2" 功能。接下来,我们将运行 diesel setup

Creating migrations directory at: /home/creativcoder/book/Mastering-RUST-Second-Edition/Chapter14/linksnap_v2/migrations

这将在根目录下创建一个 diesel.toml 文件,其内容如下:

# linksnap_v2/diesel.toml

# For documentation on how to configure this file,
# see diesel.rs/guides/configuring-diesel-cli

[print_schema]
file = "src/schema.rs"

Diesel 依赖于宏来提供一些出色的功能,例如在编译时提供额外的安全和性能。为了能够做到这一点,它需要编译时对数据库的访问。这就是为什么 .env 文件很重要的原因。diesel setup 命令通过读取数据库并将其写入一个名为 schema.rs 的文件来自动生成模型类型。模型通常分为查询和插入结构体。两者都使用 derive 宏来为不同的用例生成模型代码。此外,基于 diesel 的应用程序需要代码来连接到数据库,以及一组数据库迁移来构建和维护数据库表。

现在,让我们添加一个迁移,通过运行以下代码来创建我们的表:

diesel migration generate linksnap

此命令生成一个新的迁移,包含两个空的 up.sqldown.sql 迁移文件:

Creating migrations/2019-01-30-045330_linksnap/up.sql
Creating migrations/2019-01-30-045330_linksnap/down.sql

迁移文件只是普通的 SQL,所以我们可以直接放入我们之前的 CREATE TABLE 命令:

-- linksnap_v2/migrations/2019-01-30-045330_linksnap_db/up.sql

CREATE TABLE linksnap (
  id SERIAL PRIMARY KEY,
  title VARCHAR NOT NULL,
  url TEXT NOT NULL,
  added TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)

down.sql 文件应该包含相应的 DROP TABLE

-- linksnap_v2/migrations/2019-01-30-045330_linksnap_db/down.sqlDROP TABLE linksnap

一旦我们这样做,我们就必须运行:

$ diesel migration run

这将通过从数据库中读取来创建一个 schema.rs 文件:

// linksnap_v2/src/schema.rs

table! {
    linksnap (id) {
        id -> Int4,
        title -> Varchar,
        url -> Text,
        added -> Timestamp,
    }
}

table! 宏为具有 id 作为主键的 linksnap 表生成代码。它还指定了列名,包括 idtitleurladded

现在,我们可以为这个表编写一个模型。在 diesel 中,模型可以存在于任何可见的模块中,但我们将遵循将它们放在 src/models.rs 中的约定。以下是我们的 Link 模型的内容:

// linksnap_v2/src/models.rs

use chrono::prelude::*;
use diesel::prelude::Queryable;
use chrono::NaiveDateTime;
use diesel;
use diesel::pg::PgConnection;
use diesel::prelude::*;
use crate::state::AddLink;
use crate::schema::linksnap;
use crate::schema::linksnap::dsl::{linksnap as get_links};
use serde_derive::{Serialize, Deserialize};

pub type LinkId = i32;

#[derive(Queryable, Debug)]
pub struct Link {
    pub id: i32,
    pub title: String,
    pub url: String,
    pub added: NaiveDateTime,
}

impl Link {
    pub fn add_link(new_link: AddLink, conn: &PgConnection) -> QueryResult<usize> {
        diesel::insert_into(linksnap::table)
            .values(&new_link)
            .execute(conn)
    }

    pub fn get_links(conn: &PgConnection) -> QueryResult<Vec<Link>> {
        get_links.order(linksnap::id.desc()).load::<Link>(conn)
    }

    pub fn rm_link(id: LinkId, conn: &PgConnection) -> QueryResult<usize> {
        diesel::delete(get_links.find(id)).execute(conn)
    }
}

我们创建了一个 Link 结构体,它可以用来查询数据库。它还包含各种方法,当我们的服务器在相应的端点上收到请求时会被调用。

接下来,state.rs 文件包含 diesel 和 postgres 特定的代码:

// linksnap_v2/src/state.rs

use diesel::pg::PgConnection;
use actix::Addr;
use actix::SyncArbiter;
use std::env;
use diesel::r2d2::{ConnectionManager, Pool, PoolError, PooledConnection};
use actix::{Handler, Message};
use crate::models::Link;
use serde_derive::{Serialize, Deserialize};
use crate::schema::linksnap;
use std::ops::Deref;

const DB_THREADS: usize = 3;

use actix_web::{error, Error};
use actix::Actor;
use actix::SyncContext;

// Using this we create the connection pool.
pub type PgPool = Pool<ConnectionManager<PgConnection>>;
type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
pub struct Db(pub PgPool);

// We define a get conn a convenient method to get
impl Db {
    pub fn get_conn(&self) -> Result<PgPooledConnection, Error> {
        self.0.get().map_err(|e| error::ErrorInternalServerError(e))
    }
}

首先,我们为我们的 PostgreSQL 连接池创建了一组方便的别名。我们有一个相同的 Db 结构体,它封装了 PgPool 类型。PgPool 类型是 diesel 中 r2d2 模块的一个 ConnectionManager。在 Db 结构体上,我们还定义了 get_conn 方法,它返回一个指向池连接的引用。

继续阅读同一文件:


// We then implement the Actor trait on the actor.
impl Actor for Db {
    type Context = SyncContext<Self>;
}

pub fn init_pool(database_url: &str) -> Result<PgPool, PoolError> {
    let manager = ConnectionManager::<PgConnection>::new(database_url);
    Pool::builder().build(manager)
}

// This type is simply wraps a Addr what we 
#[derive(Clone)]
pub struct State {
    pub inner: Addr<Db>
}

impl State {
    // The init method creates 
    pub fn init() -> State {
        let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
        let pool = init_pool(&database_url).expect("Failed to create pool");
        let addr = SyncArbiter::start(DB_THREADS, move || Db(pool.clone()));
        let state = State {
            inner: addr.clone()
        };
        state
    }
    pub fn get(&self) -> &Addr<Db> {
        &self.inner
    }
}

我们有熟悉的 State 类型,但这里的 init 方法不同。它首先访问 DATABASE_URL 环境变量,并尝试使用 pool 中的 database_url 来建立一个池连接。然后我们启动一个 SyncArbiter 线程,克隆 pool。最后,我们将 state 实例返回给调用者。

除了这些,我们不需要对之前的 linksnap 代码库做太多修改。让我们试运行一下我们的新服务器。我们将使用 curl 将一个 Link 插入到我们的服务器中,如下所示:

curl --header "Content-Type: application/json" \
 --request POST \
 --data '{"title":"rust webpage","url":"https://rust-lang.org"}' \
 127.0.0.1:8080/add

现在为了确认这个链接已经到达我们的 postgres 数据库,让我们从 psql 提示符中查询这个条目:

图片

太棒了!我们的 curl 请求已经到达了 postgres 数据库。

虽然 Diesel 的入门门槛有点高,但随着越来越多的示例和文献出现,情况应该会得到改善。

摘要

在本章中,我们简要地探讨了使用 Rust 通过低级别的 SQLite 和 PostgreSQL 库 crate 来执行基本数据库交互的几种方法。我们看到了如何使用 r2d2 来增强数据库连接性,并使用连接池。最后,我们使用 Diesel,一个安全且高效的 ORM,创建了一个小型应用程序。

在下一章中,我们将探讨如何使用名为 WebAssembly 的前沿技术使 Rust 能够在网络上运行。

第十五章:使用 WebAssembly 在 Web 上构建 Rust

Rust 超出了其系统编程领域,也可以在 Web 上运行。在本章中,我们将探讨一种实现这一目标的技术,称为 WebAssembly。我们将详细介绍 WebAssembly 究竟是什么,以及如何使用这项技术使 Rust 能够与 JavaScript 一起运行。能够在 Web 浏览器上运行解锁了一个领域,使得 Rust 能够被更广泛的受众使用,即 Web 开发者社区,并使他们能够在应用程序中利用系统语言的功能。在本章的后面部分,我们将探讨提供 WebAssembly 支持的工具和库,并构建一个实时 Markdown 编辑器,该编辑器调用在 Rust 中实现的 API,将 Markdown 文档渲染到 HTML 页面上。

在本章中,我们将涵盖以下主题

  • 什么是 WebAssembly?

  • WebAssembly 的目标

  • 如何使用 WebAssembly?

  • Rust 和 WebAssembly 的故事以及可用的 crate

  • 在 Rust 中构建基于 WebAssembly 的 Web 应用程序

什么是 WebAssembly?

“保持好奇心。广泛阅读。尝试新事物。我认为人们所说的许多所谓的智力都归结为好奇心。”                                                                                                           – 阿伦·斯沃茨

WebAssembly 是一套技术和规范,它允许通过编译成名为 wasm 的低级编程语言来在 Web 上运行原生代码。从可用性的角度来看,它是一套技术,允许用其他非 Web 编程语言编写的程序在 Web 浏览器上运行。从技术角度来看,WebAssembly 是一个具有二进制、加载时高效的 指令集架构 (ISA) 的虚拟机规范。好吧,这是术语过载。让我们稍微简化一下这个定义。正如我们所知,编译器是一个复杂的程序,它将人类可读的编程语言编写的代码转换为由零和一组成的机器代码。然而,这种转换发生在多步过程中。它通过编译的几个阶段来完成,最终编译成特定于机器的汇编语言。随后,特定于机器的汇编器根据目标机器的 ISA 规则将其编码为机器代码。在这里,编译器针对的是真实机器。然而,它不总是必须是真实机器。它也可以是一个虚拟机 (VM),在真实机器上执行自己的虚拟指令集。虚拟机的一个例子是视频游戏模拟器,如运行在普通计算机上的 Gameboy 模拟器,它模拟 Gameboy 硬件。WebAssembly 虚拟机与此类似!在这里,浏览器引擎实现了 WebAssembly 虚拟机,使我们能够在 JavaScript 旁边运行 wasm 代码。

指令集架构或 ISA 定义了计算机如何执行指令以及它在最低级别支持的操作类型。这个 ISA 不一定要总是为真实的物理机器定义;它也可以为虚拟机定义。Wasm 是 WebAssembly 虚拟机的 ISA。

过去 5 年中,对网络及其各种应用的日益依赖导致了开发者们努力将他们的代码转换为 JavaScript。这是因为 JavaScript 是最受欢迎的,也是网络上的唯一跨平台技术。一个名为asm.js的项目,由 Mozilla(JavaScript 的一个更快的子集)发起,是第一个努力使网络更高效、足够快以满足不断增长需求的项目。从asm.js及其创立原则和所学经验中,WebAssembly 应运而生。

WebAssembly 是来自各大公司浏览器委员会的共同努力,包括 Mozilla、Google、Apple 和 Microsoft。自 2018 年初以来,它作为各种语言的编译目标而受到极大的欢迎,这些语言从使用 Emscripten 工具链的C++,到使用 LLVM/Emscripten 的 Rust,使用 AssemblyScript 的 TypeScript,以及其他许多语言。截至 2019 年,所有主要浏览器都在其网络浏览器引擎中实现了 WebAssembly 虚拟机。

WebAssembly 的名字中有 assembly,因为它是一种类似于汇编指令的低级编程语言。它有一组有限的原始类型,这使得语言易于解析和运行。它支持以下类型:

  • i32: 32 位整数

  • i64: 64 位整数

  • f32: 32 位浮点数

  • f64: 64 位浮点数

它不像 JavaScript 那样是你每天都会编写的编程语言,而更像是一个编译目标,供编译器使用。WebAssembly 平台和生态系统目前专注于在网络上运行这项技术,但它并不局限于网络。如果一个平台将 WebAssembly 虚拟机规范实现为一个程序,那么 wasm 程序就能在该虚拟机上运行。

为了在任何平台上支持 WebAssembly,需要用该平台支持的语言实现一个虚拟机。这就像 JVM 的平台无关代码——一次编写,运行更快,安全在任何地方!但它的主要目标,到目前为止,是浏览器。大多数网络浏览器都带有 JavaScript 解析器,可以在其浏览器引擎中解析.js文件,以使用户能够进行各种交互。为了允许网络也能解释 wasm 文件,这些引擎在其内部实现了 WebAssembly VM,允许浏览器在 JavaScript 代码旁边解释和运行 wasm 代码。

解析 JavaScript 和解析 WebAssembly 代码之间的一个显著差异是,由于其紧凑的表示,wasm 代码的解析速度要快一个数量级。在动态网站上,大多数初始页面加载时间都花在解析 JavaScript 代码上,而使用 WebAssembly 可以为这些网站提供巨大的性能提升。然而,WebAssembly 的目标并不是取代 JavaScript,而是在性能重要时成为 JavaScript 的助手。

根据webassembly.org/上的规范,WebAssembly 的语言有两种格式:人类可读的文本格式,.wat,适合在最终部署前查看和调试 WebAssembly 代码,以及称为 wasm 的紧凑、低级机器格式。.wasm 格式是 WebAssembly VM 解释和执行的一种格式。

一个 WebAssembly 程序从模块开始。在模块内部,你可以定义变量、函数、常量等。Wasm 程序以 s-表达式编写。S-表达式是通过嵌套括号分隔的块序列来表示程序的简洁方式。例如,单个 (1) 是一个返回值 1 的 s-表达式。WebAssembly 中的每个 s-表达式都返回一个值。让我们看看一个非常简单的 WebAssembly 程序,以可读的 .wat 格式:

(module
 (table 0 anyfunc)
 (memory $0 1)
 (export "memory" (memory $0))
 (export "one" (func $one))
 (func $one (; 0 ;) (result i32)
  (i32.const 1)
 )
)

在前面的 wat 代码中,我们有一个包含其他嵌套 s-表达式的父 s-表达式块 (module)。在 module 内部,我们有名为 tablememoryexport 的部分,以及一个返回 i32func 定义 $one。我们不会深入探讨它们的细节,因为这会让我们偏离主题太远。

关于 wasm 程序的一个重要点是,它们在表示上非常高效,可以在浏览器中比 JavaScript 快得多地发送和解析。话虽如此,WebAssembly 是为了实现一系列目标而设计的,而不是作为一个通用编程语言。

WebAssembly 的设计目标

WebAssembly 的设计是主要浏览器供应商之间联合协作的结果。他们共同的目标是以下目标来塑造其设计:

  • 像 JavaScript 一样安全和通用:网络平台是一个不安全的环境,运行不受信任的代码对网络用户的安全是有害的。

  • 以原生代码的速度运行:由于语言相当紧凑,WebAssembly 可以比 JavaScript 代码加载得更快,并且可以比 JavaScript 快五倍的速度进行解释。

  • 提供一致、可预测的性能:作为静态类型,并且在运行时进行非常少的分析,WebAssembly 能够在网络上提供一致的性能,而 JavaScript 由于其动态性质而在这方面表现不足。

  • 允许在 Web 和本地之间重用代码:许多现有的 C/C++、Rust 和其他语言的代码库现在可以在编译成 WebAssembly 后重用并在网络上运行。

开始使用 WebAssembly

虽然可以手动编写 WebAssembly 模块,但这样做并不建议,因为代码难以维护且不便于人类使用。它是一种相当低级的语言,因此,使用原始 wasm 代码创建复杂的应用程序可能会具有挑战性且耗时。相反,它通常被编译成或从各种语言生成。让我们看看我们可以使用的可用工具,以探索 WebAssembly 程序的编写和运行细节。

在线尝试

在我们讨论 WebAssembly 如何作为不同语言生态系统的编译目标之前,我们可以在网上探索它,而无需在我们的机器上做任何设置。以下是一些可以用来做这件事的工具:

  • WebAssembly Studio:Mozilla 的人们创建了一个非常实用的工具,可以快速尝试 WebAssembly,它托管在 webassembly.studio。使用这个工具,我们可以快速实验和原型设计 WebAssembly 的想法。

  • Wasm Fiddle:这是另一个方便的工具,可以在线尝试 wasm 代码,可以在 wasdk.github.io/WasmFiddle/ 找到。

在线还有其他工具和社区资源供您探索,您都可以在 github.com/mbasso/awesome-wasm 找到。

生成 WebAssembly 的方法

有几个编译器工具链项目可以帮助开发者将任何语言的代码编译为 wasm。编译本地代码对网络有巨大的影响。这意味着大多数主要性能密集型代码现在都可以在网络上运行。例如,可以使用 emscripten LLVM 后端将 C++ 代码编译为 wasm。emscripten 项目接收由 C++ 编译器生成的 LLVM IR,并将其转换为 wasm 格式的 WebAssembly 模块。还有像 AssemblyScript 这样的项目,使用类似 emscripten 的工具 binaryen 将 TypeScript 代码转换为 WebAssembly。Rust 还默认支持通过 LLVM 的本地 WebAssembly 后端生成 WebAssembly 代码。使用 Rust 编译到 wasm 非常简单。首先,我们需要运行以下代码来添加 wasm:

rustup target add wasm32-unknown-unknown

完成这些后,我们可以通过运行以下代码将任何 Rust 程序编译为 wasm:

 cargo build --target=wasm32-unknown-unknown

这是从 Rust crate 创建 wasm 文件所需的最基本内容,但从那里开始有很多手动操作。幸运的是,围绕 wasm 和 Rust 生态系统正在开发一些惊人的项目,允许进行更高级、更直观的 JavaScript 和 Rust 之间的交互,反之亦然。我们将探索这样一个项目,称为 wasm-bindgen,并很快构建一个真实世界的 Web 应用程序。

Rust 和 WebAssembly

Rust 和 WebAssembly 围绕的生态系统正在以相当快的速度发展,社区在就构建实用应用程序的工具集达成一致之前还需要一段时间。幸运的是,一些工具和库正在出现,为我们描绘了当使用 WebAssembly 在 Rust 中构建 Web 应用程序时,作为开发者我们可以期待什么。

在本节中,我们将探索一个来自社区的库,称为 wasm-bindgen. 这个库基本上是一个正在进行中的项目,因为 WebAssembly 规范本身也是一个正在进行中的项目,但尽管如此,它仍然功能丰富,可以探索可能实现的内容。

Wasm-bindgen

wasm-bindgen 是由 GitHub 上的 rust-wasm 团队开发的一个库。它允许 Rust 代码调用 JavaScript 代码,反之亦然。基于这个库,已经构建了其他高级库,如 web-sys 库和 js-sys 库。

JavaScript 本身是由 ECMA 标准定义的,但该标准并未指定其在网页上的工作方式。JavaScript 可以支持许多宿主环境,而网页恰好是其中之一。web-sys 库提供了对网页上所有 JavaScript API 的访问,即 DOM API,如 WindowNavigatorEventListener 等。js-sys 库提供了 ECMA 标准规范中指定的所有基本 JavaScript 对象,即函数、对象、数字等。

由于 WebAssembly 只支持数值类型,wasm-bindgen 库生成垫片,允许你在 JavaScript 中使用原生 Rust 类型。例如,Rust 中的结构体在 JavaScript 端表示为对象,而 Promise 对象在 Rust 端可以像 Future 一样访问。它通过在函数定义上使用 #[wasm-bindgen] 属性宏来实现所有这些。

为了探索 wasm-bindgen 以及它与 JavaScript 的交互方式,我们将构建一些实用的东西。我们将构建一个实时 Markdown 编辑器应用程序,允许你编写 Markdown 并查看渲染的 HTML 页面。不过,在我们开始之前,我们需要安装 wasm-bindgen-cli 工具,该工具将为我们生成垫片,使我们能够方便地使用库中暴露的 Rust 函数。我们可以通过运行以下命令来安装它:

cargo install wasm-bindgen-cli

接下来,让我们通过运行 cargo new livemd 并在 Cargo.toml 中包含以下内容来创建一个新的项目:

[package]
name = "livemd"
version = "0.1.0"
authors = ["Rahul Sharma <creativcoders@gmail.com>"]
edition = "2018"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2.29"
comrak = "0.4.0"

我们将我们的库命名为 livemd。我们的库是 cdylib 类型,并暴露了一个 C 接口,因为 WebAssembly 接受广泛可移植的动态 C 库接口,大多数语言都可以编译成这种接口。接下来,我们还将在我们目录的根目录下创建一个 run.sh 脚本,它将允许我们构建和运行我们的项目,并在我们更改任何代码时使用 cargo-watch 重新运行它。以下是 run.sh 文件的内容:

#!/bin/sh

set -ex

cargo build --target wasm32-unknown-unknown
wasm-bindgen target/wasm32-unknown-unknown/debug/livemd.wasm --out-dir app
cd app
yarn install
yarn run serve

接下来是 lib.rs 中 markdown 转换代码的实现,完整如下:

// livemd/src/lib.rs

use wasm_bindgen::prelude::*;

use comrak::{markdown_to_html, ComrakOptions};

#[wasm_bindgen]
pub fn parse(source: &amp;str) -> String {
    markdown_to_html(source, &amp;ComrakOptions::default())
}

我们的 livemd 包公开一个名为 pars 的单个函数,它接受来自网页上(尚未创建)的 textarea 标签的 Markdown 文本,并通过调用 comrak 包中的 markdown_to_html 函数返回编译后的 HTML 字符串。如您所注意到的,parse 方法被注释为 #[wasm_bindgen] 属性。此宏生成所有类型的底层转换代码,并且是暴露此函数到 JavaScript 所必需的。使用此属性,我们不必关心我们的解析方法将接受什么类型的字符串。JavaScript 中的字符串与 Rust 中的字符串不同。#[wasm_bindgen] 属性处理这种差异以及将字符串从 JavaScript 端转换为低级细节,在我们接受它作为 &str 类型之前。在撰写本书时,有一些类型 wasm-bindgen 无法转换,例如带有生命周期注释的引用和类型定义。

我们接下来需要为这个包生成 wasm 文件。但在我们这样做之前,让我们设置我们的应用。在相同的目录下,我们将创建一个名为 app/ 的目录,并通过运行 yarn init 来初始化我们的项目:

图片

yarn init 创建我们的 package.json 文件。除了常规字段外,我们还将指定 scriptsdev-dependencies

{
  "name": "livemd",
  "version": "1.0.0",
  "description": "A live markdown editor",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "build": "webpack",
    "serve": "webpack-dev-server"
  },
  "devDependencies": {
    "html-webpack-plugin": "³.2.0",
    "webpack": "⁴.28.3",
    "webpack-cli": "³.2.0",
    "webpack-dev-server": "³.1.0"
  }
}

我们将使用 webpack 来启动我们的开发 web 服务器。Webpack 是一个模块打包器。模块打包器将多个 JavaScript 源文件打包成一个文件,可能还会对其进行压缩以便在网络上使用。为了配置 webpack 以便我们可以打包我们的 JavaScript 和 wasm 生成的代码,我们将在名为 webpack.config.js 的文件中创建一个 webpack 配置文件:

// livemd/app/webpack.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: './index.js',
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: 'index.js',
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: "index.html"
        })
    ],
    mode: 'development'
};

接下来,在相同的 app/ 目录下,我们将创建三个文件:

  • index.html: 这包含应用的 UI:
<!--livemd/app/index.html-->

<!DOCTYPE html>
<html>
<head>
    <title>Livemd: Realtime markdown editor</title>
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.min.css" rel="stylesheet">
    <link href="https://fonts.googleapis.com/css?family=Aleo" rel="stylesheet"> 
    <link href="styles.css" rel="stylesheet">
</head>

<body class="container-fluid">
    <section class="row">
        <textarea class="col-md-6 container" id="editor">_Write your text here.._</textarea>
        <div class="col-md-6 container" id="preview"></div>
    </section>
    <script src="img/index.js" async defer></script>
</body>
</html>

我们已声明一个具有 ID 为 editor<textarea> HTML 元素。这将在左侧显示,并且是您可以在此处编写 Markdown 的地方。接下来,我们有一个具有 ID 为 preview<div> 元素,它将显示实时渲染的 HTML 内容。

  • style.css: 为了使我们的应用看起来更美观,此文件为应用中的实时编辑器和预览面板提供基本的样式:
/* livemd/app/styles.css */

html, body, section, .container {
    height: 100%;
} 

#editor {
    font-family: 'Aleo', serif;
    font-size: 2rem;
    color: white;
    border: none;
    overflow: auto;
    outline: none;
    resize: none;

    -webkit-box-shadow: none;
    -moz-box-shadow: none;
    box-shadow: none;
    box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, .6);
    background-color: rgb(47,79,79);
}

#preview {
    overflow: auto;
    border: 5px;
    border-left: 1px solid grey;
}
  • index.js: 此文件提供我们 UI 和 livemd 包之间的粘合代码:
// livemd/app/index.js

import('./livemd').then((livemd) => {
    var editor = document.getElementById("editor");
    var preview = document.getElementById("preview");

    var markdownToHtml = function() {
        var markdownText = editor.value;
        html = livemd.parse(markdownText);
        preview.innerHTML = html;
    };

    editor.addEventListener('input', markdownToHtml);
    // Kick off parsing of initial text
    markdownToHtml();
}).catch(console.error);

上述代码导入了 livemd 模块,该模块返回一个 Promise 实例。然后,我们通过调用 then 方法来链式调用由这个承诺产生的值,该方法接受一个匿名函数 (livemd) => {}。这个函数接收 wasm 模块(我们命名为 livemd)。在这个方法内部,我们通过 ID 获取 editorpreview HTML 元素。然后,我们创建一个名为 markdownToHtml 的函数,该函数从 editor 元素的 value 属性中获取文本并将其传递给 livemd wasm 模块的 parse 方法。这返回一个作为字符串的渲染 HTML 文本。然后,我们将 preview 元素的 innerHTML 属性设置为这个文本。接下来,为了提供用户在 editor 元素中任何文本更改的实时反馈,我们需要调用这个函数。我们可以使用 onInput 事件处理器来实现。我们对编辑器元素调用 addEventListener 方法,使用 "input" 事件,并将此函数作为处理器传递。最后,我们调用 markdownToHtml 来启动文本的解析和渲染。

就这样——我们已经创建了我们第一个使用 Rust 作为底层并运行 WebAssembly 的 Web 应用程序。

注意:这不是一个高效的实现,有很多改进可以做出。然而,由于我们在这里学习工具,为了演示目的,这是可以接受的。

现在,我们需要将我们的 crate 编译成 WebAssembly 代码,即一个 wasm 文件,并生成一个捆绑的 JavaScript 文件。我们已经设置了一个名为 run.sh 的脚本。以下是运行我们的 run.sh 脚本时的输出:

图片

run.sh 脚本首先通过运行 cargo build --target wasm32-unknown-unknown 构建 livemd crate。然后,它调用 wasm-bindgen 工具,该工具优化我们的 wasm 文件并将其输出到 app/ 目录。接着,我们在应用目录中运行 yarn install,然后是 yarn run serve,这会使用 webpack-dev-server 插件启动我们的开发服务器。

如果你在运行 wasm-bindgen 命令行时遇到错误,尝试通过运行以下命令更新 livemd/Cargo.toml 中的 wasm-bindgen 依赖项:

cargo update -p wasm-bindgen

我们还需要安装 yarn 包管理器来在本地主机上托管网页。这可以通过运行以下命令来完成:

$ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
$ echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
$ sudo apt-get update &amp;&amp; sudo apt-get install yarn

从 webpack 的输出来看,我们的开发服务器正在 http://localhost:8080 运行。让我们转到我们的网页浏览器并访问这个地址。以下是我在浏览器中的输出:

图片

如您所见,我们在左侧面板中有 markdown 格式的文本,并且它实时地渲染在右侧的 HTML 中。在底层,我们在这个页面的左侧键入的每一比特文本都被转换成了由我们创建的 livemd wasm 模块生成的 HTML 文本。

我们还可以将我们的livemd包发布为 npm 包。位于github.com/rustwasm/wasm-pack的 wasm-pack 项目提供了构建和分发用 Rust 编写并编译成 WebAssembly 的 npm 模块所需的所有编排。

其他 WebAssembly 项目

除了wasm-bindgen包及其相关项目外,Rust 社区中还有其他新兴的框架和项目值得探索。

Rust

Wasm-bindgen 并不是唯一旨在创建出色开发体验的项目。Rust 生态系统中的其他一些项目如下:

  • Stdweb:这个包旨在提供一个基于 Rust 的高层 API,用于通过 Web 访问 DOM API。

  • Yew:这是一个完整的前端 Web 应用程序构建框架,允许你使用 Rust 编写可以访问 Web API 并将它们编译成 wasm 以便在 Web 上运行的 Web 应用程序。它受到了 Elm 和 ReactJS 项目的启发。它还通过 Web Workers 封装了基于 actor 的消息传递并发。Yew 内部使用stdweb包来访问 DOM API。

  • Nebutlet:这是一个可以执行 WebAssembly 程序而不需要任何系统调用接口的微内核,这在大多数操作系统实现中通常是标准做法。

  • Wasmi:这是一个用 Rust 实现的 wasm 虚拟机,但它与浏览器引擎中的 wasm 虚拟机无关。该项目由以太坊初创公司 Parity 发起,更适合在多个平台上运行 WebAssembly 应用程序。该项目托管在github.com/paritytech/wasmi

其他语言

其他语言也有针对 WebAssembly 的技术,例如以下内容:

  • Life:Life 是一个用 Golang 实现的 WebAssembly 虚拟机,用于安全地运行高性能、去中心化的应用程序。该项目位于github.com/perlin-network/life

  • AssemblyScript:这是一个 TypeScript 到 WebAssembly 的编译器。

  • Wagon:Wagon 是一个用 Golang 编写的 WebAssembly 解释器。

摘要

WebAssembly 将对 Web 开发者构建应用程序的方式产生巨大影响,使他们能够以最小的努力获得大量的性能提升。它将允许应用开发者多样化,从而使得他们能够用他们的本地语言编写 Web 应用程序,而无需担心学习其他框架。WebAssembly 不是用来取代 JavaScript 的,而是作为一个高性能语言,用于在 Web 上运行复杂的 Web 应用程序。WebAssembly 标准正在不断演变,并有许多令人兴奋的可能性。

在本章中,我们学习了 Rust 如何编译成 wasm 代码以及帮助在网络上部署 Rust 代码的可用工具。如果你想要了解更多关于 WebAssembly 的信息,请访问优秀的文档:developer.mozilla.org/en-US/docs/WebAssembly

在下一章中,我们将学习一些关于 GUI 开发以及如何使用 Rust 构建桌面应用程序的内容。

第十六章:使用 Rust 构建桌面应用程序

如果你的软件只支持终端或基于命令行的界面,你的目标受众可能仅限于那些知道如何使用命令行的人。为你的软件提供一个图形用户界面GUI)可以扩大你的目标受众,并为用户提供一个友好且直观的界面,使他们能够轻松地使用软件。对于构建 GUI,大多数语言都提供了框架,这些框架由几个原生库组成,可以访问平台的图形和 I/O 接口。这使得开发者可以轻松地为他们的应用程序构建 GUI,而无需担心底层细节。

有很多流行的 GUI 框架针对桌面平台,如 Qt、GTK+和 ImGUI,这些框架适用于主流语言。在撰写本书时,Rust 还没有成熟的 GUI 框架生态系统,但幸运的是,我们有方便的 FFI 机制,可以通过它利用 C/C++等语言提供的原生 GUI 框架。在本章中,我们将介绍这样一个 crate,它提供了对 GTK+框架的本地绑定,用于进行 GUI 开发,并构建我们自己的新闻阅读桌面应用程序。

在本章中,我们将涵盖以下主题:

  • GUI 开发简介

  • GTK+框架和gtkcrate

  • 使用 Rust 构建黑客新闻桌面应用程序

  • 其他新兴框架

GUI 开发简介

“在编程中,困难的部分不在于解决问题,而在于决定要解决什么问题。”

保罗·格雷厄姆

基于 GUI 的软件的出现始于 GUI 操作系统。第一个 GUI 操作系统是 1973 年开发的 Xerox Alto 计算机上的 Alto Executive。从那时起,许多操作系统效仿并附带了自己的基于 GUI 的界面。如今,最著名的基于 GUI 的操作系统是 macOS、Windows 以及基于 Linux 的发行版,如 Ubuntu 和 KDE。随着用户通过视觉点按界面与操作系统交互,对基于 GUI 的应用程序的需求增加,许多软件开始附带 GUI,为用户提供了一种与软件交互的视觉方式,类似于他们与操作系统交互的方式。但 GUI 开发的早期阶段有很多手动工作,由于硬件限制,不同的应用程序在其 GUI 中都有专门的实现和性能特征。最终,GUI 框架开始普及,这为开发者提供了一个共同的基线和从底层操作系统的所有底层细节中的抽象层,同时也实现了跨平台。

在我们构建应用程序之前,了解在将 GUI 或前端集成到应用程序中时遵循的一般设计指南对我们来说非常重要。我们将从 Linux 操作系统角度进行讨论,但其他平台上的理念也有相似之处。典型的 GUI 应用程序架构通常分为两个组件:前端和后端。前端是用户与之交互的 GUI 线程或主线程。它由包含在父容器窗口中的交互式视觉单元小部件组成。小部件的一个例子是点击执行任务的按钮,或者可以按顺序显示多个项目的列表视图。GUI 线程主要关注向用户展示视觉信息,并且负责传播用户与这些小部件交互时发生的任何事件。

后端是一个独立的线程,包含用于传播状态变化的事件处理程序或事件发射器,主要用于执行计算密集型任务。从 GUI 层处理输入通常被卸载到后台线程,因为如果在主线程上执行计算密集型任务会阻止用户与应用程序的前端交互,这不是一个好的用户体验。此外,为了维护性和关注点的分离,我们通常希望保持前端和后端分离。

在没有专用框架的情况下构建基于 GUI 的应用程序可能是一个非常繁琐的过程,因为没有它们,我们可能需要在应用程序代码中处理很多细节。GUI 框架为开发者抽象了所有这些细节,例如将小部件和窗口绘制到视频内存或 GPU 的帧缓冲区,从输入设备读取事件,重新绘制和刷新窗口等等。话虽如此,让我们看看这样一个框架,即 Gimp 工具包或 GTK+,它是一个构建可扩展 GUI 应用程序的非常成熟和跨平台的解决方案。

GTK+ 框架

GTK+(以下简称 gtk)是一个用 C 语言编写的跨平台 GUI 框架。由于其跨平台性,使用 gtk 开发的应用程序可以在所有主要平台上运行,例如 Windows、Linux 或 MacOS. gtk 项目最初是为了开发 Linux 的图像处理软件 GIMP 而创建的,后来被开源。gtk 也被许多其他软件项目使用,例如许多 Linux 发行版上的 Gnome 桌面环境,它使用 gtk 来构建其实用软件。在架构上,gtk 由几个库组成,这些库协同工作以处理渲染和促进用户与应用程序中的窗口和小部件交互所需的各种细节。以下是一些这些组件的例子:

  • GLib: 这是基本的核心库,提供了多种数据结构、用于可移植性的包装器以及运行时功能(如事件循环、线程支持、动态加载和对象系统)的接口。Glib 本身由组件组成,例如 GObject,它提供了一个对象模型,以及 GIO,它提供了对 I/O 的高级抽象**。

  • Pango: Pango 是一个提供文本渲染和国际化的库。

  • Cairo: 这是一个 2D 图形库,负责在屏幕上绘制事物,并试图在多个设备之间保持一致性,同时处理硬件加速等细节。

  • ATK: ATK 是一个辅助功能工具包库,负责为屏幕阅读器、放大镜和替代输入设备等输入设备提供辅助功能。

gtk 还有一个名为 Glade 的界面构建器,它可以生成用于快速应用程序开发的 gtk 源代码框架。

gtk 使用面向对象模型来表示小部件和窗口。它利用 GObject 库来提供这种抽象。要从 Rust 使用 gtk 框架,我们有 gtk-rs 项目,该项目包含许多遵循与 gtk 中存在的库相同命名约定的 crate,并为这些库提供原生 C 绑定。在 gtk-rs 项目包含的所有 crate 中,我们将使用 gtk crate 来构建我们的应用程序。

gtk 包提供了构建 GUI 的窗口和小部件系统,并试图模拟与原生 C 库相同的 API,尽管 Rust 没有面向对象的类型系统,因此存在一些差异。gtk 包中的小部件是智能指针类型。为了在使用 API 时提供灵活性,你可以拥有许多可变引用,类似于 Rust 中的内部可变性提供的引用。gtk 中的任何非平凡小部件都继承自某种基本小部件类型。Rust 通过 IsA<T> 特性支持小部件的继承。例如,gtk::Label 小部件有一个 implimpl IsA<Widget> for Label。此外,gtk 中的大多数小部件都相互共享功能——gtk 包通过扩展特性(如 WidgetExt 特性)为所有小部件类型实现这一点。大多数小部件,如 gtk::Buttongtk::ScrollableWindow,都实现了 WidgetExt 特性。小部件还可以使用 Cast 特性将其向下转换为层次结构中的其他小部件。在简要介绍之后,让我们开始编写一个桌面应用程序。

使用 gtk-rs 构建 hacker news 应用程序

我们将使用 gtk crate 来构建一个简单的 hacker news 应用程序,该程序从 news.ycombinator.com/ 网站获取前 10 个热门故事。Hacker News 是一个专注于全球数字技术和科技新闻的网站。首先,我们为我们的应用程序创建了一个基本的线框模型:

在顶部,我们有应用标题栏,其中左侧有一个刷新按钮,可以按需更新我们的故事。故事是用户在 Hacker News 网站上发布的新闻条目。标题栏还包含居中的应用标题和右侧的常规窗口控制按钮。下面是我们的主要可滚动窗口,我们的故事将垂直渲染为故事小部件。故事小部件由两个小部件组成:一个用于显示故事的名称和分数的小部件,另一个用于渲染可以点击在用户默认浏览器中打开的故事链接。非常简单!

注意:由于我们使用的是绑定到原生 C 库的gtkcrate,我们需要安装 gtk 框架的开发 C 库。对于 Ubuntu 和 Debian 平台,我们可以通过运行以下命令来安装这些依赖项:

sudo apt-get install libgtk-3-dev

请参考 gtk-rs 文档页面gtk-rs.org/docs/requirements.html,以获取有关在其他平台上设置 gtk 的信息。

为了开始,我们将通过运行cargo new hews来创建一个新的 cargo 项目。我们创造性地将我们的应用命名为Hews,它是H(hacker 的首字母)和ews(news 的缩写)的组合。

以下是我们将在Cargo.toml文件中需要的依赖项:

# hews/Cargo.toml

[dependencies]
gtk = { version = "0.3.0", features = ["v3_18"] }
reqwest = "0.9.5"
serde_json = "1.0.33"
serde_derive = "1.0.82"
serde = "1.0.82"

在这里,我们使用了一堆 crate:

  • gtk:这是用于构建应用的 GUI。我们在这里使用的是 gtk 版本3.18的绑定。

  • reqwest:这是用于从黑客新闻 API 获取故事的。reqwesthypercrate 的高级包装器。我们为了简单起见使用reqwest的同步 API。

  • serde_json:这是用于无缝地将从网络获取的 JSON 响应转换为强类型的Story结构体。

  • serdeserde_derive:这些提供了用于自动派生内置 Rust 类型序列化代码的特性和实现。通过使用serde_derive中的SerializeDeserialize特性,我们可以将任何原生 Rust 类型序列化和反序列化到给定的格式。serde_json依赖于相同的功能将serde_json::Value类型转换为 Rust 类型。

要在我们的应用中显示新闻文章,我们将通过向官方黑客新闻 API 发起 HTTP 请求来获取它们,该 API 的文档位于github.com/HackerNews/API。我们将我们的应用分为两个模块。首先,我们有app模块,它包含所有与 UI 相关的功能,用于在屏幕上渲染应用和处理用户对 UI 状态的更新。其次,我们有hackernews模块,它提供从网络获取故事的 API。它在一个单独的线程中运行,这样在发生网络请求时不会阻塞 GUI 线程,因为这是一个阻塞的 I/O 操作。从黑客新闻 API 获取的故事是一个包含新闻标题和新闻链接的项目,以及其他属性,如故事的流行程度和评论列表。

为了使这个例子更简单、更容易理解,我们的应用程序没有适当的错误处理,并包含许多 unwrap() 调用,这在错误处理方面是一种不良做法。在您完成探索演示后,我们鼓励您在应用程序中集成更好的错误处理策略。话虽如此,让我们一步一步地通过代码。

首先,我们将查看我们的应用程序在 main.rs 中的入口点:

// hews/src/main.rs

mod app;
mod hackernews;
use app::App;

fn main() {
    let (app, rx) = App::new();
    app.launch(rx);
}

在我们的 main 函数中,我们调用 App::new(),它返回一个 App 实例,以及 rx,它是一个 mpsc::Receiver。为了使我们的 GUI 与网络请求解耦,所有状态更新在 hews 中都是通过通道异步处理的。App 实例内部调用 mpsc::channel(),返回 txrx。它将 tx 与其存储,并将其传递给网络线程,允许它通知 UI 任何新的故事。在 new 方法调用之后,我们在 app 上调用 launch,传入 rx,这在 GUI 线程中用于监听来自网络线程的事件。

接下来,让我们通过 app.rs 模块中的 app 模块来了解我们的应用程序,它处理将我们的应用程序渲染到屏幕上所需的大部分编排工作。

如果您想了解更多关于以下小部件的解释,请查找 gtk-rs 的优秀文档在 gtk-rs.org/docs/gtk/,您可以在那里搜索任何小部件并探索更多关于其属性的信息。

首先,我们有我们的 App 结构体,它是所有 GUI 事物的入口点:

// hews/src/app.rs

pub struct App {
    window: Window,
    header: Header,
    stories: gtk::Box,
    spinner: Spinner,
    tx: Sender<Msg>,
}

这个结构体包含了一堆字段:

  • window: 这包含基本的 gtk::Window 小部件。每个 gtk 应用程序都以一个窗口开始,我们可以向其中添加子小部件以不同的布局来设计我们的 GUI。

  • header: 这是一个我们定义的结构体,它包装了一个 gtk::HeaderBar 小部件,该小部件充当我们应用程序窗口的标题栏。

  • stories: 这是一个存储我们的故事的垂直容器 gtk::Box 小部件。

  • spinner: 这是一个 gtk::Spinner 小部件,为加载故事提供视觉提示。

  • tx: 这是一个 mpsc Sender,用于从 GUI 发送事件到网络线程。消息的类型是 Msg,它是一个枚举:

pub enum Msg {
            NewStory(Story),
            Loading,
            Loaded,
            Refresh,
        }

当从 hackernews 模块调用 fetch_posts 方法时,我们的应用程序以初始状态 Loading 开始。我们稍后会看到这一点。NewStory 是在获取新故事时发生的状态。Loaded 是在所有故事都加载时发生的状态,当用户想要重新加载故事时,会发送 Refresh

让我们继续到 App 结构体上的方法。这是我们的 new 方法:

impl App {
    pub fn new() -> (App, Receiver<Msg>) {
        if gtk::init().is_err() {
            println!("Failed to init hews window");
            process::exit(1);
        }

new 方法首先使用 gtk::init() 启动 gtk 事件循环。如果失败,我们将退出,并在控制台打印一条消息:

        let (tx, rx) = channel();
        let window = gtk::Window::new(gtk::WindowType::Toplevel);
        let sw = ScrolledWindow::new(None, None);
        let stories = gtk::Box::new(gtk::Orientation::Vertical, 20);
        let spinner = gtk::Spinner::new();
        let header = Header::new(stories.clone(), tx.clone());

然后,我们为网络线程和 GUI 线程之间的通信创建 txrx 通道端点。接下来,我们创建我们的 window,它是一个 TopLevel 窗口。现在,如果窗口被调整大小,多个故事可能不会适合我们的应用程序窗口,因此我们需要一个可滚动的窗口。为此,我们将创建一个 ScrolledWindow 实例作为 sw。然而,gtk 的 ScrolledWindow 只接受其内部的单个子小部件,而我们需要存储多个故事,这是一个问题。幸运的是,我们可以使用 gtk::Box 类型,它是一个通用的容器小部件,用于布局和组织子小部件。在这里,我们创建一个具有 Orientation::Vertical 方向的 gtk::Box 实例作为 stories,这样我们的每个故事都会垂直堆叠渲染。我们还想在故事正在加载时在滚动小部件的顶部显示一个旋转器,因此我们将创建一个 gtk::Spinner 小部件并将其添加到 stories 中以在顶部渲染它。我们还将创建我们的 Header 栏,并将 stories 的引用以及 tx 传递给它。我们的标题栏包含刷新按钮,并有一个点击处理程序,该处理程序需要 stories 容器来清除其内部的项目,这样我们就可以加载新的故事:

        stories.pack_start(&spinner, false, false, 2);
        sw.add(&stories);
        window.add(&sw);
        window.set_default_size(600, 350);
        window.set_titlebar(&header.header);

接下来,我们开始组合我们的小部件。首先,我们将 spinner 添加到 stories 中。然后,我们将 stories 容器小部件添加到我们的滚动小部件 sw 中,然后将其添加到我们的父 window 中。我们还使用 set_default_size 设置窗口大小。然后我们使用 set_titlebar 并传递我们的 header 来设置其标题栏。随后,我们向我们的窗口附加一个信号处理程序:

        window.connect_delete_event(move |_, _| {
            main_quit();
            Inhibit(false)
        });

如果我们调用 main_quit(),这将退出应用程序。Inhibit(false) 的返回类型不会阻止信号传播到 delete_event 的默认处理程序。所有小部件都有一个默认的信号处理程序。在 gtk crate 中的小部件上的信号处理程序遵循 connect_<event> 的命名约定,并接受一个闭包作为它们的第一个参数,该闭包以小部件作为其第一个参数和事件对象。

接下来,让我们看看 App 上的 launch 方法,它在 main.rs 中被调用:

    pub fn launch(&self, rx: Receiver<Msg>) {
        self.window.show_all();
        let client = Arc::new(reqwest::Client::new());
        self.fetch_posts(client.clone());
        self.run_event_loop(rx, client);
    }

首先,我们启用 window 小部件及其子小部件。我们通过调用 show_all 方法使它们可见,因为 gtk 中的小部件默认是不可见的。接下来,我们创建我们的 HTTP Client 并将其包装在 Arc 中,因为我们想与我们的网络线程共享它。然后我们调用 fetch_posts,传递我们的客户端。随后,我们通过调用 run_event_loop 并传递 rx 来运行我们的事件循环。fetch_posts 方法定义如下:

    fn fetch_posts(&self, client: Arc<Client>) {
        self.spinner.start();
        self.tx.send(Msg::Loading).unwrap();
        let tx_clone = self.tx.clone();
        top_stories(client, 10, &tx_clone);
    }

它通过调用其 start 方法启动旋转器动画,并发送 Loading 消息作为初始状态。然后它调用 hackernews 模块中的 top_stories 函数,传递 10 作为要获取的故事数量和一个 Sender 来通知 GUI 线程有新的故事。

在调用 fetch_posts 之后,我们在 App 上调用 run_event_loop 方法,该方法的定义如下:

    fn run_event_loop(&self, rx: Receiver<Msg>, client: Arc<Client>) {
        let container = self.stories.clone();
        let spinner = self.spinner.clone();
        let header = self.header.clone();
        let tx_clone = self.tx.clone();

        gtk::timeout_add(100, move || {
            match rx.try_recv() {
                Ok(Msg::NewStory(s)) => App::render_story(s, &container),
                Ok(Msg::Loading) => header.disable_refresh(),
                Ok(Msg::Loaded) => {
                    spinner.stop();
                    header.enable_refresh();
                }
                Ok(Msg::Refresh) => {
                    spinner.start();
                    spinner.show();
                    (&tx_clone).send(Msg::Loading).unwrap();
                    top_stories(client.clone(), 10, &tx_clone);
                }
                Err(_) => {}
            }
            gtk::Continue(true)
        });

        gtk::main();
    }

首先,我们获取我们将要使用的一堆对象的引用。随后,我们调用 gtk::timeout_add,它每 100 毫秒运行一次给定的闭包。在闭包内部,我们以非阻塞方式对 rx 进行轮询,使用 try_recv() 获取来自网络或 GUI 线程的事件。当我们收到 NewStory 消息时,我们调用 render_story。当我们收到 Loading 消息时,我们禁用刷新按钮。在 Loaded 消息的情况下,我们停止我们的旋转器并启用刷新按钮,以便用户可以再次重新加载故事。最后,在收到 Refresh 消息的情况下,我们再次启动旋转器,并将 Loading 消息发送到 GUI 线程本身,随后调用 top_stories 方法。

我们的 render_story 方法定义如下:

    fn render_story(s: Story, stories: &gtk::Box) {
        let title_with_score = format!("{} ({})", s.title, s.score);
        let label = gtk::Label::new(&*title_with_score);
        let story_url = s.url.unwrap_or("N/A".to_string());
        let link_label = gtk::Label::new(&*story_url);
        let label_markup = format!("<a href=\"{}\">{}</a>", story_url, story_url);
        link_label.set_markup(&label_markup);
        stories.pack_start(&label, false, false, 2);
        stories.pack_start(&link_label, false, false, 2);
        stories.show_all();
    }

render_story 方法在创建两个标签之前,将 Story 实例作为 sstories 容器小部件作为参数获取:title_with_score,它包含故事标题及其评分,以及 link_label,它包含故事的链接。对于 link_label,我们将添加一个包含 <a> 标签的 URL 的自定义标记。最后,我们将这两个标签放入我们的 stories 容器中,并在最后调用 show_all 以使这些标签在屏幕上可见。

我们之前提到的 Header 结构体及其方法,是 App 结构体的一部分,具体如下:

// hews/src/app.rs

#[derive(Clone)]
pub struct Header {
    pub header: HeaderBar,
    pub refresh_btn: Button
}

impl Header {
    pub fn new(story_container: gtk::Box, tx: Sender<Msg>) -> Header {
        let header = HeaderBar::new();
        let refresh_btn = gtk::Button::new_with_label("Refresh");
        refresh_btn.set_sensitive(false);
        header.pack_start(&refresh_btn);
        header.set_title("Hews - popular stories from hacker news");
        header.set_show_close_button(true);

        refresh_btn.connect_clicked(move |_| {
            for i in story_container.get_children().iter().skip(1) {
                story_container.remove(i);
            }
            tx.send(Msg::Refresh).unwrap();
        });

        Header {
            header,
            refresh_btn
        }
    }

    fn disable_refresh(&self) {
        self.refresh_btn.set_label("Loading");
        self.refresh_btn.set_sensitive(false);
    }

    fn enable_refresh(&self) {
        self.refresh_btn.set_label("Refresh");
        self.refresh_btn.set_sensitive(true);
    }
}

这个结构体包含以下字段:

  • header: 一个 gtk HeaderBar,类似于一个适合窗口标题栏的水平 gtk Box

  • refresh_btn: 一个用于按需重新加载故事的 gtk Button

Header 还有三个方法:

  • new: 这将创建一个新的 Header 实例。在 new 方法内部,我们创建一个新的 gtk HeaderBar,将其关闭按钮设置为显示,并添加一个标题。然后,我们创建一个刷新按钮,并使用 connect_clicked 方法将其附加到它上面,该方法接受一个闭包。在这个闭包内部,我们遍历滚动窗口容器的所有子项,这些子项作为 story_container 传递给此方法。然而,我们跳过了第一个,因为第一个小部件是一个 Spinner,我们希望它在多次重新加载之间保持显示其进度。

  • disable_refresh: 这将禁用刷新按钮,将其灵敏度设置为 false

  • enable_refresh: 这将启用刷新按钮,将其灵敏度设置为 true

接下来,让我们看看我们的 hackernews 模块,它负责从 API 端点获取故事作为 json 并使用 serde_json 解析为 Story 实例的所有繁重工作。以下是 hackernews.rs 的第一部分内容:

// hews/src/hackernews.rs

use crate::app::Msg;
use serde_json::Value;
use std::sync::mpsc::Sender;
use std::thread;
use serde_derive::Deserialize;

const HN_BASE_URL: &str = "https://hacker-news.firebaseio.com/v0/";

#[derive(Deserialize, Debug)]
pub struct Story {
    pub by: String,
    pub id: u32,
    pub score: u64,
    pub time: u64,
    pub title: String,
    #[serde(rename = "type")]
    pub _type: String,
    pub url: Option<String>,
    pub kids: Option<Value>,
    pub descendents: Option<u64>,
}

首先,我们有一个为托管在 Firebase 上的 hackernews API 声明的基 URL 端点 HN_BASE_URL。Firebase 是来自 Google 的实时数据库。然后,我们有 Story 结构体的声明,并带有 DeserializeDebug 特性的注解。Deserialize 特性来自 serde_derive 包,它提供了一个 derive 宏,可以将任何值转换为原生 Rust 类型。我们需要它,因为我们希望能够将来自网络的 json 响应解析为 Story 结构体。

Story 结构体包含与在 stories 端点返回的 json 响应中找到的相同字段。有关 json 结构的更多信息,请参阅 github.com/HackerNews/API#items。此外,在 Story 结构体的所有字段中,我们有一个名为 type 的字段。然而,type 也是 Rust 中用于声明类型别名的关键字,将 type 作为结构体的字段是不合法的,因此我们将它命名为 _type。但是,这不会解析为我们的 json 响应中的 type 字段。为了解决这种冲突,serde 为我们提供了一个字段级别的属性,允许我们在使用 #[serde(rename = "type")] 属性的字段上,即使在存在此类冲突的情况下也能解析值。rename 的值应与传入的 json 响应的字段名称中的值相匹配。接下来,让我们看看这个模块提供的方法集:

// hews/src/hackernews.rs

fn fetch_stories_parsed(client: &Client) -> Result<Value, reqwest::Error> {
    let stories_url = format!("{}topstories.json", HN_BASE_URL);
    let body = client.get(&stories_url).send()?.text()?;
    let story_ids: Value = serde_json::from_str(&body).unwrap();
    Ok(story_ids)
}

pub fn top_stories(client: Arc<Client>, count: usize, tx: &Sender<Msg>) {
    let tx_clone = tx.clone();
    thread::spawn(move || {
        let story_ids = fetch_stories_parsed(&client).unwrap();
        let filtered: Vec<&Value> = story_ids.as_array()
                                             .unwrap()
                                             .iter()
                                             .take(count)
                                             .collect();

        let loaded = !filtered.is_empty();

        for id in filtered {
            let id = id.as_u64().unwrap();
            let story_url = format!("{}item/{}.json", HN_BASE_URL, id);
            let story = client.get(&story_url)
                              .send()
                              .unwrap()
                              .text()
                              .unwrap();
            let story: Story = serde_json::from_str(&story).unwrap();
            tx_clone.send(Msg::NewStory(story)).unwrap();
        }

        if loaded {
            tx_clone.send(Msg::Loaded).unwrap();
        }
    });
}

这个模块公开的唯一函数是 top_stories。这个函数接受一个来自 reqwest 包的 Client 引用,然后是一个 count 参数,指定要检索的故事数量,以及一个 Sender 实例 tx,它可以发送类型为 Msg 的消息,Msg 是一个枚举。tx 用于将有关我们的网络请求状态的消息传达给 GUI 线程。最初,GUI 以 Msg::Loading 状态启动,这会禁用刷新按钮。

在这个函数中,我们首先克隆了我们的 tx 发送者,然后在一个线程中使用了这个 tx。我们创建一个线程是为了在网络请求进行时不会阻塞 UI 线程。在闭包中,我们调用 fetch_stories_parsed()。在这个方法中,我们首先使用 format! 宏将 /top_stories.json 端点与 HN_BASE_URL 连接起来。然后,我们向构建的端点发送请求以获取所有故事列表。我们调用 text() 方法将响应转换为 json 字符串。返回的 json 响应是一个包含故事 ID 的列表,每个 ID 都可以用来发起另一组请求,从而提供关于故事的详细信息,作为另一个 json 对象。然后我们使用 serde_json::from_str(&body) 解析这个响应。这给我们一个 Value 枚举值,它是一个解析后的 json 数组,包含故事 ID 列表。

因此,一旦我们将故事 ID 存储在story_ids中,我们就通过调用as_array()显式地将其转换为数组,然后对其iter()并调用take(count)来限制我们想要的条目数,最后调用collect(),这将返回一个Vec<Story>

        let story_ids = fetch_stories_parsed(&client).unwrap();
        let filtered: Vec<&Value> = story_ids.as_array()
                                             .unwrap()
                                             .iter()
                                             .take(count)
                                             .collect();

接下来,我们检查过滤后的故事 ID 是否为空。如果是,我们将loaded变量设置为false

       let loaded = !filtered.is_empty();

loaded布尔值用于在加载我们的任何故事时向主 GUI 线程发送通知。接下来,如果filtered列表不为空,我们遍历我们的filtered故事并构建一个story_url

        for id in filtered {
            let id = id.as_u64().unwrap();
            let story_url = format!("{}item/{}.json", HN_BASE_URL, id);
            let story = client.get(&story_url)
                              .send()
                              .unwrap()
                              .text()
                              .unwrap();
            let story: Story = serde_json::from_str(&story).unwrap();
            tx_clone.send(Msg::NewStory(story)).unwrap();
        }

我们对每个构造的story_url从故事id发起 GET 请求,获取 JSON 响应,并使用serde_json::from_str函数将其解析为Story结构体。之后,我们通过tx_clone将包装在Msg::NewStory(story)中的故事发送到 GUI 线程。

发送所有故事后,我们向 GUI 线程发送一个Msg::Loaded消息,这启用了刷新按钮,以便用户可以再次重新加载故事。

好吧!是时候在我们的应用程序上阅读流行的新闻故事了。运行cargo run后,我们可以在窗口中看到我们的故事被拉取并渲染:

图片

点击任何故事的链接时,它将在你的默认浏览器中打开。这就是全部。我们已经使用非常少的代码在 Rust 中制作了我们的 GUI 应用程序。现在,是时候探索和实验这个应用程序了。

练习

我们的应用程序运行得很好,但我们可以从很多方面来改进它。如果你有雄心壮志,可以查看以下挑战:

  • 改进应用程序的错误处理,通过添加重试机制来处理网络缓慢的情况。

  • 通过在标题栏上放置一个输入字段小部件来自定义要加载的故事数量,并将该数字解析并传递给网络线程。

  • 为每个故事添加一个按钮来查看评论。当用户点击评论按钮时,应用程序应在右侧打开一个可滚动的窗口并逐个填充该故事的评论。

  • 小部件可以使用 CSS 进行样式化。尝试使用gtk::StyleProvider API 根据帖子的流行度给故事容器添加颜色。

其他新兴的 GUI 框架

正如我们已经看到的,gtk 包暴露的 API 在编写复杂的 GUI 时可能会有些不舒服。幸运的是,我们有一个名为 relm 的包装包。relm 包受到了 Elm 语言 Model-View-Update 架构的启发,它为构建反应式 GUI 提供了一种简单的方法。除了 relm 之外,Rust 社区还在开发许多其他独立的 GUI 工具包和包。其中之一是新的、有希望的 Azul,可以在 azul.rs/ 找到。它是一个支持异步 I/O 的功能 GUI 框架,还具备双向数据绑定等特性,允许你构建反应式小部件,并采用组合原则来构建小部件,而不是我们在构建 hews 时在 gtk 框架中探索的对象模型。作为渲染后端,Azul 使用了 Mozilla 的 Servo 中使用的性能优异的 Webrender 渲染引擎。

其他值得注意的提及包括来自 Piston 开发者组织在 github.com/PistonDevelopers/conrodconrod,以及 github.com/Gekkio/imgui-rsimgui-rs,这是一个为流行的即时模式 ImGUI 框架在 C++ 中的绑定库。

摘要

这是对使用 Rust 进行 GUI 开发的简要概述。本章让我们窥见了当前开发 GUI 应用程序的经验。在撰写本文时,这种体验并不算很好,但有一些新兴的框架,例如 Azul,旨在将这种体验提升到新的水平。

下一章将介绍如何使用调试器查找和修复程序中的错误。

第十七章:调试

本章将介绍调试 Rust 程序的各种方法。在二进制层面,Rust 程序与 C 程序非常相似。这意味着我们可以利用行业标准调试器(如 gdb 和 lldb)的强大遗产,这些调试器用于调试 C/C++程序,并使用相同的工具来调试 Rust 代码。在本章中,我们将交互式地通过一些基本的调试工作流程和 gdb 命令。我们还将介绍将 gdb 调试器与Visual Studio Codevscode)编辑器集成,以及稍后简要概述另一个名为rr的调试器。

在本章中,我们将涵盖以下主题:

  • 调试简介

  • GDB 基础和 Rust 程序的调试

  • GDB 与 Visual Studio Code 的集成

  • RR 调试器——快速概述

调试简介

“如果调试是去除 bug 的过程,那么编程就必须是放置 bug 的过程。” —— Edsger W. Dijkstra

这里是情况:你的程序不工作,你也不知道为什么。为了修复代码中这个神秘的问题,你已经添加了几个打印语句并启用了跟踪日志,但仍然没有成功。不用担心,你不是一个人!每个程序员都曾经遇到过这种情况,并且花费了无数小时寻找那个导致生产混乱的讨厌的 bug。

软件中的错误和偏差被称为 bug,移除它们的行为称为调试。调试是检查软件中故障原因和结果的一种受控和系统化的方法。对于任何希望深入了解其程序行为和运行方式的开发者来说,这是一项必备的技能。然而,没有合适的工具,调试通常不是一项容易的任务,开发者可能会失去对实际 bug 的追踪,甚至可能在错误的地方寻找 bug。我们用于识别软件中 bug 的方法可以极大地影响解决它们所需的时间,并继续在愉快的路径上前进。根据 bug 的复杂程度,调试通常采用以下一种方法:

  • 打印行调试:在这种方法中,我们在代码中可能存在 bug 并可能修改应用程序状态的所需位置添加打印语句,并在程序运行时监控输出。这是简单、粗糙且通常有效的,但在某些情况下是不可能的。这项技术不需要额外的工具,每个人都知道如何做。实际上,这是大多数 bug 调试的起点。为了帮助打印行调试,Rust 提供了Debug特质,我们之前已经多次使用过,以及dbg!println!eprintln!宏系列。

  • 基于读取-评估-打印循环的调试:像 Python 这样的解释型语言通常自带自己的解释器。解释器为你提供了一个读取-评估-打印循环(REPL)界面,你可以将程序加载到交互会话中,并逐步检查变量的状态。这在调试中非常有用,特别是如果你已经正确地将代码模块化,使其可以作为函数独立调用。不幸的是,Rust 没有官方的 REPL,其整体设计也不支持 REPL。然而,miri 项目在这方面做了一些努力,该项目可以在github.com/solson/miri找到。

  • 调试器:采用这种方法,我们在生成的二进制程序中添加特殊的调试符号,并使用外部程序来监控其执行。这些外部程序被称为调试器,其中最流行的是 gdb 和 lldb。它们是调试中最强大和高效的方法,允许你在程序运行时检查大量关于程序细节的信息。调试器让你能够暂停正在运行的程序,并检查其在内存中的状态,以找出引入错误的特定代码行。

前两种方法相当明显,所以我们在这里不需要详细说明。这让我们只剩下了第三种方法:调试器。作为工具,调试器非常容易使用,但它们并不容易理解,并且通常在程序员职业生涯的早期并没有得到适当的介绍。在下一节中,我们将通过一步一步的过程来调试用 gdb 编写的 Rust 程序。但在那之前,让我们先了解一下调试器。

调试器概述

调试器是可以在程序运行时检查程序内部状态的程序,前提是程序已被编译,包括调试符号。它们依赖于进程内省系统调用,如 Linux 中的ptrace。它们允许你在程序运行时暂停程序的执行。为了实现这一点,它们提供了一个名为断点的功能。断点代表运行程序中的暂停点。可以在程序中的任何函数或代码行上设置断点。一旦调试器遇到断点,它就会暂停并等待用户输入进一步的指令。此时,程序没有运行,正处于执行过程中。在这里,你可以检查变量的状态、程序的活动堆栈帧以及其他如程序计数器和汇编指令等事物。调试器还提供了观察点,它们类似于断点,但作用于变量。当变量被读取或写入时,它们会触发并停止执行。

要在程序上使用调试器,我们需要一些先决条件。让我们接下来讨论它们。

调试前的准备工作

编译后的程序或对象文件是一系列零和一,没有从编译它的原始源代码映射。为了使程序能够被调试器检查,我们需要以某种方式将编译的二进制指令映射到源文件。这是通过在编译过程中注入额外的记账符号和仪器代码来完成的,调试器可以随后锁定这些符号。这些符号保存在一个符号表中,其中包含有关程序元素的信息,例如变量、函数和类型的名称。它们遵循一个称为 带有属性记录格式的调试DWARF)的标准格式,大多数标准调试器都知道如何解析和理解。这些符号使开发者能够检查程序,例如将源代码与运行的二进制文件匹配,保持调用帧、寄存器值和程序内存映射等信息。

要调试我们的程序,我们需要以调试模式编译它。在调试模式下,编译的二进制文件将包含 DWARF 调试格式的调试符号。在这里,二进制文件的大小略有增加,运行速度也较慢,因为它需要在运行时更新调试符号表。当编译 C 程序时,您需要使用 -g 标志来告诉编译器包含调试符号进行编译。使用 Cargo 时,项目的调试构建默认在 target/debug/ 目录下编译,并包含调试符号。

注意:当使用除 Cargo 之外的其他包管理器时,也可以向 rustc 传递 -g 标志。

对发布构建运行调试器也是可能的,但可用的操作选择非常有限。如果您想在发布构建中启用 DWARF 调试符号,可以在 Cargo.toml 中通过修改 profile.release 部分,如下所示:

[profile.release]
debug = true

话虽如此,让我们深入探讨如何设置 gdb。

设置 gdb

要开始使用 gdb,我们首先需要安装它。通常,Linux 系统上默认安装了它,但如果未安装,请参考互联网上的指南来设置您的机器。在 Ubuntu 上,只需运行一个安装命令,例如 apt-get install gdb。在这里,我们将使用 7.11.1 版本的 gdb 进行演示。

虽然 gdb 对 Rust 程序有惊人的支持,但它并没有正确处理一些 Rust 特有的问题,例如获取更整洁的输出。Rust 工具链 rustup 还为 gdb 和 lldb 调试器安装了包装器,分别是 rust-gdbrust-lldb。这样做是为了解决处理 Rust 代码的一些限制,例如为混淆类型获取更整洁的输出以及一些用户定义类型的格式化打印。让我们来探索如何使用 gdb 调试 Rust 程序。

一个示例程序 - buggie

我们需要一个程序来调试以体验 gdb。让我们通过运行cargo new buggie来创建一个新的项目。我们的程序将有一个单独的函数fibonacci,它接受一个usize类型的n位置,并给出第n个斐波那契数。这个函数假设斐波那契数的初始值是01。以下代码是整个程序的代码:

1 // buggie/src/main.rs
2 
3 use std::env;
4 
5 pub fn fibonacci(n: u32) -> u32 {
6     let mut a = 0;
7     let mut b = 1;
8     let mut c = 0;
9     for _ in 2..n {
10        let c = a + b;
11        a = b;
12        b = c;
13    }
14    c
15 }
16 
17 fn main() {
18     let arg = env::args().skip(1).next().unwrap();
19     let pos = str::parse::<u32>(&arg).unwrap();
20     let nth_fib = fibonacci(pos);
21     println!("Fibonacci number at {} is {}", pos, nth_fib);
22 }

让我们试运行这个程序:

我们用4作为参数运行了程序,但我们看到输出是0,而应该是3。这里有一个错误。虽然我们可以使用println!dbg!宏轻松解决这个问题,但这次我们将使用 gdb。

在我们运行 gdb 之前,我们需要规划我们的调试会话。这包括决定在程序中查看哪些部分以及要查找什么。作为一个起点,我们将检查main函数的内容,然后进入fibonacci函数。我们将设置两个断点,一个在main中,另一个在fibonacci内部。

gdb 基础知识

我们将再次运行我们的程序,这次使用 gdb 通过rust-gdb包装器运行rust-gdb --args target/debug/buggie 4--args标志用于将参数传递给程序。这里我们传递了数字4。以下是 gdb 的输出:

在加载我们的程序后,gdb 将我们带到(gdb)提示符。在这个时候,程序还没有运行——它只是被加载了。让我们快速查看 gdb 功能的范围。尝试使用help命令(显示命令的高级部分)和help all命令(显示所有可用命令的帮助信息):

好吧,所以看起来 gdb 可以做很多事情:这里有32页的命令。接下来,让我们在 gdb 提示符上调用run来运行程序并查看结果:

这就是我们的程序在 gdb 上下文中的运行方式。正如你所见,中间部分,我们得到了第四个斐波那契数的相同错误输出0。我们现在将调试这个问题。通过按Ctrl + L来清除屏幕。我们也可以通过调用q来退出 gdb,然后通过运行rust-gdb --args target/debug/buggie 4来重新开始。

在我们的调试会话的起点,为了查看我们是否向斐波那契函数传递了正确的数字,我们将在main的开始处添加一个断点,即我们程序中的第18行。为了在该行添加断点,我们将运行以下代码:

(gdb) break 18

这给我们以下输出:

Breakpoint 1 at 0x9147: file src/main.rs, line 18.

现在 gdb 已经在我们请求的同一行设置了断点,即18。让我们通过调用run来运行我们的程序:

(gdb) run

这给我们以下输出:

(gdb) run
Starting program: /home/creativcoder/buggie/target/debug/buggie 4
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, buggie::main::h8018d7420dbab31b () at src/main.rs:18
18        let arg = env::args().skip(1).next().unwrap();
(gdb)

现在我们的程序在断点处暂停,等待其下一条指令。

你会看到 Rust 的符号前面带有它们的模块名,后面跟着一些随机 ID,例如,buggie::main::h8018d7420dbab31。现在,为了查看我们在程序中的位置,我们可以运行list命令来查看源代码,或者我们可以通过运行以下代码来使用更直观的 TUI 模式:

(gdb) tui enable

这将打开 gdb,并有一些不错的反馈,同时我们的命令提示符仍然在底部:

如你所见,TUI 指示我们在第 18 行有一个断点,符号为B+>。我们可以通过 TUI 面板中的代码列表来滚动查看我们的整个源代码。

注意:如果 TUI 屏幕渲染不正确,你可以输入refresh并重新绘制面板和代码列表。

现在,我们将逐行通过我们的程序。为此,我们有两个可用的命令:nextstep。第一个是逐行运行程序,而step允许你跳入函数并逐行执行其中的指令。我们想使用next,这将带我们到下一行 19,而不是进入 Rust 标准库 API 调用的细节。运行以下代码:

(gdb) next

在达到斐波那契函数之前,我们还需要做两次这样的操作。我们可以通过在键盘上按Enter键来运行最后一个命令。在这种情况下,按Enter键两次将运行下一行代码。现在,我们就在斐波那契调用的前面:

在我们进入斐波那契函数之前,让我们检查pos变量,确保它不是一些垃圾或0。我们可以使用print命令来做这件事:

(gdb) print pos
$1 = 4
(gdb) 

好的,我们的pos是正确的。现在,我们处于第 20 行,正好在我们调用fibonacci之前。现在,使用step命令进入fibonacci函数:

(gdb) step

我们现在在第 6 行:

接下来,让我们逐行执行代码。当我们正在斐波那契函数中逐行执行代码时,我们可以使用info localsinfo args命令来检查变量:

(gdb) info locals
iter = Range<u32> = {start = 3, end = 4}
c = 0
b = 1
a = 1
(gdb) info args
n = 4
(gdb) 

前面的输出显示了第三次迭代的iter变量。下一行显示了函数中使用的所有其他变量。我们可以看到,在每次迭代中,c变量都被重新赋值为0。这是因为我们有let c = a + b;,它遮蔽了在循环外部声明的c变量。Rust 允许你使用相同的名称重新声明一个变量。我们在这里找到了我们的错误。

我们将通过删除c的重新声明来移除我们的错误。斐波那契函数变为以下形式:

pub fn fibonacci(n: u32) -> u32 {
    let mut a = 0;
    let mut b = 1;
    let mut c = 0;
    for _ in 2..n {
        c = a + b;
        a = b;
        b = c;
    }
    c
}

因此,让我们再次运行这个程序。这次,我们将不带 gdb 调试器运行它:

现在我们得到了正确的输出,即第四个斐波那契数的2。这些都是使用 gdb 调试 Rust 代码的基本知识。

与 gdb 类似,lldb 是另一个与 Rust 兼容的调试器。

接下来,让我们看看如何将 gdb 与代码编辑器集成。

Visual Studio Code 的调试器集成

使用命令行中的调试器是调试程序的一种常见方式。这也是一项重要的技能,因为您可能会遇到更高级的编码平台不可用的情况。例如,您可能需要调试一个已经在生产中运行的程序。使用 gdblldb 都可以附加到正在运行的进程,但您可能无法从您的编辑器中附加到正在运行的程序。

然而,在典型的开发环境设置中,您会使用代码编辑器或 IDE,如果您可以直接从编辑器调试程序而不离开编辑器,这将非常方便。这样,您将获得一个与调试器正确集成的编辑器提供的更流畅的调试体验和更快的反馈循环。在本节中,我们将探讨如何将 gdb 集成到 vscode 中。

要在 vscode 中设置 gdb,我们需要安装 Native Debug 扩展。在打开我们的 vscode 编辑器后,我们将按 Ctrl + Shift + P 并输入 install extension。或者,您可以选择左下角的扩展图标,如图所示,并输入 native debug。通过这样做,我们将获得 Native Debug 扩展的页面:

图片

我们将点击安装并等待安装完成。一旦扩展被安装,我们将点击重新加载以重新启动 Visual Studio Code。这将启用任何新安装的扩展。接下来,我们将在 vscode 中打开我们的 buggie 目录,点击顶部的 调试 菜单,并选择 开始调试,如图所示:

图片

从这里开始,我们将被要求选择一个环境,如下所示:

图片

NativeDebug 扩展支持 gdb 和 lldb。我们将从这个菜单中选择 gdb。这将打开一个新的 launch.json 配置文件,用于配置本项目的调试会话。它位于同一项目根目录下的 .vscode/ 目录中。如果它没有打开,我们可以手动创建一个包含 launch.json.vscode/ 目录。我们将用以下配置填充这个 launch.json

// buggie/.vscode/launch.json

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Buggie demo",
            "type": "gdb",
            "request": "launch",
            "target": "target/debug/buggie",
            "cwd": "${workspaceRoot}",
            "arguments": "4",
            "gdbpath": "rust-gdb",
            "printCalls": true
        }
    ]
}

launch.json 文件为 gdb 和 vscode 设置了重要的详细信息,例如要调用的目标和使用参数。在其他大多数情况下,其他字段将自动为您填充。我们项目的特定配置如下:

  • 我们给我们的配置添加了一个名称,即 "Buggie demo"

  • 我们添加了一个指向 rust-gdbgdbpath 变量。这将通过 rust-gdb 包装器启动 gdb,该包装器知道如何在 Rust 中格式化打印复杂的数据类型。

  • 我们将 target 字段指向我们的调试二进制文件,即 target/debug/buggie

我们将保存此文件。接下来,让我们在编辑器中为我们的程序添加一个断点。我们可以通过在 vscode 中的文本区域左侧单击来实现,如下面的截图所示:

图片

在前面的截图中,如果您将鼠标悬停在左侧,您将看到一个淡红色的标记出现。我们可以点击此标记的左侧来设置断点,然后它将显示为红色的小点。一旦我们完成设置,我们将按 F5 键在 vscode 中启动 gdb。一旦 gdb 运行并遇到我们的断点,我们的代码编辑器将看起来像这样:

图片

在顶部中心,我们可以看到常用的控件,例如单步执行、进入或暂停/停止代码中的调试。在左侧面板中,我们可以获取有关当前堆栈帧中变量的信息。在左下角,我们有关于我们的调用堆栈的信息。在左侧中间,我们可以监视任何变量。在上面的代码中,我添加了对我们的 c 变量的监视。

程序现在暂停在第 9 行。我们还可以将鼠标悬停在代码中的变量上以查看它们的值,如下面的截图所示:

图片

这非常有帮助。这些都是使用 vscode 与 gdb 配合使用的基础知识。接下来,让我们简要概述另一个在调试多线程代码时非常有用的调试器:RR 调试器。

RR 调试器 – 快速概述

除了 gdb 和 lldb,rr 是另一个在调试多线程代码时非常有用的强大调试器,由于它的非确定性,调试起来比较困难。通常,在调试多线程代码时,某个代码段会触发一个错误,但在程序后续执行中无法重现。

由于多线程代码引起的错误也被称为海森堡错误。

RR 调试器可以为非确定性的多线程代码执行可重现的调试会话。它是通过记录调试会话来实现的,您可以回放并逐步跟踪以缩小问题范围。它是通过首先将程序的跟踪保存到磁盘上,并包含所有重现程序执行所需的信息来做到这一点的。

RR 的一个限制是,目前它只支持 Linux 和 Intel CPU。要在 Ubuntu 16.04 上设置 RR 调试器,我们将从 github.com/mozilla/rr/releases 拉取最新的 .deb 软件包。在撰写本文时,rr 版本为 5.2.0。下载完 deb 软件包后,我们可以通过运行以下代码来安装 rr:

sudo dpkg -i https://github.com/mozilla/rr/releases/download/5.2.0/rr-5.2.0-Linux-x86_64.deb

注意:安装 perf 工具是先决条件。你可以按照以下步骤安装它们:

sudo apt-get install linux-tools-4.15.0-43-generic

根据您的内核版本,将linux-tools-(version)替换为适用版本。您可以在 Linux 上使用uname -a命令获取内核版本。另一个先决条件是将sysctl标志在perf_event_paranoid中从3更改为-1。建议您通过运行以下代码临时设置此标志:

sudo sysctl -w kernel.perf_event_paranoid=-1

完成这些后,让我们快速通过运行cargo new rr_demo来创建一个新的项目,并使用 rr 进行调试会话。我们将探索如何使用 rr 调试器对示例程序进行调试,该程序演示了非确定性。我们将依赖rand包,我们可以通过运行cargo add rand将其添加到Cargo.toml文件中。在我们的main.rs文件中有以下代码:

// rr_demo/src/main.rs

use rand::prelude::*;

use std::thread;
use std::time::Duration;

fn main() {
    for i in 0..10 {
        thread::spawn(move || {
            let mut rng = rand::thread_rng();
            let y: f64 = rng.gen();
            let a: u64 = rand::thread_rng().gen_range(0, 100); 
            thread::sleep(Duration::from_millis(a));
            print!("{} ", i);
        });
    }
    thread::sleep(Duration::from_millis(1000));
    println!("Hello, world!");
}

这是一个最小化、非确定性的程序,它启动10个线程并将它们打印到标准输出。为了突出 rr 的可重复性方面,我们将启动线程并随机休眠。

首先,我们需要使用 rr 记录程序的执行。这是通过运行以下代码完成的:

rr record target/debug/rr_demo

这给我们以下输出:

图片

在我的机器上,程序执行跟踪被记录并存储在以下位置:

rr: Saving execution to trace directory `/home/creativcoder/.local/share/rr/rr_demo-15'

记录的文件,rr_demo-15,在您的机器上可能被命名为不同的名称。现在我们可以通过运行以下代码来重新播放记录的程序:

rr replay -d rust-gdb /home/creativcoder/.local/share/rr/rr_demo-15

以下是在 rr 下运行 gdb 的会话:

图片

如您所见,每次运行程序时都会打印出相同的数字序列,因为程序是从上一次运行中记录的会话中运行的。这有助于调试多线程程序,其中线程运行顺序混乱,并且您下次运行程序时可能无法重现错误。

摘要

在本章中,我们通过使用 GNU 的gdb和现有的调试器来手动调试 Rust 代码。我们还配置了 gdb 与 vscode,为我们提供了一个方便的基于点击的 UI 来调试我们的代码。最后,我们了解到了 rr 调试器如何使多线程代码的调试变得确定性。

通过这种方式,我们结束了使用 Rust 的编程之旅。

posted @ 2025-09-06 13:43  绝不原创的飞龙  阅读(42)  评论(0)    收藏  举报