Chapter 1: Cargo——Rust 的包管理器
Cargo 是 Rust 的一个强大的 包管理器,通过 cargo 可以快速构建一个结构完整的 Rust 项目,并且通过命令行来编译运行项目
1.cargo new
该指令用于创建一个完整的 Rust 项目。可加参数 " vsc ==" 来创建附加内容,默认创建了一个 git 存储库,但是我关掉了,具体关掉的方法如下:
在 Rust 中使用 cargo 创建新项目时,默认会初始化一个 Git 仓库。如果你希望 不自动添加 Git 版本控制,可以使用以下两种方法:
方法 1:使用 --vcs none 参数
在创建项目时,显式指定 不初始化任何版本控制系统:
bash
|
|
这会跳过 Git 仓库的初始化。
方法 2:全局配置 Cargo 默认不初始化 Git
如果你希望所有新项目都默认不初始化 Git,可以修改 Cargo 的全局配置:
-
打开或创建 Cargo 的配置文件
~/.cargo/config.toml(Linux/macOS)或%USERPROFILE%\.cargo\config.toml(Windows)。 -
添加以下内容:
toml
1 2[cargo-new] vcs = "none"
这样之后所有 cargo new 命令都会跳过版本控制系统的初始化。
补充说明
- 如果已经创建了项目,你可以直接删除项目根目录下的
.git文件夹来移除 Git 跟踪。 - 支持的
--vcs选项包括:git(默认)、hg(Mercurial)、pijul或none。
这种方法既简单又干净,适合不需要版本控制或打算使用其他版本控制系统(如 Mercurial 或 SVN)的情况。
2.cargo build
该指令用于 编译并生成一个可执行的二进制文件。注意与后文的 cargo check 区分。可以添加 --release 来让生成文件存放在 targer/release 中,而非默认的 target/debug 中。
3.cargo check
该指令用来编译当前的工程,但注意,cargo check 并不会 和 cargo build 一样生成一个可执行的二进制文件,所以耗时会大大快于后者,通常用于代码工程编写中途来 检测当前有无显著语法错误。
4.cargo run
编译并且运行。
Chapter 2: 猜数游戏
程序将生成一个介于 1 到 100 之间的随机整数。然后它会提示玩家输入猜测。输入猜测后,程序会指示猜测是太低还是太高。如果猜对,游戏将打印一条祝贺消息并退出。
2.1 使用标准输入输出流
想要实现一个猜数游戏,首先能够进行标准的输入输出,所以就要引入 Rust 官方的 标准输入输出流 use std::io;。其作用类似于 C 语言中的 #include <stdio.h>,其中 “std” 表示 “standard library”,即标准库的意思,而 “io” 就是输入输出模块的意思。
正常情况下,Rust 的标准库中存在很多模块,称之为 prelude,具体的所有内容可以通过 [Rust 标准库](Hello, Cargo! - The Rust Programming Language “Rust 标准库”)中查看。
2.2.fn main()
在 Rust 中,关键字 fn 表示声明一个函数,() 中填入函数的入参,和 C 语言一样,Rust 总是从 main 函数 中作为入口进入。
2.3.println!()
作为一个输出语句,Rust 标准的输出语句是 println,没有 !, 这个感叹号的意思是这是一个 宏(marco),而非一个函数。而 println! 的标准占位符是 {},具体用法后文再提。
2.4.let
在 Rust 中,let 用来声明一个变量,比如 let apple = 5; 在这个语句中,我们声明了一个变量名为 apple,其值为 5 的一个变量。在 Rust 中,如果不做说明,则默认声明的变量是一个 不可变的(immutable)。意思就是,在我们声明了这个 apple 之后,是没有办法修改其值 5 的,编译器会报错,如下图所示:
我们现在删除修改其值的语句之后再次编译会发现就可以成功通过编译并且运行:
如果所有的变量都是不可变的,那 Rust 也太鸡肋了,所以官方有一个关键字 mut,来声明这个变量是 可变的(mutable),在声明变量的时候加上这个关键字就可以告知编译器,当前这个变量是一个可变的变量。如下图所示:
2.5.String:: new()
|
|
let mut guess = String::new(); 创建了一个字符串类型的变量,其中 mut 表示可变,new()表示创建了一个 String 的实例。在 Rust 中,:: 表示一个与前者关联的一个函数,在这里,String 是 Rust 标准库提供的一个字符串数据类型,new() 表示创建一个新的实例,是 String 所提供的一个 关联函数(associated function),所谓关联函数,是一个类中的函数,可以理解为 Python 中一个 类(class) 中的一个 方法(method)。
2.6 读取用户输入
io::stdin().read_line(&mut guess).expect("Fail to readline"); 这条语句用来读取用户的终端输入。由于我们在第一行就显式的引入了标准输入输出,我们这里可以直接使用 io::stdin() 来使用 stdin() 这个方法。如果我们没有在第一行中显式的导入标准输入输出,我们同样可以直接调用 stdin() 但是需要说明来源,如 stdio::io::stdin。
在 io::stdin() 中,stdin() 会返回一个 std::io::Stdin 的一个实例,该类型是作为一个获取用户终端输入的一个 句柄(handle)。
之后的 .read_line(&mut guess) 调用了 read_line 方法来获取用户的输入,并且传入了 &mut guess 来告诉 Rust 将读到的字符串放到 guess 变量中。read_line 会将用户在终端中输入的所有内容都变换成一个字符串来存储,存储方式是 追加(append),而不是 覆写(overwrite)。
代码中的 &,表示是 引用(reference),提供了一种让代码的多个部分都可以访问这一条数据,并且不需要多次复制这条数据的方法。本章中不做深究,在后续的章节中会详细讲解 Rust 的 &。目前我们只需要知道引用默认是不可变的,所以我们需要传入 &mut guess 作为参数而不是直接传入 &guess。
2.7 处理潜在的问题
可以注意到,在读取输入的过程中我们还有一句话,是 .expect("Fail to readline")。本小节我们将着重讨论这个语句。
如前文所提到的,read_line 方法会将用户的任何输入都读取到一个字符串中,但是它也会返回一个 Result 来表明语句的执行情况。这个 Result 是一个 枚举值(enumeration)(也简写为 enum)。用于进行 错误处理 的。详细内容将在后面仔细讲解如何通过这个值来在 Rust 中对代码进行错误处理。
Result 的值有两种,分别是 Err 和 Ok,前者表示语句执行出现错误,并且会包含一些调试信息,后者表示顺利执行。而 except 是 Result 类型的一种方法,如果 Result 的返回值为 Err,那么 except 就会终止程序,并且在终端中显示我们传入进去的字符串。如果 Result 的返回值是 Ok,在本条语句中,返回值将是用户输入的字节数目。如果不添加 except,项目依旧可以正常编译,但是编译器会向我们发送警告:
2.8 导入外部 crate
在使用 rand 生成随机数之前,我们首先需要修改 Cargo.toml,把 rand 添加到我们的 依赖(dependencies) 中:
其中格式为 “crate 名 + 版本号”(Rust 中的 crates 可以简单理解为外部库),这里的 0.8.5 是 ^0.8.5 的简写,意味着任何低于 0.9 但是高于等于 0.8.5 的版本。
接下来不对代码做任何修改,直接编译运行,我们会发现,它在自己下载相关依赖并且重新编译:
当我们包含外部依赖项时,Cargo 会从 注册表 中获取依赖项所需的所有内容的最新版本, 注册表 是 Crates.io 中的数据副本。Crates.io 是 Rust 生态系统中的人们发布他们的开源 Rust 项目供其他人使用的地方。
Cargo 拥有一种机制,确保每次你或其他人构建代码时都能重新构建相同的工件:Cargo 将仅使用你指定的依赖版本,直到你明确表示更改。例如,假设下周 rand crate 发布了 0.8.6 版本,该版本包含一个重要的错误修复,但也包含一个会导致你的代码出错的回归问题。为了处理这种情况,Rust 在你第一次运行 cargo build 时会创建 Cargo.lock 文件,因此现在我们在 guessing_game 目录中有了这个文件。
当你第一次构建项目时,Cargo 会确定所有符合要求的依赖版本,并将这些版本写入 Cargo.lock 文件中。以后当你再次构建项目时,Cargo 会看到 Cargo.lock 文件存在,并使用该文件中指定的版本,而不是重新计算版本。这使得你可以自动获得可重复的构建结果。换句话说,由于 Cargo.lock 文件的存在,你的项目会一直保持在 0.8.5 版本,直到你显式地进行升级。因为 Cargo.lock 文件对于可重复构建非常重要,所以它通常会和其他代码一起被纳入源代码控制中。
当你需要更新一个 crates 时,Cargo 提供了 update 命令,该命令会忽略 Cargo.lock 文件,并找出所有符合 Cargo.toml 中你指定条件的最新版本。然后 Cargo 会将这些版本写入 Cargo.lock 文件。在这种情况下,Cargo 将只查找版本号大于 0.8.5 且小于 0.9.0 的版本。如果 rand crates 发布了两个新版本 0.8.6 和 0.9.0,当你运行 cargo update 时,你会看到以下内容:
|
|
2.9 生成随机数
|
|
如上更新了代码,我们先是引用了 rand 库的 Rng 模块,里面定义了生成随机数的方法,之后使用不可变的变量获得了一个 1~100 的随机数。rand::thread_rng 是一个仅限于当前执行线程且由操作系统初始化的生成器,加上后文的 gen_range 就可以产生一个随机数,其中 gen_range 的入参是一个 范围表达式,用来指定生成随机数的范围。
[!TIP]
你无法凭空知道该使用哪些特性(traits)、调用哪些方法和函数,因此每个 crate(Rust 库)都附带了说明文档。Cargo 还有一个便捷功能:运行
cargo doc --open命令会在本地构建所有依赖项的文档,并在浏览器中打开。例如,如果你想了解randcrate 的其他功能,只需运行cargo doc --open,然后在左侧边栏点击rand即可查看其文档。
2.10 match——通过分支结构来比较随机数与猜测数
|
|
首先我们先导入 cmp 库中的 Ordering 模块,Ordering 本质也是一个枚举,拥有 Equal, Greater, Less 三个值。match 在这里的作用是根据后面的 Ordering 的值来匹配执行不同的语句,类似与 C 语言中的 switch case 语句,它是 顺序依次匹配。而不同的返回值对应的执行语句则是通过 => 来引出。我们通过 .cmp 方法来比较 guess 和 secret_number 的值, 并且返回对应的 Ordering 的枚举值。注意,.cmp 中应当使用 & 来引用,而不是直接通过传入变量名来比较!
接下来我们编译运行,会发现运行报错:
报错的主要问题是 类型不匹配(mismatch)。Rust 作为一个强静态类型语言,具有一个 数据类型推测功能。我们的 guess,在最开始声明的时候就是 String 字符串类型,但是我们生成的随机数,由于我们没有显式的指定类型,编译器会自己猜测数据类型,通常会猜测是一个 整型(integer),Rust 的几种数值类型中,有些可以在 1 到 100 之间取值: i32 (32 位数值)、 u32 (无符号 32 位数值)、 i64 (64 位数值),以及其他类型。除非另有指定,Rust 默认使用 i32 类型。我们想要把一个字符串与整型比较,这显然是不符合常理的,所以编译器才会报错,说 expected &String, found &{integer}, 预期输入的是一个字符串,但是却发现输入的是一个整型。
想要能够正确识别,我们可以将 guess 从字符串转换为整型。代码如下:
|
|
我们会惊奇的发现,明明 guess 这个变量在之前定义过了,这里怎么还可以再次定义,而且编译器还没有报错?!这是因为 Rust 中允许重复使用一个变量名,但是之后的变量会覆盖前面的变量值。在这里,我们后面的 guess 就成功用整型的数值覆盖了之前的 guess 的字符串类型的数值。这种特性可以让我们重复的使用一个变量名,而不是使用 guess_str 和 guess_int 来区分。眼下我们只需要知道,我们进行数据类型变换的时候通常会使用这个特性。
我们现在来看看我们的转换语,首先 guess.trim() 中的 guess,是最先开始的那个字符串,而其调用了 trim() 方法,该方法的作用是消除开头和结尾的任何空白,这是在将字符串转换为只能包含数值数据的 u32 之前必须做的。用户必须按下 enter 来触发 read_line 并输入他们的猜测,这会在字符串中添加一个换行符。例如,如果用户输入 5 并按下 enter , guess 看起来是这样的: 5\n 。 \n 代表 “换行” (在 Windows 上,按下 enter 会导致回车和换行, \r\n )。 trim 方法会消除 \n 或 \r\n ,结果只剩下 5 。
接下里的 parse() 则是把字符串类型转换成别的数类型。使用的时候我们必须显式的告诉编译器我们期望转换的类型,通过赋值语句之前的 :u32 来告诉编译器我们想要的数据类型是一个无符号 32 位整型。同时这也意味着编译器会推断 secret_number 也是一个 u32 的数据类型,以此才方便两者比较。最后再加上 except() 来进行可能的错误处理。
[!NOTE]
parse方法只适用于逻辑上可以转换为数字的字符,因此很容易导致错误。例如,如果字符串包含A👍%,则无法将其转换为数字。由于它可能会失败,parse方法返回Result类型,就像read_line方法一样。我们将以相同的方式处理这个Result,再次使用expect方法。如果parse因为无法从字符串中创建数字而返回Err,expect调用将崩溃游戏并打印我们给它的消息。如果parse成功将字符串转换为数字,它将返回Ok变体的Result,而expect将返回我们从Ok值中想要的数字。
接下来我们直接编译并且运行代码,会发现代码成功运行起来了:
但是有个显而易见的问题,不论我们猜测的正确与否,我们都只能猜测依次,我们预期的是,我们不停猜测,直到我们猜测的结果正确为止,这就涉及到了一个重要概念——循环(loop)!
2.11 通过循环实现多次猜测
|
|
现在我们可以多次循环猜测,但是也出现了一个问题:没有终止体条件!! 所以我们要添加一个终止条件,即如果我们猜测的数刚好就是产生的数,则自动停止。
|
|
在这里,我们使用 match 分支来处理错误情况,上文提到过 Result 作为一个枚举值,有 OK 和 Err 两种情况。如果是 Ok 那么它会包含一个值,这个值就是我们预期想得到的一个类型转换后的值。如果是 Err,则 Err(_)。其中 _ 是一个 catch-all value,代表着不论这里存储的是什么,我们都期望与我们的这个分支匹配。
Chapter 3: 常见的编程概念
3.1 Rust 中特有概念
与其他编程语言不同,Rust 中有很多独有的概念,同时也是 Rust 的最重要的特性。
3.1.1.变量(variable)和可变性(mutability)
正如之前所提到的,Rust 中的所有的变量默认是不可变的。如果我们想要让这个变量可变,需要使用到关键字 mut 来显式声明这个变量是可变的。
3.1.2.常量(constants)
对于常量来说,我们是严令禁止 mut 声明的,因为常量本身的意义就在于不可变。声明一个常量我们使用的关键字是 const 而不是 let,并且 常量必须显式的声明其数据类型,而不是和普通变量一样可以让编译器自己推测。
3.1.3.变量遮蔽(shadowing)
在第二章中,我们使用了相同的变量名来处理数据类型变换的问题。在 Rust 中,我们通常说前一个变量被之后的变量给 遮蔽了(shadowed by),遮蔽的不仅是值,还有可变/不可变的属性。但是 变量遮蔽仅仅存在于后来的变量的作用域中。
|
|
|
|
在这个例子中,x 首先被声明为 5,紧接着就被 7(5 + 2)给遮蔽了,但是又在代码块中被 9999 给遮蔽,在 Rust 中,一个大括号对就代表一个代码块,其内部的变量的作用域只能作用域声明它的代码块中。所以当程序执行完第 7 行之后,值为 9999 的变量 x 会被销毁,所以第 9 行打印的 x 的值是之前的 x,即 7。
mut 和遮蔽的另一个区别在于,当我们再次使用 let 关键字时,我们实际上是在 创建一个新的变量,因此我们可以改变值的类型但重用相同的名称。例如,假设我们的程序要求用户输入一些空格字符来显示他们希望在文本之间有多少空格,然后我们想把输入存储为数字:
|
|
第一个 spaces 变量是字符串类型,第二个 spaces 变量是数字类型。因此,我们不必想出不同的名称,比如 spaces_str 和 spaces_num ;相反,我们可以重用更简单的 spaces 名称。然而,如果我们尝试像这样使用 mut ,我们会得到一个 编译时错误:
|
|
这是因为我们在第一行已经声明了 space 变量为一个字符串,而 len() 方法返回值是当前字符串的长度,是一个整数。我们尝试把一个整数赋值给一个字符串,这是 Rust 不允许的,所以报错了。正确做法应该是和上文一样使用两次 let。
3.2 数据类型
Rust 作为一个静态类型语言,在编译的时候必须知道所有的数据的类型。一般来说,编译器可以通过我们给的赋值和使用来自己推断大多数的数据的类型。但是对于一些特殊情况,我们必须显式的声明数据类型,比如在声明一个常量的时候,比如我们在猜数游戏中的强制类型转换的时候。
|
|
如果我们不添加 :u32,那么编译器会报错如下:
3.2.1 标量
Rust 中的四种常见的标量分别是 整数(integer)、浮点数(floating-point numbers)、布尔值(Booleans)和字符(characters)。
3.2.2 整型
整型分为 有符号整型 i 和无符号整型 u,具体内容如下表:
| 长度 | 有符号 | 无符号 |
|---|---|---|
| 8-bits | i8 | u8 |
| 16-bit | i16 | u16 |
| 32-bit | i32 | u32 |
| 64-bit | i64 | u64 |
| 128-bit | i128 | u128 |
| 架构决定 | isize | usize |
每个变体都可以是有符号或无符号的,并且具有明确的大小。有符号和无符号指的是该数字是否可能为负数——换句话说,该数字是否需要带有符号(有符号)或是否永远为正数,因此可以不需要符号来表示(无符号)。这就像在纸上写数字一样:当符号重要时,数字会显示加号或减号;然而,当可以安全地假设数字为正数时,则不显示符号。有符号数字使用二进制补码表示法存储。
每个有符号变体可以存储从 −(2 ^n-1^ ) 到 2^n-1^-1 的数字,其中 n 是该变体使用的位数。因此一个 i8 可以存储从 −(2^7^ ) 到 2^7^ − 1 的数字,这等于 −128 到 127。无符号变体可以存储从 0 到 2^n^− 1 的数字,所以一个 u8 可以存储从 0 到 2^8^ − 1 的数字,这等于 0 到 255。
此外,isize 和 usize 类型取决于你的程序运行在哪种计算机架构上:如果你在 64 位架构上,它们是 64 位的;如果你在 32 位架构上,它们是 32 位的。
同时,你可以按照下表的所有方式书写整型。请注意,可以表示为多种数值类型的数字字面量允许使用 类型后缀,例如 57u8 ,来指定类型。数字字面量也可以使用 _ 作为视觉分隔符,以便更容易阅读数字,例如 1_000 ,它将与你指定 1000 时具有相同的值。
| 进制 | 例子 |
|---|---|
| 十进制 | 98_222 |
| 十六进制 | 0xff |
| 八进制 | 0o77 |
| 二进制 | 0b1111_0000 |
| 字节 | b’A’ |
[!NOTE]
假设你有一个类型为
u8的变量,它可以存储 0 到 255 之间的值。如果你尝试将变量更改为该范围之外的值,例如 256,就会发生整数溢出,这可能导致两种行为之一。在调试模式下编译时,Rust 会包含整数溢出的检查,如果发生这种行为,程序将在运行时崩溃。Rust 使用术语 panicking 来描述程序以错误退出。 当你以带--release标志的发布模式编译时,Rust 不会包含导致恐慌的整数溢出检查。相反,如果发生溢出,Rust 会执行补码回绕。简而言之,超出类型所能持有的最大值的值会“回绕”到类型所能持有的最小值。对于u8类型,值 256 会变成 0,值 257 会变成 1,以此类推。程序不会恐慌,但变量的值可能并不是你期望的值。依赖整数溢出的回绕行为被认为是一种错误。
3.2.3 浮点数
Rust 中也有两种浮点数 f32,f64。分别代表双经度和单精度,分别占用 32 位和 64 位。默认类型是 f64,因为在现代 CPU 上,它的速度与 f32 大致相同,但能提供更高的精度。所有浮点数类型都是带符号的。
|
|
3.2.4 数值运算
|
|
[!CAUTION]
let value2 = 5.4 / -2;这样的语句会报错,因为浮点数类型只能和浮点数类型相除。同样,浮点数不能取余。
3.2.5 布尔值
和大多数其他编程语言一样,Rust 中的布尔类型有两个可能的值:true 和 false。布尔类型的大小为 1 个字节。在 Rust 中,使用 bool 来指定布尔类型。例如:
|
|
3.2.6 字符类型
Rust 的 char 类型是语言中最原始的字母类型。这里有一些声明 char 值的例子:
|
|
请注意,我们使用单引号指定 char 字面量,而字符串字面量则使用双引号。Rust 的 char 类型占用四个字节,表示 Unicode 标量值,这意味着它不仅限于 ASCII 字符。带重音的字母;中、日、韩文字符;表情符号;以及零宽空格都是 Rust 中有效的 char 值。Unicode 标量值的范围从 U+0000 到 U+D7FF ,以及 U+E000 到 U+10FFFF 。然而,在 Unicode 中,“字符”并非一个真正存在的概念,因此你对“字符”的人类直觉可能与 Rust 中的 char 不一致。我们将在第 8 章的“使用字符串存储 UTF-8 编码文本”中详细讨论这一主题。
3.2.6 复合类型
复合类型可以将多个值组合为一个类型。Rust 有两种原始复合类型:元组和数组。
元组 Tuple
元组可以将不同类型的值存储在一个变量之中,但是 一旦声明之后,就不能更改元组的长度。
我们通过在括号内编写用逗号分隔的值列表来创建元组。元组中的每个位置都有一个类型,元组中不同值的类型不必相同。在这个示例中,我们添加了可选的类型注解:
|
|
变量 tup 绑定到整个元组,因为元组被视为一个复合元素。要从元组中获取各个值,我们可以使用 模式匹配来解构元组值,如下所示:
|
|
上述代码首先创建了一个元组,并绑定在一个变量 tup 中,之后又使用 let 来让 (x,y,z) 分别获取 tup 对应位置的值。这个过程就叫 "解构(destructuring)"。
我们也可以通过 **".+索引值 " ** 来直接引用元组中的值,如下所示:
|
|
这个程序创建了元组 x ,然后使用各自的索引访问元组的每个元素。和大多数编程语言一样,元组中的第一个索引是 0。但是,我们 不能通过 . 配合一个变量来动态的访问一个元素:
|
|
编译上面这段代码会如下报错:
这是因为如果是用一个变量动态的访问一个元组,很有可能,这个变量的面值超过了元组的元素数量,会造成类似数组越界的错误。这是 Rust 所不希望看见的。
没有任何值的元组有一个特殊的名称,称为 unit。这个值及其对应类型都写作 () ,表示一个空值或一个空的返回类型。如果表达式没有返回其他值,则会隐式返回 unit 值。
数组 Array
数组也是一种常见的复合数据类型,但是与元组不同的是,数组中的数据的类型必须是同一种。与某些语言不同的是,Rust 中的数组长度是固定的。
我们通常使用 [] 来将一组数框起来作为数组。如下所示:
|
|
和之前介绍的类型一样,数组的声明会从“栈(stack)”中申请内存,而不是“堆(heap)”中。而“向量(vector)”不同,它是分配在堆区的,所以它 可以自由的增加或减少。如果您不确定是否应该使用数组或向量,那么您很可能应该使用向量。我们将在后面的章节详细阐述这一点。
然而,当你知道元素数量不会改变时,数组更有用。例如,如果你在程序中使用月份的名称,你可能会使用数组而不是向量,因为你知道它总是包含 12 个元素:
|
|
可以使用方括号来编写数组的类型,其中包含每个元素的类型、分号,然后是数组中的元素数量,如下所示:
|
|
在这里,i32 就是这个数组的数据类型。
同样,我们也可以指定数组的初始值。如下所示:
|
|
此处,我们就声明了一个叫做 a 的数组,有 5 个成员初始值都为 3。等同于 let a = [3, 3, 3, 3, 3];
数组是一个已知大小且固定大小的内存块,可以在栈上分配。你可以使用索引来访问数组的元素,就像这样:
|
|
在这个例子中,名为 first 的变量将获得值 1 ,因为这是数组中索引 [0] 的值。名为 second 的变量将从数组中的索引 [1] 获得值 2 。
和其他语言一样,数组会存在越界现象,比如下面的代码:
|
|
如果我们输入的索引值超过了 4,那么就会看到如下报错:
程序在使用索引操作时无效值导致运行时错误。程序退出并显示错误消息,未执行最后的 println! 语句。当你尝试使用索引访问元素时,Rust 会检查你指定的索引是否小于数组长度。如果索引大于或等于长度,Rust 将发生 panic。这个检查必须在运行时进行,特别是这种情况,因为编译器不可能知道用户在后期运行代码时会输入什么值。
这是 Rust 内存安全原则的一个实例。在许多低级语言中,这种检查不会进行,当你提供错误的索引时,可能会访问无效的内存。Rust 通过立即退出而不是允许内存访问并继续来保护你免受这种错误的影响。
3.3 函数(Functions)
函数在 Rust 代码中非常普遍。你已经见过语言中最重要之一的函数: main 函数,它是许多程序的入口点。你还见过 fn 关键字,它允许你声明新的函数。
在 Rust 中,多使用 蛇形命名法 来对一个函数命名。所谓蛇形命名法,就是将函数名按照 “小写字母 + 下划线” 的组合进行命名。如下面的两个例子:
|
|
和 C 语言一样,我们使用 {} 来将一个函数括起来,表示这个函数的作用域,来表明函数的开始和结束。
同时我们可以注意到,我们明明在 main 函数之后声明的这个 function,但是我们依旧可以在 main 函数中调用这个函数。因为在 Rust 中,编译器并不像 C 语言的编译器一样那么严格要求函数的声明必须要在调用之前;不论你在哪里定义了这个函数,你都可以调用。
3.3.1 参数(parameters)
上文我们定义的 another_function,没有入参。在 Rust 中,我们也可以定义拥有入参的函数。技术上,具体的值被称为 实参(arguments),但在日常对话中,人们往往将 参数(parameter) 和实参这两个词混用,指代函数定义中的变量或调用函数时传入的具体值。
|
|
我们修改了我们的 function,给他添加了一个数据类型为 i32 的入参 x,其函数功能就是将它的值打印出来。
在定义函数的时候,我们 必须显式的指定 我们每一个入参的数据类型。同样我们也可以定义一个有多个入参的函数,如下:
|
|
|
|
如果我们想要入参是一个字符串而不是单纯是一个指针,我们首先想到的代码如下:
|
|
在这段代码中我们直接指定 y 的数据类型是 str 字符串型,然后在调用函数的时候传入我们想要的字符串即可,可是我们编译运行发现不能通过编译。编译器报错如下图:
我们查看编译器的第一个报错,它说 function 在 y 的位置预期得到的是一个 str 类型,但是我们传入的 "N1netyNine99" 确实一个 &str 类型。这让我很疑惑,但是我们按下不表,接着看下面的报错。后面两个报错的都是 doesn't have a size known at compile-time,意思是在编译的时候不知道大小。经过我的查询,Rust 作为一个严格的内存安全的语言,其不允许任何可能造成内存错误的事情发生,就比如我们想要传入一个不知道大小的数据。在 Rust 中,str 和 &str 是两种不同的数据类型;前者是一个抽象的数据类型,表示一段 UTF-8 字符串,它是动态大小类型(DST),编译时不知道具体大小。而 &str 它是对 str 的借用引用,&str 本质是一个胖指针(fat pointer),它包含了两个部分:一个指向实际字符串数据的内存地址的指针、字符串的字节长度;&str 的大小是固定的,在 64 位系统中总是 16 字节(8 字节指针 + 8 字节长度)。所以可以使用,因为编译器清楚的知道其大小。
[!NOTE]
想象一下:
str就像 “一本书的内容” - 你知道有内容,但不知道有多少页&str就像 “书签 + 页数标记” - 告诉你内容在哪里,有多少页
所以我们对代码修改一下:
|
|
编译运行之后就发现可以正常运行了!
3.3.2 语句(Statement)和表达式(Expression)
函数体由一系列可选以表达式结尾的语句组成。到目前为止,我们讨论过的函数还没有包含结尾表达式,但你已经见过表达式作为语句的一部分。由于 Rust 是一种基于表达式的语言,理解这一点非常重要。其他语言没有这样的区别,所以让我们看看 语句 和 表达式 是什么,以及它们的差异如何影响函数体。
- 语句是执行某些操作,但是没有返回值。
- 表达式是经过一些计算,然后返回一个值。
|
|
比如上述的 let y = 5; 就是一个语句,它没有任何的返回值。所以下列的操作是明确禁止的:
|
|
编译器报错说 error: expected expression, found let statement,这也和我们上述的表述相同。let x = 5 语句不返回值,所以 y 没有可以绑定到的东西。这与 C 语言和 Ruby 等其他语言不同,在这些语言中,赋值会返回赋值的值。在这些语言中,你可以写 x = y = 6 ,让 x 和 y 都具有值 `5 ;在 Rust 中则不是这种情况。
在 Rust 中,使用 {} 可以创建一个表达式,表达式是有返回值的。
|
|
在这个例子中,我们给 x 的赋值就是一个表达式,这个表达式的返回值就是第 4 行的 y + 1,由于我们在第三行定义了 y 是 1,那么这个表达式的返回值就是 1 + 1 即 2,所以我们打印出来的值就是 2。
注意 y + 1 行末尾没有分号,这与你之前看到的绝大多数行不同。表达式不包括结束分号。如果你在表达式末尾添加分号,它会变成一个语句,并且不会返回值。 在接下来探索函数返回值和表达式时,请记住这一点。
3.3.3 带返回值的函数
函数可以向调用它们的代码返回值。我们不命名返回值,但必须在箭头( -> )之后声明它们的类型。在 Rust 中,函数的返回值与函数体块中最后一条表达式的值是同义的。你可以使用 return 关键字并指定一个值来提前返回函数,但大多数函数会隐式地返回最后一条表达式。 下面是一个返回值的函数示例:
|
|
运行这段代码,你会看见终端显示 The value of x is: 5,表明 5 已经正确返回到了调用它的主函数当中。我们也可以使用 return 来返回一个值或者表达式:
|
|
其效果和上面的代码一样。
在上述的代码中,我们使用了一个无入参,返回值为 i32 的函数来对一个变量初始化了,同样的,我们可以通过使用一个带参数的函数来达到初始化一个变量的目的:
|
|
此时我们 x 的值就是 3。但是我们对代码稍作修改:
|
|
编译后运行发现:
这是因为,函数声明中显示说将会返回一个 i32 类型的值。但是我们的函数体内部,没有显式的返回值,第二行代码也只是简单的对 x 和 y 执行了一次加法,并且由于以 ; 结尾,表明这是一个语句,没有返回值。那么函数的返回值便是 (),我们之前提到过,其数据类型是 unit,与我们的预取 i32 并不符合,所以编译器报错了。
3.4 注释
所有程序员都力求让他们的代码易于理解,但有时需要额外的解释。在这种情况下,程序员会在他们的源代码中留下注释,编译器会忽略这些注释,但阅读源代码的人可能会觉得有用。
这是一个简单的注释:
|
|
在 Rust 中,惯用的注释风格以两个斜杠开始注释,注释内容持续到行尾。 对于跨越多行的注释,你需要在每一行都包含 // ,像这样:
|
|
注释也可以放在包含代码的行末:
|
|
[!NOTE]
官方文档中没有提到,但是在我的开发环境中(vscode)确实是可以通过
/* */来达到和 C 语言一样的注释风格。
3.5 控制流
能够在条件为 true 时运行某些代码,以及在条件为 true 时重复运行某些代码,是大多数编程语言中的基本构建块。让 Rust 代码执行流控制的最常见结构是 if 表达式和循环。
3.5.1 if 分支
一个 if 表达式 允许你根据条件分支你的代码。你提供一个条件,然后说明,“如果这个条件满足,运行这块代码。如果条件不满足,不要运行这块代码。”
所有 if 表达式都以关键词 if 开头,后面跟着一个条件。我们将要执行的代码块立即放在大括号内的条件后面。与 if 表达式中的条件关联的代码块有时被称为 臂,就像我们在第 2 章的 “比较猜测值与秘密数字” 部分讨论的 match 表达式中的臂一样。
我们也可以选择包含一个 else 表达式,以便在条件评估为 false 时给程序提供一个可执行的代码块。如果你不提供 else 表达式并且条件为 false ,程序将直接跳过 if 块并继续执行下一段代码。
|
|
例如这段代码,其功能就是从终端中读取用户输入的值来与 5 作比较,根据不同的比较结果来向终端中打印不同的语句。注意,我们这里使用了 loop 和 match 来处理如果用户输入的值不是一个数字的情况,顺道复习了一下第二章所学习到的内容。
在 Rust 中,控制分支的条件必须是一个布尔值。而不能像 C 语言一样使用一个整型来当作 if 的条件。例如下面的代码就是不合法的:
|
|
编译运行这段代码会发现编译器报错如下:
报错表明 Rust 期望一个 bool ,但得到的是一个整数。与 Ruby 和 JavaScript 等语言不同,Rust 不会自动尝试将非布尔类型转换为布尔类型。你必须明确指定,并且始终使用布尔值作为 if 的条件。例如,如果我们希望 if 代码块仅在数字不等于 0 时执行,我们可以将 if 表达式更改为以下内容:
|
|
在 Rust 中,if 是一个表达式,这意味着我们可以在 let 语句的右侧使用它来给变量赋值,如下所示:
|
|
在这个例子中,number 变量将绑定到基于 if 表达式结果的值。运行此代码以查看会发生什么:
|
|
记住,代码块的值是其最后一个表达式的值,数字本身也是表达式。在这种情况下,整个 if 表达式的值取决于执行哪个代码块。这意味着 if 的每个分支的潜在返回值必须是相同的类型;在上面的例子中,if 分支和 else 分支的结果都是 i32 整数。如果类型不匹配,如下例所示,我们会得到一个错误:
|
|
当我们尝试编译此代码时,我们会得到一个错误。if 和 else 分支具有不兼容的值类型,Rust 明确指出在程序中找到问题的位置:
|
|
if 代码块中的表达式计算为整数,而 else 代码块中的表达式计算为字符串。这不起作用,因为变量必须具有单一类型,并且 Rust 需要在编译时明确知道 number 变量的类型。知道 number 的类型让编译器验证该类型在我们使用 number 的任何地方都有效。如果 number 的类型只能在运行时确定,Rust 就无法做到这一点;如果编译器必须跟踪任何变量的多个假设类型,编译器会更复杂,对代码的保证会更少。
3.2 使用循环重复执行
多次执行一段代码通常很有用。对于这个任务,Rust 提供了几种循环,它们将运行循环体内的代码直到结束,然后立即从头开始。为了试验循环,让我们创建一个名为 loops 的新项目。
Rust 有三种循环:loop、while 和 for。让我们尝试每一种。
3.2.1 使用 loop 重复执行代码
loop 关键字告诉 Rust 一遍又一遍地执行一段代码,直到你明确告诉它停止。
作为示例,将 loops 目录中的 src/main.rs 文件更改为如下所示:
|
|
当我们运行这个程序时,我们会看到 again! 连续打印,直到我们手动停止程序。大多数终端支持键盘快捷键 ctrl-c 来中断陷入连续循环的程序。试一试:
|
|
符号 ^C 表示你按下 ctrl-c 的位置。你可能会或可能不会在 ^C 之后看到单词 again! 打印,这取决于代码在接收到中断信号时在循环中的位置。
幸运的是,Rust 还提供了一种使用代码跳出循环的方法。你可以在循环内放置 break 关键字来告诉程序何时停止执行循环。回想一下,我们在第 2 章的猜数游戏中的 “猜对后退出” 部分中这样做了,当用户通过猜对数字赢得游戏时退出程序。
你也可以在循环中使用 continue,它告诉程序跳过此循环迭代中的任何剩余代码并转到下一次迭代。
从循环返回值
loop 的用途之一是重试你知道可能失败的操作,例如检查线程是否已完成其作业。你可能还需要将该操作的结果从循环传递到代码的其余部分。为此,你可以在用于停止循环的 break 表达式之后添加你想要返回的值;该值将从循环中返回,以便你可以使用它,如下所示:
|
|
在循环之前,我们声明一个名为 counter 的变量并将其初始化为 0。然后我们声明一个名为 result 的变量来保存从循环返回的值。在循环的每次迭代中,我们将 1 添加到 counter 变量,然后检查 counter 是否等于 10。当它等于时,我们使用 break 关键字和值 counter * 2。循环后,我们使用分号结束将值赋给 result 的语句。最后,我们打印 result 中的值,在这种情况下是 20。
循环标签以消除多个循环之间的歧义
如果你在循环内有循环,break 和 continue 适用于该点的最内层循环。你可以选择在循环上指定循环标签,然后我们可以将其与 break 或 continue 一起使用,以指定这些关键字适用于标记的循环而不是最内层循环。循环标签必须以单引号开头。这是一个带有两个嵌套循环的示例:
|
|
外层循环有标签 'counting_up,它将从 0 计数到 2。没有标签的内层循环从 10 倒数到 9。第一个没有指定标签的 break 将只退出内层循环。break 'counting_up; 语句将退出外层循环。此代码打印:
|
|
3.2.2 使用 while 的条件循环
程序经常需要评估循环内的条件。当条件为 true 时,循环运行。当条件不再为 true 时,程序调用 break,停止循环。可以使用 loop、if、else 和 break 的组合来实现这样的行为;如果你愿意,你现在可以在程序中尝试这样做。然而,这种模式非常常见,以至于 Rust 为它提供了一个内置的语言构造,称为 while 循环。在下面的例子中,我们使用 while 循环程序三次,每次倒数,然后,循环后,打印一条消息并退出。
|
|
这种构造消除了使用 loop、if、else 和 break 时需要的大量嵌套,并且更清晰。当条件为 true 时,代码运行;否则,它退出循环。
3.2.3 使用 for 遍历集合
你可以使用 while 构造来循环遍历集合的元素,例如数组。例如,下面的循环打印数组 a 中的每个元素。
|
|
这里,代码计数遍历数组中的元素。它从索引 0 开始,然后循环直到它到达数组中的最终索引(即,当 index < 5 不再为 true 时)。运行此代码将打印数组中的每个元素:
|
|
数组中的所有五个元素都按预期出现在终端中。即使 index 在某个点将达到值 5,循环也会在尝试从数组中获取第六个值之前停止执行。
但是,这种方法容易出错;如果索引值或测试条件不正确,我们可能会导致程序崩溃。例如,如果你将 a 数组的定义更改为有四个元素,但忘记将条件更新为 while index < 4,代码将崩溃。它也很慢,因为编译器添加运行时代码以 在循环的每次迭代中执行条件检查,以检查索引是否在数组的边界内。
作为更简洁的替代方案,你可以使用 for 循环并对数组的每个项目执行一些代码。for 循环看起来像下面的代码:
|
|
当我们运行此代码时,我们将看到与前面的例子相同的输出。更重要的是,我们现在提高了代码的安全性并消除了可能由于超出数组末尾或没有走得足够远而遗漏某些项目而导致的错误的可能性。
使用 for 循环,如果你更改了数组中值的数量,你不需要记住更改任何其他代码,就像你使用前面的方法一样。
for 循环的安全性和简洁性使它们成为 Rust 中最常用的循环构造。即使在你想要运行一些代码一定次数的情况下,就像前面使用 while 循环的倒数示例一样,大多数 Rustaceans 也会使用 for 循环。做到这一点的方法是使用 Range,它是标准库提供的一种类型,它按顺序生成从一个数字开始到另一个数字结束之前的所有数字。
这是使用 for 循环和我们尚未谈论的 rev 方法来反转范围的倒数的样子:
|
|
这段代码更好一些,不是吗?
3.6 课后训练
3.6.1 编写一个华氏度和摄氏度相互转换的程序
|
|
3.6.2 生成第 n 个斐波那契数
|
|
Chapter 4: 理解所有权
4.1 什么是所有权?
所有权 是一套管理 Rust 程序如何管理内存的规则。所有程序都必须在运行时管理它们使用计算机内存的方式。有些语言有垃圾回收机制,会定期在程序运行时查找不再使用的内存;在其他语言中,程序员必须显式地分配和释放内存。Rust 采用第三种方法:通过一套编译器会进行检查的所有权规则系统来管理内存。如果任何规则被违反,程序将无法编译。所有权的所有特性都不会在程序运行时减慢其速度。
由于所有权对许多程序员来说是一个新概念,确实需要一些时间来适应。好消息是,随着你对 Rust 和所有权系统规则的经验越来越丰富,你会发现自然而然地开发出安全且高效的代码变得越来越容易。坚持下去!
当你理解了所有权,你就有了理解使 Rust 独特的特性的坚实基础。在本章中,你将通过一些专注于一个非常常见的数据结构的示例来学习所有权:字符串。
4.1.1 栈(Stack)与堆(Heap)
许多编程语言不要求你经常考虑栈和堆。但在像 Rust 这样的系统编程语言中,值是在栈上还是在堆上会影响语言的行为以及你必须做出某些决定的原因。本章稍后将描述与栈和堆相关的所有权部分,所以这里有一个简要的解释作为准备。
栈 以严格的 后进先出(LIFO) 顺序存储数据。想象一叠盘子:新盘子只能放在顶部,取用时也只能从顶部拿取(不可从中间或底部操作)。数据的存入称为 压栈(push),移除称为 出栈(pop)。所有存储在栈中的数据必须具有 编译期已知的固定大小,无法满足此条件的数据必须存放在堆中。
堆 的组织更为松散:在堆上存储数据时,需先申请特定大小的空间。内存分配器(allocator)会在堆中找到足够大的空闲区域,将其标记为已占用,并返回指向该位置的 指针(即内存地址),此过程称为 堆分配(allocating)。由于堆指针本身是固定大小的,它可以被存储在栈上,但访问实际数据时需通过指针跳转。类比餐厅等位:入场时告知人数,服务员会安排合适餐桌并引导入座;若有人迟到,只需根据桌号定位即可。
访问栈中的数据比访问堆中的数据更快,原因有二:
- 速度:压栈操作远快于堆分配,因为栈的写入位置永远在顶部(无需搜索空闲内存),而堆分配需先寻找足够空间并维护分配记录。
- 访问效率:访问堆数据通常更慢,因为需要通过指针间接寻址。现代处理器对连续内存访问(如栈)有优化,而随机跳转(如堆)会降低缓存命中率。延续餐厅类比:服务员若在 A、B 两桌间反复切换处理订单,效率必然低于集中处理完一桌再处理下一桌。
当你的代码调用一个函数时,传递给函数的值(包括可能指向堆上数据的指针)和函数的局部变量会被推送到栈上。当函数结束时,这些值会从栈中弹出。
跟踪代码的哪些部分正在使用堆上的哪些数据,最小化堆上的重复数据量,以及清理堆上未使用的数据以免空间不足,这些都是所有权要解决的问题。一旦你理解了所有权,你就不需要经常考虑栈和堆了,但知道管理堆数据是所有权存在的主要原因可以帮助解释为什么它会这样工作。
4.1.2 所有权规则
首先,让我们看看所有权规则。当我们通过示例来说明这些规则时,请记住这些规则:
- 在 Rust 中,每个值都有一个 所有者。
- 同一时间,一个值只能被一个所有者拥有。
- 当程序超出所有者的作用域,其所有的值会被舍弃。
4.1.3 变量作用域
现在我们已经过了基本的 Rust 语法,我们不会在示例中包含所有的 fn main() { 代码,所以如果你正在跟着做,请确保手动将以下示例放在 main 函数内。因此,我们的示例会更简洁一些,让我们专注于实际细节而不是样板代码。
作用域是指程序中某个项目有效的范围。以下是一个变量:
|
|
变量 s 指向一个字符串字面量,其中字符串的值硬编码在程序的文本中。该变量从声明点开始直到当前作用域的末尾都有效。此时,作用域与变量有效性的关系与其他编程语言相似。现在我们将基于这一理解,引入 String 类型。
4.1.4 String 类型
为了说明所有权规则,我们需要一个比第三章 “数据类型” 部分所涵盖的 更复杂的数据类型。之前 涵盖的类型是已知大小的,可以存储在栈上,在其作用域结束时从栈中弹出,并且可以快速且简单地复制以创建一个新的独立实例,如果代码的其他部分需要在不同的作用域中使用相同的值。但我们想要查看存储在堆上的数据,并探索 Rust 何时清理这些数据,而 String 类型就是一个很好的例子。
我们已经见过字符串字面量,其中字符串值被植入到我们的程序中。字符串字面量很方便,但它们并不适用于我们可能想要使用文本的每种情况。一个原因是它们是不可变的。另一个原因是并非每个字符串值在我们编写代码时都能知道:例如,如果我们想要获取用户输入并将其存储起来怎么办?对于这些情况,Rust 有一种第二种字符串类型,即 String。这种类型管理堆上分配的数据,因此能够存储编译时我们不知道数量的文本。你可以使用 from 函数从一个字符串字面量创建一个 String,就像这样:
|
|
双冒号 :: 运算符允许我们在这个特定的 from 函数下命名 String 类型,而不是使用某种名称,如 string_from。我们将在第 5 章的 “方法语法” 部分和第 7 章的 “模块路径引用项目” 中更多地讨论这种语法。
很自然的我们就能想到,这个 String 和我们之前使用的 String,两者有何区别呢?
有一个很显著的区别就是,后者是可变的:
|
|
那么,这里有什么区别呢?为什么 String 可以被改变而字面量不行?区别在于这两种类型如何处理内存。
4.1.5 内存与分配
在字符串字面量的情况下,我们知道编译时的内容,因此文本直接硬编码到最终的可执行文件中。这就是为什么字符串字面量速度快且高效的原因。但这些特性只来自于字符串字面量的不可变性。不幸的是,我们不能为每个在编译时大小未知且运行时大小可能会变化的文本块在二进制文件中放入一块内存。
使用 String 类型,为了支持一个可变且可增长的文本,我们需要在堆上分配一段在编译时未知大小的内存来存储内容。这意味着:
- 内存必须在运行时从内存分配器请求。
- 我们需要一种方式在完成对
String的使用后将这块内存返回给分配器。
第一部分是由我们完成的:当我们调用 String::from 时,它会 向堆申请内存。这在编程语言中几乎是通用的。
然而,第二部分是不同的。在具有垃圾收集器(GC)的语言中,垃圾收集器会跟踪并清理不再使用的内存,我们不需要考虑这些。在大多数没有垃圾收集器的语言中,我们需要自己识别不再使用的内存,并调用代码显式地释放它,就像我们请求内存时所做的那样。正确地做到这一点历来是一个困难的编程问题。如果我们忘记了,我们会浪费内存。如果我们过早地释放,会导致变量无效。如果我们释放两次,这也是一种错误。我们需要一对一地配对 allocate 和 free。
Rust 采取了不同的路径:内存会在拥有它的变量超出作用域时自动返回。比如下列代码:
|
|
有一个自然的时机可以将 String 所需的内存返回给分配器:当 s 超出作用域时。当一个变量超出作用域时,Rust 会为我们调用一个特殊函数。这个函数叫做 drop,作者可以在 drop 中放入返回内存的代码。Rust 会在闭大括号时自动调用 drop。
这种模式对 Rust 代码的编写方式产生了深远的影响。现在看来可能很简单,但在更复杂的情况下,当我们希望多个变量使用我们在堆上分配的数据时,代码的行为可能会出乎意料。我们现在就来探讨一些这种情况。
4.1.6 变量与数据交互的方式:移动
多个变量可能会在程序中通过不同的方式相互影响。比如下列的例子
|
|
显而易见的,我们先定义了一个变量 x 让它的值变成了 5,然后又让一个变量 y 与 x 也就是 5 绑定。这样的话 x 和 y 的值都是 5。并且由于 5 是一个整型,其大小固定且已知,这两个 5 都会入栈。
但是如果是 String 类型,情况就有所不同了:
|
|
这看起来非常相似,所以我们可能会假设它的工作方式是相同的:也就是说,第三行会复制 s1 中的值并将其绑定到 s2。但这并不是实际发生的真实情况。
看看下图,了解 String 在底层发生了什么。String 由三部分组成,如图左侧所示:一个指向存放字符串内容的内存的指针、一个长度和一个容量。这组数据存储在栈上。右侧是堆上存放内容的内存。
长度是指 String 的内容目前使用的内存大小(以字节为单位)。容量是指 String 从分配器那里获得的内存总大小(以字节为单位)。长度和容量之间的差异很重要,但在当前上下文中并不重要,因此暂时可以忽略容量。
当我们把 s1 赋值给 s2 时,String 数据会被复制,这意味着我们复制了栈上的指针、长度和容量。我们不会复制指针所指向的堆上的数据。
之前,我们说过当变量超出作用域时,Rust 会自动调用 drop 函数并清理该变量的堆内存。但上图显示两个数据指针都指向相同的位置。这是一个问题:当 s2 和 s1 超出作用域时,它们都会尝试释放同一块内存。这被称为 双重释放错误,也是我们之前提到的内存安全漏洞之一。重复释放内存会导致内存损坏,这可能会引发安全漏洞。
为确保内存安全,在行 let s2 = s1; 之后,Rust 将 s1 视为不再有效。因此,当 s1 超出作用域时,Rust 无需释放任何内容。看看在 s2 创建后尝试使用 s1 会发生什么;它将无法工作:
|
|
你会得到类似这样的错误,因为 Rust 阻止你使用失效的引用:
如果你在其他语言中听说过浅拷贝和深拷贝这两个术语,那么复制指针、长度和容量而不复制数据的概念听起来可能就像是在进行浅拷贝。但由于 Rust 也会使第一个变量失效,所以它不是被称为浅拷贝,而是被称为 移动。在这个例子中,我们会说 s1 被移动到了 s2。
这样就解决了我们的问题!当只有 s2 有效时,当它超出作用域时,它将单独释放内存,我们就完成了。
此外,还有一个由此暗示的设计选择:Rust 永远不会自动创建数据的 “深度” 副本。因此,任何自动复制都可以被认为是运行时性能上成本不高的。
4.1.6.1 变量重新赋值时的内存管理
对于作用域、所有权以及通过 drop 函数释放内存之间的关系,情况也正好相反。当你给一个现有变量赋予一个全新的值时,Rust 会立即调用 drop 并释放原始值的内存。例如,考虑以下代码:
|
|
我们最初声明一个变量 s 并将其绑定到值为 "hello" 的 String。然后我们立即创建一个新的 String,其值为 "ahoy",并将其赋给 s。此时,没有任何东西再引用堆上的原始值了。
因此原始字符串立即超出作用域。Rust 会运行 drop 函数处理它,并且内存会立即释放。当我们最后打印这个值时,它将是 "ahoy, world!"。
4.1.7 克隆(Clone)
如果我们想要深度复制 String 的堆数据,而不仅仅是栈数据,我们可以使用一个常见的方法——clone。我们将在第 5 章讨论方法语法,但由于方法在许多编程语言中都是常见特性,你可能之前已经见过它们了。
这里是一个 clone 方法使用的示例:
|
|
这种方式运行得很好,其中堆数据确实被复制了。
4.1.8 仅栈数据(Stack-Only Data): 复制(Copy)
还有一个我们还没讨论过的细节。这段使用整数的代码,是有效且可以运行的:
|
|
但这段代码似乎与我们刚刚学到的知识相矛盾:我们没有调用 clone,但 x 仍然有效,并且没有被移动到 y。
原因是像整数这样在编译时具有已知大小的类型完全存储在栈上,因此实际值的副本可以快速创建。这意味着在我们创建变量 y 之后,没有理由阻止 x 保持有效。换句话说,在这里深拷贝和浅拷贝没有区别,调用 clone 不会与通常的浅拷贝产生任何不同,因此我们可以省略它。
Rust 有一种特殊的特性叫做 Copy trait,我们可以将它用于存储在栈上的类型(比如整数)。如果一个类型实现了 Copy trait,那么使用该类型的变量在赋值给其他变量时不会发生移动(move),而是进行简单的复制(copy),因此原变量在赋值后仍然有效。
如果某个类型或其任何部分实现了 Drop trait,Rust 将不允许我们为该类型添加 Copy 注解。当一个类型需要在值离开作用域时执行特殊操作时,如果我们为其添加 Copy 注解,就会导致编译时错误。
那么哪些类型实现了 Copy trait 呢?你可以查看具体类型的文档来确认,但一般来说,任何简单的标量值集合都可以实现 Copy,而需要分配内存或是某种资源的形式的类型则不能实现 Copy。以下是一些实现了 Copy trait 的类型:
- 所有整数类型,例如
u32。 - 布尔类型
bool,其值为true和false。 - 所有浮点数类型,例如
f64。 - 字符类型
char。 - 元组(
tuple),当且仅当其包含的所有类型都实现了Copy时。例如,(i32, i32)实现了 Copy,但(i32, String)则没有。
4.1.9 函数与所有权
将值传递给函数的机制与将值赋给变量的机制相似。将变量传递给函数会移动或复制,就像赋值一样。比如下列代码:
|
|
如果我们尝试在调用 takes_ownership 之后使用 s,Rust 会抛出一个编译时错误。这些静态检查可以保护我们免犯错误。尝试向 main 添加代码,使用 s 和 x,看看你可以在哪里使用它们,以及所有权规则如何阻止你这样做。
4.1.10 返回值和作用域
返回值也可以转移所有权。如下所示的代码:
|
|
变量的所有权每次都遵循同样的模式:将值赋给另一个变量会移动它。当包含堆上数据的变量超出作用域时,如果没有将数据所有权移动到另一个变量,值将会被 drop 清理。
虽然这样可行,但每次函数获取所有权然后再返回所有权有点繁琐。如果我们想让一个函数使用某个值但不获取其所有权怎么办?如果我们要再次使用传递进来的任何内容,不仅需要将其传递回去,还需要处理函数体中可能要返回的任何数据,这确实很烦人。
Rust 确实允许我们使用元组返回多个值:
|
|
但这对于一个应该很常见的概念来说,仪式太多,工作也太繁琐。幸运的是,Rust 有一种使用值而不转移所有权的功能——引用(Reference)。
[!TIP]
通过上述的这个例子,我们可以清除的知道一点:我们可以通过返回一个元组来让一个函数可以返回多个值。
4.2 引用(References)和借用(Brorrowing)模型
对于上面那个例子来说,我们必须将 String 返回给调用函数,这样我们才能在调用 calculate_length 之后仍然使用 String ,因为 String 已经被移动到 calculate_length 中了。相反,我们可以提供一个指向 String 值的 引用。引用 类似于指针,它是一个地址,我们可以通过这个地址来访问存储在该地址的数据;这些数据由其他某个变量拥有。与指针不同,引用 保证在其生命周期内始终指向特定类型的有效值。
以下是定义和使用一个参数为对象引用而不是值所有权的 calculate_length 函数的方法:
|
|
首先,请注意变量声明和函数返回值中的所有元组代码都不见了。其次,请注意我们将 &s 传递给 calculate_length ,在其定义中,我们使用 &String 而不是 String 。这些 & 代表 引用,它们允许你在不拥有值的情况下引用它。具体关系如下图所示:
[!NOTE]
使用
&进行引用的反操作是解引用,这通过解引用操作符*完成。
重新聚焦到上面的代码中,&s1 语法让我们能够创建一个 引用,它引用 s1 的值但并不拥有它。由于 引用 不拥有它,当 引用 不再被使用时,它指向的值不会被丢弃。
同样,函数的签名使用 & 来指示参数 s 的类型是指针。让我们添加一些解释性注释:
|
|
变量 s 的有效范围与任何函数参数的有效范围相同,但当 s 停止使用时,引用所指向的值不会被丢弃,因为 s 没有所有权。当函数以引用作为参数而不是实际值时,我们不需要返回值来交还所有权,因为我们从未拥有过这些值。
我们称创建引用的行为为 借用(Brorrowing)。就像现实生活中一样,如果一个人拥有某物,你可以向他们借用。用完后,你必须归还。你并不拥有它。
接下来让我们看看如果我们尝试对一个我们借用来的值进行修改,会发生什么:
|
|
在这个代码中,我们将 s1 的 String 借用给了函数 change 中的 s,并尝试在 s 的作用域中修改 String,但是编译后会发现如下报错:
这是因为和变量默认的不可变一样,引用 默认也是不可变的。所以修改一个我们借用来的值,是不被允许的。
4.2.1 可变的引用(mutable references)
我们可以对上述代码稍作修改,让它按照我们的意愿运行:
|
|
修改过后就可以正常编译运行,我们可以看见终端中正确的打印出来我们想要的 “Hello, World” 了。
我们只是将函数的入参改为预期接受一个 &mut String 类型,即一个 可变的字符串类型;同样的,我们也要在调用的时候传入对应的类型。
不过 可变引用 有一个巨大限制:只能存在一个可变引用。当我们将一个值通过 可变引用 借用给了一个变量 a,那么我们就不能在 a 的作用域内再次借给 b。比如下列的代码:
|
|
其报错的代码如下:
报错中提到:我们不能在同一时间多次 可变的借用 s。与之相对的,以下的代码都是是可以正常运行的:
|
|
但是还有一种情况,我们自然而然的想到,我一个变量 可变引用,另一个变量 不可变引用,可否成功编译呢?如下列代码:
|
|
编译结果如下:
报错说,我们不能同时有 可变引用 和 不可变引用。不可变引用 的用户不会期望值突然从他们下面改变!然而,允许多个 不可变引用,因为任何只是读取数据的人都没有能力影响其他人读取数据。
这种限制的好处是 Rust 可以在编译时防止 数据竞争。数据竞争 类似于竞态条件,当出现以下三种行为时会发生:两个或多个指针同时访问相同的数据、至少有一个指针正在写入数据、没有机制用于同步对数据的访问。数据竞争 会导致未定义行为,当你试图在运行时追踪它们时,诊断和修复它们可能很困难;Rust 通过拒绝编译存在 数据竞争 的代码来防止这个问题!
尽管 借用错误 有时令人沮丧,但请记住,这是 Rust 编译器在编译时(而非运行时)指出了一个潜在的 bug,并明确告诉你问题所在。这样你就不必去追踪为什么你的数据不是你预期的样子了。
4.2.2 悬垂引用(Dangling References)
在有指针语言中,很容易通过释放一些内存同时保留对该内存的指针,从而错误地创建 悬垂指针——一个引用了内存中可能已被分配给其他人的位置的指针。相比之下,在 Rust 中,编译器保证引用永远不会是 悬垂引用:如果你有一个指向某些数据的引用,编译器将确保这些数据不会在引用它们之前超出作用域。
让我们尝试创建一个 悬垂引用,看看 Rust 如何通过编译时错误来阻止它们:
|
|
编译报错的结果如下:
这个错误信息指的是我们尚未讨论的一个特性:生命周期。我们将在第 10 章详细讨论 生命周期。但是,如果你忽略关于 生命周期 的部分,这条信息确实包含了导致这段代码成为问题的关键:
this function’s return type contains a borrowed value, but there is no value for it to be borrowed from
因为 s 是在 dangle 内部创建的,当 dangle 的代码执行完毕时, s 会被释放。但我们试图返回它的引用。这意味着这个引用将指向一个无效的 String 。这不行!Rust 不会允许我们这样做。
解决方案是直接返回一个值,而不是其引用:
|
|
这没有任何问题。所有权 被转移出去,没有任何内容被释放。
4.2.3 引用的规则
- 任何时间,只能有一个可变引用或者多个不可变引用,但是不能两者都有。
- 引用必须始终有效,即不能出现悬挂引用。
4.3 切片(Slice)
切片 让你能够引用集合中一个连续的元素序列,它是一种引用,因此不拥有所有权。
我们来看一个编程问题:编写一个函数,它接受一个以空格分隔的单词字符串,并返回第一个单词。如果字符串中没有空格,则整个字符串就是一个单词,函数应该返回整个字符串。
为了更好地理解 切片 的作用,我们先尝试不使用它来解决这个问题。
|
|
first_word 函数接受一个 &String 类型的参数,这很好,因为我们不需要它的所有权。那么,我们该返回什么呢?我们没有一个直接的方式来描述字符串的一部分。一种方法是返回第一个单词末尾的索引,也就是空格的位置。
|
|
为了逐个检查字符串中的字节,我们使用 s.as_bytes() 将 String 转换为字节数组。然后,我们用 iter().enumerate() 来遍历数组。
我们会在第 13 章更详细地学习 迭代器。现在你只需要知道,iter 方法会返回集合中的每一个元素,而 enumerate 则会把 iter 的结果包装成一个元组,其中第一个元素是索引,第二个是元素的引用。这比我们手动追踪索引要方便得多。
enumerate 返回元组,我们可以用 模式(patterns)来解构它。我们会在第 6 章详细讨论 模式。在 for 循环中,我们指定了一个 模式:i 用于索引,&item 用于单个字节。因为 .iter().enumerate() 返回的是元素的引用,所以我们在模式中也使用了 &。
在循环中,我们使用字节字面量 b' ' 来查找空格。如果找到了,就返回索引;否则,返回字符串的长度。
现在我们有了第一个单词的索引,但这带来了一个新问题。这个 usize 类型的索引是独立于 &String 的,它的有效性无法得到保证。换句话说,它只是一个数字,但它所代表的上下文可能会发生改变。看下面这个例子:
|
|
这段代码可以正常编译。即使在调用 s.clear() 之后我们使用了 word,它也不会报错,因为 word 的值 5 和 s 的状态完全没有关联。如果我们尝试用 word 的值去提取 s 的第一个单词,就会出错,因为 s 的内容已经变了。
不得不时刻担心 word 中的索引与 s 中的数据不同步,这既繁琐又容易出错。如果我们再写一个 second_word 函数,情况会变得更糟:
|
|
现在我们需要跟踪一个起始索引和一个结束索引,这些值都是从特定数据计算出来的,但它们与原始数据又是独立的。我们有三组不相关的变量,必须确保它们始终保持同步。
幸运的是,Rust 提供了 字符串切片(string slices)来完美地解决这个问题。
4.3.1 字符串切片(string slice)
字符串切片 是对 String 元素的一个连续序列的引用,看起来像这样:
|
|
切片 不是对一整个字符串的引用,而是其的一部分。比如说 “hello” 在字符串中的切片就是从第 0 个字符到第 5 个字符,不包含第五个字符。所以它是 &s[0..5]。一个字符切片的格式就是你想要切片的字符串的引用加上 [start_index..end_index],其中 start_index 是第一个字符的位置,而 end_index 是最后一个字符的后一个位置(因为 Rust 总是左开右闭)。在字符串切片的内部,存储着两个信息,第一是切片开始的位置,第二个是字符长度,通过 end_index 减去 start_index 得到。所以在 let world = &s[6..11] 之中的 world,就是一个包含一个指向 s 的第 6 个字符,并且长度为 5 的指针。具体如下图所示:
在 Rust 的 .. 表达中,如果这个序列是从 0 开始的,那么 0 是可以省略,意味着下面两个表述是等价的:
|
|
同样的,你可以两端都舍去来表达获取整个字符串的切片:
|
|
[!NOTE]
字符串切片的范围索引必须出现在有效的 UTF-8 字符边界上。如果你试图在多字节字符的中间创建一个字符串切片,你的程序将会以错误退出。
有了以上的基础,我们来重新编写我们的 find_first_word 函数,我们可以通过返回一个 字符串切片 来找到第一个单词。其中 字符串切片 类型写做 &str:
|
|
现在当我们调用 find_first_word 时,我们会得到一个与字符串强相关的数据相关联的 切片。这个 切片 由指向切片起点的引用和切片中的元素数量组成。
同样,我们可以通过 字符串切片 来编写一个 find_second_word 函数:
|
|
我们现在拥有一个更简单的 API,因为编译器将确保 String 中的引用保持有效。还记得 之前程序中国的 bug 吗?当时我们得到了第一个单词的索引,但随后清除了字符串,导致索引无效?那段代码在逻辑上是不正确的,但并没有立即显示错误。如果我们继续尝试用清空后的字符串使用第一个单词的索引,问题会稍后显现。切片 使这个 bug 不可能发生,并让我们能更快地发现代码中的问题。使用 find_first_word 的 切片 版本会在编译时抛出错误:
|
|
回想 借用规则,如果我们对一个事物有 不可变引用,我们就不能同时获取一个 可变引用。因为 clear 需要截断 String ,它需要获取一个 可变引用。调用 clear 后的 println! 使用 word 中的引用,所以在那一点上 不可变引用 必须仍然有效。Rust 禁止 clear 中的 可变引用 和 word 中的 不可变引用 同时存在,编译会失败。Rust 不仅使我们的 API 更容易使用,还在编译时消除了整类错误!
回想我们之前讨论过 字符串字面量 存储在二进制文件中。现在我们已经了解了 切片,可以正确理解 字符串字面量:
|
|
这里的类型是 &str : 它是一个指向二进制中特定位置的 切片。这也是为什么 字符串字面量 是不可变的; &str 是一个 不可变引用。
4.3.2 字符串切片作为函数入参
了解 字符串切片 之后,我们可以进一步修改我们的 find_first_word 函数。现在的函数声明可以是这样的:
|
|
更有经验的 Rust 使用者 会编写上述的函数,因为它允许我们使用同一个函数处理 &String 值和 &str 值:当我们想要处理 &String 的时候,只需要使用 &s[..] 即可,或者我们可以直接写 &String,因为两者是等价的:
|
|
如果我们有一个 字符串切片,可以直接传递。如果我们有一个 String ,可以传递 String 的 切片 或 String 的引用。这种灵活性利用了 deref 强制转换,这一特性我们将在第 15 章的 “函数和方法中的隐式 deref 强制转换” 部分进行介绍。
4.3.3 其他切片
字符串切片,正如你所想象的那样,是专门针对字符串的。但还有一个更通用的 切片 类型。考虑这个数组:
|
|
就和我们引用字符串一样,我们可能想要引用数组的一部分。我们会这样做:
|
|
这个 切片 的类型是 &[i32] 。它和 字符串切片 一样工作,通过存储对第一个元素的引用和长度。你会用这种类型的 切片 来处理各种其他集合。当我们在第 8 章讨论向量时,我们会详细讨论这些集合。
Chapter 5:结构体(Structs)
结构体是一种自定义数据类型,它允许你将多个相关的值组合在一起并命名,这些值共同构成一个有意义的组。如果你熟悉面向对象语言,结构体就像对象的数据属性。在本章中,我们将比较和对比元组和结构体,以扩展你已经掌握的知识,并展示何时使用结构体是组织数据更好的方式。
5.1 定义和实例化结构体
与之前介绍过的元组很像,使用 结构体 可以让你把不同类型的变量聚集在一起。但是与元组不同的是,结构体要求你给每一个元素给一个命名,这个特性使得结构体可以更加灵活的引用元素,而不必像元组那样依照固定顺序。
定义一个结构体,我们使用关键字 struct 加上这个结构体的名称,并用大括号将里面不同类型的元素给括起来,之后每个元素都要求有一个名称,并且必须显式声明这个元素的数据类型。具体如下所示:
|
|
在定义了结构体之后,要使用它,我们通过为每个字段指定具体值来创建该结构体的 实例。我们通过声明结构体的名称,然后添加包含键值对的大括号来创建实例,其中键是字段的名称,值是我们想要存储在这些字段中的数据。我们不必按照在结构体中声明的顺序来指定字段。换句话说,结构体定义就像类型的通用模板,而实例用特定数据填充该模板来创建类型的值。如下所示:
|
|
和 C 语言一样,我们使用 . 来引出一个结构体成员:
|
|
注意:在 Rust 中,我们只能控制整个结构体的可变性:我们不能要求某些部分是可变的,某些部分是不可变的,我们只能让整个实例是可变的或者不可变的。同样的,我们可以通过在函数中构建一个结构体然后返回出来:
|
|
给函数参数命名与结构体字段同名是合理的,但不得不重复 email 和 username 字段名和变量确实有点繁琐。如果结构体有更多字段,重复每个名称会更加令人烦恼。幸运的是,有一个方便的简写方式!
字段初始化简写语法
因为参数名与字段名都完全相同,我们可以使用 字段初始化简写语法(field init shorthand)来重写 build_user,这样其行为与之前完全相同,不过无需重复 username 和 email 了:
|
|
这里我们创建了一个新的 User 结构体实例,它有一个叫做 username 的字段。我们想要将 username 字段的值设置为函数参数 username 的值。因为 username 字段与 username 参数有着相同的名字,只需编写 username 而不是 username: username。
使用结构体更新语法从其他实例创建实例
使用旧实例的大部分值但改变其部分值来创建一个新的结构体实例通常是很有用的。这可以通过 结构体更新语法(struct update syntax)实现。
首先展示不使用更新语法时,如何在 user2 中创建一个新 User 实例。我们为 email 设置了新的值,其他值则使用了之前创建的 user1 中的同名值:
|
|
使用结构体更新语法,我们可以通过更少的代码来达到相同的效果。.. 语法指定了剩余未显式设置值的字段应有与给定实例对应字段相同的值:
|
|
上面的代码也会在 user2 中创建一个新实例,该实例有不同的 email 值,但其他字段与 user1 相同。..user1 必须放在最后,以指定其余的字段应从 user1 的相应字段中获取其值,但我们可以选择以任何顺序为任意数量的字段指定值,而不用考虑结构体定义中字段的顺序。
需要注意的是,结构体更新语法就像带有 = 的赋值,因为它移动了数据,就像我们在 “变量与数据交互的方式(一):移动” 部分讲到的一样。在这个例子中,我们在创建 user2 后不能再使用 user1,因为 user1 的 username 字段中的 String 被移到 user2 中。如果我们给 user2 的 email 和 username 都赋予新的 String 值,从而只使用 user1 的 active 和 sign_in_count 值,那么 user1 在创建 user2 后仍然有效。active 和 sign_in_count 的类型是实现 Copy trait 的类型,所以我们在 “变量与数据交互的方式(二):克隆” 部分讨论的行为同样适用。
5.1.2 元组结构体(Tuple Struct)
Rust 中也允许结构体像元组那样创建,我们称之为 元组结构体。元组结构体只有结构体名称提供的意义,但是没有与其元素关联的名称含义,因为它只提供了元素类型。当你想给整个元组命名,但是又想让他和其他元组成为不同的数据类型的时候,元组结构体十分好用;并且当你觉得常规结构体的结构体成员的命名过于复杂的时候,元组结构体也可以派上用场。
要定义一个元组结构体,以 struct 关键字开始,然后是结构体名称,接着是元组中的类型。例如,这里我们定义并使用了两个名为 Color 和 Point 的元组结构体:
|
|
虽然从结构体成员类型看上去 Color 和 Point 是相同的,但是它们任然是不同数据类型。因此,black 和 origin 也是两个不同类型的变量,因为他们是不同类型的实例。并且,如果一个函数的入参要求的数据类型是 Color,那么我们便不能传入 origin 作为其参数,因为 origin 的数据类型是 Point。与元组相同的是,我们也可以对元组结构体进行解构:
|
|
这样,我们就可以通过 origin.x 这样的方式引出结构体成员了。
5.1.3 单元结构体(Unit-Like Struct)——无结构体成员的结构体
Rust 中可以定义没有任何结构体成员的结构体,我们称之为 单元结构体,它与元组中的 unit 类型很像。当你需要在某个类型上实现一个 trait,但又不想在该类型中存储任何数据时,单元结构体就很有用。我们将在第 10 章讨论 trait。下面是一个声明并实例化名为 AlwaysEqual 的单元结构体的示例:
|
|
要定义 AlwaysEqual,我们使用 struct 关键字、结构体名称,然后加上一个分号。不需要大括号或圆括号! 接着,我们可以用类似的方式在 subject 变量中获取 AlwaysEqual 的实例:直接使用定义的名字,不带任何大括号或圆括号。
假设之后我们要为这个类型实现某种行为,使得 AlwaysEqual 的每个实例总是等于其他任何类型的实例(可能是为了测试目的而设定的固定结果)。我们不需要任何数据来实现这种行为! 在第 10 章,你将学习如何定义 trait 并在任何类型(包括单元结构体)上实现它们。
5.1.4 结构体的所有权
注意到在我们本章第一个例子的 User 结构体中,我们对其结构体成员的 username 和 email 都是使用的 String 类型,而不是一个 &str 类型。这是因为我们想要结构体拥有其所有成员的所有权,并且这些数据在该结构体的生命周期中始终有效。
当然,我们也可以让结构体成员的数据类型是被其他对象拥有的数据类型的引用,但是实现这个点要求了一个 Rust 的中的概念——生命周期(lifetime)。这是一个后续会讨论的特性,在这里我们只需要知道,生命周期确保结构体引用的数据在其存在期间始终有效。假设你尝试在结构体中存储一个未指定生命周期的引用,就像下面这样;这是行不通的:
|
|
你会得到如下的报错:
在第 10 章,我们将讨论如何修复这些错误,以便你可以在结构体中存储引用,但目前我们将使用类似于 String 的拥有类型来修复这些错误,而不是使用类似于 &str 的引用。
5.2 使用结构体
要了解我们何时可能需要使用结构体,让我们编写一个计算矩形面积的程序。我们将从使用单个变量开始,然后重构程序,直到使用结构体为止。
让我们使用 Cargo 创建一个新的二进制项目,名为 rectangles,它将接收以像素为单位的矩形宽度和高度,并计算矩形的面积:
|
|
编译结果如下:
这段代码通过调用 calculate_area 函数,使用每个维度来成功计算出矩形的面积,但我们还可以做更多来使这段代码更加清晰易读。
函数 area 本应计算一个矩形的面积,但我们编写的函数有两个参数,而且程序中没有任何地方明确说明这两个参数之间的关系。将宽度和高度组合在一起会使代码更易读、更易于管理。
5.2.2 使用元组重构
以下的程序使用的是 元组 来重构整个函数:
|
|
在某种意义上,这个程序更好。元组让我们添加了一些结构,我们现在只传递一个参数。但在另一种意义上,这个版本更不清晰:元组不命名它们的元素,所以我们不得不索引到元组的各个部分,这使得我们的计算不那么明显。
将宽度和高度混淆对于面积计算来说无关紧要,但如果我们要在屏幕上绘制矩形,这就很重要了!我们需要记住 width 是元组的索引 0 ,而 height 是元组的索引 1 。如果其他人要使用我们的代码,这会让他们更难弄清楚并记住。因为我们没有在我们的代码中传达数据的含义,所以现在更容易引入错误。
5.2.3 使用结构体重构:增加更多意义
我们使用 结构体 来重构这个程序,通过给结构体成员的命名来优化程序。我们将元组修改成结构体:
|
|
在这里我们定义了一个结构体 Rectangle,其结构体成员是宽度 width,高度 height,他们的数据类型都是 f64。然后函数 calculate_area 的入参也是修改为 Rectangle 的引用,因为我们不需要修改其内容,只是简单的对其成员做一个乘法。
现在,我们的长宽都是高度关联的,而不是像元组那样用 0、1 来表述。这对于清晰度来说是一个优势。
5.2.4 派生特性
在调试程序时,能够打印出 Rectangle 的实例并查看其所有字段的值会很有用。但是直接使用 println! 是不行的:
|
|
会得到如下报错:
println! 宏能够进行多种格式化操作,默认情况下,花括号会指示 println! 使用称为 Display 的格式化方式:这种输出形式是直接面向最终用户的。我们目前见过的原始类型都默认实现了 Display,因为你只会想用一种方式向用户展示 1 或其他原始类型的值。但对于结构体来说,println! 该如何格式化输出就不那么明确了,因为存在更多显示可能性:是否需要逗号?是否要打印花括号?所有字段都应该显示吗?由于这种不确定性,Rust 不会尝试猜测我们的意图,因此结构体没有提供默认的 Display 实现来配合 println! 和 {} 占位符使用。
让我们试试看!现在 println! 宏调用会写成这样:println!("rect1 is {rect1:?}");。在花括号内加上 :? 标识符是告诉 println! 我们要使用名为 Debug 的输出格式。Debug trait 能让我们以对开发者友好的方式打印结构体,这样在调试代码时就能查看它的值了:
|
|
编译后依旧报错:
但是人性化的编译器提示我们:add #[derive(Debug)] to Rectangle or manually impl Debug for Rectangle。这是因为 Rust 本身不自带输出调试信息,我们需要手动显式的为我们的结构体开启这个功能——在结构体之前加上 #[derive(Debug)]:
|
|
加上之后再次编译运行代码就会看到如下输出:
太好了!虽然输出不是最美观的,但它显示了该实例中所有字段的值,这在调试时肯定会有帮助。当我们的结构体更复杂时,有一个更易于阅读的输出会很有用;在这种情况下,我们可以在 println! 字符串中使用 {:#?} 而不是 {:?} 。在这个例子中,使用 {:#?} 风格将输出以下内容:
[!NOTE]
如果有多个结构体需要使用这个派生功能,我们需要给每个你想要使用派生功能的结构体声明之前都加上
#[derive(Debug)]。
使用 Debug 格式打印出值的另一种方法是使用 dbg! 宏,该宏会获取表达式的所有权(与 println! 不同,println! 获取的是引用),打印出 dbg! 宏在代码中调用位置所在的文件和行号,以及该表达式计算出的结果值,并返回该值的所有权。
比如下列这个例子:
|
|
我们可以将 dbg! 宏包裹在表达式 30f64 * scale 外围,由于 dbg! 会返回该表达式的值的所有权(即不影响原值传递),因此 width 字段最终获得的值就和没有调用 dbg! 时完全一样。在接下来的调用中,我们不希望 dbg! 获取 rect1 的所有权,所以传入的是 rect1 的引用。以下是这个示例的输出结果:
从输出中可以看到,第一行内容来自 src/main.rs 第 10 行的调试输出,我们正在检查表达式 30f64 * scale 的计算过程,其结果值为 60.0(Rust 为整数/浮点数实现的 Debug 格式化输出会直接显示其数值)。src/main.rs 第 14 行的 dbg! 调用输出了 &rect1 的值,也就是 Rectangle 结构体的内容。这里的输出使用了 Rectangle 类型提供的格式化调试显示方式。当你需要弄清楚代码的实际执行过程时,dbg! 宏会非常有用!
除了 Debug trait 外,Rust 还提供了许多可以通过 派生(derive)属性来使用的 trait,它们能为我们的自定义类型添加实用功能。我们将在第 10 章探讨如何为这些 trait 实现自定义行为,以及如何创建你自己的 trait。Rust 中除了派生外还有许多其他属性,更多信息请参阅《Rust 参考手册》中的 “属性” 章节。
我们当前的 calculate_area 函数功能非常特定化:它只能计算矩形的面积。如果能将这个行为与 Rectangle 结构体更紧密地绑定会更好,因为这个函数无法适用于其他类型。接下来我们将探讨如何通过把 calculate_area 函数改造成 Rectangle 类型的 方法 来继续重构这段代码。
5.3 方法(Method)
方法和函数类似,都可以有入参、返回值,并且在内部也可以调用其他方法或者函数。但是与函数不同的是, 方法 是在结构体内部实现的,并且方法的第一个入参必须是 self,代表调用方法的结构体实例本身。
5.3.1 定义一个方法
我们可以修改 calculate_area,让它成为一个 方法:
|
|
在这里,我们使用 impl(implementation)为结构体添加关联方法。具体做法是:在 impl 关键字后指定目标结构体名称(本例中是 Rectangle),然后在 {} 代码块内定义方法。我们将原来的 calculate_area 函数移植到这个实现块中,并将第一个参数改为 &self - 这样就成功将函数转换为 Rectangle 结构体的 方法。调用时,和使用结构体字段一样,只需通过 . 运算符即可(例如 rect.calculate_area()),Rust 会自动将调用该方法的实例填入 self 参数。
在 calculate_area 方法的原型中,我们使用 &self 替代了 rectangle: &Rectangle。这里的 &self 实际上是 self: &Self 的简写形式。在 impl 代码块内部,Self 类型就是该实现块对应类型的别名(本例中即 Rectangle)。按照 Rust 的约定,方法的第一个参数必须是一个名为 self 的 Self 类型参数,因此 Rust 允许我们在第一个参数位置直接简写为 self。需要注意的是,我们仍然需要在简写的 self 前添加 & 符号(即 &self),这表明该方法 不可变地借用 了 Self 实例(与我们之前使用 rectangle: &Rectangle 的意图一致)。方法可以通过以下三种方式处理 self 参数:获取所有权(直接使用 self)、不可变借用(如本例的 &self)、或者 可变借用(&mut self)—— 这与处理其他常规参数的方式完全一致。
使用 方法 而非函数的主要原因,除了提供更简洁的方法调用语法(不必在每个方法签名中重复声明 self 的类型)外,更在于代码的组织性。通过将所有针对类型实例的操作集中定义在同一个 impl 代码块中,我们避免了让后续使用我们代码的开发者在库的不同位置四处寻找 Rectangle 的功能实现。
并且我们可以实现一个与结构体成员相同名字的 方法:
|
|
运行结果如下:
在这个实现中,我们定义当实例的 width 成员值大于 0 时,width 方法 返回 true,值小于等于 0 时返回 false:这种同名方法可以自由访问同名成员,实现任意逻辑。在 main 函数中:
- 当使用
rect1.width()带括号形式时,Rust 明确识别这是调用width方法 - 当使用
rect1.width无括号形式时,Rust 则自动识别为访问width成员
在 Rust 中,我们经常会(但并非总是)将 方法 命名为与字段同名,使其仅返回字段值而不进行其他操作。这类方法被称为 getter 方法。与其他语言不同,Rust 不会自动为结构体字段生成 getter,需要手动实现。这种设计主要有以下优势:
-
访问控制:
- 可以将字段设为 私有(private),同时将 getter 方法设为 公开(public)
- 从而实现该字段的只读访问权限,作为类型公开 API 的一部分
-
封装性
1 2 3 4 5 6 7 8 9 10 11pub struct Rectangle { width: u32, // 私有字段 height: u32, // 私有字段 } impl Rectangle { // 公开的 getter 方法 pub fn width(&self) -> u32 { self.width } } -
设计灵活性:
- 即使后续修改内部数据结构,只要保持 getter 接口不变,就不会破坏外部代码
- 可以在 getter 中添加简单的验证逻辑或计算
关于 公有(public)和 私有(private)的具体概念,以及如何将字段或方法指定为公有/私有,我们将在第 7 章详细讨论。这种显式的访问控制是 Rust 封装性的重要体现,也是其安全保证的基础之一。
[!TIP]
-> 操作符去哪了?
在 C 和 C++ 中,调用方法需要使用两个不同的操作符:
- 直接调用对象方法时使用
.- 调用对象指针的方法时使用
->(需要先解引用指针)换句话说,如果
object是一个指针,object->something()就类似于(*object).something()。而 Rust 没有与
->等效的操作符,取而代之的是 Rust 的 自动引用和解引用 功能。方法调用 是 Rust 中少数几个具有这种行为的地方之一。工作原理
当你使用
object.something()调用 方法 时,Rust 会自动添加&、&mut或*以使object与方法签名匹配。也就是说,以下两种写法是等价的:
1 2p1.distance(&p2); (&p1).distance(&p2);显然第一种写法更加简洁。这种 自动引用 行为之所以能工作,是因为 方法 有明确的接收者——即
self的类型。根据接收者和方法名,Rust 可以明确判断方法是在:
- 读取数据(
&self)- 修改数据(
&mut self)- 还是获取所有权(
self)Rust 将 方法 接收者的 借用 隐式化,这一特性在实践中大大提升了 所有权 的易用性。
5.3.2 有多个入参的方法
现在,我们来实现另一个 方法 can_hold,用于比较一个矩形是否能够容纳下另一个矩形,其本质就是比较两个矩形的面积:
|
|
输出结果如下:
我们知道要定义一个 方法,因此它应该位于 impl Rectangle 代码块中。这个方法将被命名为 can_hold,它会以 不可变借用 的形式接收另一个 Rectangle 作为参数。通过观察调用该方法的代码 rect1.can_hold(&rect2),我们可以确定参数类型:这里传入了 &rect2,即对 rect2 实例的 不可变借用(rect2 是 Rectangle 的一个实例)。这样的设计是合理的,因为我们只需要读取 rect2 的数据(而不需要修改,否则就需要 可变借用),同时我们希望 main 函数能保留 rect2 的 所有权 以便在调用 can_hold 方法后继续使用它。
can_hold 方法 的返回值将是一个布尔值,其实现逻辑会分别检查当前矩形实例(self)的宽度和高度是否大于另一个 Rectangle 的宽度和高度。
当然,Rust 并不要求我们将一个结构体的 方法 都写在一个 impl 代码块中,我们可以按照下面的形式将同一个结构体的不同方法都单独用一个代码块声明:
|
|
虽然这是可行的,但是我们仍然建议放在一起,因为这样集中化的处理会更有利于开发者管理结构体的 方法。
5.3.3 关联函数(Associated Functions)
所有在 impl 代码块中声明的函数都叫做 关联函数,但是不是所有的 关联函数 都是 方法。因为 方法 的第一个入参必须是 self 用来指定实例本身;但是我们依然可以定义一个入参不含有 self 的函数,因为他们并不需要操作具体的类型实例。我们之前已经使用过了像这样的函数 : String 类型中的 String::from。
在 Rust 中,不作为 方法 的 关联函数 常被用作 构造器,用于返回结构体的新实例。这类函数通常被命名为 new,但要注意 new 并非语言内置关键字,只是一个约定俗成的命名惯例。例如,我们可以为 Rectangle 实现一个名为 square 的 关联函数,它只需接收一个尺寸参数,并将该参数同时作为宽度和高度,从而更便捷地创建正方形矩形,而不需要重复指定相同的值两次:
|
|
函数中的 Self 表示拥有这个 关联函数 的结构体的数据类型,在这个例子中就是 Rectangle。
调用 关联函数 和调用其 方法 的运算符也是不同的,调用 方法 的运算符在前文我们已经使用了很多次了,是 .;但是调用一个 关联函数 我们使用的运算符是 ::,这类函数归属于结构体的 命名空间:Rust 中的 :: 语法既用于 关联函数,也用于模块创建的 命名空间(我们将在第 7 章详细讨论模块系统)。
Chapter 6:枚举(Enums)和类型匹配(Pattern Matching)
结构体让你能够将相关的字段和数据组合在一起,比如一个 Rectangle 及其 width 和 height ,而 枚举 则让你能够表示一个值是可能值集合中的一个。例如,我们可能想要表示 Rectangle 是一组可能形状中的一个,这组形状还包括 Circle 和 Triangle 。为此,Rust 允许我们将这些可能性编码为一个 枚举。
让我们来看一个我们可能需要在代码中表达的情况,并了解为什么在这种情况下 枚举 比结构体更有用和更合适。比如说我们需要处理 IP 地址。目前,IP 地址使用两种主要标准:版本四和版本六。由于这些是我们程序可能遇到的 IP 地址的唯一可能性,我们可以 枚举 所有可能的 变体,这也是 枚举 得名的由来。
任何 IP 地址要么是版本四的地址,要么是版本六的地址,但不能同时是两者。IP 地址的这种特性使得 枚举 数据结构非常合适,因为 枚举值 只能是它的某个 变体。版本四和版本六的 IP 地址本质上仍然是 IP 地址,所以在代码处理适用于任何类型 IP 地址的情况时,它们应该被视为同一类型。
根据上述例子,我们可以构建一个名为 IpAddrVersion 的一个 枚举类型:
|
|
现在 IpAddrVersino 就是一个我们自定义的数据类型,我们可以在程序其他地方使用它!
6.1 枚举值
在定义 枚举类型 之后,我们可以按照下列的方式定义其实例:
|
|
同样,我们也可以定义一个入参为 IpAddrVersion 类型的函数:
|
|
使用 枚举 的优势很多。再深入思考一下我们的 IP 地址类型,目前我们没有存储实际 IP 地址数据的方式;我们只知道它的类型。鉴于你在第 5 章刚刚学习了结构体,你可能会想用结构体来解决这个问题:
|
|
但是,与 C 语言的 枚举 不同,Rust 的 枚举 更加强大,我们甚至不需要定义一个结构体,而是只用一个 枚举类型 就可以做到这点:
|
|
在 Rust 中,枚举变体 是可以添加值的,所以不需要额外的结构体来达到目的。同时,我们每定义一个 变体,其本质都是使用了一个构造实例的函数。这里的 IpAddrKind::V4 本质是接收一个 String 类型的入参,然后返回一个 IpAddrKind 类型的变量的函数。
同时,枚举 中的所有成员的值不一定都要相同,我们可以给不同的 枚举变体 不同的值:
|
|
像这里我就让 V4 的值是 4 个 u8 类型的数据,而 V6 则是一个 String 类型的数据。
上述我们通过了多种方式来定义 IP 地址信息,说明在程序中定义一个 IP 地址信息是一个很普遍的需求,所以 Rust 的 标准库 已经帮我们定义好了相关的数据类型,以下则是相关定义:
|
|
可以看到,标准库 中是事先定义好了两个结构体来存储不同版本的 IP 地址信息,然后通过一个 枚举值 来集中起来。同时这个例子我们也可以清楚的看见:我们能给 枚举值 的值中不仅仅可以存储基本的数字类型和字符串类型,还可以是我们自定义的结构体类型,甚至是另一个 枚举类型!并且 标准库 中的数据类型不会比你将会遇见的类型复杂很多!
请注意,尽管 标准库 中包含 IpAddr 的定义,但由于我们没有将 标准库 的定义引入当前作用域,我们仍然可以创建和使用自己的定义而不会产生冲突。我们将在第 7 章中更详细地讨论如何将类型引入作用域。
接下来看下面这个例子:
|
|
这个 枚举类型 有四个成员:
Quit:没有任何值Move:其有两个有名字的值Write:有一个无名的值ChangeColor:有三个无名的值
这样的定义有点类似于结构体:
|
|
但是与结构体不同的是,枚举 把所有的这些 变体 都归为一个数据类型。并且这样使用结构体定义不同的 变体 会让我们函数的入参成为一个大麻烦:我们很难处理入参都是不同的数据类型,在这点上,使用一个 枚举值 将会有很大的好处。
与结构体中的方法一样,我们可以使用 impl 关键字来为 枚举 定义其方法。比如下列我们就给 Message 定义了一个名为 call 的方法:
|
|
方法体将使用 self 获取我们调用了方法的对象的值。在这个例子中,我们创建了一个名为 m 的变量,其值为 Message::Write(String::from("hello")) ,当 m.call() 运行时, self 将是 call 方法体中的值。
6.1.2 选项枚举(Option)
Option 是标准库定义的一个枚举。它可以有一个值,也可以没有。例如,如果你请求一个非空列表中的第一个元素,你会得到一个值。如果你请求一个空列表中的第一个元素,你会什么也得不到:这意味着编译器可以检查你是否处理了所有应该处理的情形;这项功能可以防止在其他编程语言中极为常见的错误。
编程语言的设计常常被认为取决于包含哪些特性,但刻意排除的特性同样重要。Rust 就没有许多其他语言中常见的 null 功能。Null 是一个表示 “无值” 的值。在支持 null 的语言中,变量总是处于两种状态之一:null 或 非 null。
但是 null 会导致很多隐藏的问题:
|
|
开发者必须要手动检查一下当前的这个值是不是 空值 :if(str != null),但是编译器通常不会强制要求你这么做,所以很容易遗漏。
因此,Rust 不提供 null,但是 标准库 提供了一种 枚举值 来表示一个值是否为 空值 。如下是 标准库 中 Option<T> 的定义:
|
|
由于这个 枚举 过于常用,所以它被 prelude 包含,意味着我们不需要显式将其带入作用域。它的 变体 也直接被 prelude 包含,所以我们可以直接使用 Some(T) 和 None 而不必使用 Option:: 的前缀。这种情况下,Option<T> 依旧是一种普通的 枚举 ,并且 Some(T) 和 None 也依然是其 变体 。
**其中 <T> 是我们还没学习过的语法。它是泛型类型(generic)参数,我们将在第 10 章更详细地介绍泛型。需要知道的是, <T> 表示 Some ** 枚举 **的 Option ** 变体 可以容纳任意类型的单个数据,而每次用具体类型替换 T 时,都会使整体 Option<T> 类型变为不同的类型。以下是一些使用 Option 值来容纳数值类型和字符类型的示例:
|
|
**some_number 的类型是 Option<i32> 。 some_char 的类型是 Option<char> ,这是一个不同的类型。Rust 可以推断这些类型,因为我们已经在 Some ** 变体 **中指定了一个值。对于 absent_number ,Rust 要求我们标注整体的 Option 类型:编译器不能仅通过查看一个 None 值来推断对应的 Some ** 变体 将持有哪种类型。在这里,我们告诉 Rust,我们希望 absent_number 是 Option<i32> 类型。
当我们拥有一个 Some 值时,我们知道存在一个值,并且该值被包含在 Some 中。当我们拥有一个 None 值时,从某种意义上说,它和 null 意思相同:我们没有有效的值。那么,拥有 Option<T> 比拥有 null 好在哪里呢?
简而言之,因为 Option<T> 和 T (其中 T 可以是任何类型)是不同的类型,编译器不会让我们将一个 Option<T> 值当作一个确定有效的值来使用。例如,这段代码不会编译,因为它试图将一个 i8 加到一个 Option<i8> 上:
|
|
运行这段代码的报错如下:
换句话说,我们必须先将 Option<T> 转换成 T 类型,才能对其执行 T 类型的操作。这种设计从根本上解决了 null 最常见的陷阱——当某个值实际为 null 时,开发者却误以为它非空。
通过显式要求可能为空的值必须声明为 Option<T> 类型,Rust 帮助你避免错误假设值非空的情况,从而让你对代码更有信心。当你需要使用可能为空的值时,必须通过将该值的类型设为 Option<T> 来明确表明这一点。在使用这个值时,我们必须显式处理值为空的情况。而对于那些类型不是 Option<T> 的值,你可以安全地认为它们绝不会为空。这是 Rust 为了限制 空值 的普遍性并提高代码安全性而做出的深思熟虑的设计决策。
那么当你有一个 Option<T> 类型的值时,如何从 Some 变体中取出 T 值以便使用呢?Option<T> 枚举提供了大量适用于各种场景的方法,你可以在其 [文档](Option in std::option - Rust)中查阅。熟悉 Option<T> 的各种方法对你学习 Rust 将会非常有帮助。
通常来说,要使用一个 Option<T> 值,你需要编写能处理每种 变体 的代码。当值为 Some(T) 时执行某段代码(该代码可以使用内部的 T 值),而当值为 None 时则执行另一段代码(此时没有 T 值可用)。match 表达式正是用于处理 枚举 的控制流结构:它会根据 枚举 的具体 变体 执行不同的代码分支,且这些分支可以访问匹配值内部的数据。
6.2 match——流控制结构
Rust 有一个极其强大的控制流结构叫 match,它允许你将值与一系列 模式 进行比较,然后根据匹配到的 模式 执行对应代码。模式 可以由字面值、变量名、通配符和其他多种内容构成;第 19 章会详细介绍所有不同的 模式 及其用途。match 的强大之处在于其 模式 的表现力,以及编译器会确保所有可能的情况都得到处理这一事实。
将 match 表达式想象成一个硬币分拣机:硬币沿着带有不同尺寸孔洞的轨道滑下,每个硬币会穿过它遇到的第一个能容纳它的孔洞。同样地,值会依次通过 match 中的每个 模式,当值在第一个匹配的 模式 中 “契合” 时,它就会进入相应的代码块,在执行过程中被使用。
说到硬币,让我们用 match 作为例子!我们可以编写一个函数,它接受一个未知的硬币,并以与计数机类似的方式确定它是哪种硬币,并返回其以分为单位的值:
|
|
让我们拆解这个 value_in_cents 函数中的 match 表达式。首先我们列出 match 关键字,后跟一个表达式(本例中是 coin 值)。这看起来与 if 使用的条件表达式非常相似,但有个重要区别:if 的条件必须求值为布尔值,而这里的表达式可以是任何类型。本例中 coin 的类型是我们第一行定义的 Coin 枚举。
接下来是匹配分支(arms)。每个分支有两部分:一个模式和一些代码。第一个分支的模式是值 Coin::Penny,然后是用 => 运算符分隔的模式和要运行的代码。这里的代码就是简单的值 1。每个分支之间用逗号分隔。
当 match 表达式执行时,它会按顺序将结果值与每个分支的模式进行比较。如果模式匹配值,则执行与该模式关联的代码。如果模式不匹配,则继续执行下一个分支,就像硬币分拣机一样。我们可以根据需要设置任意数量的分支:在示例中,我们的 match 有四个分支。
每个分支关联的代码都是一个表达式,匹配分支中表达式的返回值就是整个 match 表达式的返回值。
如果匹配分支的代码很短(如示例 6-3 中每个分支只是返回一个值),我们通常不使用大括号。如果想在匹配分支中运行多行代码,则必须使用大括号,此时分支后的逗号是可选的。例如,以下代码在每次用 Coin::Penny 调用方法时都会打印 “Lucky penny!",但仍然会返回代码块的最后一个值 1:
|
|
6.2.2 绑定值的匹配值
match 匹配分支的另一个实用特性是:它们可以绑定到与 模式 匹配的值部分。这让我们能够从 枚举变体 中提取值。
举个例子,我们修改其中一个 枚举变体,使其内部包含数据。在 1999 年至 2008 年间,美国铸币局发行了背面印有 50 个州不同设计的 25 美分硬币。其他硬币没有这种特殊设计,只有 25 美分硬币有这个额外值。我们可以通过修改 Quarter 变体 来包含一个内部的 UsState 值,如示例:
|
|
假设我们有个朋友正在收集全美 50 州的纪念币。当我们按硬币类型整理零钱时,还会特别报出每个 25 美分硬币对应的州名——这样如果遇到朋友尚未收藏的款式,他们就可以将其收入囊中。
在这段代码的 match 表达式中,我们为匹配 Coin::Quarter 变体 的 模式 添加了一个名为 state 的变量。当匹配到 Coin::Quarter 时,state 变量会自动绑定到该硬币对应的州值。这样我们就可以在该分支的代码中使用这个 state 值,具体实现如下:
|
|
当我们调用 value_in_cents(Coin::Quarter(UsState::Alaska)) 时,coin 的值会是 Coin::Quarter(UsState::Alaska)。当我们将这个值与每个匹配分支进行比较时,在到达 Coin::Quarter(state) 之前都不会匹配成功。此时,state 将会绑定到值 UsState::Alaska。接着我们就可以在 println! 表达式中使用这个绑定,从而从 Quarter 这个 枚举变体 中获取到内部的州值。
6.2.3 与 Option<T> 类型匹配
在上一节中,我们希望在使用 Option<T> 时从 Some 中获取 T 的值;我们也可以使用 match 处理 Option<T> ,就像我们处理 Coin 枚举 一样!我们不再比较硬币,而是比较 Option<T> 的 变体,但 match 表达式的工作方式保持不变。
接下来我们写一个函数接收 Option<i32> 类型的值,如果有值,则在原来值的基础上加一,如果没有,就返回其本身回去。
|
|
当我们调用 plus_one(five) 的时候,var 的值就是 Some(5),与 None => None, 分支不匹配所以会直接跳过这个分支,去匹配下面的分支。之后发现与 Some(Num) => Some(Num + 1), 匹配,所以会执行该分支的代码。
将 match 和 枚举 结合使用在很多场景下都非常实用。你会在 Rust 代码中频繁看到这种 模式:对一个 枚举 进行 match 匹配,将变量绑定到内部数据,然后基于此执行相应代码。刚开始可能有点难以掌握,但一旦习惯后,你会希望所有语言都有这个特性。这一直是最受用户喜爱的功能之一。
来看这下面的代码:
|
|
编译运行会发现如下报错:
我们没有处理 None 分支,存在潜在威胁,所以编译器向我们报错了。
Rust 的编译器知道我们没有覆盖所有可能的情况,甚至能精确指出我们遗漏了哪个 模式!Rust 中的匹配是 穷尽式 的:我们必须涵盖所有可能性,代码才能通过编译。特别是在处理 Option<T> 时,当 Rust 阻止我们忘记显式处理 None 的情况时,它实际上保护我们免于在可能为 null 的情况下错误假定值一定存在——从而彻底避免了 null 值匹配的情况。
6.2.4. 全模式匹配与 _ 占位符
在使用 枚举 时,我们可以针对少数特定值执行特殊操作,而对其他所有值执行默认操作。假设我们正在实现一个游戏:如果你掷出 3 点,你的玩家不会移动,而是获得一顶新潮帽子;如果掷出 7 点,你的玩家会失去一顶潮帽;对于其他所有数值,你的玩家将在游戏板上移动相应数量的格子。以下是一个实现该逻辑的 match 示例(其中骰子结果是硬编码值而非随机数,其他逻辑用空函数表示,因为具体实现超出本例范围):
|
|
对于前两个分支,其 模式 直接匹配字面值 3 和 7。最后一个分支覆盖所有其他可能的值,模式 为我们命名为 other 的变量。该分支运行的代码通过将变量传递给 move_player 函数来使用它。
这段代码能够编译通过,尽管我们并未列出所有可能的 u8 类型值,因为最后一个 模式 会匹配所有未被明确列出的值。这种 通配模式(catch-all pattern)确保了 match 表达式的 穷尽性 要求。需要注意的是,我们必须将 通配分支 放在最后,因为 模式 是按顺序匹配的。如果将 通配分支 放在前面,后面的分支就永远不会被执行——因此,如果在 通配分支 之后添加其他分支,Rust 会发出警告!
Rust 还提供了一种在需要 通配匹配 但又不使用该值时的特殊 模式:_。这个下划线 模式 会匹配任何值,但不会绑定到该值。这相当于告诉 Rust 我们不会使用这个值,因此 Rust 不会提示存在未使用的变量。
现在假设我们修改游戏规则:如果掷出的数字不是 3 或 7,就必须重新掷骰子。此时我们不再需要使用 通配值,因此可以将代码中的 other 变量改为 _:
|
|
这个例子也达到了 match 的 穷尽性 要求,并且由于我们不需要使用这个值,我们使用 _ 来避免编译器警告。
最后,我们将游戏规则再修改一次,以便如果你掷出的不是 3 或 7,你的回合中就不会发生任何其他事情。我们可以通过将 unit 类型作为与 _ 分支对应的代码来表示这一点:
|
|
这样,我们就告诉编译器我们不需要使用它的值,并且它对应的分支上不需要做任何操作。
6.3 使用 if let 和 let else 实现更简洁的流控制
if let 语法让你能将 if 和 let 结合成一种更简洁的方式来处理匹配某个 模式 而忽略其余部分的情况。如下列代码,它匹配 config_max 变量中的 Option<u8> 值,但只想在值是 Some 变体 时执行代码。
|
|
如果值是 Some ,我们通过在分支中将值绑定到变量 max 来打印出 Some 变体 中的值。我们不想对 None 值做任何操作。为了满足 match 表达式,我们必须在处理完一个 变体 后添加 _ => () ,这很烦人,需要添加冗余代码。
对于这种情况,我们可以使用 if let 来简化这个流程:
|
|
if let 的语法采用等号分隔的 模式 和表达式。它的工作方式与 match 相同:表达式会被传入 match,而 模式 则作为第一个匹配分支。在这个例子中,模式 是 Some(max),max 会绑定到 Some 内部的值。然后我们就可以在 if let 代码块中使用 max,就像在对应的 match 分支中使用 max 一样。只有当值匹配该 模式 时,才会执行 if let 代码块中的代码。
使用 if let 意味着更少的代码、更少的缩进和更少的样板代码。但这样做的代价是失去了 match 强制执行的 穷尽性检查——这种检查能确保你不会遗漏处理任何情况。选择使用 match 还是 if let 取决于你当前的具体需求,以及为了代码简洁性而放弃 穷尽性检查 是否值得。
换句话说,你可以把 if let 看作是一种语法,它对应的是仅匹配单个 模式 并忽略其他所有值的 match 表达式。
我们还可以为 if let 添加一个 else 分支。这个 else 代码块的作用等同于与 if let + else 等价的 match 表达式中的 _ 分支。回想一下之前的 Coin 枚举 定义,其 Quarter 变体 还包含一个 UsState 值。如果我们想在统计所有非 25 美分硬币的同时,还要报告 25 美分硬币所属的州,我们可以这样使用 match 表达式:
|
|
或者我们可以使用 if let 和 else 来修改这个代码:
|
|
常见的 模式 是:当值存在时执行某些计算,否则返回一个默认值。继续以带有 UsState 值的硬币为例,如果我们想根据硬币上刻印的州的历史年代说些有趣的话,可以给 UsState 添加一个 方法 来检查该州的 “年龄”,比如这样:
|
|
通过在内部引入一个 state 变量结合 if let 语句去匹配硬币的类型:
|
|
虽然这样能完成任务,但它把所有逻辑都塞进了 if let 语句的代码块里。如果要处理的逻辑更复杂,就很难一眼看出顶层分支之间的关系了。我们其实可以利用表达式会产生值这个特性——既可以通过 if let 获取州信息,也可以直接提前返回,如下所示(用 match 也能实现类似效果):
|
|
不过这种方式也有点让人头疼!if let 的一个分支会产生一个值,而另一个分支则会直接终止函数并返回。
为了让这种常见 模式 表达得更优雅,Rust 提供了 let...else 语法。let...else 的语法结构和 if let 非常相似:左侧是 模式,右侧是表达式。但不同之处在于它没有 if 分支,只有 else 分支。如果 模式 匹配成功,就会在当前作用域中绑定 模式 匹配的值;如果匹配失败,程序就会进入 else 分支,而该分支必须通过返回(或其他方式)终止当前控制流:
|
|
可以看到,这种方式让代码始终保持在函数的 “主逻辑路径” 上,不会像 if let 那样因为两个分支产生显著不同的控制流。
当你遇到用 match 表达会显得过于冗长的逻辑时,请记住你的 Rust 工具箱里还有 if let 和 let...else 这两个利器。
Chapter 7 项目管理
随着程序规模的增长,代码组织会变得越来越重要。通过将相关功能分组并用清晰的特征分隔代码,你能更明确地找到实现特定功能的代码位置,以及修改功能行为的入口。
目前我们编写的程序都位于单个文件的单一 模块 中。当项目增长时,应当通过将代码拆分为多个 模块、再分散到多个文件中来组织代码结构。一个 包(package)可以包含多个二进制 箱(crate),以及可选的一个库 箱。当包继续扩展时,你可以将部分代码提取为独立的 箱,使其成为外部依赖项。本章将涵盖所有这些技术。对于由多个相互关联且协同演进的包组成的大型项目,Cargo 提供了 工作区(workspace)功能,我们将在第 14 章 " Cargo 工作区” 中讨论。
我们还将讨论封装实现细节——这能让你在更高层次复用代码:一旦实现了某个操作,其他代码只需通过公开接口调用,而无需了解内部实现逻辑。通过代码设计,你可以明确哪些部分是对外公开的,哪些是保留修改权的私有实现细节。这也是减少心智负担的重要方式。
另一个相关概念是 作用域(scope):代码所处的嵌套上下文会定义一组 “在作用域内” 的名称。在编写、阅读和编译代码时,程序员和编译器都需要知道特定位置的名称是指变量、函数、结构体、枚举、模块、常量还是其他项,并理解其含义。你可以创建 作用域 并控制名称的可见性。同一 作用域 内不允许存在同名项,但可通过工具解决命名冲突。
Rust 提供了一系列管理代码组织的功能,包括控制细节的公开/私有性以及程序中各 作用域 的命名。这些功能被统称为 模块系统,主要包括:
- 包(Packages):Cargo 提供的构建、测试和共享 箱 的功能
- 箱(Crates):生成库或可执行文件的 模块 树
- 模块 与
use关键字:控制路径的组织、作用域 和私有性 - 路径(Paths):命名结构体、函数或 模块 等项的方式
本章将详解这些功能及其协作方式,并阐述如何利用它们管理 作用域。学完后,你将建立起对 模块系统 的完整认知,并能像专家一样驾驭 作用域!
7.1 模块(Moudel)
模块化编程 一直都是编程中很重要的概念,Rust 中使用了 mod 来定义一个外部可调用的 模块,我们可以使用 pub 关键字来让我们想要其他文件可调用的部分变为外部可调用;我们可以通过 use 来引用我们自己的 模块 或者其他人写好的 模块;使用一个 模块 内的接口要通过 路径(path)使用 :: 来引出我们要使用的部分。
Rust 的 模块系统 通过 mod 和 pub 关键字实现了灵活的代码组织。根 crate 通常是 main.rs(二进制入口)或 lib.rs(库核心),但也可以通过 src/bin/ 放置多个二进制 crate。对于复杂项目,建议在 lib.rs 中声明所有 模块 并通过单独文件实现具体逻辑,main.rs 仅作调用入口,这种分离提升了代码复用性和编译效率。
模块 声明方式有两种:在根文件中用 mod name; 声明并拆分到同名文件(如 name.rs),或直接用 mod name { ... } 内联实现。简单逻辑适合内联,复杂 模块 建议拆分为文件。跨 模块 访问需双重公开:既要用 pub mod 声明 模块 公开,又要用 pub 标记 模块 内需要暴露的项(函数/类型等),否则默认私有。例如 pub mod utils 配合 pub fn helper() 才能被其他文件通过 crate::utils::helper 调用。
use 关键字可简化长 路径,而文件 模块 的物理结构(如 src/a/b.rs)需与逻辑 路径(crate::a::b)匹配。这种设计既保证了代码组织的清晰度,又通过严格的可见性控制(pub 层级)维护了封装性,适应从脚本到大型项目的各种场景。
这里,我们创建了一个名为 backyard 的二进制 crate,以说明这些规则。crate 的目录,也名为 backyard ,包含这些文件和目录:
|
|
这个情况下的 crate 根文件是 src/main.rs,它包含:
|
|
pub mod garden; 告诉编译器要引入从 src/garden.rs 中找到的代码:
|
|
这里 pub mod vegetables; 又会让编译器去寻找 src/garden/vegetables.rs 中的代码,然后包含它:
|
|
7.1.2 将相关的代码包含到一个模块中
模块(moudel)让我们很好的组织我们的代码,让代码可读性和复用性更好。并且 模块 让我们控制 模块 的隐私性,因为除非使用 pub 显式声明,一个 模块 默认是不可外部调用的;但是一旦使用了 pub 声明 模块,则该 模块 就是外部可调用的。
接下来我们来编写一个例子,我们会写一个提供餐厅各种功能的 crate;我们只攥写函数名,但是将函数内部空出来,因为具体实现与我们例子关系不大。
在餐饮行业,餐厅的某些区域被称为前厅,而另一些区域则被称为后厨。前厅是顾客所在的地方;这包括接待员安排顾客就座、服务员点单和收款,以及调酒师调制饮品。后厨是厨师和厨师在厨房工作、洗碗工清洁以及经理处理行政工作的地方。
要按这种方式组织我们的 crates,我们可以将它的函数组织成嵌套 模块。通过运行 cargo new restaurant --lib 创建一个名为 restaurant 的新库。
|
|
我们使用 mod 关键字定义 模块,后跟 模块 名称(本例中为 front_of_house)。模块 体则位于大括号内。模块 内部可以嵌套其他 模块,如本例中的 hosting 和 serving 模块。模块 还能包含其他项的定义,例如结构体、枚举、常量、特征,以及函数。
通过 模块系统,我们可以将相关定义分组并标注其关联性。使用这些代码的程序员能够根据分组导航代码,而无需逐行阅读所有定义,从而更轻松地定位目标功能。需要新增功能时,程序员也能明确代码存放位置以保持程序结构清晰。
前文提到,src/main.rs 和 src/lib.rs 被称为 crate 根。其命名源于这两个文件的内容会构成名为 crate 的根 模块,位于 crate 模块 层级结构的顶端(即 模块树(moudel tree)的根部)。
模块树 的分支就像下面这样:
|
|
该树状图展示了 模块 间的嵌套关系(例如 hosting 嵌套在 front_of_house 中),同时揭示了同级 模块 的关系——如 hosting 与 serving 就是定义在 front_of_house 下的同级 模块。当 模块 A 包含于 模块 B 时,我们称 模块 A 是 模块 B 的 子模块(child moudel),模块 B 则是 模块 A 的 父模块(parent moudel)。值得注意的是,整个 模块树 都以隐式的 crate 模块 作为根节点。
这种 模块树 结构会让人联想到计算机的文件系统目录树,这个类比非常贴切!正如用目录管理文件,我们用 模块 组织代码;而如同定位文件需要 路径,访问 模块 也需要明确的 路径 规则。
7.2 通过路径引用模块
在 Rust 中,我们需要通过 路径 来定位 模块树 中的特定项,就像在文件系统中使用 路径 导航一样。要调用某个函数,必须知道它的 路径。
路径 有两种形式:
- 绝对路径 是从 crate 根开始的完整 路径。对于外部 crate 的代码,绝对路径 以 crate 名开头;对于当前 crate 的代码,则以字面量 crate 开头。
- 相对路径 从当前 模块 开始,使用
self、super或当前 模块 中的标识符。
无论是 绝对路径 还是 相对路径,后面都会跟着一个或多个由双冒号(::)分隔的标识符。
回到图例子,假设我们要调用 add_to_waitlist 函数。这就相当于问:add_to_waitlist 函数的 路径 是什么?
我们将展示两种从 crate 根定义的新函数 eat_at_restaurant 中调用 add_to_waitlist 函数的方法。这些 路径 本身是正确的,但还存在另一个问题会导致当前示例无法通过编译,我们稍后会解释原因。
eat_at_restaurant 函数是我们库 crate 公共 API 的一部分,因此我们用 pub 关键字标记它。在 “使用 pub 关键字暴露 路径” 一节中,我们将更详细地讨论 pub。在 src/libr.rs 中做如下修改(对 mod 做了一定简化):
|
|
在 eat_at_restaurant 函数中第一次调用 add_to_waitlist 时,我们使用了 绝对路径。由于 add_to_waitlist 与 eat_at_restaurant 定义在同一个 crate 中,因此可以使用 crate 关键字作为 绝对路径 的起点。我们依次包含各级 模块,最终定位到 add_to_waitlist 函数。这就像在具有相同结构的文件系统中指定 路径 /front_of_house/hosting/add_to_waitlist 来执行程序一样——用 crate 从 crate 根目录开始,就如同在 shell 中使用 / 从文件系统根目录开始导航。
在 eat_at_restaurant 函数中第二次调用 add_to_waitlist 时,我们采用了 相对路径 的写法。这个 路径 以 front_of_house 模块 名开头,该 模块 与 eat_at_restaurant 函数在 模块树 中属于同级关系。用文件系统来类比的话,这就相当于使用 front_of_house/hosting/add_to_waitlist 这样的 相对路径。以 模块 名称开头的 路径 表明这是一个相对于当前 模块 的 路径。
选择使用 相对路径 还是 绝对路径 需要根据项目实际情况来决定,这主要取决于被调用项的定义代码和调用代码是否经常需要同步移动。举个例子:
- 如果我们将
front_of_house模块 和eat_at_restaurant函数一起移动到名为customer_experience的新 模块 中:- 绝对路径 需要相应更新
- 但 相对路径 仍然有效
- 如果仅将
eat_at_restaurant函数单独移动到dining模块:- 绝对路径 保持不变
- 但 相对路径 需要调整
一般来说,我们更倾向于使用 绝对路径,因为在代码重构时,项的定义位置和调用位置往往需要独立调整。这就像在文件系统中使用 绝对路径 能更好地适应目录结构调整一样,能显著提升代码的可维护性。
我们编译上述代码会发现:
错误信息显示 hosting 模块 是私有的。换句话说,虽然我们给出的 hosting 模块 和 add_to_waitlist 函数的 路径 是正确的,但 Rust 不允许我们使用这些 路径,因为它无法访问私有部分。在 Rust 中,所有项(函数、方法、结构体、枚举、模块 和常量)默认对其 父模块 都是私有的。如果你想让某个项(比如函数或结构体)保持私有,只需将其放入 模块 中即可。
父模块 中的项不能使用 子模块 中的私有项,但 子模块 中的项可以使用其祖先 模块 中的项。这是因为 子模块 封装并隐藏了它们的实现细节,但 子模块 可以看到它们被定义的上下文环境。延续我们的比喻,可以将隐私规则想象成餐厅的后厨:对顾客来说后厨操作是私密的,但餐厅经理可以看到并操作整个餐厅的所有环节。
Rust 选择让 模块系统 以这种方式运作,是为了默认隐藏内部实现细节。这样你就能清楚地知道可以修改哪些内部代码而不会破坏外部代码。不过,Rust 也提供了通过 pub 关键字将 子模块 内部代码暴露给外部祖先 模块 的选项。
7.2.2 使用 pub 关键字暴露 路径
上面那个例子的解决方案就是给我们想要暴露的部分加上 pub 关键字:
|
|
但是编译后发现依旧报错:
编译错误现在指出 add_to_waitlist 函数是私有的。隐私规则不仅适用于 模块,还适用于结构体、枚举、函数和方法。
这是因为,虽然我们使用 pub 关键字 公开了 hosting,但是其内部的 add_to_waitlist 依旧是私有的。这就是我们前面提到的双重公开,我们想要调用 add_to_waitlist,必须也要在定义函数的时候加上 pub:
|
|
再次编译就不会报错了。
现在代码可以编译了!让我们看看 绝对路径 和 相对路径,并再次确认为什么添加 pub 关键字 能让我们在 add_to_waitlist 中使用这些 路径,同时遵守隐私规则。
在 绝对路径 的情况下,我们从 crate(也就是 crate 根)开始。crate 根中定义了 front_of_house 模块。虽然 front_of_house 模块 不是公共的,但因为 eat_at_restaurant 函数与 front_of_house 定义在同一个 模块 中(即它们都是 crate 根的子项),所以我们可以从 eat_at_restaurant 中引用 front_of_house。接下来是标记为 pub 的 hosting 模块。我们可以访问 hosting 的 父模块,所以我们可以访问 hosting。最后,add_to_waitlist 函数被标记为 pub,我们可以访问其 父模块,所以这个函数调用是有效的!
在 相对路径 的情况下,除了第一步之外,逻辑与 绝对路径 相同:路径 不是从 crate 根开始,而是从 front_of_house 开始。front_of_house 模块 与 eat_at_restaurant 在同一 模块 中定义,所以从定义 eat_at_restaurant 的 模块 开始的 相对路径 是有效的。然后,因为 hosting 和 add_to_waitlist 被标记为 pub,所以 路径 的其余部分也是有效的,因此这个函数调用也是有效的!
如果你打算把库 crate 分享给其他项目,那么公开的 API 就是你与使用者之间的 “契约”,决定了他们如何与你的代码交互。关于如何管理公共 API 的变更,让下游依赖更轻松,还有很多细节可说,但这超出了本书范围;如果想深入了解,请参考 [The Rust API Guidelines](About - Rust API Guidelines)。
7.2.3 使用 super 的相对路径
我们可以用 super 开头构建 相对路径,使其从 父模块 而非当前 模块 或 crate 根开始查找。这种做法类似于文件系统里的 ..,表示 “返回上一级目录”。借助 super,只要知道目标就在 父模块 里,我们就能直接引用;当该 模块 将来可能被移到 模块树 的其他位置时,这种写法也更容易整体搬迁。
以这个场景为例:主厨发现订单出错,亲自更正后把餐点端给顾客。back_of_house 模块 里的 fix_incorrect_order 函数需要调用定义在 父模块 中的 deliver_order,于是 路径 以 super 开头,指向 父模块 的 deliver_order:
|
|
fix_incorrect_order 位于 back_of_house 模块,因此用 super 可以跳到它的 父模块,也就是 crate 根。从那里再寻找 deliver_order 就能找到,调用成功!我们预期 back_of_house 模块 与 deliver_order 函数之间的关系会保持稳定,未来若要重构 模块树,它们大概率会一起移动。因此选用 super,日后只需改动更少的地方即可。
7.2.4 在模块中定义公共的枚举和结构体
我们也可以把 pub 放在结构体和枚举的定义前,让它们成为公共类型,不过这里还有一些额外细节。如果给结构体整体加 pub,结构体本身对外可见,但它的成员默认仍然是私有的;你可以按需决定每个字段是否公开。
以下示例中,我们定义了一个公共的 back_of_house::Breakfast:其中的 toast 字段公开,seasonal_fruit 字段私有。这模拟了餐厅里顾客可以挑选配餐面包,而具体搭配哪种水果由厨师根据当季库存决定。水果变化太快,顾客既无法选择,也无法提前知道会拿到什么:
|
|
因为 back_of_house::Breakfast 中的 toast 字段是公有的,所以在 eat_at_restaurant 里可以用点号对它进行读写。注意,我们无法在 eat_at_restaurant 里使用 seasonal_fruit 字段,因为它是私有的。试着把修改 seasonal_fruit 字段值的那一行取消注释,看看会得到什么错误!
另外,由于 back_of_house::Breakfast 含有一个私有字段,该结构体必须提供一个公有的关联函数来构造 Breakfast 实例(这里我们把它命名为 summer)。如果没有这个函数,我们在 eat_at_restaurant 里就无法创建 Breakfast 的实例,因为无法在那里给私有的 seasonal_fruit 字段赋值。因为在没有 Defualt(之后了解)的情况下,我们必须显式指定每个结构体字段的值,但是我们又没办法访问 seasonal_fruit,所以需要一个函数来返回实例。
但是与结构体不同的是,我们如果适用一个 pub 来声明一个枚举值,那么它的所有字段都会变成公共的。
|
|
由于我们把 Appetizer 枚举设为 pub,因此能在 eat_at_restaurant 中使用其 Soup 和 Salad 这两个变体。
通常,除非枚举的变体是公开的,否则枚举就没什么用;要是在每个变体前都写 pub 会很烦人,因此枚举变体默认就是公共的。结构体则不同,即使字段不公开也常有价值,所以结构体字段遵循 “默认私有” 这一普遍规则,除非显式标注 pub。
关于 pub,还有一种情况尚未提及,那就是模块系统的最后一个特性:use 关键字。我们先单独介绍 use,随后展示如何把它与 pub 结合使用。
7.3 使用 use 将 路径 引入 作用域
每次调用函数都要写出完整 路径,既麻烦又重复。在之前的示例中,无论我们选 绝对路径 还是 相对路径,只要想调用 add_to_waitlist ,就必须连带写 front_of_house:: hosting 。幸运的是,可以用 use 关键字一次性创建 路径 的 “快捷方式”,之后在当前 作用域 里就能用更短的名字调用。
在下面的示例中,我们把 crate::front_of_house::hosting 模块 引入 eat_at_restauran t 函数的 作用域,于是只需写 hosting::add_to_waitlist 即可调用 add_to_waitlist 。
|
|
在当前 作用域 里写 use 和 路径,就像在文件系统里创建一条符号链接。把 use crate::front_of_house::hosting; 加进 crate 根后,hosting 就成了该 作用域 里的合法名字,仿佛 hosting 模块 就定义在 crate 根一样。通过 use 引入的 路径 同样会检查可见性,与其他 路径 无异。
注意:use 只在它所出现的那一层 作用域 里建立快捷方式。下面把 eat_at_restaurant 函数移到一个新的 子模块 customer 中,这就进入了与 use 语句不同的 作用域,因此函数体将无法通过编译。
|
|
编译后如下报错:
注意,编译器还会警告:这段 use 在它所在的 作用域 里并未被使用!解决办法有两种:把 use crate::front_of_house::hosting; 同样移到 customer 模块 内部,或者在 子模块 customer 里改用 super::hosting 来引用 父模块 中的这条快捷 路径。
7.3.2 创建符合自然语言习惯的 use 路径
在上述的第一个例子中,我们不禁好奇,使用 use 的时候为什么不直接 use crate::font_of_house::hosting::add_to_waitist,而是只到 hosting 为止?正如下面的这个例子一样:
|
|
虽然这个例子也能完成任务,但示例之前那种方法才是用 use 引入函数的惯用写法:把函数的 父模块 引入 作用域,调用时仍需写出 父模块 前缀。这样既避免了重复完整 路径,又一眼就能看出函数并非本地定义。本例子的写法让人难以判断 add_to_waitlist 到底定义在哪。
另一方面,对于结构体、枚举等其他项,惯用法则是直接指定完整 路径。下面展示了在二进制 crate 作用域 中以惯用方式引入标准库的 HashMap 结构体。
|
|
这种写法并不是强制要求的,而是人们通俗约定的一种便于编写和阅读 Rust 代码的一种方式。而且这种编写方式可以避免有两个不同的 模块 拥有同样名称的一个方法或者内联函数:
|
|
比如这两个函数就是,他们都需要返回一个 Result,但是由于这两个 Result 的所属的 父级模块 不同,所以其实他们不是同一种类型,这样加上 父级模块,就能很好的规避这个问题。并且 Rust 也不允许这种多重定义的情况发生。
7.3.3 使用 as 关键字为 模块 取别名
刚刚说的这种使用完整 路径 而导致冲突的情况,我们可以使用 as 关键字来将其中一个 Result 命名成其他的:
|
|
这样,我们就可以让编译器清楚的知道这两个是不同的类型。
7.3.4 pub use 重新导出 模块
当我们用 use 把某个名字导入 作用域 时,这个名字只在当前 作用域 内可见。如果想让外部代码也能像它在当前 作用域 定义一样地使用这个名字,可以把 pub 与 use 连用。这种写法称为 “重导出”(re-export):既把条目引入当前 作用域,又让它可供其他 模块 再次导入:
|
|
在改动之前,外部代码若想调用 add_to_waitlist 函数,必须写 restaurant::front_of_house::hosting::add_to_waitlist(),并且还得把 front_of_house 模块 标成 pub。现在,借助这条 pub use 从根 模块 重新导出了 hosting,外部代码就能直接用 restaurant::hosting::add_to_waitlist() 来调用。
重导出的意义在于:当代码的内部组织与使用者心目中的领域模型不一致时,可以用它做 “翻译”。以餐厅为例,餐厅员工会把空间划分为 “前厅” 和 “后厨”,但顾客并不会这么思考。通过 pub use,我们可以保留内部结构,同时向外暴露另一套更符合使用者直觉的 API。这样既方便库作者维护,又方便库用户调用。
7.3.5 使用外部库
在第二章中,我们编写一个猜数游戏的时候,使用了一个随机数库 rand,我们将它添加到我们的 Cargo.toml 中。这让 Cargo 知道我们这个项目有哪些依赖,并且它会从 [crate.io](crates.io: Rust Package Registry)中下载这个库,并且让这个库在我们的项目中可以被使用。
然后,我们使用 use 关键字将 rand 库中的 Rng 模块 带入到了我们的 作用域 中。社区为 Rust 提供了特别多的外部库,我们可以使用相同的方法来引入其他的库。
注意,标准库 std 其实也是一个外部的库,只是其与 Rust 一起分发,所以我们使用标准库的时候不需要在 Cargo.toml 中指定这个依赖,我们只需要在源文件中使用 use 来将其中我们想要使用的包给引入我们的 作用域 中即可;就比如如果我们想要使用标准库中的哈希——HashMap 时,我们只需要在程序中加上:
|
|
7.3.6 嵌套 路径(Nested path)
如果我们想要使用同一个库中的多个 模块,没一个 模块 都要使用一个 use 当然不太现实,就比如我们第二章的猜数游戏中使用标准库的两个 模块 的时候:
|
|
对于这种情况,我们建议使用嵌套 路径 来引入 模块:
|
|
我们先使用 :: 操作符将公共部分印出来,最后在 {} 之中分别引用各自需要的部分。这在大型项目中很有用处。我们可以在 路径 的任何级别使用嵌套 路径,这在组合两个共享子 路径 的 use 语句时非常有用。比如下个例子,我们使用两个 use 将标准库的 std::io 和 std::io::Write 引入 作用域:
|
|
这两个 模块 的共同 路径 都是 std::io,所以我们可以合并成下面这样:
|
|
这里的 self 其实就是 std::io 本身,所以我们使用一个语句合并了两个语句。
7.3.7 通配符(The Glob Operator)
如果我们想要将一个 模块 的所有公共项给引入我们的项目,我们可以使用通配符 *:
|
|
这条 use 语句会把 std::collections 中定义的所有公共项一次性引入当前 作用域。使用通配符时必须小心!它会让 “当前 作用域 里到底有哪些名字、这些名字最初定义在哪” 变得难以判断。此外,一旦依赖项改变了其定义,你引入的内容也会随之变化;例如,升级依赖后,如果它新增了一个与你本地同名的定义,就可能导致编译错误。
通配符常见于测试场景,用来把待测 模块 里的所有内容一次性导入 tests 模块;我们将在第 11 章中讨论这一点。通配符有时也会出现在 “prelude 模式” 中,如需了解该模式的详情,可查阅 [标准库文档](std::prelude - Rust)。
7.2 模块 化编程
目前为止,我们都在一个文件中定义不同的 模块,但是在大型项目中,我们通常将不同功能的 模块 分别放到单独的一个文件中。
就比如之前那个餐厅的例子,我们接下来会在不同文件中定义不同的 模块,而不是都放在根 crate 文件中。在这个例子中,根 crate 是 src/lib.rs,但是程序在根 crate 为 src/main.rs 的情况下也依旧可以运行。
首先,我们把 front_of_house 模块 提取到单独的文件中。把原来 front_of_house 模块 花括号里的代码全部删掉,只留下 mod front_of_house; 这条声明,使 src/lib.rs 变成示例所示的内容。注意这段代码还无法通过编译。
Filename: src/lib.rs
|
|
接下来,把原先放在花括号里的代码移到新建的 src/front_of_house.rs 文件中。编译器之所以会去这个文件里找 模块 内容,是因为它在 crate 根遇到了名为 front_of_house 的 模块 声明。
Filename: src/front_of_house.rs
|
|
注意:在整个 模块树 里,只需用 mod 声明一次文件即可。一旦编译器通过这条 mod 语句知道该文件属于项目,并确定了它在 模块树 中的位置,项目中的其他文件就应该用 “路径” 来引用这段代码,路径 的起点是这条 mod 语句所在的位置,这在 7.2 一节已经讲过。换句话说,mod 并不像你在其他语言里见过的 “include” 操作那样简单地把文件内容原地插入。
接下来,我们把 hosting 模块 也提取到独立文件。由于 hosting 是 front_of_house 的 子模块,而不是根 模块 的 子模块,步骤稍有不同。我们需要在 src/front_of_house 目录下为它新建文件,目录名与它在 模块树 中的祖先 路径 保持一致。
要开始移动 hosting,先把 src/front_of_house.rs 改成只保留对 hosting 模块 的声明:
Filename: src/front_of_house.rs
|
|
然后我们创建 src/front_of_house 目录,和一个 hosting.rs 去包含 hosting 模块 内的一些定义:
Filename: src/front_of_house/hosting.rs
|
|
如果我们把 hosting.rs 直接放在 src 目录下,编译器就会认为它是 crate 根里声明的 hosting 模块,而不是 front_of_house 的 子模块。编译器根据 模块 声明的位置去找对应文件,因此目录和文件结构必须与 模块树 完全一致。
我们已经把每个 模块 的代码移到了单独的文件里,模块树 本身保持不变。eat_at_restaurant 里的函数调用无需任何改动即可正常工作,尽管定义已分散在不同文件中。随着 模块 体积增大,这种 “按文件拆分” 的做法随时可用。
注意,src/lib.rs 中的 pub use crate::front_of_house::hosting; 语句并未改变;use 也不会影响哪些文件被编译。真正决定 模块 归属的是 mod 关键字:Rust 会去寻找与 模块 同名的文件,并把其内容当作该 模块 的代码。
Rust 的 模块 系统把 “声明位置 → 文件系统 路径” 做成了一条强规则:
- 你在哪一级写
mod xxx;,编译器就在同级目录找xxx.rs,或在同名目录xxx/里找下一层 子模块。 - 路径 必须层层对齐,不能错位;一旦声明了 子模块,父模块 就必须升级为目录。
- 你不想拆文件时,可以直接在任何一级用花括号
mod xxx { ... }就地实现;只要后续不再新增 子模块,就无需改动目录。
一句话:“声明在哪,文件就在哪;有 子模块 就升目录,没 子模块 可留在花括号里。”
Chapter 8 常见的集合(Collections)
Rust 的标准库包含一系列非常实用的数据结构,称为 集合。大多数其他数据类型只代表单个特定值,而 集合 可以包含多个值。与内建的数组和元组类型不同,这些 集合 指向的数据存储在 堆 上,这意味着数据量无须在编译时就确定,可以在程序运行时增长或缩减。每种 集合 都有不同的能力与开销,根据当前场景选择合适的 集合 是一项会随着时间而提升的技能。在本章中,我们将讨论 Rust 程序中非常常用的三种 集合:
- 向量(vector)允许你存储可变数量的相邻值。
- 字符串(string)是字符的 集合。我们之前提到过
String类型,但在本章中将深入探讨。 - 哈希映射(hash map)允许你将值与特定键关联。它是更通用的数据结构——映射——的一种具体实现。
我们将讨论如何创建和更新 向量、字符串 和 哈希映射,以及它们各自的独特之处。
8.1 向量(vector)
向量 允许我们在一个数据结构中存储多个值,他们 内存 中的位置都是相邻的;并且 向量 只能存储一种 数据类型 的数据。我们用 Vec<T> 来表示。
8.1.2 创建一个向量
就如我们之前学到的,对于一个 数据类型,标准库中通常允许我们使用 new 方法来创建一个空的实例,所以我们使用以下的语句来创建一个空的 向量 实例:
|
|
注意到我们这里显式指定了 向量 的存储的 数据类型,这是由于我们目前还没有给 向量 中存储任何数据,所以编译器并不知道这里的 数据类型。这是因为 向量 中的 <T> 是一种 泛型(generics),我们将在第十章具体讨论 泛型,我们现在只需要知道这是一种可以存储所有 数据类型 的一种表达。
但是更多时候我们在声明一个 向量 的时候都是使用了初始值的方式,这个时候编译器就可以推断 数据类型,所以我们就不需要显式指定 数据类型,我们将初始值放到 [] 中,我们使用 vec![] 宏 来操作这个过程:
|
|
接下来我们来学习如何修改一个 向量。
8.1.3 向向量中增加数据
我们使用 push 方法来给一个已经创建的 向量 实例添加数据:
|
|
我们需要在声明的时候加上 mut,意味着这个 向量 是可变的,并且由于 1,2,3 这些都是默认的 i32,所以编译器可以推断出 数据类型。
8.1.4 获取向量中的值
想要引用 向量 中的值,总共有两种方式,第一种是使用 索引值 或者使用 get 方法。比如下面这个例子,我们就使用这两种方法来获取,为了更清晰的展示,我们将显式的指定 数据类型:
|
|
我们使用 索引值 2 来获取 向量 的第三个值,因为 索引值 总是从 0 开始的。我们使用 & 和 [] 来获取 向量 这个 索引 位置的值的 引用。当我们使用 get 方法的时候,会返回一个 Option<T> 数据类型,所以我们要使用 match 来判断一下这个位置是不是有值。
使用 get 方法访问到 向量 范围之外的 索引 会返回一个 None 值,那如果我们直接使用 索引值 会发生什么呢?
|
|
当我们运行这段代码时,第一个 [] 方法会导致程序崩溃,因为它 引用 了一个不存在的元素。这个方法最适合在你希望程序在尝试访问 向量 末尾之外的元素时崩溃时使用。
使用 get 方法就可以避免这个问题,但是我们需要在程序中加上处理 Some(&element) 或者 None 的逻辑。
当程序拥有一个有效 引用 时,借用检查器 会强制执行第 4 章中讲到的 所有权 与 借用 规则,以确保该 引用 以及任何指向 向量 内容的其他 引用 始终有效。回忆一下那条规则:在同一 作用域 内,不能同时存在 可变引用 和 不可变引用。这条规则同样适用于:我们先持有一个指向 向量 首元素的 不可变引用,然后又试图在 向量 末尾添加一个新元素。如果随后还在函数中再次使用该首元素的 引用,这段程序将无法通过编译。
|
|
编译后的结果是:
示例的代码乍一看似乎应该能行:指向首元素的 引用 为什么要关心 向量 末尾的变化呢?这个错误源于 向量 的实现方式:由于 向量 把值连续地存放在 内存 里,当 向量 当前位置旁没有足够的空间时,再向末尾添加新元素就可能需要分配一块新 内存,并把旧元素整体复制过去。在这种情况下,原先指向首元素的 引用 就会指向已被释放的 内存。借用 规则正是为了防止程序陷入这种境地而设计的。
8.1.5 遍历向量中的值
和数组一样,我们可以使用 for 循环来遍历 向量 中的每一个值:
|
|
也可以在遍历中修改一个可变 向量 中的值:
|
|
在修改 向量 中的值之前,我们要使用 * 来 解引用 i,依次来把存储在 i 中的值给取出来,然后进行修改。
无论以不可变还是可变方式遍历 向量,由于 借用检查器 的规则,整个过程都是安全的。如果我们尝试在 for 循环体中插入或删除元素,就会得到 “可变引用 和 不可变引用” 共存的编译错误。for 循环持有的对 向量 的 引用 会阻止同时修改整个 向量,因为 for 本质也是一个 不可变引用。
8.1.6 枚举与向量
由于 向量 只能存储一种类型的数据,我们可以将 向量 与 枚举 结合起来,通过 枚举 的值来让 向量 中可以存在不同的类型,但是由于 枚举 其实只是一种 数据类型,所以是可行的:
|
|
Rust 需要在编译时就知道向量中会存放哪些类型,以便精确计算在堆上为每个元素分配多少内存。我们还必须显式指定该向量允许存放的类型。如果 Rust 允许一个向量持有任意类型,那么其中某些类型可能会在向量元素的操作中引发错误。借助枚举与 match 表达式,Rust 会在编译期确保所有可能情况都被处理,这一点在第 6 章已有讨论。
如果在编译时并不知道程序运行时会遇到哪些类型并把它们存入 向量,枚举 技巧就派不上用场。此时可以使用 trait 对象,我们将在第 18 章讨论这一机制。
既然我们已经探讨了使用 向量 的常见方法,请务必查阅标准库为 Vec<T> 定义的全部实用方法。例如,除了 push 之外,pop 方法可以移除并返回 向量 的最后一个元素。
8.1.7 释放一个向量就会释放向量中的所有的值
当一个向量被释放时:
|
|
当我们 向量 v 被释放掉的时候,v 中所有的值都会被释放掉,意味着这些值会被清空 借用检查器 确保:任何指向 向量 内部元素的 引用,只能在 向量 本身仍然有效的期间使用。
8.2 字符串(String)
8.2.1 字符串存储的时 UTF-8 编码的文本
我们在第 4 章已经谈到过 字符串,但现在需要更深入地探讨。Rust 初学者常在 字符串 上 “翻车”,原因大体有三:Rust 倾向于把潜在错误暴露出来;字符串 这种 数据结构 远比许多程序员想象的复杂;以及无处不在的 UTF-8。这些因素交织在一起,让从其他语言转过来的开发者感到棘手。
之所以把 字符串 放在 “集合” 这一章讨论,是因为 字符串 本质上是一个 字节 集合,并附带一些方法,使得这些 字节 被解释为文本时能提供有用的功能。本节将介绍 String 作为 集合 类型所共有的操作:创建、更新、读取。我们也会讨论 String 与其他 集合 的差异,尤其是 “按索引访问” 在 String 上为何因人类与计算机对 字符串 数据的不同理解而变得复杂。
首先,我们来明确 “字符串” 一词的含义。在 Rust 的核心语言中,只有一种 字符串 类型,即 字符串 切片 str,通常以借用的形式 &str 出现。第 4 章里讨论过,字符串 切片是对别处存储的某段 UTF-8 编码数据的 引用。例如,字符串 字面量就被存放在程序的二进制文件中,因而也是 字符串 切片。
String 类型则由 Rust 的标准库提供,而非直接内置在核心语言中。它是一种可增长、可变、拥有 所有权 的 UTF-8 编码 字符串 类型。当 Rustacean 提到 Rust 里的 “字符串” 时,可能指的是 String,也可能指的是 字符串 切片 &str,而仅仅是其中一种。尽管本节主要围绕 String 展开,但 String 与 &str 在标准库中都极为常用,并且二者均采用 UTF-8 编码。
8.2.2 创建一个 String
很多适用于 向量 的操作,同样也适用于 String,因为其实 String 其实也是一种存储 字节 的 向量,只是带有其特殊的限制和功能。因此,创建一个 String 我们可以使用 new 方法来创建一个空白的实例:
|
|
对于想要在创建的时候就赋予其一个初始值,我们可以使用 to_string 方法。这个方法适用于所有实现了 Display 特性 的 数据类型;而 字符串 的面量就实现了这个 特性:
|
|
同样,我们也可以使用 from 语句来创建一个有初始化的实例:
|
|
由于我们经常使用 字符串,所以 字符串 有许多的公用 API 操作。虽然有些 API 看上去都在做同一件事,但是他们都有自己独特的作用。不过我们上述两个例子中的 to_string 和 from 确实都只是为了让 String 实例有一个初始值,选择哪种方式只是个人风格的问题。
我们上文提到过,String 类型存储的是 UTF-8 数据类型,所以我们可以存储任何正确的编码:
|
|
8.2.3 增加字符串
String 可以像 Vec<T> 一样增长和修改内容——只要你往里继续 push 数据。此外,你还可以方便地使用 + 运算符或 format! 宏 来把多个 String 拼接在一起:
|
|
还有我们之前就使用过的 push_str,这个方法将一个 字符串 追加到目标 字符串 的后面,就如同上面的 s1.push_str(", Rust");,把 “, Rust” 追加到 s1 的后面。同样,我们也可以将另一个 字符串 变量的 引用 作为参数达到合并两个 字符串 的效果:
|
|
String 中也存在 push 方法,只不过他只是将一个字符追加到 字符串 的后面:
|
|
想要使用 push_str 将一个 字符串 格式化追加到另一个 字符串 要这样:
|
|
这是因为 push_str 的入参必须是 &str,而我们使用格式化 宏 是返回 String 类型,所以需要加上 &,format! 宏 接下里就会讲。
8.2.4 + 操作符和 format! 宏
刚刚只是简单引入了这两个概念,接下来是更细节的信息。
当我们想要连接两个 字符串 的时候,最常用的还是 + 操作:
|
|
至于我们为什么不能再 + 操作符之后使用 s1 是并且 s2 要使用 引用 是因为,当我们调用 + 的时候,其本质是调用了 add 方法,而该方法的原型是类似于下面这样:
|
|
在标准库中,你会看到 add 的入参是 泛型 和关联类型。在这里,我们替换成了具体类型,这就是当我们用 String 值调用这个方法时发生的情况。
首先,s2 前面有一个 &,表示我们把第二个 字符串 的 引用 拼接到第一个 字符串 上。这是因为 add 函数的第二个参数 s 的类型限制:只能把 &str 追加到 String,而不能把两个 String 直接相加。但等一下,&s2 的类型是 &String,并不是 add 函数签名中要求的 &str,那示例 8-18 为什么还能编译通过呢?
原因是在调用 add 时,编译器会自动把 &String 强转( coerce )为 &str。具体做法是利用 “解引用强制转换"(deref coercion):在这里,&s2 被转换成 &s2[..]。第 15 章我们会更详细地讨论 解引用强制转换。由于 add 并不会取得 s 参数的 所有权,所以操作完成后 s2 依旧是一个有效的 String。
其次,从函数签名可以看出,add 会取得 self 的 所有权,因为 self 前面没有 &。这意味着示例中的 s1 会被移动到 add 调用里,此后 s1 就不再有效。因此,尽管 let s3 = s1 + &s2; 看起来像是把两个 字符串 都复制一遍再生成一个新 字符串,实际上这条语句只拿走了 s1 的 所有权,把 s2 的内容复制追加进去,然后把结果的 所有权 返回给 s3。换句话说,表面上看似大量复制,实则不然,其内部实现比拷贝更高效。
当我们想要将多个 字符串 连接到一起,使用 + 就会显得很复杂:
|
|
现在,s 的值就是 tic-tac-toe,但是我们也说了,这是一个很复杂的操作,所以对于多 字符串 合并,我们使用的是 format! 宏:
|
|
这段代码同样把 s 设为 "tic-tac-toe"。format! 宏的工作方式与 println! 类似,只是它不将结果输出到屏幕,而是返回一个包含内容的 String。使用 format! 的版本读起来要容易得多,而且 format! 宏生成的代码使用的是引用,因此这次调用不会夺取任何参数的所有权。
8.2.5 String 中的索引
在很多编程语言中,想要获取一个字符串中的某个具体的字符,通常就是使用该字符在这个字符串中的索引来引用。但是你想在 Rust 中这么干,是不行的:
|
|
编译这段代码,会发现如下报错:
这个报错告诉我们,Rust 中的字符串并不支持使用索引值来访问。但是为什么呢?为了回答这个问题,我们要先从字符串在内存中的存储说起。
8.2.6 字符串的内部表示
String 类型其实是包装在 Vec<u8> 上的一种结构,看看如下的 UTF-8 的字符串:
|
|
以 let hello = String::from("Hola"); 为例,它的 len 是 4,意味着存储字符串“Hola”的向量占用 4 个字节。在 UTF-8 编码中,每一个字母都占用一个字节;然而,下一行可能会让你感到惊讶(请注意,这个字符串以大写的西里尔字母“Ze”开头,而不是数字 3):
|
|
如果你被问及字符串的长度,你可能会说 12。实际上,Rust 的答案是 24:这是编码“Здравствуйте”为 UTF-8 所需的字节数,因为该字符串中的每个 Unicode 标量值需要 2 个字节的存储空间。因此,字符串字节的索引并不总是与有效的 Unicode 标量值对应。为了说明这一点,参考以下无效的 Rust 代码:
|
|
你已经知道,answer 不会是 “З”——这个单词的第一个字母。当用 UTF-8 编码时,“З” 的第一个字节是 208,第二个字节是 151,所以看起来 answer 似乎应该是 208;然而 208 并不是一个有效的独立字符。如果用户想获取这个字符串的第一个字母,返回 208 显然不是他们想要的结果。即使字符串里只包含拉丁字母,用户通常也不希望返回字节值:假如 &"hi"[0] 是合法代码并返回字节值,那会得到 104,而不是字符 'h'。
因此,Rust 的答案是:为了避免返回意外的值并在日后埋下难以察觉的 bug,这段代码根本不会被编译——在开发早期就把误解扼杀在摇篮里。
8.2.7 字节(Byte)、标量值(Scalar Value)、字素簇(Grapheme Clusters)
关于 UTF-8 的另一个要点是:从 Rust 的角度来看,字符串实际上有三种相关的观察方式——字节(bytes)、标量值(scalar values)以及字素簇(grapheme clusters,最接近我们日常所说的“字母”)。
以天城文书写的印地语单词 “नमस्ते” 为例,它在内存中存成一个 u8 向量,其字节序列如下:
|
|
这 18 个字节就是计算机如何存储这个单词的形式。如果我们使用 Unicode 中标量值的视角,即 Rust 中的 char 类型,这些字节长这样:
|
|
这里有六个 char 值,但第四个和第六个不是字母:它们是变音符号,单独使用没有意义。最后,如果我们把它们看作是字素簇,我们就会得到一个人会称之为构成印地语单词的四个字母的东西:
|
|
Rust 提供了多种方式来解释计算机实际存储的原始字符串数据,从而让每个程序都能根据自己的需求选择合适的解释,无论这些数据属于何种人类语言。
Rust 不允许我们用索引直接获取 String 中字符的最后一个原因是:索引操作通常被期望在常数时间 O(1) 内完成。然而对于 String 来说,这一点无法保证——为了确定给定索引处到底有多少个有效字符,Rust 必须从字符串开头一路遍历到该位置。
8.2.8 切片字符串
对字符串进行索引通常是个糟糕的主意,因为很难确定索引操作应该返回什么类型:字节值、单个字符、字素簇,还是字符串切片?因此,如果你真的需要用索引来创建字符串切片,Rust 要求你更明确一些。
与其用单个数字的 [] 索引,不如用 [] 配合一个区间来创建包含特定字节的字符串切片:
|
|
这里,s 就是一个字符串切片 &str 类型,它包含了 hello 的前四个字节存储的字符。之前我们说过,“Здравствуйте”中的每个字母都占用两个字节,所以 s 就是 Зд。
如果我们试图用类似 &hello[0..1] 的方式,只对某个字符的一部分字节进行切片,Rust 会在运行时直接 panic,效果就像在向量中用无效索引访问元素一样:
|
|
编译结果如下:
但是如果我们换成下列的代码又会是可行的了:
|
|
这是因为“Hello”中每一个字母只占用一个字节,所以第一个索引值是有意义的,此时的 s 其实就是字母 h。
8.2.9 遍历字符串的方法
操作字符串片段的最佳方式是显式声明你到底想要“字符”还是“字节”。
若要获取单个 Unicode 标量值(char),请使用 chars 方法。对字符串 "Зд" 调用 chars 会将其拆成两个 char,随后即可遍历结果来逐元素访问:
|
|
相反,bytes 方法会返回原始的字节值:
|
|
但务必记住:有效的 Unicode 标量值可能由不止一个字节组成。
如果想从字符串中提取字素簇(例如天城文书写的文本),情况会更复杂,因此标准库并未提供这一功能。若你需要这种能力,可在 crates.io 上找到相应的 crate。
8.2.10 字符串并不简单
总之,字符串确实复杂。不同语言在“如何把这种复杂性呈现给开发者”上做了不同取舍。Rust 选择把“正确处理 String 数据”设为所有程序的默认行为,这意味着开发者必须在一开始就认真考虑 UTF-8 数据的处理。这种权衡让 Rust 暴露出的字符串复杂度看起来比其他语言更高,但它能让你在后续开发生命周期中免于处理各种涉及非 ASCII 字符的错误。
好消息是,标准库基于 String 和 &str 提供了大量开箱即用的功能,帮助你正确应对这些棘手场景。别忘了去查阅文档,像 contains(在字符串中搜索)和 replace(用另一段字符串替换部分内容)等方法都非常有用。
接下来,让我们切换到不那么复杂的话题:哈希映射!
8.3 哈希映射(Hash Map)
我们要介绍的最后一个常用 集合 是 哈希映射(hash map)。HashMap<K, V> 类型通过 哈希函数 把类型为 K 的 键 映射到类型为 V 的 值,并决定它们在 内存 中的存放位置。许多编程语言都支持这种 数据结构,只是名称各不相同,例如 hash、map、object、hash table、dictionary 或 associative array 等。
当你不想用 向量 那样的整数索引,而是想用任意类型的 键 来查询数据时,哈希映射 就非常有用。例如,在游戏中,你可以用 哈希映射 来记录各队得分:键 是队名,值 是该队的分数。给定队名即可快速查到对应分数。
本节仅介绍 哈希映射 的基本 API,但标准库在 HashMap<K, V> 上还提供了大量实用功能。一如既往,请查阅标准库文档获取更多信息。
8.3.2 创建一个哈希映射
我们使用 new 方法创建一个空的 哈希映射,使用 insert 方法来给一个已经存在的 哈希映射 添加元素。下面我们使用 哈希映射 来持续追踪两个队的得分,假设黄队初始分数为 50,蓝队的初始分数为 10:
|
|
由于相比起 向量 和 字符串,哈希映射 使用的最少,所以它并不会自动被包含到我们的项目中。我们想要使用 哈希映射 首先需要使用 use 将 哈希映射 给带入我们的 作用域。并且标准库对于 哈希映射 的支持也比较少,就比如没有任何类似 vec![] 和 format! 的 宏 来处理 哈希映射。
和 向量 一样,哈希映射 也是在 堆 上存储。我们这个例子中,该哈希的 键 是 String 类型,值 是 i32 类型;和 向量 一样,在一个哈希中,所有的 键值对 的 数据类型 必须是一致的,也就是说 键 必须是同一种类型,值 也必须是同一种类型。
8.3.3 获取哈希中的值
我们可以使用 get 方法来获取一个哈希中的 值。
|
|
这行代码中,score 最后会得到哈希中 键 Blue 所对应的 值,也就是 10。get 方法返回的 值 是 Option<&V>,如果这个位置没有 值,就会返回 None。这里使用 copied 方法获得 Option<i32> 而不是 Option<&i32>,然后使用 unwrap_or:如果 scores 里没有这条 键,就把 score 设为 0。
[!NOTE]
copied是定义在Option和Iterator上的适配器方法,用来把引用里的值复制一份,从而把Option<&T>(或迭代器里的&T)变成Option<T>(或迭代器里的T)。
- 在
Option上的签名
1 2 3 4 5impl<T> Option<&T> { pub fn copied(self) -> Option<T> where T: Copy; }
- 作用:把
Option<&T>转成Option<T>。- 前提:
T必须实现Copy(如i32、bool、char等简单类型)。
- 典型用法
1 2 3 4 5 6 7 8use std::collections::HashMap; let mut map = HashMap::new(); map.insert("Blue", 10); let score: i32 = map.get("Blue") // Option<&i32> .copied() // Option<i32> .unwrap_or(0); // 没有就返回 0如果不加
.copied(),map.get("Blue")得到的是Option<&i32>,直接unwrap_or(0)会因为类型不匹配而报错。
- 与
cloned的区别
copied要求T: Copy,只做按位复制,速度快。cloned要求T: Clone,会执行Clone::clone,允许更复杂的复制逻辑。简单类型优先用
copied;需要深拷贝或自定义复制逻辑时用cloned。
8.3.4 哈希映射与所有权
对于实现了 Copy 特质 的类型,比如 i32 ,值 会被复制到 哈希映射 中。对于像 String 这样的拥有 值,值 会被移动,哈希映射 将成为这些 值 的拥有者:
|
|
如果解除注释之后再编译,会发现如下报错:
温习一下之前的关于仅 栈 数据(stack-only data)的内容,对于类似于 i32 这样的只存在于 栈 上的数据,是实现了 copy trait 的,意味着它可以在 栈 上快速创建副本,所以在值传递的时候是传递的副本,而不是 所有权 的转交,而像 字符串 这样比较复杂 数据类型,那么传递 值 就是通过 所有权 转交。所以我们这里 field_name 和 field_value 在传入给哈希之后,所有权 就哈希所有,我们在后面就不能调用它了。
如果我们向 哈希映射 中插入 值 的 引用,这些 值 不会被移动到 哈希映射 中。引用 指向的 值 必须至少在 哈希映射 有效期间保持有效。
8.3.5 更新哈希映射
虽然 键值对 可以一直增长,但是每个 键 只能有一个 值,但是同一个 值 可以有多个 键。例如,蓝队和黄队都可以在哈希表 scores 中存储 值 10
当你想修改 哈希映射 中的数据时,必须决定 “键 已存在” 时的策略:
- 直接覆盖旧 值——完全忽略旧 值,用新 值 替代。
- 保留旧 值——仅当 键 不存在时才插入新 值,已存在则保持原样。
- 合并更新——把旧 值 与新 值 按某种规则合并后再存回去。
接下来我们逐一演示这三种做法。
8.3.5.1 覆写数据
当我们使用 insert 尝试在一个原有 值 的 键 中插入一个新 值,这个新 值 就会直接覆盖掉原来的 值:
|
|
比如这里,虽然 Blue 插入了两次 值,但是他只会存储最新的 值,也就是 25。
8.3.5.2 当一个键不存在的时候再添加新的键值对
一种常见需求是:先检查 哈希映射 中某个 键 是否已经存在 值,然后按以下规则处理——如果 键 已存在,保持原 值 不变;如果 键 不存在,则插入该 键 并设定对应 值。
哈希映射 为此提供了专门的 API:entry。它接收你想检查的 键 作为参数,返回一个名为 Entry 的 枚举,表示该 键 对应的 值 可能存在,也可能不存在。
假设我们想检查 Yellow 队对应的 键 是否已有 值:如果没有,就插入 值 50;对 Blue 队也做同样处理。
|
|
Entry 类型中的 or_insert 的作用是,如果这个 键 有 值,则会返回这个 值 的 可变引用;如果没有,则会插入它接收的入参作为该 键 的 值,并且返回这个新 值 的 可变引用。
运行的代码将打印 {"Yellow": 50, "Blue": 10}。
第一次调用 entry 时,由于 Yellow 队尚无对应 值,会把 键 Yellow 连同 值 50 插入 哈希映射;
第二次调用 entry 时,Blue 队已存在 值 10,因此 哈希映射 保持不变。
8.3.5.3 基于旧值更新
哈希映射 的另一个常见用法是:先查某个 键 对应的 值,再基于旧 值 做更新。
示例的代码就展示了这种场景:统计一段文本里每个单词出现的次数。我们把单词作为 键 存入 哈希映射,每遇到一次就把 值 加 1;若首次遇到该单词,则先插入初始 值 0:
|
|
这段代码会打印出 {"world": 2, "hello": 1, "wonderful": 1}。你可能看到同样的 键值对 以不同顺序输出:回忆一下 “获取哈希中的值” 一节,遍历 哈希映射 的顺序是任意的。
split_whitespace 方法会返回一个 迭代器,按空白字符把 text 切分成多个子切片。or_insert 方法返回指定 键 所对应 值 的 可变引用(&mut V)。在这里,我们把该 可变引用 存进 count 变量,因此要想修改这个 值,必须先使用星号(*)对 count 进行 解引用。在 for 循环结束时,该 可变引用 离开 作用域,因此所有这些改动都是安全的,也符合 借用规则。
8.3.6 哈希函数
默认情况下,HashMap 使用名为 SipHash 的 哈希函数,它能抵御针对 哈希表 的拒绝服务(DoS)攻击。这并不是最快的 哈希算法,但为换取更高的安全性而牺牲一些性能,这种权衡通常是值得的。
如果你在性能剖析中发现默认 哈希函数 过慢,可以通过指定另一个 hasher(实现 BuildHasher trait 的类型)来切换算法。第 10 章将介绍 trait 及其实现方式。你不必从零自己实现 hasher;crates.io 上已有许多社区共享的库,提供了多种常见 哈希算法 的现成 hasher。
8.4 课后训练
- 给定一个整数列表,使用 向量 并返回中位数和众数:
|
|
- 将 字符串 转换成 Pig Latin。每个单词的首个辅音被移到词尾并加上 ay,因此 first 变成 irst-fay。以元音开头的单词则在末尾加 hay(apple 变成 apple-hay)。
方式 1:只使用学过的知识
|
|
方式 2:使用高级的方法
|
|
- 使用 哈希映射 和 向量,创建一个文本界面,让用户能够把员工姓名添加到公司的某个部门;例如,“Add Sally to Engineering” 或 “Add Amir to Sales”。然后允许用户按部门获取该部门所有人员的列表,或按部门获取全公司所有人员的列表,并按字母序排序。
|
|
Chapter 9 错误处理
在编程中遇到错误是很正常的,Rust 提供了很多种方式去处理错误。大多数情况,Rust 希望你能提 前预知错误,并且再编译前采取一些措施来正确的处理它;这让我们写的程序更加稳固。
Rust 将错误分成了两类:可恢复(recoverable )的错误、不可恢复(unrecoverable )的错误。可恢复的错误比如“file not found error”,我们通常的解决方案是像用户报告这个 错误并且重试。不可恢复的错误通常就是一些 bug,比如说数组越界访问,对于这种情况,我们通常希望是直 接终止程序。
大多数语言不会去区分这两种错误,他们通常使用一套机制来处理所有的错误——exception。相反,Rust 虽然不提供 exception 机制,但是为可恢复的错误提供了 Result<T,E>,对于不可恢复错误 panic! 宏会终止程序的运行。本章会先讨论 panic!,然后再讨论返回一个 Result<T, E> 类型的值。另外,处理一个错误是尝试去恢复它还是终止程序,这是一个值得考量的点。
9.1 不可恢复错误与 panic! 宏
有时候我们遇到了不可恢复的错误,并且对此无能为力。针对这种情况,Rust 提供了 panic! 宏。程序进入 panic 状态有两种可能:第一种就是程序的某些操作导致了 panic,比如说数组越界访问;第二种情况就是我们主动调用 panic 宏。默认情况下,程序进入 panic 的时候会打印一些调试信息。通过一个环境变量,我们可以让 Rust 展示 发生 panic 的时候的调用栈,让我们更快速的处理错误。
默认情况下,当程序进入 panic 状态时,Rust 程序会开始 unwinding,它会返自己调用的所有栈,然后 清除里面的数据,最后再终止。当然,这是一个很耗时的工作,所以 Rust 也允许我们 aborting,即 直接终止程序,不去清理调用的栈。这种情况下,清理栈的工作就交给了操作系统,如果我们想要获得一个 极小占用的二进制可执行文件,我们就可以再 Cargo.toml 中的 [profile] 片段中加上 panic = 'abort';如果只是想要再 release 版本中这样做,就可以像下面一样操作:
|
|
当我们编写如下一个自己手动触发 panic 的程序:
|
|
编译结果如下:
调用 panic! 宏导致输出了两行包含我们的错误信息的消息和具体发生 panic 的位置。src\main.rs:2:5: 意味 panic 发生于第 2 行的第 5 个字,紧接着就是我们想要输出的错误信息:crash and burn。
有时候可能是由我们自己的程序调用的 panic! 宏,有时候也有可能是别人写的库函数,我们调用这个库函数,由这个库函数调用了 panic! 宏;这样我们就可以根据提示的信息去查看具体问题出在哪里。
我们可以通过查看 panic! 的 回溯(back-trace) 来查看具体是哪部分代码出了问题。要想具体理解回溯的概念,我们可以先看看库函数的 panic! 发生时候的信息:
|
|
编译执行这段代码,会发现输出如下结果:
这是一个很明显的数组越界操作,理论上说 [] 会返回向量在该索引处的值,但是由于发生了 panic,所以并不会返回任何值。
在 C 语言中,数组越界是个未定义行为。你会得到在这个位置的内存上存储的值,但绝对不是你想要的值。这就叫做“缓冲区越界”(bufffer overread),并且会导致内存漏洞。Rust 为了避免这一点,所以会组织你执行这段程序。
错误信息中的 note 告诉我们,我们可以设置环境变量 RUST_BACKTRACE 去回溯查看由什么造成了这个错误。所谓回溯,所有造成这个错误的函数列表。查看回溯的方法就是,从头开始看,直到看到你自己编写的文件为止,而这个位置就是发生错误的具体位置。这个位置之前的所有是你的程序已经调用的代码,之后的位置是所有调用了你的代码的代码。这些位置可能包含标准库或者任何 你添加的 crate 中的函数。接下来尝试把环境变量 RUST_BACKTRACE 设置成 任意非 0 值,执行 RUST_BACKTRACE=1 cargo run,你可以看见如下报错:
会发现比之前的输出多了很多,根据我们项目的依赖和配置不同,可能由不同的输出。但是使用回溯之前,一定要保证调试符号开启。在我们使用 cargo build 或者 cargp run 不加上 –-release 参数的时候,调试符号是默认开启的。
在示例的输出中,回溯第 6 行指向了我们项目中导致问题的那一行:src/main.rs 的第 4 行。如果我们不想让程序 panic,就应该从回溯信息里第一条提到我们自己文件的位置开始调查。我们故意写了会触发 panic 的代码,解决的办法就是不要请求超出向量索引范围的元素。将来当你的代码 panic 时,你需要弄清代码在用什么值执行什么操作导致了 panic,并决定代码应当如何处理这些情况。
稍后本章的一节中,我们会再讨论 panic! 以及何时应或不应使用 panic! 来处理错误。接下来,我们先看看如何用 Result 从错误中恢复。
9.2 可恢复错误与 Result
多数情况下,一个错误可能并没有严重到需要终止程序的地步。一些函数执行失败的原因可能我们很好处理;比如说我们有一个尝试打开某个文件的函数执行错误了,我们的处理方式应该是 重新打开或者尝试创建一个文件,而不是直接终止程序。
在第二章中我们就有接触到 Result 枚举,它的定义如下:
|
|
T 和 E 是泛型;其中 T 代表 执行成功时将要返回的值的数据类型,通过 Ok 将值返回出去。E 代表 执行错误的时候将要返回的值,通过 Err 返回。正是因为泛型,所以它的适用性特别强,几乎适用于所有执行成功和执行失败的值不同的情况。
接下来我们试着调用一个返回值为 Result 类型的函数,因为其有可能执行失败:
|
|
File::open 的返回类型是 Result<T, E>。这里的泛型参数 T 已由 File::open 的实现指定为 成功值 的类型,即 std::fs::File,它是一个文件句柄;而 E 被指定为 错误值 的类型,即 std::io::Erro r。
这个返回类型意味着 调用 File::open 可能会成功并返回一个 可用于读写操作的文件句柄,但也有 可能失败:例如,文件可能不存在,或者我们没有访问该文件的权限。File::open 需要有一种方式告诉我们它是否成功,同时返回文件句柄或错误信息——这正是 Result 枚举 所传达的信息。
当 File::open 成功时,变量 greeting_file_result 中的值将是 Ok 的一个实例,其中包含一个文件句柄;而当调用失败时,greeting_file_result 中的值将是 Err 的一个实例,其中包含有关所发生错误类型的更多信息。
我们需要在实例的代码基础上增加逻辑,以便根据 File::open 返回的值采取不同的操作。
下面使用基础工具——我们在第 6 章讨论过的 match 表达式——来处理 Result 的一种办法。
|
|
与 Option 不同,Result 是 默认被集成到 Rust 中的,所以我们不不需要使用 Result:: 来引出这个枚举值。
当枚举值为 Ok,我们就返回其内部包含的 file 变量。之后我们想要操作这个文件,我们就可以使用这个 file 作为文件的句柄;如果枚举值是 Err,我们就手动让程序进入 panic;如果我们没有 hello.txt 文件,则会看到下面的输出:
9.2.1 匹配不同类型的错误
刚刚举得例子中不论函数为什么执行失败,都会 panic。但是多数情况下,我们想要的是针对不同情况的错误进行不同的处理,而不是一棒子打死。比如说如果是因为文件不存在而错误,我们可能会想去创建一个文件,并且返回这个新文件的句柄;如果是因为我们没权限去处理这个文件,我们可能就选择执行 panic! 宏。
|
|
[!TIP]
处理
Result<T,E>的其他方法:上述我们处理
Result就是简单粗暴的堆砌match表达式,虽然很有用,但是也让整个程序不易读。在 13 章中,我们将介绍一种新的方式——闭包(closures)。将他与Result<T,E>中的其他定义的方法结合在一起,会比使用match更加简洁;比如下列程序就使用闭包达到了和上述程序一样的效果,而且更易读:
1 2 3 4 5 6 7 8 9 10 11 12 13 14use std::fs::File; use std::io::ErrorKind; fn main() { let greeting_file = File::open("hello.txt").unwrap_or_else(|error| { if error.kind() == ErrorKind::NotFound { File::create("hello.txt").unwrap_or_else(|error| { panic!("Problem creating the file: {error:?}"); }) } else { panic!("Problem opening the file: {error:?}"); } }); }
9.2.2 处理错误的快捷方式——unwarp 和 expect
我们还有一种更快捷的方式去处理错误,Reuslt<T,E> 提供了多种方法来帮组我们完成这种复杂的匹配工作。unwarp 就像我们上面写的 match 一样的工作方式,如果结果是 Ok,那么 unwarp 就会返回 Ok 中包含的值,如果结果是 Err 则会调用 panic!。下面是一个使用例子:
|
|
如果我们在没有 hello.txt 文件的情况下运行这段代码,我们会看见 panic! 的如下输出:
与 unwarp 相似,我们使用 expect 的时候,在其入参填入 panic 时候的报错信息。使用 expect 让我们更好传达我们的意图,在回溯的时候更快捷方便的处理我们的错误。其语法如下:
|
|
与 unwarp 的用法一样,执行成功就返回文件的句柄,执行失败就 panic!,只不过这里的信息是我们自己填入的信息。
将上述代码编译之后输出如下:
可以看到,我们自己编写的错误信息 “hello.txt should be included in this project” 也在终端中输出了。
9.2.3 传播错误(Propagating Errors)
当一个函数调用出现错误,除了函数自己处理这个错误之外,我们还可以通过 Result 将错误值返回给调用这个函数的代码,让代码的灵活性更高,这就被称作传播错误。传播错误让代码拥有更多控制权,因为代码中可能包含更多的信息和对错误的处理逻辑。
比如下面这个例子,尝试从文件中读取一个用户名。如果文件不存在或者没有权限访问这个文件,函数机会将错误返回给调用这个函数的代码中:
|
|
这段函数其实可以用 更简短 的方式编写,但为了让读者充分理解错误处理,我们先手动一步步实现;最后,我们再展示简写形式。
首先,看函数的返回类型:
Result<String, io::Error>
这意味着函数返回一个 Result<T, E>,其中 泛型参数 T 被具体类型 String 取代,而 泛型参数 E 被具体类型 io:: Error 取代。
- 如果函数 成功,调用方会收到一个
Ok,里面装着 从文件读到的用户名(String)。 - 如果函数 失败,调用方会收到一个
Err,里面装着io::Error,提供 更详细的错误信息。
之所以选择 io::Error 作为返回类型,是因为函数体内 可能失败的两个操作——File::open 和 read_to_string——恰好都返回 io::Error。
函数体首先调用 File::open,然后用 match 处理 Result:
- 若
File::open成功,模式变量file中的文件 句柄会被赋给可变变量username_file,函数继续执行。 - 若
File::open失败,我们不再调用panic!,而是使用return提前返回,把File::open产生的错误值(模式变量e)原样返回给调用方。
若 username_file 里已拿到文件句柄,函数接着新建一个 String 变量 username,并在 username_file 上调用 read_to_string,把文件内容读到 username 中。
read_to_string 同样返回 Result,因为它也可能失败,即使之前 File::open 已成功。
因此,我们需要再写一个 match:
- 若
read_to_string成功,函数成功结束,我们将username用Ok包装后返回。 - 若
read_to_string失败,我们以同样方式返回错误值。不过,此时 无需显式写return,因为这是函数的 最后一个表达式。
调用方拿到结果后,再决定如何处理:
- 若收到
Ok,就取出用户名。 - 若收到
Err,可以panic!崩溃,也可以用默认用户名,或从别的途径查找用户名。
由于我们无法预知调用方的意图,我们把成功或失败的信息全部向上传播,让调用方自行处理。
这种 错误传播模式 在 Rust 中非常常见,因此 Rust 提供了 ? 运算符 来简化它。
9.2.4 使用 ? 操作符简化错误传播
下面这个函数和上面的实例的功能相同,但是区别是使用了 ? 来让函数更简洁:
|
|
这里在 Result 之后的 ? 是和上面的 match 表达式相同的工作方式。如果 Result 的值是 Ok,那么 Ok 存储的值就会从表达式中返回出来。如果是 Err,这个错误就会直接让整个函数 return,将错误返回给函数调用的代码中。
match 表达式与 ? 运算符之间有一个区别:被 ? 运算符作用的错误值会经过标准库中 From trait 定义的 from 函数,该函数用于将一种类型的值转换成另一种类型。当 ? 运算符调用 from 函数时,所接收到的 错误类型会被转换成当前函数返回类型中定义的错误类型。这在函数需要返回一种错误类型来代表所有可能的失败方式时非常有用,即使各个部分可能因不同原因失败。
例如,我们可以把 read_username_from_file 函数的返回类型改为一个我们自定义的错误类型 OurError。如果我们同时为 OurError 实现 impl From<>,使其能够从 io::Error 构造一个 OurError 实例,那么 read_username_from_file 函数体中的 ? 运算符就会调用 from 完成错误类型的转换,而无需再添加额外代码。
在实例的上下文中,File::open 调用末尾的 ? 会将 Ok 内部的值返回给变量 username_file;如果发生错误,? 运算符会提前返回整个函数,并将任何 Err 值交给调用方。read_to_string 调用末尾的 ? 同理。
? 运算符消除了大量样板代码,让该函数的实现更简单。我们甚至可以在 ? 之后立即链式调用方法,进一步缩短代码,如下所示所示:
|
|
我们把创建新 String 的语句提前到了函数开头,这部分没有变。
与之前不同的是,我们不再创建变量 username_file,而是直接把 read_to_string 调用链式地跟在 File::open("hello.txt")? 的结果后面。
read_to_string 调用末尾仍然有一个 ?,并且当 File::open 和 read_to_string 都成功时,我们仍然返回包含 username 的 Ok;如果发生错误,则返回对应的 Err。
下面展示了利用 fs::read_to_string 把代码进一步缩短的方法:
|
|
将文件读入字符串是 一种相当常见的操作,因此标准库提供了方便的 fs::read_to_string 函数,该函数打开文件,创建一个新的 String ,读取文件内容,将内容放入该 String ,然后返回它。当然,使用 fs::read_to_string 并没有给我们机会解释所有的错误处理,所以我们先用了更冗长的处理方式。
9.2.5 ? 适用于什么地方
? 运算符只能用于返回类型与 ? 所作用的值相兼容的函数中。这是因为 ? 运 算符被定义为提前从函数中返回一个值,其方式与我们在定义的 match 表达式相同。在之前的例子中,match 使用的是一个 Result 值,而提前返回的分支返回的是 Err(e)。因此函数的返回类型必须是 Result,才能与此返回值兼容。
让我们看看如果在 main 函数中使用 ? 运算符,而 main 的返回类型与我们对其使用 ? 的值类型不兼容时,会得到怎样的错误:
|
|
编译报错如下:
可以看到提示我们说,? 只能被用在返回 Result 或者 Optiont 或者其他实现了 FromResidual 的数据类型。但是我们的 main 函数没有返回值,或者说是返回一个 ();这与 ? 的要求是不符合的。所以会报错。
要修复这个错误,你有两种选择。第一种选择是将函数的返回类型改为与 ? 运算符所作用的值相兼容的类型,只要没有任何限制阻止你这么做即可。第二种选择是使用 match 或 Result<T, E> 的某个方法来以合适的方式处理 Result<T, E>。
错误信息还提到,? 也可以用于 Option 值。与在 Result 上使用 ? 类似,你只能在返回类型为 Option 的函数中对 Option 使用 ?。当 ? 作用于 Option 时,其行为与作用于 Result<T, E> 时类似:如果值是 None,则在该点提前返回 None;如果值是 Some,则获取 Some 中的值作为表达式的结果,函数继续执行。下面给出了一个示例函数,该函数在给定文本中查找第一行的最后一个字符:
|
|
该函数返回 Option<char>,因为那里可能有一个字符,也可能没有。这段代码接受一个字符串切片参数 text,并对其调用 lines 方法,该方法返回一个遍历字符串中各行的迭代器。由于本函数想检查第一行,于是对迭代器调用 next 以获取第一个值。如果 text 是空字符串,这次 next 调用将返回 None,此时我们使用 ? 停止并从 last_char_of_first_line 返回 None。 如果 text 不是空字符串,next 会返回一个 Some,其中包含 text 第一行的字符串切片。
? 提取出该字符串切片,然后我们可以对其调用 chars 以获得字符迭代器。我们关心第一行的最后一个字符,于是调用 last 返回迭代器中的最后一项。由于第一行也可能是空字符串(例如 text 以空行开头但后续行有字符,如 "\nhi"),因此 last 返回 Option。然而,如果第一行确实有最后一个字符,它将以 Some 变体返回。中间的 ? 运算符让我们能用简洁的方式表达这一逻辑,从而在一行内实现该函数。若不能对 Option 使用 ?,我们就得用更多方法调用或 match 表达式来实现此逻辑。
请注意,你可以在返回 Result 的函数中对 Result 使用 ?,也可以在返回 Option 的函数中对 Option 使用 ?,但不能混用。? 运算符不会自动将 Result 转换为 Option 或反之;在这些情况下,可以使用 Result 上的 ok 方法或 Option 上的 ok_or 等方法显式完成转换。
到目前为止,我们使用的所有 main 函数都返回 ()。main 函数很特殊,因为它是可执行程序的入口和出口,为了保证程序行为符合预期,其返回类型受到限制。
幸运的是,main 也可以返回 Result<(), E>;我们将 main 的返回类型改为 Result<(), Box<dyn Error>>,并在末尾添加了返回值 Ok(()),现在这段代码就能编译通过了:
|
|
Box<dyn Error> 类型是一种 trait 对象,我们将在第 18 章中讨论。目前,你可以把 Box<dyn Error> 理解为“任何种类的错误”。在返回 Box<dyn Error> 作为错误类型的 main 函数中,对 Result 值使用 ? 是被允许的,因为它允许任何 Err 值被提前返回。尽管这个 main 函数体目前只会返回 std::io::Error 类型的错误,但通过指定 Box<dyn Error>,即使以后在 main 函数体中添加了返回其他错误的代码,此签名仍将保持正确。
当 main 函数返回 Result<(), E> 时,可执行文件会在 main 返回 Ok(()) 时以 0 退出,在返回 Err 时以非零值退出。用 C 语言编写的可执行文件在退出时会返回整数:成功退出的程序返回整数 0,出错的程序返回非零整数。为了与此约定兼容,Rust 的可执行文件也会返回整数。
main 函数可以返回任何实现了 std::process::Termination trait 的类型,该 trait 包含一个返回 ExitCode 的 report 函数。如需了解如何为你自己的类型实现 Termination trait,请查阅标准库文档。
既然我们已经讨论了调用 panic! 或返回 Result 的细节,让我们回到如何决定在何种情况下使用哪一种更合适的话题。
9.3 是否使用 panic!
那么,你该如何决定何时调用 panic!,何时返回 Result 呢?当代码触发 panic! 时,就无法再恢复。你可以在任何错误场景下直接 panic!,无论是否存在恢复的可能,但这等于你替调用方做出了“此情况不可恢复”的决定。而选择返回 Result 值,则把选择权交给调用方:它可以根据自身场景尝试恢复,也可以认定这个 Err 不可恢复,于是调用 panic! 把你原本“可恢复”的错误变成“不可恢复”。因此,在定义一个可能失败的函数时,把返回 Result 作为默认选择通常是更好的做法。
在示例代码、原型代码以及测试代码这类场景里,直接让代码 panic! 往往更合适。接下来我们会探讨原因,并讨论一些编译器无法、但人类可以断定“失败绝不可能”的情况。本章最后会给出在库代码中决定是否 panic! 的一般性指导原则。
9.3.1 例子、原型和测试
当你写示例代码来说明某个概念时,如果还加上完善的错误处理,反而会让 示例显得不够清晰。在示例里,大家都理解像 unwrap 这样可能触发 panic! 的调用只是占位符,代表你真正在应用中处理错误的方式——具体怎么做则取决于代码其余部分的实际需求。
同理,在原型阶段尚未决定如何处理错误时,unwrap 和 expect 非常方便。它们在你的代码里留下了清晰的标记,提醒你稍后要让程序更健壮。
如果测试中的某个方法调用失败,即使该方法不是当前正在测试的功能,你也希望整个测试失败。由于 panic! 正是标记测试失败的手段,因此在测试中调用 unwrap 或 expect 恰恰是最合适的选择。
9.3.2 在你可以比编译器获得更多信息的情况下
当你通过其他逻辑已经 确保 Result 必定是 Ok,但这种保证编译器无法识别时,调用 expect 也是合适的。此时你仍然得到一个需要处理的 Result:一般而言,被调用的操作仍有失败的可能,只是在你当前的特定情境下逻辑上不可能出错。如果你能人工检查代码并确认永远不会出现 Err,那么使用 expect 并在参数文本中说明你认为不会出错的理由,就是完全可以接受的。示例如下:
|
|
我们通过解析一段硬编码的字符串来创建 IpAddr 实例。明显可以看出 127.0.0.1 是一个合法的 IP 地址,因此在这里使用 expect 是合理的。然而,即 使字符串是硬编码且有效的,parse 方法的返回类型依旧是 Result;编译器仍 强制我们像处理可能出错的情况一样去处理这个 Result,因为它无法聪明到识别出该字符串必然合法。如果 IP 地址字符串来 自用户输入而非硬编码,从而确实存在失败的可能,我们就必须以更健壮的方式来处理 Result。通过在注释中指出“该 IP 地址是硬编码的”这一假设,将来若需要从其他来源获取 IP 地址,我们会记得把 expect 替换为更完善的错误处理代码。
9.3.3 错误处理指南
当代码有可能进入不良状态时,建议让它直接 panic!。这里的“不良状态”指的是某 些假设、保证、约定或不变量被破坏——例如向你的代码传递了无效值、矛盾值或缺失值——并且满足以下至少一条:
- 这种不良状态是意料之外的,而不是像“用户偶尔输错格式”那样可能发生;
- 后续逻辑必须建立在“不会进入此状态”之上,而不是每一步都去检查问题;
- 无法用你现有的类型系统把这一信息编码出来(第 18 章“将状态和行为编码为类型”会举例说明)。
如果调用者传入明显不合理的值,最好返回 Result,让库的使用者决定如何处理。但若继续执行会带来安全隐患或危害,就应 panic!,提醒开发者修复 bug。同样,当你调用不可控的外部代码而它返回无法修复的无效状态时,panic! 也往往是合适的。
相反,如果 失败是可预期的——解析器遇到格式错误的数据、HTTP 请求返回限流状态等——就应返回 Result,表明失败是调用方必须处理的正常情形。
当某操作若使用 无效值会让用户面临风险时,代码应先验证值的有效性,无效则 panic!。这主要是出于安全考虑:在无效数据上操作容易暴露漏洞。标准库对越界内存访问就 panic!,原因即在此。函数往往带有“契约”:仅当输入满足特定要求时行为才有保障。违反契约即调用方 bug,不应让调用代码显式处理,也无可恢复,因此 panic! 是合理的。这类契约及其违反时的行为应在 API 文档中写明。
然而,在每个函数里大量手写检查既啰嗦又烦人。幸运的是,Rust 的类型系统(以及编译器的类型检查)能帮你完成许多检查。例如:
- 参数是某具体类型而非
Option<T>时,你已确保调用者必然提供了值,无需再分Some/None两种情况;试图传None连编译都过不了。 - 使用
u32等无符号整型,就能保证参数绝不会是负数。
9.3.4 创建用于验证的自定义数据类型
接下来我们尝试使用 Rust 的数据类型体系来确保不会出现明显错误,然后再试着创建一个自己的数据类型来对值进行验证。第二章中我们创建的猜数游戏中,我们预期让用户输入一个 1~100 的值来与我们产生的随机值匹配,但是我们从来没有验证过用户输入的值是不是在合理的范围内;我们只确保了随机数在这个范围内。但是对于我们的猜数游戏来说,这个问题并不严重,因为我们对于大小的判断依旧是有效的,不过限制用户的输入确实可以增强我们程序的逻辑性,让程序对超出我们预设范围的值进行不同的处理。
有一个有效的方法就是将 u32 的限制改为 i32,然后加上范围判断:
|
|
if 表达式先检查数值是否超出范围,向用户指出问题,并调用 continue 开始下一次循环、重新请求输入。经过 if 之后,我们才能确信 guess 已落在 1 到 100 之间,然后再拿它与秘密数字比较。
然而,这并不是理想的方案:如果程序必须只在 1 到 100 之间的值上运行,并且有许多函数都有这一要求,那么在每个函数里都写一遍范围检查既繁琐(也可能影响性能)。
更好的做法是 :在一个独立模块里创建一个新类型,把验证逻辑放在创建该类型实例的函数中,而不是到处重复这些检查。 这样一来,其他函数只要在签名里使用这个新类型,就可以放心地使用收到的值。下面展示了如何定义一个 Guess 类型:只有当 new 函数收到的值介于 1 到 100 之间时,才会创建 Guess 实例。
Filename: src/guessing_game.rs
|
|
请注意,这段位于 src/guessing_game.rs 中的代码依赖于在 src/lib.rs 里加上一条我们尚未展示的模块声明 mod guessing_game;。在这个新模块文件里,我们定义了一个名为 Guess 的结构体,其中有一个字段 value,类型为 i32,用来存放具体的数字。
接着,我们为 Guess 实现了一个关联函数 new,用于创建 Guess 实例。new 函数接收一个名为 value 的 i32 参数,并返回一个 Guess。函数体先检查 value 是否落在 1 到 100 之间;若未通过检查,就调用 panic!,提醒调用者出现了必须修复的 bug,因为用超出此范围的值创建 Guess 会破坏 Guess::new 依赖的约定。Guess::new 可能触发 panic! 的情形应当在其公开的 API 文档中说明;第 14 章将介绍如何在自建 API 文档中标明 panic! 的可能性。如果 value 通过检查,就用该参数创建一个新的 Guess 并返回。
随后,我们实现了一个名为 value 的方法,它借用 self,无其他参数,返回一个 i32。这类方法通常称为 getter,作用是从字段中取出数据并返回。由于 Guess 结构体的 value 字段是私有的,这个公有方法不可或缺。保持 value 字段私有十分重要,这样可以防止外部代码直接设置 value:外部模块必须通过 Guess::new 创建 Guess 实例,从而确保任何 Guess 的值都经过了 Guess::new 中条件的校验。
于是,一个只接受或只返回 1 到 100 之间数字的函数,就可以在签名里声明其参数或返回值为 Guess 而非 i32,而无需再在函数体内做任何额外检查。
9.4 总结
Rust 的错误处理机制旨在帮助你编写更健壮的代码。
panic!宏用于表明程序已处于一个无法继续正常运行的状态,它通知进程停止,而不是带着无效或错误的值继续执行。Result枚举则利用 Rust 的类型系统,指出某些操作可能失败,但你的代码仍有机会从中恢复。你可以用Result告诉调用者:必须妥善处理潜在的成功或失败结果。
在合适的场景下使用 panic! 和 Result,能让你的代码在面对不可避免的问题时更加可靠。
现在,你已经看到标准库如何通过 Option 和 Result 枚举来运用泛型。接下来,我们将讨论泛型的具体工作机制,以及如何在代码中使用它们。
Chapter 10:泛型(Generic Types)、特征(Trait)和生命周期(Lifetime)
每一种编程语言都需要有效处理“概念重复”的工具。在 Rust 中,这样的工具之一就是泛型(generics):它们是对具体类型或其他属性的抽象占位符。我们可以在不知道最终会被替换成什么具体类型的情况下,描述泛型的行为,或它们与其他泛型之间的关系。
函数可以接受某个 泛型类型的参数,而不是像 i32 或 String 这样的具体类型;这与函数通过未知值参数来复用同一段代码、作用于多种具体值的思路如出一辙。事实上,我们早在第 6 章就通过 Option<T>、第 8 章通过 Vec<T> 和 HashMap<K, V>、第 9 章通过 Result<T, E> 使用过泛型。本章将探索如何为你自己的类型、函数和方法定义泛型!
首先,我们会回顾如何通过提取函数来消除代码重复;接着,用同样的技巧把只在参数类型上有差异的两个函数合并为一个泛型函数。我们还会讲解如何在结构体和枚举定义中使用泛型类型。
随后,你将学习如何通过特征(trait)以泛型方式定义行为。把特征与泛型结合,可以给泛型加上约束——使其只接受具备特定行为的类型,而不仅仅是任意类型。
最后,我们将讨论生命周期(lifetimes):一种特殊的泛型,用于向编译器描述引用之间的关系。生命周期让我们能够给编译器提供足够的信息,确保借用值在更多场景下仍然有效——没有这些信息,编译器就无法做到这样的保证。
10.1 通过函数来消除重复内容
上面提到,泛型可以显著降低重复度,其具体实现有点类似于函数;现在先看看不使用泛型的话如何消除代码重复:把一组具体值提取成函数参数,让同一个函数代表多种值。随后,我们将用同样的思路把 “针对不同具体类型的重复代码”提取成泛型函数。通过练习识别可以提取为函数的重复代码,你会逐渐学会识别那些可以用泛型来消除的重复。
首先先看下面的一个代码,从一个列表中找到最大值:
|
|
我们通过简单的遍历向量,让向量的每一个值都与我们预设的 max 值对比,只要有一个值大于我们预设的 max,那么 max 就会更新;通过这样的方式不断迭代就可以找到向量中的最大值。
现在我们的新任务是找到两个不同的列表中的最大值,我们先不使用函数,在 main 中重复代码段:
|
|
可以看到,虽然代码可以正常运行,但是重复程度特别高。这导致当我们想要修改代码内容的时候,我们需要修改很多地方。
为了解决这个问题,我们采取函数的方式,定义一个通用的函数,让函数接收不同的入参来到达到减少重复的目的:
|
|
现在,当有相同需求的时候,我们只需要将要找最大值的集合作为参数传入我们的 find_max 函数即可。显著降低了代码的重复性。
总结起来,我们做了以下三步:
- 找出重复代码。
- 把重复代码提取到函数体中,并在函数签名里指定这些代码所需的输入和返回值。
- 将两处原先重复的代码改为调用该函数。
接下来,我们将用完全相同的思路,但改用泛型来减少代码重复。正如函数体可以对“抽象的列表”而非具体值进行操作一样,泛型允许代码对“抽象的类型”进行操作。
举个例子:假设我们有两个函数——一个用于找出 i32 切片中的最大值,另一个用于找出 char 切片中的最大值。该如何消除这种重复?让我们一探究竟!
10.2 泛型
定义泛型的时候就像定义一个函数、结构体一样,我们可以使用很多确切的数据类型。我们先来看看如何使用泛型来定义一个函数、结构体、枚举和方法,最后来讨论一下泛型对代码性能的影响。
10.2.1 用泛型定义一个函数
10.1 小节中,我们使用了一个名为 find_max 的函数来降低了代码重复性,但是他有一个问题,他只能接收一个 i32 类型的切片并且也只能返回一个 &i32 的数据类型;如果数据类型是 char,显然就不能起作用了,当然我们很好解决这个问题,我们可以再定义一个相同功能的函数,只不过入参的数据类型是 char:
|
|
find_max_num 函数和之前的 find_max 没区别,只是改了一个名字;另外新增的 find_max_char 函数就是为了找出字符的最大值。但是我们可以明显看到,两个函数内部实现都是一模一样的!只是数据类型不一样,所以这个时候就可以使用泛型来简化这个流程。
要为单个函数中的类型进行参数化,我们需要为类型参数命名,就像为函数的值参数命名一样。你可以使用任何标识符作为类型参数名。但我们将使用 T,因为按照惯例,Rust 中的类型参数名通常很短,往往只有一个字母,而且 Rust 的类型命名约定是驼峰命名法(CamelCase)。作为 “type” 的缩写,T 是大多数 Rust 程序员默认的首选。
当我们在函数体中使用一个参数时,必须在函数签名中声明该参数的名称,以便编译器知道这个名称的含义。同样地,当我们在函数签名中使用类型参数名称时,也必须先声明该类型参数名称,然后才能使用它。为了定义泛型函数,我们把类型名称声明放在尖括号 <> 中,介于函数名和参数列表之间,就像这样:
|
|
现在代码的整体就像下面这个样子:
|
|
但是我们编译之后就会发现如下报错:
帮助文本提到了 std::cmp::PartialOrd,这是一个 trait,我们将在下一节讨论 trait。现在只需知道,这条错误表明 largest 的函数体无法适用于所有可能的 T 类型。因为我们想在函数体中比较 T 类型的值,所以只能使用那些可以进行排序的类型。为了启用比较功能,标准库提供了 std::cmp::PartialOrd trait,你可以将其实现到类型上。要修复示例,我们可以按照帮助文本的建议,将 T 的有效类型限制为那些实现了 PartialOrd 的类型。之后,示例将成功编译,因为标准库已为 i32 和 char 实现了 PartialOrd。
10.2.2 在结构体中定义泛型
我们也可以在结构体中定义泛型,同时也可以定义其他的类型。定义的方法就是将 <T> 加在结构体名字和 { 之间,之后想要使用泛型的字段就在数据类型中填上 T 即可。 比如下面就定义了一个名为 Point<T> 的结构体:
|
|
定义完成就像这样,使用方式也和其他结构体没有任何区别。但需要注意的是,同一个结构体的泛型中,不能出现两种不同的类型,就像下面这个例子:
|
|
编译后查看报错信息:
在这个例子中,我们想要 T 既可以是 i32 类型,又可以是浮点类型;这是不允许的,我们查看报错信息可以看到,编译器会把第一个使用到的泛型固化成对应的类型,在这里,x 先被定义,是 i32 类型,那么编译器就会认为整个的泛型的数据类型都是 i32,所以我们后来的 y 尝试定义为一个浮点数,这就是不允许的。
想要在同一个结构体中使用不同类型的泛型来表达不同类型的数据,我们需要定义多个泛型,语法就是 <T> 改为 <T,U,...>(…表示你需要的泛型个数),比如下面这个例子:
|
|
这就不会报错了,因为我们让 x 的数据类型是泛型 T,y 的是泛型 U;虽然二者都是泛型,但是他们可以变成不同的数据类型。虽然我们可以定义无数多个泛型,但是太多的泛型会让我们的代码不易读。
10.2.3 在枚举中定义泛型
和结构体一样,当我们的枚举类型中需要出现泛型的时候,我们 需要在枚举名字和 { 之间加上 <T>。就比如标注库中的 Option<T>,它的定义就是如下:
|
|
这也解释了为什么我们在之前的章节中都说 Some(T) 可以容纳任何的数据类型。
和结构体一样,枚举也可以有不同类型的泛型,就比如标准库中的 Reuslt,它的两个变体的值的类型都不一样:
|
|
Result 枚举在两个类型 T 和 E 上都是泛型的,它有两个变体:Ok 保存一个 T 类型的值,Err 保存一个 E 类型的值。这个定义让 我们在任何可能会出现“成功(返回某种类型 T 的值)”或“失败(返回某种类型 E 的错误)”的场景中,都能方便地使用 Result 枚举。
当你在代码中发现多个结构体或枚举的定义仅仅因为保存的值的类型不同而有所区别时,就可以使用泛型来避免重复。
10.2.4 在方法中定义泛型
我们可以为结构体和枚举变量的方法中也使用泛型:
|
|
这里,我们为 Point<T> 定义了一个名为 x 的方法,它返回对字段 x 中数据的引用。
注意,我们必须在 impl 之后声明 T,这样我们才能用 T 来指明我们正在为类型 Point<T> 实现方法。通过在 impl 之后把 T 声明为泛型类型,Rust 就能识别出 Point<...> 尖括号里的类型是一个泛型,而不是某个具体类型。我们也可以为这个泛型参数取一个与结构体定义中不同的名字,但使用相同名称是惯例。如果你在一个声明了泛型类型的 impl 中编写方法,那么无论最终用什么具体类型替换该泛型类型,这个方法都会对该类型的任何实例生效。
在针对该类型定义方法时,我们还可以对泛型类型施加约束。例如,我们可以只为 Point<f32> 的实例实现方法,而不是为所有泛型 Point<T> 实现。在下个示例中,我们使用了具体类型 f32,这意味着在 impl 之后我们没有再声明任何类型:
|
|
这个函数计算了当前的坐标点距离原点 (0.0,0.0) 的距离,但是在声明的时候没有在 impl 后面加上 <T>,而且直接声明作用于 Point<f32>,这代表 distance_from_origin 函数只会在 Point<T> 为 f32 的时候才起作用。
结构体定义中的泛型类型参数并不总是与该结构体方法签名中使用的泛型参数相同。 下面这个例子 Point 结构体使用泛型类型 X1 和 Y1,而在 mixup 方法签名中使用 X2 和 Y2,以便让示例更清晰。该方法会创建一个新的 Point 实例,其 x 值来自调用方法的 self(类型为 X1),y 值来自传入的 Point(类型为 Y2)。
|
|
在 main 中,我们定义了一个 Point,其 x 是 i32(值为 5),y 是 f64(值为 10.4)。变量 p2 是一个 Point 结构体,其 x 是字符串切片(值为 "Hello"),y 是 char(值为 c)。在 p1 上调用 mixup 并传入 p2 作为参数,得到 p3,它的 x 将是 i32,因为 x 来自 p1;y 将是 char,因为 y 来自 p2。println! 宏调用将打印 p3.x = 5, p3.y = c。
这个示例的目的是演示一种场景:某些泛型参数在 impl 后声明,而另一些则在方法定义中声明。 这里,泛型参数 X1 和 Y1 在 impl 后声明,因为它们与结构体定义相关;泛型参数 X2 和 Y2 在 fn mixup 后声明,因为它们仅与方法相关。
10.2.5 使用泛型的性能
可能有人会好奇,使用泛型会不会在运行时造成额外的消耗?答案是否,使用泛型并不会比使用某个具体的数据类型更慢!
Rust 在编译时通过单态化(monomorphization)来完成这一过程。单态化是指根据编译时使用的具体类型,将泛型代码转换成特定代码的过程。在这个过程中,编译器执行的操作与我们创建泛型函数的步骤相反:编译器会查找所有调用泛型代码的地方,并根据调用时提供的具体类型生成相应的代码。
来查看标准库中的 Option<T> 枚举:
|
|
当编译器编译这段代码的时候,就会执行单态化。在这个过程中,编译器会读取 Option<T> 实例中被使用的值,然后找到这两个值使用的数据类型,一个是 i32 一个是 f64;然后他就会使用 i32 和 f64 将对应地方的 Option<T> 替换掉。
代码的单态化版本看起来类似于以下内容(编译器使用与这里为说明而使用的不同名称):
|
|
这样,泛型的 Option<T> 就会被替换成具体的数据类型。正因为 Rust 将每一个泛型实例都转换成了一个具体的数据类型的实例,所以我们在运行的时候并会有额外的开销。当代码运行时,它的表现与手动复制每个定义后的表现完全相同。 单例态过程使 Rust 的泛型在运行时非常高效。
10.3 定义共享行为——特征(trait)
特征定义了,某个具体类型的功能和可以和其他类型共享的功能。我们可以用一个抽象的方式定义一个特质,并且我们可以使用特征来约束泛型只能成为某些满足这个特征的数据类型。
10.3.1 定义一个特征
类型的行为,就是指我们可以在该类型上调用的方法。如果对于多种类型,我们都能调用相同的方法,那么这些类型就共享了同一种行为。trait 定义把一系列方法签名组合在一起,从而描述为达成某个目的所必需的一组行为。
例如,假设我们有多个结构体,它们分别保存不同形式和数量的文本:NewsArticle 结构体保存一篇在特定地点发布的新闻报道;SocialPost 结构体最多保存 280 个字符的文本,并附带一些元数据,表明这是一条新帖、转发,还是对另一条帖子的回复。
我们想创建一个名为 aggregator 的媒体聚合库 crate,用来展示可能存放在 NewsArticle 或 SocialPost 实例中的数据摘要。为此,我们需要从每种类型获取摘要,并通过在实例上调用 summarize 方法来请求该摘要,定义一个特性要使用关键字 trait:
|
|
这个语句就声明了一个 trait 叫做 summary,同时他也是一个公开的特性,因为我们使用了 pub 关键字;因此基于这个 crate 的代码也可以使用这个特性。在大括号内,我们定义了一个方法来描述实现了这个特征的数据类型的行为,在这里就是 fn summarize(&self) -> String;。
在方法的签名之后,和平常的方法不同,我们并没有再写一个大括号来对这个方法进行内部实现,而是直接使用分号结尾;因为每一个实现了这个特征的类型必须由自己实现具体的行为。编译器强制要求任何具有 summary 特性的类型都具有这个 summarize 方法。
一个特征可以有多个方法,每一个方法都占用一行,使用分号结尾。
10.3.2 在类型上实现一个特征
现在,我们要如何在一个类型中实现这个 Summary 特征呢?接着上述的例子,我们想要把该特征实现在 NewArticle 和 SocialPost 结构体中。对于 NewArticle 来说,使用标题和作者来作为 summarize 方法的返回值;对于 SocialPost,把返回值定为用户名后跟帖子的全部文本,假设帖子内容已经限制在 280 个字符以内:
filename: src/lib.rs
|
|
和定义一个方法类似,想要实现一个特征要使用 impl 关键字,只不过还要加上 for 来指示为具体某个类型实现的特征。然后再代码块的内部实现这个特征的行为的具体内容。
现在就有一个实现了库,库中的两个数据类型 SocialPost 和 NewArticle 都是实现了 Summary 特征的;用户可以使用这两个类型的实例中的特征方法,使用方法就和我们使用平常普通的方法一样。唯一的不同就是用户需要把特征和类型都带入作用域,这是一个如何使用我们的 aggregator 库的例子:
filename: src/main.rs
|
|
[!NOTE]
要求创建项目时的名字药叫做 aggregator 才可正常编译
依赖于 aggregator crate 的其他 crate 也可以把 Summary trait 引入作用域,从而在自己的类型上实现 Summary。需要注意的一条限制是,只有在 trait 或类型(或两者)属于本地 crate 时,我们才能为该类型实现该 trait。
例如,我们可以在 aggregator crate 中为自定义类型 SocialPost 实现标准库 trait Display,因为 SocialPost 是 aggregator crate 本地的类型;同样,我们也可以在 aggregator crate 中为 Vec<T> 实现 Summary,因为 Summary 是 aggregator crate 本地的 trait。
但是,我们不能为外部类型实现外部 trai t。例如,在 aggregator crate 中,不能为 Vec<T> 实现标准库的 Display trait,因为 Display 和 Vec<T> 都定义在标准库中,不属于 aggregator crate 本地。这条限制是所谓“一致性”(coherence)的一部分,更具体地称为孤儿规则(orphan rule);之所以叫“孤儿”,是因为“父类型”不存在。这条规则确保别人的代码不会破坏你的代码,反之亦然。如果没有这条规则,两个 crate 可能为同一个类型实现同一个 trait,而 Rust 将无法决定该使用哪一个实现。
10.3.3 默认实现
有时候给某些或者所有的特征的方法一个默认行为是很有用的,这样我们就不用给所有的类型都写一遍实现。有了一个默认的实现之后,当我们为某一个具体的数据类型实现该特征的时候,我们可以保留这个默认行为或者覆写这个默认行为。
在下面这个例子中,我们为特征 Summary 的 summarize 方法写了一个默认的实现,让它返回一个字符串。为了达到这个目的,我们需要在代码块中不单单只写函数签名,而是像定义一个平常的方法一样:
filename: src/lib.rs
|
|
为了在 NewArticle 实例中使用这个方法的默认实现,我们这样写即可:impl Summary for NewArticle{}。
这样,即使我们没有为 NewArticle 具体实现 Summary 特征中的 summarize 方法,我们也可以直接使用方法默认的实现:
filename: src/main.rs
|
|
这个函数会打印 New article available! (Read more...)。
默认实现只是在对于有该特征,但是没有具体实现这个方法的类型,提供了一个默认的行为,他不会影响其他自己实现了该方法的数据类型的自定义实现。所以我们现在对于 SocialPost 来说,之前定义的方法依旧可行。比如说,我们可以再为 Summary 特征定义一个方法 summarize_author,要求类型自己去实现其内容,即没有默认实现,而保留 summarize 方法的默认实现:
filename: src/lib.rs
|
|
如实例所示,我们现在只需要实现 summarize_author 方法即可。
在定义了 summarize_author 之后,我们可以在 SocialPost 结构体的实例上调用 summarize ,而 summarize 的默认实现将会调用我们提供的 summarize_author 的定义。因为我们实现了 summarize_author , Summary 特性已经给了我们 summarize 方法的功能,而无需我们再编写任何更多代码。这看起来是这样的:
filename: src/main.rs
|
|
这段代码打印 1 new post: (Read more from @horse_ebooks...) 。
10.3.4 作为参数的特征
既然已经学会了如何定义并实现 trait,我们就可以利用 trait 来编写能够接受多种不同类型的函数了。我们将继续沿用为 NewsArticle 和 SocialPost 实现的 Summary trait,来定义一个 notify 函数:该函数会在其参数 item(某个实现了 Summary 的类型)上调用 summarize 方法。为此,我们使用 impl Trait 语法,像这样:
|
|
我们不再为 item 参数指定一个具体类型,而是用 impl 关键字加上 trait 名。这样,该参数就能接受任何实现了指定 trait 的类型。在 notify 的函数体里,我们可以调用 item 上来自 Summary trait 的任何方法,比如 summarize。我们可以把 NewsArticle 或 SocialPost 的实例传给 notify 来调用它。如果用其他没有实现 Summary 的类型(如 String 或 i32)去调用该函数,代码将无法通过编译。
10.3.5 特征约束
impl Trait 语法适用于简单场景,但它本质上只是下面这种更完整写法(trait bound)的语法糖:
[!TIP]
语法糖(syntactic sugar) 指的是: 一门编程语言为了让代码写起来更简洁、更直观,而提供的一种 “表面写法”,它在功能上与另一种更底层、更啰嗦 的写法完全等价,只是可读性更好。
|
|
这种更长的形式与上一节的示例等效,但更冗长。我们将特征界限与泛型类型参数的声明一起放在冒号之后和尖括号之内。
这个 impl Trait 语法在简单的情况下让代码更简洁,但是完整的特征约束语法可以在其他情况下增加表达式的复杂性。比如下面这个函数,我们让函数可以接受两个实现了 Summary 特征的类型。像这样:
|
|
使用 impl Trait 在对于想要 item1,item2 是不同的数据类型的时候很有用,前提是这些数据类型都实现了 Summary 特征的。但是如果我们想要这两个参数都是同一个数据类型的话,就要像下面这样表达:
|
|
指定为 item1 和 item2 参数类型的泛型类型 T 限制了函数,使得作为 item1 和 item2 参数的实参传递的值的具体类型必须相同。
我们也可以增加不止一个约束。就比如我们想要 notify 函数的入参既满足格式化输出(Display 特征)、又满足 SUmmary。那么就可以使用 + 操作符;就像下面这样:
|
|
对于泛型来说,+ 也同样适用:
|
|
有以上两个特征,notify 的函数主题就可以使用 summarize 函数,并且可以格式化输出。
10.3.5 使用 where 闭包(Clauses)更明确的声明特征约束
使用过多的约束也会有缺点。如果每一个泛型都有自己的特征约束,一旦泛型多了之后,函数名和其参数列表之间的距离就会很远,让代码不是特别容易阅读。所以,Rust 提供了一个替代的语法 where 闭包来处理特征约束,但是 where 在函数参数列表之后。就比如下面这个例子:
|
|
我们可以使用 where 来简化这个表达式:
|
|
这样,函数的签名就更加简单明了,函数名、参数列表和返回类型靠得近。
10.3.6 返回一个实现了特征的值
我们也可以使用 impl Trait 语法来让函数的返回值也是某个实现了某些特征的数据类型,就像下面这样:
|
|
使用 impl Summary 让我们的 函数的返回值不一定是某个具体的数据类型,而只是一个实现了 Summary 特征的类型。在这个例子中,函数返回的是 SocialPost 类型,但是调用这个函数的代码不需要知道这个。
仅通过 trait 来指定返回值类型的能力,在闭包与迭代器(第 13 章将深入讨论)场景中尤为实用。闭包和迭代器产生的类型通常只有编译器知道,或者类型名非常冗长;使用 impl Trait 语法可以简洁地表明“返回某个实现了 Iterator 的类型”,而无需写出繁琐的具体类型。
但是,impl Trait 只能用于返回单一类型。例如,下面这段代码试图根据条件返回 NewsArticle 或 SocialPost,并把返回类型写成 impl Summary,这是不合法的:
|
|
编译结果如下:
由于编译器在实现 impl Trait 语法时的限制,不能让它同时返回 NewsArticle 或 SocialPost 两种不同类型。在第 18 章我们会介绍如何编写真正可以返回多种类型的函数。
10.3.7 使用 Trait Bound 为方法实现加上“条件”
通过在使用泛型类型参数的 impl 块中使用 trait bound,我们可以有条件地为实现了指定 trait 的类型实现方法。例如,示例中的类型 Pair<T> 始终实现了 new 函数,用于返回一个新的 Pair<T> 实例。但在下一个 impl 块中,只有当内部类型 T 实现了用于比较的 PartialOrd trait 以及用于打印的 Display trait 时,Pair<T> 才会实现 cmp_display 方法。
|
|
我们还可以为任何实现了指定 trait 的类型有条件地实现某个 trait。这种对满足 trait bound 的所有类型实现的 trait 称为覆盖实现(blanket implementations),在 Rust 标准库中被广泛使用。例如,标准库为所有实现了 Display trait 的类型实现了 ToString trait。标准库中的 impl 块大致如下:
|
|
因为标准库提供了这一覆盖实现,我们就可以在任何实现了 Display trait 的类型上,调用由 ToString trait 定义的 to_string 方法。例如,由于整数实现了 Display,我们可以像这样把整数转换成对应的 String 值:
|
|
覆盖实现会出现在该 trait 文档的 “Implementors”(实现者)部分。
Trait 与 trait bound 让我们能够编写使用泛型参数的代码,既减少了重复,又能向编译器表明:我们希望泛型类型具备特定的行为。接着编译器会利用 trait bound 信息,检查所有与我们的代码一起使用的具体类型是否确实提供了正确的行为。在动态类型语言中,如果我们在某个类型上调用了未定义的方法,要到运行时才会报错;而 Rust 则将这些错误提前到编译期,迫使我们在代码尚未运行之前就必须解决问题。此外,由于我们已在编译期完成检查,就无需再编写运行时检查行为的代码。这种做法在保持泛型灵活性的同时,也提升了性能。
10.4 生命周期(Lifetime)
生命周期其实本质也是一种泛型。但是生命周期不是用于确保一个类型有我们想要的行为,而是确保我们使用引用的时候引用是有效的。
在第四章介绍引用和借用的时候,我们有一个点没有提到,就是在 Rust 中每一个引用的都有一个生命周期,即其有效的作用域。就和变量类型一样,大多数时间是不用显式声明的,而是由编译器来推断的;只有当面临很多不同类型同时适用的时候,我们才需要显式指定。同样的,只有当生命周期有很多不同的方式的时候,我们才需要显示指定。Rust 要求我们使用泛型生命周期参数来标注这些关系,以确保在运行时实际使用的引用一定是有效的。
对于其他很多语言,显式指定生命周期都不是一个内容,所以这会让我们有点陌生。尽管我们不会在本章全面介绍生命周期,但我们会讨论你可能遇到的生命周期语法的一些常见方式,以便你熟悉这个概念。
10.4.1 使用生命周期避免悬挂引用
生命周期是为了避免引用到我们不想引用到的数据上,比如下面这个程序:
|
|
编译结果如下:
这个程序尝试让变量 r 引用 x,但是在使用这个引用的时候,x 由于超出其作用域,已经被 drop 了,这就导致此时的 r 指向了一块未知的内存。所以我们可以看到编译器报错告诉我们:x 没有存活足够长的时间。
[!NOTE]
注意:清单 10-16、10-17 和 10-23 中的示例在声明变量时没有给出初始值,因此变量名存在于外部作用域。乍看之下,这似乎与 Rust“没有空值”的原则相矛盾。然而,如果我们试图在赋值之前就使用该变量,编译器会在编译时报错,这恰恰说明 Rust 确实不允许空值。
10.4.2 借用检查器(Borrow Checker)
Rust 编译器有一个借用检查器,它会比较作用域以确定所有借用是否有效。查看下面对于上面代码的一些注释,有助于更好的理解生命周期:
|
|
在这里,我们为 r 的生命周期标注了 'a,为 x 的生命周期标注了 'b。如你所见,内部的 'b 块比外部的 'a 生命周期块要小得多。在编译时,Rust 会比较这两个生命周期的大小,发现 r 具有 'a 的生命周期,却引用了只有 'b 生命周期的内存。程序因此被拒绝:因为 'b 比 'a 短——被引用的对象存活得没有引用本身久。
解决这个问题也很简单:
|
|
这样,'b 的生命周期就是明显大于 ’a 的,所以引用的对象存活的肯定比其引用久。
既然你已经知道了引用的生命周期位于何处,也明白了 Rust 如何通过分析生命周期来确保引用始终有效,接下来就让我们在函数的上下文中,探讨泛型生命周期参数以及返回值中的生命周期。
10.4.3 函数中的泛型生命周期
我们将要写一个函数,返回两个字符串较长的那个字符串的切片。函数接收两个字符串但是只返回一个字符串切片。我们让这个函数叫做 longest,其效果就是下面这样,最后应该会打印 The longest string is abcd:
|
|
从上面的例子就可以看到,我们希望函数接收的是切片,而不是字符串,而切片就是一种引用;因为我们不希望函数直接获取所有权。如果我们按照下面这样实现函数,编译不会成功:
|
|
编译如下:
帮助信息指出,返回类型需要一个泛型生命周期参数,因为 Rust 无法判断返回的引用究竟指向 x 还是 y。事实上,我们也不知道——函数体中的 if 块返回 x 的引用,而 else 块返回 y 的引用!
在定义这个函数时,我们并不知道实际会传入哪些具体值,因此无法确定执行的是 if 分支还是 else 分支。我们同样不知道传入引用的具体生命周期,也就不能像之前那样通过查看作用域来判断返回的引用是否始终有效。借用检查器也无法推断这一点,因为它不知道 x 和 y 的生命周期与返回值的生命周期之间的关系。要消除这个错误,我们需要添加泛型生命周期参数,明确定义这些引用之间的关系,从而让借用检查器能够进行正确的分析。
10.4.4 显式声明生命周期
生命周期标注并不会改变任何引用的实际存活时长。它们只是描述多个引用生命周期之间的关系,而不会对这些生命周期本身产生任何影响。正如函数在签名中指定泛型类型参数后,可以接收任何类型的参数一样;函数在指定泛型生命周期参数后,也可以接收任何生命周期的引用。
生命周期标注的语法略显特别:生命周期参数的名称必须以单个单引号(’)开头,通常全部使用小写字母且非常简短,这一点与泛型类型相似。大多数人会为第一个生命周期标注使用名称 'a。我 们把生命周期参数标注放在引用的 & 之后,并用空格将其与引用的类型隔开。
下面是一些例子:
|
|
单独的一个生命周期标注本身并没有太大意义,因为这些标注的目的在于告诉 Rust:多个引用所对应的泛型生命周期参数之间如何相互关联。现在,让我们在 longest 函数的上下文中,来考察这些生命周期标注是如何彼此关联的。
10.4.5 在函数签名中声明生命周期
要在函数签名中使用生命周期标注,我们需要在函数名与参数列表之间的尖括号内声明泛型生命周期参数,做法与声明泛型类型参数相同。
我们希望签名表达如下约束:只要两个参数都有效,返回的引用就有效。这就是参数生命周期与返回值生命周期之间的关系。我们将该生命周期命名为 'a,然后把它加到每一处引用上,如下所示:
|
|
现在这个代码就是可以正常编译运行的了。
现在,函数签名告诉 Rust:对于某个生命周期 'a,该函数接受两个参数,这两个参数都是至少与生命周期 'a 一样长的字符串切片;函数签名还告诉 Rust,函数返回的字符串切片也至少与生命周期 'a 一样长。在实践中,这意味着 longest 函数返回的引用的生命周期等于函数参数所引用值的较小生命周期。这些关系就是我们在分析这段代码时希望 Rust 采纳的规则。
请记住,当我们在函数签名中指定生命周期参数时,并没有改变传入或返回的任何值的实际生命周期,而只是告诉借用检查器:拒绝任何不满足这些约束的值。注意,longest 函数并不需要精确知道 x 和 y 会活多久,它只知道存在某个作用域可以替代 'a,从而满足此签名即可。
在函数中标注生命周期时,标注应放在函数签名里,而非函数体中。生命周期标注会成为函数契约的一部分,就像签名中的类型一样。让函数签名包含生命周期契约意味着 Rust 编译器所做的分析可以更简单。如果函数标注或调用方式存在问题,编译器就能更精确地指出我们代码中的具体位置以及相关的约束。相反,如果 Rust 编译器需要更多推断我们期望的生命周期关系,它可能只能在远离问题根源的许多步骤之后,才指向我们代码的某一处使用。
当我们向 longest 传入具体引用时,被替换到 'a 的具体生命周期就是 x 的作用域与 y 的作用域重叠的部分。换句话说,泛型生命周期 'a 将获得等于 x 与 y 中较小生命周期的具体生命周期。因为我们用同一个生命周期参数 'a 标注了返回的引用,所以返回的引用也将在 x 和 y 中较小生命周期的长度内保持有效。
接下来,让我们通过传入具有不同具体生命周期的引用,来看看生命周期标注是如何限制 longest 函数的。下面这个例子:
|
|
在这个例子里,string1 的作用域从第 2 行一直延续到第 9 行,而 string2 仅存活于第 5~8 行,result 也只活在这段区间。
此时 'a 被推断为 两者作用域的交集(即第 5~8 行),result 的使用范围恰好落在这个交集之内,所以编译通过,打印 The longest string is long string is long。
现在我们修改代码,让 result 的生命域超出 string2 的结束点,但仍不超出 string1:
|
|
此时 string2 在第 7 行就被释放,而 result 却想活到第 9 行的 println!。
无论实际上 longest 返回的是 x 还是 y,Rust 都要求“返回值只能与两入参中更短的寿命一样长”。
由于 string2 的寿命就是更短的那一方,result 若超出它就必然悬空,因此编译器拒绝这段代码,产生借用检查错误:
再看下面这个例子:
|
|
这个代码是可以编译的,但是我们肉眼看上去很明显 result 的生命周期比 string2 要长,那为什么可以编译呢?
|
|
在这段代码里,string1 与 string2 的作用域完全重叠、都持续到 main 结束,因此编译器把 'a 推断成这个交集;result 的生命周期恰好等于 string2,并未超出,所以满足“返回值 ≤ 较短生命周期”的条件,自然不会报错。简单来说,虽然 result 比 string2 先声明,但是通过函数获得引用之后,使用这个引用的时候,string1 和 string2 都还是有效的,不会造成悬挂引用,所以是有效的。
“返回值的生命周期 ≤ 两个入参中较短的生命周期”——这里的“较短”指的是作用域的交集;“较短”并不是字面意义上谁早结束,而是“两个入参引用共同还能活着的那一段重叠区间”;在这段区间里,两个引用都必须有效,因此返回值也只能在这段区间里用。
10.4.6 从生命周期的角度思考
需要怎样标注生命周期参数,完全取决于函数实际做了什么。例如,如果我们把 longest 的实现改成总是返回第一个参数,而不再比较长度,那么就不必给第二个参数 y 标注生命周期。下面的代码就能通过编译:
|
|
我们只给参数 x 和返回类型指定了生命周期参数 'a,而没有给参数 y 指定,因为 y 的生命周期与 x 或返回值的生命周期没有任何关系。
从函数返回引用时,返回类型的生命周期参数必须与某个参数的生命周期参数保持一致。 如果返回的引用并不指向任何一个参数,那它只能指向函数内部新创建的值;然而,这将导致悬垂引用,因为该值会在函数结束时离开作用域而被释放。下面这个试图实现的 longest 函数就无法通过编译:
|
|
编译如下:
归根结底,生命周期语法所做的只是把函数各参数与返回值的生命周期连接起来;一旦连接完成,Rust 就拥有了足够的信息去允许那些内存安全的操作,同时拒绝任何会产生悬垂指针或破坏内存安全的操作。
10.4.7 在结构体中声明生命周期
目前为止,我们声明的结构体中都是拥有值的类型。但是现在接触了生命周期,我们就可以在结构体中声明引用了,我们需要在结构体的定义中显式声明每一个引用的生命周期:
|
|
这个结构体只有一个字段 part,它保存一个字符串切片,也就是一个引用 。与泛型数据类型一样,我们在结构体名称后的尖括号内声明泛型生命周期参数的名字,以便在结构体定义体内使用这一生命周期参数。该标注意味着:一个 ImportantExcerpt 实例的存活时间不能超过其 part 字段所持有引用的生命周期。
这里的 main 函数创建了一个 ImportantExcerpt 实例,它持有一个指向 novel 所拥有 String 中第一句的引用。novel 中的数据在 ImportantExcerpt 实例创建之前就已存在;此外,novel 直到 ImportantExcerpt 离开作用域之后才离开作用域,因此 ImportantExcerpt 实例中的引用始终有效。
10.4.8 生命周期推断
现在我们已经找到了每一个引用都有其自己的生命周期;当我们在函数、结构体中使用到了引用的时候,我们需要显式声明生命周期。但是我们来看下面一个函数,他是可以被成功编译的,但是他与我们想的好像有点出入:
|
|
之所以这段函数在没有显式生命周期标注的情况下仍能编译,是出于历史原因:在 Rust 1.0 之前的早期版本中,这段代码无法通过编译,因为那时每个引用都必须显式写出生命周期。当时的函数签名需要写成这样:
|
|
在大量编写 Rust 代码后,Rust 团队发现,程序员在特定场景下总是反复填写同样的生命周期标注。这些场景是可预测的,并遵循若干确定的模式。于是开发者把这些模式直接编进了编译器,使得借用检查器能够在这些情况下自动推断生命周期,无需再手写标注。
这段历史之所以重要,是因为未来可能还会出现更多可确定的模式,并被继续加入编译器——届时需要显式标注的情况会进一步减少。
这些被固化到 Rust 引用分析里的模式称为生命周期省略规则(l ifetime elision rules)。它们不是给程序员遵守的规则,而是编译器在遇到特定情形时会自动应用的判断;只要代码符合这些情形,就不必显式写出生命周期。
省略规则并不提供完全推断。如果 Rust 应用规则后仍存在歧义,编译器不会靠猜测来决定剩余引用的生命周期,而是报出错误,提示你通过显式标注来解决。
函数或方法参数上的生命周期称为输入生命周期(input lifetimes),返回值上的生命周期称为输出生命周期(output lifetimes)。
编译器在没有显式标注时,通过三条规则推断引用的生命周期。前两条规则覆盖所有 fn 和 impl,规则 1 处理输入生命周期,规则 2、3 处理输出生命周期;若三条规则用完后仍有引用生命周期无法确定,编译器就报错:
-
给每个引用型参数都分配一个独立的生命周期参数。
- 单参数:
fn foo<'a>(x: &'a i32) - 双参数:
fn foo<'a, 'b>(x: &'a i32, y: &'b i32)
以此类推。
- 单参数:
-
如果只有一个输入生命周期参数,则该参数被赋给所有输出生命周期。
例:
fn foo<'a>(x: &'a i32) -> &'a i32 -
如果有多个输入生命周期,但其中一个是
&self或&mut self(即方法),就把self的生命周期赋给所有输出生命周期。这让方法签名更简洁。注意:规则 2 和规则 3 是或的关系,不是和的关系
现在让我们扮演一次编译器,用这三条规则为 first_word 函数的签名推断生命周期。初始签名不带任何生命周期信息:
|
|
首先看第一条规则,为每一个参数都分配一个独立的生命周期参数,现在函数签名是这样的:
|
|
然后看第二个规则,由于入参只有一个 s,所以符合第二个规则,签名变成这样:
|
|
到此为止,函数的所有相关参数都有了生命周期,所以编译器可以正常编译而不需要程序员显式指定生命周期。
再来看另一个例子,这次使用我们之前的 longest 函数,但是没有声明生命周期:
|
|
规则一处理之后就会变成这样:
|
|
处理规则二的时候,明显发现,有多个输入生命周期,不符合规则二。同样也不适用于规则三,所以编译器会报错,让程序显式指定生命周期。
因为第三条规则实际上只适用于方法签名,接下来我们将在方法的上下文中查看生命周期,从而理解为什么这条规则让我们几乎不必在方法签名里手动标注生命周期。
10.4.9 在方法中声明生命周期
在为带有生命周期的结构体实现方法时,我们使用与泛型类型参数相同的语法。生命周期参数该在何处声明、如何使用,取决于它们是与结构体字段相关,还是与方法参数及返回值相关。
结构体字段用到的生命周期名称必须写在 impl 关键字之后的尖括号里,并在结构体名后再写一次,因为这些生命周期本就是结构体类型的一部分。
在 impl 块中的方法签名里,引用既可能与结构体字段中的引用共享同一生命周期,也可能是独立的。此外,生命周期省略规则常常 让我们无需在方法签名里再写任何生命周期标注。下面用之前定义的 ImportantExcerpt 结构体举例说明。
首先,我们实现一个名为 level 的方法:它唯一的参数是对 self 的引用,返回值是 i32(并非任何引用):
|
|
在 impl 之后必须写上生命周期参数,并在类型名后再写一次;但不需要给 &self 标注生命周期,因为第一条生命周期省略规则已经帮我们处理好了。
下面是一个 第三条生命周期省略规则生效 的例子:
|
|
此时有两个输入生命周期,于是 Rust 先按第一条生命周期省略规则,为 &self 和 announcement 各分配一个独立的生命周期;接着,由于其中一个参数是 &self,第三条规则便将 &self 的生命周期赋给返回类型。至此,所有生命周期均已确定,无需额外标注。
10.4.10 静态生命周期
我们需要讨论的一种特殊生命周期是 'static,它表示该引用可以存活于整个程序运行期间。所有字符串字面量都具有 'static 生命周期,可以这样标注:
|
|
这段字符串的文本会直接存储在程序的二进制文件中,因此始终可用;所以所有字符串字面量的生命周期都是 'static。
你可能在错误提示里看到建议使用 'static 生命周期。但在把 'static 指定为某个引用的生命周期之前,先想一想:你手里的引用是否真的会、并且你希望它在整个程序运行期间都存活?绝大多数情况下,错误提示让你用 'static,是因为你试图创建悬垂引用,或者可用的生命周期不匹配——这时候应当修正这些问题,而不是简单地加上 'static。
10.4.11 泛型、特征约束、生命周期
让我们通过一个函数将本章的所学的全部结合起来:
|
|
这就是之前的 longest 函数,它返回两个字符串切片中较长的一个;但现在新增了一个名为 ann 的泛型参数 T,该参数由 where 子句限定为任何实现了 Display trait 的类型。这个额外参数将使用 {} 打印,因此必须要求 Display trait bound。
由于生命周期本身也是一种泛型,生命周期参数 'a 和泛型类型参数 T 的声明被放在同一列表中,写在函数名后的尖括号里。
Chapter 11:自动化测试(Automated Tests)
程序的正确性,是指代码在多大程度上准确地完成了我们意图让它做的事。Rust 在设计之初就对正确性给予了极高关注,可正确性本身错综复杂、难以证明。Rust 的类型系统承担了其中极大一部分工作,但类型系统并非万能。正因如此,Rust 内置了对自动化软件测试的支持。
假设我们写一个 add_two 函数,它把传进来的数加 2。该函数签名接收一个整数参数并返回一个整数结果。当我们实现并编译它时,Rust 会进行迄今所学的全部类型检查和借用检查,确保例如不会把 String 或悬垂引用传进去。然 而,Rust 无法验证该函数是否真的返回“参数 + 2”,而不是“参数 + 10”或“参数 − 50”! 这正是测试的用武之地。
我们可以编写断言(assert):例如,当把 3 传给 add_two 时,返回值应为 5。此后每次修改代码,都能运行这些测试,确保既有的正确行为未被破坏。
测试是一门复杂技艺:虽然本章无法在细节上穷尽“如何写好测试”,但我们会讨论 Rust 测试机制的基础用法——包括编写测试时可用的注解与宏、测试运行的默认行为与可选参数,以及如何把测试组织成单元测试和集成测试。
11.1 如何编写一个测试
测试本质也是一些函数,它的作用是用来测试我们自己编写的函数是否可以达到我们想要的结果。测试主要执行下面三个行为:
- 设置需要用到的数据或者状态
- 运行你想要测试的代码
- 通过断言来判断代码结果是不是所预期的
Rust 提供了很多专门为测试设计的特性和一些宏,比如 should_panic。接下来我们将一一查看。
11.1.1 测试函数的结构
最简单的一个测试函数的结构就是显式声明 test 属性,所谓属性就是 Rust 代码中的一些元代码(metadata),就比如我们之前结构体那一章用过的 derive。我们在 fn 关键字之前用 #[test] 来声明下面的函数是测试代码。然后我们在终端中运行 cargo test 就可以进行测试了。
当我们使用 cargo new xxx --lib 的时候,会自动创建一个库 crate,里面的 src/lib.rs 默认的内容就是 Rust 为我们提供的默认测试模块:
|
|
模板中默认给了一个 add 函数,将两个整数加起来的和返回。紧接着就是一个 mod 专门用于测试,由于我们模块内部要引用外面的 add 函数,所以使用 use super::* 表示导入父级的所有函数和数据结构,紧接着就是 #[test] 来声明了一个测试函数 it_works,这个函数就是用于测试我们 add 函数是符合我们的预期:首先使用 result 把函数返回值获取到,最后再通过断言 assert_eq! 来判断是不是我们想要的值,如果是的话我们运行 cargo test 会看到以下输出:
Cargo 编译并运行了测试。我们能看到一行 running 1 test,下一行显示了生成的测试函数名 tests::it_works,并提示该测试的结果是 ok。总结中的 test result: ok. 表示所有测试都通过了;1 passed; 0 failed 则统计了通过和失败的测试数量。
你还可以把某个测试标记为忽略,这样在默认情况下它不会运行;本章稍后一节会讲到这一点。因为我们这里没有做任何忽略,所以总结里显示了 0 ignored。你也可以给 cargo test 传入参数,只运行名称与某个字符串匹配的测试,这叫做过滤;我们将在“按名称运行部分测试”一节介绍。这里我们没有过滤,所以总结末尾显示 0 filtered out。
0 measured 这一统计留给基准测试(benchmark tests),它们用于衡量性能。截至目前,基准测试只在 nightly Rust 中可用;如需了解详情,请查阅基准测试的文档。
从 Doc-tests adder 开始的下一部分输出,展示的是文档测试的结果。我们目前还没有任何文档测试,但 Rust 会编译 API 文档中出现的任何代码示例。这一功能有助于保持文档与代码同步!我们将在第 14 章讨论如何编写文档测试。目前,先忽略 Doc-tests 的输出即可。
同样我们可以自定义测试函数的名字:
|
|
现在再次测试看看编译结果:
可以看到代码测试依旧通过,只是函数名字变化了而已。
现在我们手动让它断言错误看看:
|
|
再运行 cargo test 可以看到如下:
可以看到我们的测试失败,并且 thread 'tests::it_works' panicked at src/lib.rs:12:9: 告诉我们错误出在 src/lib.rs 中的第 12 行的第 9 个字,断言失败的原因就是左边不等于右边,然后把左边右边的值都给我们打印出来了。所以我们可以知道 assert_eq! 宏返回 true 的时候会正常运行,错误的时候会让代码 panic,然后告诉错误原因和相关部分的值。
当然我们也可以再添加一个测试代码,我们让这个新代码中直接 panic!,并填入我们的自定义错误信息:
|
|
再次查看测试结果:
我们自定义的测试部分就显示了 FAILED,说明测试不通过。在 failures 中可以看到在 panic 相关的地方显示了我们自定义的错误消息:It`s my panic。最后做了一个总结,我们有一个测试不通过,一个测试通过。
11.1.2 assert! 宏
assert! 是由标准库提供的一个宏,用于确保在测试中某些条件是 true。这个宏的入参是一个布尔类型,如果这个入参的结果是 true,那么就无事发生,如果是 false,那么程序就会 painc 导致测试失败。
借用之前第五章的 Rectangle 结构体,我们为其定义了一个 can_hold 方法:
|
|
这个方法的返回值正好就是布尔值,所以我们可以编写以下的测试:
|
|
现在我们测试一下看看结果:
成功通过测试,再添加一个测试 smaller_cannot_hold_larger:
|
|
测试结果如下:
两条都成功通过测试了!我们现在人为制造一个错误:
|
|
现在再看编译结果:
我们的测试成功捕获了这个 bug!因为 larger.width 是 8.0,而 smaller.width 是 5.0,所以 can_hold 中对宽度的比较现在返回 false:8.0 不小于 5.0。
11.1.3 assert_eq! 宏和 assert_ne! 宏
测试有个常见的用法就是 assert!(actual == expect) 和 assert!(actual != expect),用来判断相等和不等的情况。所以 Rust 为这种情况专门设立了两个宏,assert_eq! 用于判断 ==,assert_ne! 用于判断 != 的情况。并且在返回值为 false 的情况下,会将把对应的值打印出来,方便我们测试。
现在写一个函数 add_two,传入一个整型,然后返回这个值+2:
|
|
测试结果如下:
现在修改函数内容,人造一个 bug,我们将 +2 改为 +3:
|
|
再次测试:
可以看到,和 assert! 的区别就是,告诉我们是哪里错了,然后会将 left 和 right 的值打印出来。assert_ne! 就是判断不等于的情况。
请注意,在某些语言和测试框架中,相等断言函数的参数被称为 expected 和 actual,并且指定参数的顺序很重要。然而,在 Rust 中,它们被称为 left 和 right,我们指定预期值和代码产生的值的顺序并不重要。我们可以将测试中的断言写成 assert_eq!(4, result),这将产生相同的失败消息,显示 assertion \ left == right` failed`。
assert_ne! 宏会在我们提供的两个值不相等时通过,在相等时失败。这个宏在我们不确定某个值具体是什么,但明确知道它绝对不应该是什么时最有用。例如,如果我们正在测试一个函数,该函数保证会以某种方式修改其输入,但具体如何修改取决于我们运行测试的星期几,那么最好的断言可能是确保函数的输出不等于其输入。
在底层,assert_eq! 和 assert_ne! 宏分别使用 == 和 != 运算符。当断言失败时,这 些宏会使用调试格式打印其参数,这意味着被比较的值必须实现 PartialEq 和 Debug 特征。所有原始类型和大多数标准库类型都实现了这些特征。对于你自己定义的 struct 和 enum,你需要实现 PartialEq 才能对这些类型进行相等性断言。你还需要实现 Debug,以便在断言失败时打印值。由于这两个特征都是可派生特征,正如第 5 章所提到的,通 常只需在你的 struct 或 enum 定义上添加 #[derive(PartialEq, Debug)] 注解即可。
11.1.4 自定义错误信息
我们的 assert_eq! 和 assert_ne! 还可以附带一个错误信息,比如下面这个例子判断函数返回的字符串是否包含我们输入的参数:
|
|
这个程序的需求尚未最终敲定,我们几乎肯定问候语开头的 “Hello” 文字会改动。我们决定不在需求变更时再去更新测试,因此不再检查 greeting 函数返回值的完全相等,而是断言其输出中包含传入参数的那段文字。
现在人为加一个 bug,并且加上自定义错误信息:
|
|
测试报错如下:
可以看到我们自定义的信息成功输出!
11.1.5 使用 should_panic 检查 panic
除了检查返回值之外,验证代码在错误条件下的行为也至关重要。例如,回顾我们在第 9 章创建的 Guess 类型:使用 Guess 的其他代码都依赖于一个保证——Guess 实例只能包含 1 到 100 之间的值。我们可以编写一个测试,确保当试图用超出该范围的值创建 Guess 实例时,程序会触发 panic。
为此,我们在测试函数上添加 should_panic 属性。如果函数体内的代码发生 panic,测试通过;如果未发生 panic,测试失败。
展示了一个测试,用于验证 Guess::new 的错误条件是否在我们预期的情况下发生:
|
|
我们将 #[should_panic] 添加到 #[test] 之下,函数 fn 之上,然后测试看看结果:
现在我们再修改一下 new 函数,将范围修改为 < 1,这样 200 是符合要求的,所以不会测试通过:
|
|
测试结果如下:
在这种情况下,我们并没有得到一条很有帮助的提示。但当我们查看测试函数时,会发现它带有 #[should_panic] 注解。测试失败说明函数体内的代码并未触发 panic。
使用 should_panic 的测试可能不够精确——即使测试因与我们预期不同的原因 panic,也会通过。为了让 should_panic 测试更精确,可以给属性添加可选的 expected 参数。测试框架会确保失败信息中包含指定的文本。例如,下面对 Guess 做了修改,new 函数会在值过小时和过大时分别触发不同的 panic 消息。
|
|
测试结果发现:
这条失败信息表明测试确实如我们预期那样触发了 panic,但 panic 消息中并不包含我们期望的子串 less than or equal to 100。实际得到的 panic 消息是 Guess value must be greater than or equal to 1, got 200。现在,我们就可以着手定位 bug 了!
11.1.6 在测试中使用 Result<T,E>
到目前为止,我们编写的测试在失败时都会 panic。其实我们也可以写使用 Result<T, E> 的测试! 下面是把之前的测试改写成使用 Result<T, E>,并在失败时返回 Err 而非直接 panic:
|
|
it_works 函数的返回类型现在改成了 Result<(), String>。函数体内不再调用 assert_eq! 宏,而**是在测试通过时返回 Ok(()),在测试失败时返回一个包含 String 的 Err**。
让测试返回 Result<T, E> 后,便可在测试函数体内使用 ? 运算符。当其中任何操作返回 Err 变体时,测试会自动失败,这种方式写起来非常方便。
对于使用 Result<T, E> 的测试,不能加 #[should_panic] 注解。若想断言某操作返回 Err 变体,不要在对应的 Result<T, E> 上使用 ? 运算符,而应使用 assert!(value.is_err())。
现在你已经掌握了多种编写测试的方法,接下来让我们看看运行测试时到底发生了什么,并探索可与 cargo test 搭配使用的各种选项。
11.2 控制测试运行的方式
就像 cargo run 会编译你的代码然后运行生成的二进制文件一样,cargo test 也会以测试模式编译你的代码并运行生成的测试二进制文件。cargo test 生成的二进制文件的默认行为是并行运行所有测试,并捕获测试运行期间产生的输出,防止这些输出被显示出来,从而更容易阅读与测试结果相关的输出。不过,你可以通过指定命令行选项来改变这种默认行为。
有些命令行选项是传给 cargo test 的,有些则是传给生成的测试二进制文件的。为了区分这两种参数,你需要先列出传给 cargo test 的参数,然后使用分隔符 --,再列出传给测试二进制文件的参数。运行 cargo test --help 会显示可与 cargo test 一起使用的选项,而运行 cargo test -- --help 则会显示分隔符之后可用的选项。
11.2.1 并行或者串行运行测试
当你同时运行多个测试时,默认情况下它们会使用线程并行执行,因此测试完成得更快,你也能更快得到反馈。由于测试是同时运行的,你必须确保这些测试彼此独立,且不依赖于任何共享状态,包括共享环境(例如当前工作目录或环境变量)。
举个例子:假设你的每个测试都会运行一段代码,在磁盘上创建一个名为 test-output.txt 的文件,并向其中写入一些数据。接着,每个测试会读取该文件中的数据,并断言文件包含一个特定的值,而这个值在每个测试中是不同的。由于测试是并行运行的,一个测试可能会在另一个测试写入和读取该文件之间的某个时刻覆盖该文件。于是第二个测试会失败,并非因为代码本身有问题,而是因为测试在并行运行时互相干扰。一种解决方法是确保每个测试写入不同的文件;另一种解决方法是改为一次只运行一个测试。
如果你不想并行运行测试,或者想更精细地控制所使用的线程数量,可以将 --test-threads 标志和你希望使用的线程数一起传给测试二进制文件。请看下面的示例:
|
|
这就是告诉编译器,我们只使用 1 个线程来进行测试,意味着测试串行运行。串行测试意味着会花更久的时间去测试,但是如果多个测试之间有共享的状态不会互相影响。
11.2.2 显示函数输出
默认情况下,如果某个测试通过,Rust 的测试库会捕获所有打印到标准输出的内容。例如,如果我们在测试中调用了 println!,而该测试又通过了,那么在终端里我们就看不到 println! 的输出,只会看到一行表示测试通过的信息。如果测试失败,我们则会在失败信息的其余内容中,看到所有打印到标准输出的内容。
现在有一个函数,打印接收到的参数,然后返回 10。有两个关于这个函数的测试,一个通过,一个不通过:
|
|
编译测试之后的效果如下:
可以看到,只有未通过的输出,而通过的测试已经被捕获了。如果我们想要看到通过测试的输出要怎么办呢?我们需要给 cargo test 加上 –-show-output 参数:
|
|
其运行结果如下:
现在就同时可以看到 test_pass 和 test_not_pass 的输出了。
11.2.3 通过名称运行测试子集
有时候,如果我们写了很多测试,但是只想要看某一个测试,这时候如果运行默认的 cargo test;编译器会运行所有测试,这会消耗很长的时间。所以我们可以通过 cargo test 后跟上你想要测试的名字,来只对某一个测试运行。
比如现在我们有个 add_two 函数,和关于他的很多个测试:
|
|
如果我们只运行 cargo test,将会把所有的测试都跑一遍:
单个测试
如果我们只想要运行一个测试的话,就直接将测试函数的名字加到命令中,比如这里只想运行 one_hundred 这个测试函数:
$ cargo test one_hundred
结果如下:
通过过滤运行多个测试
我们还可以通过指定有某个关键字的所有测试,来过滤出我们想要的测试函数。比如说我们想要运行 add_two_and_two 和 add_three_and_two 这两个测试,那么我们就通过 add 关键字:
|
|
输出如下:
在这个例子中 and、two 这些关键字也有同样的效果。
非特殊要求忽略某些测试
有时,某些特定测试的执行开销极大,因此你可能希望在大多数 cargo test 运行中将其排除。与其在命令行里逐一列出所有要运行的测试,不如直接在耗时测试上添加 ignore 属性来排除它们,示例如下:
|
|
对于我们想要默认忽略的测试,我们在 #[test] 之后加上 #[ignore] 参数,代表忽略这个测试。 现在我们再运行这个测试,会发现只有 it_works 测试会运行,expensive_test 不会运行:
如果我们只想要运行标记为 #[ignore] 的测试,我们在测试命令加上 –-ignored 参数:
|
|
运行效果如下:
通过控制哪些测试需要运行,你可以确保 cargo test 的结果能够快速返回。当你到了需要检查那些被忽略的测试结果、并且愿意等待它们完成时,可以运行:cargo test -- --ignored。如果你想无论测试是否被忽略都全部运行,可以执行:cargo test -- --include-ignored。
11.3 测试的组织性
正如本章开头所说,测试是一门复杂学问,不同人用的术语和组织方式各异。Rust 社区把测试分成两大类:单元测试和集成测试。单元测试小而聚焦,一次只隔离测试一个模块,还能测私有接口;集成测试则完全放在库外,像外部代码一样使用你的库,仅用公共接口,一个测试可能跨多个模块。
同时写这两种测试很重要,能确保库的各部分无论是单独还是协作时都按预期工作。
11.3.1 单元测试
单元测试的目的是把代码的每个单元与剩余部分隔离开来单独测试,以便迅速定位哪里正常、哪里出错。你会把单元测试放在 src 目录下与被测代码同文件里。惯例是在每个文件里建一个名为 tests 的模块,把测试函数放进去,并用 #[cfg(test)] 标注该模块。
被测试模块与 #[cfg(test)]
#[cfg(test)] 标注在 tests 模块上,告诉 Rust 仅在执行 cargo test 时才编译并运行测试代码,而执行 cargo build 时则跳过。这样只构建库时可节省编译时间,也减少最终编译产物体积,因为测试代码不会被包含。你会看到,集成测试放在另一目录,因此无需 #[cfg(test)];而单元测试与源码同文件,就得用它指明别编进最终结果。
还记得本章第一节生成新 adder 项目时,Cargo 为我们生成了这段代码:
|
|
对于自动生成的 tests 模块,属性 cfg 代表 configuration,它告诉 Rust:只有给定的配置选项成立时才把后面的条目编进来。这里的配置选项是 test,由 Rust 在编译和运行测试时自动提供。用了 cfg 属性后,Cargo 仅在我们主动执行 cargo test 时才编译这些测试代码,模块里所有辅助函数——包括标了 #[test] 的——都遵循这一规则。
测试私有函数
在测试社区里,是否直接测试私有函数一直存在争议,而其他语言往往让这件事变得困难甚至不可能。无论你认同哪种测试理念,Rust 的隐私规则都允许你测试私有函数。来看实例中的私有函数 internal_adder:
|
|
注意,internal_adder 函数并未标记为 pub。测试只是普通的 Rust 代码,而 tests 模块也只是另一个模块。正如我们在第七章中讨论的那样,子模块里的条目可以使用其祖先模块中的条目。在此测试中,我们通过 use super::* 把 tests 模块父模块的所有条目引入作用域,于是测试就能调用 internal_adder。若你认为不应测试私有函数,Rust 也不会强迫你这么做。
13.3.2 集成测试
在 Rust 中,集成测试完全位于你的库之外。它们像任何外部代码一样使用你的库,因此只能调用库公开 API 中的函数。其目的是检验库的多个部分能否协同工作。 单独运行正确的代码单元在集成后可能出现问题,因此覆盖集成代码同样重要。要编写集成测试,首先得创建一个 tests 目录。
我们按照下图所示创建文件:
|
|
在 tests/integration_test.rs 文件中输入以下内容:
|
|
tests 目录下的每个文件都是独立的 crate,因此我们需要把库引入每个测试 crate 的作用域。为此,在文件顶部加上 use adder::add_two;,这在单元测试里并不需要。
我们不必在 tests/integration_test.rs 里的任何代码上标注 #[cfg(test)]。Cargo 会特殊对待 tests 目录,只有执行 cargo test 时才编译其中的文件。现在运行 cargo test:
输出的三部分依次是单元测试、集成测试和文档测试。注意,如果某部分的任何测试失败,后面的部分就不会运行。例如,若单元测试失败,集成测试和文档测试不会有输出,因为只有在全部单元测试通过时才会继续。
第一部分是单元测试,和之前一样:每个单元测试一行(我们之前的名为 internal 的测试),然后是一行汇总。
集成测试部分开头是 Running tests/integration_test.rs,接着列出该文件中的每个测试函数,并在 Doc-tests adder 开始前给出汇总。
每个集成测试文件都会单独成段,所以如果在 tests 目录再添加文件,就会出现更多集成测试段落。
仍可用 cargo test 函数名 来运行某个特定的集成测试函数;若要运行某个文件里的全部集成测试,使用 cargo test --test 文件名:
这就只让 tests/integration_test.rs 中的测试函数运行。
集成测试中的子模块
随着集成测试增多,你可能想在 tests 目录里再建几个文件来分类;比如按功能把测试函数分组。前面说过,tests 目录下的每个文件都会编译成独立的 crate,这样能各自拥有独立作用域,更贴近最终用户的使用方式。但也因此,这些文件不会像 src 里的文件那样共享行为——第 7 章讲过的“把模块拆到不同文件”的做法在这里并不适用。
当你想在多个集成测试文件里共用一组辅助函数,并尝试按第 7 章的步骤把它们提取到一个公共模块时,这种差异最明显。例如,我们创建 tests/common.rs 并在其中放一个 setup 函数,就可以在 setup 里写一些希望被多个测试文件里的多个测试函数调用的代码:
filename tests/common.rs:
|
|
再次运行测试时,我们会在测试输出里看到一个对应 common.rs 的新段落,尽管这个文件里没有任何测试函数,我们也没从任何地方调用 setup 函数:
让 common 出现在测试结果里,并显示“running 0 tests”并不是我们想要的效果;我们只是想在多个集成测试文件之间共享代码。为了避免 common 出现在测试输出中,我们不要创建 tests/common.rs,而是创建 tests/common/mod.rs。现在项目目录如下:
|
|
这是第 7 章“替代文件路径”中提到的旧命名方式,Rust 依然支持。把文件命名为 tests/common/mod.rs 后,Rust 就不会再把它当成集成测试文件。把 setup 函数移到 tests/common/mod.rs 并删除 tests/common.rs,测试输出里就不会再出现对应段落。tests 子目录中的文件不会被编译成独立 crate,也不会产生测试输出段落。
创建 tests/common/mod.rs 后,就能在任何集成测试文件里把它当作模块使用。下面示例展示了在 tests/integration_test.rs 的 it_adds_two 测试中调用 setup 函数:
|
|
注意,mod common; 声明与我们之前在第七章展示的模块声明完全一样。接着在测试函数里,就可以直接调用 common::setup()。
二进制 Crate 的集成测试
如果项目只是一个二进制 crate,只有 src/main.rs 而没有 src/lib.rs,就不能在 tests 目录里写集成测试并用 use 把 src/main.rs 里的函数导入作用域。只有库 crate 才能暴露供其他 crate 使用的函数;二进制 crate 设计为独立运行。
这也是很多提供可执行文件的 Rust 项目会把逻辑放在 src/lib.rs,再由简洁的 src/main.rs 去调用的原因。这样一来,集成测试就能 use 库 crate 来验证核心功能;核心功能正常,那一点 main.rs 里的代码自然也不会出错,无需单独测试。
Chapter 12:一个 I/O 项目:构建一个命令行程序
本章将回顾你到目前为止学到的众多技能,并探讨更多标准库特性。我们将构建一个命令行工具,它能与文件和命令行输入/输出进行交互,以此来练习一些你已掌握的 Rust 概念。
Rust 的速度、安全性、单一二进制输出以及跨平台支持使其成为创建命令行工具的理想语言,因此在我们的项目中,我们将打造经典命令行搜索工具 grep(*g lobally search a * r egular * e xpression and * p rint,即全局搜索正则表达式并打印)的自有版本。在最简单的使用场景中,grep 在指定文件中搜索指定字符串。 为此,grep 接收文件路径和字符串作为参数。然后它读取文件,找出文件中包含该字符串参数的行,并打印这些行。
在此过程中,我们将展示如何让我们的命令行工具使用许多其他命令行工具所采用的终端功能。我们会读取环境变量的值,以便用户配置我们工具的行为。我们还会将错误消息打印到标准错误控制台流(stderr),而不是标准输出(stdout),这样一来,例如,用户可以将成功的输出重定向到文件,同时仍能在屏幕上看到错误消息。
我们将会用到从第七章到第十一章的知识,并简单介绍一下迭代器(iterators)、闭包(closures)、特征对象(trait objects)这些概念。
12.1 接收命令行参数
首先,我们创建一个名为 minigrep 的新项目,使用 cargo new minigrep,我们将使用 minigrep 和我们系统自带的 grep 做区别。
项目的第一个要求就是,我们的可执行文件可以从终端接收两个命令行参数:要查询的文件路径和我们要查询的字符串。也就是说,我们希望能够通过 cargo run 运行程序,用两个连字符表示后面的参数是给我们的程序的,而不是给 cargo 的,还需要一个要搜索的字符串和一个要搜索的文件的路径,就像这样:
|
|
但是现在我们的程序还不能接收命令行的参数,我们将从 0 开始搭建这个过程。
12.1.1 读取参数的值
我们需要使用 std::env::args 来让程序可以读取到我们传入的参数值,这个函数会返回一个包含命令行参数的迭代器对象。迭代器将在十三章中详细解释,现在只需要明白,迭代器会产生一系列的值,我们可以使用 collect 方法将这些值转换成一个集合。
就比如下面这样:
filename: src/main.rs:
|
|
我们先把 std::env 引入到我们的作用域,以便我们使用他的 args 函数,然后再对其产生的迭代器进行 collect 产生一个集合;由于我们显式指定了 Vec<String>,所以这个产生的结合就是一个字符串的向量。因为 collect 必须式指定数据类型,和数据类型强转一个道理。最后我们是使用了 dbg! 宏来将向量的值打印出来。
[!WARNING]
请注意,如果任何参数包含无效的 Unicode,
std::env::args将会 panic。如果你的程序需要接受包含无效 Unicode 的参数,请改用std::env::args_os。该函数返回一个迭代器,产生的是OsString值而非String值。我们在此选择使用std::env::args是为了简便,因为OsString值因平台而异,且比String值更难处理。
我们可以先看看没有命令行参数的输出是什么样的:
再来看看添加了命令行参数的:
注意,向量中的第一个值是 "target/debug/minigrep",这是我们二进制文件的名称。这与 C 语言中参数列表的行为相符,允许程序在执行时使用调用它们的名称。如果需要在消息中打印程序名称,或者根据调用程序时使用的命令行别名来更改程序行为,那么能够访问程序名称通常会很方便。但就本章而言,我们会忽略它,只保存我们需要的两个参数。
12.1.2 将命令行参数保存到变量中
之前的内容我们将命令行参数保留到了一个集合中;为了后续更加方便的使用,我们将他们分别保存到不同的变量中就可以了。方法也很简单,直接使用向量的索引就行了:
filename: src/main.rs:
|
|
正如我们打印向量时所看到的,程序名称占据了向量中 args[0] 处的第一个值,因此我们从索引 1 开始处理参数。minigrep 接收的第一个参数是我们要搜索的字符串,所以我们将第一个参数的引用存入变量 query 中。第二个参数是文件路径,因此我们将第二个参数的引用存入变量 file_path 中。
我们可以暂时打印一下这两个参数的值看看:
可以看到我们的两个参数正确的打印出来了。
12.2 读取文件
现在我们将添加功能来读取 file_path 参数中指定的文件。首先,我们需要一个示例文件来进行测试:我们将使用一个包含少量文本的文件,这些文本分布在多行中,并且有一些重复的单词。在你的项目根目录下创建一个名为 poem.txt 的文件,并输入诗歌《断章》:
Filename: poem.txt:
|
|
之后,我们再修改代码来读取这个文件。
Filename: src/main.rs:
|
|
首先我们需要引用 fs 获取文件处理的相关操作。在主函数中,fs::read_to_string 将文件路径 file_path 作为参数,将该路径下的文件中的内容以字符串格式读取出来,expect 用于无法打开或者文件不存在时终止程序。最后通过 prinln! 将读取到的文件内容打印到终端中,我们可以运行 cargo run -- placeholder poem.txt(这里的第一个参数没有使用,所以写什么都无所谓)来观察现象:
太好了!这段代码读取并打印了文件内容。但它存在一些缺陷。目前,main 函数承担了多项职责:通常来说,如果每个函数只负责一个功能,函数会更清晰、更易于维护。另一个问题是我们对错误的处理还不够完善。这个程序现在还很小,所以这些缺陷不算大问题,但随着程序不断扩大,要干净利落地修复它们会变得更加困难。在开发程序时,尽早开始重构是个好习惯,因为重构少量代码要容易得多。接下来我们就这么做。
12.3 使用模块化重构、错误处理
为了改进我们的程序,我们在这一节将要修复四个问题:第一个问题,我们当前所有的逻辑都放在 main 函数中;在代码量特别小的时候不成问题,但是代码量过多就不易读。为了解决这个问题,我们将不同功能的部分分成不同模块来实现。
这个问题还与第二个问题相关:尽管 query 和 file_path 是我们程序的配置变量,但像 contents 这样的变量是用于执行程序逻辑的。main 函数变得越长,我们需要纳入作用域的变量就越多;作用域中的变量越多,就越难记住每个变量的用途。最好将配置变量分组到一个结构中,以明确它们的用途。
第三个问题是关于错误处理,我们当前只是简单粗暴的使用 expect 在无法打开文件的时候终止程序然后在终端中打印一条消息。但是打不开文件的原因有很多种,比如说可能是因为文件不存在、没有权限打开等,但是不同错误应该分开处理,所以我们还需要强化我们的错误处理部分。
第四,我们使用 expect 来处理错误,如果用户运行我们的程序时没有指定足够的参数,他们会收到 Rust 给出的 index out of bounds 错误,但这个错误并没有清晰地解释问题所在。最好将所有错误处理代码放在一个地方,这样如果将来需要修改错误处理逻辑,维护人员只需查看这一处代码即可。将所有错误处理代码集中在一处还能确保我们输出的信息对最终用户来说是有意义的。
接下来就着手于解决这些问题。
12.3.1 如何模块化
我们按照下列的步骤把项目模块化:
- 将我们的项目分为 main.rs 和 lib.rs,将所有的逻辑层都放到 lib.rs 中。
- 如果我们的命令行解析的逻辑足够简单,那么就可以留在 main.rs 中。
- 当命令行解析逻辑开始变得复杂时,将其从
main函数中提取到其他函数或类型中。
模块化之后 main.rs 中的主要职责应该限于以下内容:
- 调用命令行解析的函数,将命令行的参数传入。
- 设置其他功能的配置。
- 调用一个来自 lib.rs 的
run函数。 - 如果
run返回一个错误结果,处理run函数的错误。
这种模式的核心是关注点分离:main.rs 负责程序的运行,lib.rs 负责处理当前任务的所有逻辑。由于无法直接测试 main 函数,这种结构通过将程序的所有逻辑从 main 函数中移出,使你能够对其进行测试。留在 main 函数中的代码会足够简短,通过阅读就能验证其正确性。让我们按照这个流程重新编写程序。
12.3.2 分离命令行解析函数
我们将通过把命令行解析的功能单独封装成一个函数,然后在主函数中接收命令行的参数,再调用它即可:
Filename: src/main.rs
|
|
我们仍然在将命令行参数收集到一个向量中,但不再在 main 函数内将索引 1 处的参数值赋给变量 query、将索引 2 处的参数值赋给变量 file_path,而是将整个向量传递给 parse_config 函数。随后,parse_config 函数负责处理确定哪个参数对应哪个变量的逻辑,并将值传回 main。我们仍会在 main 中创建 query 和 file_path 变量,但 main 不再负责确定命令行参数与变量之间的对应关系。
对于我们这个小程序来说,这种修改可能显得有些多余,但我们正在以小而渐进的步骤进行重构。完成这一更改后,再次运行程序,验证参数解析是否仍然有效。经常检查进展情况是个好办法,这样在出现问题时有助于找出原因。
12.3.3 配置值分组
我们可以再迈出一小步,进一步改进 parse_config 函数。目前,我们返回的是一个元组,但随后又立即将这个元组拆分成各个部分。这表明我们可能还没有找到合适的抽象方式。
另一个表明还有改进空间的指标是 config 部分,即 parse_config,这意味着我们返回的两个值是相关的,并且都是一个配置值的组成部分。目前,除了将这两个值组合成一个元组外,我们在数据结构中并没有传达这一含义;相反,我们会将这两个值放入一个结构体中,并为每个结构体字段赋予一个有意义的名称。这样做将使未来维护这段代码的人更容易理解不同值之间的关系以及它们的用途。
Filename: src/main.rs
|
|
我们添加了一个名为 Config 的结构体,它定义了名为 query 和 file_path 的字段。parse_config 的签名现在表明它返回一个 Config 值 。在 parse_config 的主体中,我们曾经返回引用 args 中 String 值的字符串切片,现在我们定义 Config 来包含有所有权的 String 值。main 中的 args 变量是参数值的所有者,它只允许 parse_config 函数借用这些值,这意味着如果 Config 试图获取 args 中值的所有权,就会违反 Rust 的借用规则。
我们有多种方法可以管理 String 数据;最简单但效率稍低的方法是对这些值调用 clone 方法。这将为 Config 实例创建数据的完整副本,使其拥有该数据,这比存储字符串数据的引用要花费更多时间和内存。不过,克隆数据也让我们的代码变得非常简单,因为我们不必管理引用的生命周期;在这种情况下,牺牲一点性能来换取简洁性是一个值得的权衡。
[!NOTE]
许多 Rust 开发者倾向于避免使用
clone来解决所有权问题,因为它存在运行时成本。我们将在第十三章中学习更高效的方法,但就目前而言,复制几个字符串来继续推进是没问题的,因为你只会进行这些复制一次,而且你的文件路径和查询字符串都非常短。拥有一个能正常运行但效率稍低的程序,比在第一次尝试时就过度优化代码要好。随着你对 Rust 的经验越来越丰富,从最高效的解决方案入手会变得更容易,但现在,调用clone是完全可以接受的。
我们已经更新了 main,使其将 parse_config 返回的 Config 实例存入一个名为 config_Value 的变量中,并且我们更新了之前使用单独的 query 和 file_path 变量的代码,现在该代码使用的是 Config 结构体上的字段。
现在我们的代码更清晰地传达了 query 和 file_path 是相关的,并且它们的用途是配置程序的运行方式。任何使用这些值的代码都知道要在 config 实例中按其用途命名的字段中找到它们。
12.3.4 为 Config 结构体创建一个构造函数
到目前为止,我们已经从 main 中提取出了负责解析命令行参数的逻辑,并将其放入了 parse_config 函数中。这样做有助于我们发现 query 和 file_path 的值是相关联的,而这种关联应该在我们的代码中体现出来。随后,我们添加了一个 Config 结构体,以明确 query 和 file_path 的相关用途,并能够从 parse_config 函数中以结构体字段名的形式返回这些值的名称。
既然 parse_config 函数的作用是创建一个 Config 实例,我们可以将 parse_config 从一个普通函数改为一个名为 new 的函数,并将其与 Config 结构体相关联。这样的修改会让代码更符合惯例。我们可以通过调用 String::new 来创建标准库中类型的实例,比如 String。同样,通过将 parse_config 改为与 Config 相关联的 new 函数,我们将能够通过调用 Config::new 来创建 Config 的实例:
Filename: src/main.rs
|
|
我们已经更新了 main 函数,将其中对 parse_config 的调用替换为对 Config::new 的调用。我们将 parse_config 的名称改为 new </b4,并将其移入一个 impl 块中,这样就把 new 函数与 Config 关联起来了。请尝试再次编译这段代码,确保它能正常运行。
12.3.5 错误处理
现在我们来修复错误处理。回想一下,如果尝试访问 args 向量中索引 1 或索引 2 处的值,而该向量包含的元素少于 3 个,程序就会崩溃。试着不带任何参数运行程序,结果会是这样的:
“index out of bounds: the len is 1 but the index is 1”这条信息是针对程序员的错误提示。它无法帮助我们的终端用户理解他们应该采取什么操作。我们现在就来解决这个问题。
改进错误消息
我们可以优化我们的 Config 的 new 方法,让其有一个判断向量参数个数的方法:
|
|
现在我们再试试不带参数运行程序:
现在这个输出就明确了,明确的告知了我们当前的输入参数小于了 3 个。但是我们在第九章也提到过一个新的方法,不是简单的报错而是返回一个 Result,处理错误的逻辑交给调用者。
返回一个 Result 而不是简单的 panic!:
我们可以返回一个 Result 值,该值在成功的情况下将包含一个 Config 实例,并在出错的情况下描述问题。我们还将把函数名从 new 改为 build,因为许多程序员认为 new 函数永远不会失败。当 Config::build 与 main 通信时,我们可以使用 Result 类型来表示出现了问题。然后,我们可以修改 main,将 Err 变体转换为对用户来说更实用的错误,而不会像调用 panic! 那样出现关于 thread 'main' 和 RUST_BACKTRACE 的附加文本。
我们按照下面的方式修改代码:
|
|
我们的 build 函数返回一个 Result,在成功的情况下包含一个 Config 实例,在错误的情况下包含一个字符串字面量。我们的错误值将始终是具有 'static 生命周期的字符串字面量。
我们对函数体做了两处修改:当用户没有传递足够的参数时,不再调用 panic!,而是返回一个 Err 值;同时,我们将 Config 返回值包装在了 Ok 中。这些修改使该函数符合其新的类型签名。
从 Config::build 返回 Err 值,能让 main 函数处理从 build 函数返回的 Result 值,并在出现错误时更干净地退出进程。
调用 Config::build 之后处理错误
现在我们的构建函数会返回一个 Result 类型,所以我们的错误处理逻辑就轮到了我们调用函数来,更新我们的 src/main.rs,像下面的例子一样:
Filename: src/main.rs
|
|
这就是很常见的一种使用 if let ... else 的方式来处理 Result,但是还有像下面这种:
Filename: src/main.rs
|
|
我们使用了一种尚未详细介绍的方法:unwrap_or_else,它由标准库在 Result<T, E> 上定义。使用 unwrap_or_else 允许我们定义一些自定义的、非 panic! 的错误处理。如果 Result 是一个 Ok 值,该方法的行为与 unwrap 类似:它会返回 Ok 所包裹的内部值。然而,如果该值是一个 Err 值,此方法会调用闭包(closure)中的代码,闭包是我们定义并作为参数传递给 unwrap_or_else 的匿名函数。我们将在第 13 章更详细地介绍闭包。目前,你只需要知道 unwrap_or_else 会将 Err 的内部值(在这种情况下,就是我们添加的静态字符串 "not enough arguments")传递给竖线之间的参数 err 所对应的闭包。闭包中的代码在运行时就可以使用这个 err 值了。
我们添加了一个新的 use 行,将标准库中的 process 引入作用域。在错误情况下运行的闭包中的代码只有两行:我们打印 err 值,然后调用 process::exit。**process::exit 函数会立即停止程序,并返回作为退出状态码传递的数字**。这与我们之前的基于 panic! 的处理方式类似,但我们不会再得到所有额外的输出:
现在这个输出界面就对用户来说更加友好了。
将逻辑层从 main 函数中提取出来
现在,我们将有关命令行处理的逻辑单独封装成一个 run 函数,这个函数只负责接收解析好的命令行然后处理逻辑即可。这样的好处就是将获取命令行参数和命令行操作分开,更易于后期的维护,并且一定程度上解耦:
Filename: src/main.rs
|
|
run 函数现在包含了来自 main 的所有剩余逻辑,从读取文件开始。
从 run 函数中返回错误
接下来,要对 run 函数返回一个状态来表示执行情况,因为涉及到打开文件、读取文件等操作。并不是每一次操作都是成功的,有的情况会失败;所以我们接下来要让 run 函数返回一个 Result<T,E> 类型的数据来指示当前的情况:
|
|
我们在这里做了三处重大修改。首先,我们将 run 函数的返回类型改为了 Result<(), Box<dyn Error>>。该函数之前返回的是单元类型 (),现在我们在 Ok 情况中仍然返回该值。
对于错误类型,我们使用了特征对象(trait object)Box<dyn Error>(并且我们已经通过顶部的 use 语句将 std::error::Error 引入作用域)。我们将在第十八章具体介绍特征对象,目前,只需知道 Box<dyn Error> 意味着该函数将返回一个实现了 Error trait 的类型,但我们不必指定返回值具体是什么类型。这使我们能够灵活地返回在不同错误情况下可能属于不同类型的错误值。dyn 关键字是 dynamic 的缩写。
其次,我们使用了 ? 操作符来替代 expect;如同第九章中提到的,? 操作符在错误的时候会直接返回错误值,由调用者处理,而不是让程序 panic。
第三,run 函数现在在成功的情况下会返回一个 Ok 值。我们在签名中已将 run 函数的成功类型声明为 (),这意味着我们需要将单元类型值包装在 Ok 值中。这种 Ok(()) 语法起初可能看起来有点奇怪,但像这样使用 () 是一种惯用方式,用于表明我们调用 run 只是为了其副作用,它不会返回我们需要的值。
现在我们运行这个代码会看到一个警告:
Rust 告诉我们,我们的代码忽略了 Result 值,而 Result 值可能表明发生了错误。但我们没有检查是否存在错误,编译器提醒我们,这里或许本应有一些错误处理代码!现在让我们纠正这个问题。
我们按照 Config::build 一样的逻辑来处理这个错误:
|
|
这样,如果 run 函数返回的是一个错误值,将会和 if let 匹配,打印相关信息之后退出程序;如果是 Ok 则无事发生。这里使用 if let 而不是使用闭包是因为,这里的 Ok 包含的值是一个单元值,并没有任何意义,我们不需要使用他。在这两种情况下,if let 和 unwrap_or_else 函数的主体是相同的:我们打印错误并退出。
将代码放到库中
目前为止我们的 minigrep 初具雏形。但是所有的代码都放到了 src/main.rs 中,现在我们将部分代码放到 src/lib.rs 中,这样方便我们测试。
让我们在 src/lib.rs 中定义一个搜索文本的代码,这将使我们(或任何使用我们的 minigrep 库的人)能够从比我们的 minigrep 二进制文件更多的场景中调用搜索函数。首先,让我们如代码下所示,在 src/lib.rs 中定义 search 函数签名,其函数体调用 unimplemented! 宏,表示当前函数没有实现。我们将在填充实现时更详细地解释该签名:
Filename: src/lib.rs
|
|
我们在函数定义上使用了 pub 关键字,将 search 指定为我们库 crate 的公共 API 的一部分。现在我们有了一个库 crate,可以从二进制 crate 中使用它,也可以对其进行测试!
现在我们需要将 src/lib.rs 中定义的代码引入到 src/main.rs 中二进制 crate 的作用域内并调用它:
Filename: src/main.rs
|
|
我们添加了一行 use minigrep::search,将库 crate 中的 search 函数引入到二进制 crate 的作用域中。然后,在 run 函数里,我们不再打印文件内容,而是调用 search 函数,并将 config.query 的值和 contents 的引用作为参数传入。接着,run 会使用一个 for 循环来打印从 search 返回的与查询匹配的每一行。此时,也应该移除 main 函数中用于显示查询内容和文件路径的 println! 调用,这样我们的程序就只会打印搜索结果(如果没有发生错误的话)。
请注意,搜索功能会在进行任何打印操作之前,将所有结果收集到一个向量中。 在搜索大型文件时,这种实现方式可能会导致结果显示缓慢,因为结果不会在找到时立即打印出来;我们将在第 13 章讨论一种使用迭代器来解决这个问题的可能方法。
现在处理错误变得容易多了,而且我们让代码更模块化了。从现在开始,我们几乎所有的工作都将在 src/lib.rs 中完成。让我们利用这种新发现的模块化特性来做一件事,这件事在旧代码中很难实现,但在新代码中却很容易:我们要编写一些测试!
12.4 TDD(Test-Driven Development 测试驱动)
既然我们已经将搜索逻辑放在了 src/lib.rs 中,与 main 函数分离,那么为代码的核心功能编写测试就容易多了。我们可以直接用各种参数调用函数并检查返回值,而不必从命令行调用我们的二进制文件。
在本节中,我们将使用测试驱动开发(TDD)流程,通过以下步骤向 minigrep 程序添加搜索逻辑:
- 编写一个会失败的测试,然后运行它,以确保它是因为你预期的原因而失败。
- 编写或修改足够的代码,使新测试通过。
- 重构你刚刚添加或修改的代码,并确保测试继续通过。
- 从步骤 1 开始重复。
虽然测试驱动开发只是众多编写软件的方法之一,但它有助于推动代码设计。在编写能让测试通过的代码之前先编写测试,有助于在整个过程中保持较高的测试覆盖率。
我们将试运行这一功能的实现,该功能会实际在文件内容中搜索查询字符串,并生成与查询匹配的行列表。我们会将此功能添加到一个名为 search 的函数中。
12.4.1 编写一个错误的测试
在 src/lib.rs 中,我们将添加一个 tests 模块和一个测试函数,就像我们在第 11 章中所做的那样。测试函数指定了我们希望 search 函数具备的行为:它将接收一个查询和要搜索的文本,并仅返回文本中包含该查询的行:
此测试用于搜索字符串 "duct"。我们要搜索的文本有三行,其中只有一行包含 "duct"(注意,开头双引号后的反斜杠告诉 Rust 不要在该字符串字面量的内容开头添加换行符)。我们断言,search 函数返回的值只包含我们预期的那一行。
但是由于我们当前的 search 没有实现,使用的是 unimplemented! 作为占位符,所以程序会 panic。为了避免这个问题,我们让我们的函数返回一个空的向量:
Filename: src/lib.rs
|
|
现在我们来讨论为什么需要在 search 的签名中定义一个显式生命周期 'a,并将该生命周期用于 contents 参数和返回值。生命周期 参数指定哪个参数的生命周期与返回值的生命周期相关联。在这种情况下,我们表明返回的向量应该包含引用参数 contents(而非参数 query)切片的字符串切片。
换句话说,我们告诉 Rust,search 函数返回的数据的生命周期与通过 contents 参数传入 search 函数的数据的生命周期一样长。这一点很重要!切片所引用的 by 数据必须有效,引用才能有效;如果编译器认为我们是在对 query 而不是 contents 进行字符串切片,那么它的安全性检查就会出错。
如果我们不使用 'a,我们编译会发现这样:
这是由于编译器不知道两个入参哪个会被输出,我们需要显式告诉编译器。请注意,帮助文本建议为所有参数和输出类型指定相同的生命周期参数,这是不正确的!因为 contents 是包含我们所有文本的参数,而我们想要返回该文本中匹配的部分,所以我们知道 contents 是唯一应该使用生命周期语法与返回值相关联的参数。
12.4.2 编写一个可以通过的测试
目前,我们的测试失败了,因为我们总是返回一个空向量。要解决这个问题并实现 search,我们的程序需要遵循以下步骤:
- 遍历内容中的每一行。
- 检查该行是否包含我们的查询字符串。
- 如果是这样,就把它添加到我们要返回的值列表中。
- 如果没有,就什么也不做。
- 返回匹配的结果列表。
接下来就按照这个步骤。
遍历每一行
Rust 有一个方法就是一行一行的遍历字符串,一般都叫做 lines。就像下面这样:
Filename src/lib.rs
|
|
lines 方法会返回一个迭代器。对于迭代器,我们 13 章会详细讲,现在只需要知道我们可以通过 for 来遍历这个迭代器。
逐行寻找我们要查询的字符串
Rust 中有一个已经实现了检查字符串是否包含的方法 contains,所以我们可以像下面这样续写:
|
|
这样我们就可以简单判断一句话中是否包含我们要查询的字符串。但是现在的函数依然不能通过编译。
存储匹配的行
要完成这个函数,我们需要一种方法来存储我们想要返回的匹配行。为此,我们可以在 for 循环之前创建一个可变向量,并调用 push 方法将 line 存储到该向量中。在 for 循环之后,我们返回这个向量:
|
|
现在,search 功能应该只返回包含 query 的行,这样我们的测试应该就能通过了:
现在我们可以考虑一下重构 search 函数,同时保持原有的功能,确保其可以通过测试。十三章中我们会利用迭代器的一些特性来重构这个函数。
现在整个程序应该可以运行了!让我们来试一下,首先用一个应该能从 poem.txt 的诗中返回恰好一行包含“楼”字的行:
|
|
编译如下:
可以试试和多行匹配的字比如“风景”:
现在再试试我们输入一个不存在的字,比如“Rust”,应该是不会返回任何值的:
为了完善这个项目,我们将简要演示如何使用环境变量以及如何打印到标准错误流,这两者在编写命令行程序时都很有用。
12.5 环境变量
我们将通过添加一个额外功能来改进 minigrep 二进制文件:一个不区分大小写的搜索选项,用户可以通过环境变量开启该选项。我们本可以将此功能设为命令行选项,要求用户每次希望应用该功能时都输入它,但通过将其设为环境变量,我们允许用户设置一次环境变量,使其在该终端会话中的所有搜索都不区分大小写。
12.5.1 为不区分大小写搜索添加一个失败的测试
首先向 minigrep 添加一个函数名为 search_case_insensitive,用来不区分大小写来搜索。之后接着按照 TDD 流程来编写一个会失败的测试:
Filename: src/lib.rs:
|
|
在测试中我们也把原来的测试中来稍作了修改,用来测测试大小写。中使用了大写的 D,这样在进行区分大小写的搜索时,它不应与查询词 "duct" 匹配。通过这种方式修改旧测试,有助于确保我们不会意外破坏已经实现的区分大小写的搜索功能。现在这个测试应该能通过,并且在我们开发不区分大小写的搜索功能时,它也应该能继续通过。
新的测试中,我们的想要查询的词是 rUsT,在不区分大小写中,应该与之匹配的内容有:Rust:,Trust me。但是由于我们的函数并未具体构建,所以会测试不通过。
12.5.1 实现 search_case_insensitive 函数
其实实现方式很简单,我们只需要在遍历之前将我们要查询的字符串全小写,然后字符串中也全小写,之后的逻辑就和普通的 search 函数一样:
Filename: src/lib.rs
|
|
我们首先在函数内通过 to_lowercase() 让 query 变成全部小写,注 意,to_lowercase() 是会返回一个 String 类型的值。之后在遍历中,我们判断是否包含之前要将当前行给小写;其他的逻辑就没啥区别了。有一点,contains 方法的函数签名是这样的:
所以我们要保证入参是一个实现了 Pattern 的数据类型,String 类型没有实现的!所以我们这里需要使用 &query,因为此时的 query 已经不是我们的入参的 &str 类型了,而是由 to_lowercase() 返回的一个 Strinfg!
现在我们再查看我们的测试,结果如下:
可以看到,所有的测试都已经通过了,说明我们的构建是没问题的!
之后我们就需要通过环境变量来判断何时在 run 中调用这个函数,我们要先将我们 Config 结构体添加一个字段用于判断是否忽略大小写:
Filename: src/main.rs
|
|
之后我们在 run 函数中只需要判断 ignore_case 这个字段的值就可以判断调用哪个搜索函数了!我们的 run 函数现在应该是这样的:
|
|
现在我们还要去修改 Config 结构体的构建函数,我们需要根据环境变量的不同来为 Config 的 ignore_case 赋值。读取环境变量的一些方法都在标准库的 env 模块中,我们已经引入到了我们的作用域中。我们调用 var 函数来查看某个环境变量有没有被设置,在这里我们给这个变量命名为 IGNORE_CASE:
|
|
在这里,我们创建了一个新变量 ignore_case。为了设置它的值,我们调用 env::var 函数并向其传递环境变量 IGNORE_CASE 的名称。如果该环境变量被设置为任何值,env::var 函数会返回一个 Result,其中包含成功的 Ok 变体,该变体中存有环境变量的值。如果环境变量未被设置,它会返回 Err 变体。
我们在 Result 上使用 is_ok 方法来检查环境变量是否已设置,这意味着程序应该执行不区分大小写的搜索。如果 IGNORE_CASE 环境变量未设置任何值,is_ok 将返回 false,程序将执行区分大小写的搜索。我们不关心环境变量的 value,只关心它是否已设置,所以我们检查的是 is_ok,而不是使用 unwrap、expect 或我们在 Result 上见过的任何其他方法。
为了方便测试,我们修改 poem.txt 的内容为如下:
Filename: poem.txt
|
|
首先我们不设置该环境变量,直接运行 cargo run -- to poem.txt:
现在在该命令之前加上 IGNORE_CASE=1,就可以设置该环境变量了:
|
|
如果是 windows 的 Powershell,设置环境变量的命令行是:
|
|
这将使 IGNORE_CASE 在您的 shell 会话剩余时间内保持有效。可以使用 Remove-Item cmdlet 来取消设置:
|
|
我们可以看到下面的输出:
太棒了,我们还得到了包含 To 的行!我们的 minigrep 程序现在可以通过环境变量来控制不区分大小写的搜索了。现在你知道如何管理通过命令行参数或环境变量设置的选项了。
12.6 错误消息
目前,我们所有的输出都是使用 println! 宏写入终端的。在大多数终端中,有 两种输出类型:用于一般信息的 标准输出(stdout)和用于错误消息的 标准错误(stderr)。这种区分使用户可以选择将程序的成功输出定向到文件,同时仍将错误消息打印到屏幕上。println! 宏只能打印到标准输出,因此我们必须使用其他工具来打印到标准错误。
12.6.1 查看在错误出现在哪
首先,让我们观察一下 minigrep 输出的内容目前是如何写入标准输出的,包括那些我们希望改为写入标准错误的错误信息。我们可以通过将标准输出流重定向到一个文件,同时故意制造一个错误来实现这一点。我们不会重定向标准错误流,因此任何发送到标准错误的内容都将继续显示在屏幕上。
命令行程序应该将错误信息发送到标准错误流,这样即使我们将标准输出流重定向到文件,仍然能在屏幕上看到错误信息。我们的程序目前运行得并不好:我们马上就会发现它把错误信息输出保存到了文件中!
为了演示这种行为,我们将使用 > 和我们希望重定向标准输出流到的文件路径 output.txt 来运行程序。我们不会传递任何参数,这应该会导致错误:
|
|
> 语法告诉 shell 将标准输出的内容写入 output.txt,而不是显示在屏幕上。我们没有在屏幕上看到预期的错误消息,这意味着它一定是被写入了该文件。以下是 output.txt 包含的内容:
Filename: output.txt
|
|
可以看到,我们将本应该显示在终端的错误消息输出到了文件里,这显然是不符合我们的要求的。
12.6.2 将错误打印到标准错误流中
在标准库中提供了另一个打印宏——eprintln!,这个宏就会将错误答应到标准错误流中,所以我们可以按照下面来修改我们的主函数:
Filename: src/main.rs
|
|
现在再运行刚刚的命令:
可以看到错误正常打印到屏幕上了,而且在 output.txt 中会被空白覆盖,因为根本没有任何东西被输出。
最后我们再试试能不能正常使用,运行以下命令:
|
|
可以看到在 output.txt 中有以下内容:
说明我们的程序正常运行了!当然我们也可以试试加上环境变量,可自行测试。
Chapter 13:迭代器与闭包
Rust 的设计从许多现有语言和技术中汲取了灵感,其中一个重要影响是 函数式编程(functional programming)。采用函数式风格编程通常包括 将函数作为值来使用,比如将它们作为参数传递、从其他函数中返回、赋值给变量以便后续执行等等。在本章中,我们不会争论函数式编程是什么或不是什么,而是会讨论 Rust 中的一些特性,这些特性与许多通常被称为函数式的语言中的特性相似。
我们将会具体包含这几个方面:
- 闭包,一个类似于函数的结构,但是可以存储到一个变量中。
- 迭代器,一个用于遍历一系列变量的方法。
- 如何用迭代器何闭包来优化我们 12 章中的
minigrep。 - 迭代器何闭包的表现水平。
我们已经介绍过 Rust 的其他一些特性,比如模式匹配和枚举,它们也受到了函数式风格的影响。由于掌握闭包和迭代器是编写符合 Rust 风格且高效的代码的重要部分,所以我们将用整整一章来讲解它们。
13.1 闭包——捕获环境的匿名函数
Rust 中的闭包就是一个可以存储在变量中的匿名函数,我们将其当作一个参数传入函数中。我们可以在一个地方创建一个闭包,之后就可以在其他地方调用这个闭包,就可以在不同的上下文环境中求值。与 函数的一个重要的区别就是,闭包可以随时使用定义它的作用域中的值。
13.1.1 使用闭包捕获环境
首先,我们可以认识一下什么叫做捕获环境:其本质就是我们可以在之后的调用中使用定义闭包的作用域的一些变量。举个例子,情况是这样的:我们的 T 恤公司会时常向邮件列表中的某位用户赠送一件独家限量版 T 恤作为促销活动。邮件列表中的用户可以选择在个人资料中添加自己最喜欢的颜色。如果被选中获得免费 T 恤的用户设置了最喜欢的颜色,他们就会得到该颜色的 T 恤。如果用户没有指定最喜欢的颜色,他们会得到公司目前库存最多的那种颜色的 T 恤。
我们有很多方法实现这个功能,我们先使用一个枚举 ShirtColor,有两个变体 Red 和 Blue 来代表颜色。然后使用一个结构体 Inventory 来代表公司,他有一个 Vec<ShirtColor> 的字段叫做 shirts 用于表示当前库存的 T 恤。之后定义有一个方法叫做 giveaway 用来表示来给出 T 恤。代码如下:
|
|
在 main 中定义的 store 还剩两件蓝色衬衫和一件红色衬衫,用于此次限量版促销活动的发放。我们为一位偏好红色衬衫的用户和一位没有任何偏好的用户调用了 giveaway 方法。
同样,这段代码可以通过多种方式实现,在这里,为了聚焦于闭包,我们只使用了你已经学过的概念,除了使用闭包的 giveaway 方法体之外。在 giveaway 方法中,我们将用户偏好作为 Option<ShirtColor> 类型的参数获取,并对 user_preference 调用 unwrap_or_else 方法。unwrap_or_else 方法是在 Option<T> 上的方法,它由标准库定义。它 接受一个参数:一个不带任何参数的闭包,该闭包返回一个值 T(在这种情况下,与存储在 Option<T> 的 Some 变体中的类型相同,即 ShirtColor)。 如果 Option<T> 是 Some 变体,unwrap_or_else 会返回 Some 中的值。如果 Option<T> 是 None 变体,unwrap_or_else 会调用该闭包并返回闭包所返回的值。
我们将闭包表达式 || self.most_stocked() 指定为 unwrap_or_else 的参数。这是一个自身不接受任何参数的闭包(如果该闭包有参数,参数会出现在两个竖线之间)。闭包体调用了 self.most_stocked()。我们在此处定义这个闭包,而 unwrap_or_else 的实现会在需要结果时再对该闭包进行求值。
编译运行效果如下:
这里一个有趣的地方是,我们传递了一个闭包,该闭包在当前的 Inventory 实例上调用 self.most_stocked()。标准库无需了解我们定义的 Inventory 或 ShirtColor 类型,也无需了解我们在这种情况下想要使用的逻辑。该闭包捕获了对Inventory实例的 self 的不可变引用,并将其与我们指定的代码一起传递给 unwrap_or_else 方法。函数无法以这种方式捕获其环境。
13.1.2 闭包的类型推断与显式声明
函数和闭包之间还有更多区别。闭包通常不像fn函数那样要求你标注参数类型或返回值类型。函数需要类型标注,因为这些类型是暴露给用户的显式接口的一部分。严格定义这个接口很重要,这能确保所有人都认同函数使用和返回的值的类型。另一方面,闭包不会用于这样的暴露接口中:它们被存储在变量中,使用时无需命名,也不会暴露给我们库的用户。
闭包通常很短小,且仅在有限的上下文中有意义,而非适用于任何随意的场景。在这些有限的上下文中,编译器能够推断参数的类型和返回类型,这与它推断大多数变量类型的方式类似(不过在极少数情况下,编译器也需要闭包的类型注解)。
与变量类似,如果我们希望提高明确性和清晰度,就可以添加类型显式声明,不过这样做会比绝对必要的情况更为冗长。为闭包标注类型的方式如代码如下所示。在这个示例中,我们定义了一个闭包并将其存储在变量中,而不是在传递闭包作为参数的位置直接定义它:
|
|
添加了显式的类型声明之后,看上去就和函数特别相似了。为了便于比较,这里我们定义了一个给其参数加1的函数,以及一个具有相同行为的闭包。我们添加了一些空格来对齐相关部分。这说明了闭包语法与函数语法的相似之处,除了管道的使用以及可选语法的数量不同:
|
|
第一行展示了一个函数定义,第二行展示了一个带有完整注解的闭包定义。在第三行中,我们移除了闭包定义中的类型注解。在第四行中,我们去掉了括号,由于闭包体只有一个表达式,所以这些括号是可选的。这些都是有效的定义,在被调用时会产生相同的行为。add_one_v3和add_one_v4这两行要求闭包必须经过求值才能编译,因为它们的类型将从使用场景中推断得出。这类似于let v = Vec::new();,要么需要类型注解,要么需要向Vec中插入某种类型的值,Rust才能推断出其类型。
对于闭包定义,编译器会为其每个参数和返回值推断出一个具体类型。例如,下面展示了一个简短闭包的定义,该闭包仅返回它作为参数接收的值。除了本示例的目的外,这个闭包并不是很有用。注意,我们没有在定义中添加任何类型注解。由于没有类型注解,我们可以用任何类型来调用这个闭包,这里第一次使用的是String类型。如果我们随后尝试用整数调用example_closure,就会得到一个错误:
|
|
编译结果如下:
我们第一次用String值调用example_closure时,编译器会推断出x的类型以及闭包的返回类型都是String。这些类型随后会锁定在example_closure的闭包中,当我们接下来尝试用不同类型的值使用同一个闭包时,就会出现类型错误。
13.1.3 捕获一个引用或者移动所有权
闭包可以通过三种方式从其环境中捕获值,这三种方式直接对应函数接收参数的三种方式:不可变借用、可变借用和获取所有权。闭包会根据函数体对捕获值的操作来决定使用哪种方式。
看下面这个例子,我们定义了一个闭包,这个闭包会捕获一个向量的不可变引用,因为它不需要修改这个值,只是简单的打印:
|
|
这个例子还说明了变量可以绑定到闭包定义,之后我们可以通过使用变量名和括号来调用该闭包,就好像这个变量名是一个函数名一样。
由于我们可以同时拥有对list的多个不可变引用,list在闭包定义之前的代码中、闭包定义之后但调用之前以及闭包调用之后仍然是可访问的。这段代码能够编译、运行并输出:
之后我们修改闭包的函数体,让它向向量中添加一个元素。现在这个闭包就是捕获的一个可变引用:
|
|
编译结果如下:
请注意,在borrows_mutably闭包的定义和调用之间不再有println!:当定义borrows_mutably时,它会捕获对list的可变引用。在闭包被调用后,我们不会再使用该闭包,因此可变借用会结束。在闭包定义和闭包调用之间,不允许用于打印的不可变借用,因为存在可变借用时,不允许其他任何借用。尝试在那里添加一个println!,看看会得到什么错误消息:
如果你希望强制闭包获取其在环境中使用的值的所有权,即使闭包体并不严格需要所有权,你可以在参数列表前使用 move 关键字。当将闭包传递给新线程以转移数据,使数据归新线程所有时,这种技术非常有用。我们将在第16章讨论并发时详细探讨线程以及使用它们的原因,但现在,让我们简要了解一下如何使用需要move关键字的闭包来生成新线程:
|
|
我们生成一个新线程,并给该线程传递一个要运行的闭包作为参数。闭包体打印出列表。在之前的代码中,闭包仅通过不可变引用来捕获list,因为这是打印它所需的对list的最小访问权限。在这个例子中,尽管闭包体仍然只需要一个不可变引用,但我们需要通过在闭包定义的开头加上move关键字来指定list应被移动到闭包中。新线程可能在主线程的其余部分完成之前结束,也可能主线程先完成。如果主线程保留了list的所有权,但在新线程结束之前就结束了并丢弃了list,那么线程中的不可变引用就会失效。因此,编译器要求将list移动到提供给新线程的闭包中,以确保引用有效。试着移除move关键字,或者在定义闭包后在主线程中使用list,看看会出现什么编译器错误:
13.1.4 将捕获到的值移出闭包以及Fn trait
**一旦闭包捕获了其定义环境中的引用或获取了某个值的所有权(这会影响移入闭包的内容——如果有的话),闭包体中的代码就定义了在后续计算闭包时这些引用或值会发生什么(这会影响从闭包中移出的内容——如果有的话)。**闭包体可以执行以下任何操作:将捕获的值移出闭包、修改捕获的值、既不移出也不修改该值,或者从一开始就不从环境中捕获任何内容。
闭包捕获和处理环境中值的方式会影响闭包实现哪些 trait,而 trait 正是函数和结构体能够指定它们可以使用何种闭包的方式。根据闭包体处理值的方式,闭包会以累加的方式自动实现这些 Fn trait 中的一个、两个或全部三个。
FnOnce适用于可以被调用一次的闭包。所有闭包至少都实现了这个 trait,因为所有闭包都能被调用。将捕获的值移出其主体的闭包只会实现FnOnce,而不会实现其他Fntrait,因为它只能被调用一次。FnMut适用于不会将捕获的值移出其主体,但可能会修改捕获值的闭包。这些闭包可以被多次调用。Fn适用于不会将捕获的值移出其主体、不会修改捕获的值的闭包,以及不从环境中捕获任何内容的闭包。这些闭包可以被多次调用而不会修改其环境,这在诸如并发多次调用闭包等情况下非常重要。
可以看看Option<T> 的 unwarp_or_esle方法的函数签名:
|
|
在这里T是Some变体中的存储的值,改变体是属于Option<T>的,同时也是unwarp_or_else的返回类型。比如对Option<i32>调用unwarp_or_else会返回一个i32的类型。接下来,注意到unwrap_or_else函数有一个额外的泛型类型参数F。F类型是名为f的参数的类型,该参数是我们在调用unwrap_or_else时提供的闭包。
泛型类型F上指定的 trait 约束是FnOnce() -> T,这意味着F必须能够被调用一次,不接受任何参数,并且返回一个T。在 trait 约束中使用FnOnce表示这样一种限制:unwrap_or_else最多只会调用f一次。在unwrap_or_else的函数体中,我们可以看到如果Option是Some,则不会调用f;如果Option是None,则会调用f一次。由于所有闭包都实现了FnOnce,因此unwrap_or_else可以接受所有三种类型的闭包,并且具有尽可能高的灵活性。
[!NOTE]
如果我们想要做的事情不需要从环境中捕获值,我们可以使用函数名而非闭包。例如,我们可以对一个
Option<Vec<T>>类型的值调用unwrap_or_else(Vec::new),这样如果该值是None,就能得到一个新的空向量。编译器会自动为函数定义实现适用的Fn特征。
现在让我们看看切片上定义的标准库方法sort_by_key,了解它与unwrap_or_else有何不同,以及为什么sort_by_key在 trait 约束中使用FnMut而不是FnOnce。该闭包接收一个参数,形式为对当前正在处理的切片中项目的引用,并返回一个可排序的K类型的值。当你想根据每个项目的特定属性对切片进行排序时,这个函数很有用。在下列代码中,我们有一个Rectangle实例列表,并使用sort_by_key按它们的width属性从低到高排序:
|
|
函数会打印:
之所以sort_by_key被定义为接收一个FnMUt的闭包,是因为这个函数会多次调用闭包:它会对切片中的每个元素都调用一次。闭包|r| r.width的任务也很简单,接受一个Rectangle 结构体,返回其width字段,不会从其环境中捕获、修改或移出任何内容,因此它满足 trait 约束要求。
相比之下,下面展示了一个仅实现了FnOnce trait的闭包示例,因为它会将一个值从环境中移走。编译器不允许我们将这个闭包与sort_by_key一起使用:
|
|
这是一种刻意设计的、复杂且行不通的方法,试图计算在对list进行排序时,sort_by_key调用闭包的次数。这段代码尝试通过将value(闭包环境中的一个String)追加到sort_operations向量来进行计数。闭包捕获value,然后通过将value的所有权转移给sort_operations向量,将value从闭包中移出。这个闭包只能被调用一次;尝试第二次调用它是行不通的,因为value将不再存在于环境中,无法再次被推入sort_operations!因此,这个闭包只实现了FnOnce。当我们尝试编译这段代码时,会得到一个错误,提示value无法从闭包中移出,因为该闭包必须实现FnMut:
该错误指向闭包体中将 value 移出环境的那一行。要修复此问题,我们需要修改闭包体,使其不会将值移出环境。在环境中保留一个计数器,并在闭包体中递增其值,是一种更直接的方法来计算闭包被调用的次数。下面的闭包可以与 sort_by_key 配合使用,因为它仅捕获了对 num_sort_operations 计数器的可变引用,因此可以被多次调用:
|
|
在定义或使用函数,或者使用闭包的类型时,Fn特征非常重要。下一节中,我们将讨论迭代器。许多迭代器方法会接受闭包作为参数,所以在我们继续学习时,请记住这些闭包的细节!
13.2 迭代器——遍历一系列数据
迭代器就是用于快速遍历一个集合。如果我们要对一系列的数据中的每一个都做相同的操作,那么使用迭代器就可以避免很多重复的代码。
Rust中的迭代器是懒惰的,意味着只有在你调用方法来消耗迭代器的时候,迭代器才会一步一步往下遍历。比如下面这个代码,我们对向量v1创建了一个迭代器,但是不使用任何方法消耗这个迭代器,那么他本身并没有任何含义:
|
|
这个迭代器被存储在v1_iter变量中,只要我们创建了一个迭代器,我们可以用很多种方法去使用它。在之前讲循环的时候,我们使用了for循环来遍历一系列数据。其实这一过程的的底层中,隐式的创建了一个迭代器,然后逐步消耗他,只是我们之前并没有详细说明其工作原理。
比如下面这个例子,我们将迭代器的创建和迭代分开来,迭代我们使用的是for循环。在for循环内部,会把每一个迭代的对象打印出来:
|
|
在那些标准库未提供迭代器的语言中,你可能需要通过以下方式实现相同的功能:将一个变量初始化为索引0,使用该变量对向量进行索引以获取值,然后在循环中递增该变量的值,直到它达到向量中的项目总数。迭代器会为你处理所有这些逻辑,减少你可能会搞砸的重复代码。迭代器让你拥有更大的灵活性,可以将相同的逻辑用于多种不同类型的序列,而不仅仅是像向量这样可以索引的数据结构。让我们来看看迭代器是如何做到这一点的。
13.2.1 Iterator Trait和next方法
所有的迭代器都实现了一个叫做Iterator的trait。它是由标准库定义的,其定义看上去是这样的:
|
|
这里有新语法叫做type Item和Self::Item,这是用于定义一个该trait的一个关联类型(associated type)。对于关联类型,我们将在20章具体讨论,现在我们只知道,这个Iterator trait要求我们定义一个Item类型,该类型会被用于后面的next方法的返回类型中。换句话说,Item 类型将是从迭代器返回的类型。
Iterator trait只要求实现一个next方法,该方法会返回迭代器中的下一个值,这个值会被Some包裹,如果迭代器遍历完了将会返回None。
我们也可以直接对一个迭代器使用next方法来获得下一个值,比如可以查看下面这个测试:
|
|
测试结果如下:
需要注意的是,我们需要让v1_iter是可变的:在迭代器上调用next方法会改变迭代器用于跟踪自身在序列中位置的内部状态。换句话说,这段代码会消耗(或者说用完)迭代器。每次调用next都会从迭代器中取出一个元素。当我们使用for循环时,不需要让v1_iter是可变的,因为循环会获取v1_iter的所有权,并在后台使其变为可变的。
还要注意,我们通过调用next所获得的值是指向向量中值的不可变引用。iter方法会生成一个基于不可变引用的迭代器。如果我们想要创建一个获取v1的所有权并返回自有值的迭代器,可以调用into_iter而非iter。同样地,如果我们想要遍历可变引用,可以调用iter_mut而非iter。
13.2.2 消耗迭代器的方法
Iterator trait 有许多不同的方法,标准库为这些方法提供了默认实现;你可以在标准库 API 文档中查找 Iterator trait 来了解这些方法。其中一些方法在其定义中会调用 next 方法,这也是为什么在实现 Iterator trait 时,你需要实现 next 方法。
调用next的方法被称为消费型适配器(consumer adapters),因为调用它们会耗尽迭代器。sum方法就是一个例子,它会获取迭代器的所有权,并通过反复调用next来遍历元素,从而耗尽迭代器。在遍历过程中,它会将每个元素加到一个累加的总和中,当迭代完成时返回这个总和:
|
|
在调用sum之后,我们不允许使用v1_iter,因为sum会获取我们调用它所使用的迭代器的所有权。
13.2.3 其他产生迭代器的方法
**迭代器适配器(Iterator adapters)**是在Iterator trait上定义的方法,它们不会消耗迭代器,相反,它们通过改变原始迭代器的某些方面来生成不同的迭代器。
下面这个例子展示了一个调用了迭代适配器的一个方法叫做map,map接收一个闭包,之后对迭代器中的每一个对象都调用一次该闭包,然后会返回一个经过传入的闭包处理过后的一个新的迭代器,下面这个例子就是把迭代器中的每一个值都加上1,返回的迭代器就是原本的迭代器的所有对象的值都加上1:
|
|
可以看到有一个警告iterators are lazy and do nothing unless consumed,这很好符合我们之前说的迭代器都是惰性的。
为了修复这个问题,我们可以使用collect方法,它会消耗尽所有的迭代器,然后把该迭代器变成一个集合,不过我们需要显式指定这个集合的数据类型。比如下面这个代码:
|
|
因为map会接收一个闭包,所以我们可以指定想要对每个元素执行的任何操作。这是一个很好的例子,展示了闭包如何让你在复用Iterator trait提供的迭代行为的同时,自定义某些行为。你可以将多个迭代器适配器调用链接起来,以一种易读的方式执行复杂操作。但由于所有迭代器都是惰性的,你必须调用其中一个消费型适配器方法,才能从迭代器适配器的调用中获取结果。
13.2.4 使用捕获环境的闭包
许多迭代器适配器将闭包作为参数,通常我们指定为迭代器适配器参数的闭包是会捕获其环境的闭包。在这个示例中,我们将使用接受闭包的filter方法。该闭包从迭代器中获取一个元素并返回一个bool值。如果闭包返回true,则该值将被包含在filter生成的迭代中。如果闭包返回false,则该值不会被包含在内。
我们使用filter接受一个捕获自己环境中的shoe_size的闭包作为入参,让filter作用于一个Shoe结构体的集合。他只会返回一个满足我们的条件的鞋子:
|
|
函数shoes_fit_size会直接获取所有权,它会返回一个存有匹配我们设定尺码的鞋子的向量。在函数内部,我们使用了into_iter方法去创建了自有所有权的迭代器,之后使用filter去把迭代器调整成一个只包含让filter入参中的闭包返回为true的新的迭代器。闭包从环境中捕获shoe_size参数,并将该值与每只鞋的尺寸进行比较,只保留指定尺寸的鞋。最后,调用collect会将经过适配的迭代器返回的值收集到一个向量中,该向量由函数返回。
测试结果如下:
测试表明,当我们调用shoes_in_size时,我们只会得到与我们指定的值尺寸相同的鞋子。
13.3 改进我们的I/O程序
现在我们系统性的学习了闭包和迭代器,我们可以对我们12章写的minigrep做一些优化。来看看如何用所学的新知识来改进我们对Config::build和search的实现吧!
13.3.1 移除clone,使用迭代器
Filename: src/lib.rs
|
|
我们之前实现Config的构建函数的时候,我们说不必担心低效的clone调用,因为我们会在未来将其移除。好了,现在就是那个时候了。我们在这里需要clone,因为参数args中有一个包含String元素的切片,但build函数并不拥有args。为了返回一个Config实例的所有权,我们必须从Config的query和file_path字段中克隆值,这样Config实例才能拥有它自己的值。
有了关于迭代器的新知识,我们可以修改build函数,使其接收一个迭代器的所有权作为参数,而不是借用一个切片。我们将使用迭代器的功能,而不是检查切片长度并索引到特定位置的代码。这将使Config::build函数的作用更加清晰,因为迭代器会访问这些值。一旦Config::build获得迭代器的所有权,并且不再使用会进行借用的索引操作,我们就可以将String值从迭代器中移到Config中,而不必调用clone来进行新的内存分配。
直接返回一个迭代器
我们的已有的代码的src/main.rs中的主函数应该是这样的:
|
|
我们会从main函数开始改进,直到我们修改完成我们的Config::build函数之前,我们的代码都是不可以正常通过编译的。首先我们修改如下:
Filename:src/main.rs
|
|
env::args 函数会返回一个迭代器!我们没有将迭代器的值收集到向量中,然后再将切片传递给 Config::build,而是直接将从 env::args 返回的迭代器的所有权传递给了 Config::build。
接下来,我们需要更新Config::build的定义。在你的I/O项目的src/lib.rs文件中,让我们将Config::build的签名修改为如代码所示的样子。由于我们还需要更新函数体,所以此时它仍然无法编译:
Filename:src/lib.rs
|
|
标准库文档中关于env::args函数的说明显示,它返回的迭代器类型是std::env::Args,该类型实现了Iterator特性并返回String值。
我们更新了Config::build函数的签名,因此参数args具有带 trait 约束的泛型类型impl Iterator<Item = String>,而非&[String]。这里对impl Trait语法的使用我们在第10章就提过了,它意味着args可以是任何实现了Iterator trait并且返回String的类型。
因为我们正在获取args的所有权,并且我们将通过迭代来修改args,所以我们可以在args参数的规范中添加mut关键字,使其变为可变的。
使用迭代器而不是索引
接下来我们可以修改函数体,由于我们的入参args是一个迭代器,所以我们可以使用next方法来挨个访问数据:
Filename: src/lib.rs
|
|
请记住,env::args返回值中的第一个值是程序的名称。我们要忽略这个值,获取下一个值,所以首先调用next,并且不对返回值进行任何处理。然后我们调用next来获取要放入Config的query字段的值。如果next返回Some,我们就用match来提取该值。如果它返回None,那就意味着提供的参数不足,我们会提前返回一个Err值。对于file_path值,我们也会执行同样的操作。
13.3.2 使用迭代适配器来让代码更易读
我们可以利用迭代器配合上filter方法来让我们的search函数更加简洁。函数式编程风格倾向于尽可能减少可变状态的数量,以使代码更清晰。移除可变状态可能会为未来的改进创造条件,让搜索能够并行进行,因为我们不必管理对results向量的并发访问:
Filename: src/lib.rs
|
|
回想一下,search函数的作用是返回contents中所有包含query的行。这段代码使用filter适配器来仅保留那些line.contains(query)返回true的行。然后,我们用collect将匹配的行收集到另一个向量中。简单多了!你也可以对search_case_insensitive函数做同样的修改,以使用迭代器方法:
Filename: src/lib.rs
|
|
13.3.3 在迭代器和循环中抉择
接下来顺理成章的问题是,在你自己的代码中应该选择哪种风格以及为什么:是for循环的原始实现,还是使用迭代器的版本。大多数Rust程序员更倾向于使用迭代器风格。一开始可能有点难掌握,但一旦你对各种迭代器适配器及其作用有了感觉,迭代器就会更容易理解。代码不再需要处理各种循环细节和构建新向量,而是专注于循环的高层目标。这抽象掉了一些常见代码,因此更容易看到这段代码特有的概念,例如迭代器中的每个元素必须满足的过滤条件。
但这两种实现真的等效吗?直观的假设可能是低级循环会更快。让我们来谈谈性能。
13.4 性能比较: 迭代器 vs. 循环
要确定是使用循环还是迭代器,你需要知道哪种实现更快:是带有显式for循环的search函数版本,还是带有迭代器的版本。
我们进行了一项基准测试,方法是将阿瑟·柯南·道尔爵士的《福尔摩斯探案集》全文加载到一个String中,并在内容中查找“the”这个词。以下是使用for循环的search版本和使用迭代器的search版本的基准测试结果:
|
|
这两种实现的性能相近!我们在此不解释基准测试代码,因为重点并非证明这两个版本是等效的,而是大致了解这两种实现在性能方面的对比情况。
为了进行更全面的基准测试,你应该使用各种大小的不同文本作为contents,使用不同的单词以及不同长度的单词作为query,并尝试其他各种变体。关键在于:迭代器虽然是一种高级抽象,但编译后生成的代码与你自己编写的低级代码大致相同。迭代器是Rust的零成本抽象之一,这意味着使用这种抽象不会带来额外的运行时开销。这类似于C++的最初设计者和实现者比雅尼·斯特劳斯特鲁普在《C++基础》(2012年)中对零开销的定义:
一般来说,C++的实现遵循零开销原则:未使用的部分,无需付出代价。而且更进一步讲:所使用的部分,其效率无法通过手工编码得到更优的实现。
再举一个例子,以下代码取自一个音频解码器。该解码算法使用线性预测这一数学运算,基于先前样本的线性函数来估计未来值。这段代码使用迭代器链对作用域内的三个变量进行一些数学运算:一个数据切片buffer、一个包含12个元素的coefficients数组,以及用于在qlp_shift中移位数据的量。我们在这个示例中声明了这些变量,但没有给它们赋值;尽管这段代码脱离其上下文后没有太多意义,但它仍然是一个简洁的、真实世界中的例子,展示了Rust如何将高层概念转化为低层代码。
|
|
为了计算prediction的值,这段代码会遍历coefficients中的12个值,并使用zip方法将系数值与buffer中前12个值进行配对。然后,对于每一对值,我们将它们相乘,将所有结果相加,并将总和中的位向右移动qlp_shift位。
像音频解码器这类应用中的计算往往将性能放在首位。在这里,我们创建了一个迭代器,使用了两个适配器,然后消费该值。这段Rust代码会编译成什么样的汇编代码呢?嗯,在撰写本文时,它编译出的汇编代码与你手动编写的完全相同。根本没有与遍历coefficients中值相对应的循环:Rust知道会有12次迭代,因此它会“展开”循环。循环展开是一种优化手段,它去除了循环控制代码的开销,转而针对循环的每次迭代生成重复代码。
所有系数都存储在寄存器中,这意味着访问这些值的速度非常快。在运行时,对数组访问没有边界检查。Rust能够应用的所有这些优化使得生成的代码极其高效。既然了解了这一点,你就可以放心使用迭代器和闭包了!它们让代码看起来更高级,但不会因此带来运行时的性能损耗。
13.4 总结
闭包和迭代器是受函数式编程语言思想启发的 Rust 特性。它们使 Rust 能够以底层性能清晰地表达高级思想。闭包和迭代器的实现不会影响运行时性能。这是 Rust 努力提供零成本抽象这一目标的一部分。既然我们已经提升了I/O项目的表现力,那就来看看cargo的更多功能吧,这些功能将帮助我们与世界分享这个项目。
Chapter 14:Cargo 和 Crates.io
到目前为止,我们只使用了Cargo最基本的功能来构建、运行和测试代码,但它能做的还有很多。在本章中,我们将讨论它的一些其他更高级的功能,向你展示如何进行以下操作:
Cargo 的功能远不止本章所介绍的这些,因此,若想全面了解其所有特性,请参阅 它的文档。
14.1 自定义构建配置
在Rust中,release profile是预定义并且可以自定义的配置文件,我们可以修改里面的一些配置来更好的控制代码的编译的各种选项。每个项目的配置文件都是独立于其他的配置文件。
Cargo 有两个主要配置文件:运行 cargo build 时 Cargo 会使用的 dev 配置文件,以及运行 cargo build --release 时 Cargo 会使用的 release 配置文件。dev 配置文件具有适合开发的良好默认设置,而 release 配置文件则具有适合发布构建的良好默认设置。
这些配置文件名称可能在你的构建输出中见过:
dev和release是编译器使用的不同配置文件。
Cargo 对每个配置文件都有默认设置,当你未在项目的 Cargo.toml 文件中明确添加任何 [profile.*] 部分时,这些默认设置将生效。通过为你想要自定义的任何配置文件添加 [profile.*] 部分,你可以覆盖默认设置的任何子集。例如,以下是 dev 和 release 配置文件的 opt-level 设置的默认值:
Filename: Cargo.toml
|
|
opt-level 设置控制着 Rust 对代码应用的优化数量,其范围为 0 到 3。应用的优化越多,编译时间就越长,因此如果你处于开发阶段且需要频繁编译代码,可能希望减少优化以加快编译速度,即便生成的代码运行速度会变慢。因此,dev 模式下的默认 opt-level 是 0。当你准备发布代码时,最好花更多时间进行编译。你只会在发布模式下编译一次,但会多次运行编译后的程序,所以发布模式以更长的编译时间换取运行更快的代码。这就是 release 配置文件的默认 opt-level 为 3 的原因。
你可以通过在Cargo.toml中为默认设置添加不同的值来覆盖它。例如,如果我们想在开发配置文件中使用优化级别1,可以在项目的Cargo.toml文件中添加以下两行:
Filename: Cargo.toml
|
|
这段代码覆盖了0的默认设置。现在,当我们运行cargo build时,Cargo会使用dev配置文件的默认值,再加上我们对opt-level的自定义设置。由于我们将opt-level设置为1,Cargo将应用比默认情况更多的优化,但不会像发布版本构建那样进行过多优化。
有关每个配置文件的完整配置选项和默认值列表,请参见Cargo 的文档。
14.2 在Crate.io上发布自己的crate
我们使用了来自crates.io的包作为我们项目的依赖项,但你也可以通过发布自己的包与其他人分享你的代码。位于crates.io的 crate 注册表,它会分发你的包的源代码,因此它主要托管开源代码。
14.2.1 攥写有用的文档注释
准确地为你的包编写文档将帮助其他用户了解如何以及何时使用它们,因此花时间编写文档是值得的。在第3章中,我们讨论了如何使用双斜杠//来注释Rust代码。Rust还有一种特殊的注释用于文档,方便地称为文档注释,它可以生成HTML文档。HTML会显示公共API项的文档注释内容,这些内容是为那些有兴趣了解如何使用你的 crate 而不是你的 crate 是如何实现的程序员准备的。
文档注释使用三个斜杠 /// 而非两个,并且支持使用Markdown符号来设置文本格式。请将文档注释放在它们所要说明的项的前面。下面展示了名为 my_crate 的 crate 中一个 add_one 函数的文档注释:
Filename: src/lib.rs
|
|
在这里,我们对add_one函数的功能进行描述,以Examples为标题开始一个章节,然后提供演示如何使用add_one函数的代码。我们可以通过运行cargo doc从这个文档注释生成HTML文档。该命令会运行Rust附带的rustdoc工具,并将生成的HTML文档放在target/doc目录中。
为方便起见,运行cargo doc --open会为当前 crate 的文档(以及该 crate 所有依赖项的文档)生成 HTML 文件,并在网页浏览器中打开结果。导航到add_one函数,你会看到文档注释中的文本是如何呈现的,如图:
14.2.1 常用的部分
我们使用了# Examples这个Markdown标题,在HTML中创建了一个标题为“示例”的部分。以下是 crate 作者在其文档中常用的其他部分:
- Panics:所记录函数可能发生panic的场景。不希望程序发生panic的函数调用者应确保不在这些情况下调用该函数。
- Errors:如果函数返回一个
Result,那么描述可能出现的错误类型以及导致这些错误返回的条件,会对调用者有所帮助,这样他们就能编写代码以不同方式处理不同类型的错误。 - Safety:如果调用该函数是
unsafe(我们将在第20章讨论不安全性),则应有一个章节解释该函数为何不安全,并涵盖该函数期望调用者遵守的不变式。
大多数文档注释并不需要包含所有这些部分,但这是一个很好的清单,能提醒你代码用户可能会关心的方面。
14.2.2 作为测试的文档
在文档注释中添加示例代码块有助于展示如何使用你的库,而且这样做还有一个额外好处:运行cargo test会将文档中的代码示例作为测试来运行!没有什么比带有示例的文档更好的了。但最糟糕的莫过于示例无法运行,因为自文档编写以来代码已经发生了变化。如果我们针对add_one函数的文档运行cargo test`,会在测试结果中看到一个类似这样的部分:
现在,如果我们修改函数或示例,使得示例中的assert_eq!触发恐慌,然后再次运行cargo test,就会发现文档测试能检测到示例与代码不一致的问题!
14.2.3 为包含的项目添加注释
文档注释的格式//!会将文档添加到包含该注释的项中,而非添加到注释后面的项中。我们通常在 crate 根文件(按照惯例是src/lib.rs)中或模块内部使用这类文档注释,以对整个 crate 或模块进行文档说明。例如,要添加文档来描述包含add_one函数的my_crate包的用途,我们需要在src/lib.rs文件的开头添加以//!开头的文档注释,如代码:
Filename: src/lib.rs
|
|
注意,在最后一行以//!开头的代码后面没有任何代码。因为我们用//!而不是///来开始注释,所以我们是在为包含此注释的项编写文档,而不是为紧随此注释的项编写文档。在这种情况下,该项是src/lib.rs文件,也就是 crate 的根目录。这些注释描述了整个 crate。
当我们运行cargo doc --open时,这些注释将显示在my_crate文档的首页上,位于 crate 中公共项列表的上方,如图:
项目中的文档注释对于描述 crate 和模块尤其有用。使用它们来解释容器的整体用途,以帮助用户理解 crate 的结构。
14.2.4 使用pub use导出公用的API
发布 crate 时,其公共 API 的结构是一个重要考量因素。使用你的 crate 的人对其结构的熟悉程度不如你,如果你的 crate 有庞大的模块层级,他们可能很难找到自己想要使用的部分。
在第7章中,我们介绍了如何使用pub关键字使项目公开,以及如何使用use关键字将项目引入作用域。然而,在你开发一个 crate 时,对你来说合理的结构可能对用户并不那么方便。你可能希望将结构体组织在包含多个层级的层次结构中,但这样一来,那些想要使用你在层级结构深处定义的类型的人可能很难发现该类型的存在。他们也可能会对必须输入use my_crate::some_module::another_module::UsefulType; 而不是use my_crate::UsefulType;感到厌烦。
好消息是,如果某个结构不方便其他库使用,你不必重新调整内部组织:相反,你可以通过使用pub use来重新导出条目,从而创建一个与私有结构不同的公共结构。重新导出会将一个位置的公共条目在另一个位置也设为公共,就好像该条目原本是在另一个位置定义的一样。
例如,假设我们制作了一个名为art的库,用于建模艺术概念。这个库包含两个模块:一个是kinds模块,其中包含两个名为PrimaryColor和SecondaryColor的枚举;另一个是utils模块,其中包含一个名为mix的函数,如代码:
Filename: src/lib.rs
|
|
cargo doc生成的该 crate 文档的首页应该是这样的:
请注意,PrimaryColor和SecondaryColor类型未在首页列出,mix函数也未列出。我们必须点击kinds和utils才能看到它们。另一个依赖于该库的包需要使用use语句将art中的项引入作用域,并指定当前定义的模块结构。下面展示了一个使用art包中的PrimaryColor和mix项的包的示例:
Filename: src/main.rs
|
|
代码的作者使用了art crate,他必须弄清楚PrimaryColor位于kinds模块中,而mix位于utils模块中。art crate的模块结构对开发art crate的开发者来说更为相关,而对使用它的人来说则不然。内部结构对于试图理解如何使用art crate的人来说没有任何有用信息,反而会造成困惑,因为使用它的开发者必须弄清楚该去哪里查找,还必须在use语句中指定模块名称。
为了从公共API中移除内部组织,我们可以修改代码,添加pub use语句以在顶层重新导出这些项,如下所示:
Filename: src/lib.rs
|
|
现在,cargo doc 为这个 crate 生成的 API 文档会在首页列出并链接重导出的内容,如图所示,这使得 PrimaryColor 和 SecondaryColor 类型以及 mix 函数更容易被找到:
在存在许多嵌套模块的情况下,使用pub use在顶层重新导出类型,会极大改善使用该 crate 的用户体验。pub use的另一个常见用途是在当前 crate 中重新导出某个依赖项的定义,使该 crate 的定义成为你自己 crate 公共 API 的一部分。
创建一个有用的公共API结构更像是一门艺术而非科学,你可以通过迭代来找到最适合用户的API。选择pub use能让你在内部 crate 结构的组织上拥有灵活性,并将这种内部结构与呈现给用户的内容解耦。查看一些你已安装的 crate 的代码,看看它们的内部结构是否与公共API有所不同。
14.2.5 创建一个Crates.io账号
**在你发布任何 crate 之前,你需要在crates.io上创建一个账户并获取一个 API 令牌。**具体操作是,访问crates.io的主页,通过 GitHub 账户登录。(目前 GitHub 账户是必需的,但该网站未来可能会支持其他创建账户的方式。)登录后,访问位于https://crates.io/me/的账户设置,获取你的 API 密钥。然后运行cargo login命令,并在提示时粘贴你的 API 密钥,如下所示:
|
|
此命令会将你的API令牌告知Cargo,并将其本地存储在~/.cargo/credentials中。请注意,该令牌是一个秘密:不要与其他任何人分享。如果你因任何原因将其分享给了他人,你应当在crates.io上撤销该令牌并生成一个新的令牌。
14.2.6 为新的crate添加元数据
假设你有一个想要发布的 crate。发布前,你需要在该 crate 的 Cargo.toml 文件的 [package] 部分添加一些元数据。
你的 crate 需要一个独特的名称。当你在本地处理 crate 时,你可以给它取任何你喜欢的名字。不过,crates.io 上的 crate 名称采用先到先得的原则进行分配。一旦某个 crate 名称被占用,其他人就不能再使用该名称发布 crate 了**。在尝试发布 crate 之前,请搜索你想要使用的名称。如果该名称已被使用,你需要另找一个名称**,并编辑 [package] 部分下 Cargo.toml 文件中的 name 字段,使用新名称进行发布,如下所示:
Filename: Cargo.toml
|
|
即使你已经选择了一个独特的名称,此时当你运行cargo publish来发布这个 crate 时,你会先收到一个警告,然后是一个错误:
|
|
这会导致错误,因为你缺少一些关键信息:需要提供描述和许可证,这样人们才能知道你的 crate 是做什么的,以及他们可以在什么条款下使用它。在 Cargo.toml 中,添加一两句话的描述,因为它会和你的 crate 一起出现在搜索结果中。对于 license 字段,你需要提供一个 许可证标识符值。Linux 基金会的软件包数据交换(SPDX) 列出了可用于该值的标识符。例如,要指定你的 crate 使用 MIT 许可证,添加 MIT 标识符即可:
Filename: Cargo.toml
|
|
如果你想使用SPDX中未列出的许可证,需要将该许可证的文本放入一个文件中,将该文件包含在你的项目里,然后使用license-file来指定该文件的名称,而非使用license键。关于哪种许可证适合您的项目,超出了本书的范围。Rust社区中的许多人通过使用MIT OR Apache-2.0的双重许可,以与Rust相同的方式为他们的项目授权。这种做法表明,您也可以指定多个许可证标识符,并使用OR分隔,为您的项目设置多个许可证。
添加了独特的名称、版本、描述和许可证后,一个准备发布的项目的Cargo.toml文件可能如下所示:
Filename: Cargo.toml
|
|
Cargo 的文档 描述了其他元数据,你可以通过指定这些元数据,让其他人能更轻松地发现和使用你的 crate。
14.2.7 发布到Crates.io
既然你已经创建了账户、保存了API令牌、为你的包选择了名称并指定了所需的元数据,现在就可以发布了!发布包会将特定版本上传到crates.io供他人使用。
请注意,发布是永久性的。该版本永远无法被覆盖,代码也不能被删除。crates.io的一个主要目标是作为代码的永久存档,以便所有依赖于crates.io上的 crate 的项目都能进行构建都可以正常使用。允许删除版本会导致无法实现该目标。不过,您可以发布的 crate 版本数量没有限制。
再次运行cargo publish命令。现在它应该会成功:
|
|
您现在已经与Rust社区分享了您的代码,任何人都可以轻松地将您的 crate 作为其项目的依赖项。
14.2.8 为已存在的crate发布新版本
当你对自己的 crate 做了修改并准备发布新版本时,需要更改 version 在 Cargo.toml 文件中指定的值,然后重新发布。根据你所做的更改类型,使用 语义化版本规则 来确定合适的下一个版本号。之后运行 cargo publish上传新版本。
14.2.9 弃用版本
虽然你无法删除 crate 的旧版本,但可以阻止未来的项目将它们作为新依赖项添加。当某个 crate 版本因某种原因出现问题时,这一功能会很有用。在这种情况下,Cargo 支持撤销 crate 版本。撤回一个版本会阻止新项目依赖该版本,同时允许所有已依赖它的现有项目继续运行。本质上,撤回意味着所有带有Cargo.lock的项目都不会出现问题,且未来生成的任何Cargo.lock文件都不会使用被撤回的版本。
使用cargo yank就可以弃用某个版本。例如,如果我们发布过一个名为 guessing_game的 1.0.1 版本 crate,并且想要撤回它,那么在 guessing_game的项目目录中,我们会运行:
|
|
在命令中添加--undo,你还可以撤销一次弃用操作,并允许项目重新开始依赖某个版本。cargo yank操作不会删除任何代码。例如,它无法删除意外上传的密钥。如果发生这种情况,你必须立即重置这些密钥。
14.3 Cargo 工作区
在第12章中,我们构建了一个包含二进制 crate 和库 crate 的包。随着项目的发展,你可能会发现库 crate 不断变大,这时你会希望将包进一步拆分为多个库 crate。Cargo 提供了一个名为 工作区(workspace) 的功能,它可以帮助管理多个协同开发的相关包。
14.3.1 创建一个工作区
工作区是一组共享相同Cargo.lock和输出目录的包。让我们使用工作区创建一个项目——我们会使用简单的代码,这样就能专注于工作区的结构。构建工作区有多种方法,因此我们只展示一种常见方式。我们将创建一个包含一个二进制文件和两个库的工作区。这个二进制文件将提供主要功能,它会依赖这两个库。一个库将提供add_one函数,另一个库将提供add_two函数。这三个 crate 将属于同一个工作区。我们首先为工作区创建一个新目录:
|
|
接下来,在add目录中,我们创建Cargo.toml文件,该文件将配置整个工作区。此文件不会包含[package]部分。相反,它将以[workspace]部分开头,这使我们能够向工作区添加成员。我们还特意通过将resolver设置为"3",在工作区中使用Cargo解析器算法的最新版本:
Filename: Cargo.toml
|
|
接下来,我们将通过在add目录中运行cargo new来创建adder二进制 crate:
|
|
在工作区中运行cargo new还会自动将新创建的包添加到工作区Cargo.toml中[workspace]定义的members键中,如下所示:
|
|
此时,我们可以通过运行cargo build来构建工作区。你的add目录中的文件应该如下所示:
|
|
工作区的顶层有一个target目录,编译后的工件将被放置到该目录中;adder包没有自己的target目录。即使我们从adder目录内运行cargo build,编译后的工件最终仍会放在add/target中,而不是add/adder/target。Cargo在工作区中这样构建target目录结构,是因为工作区中的 crate 旨在相互依赖。如果每个 crate 都有自己的target目录,那么每个 crate 都必须重新编译工作区中的其他每个 crate,才能将工件放入自己的target目录。通过共享一个target目录,这些 crate 可以避免不必要的重新构建。
14.3.2 创建第二个Package
接下来,让我们在工作区中创建另一个成员包,并将其命名为add_one。生成一个名为add_one的新库 crate:
|
|
顶级的Cargo.toml现在将在members列表中包含add_one路径:
Filename: Cargo.toml
|
|
你的 add 目录现在应该包含这些目录和文件:
|
|
在add_one/src/lib.rs文件中,我们来添加一个add_one函数:
Filename: add_one/src/lib.rs
|
|
现在,我们可以让包含二进制文件的adder包依赖于包含我们库的add_one包。首先,我们需要在adder/Cargo.toml中添加一个对add_one的路径依赖。
Filename: adder/Cargo.toml
|
|
Cargo并不假设工作区中的 crate 会相互依赖,因此我们需要明确指定依赖关系:
接下来,让我们在adder crate中使用add_one函数(来自add_one crate)。打开adder/src/main.rs文件,并修改main函数以调用add_one函数,如下所示:
Filename: adder/src/main.rs
|
|
让我们在顶级 add 目录中运行cargo build来构建工作区:
|
|
要从 add 目录运行二进制 crate,我们可以使用-p参数和包名称,通过cargo run来指定要运行工作区中的哪个包:
|
|
这会运行adder/src/main.rs中的代码,该代码依赖于add_one crate。
14.3.3 在工作区中依赖外部包
请注意,工作区的顶层只有一个Cargo.lock文件,而不是在每个 crate 的目录中都有一个Cargo.lock文件。这确保了所有 crate 都使用相同版本的所有依赖项。如果我们将rand包添加到adder/Cargo.toml和add_one/Cargo.toml文件中,Cargo 会将这两个文件中的依赖项解析为同一个版本的rand,并将其记录在唯一的Cargo.lock中。让工作区中的所有 crate 使用相同的依赖项,意味着这些 crate 之间始终是兼容的。让我们将rand crate 添加到add_one/Cargo.toml文件的[dependencies]部分,这样我们就可以在add_one crate 中使用rand crate 了:
Filename: add_one/Cargo.toml
|
|
现在我们可以在add_one/src/lib.rs文件中添加use rand;,然后在add目录下运行cargo build来构建整个工作区,这样就会引入并编译rand crate。我们会收到一个警告,因为我们没有引用引入作用域的rand。
|
|
顶级的Cargo.lock现在包含了add_one对rand的依赖信息。不过,即便rand在工作区的某个地方被使用了,我们也不能在工作区的其他 crate 中使用它,除非我们也将rand添加到它们的Cargo.toml文件中。例如,如果我们在adder包的adder/src/main.rs文件中添加use rand;,就会出现错误:
|
|
要解决此问题,请编辑Cargo.toml文件(针对adder包),并指明rand也是该包的一个依赖项。构建adder包时,会将rand添加到Cargo.lock中adder的依赖项列表,但不会额外下载rand的副本。Cargo会确保工作区中每个使用rand包的包中的每个 crate 都使用相同的版本,只要它们指定了兼容的rand版本,这样既节省了空间,又能确保工作区中的各个 crate 彼此兼容。
如果工作区中的 crate 指定了同一依赖项的不兼容版本,Cargo 会解析每个版本,但仍会尽量减少解析的版本数量。
14.3.4 在工作区创建一个测试
另一个增强功能是,我们在add_one crate中添加对add_one::add_one函数的测试:
Filename: add_one/src/lib.rs
|
|
现在在顶级的add目录中运行cargo test。在这样结构的工作区中运行cargo test将会执行工作区中所有包的测试。
|
|
输出的第一部分显示,it_works测试在add_one crate中通过。下一部分显示在adder crate中未找到任何测试,最后一部分则显示在add_one crate中未找到任何文档测试。我们也可以从顶级目录对工作区中的某个特定 crate 运行测试,方法是使用 -p 标志并指定我们要测试的 crate 的名称:
|
|
此输出显示cargo test只运行了add_one crate的测试,没有运行adder crate的测试。如果你要将工作区中的包发布到crates.io,工作区中的每个包都需要单独发布。与cargo test类似,我们可以使用-p标志并指定想要发布的包的名称,来发布工作区中的某个特定包。
随着项目的发展,可以考虑使用工作区:这能让你处理更小、更易于理解的组件,而非一大块杂乱的代码。此外,如果多个 crate 经常需要同时修改,将它们放在工作区中可以更方便 crate 之间的协作。
14.4 使用cargo install安装二进制包
cargo install 命令允许你在本地安装和使用二进制 crate。这并非旨在替代系统包,而是为 Rust 开发者提供一种便捷的方式,用于安装其他人在 crates.io 上分享的工具。请注意,您只能安装具有二进制目标的包。二进制目标是指如果 crate 包含 src/main.rs 文件或其他指定为二进制的文件时所生成的可运行程序,与之相对的是库目标,库目标自身不可运行,但适合包含在其他程序中。通常,crates 会在 README 文件中说明该 crate 是库、具有二进制目标,还是两者兼具。
使用cargo install安装的所有二进制文件都存储在安装根目录的bin文件夹中。如果您使用rustup.rs安装了Rust,且没有任何自定义配置,该目录将是*$HOME/.cargo/bin*。请确保该目录在您的$PATH中,以便能够运行通过cargo install安装的程序。
例如,在第12章中我们提到,有一个用Rust实现的grep工具,名为ripgrep,用于搜索文件。要安装ripgrep,我们可以运行以下命令:
|
|
输出的倒数第二行显示了已安装二进制文件的位置和名称,对于ripgrep来说,这个名称是rg。如前所述,只要安装目录在你的$PATH中,你就可以运行rg --help,开始使用这个更快、更具Rust风格的文件搜索工具了!
14.4 使用自定义命令拓展Cargo
Cargo 的设计允许你在不修改它的情况下通过新的子命令对其进行扩展。如果你的 $PATH 中有一个名为 cargo-something 的二进制文件,你可以通过运行 cargo something 来将其当作 Cargo 的子命令执行。像这样的自定义命令在你运行 cargo --list 时也会被列出。能够使用 cargo install 来安装扩展,然后像使用 Cargo 的内置工具一样运行它们,这是 Cargo 设计带来的一个极为便捷的优势!
14.5 总结
使用Cargo和crates.io共享代码这是Rust生态系统适用于多种不同任务的部分原因。Rust的标准库小巧且稳定,但 crate 易于共享、使用和改进,其时间线与该语言不同。不要羞于在 crates.io 上分享对你有用的代码;它很可能也会对其他人有用!
Chapter 15:智能指针(Smart Pointers)
指针是一个通用概念,指的是包含内存中某个地址的变量。这个地址指向或“指向”其他一些数据。Rust中最常见的指针类型是引用,这在第4章已经介绍过。引用由&符号表示,它会借用所指向的值。除了引用数据外,引用没有任何特殊功能,也没有额外开销。
智能指针是一个类似于指针的数据结构,但是它拥有额外的元数据和功能。在类似于C++中,也有智能指针的概念,所以这不是Rust独有的一个概念。Rust 的标准库中定义了多种智能指针,它们提供的功能超出了引用所提供的功能。为了探讨这个通用概念,我们将看几个不同的智能指针示例,包括一种引用计数智能指针类型。这种指会跟踪所有的所有权拥有者,来允许一个数据的所有权被多个变量拥有,当没有所有权拥有者存在时,这个数据就会被清除。
由于Rust的独特的所有权和借用模型,智能指针与引用还是很不同的:引用只是借用数据,并不拥有数据,但是智能指针拥有被他指向的数据的所有权。
虽然当时我们并没有这样称呼它们,但在本书中我们已经遇到过一些智能指针了,包括第8章中的String和Vec<T>。这两种类型都算得上是智能指针,因为它们拥有一些内存并允许你对其进行操作。它们还具有元数据以及额外的功能或保证。例如,String会将其容量作为元数据存储,并且具备确保其数据始终是有效的UTF-8编码这一额外能力。
智能指针很多时候都是使用结构体来实现的,但是与普通的结构体不同,智能指针实现了Deref和Drop trait。Deref trait允许一个智能指针实例像引用一样被我们使用。Drop trait允许我们自定义当我们的代码运行超过智能指针实例的作用域之后的行为。在本章中,我们将讨论这两个 trait,并说明它们为何对智能指针很重要。
鉴于智能指针模式是 Rust 中频繁使用的一种通用设计模式,本章不会涵盖所有现有的智能指针。许多库都有自己的智能指针,你甚至还可以编写自己的智能指针。我们将介绍标准库中最常见的智能指针:
Box<T>在堆上分配内存Rc<T>,一种支持多重所有权的引用计数类型Ref<T>和RefMut<T>可通过RefCell<T>访问,RefCell是一种在运行时而非编译时强制执行借用规则的类型
此外,我们还将介绍内部可变性模式,即不可变类型暴露用于修改内部值的API。我们还将讨论引用循环:它们如何导致内存泄漏以及如何防止这种情况发生。
15.1 使用Box<T>指向堆上的数据
Box<T>是最常用的一种智能指针,它可以存储堆上的数据而不是栈上的数据,然后在栈中留下指向堆中数据的指针。
除了将数据存储在堆上而不是栈上之外,Box 没有性能开销。但它们也没有太多额外的功能。你最常在以下这些情况下使用它们:
- 当你有一个类型,其大小在编译时无法确定,而你又想在需要精确大小的上下文中使用该类型的值时
- 当你有大量数据并希望转移所有权,但要确保在此过程中数据不会被复制时
- 当你想要拥有一个值,并且只关心它是实现了特定 trait 的类型,而非某个具体类型时
15.1.1 Box<T>存储堆上的数据
在讨论Box<T>的堆存储用例之前,我们先来介绍其语法以及如何与存储在Box<T>中的值进行交互。下面这个例子展示了如何使用Box<T>来存储一个堆上的i32数据:
|
|
我们定义了一个b的值为一个指向5的Box,而这个5是存储在堆上的。这个程序的最终结果会输出b = 5,意味着我们可以和使用一个存储在栈上的数据一样去使用它。和任何拥有所有权的值一样,当Box超出作用域时——main函数的末尾——它就会被释放,会同时释放掉栈上的指针和堆上的数据。
但是在实际过程中,在堆上存放单个值并不常见,这种操作不如直接在栈上存储。让我们来看一个例子,在这个例子中,装箱能让我们定义一些如果没有装箱就无法定义的类型。
15.1.2 使用Box实现递归类型
递归类型的值可以包含另一个相同类型的值作为自身的一部分。递归类型会带来一个问题,因为Rust需要在编译时知道一个类型占用多少空间。然而,递归类型的值在理论上可以无限嵌套,所以Rust无法知道该值需要多少空间。由于箱式指针(box)的大小是已知的,我们可以通过在递归类型定义中插入一个箱式指针来实现递归类型。
作为递归类型的一个例子,让我们来探讨一下cons list。这是函数式编程语言中常见的一种数据类型。我们将要定义的cons列表类型,除了递归部分外都很简单;因此,这个示例中涉及的概念在你遇到任何更复杂的递归类型相关场景时都会很有用。
cons 列表是一种源自Lisp编程语言及其方言的数据结构,由嵌套的对组成,是链表的Lisp版本。它的名称来源于Lisp中的cons函数(构造函数的缩写),该函数根据其两个参数构造一个新的对。通过对由一个值和另一个对组成的对调用cons,我们可以构造由递归对组成的cons列表。
例如,下面是一个包含列表 1, 2, 3 的 cons 列表的伪代码表示,其中每对元素都放在括号中:
|
|
cons列表中的每个元素都包含两个部分:当前元素的值和下一个元素。列表中的最后一个元素只包含一个名为Nil的值,没有下一个元素。cons列表是通过递归调用cons函数生成的。表示递归基本情况的标准名称是Nil。请注意,这与第6章中讨论的“null”或“nil”概念不同,后者是一个无效值或缺失值。
cons 列表并不是 Rust 中常用的数据结构。大多数时候,当你在 Rust 中有一个项目列表时,Vec<T> 是一个更好的选择。其他更复杂的递归数据类型在各种情况下都 很有用,但通过在本章从 cons 列表开始,我们可以探索装箱如何让我们定义一个递归数据类型,而不会有太多干扰。
下面这个例子展示了一种类似于cons列表的Rust实现,但是现在这个代码不能通过编译,因为List类型并没有一个已知大小:
Filename:src/main.rs
|
|
[!NOTE]
我们当前实现的这个
List只能存储i32数据类型,但是其实我们可以使用泛型来实现一个什么类型都可以存储的cons列表
我们现在使用List类型来存储一个1,2,3的列表,就像这样(依旧不可编译):
Filename:src/main.rs
|
|
第一个Cons值包含1和另一个List值。这个List值是另一个Cons值,它包含2和另一个List值。这个List值是又一个Cons值,它包含3和一个List值,而这个List值最终是Nil,即表示列表结束的非递归变体。
编译结果如下:
错误显示这种类型“具有无限大小”。原因是我们定义的List包含一个递归的变体:它直接持有另一个自身类型的值。因此,Rust无法确定存储一个List值需要多少空间。让我们分析一下出现这个错误的原因。首先,我们来看看Rust是如何确定存储非递归类型的值需要多少空间的。
15.1.3 计算非递归类型的大小
回想一下我们在第六章定义的一个名为Recall的枚举类型:
|
|
为了确定要为Message值分配多少空间,Rust会检查每个变体,看哪个变体需要的空间最大。Rust发现Message::Quit不需要任何空间,Message::Move需要足够的空间来存储两个i32值,依此类推。由于只会使用一个变体,因此Message值所需的最大空间就是存储其最大变体所需的空间。
与此形成对比的是,当Rust试图确定像List枚举这样的递归类型需要多少空间时,会发生如下情况:编译器首先查看Cons变体,它包含一个i32类型的值和一个List类型的值。因此,Cons所需的空间大小等于一个i32的大小加上一个List的大小。为了确定List类型需要多少内存,编译器会查看各个变体,从Cons变体开始。Cons变体包含一个i32类型的值和一个List类型的值,这个过程会无限持续下去,如图所示:
15.1.4 使用Box<T>去获取一个位置大小的递归类型
上面的例子中,由于编译器不知道到底需要分配多少的内存给一个递归定义的类型,所以会报错。但是我们看他的提示:
提示告诉我们,让我们不要直接存储数据,而是使用Box等方法通过指针来间接存储数据来打破这个循环。
这是因为Box<T>是一个指针,指针的大小是已知的,因为它是由操作系统决定的。这意味着我们可以直接将一个Box<T>放在Cons中,而不是直接是另一个List。BOx<T>会直接指向下一个存储在堆上的List。从概念上讲,我们仍然拥有一个列表,它是通过让列表包含其他列表来创建的,但现在这种实现方式更像是将项目并排放置,而不是一个嵌套在另一个内部。
与之对应的,我们的代码应该修改成这样:
|
|
Cons 需要一个 i32的大小,再加上存储指针数据所需的空间。Nil 变体不存储任何值,因此它需要的空间比 Cons 变体少。现在我们知道,任何 List值都将占用一个 i32的大小加上指针数据的大小。通过使用指针,我们打破了无限的递归链,因此编译器可以计算出存储 List值所需的大小。图展示了 Cons 变体现在的样子:
Box 仅提供间接引用和堆分配功能,没有其他智能指针类型所具备的特殊能力。它们也不会因这些特殊能力而产生性能开销,因此在像 cons 列表这类只需要间接引用功能的场景中非常有用。我们将在第 18 章探讨 Box 更多的使用场景。
Box<T> 类型是一种智能指针,因为它实现了 Deref 特征,这使得 Box<T> 值可以像引用一样被对待。当 Box<T> 值超出作用域时,由于 Drop 特征的实现,该装箱所指向的堆数据也会被清理。在本章其余部分将要讨论的其他智能指针类型所提供的功能中,这两个特征将更为重要。让我们更详细地探讨这两个特征。
15.2 使用Deref让智能指针和常规引用一样
实现Deref trait 可以让你自定义解引用运算符*的行为。通过以某种方式实现Deref,使智能指针可以像常规引用一样使用,你可以编写操作引用的代码,并且也能将该代码用于智能指针。
让我们首先看看解引用运算符如何与常规引用一起工作。然后,我们将尝试定义一个行为类似于Box<T>的自定义类型,并了解为什么解引用运算符在我们新定义的类型上不能像在引用上那样工作。我们将探讨实现Deref trait 如何使智能指针能够以类似于引用的方式工作。接着,我们会了解 Rust 的解引用强制转换特性,以及它如何让我们能够同时处理引用或智能指针。
[!NOTE]
我们即将构建的
MyBox<T>类型与真实的Box<T>有一个很大的区别:我们的版本不会将数据存储在堆上。我们这个示例的重点是Deref,因此数据的实际存储位置与其类指针行为相比,重要性要低一些。
15.2.1 理解如何对一个值引用
常规的引用就是一个指针,理解指针的一种方式是将其视为指向存储在其他位置的值的箭头。下列的实例代码中,我们创建了一个i32的数据类型的引用,然后通过解引用来间接访问这个值:
|
|
在这里,我们定义了一个变量a存储着一个i32类型的5,之后定义了b,让b作为a的引用,所以b的数据类型就是&i32。所以我们通过*解引用b,就会得到a的值,因此我们的两个assert_eq!都是通过的。
但是如果我们修改为assert_eq!(b,5),再次运行会看到如下的报错:
不允许将一个数字与指向数字的引用进行比较,因为它们的类型不同。我们必须使用解引用运算符来获取该引用所指向的值。
15.2.2 像使用引用一样使用一个Box<T>
仿照上面的实例,我们可以将Box<T>加入代码:
|
|
p与b的区别在于,b是对a的引用,这里始终只存在一个5这个数据,它是存储在栈中的。但是p是一个指向存储在堆上的一个a也就是5的副本;在这里,我们也可以使用解引用操作符来将Box中的值给解引用出来。
15.2.3 定义一个我们自己的智能指针
我们可以通过自己构建一个类似于Box<T>的结构来加深我们对智能指针的理解。一个Box<T>最终会被定义为一个元组结构体,并且只有一个元素。所以我们可以按照同样的方法定义一个MyBox类型,然后为其提供一个new方法:
|
|
这样,我们就定义了一个MyBox结构体。我们使用<T>是因为我们需要存储任何数据类型,MyBox::new函数会获取一个入参,然后将这个入参通过MyBox包裹之后返回这个MyBox实例。
现在我们来测试一下我们的MyBox<T>,但是下面的这个代码并不能编译,因为我们没有告诉Rust要如何对MyBox这个新的数据类型解引用:
|
|
编译报错如下:
我们的MyBox<T>类型无法被解引用,因为我们尚未在该类型上实现这一功能。要启用使用*运算符进行解引用,我们需要实现Deref trait。
15.2.4 为MyBox实现Deref Trait
正如我们第十章内容所说的,要想要实现一个trait,我们需要实现该trait要求的方法。对于Deref trait,标准库要求我们实现一个叫做deref的方法来借用self的值然后返回对该值的引用。就像下面这样:
|
|
type Target = T; 这一语法为 Deref 特征定义了一个关联类型。关联类型是声明泛型参数的一种稍有不同的方式,但目前你无需担心它们;我们将在第 20 章更详细地介绍它们。
我们将deref的函数直接返回&self.0,这样deref函数就能够返回一个对self类型的引用,对应的,我们就可以使用*来解引用我们的MyBox中存储的值了。现在我们之前的测试就可以通过了。
如果没有Deref特征,编译器只能解引用使用&操作符的引用。deref方法使编译器能够获取任何实现了Deref特征的值,并调用deref方法来得到一个它知道如何解引用的&引用。
所以,当我们运行*p的时候,实际上是在运行*(p.deref())。Rust 用对 deref 方法的调用以及随后的普通解引用来替代 * 运算符,这样我们就不必考虑是否需要调用 deref 方法了。Rust 的这一特性使我们编写的代码无论面对普通引用还是实现了 Deref 的类型,都能以相同的方式工作。
deref方法返回值的引用,以及在*(y.deref())中括号外仍需要显式解引用的原因,都与所有权系统有关。如果deref方法直接返回值而不是值的引用,该值就会被移出self。在这种情况下,以及在大多数使用解引用运算符的情况下,我们并不想获取MyBox<T>内部值的所有权。
请注意,每次我们在代码中使用*时,*运算符都会被替换为对deref方法的一次调用,然后再对*运算符进行一次调用。由于*运算符的替换不会无限递归,我们最终会得到i32类型的数据。
15.2.5 函数、方法的隐式解引用强制转换
解引用强制转(deref coercion converts)换会将实现了Deref trait的类型的引用转换为另一种类型的引用。例如,解引用强制转换可以将&String转换为&str,因为String实现了Deref trait,使其能够返回&str。解引用强制转换是Rust为函数和方法的参数提供的一种便捷操作,且仅适用于实现了Deref trait的类型。当我们将特定类型值的引用作为参数传递给函数或方法,而该引用与函数或方法定义中的参数类型不匹配时,解引用强制转换会自动发生。通过一系列对deref方法的调用,可将我们提供的类型转换为参数所需的类型。
Rust 中加入解引用强制转换是为了让编写函数和方法调用的程序员不必使用&和*添加过多显式的引用和解引用。解引用强制转换特性还使我们能编写更多既适用于引用又适用于智能指针的代码。
为了查看解引用强制转换的实际效果,我们接着使用MyBox<T>,首先定义一个hello函数接收一个字符串切片然后打印这句话。我们可以将字符串切片作为参数来调用hello函数,例如hello("Rust");。解引用强制转换使得可以用MyBox<String>类型值的引用调用hello,如:
|
|
在这里,我们用参数&m调用hello函数,该参数是对MyBox<String>值的引用。由于我们为MyBox<T>实现了Deref特征,Rust可以通过调用deref将&MyBox<String>转换为&String。标准库为String提供了Deref的实现,它会返回一个字符串切片,这一点在Deref的API文档中有说明。Rust会再次调用deref,将&String转换为&str,而这与hello函数的定义相匹配。
如果Rust没有这个功能,同样的调用hello函数,我们需要像这样书写:
|
|
首先(*m)会解引用MyBox<String>得到String。然后&和[..]会获取String的字符切片,这里等价于整个字符串,这样才能满足hello的入参要求。这段代码会因为包含所有这些符号而更难读、难写且难理解。解引用强制转换让 Rust 能够自动为我们处理这些转换。
当涉及的类型定义了Deref特征时,Rust会分析这些类型,并根据需要多次使用Deref::deref以获取与参数类型匹配的引用。Deref::deref需要插入的次数是在编译时确定的**,因此利用解引用强制转换不会有运行时开销**!
15.2.6 如何让解引用强制转换和可变性配合
就像你使用Deref trait来重载不可变引用上的*运算符一样,你可以使用DerefMut trait来重载可变引用上的*运算符。
当Rust发现类型和trait实现满足以下三种情况的时候,Rust会进行解引用强制类型转换:
- 当
T: Deref<Target=U>时,从&T到&U - 当
T: DerefMut<Target=U>时,从&mut T到&mut U - 当
T: Deref<Target=U>时,从&mut T到&U
前两种情况基本相同,不同之处在于第二种情况实现了可变性。第一种情况表明,如果你有一个&T,且T实现了对某种类型U的Deref,你可以自然而然地获得一个&U。第二种情况则表明,对于可变引用,同样会发生这种解引用强制转换。就比如我们上面的hello函数,就是因为String实现了String:Deref<Target=str>,所以我们可以直接将一个&String类型的变量作为&str的入参。
第三种情况更棘手:Rust 也会将可变引用强制转换为不可变引用。但反过来则不行:不可变引用永远不会强制转换为可变引用。由于借用规则,如果你拥有一个可变引用,那么该可变引用必须是指向该数据的唯一引用(否则,程序将无法编译)。将一个可变引用转换为一个不可变引用永远不会违反借用规则。而将不可变引用转换为可变引用,则要求最初的不可变引用是指向该数据的唯一不可变引用,但借用规则并不能保证这一点。因此,Rust 无法假定将不可变引用转换为可变引用是可行的。
15.3 使用Drop trait在清理时运行代码
第二个对于智能指针特别重要的trait就是Drop,它允许你自定义你的当代码将要超出作用域时候一个值的行为。我们可以为人任何合理的类型实现Drop,而该代码可用于释放文件或网络连接等资源。
我们在智能指针的语境下介绍Drop,是因为实现智能指针时几乎总会用到Drop trait的功能。例如,当Box<T>被丢弃时,它会释放该Box所指向的堆上的空间。
在某些语言中,对于某些类型,程序员每次使用完这些类型的实例后,都必须调用代码来释放内存或资源。例如文件句柄、套接字和锁。如果他们忘记了,系统可能会过载并崩溃。在Rust中,你可以指定当一个值超出作用域时运行某段特定的代码,编译器会自动插入这段代码。因此,你无需费心在程序中所有使用完特定类型实例的地方放置清理代码,也不会出现资源泄漏的情况!
你可以通过实现Drop trait来指定当值超出作用域时要运行的代码。Drop trait要求你实现一个名为drop的方法,该方法接受一个对self的可变引用。为了了解Rust何时会调用drop,现在我们先用println!语句来实现drop。
下面展示了一个CustomSmartPointer结构体,当超出其作用域的时候,它会打印一句Dropping CustomSmartPointer!:
|
|
Drop trait 包含在 prelude 中,因此我们无需将其引入作用域。我们在 CustomSmartPointer 上实现了 Drop trait,并为调用 println! 的 drop 方法提供了一个实现**。drop 方法的主体部分是你可以放置任何希望在类型实例超出作用域时运行的逻辑的地方**。我们在这里打印一些文本,以便直观地展示 Rust 何时会调用 drop。
在main中,我们创建了两个CustomSmartPointer实例,然后打印CustomSmartPointers created。在main的末尾,我们的CustomSmartPointer实例将超出作用域,Rust会调用我们放在drop方法中的代码,打印出最后的消息。注意,我们不需要显式调用drop方法。
运行这段代码,可以看到下面的输出:
当实例超出作用域时,Rust 会自动为我们调用drop,执行我们指定的代码。变量的销毁顺序与创建顺序相反,因此d在c之前被销毁。这个示例的目的是直观地展示drop方法的工作原理;通常情况下,你需要指定类型所需的清理代码,而不是打印消息。
不幸的是,禁用自动的drop功能并非易事。通常情况下,禁用drop是没有必要的;Drop trait的核心意义就在于它会自动处理。不过,偶尔你可能希望提前清理某个值。例如,在使用管理锁的智能指针时:你可能希望强制调用drop方法来释放锁,以便同一作用域中的其他代码能够获取该锁。Rust不允许你手动调用Drop trait的drop方法;相反,如果你想在值的作用域结束前强制丢弃它,必须调用标准库提供的std::mem::drop函数。
如果我们尝试在main函数中手动调用Drop trait的drop方法,将会得到一个编译错误:
|
|
报错如下:
这条错误信息表明我们不允许显式调用drop。错误信息中使用了**析构函数(destructor)**这一术语,它是编程中用于描述清理实例的函数的通用术语。析构函数类似于构造函数,后者用于创建实例。Rust中的drop函数就是一种特定的析构函数。
Rust不允许我们显式调用drop,因为Rust仍会在main结束时自动对该值调用drop。这会导致二次释放错误,因为Rust会尝试清理同一个值两次。当一个值超出作用域时,我们无法禁用自动插入的drop,也不能显式调用drop方法。因此,如果我们需要强制一个值提前被清理,就会使用std::mem::drop函数。
|
|
文本Dropping CustomSmartPointer with data some data!打印在CustomSmartPointer created.和CustomSmartPointer dropped before the end of main.这两段文本之间,这表明此时会调用drop方法的代码来丢弃c。这说明,不管是被动的超出作用域还是手动的释放,只要一个类型是实现了Drop trait,他就会在释放时触发drop方法中的代码。
你可以通过多种方式使用在Drop trait实现中指定的代码,来使清理工作既方便又安全:例如,你可以用它来创建自己的内存分配器!借助Drop trait和Rust的所有权系统,你不必记得去清理,因为Rust会自动完成。你也不必担心因意外清理仍在使用的值而导致的问题:确保引用始终有效的所有权系统,也能保证当值不再被使用时,drop 只会被调用一次。
既然我们已经研究了Box<T>以及智能指针的一些特性,接下来让我们看看标准库中定义的其他一些智能指针。
15.4 Rc<T>引用计数器智能指针
在大多数情况下,所有权的归属都很明确,我们清楚地知道哪个变量拥有这个值的所有权。但是有时候,一个值可能会被很多变量拥有。例如,在图数据结构中,多条边可能指向同一个节点,从概念上讲,该节点归所有指向它的边**。只有所有指向这个节点的边都被drop之后才能将节点给释放掉,而不是只有一个边被释放这个节点就被释放**。
你必须通过使用Rust类型Rc<T>来显式启用多重所有权,该类型是引用计数(reference counting)的缩写。Rc<T>类型会跟踪对一个值的引用数量,以确定该值是否仍在使用中。如果一个值的引用数量为零,那么这个值就可以被清理掉,且不会导致任何引用失效。
可以把Rc<T>看作是一个家里面的电视,当有人想看电视的时候,就会打开它;同时别人也可以在中途参与进来一起看。当最后一个人不想看了就可以将电视关闭。如果有人在别人看电视的时候将电视关了,这不利于家庭和谐。
**当我们想要在堆上申请一部分内存,并且在程序的多个部分中都要使用它而且我们在编译的时候不知道哪部分最后使用这些内存,我们就可以使用Rc<T>。**相反,如果我们知道谁是最后一个使用者,我们大可以简单让这个使用者拥有这部分内存的所有权即可。
请注意,Rc<T>仅用于单线程场景。当我们在第16章讨论并发时,会介绍如何在多线程程序中进行引用计数。
15.4.1 使用Rc<T>分享数据
回想一下我们之前的cons list的例子,现在我们将让两条列表同时拥有第三条列表的所有权,就像下图一样:
我们将创建列表a,其中包含5,然后是10。接着,我们再创建两个列表:b以3开头,c以4开头。然后,b列表和c列表都将延续到第一个包含5和10的a列表。换句话说,这两个列表都将共享包含5和10的第一个列表。
如果使用Box<T>来创建的话,大概是类似于这样的代码:
|
|
编译结果如下:
因为Cons会拥有存储的值的所有权,所以当我们创建b的时候,a就被移动到了b,换句话说,b拥有了a;所以当我们创建c的时候,就不能再次使用a了,因为a已经被移动了。我们可以将Cons的定义改为持有引用,但这样我们就必须指定生命周期参数。通过指定生命周期参数,我们相当于表明列表中的每个元素的生命周期至少与整个列表一样长。这只是针对于这个例子的情况,但并非在所有场景下都是如此。
相反,我们将修改List的定义,用Rc<T>替代Box<T>,如清下所示。每个Cons变体现在将包含一个值和一个指向List的Rc<T>。当我们创建b时,不会获取a的所有权,而是克隆a所持有的Rc<List>,从而将引用计数从1增加到2,让a和b共享该Rc<List>中数据的所有权。在创建c时,我们也会克隆a,将引用计数从2增加到3。每次我们调用Rc::clone时,Rc<List>中数据的引用计数都会增加,并且只有当引用计数为零时,数据才会被清理。
|
|
我们需要添加一个use语句来将Rc<T>引入作用域**,因为它不在预导入模块中**。在main函数里,我们创建了一个包含5和10的列表,并将其存储在a中的一个新的Rc<List>里。然后,当我们创建b和c时,我们调用Rc::clone函数,并将a中Rc<List>的引用作为参数传递进去。
我们本可以调用a.clone(),而不是Rc::clone(&a),但在这种情况下**,Rust的惯例是使用Rc::clone。Rc::clone的实现不会像大多数类型的clone实现那样对所有数据进行深拷贝**。调用Rc::clone只会增加引用计数,这不会花费太多时间。数据的深拷贝可能会耗费大量时间。通过使用Rc::clone进行引用计数,我们可以从视觉上区分深拷贝类型的克隆和增加引用计数类型的克隆。在代码中查找性能问题时,我们只需要考虑深拷贝的克隆,而可以忽略对Rc::clone的调用。
15.4.2 克隆一个Rc<T>,增加一个引用计数器的值
将代码修改成下面的样子,我们就可以看到创建、删除Rc<T>的引用时候的引用计数器的变化了;我们通过设置一个内部的作用域,让c超出作用于被释放:
|
|
每当我们的引用计数值变化,我们就打印一次当前的引用计数值。这里我们使用的是strong_count而不是count是因为Rc<T>类型还存在一个weak_count方法,至于weak_count我们将在后文介绍。上面的代码的执行情况如下:
可以看到,引用计数器在a被创建的时候就有一个初始值1;之后每次调用Rc::clone都会让计数值加1。当减少一个引用——c离开了作用域之后,计数值就对应的减去1,这个过程是不需要我们手动实现的,因为Rc<T>实现了一个Drop trait来让离开作用域的时候减少计数值。
在这个例子中,当程序将主函数运行完了的时候,其实这个计数值会被清0,只是我们看不到这个过程。使用Rc<T>让一个值有很多的所有权拥有者,计数值会保证这个引用在所有权拥有者不为0之前的所有情况都有效。
通过不可变引用,Rc<T> 允许你在程序的多个部分之间共享数据,且只能用于读取,即它是不可变的。如果 Rc<T> 也允许你拥有多个可变引用,你可能会违反第 4 章中讨论的一个借用规则:对同一位置的多个可变借用可能会导致数据竞争和不一致。但能够修改数据是非常有用的!在下一节中,我们将讨论内部可变性模式以及 RefCell<T> 类型,你可以将其与 Rc<T> 结合使用,以应对这种不可变性限制。
15.5 RefCell<T>与内部可变
内部可变性(interior mutability)是Rust中的一种设计模式,它允许你在存在数据的不可变引用时仍能修改数据;通常情况下,这种操作是被借用规则禁止的。为了修改数据,该模式在数据结构内部使用unsafe代码来绕开Rust中管理修改和借用的常规规则。不安全代码向编译器表明,我们正在手动检查规则,而不是依赖编译器来为我们检查;我们将在第20章更详细地讨论不安全代码。
只有当我们能够确保在运行时遵守借用规则(即使编译器无法保证这一点)时,我们才能使用采用内部可变性模式的类型。此时,涉及的unsafe代码会被包装在安全的API中,而外部类型仍然是不可变的。
15.5.1 使用RefCell<T>的运行时强制借用规则
与Rc<T>不同,RefCell<T>类型代表对其持有的数据的唯一所有权。那么,RefCell<T>与Box<T>这类类型的区别是什么呢?回想一下你在第4章学到的借用规则:
- 在任何给定时间,你要么只能拥有一个可变引用,要么可以拥有任意数量的不可变引用(但不能同时拥有两者)。
- 引用必须始终有效。
对于普通引用和Box<T>,借用规则的不变性在编译时得到强制执行。而对于RefCell<T>,这些不变性则在运行时得到强制执行。使用引用时,若违反这些规则,会收到编译错误;使用RefCell<T>时,若违反这些规则,程序会发生恐慌并退出。
在编译时检查借用规则的优点是,错误能在开发过程中更早被发现,而且由于所有分析都提前完成,不会对运行时性能产生影响。出于这些原因,在大多数情况下,在编译时检查借用规则是最佳选择,这也是Rust将其设为默认方式的原因。
而在运行时检查借用规则的优势在于,它允许了一些不被编译器通过的内存安全场景。像Rust编译器所做的静态分析,本质上是保守的。代码的某些特性无法通过分析代码来检测:最著名的例子就是阻塞问题,这超出了本书的范围,但却是一个值得研究的有趣话题。
因为某些分析是不可能的,如果Rust编译器无法确定代码符合所有权规则,它可能会拒绝一个正确的程序;从这个角度来说,它是保守的。如果Rust接受了一个错误的程序,用户就无法信任Rust所做出的保证。然而,如果Rust拒绝了一个正确的程序,程序员会感到不便,但不会发生灾难性的后果。当你确定自己的代码遵循了借用规则,而编译器却无法理解并保证这一点时,RefCell<T>类型就很有用了。
与Rc<T>类似,RefCell<T>仅用于单线程场景,如果你尝试在多线程环境中使用它,会收到编译时错误。我们将在第16章讨论如何在多线程程序中实现RefCell<T>的功能。
以下是选择Box<T>、Rc<T>或RefCell<T>的原因总结:
Rc<T>允许一个值被多个变量拥有,但是Box<T>和RefCell<T>只能有一个拥有者。- 编译时,
Box<T>支持可变的引用,也支持不可变引用;Rc<T>编译时,只允许不可比引用;RefCell<T>在运行时,支持可变和不可变的引用。 - 因为
RefCell<T>允许在运行时检查可变借用,所以即使RefCell<T>是不可变的,你也可以修改RefCell<T>内部的值。
在不可变值内部修改其值的方式就是内部可变性模式。我们来看看内部可变性有用武之地的场景,并探究其实现原理。
15.5.2 内部可变:对一个不可变的变量进行可变引用
借用规则的一个结果是,当你拥有一个不可变的值时,你不能以可变方式借用它。例如,下面这段代码无法编译:
|
|
然而,在某些情况下,让一个值能够在自身方法中修改自己,同时在其他代码看来是不可变的,会很有用。该值方法外部的代码将无法修改这个值。使用RefCell<T>是实现内部可变性的一种方式,但RefCell<T>并未完全绕过借用规则:编译器中的借用检查器允许这种内部可变性,而借用规则会在运行时进行检查。如果违反了这些规则,会导致panic!,而非编译错误。
让我们来看一个实际示例,在这个示例中,我们可以使用RefCell<T>来修改不可变的值,并了解这样做的用处。
内部可变性的例子:模拟对象(Mock Objects)
在代码测试过程中,程序员有时会用一种类型替代另一种类型,以观察特定行为并确认其实现是否正确。这种占位类型被称为测试替身(test double)。可以把它想象成电影制作中的特技替身,即由一个人代替演员完成某个特别棘手的场景。在运行测试时,测试替身会替代其他类型。模拟对象是一种特定类型的测试替身,它会记录测试过程中发生的情况,这样你就可以确认是否执行了正确的操作。
Rust 中所谓的对象与其他语言中的对象并非同一概念,而且 Rust 的标准库中也没有像某些其他语言那样内置模拟对象功能。不过,你完全可以创建一个结构体来实现与模拟对象相同的功能。
以下是我们将要测试的场景:我们会创建一个库,用于跟踪某个值相对于最大值的情况,并根据当前值与最大值的接近程度发送消息。例如,这个库可用于跟踪用户被允许进行的API调用次数配额。
我们的库仅会提供追踪某个值接近最大值的程度,以及在何时应发送何种消息的功能。使用我们库的应用程序需要自行提供发送消息的机制:应用程序可以在自身内部显示消息、发送电子邮件、发送短信,或者采取其他方式。库不需要了解这些细节,它只需要某个实现了我们提供的名为Messenger的 trait 的东西即可。库代码如下:
Filename: src/lib.rs
|
|
这段代码的一个重要部分是,Messenger trait有一个名为send的方法,该方法接受一个对self的不可变引用和消息文本。这个特征是我们的模拟对象需要实现的接口,这样模拟对象就能以与真实对象相同的方式被使用。另一个重要部分是,我们希望测试LimitTracker上set_value方法的行为。我们可以更改传入value参数的值,但set_value不会返回任何值供我们进行断言。我们希望能够做到:如果我们创建一个LimitTracker,它带有一个实现了Messenger trait的对象和一个特定的max值,那么当我们为value传入不同的数字时,会通知信使发送相应的消息。
我们需要一个模拟对象,当我们调用send时,它不会发送电子邮件或短信,而只会记录它被要求发送的消息。我们可以创建这个模拟对象的新实例,创建一个使用该模拟对象的LimitTracker,调用LimitTracker上的set_value方法,然后检查该模拟对象是否包含我们预期的消息。下面展示了实现这样一个模拟对象的尝试,但借用检查器不允许这样做:
Filename: src/lib.rs
|
|
这段测试代码定义了一个MockMessenger结构体,它包含一个sent_messages字段,该字段是一个Vec类型的String值集合,用于记录它被要求发送的消息。我们还定义了一个关联函数new,以便创建新的MockMessenger值,这些值初始时的消息列表为空。然后,我们为MockMessenger实现了Messenger trait,这样我们就可以将MockMessenger提供给LimitTracker。在send方法的定义中,我们接收传入的消息作为参数,并将其存储在MockMessenger的sent_messages列表中。
在测试中,我们正在测试当告诉LimitTracker将value设置为超过max值的75%时会发生什么。首先,我们创建一个新的MockMessenger,它将以一个空消息列表开始。然后我们创建一个新的LimitTracker,并给它一个新的MockMessenger的引用和一个max值100。我们调用LimitTracker上的set_value方法,传入值80,这个值超过了100的75%。然后我们断言,MockMessenger正在跟踪的消息列表现在应该包含一条消息。
但是会出现下面这个问题:
我们无法修改MockMessenger来跟踪消息,因为send方法接收的是self的不可变引用。我们也不能按照错误文本的建议,在impl方法和trait定义中都使用&mut self。我们不想仅仅为了测试而修改Messenger trait。相反,我们需要找到一种方法,让测试代码能够与现有的设计正确配合工作。
这就是内部可变性能够发挥作用的场景!我们会将sent_messages存储在RefCell<T>中,这样send方法就能修改sent_messages,以存储我们已看到的消息:
Filename: src/lib.rs
|
|
sent_messages字段现在的类型是RefCell<Vec<String>>,而不是Vec<String>。在new函数中,我们围绕空向量创建了一个新的RefCell<Vec<String>>实例。
在实现send方法时,第一个参数仍然是self的不可变借用,这与特征定义相符。我们对self.sent_messages中的RefCell<Vec<String>>调用borrow_mut,以获取RefCell<Vec<String>>内部值(即该向量)的可变引用。然后,我们可以对该向量的可变引用调用push,以记录测试期间发送的消息。
我们需要做的最后一处修改是在断言部分:要查看内部向量中有多少个元素,我们需要对RefCell<Vec<String>>调用borrow方法,以获取该向量的不可变引用。
既然你已经了解了如何使用RefCell<T>,那我们就来深入探讨它的工作原理吧!
15.5.3 使用RefCell<T>在运行时跟踪引用情况
一般来说,我们想要对一个值创建一个不可变引用或者可变引用,我们使用&和&mut。但是当我们使用RefCell<T>的时候,我们要使用borrow来创建不可变引用,使用borrow_mut创建一个可变引用;而这两个API时RefCell<T>的安全的API。borrow会返回一个Ref<T>的智能指针类型,borrow_mut会返回一个RefMut<T>类型;这两种类型都实现了Deref trait,所以我们可以像平常的引用一样使用它。
RefCell<T> 会跟踪当前有多少个 Ref<T> 和 RefMut<T> 智能指针处于活跃状态。每次我们调用 borrow 时,RefCell<T> 就会增加其活跃的不可变借用计数。当 Ref<T> 值超出作用域时,不可变借用计数就会减 1。就像编译时的借用规则一样,RefCell<T> 允许我们在任何时候拥有多个不可变借用或一个可变借用。
如果我们尝试违反这些规则,与使用引用时会得到编译器错误不同,RefCell<T>的实现会在运行时触发恐慌。我们故意尝试在同一作用域中创建两个活跃的可变借用,以说明RefCell<T>会在运行时阻止我们这样做,我们在之前的基础上,在mod tests中加上下面的代码:
Filename: src/lib.rs
|
|
我们为从borrow_mut返回的RefMut<T>智能指针创建了一个变量one_borrow。然后,我们以同样的方式在变量two_borrow中创建了另一个可变借用。这就导致在同一作用域中出现了两个可变引用,这是不被允许的。当我们为库运行测试时,代码能够编译通过且无任何错误,但测试会失败:
注意,代码出现了恐慌,错误信息为already borrowed: BorrowMutError。这就是RefCell<T>在运行时处理违反借用规则的方式。
选择在运行时而不是编译时捕获借用错误,意味着你可能会在开发过程的后期才发现代码中的错误:甚至可能要等到代码部署到生产环境时才会发现。此外,由于在运行时而不是编译时跟踪借用情况,你的代码会受到轻微的运行时性能损失。不过,使用RefCell<T>可以编写一个模拟对象,该对象能够在仅允许使用不可变值的上下文中修改自身,以跟踪它所收到的消息。尽管存在这些权衡,你仍然可以使用RefCell<T>来获得比常规引用更多的功能。
15.6 使用Rc<T>和RcCell<T>实现可变的数据多所有权
使用RefCell<T>的一种常见方式是与Rc<T>结合。回想一下,Rc<T>允许你拥有某些数据的多个所有者,但它只提供对该数据的不可变访问。如果你有一个持有RefCell<T>的Rc<T>,你就能得到一个既可以有多个所有者又可以被修改的值!
例如,回想一下cons列表示例,我们在其中使用Rc<T>来允许多个列表共享另一个列表的所有权。由于Rc<T>只持有不可变值,一旦创建了列表中的值,我们就无法修改它们。让我们加入RefCell<T>,因为它能够修改列表中的值。下面的代码显示,通过在Cons定义中使用RefCell<T>,我们可以修改所有列表中存储的值:
Filename: src/main.rs
|
|
我们创建了一个值,它是Rc<RefCell<i32>>的实例,并将其存储在名为value的变量中,以便稍后可以直接访问它。然后,我们在a中创建了一个List,其中包含一个Cons变体,该变体持有value。我们需要克隆value,这样a和value都拥有内部5值的所有权,而不是将所有权从value转移到a,也不是让a从value那里借用。
我们将列表a包装在Rc<T>中,这样当我们创建列表b和c时,它们都可以引用a。
在我们在a、b和c中创建了列表之后,我们想给value中的值加上10。我们通过对value调用borrow_mut来实现这一点,这用到了我们在讨论过的自动解引用特性:解引用Rc<T>以获取内部的RefCell<T>值。borrow_mut方法会返回一个RefMut<T>智能指针,我们对其使用解引用运算符并修改内部值。
当我们打印a、b和c时,可以看到它们的值都被修改为了15,而不是5:
这个技术非常巧妙!通过使用RefCell<T>,我们有了一个表面上不可变的List值。但我们可以使用RefCell<T>上的方法来获取其内部可变性,这样在需要时就能修改数据。借用规则的运行时检查可以保护我们免受数据竞争的影响,有时为了数据结构的这种灵活性,牺牲一点速度是值得的。注意,RefCell<T>不适用于多线程代码!Mutex<T>是RefCell<T>的线程安全版本,我们将在第16章讨论Mutex<T>。
15.6 引用循环会导致内存泄露!
Rust的内存安全保证使得意外创建永远不会被清理的内存(即内存泄漏(memory leak))变得困难,但并非不可能。完全防止内存泄漏并非Rust的保证之一,这意味着在Rust中内存泄漏是内存安全的。我们可以通过使用Rc<T>和RefCell<T>看到Rust允许内存泄漏:有可能创建一些引用,其中的项形成相互引用的循环。这会导致内存泄漏,因为循环中每个项的引用计数永远不会达到0,这些值也永远不会被丢弃。
15.6.1 创建一个引用循环
我们通过下面这个例子来看一下一个引用循环会发生什么,和我们要如何去阻止它,我们依旧使用之前的List枚举和一个tail方法:
|
|
我们对List的枚举中的Cons变体的第二个值定义为RefCell<Rc<List>>,意味着,我们可能会修改Cons变体中指向的List的值。这个tail方法也让我们更加简单的找到下一项。
我们添加了一个main函数,这段代码在a中创建了一个列表,并在b中创建了一个指向a中列表的列表。然后,它修改a中的列表以指向b,从而形成一个引用循环。过程中还穿插了一些println!语句,用于显示该过程中各个节点的引用计数:
|
|
我们创建了一个Rc<List>实例,在变量a中存储了一个List值,初始列表为5, Nil。然后,我们在变量b中创建了另一个Rc<List>实例,该实例存储了另一个List值,其中包含值10,并指向a中的列表。
我们修改a,使其指向b而非Nil,从而创建一个循环。我们通过使用tail方法获取a中RefCell<Rc<List>>的引用,并将其存入变量link来实现这一点。然后,我们对RefCell<Rc<List>>调用borrow_mut方法,将其中的值从一个持有Nil值的Rc<List>修改为b中的Rc<List>。
编译结果如下:
在我们将a中的列表改为指向b后,a和b中Rc<List>实例的引用计数均为2。在main结束时,Rust会丢弃变量b,这会减少b的引用计数,b的Rc<List> 实例从 2 减到 1。此时,Rc<List> 在堆上的内存不会被释放,因为它的引用计数是 1,而不是 0。然后 Rust 释放 a,这会减少 a 的引用计数:a的 ``Rc实例也从2减到1。**这个实例的内存也不能被释放,因为另一个Rc`实例仍然引用着它。分配给这个列表的内存将永远无法被回收**。如下图:
如果你取消最后一个println!的注释并运行程序,Rust会尝试打印这个循环——a指向b,b又指向a,如此反复,直到栈溢出。
与现实世界中的程序相比,在这个例子中创建引用循环的后果并不严重:我们创建引用循环后,程序就会立即结束。然而,如果一个更复杂的程序在循环中分配了大量内存并长时间持有这些内存,该程序所使用的内存就会超过实际需求,可能会使系统不堪重负,导致可用内存耗尽。
避免引用循环的另一个解决方案是重新组织数据结构,使部分引用表示所有权,部分引用不表示所有权。这样一来,循环可以由一些所有权关系和一些非所有权关系构成,而只有所有权关系会影响一个值是否可以被丢弃。
15.6.2 使用Weak<T>来避免循环引用
到目前为止,我们已经证明,调用Rc::clone会增加Rc<T>实例的strong_count,而只有当Rcstrong_count为0时,它才会被清理Rc::downgrade并传递对Rc<T>的引用来创建指向Rc<T>实例内部值的弱引用(weak reference)。强引用是你可以共享Rc<T>实例所有权的方式。弱引用不会表达所有权关系,它们的计数也不会影响Rc<T>实例的清理时机。它们不会导致引用循环,因为一旦涉及的值的强引用计数为0,任何包含一些弱引用的循环都会被打破。
当你调用Rc::downgrade时,会得到一个类型为Weak<T>的智能指针。调用Rc::downgrade不会将Rc<T>实例中的strong_count加1,而是会将weak_count加1。Rc<T>类型使用weak_count来跟踪存在多少个Weak<T>引用,这与strong_count类似。不同之处在于,要清理Rc<T>实例,weak_count不必为0。
由于 Weak<T> 所引用的值可能已经被丢弃,因此要对 Weak<T> 指向的值进行任何操作,必须确保该值仍然存在。可以通过在 Weak<T> 实例上调用 upgrade 方法来实现,该方法会返回一个 Option<Rc<T>>。如果 Rc<T> 的值尚未被丢弃,你会得到 Some 的结果;如果 Rc<T> 的值已经被丢弃,则会得到 None 的结果。因为 upgrade 会返回 Option<Rc<T>>,Rust 会确保 Some 情况和 None 情况都得到处理,从而不会出现无效指针。
举个例子,我们不会使用一种列表(其中的条目只知道下一个条目),而是会创建一种树状结构,其中的条目既知道自己的子条目,也知道自己的父条目。
15.6.3 创建一个树状结构
首先,我们将构建一个树,其节点知晓自己的子节点。我们会创建一个名为Node的结构体,它既包含自身的i32值,也包含对其子节点Node值的引用:
|
|
我们希望一个Node拥有它的子节点,并且希望与变量共享这种所有权,这样我们就可以直接访问树中的每个Node。为了实现这一点,我们将Vec<T>项定义为Rc<Node>类型的值。我们还希望修改那些是另一个节点的子节点的节点,因此在children中,我们在Vec<Rc<Node>>周围设置了一个RefCell<T>。
接下来,我们将使用结构体定义创建一个名为leaf的Node实例,其值为3且没有子节点,另一个实例名为branch,值为5,并将leaf作为其子节点之一,如代码:
|
|
我们克隆了Rc<Node>并将其存储在leaf中,然后把它存到branch里,这意味着leaf中的Node现在有两个所有者:leaf和branch。我们可以通过branch.children从branch访问到leaf,但无法从leaf访问到branch。原因是leaf没有指向branch的引用,不知道它们之间有关联。我们希望leaf知道branch是它的父节点。接下来我们就来实现这一点。
15.6.4 创建一个从子到父的引用
为了让子节点知晓其父节点,我们需要在parent字段中添加Node结构体定义。问题在于确定parent的类型。我们知道它不能包含Rc<T>,因为这会创建一个引用循环:leaf.parent指向branch,而branch.children指向leaf,这会导致它们的strong_count值永远不会为0。
换个角度思考这种关系,父节点应该拥有其子节点:如果父节点被删除,其子节点也应该被删除。但是,子节点不应该拥有其父节点:如果我们删除子节点,父节点应该仍然存在。这就是弱引用的用武之地。
因此,我们不会使用Rc<T>,而是让parent的类型使用Weak<T>,具体来说是RefCell<Weak<Node>>。现在我们的Node结构体定义如下:
|
|
一个节点可以引用其父节点,但并不拥有其父节点;我们更新了main以使用这个新定义,这样leaf节点就能够引用其父节点branch了:
|
|
创建leaf节点的过程与之前类似,不同之处在于parent字段:leaf节点初始时没有父节点,因此我们创建一个新的、空的Weak<Node>引用实例。
此时,当我们尝试通过使用upgrade方法获取leaf的父节点引用时,得到的是一个None值。我们在第一个println!语句的输出中可以看到这一点:
当我们创建branch节点时,它的parent字段中也会有一个新的Weak<Node>引用,因为branch没有父节点。我们仍然将leaf作为branch的子节点之一。一旦在branch中获得了Node实例,我们就可以修改leaf,使其拥有一个指向其父节点的Weak<Node>引用。我们对leaf的parent字段中的RefCell<Weak<Node>>调用borrow_mut方法,然后使用Rc::downgrade函数从branch中的Rc<Node>创建一个指向branch的Weak<Node>引用:
输出不是无限的,这表明这段代码没有创建引用循环。我们也可以通过调用Rc::strong_count和Rc::weak_count得到的值来确认这一点。
15.6.5 可视化strong_count和weak_count的变化
让我们通过创建一个新的内部作用域并将branch的创建移到该作用域中,来看看Rc<Node>实例的strong_count和weak_count值是如何变化的。通过这种方式,我们可以观察到当branch被创建,然后在超出作用域时被丢弃时会发生什么。
|
|
编译结果如下:
创建leaf后,其Rc<Node>的强引用计数为1,弱引用计数为0。在内部作用域中,我们创建branch并将其与leaf关联,此时当我们打印计数时,branch中的Rc<Node>强引用计数为1,弱引用计数为1(因为leaf.parent通过Weak<Node>指向branch)。当我们打印leaf的计数时,会发现其强引用计数为2,因为branch现在在branch.children中存储了leaf的Rc<Node>的一个克隆,但弱引用计数仍为0。
当内部作用域结束时,branch 超出作用域,Rc<Node> 的强引用计数减少到 0,因此其 Node 会被丢弃。来自 leaf.parent 的 1 个弱引用计数不影响 Node</b4 是否被丢弃,所以我们不会有任何内存泄漏!
如果我们在作用域结束后尝试访问leaf的父节点,会再次得到None。程序结束时,leaf中的Rc<Node>的强引用计数为1,弱引用计数为0,因为此时变量leaf再次成为指向Rc<Node>的唯一引用。
所有用于管理计数和值释放的逻辑都内置在Rc<T>、Weak<T>及其对Drop trait的实现中。通过在Node的定义中指定子节点到父节点的关系应为Weak<T>引用,你可以让父节点指向子节点,反之亦然,且不会产生引用循环和内存泄漏。
15.7 总结
本章介绍了如何使用智能指针来实现与Rust默认通过常规引用来提供的不同保证和权衡。Box<T>类型具有已知大小,指向堆上分配的数据。Rc<T>类型会跟踪堆上数据的引用数量,因此数据可以有多个所有者。具有内部可变性的RefCell<T>类型为我们提供了一种类型,当我们需要一个不可变类型但又需要更改该类型的内部值时,可以使用它;它还在运行时而不是编译时强制执行借用规则。
还讨论了Deref和Drop trait,它们为智能指针提供了诸多功能。我们探讨了可能导致内存泄漏的引用循环,以及如何使用Weak<T>来防止这种情况。
如果本章引起了你的兴趣,并且你想实现自己的智能指针,可以查阅*[The Rustonomicon](Introduction - The Rustonomicon)*以获取更多有用的信息。
接下来,我们将讨论Rust中的并发。你甚至会了解到一些新的智能指针。
Chapter 16:无畏并发(Concurrency)
安全高效地处理并发编程是 Rust 的另一个主要目标。并发编程(Concurrent programming)指程序的不同部分独立执行,并行编程(parallel programming)指程序的不同部分同时执行,随着越来越多的计算机利用其多处理器,这两者正变得日益重要。从历史上看,在这些场景下编程一直是困难且容易出错的。Rust 希望改变这种状况。
起初,Rust 团队认为,确保内存安全和防止并发问题是两个需要用不同方法解决的独立挑战。随着时间的推移,团队发现所有权和类型系统是一套强大的工具,有助于管理内存安全和并发问题!通过利用所有权和类型检查,许多并发错误在 Rust 中是编译时错误,而非运行时错误。因此,错误的代码不会让你花费大量时间去重现运行时并发 bug 出现的确切环境,而是会拒绝编译,并给出解释问题的错误提示。这样一来,你可以在编写代码时就修复问题,而不是可能在代码投入生产环境之后才去修复。我们将 Rust 的这一特性称为无畏并发(Fearless Concurrency)。无畏并发使你能够编写没有细微错误的代码,并且易于重构,不会引入新的错误。
[!NOTE]
注意:为简洁起见,我们将许多问题称为并发的,而非更精确地表述为并发和/或并行的。在本章中,每当我们使用并发的这一表述时,请在心中替换为并发和/或并行的。在下一章中,由于这一区分更为重要,我们会表述得更加具体。
16.1 使用线程同时运行多个代码
在大多数当前的操作系统中,已执行程序的代码在进程(process)中运行,操作系统会同时管理多个进程。在一个程序内部,也可以有同时运行的独立部分。运行这些独立部分的功能称为线程(thread)。例如,Web服务器可以有多个线程,这样它就能同时响应多个请求。
将程序中的计算拆分为多个线程以同时运行多项任务可以提高性能,但这也会增加复杂性。由于线程可以同时运行,无法保证不同线程上的代码部分的执行顺序。这可能会导致诸如以下的问题:
- 竞态条件,即线程以不一致的顺序访问数据或资源
- 死锁是指两个线程相互等待,导致两者都无法继续运行的情况。
- 仅在特定情况下出现且难以稳定复现和修复的漏洞
Rust 试图减轻使用线程带来的负面影响,但在多线程环境中编程仍需谨慎思考,并且需要与单线程程序不同的代码结构。
编程语言以几种不同的方式实现线程,许多操作系统提供了一种API,供编程语言调用以创建新线程。Rust标准库采用1:1的线程实现模型,即程序中每个语言线程对应一个操作系统线程。有一些 crate 实现了其他线程模型,与1:1模型相比各有不同的取舍。
16.1.1 使用spawn来创建一个新的线程
我们首先需要给文件添加上std::thread,来引入标准库线程相关的crate;之后使用thread::spawn,为其传入一个闭包,闭包中就是我们想要新线程运行的代码。就比如下面这个例子:
|
|
程序输出如下:
**可以看到,我们创建的线程并没有运行完,而是由于主线程结束之后所有的线程都会被强制结束!**每一次的输出可能都不尽相同。
thread::sleep的调用会迫使线程暂停执行一小段时间,让其他线程得以运行。这些线程可能会交替执行,但这并不能保证:这取决于操作系统对线程的调度方式。在本次运行中,主线程先打印了内容,尽管新生成线程的打印语句在代码中出现得更早。而且,虽然我们让新生成的线程打印到i为9,但它只打印到4,主线程就终止了。
如果你运行这段代码,却只看到主线程的输出,或者没有看到任何重叠部分,可以尝试增大范围中的数值,为操作系统创造更多在线程之间切换的机会。
16.1.2 使用JoinHandle来等待所有线程结束
上面的示例,只要主线程结束就会让所有的线程全部结束,而且由于无法保证线程的运行顺序,我们也不能确保生成的线程一定会运行。
我们可以通过将thread::spawn的返回值保存到变量中,来解决生成的线程不运行或过早结束的问题。thread::spawn的返回类型是JoinHandle<T>。JoinHandle<T>是一个拥有所有权的值,当我们在它上面调用join方法时,会等待其对应的线程完成。下面展示了如何使用我们创建的线程的JoinHandle<T>,以及如何调用join以确保生成的线程在main退出之前完成:
|
|
在句柄上调用join会阻塞当前运行的线程,直到该句柄所代表的线程终止。阻塞线程意味着该线程无法执行工作或退出。由于我们将对join的调用放在了主线程的for循环之后,会产生类似如下的输出:
这两个线程继续交替运行,但主线程因调用handle.join()而等待,直到派生线程完成后才会结束。但让我们看看,如果我们将handle.join()移到main中的for循环之前,会发生什么情况:
|
|
现在主线程会等待子线程全部完成之后,才会运行自己的for循环,所以输出是这样的:
所以说,调用join的位置,会影响你的多线程是否可以同时运行!
16.1.3 在线程中使用move闭包
我们经常会将move关键字与传递给thread::spawn的闭包一起使用,因为这样闭包就会从环境中获取它所使用的值的所有权,从而将这些值的所有权从一个线程转移到另一个线程。在第13章中,我们讨论了闭包上下文中的move。现在,我们将更专注于move和thread::spawn之间的交互。
比如下面这个例子,我们传递给thread::spawn的闭包不接受任何参数:我们在衍生线程的代码中没有使用主线程的任何数据。要在衍生线程中使用主线程的数据,衍生线程的闭包必须捕获它所需的值。例子中展示了一种尝试,即在主线程中创建一个向量并在衍生线程中使用它。不过,你马上就会发现,这目前还无法正常工作:
|
|
这个闭包使用了v,所以它会捕获v并将其作为闭包环境的一部分。由于thread::spawn会在新线程中运行这个闭包,我们应该能够在那个新线程中访问v。但是当我们编译这个示例时,会得到以下错误:
Rust会推断如何捕获v,并且由于println!只需要v的一个引用,该闭包会尝试借用v。然而,这里存在一个问题:Rust无法判断衍生线程会运行多久,因此它不知道对v的引用是否始终有效。下面这个就是一种可能会导致v不合法的一种情况:
|
|
如果Rust允许我们运行这段代码,那么有可能生成的线程会立即被置于后台,根本不运行。生成的线程内部有一个对v的引用,但主线程会立即使用我们在第15章讨论过的drop函数丢弃v。这样一来,当生成的线程开始执行时,v就不再有效了,所以对它的引用也无效。
为了修复这个问题,我们可以使用move关键字,放置于闭包的前面;这样,我们会让闭包强行将捕获的值的所有权占为己有,而不是让编译器去推断:
|
|
现在,这段代码就可以正常编译并且运行了。
但是对于我们在主线程中,主动调用drop来释放掉v的情况呢?如下:
|
|
运行这段代码,编译器会报错如下:
编译器告诉我们,Vec<i32>由于没有实现Copy trait,所以move不能创建一个新的副本,而是直接转交所有权,导致我们不能在主线程中使用一个已经被移动到子线程中的向量!
那如果我们将这里的Vec<i32>换成一个实现了Copy trait的数据类型,比如i32:
|
|
代码编译结果如下:
显然这是可行的!说明对于move,实现了Copy trait的数据类型是会复制一个副本到子线程,而不是转接所有权!
既然我们已经介绍了线程是什么以及threadAPI提供的方法,接下来让我们看看一些可以使用线程的场景。
16.2 使用消息传递(Message Passing)在线程之间传递数据
“不要通过共享内存来传递让不同线程交流,而是使用交流来共享数据”;为了确保并发的安全性,我们使用消息传递——通过线程之间互相发送包含数据的消息来让不同线程之间交流。
Rust的标准库提供了一套完整的消息传递机制,通过channel。channel是编程概念中的通用概念,来表示数据通过哪里来在两个线程之间传递数据。
在消息传递中有两个部分,一个部分是发送方(transmitter),对应的就是接收方(receiver)。顾名思义,发送方就是数据来源地,接收方就是数据接收地。
在这里,我们将编写一个程序,其中一个线程生成值并通过通道发送这些值,另一个线程接收这些值并将其打印出来。我们会使用通道在线程之间发送简单的值,以此来演示这一特性。一旦你熟悉了这种技术,就可以将通道用于任何需要相互通信的线程,比如聊天系统,或者在一个系统中,多个线程执行计算的不同部分,并将这些部分发送给一个线程,由该线程汇总结果。
如下列的代码,我们会创建一个channel,但是不做其他的任何事情。当前的代码还不能编译,因为Rust并不知道我们想要发送的信息的数据类型:
Filename: src/main.rs
|
|
我们使用mpsc::channel函数创建一个新通道;mpsc代表多生产者、单消费者(multiple producer single consumer)。简而言之,Rust标准库实现通道的方式意味着一个通道可以有多个用于生成值的发送端,但只有一个用于消费这些值的接收端。想象一下,多条溪流汇聚成一条大河:任何一条溪流中流下的所有东西最终都会汇入同一条河。现在我们先从单个生产者开始,但当这个示例能正常运行时,我们会添加多个生产者。
mpsc::channel 函数会返回一个元组,其中第一个元素是发送端——发送者,第二个元素是接收端——接收者。在许多领域中,缩写 tx 和 rx 传统上分别用于表示 发送器 和 接收器,因此我们也用这样的名称来命名变量,以表明各自的端。我们使用带有解构元组模式的 let 语句;我们将在第 19 章讨论 let 语句中模式的使用和解构。目前,只需知道以这种方式使用 let 语句是提取 mpsc::channel 返回的元组各部分的便捷方法。
让我们把发送端移到一个生成的线程中,并让它发送一个字符串,这样生成的线程就能与主线程进行通信:
|
|
我们先使用thread::spawn创建了一个线程,然后使用move将tx的所有权移动到当前的子线程中,再在子线程中调用send将数据发送出去,由于send的返回值是Result<T,E>;所以如果接收方已经被释放,或者没有接收方的时候,就会出现错误。在这里,我们只是简单的使用unwrap来在错误的时候让程序panic。
接下来,我们让主线程就作为接收端,接收到消息然后打印出来:
|
|
对于接收方,有两种方式接收消息,一种是我们这里的recv()(receive的简写),该方法是阻塞式的,会一直等待接收到了数据才会往后执行,当发送方发送一个值,它会返回一个Result<T,E>的数据类型,当发送方结束了线程还没接收到数据,他就会返回一个错误。
另外一种就是try_recv()不会阻塞,而是会立刻返回一个Result<T,E>:如果此时有消息则会返回Ok ,OK会持有消息的值,如果此时没有任何消息则会返回一个Err的值。这种非阻塞的接收很适用于一个线程不仅需要接受数据还需要有其他的功能;我们可以编写一个循环,每隔一段时间调用一次try_recv,如果有消息的话就进行处理,否则就先做一会儿其他工作,之后再重新检查。
在这个示例中,我们使用recv是为了简洁起见;主线程除了等待消息外没有其他工作要做,因此阻塞主线程是合适的。
运行这个代码就可以看见: