写给-Rust-爱好者的-Rust-指南-全-

写给 Rust 爱好者的 Rust 指南(全)

原文:zh.annas-archive.org/md5/bdfbf93593c5b14783ecf008a8b9f4e0

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在任何语言中,入门教材教授的内容和你通过多年的实践经验积累的知识之间,总是存在着巨大的差距。随着时间的推移,你会逐渐熟悉各种习惯用语,建立起更好的核心概念的思维模型,了解哪些设计和模式有效,哪些无效,并发现周边生态中的有用库和工具。综上所述,这些经验使你能够在更短的时间内编写出更好的代码。

本书旨在将我多年来编写 Rust 代码的经验浓缩成一本易于消化的资源。Rust for Rustaceans 是在 The Rust Programming Language(“Rust 书籍”)的基础上继续深入的内容,尽管它非常适合任何希望超越基础知识的 Rust 程序员,无论你在哪里学习这门技术。本书深入探讨了像是不安全代码、特性系统、no_std 代码和宏等概念,还涉及了新的领域,如异步 I/O、测试、嵌入式开发和人体工程学的 API 设计。我旨在解释并解开这些更高级且强大的 Rust 特性的神秘面纱,帮助你构建更快、更符合人体工学且更健壮的应用程序。

本书内容

本书既是一本指南,也是一本参考书。各章内容基本独立,因此你可以直接跳到特别感兴趣的(或当前令你头疼的)主题,或者从头到尾阅读,获得更全面的体验。话虽如此,我还是建议你从第一章和第二章开始阅读,因为它们为后续章节和你日常 Rust 开发中会遇到的许多主题奠定了基础。以下是每一章内容的简要概述:

  1. 第一章,基础,对 Rust 中的基本概念进行了更深入、更全面的描述,包括变量、内存、所有权、借用和生命周期等,你需要熟悉这些内容才能继续阅读本书的其他部分。

  2. 第二章,类型,同样提供了关于 Rust 类型和特性的更详尽的解释,包括编译器如何推理它们、它们的特性和限制,以及许多高级应用。

  3. 第三章,设计接口,讨论如何设计直观、灵活且能防止误用的 API,包括如何命名事物、如何利用类型系统来强制执行 API 合约,以及何时使用泛型与特性对象。

  4. 第四章,错误处理,探讨了两种主要的错误类型(枚举型和不透明型),何时使用每种错误类型,以及如何定义、构造、传播和处理这些错误。

  5. 第五章,项目结构,聚焦于 Rust 项目中非代码部分,如 Cargo 元数据和配置、crate 特性以及版本控制。

  6. 第六章,测试,详细讲解了标准 Rust 测试工具的工作原理,并介绍了一些超越标准单元测试和集成测试的测试工具和技术,如模糊测试和性能测试。

  7. 第七章,,介绍了声明式宏和过程式宏,包括它们的编写方法、用途以及一些陷阱。

  8. 第八章,异步编程,介绍了同步与异步接口的区别,然后深入探讨了 Rust 中如何表示异步性,既包括低层次的FuturePin,也包括高层次的asyncawait。本章还解释了异步执行器的角色,以及它如何让整个异步机制协同工作。

  9. 第九章,不安全代码,解释了unsafe关键字所解锁的强大能力,以及伴随而来的巨大责任。你将了解不安全代码中的常见陷阱,并学会使用一些工具和技术来降低编写不正确不安全代码的风险。

  10. 第十章,并发(与并行),探讨了 Rust 中并发的表现形式,以及为什么在正确性和性能上做到并发编程是如此困难。它讲解了并发和异步的关系(但并非完全相同),当你更接近硬件时并发是如何工作的,以及如何保持理智,尽量编写正确的并发程序。

  11. 第十一章,外部函数接口,讲解了如何让 Rust 与其他语言良好合作,以及像extern关键字这样的 FFI 原语到底是做什么的。

  12. 第十二章,没有标准库的 Rust,讲述了在没有完整标准库的情况下使用 Rust 的情况,例如在嵌入式设备或其他受限平台上,这些地方只提供corealloc模块。

  13. 第十三章,Rust 生态系统,并没有涵盖某个特定的 Rust 主题,而是旨在提供关于如何在 Rust 生态系统中工作的广泛指导。它包含了常见设计模式的描述、关于保持语言更新和最佳实践的建议、关于有用工具的技巧以及我多年来积累的其他有用小知识,这些内容在任何单一地方都没有详细描述。

本书有一个网站,rust-for-rustaceans.com,提供书中的资源链接、未来的勘误等信息。你还可以在本书的页面上找到相关信息,网址是nostarch.com/rust-rustaceans/

现在,所有的内容已经讲完,剩下的只有一件事要做:

fn main() {

第一章:基础

当你深入探索 Rust 的更高级内容时,确保你对基础知识有扎实的理解非常重要。像任何编程语言一样,在你开始以更复杂的方式使用 Rust 时,各种关键字和概念的精确定义变得至关重要。在本章中,我们将逐步讲解 Rust 的许多基本概念,并尽可能清晰地定义它们的含义、工作原理以及为什么它们是以这种方式存在的。具体来说,我们将探讨变量和值的区别、它们在内存中的表示方式以及程序所拥有的不同内存区域。接着,我们将讨论一些所有权、借用和生命周期的细微之处,这些都是你在继续阅读本书之前需要掌握的内容。

如果你愿意,你可以从头到尾阅读本章,或者将其作为参考,回顾你不太确定的概念。我建议你在完全理解本章内容之前,不要急于继续,因为对这些基本概念的误解会迅速阻碍你理解更高级的主题,或者导致你错误地使用它们。

讨论内存

并非所有内存都是相同的。在大多数编程环境中,你的程序可以访问栈、堆、寄存器、文本段、内存映射寄存器、内存映射文件,甚至可能是非易失性 RAM。你选择在特定情况下使用哪种内存区域,会影响你能在那里存储什么、它能保持可访问的时间以及你如何访问它。不同平台之间这些内存区域的具体细节有所不同,超出了本书的范围,但有些内存区域对于你理解 Rust 代码的方式非常重要,值得在这里进行讨论。

内存术语

在我们深入了解内存区域之前,你需要先了解值、变量和指针之间的区别。Rust 中的一个是类型和该类型值域中的元素的组合。一个值可以通过其类型的表示转换为字节序列,但单独来看,你可以把值理解为你作为程序员所表示的东西。例如,类型为 u8 的数字 6 是数学整数 6 的一个实例,其在内存中的表示是字节 0x06。类似地,字符串 str "Hello world" 是所有字符串的一个值,其表示是其 UTF-8 编码。一个值的含义与这些字节存储的位置无关。

一个值被存储在一个位置中,这个术语在 Rust 中指的是“可以存储值的位置”。这个位置可以位于栈上、堆上或其他多个地方。存储值最常见的位置是一个变量,它是栈上一个命名的值槽。

指针是一个值,它保存内存区域的地址,因此指针指向一个位置。指针可以被解引用以访问它所指向的内存位置中存储的值。我们可以将相同的指针存储在多个变量中,从而让多个变量间接引用内存中的同一位置,因此引用相同的底层值。

请参考示例 1-1 中的代码,它展示了这三个元素。

let x = 42;
let y = 43;
let var1 = &x;
let mut var2 = &x;
1 var2 = &y;

示例 1-1:值、变量和指针

在这里,有四个不同的值:42(一个i32),43(一个i32),x的地址(一个指针),以及y的地址(一个指针)。也有四个变量:xyvar1var2。后两个变量都保存指针类型的值,因为引用是指针。虽然var1var2最初存储相同的值,但它们存储的是该值的独立副本;当我们改变var2中存储的值时,var1中的值不会改变。特别地,=运算符将右侧表达式的值存储在左侧命名的地方。

一个有趣的例子,展示了变量、值和指针之间的区别何时变得重要,出现在如下语句中:

let string = "Hello world";

即使我们将一个字符串值赋给变量string,该变量的实际值是指向字符串值"Hello world"中第一个字符的指针,而不是字符串值本身。此时你可能会说,“等等,那字符串值到底存储在哪里?指针指向哪里?”如果是这样,你的眼光非常敏锐——我们马上就会讲到这个问题。

变量深入解析

我之前给出的变量定义比较宽泛,单独来看可能并不太有用。随着你遇到更复杂的代码,你将需要一个更准确的思维模型,帮助你推理程序到底在做什么。我们可以利用许多这样的模型。详细描述它们会占用几章内容,超出了本书的范围,但广义上来说,它们可以分为两类:高级模型和低级模型。高级模型适用于从生命周期和借用的角度思考代码,而低级模型则适合在你推理不安全代码和原始指针时使用。以下两节中描述的变量模型足以涵盖本书中的大部分内容。

高级模型

在高级模型中,我们不把变量看作存储字节的地方。相反,我们把它们看作是赋值时被赋予名称的值,这些值在程序中被实例化、移动并使用。当你将一个值赋给一个变量时,从此该变量就会“命名”这个值。当后续访问变量时,你可以想象从变量的前次访问到新的访问之间画一条线,这样就建立了两个访问之间的依赖关系。如果变量中的值被移动,则不再能够从其访问绘制任何线条。

在这个模型中,一个变量仅在它持有一个合法的值时存在;你不能从一个未初始化或已被移动的变量绘制线条,所以实际上它就不存在了。使用这个模型,你的整个程序由许多这样的依赖线条组成,这些线条通常被称为,每一条都追踪一个特定值实例的生命周期。流可以在分支处分叉和合并,每个分支追踪该值的不同生命周期。编译器可以检查在程序的任何给定点,所有可能并行存在的流是否兼容。例如,不能有两个并行的流同时对一个值有可变访问;也不能有一个流借用一个值,而没有一个流拥有该值。Listing 1-2 显示了这两种情况的示例。

let mut x;
// this access would be illegal, nowhere to draw the flow from:
// assert_eq!(x, 42);
1 x = 42;
// this is okay, can draw a flow from the value assigned above:
2 let y = &x;
// this establishes a second, mutable flow from x:
3 x = 43;
// this continues the flow from y, which in turn draws from x.
// but that flow conflicts with the assignment to x!
4 assert_eq!(*y, 42);

Listing 1-2:借用检查器会捕获的非法流

首先,我们不能在x初始化之前使用它,因为我们无法从任何地方绘制流。只有在我们为x赋值时,才能从它绘制流。这个代码有两个流:一个独占(&mut)流从 1 到 3,和一个共享(&)流从 1 经过 2 到 4。借用检查器会检查每个流的每个节点,确保没有其他不兼容的流并行存在。在这种情况下,当借用检查器检查 3 处的独占流时,它看到终止于 4 的共享流。由于不能同时对一个值有独占和共享的使用,借用检查器(正确地)拒绝了这段代码。注意,如果 4 不在那里,这段代码会正常编译!共享流将在 2 处终止,而当独占流在 3 处被检查时,不会有冲突的流存在。

如果声明一个新变量,且该变量与先前的变量同名,它们仍然被视为不同的变量。这被称为遮蔽——后声明的变量“遮蔽”了前一个变量。两个变量共存,尽管后续代码不再有方法访问前一个变量。这一模型大致匹配编译器的工作方式,特别是借用检查器如何推理程序,并且实际上在编译器内部用于生成高效的代码。

低级模型

变量命名内存位置,这些位置可能包含合法值,也可能不包含。你可以把变量看作是一个“值槽”。当你给它赋值时,这个槽就被填满,原来的值(如果有的话)会被丢弃并被新值替换。当你访问它时,编译器会检查这个槽是否为空,因为如果为空,意味着变量未初始化或者其值已经被移动。指向变量的指针指向该变量的后备内存,可以解引用来获取其值。例如,在语句let x: usize中,变量x是栈上一个内存区域的名字,该区域足够存储一个usize类型的值,但该值没有明确的定义(槽为空)。如果你给这个变量赋值,比如x = 6,那块内存区域就会存储表示值6的字节。&x在你给x赋值时不会改变。如果你声明多个同名变量,它们最终会有不同的内存块作为后备存储。这个模型与 C 和 C++以及许多其他低级语言使用的内存模型相匹配,并且在需要明确推理内存时非常有用。

你可能会发现其中某个模型比另一个更符合你之前的理解,但我鼓励你尝试理解这两者。它们都是有效的简化模型,就像任何有用的思维模型必须具备的特点一样。如果你能够从这两种视角考虑一段代码,你会发现解决复杂的代码段变得更加容易,也能更好地理解为什么代码能够或不能按预期编译和运行。

内存区域

现在你已经掌握了我们如何引用内存的方式,接下来需要讨论内存到底是什么。内存有许多不同的区域,或许让你惊讶的是,并非所有的内存都存储在计算机的 DRAM 中。你使用的内存部分会对你编写代码的方式产生重大影响。在编写 Rust 代码时,三个最重要的内存区域是栈、堆和静态内存。

是程序用于函数调用时的临时内存空间。每当调用一个函数时,栈顶会分配一个连续的内存块,称为。栈底靠近的地方是main函数的帧,而随着函数调用其他函数,更多的帧会被压入栈中。一个函数的帧包含该函数内所有变量以及该函数接收的任何参数。当函数返回时,它的栈帧会被回收。

组成函数局部变量值的字节不会立即被清除,但访问它们并不安全,因为它们可能已被后续函数调用覆盖,这些函数的帧可能与被回收的帧重叠。即使它们没有被覆盖,它们也可能包含非法的值,例如在函数返回时被移动的值。

栈帧,特别是它们最终消失这一事实,和 Rust 中的生命周期概念密切相关。存储在栈帧中的任何变量,在该栈帧消失后都不能再访问,因此任何对它的引用必须具有一个生命周期,这个生命周期最多和栈帧的生命周期一样长。

是一块内存池,它与程序当前的调用栈无关。堆内存中的值会一直存在,直到它们被显式地释放。当你希望一个值的生命周期超出当前函数的作用域时,堆内存就非常有用。如果这个值是函数的返回值,调用函数可以在其栈上留出一些空间,让被调用的函数在返回之前将这个值写入其中。但如果你希望将这个值发送到一个与当前线程可能没有任何栈帧共享的线程中,你可以将它存储在堆上。

堆允许你显式地分配连续的内存段。当你这样做时,你会得到指向该内存段开始位置的指针。该内存段会被保留,直到你稍后显式释放它;这个过程通常被称为释放,这是 C 标准库中相应函数的名称。由于堆内存的分配不会在函数返回时消失,你可以在一个地方分配内存,将指针传递给另一个线程,并让那个线程安全地继续操作该值。换句话说,当你在堆上分配内存时,得到的指针具有不受限制的生命周期——它的生命周期长到程序决定将它保持活跃为止。

在 Rust 中与堆交互的主要机制是 Box 类型。当你写 Box::new(value) 时,值会被放置到堆上,而你得到的返回值(Box<T>)是指向该堆上值的指针。当 Box 最终被丢弃时,这块内存会被释放。

如果你忘记释放堆内存,它将永远存在,你的应用程序最终会占满机器上的所有内存。这被称为内存泄漏,通常是我们要避免的。然而,也有一些情况你可能故意想要发生内存泄漏。例如,假设你有一个只读的配置,程序中的每个部分都应该能够访问它。你可以将其分配到堆上,并通过 Box::leak 显式地泄漏它,从而获得对其的 'static 引用。

静态内存

静态内存 实际上是一个统称,指的是位于你程序编译文件中的多个密切相关的区域。这些区域在程序执行时会自动加载到你的程序内存中。静态内存中的值会在程序执行期间一直存在。你的程序的静态内存包含了程序的二进制代码,这些代码通常映射为只读。当你的程序执行时,它会逐条指令地遍历二进制代码,并在每次调用函数时跳转。静态内存还保存你使用static关键字声明的变量的内存,以及你代码中的某些常量值,如字符串。

特殊生命周期'static,其名称来自静态内存区域,标记一个引用有效的时间为“只要静态内存存在”,即直到程序关闭。由于静态变量的内存在程序启动时分配,指向静态内存中变量的引用,按定义就是'static,因为它不会被释放直到程序关闭。反之则不然——可能会有指向非静态内存的'static引用——但这个名称仍然是合适的:一旦你创建了一个具有静态生命周期的引用,无论它指向什么,其他部分的程序都认为它好像指向了静态内存,因为它可以在程序需要的任何时长内使用。

当你在 Rust 中工作时,你会比遇到真正的静态内存(例如通过static关键字)更常遇到'static生命周期。这是因为'static通常出现在类型参数的 trait 约束中。像T: 'static这样的约束表示类型参数T能够存在多长时间,我们保留它,直到程序执行结束。实质上,这个约束要求T是拥有者且自给自足的,要么它不借用其他(非静态)值,要么它借用的任何东西也是'static的,因此会一直存在直到程序结束。一个关于'static作为约束的好例子是std::thread::spawn函数,它用于创建一个新线程,这要求你传递的闭包是'static的。由于新线程可能会比当前线程存活得更久,它不能引用任何存储在旧线程栈上的东西。新线程只能引用那些会在其整个生命周期内存在的值,这些值可能会持续到程序结束。

所有权

Rust 的内存模型以所有值都有一个单一的所有者为核心——即,恰好有一个位置(通常是作用域)负责最终释放每个值的内存。这通过借用检查器来强制执行。如果值被移动,比如通过将其赋值给一个新变量、将其推送到一个向量中或放到堆上,那么值的所有权从旧位置转移到新位置。此时,你不能再通过原始所有者流出的变量访问该值,即使构成该值的位在技术上仍然存在。相反,你必须通过引用其新位置的变量来访问已移动的值。

有些类型是叛逆者,不遵循这个规则。如果一个值的类型实现了特殊的Copy特征,那么即使它被重新分配到新的内存位置,也不会被视为已经移动。相反,值会被复制,旧的和新的位置都可以访问。本质上,另一个相同的值实例会在移动的目标位置被构造出来。Rust 中的大多数原始类型,如整数和浮点类型,都是Copy类型。为了成为Copy,必须能够通过简单地复制它们的位来复制类型的值。这排除了所有包含Copy类型的类型,以及任何拥有必须在值被丢弃时释放的资源的类型。

为了理解原因,考虑一下如果像 Box 这样的类型是 Copy 会发生什么。如果我们执行 box2 = box1,那么 box1box2 都会认为它们拥有为 box 分配的堆内存,当它们超出作用域时,它们都会尝试释放这段内存。两次释放内存可能会导致灾难性的后果。

当一个值的所有者不再需要它时,清理这个值的责任就落在所有者身上,丢弃它。在 Rust 中,当持有值的变量不再在作用域内时,丢弃会自动发生。类型通常会递归地丢弃它们包含的值,因此丢弃一个复杂类型的变量可能会导致多个值被丢弃。由于 Rust 的显式所有权要求,我们不能意外地多次丢弃同一个值。一个持有对另一个值引用的变量并不拥有该值,因此当该变量被丢弃时,该值不会被丢弃。

清单 1-3 中的代码简要总结了有关所有权、移动和复制语义以及丢弃的规则。

let x1 = 42;
let y1 = Box::new(84);
{ // starts a new scope
  1 let z = (x1, y1);
  // z goes out of scope, and is dropped;
  // it in turn drops the values from x1 and y1
2 }
// x1's value is Copy, so it was not moved into z
3 let x2 = x1;
// y1's value is not Copy, so it was moved into z
4 // let y2 = y1;

清单 1-3:移动和复制语义

我们从两个值开始,一个是数字42,另一个是包含数字84Box(堆分配的值)。前者是Copy类型,而后者不是。当我们将x1y1放入元组z时,x1复制z中,而y1则被移动z中。此时,x1仍然可访问并且可以再次使用 3。另一方面,一旦y1的值被移动 4,它就变得不可访问,任何尝试访问它的行为都会导致编译错误。当z超出作用域 2 时,它包含的元组值会被丢弃,这也会丢弃从x1复制的值和从y1移动的值。当y1Box被丢弃时,它也会释放用于存储y1值的堆内存。

借用和生命周期

Rust 允许值的所有者将该值通过引用借给其他人,而不放弃所有权。引用是指针,它附带了额外的约定,规定了如何使用这些引用,例如该引用是否提供对引用值的独占访问权限,或者该引用的值是否可能同时被其他引用指向。

共享引用

共享引用&T,顾名思义,是可以共享的指针。可以存在任何数量的其他引用指向相同的值,并且每个共享引用都是Copy类型,因此你可以轻松地创建更多的共享引用。共享引用所指向的值不可变;你不能修改或重新赋值共享引用指向的值,也不能将共享引用转换为可变引用。

Rust 编译器允许假设共享引用指向的值在引用存在期间不会改变。例如,如果 Rust 编译器发现共享引用指向的值在函数中被多次读取,它可以仅读取一次并重用该值。更具体地说,列表 1-4 中的断言永远不应该失败。

fn cache(input: &i32, sum: &mut i32) {
  *sum = *input + *input;
  assert_eq!(*sum, 2 * *input);
}

列表 1-4:Rust 假设共享引用是不可变的。

编译器是否选择应用某种优化通常无关紧要。编译器的启发式规则会随着时间变化,因此你通常需要根据编译器允许的行为来编写代码,而不是依据它在某一特定时刻做出的实际行为。

可变引用

共享引用的替代方案是可变引用:&mut T。对于可变引用,Rust 编译器同样可以完全利用引用所附带的约定:编译器假设没有其他线程通过共享引用或可变引用访问目标值。换句话说,它假设可变引用是独占的。这使得某些优化变得可能,而这些优化在其他语言中可能不容易实现。例如,参考列表 1-5 中的代码。

fn noalias(input: &i32, output: &mut i32) {
  if *input == 1 {
   1 *output = 2;
  }
2 if *input != 1 {
     *output = 3;
  }
}

列表 1-5:Rust 假设可变引用是独占的。

在 Rust 中,编译器可以假设 inputoutput 不指向相同的内存。因此,在 1 处重新分配 output 不会影响 2 处的检查,整个函数可以作为一个单独的 if-else 块编译。如果编译器不能依赖于独占的可变性契约,那么该优化将无效,因为 input1 时可能导致 output3,例如在 noalias(&x, &mut x) 的情况下。

可变引用让你只能修改引用所指向的内存位置。是否可以修改超出直接引用范围的值,取决于该类型之间提供的方法。这可以通过一个示例更容易理解,参考 列表 1-6。

let x = 42;
let mut y = &x; // y is of type &i32
let z = &mut y; // z is of type &mut &i32

列表 1-6:可变性仅适用于直接引用的内存。

在这个例子中,你可以通过让指针 y 引用另一个变量来改变指针的值(即改变指针本身),但你不能改变它所指向的值(即 x 的值)。同样,你可以通过 z 来改变 y 的指针值,但不能改变 z 本身,使其持有不同的引用。

拥有一个值和拥有它的可变引用之间的主要区别在于,拥有者负责在不再需要该值时丢弃它。除此之外,你可以通过可变引用做任何你可以通过拥有该值做的事情,有一个例外:如果你将值从可变引用背后移走,那么你必须留下另一个值来替代它。如果你没有这样做,拥有者仍然认为它需要丢弃该值,但没有值可以丢弃!

列表 1-7 给出了你如何移除可变引用背后值的示例。

fn replace_with_84(s: &mut Box<i32>) {
  // this is not okay, as *s would be empty:
1 // let was = *s;
  // but this is:
2 let was = std::mem::take(s);
  // so is this:
3 *s = was;
  // we can exchange values behind &mut:
  let mut r = Box::new(84);
4 std::mem::swap(s, &mut r);
  assert_ne!(*r, 84);
}
let mut s = Box::new(42);
replace_with_84(&mut s);
5

列表 1-7:通过可变引用的访问必须留下一个值。

我已经添加了注释掉的行,表示非法操作。你不能简单地将值移出 1,因为调用者仍然认为它拥有该值,并会在 5 处再次释放它,从而导致双重释放。如果你只是想留下一个有效的值,std::mem::take 2 是一个很好的选择。它等价于 std::mem::replace(&mut value, Default::default());它将 value 从可变引用后面移出,但在其位置留下一个新的默认值。默认值是一个独立的、拥有的值,因此调用者可以在作用域结束时安全地丢弃它。

或者,如果你不需要引用背后的旧值,你可以用一个你已经拥有的值 3 来覆盖它,将其留给调用者稍后丢弃该值。当你这么做时,原来在可变引用背后的值会立即被丢弃。

最后,如果你有两个可变引用,你可以交换它们的值而无需拥有它们 4,因为两个引用最终都会得到一个合法的拥有值,供它们的拥有者最终释放。

内部可变性

有些类型提供 内部可变性,意味着它们允许你通过共享引用来变更一个值。这些类型通常依赖于额外的机制(如原子 CPU 指令)或不变量来提供安全的可变性,而不依赖于独占引用的语义。这些类型通常分为两类:一类是让你通过共享引用获得可变引用,另一类是让你仅凭共享引用就能替换一个值。

第一类类型包括 MutexRefCell,它们包含安全机制,确保对于它们给出的可变引用的任何值,在同一时间内只能存在一个可变引用(且没有共享引用)。在底层,这些类型(以及类似的类型)都依赖于一个名为 UnsafeCell 的类型,其名称应该让你在使用时有所犹豫。我们将在第九章详细讨论 UnsafeCell,但目前你需要知道,它是通过共享引用进行变更的唯一正确方式。

提供内部可变性的其他类型是那些不直接给出对内部值的可变引用,而是提供一些方法来在原地操作该值的类型。std::sync::atomic 中的原子整数类型和 std::cell::Cell 类型属于这一类。你不能直接获取到这些类型背后的 usizei32 引用,但你可以在某个时刻读取并替换其值。

生命周期

如果你正在阅读本书,你可能已经对生命周期的概念有所了解,可能是通过编译器多次提醒生命周期规则的违规。这个层次的理解对于你编写的大部分 Rust 代码来说是足够的,但随着我们深入探索 Rust 的更复杂部分,你将需要一个更严谨的思维模型来进行工作。

新的 Rust 开发者通常被教导将生命周期视为与作用域对应:生命周期在你获取某个变量的引用时开始,在该变量被移动或超出作用域时结束。这通常是正确的,而且通常很有用,但现实情况要复杂一些。生命周期实际上是指一些引用必须在其上有效的代码区域。虽然生命周期通常与作用域重合,但它不一定总是如此,正如我们稍后在本节中将看到的那样。

生命周期与借用检查器

Rust 生命周期的核心是借用检查器。每当某个生命周期'a的引用被使用时,借用检查器检查'a是否仍然有效。它通过回溯路径到'a开始的地方——即引用被获取的地方——并检查沿路径是否没有冲突的使用。这确保了引用仍然指向一个安全访问的值。这类似于我们在本章前面讨论的高级“数据流”思维模型;编译器检查我们正在访问的引用的流动是否与其他平行的流动冲突。

清单 1-8 展示了一个简单的代码示例,带有对x引用的生命周期注解。

let mut x = Box::new(42);
1 let r = &x;           // 'a
if rand() > 0.5 {
2 *x = 84;
} else {
3 println!("{}", r);  // 'a
}
4

清单 1-8:生命周期不需要是连续的。

当我们获取对x的引用时,生命周期从 1 开始。在第一个分支 2 时,我们立即尝试通过将x的值更改为84来修改x,这需要一个&mut x。借用检查器获取对x的可变引用,并立即检查它的使用。它没有发现引用被获取时与使用时之间存在冲突的使用,因此它接受了这段代码。如果你习惯于将生命周期视为作用域,可能会感到惊讶,因为r仍然在 2 时有效(它在 4 时失效)。但是,借用检查器足够智能,能意识到如果选择了这个分支,r之后不会被使用,因此允许在此处可变地访问x。换句话说,从 1 开始的生命周期不会延伸到这个分支:在 2 之后没有来自r的流动,因此没有冲突的流动。借用检查器接着找到了在 3 时打印语句中使用r的地方。它回溯到 1,并没有发现冲突的使用(2 不在这个路径上),因此它也接受了这个使用。

如果我们在清单 1-8 中添加一个在 4 时对r的使用,代码将不再编译。生命周期'a将从 1 持续到 4(r的最后一次使用),当借用检查器检查我们对r的新使用时,它会在 2 时发现一个冲突的使用。

生命周期可能变得相当复杂。在清单 1-9 中,你可以看到一个生命周期的例子,它有空洞,在它开始和最终结束之间存在间歇性无效的情况。

let mut x = Box::new(42);
1 let mut z = &x;          // 'a
for i in 0..100 {
2 println!("{}", z);     // 'a
3 x = Box::new(i);
4 z = &x;                // 'a
}
println!("{}", z);       // 'a

清单 1-9:生命周期可以有空洞。

当我们获取对x的引用时,生命周期从 1 开始。然后在 3 时我们离开了x,这结束了生命周期'a,因为它不再有效。借用检查器通过认为'a在 2 时结束,从而接受了这个移动,这样在 3 时x没有冲突的流动。接着,我们通过在z中更新引用在 4 时重新启动生命周期。不管代码现在是回到 2 还是继续到最终的打印语句,这两种使用方式都有有效的值流动,并且没有冲突的流动,所以借用检查器接受了这段代码!

再次强调,这与我们之前讨论的内存数据流模型完全一致。当x被移动时,z就停止存在了。当我们稍后重新赋值给z时,我们实际上是在创建一个从那时起才存在的新变量。恰好的是,这个新变量也被命名为z。考虑到这个模型,这个例子并不奇怪。

泛型生命周期

有时你需要在自己的类型中存储引用。这些引用需要有一个生命周期,以便借用检查器在它们在该类型的各种方法中使用时检查它们的有效性。如果你希望类型上的方法返回一个比对self的引用生命周期更长的引用,这一点尤其重要。

Rust 允许你在一个或多个生命周期上定义泛型类型,就像它允许你在类型上定义泛型一样。Steve Klabnik 和 Carol Nichols 的《Rust 编程语言》(No Starch Press,2018)对此主题进行了详细讲解,因此我在这里不会重复基础知识。但在你编写此类更复杂的类型时,有两个关于这些类型与生命周期交互的细微之处是你需要注意的。

首先,如果你的类型还实现了Drop,那么丢弃你的类型就算作使用了任何类型或生命周期。基本上,当你的类型的一个实例被丢弃时,借用检查器会检查在丢弃之前是否仍然合法使用你的类型的任何泛型生命周期。这是必要的,以防你的丢弃代码确实使用了这些引用。如果你的类型没有实现Drop,丢弃类型就算作使用,用户可以忽略存储在类型中的任何引用,只要他们不再使用它,就像我们在列表 1-7 中看到的那样。我们将在第九章中进一步讨论这些与丢弃相关的规则。

其次,虽然一个类型可以在多个生命周期上进行泛型化,但这样做往往只会让类型签名变得不必要地复杂。通常,一个类型在单一生命周期上进行泛型化就足够了,编译器会使用插入到类型中的任何引用的较短生命周期作为那个生命周期。如果你有一个包含多个引用的类型,并且它的方法返回的引用应该与这些引用中的一个的生命周期绑定在一起时,你才真的需要使用多个泛型生命周期参数。

考虑列表 1-10 中的类型,它提供了一个迭代器,用于遍历由特定字符串分隔的字符串部分。

struct StrSplit<'s, 'p> {
  delimiter: &'p str,
  document: &'s str,
}
impl<'s, 'p> Iterator for StrSplit<'s, 'p> {
  type Item = &'s str;
  fn next(&self) -> Option<Self::Item> {
    todo!()
  }
}
fn str_before(s: &str, c: char) -> Option<&str> {
  StrSplit { document: s, delimiter: &c.to_string() }.next()
}

列表 1-10:需要在多个生命周期上进行泛型化的类型

当你构建这种类型时,你必须提供delimiterdocument来进行搜索,它们都是字符串值的引用。当你请求下一个字符串时,你会获得一个指向文档的引用。想一想如果你在这个类型中使用单一生命周期会发生什么。迭代器产生的值将与documentdelimiter的生命周期绑定在一起。这将使得str_before无法编写:返回类型会与一个局部变量的生命周期相关联——由to_string生成的String——借用检查器将拒绝这段代码。

生命周期方差

方差是一个程序员常常接触到的概念,但很少知道它的名字,因为它大多数时候是不可见的。方差从表面上看描述了哪些类型是其他类型的子类型,以及何时可以用子类型替代父类型(反之亦然)。广义来说,如果类型A至少和类型B一样有用,那么AB的子类型。方差是为什么在 Java 中,如果TurtleAnimal的子类型,你可以将一个Turtle传递给一个接受Animal的函数,或者在 Rust 中,你可以将&'static str传递给一个接受&'a str的函数。

虽然方差通常是隐藏的,但它足够常见,我们需要对它有一定的了解。TurtleAnimal的子类型,因为Turtle比某个不具体指定的Animal更“有用”——Turtle可以做任何Animal能做的事情,而且可能做得更多。同样,'static'a的子类型,因为'static至少与任何'a一样长,因此更有用。或者更普遍地说,如果'b: 'a(即'b的生命周期比'a长),那么'b'a的子类型。这显然不是正式的定义,但它已经足够接近实际使用了。

所有类型都有一个方差,它定义了哪些其他相似的类型可以替代该类型。有三种方差:协变、不变和逆变。如果你可以直接用子类型替代该类型,则该类型是协变的。例如,如果一个变量的类型是&'a T,你可以向其提供一个类型为&'static T的值,因为&'a T'a上是协变的。&'a TT上也是协变的,因此你可以将一个&Vec<&'static str>传递给一个接受&Vec<&'a str>的函数。

一些类型是不变的,这意味着你必须提供完全相同的类型。&mut T就是一个例子——如果一个函数接受&mut Vec<&'a str>,你不能传递给它一个&mut Vec<&'static str>。也就是说,&mut TT中是不可变的。如果可以这样做,函数可能会在Vec中放入一个生命周期较短的字符串,调用者然后继续使用它,以为它是一个Vec<&'static str>,从而认为里面的字符串是'static!任何提供可变性的类型通常都是不可变的,原因是相同的——例如,Cell<T>T中是不变的。

最后一类,协变,出现在函数参数中。如果函数类型允许其参数变得不那么有用,那么函数类型会更有用。如果将参数类型的方差与作为函数参数时的方差进行对比,这一点会更加清晰:

let x: &'static str; // more useful, lives longer
let x: &'a      str; // less useful, lives shorter

fn take_func1(&'static str) // stricter, so less useful
fn take_func2(&'a str)      // less strict, more useful

这种反转的关系表明,Fn(T)T上是协变的。

那么,为什么在处理生命周期时需要了解方差呢?方差变得重要是当你考虑泛型生命周期参数如何与借用检查器交互时。考虑像示例 1-11 中所示的类型,它在单个字段中使用了多个生命周期。

struct MutStr<'a, 'b> {
  s: &'a mut &'b str
}
let mut s = "hello";
1 *MutStr { s: &mut s }.s = "world";
println!("{}", s);

示例 1-11:一个需要多个生命周期的泛型类型

乍一看,在这里使用两个生命周期似乎是不必要的——我们没有需要区分结构体不同部分借用的方法,正如在示例 1-10 中我们对StrSplit所做的那样。但如果你将这里的两个生命周期替换为一个'a,代码将无法编译!这完全是因为方差的原因。

在 1 处,编译器必须确定生命周期参数应该设置为什么生命周期。如果有两个生命周期,'a被设置为s借用的待定生命周期,而'b被设置为'static,因为这是提供的字符串"hello"的生命周期。如果只有一个生命周期'a,编译器推断该生命周期必须是'static

当我们稍后尝试通过共享引用来访问字符串引用s并打印时,编译器试图缩短MutStr所使用的s的可变借用,以允许s的共享借用。

在两个生命周期的情况下,'a仅在println之前结束,而'b保持不变。另一方面,在单一生命周期的情况下,我们遇到了问题。编译器想要缩短s的借用,但为此,它还必须缩短str的借用。虽然&'static str通常可以缩短为任何&'a str&'a T'a上是协变的),但这里它处于&mut T背后,而&mut TT上是不可变的。不变性要求相关类型永远不能被替换为子类型或父类型,因此编译器试图缩短借用失败,并报告s仍然被可变借用。哎呀!

由于不变性带来的灵活性降低,你需要确保你的类型在尽可能多的泛型参数上保持协变(或在适当的地方保持逆变)。如果这要求引入额外的生命周期参数,你需要仔细权衡增加另一个参数的认知成本与不变性带来的人体工学成本。

总结

本章的目的是建立一个坚实的、共同的基础,以便在接下来的章节中继续构建。到现在为止,我希望你已经牢牢掌握了 Rust 的内存和所有权模型,并且那些你可能从借用检查器那里收到的错误看起来不再那么神秘了。你可能已经了解了我们在这里讲解的一些知识点,但希望本章能给你提供一个更加全面的视角,帮助你理解它们是如何相互联系的。在下一章,我们将做类似的内容,探讨类型。我们将介绍类型如何在内存中表示,看看泛型和特性如何生成可运行的代码,并了解 Rust 提供的一些特殊类型和特性构造,用于更高级的用例。

第二章:类型

现在基础知识已经讲完,我们来看看 Rust 的类型系统。我们将跳过《Rust 编程语言》一书中涵盖的基础内容,直接深入探讨不同类型在内存中的布局、特征和特征约束的细节、存在类型以及跨 crate 边界使用类型的规则。

内存中的类型

每个 Rust 值都有一个类型。类型在 Rust 中有许多用途,正如我们在本章中将看到的那样,但它们最基本的作用之一是告诉你如何解释内存中的比特。例如,比特序列0b10111101(以十六进制表示为0xBD)本身并没有意义,直到你给它指定一个类型。当按u8类型解释时,这个比特序列是数字 189。当按i8类型解释时,它是-67。当你定义自己的类型时,编译器的工作是确定定义类型的每个部分在该类型的内存表示中放置的位置。你的结构体的每个字段会出现在比特序列的哪里?你的枚举的判别值存储在哪里?理解这个过程如何工作非常重要,因为这些细节会影响到你代码的正确性和性能,尤其是在你开始编写更复杂的 Rust 代码时。

对齐

在我们讨论一个类型的内存表示是如何确定之前,我们首先需要讨论对齐的概念,它决定了一个类型的字节应该存储在哪里。一旦确定了一个类型的表示,你可能认为可以随便选一个内存位置,并将存储在那里的字节解释为该类型。虽然从理论上讲这是正确的,但在实践中,硬件也会限制某个类型可以放置的位置。最明显的例子是指针指向的是字节,而不是比特。如果你把一个类型为T的值放在计算机内存的第 4 位开始,你将无法引用它的位置;你只能创建一个指向字节 0 或字节 1(第 8 位)的指针。因此,所有的值,不论其类型,都必须从字节边界开始。我们说所有的值必须至少是字节对齐的——它们必须放置在一个是 8 位倍数的地址上。

有些值的对齐规则比仅仅字节对齐要严格。在 CPU 和内存系统中,内存通常是按比单个字节更大的块访问的。例如,在 64 位 CPU 上,大多数值是按 8 字节(64 位)为一组访问的,每次操作都会从一个 8 字节对齐的地址开始。这被称为 CPU 的字长。然后,CPU 会使用一些巧妙的方法来处理读取和写入较小的值,或者跨越这些块边界的值。

在可能的情况下,您需要确保硬件能够以其“本地”对齐方式运行。为了理解为什么这样做很重要,请考虑如果您尝试读取一个从 8 字节块中间开始的i64会发生什么(即,它的指针没有 8 字节对齐)。硬件将不得不进行两次读取——第一次从第一个块的后半部分读取以获取i64的开始,第二次从第二个块的前半部分读取以获取i64的其余部分——然后将结果拼接在一起。这种方式效率不高。由于该操作跨多个访问底层内存进行,如果您读取的内存正在被另一个线程并发写入,您可能会得到奇怪的结果。您可能会在另一个线程写入之前读取前 4 字节,而在写入之后读取后 4 字节,导致值被破坏。

对未对齐数据的操作被称为未对齐访问,这可能导致性能低下和不良的并发问题。正因为如此,许多 CPU 操作要求或强烈偏好它们的参数是自然对齐的。自然对齐的值是指其对齐方式与其大小相匹配。例如,对于一个 8 字节加载,提供的地址需要是 8 字节对齐的。

由于对齐访问通常更快,并且提供更强的统一性语义,编译器会尽可能利用它们。它通过为每个类型计算一个基于其包含类型的对齐方式来实现这一点。内置值通常按照其大小对齐,因此u8按字节对齐,u16按 2 字节对齐,u32按 4 字节对齐,u64按 8 字节对齐。复杂类型——即包含其他类型的类型——通常会分配它们所包含的任何类型中最大的对齐方式。例如,一个包含u8u16u32的类型将会按 4 字节对齐,因为它包含u32

布局

现在你已经了解了对齐方式,我们可以探讨编译器如何决定类型的内存布局,也就是所谓的布局。默认情况下,正如你很快会看到的,Rust 编译器对类型的布局几乎没有什么保证,这使得它成为理解底层原理的一个不太理想的起点。幸运的是,Rust 提供了一个repr属性,你可以在类型定义中添加它来请求特定的内存表示。最常见的,假如你看到了这个属性,便是repr(C)。顾名思义,它将类型按照与 C 或 C++编译器布局相兼容的方式进行布局。当编写与其他语言通过外部函数接口(FFI)交互的 Rust 代码时,这非常有帮助,我们将在第十一章讨论,因为 Rust 会生成与其他语言编译器预期的布局相匹配的布局。由于 C 的布局是可预测的,并且不会改变,repr(C)在不安全上下文中也很有用,尤其是当你在处理指向类型的原始指针时,或者当你需要在两个具有相同字段的不同类型之间进行转换时。它当然也是进入布局算法的完美起点。

所以,让我们看看编译器如何使用repr(C)布局某个特定类型:在示例 2-1 中的Foo类型。你认为编译器会如何在内存中布局这个类型?

#[repr(C)]
struct Foo {
  tiny: bool,
  normal: u32,
  small: u8,
  long: u64,
  short: u16,
}

示例 2-1:对齐方式影响布局。

首先,编译器看到字段tiny,它的逻辑大小是 1 位(truefalse)。但是由于 CPU 和内存以字节为单位进行操作,tiny在内存中的表示占用了 1 字节。接着,normal是一个 4 字节类型,因此我们希望它按照 4 字节对齐。但即使Foo已经对齐,分配给tiny的 1 字节也会使得normal错过它的对齐位置。为了解决这个问题,编译器在tinynormal之间插入了 3 字节的填充——这些字节值不确定,在用户代码中被忽略——以便使内存表示正确对齐。填充字节中没有值,但它确实占用了空间。

对于下一个字段,small,对齐很简单:它是一个 1 字节的值,而当前结构的字节偏移量是 1 + 3 + 4 = 8\。这已经是字节对齐的,因此small可以紧跟在normal后面。不过,long又出现了问题。现在我们已经进入Foo的 1 + 3 + 4 + 1 = 9 字节。如果Foo是对齐的,那么long就不是我们希望的 8 字节对齐,因此我们必须插入 7 个字节的填充,使long重新对齐。这个操作也方便地确保了最后一个字段short需要的 2 字节对齐,总共使得大小为 26 字节。现在我们已经处理完所有字段,还需要确定Foo本身的对齐。这里的规则是使用Foo所有字段中最大的对齐方式,这将是 8 字节,因为long字段的原因。因此,为了确保Foo在被放入数组时仍然对齐,编译器会最终添加 6 字节的填充,使Foo的大小成为其对齐的倍数,总计 32 字节。

现在我们准备抛弃 C 语言的遗产,考虑一下如果我们不使用repr(C),会发生什么情况,如 Listing 2-1 所示。C 表示的一个主要限制是它要求我们按原始结构定义中出现的顺序放置所有字段。默认的 Rust 表示repr(Rust)消除了这个限制,并且去除了其他一些较小的限制,例如对类型字段的确定性排序——即使两个不同的类型共享完全相同的字段,且字段顺序相同,在默认的 Rust 布局下也不能保证它们的布局相同!

由于现在允许重新排序字段,我们可以按照字段大小的递减顺序排列它们。这意味着我们不再需要在Foo的字段之间添加填充;字段本身就能实现所需的对齐!Foo现在只占用其字段的大小:仅 16 字节。这也是 Rust 默认情况下不会对类型在内存中的布局做出太多保证的原因之一:通过给编译器更多的调整空间,我们可以生成更高效的代码。

事实证明,还有第三种布局类型的方法,那就是告诉编译器我们不希望字段之间有任何填充。在这样做时,我们表示愿意接受使用未对齐访问所带来的性能损失。这种做法最常见的用例是当每增加一个字节的内存都会带来影响时,比如当你有大量的类型实例、内存非常有限,或者你正在通过低带宽的媒介(如网络连接)传输内存中的表示。要启用这种行为,你可以用#[repr(packed)]注解你的类型。请记住,这可能会导致代码变得更慢,在极端情况下,如果你尝试执行仅在对齐参数上支持的操作,程序可能会崩溃。

有时,你可能希望给某个特定字段或类型一个比它技术上要求的更大的对齐方式。你可以使用属性#[repr(align(n))]来实现。一个常见的用例是确保在内存中连续存储的不同值(例如在数组中)最终会位于 CPU 的不同缓存行中。这样,你可以避免伪共享,它会导致并发程序中的性能大幅下降。伪共享发生在两个不同的 CPU 访问恰好共享同一缓存行的不同值时;尽管它们理论上可以并行操作,但最终它们都会争抢更新缓存中的同一个条目。我们将在第十章中更详细地讨论并发。

复杂类型

你可能好奇编译器是如何在内存中表示其他 Rust 类型的。这里有一个快速参考:

  1. 元组 表示方式类似于结构体,其中字段与元组值的类型相同,且顺序一致。

  2. 数组 表示为包含类型的连续序列,元素之间没有填充。

  3. 联合体 布局对于每个变体独立选择。对齐方式是所有变体中的最大值。

  4. 枚举 与联合体相同,但多了一个隐藏的共享字段,用于存储枚举变体的区分符。区分符是代码用来判断给定值属于哪个枚举变体的值。区分符字段的大小取决于变体的数量。

动态大小类型和宽指针

你可能在 Rust 文档的各个奇怪角落和错误消息中遇到过标记特性Sized。通常,它会出现是因为编译器希望你提供一个Sized的类型,但你(显然)没有提供。Rust 中的大多数类型会自动实现Sized,也就是说,它们的大小在编译时是已知的,但有两个常见类型不是:特性对象和切片。例如,如果你有一个dyn Iterator[u8],它们的大小是没有明确规定的。它们的大小依赖于程序运行时才会知道的信息,而不是在编译时已知的,这就是为什么它们被称为动态大小类型(DSTs)。没有人能提前知道你的函数接收到的dyn Iterator是这个 200 字节的结构体还是那个 8 字节的结构体。这就提出了一个问题:编译器通常必须知道某个事物的大小才能生成有效的代码,比如如何为类型(i32, dyn Iterator, [u8], i32)的元组分配空间,或者如果你的代码试图访问第四个字段时应该使用什么偏移量。但如果类型不是Sized,那就无法获得这些信息。

编译器几乎在所有地方都要求类型必须是Sized。结构体字段、函数参数、返回值、变量类型和数组类型都必须是Sized。这种限制非常常见,几乎每次你编写类型约束时,都会包括T: Sized,除非你显式地选择不使用它,像是通过T: ?Sized?表示“可能不是”)。但是,如果你有一个动态大小类型(DST),并且想对其执行某些操作,比如你真的希望你的函数接受一个特征对象或者切片作为参数,这样的约束就显得不太有用了。

弥合大小类型和非大小类型之间的差距的方法是将非大小类型放在一个宽指针(也叫做胖指针)后面。宽指针就像普通指针,但它包含一个额外的与字长相同大小的字段,提供指针的额外信息,这些信息是编译器在生成合理的指针操作代码时所需要的。当你获取一个 DST 的引用时,编译器会自动为你构造一个宽指针。对于切片,额外的信息就是切片的长度。对于特征对象——嗯,我们稍后会详细讲解。而关键是,这个宽指针Sized的。具体来说,它的大小是usize的两倍(usize是目标平台上一个字的大小):一个usize用于存储指针,另一个usize用于存储完成类型所需的额外信息。

特征和特征约束

特征是 Rust 类型系统的关键部分——它们是允许类型之间进行交互的粘合剂,尽管这些类型在定义时彼此并不知情。《Rust 编程语言》很好地介绍了如何定义和使用特征,所以我在这里就不再赘述。相反,我们将探讨一些更技术性的特征方面:它们是如何实现的,你需要遵守的限制,以及特征的一些更深奥的使用方式。

编译和分派

到现在为止,你可能已经编写了相当多的 Rust 泛型代码。你已经在类型和方法上使用了泛型类型参数,甚至可能还使用了一些特征约束。但是,你是否曾经想过,当你编译这些泛型代码时,实际发生了什么?或者,当你在dyn Trait上调用一个特征方法时,发生了什么?

当你编写一个对T泛型的类型或函数时,你实际上是在告诉编译器为每个类型T创建该类型或函数的副本。当你构造一个Vec<i32>HashMap<String, bool>时,编译器本质上是复制粘贴了泛型类型及其所有实现块,并将每个泛型参数的实例替换为你提供的具体类型。它为Vec类型做了一个完整的副本,将所有的T替换为i32,为HashMap类型做了一个完整的副本,将所有的K替换为String,将所有的V替换为bool

同样的情况也适用于泛型函数。请看示例 2-2,其中展示了一个泛型方法。

impl String {
  pub fn contains(&self, p: impl Pattern) -> bool {
    p.is_contained_in(self)
  }
}

示例 2-2:使用静态分派的泛型方法

对于每种不同的模式类型,都需要为其创建一个该方法的副本(回想一下,impl Trait<T: Trait> 的简写)。我们需要为每个 impl Pattern 类型准备一个不同的函数体副本,因为我们需要知道 is_contained_in 函数的地址以便调用它。CPU 需要知道跳转到哪里继续执行。对于任何给定的模式,编译器知道那个地址就是该模式类型实现该特征方法的地方的地址。但我们无法为任何类型使用一个地址,所以我们需要为每个类型准备一个副本,每个副本都有自己的跳转地址。这种方式被称为静态分派,因为对于该方法的任何副本,我们“分派到”的地址是静态已知的。

从一个泛型类型转换为多个非泛型类型的过程被称为泛型化,这也是泛型 Rust 代码通常与非泛型代码性能相当的原因之一。等到编译器开始优化你的代码时,几乎就好像根本没有泛型存在!每个实例都会单独优化,并且所有类型都已知。因此,代码的效率与直接调用传入模式的 is_contained_in 方法(没有任何特征)时的效果是一样的。编译器完全了解所涉及的类型,甚至可以根据需要内联 is_contained_in 的实现。

泛型化也有其成本:所有这些类型实例化需要单独编译,如果编译器不能优化掉它们,这会增加编译时间。每个泛型化的函数还会生成自己的机器代码块,这可能会使程序变大。而且,因为不同实例化的泛型类型方法之间的指令不能共享,CPU 的指令缓存也会变得不那么高效,因为它现在需要存储多个几乎相同的指令副本。

静态派发的替代方案是动态派发,它使得代码能够在不知晓类型的情况下调用泛型类型的 trait 方法。我之前说过,之所以在 Listing 2-2 中需要多个方法实例,是因为否则你的程序无法知道跳转到哪个地址以调用给定模式上的 trait 方法is_contained_in。好了,使用动态派发时,调用者会直接告诉你。如果你将impl Pattern替换为&dyn Pattern,你就是在告诉调用者,他们必须为此参数提供两个信息:模式的地址is_contained_in方法的地址。在实践中,调用者给我们提供一个指向内存块的指针,这个内存块称为虚拟方法表(vtable),它保存了给定类型的 trait 方法的所有实现地址,其中之一就是is_contained_in。当方法内部代码想要调用提供的模式上的 trait 方法时,它会在 vtable 中查找该模式的is_contained_in实现地址,然后调用该地址的函数。这使得我们无论调用者希望使用何种类型,都可以使用相同的函数体。

当我们使用dyn关键字选择动态派发时,你会注意到我们必须在前面加上一个&。原因是我们在编译时无法知道调用者传入的模式类型的大小,因此我们不知道需要为其预留多少空间。换句话说,dyn Trait!Sized,其中!表示不是。为了让它变成Sized,以便我们可以将其作为参数传入,我们将其放在指针后面(我们知道指针的大小)。由于我们还需要传递方法地址的表格,因此这个指针变成了一个宽指针,其中额外的字保存了指向虚拟方法表(vtable)的指针。你可以使用任何能够持有宽指针的类型来进行动态派发,例如&mutBoxArc。Listing 2-3 展示了与 Listing 2-2 相对应的动态派发示例。

impl String {
  pub fn contains(&self, p: &dyn Pattern) -> bool {
    p.is_contained_in(&*self)
  }
}

Listing 2-3: 使用动态派发的泛型方法

实现了特性的类型与其虚表的组合被称为特性对象。大多数特性可以转换为特性对象,但并非所有。例如,Clone特性,其clone方法返回Self,不能转换为特性对象。如果我们接受一个dyn Clone特性对象并在其上调用clone,编译器将无法知道返回什么类型。再比如,来自标准库的Extend特性,它有一个extend方法,该方法对于提供的迭代器类型是泛型的(因此可能有多个实例)。如果你调用一个接受dyn Extend的方法,则extend在特性对象的虚表中无法放入一个唯一的地址;必须为extend可能被调用的每个类型插入一个条目。这些都是不能安全作为对象的特性,因此无法转换为特性对象。为了安全作为对象,特性中的方法不能是泛型的,也不能使用Self类型。此外,特性不能有任何静态方法(即那些第一个参数不是解引用到Self的方法),因为无法知道应该调用哪个方法实例。例如,FromIterator::from_iter(&[0])应该执行什么代码是不明确的。

当阅读关于特性对象的内容时,可能会看到提到特性约束Self: Sized。这样的约束意味着Self没有通过特性对象使用(因为它那时会是!Sized)。你可以将这个约束放在一个特性上,要求该特性从不使用动态分发,或者将它放在特定方法上,使得该方法在通过特性对象访问时不可用。具有where Self: Sized约束的方法在检查特性是否安全作为对象时会被豁免。

动态分发减少了编译时间,因为不再需要编译多个类型和方法的副本,而且它还可以提高 CPU 指令缓存的效率。然而,它也阻止编译器对所使用的具体类型进行优化。在动态分发中,编译器对示例 2-2 中的find方法所能做的唯一优化,就是通过虚表插入一个对该函数的调用——它无法执行任何其他优化,因为它不知道函数调用的另一侧将执行什么代码。此外,特性对象上的每个方法调用都需要在虚表中查找,这比直接调用方法多了一点额外开销。

当你在静态分发和动态分发之间做选择时,通常没有明确的正确答案。大体而言,你会希望在库中使用静态分发,而在二进制文件中使用动态分发。在库中,你希望允许用户自行决定哪种分发方式最适合他们,因为你不知道他们的需求。如果你使用动态分发,他们也必须做出同样的决定;而如果你使用静态分发,他们可以选择是否使用动态分发。另一方面,在二进制文件中,你是写最终的代码,所以只有你写的代码的需求需要考虑。动态分发通常允许你编写更简洁的代码,省略泛型参数,且编译速度更快,虽然通常会有微小的性能损失,因此它通常是二进制文件的更好选择。

泛型特征

Rust 特征可以通过两种方式实现泛型:使用泛型类型参数,如trait Foo<T>,或使用关联类型,如trait Foo { type Bar; }。这两者之间的区别不容易立刻看出来,但幸运的是,经验法则非常简单:如果你期望某个特征对于给定类型只有一个实现,就使用关联类型;否则使用泛型类型参数。

这样做的原因是,关联类型通常更容易使用,但不允许多次实现。更简单地说,这条建议的核心就是尽可能使用关联类型。

对于一个泛型特征,用户必须始终指定所有泛型参数并重复这些参数的任何约束条件。这可能很快变得混乱且难以维护。如果你向特征中添加一个泛型参数,那么所有使用该特征的用户也必须更新以反映这一变化。而且,由于对于给定类型可能存在多个特征实现,编译器可能很难决定你想使用哪个特征实例,从而导致像FromIterator::<u32>::from_iter这样的糟糕歧义函数调用。但好处是,你可以为同一类型多次实现该特征——例如,你可以为你的类型实现针对多个右侧类型的PartialEq,或者你可以同时实现FromIterator<T> FromIterator<&T> where T: Clone,这正是由于泛型特征所提供的灵活性。

另一方面,使用关联类型时,编译器只需要知道实现了该特征的类型,所有的关联类型都会随之确定(因为只有一个实现)。这意味着所有的约束条件可以全部放在特征本身,而无需在使用时重复。这反过来允许特征添加更多的关联类型,而不会影响其使用者。而且,由于类型决定了特征的所有关联类型,你永远不需要像前一段所示那样使用统一的函数调用语法来消除歧义。然而,你不能对多个Target类型实现Deref,也不能对多个不同的Item类型实现Iterator

一致性与孤儿规则

Rust 对你可以在哪些类型上实现特征以及如何实现它们有一些相当严格的规则。这些规则的存在是为了保持一致性属性:对于任何给定的类型和方法,始终只有一个正确的选择来决定该类型使用哪种方法实现。为了理解这一点,想象一下如果我能为标准库中的bool类型编写自己的Display特征实现会发生什么。现在,对于任何尝试打印bool值并且包含我的 crate 的代码,编译器将不知道是选择我写的实现还是标准库中的实现。没有哪个选择是正确的或比另一个更好,而且编译器显然不能随机选择。如果完全没有标准库的参与,而是我们有两个相互依赖的 crate,它们都为某个共享类型实现了一个特征,也会发生同样的问题。一致性属性确保编译器永远不会陷入这些情况,并且永远不需要做出这些选择:总会有一个明确的选择。

维持一致性的一个简单方法是确保只有定义特征的 crate 可以为该特征编写实现;如果没有其他人能实现这个特征,那么就不会有冲突的实现。然而,实践中这太过严格,基本上会让特征变得没用,因为你无法为自己的类型实现像std::fmt::Debugserde::Serialize这样的特征,除非你将自己的类型包含进定义特征的 crate 中。相反的极端说法是,只能为自己的类型实现特征,这虽然解决了问题,但又引入了另一个问题:一个定义特征的 crate 现在不能为标准库或其他流行 crate 中的类型提供该特征的实现!理想情况下,我们希望找到一组规则,既能平衡下游 crate 为自己的类型实现上游特征的需求,又能让上游 crate 在不破坏下游代码的情况下添加自己的特征实现。

在 Rust 中,确立这种平衡的规则是孤儿规则。简单来说,孤儿规则规定,只有当 trait 类型属于你自己的 crate 时,你才可以为该类型实现 trait。因此,你可以为你自己的类型实现 Debug,你也可以为 bool 实现 MyNeatTrait,但不能为 bool 实现 Debug。如果你尝试这么做,编译器会告诉你代码无法编译,因为存在冲突的实现。

这使你走得很远;它允许你为第三方类型实现你自己的 trait,并为你自己的类型实现第三方 trait。然而,孤儿规则并不是故事的全部。它还有许多额外的影响、注意事项和例外情况,你应该了解这些内容。

通用实现

孤儿规则允许你通过像 impl<T> MyTrait for T where T: 这样的代码对一系列类型实现 trait。这是一种通用实现——它不限于某一个特定类型,而是适用于广泛的类型。只有定义 trait 的 crate 才能编写通用实现,并且向现有 trait 添加通用实现会被视为破坏性变更。如果不是这样,某个下游 crate 中的 impl MyTrait for Foo 可能会因为你更新定义 MyTrait 的 crate 而突然无法编译,原因是冲突的实现。

基本类型

有些类型是如此重要,以至于必须允许任何人都能为它们实现 trait,即使这看起来违反了孤儿规则。这些类型会标记上 #[fundamental] 属性,目前包括 &&mutBox。就孤儿规则而言,基本类型可以说是不存在的——它们在检查孤儿规则之前会被有效地“擦除”,从而允许你例如为 &MyType 实现 IntoIterator。如果仅有孤儿规则,这种实现是不被允许的,因为它为一个外来类型实现了一个外来 trait——IntoIterator& 都来自标准库。为基本类型添加通用实现同样会被视为破坏性变更。

覆盖实现

有一些有限的情况,我们希望允许为外来类型实现外来 trait,这是孤儿规则通常不允许的。最简单的例子是当你想写类似 impl From<MyType> for Vec<i32> 的代码时。在这里,From trait 是外来的,Vec 类型也是外来的,但没有违反一致性的风险。这是因为冲突的实现只能通过标准库中的通用实现添加(标准库不能直接命名 MyType),而且这本身就是破坏性变更。

为了允许这些类型的实现,孤儿规则包括一个狭义的豁免,允许在非常特定的情况下为外部类型实现外部特征。具体来说,只有当至少有一个 Ti 是本地类型,并且在第一个 Ti 之前没有任何 T 是泛型类型 P1..=Pn 时,才允许给定的 impl<P1..=Pn> ForeignTrait<T1..=Tn> for T0。只要它们被某个中介类型所“涵盖”,泛型类型参数(P允许出现在 T0..Ti 中的。一个 T 被认为是被涵盖的,如果它作为某个其他类型的类型参数出现(例如 Vec<T>),但如果它单独存在(只是 T)或仅出现在基本类型后面(例如 &T),则不被视为涵盖。所以,列表 2-4 中的所有实现都是有效的。

impl<T> From<T> for MyType
impl<T> From<T> for MyType<T>
impl<T> From<MyType> for Vec<T>
impl<T> ForeignTrait<MyType, T> for Vec<T>

列表 2-4:外部类型的外部特征有效实现

然而,列表 2-5 中的实现是无效的。

impl<T> ForeignTrait for T
impl<T> From<T> for T
impl<T> From<Vec<T>> for T
impl<T> From<MyType<T>> for T
impl<T> From<T> for Vec<T>
impl<T> ForeignTrait<T, MyType> for Vec<T>

列表 2-5:外部类型的外部特征无效实现

这种对孤儿规则的放宽使得在为现有特征添加新实现时,什么构成破坏性变更的规则变得更加复杂。特别是,向现有特征添加新实现只有在它包含至少一个新的本地类型,并且该新本地类型满足前面描述的豁免规则时,才算作非破坏性变更。添加任何其他新实现都是破坏性变更。

特征边界

标准库充满了特征边界,无论是 HashMap 中的键必须实现 Hash + Eq,还是传递给 thread::spawn 的函数必须是 FnOnce + Send + 'static。当你自己编写泛型代码时,几乎肯定会涉及特征边界,否则你的代码无法在泛型类型上做很多事情。随着你编写越来越复杂的泛型实现,你会发现你需要更多的特征边界精度,那么我们来看看如何实现这一点。

首先,trait 边界不必是 T: Trait 的形式,其中 T 是你实现或类型所泛型化的某种类型。边界可以是任意类型限制,甚至不需要包含泛型参数、参数类型或局部类型。你可以写出像 where String: Clone 这样的 trait 边界,即使 String: Clone 永远为真且不包含局部类型。你还可以写出 where io::Error: From<MyError<T>>;你的泛型类型参数不需要仅出现在左侧。这不仅让你能够表达更复杂的边界,还能避免不必要的重复边界。例如,如果你的方法想要构造一个 HashMap<K, V, S>,其中键是某种泛型类型 T,值是 usize,你可以避免像 where T: Hash + Eq, S: BuildHasher + Default 这样写出边界,而是写 where HashMap<T, usize, S>: FromIterator。这样可以避免查找你最终使用的方法的确切边界要求,同时更清晰地传达代码的“真正”要求。如你所见,如果你想调用的底层 trait 方法的边界很复杂,这也可以显著减少边界的复杂性。

有时候,你需要对泛型中关联类型设置边界。例如,考虑 flatten 迭代器方法,它接收一个产生项目的迭代器,而这些项目本身实现了 Iterator,并生成这些内部迭代器项的迭代器。它生成的类型 Flatten 是泛型的,泛型参数 I 是外部迭代器的类型。如果 I 实现了 Iterator并且 I 产生的项本身实现了 IntoIterator,那么 Flatten 就实现了 Iterator。为了使你能够编写类似的边界,Rust 允许你使用 Type::AssocType 语法来引用类型的关联类型。例如,我们可以通过 I::Item 来引用 IItem 类型。如果一个类型有多个相同名称的关联类型(例如,提供该关联类型的 trait 本身是泛型的,因此有多个实现),你可以通过 <Type as Trait>::AssocType 语法来消除歧义。使用这种方式,你不仅可以为外部迭代器类型编写边界,还可以为该外部迭代器的项类型编写边界。

在广泛使用泛型的代码中,你可能会发现需要写一个与类型的引用相关的约束。通常这没问题,因为你通常会有一个泛型生命周期参数,可以用作这些引用的生命周期。然而,在某些情况下,你希望约束能够说“这个引用在任何生命周期下都实现这个 trait”。这种类型的约束被称为 高阶 trait 约束,它在与 Fn trait 结合使用时尤其有用。例如,假设你想要对一个函数进行泛型化,该函数接受对 T 的引用并返回对该 T 内部的引用。如果你写 F: Fn(&T) -> &U,你需要为这些引用提供生命周期,但你实际上想说的是“任何生命周期,只要输出和输入相同”。通过使用高阶生命周期,你可以写 F: for<'a> Fn(&'a T) -> &'a U,表示对于 任何 生命周期 'a,约束必须成立。Rust 编译器足够智能,当你像这样使用带有引用的 Fn 约束时,它会自动添加 for,这涵盖了该特性的绝大多数使用场景。显式的形式如此罕见,以至于在写作时,标准库仅在三个地方使用了它——但它确实存在,所以值得了解。

为了将所有这些内容结合起来,考虑 示例 2-6 中的代码,它可以用于为任何可以迭代且其元素为 Debug 的类型实现 Debug

impl Debug for AnyIterable
  where for<'a> &'a Self: IntoIterator,
        for<'a> <&'a Self as IntoIterator>::Item: Debug {
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
        f.debug_list().entries(self).finish()
}}

示例 2-6:适用于任何可迭代集合的过于泛化的 Debug 实现

你可以将这个实现复制并粘贴到几乎任何集合类型中,它都会“正常工作”。当然,你可能希望有一个更智能的调试实现,但这很好地展示了 trait 约束的强大功能。

标记 Trait

通常,我们使用 trait 来表示多个类型可以支持的功能;Hash 类型可以通过调用 hash 进行哈希,Clone 类型可以通过调用 clone 进行克隆,Debug 类型可以通过调用 fmt 进行调试格式化。但并非所有的 trait 都是以这种方式具有功能性的。有些 trait,称为 标记 trait,则表示实现类型的某种属性。标记 trait 没有方法或关联类型,仅仅是告诉你某个特定类型是否可以或不能以某种方式使用。例如,如果一个类型实现了 Send 标记 trait,那么它可以安全地跨线程边界发送。如果没有实现这个标记 trait,那么发送它是不安全的。与这种行为没有关联的方法;它只是类型的一个事实。标准库中有许多这样的 trait,位于 std::marker 模块中,包括 SendSyncCopySizedUnpin。其中大多数(除了 Copy)也是 自动 trait;编译器会自动为这些类型实现它们,除非类型包含某些未实现标记 trait 的内容。

标记特性在 Rust 中发挥着重要作用:它们允许你编写捕获语义需求的约束,而这些需求并未直接在代码中表达。代码中没有调用 send 来要求某个类型是 Send。相反,代码 假设 给定类型可以安全地在单独的线程中使用,如果没有标记特性,编译器将无法检查这一假设。程序员必须记住这个假设,并仔细阅读代码,而我们都知道,这并不是我们希望依赖的方式。那条道路充满了数据竞争、段错误和其他运行时问题。

类似于标记特性的,还有 标记类型。这些是单位类型(例如 struct MyMarker;),它们不包含任何数据,也没有方法。标记类型的作用是将类型标记为某种特定的状态。当你想确保用户不能误用某个 API 时,标记类型非常有用。例如,考虑一个类型 SshConnection,它可能已经认证过,也可能还没有认证。你可以向 SshConnection 添加一个泛型类型参数,然后创建两个标记类型:UnauthenticatedAuthenticated。当用户首次连接时,他们会得到 SshConnection<Unauthenticated>。在其 impl 块中,你只提供一个方法:connectconnect 方法返回一个 SshConnection<Authenticated>,并且只有在那个 impl 块中,你才提供运行命令等其他方法。我们将在第三章进一步讨论这个模式。

存在类型

在 Rust 中,你很少需要在函数体内声明变量的类型,或者指定你调用的方法的泛型参数的类型。这是因为 类型推断,编译器根据代码中出现的类型推断出使用什么类型。编译器通常只会推断变量的类型以及闭包的参数(和返回类型);顶层定义,如函数、类型、特性和特性实现块,都要求你显式地指定所有类型。这有几个原因,主要原因是当你至少有一些已知的起始点时,类型推断会更容易。然而,完全命名一个类型并不总是容易,甚至可能是不可能的!例如,如果你从函数中返回一个闭包,或者从特性方法中返回一个异步块,它的类型没有一个你可以在代码中直接写出的名称。

为了处理类似的情况,Rust 支持 存在类型。你很可能已经见过存在类型的应用。所有标记为 async fn 或返回类型为 impl Trait 的函数都有一个存在返回类型:该函数签名并没有给出返回值的真实类型,只是给出了一个提示,表明该函数返回 某种 类型,该类型实现了调用者可以依赖的某些特性。而且,重要的是,调用者只能依赖返回类型实现这些特性,而不能依赖其他任何东西。

这种行为赋予了存在类型它们的名字:我们在声明中断言某个具体的类型存在,并且将找到该类型的任务交给编译器。编译器通常会通过在函数体内应用类型推导来找出那个类型。

并非所有的 impl Trait 实例都使用了存在类型。如果你在函数的参数位置使用 impl Trait,它实际上只是一个未命名的泛型参数的简写。例如,fn foo(s: impl ToString) 其实只是 fn foo<S: ToString>(s: S) 的语法糖。

存在类型在实现包含关联类型的特征时特别有用。例如,假设你正在实现 IntoIterator 特征。它有一个关联类型 IntoIter,表示该类型可以转换成的迭代器类型。有了存在类型,你就不需要定义一个单独的迭代器类型来作为 IntoIter。相反,你可以将关联类型定义为 impl Iterator<Item = Self::Item>,然后在 fn into_iter(self) 内部写一个表达式,计算出一个 Iterator,例如使用一些现有迭代器类型的 mapfilter 方法。

存在类型不仅仅提供了便捷功能:它们还允许你进行零成本的类型擦除。你不需要因为某些类型出现在公共签名中而导出辅助类型——迭代器和 future 就是常见的例子——你可以使用存在类型来隐藏底层的具体类型。接口的用户只会看到相关类型实现的特征,而具体类型则作为实现细节被隐藏。这样不仅简化了接口,也让你可以随意更改实现,而不会破坏未来的下游代码。

摘要

本章提供了对 Rust 类型系统的全面回顾。我们既看了编译器是如何在内存中表示类型的,也看了编译器是如何推理类型的。这些是编写不安全代码、复杂应用接口和后续章节中异步代码的重要背景材料。你还会发现,本章中大部分类型推理的内容将影响你如何设计 Rust 代码接口,我们将在下一章中进行讨论。

第三章:设计接口

每个项目,无论大小,都有 API。实际上,通常有多个 API。其中一些是面向用户的,例如 HTTP 端点或命令行界面,而另一些是面向开发者的,比如库的公共接口。除了这些外,Rust 的 crate 还拥有许多内部接口:每个类型、trait 和模块边界都有自己的小型 API,其他代码也会与之交互。随着代码库的规模和复杂性增加,你会发现投入一些思考和心思来设计内部 API 是值得的,这样可以尽量让代码的使用和维护体验变得更为愉快。

本章将介绍编写 Rust 语言习惯接口时需要考虑的一些重要因素,无论这些接口的用户是你自己的代码,还是其他开发者在使用你的库。这些因素本质上可以归结为四个原则:接口应该是不令人惊讶的、灵活的、显而易见的以及受限的。我将依次讨论这四个原则,提供编写可靠且易用接口的指导。

强烈推荐在阅读完本章之后查看 Rust API 指南(rust-lang.github.io/api-guidelines/)。这里有一个非常好的检查清单,详细解释了每项推荐。许多本章中的建议也会通过 cargo clippy 工具进行检查,如果你还没有使用该工具,应该尽早在代码中运行它。我还鼓励你阅读 Rust RFC 1105(rust-lang.github.io/rfcs/1105-api-evolution.html)以及《Cargo 手册》中关于 SemVer 兼容性的章节(doc.rust-lang.org/cargo/reference/semver.html),它们涵盖了在 Rust 中什么是 breaking change,什么不是。

不足为奇

最小惊讶原则(The Principle of Least Surprise),也称为最小惊讶法则(Law of Least Astonishment),在软件工程中经常出现,这一原则同样适用于 Rust 接口。尽可能地,接口应该足够直观,用户如果不得不猜测,通常能够猜对。当然,应用程序的所有内容并不总是能够以这种方式直观易懂,但任何可以做到不令人惊讶的部分,都应该做到不令人惊讶。这里的核心思想是,尽量贴近用户可能已经知道的事物,这样他们就不需要以不同于他们习惯的方式重新学习概念。这样,你就可以节省他们的脑力,让他们专注于理解那些实际上是特定于你接口的内容。

你可以通过多种方式使接口具有可预测性。在这里,我们将探讨如何利用命名、共性特征以及人体工程学特征技巧来帮助用户。

命名规范

用户首先会通过接口的名称接触到它;他们会立即从遇到的类型、方法、变量、字段和库的名称中推断出一些信息。如果你的接口重用了其他(可能是常见的)接口中的名称——比如方法和类型——用户会知道他们可以对你的方法和类型做出某些假设。一个叫做iter的方法可能接受&self,并且很可能会给你一个迭代器。一个叫做into_inner的方法可能接受self,并很可能返回某种包装类型。一个叫做SomethingError的类型可能实现了std::error::Error并出现在各种Result中。通过为相同目的重用常见名称,你可以让用户更容易猜测某些事物的作用,并帮助他们更容易理解你的接口中与众不同的部分。

由此产生的一个推论是,具有相同名称的事物应该以相同的方式工作。否则——例如,如果你的iter方法接受self,或者你的SomethingError类型没有实现Error——用户可能会根据他们对接口的预期编写错误的代码。他们会感到惊讶和沮丧,并且不得不花时间去弄清楚你的接口与他们的预期有什么不同。当我们能够为用户减少这种摩擦时,我们应该尽量做到。

类型的常见特征

Rust 用户通常会做出一个重要的假设,即接口中的所有东西“都能正常工作”。他们期望能够使用{:?}打印任何类型,并将任何东西发送到另一个线程,而且他们还期望每个类型都实现了Clone。在可能的情况下,我们应避免给用户带来意外,并且积极实现大多数标准特征,即使我们暂时不需要它们。

由于第二章讨论的连贯性规则,编译器将不允许用户在需要这些特征时自行实现它们。用户不能为外部类型(如来自你接口的类型)实现外部特征(例如Clone)。他们需要将你的接口类型包装在自己的类型中,即使如此,没有访问类型内部信息的权限,编写一个合理的实现也可能非常困难。

这些标准特征中的首个是Debug特征。几乎每种类型都可以且应该实现Debug,即使它仅仅是打印类型的名称。使用#[derive(Debug)]通常是实现接口中Debug特征的最佳方式,但请记住,所有派生特征会自动为任何泛型参数添加相同的限制。你也可以通过利用fmt::Formatter上的各种debug_辅助方法来编写自己的实现。

紧随其后的是 Rust 自动特性 SendSync(以及,较少程度上,Unpin)。如果一个类型没有实现其中一个特性,那么它应该有充分的理由。一个非 Send 的类型不能放入 Mutex,即使在包含线程池的应用中,也不能被传递使用。一个非 Sync 的类型不能通过 Arc 共享,也不能放入静态变量。用户已经习惯于这些类型在这些上下文中正常工作,特别是在异步环境中,几乎所有东西都运行在线程池上。如果你没有确保你的类型实现了这些特性,用户会感到沮丧。如果你的类型无法实现这些特性,确保在文档中清楚地说明这一事实及其原因!

接下来是你应该实现的几乎普遍适用的特性:CloneDefault。这些特性可以很容易地派生或实现,对于大多数类型来说实现它们是有意义的。如果你的类型不能实现这些特性,确保在文档中指出,因为用户通常会期望能够轻松创建更多(以及新的)类型实例。如果不能做到这一点,他们会感到惊讶。

在预期特性的层级中,接下来是比较特性:PartialEqPartialOrdHashEqOrdPartialEq 特性尤其受欢迎,因为用户最终会遇到两个你类型的实例,他们希望用 ==assert_eq! 来比较它们。即使你的类型仅在同一个实例之间才会比较相等,还是值得实现 PartialEq,以便让用户能够使用 assert_eq!

PartialOrdHash 是更为专业化的特性,可能并不适用于所有情况,但在可能的情况下,你也需要实现它们。尤其是对于用户可能用作映射中的键,或是可能通过任何 std::collection 集合类型进行去重的类型,因为这些类型通常需要这些边界。EqOrd 在实现类型的比较操作时,除了 PartialEqPartialOrd 的要求之外,还带有额外的语义要求。这些要求在这些特性的文档中有详细说明,只有在你确定这些语义适用于你的类型时,才应实现它们,仅限于此

最后,对于大多数类型,实现 serde crate 的 SerializeDeserialize 特性是有意义的。这些特性可以轻松派生,serde_derive crate 甚至提供了仅重写某个字段或枚举变体的序列化机制。由于 serde 是一个第三方 crate,你可能不希望将其作为必需的依赖项。因此,大多数库选择提供一个 serde 特性,仅在用户选择时才添加对 serde 的支持。

你可能会好奇为什么我没有在这一节中包括可派生特征CopyCopy与前面提到的其他特征有两点不同。首先,用户通常不期望类型是Copy;相反,他们往往期望如果需要两个副本的话,必须调用cloneCopy改变了移动给定类型的值的语义,这可能会让用户感到惊讶。第二个观察点是:类型变得不再是Copy非常容易,因为Copy类型的限制非常严格。一个开始时很简单的类型,最终可能需要持有String或其他非Copy类型。如果发生这种情况,而你需要移除Copy实现,那将是一个不兼容的改变。相比之下,通常你不需要移除Clone实现,因此这是一种较为轻松的承诺。

人体工学特征实现

Rust 并不会自动为引用类型实现特征。换句话说,你通常不能使用fn foo<T: Trait>(t: T)来传入&Bar,即使Bar: Trait。这是因为Trait可能包含一些接受&mut selfself的方法,而显然这些方法不能在&Bar上调用。然而,这种行为对于看到Trait只有&self方法的用户来说可能会非常令人惊讶!

正因如此,当你定义一个新的特征时,通常希望为&T where T: Trait&mut T where T: Trait以及Box<T> where T: Trait提供适当的通用实现。根据Trait方法的接收者,你可能只能实现其中的一些。标准库中的许多特征都有类似的实现,正是因为这可以减少用户的意外。

迭代器是另一个常见的情况,你通常会想要专门为类型的引用添加特征实现。对于任何可以被迭代的类型,考虑为&MyType&mut MyType分别实现IntoIterator(如果适用)。这使得for循环能够像用户期望的那样,在你的类型的借用实例上直接工作。

包装类型

Rust 并没有像传统意义上的对象继承。然而,Deref特征及其“亲戚”AsRef提供了一些类似继承的功能。这些特征允许你拥有一个类型为T的值,并通过直接调用T类型的值来调用类型U的方法,只要T: Deref<Target = U>。这对用户来说像是魔法,通常效果很好。

如果你提供一个相对透明的包装类型(如 Arc),你很可能希望实现 Deref,以便用户可以仅使用 . 运算符调用内部类型的方法。如果访问内部类型不需要任何复杂或可能较慢的逻辑,你还应该考虑实现 AsRef,这样用户就可以轻松地将 &WrapperType 用作 &InnerType。对于大多数包装类型,你还会希望在可能的情况下实现 From<InnerType>Into<InnerType>,以便用户可以轻松地添加或移除你的包装。

你可能也遇到过 Borrow 特征,它看起来与 DerefAsRef 非常相似,但实际上它是有些不同的。具体来说,Borrow 是为一个更窄的用例量身定制的:允许调用者提供多个基本相同的类型变体中的任何一个。它或许本可以被称为 Equivalent。例如,对于一个 HashSet<String>Borrow 允许调用者提供一个 &str &String。虽然同样可以通过 AsRef 实现这一点,但没有 Borrow 的额外要求(目标类型必须实现与实现类型完全相同的 HashEqOrd),这将是不安全的。Borrow 还对 T&T&mut T 实现了 Borrow<T> 的广泛实现,这使得它在特征边界中非常方便,可以接受给定类型的所有权 引用值。一般来说,Borrow 仅用于当你的类型与另一种类型本质上等价时,而 DerefAsRef 更广泛地用于实现任何你的类型可以“充当”的内容。

灵活

你编写的每一行代码,隐式或显式地都包含一个合同。这个合同由一组要求和一组承诺组成。要求是对代码如何使用的限制,而承诺是关于代码如何使用的保证。在设计新接口时,你需要仔细考虑这个合同。一个好的经验法则是避免施加不必要的限制,并且只做出你能够兑现的承诺。增加限制或去除承诺通常需要进行重大语义版本的更改,并且可能会破坏其他地方的代码。另一方面,放宽限制或增加承诺通常是向后兼容的。

在 Rust 中,限制通常表现为特征边界和参数类型,而承诺则表现为特征实现和返回类型。例如,比较 Listing 3-1 中的三个函数签名。

fn frobnicate1(s: String) -> String
fn frobnicate2(s: &str) -> Cow<'_, str>
fn frobnicate3(s: impl AsRef<str>) -> impl AsRef<str>

Listing 3-1: 相似的函数签名,具有不同的合同

这三个函数签名都接受一个字符串并返回一个字符串,但它们的合同却大不相同。

第一个函数要求调用者拥有一个String类型的字符串,并且承诺返回一个拥有所有权的String。由于契约要求调用者分配内存,并要求我们返回一个拥有所有权的String,因此我们不能在不破坏向后兼容性的情况下使这个函数不再需要分配内存。

第二个函数放宽了契约:调用者可以提供任何字符串引用,因此用户不再需要分配内存或放弃String的所有权。它还承诺返回一个std::borrow::Cow,意味着它可以根据是否需要拥有字符串的所有权,返回字符串引用或拥有所有权的String。这里的承诺是函数将始终返回一个Cow,这意味着我们不能后来将其改为使用其他优化的字符串表示。调用者还必须特别提供一个&str,因此如果他们有一个现成的String,必须将其解引用为&str才能调用我们的函数。

第三个函数解除这些限制。它仅要求用户传入一个能够生成字符串引用的类型,并且只承诺返回值能够生成字符串引用。

这些函数签名中没有一个比其他的更好。如果你需要在函数中拥有一个字符串的所有权,你可以使用第一个参数类型来避免额外的字符串复制。如果你希望允许调用者利用已经分配并返回的拥有所有权的字符串的情况,那么第二个返回类型为Cow的函数可能是一个不错的选择。相反,我希望你从中得到的是,你应该仔细考虑接口所绑定的契约,因为事后修改它可能会带来破坏性影响。

在本节的其余部分,我将给出一些接口设计决策的例子,这些决策经常出现,并且它们对你的接口契约的影响。

泛型参数

你的接口必须对用户施加的一个明显要求是,他们必须为你的代码提供什么类型。如果你的函数明确地接受一个Foo类型,用户必须拥有并提供一个Foo。这是无法绕过的。在大多数情况下,使用泛型而不是具体类型是值得的,这样可以允许调用者传递任何符合你函数实际需求的类型,而不仅仅是某个特定类型。将示例 3-1 中的&str改为impl AsRef<str>就是这种宽松的一个例子。通过这种方式放宽要求的一种方法是,从完全泛型且没有边界的参数开始,然后通过编译器的错误信息来发现你需要添加哪些边界。

然而,如果把这种方法推到极限,它会让每个函数的每个参数都成为它自己的泛型类型,这将既难以阅读也难以理解。并没有硬性规则说明什么时候应该或者不应该将某个参数设为泛型,所以要凭借你的最佳判断来决定。一个好的经验法则是,如果你能想到其他类型,用户合理且频繁地想要使用而不是你最初使用的具体类型,那么就可以将该参数设为泛型。

你可能还记得在第二章中提到过,泛型代码会通过单态化(monomorphization)为每个与泛型代码一起使用的类型组合生成一份副本。考虑到这一点,过度泛化大量参数的想法可能会让你担心会使二进制文件过于庞大。在第二章中,我们还讨论了如何通过动态调度(dynamic dispatch)来缓解这一问题,并且通常不会对性能造成明显影响,这一点在这里同样适用。对于那些你反正会通过引用传递的参数(回想一下,dyn Trait不是Sized,你需要一个宽指针才能使用它们),你可以轻松地将泛型参数替换为使用动态调度的参数。例如,代替impl AsRef<str>,你可以使用&dyn AsRef<str>

不过,在你开始这么做之前,还是有几个问题需要考虑。首先,你是代表你的用户做这个选择的,用户无法选择不使用动态调度。如果你知道你正在应用动态调度的代码永远不会对性能产生敏感影响,那么这样做可能没问题。但如果有用户想在他们的高性能应用中使用你的库,在一个热循环中调用的函数中使用动态调度可能会成为一个障碍。其次,在撰写本文时,使用动态调度仅在你拥有像T: AsRef<str>impl AsRef<str>这样简单的特征约束时才有效。对于更复杂的约束,Rust 无法构建动态调度的 vtable,因此你不能使用像&dyn Hash + Eq这样的类型。最后,请记住,对于泛型,调用者始终可以通过传递特征对象来选择动态调度。而反过来则不成立:如果你接收一个特征对象,那么调用者必须提供它。

你可能会觉得很有诱惑力,从具体类型开始你的接口,然后随着时间的推移将它们变为泛型。这是可行的,但请记住,这样的变化不一定是向后兼容的。为了说明为什么,假设你将一个函数从fn foo(v: &Vec<usize>)改为fn foo(v: impl AsRef<[usize]>)。虽然每个&Vec<usize>都实现了AsRef<[usize]>,但类型推断仍然可能给用户带来问题。想象一下,如果调用者以foo(&iter.collect())的方式调用foo,会发生什么情况。在原始版本中,编译器能够确定它应该将数据收集到Vec中,但现在它只知道需要将数据收集到实现了AsRef<[usize]>的某个类型中。而且可能有多个这样的类型,因此经过此更改后,调用者的代码将无法编译!

对象安全

当你定义一个新特性时,该特性是否具备对象安全性(请参见第二章“编译与分发”部分的结尾)是特性的契约中一个未明确写明的部分。如果特性是对象安全的,用户可以将实现该特性的不同类型当作一个通用类型来使用dyn Trait。如果不是,编译器将不允许为该特性使用dyn Trait。即使这样做会稍微影响使用它们的可用性(比如,采用impl AsRef<str>而不是&str),你也应该倾向于使你的特性具备对象安全性,因为对象安全性能为你的特性提供新的使用方式。如果你的特性必须有一个泛型方法,考虑它的泛型参数是否可以位于特性本身,或者它的泛型参数是否也可以使用动态分发来保持特性的对象安全性。或者,你可以为该方法添加where Self: Sized的特性约束,这样就可以仅使用特性实例(而不是通过dyn Trait)调用该方法。你可以在IteratorRead特性中看到这种模式的例子,这些特性是对象安全的,但在具体实例上提供了一些额外的便利方法。

对于“你应该愿意为保持对象安全做出多少牺牲”这个问题,并没有唯一的答案。我的建议是,你应该考虑你的特性将如何被使用,用户是否有必要将其用作特性对象。如果你认为用户可能会希望将你的特性与许多不同的实例一起使用,那么你应该更加努力地提供对象安全,而不是认为这种用例没有什么意义。例如,动态分发对于FromIterator特性没有用处,因为它的唯一方法不接受self,因此你根本无法构造一个特性对象。类似地,std::io::Seek作为一个特性对象本身几乎没有用,因为你只能在特性对象上进行寻址,而无法进行读写操作。

请记住,对象安全是你公共接口的一部分!如果你以向后兼容的方式修改了特性,比如添加一个具有默认实现的方法,但却导致特性不再具备对象安全性,那么你需要增加你的主语义版本号。

借用与拥有

对于你在 Rust 中定义的几乎每个函数、特性和类型,你都必须决定它是应该拥有其数据,还是仅仅持有其数据的引用。无论你做出什么决定,都将对你的接口的可用性和性能产生深远的影响。幸运的是,这些决策通常会自己显现出来。

如果你编写的代码需要拥有数据,例如调用需要self的方式或将数据移动到另一个线程,它必须存储拥有的数据。当你的代码必须拥有数据时,通常也应该要求调用者提供拥有的数据,而不是通过引用获取值并克隆它们。这让调用者控制分配过程,并且明确了使用该接口的成本。

另一方面,如果你的代码不需要拥有数据,它应该改为操作引用。一个常见的例外是像i32boolf64这样的简单类型,它们直接存储和复制的成本与通过引用存储的成本相当。然而,要小心假设这一规则适用于所有Copy类型;[u8; 8192]Copy类型,但将它到处存储和复制会非常昂贵。

有时,你并不知道代码是否需要拥有数据,因为这取决于运行时。在这种情况下,Cow类型是你的朋友。它允许你通过持有引用或拥有的值来表示可能拥有的数据。如果要求在只有引用时生成一个拥有的值,Cow会使用ToOwned特性在幕后创建一个,通常是通过克隆。Cow通常用于返回类型,表示某些函数可能会分配内存。例如,String::from_utf8_lossy只有在输入包含无效 UTF-8 时才会分配内存。Cow也可以用于函数的参数,这些函数有时会使用拥有的输入,但在实践中这种情况较少见。

有时,引用生命周期会使接口变得复杂到让它使用起来很麻烦。如果你的用户在使用你的接口时难以让代码编译通过,那通常是一个信号,表明你可能希望(即使不必要)对某些数据拥有所有权。如果你这么做,应该从那些克隆成本较低或不涉及性能敏感的部分数据开始,然后再决定是否为可能是大块字节的数据分配堆内存。

可失败和阻塞的析构函数

以 I/O 为中心的类型通常在被丢弃时需要执行清理操作。这可能包括将数据刷新到磁盘、关闭文件或优雅地终止与远程主机的连接。执行这些清理操作的自然位置是在该类型的Drop实现中。不幸的是,一旦值被丢弃,我们就没有办法通过其他方式向用户报告错误,除了触发恐慌(panic)。在异步代码中也会出现类似的问题,我们希望在有待完成的工作时完成任务。等到drop被调用时,执行器可能已经在关闭中,我们也无法再执行更多的工作。我们可以尝试启动另一个执行器,但这会带来一系列问题,例如在异步代码中阻塞,正如我们在第八章中将看到的那样。

这些问题没有完美的解决方案,无论我们做什么,一些应用程序不可避免地会回落到我们的Drop实现。因此,我们需要通过Drop提供尽力而为的清理工作。如果清理出错,至少我们尝试过——我们会吞下错误并继续。如果执行器仍然可用,我们可能会启动一个未来任务来进行清理,但如果它永远无法执行,我们也尽力了。

然而,我们应该为那些希望不留下任何悬而未决问题的用户提供更好的替代方案。我们可以通过提供显式析构函数来做到这一点。这通常表现为一个方法,该方法获取self的所有权,并暴露销毁过程中固有的任何错误(使用-> Result<_, _>)或异步操作(使用async fn)。谨慎的用户可以使用该方法优雅地拆除任何相关资源。

和往常一样,这里有一个权衡。一旦你添加了显式析构函数,你将遇到两个问题。首先,由于你的类型实现了Drop,你无法在析构函数中从任何该类型的字段中移动出来。这是因为Drop::drop会在显式析构函数运行后被调用,它需要&mut self,这要求self的任何部分都不能被移动。其次,drop接受的是&mut self,而不是self,因此你的Drop实现不能简单地调用显式析构函数并忽略其结果(因为它不拥有self)。有几种方法可以绕过这些问题,但没有一种是完美的。

第一个方法是将你的顶层类型做成一个围绕Option的新类型包装器,Option又包含一个内部类型,该类型包含所有字段。然后,你可以在两个析构函数中使用Option::take,并且仅在内部类型尚未被取走时,才调用内部类型的显式析构函数。由于内部类型没有实现Drop,你可以获得所有字段的所有权。此方法的缺点是,你希望在顶层类型上提供的所有方法现在必须包含代码,绕过Option(你知道它总是Some,因为drop尚未被调用)以访问内部类型的字段。

第二个解决方法是让每个字段都变得可取走。你可以通过将Option替换为None来“取走”它(这就是Option::take的作用),但你也可以对许多其他类型进行类似操作。例如,你可以通过将VecHashMap替换为它们便宜的默认构造值来取走它们——std::mem::take在这里非常有用。如果你的类型有合理的“空”值,这种方法非常有效,但如果你必须将几乎每个字段都包装在Option中,然后在访问这些字段时用匹配的unwrap来修改每个访问,就会变得非常繁琐。

第三个选项是将数据保存在ManuallyDrop类型中,该类型解引用到内部类型,因此无需进行 unwrap。你也可以在drop中使用ManuallyDrop::take来在销毁时获取所有权。这个方法的主要缺点是ManuallyDrop::take是一个不安全的操作。没有任何安全机制来确保你在调用take之后不会尝试使用ManuallyDrop中的值,或者确保你不会多次调用take。如果你这样做了,程序将悄无声息地表现出未定义的行为,且可能会发生严重问题。

最终,你应该选择最适合你的应用程序的方式。我倾向于选择第二种方式,只有当你发现自己陷入了大量Option的困境时,才切换到其他方式。如果代码足够简单,你可以轻松检查代码的安全性,并且你对自己能够做到这一点有信心,那么ManuallyDrop的解决方案是非常好的。

显而易见

虽然一些用户可能熟悉支撑你接口实现的某些方面,但他们不太可能理解所有的规则和限制。他们可能不知道在调用bar之后调用foo永远是不可以的,也不知道只有当月亮位于 47 度角并且过去 18 秒内没有人打喷嚏时,调用不安全方法baz才是安全的。只有当接口明确表明某些行为很奇怪时,用户才会去查阅文档或仔细阅读类型签名。因此,为用户提供尽可能简便的理解接口的方式,并尽可能使其难以错误使用接口是至关重要的。实现这一点的两大主要技术手段就是文档和类型系统,接下来我们分别来看一下这两者。

文档

让你的接口透明的第一步是写好文档。我可以写一本书来专门讨论如何写文档,但在这里我们将专注于 Rust 的特定建议。

首先,清楚地记录下任何可能导致代码产生意外行为的情况,或者代码依赖用户在类型签名之外执行的操作。恐慌是这两种情况的一个好例子:如果你的代码可能发生恐慌,文档中需要记录这一事实,并说明在什么情况下可能发生恐慌。同样,如果代码可能返回错误,也需要记录在哪些情况下会返回错误。对于不安全的函数,记录调用者需要保证什么条件才能确保调用是安全的。

第二,为你的代码提供端到端的使用示例,分别在 crate 和模块层级上。这些示例比针对特定类型或方法的示例更为重要,因为它们可以让用户了解整个系统是如何协同工作的。通过对接口结构的高层次理解,开发者很快就能意识到某些方法和类型的作用以及它们应该如何使用。端到端的示例还为用户提供了定制使用的起点,他们通常会复制并粘贴示例,然后根据自己的需求修改它。这种“做中学”的方法通常比让用户从各个组件拼凑起来要有效得多。

第三,组织好你的文档。将所有的类型、特征和函数放在一个顶级模块中会让用户很难判断从哪里开始。利用模块将语义相关的项组合在一起。然后,使用文档内链接相互关联这些项目。如果类型 A 的文档中提到特征 B,那么就应该直接链接到该特征。如果你让用户更容易探索你的接口,他们就不太可能错过重要的连接或依赖关系。还要考虑使用#[doc(hidden)]标记那些由于历史原因而需要存在但不打算公开的接口部分,这样它们就不会干扰文档。

最后,尽可能丰富你的文档。链接到外部资源,解释概念、数据结构、算法或其他接口方面的内容,这些可能在其他地方有很好的解释。RFC、博客文章和白皮书是很好的资源,如果有相关的。使用#[doc(cfg(..))]突出显示那些只有在特定配置下才可用的项,让用户快速理解为什么某些文档中列出的方法不可用。使用#[doc(alias = "...")]使类型和方法能通过用户可能搜索的其他名称进行发现。在顶级文档中,引导用户查看常用的模块、功能、类型、特征和方法。

类型系统指导

类型系统是确保你的接口明显、自动文档化且抗滥用的优秀工具。你有多种技术手段可以使你的接口不容易被滥用,从而更有可能正确地使用它们。

其中第一个是语义类型,即你添加类型来表示一个值的含义,而不仅仅是它的原始类型。经典的例子是布尔类型:如果你的函数接受三个bool类型的参数,通常某个用户会搞错参数顺序,并且只有在某些严重问题发生后才会意识到这一点。而另一方面,如果它接受三个不同的、带有两个变体的枚举类型作为参数,用户就无法搞错顺序,因为编译器会立即报错:如果他们试图将DryRun::Yes传给overwrite参数,这是行不通的,传Overwrite::Nodry_run参数也不行。你可以将语义类型应用到布尔类型之外的其他类型。例如,围绕数字类型的新类型可能为包含的值提供一个单位,或者它可以限制原始指针参数,只接受由其他方法返回的指针。

一个密切相关的技术是使用零大小类型来表示类型实例的某些事实。例如,考虑一个名为Rocket的类型,它表示一个真实火箭的状态。在Rocket上的某些操作(方法)无论火箭处于何种状态都应该可用,但有些操作只有在特定情况下才有意义。例如,如果火箭已经发射,就无法再次发射。同样,如果火箭尚未发射,可能也不应允许拆卸燃料箱。我们可以将这些操作建模为枚举变体,但那样的话,所有方法在每个阶段都会可用,并且我们需要引入可能的恐慌(panic)。

相反,如清单 3-2 所示,我们可以在RocketStage上引入一个泛型参数,并使用它来限制在何时可以调用哪些方法。

1 struct Grounded;
struct Launched;
// and so on
struct Rocket<Stage = Grounded> {
  2 stage: std::marker::PhantomData<Stage>,
}

3 impl Default for Rocket<Grounded> {}
impl Rocket<Grounded> {
  pub fn launch(self) -> Rocket<Launched> { }
}
4 impl Rocket<Launched> {
  pub fn accelerate(&mut self) { }
  pub fn decelerate(&mut self) { }
}

5 impl<Stage> Rocket<Stage> {
  pub fn color(&self) -> Color { }
  pub fn weight(&self) -> Kilograms { }
}

清单 3-2:使用标记类型来限制实现

我们引入了单位类型来表示火箭的每个阶段。实际上,我们并不需要存储阶段本身——只需存储它提供的元信息——因此我们通过PhantomData存储它,以确保它在编译时被消除。然后,我们仅在Rocket持有特定类型参数时,才编写实现块。你只能在地面上构建火箭(暂时如此),并且只能从地面发射它。只有当火箭已被发射时,才能控制其速度。有些事情是你无论火箭处于什么状态时都可以做的,这些操作我们放入了泛型实现块中。你会注意到,以这种方式设计接口时,用户不可能在错误的时机调用方法——我们已经将使用规则编码在类型本身中,并使非法状态无法表示

这个概念也扩展到了许多其他领域;如果你的函数忽略了一个指针参数,除非给定的布尔参数为真,那么最好将这两个参数合并在一起。使用一个枚举类型,其中一个变体表示false(且没有指针),另一个变体表示true并携带一个指针,这样调用者和实现者都不会误解这两者之间的关系。这是一个强大的思想,我强烈鼓励你加以利用。

另一个小而有用的工具,能够使接口更直观的是#[must_use]注解。将其添加到任何类型、特征或函数上,如果用户的代码接收了该类型或特征的元素,或者调用了该函数,却没有显式处理它,编译器将发出警告。你可能已经在Result的上下文中见过这种用法:如果一个函数返回一个Result,而你没有在某个地方使用其返回值,就会收到编译器的警告。不过,要小心不要过度使用这个注解——只有当用户如果不使用返回值时,很可能会犯错误时,才应该添加它。

受限

随着时间的推移,某些用户会依赖你接口的每个属性,无论是 bug 还是功能。这对于那些公开可用的库尤其如此,因为你无法控制用户的使用方式。因此,在做出对用户可见的修改之前,你应该仔细思考。无论是添加一个新的类型、字段、方法或特征实现,还是修改现有的,你都需要确保这些修改不会破坏现有用户的代码,并且你打算在一段时间内保留这个修改。频繁的向后不兼容修改(语义版本管理中的主版本增加)必然会引起用户的不满。

许多向后不兼容的更改是显而易见的,比如重命名公共类型或删除公共方法,但有些更改则更为微妙,并且与 Rust 的工作方式深度结合。在这里,我们将讨论一些更棘手、微妙的更改,以及如何为它们做好规划。你会看到,你需要在这些变化与希望接口灵活性之间找到平衡——有时候,必须做出妥协。

类型修改

删除或重命名公共类型几乎肯定会破坏某些用户的代码。为了应对这一点,你需要尽可能利用 Rust 的可见性修饰符,如pub(crate)pub(in path)。公共类型越少,你在以后修改代码时,就能越自由地避免破坏现有代码。

然而,用户的代码可以以比仅仅通过名称依赖你的类型更多的方式进行依赖。考虑一下列表 3-3 中的公共类型以及该代码的使用方式。

// in your interface
pub struct Unit;
// in user code
let u = lib::Unit;

列表 3-3:一个看似无害的公共类型

现在考虑一下,如果你向Unit添加一个私有字段会发生什么。即使你添加的字段是私有的,这个改变仍然会破坏用户的代码,因为他们依赖的构造函数已经消失了。类似地,考虑一下列表 3-4 中的代码和用法。

// in your interface
pub struct Unit { pub field: bool };
// in user code
fn is_true(u: lib::Unit) -> bool {
    matches!(u, Unit { field: true })
}

列表 3-4:用户代码访问单一公共字段

在这里,向 Unit 添加一个私有字段会破坏用户代码,这次是因为 Rust 的穷举模式匹配检查逻辑能够看到用户无法看到的接口部分。它会识别出有更多的字段,尽管用户代码无法访问它们,并拒绝用户的模式作为不完整模式。如果我们将元组结构体转换为带有命名字段的常规结构体,也会出现类似的问题:即使字段本身完全相同,任何旧模式也将不再适用于新的类型定义。

Rust 提供了 #[non_exhaustive] 属性来帮助缓解这些问题。你可以将它添加到任何类型定义中,编译器将禁止在该类型上使用隐式构造函数(例如 lib::Unit { field1: true })和非穷举模式匹配(即没有尾随 , .. 的模式)。如果你怀疑将来可能会修改某个特定类型,那么添加这个属性是非常有用的。然而,它确实会约束用户代码,例如剥夺用户依赖穷举模式匹配的能力,因此,如果你认为某个类型可能保持稳定,最好避免添加这个属性。

特征实现

正如你在第二章中回顾到的,Rust 的一致性规则不允许对给定类型的特征进行多重实现。由于我们不知道下游代码可能已经添加了什么实现,添加现有特征的通用实现通常是一个破坏性更改。对现有类型实现外部特征,或对外部类型实现现有特征也是如此——在这两种情况下,外部特征或类型的拥有者可能会同时添加冲突的实现,因此这必须是一个破坏性更改。

移除一个特征实现是一个破坏性更改,但为一个类型实现特征永远不是问题,因为没有 crate 会有与该类型冲突的实现。

或许反直觉的是,你也需要小心为现有类型实现任何特征。为了理解原因,考虑列表 3-5 中的代码。

// crate1 1.0
pub struct Unit;
put trait Foo1 { fn foo(&self) }
// note that Foo1 is not implemented for Unit

// crate2; depends on crate1 1.0
use crate1::{Unit, Foo1};
trait Foo2 { fn foo(&self) }
impl Foo2 for Unit { .. }
fn main() {
  Unit.foo();
}

列表 3-5:为现有类型实现特征可能会导致问题。

如果你为 crate1 添加了 impl Foo1 for Unit,却没有标记为破坏性更改,下游代码将突然停止编译,因为对 foo 的调用现在变得模糊不清。这甚至适用于实现公共特征的情况,如果下游 crate 使用了通配符导入(use crate1::*)。如果你提供了一个 prelude 模块,并指示用户使用通配符导入,你尤其需要牢记这一点。

对现有特征的大多数更改也是破坏性更改,例如更改方法签名或添加新方法。更改方法签名会破坏所有实现,并可能破坏特征的许多使用情况,而添加新方法“仅仅”会破坏所有实现。然而,添加带有默认实现的新方法是可以的,因为现有的实现将继续适用。

我这里说“通常”和“最”是因为作为接口的作者,我们有一个工具可以让我们绕过一些规则:封闭特征。封闭特征是只能由其他 crate 使用,而不能由它们实现的特征。这立即使得一些破坏性更改变得非破坏性。例如,你可以向封闭特征添加一个新方法,因为你知道没有其他实现需要考虑。同样,你可以为新的外部类型实现封闭特征,因为你知道定义该类型的外部 crate 不可能添加与之冲突的实现。

封闭特征最常用于派生特征——为实现特定其他特征的类型提供通用实现的特征。只有当你认为外部 crate 不应实现你的特征时,才应该封闭特征;这会严重限制特征的可用性,因为下游 crate 将无法为自己的类型实现该特征。你还可以使用封闭特征来限制哪些类型可以作为类型参数,例如在 列表 3-2 的 Rocket 示例中,将 Stage 类型限制为仅 GroundedLaunched 类型。

列表 3-6 展示了如何封闭一个特征,并如何在定义 crate 中为其添加实现。

pub trait CanUseCannotImplement: sealed::Sealed 1 { .. }
mod sealed {
  pub trait Sealed {}
  2 impl<T> Sealed for T where T: TraitBounds {}
}
impl<T> CanUseCannotImplement for T where T: TraitBounds {}

列表 3-6:如何封闭一个特征并为其添加实现

诀窍是将一个私有的、空的特征作为你希望封闭的特征的超特征 1。由于超特征位于私有模块中,其他 crate 无法访问它,因此也无法实现它。封闭特征要求底层类型实现 Sealed,因此只有我们明确允许的类型 2 才能最终实现该特征。

隐藏的契约

有时,你对代码某一部分所做的更改会以微妙的方式影响接口中其他部分的契约。发生这种情况的两种主要方式是通过重新导出和自动特征。

重新导出

如果你的接口的任何部分暴露了外部类型,那么这些外部类型的任何变化会影响你的接口。例如,考虑如果你迁移到依赖项的新主版本,并将该依赖项中的某个类型作为迭代器类型暴露在你的接口中,会发生什么。依赖于你的接口的用户可能也会直接依赖该依赖项,并期望你的接口提供的类型与该依赖项中相同名称的类型相同。但如果你更改了依赖项的主版本,即使类型的名称相同,这种假设也不再成立。列表 3-7 展示了这一点的示例。

// your crate: bestiter
pub fn iter<T>() -> itercrate::Empty<T> { .. }
// their crate
struct EmptyIterator { it: itercrate::Empty<()> }
EmptyIterator { it: bestiter::iter() }

列表 3-7:重新导出使外部 crate 成为你接口契约的一部分。

如果你的 crate 从 itercrate 1.0 迁移到 itercrate 2.0,但其他方面没有变化,那么此列表中的代码将无法编译。即使类型没有发生变化,编译器也会(正确地)认为 itercrate1.0::Emptyitercrate2.0::Empty不同的类型。因此,你不能将后者赋值给前者,这使得这是一个破坏性的接口变更。

为了减轻类似问题,通常最好的做法是使用新类型模式(newtype pattern)来包装外部类型,然后仅暴露你认为有用的外部类型部分。在许多情况下,你可以完全避免使用新类型包装器,通过 impl Trait 只提供非常简化的契约给调用者。通过承诺更少,你可以减少破坏性变化。

自动特性

Rust 有一些特性(traits)会根据类型所包含的内容自动实现。对于本次讨论,最相关的是 SendSync,尽管 UnpinSizedUnwindSafe 特性也存在类似的问题。从本质上讲,这些特性为你的接口中的几乎每个类型添加了一个隐藏的承诺。这些特性甚至会通过像 impl Trait 这样的类型擦除类型传播。

这些特性的实现(通常)是由编译器自动添加的,但这也意味着,如果它们不再适用,它们不会被自动添加。所以,如果你有一个包含私有类型 B 的公共类型 A,并且你修改 B 使其不再是 Send,那么 A 现在不再是 Send。这就是一个破坏性更改!

这些变化可能很难追踪,并且通常直到接口的用户抱怨他们的代码不再工作时才会被发现。为了在问题发生前捕捉到这些情况,良好的做法是在测试套件中包含一些简单的测试,检查你的所有类型是否按照预期实现了这些特性。列表 3-8 给出了一个此类测试的示例。

fn is_normal<T: Sized + Send + Sync + Unpin>() {}
#[test]
fn normal_types() {
  is_normal::<MyType>();
}

列表 3-8:测试类型是否实现了一组特性

请注意,这个测试不会运行任何代码,而只是测试代码是否能成功编译。如果 MyType 不再实现 Sync,测试代码将无法编译,你将知道你刚刚做的更改破坏了自动特性实现。

总结

在本章中,我们探讨了设计 Rust 接口的多个方面,无论它是面向外部使用,还是仅作为你 crate 内部不同模块之间的抽象边界。我们覆盖了许多具体的陷阱和技巧,但最终,高层的原则应该是引导你思考的方向:你的接口应该是让人毫不意外、灵活、显而易见且有约束的。在下一章中,我们将深入讨论如何在 Rust 代码中表示和处理错误。

第四章:错误处理

对于除最简单的程序之外的所有程序,你将会有可能失败的方法。在本章中,我们将探讨不同的表示、处理和传播这些失败的方式,以及每种方式的优缺点。我们将从探索表示错误的不同方式开始,包括枚举和擦除,然后检查一些需要不同表示技术的特殊错误情况。接下来,我们将研究各种错误处理方式以及错误处理的未来发展。

值得注意的是,Rust 中的错误处理最佳实践仍然是一个活跃的话题,在写作时,生态系统尚未统一采用单一的方法。因此,本章将侧重于基本原则和技术,而不是推荐具体的 crate 或模式。

表示错误

当你编写可能失败的代码时,最重要的问题是问自己,用户如何与返回的错误进行交互。用户需要确切知道发生了什么错误以及具体出了什么问题,还是仅仅记录发生了错误并尽可能继续前进?要理解这一点,我们必须考虑错误的性质是否可能影响调用者收到错误后的行为。这反过来将决定我们如何表示不同的错误。

你有两种主要选项来表示错误:枚举和擦除。也就是说,你可以让错误类型 枚举 可能的错误条件,以便调用者能够区分它们,或者你可以仅提供一个单一的、不透明 的错误给调用者。让我们依次讨论这两种选项。

枚举

在我们的例子中,我们将使用一个库函数,它将字节从某个输入流复制到某个输出流,类似于 std::io::copy。用户为你提供两个流,一个用于读取,另一个用于写入,你将字节从一个流复制到另一个流。在此过程中,任一流都可能失败,此时复制必须停止并向用户返回错误。在这里,用户可能想知道是输入流失败了还是输出流失败了。例如,在一个 Web 服务器中,如果在将文件流式传输到客户端时输入流发生错误,可能是因为磁盘被弹出了,而如果输出流出错,可能是客户端断开了连接。后者可能是服务器应该忽略的错误,因为新的连接仍然可以完成复制,而前者可能需要服务器关闭!

这是一个我们希望枚举错误的情况。用户需要能够区分不同的错误情况,以便他们能够做出适当的响应,因此我们使用一个名为 CopyErrorenum,每个变体表示错误的不同根本原因,类似于 列表 4-1。

pub enum CopyError {
  In(std::io::Error),
  Out(std::io::Error),
}

列表 4-1:枚举错误类型

每个变体还包括遇到的错误,以尽可能多地提供关于出错原因的信息。

在创建你自己的错误类型时,你需要采取一些步骤,使错误类型能够与 Rust 生态系统中的其他部分良好配合。首先,你的错误类型应该实现std::error::Error特性,它为调用者提供了用于检查错误类型的常用方法。最重要的方法是Error::source,它提供了一种机制来找到错误的根本原因。这通常用于打印回溯,以显示从错误的根源到当前错误的完整追踪。对于我们的CopyError类型,source的实现非常直接:我们根据self进行匹配,提取并返回内部的std::io::Error

其次,你的类型应该实现DisplayDebug,以便调用者能够有意义地打印错误。这是实现Error特性时的要求。一般来说,你的Display实现应该提供一行简短的描述,说明出了什么问题,并且能够轻松地整合到其他错误消息中。显示格式应为小写并且没有结尾标点,以便能够很好地融入到其他更大的错误报告中。Debug应该提供更详细的错误描述,包括可能有助于追踪错误原因的辅助信息,如端口号、请求标识符、文件路径等,通常#[derive(Debug)]就足够了。

第三,你的类型应该实现SendSync,这样用户就能够跨线程边界共享错误。如果你的错误类型不是线程安全的,你会发现几乎无法在多线程环境中使用你的库。实现了SendSync的错误类型也更容易与非常常见的std::io::Error类型一起使用,因为它能够包装实现了ErrorSendSync的错误。当然,并非所有错误类型都能合理地实现SendSync,例如,如果它们依赖于特定的线程局部资源,这也没问题。你可能也不会将这些错误跨线程边界传递。不过,在你将Rc<String>RefCell<bool>类型放入你的错误中之前,最好意识到这一点。

最后,如果可能的话,你的错误类型应该是'static。这带来的直接好处是,它允许调用者更轻松地将错误传播到调用栈的上层,而不会遇到生命周期问题。它还使得你的错误类型能够更方便地与类型擦除的错误类型一起使用,稍后我们将看到这一点。

不透明错误

现在让我们考虑一个不同的例子:一个图像解码库。你给这个库一堆字节来解码,它会让你访问各种图像处理方法。如果解码失败,用户需要能够弄清楚如何解决问题,因此必须了解原因。但问题的原因是图像头部的size字段无效,还是压缩算法无法解压某个块,是否重要呢?可能不重要——即使知道确切的原因,应用程序也无法从这两种情况中有效恢复。在这种情况下,你作为库的作者可能更愿意提供一个单一的不透明错误类型。这也使得你的库更加易于使用,因为在任何地方只有一个错误类型。这个错误类型应该实现SendDebugDisplayError(在适当的地方包括source方法),但除此之外,调用者不需要知道更多内容。你可能会在内部表示更细粒度的错误状态,但没有必要将这些暴露给库的用户。这样做只会不必要地增加你的 API 的大小和复杂性。

你的不透明错误类型到底应该是什么,主要由你决定。它可以只是一个包含所有私有字段的类型,只暴露有限的方法来显示和检查错误,或者它可以是一个高度类型擦除的错误类型,如Box<dyn Error + Send + Sync + 'static>,它除了表明这是一个错误之外,什么也不揭示,也通常不允许用户进行任何检查。决定将错误类型做得多么不透明,主要取决于这个错误除了描述之外是否还有其他有趣的信息。如果使用Box<dyn Error>,你基本上只能让用户将错误向上传递。如果这个错误真的没有任何有价值的信息要展示给用户——例如,它只是一个动态错误信息,或者是来自你程序内部深层次的多个无关错误之一,那么这种方式可能是可以接受的。但如果错误有一些有趣的方面,比如行号或状态码,你可能想通过一个具体但不透明的类型来暴露这些信息。

使用类型擦除错误的一个好处是,它允许你轻松地将来自不同来源的错误组合在一起,而不需要引入额外的错误类型。也就是说,类型擦除的错误通常能够良好地组合,并允许你表达一个开放式的错误集合。如果你编写了一个返回类型为Box<dyn Error + ...>的函数,那么你可以在该函数内对不同的错误类型使用?,无论是哪种不同的错误,它们都会被转换为那个共同的错误类型。

Box<dyn Error + Send + Sync + 'static> 上的 'static 约束在擦除类型的上下文中值得花一些时间讲解。我在前一节中提到过,它有助于让调用者在不担心失败方法生命周期约束的情况下传播错误,但它还有更重要的作用:访问下转型。下转型是将某种类型的项转换为更具体类型的过程。这是 Rust 在运行时提供类型信息的少数几种情况之一;它是动态语言常常提供的更一般的类型反射的有限版本。在错误的上下文中,下转型允许用户将 dyn Error 转换为原本就是该类型的具体底层错误类型。例如,用户可能希望在接收到的错误是 std::io::Error 且错误类型为 std::io::ErrorKind::WouldBlock 时执行特定操作,而在其他情况下则不执行该操作。如果用户得到一个 dyn Error,他们可以使用 Error::downcast_ref 尝试将错误下转型为 std::io::Errordowncast_ref 方法返回一个 Option,告诉用户下转型是否成功。这里的关键观察是:downcast_ref 只有在参数是 'static 时才有效。如果我们返回一个不是 'static 的不透明 Error,就会剥夺用户进行此类错误自省的能力。

生态系统中对于库的类型擦除错误(或更一般地说,类型擦除类型)是否是其公开且稳定的 API 的一部分存在一些争议。也就是说,如果你库中的方法 foo 返回一个 Box<dyn Error> 类型的 lib::MyError,那么将 foo 修改为返回不同的错误类型是否会导致破坏性变化?类型签名没有改变,但用户可能已经编写了假设能够使用 downcast 将错误转回为 lib::MyError 的代码。对此我个人的看法是,你选择返回 Box<dyn Error>(而不是 lib::MyError)是有原因的,除非明确记录,否则这并不保证下转型的任何特定行为。

你可能会疑惑,Error::downcast_ref是如何安全的。也就是说,它是如何判断提供的dyn Error参数是否确实是给定类型T的?标准库中甚至有一个叫做Any的特性,它被实现于任何类型,并且为dyn Any实现了downcast_ref——那怎么可能是安全的呢?答案在于编译器支持的类型std::any::TypeId,它允许你获取任何类型的唯一标识符。Error特性有一个隐藏的提供方法叫做type_id,它的默认实现是返回TypeId::of::<Self>()。类似地,AnyT提供了一个通用实现,且在该实现中,它的type_id返回相同的内容。在这些impl块的上下文中,Self的具体类型是已知的,因此这个type_id就是实际类型的类型标识符。这为downcast_ref提供了它所需要的所有信息。downcast_ref调用self.type_id,它通过虚表(vtable)转发到动态大小类型的实现(参见第二章),并将其与提供的下转类型的类型标识符进行比较。如果它们匹配,那么dyn Errordyn Any背后的类型确实是T,从一个引用转换为另一个引用是安全的。

特殊错误情况

有些函数是可能失败的,但如果失败了无法返回任何有意义的错误。从概念上讲,这些函数的返回类型是Result<T, ()>。在某些代码库中,你可能会看到它被表示为Option<T>。虽然这两者都是这类函数的合法返回类型,但它们传达了不同的语义含义,通常不应将Result<T, ()>“简化”为Option<T>Err(())表示操作失败,应该重试、报告或以其他方式特殊处理。而None则仅表示函数没有返回值;通常不认为这是一种异常情况或需要处理的事情。你可以从Result类型上的#[must_use]注解中看到这一点——当你得到一个Result时,语言期望你处理两种情况,而对于Option,实际上没有任何情况需要处理。

有些函数,比如那些启动持续运行服务器循环的函数,只有在发生错误时才会返回错误;如果没有错误发生,它们将永远运行下去。其他函数虽然永远不会出错,但仍然需要返回一个Result,例如为了匹配特征签名。对于这样的函数,Rust 提供了never 类型,其语法是!。never 类型表示一个永远无法生成的值。你不能自己构造这种类型的实例——生成它的唯一方法是进入一个无限循环或发生 panic,或者通过一些编译器知道永远不会返回的特殊操作。对于Result,当你知道一个OkErr永远不会被使用时,可以将它设置为!类型。如果你写一个返回Result<T, !>的函数,你将永远无法返回Err,因为返回Err的唯一方式是进入一个永远不会返回的代码。由于编译器知道任何包含!的变体永远不会被生成,它也可以据此优化你的代码,例如在对Result<T, !>进行unwrap时不生成 panic 代码。当你进行模式匹配时,编译器知道任何包含!的变体根本不需要列出。相当巧妙吧!

另一个奇怪的错误情况是错误类型std::thread::Result。这是它的定义:

type Result<T> = Result<T, Box<dyn Any + Send + 'static>>;

错误类型是类型擦除的,但它并不像我们之前看到的那样被擦除成dyn Error。相反,它是一个dyn Any,这保证了错误只是某种类型,除此之外没有任何保证……这几乎不算什么保证。这个看起来很奇怪的错误类型的原因是,std::thread::Result的错误变体仅在响应 panic 时产生;特别是当你尝试加入一个已经发生 panic 的线程时。在这种情况下,加入线程的线程似乎除了忽略错误或自己使用unwrap发生 panic 外,几乎没有其他办法可以处理错误。实际上,错误类型是“一个 panic”,而值是“传递给panic!的任何参数”,它可以是真正的任何类型(尽管通常是格式化字符串)。

错误传播

Rust 的?操作符作为unwrap 或者提前返回的快捷方式,用于方便地处理错误。但它也有一些其他的技巧值得了解。首先,?通过From特征执行类型转换。在返回Result<T, E>的函数中,你可以在任何Result<T, X>上使用?,前提是E: From<X>。正是这个特性使得通过Box<dyn Error>进行错误擦除变得如此吸引人;你可以在任何地方使用?,而不必担心特定的错误类型,它通常会“自动工作”。

需要注意的第二个方面是,? 操作符实际上只是一个语法糖,代表一个名为 Try 的 trait。撰写本文时,Try trait 还没有稳定,但在你阅读本文时,它或类似的东西很可能已经稳定。由于细节尚未完全确定,我只会给出 Try 的工作原理概述,而不是完整的方法签名。Try 的核心定义了一个包装类型,其状态要么表示进一步计算有意义(快乐路径),要么表示没有意义。你们中的一些人可能会想到单子(monads),尽管我们在这里不会深入探讨这个连接。例如,在 Result<T, E> 的情况下,如果你有一个 Ok(t),你可以通过解包 t 来继续执行快乐路径。另一方面,如果你有一个 Err(e),你就需要停止执行并立即返回错误值,因为没有 t,进一步的计算是不可能的。

Try 的有趣之处在于,它适用于比 Result 更多的类型。例如,Option<T> 也遵循相同的模式——如果你有一个 Some(t),你可以继续执行快乐路径,而如果你有一个 None,你想返回 None 而不是继续。这种模式扩展到了更复杂的类型,如 Poll<Result<T, E>>,其快乐路径类型是 Poll<T>,这使得 ? 可以在更多情况下使用,超出你预期的范围。当 Try 稳定下来时,我们可能会看到 ? 开始适用于各种类型,从而使我们的快乐路径代码更加简洁。

The

? 操作符已经可以在失败函数、doctests 和 fn main 中使用。然而,要发挥其全部潜力,我们还需要一种方法来限制错误处理的范围。例如,考虑 列表 4-2 中的函数。

fn do_the_thing() -> Result<(), Error> {
  let thing = Thing::setup()?;
 // .. code that uses thing and ? ..
  thing.cleanup();
  Ok(())
}

列表 4-2:使用 ? 操作符的多步骤失败函数

这不会按预期工作。setupcleanup 之间的任何 ? 都会导致整个函数提前返回,这样就会跳过清理代码!这是 try 块 旨在解决的问题。try 块的作用几乎就像一个单次迭代的循环,其中 ? 使用 break 而不是 return,并且块的最终表达式隐式地带有 break。我们现在可以修复 列表 4-2 中的代码,使其始终进行清理,如 列表 4-3 所示。

fn do_the_thing() -> Result<(), Error> {
  let thing = Thing::setup()?;
  let r = try {
 // .. code that uses thing and ? ..
  };
  thing.cleanup();
  r
}

列表 4-3:一个总是清理的多步骤失败函数

撰写本文时,Try 块也尚不稳定,但关于它们的有用性的共识已经足够,可能会以类似这里描述的形式稳定下来。

总结

本章介绍了在 Rust 中构建错误类型的两种主要方式:枚举和擦除。我们探讨了在何种情况下你可能需要使用每种方式,以及它们各自的优缺点。我们还了解了 ? 运算符的一些幕后细节,并考虑了 ? 在未来可能变得更加有用的情况。在下一章中,我们将从代码中抽身,探讨如何构建一个 Rust 项目。我们将讨论特性标志、依赖管理和版本控制,以及如何使用工作区和子包来管理更复杂的 crate。下一页见!

第五章:项目结构

本章提供了一些关于如何构建 Rust 项目的思路。对于简单的项目,cargo new 创建的结构可能是你几乎不需要考虑的。你可能会添加一些模块来拆分代码,添加一些依赖项以获得额外的功能,但大致就是这样。然而,随着项目规模和复杂度的增长,你会发现你需要超越这一点。也许你的 crate 的编译时间失控了,或者你需要条件依赖,或者你需要更好的持续集成策略。在本章中,我们将看看 Rust 语言以及 Cargo 特别提供的一些工具,这些工具使得管理这些问题变得更加容易。

特性

特性是 Rust 自定义项目的主要工具。从本质上讲,特性只是一个构建标志,crate 可以将其传递给依赖项,以便添加可选功能。特性本身没有语义意义——相反,决定特性对你的 crate 意味着什么。

通常,我们以三种方式使用特性:启用可选依赖项、根据条件包含 crate 的额外组件以及增强代码的行为。请注意,这些用途都是增量的;特性可以增强 crate 的功能,但通常不应做诸如移除模块或替换类型或函数签名之类的事情。这源于这样一个原则:如果开发者对自己的 Cargo.toml 做了一个简单的更改,例如添加一个新依赖或启用一个特性,这不应该导致他们的 crate 无法编译。如果一个 crate 具有互斥的特性,这个原则将很快失效——如果 crate A 依赖于 crate C 的某个特性,而 crate B 依赖于 crate C 的另一个互斥特性,那么添加对 crate B 的依赖将会破坏 crate A!因此,我们通常遵循一个原则,即如果 crate A 在 crate C 上编译时启用了某些特性,它也应该在 crate C 启用所有特性时成功编译。

Cargo 在这一原则上非常坚持。例如,如果两个 crate(A 和 B)都依赖于 crate C,但它们分别启用了 C 上的不同特性,Cargo 只会编译一次 crate C,包含 A 或 B 所需的所有特性。也就是说,它会合并 A 和 B 中对 C 请求的特性。因此,一般来说,很难向 Rust crate 添加互斥特性;因为有可能两个依赖项会依赖于具有不同特性的 crate,如果这些特性是互斥的,下游 crate 将无法编译。

定义和包含特性

特性在 Cargo.toml 中定义。示例 5-1 展示了一个名为 foo 的 crate 的例子,该 crate 启用了一个简单的特性,用于启用可选依赖项 syn

[package]
name = "foo"
...
[features]
derive = ["syn"]

[dependencies]
syn = { version = "1", optional = true }

示例 5-1:启用可选依赖项的特性

当 Cargo 编译这个 crate 时,默认情况下不会编译syn crate,这样可以减少编译时间(通常是显著减少)。只有当下游 crate 需要使用由derive特性启用的 API,并且明确选择启用该特性时,syn crate 才会被编译。列表 5-2 展示了如何让下游 crate bar启用derive特性,从而包含syn依赖。

[package]
name = "bar"
...
[dependencies]
foo = { version = "1", features = ["derive"] }

列表 5-2:启用依赖的特性

有些特性使用得非常频繁,以至于让 crate 选择退出这些特性比选择启用它们更为合理。为了支持这一点,Cargo 允许你为一个 crate 定义一组默认特性。同样,它也允许你选择退出依赖的默认特性。列表 5-3 展示了foo如何使其derive特性默认启用,同时选择退出syn的某些默认特性,仅启用derive特性所需的特性。

[package]
name = "foo"
...
[features]
derive = ["syn"]
default = ["derive"]

[dependencies.syn]
version = "1"
default-features = false
features = ["derive", "parsing", "printing"]
optional = true

列表 5-3:添加并选择退出默认特性,从而管理可选依赖

在这里,如果一个 crate 依赖于foo并且没有明确选择退出默认特性,它也会编译foosyn依赖。反过来,syn将仅使用列出的三个特性进行构建,不会包含其他特性。以这种方式退出默认特性,并仅选择所需的特性,是减少编译时间的好方法!

在你的 crate 中使用特性

在使用特性时,你需要确保只有在依赖可用时才使用它。而且,如果你的特性启用了某个特定组件,你需要确保如果该特性没有启用,那么该组件不会被包含。

你可以通过使用条件编译来实现这一点,它让你使用注释来指定在特定条件下某段代码是否应该被编译。条件编译主要通过#[cfg]属性来表示。同时,还有一个紧密相关的cfg!宏,它让你根据类似的条件来改变运行时行为。通过条件编译,你可以做很多有趣的事情,正如我们在本章稍后会看到的,但最基本的形式是#[cfg(feature = "some-feature")],它的作用是仅在启用了some-feature特性时,才会编译源代码中的下一项内容。类似地,if cfg!(feature = "some-feature")仅在启用了derive特性时,相当于if true(否则为if false)。

#[cfg] 属性比 cfg! 宏使用得更多,因为宏基于功能修改运行时行为,这可能使得确保功能是可添加的变得困难。您可以将 #[cfg] 放置在某些 Rust 前面—例如函数和类型定义、impl 块、模块和 use 语句,也可以放置在其他一些结构上,如结构体字段、函数参数和语句上。不过,#[cfg] 属性不能随便放置;其出现位置受到 Rust 语言团队的严格限制,以避免条件编译导致过于奇怪且难以调试的情况。

请记住,修改 API 的某些公共部分可能会无意中使某个功能变得不可添加,这可能会导致某些用户无法编译您的 crate。您通常可以将向后兼容性更改的规则作为一个经验法则—例如,如果您使一个枚举变体或公共结构体字段依赖于某个功能,那么该类型也必须使用#[non_exhaustive]进行注解。否则,如果由于依赖树中的另一个 crate 添加了该功能,那么没有启用该功能的依赖 crate 可能将无法再编译。

工作区

Crate 在 Rust 中扮演着多种角色——它们是依赖图中的顶点,是特性一致性的边界,也是编译特性的作用域。因此,每个 crate 都作为一个单独的编译单元进行管理;Rust 编译器将 crate 视为一个大的源文件,将其作为一个整体编译,最终生成一个单一的二进制输出(可以是二进制文件或库)。

虽然这简化了编译器的许多方面,但也意味着大型 crate 的使用可能会变得十分麻烦。如果您更改了应用程序中某个部分的单元测试、注释或类型,编译器必须重新评估整个 crate,以确定是否发生了变化。编译器内部实现了许多加速这一过程的机制,如增量重编译和并行代码生成,但最终 crate 的大小是影响项目编译时间的一个重要因素。

因此,随着项目的增长,您可能希望将其拆分为多个相互依赖的 crate。Cargo 提供了一个非常方便的功能来实现这一点:工作区(workspaces)。一个工作区是由多个 crate(通常称为子 crate)组成的集合,它们通过一个顶级的Cargo.toml 文件相互关联,就像在清单 5-4 中展示的那样。

[workspace]
members = [
  "foo",
  "bar/one",
  "bar/two",
]

清单 5-4:一个工作区 Cargo.toml

members数组是一个包含工作区中每个 crate 所在目录的列表。这些 crate 各自有自己子目录中的Cargo.toml文件,但它们共享一个Cargo.lock文件和一个输出目录。crate 的名称不需要与members中的条目匹配。虽然不是强制要求,但工作区中的 crate 通常共享一个名称前缀,通常选择“主”crate 的名称。例如,在tokio crate 中,成员分别是tokiotokio-testtokio-macros,等等。

也许工作区最重要的功能是,你可以通过在工作区根目录下调用cargo与工作区的所有成员交互。想检查它们是否都能编译?cargo check会检查它们所有的情况。想运行所有测试?cargo test会测试所有的。虽然这不像将所有内容放在一个 crate 中那么方便,所以不要将一切拆分成极小的 crate,但这是一个相当不错的近似。

一旦你拥有一个包含工作区成员数组的工作区级别的Cargo.toml,你可以通过路径依赖将你的 crate 彼此依赖,如示例 5-5 所示。

# bar/two/Cargo.toml
[dependencies]
one = { path = "../one" }
# bar/one/Cargo.toml
[dependencies]
foo = { path = "../../foo" }

示例 5-5:工作区 crate 之间的互依赖

现在,如果你对bar/two中的 crate 进行修改,那么只有这个 crate 会重新编译,因为foobar/one没有发生变化。从头开始编译你的项目可能会更快,因为编译器不需要评估整个项目源代码来寻找优化机会。

项目配置

运行cargo new会为你创建一个最小的Cargo.toml,其中包含 crate 的名称、版本号、一些作者信息和一个空的依赖列表。这会让你走得很远,但随着项目的成熟,你可能会想在Cargo.toml中添加一些有用的内容。

创建元数据

添加到你的Cargo.toml文件中最先且最明显的事情是所有 Cargo 支持的元数据指令。除了像descriptionhomepage这样的明显字段外,包含一些信息也很有用,比如 crate 的README路径(readme)、与cargo run一起运行的默认二进制文件(default-run),以及额外的keywordscategories,这些有助于crates.io对你的 crate 进行分类。

对于具有更复杂项目布局的 crate,设置includeexclude元数据字段也很有用。这些字段决定了哪些文件应该包含在你的包中并发布。默认情况下,Cargo 会包含 crate 目录中的所有文件,除了任何在.gitignore文件中列出的文件,但如果你在同一目录中有大型测试夹具、不相关的脚本或其他辅助数据,并且这些数据确实需要版本控制,那么这可能不是你想要的。正如它们的名称所示,includeexclude分别允许你仅包含特定的文件集或排除符合给定模式的文件。

你可以使用的元数据指令列表不断增长,所以请定期查看 Cargo 文档中的 Manifest 格式页面(doc.rust-lang.org/cargo/reference/manifest.html)。

构建配置

Cargo.toml 还可以让你控制 Cargo 如何构建你的 crate。最明显的工具是 build 参数,它允许你为 crate 编写完全自定义的构建程序(我们将在第十一章中回顾这一点)。然而,Cargo 还提供了两个较小但非常有用的机制,我们将在这里探讨:补丁和配置文件。

[patch]

Cargo.toml 中的 [patch] 部分允许你为依赖项指定一个不同的源,可以临时使用,无论这个补丁依赖项在你的依赖链中出现的位置在哪里。当你需要编译你的 crate 以测试某个修复的 bug、性能提升或即将发布的新小版本时,这一点尤为重要。列表 5-6 展示了如何临时使用一组依赖项的变体。

[patch.crates-io]
# use a local (presumably modified) source
regex = { path = "/home/jon/regex" }
# use a modification on a git branch
serde = { git = "https://github.com/serde-rs/serde.git", branch = "faster" }
# patch a git dependency
[patch.'https://github.com/jonhoo/project.git']
project = { path = "/home/jon/project" }

列表 5-6:使用 [patch]Cargo.toml 中覆盖依赖源

即使你对某个依赖项进行了补丁,Cargo 也会仔细检查 crate 版本,以确保你不会不小心补丁错误的主版本。如果出于某种原因,你的 crate 依赖于同一个 crate 的多个主版本,你可以通过为它们分配不同的标识符来为每个版本打补丁,就像在 列表 5-7 中展示的那样。

[patch.crates-io]
nom4 = { path = "/home/jon/nom4", package = "nom" }
nom5 = { path = "/home/jon/nom5", package = "nom" }

列表 5-7:使用 [patch]Cargo.toml 中覆盖同一 crate 的多个版本

Cargo 会查看每个路径中的 Cargo.toml,识别出 /nom4 包含主版本 4,而 /nom5 包含主版本 5,并相应地对这两个版本进行补丁。package 关键字告诉 Cargo 查找名为 nom 的 crate,而不是像默认那样使用依赖标识符(左侧部分)。你也可以在常规依赖项中以这种方式使用 package 来重命名依赖项!

请记住,在发布 crate 时上传的包不会考虑补丁。依赖于你 crate 的 crate 将只使用它自己 [patch] 部分(可能为空),而不会使用你 crate 的 [patch] 部分!

[profile]

[profile] 部分允许你传递额外的选项给 Rust 编译器,以改变编译 crate 的方式。这些选项主要分为三类:性能选项、调试选项和改变代码行为的用户自定义选项。根据你是以调试模式还是发布模式进行编译(当然还有其他模式),它们有不同的默认设置。

三个主要的性能选项是 opt-levelcodegen-unitsltoopt-level 选项通过告诉编译器如何积极地优化程序来调整运行时性能(0 是“完全不优化”,3 是“尽可能多地优化”)。设置越高,代码就会被优化得越多,这 可能 会使程序运行得更快。不过,额外的优化会带来更高的编译时间,这也是为什么通常只在发布版本中启用优化的原因。

codegen-units 选项关系到编译时性能。它告诉编译器允许将单个 crate 的编译拆分成多少个独立的编译任务(代码生成单元)。一个大 crate 的编译被拆分成更多的部分,编译速度会更快,因为更多的线程可以帮助并行编译 crate。不幸的是,为了实现这一加速,线程需要尽可能独立工作,这意味着代码优化会受到影响。例如,假设在一个线程中编译的 crate 部分可以通过内联其他部分的代码来受益——但由于这两个部分是独立的,内联无法发生!因此,这个设置是在编译时性能和运行时性能之间的折衷。默认情况下,Rust 在调试模式下使用几乎没有限制的代码生成单元数量(基本上是“尽可能快地编译”),而在发布模式下使用较少的数量(写作时为 16)。

lto 设置切换 链接时优化(LTO),它使得编译器(或者更技术性地说是链接器)能够共同优化程序的各个部分,这些部分原本是分别编译的,称为 编译单元。LTO 的具体细节超出了本书的范围,但基本思想是,每个编译单元的输出包括了关于该单元所包含代码的信息。当所有单元编译完成后,链接器会再次遍历所有单元,并利用这些额外的信息来优化合并后的编译代码。这一额外的处理步骤会增加编译时间,但能够恢复大部分由于将编译拆分成更小部分而损失的运行时性能。特别是,LTO 可以为性能敏感的程序提供显著的性能提升,这些程序可能从跨 crate 的优化中受益。然而,需要注意的是,跨 crate 的 LTO 可能会显著增加编译时间。

Rust 默认在每个 crate 内的所有 codegen 单元之间执行 LTO,以弥补使用多个 codegen 单元时导致的优化损失。由于 LTO 仅在每个 crate 内执行,而不是跨 crate 执行,因此这个额外的过程并不会太繁重,且增加的编译时间应当低于使用大量 codegen 单元所节省的时间。Rust 还提供了一种名为薄 LTO(thin LTO)的方法,该方法允许 LTO 过程大部分并行化,但代价是错过一些“完整” LTO 过程会找到的优化。

[profile] 部分还支持有助于调试的标志,如 debugdebug-assertionsoverflow-checksdebug 标志告诉编译器在编译的二进制文件中包含调试符号。这会增加二进制文件的大小,但它意味着在回溯和性能分析中,你将看到函数名等,而不是仅仅是指令地址。debug-assertions 标志启用 debug_assert! 宏和其他相关的调试代码,这些代码默认情况下不会编译(通过 cfg(debug_assertions))。这些代码可能会使程序运行变慢,但它能帮助你在运行时捕捉到可疑行为。overflow-checks 标志,顾名思义,会在整数操作中启用溢出检查。这会使操作变慢(注意到一个趋势了吗?),但可以帮助你早期发现棘手的 bug。默认情况下,这些选项在调试模式下是启用的,在发布模式下是禁用的。

[profile.*.panic]

[profile] 部分有一个需要单独小节讨论的标志:panic。这个选项决定了程序中的代码在调用 panic! 时(无论是直接调用还是通过类似 unwrap 的间接调用)会发生什么。你可以将 panic 设置为 unwind(大多数平台的默认值)或 abort。我们将在第九章中更详细地讨论 panic 和展开的内容,但这里我会给出一个简要总结。

在 Rust 中,通常当程序发生 panic 时,发生 panic 的线程会开始展开其栈。你可以将展开理解为强制从当前函数递归返回,直到回到该线程栈的底部。也就是说,如果 main 调用了 foofoo 调用了 barbar 调用了 baz,那么 baz 中发生 panic 时会强制从 baz 返回,然后是 bar,然后是 foo,最后是 main,从而导致程序退出。发生展开的线程会正常地丢弃栈上的所有值,这为这些值提供了清理资源、报告错误等的机会。这使得即使在发生 panic 的情况下,运行中的系统也有机会优雅地退出。

当一个线程发生 panic 并展开时,其他线程会继续运行,不受影响。只有当(如果)运行 main 的线程退出时,程序才会终止。也就是说,panic 通常仅限于发生 panic 的线程。

这意味着堆栈展开是一把双刃剑;程序在某些组件失败的情况下勉强运行,这可能导致各种奇怪的行为。例如,想象一个线程在更新Mutex状态的过程中发生恐慌。任何后续获取该Mutex的线程都必须准备好处理状态可能处于部分更新、不一致的情况。因此,一些同步原语(如Mutex)会记住上次访问时是否发生了恐慌,并将这一信息传递给任何后续尝试访问该原语的线程。如果线程遇到这种状态,它通常也会发生恐慌,从而导致级联效应,最终终止整个程序。但可以说,这比在损坏的状态下继续运行要好得多!

支持堆栈展开所需的记录并非免费的,它通常需要编译器和目标平台的特别支持。例如,许多嵌入式平台根本无法有效地展开堆栈。因此,Rust 支持一种不同的恐慌模式:abort,它确保当发生恐慌时,整个程序会立即退出。在这种模式下,所有线程都不会进行任何清理工作。这可能显得很严厉,确实如此,但它确保了程序永远不会在半工作状态下运行,并且错误能够立即显现出来。

你可能注意到,当一个线程发生恐慌时,它往往会打印出回溯信息:导致恐慌发生的函数调用轨迹。这也是一种堆栈展开,尽管它与这里讨论的堆栈展开恐慌行为是不同的。即使使用panic=abort,通过传递-Cforce-unwind-tablesrustc,你仍然可以获取回溯信息,这会使rustc包含必要的信息,以便在终止程序的同时回溯堆栈。

条件编译

你编写的大多数 Rust 代码是通用的——无论它运行在什么 CPU 或操作系统上,都会以相同的方式工作。但有时你必须做一些特殊的事情,才能让代码在 Windows 上、ARM 芯片上,或者在针对特定平台应用程序二进制接口(ABI)编译时正常工作。或者,可能你希望在某个特定的 CPU 指令可用时,编写某个函数的优化版本,或者在持续集成(CI)环境中禁用一些慢但无关紧要的设置代码。为了应对这些情况,Rust 提供了条件编译机制,其中只有在特定的编译环境条件为真时,某个代码片段才会被编译。

我们使用cfg关键字来表示条件编译,你在本章的“在你的 crate 中使用功能”部分曾看到过它。它通常以#[cfg(condition)]属性的形式出现,表示仅在condition为真时编译下一个项目。Rust 还提供了#[cfg_attr(condition, attribute)],当condition为真时,它会被编译为#[attribute],否则它不做任何操作。你还可以使用cfg!(condition)宏将cfg条件作为布尔表达式进行评估。

每个cfg构造都接受一个由选项组成的单一条件,比如feature = "some-feature",以及组合器allanynot,它们的功能大致如你所预期。选项可以是简单的名称,如unix,或者是像特性条件中使用的键值对。

有一些有趣的选项可以让编译依赖于特定条件。我们来逐一介绍它们,从最常见的到最不常见的:

特性选项

  1. 你已经看过这些例子。特性选项以feature = "name-of-feature"的形式出现,当指定的特性启用时,它们为真。你可以使用组合器在一个条件中检查多个特性。例如,any(feature = "f1", feature = "f2")f1f2中的任意一个特性启用时为真。

操作系统选项

  1. 这些使用键值语法,键为target_os,值如windowsmacoslinux。你还可以使用target_family指定一个操作系统系列,它的值可以是windowsunix。这些选项足够常见,因此它们有自己的简短命名形式,你可以直接使用cfg(windows)cfg(unix)。例如,如果你希望某段代码仅在 macOS 和 Windows 上编译,可以写成:#[cfg(any(windows, target_os = "macos"))]

上下文选项

  1. 这些选项让你根据特定的编译上下文来调整代码。最常见的选项是test选项,只有在 crate 以测试配置编译时它才为真。请记住,test仅针对正在测试的 crate 设置,而不是它的任何依赖项。这也意味着,在运行集成测试时,你的 crate 并不会设置test,而是集成测试在测试配置下编译,而你的实际 crate 则正常编译(也就是没有设置test)。docdoctest选项也同样如此,只有在构建文档或编译文档测试时才会设置。还有debug_assertions选项,它默认在调试模式下设置。

工具选项

  1. 一些工具,如 clippy 和 Miri,设置了自定义选项(稍后会详细介绍),让你在这些工具下运行时自定义编译。通常,这些选项以相关工具的名称命名。例如,如果你不希望某个计算密集型测试在 Miri 下运行,你可以为其设置#[cfg_attr(miri, ignore)]属性。

架构选项

  1. 这些选项让你可以根据编译器目标的 CPU 指令集来编译代码。你可以通过target_arch指定特定的架构,target_arch接受像x86mipsaarch64这样的值,或者你可以通过target_feature指定特定的平台特性,target_feature接受像avxsse2这样的值。对于非常底层的代码,你还可能发现target_endiantarget_pointer_width选项非常有用。

编译器选项

  1. 这些选项让你可以将代码适配到其编译目标平台的 ABI,并且可以通过target_env来使用,target_env的值包括gnumsvcmusl。出于历史原因,这个值在 GNU 平台上通常为空。通常,只有在你需要直接与环境 ABI 接口时,才需要这个选项,例如在使用#[link]链接到 ABI 特定符号名称时。

虽然cfg条件通常用于定制代码,但也有一些可以用于定制依赖项。例如,依赖项winrt通常只有在 Windows 系统上才有意义,而nix crate 可能只在 Unix-based 平台上有用。列表 5-9 提供了一个如何使用cfg条件的示例:

[target.'cfg(windows)'.dependencies]
winrt = "0.7"
[target.'cfg(unix)'.dependencies]
nix = "0.17"

列表 5-9: 条件依赖

在这里,我们指定只有在cfg(windows)条件下(即在 Windows 上),winrt版本 0.7 才应该被视为一个依赖项,而nix版本 0.17 仅在cfg(unix)条件下(即在 Linux、macOS 和其他 Unix-based 平台上)才被视为依赖项。需要记住的一点是,[dependencies]部分在构建过程中非常早期就会被评估,此时只有某些cfg选项可用。特别地,功能和上下文选项在此时尚不可用,因此你不能使用这种语法来根据功能和上下文拉取依赖项。然而,你可以使用仅依赖于目标规范或架构的任何cfg选项,以及任何显式由调用rustc的工具设置的选项(如cfg(miri))。

添加你自己的自定义条件编译选项也相当简单。你只需要确保在rustc编译你的 crate 时传递--cfg=myoptionrustc。最简单的做法是将你的--cfg添加到RUSTFLAGS环境变量中。这在 CI 中很有用,在 CI 中你可能希望根据测试是在 CI 上运行还是在开发机器上运行来定制测试套件:在 CI 设置中将--cfg=ci添加到RUSTFLAGS中,然后在代码中使用cfg(ci)cfg(not(ci))。以这种方式设置的选项也可以在Cargo.toml依赖项中使用。

版本控制

所有 Rust crate 都有版本,并且预计会遵循 Cargo 的语义化版本控制实现。语义化版本控制 规定了哪些类型的变更需要什么类型的版本提升,以及哪些版本是兼容的,兼容的方式是什么。RFC 1105 标准本身值得一读(它并不复杂),但总结起来,它将变更分为三种类型:破坏性变更,需要进行主版本号更新;新增功能,需要进行次版本号更新;修复 bug,仅需进行补丁版本更新。RFC 1105 对什么构成 Rust 中的破坏性变更做了相当好的阐述,我们在本书的其他地方也涉及到了一些相关内容。

我在这里不打算详细说明不同类型变更的确切语义。相反,我想突出一些 Rust 生态系统中版本号出现的更不直观的方式,这些方式在决定如何为自己的 crate 版本时需要特别注意。

最小支持的 Rust 版本

第一个 Rust 特性是 最小支持的 Rust 版本(MSRV)。关于项目在其 MSRV 和版本管理方面应该遵循什么政策,Rust 社区有很多争议,并没有一个真正完美的答案。问题的核心是,一些 Rust 用户受限于使用较旧版本的 Rust,通常是在企业环境中,他们几乎没有选择。如果我们不断利用新稳定的 API,这些用户将无法编译我们 crate 的最新版本,并会被甩在后头。

有两种技术可以帮助 crate 作者让处于这种情况的用户更轻松。第一种是建立一个 MSRV 政策,承诺 crate 的新版本将始终能够与过去 X 个月内的任何稳定版本一起编译。具体的时间长度会有所不同,但 6 个月或 12 个月是常见的。由于 Rust 的六周发布周期,这通常对应于最新的四个或八个稳定版本。任何新引入的代码必须与 MSRV 编译器一起编译(通常由 CI 检查),否则就必须等到 MSRV 政策允许后才能按现状合并。虽然这样可能会有些麻烦,因为这意味着这些 crate 不能利用语言所提供的最新最强功能,但它将让用户的使用体验更轻松。

第二种技术是在 MSRV 更改时确保增加 crate 的次版本号。因此,如果你发布了 2.7.0 版本,并且该版本将 MSRV 从 Rust 1.44 提升到 Rust 1.45,那么一个卡在 1.44 版本的项目,如果依赖于你的 crate,可以使用依赖版本说明符 version = "2, <2.7" 来保持项目的正常运行,直到它能够升级到 Rust 1.45。重要的是,你需要增加次版本号,而不仅仅是补丁版本号,这样你就可以在需要时通过发布另一个补丁版本,来为之前的 MSRV 发布版本修复关键的安全问题。

一些项目对 MSRV 的支持非常认真,以至于他们认为 MSRV 的变化是破坏性变化,并且会增加主版本号。这意味着下游项目必须显式地选择接受 MSRV 变化,而不是选择退出——但这也意味着那些没有如此严格 MSRV 要求的用户,如果不更新依赖项,就无法看到未来的 bug 修复,这可能也需要他们发布破坏性变化。正如我所说,这些解决方案并非没有缺点。

在 Rust 生态系统中,强制执行 MSRV 是一项挑战。只有一小部分 crate 提供了 MSRV 保证,即使你的依赖项提供了 MSRV 保证,你也需要不断监控它们,知道它们何时提高 MSRV。一旦它们这样做,你需要发布新的 crate 版本,并且提到前面所说的版本约束,以确保你的 MSRV 不会改变。这反过来可能会迫使你放弃依赖项的安全和性能更新,因为你必须继续使用旧版本,直到你的 MSRV 策略允许更新。而这个决定也会影响到你的依赖者。曾有提议将 MSRV 检查内建到 Cargo 中,但截至目前,还没有稳定的可行方案。

最小依赖版本

当你首次添加依赖时,并不总是很明确你应该为该依赖指定什么版本说明。程序员通常选择最新版本,或仅选择当前的主版本,但很可能这两种选择都是错误的。这里所说的“错误”,并不是说你的 crate 无法编译,而是说做出这个选择可能会在未来给你的 crate 用户带来麻烦。我们来看一下为什么每一种情况都是有问题的。

首先,考虑你添加了对hugs = "1.7.3"(最新发布版本)的依赖的情况。现在假设某个开发者依赖于你的 crate,但他们同时也依赖另一个 crate,foo,而 foo 又依赖于 hugs。进一步假设 foo 的作者非常小心他们的 MSRV 策略,因此他们依赖的是 hugs = "1, <1.6"。在这种情况下,你将遇到问题。当 Cargo 看到 hugs = "1.7.3" 时,它只考虑版本 >=1.7。但接着它看到 foohugs 的依赖要求是 <1.6,于是它放弃了并报告没有一个版本的 hugs 能兼容所有要求。

这很遗憾,因为很可能你的 crate 与例如 hugs 1.5.6 编译得很好。也许它甚至与 任何 1.X 版本都能编译成功!但通过使用最新版本号,你是在告诉 Cargo 只考虑该次版本号或更高版本的依赖项。那么,解决方案是否是使用 hugs = "1" 呢?不,这也不完全正确。可能你的代码确实依赖于仅在 hugs 1.6 中新增的内容,所以虽然 1.6.2 是可以的,但 1.5.6 却不行。如果你只是在一些会自动使用新版本的情况下编译你的 crate,你可能没有意识到这一点,但如果依赖图中的某个 crate 指定了 hugs = "1, <1.5",你的 crate 就无法编译!

正确的策略是列出你 crate 依赖的所有内容的最早版本,并确保随着你向 crate 添加新代码,这一情况仍然保持。但如何在不依赖变更日志或通过反复试验的情况下确认这一点呢?你最好的选择是使用 Cargo 的不稳定 -Zminimal-versions 标志,它使你的 crate 使用所有依赖项的最低可接受版本,而不是最高版本。然后,将所有依赖项设置为仅使用最新的主版本号,尝试编译,并为那些无法编译的依赖项添加一个次版本号。如此反复直到所有内容都能顺利编译,这样你就得到了最小版本要求!

值得注意的是,像 MSRV 一样,最小版本检查面临着生态系统采纳的问题。虽然你可能已正确设置了所有版本说明符,但你依赖的项目可能没有。这使得在实践中使用 Cargo 的最小版本标志变得困难(这也是为什么它仍然不稳定的原因)。如果你依赖 foo,而 foo 又依赖 bar,并且 bar 的版本说明符是 bar = "1",但实际上它需要的是 bar = "1.4",那么无论你如何列出 foo,Cargo 都会报告它编译 foo 失败,因为 -Z 标志要求它始终优先使用最小版本。你可以通过在 你的 依赖项中直接列出 bar 并指定适当的版本要求来绕过这个问题,但这些解决方法可能会很麻烦且难以维护。你最终可能需要列出大量通过传递依赖间接引入的依赖项,并且必须随着时间的推移不断保持该列表的更新。

变更日志

对于大多数非微不足道的 crate,我强烈建议保留变更日志。没有什么比看到一个依赖项已经发布了重大版本更新后,却不得不翻阅 Git 日志去查找具体更改内容和如何更新代码更让人沮丧了。我建议你不要将 Git 日志直接倒入一个名为 changelog 的文件中,而是应该手动维护一个变更日志。这更有可能是有用的。

一个简单但有效的变更日志格式是 Keep a Changelog 格式,详细文档见 keepachangelog.com/

未发布的版本

Rust 会考虑版本号,即使依赖的来源是目录或 Git 仓库。这意味着即使你尚未发布到 crates.io,语义化版本控制仍然很重要;在发布之间,你的 Cargo.toml 中列出的版本号也很重要。语义化版本控制标准并没有规定如何处理这种情况,但我会提供一个有效的工作流程,既不繁琐,又能有效地运作。

发布版本后,立即在 Cargo.toml 中更新版本号,设置为下一个补丁版本,后缀如 -alpha.1。如果你刚发布了 2.0.3,则新版本应该是 2.0.4-alpha.1。如果你刚发布了 alpha 版本,则递增 alpha 版本号。

当你在发布之间对代码进行更改时,注意查看是否有新增功能或破坏性更改。如果发生了其中之一,并且自上次发布以来相应的版本号没有变化,应该递增版本号。例如,如果上次发布的版本是 2.0.3,当前版本是 2.0.4-alpha.2,并且你做了新增更改,那么这个版本号应该是 2.1.0-alpha.1。如果你做了破坏性更改,那么版本号应该变为 3.0.0-alpha.1。如果相应的版本号已经更新,只需递增 alpha 版本号即可。

当你发布一个版本时,移除后缀(除非你想做预发布版本),然后发布,并从头开始。

这个流程之所以有效,是因为它使两个常见的工作流程能够更好地运作。首先,假设一个开发者依赖于你的 crate 的主要版本 2,但他们需要的一个特性目前仅在 Git 上可用。然后你提交了一个破坏性更改。如果你没有同时增加主要版本号,他们的代码将突然以意外的方式失败,可能无法编译,或者会出现奇怪的运行时问题。如果你按照这里的流程操作,Cargo 会通知他们发生了破坏性更改,他们必须解决这个问题或固定一个特定的提交。

接下来,假设一个开发者需要一个他们刚刚贡献的特性,但该特性还没有成为你 crate 发布的版本的一部分。他们已经通过 Git 依赖使用了你的 crate 一段时间,因此项目中的其他开发者已经拥有你仓库的旧版本。如果你没有在 Git 中递增主要版本号,这个开发者无法传达他们的项目现在依赖于刚合并的特性。如果他们推送了他们的更改,他们的同事会发现项目无法再编译,因为 Cargo 会重新使用旧的代码库。另一方面,如果开发者可以递增 Git 依赖的次要版本号,Cargo 会意识到旧的代码库已经过时。

这个工作流程远非完美。它没有提供一种很好的方式来传达多个小或大更改之间的版本变化,而且你仍然需要做一些工作来跟踪版本。然而,它确实解决了 Rust 开发者在使用 Git 依赖项时遇到的两个最常见问题,即使你在版本发布之间做了多个这样的更改,这个工作流程仍然能够捕捉到许多问题。

如果你不太担心版本发布中的小版本号或连续的版本号,你可以通过始终递增版本号的适当部分来改进这个建议的工作流程。不过需要注意的是,取决于你进行此类更改的频率,这可能会使你的版本号变得非常大!

总结

在本章中,我们已经探讨了多种配置、组织和发布 crate 的机制,这些机制既有利于你自己,也有利于他人。我们还讨论了在使用 Cargo 中的依赖项和功能时的一些常见陷阱,希望这些陷阱以后不会再让你措手不及。在下一章,我们将转向测试,深入了解如何超越我们熟悉和喜爱的 Rust 简单的 #[test] 函数。

第六章:测试

在本章中,我们将探讨扩展 Rust 测试功能的各种方式,以及可能想要加入的其他类型的测试。Rust 自带了一些内建的测试工具,这些工具在《Rust 编程语言》中有详细介绍,主要通过#[test]属性和tests/目录进行表示。这些工具能够很好地支持各种应用和规模,通常在你刚开始一个项目时,已经足够用了。然而,随着代码库的发展,测试需求变得更加复杂,你可能需要超越简单地在单个函数上标记#[test]

本章分为两个主要部分。第一部分涵盖了 Rust 的测试机制,比如标准的测试框架和条件性测试代码。第二部分则讨论了其他评估 Rust 代码正确性的方法,如基准测试、代码检查和模糊测试。

Rust 测试机制

要理解 Rust 提供的各种测试机制,首先必须了解 Rust 是如何构建和运行测试的。当你运行cargo test --lib时,Cargo 唯一做的特殊事情就是将--test标志传递给rustc。这个标志告诉rustc生成一个测试二进制文件,运行所有单元测试,而不仅仅是编译包的库或二进制文件。在幕后,--test有两个主要效果。首先,它启用了cfg(test),这样你可以有条件地包含测试代码(稍后会详细介绍)。其次,它使编译器生成一个测试框架:一个精心生成的main函数,运行程序中的每个#[test]函数。

测试框架

编译器通过程序化宏(在第七章中我们将深入讨论)和一些魔法的小技巧,生成测试框架的main函数。基本上,框架将每个被#[test]注解的函数转换成一个测试描述符——这是程序化宏的部分。然后,它将每个描述符的路径暴露给生成的main函数——这就是魔法的部分。描述符包含信息,例如测试的名称、设置的任何附加选项(如#[should_panic])等。核心的测试框架会遍历包中的所有测试,运行它们,捕获结果并打印输出。所以,它还包括解析命令行参数的逻辑(例如--test-threads=1),捕获测试输出,平行运行列出的测试,并收集测试结果。

在写本文时,Rust 开发者正在致力于使测试工具生成的魔法部分成为公开可用的 API,以便开发者能够构建自己的测试工具。这项工作仍处于实验阶段,但该提案与现有模型非常接近。需要解决的部分魔法是如何确保即使 #[test] 函数位于私有子模块中,也能让它们在生成的 main 函数中可用。

集成测试(位于 tests/ 目录中的测试)与单元测试遵循相同的流程,唯一的例外是它们每个都作为独立的 crate 编译,这意味着它们只能访问主 crate 的公共接口,并且是在没有 #[cfg(test)] 的情况下针对主 crate 编译运行的。每个 tests/ 中的文件都会生成一个测试工具。对于 tests/ 下子目录中的文件,不会生成测试工具,这样你可以为测试共享子模块。

Rust 不要求你使用默认的测试工具。你可以选择退出它,并实现你自己的 main 方法,作为测试运行器,通过在 Cargo.toml 中为特定的集成测试设置 harness = false,如清单 6-1 所示。你定义的 main 方法将会被调用来运行测试。

[[test]]
name = "custom"
path = "tests/custom.rs"
harness = false

清单 6-1:退出标准测试工具

没有测试工具时,#[test] 的相关魔法不会发生。相反,你需要编写你自己的 main 函数来运行你希望执行的测试代码。实际上,你正在编写一个普通的 Rust 二进制文件,只不过它是由 cargo test 运行的。这个二进制文件负责处理默认测试工具通常处理的所有事务(如果你希望支持它们的话),比如命令行标志。harness 属性是针对每个集成测试单独设置的,因此你可以拥有一个使用标准测试工具的测试文件,也可以拥有一个不使用它的文件。

没有测试工具的集成测试主要用于基准测试,正如我们稍后将看到的,但它们在你想运行那些不符合标准“一个函数,一个测试”模型的测试时也很有用。例如,你会经常看到没有测试工具的测试与模糊测试器、模型检查器一起使用,或者需要自定义全局设置的测试(如在 WebAssembly 中,或与自定义目标一起使用时)。

#[cfg(test)]

当 Rust 为测试构建代码时,它会设置编译器配置标志 test,你可以利用条件编译来确保代码只有在进行测试时才会被编译。表面上看,这似乎有些奇怪:你不想测试进入生产环境的完全相同的代码吗?当然是的,但只在测试时提供代码,可以让你以几种方式编写更好、更彻底的测试。

仅限测试的 API

首先,只有测试代码可以让你向(单元)测试暴露额外的方法、字段和类型,这样测试不仅可以检查公共 API 是否正常工作,还可以检查内部状态是否正确。例如,考虑hashbrown中的HashMap类型,它是实现标准库HashMap的 crate。HashMap类型实际上只是一个围绕RawTable类型的包装器,后者实现了大部分哈希表的逻辑。假设在对一个空的哈希表执行HashMap::insert后,你想检查哈希表中的一个桶是否非空,如 Listing 6-2 所示。

#[test]
fn insert_just_one() {
  let mut m = HashMap::new();
  m.insert(42, ());
  let full = m.table.buckets.iter().filter(Bucket::is_full).count();
  assert_eq!(full, 1);
}

Listing 6-2:一个访问不可访问内部状态的测试,因此无法编译

这段代码按原样无法编译,因为尽管测试代码可以访问HashMap的私有table字段,但它不能访问RawTable的私有buckets字段,因为RawTable位于不同的模块中。我们可以通过将buckets字段的可见性设置为pub(crate)来解决此问题,但我们并不希望HashMap能触及buckets,因为这可能会意外地破坏RawTable的内部状态。即使将buckets设置为只读也可能会带来问题,因为HashMap中的新代码可能会开始依赖RawTable的内部状态,从而使未来的修改更加困难。

解决方案是使用#[cfg(test)]。我们可以向RawTable添加一个方法,允许在测试时访问buckets,如 Listing 6-3 所示,从而避免为其他代码增加不必要的风险。然后,可以更新 Listing 6-2 中的代码,调用buckets()而不是直接访问私有的buckets字段。

impl RawTable {
  #[cfg(test)]
  pub(crate) fn buckets(&self) -> &[Bucket] {
    &self.buckets
  }
}

Listing 6-3:使用#[cfg(test)]使内部状态在测试上下文中可访问

测试断言的记账

只有在测试期间存在的代码的第二个好处是,你可以增强程序以执行额外的运行时记账操作,然后由测试进行检查。例如,假设你正在编写自己版本的标准库中的BufWriter类型。在测试时,你希望确保BufWriter不会不必要地发出系统调用。最直接的方式是让BufWriter跟踪它在底层Write上调用write的次数。然而,在生产环境中,这些信息并不重要,而跟踪这些信息会带来(边际)性能和内存开销。通过#[cfg(test)],你可以确保记账操作仅在测试时发生,如 Listing 6-4 所示。

struct BufWriter<T> {
  #[cfg(test)]
  write_through: usize,
  // other fields...
}

impl<T: Write> Write for BufWriter<T> {
  fn write(&mut self, buf: &[u8]) -> Result<usize> {
    // ...
    if self.full() {
      #[cfg(test)]
      self.write_through += 1;
      let n = self.inner.write(&self.buffer[..])?;
    // ...
  }
}

Listing 6-4:使用#[cfg(test)]将记账限制在测试上下文中

请记住,test 仅在作为测试编译的 crate 中设置。对于单元测试,这是正在测试的 crate,正如你所期望的那样。然而,对于集成测试,它是作为测试编译的集成测试二进制文件——你正在测试的 crate 只是作为库编译,因此不会设置 test

Doctests

文档注释中的 Rust 代码片段会自动作为测试用例运行。这些通常被称为 doctests。由于 doctests 出现在你的 crate 的公共文档中,用户很可能会模仿它们,因此它们作为集成测试运行。这意味着 doctests 无法访问私有字段和方法,并且 test 在主 crate 的代码中没有设置。每个 doctest 都作为一个独立的 crate 编译,并且在隔离环境中运行,就像用户将 doctest 复制粘贴到自己的程序中一样。

在幕后,编译器对 doctest 进行一些预处理,使其更加简洁。最重要的是,它会自动为你的代码添加一个 fn main。这让 doctest 只关注用户可能关心的重要部分,比如实际使用你库中的类型和方法的部分,而不会包含不必要的样板代码。

你可以通过在 doctest 中定义自己的 fn main 来选择退出这种自动包装。例如,如果你想使用类似 #[tokio::main] async fn main 的异步 main 函数,或者你想在 doctest 中添加其他模块,可能需要这么做。

在你的 doctest 中使用 ? 运算符时,通常不需要使用自定义的 main 函数,因为 rustdoc 会自动进行一些启发式处理,如果你的代码看起来像是使用了 ?(例如,代码以 Ok(()) 结尾),它会将返回类型设置为 Result<(), impl Debug>。如果类型推断在函数的错误类型上让你感到困惑,你可以通过显式指定最后一行的类型来消除歧义,例如:Ok::<(), T>(())

Doctest 具有许多额外的功能,这些功能在为更复杂的接口编写文档时非常实用。第一个功能是隐藏单独的行。如果你在 doctest 的一行前加上 #,该行会在 doctest 编译并运行时被包含,但不会出现在文档生成的代码片段中。这样你可以轻松隐藏当前示例中不重要的细节,例如为虚拟类型实现特征或生成值。如果你希望展示一系列示例,而不每次都显示相同的前导代码,这也非常有用。列表 6-5 给出了一个包含隐藏行的 doctest 示例。

/// Completely frobnifies a number through I/O.
///
/// In this first example we hide the value generation.
/// ```

/// # let unfrobnified_number = 0;

/// # let already_frobnified = 1;

/// assert!(frobnify(unfrobnified_number).is_ok());

/// assert!(frobnify(already_frobnified).is_err());

/// ```
///
/// Here's an example that uses ? on multiple types
/// and thus needs to declare the concrete error type,
/// but we don't want to distract the user with that.
/// We also hide the use that brings the function into scope.
/// ```

/// # use mylib::frobnify;

/// frobnify("0".parse()?)?;

/// # Ok::<(), anyhow::Error>(())

/// ```
///
/// You could even replace an entire block of code completely,
/// though use this _very_ sparingly:
/// ```

/// # /*

/// let i = ...;

/// # */

/// # let i = 42;

/// frobnify(i)?;

/// ```
fn frobnify(i: usize) -> std::io::Result<()> {

列表 6-5:使用 # 在文档测试中隐藏行

#[test] 函数类似,文档测试也支持修改文档测试运行方式的属性。这些属性紧跟在用于表示代码块的三重反引号后面,多个属性可以用逗号分隔。

与测试函数类似,您可以指定 should_panic 属性来指示特定的文档测试中的代码在运行时应当触发 panic,或者使用 ignore 仅在 cargo test--ignored 标志运行时检查代码段。您还可以使用 no_run 属性来指示某个文档测试应编译但不应运行。

属性 compile_fail 告诉 rustdoc 文档示例中的代码不应该编译。这向用户指示某个特定用法不可行,并作为一个有用的测试提醒您在库的相关部分发生变化时更新文档。您还可以使用此属性来检查某些静态属性是否对您的类型有效。列表 6-6 展示了如何使用 compile_fail 来检查某个类型是否没有实现 Send,这可能对在不安全代码中维护安全保证至关重要。

```compile_fail

# struct MyNonSendType(std::rc::Rc<()>);

fn is_send<T: Send>() {}

is_send::<MyNonSendType>();


列表 6-6:测试代码是否无法编译,使用 `compile_fail`

`compile_fail` 是一个相对粗略的工具,因为它没有指示 *为什么* 代码不编译。例如,如果代码因为缺少分号而无法编译,`compile_fail` 测试可能看起来已成功。出于这个原因,您通常需要在确保测试确实因为预期错误无法编译后再添加该属性。如果您需要更细粒度的编译错误测试,例如在开发宏时,建议查看 `trybuild` crate。

## 其他测试工具

测试不仅仅是运行测试函数并查看它们是否产生预期的结果。全面的测试技术、方法论和工具的调研超出了本书的范围,但有一些关键的 Rust 特定内容是您在扩展测试工具时应该了解的。

### 静态分析

您可能不会将静态分析工具(linter)的检查视为测试,但在 Rust 中,它们通常可以视为测试。Rust 的 linter *clippy* 将其多个 lint 分类为 *正确性* lint。这些 lint 能捕捉那些能够编译但几乎肯定是 bug 的代码模式。一些示例包括 `a = b; b = a`,这无法交换 `a` 和 `b`;`std::mem::forget(t)`,其中 `t` 是一个引用;以及 `for x in y.next()`,这只会遍历 `y` 中的第一个元素。如果您尚未在 CI 流水线中运行 clippy,您可能应该开始使用它。

Clippy 附带了许多其他的 lint(静态分析检查),虽然这些通常很有帮助,但有时可能比你希望的更具主观性。例如,默认开启的 `type_complexity` lint 会发出警告,提示你使用了一个特别复杂的类型,例如 `Rc<Vec<Vec<Box<(u32, u32, u32, u32)>>>>`。虽然这个警告鼓励你编写更易读的代码,但你可能会觉得它过于吹毛求疵,难以广泛应用。如果你代码中的某一部分错误地触发了特定的 lint,或者你只是希望允许某一特定实例,你可以使用 `#[allow(clippy::name_of_lint)]` 让该部分代码不受该 lint 的影响。

Rust 编译器也带有一套自己的 lint,形式是警告,尽管这些警告通常更多是针对编写符合惯用法的代码,而不是检查代码的正确性。相反,编译器中的正确性 lint 会被视为错误(可以通过 `rustc -W help` 查看相关列表)。

### 测试生成

编写一个好的测试套件是非常耗费精力的。而且,即使你完成了这项工作,你编写的测试仅仅测试了你在编写它们时考虑的特定行为集。幸运的是,你可以利用一些测试生成技术来开发更好、更全面的测试。这些技术会为你生成输入,用来检查你应用程序的正确性。许多此类工具已经存在,每个工具都有自己的优缺点,因此在这里我只会讲解这些工具的主要策略:模糊测试和属性测试。

#### 模糊测试

关于模糊测试(fuzzing),已经有许多书籍专门讨论了这个话题,但从高层次上看,原理很简单:为你的程序生成随机输入,看看它是否崩溃。如果程序崩溃了,那就是一个 bug。例如,如果你正在编写一个 URL 解析库,你可以通过系统地生成随机字符串并将其传递给解析函数进行模糊测试,直到它发生 panic。若操作不够精细,这个过程可能需要一些时间才能得到结果:如果模糊测试器从 `a` 开始,然后是 `b`,接着是 `c`,依此类推,它需要很长时间才能生成像 `http://[:]` 这样棘手的 URL。在实际应用中,现代的模糊测试工具使用代码覆盖率度量来探索代码中的不同路径,这使得它们能够比真正随机选择输入时更快地达到更高的覆盖度。

模糊测试工具在发现你的代码无法正确处理的奇怪边界情况方面非常出色。它们对你几乎没有设置要求:你只需要将模糊测试工具指向一个接受“可模糊输入”的函数,它就会自动开始工作。例如,Listing 6-7 显示了一个如何进行 URL 解析器模糊测试的例子。

libfuzzer_sys::fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let _ = url::Url::parse(s);
}
});


Listing 6-7: 使用 `libfuzzer` 进行 URL 解析器模糊测试

模糊测试工具将会为闭包生成半随机输入,任何能够形成有效 UTF-8 字符串的输入都会被传递给解析器。注意,代码这里并没有检查解析是否成功或失败——而是关注于查找解析器因为违反了内部不变条件而 panic 或崩溃的情况。

模糊测试工具会一直运行,直到你终止它,所以大多数模糊测试工具都内建有机制,在探索了某些测试用例后停止。如果你的输入不是一个可以简单模糊测试的类型——比如哈希表——你通常可以使用像`arbitrary`这样的库,将模糊测试生成的字节字符串转化为更复杂的 Rust 类型。它看起来像魔法,但在幕后,它其实是通过非常直接的方式实现的。这个库定义了一个`Arbitrary`特性,其中有一个方法`arbitrary`,用于从随机字节源构建实现该特性的类型。像`u32`或`bool`这样的原始类型会从输入中读取必要的字节数来构建其有效实例,而像`HashMap`或`BTreeSet`这样的复杂类型会从输入中生成一个数字来决定它们的长度,然后对它们的内部类型调用`Arbitrary`该次数。甚至有一个属性`#[derive(Arbitrary)]`,它通过对每个包含类型调用`arbitrary`来实现`Arbitrary`!如果你想更深入地了解模糊测试,我推荐从`cargo-fuzz`开始。

#### 基于属性的测试

有时候你不仅仅想检查程序是否崩溃,还希望它能够按预期执行。这意味着你的`add`函数没有崩溃是好事,但如果它告诉你`add(1, 4)`的结果是`68`,那可能还是错误的。这就是*基于属性的测试*发挥作用的地方;你描述一些代码应该遵循的属性,然后属性测试框架生成输入并检查这些属性是否确实成立。

使用基于属性的测试的常见方法是,首先编写一个简单但天真的版本的代码,这是你确信正确的版本。然后,对于给定的输入,你将该输入同时提供给你想测试的代码和简化但天真的版本。如果两个实现的结果或输出相同,那么你的代码就是好的——这就是你正在寻找的正确性属性——但如果不相同,你可能已经发现了一个 bug。你还可以使用基于属性的测试来检查与正确性无关的属性,比如某个实现的操作是否比另一个实现的操作耗时更少。普遍的原则是,你希望实际版本和测试版本之间的任何结果差异都是有信息量的并且可操作的,这样每次失败都能让你改进。天真的实现可能是你想替换或增强的标准库中的某个实现(比如`std::collections::VecDeque`),或者它可能是你正在尝试优化的算法的简化版本(比如天真的矩阵乘法与优化后的矩阵乘法)。

如果这种生成输入直到满足某些条件的方式听起来很像模糊测试,那是因为它确实如此——比我更聪明的人曾经说过,模糊测试“只是”基于属性的测试,而你测试的属性就是“它不会崩溃”。

基于属性的测试的一个缺点是它更加依赖于输入的描述。与模糊测试不断尝试所有可能的输入不同,属性测试往往依赖开发者注释的指导,如“0 到 64 之间的数字”或“包含三个逗号的字符串”。这使得属性测试能够更快地覆盖到模糊测试可能需要很长时间才能随机遇到的情况,但它确实需要手动工作,并且可能错过一些重要但冷门的 bug 输入。然而,随着模糊测试和属性测试的不断融合,模糊测试也开始具备这种基于约束的搜索能力。

如果你对基于属性的测试生成感兴趣,我推荐从 `proptest` crate 开始。

### 测试增强

假设你已经设置好了一个非常棒的测试套件,并且你的代码通过了所有的测试。这是如此辉煌。但有一天,通常可靠的测试却莫名其妙地失败了,或者因为分段错误崩溃。有两种常见的原因会导致这种非确定性的测试失败:竞态条件,测试可能仅在两个操作以特定顺序在不同线程上发生时才会失败,以及不安全代码中的未定义行为,例如某些不安全代码从未初始化的内存中读取特定值。

使用常规测试捕捉这些类型的 bug 可能很困难——通常你无法充分控制线程调度、内存布局和内容,或者其他随机系统因素来编写可靠的测试。你可以在循环中多次运行每个测试,但即便如此,如果错误的发生足够罕见或不太可能,它可能仍然不会被捕捉到。幸运的是,有一些工具可以帮助增强你的测试,使捕捉这些类型的 bug 变得更容易。

其中第一个是令人惊叹的工具 *Miri*,它是 Rust 的 *中级中间表示(MIR)* 的解释器。MIR 是 Rust 的一种内部简化表示,帮助编译器在无需考虑 Rust 本身的语法糖的情况下,找到优化并检查属性。通过 Miri 运行测试就像运行 `cargo miri test` 一样简单。Miri *解释* 你的代码,而不是像正常的二进制文件那样编译和运行,这使得测试运行的速度会慢一些。但作为回报,Miri 可以在执行代码的每一行时跟踪整个程序的状态。这使得 Miri 能够检测并报告你的程序是否表现出某些类型的未定义行为,比如未初始化的内存读取、在变量被销毁后使用值,或者越界指针访问。与其让这些操作导致奇怪的程序行为,这些行为可能只在某些情况下才会导致可观察的测试失败(如崩溃),Miri 能够在它们发生时检测到,并立即告诉你。

例如,考虑一下 列表 6-8 中非常不安全的代码,它创建了两个指向同一值的独占引用。

let mut x = 42;
let x: *mut i32 = &mut x;
let (x1, x2) = unsafe { (&mut *x, &mut *x) };
println!("{} {}", x1, x2);


列表 6-8:Miri 检测到的极其不安全的代码是错误的

在撰写本文时,如果你通过 Miri 运行这段代码,你会遇到一个错误,错误信息会准确指出问题所在:

error: Undefined Behavior: trying to reborrow for Unique at alloc1383, but parent tag <2772> does not have an appropriate item in the borrow stack
--> src/main.rs:4:6
|
4 | let (x1, x2) = unsafe { (&mut *x, &mut *x) };
| ^^ trying to reborrow for Unique at alloc1383, but parent tag <2772> does not have an appropriate item in the borrow stack


另一个值得关注的工具是 *Loom*,这是一个巧妙的库,旨在确保你的测试以每种相关的并发操作交替顺序运行。从高层次来看,Loom 跟踪所有线程间同步点,并反复运行你的测试,每次都调整线程从这些同步点开始执行的顺序。因此,如果线程 A 和线程 B 都需要获取相同的 `Mutex`,Loom 会确保测试分别以 A 先获取和 B 先获取的顺序运行。Loom 还跟踪原子操作、内存顺序和对 `UnsafeCell`(我们将在第九章讨论)访问,并检查线程是否不当访问这些内容。如果测试失败,Loom 会提供一个详细的报告,告诉你哪些线程按照什么顺序执行,这样你就可以确定崩溃发生的原因。

### 性能测试

编写性能测试很困难,因为通常很难准确模拟一个能够反映你 crate 的实际使用情况的工作负载。但拥有这样的测试是很重要的;如果你的代码突然变得慢了 100 倍,这应该被视为一个 bug,然而没有性能测试的话,你可能不会发现这种回归。如果你的代码运行得快了 100 倍,这也可能意味着出了问题。这两种情况都是将自动化性能测试纳入 CI 的充分理由——如果性能发生了大幅变化,无论是变快还是变慢,你都应该知道。

与功能测试不同,性能测试没有一个共同的、明确定义的输出。功能测试要么通过,要么失败,而性能测试可能会给出吞吐量、延迟曲线、处理的样本数,或其他与应用程序相关的指标。此外,性能测试可能需要在循环中执行一个函数数十万次,或者可能需要几个小时才能在一个分布式的多核机器网络上运行。由于这个原因,很难在一般意义上讨论如何编写性能测试。因此,在本节中,我们将讨论编写 Rust 性能测试时可能遇到的一些问题,并探讨如何减轻这些问题。三个常被忽视的常见陷阱是性能波动、编译器优化和 I/O 开销。让我们依次探讨这些问题。

#### 性能波动

性能可能由于各种原因而有所不同,许多因素会影响特定机器指令序列的运行速度。有些因素很明显,比如 CPU 和内存的时钟速度,或者机器的负载情况,但很多因素则更为微妙。例如,你的内核版本可能会改变分页性能,用户名的长度可能会改变内存布局,而房间的温度可能会导致 CPU 降频。最终,如果你运行基准测试两次,几乎不可能得到相同的结果。事实上,即使使用相同的硬件,你也可能观察到显著的波动。或者,从另一个角度来看,你的代码可能变得更慢或更快,但由于基准测试环境的差异,这种变化可能是不可见的。

除非你恰好能够在一个高度多样化的机器集群上重复运行基准测试,否则没有完美的方法可以消除性能结果中的所有波动。即便如此,尽力处理这些测量的波动以从基准测试给出的噪音数据中提取信号仍然很重要。在实践中,我们应对波动的好帮手是多次运行每个基准测试,然后查看测量结果的*分布*,而不仅仅是单一的结果。Rust 提供了一些有助于此的工具。例如,像 `hdrhistogram` 这样的 crate 使我们能够查看诸如“95% 的样本运行时间范围是什么?”这样的统计数据,而不仅仅是询问“这个函数的平均运行时间是多少?”为了更加严谨,我们还可以使用像统计学中的零假设检验等技术,来建立一定的信心,确保一个测得的差异确实代表了一个真实的变化,而不仅仅是噪音。

统计假设检验的讲解超出了本书的范围,但幸运的是,这项工作中的大部分已经由其他人完成。例如,`criterion` crate 就可以为你完成所有这些工作,并且更多。你所需要做的就是提供一个函数,供它调用以运行基准测试的一次迭代,然后它会运行适当次数,以确保结果可靠。它随后会生成基准测试报告,其中包括结果摘要、异常值分析,甚至是随时间变化的趋势图表。当然,它不能消除仅在特定硬件配置上测试的影响,但至少它会对在多次执行中可以测量到的噪音进行分类。

#### 编译器优化

现在的编译器非常聪明。它们会消除死代码,在编译时计算复杂表达式,展开循环,并执行其他黑魔法,以挤出代码的每一滴性能。通常这很棒,但当我们试图衡量某段代码的执行速度时,编译器的聪明才智可能会给我们带来无效的结果。例如,考虑在清单 6-9 中基准测试 `Vec::push` 的代码。

let mut vs = Vec::with_capacity(4);
let start = std::time::Instant::now();
for i in 0..4 {
vs.push(i);
}
println!("took {:?}", start.elapsed());


列表 6-9:一个可疑的快速性能基准

如果你查看使用类似于优秀的*godbolt.org*或`cargo-asm`在发布模式下编译的代码的汇编输出,你会立即注意到有些问题:`Vec::with_capacity`和`Vec::push`的调用,甚至整个`for`循环,都完全消失了。它们已经被完全优化掉了。编译器意识到代码中实际上没有任何地方需要执行向量操作,因此将它们作为死代码剔除了。当然,编译器完全有权这样做,但对于基准测试来说,这并没有什么帮助。

为了避免这种基准测试优化,标准库提供了`std::hint::black_box`。这个函数曾引发过很多争论和困惑,并且在撰写本文时仍待稳定化,但它非常有用,值得在这里讨论。它的核心实际上是一个恒等函数(即接受`x`并返回`x`),它告诉编译器假设该函数的参数以任意(合法的)方式被使用。它并不会阻止编译器对输入参数应用优化,也不会阻止编译器优化返回值的使用方式。相反,它鼓励编译器实际计算函数的参数(假设该参数会被使用),并将结果存储在某个 CPU 可以访问的地方,以便`black_box`可以用计算出的值调用。编译器可以自由地在编译时计算输入参数,但它仍应将结果注入程序中。

这个函数就是我们在很多基准测试需求中所需要的,尽管并非所有的需求都能用它来解决。例如,我们可以对列表 6-9 进行标注,以便不再让向量访问被优化掉,如列表 6-10 所示。

let mut vs = Vec::with_capacity(4);
let start = std::time::Instant::now();
for i in 0..4 {
black_box(vs.as_ptr());
vs.push(i);
black_box(vs.as_ptr());
}
println!("took {:?}", start.elapsed());


列表 6-10:列表 6-9 的修正版本

我们已经告诉编译器假设`vs`在每次循环迭代中以任意方式被使用,无论是在调用`push`之前还是之后。这迫使编译器按顺序执行每个`push`,而不合并或以其他方式优化连续的调用,因为它必须假设在每次调用之间,`vs`可能会发生“无法优化掉的任意操作”(这就是`black_box`部分的作用)。

注意,我们使用了`vs.as_ptr()`而不是`&vs`。这是因为编译器应该假设`black_box`可以对其参数执行任何*合法*的操作。通过共享引用修改`Vec`是不合法的,因此如果我们使用`black_box(&vs)`,编译器可能会注意到`vs`在循环的每次迭代之间不会发生变化,从而根据这一观察实施优化!

#### I/O 开销测量

编写基准测试时,容易不小心测量到错误的东西。例如,我们通常希望实时了解基准测试进行到什么阶段。为了做到这一点,我们可能会编写像列表 6-11 那样的代码,旨在衡量`my_function`的运行速度:

let start = std::time::Instant::now();
for i in 0..1_000_000 {
println!("iteration {}", i);
my_function();
}
println!("took {:?}", start.elapsed());


列表 6-11:我们到底在基准测试什么?

这看起来似乎达到了目标,但实际上它并没有真正衡量`my_function`的执行速度。相反,这个循环更有可能告诉我们打印一百万个数字需要多长时间。循环体中的`println!`在幕后做了大量的工作:它将二进制整数转换为十进制数字以便打印,锁定标准输出,使用至少一个系统调用写出一系列 UTF-8 码点,然后释放标准输出锁。不仅如此,如果你的终端打印输入的速度很慢,系统调用可能会被阻塞。这消耗了很多计算周期!而调用`my_function`所需的时间可能与之相比微不足道。

当你的基准测试使用随机数时,会发生类似的情况。如果你在循环中运行`my_function(rand::random())`,你很可能主要在测量生成一百万个随机数的时间。获取当前时间、读取配置文件或启动新线程的情况也是如此——这些事情相对来说都花费很长时间,可能最终会掩盖你实际上想要测量的时间。

幸运的是,一旦你意识到这个问题,通常很容易找到解决方法。确保基准测试循环体内几乎只有你想要测量的代码。所有其他代码应在基准测试开始之前或基准测试的测量部分之外运行。如果你使用`criterion`,可以查看它提供的不同计时循环——它们都旨在适应需要不同测量策略的基准测试案例!

## 总结

在本章中,我们详细探讨了 Rust 所提供的内置测试功能。我们还介绍了许多在测试 Rust 代码时有用的测试工具和技巧。本章是本书中专注于中级 Rust 使用的最后一章。从下一章关于声明式和过程宏开始,我们将更多关注 Rust 代码。下页见!


# 第七章:宏

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/rs-rs/img/chapterart.png)

宏本质上是一个让编译器为你编写代码的工具。你给编译器提供一个生成代码的公式,并且根据一些输入参数,编译器会将每次调用宏的地方替换为公式运行后的结果。你可以将宏看作是自动代码替换,规则由你定义。

Rust 的宏有许多不同的形态和大小,旨在使实现各种代码生成变得简单。两种主要类型是*声明式*宏和*程序化*宏,我们将在本章中探讨这两种类型。我们还将探讨一些宏如何在日常编码中派上用场,以及更高级使用时可能出现的一些陷阱。

来自 C 语言及其衍生语言的程序员可能已经习惯了 C 和 C++的“邪恶领域”,在这里你可以使用`#define`将每个`true`改成`false`,或者移除所有`else`关键字。如果你有这种经验,你需要将宏与做“坏事”的感觉分开。Rust 中的宏远不是 C 语言宏的“西部荒野”。它们遵循(大部分)良好定义的规则,并且具有相当的抗滥用能力。

## 声明式宏

声明式宏是使用`macro_rules!`语法定义的宏,它允许你方便地定义类似函数的宏,而不需要为此编写专门的 crate(就像你做程序化宏时那样)。一旦定义了声明式宏,你可以通过宏的名称后跟感叹号来调用它。我喜欢把这种宏看作是编译器辅助的搜索和替换:它为许多常规的、结构化良好的转换任务提供了解决方案,并且能够消除重复的模板代码。在你至今为止使用 Rust 的经验中,你认定的大多数宏可能就是声明式宏。需要注意的是,并非所有类似函数的宏都是声明式宏;`macro_rules!`本身就是一个例子,`format_args!`也是。`!`后缀仅仅是告诉编译器宏调用将在编译时被替换成不同的源代码。

为什么声明式宏被称为声明式宏,可能一开始并不显而易见。毕竟,你不是在程序中“声明”所有东西吗?在这里,*声明式*指的是你不需要说明宏的输入如何转换成输出,只是声明当输入是 B 时,你希望输出是 A。你声明它应该是这样的,然后编译器会处理所有需要的解析和重排工作,把你的声明变成现实。这使得声明式宏简洁而富有表现力,尽管它也有使得宏显得相当晦涩的倾向,因为你只有一个有限的语言来表达你的声明。

### 何时使用宏

声明式宏主要在你发现自己反复写相同代码时派上用场,这时你可能希望不再这样做。它们最适合做一些比较机械的替换——如果你打算做一些复杂的代码转换或大量的代码生成,过程宏可能更合适。

我最常使用声明式宏的场景是当我发现自己编写重复且结构相似的代码时,比如在测试和 trait 实现中。对于测试,我通常希望多次运行相同的测试,但使用稍微不同的配置。我可能会有像清单 7-1 中展示的那种代码。

fn test_inner(init: T, frobnify: bool) { ... }

[test]

fn test_1u8_frobnified() {
test_inner(1u8, true);
}
// ...

[test]

fn test_1i128_not_frobnified() {
test_inner(1i128, false);
}


清单 7-1:重复的测试代码

虽然这样做是可行的,但它过于冗长、重复,而且容易出错。使用宏,我们可以做得更好,正如在清单 7-2 中展示的那样。

macro_rules! test_battery {
(\((\)t:ty as $name:ident),)) => {
$(
mod \(name { #[test] fn frobnified() { test_inner::<\)t>(1, true) }
#[test]
fn unfrobnified() { test_inner::<$t>(1, false) }
}
)

}
}
test_battery! {
u8 as u8_tests,
// ...
i128 as i128_tests
);


清单 7-2:让宏为你重复执行

这个宏将每个以逗号分隔的指令展开为一个单独的模块,每个模块包含两个测试,一个使用`test_inner`并传入`true`,另一个传入`false`。虽然宏的定义并不简单,但它使得添加更多的测试变得更加容易。每个类型都是`test_battery!`调用中的一行,宏将自动生成针对`true`和`false`参数的测试。我们还可以让它为`init`的不同值生成测试。现在,我们大大减少了忘记测试某个特定配置的可能性!

trait 实现的情况类似。如果你定义了自己的 trait,通常你会希望为标准库中的多个类型实现这个 trait,即使这些实现很简单。假设你发明了`Clone` trait,并希望为标准库中所有`Copy`类型实现它。你可以像清单 7-3 中的宏一样,使用宏而不是手动为每个类型编写实现。

macro_rules! clone_from_copy {
(\((\)t:ty),) => {
$(impl Clone for $t {
fn clone(&self) -> Self { self }
})

}
}
clone_from_copy![bool, f32, f64, u8, i8, /
... */];


清单 7-3:使用宏一次性为多个相似类型实现一个 trait

在这里,我们为每个提供的类型生成一个`Clone`的实现,其主体仅使用`*`从`&self`中`copy`。你可能会想,为什么我们不为`T: Copy`类型添加一个通用的`Clone`实现呢?我们可以这么做,但一个主要的原因是,这样做会迫使其他 crate 中的类型也使用相同的`Clone`实现,尤其是那些恰好是`Copy`的类型。一个名为*特化*的实验性编译器功能可能提供一个解决方法,但在写这篇文章时,该功能的稳定化还有一段时间。因此,目前来说,我们最好具体列举类型。这个模式不仅适用于简单的转发实现:例如,你可以轻松修改清单 7-3 中的代码,为所有整数类型实现一个`AddOne` trait!

### 它们是如何工作的

每种编程语言都有一个*语法*,它规定了组成源代码的各个字符如何转换成*符号*。符号是语言的最低级构建块,如数字、标点符号、字符串和字符字面量、标识符等;在这一层级,语言关键字和变量名之间没有区别。例如,文本`(value + 4)`在类似 Rust 的语法中会被表示为五个符号序列`(`、`value`、`+`、`4`、`)`。将文本转换成符号的过程还为编译器的其余部分与解析文本的复杂低级细节之间提供了一层抽象。例如,在符号表示中没有空白字符的概念,`/*"foo"*/`和`"/*foo*/"`有不同的表示(前者不是符号,后者是一个字符串字面量符号,内容为`/*foo*/`)。

一旦源代码被转换成符号序列,编译器会遍历该序列并为符号分配语法意义。例如,`()`定界符的符号表示一个分组,`!`符号表示宏调用,依此类推。这就是*解析*过程,它最终生成一个描述源代码结构的抽象语法树(AST)。举个例子,考虑表达式`let x = || 4`,它由符号序列`let`(关键字)、`x`(标识符)、`=`(标点符号)、两个`|`(标点符号)和`4`(字面量)组成。当编译器将其转换成语法树时,它表示为一个*语句*,其中*模式*是*标识符*`x`,其右侧的*表达式*是一个*闭包*,该闭包有一个空的*参数列表*,并且其主体是*整数字面量*`4`的*字面量表达式*。请注意,语法树表示比符号序列更加丰富,因为它为符号组合分配了语法意义,这符合语言的语法规则。

Rust 宏决定了给定符号序列转换成的语法树——当编译器在解析过程中遇到宏调用时,它必须先求值宏,以确定替换的符号,这些符号最终将成为宏调用的语法树。然而,此时编译器仍然在解析符号,可能还没有准备好求值宏,因为它所做的仅仅是解析宏定义的符号。因此,编译器会推迟解析宏调用的定界符中的内容,并记住输入的符号序列。当编译器准备好评估指定的宏时,它会在符号序列上求值宏,解析它生成的符号,并将结果语法树替换到宏调用的位置。

从技术上讲,编译器确实会为宏的输入做一些解析。具体来说,它会解析出基本的内容,如字符串字面量和分隔组,从而生成一系列令牌*树*,而不仅仅是令牌。例如,代码 `x - (a.b + 4)` 会解析成三个令牌树的序列。第一个令牌树是单个令牌,它是标识符 `x`;第二个是单个令牌,它是标点符号 `-`;第三个是一个组(使用圆括号作为分隔符),它本身包含五个令牌树的序列:`a`(标识符)、`.`(标点符号)、`b`(另一个标识符)、`+`(另一个标点符号)、`4`(字面量)。这意味着传递给宏的输入不一定是有效的 Rust,但必须是 Rust 编译器可以解析的代码。例如,你不能在 Rust 中写出 `for <- x`,除非它在宏调用中,但如果宏生成有效语法,则可以这么做。另一方面,你不能将 `for {` 传递给宏,因为它没有闭合的括号。

声明式宏始终生成有效的 Rust 输出。你不能让一个宏生成,比如说,一个函数调用的一半,或者一个没有后续代码块的`if`。声明式宏必须生成一个表达式(基本上是任何你可以赋值给变量的内容),一个语句,如`let x = 1;`,一个项,如 trait 定义或 `impl` 块,一个类型,或一个 `match` 模式。这使得 Rust 宏不容易被滥用:你根本不能写出一个生成无效 Rust 代码的声明式宏,因为宏定义本身无法编译!

这就是声明式宏的高级概述——当编译器遇到宏调用时,它会将调用分隔符中的令牌传递给宏,解析结果令牌流,并用生成的 AST 替换宏调用。

### 如何编写声明式宏

对声明式宏支持的所有语法的详尽解释超出了本书的范围。然而,我们会涵盖一些基础知识,因为有些细节值得指出。

声明式宏由两个主要部分组成:*匹配器* 和 *转录器*。一个宏可以有多个匹配器,每个匹配器都有一个关联的转录器。当编译器遇到宏调用时,它会从第一个到最后一个遍历宏的匹配器,当找到一个匹配调用中令牌的匹配器时,它会通过遍历相应转录器的令牌来替换宏调用。列表 7-4 展示了声明式宏规则的不同部分是如何配合工作的。

macro_rules! /* macro name / {
(/
1st matcher /) => { / 1st transcriber / };
(/
2nd matcher /) => { / 2nd transcriber */ };
}


列表 7-4:声明式宏定义组件

#### 匹配器

你可以将宏匹配器视为一个符号树,编译器尝试以预定义的方式扭曲和弯曲它,以匹配它在调用处给定的输入符号树。举个例子,考虑一个带有匹配器`$a:ident + $b:expr`的宏。这个匹配器会匹配任何标识符(`:ident`)后跟一个加号,再后跟任何 Rust 表达式(`:expr`)。如果宏被调用时传入`x + 3 * 5`,编译器会发现,匹配器会匹配,当它将`$a = x`和`$b = 3 * 5`时,即使`*`没有出现在匹配器中,编译器仍然意识到`3 * 5`是一个有效的表达式,因此可以用`$b:expr`进行匹配,因为`$b:expr`接受任何表达式(`:`expr`部分)。

匹配器可能会变得相当复杂,但它们具有巨大的表达能力,就像正则表达式一样。举个不那么复杂的例子,这个匹配器接受一个或多个(`+`)由逗号分隔(`),`)的键/值对,格式为`key => value`:

\((\)key:expr => $value:expr),+


更重要的是,调用带有此匹配器的宏的代码可以为键或值提供任意复杂的表达式——匹配器的魔力将确保键和值表达式被适当地分割。

宏规则支持多种*片段类型*;你已经见过用于标识符的`:ident`和用于表达式的`:expr`,但还有用于类型的`:ty`,甚至还有适用于任何单个符号树的`:tt`!你可以在 Rust 语言参考的第三章中找到完整的片段类型列表([`doc.rust-lang.org/reference/macros-by-example.html`](https://doc.rust-lang.org/reference/macros-by-example.html))。这些片段类型,加上用于重复匹配模式的机制(`$()`),使你能够匹配大多数简单的代码模式。不过,如果你发现很难用匹配器表达你想要的模式,可能想尝试使用过程宏,在那里你不需要遵循`macro_rules!`所要求的严格语法。我们将在本章稍后更详细地讨论这些内容。

#### 转录器

一旦编译器匹配了声明性宏匹配器,它会使用匹配器的相关转录器生成代码。宏匹配器定义的变量被称为*元变量*,编译器会在转录器中替换每个元变量的任何出现(如上一节中的`$key`)为匹配该部分匹配器的输入。如果匹配器中有重复(例如,那个示例中的`$(),+`),你可以在转录器中使用相同的语法,它会对输入中的每个匹配重复一次,每次扩展都持有该迭代的适当元变量替换。例如,对于`$key`和`$value`匹配器,我们可以编写以下转录器,为每个匹配到的`$key`/`$value`对生成一次`insert`调用,将其插入到某个映射中:

\((map.insert(\)key, $value);)+


请注意,这里我们希望每次重复时都有一个分号,而不仅仅是用来分隔重复,因此我们将分号放在重复的括号内。

#### 卫生

你可能听说过 Rust 宏是*卫生的*,也许你认为卫生性使得它们更安全或更容易使用,但可能并不完全理解这意味着什么。当我们说 Rust 宏是卫生性的,我们的意思是声明式宏(通常)不能影响那些没有显式传递给它的变量。一个简单的例子是,如果你声明一个名为`foo`的变量,然后调用一个也定义了名为`foo`的宏,那么宏的`foo`默认在调用位置(即宏被调用的地方)是不可见的。同样,除非显式传递给宏,否则宏不能访问在调用位置定义的变量(包括`self`)。

大多数时候,你可以将宏标识符看作是存在于与它们扩展到的代码不同的命名空间中。例如,看看示例 7-5 中的代码,它包含一个宏,尝试(并失败)在调用位置遮蔽一个变量。

macro_rules! let_foo {
($x:expr) => {
let foo = $x;
}
}
let foo = 1;
// expands to let foo = 2;
let_foo!(2);
assert_eq!(foo, 1);


示例 7-5:宏存在于它们自己的小宇宙中。大多数情况下是这样。

在编译器展开`let_foo!(2)`之后,断言看起来应该会失败。然而,原始代码中的`foo`和宏生成的`foo`存在于不同的宇宙中,它们之间没有任何关系,除了它们恰好共享一个人类可读的名字。事实上,编译器会抱怨宏中的`let foo`是一个未使用的变量。这种卫生性在调试宏时非常有帮助——你不必担心由于恰好选择了相同的变量名而在宏调用者中意外地遮蔽或覆盖变量!

然而,这种卫生性分离仅适用于变量标识符。声明式宏确实共享类型、模块和函数的命名空间与调用位置。这意味着你的宏可以定义新函数,并且这些函数可以在调用作用域中被调用,向其他地方(而非传入的地方)定义的类型添加新实现,引入一个新模块,在宏调用的位置可以访问该模块,等等。这是设计上的考虑——如果宏不能像这样影响更广泛的代码,那么在生成类型、特征实现和函数时使用它们就会更加繁琐,而这些正是宏最为有用的地方。

宏中类型的非卫生性在编写你希望从 crate 中导出的宏时尤其重要。为了使宏真正可重用,你不能假设调用者作用域中会有什么类型。也许调用你宏的代码中定义了 `mod std {}` 或导入了自己的 `Result` 类型。为了安全起见,请确保使用完全指定的类型,比如 `::core::option::Option` 或 `::alloc::boxed::Box`。如果你特别需要引用定义宏的 crate 中的某些内容,使用特殊的元变量 `$crate`。

如果你希望宏影响调用者作用域中的特定变量,你可以选择在宏和调用者之间共享标识符。关键是要记住标识符的来源,因为这就是标识符将绑定到的命名空间。如果你在宏中写 `let foo = 1;`,那么标识符 `foo` 的来源是宏,它将永远不会在调用者的标识符命名空间中可用。另一方面,如果宏接受 `$foo:ident` 作为参数,然后写 `let $foo = 1;`,当调用者用 `!(foo)` 调用宏时,标识符将来源于调用者,因此将引用调用者作用域中的 `foo`。

标识符不必明确传递;在宏外部源代码中出现的任何标识符都会引用调用者作用域中的标识符。在示例 7-6 中,变量标识符出现在 `:expr` 中,但仍然可以访问调用者作用域中的变量。

macro_rules! please_set {
($i:ident, $x:expr) => {
$i = $x;
}
}
let mut x = 1;
please_set!(x, x + 1);
assert_eq!(x, 2);


示例 7-6:让宏访问调用站点的标识符

我们本可以在宏中使用 `= $i + 1`,但不能使用 `= x + 1`,因为 `x` 这个名称在宏的定义作用域内是不可用的。

关于声明式宏和作用域的最后一点:与 Rust 中几乎所有其他内容不同,声明式宏在声明之前在源代码中是不存在的。如果你尝试在文件后面使用一个宏定义,这将不起作用!这对你的整个项目都适用;如果你在一个模块中声明了一个宏,并希望在另一个模块中使用它,那么声明宏的模块必须出现在 crate 的前面,而不是后面。如果 `foo` 和 `bar` 是 crate 根目录下的模块,并且 `foo` 声明了一个 `bar` 想要使用的宏,那么 `mod foo` 必须出现在 `mod bar` 之前,在 *lib.rs* 中!

## 过程宏

你可以将过程宏看作是一个解析器和代码生成器的组合,其中你编写连接代码。在高层次上,通过过程宏,编译器收集输入给宏的标记序列,并运行你的程序来确定用什么标记替换它们。

过程宏之所以如此命名,是因为你定义了*如何*根据输入的令牌生成代码,而不是仅仅编写生成的代码。编译器端几乎没有智能——在它看来,过程宏充其量是一个源代码预处理器,可能会任意替换代码。要求你的输入可以被解析为一串 Rust 令牌仍然有效,但仅此而已!

### 过程宏的类型

过程宏有三种不同的类型,每种类型都针对特定的常见用例:

+   类似函数的宏,比如 `macro_rules!` 生成的那些

+   属性宏,比如 `#[test]`

+   派生宏,比如 `#[derive(Serialize)]`

这三种类型都使用相同的底层机制:编译器将一个令牌序列提供给你的宏,并期望你返回一个令牌序列,该序列(可能)与输入树相关。然而,它们在宏的调用方式和输出处理方式上有所不同。我们将简要介绍每一种。

#### 类似函数的宏

类似函数的宏是过程宏中最简单的形式。像声明式宏一样,它只是将宏代码替换为过程宏返回的代码。然而,与声明式宏不同的是,这些宏(像所有过程宏一样)不需要保持卫生性,并且不会保护你避免与调用站点周围代码中的标识符交互。相反,你的宏需要明确指出哪些标识符应该与周围代码重叠(使用 `Span::call_site`),哪些应该视为宏的私有(使用 `Span::mixed_site`,我们稍后会讨论)。

#### 属性宏

属性宏也会整体替换它所附加的项目,但它需要两个输入:出现在属性中的令牌树(去掉属性名称)和整个项的令牌树,包括该项可能包含的其他属性。属性宏使你能够轻松编写一个过程宏,来转换某个项,例如通过向函数定义添加前言或尾声(就像 `#[test]` 所做的那样),或修改结构体的字段。

#### 派生宏

派生宏与另外两个宏有所不同,它不是替换宏的目标,而是向宏的目标添加内容。尽管这种限制看起来可能很严苛,但派生宏是促使创建过程宏的最初动因之一。具体来说,`serde` crate 需要派生宏来实现它现在广为人知的 `#[derive(Serialize, Deserialize)]` 魔法。

派生宏可以说是最简单的程序宏,因为它们有非常严格的格式:你只能在被注解的项后追加项;你不能替换被注解的项,也不能让派生过程接受参数。派生宏确实允许你定义*辅助属性*——这些属性可以放置在被注解的类型内部,以提供给派生宏线索(如`#[serde(skip)]`)——但这些属性主要像标记一样,不能作为独立的宏存在。

### 程序宏的成本

在讨论每种不同类型的程序宏何时适用之前,值得讨论一下为什么在使用程序宏之前你可能需要三思——也就是增加的编译时间。

程序宏可以显著增加编译时间,主要有两个原因。第一个是它们通常带来一些比较重的依赖。例如,`syn` crate,它提供了一个用于 Rust 令牌流的解析器,使得编写程序宏的体验更加轻松,但启用所有特性后,它的编译可能需要几十秒。你可以(并且应该)通过禁用不需要的特性以及在调试模式下编译程序宏,而不是在发布模式下编译,来减轻这一问题。代码在调试模式下通常编译速度是发布模式的几倍,而且对于大多数程序宏来说,你甚至不会注意到执行时间的差异。

程序宏增加编译时间的第二个原因是,它们让你在不自觉的情况下生成大量代码。虽然宏可以让你免去实际输入生成代码的麻烦,但它并不能减轻编译器必须解析、编译和优化这些代码的负担。随着你使用更多程序宏,生成的模板代码会不断累积,这可能会导致编译时间的膨胀。

话虽如此,程序宏的实际执行时间在整体编译时间中很少成为一个重要因素。虽然编译器必须等待程序宏完成其操作后才能继续,但实际上,大多数程序宏不会做任何复杂的计算。尽管如此,如果你的程序宏特别复杂,编译时间可能会在程序宏代码上消耗相当大的执行时间,这一点值得关注!

### 所以你认为你需要一个宏

现在让我们来看看每种程序宏的好用场景。我们先从简单的开始:派生宏。

#### 何时使用派生宏

派生宏仅用于一件事:自动化实现一个可以自动化的特征。并非所有特征都有明显的自动化实现,但很多特征是有的。实际上,只有当特征经常被实现,并且对于任何给定类型的实现非常明显时,才应考虑为该特征添加一个派生宏。第一个条件看起来像常识;如果你的特征只会实现一两次,那么编写和维护一个复杂的派生宏可能并不值得。

第二个条件可能看起来更奇怪:什么是“明显”的实现?考虑一个像`Debug`这样的特征。如果你知道`Debug`的作用,并且看到一个类型,你可能会期望`Debug`的实现输出每个字段的名称,以及字段值的调试表示。这正是`derive(Debug)`所做的。那`Clone`呢?你可能会期望它只克隆每个字段——同样,这正是`derive(Clone)`所做的。对于`derive(serde::Serialize)`,我们期望它序列化每个字段及其值,而它确实做到了。一般来说,你希望特征的派生能匹配开发者对于其可能作用的直觉。如果某个特征没有明显的派生方式,或者更糟的是,如果你的派生方式与明显的实现不匹配,那么你可能最好不要为它提供一个派生宏。

#### 何时使用类似函数的宏

对于类似函数的宏,很难给出一个通用的经验法则。你可能会说,当你想使用类似函数的宏,但无法通过`macro_rules!`来表达时,应该使用类似函数的宏,但这是一个相当主观的指南。毕竟,如果你真心投入,声明式宏可以做很多事情!

有两个特别好的理由可以选择使用类似函数的宏:

+   如果你已经有了一个声明式宏,并且它的定义变得越来越复杂,以至于宏难以维护。

+   如果你有一个纯函数,需要在编译时执行,但无法使用`const fn`来表达。一个例子是`phf` crate,它在编译时接收到一组键,并使用完美哈希函数生成一个哈希映射或集合。另一个例子是`hex-literal`,它接收一个十六进制字符的字符串,并将其替换为相应的字节。一般来说,任何不仅仅在编译时转换输入,而是对输入进行实际计算的内容,都是一个很好的候选者。

我不推荐仅仅为了打破宏的卫生规则而去使用类似函数的宏。类似函数的宏的卫生特性可以避免许多调试上的麻烦,在故意破坏它之前,你应该三思而后行。

#### 何时使用属性宏

这使我们得到了属性宏。尽管这些可以说是过程宏中最通用的,但也是最难知道何时使用的。多年来,我一再看到属性宏增加了四种方式,为代码添加了巨大的价值。

**测试生成**

1.  很常见的情况是希望在多个不同配置下运行相同的测试,或者在相同引导代码下运行许多类似的测试。虽然声明性宏可以帮助您表达这一点,但如果您有类似 `#[foo_test]` 的属性,它在每个注解测试中引入设置序言和后记,或者像 `#[test_case(1)] #[test_case(2)]` 这样的可重复属性,标记给定测试应多次重复执行,那么您的代码通常更易于阅读和维护。

**框架注解**

1.  类似 `rocket` 的库使用属性宏来增强函数和类型,使框架可以在用户无需进行大量手动配置的情况下使用这些信息。能够写 `#[get("/<name>")] fn hello(name: String)` 要比设置一个包含函数指针等内容的配置结构体方便得多。基本上,这些属性构成了一个小型领域特定语言(DSL),隐藏了许多必要的样板代码。类似地,异步 I/O 框架 `tokio` 允许您使用 `#[tokio::main] async fn main()` 自动设置运行时并运行您的异步代码,从而避免在每个异步应用程序的 `main` 函数中编写相同的运行时设置。

**透明中间件**

1.  一些库希望以不显眼的方式注入到您的应用程序中,以提供不改变应用程序功能的增值服务。例如,跟踪和日志记录库如 `tracing` 和度量收集库如 `metered` 允许您通过向函数添加属性来透明地为其加入一些额外的代码,然后该函数的每次调用都将执行库所指定的一些附加代码。

**类型转换器**

1.  有时,你希望不仅仅为某个类型派生特性,而是实际以某种根本的方式更改该类型的定义。在这些情况下,属性宏是解决方案。`pin_project` crate 就是一个很好的例子:它的主要目的是确保所有对给定类型字段的固定访问都按照 Rust 的`Pin`类型和`Unpin`特性所设定的严格规则进行(我们将在第八章详细讨论这些类型)。它通过生成额外的辅助类型、向注解的类型添加方法,并引入静态安全检查,确保用户不会不小心自我陷害。虽然`pin_project`本可以通过过程派生宏来实现,但那种派生的特性实现可能并不明显,这违背了我们使用过程宏的规则之一。

### 它们是如何工作的?

所有过程宏的核心是`TokenStream`类型,它可以被迭代以获取组成该令牌流的单独`TokenTree`项。一个`TokenTree`可以是单个令牌——例如标识符、标点符号或字面量——或者是另一个被定界符如`()`或`{}`括起来的`TokenStream`。通过遍历`TokenStream`,你可以解析出任何你想要的语法,只要这些单独的令牌是有效的 Rust 令牌。如果你想将输入专门解析为 Rust 代码,你可能需要使用`syn` crate,它实现了一个完整的 Rust 解析器,并可以将`TokenStream`转换为一个易于遍历的 Rust AST。

对于大多数过程宏,你不仅想要解析一个`TokenStream`,还需要生成 Rust 代码,将其注入到调用该过程宏的程序中。有两种主要方法可以做到这一点。第一种是手动构造一个`TokenStream`并逐一扩展每个`TokenTree`。第二种是使用`TokenStream`的`FromStr`实现,它允许你解析包含 Rust 代码的字符串,转换成`TokenStream`,方法是`"".parse::<TokenStream>()`。你也可以混合使用这两种方法;如果你想在宏的输入前添加一些代码,只需为前言构造一个`TokenStream`,然后使用`Extend`特性来追加原始输入。

令牌比我目前所描述的要稍微神奇一点,因为每个令牌,实际上每个`TokenTree`,也都有一个*跨度*。跨度是编译器将生成的代码与产生该代码的源代码关联的方式。每个令牌的跨度标记了该令牌的起源。例如,考虑一个(声明式)宏,如列表 7-7 中所示,它为提供的类型生成一个简单的`Debug`实现。

macro_rules! name_as_debug {
($t:ty) => {
impl ::core::fmt::Debug for \(t { fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { ::core::write!(f, ::core::stringify!(\)t)) }
} }; }


列表 7-7:实现`Debug`的一个非常简单的宏

现在假设有人用`name_as_debug!(u31)`调用了这个宏。从技术上讲,编译错误发生在宏内部,特别是在我们写`for $t`的位置(`$t`的另一个用法可以处理无效的类型)。但我们希望编译器能将错误指向用户代码中的`u31`——实际上,这就是跨度所能做到的。

生成的代码中,`$t`的跨度是映射到宏调用中的`$t`的代码。然后,这些信息会被传递到编译器,并与最终的编译错误关联。当编译器最终打印出该错误时,编译器会从宏内部打印错误,提示类型`u31`不存在,但会高亮显示宏调用中的`u31`参数,因为这就是错误所关联的跨度!

跨度非常灵活,它们使得你能够编写过程宏,如果使用`compile_error!`宏,可以生成复杂的错误消息。顾名思义,`compile_error!`会使编译器在其所在位置发出错误,并使用提供的字符串作为错误消息。这看起来可能不太有用,直到你将它与跨度配合使用。通过将你为`compile_error!`调用生成的`TokenTree`的跨度设置为输入某些子集的跨度,你实际上是在告诉编译器发出此编译错误,并将用户指向源代码中的这一部分。结合这两种机制,宏可以生成看似源自相关代码部分的错误,即使实际的编译错误出现在生成代码中,用户甚至从未看到过这些生成的代码!

跨度的强大功能不止于此;跨度也是 Rust 宏清洁性实现的方式。当你构造一个`Ident`标记时,你还需要为该标识符提供跨度,这个跨度决定了该标识符的作用域。如果你将标识符的跨度设置为`Span::call_site()`,则该标识符会在宏调用的地方解析,从而不会与周围的作用域隔离。另一方面,如果你将其设置为`Span::mixed_site()`,则(变量)标识符会在宏定义的地方解析,因此在调用站点中与同名变量完全保持清洁。`Span::mixed_site`之所以这样命名,是因为它遵循`macro_rules!`的标识符清洁规则,正如我们之前所讨论的那样,它在变量使用宏定义站点时与使用调用站点解析类型、模块及其他内容时“混合”了标识符解析。

## 总结

本章我们介绍了声明式宏和过程宏,并探讨了你在自己代码中可能会发现它们各自有用的时机。我们还深入探讨了支撑每种类型宏的机制,以及在编写自己宏时需要注意的一些特性和陷阱。在下一章,我们将开始我们的异步编程之旅,介绍`Future`特性。我保证——它就在下一页。


# 第八章:异步编程

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/rs-rs/img/chapterart.png)

异步编程,顾名思义,就是不按同步方式执行的编程。高层次地说,异步操作是在后台执行的——程序不会等待异步操作完成,而是会立即继续执行下一行代码。如果你还不熟悉异步编程,可能会觉得这个定义不够充分,因为它并没有真正解释异步编程*是什么*。为了真正理解异步编程模型以及它在 Rust 中是如何工作的,我们首先必须了解它的替代模型。也就是说,我们需要理解*同步*编程模型,才能更好地理解*异步*编程模型。这一点很重要,不仅能帮助我们理清概念,还能展示使用异步编程的权衡:异步方案并不总是正确的选择!我们将在本章开始时,快速回顾一下为什么异步编程作为一个概念会被提出;然后,我们将深入探讨 Rust 中异步操作的实现原理。

## 异步操作到底是怎么回事?

在深入了解同步和异步编程模型的细节之前,我们首先需要快速了解一下计算机在运行程序时究竟在做什么。

计算机很快。真的很快。事实上,它们的速度快到大部分时间都在等待事情发生。除非你在解压文件、编码音频或进行复杂的计算,否则你的 CPU 很可能大部分时间都是空闲的,在等待操作完成。它在等待网络数据包的到达,等待鼠标的移动,等待磁盘写入某些字节,甚至可能只是在等待从主内存中读取的数据完成。从 CPU 的角度来看,大多数此类事件之间仿佛过了很长时间。当某个事件发生时,CPU 执行一些指令,然后又回到等待状态。看看你的 CPU 使用率——它可能一直徘徊在个位数的低值,这也是大多数时间它的工作状态。

### 同步接口

同步接口允许你的程序(或者更准确地说,程序中的单个线程)一次只能执行一个操作;每个操作必须等待前一个同步操作完成后才能运行。大多数你在实际开发中遇到的接口都是同步的:你调用它们,它们做一些事情,最终在操作完成后返回,程序才能继续运行。我们稍后在本章中将看到,这背后的原因是:使操作变为异步需要一些额外的机制。除非你需要异步的好处,否则坚持同步模型会少一些繁琐的步骤和复杂的情况。

同步接口隐藏了所有这些等待;应用程序调用一个函数,要求“将这些字节写入这个文件”,过一段时间后,那个函数完成,下一行代码执行。在幕后,实际上发生的是,操作系统将写操作排队到磁盘,并将应用程序挂起,直到磁盘报告写入完成。应用程序体验到的是该函数执行时间很长,但实际上并没有执行,只是处于等待状态。

以这种方式按顺序执行操作的接口通常也被称为*阻塞*,因为接口中的操作必须等待某个外部事件发生,才能继续执行,而这个事件的发生*阻塞*了后续的执行。无论你是称接口为同步还是阻塞,基本的概念是相同的:应用程序不会继续进行,直到当前操作完成。在等待操作时,应用程序也在等待。

同步接口通常被认为易于使用且容易推理,因为你的代码一次只执行一行。但它们也只能让应用程序一次执行一项任务。这意味着,如果你希望程序等待用户输入或网络数据包,你就没戏了,除非操作系统提供了专门的操作来处理这种情况。同样,即使你的应用程序在磁盘写文件时可以做一些其他有用的工作,它也没有这个选择,因为文件写操作会阻塞执行!

### 多线程

到目前为止,允许并发执行的最常见解决方案是使用*多线程*。在一个多线程程序中,每个线程负责执行一系列独立的阻塞操作,操作系统在各个线程之间进行多路复用,以便如果有线程可以继续执行,就会继续执行。如果某个线程被阻塞,其他线程可能仍然可以运行,因此应用程序可以继续进行有用的工作。

通常,这些线程通过使用像锁或通道这样的同步原语相互通信,从而使应用程序仍然能够协调它们的工作。例如,你可能会有一个线程等待用户输入,一个线程等待网络数据包,另一个线程等待这两个线程中的任何一个在线程之间共享的通道上发送消息。

多线程为你提供了*并发*——能够在任何时刻执行多个独立操作的能力。由运行该应用程序的系统(在本例中是操作系统)决定在没有被阻塞的线程中选择哪个线程执行,并决定接下来执行哪个线程。如果某个线程被阻塞,系统可以选择运行另一个可以继续执行的线程。

多线程结合阻塞接口能够让你走得很远,许多生产级软件都是以这种方式构建的。但是这种方法也有它的缺点。首先,快速跟踪所有这些线程会变得繁琐;如果你必须为每一个并发任务创建一个线程,包括像等待键盘输入这样简单的任务,线程会迅速增加,管理这些线程之间的交互、通信和协调的复杂性也随之增加。

其次,线程之间的切换成本随着线程数量的增加而增加。每当一个线程停止运行,另一个线程接替运行时,你需要向操作系统调度器做一次回调,而这不是免费的。在某些平台上,创建新线程也是一个相对重量级的过程。高性能需求的应用程序通常通过重用线程和使用允许你在多个相关操作上进行阻塞的操作系统调用来减轻这一成本,但最终你还是会面临同样的问题:阻塞接口要求你有与要进行的阻塞调用数量相同的线程。

最后,线程为你的程序引入了*并行性*。并发性和并行性的区别微妙但重要:并发性意味着你的任务执行是交替进行的,而并行性意味着多个任务同时执行。如果你有两个任务,它们的执行用 ASCII 表示可能像 `_-_-_`(并发性)与 `=====`(并行性)这样。多线程并不一定意味着并行性——即使你有许多线程,可能只有一个核心,这样只有一个线程在某一时刻执行——但两者通常是密切相关的。你可以通过使用 `Mutex` 或其他同步原语来使两个线程的执行互斥,但这会引入额外的复杂性——线程希望并行运行。并行性通常是一个好事——谁不希望他们的程序在更多核心上运行得更快呢——但这也意味着你的程序必须处理对共享数据结构的真正并发访问。这意味着从 `Rc`、`Cell` 和 `RefCell` 转向更强大但也更慢的 `Arc` 和 `Mutex`。虽然你*可能*想在并发程序中使用后者类型来启用并行性,但线程*迫使*你使用它们。我们将在第十章详细探讨多线程。

### 异步接口

现在我们已经探讨了同步接口,接下来可以看看它的替代方案:异步或*非阻塞*接口。异步接口是指可能不会立刻返回结果,而是可能表示结果会在稍后的某个时间可用。这让调用者有机会在此期间做其他事情,而不必等到特定操作完成才“睡眠”。在 Rust 的术语中,异步接口是返回`Poll`的方法,如示例 8-1 所定义。

enum Poll {
Ready(T),
Pending
}


示例 8-1:异步的核心:“你现在就可以得到,或者稍后再来”的类型

`Poll` 通常出现在以 `poll` 开头的函数的返回类型中——这些方法表示它们可以在不阻塞的情况下尝试某个操作。我们将在本章后面详细讨论它们是如何做到这一点的,但一般来说,它们会在通常会阻塞之前尽可能多地执行操作,然后返回。而且关键是,它们会记住它们暂停的位置,以便当有进一步进展时,可以恢复执行。

这些非阻塞函数使我们能够轻松地并发执行多个任务。例如,如果你想从网络或用户的键盘中读取数据,哪个先有事件可用,你只需要在循环中轮询两个,直到其中一个返回 `Poll::Ready`。不需要任何额外的线程或同步!

这里的*循环*这个词应该让你有点紧张。你可不希望你的程序每秒执行三十亿次循环,而可能还要等几分钟才有下一个输入发生。在阻塞接口的世界里,这不是问题,因为操作系统会简单地让线程进入休眠,然后在发生相关事件时唤醒它,但在这个全新的非阻塞世界里,我们该如何避免在等待时浪费 CPU 周期呢?这正是本章其余部分要讲解的内容。

### 标准化轮询

为了实现一个可以以非阻塞方式使用每个库的世界,我们可以让每个库的作者编写他们自己的 `poll` 方法,虽然这些方法的名字、签名和返回类型可能略有不同,但那样很快就会变得难以管理。相反,在 Rust 中,轮询通过 `Future` 特征来标准化。`Future` 的简化版本如示例 8-2 所示(我们将在本章后面介绍真正的版本)。

trait Future {
type Output;
fn poll(&mut self) -> PollSelf::Output;
}


示例 8-2:`Future` 特征的简化视图

实现了 `Future` 特征的类型被称为 *futures*,代表那些可能还不可用的值。一个 future 可能代表下次网络数据包的到来、下次鼠标光标的移动,或者只是某段时间过去的时刻。你可以将 `Future<Output = Foo>` 理解为“一个将来会生成 `Foo` 类型的类型”。这种类型在其他语言中通常被称为 *promises*——它们承诺最终会返回指定的类型。当一个 future 最终返回 `Poll::Ready(T)` 时,我们说这个 future *解析* 为 `T`。

有了这个特征,我们可以将提供 `poll` 方法的模式泛化。我们不需要像 `poll_recv` 和 `poll_keypress` 这样的特定方法,而可以使用像 `recv` 和 `keypress` 这样的方法,它们都会返回 `impl Future` 和适当的 `Output` 类型。这并不改变你必须轮询它们的事实——我们稍后会处理这个问题——但它至少意味着这些挂起值有了标准化的接口,我们不需要到处使用 `poll_` 前缀。

## 人体工程学未来

以我所描述的方式编写一个实现 `Future` 的类型相当麻烦。为了理解为什么,首先来看一下 列表 8-3 中那个相当简单的异步代码块,它只是尝试将输入通道 `rx` 中的消息转发到输出通道 `tx`。

async fn forward(rx: Receiver, tx: Sender) {
while let Some(t) = rx.next().await {
tx.send(t).await;
}
}


列表 8-3:使用 `async` 和 `await` 实现一个通道转发的未来

这段代码使用 `async` 和 await 语法编写,看起来与其同步代码非常相似,且易于阅读。我们只是简单地在一个循环中发送每条接收到的消息,直到没有更多消息,每个 `await` 点对应一个同步变体可能会阻塞的地方。现在想象一下,如果你必须手动实现 `Future` 特征来表达这段代码会怎样。由于每次调用 `poll` 都从函数顶部开始,你需要打包必要的状态,以便从上次代码暂停的地方继续执行。结果相当难看,正如 列表 8-4 所展示的那样。

enum Forward { 1
WaitingForReceive(ReceiveFuture, Option<Sender>),
WaitingForSend(SendFuture, Option<Receiver>),
}

impl Future for Forward {
type Output = (); 2
fn poll(&mut self) -> PollSelf::Output {
match self { 3
Forward::WaitingForReceive(recv, tx) => {
if let Poll::Ready((rx, v)) = recv.poll() {
if let Some(v) = v {
let tx = tx.take().unwrap(); 4
*self = Forward::WaitingForSend(tx.send(v), Some(rx)); 5
// Try to make progress on sending.
return self.poll(); 6
} else {
// No more items.
Poll::Ready(())
}
} else {
Poll::Pending
}
}
Forward::WaitingForSend(send, rx) => {
if let Poll::Ready(tx) = send.poll() {
let rx = rx.take().unwrap();
*self = Forward::WaitingForReceive(rx.receive(), Some(tx));
// Try to make progress on receiving.
return self.poll();
} else {
Poll::Pending
}
}
}
}
}


列表 8-4:手动实现一个通道转发的未来

你在 Rust 中很少需要再写这样的代码了,但它提供了关于底层工作原理的重要洞察,所以让我们一起了解一下。首先,我们将未来类型定义为一个`enum`,用来追踪我们当前在等待什么。这是因为当我们返回`Poll::Pending`时,下一次调用`poll`会从函数的顶部重新开始。我们需要某种方式来知道我们当时停在哪里,这样我们才能知道继续进行哪个操作。此外,根据我们正在做的事情,我们还需要跟踪不同的信息:如果我们在等待`receive`完成,我们需要保留那个`ReceiveFuture`(这个定义在本示例中没有显示),这样我们下次被轮询时就能轮询它,`SendFuture`也是一样。这里的`Option`可能也会让你觉得奇怪;我们很快就会解释它们。

当我们为`Forward`实现`Future`时,我们将其输出类型声明为`()`,因为这个未来实际上并不返回任何东西。相反,当它完成从输入通道到输出通道的所有转发时,未来就会解析(没有结果)。在一个更完整的示例中,我们的转发类型的`Output`可能是一个`Result`,这样它就可以将来自`receive()`和`send()`的错误信息传递回堆栈中的函数,以便轮询转发的完成。但这段代码已经足够复杂了,我们暂时将这个问题留到以后再说。

当`Forward`被轮询时,它需要从上次停下的地方恢复,我们通过匹配当前在`self`中持有的枚举变体来找出这一点。无论进入哪个分支,第一步是轮询当前操作阻塞进度的未来;如果我们试图接收,我们就轮询`ReceiveFuture`,如果我们试图发送,我们就轮询`SendFuture`。如果对`poll`的调用返回`Poll::Pending`,那么我们无法取得进展,我们也会返回`Poll::Pending`。但如果当前的未来被解析,我们就有事情要做!

当其中一个内部的未来解析时,我们需要通过切换`self`中存储的枚举变体来更新当前的操作。为了做到这一点,我们必须从`self`中移出,以调用`Receiver::receive`或`Sender::send`——但是我们不能这样做,因为我们只有`&mut self`。因此,我们将必须移出的状态存储在一个`Option`中,我们通过`Option::take`移出它。这看起来有些傻,因为我们即将覆盖`self`,因此这些`Option`总是`Some`,但有时一些技巧是必须的,才能让借用检查器满意。

最后,如果我们确实取得了进展,我们会再次轮询`self`,这样如果我们能立即处理待发送或待接收的操作,就会继续进行。这实际上是实现真实`Future`特质时的必要步骤,稍后我们会回到这个话题,但现在可以把它当作一种优化来理解。

我们刚刚手写了一个 *状态机*:一种具有多个可能状态并根据特定事件在状态之间移动的类型。实际上,这只是一个相当简单的状态机。试想一下,如果你需要为更复杂的用例编写这样的代码,其中还包含额外的中间步骤,会是怎样的一种情况!

除了编写笨重的状态机外,我们还必须知道`Sender::send`和`Receiver::receive`返回的 futures 类型,以便我们能够将它们存储在我们的类型中。如果这些方法返回的是`impl Future`,我们将无法为我们的变体写出类型。`send`和`receive`方法还必须获取发送者和接收者的所有权;如果没有获取所有权,它们返回的 futures 的生命周期将与`self`的借用相关联,而当我们从`poll`返回时,生命周期就会结束。但那样是不行的,因为我们要尝试将这些 futures *存储在* `self`中。

最终,这段代码既难以编写,也难以阅读,还难以修改。例如,如果我们想添加错误处理,代码的复杂度将显著增加。幸运的是,有一种更好的方法!

### async/await

Rust 1.39 为我们引入了`async`关键字和紧密相关的`await`后缀操作符,我们在清单 8-3 中的原始示例中使用了它们。它们一起提供了一种更方便的机制,用于编写像清单 8-5 中那样的异步状态机。具体来说,它们让你可以以一种方式编写代码,看起来根本不像是状态机!

async fn forward(rx: Receiver, tx: Sender) {
while let Some(t) = rx.next().await {
tx.send(t).await;
}
}


清单 8-5:使用`async`和`await`实现一个频道转发的 future,重复自清单 8-3

如果你对`async`和`await`没有太多经验,清单 8-4 和清单 8-5 之间的区别可能会让你大致明白为什么 Rust 社区对它们的出现如此兴奋。但由于这是一本中级书籍,让我们更深入地探讨一下,理解这一小段代码是如何替代更长的手动实现的。为此,我们首先需要谈论一下 *生成器*——`async`和`await`的实现机制。

#### 生成器

简单来说,生成器是一段代码,带有一些额外的由编译器生成的部分,使得它能够在执行过程中暂停,或 *yield*,然后在稍后从上次暂停的地方恢复。以清单 8-3 中的`forward`函数为例。假设它执行到调用`send`时,频道当前已满。函数无法继续执行,但它也不能阻塞(毕竟这是非阻塞代码),因此它需要返回。现在假设频道最终清空,我们希望继续发送。如果我们从头再次调用`forward`,它会再次调用`next`,而我们之前尝试发送的项目会丢失,这样就不好了。相反,我们将`forward`变成一个生成器。

每当`forward`生成器无法再继续执行时,它需要将当前状态存储在某个地方,以便在执行恢复时,能够在正确的位置和正确的状态下恢复。它通过编译器生成的一个关联数据结构保存状态,该结构包含生成器在某一时刻的所有状态。该数据结构上的一个方法(也是编译器生成的)允许生成器从当前状态(存储在`&mut self`中)恢复,并在生成器再次无法继续执行时更新状态。

这种“返回但允许我稍后恢复”的操作称为*yielding*,其有效含义是它在返回的同时保持一些额外的状态。稍后当我们想恢复对`forward`的调用时,我们会调用生成器的已知入口点(即*恢复方法*,对于`async`生成器来说是`poll`),生成器检查之前存储在`self`中的状态来决定下一步做什么。这与我们在列表 8-4 中手动完成的操作完全相同!换句话说,列表 8-5 中的代码松散地等价于列表 8-6 中所示的假设代码。

generator fn forward(rx: Receiver, tx: Sender) {
loop {
let mut f = rx.next();
let r = if let Poll::Ready(r) = f.poll() { r } else { yield };
if let Some(t) = r {
let mut f = tx.send(t);
let _ = if let Poll::Ready(r) = f.poll() { r } else { yield };
} else { break Poll::Ready(()); }
}
}


列表 8-6:将`async`/`await`转化为生成器

截至目前,生成器在 Rust 中实际上是不可用的——它们仅在编译器内部用于实现`async`/`await`——但这在未来可能会改变。生成器在许多情况下非常有用,例如在不需要携带`struct`的情况下实现迭代器,或者实现一个`impl Iterator`,它能够逐个处理生成项。

如果你仔细观察列表 8-5 和 8-6,你可能会发现一旦知道每个`await`或`yield`实际上是函数的一个返回,你就会觉得它们有些神奇。毕竟,函数中有几个局部变量,且不清楚它们在我们稍后恢复时是如何恢复的。这正是编译器生成的生成器部分发挥作用的地方。编译器透明地注入代码,在执行时将这些变量持久化到生成器的关联数据结构中,而不是栈中。因此,如果你声明、写入或读取某个局部变量`a`,你实际上是在操作类似于`self.a`的东西。问题解决!这一切实际上都非常神奇。

手动实现的 `forward` 和 `async`/`await` 版本之间有一个微妙但重要的区别,即后者可以跨 `yield` 点持有引用。这使得像清单 8-5 中的 `Receiver::next` 和 `Sender::send` 这样的函数可以使用 `&mut self`,而不是像清单 8-4 中那样使用 `self`。如果我们在手动状态机实现中尝试使用 `&mut self` 的接收器,借用检查器将无法确保 `Receiver` 存储在 `Forward` 中的实例在 `Receiver::next` 被调用和它返回的 future 解析之间不会被引用,因此它会拒绝这段代码。只有将 `Receiver` 移动到 future 中,我们才能说服编译器相信 `Receiver` 不会被其他方式访问。与此同时,使用 `async`/`await` 时,借用检查器可以在编译器将代码转换成状态机之前检查代码,并验证 `rx` 在 future 被丢弃之前确实没有再次被访问,直到 `await` 返回。

### Pin 和 Unpin

我们还没有完全完成。虽然生成器很有趣,但从目前为止我描述的技术中会出现一个挑战。特别是,如果生成器中的代码(或者等价地,`async` 块)引用了局部变量,那么到底会发生什么并不明确。在清单 8-5 中的代码中,如果下一个消息不可立即获取,则`rx.next()`返回的 future 必须持有 `rx` 的引用,以便它知道生成器下一次恢复时该从哪里重新开始。当生成器 `yield` 时,future 和它所包含的引用会被存储在生成器内部。但是如果生成器被移动了,现在会发生什么呢?特别地,看看清单 8-7 中的代码,它调用了 `forward`。

async fn try_forward(rx: Receiver, tx: Sender) -> Option {
let mut f = forward(rx, tx);
if f.poll().is_pending() { Some(f) } else { None }
}


清单 8-7:轮询之后移动一个 future

`try_forward` 函数只轮询一次 `forward`,以便尽可能多地转发消息而不阻塞。如果接收方可能仍会生成更多消息(即,如果它返回的是 `Poll::Pending` 而不是 `Poll::Ready(None)`),这些消息会被推迟到稍后转发,通过将转发的 future 返回给调用者,调用者可以选择在合适的时机再次轮询。

让我们结合目前对`async`和`await`的理解,逐步分析这里发生了什么。当我们轮询`forward`生成器时,它会进入`while`循环若干次,最终返回`Poll::Ready(())`,如果接收方已结束,或者返回`Poll::Pending`,否则。如果返回`Poll::Pending`,则生成器包含一个由`rx.next()`或`tx.send(t)`返回的`future`。这两个`future`都包含对最初传递给`forward`的某个参数的引用(分别是`rx`和`tx`),这些引用也必须存储在生成器中。但是,当`try_forward`返回整个生成器时,生成器的字段也会移动。因此,`rx`和`tx`不再位于内存中的相同位置,存储在临时`future`中的引用不再指向正确的数据!

我们在这里遇到的是一个*自指*数据结构的案例:一种同时包含数据和对该数据的引用的结构。使用生成器,这些自指结构非常容易构造,不能支持它们将对易用性产生重大打击,因为这意味着你将无法在任何`yield`点之间保持引用。Rust 支持自指数据结构的(巧妙的)解决方案是`Pin`类型和`Unpin`特征。简而言之,`Pin`是一个包装类型,它阻止被包装类型(安全地)移动,而`Unpin`是一个标记特征,表示实现该特征的类型*可以*从`Pin`中安全地移除。

#### Pin

这里有很多细节需要探讨,让我们从`Pin`包装器的一个具体用法开始。Listing 8-2 给出了`Future`特征的简化版本,但现在我们准备好揭示简化的部分内容。Listing 8-8 展示了更接近最终形式的`Future`特征。

trait Future {
type Output;
fn poll(self: Pin<&mut Self>) -> PollSelf::Output;
}


Listing 8-8: 带有`Pin`的`Future`特征的较简化视图

特别地,这个定义要求你在`Pin<&mut Self>`上调用`poll`。一旦你得到了一个`Pin`包装的值,就意味着你与这个值之间的契约:这个值永远不会再移动。这意味着你可以根据需要在内部构造自引用,正如你为生成器所期望的那样。

但是,如何让`Pin`调用`poll`呢?`Pin`如何确保包含的值不会移动?要了解这个魔法是如何运作的,我们来看看`std::pin::Pin`的定义和一些关键方法,如 Listing 8-9 所示。

struct Pin

{ pointer: P }
impl

Pin

where P: Deref {
pub unsafe fn new_unchecked(pointer: P) -> Self;
}
impl<'a, T> Pin<&'a mut T> {
pub unsafe fn get_unchecked_mut(self) -> &'a mut T;
}
impl

Deref for Pin

where P: Deref {
type Target = P::Target;
fn deref(&self) -> &Self::Target;
}


Listing 8-9: `std::pin::Pin`及其关键方法

这里有很多内容需要理解,我们需要多次查看 Listing 8-9 的定义,直到所有细节都能理顺,所以请耐心一点。

首先,你会注意到`Pin`持有的是*指针类型*。也就是说,它并不是直接持有某个`T`,而是持有一个通过`Deref`解引用到`T`的类型`P`。这意味着,你不会直接拥有一个`Pin<MyType>`,而是会拥有`Pin<Box<MyType>>`、`Pin<Rc<MyType>>`或`Pin<&mut MyType>`。这样设计的原因很简单——`Pin`的主要目的是确保一旦你把`T`放在`Pin`后面,`T`就不会移动,因为这样做可能会使存储在`T`中的自引用失效。如果`Pin`直接持有`T`,那么仅仅移动`Pin`就足以使这个不变式失效!在本节的其余部分,我将`P`称为*指针*类型,将`T`称为*目标*类型。

接下来,注意到`Pin`的构造函数`new_unchecked`是一个不安全的函数。这是因为编译器无法实际检查指针类型是否真的承诺被指向的(目标)类型不会再移动。例如,考虑一个栈上的变量`foo`。如果`Pin`的构造函数是安全的,我们可以执行`Pin::new(&mut foo)`,然后调用一个需要`Pin<&mut Self>`的方法(因此假设`Self`不会再移动),接着丢弃`Pin`。此时,我们可以任意修改`foo`,因为它不再被借用——包括移动它!然后我们可以再次将它固定,并调用相同的方法,但该方法并不会察觉任何它可能在第一次调用时构造的自引用指针现在已经无效了。

接着是`get_unchecked_mut`方法,它返回一个对`Pin`的指针类型后面的`T`的可变引用。这个方法也是不安全的,因为一旦我们给出了一个`&mut T`,调用者必须保证不会使用这个`&mut T`来移动`T`或以其他方式使其内存无效,否则任何自引用都会失效。如果这个方法不是不安全的,调用者可以调用一个接受`Pin<&mut Self>`的方法,然后在两个`Pin<&mut _>`上调用`get_unchecked_mut`的安全版本,再使用`mem::swap`交换`Pin`后面的值。如果我们随后再次在任一`Pin`上调用一个接受`Pin<&mut Self>`的方法,它会假设`Self`没有移动,但这种假设会被破坏,任何它存储的内部引用都会无效!

或许令人惊讶的是,`Pin<P>`总是实现了`Deref<Target = T>`,而且这是完全安全的。原因在于,`&T`不会让你在没有写其他不安全代码(例如`UnsafeCell`,我们将在第九章讨论)的情况下移动`T`。这是一个很好的例子,说明了为什么不安全代码块的作用范围不仅限于它所包含的代码。如果你在应用程序的某个地方(不安全地)用`UnsafeCell`替换了一个`&`后面的`T`,那么*可能*这个`&T`最初来自一个`Pin<&mut T>`,而你现在破坏了`Pin`后面`T`永远不能移动的这个不变式,即使你在不安全地替换`&T`的地方根本没有提到`Pin`!

#### Unpin: 安全固定的关键

在此时,你可能会问:既然获取可变引用本身就不安全,为什么不让`Pin`直接持有`T`呢?也就是说,为什么不通过指针类型间接访问,而是将`get_unchecked_mut`的契约设为:只有在你没有移动`Pin`时调用它才是安全的。这个问题的答案就在于,`Pin`的指针设计允许我们进行一种巧妙的安全使用。回想一下,我们最初需要`Pin`的原因是希望能够使用可能包含自身引用的目标类型(比如生成器),并为其方法提供一个保证,即目标类型未发生移动,从而确保内部的自引用仍然有效。`Pin`使我们能够使用类型系统来强制执行这个保证,这是很棒的。但不幸的是,按目前的设计,`Pin`使用起来非常笨拙。这是因为它总是需要不安全的代码,即使你正在处理一个不包含任何自引用的目标类型,也不关心它是否已被移动。

这时标记特征`Unpin`就发挥作用了。为某个类型实现`Unpin`,简单地声明该类型在作为目标类型时,能够安全地从`Pin`中移出。也就是说,该类型保证在作为目标类型使用时,永远不会使用`Pin`所提供的关于引用对象不再移动的任何保证,因此这些保证可以被打破。`Unpin`是一个自动特征,就像`Send`和`Sync`一样,因此编译器会为任何只包含`Unpin`成员的类型自动实现`Unpin`。只有那些明确选择不实现`Unpin`的类型(比如生成器)以及包含这些类型的类型才是`!Unpin`。

对于`Unpin`类型的目标,我们可以提供一个更简单的安全接口给`Pin`,正如在清单 8-10 中所示。

impl

Pin

where P: Deref, P::Target: Unpin {
pub fn new(pointer: P) -> Self;
}
impl

DerefMut for Pin

where P: DerefMut, P::Target: Unpin {
fn deref_mut(&mut self) -> &mut Self::Target;
}


清单 8-10:用于`Unpin`目标类型的安全 API `Pin`

要理解清单 8-10 中的安全 API,可以思考清单 8-9 中的不安全方法的安全要求:`Pin::new_unchecked`函数是不安全的,因为调用者必须保证引用对象不能被移出`Pin`,并且指针类型的`Deref`、`DerefMut`和`Drop`的实现不会通过它们接收到的引用移动引用对象。这些要求是为了确保一旦我们将`Pin`交给某个`T`,就不再移动该`T`。但是,如果`T`是`Unpin`,它已经声明自己不关心是否被移动,即使之前它是被固定的,因此如果调用者没有满足这些要求也没问题!

类似地,`get_unchecked_mut`是`unsafe`的,因为调用者必须保证它不会将`T`从`&mut T`中移出——但对于`T: Unpin`,`T`已经声明它在被固定后仍然可以被移动,因此这个安全要求不再重要。这意味着对于`Pin<P> where P::Target: Unpin`,我们可以简单地提供这两个方法的安全版本(`DerefMut`是`get_unchecked_mut`的安全版本)。事实上,我们甚至可以提供一个`Pin::into_inner`,它会在目标类型是`Unpin`时简单地返回拥有的`P`,因为`Pin`在这种情况下基本上没有意义!

#### 获取 Pin 的方式

通过我们对`Pin`和`Unpin`的新理解,我们现在可以朝着使用需要`Pin<&mut Self>`的新`Future`定义的方向前进,这个定义来自于列表 8-8。第一步是构造所需的类型。如果未来类型是`Unpin`,那一步很简单——我们只需要使用`Pin::new(&mut future)`。如果它不是`Unpin`,我们可以通过两种主要方式之一将未来固定:通过将它固定到堆上或固定到栈上。

让我们从将值固定到堆上开始。`Pin`的主要合同是,一旦某个对象被固定,它就不能再移动。固定 API 会确保所有方法和特性都遵守这一合同,因此构造`Pin`的任何函数的主要作用是确保如果`Pin` *本身*移动,引用的值也不会移动。确保这一点最简单的方法是将引用的值放在堆上,然后在`Pin`中放置对引用值的指针。你可以随心所欲地移动`Pin`,但是目标值会保持原样。这就是(安全)方法`Box::pin`的逻辑,它接受一个`T`并返回一个`Pin<Box<T>>`。这并没有什么神奇之处;它只是确保`Box`遵循`Pin`构造函数、`Deref`和`Drop`合同。

另一个选项是将值固定到栈上,这有点复杂,在撰写时需要一些不安全的代码。我们必须确保在`Pin`与`&mut`引用已被丢弃后,固定的值无法再被访问。我们通过像列表 8-11 中宏所示的那样对值进行遮蔽,或使用提供此类宏的其中一个库来实现这一点。也许有一天它甚至会进入标准库!

macro_rules! pin_mut {
($var:ident) => {
let mut $var = $var;
let mut $var = unsafe { Pin::new_unchecked(&mut $var) };
}
}


列表 8-11:用于将值固定到栈上的宏

通过获取要固定到栈上的变量名,宏确保调用者已经在栈上的某个地方有了它想要固定的值。对`$var`的遮蔽确保了调用者无法丢弃`Pin`并继续使用未固定的值(这会违反对任何`!Unpin`目标类型的`Pin`合同)。通过移动存储在`$var`中的值,宏还确保调用者不能在不丢弃原始变量的情况下丢弃绑定宏声明的`$var`。具体来说,如果没有那行代码,调用者可能会写出(请注意额外的作用域):

let foo = /* */; { pin_mut!(foo); foo.poll() }; foo.mut_self_method();


在这里,我们将一个`foo`的固定实例传递给`poll`,但是之后我们又使用了一个`&mut`来传递`foo`,而没有使用`Pin`,这违反了`Pin`契约。另一方面,通过额外的重新赋值,这段代码会将`foo`移动到新的作用域中,使得它在作用域结束后变得不可用。

因此,栈上的固定操作需要不安全的代码,这与`Box::pin`不同,但避免了`Box`引入的额外分配,并且在`no_std`环境中也能正常工作。

#### 回到未来

现在我们有了固定的 future,并且我们知道这意味着什么。但你可能已经注意到,尽管这些重要的固定操作在大多数你写的异步代码中并没有显现出来——比如使用`async`和`await`,而且这是因为编译器将它们隐藏了。

回想一下我们讨论过的列表 8-5,当时我告诉你,`<expr>.await`去糖化成类似于以下内容:

loop { if let Poll::Ready(r) = expr.poll() { break r } else { yield } }


那只是一个非常轻微的简化,因为正如我们所看到的,只有当你拥有一个`Pin<&mut Self>`类型的 future 时,才能调用`Future::poll`。去糖化实际上要复杂一些,如列表 8-12 所示。

1 match expr {
mut pinned => loop {
2 match unsafe { Pin::new_unchecked(&mut pinned) }.poll() {
Poll::Ready(r) => break r,
Poll::Pending => yield,
}
}
}


列表 8-12:`<expr>.await`的更准确的去糖化

匹配 1 是一种巧妙的简写,不仅确保扩展保持有效表达式,还将表达式的结果移动到一个变量中,我们可以在栈上对其进行固定。除此之外,主要的新内容是调用了`Pin::new_unchecked` 2。该调用是安全的,因为为了能够对包含的异步块进行轮询,它必须已经被固定,因为`Future::poll`的签名要求如此。而异步块已经被轮询过,因此我们达到了调用`Pin::new_unchecked`的步骤,所以生成器状态已经被固定。由于`pinned`存储在与异步块对应的生成器中(它必须如此,以确保`yield`能正确恢复),我们知道`pinned`不会再移动。而且,一旦进入循环,`pinned`无法被访问,除非通过`Pin`,所以没有代码能够将值从`pinned`中移出。因此,我们满足了`Pin::new_unchecked`的所有安全要求,代码是安全的。

## 睡觉中

我们已经深入探讨了`Pin`,但现在我们已经走出那一段,关于 future 还有另一个可能让你大脑痒痒的问题。如果`Future::poll`调用返回`Poll::Pending`,你需要某种机制在稍后的时间再次调用`poll`来检查是否可以继续推进。这个机制通常被称为*执行器*。你的执行器可以是一个简单的循环,轮询你等待的所有 future,直到它们都返回`Poll::Ready`,但这样会浪费很多 CPU 周期,而你本可以将它们用于其他更有用的事情,比如运行你的网页浏览器。相反,我们希望执行器做它能做的任何有用工作,然后进入睡眠状态。它应该保持睡眠,直到某个 future 能够继续推进,只有到那个时候,才会醒来做下一次轮询,再次进入睡眠。

### 醒来

决定何时检查给定 future 的条件差异很大。它可能是“当网络包到达此端口时”,“当鼠标光标移动时”,“当有人在此通道上发送时”,“当 CPU 收到特定中断时”,甚至是“经过了这么多时间之后”。此外,开发人员还可以编写自己的 futures,它们可能会包装多个其他 futures,因此可能有多个唤醒条件。一些 futures 甚至可能引入完全自定义的唤醒事件。

为了适应这些不同的使用场景,Rust 引入了 `Waker` 的概念:一种唤醒执行器以信号推进的方式。`Waker` 是整个未来(futures)机制能够工作的关键。执行器构造一个与其睡眠机制整合的 `Waker`,并将该 `Waker` 传递给它轮询的每一个 `Future`。怎么做的?就是通过我之前一直隐藏的 `Future::poll` 的额外参数。抱歉之前没告诉你。Listing 8-13 给出了 `Future` 的最终和真实定义——没有更多的谎言!

trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> PollSelf::Output;
}


Listing 8-13:实际的 `Future` trait 和 `Context`

`&mut Context` 包含了 `Waker`。该参数是一个 `Context`,而不是直接传递一个 `Waker`,这样我们可以在需要时为 futures 提供额外的上下文,从而扩展异步生态系统。

`Waker` 的主要方法是 `wake`(以及按引用变体 `wake_by_ref`),当 future 可以继续推进时应该调用该方法。`wake` 方法不接受任何参数,其效果完全由构造 `Waker` 的执行器定义。你看,`Waker` 在幕后是对执行器进行泛型化的。或者更准确地说,构造 `Waker` 的对象决定了当调用 `Waker::wake`、克隆 `Waker` 以及丢弃 `Waker` 时会发生什么。这一切都是通过手动实现的 vtable 来实现的,类似于我们在第二章讨论的动态派发。

构造一个 `Waker` 是一个相对复杂的过程,其机制对于使用它并不是特别重要,但你可以在标准库中的 `RawWakerVTable` 类型中看到构建块。它有一个构造函数,接受 `wake` 和 `wake_by_ref` 的函数指针,以及 `Clone` 和 `Drop`。`RawWakerVTable` 通常在所有执行器的 waker 之间共享,它与一个原始指针捆绑在一起,该指针用于存储特定于每个 `Waker` 实例的数据(比如它是针对哪个 future),然后转换为一个 `RawWaker`。接着,它被传递到 `Waker::from_raw` 以生成一个安全的 `Waker`,可以传递给 `Future::poll`。

### 履行 Poll 合约

到目前为止,我们已经回避了 future 如何使用 `Waker` 的问题。这个概念相当简单:如果 `Future::poll` 返回 `Poll::Pending`,那么 future 的责任是确保当 future 下次能够进展时,*某些东西* 会调用提供的 `Waker` 的 `wake` 方法。大多数 future 通过仅在其他 future 也返回 `Poll::Pending` 时才返回 `Poll::Pending` 来遵守这一规则;通过这种方式,它轻松履行了 `poll` 的合同,因为内部的 future 必须遵循相同的合同。但事情不可能永远这么简单。最终,你会遇到一个不对其他 future 进行轮询的 future,而是做一些像写入网络套接字或尝试从通道接收的操作。这些通常被称为 *叶子 future*,因为它们没有子 future。叶子 future 没有内部 future,而是直接表示某个可能还未准备好返回结果的资源。

叶子 future 通常有两种形态:一种是等待来自同一进程内的事件(比如通道接收器),另一种是等待来自进程外部的事件(比如 TCP 数据包读取)。那些等待内部事件的叶子 future 都倾向于遵循相同的模式:将 `Waker` 存储在代码中,确保唤醒代码可以找到它,并在生成相关事件时调用 `Waker` 的 `wake` 方法。例如,考虑一个必须等待内存通道中消息的叶子 future。它将其 `Waker` 存储在通道的发送者和接收者共享的部分,然后返回 `Poll::Pending`。当发送者稍后往通道中注入一条消息时,它会注意到接收者留下的 `Waker`,并在从 `send` 返回之前调用 `wake` 方法。现在接收者被唤醒,轮询合同得到了遵守。

处理外部事件的叶子 future 更为复杂,因为生成它们所等待事件的代码并不了解 future 或 waker。最常见的生成代码是操作系统内核,它知道何时磁盘准备就绪或定时器到期,但它也可能是一个 C 库,在操作完成时调用回调进入 Rust 或其他类似的外部实体。这样一个包装了外部资源的叶子 future 可能会启动一个执行阻塞系统调用(或等待 C 回调)的线程,然后使用内部唤醒机制,但那样会浪费资源;每次操作需要等待时都会启动一个线程,结果就会有很多只使用一次的线程闲置在那里等待事件。

相反,执行器通常提供叶子 future 的实现,这些实现与执行器在幕后通信,以安排与操作系统的适当交互。具体如何协调取决于执行器和操作系统,但大致来说,执行器会跟踪所有它应该监听的事件源,以便下次进入休眠时使用。当叶子 future 意识到必须等待外部事件时,它会更新该执行器的状态(它知道这个状态,因为它由执行器 crate 提供),将外部事件源与其 `Waker` 一起包括在内。当执行器无法继续执行时,它会收集所有正在等待的叶子 future 的事件源,并进行一次大的阻塞调用,告诉操作系统,当*任何*叶子 future 正在等待的资源有新事件时返回。在 Linux 上,通常通过 `epoll` 系统调用实现;Windows、BSD、macOS 以及几乎所有其他操作系统也提供类似的机制。当该调用返回时,执行器会对所有与操作系统报告事件的事件源相关联的 waker 调用 `wake`,从而完成轮询契约。

叶子 future 与执行器之间紧密集成的一个连锁反应是,来自一个执行器 crate 的叶子 future 通常不能与另一个执行器一起使用。或者至少,除非叶子 future 的执行器*也*在运行,否则无法使用。当叶子 future 要存储其 `Waker` 并注册它正在等待的事件源时,它所构建的执行器需要设置该状态,并且需要运行,以便事件源实际上会被监视,并最终调用 `wake`。有一些方法可以绕过这个问题,例如在没有执行器运行的情况下让叶子 future 生成一个执行器,但这并不总是可取的,因为这意味着应用程序可能会透明地在同一时间运行多个执行器,这会降低性能,并且在调试时必须检查多个执行器的状态。

希望支持多个执行器的库 crate 必须在其叶子资源上使用泛型。例如,库可以存储一个泛型的 `T: AsyncRead + AsyncWrite`,而不是使用特定执行器的``TcpStream 或 `File` future 类型。然而,生态系统尚未确定这些 trait 应该是什么样子以及需要哪些 trait,因此目前很难使代码在执行器上真正具有泛型性。例如,虽然 `AsyncRead` 和 `AsyncWrite` 在生态系统中有些常见(或者如果需要,可以很容易地进行适配),但目前还没有为在后台运行 future(*生成*,我们稍后将讨论)或表示定时器的 trait。

````### Waking Is a Misnomer    You may already have realized that `Waker::wake` doesn’t necessarily seem to *wake* anything. For example, for external events (as described in the previous section), the executor is already awake, and it might seem silly for it to then call `wake` on a `Waker` that belongs to that executor anyway! The reality is that `Waker::wake` is a bit of a misnomer—in reality, it signals that a particular future is *runnable*. That is, it tells the executor that it should make sure to poll this particular future when it gets around to it rather than go to sleep again, since this future can make progress. This might wake the executor if it is currently sleeping so it will go poll that future, but that’s more of a side effect than its primary purpose.    It is important for the executor to know which futures are runnable for two reasons. First, it needs to know when it can stop polling a future and go to sleep; it’s not sufficient to just poll each future until it returns `Poll::Pending`, since polling a later future might make it possible to progress an earlier future. Consider the case where two futures bounce messages back and forth on channels to one another. When you poll one, the other becomes ready, and vice versa. In this case, the executor should never go to sleep, as there is always more work to do.    Second, knowing which futures are runnable lets the executor avoid polling futures unnecessarily. If an executor manages thousands of pending futures, it shouldn’t poll all of them just because an event made one of them runnable. If it did, executing asynchronous code would get very slow indeed.    ### Tasks and Subexecutors    The futures in an asynchronous program form a tree: a future may contain any number of other futures, which in turn may contain other futures, all the way down to the leaf futures that interact with wakers. The root of each tree is the future you give to whatever the executor’s main “run” function is. These root futures are called *tasks*, and they are the only point of contact between the executor and the futures tree. The executor calls `poll` on the task, and from that point forward the code of each contained future must figure out which inner future(s) to poll in response, all the way down to the relevant leaf.    Executors generally construct a separate `Waker` for each task they poll so that when `wake` is later called, they know which task was just made runnable and can mark it as such. That is what the raw pointer in `RawWaker` is for—to differentiate between tasks while sharing the code for the various `Waker` methods.    When the executor eventually polls a task, that task starts running from the top of its implementation of `Future::poll` and must decide from there how to get to the future deeper down that can now make progress. Since each future knows only about its own fields, and nothing about the whole tree, this all happens through calls to `poll` that each traverse one edge in the tree.    The choice of which inner future to poll is often obvious, but not always. In the case of `async`/`await`, the future to poll is the one we’re blocked waiting for. But in a future that waits for the first of several futures to make progress (often called a *select*), or for all of a set of futures (often called a *join*), there are many options. A future that has to make such a choice is basically a subexecutor. It could poll all of its inner futures, but doing so could be quite wasteful. Instead, these subexecutors often wrap the `Waker` they receive in `poll`’s `Context` with their own `Waker` type before they invoke `poll` on any inner future. In the wrapping code, they mark the future they just polled as runnable in their own state before they call `wake` on the original `Waker`. That way, when the executor eventually polls the subexecutor future again, the subexecutor can consult its own internal state to figure out which of its inner futures caused the current call to `poll`, and then only poll those.    ## Tying It All Together with spawn    When working with asynchronous executors, you may come across an operation that spawns a future. We’re now in a position to explore what that means! Let’s do so by way of example. First, consider the simple server implementation in Listing 8-14.    ``` async fn handle_client(socket: TcpStream) -> Result<()> {     // Interact with the client over the given socket. }  async fn server(socket: TcpListener) -> Result<()> {     while let Some(stream) = socket.accept().await? {         handle_client(stream).await?;     } } ```    Listing 8-14: Handling connections sequentially    The top-level `server` function is essentially one big future that listens for new connections and does something when a new connection arrives. You hand that future to the executor and say “run this,” and since you don’t want your program to then exit immediately, you’ll probably have the executor block on that future. That is, the call to the executor to run the server future will not return until the server future resolves, which may be never (another client could always arrive later).    Now, every time a new client connection comes in, the code in Listing 8-14 makes a new future (by calling `handle_client`) to handle that connection. Since the handling is itself a future, we `await` it and then move on to the next client connection.    The downside of this approach is that we only ever handle one connection at a time—there is no concurrency. Once the server accepts a connection, the `handle_client` function is called, and since we `await` it, we don’t go around the loop again until `handle_client`’s return future resolves (presumably when that client has left).    We could improve on this by keeping a set of all the client futures and having the loop in which the server accepts new connections also check all the client futures to see if any can make progress. Listing 8-15 shows what that might look like.    ``` async fn server(socket: TcpListener) -> Result<()> {     let mut clients = Vec::new();     loop {         poll_client_futures(&mut clients)?;         if let Some(stream) = socket.try_accept()? {             clients.push(handle_client(stream));         }     } } ```    Listing 8-15: Handling connections with a manual executor    This at least handles many connections concurrently, but it’s quite convoluted. It’s also not very efficient because the code now busy-loops, switching between handling the connections we already have and accepting new ones. And it has to check each connection each time, since it won’t know which ones can make progress (if any). It also can’t `await` at any point, since that would prevent the other futures from making progress. You could implement your own wakers to ensure that the code polls only the futures that can make progress, but ultimately this is going down the path of developing your own mini-executor.    Another downside of sticking with just the one task for the server that internally contains the futures for all of the client connections is that the server ends up being single-threaded. There is just the one task and to poll it the code must hold an exclusive reference to the task’s future (`poll` takes `Pin<&mut Self>`), which only one thread can hold at a time.    The solution is to make each client future its own task and leave it to the executor to multiplex among all the tasks. Which, you guessed it, you do by spawning the future. The executor will continue to block on the server future, but if it cannot make progress on that future, it will use its execution machinery to make progress on the other tasks in the meantime behind the scenes. And best of all, if the executor is multithreaded and your client futures are `Send`, it can run them in parallel since it can hold `&mut`s to the separate tasks concurrently. Listing 8-16 gives an example of what this might look like.    ``` async fn server(socket: TcpListener) -> Result<()> {     while let Some(stream) = socket.accept().await? {         // Spawn a new task with the Future that represents this client.         // The current task will continue to just poll for more connections         // and will run concurrently (and possibly in parallel) with handle_client.         spawn(handle_client(stream));     } } ```    Listing 8-16: Spawning futures to create more tasks that can be polled concurrently    When you spawn a future and thus make it a task, it’s sort of like spawning a thread. The future continues running in the background and is multiplexed concurrently with any other tasks given to the executor. However, unlike a spawned thread, spawned tasks still depend on being polled by the executor. If the executor stops running, either because you drop it or because your code no longer runs the executor’s code, those spawned tasks will stop making progress. In the server example, imagine what will happen if the main server future resolves for some reason. Since the executor has returned control back to your code, it cannot continue doing, well, anything. Multi-threaded executors often spawn background threads that continue to poll tasks even if the executor yields control back to the user’s code, but not all executors do this, so check your executor before you rely on that behavior!    ## Summary    In this chapter, we’ve taken a look behind the scenes of the asynchronous constructs available in Rust. We’ve seen how the compiler implements generators and self-referential types, and why that work was necessary to support what we now know as `async`/`await`. We’ve also explored how futures are executed, and how wakers allow executors to multiplex among tasks when only some of them can make progress at any given moment. In the next chapter, we’ll tackle what is perhaps the deepest and most discussed area of Rust: unsafe code. Take a deep breath, and then turn the page.````


# 第九章:不安全代码

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/rs-rs/img/chapterart.png)

仅仅提到不安全代码,常常会引发 Rust 社区以及许多旁观者的强烈反应。有些人认为这“没什么大不了”,而另一些人则谴责它为“Rust 所有承诺的谎言”。在本章中,我希望揭开一些谜团,解释什么是 `unsafe`,什么不是,以及如何安全地使用它。在写这本书时,也可能是你阅读时,Rust 对不安全代码的具体要求仍在确定中,即使它们都被敲定下来,完整的描述也超出了本书的范围。相反,我会尽力为你提供构建模块、直觉和工具,以帮助你顺利应对大多数不安全代码。

本章的主要收获应该是:不安全代码是 Rust 提供给开发者的机制,目的是利用那些编译器无法检查的不可变性,无论其原因是什么。我们将讨论 `unsafe` 如何实现这一点,这些不可变性可能是什么,以及我们因此可以做些什么。

关键是,不安全代码不是绕过 Rust 各种规则(如借用检查)的方式,而是通过超越编译器的推理来执行这些规则。当你编写不安全代码时,责任在你,确保生成的代码是安全的。从某种意义上说,`unsafe` 作为关键字在允许通过 `unsafe {}` 执行不安全操作时具有误导性;它并不是说包含的代码 *是* 不安全的,而是说在这个特定的上下文中,代码被允许执行本应不安全的操作,因为这些操作 *是* 安全的。

本章的其余部分分为四个部分。我们将首先简要讨论关键字的使用方式,然后探索 `unsafe` 允许你做的事情。接下来,我们将看看你在编写安全的不安全代码时必须遵循的规则。最后,我会给你一些关于如何安全地编写不安全代码的建议。

## 不安全关键字

在讨论 `unsafe` 授予你的能力之前,我们需要先谈谈它的两种不同含义。`unsafe` 关键字在 Rust 中有双重作用:它将特定函数标记为不安全调用 *并且* 它使你能够在特定代码块中调用不安全的功能。例如,列表 9-1 中的方法被标记为不安全,尽管它不包含任何不安全代码。在这里,`unsafe` 关键字作为警告,提醒调用者,在调用 `decr` 的代码中,必须手动检查额外的保证。

impl SomeType {
pub unsafe fn decr(&self) {
self.some_usize -= 1;
}
}


列表 9-1:仅包含安全代码的不安全方法

列表 9-2 展示了第二种用法。在这里,方法本身没有标记为不安全,尽管它包含不安全的代码。

impl SomeType {
pub fn as_ref(&self) -> &T {
unsafe { &*self.ptr }
}
}


列表 9-2:包含不安全代码的安全方法

这两个示例在`unsafe`的使用上有所不同,因为它们代表了不同的契约。`decr`要求调用者在调用该方法时小心,而`as_ref`则假设调用者在调用其他不安全方法(如`decr`)时*已经*小心。为了理解原因,假设`SomeType`实际上是一个引用计数类型,如`Rc`。即使`decr`只是递减一个数字,这个递减可能会通过安全方法`as_ref`触发未定义行为。如果你调用`decr`,然后丢弃某个`T`的倒数第二个`Rc`,引用计数降为零,`T`将被丢弃——但是程序可能仍然会在最后一个`Rc`上调用`as_ref`,最终导致悬挂引用。

相反,只要没有方法通过安全代码破坏`Rc`的引用计数,就可以像`as_ref`中的代码那样安全地解引用`Rc`中的指针——`&self`的存在证明了指针仍然有效。我们可以利用这一点为调用者提供一个安全的 API,进行一个本来不安全的操作,这是如何负责任地使用`unsafe`的核心内容。

出于历史原因,今天每个`unsafe fn`在 Rust 中都包含一个隐式的不安全块。也就是说,如果你声明一个`unsafe fn`,你可以在这个`fn`中调用任何不安全方法或原始操作。然而,这个决定现在被认为是一个错误,并且正在通过已经接受并实施的 RFC 2585 来撤销。该 RFC 警告在没有显式`unsafe`块的情况下执行不安全操作的`unsafe fn`。这个 lint 也很可能在未来的 Rust 版本中成为一个硬错误。其理念是减少“脚枪半径”——如果每个`unsafe fn`都是一个巨大的不安全块,你可能会不小心执行不安全操作而没有意识到!例如,在示例 9-1 中的`decr`,根据当前规则,你也可以在没有任何`unsafe`注解的情况下加入`*std::ptr::null()`。

`unsafe`作为标记与不安全块作为启用不安全操作的机制之间的区别非常重要,因为你必须以不同的方式思考它们。一个`unsafe fn`告诉调用者,在调用该`fn`时必须小心,并且他们必须确保该函数的文档化安全不变量成立。

与此同时,一个`unsafe`块意味着编写该块的开发者已经仔细检查过其中执行的任何不安全操作的安全不变量。如果你想要一个大致的现实世界类比,`unsafe fn`就像是一个未签署的合同,它要求调用代码的作者“郑重承诺 X、Y 和 Z。”与此同时,`unsafe {}`则是调用代码的作者在签署块中所有不安全合同时的同意。记住这一点,在我们继续阅读本章的内容时。

## 强大之力

所以,一旦你签署了`unsafe {}`的“不安全合同”,你可以做什么呢?老实说,并没有太多。或者说,它并没有启用太多新功能。在`unsafe`块内部,你被允许解引用原始指针并调用`unsafe fn`。

就这样。技术上来说,你可以做一些其他事情,比如访问可变的和外部静态变量,访问联合体的字段,但这些并不会对讨论产生太大影响。老实说,这些就够了。总的来说,这些能力使你能够制造各种混乱,比如通过`mem::transmute`将类型互相转换,解引用指向不知道在哪里的原始指针,将`&'a`转换为`&'static`,或者让类型在跨线程边界共享,即使它们本身不是线程安全的。

在这一节中,我们不会过多担心这些能力可能会出错的情况。我们会把这些留到后面那部分沉闷、负责任的大人部分去讨论。相反,我们将看看这些闪亮的新玩具,以及我们能用它们做些什么。

### 处理原始指针

使用`unsafe`的一个最基本的原因是处理 Rust 的原始指针类型:`*const T`和`*mut T`。你可以把它们看作是与`&T`和`&mut T`大致相当,只是它们没有生命周期,且不像`&`引用那样受相同的有效性规则约束,我们将在本章稍后讨论这些规则。这些类型通常被称为*指针*和*原始指针*,主要是因为许多开发者本能地将引用称为指针,而将它们称为原始指针可以让这个区别更加清晰。

由于适用于`*`的规则比`&`少,你可以在`unsafe`块外将引用转换为指针。只有当你想反过来做,从`*`转换为`&`时,才需要使用`unsafe`。通常,你会将指针转回引用,以便对指向的数据执行有用的操作,例如读取或修改其值。因此,指针上常用的操作是`unsafe { &*ptr }`(或`&mut *`)。这里的`*`可能看起来有些奇怪,因为代码只是构造一个引用,而不是解引用指针,但如果你看一下类型就能明白;如果你有一个`*mut T`,并且想要得到一个`&mut T`,那么`&mut ptr`只会给你一个`&mut *mut T`。你需要`*`来表明你想要`ptr`所指向内容的可变引用。

#### 无法表示的生命周期

由于原始指针没有生命周期,它们可以在 Rust 的生命周期系统无法静态表示所指向值的存活性的情况下使用,例如我们在第八章讨论的自引用结构体中的自指针。指向`self`的指针只要`self`还存在(并且没有移动,`Pin`正是为了这个目的),就一直有效,但这不是你通常可以命名的生命周期。虽然整个自引用类型可能是`'static`,但自指针却不是——如果它是静态的,即使你把指针交给别人,他们也能一直使用它,即使`self`已经不存在了!以列表 9-3 中的类型为例;在这里,我们尝试将构成一个值的原始字节与它的存储表示一起存储。

struct Person<'a> {
name: &'a str,
age: usize,
}
struct Parsed {
bytes: [u8; 1024],
parsed: Person<'???>,
}


列表 9-3:尝试命名自引用引用的生命周期,但失败了

`Person`中的引用想要引用存储在`Parsed`中的`bytes`数据,但我们无法为这个引用指定生命周期。它既不是`'static`,也不是类似`'self`(不存在)的东西,因为如果`Parsed`被移动,该引用将不再有效。

由于指针没有生命周期,它们避免了这个问题,因为你不需要能够命名生命周期。相反,你只需要确保在使用指针时,它仍然有效,这就是你在编写`unsafe { &*ptr }`时所签署的内容。在列表 9-3 中的示例中,`Person`将存储一个`*const str`,然后在合适的时候不安全地将其转换为`&str`,当它能保证指针仍然有效时。

类似的问题出现在像`Arc`这样的类型中,它包含一个指向某个值的指针,该值在某段时间内是共享的,但这段时间只有在运行时才能知道,当最后一个`Arc`被释放时。这个指针有点像是`'static`,但实际上不是——就像在自引用的情况中,当最后一个`Arc`引用消失时,指针就不再有效,因此生命周期更像是`'self`。在`Arc`的兄弟类型`Weak`中,生命周期也是“当最后一个`Arc`消失时”,但由于`Weak`不是`Arc`,所以生命周期甚至不与`self`相关。因此,`Arc`和`Weak`内部都使用原始指针。

#### 指针运算

使用原始指针,你可以进行任意的指针运算,就像在 C 语言中一样,使用`.offset()`、`.add()`和`.sub()`将指针移动到同一分配中的任何字节。这通常用于高度优化空间的数据结构,比如哈希表,在这些结构中,为每个元素存储一个额外的指针会增加太多开销,而使用切片又不可行。这些是相对小众的用例,我们在本书中不会深入讨论,但如果你想了解更多,可以阅读`hashbrown::RawTable`的代码([`github.com/rust-lang/hashbrown/`](https://github.com/rust-lang/hashbrown/))。

即使你不打算将指针转换为引用,指针算术方法也是不安全的。造成这种情况的原因有几个,但最主要的是,指针指向原始分配末尾之外的地方是非法的。这样做会触发未定义行为,编译器可以决定吞掉你的代码,并将其替换为只有编译器能理解的任意胡话。如果你确实使用这些方法,请仔细阅读文档!

#### 到指针再回

当你需要使用指针时,通常是因为你有某个普通的 Rust 类型,比如引用、切片或字符串,然后你需要暂时转到指针的世界,然后再返回到原始的普通类型。因此,一些关键的标准库类型为你提供了一种方法,将它们转换为原始组成部分,例如切片的指针和长度,并通过这些相同的部分将其转换回完整的类型。例如,你可以通过 `as_ptr` 获取切片的数据指针,通过 `[]::len` 获取它的长度。然后,你可以通过将这些相同的值传递给 `std::slice::from_raw_parts` 来重建切片。`Vec`、`Arc` 和 `String` 也有类似的方法,它们返回指向底层分配的原始指针,而 `Box` 则有 `Box::into_raw` 和 `Box::from_raw`,它们执行相同的操作。

#### 对类型的自由操作

有时,你有一个类型 `T`,并希望将其视为另一个类型 `U`。无论是因为你需要进行极速的零拷贝解析,还是因为你需要调整某些生命周期,Rust 提供了一些(非常不安全的)工具来实现这一点。

其中最常用的指针转换是:你可以将 `*const T` 转换为任何其他 `*const U`(`mut` 同理),而且你甚至不需要使用 `unsafe`。不安全性只在你之后尝试将转换后的指针作为引用使用时出现,因为你必须断言该原始指针实际上可以作为它所指向类型的引用来使用。

这种指针类型转换在处理外部函数接口(FFI)时特别有用——你可以将任何 Rust 指针转换为 `*const std::ffi::c_void` 或 `*mut std::ffi::c_void`,然后将其传递给一个期望空指针的 C 函数。类似地,如果你从 C 函数那里获得一个你之前传入的空指针,你可以轻松地将其转换回原来的类型。

指针转换在你希望将一串字节解释为普通数据时也非常有用——例如整数、布尔值、字符、数组,或这些类型的 `#[repr(C)]` 结构体——或者直接将这些类型以字节流的形式写出,而不进行序列化。如果你想尝试这样做,有很多安全不变量需要记住,但我们稍后再讨论这些。

### 调用不安全函数

可以说,`unsafe` 最常用的特性是它允许你调用不安全函数。在栈的更深处,这些函数大多数是不安全的,因为它们在某些基本层面上操作原始指针,但在栈的更高层,你通常通过函数调用与不安全性进行交互。

调用不安全函数的结果没有限制,因为它完全取决于你交互的库。但是,*一般来说*,不安全函数可以分为三类:与非 Rust 接口交互的、不进行安全检查的以及具有自定义不变式的。

#### 外部函数接口

Rust 允许你使用 `extern` 块声明在 Rust 以外的语言中定义的函数和静态变量(我们将在第十一章详细讨论)。当你声明这样的块时,你是在告诉 Rust,这些在其中出现的项将在最终程序二进制文件链接时由某个外部源实现,例如你正在集成的 C 库。由于 `extern` 是 Rust 控制之外的,它们在访问时本质上是不安全的。如果你从 Rust 调用 C 函数,所有的保证都不成立——它可能会覆盖你整个内存内容,并将你精心安排的引用弄乱,变成指向内核某处的随机指针。类似地,`extern` 静态变量可能随时被外部代码修改,甚至可能被填充上与其声明类型完全不符的各种无效字节。不过,在不安全块中,只要你愿意担保外部代码遵守 Rust 的规则,你就可以随心所欲地访问 `extern`。

#### 我放弃安全检查

一些不安全操作可以通过引入额外的运行时检查变得完全安全。例如,访问切片中的项是危险的,因为你可能会尝试访问超出切片长度的项。但是,鉴于这种操作的普遍性,如果切片的索引操作是危险的,那将是非常不幸的。因此,安全的实现包括边界检查(取决于你使用的方法),如果提供的索引超出范围,它会导致程序崩溃或返回一个 `Option`。这样,即使你传入一个超出切片长度的索引,也不会导致未定义行为。另一个例子是在哈希表中,哈希表对你提供的键进行哈希,而不是让你自己提供哈希值;这确保了你永远不会使用错误的哈希值去访问某个键。

然而,在追求极致性能的过程中,一些开发者可能会发现这些安全检查在他们的紧密循环中增加了过多的开销。为了应对对性能要求极高且调用者知道索引在有效范围内的情况,许多数据结构提供了不包含这些安全检查的特定方法版本。这些方法通常在名称中包含 `unchecked` 这个词,以表明它们盲目地信任所提供的参数是安全的,并且不执行那些烦人的、缓慢的安全检查。一些例子包括 `NonNull::new_unchecked`、`slice::get_unchecked`、`NonZero::new_unchecked`、`Arc::get_mut_unchecked` 和 `str::from_utf8_unchecked`。

在实践中,对于不安全方法的安全性和性能权衡,通常是无法值得的。和性能优化一样,先进行测量,再进行优化。

#### 自定义不变量

大多数 `unsafe` 的使用都在一定程度上依赖于自定义不变量。也就是说,它们依赖于 Rust 本身所提供的不变量之外的约定,这些约定是特定于特定应用程序或库的。由于有许多函数属于这一类,因此很难给出一个好的概述。不过,我将举一些可能在实践中遇到并希望使用的带有自定义不变量的 `unsafe` 函数的例子:

**`MaybeUninit::assume_init`**

1.  `MaybeUninit` 类型是 Rust 中少数几种可以存储不合法值的方式之一。你可以将 `MaybeUninit<T>` 看作是一个当前可能不合法使用的 `T`。例如,`MaybeUninit<NonNull>` 允许存储一个空指针,`MaybeUninit<Box>` 允许存储一个悬空堆指针,而 `MaybeUninit<bool>` 允许存储数字 3 的比特模式(通常它只能是 0 或 1)。这在你按位构建一个值或处理最终会变得合法的零值或未初始化内存时非常有用(比如通过调用 `std::io::Read::read` 填充)。`assume_init` 函数断言 `MaybeUninit` 现在包含一个对类型 `T` 有效的值,因此可以作为 `T` 使用。

````` **`ManuallyDrop::drop`**    1.  The `ManuallyDrop` type is a wrapper type around a type `T` that does not drop that `T` when the `ManuallyDrop` is dropped. Or, phrased differently, it decouples the dropping of the outer type (`ManuallyDrop`) from the dropping of the inner type (`T`). It implements safe access to the `T` through `DerefMut<Target = T>` but also provides a `drop` method (separately from the `drop` method of the `Drop` trait) to drop the wrapped `T` *without* dropping the `ManuallyDrop`. That is, the `drop` function takes `&mut self` despite dropping the `T`, and so leaves the `ManuallyDrop` behind. This comes in handy if you have to explicitly drop a value that you cannot move, such as in implementations of the `Drop` trait. Once that value is dropped, it is no longer safe to try to access the `T`, which is why the call to `drop` is unsafe—it asserts that the `T` will never be accessed again.    **`std::ptr::drop_in_place`**    1.  `drop_in_place` lets you call a value’s destructor directly through a pointer to that value. This is unsafe because the pointee will be left behind after the call, so if some code then tries to dereference the pointer, it’ll be in for a bad time! This method is particularly useful when you may want to reuse memory, such as in an arena allocator, and need to drop an old value in place without reclaiming the surrounding memory.    **`Waker::from_raw`**    1.  In Chapter 8 we talked about the `Waker` type and how it is made up of a data pointer and a `RawWaker` that holds a manually implemented vtable. Once a `Waker` has been constructed, the raw function pointers in the vtable, such as `wake` and `drop`, can be called from safe code (through `Waker::wake` and `drop(waker)`, respectively). `Waker::from_raw` is where the asynchronous executor asserts that all the pointers in its vtable are in fact valid function pointers that follow the contract set forth in the documentation of `RawWakerVTable`.    **`std::hint::unreachable_unchecked`**    1.  The `hint` module holds functions that give hints to the compiler about the surrounding code but do not actually produce any machine code. The `unreachable_unchecked` function in particular tells the compiler that it is impossible for the program to reach a section of the code at runtime. This in turn allows the compiler to make optimizations based on that knowledge, such as eliminating conditional branches to that location. Unlike the `unreachable!` macro, which panics if the code does reach the line in question, the effects of an erroneous `unreachable_unchecked` are hard to predict. The compiler optimizations may cause peculiar and hard-to-debug behavior, not to mention that your program will continue running when something it believed to be true was not!    **`std::ptr::{read,write}_{unaligned,volatile}`**    1.  The `ptr` module holds a number of functions that let you work with *odd* pointers—those that do not meet the assumptions that Rust generally makes about pointers. The first of these functions are `read_unaligned` and `write_unaligned`, which let you access pointers that point to a `T` even if that `T` is not stored according to `T`’s alignment (see the section on alignment in Chapter 2). This might happen if the `T` is contained directly in a byte array or is otherwise packed in with other values without proper padding. The second notable pair of functions is `read_volatile` and `write_volatile`, which let you operate on pointers that don’t point to normal memory. Concretely, these functions will always access the given pointer (they won’t be cached in a register, for example, even if you read the same pointer twice in a row), and the compiler won’t reorder the volatile accesses relative to other volatile accesses. Volatile operations come in handy when working with pointers that aren’t backed by normal DRAM memory—we’ll discuss this further in Chapter 11. Ultimately, these methods are unsafe because they dereference the given pointer (and to an owned `T`, at that), so you as the caller need to sign off on all the contracts associated with doing so.    **`std::thread::Builder::spawn_unchecked`**    1.  The normal `thread::spawn` that we know and love requires that the provided closure is `'static`. That bound stems from the fact that the spawned thread might run for an indeterminate amount of time; if we were allowed to use a reference to, say, the caller’s stack, the caller might return well before the spawned thread exits, rendering the reference invalid. Sometimes, however, you know that some non-`'static` value in the caller will outlive the spawned thread. This might happen if you join the thread before dropping the value in question, or if the value is dropped only strictly after you know the spawned thread will no longer use it. That’s where `spawn_unchecked` comes in—it does not have the `'static` bound and thus lets you implement those use cases as long as you’re willing to sign the contract saying that no unsafe accesses will happen as a result. Be careful of panics, though; if the caller panics, it might drop values earlier than you planned and cause undefined behavior in the spawned thread!    Note that all of these methods (and indeed all unsafe methods in the standard library) provide explicit documentation for their safety invariants, as should be the case for any unsafe method.    ### Implementing Unsafe Traits    Unsafe traits aren’t unsafe to *use*, but unsafe to *implement*. This is because unsafe code is allowed to rely on the correctness (defined by the trait’s documentation) of the implementation of unsafe traits. For example, to implement the unsafe trait `Send`, you need to write `unsafe impl Send for ...`. Like unsafe functions, unsafe traits generally have custom invariants that are (or at least should be) specified in the documentation for the trait. Thus, it’s difficult to cover unsafe traits as a group, so here too I’ll give some common examples from the standard library that are worth going over.    #### Send and Sync    The `Send` and `Sync` traits denote that a type is safe to send or share across thread boundaries, respectively. We’ll talk more about these traits in Chapter 10, but for now what you need to know is that they are auto-traits, and so they’ll usually be implemented for most types for you by the compiler. But, as tends to be the case with auto-traits, `Send` and `Sync` will not be implemented if any members of the type in question are not themselves `Send` or `Sync`.    In the context of unsafe code, this problem occurs primarily due to raw pointers, which are neither `Send` nor `Sync`. At first glance, this might seem reasonable: the compiler has no way to know who else may have a raw pointer to the same value or how they may be using it at the moment, so how can the type be safe to send across threads? Now that we’re seasoned unsafe developers though, that argument seems weak—after all, dereferencing a raw pointer is already unsafe, so why should handling the invariants of `Send` and `Sync` be any different?    Strictly speaking, raw pointers could be both `Send` and `Sync`. The problem is that if they were, the types that contain raw pointers would automatically be `Send` and `Sync` themselves, even though their author might not realize that was the case. The developer might then unsafely dereference the raw pointers without ever thinking about what would happen if those types were sent or shared across thread boundaries, and thus inadvertently introduce undefined behavior. Instead, the raw pointer types block these automatic implementations as an additional safeguard to unsafe code to make authors explicitly sign the contract that they have also followed the `Send` and `Sync` invariants.    #### GlobalAlloc    The `GlobalAlloc` trait is how you implement a custom memory allocator in Rust. We won’t talk too much about that topic in this book, but the trait itself is interesting. Listing 9-4 gives the required methods for the `GlobalAlloc` trait.    ``` pub unsafe trait GlobalAlloc {     pub unsafe fn alloc(&self, layout: Layout) -> *mut u8;     pub unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout); } ```    Listing 9-4: The `GlobalAlloc` trait with its required methods    At its core, the trait has one method for allocating a new chunk of memory, `alloc`, and one for deallocating a chunk of memory, `dealloc`. The `Layout` argument describes the type’s size and alignment, as we discussed in Chapter 2. Each of those methods is unsafe and carries a number of safety invariants that its callers must uphold.    `GlobalAlloc` itself is also unsafe because it places restrictions on the implementer of the trait, not the caller of its methods. Only the unsafety of the trait ensures that implementers agree to uphold the invariants that Rust itself assumes of its memory allocator, such as in the standard library’s implementation of `Box`. If the trait was not unsafe, an implementer could safely implement `GlobalAlloc` in a way that produced unaligned pointers or incorrectly sized allocations, which would trigger unsafety in otherwise safe code that assumes that allocations are sane. This would break the rule that safe code should not be able to trigger memory unsafety in other safe code, and thus cause all sorts of mayhem.    #### Surprisingly Not Unpin    The `Unpin` trait is not unsafe, which comes as a surprise to many Rust developers. It may even come as a surprise to you after reading Chapter 8. After all, the trait is supposed to ensure that self-referential types aren’t invalidated if they’re moved after they have established internal pointers (that is, after they’ve been placed in a `Pin`). It seems strange, then, that `Unpin` can be used to safely remove a type from a `Pin`.    There are two main reasons why `Unpin` isn’t an unsafe trait. First, it’s unnecessary. Implementing `Unpin` for a type that you control does not grant you the ability to safely pin or unpin a `!Unpin` type; that still requires unsafety in the form of a call to `Pin::new_unchecked` or `Pin::get_unchecked_mut`. Second, there is already a safe way for you to unpin any type you control: the `Drop` trait! When you implement `Drop` for a type, you’re passed `&mut self`, even if your type was previously stored in a `Pin` and is `!Unpin`, all without any unsafety. That potential for unsafety is covered by the invariants of `Pin::new_unchecked`, which must be upheld to create a `Pin` of such an `!Unpin` type in the first place.    #### When to Make a Trait Unsafe    Few traits in the wild are unsafe, but those that are all follow the same pattern. A trait should be unsafe if safe code that assumes that trait is implemented correctly can exhibit memory unsafety if the trait is *not* implemented correctly.    The `Send` trait is a good example to keep in mind here—safe code can easily spawn a thread and pass a value to that spawned thread, but if `Rc` were ``Send, that sequence of operations could trivially lead to memory unsafety. Consider what would happen if you cloned an `Rc<Box>` and sent it to another thread: the two threads could easily both try to deallocate the `Box` since they do not correctly synchronize access to the `Rc`’s reference count.``   ````The `Unpin` trait is a good counterexample. While it is possible to write unsafe code that triggers memory unsafety if `Unpin` is implemented incorrectly, no entirely safe code can trigger memory unsafety due to an implementation of `Unpin`. It’s not always easy to determine that a trait can be safe (indeed, the `Unpin` trait was unsafe throughout most of the RFC process), but you can always err on the side of making the trait unsafe, and then make it safe later on if you realize that is the case! Just keep in mind that that is a backward incompatible change.    Also keep in mind that just because it feels like an incorrect (or even malicious) implementation of a trait would cause a lot of havoc, that’s not necessarily a good reason to make it unsafe. The `unsafe` marker should first and foremost be used to highlight cases of *memory* unsafety, not just something that can trigger errors in business logic. For example, the `Eq`, `Ord`, `Deref`, and `Hash` traits are all safe, even though there is likely much code out in the world that would go haywire if faced with a malicious implementation of, say, `Hash` that returned a different random hash each time it was called. This extends to unsafe code too—there is almost certainly unsafe code out there that would be memory-unsafe in the presence of such an implementation of `Hash`—but that does not mean `Hash` should be unsafe. The same is true for an implementation of `Deref` that dereferenced to a different (but valid) target each time. Such unsafe code would be relying on a contract of `Hash` or `Deref` that does not actually hold; `Hash` never claimed that it was deterministic, and neither did `Deref`. Or rather, the authors of those implementations never used the `unsafe` keyword to make that claim!    ## Great Responsibility    So far, we’ve looked mainly at the various things that you are allowed to do with unsafe code. But unsafe code is allowed to do those things only if it does so safely. Even though unsafe code can, say, dereference a raw pointer, it must do so only if it knows that pointer is valid as a reference to its pointee at that moment in time, subject to all of Rust’s normal requirements of references. In other words, unsafe code is given access to tools that could be used to do unsafe things, but it must do only safe things using those tools.    That, then, raises the question of what *safe* even means in the first place. When is it safe to dereference a pointer? When is it safe to transmute between two different types? In this section, we’ll explore some of the key invariants to keep in mind when wielding the power of `unsafe`, look at some common gotchas, and get familiar with some of the tools that help you write safer unsafe code.    The exact rules around what it means for Rust code to be safe are still being worked out. At the time of writing, the Unsafe Code Guidelines Working Group is hard at work nailing down all the dos and don’ts, but many questions remain unanswered. Most of the advice in this section is more or less settled, but I’ll make sure to call out any that isn’t. If anything, I’m hoping that this section will teach you to be careful about making assumptions when you write unsafe code, and prompt you to double-check the Rust reference before you declare your code production-ready.    ### What Can Go Wrong?    We can’t really get into the rules unsafe code must abide by without talking about what happens if you violate those rules. Let’s say you do mutably access a value from multiple threads concurrently, construct an unaligned reference, or dereference a dangling pointer—now what?    Unsafe code that is not ultimately safe is referred to as having *undefined behavior*. Undefined behavior generally manifests in one of three ways: not at all, through visible errors, or through invisible corruption. The first is the happy case—you wrote some code that is truly not safe, but the compiler generated sane code that the computer you’re running the code on executes in a sane way. Unfortunately, the happiness here is very brittle. Should a new and slightly smarter version of the compiler come along, or some surrounding code cause the compiler to apply another optimization, the code may no longer do something sane and tip over into one of the worse cases. Even if the same code is compiled by the same compiler, if it runs on a different platform or host, the program might act differently! This is why it is important to avoid undefined behavior even if everything currently seems to work fine. Not to do so is like playing a second round of Russian roulette just because you survived the first.    Visible errors are the easiest undefined behavior to catch. If you dereference a null pointer, for example, your program will (in all likelihood) crash with an error, which you can then debug back to the root cause. That debugging may itself be difficult, but at least you have a notification that something is wrong. Visible errors can also manifest in less severe ways, such as deadlocks, garbled output, or panics that are printed but don’t trigger a program exit, all of which tell you that there is a bug in your code that you have to go fix.    The worst manifestation of undefined behavior is when there is no immediate visible effect, but the program state is invisibly corrupted. Transaction amounts might be slightly off from what they should be, backups might be silently corrupted, or random bits of internal memory could be exposed to external clients. The undefined behavior could cause ongoing corruption, or extremely infrequent outages. Part of the challenge with undefined behavior is that, as the name implies, the behavior of the non-safe unsafe code is not defined—the compiler might eliminate it entirely, dramatically change the semantics of the code, or even miscompile surrounding code. What that does to your program is entirely dependent on what the code in question does. The unpredictable impact of undefined behavior is the reason why *all* undefined behavior should be considered a serious bug, no matter how it *currently* manifests.    ### Validity    Perhaps the most important concept to understand before writing unsafe code is *validity*, which dictates the rules for what values inhabit a given type—or, less formally, the rules for a type’s values. The concept is simpler than it sounds, so let’s dive into some concrete examples.    #### Reference Types    Rust is very strict about what values its reference types can hold. Specifically, references must never dangle, must always be aligned, and must always point to a valid value for their target type. In addition, a shared and an exclusive reference to a given memory location can never exist at the same time, and neither can multiple exclusive references to a location. These rules apply regardless of whether your code uses the references or not—you are not allowed to create a null reference even if you then immediately discard it!    Shared references have the additional constraint that the pointee is not allowed to change during the reference’s lifetime. That is, any value the pointee contains must remain exactly the same over its lifetime. This applies transitively, so if you have an `&` to a type that contains a `*mut T`, you are not allowed to ever mutate the `T` through that `*mut` even though you could write code to do so using `unsafe`. The *only* exception to this rule is a value wrapped by the `UnsafeCell` type. All other types that provide interior mutability, like `Cell`, `RefCell`, and `Mutex`, internally use an `UnsafeCell`.    An interesting result of Rust’s strict rules for references is that for many years, it was impossible to safely take a reference to a field of a packed or partially uninitialized struct that used `repr(Rust)`. Since `repr(Rust)` leaves a type’s layout undefined, the only way to get the address of a field was by writing `&some_struct.field as *const _`. However, if `some_struct` is packed, then `some_struct.field` may not be aligned, and thus creating an `&` to it is illegal! Further, if `some_struct` isn’t fully initialized, then the `some_struct` reference itself cannot exist! In Rust 1.51.0, the `ptr::addr_of!` macro was stabilized, which added a mechanism for directly obtaining a reference to a field without first creating a reference, fixing this particular problem. Internally, it is implemented using something called *raw references* (not to be confused with raw pointers), which directly create pointers to their operands rather than going via a reference. Raw references were introduced in RFC 2582 but haven’t been stabilized themselves yet at the time of writing.    #### Primitive Types    Some of Rust’s primitive types have restrictions on what values they can hold. For example, a `bool` is defined as being 1 byte large but is only allowed to hold the value `0x00` or the value `0x01`, and a `char` is not allowed to hold a surrogate or a value above `char::MAX`. Most of Rust’s primitive types, and indeed most of Rust’s types overall, also cannot be constructed from uninitialized memory. These restrictions may seem arbitrary, but again often stem from the need to enable optimizations that wouldn’t be possible otherwise.    A good illustration of this is the niche optimization, which we discussed briefly when talking about pointer types earlier in this chapter. To recap, the niche optimization tucks away the enum discriminant value in the wrapped type in certain cases. For example, since a reference cannot ever be all zeros, an `Option<&T>` can use all zeros to represent `None`, and thus avoid spending an extra byte (plus padding) to store the discriminator byte. The compiler can optimize Booleans in the same way and potentially take it even further. Consider the type `Option<Option<bool>>>`. Since the compiler knows that the `bool` is either `0x00` or `0x01`, it’s free to use `0x02` to represent `Some(None)` and `0x03` to represent `None`. Very nice and tidy! But if someone were to come along and treat the byte `0x03` as a `bool`, and then place that value in an `Option<Option<bool>>` optimized in this way, bad things would happen.    It bears repeating that it’s not important whether the Rust compiler currently implements this optimization or not. The point is that it is allowed to, and therefore any unsafe code you write must conform to that contract or risk hitting a bug later on should the behavior change.    #### Owned Pointer Types    Types that point to memory they own, like `Box` and `Vec`, are generally subject to the same optimizations as if they held an exclusive reference to the pointed-to memory unless they’re explicitly accessed through a shared reference. Specifically, the compiler assumes that the pointed-to memory is not shared or aliased elsewhere, and makes optimizations based on that assumption. For example, if you extracted the pointer from a `Box` and then constructed two `Box`es from that same pointer and wrapped them in `ManuallyDrop` to prevent a double-free, you’d likely be entering undefined behavior territory. That’s the case even if you only ever access the inner type through shared references. (I say “likely” because this isn’t fully settled in the language reference yet, but a rough consensus has arisen.)    #### Storing Invalid Values    Sometimes you need to store a value that isn’t currently valid for its type. The most common example of this is if you want to allocate a chunk of memory for some type `T` and then read in the bytes from, say, the network. Until all the bytes have been read in, the memory isn’t going to be a valid `T`. Even if you just tried to read the bytes into a slice of `u8`, you would have to zero those `u8`s first, because constructing a `u8` from uninitialized memory is also undefined behavior.    The `MaybeUninit<T>` type is Rust’s mechanism for working with values that aren’t valid. A `MaybeUninit<T>` stores exactly a `T` (it is `#[repr(transparent)]`), but the compiler knows to make no assumptions about the validity of that `T`. It won’t assume that references are non-null, that a `Box<T>` isn’t dangling, or that a `bool` is either 0 or 1\. This means it’s safe to hold a `T` backed by uninitialized memory inside a `MaybeUninit` (as the name implies). `MaybeUninit` is also a very useful tool in other unsafe code where you have to temporarily store a value that may be invalid. Maybe you have to store an aliased `Box<T>` or stash a `char` surrogate for a second—`MaybeUninit` is your friend.    You will generally do only three things with a `MaybeUninit`: create it using the `MaybeUninit::uninit` method, write to its contents using `MaybeUninit::as_mut_ptr`, or take the inner `T` once it is valid again with `MaybeUninit::assume_init`. As its name implies, `uninit` creates a new `MaybeUninit<T>` of the same size as a `T` that initially holds uninitialized memory. The `as_mut_ptr` method gives you a raw pointer to the inner `T` that you can then write to; nothing stops you from reading from it, but reading from any of the uninitialized bits is undefined behavior. And finally, the unsafe `assume_init` method consumes the `MaybeUninit<T>` and returns its contents as a `T` following the assertion that the backing memory now makes up a valid `T`.    Listing 9-5 shows an example of how we might use `MaybeUninit` to safely initialize a byte array without explicitly zeroing it.    ``` fn fill(gen: impl FnMut() -> Option<u8>) {     let mut buf = [MaybeUninit::<u8>::uninit(); 4096];     let mut last = 0;     for (i, g) in std::iter::from_fn(gen).take(4096).enumerate() {         buf[i] = MaybeUninit::new(g);         last = i + 1;     }     // Safety: all the u8s up to last are initialized.     let init: &[u8] = unsafe {        MaybeUninit::slice_assume_init_ref(&buf[..last]) };     // ... do something with init ... } ```    Listing 9-5: Using `MaybeUninit` to safely initialize an array    While we could have declared `buf` as `[0; 4096]` instead, that would require the function to first write out all those zeros to the stack before executing, even if it’s going to overwrite them all again shortly thereafter. Normally that wouldn’t have a noticeable impact on performance, but if this was in a sufficiently hot loop, it might! Here, we instead allow the array to keep whatever values happened to be on the stack when the function was called, and then overwrite only what we end up needing.    ### Panics    An important and often overlooked aspect of ensuring that code using unsafe operations is safe is that the code must also be prepared to handle panics. In particular, as we discussed briefly in Chapter 5, Rust’s default panic handler on most platforms will not crash your program on a panic but will instead *unwind* the current thread. An unwinding panic effectively drops everything in the current scope, returns from the current function, drops everything in the scope that enclosed the function, and so on, all the way down the stack until it hits the first stack frame for the current thread. If you don’t take unwinding into account in your unsafe code, you may be in for trouble. For example, consider the code in Listing 9-6, which tries to efficiently push many values into a `Vec` at once.    ``` impl<T: Default> Vec<T> {     pub fn fill_default(&mut self) {         let fill = self.capacity() - self.len();         if fill == 0 { return; }         let start = self.len();         unsafe {             self.set_len(start + fill);  for i in 0..fill {                 *self.get_unchecked_mut(start + i) = T::default();             }         }     } } ```    Listing 9-6: A seemingly safe method for filling a vector with `Default` values    Consider what happens to this code if a call to `T::default` panics. First, `fill_default` will drop all its local values (which are just integers) and then return. The caller will then do the same. At some point up the stack, we get to the owner of the `Vec`. When the owner drops the vector, we have a problem: the length of the vector now indicates that we own more `T`s than we actually produced due to the call to `set_len`. For example, if the very first call to `T::default` panicked when we aimed to fill eight elements, that means `Vec::drop` will call `drop` on eight `T`s that actually contain uninitialized memory!    The fix in this case is simple: the code must update the length *after* writing all the elements. We wouldn’t have realized there was a problem if we didn’t carefully consider the effect of unwinding panics on the correctness of our unsafe code.    When you’re combing through your code for these kinds of problems, you’ll want to look out for any statements that may panic, and consider whether your code is safe if they do. Alternatively, check whether you can convince yourself that the code in question will never panic. Pay particular attention to anything that calls user-provided code—in those cases, you have no control over the panics and should assume that the user code will panic.    A similar situation arises when you use the `?` operator to return early from a function. If you do this, make sure that your code is still safe if it does not execute the remainder of the code in the function. It’s rarer for `?` to catch you off guard since you opted into it explicitly, but it’s worth keeping an eye out for.    ### Casting    As we discussed in Chapter 2, two different types that are both `#[repr(Rust)]` may be represented differently in memory even if they have fields of the same type and in the same order. This in turn means that it’s not always obvious whether it is safe to cast between two different types. In fact, Rust doesn’t even guarantee that two instances of a single type with generic arguments that are themselves laid out the same way are represented the same way. For example, in Listing 9-7, `A` and `B` are not guaranteed to have the same in-memory representation.    ``` struct Foo<T> {     one: bool,     two: PhantomData<T>, } struct Bar; struct Baz; type A = Foo<Bar>; type B = Foo<Baz>; ```    Listing 9-7: Type layout is not predictable.    The lack of guarantees for `repr(Rust)` is important to keep in mind when you do type casting in unsafe code—just because two types feel like they should be interchangeable, that is not necessarily the case. Casting between two types that have different representations is a quick path to undefined behavior. At the time of writing, the Rust community is actively working out the exact rules for how types are represented, but for now, very few guarantees are given, so that’s what we have to work with.    Even if identical types were guaranteed to have the same in-memory representation, you’d still run into the same problem when types are nested. For example, while `UnsafeCell<T>`, `MaybeUninit<T>`, and `T` all really just hold a `T`, and you can cast between them to your heart’s delight, that goes out the window once you have, for example, an `Option<MaybeUninit<T>>`. Though `Option<T>` may be able to take advantage of the niche optimization (using some invalid value of `T` to represent `None` for the `Option`), `MaybeUninit<T>` can hold any bit pattern, so that optimization does not apply, and an extra byte must be kept for the `Option` discriminator.    It’s not just optimizations that can cause layouts to diverge once wrapper types come into play. As an example, take the code in Listing 9-8; here, the layout of `Wrapper<PhantomData<u8>>` and `Wrapper<PhantomData<i8>>` is completely different even though the provided types are both empty!    ``` struct Wrapper<T: SneakyTrait> {     item: T::Sneaky,     iter: PhantomData<T>, } trait SneakyTrait {     type Sneaky; } impl SneakyTrait for PhantomData<u8> {     type Sneaky = (); } impl SneakyTrait for PhantomData<i8> {     type Sneaky = [u8; 1024]; } ```    Listing 9-8: Wrapper types make casting hard to get right.    All of this isn’t to say that you can never cast types in Rust. Things get a lot easier, for example, when you control all of the types involved and their trait implementations, or if types are `#[repr(C)]`. You just need to be aware that Rust gives very few guarantees about in-memory representations, and write your code accordingly!    ### The Drop Check    The Rust borrow checker is, in essence, a sophisticated tool for ensuring the soundness of code at compile time, which is in turn what gives Rust a way to express code being “safe.” How exactly the borrow checker does its job is beyond the scope of this book, but one check, the *drop check*, is worth going through in some detail since it has some direct implications for unsafe code. To understand drop checking, let’s put ourselves in the Rust compiler’s shoes for a second and look at two code snippets. First, take a look at the little three-liner in Listing 9-9 that takes a mutable reference to a variable and then mutates that same variable right after.    ``` let mut x = true; let foo = Foo(&mut x); x = false; ```    Listing 9-9: The implementation of `Foo` dictates whether this code should compile    Without knowing the definition of `Foo`, can you say whether this code should compile or not? When we set `x = false`, there is still a `foo` hanging around that will be dropped at the end of the scope. We know that `foo` contains a mutable borrow of `x`, which would indicate that the mutable borrow that’s necessary to modify `x` is illegal. But what’s the harm in allowing it? It turns out that allowing the mutation of `x` is problematic only if `Foo` implements `Drop`—if `Foo` doesn’t implement `Drop`, then we know that `Foo` won’t touch the reference to `x` after its last use. Since that last use is before we need the exclusive reference for the assignment, we can allow the code! On the other hand, if `Foo` does implement `Drop`, we can’t allow this code, since the `Drop` implementation may use the reference to `x`.    Now that you’re warmed up, take a look at Listing 9-10. In this not-so-straightforward code snippet, the mutable reference is buried even deeper.    ``` fn barify<’a>(_: &’a mut i32) -> Bar<Foo<’a>> { .. } let mut x = true; let foo = barify(&mut x); x = false; ```    Listing 9-10: The implementations of both `Foo` and `Bar` dictate whether this code should compile    Again, without knowing the definitions of `Foo` and `Bar`, can you say whether this code should compile or not? Let’s consider what happens if `Foo` implements `Drop` but `Bar` does not, since that’s the most interesting case. Usually, when a `Bar` goes out of scope, or otherwise gets dropped, it’ll still have to drop `Foo`, which in turn means that the code should be rejected for the same reason as before: `Foo::drop` might access the reference to `x`. However, `Bar` may not contain a `Foo` directly at all, but instead just a `PhantomData<Foo<'a>>` or a `&'static Foo<'a>`, in which case the code is actually okay—even though the `Bar` is dropped, `Foo::drop` is never invoked, and the reference to `x` is never accessed. This is the kind of code we want the compiler to accept because a human will be able to identify that it’s okay, even if the compiler finds it difficult to detect that this is the case.    The logic we’ve just walked through is the drop check. Normally it doesn’t affect unsafe code too much as its default behavior matches user expectations, with one major exception: dangling generic parameters. Imagine that you’re implementing your own `Box<T>` type, and someone places a `&mut x` into it as we did in Listing 9-9. Your `Box` type needs to implement `Drop` to free memory, but it doesn’t access `T` beyond dropping it. Since dropping a `&mut` does nothing, it should be entirely fine for code to access `&mut x` again after the last time the `Box` is accessed but before it’s dropped! To support types like this, Rust has an unstable feature called `dropck_eyepatch` (because it makes the drop check partially blind). The feature is likely to remain unstable forever and is intended to serve only as a temporary escape hatch until a proper mechanism is devised. The `dropck_eyepatch` feature adds a `#[may_dangle]` attribute, which you can add as a prefix for generic lifetimes and types in a type’s `Drop` implementation to tell the drop check machinery that you won’t use the annotated lifetime or type beyond dropping it. You use it by writing:    ``` unsafe impl<#[may_dangle] T> Drop for .. ```    This escape hatch allows a type to declare that a given generic parameter isn’t used in `Drop`, which enables use cases like `Box<&mut T>`. However, it also introduces a new problem if your `Box<T>` holds a raw heap pointer, `*mut T`, and allows `T` to dangle using `#[may_dangle]`. Specifically, the `*mut T` makes Rust’s drop check think that your `Box<T>` doesn’t own a `T`, and thus that it doesn’t call `T::drop` either. Combined with the `may_dangle` assertion that we don’t access `T` when the `Box<T>` is dropped, the drop check now concludes that it’s fine to have a `Box<T>` where the `T` doesn’t live until the `Box` is dropped (like our shortened `&mut x` in Listing 9-10). But that’s not true, since we *do* call `T::drop`, which may itself access, say, a reference to said `x`.    Luckily, the fix is simple: we add a `PhantomData<T>` to tell the drop check that even though the `Box<T>` doesn’t hold any `T`, and won’t access `T` on drop, it does still own a `T` and will drop one when the `Box` is dropped. Listing 9-11 shows what our hypothetical `Box` type would look like.    ``` struct Box<T> {   t: NonNull<T>, // NonNull not *mut for covariance (Chapter 1)   _owned: PhantomData<T>, // For drop check to realize we drop a T } unsafe impl<#[may_dangle] T>  Drop for Box<T> { /* ... */ } ```    Listing 9-11: A definition for `Box` that is maximally flexible in terms of the drop check    This interaction is subtle and easy to miss, but it arises only when you use the unstable `#[may_dangle]` attribute. Hopefully this subsection will serve as a warning so that when you see `unsafe impl Drop` in the wild in the future, you’ll know to look for a `PhantomData<T>` as well!    ## Coping with Fear    With this chapter mostly behind you, you may now be more afraid of unsafe code than you were before you started. While that is understandable, it’s important to stress that it’s not only *possible* to write safe unsafe code, but most of the time it’s not even that difficult. The key is to make sure that you handle unsafe code with care; that’s half the struggle. And be really sure that there isn’t a safe implementation you can use instead before resorting to `unsafe`.    In the remainder of this chapter, we’ll look at some techniques and tools that can help you be more confident in the correctness of your unsafe code when there’s no way around it.    ### Manage Unsafe Boundaries    It’s tempting to reason about unsafety *locally*; that is, to consider whether the code in the unsafe block you just wrote is safe without thinking too much about its interaction with the rest of the codebase. Unfortunately, that kind of local reasoning often comes back to bite you. A good example of this is the `Unpin` trait—you may write some code for your type that uses `Pin::new_unchecked` to produce a pinned reference to a field of the type, and that code may be entirely safe when you write it. But then at some later point in time, you (or someone else) might add a safe implementation of `Unpin` for said type, and suddenly the unsafe code is no longer safe, even though it’s nowhere near the new `impl`!    Safety is a property that can be checked only at the privacy boundary of all code that relates to the unsafe block. *Privacy boundary* here isn’t so much a formal term as an attempt at describing “any part of your code that can fiddle with the unsafe bits.” For example, if you declare a public type `Foo` in a module `bar` that is marked `pub` or `pub(crate)`, then any other code in the same crate can implement methods on and traits for `Foo`. So, if the safety of your unsafe code depends on `Foo` not implementing particular traits or methods with particular signatures, you need to remember to recheck the safety of that unsafe block any time you add an `impl` for `Foo`. If, on the other hand, `Foo` is not visible to the entire crate, then a much smaller set of scopes is able to add problematic implementations, and thus, the risk of accidentally adding an implementation that breaks the safety invariants goes down accordingly. If `Foo` is private, then only the current module and any submodules can add such implementations.    The same rule applies to access to fields: if the safety of an unsafe block depends on certain invariants over a type’s fields, then any code that can touch those fields (including safe code) falls within the privacy boundary of the unsafe block. Here, too, minimizing the privacy boundary is the best approach—code that cannot get to the fields cannot mess up your invariants!    Because unsafe code often requires this wide-reaching reasoning, it’s best practice to encapsulate the unsafety in your code as best you can. Provide the unsafety in the form of a single module, and strive to give that module an interface that is entirely safe. That way you only need to audit the internals of that module for your invariants. Or better yet, stick the unsafe bits in their own crate so that you can’t leave any holes open by accident!    It’s not always possible to fully encapsulate complex unsafe interactions to a single, safe interface, however. When that’s the case, try to narrow down the parts of the public interface that have to be unsafe so that you have only a very small number of them, give them names that clearly communicate that care is needed, and then document them rigorously.    It is sometimes tempting to remove the `unsafe` marker on internal APIs so that you don’t have to stick `unsafe {}` throughout your code. After all, inside your code you know never to invoke `frobnify` if you’ve previously called `bazzify`, right? Removing the `unsafe` annotation can lead to cleaner code but is usually a bad decision in the long run. A year from now, when your codebase has grown, you’ve paged out some of the safety invariants, and you “just want to hack together this one feature real quick,” chances are that you’ll inadvertently violate one of those invariants. And since you don’t have to type `unsafe`, you won’t even think to check. Plus, even if you never make mistakes, what about other contributors to your code? Ultimately, cleaner code is not a good enough argument to remove the intentionally noisy `unsafe` marker.    ### Read and Write Documentation    It goes without saying that if you write an unsafe function, you must document the conditions under which that function is safe to call. Here, both clarity and completeness are important. Don’t leave any invariants out, even if you’ve already written them somewhere else. If you have a type or module that requires certain global invariants—invariants that must always hold for all uses of the type—then remind the reader that they must also uphold the global invariants in every unsafe function’s documentation too. Developers often read documentation in an ad hoc, on-demand manner, so you can assume they have probably not read your carefully written module-level documentation and need to be given a nudge to do so.    What may be less obvious is that you should also document all unsafe implementations and blocks—think of this as providing proof that you do indeed uphold the contract the operation in question requires. For example, `slice::get_unchecked` requires that the provided index is within the bounds of the slice; when you call that method, put a comment just above it explaining how you know that the index is in fact guaranteed to be in bounds. In some cases, the invariants that the unsafe block requires are extensive, and your comments may get long. That’s a good thing. I have caught mistakes many times by trying to write the safety comment for an unsafe block and realizing halfway through that I actually don’t uphold a key invariant. You’ll also thank yourself a year down the road when you have to modify this code and ensure it’s still safe. And so will the contributor to your project who just stumbled across this unsafe call and wants to understand what’s going on.    Before you get too deep into writing unsafe code, I also highly recommend that you go read the Rustonomicon ([`doc.rust-lang.org/nomicon/`](https://doc.rust-lang.org/nomicon/)) cover to cover. There are so many details that are easy to miss, and will come back to bite you if you’re not aware of them. We’ve covered many of them in this chapter, but it never hurts to be more aware. You should also make liberal use of the Rust reference whenever you’re in doubt. It’s added to regularly, and chances are that if you’re even slightly unsure about whether some assumption you have is right, the reference will call it out. If it doesn’t, consider opening an issue so that it’ll be added!    ### Check Your Work    Okay, so you’ve written some unsafe code, you’ve double- and triple-checked all the invariants, and you think it’s ready to go. Before you put it into production, there are some automated tools that you should run your test suite through (you have a test suite, right?).    The first of these is Miri, the mid-level intermediate representation interpreter. Miri doesn’t compile your code into machine code but instead interprets the Rust code directly. This provides Miri with far more visibility into what your program is doing, which in turn allows it to check that your program doesn’t do anything obviously bad, like read from uninitialized memory. Miri can catch a lot of very subtle and Rust-specific bugs and is a lifesaver for anyone writing unsafe code.    Unfortunately, because Miri has to interpret the code to execute it, code run under Miri often runs orders of magnitude slower than its compiled counterpart. For that reason, Miri should really be used only to execute your test suite. It can also check only the code that actually runs, and thus won’t catch issues in code paths that your test suite doesn’t reach. You should think of Miri as an extension of your test suite, not a replacement for it.    There are also tools known as *sanitizers*, which instrument machine code to detect erroneous behavior at runtime. The overhead and fidelity of these tools vary greatly, but one widely loved tool is Google’s AddressSanitizer. It detects a large number of memory errors, such as use-after-free, buffer overflows, and memory leaks, all of which are common symptoms of incorrect unsafe code. Unlike Miri, these tools operate on machine code and thus tend to be fairly fast—usually within the same order of magnitude. But like Miri, they are constrained to analyzing the code that actually runs, so here too a solid test suite is vital.    The key to using these tools effectively is to automate them through your continuous integration pipeline so they’re run for every change, and to ensure that you add regression tests over time as you discover errors. The tools get better at catching problems as the quality of your test suite improves, so by incorporating new tests as you fix known bugs, you’re earning double points back, so to speak!    Finally, don’t forget to sprinkle assertions generously through unsafe code. A panic is always better than triggering undefined behavior! Check all of your assumptions with assertions if you can—even things like the size of a `usize` if you rely on that for safety. If you’re concerned about runtime cost, make use of the `debug_assert*` macros and the `if cfg!(debug_assertions) || cfg!(test)` construct to execute them only in debug and test contexts.    ## Summary    In this chapter, we’ve walked through the powers that come with the `unsafe` keyword and the responsibilities we accept by leveraging those powers. We also talked about the consequences of writing unsafe unsafe code, and how you really should be thinking about `unsafe` as a way to swear to the compiler that you’ve manually checked that the indicated code is still safe. In the next chapter, we’ll jump into concurrency in Rust and see how you can get all those cores on your shiny new computer to pull in the same direction!```` `````


# 第十章:并发(和并行)

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/rs-rs/img/chapterart.png)

本章的目的是为你提供所有必要的信息和工具,使你能够在 Rust 程序中有效地利用并发,实现在库中对并发的支持,并正确使用 Rust 的并发原语。我不会直接教你如何实现并发数据结构或编写高性能的并发应用程序。我的目标是让你充分理解底层机制,从而具备使用它们的能力,无论你将来需要它们做什么。

并发有三种形式:单线程并发(就像我们在第八章讨论的`async`/`await`),单核多线程并发,以及多核并发,后者实现了真正的并行。每种形式都允许在程序中以不同的方式交替执行并发任务。如果考虑到操作系统调度和抢占的细节,甚至还有更多的子形式,但我们不会深入探讨这些。

在类型层面上,Rust 只表示并发的一个方面:多线程。类型要么对多个线程是安全的,要么不是。如果程序有多个线程(因此是并发的),但只有一个核心(因此不是并行的),Rust 必须假设,如果有多个线程,就可能存在并行性。我们接下来要讨论的大多数类型和技术,无论两个线程是否实际并行执行,都同样适用,因此为了保持语言简洁,我会在本章中用*并发*这个词来表示“事物大致同时运行”的非正式意义。当这种区别很重要时,我会特别指出。

Rust 对基于类型的安全多线程的特别之处在于,这不是编译器的特性,而是一个库特性,开发人员可以扩展它来开发复杂的并发契约。由于线程安全通过`Send`和`Sync`实现以及约束在类型系统中表示,并且这些约束会一直传递到应用代码中,整个程序的线程安全性仅通过类型检查就能得到验证。

《Rust 编程语言》已经涵盖了并发的基础知识,包括`Send`和`Sync`特性、`Arc`和`Mutex`,以及通道。因此,我不会在这里重复这些内容,除非在某些其他话题的上下文中特别值得提及。相反,我们将探讨使并发变得困难的原因,以及一些常见的并发模式来应对这些困难。我们还将探索并发和异步的相互作用(以及它们不相互作用的方式),然后深入研究如何使用原子操作实现更低级别的并发操作。最后,我将以一些建议结束本章,帮助你在处理并发代码时保持理智。

## 并发的困境

在我们深入探讨并发编程的良好模式以及 Rust 的并发机制细节之前,花些时间理解并发为何具有挑战性是值得的。换句话说,为什么我们需要并发代码的特殊模式和机制?

### 正确性

并发中的主要难点在于协调对共享资源的访问——特别是写访问。如果很多线程只是为了读取共享资源而共享它,那么通常很容易:你将它放入一个`Arc`中,或者放入你能获得`&'static`引用的地方,这样就完成了。但一旦有线程想要写入,问题就会层出不穷,通常表现为*数据竞争*。简而言之,数据竞争发生在一个线程更新共享状态时,而第二个线程也在访问该状态,可能是在读取它或更新它。如果没有额外的安全措施,第二个线程可能读取到部分被覆盖的状态,破坏第一个线程写入的部分内容,或根本无法看到第一个线程的写入!一般来说,所有的数据竞争都被认为是未定义行为。

数据竞争是更广泛问题类别的一部分,这类问题主要(但不限于)发生在并发环境中:*竞态条件*。竞态条件发生在多个结果可能由一系列指令产生时,这取决于系统中其他事件的相对时机。这些事件可以是线程执行特定代码、定时器触发、网络数据包到达,或任何其他与时间相关的事件。与数据竞争不同,竞态条件并不固有地是坏的,也不被认为是未定义行为。然而,当出现特别奇怪的竞态时,它们会成为漏洞的温床,正如本章中将要展示的那样。

### 性能

开发者通常在程序中引入并发,是希望提高性能。更准确地说,他们希望并发能够通过利用更多的硬件资源来提高每秒操作的总数。这可以在单个核心上通过让一个线程在另一个线程等待时运行,或者在多个核心上通过让线程同时工作(每个核心上一个线程)来完成,这样本来在单个核心上串行执行的操作得以并行化。当开发者谈到并发时,通常指的是后者这种性能提升,常常以“可扩展性”的形式来讨论。在这个语境中,所谓的可扩展性是指“该程序的性能随着核心数的增加而提升”,意味着如果你给程序更多的核心,它的性能会有所提高。

虽然实现这样的加速是可能的,但比看起来更困难。可扩展性的最终目标是线性扩展性,即当核心数翻倍时,程序每单位时间完成的工作量也翻倍。线性扩展性也常被称为完美扩展性。然而,现实中,很少有并发程序能实现这种加速。亚线性扩展性更为常见,即当从一个核心扩展到两个核心时,吞吐量几乎呈线性增长,但增加更多的核心会带来收益递减。有些程序甚至会经历负扩展性,即让程序使用更多核心反而*减少*吞吐量,通常是因为许多线程都在争用某些共享资源。

可以把它想象成一群人试图将气泡膜上的所有气泡戳破——最初,增加更多的人是有帮助的,但到了某个阶段,由于人太多,每个人的工作反而变得更难。 如果参与的人特别低效,你的团队可能最终会站在那讨论接下来谁该戳破气泡,结果一个气泡也没戳破!这种本应并行执行的任务之间的干扰被称为*争用*,它是扩展性差的死敌。争用可能有多种表现方式,但主要的罪魁祸首是互斥、共享资源耗尽和虚假共享。

#### 互斥

当任何时刻只有一个并发任务可以执行特定代码段时,我们称该段代码的执行是互斥的——如果一个线程执行它,其他线程不能同时执行它。这个概念的典型例子是互斥锁,或称为*mutex*,它明确规定每次只有一个线程可以进入程序代码中的某个关键区段。然而,互斥也可能隐式发生。例如,如果你启动一个线程来管理共享资源,并通过`mpsc`通道发送任务给它,那么这个线程实际上实现了互斥,因为每次只有一个任务可以执行。

互斥也可能在调用操作系统或库函数时发生,这些函数在内部强制执行对关键区段的单线程访问。例如,多年来,标准的内存分配器在某些分配时需要互斥,这使得内存分配成为一种在其他高度并行的程序中造成显著争用的操作。同样,许多看起来应该是独立的操作系统操作,比如在同一目录下创建两个不同名字的文件,可能最终必须在内核中顺序执行。

互斥是并行加速中最明显的障碍,因为根据定义,它强制执行程序某些部分的串行执行。即使你让程序的其他部分完美地随着核心数扩展,你能达到的总加速仍然受到互斥、串行部分长度的限制。要注意你的互斥部分,并尽量将其限制在严格必要的地方。

#### 共享资源耗尽

不幸的是,即使你在任务内部实现了完美的并发,任务需要交互的环境本身可能并不具备完美的可扩展性。内核每秒钟只能处理一定数量的 TCP 套接字发送,内存总线也只能同时进行有限数量的读取,而你的 GPU 在并发处理方面也有其容量限制。对此没有解决办法。通常,环境就是在实践中完美可扩展性崩溃的地方,针对这种情况的修复通常需要大规模的重新设计(甚至是新的硬件!),因此我们在本章中不会再多谈这个话题。只要记住,可扩展性很少是你能够“实现”的,而更多的是你需要持续追求的目标。

#### 假共享

假共享发生在两个不应该相互竞争的操作,尽管它们不相关,仍然相互竞争,从而阻碍了高效的并行执行。这通常是因为这两个操作恰好在某个共享资源上发生冲突,即便它们使用的是该资源的不同部分。

这个问题最简单的例子是锁的过度共享,其中一个锁保护某个复合状态,而两个原本独立的操作都需要获取锁来更新它们各自的状态部分。这就意味着这些操作必须串行执行,而无法并行执行。在某些情况下,可以将一个锁分成两个,每个操作负责独立的部分,这样操作就可以并行进行。然而,这种分锁并不总是直接可行——状态可能因为某个第三操作需要对所有状态部分加锁,所以使用了单一锁。通常情况下,你仍然可以分锁,但必须小心不同线程获取分锁的顺序,以避免死锁的发生——死锁发生在两个操作尝试按不同的顺序获取锁时(如果你感兴趣,可以查阅“哲学家就餐问题”)。另外,对于某些问题,你也许能够完全避免临界区,通过使用底层算法的无锁版本,尽管这些也很难做到完美。归根结底,假共享是一个难以解决的问题,没有一个通用的解决方案,但识别出问题本身就是一个良好的开始。

伪共享的一个更微妙的例子出现在 CPU 层面,正如我们在第二章中简要讨论的那样。CPU 在内部按缓存行操作内存——即内存中连续字节的较长序列——而不是按单个字节操作,以摊销内存访问的成本。例如,在大多数英特尔处理器上,缓存行的大小是 64 字节。这意味着每个内存操作实际上最终读取或写入的是 64 字节的整数倍。伪共享的发生是在两个核心希望更新两个不同字节的值,而这两个字节恰好位于同一个缓存行中时;即使这些更新在逻辑上是分离的,这些更新也必须顺序执行。

这看起来可能太低级,不值得关注,但实际上,这种伪共享会严重影响应用程序的并行加速。想象一下,你为每个线程分配了一个整数数组来表示它完成了多少个操作,但这些整数都位于同一个缓存行内——现在,所有原本并行的线程将在每次执行操作时争用那一行缓存。如果这些操作比较快速,*大部分*执行时间可能最终都花费在争用这些计数器上!

避免伪缓存行共享的技巧是通过填充你的值,使其大小与缓存行相等。这样,两个相邻的值总是位于不同的缓存行上。当然,这也会增加数据结构的大小,所以只有当基准测试表明存在问题时,才使用这种方法。

## 并发模型

Rust 有三种常见的并发模式,你很可能会遇到:共享内存并发、工作池和演员模型。要详细介绍每种实现并发的方法本身就足以写一本书,因此在这里,我将专注于这三种模式。

### 共享内存

共享内存并发,从概念上讲,非常简单:线程通过在它们之间共享的内存区域上进行操作来协作。这可能表现为由互斥锁保护的状态,或者存储在支持多个线程并发访问的哈希映射中。多个线程可能在不重叠的数据片段上执行相同的任务,例如多个线程对`Vec`的不同子范围执行某些功能,或者它们可能执行需要一些共享状态的不同任务,例如在数据库中,一个线程处理用户对表的查询,而另一个线程在后台优化用于存储该表的数据结构。

当你使用共享内存并发时,选择合适的数据结构非常重要,尤其是当涉及的线程需要紧密配合时。一个常规的互斥锁可能会限制核心数的扩展,而一个读写锁可能以牺牲写操作速度为代价,允许更多的并发读取,而一个分片的读写锁可能允许完美可扩展的读取,但会使写操作变得高度破坏性。类似地,一些并发哈希映射旨在提供良好的全方位性能,而其他则特别针对例如并发读取,在写操作较为稀少的场景下表现更好。通常,在共享内存并发中,你希望使用那些专门为你的目标用例设计的数据结构,这样你就可以利用那些针对你应用程序不关心的性能方面做出的优化,换取那些你关注的性能优化。

共享内存并发非常适合那些线程需要以一种不交换顺序的方式共同更新一些共享状态的用例。也就是说,如果一个线程必须使用某个函数`f`更新状态`s`,而另一个线程必须使用另一个函数`g`更新状态,并且`f(g(s)) != g(f(s))`,那么共享内存并发可能是必需的。如果情况并非如此,其他两种模式可能会更适合,因为它们通常能带来更简单和更高性能的设计。

### 工作池

在工作池模型中,许多相同的线程从共享的任务队列中获取任务,然后完全独立地执行这些任务。例如,Web 服务器通常会有一个工作池来处理传入的连接,而异步代码的多线程运行时通常使用工作池来共同执行应用程序的所有未来任务(或者更准确地说,是其顶层任务)。

共享内存并发和工作池之间的界限通常是模糊的,因为工作池通常使用共享内存并发来协调它们如何从队列中获取任务,以及如何将未完成的任务返回队列。例如,假设你正在使用数据并行库`rayon`来并行处理一个向量的每个元素。在幕后,`rayon`会启动一个工作池,将向量分割成子区间,然后将子区间分配给池中的线程。当池中的某个线程完成一个子区间时,`rayon`会安排它开始处理下一个未处理的子区间。向量在所有工作线程之间共享,线程通过一个支持工作窃取的共享内存队列样式的数据结构进行协调。

工作窃取是大多数工作池的一个关键特性。其基本前提是,如果某个线程提前完成了工作,并且没有更多未分配的工作可用,那么该线程可以窃取已经分配给其他工作线程但尚未开始的工作。并非所有的工作都需要相同的时间来完成,因此即使每个工作线程被分配了相同的*工作数量*,一些线程可能比其他线程更快完成它们的任务。与其坐着等那些执行较长任务的线程完成,不如让那些提前完成的线程去帮助落后的线程,从而使整体操作能够更快完成。

实现一个支持这种工作窃取的数据结构是相当困难的,尤其是在不引入线程间不断相互窃取工作的显著开销的情况下,但这个特性对高性能的工作池至关重要。如果你需要一个工作池,通常最好的选择是使用已经经过大量工作验证的工作池,或者至少重用现有工作池的数据结构,而不是从头开始自己编写。

当每个线程执行的工作相同,但它所处理的数据*不同*时,工作池是一个不错的选择。在`rayon`并行映射操作中,每个线程执行相同的映射计算,只是它们在不同的底层数据子集上执行这个计算。在一个多线程异步运行时,每个线程简单地调用`Future::poll`,它们只是针对不同的 future 进行调用。如果你开始需要区分线程池中的线程,那么可能需要考虑采用不同的设计。

### Actor

Actor 并发模型在许多方面与工作池模型正好相反。工作池有许多相同的线程共享一个工作队列,而 actor 模型则有许多独立的工作队列,每个队列对应一个“话题”。每个工作队列输入到一个特定的 actor,由它处理所有与应用程序状态子集相关的任务。这个状态可能是数据库连接、文件、度量数据结构,或者任何你能想象的,可能需要多个线程访问的结构。不管是什么,单个 actor 拥有该状态,如果某个任务想与这个状态交互,它需要向拥有者 actor 发送一条消息,概述它希望执行的操作。当拥有者 actor 接收到消息后,它执行指定的操作,并在相关的情况下将操作结果反馈给询问任务。由于 actor 对其内部资源拥有独占访问权限,因此除了进行消息传递所需的同步机制外,不需要其他锁或同步机制。

Actor 模式的一个关键点是,所有的 actor 都会相互通信。例如,负责日志记录的 actor 如果需要写入文件和数据库表,它可能会向负责这两项操作的 actor 发送消息,要求它们分别执行相应的动作,然后继续处理下一个日志事件。通过这种方式,actor 模型更像是一个网络,而不是轮子上的辐条——一个用户请求到网络服务器可能开始时只是向负责该连接的 actor 发出的单一请求,但可能会传递出成百上千条消息,最终到达系统深处的多个 actor,才满足用户的请求。

在 actor 模式中,没有任何要求每个 actor 都必须是一个独立的线程。相反,大多数 actor 系统建议应该有大量的 actor,因此每个 actor 应该映射为一个任务,而不是一个线程。毕竟,actor 只在执行时需要独占其封装的资源,并不关心它是否运行在自己的线程上。实际上,actor 模型通常与工作池模型一起使用——例如,使用多线程异步运行时 Tokio 的应用程序可以为每个 actor 启动一个异步任务,Tokio 会将每个 actor 的执行转变为工作池中的一项工作。因此,给定 actor 的执行可能会在工作池中的线程之间移动,因为 actor 会在执行时交出控制权并在稍后恢复执行,但每次执行时,actor 都会保持对其封装资源的独占访问。

Actor 并发模型非常适合于资源可以相对独立操作,且每个资源内部几乎没有或没有并发机会的情况。例如,操作系统可能会为每个硬件设备分配一个 actor,网络服务器可能会为每个后端数据库连接分配一个 actor。如果你只需要少数几个 actor,或者工作负载在各个 actor 之间差异很大,或者某些 actor 变得很大,那么 actor 模型可能就不太适用了——在这些情况下,你的应用程序可能会因为系统中某个单一 actor 的执行速度成为瓶颈。而且,由于每个 actor 都期望独占它所负责的资源,因此你无法轻易地将这个瓶颈 actor 的执行过程并行化。

## 异步与并行

正如我们在第八章中讨论的,Rust 中的异步性实现了没有并行的并发——我们可以使用诸如 select 和 join 这样的结构,让单个线程轮询多个 future,并在其中一个、一些或所有 future 完成时继续执行。由于没有涉及并行,因此并发使用 futures 并不要求这些 futures 必须是 `Send`。即使是将一个 future 作为附加的顶层任务进行启动,也不需要 `Send`,因为单个执行线程可以同时管理多个 future 的轮询。

然而,在*大多数*情况下,应用程序既需要并发也需要并行。例如,如果一个 Web 应用程序为每个传入的连接构建一个`Future`,因此会有多个活跃连接同时存在,它可能希望异步执行器能够利用主机计算机的多个核心。这不会自然而然地发生:你的代码必须明确告诉执行器哪些`Future`可以并行执行,哪些不能。

特别地,必须向执行器提供两条信息,以便让它知道可以将`Future`中的工作分配到线程池中。第一条是这些`Future`是`Send`类型——如果不是,执行器将不允许将这些`Future`发送到其他线程进行处理,也就无法实现并行;只有构建这些`Future`的线程才能调用它们的`poll`方法。

第二个信息是如何将`Future`分割成可以独立操作的任务。这与第八章讨论的任务与`Future`的关系有关:如果一个巨大的`Future`包含多个`Future`实例,而这些`Future`实例本身对应的是可以并行运行的任务,那么执行器仍然必须在顶层`Future`上调用`poll`,并且必须由单个线程来调用,因为`poll`需要`&mut self`。因此,为了实现并行,必须显式地生成你希望能够并行运行的`Future`。此外,由于第一个要求,执行器函数需要保证传入的`Future`是`Send`类型。

## 低级并发

标准库提供了`std::sync::atomic`模块,它提供了对底层 CPU 原语的访问,像通道和互斥锁这样的高级构造正是基于这些原语构建的。这些原语以以`Atomic`开头的原子类型的形式出现——`AtomicUsize`、`AtomicI32`、`AtomicBool`、`AtomicPtr`等等,还有`Ordering`类型,以及两个名为`fence`和`compiler_fence`的函数。我们将在接下来的几个部分详细讲解这些内容。

这些类型是构建任何需要在线程之间通信的代码的基础块。互斥锁、通道、屏障、并发哈希表、无锁栈以及所有其他同步构造最终都依赖于这些原语来完成它们的工作。它们本身也非常有用,特别是在需要线程间轻量级协作的场景中,这时像互斥锁这样的重型同步机制显得过于繁重——例如,用来递增共享计数器或将共享布尔值设置为`true`。

原子类型之所以特别,是因为它们定义了多个线程尝试并发访问时应该发生的语义。这些类型都支持(大致上)相同的 API:`load`、`store`、`fetch_*`和`compare_exchange`。在本节的其余部分,我们将探讨这些函数的作用,如何正确使用它们,以及它们的应用场景。但首先,我们必须讨论低级内存操作和内存排序。

### 内存操作

非正式地,我们通常将访问变量称为“从内存读取”或“写入内存”。实际上,代码使用变量与实际的 CPU 指令访问内存硬件之间有很多中间机制。理解这些机制,至少从高层次理解它们,对于理解并发内存访问的行为至关重要。

编译器决定在程序读取变量的值或给变量赋新值时发出哪些指令。它可以对代码进行各种转换和优化,并可能最终重新排序程序语句、消除其认为多余的操作,或使用 CPU 寄存器而不是实际内存来存储中间计算结果。编译器在这些转换上受到一定的限制,但最终只有部分变量访问会实际转化为内存访问指令。

在 CPU 级别,内存指令有两种主要形式:加载和存储。加载指令将内存位置的字节拉入 CPU 寄存器,存储指令则将 CPU 寄存器中的字节存储到内存位置。加载和存储操作一次处理较小的内存块:在现代 CPU 上通常是 8 个字节或更少。如果一个变量访问跨越的字节数超过了单次加载或存储可以访问的范围,编译器会自动将其转化为多个加载或存储指令,视情况而定。CPU 在执行程序指令时也有一定的灵活性,以更好地利用硬件并提高程序性能。例如,现代 CPU 常常并行执行指令,甚至在指令之间没有依赖关系时会乱序执行。此外,CPU 与计算机的 DRAM 之间还有几层缓存,这意味着对给定内存位置的加载可能不会看到最新的存储操作,按时钟时间来计算。

在大多数代码中,编译器和 CPU 只能以不影响程序语义的方式转换代码,因此这些转换对程序员是不可见的。然而,在并行执行的上下文中,这些转换可能对应用程序的行为产生重大影响。因此,CPU 通常提供多种不同的加载和存储指令变体,每种变体都有不同的保证,说明 CPU 如何重新排序它们以及它们如何与其他 CPU 上的并行操作交错执行。类似地,编译器(或者更准确地说,编译器编译的语言)提供了不同的注解,您可以使用这些注解为其某些内存访问的子集强制执行特定的执行约束。在 Rust 中,这些注解以原子类型及其方法的形式出现,我们将在本节的剩余部分对其进行详细分析。

### 原子类型

Rust 的原子类型之所以被称为原子类型,是因为它们可以原子地访问——也就是说,原子类型变量的值是一次性写入的,绝不会通过多个存储操作进行写入,从而保证了对该变量的加载操作无法观察到只有部分字节发生了变化,而其他字节尚未发生变化(或还没有发生变化)。通过与非原子类型的对比,最容易理解这一点。例如,将新值重新赋给类型为 `(i64, i64)` 的元组通常需要两条 CPU 存储指令,每条指令对应一个 8 字节的值。如果一个线程执行这两条存储指令,另一个线程则可以(如果暂时忽略借用检查器)在第一次存储之后、第二次存储之前读取该元组的值,从而得到该元组值的不一致视图。它最终会读取到第一个元素的新值和第二个元素的旧值,而第二个元素的值根本没有被任何线程存储过。

CPU 只能原子地访问某些大小的值,因此只有少数几种原子类型,所有这些类型都位于 `atomic` 模块中。每种原子类型的大小都是 CPU 支持原子访问的大小,并且针对诸如值是否为有符号类型、区分原子 `usize` 和指针(其大小与 `usize` 相同)等情况提供了多个变体。此外,原子类型还具有显式的方法,用于加载和存储它们所持有的值,并且还有一些更复杂的方法,我们稍后会回到这些方法,这样可以使程序员编写的代码与最终生成的 CPU 指令之间的映射更加清晰。例如,`AtomicI32::load` 执行对一个有符号 32 位值的单次加载操作,而 `AtomicPtr::store` 执行对一个指针大小的值(在 64 位平台上为 64 位)的单次存储操作。

### 内存排序

大多数原子类型的方法都接受一个 `Ordering` 类型的参数,该参数决定了原子操作所受的内存排序限制。在不同线程之间,原子值的加载和存储可能仅由编译器和 CPU 以与每个原子操作请求的内存排序兼容的交错方式来排序。在接下来的几个部分中,我们将看到一些示例,说明为什么对排序的控制对于从编译器和 CPU 获得预期的语义是重要且必要的。

内存排序常常显得直觉上反常,因为我们人类习惯从上到下阅读程序,并想象它们是逐行执行的——但实际上,当代码运行到硬件时,并不是按这个方式执行的。内存访问可能会被重新排序,甚至可能完全省略,一个线程的写操作可能不会立即被其他线程看到,即使程序顺序中稍后的写操作已经被观察到。

可以这样理解:每个内存位置看到的是来自不同线程的一系列修改,并且不同内存位置的修改序列是相互独立的。如果两个线程 T1 和 T2 都写入内存位置 M,那么即使 T1 按照用户用秒表测量的顺序先执行,T2 对 M 的写操作仍然可能看起来先于 T1 执行,前提是两者执行之间没有其他约束。实际上,*计算机在确定给定内存位置的值时并不考虑墙钟时间*——唯一重要的是程序员对什么构成有效执行所施加的执行约束。例如,如果 T1 向 M 写入数据,然后启动线程 T2,T2 然后写入 M,计算机必须识别 T1 的写操作发生在前,因为 T2 的存在依赖于 T1。

如果这很难理解,不用担心——内存顺序是个令人费解的话题,而且语言规范通常使用非常精确但不太直观的措辞来描述它。我们可以通过关注底层硬件架构构建一个更易理解的心理模型,尽管它稍微简化一些。基本来说,你的计算机内存是作为一个树状层级结构来组织的,其中叶子节点是 CPU 寄存器,根节点是存储在物理内存芯片上的数据,通常称为主内存。两者之间有多个层级的缓存,并且不同层级的缓存可能位于不同的硬件上。当一个线程向内存位置写入时,实际上发生的是 CPU 启动一个写请求,该请求会从给定的 CPU 寄存器开始,最终向上进入内存层级直到主内存。当一个线程执行加载操作时,请求会沿层级流动,直到找到有该值的缓存层级并从那里返回。问题在于:写操作并非在所有地方都可见,直到所有缓存都更新了被写入的内存位置,但其他 CPU 可以同时对同一内存位置执行指令,这时就会出现奇怪的情况。因此,内存顺序是一种请求精确语义的方法,描述在多个 CPU 访问特定内存位置时发生了什么操作。

记住这一点后,我们来看看 `Ordering` 类型,它是我们作为程序员用来对并发执行施加额外约束的主要机制。

`Ordering` 被定义为一个 `enum`,其变体如 列表 10-1 所示。

enum Ordering {
Relaxed,
Release,
Acquire,
AcqRel,
SeqCst
}


列表 10-1:`Ordering` 的定义

每个这些地方对源代码到执行语义的映射施加不同的限制,接下来我们将依次探讨每一个。

#### 放松顺序

放松排序本质上并不对并发访问该值提供任何保证,除了访问是原子的这一事实。特别地,放松排序并不保证不同线程间内存访问的相对顺序。这是最弱的内存排序形式。示例 10-2 展示了一个简单的程序,其中两个线程使用 `Ordering::Relaxed` 访问两个原子变量。

static X: AtomicBool = AtomicBool::new(false);
static Y: AtomicBool = AtomicBool::new(false);

let t1 = spawn(|| {
1 let r1 = Y.load(Ordering::Relaxed);
2 X.store(r1, Ordering::Relaxed);
});
let t2 = spawn(|| {
3 let r2 = X.load(Ordering::Relaxed);
4 Y.store(true, Ordering::Relaxed)
});


示例 10-2:两个竞态线程与 `Ordering::Relaxed`

查看作为 `t2` 生成的线程,你可能会认为 `r2` 永远不会是 `true`,因为所有的值在同一线程将 `true` 赋值给 `Y` 之后,直到读取 `X` 这一行*才*变成 `true`,之前都是 `false`。然而,在放宽的内存排序下,这个结果完全是可能的。原因在于,CPU 允许重新排序涉及的加载和存储操作。让我们逐步分析一下,是什么让 `r2 = true` 成为可能。

首先,CPU 注意到 4 不一定要在 3 之后发生,因为 4 不依赖于 3 的任何输出或副作用。也就是说,4 不依赖于 3 的执行顺序。因此,CPU 决定为了*某些原因*重新排序它们,以使程序运行得更快。于是,CPU 首先执行 4,设置 `Y = true`,即使 3 还没有执行。然后,`t2` 被操作系统挂起,`t1` 执行了几条指令,或者 `t1` 在另一个核心上执行。在 `t1` 中,编译器确实必须首先执行 1,然后执行 2,因为 2 依赖于 1 中读取的值。因此,`t1` 从 `Y`(由 4 写入)读取 `true` 并将其写回 `X`。最后,`t2` 执行 3,读取 `X` 并获得 `true`,正如 2 所写的那样。

```````````` The relaxed memory ordering allows this execution because it imposes no additional constraints on concurrent execution. That is, under relaxed memory ordering, the compiler must ensure only that execution dependencies on any given thread are respected (just as if atomics weren’t involved); it need not make any promises about the interleaving of concurrent operations. Reordering 3 and 4 is permitted for a single-threaded execution, so it is permitted under relaxed ordering as well.    In some cases, this kind of reordering is fine. For example, if you have a counter that just keeps track of metrics, it doesn’t really matter when exactly it executes relative to other instructions, and `Ordering::Relaxed` is fine. In other cases, this could be disastrous: say, if your program uses `r2` to figure out if security protections have already been set up, and thus ends up erroneously believing that they already have been.    You don’t generally notice this reordering when writing code that doesn’t make fancy use of atomics—the CPU has to promise that there is no observable difference between the code as written and what each thread actually executes, so everything seems like it runs in order just as you wrote it. This is referred to as respecting program order or evaluation order; the terms are synonyms.    #### Acquire/Release Ordering    At the next step up in the memory ordering hierarchy, we have `Ordering::Acquire`, `Ordering::Release`, and `Ordering::AcqRel` (acquire plus release). At a high level, these establish an execution dependency between a store in one thread and a load in another and then restrict how operations can be reordered with respect to that load and store. Crucially, these dependencies not only establish a relationship between a store and a load of a single value, but also put ordering constraints on *other* loads and stores in the threads involved. This is because every execution must respect the program order; if a load in thread B has a dependency on some store in thread A (the store in A must execute before the load in B), then any read or write in B after that load must also happen after that store in A.    Concretely, these memory orderings place the following restrictions on execution:    1.  Loads and stores cannot be moved forward past a store with `Ordering::Release`. 2.  Loads and stores cannot be moved back before a load with `Ordering::Acquire`. 3.  An `Ordering::Acquire` load of a variable must see all stores that happened before an `Ordering::Release` store that stored what the load loaded.    To see how these memory orderings change things, Listing 10-3 shows Listing 10-2 again but with the memory ordering swapped out for `Acquire` and `Release`.    ``` static X: AtomicBool = AtomicBool::new(false); static Y: AtomicBool = AtomicBool::new(false);  let t1 = spawn(|| {     let r1 = Y.load(Ordering::Acquire);     X.store(r1, Ordering::Release); }); let t2 = spawn(|| {   1 let r2 = X.load(Ordering::Acquire);   2 Y.store(true, Ordering::Release) }); ```    Listing 10-3: Listing 10-2 with `Acquire/Release` `memory ordering`   ``````````` These additional restrictions mean that it is no longer possible for `t2` to see `r2 = true`. To see why, consider the primary cause of the weird outcome in Listing 10-2: the reordering of 1 and 2. The very first restriction, on stores with `Ordering::Release`, dictates that we cannot move 1 below 2, so we’re all good!    But these rules are useful beyond this simple example. For example, imagine that you implement a mutual exclusion lock. You want to make sure that any loads and stores a thread runs while it holds the lock are executed only while it’s actually holding the lock, and visible to any thread that takes the lock later. This is exactly what `Release` and `Acquire` enable you to do. By performing a `Release` store to release the lock and an `Acquire` load to acquire the lock, you can guarantee that the loads and stores in the critical section are never moved to before the lock was actually acquired or to after the lock was released!   `````````` #### Sequentially Consistent Ordering    Sequentially consistent ordering (`Ordering::SeqCst`) is the strongest memory ordering we have access to. Its exact guarantees are somewhat hard to nail down, but very broadly, it requires not only that each thread sees results consistent with `Acquire/Release```` , but also that all threads see the *same* ordering as one another. This is best seen by way of contrast with the behavior of `Acquire` and `Release`. Specifically, `Acquire/Release` `` ordering does *not* guarantee that if two threads A and B atomically load values written by two other threads X and Y, A and B will see a consistent pattern of when X wrote relative to Y. That’s fairly abstract, so consider the example in Listing 10-4, which shows a case where `Acquire/Release` `ordering can produce unexpected results. Afterwards, we’ll see how sequentially consistent ordering avoids that particular unexpected outcome.` `` ```   ````````` ```````` ``````` ``` static X: AtomicBool = AtomicBool::new(false); static Y: AtomicBool = AtomicBool::new(false); static Z: AtomicI32 = AtomicI32::new(0);  let t1 = spawn(|| {     X.store(true, Ordering::Release); }); let t2 = spawn(|| {     Y.store(true, Ordering::Release); }); let t3 = spawn(|| {     while (!X.load(Ordering::Acquire)) {}   1 if (Y.load(Ordering::Acquire)) {         Z.fetch_add(1, Ordering::Relaxed); } }); let t4 = spawn(|| {     while (!Y.load(Ordering::Acquire)) {}   2 if (X.load(Ordering::Acquire)) {         Z.fetch_add(1, Ordering::Relaxed); } }); ```    Listing 10-4: Weird results with `Acquire/Release` `ordering`   `````` The two threads `t1` and `t2` set `X` and `Y` to `true`, respectively. Thread `t3` waits for `X` to be `true`; once `X` is `true`, it checks if `Y` is `true` and, if so, adds 1 to `Z`. Thread `t4` instead waits for `Y` to become `true`, and then checks if `X` is `true` and, if so, adds 1 to `Z`. At this point the question is: what are the possible values for `Z` after all the threads terminate? Before I show you the answer, try to work your way through it given the definitions of `Release` and `Acquire` ordering in the previous section.    First, let’s recap the conditions under which `Z` is incremented. Thread `t3` increments `Z` if it sees that `Y` is `true` after it observes that `X` is `true`, which can happen only if `t2` runs before `t3` evaluates the load at 1. Conversely, thread `t4` increments `Z` if it sees that `X` is `true` after it observes that `Y` is `true`, so only if `t1` runs before `t4` evaluates the load at 2. To simplify the explanation, let’s assume for now that each thread runs to completion once it runs.    Logically, then, `Z` can be incremented twice if the threads run in the order 1, 2, 3, 4—both `X` and `Y` are set to `true`, and then `t3` and `t4` run to find that their conditions for incrementing `Z` are met. Similarly, `Z` can trivially be incremented just once if the threads run in the order 1, 3, 2, 4\. This satisfies `t4`’s condition for incrementing `Z`, but not `t3`’s. Getting `Z` to be `0`, however, *seems* impossible: if we want to prevent `t3` from incrementing `Z`, `t2` has to run after `t3`. Since `t3` runs only after `t1`, that implies that `t2` runs after `t1`. However, `t4` won’t run until after `t2` has run, so `t1` must have run and set `X` to `true` by the time `t4` runs, and so `t4` will increment `Z`.    Our inability to get `Z` to be `0` stems mostly from our human inclination for linear explanations; this happened, then this happened, then this happened. Computers aren’t limited in the same way and have no need to box all events into a single global order. There’s nothing in the rules for `Release` and `Acquire` that says that `t3` must observe the same execution order for `t1` and `t2` as `t4` observes. As far as the computer is concerned, it’s fine to let `t3` observe `t1` as having executed first, while having `t4` observe `t2` as having executed first. With that in mind, an execution in which `t3` observes that `Y` is `false` after it observes that `X` is `true` (implying that `t2` runs after `t1`), while in the same execution `t4` observes that `X` is `false` after it observes that `Y` is `true` (implying that `t2` runs before `t1`), is completely reasonable, even if that seems outrageous to us mere humans.    As we discussed earlier, `Acquire/Release` ``requires only that an `Ordering::Acquire` load of a variable must see all stores that happened before an `Ordering::Release` store that stored what the load loaded. In the ordering just discussed, the computer *did* uphold that property: `t3` sees `X == true`, and indeed sees all stores by `t1` prior to it setting `X = true`—there are none. It also sees `Y == false`, which was stored by the main thread at program startup, so there aren’t any relevant stores to be concerned with. Similarly, `t4` sees `Y = true` and also sees all stores by `t2` prior to setting `Y = true`—again, there are none. It also sees `X == false`, which was stored by the main thread and has no preceding store. No rules are broken, yet it just seems wrong somehow.``   ````` Our intuitive expectation was that we could put the threads in some global order to make sense of what every thread saw and did, but that was not the case for `Acquire/Release` `ordering in this example. To achieve something closer to that intuitive expectation, we need sequential consistency. Sequential consistency requires all the threads taking part in an atomic operation to coordinate to ensure that what each thread observes corresponds to (or at least appears to correspond to) *some* single, common execution order. This makes it easier to reason about but also makes it costly.`   ````Atomic loads and stores marked with `Ordering::SeqCst` instruct the compiler to take any extra precautions (such as using special CPU instructions) needed to guarantee sequential consistency for those loads and stores. The exact formalism around this is fairly convoluted, but sequential consistency essentially ensures that if you looked at all the related `SeqCst` operations from across all your threads, you could put the thread executions in *some* order so that the values that were loaded and stored would all match up.    If we replaced all the memory ordering arguments in Listing 10-4 with `SeqCst`, `Z` could not possibly be `0` after all the threads have exited, just as we originally expected. Under sequential consistency, it must be possible to say either that `t1` definitely ran before `t2` or that `t2` definitely ran before `t1`, so the execution where `t3` and `t4` see different orders is not allowed, and thus `Z` cannot be `0`.    ### Compare and Exchange    In addition to `load` and `store`, all of Rust’s atomic types provide a method called `compare_exchange`. This method is used to atomically *and conditionally* replace a value. You provide `compare_exchange` with the last value you observed for an atomic variable and the new value you want to replace the original value with, and it will replace the value only if it is still the same as it was when you last observed it. To see why this is important, take a look at the (broken) implementation of a mutual exclusion lock in Listing 10-5. This implementation keeps track of whether the lock is held in the static atomic variable `LOCK`. We use the Boolean value `true` to represent that the lock is held. To acquire the lock, a thread waits for `LOCK` to be `false`, then sets it to `true` again; it then enters its critical section and sets `LOCK` to `false` to release the lock when its work (`f`) is done.    ``` static LOCK: AtomicBool = AtomicBool::new(false);  fn mutex(f: impl FnOnce()) {     // Wait for the lock to become free (false).     while LOCK.load(Ordering::Acquire)       { /* .. TODO: avoid spinning .. */ }     // Store the fact that we hold the lock.     LOCK.store(true, Ordering::Release);     // Call f while holding the lock.     f();     // Release the lock.     LOCK.store(false, Ordering::Release); } ```    Listing 10-5: An incorrect implementation of a mutual exclusion lock    This mostly works, but it has a terrible flaw—two threads might both see `LOCK == false` at the same time and both leave the `while` loop. Then they both set `LOCK` to `true` and both enter the critical section, which is exactly what the `mutex` function was supposed to prevent!    The issue in Listing 10-5 is that there is a gap between when we load the current value of the atomic variable and when we subsequently update it, during which another thread might get to run and read or touch its value. It is exactly this problem that `compare_exchange` solves—it swaps out the value behind the atomic variable *only* if its value still matches the previous read, and otherwise notifies you that the value has changed. Listing 10-6 shows the corrected implementation using `compare_exchange`.    ``` static LOCK: AtomicBool = AtomicBool::new(false);  fn mutex(f: impl FnOnce()) {     // Wait for the lock to become free (false).     loop {       let take = LOCK.compare_exchange(           false,           true,           Ordering::AcqRel,           Ordering::Relaxed       );       match take {         Ok(false) => break,         Ok(true) | Err(false) => unreachable!(),  Err(true) => { /* .. TODO: avoid spinning .. */ }       }     }     // Call f while holding the lock.     f();     // Release the lock.     LOCK.store(false, Ordering::Release); } ```    Listing 10-6: A corrected implementation of a mutual exclusion lock    This time around, we use `compare_exchange` in the loop, and it takes care of both checking that the lock is currently not held and storing `true` to take the lock as appropriate. This happens through the first and second arguments to `compare_exchange`, respectively: in this case, `false` and then `true`. You can read the invocation as “Store `true` only if the current value is `false`.” The `compare_exchange` method returns a `Result` that indicates either that the value was successfully updated (`Ok`) or that it could not be updated (`Err`). In either case, it also returns the current value. This isn’t too useful with an `AtomicBool` since we know what the value must be if the operation failed, but for something like an `AtomicI32`, the updated current value will let you quickly recompute what to store and then try again without having to do another load.    Unlike simple loads and stores, `compare_exchange` takes *two* `Ordering` arguments. The first is the “success ordering,” and it dictates what memory ordering should be used for the load and store that the `compare_exchange` represents in the case that the value was successfully updated. The second is the “failure ordering,” and it dictates the memory ordering for the load if the loaded value does not match the expected current value. These two orderings are kept separate so that the developer can give the CPU leeway to improve execution performance by reordering loads and stores on failure when appropriate, but still get the correct ordering on success. In this case, it’s okay to reorder loads and stores across failed iterations of the lock acquisition loop, but it’s *not* okay to reorder loads and stores inside the critical section in such a way that they end up outside of it.    Even though its interface is simple, `compare_exchange` is a very powerful synchronization primitive—so much so that it’s been theoretically proven that you can build all other distributed consensus primitives using only `compare_exchange`! For that reason, it is the workhorse of many, if not most, synchronization constructs when you really dig into the implementation details.    Be aware, though, that a `compare_exchange` requires that a single CPU has exclusive access to the underlying value, and it is therefore a form of mutual exclusion at the hardware level. This in turn means that `compare_exchange` can quickly become a scalability bottleneck: only one CPU can make progress at a time, so there’s a portion of your code that will not scale with the number of cores. In fact, it’s probably worse than that—the CPUs have to coordinate to ensure that only one CPU succeeds at a `compare_exchange` for a variable at a time (take a look at the MESI protocol if you’re curious about how that works), and that coordination grows quadratically more costly the more CPUs are involved!    ### The Fetch Methods    Fetch methods (`fetch_add`, `fetch_sub`, `fetch_and`, and the like) are designed to allow more efficient execution of atomic operations that commute—that is, operations that have meaningful semantics regardless of the order they execute in. The motivation for this is that the `compare_exchange` method is powerful, but also costly—if two threads both want to update a single atomic variable, one will succeed, while the other will fail and have to retry. If many threads are involved, they all have to mediate sequential access to the underlying value, and there will be plenty of spinning while threads retry on failure.    For simple operations that commute, rather than fail and retry just because another thread modified the value, we can tell the CPU what operation to perform on the atomic variable. It’ll then perform that operation on whatever the current value happens to be when the CPU eventually gets exclusive access. Think of an `AtomicUsize` that counts the number of operations a pool of threads has completed. If two threads both complete a job at the same time, it doesn’t matter which one updates the counter first as long as both their increments are counted.    The fetch methods implement these kinds of commutative operations. They perform a read *and* a store operation in a single step and guarantee that the store operation was performed on the atomic variable when it held exactly the value returned by the method. As an example, `AtomicUsize::fetch_add(1, Ordering::Relaxed)` never fails—it always adds 1 to the current value of the `AtomicUsize`, no matter what it is, and returns the value of the `AtomicUsize` precisely when this thread’s 1 was added.    The fetch methods tend to be more efficient than `compare_exchange` because they don’t require threads to fail and retry when multiple threads contend for access to a variable. Some hardware architectures even have specialized fetch method implementations that scale much better as the number of involved CPUs grows. Nevertheless, if enough threads try to operate on the same atomic variable, those operations will begin to slow down and exhibit sublinear scaling due to the coordination required. In general, the best way to significantly improve the performance of a concurrent algorithm is to split contended variables into more atomic variables that are each less contended, rather than switching from `compare_exchange` to a fetch method.    ## Sane Concurrency    Writing correct and performant concurrent code is harder than writing sequential code; you have to consider not only possible execution interleavings but also how your code interacts with the compiler, the CPU, and the memory subsystem. With such a wide array of footguns at your disposal, it’s easy to want to throw your hands in the air and just give up on concurrency altogether. In this section we’ll explore some techniques and tools that can help ensure that you write correct concurrent code without (as much) fear.    ### Start Simple    It is a fact of life that simple, straightforward, easy-to-follow code is more likely to be correct. This principle also applies to concurrent code—always start with the simplest concurrent design you can think of, then measure, and only if measurement reveals a performance problem should you optimize your algorithm.    To follow this tip in practice, start out with concurrency patterns that do not require intricate use of atomics or lots of fine-grained locks. Begin with multiple threads that run sequential code and communicate over channels, or that cooperate through locks, and then benchmark the resulting performance with the workload you care about. You’re much less likely to make mistakes this way than by implementing fancy lockless algorithms or by splitting your locks into a thousand pieces to avoid false sharing. For many use cases, these designs are plenty fast enough; it turns out a lot of time and effort has gone into making channels and locks perform well! And if the simple approach is fast enough for your use case, why introduce more complex and error-prone code?    If your benchmarks indicate a performance problem, then figure out exactly which part of your system scales poorly. Focus on fixing that bottleneck in isolation where you can, and try to do so with small adjustments where possible. Maybe it’s enough to split a lock in two rather than move to a concurrent hash table, or to introduce another thread and a channel rather than implement a lock-free work stealing queue. If so, do that.    Even when you do have to work directly with atomics and the like, keep things simple until there’s a proven need to optimize—use `Ordering::SeqCst` and `compare_exchange` at first, and then iterate if you find concrete evidence that those are becoming bottlenecks that must be taken care of.    ### Write Stress Tests    As the author, you have a lot of insight into where bugs in your code may hide, without necessarily knowing what those bugs are (yet, anyway). Writing stress tests is a good way to shake out some of the hidden bugs. Stress tests don’t necessarily perform a complex sequence of steps but instead have lots of threads doing relatively simple operations in parallel.    For example, if you were writing a concurrent hash map, one stress test might be to have *N* threads insert or update keys and *M* threads read keys in such a way that those *M*+*N* threads are likely to often choose the same keys. Such a test doesn’t test for a particular outcome or value but instead tries to trigger many possible interleavings of operations in the hopes that buggy interleavings might reveal themselves.    Stress tests resemble fuzz tests in many ways; whereas fuzzing generates many random inputs to a given function, the stress test instead generates many random thread and memory access schedules. Just like fuzzers, stress tests are therefore only as good as the assertions in your code; they can’t tell you about a bug that doesn’t manifest in some easy-to-spot way like an assertion failure or some other kind of panic. For that reason, it’s a good idea to litter your low-level concurrency code with assertions, or `debug_assert_*` if you’re worried about runtime cost in particularly hot loops.    ### Use Concurrency Testing Tools    The primary challenge in writing concurrent code is to handle all the possible ways the execution of different threads can interleave. As we saw in the `Ordering::SeqCst` example in Listing 10-4, it’s not just the thread scheduling that matters, but also which memory values are possible for a given thread to observe at any given point in time. Writing tests that execute every possible legal execution is not only tedious but also difficult—you need very low-level control over which threads execute when and what values their reads return, which the operating system likely doesn’t provide.    #### Model Checking with Loom    Luckily, a tool already exists that can simplify this execution exploration for you in the form of the `loom` crate. Given the relative release cycles of this book and that of a Rust crate, I won’t give any examples of how to use Loom here, as they’d likely be out of date by the time you read this book, but I will give an overview of what it does.    Loom expects you to write dedicated test cases in the form of closures that you pass into a Loom model. The model keeps track of all cross-thread interactions and tries to intelligently explore all possible iterations of those interactions by executing the test case closure multiple times. To detect and control thread interactions, Loom provides replacement types for all the types in the standard library that allow threads to coordinate with one another; that includes most types under `std::sync` and `std::thread` as well as `UnsafeCell` and a few others. Loom expects your application to use those replacement types whenever you run the Loom tests. The replacement types tie into the Loom executor and perform a dual function: they act as rescheduling points so that Loom can choose which operation to run next after each possible thread interaction point, and they inform Loom of new possible interleavings to consider. Essentially, Loom builds up a tree of all the possible future executions for each point at which multiple execution interleavings are possible and then tries to execute all of them, one after the other.    Loom attempts to fully explore all possible executions of the test cases you provide it with, which means it can find bugs that occur only in extremely rare executions that stress testing would not find in a hundred years. While that’s great for smaller test cases, it’s generally not feasible to apply that kind of rigorous testing to larger test cases that test more involved sequences of operations or require many threads to run at once. Loom would simply take too long to get decent coverage of the code. In practice, you may therefore want to tell Loom to consider only a subset of the possible executions, which Loom’s documentation has more details on.    Like with stress tests, Loom can catch only bugs that manifest as panics, so that’s yet another reason to spend some time placing strategic assertions in your concurrent code! In many cases, it may even be worthwhile to add additional state tracking and bookkeeping instructions to your concurrent code to give you better assertions.    #### Runtime Checking with ThreadSanitizer    For larger test cases, your best bet is to run the test through a couple of iterations under Google’s excellent `ThreadSanitizer`, also known as TSan. TSan automatically augments your code by placing extra bookkeeping instructions prior to every memory access. Then, as your code runs, those bookkeeping instructions update and check a special state machine that flags any concurrent memory operations that indicate a problematic race condition. For example, if thread B writes to some atomic value X, but has not synchronized (lots of hand waving here) with the thread that wrote the previous value of X that indicates a write/write race, which is nearly always a bug.    Since TSan only observes your code running and does not execute it over and over again like Loom, it generally only adds a constant-factor overhead to the runtime of your program. While that factor can be significant (5–15 times at the time of writing), it’s still small enough that you can execute even most complex test cases in a reasonable amount of time.    At the time of writing, to use TSan you need to use a nightly version of the Rust compiler and pass in the `-Zsanitizer=thread` command-line argument (or set it in `RUSTFLAGS`), though hopefully in time this will be a standard supported option. Other sanitizers are also available that check things like out-of-bounds memory accesses, use-after-free, memory leaks, and reads of uninitialized memory, and you may want to run your concurrent test suite through those too!    ## Summary    In this chapter, we first covered common correctness and performance pitfalls in concurrent Rust, and some of the high-level concurrency patterns that successful concurrent applications tend to use to work around them. We also explored how asynchronous Rust enables concurrency without parallelism, and how to explicitly introduce parallelism in asynchronous Rust code. We then dove deeper into Rust’s many different lower-level concurrency primitives, including how they work, how they differ, and what they’re all for. Finally, we explored techniques for writing better concurrent code and looked at tools like Loom and TSan that can help you vet that code. In the next chapter we’ll continue our journey through the lower levels of Rust by digging into foreign function interfaces, which allow Rust code to link directly against code written in other languages.```` ````` `````` ``````` ```````` ````````` `````````` ``````````` ````````````


# 第十一章:外部函数接口

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/rs-rs/img/chapterart.png)

不是所有的代码都是用 Rust 编写的。这是令人震惊的,我知道。时不时,你需要与其他语言编写的代码进行交互,可能是通过从 Rust 调用这些代码,或者允许这些代码调用你的 Rust 代码。你可以通过*外部函数接口(FFI)*来实现这一点。

在本章中,我们将首先了解 Rust 为 FFI 提供的主要机制:`extern`关键字。我们将看到如何使用`extern`将 Rust 函数和静态变量暴露给其他语言,以及如何让 Rust 访问外部提供的函数和静态变量。然后,我们将介绍如何将 Rust 类型与其他语言中定义的类型对齐,并探讨一些让数据流经 FFI 边界的复杂性。最后,我们将讨论一些在进行大量 FFI 操作时,你可能会用到的工具。

## 跨越边界与 extern

FFI 的核心目标是访问来自你应用程序 Rust 代码之外的字节。为此,Rust 提供了两个主要构建块:*符号*,它们是分配给你二进制文件中某一段特定地址的名称,允许你在外部来源与 Rust 代码之间共享内存(无论是数据还是代码);以及*调用约定*,它们提供了一种共同的理解,说明如何调用存储在这些共享内存中的函数。我们将依次了解这两个构建块。

### 符号

编译器从你的代码生成的任何二进制文件都会充满符号——你定义的每个函数或静态变量都有一个符号,指向它在编译后二进制文件中的位置。通用函数甚至可能有多个符号,每个符号对应编译器生成的该函数的不同单态化版本!

通常,你不需要考虑符号——它们是由编译器内部使用,用来传递二进制文件中函数或静态变量的最终地址。这就是编译器知道在生成最终机器代码时,每个函数调用应该定位到内存中的哪个位置,或者当你的代码访问静态变量时应该从哪里读取。由于你通常不会在代码中直接引用符号,编译器默认会为它们选择半随机名称——你可能在代码的不同部分有两个名为`foo`的函数,但编译器会为它们生成不同的符号,从而避免混淆。

然而,当你想调用一个函数或访问一个未同时编译的静态变量时,使用随机名称的符号是行不通的,例如那些用不同语言编写并因此由不同编译器编译的代码。如果符号的名称是半随机的并且不断变化,你无法将一个 C 语言中定义的静态变量告诉 Rust。反之,如果你无法为 Rust 函数提供一个稳定的名称,你也无法通过 Python 的 FFI 接口来访问 Rust 函数。

为了使用具有外部来源的符号,我们还需要某种方式告诉 Rust 该变量或函数的存在,以便编译器会查找在其他地方定义的相同符号,而不是定义自己的符号(稍后我们会讨论这种查找是如何进行的)。否则,我们最终会得到两个相同的符号用于那个函数或静态变量,且无法共享。实际上,很可能编译会失败,因为任何引用该符号的代码都不知道该使用哪个定义(即,哪个地址)。

#### 编译与链接的简要说明

编译器速成课程!了解将代码转换为可运行二进制文件的复杂过程的粗略概念,将有助于你更好地理解 FFI。你看,编译器并不是一个庞大的程序,而是(通常)被拆分成几个小程序,每个程序执行不同的任务,并按顺序运行。从高层次来看,编译有三个不同的阶段——*编译*、*代码生成*和*链接*——由三个不同的组件来处理。

第一个阶段由大多数人认为是“编译器”的程序执行;它处理类型检查、借用检查、单态化以及我们与某个编程语言相关联的其他特性。这个阶段不会生成机器代码,而是生成一个使用大量注解的抽象机器操作的低级表示。然后,这个低级表示被传递给代码生成工具,后者会生成可以在特定 CPU 上运行的机器代码。

这两个操作,合起来并不需要在一次大规模的过程中对整个代码库进行处理。相反,代码库可以被切分成更小的块,然后并行地进行编译。例如,Rust 通常会独立且并行地编译不同的 crate,只要它们之间没有依赖关系。它还可以分别调用代码生成工具来并行处理独立的 crate。Rust 甚至经常可以分别编译同一个 crate 的多个小片段!

一旦应用程序的每个部分的机器码都生成完毕,这些部分就可以被组合在一起。这是在链接阶段完成的,毫不奇怪的是,由链接器来进行。链接器的主要工作是将代码生成过程中产生的所有二进制文件(称为*目标文件*)拼接成一个单一的文件,然后将每个符号的引用替换为该符号的最终内存地址。这就是你如何在一个 crate 中定义一个函数并在另一个 crate 中调用它,同时仍然能分别编译这两个 crate 的原因。

链接器使 FFI 工作。它不关心每个输入目标文件是如何构建的;它只是忠实地将所有目标文件链接在一起,然后解析任何共享的符号。一个目标文件可能最初是 Rust 代码,一个可能是 C 代码,还有一个可能是从互联网下载的二进制文件;只要它们使用相同的符号名称,链接器就会确保生成的机器代码为任何共享符号使用正确的交叉引用地址。

符号可以通过*静态*或*动态*方式进行链接。静态链接是最简单的,因为每个对符号的引用仅仅被该符号定义的地址所替代。另一方面,动态链接将每个对符号的引用与生成的代码绑定,该代码会在程序*运行时*尝试找到符号的定义。稍后我们将进一步讨论这些链接模式。Rust 通常默认对 Rust 代码使用静态链接,对 FFI 使用动态链接。

#### 使用 extern

`extern` 关键字是让我们声明一个符号存在于外部接口中的机制。具体来说,它声明了一个在其他地方定义的符号的存在。在清单 11-1 中,我们在 Rust 中定义了一个静态变量 `RS_DEBUG`,并通过 FFI 使其对其他代码可用。我们还声明了一个静态变量 `FOREIGN_DEBUG`,它的定义未指定,但将在链接时解决。

[no_mangle]

pub static RS_DEBUG: bool = true;

extern {
static FOREIGN_DEBUG: bool;
}


清单 11-1:通过 FFI 暴露一个 Rust 静态变量,并访问在其他地方声明的静态变量

`#[no_mangle]` 属性确保 `RS_DEBUG` 在编译期间保持该名称,而不是让编译器为其分配另一个符号名称,例如,用以区分程序中其他地方的(非 FFI)`RS_DEBUG` 静态变量。由于它是 crate 的公共 API 一部分,因此该变量也声明为 `pub`,尽管对于标记为 `#[no_mangle]` 的项,这个注解并不是严格必要的。请注意,我们没有为 `RS_DEBUG` 使用 `extern`,因为它是在此处定义的。它仍然可以通过其他语言进行链接访问。

包围 `FOREIGN_DEBUG` 静态变量的 `extern` 块表示该声明引用了一个 Rust 会在链接时基于同一符号定义所在位置来学习的地方。由于它在其他地方定义,因此我们没有给它初始化值,只给了一个类型,这个类型应该与定义位置使用的类型匹配。因为 Rust 不知道定义静态变量的代码,所以无法检查你是否为符号声明了正确的类型,因此 `FOREIGN_DEBUG` 只能在 `unsafe` 块内访问。

声明 FFI 函数的过程非常类似。在清单 11-2 中,我们使 `hello_rust` 可供非 Rust 代码访问,并引入外部的 `hello_foreign` 函数。

[no_mangle]

pub extern fn hello_rust(i: i32) { ... }

extern {
fn hello_foreign(i: i32);
}


清单 11-2:通过 FFI 暴露一个 Rust 函数,并访问在其他地方定义的函数

构建模块与 Listing 11-1 中的完全相同,唯一不同的是 Rust 函数使用 `extern fn` 声明,我们将在下一节中探讨这一点。

如果存在多个定义的外部符号,例如 `FOREIGN_DEBUG` 或 `hello_foreign`,你可以使用 `#[link]` 属性明确指定符号应该链接到哪个库。如果不指定,链接器会报错,表示它找到了该符号的多个定义。例如,如果你在 `extern` 块前加上 `#[link(name = "crypto")]`,你是在告诉链接器将任何符号(无论是静态的还是函数)与名为 “crypto” 的库进行链接。你还可以通过在 Rust 代码中为声明加上 `#[link_name = "<actual_symbol_name>"]` 注解来重命名外部静态或函数,这样该项就会链接到你希望的任何名称。类似地,你可以使用 `#[export_name = "<export_symbol_name>"]` 来重命名 Rust 项目的导出名称。

#### 链接类型

`#[link]` 也接受 `kind` 参数,该参数决定块中的项应如何链接。默认情况下,该参数为 `"dylib"`,表示 C 兼容的动态链接。另一个 `kind` 值是 `"static"`,表示块中的项应该在编译时完全链接(即静态链接)。这基本上意味着外部代码直接被嵌入编译器生成的二进制文件中,因此在运行时不需要存在。还有一些其他的 `kind` 值,但它们不太常见,且超出了本书的范围。

静态链接和动态链接之间有几个权衡,但主要考虑因素是安全性、二进制文件大小和分发。首先,动态链接通常更安全,因为它使得独立升级库变得更加容易。动态链接允许部署包含你代码的二进制文件的人在不需要重新编译代码的情况下升级代码所依赖的库。如果比如说 `libcrypto` 得到了安全更新,用户可以在主机上更新加密库并重启二进制文件,更新后的库代码将自动使用。静态编译则是将库的代码直接硬编码到二进制文件中,因此用户必须重新编译你的代码,以适应升级后的库版本。

动态链接通常会产生更小的二进制文件。由于静态编译会将所有链接的代码包含到最终的二进制输出中,并且任何代码所拉入的代码也会被包括进来,从而产生更大的二进制文件。而动态链接则使每个外部项仅包含少量的包装代码,在运行时加载指定的库,并转发访问。

到目前为止,静态链接可能看起来不太吸引人,但它相比动态链接有一个大优势:分发的便捷性。使用动态链接时,任何想要运行包含你代码的二进制文件的人,*还需要*拥有你的代码所依赖的所有库。更重要的是,他们必须确保拥有的每个库的版本与代码期望的版本兼容。像 `glibc` 或 OpenSSL 这样的库在大多数系统上都有,可能不会造成问题,但对于一些较为冷门的库来说就会产生问题。用户需要知道自己应该安装这些库,并且必须去寻找它们才能运行你的代码!而静态链接将库的代码直接嵌入到二进制文件中,因此用户不需要自己安装它。

最终,静态链接和动态链接之间没有一个*正确*的选择。动态链接通常是一个很好的默认选项,但对于特别受限的部署环境,或者非常小型或特殊的库依赖,静态编译可能是更好的选择。使用你的最佳判断!

### 调用约定

符号决定了*某个*函数或变量定义的位置,但这还不足以跨越 FFI 边界进行函数调用。要调用任何语言中的外部函数,编译器还需要知道它的*调用约定*,这决定了用什么样的汇编代码来调用该函数。我们这里不会涉及每个调用约定的实际技术细节,但作为一个概述,调用约定规定了:

+   调用的栈帧是如何设置的

+   参数是如何传递的(是在栈上还是寄存器中,顺序还是反向顺序)

+   函数返回时如何告知它跳回哪里

+   在函数完成后,如何恢复各种 CPU 状态,如寄存器等

Rust 有自己独特的调用约定,这个约定并没有标准化,并且允许编译器随时间变化进行调整。只要所有的函数定义和调用都由同一个 Rust 编译器编译,这样的约定就没有问题,但如果你希望与外部代码互操作,就会出现问题,因为外部代码不知道 Rust 的调用约定。

如果你没有声明任何其他内容,所有的 Rust 函数都会隐式声明为 `extern "Rust"`。单独使用 `extern`,就像在示例 11-2 中那样,是 `extern "C"` 的简写,意思是“使用标准的 C 调用约定”。之所以使用简写,是因为 C 调用约定几乎是所有 FFI 情况下的首选。

Rust 还支持多种调用约定,你可以通过在 `extern` 关键字后添加字符串来指定(在 `fn` 和块上下文中均适用)。例如,`extern "system"` 表示使用操作系统标准库接口的调用约定,在写作时,这个约定在除 Win32 以外的地方与 `"C"` 一致,而 Win32 使用的是 `"stdcall"` 调用约定。一般来说,除非你处理的是特别平台特定或高度优化的外部接口,否则很少需要显式地提供调用约定,因此仅使用 `extern`(即 `extern "C"`)就可以了。

## 跨语言边界的类型

在 FFI 中,类型布局至关重要;如果一种语言以某种方式布局共享数据的内存,而 FFI 边界另一端的语言期望它以不同的方式布局,那么两端将不一致地解释数据。在这一节中,我们将讨论如何在 FFI 中使类型匹配,并在跨语言边界时需要注意的其他类型方面。

### 类型匹配

类型在 FFI 边界上不共享。当你在 Rust 中声明一个类型时,编译后该类型的信息会完全丢失。传递到另一端的只是构成该类型值的位。因此,你需要在边界的两端都声明这些位的类型。当你声明 Rust 版本的类型时,必须首先确保类型内包含的原始类型匹配。例如,如果另一端使用 C,并且 C 类型使用 `int`,那么 Rust 代码最好使用完全相同的 Rust 等价类型:`i32`。为了减少这一过程中的猜测,对于使用类似 C 类型的接口,Rust 标准库通过 `std::os::raw` 模块为你提供正确的 C 类型,其中定义了 `type c_int = i32`,`type c_char = i8/u8`(取决于 `char` 是否有符号),`type c_long = i32/i64`(取决于目标指针宽度)等。

对于更复杂的类型,如向量和字符串,通常需要手动进行映射。例如,由于 C 通常将字符串表示为以 0 字节终止的字节序列,而不是以 UTF-8 编码的字符串并将长度单独存储,因此一般不能通过 FFI 使用 Rust 的字符串类型。相反,假设另一端使用 C 风格的字符串表示,你应该分别使用 `std::ffi::CStr` 和 `std::ffi::CString` 类型来处理借用和拥有的字符串。对于向量,你可能需要使用指向第一个元素的原始指针,然后单独传递长度—`Vec::into_raw_parts` 方法在这方面可能会派上用场。

对于包含其他类型的类型,例如结构体和联合体,你还需要处理布局和对齐。如我们在第二章讨论的那样,Rust 默认情况下以未定义的方式布局类型,因此至少你需要使用`#[repr(C)]`来确保该类型具有确定的布局和对齐方式,这种方式与 FFI 边界上可能使用的(并且希望使用的)方式一致。如果接口还指定了该类型的其他配置,例如手动设置其对齐方式或删除填充,你需要相应地调整你的`#[repr]`。

Rust 枚举有多种可能的 C 样式表示,取决于枚举是否包含数据。考虑一个没有数据的枚举,如下所示:

enum Foo { Bar, Baz }


使用`#[repr(C)]`,类型`Foo`仅使用一个与 C 编译器为具有相同数量变体的枚举选择的整数大小相同的整数进行编码。第一个变体的值为`0`,第二个变体的值为`1`,以此类推。你也可以手动为每个变体分配值,如列表 11-3 所示。

[repr(C)]

enum Foo {
Bar = 1,
Baz = 2,
}


列表 11-3:为无数据枚举定义显式变体值

然而,在将 C 中类似枚举的类型映射到 Rust 时,你需要小心,因为只有已定义变体的值对枚举类型的实例是有效的。这通常会导致你遇到问题,特别是与 C 风格的枚举有关,C 风格的枚举往往更像是位集合,变体可以按位或组合成一个值,从而同时封装多个变体。例如,在列表 11-3 中的例子中,取`Bar | Baz`产生的值`3`在 Rust 中对于`Foo`是无效的!如果你需要模拟一个 C API,该 API 使用枚举表示一组可以单独设置和取消设置的位标志,请考虑使用围绕整数类型的新类型包装器,并为每个变体定义关联常量,另外实现各种`Bit*`特征以提高可操作性。或者使用`bitflags` crate。

对于包含数据的枚举,`#[repr(C)]`属性使得枚举被表示为*标记联合体*。也就是说,它在内存中通过一个`#[repr(C)]`结构体表示,结构体有两个字段,第一个字段是判别符,如果没有变体包含字段时,它将按该方式进行编码,第二个字段是每个变体数据结构的联合体。具体示例,请参见列表 11-4 中的枚举及其相关表示。

[repr(C)]

enum Foo {
Bar(i32),
Baz { a: bool, b: f64 }
}
// is represented as

[repr(C)]

enum FooTag { Bar, Baz }

[repr(C)]

struct FooBar(i32);

[repr(C)]

struct FooBaz{ a: bool, b: f64 }

[repr(C)]

union FooData {
bar: FooBar,
baz: FooBaz,
}

[repr(C)]

struct Foo {
tag: FooTag,
data: FooData
}


列表 11-4:带有`#[repr(C)]`的 Rust 枚举被表示为标记联合体。

### 分配

当你分配内存时,该内存的分配归属于它的分配器,只有该分配器才能释放这块内存。如果你在 Rust 中使用多个分配器,或者如果你在 Rust 中分配内存并使用 FFI 边界另一侧的某个分配器进行内存分配,情况也是如此。你可以自由地跨越边界发送指针并随意访问这块内存,但当再次释放内存时,必须将其返回给相应的分配器。

大多数 FFI 接口将有两种内存分配处理配置:要么是调用者提供数据指针指向内存块,要么是接口暴露专门的释放方法,任何分配的资源在不再需要时应返回到这些方法中。示例 11-5 展示了来自 OpenSSL 库的一些 Rust 声明示例,这些声明使用了实现管理的内存。

// One function allocates memory for a new object.
extern fn ECDSA_SIG_new() -> *mut ECDSA_SIG;

// And another accepts a pointer created by new
// and deallocates it when the caller is done with it.
extern fn ECDSA_SIG_free(sig: *mut ECDSA_SIG);


示例 11-5:实现管理的内存接口

函数 `ECDSA_SIG_new` 和 `ECDSA_SIG_free` 组成一对,调用者应先调用 `new` 函数,在需要时使用返回的指针(可能会将其传递给其他函数),然后在完成引用的资源后将指针传递给 `free` 函数。可以推测,实现会在 `new` 函数中分配内存,在 `free` 函数中释放内存。如果这些函数在 Rust 中定义,`new` 函数可能会使用 `Box::new`,而 `free` 函数则会调用 `Box::from_raw`,然后通过 `drop` 执行析构。

示例 11-6 展示了调用者管理的内存示例。

// An example of caller-managed memory.
// The caller provides a pointer to a chunk of memory,
// which the implementation then uses to instantiate its own types.
// No free function is provided, as that happens in the caller.
extern fn BIO_new_mem_buf(buf: *const c_void, len: c_int) -> *mut BIO


示例 11-6:调用者管理的内存接口

在这里,`BIO_new_mem_buf` 函数要求调用者提供后备内存。调用者可以选择在堆上分配内存,或者使用任何其他合适的机制来获取所需的内存,并将其传递给库。之后,责任在于调用者,确保内存在不再被 FFI 实现使用时被释放!

你可以在 FFI API 中使用这些方法中的任意一种,甚至可以根据需要将它们混合使用。一般来说,当可能时,允许调用者传入内存,因为这样可以让调用者在管理内存时拥有更多自由。例如,调用者可能在某个定制的操作系统上使用高度专业化的分配器,可能不希望被迫使用你实现中将使用的标准分配器。如果调用者可以传入内存,它甚至可以避免分配内存,若它能够使用堆栈内存或重用已分配的内存。然而,记住调用者管理的接口通常更复杂,因为调用者必须完成所有工作,计算要分配多少内存,并在调用库之前进行设置。

在某些情况下,调用者甚至无法提前知道需要分配多少内存——例如,如果您的库类型是不可见的(因此调用者无法知晓),或者这些类型会随着时间变化,调用者就无法预测分配的大小。类似地,如果您的代码在运行时需要分配更多内存,比如在动态构建图时,所需的内存量可能会在运行时动态变化。在这种情况下,您将需要使用由实现管理的内存。

当您必须做出权衡时,对于任何*较大*或*频繁*的内存分配,最好使用调用者分配内存。在这些情况下,调用者可能最关心的是控制内存分配。对于其他情况,您的代码分配内存并为每个相关类型暴露析构函数可能是可以接受的。

### 回调

您可以跨 FFI 边界传递函数指针,并通过这些指针调用引用的函数,只要函数指针的类型具有与函数调用约定匹配的 `extern` 注解。也就是说,您可以在 Rust 中定义一个 `extern "C" fn(c_int) -> c_int` 函数,然后将该函数的引用传递给 C 代码,作为回调,C 代码最终会调用它。

使用回调时要小心恐慌,因为如果恐慌在函数结束后发生,而该函数的类型不是 `extern "Rust"`,则行为是未定义的。目前,Rust 编译器在检测到这种恐慌时会自动中止,但这可能不是您希望的行为。相反,您可能希望使用 `std::panic::catch_unwind` 来检测任何标记为 `extern` 的函数中的恐慌,然后将恐慌转化为一个 FFI 兼容的错误。

### 安全性

当您编写 Rust FFI 绑定时,大部分实际与 FFI 接口交互的代码都会是 `unsafe` 的,主要涉及原始指针。然而,您的目标应该是在 FFI 上方最终呈现一个*安全*的 Rust 接口。这样做主要是通过仔细阅读您正在包装的 `unsafe` 接口的不变量,并确保通过 Rust 类型系统在安全接口中保持这些不变量。安全封装外部接口的三个最重要元素是准确捕获 `&` 与 `&mut`,适当地实现 `Send` 和 `Sync`,并确保指针不会被意外混淆。接下来,我将详细介绍如何执行这些操作。

#### 引用和生命周期

如果外部代码有可能修改给定指针指向的数据,确保安全的 Rust 接口通过 `&mut` 拥有对相关数据的独占引用。否则,您的安全封装的用户可能会意外读取正在被外部代码同时修改的内存,后果不堪设想!

你还需要充分利用 Rust 生命周期来确保所有指针的生命周期与 FFI 要求的一致。例如,假设有一个外部接口,允许你创建一个`Context`,然后从该`Context`创建一个`Device`,并要求`Context`在`Device`的生命周期内保持有效。在这种情况下,任何安全的接口包装器都应该通过使`Device`持有与`Context`的借用相关联的生命周期来在类型系统中强制执行这一要求。

#### Send 和 Sync

除非外部库明确记录其类型是线程安全的,否则不要为外部库中的类型实现`Send`和`Sync`!确保安全 Rust 代码*不能*违反外部代码的不变式,从而触发未定义行为,这是安全 Rust 包装器的职责。

有时,你甚至可能想要引入虚拟类型来强制执行外部不变式。例如,假设你有一个事件循环库,其接口如示例 11-7 所示。

extern fn start_main_loop();
extern fn next_event() -> *mut Event;


示例 11-7:一个期望单线程使用的库

假设外部库的文档说明`next_event`只能由调用`start_main_loop`的同一线程调用。然而,在这里我们没有可以避免为其实现`Send`的类型!相反,我们可以借鉴第三章的思路,介绍额外的标记状态来强制执行不变式,如示例 11-8 所示。

pub struct EventLoop(std::marker::PhantomData<*const ()>);
pub fn start() -> EventLoop {
unsafe { ffi::start_main_loop() };
EventLoop(std::marker::PhantomData)
}
impl EventLoop {
pub fn next_event(&self) -> Option {
let e = unsafe { ffi::next_event() };
// ...
}
}


示例 11-8:通过引入辅助类型来强制执行 FFI 不变式

空类型`EventLoop`实际上并未与底层外部接口的任何内容连接,而是强制要求在调用`start_main_loop`后,并且仅在同一线程上调用`next_event`。你通过使`EventLoop`既不是`Send`也不是`Sync`来强制执行“同一线程”这一部分,通过让它持有一个虚拟的原始指针(该指针本身既不是`Send`也不是`Sync`)。

````Using `PhantomData<*const ()>` to “undo” the `Send` and `Sync` auto-traits as we do here is a bit ugly and indirect. Rust does have an unstable compiler feature that enables negative trait implementations like `impl !Send for EventLoop {}`, but it’s surprisingly difficult to get its implementation right, and it likely won’t stabilize for some time.    You may have noticed that nothing prevents the caller from invoking `start_main_loop` multiple times, either from the same thread or from another thread. How you’d handle that would depend on the semantics of the library in question, so I’ll leave it to you as an exercise.    #### Pointer Confusion    In many FFI APIs, you don’t necessarily want the caller to know the internal representation for each and every chunk of memory you give it pointers to. The type might have internal state that the caller shouldn’t fiddle with, or the state might be difficult to express in a cross-language-compatible way. For these kinds of situations, C-style APIs usually expose *void pointers*, written out as the C type `void*`, which is equivalent to `*mut std::ffi::c_void` in Rust. A type-erased pointer like this is, effectively, *just* a pointer, and does not convey anything about the thing it points to. For that reason, these kinds of pointers are often referred to as *opaque*.    Opaque pointers effectively serve the role of visibility modifiers for types across FFI boundaries—since the method signature does not say what’s being pointed to, the caller has no option but to pass around the pointer as is and use any available FFI methods to provide visibility into the referenced data. Unfortunately, since one `*mut c_void` is indistinguishable from another, there’s nothing stopping a user from taking an opaque pointer as is returned from one FFI method and supplying it to a method that expects a pointer to a *different* opaque type.    We can do better than this in Rust. To mitigate this kind of pointer type confusion, we can avoid using `*mut c_void` directly for opaque pointers in FFI, even if the actual interface calls for a `void*`, and instead construct different empty types for each distinct opaque type. For example, in Listing 11-9 I use two distinct opaque pointer types that cannot be confused.    ``` #[non_exhaustive] #[repr(transparent)] pub struct Foo(c_void); #[non_exhaustive] #[repr(transparent)] pub struct Bar(c_void); extern {     pub fn foo() -> *mut Foo;     pub fn take_foo(arg: *mut Foo);     pub fn take_bar(arg: *mut Bar); } ```    Listing 11-9: Opaque pointer types that cannot be confused    Since `Foo` and `Bar` are both zero-sized types, they can be used in place of `()` in the `extern` method signatures. Even better, since they are now distinct types, Rust won’t let you use one where the other is required, so it’s now impossible to call `take_bar` with a pointer you got back from `foo`. Adding the `#[non_exhaustive]` annotation ensures that the `Foo` and `Bar` types cannot be constructed outside of this crate.    ## bindgen and Build Scripts    Mapping out the Rust types and `extern`s for a larger external library can be quite a chore. Big libraries tend to have a large enough number of type and method signatures to match up that writing out all the Rust equivalents is time-consuming. They also have enough corner cases and C oddities that some patterns are bound to require more careful thought to translate.    Luckily, the Rust community has developed a tool called `bindgen` that significantly simplifies this process as long as you have C header files available for the library you want to interface with. `bindgen` essentially encodes all the rules and best practices we’ve discussed in this chapter, plus a number of others, and wraps them up in a configurable code generator that takes in C header files and spits out appropriate Rust equivalents.    `bindgen` provides a stand-alone binary that generates the Rust code for C headers once, which is convenient when you want to check in the bindings. This process allows you to hand-tune the generated bindings, should that be necessary. If, on the other hand, you want to generate the bindings automatically on every build and just include the C header files in your source code, `bindgen` also ships as a library that you can invoke in a custom *build script* for your package.    You declare a build script by adding `build = "``<some-file.rs>``"` to the `[package]` section of your *Cargo.toml*. This tells Cargo that, before compiling your crate, it should compile *<some-file.rs>* as a stand-alone Rust program and run it; only then should it compile the source code of your crate. The build script also gets its own dependencies, which you declare in the `[build-dependencies]` section of your *Cargo.toml*.    Build scripts come in very handy with FFI—they can compile a bundled C library from source, dynamically discover and declare additional build flags to be passed to the compiler, declare additional files that Cargo should check for changes for the purposes of recompilation, and, you guessed it, generate additional source files on the fly!    Though build scripts are very versatile, beware of making them too aware of the environment they run in. While you can use a build script to detect if the Rust compiler version is a prime or if it’s going to rain in Istanbul tomorrow, making your compilation dependent on such conditions may make builds fail unexpectedly for other developers, which leads to a poor development experience.    The build script can write files to a special directory supplied through the `OUT_DIR` environment variable. The same directory and environment variable are also accessible in the Rust source code at compile time so that it can pick up files generated by the build script. To generate and use Rust types from a C header, you first have your build script use the library version of `bindgen` to read in a *.h* file and turn it into a file called, say, *bindings.rs* inside `OUT_DIR`. You then add the following line to any Rust file in your crate to include *bindings.rs* at compilation time:    ``` include!(concat!(env!("OUT_DIR"), "/bindings.rs")); ```    Since the code in *bindings.rs* is autogenerated, it’s generally best practice to place the bindings in their own crate and give the crate the same name as the library the bindings are for, with the suffix `-sys` (for example, `openssl-sys`). If you don’t follow this practice, releasing new versions of your library will be much more painful, as it is illegal for two crates that link against the same external library through the `links` key in *Cargo.toml* to coexist in a given build. You would essentially have to upgrade the entire ecosystem to the new major version of your library all at once. Separating just the bindings into their own crate allows you to issue new major versions of the wrapper crate that can be adopted incrementally. The separation also allows you to cut a breaking release of the crate with those bindings if the Rust bindings change—say, if the header files themselves are upgraded or a `bindgen` upgrade causes the generated Rust code to change slightly—without *also* having to cut a breaking release of the crate that safely wraps the FFI bindings.    If your crate instead produces a library file that you intend others to use through FFI, you should also publish a C header file for its interface to make it easier to generate native bindings to your library from other languages. However, that C header file then needs to be kept up to date as your crate changes, which can become cumbersome as your library grows in size. Fortunately, the Rust community has also developed a tool to automate this task: `cbindgen`. Like `bindgen`, `cbindgen` is a build tool, and it also comes as both a binary and a library for use in build scripts. Instead of taking in a C header file and producing Rust, it takes Rust in and produces a C header file. Since the C header file represents the main computer-readable description of your crate’s FFI, I recommend manually looking it over to make sure the autogenerated C code isn’t too unwieldy, though in general `cbindgen` tends to produce fairly reasonable code. If it doesn’t, file a bug!    ## Summary    In this chapter, we’ve covered how to use the `extern` keyword to call out of Rust into external code, as well as how to use it to make Rust code accessible to external code. We’ve also discussed how to align Rust types with types on the other side of the FFI boundary, and some of the common pitfalls in trying to get code written in two different languages to mesh well. Finally, we talked about the `bindgen` and `cbindgen` tools, which make the experience of keeping FFI bindings up to date much more pleasant. In the next chapter, we’ll look at how to use Rust in more restricted environments, like embedded devices, where the standard library may not be available and where even a simple operation like allocating memory may not be possible.````


# 第十二章:无标准库的 Rust

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/rs-rs/img/chapterart.png)

Rust 旨在成为一种系统编程语言,但它究竟意味着什么并不总是显而易见。至少,系统编程语言通常被期望允许程序员编写不依赖操作系统并可以直接在硬件上运行的程序,无论这个硬件是一个千核超级计算机,还是一个拥有 72MHz 时钟速度和 256KiB 内存的单核 ARM 嵌入式设备。

在本章中,我们将探讨如何在非常规环境中使用 Rust,例如没有操作系统的环境,或者那些甚至无法动态分配内存的环境!我们的讨论将重点关注`#![no_std]`属性,但我们也将研究 Rust 的`alloc`模块、Rust 的运行时(是的,Rust 确实有一个运行时)以及在这种环境中编写 Rust 二进制文件时需要采用的一些技巧。

## 放弃标准库

作为一门语言,Rust 由多个独立的部分组成。首先是编译器,它规定了 Rust 语言的语法,并实现了类型检查、借用检查以及最终转换为机器可运行代码的过程。然后是标准库`std`,它实现了大多数程序需要的所有常用功能——例如文件和网络访问、时间的概念、打印和读取用户输入的功能等。但`std`本身也是一个复合体,建立在两个更基础的库`core`和`alloc`之上。事实上,`std`中的许多类型和函数只是从这两个库重新导出的。

`core`库位于标准库金字塔的底部,包含任何仅依赖于 Rust 语言本身和程序运行时硬件的功能——如排序算法、标记类型、基础类型如`Option`和`Result`、低级操作如原子内存访问方法以及编译器提示等。`core`库的工作方式就像操作系统不存在一样,因此没有标准输入、没有文件系统,也没有网络。同样,也没有内存分配器,因此像`Box`、`Vec`和`HashMap`这样的类型也无法找到。

在`core`之上是`alloc`,它包含所有依赖于动态内存分配的功能,如集合、智能指针和动态分配的字符串(`String`)。我们将在下一节中回到`alloc`。

大多数情况下,因为`std`重新导出了`core`和`alloc`中的所有内容,开发者不需要了解这三个库之间的差异。这意味着,尽管`Option`技术上存在于`core::option::Option`中,但你可以通过`std::option::Option`来访问它。

然而,在像嵌入式设备这样的非常规环境中,情况就大不相同,因为这类设备没有操作系统,这种区分就变得至关重要。虽然使用 `Iterator` 或对数字列表进行排序是可以的,但嵌入式设备可能根本没有任何有效的方式来访问文件(因为这需要文件系统)或输出到终端(因为这需要终端)—因此没有 `File` 或 `println!`。此外,设备的内存可能极其有限,以至于动态内存分配成为了一种你无法承受的奢侈品,因此任何在运行时分配内存的操作都是不可行的—告别 `Box` 和 `Vec`。

为了避免强迫开发人员在这类环境中小心避开那些基本构造,Rust 提供了一种方法,使得开发者可以选择退出除语言核心功能之外的所有内容:`#![no_std]` 属性。这是一个 crate 级别的属性(`#!`),它将 crate 的预定义(参见第 213 页的框)从 `std::prelude` 切换到 `core::prelude`,从而避免你无意间依赖 `core` 以外的任何在目标环境中可能无法正常工作的内容。

然而,`#![no_std]` 属性的作用仅仅是*全部*—它并不会阻止你通过 `extern std` 显式引入标准库。这可能让人感到意外,因为这意味着标记为 `#![no_std]` 的 crate 实际上可能与不支持 `std` 的目标环境不兼容,但这一设计决定是有意为之:它允许你将 crate 标记为 `no_std` 兼容,但在启用某些特性时仍然可以使用标准库中的功能。例如,许多 crate 有一个名为 `std` 的特性,当启用时,可以访问更复杂的 API,并与 `std` 中的类型进行集成。这使得 crate 的作者可以为受限的使用场景提供核心实现,并为更标准平台上的用户添加附加功能。

## 动态内存分配

正如我们在第一章中讨论的,计算机有许多不同的内存区域,每个区域都有不同的用途。程序代码和静态变量占用静态内存,函数局部变量和函数参数则使用栈内存,堆内存则用于,嗯,其他所有内容。堆支持在运行时分配可变大小的内存区域,这些分配会持续存在,直到你不再需要它们为止。这使得堆内存极为灵活,因此你会在各个地方看到它的使用。`Vec`、`String`、`Arc` 和 `Rc` 以及集合类型都在堆内存中实现,这使得它们可以随着时间的推移而增长或缩小,并且能够从函数中返回而不会被借用检查器抱怨。

在幕后,堆实际上只是一个由*分配器*管理的大块连续内存。正是分配器提供了堆中不同分配的假象,确保这些分配不重叠,并且不再使用的内存区域能够被重用。默认情况下,Rust 使用系统分配器,通常这是由标准 C 库规定的分配器。这适用于大多数用例,但如果有需要,你可以通过`GlobalAlloc`特性和`#[global_allocator]`属性来覆盖 Rust 使用的分配器,这要求实现一个`alloc`方法来分配新内存段,以及`dealloc`方法来将过去的分配返回给分配器进行重用。

在没有操作系统的环境中,标准 C 库通常也不可用,因此标准系统分配器也不可用。由于这个原因,`#![no_std]`也排除了所有依赖于动态内存分配的类型。但由于完全可以在没有完整操作系统的情况下实现内存分配器,Rust 允许你仅选择需要分配器的 Rust 标准库部分,而不需要通过`alloc`包选择所有的`std`。`alloc`包随标准 Rust 工具链一起提供(就像`core`和`std`一样),包含了你最喜欢的堆分配类型,如`Box`、`Arc`、`String`、`Vec`和`BTreeMap`。`HashMap`不在其中,因为它依赖于随机数生成来进行键值哈希,这需要操作系统的支持。要在`no_std`环境中使用`alloc`中的类型,你只需将之前引入`use std::`的代码替换为`use alloc::`即可。不过,请记住,依赖于`alloc`意味着你的`#![no_std]`包将不再可供任何禁止动态内存分配的程序使用,不论是因为它没有分配器,还是因为内存不足以进行动态内存分配。

你可能会觉得奇怪,居然可以编写仅使用 `core` 的非平凡 crate。毕竟,它们不能使用集合、`String` 类型、网络或文件系统,甚至没有时间的概念!`core` 仅 crate 的诀窍在于利用栈和静态分配。例如,对于一个无堆向量,你提前分配足够的内存——无论是在静态内存中还是在函数的栈帧中——用于你预期该向量能够容纳的最大元素数量,然后通过一个 `usize` 来追踪它当前持有的元素数量。要向向量中推送元素,你只需写入(静态大小的)数组中的下一个元素,并递增一个变量以跟踪元素数量。如果向量的长度达到了静态大小,下一次推送将会失败。列表 12-1 给出了一个使用 `const` 泛型实现的无堆向量类型的示例。

struct ArrayVec<T, const N: usize> {
values: [Option; N],
len: usize,
}
impl<T, const N: usize> ArrayVec<T, N> {
fn try_push(&mut self, t: T) -> Result<(), T> {
if self.len == N {
return Err(t);
}
self.values[self.len] = Some(t);
self.len += 1;
return Ok(());
}
}


列表 12-1:一个无堆向量类型

我们将 `ArrayVec` 泛型化,既包含元素类型 `T`,也包含最大元素数量 `N`,然后将向量表示为一个包含 `N` 个 *可选* `T` 的数组。该结构始终存储 `N` 个 `Option<T>`,因此它的大小在编译时已知,可以存储在栈上,但它仍然可以像向量一样,通过使用运行时信息来指导我们如何访问该数组。

## Rust 运行时

你可能听说过 Rust 没有运行时的说法。虽然从高层来说这是真的——它没有垃圾回收器、解释器或内置的用户级调度器——但从严格意义上说这并不完全正确。具体来说,Rust 确实有一些特殊代码,在你的 `main` 函数之前运行,并在你的代码中响应某些特殊条件,这实际上是一种最简化的运行时形式。

### 恐慌处理器

这种特殊代码的第一部分是 Rust 的 *恐慌处理器*。当 Rust 代码通过调用 `panic!` 或 `panic_any` 触发恐慌时,恐慌处理器决定接下来发生的事情。当 Rust 运行时可用时——如大多数提供 `std` 的目标——恐慌处理器首先调用通过 `std::panic::set_hook` 设置的 *恐慌钩子*,该钩子默认会向标准错误打印一条消息并可选择性地打印回溯。然后,它会根据当前编译选项(通过 Cargo 配置或直接传递给 `rustc` 的参数)决定是展开当前线程的堆栈,还是终止进程。

然而,并非所有目标平台都提供 panic 处理程序。例如,大多数嵌入式目标平台并不提供,因为针对所有用途的目标平台并没有一个通用的实现方式。对于那些不提供 panic 处理程序的目标平台,Rust 仍然需要知道在发生 panic 时该如何处理。为此,我们可以使用 `#[panic_handler]` 属性来装饰程序中一个符合签名 `fn(&PanicInfo) -> !` 的函数。每当程序触发 panic 时,该函数会被调用,并且会传递一个 `core::panic::PanicInfo` 类型的 panic 信息。函数如何处理这些信息完全没有规定,但它永远不能返回(这由 `!` 返回类型表示)。这一点非常重要,因为 Rust 编译器假定 panic 后的代码永远不会被执行。

panic 处理程序有很多有效的方式来避免返回。标准的 panic 处理程序会展开线程的栈,然后终止该线程,但 panic 处理程序也可以使用 `loop {}` 来停止线程、终止程序,或者做任何其他适合目标平台的操作,甚至是重置设备。

### 程序初始化

与普遍的看法相反,`main` 函数并不是 Rust 程序中首先运行的部分。实际上,在 Rust 二进制文件中,`main` 符号指向的是标准库中名为 `lang_start` 的函数。该函数执行(相对简单的)Rust 运行时的初始化工作,包括将程序的命令行参数存储在 `std::env::args` 可以访问的地方、设置主线程的名称、处理 `main` 函数中的 panic、在程序退出时刷新标准输出以及设置信号处理器。`lang_start` 函数随后会调用你在 crate 中定义的 `main` 函数,这样你就不需要考虑如何处理例如 Windows 和 Linux 在传递命令行参数时的差异了。

这种安排在所有这些设置合理且受支持的平台上运作良好,但在一些嵌入式平台上会遇到问题,因为程序启动时主内存可能无法访问。在这种平台上,你通常需要完全跳过 Rust 的初始化代码,使用 `#![no_main]` crate 级别属性。这个属性会完全跳过 `lang_start`,意味着作为开发者的你必须弄清楚如何启动程序,例如通过声明一个使用 `#[export_name = "main"]` 的函数来匹配目标平台预期的启动序列。

### 内存不足处理程序

如果你编写的程序希望使用 `alloc`,但它是为没有提供分配器的平台构建的,那么你必须通过本章前面提到的 `#[global_allocator]` 属性来指定使用哪个分配器。但你还必须指定如果该全局分配器无法分配内存时该怎么办。具体来说,你需要定义一个*内存不足处理程序*,说明当像 `Vec::push` 这样的不可失败操作需要分配更多内存,但分配器无法提供时应该发生什么。

在启用 `std` 的平台上,内存不足处理程序的默认行为是向标准错误输出打印错误信息,然后中止进程。然而,在一个例如没有标准错误的平台上,显然这不起作用。撰写本文时,在这种平台上,你的程序必须显式地使用不稳定属性 `#[lang = "oom"]` 来定义内存不足处理程序。请记住,处理程序几乎肯定应该阻止后续执行,否则尝试分配的代码将在不知道未能分配所需内存的情况下继续执行!

## 低级内存访问

在第十章中,我们讨论了编译器在将程序语句转换为机器指令时所拥有的相当自由度,以及 CPU 允许执行无序指令的空间。通常,编译器和 CPU 所能利用的快捷方式和优化对程序的语义是不可见的——你通常无法判断,例如,两个读取操作是否相对重排过,或者从同一内存位置的两次读取是否实际上会导致两条 CPU 加载指令。这是经过设计的。语言和硬件设计者仔细指定了程序运行时程序员通常期望的语义,这样你的代码通常会按你预期的方式执行。

然而,`no_std` 编程有时会让你超越“隐形优化”的常规边界。特别是,你经常需要通过*内存映射*与硬件设备进行通信,在这种方式下,设备的内部状态会在内存中的特定区域提供。例如,当你的计算机启动时,内存地址范围 `0xA0000`–`0xBFFFF` 映射到一个粗略的图形渲染管道;在该范围内对单个字节的写入将改变屏幕上的特定像素(或者块,取决于模式)。

当你与设备映射内存交互时,设备可能会对每次内存访问实现自定义行为,因此你 CPU 和编译器对常规内存加载和存储的假设可能不再成立。例如,硬件设备常常有内存映射寄存器,在读取时会被修改,这意味着读取操作会有副作用。在这种情况下,如果你连续两次读取相同的内存地址,编译器就不能安全地省略内存存储操作!

当程序执行突然偏离代码中所表示的方式时,就会出现类似的问题,编译器无法预见这些情况。执行可能会被转移,如果没有底层操作系统来处理处理器异常或中断,或者如果进程收到中断执行的信号。在这些情况下,活动代码段的执行会停止,CPU 开始执行触发偏移的事件处理程序中的指令。通常,由于编译器可以预见所有可能的执行情况,它会安排优化,以使执行无法观察到操作是否已按顺序执行或被优化掉。然而,由于编译器无法预测这些异常跳转,它也无法为这些跳转做出计划以忽视其优化,因此这些事件处理程序可能会观察到与原始程序代码中不同顺序执行的指令。

为了应对这些异常情况,Rust 提供了*volatile*内存操作,这些操作不能与其他 volatile 操作进行省略或重新排序。这些操作以`std::ptr::read_volatile`和`std::ptr::write_volatile`的形式出现。Volatile 操作非常适合访问内存映射的硬件资源:它们直接映射到内存访问操作,没有编译器的伎俩,并且 volatile 操作之间不会重新排序的保证确保即使它们通常看起来可以互换(例如加载一个地址并将数据存储到另一个地址),硬件操作也不会发生顺序错乱。无重新排序的保证也有助于异常执行情况,只要任何触及在异常上下文中访问的内存的代码仅使用 volatile 内存操作。

## 防滥用硬件抽象

Rust 的类型系统擅长将不安全、复杂以及其他不愉快的代码封装在安全、符合人体工学的接口背后。没有比在低级系统编程这个著名复杂的领域中更为重要,那里充满了从晦涩的手册中提取出来的硬件定义的神秘值,以及使用神秘的未文档化汇编指令咒语来让设备达到恰到好处的状态。而这一切发生在一个运行时错误可能不仅仅会崩溃用户程序的空间中!

在`no_std`程序中,使用类型系统来使非法状态无法表示是极其重要的,正如我们在第三章中讨论的那样。如果某些寄存器值的组合不能同时发生,那么可以创建一个单一类型,其类型参数表示相关寄存器的当前状态,并仅在其上实现合法的转换,就像我们在 Listing 3-2 中为火箭示例所做的那样。

例如,考虑一对寄存器,在任何给定时刻最多只有一个寄存器应该是“开启”状态。清单 12-2 展示了如何在一个(单线程)程序中以一种方式表示这一点,使得不可能编写违反该不变式的代码。

// raw register address -- private submodule
mod registers;
pub struct On;
pub struct Off;
pub struct Pair<R1, R2>(PhantomData<(R1, R2)>);
impl Pair<Off, Off> {
pub fn get() -> Option {
static mut PAIR_TAKEN: bool = false;
if unsafe { PAIR_TAKEN } {
None
} else {
// Ensure initial state is correct.
registers::off("r1");
registers::off("r2");
unsafe { PAIR_TAKEN = true };
Some(Pair(PhantomData))
}
}

pub fn first_on(self) -> Pair<On, Off> {
    registers::set_on("r1");
    Pair(PhantomData)
}

// .. and inverse for -> Pair<Off, On>
}
impl Pair<On, Off> {
pub fn off(self) -> Pair<Off, Off> {
registers::set_off("r1");
Pair(PhantomData)
}
}
// .. and inverse for Pair<Off, On>


清单 12-2:静态确保正确操作

这段代码中有一些值得注意的模式。第一个是我们通过在唯一构造函数中检查一个私有的静态布尔值,确保`Pair`类的唯一实例只会存在一次,并使所有方法消耗`self`。接着,我们确保初始状态有效,并且只允许有效的状态转换,因此不变式必须在全局范围内保持成立。

清单 12-2 中的第二个值得注意的模式是我们使用`PhantomData`来利用零大小类型,并以静态方式表示运行时信息。也就是说,在代码的任何给定时刻,类型告诉我们运行时状态*必须*是什么,因此我们不需要在运行时跟踪或检查与寄存器相关的任何状态。当我们需要启用`r1`时,不需要检查`r2`是否已经处于开启状态,因为类型已经防止了编写出现这种情况的程序。

## 交叉编译

通常,你会在一台运行完整操作系统并配备现代硬件的计算机上编写`no_std`程序,但最终会在一个只有 93/4 位 RAM、CPU 像袜子一样的简陋硬件设备上运行。这就需要*交叉编译*——你需要在开发环境中编译代码,但需要为袜子编译。这并不是交叉编译唯一重要的场景。例如,现在越来越常见的是,构建流水线生成所有消费者平台的二进制文件,而不是为每个消费者可能使用的平台创建一个构建流水线,这就需要使用交叉编译。

交叉编译涉及两个平台:*宿主*平台和*目标*平台。宿主平台是进行编译的平台,目标平台是最终运行编译输出的平台。我们通过*目标三元组*来指定平台,形式为`machine-vendor-os`。`machine`部分决定了代码将运行的机器架构,如`x86_64`、`armv7`或`wasm32`,并告诉编译器使用哪种指令集来生成机器代码。`vendor`部分通常在 Windows 上为`pc`,在 macOS 和 iOS 上为`apple`,在其他地方为`unknown`,且不会在编译过程中产生有意义的影响;它通常不重要,甚至可以省略。`os`部分告诉编译器最终的二进制文件应使用何种格式,所以`linux`表示 Linux 的*.so*文件,`windows`表示 Windows 的*.dll*文件,依此类推。

要告诉 Cargo 进行交叉编译,你只需传递 `--target <``target triple``>` 参数,指定你选择的三元组。然后,Cargo 会将这个信息转发给 Rust 编译器,以便生成适用于给定目标平台的二进制文件。Cargo 还会确保使用适用于该平台的标准库版本——毕竟,标准库包含了许多条件编译指令(使用 `#[cfg(...)]`),以便调用正确的系统调用并使用适合架构的实现,因此我们不能在目标平台上使用主机平台的标准库。

目标平台还决定了标准库中可用的组件。例如,`x86_64-unknown-linux-gnu` 包含完整的 `std` 库,而像 `thumbv7m-none-eabi` 这样的目标平台没有,并且甚至没有定义分配器,因此如果你在没有显式定义分配器的情况下使用 `alloc`,你将会遇到构建错误。这对于测试你编写的代码是否*确实*不需要 `std` 很有用(记住,即使使用 `#![no_std]`,你仍然可以使用 `use std::`,因为 `no_std` 只是放弃了 `std` 的预导入)。如果你让持续集成管道在 `--target thumbv7m-none-eabi` 的条件下构建你的 crate,那么任何试图访问 `core` 以外组件的行为都会触发构建失败。关键是,这也会检查你的 crate 是否不小心引入了依赖项,而这些依赖项本身使用了 `std`(或 `alloc`)中的项目。

## 总结

在本章中,我们讨论了标准库的底层内容——更准确地说,是 `std` 之下的内容。我们讲解了使用 `core` 可以获得的内容,如何通过 `alloc` 扩展非 `std` 的使用范围,以及 (非常小的) Rust 运行时为你的程序添加了什么,使得 `fn main` 能够工作。我们还探讨了如何与设备映射内存交互,以及如何处理在硬件编程的最低层次可能发生的非传统执行模式,并且如何在 Rust 类型系统中安全地封装硬件的奇特之处。接下来,我们将从非常小的内容转向非常大的内容,讨论如何在 Rust 生态系统中导航、理解,甚至可能为其做出贡献。


# 第十三章:Rust 生态系统

![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/rs-rs/img/chapterart.png)

编程如今很少在真空中进行——几乎每一个你构建的 Rust crate 都可能依赖于*某些*不是你编写的代码。无论这种趋势是好是坏,还是两者兼而有之,都是一个备受争议的话题,但无论如何,它已经成为今天开发者体验的一部分。

在这个勇敢的新互相依赖的世界里,比以往任何时候都更重要的是要对可用的库和工具有一个扎实的掌握,并保持对 Rust 社区最新和最好的成果的了解。本章将专门讨论如何利用、跟踪、理解并为 Rust 生态系统做出贡献。由于这是最后一章,在结尾部分,我还将提供一些额外资源的建议,帮助你继续发展你的 Rust 技能。

## 现有的工具

尽管 Rust 相对年轻,但它已经有了一个庞大的生态系统,以至于很难跟踪所有可用的资源。如果你知道自己需要什么,你可能能够通过搜索找到一组合适的 crate,并通过下载统计信息和对每个 crate 仓库的表面检查来确定哪些可能成为合理的依赖项。然而,还有许多工具、crate 和通用的语言特性,可能是你不一定知道要寻找的,但它们有可能为你节省无数小时和复杂的设计决策。

在这一节中,我将介绍一些多年来我发现有帮助的工具、库和 Rust 特性,希望它们在某些时刻对你也能有所帮助!

### 工具

首先,以下是一些我发现自己经常使用的 Rust 工具,你应该将它们添加到你的工具箱中:

**`cargo-deny`**

1.  提供了一种对你的依赖图进行 lint 检查的方法。在写这篇文章时,你可以使用 `cargo-deny` 仅允许某些许可证,禁止某些 crate 或特定版本,检测已知漏洞的依赖项或使用 Git 来源的依赖项,并检测在依赖图中以不同版本多次出现的 crate。在你阅读本文时,可能会有更多有用的 lint 检查。

**`cargo-expand`**

1.  扩展给定 crate 中的宏,并让你检查输出,这使得发现宏转录器或过程宏中深层次的错误变得更加容易。当你编写自己的宏时,`cargo-expand` 是一款不可或缺的工具。

**`cargo-hack`**

1.  帮助你检查你的 crate 是否与启用的任何功能组合兼容。该工具呈现一个类似于 Cargo 本身的界面(如 `cargo check`、`build` 和 `test`),但它提供了运行给定命令与 crate 功能的所有可能组合(*幂集*)的能力。

**`cargo-llvm-lines`**

1.  分析 Rust 代码到中间表示(IR)的映射,这些 IR 会传递给 Rust 编译器中的部分,用于生成机器代码(LLVM),并告诉你哪些 Rust 代码生成了最大的 IR。这非常有用,因为更大的 IR 意味着更长的编译时间,因此找出生成更大 IR 的 Rust 代码(例如,由于单态化)可以帮助发现缩短编译时间的机会。

**`cargo-outdated`**

1.  检查你的任何依赖项(无论是直接的还是传递的)是否有新版本可用。关键是,不像`cargo update`,它甚至会告诉你有关新主版本的信息,因此它是检查你是否错过了由于过时的主版本指定而导致的新版本的重要工具。只需记住,提升依赖项的主版本可能会对你的 crate 产生破坏性影响,特别是如果你在接口中暴露了该依赖项的类型!

**`cargo-udeps`**

1.  识别你在*Cargo.toml*中列出的、实际上从未使用过的任何依赖项。也许你曾经使用过它们,但它们现在已经变得多余,或者它们应该被移动到`dev-dependencies`;无论是哪种情况,这个工具都能帮助你削减依赖闭包中的冗余部分。

虽然它们不是专门用于开发 Rust 的工具,我也强烈推荐`fd`和`ripgrep`——它们是`find`和`grep`的优秀替代品,而且它们本身也是用 Rust 编写的。我每天都在使用这两个工具。

### 库

接下来是一些有用但鲜为人知的 crate,我经常用它们,并且我猜我会在很长一段时间内继续依赖它们:

**`bytes`**

1.  提供了一种高效的机制,用于在不进行复制或处理生命周期的情况下,传递单个连续内存块的子切片。这在低级网络代码中非常有用,因为你可能需要对一块字节进行多个视图,而复制操作是不可取的。

**`criterion`**

1.  一种基于统计学的基准测试库,它利用数学方法消除基准测量中的噪音,并可靠地检测随时间变化的性能变化。如果你在你的 crate 中包含了微基准测试,你几乎可以肯定需要使用它。

**`cxx`**

1.  提供了一个安全且符合人体工程学的机制,用于从 Rust 调用 C++代码,反之亦然。如果你愿意投入一些时间提前更彻底地声明你的接口,以换取更好的跨语言兼容性,那么这个库非常值得关注。

**`flume`**

1.  实现了一个多生产者、多消费者的通道,比 Rust 标准库中包含的更快速、更灵活、更简单。它还支持异步和同步操作,因此它是连接这两个世界的一个很好的桥梁。

**`hdrhistogram`**

1.  一个 Rust 版本的高动态范围(HDR)直方图数据结构,它提供了跨越广泛值范围的直方图的紧凑表示。任何当前跟踪平均值或最小/最大值的地方,你很可能应该改用 HDR 直方图,它能为你提供更好的指标分布洞察。

**`heapless`**

1.  提供不使用堆的数据结构。`heapless`的数据结构都由静态内存支持,这使得它们非常适合嵌入式环境或其他不希望进行内存分配的场景。

**`itertools`**

1.  扩展了标准库中的`Iterator`特性,提供了许多新的便捷方法用于去重、分组和计算幂集。这些扩展方法可以显著减少代码中的样板代码,例如在序列上手动实现某些常见算法时,如同时查找最小值和最大值(`Itertools::minmax`),或使用像检查迭代器是否恰好包含一个项这样的常见模式(`Itertools::exactly_one`)。

**`nix`**

1.  提供类 Unix 系统上的系统调用的惯用绑定,与直接使用像`libc`这样的 C 兼容 FFI 类型相比,能带来更好的体验。

**`pin-project`**

1.  提供了强制执行引脚安全不变量的宏,适用于注解类型,这反过来为这些类型提供了一个安全的引脚接口。这使你可以避免大部分自己实现`Pin`和`Unpin`时的麻烦。另外还有`pin-project-lite`,它避免了(当前)对过程宏机制的相对沉重依赖,但代价是稍微降低了易用性。

**`ring`**

1.  从 C 语言编写的加密库 BoringSSL 中提取出精华,并通过一个快速、简单且难以滥用的接口将其带入 Rust。如果你需要在自己的 crate 中使用加密,这是一个很好的起点。你可能已经在`rustls`库中遇到过它,该库使用`ring`提供现代、安全默认的 TLS 栈。

**`slab`**

1.  实现了一个高效的数据结构,用于替代`HashMap<Token, T>`,其中`Token`是一个仅用于区分映射中的条目的不透明类型。在资源管理中经常使用这种模式,其中当前资源的集合必须集中管理,但单个资源也必须能够以某种方式访问。

**`static_assertions`**

1.  提供静态断言——也就是说,在编译时进行评估的断言,因此可能会在编译时失败。你可以使用它来断言某个类型实现了给定的特性(例如`Send`)或具有给定的大小。我强烈推荐在那些保证可能非常重要的代码中加入这种类型的断言。

**`structopt`**

1.  包装了著名的参数解析库 `clap`,并提供了一种方式,使用 Rust 类型系统(加上宏注解)来描述应用程序的命令行接口。当你解析应用程序的参数时,你会得到你定义的类型的值,从而获得所有类型检查的好处,比如穷尽匹配和 IDE 自动完成。

**`thiserror`**

1.  使得编写自定义枚举错误类型(比如我们在第四章讨论的那种)变得轻松愉快。它会处理实现推荐的特性并遵循既定的惯例,而你只需要定义那些对你的应用程序独特且至关重要的部分。

**`tower`**

1.  实质上,它将函数签名 `async fn(Request) -> Response` 进行封装并在其上实现了一个完整的生态系统。其核心是 `Service` 特性,代表一种类型,可以将请求转换为响应(我怀疑它有一天可能会进入标准库)。这是一个很好的抽象,可以用来构建任何类似服务的东西。

**`tracing`**

1.  提供了高效追踪应用程序执行所需的所有基础设施。至关重要的是,它对你追踪的事件类型以及你想对这些事件做什么保持中立。这个库可以用于日志记录、度量收集、调试、性能分析,当然也包括追踪,所有这些都可以用相同的机制和接口来实现。

### Rust 工具

Rust 工具链有一些你可能不知道的特性,这些特性通常适用于非常具体的使用场景,但如果它们适合你的需求,它们可以成为救命稻草!

#### Rustup

Rustup,Rust 工具链安装器,工作非常高效,以至于它通常会消失在背景中并被遗忘。你偶尔会用它来更新工具链、设置目录覆盖或安装组件,除此之外基本不太使用。然而,Rustup 支持一个非常实用的小技巧,值得了解:工具链覆盖简写。你可以将 `+toolchain` 作为第一个参数传递给任何由 Rustup 管理的二进制文件,二进制文件会按你设置的工具链覆盖运行该命令,并在运行完后重置覆盖为之前的状态。所以,`cargo +nightly miri` 会使用 nightly 工具链运行 Miri,而 `cargo +1.53.0 check` 会检查代码是否能用 Rust 1.53.0 编译。后者在检查你是否破坏了最低支持的 Rust 版本协议时特别有用。

Rustup 还有一个非常方便的子命令 `doc`,它会在浏览器中打开当前 Rust 编译器版本的 Rust 标准库文档本地副本。如果你在没有网络连接的情况下进行开发,这个功能非常有价值!

#### Cargo

Cargo 还提供了一些不太容易发现的实用功能。其中第一个是 `cargo tree`,这是一个内置于 Cargo 自身的子命令,用于检查 crate 的依赖关系图。这个命令的主要功能是将依赖关系图以树状结构打印出来。这个功能本身就很有用,但 `cargo tree` 真正的亮点是 `--invert` 选项:它接受一个 crate 标识符,并生成一个反转的树,显示从当前 crate 开始的所有依赖路径,找到该依赖项。例如,`cargo tree -i rand` 会列出当前 crate 如何依赖任何版本的 `rand`,包括通过传递依赖的方式。如果你想删除某个依赖项,或者某个依赖项的特定版本,并想知道为什么它仍然被拉入依赖,这个功能非常有用。你还可以使用 `-e features` 选项,包含有关为什么启用目标 crate 的每个 Cargo 特性的详细信息。

说到 Cargo 子命令,写自己的子命令其实非常简单,无论是为了与他人分享,还是仅仅为了本地开发。当 Cargo 被调用时,如果遇到一个它无法识别的子命令,它会检查是否存在一个名为 `cargo-$subcommand` 的程序。如果存在,Cargo 会调用该程序并传递任何命令行参数——例如,`cargo foo bar` 会调用 `cargo-foo` 并传递参数 `bar`。Cargo 甚至会将这个命令与 `cargo help` 集成,将 `cargo help foo` 转换为对 `cargo-foo --help` 的调用。

随着你参与更多 Rust 项目,你可能会注意到 Cargo(以及 Rust 更广泛的情况)在磁盘空间方面并不宽容。每个项目都会有自己的目标目录,用于存放其编译结果,随着时间的推移,你会积累多个相同的已编译文件副本,尤其是对于常见的依赖。为每个项目保持独立的编译产物是一个合理的选择,因为它们在不同项目之间不一定兼容(比如,一个项目使用的编译器标志可能和另一个项目不同)。但是在大多数开发环境中,共享构建产物是完全合理的,并且在不同项目之间切换时,可以节省相当多的编译时间。幸运的是,配置 Cargo 以共享构建产物非常简单:只需在你的 *~/.cargo/config.toml* 文件中设置 `[build] target` 为你希望共享的构建产物所在的目录,Cargo 会处理其余的工作。再也不需要目标目录了!只需确保定期清理该目录,并且要知道,`cargo clean` 现在会清除*所有*项目的构建产物。

最后,如果你觉得 Cargo 构建你的 crate 的时间异常长,你可以尝试当前不稳定的 Cargo `-Ztimings`标志。使用该标志运行 Cargo 会输出关于每个 crate 处理所花时间的信息、构建脚本运行时间、哪些 crate 需要等待其他 crate 编译完成的时间,以及大量其他有用的度量信息。这可能会突出一个特别慢的依赖链,你可以着手消除它,或者揭示出一个构建脚本,它从头开始编译一个本地依赖库,你可以改为使用系统库。如果你想深入挖掘,还可以使用`rustc -Ztime-passes`,它会输出关于每个 crate 在编译器内部花费时间的相关信息——不过这些信息可能只有在你打算为编译器本身做贡献时才有用。

#### rustc

Rust 编译器还有一些不太为人所知的功能,对于有创新精神的开发者来说,这些功能可能非常有用。第一个是当前不稳定的`-Zprint-type-sizes`参数,它打印当前 crate 中所有类型的大小。对于除最小的 crate 外,这会产生大量的信息,但当你试图确定调用`memcpy`时意外的时间消耗来源,或者寻找减少内存使用的方法,尤其是当分配大量特定类型的对象时,它非常有价值。`-Zprint-type-sizes`参数还会显示每种类型的计算对齐方式和布局,这可能会引导你发现,比如将一个`usize`类型转换为`u32`可能会对类型在内存中的表示产生重大影响。在调试完某个特定类型的大小、对齐和布局后,我建议你添加静态断言,确保它们不会随着时间的推移而发生回归。你也许会对`variant_size_differences` lint 感兴趣,如果一个 crate 包含大小差异显著的`enum`类型的变体,它会发出警告。

如果你的性能分析样本看起来很奇怪,堆栈帧被重新排序或完全缺失,你也可以尝试`-Cforce-frame-pointers = yes`。帧指针提供了一种更可靠的方式来展开堆栈——在性能分析中,这个操作会被频繁执行——代价是每次函数调用时会使用一个额外的寄存器。即使堆栈展开*应该*在只启用常规调试符号的情况下正常工作(记得在使用发布配置时设置`debug = true`),但这并非总是如此,帧指针可能解决你遇到的任何问题。

### 标准库

Rust 标准库通常被认为比其他编程语言的标准库要小,但它在深度上弥补了广度的不足;你不会在 Rust 的标准库中找到一个 Web 服务器实现或 X.509 证书解析器,但你会找到超过 40 种与 `Option` 类型相关的方法,以及 20 多个特征实现。对于包含的类型,Rust 尽力提供所有相关功能,以显著改善可用性,避免了那些容易出现的冗长模板代码。在本节中,我将介绍一些你可能之前没有遇到过的标准库类型、宏、函数和方法,它们往往能简化或改进(或两者兼而有之)你的代码。

#### 宏和函数

让我们从几个独立的实用工具开始。第一个是 `write!` 宏,它允许你使用格式化字符串写入文件、网络套接字或任何其他实现了 `Write` 的对象。你可能已经熟悉它了——但 `write!` 有一个鲜为人知的特性,那就是它可以同时与 `std::io::Write` 和 `std::fmt::Write` 一起使用,这意味着你可以直接将格式化文本写入 `String` 中。也就是说,你可以写 `use std::fmt::Write; write!(&mut s, "{}+1={}", x, x + 1);` 将格式化的文本附加到 `String s` 中!

`iter::once` 函数接受一个值并生成一个迭代器,该迭代器只会返回该值一次。当调用需要迭代器的函数时,如果你不想分配额外的内存,或者与 `Iterator::chain` 结合使用时,它特别有用,能够将单个元素附加到现有的迭代器上。

我们在第一章简要提到了 `mem::replace`,但值得再提一次,以防你错过了它。这个函数接受对 `T` 的独占引用和一个拥有的 `T`,交换这两者,使得引用对象现在变为拥有的 `T`,并返回先前引用对象的所有权。当你需要在仅有独占引用的情况下获取一个值的所有权时,这个函数非常有用,比如在 `Drop` 的实现中。对于 `T: Default`,还可以参考 `mem::take`。

#### 类型

接下来,让我们来看一些方便的标准库类型。`BufReader` 和 `BufWriter` 类型是进行 I/O 操作时必不可少的,它们会对底层 I/O 资源发出许多小的读写请求。这些类型包装了各自底层的 `Read` 或 `Write`,并实现了 `Read` 和 `Write` 接口,但它们额外对操作进行缓冲,使得许多小的读取操作合并为一次大的读取,许多小的写入操作合并为一次大的写入。这可以显著提高性能,因为你不需要频繁地跨越系统调用边界进入操作系统。

`Cow` 类型,如第三章所述,在你需要对持有的类型或返回的类型有灵活性时非常有用。你很少会将 `Cow` 用作函数参数(回想一下,如果有必要的话,你应该让调用者分配),但作为返回类型时它是无价的,因为它可以精确地表示那些可能会或可能不会分配内存的函数的返回类型。它也非常适合那些既可以作为输入 *也* 可以作为输出的类型,比如类似 RPC 的 API 中的核心类型。假设我们有一个类型 `EntityIdentifier`,如示例 13-1 所示,它用于 RPC 服务接口中。

struct EntityIdentifier {
namespace: String,
name: String,
}


示例 13-1:一个需要分配的组合输入/输出类型的表示

现在假设有两个方法:`get_entity` 以 `EntityIdentifier` 作为参数,`find_by` 根据一些搜索参数返回一个 `EntityIdentifier`。`get_entity` 方法只需要一个引用,因为标识符将(假设)在发送到服务器之前被序列化。但对于 `find_by`,实体将从服务器响应中反序列化,因此必须作为拥有的值来表示。如果我们让 `get_entity` 接受 `&EntityIdentifier`,那就意味着调用者仍然必须分配拥有的 `String` 来调用 `get_entity`,即使接口并不要求这么做,因为它在构造 `EntityIdentifier` 时是必要的!我们可以为 `get_entity` 引入一个单独的类型 `EntityIdenifierRef`,它只持有 `&str` 类型,但那样的话我们就得用两种类型来表示同一件事。`Cow` 来拯救我们!示例 13-2 展示了一个 `EntityIdentifier`,它内部持有 `Cow`。

struct EntityIdentifier<'a> {
namespace: Cow<'a, str>,
name: Cow<'a str>,
}


示例 13-2:一个不需要分配的组合输入/输出类型的表示

使用这种构造,`get_entity` 可以接受任何 `EntityIdentifier<'_>`,这使得调用者仅需使用引用即可调用该方法。而 `find_by` 可以返回 `EntityIdentifier<'static>`,其中所有字段都是 `Cow::Owned`。两个接口共享同一个类型,无需不必要的分配!

`std::sync::Once` 类型是一个同步原语,它允许你在初始化时只运行某段代码一次。这对于 FFI 中的初始化非常有用,尤其是当 FFI 边界另一边的库要求初始化只执行一次时。

`VecDeque`类型是`std::collections`中的一个常被忽视的成员,我发现自己经常用到它——基本上,每当我需要栈或队列时。它的接口类似于`Vec`,而且像`Vec`一样,它在内存中的表示是一个单一的内存块。不同之处在于,`VecDeque`同时跟踪数据的开始和结束位置,这使得从`VecDeque`的*任意*一侧执行推入和弹出操作时能够保持常数时间复杂度,这意味着它可以用作栈、队列,甚至同时作为两者。你需要支付的代价是,值在内存中不再一定是连续的(它们可能已经绕回),这意味着`VecDeque<T>`没有实现`AsRef<[T]>`。

#### 方法

让我们通过快速浏览一些有用的方法来结束这一部分。首先是`Arc::make_mut`,它接受一个`&mut Arc<T>`并返回一个`&mut T`。如果`Arc`是唯一存在的,它会返回`Arc`背后的`T`;否则,它会分配一个新的`Arc<T>`,其中包含`T`的克隆,替换掉当前引用的`Arc`,然后将`&mut`赋给新单例`Arc`中的`T`。

`Clone::clone_from`方法是`.clone()`的另一种形式,它允许你重用要克隆的类型实例,而不是分配一个新的实例。换句话说,如果你已经有了一个`x: T`,你可以执行`x.clone_from(y)`,而不是`x = y.clone()`,这样可能会节省一些内存分配。

`std::fmt::Formatter::debug_*`是实现`Debug`的最简单方法,特别是当`#[derive(Debug)]`无法满足你的需求时,比如如果你只想包括某些字段或暴露一些`Debug`实现未暴露的字段信息。在实现`Debug`的`fmt`方法时,只需在传入的`Formatter`上调用适当的`debug_`方法(例如`debug_struct`或`debug_map`),然后在返回的类型上调用包含的方法来填写类型的详细信息(如使用`field`添加字段,或使用`entries`添加键/值条目),最后调用`finish`。

```` `Instant::elapsed` returns the `Duration` since an `Instant` was created. This is much more concise than the common approach of creating a new `Instant` and subtracting the earlier instance.    `Option::as_deref` takes an `Option<P>` where `P: Deref` and returns `Option<&P::Target>` (there’s also an `as_deref_mut` method). This simple operation can make functional transformation chains that operate on `Option` much cleaner by avoiding the inscrutable `.as_ref().map(|r| &**r)`.    `Ord::clamp` lets you take any type that implements `Ord` and clamp it between two other values of a given range. That is, given a lower limit `min` and an upper limit `max`, `x.clamp(min, max)` returns `min` if `x` is less than `min`, `max` if `x` is greater than `max`, and `x` otherwise.    `Result::transpose` and its counterpart `Option::transpose` invert types that nest `Result` and `Option`. That is, transposing a `Result<Option<T>, E>` gives an `Option<Result<T, E>>`, and vice versa. When combined with `?`, this operation can make for cleaner code when working with `Iterator::next` and similar methods in fallible contexts.    `Vec::swap_remove` is `Vec::remove`’s faster twin. `Vec::remove` preserves the order of the vector, which means that to remove an element in the middle, it must shift all the later elements in the vector down by one. This can be very slow for large vectors. `Vec::swap_remove`, on the other hand, swaps the to-be-removed element with the last element and then truncates the vector’s length by one, which is a constant-time operation. Be aware, though, that it will shuffle your vector around and thus invalidate old indexes!    ## Patterns in the Wild    As you start exploring codebases that aren’t your own, you’ll likely come across a couple of common Rust patterns that we haven’t discussed in the book so far. Knowing about them will make it easier to recognize them, and thus understand their purpose, when you do encounter them. You may even find use for them in your own codebase one day!    ### Index Pointers    Index pointers allow you to store multiple references to data within a data structure without running afoul of the borrow checker. For example, if you want to store a collection of data so that it can be efficiently accessed in more than one way, such as by keeping one `HashMap` keyed by one field and one keyed by a different field, you don’t want to store the underlying data multiple times too. You could use `Arc` or `Rc`, but they use dynamic reference counting that introduces unnecessary overhead, and the extra bookkeeping requires you to store additional bytes per entry. You could use references, but the lifetimes become difficult if not impossible to manage because the data and the references live in the same data structure (it’s a self-referential data structure, as we discussed in Chapter 8). You could use raw pointers combined with `Pin` to ensure the pointers remain valid, but that introduces a lot of complexity as well as unsafety you then need to carefully consider.    Most crates use index pointers—or, as I like to call them, *indeferences*—instead. The idea is simple: store each data entry in some indexable data structure like a `Vec`, and then store just the index in a derived data structure. To then perform an operation, first use the derived data structure to efficiently find the data index, and then use the index to retrieve the referenced data. No lifetimes needed—and you can even have cycles in the derived data representation if you wish!    The `indexmap` crate, which provides a `HashMap` implementation where the iteration order matches the map insertion order, provides a good example of this pattern. The implementation has to store the keys in two places, both in the map of keys to values and in the list of all the keys, but it obviously doesn’t want to keep two copies in case the key type itself is large. So, it uses index pointers. Specifically, it keeps all the key/value pairs in a single `Vec` and then keeps a mapping from key hashes to `Vec` indexes. To iterate over all the elements of the map, it just walks the `Vec`. To look up a given key, it hashes that key, looks that hash up in the mapping, which yields the key’s index in the `Vec` (the index pointer), and then uses that to get the key’s value from the `Vec`.    The `petgraph` crate, which implements graph data structures and algorithms, also uses this pattern. The crate stores one `Vec` of all node values and another of all edge values and then only ever uses the indexes into those `Vec`s to refer to a node or edge. So, for example, the two nodes associated with an edge are stored in that edge simply as two `u32`s, rather than as references or reference-counted values.    The trick lies in how you support deletions. To delete a data entry, you first need to search for its index in all of the derived data structures and remove the corresponding entries, and then you need to remove the data from the root data store. If the root data store is a `Vec`, removing the entry will also change the index of one other data entry (when using `swap_remove`), so you then need to go update all the derived data structures to reflect the new index for the entry that moved.    ### Drop Guards    Drop guards provide a simple but reliable way to ensure that a bit of code runs even in the presence of panics, which is often essential in unsafe code. An example is a function that takes a closure `f: FnOnce` and executes it under mutual exclusion using atomics. Say the function uses `compare_exchange` (discussed in Chapter 10) to set a Boolean from `false` to `true`, calls `f`, and then sets the Boolean back to `false` to end the mutual exclusion. But consider what happens if `f` panics—the function will never get to run its cleanup, and no other call will be able to enter the mutual exclusion section ever again.    It’s possible to work around this using `catch_unwind`, but drop guards provide an alternative that is often more ergonomic. Listing 13-3 shows how, in our current example, we can use a drop guard to ensure the Boolean always gets reset.    ``` fn mutex(lock: &AtomicBool, f: impl FnOnce()) {     // .. while lock.compare_exchange(false, true).is_err() ..     struct DropGuard<'a>(&'a AtomicBool);     impl Drop for DropGuard<'_> {         fn drop(&mut self) {             self.0.store(true, Ordering::Release);         }     }     let _guard = DropGuard(lock);     f(); } ```    Listing 13-3: Using a drop guard to ensure code gets run after an unwinding panic    We introduce the local type `DropGuard` that implements `Drop` and place the cleanup code in its implementation of `Drop::drop`. Any necessary state can be passed in through the fields of `DropGuard`. Then, we construct an instance of the guard type just before we call the function that might panic, which is `f` here. When `f` returns, whether due to a panic or because it returns normally, the guard is dropped, its destructor runs, the lock is released, and all is well.    It’s important that the guard is assigned to a variable that is dropped at the end of the scope, after the user-provided code has been executed. This means that even though we never refer to the guard’s variable again, it needs to be given a name, as `let _ = DropGuard(lock)` would drop the guard immediately—before the user-provided code even runs!    This pattern is frequently used in conjunction with thread locals, when library code may wish to set the thread local state so that it’s valid only for the duration of the execution of the closure, and thus needs to be cleared afterwards. For example, at the time of writing, Tokio uses this pattern to provide information about the executor calling `Future::poll` to leaf resources like `TcpStream` without having to propagate that information through function signatures that are visible to users. It’d be no good if the thread local state continued to indicate that a particular executor thread was active even after `Future::poll` returned due to a panic, so Tokio uses a drop guard to ensure that the thread local state is reset.    ### Extension Traits    Extension traits allow crates to provide additional functionality to types that implement a trait from a different crate. For example, the `itertools` crate provides an extension trait for `Iterator`, which adds a number of convenient shortcuts for common (and not so common) iterator operations. As another example, `tower` provides `ServiceExt`, which adds several more ergonomic operations to wrap the low-level interface in the `Service` trait from `tower-service`.    Extension traits tend to be useful either when you do not control the base trait, as with `Iterator`, or when the base trait lives in a crate of its own so that it rarely sees breaking releases and thus doesn’t cause unnecessary ecosystem splits, as with `Service`.    An extension trait extends the base trait it is an extension of (`trait ServiceExt: Service`) and consists solely of provided methods. It also comes with a blanket implementation for any `T` that implements the base trait (`impl<T> ServiceExt for T where T: Service {}`). Together, these conditions ensure that the extension trait’s methods are available on anything that implements the base trait.    ### Crate Preludes    In Chapter 12, we talked about the standard library prelude that makes a number of types and traits automatically available without you having to write any `use` statements. Along similar lines, crates that export multiple types, traits, or functions that you’ll often use together sometimes define their own prelude in the form of a module called `prelude`, which re-exports some particularly common subset of those types, traits, and functions. There’s nothing magical about that module name, and it doesn’t get used automatically, but it serves as a signal to users that they likely want to add `use` `somecrate``::prelude::*` to files that want to use the crate in question. The `*` is a *glob import* and tells Rust to use all publicly available items from the indicated module. This can save quite a bit of typing when the crate has a lot of items you’ll usually need to name.    Preludes are also great for crates that expose a lot of extension traits, since trait methods can be called only if the trait that defines them is in scope. For example, the `diesel` crate, which provides ergonomic access to relational databases, makes extensive use of extension traits so you can write code like:    ``` posts.filter(published.eq(true)).limit(5).load::<Post>(&connection) ```    This line will work only if all the right traits are in scope, which the prelude takes care of.    In general, you should be careful when adding glob imports to your code, as they can potentially turn additions to the indicated module into backward-incompatible changes. For example, if someone adds a new trait to a module you glob-import from, and that new trait makes a method `foo` available on a type that already had some other `foo` method, code that calls `foo` on that type will no longer compile as the call to `foo` is now ambiguous. Interestingly enough, while the existence of glob imports makes any module addition a technically breaking change, the Rust RFC on API evolution (RFC 1105; see [`rust-lang.github.io/rfcs/1105-api-evolution.html`](https://rust-lang.github.io/rfcs/1105-api-evolution.html)) does *not* require a library to issue a new major version for such a change. The RFC goes into great detail about why, and I recommend you read it, but the gist is that minor releases are allowed to require minimally invasive changes to dependents, like having to add type annotations in edge cases, because otherwise a large fraction of changes would require new major versions despite being very unlikely to actually break any consumers.    Specifically in the case of preludes, using glob imports is usually fine when recommended by the vending crate, since its maintainers know that their users will use glob imports for the prelude module and thus will take that into account when deciding whether a change requires a major version bump.    ## Staying Up to Date    Rust, being such a young language, is evolving rapidly. The language itself, the standard library, the tooling, and the broader ecosystem are all still in their infancy, and new developments happen every day. While staying on top of all the changes would be infeasible, it’s worth your time to keep up with significant developments so that you can take advantage of the latest and greatest features in your projects.    For monitoring improvements to Rust itself, including new language features, standard library additions, and core tooling upgrades, the official Rust blog at [`blog.rust-lang.org/`](https://blog.rust-lang.org/)is a good, low-volume place to start. It mainly features announcements for each new Rust release. I recommend you make a habit of reading these, as they tend to include interesting tidbits that will slowly but surely deepen your knowledge of the language. To dig a little deeper, I highly recommend reading the detailed changelogs for Rust and Cargo as well (links can usually be found near the bottom of each release announcement). The changelogs surface changes that weren’t large enough to warrant a paragraph in the release notes but that may be just what you need two weeks from now. For a less frequently updated news source, check in on *The Edition Guide* at [`doc.rust-lang.org/edition-guide/`](https://doc.rust-lang.org/edition-guide/), which outlines what’s new in each Rust edition. Rust editions tend to be released every three years.    If you’re curious about how Rust itself is developed, you may also want to subscribe to the *Inside Rust* blog at[`blog.rust-lang.org/inside-rust/`](https://blog.rust-lang.org/inside-rust/). It includes updates from the various Rust teams, as well as incident reports, larger change proposals, edition planning information, and the like. To get involved in Rust development yourself—which I highly encourage, as it’s a lot of fun and a great learning experience—you can check out the various Rust working groups at [`www.rust-lang.org/governance/`](https://www.rust-lang.org/governance/), which each focus on improving a specific aspect of Rust. Find one that appeals to you, check in with the group wherever it meets and ask how you may be able to help. You can also join the community discussion about Rust internals over at [`internals.rust-lang.org/`](https://internals.rust-lang.org/); this is another great way to get insight into the thought that goes into every part of Rust’s design and development.    As is the case for most programming languages, much of Rust’s value is derived from its community. Not only do the members of the Rust community constantly develop new work-saving crates and discover new Rust-specific techniques and design patterns, but they also collectively and continuously help one another understand, document, and explain how to take best advantage of the Rust language. Everything I have covered in this book, and much more, has already been discussed by the community in thousands of comment threads, blog posts, and Twitter and Discord conversations. Dipping into these discussions even just once in a while is almost guaranteed to show you new things about a language feature, a technique, or a crate that you didn’t already know.    The Rust community lives in a lot of places, but some good places to start are the Users forum ([`users.rust-lang.org/`](https://users.rust-lang.org/)), the Rust subreddit ([`www.reddit.com/r/rust/`](https://www.reddit.com/r/rust/)), the Rust Community Discord ([`discord.gg/rust-lang-community`](https://discord.gg/rust-lang-community)), and the Rust Twitter account ([`twitter.com/rustlang`](https://twitter.com/rustlang)). You don’t have to engage with all of these, or all of the time—pick one you like the vibe of, and check in occasionally!    A great single location for staying up to date with ongoing developments is the *This Week in Rust* blog ([`this-week-in-rust.org/`](https://this-week-in-rust.org/)), a “weekly summary of [Rust’s] progress and community.” It links to official announcements and changelogs as well as popular community discussions and resources, interesting new crates, opportunities for contributions, upcoming Rust events, and Rust job opportunities. It even lists interesting language RFCs and compiler PRs, so this site truly has it all! Discerning what information is valuable to you and what isn’t may be a little daunting, but even just scrolling through and clicking occasional links that appear interesting is a good way to keep a steady stream of new Rust knowledge trickling into your brain.    ## What Next?    So, you’ve read this book front to back, absorbed all the knowledge it imparts, and are still hungry for more? Great! There are a number of other excellent resources out there for broadening and deepening your knowledge and understanding of Rust, and in this very final section I’ll give you a survey of some of my favorites so that you can keep learning. I’ve divided them into subsections based on how different people prefer to learn so that you can find resources that’ll work for you.    ### Learn by Watching    Watching experienced developers code is essentially a life hack to remedy the slow starting phase of solo learning. It allows you to observe the process of designing and building while utilizing someone else’s experience. Listening to experienced developers articulate their thinking and explain tricky concepts or techniques as they come up can be an excellent alternative to struggling through problems on your own. You’ll also pick up a variety of auxiliary knowledge like debugging techniques, design patterns, and best practices. Eventually you will have to sit down and do things yourself—it’s the only way to check that you actually understand what you’ve observed—but piggybacking on the experience of others will almost certainly make the early stages more pleasant. And if the experience is interactive, that’s even better!    So, with that said, here are some Rust video channels that I recommend:    1.  Perhaps unsurprisingly, my own channel:[`www.youtube.com/c/JonGjengset/`](https://www.youtube.com/c/JonGjengset/). I have a mix of long-form coding videos and short(er) code-based theory/concept explanation videos, as well as occasional videos that dive into interesting Rust coding stories. 2.  The *Awesome Rust Streaming* listing: [`github.com/jamesmunns/awesome-rust-streaming/`](https://github.com/jamesmunns/awesome-rust-streaming/). This resource lists a wide variety of developers who stream Rust coding or other Rust content. 3.  The channel of Tim McNamara, the author of *Rust in Action*: [`www.youtube.com/c/timClicks/`](https://www.youtube.com/c/timClicks/). Tim’s channel, like mine, splits its time between implementation and theory, though Tim has a particular knack for creative visual projects, which makes for fun viewing. 4.  Jonathan Turner’s *Systems with JT* channel: [`www.youtube.com/c/SystemswithJT/`](https://www.youtube.com/c/SystemswithJT/). Jonathan’s videos document their work on Nushell, their take on a “new type of shell,” providing a great sense of what it’s like to work on a nontrivial existing codebase. 5.  Ryan Levick’s channel: [`www.youtube.com/c/RyanLevicksVideos/`](https://www.youtube.com/c/RyanLevicksVideos/). Ryan mainly posts videos that tackle particular Rust concepts and walks through them using concrete code examples, but he also occasionally does implementation videos (like FFI for Microsoft Flight Simulator!) and deep dives into how well-known crates work under the hood.    Given that I make Rust videos, it should come as no surprise that I am a fan of this approach to teaching. But this kind of receptive or interactive learning doesn’t have to come in the form of videos. Another great avenue for learning from experienced developers is pair programming. If you have a colleague or friend with expertise in a particular aspect of Rust you’d like to learn, ask if you can do a pair-programming session with them to solve a problem together!    ### Learn by Doing    Since your ultimate goal is to get better at writing Rust, there’s no substitute for programming experience. No matter what or how many resources you learn from, you need to put that learning into practice. However, finding a good place to start can be tricky, so here I’ll give some suggestions.    Before I dive into the list, I want to provide some general guidance on how to pick projects. First, choose a project that *you* care about, without worrying too much whether others care about it. While there are plenty of popular and established Rust projects out there that would love to have you as a contributor, and it’s fun to be able to say “I contributed to the well-known library X,” your first priority must be your own interest. Without concrete motivation, you’ll quickly lose steam and find contributing to be a chore. The very best targets are projects that you use yourself and have experienced problems with—go fix them! Nothing is more satisfying than getting rid of a long-standing personal nuisance while also contributing back to the community.    Okay, so back to project suggestions. First and foremost, consider contributing to the Rust compiler and its associated tools. It’s a high-quality codebase with good documentation and an endless supply of issues (you probably know of some yourself), and there are several great mentors who can provide outlines for how to approach solving issues. If you look through the issue tracker for issues marked E-easy or E-mentor, you’ll likely find a good candidate quickly. As you gain more experience, you can keep leveling up to contribute to trickier parts.    If that’s not your cup of tea, I recommend finding something you use frequently that’s written in another language and porting it to Rust—not necessarily with the intention of replacing the original library or tool, but just because the experience will allow you to focus on writing Rust without having to spend too much time coming up with all the functionality yourself. If it turns out well, the fact that it already exists suggests that someone else also needed it, so there may be a wider audience for your port too! Data structures and command-line tools often make for great porting subjects, but find a niche that appeals to you.    Should you be more of a “build it from scratch” kind of person, I recommend looking back at your own development experience so far and thinking about similar code you’ve ended up writing in multiple projects (whether in Rust or in other languages). Such repetition tends to be a good signal that something is reusable and could be turned into a library. If nothing comes to mind, David Tolnay maintains a list of smaller utility crates that other Rust developers have requested at [`github.com/dtolnay/request-for-implementation/`](https://github.com/dtolnay/request-for-implementation/) that may provide a source of inspiration. If you’re looking for something more substantial and ambitious, there’s also the Not Yet Awesome list at [`github.com/not-yet-awesome-rust/not-yet-awesome-rust/`](https://github.com/not-yet-awesome-rust/not-yet-awesome-rust/) that lists things that should exist in Rust but don’t (yet).    ### Learn by Reading    Although the state of affairs is constantly improving, finding good Rust reading material beyond the beginner level can still be tricky. Here’s a collection of pointers to some of my favorite resources that continue to teach me new things or serve as good references when I have particularly niche or nuanced questions.    First, I recommend looking through the official virtual Rust books linked from [`www.rust-lang.org/learn/`](https://www.rust-lang.org/learn/). Some, like the Cargo book, are more reference-like while others, like the Embedded book, are more guide-like, but they’re all deep sources of solid technical information about their respective topics. *The Rustonomicon* ([`doc.rust-lang.org/nomicon/`](https://doc.rust-lang.org/nomicon/)), in particular, is a lifesaver when you’re writing unsafe code.    Two more books that are worth checking out are the *Guide to rustc Development* ([`rustc-dev-guide.rust-lang.org/`](https://rustc-dev-guide.rust-lang.org/)) and the *Standard Library Developers Guide* ([`std-dev-guide.rust-lang.org/`](https://std-dev-guide.rust-lang.org/)). These are fantastic resources if you’re curious about how the Rust compiler does what it does or how the standard library is designed, or if you want some pointers before you try your hand at contributing to Rust itself. The official Rust guidelines are also a treasure trove of information; I’ve already mentioned the *Rust API Guidelines* ([`rust-lang.github.io/api-guidelines/`](https://rust-lang.github.io/api-guidelines/)) in the book, but a *Rust Unsafe Code Guidelines Reference* is also available ([`rust-lang.github.io/unsafe-code-guidelines/`](https://rust-lang.github.io/unsafe-code-guidelines/)), and by the time you read this book there may be more.    There are also a number of unofficial virtual Rust books that are enormously valuable collections of experience and knowledge. *The Little Book of Rust Macros* ([`veykril.github.io/tlborm/`](https://veykril.github.io/tlborm/)), for example, is indispensable if you want to write nontrivial declarative macros, and *The Rust Performance Book* ([`nnethercote.github.io/perf-book/`](https://nnethercote.github.io/perf-book/)) is filled with tips and tricks for improving the performance of Rust code both at the micro and the macro level. Other great resources include the *Rust Fuzz Book* ([`rust-fuzz.github.io/book/`](https://rust-fuzz.github.io/book/)), which explores fuzz testing in more detail, and the *Rust Cookbook* ([`rust-lang-nursery.github.io/rust-cookbook/`](https://rust-lang-nursery.github.io/rust-cookbook/)), which suggests idiomatic solutions to common programming tasks. There’s even a resource for finding more books, *The Little Book of Rust Books* ([`lborb.github.io/book/unofficial.html`](https://lborb.github.io/book/unofficial.html))!    If you prefer more hands-on reading, the Tokio project has published *mini-redis* ([`github.com/tokio-rs/mini-redis/`](https://github.com/tokio-rs/mini-redis/)), an incomplete but idiomatic implementation of a Redis client and server that’s extremely well documented and specifically written to serve as a guide to writing asynchronous code. If you’re more of a data structures person, *Learn Rust with Entirely Too Many Linked Lists* ([`rust-unofficial.github.io/too-many-lists/`](https://rust-unofficial.github.io/too-many-lists/)) is an enlightening and fun read that gets into lots of gnarly details about ownership and references. If you’re looking for something closer to the hardware, Philipp Oppermann’s *Writing an OS in Rust* ([`os.phil-opp.com/`](https://os.phil-opp.com/)) goes through the whole operating system stack in great detail while teaching you good Rust patterns in the process. I also highly recommend Amos’s collection of articles ([`fasterthanli.me/tags/rust/`](https://fasterthanli.me/tags/rust/)) if you want a wide sampling of interesting deep dives written in a conversational style.    When you feel more confident in your Rust abilities and need more of a quick reference than a long tutorial, I’ve found the *Rust Language Cheat Sheet* ([`cheats.rs/`](https://cheats.rs/)) great for looking things up quickly. It also provides very nice visual explanations for most topics, so even if you’re looking up something you’re not intimately familiar with already, the explanations are pretty approachable.    And finally, if you want to put all of your Rust understanding to the test, go give David Tolnay’s *Rust Quiz* ([`dtolnay.github.io/rust-quiz/`](https://dtolnay.github.io/rust-quiz/)) a try. There are some real mind-benders in there, but each question comes with a thorough explanation of what’s going on, so even if you get one wrong, you’ll have learned from the experience!    ### Learn by Teaching    My experience has been that the best way to learn something well and thoroughly, by far, is to try to teach it to others. I have learned an enormous amount from writing this book, and I learn new things every time I make a new Rust video or podcast episode. So, I wholeheartedly recommend that you try your hand at teaching others about some of the things you’ve learned from reading this book or that you learn from here on out. It can take whatever form you prefer: in person, writing a blog post, tweeting, making a video or podcast, or giving a talk. The important thing is that you try to convey your newfound knowledge in your own words to someone who doesn’t already understand the topic—in doing so, you also give back to the community so that the next you that comes along has a slightly easier time getting up to speed. Teaching is a humbling and deeply educational experience, and I cannot recommend it highly enough.    ## Summary    In this chapter, we’ve covered Rust beyond what exists in your local workspace. We surveyed useful tools, libraries, and Rust features; looked at how to stay up to date as the ecosystem continues to evolve; and then discussed how you can get your hands dirty and contribute back to the ecosystem yourself. Finally, we discussed where you can go next to continue your Rust journey now that this book has reached its end. And with that, there’s little more to do than to declare:    ``` } ``` ````
posted @ 2025-12-01 09:42  绝不原创的飞龙  阅读(13)  评论(0)    收藏  举报