欢迎

🌏 中文版说明 本书已翻译完成(100%)。访问 100.rust-roof.top 查看中文版。 英文原版请访问 rust-exercises.com。 翻译问题请提交到 GitHub Issues

欢迎来到 "100个Rust练习"

本课程将通过一个个练习,教你Rust的核心概念 (core concepts)。 你将学习Rust的语法 (syntax)、类型系统 (type system)、标准库 (standard library) 和生态系统 (ecosystem)。

我们不假设你有任何Rust的先验知识,但假设你至少了解另一种编程语言 (programming language)。 我们也不假设你有系统编程 (systems programming) 或内存管理 (memory management) 的先验知识。这些主题将在课程中介绍。

换句话说,我们将从零开始! 你将以小而易管理的步骤逐步建立你的Rust知识。 课程结束时,你将完成约100个练习,足以让你在中小型Rust项目中熟练开发。

方法论

本课程基于"做中学"的原则。 它被设计为互动式和实践性的。

Mainmatter 开发了这门课程, 用于在课堂环境中授课,为期4天:每位学员按自己的节奏推进课程, 经验丰富的讲师提供指导,回答问题并根据需要深入探讨主题。 你可以在我们的网站上报名参加下一期辅导课程。 如果你想为你的公司组织私人课程,请联系我们

你也可以自学这门课程,但我们建议你找一个朋友或导师在你遇到困难时帮助你。 你可以在GitHub仓库的solutions分支 中找到所有练习的解决方案。

格式

你可以在浏览器中浏览课程中文材料, 或下载PDF文件离线阅读。 如果你喜欢打印版本,可以在亚马逊购买纸质书

结构

在屏幕左侧,你可以看到课程分为多个章节。 每个章节介绍Rust语言的一个新概念或特性。 为了验证你的理解,每个章节都配有一个需要解决的练习。

你可以在配套的GitHub仓库中找到练习。 开始课程之前,请确保将仓库克隆到本地:

# 如果你已经在GitHub上设置了SSH密钥
git clone git@github.com:mainmatter/100-exercises-to-learn-rust.git
# 否则,使用HTTPS URL:
#   https://github.com/mainmatter/100-exercises-to-learn-rust.git

我们还建议你在一个分支上工作,这样你可以轻松跟踪进度, 并在需要时从主仓库拉取更新:

cd 100-exercises-to-learn-rust
git checkout -b my-solutions

所有练习都位于exercises文件夹中。 每个练习都构建为一个Rust包 (Rust package)。 包中包含练习本身、要做什么的说明(在src/lib.rs中), 以及自动验证你的解决方案的测试套件 (test suite)。

工具

要学习本课程,你需要:

  • Rust。 如果系统上已安装rustup,运行rustup update(或根据你安装Rust的方式使用其他适当的命令)以确保你运行的是最新的稳定版本。
  • (可选但推荐) 支持Rust自动完成的IDE。 我们推荐以下之一:

Workshop runner, wr

为了验证你的解决方案,我们还提供了一个工具来指导你完成课程:wr CLI,即"工作坊运行器 (workshop runner)"的缩写。 按照其网站上的说明安装wr

安装wr后,打开一个新终端并导航到仓库的顶级文件夹。 运行wr命令开始课程:

wr

wr将验证当前练习的解决方案。 在解决当前章节的练习之前,不要进入下一章节。

我们建议在课程进展过程中将你的解决方案提交到Git, 这样你可以轻松跟踪进度,并在需要时从已知点"重新开始"。

享受课程!

作者

本课程由 Luca Palmieri 编写, 他是 Mainmatter 的首席工程咨询顾问。 Luca 自2018年以来一直在使用Rust,最初在TrueLayer,然后在AWS。 Luca 是 "Zero to Production in Rust" 的作者, 这是学习如何在Rust中构建后端应用程序的首选资源。 他还是各种开源Rust项目的作者和维护者,包括 cargo-chefPavexwiremock


📝 翻译说明 中文翻译:[tom] 本翻译版本遵循 CC BY-NC 4.0 许可证,仅供非商业用途使用。 原作者和所有链接均已保留。

原文链接:英文原文

语法 (Syntax)

不要跳过!
请先完成上一部分的练习再开始这一部分。
练习位于课程GitHub仓库中的 exercises/01_intro/00_welcome 目录。
使用wr来开始课程并验证你的解决方案。

前面的任务甚至算不上一个练习,但它已经让你接触到了不少Rust的语法 (syntax)。 我们不会涵盖前面练习中使用的Rust语法的每一个细节。 相反,我们将覆盖_恰好够用_的内容来让我们继续前进,而不会陷入细节中。
一步一步来!

注释 (Comments)

你可以使用 // 来写单行注释 (single-line comments):

// 这是一个单行注释
// 后面跟着另一个单行注释

函数 (Functions)

Rust中的函数 (function) 使用fn关键字 (keyword) 定义,后跟函数名、输入参数 (input parameters) 和返回类型 (return type)。 函数体 (function body) 包含在大括号 (braces) {}中。

在前面的练习中,你看到了greeting函数:

// `fn` <函数名> ( <输入参数> ) -> <返回类型> { <函数体> }
// `fn` <function_name> ( <input_params> ) -> <return_type> { <body> }
fn greeting() -> &'static str {
    // TODO: 修复我 👇
    "I'm ready to __!"
}

greeting函数没有输入参数,并返回一个字符串切片 (string slice) 的引用 (reference) (&'static str)。

返回类型 (Return type)

如果函数不返回任何值(即返回(),Rust的单元类型 (unit type)),可以从签名 (signature) 中省略返回类型。 这就是test_welcome函数的情况:

fn test_welcome() {
    assert_eq!(greeting(), "I'm ready to learn Rust!");
}

上面的代码等价于:

// 显式指明单元返回类型
//                   👇
fn test_welcome() -> () {
    assert_eq!(greeting(), "I'm ready to learn Rust!");
}

返回值 (Returning values)

函数中的最后一个表达式 (expression) 会被隐式返回:

fn greeting() -> &'static str {
    // 这是函数中的最后一个表达式
    // 因此它的值会被`greeting`返回
    "I'm ready to learn Rust!"
}

你也可以使用return关键字来提前返回一个值:

fn greeting() -> &'static str {
    // 注意行末的分号!
    return "I'm ready to learn Rust!";
}

在可能的情况下省略return关键字被认为是符合惯例的 (idiomatic)。

输入参数 (Input parameters)

输入参数在函数名后的圆括号()内声明。
每个参数的声明格式为:参数名,后跟冒号:,然后是其类型。

例如,下面的greet函数接受一个类型为&str("字符串切片 (string slice)")的name参数:

// 一个输入参数
//        👇
fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}

如果有多个输入参数,它们必须用逗号分隔。

类型标注 (Type annotations)

既然我们已经多次提到了"类型 (types)",让我们明确说明:Rust是一种静态类型语言 (statically typed language)
Rust中的每个值都有一个类型,并且该类型必须在编译时(compile-time)被编译器(compiler)所知。

类型是一种静态分析 (static analysis)形式。
你可以将类型看作是编译器 (compiler) 附加到程序中每个值的
标签 (tag)
。根据不同的标签,编译器 (compiler) 可以执行不同的规则——例如,你不能将字符串加到数字上,但你可以将两个数字相加。 如果正确利用,类型可以防止整类运行时错误 (runtime bugs)。


原文链接:英文原文

基础计算器 (Basic Calculator)

在本章中,我们将学习如何将 Rust 用作计算器 (calculator)
这听起来可能没什么,但它将让我们有机会涵盖 Rust 的许多基础知识,例如:

  • 如何定义和调用函数 (functions)
  • 如何声明和使用变量 (variables)
  • 原始类型 (primitive types)(整数 (integers) 和布尔值 (booleans))
  • 算术运算符 (arithmetic operators)(包括溢出 (overflow) 和下溢 (underflow) 行为)
  • 比较运算符 (comparison operators)
  • 控制流 (control flow)
  • 恐慌 (panics)

通过一些练习掌握基础知识将让你对这门语言驾轻就熟。 当我们转向更复杂的主题,如特质 (traits) 和所有权 (ownership) 时,你将能够专注于新概念, 而不会被语法或其他琐碎的细节所困扰。

原文链接:英文原文

类型,第一部分 (Types, part 1)

"语法"章节中,compute 的输入参数类型为 u32
让我们来详细解释一下这_意味着_什么。

基本类型 (Primitive types)

u32 是 Rust 的基本类型 (primitive types) 之一。基本类型是语言中最基础的构建块。 它们内置于语言本身——也就是说,它们不是用其他类型来定义的。

你可以组合这些基本类型来创建更复杂的类型。我们很快就会看到如何做到这一点。

整数 (Integers)

u32 特别地,是一个无符号 32 位整数 (unsigned 32-bit integer)

整数 (integer) 是一个可以不带小数部分书写的数字。例如 1 是一个整数,而 1.2 不是。

有符号 (Signed) vs. 无符号 (Unsigned)

整数可以是有符号 (signed)无符号 (unsigned) 的。
无符号整数只能表示非负数(即 0 或更大的数)。 有符号整数可以表示正数和负数(例如 -112 等)。

u32 中的 u 代表无符号 (unsigned)
有符号整数的等效类型是 i32,其中 i 代表整数 (integer),即可以是正数或负数的任何整数。

位宽 (Bit width)

u32 中的 32 指的是用于在内存中表示数字的位数1
位数越多,能表示的数字范围就越大。

Rust 支持多种位宽的整数:8163264128

使用 32 位,u32 可以表示从 02^32 - 1 的数字(也称为 u32::MAX)。
使用相同数量的位数,有符号整数(i32)可以表示从 -2^312^31 - 1 的数字 (即从 i32::MINi32::MAX)。
i32 的最大值小于 u32 的最大值,因为有一位用于表示数字的符号。查看二进制补码表示法了解更多关于有符号整数在内存中表示方式的详细信息。

总结

结合两个变量(有符号/无符号和位宽),我们得到以下整数类型:

位宽 (Bit width)有符号 (Signed)无符号 (Unsigned)
8 位i8u8
16 位i16u16
32 位i32u32
64 位i64u64
128 位i128u128

字面量 (Literals)

字面量 (literal) 是在源代码中表示固定值的记号。
例如,42 是表示数字四十二的 Rust 字面量。

字面量的类型注解

但是 Rust 中的所有值都有类型,那么... 42 的类型是什么?

Rust 编译器 (compiler) 会尝试根据字面量的使用方式来推断其类型。
如果你不提供任何上下文,编译器会将整数字面量默认为 i32
如果你想使用不同的类型,你可以添加所需的整数类型作为后缀——例如,2u64 就是一个显式类型为 u64 的 2。

字面量中的下划线

你可以使用下划线 _ 来提高大数字的可读性。
例如,1_000_0001000000 相同。

算术运算符 (Arithmetic operators)

Rust 支持以下整数的算术运算符2

  • + 加法 (addition)
  • - 减法 (subtraction)
  • * 乘法 (multiplication)
  • / 除法 (division)
  • % 取余 (remainder)

这些运算符的优先级和结合性与数学中的相同。
你可以使用括号来覆盖默认的优先级。例如 2 * (3 + 4)

⚠️ 警告 (Warning)

除法运算符 / 在用于整数类型时执行整数除法。 也就是说,结果会向零截断。例如,5 / 22,而不是 2.5

无自动类型强制转换 (No automatic type coercion)

正如我们在上一个练习中讨论的,Rust 是一种静态类型语言。
特别是,Rust 对类型强制转换 (type coercion) 非常严格。它不会自动将值从一种类型转换为另一种类型3, 即使转换是无损的。你必须显式地进行转换。

例如,你不能将 u8 值赋给类型为 u32 的变量,即使所有 u8 值都是有效的 u32 值:

let b: u8 = 100;
let a: u32 = b;

它会抛出编译错误:

error[E0308]: mismatched types
  |
3 |     let a: u32 = b;
  |            ---   ^ expected `u32`, found `u8`
  |            |
  |            expected due to this
  |

我们将在本课程的后面部分看到如何在类型之间进行转换。

进一步阅读

1

位 (bit) 是计算机中最小的数据单位。它只能有两个值:01

2

Rust 不允许你定义自定义运算符,但它让你控制内置运算符的行为。 我们将在课程中稍后讨论运算符重载,在我们介绍了特质 (trait) 之后。

3

这个规则有一些例外,主要与引用、智能指针和人体工程学有关。我们将在稍后介绍。 "所有转换都是显式的"这个心理模型在现阶段会对你很有帮助。

原文链接:英文原文

变量 (Variables)

在 Rust 中,你可以使用 let 关键字来声明变量 (variables)
例如:

let x = 42;

上面我们定义了一个变量 (variable) x 并给它赋值为 42

类型 (Type)

Rust 中的每个变量都必须有一个类型。这个类型可以由编译器 (compiler) 推断,也可以由开发者显式指定。

显式类型注解 (Explicit type annotation)

你可以通过在变量名后添加冒号 : 和类型来指定变量类型。例如:

// let <变量名>: <类型> = <表达式>;
let x: u32 = 42;

在上面的例子中,我们显式地将 x 的类型约束为 u32

类型推断 (Type inference)

如果我们不指定变量的类型,编译器 (compiler) 会根据变量的使用上下文来尝试推断它。

let x = 42;
let y: u32 = x;

在上面的例子中,我们没有指定 x 的类型。
x 后来被赋值给 y,而 y 被显式地指定为 u32 类型。由于 Rust 不会执行自动类型强制转换 (automatic type coercion),编译器 (compiler) 推断 x 的类型为 u32——与 y 相同的类型,这是唯一能让程序无错误编译的类型。

推断的局限性 (Inference limitations)

编译器 (compiler) 有时需要根据变量的使用情况来帮助推断正确的变量类型。
在这些情况下,你会得到一个编译错误,编译器会要求你提供一个显式的类型提示来解决歧义。

函数参数也是变量 (Function arguments are variables)

并非所有的英雄都穿披风,也并非所有的变量都用 let 声明。
函数参数也是变量!

fn add_one(x: u32) -> u32 {
    x + 1
}

在上面的例子中,x 是一个 u32 类型的变量。
x 与用 let 声明的变量之间唯一的区别是,函数参数必须显式声明其类型。编译器 (compiler) 不会为你推断它。
这个约束让 Rust 编译器 (compiler)(还有我们人类!)能够在不查看函数实现的情况下理解函数的签名 (function signature)。这对编译速度是一个巨大的提升1

初始化 (Initialization)

你不需要在声明变量时就初始化它。
例如

let x: u32;

是一个有效的变量声明。
然而,你必须在使用变量之前初始化它。如果你没有这么做,编译器 (compiler) 会抛出错误:

let x: u32;
let y = x + 1;

会抛出一个编译错误:

error[E0381]: used binding `x` isn't initialized
 --> src/main.rs:3:9
  |
2 | let x: u32;
  |     - binding declared here but left uninitialized
3 | let y = x + 1;
  |         ^ `x` used here but it isn't initialized
  |
help: consider assigning a value
  |
2 | let x: u32 = 0;
  |            +++
1

当涉及到编译速度时,Rust 编译器 (compiler) 需要所有能得到的帮助。

原文链接:英文原文

控制流,第一部分 (Control flow, part 1)

我们到目前为止编写的程序都非常简单。
指令序列从上到下执行,仅此而已。

是时候引入一些分支 (branching) 了。

if 语句

if 关键字用于仅在条件为真时执行代码块。

下面是一个简单的例子:

let number = 3;
if number < 5 {
    println!("`number` 小于 5");
}

这个程序会打印 `number` 小于 5,因为条件 number < 5 为真。

else 语句

与大多数编程语言一样,Rust 支持可选的 else 分支,当 if 表达式中的条件为假时执行代码块。
例如:

let number = 3;

if number < 5 {
    println!("`number` 小于 5");
} else {
    println!("`number` 大于或等于 5");
}

else if 语句

当你有多个 if 表达式,一个嵌套在另一个里面时,你的代码会越来越向右偏移。

let number = 3;

if number < 5 {
    println!("`number` 小于 5");
} else {
    if number >= 3 {
        println!("`number` 大于或等于 3,但小于 5");
    } else {
        println!("`number` 小于 3");
    }
}

你可以使用 else if 关键字将多个 if 表达式组合成一个:

let number = 3;

if number < 5 {
    println!("`number` 小于 5");
} else if number >= 3 {
    println!("`number` 大于或等于 3,但小于 5");
} else {
    println!("`number` 小于 3");
}

布尔值 (Booleans)

if 表达式中的条件必须是 bool 类型,即布尔值 (boolean)
布尔值与整数一样,是 Rust 中的基本类型。

布尔值可以有两个值之一:truefalse

没有真值或假值 (No truthy or falsy values)

如果 if 表达式中的条件不是布尔值,你会得到一个编译错误 (compilation error)。

例如,下面的代码将无法编译:

let number = 3;
if number {
    println!("`number` 不为零");
}

你会得到以下编译错误:

error[E0308]: 类型不匹配
 --> src/main.rs:3:8
  |
