欢迎
欢迎来到 “学习Rust的100个练习”!
本课程将通过每次一个练习的方式,教你Rust的核心概念。 你将学习Rust的语法、类型系统、标准库和生态系统。
我们不会假设你有任何Rust的先验知识,但我们假设你至少知道另一种编程语言。 我们同样不会假设你有任何系统编程或内存管理的先验知识。这些主题将在课程中涵盖。
换句话说,我们将从零开始! 你将通过小且可控的步骤逐步建立你的Rust知识。到课程结束时,你将解决大约100个练习,足以让你感觉能够舒适地处理小到中等规模的Rust项目。
方法论
本课程基于“边做边学”的原则。 它被设计成互动且亲自动手的形式。
Mainmatter 开发了这门课程, 能够在四天内的课堂环境中进行授课:每位参与者按照自己的进度推进课程,由经验丰富的讲师提供指导,回答问题,并根据需要深入讨论主题。 如果你对我们的培训课程感兴趣,或者想将这门课程引入你的公司,请与我们联系。
你也可以自己跟随课程学习,但我们建议你找一个朋友或导师在遇到困难时帮助你。你也可以在GitHub仓库的solutions分支中找到所有练习的解决方案。
这个课程的中文翻译由鸟窝完成,中文在线版可以访问 [rust100](https://colobu.com/rust100)。
结构
在屏幕的左侧,你可以看到课程被划分为多个章节。每个章节都会介绍Rust语言的一个新概念或特性。 为了检验你的理解程度,每个章节都配套有一个你需要完成的练习。
你可以在配套的GitHub仓库中找到这些练习。 开始课程之前,请确保将仓库克隆到你的本地机器上:
# 如果你已经为GitHub设置了SSH密钥
git clone git@github.com:mainmatter/100-exercises-to-learn-rust.git
# 否则,使用HTTPS链接:
git clone https://github.com/mainmatter/100-exercises-to-learn-rust.git
我们还建议你在分支上进行操作,这样可以轻松跟踪你的进度,并在需要时从主仓库拉取更新:
cd 100-exercises-to-learn-rust
git checkout -b my-solutions
所有练习都位于exercises
文件夹内。每个练习都被构建成一个Rust包的形式。这个包包含了练习本身、操作指南(位于src/lib.rs
中)以及一套测试套件来自动验证你的解决方案。
Workshop运行器 wr
为了验证你的答案,我们提供了一个工具,它将引导你完成整个课程。这就是wr命令行工具(全称为"workshop runner")。通过以下命令安装:
cargo install --locked workshop-runner
新开一个终端,回到仓库的顶层目录,运行wr
命令以启动课程:
wr
wr
会验证当前练习的解答。
在未解决当前章节的练习前,不要继续到下一章节。
我们建议随着课程的进行,将你的解决方案提交到Git,这样可以方便地追踪进度,并在需要时“从已知点”重新开始。
祝学习愉快!
参考
本章节的练习位于exercises/01_intro/00_welcome
作者
本课程由Mainmatter的首席工程顾问Luca Palmieri编写。
Luca自2018年起就开始使用Rust,最初在TrueLayer公司工作,之后在AWS工作。
Luca是《从零到生产环境的Rust》一书的作者,这是学习如何用Rust构建后端应用的首选资源。
他也是多个开源Rust项目的作者和维护者,包括cargo-chef
、Pavex
和wiremock
。
语法
别跳过哦!
在开始这一部分之前,请先完成上一部分的练习。
它位于课程GitHub仓库中的exercises/01_intro/00_welcome
。
使用wr
来开始课程并验证你的解决方案。
前一个任务甚至都不算是一个练习,但它已经让你接触到了不少Rust的语法。 我们不会涵盖之前练习中使用的每一个Rust语法细节。 相反,我们会涵盖足够的内容,以便继续前进而不会陷入细节中。 一步一步来!
注释
你可以使用//
来编写单行注释:
#![allow(unused)] fn main() { // 这是一行单行注释 // 后面跟着另一行单行注释 }
函数
Rust中的函数使用fn关键字定义,后面跟着函数名称、输入参数和返回类型。 函数体用大括号{}括起来。 在前一个练习中,你看到了greeting函数:
#![allow(unused)] fn main() { // `fn` <function_name> ( <input parameters> ) -> <return_type> { <body> } fn greeting() -> &'static str { // TODO: fix me 👇 "I'm ready to __!" } }
greeting
没有输入参数,返回一个字符串切片引用(&'static str)
。
返回类型
如果函数不返回任何值(即返回Rust的单元类型())的话,返回类型可以从签名中省略。 这就是test_welcome函数的情况:
#![allow(unused)] fn main() { fn test_welcome() { assert_eq!(greeting(), "I'm ready to learn Rust!"); } }
上述代码等同于:
#![allow(unused)] fn main() { // 显式地写出单元返回类型 // 👇 fn test_welcome() -> () { assert_eq!(greeting(), "I'm ready to learn Rust!"); } }
返回值
函数中的最后一个表达式会被隐式地返回:
#![allow(unused)] fn main() { fn greeting() -> &'static str { // 这是函数中的最后一个表达式 // 因此它的值会被`greeting`返回 "I'm ready to learn Rust!" } }
你也可以使用return
关键字提前返回一个值:
#![allow(unused)] fn main() { fn greeting() -> &'static str { // 注意行尾的分号! return "I'm ready to learn Rust!"; } }
当可能时,省略return
关键字被认为是惯用的写法。
输入参数
输入参数在函数名后面的括号()
内声明。
每个参数都用其名称、一个冒号:
和它的类型来声明。
例如,下面的greet
函数接受一个类型为&str
(字符串切片)的name
参数:
#![allow(unused)] fn main() { // 一个输入参数 // 👇 fn greet(name: &str) -> String { format!("Hello, {}!", name) } }
如果有多个输入参数,它们必须用逗号分隔开。
类型注解
既然我们已经提到了"类型"几次,让我们明确一下:Rust是一种静态类型语言。 Rust中的每个值都有一个类型,并且该类型在编译时必须为编译器所知。
类型是一种静态分析的形式。
你可以将类型看作是编译器附加在你程序中每个值上的标签。根据这个标签,编译器可以强制执行不同的规则——例如,你不能将一个字符串加到一个数字上,但你可以将两个数字相加。
如果使用正确,类型可以防止整个类别的运行时错误。
参考资料
- 本节的练习位于
exercises/01_intro/01_syntax
一个基本计算器
在本章中,我们将学习如何将Rust用作计算器。这可能听起来没什么大不了的,但它将让我们有机会涵盖Rust的许多基础知识,例如:
- 如何定义和调用函数
- 如何声明和使用变量
- 基本类型(整数和布尔值)
- 算术运算符(包括上溢和下溢行为)
- 比较运算符
- 控制流
- 异常
通过几个练习掌握基础知识,语言就会在你的手指下流畅运行。当我们继续讨论更复杂的主题时,例如特征和所有权,你将能够专注于新概念,而不会被语法或其他琐碎的细节拖累。
参考资料
- 本节的练习位于
exercises/02_basic_calculator/00_intro
类型,第1部分
在"语法"部分中,compute
的输入参数类型为 u32
。让我们来解开这个是什么意思。
基本类型
u32
是Rust的基本类型之一。基本类型是语言最基本的构建模块。它们内置于语言本身中——也就是说,它们不是由其他类型定义的。
你可以组合这些基本类型来创建更复杂的类型。我们很快就会看到怎么做。
整数
具体来说,u32
是一个无符号32位整数。
整数是一个不包含小数部分的数字。例如,1
是一个整数,而1.2
不是。
有符号与无符号
整数可以是有符号或无符号的。无符号整数只能表示非负数(即0
或更大)。有符号整数可以表示正数和负数(例如-1
、12
等)。
u32
中的u
代表无符号。等效的有符号整数类型是i32
,其中i
代表整数(即任何整数,正数或负数)。
位宽
u32
中的32
是指用于在内存中表示该数字的位数1。位数越多,可以表示的数字范围就越大。
Rust支持多个位宽的整数:8
、16
、32
、64
、128
。
用32位,u32
可以表示从0
到2^32 - 1
(又称u32::MAX
)的数字。
用相同的位数,有符号整数(i32
)可以表示从-2^31
到2^31 - 1
的数字(即从i32::MIN
到i32::MAX
)。
i32
的最大值小于u32
的最大值,因为一个位用于表示数字的符号。
更多关于有符号整数在内存中是如何表示的详细信息,请查看二补码表示。
总结
将有符号/无符号和位宽两个变量结合,我们得到以下整数类型:
位宽 | 有符号 | 无符号 |
---|---|---|
8位 | i8 | u8 |
16位 | i16 | u16 |
32位 | i32 | u32 |
64位 | i64 | u64 |
128位 | i128 | u128 |
字面值
字面值是在源代码中表示固定值的符号。例如,42
是Rust中表示四十二这个数字的字面值。
字面值的类型注解
但是Rust中所有值都有类型,所以...42
是什么类型呢?
Rust编译器会尝试根据使用环境来推断字面值的类型。如果你不提供任何上下文信息,编译器将默认为i32
整数字面值。如果你想使用其他类型,你可以在字面值后面添加所需的整数类型作为后缀——例如2u64
表示一个显式声明为u64
类型的2。
字面值中的下划线
你可以使用下划线_
来提高大数字的可读性。例如,1_000_000
和1000000
是相同的。
算术运算符
Rust支持以下用于整数的算术运算符2:
+
用于加法-
用于减法*
用于乘法/
用于除法%
用于取余
这些运算符的优先级和结合性规则与数学中的相同。你可以使用括号来覆盖默认的优先级,例如2 * (3 + 4)
。
⚠️ 警告
当用于整数类型时,除法运算符
/
执行整数除法。 换句话说,结果会被截断为0。例如,5 / 2
的结果是2
,而不是2.5
。
没有自动类型强制转换
正如我们在上一个练习中讨论的,Rust是一种静态类型语言。具体来说,Rust对类型强制转换非常严格。即使转换是无损的,它也不会自动将一个值从一种类型转换为另一种类型3,你必须显式地进行转换。
例如,你不能将一个u8
值赋给类型为u32
的变量,即使所有的u8
值都是有效的u32
值:
#![allow(unused)] fn main() { 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
|
我们将在本课程的后面看到如何在不同类型之间进行转换。
参考资料
本节的练习位于 exercises/02_basic_calculator/01_integers
进一步阅读
好的,以下是这些内容的翻译:
位是计算机中最小的数据单位。它只能有两个值:0或1。
Rust不允许你定义自定义运算符,但它让你可以控制内置运算符的行为。在我们讨论了特征之后,将会在本课程的后面讨论运算符重载。
对于这个规则也有一些例外,主要与引用、智能指针和人体工程学有关。我们将稍后介绍这些内容。目前,"所有转换都是显式的"这个心智模型将为你提供很好的服务。
变量
在 Rust 中,你可以使用 let
关键字来声明变量。
例如:
#![allow(unused)] fn main() { let x = 42; }
上面我们定义了一个变量 x
并给它赋值为 42
。
类型
Rust 中的每个变量都必须具有一个类型,这个类型可以由编译器推断定,或者由开发者显式指定。
显式类型标注
你可以通过在变量名后加上 :
再跟上类型的方式来指定变量的类型。例如:
#![allow(unused)] fn main() { // let <variable_name>: <type> = <expression>; let x: u32 = 42; }
在上述示例中,我们明确地将 x
的类型约束为 u32
。
类型推断
如果我们不指定变量的类型,编译器会根据变量使用的上下文尝试推断其类型。
#![allow(unused)] fn main() { let x = 42; let y: u32 = x; }
在上面的例子中,我们没有指定 x
的类型。
由于 x
被赋给了显式类型为 u32
的 y
,且 Rust 不会自动类型转换,编译器因此推断定 x
的类型为 u32
—— 和 y
的类型一致,这样才能保证程序编译不报错。
推断限制
有时编译器需要一些辅助信息来基于变量的使用情况推断正确的类型。
这种情况下,你会遇到编译错误,编译器会要求你提供显式类型提示以消除歧义。
函数参数也是变量
不是所有英雄都穿披风衣,也不是所有变量都用 let
声明。
函数参数也是变量!
#![allow(unused)] fn main() { fn add_one(x: u32) -> u32 { x + 1 } }
在上述示例中,x
是类型为 u32
的变量。
x
和用 let
声明的变量唯一区别在于,函数参数必须显式*声明类型。编译器不会为你推断。
这一约束使得 Rust 编译器(以及我们人类!)能在不看实现细节的情况下理解函数的签名,大大提升了编译速度1!
初始化
声明变量时不必立即初始化。
例如:
#![allow(unused)] fn main() { let x: u32; }
是一个有效的变量声明。
但是,在使用变量之前必须初始化它。否则编译器会报错:
#![allow(unused)] fn main() { 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;
| +++
参考
- 本节练习位于
exercises/02_basic_calculator/02_variables
Rust 编译器在提升编译速度方面需要一切可能的帮助。
控制制流,第一部分
迄今为止,我们的所有程序都相当直接。
一系列指令自上而下执行,仅此而已。
现在是引入分支的时候了。
if
表达式
if
关键字用于仅在条件为真时执行一段代码。
这里有个简单示例:
#![allow(unused)] fn main() { let number = 3; if number < 5 { println!("`number` 小于5"); } }
此程序会打印 number 小于5
,因为条件 number < 5
为真。
与多数编程语言一样,Rust 支持可选的 else
分支,在 if
表达式中的条件为假时执行代码块。
例如:
#![allow(unused)] fn main() { let number = 3; if number < 5 { println!("`number` 小于5"); } else { println!("`number` 大于或等于5"); } }
布尔值
if
表达式中的条件必须为类型 bool
,即布尔值。
布尔值,与整数一样,在 Rust 中是原始类型。
布尔值有两个可能:true
或 false
。
真值与非真值
如果 if
表达式中的条件不是布尔值,你会遇到编译错误。
例如,以下代码无法编译:
#![allow(unused)] fn main() { let number = 3; if number { println!("`number` 不为零"); } }
你会得到以下编译错误:
error[E0308]: mismatched types
--> src/main.rs:3:8
|
3 | if number {
| ^^^^^^ expected `bool`, found integer
这源自 Rust 的类型强制哲学:不存在从非布尔类型到布尔类型的自动转换。
不像JavaScript 或 Python, Rust 不具备 真值
或 假值
的概念。
你需要明确你想检查的条件。
比较运算符
使用比较运算符构建if 表达式的条件非常常见。 在处理整数时,Rust 提供以下比较运算符:
==
:等于!=
:不等于<
:小于>
:大于<=
:小于或等于>=
:大于或等于
参考
本节练习位于 exercises/02_basic_calculator/03_if_else
Panic 恐慌
让我们回顾一下在“变量”章节中编写的 speed
函数。大概像这样:
#![allow(unused)] fn main() { 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。
panic是Rust表明出现了严重错误,程序无法继续执行,是不可恢复的错误2。除以零属于这类错误。
panic! 宏
你可以通过调用 panic!
宏3故意触发恐慌:
fn main() { panic!("This is a panic!"); // The line below will never be executed let x = 1 + 2; }
Rust 还有其他处理可恢复错误的机制,我们将在稍后讨论。 目前,我们将使用恐慌作为简单但直接的临时解决方案。
参考
本节练习位于 exercises/02_basic_calculator/04_panics
深入一步阅读
speed
还有另一个问题,我们很快会解决。你能发现吗?
你可以尝试捕获恐慌,但这应作为最后的手段,仅保留于特定情况。
如果后面跟着 !
,那便是宏的调用法。目前可以把宏想象为加了调料的函数。我们将在课程后面更深入地讲解它们。
阶乘
迄今为止,你已经学到了:
- 如何定义函数
- 如何调用函数
- Rust 中有哪些整数类型可用
- 整数运算提供了哪些算术操作符
- 如何通过比较和 if/else 表达式来执行条件逻辑
看来你已经准备好应对阶乘啦!
参考资料
本节练习位于 exercises/02_basic_calculator/05_factorial
循环,第一部分:while
你的阶乘实现被迫采用了递归方法。
如果你来自函数式编程背景,这对你来说可能感觉很自然。或者,如果你习惯了C或Python这样的命令式语言,这可能会觉得有些奇怪。
让我们看看如何使用循环来实现相同的功能。
while
循环
while``循环是一种在条件为真时执行代码块的方法。
一般语法如下:
#![allow(unused)] fn main() { while <condition> { // code to execute } }
例如,我们可能想计算1到5的数字之和:
#![allow(unused)] fn main() { let sum = 0; let i = 1; // "while i is less than or equal to 5" while i <= 5 { // `+=` is a shorthand for `sum = sum + i` sum += i; i += 1; } }
这会持续将1加到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中的变量默认是不可变的。
一旦赋值后,就不能改变其值。
如果你想允许修改,就需要使用mut
关键字声明变量为可变:
#![allow(unused)] fn main() { // `sum` and `i` are mutable now! let mut sum = 0; let mut i = 1; while i <= 5 { sum += i; i += 1; } }
这样就能正常编译和运行了。
参考资料
本节练习位于 exercises/02_basic_calculator/06_while
进一步阅读
循环,第二部分:for
手动递增计数器变量略显繁琐,而且这种模式极为普遍!为了简化这一过程,Rust提供了一种更简洁的方式来遍历一系列值:for
循环。
for循环
for循环是一种针对迭代器1中每个元素执行代码块的方式1。
基本语法如下:
#![allow(unused)] fn main() { for <element> in <iterator> { // code to execute } }
区间
Rust的标准库提供了区间类型,可用于遍历一系列数字2。
例如,如果我们想计算1到5的数字之和:
#![allow(unused)] fn main() { let mut sum = 0; for i in 1..=5 { sum += i; } }
每次循环运行时,i
都会被赋予区间的下一个值,然后执行代码块。
Rust中有五种类型的区间:
1..5
:半开区间。包含从1到4的所有数字。不包括最后一个值,即5。1..=5
:包含结束值的区间。包含从1到5的所有数字,包括最后一个值,即5。1..
:开放式区间。从1开始,理论上到整数最大值的所有数字(实际上是直到整数类型的上限)。..5
:从整数类型的最小值开始,到4的所有数字。不包括最后一个值,即5。..=5
:从整数类型的最小值开始,到5的所有数字。包括最后一个值,即5。
你可以使用for
循环配合前三种区间,其中起始点是明确指定的。最后两种区间类型则用于其他情境,我们将在后续学习中覆盖。
区间的极端值不必是整数字面量——它们也可以是变量或表达式!
例如:
#![allow(unused)] fn main() { let end = 5; let mut sum = 0; for i in 1..(end + 1) { sum += i; } }
参考资料
本节练习位于 exercises/02_basic_calculator/07_for
进一步阅读
在课程后期,我们将给出“迭代器”的精确定义。目前,可以将其理解为一个你可以遍历的值序列。
区间也可以与其他类型一起使用(比如字符和IP地址),但在日常的Rust编程中,整数无疑是最常见的应用场景。
溢出
一个数的阶乘增长速度相当快。例如,20的阶乘是2,432,902,008,176,640,000,这已经超过了32位整数的最大值2,147,483,647。
当算术操作的结果超过给定整数类型的极限值时,我们就遇到了整数溢出的问题。
整数溢出是个问题,因为它违背了算术操作的约定。两个特定类型整数之间的算术操作结果应该还是同类型的另一个整数。但数学上正确的结果却超出了那个整数类型能表示的范围!
如果结果小于给定整数类型的最小值,我们称其为整数下溢。
为了简洁,本节余下的部分我们将只讨论整数溢出,但请记住,我们所说的一切同样适用于整数下溢。你在"变量"部分编写的
speed
函数在某些输入组合下发生了下溢。
例如,如果end
小于start
,那么end - start
将会导致u32
类型下溢,因为结果应该是负数,而u32
无法表示负数。
无自动提升
一种可能的处理方式是自动将结果提升到更大的整数类型。
例如,当你相加两个u8
整数,如果结果是256(u8::MAX + 1
),Rust可以选择将结果解释为u16
,这是足够容纳256的下一个整数类型。
然而,正如我们之前讨论的,Rust对类型转换相当严格。自动整数提升并不是Rust解决整数溢出问题的方法。
替代方案
既然排除了自动提升,当发生整数溢出时我们能做什么呢?
归结起来有两种不同的方法:
- 拒绝该操作
- 提出一个“合理”的结果,使其适应预期的整数类型
拒绝操作
这是最保守的方法:当发生整数溢出时停止程序。
这是通过我们在"恐慌"部分已经见过的恐慌机制来实现的。
提出一个“合理”的结果
当算术操作的结果超过给定整数类型的最大值时,你可以选择环绕。
如果你将给定整数类型的所有可能值想象成一个圆圈,环绕就意味着当你达到最大值时,你又从最小值开始。
例如,如果你在1和255(=u8::MAX
)之间进行环绕加法,结果是0(=u8::MIN
)。如果你使用有符号整数,同样的原则适用。例如,将1加到127(=i8::MAX
)并采用环绕,将给你-128(=i8::MIN
)。
overflow-checks
Rust让你,作为开发者,选择当整数溢出时采取哪种方法。
这种行为由overflow-checks
配置设置控制。
如果overflow-checks
设置为true
,Rust在整数操作溢出时将在运行时恐慌。如果overflow-checks
设置为false
,Rust在整数操作溢出时将环绕。
你可能想知道——配置文件设置是什么?让我们来了解一下!
配置文件
一个配置文件是一组配置选项,可以用来定制Rust代码的编译方式。
Cargo提供了两个内置的配置文件:dev
和release
。
dev
配置文件在每次你运行cargo build
、cargo run
或cargo test
时使用。它旨在本地开发,因此牺牲了运行时性能以换取更快的编译时间和更好的调试体验。
相反,release
配置文件针对运行时性能进行了优化,但会导致更长的编译时间。你需要通过--release
标志明确请求——例如,cargo build --release
或cargo run --release
。
“你是否以发布模式构建了你的项目?”几乎成了Rust社区的一个梗。
它指的是不熟悉Rust并在社交媒体(如Reddit、Twitter等)上抱怨其性能,却没意识到自己还没以发布模式构建项目的开发者。
你还可以定义自定义配置文件或自定义内置文件。
overflow-check
设置
默认情况下,overflow-checks
设置为:
- 对于
dev
配置文件设为true
- 对于
release
配置文件设为false
这符合两个配置文件的目标。
dev
旨在本地开发,因此它会在尽可能早的时候出现潜在问题时恐慌。
而release
则是为运行时性能优化:检查溢出会减慢程序,所以它更倾向于环绕。
同时,两个配置文件的不同行为可能导致微妙的bug。
我们的建议是对两个配置文件都启用overflow-checks
:宁可崩溃也不要默默地产生错误结果。在大多数情况下,运行时性能的影响微乎其微;如果你正在开发对性能要求严格的应用程序,你可以运行基准测试来决定是否能够接受这一点。
参考资料
- 本节练习位于
exercises/02_basic_calculator/08_overflow
进一步阅读
- 查看"关于Rust中整数溢出的神话与传说"深入了解Rust中的整数溢出。
你可以尝试捕捉恐慌,但这应该是仅在非常特殊的情况下才考虑的最后手段。
根据具体情况的行为
overflow-checks
是一个较为粗糙的工具:它是一个全局设置,影响整个程序。
通常情况下,你可能希望根据不同的上下文来区别处理整数溢出:有时环绕是正确的选择,而有时恐慌更为可取。
wrapping_
方法
你可以通过使用 wrapping_
方法1在每次操作的基础上选择执行环绕算术。
例如,你可以使用 wrapping_add
来带环绕地添加两个整数:
#![allow(unused)] fn main() { let x = 255u8; let y = 1u8; let sum = x.wrapping_add(y); assert_eq!(sum, 0); }
saturating_
方法
或者,你可以使用 saturating_
方法选择执行饱和算术。
饱和算术不会进行环绕,而是会返回整数类型的最大或最小值。例如:
#![allow(unused)] fn main() { let x = 255u8; let y = 1u8; let sum = x.saturating_add(y); assert_eq!(sum, 255); }
由于 255 + 1
等于 256
,这超过了 u8::MAX
,所以结果就是 u8::MAX
(即255)。
对于下溢也是如此:0 - 1
是 -1
,这小于 u8::MIN
,因此结果就是 u8::MIN
(即0)。
你无法通过 overflow-checks
配置文件设置来获得饱和算术——在执行算术操作时,你必须明确选择它。
参考资料
- 本节练习位于
exercises/02_basic_calculator/09_saturating
你可以将方法视为“附着”到特定类型的函数。 我们将在下一章中涵盖方法(以及如何定义它们)。
类型转换,第一部分
我们已经多次强调过,Rust 不会为整数执行隐式类型转换。
那么,如何进行显式转换呢?
as
关键字
你可以使用 as
关键字在整数类型之间进行转换。
as
转换是不会失败的。
例如:
#![allow(unused)] fn main() { let a: u32 = 10; // 将 `a` 转换为 `u64` 类型 let b = a as u64; // 你可以使用 `_` 作为目标类型 // 如果编译器能正确推断出来的话 // 比如: let c: u64 = a as _; }
这种转换的语义是你所期望的:所有 u32
的值都是有效的 u64
值。
截断
如果我们反向进行就会更有趣:
#![allow(unused)] fn main() { // 一个太大以至于无法放入 `u8` 的数字 let a: u16 = 255 + 1; let b = a as u8; }
此程序将无问题运行,因为 as
转换是绝对不会失败的。
但 b
的值是多少呢?
从较大的整数类型转换到较小的类型时,Rust 编译器会执行截断。
要了解发生了什么,我们先来看 256u16
在内存中是如何表示的,即一系列位:
0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0
| | |
+---------------+---------------+
高8位 低8位
转换为 u8
时,Rust 编译器将保留 u16
内存表示的最后8位:
0 0 0 0 0 0 0 0
| |
+---------------+
低8位
因此,256 as u8
等于 0
。这在大多数场景下...不太理想。
实际上,如果编译器看到你试图进行会导致截断的字面值转换,它会主动阻止你:
错误:字面值超出 `i8` 的范围
|
4 | let a = 255 as i8;
| ^^^
|
= 注意:字面值 `255` 无法放入范围为 `-128..=127` 的 `i8` 类型中
= 帮助:考虑使用类型 `u8` 代替
= 注意:默认启用了 `#[deny(overflowing_literals)]`
建议
总的来说,使用 as
转换要非常小心。
仅限于从较小类型转换到较大类型时使用。若要从较大整数类型转换到较小的整数类型,请依赖于我们将在课程后期探索的可失败的转换机制。
局限性
令人惊讶的行为并不是 as
转换的唯一缺点。它也相当有限:你只能依靠 as
转换用于原始类型和其他少数特殊情况。
在处理复合类型时,你将需要依赖于不同的转换机制(可失败的 和 不可失败的),我们将在后续内容中探讨。
参考资料
- 本节练习位于
exercises/02_basic_calculator/10_as_casting
进一步阅读
- 查阅 Rust 官方参考文档的这一部分,了解每种源类型和目标类型组合下
as
转换的确切行为,以及所有允许的转换列表。
模拟建一个票务系统
第一章应已让你对Rust的基本类型、操作符和控制流程构造有了良好的掌握。
本章,我们将更进一步探讨使Rust真正独特之处:所有权
所有权让Rust既内存安全,又高效,无需垃圾回收器。
以一个票务系统(类似JIRA)为例,你用于跟踪软件项目中的错误、特性或任务。
我们将尝试用Rust建模它。这只是初版,不完美,也不够地道,章节末。但足够挑战!
前进需掌握几个Rust概念:
struct
,定义自定义类型的方式之一- 所有权和借用
- 内存管理:栈、指针、数据布局、析构
- 模块和可见性- 字符串
参考
- 本节练习位于
exercises/03_ticket_v1/00_intro
结构体(Struct)
我们需要为每个工单追踪三部分信息:
- 标题
- 描述
- 状态
我们可以先使用 String
来表示它们。String
是Rust标准库中定义的类型,用于表示UTF-8编码的文本。
但是,我们如何将这三部分信息合并为一个实体呢?
定义一个struct
struct
定义了一个新的Rust类型。
#![allow(unused)] fn main() { struct Ticket { title: String, description: String, status: String, } }
struct
与你在其他编程语言中称为类或对象的东西非常相似。
定义字段
新类型是通过组合其他类型作为字段建立的。
每个字段都需要一个名字和一个类型,中间用冒号分隔开::
如果有多个字段,则用逗号,
分隔开。
字段不必是同一类型,如下面的Configuration
结构体所示:
#![allow(unused)] fn main() { struct Configuration { version: u32, active: bool, } }
实例化
通过为每个字段指定值可以创建一个struct
的实例:
#![allow(unused)] fn main() { // 语法:<StructName> { <field_name>: <value>, ... } let ticket = Ticket { title: "建立一个工单系统".into(), description: "创建一个可以在看板上管理工单的系统".into(), status: "打开".into()}; }
访问字段
你可以使用.
操作符访问struct
的字段:
#![allow(unused)] fn main() { // 字段访问 let x = ticket.description; }
方法
我们可以通过定义方法为我们的struct
附加行为。
以Ticket
结构体为例:
#![allow(unused)] fn main() { impl Ticket { fn is_open(&self) -> bool { self.status == "Open" } } // 语法: // impl <StructName> { // fn <method_name>(&self, <parameters>) -> <return_type> { // // 方法体 // } // } }
方法与函数很相似,但有两个关键区别:
- 方法必须在**
impl
块内定义 - 方法可以使用
self
作为它们的第一个参数。self
是一个关键字,代表调用其调用方法的struct
实例。
self
如果方法以self
作为其第一个参数,它可以使用方法调用语法调用:
#![allow(unused)] fn main() { // 方法调用语法: <instance>.<method_name>(<parameters>) let is_open = ticket.is_open(); }
这与上一章中对u32
值执行饱和算术操作使用的调用语法相同02_basic_calculator/09_saturating.md。
静态方法
如果方法不以self
作为其第一个参数,它是一个静态方法。
#![allow(unused)] fn main() { struct Configuration { version: u32, active: bool, } impl Configuration { // `default` 是 `Configuration` 上的静态方法 fn default() -> Configuration { Configuration { version: 0, active: false } } }
调用静态方法的唯一方式是使用函数调用语法:
#![allow(unused)] fn main() { // 函数调用语法: <StructName>::<method_name>(<parameters>) let default_config = Configuration::default(); }
等价性
即使以self
作为第一个参数的方法,你也可以使用函数调用语法:
#![allow(unused)] fn main() { // 函数调用语法: <StructName>::<method_name>(<instance>, <parameters>) let is_open = Ticket::is_open(ticket); }
函数调用语法清晰地表明ticket
作为self
,方法的第一个参数在使用,但确实更冗长。可能时优先使用方法调用语法。
参考资料
- 本节练习位于
exercises/03_ticket_v1/01_struct
校验
回到我们的工单定义:
#![allow(unused)] fn main() { struct Ticket { title: String, description: String, status: String, } }
我们在Ticket
结构体的字段中使用了“原始”类型。这意味着用户可以创建一个标题为空、描述超级长或状态无意义(例如"Funny")的工单。我们可以做得更好!
参考资料
- 本节练习位于
exercises/03_ticket_v1/02_validation
进一步阅读
- 查看
String
的文档详细了解它提供的方法。你做练习时会用到的!
模块
你刚定义的new
方法试图对Ticket
的字段值实施一些约束。但这些约束真的被执行了吗?有什么能阻止开发者不通过Ticket::new
直接创建Ticket
呢?
要实现真正的封装,你需要了解两个新概念:可见性和模块。我们先从模块开始讲起。
什么是模块?
在Rust中,模块是一种将相关代码组织在一起的方式,置于一个共同的命名空间下(即模块名)。你已经看过模块的实践了:验证代码正确性的单元测试被定义在一个不同的模块里,名为tests
。
#![allow(unused)] fn main() { #[cfg(test)] mod tests { // [...] } }
内联模块
上面的tests
模块是内联模块的例子:模块声明(mod tests
)和模块内容(里面的内容{ ... }
)紧挨着一起。
模块树
模块可以嵌套,形成树状结构。树的根是crate本身**,即包含所有其他模块的顶级模块。对于库,根模块通常是src/lib.rs
(除非位置被自定义过)。
根模块也被称为crate根。
根模块可以有子模块,它们反过来也有自己的子模块,以此类推。
外部模块和文件系统
内联模块对小段代码很有用,但随着项目成长,你会想把代码拆分成多个文件。在父模块里,你用mod
关键字声明子模块的存在。
#![allow(unused)] fn main() { mod dog; }
Rust的构建工具cargo
则负责找到包含模块实现的文件。如果你的模块声明在crate的根目录(如src/lib.rs
或src/main.rs
),cargo
期待文件命名为:
src/<module_name>.rs
src/<module>/mod.rs
如果你的模块是另一个模块的子模块,文件应命名为:
[..]/<parent_module>/<module>.rs
[..]/<module>/mod.rs
比如,如果是animals
的子模块,那么src/animals/dog.rs
或src/og/mod.rs
。
你的IDE可能在你用mod
关键字声明新模块时自动帮你创建这些文件。
项路径和use
语句
同一模块里的项可以直接访问,不需要特别语法。直接用它们的名字就行。
#![allow(unused)] fn main() { struct Ticket { // [...] } // 这里不需要限定`Ticket`的任何方式 //因为我们处于同一模块 fn mark_ticket_done(ticket: Ticket) { // [...]} }
但如果你想从不同模块访问实体就不是这样了。你得用指向要访问实体的路径。
路径可以用多种方式组合:
- 从当前crate根开始,比如
crate::module_1::module_2::MyStruct
- 从父模块开始,比如
super::my_function
- 从当前模块开始,比如
sub_module::MyStruct
每次引用类型都写全路径可能很繁琐。为了方便,你可以引入use
语句来把实体引入作用域。
#![allow(unused)] fn main() { // 引入MyStruct`到作用域 use crate::module_1::module_2::MyStruct; // 现在可以直接引用`MyStruct` fn a_function(s: MyStruct) { // [...]} }
星号导入
你也可以用一个use
语句导入一个模块的所有项。
#![allow(unused)] fn main() { use crate::module_1::module_2::*; }
这称为星号导入。
通常不鼓励这样做因为它可能会污染当前命名空间,使得难以理解每个名字来自哪里,并且潜在地引起名称冲突。
尽管如此,在某些情况它还是有用的,比如写单元测试时。你可能注意到多数测试模块以use super::*;
开始,引入父模块(被测试的模块)的所有项到作用域。
参考资料
- 本节练习位于
exercises/03_ticket_v1/03_modules
可见性
当你开始将代码分解成多个模块时,就需要开始考虑可见性的问题了。可见性决定了你的代码(或其他人的代码)中哪些部分能够访问给定的实体,不论是结构体、函数、字段等。
默认私有
Rust中,默认一切都是私有的。
私有实体只能在以下情况下被访问:
- 定义它的同一个模块内部,或
- 其子模块之一
在之前的练习中,我们广泛使用了这一点:
create_todo_ticket
工作正常(一旦你添加了use
语句),因为helpers
是crate根模块的子模块,而Ticket
在那里被定义。因此,create_todo_ticket
可以无障碍地访问Ticket
,即便Ticket
是私有的。- 所有单元测试都定义在其测试代码的子模块中,因此可以不受限制地访问一切。
可见性修饰符
你可以使用可见性修饰符来修改实体的默认可见性。一些常见的可见性修饰符包括:
pub
:使实体公开,即在定义它的模块之外也能访问,可能还允许其他crate访问。pub(crate)
:在同一个crate内部公开实体,但不允许外部访问。pub(super)
:在父模块中公开实体。pub(in path::to::module)
:在指定的模块中公开实体。
你可以在模块、结构体、函数、字段等上使用这些修饰符。 例如:
#![allow(unused)] fn main() { pub struct Configuration { pub(crate) version: u32, active: bool, } }
Configuration
是公开的,但你只能在同一crate内访问version
字段。
相反,active
字段是私有的,只能在同一个模块或其子模块内部访问。
参考资料
- 本节练习位于
exercises/03_ticket_v1/04_visibility
封装
现在我们对模块和可见性有了基本的了解,让我们回到封装的概念上来。封装是隐藏对象内部表示的做法,最常用于强制执行对象状态的一些不变量。
回到我们的Ticket
结构体:
#![allow(unused)] fn main() { struct Ticket { title: String, description: String, status: String, } }
如果所有字段都是公开的,就没有封装。你必须假设字段可以在任何时候被修改为由其类型允许的任何值。你不能排除票证明确实可能有空标题或没有意义的状态。
为了执行更严格的规则,我们必须保持字段私有1。然后,我们可以提供公共方法来与Ticket
实例交互。那些公共方法将负责维护我们的不变量(例如,标题不能为空)。
如果所有字段都是私有的,那么就不能直接使用结构体实例化语法创建Ticket
实例了:
#![allow(unused)] fn main() { // 这样做不行! let ticket = Ticket { title: "建立一个工单系统".into(), description: "创建一个可以在看板上管理工单的系统".into(), status: "打开".into()} }
在前面关于可见性的练习中你已经看到了这个操作。我们现在需要提供一个或多个构造函数,即可以从模块外部使用的静态方法或函数来创建结构体的新实例。幸运的是,我们已经有了一个:Ticket::new
,如之前练习中实现的这里。
访问器方法
总结一下:
Ticket
的所有字段都是私有的- 我们提供了一个构造函数,
Ticket::new
,它在创建时强制执行了我们的验证规则
这是一个好的开始,但还不够:除了创建Ticket
之外,我们还需要与其交互。但如果字段是私有的,我们怎么访问呢?
我们需要提供访问器方法。访问器方法是公共方法,允许你读取私有结构体字段(或字段)的值。
Rust不像其他一些语言那样内置了为你生成访问器方法的方式。你需要自己编写它们——它们只是常规的方法。
参考
- 本节练习位于
exercises/03_ticket_v1/05_encapsulation
或者细化它们的类型,这是我们将在后面探索的技术这里。
所有权
如果你按照本课程目前所学的内容解决了上一个练习,你的访问器方法可能看起来像这样:
#![allow(unused)] fn main() { impl Ticket { pub fn title(self) -> String { self.title } pub fn description(self) -> String { self.description } pub fn status(self) -> String { self.status } } }
这些方法可以编译通过,并且足以让测试通过,但在实际场景中,它们不会让你走得太远。考虑这段代码:
#![allow(unused)] fn main() { if ticket.status() == "待办" { // 尽管我们还没讲到 `println!` 宏, // 但目前只需知道它会将(模板化的)消息打印到控制台 println!("你的下一个任务是: {}", ticket.title()); } }
如果你尝试编译它,你会得到一个错误:
错误[E0382]: 使用了已移动的值: `ticket`
--> src/main.rs:30:43
|
25 | let ticket = Ticket::new(/* */);
| ------ `ticket` 因为此处类型为 `Ticket` 而被移动,
| 此类型未实现 `Copy` 特征
26 | if ticket.status() == "待办" {
| -------- `ticket` 因为此方法调用而被移动
...
30 | println!("你的下一个任务是: {}", ticket.title());
| ^^^^^^ 在移动后此处再次使用了值
|
注意: `Ticket::status` 接收者 `self` 采用所有权,这导致 `ticket` 被移动
--> src/main.rs:12:23
|
12 | pub fn status(self) -> String {
| ^^^^
恭喜,这是你遇到的第一个借用检查器错误!
Rust所有权系统的优点
Rust的所有权系统旨在确保:
- 数据在被读取时从不被修改
- 数据在被修改时从不被读取
- 数据被销毁后不再被访问
这些约束由借用检查器强制执行,它是Rust编译器的一个子系统,经常成为Rust社区中笑话和梗的主题。
所有权是Rust中的一个关键概念,也是使该语言独特的原因。所有权使得Rust能够在不牺牲性能的情况下提供内存安全性。对于Rust来说,以下所有内容同时都是真实的:
- 没有运行时垃圾收集器
- 作为开发者,你很少需要直接管理内存
- 你无法造成悬挂指针、重复释放以及其他与内存相关的错误
像Python、JavaScript和Java这样的语言给你2.和3.,但不提供1.。像C或C++这样的语言给你1.,但不提供2.和3.。
根据你的背景,3.听起来可能有点神秘:什么是“悬挂指针”?什么是“重复释放”?为什么它们危险?别担心:我们将在课程的其余部分更详细地讨论这些概念。
不过,目前,让我们先专注于学习如何在Rust的所有权系统下工作。
所有者
在Rust中,每个值都有一个所有者,在编译时静态确定。在任何给定时间,每个值只有一个所有者。
移动语义
所有权可以转移。
如果你拥有一个值,例如,你可以将其所有权转移到另一个变量:
#![allow(unused)] fn main() { let a = 42; // <--- `a` 是值 `42` 的所有者 let b = a; // <--- `b` 现在是值 `42` 的所有者 }
Rust的所有权系统内置于类型系统中:每个函数都必须在其签名中声明它打算如何与其参数交互。
到目前为止,我们所有的方法和函数都消耗了它们的参数:它们获取了参数的所有权。 例如:
#![allow(unused)] fn main() { impl Ticket { pub fn description(self) -> String { self.description } } }
Ticket::description
获取调用它的Ticket
实例的所有权。这被称为移动语义:值(self
)的所有权从调用者转移到被调用者,调用者不能再使用它了。
这正是我们在前面看到的编译器错误信息中所使用的语言:
错误[E0382]: 使用了已移动的值: `ticket`
--> src/main.rs:30:43
|
25 | let ticket = Ticket::new(/* */);
| ------ 因为 `ticket` 类型为 `Ticket`,在此发生移动,
| 此类型未实现 `Copy` 特征
26 | if ticket.status() == "待办" {
| -------- `ticket` 因为此方法调用而被移动
...
30 | println!("你的下一个任务是: {}", ticket.title());
| ^^^^^^ 在移动后此处再次使用了值
|
注意: `Ticket::status` 接收者 `self` 采用所有权,导致 `ticket` 被移动
--> src/main.rs:12:23
|
12 | pub fn status(self) -> String {
| ^^^^
具体来说,当我们调用ticket.status()
时,事件序列如下:
Ticket::status
获取Ticket
实例的所有权Ticket::status
从self
提取status
并将status
的所有权转移回调用者- 剩余的
Ticket
实例部分被丢弃(title
和description
)
当我们尝试再次通过ticket.title()
使用ticket
时,编译器会抱怨:ticket
值现在已经没了,我们不再拥有它,因此不能再使用它。
要构建有用的访问器方法,我们需要开始使用引用。
借用
拥有一些不获取其所有权就能读取变量值的方法是可取的。否则编程将受到很大限制。在Rust中,这是通过借用来完成的。
每次你借用一个值时,都会得到它的引用。引用带有它们的权限标签1:
- 不可变引用(
&
)允许你读取值,但不允许修改它 - 可变引用(
&mut
)允许你读取并修改值
回到Rust所有权系统的目标:
- 数据在被读取时从不被修改
- 数据在被修改时从不被读取
为了确保这两点,Rust必须对引用引入一些限制:
- 你不能同时拥有对同一值的可变引用和不可变引用
- 你不能同时拥有对同一值的多个可变引用
- 所有者在值被借用期间不能修改值
- 只要有不可变引用,你可以拥有任意数量的不可变引用,只要没有可变引用
在某种程度上,你可以将不可变引用视为值上的“只读”锁,而可变引用则像是“读写”锁。
所有这些限制都由借用检查器在编译时强制执行。
语法
实际上如何借用一个值呢?
通过在变量前添加&
或&mut
,你就是在借用它的值。但要注意!相同的符号(&
和&mut
)在类型的前面有不同的含义:它们表示原始类型的引用,即引用类型本身。
例如:
struct 配置 { 版本: u32, 活动: bool, } fn main() { let 配置 = 配置 { 版本: 1, 活动: true, }; // `b` 是对 `config` 的 `版本` 字段的引用。 // `b` 的类型是 `&u32`,因为它包含对 `u32` 值的引用。 // 我们通过借用 `config.版本` 并使用 `&` 运算符创建引用。 // 同样的符号(`&`),根据上下文有不同的含义! let b: &u32 = &配置.版本; // ^ 类型注解不是必需的, // 它只是为了阐明正在发生的事情 }
同样的概念适用于函数参数和返回类型:
#![allow(unused)] fn main() { // `f` 接受一个 `u32` 的可变引用作为参数,绑定到名为 `number` fn f(number: &mut u32) -> &u32 { // [...] } }
深呼吸
Rust的所有权系统一开始可能会让人有些不知所措。但别担心:通过实践它会变得自然而然。在本章剩余部分以及整个课程中,你将获得大量的实践机会!我们将多次回顾每个概念,确保你熟悉它们并真正理解它们的工作原理。
在本章末尾,我们会解释为什么Rust的所有权系统设计成这样。目前,集中精力理解如何做。把每一个编译器错误都当作一次学习机会!
参考
- 本节练习位于
exercises/03_ticket_v1/06_ownership
这是一个很好的入门心理模型,但它没有捕捉到完整的画面。 我们将在课程的后续部分深化对引用的理解。
可变引用
现在,你的访问器方法应该看起来像这样:
#![allow(unused)] fn main() { impl Ticket { pub fn title(&self) -> &String { &self.title } pub fn description(&self) -> &String { &self.description } pub fn status(&self) -> &String { &self.status } } }
在这里那里撒上一点&
就搞定了!我们现在有一种方式可以在不消耗Ticket
实例的过程中访问其字段。接下来,让我们看看如何通过添加设置器方法来增强我们的Ticket
结构体。
设置器
设置器方法允许用户更改Ticket
的私有字段值,同时确保其不变性得到尊重(例如,你不能将Ticket
的标题设置为空字符串)。
在Rust中有两种常见的设置器实现方式:
- 将
self
作为输入。 - 将
&mut self
作为输入。
将self
作为输入
第一种方法如下所示:
#![allow(unused)] fn main() { impl Ticket { pub fn set_title(mut self, new_title: String) -> Self { // 验证新标题 [...] self.title = new_title; self } } }
它获取self
的所有权,更改标题,并返回修改后的Ticket
实例。你可以这样使用它:
#![allow(unused)] fn main() { let ticket = Ticket::new("标题".into(), "描述".into(), "待办".into()); let ticket = ticket.set_title("新标题".into()); }
由于set_title
获取self
的所有权(即消耗它),我们需要将结果重新赋值给一个变量。在上面的例子中,我们利用变量覆盖来重用相同的变量名:当你使用与现有变量相同的名字声明新变量时,新变量会覆盖旧变量。这是Rust代码中的一个常见模式。
当需要一次性更改多个字段时,self
-设置器工作得相当不错:你可以将多个调用串联在一起!
#![allow(unused)] fn main() { let ticket = ticket .set_title("新标题".into()) .set_description("新描述".into()) .set_status("进行中".into()); }
将&mut self
作为输入
第二种设置器方法,使用&mut self
,则是这样的:
#![allow(unused)] fn main() { impl Ticket { pub fn set_title(&mut self, new_title: String) { // 验证新标题 [...] self.title = new_title; } } }
这次,该方法以可变引用的形式接收self
作为输入,更改标题,就这样。没有返回任何东西。
你可以这样使用它:
#![allow(unused)] fn main() { let mut ticket = Ticket::new("标题".into(), "描述".into(), "待办".into()); ticket.set_title("新标题".into()); // 使用已修改的ticket }
所有权保留在调用者手中,所以原始的ticket
变量仍然是有效的。我们不需要重新分配结果。但是,我们需要将ticket
标记为可变的,因为我们正在对其采取可变引用。
&mut
-设置器有一个缺点:你不能链式调用多个设置。由于它们不返回修改后的Ticket
实例,你不能在第一个调用的结果上再调用另一个设置器。你必须分别调用每个设置器:
#![allow(unused)] fn main() { ticket.set_title("新标题".into()); ticket.set_description("新描述".into()); ticket.set_status("进行中".into()); }
参考
- 本节练习位于
exercises/03_ticket_v1/07_setters
内存布局
我们从操作的角度探讨了所有权和引用——你能用它们做什么和不能做什么。现在是时候深入了解幕后情况了:让我们谈谈内存。
栈与堆
在讨论内存时,你经常会听到人们谈论栈和堆。这两个不同的内存区域被程序用来存储数据。
让我们从栈开始说起。
栈
栈是一种后进先出(Last In, First Out, LIFO)的数据结构。当你调用一个函数时,一个新的栈帧会被添加到栈顶。这个栈帧存储了函数的参数、局部变量和一些“簿记”值。当函数返回时,这个栈帧就会从栈中弹出1。
+-----------------+
func2 | frame for func2 | func2
+-----------------+ is called +-----------------+ returns +-----------------+
| frame for func1 | -----------> | frame for func1 | ---------> | frame for func1 |
+-----------------+ +-----------------+ +-----------------+
从操作角度看,栈的分配/释放非常快。我们总是在栈顶推入和弹出数据,因此不需要搜索空闲内存。我们也不必担心碎片化问题:栈是一块连续的内存。
Rust中的栈
Rust经常在栈上分配数据。你的函数有一个u32
类型的输入参数吗?那32位就会在栈上。你定义了一个i64
类型的局部变量吗?那64位也会在栈上。这一切运作得很顺利,因为这些整数的大小在编译时就已经知道了,因此编译后的程序知道需要为它们在栈上预留多少空间。
std::mem::size_of
你可以使用std::mem::size_of
函数验证类型在栈上会占用多少空间。
比如对于u8
:
#![allow(unused)] fn main() { // 我们稍后会解释这种有趣的语法(`::<String>`)。现在先忽略它。 assert_eq!(std::mem::size_of::<u8>(), 1); }
1是合理的,因为u8
是8位长,或者说1字节。
参考
- 本节练习位于
exercises/03_ticket_v1/08_stack
如果你有嵌套的函数调用,每个函数在被调用时都会将自己的数据推到栈上,但直到最内层的函数返回才将其弹出。 如果嵌套的函数调用太多,可能会耗尽栈空间——栈不是无限大的!这就是所谓的栈溢出。
堆
栈很棒,但它不能解决所有问题。那些在编译时大小未知的数据怎么办呢?集合、字符串和其他动态大小的数据无法完全在栈上分配。这时就需要引入堆了。
堆分配
你可以将堆想象成一大块内存——如果愿意的话,就像一个巨大的数组。每当需要在堆上存储数据时,你就要向一个特殊的程序请求,即分配器,为你保留堆中的一部分。我们将这种交互(以及你保留的内存)称为堆分配。如果分配成功,分配器会给你指向已预留块起始位置的指针。
无自动解除分配
堆的结构与栈大不相同。堆分配不是连续的,它们可以位于堆内的任意位置。
+---+---+---+---+---+---+-...-+-...-+---+---+---+---+---+---+---+
| Allocation 1 | Free | ... | ... | Allocation N | Free |
+---+---+---+---+---+---+ ... + ... +---+---+---+---+---+---+---+
跟踪堆的哪些部分正在使用,哪些部分是空闲的是分配器的工作。然而,分配器不会自动释放你分配的内存:你需要主动去做这件事,再次调用分配器来释放不再需要的内存。
性能
堆的灵活性是有代价的:堆分配比栈分配慢。涉及更多的管理操作!如果你阅读关于性能优化的文章,往往会建议你尽量减少堆分配,并尽可能优先使用栈上分配的数据。
String
的内存布局
当你创建一个类型为String
的局部变量时,Rust被迫在堆上分配1:它事先不知道你要放入多少文本,因此无法在栈上预留正确大小的空间。但String
并非完全堆分配,它也在栈上保留了一些数据。具体来说:
- 指向你在堆上预留区域的指针。
- 字符串的长度,即字符串中有多少字节。
- 字符串的容量,即在堆上预留了多少字节。
让我们通过一个例子更好地理解这一点:
#![allow(unused)] fn main() { let mut s = String::with_capacity(5); }
如果运行这段代码,内存将如下布局:
+---------+--------+----------+
Stack | pointer | length | capacity |
| | | 0 | 5 |
+--|------+--------+----------+
|
|
v
+---+---+---+---+---+
Heap: | ? | ? | ? | ? | ? |
+---+---+---+---+---+
我们要求一个可以容纳最多5字节文本的String
。String::with_capacity
去到分配器那里请求5字节的堆内存。分配器返回指向该内存块起始位置的指针。不过,String
是空的。在栈上,我们通过区分长度和容量来记录这个信息:这个String
最多可以容纳5字节,但目前它实际持有0字节的文本。
如果你向String
中推送一些文本,情况就会改变:
#![allow(unused)] fn main() { s.push_str("Hey"); }
+---------+--------+----------+
Stack | pointer | length | capacity |
| | | 3 | 5 |
+----|----+--------+----------+
|
|
v
+---+---+---+---+---+
Heap: | H | e | y | ? | ? |
+---+---+---+---+---+
s
现在持有3字节的文本。它的长度更新为3,但容量保持为5。堆上的5个字节中有3个用于存储字符嘿
。
usize
我们在栈上存储指针、长度和容量需要多少空间?这取决于你运行机器的架构。
机器上的每个内存位置都有一个地址,通常表示为无符号整数。根据地址空间的最大大小(即你的机器可以寻址多少内存),这个整数可以有不同的大小。大多数现代机器使用32位或64位地址空间。
Rust通过提供usize
类型抽象了这些与架构相关的细节:一个无符号整数,其大小与在你的机器上所需寻址内存的字节数相同。在32位机器上,usize
等同于u32
。在64位机器上,它匹配u64
。
容量、长度和指针在Rust中都表示为usize
2。
堆上没有std::mem::size_of
std::mem::size_of
返回类型在栈上会占用的空间量,这也被称为类型的大小。
那么
String
在堆上管理的内存缓冲区呢?难道那不是String
大小的一部分吗?
不!那个堆分配是String
正在管理的一个资源。编译器并不将其视为String
类型的一部分。
std::mem::size_of
不知道(也不关心)类型可能通过指针管理或引用的额外堆分配数据,如String
的情况,因此它不跟踪其大小。
不幸的是,没有std::mem::size_of
的等价物来衡量某个值在运行时分配的堆内存量。某些类型可能提供了检查其堆使用情况的方法(例如String
的capacity
方法),但在Rust中没有通用的“API”来检索运行时堆使用情况。然而,你可以使用内存分析工具(例如DHAT或自定义分配器)来检查程序的堆使用情况。
参考
- 本节练习位于
exercises/03_ticket_v1/09_heap
如果你创建一个空的String
(即String::new()
),标准库实际上并不会分配堆内存。首次向其中推送数据时才会预留堆内存。
指针的大小也取决于操作系统。在某些环境中,指针比内存地址大(例如CHERI)。Rust做了一个简化的假设,即指针和内存地址的大小相同,这对大多数你可能遇到的现代系统来说都是正确的。
引用
那么引用,如 &String
或 &mut String
,它们在内存中是如何表示的呢?
Rust 中的大多数引用1在内存中都是以指向内存位置的指针形式表示的。因此,它们的大小与指针的大小相同,即 usize
。
你可以使用 std::mem::size_of
来验证这一点:
#![allow(unused)] fn main() { assert_eq!(std::mem::size_of::<&String>(), 8); assert_eq!(std::mem::size_of::<&mut String>(), 8); }
特别是,一个 &String
是指向存储 String
元数据的内存位置的指针。如果你运行这段代码:
#![allow(unused)] fn main() { let s = String::from("嘿"); let r = &s; }
在内存中你会得到类似这样的布局:
--------------------------------------
| |
+----v----+--------+----------+ +----|----+
Stack | pointer | length | capacity | | pointer |
| | | 3 | 5 | | |
+----|----+--------+----------+ +---------+
| s r
|
v
+---+---+---+---+---+
Heap | H | e | y | ? | ? |
+---+---+---+---+---+
可以说,这是一个指向指向堆分配数据的指针的指针。&mut String
的情况也是如此。
并非所有指针都指向堆
上述例子澄清了一点:并非所有指针都指向堆。它们只是指向一个内存位置,这个位置_可能_在堆上,但不一定是。
参考
- 本节练习位于
exercises/03_ticket_v1/10_references_in_memory
在课程的后续部分中,我们将讨论胖指针,即带有附加元数据的指针。顾名思义,它们比本章讨论的指针(也称为瘦指针)更大。
析构函数
在介绍堆时,我们提到你需要负责释放你分配的内存。在介绍借用检查器时,我们也说过,在Rust中你很少需要直接管理内存。
这两个陈述起初可能看似矛盾。让我们通过引入作用域和析构函数来看看它们是如何结合在一起的。
作用域
变量的作用域是指Rust代码中该变量有效或存活的区域。
变量的作用域从其声明开始。当以下之一发生时结束:
- 变量被声明的代码块(即
{}
之间)结束fn main() { // `x` is not yet in scope here let y = "Hello".to_string(); let x = "World".to_string(); // <-- x's scope starts here... let h = "!".to_string(); // | } // <-------------- ...and ends here
- 变量的所有权转移到其他人(例如,函数或其他变量)
fn compute(t: String) { // Do something [...] } fn main() { let s = "Hello".to_string(); // <-- s's scope starts here... // | compute(s); // <------------------- ..and ends here // because `s` is moved into `compute` }
析构函数
当一个值的所有者超出作用域时,Rust会调用其析构函数。析构函数试图清理该值使用的资源,尤其是它分配的内存。
你可以通过将值传递给std::mem::drop
来手动调用析构函数。这就是为什么Rust开发者常会说某个值已经被丢弃,以此来表达一个值已经超出作用域且其析构函数已被调用。
可视化解构点
我们可以通过插入显式的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(); // Variables are dropped in reverse order of declaration drop(h); drop(x); drop(y); }
来看第二个例子,其中s
的所有权被转移到compute
:
fn compute(s: String) { // Do something [...] } fn main() { let s = "Hello".to_string(); compute(s); }
它等同于:
fn compute(t: String) { // Do something [...] drop(t); // <-- Assuming `t` wasn't dropped or moved // before this point, the compiler will call // `drop` here, when it goes out of scope } fn main() { let s = "Hello".to_string(); compute(s); }
注意区别:尽管在main
中调用compute
后s
不再有效,但在main
中并没有s
的drop(s)
。
当你将值的所有权转移到函数时,你也正在转移清理它的责任。
这确保了一个值的析构函数至多被调用一次,设计上防止了双重释放漏洞。
丢弃后的使用
如果你尝试在值被丢弃后使用它会发生什么?
#![allow(unused)] fn main() { let x = "Hello".to_string(); drop(x); println!("{}", x); }
如果你尝试编译这段代码,你会收到错误:
#![allow(unused)] fn main() { 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消耗掉它被调用的对象,意味着调用后该对象不再有效。因此,编译器会阻止你使用它,避免了释放后使用漏洞。
引用的丢弃
如果变量包含引用会怎样? 例如:
#![allow(unused)] fn main() { 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`
|
这回到了我们之前所说:我们只想调用一次析构函数。你可能对同一个值有多个引用——如果我们中的一个超出作用域时就调用它们指向的值的析构函数,其他引用会怎么样? 它们会指向一个不再有效的内存位置:一个所谓的悬挂指针,是释放后使用漏洞的近亲。Rust的所有权制度从设计上排除了这类漏洞。
参考资料
- 本节练习位于
exercises/03_ticket_v1/11_destructor
Rust不保证析构函数一定会执行。例如,如果你选择故意泄露内存,它们就不会执行。
总结回顾
本章中,我们已经涵盖了Rust的许多基础概念。在继续前进之前,让我们通过最后一个练习来巩固所学的知识。这次你将得到最少的指引——仅有的是练习描述和测试用例来引导你。
练习参考
- 本节的练习位于
exercises/03_ticket_v1/12_outro
特性(Trait)
在前一章中,我们学习了Rust的类型和所有权系统的基础知识。现在是时候深入研究一下了:我们将探索特质,Rust对接口的理解。
一旦你了解了特质,你就会开始到处看到它们的踪迹。实际上,你已经在前一章中看到了特质的实际应用,比如.into()
调用以及诸如==
和+
这样的运算符。
除了特质这一概念之外,我们还将涵盖Rust标准库中定义的一些关键特质:
- 运算符特质(例如
Add
、Sub
、PartialEq
等) From
和Into
,用于不可失败的转换Clone
和Copy
,用于复制值Deref
和解引用强制转换Sized
,标记具有已知大小的类型Drop
,用于自定义清理逻辑
既然我们将要讨论转换,我们也会借此机会填补前一章中的一些“知识空白”——比如,"A title"
确切是什么?也是时候更深入学习切片了!
参考资料
- 本节练习位于
exercises/04_traits/00_intro
让我们再次审视我们的 Ticket
类型:
#![allow(unused)] fn main() { pub struct Ticket { title: String, description: String, status: String, } }
迄今为止,我们的所有测试都是使用 Ticket
的字段进行断言的。
#![allow(unused)] fn main() { assert_eq!(ticket.title(), "一个新的标题"); }
如果我们想直接比较两个 Ticket
实例会怎样呢?
#![allow(unused)] fn main() { 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 不会神奇地推断如何比较两个 Ticket
实例,仅仅因为它们包含了 String
s。
不过,Rust 编译器正把我们推向正确的方向:它提示我们可能缺少了 PartialEq
的实现。PartialEq
是一个特性!
特性是什么?
特性是Rust定义接口的方式。
特性定义了一组类型必须实现的方法,以满足特性的契约。
定义特性
特性定义的语法如下:
#![allow(unused)] fn main() { trait <TraitName> { fn <method_name>(<parameters>) -> <return_type>; } }
例如,我们可能会定义一个名为 MaybeZero
的特性,要求其实现者定义一个 is_zero
方法:
#![allow(unused)] fn main() { trait MaybeZero { fn is_zero(self) -> bool; } }
实现特性
为类型实现特性时,我们使用 impl
关键字,就像我们为常规1方法那样,但语法略有不同:
#![allow(unused)] fn main() { impl <TraitName> for <TypeName> { fn <method_name>(<parameters>) -> <return_type> { // Method body } } }
例如,为自定义数字类型 WrappingU32
实现 MaybeZero
特性:
#![allow(unused)] fn main() { pub struct WrappingU32 { inner: u32, } impl MaybeZero for WrappingU32 { fn is_zero(self) -> bool { self.inner == 0 } } }
调用特性方法
调用特性方法时,我们使用.
操作符,就像调用常规方法一样:
#![allow(unused)] fn main() { let x = WrappingU32 { inner: 5 }; assert!(!x.is_zero()); }
要调用特性方法,两件事必须为真:
- 类型必须实现了该特性。
- 特性必须在作用域内。
为满足后者,你可能需要添加一个 use
语句来引入特性:
#![allow(unused)] fn main() { use crate::MaybeZero; }
如果:
- 特性在调用发生的同一模块中定义。
- 特性定义在标准库的预置中。预置是一组自动导入到每个Rust程序中的特性和类型。这就像是在每个Rust模块开头添加了
use std::prelude::*;
。
你可以在Rust文档中找到预置中特性和类型的列表:https://doc.rust-lang.org/std/prelude/index.html。
参考资料
- 本节练习位于
exercises/04_traits/01_trait
直接定义在一个类型上,而不使用特性的方法,也称为固有方法。
实现特性
当一个类型是在另一个crate中定义的(例如,来自Rust标准库的u32
),你不能直接为它定义新的方法。如果你尝试这样做:
#![allow(unused)] fn main() { 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
扩展特性
扩展特性是一种主要目的是向外部类型(如u32
)附加新方法的特性。这正是你在上一个练习中采用的模式,通过定义IsEven
特性然后为i32
和u32
实现它。只要IsEven
在作用域内,你就可以自由地在这些类型上调用is_even
方法。
// 引入特性 use my_library::IsEven; fn main() { // 在实现了它的类型上调用其方法 if 4.is_even() { // [...] } }
单一实现
在你能编写的特性实现中存在一些限制。最简单且最直接的一个是:你不能在一个crate中,为同一个类型两次实现同一个特性。
例如:
#![allow(unused)] fn main() { 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
时,不能存在任何关于应该使用哪个特性实现的歧义,因此只能有一个。
孤儿规则
当涉及多个crate时,情况变得更加微妙。特别地,以下至少有一项必须为真:
- 特性在当前crate中定义
- 实现者类型在当前crate中定义
这被称为Rust的孤儿规则。其目的是使方法解析过程无歧义。
想象以下情形:
- Crate
A
定义了IsEven
特性 - Crate
B
为u32
实现了IsEven
- Crate
C
提供了IsEven
特性针对u32
的不同实现 - Crate
D
同时依赖于B
和C
,并调用1.is_even()
应该使用哪个实现?是B
中定义的吗?还是C
中定义的?没有明确的答案,因此定义了孤儿规则以防止这种情况发生。得益于孤儿规则,无论是crate B
还是crate C
都不会编译成功。
参考资料
- 本节练习位于
exercises/04_traits/02_orphan_rule
进一步阅读
- 如上所述的孤儿规则有一些例外和注意事项。如果你想了解其细节,请查阅参考文档。
运算符重载
既然我们对特质(traits)有了基本的认识,现在让我们回过头来探讨一下运算符重载(operator overloading)。
运算符重载是指为诸如+
、-
、*
、/
、==
、!=
等运算符定义自定义行为的能力。
在Rust中,运算符是通过特质来实现的。对于每个运算符,都存在一个相应的特质来定义该运算符的行为。通过为你的类型实现这些特质,你就能解锁使用对应的运算符。
例如,PartialEq
特质定义了==
和!=
这两个运算符的行为:
#![allow(unused)] fn main() { // 从Rust标准库中简化得来的`PartialEq`特质定义 pub trait PartialEq { // 必须实现的方法 // `Self`是一个Rust关键字,表示“实现该特质的类型” fn eq(&self, other: &Self) -> bool; // 默认提供的方法 fn ne(&self, other: &Self) -> bool { ... } } }
当你编写x == y
时,编译器会查找x
和y
类型的PartialEq
特质的实现,并将x == y
替换为x.eq(y)
。这是一种语法糖!
以下是主要运算符与其对应特质的对照表:
算术运算符位于std::ops
模块中,而比较运算符则位于std::cmp
模块。
默认实现
关于PartialEq::ne
的注释说明它是“提供的方法”。这意味着PartialEq
在其特质定义中为ne
提供了一个默认实现——即定义片段中的省略号{ ... }
所代表的部分。如果展开这部分,它看起来是这样的:
#![allow(unused)] fn main() { pub trait PartialEq { fn eq(&self, other: &Self) -> bool; fn ne(&self, other: &Self) -> bool { !self.eq(other) } } }
这正如你所料:ne
是eq
的否定。由于提供了默认实现,当你为自己的类型实现PartialEq
时,可以省略实现ne
。实现eq
就足够了:
#![allow(unused)] fn main() { struct WrappingU8 { inner: u8, } impl PartialEq for WrappingU8 { fn eq(&self, other: &WrappingU8) -> bool { self.inner == other.inner } // 这里没有`ne`的实现 } }
然而,你并不一定要使用默认实现。在实现特质时,你可以选择覆盖它:
#![allow(unused)] fn main() { struct MyType; impl PartialEq for MyType { fn eq(&self, other: &MyType) -> bool { // 自定义实现 } fn ne(&self, other: &MyType) -> bool { // 自定义实现 } } }
参考练习
- 本节的练习位于
exercises/04_traits/03_operator_overloading
目录下。
派生宏
实现PartialEq
对于Ticket
来说有点繁琐,对吧?你不得不手动比较结构体中的每一个字段。
解构语法
而且,这种实现方式很脆弱:如果结构体的定义发生变化(比如添加了一个新字段),你还得记得更新PartialEq
的实现。
为了降低风险,你可以使用解构来将结构体分解为各个字段:
#![allow(unused)] fn main() { impl PartialEq for Ticket { fn eq(&self, other: &Self) -> bool { let Ticket { title, description, status, } = self; // [...] } } }
如果Ticket
的定义发生了变化,编译器将会报错,提示你的解构不再全面。你也可以重命名结构体字段,以避免变量遮蔽:
#![allow(unused)] fn main() { 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 macros)。
宏
在之前的练习中,你已经遇到过一些宏:
- 测试用例中的
assert_eq!
和assert!
- 向控制台打印的
println!
Rust的宏是代码生成器。它们根据你提供的输入生成新的Rust代码,这段生成的代码随后会与程序的其他部分一起被编译。有些宏是内置在Rust标准库中的,但你也可以编写自己的宏。虽然本课程不涉及创建宏,但你可以在“进一步阅读”部分找到一些有用的信息。
检视宏
一些集成开发环境(IDE)允许你展开宏以检查生成的代码。如果IDE不支持此功能,你可以使用cargo-expand
工具。
派生宏
派生宏是Rust宏的一种特殊形式。它作为属性放在结构体定义的顶部。
#![allow(unused)] fn main() { #[derive(PartialEq)] struct Ticket { title: String, description: String, status: String, } }
派生宏用于自动为自定义类型实现一些常见(且显而易见)的特质。在上面的例子中,PartialEq
特质自动为Ticket
实现。如果你展开这个宏,会看到生成的代码在功能上等同于你手动编写的代码,尽管读起来可能稍显复杂:
#![allow(unused)] fn main() { #[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 } } }
编译器会在可能的情况下提示你使用派生特质。
参考资料
- 本节的练习位于
exercises/04_traits/04_derive
目录下。
字符串切片
在之前的章节中,你已经见过不少字符串字面量被用于代码中,比如 "待办事项"
或 "票据描述"
。它们后面常常跟着 .to_string()
或 .into()
的调用。现在是时候理解这么做的原因了!
字符串字面量
通过将原始文本包含在双引号中,你可以定义一个字符串字面量:
#![allow(unused)] fn main() { let s = "你好,世界!"; }
s
的类型是 &str
,即指向字符串切片的引用。
内存布局
&str
和 String
是不同的类型——它们不能互换。让我们回顾一下之前探索过的 String
的内存布局。如果我们运行:
#![allow(unused)] fn main() { 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
元数据的内存位置。如果我们跟随指针,就能到达堆分配的数据,特别是字符串的第一个字节 你
。
如果我们想要一个类型来表示 s
的子字符串呢?比如在 "你好"
中的 "好世界"
?
字符串切片
&str
是对字符串的一个视图,是对存储在别处的 UTF-8 字节序列的引用。例如,你可以像这样从 String
创建一个 &str
:
#![allow(unused)] fn main() { let mut s = String::with_capacity(5); s.push_str("你好"); // 从 `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
并不拥有数据,它只是指向数据:它是对 String
堆分配数据的引用。当 slice
被丢弃时,堆分配的数据不会被释放,因为它仍由 s
所拥有。这就是为什么 slice
没有 容量
字段的原因:它不拥有数据,所以不需要知道为数据分配了多少空间;它只关心它所引用的数据。
&str
与 &String
的区别
一般来说,当你需要对文本数据的引用时,优先使用 &str
而不是 &String
。&str
更加灵活,并且通常被认为是 Rust 代码中更符合习惯的用法。
如果一个方法返回 &String
,你是在承诺某处存在与你返回引用所匹配的堆分配的 UTF-8 文本。相反,如果一个方法返回 &str
,则拥有更多的灵活性:你只是说某处有一段文本数据,并且其中的一部分与你需要的匹配,因此你返回对这部分的引用。
参考练习
- 本节的练习位于
exercises/04_traits/05_str_slice
Deref
特性
在上一练习中,你其实没做太多工作,对吧?将
#![allow(unused)] fn main() { impl Ticket { pub fn title(&self) -> &String { &self.title } } }
修改为
#![allow(unused)] fn main() { impl Ticket { pub fn title(&self) -> &str { &self.title } } }
就是为了让代码编译通过并使测试成功。不过,你的脑海中或许会响起警钟。
看似不应行得通,却偏偏可行
让我们回顾一下事实:
self.title
是一个String
- 因此,
&self.title
是一个&String
- 修改后的
title
方法输出的是&str
你可能会期待编译错误,对吧?比如“预期 &String,发现 &str”之类的。然而,它就这么正常工作了。为何如此**?
Deref
特性来救援
Deref
特性是名为解引用强制(deref 强制)这一语言特性的背后机制。该特性在标准库的 std::ops
模块中定义:
#![allow(unused)] fn main() { // 我暂时简化了定义。 // 后面我们会看到完整定义。 pub trait Deref { type Target; fn deref(&self) -> &Self::Target; } }
type Target
是一个关联类型,它是实现特质时必须指定的具体类型占位符。
解引用强制
通过为类型 T
实现 Deref<Target = U>
,你实际上是告诉编译器 &T
和 &U
在某种程度上可以互换。具体而言,你会得到以下行为:
- 对
T
的引用会被隐式转换为对U
的引用(即&T
变为&U
) - 你可以对
&T
调用所有在U
上定义的接受&self
作为输入的方法。
关于解引用操作符 *
还有一点,但我们目前不需要了解(如果好奇可以查阅std
的文档)。
String
实现了 Deref
String
通过 Target = str
实现了 Deref
:
#![allow(unused)] fn main() { impl Deref for String { type Target = str; fn deref(&self) -> &str { // [...] } } }
得益于这个实现和解引用强制,当需要时 &String
会自动转换为 &str
。
不要慎用解引用强制
解引用强制是一个强大的特性,但也可能导致混淆。自动类型转换会使代码更难读且难以理解。如果同一名称的方法既定义在 T
上也在 U
上,到底会调用哪个?
在课程的后续部分,我们将探讨解引用强制最安全的使用场景:智能指针。
参考资料
- 本节的练习位于
exercises/04_traits/06_deref
Sized
特性深入探讨
即使在研究了 deref
强制转换之后,&str
仍然有着更多不为人知的细节。根据我们之前关于内存布局的讨论,将 &str
表示为堆栈上的单一 usize
(一个指针)似乎是合理的。然而事实并非如此。&str
在指针旁边存储了一些元数据:它所指向切片的长度。回顾前一节中的例子:
#![allow(unused)] fn main() { 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 | |
+---+---+---+---+---+ |
^ |
| |
+--------------------------------+
这是怎么回事?
动态大小类型
str
是一个动态大小类型(Dynamically Sized Type, DST)。DST是一种其大小在编译时未知的类型。每当你拥有一个指向DST的引用,比如&str
时,它必须包含额外的关于所指数据的信息。它是一个胖指针。对于&str
而言,它存储了所指向切片的长度。在课程的其余部分中,我们将看到更多DST的例子。
Sized
特性
Rust的标准库定义了一个叫做 Sized
的特性。
#![allow(unused)] fn main() { pub trait Sized { // 这是一个空特性,无需实现任何方法。} }
如果一个类型的大小在编译时已知,则它是 Sized
的。换句话说,它不是DST。
标记号特质
Sized
是你遇到的第一个标记特质的例子。标记特质不需要实现任何方法。它不定义任何行为。它仅用于标记类型具有某些属性。
这个标记随后被编译器利用,以启用特定行为或优化。
自动特质
特别地,Sized
也是一个自动特质。你不需要显式实现它;编译器会根据类型的定义自动为你实现。
示例
迄今为止我们见过的所有类型都是 Sized
的:u32
、String
、bool
等。
正如我们刚看到的,str
不是 Sized
的。
然而,&str
是 Sized
!我们在编译时知道它的大小:两个 usize
,一个用于指针,一个用于长度。
参考资料
- 本节的练习位于
exercises/04_traits/07_sized
From
和 Into
让我们回到旅程的起点:
#![allow(unused)] fn main() { let ticket = Ticket::new("A title".into(), "A description".into(), "To-Do".into()); }
我们现在足以解开.into()
在这里的作用了。
问题所在
这是new
方法的签名:
#![allow(unused)] fn main() { impl Ticket { pub fn new(title: String, description: String, status: String) -> Self { // [...] } } }
我们也了解到字符串字面量,如"一个标题"
是类型&str
。这里类型不匹配:期望一个String
,但我们有一个&str
。这次没有魔法会拯救我们,我们需要进行转换。
From
和 Into
Rust标准库为**可转换**定义了两个特性:
From和
Into,位于
std::convert`模块中。
#![allow(unused)] fn main() { pub trait From<T> { fn from(value: T) -> Self;} pub trait<T> { fn into(self) -> T;} }
这些定义展示了我们之前未见过的几个概念:Supertrait、泛型和隐式约束。我们先来拆解这些。
Supertrait
/ Subtrait
From: Sized
语法意味着From
是一个SubtraitSized
:任何实现了From
的类型也必须实现Sized
。或者可以说Sized
是From
的Supertrait。
泛型
From
和Into
都是泛型特性**。它们带有一个参数T
,代表转换的类型。T
是一个占位符,实际类型,将在实现或使用特性时指定。
隐式约束
每次有泛型参数时,编译器都默认它实现了Sized
。
例如:
#![allow(unused)] fn main() { pub struct Foo<T> { inner: T, }```` 实际上等同于: ```rust pub struct Foo<T> where T: Sized, // ^^^^^^ 这被称为**约束** // 它指定此实现仅适用于 // 类型T`实现`Sized` // 你可以要求多个特性被实现 // 使用+`符号 // 如`Sized + PartialEq<T>` { inner: T, } }
你可以通过负约束(negative trait bound
)来选择退出这个行为:
#![allow(unused)] fn main() { // 你也可以内联行约束, // 而不是使用`where`子句 pub struct Foo<Sized> { // ^^^^^^ // 这是一个负约束 // 它读作“T`可能不是”Sized”, // 并允许你绑定T到 DST(如`Foo<str>) inner: T, } }
在From<T>
情况下,我们希望T
和实现From<T>
类型都Sized
,尽管后者是隐式的。
&str
到 String
在std
的文档中,你可以看到哪些类型实现了From
特性。你会发现&str
实现了From<&str> for String
。因此,我们可以写:
#![allow(unused)] fn main() { let title = String::from("A title"); }
我们主要使用了.into()
。如果你查看[Into的实现](https://doc.rust-lang.org/std/convert/trait.Into.html),找不到
Into<&str> for String`。怎么回事?
From
和Into
是对称特性。特别是,任何实现了From
的类型都会自动实现一个**空白实现Into
:
#![allow(unused)] fn main() { impl<T, U> Into<U> for T where U: From<T>, { fn into(self) -> U { U::from(self) } }
如果类型T
实现了From<U>
,那么Into<U> for T
会自动实现。这就是我们能写let title = "A title".into();
的原因。
.into()
每次看到.into()
,你都在见证了一次类型间的转换。目标类型是什么?
大多数情况下,目标类型是:
- 函数/方法签名指定(如上例
Ticket::new
)- 变量声明时类型注解(例let title: String = "A title".into()
)。
只要编译器能无歧义地从上下文中推断目标类型,.into()
就会工作。
参考资料
- 本节的练习位于
exercises/04_traits/08_from
关联类型和相关类型
让我们重新审视目前为止学习过的两个特性From
和Deref
的定义:
#![allow(unused)] fn main() { trait From<T> { fn from(value: T) -> Self;} trait Deref { type Target; fn deref(&self) -> &Self::Target;} }
它们都涉及到类型。在From
的情况下,它是泛型参数T
。在Deref
的情况下,它是相关类型Target
。
有什么不同?为什么要用一个而非另一个?
最多实现
由于deref强制转换的工作原理,给定类型只能有一个"目标"类型"。例如,String
只能Deref
到str
。这是为了避免歧义:如果你能多次实现Deref
,当调用self
方法时编译器应该选择哪个Target
?
这就是Deref
使用相关类型的原因,Target
。相关类型是由特性实现唯一确定的。因为你不能实现Deref
超过一次,你只能为给定类型指定一个Target
,不会有歧义。
泛型特性
另一方面,你可以为类型多次实现From
,**只要输入类型不同即可。例如,你可以用
u32和
u16作为输入类型为
WrappingU32实现
From`:
#![allow(unused)] fn main() { impl From<u32> for WrappingU32 { fn from(value: u32) -> Self { WrappingU32 { inner: value } impl<u16> for WrappingU32 { fn from(value: u16) -> Self { WrappingU32 { inner: value.into()} }
这可行,因为From<u16>
和From<u32>
被视为不同特性。没有歧义:编译器可以根据转换值的类型决定使用哪个实现。
案案例分析:Add
作为结束示例,考虑标准库中的Add
特性:
#![allow(unused)] fn main() { trait Add<RHS = Self> { type Output; fn add(self, rhs: RHS) -> Self::Output;} }
它使用了两种机制:
- 有一个泛型参数
RHS
(右手边),默认为Self
- 有一个相关类型
Output
,加法的结果类型
RHS
RHS
是一个泛型,允许不同类型相加在一起。例如,在标准库中你会发现这两个实现:
#![allow(unused)] fn main() { impl<u32> for u32 { type Output = u32; fn add(self, u32) -> u32 { [...]} impl<&u32> for u32 { type Output = u32; fn add(self, &u32) -> u32 { [...]} }
这让下面的代码可以编译:
#![allow(unused)] fn main() { let x = 5u32 + &5u32 + 6u32 }
因为u32
实现在实现了Add<&u32>
和以及
Add
Output
另一方面,必须在操作数类型已知时唯一确定。这就是它作为相关类型而不是第二个泛型参数的原因。
总结:
- 当类型必须为给定实现时使用相关类型。
- 当想允许同一类型有**多个实现时使用泛型,输入类型不同。
参考资料
- 本节的练习位于
exercises/04_traits/09_assoc_vs_generic
复制值,第1部分
在上一章中,我们介绍了所有权和借用的概念,并特别指出:
- 在任何给定时间每个值都有一个所有者。
- 当函数取得某个值的所有权(“消耗它”后,调用者就无法再使用该值了。
这些限制可能有些局限性。有时我们可能需要调用一个会取走值所有权的函数,但之后仍需使用那个值。
#![allow(unused)] fn main() { fn consumer(s: String) { /* ... */ }fn example() { let mut s = String::from("hello"); consumer(s); s.push_str("!"); // 错误:值已被移动}} }
这时Clone
就登场了。
Clone
Clone
是Rust`标准库中定义的一个特性:
#![allow(unused)] fn main() { pub trait Clone { fn clone(&self) -> Self;} }
它的方法clone
接受一个引用self
并返回一个相同类型的新拥有实例。
实战
回到上面的例子,我们可以在调用clone
创建一个新的String
实例后再调用consumer
:
#![allow(unused)] fn main() { fn consumer(s: String) { /* ... */ }fn example() { let mut s = String::from("hello"); let t = s.clone(); consumer(t); s.push_str("!"); // 正常}} }
我们不是将s
的所有权交给consumer
,而是通过s
克隆创建一个新的String
(通过s.clone
)并将其给consumer
。这样s
在调用consumer
之后依然有效并可使用。
内存
让我们看看上面例子中内存里发生了什么。执行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 s
+---------+--------+----------+ +---------+--------+----------+
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
想象成深拷贝对象的一种方式。
实现Clone
要让一个类型Clone
,我们必须为其实现Clone
特性。通常通过派生成功现Clone
:
#![allow(unused)] fn main() { #[derive(Clone)] struct MyType { // 字段落} }
编译器为你的MyType
实现了Clone
,如同所期望:克隆了MyType
的逐个字段并构造新MyType
。记住可以用cargo expand
或IDE
探索由derive
宏生成的代码。
参考资料
- 本节的练习位于
exercises/04_traits/0_clone
复制值,第二部分
让我们考虑与之前相同的例子,但稍作调整:使用u32
代替String
作为类型。
#![allow(unused)] fn main() { fn consumer(s: u32) { /* ... */ }fn example() { let s: u32 = 5; consumer(s); let t = s + 1; // 无错误!}} }
这将无误编译!这是怎么回事?String
和u32
之间的区别是什么,使得后者无需.clone()
就能工作?
Copy
Copy
是Rust标准库中定义的另一个特性:
#![allow(unused)] fn main() { pub trait Copy: Clone { } }
它是一个标记特性,类似于Sized
。
如果一个类型实现了Copy
,创建该类型的实例时就不需要显式调用.clone()
:Rust会隐式地为你处理。u32
就是一个实现Copy
的类型示例,因此上述代码能无误编译:当调用consumer(s)
时,Rust通过对s
进行位级复制来创建一个新的u32
实例,然后将这个新实例传递给consumer
。这一切都在幕后自动完成,无需你的介入。
什么可以是Copy
?
Copy
并不等同于“自动克隆”,尽管它暗示了这一点。类型必须满足一些条件才能被允许实现Copy
。
首先,它必须实现Clone
,因为Copy
是Clone
的子特性。
这是有道理的:如果Rust能够_隐式_创建类型的实例,那么通过调用.clone()
也应该能够_显式_创建新实例。
但这还不是全部。还需满足几个其他条件:
- 类型不管理任何额外资源(如堆内存、文件句柄等),除了它在内存中占用的
std::mem::size_of
字节。 - 类型不是可变引用(
&mut T
)。
如果这两个条件都满足,那么Rust就可以通过执行原实例的位级复制安全地创建一个新实例——这常被称为memcpy
操作,源自C标准库中执行位级复制的函数。
案例研究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
实例都会在超出作用域时尝试释放内存缓冲区,导致重复释放错误。
你也可能创建两个指向同一内存缓冲区的不同可变&mut String
引用,违反了Rust的借用规则。
案例研究2:u32
u32
实现了Copy
。实际上,所有整数类型都是如此。
一个整数就是内存中代表数字的那些字节。没有别的!
如果复制那些字节,就会得到另一个完全有效的整数实例。
没有任何不良后果,所以Rust允许这样做。
案例研究3:&mut u32
当我们介绍所有权和可变借用时,明确了一条规则:任何时候对一个值只能有一个可变借用。
这就是&mut u32
不实现Copy
的原因,即便u32
本身实现了。
如果&mut u32
实现了Copy
,你就可以创建多个指向同一值的可变引用,并同时在多处修改它。
这将违反Rust的借用规则!因此,无论T
是什么,&mut T
都不实现Copy
。
实现Copy
大多数情况下,你不需要手动实现Copy
。
你可以这样派生它:
#![allow(unused)] fn main() { #[derive(Copy, Clone)] struct MyStruct { field: u32, } }
参考资料
- 本节的练习位于
exercises/04_traits/11_copy
Drop
特性
当我们引入析构器时,我们提到了drop
函数:
- 回收回应类型的内存(即
std::mem::size_of
字节) - 清理值可能正在管理的任何额外资源(例如
String
的堆缓冲区)
步骤2. 就是Drop
特性发挥作用的地方。
#![allow(unused)] fn main() { pub trait Drop { fn drop(&mut self);} }
Drop
特性是一种机制,让你为类型定义额外清理逻辑**,超出编译器自动为你做的部分。你在drop
方法中放入的任何内容都会在值超出作用域时被执行。
Drop
与Copy
谈论Copy
特性时,我们说类型如果管理的资源超出了它在内存中占据的std::mem::size_of
字节,就不能实现Copy
。
你可能好奇:编译器怎么知道类型是否管理资源?
没错:Drop
特性的实现!如果你的类型有显式的Drop
实现,编译器会认为你的类型附加了额外资源,并不允许你实现Copy
。
#![allow(unused)] fn main() { // 这是一个单元结构体,即无字段的结构体。 #[derive(Clone, Copy)] struct MyType; impl Drop for MyType { fn drop(&mut self) { // 我们不需要在这里做什么, // 有"空"Drop"实现就足够了 } }
编译器会报此错误信息:
error[E0184]: `Copy`特性不能为此类型实现;该类型具有析构器
--> src/lib.rs:2:7
2 | #[derive(Clone, Copy)]
| ^^^^ `Copy`不允许有析构器的类型上使用
参考资料
- 本节的练习位于
exercises/04_traits/12_drop
封装起来
本章我们已经覆盖了不少不同的特性——而仅仅触及皮毛皮!你可能会感觉有许多要记的东西,但别担心:当你编写Rust代码时,你会频繁遇到这些特性以至于它们很快变得自然而然。
继续前进之前,让我们通过最后一个练习巩固所学的内容。这次你将得到最少的指引——只有练习描述和测试指导你。
参考
- 本节的练习位于
exercises/04_traits/13_outro
建模 Ticket 系统,第二部分
我们在前几章中工作的Ticket
结构体是一个良好的开端,但它仍然透露出“我是Rust新手!”的气息。
我们将利用这一章节来提升我们的Rust领域建模技能。一路上,我们需要引入几个额外的概念:
enum
,Rust数据建模中最为强大的特性之一Option
类型,用来建模可空值Result
类型,用来建模可恢复的错误Debug
和Display
特质,用于打印输出Error
特质,用于标记错误类型TryFrom
和TryInto
特质,用于可能失败的转换- Rust的包管理系统,解释什么是库、什么是二进制文件,以及如何使用第三方库
参考资料
- 本节练习位于
exercises/05_ticket_v2/00_intro
枚举类型
根据您在前一章节中编写的验证逻辑,一张票证(ticket)只有几种有效状态:待办(To-Do)
、进行中(InProgress)
和已完成(Done)
。然而,如果我们查看Ticket
结构体中的status
字段或new
方法中status
参数的类型,这一点并不明显。
#![allow(unused)] fn main() { #[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
枚举是一种可以有固定值集合的类型,这些值被称为变体(variants)。在Rust中,你可以使用enum
关键字来定义枚举:
#![allow(unused)] fn main() { enum Status { ToDo, InProgress, Done, } }
就像struct
一样,enum
也定义了一个新的Rust类型。
参考资料
- 本节的练习位于
exercises/05_ticket_v2/01_enum
匹配(match)
你可能在想,枚举(enum)究竟能做些什么?最常见的操作就是**匹配(match)**它。
#![allow(unused)] fn main() { enum Status { ToDo, InProgress, Done } impl Status { fn is_done(&self) -> bool { match self { Status::Done => true, // The `|` operator lets you match multiple patterns. // It reads as "either `Status::ToDo` or `Status::InProgress`". Status::InProgress | Status::ToDo => false } } } }
match
语句让你能把Rust值与一系列模式进行比较。你可以把它想象成类型级别的if
。如果status
是已完成
变体,执行第一块代码;如果是进行中
或待办
变体,则执行第二块代码。
完备性
这里的关键点在于match
是完备的。你必须处理所有枚举变体。如果你遗漏了某个变体,Rust会在编译时阻止你并报错。
例如,如果我们忘记处理待办
变体:
#![allow(unused)] fn main() { 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开发者经常夸赞“编译器驱动重构”——编译器告诉你接下来要做什么,你只需修复它报告的问题即可。
通配符
如果你不关心一个或多个变体,可以使用_
模式作为通配符:
#![allow(unused)] fn main() { match status { Status::Done => true, _ => false } }
_
模式匹配所有之前模式未匹配到的情况。
参考资料
- 本节练习位于
exercises/05_ticket_v2/02_match
变体可以持有数据
#![allow(unused)] fn main() { enum Status { ToDo, InProgress, Done, } }
我们的Status
枚举通常被称为C风格枚举。每个变体都是一个简单的标签,有点像命名常量。你可以在许多编程语言中找到这种枚举,如C、C++、Java、C#、Python等。
不过,Rust的枚举可以更进一步。我们可以在每个变体附加数据。
变体
假设我们想存储当前正在处理票证的人的名字。只有当票证处于进行中状态时,我们才有此信息。对于待办或已完成的票证则不会有此信息。我们可以通过在InProgress
变体上附加一个String
字段来模拟这个模型:
#![allow(unused)] fn main() { enum Status { ToDo, InProgress { assigned_to: String, }, Done, } }
InProgress
现在变成了一个类似结构体的变体。语法实际上反映了我们定义结构体时使用的语法——只是作为变体“内联”在枚举中。
访问变体数据
如果我们尝试访问Status
实例上的assigned_to
,
#![allow(unused)] fn main() { let status: Status = /* */; // This won't compile 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
是特定于变体的,不是所有Status
实例都可用。要访问assigned_to
,我们需要使用模式匹配:
#![allow(unused)] fn main() { match status { Status::InProgress { assigned_to } => { println!("Assigned to: {}", assigned_to); }, Status::ToDo | Status::Done => { println!("Done"); } } }
绑定
在匹配模式Status::InProgress { assigned_to }
中,assigned_to
是一个绑定。我们正在解构Status::InProgress
变体并将assigned_to
字段绑定到一个新变量,也称为assigned_to
。如果我们愿意,我们可以将字段绑定到一个不同的变量名:
#![allow(unused)] fn main() { match status { Status::InProgress { assigned_to: person } => { println!("Assigned to: {}", person); }, Status::ToDo | Status::Done => { println!("Done"); } } }
参考资料
- 本节练习位于
exercises/05_ticket_v2/03_variants_with_data
简洁的分支判断
你对上一练习的解答可能如下所示:
#![allow(unused)] fn main() { 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
方法:
#![allow(unused)] fn main() { 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
分支旨在提前返回(恐慌也算作提前返回!),你可以使用let/else
构造:
#![allow(unused)] fn main() { 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 } } }
它允许你分配解构后的变量,而不会引起任何“向右偏移”,即变量在与其前面代码相同缩进级别被赋值。
样式
if let
和let/else
都是惯用的Rust构造。根据需要使用它们来提高代码的可读性,但不要过度:当你需要时,match
总是在那里。
参考资料
- 本节练习位于
exercises/05_ticket_v2/04_if_let
空值处理
我们对assigned
方法的实现相当直接:对于待办和已完成的票证采用恐慌处理远非理想。通过使用Rust的Option
类型,我们可以做得更好。
Option
Option
是Rust中表示可空值的类型。它是Rust标准库中定义的一个枚举:
#![allow(unused)] fn main() { enum Option<T> { Some(T), None, } }
Option
编码了值可能存在(Some(T)
)或不存在(None
)的概念。它还强制你明确处理两种情况。如果你在处理可空值时忘记处理None
情况,编译器会报错。这相比其他语言中“隐式”的空值处理是一个显著改进,在那些语言中,你可能会忘记检查null
从而触发运行时错误。
Option
的定义
Option
的定义使用了一个你之前未见过的Rust结构:元组风格的变体。
元组风格的变体
Option
有两个变体:Some(T)
和None
。Some
是一个元组风格的变体:它保存了未命名的字段。
元组风格的变体常用于需要存储单个字段的场合,尤其是当我们面对像Option
这样的“包装”类型时。
元组风格的结构体
它们不仅限于枚举——你也可以定义元组风格的结构体:
#![allow(unused)] fn main() { struct Point(i32, i32); }
然后你可以通过位置索引来访问Point
实例的两个字段:
#![allow(unused)] fn main() { let point = Point(3, 4); let x = point.0; let y = point.1; }
元组
在还未见过元组的情况下就说某物像元组可能听起来有些奇怪!元组是Rust的基本类型。它们组合了一定数量的值,这些值可能具有(也可能不具有)不同的类型:
#![allow(unused)] fn main() { // 两个值,相同类型 let first: (i32, i32) = (3, 4); // 三个值,不同类型 let second: (i32, u32, u8) = (-42, 3, 8); }
语法很简单:你只需要在括号间列出类型,用逗号分隔。你可以使用点符号和字段索引来访问元组的字段:
#![allow(unused)] fn main() { assert_eq!(second.0, -42); assert_eq!(second.1, 3); assert_eq!(second.2, 8); }
当你懒得定义一个专用的结构体类型时,元组是将值组合在一起的一种便捷方式。
参考资料
- 本节的练习位于
exercises/05_ticket_v2/05_nullability
失败处理
让我们回顾一下上一练习中的Ticket::new
函数:
#![allow(unused)] fn main() { impl Ticket { pub fn new(title: String, description: String, status: Status) -> Ticket { // ... 检查逻辑和错误处理 ... Ticket { title, description, status, } } } }
一旦检查失败,该函数就会恐慌。这并不理想,因为它没有给调用者处理错误的机会。
现在是时候介绍Result
类型了,这是Rust处理错误的主要机制。
Result
类型
Result
类型是标准库中定义的一个枚举:
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }
它有两个变体:
Ok(T)
:表示成功执行的操作,包含操作的输出T
。Err(E)
:表示失败的操作,包含发生的错误E
。
Ok
和Err
都是泛型,允许你为成功和错误情况指定自己的类型。
无异常
Rust中的可恢复错误以值的形式表示。它们只是一个类型的实例,像任何其他值一样被传递和操作。这与其他语言(如Python或C#)使用异常来指示错误有显著不同。
异常创建了一个难以推理的独立控制流路径。仅凭函数签名,你无法知道它是否会抛出异常。仅凭函数签名,你也无法知道它会抛出哪种异常类型。你必须阅读函数的文档或查看其实现才能了解。
异常处理逻辑的局部性很差:抛出异常的代码与捕获它的代码相距甚远,两者之间没有直接联系。
失败处理编码在类型系统中
Rust通过Result
强制你在函数签名中编码失败的可能性。如果一个函数可能失败(并且你想让调用者有机会处理错误),它必须返回一个Result
。
#![allow(unused)] fn main() { // 仅凭签名,你就能知道这个函数可能会失败。 // 你还可以检查`ParseIntError`以了解可能出现的失败类型。 fn parse_int(s: &str) -> Result<i32, ParseIntError> { // ... } }
这就是Result
的最大优势:它使失败显式化。
但是,请记住,恐慌依然存在。它们没有被类型系统跟踪,就像其他语言中的异常一样。但它们用于不可恢复的错误,应谨慎使用。
参考资料
- 本节练习位于
exercises/05_ticket_v2/06_fallibility
解包处理
Ticket::new
现在在遇到无效输入时返回一个Result
而不是恐慌。这对调用者意味着什么?
错误不能(隐式)忽略
与异常不同,Rust的Result
迫使你在调用地点显式处理错误。如果你调用一个返回Result
的函数,Rust不允许你隐式忽略错误情况。
#![allow(unused)] fn main() { fn parse_int(s: &str) -> Result<i32, ParseIntError> { // ... } // 这将无法编译:我们没有处理错误情况。 // 我们必须使用`match`或`Result`提供的组合子之一来“解包”成功值或处理错误。 let number = parse_int("42") + 2; }
得到了一个Result
,然后呢?
当你调用返回Result
的函数时,你有两个主要选择:
- 如果操作失败,则恐慌。
这通常使用
unwrap
或expect
方法完成。#![allow(unused)] fn main() { // 如果`parse_int`返回`Err`则恐慌。 let number = parse_int("42").unwrap(); // `expect`允许你指定自定义的恐慌信息。 let number = parse_int("42").expect("解析整数失败"); }
- 使用
match
表达式解构Result
以显式处理错误情况。#![allow(unused)] fn main() { match parse_int("42") { Ok(number) => println!("解析的数字: {}", number), Err(err) => eprintln!("错误: {}", err), } }
参考资料
- 本节练习位于
exercises/05_ticket_v2/07_unwrap
错误枚举
你可能感觉上一练习的解答有些笨拙:基于字符串进行匹配并不理想!如果同事修改了Ticket::new
返回的错误信息(例如为了提高可读性),突然之间,你的调用代码就会出错。
你已经知道了修复这个问题所需的方法:枚举!
针对错误做出反应
当你希望允许调用者根据发生的特定错误采取不同行为时,你可以使用枚举来表示不同的错误情况:
#![allow(unused)] fn main() { // 一个错误枚举,代表从字符串解析`u32`时可能发生的 // 不同错误情况。 enum U32ParseError { NotANumber, TooLarge, Negative, } }
使用错误枚举,你将不同的错误情况编码进了类型系统中——它们成为了可失败函数签名的一部分。这简化了调用者的错误处理,因为他们可以使用match
表达式针对不同的错误情况进行反应:
#![allow(unused)] fn main() { match s.parse_u32() { Ok(n) => n, Err(U32ParseError::Negative) => 0, Err(U32ParseError::TooLarge) => u32::MAX, Err(U32ParseError::NotANumber) => { panic!("Not a number: {}", s); } } }
参考资料
- 本节练习位于
exercises/05_ticket_v2/08_error_enums
错误特性(Error trait)
错误报告
在上一练习中,你需要解构InvalidTitle
变体以提取错误信息,并将其传递给panic!
宏。这是错误报告的一个(基本)示例:将错误类型转换为可以展示给用户、服务操作员或开发者的表示形式。
每个Rust开发者都提出自己的错误报告策略是不切实际的:这会浪费时间,而且在项目间组合效果也不好。这就是Rust提供std::error::Error
特性的原因。
Error
特性
在Result
中的Err
变体类型没有约束,但使用实现了Error
特性的类型是一个良好实践。
Error
是Rust错误处理故事的基石:
#![allow(unused)] fn main() { // `Error`特性的简化定义 pub trait Error: Debug + Display {} }
你可能回想起来自《Sized特性》的:
语法——它用于指定SuperTrait。对于Error
,有两个超特性:Debug
和Display
。如果一个类型想要实现Error
,它也必须实现Debug
和Display
。
Display
和Debug
我们已经在之前的练习中遇到过Debug
特性——它是assert_eq!
在断言失败时显示其比较的变量值所使用的特性。
从“机械”角度看,Display
和Debug
是相同的——它们编码了类型应该如何转换为字符串般的表示形式:
#![allow(unused)] fn main() { // `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
返回的表示形式是为“终端用户”准备的,而Debug
提供了更适合开发者和服务操作员的低级表示。这就是为什么Debug
可以通过#[derive(Debug)]
属性自动实现,而Display
需要手动实现。
参考
- 本节练习位于
exercises/05_ticket_v2/09_error_trait
库与二进制文件
为TicketNewError
实现Error
特质是不是挺费劲的?手动实现Display
,再加上一个Error
实现块。
我们可以通过使用第三方库thiserror
减少一些样板代码,它提供了一个过程宏来简化自定义错误类型的创建。但话说回来,thiserror
是我们第一个依赖的第三方库!
在深入探讨依赖关系之前,我们先退一步谈谈Rust的打包系统。
什么是包?
Rust包由Cargo.toml
文件中的[package]
部分定义,也称为清单。在[package]
内,你可以设置包的元数据,比如名称和版本。
去看看这一节练习目录下的Cargo.toml
文件吧!
什么是crate?
在一个包内部,你可以有一个或多个crate,也称为目标。最常见的两种crate类型是二进制crate和库crate。
二进制文件
二进制文件是可以编译成可执行文件的程序。它必须包含一个名为main
的函数——程序的入口点。当程序被执行时,main
函数会被调用。
库
另一方面,库本身不可执行。你不能直接运行一个库,但可以从依赖它的其他包导入其代码。库将代码(如函数、类型等)组合在一起,作为依赖项供其他包使用。
迄今为止,你解决的所有练习都被构建为带有测试套件的库。
约定
关于Rust包,有一些约定需要记住:
- 包的源代码通常位于
src
目录下。 - 如果存在
src/lib.rs
文件,cargo
将推断包包含一个库crate。 - 如果存在
src/main.rs
文件,cargo
将推断包包含一个二进制crate。
你可以通过在Cargo.toml
文件中明确声明目标来覆盖这些默认值——更多细节见cargo文档。
请记住,虽然一个包可以包含多个crate,但它只能包含一个库crate。
构建新包的脚手架
你可以使用cargo
命令来生成一个新的包:
cargo new my-binary
这将在当前目录下创建一个名为my-binary
的新文件夹,包含一个同名的Rust包和一个单一的二进制crate。
如果你想创建一个库crate,可以使用--lib
标志:
cargo new my-library --lib
参考资料
- 本节练习位于
exercises/05_ticket_v2/10_packages
依赖管理
包可以通过在它的Cargo.toml
文件的[dependencies]
部分列出其他包来依赖它们。指定依赖最常用的方式是提供其名称和版本号:
[dependencies]
thiserror = "1"
这样会将thiserror
作为依赖添加到你的包中,其最低版本为1.0.0
。thiserror
将会从Rust的官方包注册中心crates.io获取。当你运行cargo build
时,cargo
会经历几个阶段:
- 依赖解析
- 下载依赖
- 编译项目(包括你自己的代码和依赖)
如果你的项目有Cargo.lock
文件,并且你的清单文件未发生变化,那么依赖解析步骤将被跳过。锁定文件是cargo
在成功完成一轮依赖解析后自动生成的,它包含了项目中所有依赖的确切版本,用于确保在不同构建环境(例如CI)中始终使用相同版本的依赖。如果你正在与多位开发者共同开发项目,应当将Cargo.lock
文件提交到版本控制系统中。
你可以使用cargo update
命令来更新Cargo.lock
文件,使其包含所有依赖的最新(兼容)版本。
路径依赖
你也可以通过路径来指定依赖,这对于处理多个本地包时非常有用。
[dependencies]
my-library = { path = "../my-library" }
这个路径是相对于声明依赖的包的Cargo.toml
文件而言的。
其他来源
查阅Cargo文档以了解更多关于如何在Cargo.toml
文件中指定依赖以及从何处获取依赖的信息。
开发依赖
你还可以指定仅在开发过程中需要的依赖项,也就是说,只有在运行cargo test
时它们才会被引入。这类依赖应放在Cargo.toml
文件的[dev-dependencies]
部分:
[dev-dependencies]
static_assertions = "1.1.0"
本书中,我们已经使用了几个这样的依赖来简化测试代码。
参考资料
- 本节练习位于
exercises/05_ticket_v2/11_dependencies
thiserror
稍微绕了个弯,对吧?但这是必要的!现在让我们回到正轨:自定义错误类型与 thiserror
。
自定义错误类型
我们已经了解了如何为自定义错误类型“手动”实现 Error
特性。想象一下,如果你需要在代码库中的大多数错误类型上都这样做,那将产生大量的样板代码,不是吗?
通过使用 Rust 仓库 thiserror
,我们可以减少一些这样的样板代码。它提供了一个过程宏来简化自定义错误类型的创建。
#![allow(unused)] fn main() { #[derive(thiserror::Error, Debug)] enum TicketNewError { #[error("{0}")] TitleError(String), #[error("{0}")] DescriptionError(String), } }
你可以编写自己的宏
迄今为止,我们见到的所有 derive
宏都是由 Rust 标准库提供的。thiserror::Error
是第三方 derive
宏的第一个示例。
derive
宏是过程宏的一个子集,过程宏是一种在编译时生成 Rust 代码的方式。虽然在这个课程中我们不会深入探讨如何编写过程宏的细节,但重要的是要明白你可以自己编写它们!这是一个更高级的 Rust 课程中会涉及的主题。
自定义语法
每个过程宏都可以定义自己的语法,通常在仓库的文档中进行解释。以 thiserror
为例,我们有:
#[derive(thiserror::Error)]
:这是使用thiserror
协助为自定义错误类型派生Error
特性的语法。#[error("{0}")]
:这是为自定义错误类型的每个变体定义Display
实现的语法。{0}
在错误被显示时会被变体的第零个字段(在此例中为String
)替换。
参考资料
- 本节练习位于
exercises/05_ticket_v2/12_thiserror
TryFrom
和 TryInto
在前一章中,我们学习了 From
和 Into
特性,这是 Rust 中用于肯定不会出错类型转换的习惯用法接口。但如果转换不能保证成功呢?
我们现在对错误有足够的了解,可以讨论 From
和 Into
的可能出错对应物:TryFrom
和 TryInto
。
TryFrom
和 TryInto
TryFrom
和 TryInto
都定义在 std::convert
模块中,和 From
与 Into
一样。
#![allow(unused)] fn main() { 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
/Into
与 TryFrom
/TryInto
之间的主要区别在于后者返回一个 Result
类型。这允许转换失败,并返回错误而不是导致恐慌。
Self::Error
TryFrom
和 TryInto
都有一个关联的 Error
类型。这让每个实现都能指定自己的错误类型,理想情况下,该错误类型最适合尝试进行的转换。
Self::Error
是一种引用在特性自身中定义的关联错误类型的方式。
互补性
就像 From
和 Into
一样,TryFrom
和 TryInto
也是互补的特征。如果你为某个类型实现了 TryFrom
,那么就会免费获得 TryInto
。
参考资料
- 本节练习位于
exercises/05_ticket_v2/13_try_from
Error::source
There's one more thing we need to talk about to complete our coverage of the Error
trait: the source
method.
#![allow(unused)] fn main() { // Full definition this time! pub trait Error: Debug + Display { fn source(&self) -> Option<&(dyn Error + 'static)> { None } } }
The source
method is a way to access the error cause, if any.
Errors are often chained, meaning that one error is the cause of another: you have a high-level error (e.g.
cannot connect to the database) that is caused by a lower-level error (e.g. can't resolve the database hostname).
The source
method allows you to "walk" the full chain of errors, often used when capturing error context in logs.
Implementing source
The Error
trait provides a default implementation that always returns None
(i.e. no underlying cause). That's why
you didn't have to care about source
in the previous exercises.
You can override this default implementation to provide a cause for your error type.
#![allow(unused)] fn main() { 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) } } }
In this example, DatabaseError
wraps an std::io::Error
as its source.
We then override the source
method to return this source when called.
&(dyn Error + 'static)
What's this &(dyn Error + 'static)
type?
Let's unpack it:
dyn Error
is a trait object. It's a way to refer to any type that implements theError
trait.'static
is a special lifetime specifier.'static
implies that the reference is valid for "as long as we need it", i.e. the entire program execution.
Combined: &(dyn Error + 'static)
is a reference to a trait object that implements the Error
trait
and is valid for the entire program execution.
Don't worry too much about either of these concepts for now. We'll cover them in more detail in future chapters.
Implementing source
using thiserror
thiserror
provides three ways to automatically implement source
for your error types:
- A field named
source
will automatically be used as the source of the error.#![allow(unused)] fn main() { use thiserror::Error; #[derive(Error, Debug)] pub enum MyError { #[error("Failed to connect to the database")] DatabaseError { source: std::io::Error } } }
- A field annotated with the
#[source]
attribute will automatically be used as the source of the error.#![allow(unused)] fn main() { use thiserror::Error; #[derive(Error, Debug)] pub enum MyError { #[error("Failed to connect to the database")] DatabaseError { #[source] inner: std::io::Error } } }
- A field annotated with the
#[from]
attribute will automatically be used as the source of the error andthiserror
will automatically generate aFrom
implementation to convert the annotated type into your error type.#![allow(unused)] fn main() { use thiserror::Error; #[derive(Error, Debug)] pub enum MyError { #[error("Failed to connect to the database")] DatabaseError { #[from] inner: std::io::Error } } }
The ?
operator
The ?
operator is a shorthand for propagating errors.
When used in a function that returns a Result
, it will return early with an error if the Result
is Err
.
For example:
#![allow(unused)] fn main() { 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) } }
is equivalent to:
#![allow(unused)] fn main() { 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) } }
You can use the ?
operator to shorten your error handling code significantly.
In particular, the ?
operator will automatically convert the error type of the fallible operation into the error type
of the function, if a conversion is possible (i.e. if there is a suitable From
implementation)
References
- The exercise for this section is located in
exercises/05_ticket_v2/14_source
总结回顾
在领域建模中,细节决定成败。Rust 提供了丰富的工具集,帮助你直接在类型系统中表达领域约束,但这需要通过实践来熟练掌握,以编写出既正确又符合习惯的代码。
让我们通过对 Ticket
模型的最后一次优化来结束这一章。我们将为 Ticket
结构体中的每个字段引入一个新的类型,以封装各自的具体约束。这样一来,每次有人访问 Ticket
的字段时,他们得到的都将是一个确保有效的值——比如,一个 TicketTitle
而非一个普通的 String
。他们在代码的其他部分就不必担心标题为空的问题了:只要他们拥有了一个 TicketTitle
,就可以凭借其构造方式知道它是有效的。
这仅仅是利用 Rust 类型系统使你的代码更安全、更具表现力的一个示例。
参考练习
- 本节相关的练习位于
exercises/05_ticket_v2/15_outro
深入阅读
引言
在上一章中,我们在一个孤立的环境中对 Ticket
进行了建模:我们定义了其字段及其约束,学习了如何用 Rust 最好地表示它们,但我们并未考虑 Ticket
如何融入更大的系统中。本章将围绕 Ticket
构建一个简单的工作流程,引入一个基本的管理系统来存储和检索票据。
此任务将为我们提供探索 Rust 新概念的机会,包括:
- 堆分配的数组
Vec
,一种可增长的数组类型,以及切片Iterator
和IntoIterator
,用于遍历集合- 切片(
&[T]
),用于操作集合的部分 - 生命周期,描述引用的有效期
HashMap
和BTreeMap
,两种键值数据结构Eq
和Hash
,用于在HashMap
中比较键Ord
和PartialOrd
,用于操作BTreeMap
Index
和IndexMut
,用于访问集合中的元素
数组
一旦开始讨论“票务管理”,我们就需要考虑一种存储多个票据**的方法。这进而意味着我们需要考虑集合,尤其是同质集合:我们希望存储同一类型的多个实例。
在这方面,Rust 提供了哪些工具呢?
数组
初次尝试可以是使用数组。
Rust 中的数组是固定大小、元素类型相同的集合。
定义数组的方法如下:
#![allow(unused)] fn main() { // 数组类型语法:[ <类型> ; <元素数量> ] let numbers: [u32; 3] = [1, 2, 3]; }
这创建了一个包含三个整数的数组,并初始化为值 1
、2
和 3
。数组的类型是 [u32; 3]
,意为“长度为3的 u32
类型数组”。
访问元素
你可以使用方括号访问数组的元素:
#![allow(unused)] fn main() { let first = numbers[0]; let second = numbers[1]; let third = numbers[2]; }
索引必须为 usize
类型。数组是从零开始索引的,这在 Rust 中很常见。你之前在字符串切片和元组/类似元组变体的字段索引中见过这一点。
越界访问
如果你试图访问越界的元素,Rust 会引发恐慌:
#![allow(unused)] fn main() { let numbers: [u32; 3] = [1, 2, 3]; let fourth = numbers[3]; // 这将引发恐慌 }
这是通过边界检查在运行时强制执行的。它会带来一点性能开销,但也是 Rust 防止缓冲区溢出的方式。
在某些场景下,Rust 编译器可以优化掉边界检查,特别是涉及到迭代器时——我们稍后会详细讨论这一点。
如果你不想触发恐慌,可以使用 get
方法,它返回 Option<&T>
:
#![allow(unused)] fn main() { let numbers: [u32; 3] = [1, 2, 3]; assert_eq!(numbers.get(0), Some(&1)); // 如果尝试访问越界索引,你会得到 `None` 而不是恐慌。 assert_eq!(numbers.get(3), None); }
性能
由于数组的大小在编译时已知,编译器可以将数组分配在栈上。如果运行以下代码:
#![allow(unused)] fn main() { let numbers: [u32; 3] = [1, 2, 3]; }
你将得到以下内存布局:
+---+---+---+
Stack: | 1 | 2 | 3 |
+---+---+---+
换句话说,数组的大小是 std::mem::size_of::<T>() * N
,其中 T
是元素的类型,N
是元素的数量。
你可以以 O(1)
时间复杂度访问和替换每个元素。
向量
数组的优势也恰恰是其局限所在:其大小必须在编译时预先确定。如果你尝试创建一个大小仅在运行时才知道的数组,将会遇到编译错误:
#![allow(unused)] fn main() { let n = 10; let numbers: [u32; n]; }
error[E0435]: 尝试在常量中使用非常量值
--> src/main.rs:3:20
|
2 | let n = 10;
3 | let numbers: [u32; n];
| ^ 非常量值
对于票务管理系统来说,数组并不适用——我们在编译时不知道需要存储多少张票。这时候,Vec
就派上用场了。
Vec
Vec
是标准库提供的一个可增长的数组类型。
你可以使用Vec::new
函数创建一个空的向量:
#![allow(unused)] fn main() { let mut numbers: Vec<u32> = Vec::new(); }
然后,你可以使用push
方法向向量中添加元素:
#![allow(unused)] fn main() { numbers.push(1); numbers.push(2); numbers.push(3); }
新值会被追加到向量的末尾。
如果在创建时就知道元素值,也可以使用vec!
宏来创建一个初始化的向量:
#![allow(unused)] fn main() { let numbers = vec![1, 2, 3]; }
访问元素
访问元素的语法与数组相同:
#![allow(unused)] fn main() { let numbers = vec![1, 2, 3]; let first = numbers[0]; let second = numbers[1]; let third = numbers[2]; }
索引必须是usize
类型。
同样,你也可以使用get
方法,它返回一个Option<&T>
:
#![allow(unused)] fn main() { let numbers = vec![1, 2, 3]; assert_eq!(numbers.get(0), Some(&1)); // 如果尝试访问越界索引,会得到 `None` 而不是恐慌。 assert_eq!(numbers.get(3), None); }
访问同样进行了边界检查,复杂度为O(1)。
内存布局
Vec
是一个堆分配的数据结构。
当你创建一个Vec
时,它会在堆上分配内存来存储元素。
如果运行以下代码:
#![allow(unused)] fn main() { let mut numbers = Vec::with_capacity(3); numbers.push(1); numbers.push(2); }
得到的内存布局如下:
+---------+--------+----------+
栈 | 指针 | 长度 | 容量 |
| | | 2 | 3 |
+--|------+--------+----------+
|
|
v
+---+---+---+
堆: | 1 | 2 | ? |
+---+---+---+
Vec
跟踪三件事:
- 指针到你预留的堆区域。
- 长度,即向量中有多少个元素。
- 容量,即堆上预留空间能容纳的元素数量。
这个布局应该很眼熟:它和String
完全一样!这不是巧合:String
本质上在内部定义为字节的向量,即Vec<u8>
:
#![allow(unused)] fn main() { pub struct String { vec: Vec<u8>, } }
重新调整大小
我们提到过Vec
是一种“可增长”的向量类型,但这具体意味着什么呢?
如果尝试在一个已经达到最大容量的Vec
中插入一个元素会发生什么?
#![allow(unused)] fn main() { let mut numbers = Vec::with_capacity(3); numbers.push(1); numbers.push(2); numbers.push(3); // 达到最大容量 numbers.push(4); // 这里会发生什么? }
这时,Vec
将会自动扩容。
它会向分配器请求一块新的(更大)堆内存,将所有元素复制过去,并释放旧的内存空间。
这个操作可能会比较昂贵,因为它涉及到了新的内存分配及现有所有元素的复制过程。
Vec::with_capacity
如果你对将要在Vec
中存储多少个元素有个大概的预估,可以使用Vec::with_capacity
方法预先分配足够的内存。
这可以在Vec
增长时避免新的内存分配操作,但如果你高估了实际使用量,也可能造成内存浪费。
这需要根据具体情况具体分析权衡。
迭代
在最初的几个练习中,你已经了解到 Rust 允许你使用 for
循环遍历集合。当时我们处理的是范围(例如 0..5
),但同样的规则也适用于数组和向量这样的集合。
#![allow(unused)] fn main() { // 对于 `Vec` 有效 let v = vec![1, 2, 3]; for n in &v { println!("{}", n); } // 对于数组也有效 let a: [u32; 3] = [1, 2, 3]; for n in a.iter() { println!("{}", n); } }
现在,是时候了解这背后的原理了。
for
循环的展开
每次你在 Rust 中编写 for
循环时,编译器都会将其转换为如下代码:
#![allow(unused)] fn main() { let mut iter = v.into_iter(); loop { match iter.next() { Some(n) => { println!("{}", n); } None => break, } } }
loop
是除了 for
和 while
外的另一种循环结构。
loop
块会无限循环,除非你明确地使用 break
退出它。
Iterator
特性
前面代码片段中的 next
方法来自 Iterator
特性。
Iterator
特性在 Rust 标准库中定义,为能够产生一系列值的类型提供了一个共享接口:
#![allow(unused)] fn main() { trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; } }
Item
关联类型指定了由迭代器产生的值的类型。
next
返回序列中的下一个值。
如果有值返回,则返回 Some(value)
;如果没有,则返回 None
。
请注意:迭代器返回 None
并不能保证它已被耗尽。只有当迭代器实现了更严格的
FusedIterator
特性时,才会有此保证。
IntoIterator
特性
并非所有类型都实现了 Iterator
,但许多类型可以转换为实现了该特性的类型。
这就是 IntoIterator
特性的作用:
#![allow(unused)] fn main() { trait IntoIterator { type Item; type IntoIter: Iterator<Item = Self::Item>; fn into_iter(self) -> Self::IntoIter; } }
into_iter
方法消耗原始值并返回其元素的迭代器。
一个类型只能有一个 IntoIterator
的实现:对于 for
应该展开为什么形式不存在任何歧义。
一个小细节:任何实现了 Iterator
的类型也会自动实现 IntoIterator
。它们只是从 into_iter
返回自己!
边界检查
迭代迭代器有一个很好的副作用:设计上你不可能越界。
这让 Rust 能够从生成的机器代码中移除边界检查,从而加快迭代速度。
换言之,
#![allow(unused)] fn main() { let v = vec![1, 2, 3]; for n in v { println!("{}", n); } }
通常比
#![allow(unused)] fn main() { let v = vec![1, 2, 3]; for i in 0..v.len() { println!("{}", v[i]); } }
更快。当然,也有例外:编译器有时能证明即使手动索引也不会越界,因此也会移除边界检查。但一般而言,尽可能选择迭代而非索引。
iter方法
IntoIterator
消耗 self
以创建一个迭代器。
这样做的好处是:你可以从迭代器中获取拥有所有权的值。例如,如果你在一个 Vec<Ticket>
上调用 .into_iter()
,你会得到一个返回 Ticket
值的迭代器。
但这同时也是它的缺点:在调用了 .into_iter()
后,你不能再使用原来的集合。
很多时候,你希望在不消耗集合的情况下遍历它,而是查看对值的引用。
以 Vec<Ticket>
为例,你可能想要遍历 &Ticket
类型的值。
大多数集合都提供了一个名为 .iter()
的方法,它返回一个迭代器,该迭代器提供对集合元素的引用。
例如:
#![allow(unused)] fn main() { let numbers: Vec<u32> = vec![1, 2]; // 在这里,`n` 的类型为 `&u32` for n in numbers.iter() { // [...] } }
这种模式可以通过为集合的引用实现 IntoIterator
来简化。
在上面的例子中,那就是 &Vec<Ticket>
。
标准库就是这样做的,所以以下代码能够工作:
#![allow(unused)] fn main() { let numbers: Vec<u32> = vec![1, 2]; // 在这里,`n` 的类型为 `&u32` // 我们不必显式地调用 `.iter()` // 在 `for` 循环中直接使用 `&numbers` 就足够了 for n in &numbers { // [...] } }
通常情况下,提供两种选项是惯用的做法:
- 为集合的引用实现
IntoIterator
。 - 提供一个
.iter()
方法,它返回一个迭代器,该迭代器提供对集合元素的引用。
前者在 for
循环中更为方便,后者则更加明确,可以在其他上下文中使用。
生命周期
让我们尝试通过为 &TicketStore
添加 IntoIterator
的实现来完成之前的练习,以便在 for
循环中获得最大的便利性。
首先,我们填写实现中最“明显”的部分:
#![allow(unused)] fn main() { 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::Iter
。Iter
的定义是:
#![allow(unused)] fn main() { pub struct Iter<'a, T> { /* 字段省略 */ } }
'a
是一个 生命周期参数。
生命周期参数
生命周期是 Rust 编译器用来追踪引用(无论是可变还是不可变)有效时间的 标签。
引用的生命周期受到它所指向值的作用域限制。Rust 总是在编译时确保引用不会在其指向的值被丢弃后使用,以避免悬挂指针和使用已释放内存的错误。
这听起来应该很熟悉:在讨论所有权和借用时,我们已经看到过这些概念的应用。生命周期只是给特定引用的有效时间 命名 的方式。
当存在多个引用并且需要澄清它们彼此之间的 关联关系 时,命名变得重要。让我们看看 Vec::iter()
的签名:
#![allow(unused)] fn main() { impl <T> Vec<T> { // 稍作简化 pub fn iter<'a>(&'a self) -> Iter<'a, T> { // [...] } } }
Vec::iter()
对一个名为 'a
的生命周期参数是泛型的。
'a
用来 绑定 Vec
的生命周期和由 iter()
返回的 Iter
的生命周期。通俗地说:由 iter()
返回的 Iter
不能超过创建它的 Vec
引用(&self
)的生命周期。
这一点很重要,因为如前所述,Vec::iter
返回的是 对 Vec
元素的引用。如果 Vec
被丢弃,迭代器返回的引用将无效。Rust 必须确保这种情况不会发生,而生命周期就是它用来实施这一规则的工具。
生命周期省略
Rust 有一套称为 生命周期省略规则 的规则,在很多情况下允许你省略显式的生命周期注解。例如,Vec::iter
在 std
源代码中的定义是这样的:
#![allow(unused)] fn main() { impl <T> Vec<T> { pub fn iter(&self) -> Iter<'_, T> { // [...] } } }
Vec::iter()
的签名中没有显式的生命周期参数。省略规则意味着 iter()
返回的 Iter
的生命周期与 &self
引用的生命周期相关联。你可以把 '_
当作 占位符 来理解,代表 &self
引用的生命周期。
有关生命周期省略的官方文档链接,请参阅 参考资料 部分。在大多数情况下,你可以依赖编译器告诉你何时需要添加显式的生命周期注解。
参考资料
组合子
迭代器能做的远不止是 for
循环!
如果你查阅 Iterator
特性的文档,你会发现大量的方法集合,可以用来以各种方式转换、过滤和组合迭代器。
这里列举一些最常见的:
map
对迭代器中的每个元素应用一个函数。filter
只保留满足谓词的元素。filter_map
结合了filter
和map
的功能,一步完成过滤和映射。cloned
将引用迭代器转换为值迭代器,并克隆每个元素。enumerate
返回一个新的迭代器,产生(索引, 值)
对。skip
跳过迭代器的前n
个元素。take
在处理完n
个元素后停止迭代器。chain
将两个迭代器合并为一个。
这些方法被称为 组合子。
它们通常被 链式调用 来以简洁且易于阅读的方式创建复杂的转换:
#![allow(unused)] fn main() { let numbers = vec![1, 2, 3, 4, 5]; // 偶数的平方和 let outcome: u32 = numbers.iter() .filter(|&n| n % 2 == 0) .map(|&n| n * n) .sum(); }
闭包
上述 filter
和 map
方法是怎么回事呢?
它们接受 闭包 作为参数。
闭包是 匿名函数,即不由我们熟悉的 fn
语法定义的函数。
它们使用 |args| body
语法定义,其中 args
是参数,body
是函数体。body
可以是一段代码块或单个表达式。
例如:
#![allow(unused)] fn main() { // 一个给其参数加1的匿名函数 let add_one = |x| x + 1; // 也可以用代码块写: let add_one = |x| { x + 1 }; }
闭包可以接受多个参数:
#![allow(unused)] fn main() { let add = |x, y| x + y; let sum = add(1, 2); }
它们还能捕获环境中的变量:
#![allow(unused)] fn main() { let x = 42; let add_x = |y| x + y; let sum = add_x(1); }
必要时,你可以指定参数和/或返回类型的类型:
#![allow(unused)] fn main() { // 只指定输入类型 let add_one = |x: i32| x + 1; // 或者同时指定输入和输出类型,使用 `fn` 语法 let add_one: fn(i32) -> i32 = |x| x + 1; }
collect
使用组合子转换完迭代器后怎么办?
你可以使用 for
循环遍历转换后的值,或者将它们收集到集合中。
后者通过 collect
方法完成。
collect
消耗尽迭代器并将其元素收集到你选择的集合中。
例如,你可以将偶数的平方收集到一个 Vec
中:
#![allow(unused)] fn main() { 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
对其 返回类型 是泛型的。
因此,通常需要提供类型提示帮助编译器推断正确的类型。
在上面的例子中,我们标注了 squares_of_evens
的类型为 Vec<u32>
。
或者,你可以使用 turbofish语法 来指定类型:
#![allow(unused)] fn main() { let squares_of_evens = numbers.iter() .filter(|&n| n % 2 == 0) .map(|&n| n * n) // turbofish语法:`<method_name>::<type>()` // 因为 `::<>` 看起来像一条鱼,故得名turbofish .collect::<Vec<u32>>(); }
进一步学习
Iterator
的文档概述了标准库中迭代器可用的方法。- itertools 库定义了更多针对迭代器的 组合子。
impl Trait
TicketStore::to_dos
返回一个 Vec<&Ticket>
。这样的签名导致每次调用 to_dos
时都会进行一次堆分配,而实际上根据调用者的后续操作,这可能是不必要的开销。如果 to_dos
能返回一个迭代器而不是 Vec
,就能让调用者决定是否将结果收集到 Vec
中或直接进行迭代,这样会更佳。
但这有点棘手!下面这样实现的 to_dos
返回类型是什么呢?
#![allow(unused)] fn main() { impl TicketStore { pub fn to_dos(&self) -> ??? { self.tickets.iter().filter(|t| t.status == Status::ToDo) } } }
无法命名的类型
filter
方法返回一个 std::iter::Filter
实例,其定义如下:
#![allow(unused)] fn main() { pub struct Filter<I, P> { /* 省略字段 */} }
这里 I
是被过滤的迭代器的类型,P
是用于过滤元素的谓词。我们知道在这个例子中 I
是 std::slice::Iter<'_, Ticket>
,但 P
呢?P
是一个闭包,一个匿名函数。正如其名所示,闭包没有名字,所以我们无法直接在代码中写出它的类型。
Rust 对此提供了一个解决方案:impl Trait。
impl Trait
impl Trait
是一个特性,允许你在不指定类型名称的情况下返回类型。你只需声明类型实现了哪些特征(trait),剩下的交给 Rust 解决。
在这种情况下,我们想返回一个对 Ticket
的引用的迭代器:
#![allow(unused)] fn main() { impl TicketStore { pub fn to_dos(&self) -> impl Iterator<Item = &Ticket> { self.tickets.iter().filter(|t| t.status == Status::ToDo) } } }
这就对了!
泛型吗?
返回位置上的 impl Trait
不是 泛型参数。
泛型是函数调用者填充的类型占位符。具有泛型参数的函数是多态的:它可以被不同类型的调用,并且编译器会为每种类型生成不同的实现。
而 impl Trait
不是这样。带有 impl Trait
的函数的返回类型在编译时是固定的,编译器会为其生成单一的实现。这也是为什么 impl Trait
也被称作不透明返回类型:调用者不知道返回值的确切类型,只知道它实现了指定的特征(trait)。但编译器知道确切的类型,这里不涉及多态。
RPIT
如果你阅读 Rust 的 RFC 或深入探讨文章,可能会遇到 RPIT 这个缩写。它代表 "Return Position Impl Trait",指的是在返回位置使用 impl Trait
的情况。
impl Trait
作为参数位置的使用
在前一节中,我们了解了如何使用 impl Trait
在不指定具体类型名称的情况下返回类型。同样的语法也可以用于参数位置:
#![allow(unused)] fn main() { fn print_iter(iter: impl Iterator<Item = i32>) { for i in iter { println!("{}", i); } } }
print_iter
函数接收一个 i32
类型的迭代器并打印每个元素。当在参数位置使用 impl Trait
时,它等同于带有特质界限的泛型参数:
#![allow(unused)] fn main() { fn print_iter<T>(iter: T) where T: Iterator<Item = i32> { for i in iter { println!("{}", i); } } }
不利之处
一般而言,相较于在参数位置使用 impl Trait
,优先考虑使用泛型更为适宜。泛型允许调用者通过涡轮鱼语法(::<>
)显式指定参数的类型,这对于消除类型歧义非常有用,而这是 impl Trait
所不具备的。
切片(Slices)
让我们回到 Vec
的内存布局:
#![allow(unused)] fn main() { 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
的连续元素序列的切片。它最常以引用形式 &[T]
出现。
有多种方式可以从 Vec
创建切片引用:
#![allow(unused)] fn main() { let numbers = vec![1, 2, 3]; // 通过索引语法 let slice: &[i32] = &numbers[..]; // 通过方法 let slice: &[i32] = numbers.as_slice(); // 或仅针对元素的子集 let slice: &[i32] = &numbers[1..]; }
Vec
使用 [T]
作为目标类型实现了 Deref
特性,因此由于解引用强制转换,你可以在 Vec
直接使用切片方法:
#![allow(unused)] fn main() { let numbers = vec![1, 2, 3]; // 惊喜!“iter”并不是“Vec”上的方法! // 它实际上是 “&[T]” 上的方法,但由于解引用强制, // 你可以在 “Vec” 上直接调用它。 let sum: i32 = numbers.iter().sum(); }
内存布局
&[T]
是一个宽指针,就像 &str
一样。它由指向切片第一个元素的指针和切片长度组成。
如果你有一个包含三个元素的 Vec
:
#![allow(unused)] fn main() { let numbers = vec![1, 2, 3]; }
然后创建一个切片引用:
#![allow(unused)] fn main() { 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]
而非 &Vec<T>
。这使得函数能够接受任何形式的切片,而不一定是由 Vec
支持的。
例如,你可以传递 Vec
中元素的子集。
但不仅如此,你还可以传递数组的切片:
#![allow(unused)] fn main() { let array = [1, 2, 3]; let slice: &[i32] = &array; }
数组切片和 Vec
切片是相同类型:它们都是指向连续元素序列的宽指针。对于数组来说,指针指向栈而非堆,但在使用切片时这一点并不重要。
可变切片
当我们谈及切片类型,比如字符串切片(&str)和泛型切片([T])时,我们通常指的是它们的不可变借用形式(&str 和 &[T])。但实际上,切片也支持可变形式!
创建可变切片的方式如下:
#![allow(unused)] fn main() { let mut numbers = vec![1, 2, 3]; let slice: &mut [i32] = &mut numbers; }
之后,你就可以通过这个可变切片来修改其内部元素:
#![allow(unused)] fn main() { slice[0] = 42; }
这样就将 Vec
中的第一个元素改为了 42。
限制
在使用不可变借用时,规则相对明确:推荐优先采用切片引用(如 &[T])而非容器本身的引用(如 &Vec
思考以下示例:
#![allow(unused)] fn main() { let mut numbers = Vec::with_capacity(2); let mut slice: &mut [i32] = &mut numbers; slice.push(1); }
这段代码将无法通过编译!原因在于,push
方法是属于 Vec
的,而不是切片的。这反映了一个普遍原则:Rust 不允许直接通过切片来增加或减少元素数量。你仅能修改已存在的元素内容。
从这个角度看,&mut Vec 或 &mut String 相较于 &mut [T] 或 &mut str 提供了更多的功能,因为它们允许对容器本身的结构进行修改,比如添加或移除元素。
因此,你应该根据实际需求选择最合适的类型:如果只需修改数据而不涉及容器结构变化,可使用可变切片;若需调整容器大小,则应采用容器本身的可变引用。
工单编号
让我们再次思考一下我们的工单管理系统。目前,我们的工单模型是这样的:
#![allow(unused)] fn main() { pub struct Ticket { pub title: TicketTitle, pub description: TicketDescription, pub status: Status } }
这里缺少了一样东西:一个用来唯一标识工单的编号。这个编号对每个工单都应该是唯一的。我们可以在创建新工单时自动生成编号来保证这一点。
优化模型
编号应该存储在哪里呢?我们可以在 Ticket
结构体中添加一个新的字段:
#![allow(unused)] fn main() { pub struct Ticket { pub id: TicketId, pub title: TicketTitle, pub description: TicketDescription, pub status: Status } }
但我们在创建工单之前是不知道这个编号的。因此,一开始它不能存在。它必须是可选的:
#![allow(unused)] fn main() { pub struct Ticket { pub id: Option<TicketId>, pub title: TicketTitle, pub description: TicketDescription, pub status: Status } }
这也不是理想的情况——每次我们从存储中检索工单时,都不得不处理 None
的情况,尽管我们知道一旦工单被创建,编号就应该始终存在。
最佳的解决方案是设置工单的两种状态,由两个不同的类型表示:TicketDraft
(工单草稿)和 Ticket
(正式工单):
#![allow(unused)] fn main() { 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
是尚未创建的工单,它没有编号,也没有状态。而 Ticket
是已经创建的工单,它既有编号也有状态。由于 TicketDraft
和 Ticket
中的每个字段都嵌入了自己的约束,我们不需要在两个类型之间重复逻辑。
索引
TicketStore::get
方法针对给定的 TicketId
返回一个 Option<&Ticket>
。之前我们已经了解了如何使用 Rust 的索引语法来访问数组和向量的元素:
#![allow(unused)] fn main() { let v = vec![0, 1, 2]; assert_eq!(v[0], 0); }
我们怎样才能为 TicketStore
提供类似的访问体验呢?你猜对了:我们需要实现一个特质,那就是 Index
!
Index
特质
Index
特质是在 Rust 的标准库中定义的:
#![allow(unused)] fn main() { // 略微简化的版本 pub trait Index<Idx> { type Output; // 必须实现的方法 fn index(&self, index: Idx) -> &Self::Output; } }
它包含:
- 一个泛型参数
Idx
,用于表示索引类型 - 一个关联类型
Output
,表示通过索引获取的类型
注意,index
方法并不返回一个 Option
。其假设是如果你尝试访问不存在的元素,index
会像数组和向量索引那样 panic。
可变索引
Index
只提供了只读访问权限,并不允许你修改获取的值。
IndexMut
如果你想允许可变性,就需要实现 IndexMut
特质。
#![allow(unused)] fn main() { // 略微简化的版本 pub trait IndexMut<Idx>: Index<Idx> { // 必须实现的方法 fn index_mut(&mut self, index: Idx) -> &mut Self::Output; } }
只有当类型已经实现了 Index
特质后,才能实现 IndexMut
,因为它解锁了额外的修改能力。
HashMap
我们对 Index
/IndexMut
的实现并不理想:我们需要遍历整个 Vec
来通过 ID 获取工单;算法复杂度为 O(n)
,其中 n
是存储中工单的数量。
我们可以通过使用不同的数据结构 HashMap<K, V>
来改善这一情况来存储工单。
#![allow(unused)] fn main() { use std::collections::HashMap; // 类型推导允许我们省略显式的类型签名(在这个例子中将是 `HashMap<String, String>`)。 let mut book_reviews = HashMap::new(); book_reviews.insert( "哈克贝利·费恩历险记".to_string(), "我最喜欢的书.".to_string(), ); }
HashMap
通过键值对工作。它对两者都具有泛型性:K
是键类型的泛型参数,而 V
是值类型的泛型参数。
插入、检索和移除的预期成本是恒定的,即 O(1)
。这听起来非常适合我们的应用场景,不是吗?
键的要求
虽然 HashMap
的结构定义上没有特征界限,但你可以在其方法上找到一些。以 insert
为例:
#![allow(unused)] fn main() { // 略微简化 impl<K, V> HashMap<K, V> where K: Eq + Hash, { pub fn insert(&mut self, k: K, v: V) -> Option<V> { // [...] } } }
键的类型必须实现 Eq
和 Hash
特征。
Hash
哈希函数(或散列器)将一个潜在无限的值集合(例如,所有可能的字符串)映射到一个有限范围内(例如,一个 u64
值)。有许多不同的哈希函数,各有不同的属性(速度、碰撞风险、可逆性等)。
顾名思义,HashMap
在幕后使用哈希函数。它对你的键进行哈希运算,然后使用该哈希值来存储/检索相关的值。这种策略要求键类型必须是可哈希的,因此在 K
上有 Hash
特征界限。
你可以在 std::hash
模块中找到 Hash
特征:
#![allow(unused)] fn main() { pub trait Hash { // 必须实现的方法 fn hash<H>(&self, state: &mut H) where H: Hasher; } }
你很少会手动实现 Hash
,大多数时候你会通过派生来实现它:
#![allow(unused)] fn main() { #[derive(Hash)] struct Person { id: u32, name: String, } }
Eq
HashMap
必须能够比较键的相等性。在处理哈希碰撞时这一点尤为重要——即两个不同的键哈希到相同的值。
你可能疑惑:这不是 PartialEq
特征的作用吗?几乎!但 PartialEq
对于 HashMap
来说不够,因为它不保证自反性,即 a == a
总是 true
。例如,浮点数 (f32
和 f64
) 实现了 PartialEq
,但它们不满足自反性:f32::NAN == f32::NAN
是 false
。自反性对于 HashMap
正确工作至关重要:没有它,你将无法使用插入时的相同键从映射中检索值。
Eq
特征扩展了 PartialEq
并包含了自反性:
#![allow(unused)] fn main() { pub trait Eq: PartialEq { // 无新增方法 } }
它是一个标记特征:它不添加任何新方法,只是你向编译器表明在 PartialEq
中实现的等价逻辑是自反性的一种方式。
当你派生 PartialEq
时,也可以自动派生 Eq
:
#![allow(unused)] fn main() { #[derive(PartialEq, Eq)] struct Person { id: u32, name: String, } }
Eq
和 Hash
的联系
Eq
和 Hash
之间存在着隐含的约定:如果两个键相等,它们的哈希值也必须相等。这对于 HashMap
正确工作至关重要。如果你破坏了这个约定,使用 HashMap
时将会得到不合逻辑的结果。
排序
通过从 Vec
转换到 HashMap
,我们提升了工单管理系统的性能,并在此过程中简化了代码。然而,并非一切都完美无瑕。当遍历基于 Vec
的存储时,我们可以确保工单按照添加的顺序返回。而对于 HashMap
来说则并非如此:你能够遍历工单,但是顺序是随机的。
我们可以通过将 HashMap
替换为 BTreeMap
来恢复一致的排序。
BTreeMap
BTreeMap
保证了条目按其键的顺序排列。当你需要按照特定顺序遍历条目,或者需要执行范围查询(例如,“给我所有ID在10到20之间的工单”)时,这一点非常有用。
和 HashMap
一样,在 BTreeMap
的定义上你找不到特征界限,但是在其方法上可以找到。我们来看一看 insert
方法:
#![allow(unused)] fn main() { // `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
中:
#![allow(unused)] fn main() { pub trait Ord: Eq + PartialOrd { fn cmp(&self, other: &Self) -> Ordering; } }
cmp
方法返回一个 Ordering
枚举,可以是 Less
、Equal
或 Greater
中的一个。Ord
还要求实现另外两个特征:Eq
和 PartialOrd
。
PartialOrd
PartialOrd
是 Ord
的弱化版本,就像 PartialEq
是 Eq
的弱化版本一样。通过查看其定义可以理解这一点:
#![allow(unused)] fn main() { pub trait PartialOrd: PartialEq { fn partial_cmp(&self, other: &Self) -> Option<Ordering>; } }
PartialOrd::partial_cmp
返回一个 Option
—— 并不保证两个值可以被比较。例如,f32
不实现 Ord
因为 NaN
值不可比较,这也是 f32
不实现 Eq
的原因。
实现 Ord
和 PartialOrd
Ord
和 PartialOrd
都可以为你的类型自动生成:
#![allow(unused)] fn main() { // 需要同时添加 `Eq` 和 `PartialEq`,因为 `Ord` 要求它们。 #[derive(Eq, PartialEq, Ord, PartialOrd)] struct TicketId(u64); }
如果你选择(或需要)手动实现它们,请务必小心:
Ord
和PartialOrd
必须与Eq
和PartialEq
保持一致。Ord
和PartialOrd
必须彼此一致。
引言
Rust 的一大承诺是无畏的并发:让编写安全、并发的程序变得更加容易。到目前为止,我们对此还没有太多了解。迄今为止所做的所有工作都是单线程的。现在是时候做出改变了!
在本章中,我们将使我们的票务系统支持多线程。我们将有机会接触到 Rust 核心并发特性中的大部分内容,包括:
- 使用
std::thread
模块的线程 - 使用通道进行消息传递
- 使用
Arc
、Mutex
和RwLock
管理共享状态 Send
和Sync
特性,它们编码了 Rust 的并发保证
我们还将讨论多线程系统的一些设计模式及其权衡。
线程
在开始编写多线程代码之前,让我们先退一步,谈谈线程是什么,以及为什么我们可能想要使用它们。
什么是线程?
线程是由操作系统管理的执行上下文。每个线程都有自己的栈、指令指针和程序计数器。
单个进程可以管理多个线程。这些线程共享相同的内存空间,这意味着它们可以访问相同的数据。
线程是一个逻辑构造。归根结底,你只能在一个CPU核心(物理执行单元)上一次运行一套指令。由于线程数量可能远远超过CPU核心的数量,操作系统的调度器负责决定在任何给定时间运行哪个线程,通过对它们分配CPU时间来最大化吞吐量和响应速度。
main
当Rust程序启动时,它在一个单一的线程上运行,即主线程。这个线程由操作系统创建,负责运行main
函数。
use std::thread; use std::time::Duration; fn main() { loop { thread::sleep(Duration::from_secs(2)); println!("主线程问候!"); } }
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!("来自线程的问候!"); } }); loop { thread::sleep(Duration::from_secs(2)); println!("主线程问候!"); } }
如果在Rust Playground上执行这个程序,你会发现主线程和生成的线程并发运行。每个线程独立地进行。
进程终止
当主线程完成时,整个进程将退出。生成的线程将继续运行,直到它完成或主线程完成。
use std::thread; use std::time::Duration; fn main() { let handle = thread::spawn(|| { loop { thread::sleep(Duration::from_secs(1)); println!("来自线程的问候!"); } }); thread::sleep(Duration::from_secs(5)); }
在上面的例子中,你期望看到大约五次“来自线程的问候!”的消息打印出来。然后,当sleep
调用返回时,主线程将结束,生成的线程也会因为整个进程退出而被终止。
join
你也可以通过调用spawn
返回的JoinHandle
上的join
方法来等待生成的线程完成。
use std::thread; fn main() { let handle = thread::spawn(|| { println!("来自线程的问候!"); }); handle.join().unwrap(); }
在这个例子中,主线程将在退出之前等待生成的线程完成。这引入了一种线程间的同步形式:你保证会在程序退出前看到“来自线程的问候!”的消息被打印,因为主线程不会退出,直到生成的线程完成。
'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 = thread::spawn(move || left.iter().sum::<i32>());
| ------------------------------------------------
argument requires that `v` is borrowed for `'static`
19 | }
| - `v` dropped here while still borrowed
“参数要求v
被借用为'static
”,这是什么意思呢?
'static
生命周期在Rust中是一个特殊的存在。它意味着这个值在整个程序的运行期间都是有效的。
脱离的线程
通过thread::spawn
启动的线程可以比创建它的线程更持久。例如:
#![allow(unused)] fn main() { use std::thread; fn f() { thread::spawn(|| { thread::spawn(|| { loop { thread::sleep(std::time::Duration::from_secs(1)); println!("Hello from the detached thread!"); } }); }); } }
在这个示例中,第一个生成的线程会进一步生成一个子线程,每秒打印一条消息。然后,第一个线程会完成并退出。当这种情况发生时,其子线程会继续运行,只要整个程序还在运行。在Rust的术语中,我们说子线程已经超过了其父线程的生命周期。
'static
生命周期
既然一个生成的线程能够:
- 比生成它的线程(父线程)存活更久
- 运行到程序结束
那么它就不能借用任何可能在程序结束前被丢弃的值;违反这一约束可能会使我们面临使用已释放内存的问题。这就是std::thread::spawn
的签名要求传递给它的闭包具有'static
生命周期的原因:
#![allow(unused)] fn main() { pub fn spawn<F, T>(f: F) -> JoinHandle<T> where F: FnOnce() -> T + Send + 'static, T: Send + 'static { // [..] } }
'static
不仅仅关于引用
Rust中所有值都有生命周期,不仅仅是引用。
特别是,一个拥有自己数据的类型(如Vec
或String
)满足'static
约束:如果你拥有它,即使最初创建它的函数已经返回,你也可以随意处理它,想处理多久就处理多久。
因此,你可以将'static
理解为一种表达方式:
- 给我一个拥有权值
- 给我一个在整个程序期间都有效的引用
第一种方法就是你在前一个练习中解决问题的方法:通过分配新的向量来持有原始向量的左右部分,然后将它们移动到生成的线程中。
'static
引用
现在讨论第二种情况,即在整个程序期间都有效的引用。
静态数据
最常见的案例是指向静态数据的引用,比如字符串字面量:
#![allow(unused)] fn main() { let s: &'static str = "你好,世界!"; }
由于字符串字面量在编译时已知,Rust会将它们存储在可执行文件的内部,一个称为只读数据段的区域。因此,指向该区域的所有引用都会在程序运行期间保持有效;它们满足'static
要求。
进一步阅读
- 数据段(英文维基百科页面,解释了程序的静态数据存储区域)
数据泄露
围绕将引用传递给生成的线程的主要担忧是“使用后释放”错误:即使用指向已经被释放或取消分配的内存区域的指针来访问数据。 如果你正在使用堆上分配的数据,可以通过告诉Rust你将永远不会回收那部分内存来避免这个问题——你选择故意泄露内存。
这可以通过使用Rust标准库中的Box::leak
方法来实现,例如:
#![allow(unused)] fn main() { // 通过包装在一个`Box`中,在堆上分配一个`u32`。 let x = Box::new(41u32); // 告诉Rust你将永远不会释放那个堆分配, // 使用`Box::leak`。这样你就可以得到一个`'static`引用。 let static_ref: &'static mut u32 = Box::leak(x); }
数据泄露的影响范围是进程级的
泄露数据是危险的:如果你持续泄露内存,最终会耗尽内存并因内存不足而崩溃。
#![allow(unused)] fn main() { // 如果让这段代码运行一段时间, // 它最终会消耗掉所有可用内存。 fn 导致内存溢出() { loop { let v: Vec<usize> = Vec::with_capacity(1024); Box::leak(v); } } }
同时,通过Box::leak
泄露的内存并不是真正被遗忘的。操作系统可以将每个内存区域映射到负责它的进程。当进程退出时,操作系统会回收那些内存。
考虑到这一点,如果满足以下条件,泄露内存是可以接受的:
- 你需要泄露的内存量不是无限的或预先已知的,或者
- 你的进程是短暂的,并且你确信在它退出之前不会耗尽所有可用内存
如果应用场景允许,“让操作系统处理它”是一种完全合理的内存管理策略。
限制生存期的线程
到目前为止我们讨论的所有生命周期问题都有一个共同的根源:生成的线程可能比其父线程寿命更长。我们可以通过使用**限制生存期的线程(scoped threads)**来绕过这个问题。
#![allow(unused)] fn main() { 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
实例。
限制生存期的生成
Scope
提供了一个spawn
方法。与std::thread::spawn
不同,使用Scope
生成的所有线程会在作用域结束时自动加入(即等待线程完成)。
如果我们把前面的例子用std::thread::spawn
重写,看起来会是这样的:
#![allow(unused)] fn main() { 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:?}"); }
从环境借用
然而,这个转换后的例子无法编译:编译器会抱怨说,由于&v
的生命周期不是'static
,所以不能在生成的线程中使用它。
但这在使用std::thread::scope
时不是问题——你可以安全地从环境借用。
在我们的例子中,v
在生成线程之前创建。它只会在scope
返回后被丢弃。同时,所有在scope
内部生成的线程都保证了在v
被丢弃前完成,因此不存在悬空引用的风险。
编译器不会报错!
通道(Channel)
迄今为止,我们创建的所有线程都是短生命周期的。它们获取输入、执行计算、返回结果并关闭。但对于我们的票务管理系统,我们想要采取不同的方式:一种客户端-服务器架构。
我们将拥有一个长时间运行的服务器线程,负责管理我们的状态,即存储的票务信息。随后,我们将拥有多个客户端线程。每个客户端都能向这个有状态的线程发送命令和查询,以便改变其状态(例如,添加新票据)或检索信息(例如,获取票据的状态)。客户端线程将以并发的方式运行。
通信
到目前为止,我们所进行的父子线程间通信还相当有限:
- 生成的线程从父级上下文中借用或消耗数据,
- 当线程连接时,生成的线程向父级返回数据。
这对于客户端-服务器设计来说是不够的。客户端需要能够在服务器线程启动后,能够与其发送和接收数据。我们可以通过使用**通道(Channels)**来解决这个问题。
通道
Rust的标准库在其std::sync::mpsc
模块中提供了**多生产者单消费者(Multi-Producer, Single-Consumer, 简称mpsc)**通道。通道有两种类型:有界和无界。目前我们先关注无界版本,但稍后会讨论它们各自的优缺点。
创建通道的代码如下所示:
#![allow(unused)] fn main() { use std::sync::mpsc::channel; let (sender, receiver) = channel(); }
这样你就得到了一个发送器(sender)和一个接收器(receiver)。你可以在发送器上调用send
方法将数据推入通道,在接收器上调用recv
方法从通道中拉取数据。
多个发送者
Sender
是可以克隆的:我们可以创建多个发送器(比如,为每个客户端线程一个),它们都将数据推送到同一个通道中。
相反,Receiver
是不可克隆的:对于给定的通道,只能存在一个接收器。
这就是mpsc(多生产者单消费者)的含义!
消息类型
Sender
和Receiver
都对一个类型参数T
进行了泛型。这是可以在通道上传输的消息的类型。
它可以是一个u64
、结构体、枚举等。
错误处理
send
和recv
都可能失败。如果接收器已经被丢弃,则send
会返回错误;如果所有发送器都被丢弃且通道为空,则recv
会返回错误。
换句话说,当通道实际上已经关闭时,send
和recv
就会发生错误。
内部可变性(Interior Mutability)
让我们花点时间分析一下Sender
的send
方法的签名:
#![allow(unused)] fn main() { impl<T> Sender<T> { pub fn send(&self, t: T) -> Result<(), SendError<T>> { // [...] } } }
send
接受&self
作为其参数。但它显然造成了状态的变更:它向通道中添加了一条新的消息。更有趣的是,Sender
是可以克隆的:我们可以有多份Sender
实例试图同时从不同线程修改通道的状态。
这正是我们构建这种客户端-服务器架构所利用的关键特性。但为什么这能行得通呢?难道它不违反了Rust关于借用的规则吗?我们是如何通过一个不可变的引用来进行修改的呢?
共享而非不可变的引用
当我们引入借用检查器时,我们提到了Rust中可以拥有的两种类型的引用:
- 不可变引用(
&T
) - 可变引用(
&mut T
)
其实更准确的说法应该是:
- 共享引用(
&T
) - 排他引用(
&mut T
)
不可变/可变是一种适用于绝大多数情况的心智模型,并且它是初学Rust时非常好的入门概念。但正如你刚刚看到的,这并不是全部:&T
实际上并不保证它指向的数据是不可变的。不过别担心,Rust仍然遵守着它的承诺。只是这些术语比最初看起来的要微妙一些。
UnsafeCell
每当一个类型允许你通过共享引用修改数据时,你就是在处理内部可变性。
默认情况下,Rust编译器假设共享引用是不可变的。它基于这一假设优化你的代码。编译器可以重新排序操作,缓存值,并做各种魔法来加速你的代码。
你可以通过将数据包裹在UnsafeCell
中告诉编译器:“不,这个共享引用实际上是可变的”。每当你看到一个允许内部可变性的类型时,你可以肯定UnsafeCell
直接或间接地参与其中。使用UnsafeCell
、原始指针和unsafe
代码,你可以通过共享引用来修改数据。
但要明确的是:UnsafeCell
并不是一根可以让你无视借用检查器的魔法棒!unsafe
代码仍然受到Rust关于借用和别名规则的约束。它是一个(高级)工具,你可以利用它来构建那些安全性不能直接用Rust类型系统表达的安全抽象。每当你使用unsafe
关键字时,你都在告诉编译器:“我知道我在做什么,我不会违反你的不变量,请相信我。”
每次你调用一个unsafe
函数时,都会有关于其安全先决条件的文档说明:在什么情况下执行其unsafe
块是安全的。你可以在Rust标准库的文档中找到关于UnsafeCell
的。
在这个课程中,我们不会直接使用UnsafeCell
,也不会编写unsafe
代码。但了解它的存在、它为何存在以及它与你在Rust中每天使用的类型之间的关系是很重要的。
关键示例
让我们浏览几个利用内部可变性的标准库类型。这些是你在Rust代码中会经常遇到的类型,尤其是当你深入了解你所使用的某些库的内部机制时。
引用计数
Rc
是一个引用计数指针。它包裹一个值并跟踪对该值存在的引用数量。当最后一个引用被丢弃时,该值会被释放。被Rc
包裹的值是不可变的:你只能获得对其的共享引用。
#![allow(unused)] fn main() { use std::rc::Rc; let a: Rc<String> = Rc::new("我的字符串".to_string()); // 对字符串数据只有一个引用。 assert_eq!(Rc::strong_count(&a), 1); // 当我们调用`clone`时,字符串数据并没有被复制! // 相反,`Rc`的引用计数被递增。 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
包裹的值。
这是通过运行时借用检查实现的。RefCell
在运行时跟踪它包含的值的引用数量(和类型)。如果你尝试在已有不可变借用的情况下可变地借用值,程序将panic,确保Rust的借用规则始终得到执行。
#![allow(unused)] fn main() { use std::cell::RefCell; let x = RefCell::new(42); let y = x.borrow(); // 不可变借用 let z = x.borrow_mut(); // 引发恐慌!存在活跃的不可变借用。 }
双向通信
在当前的客户端-服务器实现中,通信是单向的:从客户端到服务器。客户端无法得知服务器是否接收到消息、成功执行还是执行失败。这并不理想。
为了解决这个问题,我们可以引入一个双向通信系统。
响应通道
我们需要一种方式让服务器能将响应发送回客户端。实现这一目标有多种方法,但最简单的方式是在客户端发送给服务器的消息中包含一个Sender
通道。服务器处理完消息后,可以使用这个通道将响应发送回客户端。
这是构建在消息传递原语之上的Rust应用程序中相当常见的模式。
一个专门的 Client
类型
迄今为止,客户端的所有交互都非常底层:你必须手动创建响应通道,构建命令,将其发送到服务器,然后调用响应通道上的recv
来获取响应。这些都是可以抽象掉的模板代码,而这正是我们在本练习中将要做的。
有界与无界通道
至今为止,我们一直在使用无界通道。你可以发送任意数量的消息,通道会根据需要扩容以容纳它们。但在多生产者单消费者场景下,这可能带来问题:如果生产者入队消息的速度超过消费者处理速度,通道将会不断增长,有可能消耗掉所有可用内存。
我们的建议是绝不在生产系统中使用无界通道。你应该总是通过有界通道对可入队消息的数量设置一个上限。
有界通道
有界通道具有固定的容量。你可以通过调用sync_channel
并传入大于零的容量来创建一个有界通道:
#![allow(unused)] fn main() { use std::sync::mpsc::sync_channel; let (sender, receiver) = sync_channel(10); }
receiver
的类型与之前相同,为Receiver<T>
。而sender
则是一个SyncSender<T>
的实例。
发送消息
通过SyncSender
发送消息有两种不同的方法:
send
:如果通道中有空间,它会将消息入队并返回Ok(())
。如果通道已满,它会阻塞等待直到有空间可用。try_send
:如果通道中有空间,它会将消息入队并返回Ok(())
。如果通道已满,它会返回Err(TrySendError::Full(value))
,其中value
是未能发送的消息。
根据你的应用场景,你可能选择使用其中之一。
反压
使用有界通道的主要优势在于它们提供了一种反压机制。它们迫使生产者在消费者无法跟上时减速。这种反压可以进一步传播至整个系统,可能影响整体架构并防止终端用户以过多请求压垮系统。
更新操作
到目前为止,我们仅实现了插入和检索操作。接下来,让我们看看如何扩展系统以提供更新操作功能。
旧版更新
在非线程化的系统版本中,更新操作相对直接:TicketStore
暴露了一个get_mut
方法,允许调用者获得对票据的可变引用,然后对其进行修改。
多线程更新
在当前的多线程版本中,相同的策略将不再适用,因为可变引用需要通过通道发送。借用检查器会阻止我们这样做,因为&mut Ticket
不符合SyncSender::send
所需的'static
生命周期要求。
有几种方法可以绕过这个限制。我们将在接下来的练习中探讨其中的一些方法。
打补丁
我们无法通过通道发送&mut Ticket
,因此无法在客户端进行修改。那我们能在服务器端进行修改吗?
如果告诉服务器需要更改的内容,就可以。换句话说,如果向服务器发送一个补丁:
#![allow(unused)] fn main() { struct TicketPatch { id: TicketId, title: Option<TicketTitle>, description: Option<TicketDescription>, status: Option<TicketStatus>, } }
id
字段是必需的,因为它用于标识需要更新的票据。所有其他字段都是可选的:
- 如果一个字段是
None
,表示该字段不应被更改。 - 如果一个字段是
Some(value)
,表示该字段应更改为value
。
锁、Send
与Arc
你刚实现的打补丁策略有一个重大缺陷:它存在竞争条件。如果两个客户端几乎同时为同一张票证发送补丁,服务器将以任意顺序应用这些补丁。无论谁最后排队等候其补丁,都会覆盖另一个客户端所做的更改。
版本号
我们可以通过使用版本号来尝试解决这个问题。每张票证创建时都会分配一个版本号,初始值设为0
。每当客户端发送补丁时,他们必须包含票证的当前版本号以及期望的更改。只有当版本号与服务器存储的一致时,服务器才会应用该补丁。
在上述场景中,服务器会拒绝第二个补丁,因为版本号会被第一个补丁增量,从而与第二个客户端发送的不匹配。这种方法在分布式系统中相当常见(例如,当客户端和服务器不共享内存时),并被称为乐观并发控制。其思想是大多数时候冲突不会发生,因此我们可以针对常见情况进行优化。如果你愿意,你现在对Rust的了解足以让你作为额外练习自行实现这一策略。
加锁
我们也可以通过引入锁来修复竞态条件。每当客户端想要更新票证时,他们必须首先获取对该票证的锁。在锁激活期间,其他客户端不能修改票证。
Rust标准库提供了两种不同的锁原语:Mutex<T>
和RwLock<T>
。我们先从Mutex<T>
开始。它代表“互斥”,是最简单的锁类型:无论读取还是写入,都只允许一个线程访问数据。
Mutex<T>
包裹了它所保护的数据,并且是泛型于数据类型。你不能直接访问数据:类型系统强制你先使用Mutex::lock
或Mutex::try_lock
获取锁。前者直到获取锁为止会阻塞,后者如果无法获取锁则会立即返回错误。两种方法都会返回一个守卫对象,该对象解引用后可访问数据,允许你修改它。当守卫被释放时,锁也会被释放。
#![allow(unused)] fn main() { use std::sync::Mutex; // 由互斥锁保护的整数 let lock = Mutex::new(0); // 获取互斥锁 let mut guard = lock.lock().unwrap(); // 通过守卫间接修改数据, // 利用其`Deref`实现 *guard += 1; // 当`guard`超出作用域时释放锁 // 可以通过显式丢弃守卫来完成 // 或者守卫自然离开作用域时隐式发生 drop(guard); }
锁的粒度
我们的Mutex
应该包装什么?最简单的选择是将整个TicketStore
用单个Mutex
包裹。这虽然可行,但会严重限制系统的性能:你无法并行读取票证,因为每次读取都必须等待锁释放。这被称为粗粒度锁定。
更好的做法是使用细粒度锁定,即每张票证都有自己的锁。这样,只要客户端不尝试访问同一张票证,它们就可以继续并行处理票证。
#![allow(unused)] fn main() { // 新结构,每张票证都有自己的锁 struct TicketStore { tickets: BTreeMap<TicketId, Mutex<Ticket>>, } }
这种方法效率更高,但也有缺点:TicketStore
必须开始意识到系统的多线程性质;到目前为止,TicketStore
一直忽略了线程的存在。尽管如此,我们还是采用这种方法。
谁持有锁?
为了使整个方案工作,锁必须传递给想要修改票证的客户端。客户端随后可以直接修改票证(就像他们拥有&mut Ticket
一样),并在完成后释放锁。
这有点棘手。我们不能通过通道发送Mutex<Ticket>
,因为Mutex
不可克隆,而且我们不能将其移出TicketStore
。那我们能发送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(); }); // 尝试通过通道发送守卫到另一个线程 sender.send(guard); }
编译器对此代码不满意:
错误[E0277]: `MutexGuard<'_, i32>`不能安全地在线程间发送
--> src/main.rs:10:7
|
10 | spawn(move || {
| _-----_^
| | |
| | 需要此边界的调用
11 | | receiver.recv().unwrap();;
12 | | });
| |_^ `MutexGuard<'_, i32>`不能安全地在线程间发送
|
= 帮助: 类型`MutexGuard<'_, i32>`没有实现`Send`特质,这是`{closure@src/main.rs:10:7: 10:14}: Send`所需要的
= 注意: 这是因为`std::sync::mpsc::Receiver<MutexGuard<'_, i32>>`需要实现`Send`
注意: 因为它在这个闭包内被使用
MutexGuard<'_, i32>
不是Send
:这意味着什么?
Send
Send
是一个标记特质,表明一种类型可以安全地从一个线程转移到另一个线程。Send
也是一个自动特质,就像Sized
一样;编译器会根据类型的定义自动实现(或不实现)它。你也可以手动为你的类型实现Send
,但这需要unsafe
,因为你必须保证类型确实可以在线程间安全发送,而这是编译器无法自动验证的原因。
通道需求
Sender<T>
、SyncSender<T>
和Receiver<T>
只有当T
是Send
时才是Send
。这是因为它们用于在线程间发送值,如果值本身不是Send
,那么在线程间发送它是不安全的。
MutexGuard
MutexGuard
不是Send
,因为在某些平台上,Mutex
用来实现锁的底层操作系统原语要求必须由获取它的同一线程释放锁。如果我们把MutexGuard
发送到另一个线程,锁就会被不同的线程释放,导致未定义行为。
我们的挑战
总结一下:
- 我们不能通过通道发送
MutexGuard
。所以我们不能在服务器端加锁然后在客户端修改票证。 - 我们可以发送
Mutex
通过通道,只要它保护的数据是Send
,对于Ticket
来说就是这种情况。 同时,我们不能将Mutex
移出TicketStore
,也不能克隆它。
我们如何解决这个难题?我们需要从不同的角度审视问题。
锁定Mutex
时,我们不需要拥有值。共享引用就足够了,因为Mutex
使用内部可变性:
#![allow(unused)] fn main() { impl<T> Mutex<T> { // `&self`,而不是`self`! pub fn lock(&self) -> LockResult<MutexGuard<'_, T>> { // 实现细节 } } }
因此,发送给客户端一个共享引用就足够了。然而,我们不能直接这么做,因为引用必须是'static
,而实际情况并非如此。在某种程度上,我们需要一个“拥有式的共享引用”。事实证明,Rust有一个符合要求的类型:Arc
。
Arc
来救援
Arc
代表原子引用计数。Arc
包裹着一个值并跟踪对这个值存在的引用数量。当最后一个引用被释放时,值就被回收。被Arc
包裹的值是不可变的:你只能获取到它的共享引用。
#![allow(unused)] fn main() { 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
使用一个原子整数,可以在线程间安全共享和修改。
Arc<Mutex<T>>
如果我们将Arc
与Mutex
配对,最终得到一个类型,它可以:
- 在线程间发送,因为:
- 如果
T
是Send
,则Arc
是Send
; - 如果
T
是Send
,则Mutex
也是Send
。 T
是Ticket
,它是Send
。
- 如果
- 可以克隆,因为无论
T
是什么,Arc
都是Clone
。克隆Arc
会增加引用计数,数据不会被复制。 - 可以用来修改它包装的数据,因为
Arc
让你可以获得Mutex<T>
的共享引用,进而可以获取锁。
我们现在有了实现票证存储锁定策略所需的所有部件。
深入阅读
- 在本课程中,我们不会深入讲解原子操作的细节,但你可以在Rust标准库文档以及"Rust原子操作和锁"书籍中找到更多信息。
Reader 与 Writer
我们新设计的TicketStore
能够运行,但其读取性能并不理想:同一时间只有一个客户端能读取特定的票证,因为Mutex<T>
并未区分读者和写者。
我们可以通过使用另一种锁原语RwLock<T>
来解决这个问题。RwLock<T>
代表读写锁,它允许多个读者同时访问数据,但同一时间只允许一个写者进行操作。
RwLock<T>
提供了两种获取锁的方法:read
和write
。read
返回一个允许你读取数据的守卫,而write
则返回一个允许你修改数据的守卫。
#![allow(unused)] fn main() { use std::sync::RwLock; // 由读写锁保护的整数 let lock = RwLock::new(0); // 获取读锁 let guard1 = lock.read().unwrap(); // 在第一个读锁仍激活的情况下 // 获取**第二个**读锁 let guard2 = lock.read().unwrap(); }
权衡取舍
表面上看,RwLock<T>
似乎是不二之选:它提供了Mutex<T>
功能的超集。如果可以使用RwLock<T>
,为什么还要用Mutex<T>
呢?
有两个关键原因:
- 锁定
RwLock<T>
比锁定Mutex<T>
成本更高。这是因为RwLock<T>
需要追踪活跃读者和写者的数量,而Mutex<T>
只需记录锁是否被持有。如果读取操作远多于写入操作,这种性能开销不是问题,但如果工作负载偏向写入,Mutex<T>
可能是更好的选择。 RwLock<T>
可能导致写者饥饿。如果有持续的读者等待获取锁,写者可能永远没有机会执行。RwLock<T>
不对读者和写者获得锁的顺序提供任何保证,这取决于底层操作系统的实现策略,对写者可能不公平。
在我们的案例中,预期的工作负载偏向读取(因为大多数客户端将读取票证而非修改),因此RwLock<T>
是一个合适的选择。
设计回顾
让我们花点时间回顾一下走过的历程。
无锁与通道序列化
我们对多线程票证存储的第一个实现采用了以下方式:
- 单一线程(服务器),持有共享状态,
- 多个客户端通过他们自己的线程通过通道向其发送请求。
无需对状态加锁,因为只有服务器在修改状态。这是因为“收件箱”通道自然地序列化了进来的请求:服务器会逐一处理它们。我们已经讨论过这种方法在打补丁行为上的局限性,但我们尚未讨论原始设计的性能影响:服务器一次只能处理一个请求,包括读取。
细粒度加锁
随后,我们转向了一个更复杂的设计,其中每个票证都有自己的锁保护,客户端可以独立决定他们是想读取还是原子性地修改票证,获取适当的锁。
该设计允许更好的并行性(即,多个客户端可以同时读取票证),但本质上仍然是串行的:服务器逐一处理命令。特别是,它逐一给客户端分配锁。
我们能否完全移除通道,让客户端直接访问TicketStore
,仅依靠锁来同步访问呢?
移除通道
我们需要解决两个问题:
- 在线程间共享
TicketStore
- 同步访问存储
在线程间共享TicketStore
我们希望所有线程都指向同一个状态,否则实际上并没有形成多线程系统——我们只是并行运行多个单线程系统而已。我们已经在尝试跨线程共享锁时遇到过这个问题:我们可以使用Arc
来解决。
同步访问存储
由于通道提供的序列化,还有一种交互仍然是无锁的:向存储中插入(或移除)票证。
如果我们移除通道,就需要引入(另一个)锁来同步对TicketStore
本身的访问。
如果使用Mutex
,那么再为每张票证添加额外的RwLock
就没有意义了:Mutex
已经序列化了对整个存储的访问,所以我们无论如何也无法并行读取票证。相反,如果使用RwLock
,我们就能并行读取票证。我们只需在插入或移除票证时暂停所有读取即可。
让我们沿着这条路径看看它会带我们走向何方。
Sync
在结束本章之前,让我们谈谈Rust标准库中的另一个关键特性:Sync
。
Sync
是一个自动特质,就像Send
一样。它被所有类型自动实现,这些类型能够在线程间安全地共享。
换句话说:T: Sync
意味着&T
是Send
。
Sync
并不意味着Send
需要注意的是,Sync
并不意味着Send
。例如:MutexGuard
不是Send
,但是它是Sync
。
它不是Send
,因为锁必须在获取它的同一个线程上释放,因此我们不希望MutexGuard
在不同的线程上被丢弃。但它又是Sync
,因为将&MutexGuard
传递给另一个线程并不会影响锁在哪里释放。
Send
并不意味着Sync
反之亦然:Send
并不意味着Sync
。例如:RefCell<T>
是Send
(如果T
是Send
的话),但它不是Sync
。
RefCell<T>
执行运行时借用检查,但它使用的用于跟踪借用的计数器不是线程安全的。因此,多个线程持有&RefCell
会导致数据竞争,可能会有多个线程获得对同一数据的可变引用。因此RefCell
不是Sync
。而Send
是可以的,因为我们向另一个线程发送RefCell
时,并没有留下对其包含数据的任何引用,因此没有并发修改访问的风险。
异步 Rust
线程并不是Rust中编写并发程序的唯一方式。在本章中,我们将探索另一种方法:异步编程。
具体而言,你将获得以下内容的介绍:
async
/.await
关键字,轻松编写异步代码Future
特质,表示可能尚未完成的计算tokio
,运行异步代码最受欢迎的运行时- Rust异步模型的合作本质,以及这对你的代码有何影响
异步函数
迄今为止,你编写的全部函数和方法都是即时执行的。除非你调用它们,否则什么都不会发生。但一旦调用了,它们就会运行至完成:它们会做所有的工作,然后返回输出结果。
有时这并非理想情况。例如,如果你正在编写一个HTTP服务器,可能会有很多等待的情况:等待请求体到达、等待数据库响应、等待下游服务回复等。
如果在等待时可以做些其他事情会怎样?如果你可以选择中途放弃计算会怎样?如果你可以选择优先处理另一个任务而非当前任务会怎样?
这就是异步函数发挥作用的地方。
async fn
你可以使用async
关键字来定义一个异步函数:
#![allow(unused)] fn main() { use tokio::net::TcpListener; // 这个函数是异步的 async fn bind_random() -> TcpListener { // [...] } }
如果你像调用普通函数那样调用bind_random
会发生什么?
#![allow(unused)] fn main() { fn run() { // 调用 `bind_random` let listener = bind_random(); // 现在怎么办? } }
什么也不会发生!当你调用bind_random
时,Rust并不会开始执行它,甚至不会作为一个后台任务来启动(这可能基于你在其他语言中的经验)。Rust中的异步函数是惰性的:它们直到你明确要求它们执行才开始做任何工作。使用Rust的术语来说,我们说bind_random
返回了一个未来,这是一种代表可能稍后完成的计算的类型。它们之所以称为“未来”,是因为它们实现了Future
特质,我们将在本章后面详细探讨这个接口。
.await
让异步函数执行一些工作的最常见方法是使用.await
关键字:
#![allow(unused)] fn main() { use tokio::net::TcpListener; async fn bind_random() -> TcpListener { // [...] } async fn run() { // 调用 `bind_random` 并等待它完成 let listener = bind_random().await; // 现在 `listener` 已经准备好了 } }
.await
直到异步函数运行完成——例如,上述示例中直到TcpListener
被创建——才会将控制权交还给调用者。
运行时
如果你感到困惑,这是很正常的!我们刚刚说过异步函数的优点之一是它们不会立即做所有工作。然后我们介绍了.await
,它要等到异步函数完成才会返回。我们是不是又重新引入了我们试图解决的问题?意义何在?
不尽然!在你调用.await
时,幕后发生了许多事情!你将控制权交给了一异步运行时,也称为异步执行器。执行器是魔法发生的地点:它们负责管理你所有的正在进行的任务。具体来说,它们平衡两个不同的目标:
- 进展:确保任务在可能时取得进展。
- 效率:如果一个任务在等待某事,它们尽量确保另一个任务可以在此期间运行,充分利用可用资源。
无默认运行时
Rust在异步编程方面采取的方法相当独特:没有默认运行时。标准库不附带运行时。你需要自己引入!
在大多数情况下,你会从生态系统中选择一个可用的选项。有些运行时设计得广泛适用,对大多数应用程序都是坚实的选择。tokio
和async-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]
宏来简化这一过程:
#![allow(unused)] fn main() { #[tokio::test] async fn my_test() { // 你的异步测试代码放这里 } }
创建任务
对于上一个练习,你的解决方案应该看起来像这样:
#![allow(unused)] fn main() { 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
继续在后台运行这个被派生的任务,与派生它的任务并发进行。
以下是使用它并发处理多个连接的方式:
#![allow(unused)] fn main() { use tokio::net::TcpListener; pub async fn echo(listener: TcpListener) -> Result<(), anyhow::Error> { loop { let (mut socket, _) = listener.accept().await?; // 在后台启动一个任务以处理连接 // 从而使主任务能够立即开始接受新连接 tokio::spawn(async move { let (mut reader, mut writer) = socket.split(); tokio::io::copy(&mut reader, &mut writer).await?; }); } } }
异步块
在这个例子中,我们向tokio::spawn
传递了一个异步块:async move { /* */ }
异步块是一种快速标记代码区域为异步的方式,而不需要定义单独的异步函数。
JoinHandle
tokio::spawn
返回一个JoinHandle
。你可以使用JoinHandle
来.await
后台任务,就像我们对线程使用join
一样。
#![allow(unused)] fn main() { pub async fn run() { // 在后台启动一个任务以向远程服务器发送遥测数据 let handle = tokio::spawn(emit_telemetry()); // 同时做一些其他有用的工作 do_work().await; // 但在遥测数据成功发送之前不要返回给调用者 handle.await; } pub async fn emit_telemetry() { // [...] } pub async fn do_work() { // [...] } }
惊群边界
如果使用tokio::spawn
启动的任务发生了恐慌(panic),恐慌会被执行器捕获。如果你不.await
对应的JoinHandle
,恐慌就不会传播给启动者。即使你确实.await
了JoinHandle
,恐慌也不会自动传播。等待JoinHandle
会返回一个Result
,错误类型为JoinError
。然后你可以通过调用JoinError::is_panic
来检查任务是否发生了恐慌,并选择如何处理这个恐慌——比如记录它、忽略它或传播它。
#![allow(unused)] fn main() { 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::resume_unwind(reason); } } } pub async fn work() { // [...] } }
std::thread::spawn
与tokio::spawn
对比
你可以把tokio::spawn
想象成std::thread::spawn
的异步兄弟。
注意一个关键区别:使用std::thread::spawn
时,你将控制权委托给了操作系统调度器。你无法控制线程是如何调度的。
而在使用tokio::spawn
时,你将控制权委托给了完全在用户空间运行的异步执行器。底层的操作系统调度器并不参与决定下一个运行哪个任务。现在,通过我们选择使用的执行器,我们负责这个决策。
运行时
到目前为止,我们一直在将异步运行时作为一个抽象概念进行讨论。让我们深入了解一下它们的实现方式——很快你就会发现,这对我们的代码有影响。
类型
tokio
提供了两种不同类型的运行时风味(flavors)**。
你可以通过tokio::runtime::Builder
配置你的运行时:
Builder::new_multi_thread
为你提供了一个多线程的tokio
运行时- 而
Builder::new_current_thread
则依赖于当前线程进行执行。
默认情况下,#[tokio::main]
返回一个多线程运行时,而#[tokio::test]
则直接使用当前线程的运行时。
当前线程运行时
顾名思义,当前线程运行时完全依赖于启动它的操作系统线程来调度和执行任务。使用当前线程运行时,你拥有并发性但没有并行性:异步任务会交错执行,但在任何给定的时间最多只有一个任务在运行。
多线程运行时
而使用多线程运行时,可以在任意给定时间最多有N
个任务并行运行,这里的N
是运行时使用的线程数量。默认情况下,N
等于可用CPU核心的数量。
不仅如此,tokio
还执行工作窃取。如果一个线程空闲,它不会闲置:它会尝试找到一个新的、准备好执行的任务,这可以从全局队列中获取,或者从另一个线程的本地队列中窃取。工作窃取在性能上有显著的好处,特别是在尾部延迟上,尤其是当你的应用程序处理的工作负载在各线程间并非完美平衡时。
影响
tokio::spawn
是不受运行时风味限制的:无论你是在多线程还是当前线程运行时上运行,它都能工作。缺点是它的签名假定了最坏的情况(即多线程)并据此进行了约束:
#![allow(unused)] fn main() { 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
约束相同的理由:被派生的任务可能会超出它被派生的上下文的生命周期,因此它不应该依赖任何可能在其派生上下文被销毁后被析构的局部数据。
#![allow(unused)] fn main() { fn spawner() { let v = vec![1, 2, 3]; // 这样不行,因为`&v`生命周期不够长。 tokio::spawn(async { for x in &v { println!("{x}") } }) } }
另一方面,Send
是tokio
的工作窃取策略的直接结果:在一个线程A
上派生的任务,如果线程B
空闲,可能会被移动到B
上执行,因此需要Send
约束,因为我们在跨越线程边界。
#![allow(unused)] fn main() { fn spawner(input: Rc<u64>) { // 这也不行,因为`Rc`不是`Send`。 tokio::spawn(async move { println!("{}", input); }) } }
Future
特性
局部Rc
问题
回到tokio::spawn
的签名:
#![allow(unused)] fn main() { pub fn spawn<F>(future: F) -> JoinHandle<F::Output> where F: Future + Send + 'static, F::Output: Send + 'static, { /* */ } }
对于F
来说,Send
实际上意味着什么呢?正如我们在前一节中看到的,这意味着它从派生环境中捕获的任何值都必须是Send
。但不仅如此。
任何_跨过.await
点_持有的值都必须是Send
。让我们看一个例子:
#![allow(unused)] fn main() { use std::rc::Rc; use tokio::task::yield_now; fn spawner() { tokio::spawn(example()); } async fn example() { // 一个不是`Send`的值,在异步函数内部创建 let non_send = Rc::new(1); // 一个什么都不做的`.await`点 yield_now().await; // `.await`之后仍然需要局部非`Send`值 println!("{}", non_send); } }
编译器会拒绝这段代码:
错误:future不能安全地在线程间发送
|
5 | tokio::spawn(example());
| ^^^^^^^^^ future由`example`返回的不是`Send`
|
注意:future不是`Send`,因为此值在线程等待中被使用
|
11 | let non_send = Rc::new(1);
| -------- 类型为`Rc<i32>`,不是`Send`
12 | // 一个`.await`点
13 | yield_now().await;
| ^^^^^ 等待发生在这里,`non_send`可能稍后使用
注意:`tokio::spawn`中所需的一个约束
|
164 | pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
| ----- 此函数所需的一个约束
165 | where
166 | F: Future + Send + 'static,
| ^^^^ 此约束在`spawn`中所需
为了理解为什么会这样,我们需要深化对Rust异步模型的理解。
Future
特性
我们之前说过,async
函数返回future,实现Future
特性的类型。你可以将future视为一个状态机。它处于以下两种状态之一:
- pending:计算尚未完成。
- ready:计算已完成,这里是输出结果。
这一点在特性定义中编码如下:
#![allow(unused)] fn main() { trait Future { type Output; // 目前忽略`Pin`和`Context` fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>; } }
poll
poll
方法是Future
特性的核心。future本身不做任何事情。它需要轮询才能取得进展。当你调用poll
时,你是在要求future做一些工作。poll
尝试取得进展,然后返回以下之一:
Poll::Pending
:future还没有准备好。你需要稍后再调用poll
。Poll::Ready(value)
:future已经完成。value
是计算的结果,类型为Self::Output
。
一旦Future::poll
返回Poll::Ready
,就不应再对其进行轮询:future已完成,没有更多事情要做。
运行时的角色
你很少(如果有的话)会直接调用poll
。这是你的异步运行时的工作:它拥有poll
签名中所需的全部信息(Context
),以确保你的future能在可能时取得进展。
async fn
和future
我们一直使用的是高层接口,即异步函数。现在我们已经查看了低层原始特性,即Future
特性。
它们是如何关联的呢?
每次你将一个函数标记为异步时,该函数都会返回一个future。编译器会将你异步函数的主体转换为一个状态机:每个.await
点对应一个状态。
回到我们的Rc
例子:
#![allow(unused)] fn main() { use std::rc::Rc; use tokio::task::yield_now; async fn example() { let non_send = Rc::new(1); yield_now().await; println!("{}", non_send); } }
编译器会将其转换为类似这样的枚举:
#![allow(unused)] fn main() { pub enum ExampleFuture { NotStarted, YieldNow(Rc<i32>), Terminated, } }
当调用example
时,它返回ExampleFuture::NotStarted
。future从未被轮询过,所以什么也没发生。
当运行时首次轮询它时,ExampleFuture
会前进到下一个.await
点:它会在状态机的ExampleFuture::YieldNow(Rc<i32>)
阶段停止,并返回Poll::Pending
。
当再次轮询时,它将执行剩余的代码(println!
)并返回Poll::Ready(())
。
当你查看它的状态机表示形式ExampleFuture
时,现在就很清楚为什么example
不是Send
了:它持有一个Rc
,因此不能是Send
。
放弃点
正如你刚在example
中看到的,每一个.await
点都会在future的生命周期中创建一个新的中间状态。
这就是为什么.await
点也被称为yield points:你的future_将控制权_交回轮询它的运行时,允许运行时暂停它,并(如果必要)调度另一个任务进行执行,从而在多个方面同时取得进展。
我们将在后面的章节中回到放弃的重要性。
不要阻塞运行时
让我们回到放弃点的概念上来。与线程不同,Rust的任务不能被抢先。
tokio
本身不能决定暂停一个任务并代之以运行另一个任务。控制权仅当任务放弃时才会回到执行器——也就是说,当Future::poll
返回Poll::Pending
,或者在async fn
的情况下,当你.await
一个future时。
这使运行时面临风险:如果一个任务从不放弃,运行时就永远不会能够运行另一个任务。这被称为阻塞运行时。
什么是阻塞?
多久算太久?一个任务在不放弃之前可以花费多少时间才成为问题?
这取决于运行时、应用程序、正在执行的任务数量以及许多其他因素。但作为一个经验法则,尽量在放弃点之间花费少于100微秒。
后果
阻塞运行时可能导致:
- 死锁:如果永不放弃的任务正在等待另一个任务完成,而那个任务又在等待第一个任务放弃,就会形成死锁。除非运行时能够在不同的线程上调度其他任务,否则无法取得进展。
- 饥饿:其他任务可能无法运行,或者可能在长时间延迟后才运行,这可能导致性能不佳(例如,高尾部延迟)。
阻塞并不总是显而易见
有些操作通常应该在异步代码中避免,比如:
- 同步I/O。你无法预测它将花费多长时间,而且很可能会超过100微秒。
- 耗时的CPU密集型计算。
然而,后一类情况并不总是显而易见。例如,对含有少量元素的向量进行排序没有问题;但如果向量包含数十亿条目,这一评估就会改变。
如何避免阻塞
那么,假设你必须执行一个可能被视为阻塞或有风险的操作,你如何避免阻塞运行时呢?你需要将工作移到不同的线程上。你不希望使用所谓的运行时线程,即tokio
用来运行任务的那些线程。
tokio
为此目的提供了一个专用的线程池,称为阻塞池。你可以使用tokio::task::spawn_blocking
函数在阻塞池上启动同步操作。spawn_blocking
返回一个future,当操作完成后解析为操作的结果。
#![allow(unused)] fn main() { use tokio::task; fn expensive_computation() -> u64 { // [...] } async fn run() { let handle = task::spawn_blocking(expensive_computation); // 同时做其他事情 let result = handle.await.unwrap(); } }
阻塞池是长期存在的。相较于直接通过std::thread::spawn
创建新线程,spawn_blocking
应该更快,因为线程初始化的成本在多次调用中被分摊了。
进一步阅读
- 可以查阅Alice Ryhl的博客文章,了解更多关于这个主题的内容。
异步感知原语
如果你浏览tokio
的文档,你会发现它提供了很多类型,这些类型“镜像”了标准库中的那些,但加入了异步的特性:锁、通道、计时器等等。
在异步环境下工作时,你应该优先选择这些异步替代品,而不是它们的同步对应物。
为了理解原因,让我们回顾一下上一章中探讨过的互斥锁Mutex
。
案例研究:Mutex
来看一个简单的例子:
#![allow(unused)] fn main() { 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`在此处被释放 } /// 使用`v`作为HTTP请求体 async fn http_call(v: &[u64]){ // [...] } }
std::sync::MutexGuard
与放弃点
这段代码能编译,但存在隐患。
我们在异步上下文中尝试获取std
中的Mutex
锁,然后在.await
(在http_call
上)的放弃点上保持得到的MutexGuard
。
假设有两个任务在单线程运行时上并发执行run
,我们观察到以下调度事件序列:
Task A Task B
|
Acquire lock
Yields to runtime
|
+--------------+
|
Tries to acquire lock
我们遇到了死锁。任务B永远无法获得锁,因为锁目前被任务A持有,而任务A在释放锁之前已经放弃了运行时的控制权,且运行时无法抢占任务B,因此任务A不会再被调度。
tokio::sync::Mutex
通过切换到tokio::sync::Mutex
可以解决这个问题:
#![allow(unused)] fn main() { 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`在此处被释放 } }
现在获取锁是一个异步操作,如果无法继续推进就会让出运行时。回到之前的场景,情况会变成这样:
Task A Task B
|
Acquires the lock
Starts `http_call`
Yields to runtime
|
+--------------+
|
Tries to acquire the lock
Cannot acquire the lock
Yields to runtime
|
+--------------+
|
`http_call` completes
Releases the lock
Yield to runtime
|
+--------------+
|
Acquires the lock
[...]
一切顺利!
多线程并不能拯救你
虽然我们在前面的例子中使用了单线程运行时作为执行环境,但在使用多线程运行时时,同样的风险依然存在。
唯一的区别在于导致死锁所需的并发任务数量:在单线程运行时中,2个就足够;而在多线程运行时,我们则需要N+1
个任务,其中N
是运行时线程的数量。
缺点
拥有异步感知的Mutex
伴随着性能上的损失。
如果你确信锁的竞争并不激烈,并且小心地不在放弃点上持有它,你仍然可以在异步上下文中使用std::sync::Mutex
。
但是,要权衡性能收益与你将承担的活性风险。
其他原语
我们以Mutex
为例,但这同样适用于RwLock
、信号量等。在异步环境中工作时,应优先选择异步感知版本,以最小化潜在问题的风险。
取消
当一个挂起的future被丢弃时会发生什么?运行时将不再对其轮询,因此它不会进一步推进。换句话说,其执行已经被取消了。
在实际应用中,这种情况经常发生在处理超时场景中。例如:
#![allow(unused)] fn main() { use tokio::time::timeout; use tokio::sync::oneshot; use std::time::Duration; async fn http_call() { // [...] } async fn run() { // 用设置为10毫秒后超时的`Timeout`包裹future。 let duration = Duration::from_millis(10); if let Err(_) = timeout(duration, http_call()).await { println!("10毫秒内未收到值"); } } }
当超时时,由http_call
返回的future会被取消。让我们想象这是http_call
的主体:
#![allow(unused)] fn main() { use std::net::TcpStream; async fn http_call() { let (stream, _) = TcpStream::connect(/* */).await.unwrap(); let request: Vec<u8> = /* */; stream.write_all(&request).await.unwrap(); } }
每个await
点都变成了一个取消点。由于http_call
不能被运行时抢占,因此只能在通过.await
将控制权交回执行器后被丢弃。这递归适用,例如,stream.write_all(&request)
在其实现中可能有多个yield
点。完全有可能看到http_call
在被取消前推送了_部分_请求,从而断开连接,永远无法完成消息体的传输。
清理
Rust的取消机制非常强大,它允许调用者无需任何来自任务本身的协作就能取消正在进行的任务。同时,这也相当危险。可能需要进行优雅的取消,以确保在中止操作之前执行某些清理任务。
例如,考虑这个虚构的SQL事务API:
#![allow(unused)] fn main() { 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?; } }
在取消时,理想情况下应明确地中止挂起的事务,而非使其悬而未决。遗憾的是,Rust并未提供一种针对这种异步清理操作的万无一失的机制。
最常见的策略是依赖Drop
特质来安排所需的清理工作。这可以通过:
- 在运行时上派生一个新的任务
- 在通道上排队一条消息
- 派生一个后台线程
最佳选择视情况而定。
取消已派生的任务
使用tokio::spawn
派生任务后,你将无法再丢弃它;它属于运行时。尽管如此,如果有需要,你可以使用其JoinHandle
来取消它:
#![allow(unused)] fn main() { async fn run() { let handle = tokio::spawn(/* 某个异步任务 */); // 取消已派生的任务 handle.abort(); } }
进一步阅读
- 使用
tokio
的select!
宏来“竞速”两个不同的future时要极度小心。除非你能确保取消安全,否则在循环中重试同一任务是危险的。更多细节请查看select!
的文档。 如果你需要交织两个异步数据流(如socket和通道),推荐使用StreamExt::merge
代替。 - 与“突然”的取消相比,依赖于
CancellationToken
可能是更可取的。
结语
Rust的异步模型功能强大,但也引入了额外的复杂性。花时间了解你的工具:深入探索tokio
的文档,熟悉其原语,充分利用它。
同时,请记住,语言层面和std
级别正在进行工作,以简化和完善Rust的异步故事。由于缺少某些部分,你可能在日常工作中遇到一些棘手的问题。
为了获得相对无痛的异步体验,这里有一些建议:
- 选择一个运行时并坚持使用。
某些原语(如定时器、I/O)在运行时之间不可移植。尝试混合运行时可能会给你带来麻烦。试图编写运行时无关的代码可能会显著增加代码库的复杂度。如果可以,尽量避免。 - 目前还没有稳定的
Stream
/AsyncIterator
接口。
从概念上讲,AsyncIterator
是一个异步产出新项的迭代器。虽然设计工作正在进行中,但尚无共识。如果你使用tokio
,请参考tokio_stream
作为你的首选接口。 - 小心缓冲。
它往往是微妙错误的根源。想了解更多详情,可以查阅"Barbara对抗缓冲流"。 - 异步任务没有类似范围线程的等价物。
详情请参阅"范围任务三难困境"。
不要让这些注意事项吓到你:异步Rust正被大规模有效地应用于诸如AWS、Meta等基础服务中,以驱动核心业务。如果你计划构建Rust网络应用程序,掌握它是必不可少的。
后记
我们的Rust之旅在这里告一段落。虽然内容已经相当广泛,但这远非穷尽:Rust是一门表面积广阔的语言,拥有一个更加庞大的生态系统!然而,请不要因此感到畏惧:无需掌握一切。在从事项目(后端、嵌入式、命令行界面、图形用户界面等)时,你会自然而然地学到所需的一切有效知识。
归根结底,没有任何捷径:如果你想擅长某事,你需要反复实践。在这门课程中,你已经编写了不少Rust代码,足以让这门语言及其语法流畅地在你的指尖流淌。要想让Rust真正成为你的得心应手之选,还需更多代码的磨练,但只要不断练习,那一刻无疑终将来临。
进一步学习
让我们以一些额外资源的指引作为结束,它们或许能帮助你在继续Rust旅程时找到方向。
练习
你可以在rustlings
项目和exercism.io的Rust赛道上找到更多练习Rust的题目。
入门材料
如果你想从不同角度学习我们课程中涵盖的概念,可以参考Rust书籍和《Programming Rust》(O'Reilly出版社版)。这些资料并非完全重复,因此在学习过程中你肯定会收获新知。
高级材料
如果你想深入语言,可以查阅Rustonomicon和《Rust编程进阶》(No Starch出版社版)。此外,“Decrusted”系列视频教程(YouTube播放列表)也是深入了解许多热门Rust库内部运作的优秀资源。
领域特定材料
若想用Rust进行后端开发,可以参考"从零到生产环境的Rust开发";若想进行嵌入式开发,则可以查阅嵌入式Rust书籍。
专题精讲
你还能找到横跨多个领域的关键主题资源。对于测试,可以学习"超越基础的高级测试";对于遥测,可以参考"看不见就无法修复的问题"。