3 |     if number {
  |        ^^^^^^ 期望 `bool`,找到整数

这遵循了 Rust 关于类型强制的理念:没有从非布尔类型到布尔类型的自动转换。
Rust 没有像 JavaScript 或 Python 那样的真值 (truthy)假值 (falsy) 概念。
你必须明确指定要检查的条件。

比较运算符 (Comparison operators)

使用比较运算符为 if 表达式构建条件是很常见的。
以下是 Rust 中处理整数时可用的比较运算符:

  • ==:等于
  • !=:不等于
  • <:小于
  • >:大于
  • <=:小于或等于
  • >=:大于或等于

if/else 是表达式 (expressions)

在 Rust 中,if 表达式是表达式 (expression),而不是语句:它们返回一个值。
这个值可以赋给变量或在其他表达式中使用。例如:

let number = 3;
let message = if number < 5 {
    "小于 5"
} else {
    "大于或等于 5"
};

在上面的例子中,if 的每个分支都评估为一个字符串字面量 (string literal), 然后将其赋值给 message 变量。
唯一的要求是两个 if 分支必须返回相同的类型。

原文链接:英文原文

恐慌 (Panics)

让我们回到你为"变量"章节编写的 speed 函数。 它可能看起来像这样:

fn speed(start: u32, end: u32, time_elapsed: u32) -> u32 {
    let distance = end - start;
    distance / time_elapsed
}

如果你有敏锐的眼光,你可能已经发现了一个问题1:如果 time_elapsed 为零会发生什么?

你可以在Rust playground上尝试一下!
程序将退出并显示以下错误信息:

thread 'main' panicked at src/main.rs:3:5:
attempt to divide by zero

这就是所谓的恐慌 (panic)
恐慌是 Rust 用来表示出现问题严重到程序无法继续执行的方式,它是一个不可恢复的错误 (unrecoverable error)2。除零操作就被归类为这样的错误。

panic! 宏 (Macro)

你可以通过调用 panic!3来有意触发恐慌:

fn main() {
    panic!("这是一个恐慌!");
    // 下面这行永远不会被执行
    let x = 1 + 2;
}

在 Rust 中有其他机制来处理可恢复的错误,我们稍后会介绍。 目前我们将坚持使用恐慌作为一种残酷但简单的权宜之计。

进一步阅读

1

speed 函数还有另一个问题,我们很快就会解决。你能发现它吗?

2

你可以尝试捕获恐慌,但这应该是为非常特定情况保留的最后手段。

3

如果后面跟着 !,那就是宏调用。现在可以把宏看作是"辣味函数"。我们会在课程后面更详细地介绍它们。

原文链接:英文原文

阶乘 (Factorial)

到目前为止,你已经学习了:

  • 如何定义函数 (function)
  • 如何调用函数
  • Rust 中可用的整数类型
  • 整数可用的算术运算符
  • 如何通过比较和 if/else 表达式执行条件逻辑

看起来你已经准备好处理阶乘了!

原文链接:英文原文

循环,第一部分:while (Loops, part 1)

你之前对 factorial 的实现被迫使用了递归 (recursion)。
如果你来自函数式编程 (functional programming) 背景,这可能让你感觉很自然。 但如果你习惯了 C 或 Python 这类更偏命令式 (imperative) 的语言,可能会觉得有些奇怪。

让我们看看如何用循环 (loop) 来实现相同的功能。

while 循环

while 循环是一种只要条件 (condition) 为真就持续执行代码块的方式。
通用语法如下:

while <condition> {
    // 要执行的代码
}

例如,我们可能想要对 1 到 5 之间的数字求和:

let sum = 0;
let i = 1;
// "当 i 小于或等于 5 时"
while i <= 5 {
    // `+=` 是 `sum = sum + i` 的简写
    sum += i;
    i += 1;
}

这段代码会不断地给 i 加 1,并把 i 加到 sum 上,直到 i 不再小于或等于 5。

mut 关键字

上面的例子按原样是无法编译的。你会得到类似这样的错误:

error[E0384]: cannot assign twice to immutable variable `sum`
 --> src/main.rs:7:9
  |
2 |     let sum = 0;
  |         ---
  |         |
  |         first assignment to `sum`
  |         help: consider making this binding mutable: `mut sum`
...
7 |         sum += i;
  |         ^^^^^^^^ cannot assign twice to immutable variable

error[E0384]: cannot assign twice to immutable variable `i`
 --> src/main.rs:8:9
  |
3 |     let i = 1;
  |         -
  |         |
  |         first assignment to `i`
  |         help: consider making this binding mutable: `mut i`
...
8 |         i += 1;
  |         ^^^^^^ cannot assign twice to immutable variable

这是因为 Rust 中的变量默认是不可变的 (immutable)
一旦赋值后,你就不能再改变它们的值。

如果你希望允许修改,必须使用 mut 关键字将变量声明为可变的 (mutable)

// `sum` 和 `i` 现在是可变的了!
let mut sum = 0;
let mut i = 1;

while i <= 5 {
    sum += i;
    i += 1;
}

这样就可以正常编译并运行了。

进一步阅读

原文链接:英文原文

循环,第二部分:for (Loops, part 2)

手动递增一个计数器变量有点繁琐。这种模式还非常常见!
为了让事情更简单,Rust 提供了一种更简洁的方式来遍历一个值序列:for 循环。

for 循环

for 循环是一种为迭代器 (iterator)1 中的每个元素执行代码块的方式。

通用语法如下:

for <element> in <iterator> {
    // 要执行的代码
}

范围 (Ranges)

Rust 标准库 (standard library) 提供了范围 (range) 类型,可以用来遍历一个数字序列2

例如,如果我们想对 1 到 5 之间的数字求和:

let mut sum = 0;
for i in 1..=5 {
    sum += i;
}

每次循环运行时,i 都会在执行代码块之前被赋值为范围 (range) 中的下一个值。

Rust 中有五种范围 (range):

  • 1..5:一个(半开)范围。它包含从 1 到 4 的所有数字,不包含最后一个值 5。
  • 1..=5:一个闭合范围 (inclusive range)。它包含从 1 到 5 的所有数字,包括最后一个值 5。
  • 1..:一个开放式范围 (open-ended range)。它包含从 1 到无穷大的所有数字(实际上是到该整数类型的最大值)。
  • ..5:一个从该整数类型的最小值开始、到 4 结束的范围。它不包含最后一个值 5。
  • ..=5:一个从该整数类型的最小值开始、到 5 结束的范围。它包含最后一个值 5。

你可以在 for 循环中使用前三种范围 (range),因为它们显式指定了起始点。后两种范围 (range) 用于其他场合,我们之后会介绍。

范围 (range) 的端点不一定要是整数字面量 (integer literal)——它们也可以是变量或表达式 (expression)!

例如:

let end = 5;
let mut sum = 0;

for i in 1..(end + 1) {
    sum += i;
}

进一步阅读

1

在课程的后面部分,我们会精确地定义什么算作"迭代器 (iterator)"。 现在,把它当作一个你可以遍历的值序列即可。

2

你也可以将范围 (range) 用于其他类型(例如字符和 IP 地址), 但在日常的 Rust 编程中,整数无疑是最常见的情况。

原文链接:英文原文

溢出 (Overflow)

阶乘 (factorial) 的增长相当快。
例如,20 的阶乘是 2,432,902,008,176,640,000。这已经比 32 位整数 (integer) 的最大值 2,147,483,647 还要大了。

当一个算术运算 (arithmetic operation) 的结果大于给定整数类型 (integer type) 的最大值时,我们称之为整数溢出 (integer overflow)

整数溢出 (integer overflow) 是一个问题,因为它违背了算术运算的契约 (contract)。
对两个给定类型的整数进行算术运算的结果应该是同一类型的另一个整数。 但_数学上正确的结果_无法装入该整数类型!

如果结果小于给定整数类型的最小值,我们将这种情况称为整数下溢 (integer underflow)
为了简洁,本节后续我们只讨论整数溢出 (integer overflow),但请记住, 我们所说的一切同样适用于整数下溢 (integer underflow)。

你在"变量"章节中编写的 speed 函数对某些输入组合就会发生下溢 (underflow)。 例如,如果 end 小于 startend - start 会让 u32 类型下溢,因为结果本应是负数, 但 u32 无法表示负数。

没有自动提升 (No automatic promotion)

一种可能的方法是自动将结果提升到更大的整数类型 (integer type)。 例如,如果你在两个 u8 整数相加,结果是 256(即 u8::MAX + 1),Rust 可以选择将结果解释为 u16,这是下一个能容纳 256 的整数类型。

但正如我们之前讨论过的,Rust 在类型转换 (type conversion) 上相当严格。自动整数提升 (automatic integer promotion) 并不是 Rust 解决整数溢出问题的方案。

替代方案 (Alternatives)

既然排除了自动提升,那么发生整数溢出 (integer overflow) 时我们能做什么?
归结起来有两种不同的方法:

  • 拒绝该操作
  • 给出一个能装入预期整数类型 (integer type) 的"合理"结果

拒绝该操作

这是最保守的方法:当发生整数溢出 (integer overflow) 时,我们停止程序。
这是通过恐慌 (panic) 来完成的,我们已经在“恐慌”章节中见过这个机制。

给出"合理"的结果

当一个算术运算 (arithmetic operation) 的结果大于给定整数类型 (integer type) 的最大值时, 你可以选择回绕 (wrap around)
如果你把给定整数类型 (integer type) 的所有可能值想象成一个圆圈,回绕 (wrap around) 的意思就是当你 到达最大值时,会从最小值重新开始。

例如,如果你对 1 和 255(即 u8::MAX)执行回绕加法 (wrapping addition),结果是 0(即 u8::MIN)。 对于有符号整数 (signed integer),原理也是一样。例如,对 127(即 i8::MAX)加 1 进行回绕 (wrap around) 会得到 -128(即 i8::MIN)。

overflow-checks

Rust 让你(开发者)来选择整数溢出 (integer overflow) 发生时使用哪种方法。 这种行为由 overflow-checks 配置项 (profile setting) 控制。

如果 overflow-checks 设置为 true,Rust 会在整数运算溢出时在运行时恐慌 (panic at runtime)。 如果 overflow-checks 设置为 false,Rust 会在整数运算溢出时回绕 (wrap around)

你可能想知道——什么是配置项 (profile setting)?让我们来看看!

配置 (Profiles)

配置 (profile) 是一组配置选项,可以 用来定制 Rust 代码的编译方式。

Cargo 提供了 4 个内置配置 (profile):devreleasetestbench
每次你运行 cargo buildcargo runcargo test 时都会使用 dev 配置 (profile)。它面向本地 开发,因此牺牲了运行时性能,以换取更快的编译时间和更好的调试体验。
release 配置 (profile) 则针对运行时性能进行优化,但会带来更长的编译时间。你需要 通过 --release 标志显式请求——例如 cargo build --releasecargo run --releasetest 配置 (profile) 是 cargo test 默认使用的配置。test 配置继承自 dev 配置的设置。 bench 配置 (profile) 是 cargo bench 默认使用的配置。bench 配置继承自 release 配置。 使用 dev 进行迭代开发和调试,release 用于优化的生产构建,
test 用于正确性测试,bench 用于性能基准测试。

"你以 release 模式构建你的项目了吗?"在 Rust 社区里几乎成了一句梗。
它指的是那些不熟悉 Rust 的开发者,在没有意识到自己没有以 release 模式构建项目之前, 就在社交媒体(例如 Reddit、Twitter)上抱怨 Rust 性能差。

你也可以定义自定义配置 (profile) 或定制内置的配置。

overflow-check

默认情况下,overflow-checks 设置为:

  • dev 配置 (profile) 为 true
  • release 配置 (profile) 为 false

这与两个配置 (profile) 的目标一致。
dev 面向本地开发,所以它会恐慌 (panic) 以便尽早暴露潜在问题。
release 针对运行时性能进行了调优:检查溢出会拖慢程序,所以它 更倾向于回绕 (wrap around)。

同时,两个配置 (profile) 行为不同也可能导致难以察觉的 bug。
我们的建议是在两个配置 (profile) 中都启用 overflow-checks:宁可崩溃,也不要静默地产生 错误结果。在大多数情况下,运行时性能的影响微乎其微;如果你正在开发性能关键的 应用,可以通过基准测试 (benchmark) 来决定是否能承受这种开销。

进一步阅读

原文链接:英文原文

按情况处理的行为 (Case-by-case behavior)

overflow-checks 是一个粗粒度的工具:它是一个全局设置,会影响整个程序。
然而经常会有这样的情况:你希望根据上下文以不同方式处理整数溢出 (integer overflow)——有时 回绕 (wrapping) 才是正确的选择,有时恐慌 (panic) 反而更合适。

wrapping_ 方法

你可以通过使用 wrapping_ 方法1 来逐操作地选择回绕算术 (wrapping arithmetic)。
例如,你可以使用 wrapping_add 对两个整数进行回绕加法:

let x = 255u8;
let y = 1u8;
let sum = x.wrapping_add(y);
assert_eq!(sum, 0);

saturating_ 方法

或者,你可以通过使用 saturating_ 方法选择饱和算术 (saturating arithmetic)
饱和算术 (saturating arithmetic) 不会回绕 (wrap around),而是会返回该整数类型的最大值或最小值。 例如:

let x = 255u8;
let y = 1u8;
let sum = x.saturating_add(y);
assert_eq!(sum, 255);

由于 255 + 1256,比 u8::MAX 大,所以结果是 u8::MAX(255)。
下溢 (underflow) 时则相反:0 - 1-1,比 u8::MIN 小,所以结果是 u8::MIN(0)。

你无法通过 overflow-checks 配置项 (profile setting) 来获得饱和算术 (saturating arithmetic)—— 你必须在执行算术运算时显式选择使用它。

1

你可以把方法 (method) 看作是"附加"在特定类型上的函数。 我们将在下一章中介绍方法 (method)(以及如何定义它们)。

原文链接:英文原文

类型转换,第一部分 (Conversions, pt. 1)

我们一遍又一遍地强调过,Rust 不会对整数 (integer) 执行 隐式的类型转换 (implicit type conversion)。
那么如何执行_显式_的类型转换呢?

as

你可以使用 as 运算符在不同的整数类型 (integer type) 之间进行转换。
as 转换是绝对成功的 (infallible)

例如:

let a: u32 = 10;

// 把 `a` 转换为 `u64` 类型
let b = a as u64;

// 如果编译器能正确推断出目标类型,
// 你也可以使用 `_` 作为目标类型。
// 例如:
let c: u64 = a as _;

这个转换的语义符合你的预期:所有的 u32 值都是合法的 u64 值。

截断 (Truncation)

如果反向转换,事情就有趣了:

// 一个无法装入 `u8` 的
// 数字
let a: u16 = 255 + 1;
let b = a as u8;

这个程序运行时不会有任何问题,因为 as 转换是绝对成功的 (infallible)。 但 b 的值是什么? 当从一个较大的整数类型 (integer type) 转到较小的整数类型时,Rust 编译器 (compiler) 会执行 截断 (truncation)

为了理解发生了什么,让我们先看看 256u16 在内存中 是如何以位序列表示的:

 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0
|               |               |
+---------------+---------------+
   前 8 位          后 8 位

转换为 u8 时,Rust 编译器 (compiler) 会保留 u16 内存表示的后 8 位:

 0 0 0 0 0 0 0 0 
|               |
+---------------+
     后 8 位

因此 256 as u8 等于 0。在大多数情况下,这……并不理想。
事实上,如果 Rust 编译器 (compiler) 看到你试图 对一个会导致截断 (truncation) 的字面量值进行类型转换,它会主动尝试阻止你:

error: literal out of range for `i8`
  |
4 |     let a = 255 as i8;
  |             ^^^
  |
  = note: the literal `255` does not fit into the type `i8` 
          whose range is `-128..=127`
  = help: consider using the type `u8` instead
  = note: `#[deny(overflowing_literals)]` on by default

建议 (Recommendation)

作为一条经验法则,使用 as 转换时要相当小心。
_只_在从较小类型转换到较大类型时使用它。 要从较大整数类型转换到较小整数类型,请依赖 我们将在课程后面探讨的_可失败_转换机制 (fallible conversion machinery)

局限性 (Limitations)

行为出人意料并不是 as 转换的唯一缺点。 它的适用范围也相当有限:你只能对原始类型 (primitive type) 和少数其他特殊情况 依赖 as 转换。
处理复合类型 (composite type) 时,你必须依赖 不同的转换机制(可失败的绝对成功的),我们将在后面探讨。

进一步阅读

  • 查看 Rust 官方参考手册 以了解 as 转换在每种源/目标类型组合下的精确行为, 以及允许的转换的详尽列表。

原文链接:英文原文

工单建模 (Modelling a Ticket)

第一章应该已经让你对 Rust 的一些原始类型 (primitive type)、运算符 (operator) 和基本控制流结构有了不错的掌握。
本章我们要更进一步,讲解 Rust 真正与众不同的特性:所有权 (ownership)
所有权 (ownership) 让 Rust 既能做到内存安全 (memory-safe),又能保持高性能,且无需垃圾回收器 (garbage collector)。

作为贯穿全章的示例,我们会使用一个(类似 JIRA 的)工单 (ticket)——你在软件项目中用来跟踪 bug、特性或任务的那种东西。
我们会尝试用 Rust 来对它建模。这是第一次迭代——到本章结束时,模型既不会完美,也不会非常符合习惯用法 (idiomatic)。但已经足够有挑战性了!
为了向前推进,你需要掌握若干新的 Rust 概念,例如:

  • struct,Rust 中定义自定义类型的方式之一
  • 所有权 (ownership)、引用 (reference) 和借用 (borrowing)
  • 内存管理:栈 (stack)、堆 (heap)、指针 (pointer)、数据布局 (data layout)、析构函数 (destructor)
  • 模块 (module) 和可见性 (visibility)
  • 字符串 (string)

原文链接:英文原文

结构体 (Structs)

我们需要为每个工单 (ticket) 跟踪三项信息:

  • 标题 (title)
  • 描述 (description)
  • 状态 (status)

可以先用 String 来表示它们。String 是 Rust 标准库中定义的类型,用于表示 UTF-8 编码 的文本。

但我们怎么把这三项信息组合成一个单一实体呢?

定义 struct

struct(结构体)定义一个新的 Rust 类型

struct Ticket {
    title: String,
    description: String,
    status: String
}

结构体 (struct) 跟其他编程语言中的"类 (class)"或"对象 (object)"很相似。

定义字段 (Defining fields)

新类型由其他类型作为字段 (field) 组合而成。
每个字段必须有名字和类型,二者用冒号 : 分隔。如果有多个字段,则用逗号 , 分隔。

字段不必是同一类型,参见下面的 Configuration 结构体:

struct Configuration {
   version: u32,
   active: bool
}

实例化 (Instantiation)

你可以通过为每个字段指定值来创建一个结构体实例:

// 语法:<结构体名> { <字段名>: <值>, ... }
let ticket = Ticket {
    title: "Build a ticket system".into(),
    description: "A Kanban board".into(),
    status: "Open".into()
};

访问字段 (Accessing fields)

你可以使用 . 运算符访问结构体的字段:

// 字段访问
let x = ticket.description;

方法 (Methods)

我们可以通过定义方法 (method) 来给结构体附加行为。
Ticket 结构体为例:

impl Ticket {
    fn is_open(self) -> bool {
        self.status == "Open"
    }
}

// 语法:
// impl <结构体名> {
//    fn <方法名>(<参数>) -> <返回类型> {
//        // 方法体
//    }
// }

方法 (method) 与函数 (function) 非常相似,但有两个关键区别:

  1. 方法必须定义在 impl
  2. 方法可以使用 self 作为它的第一个参数。 self 是一个关键字,代表方法被调用时所在的结构体实例。

self

如果方法的第一个参数是 self,可以使用方法调用语法 (method call syntax) 来调用:

// 方法调用语法:<实例>.<方法名>(<参数>)
let is_open = ticket.is_open();

这与你在上一章中对 u32 值执行饱和算术运算时所用的调用语法是一样的。

静态方法 (Static methods)

如果方法不以 self 作为第一个参数,那它就是静态方法 (static method)

struct Configuration {
    version: u32,
    active: bool
}

impl Configuration {
    // `default` 是 `Configuration` 上的静态方法
    fn default() -> Configuration {
        Configuration { version: 0, active: false }
    }
}

调用静态方法的唯一方式是使用函数调用语法 (function call syntax)

// 函数调用语法:<结构体名>::<方法名>(<参数>)
let default_config = Configuration::default();

等价性 (Equivalence)

即使方法的第一个参数是 self,你依然可以用函数调用语法来调用它:

// 函数调用语法:
//   <结构体名>::<方法名>(<实例>, <参数>)
let is_open = Ticket::is_open(ticket);

函数调用语法非常清楚地表明 ticket 被当作 self(方法的第一个参数)使用,但显然更冗长。可以的话还是优先使用方法调用语法。

原文链接:英文原文

验证 (Validation)

让我们回到工单 (ticket) 的定义:

struct Ticket {
    title: String,
    description: String,
    status: String,
}

我们对 Ticket 结构体的字段使用了"原始"类型。 这意味着用户可以创建一个标题为空、描述超长、或者状态毫无意义(例如 "Funny")的工单。
我们能做得更好!

进一步阅读

  • 完整浏览一下 String 的文档, 了解它提供的方法。完成练习时你会用到它!

原文链接:英文原文

模块 (Modules)

你刚刚定义的 new 方法试图在 Ticket 的字段值上强制施加一些约束 (constraint)。 但这些不变量 (invariant) 真的被强制执行了吗?是什么阻止开发者绕过 Ticket::new 来创建一个 Ticket

要实现真正的封装 (encapsulation),你需要熟悉两个新概念:可见性 (visibility)模块 (module)。 我们先从模块讲起。

什么是模块?(What is a module?)

在 Rust 中,模块 (module) 是一种把相关代码聚合在一起、放在一个共同命名空间(即模块名)下的方式。
你已经见过模块的实际用法:用来验证代码正确性的单元测试 (unit test) 就定义在一个名为 tests 的独立模块中。

#[cfg(test)]
mod tests {
    // [...]
}

内联模块 (Inline modules)

上面的 tests 模块是内联模块 (inline module) 的例子:模块声明 (mod tests) 和模块内容({ ... } 内的部分)紧挨在一起。

模块树 (Module tree)

模块可以嵌套,从而形成一棵结构。
树的根是 crate 本身,也就是包含所有其他模块的顶级模块。 对于库 crate (library crate),根模块通常是 src/lib.rs(除非自定义了它的位置)。 根模块也叫作 crate 根 (crate root)

crate 根可以有子模块,子模块还可以再有自己的子模块,依此类推。

外部模块与文件系统 (External modules and the filesystem)

内联模块适合放小段代码,但项目变大后,你会想把代码拆到多个文件中。在父模块里,你用 mod 关键字声明子模块的存在。

mod dog;

接下来,由 Rust 的构建工具 cargo 负责找到包含模块实现的文件。
如果你的模块声明在 crate 根(例如 src/lib.rssrc/main.rs),cargo 期待文件名是以下之一:

  • src/<模块名>.rs
  • src/<模块名>/mod.rs

如果你的模块是另一个模块的子模块,文件应当命名为:

  • [..]/<父模块>/<模块名>.rs
  • [..]/<父模块>/<模块名>/mod.rs

例如,如果 doganimals 的子模块,则路径为 src/animals/dog.rssrc/animals/dog/mod.rs

当你用 mod 关键字声明新模块时,IDE 通常会帮你自动创建这些文件。

项目路径与 use 语句 (Item paths and use statements)

在同一模块内访问已定义的项 (item) 不需要任何特殊语法,直接用名字即可。

struct Ticket {
    // [...]
}

// 这里不需要对 `Ticket` 做任何限定
// 因为我们处于同一个模块中
fn mark_ticket_as_done(ticket: Ticket) {
    // [...]
}

但要访问另一个模块中的实体,情况就不一样了。
你必须使用一个路径 (path) 来指向想访问的实体。

可以用多种方式组合路径:

  • 从当前 crate 的根开始,例如 crate::module_1::MyStruct
  • 从父模块开始,例如 super::my_function
  • 从当前模块开始,例如 sub_module_1::MyStruct

cratesuper 都是关键字
crate 指向当前 crate 的根;super 指向当前模块的父模块。

每次都要写完整路径会很啰嗦。 为了让你的生活更轻松,可以用 use 语句把实体引入作用域 (scope)。

// 把 `MyStruct` 引入作用域
use crate::module_1::module_2::MyStruct;

// 现在可以直接使用 `MyStruct`
fn a_function(s: MyStruct) {
     // [...]
}

星号导入 (Star imports)

也可以用一条 use 语句导入模块中的所有项。

use crate::module_1::module_2::*;

这种用法称为星号导入 (star import)
通常不建议使用,因为它会污染当前命名空间,让人难以分辨每个名字的来源,还可能引入命名冲突。
不过在某些场景下它仍然有用,比如写单元测试时。你可能注意到我们大部分测试模块开头都有一句 use super::*;,把父模块(被测模块)中的所有项引入作用域。

可视化模块树 (Visualizing the module tree)

如果你很难想象项目的模块树,可以试试用 cargo-modules 来可视化它!

具体的安装与使用方法请参考其文档。

原文链接:英文原文

可见性 (Visibility)

当你开始把代码拆分成多个模块时,就需要考虑可见性 (visibility) 了。 可见性决定了哪些代码区域(你的或别人的)可以访问某个实体——无论它是结构体、函数、字段还是其他东西。

默认私有 (Private by default)

默认情况下,Rust 中的所有内容都是私有 (private) 的。
私有实体只能在以下范围内访问:

  1. 在它定义所在的同一模块内,或
  2. 该模块的某个子模块内

我们在前面的练习中已经大量用到这点:

  • create_todo_ticket 能够运行(在你加上 use 语句之后),是因为 helpers 是 crate 根的子模块,而 Ticket 就定义在 crate 根中。因此 create_todo_ticket 即使在 Ticket 是私有的情况下也能毫无问题地访问到它。
  • 我们所有的单元测试都定义在被测代码的子模块中,因此它们能不受限制地访问任何东西。

可见性修饰符 (Visibility modifiers)

你可以用可见性修饰符 (visibility modifier) 来修改实体的默认可见性。
一些常见的可见性修饰符是:

  • pub:将实体设为公有 (public),即可以从定义所在模块的外部访问,潜在地包括其他 crate。
  • pub(crate):在同一 crate 内公开,但不暴露给 crate 外部。
  • pub(super):在父模块中公开。
  • pub(in path::to::module):在指定的模块中公开。

这些修饰符可以用在模块、结构体、函数、字段等之上。 例如:

pub struct Configuration {
    pub(crate) version: u32,
    active: bool,
}

Configuration 是公有的,但你只能在同一 crate 内访问 version 字段。 而 active 字段是私有的,只能在同一模块或其子模块中访问。

原文链接:英文原文

封装 (Encapsulation)

现在我们对模块 (module) 和可见性 (visibility) 有了基本的理解,让我们回到封装 (encapsulation) 这个话题。
封装是指隐藏对象内部表示 (internal representation) 的实践。它最常见的用途是在对象状态上强制施加一些不变量 (invariant)

回到我们的 Ticket 结构体:

struct Ticket {
    title: String,
    description: String,
    status: String,
}

如果所有字段都设为公有,就完全没有封装可言。
你必须假设字段随时可能被修改,可以被设为类型允许的任何值。你无法排除工单标题为空、或者状态毫无意义的可能性。

要执行更严格的规则,我们必须把字段保持为私有1。 然后我们提供公有方法来与 Ticket 实例交互。 这些公有方法负责维护我们的不变量(例如:标题不能为空)。

只要至少有一个字段是私有的,就不再可能用结构体实例化语法直接创建 Ticket 实例:

// 这无法工作!
let ticket = Ticket {
    title: "Build a ticket system".into(),
    description: "A Kanban board".into(),
    status: "Open".into()
};

你在前面关于可见性 (visibility) 的练习中已经见识过这一点。
我们现在需要提供一个或多个公有的构造器 (constructor)——也就是可以从模块外部使用的、用来创建结构体实例的静态方法或函数。
所幸我们已经有一个:前一个练习中实现的 Ticket::new

访问器方法 (Accessor methods)

总结一下:

  • Ticket 的所有字段都是私有的
  • 我们提供了公有构造器 Ticket::new,在创建时强制执行验证规则

这是个不错的开始,但还不够:除了创建 Ticket,我们还要与它交互。 但如果字段是私有的,怎么访问它们呢?

我们需要提供访问器方法 (accessor method)
访问器方法是公有方法,允许你读取结构体一个(或多个)私有字段的值。

Rust 没有像某些语言那样内建的方式来自动生成访问器方法。 你得自己写——它们就是普通的方法。

1

也可以选择细化(refine)字段的类型,这是一种我们稍后会探索的技术。

原文链接:英文原文

所有权 (Ownership)

如果你只用本课程到目前为止所学的内容来解决前一个练习, 你的访问器方法可能长这样:

impl Ticket {
    pub fn title(self) -> String {
        self.title
    }

    pub fn description(self) -> String {
        self.description
    }

    pub fn status(self) -> String {
        self.status
    }
}

这些方法能编译通过,并且足以让测试通过,但放到真实场景中就走不远了。 看看下面这段代码:

if ticket.status() == "To-Do" {
    // 我们还没介绍 `println!` 宏 (macro),
    // 但目前你只需要知道它会向控制台
    // 打印(带模板的)信息
    println!("Your next task is: {}", ticket.title());
}

如果你尝试编译它,会得到一个错误:

error[E0382]: use of moved value: `ticket`
  --> src/main.rs:30:43
   |
25 |     let ticket = Ticket::new(/* */);
   |         ------ move occurs because `ticket` has type `Ticket`, 
   |                which does not implement the `Copy` trait
26 |     if ticket.status() == "To-Do" {
   |               -------- `ticket` moved due to this method call
...
30 |         println!("Your next task is: {}", ticket.title());
   |                                           ^^^^^^ 
   |                                value used here after move
   |
note: `Ticket::status` takes ownership of the receiver `self`, 
      which moves `ticket`
  --> src/main.rs:12:23
   |
12 |         pub fn status(self) -> String {
   |                       ^^^^

恭喜你,这是你的第一个借用检查器 (borrow-checker) 错误!

Rust 所有权系统的好处 (The perks of Rust's ownership system)

Rust 的所有权系统旨在确保:

  • 数据在被读取时永远不会被修改
  • 数据在被修改时永远不会被读取
  • 数据被销毁后永远不会被访问

这些约束由借用检查器 (borrow checker) 强制执行,它是 Rust 编译器的一个子系统,常常成为 Rust 社区里的笑话和梗的主角。

所有权 (ownership) 是 Rust 中的关键概念,也是这门语言独一无二的原因。 所有权让 Rust 能够提供内存安全 (memory safety) 而不牺牲性能。 对 Rust 来说,下面这些可以同时成立:

  1. 没有运行时垃圾回收器 (runtime garbage collector)
  2. 作为开发者,你很少需要直接管理内存
  3. 不会出现悬垂指针 (dangling pointer)、双重释放 (double free) 以及其他与内存相关的 bug

像 Python、JavaScript 和 Java 这样的语言能给你 2 和 3,但没有 1。
像 C 或 C++ 这样的语言给你 1,但既没有 2 也没有 3。

根据你的背景不同,第 3 点可能听起来有点神秘:什么是"悬垂指针 (dangling pointer)"?什么是"双重释放 (double free)"?为什么它们危险?
别担心:在课程的剩余部分我们会更详细地讨论这些概念。

不过现在,我们先专注于学习如何在 Rust 的所有权系统中工作。

所有者 (The owner)

在 Rust 中,每个值都有一个所有者 (owner),所有者在编译时被静态确定。 任意时刻,每个值都只有一个所有者。

移动语义 (Move semantics)

所有权可以转移。

例如,如果你拥有某个值,你可以把所有权转移给另一个变量:

let a = "hello, world".to_string(); // <- `a` 是该 String 的所有者
let b = a;  // <- `b` 现在是该 String 的所有者

Rust 的所有权系统是融入到类型系统中的:每个函数都必须在其签名 (signature) 中声明 它打算如何与其参数交互。

到目前为止,我们所有的方法和函数都消费 (consumed) 了它们的参数:它们获取了参数的所有权。 例如:

impl Ticket {
    pub fn description(self) -> String {
        self.description
    }
}

Ticket::description 获取了它被调用的 Ticket 实例的所有权。
这就是所谓的移动语义 (move semantics):值(self)的所有权从调用方移动 (moved) 到被调用方,调用方再也不能使用它了。

这正是编译器在前面那条错误信息里所用的措辞:

error[E0382]: use of moved value: `ticket`
  --> src/main.rs:30:43
   |
25 |     let ticket = Ticket::new(/* */);
   |         ------ move occurs because `ticket` has type `Ticket`, 
   |                which does not implement the `Copy` trait
26 |     if ticket.status() == "To-Do" {
   |               -------- `ticket` moved due to this method call
...
30 |         println!("Your next task is: {}", ticket.title());
   |                                           ^^^^^^ 
   |                                 value used here after move
   |
note: `Ticket::status` takes ownership of the receiver `self`, 
      which moves `ticket`
  --> src/main.rs:12:23
   |
12 |         pub fn status(self) -> String {
   |                       ^^^^

具体来说,调用 ticket.status() 时发生了如下事件序列:

  • Ticket::status 拿走 Ticket 实例的所有权
  • Ticket::statusself 中提取出 status,并把 status 的所有权转移回调用方
  • Ticket 实例的其余部分被丢弃(titledescription

接着我们尝试通过 ticket.title() 再次使用 ticket 时,编译器抱怨:ticket 这个值已经没了,我们不再拥有它,因此不能再使用它。

要构建_有用的_访问器方法,我们需要开始使用引用 (reference)

借用 (Borrowing)

我们希望访问器方法能读取变量的值,而不夺走它的所有权。
否则编程就太受限了。在 Rust 中,这通过借用 (borrowing) 来实现。

每当你借用一个值时,你会得到对它的一个引用 (reference)
引用按照其权限被打上标签1

  • 不可变引用 (&):允许你读取值,但不能修改
  • 可变引用 (&mut):允许你既读取也修改值

回到 Rust 所有权系统的目标:

  • 数据在被读取时永远不会被修改
  • 数据在被修改时永远不会被读取

为了保证这两点,Rust 必须对引用引入一些限制:

  • 不能同时存在指向同一值的可变引用和不可变引用
  • 不能同时存在多个指向同一值的可变引用
  • 当一个值正在被借用时,所有者不能修改这个值
  • 只要不存在可变引用,你想要多少不可变引用都可以

某种程度上,你可以把不可变引用看作对该值的"只读 (read-only)"锁, 而可变引用则像"读写 (read-write)"锁。

所有这些限制都由借用检查器 (borrow checker) 在编译期强制执行。

语法 (Syntax)

实际上你怎么借用一个值?
通过在变量前面加上 &&mut,你就借用了它的值。 但要注意!同样的符号(&&mut)放在类型前面含义不同: 它们表示一个不同的类型——对原类型的引用 (reference)。

例如:

struct Configuration {
    version: u32,
    active: bool,
}

fn main() {
    let config = Configuration {
        version: 1,
        active: true,
    };
    // `b` 是对 `config` 的 `version` 字段的引用。
    // `b` 的类型是 `&u32`,因为它包含了对一个
    // `u32` 值的引用。
    // 我们用 `&` 运算符借用 `config.version`,
    // 创建了一个引用。
    // 同样的符号 (`&`),根据上下文含义不同!
    let b: &u32 = &config.version;
    //     ^ 类型注解 (type annotation) 不是必需的,
    //       这里写出来只是为了说明发生了什么
}

同样的概念也适用于函数参数和返回类型:

// `f` 接受一个对 `u32` 的可变引用作为参数,
// 绑定到名字 `number`
fn f(number: &mut u32) -> &u32 {
    // [...]
}

深呼吸 (Breathe in, breathe out)

Rust 的所有权系统初看可能有点压倒性。
但别担心:随着练习的增多,它会变成你的第二天性。
本章剩余部分以及整个课程都会让你练个够! 我们会反复回顾每个概念,确保你对它们足够熟悉、真正理解它们的工作原理。

到本章末尾,我们会解释 Rust 的所有权系统_为什么_要这样设计。 此刻,先专注于理解_怎么用_。把每条编译器错误都当作一次学习机会!

1

这是个不错的入门心智模型,但它没有捕捉到_完整_的图景。 我们在课程后面会进一步细化对引用 (reference) 的理解。

原文链接:英文原文

可变引用 (Mutable references)

到现在你的访问器方法应该长这样:

impl Ticket {
    pub fn title(&self) -> &String {
        &self.title
    }

    pub fn description(&self) -> &String {
        &self.description
    }

    pub fn status(&self) -> &String {
        &self.status
    }
}

这里那里撒上几个 & 就搞定了!
我们现在有了一种访问 Ticket 实例字段而又不消耗它的方式。 接下来看看怎么用setter 方法 (setter method) 来增强我们的 Ticket 结构体。

Setter

Setter 方法允许用户修改 Ticket 私有字段的值,同时保证不变量 (invariant) 得到尊重(也就是说,你不能把 Ticket 的标题设为空字符串)。

在 Rust 中实现 setter 有两种常见方式:

  • self 作为输入。
  • &mut self 作为输入。

self 作为输入 (Taking self as input)

第一种方式像这样:

impl Ticket {
    pub fn set_title(mut self, new_title: String) -> Self {
        // 验证新标题 [...]
        self.title = new_title;
        self
    }
}

它拿走 self 的所有权,修改标题,再把修改后的 Ticket 实例返回。
你会这样使用它:

let ticket = Ticket::new(
    "Title".into(), 
    "Description".into(), 
    "To-Do".into()
);
let ticket = ticket.set_title("New title".into());

由于 set_title 拿走了 self 的所有权(即消费 (consumes) 它),我们需要把结果重新赋给一个变量。 上面的例子利用了变量遮蔽 (variable shadowing) 来重用同一个变量名:当你用一个已存在的名字声明一个新变量时,新变量会遮蔽 (shadow) 旧变量。这是 Rust 代码中常见的模式。

self 风格的 setter 在你需要一次性修改多个字段时表现得很好:你可以把多个调用串起来!

let ticket = ticket
    .set_title("New title".into())
    .set_description("New description".into())
    .set_status("In Progress".into());

&mut self 作为输入 (Taking &mut self as input)

第二种 setter 方式使用 &mut self,看起来像这样:

impl Ticket {
    pub fn set_title(&mut self, new_title: String) {
        // 验证新标题 [...]
        
        self.title = new_title;
    }
}

这次方法接受的是对 self 的可变引用,修改标题,仅此而已。 什么也不返回。

你会这样使用它:

let mut ticket = Ticket::new(
    "Title".into(),
    "Description".into(),
    "To-Do".into()
);
ticket.set_title("New title".into());

// 使用修改后的 ticket

所有权仍然在调用方手里,因此原来的 ticket 变量依旧有效。我们不需要把结果重新赋值。 不过我们要把 ticket 标记为 mut(可变),因为我们对它取了可变引用。

&mut 风格的 setter 有个缺点:你不能把多个调用串起来。 因为它们不返回修改后的 Ticket 实例,所以你不能在第一个调用的结果上再调用另一个 setter。 你必须分别调用每个 setter:

ticket.set_title("New title".into());
ticket.set_description("New description".into());
ticket.set_status("In Progress".into());

原文链接:英文原文

内存布局 (Memory layout)

我们已经从操作层面看过了所有权 (ownership) 和引用 (reference)——能做什么、不能做什么。 现在是时候掀开盖子看看:聊聊内存 (memory) 吧。

栈与堆 (Stack and heap)

谈到内存时,你常常会听人提起栈 (stack)堆 (heap)
它们是程序用来存储数据的两种不同的内存区域。

我们先从栈讲起。

栈 (Stack)

栈 (stack) 是一种 LIFO(Last In, First Out,后进先出)数据结构。
当你调用一个函数时,一个新的栈帧 (stack frame) 会被压到栈顶。这个栈帧用于存储函数的参数、局部变量以及一些"簿记 (bookkeeping)"值。
当函数返回时,对应的栈帧会从栈上弹出1

+-----------------+
| frame for func1 |
+-----------------+
        |
        | 调用 func2
        v
+-----------------+
| frame for func2 |
+-----------------+
| frame for func1 |
+-----------------+
        |
        | func2 返回
        v
+-----------------+
| frame for func1 |
+-----------------+

从操作层面看,栈上的分配/释放非常快
我们总是从栈顶压入和弹出数据,因此不需要去搜索可用内存。 我们也不必担心碎片 (fragmentation):栈是一整块连续 (contiguous) 的内存。

Rust

Rust 经常把数据放在栈上。
你的函数有一个 u32 类型的输入参数?那 32 位会放在栈上。
你定义了一个 i64 类型的局部变量?那 64 位也会放在栈上。
这一切运转良好,是因为这些整数的大小在编译期就已知,因此编译后的程序知道为它们在栈上保留多少空间。

std::mem::size_of

你可以使用 std::mem::size_of 函数来查看某个类型在栈上会占用多少空间。

例如对 u8

// 这个奇怪的语法 (`::<u8>`) 我们之后再讲。
// 现在先忽略它。
assert_eq!(std::mem::size_of::<u8>(), 1);

结果是 1,这很合理,因为 u8 是 8 位长,也就是 1 字节。

1

如果你有嵌套的函数调用,每个函数被调用时都把它的数据压到栈上, 但要等到最内层的函数返回时才会弹出。 如果嵌套调用太深,你可能会用尽栈空间——栈不是无限的! 这就叫 栈溢出 (stack overflow)

原文链接:英文原文

堆 (Heap)

栈很棒,但它不能解决我们所有的问题。那些大小在编译期未知的数据怎么办? 集合 (collection)、字符串 (string) 以及其他动态尺寸 (dynamically-sized) 的数据无法(完整地)放到栈上。 这就轮到堆 (heap) 登场了。

堆分配 (Heap allocations)

你可以把堆想象成一大块内存——如果愿意的话,可以把它当成一个巨大的数组。
每当你需要在堆上存储数据时,你向一个特殊的程序——分配器 (allocator)——请求为你预留堆中的一部分。我们把这种交互(以及你预留的那块内存)称作一次堆分配 (heap allocation)。 如果分配成功,分配器会给你一个指向所预留区块起始位置的指针 (pointer)

没有自动释放 (No automatic de-allocation)

堆的结构与栈相当不同。
堆分配不是连续的,它们可以位于堆中的任意位置。

+---+---+---+---+---+---+-...-+-...-+---+---+---+---+---+---+---+
|  Allocation 1 | Free  | ... | ... |  Allocation N |    Free   |
+---+---+---+---+---+---+ ... + ... +---+---+---+---+---+---+---+

哪些区域在使用、哪些是空闲的,由分配器来跟踪。 不过分配器不会自动释放你分配的内存:你需要主动行事,再次调用分配器来释放 (free) 你不再需要的内存。

性能 (Performance)

堆的灵活性是有代价的:堆分配比栈分配更慢。 其中涉及大量的簿记!
如果你读过性能优化方面的文章,常会看到这样的建议:尽量减少堆分配,能用栈分配的就用栈。

String 的内存布局 (String's memory layout)

当你创建一个 String 类型的局部变量时, Rust 不得不在堆上分配1:它无法事先知道你要往里塞多少文本, 所以无法在栈上提前预留合适的空间。
String 并不是_完全_位于堆上,它在栈上也保留了一些数据。具体来说:

  • 指向你预留的堆区域的指针 (pointer)
  • 字符串的长度 (length),即字符串当前包含的字节数。
  • 字符串的容量 (capacity),即在堆上预留了多少字节。

我们看个例子来更好理解:

let mut s = String::with_capacity(5);

如果你运行这段代码,内存布局会是这样:

      +---------+--------+----------+
Stack | pointer | length | capacity | 
      |  |      |   0    |    5     |
      +--|------+--------+----------+
         |
         |
         v
       +---+---+---+---+---+
Heap:  | ? | ? | ? | ? | ? |
       +---+---+---+---+---+

我们请求了一个最多能装 5 字节文本的 String
String::with_capacity 向分配器请求 5 字节的堆内存。分配器返回一个指向那块内存起始位置的指针。
不过此时 String 是空的。在栈上,我们通过区分长度 (length) 和容量 (capacity) 来记录这一信息:这个 String 最多可装 5 字节,但当前只装了 0 字节实际文本。

如果你往 String 里塞一些文本,情况就变了:

s.push_str("Hey");
      +---------+--------+----------+
Stack | pointer | length | capacity |
      |  |      |   3    |    5     |
      +--|  ----+--------+----------+
         |
         |
         v
       +---+---+---+---+---+
Heap:  | H | e | y | ? | ? |
       +---+---+---+---+---+

s 现在装了 3 字节的文本。它的长度更新为 3,但容量仍是 5。 堆上 5 个字节中有 3 个被用来存储字符 Hey

usize

我们要在栈上存指针、长度和容量,需要多大空间?
这取决于你运行机器的架构 (architecture)

机器上的每个内存位置都有一个地址 (address),通常表示为一个无符号整数。 根据地址空间的最大大小(即你的机器能寻址多少内存)不同, 这个整数可能有不同的大小。大多数现代机器使用 32 位或 64 位的地址空间。

Rust 通过提供 usize 类型来抽象掉这些与架构相关的细节: 一个无符号整数,其大小恰好等于在你机器上寻址内存所需的字节数。 在 32 位机器上,usize 等价于 u32;在 64 位机器上,对应 u64

容量、长度和指针在 Rust 中都用 usize 表示2

堆上没有 std::mem::size_of (No std::mem::size_of for the heap)

std::mem::size_of 返回某个类型在栈上会占用多少空间, 也称为类型的大小 (size of the type)

那么 String 在堆上管理的内存缓冲区呢?它不算 String 大小的一部分吗?

不算!
那块堆分配是 String管理的资源。 编译器并不把它看作 String 类型的一部分。

std::mem::size_of 不知道(也不在乎)某个类型可能管理或通过指针引用的额外堆内存——String 就属于这种情况—— 因此它不会跟踪这部分大小。

不幸的是,目前没有等价于 std::mem::size_of 的工具来在运行时测量某个值正在分配多少堆内存。 某些类型可能提供方法来查看它们的堆使用情况(例如 Stringcapacity 方法), 但 Rust 没有通用的"API"来在运行时获取堆使用情况。
不过你可以用内存分析工具(例如 DHAT自定义分配器 (custom allocator))来检查程序的堆使用情况。

1

如果你创建一个 String(即 String::new()),std 不会进行堆分配。 当你第一次往里塞数据时,才会预留堆内存。

2

指针的大小还取决于操作系统。 在某些环境下,指针内存地址更大(例如 CHERI)。 Rust 做了一个简化假设——指针和内存地址同样大——这对你大多数现代系统来说是成立的。

原文链接:英文原文

引用 (References)

那么 &String&mut String 这样的引用呢?它们在内存中是如何表示的?

Rust 中大多数引用1在内存中都表示为指向一个内存位置的指针。
因此它们的大小与指针大小相同,也就是一个 usize

你可以使用 std::mem::size_of 来验证:

assert_eq!(std::mem::size_of::<&String>(), 8);
assert_eq!(std::mem::size_of::<&mut String>(), 8);

具体来说,&String 是一个指向存储 String 元数据 (metadata) 的内存位置的指针。
如果你运行下面这段代码:

let s = String::from("Hey");
let r = &s;

内存中你会看到类似这样的布局:

           --------------------------------------
           |                                    |
      +----v----+--------+----------+      +----|----+
Stack | pointer | length | capacity |      | pointer |
      |  |      |   3    |    5     |      |         |
      +--|  ----+--------+----------+      +---------+
         |          s                           r
         |
         v
       +---+---+---+---+---+
Heap   | H | e | y | ? | ? |
       +---+---+---+---+---+

如果你愿意这么想:它是一个指向"指向堆上数据的指针"的指针。 &mut String 也是一样。

不是所有指针都指向堆 (Not all pointers point to the heap)

上面的例子应该能澄清一件事:并不是所有指针都指向堆。
它们仅仅指向某个内存位置——_可能_在堆上,但不一定。

1

在课程的后面我们会讨论胖指针 (fat pointer), 也就是带额外元数据的指针。顾名思义,它们比本章讨论的指针更大——后者也叫瘦指针 (thin pointer)

原文链接:英文原文

析构函数 (Destructors)

引入堆 (heap) 时,我们提到过:你分配的内存需要由你自己负责释放。
引入借用检查器 (borrow-checker) 时,我们又说在 Rust 里你很少需要直接管理内存。

这两条乍一看像是矛盾的。 让我们通过引入作用域 (scope)析构函数 (destructor) 来看看它们如何能合得拢。

作用域 (Scopes)

变量的作用域 (scope) 是 Rust 代码中该变量有效(即存活 (alive))的区域。

变量的作用域从它声明开始。 作用域结束于以下任意一种情况:

  1. 变量声明所在的代码块(即 {} 之间的代码)结束
    fn main() {
       // `x` 这里还不在作用域中
       let y = "Hello".to_string();
       let x = "World".to_string(); // <-- x 的作用域从这里开始……
       let h = "!".to_string(); //   |
    } //  <-------------- ……到这里结束
  2. 变量的所有权被转移给了别人(例如某个函数或另一个变量)
    fn compute(t: String) {
       // 做点事情 [...]
    }
    
    fn main() {
        let s = "Hello".to_string(); // <-- s 的作用域从这里开始……
                    //                    | 
        compute(s); // <------------------- ……到这里结束
                    //   因为 `s` 被移动 (moved) 到了 `compute` 中
    }

析构函数 (Destructors)

当一个值的所有者离开作用域时,Rust 会调用它的析构函数 (destructor)
析构函数会尝试清理该值所使用的资源——尤其是它分配过的内存。

你可以通过把值传给 std::mem::drop 来手动调用它的析构函数。
这就是为什么你常听到 Rust 开发者说"那个值已经被 drop(丢弃)了"——表示该值离开了作用域、其析构函数已被调用。

可视化 drop 的位置 (Visualizing drop points)

我们可以通过插入显式的 drop 调用来"明示"编译器替我们做了什么。回到前面的例子:

fn main() {
   let y = "Hello".to_string();
   let x = "World".to_string();
   let h = "!".to_string();
}

它等价于:

fn main() {
   let y = "Hello".to_string();
   let x = "World".to_string();
   let h = "!".to_string();
   // 变量按声明的反向顺序被丢弃 (drop)
   drop(h);
   drop(x);
   drop(y);
}

再看第二个例子,s 的所有权被转移给了 compute

fn compute(s: String) {
   // 做点事情 [...]
}

fn main() {
   let s = "Hello".to_string();
   compute(s);
}

它等价于:

fn compute(t: String) {
    // 做点事情 [...]
    drop(t); // <-- 假设 `t` 在此之前没有被 drop 或被移动 (move),
             //     编译器会在这里——它离开作用域时——调用 `drop`
}

fn main() {
    let s = "Hello".to_string();
    compute(s);
}

注意区别:尽管 compute 调用之后 smain 中已经无效,但 main 里并没有 drop(s)。 当你把一个值的所有权转移给一个函数时,你也把清理它的责任一并转移过去了

这能确保某个值的析构函数最多1被调用一次,从设计上避免了 双重释放 bug (double free bugs)

释放后再使用 (Use after drop)

如果你试图在一个值被 drop 后还使用它,会发生什么?

let x = "Hello".to_string();
drop(x);
println!("{}", x);

如果你尝试编译这段代码,会得到一个错误:

error[E0382]: use of moved value: `x`
 --> src/main.rs:4:20
  |
3 |     drop(x);
  |          - value moved here
4 |     println!("{}", x);
  |                    ^ value used here after move

drop 消费 (consumes) 了它被调用的那个值,意味着调用之后该值就不再有效。
因此编译器会阻止你使用它,从而避免了 释放后使用 bug (use-after-free bugs)

丢弃引用 (Dropping references)

如果变量保存的是一个引用呢?
例如:

let x = 42i32;
let y = &x;
drop(y);

当你调用 drop(y) 时……什么也不会发生。
如果你真的尝试编译这段代码,会得到一条警告:

warning: calls to `std::mem::drop` with a reference 
         instead of an owned value does nothing
 --> src/main.rs:4:5
  |
4 |     drop(y);
  |     ^^^^^-^
  |          |
  |          argument has type `&i32`
  |

这回到我们前面说的:我们只想让析构函数被调用一次。
你可以同时持有多个指向同一个值的引用——如果其中一个引用离开作用域时就调用值的析构函数,那其他引用怎么办? 它们将指向一个不再有效的内存位置:所谓的悬垂指针 (dangling pointer), 是 释放后使用 bug (use-after-free bug) 的近亲。 Rust 的所有权系统从设计上排除了这一类 bug。

1

Rust 不保证析构函数一定会运行。例如,如果你显式选择泄漏内存 (leak memory), 它们就不会运行。

原文链接:英文原文

总结 (Wrapping up)

本章我们覆盖了相当多 Rust 的基础概念。
继续向前之前,让我们再做最后一个练习来巩固所学。 这次你只会得到很少的指引——只有练习描述和测试可供参考。

原文链接:英文原文

特质 (Traits)

上一章我们覆盖了 Rust 类型系统和所有权 (ownership) 系统的基础。
现在该深入挖掘了:我们要探索特质 (trait)——Rust 对接口 (interface) 的诠释。

一旦你了解了特质,就会开始在各处看到它们的影子。
事实上,前一章你已经在不断接触特质了,例如 .into() 调用以及 ==+ 这类运算符。

除了把特质作为一个概念讨论之外,本章还会覆盖 Rust 标准库 (standard library) 中定义的几个关键特质:

  • 运算符特质(例如 AddSubPartialEq 等)
  • FromInto,用于不会失败的转换 (infallible conversions)
  • CloneCopy,用于复制值
  • Deref 与解引用强制转换 (deref coercion)
  • Sized,用于标记大小已知的类型
  • Drop,用于自定义清理逻辑

既然要谈到类型转换,我们也会顺便填补上一章遗留的一些"知识空白"——比如 "A title" 究竟是什么?是时候多了解一下切片 (slice) 了!

原文链接:英文原文

特质 (Traits)

让我们再次看看 Ticket 类型:

pub struct Ticket {
    title: String,
    description: String,
    status: String,
}

到目前为止,我们的所有测试都是基于 Ticket 的字段进行断言的。

assert_eq!(ticket.title(), "A new title");

如果我们想直接比较两个 Ticket 实例呢?

let ticket1 = Ticket::new(/* ... */);
let ticket2 = Ticket::new(/* ... */);
ticket1 == ticket2

编译器会拦下我们:

error[E0369]: binary operation `==` cannot be applied to type `Ticket`
  --> src/main.rs:18:13
   |
18 |     ticket1 == ticket2
   |     ------- ^^ ------- Ticket
   |     |
   |     Ticket
   |
note: an implementation of `PartialEq` might be missing for `Ticket`

Ticket 是一个新类型。开箱即用时,它身上没有任何行为
Rust 不会因为它包含了 String 字段就魔法般地推断出该如何比较两个 Ticket 实例。

不过 Rust 编译器朝着正确方向给了我们提示:它建议我们可能缺少了 PartialEq 的实现。PartialEq 就是一个特质 (trait)

什么是特质?(What are traits?)

特质 (trait) 是 Rust 定义接口 (interface) 的方式。
特质定义了一组方法,类型必须实现它们才能满足该特质的契约 (contract)。

定义一个特质 (Defining a trait)

特质定义的语法如下:

trait <特质名> {
    fn <方法名>(<参数>) -> <返回类型>;
}

例如,我们可以定义一个名为 MaybeZero 的特质,要求实现者定义一个 is_zero 方法:

trait MaybeZero {
    fn is_zero(self) -> bool;
}

实现一个特质 (Implementing a trait)

为类型实现特质要使用 impl 关键字,跟我们为类型定义普通1方法时一样,只是语法略有不同:

impl <特质名> for <类型名> {
    fn <方法名>(<参数>) -> <返回类型> {
        // 方法体
    }
}

例如,给一个自定义数字类型 WrappingU32 实现 MaybeZero 特质:

pub struct WrappingU32 {
    inner: u32,
}

impl MaybeZero for WrappingU32 {
    fn is_zero(self) -> bool {
        self.inner == 0
    }
}

调用特质方法 (Invoking a trait method)

要调用特质的方法,跟调用普通方法一样使用 . 运算符:

let x = WrappingU32 { inner: 5 };
assert!(!x.is_zero());

要调用特质方法,必须满足两个条件:

  • 类型实现了该特质。
  • 该特质必须在作用域 (scope) 中。

为满足后者,你可能需要为该特质添加一个 use 语句:

use crate::MaybeZero;

下列情况则不需要 use

  • 特质定义在调用所在的同一模块中。
  • 特质定义在标准库的预导入 (prelude) 中。 预导入是一组在每个 Rust 程序中都会自动导入的特质和类型。 就好像每个 Rust 模块开头都隐式加了一句 use std::prelude::*;

你可以在 Rust 文档中查看预导入中的特质和类型列表。

1

不通过特质、直接定义在类型上的方法也叫固有方法 (inherent method)

原文链接:英文原文

实现特质 (Implementing traits)

当一个类型定义在另一个 crate 中(例如来自 Rust 标准库的 u32),你不能直接为它定义新方法。如果你尝试:

impl u32 {
    fn is_even(&self) -> bool {
        self % 2 == 0
    }
}

编译器会抱怨:

error[E0390]: cannot define inherent `impl` for primitive types
  |
1 | impl u32 {
  | ^^^^^^^^
  |
  = help: consider using an extension trait instead

扩展特质 (Extension trait)

扩展特质 (extension trait) 是一种特质,其主要目的是给外部类型(例如 u32)附加新方法。 这正是你在前一个练习中部署的模式:定义 IsEven 特质,再为 i32u32 实现它。然后只要 IsEven 在作用域中,你就可以在这些类型上调用 is_even

// 把特质引入作用域
use my_library::IsEven;

fn main() {
    // 在实现了它的类型上调用方法
    if 4.is_even() {
        // [...]
    }
}

只能有一个实现 (One implementation)

你能写的特质实现是有限制的。
最简单、最直接的限制是:在同一个 crate 中,你不能为同一类型实现同一个特质两次。

例如:

trait IsEven {
    fn is_even(&self) -> bool;
}

impl IsEven for u32 {
    fn is_even(&self) -> bool {
        true
    }
}

impl IsEven for u32 {
    fn is_even(&self) -> bool {
        false
    }
}

编译器会拒绝它:

error[E0119]: conflicting implementations of trait `IsEven` for type `u32`
   |
5  | impl IsEven for u32 {
   | ------------------- first implementation here
...
11 | impl IsEven for u32 {
   | ^^^^^^^^^^^^^^^^^^^ conflicting implementation for `u32`

当对一个 u32 值调用 IsEven::is_even 时,应当使用哪个特质实现不能存在歧义,因此只能存在一个。

孤儿规则 (Orphan rule)

涉及多个 crate 时情况更微妙。 具体来说,下列条件至少要有一个为真:

  • 该特质定义在当前 crate 中
  • 实现者类型定义在当前 crate 中

这就是 Rust 的孤儿规则 (orphan rule)。它的目标是让方法解析过程不出现歧义。

设想下面的情形:

  • crate A 定义了 IsEven 特质
  • crate Bu32 实现了 IsEven
  • crate Cu32 提供了(与 B 不同的)IsEven 实现
  • crate D 同时依赖 BC,并调用 1.is_even()

应当使用哪个实现?是 B 中定义的那个?还是 C 中定义的那个?
没有好答案,因此孤儿规则被定义出来以阻止这种情形发生。 有了孤儿规则,crate B 和 crate C 都将无法编译。

进一步阅读

  • 上述孤儿规则其实有一些注意事项和例外。 如果你想熟悉它的细节,请查看参考手册

原文链接:英文原文

运算符重载 (Operator overloading)

现在我们对特质 (trait) 有了基本的理解,让我们回到运算符重载 (operator overloading)。 运算符重载是为 +-*/==!= 等运算符定义自定义行为的能力。

运算符就是特质 (Operators are traits)

在 Rust 中,运算符就是特质。
对于每个运算符,都有一个对应的特质来定义它的行为。 通过为你的类型实现该特质,你就解锁了相应运算符的使用。

例如,PartialEq 特质定义了 ==!= 运算符的行为:

// `PartialEq` 特质的定义,来自 Rust 标准库
//(这里*稍微*简化了一下,暂时只看核心部分)
pub trait PartialEq {
    // 必须实现的方法
    //
    // `Self` 是 Rust 关键字,代表
    // "正在实现该特质的那个类型"
    fn eq(&self, other: &Self) -> bool;

    // 提供的方法(已带默认实现)
    fn ne(&self, other: &Self) -> bool { ... }
}

当你写 x == y 时,编译器会查找 xy 类型的 PartialEq 实现,并把 x == y 替换为 x.eq(y)。这是语法糖 (syntactic sugar)!

主要运算符与特质的对应关系如下:

运算符特质
+Add
-Sub
*Mul
/Div
%Rem
==!=PartialEq
<><=>=PartialOrd

算术运算符位于 std::ops 模块下, 比较运算符位于 std::cmp 模块下。

默认实现 (Default implementations)

PartialEq::ne 上的注释说 "ne 是一个 provided method"。
意思是 PartialEq 在特质定义中为 ne 提供了默认实现 (default implementation)——也就是上面定义里被省略的 { ... } 代码块。
把那段省略的代码块展开后,它看起来是这样:

pub trait PartialEq {
    fn eq(&self, other: &Self) -> bool;

    fn ne(&self, other: &Self) -> bool {
        !self.eq(other)
    }
}

跟你预期的一样:ne 就是 eq 的取反。
既然提供了默认实现,当你为自己的类型实现 PartialEq 时就可以省略 ne,只实现 eq 就够了:

struct WrappingU8 {
    inner: u8,
}

impl PartialEq for WrappingU8 {
    fn eq(&self, other: &WrappingU8) -> bool {
        self.inner == other.inner
    }
    
    // 这里没有 `ne` 实现
}

不过你也不是非得用默认实现。 实现特质时你可以选择覆盖它:

struct MyType;

impl PartialEq for MyType {
    fn eq(&self, other: &MyType) -> bool {
        // 自定义实现
    }

    fn ne(&self, other: &MyType) -> bool {
        // 自定义实现
    }
}

原文链接:英文原文

派生宏 (Derive macros)

Ticket 实现 PartialEq 是不是有点繁琐? 你必须手动比较结构体的每一个字段。

解构语法 (Destructuring syntax)

更何况,这种实现很脆弱:如果结构体定义发生变化(例如新加了一个字段),你必须记得去更新 PartialEq 的实现。

你可以通过把结构体解构 (destructure) 为它的各个字段来缓解这种风险:

impl PartialEq for Ticket {
    fn eq(&self, other: &Self) -> bool {
        let Ticket {
            title,
            description,
            status,
        } = self;
        // [...]
    }
}

如果 Ticket 的定义变了,编译器会报错,抱怨你的解构不再是穷尽 (exhaustive) 的。
你也可以重命名结构体字段,避免变量遮蔽 (variable shadowing):

impl PartialEq for Ticket {
    fn eq(&self, other: &Self) -> bool {
        let Ticket {
            title,
            description,
            status,
        } = self;
        let Ticket {
            title: other_title,
            description: other_description,
            status: other_status,
        } = other;
        // [...]
    }
}

解构是工具箱里好用的一招,但还有一种更便利的方式:派生宏 (derive macro)

宏 (Macros)

你在前面的练习中已经接触过几个宏:

  • assert_eq!assert!,在测试用例中
  • println!,向控制台打印

Rust 宏是代码生成器 (code generator)
它们根据你提供的输入生成新的 Rust 代码,生成的代码会和你程序的其余部分一起被编译。某些宏内建在 Rust 标准库中,但你也可以自己写宏。本课程不会让你自己写宏,但下面的"进一步阅读"部分有不错的指引。

检视 (Inspection)

某些 IDE 允许你展开宏来查看生成的代码。如果做不到,你可以用 cargo-expand

派生宏 (Derive macros)

派生宏 (derive macro) 是 Rust 宏的一个特殊类别。它以属性 (attribute) 的形式标注在结构体上。

#[derive(PartialEq)]
struct Ticket {
    title: String,
    description: String,
    status: String
}

派生宏用来自动化为自定义类型实现常见的(且"显而易见"的)特质。 在上面的例子中,Ticket 自动实现了 PartialEq 特质。 如果你展开宏,会看到生成的代码与你手动写出的功能等价,只是读起来稍显冗长:

#[automatically_derived]
impl ::core::cmp::PartialEq for Ticket {
    #[inline]
    fn eq(&self, other: &Ticket) -> bool {
        self.title == other.title 
            && self.description == other.description
            && self.status == other.status
    }
}

只要可能,编译器会鼓励你使用派生 (derive) 来实现特质。

进一步阅读

原文链接:英文原文

特质约束 (Trait bounds)

到目前为止,我们看到了特质 (trait) 的两种用途:

  • 解锁"内建"的行为(例如运算符重载)
  • 给已存在的类型添加新行为(即扩展特质 (extension trait))

还有第三种用途:泛型编程 (generic programming)

问题 (The problem)

到目前为止,我们所有的函数和方法都是基于具体类型 (concrete type) 工作的。
处理具体类型的代码通常容易写也容易理解。但它的可重用性受限。
比如,假设我们想写一个函数,用来判断一个整数是否为偶数。 基于具体类型,我们就得为每一个想支持的整数类型分别写一个函数:

fn is_even_i32(n: i32) -> bool {
    n % 2 == 0
}

fn is_even_i64(n: i64) -> bool {
    n % 2 == 0
}

// 等等。

或者,我们可以写一个扩展特质,再为每个整数类型分别实现它:

trait IsEven {
    fn is_even(&self) -> bool;
}

impl IsEven for i32 {
    fn is_even(&self) -> bool {
        self % 2 == 0
    }
}

impl IsEven for i64 {
    fn is_even(&self) -> bool {
        self % 2 == 0
    }
}

// 等等。

重复仍然存在。

泛型编程 (Generic programming)

我们可以用泛型 (generics) 做得更好。
泛型让我们写出基于类型参数 (type parameter) 而非具体类型工作的代码:

fn print_if_even<T>(n: T)
where
    T: IsEven + Debug
{
    if n.is_even() {
        println!("{n:?} is even");
    }
}

print_if_even 是一个泛型函数 (generic function)
它不绑死在某个具体输入类型上,而是适用于任何同时满足下列条件的类型 T

  • 实现了 IsEven 特质。
  • 实现了 Debug 特质。

这个契约通过一个特质约束 (trait bound) 来表达:T: IsEven + Debug
+ 符号用来要求 T 实现多个特质。T: IsEven + Debug 等价于"T 同时实现了 IsEven Debug"。

特质约束 (Trait bounds)

特质约束在 print_if_even 中起到什么作用?
我们试着把它去掉来看看:

fn print_if_even<T>(n: T) {
    if n.is_even() {
        println!("{n:?} is even");
    }
}

这段代码无法编译:

error[E0599]: no method named `is_even` found for type parameter `T` 
              in the current scope
 --> src/lib.rs:2:10
  |
1 | fn print_if_even<T>(n: T) {
  |                  - method `is_even` not found 
  |                    for this type parameter
2 |     if n.is_even() {
  |          ^^^^^^^ method not found in `T`

error[E0277]: `T` doesn't implement `Debug`
 --> src/lib.rs:3:19
  |
3 |         println!("{n:?} is even");
  |                   ^^^^^ 
  |   `T` cannot be formatted using `{:?}` because 
  |         it doesn't implement `Debug`
  |
help: consider restricting type parameter `T`
  |
1 | fn print_if_even<T: std::fmt::Debug>(n: T) {
  |                   +++++++++++++++++

没有特质约束,编译器不知道 T 能做什么
它不知道 Tis_even 方法,也不知道怎么把 T 格式化用于打印。 从编译器的视角看,光秃秃的 T 没有任何行为。
特质约束通过确保函数体所需的行为是存在的,来限制可用的类型集合。

语法:内联特质约束 (Syntax: inlining trait bounds)

上面的例子都使用了 where 子句来指定特质约束:

fn print_if_even<T>(n: T)
where
    T: IsEven + Debug
//  ^^^^^^^^^^^^^^^^^
//  这就是 `where` 子句
{
    // [...]
}

如果特质约束很简单,你可以直接内联 (inline) 在类型参数旁边:

fn print_if_even<T: IsEven + Debug>(n: T) {
    //           ^^^^^^^^^^^^^^^^^
    //           这是内联特质约束
    // [...]
}

语法:使用有意义的名字 (Syntax: meaningful names)

上面的例子里,我们使用 T 作为类型参数名。当函数只有一个类型参数时,这是常见的约定。
不过没什么阻止你使用更有意义的名字:

fn print_if_even<Number: IsEven + Debug>(n: Number) {
    // [...]
}

事实上,当涉及多个类型参数、或是 T 名字本身不能体现该类型在函数中的角色时,使用更有意义的名字是值得提倡的。 取类型参数名时,要像取变量名或函数参数名一样最大限度地追求清晰和可读性。 不过要遵循 Rust 的命名约定:使用符合 RFC 430 的大驼峰命名 (upper camel case) 来命名类型参数

函数签名说了算 (The function signature is king)

你可能会问:我们到底为什么需要特质约束?编译器不能从函数体推断出所需的特质吗?
它能,但不会这么做。
这与函数参数上的显式类型注解的理由是一样的: 每个函数签名都是调用方与被调用方之间的契约 (contract),条款必须明确写出来。 这能带来更好的错误信息、更好的文档、版本之间更少的意外破坏,以及更快的编译速度。

原文链接:英文原文

字符串切片 (String slices)

前面几章你看过不少代码里使用了字符串字面量 (string literal), 比如 "To-Do""A ticket description"。 它们后面总跟着一个 .to_string().into() 调用。是时候搞清楚为什么了!

字符串字面量 (String literals)

你通过把原始文本用双引号括起来定义一个字符串字面量:

let s = "Hello, world!";

s 的类型是 &str,即对字符串切片的引用 (a reference to a string slice)

内存布局 (Memory layout)

&strString 是不同的类型——它们不能互换。
回忆我们此前的探讨String 的内存布局。 如果运行:

let mut s = String::with_capacity(5);
s.push_str("Hello");

我们在内存里会得到这样的图景:

      +---------+--------+----------+
Stack | pointer | length | capacity | 
      |  |      |   5    |    5     |
      +--|------+--------+----------+
         |
         |
         v
       +---+---+---+---+---+
Heap:  | H | e | l | l | o |
       +---+---+---+---+---+

如果你还记得,我们也分析过 &String 在内存里的布局:

     --------------------------------------
     |                                    |         
+----v----+--------+----------+      +----|----+
| pointer | length | capacity |      | pointer |
|    |    |   5    |    5     |      |         |
+----|----+--------+----------+      +---------+
     |        s                          &s 
     |       
     v       
   +---+---+---+---+---+
   | H | e | l | l | o |
   +---+---+---+---+---+

&String 指向存储 String 元数据 (metadata) 的内存位置。
顺着这个指针走,就到了堆上分配的数据。具体来说,到达字符串的第一个字节 H

如果我们想要一个表示 s子串 (substring) 的类型呢?例如表示 Hello 中的 ello

字符串切片 (String slices)

&str 是对字符串的一个视图 (view)——指向存储在别处的一段 UTF-8 字节序列的引用 (reference)。 例如,可以这样从 String 创建一个 &str

let mut s = String::with_capacity(5);
s.push_str("Hello");
// 从 `String` 创建一个字符串切片引用,
// 跳过第一个字节。
let slice: &str = &s[1..];

在内存里,看起来是这样:

                    s                              slice
      +---------+--------+----------+      +---------+--------+
Stack | pointer | length | capacity |      | pointer | length |
      |    |    |   5    |    5     |      |    |    |   4    |
      +----|----+--------+----------+      +----|----+--------+
           |        s                           |  
           |                                    |
           v                                    | 
         +---+---+---+---+---+                  |
Heap:    | H | e | l | l | o |                  |
         +---+---+---+---+---+                  |
               ^                                |
               |                                |
               +--------------------------------+

slice 在栈上存了两条信息:

  • 一个指向切片首字节的指针。
  • 切片的长度。

slice 不拥有 (own) 数据,只是指向数据:它是对 String 堆上数据的引用 (reference)
slice 被 drop 时,堆上的数据并不会被释放,因为它仍然由 s 拥有。 这就是为什么 slice 没有 capacity 字段:它不拥有数据,因此不需要知道为这块数据分配了多大空间;它只关心自己引用的部分。

&str&String

经验法则:当你需要对文本数据的引用时,用 &str 而不是 &String
&str 更灵活,在 Rust 代码里通常也被认为更符合习惯用法 (idiomatic)。

如果一个方法返回 &String,你就承诺:在某处存在一段堆分配的 UTF-8 文本,与你返回引用所指的那段完全匹配
如果方法返回的是 &str,则你拥有更多自由:你只是说"_某处_有一堆文本数据,其中某一部分是我需要的,因此返回对它的引用"。

原文链接:英文原文

Deref 特质 (Deref trait)

在前一个练习中你其实没做太多事,对吧?

impl Ticket {
    pub fn title(&self) -> &String {
        &self.title
    }
}

改成

impl Ticket {
    pub fn title(&self) -> &str {
        &self.title
    }
}

就足以让代码通过编译并通过测试。 但你脑子里应该响起一些警铃。

它本不该工作,但偏偏工作了 (It shouldn't work, but it does)

来梳理一下:

  • self.titleString
  • 因此 &self.title&String
  • (改动后)title 方法的输出类型是 &str

你会预期得到一个编译错误,对吧?类似 Expected &String, found &str 之类的。 然而它就是工作了。为什么

Deref 来救场 (Deref to the rescue)

Deref 特质是被称作解引用强制转换 (deref coercion) 的语言特性背后的机制。
该特质定义在标准库的 std::ops 模块中:

// 我现在略微简化了定义。
// 后面我们会看到完整定义。
pub trait Deref {
    type Target;
    
    fn deref(&self) -> &Self::Target;
}

type Target 是一个关联类型 (associated type)
它是一个占位符,代表在实现该特质时必须指定的具体类型。

解引用强制转换 (Deref coercion)

通过为类型 T 实现 Deref<Target = U>,你就告诉编译器:&T&U 在某种程度上可以互换。
具体来说,你会得到下列行为:

  • T 的引用会被隐式转换为对 U 的引用(即 &T 变成 &U
  • 你可以在 &T 上调用所有 U 上接受 &self 作为输入的方法。

围绕解引用运算符 * 还有一点细节,但我们暂时用不上(如果你好奇可以看 std 的文档)。

String 实现了 Deref

String 实现了 Deref,且 Target = str

impl Deref for String {
    type Target = str;
    
    fn deref(&self) -> &str {
        // [...]
    }
}

得益于这个实现以及解引用强制转换,&String 在需要时会自动被转换成 &str

不要滥用解引用强制转换 (Don't abuse deref coercion)

解引用强制转换是个强大的特性,但也容易引发困惑。
自动类型转换会让代码更难读、更难懂。如果在 TU 上都定义了同名方法,会调用哪个?

我们会在课程后面看看解引用强制转换的"最安全"使用场景:智能指针 (smart pointer)。

原文链接:英文原文

Sized

即便研究过解引用强制转换 (deref coercion),&str 仍然有一些没暴露出来的细节。
基于我们前面关于内存布局的讨论, 本来合理的预期是 &str 在栈上表示为单个 usize——一个指针。但事实并非如此。&str 在指针旁还存了一些元数据 (metadata):它指向的切片的长度。回到前一节的例子:

let mut s = String::with_capacity(5);
s.push_str("Hello");
// 从 `String` 创建一个字符串切片引用,
// 跳过第一个字节。
let slice: &str = &s[1..];

在内存里,我们得到:

                    s                              slice
      +---------+--------+----------+      +---------+--------+
Stack | pointer | length | capacity |      | pointer | length |
      |    |    |   5    |    5     |      |    |    |   4    |
      +----|----+--------+----------+      +----|----+--------+
           |        s                           |  
           |                                    |
           v                                    | 
         +---+---+---+---+---+                  |
Heap:    | H | e | l | l | o |                  |
         +---+---+---+---+---+                  |
               ^                                |
               |                                |
               +--------------------------------+

这是怎么回事?

动态尺寸类型 (Dynamically sized types)

str 是一种动态尺寸类型 (dynamically sized type, DST)
DST 的大小在编译期是未知的。每当你持有对 DST 的引用——例如 &str——它必须包含关于其所指数据的额外信息。这就是胖指针 (fat pointer)
&str 的情形下,它存储了它所指切片的长度。 我们会在课程后面看到更多 DST 的例子。

Sized 特质 (The Sized trait)

Rust 的 std 库定义了一个名为 Sized 的特质。

pub trait Sized {
    // 这是个空特质,没有任何方法要实现。
}

如果一个类型的大小在编译期已知,它就是 Sized 的。换句话说,它不是 DST。

标记特质 (Marker traits)

Sized 是你接触到的第一个标记特质 (marker trait)
标记特质不要求实现任何方法,也不定义任何行为。 它只用来标记 (mark) 一个类型具有某些性质。 然后编译器利用这个标记来启用某些行为或优化。

自动特质 (Auto traits)

特别地,Sized 也是一个自动特质 (auto trait)
你不需要显式实现它,编译器会根据类型的定义自动为你实现。

例子 (Examples)

到目前为止我们见过的所有类型都是 Sizedu32Stringbool 等等。

str,正如刚才所见,不是 Sized 的。
不过 &strSized 的!我们在编译期就知道它的大小:两个 usize,一个用于指针,一个用于长度。

原文链接:英文原文

FromInto

让我们回到字符串之旅开始的地方:

let ticket = Ticket::new(
    "A title".into(), 
    "A description".into(), 
    "To-Do".into()
);

我们现在已经知道得够多,可以开始拆解这里的 .into() 到底在做什么了。

问题 (The problem)

这是 new 方法的签名:

impl Ticket {
    pub fn new(
        title: String, 
        description: String, 
        status: String
    ) -> Self {
        // [...]
    }
}

我们也已经看到字符串字面量(如 "A title")的类型是 &str
这里有类型不匹配:期望的是 String,但我们手上的是 &str。 这次没有什么神奇的强制转换 (coercion) 来救场;我们需要执行一次转换 (perform a conversion)

FromInto

Rust 标准库在 std::convert 模块中定义了两个用于不会失败的转换 (infallible conversion) 的特质:FromInto

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

pub trait Into<T>: Sized {
    fn into(self) -> T;
}

这些特质定义展示了几个我们之前没见过的概念:父特质 (supertrait)隐式特质约束 (implicit trait bound)。 我们先把它们拆开来看。

父特质 / 子特质 (Supertrait / Subtrait)

From: Sized 这个语法意味着 FromSized子特质 (subtrait):任何实现了 From 的类型也必须实现 Sized。 反过来你也可以说:SizedFrom父特质 (supertrait)

隐式特质约束 (Implicit trait bounds)

每当你拥有泛型类型参数时,编译器会隐式假设它是 Sized 的。

例如:

pub struct Foo<T> {
    inner: T,
}

实际上等价于:

pub struct Foo<T: Sized> 
{
    inner: T,
}

对于 From<T>,这条特质定义等价于:

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

换句话说,T 以及 实现 From<T> 的类型都必须是 Sized 的, 即使前一条约束是隐式的。

否定特质约束 (Negative trait bounds)

你可以通过否定特质约束 (negative trait bound) 来取消隐式的 Sized 约束:

pub struct Foo<T: ?Sized> {
    //            ^^^^^^^
    //            这是一个否定特质约束
    inner: T,
}

这个语法读作"T 可能是 Sized,也可能不是",并且允许你把 T 绑到 DST 上(例如 Foo<str>)。这是一个特殊情况:否定特质约束是 Sized 专属的,不能用在其他特质上。

&strString (&str to String)

std 文档中你可以看到哪些 std 类型实现了 From 特质。
你会发现 String 实现了 From<&str> for String。因此我们可以写:

let title = String::from("A title");

不过我们一直主要在用 .into()
如果你查看 Into 的实现者列表, 你不会找到 Into<String> for &str。这是怎么回事?

FromInto对偶的特质 (dual traits)
具体来说,Into 通过一条全覆盖实现 (blanket implementation) 自动地为任何实现了 From 的类型实现:

impl<T, U> Into<U> for T
where
    U: From<T>,
{
    fn into(self) -> U {
        U::from(self)
    }
}

如果类型 U 实现了 From<T>,那么 Into<U> for T 会自动被实现。这就是为什么我们可以写 let title = "A title".into();

.into()

每当你看到 .into() 时,你正在见证一次类型之间的转换。
但目标类型是什么呢?

大多数情况下,目标类型要么是:

  • 由函数/方法的签名指定(例如上面例子里的 Ticket::new
  • 或在变量声明中通过类型注解指定(例如 let title: String = "A title".into();

只要编译器能从上下文中无歧义地推断出目标类型,.into() 就会开箱即用。

原文链接:英文原文

泛型与关联类型 (Generics and associated types)

让我们重新审视前面学过的两个特质 FromDeref 的定义:

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

pub trait Deref {
    type Target;
    
    fn deref(&self) -> &Self::Target;
}

它们都包含类型参数。
From 来说是泛型参数 (generic parameter) T
Deref 来说是关联类型 (associated type) Target

它们有什么区别?什么时候用哪个?

至多一个实现 (At most one implementation)

由于解引用强制转换 (deref coercion) 的工作机制,给定类型只能有一个"目标 (target)"类型。例如 String 只能解引用到 str。 这是为了避免歧义:如果你能为同一个类型多次实现 Deref,那当你调用 &self 方法时,编译器该选哪个 Target 类型?

这就是 Deref 使用关联类型 Target 的原因。
关联类型由特质实现唯一确定。 既然 Deref 不能为同一类型实现多次,那对每个类型也就只能指定一个 Target,不会出现歧义。

泛型特质 (Generic traits)

另一方面,你可以为同一类型多次实现 From只要输入类型 T 不同。 例如,可以为 WrappingU32 同时使用 u32u16 作为输入类型来实现 From

impl From<u32> for WrappingU32 {
    fn from(value: u32) -> Self {
        WrappingU32 { inner: value }
    }
}

impl From<u16> for WrappingU32 {
    fn from(value: u16) -> Self {
        WrappingU32 { inner: value.into() }
    }
}

这样行得通,是因为 From<u16>From<u32> 被视为不同的特质
不存在歧义:编译器可以根据被转换值的类型来确定使用哪一个实现。

案例分析:Add (Case study: Add)

作为收尾的例子,看看标准库中的 Add 特质:

pub trait Add<RHS = Self> {
    type Output;
    
    fn add(self, rhs: RHS) -> Self::Output;
}

它同时使用了两种机制:

  • 它有一个泛型参数 RHS(right-hand side,右操作数),默认为 Self
  • 它有一个关联类型 Output,表示加法结果的类型

RHS

RHS 是泛型参数,目的是允许不同类型相加。
例如,标准库中你能找到这两个实现:

impl Add<u32> for u32 {
    type Output = u32;
    
    fn add(self, rhs: u32) -> u32 {
      //                      ^^^
      // 这里也可以写成 `Self::Output`。
      // 编译器不在乎,只要你这里写的类型
      // 跟你上面赋给 `Output` 的类型匹配即可。
      // [...]
    }
}

impl Add<&u32> for u32 {
    type Output = u32;
    
    fn add(self, rhs: &u32) -> u32 {
        // [...]
    }
}

这让下面的代码可以编译通过:

let x = 5u32 + &5u32 + 6u32;

因为 u32 同时实现了 Add<&u32> 以及 Add<u32>

Output

Output 表示加法结果的类型。

为什么我们需要 Output?不能直接用 Self(即实现 Add 的类型)作为输出吗? 能是能,但会限制特质的灵活性。例如标准库中你会看到这个实现:

impl Add<&u32> for &u32 {
    type Output = u32;

    fn add(self, rhs: &u32) -> u32 {
        // [...]
    }
}

它把特质实现在 &u32 上,但加法的结果是 u32
如果 add 必须返回 Self(这里就是 &u32),那这个实现就不可能1Outputstd 把实现者类型与返回类型解耦,从而支持这种情形。

另一方面,Output 不能是泛型参数。一旦操作数的类型确定,加法的输出类型就必须唯一确定。这就是为什么它是关联类型:对给定的"实现者 + 泛型参数"组合,Output 类型只有一种。

总结 (Conclusion)

回顾一下:

  • 当类型必须由特定的特质实现唯一确定时,使用关联类型 (associated type)
  • 当你希望允许同一类型对该特质有多个实现(输入类型不同)时,使用泛型参数 (generic parameter)
1

灵活性很少是免费的:因为多了 Output,特质定义更复杂,实现者还得思考要返回什么。只有当这种灵活性确实需要时,这种权衡才划算。设计自己的特质时请记住这一点。

原文链接:英文原文

复制值,第一部分 (Copying values, pt. 1)

上一章我们引入了所有权 (ownership) 与借用 (borrowing)。
我们特别强调过:

  • Rust 中每个值在任意时刻只有一个所有者 (owner)。
  • 当函数获取一个值的所有权("消费 (consume) 它")时,调用方再也不能使用那个值。

这些限制有时颇为受限。
有时我们必须调用一个会拿走所有权的函数,但之后还想继续使用那个值。

fn consumer(s: String) { /* */ }

fn example() {
     let mut s = String::from("hello");
     consumer(s);
     s.push_str(", world!"); // error: value borrowed here after move
}

这就是 Clone 登场的时候。

Clone

Clone 是 Rust 标准库中定义的一个特质:

pub trait Clone {
    fn clone(&self) -> Self;
}

它的方法 clone 接受 self 的引用,返回一个新的、由调用方拥有 (owned) 的同类型实例。

实战 (In action)

回到上面的例子,我们可以在调用 consumer 之前用 clone 创建一个新的 String 实例:

fn consumer(s: String) { /* */ }

fn example() {
     let mut s = String::from("hello");
     let t = s.clone();
     consumer(t);
     s.push_str(", world!"); // 不再报错
}

我们不把 s 的所有权交给 consumer,而是创建一个新的 String(通过克隆 s),把它交给 consumer
sconsumer 调用之后仍然有效、仍然可用。

在内存中 (In memory)

我们看看上面例子在内存中发生了什么。 当 let mut s = String::from("hello"); 执行时,内存看起来是这样:

                    s
      +---------+--------+----------+
Stack | pointer | length | capacity | 
      |  |      |   5    |    5     |
      +--|------+--------+----------+
         |
         |
         v
       +---+---+---+---+---+
Heap:  | H | e | l | l | o |
       +---+---+---+---+---+

let t = s.clone() 执行时,会在堆上分配一整块新区域来存储数据的副本:

                    s                                    t
      +---------+--------+----------+      +---------+--------+----------+
Stack | pointer | length | capacity |      | pointer | length | capacity |
      |  |      |   5    |    5     |      |  |      |   5    |    5     |
      +--|------+--------+----------+      +--|------+--------+----------+
         |                                    |
         |                                    |
         v                                    v
       +---+---+---+---+---+                +---+---+---+---+---+
Heap:  | H | e | l | l | o |                | H | e | l | l | o |
       +---+---+---+---+---+                +---+---+---+---+---+

如果你来自像 Java 这样的语言,可以把 clone 想成创建对象深拷贝 (deep copy) 的方式。

实现 Clone (Implementing Clone)

要让一个类型可被 Clone,我们需要为它实现 Clone 特质。
通常情况下,你都会通过派生 (derive) 来实现 Clone

#[derive(Clone)]
struct MyType {
    // 字段
}

编译器会按你预期的方式为 MyType 实现 Clone:分别克隆 MyType 的每个字段,再用克隆出的字段构造一个新的 MyType 实例。
记得你可以用 cargo expand(或你的 IDE)来查看派生 (derive) 宏生成的代码。

原文链接:英文原文

复制值,第二部分 (Copying values, pt. 2)

让我们看与之前相同的例子,但稍作改动:用 u32 代替 String

fn consumer(s: u32) { /* */ }

fn example() {
     let s: u32 = 5;
     consumer(s);
     let t = s + 1;
}

它能编译通过,没有错误!这是怎么回事?Stringu32 之间有什么区别,让后者不需要 .clone() 就能用?

Copy

Copy 是 Rust 标准库中定义的另一个特质:

pub trait Copy: Clone { }

它是一个标记特质 (marker trait),跟 Sized 一样。

如果一个类型实现了 Copy,就不需要调用 .clone() 来创建新实例: Rust 会隐式 (implicitly) 替你完成。
u32 就是实现了 Copy 的类型,这就是为什么上面的例子能无错地编译: 当 consumer(s) 被调用时,Rust 通过对 s 进行逐位复制 (bitwise copy) 来创建一个新的 u32 实例,然后把这个新实例传给 consumer。这一切都在幕后发生,你什么都不用做。

什么样的类型可以 Copy?(What can be Copy?)

Copy 不等于"自动克隆 (automatic cloning)",但它确实意味着这一点。
类型必须满足若干条件,才被允许实现 Copy

首先,它必须实现 Clone,因为 CopyClone 的子特质。 这是合理的:如果 Rust 能够 隐式 创建该类型的新实例,那它也应当能通过调用 .clone()显式 创建。

但这还不够,还需要满足下面几个条件:

  1. 该类型不管理任何 额外的 资源(例如堆内存、文件句柄等),只占用 std::mem::size_of 那么多字节。
  2. 该类型不是可变引用 (&mut T)。

如果两个条件都满足,那么 Rust 就可以安全地通过对原实例进行逐位复制 (bitwise copy) 来创建一个新实例——这通常被称为 memcpy 操作(得名于 C 标准库中执行逐位复制的函数)。

案例分析 1:String (Case study 1: String)

String 不实现 Copy
为什么?因为它管理着额外资源:存储字符串数据的、堆上分配的内存缓冲区。

让我们假设 Rust 允许 String 实现 Copy
那当通过对原实例进行逐位复制创建新的 String 实例时,原实例和新实例会指向同一块内存缓冲区:

              s                                 copied_s
+---------+--------+----------+      +---------+--------+----------+
| pointer | length | capacity |      | pointer | length | capacity |
|  |      |   5    |    5     |      |  |      |   5    |    5     |
+--|------+--------+----------+      +--|------+--------+----------+
   |                                    |
   |                                    |
   v                                    |
 +---+---+---+---+---+                  |
 | H | e | l | l | o |                  |
 +---+---+---+---+---+                  |
   ^                                    |
   |                                    |
   +------------------------------------+

这是有害的! 两个 String 实例在离开作用域时都会尝试释放这同一块内存缓冲区,导致双重释放 (double-free) 错误。 你也可以创建两个不同的 &mut String 引用指向同一块内存缓冲区,这就违反了 Rust 的借用规则。

案例分析 2:u32 (Case study 2: u32)

u32 实现了 Copy。事实上所有整数类型都实现了。
整数"不过是"内存中表示该数字的那些字节,没有别的! 如果你复制这些字节,你就得到了另一个完全有效的整数实例。 不会出什么坏事,所以 Rust 允许它。

案例分析 3:&mut u32 (Case study 3: &mut u32)

引入所有权 (ownership) 和可变借用 (mutable borrow) 时,我们清晰地讲过一条规则:任意时刻一个值只能存在 一个 可变借用。
这就是为什么 &mut u32 不实现 Copy,即使 u32 实现了。

如果 &mut u32 实现了 Copy,你就能创建多个指向同一个值的可变引用,并在多个地方同时修改它。 那就违反了 Rust 的借用规则! 所以,无论 T 是什么,&mut T 都不实现 Copy

实现 Copy (Implementing Copy)

大多数情况下,你不需要手动实现 Copy。 直接派生即可,像这样:

#[derive(Copy, Clone)]
struct MyStruct {
    field: u32,
}

原文链接:英文原文

Drop 特质 (The Drop trait)

我们引入析构函数 (destructor)时提到过,drop 函数会:

  1. 回收类型所占用的内存(即 std::mem::size_of 那么多字节)
  2. 清理该值可能管理的任何额外资源(例如 String 的堆缓冲区)

第 2 步正是 Drop 特质登场的地方。

pub trait Drop {
    fn drop(&mut self);
}

Drop 特质给你一种机制,让你为自己的类型定义 额外的 清理逻辑——超出编译器自动替你做的那部分。
你放进 drop 方法里的代码会在值离开作用域时被执行。

DropCopy (Drop and Copy)

谈到 Copy 特质时我们说过:如果一个类型管理了超出 std::mem::size_of 字节的额外资源,它就不能实现 Copy

你可能会问:编译器怎么知道一个类型是否管理了额外资源?
对:通过 Drop 特质的实现!
如果你的类型有显式的 Drop 实现,编译器就会假定该类型附带了额外资源,从而不允许你实现 Copy

// 这是一个单元结构体 (unit struct),即没有字段的结构体。
#[derive(Clone, Copy)]
struct MyType;

impl Drop for MyType {
    fn drop(&mut self) {
       // 这里我们不需要做什么,
       // 有一个"空"的 Drop 实现就足够了
    }
}

编译器会用这条错误信息提出抗议:

error[E0184]: the trait `Copy` cannot be implemented for this type; 
              the type has a destructor
 --> src/lib.rs:2:17
  |
2 | #[derive(Clone, Copy)]
  |                 ^^^^ `Copy` not allowed on types with destructors

原文链接:英文原文

总结 (Wrapping up)

本章我们覆盖了相当多不同的特质 (trait)——而且我们只触及了表面! 你可能觉得有很多东西要记,但别担心:在写 Rust 代码时你会经常碰到这些特质,很快它们就会变成你的第二天性。

收尾思考 (Closing thoughts)

特质很强大,但不要滥用。
请记住下面几条指导原则:

  • 不要因为一时的便利就让函数泛型化——如果它总是只用一种类型来调用。这会在你的代码库里引入间接性,让代码更难理解和维护。
  • 如果你只有一个实现,就不要创建特质。这通常意味着你不需要这个特质。
  • 在合理时为自己的类型实现标准特质(DebugPartialEq 等)。 这能让你的类型更符合习惯用法,更易于使用,并解锁标准库及生态 crate 提供的大量功能。
  • 如果你需要某个第三方 crate 在它的生态中解锁的功能,就实现该 crate 的特质。
  • 警惕仅仅为了在测试中使用 mock 而把代码变成泛型的做法。这种方式的可维护性代价可能很高,通常更好的做法是采用不同的测试策略。详情可以查看测试 master class,了解高保真度测试。

检验你的知识 (Testing your knowledge)

继续向前之前,让我们再做最后一个练习来巩固所学。 这次你只会得到很少的指引——只有练习描述和测试可供参考。

原文链接:英文原文

工单建模,第二部分 (Modelling A Ticket, pt. 2)

我们在前几章打磨过的 Ticket 结构体是个不错的起点,但它仍然在大喊"我是个 Rust 新手 (Rustacean)!"。

我们用本章来打磨 Rust 领域建模 (domain modelling) 的能力。 途中我们还要引入几个新概念:

  • enum,Rust 在数据建模上最强大的特性之一
  • Option 类型,用于建模可空值 (nullable values)
  • Result 类型,用于建模可恢复错误 (recoverable errors)
  • DebugDisplay 特质,用于打印
  • Error 特质,用于标记错误类型
  • TryFromTryInto 特质,用于可能失败的转换 (fallible conversions)
  • Rust 的包系统 (package system):什么是库 (library)、什么是二进制 (binary)、如何使用第三方 crate

原文链接:英文原文

枚举 (Enumerations)

根据你在前一章写的验证逻辑,工单 (ticket) 实际上只有几个有效状态:To-DoInProgressDone
但当我们看 Ticket 结构体的 status 字段、或者 new 方法里 status 参数的类型时,这一点并不显而易见:

#[derive(Debug, PartialEq)]
pub struct Ticket {
    title: String,
    description: String,
    status: String,
}

impl Ticket {
    pub fn new(
        title: String, 
        description: String, 
        status: String
    ) -> Self {
        // [...]
    }
}

这两处我们都用 String 来表示 status 字段。 String 是个非常宽泛的类型——它没法立刻传达出"status 字段只能取有限几个值"这条信息。更糟的是,Ticket::new 的调用方只能在运行时才会发现自己提供的状态是否有效。

我们可以用枚举 (enumerations) 做得更好。

enum

枚举是一种可以取一组固定值的类型,每个值称为一个变体 (variant)
在 Rust 中,使用 enum 关键字定义枚举:

enum Status {
    ToDo,
    InProgress,
    Done,
}

enumstruct 一样,定义了一个新的 Rust 类型

原文链接:英文原文

match

你可能在想——拿到一个枚举之后,到底能什么?
最常见的操作就是对它进行 match(匹配)

enum Status {
    ToDo,
    InProgress,
    Done
}

impl Status {
    fn is_done(&self) -> bool {
        match self {
            Status::Done => true,
            // `|` 运算符让你可以同时匹配多个模式 (pattern)。
            // 它读作"`Status::ToDo` 或 `Status::InProgress`"。
            Status::InProgress | Status::ToDo => false
        }
    }
}

match 语句允许你把一个 Rust 值与一系列模式 (pattern) 进行比较。
你可以把它看作类型层面的 if:如果 statusDone 变体,执行第一个块;如果是 InProgressToDo 变体,执行第二个块。

穷尽性 (Exhaustiveness)

这里有个关键细节:match穷尽 (exhaustive) 的。你必须处理所有的枚举变体。
如果你忘了处理某个变体,Rust 会在编译期用一条错误信息拦下你。

例如,如果我们漏掉了 ToDo 变体:

match self {
    Status::Done => true,
    Status::InProgress => false,
}

编译器会抱怨:

error[E0004]: non-exhaustive patterns: `ToDo` not covered
 --> src/main.rs:5:9
  |
5 |     match status {
  |     ^^^^^^^^^^^^ pattern `ToDo` not covered

这是件大事!
代码库会随时间演化——以后你可能加入新的状态,例如 Blocked。Rust 编译器会对每一处缺失新变体处理逻辑的 match 语句报错。 这就是为什么 Rust 开发者常常赞美"编译器驱动的重构 (compiler-driven refactoring)"——编译器告诉你下一步要做什么,你只需要修复它指出的问题。

通配 (Catch-all)

如果你不关心其中一个或多个变体,可以用 _ 模式作为通配 (catch-all):

match status {
    Status::Done => true,
    _ => false
}

_ 模式会匹配任何之前的模式没匹配到的值。

使用通配模式时,你_失去_了编译器驱动重构带来的好处。 如果你新增一个枚举变体,编译器_不会_告诉你某处没处理它。

如果你看重正确性,避免使用通配。利用编译器去重新审视所有匹配点,决定该如何处理新的枚举变体。

原文链接:英文原文

变体可以携带数据 (Variants can hold data)

enum Status {
    ToDo,
    InProgress,
    Done,
}

我们的 Status 枚举属于通常所说的 C 风格枚举 (C-style enum)
每个变体只是一个简单的标签,类似于命名常量。这种枚举在很多编程语言中都能见到,例如 C、C++、Java、C#、Python 等。

不过 Rust 的枚举可以走得更远。我们可以为每个变体附加数据

变体 (Variants)

假设我们想存储正在处理某张工单的人的名字。
只有当工单处于"进行中"时我们才会有这条信息。待办或已完成的工单不会有。 我们可以通过给 InProgress 变体附加一个 String 字段来建模:

enum Status {
    ToDo,
    InProgress {
        assigned_to: String,
    },
    Done,
}

InProgress 现在是一个类结构体变体 (struct-like variant)
事实上它的语法和我们定义结构体时一致——只是被"内联"到枚举中作为一个变体。

访问变体数据 (Accessing variant data)

如果我们尝试在 Status 实例上访问 assigned_to

let status: Status = /* */;

// 这无法编译
println!("Assigned to: {}", status.assigned_to);

编译器会拦下我们:

error[E0609]: no field `assigned_to` on type `Status`
 --> src/main.rs:5:40
  |
5 |     println!("Assigned to: {}", status.assigned_to);
  |                                        ^^^^^^^^^^^ unknown field

assigned_to变体特有 (variant-specific) 的,并非所有 Status 实例都有它。
要访问 assigned_to,我们需要使用模式匹配 (pattern matching)

match status {
    Status::InProgress { assigned_to } => {
        println!("Assigned to: {}", assigned_to);
    },
    Status::ToDo | Status::Done => {
        println!("ToDo or Done");
    }
}

绑定 (Bindings)

在匹配模式 Status::InProgress { assigned_to } 中,assigned_to 是一个绑定 (binding)
我们正在解构 (destructure) Status::InProgress 变体,并把 assigned_to 字段绑定到一个同名的新变量上。
如果想要,也可以把字段绑定到一个不同的变量名:

match status {
    Status::InProgress { assigned_to: person } => {
        println!("Assigned to: {}", person);
    },
    Status::ToDo | Status::Done => {
        println!("ToDo or Done");
    }
}

原文链接:英文原文

简洁的分支 (Concise branching)

你对前一个练习的解决方案大概是这样:

impl Ticket {
    pub fn assigned_to(&self) -> &str {
        match &self.status {
            Status::InProgress { assigned_to } => assigned_to,
            Status::Done | Status::ToDo => {
                panic!(
                    "Only `In-Progress` tickets can be \
                    assigned to someone"
                )
            }
        }
    }
}

你只关心 Status::InProgress 这一个变体。 真的有必要把所有其他变体都列出来匹配吗?

新构造来救场!

if let

if let 构造允许你只匹配枚举的一个变体,而无需处理其他所有变体。

下面用 if let 简化 assigned_to 方法:

impl Ticket {
    pub fn assigned_to(&self) -> &str {
        if let Status::InProgress { assigned_to } = &self.status {
            assigned_to
        } else {
            panic!(
                "Only `In-Progress` tickets can be assigned to someone"
            );
        }
    }
}

let/else

如果 else 分支注定要提前返回(panic 也算提前返回!),可以使用 let/else 构造:

impl Ticket {
    pub fn assigned_to(&self) -> &str {
        let Status::InProgress { assigned_to } = &self.status else {
            panic!(
                "Only `In-Progress` tickets can be assigned to someone"
            );
        };
        assigned_to
    }
}

它让你在不引入"右漂移 (right drift)"的情况下完成解构后的赋值,即赋值与前面代码处于同一缩进层级。

风格 (Style)

if letlet/else 都是符合 Rust 习惯用法 (idiomatic) 的构造。
按你认为合适的方式使用它们来提升代码可读性,但别滥用:需要时 match 永远在那儿。

原文链接:英文原文

可空性 (Nullability)

我们对 assigned 方法的实现相当粗糙:对待办或已完成的工单直接 panic 并不理想。
我们可以用 Rust 的 Option 类型做得更好。

Option

Option 是 Rust 中表示可空值 (nullable values) 的类型。
它是一个枚举,定义在 Rust 标准库中:

enum Option<T> {
    Some(T),
    None,
}

Option 编码了"值可能存在 (Some(T)) 或不存在 (None)"的概念。
它还强制你显式处理两种情况。如果你处理一个可空值时忘了处理 None 的情形,会得到一个编译错误。
比起其他语言中"隐式"的可空性(你可以忘记检查 null,进而触发运行时错误),这是个显著进步。

Option 的定义 (Option's definition)

Option 的定义使用了一个你之前没见过的 Rust 构造:类元组变体 (tuple-like variants)

类元组变体 (Tuple-like variants)

Option 有两个变体:Some(T)None
Some 是一个类元组变体 (tuple-like variant):它持有未命名字段 (unnamed fields)

类元组变体常用于只需要存储单个字段的情形,特别是像 Option 这样的"包装 (wrapper)"类型。

类元组结构体 (Tuple-like structs)

类元组并不只属于枚举——你也可以定义类元组的结构体:

struct Point(i32, i32);

然后可以通过位置索引访问 Point 实例的两个字段:

let point = Point(3, 4);
let x = point.0;
let y = point.1;

元组 (Tuples)

我们还没见过元组,却在说"类元组",这有点奇怪!
元组是 Rust 原始类型 (primitive type) 的另一个例子。 它们将固定数量、可能不同类型的值组合在一起:

// 两个值,相同类型
let first: (i32, i32) = (3, 4);
// 三个值,不同类型
let second: (i32, u32, u8) = (-42, 3, 8);

语法很简单:把值的类型用逗号分隔列在括号里。 你可以用点号加字段索引访问元组的字段:

assert_eq!(second.0, -42);
assert_eq!(second.1, 3);
assert_eq!(second.2, 8);

当你不想专门定义一个结构体类型时,元组是把若干个值聚在一起的便捷方式。

原文链接:英文原文

可能失败 (Fallibility)

让我们重新审视前一个练习中的 Ticket::new 函数:

impl Ticket {
    pub fn new(
        title: String, 
        description: String, 
        status: Status
    ) -> Ticket {
        if title.is_empty() {
            panic!("Title cannot be empty");
        }
        if title.len() > 50 {
            panic!("Title cannot be longer than 50 bytes");
        }
        if description.is_empty() {
            panic!("Description cannot be empty");
        }
        if description.len() > 500 {
            panic!("Description cannot be longer than 500 bytes");
        }

        Ticket {
            title,
            description,
            status,
        }
    }
}

只要其中一项检查失败,函数就 panic。 这样并不理想,因为它没有给调用方处理错误 (handle the error) 的机会。

是时候介绍 Result 类型了——Rust 用来处理错误的主要机制。

Result 类型 (The Result type)

Result 类型是定义在标准库中的枚举:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

它有两个变体:

  • Ok(T):表示操作成功。携带 T,即操作的输出。
  • Err(E):表示操作失败。携带 E,即发生的错误。

OkErr 都是泛型的,让你可以为成功和失败两种情况分别指定自己的类型。

没有异常 (No exceptions)

Rust 中的可恢复错误 (recoverable errors) 被表示为值 (represented as values)
它们就是某个类型的实例,被传递、被操作,跟其他任何值一样。 这与 Python、C# 等使用异常 (exception) 来发出错误信号的语言有显著区别。

异常会创建一条独立的控制流路径,难以推理。
仅看一个函数的签名,你不知道它是否会抛出异常。 仅看签名,你不知道它会抛出哪种异常。
你必须读文档或看实现才能弄清楚。

异常处理逻辑的局部性 (locality) 很差:抛异常的代码跟捕异常的代码相距甚远,二者之间没有直接的关联。

可能失败被编码到类型系统中 (Fallibility is encoded in the type system)

Rust 用 Result 强制你把可能失败编码进函数签名
如果一个函数可能失败(且你希望调用方有机会处理错误),它必须返回 Result

// 仅看签名你就知道这个函数可能失败。
// 你也可以查看 `ParseIntError` 看会出现什么类型的失败。
fn parse_int(s: &str) -> Result<i32, ParseIntError> {
    // ...
}

这就是 Result 的最大优势:让可能失败显式化。

不过请记住 panic 是存在的。它跟其他语言的异常一样,不被类型系统跟踪。 但它们是为不可恢复错误 (unrecoverable errors) 准备的,应当谨慎使用。

原文链接:英文原文

解包 (Unwrapping)

Ticket::new 现在在收到无效输入时返回 Result 而不是 panic。
这对调用方意味着什么?

失败不能被(隐式)忽略 (Failures can't be (implicitly) ignored)

与异常 (exception) 不同,Rust 的 Result 强制你在调用点 (call site) 处理错误
如果你调用一个返回 Result 的函数,Rust 不允许你隐式忽略错误情况。

fn parse_int(s: &str) -> Result<i32, ParseIntError> {
    // ...
}

// 这无法编译:我们没处理错误情况。
// 我们必须使用 `match`,或者 `Result` 提供的某个组合子 (combinator)
// 来"解包"成功值或者处理错误。
let number = parse_int("42") + 2;

你拿到了 Result,然后呢?(You got a Result. Now what?)

调用一个返回 Result 的函数后,你有两个关键选项:

  • 操作失败时直接 panic。 通过 unwrapexpect 方法实现:
    // 如果 `parse_int` 返回 `Err`,则 panic。
    let number = parse_int("42").unwrap();
    // `expect` 让你指定自定义的 panic 消息。
    let number = parse_int("42").expect("Failed to parse integer");
  • match 表达式解构 Result,显式处理错误情况。
    match parse_int("42") {
        Ok(number) => println!("Parsed number: {}", number),
        Err(err) => eprintln!("Error: {}", err),
    }

原文链接:英文原文

错误枚举 (Error enums)

你在前一个练习里的解决方案可能感觉有点别扭:基于字符串去 match 不太理想!
某位同事重做了 Ticket::new 返回的错误信息(例如为了改善可读性),你的调用方代码立刻就崩了。

你已经知道修复这点所需的机制:枚举!

对错误做出反应 (Reacting to errors)

当你想让调用方根据具体发生的错误做出不同行为时,可以用枚举来表示不同的错误情况:

// 一个错误枚举,表示从字符串解析 `u32` 时
// 可能出现的不同错误情况。
enum U32ParseError {
    NotANumber,
    TooLarge,
    Negative,
}

通过错误枚举,你把不同错误情况编码进了类型系统——它们成为可能失败函数签名的一部分。
这能简化调用方的错误处理:他们可以用 match 表达式对不同错误情况作出反应:

match s.parse_u32() {
    Ok(n) => n,
    Err(U32ParseError::Negative) => 0,
    Err(U32ParseError::TooLarge) => u32::MAX,
    Err(U32ParseError::NotANumber) => {
        panic!("Not a number: {}", s);
    }
}

原文链接:英文原文

Error 特质 (Error trait)

错误报告 (Error reporting)

在前一个练习中,你需要解构 TitleError 变体来取出错误消息,并把它传给 panic! 宏。
这是一个(粗糙的)错误报告 (error reporting) 例子:把错误类型转换成可以呈现给用户、运维人员或开发者的表示。

让每个 Rust 开发者都各自琢磨一套错误报告策略并不实际:既浪费时间、跨项目时也不能很好地组合。 所以 Rust 提供了 std::error::Error 特质。

Error 特质 (The Error trait)

ResultErr 变体的类型没有限制,但好的实践是使用一个实现了 Error 特质的类型。 Error 是 Rust 错误处理体系的基石:

// `Error` 特质的略简化定义
pub trait Error: Debug + Display {}

你可能还记得来自 From 特质: 语法——它用来指定父特质 (supertrait)。 对 Error 来说,父特质有两个:DebugDisplay。一个类型若想实现 Error,就也必须实现 DebugDisplay

DisplayDebug

我们在前一个练习中已经接触过 Debug 特质——assert_eq! 在断言失败时用它来显示比较的变量值。

从"机制"上讲,DisplayDebug 是相同的——它们编码了一个类型应该怎么转换为类似字符串的表示:

// `Debug`
pub trait Debug {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>;
}

// `Display`
pub trait Display {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>;
}

差别在于 目的Display 返回面向"终端用户 (end-users)"的表示, 而 Debug 提供更适合开发者和运维的低层表示。
这就是为什么 Debug 可以用 #[derive(Debug)] 属性自动实现,而 Display必须手动实现。

原文链接:英文原文

库与二进制 (Libraries and binaries)

TicketNewError 实现 Error 特质用的代码不算少吧?
要手写 Display 实现,加上一个 Error impl 块。

我们可以用 thiserror 减少这些样板代码。它是一个 Rust crate,提供过程宏 (procedural macro) 来简化自定义错误类型的创建。
不过我们走得有点超前:thiserror 是第三方 crate,将是我们的第一个依赖!

在深入依赖之前,让我们退一步谈谈 Rust 的包系统 (packaging system)。

什么是包?(What is a package?)

Rust 的包由 Cargo.toml 文件中的 [package] 段定义,Cargo.toml 也称为它的清单 (manifest)。 在 [package] 里你可以设置包的元数据,例如名称和版本。

去看看本节练习目录里的 Cargo.toml 文件!

什么是 crate?(What is a crate?)

在一个包内,你可以有一个或多个 crate,也叫目标 (target)
最常见的两种 crate 类型是二进制 crate (binary crate)库 crate (library crate)

二进制 (Binaries)

二进制是一个可以编译成可执行文件 (executable file) 的程序。
它必须包含一个名为 main 的函数——程序的入口点 (entry point)。main 在程序被执行时被调用。

库 (Libraries)

而库本身不可执行。你不能 运行 一个库,但可以从依赖它的包里 导入它的代码
库把代码(即函数、类型等)聚在一起,可被其他包作为依赖 (dependency) 使用。

到目前为止你解过的所有练习都是按库的形式组织的,并附带一套测试用例。

约定 (Conventions)

围绕 Rust 包有一些约定要记住:

  • 包的源代码通常放在 src 目录下。
  • 如果有 src/lib.rs 文件,cargo 会推断这个包包含一个库 crate。
  • 如果有 src/main.rs 文件,cargo 会推断这个包包含一个二进制 crate。

你可以在 Cargo.toml 中显式声明 target 来覆盖这些默认值——更多细节参见 cargo 的文档

记住:一个包可以包含多个 crate,但只能包含一个库 crate。

原文链接:英文原文

依赖 (Dependencies)

一个包可以通过在 Cargo.toml 文件的 [dependencies] 段列出其他包来依赖它们。
最常见的依赖声明方式是提供名称和版本:

[dependencies]
thiserror = "1"

这会把 thiserror 作为依赖加入你的包,最低版本为 1.0.0thiserror 会从 crates.io 拉取——这是 Rust 官方包注册中心。 当你运行 cargo build 时,cargo 会经过几个阶段:

  • 依赖解析 (Dependency resolution)
  • 下载依赖 (Downloading the dependencies)
  • 编译你的项目(你的代码与所有依赖)

如果你的项目有 Cargo.lock 文件且 manifest 文件没有变化,依赖解析会被跳过。 锁文件 (lockfile) 在一次成功的依赖解析后由 cargo 自动生成:它包含项目所用所有依赖的精确版本,确保不同构建(例如 CI)之间使用一致的版本。如果你和多个开发者协作,应当把 Cargo.lock 文件提交到版本控制系统。

你可以用 cargo updateCargo.lock 更新为所有依赖的最新(兼容)版本。

路径依赖 (Path dependencies)

你也可以用路径 (path) 指定依赖。这在你同时开发多个本地包时很有用。

[dependencies]
my-library = { path = "../my-library" }

路径相对于声明依赖的那个包的 Cargo.toml 文件。

其他来源 (Other sources)

更多关于依赖来源以及如何在 Cargo.toml 中指定它们的细节,请查看 Cargo 文档

开发依赖 (Dev dependencies)

你也可以声明仅在开发时需要的依赖——它们只会在你执行 cargo test 时被拉入。
这些依赖放在 Cargo.toml[dev-dependencies] 段:

[dev-dependencies]
static_assertions = "1.1.0"

我们在本书中已经用了几个这类依赖来缩短测试代码。

原文链接:英文原文

thiserror

绕了点路,对吧?但是值得!
回到正题:自定义错误类型与 thiserror

自定义错误类型 (Custom error types)

我们已经看过如何为自定义错误类型"手动"实现 Error 特质。
想象一下你要给代码库里的大多数错误类型都做一遍——那是不少样板代码 (boilerplate),对吧?

我们可以用 thiserror 减少一部分样板。它是一个 Rust crate,提供过程宏 (procedural macro) 来简化自定义错误类型的创建。

#[derive(thiserror::Error, Debug)]
enum TicketNewError {
    #[error("{0}")]
    TitleError(String),
    #[error("{0}")]
    DescriptionError(String),
}

你也可以写自己的宏 (You can write your own macros)

到目前为止我们见过的所有 derive 宏都来自 Rust 标准库。
thiserror::Error 是我们见到的第一个第三方 derive 宏。

derive 宏是过程宏 (procedural macro) 的子集——一种在编译期生成 Rust 代码的方式。 本课程不会深入讲过程宏怎么写,但要知道你可以写自己的宏!
这是一个适合在更进阶的 Rust 课程中讨论的话题。

自定义语法 (Custom syntax)

每个过程宏都可以定义自己的语法,这通常会在 crate 文档中说明。 thiserror 这边:

  • #[derive(thiserror::Error)]:借助 thiserror 为自定义错误类型派生 Error 特质的语法。
  • #[error("{0}")]:为自定义错误类型每个变体定义 Display 实现的语法。 {0} 在显示错误时会被该变体第 0 号字段(这里是 String)替换。

原文链接:英文原文

TryFromTryInto

上一章我们看过 FromInto 特质—— Rust 中处理不会失败 (infallible) 类型转换的习惯接口。
但如果转换不一定成功呢?

我们对错误已经了解得够多,可以来看 FromInto可能失败 (fallible) 对应特质:TryFromTryInto

TryFromTryInto

TryFromTryInto 都定义在 std::convert 模块下,跟 FromInto 一样:

pub trait TryFrom<T>: Sized {
    type Error;
    fn try_from(value: T) -> Result<Self, Self::Error>;
}

pub trait TryInto<T>: Sized {
    type Error;
    fn try_into(self) -> Result<T, Self::Error>;
}

From/IntoTryFrom/TryInto 的主要区别在于后者返回 Result 类型。
这允许转换失败,返回错误而不是 panic。

Self::Error

TryFromTryInto 都有一个关联类型 Error。 这允许每个实现指定自己的错误类型,理想情况下选择最适合该转换的错误类型。

Self::Error 是一种引用特质本身定义的关联类型 Error 的方式。

对偶性 (Duality)

FromInto 一样,TryFromTryInto 是对偶特质。
如果你为某个类型实现了 TryFrom,就免费得到 TryInto

原文链接:英文原文

Error::source

要完整覆盖 Error 特质,还有一件事需要谈:source 方法。

// 这次是完整定义!
pub trait Error: Debug + Display {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        None
    }
}

source 方法是用来访问错误成因 (error cause) 的方式(如果有的话)。
错误经常是链式的,意思是一个错误是另一个错误的成因:你有一个高层错误(例如:无法连接到数据库),它由更低层的错误(例如:无法解析数据库主机名)引起。 source 方法允许你"遍历 (walk)"整条错误链,常用于在日志中捕获错误上下文。

实现 source (Implementing source)

Error 特质提供了一个默认实现,总是返回 None(即没有底层成因)。这就是为什么在前面练习中你不必关心 source
你可以覆盖默认实现,为你的错误类型提供成因。

use std::error::Error;

#[derive(Debug)]
struct DatabaseError {
    source: std::io::Error
}

impl std::fmt::Display for DatabaseError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "Failed to connect to the database")
    }
}

impl std::error::Error for DatabaseError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        Some(&self.source)
    }
}

这个例子里,DatabaseErrorstd::io::Error 作为它的 source 包装起来。 我们覆盖 source 方法,在被调用时返回这个 source。

&(dyn Error + 'static)

这个 &(dyn Error + 'static) 类型是什么?
我们逐个拆开:

  • dyn Error 是一个特质对象 (trait object)。它是引用任何实现了 Error 特质的类型的方式。
  • 'static 是一个特殊的生命周期标注 (lifetime specifier)'static 意味着这个引用"在我们需要它的时间内一直有效",即整个程序运行期。

合起来:&(dyn Error + 'static) 是一个对实现了 Error 特质的特质对象的引用,且在整个程序运行期内都有效。

这两个概念现在不必太担心。我们会在后面的章节中更详细地介绍它们。

thiserror 实现 source (Implementing source using thiserror)

thiserror 提供了三种自动为错误类型实现 source 的方式:

  • 名为 source 的字段会自动被用作错误的 source。
    use thiserror::Error;
    
    #[derive(Error, Debug)]
    pub enum MyError {
        #[error("Failed to connect to the database")]
        DatabaseError {
            source: std::io::Error
        }
    }
  • 标注了 #[source] 属性的字段会自动被用作错误的 source。
    use thiserror::Error;
    
    #[derive(Error, Debug)]
    pub enum MyError {
        #[error("Failed to connect to the database")]
        DatabaseError {
            #[source]
            inner: std::io::Error
        }
    }
  • 标注了 #[from] 属性的字段会自动被用作错误的 source,并且 thiserror 会自动生成一个 From 实现,把被标注类型转换为你的错误类型。
    use thiserror::Error;
    
    #[derive(Error, Debug)]
    pub enum MyError {
        #[error("Failed to connect to the database")]
        DatabaseError {
            #[from]
            inner: std::io::Error
        }
    }

? 运算符 (The ? operator)

? 运算符是错误传播 (error propagation) 的简写。
当用在返回 Result 的函数中时,如果 ResultErr,它会提前返回该错误。

例如:

use std::fs::File;

fn read_file() -> Result<String, std::io::Error> {
    let mut file = File::open("file.txt")?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

等价于:

use std::fs::File;

fn read_file() -> Result<String, std::io::Error> {
    let mut file = match File::open("file.txt") {
        Ok(file) => file,
        Err(e) => {
            return Err(e);
        }
    };
    let mut contents = String::new();
    match file.read_to_string(&mut contents) {
        Ok(_) => (),
        Err(e) => {
            return Err(e);
        }
    }
    Ok(contents)
}

? 运算符可以大幅缩短你的错误处理代码。
特别地,如果存在合适的 From 实现,? 运算符会自动把可能失败操作的错误类型转换为函数的错误类型。

原文链接:英文原文

总结 (Wrapping up)

谈到领域建模 (domain modelling),魔鬼藏在细节里。
Rust 提供了广泛的工具,帮助你把领域中的约束直接表达在类型系统中,但要做对、写出符合习惯用法 (idiomatic) 的代码,还需要一些练习。

让我们用对 Ticket 模型的最后一次打磨来收尾本章。
我们会为 Ticket 的每个字段引入一个新类型,把对应约束封装起来。
每次有人访问 Ticket 字段时,得到的值都保证是有效的——例如得到的是 TicketTitle 而不是 String。代码其他地方就不必担心标题为空了:只要他们手上有 TicketTitle,他们就知道它按构造 (by construction) 就是有效的。

这只是利用 Rust 类型系统让代码更安全、更具表达力的一个例子。

进一步阅读

原文链接:英文原文

引入 (Intro)

上一章我们在真空中对 Ticket 进行建模:定义了字段及其约束,学习了如何在 Rust 中尽量好地表达它们,但没有考虑 Ticket 在更大系统中的位置。 本章我们要围绕 Ticket 构建一个简单的工作流,引入一个(粗糙的)管理系统来存储和检索工单。

任务会让我们有机会探索新的 Rust 概念,例如:

  • 栈分配的数组 (Stack-allocated arrays)
  • Vec,可增长的数组类型
  • IteratorIntoIterator,用于在集合上迭代
  • 切片 (slice) &[T],用于处理集合的一部分
  • 生命周期 (lifetime),用于描述引用有效多久
  • HashMapBTreeMap,两个键值数据结构
  • EqHash,用于在 HashMap 中比较键
  • OrdPartialOrd,用于操作 BTreeMap
  • IndexIndexMut,用于访问集合中的元素

原文链接:英文原文

数组 (Arrays)

一谈到"工单管理 (ticket management)",我们就要想办法存储 多张 工单。 这又意味着我们需要思考集合 (collection)。具体来说,是同质 (homogeneous) 集合: 我们想存储同一类型的多个实例。

Rust 在这方面提供了什么?

数组 (Arrays)

第一种尝试是使用数组 (array)
Rust 中的数组是相同类型元素的固定大小集合。

下面是数组的定义方式:

// 数组类型语法:[ <类型> ; <元素数量> ]
let numbers: [u32; 3] = [1, 2, 3];

这创建了一个由 3 个整数组成的数组,初始化为 123
该数组的类型是 [u32; 3],读作"长度为 3 的 u32 数组"。

如果数组所有元素相同,可以用更短的语法初始化:

// [ <值> ; <元素数量> ]
let numbers: [u32; 3] = [1; 3];

[1; 3] 创建一个三元素数组,元素全为 1

访问元素 (Accessing elements)

可以用方括号访问数组元素:

let first = numbers[0];
let second = numbers[1];
let third = numbers[2];

索引必须是 usize 类型。
跟 Rust 中其他东西一样,数组是从 0 开始计索引 (zero-indexed) 的。你之前在字符串切片以及元组/类元组变体的字段索引中见过这点。

越界访问 (Out-of-bounds access)

如果你尝试访问越界的元素,Rust 会 panic:

let numbers: [u32; 3] = [1, 2, 3];
let fourth = numbers[3]; // 这里会 panic

这是通过运行时的边界检查 (bounds checking) 强制执行的。它会带来少量性能开销,但这就是 Rust 防止缓冲区溢出 (buffer overflow) 的方式。
某些情况下 Rust 编译器可以把边界检查优化掉,特别是涉及迭代器时——这点稍后会再细谈。

如果不想 panic,可以用 get 方法,它返回 Option<&T>

let numbers: [u32; 3] = [1, 2, 3];
assert_eq!(numbers.get(0), Some(&1));
// 越界时返回 `None`,
// 而不是 panic。
assert_eq!(numbers.get(3), None);

性能 (Performance)

由于数组的大小在编译期已知,编译器可以把数组分配在栈上。 如果你运行下面的代码:

let numbers: [u32; 3] = [1, 2, 3];

会得到这样的内存布局:

        +---+---+---+
Stack:  | 1 | 2 | 3 |
        +---+---+---+

换言之,数组的大小是 std::mem::size_of::<T>() * N,其中 T 是元素类型,N 是元素数量。
你可以以 O(1) 时间访问和替换每个元素。

原文链接:英文原文

向量 (Vectors)

数组的优点也是它的弱点:大小必须在编译期就已知。 如果你尝试创建一个大小只有运行时才能知道的数组,会得到编译错误:

let n = 10;
let numbers: [u32; n];
error[E0435]: attempt to use a non-constant value in a constant
 --> src/main.rs:3:20
  |
2 | let n = 10;
3 | let numbers: [u32; n];
  |                    ^ non-constant value

数组无法满足我们的工单管理系统——我们在编译期不知道要存多少工单。 这就是 Vec 出场的时候。

Vec

Vec 是标准库提供的可增长数组类型。
你可以用 Vec::new 函数创建一个空数组:

let mut numbers: Vec<u32> = Vec::new();

然后用 push 方法把元素压入向量:

numbers.push(1);
numbers.push(2);
numbers.push(3);

新值会被加到向量末尾。
如果你在创建时就知道值,也可以用 vec! 宏创建一个已初始化的向量:

let numbers = vec![1, 2, 3];

访问元素 (Accessing elements)

访问元素的语法跟数组相同:

let numbers = vec![1, 2, 3];
let first = numbers[0];
let second = numbers[1];
let third = numbers[2];

索引必须是 usize 类型。
也可以用 get 方法,返回 Option<&T>

let numbers = vec![1, 2, 3];
assert_eq!(numbers.get(0), Some(&1));
// 越界时返回 `None`,
// 而不是 panic。
assert_eq!(numbers.get(3), None);

访问会做边界检查,跟数组的元素访问一样,复杂度是 O(1)。

内存布局 (Memory layout)

Vec 是堆分配 (heap-allocated) 的数据结构。
你创建 Vec 时,它在堆上分配内存来存储元素。

如果你运行下面的代码:

let mut numbers = Vec::with_capacity(3);
numbers.push(1);
numbers.push(2);

会得到这样的内存布局:

      +---------+--------+----------+
Stack | pointer | length | capacity | 
      |  |      |   2    |    3     |
      +--|------+--------+----------+
         |
         |
         v
       +---+---+---+
Heap:  | 1 | 2 | ? |
       +---+---+---+

Vec 跟踪三件事:

  • 指向你在堆上预留区域的指针 (pointer)
  • 向量的长度 (length),即向量中有多少元素。
  • 向量的容量 (capacity),即在堆上预留的空间能装多少元素。

这种布局看起来应当很眼熟:和 String 完全一样!
这不是巧合:String 在底层就是定义为字节向量 Vec<u8>

pub struct String {
    vec: Vec<u8>,
}

原文链接:英文原文

调整大小 (Resizing)

我们说 Vec 是"可增长"的向量类型,但这究竟意味着什么? 如果你尝试向已经达到最大容量的 Vec 中插入元素,会发生什么?

let mut numbers = Vec::with_capacity(3);
numbers.push(1);
numbers.push(2);
numbers.push(3); // 已达最大容量
numbers.push(4); // 这里会发生什么?

Vec调整自身大小 (resize)
它会向分配器请求一块新的(更大的)堆内存,把元素复制过去,然后释放旧内存。

这个操作可能很昂贵,因为它涉及一次新的内存分配以及把所有现有元素复制过去。

Vec::with_capacity

如果你大致知道要在 Vec 中存多少元素,可以用 Vec::with_capacity 方法预先分配足够的内存。
这能避免 Vec 增长时的一次新分配,但如果你高估了实际用量,也会浪费内存。

依个案权衡。

原文链接:英文原文

迭代 (Iteration)

最早的练习里,你已经了解到 Rust 允许你用 for 循环遍历集合。 当时我们看的是范围(例如 0..5),但同样的规则也适用于像数组和向量这样的集合。

// 适用于 `Vec`
let v = vec![1, 2, 3];
for n in v {
    println!("{}", n);
}

// 也适用于数组
let a: [u32; 3] = [1, 2, 3];
for n in a {
    println!("{}", n);
}

是时候了解它在底层是如何工作的了。

for 的脱糖 (for desugaring)

每当你在 Rust 里写 for 循环时,编译器会把它 脱糖 (desugar) 成下面这样的代码:

let mut iter = IntoIterator::into_iter(v);
loop {
    match iter.next() {
        Some(n) => {
            println!("{}", n);
        }
        None => break,
    }
}

loop 是除 forwhile 之外的另一种循环构造。
loop 块会无限运行,除非你显式 break

Iterator 特质 (Iterator trait)

前面代码片段中的 next 方法来自 Iterator 特质。 Iterator 特质定义在 Rust 标准库中,为能够产生一系列值的类型提供共享接口:

trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

Item 关联类型 (associated type) 指定了迭代器产生的值的类型。

next 返回序列中的下一个值。
如果有值可返回则返回 Some(value),没有则返回 None

注意:迭代器返回 None 不保证它已经耗尽。这只在迭代器实现了(更严格的)FusedIterator 特质时才能保证。

IntoIterator 特质 (IntoIterator trait)

并不是所有类型都实现了 Iterator,但很多类型可以转换成实现了 Iterator 的类型。
这就是 IntoIterator 特质的用武之地:

trait IntoIterator {
    type Item;
    type IntoIter: Iterator<Item = Self::Item>;
    fn into_iter(self) -> Self::IntoIter;
}

into_iter 方法消费原值,返回它的元素上的迭代器。
一个类型只能有一个 IntoIterator 实现:for 该脱糖成什么不能有歧义。

一个细节:每个实现了 Iterator 的类型也自动实现了 IntoIterator。 它们的 into_iter 直接返回自身!

边界检查 (Bounds checks)

迭代有一个不错的副作用:按设计你不会越界。
这允许 Rust 在生成的机器码中去掉边界检查,使迭代更快。

换句话说,

let v = vec![1, 2, 3];
for n in v {
    println!("{}", n);
}

通常比

let v = vec![1, 2, 3];
for i in 0..v.len() {
    println!("{}", v[i]);
}

更快。

这条规则有例外:编译器有时能证明你没有越界,即使是手动索引也能去掉边界检查。但总的来说,能用迭代就别用索引。

原文链接:英文原文

.iter()

IntoIterator消费 (consume) self 来创建迭代器。

这有它的好处:你从迭代器拿到的是拥有所有权 (owned) 的值。 例如:在 Vec<Ticket> 上调用 .into_iter() 会得到一个返回 Ticket 值的迭代器。

这也有缺点:调用 .into_iter() 之后你就不能再使用原集合了。 很多时候你想在不消费集合的前提下遍历它,看到值的引用 (reference)。 对 Vec<Ticket> 来说,你想要的是遍历 &Ticket 值。

大多数集合都暴露了一个 .iter() 方法,返回对集合元素引用的迭代器。 例如:

let numbers: Vec<u32> = vec![1, 2];
// 这里 `n` 的类型是 `&u32`
for n in numbers.iter() {
    // [...]
}

这种模式可以通过为对集合的引用实现 IntoIterator 来简化。 上面的例子中就是 &Vec<Ticket>
标准库这样做了,所以下面的代码可以工作:

let numbers: Vec<u32> = vec![1, 2];
// 这里 `n` 的类型是 `&u32`
// 我们没有显式调用 `.iter()`
// 在 `for` 循环中使用 `&numbers` 就够了
for n in &numbers {
    // [...]
}

习惯上同时提供两种方式:

  • 为对集合的引用实现 IntoIterator
  • 一个返回对集合元素引用的迭代器的 .iter() 方法。

前者在 for 循环里方便,后者更显式,可以在其他场景使用。

原文链接:英文原文

生命周期 (Lifetimes)

为了在 for 循环中获得最大便利,我们尝试通过为 &TicketStore 实现 IntoIterator 来完成前一个练习。

先填上实现中最"显而易见"的部分:

impl IntoIterator for &TicketStore {
    type Item = &Ticket;
    type IntoIter = // 这里写什么?

    fn into_iter(self) -> Self::IntoIter {
        self.tickets.iter()
    }
}

type IntoIter 应当是什么?
直觉上,应当是 self.tickets.iter() 返回的类型,也就是 Vec::iter() 返回的类型。
查看标准库文档可以发现 Vec::iter() 返回 std::slice::IterIter 的定义是:

pub struct Iter<'a, T> { /* 字段省略 */ }

'a 是一个生命周期参数 (lifetime parameter)

生命周期参数 (Lifetime parameters)

生命周期 (lifetime) 是 Rust 编译器用来跟踪某个引用(不论可变还是不可变)有效多久的标签 (label)
引用的生命周期受它所引用的值的作用域 (scope) 约束。Rust 总是在编译期确保引用不会在它所指向的值被丢弃之后再被使用,从而避免悬垂指针 (dangling pointer) 与释放后使用 (use-after-free) bug。

这听起来应该很熟悉:我们在讨论所有权与借用时已经看过这些概念在起作用。 生命周期只是一种命名 (name) 某个引用有效多久的方式。

当你有多个引用、需要明确它们之间的关系时,命名变得重要。 我们看看 Vec::iter() 的签名:

impl <T> Vec<T> {
    // 略简化
    pub fn iter<'a>(&'a self) -> Iter<'a, T> {
        // [...]
    }
}

Vec::iter() 是一个对生命周期参数 'a 泛型的函数。
'a 用于把 Vec 的生命周期与 iter() 返回的 Iter 的生命周期绑定 (tie together) 起来。 用大白话说:iter() 返回的 Iter 不能比创建它的 Vec 引用 (&self) 活得更久。

这一点很重要,因为正如我们讨论过的,Vec::iter 返回的迭代器是对 Vec 元素引用的迭代器。 如果 Vec 被丢弃,迭代器返回的引用就失效了。Rust 必须确保这不会发生,生命周期就是它用来强制执行这条规则的工具。

生命周期省略 (Lifetime elision)

Rust 有一组生命周期省略规则 (lifetime elision rules),允许你在很多场合省略显式的生命周期标注。 例如,Vec::iterstd 源码中的定义是这样的:

impl <T> Vec<T> {
    pub fn iter(&self) -> Iter<'_, T> {
        // [...]
    }
}

Vec::iter() 的签名中没有出现显式的生命周期参数。 省略规则意味着 iter() 返回的 Iter 的生命周期与 &self 引用的生命周期绑定。 你可以把 '_ 看作 &self 引用生命周期的占位符 (placeholder)

参见参考资料 (References) 一节获取生命周期省略的官方文档链接。
大多数情况下,你可以依赖编译器在你需要添加显式生命周期标注时给出提示。

参考资料 (References)

原文链接:英文原文

组合子 (Combinators)

迭代器能做的远不止 for 循环!
查看 Iterator 特质的文档,你会发现一个庞大的方法集合,可用于以各种方式变换、过滤、组合迭代器。

我们说说最常见的几个:

  • map:对迭代器的每个元素应用一个函数。
  • filter:只保留满足某谓词 (predicate) 的元素。
  • filter_map:一步完成 filtermap
  • cloned:把对引用的迭代器转换为值的迭代器,对每个元素进行克隆 (clone)。
  • enumerate:返回一个产生 (index, value) 对的新迭代器。
  • skip:跳过迭代器的前 n 个元素。
  • take:在 n 个元素后停止迭代器。
  • chain:把两个迭代器合为一个。

这些方法被称作组合子 (combinator)
通常链式 (chained) 调用,从而以简洁可读的方式构造复杂变换:

let numbers = vec![1, 2, 3, 4, 5];
// 偶数平方和
let outcome: u32 = numbers.iter()
    .filter(|&n| n % 2 == 0)
    .map(|&n| n * n)
    .sum();

闭包 (Closures)

上面的 filtermap 方法在做什么?
它们以闭包 (closure) 作为参数。

闭包是匿名函数 (anonymous function),即不通过我们熟悉的 fn 语法定义的函数。
它们用 |args| body 语法定义,args 是参数,body 是函数体。 body 可以是一段代码块或单个表达式。 例如:

// 一个匿名函数,把参数加 1
let add_one = |x| x + 1;
// 也可以用代码块写:
let add_one = |x| { x + 1 };

闭包可以接收多个参数:

let add = |x, y| x + y;
let sum = add(1, 2);

它们也能从环境中捕获 (capture) 变量:

let x = 42;
let add_x = |y| x + y;
let sum = add_x(1);

如有需要,可以指定参数和/或返回类型:

// 只标注输入类型
let add_one = |x: i32| x + 1;
// 也可以使用 `fn` 语法标注输入和输出
let add_one: fn(i32) -> i32 = |x| x + 1;

collect

用组合子变换完一个迭代器之后,要做什么?
你要么用 for 循环遍历变换后的值,要么把它们收集到一个集合里。

后者是用 collect 方法完成。
collect 消费迭代器,把它的元素收集到你选择的集合里。

例如,可以把偶数的平方收集到 Vec 中:

let numbers = vec![1, 2, 3, 4, 5];
let squares_of_evens: Vec<u32> = numbers.iter()
    .filter(|&n| n % 2 == 0)
    .map(|&n| n * n)
    .collect();

collect 对其返回类型 (return type) 是泛型的。
所以你通常需要给出类型提示来帮编译器推断正确类型。 上面的例子中,我们把 squares_of_evens 的类型标注为 Vec<u32>。 或者你也可以用鱼叉语法 (turbofish syntax) 来指定类型:

let squares_of_evens = numbers.iter()
    .filter(|&n| n % 2 == 0)
    .map(|&n| n * n)
    // 鱼叉语法:`<方法名>::<类型>()`
    // 之所以叫 turbofish,是因为 `::<>` 看起来像一条鱼
    .collect::<Vec<u32>>();

进一步阅读

原文链接:英文原文

impl Trait

TicketStore::to_dos 返回 Vec<&Ticket>
这个签名意味着每次调用 to_dos 都会引入一次新的堆分配——这有时是不必要的,取决于调用方对结果做什么。 如果 to_dos 返回迭代器而非 Vec 会更好,让调用方自行决定要不要把结果收集到 Vec 里、还是直接遍历。

但这有点棘手! 下面这段实现里 to_dos 的返回类型该是什么?

impl TicketStore {
    pub fn to_dos(&self) -> ??? {
        self.tickets.iter().filter(|t| t.status == Status::ToDo)
    }
}

不可命名的类型 (Unnameable types)

filter 方法返回一个 std::iter::Filter 实例,其定义为:

pub struct Filter<I, P> { /* 字段省略 */ }

其中 I 是被过滤迭代器的类型,P 是用于过滤元素的谓词。
我们知道 I 这里是 std::slice::Iter<'_, Ticket>,但 P 呢?
P 是个闭包,是匿名函数 (anonymous function)。顾名思义,闭包没有名字,所以我们没法在代码里写出来。

Rust 对此有解:impl Trait

impl Trait

impl Trait 是一项允许你返回一个类型而不必指明其名字的特性。 你只声明该类型实现了哪些特质,剩下的由 Rust 弄清楚。

这里我们想返回一个对 Ticket 引用的迭代器:

impl TicketStore {
    pub fn to_dos(&self) -> impl Iterator<Item = &Ticket> {
        self.tickets.iter().filter(|t| t.status == Status::ToDo)
    }
}

就这么简单!

是泛型吗?(Generic?)

返回位置上的 impl Trait 不是泛型参数。

泛型是占位符,由函数调用方填入具体类型。 带泛型参数的函数是多态 (polymorphic) 的:可以用不同类型调用,编译器会为每种类型生成不同的实现。

impl Trait 不是这样的。 带 impl Trait 的函数返回类型在编译期是固定 (fixed) 的,编译器只会为它生成一份实现。 所以 impl Trait 也叫不透明返回类型 (opaque return type):调用方不知道返回值的精确类型,只知道它实现了所指定的特质。但编译器知道精确类型,并不涉及多态。

RPIT

如果你读 RFC 或关于 Rust 的深入文章,可能会碰到缩写 RPIT
它代表 "Return Position Impl Trait",指的就是在返回位置使用 impl Trait

原文链接:英文原文

参数位置上的 impl Trait (impl Trait in argument position)

上一节我们看到 impl Trait 可以用于返回一个类型而不指明其名字。
同样的语法也可以用在参数位置 (argument position)

fn print_iter(iter: impl Iterator<Item = i32>) {
    for i in iter {
        println!("{}", i);
    }
}

print_iter 接收一个 i32 的迭代器,并打印每个元素。
当用在参数位置时,impl Trait 等价于带特质约束的泛型参数:

fn print_iter<T>(iter: T) 
where
    T: Iterator<Item = i32>
{
    for i in iter {
        println!("{}", i);
    }
}

缺点 (Downsides)

经验法则:在参数位置,优先使用泛型而非 impl Trait
泛型允许调用方用 turbofish 语法 (::<>) 显式指定参数的类型,这对消歧很有用。impl Trait 不行。

原文链接:英文原文

切片 (Slices)

回到 Vec 的内存布局:

let mut numbers = Vec::with_capacity(3);
numbers.push(1);
numbers.push(2);
      +---------+--------+----------+
Stack | pointer | length | capacity | 
      |  |      |   2    |    3     |
      +--|------+--------+----------+
         |
         |
         v
       +---+---+---+
Heap:  | 1 | 2 | ? |
       +---+---+---+

我们已经指出 String 不过是伪装的 Vec<u8>
这个相似性应该让你提问:"那 Vec 对应的 &str 是什么?"

&[T]

[T] 是元素类型为 T 的连续序列的切片 (slice)
它最常以借用形式 &[T] 出现。

可以用多种方式从 Vec 创建切片引用:

let numbers = vec![1, 2, 3];
// 通过索引语法
let slice: &[i32] = &numbers[..];
// 通过方法
let slice: &[i32] = numbers.as_slice();
// 或针对元素的子集
let slice: &[i32] = &numbers[1..];

Vec 实现了 Deref 特质,Target[T],因此借助解引用强制转换 (deref coercion) 你可以直接在 Vec 上调用切片方法:

let numbers = vec![1, 2, 3];
// 出乎意料:`iter` 不是 `Vec` 上的方法!
// 它是 `&[T]` 上的方法,但你可以借助解引用强制转换
// 在 `Vec` 上调用它。
let sum: i32 = numbers.iter().sum();

内存布局 (Memory layout)

&[T] 是一个胖指针 (fat pointer),跟 &str 一样。
它由指向切片首元素的指针和切片长度组成。

如果你有一个含三个元素的 Vec

let numbers = vec![1, 2, 3];

然后创建一个切片引用:

let slice: &[i32] = &numbers[1..];

会得到这样的内存布局:

                  numbers                          slice
      +---------+--------+----------+      +---------+--------+
Stack | pointer | length | capacity |      | pointer | length |
      |    |    |   3    |    4     |      |    |    |   2    |
      +----|----+--------+----------+      +----|----+--------+
           |                                    |  
           |                                    |
           v                                    | 
         +---+---+---+---+                      |
Heap:    | 1 | 2 | 3 | ? |                      |
         +---+---+---+---+                      |
               ^                                |
               |                                |
               +--------------------------------+

&Vec<T>&[T] (&Vec<T> vs &[T])

当你需要把一个对 Vec 的不可变引用传给函数时,优先用 &[T] 而非 &Vec<T>
这让函数可以接受任何切片,不一定要由 Vec 支撑。

例如,你可以传 Vec 中元素的子集。 但还不止于此——你也可以传一个数组的切片

let array = [1, 2, 3];
let slice: &[i32] = &array;

数组切片和 Vec 切片是同一个类型:它们都是指向连续元素序列的胖指针。 对数组而言,指针指向的是栈而不是堆,但在使用切片时这无关紧要。

原文链接:英文原文

可变切片 (Mutable slices)

每次我们谈到切片类型(如 str[T])时,用的都是其不可变借用形式(&str&[T])。
但切片也可以是可变的!

下面是创建可变切片的方式:

let mut numbers = vec![1, 2, 3];
let slice: &mut [i32] = &mut numbers;

然后你可以修改切片中的元素:

slice[0] = 42;

这会把 Vec 的第一个元素改成 42

局限性 (Limitations)

谈不可变借用时,建议很明确:优先用切片引用而非对所有权类型的引用(例如 &[T] 优于 &Vec<T>)。
对可变借用来说不是这样。

考虑下面这个场景:

let mut numbers = Vec::with_capacity(2);
let mut slice: &mut [i32] = &mut numbers;
slice.push(1);

这无法编译!
pushVec 上的方法,而不是切片上的方法。这是更普遍的一条原则的体现:Rust 不允许你向切片添加或移除元素,你只能修改/替换已经存在的元素。

在这一点上,&mut Vec&mut String 严格强于 &mut [T]&mut str
依据你需要执行的操作选最合适的类型。

原文链接:英文原文

工单 id (Ticket ids)

让我们再思考一下我们的工单管理系统。
当前我们的工单模型是这样:

pub struct Ticket {
    pub title: TicketTitle,
    pub description: TicketDescription,
    pub status: Status
}

这里漏了一样东西:用来唯一标识工单的标识符 (identifier)
该标识符对每张工单都应当唯一。这点可以通过在新工单创建时自动生成来保证。

细化模型 (Refining the model)

id 应当存放在哪里?
我们可以给 Ticket 结构体加一个新字段:

pub struct Ticket {
    pub id: TicketId,
    pub title: TicketTitle,
    pub description: TicketDescription,
    pub status: Status
}

但在创建工单之前我们并不知道 id,所以它不能从一开始就在那。
那它必须是可选的:

pub struct Ticket {
    pub id: Option<TicketId>,
    pub title: TicketTitle,
    pub description: TicketDescription,
    pub status: Status
}

这也不理想——每次从存储中取出工单时都要处理 None 情况,尽管我们知道工单创建之后 id 总应当存在。

最好的方案是:用两种独立的类型表示工单的两种状态 (state)——TicketDraftTicket

pub struct TicketDraft {
    pub title: TicketTitle,
    pub description: TicketDescription
}

pub struct Ticket {
    pub id: TicketId,
    pub title: TicketTitle,
    pub description: TicketDescription,
    pub status: Status
}

TicketDraft 是尚未创建的工单。它没有 id,也没有状态。
Ticket 是已创建的工单。它有 id 和状态。
由于 TicketDraftTicket 中每个字段都内嵌了自己的约束,我们不需要在两个类型间复制逻辑。

原文链接:英文原文

索引 (Indexing)

TicketStore::get 接受一个 TicketId 并返回 Option<&Ticket>
我们之前见过怎么用 Rust 的索引语法访问数组和向量的元素:

let v = vec![0, 1, 2];
assert_eq!(v[0], 0);

我们怎么为 TicketStore 提供同样的体验?
你猜对了:我们要实现一个特质——Index

Index

Index 特质定义在 Rust 标准库中:

// 略简化
pub trait Index<Idx>
{
    type Output;

    // 必须实现的方法
    fn index(&self, index: Idx) -> &Self::Output;
}

它有:

  • 一个泛型参数 Idx,用于表示索引类型
  • 一个关联类型 Output,表示通过索引获取到的值的类型

注意 index 方法不返回 Option。预设是:当你尝试访问不存在的元素时 index 会 panic,跟数组和 vec 的索引一样。

原文链接:英文原文

可变索引 (Mutable indexing)

Index 只允许只读访问,不允许你修改取出的值。

IndexMut

如果你想允许可变性,需要实现 IndexMut 特质。

// 略简化
pub trait IndexMut<Idx>: Index<Idx>
{
    // 必须实现的方法
    fn index_mut(&mut self, index: Idx) -> &mut Self::Output;
}

IndexMut 只能在类型已经实现了 Index 的前提下实现,因为它解锁的是一项 额外 能力。

原文链接:英文原文

HashMap

我们的 Index/IndexMut 实现并不理想:要按 id 取出工单需要遍历整个 Vec;算法复杂度是 O(n),其中 n 是 store 中工单的数量。

我们可以通过用不同的数据结构存储工单来做得更好:HashMap<K, V>

use std::collections::HashMap;

// 类型推断让我们可以省略显式类型签名
//(这个例子里会是 `HashMap<String, String>`)。
let mut book_reviews = HashMap::new();

book_reviews.insert(
    "Adventures of Huckleberry Finn".to_string(),
    "My favorite book.".to_string(),
);

HashMap 处理键值对 (key-value pair)。它对二者都是泛型的:K 是键类型的泛型参数,V 是值类型的泛型参数。

插入、查询和删除的预期成本都是常数级的,O(1)。 听起来正合我们的需求,是不是?

键的要求 (Key requirements)

HashMap 结构体定义本身没有特质约束,但它的方法上有。我们看看 insert

// 略简化
impl<K, V> HashMap<K, V>
where
    K: Eq + Hash,
{
    pub fn insert(&mut self, k: K, v: V) -> Option<V> {
        // [...]
    }
}

键类型必须实现 EqHash 特质。
我们一一深入。

Hash

哈希函数(hashing function 或 hasher)把可能无穷的值集合(例如所有可能的字符串)映射到有界范围(例如一个 u64 值)。
有许多不同的哈希函数,各有不同特性(速度、碰撞风险、可逆性等)。

HashMap,顾名思义,幕后使用了哈希函数。 它对你的键做哈希,再用这个哈希值来存储/检索关联值。 这种策略要求键类型必须可哈希,因此 K 上有 Hash 特质约束。

Hash 特质位于 std::hash 模块下:

pub trait Hash {
    // 必须实现的方法
    fn hash<H>(&self, state: &mut H)
       where H: Hasher;
}

你很少需要手动实现 Hash。大多数情况下你会派生它:

#[derive(Hash)]
struct Person {
    id: u32,
    name: String,
}

Eq

HashMap 必须能比较键的相等性。这在处理哈希碰撞 (hash collision) 时尤其重要——即两个不同的键被哈希到同一个值。

你可能想:这不就是 PartialEq 吗?几乎是!
HashMap 来说 PartialEq 不够,因为它不保证自反性 (reflexivity),即 a == a 总为 true
例如,浮点数 (f32f64) 实现了 PartialEq,但不满足自反性:f32::NAN == f32::NANfalse
HashMap 正确工作来说自反性至关重要:没有它,你就无法使用插入时使用的同一个键再从 map 中取回值。

Eq 特质在 PartialEq 之上加了自反性属性:

pub trait Eq: PartialEq {
    // 没有附加方法
}

它是个标记特质 (marker trait):不引入新方法,只是让你向编译器声明 PartialEq 中实现的相等逻辑是自反的。

派生 PartialEq 时可以一并自动派生 Eq

#[derive(PartialEq, Eq)]
struct Person {
    id: u32,
    name: String,
}

EqHash 是关联的 (Eq and Hash are linked)

EqHash 之间有一条隐式契约:如果两个键相等,它们的哈希值也必须相等。 这对 HashMap 正确工作至关重要。如果你违反这条契约,使用 HashMap 时就会得到没意义的结果。

原文链接:英文原文

排序 (Ordering)

通过把 Vec 换成 HashMap,我们提升了工单管理系统的性能,过程中还简化了代码。
但并非全是好处。在以 Vec 为底层的 store 上迭代时,我们能确定工单按添加顺序返回。
HashMap 不是这样:你可以遍历工单,但顺序是随机的。

我们可以通过把 HashMap 换成 BTreeMap 来恢复一致顺序。

BTreeMap

BTreeMap 保证条目按键排序。
当你需要按特定顺序遍历条目,或需要做范围查询(例如"给我所有 id 在 10 到 20 之间的工单")时,这很有用。

HashMap 一样,BTreeMap 的定义上没有特质约束, 但其方法上有。我们看看 insert

// `K` 和 `V` 分别表示键和值的类型,
// 跟 `HashMap` 一样。
impl<K, V> BTreeMap<K, V> {
    pub fn insert(&mut self, key: K, value: V) -> Option<V>
    where
        K: Ord,
    {
        // 实现
    }
}

Hash 不再是必须的。取而代之,键类型必须实现 Ord 特质。

Ord

Ord 特质用来比较值。
PartialEq 用来比较相等性,Ord 用来比较顺序。

它定义在 std::cmp 中:

pub trait Ord: Eq + PartialOrd {
    fn cmp(&self, other: &Self) -> Ordering;
}

cmp 方法返回 Ordering 枚举,其值可能是 LessEqualGreater
Ord 要求另两个特质也已实现:EqPartialOrd

PartialOrd

PartialOrdOrd 的较弱版本,正如 PartialEqEq 的较弱版本。 看看它的定义就明白了:

pub trait PartialOrd: PartialEq {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering>;
}

PartialOrd::partial_cmp 返回 Option——并不能保证两个值之间一定可比较。
例如 f32 不实现 Ord,因为 NaN 值不可比较;同样的原因 f32 也不实现 Eq

实现 OrdPartialOrd (Implementing Ord and PartialOrd)

OrdPartialOrd 都可以为你的类型派生:

// 你也得加上 `Eq` 和 `PartialEq`,
// 因为 `Ord` 要求它们。
#[derive(Eq, PartialEq, Ord, PartialOrd)]
struct TicketId(u64);

如果你选择(或必须)手动实现,要小心:

  • OrdPartialOrd 必须与 EqPartialEq 一致。
  • OrdPartialOrd 必须彼此一致。

原文链接:英文原文

引入 (Intro)

Rust 的一大承诺是 无畏并发 (fearless concurrency):让安全的并发程序更易编写。 我们到目前为止还没怎么见到这点——所有工作都是单线程的。 是时候改变这个状况了!

本章我们要把工单存储改成多线程。
我们会有机会接触到 Rust 大部分核心并发特性,包括:

  • 线程,使用 std::thread 模块
  • 消息传递 (message passing),使用通道 (channel)
  • 共享状态 (shared state),使用 ArcMutexRwLock
  • SendSync,编码 Rust 并发保证的特质

我们也会讨论多线程系统的若干设计模式及其取舍。

原文链接:英文原文

线程 (Threads)

在我们开始写多线程代码之前,先退一步谈谈线程是什么、我们为什么要用它。

什么是线程?(What is a thread?)

线程 (thread) 是由底层操作系统管理的执行上下文。
每个线程有自己的栈和指令指针 (instruction pointer)。

一个进程 (process) 可以管理多个线程。 这些线程共享同一片内存空间,意味着它们可以访问同样的数据。

线程是逻辑 (logical) 构造。最终,CPU 核心(物理 (physical) 执行单元)一次只能运行一组指令。
由于线程数可能远多于 CPU 核心数,操作系统的调度器 (scheduler) 负责决定在任意时刻运行哪个线程,把 CPU 时间在它们之间划分以最大化吞吐量和响应性。

main

Rust 程序启动时,运行在单个线程上——主线程 (main thread)
这个线程由操作系统创建,负责运行 main 函数。

use std::thread;
use std::time::Duration;

fn main() {
    loop {
        thread::sleep(Duration::from_secs(2));
        println!("Hello from the main thread!");
    }
}

std::thread

Rust 标准库提供了 std::thread 模块,允许你创建和管理线程。

spawn

可以用 std::thread::spawn 创建新线程并在其上执行代码。

例如:

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        loop {
            thread::sleep(Duration::from_secs(1));
            println!("Hello from a thread!");
        }
    });
    
    loop {
        thread::sleep(Duration::from_secs(2));
        println!("Hello from the main thread!");
    }
}

如果你在 Rust playground 上执行这段程序, 你会看到主线程和被 spawn 出来的线程并发运行,各自独立地推进。

进程终止 (Process termination)

主线程结束时,整个进程也会退出。
被 spawn 的线程会一直运行,直到它自己结束或主线程结束。

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        loop {
            thread::sleep(Duration::from_secs(1));
            println!("Hello from a thread!");
        }
    });

    thread::sleep(Duration::from_secs(5));
}

上面的例子中,你应当能看到 "Hello from a thread!" 大约被打印 5 次。
然后主线程结束(sleep 调用返回时),而被 spawn 的线程也会因为整个进程退出而被终止。

join

你也可以通过对 spawn 返回的 JoinHandle 调用 join 方法来等待 spawn 的线程结束。

use std::thread;
fn main() {
    let handle = thread::spawn(|| {
        println!("Hello from a thread!");
    });

    handle.join().unwrap();
}

这个例子里,主线程会等被 spawn 的线程完成后再退出。
这就在两个线程之间引入了一种同步 (synchronization) 形式:你可以确保程序退出之前一定能看到 "Hello from a thread!",因为主线程要等到被 spawn 的线程结束才会退出。

原文链接:英文原文

'static

如果你尝试在前一个练习里从向量借用一个切片, 你大概会得到一条这样的编译错误:

error[E0597]: `v` does not live long enough
   |
11 | pub fn sum(v: Vec<i32>) -> i32 {
   |            - binding `v` declared here
...
15 |     let right = &v[split_point..];
   |                  ^ borrowed value does not live long enough
16 |     let left_handle = spawn(move || left.iter().sum::<i32>());
   |                             -------------------------------- 
                     argument requires that `v` is borrowed for `'static`
19 | }
   |  - `v` dropped here while still borrowed

argument requires that v is borrowed for 'static,这是什么意思?

'static 生命周期是 Rust 中一个特殊的生命周期。
它意味着该值在程序的整个运行期内都有效。

分离的线程 (Detached threads)

通过 thread::spawn 启动的线程可以比 (outlive) 它的父线程更长寿
例如:

use std::thread;

fn f() {
    thread::spawn(|| {
        thread::spawn(|| {
            loop {
                thread::sleep(std::time::Duration::from_secs(1));
                println!("Hello from the detached thread!");
            }
        });
    });
}

这个例子里,第一个被 spawn 的线程又 spawn 了一个子线程,子线程每秒打印一条消息。
第一个线程随后完成并退出。当这发生时, 其子线程会继续运行,只要整个进程还在运行。
用 Rust 的术语说,子线程比 (outlived) 它的父线程更长寿。

'static 生命周期 ('static lifetime)

由于 spawn 的线程可能:

  • 比 spawn 它的线程(父线程)更长寿
  • 一直运行到程序退出

它必须不能借用任何可能在程序退出之前被丢弃的值; 违反这个约束会让我们暴露在 use-after-free bug 之下。
这就是为什么 std::thread::spawn 的签名要求传给它的闭包具有 'static 生命周期:

pub fn spawn<F, T>(f: F) -> JoinHandle<T> 
where
    F: FnOnce() -> T + Send + 'static,
    T: Send + 'static
{
    // [..]
}

'static 不(仅仅)关乎引用 ('static is not (just) about references)

Rust 中所有值都有生命周期,不只是引用。

特别地,一个拥有自己数据的类型(例如 VecString)满足 'static 约束:如果你拥有它,你想用多久都行,即使最初创建它的函数已经返回了。

因此你可以把 'static 解读为:

  • 给我一个具有所有权的值
  • 给我一个在程序整个运行期内都有效的引用

第一种方式正是你在前一个练习里解决问题的方式: 分配新的向量来分别持有原向量的左半段和右半段,再把它们移入 (move) 被 spawn 的线程。

'static 引用 ('static references)

我们来谈第二种情况:在程序整个运行期内都有效的引用。

静态数据 (Static data)

最常见的情形是对静态数据 (static data) 的引用,比如字符串字面量:

let s: &'static str = "Hello world!";

由于字符串字面量在编译期已知,Rust 把它们存放在你的可执行文件 内部, 位于一个叫只读数据段 (read-only data segment) 的区域。 所有指向该区域的引用因此在整个程序运行期内都有效;它们满足 'static 契约。

进一步阅读

原文链接:英文原文

泄漏数据 (Leaking data)

把引用传给 spawn 的线程的主要顾虑是 use-after-free bug: 通过指向已经被释放/回收内存区域的指针来访问数据。
如果你处理的是堆分配的数据,可以告诉 Rust 你永远不会回收那块内存来避开这个问题:你刻意选择泄漏内存 (leak memory)

可以用 Rust 标准库里的 Box::leak 方法做到这点:

// 通过把 `u32` 包到 `Box` 里来在堆上分配它。
let x = Box::new(41u32);
// 用 `Box::leak` 告诉 Rust 你永远不会释放这块堆分配。
// 你因此能拿回一个 'static 引用。
let static_ref: &'static mut u32 = Box::leak(x);

数据泄漏是进程级的 (Data leakage is process-scoped)

泄漏数据是危险的:如果你不停泄漏内存,最终会用尽内存并以"内存不足 (out-of-memory)" 错误崩溃。

// 如果你让它跑一阵子,
// 它最终会用光所有可用内存。
fn oom_trigger() {
    loop {
        let v: Vec<usize> = Vec::with_capacity(1024);
        v.leak();
    }
}

同时,通过 leak 方法泄漏的内存并未真正被遗忘。
操作系统能把每块内存区域映射到负责它的进程。 进程退出时,操作系统会回收那块内存。

记住这点的话,泄漏内存在以下情形是可以接受的:

  • 需要泄漏的内存量是有界 (bounded)/事先已知的,或者
  • 你的进程是短生命周期的,且你确信在它退出之前不会耗尽所有可用内存

如果你的用例允许,"让 OS 处理它"是一种完全有效的内存管理策略。

原文链接:英文原文

作用域线程 (Scoped threads)

我们到目前为止讨论的所有生命周期问题都有一个共同根源: 被 spawn 的线程可能比父线程更长寿。
我们可以通过使用作用域线程 (scoped threads) 绕开这个问题。

let v = vec![1, 2, 3];
let midpoint = v.len() / 2;

std::thread::scope(|scope| {
    scope.spawn(|| {
        let first = &v[..midpoint];
        println!("Here's the first half of v: {first:?}");
    });
    scope.spawn(|| {
        let second = &v[midpoint..];
        println!("Here's the second half of v: {second:?}");
    });
});

println!("Here's v: {v:?}");

我们逐步拆解发生了什么。

scope

std::thread::scope 函数创建一个新的作用域 (scope)
std::thread::scope 接受一个闭包作为输入,闭包带一个参数:Scope 实例。

作用域内的 spawn (Scoped spawns)

Scope 暴露了一个 spawn 方法。
std::thread::spawn 不同,所有通过 Scope spawn 的线程在作用域结束时都会自动 join

如果我们把前面的例子"翻译"成 std::thread::spawn,会是这样:

let v = vec![1, 2, 3];
let midpoint = v.len() / 2;

let handle1 = std::thread::spawn(|| {
    let first = &v[..midpoint];
    println!("Here's the first half of v: {first:?}");
});
let handle2 = std::thread::spawn(|| {
    let second = &v[midpoint..];
    println!("Here's the second half of v: {second:?}");
});

handle1.join().unwrap();
handle2.join().unwrap();

println!("Here's v: {v:?}");

从环境借用 (Borrowing from the environment)

不过翻译过来的版本不会通过编译:编译器会抱怨 &v 不能从被 spawn 的线程里使用,因为它的生命周期不是 'static

std::thread::scope 没有这个问题——你可以安全地从环境借用 (safely borrow from the environment)

我们的例子里,v 在 spawn 之前就创建了。 它要在 scope 返回 之后 才被丢弃。同时,所有在 scope 内 spawn 的线程都保证在 scope 返回 之前 完成,因此不存在悬垂引用的风险。

编译器不会抱怨!

原文链接:英文原文

通道 (Channels)

到目前为止我们 spawn 的线程都生命周期相当短:拿到输入、做计算、返回结果、关停。

对我们的工单管理系统,我们想做不一样的事:客户端-服务端 (client-server) 架构。

我们将有一个长生命周期的服务端线程,负责管理我们的状态——存储的工单。

我们再有多个客户端线程
每个客户端能向有状态的线程发送命令 (commands)查询 (queries),从而改变其状态(例如新增一张工单)或检索信息(例如获取一张工单的状态)。
客户端线程并发运行。

通信 (Communication)

到目前为止我们仅有非常有限的父子通信:

  • 被 spawn 的线程从父上下文借用/消费数据
  • 被 spawn 的线程在 join 时把数据返还给父线程

这对客户端-服务端设计是不够的。
客户端需要能在服务端线程被启动 之后 与之收发数据。

我们可以用通道 (channels) 解决这个问题。

通道 (Channels)

Rust 标准库在 std::sync::mpsc 模块中提供了多生产者单消费者 (multi-producer, single-consumer, mpsc) 通道。
通道有两种风味:有界 (bounded) 和无界 (unbounded)。我们暂时用无界版本,稍后再讨论它们的优缺点。

通道创建是这样:

use std::sync::mpsc::channel;

let (sender, receiver) = channel();

你得到一个发送者 (sender) 和一个接收者 (receiver)。
对发送者调用 send 把数据推入通道。
对接收者调用 recv 从通道拉取数据。

多个发送者 (Multiple senders)

Sender 是可克隆的:我们可以创建多个发送者(例如每个客户端线程一个),它们都会把数据推入同一个通道。

Receiver 不可克隆:一个通道只能有一个接收者。

这就是 mpsc(multi-producer single-consumer)的含义!

消息类型 (Message type)

SenderReceiver 都对类型参数 T 泛型。
那就是能在我们通道上传输的 消息 的类型。

可以是 u64、结构体、枚举等。

错误 (Errors)

sendrecv 都可能失败。
如果接收者被丢弃,send 返回错误。
如果所有发送者都被丢弃且通道为空,recv 返回错误。

换句话说,通道实际上关闭时 sendrecv 会出错。

原文链接:英文原文

内部可变性 (Interior mutability)

我们来稍微推敲一下 Sendersend 签名:

impl<T> Sender<T> {
    pub fn send(&self, t: T) -> Result<(), SendError<T>> {
        // [...]
    }
}

send 接受 &self 作为参数。
但它显然在引发修改:往通道里加入了一条新消息。 更有意思的是 Sender 是可克隆的:我们可以让多个 Sender 实例从不同线程同时尝试修改通道状态。

这是我们用来构建 client-server 架构的关键性质。但它为什么有效? 难道这不违反 Rust 关于借用的规则吗?我们怎么能通过一个 不可变 引用执行修改?

共享引用而非不可变引用 (Shared rather than immutable references)

我们引入借用检查器时,把 Rust 中的两种引用命名为:

  • 不可变引用 (immutable references) &T
  • 可变引用 (mutable references) &mut T

把它们叫作下列名字会更准确:

  • 共享引用 (shared references) &T
  • 独占引用 (exclusive references) &mut T

不可变/可变是一个对绝大多数情况都有效的心智模型,也是上手 Rust 时很好的一个。但它不是全貌,正如你刚刚看到的:&T 实际上并不保证它指向的数据不可变。
不过别担心:Rust 仍然在信守它的承诺。 只是这些术语比表面看起来要更微妙。

UnsafeCell

每当一个类型允许你通过共享引用修改数据时,你就在和内部可变性 (interior mutability) 打交道。

默认情况下,Rust 编译器假设共享引用是不可变的。它基于这个假设优化你的代码
编译器可以重排操作、缓存值,做各种把戏让你的代码更快。

你可以通过把数据包到 UnsafeCell 里告诉编译器"不,这个共享引用其实是可变的"。
每次你看到一个允许内部可变性的类型,可以确定 UnsafeCell 牵涉其中——直接或间接地。
借助 UnsafeCell、原始指针和 unsafe 代码,你可以通过共享引用修改数据。

不过要说清楚:UnsafeCell 不是允许你忽略借用检查器的魔法棒!
unsafe 代码仍然受 Rust 关于借用与混叠 (aliasing) 规则的约束。 它是一种(高级)工具,用来构建那些其安全性无法直接通过 Rust 类型系统表达的安全抽象 (safe abstractions)。每次你使用 unsafe 关键字,等于在告诉编译器:"我知道我在做什么,不会违反你的不变量,相信我。"

每次你调用一个 unsafe 函数,文档都会说明它的安全前置条件 (safety preconditions): 执行其 unsafe 块要满足什么条件才安全。UnsafeCell 的可在 std 文档 找到。

我们这门课不会直接使用 UnsafeCell,也不会写 unsafe 代码。 但了解它存在、为什么存在以及它跟你日常使用的 Rust 类型有何关系,是重要的。

关键例子 (Key examples)

我们看几个利用内部可变性的重要 std 类型。
它们是你在 Rust 代码中相当常见的类型,特别是当你揭开你使用的库的盖子时。

引用计数 (Reference counting)

Rc 是一个引用计数指针。
它包裹一个值,并跟踪有多少个对该值的引用存在。 当最后一个引用被丢弃时,该值被释放。
Rc 包裹的值是不可变的:你只能得到对它的共享引用。

use std::rc::Rc;

let a: Rc<String> = Rc::new("My string".to_string());
// 字符串数据只有一个引用。
assert_eq!(Rc::strong_count(&a), 1);

// 调用 `clone` 不会复制字符串数据!
// 只是把 `Rc` 的引用计数加 1。
let b = Rc::clone(&a);
assert_eq!(Rc::strong_count(&a), 2);
assert_eq!(Rc::strong_count(&b), 2);
// ^ `a` 和 `b` 都指向同一份字符串数据
//   并共享同一个引用计数器。

Rc 内部用 UnsafeCell 来允许共享引用增减引用计数。

RefCell

RefCell 是 Rust 中内部可变性最常见的例子之一。 它允许你在只持有 RefCell 的不可变引用时,仍能修改被 RefCell 包裹的值。

这是通过运行时借用检查 (runtime borrow checking) 实现的。 RefCell 在运行时跟踪它所包含值的引用数量(与类型)。 如果你尝试在已存在不可变借用时再做可变借用,程序会 panic,确保 Rust 的借用规则始终被强制执行。

use std::cell::RefCell;

let x = RefCell::new(42);

let y = x.borrow(); // 不可变借用
let z = x.borrow_mut(); // panic!已有活跃的不可变借用。

原文链接:英文原文

双向通信 (Two-way communication)

我们当前的 client-server 实现里,通信只朝一个方向流动:从客户端到服务端。
客户端无从得知服务端是否收到消息、是否成功执行、还是失败了。 这并不理想。

要解决这个问题,我们可以引入双向通信系统。

响应通道 (Response channel)

我们需要一种方式让服务端把响应送回客户端。
有多种方式可以做到,但最简单的选项是把一个 Sender 通道也包含在客户端发给服务端的消息里。处理完消息后,服务端可以用这个通道把响应送回客户端。

这是建立在消息传递原语 (message-passing primitives) 之上的 Rust 应用中相当常见的模式。

原文链接:英文原文

专用的 Client 类型 (A dedicated Client type)

到目前为止从客户端侧的所有交互都相当低层:你必须手动创建响应通道、构建命令、把它发给服务端,再对响应通道调用 recv 取响应。

这有大量样板代码可以抽象出去,本练习要做的正是这件事。

原文链接:英文原文

有界 vs 无界通道 (Bounded vs unbounded channels)

到目前为止我们都在使用无界通道。
你想发多少消息都行,通道会自己增长以容纳它们。
在多生产者单消费者场景里,这可能有问题:如果生产者入队消息的速率比消费者处理的速率更快,通道会持续增长,可能消耗光所有可用内存。

我们的建议是:在生产系统里永远不要使用无界通道。
你应当总是用有界通道 (bounded channel) 对可入队的消息数量设置上限。

有界通道 (Bounded channels)

有界通道有固定容量。
可以通过用大于零的容量调用 sync_channel 来创建一个:

use std::sync::mpsc::sync_channel;

let (sender, receiver) = sync_channel(10);

receiver 类型同前,依然是 Receiver<T>
sender 这次是 SyncSender<T> 实例。

发送消息 (Sending messages)

通过 SyncSender 发送消息有两种不同的方法:

  • send:如果通道有空间,入队消息并返回 Ok(())
    如果通道满了,会阻塞并等待直到有可用空间。
  • try_send:如果通道有空间,入队消息并返回 Ok(())
    如果通道满了,会返回 Err(TrySendError::Full(value)),其中 value 是没能发送出去的消息。

依据你的用例选择其一。

背压 (Backpressure)

使用有界通道的主要优点是它们提供了一种背压 (backpressure) 形式。
它们强制生产者在消费者跟不上时减速。 背压随后可以在系统中传播,可能影响整个架构,防止终端用户用海量请求把系统压垮。

原文链接:英文原文

更新操作 (Update operations)

到目前为止我们只实现了插入和检索操作。
看看怎么把系统扩展为提供更新操作。

旧版的更新 (Legacy updates)

在系统的非线程版本里,更新相当直接:TicketStore 暴露了一个 get_mut 方法,允许调用方拿到工单的可变引用并修改它。

多线程下的更新 (Multithreaded updates)

同样的策略在当前的多线程版本里行不通。借用检查器会拦下我们:SyncSender<&mut Ticket> 不是 'static,因为 &mut Ticket 不满足 'static 生命周期,因此它们不能被传给 std::thread::spawn 的闭包捕获。

要绕开这个限制有几种方式。我们会在接下来的几个练习里探索其中几种。

打补丁 (Patching)

我们没法把 &mut Ticket 通过通道发送,所以无法在客户端侧进行修改。
那能不能在服务端侧修改?

可以——只要我们告诉服务端要改什么。换言之,给服务端发送一个补丁 (patch)

struct TicketPatch {
    id: TicketId,
    title: Option<TicketTitle>,
    description: Option<TicketDescription>,
    status: Option<TicketStatus>,
}

id 字段是必需的,因为它用来识别要更新哪张工单。
其他字段都是可选的:

  • 如果某字段为 None,意味着该字段不应被改变。
  • 如果某字段为 Some(value),意味着该字段应被改为 value

原文链接:英文原文

锁、SendArc (Locks, Send and Arc)

你刚实现的打补丁策略有个主要缺点:它有竞争 (racy)。
如果两个客户端几乎同时为同一张工单发送补丁,服务端会以任意顺序应用它们。 后入队补丁的那位会覆盖前者所做的更改。

版本号 (Version numbers)

我们可以尝试用版本号 (version number) 修复这个问题。
每张工单创建时被赋予一个版本号,初始为 0
每当客户端发送补丁时,必须连同当前的版本号和期望的更改一起发出。服务端只在版本号与它存储的版本号匹配时才应用补丁。

在前面描述的场景中,服务端会拒绝第二个补丁,因为版本号已被第一个补丁递增,与第二个客户端发送的版本号不匹配。

这种方式在分布式系统里相当常见(例如客户端和服务端不共享内存时),称为乐观并发控制 (optimistic concurrency control)
其思想是:大多数时候不会发生冲突,因此可以为常见场景做优化。 你目前对 Rust 已经了解得够多,可以作为附加练习自行实现这种策略。

加锁 (Locking)

我们也可以通过引入锁 (lock) 来修复竞争。
每当客户端想更新工单时,必须先获取它上面的锁。锁活跃期间,其他客户端不能修改这张工单。

Rust 标准库提供了两种不同的锁原语:Mutex<T>RwLock<T>
先从 Mutex<T> 开始。它代表 mutual exclusion(互斥锁),是最简单的锁: 不论读写,一次只允许一个线程访问数据。

Mutex<T> 包裹它所保护的数据,因此对数据类型是泛型的。
你不能直接访问数据:类型系统强制你必须先用 Mutex::lockMutex::try_lock 获取锁。前者会阻塞直到锁被获取,后者在无法获取锁时立即返回错误。
两种方法都返回一个 guard 对象,guard 解引用得到数据,从而允许你修改它。当 guard 被丢弃时锁被释放。

use std::sync::Mutex;

// 一个被互斥锁保护的整数
let lock = Mutex::new(0);

// 在 mutex 上获取一把锁
let mut guard = lock.lock().unwrap();

// 通过 guard 借助其 `Deref` 实现修改数据
*guard += 1;

// 当 `data` 离开作用域时锁被释放
// 也可以显式 drop guard 主动释放
// 或当 guard 离开作用域时隐式释放
drop(guard)

锁粒度 (Locking granularity)

我们的 Mutex 应该包裹什么?
最简单的选项是用单个 Mutex 包裹整个 TicketStore
这能工作,但会严重限制系统性能:你无法并行读取工单,因为每次读取都得等锁释放。
这种做法叫粗粒度锁 (coarse-grained locking)

更好的做法是细粒度锁 (fine-grained locking):每张工单由自己的锁保护。 这样客户端可以继续并行处理工单,只要他们没在尝试访问同一张工单。

// 新结构,每张工单一把锁
struct TicketStore {
    tickets: BTreeMap<TicketId, Mutex<Ticket>>,
}

这种方式更高效,但有个缺点:TicketStore 必须意识到 (aware of) 系统的多线程性质;到目前为止 TicketStore 一直福里地忽略线程的存在。
不管怎样,我们要走这条路。

谁持有锁?(Who holds the lock?)

要让整个方案运转起来,锁必须传给想修改工单的客户端。
客户端随后可以直接修改工单(仿佛拥有 &mut Ticket),完成后释放锁。

这有点棘手。
我们没法把 Mutex<Ticket> 通过通道发送,因为 Mutex 不是 Clone,并且我们不能从 TicketStore 中把它移走 (move out)。我们能不能把 MutexGuard 发过去?

用一个小例子检验一下这个想法:

use std::thread::spawn;
use std::sync::Mutex;
use std::sync::mpsc::sync_channel;

fn main() {
    let lock = Mutex::new(0);
    let (sender, receiver) = sync_channel(1);
    let guard = lock.lock().unwrap();

    spawn(move || {
        receiver.recv().unwrap();
    });

    // 尝试把 guard 通过通道发到另一个线程
    sender.send(guard);
}

编译器对这段代码不满意:

error[E0277]: `MutexGuard<'_, i32>` cannot be sent between 
              threads safely
   --> src/main.rs:10:7
    |
10  |   spawn(move || {
    |  _-----_^
    | | |
    | | required by a bound introduced by this call
11  | |     receiver.recv().unwrap();
12  | | });
    | |_^ `MutexGuard<'_, i32>` cannot be sent between threads safely
    |
    = help: the trait `Send` is not implemented for 
            `MutexGuard<'_, i32>`, which is required by 
            `{closure@src/main.rs:10:7: 10:14}: Send`
    = note: required for `Receiver<MutexGuard<'_, i32>>` 
            to implement `Send`
note: required because it's used within this closure

MutexGuard<'_, i32> 不是 Send:这是什么意思?

Send

Send 是一个标记特质 (marker trait),表示类型可以安全地从一个线程转移到另一个线程。
Send 也是自动特质 (auto-trait),跟 Sized 一样;编译器根据类型的定义自动为你的类型实现(或不实现)它。
你也可以为自己的类型手动实现 Send,但需要 unsafe,因为你必须保证这个类型确实可以安全地在线程间发送,编译器无法自动验证这点。

通道的要求 (Channel requirements)

Sender<T>SyncSender<T>Receiver<T> 当且仅当 TSend 时才是 Send
因为它们用于在线程间发送值,如果值本身不是 Send,在线程间发送它就不安全。

MutexGuard

MutexGuard 不是 Send,因为 Mutex 实现锁所依赖的底层操作系统原语在某些平台上要求锁必须由获取它的同一个线程释放。
如果我们把 MutexGuard 发到另一个线程,锁就会被另一个线程释放,这会导致未定义行为。

我们的挑战 (Our challenges)

总结一下:

  • 我们不能通过通道发送 MutexGuard,所以不能在服务端侧加锁、再在客户端侧修改工单。
  • 我们可以通过通道发送 Mutex,因为只要它保护的数据是 Send,它本身就是 Send,对 Ticket 来说成立。 与此同时,我们既不能把 MutexTicketStore 中移走,也不能克隆它。

怎么解开这个难题?
我们需要换个角度看问题。 要锁住一个 Mutex,我们不需要拥有所有权的值。一个共享引用就够了,因为 Mutex 使用内部可变性:

impl<T> Mutex<T> {
    // `&self`,不是 `self`!
    pub fn lock(&self) -> LockResult<MutexGuard<'_, T>> {
        // 实现细节
    }
}

因此把共享引用送到客户端就足够了。
不过我们没法直接这么做,因为引用得是 'static 而事实并非如此。
某种意义上,我们需要一种"具有所有权的共享引用 (owned shared reference)"。事实证明 Rust 有一个类型正好满足要求:Arc

Arc 来救场 (Arc to the rescue)

Arc 代表原子引用计数 (atomic reference counting)
Arc 包裹一个值,并跟踪有多少对它的引用存在。 当最后一个引用被丢弃时,该值被释放。
Arc 包裹的值是不可变的:你只能拿到对它的共享引用。

use std::sync::Arc;

let data: Arc<u32> = Arc::new(0);
let data_clone = Arc::clone(&data);

// `Arc<T>` 实现了 `Deref<T>`,所以能借助解引用强制转换
// 把 `&Arc<T>` 转为 `&T`
let data_ref: &u32 = &data;

如果你有种似曾相识的感觉,没错:Arc 听起来与我们讲内部可变性时介绍的引用计数指针 Rc 非常相似。区别在于线程安全:Rc 不是 Send,而 Arc 是。 归根结底是引用计数实现方式的差别:Rc 用"普通"整数,而 Arc原子 (atomic) 整数,可以安全地跨线程共享与修改。

Arc<Mutex<T>>

ArcMutex 配在一起,我们终于得到一种类型,它:

  • 可以在线程间发送,因为:
    • TSendArcSend
    • TSendMutexSend
    • TTicket,是 Send 的。
  • 可以克隆,因为不论 T 是什么,Arc 都是 Clone。 克隆 Arc 会增加引用计数,数据并未被复制。
  • 可以用来修改它包裹的数据,因为 Arc 让你拿到对 Mutex<T> 的共享引用,进而可以获取锁。

我们已具备实现工单存储锁策略所需的所有零件。

进一步阅读

原文链接:英文原文

读者与写者 (Readers and writers)

我们新的 TicketStore 能用,但读取性能不算好:同一时刻只有一个客户端能读取某张特定工单,因为 Mutex<T> 不区分读者和写者。

我们可以通过改用另一种锁原语 RwLock<T> 来解决这点。
RwLock<T> 代表读写锁 (read-write lock)。它允许多个读者同时访问数据,但同时只允许一个写者。

RwLock<T> 有两个获取锁的方法:readwrite
read 返回一个允许读数据的 guard;write 返回一个允许修改数据的 guard。

use std::sync::RwLock;

// 一个被读写锁保护的整数
let lock = RwLock::new(0);

// 在 RwLock 上获取一个读锁
let guard1 = lock.read().unwrap();

// 在第一个仍活跃时
// 再获取**第二**个读锁
let guard2 = lock.read().unwrap();

取舍 (Trade-offs)

表面上看 RwLock<T> 像不假思索的选择:它提供了 Mutex<T> 功能的超集。 为什么还要用 Mutex<T> 呢?

有两个关键原因:

  • 锁住 RwLock<T> 比锁住 Mutex<T> 更昂贵。
    这是因为 RwLock<T> 必须跟踪活跃的读者和写者数量,而 Mutex<T> 只需跟踪锁是否被持有。 如果读多于写,这点性能开销不是问题;但如果是写密集 (write-heavy) 工作负载,Mutex<T> 可能是更好的选择。
  • RwLock<T> 可能导致写者饿死 (writer starvation)
    如果总有读者在等锁,写者可能永远轮不到运行。
    RwLock<T> 不保证读者和写者获取锁的顺序。 这取决于底层 OS 实现的策略,可能对写者不公平。

我们的场景中,可以预期工作负载是读密集的(多数客户端在读工单而不是修改),所以 RwLock<T> 是个好选择。

原文链接:英文原文

设计回顾 (Design review)

我们花点时间回顾走过的路。

无锁 + 通道串行化 (Lockless with channel serialization)

我们多线程工单存储的第一版实现使用了:

  • 一个长生命周期线程(服务端),持有共享状态
  • 多个客户端,从各自线程通过通道向它发送请求

不需要对状态加锁,因为只有服务端在修改状态。这是因为 "收件箱"通道天然把进入的请求串行化 (serialized):服务端逐一处理。
我们之前讨论过这种方式在打补丁行为上的局限,但没讨论原始设计的性能影响:服务端一次只能处理一个请求,包括读。

细粒度锁 (Fine-grained locking)

随后我们转向了更复杂的设计:每张工单由自己的锁保护,客户端可以独立决定要读取还是原子修改某张工单,并获取相应的锁。

这种设计允许更好的并行性(即多个客户端可同时读取工单),但根本上仍然是串行 (serial) 的:服务端逐一处理命令;尤其是它逐一发放锁给客户端。

我们能不能干脆去掉通道,让客户端直接访问 TicketStore,纯靠锁来同步访问?

移除通道 (Removing channels)

我们要解决两个问题:

  • 跨线程共享 TicketStore
  • 同步对 store 的访问

跨线程共享 TicketStore (Sharing TicketStore across threads)

我们希望所有线程引用同一份状态,否则就不算真正的多线程系统——只是并行运行多个单线程系统。
我们之前在跨线程共享锁时已经遇到过这个问题:可以用 Arc

同步对 store 的访问 (Synchronizing access to the store)

有一种交互一直靠通道串行化提供的无锁性质:往 store 里插入(或移除)工单。
如果我们移除通道,需要引入(另一把)锁来同步对 TicketStore 本身的访问。

如果用 Mutex,那就没必要再为每张工单加 RwLockMutex 已经把对整个 store 的访问串行化,无论如何都无法并行读取工单。
如果改用 RwLock,则可以并行读取工单,只需在插入或移除工单时暂停所有读。

让我们沿这条路走下去看看会到哪儿。

原文链接:英文原文

Sync

收尾本章前,我们再谈 Rust 标准库中另一个关键特质:Sync

Sync 是自动特质 (auto trait),跟 Send 一样。
它会自动为所有可以安全地在线程间共享 (shared) 的类型实现。

换句话说:当 &TSendT 就是 Sync

T: Sync 不蕴含 T: Send (T: Sync doesn't imply T: Send)

要注意 T 可以是 Sync 而不是 Send
例如:MutexGuard 不是 Send,但它是 Sync

它不是 Send,是因为锁必须由获取它的同一个线程释放,因此我们不希望 MutexGuard 在另一个线程上被丢弃。
但它是 Sync,因为把 &MutexGuard 给另一个线程并不影响锁在哪儿被释放。

T: Send 不蕴含 T: Sync (T: Send doesn't imply T: Sync)

反过来也成立:T 可以是 Send 而不是 Sync
例如:RefCell<T>(当 TSend 时)是 Send,但不是 Sync

RefCell<T> 做运行时借用检查,但它用来跟踪借用的计数器不是线程安全的。 因此,多个线程同时持有 &RefCell 会导致数据竞争 (data race),可能多个线程同时拿到对同一数据的可变引用。所以 RefCell 不是 Sync
Send 则没问题,因为把 RefCell 发到另一个线程时不会留下任何指向它内部数据的引用,因此不存在并发可变访问的风险。

原文链接:英文原文

异步 Rust (Async Rust)

线程并不是 Rust 中编写并发程序的唯一方式。
本章我们要探索另一种方式:异步编程 (asynchronous programming)

具体来说,你将获得对以下内容的入门:

  • async/.await 关键字,让你毫不费力地写异步代码
  • Future 特质,表示可能尚未完成的计算
  • tokio,最流行的运行异步代码的运行时
  • Rust 异步模型的协作 (cooperative) 性质,以及它如何影响你的代码

原文链接:英文原文

异步函数 (Asynchronous functions)

到目前为止你写的所有函数和方法都是急切 (eager) 的。
你不调用它们就什么也不会发生。但一旦调用,它们会运行直到完成:把所有工作做完,然后返回输出。

有时这并不理想。
例如,如果你写一个 HTTP 服务器,可能会有大量的等待 (waiting):等待请求体到达、等待数据库响应、等待下游服务回复,等等。

要是你能在等待的同时做别的事呢?
要是你能在某个计算进行到一半时选择放弃呢?
要是你能选择把另一个任务的优先级置于当前任务之上呢?

这就轮到异步函数 (asynchronous functions) 上场了。

async fn

你用 async 关键字定义异步函数:

use tokio::net::TcpListener;

// 这个函数是异步的
async fn bind_random() -> TcpListener {
    // [...]
}

如果你像调用普通函数一样调用 bind_random 会怎样?

fn run() {
    // 调用 `bind_random`
    let listener = bind_random();
    // 接下来呢?
}

什么也不会发生!
Rust 在你调用 bind_random 时不会开始执行它, 也不会作为后台任务启动它(你可能基于其他语言的经验会这么期待)。 Rust 中的异步函数是惰性 (lazy) 的:你不显式要求它们做事,它们就什么也不做。 用 Rust 的术语说,bind_random 返回一个未来体 (future),一种表示可能稍后完成的计算的类型。它们叫 future 是因为它们实现了 Future 特质——本章稍后会详细讲解的接口。

.await

要让异步函数执行任务,最常见的方式是使用 .await 关键字:

use tokio::net::TcpListener;

async fn bind_random() -> TcpListener {
    // [...]
}

async fn run() {
    // 调用 `bind_random` 并等待它完成
    let listener = bind_random().await;
    // 现在 `listener` 就绪
}

.await 直到异步函数运行完成(例如上面例子里 TcpListener 创建完成)才把控制权交还给调用方。

运行时 (Runtimes)

如果你感到困惑,那是合理的!
我们刚说异步函数的好处是它们不会一次把所有工作做完。然后我们又引入了 .await,它直到异步函数运行完成才返回。难道不是把我们想解决的问题又引回来了吗?这有什么意义?

并非如此!调用 .await 时幕后发生了很多事!
你正把控制权让给一个异步运行时 (async runtime),也叫异步执行器 (async executor)。 执行器是魔法发生的地方:它负责管理你所有正在进行的异步任务 (tasks)。具体来说,它在两个目标之间取得平衡:

  • 推进 (Progress):确保任务在能推进时尽量推进。
  • 效率 (Efficiency):如果一个任务在等待某事,确保另一个任务能在此期间运行,充分利用可用资源。

没有默认运行时 (No default runtime)

Rust 在异步编程的方式上相当独特:没有默认运行时。 标准库不附带运行时。你需要自己带一个!

大多数情况下,你会从生态中现有的选项中选一个。 有些运行时被设计为通用,是大多数应用的稳妥选择。 tokioasync-std 属于这一类。还有些运行时为特定场景做了优化——例如嵌入式系统的 embassy

整门课程我们都依赖 tokio,它是 Rust 中通用异步编程最流行的运行时。

#[tokio::main]

你可执行文件的入口点 main 函数必须是同步函数。 你应当在它里面设置并启动你选的异步运行时。

大多数运行时提供宏来简化这个工作。tokio 的是 tokio::main

#[tokio::main]
async fn main() {
    // 这里写你的异步代码
}

它会展开为:

fn main() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(
        // 这里写你的异步函数
        // [...]
    );
}

#[tokio::test]

测试也一样:必须是同步函数。
每个测试函数在其自己的线程中运行,如果你需要在测试中执行异步代码,需要自己设置并启动异步运行时。
tokio 提供了 #[tokio::test] 宏来简化这件事:

#[tokio::test]
async fn my_test() {
    // 这里写你的异步测试代码
}

原文链接:英文原文

spawn 任务 (Spawning tasks)

你对前一个练习的解决方案大概是这样:

pub async fn echo(listener: TcpListener) -> Result<(), anyhow::Error> {
    loop {
        let (mut socket, _) = listener.accept().await?;
        let (mut reader, mut writer) = socket.split();
        tokio::io::copy(&mut reader, &mut writer).await?;
    }
}

这不算差!
如果两次进入的连接之间间隔很长,echo 函数会处于空闲状态(因为 TcpListener::accept 是异步函数),从而允许执行器在此期间运行其他任务。

但我们怎么真正让多个任务并发运行?
如果总是 .await 让异步函数运行到完成,那同一时刻就永远只有一个任务运行。

这就是 tokio::spawn 函数登场的地方。

tokio::spawn

tokio::spawn 让你把一个任务交给执行器,而不等待它完成
每次调用 tokio::spawn,你都在告诉 tokio 在后台并发运行被 spawn 的任务,与 spawn 它的任务并行。

下面看怎么用它来并发处理多个连接:

use tokio::net::TcpListener;

pub async fn echo(listener: TcpListener) -> Result<(), anyhow::Error> {
    loop {
        let (mut socket, _) = listener.accept().await?;
        // spawn 一个后台任务来处理连接,
        // 让主任务可以立即继续接收新连接
        tokio::spawn(async move {
            let (mut reader, mut writer) = socket.split();
            tokio::io::copy(&mut reader, &mut writer).await?;
        });
    }
}

异步块 (Asynchronous blocks)

这个例子里我们把一个异步块 (asynchronous block) async move { /* */ } 传给了 tokio::spawn。 异步块是把一段代码标记为异步的快捷方式,无需另定义一个异步函数。

JoinHandle

tokio::spawn 返回一个 JoinHandle
你可以用 JoinHandle.await 后台任务,跟 spawn 线程时使用 join 一样。

pub async fn run() {
    // spawn 一个后台任务把遥测数据发到远端服务器
    let handle = tokio::spawn(emit_telemetry());
    // 与此同时做一些别的有用的事
    do_work().await;
    // 但在遥测数据成功送达之前不返回给调用方
    handle.await;
}

pub async fn emit_telemetry() {
    // [...]
}

pub async fn do_work() {
    // [...]
}

Panic 边界 (Panic boundary)

如果用 tokio::spawn spawn 的任务发生 panic,panic 会被执行器捕获。
如果你不 .await 对应的 JoinHandle,panic 不会传播给 spawn 它的方。 即使你 .awaitJoinHandle,panic 也不会自动传播。 .await 一个 JoinHandle 会得到 Result,错误类型是 JoinError。然后你可以调用 JoinError::is_panic 检查任务是否 panic 了,并选择如何处理这次 panic——记日志、忽略、或传播。

use tokio::task::JoinError;

pub async fn run() {
    let handle = tokio::spawn(work());
    if let Err(e) = handle.await {
        if let Ok(reason) = e.try_into_panic() {
            // 任务 panic 了
            // 我们恢复 panic 的展开,
            // 从而把它传播到当前任务
            panic::resume_unwind(reason);
        }
    }
}

pub async fn work() {
    // [...]
}

std::thread::spawntokio::spawn 对比

可以把 tokio::spawn 看作 std::thread::spawn 的异步孪生兄弟。

注意一个关键区别:使用 std::thread::spawn 时,你把控制权交给了 OS 调度器。 你无法控制线程如何被调度。

使用 tokio::spawn 时,你把控制权交给了一个完全运行在用户态的异步执行器。 底层 OS 调度器并不参与决定接下来运行哪个任务。 现在我们通过所选用的执行器来掌握这个决定。

原文链接:英文原文

运行时架构 (Runtime architecture)

到目前为止我们一直把异步运行时当作一个抽象概念在谈。 我们稍微深入它们的实现方式——你很快会看到,这对我们的代码有影响。

风味 (Flavors)

tokio 提供两种不同的运行时 风味 (flavors)

可以通过 tokio::runtime::Builder 配置运行时:

  • Builder::new_multi_thread 给你一个多线程 tokio 运行时
  • Builder::new_current_thread 则依赖当前线程 (current thread) 来执行。

#[tokio::main] 默认返回多线程运行时, 而 #[tokio::test] 默认使用当前线程运行时。

当前线程运行时 (Current thread runtime)

当前线程运行时,顾名思义,仅依赖它启动所在的那个 OS 线程来调度和执行任务。
使用当前线程运行时时,你有并发 (concurrency) 但没有并行 (parallelism): 异步任务会被交错执行,但任意时刻最多只有一个任务在跑。

多线程运行时 (Multithreaded runtime)

而使用多线程运行时时,任意时刻最多可以有 N 个任务 并行 (in parallel) 运行,其中 N 是运行时使用的线程数。默认情况下,N 等于可用 CPU 核心数。

还不止于此:tokio 实施工作窃取 (work-stealing)
如果某线程空闲,它不会等着:会尝试找一个新的就绪任务来执行——要么从全局队列拿,要么从另一线程的本地队列偷。
当应用面对的工作负载在线程间不完全均衡时,工作窃取能带来显著的性能收益,尤其是对尾部延迟。

影响 (Implications)

tokio::spawn 是风味无关的:不管你跑在多线程还是当前线程运行时上都能工作。代价是它的签名按最坏情况(即多线程)做约束:

pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
    F: Future + Send + 'static,
    F::Output: Send + 'static,
{ /* */ }

我们暂时忽略 Future 特质,关注其余部分。
spawn 要求所有输入都是 Send 且具有 'static 生命周期。

'static 约束遵循与 std::thread::spawn'static 约束相同的逻辑: 被 spawn 的任务可能比 spawn 它的上下文更长寿,因此不应依赖任何在 spawn 上下文销毁后可能被回收的本地数据。

fn spawner() {
    let v = vec![1, 2, 3];
    // 这不会工作,因为 `&v`
    // 活得不够长。
    tokio::spawn(async { 
        for x in &v {
            println!("{x}")
        }
    })
}

Send 则是 tokio 工作窃取策略的直接后果: 在线程 A 上 spawn 的任务可能被搬到空闲的线程 B,因此需要 Send 约束,因为我们要跨线程边界。

fn spawner(input: Rc<u64>) {
    // 这也不会工作,因为
    // `Rc` 不是 `Send`。
    tokio::spawn(async move {
        println!("{}", input);
    })
}

原文链接:英文原文

Future 特质 (The Future trait)

局部 Rc 问题 (The local Rc problem)

回到 tokio::spawn 的签名:

pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
    where
        F: Future + Send + 'static,
        F::Output: Send + 'static,
{ /* */ }

FSend 究竟意味着什么?
正如上一节所见,它意味着 F 从 spawn 环境捕获的所有值都得是 Send。但还不止如此。

任何 跨越 .await 点 持有的值都必须是 Send
看一个例子:

use std::rc::Rc;
use tokio::task::yield_now;

fn spawner() {
    tokio::spawn(example());
}

async fn example() {
    // 一个非 `Send` 的值,
    // 在 async 函数 _内部_ 创建
    let non_send = Rc::new(1);
    
    // 一个什么也不做的 `.await` 点
    yield_now().await;

    // `.await` 之后仍需要这个本地非 `Send` 值
    println!("{}", non_send);
}

编译器会拒绝这段代码:

error: future cannot be sent between threads safely
    |
5   |     tokio::spawn(example());
    |                  ^^^^^^^^^ 
    | future returned by `example` is not `Send`
    |
note: future is not `Send` as this value is used across an await
    |
11  |     let non_send = Rc::new(1);
    |         -------- has type `Rc<i32>` which is not `Send`
12  |     // A `.await` point
13  |     yield_now().await;
    |                 ^^^^^ 
    |   await occurs here, with `non_send` maybe used later
note: required by a bound in `tokio::spawn`
    |
164 |     pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
    |            ----- required by a bound in this function
165 |     where
166 |         F: Future + Send + 'static,
    |                     ^^^^ required by this bound in `spawn`

要理解这是为什么,我们得细化对 Rust 异步模型的理解。

Future 特质 (The Future trait)

我们早先说过 async 函数返回未来体 (futures)——实现了 Future 特质的类型。可以把 future 看作一个状态机 (state machine)。 它处于两种状态之一:

  • pending(待定):计算尚未完成。
  • ready(就绪):计算已完成,输出在此。

这点编码在特质定义里:

trait Future {
    type Output;
    
    // 暂时忽略 `Pin` 和 `Context`
    fn poll(
      self: Pin<&mut Self>, 
      cx: &mut Context<'_>
    ) -> Poll<Self::Output>;
}

poll

poll 方法是 Future 特质的核心。
future 自身什么也不做。它需要被轮询 (polled) 才能推进。
当你调用 poll 时,你在请求 future 做一些工作。 poll 尝试推进,然后返回下列之一:

  • Poll::Pending:future 尚未就绪。你需要稍后再调用 poll
  • Poll::Ready(value):future 已完成。value 是计算结果,类型为 Self::Output

一旦 Future::poll 返回 Poll::Ready,就不应再被 poll:future 已经完成,没有事可做了。

运行时的角色 (The role of the runtime)

你很少(甚至从不)需要直接调用 poll
那是异步运行时的工作:它拥有所需的所有信息(poll 签名中的 Context),确保你的 future 在能推进时尽量推进。

async fn 与 future (async fn and futures)

我们已经使用过高层接口——异步函数。
现在又看了底层原语 Future 特质。

它们怎么关联?

每次你把函数标记为异步,那个函数就会返回一个 future。 编译器会把异步函数体转换为一个状态机 (state machine):每个 .await 点对应一个状态。

回到我们的 Rc 例子:

use std::rc::Rc;
use tokio::task::yield_now;

async fn example() {
    let non_send = Rc::new(1);
    yield_now().await;
    println!("{}", non_send);
}

编译器会把它转换成一个看起来类似下面的枚举:

pub enum ExampleFuture {
    NotStarted,
    YieldNow(Rc<i32>),
    Terminated,
}

调用 example 时,它返回 ExampleFuture::NotStarted。future 还没被 poll 过,所以什么也没发生。
当运行时第一次 poll 它时,ExampleFuture 会推进到下一个 .await 点:会停在状态机的 ExampleFuture::YieldNow(Rc<i32>) 阶段,返回 Poll::Pending
再次被 poll 时,它会执行剩余代码(println!)并返回 Poll::Ready(())

看到它的状态机表示 ExampleFuture 后,就清楚为什么 example 不是 Send 了:它持有一个 Rc,因此不能 Send

让出点 (Yield points)

如刚才用 example 看到的,每个 .await 点都在 future 生命周期里创建一个新的中间状态。
这就是 .await 点也叫让出点 (yield points) 的原因:你的 future 把控制权 让出 (yield) 给正在 poll 它的运行时,允许运行时暂停它,并(必要时)调度另一个任务执行,从而在多个方向上并发推进。

我们会在后面一节再回到让出 (yield) 的重要性。

原文链接:英文原文

不要阻塞运行时 (Don't block the runtime)

让我们回到让出点 (yield points)。
与线程不同,Rust 任务不可被抢占 (preempted)

tokio 不能自行决定暂停一个任务并替它运行另一个。 控制权仅在任务让出时回到执行器——也就是 Future::poll 返回 Poll::Pending 时,或者对 async fn 来说,你 .await 一个 future 时。

这给运行时带来一个风险:如果一个任务从不让出,运行时就永远没法运行另一个任务。这叫阻塞运行时 (blocking the runtime)

什么算阻塞?(What is blocking?)

多长才算太长?任务不让出能撑多久才会成问题?

这取决于运行时、应用、在飞 (in-flight) 任务数以及许多其他因素。但作为一般经验法则,让出点之间的执行尽量不超过 100 微秒。

后果 (Consequences)

阻塞运行时可能导致:

  • 死锁 (Deadlocks):如果不让出的任务在等待另一个任务完成,而那个任务又在等第一个任务让出,那就是死锁。 无法推进——除非运行时能把另一个任务调度到不同线程上。
  • 饿死 (Starvation):其他任务可能跑不起来,或大幅延迟才跑起来,导致性能差(例如尾部延迟高)。

阻塞并不总是显而易见 (Blocking is not always obvious)

某些操作通常应当避免在异步代码里执行,例如:

  • 同步 I/O。你预测不了它要花多久,而且很可能超过 100 微秒。
  • 昂贵的 CPU 密集型计算。

后一类不总是显而易见。例如,对几个元素的向量排序不是问题;如果向量有数十亿条目,评价就变了。

怎么避免阻塞 (How to avoid blocking)

好,那么如果你 必须 执行一项符合或可能符合阻塞条件的操作,怎么避免阻塞运行时?
你需要把工作搬到不同的线程上。你不希望使用所谓的运行时线程——也就是 tokio 用来跑任务的那些线程。

tokio 为此提供了一个专门的线程池,叫阻塞池 (blocking pool)。 你可以用 tokio::task::spawn_blocking 把同步操作 spawn 到阻塞池上。spawn_blocking 返回一个 future,操作完成时该 future 解析为操作结果。

use tokio::task;

fn expensive_computation() -> u64 {
    // [...]
}

async fn run() {
    let handle = task::spawn_blocking(expensive_computation);
    // 在此期间做别的事
    let result = handle.await.unwrap();
}

阻塞池是长生命周期的。spawn_blocking 应当比直接通过 std::thread::spawn 创建新线程更快, 因为线程初始化的成本被多次调用摊销了。

进一步阅读

原文链接:英文原文

异步感知原语 (Async-aware primitives)

如果你浏览 tokio 的文档,会注意到它提供了大量"对应"标准库的类型——但带有异步特性:锁、通道、定时器等。

在异步上下文中工作时,应该优先使用这些异步替代品,而不是其同步对应物。

为了理解为什么,我们看 Mutex,前一章探索过的互斥锁。

案例分析:Mutex (Case study: Mutex)

看一个简单例子:

use std::sync::{Arc, Mutex};

async fn run(m: Arc<Mutex<Vec<u64>>>) {
    let guard = m.lock().unwrap();
    http_call(&guard).await;
    println!("Sent {:?} to the server", &guard);
    // `guard` 在这里被 drop
}

/// 把 `v` 用作 HTTP 调用的请求体。
async fn http_call(v: &[u64]) {
  // [...]
}

std::sync::MutexGuard 与让出点 (std::sync::MutexGuard and yield points)

这段代码能编译,但很危险。

我们尝试在异步上下文中获取一个来自 stdMutex 的锁。 然后我们跨越一个让出点持有得到的 MutexGuard(对 http_call.await)。

设想有两个任务在单线程运行时上并发地执行 run。我们观察到下面的调度事件序列:

     Task A          Task B
        | 
  获取锁
让出给运行时
        | 
        +--------------+
                       |
             尝试获取锁

我们死锁了。Task B 永远拿不到锁,因为锁当前由 Task A 持有,而 Task A 在释放锁之前已经让出给运行时,并且因为运行时不能抢占 Task B 而无法被再次调度。

tokio::sync::Mutex

可以通过切换到 tokio::sync::Mutex 来解决:

use std::sync::Arc;
use tokio::sync::Mutex;

async fn run(m: Arc<Mutex<Vec<u64>>>) {
    let guard = m.lock().await;
    http_call(&guard).await;
    println!("Sent {:?} to the server", &guard);
    // `guard` 在这里被 drop
}

获取锁现在是异步操作,无法推进时会让出给运行时。
回到前面的场景,会变成这样:

       Task A          Task B
          | 
     获取锁
   开始 `http_call`
   让出给运行时
          | 
          +--------------+
                         |
                 尝试获取锁
                  无法获取
                  让出给运行时
                         |
          +--------------+
          |
   `http_call` 完成
       释放锁
    让出给运行时
          |
          +--------------+
                         |
                     获取锁
                      [...]

一切正常!

多线程救不了你 (Multithreaded won't save you)

我们前面的例子用单线程运行时作为执行上下文,但即使使用多线程运行时,同样的风险依然存在。
唯一区别是制造死锁所需的并发任务数量: 单线程运行时里 2 个就够;多线程运行时里需要 N+1 个,其中 N 是运行时线程数。

缺点 (Downsides)

异步感知的 Mutex 带有性能代价。
如果你确信锁没有显著竞争,并且 你小心从不跨越让出点持有它,仍然可以在异步上下文中使用 std::sync::Mutex

但要权衡这点性能收益与你将承担的活性 (liveness) 风险。

其他原语 (Other primitives)

我们用 Mutex 作例子,但同样适用于 RwLock、信号量等。
在异步上下文中工作时,优先使用异步感知版本以最小化问题风险。

原文链接:英文原文

取消 (Cancellation)

一个待定 (pending) 的 future 被丢弃时会发生什么?
运行时不会再 poll 它,因此它不会再有任何进展。 换句话说,它的执行已被取消 (cancelled)

实际中,这经常发生在使用超时时。 例如:

use tokio::time::timeout;
use tokio::sync::oneshot;
use std::time::Duration;

async fn http_call() {
    // [...]
}

async fn run() {
    // 把 future 用一个 10 毫秒后到期的 `Timeout` 包起来。
    let duration = Duration::from_millis(10);
    if let Err(_) = timeout(duration, http_call()).await {
        println!("Didn't receive a value within 10 ms");
    }
}

超时到期时,http_call 返回的 future 会被取消。 设想 http_call 的函数体是这样:

use std::net::TcpStream;

async fn http_call() {
    let (stream, _) = TcpStream::connect(/* */).await.unwrap();
    let request: Vec<u8> = /* */;
    stream.write_all(&request).await.unwrap();
}

每个让出点都成为一个取消点 (cancellation point)
http_call 不能被运行时抢占,所以只能在它通过 .await 让出控制权之后才被丢弃。 这是递归的——例如 stream.write_all(&request) 的实现里很可能也有多个让出点。完全可能看到 http_call 推送了 部分 请求之后被取消,从而丢弃连接、永远没把请求体发送完。

清理 (Clean up)

Rust 的取消机制相当强大——它允许调用方取消正在进行的任务,而无需任务本身做任何配合。
同时这也可能相当危险。可能希望执行优雅取消 (graceful cancellation),确保在中止操作之前完成一些清理任务。

例如,看下面这个虚构的 SQL 事务 API:

async fn transfer_money(
    connection: SqlConnection,
    payer_id: u64,
    payee_id: u64,
    amount: u64
) -> Result<(), anyhow::Error> {
    let transaction = connection.begin_transaction().await?;
    update_balance(payer_id, amount, &transaction).await?;
    decrease_balance(payee_id, amount, &transaction).await?;
    transaction.commit().await?;
}

被取消时,理想情况是显式中止还在 pending 的事务,而不是把它挂在那。 不幸的是,Rust 没有为这种异步清理操作提供万无一失的机制。

最常见的策略是依赖 Drop 特质来调度所需的清理工作。可以是:

  • 在运行时上 spawn 一个新任务
  • 把消息入队到一个通道
  • spawn 一个后台线程

最优选择取决于上下文。

取消已 spawn 的任务 (Cancelling spawned tasks)

当你用 tokio::spawn spawn 一个任务后,你不能再 drop 它;它属于运行时。
不过,如果需要,你可以用它的 JoinHandle 来取消它:

async fn run() {
    let handle = tokio::spawn(/* some async task */);
    // 取消已 spawn 的任务
    handle.abort();
}

进一步阅读

  • tokioselect! 宏"竞速"两个不同的 future 时要格外小心。 在循环中重试同一个任务很危险,除非你能确保取消安全 (cancellation safety)。 详情见 select! 的文档
    如果你需要交错处理两个异步数据流(例如套接字和通道),优先使用 StreamExt::merge
  • 在某些场合,CancellationToken 可能比 JoinHandle::abort 更可取。

原文链接:英文原文

收尾 (Outro)

Rust 的异步模型相当强大,但确实带来了额外的复杂度。花时间了解你的工具:深入 tokio 的文档,熟悉它的原语,让自己物尽其用。

也请记住,语言层面与 std 层面都还在持续工作,以打磨并"补全"Rust 的异步故事。 你日常工作中可能会因为某些缺失部分遇到一些粗糙之处。

下面是几条让异步体验大体顺畅的建议:

  • 选定一个运行时并坚持使用它。
    某些原语(例如定时器、I/O)在运行时之间不可移植。试图混用运行时很可能给你带来麻烦。试图写出运行时无关的代码可能显著增加代码库复杂度。能避免就避免。
  • 目前还没有稳定的 Stream/AsyncIterator 接口。
    AsyncIterator 在概念上是异步产生新元素的迭代器。设计工作正在进行,但(暂时)还没有共识。 如果你用 tokio,把 tokio_stream 当作首选接口。
  • 小心缓冲。
    它经常是隐蔽 bug 的根源。详情见 "Barbara battles buffered streams"
  • 异步任务还没有作用域线程的等价物。
    详情见 "The scoped task trilemma"

不要被这些注意事项吓到:异步 Rust 正被有效地用在 巨型 规模上(例如 AWS、Meta),驱动核心服务。
如果你打算用 Rust 构建网络应用,你将必须掌握它。

原文链接:英文原文

后记 (Epilogue)

我们的 Rust 之旅在此告一段落。
内容相当广泛,但远非全面:Rust 是一门表面积很大的语言,生态系统更大!
不过别让这吓到你:没必要学完所有东西。 你会在做你自己的项目时,逐渐掌握在你所在领域(后端、嵌入式、CLI、GUI 等)所需的内容。

最后,没有捷径:要在某件事上变得在行,你必须一遍又一遍地做它。整门课程下来你写了相当数量的 Rust 代码,足以让语言和它的语法在你指尖流动起来。要再写很多行才会感觉它"是你自己的",但只要你不断练习,那一刻一定会到来。

走得更远 (Going further)

最后给你一些指引,列出你在 Rust 之旅中可能用得上的额外资源。

练习 (Exercises)

更多 Rust 练习可以在 rustlings 项目和 exercism.io 的 Rust 路径中找到。

入门资料 (Introductory material)

如果你想从不同视角看本课程涵盖的概念,请看 Rust 之书 (the Rust book)"Programming Rust"。 你肯定会学到新东西,因为它们涵盖的话题不完全相同;Rust 表面积很大!

进阶资料 (Advanced material)

如果你想更深入这门语言,参考 Rustonomicon"Rust for Rustaceans"
"Decrusted" 系列 也是了解许多最流行 Rust 库内部细节的优秀资源。

领域专门资料 (Domain-specific material)

如果你想用 Rust 做后端开发, 看看 "Zero to Production in Rust"
如果你想用 Rust 做嵌入式开发, 看看 Embedded Rust book

大师班 (Masterclasses)

你还能找到一些跨领域核心话题的资源。
关于测试,看 "Advanced testing, going beyond the basics"
关于遥测 (telemetry),看 "You can't fix what you can't see"

原文链接:英文原文