持续更新…

作为一名理科生,这实在是太酷了,很符合我对未来编程语言的想象,科技并带有趣味。

参考资料:

一、安装和配置

rust安装(linux)

1
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

cargo创建项目

1
2
3
cargo new hello_world
cd helloworld
cargo run

cargo创建的项目分为:

  • bin项目:可直接运行的二进制项目
  • lib项目:库类型项目,不可以直接运行,而是作为第三方库被引用

当我们使用cargo new project时,默认创建的项目为bin项目。

helloworld

约定俗成地,需要写一个helloworld:

1
2
3
fn main() {
println!("Hello, world!");
}

入口

以cargo创建的bin项目入口为src/main.rs内的main函数。

1
2
3
fn main() {
println!("Hello, world!");
}

在项目根目录使用cargo run即可运行项目。

二、rust基础篇

1 常量

使用const声明常量,通常情况下,常量命名是全大写字母,可以用下划线_分隔单词。

1
2
3
4
5
6
const PI:f64 = 3.1415926;
fn main() {
let x = 5;
println!("{}", x);
println!("{}", PI);
}
  • 常量可以声明在任何作用域,且在声明的作用域内一直有效
  • 声明常量需要指定类型
  • 常量不可以使用mut关键字
  • 常量只可以绑定到常量表达式,而不能绑定到函数的调用结果或者是运行时才计算出的值上

2 变量

2.1 声明变量

与ts类似,我们使用let声明变量。

1
let a = 5;

rust是强类型语言,由于编译器带类型推断功能,我们可以不显式指定类型,但有些情况需要显式指定:

1
let b: i32 = 5;

在rust中,还可以直接在数值后直接标注类型,或使用下划线_隔开:

1
2
let c = 5i32;
let d = 15_u32;

2.2 可变与不可变

在默认情况下,rust的变量是不可变的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let a = 5;
a = 6;
// cargo run,你将会得到如下错误
/*
error[E0384]: cannot assign twice to immutable variable `a`
--> src/main.rs:3:5
|
2 | let a = 5;
| -
| |
| first assignment to `a`
| help: consider making this binding mutable: `mut a`
3 | a = 6;
| ^^^^^ cannot assign twice to immutable variable
*/

提示你无法对不可变的变量进行两次绑定。

如需可变的变量,则需要用mut进行显式指定:

1
2
let mut a = 5;
a = 6;

2.3 所有权与变量绑定

上文提到了变量绑定。rust是一个无GC的语言,且又不像C/C++一样不关心内存安全,它采用“所有权系统“来保证内存安全。

简单来说,当我们声明一个变量并为它“赋值”的时候

1
let a = String::from("hello world");

称这个值被变量a所拥有。在rust中,一个值只能同时被一个变量拥有。因此,当把一个变量“赋值”给另一变量时,就会发生所有权转移,以维护该规则

1
2
3
4
let a = String::from("hello world");
let b = a;
println!("{}",b);
println!("{}",a); // 在此处会报错,因为b拥有hello world的引用

所以,使用绑定而不是赋值可以更好地描述上述过程。

但是,对于简单的数据类型,比如数字类型:

1
2
3
4
5
6
fn main() {
let a = 5;
let b = a;
println!("a is {}", a);
println!("b is {}", b);
}

ab都能被正确打印,这是由于在第三行let b = a时,b的值是直接通过内存拷贝的方式实现的,即ab都被分配了内存(二者都可用),就自然没有所有权转移一说。由于数字类型直接保存在栈中,无需在堆上分配内存,这种拷贝速度非常快。但是对于上面的字符串String,不是基本类型,它保存在堆中,对于这种复杂类型的数据不能自动拷贝,这种情况才会进行所有权的转移。

实际上,rust中的自动拷贝也叫做浅拷贝,它只发生在栈上。后面介绍特征的时候,会提到浅拷贝实际上是由于类型实现了Copy特征;相对应的,深拷贝不会被自动调用,当我们需要深拷贝一个在堆上的数据,比如String,需要使用Clone

1
2
3
4
let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2);

2.4 shadowing

shadowing翻译为变量隐藏(变量遮蔽)。在rust中允许使用同名的变量声明

1
2
3
4
5
fn main() {
let x = 5;
let x = x + 1;
println!("{}", x);
}

使用let新声明的同名变量会将之前声明的变量遮蔽。与mut不同的是,这两个x都进行了内存的分配,而mut则没有第二次的内存分配。

变量的遮蔽与作用域有关,并且声明的同名新变量类型可以与之前不同,比如

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let x = 5;
{
let x = String::from("hello");
println!("{}", x);
}
println!("{}", x);
}
// 输出
// hello
// 5

这个特性很好地解决了起名困难这个千古难题。

2.5 引用

rust中的引用,可以避免所有权频繁发生转移所带来的代码复杂性。与C语言的语法很接近,创建引用就是绑定变量的内存地址:

1
2
3
4
let x = 5;
// 创建一个引用 y,使用解引用运算符来获取 y 所使用的值,&就是取地址并绑定,*就是解引用
let y = &x;
println!("{}",*y)

在rust中,引用可以理解为:可以获取到值,但并不获取所有权。因此,当引用离开作用域之后,被引用的值不会被回收。

在默认情况下,引用是不可变的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn main() {
let x = 5;
let y = &x;
// 修改引用的值
*y = 6;
}

/* 你将得到如下错误:
error[E0594]: cannot assign to `*y`, which is behind a `&` reference
--> src/main.rs:4:5
|
3 | let y = &x;
| -- help: consider changing this to be a mutable reference: `&mut x`
4 | *y = 6;
| ^^^^^^ `y` is a `&` reference, so the data it refers to cannot be written
*/

要想解决这个问题,需要使用可变引用(报错信息中,编译器也会给予提示):

1
2
3
4
5
6
7
8
fn main() {
// 将x改为可变变量
let mut x = 5;
// 创建可变引用 使用&mut
let y = &mut x;
// 修改引用的值,此时不会报错
*y = 6;
}

注意,变量的可变引用在同一作用域内最多只能有一个,这是为了使rust在编译期就避免数据竞争,数据竞争可由以下行为造成:

  • 两个或更多的指针同时访问同一数据
  • 至少有一个指针被用来写入数据
  • 没有同步数据访问的机制

通过在编译期就检查这种错误,我们可以将潜在的运行时难以追踪,并且难以诊断和修复的问题扼杀在摇篮里:rust根本不会编译存在数据竞争的代码:

1
2
3
4
5
6
7
fn main() {
let mut x = 5;
let y = &mut x;
let z = &mut x;
// 同一个变量的可变引用在同一作用域内最多只能有一个,无法通过编译
println!("{}, {}", y, z);
}

同理,可变引用与不可变引用不能同时存在,这也是为了避免使用不可变引用的数据莫名其妙地被修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn main() {
let mut x = 5;
let y = &x;
let z = &mut x;
// 修改z,会污染y
*z = 6;
println!("{}", y);
}
/* 错误如下:
|
3 | let y = &x;
| -- immutable borrow occurs here
4 | let z = &mut x;
| ^^^^^^ mutable borrow occurs here
...
7 | println!("{}", y);
| - immutable borrow later used here
*/

总结一下借用规则:

  • 在任意给定时刻,只能拥有一个可变引用或任意数量的不可变引用之一(而不是两者)
  • 引用必须总是有效的

在后续的章节中,会频繁提到引用的概念。

3 数据类型

3.1 基本类型

数值类型

整型

整数是一个没有小数部分的数字。在rust中分为有符号整型和无符号整型两类,其中有符号整型使用i开头,无符号整型使用u开头,具体分类如下表:

长度有符号无符号
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
128-biti128u128
arch(与架构相关)isizeusize

同大多属于语言一样,有符号整数采用补码方式存储,每一个有符号类型可以储存包含从在内的数字,这里n是指所使用的位数。比如i8可以储存从在内的数字,也就-128~127;无符号数可以储存包含从在内的数字,比如 u8 可以储存从的数字,也就是0~255

isizeusize 类型依赖运行程序的计算机架构:64 位架构上它们是 64 位的,32 位架构上它们是 32 位的。

可以使用下表的形式编写数字字面值:

数字字面值例子
Decimal (十进制)98_222
Hex (十六进制)0xff
Octal (八进制)0o77
Binary (二进制)0b1111_0000
Byte (单字节字符)(仅限于u8)b'A'

如果不确定该使用哪种类型的数字,可以使用rust的默认类型。数字类型默认是i32isizeusize 主要作为某些集合的索引。

下划线_ 仅做为分隔符方便观察,比如字面量1_000,它的值与 1000 相同。

另外,假设当前有一个u8,可以存放0~255的值,如果将其修改为 256,那么会发生整型溢出(integer overflow)。这可能会导致以下后果:

  • 当在debug模式编译时,rust检查这类问题并使程序panic(运行时发生错误而退出)
  • 当在release模式中编译时,rust不会检查这类整型溢出,而是会进行一种二进制补码循环溢出(two’s complement wrapping)的操作,大于该类型最大值的数值会回绕到最小值,值 256 变成 0,值 257 变成 1,依此类推。程序不会panic,不过变量可能也不会是你所期望的值。依赖补码循环溢出的行为被认为是一种错误。

使用cargo build时,默认构建方式为debug模式;当项目最终准备好发布时,可以使用 cargo build --release 来优化编译项目。这会在target/release而不是target/debug下生成可执行文件。这些优化可以让最终的可执行代码运行得更快,不过启用这些优化也需要消耗更长的编译时间。

这也就是为什么会有两种不同的配置:一种是为了开发,你需要经常快速重新构建;另一种是为用户构建最终程序,它们不会经常重新构建,并且希望程序运行得越快越好。如果为了测试代码的运行时间,请确保运行 cargo build --release 并使用target/release下的可执行文件进行测试。

为了显式地处理溢出的可能性,可以使用这几类标准库提供的原始数字类型方法:

  • 所有模式下都可以使用 wrapping_* 方法进行循环溢出,如 wrapping_add
  • 如果 checked_* 方法出现溢出,则返回 None
  • overflowing_* 方法返回值和一个布尔值,表示是否出现溢出
  • saturating_* 方法在值的最小值或最大值处进行饱和处理

比如使用wrapping_* 方法:

1
2
3
4
5
fn main() {
let a : u8 = 255;
let b = a.wrapping_add(20);
println!("{}", b); // 19
}
浮点型

rust拥有两个浮点型:f32f64f32 是单精度浮点数,f64 是双精度浮点数。默认类型是 f64,这是因为在现代CPU中,它与 f32 速度几乎一样,不过精度更高。所有的浮点型都是有符号的,浮点型根据IEEE-754 标准实现。

下面的示例展示了浮点数的定义:

1
2
3
4
5
fn main() {
let x = 2.0; // f64

let y: f32 = 3.0; // f32
}
数值运算

rust中的所有数字类型都支持基本数学运算:加法、减法、乘法、除法和取余。整数除法会向下舍入到最接近的整数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn main() {
// addition
let sum = 5 + 10;

// subtraction
let difference = 95.5 - 4.3;

// multiplication
let product = 4 * 30;

// division
let quotient = 56.7 / 32.2;
let truncated = -5 / 3; // 结果为 -1

// remainder
let remainder = 43 % 5;
}

这些语句中的每个表达式使用了一个数学运算符并计算出了一个值,然后绑定给一个变量。

NaN

对于数学上未定义的结果,例如对负数取平方根 -42.1.sqrt() ,会产生一个特殊的结果,rust的浮点数类型使用 NaN (not a number)来处理这些情况。

所有跟 NaN 交互的操作,都会返回一个 NaN,而且 NaN 不能用来比较,下面的代码会崩溃:

1
2
3
4
fn main() {
let x = (-42.0_f32).sqrt();
assert_eq!(x, x);
}

出于防御性编程的考虑,可以使用 is_nan() 等方法,可以用来判断一个数值是否是 NaN

1
2
3
4
5
6
fn main() {
let x = (-42.0_f32).sqrt();
if x.is_nan() {
println!("not a number")
}
}
位运算

rust中的位运算符如下,与大多数语言是相同的:

运算符说明
&相同位置均为1时则为1,否则为0
|相同位置只要有1时则为1,否则为0
^相同位置不相同则为1,相同则为0
!把位中的0和1相互取反,即0置为1,1置为0
<<所有位向左移动指定位数,右位补0
>>所有位向右移动指定位数,带符号移动(正数补0,负数补1)

另外,这些运算符加上=都可以用来赋值(取反!除外,与!=冲突),比如:

1
2
3
4
5
fn main() {
let mut a : i32 = 15;
a <<= 3;
println!("{a}");
}

布尔型

正如其他大部分编程语言一样,rust中的布尔类型有两个值:truefalse,布尔类型使用 bool 表示。

1
2
3
4
5
fn main() {
let t = true;

let f: bool = false; // with explicit type annotation
}

字符类型

char 类型是rust中最原生的字母类型。所有的 Unicode 值都可以作为字符,由于 Unicode 都是 4 个字节编码,因此字符类型也是占用 4 个字节。

下面是一些例子:

1
2
3
4
5
6
fn main() {
let c = 'z';
let z = 'ℤ';
let g = '国';
let heart_eyed_cat = '😻';
}

我们用单引号声明 char 字面量,而与之相反的是,使用双引号声明字符串字面量。中文、日文、韩文等字符,emoji、以及零长度的空白字符都是有效的 char 值。Unicode标量值包含从 U+0000U+D7FFU+E000U+10FFFF 在内的值。

单元类型

单元类型就是 (),它的类型和值都是()。比如fn main() 入口函数的返回值就是()println!() 的返回值也是单元类型 ()

再比如,你可以用 () 作为 map 的值,表示我们不关注具体的值,只关注 key。 这种用法和 Go 语言的struct{}类似,可以作为一个值用来占位,但是完全不占用任何内存。

3.2 复合类型

元组类型

元组(tuple)可以将多个类型组合到一起,且一旦创建,长度固定不可再变化,元组中元素的顺序也是固定的。

创建元组使用(),用逗号,分隔开各个元素,元素的数据类型可以不相同:

1
2
3
4
fn main() {
let tup: (i32, i16, u8) = (500, 18, 1);
println!("{},{},{}", tup.0, tup.1, tup.2);
}

获取元组中的值可以按位置.0.1,也可以通过解构:

1
2
3
4
5
fn main() {
let tup: (i32, i16, u8) = (500, 18, 1);
let (x, y, z) = tup;
println!("{},{},{}", x, y, z);
}

即:使用相同形式的x,y,z三个变量来接收元组中的值。

数组类型

rust中的数组分为两类,一种是长度固定的 array,另一种是动态长度数组 Vector。在内存分配方面,前者直接分配在栈上,效率非常高;后者则分配在堆上,具有更大的灵活性,但效率比前者要低。

对于array,它的性质与大部分语言的数组性质一样,长度固定、元素的数据类型必须相同:

1
2
3
fn main() {
let arr = [1, 2, 3, 4, 5];
}

有些时候,还需要显式指定数组的类型与长度:

1
2
3
4
fn main() {
// 声明一个元素类型为i32,长度为5的定长数组
let arr[i32;5] = [1, 2, 3, 4, 5];
}

当然,对于初始化一个元素相同的数组,有一种简便写法:

1
2
3
4
fn main() {
// 声明一个长度为5的定长数组,其中的全部元素初始化为3
let arr[3;5]; // [3,3,3,3,3]
}

访问数组中的元素与其他语言无异,且越界访问数组中的元素会在运行时导致panic

1
2
3
4
5
fn main() {
let arr = [1,2,3,4,5];
println!("{}",arr[1]); // 访问数组元素使用 `[]`
println!("{}",arr[6]); // panic
}

与go语言的切片类似,rust也可以取数组中的一部分连续片段:

1
2
3
4
5
6
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
// 类型为 &[i32] 起始位置为索引1,终止位置为索引3(不包括3)
let slice: &[i32] = &a[1..3]; // 实际上引用了原始数组的 [2,3]
println!("{}", slice[1]);
}

有关动态数组的讲解放到后面,详见:动态数组 Vector

结构体

(1)结构体定义与初始化

结构体通过struct定义,与C/C++非常类似:

1
2
3
4
5
6
7
8
struct MysqlConnection {
ip: String,
port: u32,
name: String,
db: String,
charset: String,
active: bool,
}

根据结构体定义,可以创建实例,创建实例时我们需要为每个字段都初始化:

1
2
3
4
5
6
7
8
9
10
11
12
fn main() {
// 初始化的顺序可以与定义不一致,但所有字段都必须初始化
let connect = MysqlConnection{
ip: String::from("127.0.0.1"),
port: 3306,
db: String::from("testdb"),
charset: String::from("utf8"),
name: String::from("root"),
password: String::from("testpassword"),
active: true,
};
}

通过.操作符访问结构体,也可以直接修改这些值,如果要修改结构体,需要用mut,rust目前不支持为结构体的每个字段设置mut

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn main() {
// 使用mut
let mut connect = MysqlConnection{
ip: String::from("127.0.0.1"),
port: 3306,
db: String::from("testdb"),
charset: String::from("utf8"),
name: String::from("root"),
password: String::from("testpassword"),
active: true,
};

connect.name = String::from("hello"); // 修改结构体中的字段

}

我们还可以从一个结构体中解构一些字段创建另一个结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn main() {
let mut connect1 = MysqlConnection{
ip: String::from("127.0.0.1"),
port: 3306,
db: String::from("testdb"),
charset: String::from("utf8"),
name: String::from("root"),
password: String::from("testpassword"),
active: true,
};
let mut connect2 = MysqlConnection{
port: 3307,
active: false,
..connect1 // 其它未声明的字段从connect1获取,未实现Copy的类型会进行所有权转移
};
}

如果你了解typescript,这种..语法应该不会感到陌生,但是在rust中,..必须在结构体的结尾使用,并且未实现Copy特征的类型会进行所有权转移。

(2)使用字段初始化简写语法

看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}

fn build_user(email: String, username: String) -> User {
User {
active: true,
username: username,
email: email,
sign_in_count: 1,
}
}

这里我们声明了一个结构体,并创建了一个 build_user 函数,它返回一个带有给定的 emailUser 的结构体实例。active 字段的值为 true,并且 sign_in_count 的值为 1

为函数参数起与结构体字段相同的名字是可以理解的,但是不得不重复 emailusername 字段名称与变量有些啰嗦。如果结构体有更多字段,重复每个名称就更加烦人了。对于参数名与字段名都完全相同的结构体,我们可以使用字段初始化简写语法(field init shorthand)来重写 build_user

1
2
3
4
5
6
7
8
fn build_user(email: String, username: String) -> User {
User {
active: true,
username,
email,
sign_in_count: 1,
}
}

这里我们创建了一个新的 User 结构体实例,它有一个叫做 email 的字段。我们想要将 email 字段的值设置为 build_user 函数 email 参数的值。因为 email 字段与 email 参数有着相同的名称,则只需编写 email 而不是 email: email

(3)元组结构体

也可以定义与元组类似的结构体,称为元组结构体(tuple structs)。元组结构体有着结构体名称提供的含义,但没有具体的字段名,只有字段的类型。当你想给整个元组取一个名字,并使元组成为与其他元组不同的类型时,元组结构体是很有用的,这时像常规结构体那样为每个字段命名就显得多余和形式化了。

要定义元组结构体,以 struct 关键字和结构体名开头并后跟元组中的类型。例如,下面是两个分别叫做 ColorPoint 元组结构体的定义和用法:

1
2
3
4
5
6
7
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

fn main() {
let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);
}

注意 blackorigin 值的类型不同,因为它们是不同的元组结构体的实例。你定义的每一个结构体有其自己的类型,即使结构体中的字段可能有着相同的类型。例如,一个获取 Color 类型参数的函数不能接受 Point 作为参数,即便这两个类型都由三个 i32 值组成。在其他方面,元组结构体实例类似于元组,你可以将它们解构为单独的部分,也可以使用 . 后跟索引来访问单独的值,等等。

(4)没有任何字段的类单元结构体

我们也可以定义一个没有任何字段的结构体。它们被称为类单元结构体(unit-like structs)因为它们类似于单元类型()。类单元结构体常常在你想要在某个类型上实现特征但不需要在类型中存储数据的时候发挥作用:

1
2
3
4
5
6
7
struct AlwaysEqual; // 类单元结构体

let subject = AlwaysEqual;

impl SomeTrait for AlwaysEqual {
//...
}

我们将在后面介绍特征,见特征Trait

(5)打印结构体

为了更方便地观察结构体,我们需要打印结构体。但直接打印会报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct Socket {
ip: String,
port: u32,
}

fn main() {
let sock = Socket{
ip: String::from("127.0.0.1"),
port: 3306,
};
println!("{}", sock);

}
/* 运行,你会收到如下错误
error[E0277]: `Socket` doesn't implement `std::fmt::Display`
--> src/main.rs:12:20
|
12 | println!("{}", sock);
| ^^^^ `Socket` cannot be formatted with the default formatter
|
= help: the trait `std::fmt::Display` is not implemented for `Socket`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0277`.
error: could not compile `practice` due to previous error
*/

编译器提示Socket没有实现 std::fmt::Display特征,特征的概念将会在后续讲到。继续往下看,编译器提示:

尝试使用{:?}来代替{},我们按照提示试一试,还是会报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fn main() {
let sock = Socket{
ip: String::from("127.0.0.1"),
port: 3306,
};
println!("{:?}", sock);
}
/* 运行,你会收到如下错误
error[E0277]: `Socket` doesn't implement `Debug`
--> src/main.rs:12:22
|
12 | println!("{:?}", sock);
| ^^^^ `Socket` cannot be formatted using `{:?}`
|
= help: the trait `Debug` is not implemented for `Socket`
= note: add `#[derive(Debug)]` to `Socket` or manually `impl Debug for Socket`
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider annotating `Socket` with `#[derive(Debug)]`
|
2 | #[derive(Debug)]
|
*/

这次的错误是:Socket没有实现Debug特征,并给出了解决方案。一个是添加 #[derive(Debug)],另外一个就是手动为Socket实现这个Debug特征。我们尝试使用前者使用 derive 派生:

1
2
3
4
5
// ...省略其它代码
println!("{:?}", sock);
/* 运行成功,结果:
Socket { ip: "127.0.0.1", port: 3306 }
*/

运行,终于将结构体打印出来了。另外,编译器刚刚还提到使用{:#?},这样会pretty-print,也就是会有更好的输出样式:

1
2
3
4
5
6
7
8
// ...省略其它代码
println!("{:#?}", sock);
/* 运行成功,结果:
Socket {
ip: "127.0.0.1",
port: 3306,
}
*/

枚举

相比C,rust的枚举更加好用了。首先是定义,看例子:

1
2
3
4
5
6
7
8
9
10
11
#[derive(Debug)]
enum Animal{
Cat,
Dog
}

fn main() {
let a = Animal::Cat;
println!("{:#?}", a);

}

使用enum关键字来定义枚举,Animal是自定义的枚举类型名,枚举类型下面可以包含多种不同的类型,比如:猫和狗都属于动物,获取枚举中成员的实例使用::操作符。

接下来,我们需要让枚举更有意义,可以给每个子类型设置一个名字:

1
2
3
4
5
6
7
8
9
10
11
12
#[derive(Debug)]
enum Animal{
Cat(String),
Dog(String)
}

fn main() {
let cat = Animal::Cat(String::from("crookshanks"));
let dog = Animal::Dog(String::from("cookie"));
println!("{:?},{:?}", cat,dog);

}

现在我们有一只名叫crookshanks的猫和一只名叫cookie的狗。当然,我们还可以在同一个枚举类型中使用不同的数据类型,并且并不止限于基本类型,任何类型的数据都可以放入枚举成员中:

1
2
3
4
5
6
7
8
9
10
11
12
#[derive(Debug)]
enum Animal{
// 成员之间的类型可以不同
Cat(String),
Dog{name:String,age:u32} // 使用匿名结构体定义
}
fn main() {
let cat = Animal::Cat(String::from("crookshanks"));
let dog = Animal::Dog{name:String::from("cookie"),age:3};
println!("{:?},{:?}", cat,dog);

}

Option枚举类是rust标准库中的枚举类,这个类用于填补rust不支持null引用的空白。像是java、C/C++、javascrpit这些语言中,都有一个关键字null,表示变量当前的值为空。null可以高效地解决少量的问题,但是带来的缺点也是致命的:在开发者把一切都当作不是null的时候给予程序致命一击,一旦对 null 进行操作,程序的运行就要彻底终止。因此我们在编程时需要格外的小心去处理这些 null 空值。

托尼·霍尔(Tony Hoare)是快速排序算法的创造者,也是图灵奖(计算机领域的诺贝尔奖)的获得者。他把 Null 添加到了 ALGOL 语言中,因为它看起来很实用而且容易实现。但几十年后,他后悔了。

Tony 表示,1965 年把 Null 引用加进 ALGOL W 时的想法非常简单,“就是因为这很容易实现。”

但如今再次谈到当初的决定时,他表示这是个价值十亿美元的大麻烦:

“ 我称之为我的十亿美元错误……当时,我正在设计第一个全面的类型系统,用于面向对象语言的引用。我的目标是确保所有对引用的使用都是绝对安全的,由编译器自动执行检查。但是我无法拒绝定义一个 Null 引用的诱惑,因为它实在太容易实现了。这导致了无法计数的错误、漏洞和系统崩溃。在过去的四十年里,这些问题可能已经造成了十亿美元的损失。”

rust在语言层面彻底不允许空值null的存在,而改为使用 Option 枚举变量来处理需要用到null的这部分问题。

在rust程序运行前,会自动引入prelude标准库的内容,包括了一些常用的类型、函数等,Option也包括在内,因此我们的rust程序可以直接使用它(也可以直接使用SomeNone,无需使用 Option::限定符)。

1
2
3
4
enum Option<T> {
Some(T), // T是泛型,后面会讲到
None,
}

好了,为什么说使用Optionnull更安全呢?个人理解是这样的,null把可能为空的问题隐藏掉而不去处理,使用Option则编译器会限制你:如果你使用Option里的值不处理空值,编译器就不会通过编译。举个例子:

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let a = 5;
let b = Some(3);
println!("{}",a+b)
}
/* 运行,你会收到如下错误
--> src/main.rs:5:20
|
5 | println!("{}",a+b)
| ^ no implementation for `{integer} + Option<{integer}>`
*/

这里我们声明了一个变量bSome(3),表示b可能为空,也可能为3(虽然这里肯定为3,但你可以把它想象成获取用户的输入)。当我们尝试相加ab,就会因为类型不同而无法相加。事实上,a这样的值在rust中一定不为空(编译器确保它总是有一个有效的值),这意味着你无需对它们做空值防护,只有Option类型你才需要去关心值是否为空。如果你想计算a+b,那就必须将Some中的值提取出来,在提取的过程中,这个值可能有效,可能为空,我们需要分别进行处理。通过这样的处理,你就可以保证a+b是安全的。

那么该如何提取Some(T)中的T呢,这里需要用到模式匹配match

1
2
3
4
5
6
7
8
9
10
11
12
13
fn main() {
let a = 5;
let b = Some(3);
let b_value = match b {
Some(something) => {
something
},
None => {
panic!("opt is nothing");
}
};
println!("{}", a+b_value);
}

我们会在后面继续介绍match相关内容(见模式匹配)。但现在,需要首先关注一下Option::None,如果使用 None,即你定义你的变量刚开始是空值,那你需要体谅一下编译器,它怎么知道值不为空的时候变量是什么类型的呢?所以需要需要手动指定:

1
let v : Option<i32> = None;

3.3 如何获取一个值的类型

rust目前还没有类似于typeof或者type这样判断变量类型的关键字。

参考@Boiethios 的回答,你可以使用 std::any::type_name,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn print_type_of<T>(_: T) {
println!("{}", std::any::type_name::<T>())
}

fn main() {
let s = "Hello";
let i = 42;

print_type_of(s); // &str
print_type_of(i); // i32
print_type_of(main); // yourprojectname::main
print_type_of(print_type_of::<i32>); // yourprojectname::print_type_of<i32>
print_type_of(|| "Hi!" ); // yourprojectname::main::{{closure}}
}

4 函数、语句与表达式

4.1 函数

函数的定义如下:

1
2
3
4
fn function(a:i32,b:i32) -> f64 {
// body
...
}

就这么简单。

与C或python不同的是,你无需关心函数定义在哪个位置(在前还是在后),rust中的函数都可以调用到。

4.2 语句与表达式

下面介绍语句与表达式。一般语句指的是以分号结尾的一些操作,在函数中,语句并不会返回值,比如:

1
2
let a = 3;
let a = a+1;

而表达式则是指没有分号的,并且会在求值后返回一个值,比如:

1
2
3
let x = 5; // 语句
x // 表达式
x+1 // 表达式

在函数中,函数体包括一些语句+最后一行的零个或一个表达式。语句与表达式在写法上就差一个分号,表达式不能包含分号,因此,在函数结尾需要返回值的时候不能带分号,否则它就会变成一条语句,不会返回值。最后,如果不返回任何值,则会隐式地返回一个 ()

当然,你也可以在函数体中使用return来返回值(return带分号和不带分号都可以):

1
2
3
4
fn foo() -> i32 {
let a = 15i32;
return a; // 不带分号,写成 return a 也可以,结果是相同的
}

4.3 发散函数!

发散函数(diverging functions),返回值类型为特殊的!,表示该函数永不返回,一般用于导致程序崩溃的函数:

1
2
3
4
5
fn forever() -> ! {
loop {
//...
};
}

4.4 函数与所有权

当看到这个标题,相信你已经明白了,函数的参数传递也会进行所有权的转移。当然,实现了Copy特征的基本类型是通过拷贝进行的,所以没有所有权的转移,下面的代码不会报错:

1
2
3
4
5
6
7
8
9
10
fn main() {
let a = 5;
let b = add_one(a);
println!("{}", b);
println!("{}", a);
}

fn add_one(value:i32)->i32{
value + 1
}

如果是复合类型,没有实现Copy特征,情况就不同了:

1
2
3
4
5
6
7
8
9
fn main() {
let a = String::from("hello");
say_word(a);
println!("{}", a);
}

fn say_word(value:String){
println!("{}", value);
}

上面的代码会报错,说明在传递参数的时候,进行了所有权转移,a不再拥有值hello,而是转移到了value,在函数执行完毕后,函数的{}作用域结束,value就被drop掉了,因此第四行再次打印a就会报错。

解决方法也很简单,我们传递参数时使用引用即可:

1
2
3
4
5
6
7
8
9
fn main() {
let a = String::from("hello");
say_word(&a);
println!("{}", a);
}

fn say_word(value:&String){
println!("{}", value);
}

因为并不拥有这个值,当引用离开作用域后,其指向的值也不会被丢弃。这样,程序就可以正常运行了。

5 流程控制

5.1 条件控制

通过ifelse

1
2
3
4
5
6
7
8
fn main() {
let number:i32=5;
if number == 5 {
println!("number is 5");
}else{
println!("number is not 5");
}
}

5.2 循环控制

rust存在三种循环。

for

首先是for

1
2
3
4
5
fn main() {
for i in 0..=10{
println!("number is {}",i);
}
}

其中,0..=10的含义是生成从0到10的连续序列,即[0,10]这样的闭区间,如果不加=0..10,则是左闭右开的区间[0,10)

对于for来说,有一点需要注意,那就是所有权的问题。

首先,对于实现了Copy特征的数组来说,使用for i in 数组并不会将所有权转移,而是进行了内存拷贝,比如:

1
2
3
4
5
6
7
fn main() {
let arr = [1,2,3,4,5];
for i in arr{
println!("number is {}",i);
}
println!("{}",arr[0]) // arr[0]仍然可用
}

但是对于复杂的类型,则会发生所有权转移:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
fn main() {
let strings = [String::from("123"), String::from("456"),];
for str in strings { // 这里发生了所有权转移
println!("{}", str);
}
println!("{}",strings[0]); // 这里会报错,所有权已经转移
}
// 你将收到以下错误
/*
|
2 | let strings = [String::from("123"), String::from("456"),];
| ------- move occurs because `strings` has type `[String; 2]`, which does not implement the `Copy` trait
3 | // move
4 | for str in strings {
| ------- `strings` moved due to this implicit call to `.into_iter()`
...
7 | println!("{}",strings[0]);
| ^^^^^^^^^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider iterating over a slice of the `[String; 2]`'s content to avoid moving into the `for` loop
|
4 | for str in &strings {
|
*/

解决方法就是使用引用:

1
2
3
4
5
6
7
8
fn main() {
let strings = [String::from("123"), String::from("456"),];

for str in &strings {
println!("{}", str);
}
println!("{}",strings[0]);
}

引用默认是不可变的,因此你无法修改元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn main() {
let strings = [String::from("123"), String::from("456"),];

for str in &strings {
*str = String::from("hello");
println!("{}", str);
}
println!("{}",strings[0]);
}
// 你将收到以下错误
/*
|
4 | for str in &strings {
| -------- this iterator yields `&` references
5 | *str = String::from("hello");
| ^^^^ `str` is a `&` reference, so the data it refers to cannot be written
*/

需要使用mut 来解决:

1
2
3
4
5
6
7
8
9
fn main() {
let mut strings = [String::from("123"), String::from("456"),];

for str in &mut strings {
*str = String::from("hello");
println!("{}", str);
}
println!("{}",strings[0]);
}

如果你使用过python,那么一定很熟悉enumerate获取可迭代对象的索引,在rust中是这样写的:

1
2
3
4
5
6
7
fn main() {
let a = [6,3,2,4];

for (index, value) in a.iter().enumerate() {
println!("index:{},number:{}", index, value);
}
}

iter()方法会将a转化为迭代器,再使用enumerate()即可在for循环中获取索引。

while

第二种是while,即条件循环,满足某个条件就进行循环,直到不满足条件为止:

1
2
3
4
5
6
7
8
9
fn main() {
let mut i = 0;

while i <= 5 {
println!("{}", i);
// do something
i = i + 1;
}
}

loop

第三种是loop,即无条件循环:

1
2
3
4
5
fn main() {
loop{
println!("hello world");
}
}

这个代码会无限循环下去。因此,对于循环,我们还需要一些可以约束循环的关键字:continuebreak

1
2
3
4
5
6
7
8
9
10
11
12
13
fn main() {
let mut i = 0;
loop{
if i == 3{
i += 1;
continue;
}else if i == 5{
break;
}
println!("{}", i);
i += 1;
}
}

若操作返回一个值,则可能需要将其传递给代码的其余部分:将该值放在 break 之后,它就会被 loop 表达式返回。

1
2
3
4
5
6
7
8
9
10
11
12
fn main() {
let mut counter = 0;

let result = loop {
counter += 1;

if counter == 10 {
break counter * 2; // loop中使用`break 表达式`返回值
}
};
}

5.3 循环标签

一个循环表达式可以选择设置一个标签。这类标签被标记为循环表达式之前的生命周期(标签),如:

1
2
3
'foo: loop { break 'foo; }
'bar: while false {}
'humbug: for _ in 0..0 {}

如果循环存在标签,则嵌套在该循环中的带此标签的 break表达式和 continue表达式可以退出此标签标记的循环层或将控制流返回至此标签标记的循环层的头部。

比如:

1
2
3
4
5
6
7
fn main() {
'outer: loop {
while true {
break 'outer;
}
}
}

同理可以用于continue

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let mut counter = 0;
'outer: loop {
counter += 1;
while counter < 3 {
continue 'outer;
}
break;
}
assert_eq!(counter, 3);
}

6 模式匹配

6.1 match

枚举一节的最后,我们提到了取Some(T)的方法,这里用到的就是模式匹配。首先通过一个例子来介绍match

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum Animal{
Cat,
Dog,
Bird,
Mouse
}

fn main() {
let cat = Animal::Cat;
match cat {
Animal::Cat => println!("a cat!"),
Animal::Dog | Animal::Bird => println!("a dog or a bird!"),
_ => println!("default"),
}
}

首先创建了一个枚举类型和一个枚举成员的实例,接下来对这个实例cat进行模式匹配,使用match去匹配它对应的类型。下面是match的一些特性:

  • match内部我们需要将所有的可能都列出来。如果你不列出来,编译器会报错:

  • =>的左边,是我们的匹配条件,也叫做模式,右边是匹配成功后执行的代码,也叫做针对该模式进行处理的代码。

  • 使用|表示逻辑或,也就是说只要有一个匹配上,就算匹配成功。

  • 最后的_代表没有匹配成功的默认匹配,和C/C++/typescript中的switch语句内的default很像,作为兜底选项存在。

  • 还需要注意一点就是=>右边可以也可以有多行代码,需要用{}包裹,但无论是单行代码还是多行代码,最后一行一定是一个表达式。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    match something {
    case1 => do_something, // 表达式
    case2 => { // 可以是多行代码
    balabala; // 语句
    balabala; // 语句
    expression // 表达式
    },
    _ => println!("...")
    }
  • match本身也是一个表达式,因此你可以这样写,将match匹配到的值绑定到a上:

    1
    2
    3
    let a = match {
    ...
    }
  • 模式匹配从上到下按顺序执行,如果模式匹配了这个值,那么模式之后的代码将被执行。如果模式并不匹配这个值,将继续执行下一个分支。

模式匹配还有一个比较好用的功能,就是获取模式中绑定的值:

1
2
3
4
5
6
7
8
9
10
11
12
enum Animal{
Cat(String),
Dog(String)
}

fn main() {
let cat = Animal::Cat(String::from("crookshanks"));
match cat {
Animal::Cat(name) => println!("cat, name: {}", name),
Animal::Dog(name) => println!("dog, name: {}", name),
}
}

这里的name就是绑定到枚举成员上的值。

另外,match同样涉及到所有权转移,还是上面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#[derive(Debug)]
enum Animal{
Cat(String),
Dog(String)
}

fn main() {
let cat = Animal::Cat(String::from("crookshanks"));
match cat {
Animal::Cat(name) => println!("cat, name: {}", name), // 这里会转移所有权
Animal::Dog(name) => println!("dog, name: {}", name),
} // 这里name被drop掉
println!("{:?}", cat); // cat不拥有所有权

}
/* 你将收到以下错误
error[E0382]: borrow of partially moved value: `cat`
--> src/main.rs:13:22
|
11 | Animal::Dog(name) => println!("dog, name: {}", name),
| ---- value partially moved here
12 | }
13 | println!("{:?}", cat);
| ^^^ value borrowed here after partial move
|
= note: partial move occurs because value has type `String`, which does not implement the `Copy` trait
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: borrow this field in the pattern to avoid moving `cat.0`
|
11 | Animal::Dog(ref name) => println!("dog, name: {}", name),
| +++
*/

根据所有权转移的规则,由于String不是基本类型,没有实现Copy特征,它的所有权先移动到cat,然后通过模式匹配,所有权转移到name,在离开{}的作用域之后,namedrop掉了,再次打印cat时,就会报错。

当然,rust编译器贴心地给出了提示,通过引用&来避免所有权转移:

1
2
3
4
match &cat {
Animal::Cat(name) => println!("cat, name: {}", name),
Animal::Dog(name) => println!("dog, name: {}", name),
}

当然,也可以通过ref,在通过 let 绑定来进行模式匹配或解构时,ref 关键字可用来创建结构体/元组的字段的引用:

1
2
3
4
match cat {
Animal::Cat(ref name) => println!("cat, name: {}", name), // 这里的name就是&String了
Animal::Dog(ref name) => println!("dog, name: {}", name),
}

6.2 if let

在某些场景下,会遇到只有一个模式的值需要被处理,其它值直接忽略的场景,这时使用match就显得很复杂

1
2
3
4
5
6
7
fn main() {
let v = Some(3u8);
match v {
Some(3) => println!("three"),
_ => (),
}
}

我们只想要对 Some(3) 模式进行匹配, 不想处理任何其他 Some<u8> 值或 None 值。但是为了满足 match 表达式(穷尽性)的要求,写代码时必须在处理完这唯一的成员后加上 _ => (),这样会增加不少无用的代码。

我们完全可以用 if let 的方式来实现,在这种只有两个情况的场景下会简洁很多:

1
2
3
4
5
6
fn main() {
let v = Some(3u8);
if let Some(3) = v {
println!("three");
}
}

if let语法格式如下,当然else是可选的:

1
2
3
4
5
if let 匹配值 = 原变量 {
匹配成功的语句块
} else {
没匹配到的语句块
}

你可以使用else if来增加判断项:

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let v = Some(4u8);
let need_number = false;
if let Some(3) = v {
println!("three");
} else if need_number{
println!("a number");
} else {
println!(":)");
}
}

6.3 while let

if let 类似,while let 也可以把别扭的 match 改写得好看一些。考虑下面这段使 i 不断增加的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 将 `optional` 设为 `Option<i32>` 类型
let mut optional = Some(0);

// 重复运行这个测试。
loop {
match optional {
// 如果 `optional` 解构成功,就执行下面语句块。
Some(i) => {
if i > 9 {
println!("Greater than 9, quit!");
optional = None;
} else {
println!("`i` is `{:?}`. Try again.", i);
optional = Some(i + 1);
}
// ^ 需要三层缩进!
},
// 当解构失败时退出循环:
_ => { break; }
// ^ 为什么必须写这样的语句呢?肯定有更优雅的处理方式!
}
}

使用 while let

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fn main() {
// 将 `optional` 设为 `Option<i32>` 类型
let mut optional = Some(0);

// 这读作:当 `let` 将 `optional` 解构成 `Some(i)` 时,就
// 执行语句块(`{}`)。否则就 `break`。
while let Some(i) = optional {
if i > 9 {
println!("Greater than 9, quit!");
optional = None;
} else {
println!("`i` is `{:?}`. Try again.", i);
optional = Some(i + 1);
}
// ^ 使用的缩进更少,并且不用显式地处理失败情况。
}
// ^ `if let` 有可选的 `else`/`else if` 分句,
// 而 `while let` 没有。
}

6.4 matches!

matches!是一个宏,它的作用是将一个表达式跟模式进行匹配,如果匹配成功,结果是 true 否则是 false

1
2
let foo = 'f';
assert!(matches!(foo, 'A'..='Z' | 'a'..='z'));

7 方法impl

impl也可以叫做“实现”(implementation)。在面向对象语言中,方法一般是和类或者对象绑定的,rust在概念上来说也大差不差,但是方法的定义却并不在类中,而是在 impl 代码块中定义。

7.1 静态方法

首先是静态方法(这种定义在 impl 中且没有 self 的函数也叫做关联函数):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct Point {
x: f64,
y: f64,
}

impl Point {
// 静态方法不需要self
// 静态方法不需要被实例调用,并且是直接绑定到类型上
// 这类方法一般用作构造器(constructor)
// 比如初始化一个(0,0)的点
fn origin() -> Point {
Point { x: 0.0, y: 0.0 }
}
// 根据参数初始化一个(x,y)的点
// 在rust这一是个约定俗成的规则,使用 new 来作为构造器的名称
// 并且,出于设计上的考虑,rust 特地没有用 new 作为关键字
fn new(x: f64, y: f64) -> Point {
Point { x: x, y: y }
}
}

现在你应该可以理解,rust的对象定义和方法定义是分离的。impl Point {} 表示为 Point 实现(implementation)方法。我们为Point实现了两个静态方法,下面来看看如何使用它:

1
2
3
4
fn main() {
let p = Point::new(1.0,2.0); // 通过双冒号 `::` (命名空间限定符)使用静态方法 `new`
println!("{:?}",p)
}

7.2 实例方法

实例方法定义

接下来我们为实例实现一个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
impl Point {
// 省略..
fn sum(self) {
// self 代指该实例对象
println!("{}", self.x + self.y)
}
}

fn main() {
let p = Point::new(1.0,2.0);
p.sum(); // 通过点`.`操作符: `实例.方法` 来调用
println!("{:?}",p);
}

一般情况下,每个函数参数都需要标注类型,但是self比较特殊,它等价于self:Self。注意大小写。

实例方法与所有权

如果你写过python,那么对这种self肯定非常熟悉。但是,rust往往会给你惊喜:猜这段代码会不会报错?是的,它毫不留情地报错了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*
|
26 | let p = Point::new(1.0,2.0);
| - move occurs because `p` has type `Point`, which does not implement the `Copy` trait
27 | p.sum();
| ----- `p` moved due to this method call
28 | println!("{:?}",p);
| ^ value borrowed here after move
|
note: this function takes ownership of the receiver `self`, which moves `p`
--> src/main.rs:20:12
|
20 | fn sum(self) {
| ^^^^
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

*/

嗯,仔细看,就会发现还是老问题:所有权。在sum的函数签名中,我们直接使用selffn sum(self),它其实相当于fn sum(self : Self)的简写,大写的Self是当前对象的类型。

实例方法具有所有权的概念,也就是说,只要你没有为当前的self(也就是Point实例)实现Copy特征,那么就会发生所有权转移,在最后进行打印时,p已经失去了所有权而导致报错。

当然,解决方法也很简单,之前的章节也不止一次提到了,使用引用:

1
fn sum(self : &Self) // 也可以简写为 `&self`

这种引用当然是不可变的,要想改变实例,需要再加上mut

1
fn sum(self : &mut Self)

方法名与字段名相同

rust允许方法名跟结构体的字段名相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#[derive(Debug)]
struct Point {
x: f64,
y: f64,
}

impl Point {
fn new(x: f64, y: f64) -> Point {
Point { x: x, y: y }
}

fn x(self : &mut Self) {
println!("{}", self.x+self.y)
}
}

fn main() {
let mut p = Point::new(1.0,2.0);
p.x();
println!("{:?}",p);
println!("{:?}",p.x);
}

当你使用p.x()时,编译器知道你在调用方法,当你使用p.x,它也知道你是在访问其中的字段。

多个impl

rust允许你为同一个类型定义多个impl块,这样可以更加灵活:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Point {
x: f64,
y: f64,
}

impl Point {
fn new(x: f64, y: f64) -> Point {
Point { x: x, y: y }
}
}

impl Point {
fn x(self : &mut Self) {
println!("{}", self.x + self.y)
}
}

除了结构体,我们还可以为枚举、特征实现方法。有关特征,马上就会讲到了。

8 泛型 Generic

如果你接触过C++或java的泛型,对泛型的概念应该不会陌生。泛型的应用广泛,并且可以极大地减少代码的重复。泛型最简单和常用的用法是用于类型参数。类型参数就像函数的形参一样,作为类型的代号指代一部分类型。一般情况下,类型参数是用尖括号和大驼峰命名的名称:<Aaa, Bbb, ...>指定的,作为其他语言约定俗成的规则,rust里也一般沿用<T>作为类型参数。要使用泛型,需要提前声明泛型的类型参数。

特征泛型会在下一章介绍完特征后再介绍,见特征泛型

8.1 函数泛型

比如,定义一个名为 foo 的泛型函数,它可接受类型为 T 的任何参数 arg

1
fn foo<T>(arg: T) { ... }

在使用类型 T 前,在函数名后面指定泛型类型参数 <T>,那么 T 就变成了泛型。

同理,在结构体中使用泛型:

1
2
3
4
struct A<T> {
a : T,
b : T,
}

需要注意的是,同一个泛型类型只能指代一种具体类型,比如:

1
2
3
4
5
6
7
8
fn main() {
let foo = A{a: 1, b : String::from("hello")};
}
/*
|
8 | let foo = A{a: 1, b : String::from("hello")};
| ^^^^^^^^^^^^^^^^^^^^^ expected integer, found struct `String`
*/

结构体A中的ab都是T类型,当初始化a时,类型推断确定T为整数类型,那么b的类型也应该是整数类型。

要想让ab都是泛型类型,且指代的具体类型不同,那就需要声明不同的泛型类型。

1
2
3
4
struct A<T,U> {
a : T,
b : U,
}

8.2 枚举泛型

枚举一节中,介绍了Option,它的定义是这样的:

1
2
3
4
enum Option<T> {
Some(T),
None,
}

这里的T就是泛型。Some(T)表示它可以接收任意类型的值。

另外一个常用的枚举使用泛型的例子就是Result

1
2
3
4
enum Result<T, E> {
Ok(T),
Err(E),
}

Option一样,主要用于函数返回值,当函数正确返回时,则返回Ok(T)T 是函数具体的返回值类型;当发生错误时,则返回 Err(E)E是错误类型。

8.3 方法泛型

在方法上也可以使用泛型:

1
2
3
4
5
6
7
8
9
10
struct Point<T> {
x: T,
y: T,
}

impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}

使用泛型参数前,依然需要提前声明:impl<T>,只有提前声明了,我们才能在Point<T>中使用它,这样rust就知道 Point 的尖括号中的类型是泛型而不是具体类型。需要注意的是,这里的 Point<T> 不再是泛型声明,而是一个完整的结构体类型,因为我们定义的结构体就是 Point<T> 而不再是 Point

除了结构体中的泛型参数,我们还能在该结构体的方法中定义额外的泛型参数,就跟泛型函数一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct Point<T, U> {
x: T,
y: U,
}

impl<T, U> Point<T, U> {
fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
Point {
x: self.x,
y: other.y,
}
}
}

fn main() {
let p1 = Point { x: 5, y: 10.4 };
let p2 = Point { x: "Hello", y: 'c'};

let p3 = p1.mixup(p2);

println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}

T,U 是定义在结构体 Point 上的泛型参数,V,W 是单独定义在方法 mixup 上的泛型参数,它们并不冲突。

8.4 使用具体类型实现方法

对于 Point<T> 类型,你不仅能定义基于 T 的方法,还能针对特定的具体类型,进行方法定义:

1
2
3
4
5
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}

这段代码意味着 Point<f32> 类型会有一个方法 distance_from_origin,而其他 T 不是 f32 类型的 Point<T> 实例则没有定义此方法。这个方法计算点实例与坐标(0.0, 0.0) 之间的距离,并使用了只能用于浮点型的数学运算符。

这样我们就能针对特定的泛型类型实现某个特定的方法,对于其它泛型类型则没有定义该方法。

8.5 const泛型

对于数组,[i32; 2][i32; 3] 是不同的数组类型:

1
2
3
4
5
6
7
8
9
10
fn display_array(arr: [i32; 3]) {
println!("{:?}", arr);
}
fn main() {
let arr: [i32; 3] = [1, 2, 3];
display_array(arr);

let arr: [i32;2] = [1,2];
display_array(arr);
}

无法使用一个函数来接收这两个不同的类型。当然,你可以通过引用来解决:

1
2
3
4
5
6
7
8
9
10
fn display_array(arr: &[i32]) {
println!("{:?}", arr);
}
fn main() {
let arr: [i32; 3] = [1, 2, 3];
display_array(&arr);

let arr: [i32;2] = [1,2];
display_array(&arr);
}

只要使用数组切片,然后传入 arr 的不可变引用即可。但是如果在某些场景下引用不适宜用或者干脆不能用呢,rust在后续引入的const泛型可以解决这个问题:

1
2
3
4
5
6
7
8
9
10
fn display_array<T: std::fmt::Debug, const N: usize>(arr: [T; N]) {
println!("{:?}", arr);
}
fn main() {
let arr: [i32; 3] = [1, 2, 3];
display_array(arr);

let arr: [i32; 2] = [1, 2];
display_array(arr);
}

如上所示,我们定义了一个类型为 [T; N] 的数组,其中 T 是一个基于类型的泛型参数,这个和之前讲的泛型没有区别,而重点在于 N 这个泛型参数,它是一个基于值的泛型参数,因为它用来替代的是数组的长度。

N 就是const泛型,定义的语法是 const N: usize,表示 const 泛型 N ,它基于的值类型是 usize

9 特征Trait

在之前,多次提到CopyDebug等特征,特征和其他语言的接口很类似,trait 是对未知类型 Self 定义的方法集,如果不同的类型具有相同的行为,那么我们就可以定义一个特征,然后为这些类型实现该特征。定义特征是把一些方法组合在一起,目的是定义一个实现某些目标所必需的行为的集合。

9.1 定义特征

第一步是为类型定义特征,首先创建一个类型,以结构体为例:

1
2
3
4
struct Dog {
color: String,
age : u32,
}

接下来,我们要定义一个特征:

1
2
3
4
5
6
trait Animal {
// `Self` 是未知的类型
fn new(color: String, age: u32) -> Self;
// `self` 是这个类型的实例
fn introduce(&self);
}

你可以把它理解为go/java的接口,即你只需要声明函数签名,而不需要具体实现。使用trait关键字声明一个特征名为Animal,并定义要实现这个特征所需要的方法集,这里定义了newintroduce两个方法。

9.2 为类型实现特征

声明好了特征之后,需要继续为Dog类型实现这个特征:

1
2
3
4
5
6
7
8
9
10
impl Animal for Dog {
fn new(color: String, age: u32) -> Dog {
Dog { color: color, age: age }
}

fn introduce(&self) {
println!("A {} color dog,age is {}.", self.color, self.age);
}

}

实现特征的语法也很简单:impl Animal for Dog,即:为Dog实现Animal特征。注意,实现特征需要具体实现该特征的所有的方法,也就是说,你不能只实现new而不实现introduce,否则就会报错。

最后,我们就可以使用了。下面是完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
struct Dog {
color: String,
age : u32,
}

trait Animal {
fn new(color: String, age: u32) -> Self;

fn introduce(&self);

}

impl Animal for Dog {
fn new(color: String, age: u32) -> Dog {
Dog { color: color, age: age }
}

fn introduce(&self) {
println!("A {} color dog,age is {}.", self.color, self.age);
}

}

fn main() {
let dog = Dog::new(String::from("red"), 3);
dog.introduce();

}

9.3 孤儿规则 Orphan Rule

定义特征与实现特征有一个限制,那就是只有当特征或者要实现特征的类型位于当前作用域时,才能为该类型实现特征。比如上面的例子,Dog类型定义在当前的作用域中,符合孤儿规则,因此我们可以为Dog实现Display特征;同理,String类型定义在标准库中,但是Animal特征定义在当前作用域,因此可以为String实现Animal特征;但是,不能为String 实现 Display 特征,因为它们都定义于标准库中,和当前程序没有任何关系、这个限制是被称为相干性(coherence) 的程序属性的一部分,或者更具体的说是孤儿规则(orphan rule),其得名于不存在父类型。这条规则确保了其他人编写的代码不会破坏你代码,反之亦然。没有这条规则的话,两个包可以分别对相同类型实现相同的特征,而 rust将无从得知应该使用哪一个实现。

9.4 默认实现

有时为特征中的某些或全部方法提供默认的方法,而不是在每个类型的每个实现中都定义自己的方法是很有用的,因为这样其它类型无需再实现这些默认方法,并且也可以在必要时重载它们:

1
2
3
4
5
6
7
trait Animal {
fn new(color: String, age: u32) -> Self;

fn introduce(&self){
println!("I'm an Animal!");
}
}

这里我们定义一个默认方法introduce,现在,想要为类型Dog实现Animal特征时,就可以不必实现introduce(当然,你也可以重载它):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct Dog {
color: String,
age : u32,
}

trait Animal {
fn new(color: String, age: u32) -> Self;

fn introduce(&self){
println!("I'm an Animal!");
}

}

impl Animal for Dog {
// 没有重载`introduce`,调用时会使用这个特征的默认方法
fn new(color: String, age: u32) -> Dog {
Dog { color: color, age: age }
}
}

fn main() {
let dog = Dog::new(String::from("red"), 3);
dog.introduce();

}

默认实现允许调用相同特征中的其他方法,哪怕这些方法没有默认实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
struct Dog {
color: String,
age : u32,
}

trait Animal {
fn new(color: String, age: u32) -> Self;

fn introduce_myself(&self) -> String;

fn introduce(&self){
println!("I'm an Animal! {}",self.introduce_myself());
}

}

impl Animal for Dog {
fn new(color: String, age: u32) -> Dog {
Dog { color: color, age: age }
}

fn introduce_myself(&self) ->String {
format!("A {} color dog,age is {}.",self.color, self.age)
}

}

fn main() {
let dog = Dog::new(String::from("red"), 3);
dog.introduce();

}

我们只需要实现introduce_myself即可,最终会打印出I'm an Animal! A red color dog,age is 3.

9.5 特征作为函数参数

特征可以作为函数参数:

1
2
3
fn notify(item: impl Animal) {
println!("A notification: {}", item.introduce_myself());
}

这里item的类型是impl Animal,即:实现了Animal特征的类型,这意味着你可以传入任何实现了该特征的类型,同时在函数体内,还可以调用该特征的方法,完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
struct Dog {
color: String,
age: u32,
}

trait Animal {
fn new(color: String, age: u32) -> Self;

fn introduce_myself(&self) -> String;

fn introduce(&self) {
println!("I'm an Animal! {}", self.introduce_myself());
}
}

impl Animal for Dog {
fn new(color: String, age: u32) -> Dog {
Dog {
color: color,
age: age,
}
}

fn introduce_myself(&self) -> String {
format!("A {} color dog,age is {}.", self.color, self.age)
}
}

fn notify(item: impl Animal) {
println!("A notification: {}", item.introduce_myself());
}

fn main() {
let dog = Dog::new(String::from("red"), 3);
notify(dog);
}

impl Trait 这种语法适用于简单的例子,实际上是一种较长形式语法的语法糖。我们称为特征约束(trait bound)。

9.6 特征约束

特征约束完整的形式如下,还是上面的例子:

1
2
3
fn notify<T: Animal>(item: T) {
println!("A notification: {}", item.introduce_myself());
}

函数泛型类似,但是在T后增加了Animal特征的约束。即:notify函数是一个泛型函数,其中参数item的类型为T,且T必须实现Animal特征。

使用这种语法看起来复杂,但是比impl Trait表达性更强。例如,可以获取两个实现Animal特征的参数,如果使用impl Trait

1
fn notify(item_a: impl Animal, item_b: impl Animal) {}

但是,如果要求这两个参数必须是同一个类型,那就只能使用完整语法:

1
fn notify<T: Animal>(item_a: T, item_b: T) {}

函数泛型来限制两个参数必须是同一类型,通过特征约束限制这些类型必须实现指定特征。

9.7 多重约束

有时需要对泛型进行多个特征约束,比如既需要该类型实现Debug特征,又需要实现某个自定义特征Animal

1
fn notify<T: Animal + Debug>(item_a: T, item_b: T) {}

使用+连接多个特征,以实现多重约束。使用impl Trait形式的多重特征约束:

1
fn notify(item: impl Animal + Debug) {}

9.8 where约束

当多重约束变得很多,多个泛型参数的函数在名称和参数列表之间会有很长的约束信息,使得整个函数签名变得非常长,不易于阅读。为此,rust提供了where,在函数签名后指定多重约束的语法,比如这个多重约束:

1
fn notify<T: Animal + Debug, U: Debug + Display + Send>(item_a: T, item_b: U) {}

可以改写为:

1
2
3
4
5
6
7
fn notify<T, U>(item_a: T, item_b: U) -> ()
where
T: Animal + Debug,
U: Debug + Display + Send,
{
println!("A notification: {}", item_a.introduce_myself());
}

这样看起来函数签名就更加紧凑了。

9.9 返回实现了特征的类型

也可以在返回值中使用 impl Trait 语法,来返回实现了某个特征的类型:

1
fn notify(item: String) -> impl Animal {}

指定notify函数返回某个实现了Animal的类型,但是不确定其具体的类型。返回一个只是指定了需要实现的特征的类型的能力在闭包和迭代器场景十分的有用。闭包和迭代器创建只有编译器知道的类型,或者是非常非常长的类型。impl Trait 允许你简单的指定函数返回一个 Iterator 而无需写出实际的冗长的类型。

不过这只适用于返回单一类型的情况。如果你的实现是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
// 假设这里Dog和Cat都实现了Animal特征
fn notify(switch: bool) -> impl Animal {
if switch {
Dog {
// ..
}
}else{
Cat {
// ..
}
}
}

虽然DogCat都实现了Animal特征,但是返回类型不单一,无法通过编译。解决方法留到后面进行讨论,详见特征对象

9.10 使用特征约束有条件地实现方法

在实现方法时使用特征约束,有条件地约束哪些类型可以拥有方法,哪些类型不可以拥有方法。比如,类型 Pair<T> 总是实现了 new 方法,不过只有那些为 T 类型实现了 PartialOrd 特征(来允许比较)和Display 特征(来启用打印)的 Pair<T> 才会实现 cmp_display 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use std::fmt::Display;

struct Pair<T> {
x: T,
y: T,
}

impl<T> Pair<T> {
fn new(x: T, y: T) -> Self {
Self {
x,
y,
}
}
}

impl<T: Display + PartialOrd> Pair<T> {
fn cmp_display(&self) {
if self.x >= self.y {
println!("The largest member is x = {}", self.x);
} else {
println!("The largest member is y = {}", self.y);
}
}
}

也可以对任何实现了特定特征的类型有条件地实现特征。对任何满足特定特征约束的类型实现特征被称为通用实现(blanket implementations) ,它们被广泛的用于rust标准库中。例如,标准库为任何实现了Display 特征的类型实现了 ToString 特征。这个impl块看起来像这样:

1
2
3
impl<T: Display> ToString for T {
// --snip--
}

再举个通俗的例子——学校要给学生颁奖,但是,想要获得颁奖资格,学生必须获得四级证书:对任何实现了特定特征的类型(有四级证书的学生)实现特征(有资格获奖)。

特征和特征约束让我们使用泛型类型参数来减少重复,并仍然能够向编译器明确指定泛型类型需要拥有哪些行为。因为我们向编译器提供了特征约束信息,它就可以检查代码中所用到的具体类型是否提供了正确的行为。

在动态类型语言中,如果我们尝试调用一个类型并没有实现的方法,会在运行时出现错误。rust将这些错误移动到了编译时,甚至在代码能够运行之前就强迫我们修复错误。另外,我们也无需编写运行时检查行为的代码,因为在编译时就已经检查过了,这样相比其他那些不愿放弃泛型灵活性的语言有更好的性能。

9.11 derive

还记得打印结构体时使用的#[derive(Debug)]吗,这种是一种特征派生语法,被 derive 标记的对象会自动实现对应的默认特征代码,继承相应的功能。对于Debug 特征,rust有写好的自动实现的默认代码,当你给一个结构体标记后,就可以使用 println!("{:?}", s) 的形式打印该结构体的对象。再如 Copy 特征,它也有一套自动实现的默认代码,当标记到一个类型上时,可以让这个类型自动实现 Copy 特征,进而可以调用 copy 方法,进行自我复制。

总之,derive 派生出来的是rust默认给我们提供的特征,在开发过程中极大的简化了自己手动实现相应特征的需求,当然,如果你有特殊的需求,还可以自己手动重载该实现。

9.12 特征泛型

特征中也可以使用泛型,它的定义如下:

1
trait A<U> {}

举个具体的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
trait A<T> {
fn foo(&self, x: T) -> T;
}

struct B;

impl<T> A<T> for B {
fn foo(&self, x: T) -> T {
x
}
}

fn main() {
let b = B;
let x = 123;
let z = String::from("hello world");
println!("x = {}", b.foo(x));
println!("z = {}", b.foo(z));
}

这里定义了一个特征泛型A,其中包含一个名为foo的方法,该方法接受一个类型参数T,并返回一个类型为T的值。

接下来,我们定义一个结构体B,并为它实现了特征A,并提供了一个foo方法,该方法返回传递给它的参数值。然后在main函数中,我们首先创建了一个B对象b,然后使用foo方法将x值、y值分别传递给b.foo并打印。

可以看出,对于不同类型的x:i32y:Stringfoo方法都可以接受它们,这使得特征可以不受类型的限制,减少编码冗余。

在后面的章节默认泛型类型参数和运算符重载中,我们还会使用到特征泛型。

9.13 Supertraits

Supertraits是为实现特定特征而需要为类型实现的特征,这可能有些拗口,但是如果将其比喻为面向对象中类的继承,就稍微好理解了一点(当然并不严谨)。

Supertraits是通过特征的 Self类型上的特征约束来声明的,并且通过这种声明特征约束的方式来传递这种Supertraits关系。一个 trait 不能是它自己的Supertrait。

下面是一个声明 ShapeCircle 的Supertrait的例子。

1
2
trait Shape { fn area(&self) -> f64; }
trait Circle : Shape { fn radius(&self) -> f64; }

下面是同改成使用where约束的等效实现:

1
2
trait Shape { fn area(&self) -> f64; }
trait Circle where Self: Shape { fn radius(&self) -> f64; }

在后面深入理解Fn特征继承中也会再次介绍。

10 集合

集合(collections)是rust中非常有用的数据结构,下面会逐个介绍三种集合。

10.1 String

字符串切片

切片在前面介绍数组时有简单提到,它是一类引用,没有所有权。

1
2
3
4
let s = String::from("hello world");

let hello = &s[0..5];
let world = &s[6..11];

sString类型,hello 没有引用整个 String s,而是引用了 s 的一部分内容,通过 [0..5] 的方式来指定。这就是创建切片的语法,使用方括号包括的一个序列:[开始索引..终止索引],其中开始索引是切片中第一个元素的索引位置,而终止索引是最后一个元素后面的索引位置,这是一个[)右半开区间。在切片数据结构内部会保存开始的位置和切片的长度,其中长度是通过 终止索引 - 开始索引 的方式计算得来的。

..除了用于解构,在这里是生成连续序列的意思,如果你想从索引 0 开始,可以使用如下的方式,这两个是等效的:

1
2
let slice = &s[0..2];
let slice = &s[..2];

同样的,如果你的切片想要包含 String 的最后一个字节,则可以这样使用:

1
2
3
4
5
6
let s = String::from("hello");

let len = s.len();

let slice = &s[4..len];
let slice = &s[4..];

你也可以截取完整的 String 切片:

1
2
3
4
5
6
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];

但通常,字符串切片是非常危险的操作,具体原因请继续往下看。

字符串的字面值是切片

对于:

1
let s = "Hello, world!";

s 的类型是 &str,因此你也可以这样声明:

1
let s: &str = "Hello, world!";

这也是为什么字符串字面量是不可变的,因为 &str 是一个不可变引用。

字符串

字符串是由字符组成的连续集合。rust中的字符是Unicode类型,因此每个字符占据4个字节内存空间,但是在字符串中不一样,字符串是UTF-8编码,也就是字符串中的字符所占的字节数是变化的(1 - 4),这样有助于大幅降低字符串所占用的内存空间

String是常用的数据类型,它是在标准库中定义的。rust在语言级别只有一种数据类型str,它通常以引用的形式&str出现,也就是字符串切片,也叫做字符串的字面值。

str 类型硬编码进可执行文件,也无法被修改,但是String则是一个可增长、可改变且具有所有权的UTF-8编码字符串。当用户提到字符串时,往往指的就是String类型和&str字符串切片类型,这两个类型都是UTF-8编码。

创建字符串与转换类型

1
let mut s = String::new();

通过new可以新建一个空字符串。但更多时候,我们需要&strString相互转换。

首先是从 &str 类型生成 String 类型:

1
2
let s = String::from("initial contents");
let t = "hello,world".to_string();

这两种生成方式得到的效果是一样的,String::from.to_string 最终做了完全相同的工作,所以如何选择就是代码风格与可读性的问题了。

String 类型转为 &str 类型也非常简单,取引用即可:

1
2
3
4
5
6
7
8
9
10
fn main() {
let s = String::from("hello,world!");
say_hello(&s);
say_hello(&s[..]);
say_hello(s.as_str());
}

fn say_hello(s: &str) {
println!("{}",s);
}

字符串的编码

字符串是UTF-8编码,所以可以包含任何可以正确编码的数据:

1
2
3
4
5
6
7
8
9
10
11
let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý den");
let hello = String::from("Hello");
let hello = String::from("שָׁלוֹם");
let hello = String::from("नमस्ते");
let hello = String::from("こんにちは");
let hello = String::from("안녕하세요");
let hello = String::from("你好");
let hello = String::from("Olá");
let hello = String::from("Здравствуйте");
let hello = String::from("Hola");

所有这些都是有效的 String 值。

字符串索引

在很多语言中,通过索引来引用字符串中的单独字符是有效且常见的操作。然而在 Rust 中,如果你尝试使用索引语法访问 String 的一部分,会出现错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn main() {
let s1 = String::from("hello");
let h = s1[0];
}
/* 你将得到如下错误:
|
13 | let h = s1[0];
| ^^^^^ `String` cannot be indexed by `{integer}`
|
= help: the trait `Index<{integer}>` is not implemented for `String`
= help: the following other types implement trait `Index<Idx>`:
<String as Index<RangeFrom<usize>>>
<String as Index<RangeFull>>
<String as Index<RangeInclusive<usize>>>
<String as Index<RangeTo<usize>>>
<String as Index<RangeToInclusive<usize>>>
<String as Index<std::ops::Range<usize>>>
*/

错误提示告诉我们,rust的字符串不支持索引。这是因为字符串在内存中的存储决定的。

内部表现

字符串的底层的数据存储格式实际上是一个u8类型的字节数组。前面举了一些UTF-8的字符串例子,比如:

1
2
3
4
5
fn main() {
let s1 = String::from("hola");
let len = s1.len(); // .len 方法用于获取数组的长度,在这里就是字节数
println!("{}", len);
}

在这里,len的值是4,因为Hola中的每个字母在UTF-8编码中仅占用1个字节,但是对于:

1
2
3
4
5
fn main() {
let s1 = String::from("你好世界");
let len = s1.len();
println!("{}", len);
}

len 的值是12,因为一个中文字符需要3个字节存储。在这种情况下,假如对s1取索引&s1[0],并不会返回,而是这3个字节中的第1个字节,这显然不是用户想要的结果。为了避免返回意外的值并造成不能立刻发现的bug,rust根本不会编译这些代码,并在开发过程中及早杜绝了误会的发生。

这引起了关于UTF-8的另外一个问题:从rust的角度来讲,事实上有三种相关方式可以理解字符串:字节、标量值和字母。

比如这个用梵文书写的印度语单词 “नमस्ते”,它底层的字节数组是如下形式:

1
2
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

这里有 18 个字节,也就是计算机最终会储存的数据。

如果从Unicode标量值的角度理解它们,也就像rust的char类型那样,这些字节看起来像这样:

1
['न', 'म', 'स', '्', 'त', 'े']

这里有六个 char,不过第四个和第六个都不是字母,它们是发音符号本身并没有任何意义。最后,如果以字母的角度理解:

1
["न", "म", "स्", "ते"]

rust提供了多种不同的方式来解释计算机储存的原始字符串数据,这样程序就可以选择它需要的表现方式,而无所谓是何种人类语言。

最后一个rust不允许使用索引获取 String 字符的原因是,索引操作预期总是需要常数时间O(1)。但是对于String不可能保证这样的性能,因为rust必须从开头到索引位置遍历来确定有多少有效的字符。

字符串切片可能造成的问题

前面提到,字符串切片是非常危险的操作,这正是由于字符串索引返回的东西不明确。因此,如果你真的希望使用索引创建字符串切片时,需要你更明确一些。为了更明确索引并表明你需要一个字符串 slice,相比使用 [] 和单个值的索引,可以使用 [] 和一个range来创建含特定字节的字符串切片:

1
2
let hello = "中国人";
let s = &hello[0..3];

这里,s 会是一个 &str,它包含字符串的前3个字节,也就是。但是,如果你取错了索引:

1
let s = &hello[0..2];

那么就会在运行时painc。你应该小心谨慎地使用这个操作,因为这么做可能会使你的程序崩溃。

字符串操作

String是动态可增加的,其内容也可以改变,下面介绍字符串的修改,添加,删除等常用方法。

追加

可以通过 push_str 方法来附加字符串切片,从而使 String 变长:

1
2
3
4
5
6
7
fn main() {
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s1 is {s1}");
println!("s2 is {s2}");
}

s2并没有在push_str后失效,这是因为我们使用的是字符串字面值,并不需要获取参数的所有权。

还可以使用pushpush 方法被定义为获取一个单独的字符作为参数,并附加到 String 中:

1
2
3
4
5
fn main() {
let mut s = String::from("lo");
s.push('l');
println!("{}",s)
}

这两个方法都是在原有的字符串上追加,并不会返回新的字符串。由于字符串追加操作要修改原来的字符串,则被添加的字符串必须是可变的,需要用mut

拼接字符串

通常你会希望将两个已知的字符串合并在一起。一种办法是使用 + 运算符。

1
2
3
4
5
6
fn main() {
let s1 = String::from("hello");
let s2 = String::from("world");
let s3 = s1 + &s2;
println!("{}",s3);
}

这里有一些细节需要注意,首先,我们将两个字符串拼接的时候,需要使用+,左边是s1,而右边则获取了引用&s2。并且,如果你在最后打印s1,会发现它不再有效,这是由于+运算背后调用了标准库的add方法,函数签名看起来像这样:

1
fn add(self, s: &str) -> String {}

根据前几章的内容可以得知,我们使用第二个字符串的引用与第一个字符串相加。这是因为 add 函数的 s 参数:只能将 &strString 相加,不能将两个 String 值相加。不过正如 add 的第二个参数所指定的,&s2 的类型是 &String 而不是 &str。那么为什么还能通过编译呢?之所以能够在 add 调用中使用 &s2 是因为 &String 可以被强转(coerced)成 &str。当add函数被调用时,rust使用了一个被称为Deref 强制转换(deref coercion)的技术,你可以将其理解为它把 &s2 变成了 &s2[..]

其次,可以发现签名中 add 获取了 self 的所有权,因为self没有使用 &。这意味着s1 的所有权将被移动到 add 调用中,之后就不再有效。所以虽然 let s3 = s1 + &s2; 看起来就像它会复制两个字符串并创建一个新的字符串,而实际上这个语句会获取 s1 的所有权,附加上从 s2 中拷贝的内容,并返回结果的所有权。换句话说,它看起来好像生成了很多拷贝,不过实际上并没有:这个实现比拷贝要更高效。

另外也可以使用+=

1
2
3
4
5
6
fn main() {
let mut s1 = String::from("hello");
let s2 = String::from("world");
s1 += &s2;
println!("{}",s1);
}

如果需要多个字符串拼接,+就不太方便了,此时可以使用format!宏:

1
2
3
4
5
6
7
8
fn main() {
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = format!("{s1}-{s2}-{s3}");
println!("{}",s);
}

format!println! 的工作原理相同,不过不同于将输出打印到屏幕上,它返回一个带有结果内容的 String。这个版本就好理解的多,宏 format! 生成的代码使用引用所以不会获取任何参数的所有权。

插入

使用 insert() 方法插入单个字符 char,也可以使用 insert_str() 方法插入字符串字面值。这两个方法需要传入两个参数,一个是待插入的值,另一个是插入的位置索引,索引从 0 开始计数,如果越界则会发生错误。由于字符串插入操作要修改原来的字符串,则该字符串必须是可变的,即字符串变量必须由mut关键字修饰。

1
2
3
4
5
6
7
8
fn main() {
let mut s = String::from("Hello world!");
println!("{}",s);
s.insert_str(0,"hello");
println!("{}",s);
s.insert(0,'a');
println!("{}",s);
}
替换

如果想要把字符串中的某个字符串替换成其它的字符串,那可以使用 replace() 方法。与替换有关的方法有三个。

  1. replace

    用于String&str 类型。replace() 方法接收两个参数,第一个参数是要被替换的字符串,第二个参数是新的字符串。该方法会替换所有匹配到的字符串。该方法是返回一个新的字符串,而不是操作原来的字符串。

    1
    2
    3
    4
    5
    fn main() {
    let string_replace = String::from("I like rust. Learning rust is my favorite!");
    let new_string_replace = string_replace.replace("rust", "RUST");
    println!("{}",new_string_replace);
    }
  2. replacen

    该方法可适用于 String&str 类型。replacen() 方法接收三个参数,前两个参数与 replace() 方法一样,第三个参数则表示替换的个数。同样返回一个新的字符串,而不是操作原来的字符串。

    1
    2
    3
    4
    5
    fn main() {
    let string_replace = String::from("I like rust. Learning rust is my favorite!");
    let new_string_replace = string_replace.replacen("rust", "RUST",1);
    println!("{}",new_string_replace);
    }
  3. replace_range

    该方法仅适用于 String 类型。replace_range 接收两个参数,第一个参数是要替换字符串的范围(Range),第二个参数是新的字符串。该方法是直接操作原来的字符串,不会返回新的字符串。因此被操作字符串需要使用 mut 关键字修饰。

    1
    2
    3
    4
    5
    fn main() {
    let mut string_replace_range = String::from("I like rust!");
    string_replace_range.replace_range(7..8, "R");
    println!("{}",string_replace_range);
    }

    将索引范围是[7,8)区间内的字符替换为字符串R

删除

与字符串删除相关的方法有 4 个,他们分别是 pop()remove()truncate()clear()。这四个方法仅适用于 String 类型,并且直接在原地操作,需要使用mut修饰。

  1. pop

    该方法删除并返回字符串的最后一个字符,返回值是一个 Option ,如果字符串为空,则返回 None

    1
    2
    3
    4
    5
    6
    7
    8
    fn main() {
    let mut s = String::from("I like rust!");
    let t = s.pop();
    if let Some(x) = t {
    println!("{}", x);
    }
    println!("{}", s);
    }
  2. remove

    该方法删除并返回字符串中指定位置的字符。接收一个参数即要删除的索引位置,如果索引越界则会panic

    1
    2
    3
    4
    5
    6
    fn main() {
    let mut s = String::from("I like rust!");
    let t = s.remove(3);
    println!("{}", t);
    println!("{}", s);
    }
  3. truncate

    该方法删除字符串中从指定位置开始到结尾的全部字符,无返回值,索引越界则会panic

    1
    2
    3
    4
    5
    6
    fn main() {
    let mut s = String::from("I like rust!");
    s.truncate(5);

    println!("{}", s);
    }
  4. clear

    该方法清空字符串,相当于truncate(0)

    1
    2
    3
    4
    5
    6
    fn main() {
    let mut s = String::from("I like rust!");
    s.clear();

    println!("{}", s);
    }

更多方法,请参阅官方文档

转义字符串

我们可以通过转义的方式\输出ASCIIUnicode字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fn main() {
// 通过 \ + 字符的十六进制表示,转义输出一个字符
let byte_escape = "I'm writing \x52\x75\x73\x74!";
println!("What are you doing\x3F (\\x3F means ?) {}", byte_escape);

// \u 可以输出一个 unicode 字符
let unicode_codepoint = "\u{211D}";
let character_name = "\"DOUBLE-STRUCK CAPITAL R\"";

println!(
"Unicode character {} (U+211D) is called {}",
unicode_codepoint, character_name
);

// 换行了也会保持之前的字符串格式
let long_string = "String literals
can span multiple lines.
The linebreak and indentation here ->\
<- can be escaped too!";
println!("{}", long_string);
}

当然,在某些情况下,可能你会希望保持字符串的原样,不要转义:

1
2
3
4
5
6
7
8
9
10
11
12
13
fn main() {
println!("{}", "hello \\x52\\x75\\x73\\x74");
let raw_str = r"Escapes don't work here: \x3F \u{211D}";
println!("{}", raw_str);

// 如果字符串包含双引号,可以在开头和结尾加 #
let quotes = r#"And then I said: "There is no escape!""#;
println!("{}", quotes);

// 如果还是有歧义,可以继续增加,没有限制
let longer_delimiter = r###"A string with "# in it. And even "##!"###;
println!("{}", longer_delimiter);
}

操作UTF-8字符串

如果你想要以Unicode字符的方式遍历字符串,最好的办法是使用 chars 方法,例如:

1
2
3
for c in "中国人".chars() {
println!("{}", c);
}

以字节形式遍历,使用bytes

1
2
3
for b in "中国人".bytes() {
println!("{}", b);
}

想要准确的从 UTF-8 字符串中获取子串是较为复杂的事情,例如想要从 holla中国人नमस्ते 这种变长的字符串中取出某一个子串,使用标准库是做不到的。 需要在 crates.io 上搜索 utf8 来寻找想要的功能:考虑使用utf8_slice

总而言之,字符串还是很复杂的。不同的语言选择了不同的向程序员展示其复杂性的方式。rust选择了较为复杂、更加准确的方式。这种权衡取舍相比其他语言更多的暴露出了字符串的复杂性,不过也使你在开发周期后期免于处理涉及非 ASCII字符的错误。

10.2 动态数组 Vector

动态数组Vec<T>也被称为vector,是长度可增长的数组,下面介绍它的基本使用。

创建动态数组

创建新的空vector,可以调用 Vec::new 函数:

1
let v: Vec<i32> = Vec::new();

这里显式增加了一个类型标注Vec<i32>,编译器可以得知v中的元素是i32类型。如果去掉标注,由于动态数组是通过泛型实现的,rust并不知道我们想要储存什么类型的元素。

当然,rust可以通过类型推断来确定动态数组的类型:

1
2
let mut v = Vec::new();
v.push(1);

首先需要使用mutv变成可变的,然后使用push方法添加一个元素,此时通过类型推断,编译器可以得知v 中的元素类型是 i32,因此推导出 v 的类型是 Vec<i32>

通常,我们会用初始值来创建一个 Vec<T> ,rust会推断出储存值的类型,使用宏 vec! 来创建数组,这个宏会根据我们提供的值来创建一个新的 vector:

1
let v = vec![1, 2, 3];

我们提供了 i32 类型的初始值,rust可以推断出 v 的类型是 Vec<i32>,因此类型注解就不是必须的。

修改动态数组

向数组尾部添加元素,可以使用 push 方法:

1
2
let mut v = Vec::new();
v.push(1);

插入元素insert

1
2
3
4
5
6
7
fn main() {
let mut vec = vec![1, 2, 3];
vec.insert(1, 4);
assert_eq!(vec, [1, 4, 2, 3]);
vec.insert(4, 5);
assert_eq!(vec, [1, 4, 2, 3, 5]);
}

删除并返回数组中最后一个元素:

1
2
3
4
5
fn main() {
let mut vec = vec![1, 2, 3];
assert_eq!(vec.pop(), Some(3));
assert_eq!(vec, [1, 2]);
}

更多方法,可以参阅官方文档

读取数组的元素

有两种方法引用 vector 中储存的值:通过索引或使用 get 方法:

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let v = vec![1, 2, 3, 4, 5];
let third: &i32 = &v[2];
println!("The third element is {third}");

let third: Option<&i32> = v.get(2);
match third {
Some(third) => println!("The third element is {third}"),
None => println!("There is no third element."),
}
}

使用 &[] 会得到一个索引位置元素的引用。当使用索引作为参数调用 get 方法时,会得到一个可以用于 matchOption<&T>枚举类型。

之所以提供两种读取方法,是因为如果索引越界,程序的效果不同,如果是直接取索引:

1
2
3
4
fn main() {
let v = vec![1, 2, 3, 4, 5];
let t: &i32 = &v[100];
}

rust直接会panic,而使用get

1
2
3
4
5
6
7
fn main() {
let v = vec![1, 2, 3, 4, 5];
let t = v.get(100);
if let None = t {
println!("t is None");
}
}

它不会panic而是返回 None,当偶尔出现超过范围的访问属于正常情况的时候可以考虑使用它。接着你的代码可以有处理 Some(&element)None 的逻辑,就像模式匹配里讲的那样。

一旦程序获取了一个有效的引用,借用检查器将会执行所有权和借用规则来确保vector内容的这个引用和任何其他引用保持有效。回忆一下不能在相同作用域中同时存在可变和不可变引用的规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn main() {
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0];
v.push(6);
println!("The first element is: {first}");
}
/*
|
13 | let first = &v[0];
| - immutable borrow occurs here
14 | v.push(6);
| ^^^^^^^^^ mutable borrow occurs here
15 | println!("The first element is: {first}");
| ----- immutable borrow later used here
*/

当我们获取了数组的第一个元素的不可变引用并尝试在末尾增加一个元素的时候,如果尝试在函数的后面引用这个元素是行不通的。为什么第一个元素的引用会关心数组结尾的变化?不能这么做的原因是由于动态数组的工作方式:在动态数组的结尾增加新元素时,在没有足够空间将所有元素依次相邻存放的情况下,可能会要求分配新内存并将老的元素拷贝到新的空间中。这时,第一个元素的引用就指向了被释放的内存。借用规则阻止程序陷入这种状况。

遍历数组元素

如果想要依次访问数组中的每一个元素,我们可以遍历其所有的元素而无需通过索引一次一个的访问。

1
2
3
4
5
6
fn main() {
let v = vec![100, 32, 57];
for i in &v {
println!("{i}");
}
}

当然,也可以遍历可变引用,以在循环中改变原始值:

1
2
3
4
5
6
fn main() {
let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50;
}
}

在其他语言中,有一种不安全的行为,比如,在python的for循环中,不断地向被迭代的对象添加元素:

1
2
3
4
5
arr = [1, 2, 3, 4, 5]

for i in arr:
print(i)
arr.append(0)

就会导致无限循环下去的bug。所以,在这些语言中,应当避免在循环体中对被循环的数组进行插入、删除等操作,在C/C++这样的编译型语言中,编译器也不会给出提醒报错,使得这样的错误有时难以被发现。

而rust则根本解决了这个问题。根据借用检查器的规则,无论可变还是不可变地遍历一个动态数组都是安全的。如果尝试在 for 循环体内插入或删除项,都会得到编译错误。这是因为在for 循环中已经获取了动态数组引用,阻止了同时对动态数组整体的修改,比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn main() {
let mut v = vec![100, 32, 57];
for i in &v {
println!("{i}");
v.push(3);
}
}
/* 得到如下错误
|
13 | for i in &mut v {
| ------
| |
| first mutable borrow occurs here
| first borrow later used here
14 | println!("{i}");
15 | v.push(3);
| ^^^^^^^^^ second mutable borrow occurs here
*/

即使改成可变引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn main() {
let mut v = vec![100, 32, 57];
for i in &mut v {
println!("{i}");
v.push(3);
}
}
/*
|
13 | for i in &mut v {
| ------
| |
| first mutable borrow occurs here
| first borrow later used here
14 | println!("{i}");
15 | v.push(3);
| ^^^^^^^^^ second mutable borrow occurs here
*/

也会由于“同一个变量的可变引用在同一作用域内最多只能有一个”这样的规则而报错。

动态数组的drop

类似于结构体,动态数组在其离开作用域时会被释放:

1
2
3
{
let v = vec![1, 2, 3, 4];
} // <- v 离开作用域时被drop释放

当动态数组被丢弃时,所有其内容也会被丢弃,这意味着这里它包含的元素将被清理。借用检查器确保了任何数组中内容的引用仅在数组本身有效时才可用。比如:

1
2
3
4
5
6
7
fn main() {
{
let mut v = vec![100, 32, 57];
println!("{}",&v[0]); // 有效
}
println!("{}",&v[0]); // v离开作用域被drop
}

使用动态数组和枚举来存储不同类型的数据

有时候,我们需要在数组中存放不同类型的数据,就像弱类型(js)或动态类型(python)的语言一样。rust中,需要麻烦一些,通过枚举来实现这种需求。

1
2
3
4
5
6
7
8
9
enum Number {
integer(i32),
big_integer(i64),
float(f32),
double(f64),
}
fn main() {
let arr = vec![Number::big_integer(24),Number::float(3.14),Number::double(3.14)];
}

这里的arr中存储的都是枚举类型Number,在枚举成员中会存放这些不同类型的值。rust在编译时就必须准确的知道数组中类型的原因在于,它需要知道储存每个元素到底需要多少内存。但如果在编译时不能确定有多少类型的数据,则无法使用枚举,可以通过特征对象来实现这个需求。留到后面进行讨论,详见特征对象

10.3 哈希表 hashmap

最后介绍的常用集合类型是哈希(hashmap),HashMap<K, V> 类型储存了一个键类型 K 对应一个值类型 V 的映射。很多编程语言支持这种数据结构,不过通常有不同的名字:哈希、map、对象、哈希表、dict或者关联数组等。例如,在一个游戏中,你可以将每个团队的分数记录到hashmap中,其中键是队伍的名字而值是每个队伍的分数。给出一个队名,就能得到他们的得分。

创建hashmap

通过new方法创建:

1
2
3
4
5
6
7
8
use std::collections::HashMap;

fn main() {
let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
}

在这三个常用集合中,HashMap 是最不常用的,所以并没有被prelude自动引用,需要通过use引入HashMap,然后创建一个空的scores,并插入两个键值对。和String一样,哈希表将它们的数据储存在堆上,这个 HashMap 的键类型是 String 而值类型是 i32。类似于动态数组,哈希表是同质的:所有的键必须是相同类型,值也必须都是相同类型。

访问hashmap

可以通过 get 方法并提供对应的键来从hashmap中获取值:

1
2
3
4
5
6
7
8
9
10
11
12
use std::collections::HashMap;

fn main() {
let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

let team_name = String::from("Blue");
let score = scores.get(&team_name).copied().unwrap_or(0);
println!("{score}");
}

这里,score 是与蓝队分数相关的值,应为 10get 方法返回 Option<&V>,如果某个键在哈希 map 中没有对应的值,get 会返回 None。程序中通过调用 copied 方法来获取一个 Option<i32> 而不是 Option<&i32>,接着调用 unwrap_orscore 中没有该键所对应的项时将其设置为零。

遍历hashmap

可以使用for循环遍历hashmap的所有值:

1
2
3
4
5
6
7
8
9
10
11
12
use std::collections::HashMap;

fn main() {
let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

for (key, value) in &scores {
println!("{key}: {value}");
}
}

所有权转移

hashmap的所有权规则与其它类型没有区别,对于实现了Copy特征的类型,该类型会被复制进hashmap;对于没实现 Copy 特征的类型,其值将被移动而hashmap会成为这些值的所有者。

1
2
3
4
5
6
7
8
9
fn main() {
let field_name = String::from("Favorite color");
let field_value = String::from("Blue");

let mut map = HashMap::new();
map.insert(field_name, field_value);
// 这里 field_name 和 field_value 不再有效,
println!("{field_name}"); // 错误,所有权已经被转移进hashmap
}

如果将值的引用插入hashmap,这些值的所有权不会被转移。但是这些引用指向的值必须至少在hashmap有效时也是有效的,看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fn main() {
let mut map = HashMap::new();
{
let field_name = String::from("Favorite color");
let field_value = String::from("Blue");
map.insert(&field_name, field_value);
} // 离开这个作用域后,field_name和field_value失效

println!("{:?}", map); // 引用在这里使用
}
/* 收到如下报错
|
18 | map.insert(&field_name, field_value);
| ^^^^^^^^^^^ borrowed value does not live long enough
19 | }
| - `field_name` dropped here while still borrowed
20 |
21 | println!("{:?}", map);
| --- borrow later used here
*/

{}作用域内向hashmap中插入了一对元素,其中,key传入了引用&field_namefield_name的所有权没有被移动,但是在{}作用域结束后,field_name被自动drop后失效,hashmap的仍然保留着的这个引用所指向的值是未知的,无法通过编译。这里涉及到生命周期的概念,后面会进行介绍(见生命周期)。

更新hashmap

当我们想要改变hashmap中的数据时,必须决定如何处理一个键已经有值了的情况。有以下几种处理方法:

  1. 直接覆盖原先的。

    如果我们插入了一个键值对,接着用相同的键插入一个不同的值,与这个键相关联的旧值将被替换。

    1
    2
    3
    4
    5
    6
    fn main() {
    let mut map = HashMap::new();
    map.insert(String::from("aaa"), 3);
    map.insert(String::from("aaa"), 4);
    println!("{:?}", map);
    }

    最终hashmap的值为:{"aaa": 4},最开始的3被覆盖了。

  2. 只在键没有对应值时插入键值对。

    有时可能会需要这样的操作:当插入时,hashmap中不存在这个key,则插入;hashmap存在这个key则不作任何操作。

    为此hashmap提供了一个entry方法,它获取我们想要检查的键作为参数,它的返回值是一个枚举Entry。这个枚举代表了可能存在也可能不存在的值。Entryor_insert 方法在键对应的值存在时就返回这个值的可变引用,如果不存在则将参数作为新值插入并返回新值的可变引用。

    1
    2
    3
    4
    5
    6
    fn main() {
    let mut map = HashMap::new();
    map.insert(String::from("aaa"), 3);
    map.entry(String::from("aaa")).or_insert(5);
    println!("{:?}", map);
    }

    使用or_insert比编写自己的逻辑要简明,另外也与借用检查器结合得更好。

  3. 根据旧值更新一个值。

    另一个常见的应用场景是找到一个键对应的值并根据旧的值更新它。

    1
    2
    3
    4
    5
    6
    7
    8
    fn main() {
    let mut map = HashMap::new();
    map.insert(String::from("aaa"), 3);
    let value = map.entry(String::from("aaa")).or_insert(8);
    *value += 1;

    println!("{:?}", map);
    }

    or_insert 返回了 &mut value 引用,因此可以通过该可变引用直接修改 map 中对应的值,使用引用时,需要解引用*

11 package&crate

11.1 rustc编译单个文件

对于简单使用,可以通过rustc编译代码,假设当前文件为main.rs,内容为:

1
2
3
fn main() {
println!("Hello, world!");
}

进行编译:

1
rustc main.rs

在 Linux、macOS 或 Windows 的 PowerShell 上,在 shell 中输入 ls 命令可以看见这个可执行文件:

1
2
$ ls
main main.rs

在 Linux 和 macOS,你会看到两个文件。在 Windows PowerShell 中,你会看到同使用 CMD 相同的三个文件。在 Windows 的 CMD 上,则输入如下内容:

1
2
> dir
main.exe main.pdb main.rs

这展示了扩展名为.rs的源文件、可执行文件(在 Windows 下是main.exe,其它平台是main),以及当使用 CMD 时会有一个包含调试信息、扩展名为.pdb的文件。从这里开始运行mainmain.exe文件,如下:

1
$ ./main # Windows 是 .\main.exe

在终端上就会打印出 Hello, world!

仅仅使用 rustc 编译简单程序是没问题的,不过随着项目的增长,你可能需要管理你项目的方方面面,并让代码易于分享。因此,使用rust提供的cargo可以更好地管理项目。

11.2 模块系统

这里有一些名词需要区分,首先是crate,它是rust在编译时最小的代码单位。如果用rustc去编译一个文件,这个文件就被认为是一个cratecrate可以包含多个modulemodule可以定义在其它文件中,然后和crate一起编译。

crate有两种形式:二进制项(binary crate)和库(library crate)。二进制项可以被编译为可执行程序,比如一个命令行程序或者一个服务器。它们必须有一个 main 函数来定义当程序被执行的时候所需要做的事情。库并没有 main 函数,它们也不会编译为可执行程序,它们提供一些诸如函数之类的东西,使其他项目也能使用这些东西。

package是提供一系列功能的一个或者多个crate,可以理解为整个项目,它包含有独立的 Cargo.toml 文件,并且包含至多一个库类型的crate(library crate),可以包含任意多个二进制crate,而且必须至少包含一个crate(不管是二进制的还是库的)

使用cargo new [packagename]可以创建一个项目:

1
2
3
4
5
6
7
$ cargo new my-project
Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs

运行了这条命令后,Cargo会给我们的包创建一个Cargo.toml文件,Cargo遵循的一个约定是src/main.rs就是一个与package同名的二进制cratecrate根。同样的,Cargo知道如果包目录中包含src/lib.rs,则package带有与其同名的库 crate,且src/lib.rscrate根。crate根文件将由Cargo传递给rustc来实际构建库或者二进制项目。默认情况下,创建的都是二进制的crate,想要创建lib类型的项目可以使用参数--lib

1
$ cargo new my-lib --lib

如果一个包同时含有src/main.rssrc/lib.rs,则它有两个crate,一个二进制的和一个库的,且名字都与包相同。通过将文件放在src/bin目录下,一个包可以拥有多个二进制crate:每个src/bin下的文件都会被编译成一个独立的二进制crate

使用cargo new创建的项目中,packagecrate的名字是相同的,因此有时可能容易混淆。

11.3 典型的package结构

一个真实项目中典型的 Package,会包含多个二进制包,这些包文件被放在 src/bin 目录下,每一个文件都是独立的二进制包,同时也会包含一个库包src/lib.rs,该包只能存在一个 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.
├── Cargo.toml
├── Cargo.lock
├── src
│ ├── main.rs
│ ├── lib.rs
│ └── bin
│ └── main1.rs
│ └── main2.rs
├── tests
│ └── some_integration_tests.rs
├── benches
│ └── simple_bench.rs
└── examples
└── simple_example.rs
  • 唯一库包:src/lib.rs
  • 默认二进制包:src/main.rs,编译后生成的可执行文件与 Package 同名
  • 其余二进制包:src/bin/main1.rssrc/bin/main2.rs,它们会分别生成一个文件同名的二进制可执行文件
  • 集成测试文件:tests 目录下
  • 基准性能测试 benchmark 文件:benches 目录下
  • 项目示例:examples 目录下

大多数真实的rust项目与上述结构类似。

理解了包的概念,下面一节我们将讨论模块和其它一些关于模块系统的部分。

11.4 module

模块module是构成crate的基本单元,它让我们可以将一个crate中的代码进行分组,以提高可读性与重用性。因为一个模块中的代码默认是私有的,所以还可以利用模块控制项的 私有性。私有项是不可为外部使用的内在详细实现。我们也可以将模块和它其中的项标记为公开的,这样,外部代码就可以使用并依赖与它们。

在餐饮业,餐馆中会有一些地方被称之为前台(front of house),还有另外一些地方被称之为后台(back of house)。前台是招待顾客的地方,在这里,店主可以为顾客安排座位,服务员接受顾客下单和付款,调酒师会制作饮品。后台则是由厨师工作的厨房,洗碗工的工作地点,以及经理做行政工作的地方组成。

我们可以将函数放置到嵌套的模块中,来使我们的crate结构与实际的餐厅结构相同。通过执行 cargo new --lib restaurant,来创建一个新的名为 restaurant 的库。然后将下面的代码放入src/lib.rs中,来定义一些模块和函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mod front_of_house {
mod hosting {
fn add_to_waitlist() {}

fn seat_at_table() {}
}

mod serving {
fn take_order() {}

fn serve_order() {}

fn take_payment() {}
}
}

我们定义一个模块,是以 mod 关键字为起始,然后指定模块的名字(本例中叫做 front_of_house),并且用花括号包围模块的主体。在模块内,我们还可以定义其他的模块,就像本例中的 hostingserving 模块。模块还可以保存一些定义的其他项,比如结构体、枚举、常量、特性、或者函数。

通过使用模块,我们可以将相关的定义分组到一起,并指出他们为什么相关。程序员可以通过使用这段代码,更加容易地找到他们想要的定义,因为他们可以基于分组来对代码进行导航,而不需要阅读所有的定义。程序员向这段代码中添加一个新的功能时,他们也会知道代码应该放置在何处,可以保持程序的组织性。

在前面提到,src/main.rssrc/lib.rs叫做crate根。之所以这样叫它们是因为这两个文件的内容都分别在crate模块结构的根组成了一个名为crate的模块,该结构被称为模块树(module tree)。

1
2
3
4
5
6
7
8
9
crate
└── front_of_house
├── hosting
│ ├── add_to_waitlist
│ └── seat_at_table
└── serving
├── take_order
├── serve_order
└── take_payment

这个树展示了一些模块是如何被嵌入到另一个模块的(例如,hosting 嵌套在 front_of_house 中)。这个树还展示了一些模块是互为兄弟(siblings)的,这意味着它们定义在同一模块中(hostingserving 被一起定义在 front_of_house 中)。继续沿用家庭关系的比喻,如果一个模块 A 被包含在模块 B 中,我们将模块 A 称为模块 B 的子(child),模块 B 则是模块 A 的父(parent)。注意,整个模块树都植根于名为 crate 的隐式模块下。

这有点像操作系统中用来管理文件的文件树,你可以使用模块来组织你的代码。并且,就像目录中的文件,我们需要一种方法来找到模块。

11.5 引用模块项目的路径

来看一下rust如何在模块树中找到一个项的位置,我们使用路径的方式,就像在文件系统使用路径一样。为了调用一个函数,我们需要知道它的路径。

路径有两种形式:

  • 绝对路径(absolute path)是以crate根(root)开头的全路径;对于外部crate的代码,是以crate名开头的绝对路径,对于对于当前crate的代码,则以字面值 crate 开头。
  • 相对路径(relative path)从当前模块开始,以 selfsuper 或当前模块的标识符开头。

绝对路径和相对路径都后跟一个或多个由双冒号(::)分割的标识符。

假设希望调用 add_to_waitlist 函数,在crate根定义了一个新函数 eat_at_restaurant,其中使用两种方法方式调用。在src/lib.rs

1
2
3
4
5
6
7
pub fn eat_at_restaurant() {
// 绝对路径
crate::front_of_house::hosting::add_to_waitlist();

// 相对路径
front_of_house::hosting::add_to_waitlist();
}

第一种方式,我们在 eat_at_restaurant 中调用 add_to_waitlist 函数,使用的是绝对路径。add_to_waitlist 函数与 eat_at_restaurant 被定义在同一 crate中,这意味着我们可以使用 crate 关键字为起始的绝对路径。

crate 后面,我们持续地嵌入模块,直到我们找到 add_to_waitlist。你可以想象出一个相同结构的文件系统,我们通过指定路径 /front_of_house/hosting/add_to_waitlist 来执行 add_to_waitlist 程序。我们使用 cratecrate根开始就类似于在 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 的模块中,我们需要更新 add_to_waitlist 的绝对路径,但是相对路径还是可用的。然而,如果我们要将 eat_at_restaurant 函数单独移到一个名为 dining 的模块中,还是可以使用原本的绝对路径来调用 add_to_waitlist,但是相对路径必须要更新。我们更倾向于使用绝对路径,因为把代码定义和项调用各自独立地移动是更常见的。

下面尝试使用cargo build编译这个项目,会发现无法通过编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  --> src\lib.rs:34:28
|
34 | crate::front_of_house::hosting::add_to_waitlist();
| ^^^^^^^ private module
|
note: the module `hosting` is defined here
--> src\lib.rs:17:5
|
17 | mod hosting {
| ^^^^^^^^^^^

error[E0603]: module `hosting` is private
--> src\lib.rs:37:21
|
37 | front_of_house::hosting::add_to_waitlist();
| ^^^^^^^ private module
|
note: the module `hosting` is defined here
--> src\lib.rs:17:5
|
17 | mod hosting {
| ^^^^^^^^^^^

错误信息提示 hosting 模块是私有的。换句话说,我们填写了正确的路径,但rust不允许使用,因为它不能访问私有片段。在rust中,默认所有项(函数、方法、结构体、枚举、模块和常量)对父模块都是私有的。如果希望创建一个私有函数或结构体,你可以将其放入一个模块。

父模块中的项不能使用子模块中的私有项,但是子模块中的项可以使用他们父模块中的项。这是因为子模块封装并隐藏了他们的实现详情,但是子模块可以看到他们定义的上下文。继续拿餐馆作比喻,把私有性规则想象成餐馆的后台办公室:餐馆内的事务对餐厅顾客来说是不可知的,但办公室经理可以洞悉其经营的餐厅并在其中做任何事情。

rust选择以这种方式来实现模块系统功能,因此默认隐藏内部实现细节。这样一来,你就知道可以更改内部代码的哪些部分而不会破坏外部代码。不过rust也提供了通过使用 pub 关键字来创建公共项,使子模块的内部部分暴露给上级模块。

11.6 使用pub暴露路径

之前的例子中,hosting 模块是私有的,我们想让父模块中的 eat_at_restaurant 函数可以访问子模块中的 add_to_waitlist 函数,因此我们使用 pub 关键字来标记 hosting 模块:

1
2
3
4
5
6
7
mod front_of_house {
pub mod hosting {
fn add_to_waitlist() {}

fn seat_at_table() {}
}
}

不过这还不够,在 mod hosting 前添加了 pub 关键字,使其变成公有的。伴随着这种变化,如果我们可以访问 front_of_house,那我们也可以访问 hosting。但是 hosting 的内容(contents)仍然是私有的;这表明使模块公有并不使其内容也是公有的。模块上的 pub 关键字只允许其父模块引用它,而不允许访问内部代码。因为模块是一个容器,只是将模块变为公有能做的其实并不太多;同时需要更深入地选择将一个或多个项变为公有。

私有性规则不但应用于模块,还应用于结构体、枚举、函数和方法。因此,还需要把函数标记为公有:

1
2
3
4
5
6
7
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}

pub fn seat_at_table() {}
}
}

现在代码就可以编译通过了。

11.7 使用super起始的相对路径

我们还可以使用 super 而不是当前模块或者crate根来开头来构建从父模块开始的相对路径。这么做类似于文件系统中以 .. 开头的语法。使用 super 允许我们引用已知的父模块中的项,当模块与父模块关联的很紧密的时候,如果某天可能需要父模块要移动到模块树的其它位置,这使得重新组织模块树变得更容易。

1
2
3
4
5
6
7
8
9
10
fn deliver_order() {}

mod back_of_house {
fn fix_incorrect_order() {
cook_order();
super::deliver_order();
}

fn cook_order() {}
}

考虑上述代码,它模拟了厨师更正了一个错误订单,并亲自将其提供给客户的情况。back_of_house 模块中的定义的 fix_incorrect_order 函数通过指定的 super 起始的 serve_order 路径,来调用父模块中的 deliver_order 函数。

fix_incorrect_order 函数在 back_of_house 模块中,所以我们可以使用 super 进入 back_of_house 父模块,也就是本例中的 crate 根。在这里,我们可以找到 deliver_order。我们认为 back_of_house 模块和 deliver_order 函数之间可能具有某种关联关系,并且,如果我们要重新组织这个crate的模块树,需要一起移动它们。因此,我们使用 super,这样一来,如果这些代码被移动到了其他模块,我们只需要更新很少的代码。

11.8 控制结构体和枚举的公有

我们还可以使用 pub 来设计公有的结构体和枚举,不过关于在结构体和枚举上使用 pub 还有一些额外的细节需要注意。如果我们在一个结构体定义的前面使用了 pub ,这个结构体会变成公有的,但是这个结构体的字段仍然是私有的。我们可以根据情况决定每个字段是否公有。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
mod back_of_house {
pub struct Breakfast {
pub toast: String,
seasonal_fruit: String,
}

impl Breakfast {
pub fn summer(toast: &str) -> Breakfast {
Breakfast {
toast: String::from(toast),
seasonal_fruit: String::from("peaches"),
}
}
}
}

pub fn eat_at_restaurant() {
// 在夏天订购一个黑麦土司作为早餐
let mut meal = back_of_house::Breakfast::summer("Rye");
// 改变主意更换想要面包的类型
meal.toast = String::from("Wheat");
println!("I'd like {} toast please", meal.toast);

// 如果取消下一行的注释代码不能编译;
// 不允许查看或修改早餐附带的季节水果
// meal.seasonal_fruit = String::from("blueberries");
}

我们定义了一个公有结构体 back_of_house:Breakfast,其中有一个公有字段 toast 和私有字段 seasonal_fruit。这个例子模拟的情况是,在一家餐馆中,顾客可以选择随餐附赠的面包类型,但是厨师会根据季节和库存情况来决定随餐搭配的水果。餐馆可用的水果变化是很快的,所以顾客不能选择水果,甚至无法看到他们将会得到什么水果。

因为 back_of_house::Breakfast 结构体的 toast 字段是公有的,所以我们可以在 eat_at_restaurant 中使用点号来随意的读写 toast 字段。注意,我们不能在 eat_at_restaurant 中使用 seasonal_fruit 字段,因为 seasonal_fruit 是私有的。尝试去除那一行修改 seasonal_fruit 字段值的代码的注释,看看会发生什么错误。

还需要注意一点,因为 back_of_house::Breakfast 具有私有字段,所以这个结构体需要提供一个公共的关联函数来构造 Breakfast 的实例 (这里我们命名为 summer)。如果 Breakfast 没有这样的函数,我们将无法在 eat_at_restaurant 中创建 Breakfast 实例,因为我们不能在 eat_at_restaurant 中设置私有字段 seasonal_fruit 的值。

与此不同的是,如果我们将枚举设为公有,则它的所有成员都将变为公有。我们只需要在 enum 关键字前面加上 pub

1
2
3
4
5
6
7
8
9
10
11
mod back_of_house {
pub enum Appetizer {
Soup,
Salad,
}
}

pub fn eat_at_restaurant() {
let order1 = back_of_house::Appetizer::Soup;
let order2 = back_of_house::Appetizer::Salad;
}

因为我们创建了名为 Appetizer 的公有枚举,所以我们可以在 eat_at_restaurant 中使用 SoupSalad 成员。

如果枚举成员不是公有的,那么枚举会显得用处不大;给枚举的所有成员挨个添加 pub 是很令人恼火的,因此枚举成员默认就是公有的。结构体通常使用时,不必将它们的字段公有化,因此结构体遵循常规,内容全部是私有的,除非使用 pub 关键字。

还有一种使用 pub 的场景我们还没有涉及到,那就是我们最后要讲的模块功能:use 关键字。我们将先单独介绍 use,然后展示如何结合使用 pubuse

11.9 使用use

无论是绝对路径还是相对路径,当每次想要调用某个函数时,都必须写明它的上面层级,这无疑是非常麻烦的。有一种方法可以简化这个过程。就类似于C++的using关键字一样,我们可以使用 use 关键字创建一个短路径,然后就可以在作用域中的任何地方使用这个更短的名字。

1
2
3
4
5
6
7
8
9
10
11
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}

use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}

我们使用usecrate::front_of_house::hosting 模块引入了 eat_at_restaurant 函数的作用域,而我们只需要指定 hosting::add_to_waitlist 即可在 eat_at_restaurant 中调用 add_to_waitlist 函数。同其它路径一样通过 use 引入作用域的路径也会检查私有性。

注意 use 只能创建 use 所在的特定作用域内的短路径。下面的代码将 eat_at_restaurant 函数移动到了一个叫 customer 的子模块,这又是一个不同于 use 语句的作用域,所以函数体不能编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}

use crate::front_of_house::hosting;

mod customer {
pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}
}

为了修复这个问题,可以将 use 移动到 customer 模块内,或者在子模块 customer 内通过 super::hosting 引用父模块中的这个短路径。

11.10 use使用惯例

看下面两种引用:

1
2
use crate::front_of_house::hosting;
use crate::front_of_house::hosting::add_to_waitlist;

这两种引用都可达到使用add_to_waitlist的效果。不过,前者是更加通用的做法,要想使用 use 将函数的父模块引入作用域,我们必须在调用函数时指定父模块,这样可以清晰地表明函数不是在本地定义的,同时使完整路径的重复度最小化;而后者则不清楚 add_to_waitlist 是在哪里被定义的。

如果想使用 use 语句将两个具有相同名称的项带入作用域,则需要引用父模块来区分它们:

1
2
3
4
5
6
7
8
9
10
use std::fmt;
use std::io;

fn function1() -> fmt::Result {
// --snip--
}

fn function2() -> io::Result<()> {
// --snip--
}

如果我们是指定 use std::fmt::Resultuse std::io::Result,我们将在同一作用域拥有了两个 Result 类型,当我们使用 Result 时,rust则不知道我们要用的是哪个。

当然,使用 use 将两个同名类型引入同一作用域这个问题还有另一个解决办法:在这个类型的路径后面使用 as 指定一个新的本地名称或者别名:

1
2
3
4
5
6
7
8
9
10
use std::fmt::Result;
use std::io::Result as IoResult;

fn function1() -> Result {
// --snip--
}

fn function2() -> IoResult<()> {
// --snip--
}

在第二个 use 语句中,我们选择 IoResult 作为 std::io::Result 的新名称,它与从 std::fmt 引入作用域的 Result 并不冲突。

11.11 重导出

使用 use 关键字,将某个名称导入当前作用域后,这个名称在此作用域中就可以使用了,但它对此作用域之外还是私有的。如果想让其他人调用我们的代码时,也能够正常使用这个名称,就好像它本来就在当前作用域一样,那我们可以将 pubuse 合起来使用。这种技术被称为重导出(re-exporting):我们不仅将一个名称导入了当前作用域,还允许别人把它导入他们自己的作用域。

1
2
3
4
5
6
7
8
9
10
11
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}

在这个修改之前,外部代码需要使用路径 restaurant::front_of_house::hosting::add_to_waitlist() 来调用 add_to_waitlist 函数。现在这个 pub use 从根模块重导出了 hosting 模块,外部代码现在可以使用路径 restaurant::hosting::add_to_waitlist

11.12 嵌套路径来消除大量的use

当需要引入很多定义于相同包或相同模块的项时,为每一项单独列出一行会占用源码很大的空间。

1
2
use std::cmp::Ordering;
use std::io;

相反,我们可以使用嵌套路径将相同的项在一行中引入作用域。这么做需要指定路径的相同部分,接着是两个冒号,接着是大括号中的各自不同的路径部分:

1
use std::{cmp::Ordering, io};

在较大的程序中,使用嵌套路径从相同包或模块中引入很多项,可以显著减少所需的独立 use 语句的数量。我们可以在路径的任何层级使用嵌套路径,这在组合两个共享子路径的 use 语句时非常有用,比如:

1
2
use std::io;
use std::io::Write;

两个路径的相同部分是 std::io,这正是第一个路径。为了在一行 use 语句中引入这两个路径,可以在嵌套路径中使用 self

1
use std::io::{self, Write};

这一行便将 std::iostd::io::Write 同时引入作用域。

如果希望将一个路径下所有公有项引入作用域,可以指定路径后跟 *,即glob运算符:

1
use std::collections::*;

这个 use 语句将 std::collections 中定义的所有公有项引入当前作用域。

使用glob运算符时请多加小心,这会使得我们难以推导作用域中有什么名称和它们是在何处定义的。

11.13 受限可见性

对于某些情况,可能希望对于某些特定的模块可见,但是对于其他模块又不可见:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pub mod a {
pub const I: i32 = 3;

fn semisecret(x: i32) -> i32 {
use self::b::c::J;
x + J
}

pub fn bar(z: i32) -> i32 {
semisecret(I) * z
}
pub fn foo(y: i32) -> i32 {
semisecret(I) + y
}

mod b {
mod c {
const J: i32 = 4;
}
}
}

我们只希望a 导出 Ibarfoo,但是上述代码会报错,J位于子模块中,对于父模块是不可见的。如果不改变代码的形式,同时满足要求,就需要限制可见性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pub mod a {
pub const I: i32 = 3;

fn semisecret(x: i32) -> i32 {
use self::b::c::J;
x + J
}

pub fn bar(z: i32) -> i32 {
semisecret(I) * z
}
pub fn foo(y: i32) -> i32 {
semisecret(I) + y
}

mod b {
pub(in crate::a) mod c {
pub(in crate::a) const J: i32 = 4;
}
}
}

b中,使用pub(in crate::a) 的方式,我们指定了模块 c 和常量 J 是公有的,但它们的可见范围都只在a模块中,a 之外的模块完全访问不到它们。

这就是受限可见性,通常有如下几种使用方法:

  • pub 意味着可见性无任何限制
  • pub(crate) 表示在当前包可见
  • pub(self) 在当前模块可见
  • pub(super) 在父模块可见
  • pub(in <path>) 表示在某个路径代表的模块中可见,其中 path 必须是父模块或者祖先模块

它们在实际项目中会非常有用。

11.14 将模块拆分成多个文件

到目前为止,本章所有的例子都在一个文件中定义多个模块。当模块变得更大时,你可能想要将它们的定义移动到单独的文件中,从而使代码更容易阅读。

我们使用在重导出中的代码作为开始。

文件名:src/lib.rs

1
2
3
4
5
6
7
8
9
10
11
mod front_of_house {
pub mod hosting {
pub fn add_to_waitlist() {}
}
}

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}

我们会将模块提取到各自的文件中,而不是将所有模块都定义到crate根文件中。在这里,crate根文件是src/lib.rs,不过这个过程也适用于crate根文件是src/main.rs的二进制crate

首先将 front_of_house 模块提取到其自己的文件中。删除 front_of_house 模块的大括号中的代码,只留下 mod front_of_house; 声明。

文件名:src/lib.rs

1
2
3
4
5
6
7
mod front_of_house;

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
hosting::add_to_waitlist();
}

接下来将之前大括号内的代码放入一个名叫src/front_of_house.rs的新文件中。因为编译器找到了crate根中名叫 front_of_house 的模块声明,它就知道去搜寻这个文件。

文件名:src/front_of_house.rs

1
2
3
pub mod hosting {
pub fn add_to_waitlist() {}
}

注意你只需在模块树中的某处使用一次 mod 声明就可以加载这个文件。一旦编译器知道了这个文件是项目的一部分(并且通过 mod 语句的位置知道了代码在模块树中的位置),项目中的其它文件应该使用其所声明的位置的路径来引用那个文件的代码。换句话说,mod 并不是某些编程语言中看到的“include“操作。

接下来我们同样将 hosting 模块提取到自己的文件中。这个过程会有所不同,因为 hostingfront_of_house 的子模块而不是根模块。我们将 hosting 的文件放在与模块树中它的父级模块同名的目录中,在这里是src/front_of_house/

为了移动 hosting,修改src/front_of_house.rs使之仅包含 hosting 模块的声明。

文件名:src/front_of_house.rs

1
pub mod hosting;

接着我们创建一个src/front_of_house目录和一个包含 hosting 模块定义的hosting.rs文件。

文件名:src/front_of_house/hosting.rs

1
pub fn add_to_waitlist() {}

如果将hosting.rs放在src目录,编译器会认为 hosting 模块中的hosting.rs的代码声明于crate根,而不是声明为 front_of_house 的子模块。编译器所遵循的哪些文件对应哪些模块的代码的规则,意味着目录和文件更接近于模块树。

现在我们将各个模块的代码移动到独立文件了,同时模块树依旧相同。eat_at_restaurant 中的函数调用也无需修改继续保持有效,即便其定义存在于不同的文件中。这个技巧让你可以在模块代码增长时,将它们移动到新文件中。

注意,src/lib.rs中的 pub use crate::front_of_house::hosting 语句是没有改变的,在文件作为crate的一部分而编译时,use 不会有任何影响。mod 关键字声明了模块,rust会在与模块同名的文件中查找模块的代码。

11.15 代码组织总结

这里我们提供一个简单的参考,用来解释模块、路径、use关键词和pub关键词如何在编译器中工作,以及大部分开发者如何组织他们的代码。

  • crate根节点开始:当编译一个crate, 编译器首先在crate根文件(通常,对于一个库crate而言是src/lib.rs,对于一个二进制crate而言是src/main.rs中寻找需要被编译的代码。
  • 声明模块:在crate根文件中,你可以声明一个新模块;比如,你用mod garden声明了一个叫做garden的模块。编译器会在下列路径中寻找模块代码:
    • 内联,在大括号中,当mod garden后方不是一个分号而是一个大括号
    • 在文件src/garden.rs
    • 在文件src/garden/mod.rs(旧版风格)
  • 声明子模块:在除了crate根节点以外的其他文件中,你可以定义子模块。比如,你可能在src/garden.rs中定义了mod vegetables;。编译器会在以父模块命名的目录中寻找子模块代码:
    • 内联,在大括号中,当mod vegetables后方不是一个分号而是一个大括号
    • 在文件src/garden/vegetables.rs
    • 在文件src/garden/vegetables/mod.rs(旧版风格)
  • 模块中的代码路径:一旦一个模块是你crate的一部分,你可以在隐私规则允许的前提下,从同一个crate内的任意地方,通过代码路径引用该模块的代码。举例而言,一个 garden vegetables 模块下的Asparagus类型可以在crate::garden::vegetables::Asparagus被找到。
  • 私有 vs 公用:一个模块里的代码默认对其父模块私有。为了使一个模块公用,应当在声明时使用pub mod替代mod。为了使一个公用模块内部的成员公用,应当在声明前使用pub
  • use 关键字:在一个作用域内,use关键字创建了一个成员的快捷方式,用来减少长路径的重复。在任何可以引用crate::garden::vegetables::Asparagus的作用域,你可以通过 use crate::garden::vegetables::Asparagus;创建一个快捷方式,然后你就可以在作用域中只写Asparagus来使用该类型。

这里我们创建一个名为backyard的二进制crate来说明这些规则。该crate的路径同样命名为backyard,该路径包含了这些文件和目录:

1
2
3
4
5
6
7
8
backyard
├── Cargo.lock
├── Cargo.toml
└── src
├── garden
│ └── vegetables.rs
├── garden.rs
└── main.rs

这个例子中的crate根文件是src/main.rs,该文件包括了:

文件名:src/main.rs

1
2
3
4
5
6
7
8
use crate::garden::vegetables::Asparagus;

pub mod garden;

fn main() {
let plant = Asparagus {};
println!("I'm growing {:?}!", plant);
}

pub mod garden;告诉编译器应该包含在src/garden.rs文件中发现的代码:

文件名:src/garden.rs

1
pub mod vegetables;

在此处, pub mod vegetables;意味着在src/garden/vegetables.rs中的代码也应该被包括。这些代码是:

1
2
#[derive(Debug)]
pub struct Asparagus {}

12 错误处理

rust中的错误主要分为两类:

  • 可恢复错误,通常用于从系统全局角度来看可以接受的错误,例如处理用户的访问、操作等错误,这些错误只会影响某个用户自身的操作进程,而不会对系统的全局稳定性产生影响
  • 不可恢复错误,刚好相反,该错误通常是全局性或者系统性的错误,例如数组越界访问,系统启动时发生了影响启动流程的错误等等,这些错误的影响往往对于系统来说是致命的

12.1 使用panic!处理不可恢复的错误

想要触发panic一般有两种方式,一种是通过执行会造成panic的代码(比如越界访问某数组),第二种是panic!宏,主动触发一个不可恢复的错误。通常情况下这些panic会打印出一个错误信息,展开并清理栈数据,然后退出。

当出现panic时,程序默认会开始展开(unwinding),这意味着rust会回溯栈并清理它遇到的每一个函数的数据,不过这个回溯并清理的过程有很多工作。另一种选择是直接终止(abort),这会不清理数据就退出程序,那么程序所使用的内存需要由操作系统来清理。如果你需要项目的最终二进制文件越小越好,panic时通过在Cargo.toml[profile] 部分增加 panic = 'abort',可以由展开切换为终止。例如,如果你想要在release模式中panic时直接终止:

1
2
[profile.release]
panic = 'abort'

让我们在一个简单的程序中调用 panic!

1
2
3
4
5
6
7
8
fn main() {
panic!("crash and burn");
}

/* 错误如下
thread 'main' panicked at 'crash and burn', src\main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
*/

在这个例子中,被指明的那一行是我们代码的一部分,而且查看这一行的话就会发现 panic! 宏的调用。在其他情况下,panic! 可能会出现在我们的代码所调用的代码中。错误信息报告的文件名和行号可能指向别人代码中的 panic! 宏调用,而不是我们代码中最终导致 panic! 的那一行。

比如:

1
2
3
4
5
6
7
8
9
fn main() {
let v = vec![1, 2, 3];

v[99];
}
/* 错误如下
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src\main.rs:4:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
*/

越界访问数组毫无疑问会panic,错误信息里的说明(note)行提醒我们可以设置 RUST_BACKTRACE 环境变量来得到一个 backtracebacktrace是一个执行到目前位置所有被调用的函数的列表。rust的backtrace跟其他语言中的一样:阅读backtrace的关键是从头开始读直到发现你编写的文件。这就是问题的发源地,这一行往上是你的代码所调用的代码;往下则是调用你的代码的代码。这些行可能包含核心rust代码,标准库代码或用到的crate代码。

下面就来设置RUST_BACKTRACE=1 并运行程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src\main.rs:4:5
stack backtrace:
0: std::panicking::begin_panic_handler
at /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483/library\std\src\panicking.rs:575
1: core::panicking::panic_fmt
at /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483/library\core\src\panicking.rs:64
2: core::panicking::panic_bounds_check
at /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483/library\core\src\panicking.rs:147
3: core::slice::index::impl$2::index<i32>
at /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483\library\core\src\slice\index.rs:260
4: core::slice::index::impl$0::index
at /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483\library\core\src\slice\index.rs:18
5: alloc::vec::impl$15::index<i32,usize,alloc::alloc::Global>
at /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483\library\alloc\src\vec\mod.rs:2727
6: myproject::main
at .\src\main.rs:4
7: core::ops::function::FnOnce::call_once<void (*)(),tuple$<> >
at /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483\library\core\src\ops\function.rs:507
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

这里有大量的输出。你实际看到的输出可能因不同的操作系统和rust版本而有所不同。为了获取带有这些信息的backtrace,必须启用debug标识。当不使用 --release 参数运行cargo buildcargo rundebug标识会默认启用,就像这里默认启用了一样。

这里,编译器还会提示你如果想获取更详细的输出,可以使用RUST_BACKTRACE=full

12.2 使用Result处理可恢复的错误

大部分错误并没有严重到需要程序完全停止执行。回忆一下在枚举泛型中介绍过的Result,它定义有OkErr

1
2
3
4
enum Result<T, E> {
Ok(T),
Err(E),
}

让我们调用一个返回 Result 的函数的例子:

1
2
3
4
5
use std::fs::File;

fn main() {
let greeting_file_result = File::open("hello.txt");
}

File::open 的返回值是 Result<T, E>。泛型参数 T 会被 File::open 的实现放入成功返回值的类型 std::fs::File,这是一个文件句柄。错误返回值使用的 E 的类型是 std::io::Error。这些返回类型意味着 File::open 调用可能成功并返回一个可以读写的文件句柄。这个函数调用也可能会失败:例如,也许文件不存在,或者可能没有权限访问这个文件。File::open 函数需要一个方法在告诉我们成功与否的同时返回文件句柄或者错误信息。这些信息正好是 Result 枚举所代表的。

File::open 成功时,greeting_file_result 变量将会是一个包含文件句柄的 Ok 实例。当失败时,greeting_file_result 变量将会是一个包含了更多关于发生了何种错误的信息的 Err 实例。

下面就用模式匹配中介绍的方法进行解构:

1
2
3
4
5
6
7
fn main() {
let greeting_file_result = File::open("hello.txt");
let file = match greeting_file_result {
Ok(f) => f,
Err(e) => panic!("Problem opening the file: {:?}", e),
};
}

Option 枚举一样,Result 枚举和其成员也被导入到了prelude中,所以就不需要在 match 分支中的 OkErr 之前指定 Result::了。

这个模式匹配的意思是,当结果是 Ok 时,返回 Ok 成员中的 file 值,然后将这个文件句柄赋值给变量 filematch 之后,我们可以利用这个文件句柄来进行读写;match 的另一个分支处理从 File::open 得到 Err 值的情况。在这种情况下,我们选择调用 panic! 宏。如果当前目录没有一个叫做hello.txt的文件,当运行这段代码时会看到如下来自 panic! 宏的输出:

1
2
thread 'main' panicked at 'Problem opening the file: Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" }', src\main.rs:7:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

匹配不同的错误

当然,你还可以细分错误的类型,如果 File::open 因为文件不存在而失败,我们希望创建这个文件并返回新文件的句柄。如果 File::open 因为任何其他原因失败,例如没有打开文件的权限,我们仍然希望 panic!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use std::fs::File;
use std::io::ErrorKind;

fn main() {
let greeting_file_result = File::open("hello.txt");

let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("Problem creating the file: {:?}", e),
},
other_error => {
panic!("Problem opening the file: {:?}", other_error);
}
},
};
}

File::open 返回的 Err 成员中的值类型 io::Error,它是一个标准库中提供的结构体。这个结构体有一个返回 io::ErrorKind 值的 kind 方法可供调用。io::ErrorKind 是一个标准库提供的枚举,它的成员对应 io 操作可能导致的不同错误类型。我们感兴趣的成员是 ErrorKind::NotFound,它代表尝试打开的文件并不存在。这样,match 就匹配完 greeting_file_result 了,不过对于 error.kind() 还有一个内层 match

我们希望在内层 match 中检查的条件是 error.kind() 的返回值是否为 ErrorKindNotFound 成员。如果是,则尝试通过 File::create 创建文件。然而因为 File::create 也可能会失败,还需要增加一个内层 match 语句。当文件不能被打开,会打印出一个不同的错误信息。外层 match 的最后一个分支保持不变,这样对任何除了文件不存在的错误会使程序panic

这里有很多 matchmatch 确实很强大,不过也非常的繁琐。在后面,我们会介绍闭包,这可以用于很多 Result<T, E> 上定义的方法。在处理代码中的 Result<T, E> 值时这些方法可能会更加简洁。比如逻辑相同但是使用闭包和 unwrap_or_else 方法的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use 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);
}
});
}

这段代码有着相同的行为,但并没有包含任何 match 表达式且更容易阅读。

unwarpexcept

match 能够胜任它的工作,不过它可能有点冗长并且不总是能很好的表明其意图。Result<T, E> 类型定义了很多辅助方法来处理各种情况。其中之一叫做 unwrap,它的实现就类似于上一小节的 match 语句。如果 Result 值是成员 Okunwrap 会返回 Ok 中的值。如果 Result 是成员 Errunwrap 会为我们调用 panic!,比如:

1
2
3
4
5
use std::fs::File;

fn main() {
let greeting_file = File::open("hello.txt").unwrap();
}

如果调用这段代码时不存在hello.txt文件,我们将会看到一个 unwrap 调用 panic! 时提供的错误信息:

1
2
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" }', src\main.rs:4:49
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

还有另一个类似于 unwrap 的方法它还允许我们选择 panic! 的错误信息:expect。使用 expect 而不是 unwrap 并提供一个好的错误信息可以表明你的意图并更易于追踪 panic 的根源。

1
2
3
4
5
6
use std::fs::File;

fn main() {
let greeting_file = File::open("hello.txt")
.expect("hello.txt should be included in this project");
}

expectunwrap 的使用方式一样:返回文件句柄或调用 panic! 宏。expect 在调用 panic! 时使用的错误信息将是我们传递给 expect 的参数,而不像 unwrap 那样使用默认的 panic! 信息。它看起来像这样:

1
2
thread 'main' panicked at 'hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" }', src\main.rs:5:10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

在生产级别的代码中选择 expect 而不是 unwrap 并提供更多关于为何操作期望是一直成功的上下文。如此如果该假设真的被证明是错的,你也有更多的信息来用于调试。

传播错误

当编写一个其实先会调用一些可能会失败的操作的函数时,除了在这个函数中处理错误外,还可以选择让调用者知道这个错误并决定该如何处理。换句话说,在获取到这个错误后并不立即进行处理,而是将其传递到上一层调用者的手里。这称为传播(propagating)错误,这样能更好的控制代码调用,因为比起你代码所拥有的上下文,调用者可能拥有更多信息或逻辑来决定应该如何处理错误。

下面展示了一个从文件中读取用户名的函数。如果文件不存在或不能读取,这个函数会将这些错误返回给调用它的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
let username_file_result = File::open("hello.txt");

let mut username_file = match username_file_result {
Ok(file) => file,
Err(e) => return Err(e),
};

let mut username = String::new();

match username_file.read_to_string(&mut username) {
Ok(_) => Ok(username),
Err(e) => Err(e),
}
}

首先注意函数的返回值:Result<String, io::Error>。这意味着函数返回一个 Result<T, E> 类型的值,其中泛型参数 T 的具体类型是 String,而 E 的具体类型是 io::Error

如果这个函数没有出任何错误成功返回,函数的调用者会收到一个包含 StringOk 值 —— 函数从文件中读取到的用户名。如果函数遇到任何错误,函数的调用者会收到一个 Err 值,它储存了一个包含更多这个问题相关信息的 io::Error 实例。这里选择 io::Error 作为函数的返回值是因为它正好是函数体中那两个可能会失败的操作的错误返回值:File::open 函数和 read_to_string 方法。

函数体以调用 File::open 函数开始。接着使用 match 处理返回值 Result,如果 File::open 成功了,模式变量 file 中的文件句柄就变成了可变变量 username_file 中的值,接着函数继续执行。在 Err 的情况下,我们没有调用 panic!,而是使用 return 关键字提前结束整个函数,并将来自 File::open 的错误值(现在在模式变量 e 中)作为函数的错误值传回给调用者。

所以 username_file 中有了一个文件句柄,函数接着在变量 username 中创建了一个新 String 并调用文件句柄 username_fileread_to_string 方法来将文件的内容读取到 username 中。read_to_string 方法也返回一个 Result 因为它也可能会失败:哪怕是 File::open 已经成功了。所以我们需要另一个 match 来处理这个 Result:如果 read_to_string 成功了,那么这个函数就成功了,并返回文件中的用户名,它现在位于被封装进 Okusername 中。如果read_to_string 失败了,则像之前处理 File::open 的返回值的 match 那样返回错误值。不过并不需要显式的调用 return,因为这是函数的最后一个表达式。

调用这个函数的代码最终会得到一个包含用户名的 Ok 值,或者一个包含 io::ErrorErr 值。我们无从得知调用者会如何处理这些值。例如,如果他们得到了一个 Err 值,他们可能会选择 panic! 并使程序崩溃、使用一个默认的用户名或者从文件之外的地方寻找用户名。我们没有足够的信息知晓调用者具体会如何尝试,所以将所有的成功或失败信息向上传播,让他们选择合适的处理方法。

上述代码在实际的使用中很常用,因此rust提供了? 问号运算符来使其更加简便。

使用?传播错误

使用?来简化上一小节的代码:

1
2
3
4
5
6
7
8
9
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
let mut username_file = File::open("hello.txt")?;
let mut username = String::new();
username_file.read_to_string(&mut username)?;
Ok(username)
}

Result 值之后的 ? 被定义为上一小节中处理 Result 值的 match 表达式相同的工作方式:如果 Result 的值是 Ok,这个表达式将会返回 Ok 中的值而程序将继续执行。如果值是 ErrErr 中的值将作为整个函数的返回值,就好像使用了 return 关键字一样,这样错误值就被传播给了调用者。

上一小节中的match 表达式与 ? 运算符所做的有一点不同:? 运算符所使用的错误值被传递给了 from 函数,它定义于标准库的 From 特征中,其用来将错误从一种类型转换为另一种类型。当 ? 运算符调用 from 函数时,收到的错误类型被转换为由当前函数返回类型所指定的错误类型。这在当函数返回单个错误类型来代表所有可能失败的方式时很有用,即使其可能会因很多种原因失败。例如,我们可以将read_username_from_file函数修改为返回一个自定义的 OurError 错误类型。如果我们也定义了 impl From<io::Error> for OurError 来从 io::Error 构造一个 OurError 实例,那么 read_username_from_file 函数体中的 ? 运算符调用会调用 from 并转换错误而无需在函数中增加任何额外的代码。

File::open 调用结尾的 ? 会将 Ok 中的值返回给变量 username_file。如果发生了错误,? 运算符会使整个函数提前返回并将任何 Err 值返回给调用代码。同理也适用于 read_to_string 调用结尾的 ?

? 运算符消除了大量样板代码并使得函数的实现更简单。我们甚至可以在 ? 之后直接使用链式方法调用来进一步缩短代码:

1
2
3
4
5
6
7
8
9
10
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
let mut username = String::new();

File::open("hello.txt")?.read_to_string(&mut username)?;

Ok(username)
}

我们对 File::open("hello.txt")? 的结果直接链式调用了 read_to_string,而不再创建变量 username_file。仍然需要 read_to_string 调用结尾的 ?,而且当 File::openread_to_string 都成功没有失败时返回包含用户名 usernameOk 值。

当然,还有一个更简单的版本:

1
2
3
4
5
6
use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
fs::read_to_string("hello.txt")
}

将文件读取到一个字符串是相当常见的操作,所以rust提供了名为 fs::read_to_string 的函数,它会打开文件、新建一个 String、读取文件的内容,并将内容放入 String,接着返回它。

使用?的位置

? 运算符只能被用于返回值与 ? 作用的值相兼容的函数。因为 ? 运算符被定义为从函数中提早返回一个值,让我们看看在返回值不兼容的 main 函数中使用 ? 运算符会得到什么错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::fs::File;

fn main() {
let greeting_file = File::open("hello.txt")?;
}
/* 错误如下
|
3 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
4 | let greeting_file = File::open("hello.txt")?;
| ^ cannot use the `?` operator in a function that returns `()`
|
= help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`
*/

这段代码打开一个文件,这可能会失败。? 运算符作用于 File::open 返回的 Result 值,不过 main 函数的返回类型是 () 而不是 Result。这个错误指出只能在返回 Result 或者其它实现了 FromResidual 的类型的函数中使用 ? 运算符。

为了修复这个错误,有两个选择。一个是如果没有限制的话将函数的返回值改为 Result<T, E>。另一个是使用 matchResult<T, E> 的方法中合适的一个来处理 Result<T, E>

错误信息也提到 ? 也可用于 Option<T> 值。如同对 Result 使用 ? 一样,只能在返回 Option 的函数中对 Option 使用 ?。在 Option<T> 上调用 ? 运算符的行为与 Result<T, E> 类似:如果值是 None,此时 None 会从函数中提前返回。如果值是 SomeSome 中的值作为表达式的返回值同时函数继续。比如:

1
2
3
fn last_char_of_first_line(text: &str) -> Option<char> {
text.lines().next()?.chars().last()
}

上面的例子是一个从给定文本中返回第一行最后一个字符的函数。

这个函数返回 Option<char> 因为它可能会在这个位置找到一个字符,也可能没有字符。这段代码获取 text 字符串切片作为参数并调用其 lines 方法,这会返回一个字符串中每一行的迭代器。因为函数希望检查第一行,所以调用了迭代器 next 来获取迭代器中第一个值。如果 text 是空字符串,next 调用会返回 None,此时我们可以使用 ? 来停止并从 last_char_of_first_line 返回 None。如果 text 不是空字符串,next 会返回一个包含 text 中第一行的字符串sliceSome 值。

? 会提取这个字符串切片,然后可以在字符串切片上调用 chars 来获取字符的迭代器。我们感兴趣的是第一行的最后一个字符,所以可以调用 last 来返回迭代器的最后一项。这是一个 Option,因为有可能第一行是一个空字符串,例如 text 以一个空行开头而后面的行有文本,像是 "\nhi"。不过,如果第一行有最后一个字符,它会返回在一个 Some 成员中。? 运算符作用于其中给了我们一个简洁的表达这种逻辑的方式。如果我们不能在 Option 上使用 ? 运算符,则不得不使用更多的方法调用或者 match 表达式来实现这些逻辑。

注意你可以在返回 Result 的函数中对 Result 使用 ? 运算符,可以在返回 Option 的函数中对 Option 使用 ? 运算符,但是不可以混合搭配。? 运算符不会自动将 Result 转化为 Option,反之亦然;在这些情况下,可以使用类似 Resultok 方法或者 Optionok_or 方法来显式转换。

幸运的是 main 函数也可以返回 Result<(), E>,下面的代码修改了 main 的返回值为 Result<(), Box<dyn Error>> 并在结尾增加了一个 Ok(()) 作为返回值。可以通过编译:

1
2
3
4
5
6
7
8
use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
let greeting_file = File::open("hello.txt")?;

Ok(())
}

Box<dyn Error> 类型是一个特征对象,通道式涉及到智能指针的相关内容,我们会在后续介绍。目前可以将 Box<dyn Error> 理解为 “任何类型的错误”。在返回 Box<dyn Error> 错误类型 main 函数中对 Result 使用 ? 是允许的,因为它允许任何 Err 值提前返回。即便 main 函数体从来只会返回 std::io::Error 错误类型,通过指定 Box<dyn Error>,这个签名也仍是正确的,甚至当 main 函数体中增加更多返回其他错误类型的代码时也是如此。

main 函数返回 Result<(), E>,如果 main 返回 Ok(()) 可执行程序会以 0 值退出,而如果 main 返回 Err 值则会以非零值退出;成功退出的程序会返回整数 0,运行错误的程序会返回非 0 的整数。rust也会从二进制程序中返回与这个惯例相兼容的整数。

main 函数也可以返回任何实现了 std::process::Termination 特征的类型,它包含了一个返回 ExitCodereport 函数。请查阅标准库文档了解更多为自定义类型实现 Termination 特征的细节。

12.3 何时使用panic!

那么,该如何决定何时应该 panic! 以及何时应该返回 Result 呢?如果代码panic,就没有恢复的可能。你可以选择对任何错误场景都调用 panic!,不管是否有可能恢复,不过这样就是你代替调用者决定了这是不可恢复的。选择返回 Result 值的话,就将选择权交给了调用者,而不是代替他们做出决定。调用者可能会选择以符合他们场景的方式尝试恢复,或者也可能干脆就认为 Err 是不可恢复的,所以他们也可能会调用 panic! 并将可恢复的错误变成了不可恢复的错误。因此返回 Result 是定义可能会失败的函数的一个好的默认选择。

下面总结了一个通用指导原则:

在当有可能会导致有害状态的情况下建议使用 panic! —— 在这里,有害状态是指当一些假设、保证、协议或不可变性被打破的状态,例如无效的值、自相矛盾的值或者被传递了不存在的值 —— 外加如下几种情况:

  • 有害状态是非预期的行为,与偶尔会发生的行为相对,比如用户输入了错误格式的数据。
  • 在此之后代码的运行依赖于不处于这种有害状态,而不是在每一步都检查是否有问题。
  • 没有可行的手段来将有害状态信息编码进所使用的类型中的情况。

如果别人调用你的代码并传递了一个没有意义的值,尽最大可能返回一个错误,如此库的用户就可以决定在这种情况下该如何处理。然而在继续执行代码是不安全或有害的情况下,最好的选择可能是调用 panic! 并警告库的用户他们的代码中有 bug,这样他们就会在开发时进行修复。类似的,如果你正在调用不受你控制的外部代码,并且它返回了一个你无法修复的无效状态,那么 panic! 往往是合适的。

然而当错误预期会出现时,返回 Result 仍要比调用 panic! 更为合适。这样的例子包括解析器接收到格式错误的数据,或者 HTTP 请求返回了一个表明触发了限流的状态。在这些例子中,应该通过返回 Result 来表明失败预期是可能的,这样将有害状态向上传播,调用者就可以决定该如何处理这个问题。使用 panic! 来处理这些情况就不是最好的选择。

当你的代码在进行一个使用无效值进行调用时可能将用户置于风险中的操作时,代码应该首先验证值是有效的,并在其无效时 panic!。这主要是出于安全的原因:尝试操作无效数据会暴露代码漏洞,这就是标准库在尝试越界访问数组时会 panic! 的主要原因:尝试访问不属于当前数据结构的内存是一个常见的安全隐患。函数通常都遵循契约(contracts):他们的行为只有在输入满足特定条件时才能得到保证。当违反契约时panic是有道理的,因为这通常代表调用方的bug,而且这也不是那种你希望所调用的代码必须处理的错误。事实上所调用的代码也没有合理的方式来恢复,而是需要调用方的程序员修复其代码。函数的契约,尤其是当违反它会造成panic的契约,应该在函数的 API 文档中得到解释。

虽然在所有函数中都拥有许多错误检查是冗长而烦人的。幸运的是,可以利用rust的类型系统(以及编译器的类型检查)为你进行很多检查。如果函数有一个特定类型的参数,可以在知晓编译器已经确保其拥有一个有效值的前提下进行你的代码逻辑。例如,如果你使用了一个并不是 Option 的类型,则程序期望它是有值的并且不是空值。你的代码无需处理 SomeNone 这两种情况,它只会有一种情况就是绝对会有一个值。尝试向函数传递空值的代码甚至根本不能编译,所以你的函数在运行时没有必要判空。另外一个例子是使用像 u32 这样的无符号整型,也会确保它永远不为负。

总之rust的错误处理功能被设计为帮助你编写更加健壮的代码。panic! 宏代表一个程序无法处理的状态,并停止执行而不是使用无效或不正确的值继续处理。rust类型系统的 Result 枚举代表操作可能会在一种可以恢复的情况下失败。可以使用 Result 来告诉代码调用者他需要处理潜在的成功或失败。在适当的场景使用 panic!Result 将会使你的代码在面对不可避免的错误时显得更加可靠。

13 生命周期

生命周期用来保证所有的引用都是有效的,它实际上是另一类泛型。一个变量的生命周期在它创建的时候开始,在它销毁的时候结束。

引用小节中我们遗漏了一个重要的细节:rust中的每一个引用都有其生命周期,也就是引用保持有效的作用域。大部分时候生命周期是隐含并可以推断的,正如大部分时候类型也是可以推断的一样。类似于当因为有多种可能类型的时候必须注明类型,也会出现引用的生命周期以一些不同方式相关联的情况,所以rust需要我们使用泛型生命周期参数来注明他们的关系,这样就能确保运行时实际使用的引用绝对是有效的。

13.1 悬垂引用

生命周期的主要目标是避免悬垂引用(dangling references):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fn main() {
let r;

{
let x = 5;
r = &x;
}

println!("r: {}", r);
}
/* 错误如下:
|
6 | r = &x;
| ^^ borrowed value does not live long enough
7 | }
| - `x` dropped here while still borrowed
8 |
9 | println!("r: {}", r);
| - borrow later used here
*/

上面的代码中:

  • 看似使用let r声明了没有初始值的变量,这些变量存在于外部作用域。这乍看之下好像和rust不允许存在空值相冲突。然而如果尝试在给它一个值之前使用这个变量,会出现一个编译时错误,这就说明了rust确实不允许空值。
  • 在内部作用域中,我们尝试将 r 的值设置为一个 x 的引用。接着在内部作用域结束后,尝试打印出 r 的值。这段代码不能编译因为 r 引用的值在尝试使用之前就离开了作用域。
  • 此处 r 就是一个悬垂指针,它引用了提前被释放的变量 x,在错误信息中也可以看到borrowed value does not live long enough的提示。告诉我们变量 x 并没有 “存在的足够久”。

作用域越大我们就说它 “存在的越久”。如果rust允许这段代码工作,r 将会引用在 x 离开作用域时被释放的内存,这时尝试对 r 做任何操作都是未知的。rust是如何决定这段代码是不被允许的呢?这得益于借用检查器。

13.2 借用检查器

rust编译器有一个借用检查器(borrow checker),它用来确保所有的借用都是有效的:

1
2
3
4
5
6
7
8
9
10
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {}", r); // |
} // ---------+

这里将 r 的生命周期标记为 'a 并将 x 的生命周期标记为 'b。如你所见,内部的 'b 块要比外部的生命周期 'a 小得多。在编译时,rust比较这两个生命周期的大小,并发现 r 拥有生命周期 'a,不过它引用了一个拥有生命周期 'b 的对象。程序被拒绝编译,因为生命周期 'b 比生命周期 'a 要小:被引用的对象比它的引用者存在的时间更短。

下面我们尝试修复它:

1
2
3
4
5
6
7
8
fn main() {
let x = 5; // ----------+-- 'b
// |
let r = &x; // --+-- 'a |
// | |
println!("r: {}", r); // | |
// --+ |
} // ----------+

这里 x 拥有生命周期 'b,比 'a 要大。这就意味着 r 可以引用 x:rust知道 r 中的引用在 x 有效的时候也总是有效的。或者说,从r被创建到销毁,都可以保证它的引用是有效的。

接下来让我们看看在函数的上下文中参数和返回值的泛型生命周期。

13.3 函数中的泛型生命周期

首先来编写一个返回两个字符串切片中较长者的函数。这个函数获取两个字符串切片并返回一个字符串切片。

1
2
3
4
5
6
7
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";

let result = longest(string1.as_str(), string2);
println!("The longest string is {}", result);
}

注意这个函数获取作为引用的字符串切片,而不是字符串,因为我们不希望 longest 函数获取参数的所有权。

下面就是实现这个longest函数,一旦我们实现了该函数,运行应该会打印出 The longest string is abcd

1
2
3
4
5
6
7
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}

可惜这并不能编译通过,你会收到如下错误:

1
2
3
4
5
6
7
8
9
  |
9 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++

提示返回值需要一个泛型生命周期参数,这是因为在函数内无法确定到底返回x的引用还是y的引用。当我们定义这个函数的时候,并不知道传递给函数的具体值,所以也不知道到底是 if 还是 else 会被执行。我们也不知道传入的引用的具体生命周期,所以也不能通过观察作用域来确定返回的引用是否总是有效,借用检查器自身同样也无法确定,因为它不知道 xy 的生命周期是如何与返回值的生命周期相关联的。为了修复这个错误,我们将增加泛型生命周期参数来定义引用间的关系以便借用检查器可以进行分析。也就是说,需要我们手动地为编译器标注出生命周期。

13.4 生命周期注解语法

生命周期注解并不改变任何引用的生命周期的长短。相反它们描述了多个引用生命周期相互的关系,而不影响其生命周期。与当函数签名中指定了泛型类型参数后就可以接受任何类型一样,当指定了泛型生命周期后函数也能接受任何生命周期的引用。

生命周期注解有着一个不太常见的语法:生命周期参数名称必须以撇(')开头,其名称通常全是小写,类似于泛型其名称非常短。大多数人使用 'a 作为第一个生命周期注解。生命周期参数注解位于引用的 & 之后,并有一个空格来将引用类型与生命周期注解分隔开。

1
2
3
&i32        // 引用
&'a i32 // 带有显式生命周期的引用
&'a mut i32 // 带有显式生命周期的可变引用

单个的生命周期注解本身没有多少意义,生命周期注解告诉rust编译器多个引用的泛型生命周期参数如何相互联系的。比如,如果函数有一个生命周期 'ai32 的引用的参数 first。还有另一个同样是生命周期 'ai32 的引用的参数 second。这两个生命周期注解意味着引用 firstsecond 必须与这泛型生命周期存在得一样久。

13.5 函数签名中的生命周期注解

为了在函数签名中使用生命周期注解,需要在函数名和参数列表间的尖括号中声明泛型生命周期参数,就像泛型类型参数一样。我们希望函数签名表达如下限制:也就是这两个参数和返回的引用存活的一样久。两个参数和返回的引用的生命周期是相关的。

1
2
3
4
5
6
7
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}

这个longest函数可以通过编译,函数签名表明对于某些生命周期 'a,函数会获取两个参数,他们都是与生命周期 'a 存在的一样长的字符串切片,函数会返回一个同样也与生命周期 'a 存在的一样长的字符串切片。它的实际含义是 longest 函数返回的引用的生命周期与函数参数所引用的值的生命周期的较小者一致。这些关系就是我们希望rust分析代码时所使用的。

通过在函数签名中指定生命周期参数,我们并没有改变任何传入值或返回值的生命周期,而是指出任何不满足这个约束条件的值都将被借用检查器拒绝。注意 longest 函数并不需要知道 xy 具体会存在多久,而只需要知道有某个可以被 'a 替代的作用域将会满足这个签名。

当在函数中使用生命周期注解时,这些注解出现在函数签名中,而不存在于函数体中的任何代码中。生命周期注解成为了函数约定的一部分,非常像签名中的类型。让函数签名包含生命周期约定意味着rust编译器的工作变得更简单了。如果函数注解有误或者调用方法不对,编译器错误可以更准确地指出代码和限制的部分。如果不这么做的话,rust编译会对我们期望的生命周期关系做更多的推断,这样编译器可能只能指出距离出现问题地方很多步之外的代码。

让我们看看如何通过传递拥有不同具体生命周期的引用来限制 longest 函数的使用:

1
2
3
4
5
6
7
8
9
fn main() {
let string1 = String::from("long string is long");

{
let string2 = String::from("xyz");
let result = longest(string1.as_str(), string2.as_str());
println!("The longest string is {}", result);
}
}

在这个例子中,string1 直到外部作用域结束都是有效的,string2 则在内部作用域中是有效的,而 result 则引用了一些直到内部作用域结束都是有效的值。代码能够编译和运行,并打印出 The longest string is long string is long

以下代码将 result 变量的声明移动出内部作用域,但是将 resultstring2 变量的赋值语句一同留在内部作用域中。接着,使用了变量 resultprintln! 也被移动到内部作用域之外。该例子可以看出result 的引用的生命周期必须是两个参数中较短的那个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn main() {
let string1 = String::from("long string is long");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
}
println!("The longest string is {}", result);
}
/* 错误如下:
|
6 | result = longest(string1.as_str(), string2.as_str());
| ^^^^^^^^^^^^^^^^ borrowed value does not live long enough
7 | }
| - `string2` dropped here while still borrowed
8 | println!("The longest string is {}", result);
| ------ borrow later used here
*/

错误表明为了保证 println! 中的 result 是有效的,string2 需要直到外部作用域结束都是有效的。rust知道这些是因为longest函数的参数和返回值都使用了相同的生命周期参数 'a

从人的角度来说这可能有点反直觉,因为很明显string1 更长,longest函数会返回string1的引用,因为 string1 尚未离开作用域,对于 println! 来说 string1 的引用仍然是有效的。然而,我们通过生命周期参数告诉rust的是longest 函数返回的引用的生命周期应该与传入参数的生命周期中较短那个保持一致。因此,借用检查器不允许代码编译通过。

13.6 深入理解生命周期

指定生命周期参数的正确方式依赖函数实现的具体功能。例如,如果将 longest 函数的实现修改为总是返回第一个参数而不是最长的字符串切片,就不需要为参数 y 指定一个生命周期。如下代码将能够编译:

1
2
3
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
x
}

我们为参数 x 和返回值指定了生命周期参数 'a,不过没有为参数 y 指定,因为 y 的生命周期与参数 x 和返回值的生命周期没有任何关系。

当从函数返回一个引用,返回值的生命周期参数需要与一个参数的生命周期参数相匹配。如果返回的引用没有指向任何一个参数,那么唯一的可能就是它指向一个函数内部创建的值。然而它将会是一个悬垂引用,因为它将会在函数结束时离开作用域。比如:

1
2
3
4
fn longest<'a>(x: &str, y: &str) -> &'a str {
let result = String::from("really long string");
result.as_str()
}

这显然无法通过编译,因为即便我们为返回值指定了生命周期参数 'aresultlongest 函数的结尾将离开作用域并被清理,而我们尝试从函数返回一个 result 的引用,但result存活得不够长。

无法通过指定生命周期参数来改变悬垂引用,而且rust也不允许我们创建一个悬垂引用。在这种情况最好的解决方案是返回一个有所有权的数据类型而不是一个引用,这样函数调用者就需要负责清理这个值了。

综上,生命周期语法是用于将函数的多个参数与其返回值的生命周期进行关联的。一旦他们形成了某种关联,rust就有了足够的信息来允许内存安全的操作并阻止会产生悬垂指针亦或是违反内存安全的行为。

13.7 结构体定义中的生命周期注解

在之前结构体的例子中,字段中并没有涉及到引用,而是使用了自身拥有所有权的类型(比如String)。这是因为在结构体中使用引用涉及到生命周期的概念,下面就来详细介绍。

要定义包含引用的结构体,需要为结构体定义中的每一个引用添加生命周期注解。

1
2
3
4
5
6
7
8
9
10
11
struct ImportantExcerpt<'a> {
part: &'a str,
}

fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
let i = ImportantExcerpt {
part: first_sentence,
};
}

这个结构体有唯一一个字段 part,它存放了一个字符串切片,这是一个引用。类似于泛型参数类型,必须在结构体名称后面的尖括号中声明泛型生命周期参数,以便在结构体定义中使用生命周期参数。这个注解意味着 ImportantExcerpt 的实例不能比其 part 字段中的引用存在得更久。

这里的 main 函数创建了一个 ImportantExcerpt 的实例(第8行),它存放了变量 novel 所拥有的 String 的第一个句子的引用。novel 的数据在 ImportantExcerpt 实例创建之前就存在(第6行)。另外,直到 ImportantExcerpt 离开作用域之后 novel 都不会离开作用域,所以 ImportantExcerpt 实例中的引用是有效的。

如果将代码修改一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#[derive(Debug)]
struct ImportantExcerpt<'a> {
part: &'a str,
}

fn main() {
let i;
{
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
i = ImportantExcerpt {
part: first_sentence,
};
}
println!("{:?}",i);
}

就无法通过编译了,因为结构体比它引用的字符串存在得更久,引用字符串在作用域结束被释放后(第14行),println! 依然在外面使用了该结构体,因此会导致无效的引用。

13.8 生命周期消除(Lifetime Elision)

现在我们已经知道了每一个引用都有一个生命周期,而且我们需要为那些使用了引用的函数或结构体指定生命周期。但是,下面的代码没有对生命周期标注也可以编译成功:

1
2
3
4
5
6
7
8
9
10
11
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();

for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}

&s[..]
}

这里定义了一个没有使用生命周期注解的函数,即便其参数和返回值都是引用。这个函数没有生命周期注解却能编译是由于一些历史原因:在早期版本(pre-1.0)的rust中,这的确是不能编译的。每一个引用都必须有明确的生命周期。那时的函数签名将会写成这样:

1
fn first_word<'a>(s: &'a str) -> &'a str {}

在编写了很多rust代码后,rust团队发现在特定情况下rust程序员们总是重复地编写一模一样的生命周期注解。这些场景是可预测的并且遵循几个明确的模式。接着rust团队就把这些模式编码进了rust编译器中,如此借用检查器在这些情况下就能推断出生命周期而不再强制程序员显式的增加注解。

被编码进rust引用分析的模式被称为生命周期省略规则(lifetime elision rules)。这并不是需要程序员遵守的规则;这些规则是一系列特定的场景,此时编译器会考虑,如果代码符合这些场景,就无需明确指定生命周期。换句话说,符合这些规则的场景下,编译器会自动处理生命周期,简化了程序员工作。在未来,这些规则可能会变化,也许以后只需要更少的生命周期注解。

函数或方法的参数的生命周期被称为输入生命周期(input lifetimes),而返回值的生命周期被称为输出生命周期(output lifetimes)。

编译器采用三条规则来判断引用何时不需要明确的注解。第一条规则适用于输入生命周期,后两条规则适用于输出生命周期。如果编译器检查完这三条规则后仍然存在没有计算出生命周期的引用,编译器将会停止并生成错误,此时就需要程序员手动标注了。这些规则适用于 fn 定义,以及 impl 块。

  1. 第一条规则是编译器为每一个是引用参数都分配了一个生命周期参数。换句话说就是,有一个引用参数的函数有一个生命周期参数:fn foo<'a>(x: &'a i32),有两个引用参数的函数有两个不同的生命周期参数,fn foo<'a, 'b>(x: &'a i32, y: &'b i32),依此类推。
  2. 第二条规则是如果只有一个输入生命周期参数,那么它被赋予所有输出生命周期参数:fn foo<'a>(x: &'a i32) -> &'a i32
  3. 第三条规则是如果方法有多个输入生命周期参数并且其中一个参数是 &self&mut self,说明是个对象的方法,那么所有输出生命周期参数被赋予 self 的生命周期。第三条规则使得方法更容易读写,因为只需更少的符号。

下面模拟编译器如何根据这些规则计算生命周期,开始时签名中的引用并没有关联任何生命周期:

1
fn first_word(s: &str) -> &str {

接着编译器应用第一条规则,也就是每个引用参数都有其自己的生命周期。我们像往常一样称之为 'a,所以现在签名看起来像这样:

1
fn first_word<'a>(s: &'a str) -> &str {

对于第二条规则,因为这里正好只有一个输入生命周期参数所以是适用的。第二条规则表明输入参数的生命周期将被赋予输出生命周期参数,所以现在签名看起来像这样:

1
fn first_word<'a>(s: &'a str) -> &'a str {

现在这个函数签名中的所有引用都有了生命周期,这部分代码可以编译通过,且用户无需手动去标注生命周期,

再看另一个例子:

1
fn longest(x: &str, y: &str) -> &str {

应用第一条规则:每个引用参数都有其自己的生命周期。这次有两个参数,所以就有两个(不同的)生命周期:

1
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {

应用第二条规则,因为函数存在多个输入生命周期,它并不适用于这种情况。

再来看第三条规则,它同样也不适用,因为没有 self 参数。

应用了三个规则之后编译器还没有计算出返回值类型的生命周期,这就是为什么编译器将会停止并生成错误的原因了,编译器使用所有已知的生命周期省略规则,仍不能计算出签名中所有引用的生命周期。

13.9 方法定义中的生命周期注解

生命周期就是泛型,因此为具有生命周期的结构体实现方法时,语法与泛型语法相同;impl 块里的方法签名中,引用可能与结构体字段中的引用相关联,也可能是独立的。另外,生命周期省略规则也经常让我们无需在方法签名中使用生命周期注解:

1
2
3
4
5
6
7
8
9
struct ImportantExcerpt<'a> {
part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
}

结构体字段的生命周期必须总是在 impl 关键字之后声明并在结构体名称之后被使用,因为这些生命周期是结构体类型的一部分。这里有一个方法 level。其唯一的参数是 self 的引用,而且返回值只是一个 i32,并不引用任何值。因为第一条生命周期规则我们并不必须标注 self 引用的生命周期。

下面的例子展示了第三规则应用的场景:

1
2
3
4
5
6
impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {}", announcement);
self.part
}
}

这里有两个输入生命周期,所以rust应用第一条生命周期省略规则并给予 &selfannouncement 他们各自的生命周期:

1
2
3
4
5
6
impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &str {
println!("Attention please: {}", announcement);
self.part
}
}

接着,因为其中一个参数是 &self,根据第三条规则,返回值类型被赋予了 &self 的生命周期,

1
2
3
4
5
6
impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &'a str {
println!("Attention please: {}", announcement);
self.part
}
}

这样所有的生命周期都被计算出来了。

13.10 静态生命周期

这里有一种特殊的生命周期值得讨论:'static,其生命周期能够存活于整个程序期间。所有的字符串字面值都拥有 'static 生命周期,我们也可以选择像下面这样标注出来:

1
let s: &'static str = "I have a static lifetime.";

这个字符串的文本被直接储存在程序的二进制文件中而这个文件总是可用的。因此所有的字符串字面值都是 'static 的。

在错误信息的帮助文本中可能会有使用 'static 生命周期的建议,不过将引用指定为 'static 之前,思考一下这个引用是否真的在整个程序的生命周期里都有效,以及你否希望它存在得这么久。大部分情况下,推荐 'static 生命周期的错误信息都是尝试创建一个悬垂引用或者可用的生命周期不匹配的结果。在这种情况下的解决方案是修复这些问题而不是指定一个 'static 的生命周期。

另外,特征对象的生命周期也是'static,见特征对象

三、rust进阶篇

1 rust与函数式编程

rust的设计灵感来源于很多现存的语言和技术。其中一个显著的影响就是函数式编程(functional programming)。函数式编程风格通常包含将函数作为参数值或其他函数的返回值、将函数赋值给变量以供之后执行等等。

我们不会讨论函数式编程是或不是什么的问题,而是展示rust的一些在功能上与其他被认为是函数式语言类似的特性。主要内容如下:

  • 闭包(Closures),一个可以储存在变量里的类似函数的结构。(这里的闭包是函数式编程概念的闭包)
  • 迭代器(Iterators),一种处理元素序列的方式。
  • 模式匹配
  • 枚举
  • 闭包和迭代器的性能。

在基础篇,我们已经介绍了其它受函数式风格影响的rust功能:模式匹配枚举,因此本章的重点放在闭包和迭代器,掌握闭包和迭代器是编写符合语言风格的高性能rust代码的重要一环。

1.1 闭包

首先要说明的是,我们这里提到的闭包仅限于函数式编程概念上,而不是闭包最初始的定义。对于闭包的另一种解释,在我的文章编译原理中有介绍。其中有一些概念相同,而有一些则不同,我们这里主要讨论rust所定义的闭包。

在rust中的闭包(closures)是可以保存在一个变量中或作为参数传递给其他函数的匿名函数。可以在一个地方创建闭包,然后在不同的上下文中执行闭包运算。不同于函数,闭包允许捕获被定义时所在作用域中的值。我们将展示闭包的这些功能如何复用代码和自定义行为。

闭包捕获环境

首先来了解如何通过闭包捕获定义它的环境中的值以便之后使用。

考虑如下场景:有时 T 恤公司会赠送限量版 T 恤给邮件列表中的成员作为促销。邮件列表中的成员可以选择将他们的喜爱的颜色添加到个人信息中。如果被选中的成员设置了喜爱的颜色,他们将获得那个颜色的 T 恤。如果他没有设置喜爱的颜色,他们会获赠公司现存最多的颜色的款式。

有很多种方式来实现这些。例如,使用有 RedBlue 两个成员的 ShirtColor 枚举(出于简单考虑限定为两种颜色)。我们使用 Inventory 结构体来代表公司的库存,它有一个类型为 Vec<ShirtColor>shirts 字段表示库存中的衬衫的颜色。Inventory 上定义的 giveaway 方法获取免费衬衫得主所喜爱的颜色(如有),并返回其获得的衬衫的颜色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor {
Red,
Blue,
}

struct Inventory {
shirts: Vec<ShirtColor>,
}

impl Inventory {
fn giveaway(&self, user_preference: Option<ShirtColor>) -> ShirtColor {
user_preference.unwrap_or_else(|| self.most_stocked())
}

fn most_stocked(&self) -> ShirtColor {
let mut num_red = 0;
let mut num_blue = 0;

for color in &self.shirts {
match color {
ShirtColor::Red => num_red += 1,
ShirtColor::Blue => num_blue += 1,
}
}
if num_red > num_blue {
ShirtColor::Red
} else {
ShirtColor::Blue
}
}
}

fn main() {
let store = Inventory {
shirts: vec![ShirtColor::Blue, ShirtColor::Red, ShirtColor::Blue],
};

let user_pref1 = Some(ShirtColor::Red);
let giveaway1 = store.giveaway(user_pref1);
println!(
"The user with preference {:?} gets {:?}",
user_pref1, giveaway1
);

let user_pref2 = None;
let giveaway2 = store.giveaway(user_pref2);
println!(
"The user with preference {:?} gets {:?}",
user_pref2, giveaway2
);
}

在上面的代码中,main 函数中定义的 store 还剩有两件蓝衬衫和一件红衬衫可在限量版促销活动中赠送。我们用一个期望获得红衬衫和一个没有期望的用户来调用 giveaway 方法。这里采取的实现都是基础篇中讲过的内容,除了giveaway 方法体中使用的闭包。

giveaway 方法获取了 Option<ShirtColor> 类型作为用户的期望颜色并在 user_preference 上调用 unwrap_or_else 方法。该方法由rust标准库中的Option提供,它获取一个没有参数、返回值类型为 T (与 Option<T>Some 成员所存储的值的类型一样,这里是 ShirtColor)的闭包作为参数。如果 Option<T>Some 成员,则 unwrap_or_else 返回 Some 中的值。 如果 Option<T>None 成员, 则 unwrap_or_else 调用闭包并返回闭包的返回值。

因此,对于第一个成员,他喜爱的颜色user_pref1Some(ShirtColor::Red),则调用unwrap_or_else时返回的就是Some中的ShirtColor::Red

重点是第二个成员,他没有设置喜爱的颜色(user_pref2None),则调用unwrap_or_else时,会去调用闭包并返回闭包的返回值。

我们将闭包表达式 || self.most_stocked() 用作 unwrap_or_else 的参数。这是一个本身不获取参数的闭包(如果闭包有参数,它们会出现在两道竖杠之间)。闭包体调用了 self.most_stocked()。我们在这里定义了闭包,而 unwrap_or_else 的实现会在之后需要其结果的时候执行闭包。对于本例来说,第二个成员就会执行该闭包,调用most_stocked方法获取公司现存最多的颜色的款式并返回。

这里一个有趣的地方是我们传递了一个会在当前 Inventory 实例上调用 self.most_stocked() 的闭包。标准库并不需要知道我们定义的 InventoryShirtColor 类型或是在这个场景下我们想要用的逻辑。闭包捕获了一个 Inventory 实例的不可变引用到 self,并连同其它代码传递给 unwrap_or_else 方法。相比之下,函数就不能以这种方式捕获其环境。

闭包的定义

通常,类似于lambda表达式一样,闭包是一个匿名函数,不需要标注类型,定义形式如下:

1
2
3
4
5
|param1, param2,...| {
语句1;
语句2;
返回表达式
}

如果只有一个返回表达式:

1
|param1| 返回表达式

如果没有参数:

1
|| 返回表达式

闭包的一些特性:

  • 声明时使用 ||将输入参数括起来。
  • 如果有多行,需要用{},对于单个表达式则不需要。
  • 闭包有能力捕获外部环境的变量
  • 闭包中最后一行表达式返回的值,就是闭包执行后的返回值
  • 将闭包绑定到变量上并不会执行它,比如定义let a = || ...,这里的a就相当于函数一样可以调用:a()

闭包类型推断和注解

函数与闭包还有更多区别。闭包并不总是要求像 fn 函数那样在参数和返回值上注明类型。函数中需要类型注解是因为他们是暴露给用户的显式接口的一部分。严格定义这些接口对保证所有人都对函数使用和返回值的类型理解一致是很重要的。与此相比,闭包并不用于这样暴露在外的接口:他们储存在变量中并被使用,不用命名他们或暴露给库的用户调用。

闭包通常很短,并只关联于小范围的上下文而非任意情境。在这些有限制的上下文中,编译器能可靠地推断参数和返回值的类型,类似于它是如何能够推断大部分变量的类型一样;当然,同时也有编译器需要闭包类型注解的极少数情况。

类似于变量类型标注一样,如果我们希望增加明确性和清晰度也可以为闭包添加类型标注,坏处是使代码变得更啰嗦。比如:

1
2
3
4
5
let expensive_closure = |num: u32| -> u32 {
println!("calculating slowly...");
thread::sleep(Duration::from_secs(2));
num
};

我们定义了一个闭包并将它保存在变量中,并为参数和返回值增加了类型注解。使用这种语法就更类似函数了,下面是函数和闭包在形式上的对比:

1
2
3
4
fn  add_one_v1   (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x| { x + 1 };
let add_one_v4 = |x| x + 1 ;

第一行展示了一个函数定义,第二行展示了一个完整标注的闭包定义。第三行闭包定义中省略了类型注解,而第四行去掉了可选的大括号,因为闭包体只有一个表达式。这些都是有效的闭包定义,并在调用时产生相同的行为。

调用闭包是 add_one_v3add_one_v4 能够编译的必要条件,因为类型将从其用法中推断出来。这类似于 let v = Vec::new();,rust需要类型注解或是某种类型的值被插入到 Vec 才能推断其类型。

编译器会为闭包定义中的每个参数和返回值推断一个具体类型。但是,类型推断不是泛型,一旦类型确定,编译器就会一直使用该类型,如果尝试对同一闭包使用不同类型则就会得到类型错误,比如:

1
2
3
4
let example_closure = |x| x;

let s = example_closure(String::from("hello"));
let n = example_closure(5);

这里定义了一个闭包,接受一个参数x并直接将其返回。在闭包定义中没有增加任何类型注解,所以我们可以用任意类型来调用这个闭包,所以第一次调用闭包时,我们使用String类型作为参数,编译器推断这个闭包中 x 的类型以及返回值的类型是 String。接着这些类型被锁定进闭包 example_closure 中。

紧接着第二次我们使用 i32调用闭包,这与编译器之前推导的 String 类型不符,因此报错:

1
2
3
4
5
6
7
8
9
10
11
12
   |
38 | let n = example_closure(5);
| --------------- ^- help: try using a conversion method: `.to_string()`
| | |
| | expected struct `String`, found integer
| arguments to this function are incorrect
|
note: closure parameter defined here
--> src\main.rs:35:28
|
35 | let example_closure = |x| x;
| ^

捕获引用或者移动所有权

闭包可以通过三种方式捕获其环境,它们直接对应到函数获取参数的三种方式:不可变借用,可变借用和获取所有权。闭包会根据函数体中如何使用被捕获的值决定用哪种方式捕获,一般来说,在满足使用需求的前提下以最小的访问捕获,比如:如果不可变借用可以完成任务,就不会使用可变借用。

获取不可变引用
1
2
3
4
5
6
7
8
9
10
fn main() {
let list = vec![1, 2, 3];
println!("Before defining closure: {:?}", list);

let only_borrows = || println!("From closure: {:?}", list);

println!("Before calling closure: {:?}", list);
only_borrows();
println!("After calling closure: {:?}", list);
}

这里定义了一个捕获名为 list的动态数组的不可变引用的闭包,因为只需不可变引用就能打印其值。因为同时可以有多个 list 的不可变引用,所以在闭包定义之前,闭包定义之后调用之前,闭包调用之后代码仍然可以访问 list。代码可以通过编译。

获取可变引用

接下来,我们修改闭包体让它向 list动态数组增加一个元素。

1
2
3
4
5
6
7
8
9
10
fn main() {
let mut list = vec![1, 2, 3];
println!("Before defining closure: {:?}", list);

let mut borrows_mutably = || list.push(7);
// println!("After calling closure: {:?}", list);
// ^^^^尝试取消上一行的注释并运行,看看会发生什么^^^^
borrows_mutably();
println!("After calling closure: {:?}", list);
}

闭包现在捕获一个可变引用。注意在 borrows_mutably 闭包的定义和调用之间不再有 println!,当 borrows_mutably 定义时,它捕获了 list 的可变引用。闭包在被调用后就不再被使用,这时可变引用结束。由于可变引用与不可变引用不能同时存在,当可变引用存在时(第5~8行)不允许用println!获取不可变引用来打印,尝试取消第6行的注释并运行,看看会获得什么错误。

另外,由于在闭包中改变了外部list的值,捕获的是它的可变引用,此时还需要将闭包绑定的变量也设置为mut,即:let mut borrows_mutably

获取所有权

即使闭包体不严格需要所有权,如果希望强制闭包获取它用到的环境中值的所有权,可以在参数列表前使用 move 关键字。在将闭包传递到一个新的线程时这个技巧很有用,它可以移动数据所有权给新线程。关于并发的细节我们放到后面,现在首先来简单探讨用需要 move 关键字的闭包来生成新的线程。

1
2
3
4
5
6
7
8
9
10
use std::thread;

fn main() {
let list = vec![1, 2, 3];
println!("Before defining closure: {:?}", list);

thread::spawn(move || println!("From thread: {:?}", list))
.join()
.unwrap();
}

我们生成了新的线程,给这个线程一个闭包作为参数运行,闭包体打印出列表。这里闭包通过不可变引用捕获 list,因为这是打印列表所需的最少的访问。这个例子中,尽管闭包体依然只需要不可变引用,我们还是在闭包定义前写上 move 关键字来指明 list 应当被移动到闭包中。新线程可能在主线程剩余部分执行完前执行完,或者也可能主线程先执行完。如果主线程维护了 list 的所有权但却在新线程之前结束并且丢弃了 list,则在线程中的不可变引用将失效。因此,编译器要求 list 被移动到在新线程中运行的闭包中,这样引用就是有效的。如果去掉 move 关键字或在闭包被定义后在主线程中使用 list 就会报错。

将被捕获的值移出闭包和Fn特征

闭包可以当做参数传递给函数,这就涉及到一个问题:函数的参数必须显式标注其类型,而闭包的类型随着参数和返回值的变化而变化。有没有一种方式可以统一表示一类闭包呢?没错,可以利用泛型和泛型的特征约束来做到这一点。对应于捕获其环境的三种方式:转移所有权、可变借用、不可变借用,相应的我们将其称为 Fn 特征,也有三种:

  1. FnOnce 表示捕获方式为通过获取所有权(T)的闭包。适用于能被调用一次的闭包,所有闭包都至少实现了这个特征,因为所有闭包都能被调用。一个会将捕获的值移出闭包体的闭包只实现 FnOnce 特征,这是因为它只能被调用一次。
  2. FnMut 表示捕获方式为通过可变引用(&mut T)的闭包。适用于不会将捕获的值移出闭包体的闭包,但它可能会修改被捕获的值。这类闭包可以被调用多次。
  3. Fn 表示捕获方式为通过不可变引用(&T)的闭包。适用于既不将被捕获的值移出闭包体也不修改被捕获的值的闭包,当然也包括不从环境中捕获值的闭包。这类闭包可以被调用多次而不改变它们的环境,这在会多次并发调用闭包的场景中十分重要。
FnOnce

下面来看看我们在之前使用的Option<T> 上的 unwrap_or_else 方法的定义:

1
2
3
4
5
6
7
8
9
10
11
impl<T> Option<T> {
pub fn unwrap_or_else<F>(self, f: F) -> T
where
F: FnOnce() -> T
{
match self {
Some(x) => x,
None => f(),
}
}
}

T 是表示 OptionSome 成员中的值的类型的泛型。类型 T 也是 unwrap_or_else 函数的返回值类型:举例来说,在 Option<String> 上调用 unwrap_or_else 会得到一个 String。接下来注意到,unwrap_or_else 函数有额外的泛型参数 FFf 参数(即调用 unwrap_or_else 时提供的闭包)的类型。在where中,泛型 F 的特征约束是 FnOnce() -> T,这意味着 F 必须能够被调用一次,没有参数并返回一个 T

在特征约束中使用 FnOnce 表示 unwrap_or_else 将最多调用 f 一次。在 unwrap_or_else 的函数体中可以看到,如果 OptionSomef 不会被调用。如果 OptionNonef 将会被调用一次。由于所有的闭包都实现了 FnOnceunwrap_or_else 能接收绝大多数不同类型的闭包,十分灵活。

再看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
fn fn_once<F>(func: F)
where
F: FnOnce(usize) -> bool,
{
println!("{}", func(3));
// println!("{}", func(4));
// ^^^^^尝试解除上一行的注释,看看会发生什么错误^^^^
}

fn main() {
let x = vec![1, 2, 3];
fn_once(|z|{z == x.len()})
}

这里定义了一个函数,F的特征约束是FnOnce(usize) -> bool,这意味着F有一个usize类型的参数,返回值为bool类型。这里仅实现了FnOnce,因此在调用时会转移这个闭包所有权,如果将第6行取消注释,会提示转移所有权后再次调用的错误。

FnMut
1
2
3
4
5
6
7
8
9
10
11
12
13
fn fn_mut<F>(mut func: F)
where
F: FnMut(&str) -> (),
{
func("world");
func("!");
}
fn main() {
let mut a = String::from("hello");
fn_mut(|str| a.push_str(str));
println!("{a}");
}

同样定义一个函数,在这里F的特征约束为FnMut(&str) -> (),意味着F有一个&str类型的参数,没有返回值。在闭包中,我们调用 a.push_str 去改变外部 a 的字符串值,因此这里捕获了它的可变引用。由于闭包内部捕获了可变引用,闭包变量也要添加mut声明,即mut func。在函数内多次调用闭包,修改外部a的值,最终会输出helloworld!

Fn
1
2
3
4
5
6
7
8
9
10
11
12
fn exec<F>(func: F)
where
F: Fn() -> (),
{
func();
func();
}
fn main() {
let a = String::from("hello");
exec(|| println!("{}", a));
}

同样地,我们将闭包改为获取a的不可变引用,并修改F的特征约束为Fn即可。

深入理解Fn特征

对闭包所要捕获的每个变量,编译器会根据闭包的行为自动推断以限制最少的方式来捕获。但是,一个闭包实现了哪种Fn特征取决于该闭包如何使用被捕获的变量,而不是取决于闭包如何捕获它们。因此,使用 move 的闭包也可能实现了FnFnMut 特征。

1
2
3
4
5
6
7
8
9
10
fn exec<F>(func: F)
where
F: FnOnce() -> (),
{
func();
}
fn main() {
let a = String::from("hello");
exec(move || println!("{}", a));
}

对于这段代码,闭包的行为本身只需要获取a的不可变引用即可,但这里用movea的所有权转移进闭包。此时这个闭包不仅仅实现了FnOnce,还实现了FnFnMut 特征。将FnOnce修改为Fn依然可以通过编译:

1
2
3
4
5
6
7
8
9
10
fn exec<F>(func: F)
where
F: Fn() -> (),
{
func();
}
fn main() {
let a = String::from("hello");
exec(move || println!("{}", a));
}

可以这样理解,一个闭包实现哪几种Fn特征,是由该闭包如何使用被捕获的变量而决定的:

  • 所有的闭包都自动实现了 FnOnce 特征,因此任何一个闭包都至少可以被调用一次
  • 没有移出所捕获变量的所有权的闭包自动实现了 FnMut 特征
  • 不需要对捕获变量进行改变的闭包自动实现了 Fn 特征

使用move可以强制获取捕获的变量的所有权,但与闭包实现了哪些特征没有必然联系。

通过源码来看看这三种特征的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pub trait FnOnce<Args: Tuple> {
type Output;

extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
}

pub trait FnMut<Args: Tuple>: FnOnce<Args> {
extern "rust-call" fn call_mut(
&mut self,
args: Args
) -> Self::Output;
}

pub trait Fn<Args: Tuple>: FnMut<Args> {
extern "rust-call" fn call(&self, args: Args) -> Self::Output;
}

这里用到了特征“继承”,如果你接触过支持继承的语言,应该不难理解:继承通常用来描述子类拥有父类的特征和行为。在rust中,可以定义依赖于某个特征的特征(可以理解为特征继承)。比如,定义特征AB,希望B特征也实现了A特征,如此一来就可以在B中使用A特征的关联项。我们可以称特征B继承特征A,实现继承的方式很简单,在定义特征B时使用冒号加上特征A即可。

1
2
trait A {}
trait B: A {}

主要关注以下两点:

  • Fn 继承 FnMutFnMut继承 FnOnce,因此要实现 Fn 就要同时实现 FnMutFnOnce
  • 另外,Fn 获取 &selfFnMut 获取 &mut self,而 FnOnce 获取 self
    • FnOnce中,call_once函数第一个参数为self,也就是闭包自身。因此,FnOnce闭包一旦被调用,闭包本身的所有权就会转移到call_once内部,这也是为什么FnOnce特征的闭包至多能够被调用一次。
    • FnMut中,call_mut方法中传入的是引用&mut self,因此call_mut可以被调用多次。不过,由于继承关系的存在,FnMut也可以调用call_once(相当于将FnMut 当成FnOnce来使用),一旦调用了call_once,就不能再调用其它方法了。
    • Fn中,call只获取了不可变引用,因此也可以调用多次,同样,由于它继承了FnMut,一旦调用了call_once,就不能再调用其它方法了。

在实际项目中,应该优先使用 Fn 特征,让编译器去推断是否使用正确,以及该如何选择。

闭包作为函数的返回值

闭包表现为特征,在返回实现了特征的类型中,介绍过函数返回实现了某个特征的类型。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
fn factory() -> impl Fn(i32) -> i32 {
let num = 5;

move |x| x + num
}

fn main(){
let f = factory();

let answer = f(1);
assert_eq!(6, answer);
}

上面的代码中,函数签名impl Fn(i32) -> i32表示返回一个闭包类型,它实现了 Fn(i32) -> i32 特征。注意需要对闭包添加move转移被捕获变量num的所有权,因为闭包可能比当前函数活得更久,但它借用了函数拥有的本地变量num

这样做可以,但正如返回实现了特征的类型提到的,这种方式有一个局限,如果返回类型不单一,则无法通过编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
fn factory(x:i32) -> impl Fn(i32) -> i32 {

let num = 5;

if x > 1{
move |x| x + num
} else {
move |x| x - num
}
}
fn main(){
let f = factory();

let answer = f(1);
assert_eq!(6, answer);
}
/*
error[E0308]: `if` and `else` have incompatible types
--> src\main.rs:8:9
|
5 | / if x > 1{
6 | | move |x| x + num
| | ----------------
| | |
| | the expected closure
| | expected because of this
7 | | } else {
8 | | move |x| x - num
| | ^^^^^^^^^^^^^^^^ expected closure, found a different closure
9 | | }
| |_____- `if` and `else` have incompatible types
*/

即使签名一样的闭包,类型也是不同的。不过,解决方法也和impl Trait的解决方法一样,使用特征对象即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn factory(x:i32) -> Box<dyn Fn(i32) -> i32>  {

let num = 5;

if x > 1{
Box::new(move |x| x + num)
} else {
Box::new(move |x| x - num)
}
}
fn main(){
let f = factory(3);

let answer = f(1);
assert_eq!(6, answer);
}

1.2 迭代器

迭代器模式允许你对一个序列的项进行某些处理。迭代器(iterator)负责遍历序列中的每一项和决定序列何时结束的逻辑。

rust中的迭代器是惰性的(lazy),在调用方法使用迭代器之前,它都不会有效果:

1
2
3
4
5
fn main() {
let v1 = vec![1, 2, 3];

let v1_iter = v1.iter();
}

迭代器被储存在 v1_iter 变量中,此时还没有对它进行迭代。一旦创建迭代器之后,可以选择用多种方式利用它,比如对它进行遍历:

1
2
3
4
5
6
7
8
9
fn main() {
let v1 = vec![1, 2, 3];

let v1_iter = v1.iter();

for v in v1_iter {
println!("{}", v)
}
}

在标准库中没有提供迭代器的语言中(如C),我们可能会使用一个从0开始的索引变量,使用这个变量索引vector中的值,并循环增加其值直到达到vector的元素数量。而迭代器没有使用索引,无需去关心索引的起始位置、终止位置,迭代器为我们处理了所有这些逻辑,这减少了重复代码并消除了潜在的混乱。并且,对于不同的序列,如数组、动态数组、hashmap都可以使用相同的逻辑处理。

Iterator特征和next方法

所有的迭代器都实现了Iterator特征,它定义于标准库中。这个特征的定义如下:

1
2
3
4
5
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// ...
}

不过,此处有两处新的语法:type ItemSelf::Item,它们定义了特征的关联类型,我们会在后面介绍关联类型。现在只需知道这段代码表明实现 Iterator 特征要求同时定义一个 Item 类型,这个 Item 类型被用作 next 方法的返回值类型。换句话说,Item 类型将是迭代器返回元素的类型。

nextIterator 实现者被要求定义的唯一方法。next一次返回迭代器中的一个项,封装在Some中,当迭代器结束时,它返回None。可以直接使用next方法:

1
2
3
4
5
6
7
8
9
10
fn main() {
let v1 = vec![1, 2, 3];

let mut v1_iter = v1.iter();

assert_eq!(v1_iter.next(), Some(&1));
assert_eq!(v1_iter.next(), Some(&2));
assert_eq!(v1_iter.next(), Some(&3));
assert_eq!(v1_iter.next(), None);
}

需要为迭代器添加mut,因为在迭代器上调用 next 方法改变了迭代器中用来记录序列位置的状态。换句话说,代码消费(consume)了,或使用了迭代器。从源码的角度来看,next方法的签名fn next(&mut self),获取的是当前迭代器实例的可变引用。每一个 next 调用都会从迭代器中消费一个项。

for循环和迭代器

这里有一个细节,为什么在for循环时不需要将迭代器设置为可变的呢:

1
2
3
4
5
6
7
8
9
fn main() {
let v1 = vec![1, 2, 3];
// 为什么这里不用设置 `mut`
let v1_iter = v1.iter();

for v in v1_iter {
println!("{}", v)
}
}

其实for本身是迭代器的语法糖,如果没有特别指定,for 循环会对in后面的集合隐式应用 into_iter 方法,这个方法是在IntoIterator特征中被定义的,该特征声明如下:

1
2
3
4
5
6
pub trait IntoIterator {
type Item;
type IntoIter: Iterator<Item = Self::Item>;

fn into_iter(self) -> Self::IntoIter;
}

根据The Rust Referencefor in可以等价地写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fn main() {
let iter_expr = vec![1, 2, 3];
{
let result = match IntoIterator::into_iter(iter_expr) {
mut iter => 'label: loop {
let mut next;
match Iterator::next(&mut iter) {
Option::Some(val) => next = val,
Option::None => break,
};
let PATTERN = next;
let () = {
/* loop body */
println!("{}", PATTERN)
};
},
};
result
}
}

通过into_iter方法,会获得这个动态数组的迭代器,使用match去匹配它,匹配项mut iter说明,在内部将这个迭代器转换为可变的。然后,通过一个loop,不停地调用next方法,匹配它的返回值:如果是Some(x)就打印它们,直到遇到None,就终止循环。

对于迭代器本身,它也实现了 IntoIterator特征,源码是这样实现的:

1
2
3
4
5
6
7
8
9
impl<I: Iterator> const IntoIterator for I {
type Item = I::Item;
type IntoIter = I;

#[inline]
fn into_iter(self) -> I {
self
}
}

因此:

  • 对于非迭代器(如动态数组),into_iter会尝试将其转化为迭代器并返回。
  • 对于迭代器,into_iter会返回它本身self.

由于for的语法糖,在内部已经自动实现了mut iter这样的转换,所以不需要将迭代器设置为可变的。

迭代器和所有权

在前面我们介绍了IntoIterator特征中的into_iter方法,该方法会获取集合的所有权,因此使用for循环遍历一个集合将消耗该集合。

1
2
3
4
5
6
7
8
fn main() {
let v1 = vec![1, 2, 3];
for i in v1 {
println!("{}", i);
}
println!("{:?}", v1); // 错误,v1的所有权已经被转移
}

有些时候可能希望迭代一个集合而不是获取它的所有权,许多集合提供了迭代器获取引用的方法,通常分别称为iteriter_mut。顾名思义:

  • iter返回的迭代器会获取集合元素的不可变引用
  • iter_mut返回的迭代器会获取集合元素的可变引用
  • into_iter返回的迭代器会获取集合元素的所有权

通常情况下,如果一个集合类型 C 提供iter方法,那么它通常还为 &C 实现 IntoIterator特征,而该实现只是调用 iter方法;同样,提供iter_mut方法的集合类型 C 通常为 &mut C 实现 IntoIterator特征,这样操作的好处就是:

1
2
3
4
5
6
7
8
9
10
fn main() {
let mut values = vec![41];
for x in &mut values { // same as `values.iter_mut()`
*x += 1;
}
for x in &values { // same as `values.iter()`
assert_eq!(*x, 42);
}
assert_eq!(values.len(), 1);
}

可以便捷地通过&&mut而不需要使用方法名。

最后需要注意几点:

  • into_iterIntoIterator特征必须实现的方法,另外两个方法并不要求必须实现。

  • 尽管许多集合都提供iter方法,但并非所有集合都提供iter_mut。例如,如果键的哈希值发生更改,则对hashset或hashmap的键进行更改可能会使该集合处于不一致状态,因此这些集合仅提供 iter

消费适配器与迭代器适配器

Iterator特征中定义了一些默认实现的方法,有一些方法通过next 方法来消费元素。这些方法被称为消费适配器(consuming adaptors)。比如sum方法:

1
2
3
4
5
6
7
fn main() {
let values = vec![1, 2, 3];
let it = values.iter();
let s: i32 = it.sum();
println!("{}", s);
}

这个方法获取迭代器的所有权并反复调用 next 来遍历迭代器,因而会消费迭代器。当其遍历每一个项时,它将每一个项加总到一个总和并在迭代完成时返回总和,如果是空迭代器,则会返回该类型的零值。

强调一下,sum会获取迭代器的所有权,因此it在调用sum后失效,但values仍然可用,因为我们是使用iter方法创建的迭代器。

另外一类方法被称为迭代器适配器(iterator adaptors),它们允许我们将当前迭代器变为不同类型的迭代器。这样就可以实现链式调用,不过因为所有的迭代器都是惰性的,必须调用一个消费适配器方法以便获取迭代器适配器调用的结果。

1
2
3
4
5
6
fn main() {
let v1: Vec<i32> = vec![1, 2, 3];

v1.iter().map(|x| x + 1);
}

这里展示了一个调用迭代器适配器方法 map 的例子,该 map 方法使用闭包来调用每个元素以生成新的迭代器。这里的闭包创建了一个新的迭代器,对vector中的每个元素都加1。不过由于迭代器是惰性的,还需要一个消费迭代器。

1
2
3
4
5
6
7
fn main() {
let v1: Vec<i32> = vec![1, 2, 3];

let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();

assert_eq!(v2, vec![2, 3, 4]);
}

collect 方法消费迭代器并将结果收集到一个数据结构中。我们为 v2 标注了 Vec<_> 类型,就是为了告诉 collect:请把迭代器中的元素消费掉,然后把值收集成 Vec<_> 类型,至于为何使用 _,因为编译器会帮我们自动推导。至于为什么 collect 在消费时要指定类型?是因为该方法其实很强大,可以收集成多种不同的集合类型,Vec<T> 仅仅是其中之一,因此我们必须显式地告诉编译器我们想要收集成的集合类型。

还有一点值得注意,map 会对迭代器中的每一个值进行一系列操作,然后把该值转换成另外一个新值,该操作是通过闭包 |x| x + 1 来完成:最终迭代器中的每个值都增加了 1,从 [1, 2, 3] 变为 [2, 3, 4]

从这个例子中也可以看出,迭代器的许多方法都可以使用闭包作为参数,它最大的好处不仅在于可以就地实现迭代器中元素的处理,还在于可以捕获环境值。

1
2
3
4
5
6
7
8
struct Shoe {
size: u32,
style: String,
}

fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
shoes.into_iter().filter(|s| s.size == shoe_size).collect()
}

这里使用 filter 方法来获取一个闭包。该闭包从迭代器中获取一项并返回一个 bool。如果闭包返回 true,其值将会包含在 filter 提供的新迭代器中。如果闭包返回 false,其值不会被包含。使用 filter 和一个捕获环境中变量 shoe_size 的闭包来遍历一个 Shoe 结构体集合。最终通过 collect 收集为 Vec<Shoe> 类型。

enumerate

在流程控制for循环中,曾经介绍过enumerate获取可迭代对象的索引,通过本章的介绍,应该会更加深入地理解这个过程。

1
2
3
4
5
6
7
fn main() {
let a = [6,3,2,4];

for (index, value) in a.iter().enumerate() {
println!("index:{},number:{}", index, value);
}
}

这里的 a.iter() 创建迭代器,然后调用 Iterator 特征上的 enumerate方法,该方法产生一个新的迭代器,其中每个元素均是元组 (索引,值)。这里的enumerate就是一个迭代器适配器,可以对其返回的迭代器继续进行链式调用:

1
2
3
4
5
6
7
8
9
10
11
12
fn main() {
let a = [6, 3, 2, 4];

let result = a
.iter()
.enumerate()
.filter(|&(idx, _)| idx % 2 == 0) // 保留索引能被2整除的元素
.map(|(idx, val)| val)
.fold(0, |sum, acm| sum + acm); // [6, 2]:6+2=8
println!("{}", result);
}

这里filter的闭包参数将索引能被2整除的元素保留,然后通过map内的闭包将其索引去掉,最后使用fold计算累计和,最终结果为8。

迭代器与循环的性能

迭代器作为一个高级的抽象,被编译成了与手写的底层代码大体一致性能代码。迭代器是rust的零成本抽象(zero-cost abstractions)之一,它意味着抽象并不会引入运行时开销。这与C++ 的设计和实现者本贾尼·斯特劳斯特卢普(Bjarne Stroustrup)在 《Foundations of C++(2012)》中所定义的零开销(zero-overhead)如出一辙:

In general, C++ implementations obey the zero-overhead principle: What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better.

从整体来说,C++ 的实现遵循了零开销原则:你不需要的,无需为他们买单。更有甚者的是:你需要的时候,也不可能找到其他更好的代码了。

rust编写的音频解码器的代码,解码算法使用线性预测数学运算来根据之前样本的线性函数预测将来的值。这些代码使用迭代器链来对作用域中的三个变量进行了某种数学计算:

1
2
3
4
5
6
7
8
9
10
11
12
let buffer: &mut [i32];
let coefficients: [i64; 12];
let qlp_shift: i16;

for i in 12..buffer.len() {
let prediction = coefficients.iter()
.zip(&buffer[i - 12..i])
.map(|(&c, &s)| c * s as i64)
.sum::<i64>() >> qlp_shift;
let delta = buffer[i];
buffer[i] = prediction as i32 + delta;
}

为了计算 prediction 的值,这些代码遍历了 coefficients 中的 12 个值,使用 zip 方法将系数与 buffer 的前 12 个值组合在一起。接着将每一对值相乘,再将所有结果相加,然后将总和右移 qlp_shift 位。

当然,无需看懂这段代码,这里想要表达的是:这里创建了一个迭代器,使用了两个适配器,接着消费了其值。rust代码将会被编译为什么样的汇编代码呢?事实上,它被编译成与手写的效率相同的汇编代码。遍历 coefficients 的值完全用不到循环:rust知道这里会迭代 12 次,所以它展开(unroll)了循环。展开是一种移除循环控制代码的开销并替换为每个迭代中的重复代码的优化。所有的系数都被储存在了寄存器中,这意味着访问他们非常快。这里也没有运行时数组访问边界检查。

总之,大胆地使用迭代器和闭包吧,它们使得代码看起来更高级,但并不为此引入运行时性能损失。

1.3 一些例子

例1

rust官方提供了一个练习rust的仓库,叫做rustlings,里面有很多rust语法的练习题。这里介绍其中的一道迭代器和闭包的的练习题,它位于exercises/iterators/iterators4.rs

1
2
3
4
5
6
7
8
9
10
11
pub fn factorial(num: u64) -> u64 {
// Complete this function to return the factorial of num
// Do not use:
// - return
// Try not to use:
// - imperative style loops (for, while)
// - additional variables
// For an extra challenge, don't use:
// - recursion
// Execute `rustlings hint iterators4` for hints.
}

题目很简单,要求计算num的阶乘。但是做了一些要求,不可以使用return,尝试不使用命令式循环(forwhileloop)和额外的变量,作为额外的挑战,不能使用递归。

如果没有这些限制我们可以创建一个额外变量存储结果,然后使用for循环遍历这个整数;或者使用递归代码。但是在rust中,你可以使用另一个函数方法,使用range和迭代器优雅地计算阶乘。

在rust中,使用..可以创建Range类型的变量,它表示一个连续的范围,比如:

1
let a: std::ops::Range<i32> = 0..3;

这个类型实现了Iterator特征,因此,我们可以使用fold方法fold 接受两个参数:一个初始值和一个带有两个参数的闭包:一个“累加器”和一个元素。闭包返回累加器下一次迭代应具有的值。如:

1
2
3
4
5
6
let a = [1, 2, 3];

// the sum of all of the elements of the array
let sum = a.iter().fold(0, |acc, x| acc + x);

assert_eq!(sum, 6);

因此,要计算阶乘,我们只需要这样实现即可:

1
2
3
4
5
6
7
8
9
10
11
12
pub fn factorial(num: u64) -> u64 {
(1..=num).fold(1, |res,item| res*item)
// Complete this function to return the factorial of num
// Do not use:
// - return
// Try not to use:
// - imperative style loops (for, while)
// - additional variables
// For an extra challenge, don't use:
// - recursion
// Execute `rustlings hint iterators4` for hints.
}

很好,这符合题目的限制,并且只需要一行代码。

2 智能指针

指针 (pointer)是一个包含内存地址的变量的通用概念。rust中最常见的指针是引用。引用以&符号为标志并借用了它们所指向的值。除了引用数据没有任何其他特殊功能,也没有额外开销。

智能指针(smart pointers)是一类数据结构,它们的表现类似指针,但是也拥有额外的元数据和功能。智能指针的概念并不为rust所独有;其起源于C++并存在于其他语言中。rust标准库中定义了多种不同的智能指针,它们提供了多于引用的额外功能。为了探索其基本概念,我们来看看一些智能指针的例子,这包括引用计数 (reference counting)智能指针类型。这种指针允许数据有多个所有者,它会记录所有者的数量,当没有所有者时清理数据。在rust中因为引用和借用,普通引用和智能指针的一个额外的区别是引用是一类只借用数据的指针;相反,在大部分情况下,智能指针拥有它们指向的数据。

在之前的章节中,其实已经出现过智能指针:StringVec<T>,只不过当时并没有这样称呼它们。这些类型都属于智能指针因为它们拥有一些数据并允许你修改它们。它们也拥有元数据和额外的功能或保证。例如String存储了其容量作为元数据,并拥有额外的能力确保其数据总是有效的UTF-8编码。

智能指针通常使用结构体实现。智能指针不同于结构体的地方在于其实现了DerefDrop特征,Deref 特征允许智能指针结构体实例表现的像引用一样,这样就可以编写既用于引用、又用于智能指针的代码。Drop 特征允许我们自定义当智能指针离开作用域时运行的代码。

智能指针是一个在rust经常被使用的通用设计模式,这里并不会覆盖所有现存的智能指针。很多库都有自己的智能指针而你也可以编写属于你自己的智能指针。这里将会讲到的是来自标准库中最常用的一些:

  • Box<T>,用于在堆上分配值
  • Rc<T>,一个引用计数类型,其数据可以有多个所有者
  • Ref<T>RefMut<T>,通过 RefCell<T> 访问。( RefCell<T> 是一个在运行时而不是在编译时执行借用规则的类型)。

另外还会涉及内部可变性(interior mutability)模式,这是不可变类型暴露出改变其内部值的API。我们也会讨论引用循(reference cycles)会如何泄漏内存,以及如何避免。

2.1 Box<T>

Box<T>是rust中非常常见的智能指针,它允许你将一个值放在堆上而不是栈上。留在栈上的则是指向堆数据的指针。除了数据被储存在堆上而不是栈上之外,box没有性能损失。不过也没有很多额外的功能。它们多用于如下场景:

  • 当有一个在编译时未知大小的类型,而又想要在需要确切大小的上下文中使用这个类型值的时候(见:box允许创建递归类型
  • 当有大量数据并希望在确保数据不被拷贝的情况下转移所有权的时候(见:使用box避免大量数据拷贝
  • 当希望拥有一个值并只关心它的类型是否实现了特定特征而不是其具体类型的时候(==todo==:examples)

使用box在堆上存储数据

首先,让我们熟悉一下语法以及如何与储存在 Box<T> 中的值进行交互。

1
2
3
4
fn main() {
let b = Box::new(5);
println!("b = {}", b);
}

这里定义了变量 b,其值是一个指向被分配在堆上的值 5Box。这个程序会打印出 b = 5。由于实现了Derefprintln! 可以正常打印出 a 的值,它隐式地调用了 Deref 对智能指针 a 进行了解引用。在这个例子中,我们可以像数据是储存在栈上的那样访问box中的数据。正如任何拥有数据所有权的值那样,当像 b 这样的box在main的末尾离开作用域时,它将被释放。这个释放过程作用于box本身(位于栈上)和它所指向的数据(位于堆上),这是因为 Box<T> 实现了 Drop 特征。另外Box::new(T)会转移T的所有权。

不过,这段代码实际代码中其实很少会存在,因为将一个简单的值分配到堆上并没有太大的意义。将像单个 i32 这样的值储存在栈上,也就是其默认存放的地方在大部分使用场景中更为合适。

使用box避免大量数据拷贝

对于实现Copy特征的简单类型,值存储在栈上,当栈上数据转移所有权时,会进行内存的拷贝。如果是堆上的数据则底层数据并不会被拷贝,当转移所有权时,仅仅会拷贝栈上指向堆内存的指针,然后将这个指针绑定到新的位置上,再使原来的位置的指针失效即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fn main() {
// 在栈上创建一个长度为1000的数组
let arr = [0;1000];
// 将arr所有权转移arr1,由于 `arr` 分配在栈上,因此这里实际上是直接重新拷贝了一份数据
let arr1 = arr;

// arr 和 arr1 都拥有各自的栈上数组,因此不会报错
println!("{:?}", arr.len());
println!("{:?}", arr1.len());

// 在堆上创建一个长度为1000的数组,然后使用一个智能指针指向它
let arr = Box::new([0;1000]);
// 将堆上数组的所有权转移给 arr1,由于数据在堆上,因此仅仅拷贝了智能指针的结构体,底层数据并没有被拷贝
// 所有权顺利转移给 arr1,arr 不再拥有所有权
let arr1 = arr;
println!("{:?}", arr1.len());
// 由于 arr 不再拥有底层数组的所有权,因此下面代码将报错
// println!("{:?}", arr.len());
}

在希望转移所有权,但又希望大量数据不被拷贝,使用box就是很好的方案。

box允许创建递归类型

递归类型(recursive type)的值可以拥有另一个同类型的值作为其的一部分。这会产生一个问题,因为rust需要在编译时知道类型占用多少空间。递归类型的值嵌套理论上可以无限的进行下去,所以rust不知道递归类型需要多少空间。因为box有一个已知的大小,所以通过在循环类型定义中插入box,就可以创建递归类型了。

cons list就是递归类型的例子,它由嵌套的列表组成。它的名字来源于Lisp中的 cons 函数(construct function的缩写)。它利用两个参数来构造一个新的列表,通过对一个包含值的列表和另一个值调用cons,可以构建由递归列表组成的cons list。比如:

1
(1, (2, (3, Nil)))

cons list的每一项都包含两个元素:当前项的值和下一项,其最后一项值包含一个叫做 Nil 的值且没有下一项。

不过在rust中,这并不常见,当你需要列表的时候,Vec<T> 可能是一个更好的选择。不过,这里主要研究的是如果利用box创建这样的类型。

1
2
3
4
enum List {
Cons(i32, List),
Nil,
}

使用枚举创建了一个List,它要么是Cons要么是Nil,其中Cons包含一个 i32 值,还包含了一个新的 List,因此这种嵌套可以无限进行下去。

使用这个结构来储存列表 1, 2, 3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use crate::List::{Cons, Nil};
enum List {
Cons(i32, List),
Nil,
}
fn main() {
let list = Cons(1, Cons(2, Cons(3, Nil)));
}
/* 收到如下错误
error[E0072]: recursive type `List` has infinite size
--> src\bin\main.rs:12:1
|
12 | enum List {
| ^^^^^^^^^
13 | Cons(i32, List),
| ---- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
13 | Cons(i32, Box<List>),
| ++++ +
*/

错误表明这个类型有无限的大小,其原因是 List 的一个成员被定义为是递归的:它直接存放了另一个相同类型的值。

rust计算非递归类型的内存大小是这样的:

1
2
3
4
5
6
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}

对于上面的枚举,rust需要知道为 Message 分配多少空间,它可以检查每一个成员并发现 Message::Quit 并不需要任何空间,Message::Move 需要足够储存两个 i32 值的空间,依此类推。因为枚举实际上只会使用其中的一个成员,所以 Message 值所需的空间等于储存其最大成员的空间大小。

与此相对,当rust检查List时,它无法计算到底需要多少空间:编译器尝试计算出储存一个 List 枚举需要多少内存,并开始检查 Cons 成员,那么 Cons 需要的空间等于 i32 的大小加上 List 的大小。为了计算 List 需要多少内存,它检查其成员,从 Cons 成员开始。Cons 需要的空间等于 i32 的大小加上 List 的大小…这会无限递归下去。对于这种在编译时无法知道具体的大小的类型,称为动态大小类型DST(Dynamic Sized Type)。

注意上面编译器的报错提示“insert some indirection (e.g., a Box, Rc, or &) to break the cycle“,为了解决这个问题,可以使用box:

1
2
3
4
5
6
7
8
use crate::List::{Cons, Nil};
enum List {
Cons(i32, Box<List>),
Nil,
}
fn main() {
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}

因为 Box<T> 是一个指针,指针的大小并不会根据其指向的数据量而改变。这意味着可以将 Box 放入 Cons 成员中而不是直接存放另一个 List 值。Box 会指向另一个位于堆上的 List 值,而不是存放在 Cons 成员中。

Cons 成员将会需要一个 i32 的大小加上储存 box 指针数据的空间。Nil 成员不储存值,所以它比 Cons 成员需要更少的空间。现在我们知道了任何 List 值最多需要一个 i32 加上 box 指针数据的大小。

现在,就可以打破这种递归,顺利通过编译了。

Box::leak

Box 中还提供了一个非常有用的关联函数:Box::leak,它可以消费掉 Box 并且强制目标值从内存中泄漏,例如,你可以把一个 String 类型,变成一个 'static 生命周期的 &str 类型:

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let s = gen_static_str();
println!("{}", s);
}

fn gen_static_str() -> &'static str{
let mut s = String::new();
s.push_str("hello, world");

Box::leak(s.into_boxed_str())
}

在之前的代码中,如果 String 创建于函数中,那么返回它的唯一方法就是转移所有权给调用者 fn move_str() -> String,而通过 Box::leak 我们不仅返回了一个 &str 字符串切片,它还是 'static 生命周期的。要知道真正具有 'static 生命周期的往往都是编译期就创建的值,例如 let v = "hello, world",这里 v 直接打包到二进制可执行文件中,因此该字符串具有 'static 生命周期,再比如 const 常量。如果手动为变量标注 'static,并不代表真正的生命周期,而使用 Box::leak 就可以将一个运行期的值转为 'static

2.2 通过Deref特征对智能指针解引用

追踪指针的值

常规引用是一个指针类型:

1
2
3
4
5
6
7
fn main() {
let x = 5;
let y = &x;

assert_eq!(5, x);
assert_eq!(5, *y);
}

这里 y 就是一个常规引用,包含了值 5 所在的内存地址,然后通过解引用 *y,我们获取到了值 5。可以断言 x 等于 5。然而,如果希望对 y 的值做出断言,必须使用 *y 来追踪引用所指向的值(也就是解引用),一旦解引用了 y,就可以访问 y 所指向的整型值并可以与 5 做比较。

如果尝试编写 assert_eq!(5, y);,则会得到如下编译错误:

1
2
3
4
5
6
7
8
error[E0277]: can't compare `{integer}` with `&{integer}` //无法将{integer} 与&{integer}进行比较
--> src/main.rs:6:5
|
6 | assert_eq!(5, y);
| ^^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
|
= help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
// 你需要为{integer}实现用于比较的特征PartialEq<&{integer}>

不允许比较数字的引用与数字,因为它们是不同的类型。必须使用解引用运算符追踪引用所指向的值。

像引用一样使用Box<T>

可以使用 Box<T> 代替引用重写上一小节的代码:

1
2
3
4
5
6
7
fn main() {
let x = 5;
let y = Box::new(x);

assert_eq!(5, x);
assert_eq!(5, *y);
}

相比之下,主要不同的地方就是将 y 设置为一个指向 x 值拷贝的 Box<T> 实例,而不是指向 x 值的引用。在最后的断言中,可以使用解引用运算符以 y 为引用时相同的方式追踪 Box<T> 的指针。接下来让我们通过实现自己的类型来探索 Box<T> 能这么做有何特殊之处。

自定义智能指针

为了体会默认情况下智能指针与引用的不同,我们创建一个类似于标准库提供的 Box<T> 类型的智能指针。接着学习如何增加使用解引用运算符的功能。

1
2
3
4
5
6
7
struct MyBox<T>(T);

impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}

从根本上说,Box<T> 被定义为包含一个元素的元组结构体,所以这里以相同的方式定义了 MyBox<T> 类型。我们还定义了 new 函数来对应定义于 Box<T>new 函数。结构体 MyBox 使用了一个泛型参数 T,因为我们希望其可以存放任何类型的值。

MyBox 是一个包含 T 类型元素的元组结构体。MyBox::new 函数获取一个 T 类型的参数并返回一个存放传入值的 MyBox 实例。

现在,尝试用这个自定义的box替换掉标准库的Box<T>

1
2
3
4
5
6
7
fn main() {
let x = 5;
let y = MyBox::new(x);

assert_eq!(5, x);
assert_eq!(5, *y);
}

编译,会得到一个错误:

1
2
3
4
5
error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
--> src\bin\main.rs:14:19
|
14 | assert_eq!(5, *y);
| ^^

MyBox<T> 类型不能解引用,因为我们尚未在该类型实现这个功能。为了启用 * 运算符的解引用功能,需要实现 Deref 特征。

实现 Deref 特征

Deref特征由标准库提供,它要求实现名为deref的方法,其借用self并返回一个内部数据的引用。

1
2
3
4
5
6
7
8
9
use std::ops::Deref;

impl<T> Deref for MyBox<T> {
type Target = T;

fn deref(&self) -> &Self::Target {
&self.0
}
}

这里又出现了type Target = T ,它定义了用于此特征的关联类型,关联类型是一个稍有不同的定义泛型参数的方式,现在还无需过多的担心它。

deref 方法体中写入了 &self.0,这样 deref 返回了希望通过 * 运算符访问的值的引用。.0 用来访问元组结构体的第一个元素。

现在,之前的代码就可以编译并能通过断言了。没有Deref特征的话,编译器只会解引用 & 引用类型。deref方法向编译器提供了获取任何实现了Deref特征的类型的值,并且调用这个类型的deref方法来获取一个它知道如何解引用的&引用的能力。

事实上,输入 *y 时rust在底层运行了如下代码:

1
*(y.deref())

*运算符替换为先调用deref方法再进行普通解引用的操作,如此我们便不用担心是否还需手动调用deref方法了。rust的这个特性可以让我们写出行为一致的代码,无论是面对的是常规引用还是实现了Deref的类型。

deref 方法返回值的引用,以及 *(y.deref()) 括号外边的普通解引用仍为必须的原因在于所有权。如果 deref 方法直接返回值而不是值的引用,其值(的所有权)将被移出 self。在这里以及大部分使用解引用运算符的情况下我们并不希望获取 MyBox<T> 内部值的所有权。

注意每次当我们在代码中使用 * 时, * 运算符都被替换成了先调用 deref 方法再接着使用 * 解引用的操作,且只会发生一次,不会对 * 操作符无限递归替换,

函数和方法的隐式Deref强制转换

Deref强制转换(deref coercions)将实现了Deref特征的类型的引用转换为另一种类型的引用。例如,Deref强制转换可以将&String转换为&str,因为String实现了Deref特征因此可以返回&str

Deref强制转换是rust在函数或方法传参上的一种便利操作,并且只能作用于实现了Deref特征的类型。当这种特定类型的引用作为实参传递给和形参类型不同的函数或方法时将自动进行。这时会有一系列的 deref 方法被调用,把我们提供的类型转换成了参数所需的类型。Deref强制转换的加入使得程序员编写函数和方法调用时无需增加过多显式使用&*的引用和解引用。这个功能也使得我们可以编写更多同时作用于引用或智能指针的代码。

现在,继续使用上一节定义的MyBox<T>,看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::ops::Deref;
struct MyBox<T>(T);

impl<T> Deref for MyBox<T> {
type Target = T;

fn deref(&self) -> &Self::Target {
&self.0
}
}

impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}

fn hello(name: &str) {
println!("Hello, {name}!");
}
fn main() {
let m = MyBox::new(String::from("Rust"));
hello(&m);
}

这里定义了hello函数,它有&str 类型的参数 name,可以使用字符串切片作为参数调用hello函数,比如 hello("Rust");,而用 MyBox<String> 类型值的引用调用 hello 也可以通过编译。

这是因为在MyBox<T>上实现了Deref特征,rust可以通过deref调用将&MyBox<String>变为&String,标准库中提供了String上的Deref实现,其会返回字符串切片,rust再次调用deref&String变为&str,这就符合hello函数的定义了。从这里也可以看出,Deref强制转换支持连续的隐式转换。

如果rust没有实现Deref强制转换,为了使用 &MyBox<String> 类型的值调用hello,则不得不编写这样的代码:

1
2
3
4
fn main() {
let m = MyBox::new(String::from("Rust"));
hello(&(*m)[..]);
}

(*m)MyBox<String>解引用为 String。接着&[..]获取了整个String的字符串切片来匹配hello的签名。没有Deref强制转换所有这些符号混在一起将更难以读写和理解。Deref强制转换使得rust自动的帮我们处理这些转换。

当所涉及到的类型定义了Deref特征,rust会分析这些类型并使用任意多次Deref::deref调用以获得匹配参数的类型。这些解析都发生在编译时,所以利用Deref强制转换并没有运行时损耗。

在使用方法、赋值中的Deref强制转换如下:

1
2
3
4
5
fn main() {
let s = MyBox::new(String::from("hello, world"));
let s1: &str = &s;
let s2: String = s.to_string();
}

对于 s1,我们通过两次 Deref&str 类型的值赋给了它(赋值操作需要手动解引用);而对于 s2,我们在其上直接调用方法 to_string,实际上 MyBox 根本没有没有实现该方法,能调用 to_string,完全是因为编译器对 MyBox 应用了 Deref 的结果(方法调用会自动解引用)。

Deref强制转换与可变性

Deref特征重载不可变引用的*运算符,类似地,rust提供了DerefMut特征用于重载可变引用的 * 运算符。

rust在发现类型和特征实现满足三种情况时会进行Deref强制转换:

  • 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。第二种情况表明对于可变引用也有着相同的行为。

第三个情况有些微妙:rust也会将可变引用强转为不可变引用。但是反之是不可能的:不可变引用永远也不能强转为可变引用。因为根据借用规则,如果有一个可变引用,其当前必须是这些数据的唯一引用(否则程序将无法编译)。将一个可变引用转换为不可变引用永远也不会打破借用规则。将不可变引用转换为可变引用则需要初始的不可变引用是数据唯一的不可变引用,而借用规则无法保证这一点。因此,rust无法假设将不可变引用转换为可变引用是可能的。

还是以MyBox<T>为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
use std::ops::{Deref, DerefMut};
struct MyBox<T>(T);

impl<T> Deref for MyBox<T> {
type Target = T;

fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T> DerefMut for MyBox<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}

fn hello(name: &mut String) {
name.push_str("hello");
println!("Hello, {name}!");
}
fn main() {
let mut m = MyBox::new(String::from("Rust"));
hello(&mut m);

}

在上述代码中:

  • 要实现 DerefMut 必须要先实现 Deref 特征:pub trait DerefMut: Deref
  • T: DerefMut<Target=U> 解读:将 &mut T 类型通过 DerefMut 特征的方法转换为 &mut U 类型,对应上例中,就是将 &mut MyBox<String> 转换为 &mut String

2.3 使用Drop特征运行清理代码

对于智能指针来说第二个重要的特征是Drop,其允许我们在值要离开作用域时执行一些代码。可以为任何类型提供 Drop trait 的实现,同时所指定的代码被用于释放资源。

我们在智能指针里讨论 Drop 是因为其功能几乎总是用于实现智能指针,例如,当 Box<T> 被丢弃时会释放box指向的堆空间。

在一些语言中的某些类型,我们不得不记住在每次使用完智能指针实例后调用清理内存或资源的代码。如果忘记的话,运行代码的系统可能会因为负荷过重而崩溃。在rust中,可以指定每当值离开作用域时被执行的代码,编译器会自动插入这些代码。于是我们就不需要在程序中到处编写在实例结束时清理这些变量的代码,而且还不会泄漏资源。

指定在值离开作用域时应该执行的代码的方式是实现Drop特征。Drop特征要求实现一个叫做 drop 的方法,它获取一个 self 的可变引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
struct A();
struct B();
struct CustomSmartPointer {
dataA: A,
dataB: B,
}
impl Drop for A {
fn drop(&mut self) {
println!("Dropping A");
}
}
impl Drop for B {
fn drop(&mut self) {
println!("Dropping B");
}
}

impl Drop for CustomSmartPointer {
fn drop(&mut self) {
println!("Dropping CustomSmartPointer!");
}
}

fn main() {
let s = CustomSmartPointer{
dataA: A{},
dataB: B{},
};
let t = A{};
println!("CustomSmartPointers created.");
}

这个例子中,展示了rust何时执行drop方法。Drop特征包含在 prelude 中,所以无需导入它,drop 函数体是放置任何当类型实例离开作用域时期望运行的逻辑的地方,这里暂时使用println!代替。

编译运行,会得到如下打印结果:

1
2
3
4
5
CustomSmartPointers created.
Dropping A
Dropping CustomSmartPointer!
Dropping A
Dropping B

这说明,当实例离开作用域rust会自动调用 drop,并调用我们指定的代码。同时可以看出drop的顺序:

  • 变量以被创建时相反的顺序被丢弃,所以 ts 之前被丢弃。
  • 结构体内部按照字段定义的顺序丢弃,所以先丢弃dataA后丢弃dataB
  • 先丢弃结构体再丢弃内部的字段

另外,rust为几乎所有类型都实现了Drop,因此,注释掉下面的这段代码:

1
2
3
4
5
impl Drop for CustomSmartPointer {
fn drop(&mut self) {
println!("Dropping CustomSmartPointer!");
}
}

也能够正常通过编译。

通过std::mem::drop手动丢弃

有时你可能需要提早清理某个值。一个例子是当使用智能指针管理锁时;你可能希望强制运行 drop 方法来释放锁以便作用域中的其他代码可以获取锁。rust并不允许我们主动调用 Drop 特征的drop方法(std::ops::Drop::drop),当我们希望在作用域结束之前就强制释放变量的话,我们应该使用的是由标准库提供的std::mem::drop

这二者是不同的,前者在值超出作用域后被隐式调用,并且不允许显式调用。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Foo {
x: i32,
}

impl Drop for Foo {
fn drop(&mut self) {
println!("kaboom");
}
}

fn main() {
let mut x = Foo { x: -7 };
x.drop(); // error: explicit use of destructor method
}

当你进行x.drop()时,编译器会报如下错误:

1
2
3
4
5
6
   |
13 | x.drop(); // error: explicit use of destructor method
| --^^^^--
| | |
| | explicit destructor calls not allowed
| help: consider using `drop` function: `drop(x)`

rust不允许我们显式调用std::ops::Drop::drop,因为rust仍然会在main作用域的结尾对值自动调用drop,这会导致一个double free错误,因为rust会尝试清理相同的值两次。

这里提示你使用drop函数,如果需要强制提早清理值,可以使用std::mem::drop函数,它位于prelude,无需显式导入即可使用。该方法可以通过传递希望强制丢弃的值作为参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Foo {
x: i32,
}

impl Drop for Foo {
fn drop(&mut self) {
println!("kaboom");
}
}

fn main() {
let mut x = Foo { x: -7 };
drop(x); // passed!
}

实际上,std::mem::drop函数的源码是这样的:

1
pub fn drop<T>(_x: T) { }

这个drop函数内部是一个空实现,它所做的仅仅是带走_x的所有权。该值在drop作用域的末尾仍然会自动调用std::ops::Drop::drop,以完成丢弃工作。虽然看起来是你手动丢弃了x,但编译器并不知道这些,它所做的只是移动x的所有权,然后在作用域结尾将其自动丢弃。

互斥的CopyDrop

无法为一个类型同时实现 CopyDrop 特征。因为实现了 Copy 的特征会被编译器隐式的复制,因此非常难以预测析构函数执行的时间和频率。因此这些实现了 Copy 的类型无法拥有析构函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#[derive(Copy)]
struct Foo;

impl Drop for Foo {
fn drop(&mut self) {
println!("Dropping Foo!")
}
}
/* 错误如下:
|
1 | #[derive(Copy)]
| ^^^^ `Copy` not allowed on types with destructors
|
= note: this error originates in the derive macro `Copy` (in Nightly builds, run with -Z macro-backtrace for more info)
*/

总之,Drop 特征实现中指定的代码可以用于许多方面,来使得清理变得方便和安全:比如可以用其创建我们自己的内存分配器。通过 Drop 特征和rust所有权系统,你无需担心之后的代码清理,rust会自动考虑这些问题。

我们也无需担心意外的清理掉仍在使用的值,这会造成编译器错误:所有权系统确保引用总是有效的,也会确保 drop 只会在值不再被使用时被调用一次。

2.4 Rc<T>引用计数智能指针

大部分情况下所有权是非常明确的:可以准确地知道哪个变量拥有某个值。然而,有些情况单个值可能会有多个所有者。例如,在图数据结构中,多个边可能指向相同的节点,而这个节点从概念上讲为所有指向它的边所拥有。节点直到没有任何边指向它之前都不应该被清理。

为了启用多所有权需要显式地使用rust类型 Rc<T>,其为引用计数(reference counting)的缩写。引用计数意味着记录一个值引用的数量来知晓这个值是否仍在被使用。如果某个值有零个引用,就代表没有任何有效引用并可以被清理。引用计数并不是rust独有的,在C++11中,也有同样的概念。在一些语言的垃圾回收(GC)机制中,也用到了引用计数算法。

Rc<T> 用于当我们希望在堆上分配一些内存供程序的多个部分读取,而且无法在编译时确定程序的哪一部分会最后结束使用它的时候。如果确实知道哪部分是最后一个结束使用的话,就可以令其成为数据的所有者,正常的所有权规则就可以在编译时生效。

使用Rc<T>共享数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn main() {
let s = String::from("hello world");
let t = Box::new(s);
let u = Box::new(s);
}
/*
|
12 | let s = String::from("hello world");
| - move occurs because `s` has type `String`, which does not implement the `Copy` trait
13 | let t = Box::new(s);
| - value moved here
14 | let u = Box::new(s);
| ^ value used here after move
*/

这里使用两次box存储s,毫无疑问会报错。因为第一次调用Box::new时已经将s所有权移动到box。

现在,使用 Rc<T> 代替 Box<T>

1
2
3
4
5
6
7
use std::rc::Rc;
fn main() {
let s = String::from("hello world");
let t = Rc::new(s);
let u = Rc::clone(&t);
println!("{}", u);
}

需要使用 use 语句将 Rc<T> 引入作用域,因为它不在prelude中。使用 Rc::new 函数创建了一个新的 Rc<String> 智能指针并将s储存到变量 t,在调用Rc::new函数时,会将引用计数加1。然后调用 Rc::clone 函数并传递 tRc<String> 的引用作为参数,此时的引用计数增加到 2

也可以调用 t.clone() 而不是 Rc::clone(&t),不过在这里rust的习惯是使用 Rc::clone函数。Rc::clone 的实现并不像大部分类型的 clone 实现那样对所有数据进行深拷贝。Rc::clone 只会增加引用计数,这并不会花费多少时间。深拷贝可能会花费很长时间。通过使用 Rc::clone 进行引用计数,可以明显的区别深拷贝类的克隆和增加引用计数类的克隆。当查找代码中的性能问题时,只需考虑深拷贝类的克隆而无需考虑 Rc::clone 调用。

查看引用计数的变化

接下来,修改上一节的例子,观察引用计数的变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
use std::rc::Rc;
fn main() {
let s = String::from("hello world");
let t = Rc::new(s);
println!("count after creating t = {}", Rc::strong_count(&t));
let z = Rc::clone(&t);
println!("count after creating z = {}", Rc::strong_count(&t));
{
let u = Rc::clone(&t);
println!("count after creating u = {}", Rc::strong_count(&t));
}
println!("count after u goes out of scope = {}", Rc::strong_count(&t));
}

使用Rc::strong_count函数观察tRc<String> 的的引用计数变化,这段代码会打印出:

1
2
3
4
count after creating t = 1
count after creating z = 2
count after creating u = 3
count after u goes out of scope = 2

能够看到 tRc<String> 的初始引用计数为1,接着每次调用 clone,计数会增加1。当 u 离开作用域时,计数减1。不必像调用 Rc::clone 增加引用计数那样调用一个函数来减少计数;由于Drop特征的实现,当 Rc<T> 值离开作用域时会自动减少引用计数。

从这个例子我们所不能看到的是,在 main 的结尾当 z 然后是 t 离开作用域时,此处计数会是0,同时 Rc<String> 被完全清理。使用 Rc<T> 允许一个值有多个所有者,引用计数则确保只要任何所有者依然存在其值也保持有效。

不可变引用

实际上,Rc<T> 是指向底层数据的不可变的引用,它允许在程序的多个部分之间只读地共享数据。如果 Rc<T> 也允许多个可变引用,则会违反借用规则之一:相同位置的多个可变借用可能造成数据竞争和不一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::rc::Rc;
fn main() {
let s = String::from("hello world");
let mut t = Rc::new(s);
println!("{}", *t);
*t = String::from("a"); // error!
}
/*
error[E0594]: cannot assign to data in an `Rc`
--> src\main.rs:6:5
|
6 | *t = String::from("a");
| ^^ cannot assign
|
= help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `Rc<String>`
*/

Rc<T>只能用于单线程,后面会介绍如何用Arc在多线程中使用计数引用,详见线程安全的引用计数Arc<T>

当然,我们有时也会需要修改数据,在下一小节进行介绍。

2.5 内部可变性模式和RefCell<T>

内部可变性模式

内部可变性(Interior mutability)是rust中的一个设计模式,它允许你即使在有不可变引用时也可以改变数据,这通常是借用规则所不允许的。为了改变数据,该模式在数据结构中使用unsafe代码来模糊rust通常的可变性和借用规则。不安全代码表明我们在手动检查这些规则而不是让编译器替我们检查。

当可以确保代码在运行时会遵守借用规则,即使编译器不能保证的情况,可以选择使用那些运用内部可变性模式的类型。所涉及的 unsafe 代码将被封装进安全的 API 中,而外部类型仍然是不可变的。后面会介绍unsafe,见不安全的rust

通过RefCell<T> 在运行时检查借用规则

不同于 Rc<T>RefCell<T> 代表其数据的唯一的所有权。那么RefCell<T>Box<T>的区别是什么?回忆一下借用规则:

  • 在任意给定时刻,只能拥有一个可变引用或任意数量的不可变引用之一(而不是两者)
  • 引用必须总是有效的

对于引用和 Box<T>,借用规则的不可变性作用于编译时。对于 RefCell<T>,这些不可变性作用于运行时。对于引用,如果违反这些规则,会得到一个编译错误。而对于 RefCell<T>,如果违反这些规则程序会panic并退出。

在编译时检查借用规则的优势是这些错误将在开发过程的早期被捕获,同时对运行时没有性能影响,因为所有的分析都提前完成了。为此,在编译时检查借用规则是大部分情况的最佳选择,这也正是rust的默认行为。

相反在运行时检查借用规则的好处则是允许出现特定内存安全的场景,而它们在编译时检查中是不允许的。静态分析,正如rust编译器,是天生保守的。但代码的一些属性不可能通过分析代码发现:其中最著名的就是 停机问题(Halting Problem)

因为一些分析是不可能的,如果rust编译器不能通过所有权规则编译,它可能会拒绝一个正确的程序;从这种角度考虑它是保守的。如果rust接受不正确的程序,那么用户也就不会相信rust所做的保证了。然而,如果rust拒绝正确的程序,虽然会给程序员带来不便,但不会带来灾难。RefCell<T> 正是用于当你确信代码遵守借用规则,而编译器不能理解和确定的时候。

类似于 Rc<T>RefCell<T> 只能用于单线程场景。如果尝试在多线程上下文中使用RefCell<T>,会得到一个编译错误。

内部可变性

借用规则的一个推论是当有一个不可变值时,不能可变地借用它。例如,如下代码不能编译:

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let x = 5;
let y = &mut x;
}
/*
|
12 | let x = 5;
| - help: consider changing this to be mutable: `mut x`
13 | let y = &mut x;
| ^^^^^^ cannot borrow as mutable
*/

然而,特定情况下,令一个值在其方法内部能够修改自身,而在其他代码中仍视为不可变,是很有用的。RefCell<T> 就是一个获得内部可变性的方法,它并没有完全绕开借用规则,编译器中的借用检查器允许内部可变性并相应地在运行时检查借用规则。如果违反了这些规则,会出现panic而不是编译错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use std::cell::RefCell;

fn main() {
let owner1 = RefCell::new(4);
println!("init value owner1 = {:?}", owner1);
{
let mut m1 = owner1.borrow_mut();
*m1 = 3;
println!("m1 set new value: {:?}", m1);
println!("owner1 = {:?}", owner1);
}
println!("owner1 = {:?}", owner1);
let m2 = owner1.borrow_mut();
println!("m2 = {:?}", m2);
}

这里引入了std::cell::RefCell,并使用new创建了一个RefCell<i32>的引用。当创建不可变和可变引用时,我们分别使用 &&mut 语法。对于 RefCell<T> 来说,则是 borrowborrow_mut 方法,这属于 RefCell<T> 安全API的一部分。borrow 方法返回 Ref<T> 类型的智能指针,borrow_mut 方法返回 RefMut<T> 类型的智能指针。这两个类型都实现了 Deref,所以可以当作常规引用对待。它的运行结果如下:

1
2
3
4
5
init value owner1 = RefCell { value: 4 }
m1 set new value: 3
owner1 = RefCell { value: <borrowed> }
owner1 = RefCell { value: 3 }
m2 = 3

初始时,owner1储存的值为4,且没有使用mut。然后通过borrow_mut方法获取了一个可变引用,并修改原始值为3,此时owner1中的可变引用计数为1,当离开{}作用域时,可变引用计数减1,然后又创建了可变引用m2。这符合借用规则,因此在运行时也不会报错。并且,我们通过RefCell获取了内部可变性,即使没有mut也能够修改自身。

如果去掉{}

1
2
3
4
5
6
7
8
9
10
11
12
13
use std::cell::RefCell;

fn main() {
let owner1 = RefCell::new(4);
println!("init value owner1 = {:?}", owner1);

let mut m1 = owner1.borrow_mut();
*m1 = 3;
println!("m1 set new value: {:?}", m1);
println!("owner1 = {:?}", owner1);
let m2 = owner1.borrow_mut();
println!("m2 = {:?}", m2);
}

虽然代码可以通过编译,但是在运行时仍然会报错:thread 'main' panicked at 'already borrowed: BorrowMutError'。这是因为违反了借用规则:可变引用计数为2(m1m2)。

这看起来像是推迟了报错的时机:从编译时报错推迟到运行时报错。它的意义在于,rust编译器是保守的,当编译器无法判断代码是否正确,就一律按照错误处理,而RefCell则是让编译器相信你的代码是正确的:至少可以通过编译。

总之,当你确信编译器误报但不知道该如何解决时,或者你有一个引用类型,需要被四处使用和修改然后导致借用关系难以管理时,都可以优先考虑使用 RefCell

RefCell<T> 记录当前有多少个活动的 Ref<T>RefMut<T> 智能指针。每次调用 borrowRefCell<T> 将活动的不可变借用计数加1。当 Ref<T> 值离开作用域时,不可变借用计数减1,可变引用同理。就像编译时借用规则一样,RefCell<T> 在任何时候只允许有多个不可变借用或一个可变借用。如果我们尝试违反这些规则,相比引用时的编译时错误,RefCell<T> 的实现会在运行时出现panic。

Cell<T>

Cell<T>RefCell<T> 在功能上没有区别,区别在于 Cell<T> 适用于 T 实现Copy特征的情况:

1
2
3
4
5
6
7
8
use std::cell::Cell;
fn main() {
let c = Cell::new("asdf");
let one = c.get();
c.set("qwer");
let two = c.get();
println!("{},{}", one, two);
}

以上代码展示了 Cell 的基本用法,变量c前面没有mut,但是我们仍然可以改变它。有几点值得注意:

  • “asdf” 是 &str 类型,它实现了 Copy 特征
  • c.get 用来取值,c.set 用来设置新值

取到值保存在 one 变量后,还能同时进行修改,这个违背了rust的借用规则,但是由于 Cell 的存在,我们很优雅地做到了这一点,但是如果你尝试在 Cell 中存放String

1
let c = Cell::new(String::from("asdf"));

编译器会立刻报错,因为 String 没有实现 Copy 特征。

由于 Cell 类型针对的是实现了 Copy 特征的值类型,因此在实际开发中,Cell 使用的并不多,以下是CellRefCell的区别:

  • Cell 只适用于 Copy 类型,用于提供值,而 RefCell 用于提供引用
  • Cell 不会 panic,而 RefCell
  • Cell 没有额外的性能损耗,RefCell 有一点运行期开销,它包含了一个字大小的“借用状态”指示器,该指示器在每次运行时借用时都会被修改

总之,需要使用内部可变性时,可以首选 Cell,只有需要存储的类型没有实现 Copy 特征,才去选择 RefCell

最后,CellRefCell仅用于单线程的共享引用,Mutex<T>是一个线程安全版本的RefCell<T>,在并发编程章节中会介绍它,详见:互斥锁Mutex

RefCell<T>的使用场景

很多时候RefCell<T>都用在结构体的字段上。这样,你就可以共享这个结构体,但是仍能够对某个字段做修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
pub trait Messenger {
fn send(&self, msg: String);
}

struct MsgQueue {
msg_cache: Vec<String>,
}

impl Messenger for MsgQueue {
fn send(&self, msg: String) {
self.msg_cache.push(msg)
}
}

这里定义了一个消息发送器特征 Messenger,它只有一个发送消息的功能:fn send(&self, msg: String),因为发送消息不需要修改自身,因此在定义时,使用了 &self 的不可变借用。

然后我们使用该特征实现一个异步消息队列,出于性能的考虑,消息先写到本地缓存(内存)中,然后批量发送出去,因此在 send 方法中,需要将消息先行插入到本地缓存 msg_cache 中。但问题是,该 send 方法的签名是 &self,上述代码会报错:

1
2
3
4
5
6
   |
3 | fn send(&self, msg: String);
| ----- help: consider changing that to be a mutable reference: `&mut self`
...
14 | self.msg_cache.push(msg)
| ^^^^^^^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be borrowed as mutable

虽然编译器提示修改函数签名,但我们希望在不修改这个特征定义的前提下,解决这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use std::cell::RefCell;
pub trait Messenger {
fn send(&self, msg: String);
}

pub struct MsgQueue {
msg_cache: RefCell<Vec<String>>,
}

impl Messenger for MsgQueue {
fn send(&self, msg: String) {
self.msg_cache.borrow_mut().push(msg)
}
}

fn main() {
let mq = MsgQueue {
msg_cache: RefCell::new(Vec::new()),
};
mq.send("hello, world".to_string());
}

通过修改结构体字段的定义,包裹一层 RefCell,让 &self 中的 msg_cache 成为一个可变值,然后实现对其的修改,并且也没有修改Messenger特征的定义。

组合使用Rc<T>RefCell<T>来拥有多个可变数据所有者

RefCell<T> 的一个常见用法是与 Rc<T> 结合。 Rc<T> 允许对相同数据有多个所有者,不过只能提供数据的不可变访问。如果有一个储存了 RefCell<T>Rc<T> 的话,就可以得到有多个所有者并且可以修改的值。

1
2
3
4
5
6
7
8
9
10
11
12
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let s = Rc::new(RefCell::new("hello".to_string()));

let s1 = s.clone();
let s2 = s.clone();
// let mut s2 = s.borrow_mut();
s2.borrow_mut().push_str(", world");

println!("{:?}\n{:?}\n{:?}", s, s1, s2);
}

上面代码中,我们使用 RefCell<String> 包裹一个字符串,同时通过Rc::new方法创建了它的三个所有者:ss1s2,并且通过其中一个所有者 s2 对字符串内容进行了修改。由于 Rc 的所有者们共享同一个底层的数据,因此当一个所有者修改了数据时,会导致全部所有者持有的数据都发生了变化。

运行结果也是如此:

1
2
3
RefCell { value: "hello, world" }
RefCell { value: "hello, world" }
RefCell { value: "hello, world" }

2.6 Borrow, BorrowMut, ToOwned

下面介绍三个与借用数据相关的三个特征,它们全部位于std::borrow下。

Borrow

第一个特征是Borrow,定义是这样的:

1
2
3
4
5
6
7
pub trait Borrow<Borrowed>
where
Borrowed: ?Sized,
{
// Required method
fn borrow(&self) -> &Borrowed;
}

这里的Borrowed就是被借用的类型。在rust中,通常为不同的用例提供类型的不同表示。例如,可以通过诸如 Box<T>Rc<T> 之类的指针类型选择合适的存储位置和管理方式来使用一个值。除了这些可以与任何类型一起使用的通用包装器之外,一些类型还提供了可选的特性,从而提供潜在的可能代价高昂的功能(potentially costly functionality)。这种类型的一个示例是 String ,它向基本 str 添加了扩展字符串的功能。这需要为简单、不可变的字符串保留不必要的附加信息。

这些类型通过对数据类型的借用提供对基础数据的访问。例如,可以将Box<T>作为T进行借用,而String可以作为str进行借用。

这些类型可以实现Borrow<T>来表示它们可以作为某种类型T进行借用,并在特征的 borrow 方法中提供对 T 的借用的具体实现。一种类型可以自由借用为多种不同的类型。如果它希望可变地借用类型——允许修改底层数据,它可以另外实现 BorrowMut<T>——我们很快在后面会介绍。

此外,在提供其他特征的实现时,需要考虑它们是否应该由于充当底层类型的表示而与底层类型具有相同的行为。当通用代码依赖于这些附加特征实现的相同行为时,通常可以使用 Borrow<T> 。特别是 EqOrdHash 对于借用值和自有值必须等效: x.borrow() == y.borrow() 应该给出与 x == y 相同的结果。

上面这句话可能不太好理解,简单来说就是,有一个类型U,当你为U实现了Borrow<T>,这意味着你可以将U作为T进行借用。此时,当你再为U实现其它的特征时(尤其是 EqOrdHash),比如Eq,那么为U实现的行为应该与为T实现Eq的行为相同。

再具体一点,比如哈希表HashMap<K, V>拥有键和值。如果键的实际数据包装在某种管理类型中,则仍然可以使用对键数据的引用来搜索值。例如,如果键是字符串,则它可能与哈希映射一起存储为 String ,而应该可以使用 &str 进行搜索。因此, insert 需要在 String 上操作,而 get 需要能够使用 &str 。下面是HashMap<K, V>的简化实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::borrow::Borrow;
use std::hash::Hash;

pub struct HashMap<K, V> {
// fields omitted
}

impl<K, V> HashMap<K, V> {
pub fn insert(&self, key: K, value: V) -> Option<V>
where K: Hash + Eq
{
// ...
}

pub fn get<Q>(&self, k: &Q) -> Option<&V>
where
K: Borrow<Q>,
Q: Hash + Eq + ?Sized
{
// ...
}
}

整个哈希映射对于键类型 K 是通用的。由于这些键与哈希映射一起存储,因此该类型必须拥有键的数据。当插入键值对时,映射会被赋予这样一个 K ,并且需要找到正确的哈希桶并根据该 K 检查该键是否已经存在。因此它需要 K: Hash + Eq

当我们使用get方法时,参数k类型是&Q,而不是&K。它指出 K 通过要求 K: Borrow<Q> 作为 Q 借用。通过额外要求 Q: Hash + Eq ,它表示要求 KQ 具有 HashEq 的实现产生相同结果的特征。假设哈希表为:HashMap<String, _>,那么这里的K就是String,由于标准库为String实现了Borrow<str>,并且特征约束中要求K: Borrow<Q>,因此这里的Q就可以是str,这就意味着我们可以通过get(k: &str)来获取键对应的值。同时,注意到Q的特征约束Q: Hash + Eq + ?Sized,这就是额外的特征,这就是要求为String实现Hash + Eq和为str实现Hash + Eq产生的结果必须相同。

什么叫做产生的结果相同呢?我们插入哈希表时使用的是String,也就是说我们根据String计算了哈希值,但是get使用的是&str,因此就必须保证根据str计算的哈希值与根据String计算的哈希值保持相同。

因此,如果包装 Q 值的 K 产生与 Q 不同的哈希值,则哈希映射会中断。

假设有一个包装字符串的类型,它的Hash 实现忽略 ASCII 大小写:

1
2
3
4
5
6
7
8
9
pub struct CaseInsensitiveString(String);

impl Hash for CaseInsensitiveString {
fn hash<H: Hasher>(&self, state: &mut H) {
for c in self.0.as_bytes() {
c.to_ascii_lowercase().hash(state)
}
}
}

CaseInsensitiveString 可以实现 Borrow<str> 吗?它当然可以通过其包含的拥有的字符串提供对字符串切片的引用。但由于其 Hash 实现不同,因此其行为与 str 不同,因此实际上不能实现 Borrow<str> 。如果它想允许其他人访问底层 str ,它可以通过 AsRef<str> 来实现,这个特征与Borrow不同,它不带有任何额外的要求。

BorrowMut

1
2
3
4
5
6
7
pub trait BorrowMut<Borrowed>: Borrow<Borrowed>
where
Borrowed: ?Sized,
{
// Required method
fn borrow_mut(&mut self) -> &mut Borrowed;
}

作为 Borrow<T> 的好朋友,此特征允许通过提供可变引用来借用类型作为基础类型。它要求实现borrow_mut方法。另外,这里的定义用到了特征的“继承”,在深入理解Fn特征中,介绍过这种语法:

1
2
trait A {}
trait B: A {} //特征`B`继承特征`A`

因此,由于BorrowMut继承自Borrow,要实现BorrowMut就必须先实现Borrow。比如Vec<T>实现了BorrowMut<[T]>,所以可以将Vec<T>作为[T]进行借用。见示例:

1
2
3
4
5
6
7
8
9
use std::borrow::BorrowMut;

fn check<T: BorrowMut<[i32]>>(mut v: T) {
assert_eq!(&mut [1, 2, 3], v.borrow_mut());
}

let v = vec![1, 2, 3];

check(v);

泛型T自动实现了Borrow和BorrowMut

rust为泛型T&T自动实现了Borrow<T>BorrowMut<T>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#[stable(feature = "rust1", since = "1.0.0")]
impl<T: ?Sized> Borrow<T> for T {
#[rustc_diagnostic_item = "noop_method_borrow"]
fn borrow(&self) -> &T {
self
}
}

#[stable(feature = "rust1", since = "1.0.0")]
impl<T: ?Sized> BorrowMut<T> for T {
fn borrow_mut(&mut self) -> &mut T {
self
}
}

#[stable(feature = "rust1", since = "1.0.0")]
impl<T: ?Sized> Borrow<T> for &T {
fn borrow(&self) -> &T {
&**self
}
}

#[stable(feature = "rust1", since = "1.0.0")]
impl<T: ?Sized> Borrow<T> for &mut T {
fn borrow(&self) -> &T {
&**self
}
}

#[stable(feature = "rust1", since = "1.0.0")]
impl<T: ?Sized> BorrowMut<T> for &mut T {
fn borrow_mut(&mut self) -> &mut T {
&mut **self
}
}

Borrow<T>BorrowMut<T>之所以被定义为泛型特征,可以让同一个类型同时有多个实现BorrowBorrowMut特征的类型,这样,一个类型就可以同时被借用为多个不同的类型:

1
2
3
4
5
6
fn main() {
let s = String::from("hello");
let s1: &str = s.borrow();
let s2: &String = s.borrow();
println!("s1: {s1:p}, s2: {s2:p}"); // s1: 0x7ff58ec05bc0, s2: 0x7ffee9169fe0
}

引用类型&str&String都可以作为String类型的借用。即通过实现Borrow特征可以让一个类型被借用成不同的引用。

ToOwned

下面看另一种特征:

1
2
3
4
5
6
7
8
9
pub trait ToOwned {
type Owned: Borrow<Self>;

// Required method
fn to_owned(&self) -> Self::Owned;

// Provided method
fn clone_into(&self, target: &mut Self::Owned) { ... }
}

这里type Owned: Borrow<Self>叫做关联类型。简单来说,这个类型需要ToOwned的实现者定义,而设计特征的人不需要关心它具体是什么。另外,这个类型Owned有一个特征约束,该类型必须实现Borrow<Self>

特征要求实现to_owned方法,它可以将类型从借用变为拥有所有权。一般来说,这种转换可以通过Clone特征达到,为什么还需要ToOwned特征呢?我们先通过一个例子看看使用cloneto_owned的区别,然后再概括总结。

举一个例子,如果一个类型实现了Clone 特征,则从这个类型的引用,生成并拥有其所指向对象副本的通用方式是调用clone,比如从一个&String克隆得到String,可以从一个&Vec<i32>克隆得到Vec<i32>

1
2
3
let s: String = String::from("hello");
let s1: &String = s.borrow();
let s2: String = s1.clone();

这里,从s1进行clone,可以得到String类型的s2

但是,当你要克隆&str时:

1
2
let s: &str = "hello";
let s1: &str = s.clone();

得到的仍然是&str。此时就不可以使用clone了,而是要使用to_owned

1
2
let s: &str = "hello";
let s1: String = s.to_owned();

我们看到了二者的区别,参考这篇文章:Rust | The Difference Between .clone() and .to_owned(),下面尝试概括它们。

.clone().to_owned() 之间的差异发生在切片(例如字符串切片 &str )或具有未定义容量的数组(例如 &[i8] ,处于借用状态 ( &T ):

  • .clone() 生成类型的副本,例如 &str&[u8] ,其借用状态具有相同类型 ( &T )。这意味着在 &str&[u8] 上使用 .clone() 将分别生成 &str[u8]
  • .to_owned() 生成具有所有权的类型的副本,例如 &str&[u8] 。这意味着在 &str&[u8] 上使用 .to_owned() 将分别生成 StringVec<u8>

.clone() 方法可用于派生 Clone 特征的所有类型。默认情况下,所有基本类型( stri8u8chararraybool 等),可以访问 .clone() 方法。并且,.clone() 生成具有相同类型 T 的对象 T 的副本,这意味着如果:

  • a 是 u8 ,副本将是 u8
  • a 是 String ,副本将是 String
  • a 是 &[&str] ,副本将是 &[&str]
  • a 是自定义结构体 MyObject ,副本将是 MyObject

我们知道,rust有几种主要基本类型(数值类型,布尔类型,字符类型),它们代表单一值。这意味着所有整数 ( 12 )、浮点数 ( 3.4 )、布尔值 ( truefalse ) 和字符 ( 'a''z' )无论使用多少次都具有相同的值。正因为如此,它使得从借用状态生成拥有的副本成为可能。换句话说,从 &TT

1
2
3
4
5
6
7
8
9
10
11
let integer: &u8 = &1; // type is &u8
let cloned_integer = integer.clone(); // type is u8

let floating_number = &2.3; // type is &f64
let cloned_floating_number = floating_number.clone(); // type is f64

let boolean = &true; // type is &boolean
let cloned_boolean = boolean.clone(); // type is boolean

let character = &'a'; // type is &char
let cloned_character = character.clone(); // type is char

当原始数组具有所有权时, .clone() 方法可以复制数组,这意味着从 [T][T]

1
2
let owned_array: [i32; 3] = [1, 2, 3];
let cloned_owned_array = owned_array.clone(); // type is [i32; 3]

只要定义了数组容量, .clone() 也可以从 &[T][T]

1
2
let borrowed_array: &[i32; 3] = &[1, 2, 3];
let cloned_borrowed_array = borrowed_array.clone(); // type is [i32; 3]

即使没有分配显式类型定义( &[T] ),数组容量也将被隐式定义。在以下示例中,生成的副本是 [T] 中的 [T]

1
2
let borrowed_array = &[1, 2, 3]; // it will implicitly define the type as &[i32; 3]
let cloned_borrowed_array = borrowed_array.clone(); // type is [i32; 3]

但是,如果定义类型为变量是数组而没有显式定义容量,虽然 .clone() 仍会生成副本。然而,这个新的副本仍处于借用状态。因此,它将从 &[T]&[T]

1
2
3
4
5
let numbers: &[i32] = &[1, 2, 3];
let cloned_numbers = numbers.clone(); // type is [&i32]

let strs: &[&str] = &["asfa", "saf", "asfas"];
let cloned_strs = strs.clone(); // type is &[&str]

正如rust文档所述, .to_owned() 方法是借用数据的 Clone 特征的泛化。这意味着 .to_owned() 生成副本。因此,如果在 T 上应用 .to_owned() ,则副本将为 T

1
2
3
4
5
6
7
8
let number:u8 = 10; // type u8
let to_owned_number = number.to_owned(); // type u8

let string: String = String::from("Hello, world!"); // type String
let to_owned_string = string.to_owned(); // type String

let my_object = MyObject::new(); // type MyObject
let to_owned_my_object = my_object.to_owned(); // type MyObject

.to_owned() 的一个关键方面是该方法能够从给定类型的任何借用中构造拥有的数据。这意味着,如果将 .to_owned() 应用于任何 &T ,则副本将为 T

1
2
3
4
5
6
7
8
let number: &u8 = &10; // type u8
let cloned_number = number.to_owned(); // type u8

let string: &String = &String::from("Hello, world!"); // type &String
let cloned_string = string.to_owned(); // type String

let my_object: &MyObject = &MyObject::new(); // type &MyObject
let cloned_my_object = my_object.to_owned(); // type MyObject

对于刚才字符串切片的例子而言,字符串切片引用 &str 处于借用状态。这意味着,存储字符串切片引用的任何变量都不拥有该值。我们已经知道.clone() 方法可以从 &T 转换为 T ,其中 T 可以是基本类型,例如布尔值或数字或自定义结构,例如 MyObject 。但它们与 str 的区别在于,字符串切片不是原始类型,而是字符序列。换句话说,字符数组 [T] 。字符串切片的一个特点是没有所有权。 .clone() 方法尝试从“借用”变为“拥有所有权”成为可能,但这并不意味着总是会发生。如你所知,这就是字符串切片引用&str的情况。

.to_owned() “概括” Clone 特征以通过借用给定类型构造数据。 to_owned() 的核心功能是确保副本始终拥有所有权,这意味着即使必须以与原始数据类型不同的数据类型分配值,也要保证这一点。

通过从字符串切片引用 &str 生成 Stringto_owned() 满足提供所有权的标准。因为如果你查看 String 的定义,你会发现它是一个基于Vec<u8>的结构体:

1
2
3
pub struct String {
vec: Vec<u8>
}

还有另一个有趣的事情,使用 .to_owned() 方法会得到不同类型的副本。当将 .to_owned() 应用于具有未定义容量的引用数组时,会发生这种情况,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
let array_i8: &[i8] = &[1, 2, 3];
let cloned_array_i8 = array_i8.to_owned(); // type is Vec<i8>

let array_i16: &[i16] = &[1, 2, 3];
let cloned_array_i16 = array_i16.to_owned(); // type is Vec<i16>

let array_i32: &[i32] = &[1, 2, 3];
let cloned_array_i32 = array_i32.to_owned(); // type is Vec<i32>

let array_i64: &[i64] = &[1, 2, 3];
let cloned_array_i64 = array_i64.to_owned(); // type is Vec<i64>

let array_i128: &[i128] = &[1, 2, 3];
let cloned_array_i128 = array_i128.to_owned(); // type is Vec<i128>

let array_u8: &[u8] = &[1, 2, 3];
let cloned_array_u8 = array_u8.to_owned(); // type is Vec<u8>

let array_u16: &[u16] = &[1, 2, 3];
let cloned_array_u16 = array_u16.to_owned(); // type is Vec<u16>

let array_u32: &[u32] = &[1, 2, 3];
let cloned_array_u32 = array_u32.to_owned(); // type is Vec<u32>

let array_u64: &[u64] = &[1, 2, 3];
let cloned_array_u64 = array_u64.to_owned(); // type is Vec<u64>

let array_u128: &[u128] = &[1, 2, 3];
let cloned_array_u128 = array_u128.to_owned(); // type is Vec<u128>

2.7 Cow

Cow也是一个智能指针,用于实现写时克隆(Clone on write)。它定义如下:

1
2
3
4
5
6
7
pub enum Cow<'a, B>
where
B: 'a + ToOwned + ?Sized,
{
Borrowed(&'a B),
Owned(<B as ToOwned>::Owned),
}

首先Cow是一个枚举,它具有两个成员BorrowedOwned。这里定义了一个泛型B,枚举要么是对类型B的不可变引用(Borrowed),要么是一个拥有类型B的所有权的数据(Owned)。

下面来看看B的特征约束'a + ToOwned + ?Sized,其中

  • 'a是生命周期约束,它表示当Cow内部类型B的生命周期为'a时,Cow的生命周期也是'a,也就是说它至少活得和B一样长
  • ToOwned就是我们在上一节介绍的,它可以从借用的&B得到具有所有权的B
  • ?Sized表示类型B可能是也可能不是在编译时已知大小的类型(因此这里要求实现ToOwned而不是Clone

接下来再看Owned,这里用到了我们会在后面介绍的完全限定语法<B as ToOwned>::Owned中,尖括号的内容向rust提供了类型注解,表示我们希望将B当做ToOwned特征来对待,然后来指定希望访问的是的是ToOwned内部的关联类型Owned

Cow作为独立的一种智能指针,存在的意义在于减少内存的分配和复制。在很多场景中,我们对一个变量都是读多写少,而读取只需要不可变引用即可,并不需要所有权。我们来看官方文档的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use std::borrow::Cow;

fn abs_all(input: &mut Cow<'_, [i32]>) {
for i in 0..input.len() {
let v = input[i];
if v < 0 {
// Clones into a vector if not already owned.
input.to_mut()[i] = -v;
}
}
}

// No clone occurs because `input` doesn't need to be mutated.
let slice = [0, 1, 2];
let mut input = Cow::from(&slice[..]);
abs_all(&mut input);

// Clone occurs because `input` needs to be mutated.
let slice = [-1, 0, 1];
let mut input = Cow::from(&slice[..]);
abs_all(&mut input);

// No clone occurs because `input` is already owned.
let mut input = Cow::from(vec![-1, 0, 1]);
abs_all(&mut input);

第一个切片中,由于没有小于零的数字,因此不发生克隆行为。第二个切片中,由于出现了负数,因此需要获取对数据所属形式的可变引用,由于尚未拥有数据,因此发生了克隆。最后一个已经拥有所有权,因此不会发生克隆。

另外一个例子是敏感词替换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use std::borrow::Cow;

const SENSITIVE_WORD: &str = "bad";

fn remove_sensitive_word<'a>(words: &'a str) -> Cow<'a, str> {
if words.contains(SENSITIVE_WORD) {
Cow::Owned(words.replace(SENSITIVE_WORD, ""))
} else {
Cow::Borrowed(words)
}
}

fn remove_sensitive_word_old(words: &str) -> String {
if words.contains(SENSITIVE_WORD) {
words.replace(SENSITIVE_WORD, "")
} else {
words.to_owned()
}
}

fn main() {
let words = "I'm a bad boy.";
let new_words = remove_sensitive_word(words);
println!("{}", new_words);

let new_words = remove_sensitive_word_old(words);
println!("{}", new_words);
}

例子中给出了remove_sensitive_wordremove_sensitive_word_old两种实现,前者的返回值使用了Cow,后者返回值使用的是String。 仔细分析一下,很明显前者的实现效率更高。因为如果输入的字符串中没有敏感词时,前者Cow::Borrowed(words)不会发生堆内存的分配和拷贝,后者words.to_owned()会发生一次堆内存的分配和拷贝。

试想一下,如果例5的敏感词替换场景,是大多数情况下都不会发生替换的,即读多写少的场景,remove_sensitive_word实现中使用Cow作为返回值就在很大程度上提高了系统的效率。

3 并发编程

不同的编程语言使用不同的线程模型,rust标准库使用1:1线程实现,这代表程序的每一个语言级线程使用一个系统线程。在线程间通信方面,rust提供了不同程度抽象的工具,比如通道、互斥锁和原子类型,我们会在后面分别介绍它们。

3.1 多线程同时运行代码

为了创建一个新线程,需要调用 thread::spawn 函数并传递一个闭包,并在其中包含希望在新线程运行的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::thread;
use std::time::Duration;

fn main() {
thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
// 强制线程停止执行一小段时间(1ms)
thread::sleep(Duration::from_millis(1));
}
});

for i in 1..5 {
println!("hi number {} from the main thread!", i);
// 强制线程停止执行一小段时间(1ms)
thread::sleep(Duration::from_millis(1));
}
}

注意当rust程序的主线程结束时,新线程也会结束,而不管其是否执行完毕。这个程序的输出可能每次都略有不同,不过它大体上看起来像这样:

1
2
3
4
5
6
7
8
9
hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 4 from the main thread!
hi number 5 from the spawned thread!

调用thread::sleep 会强制线程停止执行一小段时间,这会允许其它不同的线程运行。这些线程可能会轮流运行,不过并不保证如此:这依赖操作系统如何调度线程。在这里,主线程首先打印,即便新创建线程的打印语句位于程序的开头,甚至即便我们告诉新建的线程打印直到 i=9,它在主线程结束之前也只打印到了5。

由于主线程结束,新线程中的代码大部分时候会提早结束,在一些情况下下,由于无法保证线程运行的顺序,我们甚至不能实际保证新建线程会被执行。

使用join等待所有线程结束

可以通过将 thread::spawn 的返回值储存在变量中来修复新建线程部分没有执行或者完全没有执行的问题。thread::spawn的返回值类型是 JoinHandleJoinHandle 是一个拥有所有权的值,当对其调用 join 方法时,它会等待其线程结束。将上一节的程序改造一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::thread;
use std::time::Duration;

fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});

for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}

handle.join().unwrap();
}

这里获取到创建的线程的 JoinHandle 类型的变量handle,并调用 join 方法来确保新建线程在 main 退出前结束运行。通过调用handlejoin 会阻塞当前线程直到handle所代表的线程结束。因为我们将 join 调用放在了主线程的 for 循环之后,因此这段代码应该会产生这样的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!

这两个线程仍然会交替执行,不过主线程会由于 handle.join() 调用,会等待直到新建线程执行完毕。由于大部分情况下两个线程会交替执行,使用join可以防止主线程先于其它线程结束。如果调整join方法调用的位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
use std::thread;
use std::time::Duration;

fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});

handle.join().unwrap();

for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
}
/*
hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!
*/

主线程会等待直到新建线程执行完毕之后,才开始执行 for 循环,所以输出将不会交替出现。因此,在使用时,需要注意join使用的位置。

join会获取线程闭包的返回值,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use std::thread;
use std::time::{Duration, Instant};

fn main() {
let mut handles = vec![];
for i in 0..10 {
handles.push(thread::spawn(move || {
let start = Instant::now();
thread::sleep(Duration::from_millis(250));
println!("thread {} is complete", i);
start.elapsed().as_millis()
}));
}

let mut results: Vec<u128> = vec![];
for handle in handles {
results.push(handle.join().unwrap());
}

if results.len() != 10 {
panic!("Oh no! All the spawned threads did not finish!");
}

println!();
for (i, result) in results.into_iter().enumerate() {
println!("thread {} took {}ms", i, result);
}
}

线程结束时会返回所耗费的时间start.elapsed().as_millis(),使用handle.join会获得一个Result<T>,这里的T就是我们所返回的类型。

线程与move闭包

在介绍闭包的获取所有权小节,我们已经介绍了一个使用move的例子:

1
2
3
4
5
6
7
8
9
10
use std::thread;

fn main() {
let list = vec![1, 2, 3];
println!("Before defining closure: {:?}", list);

thread::spawn(move || println!("From thread: {:?}", list))
.join()
.unwrap();
}

先来看看如果去掉move会发生什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
use std::thread;

fn main() {
let list = vec![1, 2, 3];
println!("Before defining closure: {:?}", list);

thread::spawn(|| println!("From thread: {:?}", list))
.join()
.unwrap();
}
/*
error[E0373]: closure may outlive the current function, but it borrows `list`, which is owned by the current function
--> src\main.rs:7:19
|
7 | thread::spawn(|| println!("From thread: {:?}", list))
| ^^ ---- `list` is borrowed here
| |
| may outlive borrowed value `list`
|
note: function requires argument type to outlive `'static`
--> src\main.rs:7:5
|
7 | thread::spawn(|| println!("From thread: {:?}", list))
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: to force the closure to take ownership of `list` (and any other referenced variables), use the `move` keyword
|
7 | thread::spawn(move || println!("From thread: {:?}", list))
| ++++
*/

这段代码本身没有问题,闭包内仅仅打印了动态数组list,这只需要它的不可变引用。但是rust无法确定新的线程会执行多久——这是由操作系统决定的——所以也无法知晓 list 的引用是否一直有效。换句话说,存在一种可能,在新线程执行过程中,主线程的list已经失效(被移动或被drop清理),此时list的引用已经无效了。当然,这段代码也可能正常运行,但rust编译器是保守的,当可能出现问题,也可能不出现问题时,rust选择报错,拒绝编译。

下面就是一个可能会出现问题的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
use std::thread;

fn main() {
let v = vec![1, 2, 3];

let handle = thread::spawn(|| {
println!("Here's a vector: {:?}", v);
});

drop(v); // oh no!

handle.join().unwrap();
}

如果这段代码能够通过编译,则新建线程则可能会立刻被转移到后台并完全没有机会运行。新建线程内部有一个 v 的引用,不过主线程立刻就使用 drop 丢弃了 v(实际上是转移了所有权),接着当新建线程开始执行,v 已不再有效,所以其引用也是无效的。

通过在闭包之前增加 move 关键字,我们强制闭包获取其使用的值的所有权,而不是任由rust推断它应该借用值。

嵌套线程的执行与结束

如果尝试在子线程中嵌套创建新线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::thread;
use std::time::Duration;
fn main() {
// 创建一个线程A
let new_thread = thread::spawn(move || {
// 再创建一个线程B
thread::spawn(move || {
loop {
println!("I am a new thread.");
}
})
});

// 等待新创建的线程执行完成
new_thread.join().unwrap();
println!("Child thread is finish!");

// 睡眠一段时间,看子线程创建的子线程是否还在运行
thread::sleep(Duration::from_millis(100));
}

以上代码中,main 线程创建了一个新的线程 A,同时该新线程又创建了一个新的线程 BA 线程在创建完 B 线程后就立即结束了,而 B 线程则在不停地循环输出。这说明,创建出的B线程是独立运行的,出于安全性的考虑,rust没有提供直接杀死线程的接口,线程B需要等到主线程结束后自动结束。

线程屏障(Barrier)

在rust中,可以使用 Barrier 让多个线程都执行到某个点后,才继续一起往后执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
use std::sync::{Arc, Barrier};
use std::thread;

fn main() {
let mut handles = Vec::with_capacity(4);
let barrier = Arc::new(Barrier::new(4));

for _ in 0..4 {
let b = barrier.clone();
handles.push(thread::spawn(move|| {
println!("before wait");
b.wait();
println!("after wait");
}));
}

for handle in handles {
handle.join().unwrap();
}
}
/* 输出结果:
before wait
before wait
before wait
before wait
after wait
after wait
after wait
after wait
*/

上面代码,我们在线程打印出 before wait 后增加了一个屏障,目的就是等所有的线程都打印出before wait后,各个线程再继续执行。

线程局部变量(Thread Local Variable)

rust提供了标准库和第三方库支持线程局部变量。

使用 thread_local 宏可以初始化线程局部变量,然后在线程内部使用该变量的 with 方法获取变量值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use std::cell::RefCell;
use std::thread;

fn main() {
thread_local!(static FOO: RefCell<u32> = RefCell::new(1));

FOO.with(|f| {
assert_eq!(*f.borrow(), 1);
*f.borrow_mut() = 2;
});

// 每个线程开始时都会拿到线程局部变量的FOO的初始值
let t = thread::spawn(move|| {
FOO.with(|f| {
assert_eq!(*f.borrow(), 1);
*f.borrow_mut() = 3;
});
});

// 等待线程完成
t.join().unwrap();

// 尽管子线程中修改为了3,我们在这里依然拥有main线程中的局部值:2
FOO.with(|f| {
assert_eq!(*f.borrow(), 2);
});
}

上面代码中,FOO 即是我们创建的线程局部变量,每个新的线程访问它时,都会使用它的初始值作为开始,各个线程中的 FOO 值彼此互不干扰。注意 FOO 使用 static 声明为生命周期为 'static 的静态变量。

另外线程中对 FOO 的使用是通过借用的方式,但是若我们需要每个线程独自获取它的拷贝,最后进行汇总,就有些强人所难。

你还可以在结构体中使用线程局部变量:

1
2
3
4
5
6
7
8
9
10
11
12
use std::cell::RefCell;

struct Foo;
impl Foo {
thread_local! {
static FOO: RefCell<usize> = RefCell::new(0);
}
}

fn main() {
Foo::FOO.with(|x| println!("{:?}", x));
}

或者通过引用的方式使用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::cell::RefCell;
use std::thread::LocalKey;

thread_local! {
static FOO: RefCell<usize> = RefCell::new(0);
}
struct Bar {
foo: &'static LocalKey<RefCell<usize>>,
}
impl Bar {
fn constructor() -> Self {
Self {
foo: &FOO,
}
}
}

使用第三方库 thread-local ,可以解决只能使用引用的问题,每个线程持有值的独立拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use thread_local::ThreadLocal;
use std::sync::Arc;
use std::cell::Cell;
use std::thread;

fn main() {
let tls = Arc::new(ThreadLocal::new());

// 创建多个线程
for _ in 0..5 {
let tls2 = tls.clone();
thread::spawn(move || {
// 将计数器加1
let cell = tls2.get_or(|| Cell::new(0));
cell.set(cell.get() + 1);
}).join().unwrap();
}

// 一旦所有子线程结束,收集它们的线程局部变量中的计数器值,然后进行求和
let tls = Arc::try_unwrap(tls).unwrap();
let total = tls.into_iter().fold(0, |x, y| x + y.get());

// 和为5
assert_eq!(total, 5);
}

该库不仅仅使用了值的拷贝,而且还能自动把多个拷贝汇总到一个迭代器中,最后进行求和。

用条件控制线程的挂起和执行

std::sync::Condvar 类型实现了条件变量,它有 wait方法 和 notify_all方法,wait方法会阻塞到直到有其他线程调用 notify_all 方法或者 notify_one方法。当等待的条件到来时,可以使用notify_all 方法或者 notify_one方法通知其他线程,为了进入休眠等待条件变为 true,使用一个while循环加上wait方法是标准习惯用法。但是wait 方法会按值获取 MutexGuard 对象,使用它,并在成功时返回一个新的 MutexGuard,关于MutexGuard,它是一个智能指针,后面会介绍到。详见互斥锁Mutex

条件变量(Condition Variables)经常和 Mutex 一起使用,可以让线程挂起,直到某个条件发生后再继续执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::thread;
use std::sync::{Arc, Mutex, Condvar};

fn main() {
let pair = Arc::new((Mutex::new(false), Condvar::new()));
let pair2 = pair.clone();

thread::spawn(move|| {
let &(ref lock, ref cvar) = &*pair2;
let mut started = lock.lock().unwrap();
println!("changing started");
*started = true;
cvar.notify_one();
});

let &(ref lock, ref cvar) = &*pair;
let mut started = lock.lock().unwrap();
while !*started {
started = cvar.wait(started).unwrap();
}

println!("started changed");
}

上述代码流程如下:

  1. main 线程首先进入 while 循环,调用 wait 方法挂起等待子线程的通知,并释放了锁 started
  2. 子线程获取到锁,并将其修改为 true,然后调用条件变量的 notify_one 方法来通知主线程继续执行

确保函数只被调用一次

有时,我们会需要某个函数在多线程环境下只被调用一次,例如初始化全局变量,无论是哪个线程先调用函数来初始化,都会保证全局变量只会被初始化一次,随后的其它线程调用就会忽略该函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use std::thread;
use std::sync::Once;

static mut VAL: usize = 0;
static INIT: Once = Once::new();

fn main() {
let handle1 = thread::spawn(move || {
INIT.call_once(|| {
unsafe {
VAL = 1;
}
});
});

let handle2 = thread::spawn(move || {
INIT.call_once(|| {
unsafe {
VAL = 2;
}
});
});

handle1.join().unwrap();
handle2.join().unwrap();

println!("{}", unsafe { VAL });
}

代码运行的结果取决于哪个线程先调用 INIT.call_once (虽然代码具有先后顺序,但是线程的初始化顺序并无法被保证!因为线程初始化是异步的,且耗时较久),若 handle1 先,则输出 1,否则输出 2

call_once方法让执行初始化过程一次,并且只执行一次。如果当前有另一个初始化过程正在运行,线程将阻止该方法被调用。当这个函数返回时,保证一些初始化已经运行并完成,它还保证由执行的闭包所执行的任何内存写入都能被其他线程在这时可靠地观察到。

3.2 使用消息传递在线程间通信

消息传递(message passing)是一个确保安全并发的方式。线程通过发送包含数据的消息来相互沟通,这个思想来源于 Go 编程语言文档 中的口号:

Do not communicate by sharing memory; instead, share memory by communicating.

不要通过共享内存来通讯;而是通过通讯来共享内存。

为了实现消息传递并发,rust标准库提供了一个通道(channel)。通道是一个通用编程概念,它是可以数据从一个线程发送到另一个线程单向管道。换句话说,通道是一个线程安全的队列。

使用通道发送和接收消息

1
2
3
4
5
6
7
8
9
10
11
12
13
use std::sync::mpsc;
use std::thread;

fn main() {
let (tx, rx) = mpsc::channel();

thread::spawn(move || {
let val = String::from("hi");
tx.send(val).unwrap();
});

println!("receive {} in main thread.", rx.recv().unwrap());
}

这里使用 mpsc::channel 函数创建一个新的信道;mpsc 是多个生产者,单个消费者(multiple producer, single consumer)的缩写。简而言之,rust标准库实现信道的方式意味着一个信道可以有多个产生值的发送(sending)端,但只能有一个消费这些值的接收(receiving)端。

mpsc::channel 函数返回一个元组:第一个元素是发送端 – 发送者,而第二个元素是接收端 – 接收者。由于历史原因,txrx 通常作为发送者(transmitter)和接收者(receiver)的缩写,所以这就是我们将用来绑定这两端变量的名字。这里使用了一个 let 语句和模式来解构了此元组。

这里txrx的类型由编译器自动推导:tx.send("hi")发送了String,因此它们分别是mpsc::Sender<String>mpsc::Receiver<String>类型,由于内部是泛型实现,一旦类型被推导确定,该通道就只能传递对应类型的值。

使用 thread::spawn 来创建一个新线程并使用 movetx 移动到闭包中,这样新建线程就拥有 tx 了。新建线程需要拥有信道的发送端以便能向信道发送消息。信道的发送端有一个 send 方法用来获取需要放入信道的值。send 方法返回一个 Result<T, E> 类型,所以如果接收端已经被丢弃了,将没有发送值的目标,发送操作会返回错误,这里调用 unwrap 在出错的时候产生panic。

主线程使用rx作为通道的接收者,有两个方法:recvtry_recv。这里,我们使用了 recv,它是 receive 的缩写。这个方法会阻塞主线程执行直到从信道中接收一个值。一旦发送了一个值,recv 会在一个 Result<T, E> 中返回它。当信道发送端关闭,recv 会返回一个错误表明不会再有新的值到来了。同样的,对于recv方法来说,当发送者被丢弃时,也会接收到一个错误,这里同样使用unwrap 在出错的时快速处理。

try_recv 不会阻塞,相反它立刻返回一个 Result<T, E>Ok 值包含可用的信息,而 Err 值代表此时没有任何消息。如果线程在等待消息过程中还有其他工作时使用 try_recv 很有用:可以编写一个循环来频繁调用 try_recv,在有可用消息时进行处理,其余时候则处理一会其他工作直到再次检查。

出于简单的考虑,这个例子使用了 recv。主线程中除了等待消息之外没有任何其他工作,所以阻塞主线程是合适的。

最终代码会输出如下结果:

1
receive hi in main thread.

通道与所有权转移

所有权规则在消息传递中扮演了重要角色,其有助于我们编写安全的并发代码。现在让我们做一个试验来看看信道与所有权如何一同协作以避免产生问题:我们将尝试在新建线程中的信道中发送完 val 值之后再使用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::sync::mpsc;
use std::thread;

fn main() {
let (tx, rx) = mpsc::channel();

thread::spawn(move || {
let val = String::from("hi");
tx.send(val).unwrap();
println!("val is {}", val); // borrow of moved value: `val`
});

let received = rx.recv().unwrap();
println!("Got: {}", received);
}
/*
|
8 | let val = String::from("hi");
| --- move occurs because `val` has type `String`, which does not implement the `Copy` trait
9 | tx.send(val).unwrap();
| --- value moved here
10 | println!("val is {}", val);
| ^^^ value borrowed here after move
*/

这里尝试在通过 tx.send 发送 val 到信道中之后将其打印出来。显然这会造成问题,一旦将值发送到另一个线程后,那个线程可能会在我们再次使用它之前就将其修改或者丢弃。其他线程对值可能的修改会由于不一致或不存在的数据而导致错误或意外的结果。rust拒绝编译,说明使用通道来传输数据时仍然需要遵循所有权规则,这可以防止在发送后再次意外地使用这个值。

发送多个值并让接收者循环等待

这次,尝试发送多个值,观察接收者的等待:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
let (tx, rx) = mpsc::channel();

thread::spawn(move || {
let vals = vec![
String::from("hi"),
String::from("from"),
String::from("the"),
String::from("thread"),
];

for val in vals {
tx.send(val).unwrap();
thread::sleep(Duration::from_secs(1));
}
});

for received in rx {
println!("Got: {}", received);
}
}

这一次,在新建线程中有一个字符串vector希望发送到主线程。我们遍历它们,单独的发送每一个字符串并通过一个 Duration 值调用 thread::sleep 函数来暂停一秒。

在主线程中,不再显式调用 recv 函数,rx 是一个迭代器,可以使用for 遍历它:

1
2
3
for received in rx {
println!("Got: {}", received);
}

还可以使用如下方式:

1
2
3
while let Ok(received) = rx.recv() {
println!("Got: {}", received);
}

这两种方式是等价的,无论是用哪种,对于每一个接收到的值,我们将其打印出来。如果到达循环顶部时通道恰好是空的,则接收线程将阻塞,直到其他线程发送一个值。当通道为空且发送者已被丢弃时,循环将正常退出。

运行整个代码,将得到如下输出,每输出一行都会暂停一秒:

1
2
3
4
Got: hi
Got: from
Got: the
Got: thread

通过克隆发送者来创建多个生产者

之前提到了mpsc是多生产者单消费者的缩写,可以运用 mpsc来创建向同一接收者发送值的多个线程。这可以通过克隆发送者来做到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
let (tx, rx) = mpsc::channel();

let tx1 = tx.clone();
thread::spawn(move || {
let vals = vec![
String::from("hi"),
String::from("from"),
String::from("the"),
String::from("thread"),
];

for val in vals {
tx1.send(val).unwrap();
thread::sleep(Duration::from_secs(1));
}
});

thread::spawn(move || {
let vals = vec![
String::from("more"),
String::from("messages"),
String::from("for"),
String::from("you"),
];

for val in vals {
tx.send(val).unwrap();
thread::sleep(Duration::from_secs(1));
}
});

for received in rx {
println!("Got: {}", received);
}
}

这一次,在创建新线程之前,我们对发送者调用了 clone 方法。这会给我们一个可以传递给第一个新建线程的发送端句柄。我们会将原始的通道发送端传递给第二个新建线程。这样就会有两个线程,每个线程将向信道的接收端发送不同的消息。

如果运行这些代码,你可能会看到这样的输出:

1
2
3
4
5
6
7
8
Got: hi
Got: more
Got: from
Got: messages
Got: the
Got: for
Got: thread
Got: you

你可能会看到这些值以不同的顺序出现,这依赖于你的系统。这也是并发既有趣又困难的原因。如果通过 thread::sleep 做实验,在不同的线程中提供不同的值,就会发现他们的运行更加不确定,且每次都会产生不同的输出。

不过,同一通道的消息是有顺序的,因为它本质上是队列。

同步通道

前面通过mpsc::channel函数创建的通道是异步的,发送一条消息,即使消息没有被接收,异步通道也不会阻塞。

rust还有另外一种同步通道mpsc::sync_channel,同步通道与异步通道基本相同,有一点区别:异步通道的内部缓冲区(Buffer)是无限的,在创建时无需指定大小;而同步通道在创建时需要指定内部缓冲区(Buffer)的大小。

使用通道收发消息的程序可能会遇到这样的情况:发送值的速度超过了接收和处理的速度。这会导致越来越多的消息在内部缓冲区中累积。更糟糕的是,发送线程继续运行,占用 CPU 和其他系统资源来发送更多的值,而这些资源正好在接收端最需要这些资源。

更准确地描述就是:在数据流从上游生产者向下游消费者传输的过程中,上游生产速度大于下游消费速度,导致下游的 Buffer溢出,这种现象叫做背压(Backpressure)。

借鉴了Unix系统中管道的处理方法,Unix 系统上的每个管道也有固定的大小,如果一个进程试图写入一个暂时已满的管道,系统会阻塞该进程直到管道中有空间。rust也是如此:当内部缓冲区满时,同步通道将阻塞等待缓冲区打开。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx)= mpsc::sync_channel(0);

let handle = thread::spawn(move || {
println!("before sending");
tx.send(1).unwrap();
println!("after sending");
});

println!("main thread sleeping...");
thread::sleep(Duration::from_secs(3));

println!("receive {}", rx.recv().unwrap());
handle.join().unwrap();
}

这里使用mpsc::sync_channel(0)创建了一个内部缓冲区为0的通道,这是有效的,如果将缓冲区设为0,它将成为会合通道(rendezvous channel),其中的每个消息均会阻塞,直到recv接收它。

上面的代码就演示了这个过程,它的输出结果如下:

1
2
3
4
main thread sleeping...
before sending
receive 1
after sending

新创建的线程发送了一条消息,由于我们在主线程睡眠了3秒,主线程没有接收这个值,因此通道阻塞。直到rx.recv方法执行将值接收后,阻塞被解除。

需要注意的是,同步通道并不总是阻塞的,只有Buffer满后才会阻塞。比如,使用mpsc::sync_channel(10)代表缓冲区大小为10,只有发送消息时缓冲区已经有10条数据的情况下,新的消息才会阻塞。

无限的缓存空间

上一节提到了,异步通道的内部缓冲区(Buffer)是无限的(infinite buffer),rust是如何实现的?

类似于Vec<T>是无限大小的一样,这里的无限不是真正意义上的“无限”,你可以把元素添加到动态数组中,但是当你的物理设备资源耗尽时,无法分配足够的堆内存,此时程序可能会崩溃。通道也是同理,异步通道虽然能非常高效且不会造成发送线程的阻塞,但是存在消息未及时消费,最终内存过大的问题。在实际项目中,可以考虑使用一个带缓冲值的同步通道来避免这种风险。

传输多种类型的数据

一个消息通道只能传输一种类型的数据,如果想要传输多种类型的数据,可以为每个类型创建一个通道,也可以使用枚举类型来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::sync::mpsc::{self, Receiver, Sender};

enum Fruit {
Apple(u8),
Orange(String)
}

fn main() {
let (tx, rx): (Sender<Fruit>, Receiver<Fruit>) = mpsc::channel();

tx.send(Fruit::Orange("sweet".to_string())).unwrap();
tx.send(Fruit::Apple(2)).unwrap();

for _ in 0..2 {
match rx.recv().unwrap() {
Fruit::Apple(count) => println!("received {} apples", count),
Fruit::Orange(flavor) => println!("received {} oranges", flavor),
}
}
}

如上所示,枚举类型还能让我们带上想要传输的数据,但是有一点需要注意,rust按照枚举中占用内存最大的那个成员进行内存对齐,这意味着就算传输的是枚举中占用内存最小的成员,它占用的内存依然和最大的成员相同,可能会造成内存上的浪费。

使用mpmc的第三方库

如果你需要mpmc或者需要更高的性能,可以考虑第三方库:

  • crossbeam-channel,老牌强库,功能较全,性能较强,之前是独立的库,但是后面合并到了crossbeam 主仓库中
  • flume,官方给出的性能数据某些场景要比 crossbeam 更好些

3.3 共享状态并发

虽然消息传递是一个很好的处理并发的方式,但并不是唯一一个。另一种方式是让多个线程拥有相同的共享数据。

在某种程度上,任何编程语言中的通道都类似于单所有权,因为一旦将一个值传送到通道中,将无法再使用这个值。共享内存类似于多所有权:多个线程可以同时访问相同的内存位置。智能指针可以使多所有权成为可能,然而这会增加额外的复杂性,因为需要以某种方式管理这些不同的所有者。rust的类型系统和所有权规则极大的协助了正确地管理这些所有权。作为一个例子,让我们看看互斥器,一个更为常见的共享内存并发原语。

互斥锁Mutex

互斥锁(mutex)是 mutual exclusion 的缩写,也就是说,任意时刻,其只允许一个线程访问某些数据。在其他语言中,互斥锁的使用需要注意两点:

  • 在需要互斥访问之前(进入临界区)获得锁
  • 处理完毕数据之后(离开临界区),释放锁

使用互斥锁比较复杂,这也是许多人热衷于通道的原因。然而,在rust中,得益于类型系统和所有权,我们不会在锁和解锁上出错。

出于简单的考虑,我们从在单线程上下文使用互斥锁Mutex开始:

1
2
3
4
5
6
7
8
9
10
11
12
use std::sync::Mutex;

fn main() {
let m = Mutex::new(5);

{
let mut num = m.lock().unwrap();
*num = 6;
}

println!("m = {:?}", m);
}

像很多类型一样,我们使用关联函数 new 来创建一个 Mutex<T>,和Box<T>类似,数据被Mutex<T>所拥有,要访问内部的数据,需要使用方法m.lock()m申请一个锁,这个调用会阻塞当前线程,直到我们拥有锁为止。如果另一个线程拥有锁,并且那个线程panic了,则 lock 调用会失败。在这种情况下,没人能够再获取锁,所以这里选择 unwrap 并在遇到这种情况时使线程panic。当多个线程同时访问该数据时,只有一个线程能获取到锁,其它线程只能阻塞等待,这样就保证了数据能被安全的修改。

一旦获取了锁,就可以将返回值(在这里是num)视为一个其内部数据的可变引用。类型系统确保了我们在使用 m 中的值之前获取锁。m 的类型是 Mutex<i32> 而不是 i32,所以必须获取锁才能使用这个 i32 值。我们是不会忘记这么做的,因为反之类型系统不允许访问内部的 i32 值。

Mutex<T> 是一个智能指针,更准确的说,调用 lock 方法返回一个叫做 MutexGuard<T> 的智能指针。这个智能指针:

  • 实现了Deref特征,会被自动解引用后获得一个引用类型,该引用指向Mutex内部的数据
  • 实现了Drop特征,在离开作用域时自动释放锁,以便其它线程能继续获取锁,为此,我们不会忘记释放锁并阻塞其它线程的风险,因为锁的释放是自动发生的。正是由于此,你需要做的仅仅是做好锁的作用域管理。

丢弃了锁之后,可以打印出互斥锁的值,并发现能够将其内部的 i32 改为6

多线程中使用互斥锁

上一节的例子中仅仅演示了单线程下的互斥锁,现在来尝试使用 Mutex<T> 在多个线程间共享值。我们将启动十个线程,并在各个线程中对同一个计数器值加一,这样计数器将从 0 变为 10。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::sync::Mutex;
use std::thread;

fn main() {
let counter = Mutex::new(0);
let mut handles = vec![];

for _ in 0..10 {
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();

*num += 1;
});
handles.push(handle);
}

for handle in handles {
handle.join().unwrap();
}

println!("Result: {}", *counter.lock().unwrap());
}

这里创建了一个 counter 变量来存放内含 i32Mutex<T>,接下来遍历 range 创建了 10 个线程。使用了 thread::spawn 并对所有线程使用了相同的闭包:它们每一个都将调用 lock 方法来获取 Mutex<T> 上的锁,接着将互斥锁中的值加一。当一个线程结束执行,num 会离开闭包作用域并释放锁,这样另一个线程就可以获取它了。

在主线程中,收集了所有线程的JoinHandle,调用 join 方法来确保所有线程都会结束。这时,主线程会获取锁并打印出程序的结果。

然而这段代码会报错:

1
2
3
4
5
6
7
8
   |
5 | let counter = Mutex::new(0);
| ------- move occurs because `counter` has type `Mutex<i32>`, which does not implement the `Copy` trait
...
9 | let handle = thread::spawn(move || {
| ^^^^^^^ value moved into closure here, in previous iteration of loop
10 | let mut num = counter.lock().unwrap();
| ------- use occurs due to use in closure

错误信息表明 counter 值在上一次循环中被移动了。rust告诉我们不能将 counter 锁的所有权移动到多个线程中。

多线程和多所有权

要想让多个所有者拥有值,在前面Rc<T>引用计数智能指针章节中,介绍过使用智能指针 Rc<T> 来创建引用计数,以便拥有多所有者。现在来尝试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
let counter = Rc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
let counter = Rc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();

*num += 1;
});
handles.push(handle);
}

for handle in handles {
handle.join().unwrap();
}

println!("Result: {}", *counter.lock().unwrap());
}

这次将 Mutex<T> 封装进 Rc<T> 中并在将所有权移入线程之前克隆了 Rc<T>。但是rust仍然报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely
--> src\main.rs:11:36
|
11 | let handle = thread::spawn(move || {
| ------------- ^------
| | |
| ______________________|_____________within this `[closure@src\main.rs:11:36: 11:43]`
| | |
| | required by a bound introduced by this call
12 | | let mut num = counter.lock().unwrap();
13 | |
14 | | *num += 1;
15 | | });
| |_________^ `Rc<Mutex<i32>>` cannot be sent between threads safely
|
= help: within `[closure@src\main.rs:11:36: 11:43]`, the trait `Send` is not implemented for `Rc<Mutex<i32>>`
note: required because it's used within this closure
--> src\main.rs:11:36
|
11 | let handle = thread::spawn(move || {
| ^^^^^^^
note: required by a bound in `spawn`
--> C:\Users\aaa\.rustup\toolchains\stable-x86_64-pc-windows-msvc\lib/rustlib/src/rust\library\std\src\thread\mod.rs:704:8
|
704 | F: Send + 'static,
| ^^^^ required by this bound in `spawn`

报错中出现了一些提示,第一行错误:

1
`Rc<Mutex<i32>>` cannot be sent between threads safely

提示Rc<T>不能安全地在线程之间发送。然后是:

1
the trait `Send` is not implemented for `Rc<Mutex<i32>>`

提示Rc<T>类型没有实现Send特征。

看来,Rc<T> 并不能安全的在线程间共享。当 Rc<T> 管理引用计数时,它必须在每一个 clone 调用时增加计数,并在每一个克隆被丢弃时减少计数。Rc<T> 并没有使用任何并发原语,来确保改变计数的操作不会被其他线程打断。在计数出错时可能会导致诡异的 bug,比如可能会造成内存泄漏,或在使用结束之前就丢弃一个值。我们所需要的是一个完全类似 Rc<T>,又以一种线程安全的方式改变引用计数的类型。

线程安全的引用计数Arc<T>

Arc<T>正是一个类似 Rc<T> 保证了并发安全的引用计数。字母A代表原子性(atomic),所以这是一个原子引用计数(atomically reference counted)类型。原子类型提供线程之间的基本共享内存通信,可以在标准库中查看更详细的定义:std::sync::atomic,但现在我们只需要知道原子类型就像基本类型一样可以安全的在线程间共享。

Arc<T>Rc<T> 有着相同的 API,所以修改程序中的 use 行和 new 调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();

*num += 1;
});
handles.push(handle);
}

for handle in handles {
handle.join().unwrap();
}

println!("Result: {}", *counter.lock().unwrap());
}

代码最终可以正常运行,这会打印出:

1
Result: 10

你可能会好奇为什么不是所有的原始类型都是原子性的,为什么不是所有标准库中的类型都默认使用 Arc<T> 实现。原因在于线程安全带有性能惩罚,rust希望只在必要时才为此买单。如果只是在单线程中对值进行操作,原子性提供的保证并无必要,代码可以减少这部分性能损失,以运行的更快。

内部可变性

在之前组合使用Rc<T>RefCell<T>来拥有多个可变数据所有者小节中,介绍了Rc<T>RefCell<T>的结合,可以实现单线程中的内部可变性。

现在,我们介绍了它们线程安全的版本,即:组合使用 Mutex<T>Arc<T> 在多线程中实现内部可变性。

死锁

在rust中有多种方式可能造成死锁,了解这些方式有助于你提前规避可能的风险:

首先是单线程死锁,比较好避免:

1
2
3
4
5
6
7
use std::sync::Mutex;

fn main() {
let data = Mutex::new(0);
let d1 = data.lock();
let d2 = data.lock();
} // d1锁在此处释放

只要你在另一个锁还未被释放时去申请新的锁,线程就会被阻塞,当代码复杂后,这种情况可能就没有那么显眼。

多线程死锁就稍微复杂一些,当我们拥有两个锁,且两个线程各自使用了其中一个锁,然后试图去访问另一个锁时,就可能发生死锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
use std::{sync::{Mutex, MutexGuard}, thread};
use std::thread::sleep;
use std::time::Duration;

// 第三方库
use lazy_static::lazy_static;
// lazy_static! {} 中的代码并不会在编译时初始化静态量,它会在首次调用时,执行代码,来初始化。也就是所谓的延迟计算。
lazy_static! {
static ref MUTEX1: Mutex<i64> = Mutex::new(0);
static ref MUTEX2: Mutex<i64> = Mutex::new(0);
}

fn main() {
// 存放子线程的句柄
let mut children = vec![];
for i_thread in 0..2 {
children.push(thread::spawn(move || {
for _ in 0..1 {
// 线程1
if i_thread % 2 == 0 {
// 锁住MUTEX1
let guard: MutexGuard<i64> = MUTEX1.lock().unwrap();

println!("线程 {} 锁住了MUTEX1,接着准备去锁MUTEX2 !", i_thread);

// 当前线程睡眠一小会儿,等待线程2锁住MUTEX2
sleep(Duration::from_millis(10));

// 去锁MUTEX2
let guard = MUTEX2.lock().unwrap();
// 线程2
} else {
// 锁住MUTEX2
let _guard = MUTEX2.lock().unwrap();

println!("线程 {} 锁住了MUTEX2, 准备去锁MUTEX1", i_thread);

let _guard = MUTEX1.lock().unwrap();
}
}
}));
}

// 等子线程完成
for child in children {
let _ = child.join();
}

println!("死锁没有发生");
}

在上面的描述中,我们用了“可能“二字,原因在于死锁在这段代码中不是必然发生的,总有一次运行你能看到最后一行打印输出。这是由于子线程的初始化顺序和执行速度并不确定,我们无法确定哪个线程中的锁先被执行,因此也无法确定两个线程对锁的具体使用顺序。

但是,可以简单的说明下死锁发生的必然条件:线程 1 锁住了MUTEX1并且线程2锁住了MUTEX2,然后线程 1 试图去访问MUTEX2,同时线程2试图去访问MUTEX1,就会死锁。 因为线程 2 需要等待线程 1 释放MUTEX1后,才会释放MUTEX2,而与此同时,线程 1 需要等待线程 2 释放MUTEX2后才能释放MUTEX1,这种情况造成了两个线程都无法释放对方需要的锁,最终死锁。

但有些情况下不会发生死锁:线程 2 在线程 1 锁MUTEX1之前,就已经全部执行完了,随之线程 2 的MUTEX2MUTEX1被全部释放,线程 1 对锁的获取将不再有竞争者。 同理,线程 1 若全部被执行完,那线程 2 也不会被锁,因此我们在线程 1 中间加一个睡眠,增加死锁发生的概率。如果你在线程 2 中同样的位置也增加一个睡眠,那死锁将必然发生。

在计算机专业课《操作系统》中对这种死锁的产生和预防都有比较详细的讲解,感兴趣的读者可以自行搜搜看。

另外,与lock方法不同,还有一个try_lock方法,这个方法会尝试获取一次锁,如果无法获取会返回一个错误,因此不会发生阻塞。当try_lock失败时,会报出一个错误:Err("WouldBlock"),接着线程中的剩余代码会继续执行,不会被阻塞。

读写锁 RwLock

Mutex会对每次读写都进行加锁,但某些时候,我们需要大量的并发读,Mutex就无法满足需求了,此时就可以使用RwLock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use std::sync::RwLock;

fn main() {
let lock = RwLock::new(5);

// 同一时间允许多个读
{
let r1 = lock.read().unwrap();
let r2 = lock.read().unwrap();
assert_eq!(*r1, 5);
assert_eq!(*r2, 5);
} // 读锁在此处被drop

// 同一时间只允许一个写
{
let mut w = lock.write().unwrap();
*w += 1;
assert_eq!(*w, 6);

// 以下代码会panic,因为读和写不允许同时存在
// 写锁w直到该语句块结束才被释放,因此下面的读锁依然处于`w`的作用域中
// let r1 = lock.read();
// println!("{:?}",r1);
}// 写锁在此处被drop
}

RwLock在使用上和Mutex区别不大,需要注意的是,当读写同时发生时,程序会直接panic,因为会发生死锁。我们可以使用try_writetry_read来尝试进行一次写/读,若失败则返回错误Err("WouldBlock")

总结下RwLock:

  1. 同时允许多个读,但最多只能有一个写
  2. 读和写不能同时存在
  3. 读可以使用readtry_read,写使用writetry_write。在实际项目中,try_xxx会安全的多

Mutex还是RwLock

首先简单性上Mutex完胜,因为使用RwLock要操心几个问题:

  • 读和写不能同时发生,如果使用try_xxx解决,就必须做大量的错误处理和失败重试机制
  • 当读多写少时,写操作可能会因为一直无法获得锁导致连续多次失败:见writer starvation
  • RwLock 其实是操作系统提供的,实现原理要比Mutex复杂的多,因此单就锁的性能而言,比不上原生实现的Mutex

再来简单总结下两者的使用场景:

  • 追求高并发读取时,使用RwLock,因为Mutex一次只允许一个线程去读取
  • 如果要保证写操作的成功性,使用Mutex
  • 不知道哪个合适,统一使用Mutex

需要注意的是,RwLock虽然看上去貌似提供了高并发读取的能力,但这个不能说明它的性能比Mutex高,事实上Mutex性能要好不少,后者唯一的问题也仅仅在于不能并发读取。

一个常见的、错误的使用RwLock的场景就是使用HashMap进行简单读写,因为HashMap的读和写都非常快,RwLock的复杂实现和相对低的性能反而会导致整体性能的降低,因此一般来说更适合使用Mutex

总之,如果你要使用RwLock要确保满足以下两个条件:并发读,且需要对读到的资源进行“长时间“的操作,HashMap也许满足了并发读的需求,但是往往并不能满足后者:“长时间“的操作。

用条件变量Condvar控制线程的同步

Mutex用于解决资源安全访问的问题,但是我们还需要一个手段来解决资源访问顺序的问题。rust考虑到了这一点,为我们提供了条件变量(Condition Variables),它经常和Mutex一起使用,可以让线程挂起,直到某个条件发生后再继续执行,其实Condvar我们在之前的用条件控制线程的挂起和执行小节已经介绍过,现在再来看一个不同的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
use std::sync::{Arc,Mutex,Condvar};
use std::thread::{spawn,sleep};
use std::time::Duration;

fn main() {
let flag = Arc::new(Mutex::new(false));
let cond = Arc::new(Condvar::new());
let cflag = flag.clone();
let ccond = cond.clone();

let hdl = spawn(move || {
let mut m = { *cflag.lock().unwrap() };
let mut counter = 0;

while counter < 3 {
while !m {
m = *ccond.wait(cflag.lock().unwrap()).unwrap();
}

{
m = false;
*cflag.lock().unwrap() = false;
}

counter += 1;
println!("inner counter: {}", counter);
}
});

let mut counter = 0;
loop {
sleep(Duration::from_millis(1000));
*flag.lock().unwrap() = true;
counter += 1;
if counter > 3 {
break;
}
println!("outside counter: {}", counter);
cond.notify_one();
}
hdl.join().unwrap();
println!("{:?}", flag);
}

通过主线程来触发子线程实现交替打印输出:

1
2
3
4
5
6
7
outside counter: 1
inner counter: 1
outside counter: 2
inner counter: 2
outside counter: 3
inner counter: 3
Mutex { data: true, poisoned: false, .. }

信号量 Semaphore

信号量的概念是由荷兰计算机科学家艾兹赫尔·戴克斯特拉(Edsger W. Dijkstra)发明的,在很多书中将信号量称为PV操作或PV原语。V操作会增加信号标S的数值,P操作会减少它。在rust中,使用信号量可以用来控制同时访问特定资源的线程数量,通过协调各个线程,以保证合理的使用资源。

rust在标准库中有提供一个信号量实现,但是已经被弃用了。因此这里推荐使用tokio中提供的Semaphore实现:tokio::sync::Semaphore

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::sync::Arc;
use tokio::sync::Semaphore;

#[tokio::main]
async fn main() {
let semaphore = Arc::new(Semaphore::new(3));
let mut join_handles = Vec::new();

for _ in 0..5 {
let permit = semaphore.clone().acquire_owned().await.unwrap();
join_handles.push(tokio::spawn(async move {
//
// 在这里执行任务...
//
drop(permit);
}));
}

for handle in join_handles {
handle.await.unwrap();
}
}

上面代码创建了一个容量为 3 的信号量,当正在执行的任务超过 3 时,剩下的任务需要等待正在执行任务完成并减少信号量后到 3 以内时,才能继续执行。acquire_owned就相当于P操作,drop(permit)就相当于V操作。通过PV操作来控制各个线程资源的使用。

3.4 内存模型与atomic

Mutex用起来简单,但是无法并发读,RwLock可以并发读,但是使用场景较为受限且性能不够。atomicstd::sync::atomic)则是包含用于无锁并发编程的原子类型,另外,atomic是所有锁实现的基础。

原子指的是一系列不可被 CPU 上下文交换的机器指令,这些指令组合在一起就形成了原子操作。在多核 CPU 下,当某个 CPU 核心开始运行原子操作时,会先暂停其它 CPU 内核对内存的操作,以保证原子操作不会被其它 CPU 内核所干扰。

由于原子操作是通过指令提供的支持,因此它的性能相比锁和消息传递会好很多。相比较于锁而言,原子类型不需要开发者处理加锁和释放锁的问题,同时支持修改,读取等操作,还具备较高的并发性能,几乎所有的语言都支持原子类型。

内存模型

在具体介绍atomic之前,先介绍一下内存模型。

(1)顺序一致性SC

看一个例子。假设有下面一个简单的程序,运行两个线程,其中AB初始值都是0:

线程 1线程 2
1.A=13.B=2
2.println!(“{}”,B)4.println!(“{}”,A)

这个程序的运行结果是什么呢?这应该有多种情况:

  1. 先执行线程1,线程1结束后,再执行线程2

    语句的顺序为1,2,3,4,运行结果为:0,1

  2. 先执行线程2,线程2结束后,再执行线程1

    语句的顺序为3,4,1,2,运行结果为:0,2

前两种情况是串行执行的,下面来看线程交替执行的情况:

  1. 语句的顺序为1,3,2,4(或3,1,2,4),运行结果为:2,1
  2. 语句的顺序为1,3,4,2(或3,1,4,2),运行结果为:1,2

另外,不应该出现的是另一种结果:0,0,这是因为我们直观地认为这段程序符合顺序一致性。

假设程序能够输出0,0,那么对于两个println!语句来说,要想输出0,0,那么就必须要求:

  • 语句2要比语句3早执行
  • 语句4要比语句1早执行

另外,我们其实隐式地规定了线程内部的执行顺序:线程内的语句的执行顺序一定是按照程序规定的顺序(从上到下依次)执行的,这也符合人的阅读习惯。因此还有:

  • 语句1要比语句2早执行
  • 语句3要比语句4早执行

现在,要满足这4条规则,我们无法给出一个语句序列,使得程序能够运行,因此假设不成立,程序不能输出0,0

这就是顺序一致性(Sequential Consistency)的思想,当并行运行的多个线程操作单个内存,一切都必须按顺序发生。不存在两个事件可以同时发生,因为它们都在访问一个内存。

顺序一致性的规则可以总结为两条:

  • 每个线程内的语句都是按照程序规定的顺序依次执行的
  • 在线程之间,语句可以是交错执行的,但是所有线程所看到的总体执行顺序一样

对于第一条很好理解。第二条的意思是,线程之间语句可以交替执行,但无论从线程1还是线程2看上去,执行顺序都是相同的。拿开头的例子来说,如果线程1看到的语句顺序是1,3,2,4,那么对于线程2也是如此。

对于顺序一致性,其实上就像开关一样。假设有N个线程,在每个步骤中,开关选择一个线程中的指令运行并运行完毕,然后在下一个步骤再去选择一个线程…这样看来,在同一时间只有一个线程在工作,因此它非常慢,我们一次只能运行一条指令,失去了让多个线程并行运行的优点。

(2)全存储排序TSO

全存储排序(Total Store Ordering)模型是这样的,现代CPU往往都具有多核心,多级缓存,对于一个写入操作的完成,把值写入CPU核心的缓冲区即可,而无需等到它真正写入内存。

还是以SC的这个例子说明:

线程 1线程 2
1.A=13.B=2
2.println!(“{}”,B)4.println!(“{}”,A)

在全存储排序模型下,执行一个写入操作返回时,并不意味着内存中的值立即被修改:

image-20230311181133205

与其等待A=1依次经过缓存写入内存再返回,不如将其写入缓冲区之后直接返回。由于存储缓冲区在CPU核心上,因此访问速度非常快。在之后的某个时间,缓存层次结构将从存储缓冲区中提取写入并将其传播到缓存中,以便它对其他线程可见。

因此,可能出现以下情况:

  1. 执行操作1,写入A=1到CPU的存储缓冲区之后立即返回,并没有更新到所有CPU都能访问到的内存中。
  2. 执行操作3,写入B=2到CPU的存储缓冲区之后立即返回,并没有更新到所有CPU都能访问到的内存中。
  3. 执行操作4,读取内存中A的值,此时还没有被更新,所以读取到0
  4. 执行操作2,读取内存中B的值,此时还没有被更新,所以读取到0

因此,在引入了存储缓冲区之后,在顺序一致性模型下不能输出的0,0也可以输出了。

并且,这种缓冲区保留了单线程的预期行为。考虑单线程的代码:

1
2
3
// A初始为0
let A = 1; // 操作1
println!("{}", A); // 操作2

image-20230311181657746

写入A=1到CPU的存储缓冲区之后立即返回,执行println!时难道也会是0吗?并不,由于操作2的读取需要查看操作1的值,程序保留了预期的行为。如果去内存读取,那么只能拿到一个旧值,但是因为线程在同一个CPU上运行,读取可以直接检查存储缓冲区,查看它是否包含对正在读取的位置的写入,然后使用该值。因此,即使使用存储缓冲区,该程序也能正确打印出1

TSO保留了SC的第一条规则,即每个线程内的语句都按照程序规定的顺序依次执行。但TSO允许使用缓冲区,这些缓冲区减少了写入延迟。使语句更快地返回,程序的执行速度显著加快。SC的限制比较严格,TSO则弱化了它。

事实上,几乎每个现代架构都包含一个存储缓冲区,因此,内存模型至少与TSO一样弱。

(3)松弛型内存模型

前两种内存模型相对严格,限制了现代处理器架构下广泛使用的优化措施。例如,无法使用编译器和处理器的重排序优化松弛型内存模型(Relaxed memory models)则可以使用编译器和处理器的重排序优化,这导致程序的执行顺序并不与程序员看到的顺序为准。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static mut X: u64 = 0;
static mut Y: u64 = 1;

fn main() {
... // A

unsafe {
... // B
X = 1;
... // C
Y = 3;
... // D
X = 2;
... // E
}
}

假如在CD代码片段中,根本没有用到X = 1,那么编译器很可能会将X = 1X = 2进行合并:

1
2
3
4
5
6
7
8
9
10
 ...     // A

unsafe {
... // B
X = 2;
... // C
Y = 3;
... // D
... // E
}

若代码A中创建了一个新的线程用于读取全局静态变量X,则该线程将无法读取到X = 1的结果,因为在编译阶段就已经被优化掉。

因此,松弛型内存模型中,编译器可以在满足程序单线程执行结果的情况下对代码进行重排序,这就导致程序的执行顺序不一定和代码中编写的一样。

内存栅栏(内存屏障)

由于多种内存模型的出现,导致执行指令并不需要真正写入内存就可以返回,继续执行其它指令。但有些时候我们确实需要保证值写入内存后才返回。另外,不同的CPU架构下,所使用的内存模型也不同,有些架构没有做到内存强一致性,并且CPU也可能会对指令做重排优化。这就导致同样的代码在一些CPU上执行时,出现与预期不符的情况,就像前面SC模型中不可能出现的0,0结果可以在松弛型内存模型出现一样。但程序员是面向抽象机编程的,不应该因架构导致的结果不同而修改代码,甚至有些时候根本难以发现这些错误。

因此,引入了内存栅栏(memory barrier)。内存栅栏保证了在执行内存栅栏前的所有的内存操作的结果都写入到内存中(全局共享),在这之后继续执行其它指令。可以将其理解为手动的SC:在程序执行的某些点的前后保持顺序一致性,确保操作不会乱序。

换句话说,程序员只需要恰当地使用内存栅栏去标记出在并发程序中需要同步的变量和操作,就可以显式地告诉编译器和CPU不要对这些部分作出违反顺序一致性的优化。而对于程序的剩余部分,可以进行优化。这样既保证了正确性又保证了CPU和编译器可以做尽可能多的性能优化。

限定内存顺序

现在,我们来看看rust中提供的限定内存顺序的memory barriers,详见std::sync::atomic::Ordering,rust的内存顺序 与 C++ 20 相同,它们可以看成是对编译器和CPU内存序的控制的接口。

1
2
3
4
5
6
7
pub enum Ordering {
Relaxed,
Release,
Acquire,
AcqRel,
SeqCst,
}

Ordering是一个枚举,它们对一致性的要求逐渐增强:

  • Relaxed,对应的松弛型内存模型,对应于 C++ 20 中的 memory_order_relaxed
    • 针对一个变量的读写操作是原子操作
    • 不同线程之间针对该变量的访问操作先后顺序不能得到保证,即有可能乱序
  • Release,用来修饰一个写操作,表示在本线程中,在本行代码之前,有任何读写内存的操作,都不能重新排序到本行语句之后。对应于 C++ 20 中的 memory_order_release
  • Acquire,用来修饰一个读操作,表示在本线程中,在本行代码之后,有任何读写内存的操作,都不能重新排序到本行语句之前。对应于 C++ 20 中的 memory_order_acquire
  • AcqRel,同时拥有ReleaseAcquire的保证。对应于 C++ 20 中的 memory_order_acq_rel
  • SeqCst,顺序一致性,保证所有线程都可以按相同的顺序看到所有顺序一致的操作,对应于 C++ 20 中的 memory_order_seq_cst

原子类型

std::sync::atomic中目前支持12种原子类型:

  • AtomicBool
  • AtomicI8
  • AtomicI16
  • AtomicI32
  • AtomicI64
  • AtomicIsize
  • AtomicPtr
  • AtomicU8
  • AtomicU16
  • AtomicU32
  • AtomicU64
  • AtomicUsize

每个原子类型对应其普通类型,只不过它们的操作是原子性的。拿其中的一个AtomicI8举例,它实现了一系列操作,比如加法、减法、存值、取值等,比如store方法和load的函数签名:

1
2
pub fn store(&self, val: i8, order: Ordering)
pub fn load(&self, order: Ordering) -> i8

注意第二个参数都是Ordering类型,它描述的就是该操作的内存顺序,或者说需要使用的内存栅栏。

限定内存顺序的实例

下面,通过一个例子来总结前面的知识。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{self, JoinHandle};
const IS_OK: AtomicBool = AtomicBool::new(false);
static mut NUM: i32 = 0;
fn release() -> JoinHandle<()> {
thread::spawn(move || {
unsafe {
NUM = 100; // 1.
}
IS_OK.store(true, Ordering::Relaxed); // 2.
})
}

fn aquire() -> JoinHandle<()> {
thread::spawn(move || {
IS_OK.load(Ordering::Relaxed); // 3.
unsafe {
println!("{}", NUM); // 4.
}
})
}
fn main() {
let handle_r = release();
let handle_a = aquire();
handle_a.join();
handle_r.join();
}

对于release函数,如果采用松弛型内存模型,也就是Relaxed,那么在编译优化或者CPU优化的过程中,可能会发生2先于1的情况:

1
2
3
4
5
6
7
8
fn release() -> JoinHandle<()> {
thread::spawn(move || {
IS_OK.store(true, Ordering::Relaxed); // 2.
unsafe {
NUM = 100; // 1.
}
})
}

为了保证写入的内存序,我们改用Release

1
2
3
4
5
6
7
8
9
fn release() -> JoinHandle<()> {
thread::spawn(move || {
let s = 5; // t.
unsafe {
NUM = 100; // 1.
}
IS_OK.store(true, Ordering::Release); // 2.
})
}

这样可以告诉编译器和CPU,保证2一定在1后执行。另外在这里注意,对于1t之间可以被乱序。

假设我们想让其它线程看到1,2的顺序,使用Relaxed是不够的,因为完全可能有下面的情况发生:

1
2
3
4
5
6
7
8
fn aquire() -> JoinHandle<()> {
thread::spawn(move || {
unsafe {
println!("{}", NUM); // 4.
}
IS_OK.load(Ordering::Relaxed); // 3.
})
}

因此需要对aquire修改:

1
2
3
4
5
6
7
8
fn aquire() -> JoinHandle<()> {
thread::spawn(move || {
IS_OK.load(Ordering::Acquire); // 3.
unsafe {
println!("{}", NUM); // 4.
}
})
}

Acquire保证了后续的内存操作都不能放到这条指令之前,因此这段代码应该可以正确地输出最终结果100。但事实并非如此,在测试过程中,发现了两处问题,首先要使用static IS_OK而不能用const IS_OK,否则无法成功地修改原子类型的值;其次,要使用while !IS_OK.load(Ordering::Acquire) {}循环确保IS_OKtrue,修改后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{self, JoinHandle};
// 这里要用static,不可以使用const
static IS_OK: AtomicBool = AtomicBool::new(false);
static mut NUM: i32 = 0;
fn release() -> JoinHandle<()> {
thread::spawn(move || {
unsafe {
NUM = 100; // 1.
}
IS_OK.store(true, Ordering::Release); // 2.
})
}

fn aquire() -> JoinHandle<()> {
thread::spawn(move || {
// 确保IS_OK为true
while !IS_OK.load(Ordering::Acquire) {} // 3.

unsafe {
println!("{}", NUM); // 4.
}
})
}
fn main() {
let handle_r = release();
let handle_a = aquire();
handle_a.join().unwrap();
handle_r.join().unwrap();
}

这一小节中的很多内容与操作系统、编译器和CPU本身有关,并不是rust所独有的,比如内存模型、内存屏障,以及与C++20相同的memory order,如果有些内容不理解,不妨看看C++的实现或者内存模型的更多资料。

3.5 使用 SyncSend 特征的可扩展并发

我们之前讨论的几乎所有内容,都属于标准库,而不是rust语言本身的内容。由于不需要语言提供并发相关的基础设施,并发方案不受标准库或语言所限:我们可以编写自己的或使用别人编写的并发功能。然而有两个并发概念是内嵌于语言中的:std::marker 中的 SyncSend 特征,它们称为标记特征(marker trait)。它们的作用分别是:

  • 实现Send的类型可以在线程间安全的传递其所有权
  • 实现Sync的类型可以在线程间安全的共享(通过引用)

通过Send允许在线程间转移所有权

Send 标记特征表明实现了 Send 的类型值的所有权可以在线程间传送。任何完全由 Send 的类型组成的类型会自动被标记为 Send。几乎所有rust类型都是 Send 的。不过有一些例外,包括 Rc<T>:这是不能 Send 的,因为如果克隆了 Rc<T> 的值并尝试将克隆的所有权转移到另一个线程,这两个线程都可能同时更新引用计数。为此,Rc<T> 被实现为用于单线程场景,这时不需要为拥有线程安全的引用计数而付出性能代价。

因此,正如线程安全的引用计数介绍的,rust类型系统和特征约束确保永远也不会意外的将不安全的 Rc<T> 在线程间发送。而使用标记为 SendArc<T> 时,就没有问题了。

另外一个不能 Send 的是裸指针。关于裸指针的介绍,见解引用裸指针

另外,互斥锁通过lock创建的std::sync::MutexGuard也没有实现Send

Sync允许多线程访问

Sync 标记特征表明一个实现了 Sync 的类型可以安全的在多个线程中拥有其值的引用。换一种方式来说,对于任意类型 T,如果 &TT 的不可变引用)是 Send 的话 T 就是 Sync 的,这意味着其引用就可以安全的发送到另一个线程。类似于 Send 的情况,基本类型是 Sync 的,完全由 Sync 的类型组成的类型也是 Sync 的。

智能指针 Rc<T> 也不是 Sync 的,原因与 Send 相同。RefCell<T>Cell<T> 系列类型不是 Sync 的,RefCell<T> 在运行时所进行的借用检查也不是线程安全的。Mutex<T>Sync 的,它可以被用来在多线程中共享访问。

我们曾经介绍过读写锁 RwLock,作为例子,我们来看看它的实现:

1
unsafe impl<T: ?Sized + Send + Sync> Sync for RwLock<T> {}

首先RwLock可以在线程间安全的共享,那它肯定是实现了Sync,并且,RwLock可以并发读,说明其中的值T必定也可以在线程间共享,那T必定要实现Sync,在上面的特征约束中,可以看到T的特征约束中就有一个Sync特征。

对于互斥锁来说,它不允许多线程中共享引用,通过它的实现:

1
unsafe impl<T: ?Sized + Send> Sync for Mutex<T> {}

可以看出,T的特征约束中果然没有Sync

再来对比一下Rc<T>Arc<T>,看看Arc<T>为何可以在多线程使用:

1
2
3
4
5
6
7
// Rc源码片段
impl<T: ?Sized> !marker::Send for Rc<T> {}
impl<T: ?Sized> !marker::Sync for Rc<T> {}

// Arc源码片段
unsafe impl<T: ?Sized + Sync + Send> Send for Arc<T> {}
unsafe impl<T: ?Sized + Sync + Send> Sync for Arc<T> {}

!代表移除特征的相应实现,上面代码中Rc<T>SendSync特征被特地移除了实现,而Arc<T>则相反,实现了Sync + Send

这些源码中的?Sized,我们将在后面介绍,详见Sized 特征

手动实现SendSync是不安全的

通常并不需要手动实现 SendSync特征,因为由 SendSync 的类型组成的类型,自动就是 SendSync 的。因为他们是标记特征,甚至都不需要实现任何方法。它们只是用来加强并发相关的不可变性的。

手动实现这些标记特征涉及到编写不安全的rust代码。关于不安全的代码,在后面不安全的rust一章中会进行介绍。

3.6 为裸指针实现 SendSync

裸指针本身就没有任何安全保证,因此它没有实现 SendSync,这意味着下面代码会报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use std::thread;
fn main() {
let p = 5 as *mut u8;
let t = thread::spawn(move || {
println!("{:?}",p);
});

t.join().unwrap();
}
/*
error[E0277]: `*mut u8` cannot be sent between threads safely
--> src\main.rs:4:27
|
4 | let t = thread::spawn(move || {
| ------------- ^------
| | |
| _____________|_____________within this `[closure@src\main.rs:4:27: 4:34]`
| | |
| | required by a bound introduced by this call
5 | | println!("{:?}",p);
6 | | });
| |_____^ `*mut u8` cannot be sent between threads safely
|
= help: within `[closure@src\main.rs:4:27: 4:34]`, the trait `Send` is not implemented for `*mut u8`
*/

我们无法为其直接实现Send特征,但是可以用newtype(我们将在后面介绍,详见newtype):struct MyBox(*mut u8)。由于复合类型中有一个成员没实现Send,该复合类型就不是Send,因此我们需要手动为它实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
use std::thread;

#[derive(Debug)]
struct MyBox(*mut u8);
unsafe impl Send for MyBox {}
fn main() {
let p = MyBox(5 as *mut u8);
let t = thread::spawn(move || {
println!("{:?}",p);
});

t.join().unwrap();
}

此时,我们的指针已经可以在多线程间转移所有权,需要注意的就是,SendSyncunsafe特征,实现时需要用unsafe代码块包裹。

下面为裸指针实现 Sync,由于Sync是多线程间共享一个值,你可能会这么实现:

1
2
3
4
5
6
7
8
9
use std::thread;
fn main() {
let v = 5;
let t = thread::spawn(|| {
println!("{:?}",&v);
});

t.join().unwrap();
}

正如之前线程与move闭包提到的,这里没有使用move,线程如果直接去借用其它线程的变量,会报错:closure may outlive the current function。原因在于编译器无法确定主线程main和子线程t谁的生命周期更长,特别是当两个线程都是子线程时,没有任何人知道哪个子线程会先结束。

因此需要配合Arc去使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
use std::thread;
use std::sync::Arc;
use std::sync::Mutex;

#[derive(Debug)]
struct MyBox(*const u8);
unsafe impl Send for MyBox {}

fn main() {
let b = &MyBox(5 as *const u8);
let v = Arc::new(Mutex::new(b));
let t = thread::spawn(move || {
let _v1 = v.lock().unwrap();
});

t.join().unwrap();
}
/*
error[E0277]: `*const u8` cannot be shared between threads safely
--> src\main.rs:12:27
|
12 | let t = thread::spawn(move || {
| _____________-------------_^
| | |
| | required by a bound introduced by this call
13 | | let _v1 = v.lock().unwrap();
14 | | });
| |_____^ `*const u8` cannot be shared between threads safely
|
= help: within `MyBox`, the trait `Sync` is not implemented for `*const u8`
*/

上面代码将智能指针v的所有权转移给新线程,同时v包含了一个引用类型b,当在新的线程中试图获取内部的引用时,就会报错。

这是因为我们访问的引用实际上还是对主线程中的数据的借用,转移进来的仅仅是外层的智能指针引用。解决方法就是为MyBox实现Sync:

1
unsafe impl Sync for MyBox {}

4 rust与面向对象式编程

面向对象编程(Object-Oriented Programming,OOP)是一种模式化编程方式。现代众多语言基本都支持面向对象范式,例如C++、Java、python等等。rust则并不完全是面向对象的,在一些定义下,rust是面向对象的,在其他定义下,rust则不是。至于rust到底遵循哪一种编程范式,至今仍有争论。rust是基于表达式的编程语言,但它也是面向过程的,同时提供了函数式编程的一些特性。这里并不会过多地讨论rust到底遵循哪一种确定的范式,就像我在Go面向对象中提到的:面向对象只是一种实现形式,本章主要就是介绍如何在rust中实现面向对象设计模式,并讨论这么做与利用rust自身的一些优势实现的方案相比有什么取舍。

4.1 面向对象语言的特点

面向对象的特性为:封装、继承、多态。另外,对象(Object)也是整个理念的重要概念,它来源于 20 世纪 60 年代的 Simula 编程语言。

对象包含数据和行为

由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(Addison-Wesley Professional, 1994)编写的书 Design Patterns: Elements of Reusable Object-Oriented Software 被俗称为 The Gang of Four (字面意思为 “四人帮”),它是面向对象编程模式的目录。它这样定义面向对象编程:

Object-oriented programs are made up of objects. An object packages both data and the procedures that operate on that data. The procedures are typically called methods or operations.

面向对象的程序是由对象组成的。一个对象包含数据和操作这些数据的过程。这些过程通常被称为方法或操作。

在这个定义下,rust是面向对象的:结构体和枚举包含数据而 impl 块提供了在结构体和枚举之上的方法。虽然带有方法的结构体和枚举并不被称为对象,但是参考 The Gang of Four 中对象的定义,它们提供了与对象相同的功能。

封装

另一个通常与面向对象编程相关的方面是封装(encapsulation)的思想:对象的实现细节不能被使用对象的代码获取到。所以唯一与对象交互的方式是通过对象提供的公有 API;使用对象的代码无法深入到对象内部并直接改变数据或者行为。封装使得改变和重构对象的内部时无需改变使用对象的代码。

rust中,可以使用 pub 关键字来决定模块、类型、函数和方法是公有的,而默认情况下其他一切都是私有的。比如,我们可以定义一个包含一个 i32 类型 vector 的结构体AveragedCollection 。结构体也可以有一个字段,该字段保存了 vector 中所有值的平均值。这样,希望知道结构体中的 vector 的平均值的人可以随时获取它,而无需自己计算。换句话说,AveragedCollection 会为我们缓存平均值结果。

1
2
3
4
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}

注意,正如前面控制结构体和枚举的公有中介绍的,结构体自身被标记为 pub,这样其他代码就可以使用这个结构体,但是在结构体内部的字段仍然是私有的。这是非常重要的,因为我们希望保证变量被增加到列表或者被从列表删除时,也会同时更新平均值。可以通过在结构体上实现 addremoveaverage 方法来做到这一点,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
impl AveragedCollection {
pub fn add(&mut self, value: i32) {
self.list.push(value);
self.update_average();
}

pub fn remove(&mut self) -> Option<i32> {
let result = self.list.pop();
match result {
Some(value) => {
self.update_average();
Some(value)
}
None => None,
}
}

pub fn average(&self) -> f64 {
self.average
}

fn update_average(&mut self) {
let total: i32 = self.list.iter().sum();
self.average = total as f64 / self.list.len() as f64;
}
}

公有方法 addremoveaverage 是修改 AveragedCollection 实例的唯一方式。当使用 add 方法把一个元素加入到 list 或者使用 remove 方法来删除时,这些方法的实现同时会调用私有的 update_average 方法来更新 average 字段。

listaverage 是私有的,所以没有其他方式来使得外部的代码直接向 list 增加或者删除元素,否则 list 改变时可能会导致 average 字段不同步。average 方法返回 average 字段的值,这使得外部的代码只能读取 average 而不能修改它。

因为我们已经封装好了 AveragedCollection 的实现细节,将来可以轻松改变类似数据结构这些方面的内容。例如,可以使用 HashSet<i32> 代替 Vec<i32> 作为 list 字段的类型。只要 addremoveaverage 公有函数的签名保持不变,使用 AveragedCollection 的代码就无需改变。相反如果使得 list 为公有,就未必都会如此了: HashSet<i32>Vec<i32> 使用不同的方法增加或移除项,所以如果要想直接修改 list 的话,外部的代码可能不得不做出修改。

如果封装是一个语言被认为是面向对象语言所必要的方面的话,那么rust满足这个要求。在代码中不同的部分使用 pub 与否可以封装其实现细节。

继承

继承(Inheritance)是一个很多编程语言都提供的机制,一个对象可以定义为继承另一个对象定义中的元素,这使其可以获得父对象的数据和行为,而无需重新定义。

如果一个语言必须有继承才能被称为面向对象语言的话,那么rust就不是面向对象的。因为没有宏则无法定义一个结构体继承父结构体的成员和方法。

然而,如果你过去常常在你的编程工具箱使用继承,根据你最初考虑继承的原因,rust也提供了其他的解决方案。

选择继承的原因是:一旦为一个类型实现了特定行为,继承可以对一个不同的类型重用这个实现。rust代码中可以使用默认特征方法实现来进行有限的共享,在默认实现这一小节中,介绍过这一点。任何实现了 Animal 特征的类型都可以使用 introduce 方法而无须进一步实现。这类似于父类有一个方法的实现,而通过继承子类也拥有这个方法的实现。当实现 Animal 特征时也可以选择覆盖 introduce 的默认实现,这类似于子类覆盖从父类继承的方法实现。

另外,在深入理解Fn特征中,也介绍过定义依赖于某个特征的特征:

1
2
trait A {}
trait B: A {} //特征`B`继承特征`A`

特征之间的继承也可以看作rust所部分实现的面向对象式功能。

多态

另外一个使用继承的原因与类型系统有关:表现为子类型可以用于父类型被使用的地方。这也被称为多态(polymorphism),这意味着如果多种对象共享特定的属性,则可以相互替代使用。

很多人将多态描述为继承的同义词。不过它是一个有关可以用于多种类型的代码的更广泛的概念。对于继承来说,这些类型通常是子类。 rust则通过泛型来对不同的可能类型进行抽象,并通过特征约束对这些类型所必须提供的内容施加约束。这有时被称为约束参数多态(bounded parametric polymorphism)。

近来继承作为一种语言设计的解决方案在很多语言中失宠了,因为其时常带有共享多于所需的代码的风险。子类不应总是共享其父类的所有特征,但是继承却始终如此。如此会使程序设计更为不灵活,并引入无意义的子类方法调用,或由于方法实际并不适用于子类而造成错误的可能性。

另外某些语言还只允许单继承(意味着子类只能继承一个父类),进一步限制了程序设计的灵活性。

因为种种原因,rust选择了一个不同的途径,使用特征对象而不是继承。

4.2 特征对象

让我们看一下rust中的特征对象如何实现多态。在前面返回实现了特征的类型小节中,曾经介绍了这样的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
struct Dog {
color: String,
age: u32,
}

struct Cat {
color: String,
age: u32,
weight: f64,
}

trait Animal {

fn introduce_myself(&self) -> String;

fn introduce(&self) {
println!("I'm an Animal! {}", self.introduce_myself());
}
}

impl Animal for Dog {
fn introduce_myself(&self) -> String {
String::from("I'm a Dog!")
}
}

impl Animal for Cat {
fn introduce_myself(&self) -> String {
String::from("I'm a Cat!")
}
}

// Dog和Cat都实现了Animal特征
fn notify(switch: bool) -> impl Animal {
if switch {
Dog{
color: String::from("black"),
age: 3
}
}else{
Cat{
color:String::from("white"),
age: 2,
weight: 9.6
}
}
}

fn main(){
let num = 3;
notify(num % 2 == 0);
}

其中 DogCat 都实现了 Animal 特征,因此上面的函数试图通过返回 impl Summary 来返回这两个类型,但是编译器却报错了,这是由于 impl Trait 的返回值类型并不支持多种不同的类型返回。

使用动态数组和枚举来存储不同类型的数据小节中,我们也谈到了动态数组只能存储同种类型元素的局限。在当时,我们通过组合使用动态数组和枚举的替代方案解决了这一点,然而有时,对象集合并不能明确地知道,并且还希望库用户在特定情况下能够扩展有效的类型集合。

为了解决上述的问题,这里将创建一个图形用户接口(Graphical User Interface,GUI)工具的例子,它通过遍历列表并调用每一个项目的 draw 方法来将其绘制到屏幕上 —— 这是一个 GUI 工具的常见技术。

在编写代码的时候,我们不可能知晓并定义所有其他程序员希望创建的类型。我们所知晓的是需要一个统一的类型来处理这些对象,并需要能够对其中每一个值调用 draw 方法。这里无需知道调用 draw 方法时具体会发生什么,只要该值会有那个方法可供我们调用。

在拥有继承的语言中,可以定义一个名为 Component 的类,该类上有一个 draw 方法。其他的类比如 ButtonImageSelectBox 会从 Component 派生并因此继承 draw 方法。它们各自都可以覆盖 draw 方法来定义自己的行为,但是框架会把所有这些类型当作是 Component 的实例,并在其上调用 draw。不过这些在rust中需要另寻出路。

定义通用行为的特征

为了实现这个GUI,让我们定义一个 Draw 特征,其中包含名为 draw 的方法。接着可以定义一个存放特征对象(trait object)的 vector:

1
2
3
4
5
6
7
pub trait Draw {
fn draw(&self);
}

pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}

我们通过指定某种指针来创建特征对象,可以使用 & 引用或 Box<T> 智能指针,还有 dyn 关键字,以及指定相关的特征(在动态大小类型中会介绍特征对象必须使用指针的原因),我们可以使用特征对象代替泛型或具体类型。任何使用特征对象的位置,rust的类型系统会在编译时确保任何在此上下文中使用的值会实现其特征对象的特征。如此便无需在编译时就知晓所有可能的类型。

具体来说,上面的代码中,定义了一个存放了名叫 components 的 vector 的结构体 Screen。这个 vector 的类型是 Box<dyn Draw>,此为一个特征对象:它是 Box 中任何实现了 Draw 特征的类型的替身。

接下来,我们为Screen定义一个方法,该方法会对其 components 上的每一个组件调用 draw 方法:

1
2
3
4
5
6
7
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}

这与定义使用了带有特征约束的泛型类型参数的结构体不同。泛型类型参数一次只能替代一个具体类型,而特征对象则允许在运行时替代多种具体类型。

例如,如果定义 Screen 结构体来使用泛型和特征约束:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}

impl<T> Screen<T>
where
T: Draw,
{
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}

这就限制了 Screen 实例必须拥有components内包含了同一个类型T的组件列表。如果只需要相同类型的集合,则倾向于使用泛型和特征约束,因为其定义会在编译时采用具体类型进行单态化。不过,这不符合我们本节内容的需求。

通过使用特征对象的方法,一个 Screen 实例可以存放一个既能包含 Box<Button>,也能包含 Box<TextField>Vec<T>。接下来就先定义这两个类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}

impl Draw for Button {
fn draw(&self) {
// code to actually draw a button
}
}

struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}

impl Draw for SelectBox {
fn draw(&self) {
// code to actually draw a select box
}
}

这里增加了两个实现了 Draw 特征的类型,ButtonSelectBox,二者都拥有一些字段。由于这并不是真实的GUI库,我们只是模拟,因此draw方法中没有具体的实现。

现在,我们就可以让使用者在他们的 main 函数中创建一个 Screen 实例。至此可以通过将 SelectBoxButton 放入 Box<T> 转变为特征对象来增加组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fn main() {
let screen = Screen {
components: vec![
Box::new(SelectBox {
width: 75,
height: 10,
options: vec![
String::from("Yes"),
String::from("Maybe"),
String::from("No"),
],
}),
Box::new(Button {
width: 50,
height: 10,
label: String::from("OK"),
}),
],
};

screen.run();
}

当编写库的时候,我们不知道何人会在何时增加 SelectBox 类型,不过 Screen 的实现能够操作并绘制这个新类型,因为 SelectBox 实现了 Draw 特征,这就意味着它实现了 draw 方法。

在python中有一个概念叫做鸭子类型(duck typing):如果一只鸟走起来像鸭子,叫起来像鸭子,那么它就是一只鸭子。

在上面 Screenrun 实现中,run 并不需要知道各个组件的具体类型是什么。它并不检查组件是 Button 或者 SelectBox 的实例。通过指定 Box<dyn Draw> 作为 components 动态数组中值的类型,我们就定义了 Screen 为需要可以在其上调用 draw 方法的值。

使用特征对象和rust类型系统来进行类似鸭子类型操作的优势是无需在运行时检查一个值是否实现了特定方法或者担心在调用时因为值没有实现方法而产生错误。如果值没有实现特征对象所需的特征则rust不会编译这些代码。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fn main() {
let screen = Screen {
components: vec![
Box::new(String::from("hello")),
],
};

screen.run();
}
/*
error[E0277]: the trait bound `String: Draw` is not satisfied
--> src\main.rs:44:13
|
44 | Box::new(String::from("hello")),
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
|
= help: the following other types implement trait `Draw`:
Button
SelectBox
= note: required for the cast from `String` to the object type `dyn Draw`
*/

下面是本节实例中完整的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
pub trait Draw {
fn draw(&self);
}


pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}

impl Draw for Button {
fn draw(&self) {
// code to actually draw a button
}
}

struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}

impl Draw for SelectBox {
fn draw(&self) {
// code to actually draw a select box
}
}

fn main() {
let screen = Screen {
components: vec![
Box::new(SelectBox {
width: 75,
height: 10,
options: vec![
String::from("Yes"),
String::from("Maybe"),
String::from("No"),
],
}),
Box::new(Button {
width: 50,
height: 10,
label: String::from("OK"),
}),
],
};

screen.run();
}

注意 dyn 不能单独作为特征对象的定义,原因是特征对象可以是任意实现了某个特征的类型,编译器在编译期不知道该类型的大小,不同的类型大小是不同的,而 &dynBox<dyn> 都是指针,在编译期已知大小。

特征对象执行动态分发

在泛型中,当对泛型使用特征约束时编译器会执行单态化处理:编译器为每一个被泛型类型参数代替的具体类型生成了函数和方法的非泛型实现。单态化产生的代码在执行静态分发(static dispatch)。静态分发发生于编译器在编译时就知晓调用了什么方法的时候。这与动态分发 (dynamic dispatch)相对,这时编译器在编译时无法知晓调用了什么方法。在动态分发的场景下,编译器生成的代码到运行时才能确定调用了什么方法。

当使用特征对象时,rust必须使用动态分发。编译器无法知晓所有可能用于特征对象代码的类型,所以它也不知道应该调用哪个类型的哪个方法实现。为此,rust在运行时使用特征对象中的指针来知晓需要调用哪个方法。动态分发也阻止编译器有选择的内联方法代码,这会相应的禁用一些优化。尽管编写GUI示例代码的过程中确实获得了额外的灵活性,但仍然需要权衡取舍性能上的问题。

下面这张图很好地解释了静态分发 Box<T> 和动态分发 Box<dyn Trait> 的区别:

rust_dynamic_dispatch

结合上文的内容和这张图可以了解:

  • 特征对象大小不固定:这是因为,对于特征 Draw,类型 Button 可以实现特征 Draw,类型 SelectBox 也可以实现特征 Draw,因此特征没有固定大小
  • 几乎总是使用特征对象的引用方式,如 &dyn DrawBox<dyn Draw>
    • 虽然特征对象没有固定大小,但它的引用类型的大小是固定的,它由两个指针组成(ptrvptr),因此占用两个指针大小
    • 一个指针 ptr 指向实现了特征 Draw 的具体类型的实例,也就是当作特征 Draw 来用的类型的实例,比如类型 Button 的实例、类型 SelectBox 的实例
    • 另一个指针 vptr 指向一个虚拟方法表(virtual method table),虚拟方法表也通常被称为虚函数表(vtable),vtable 中保存了类型 Button 或类型 SelectBox 的实例对于可以调用的实现于特征 Draw 的方法。当调用方法时,直接从 vtable 中找到方法并调用。之所以要使用一个 vtable 来保存各实例的方法,是因为实现了特征 Draw 的类型有多种,这些类型拥有的方法各不相同,当将这些类型的实例都当作特征 Draw 来使用时(此时,它们全都看作是特征 Draw 类型的实例),有必要区分这些实例各自有哪些方法可调用

简而言之,当类型 Button 实现了特征 Draw 时,类型 Button 的实例对象 btn 可以当作特征 Draw 的特征对象类型来使用,btn 中保存了作为特征对象的数据指针(指向类型 Button 的实例数据)和行为指针(指向 vtable)。

一定要注意,此时的 btnDraw 的特征对象的实例,而不再是具体类型 Button 的实例,而且 btnvtable 只包含了实现自特征 Draw 的那些方法(比如 draw),因此 btn 只能调用实现于特征 Drawdraw 方法,而不能调用类型 Button 本身实现的方法和类型 Button 实现于其他特征的方法。也就是说,btn 是哪个特征对象的实例,它的 vtable 中就包含了该特征的方法。

trait 对象需要类型安全

只有对象安全(object-safe)的特征可以实现为特征对象。这里有一些复杂的规则来实现特征的对象安全,但在实践中,只有两个相关的规则。如果一个特征中定义的所有方法都符合以下规则,则该特征是对象安全的:

  • 返回值不是 Self
  • 没有泛型类型的参数

Self 关键字是我们在特征与方法上的实现的别称,特征对象必须是对象安全的,因为一旦使用特征对象,rust将不再知晓该实现的返回类型。如果一个特征的方法返回了一个 Self 类型,但是该特征对象忘记了 Self 的确切类型,那么该方法将不能使用原本的类型。当特征使用具体类型填充的泛型类型时也一样:具体类型成为实现特征的对象的一部分,当使用 trait 对象却忘了类型是什么时,无法知道应该用什么类型来填充泛型类型。

一个非对象安全的特征例子是标准库中的 Clone 特征:

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

String 类型实现了 Clone 特征,当我们在 String 的实例对象上调用 clone 方法时,我们会得到一个 String 类型实例对象。相似地,如果我们调用 Vec<T> 实例对象上的 clone 方法,我们会得到一个 Vec<T> 类型的实例对象。clone 方法的标签需要知道哪个类型是 Self 类型,因为 Self 是它的返回类型。

当我们尝试编译一些违反特征对象的对象安全规则的代码时,我们会收到编译器的提示。

1
2
3
4
5
6
7
8
9
10
11
12
13
pub struct Screen {
pub components: Vec<Box<dyn Clone>>,
}
/*
error[E0038]: the trait `Clone` cannot be made into an object
--> src/lib.rs:2:29
|
2 | pub components: Vec<Box<dyn Clone>>,
| ^^^^^^^^^ `Clone` cannot be made into an object
|
= note: the trait cannot be made into an object because it requires `Self: Sized`
= note: for a trait to be "object safe" it needs to allow building a vtable to allow the call to be resolvable dynamically; for more information visit <https://doc.rust-lang.org/reference/items/traits.html#object-safety>
*/

这个错误意味着我们不能将此特征用于特征对象。如果你想了解更多有关对象安全的细节,请参考 Rust RFC 255 或查看 Rust Reference

5 不安全的rust

目前为止讨论过的代码都有rust在编译时会强制执行的内存安全保证。然而,rust还隐藏有第二种语言,它不会强制执行这类内存安全保证:这被称为不安全rust(unsafe Rust)。它与常规rust代码无异,但是会提供额外的超能力。

在之前很多例子中可以看出,尽管代码可能没问题,但如果rust编译器没有足够的信息可以确定,它将拒绝代码。

不安全rust之所以存在,是因为静态分析本质上是保守的。当编译器尝试确定一段代码是否支持某个保证时,拒绝一些合法的程序比接受无效的程序要好一些。这必然意味着有时可能是合法的,但如果rust编译器没有足够的信息来确定,它将拒绝该代码。在这种情况下,可以使用不安全代码告诉编译器,“相信我,我知道我在干什么。” 不过千万注意,使用不安全rust风险自担:如果不安全代码出错了,比如解引用空指针,可能会导致不安全的内存使用。

另一个rust存在不安全一面的原因是:底层计算机硬件固有的不安全性。如果rust不允许进行不安全操作,那么有些任务则根本完成不了。rust需要能够进行像直接与操作系统交互,甚至于编写你自己的操作系统这样的底层系统编程。这也是 rust语言的目标之一。

5.1 不安全的超能力

可以通过 unsafe 关键字来切换到不安全rust,接着可以开启一个新的存放不安全代码的块。这里有五类可以在不安全rust中进行而不能用于安全rust的操作,它们称之为不安全的超能力(unsafe superpowers),这些超能力是:

  • 解引用裸指针
  • 调用不安全的函数或方法
  • 访问或修改可变静态变量
  • 实现不安全特征
  • 访问 union 的字段

有一点很重要,unsafe 并不会关闭借用检查器或禁用任何其他rust安全检查:如果在不安全代码中使用引用,它仍会被检查。unsafe 关键字只是提供了那五个不会被编译器检查内存安全的功能。你仍然能在不安全块中获得某种程度的安全。

再者,unsafe 不意味着块中的代码就一定是危险的或者必然导致内存安全问题:其意图在于作为程序员你将会确保 unsafe 块中的代码以有效的方式访问内存。

人是会犯错误的,错误总会发生,不过通过要求这五类操作必须位于标记为 unsafe 的块中,就能够知道任何与内存安全相关的错误必定位于 unsafe 块内。保持 unsafe 块尽可能小,如此当之后调查内存 bug 时就会感谢你自己了。

为了尽可能隔离不安全代码,将不安全代码封装进一个安全的抽象并提供安全 API 是一个好主意,当我们学习不安全函数和方法时会讨论到。标准库的一部分被实现为在被评审过的不安全代码之上的安全抽象。这个技术防止了 unsafe 泄露到所有你或者用户希望使用由 unsafe 代码实现的功能的地方,因为使用其安全抽象是安全的。

让我们按顺序依次介绍上述五个超能力,同时我们会看到一些提供不安全代码的安全接口的抽象。

5.2 解引用裸指针

在之前悬垂引用小节,提到了编译器会确保引用总是有效的。不安全rust有两个被称为裸指针(raw pointers)的类似于引用的新类型。和引用一样,裸指针是不可变或可变的,分别写作 *const T*mut T。这里的星号不是解引用运算符;它是类型名称的一部分。在裸指针的上下文中,不可变意味着指针解引用之后不能直接赋值。

裸指针与引用和智能指针的区别在于

  • 允许忽略借用规则,可以同时拥有不可变和可变的指针,或多个指向相同位置的可变指针
  • 不保证指向有效的内存
  • 允许为空
  • 不能实现任何自动清理功能

通过去掉rust强加的保证,裸指针本质上等同于C或C++的指针,你可以放弃安全保证以换取性能或使用另一个语言或硬件接口的能力,此时rust的保证并不适用。

下面的代码从引用同时创建不可变和可变裸指针:

1
2
3
4
let mut num = 5;

let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;

注意这里没有引入 unsafe 关键字。可以在安全代码中创建裸指针,只是不能在不安全块之外解引用裸指针,稍后便会看到。

这里使用 as 将不可变和可变引用强转为对应的裸指针类型。因为直接从保证安全的引用来创建他们,可以知道这些特定的裸指针是有效,但是不能对任何裸指针做出如此假设。

作为展示,接下来会创建一个不能确定其有效性的裸指针,

1
2
let address = 0x012345usize;
let r = address as *const i32;

这里创建一个指向任意内存地址(0x012345)的裸指针。尝试使用任意内存是未定义行为:此地址可能有数据也可能没有,编译器可能会优化掉这个内存访问,或者程序可能会出现段错误(segmentation fault)。虽然这是可行的,但通常没有理由去编写这样的代码。同样,可以在安全代码中创建裸指针,不过不能在安全代码中解引用裸指针和读取其指向的数据。

现在我们要做的就是对裸指针使用解引用运算符 *,这需要一个 unsafe 块:

1
2
3
4
5
6
7
8
9
10
11
fn main(){
let mut num = 5;

let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;

unsafe {
println!("r1 is: {}", *r1);
println!("r2 is: {}", *r2);
}
}

创建一个指针不会造成任何危险,只有当访问其指向的值时才有可能遇到无效的值。

注意这里创建了同时指向相同内存位置 num 的裸指针 *const i32*mut i32。相反如果尝试同时创建 num 的不可变和可变引用,将无法通过编译,因为rust的所有权规则不允许在拥有任何不可变引用的同时再创建一个可变引用。通过裸指针,就能够同时创建同一地址的可变指针和不可变指针,若通过可变指针修改数据,则可能潜在造成数据竞争,因此需要非常小心这样的操作。

还有一种创建裸指针的方式,那就是基于智能指针来创建:

1
2
3
4
5
let a: Box<i32> = Box::new(10);
// 需要先解引用a
let b: *const i32 = &*a;
// 使用 into_raw 来创建
let c: *const i32 = Box::into_raw(a);

既然存在这么多的危险,为何还要使用裸指针呢?一个主要的应用场景便是调用 C 代码接口,这在下一小节调用不安全函数或方法中会讲到。另一个场景是构建借用检查器无法理解的安全抽象。

5.3 调用不安全函数或方法

定义与使用不安全函数

unsafe的第二个超能力是调用不安全函数或方法。不安全函数和方法与常规函数方法十分类似,除了其开头有一个额外的 unsafe。在此上下文中,关键字unsafe表示该函数具有调用时需要满足的要求,而rust不会保证满足这些要求。通过在 unsafe 块中调用不安全函数,表明我们已经阅读过此函数的文档并对其是否满足函数自身的契约负责。

不安全的函数需要使用unsafe关键字定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
unsafe fn dangerous() {}
fn main() {
dangerous();
}
/*
error[E0133]: call to unsafe function is unsafe and requires unsafe function or block
--> src\main.rs:13:5
|
13 | dangerous();
| ^^^^^^^^^^^ call to unsafe function
|
= note: consult the function's documentation for information on how to avoid undefined behavior
*/

调用也需要在unsafe块中,通过 unsafe 块,我们向rust保证了我们已经阅读过函数的文档,理解如何正确使用,并验证过其满足函数的契约。另外,unsafe函数体本身也是有效的 unsafe 块,所以在不安全函数中进行另一个不安全操作时无需新增额外的 unsafe 块。

创建不安全代码的安全抽象

仅仅因为函数包含不安全代码并不意味着整个函数都需要标记为不安全的。事实上,将不安全代码封装进安全函数是一个常见的抽象。在标准库中有大量的安全函数,它们内部的一些实现包含了 unsafe 块。比如标准库中的函数split_at_mut,它需要一些不安全代码,让我们探索如何可以实现它。这个安全函数定义于可变切片之上:它获取一个 切片并从给定的索引参数开始将其分为两个切片。先来看一下用法:

1
2
3
4
5
6
7
8
9
10
fn main() {
let mut v = vec![1, 2, 3, 4, 5, 6];

let r = &mut v[..];
// 从索引为3的位置拆分为两个可变的切片
let (a, b) = r.split_at_mut(3);

assert_eq!(a, &mut [1, 2, 3]);
assert_eq!(b, &mut [4, 5, 6]);
}

这个函数无法只通过安全rust实现,尝试自己实现一个split_at_mut

1
2
3
4
5
6
7
fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = values.len();

assert!(mid <= len);

(&mut values[..mid], &mut values[mid..])
}

此函数首先获取切片的长度,然后通过检查参数是否小于或等于这个长度来断言参数所给定的索引位于切片当中。该断言意味着如果传入的索引比要分割的切片的索引更大,此函数在尝试使用这个索引前panic。

之后我们在一个元组中返回两个可变的切片:一个从原始切片的开头直到 mid 索引,另一个从 mid 直到原切片的结尾。

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

1
2
3
4
5
6
7
8
9
10
11
12
error[E0499]: cannot borrow `*values` as mutable more than once at a time
--> src\main.rs:16:31
|
11 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
| - let's call the lifetime of this reference `'1`
...
16 | (&mut values[..mid], &mut values[mid..])
| --------------------------^^^^^^--------
| | | |
| | | second mutable borrow occurs here
| | first mutable borrow occurs here
| returning this value requires that `*values` is borrowed for `'1`

本质上借用切片的不同部分是可以的,因为这两个切片不会重叠,但是rust的借用检查器不能理解我们要借用这个切片的两个不同部分:它只知道我们借用了同一个切片两次。编译器还没有智能到能够理解这些。当我们知道某些事是可以的而 rust不知道的时候,就是触及不安全代码的时候了。

接下来,使用 unsafe 块和裸指针和一些不安全函数调用来实现 split_at_mut

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use std::slice;

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = values.len();
let ptr = values.as_mut_ptr();

assert!(mid <= len);

unsafe {
(
slice::from_raw_parts_mut(ptr, mid),
slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
}

切片是一个指向一些数据的指针,并带有该切片的长度。可以使用 len 方法获取切片的长度,使用 as_mut_ptr 方法访问切片的裸指针。在这个例子中,因为有一个 i32 值的可变切片,as_mut_ptr 返回一个 *mut i32 类型的裸指针,储存在 ptr 变量中(在5.2中提到过:可以在安全代码中创建裸指针,但不能使用)。我们保留索引 mid 位于切片中的断言。

接着是不安全代码:slice::from_raw_parts_mut 函数获取一个裸指针和一个长度来创建一个切片。这里使用此函数从 ptr 中创建了一个有 mid 个项的切片。之后在 ptr 上调用 add 方法并使用 mid 作为参数来获取一个从 mid 开始的裸指针,使用这个裸指针并以 mid 之后项的数量为长度创建一个切片。这两个切片都是可变的,最后将其返回。

slice::from_raw_parts_mut 函数是不安全的,因为它获取一个裸指针,并必须确信这个指针是有效的。裸指针上的 add 方法也是不安全的,因为其必须确信此地址偏移量也是有效的指针。因此必须将 slice::from_raw_parts_mutadd 放入 unsafe 块中以便能调用它们。通过观察代码,和增加 mid 必然小于等于 len 的断言,我们可以说 unsafe 块中所有的裸指针将是有效的切片中数据的指针。这是一个可以接受的 unsafe 的恰当用法。

我们无需将 split_at_mut 函数的结果标记为 unsafe,并可以在安全rust中调用此函数。我们创建了一个不安全代码的安全抽象,其代码以一种安全的方式使用了 unsafe 代码,因为其只从这个函数访问的数据中创建了有效的指针。

与此相对 slice::from_raw_parts_mut 在使用切片时很有可能会崩溃。这段代码获取任意内存地址并创建了一个长为一万的切片:

1
2
3
4
5
6
7
8
use std::slice;
fn main() {
let address = 0x01234usize;
let r = address as *mut i32;

let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
println!("{:?}",values); // Segmentation fault
}

我们并不拥有这个任意地址的内存,也不能保证这段代码创建的切片包含有效的 i32 值。试图使用臆测为有效的 values 会导致未定义的行为。当我们使用values时,会得到一个Segmentation fault的错误。

FFI

外部函数接口(Foreign Function Interface,FFI)是一个编程语言用以定义函数的方式,其允许不同(外部)编程语言调用这些函数。

由于rust语言仍在发展,许多功能和生态还处于空白状态,在这种情况下,能够调用其它语言现有的库是一种非常方便的选择,这省去了将这些优秀的库重写为rust的重复工作。另外,还可以在将其它语言的代码重构为rust时,先将相关代码引入到rust项目中,然后渐进式地重构。

使用 extern 函数调用外部代码

正如上文所说,在一些情况下,rust代码可能需要与其它语言编写的代码交互,为此rust有一个关键字extern,有助于创建和使用FFI。下面这个例子展示了如何集成 C 标准库中的 abs 函数。extern 块中声明的函数在rust代码中总是不安全的。因为其它语言不会强制执行rust的规则且rust无法检查它们,所以确保其安全是程序员的责任:

1
2
3
4
5
6
7
8
9
extern "C" {
fn abs(input: i32) -> i32;
}

fn main() {
unsafe {
println!("Absolute value of -3 according to C: {}", abs(-3));
}
}

extern "C" 块中,列出了我们希望能够调用的另一个语言中的外部函数的签名和名称。"C" 部分定义了外部函数所使用的应用二进制接口(application binary interface,ABI) —— ABI 定义了如何在汇编语言层面调用此函数。"C" ABI 是最常见的,并遵循 C 编程语言的 ABI。

从其它语言调用rust函数

也可以使用 extern 来创建一个允许其他语言调用rust函数的接口。不同于创建整个 extern 块,就在 fn 关键字之前增加 extern 关键字并为相关函数指定所用到的 ABI。还需增加 #[no_mangle] 注解来告诉rust编译器不要 mangle 此函数的名称。Mangling发生于当编译器将我们指定的函数名修改为不同的名称时,这会增加用于其他编译过程的额外信息,不过会使其名称更难以阅读。每一个编程语言的编译器在编译时都会以稍微不同的方式 mangle 函数名,所以为了使rust函数能在其它语言中指定,必须禁用rust编译器的 name mangling。

在如下的例子中,一旦其编译为动态库并从 C 语言中链接,call_from_c 函数就能够在 C 代码中访问:

1
2
3
4
#[no_mangle]
pub extern "C" fn call_from_c() {
println!("Just called a Rust function from C!");
}

extern 的使用无需 unsafe

5.4 访问或修改可变静态变量

目前为止,我们都尽量避免讨论全局变量(global variables),rust确实支持他们,不过这对于rust的所有权规则来说是有问题的。如果有两个线程访问相同的可变全局变量,则可能会造成数据竞争。

全局变量在rust中被称为静态(static)变量,这种变量一般用于全局数据统计(如计数器、全局状态等):

1
2
3
4
5
static HELLO_WORLD: &str = "Hello, world!";

fn main() {
println!("name is: {}", HELLO_WORLD);
}

这里定义和使用了一个不可变静态变量,静态(static)变量类似于常量(const),静态变量只能储存拥有 'static 生命周期的引用(见静态生命周期),这意味着rust编译器可以自己计算出其生命周期而无需显式标注。访问不可变静态变量是安全的。

常量与不可变静态变量的区别如下:

  • 静态变量中的值有一个固定的内存地址,使用这个值总是会访问相同的地址。另一方面,常量则允许在任何被用到的时候复制其数据。
  • 静态变量可以是可变的,常量则不可以。访问和修改可变静态变量都是不安全的。
  • 存储在静态变量中的值必须要实现Sync特征。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static mut COUNTER: u32 = 0;

fn add_to_count(inc: u32) {
unsafe {
COUNTER += inc;
}
}

fn main() {
add_to_count(3);

unsafe {
println!("COUNTER: {}", COUNTER);
}
}

上面的例子中,展示了如何声明、访问和修改名为 COUNTER 的可变静态变量。就像常规变量一样,我们使用 mut 关键来指定可变性。任何读写 COUNTER 的代码都必须位于 unsafe 块中。这段代码可以编译并如期打印出 COUNTER: 3,因为这是单线程的。拥有多个线程访问 COUNTER 则可能导致数据竞争。

拥有可以全局访问的可变数据,难以保证不存在数据竞争,这就是为何rust认为可变静态变量是不安全的。任何可能的情况,可以优先使用并发编程中介绍的内容,这样编译器就能检测不同线程间的数据访问是否是安全的。

5.5 实现不安全特征

unsafe 的另一个操作用例是实现不安全特征。当特征中至少有一个方法中包含编译器无法验证的不变式(invariant)时 trait 是不安全的。可以在 trait 之前增加 unsafe 关键字将特征声明为 unsafe,同时特征的实现也必须标记为 unsafe,比如:

1
2
3
4
5
6
7
8
9
unsafe trait Foo {
// methods go here
}

unsafe impl Foo for i32 {
// method implementations go here
}

fn main() {}

这样,相应的正确性由我们自己来保证。在为裸指针实现 SendSync中的SyncSend 标记特征,编译器会自动为完全由 SendSync 类型组成的类型自动实现它们。作为例子,我们还为裸指针实现了 SendSync ,其中必须用到unsafe,这是因为rust不能验证我们的类型保证可以安全的跨线程发送或在多线程间访问,所以需要我们自己进行检查并通过 unsafe 表明。

5.6 访问union中的字段

最后一个超能力是是访问union中的字段,到目前为止还没有介绍过union,它主要用于和 C 代码中的union进行交互。 unionstruct 类似,但是在一个实例中同时只能使用一个声明的字段。union主要用于和 C 代码中的联合体交互。访问联合体的字段是不安全的,因为rust无法保证当前存储在联合体实例中数据的类型。

1
2
3
4
5
#[repr(C)]
union MyUnion {
f1: u32,
f2: f32,
}

可以看出,union 的使用方式与结构体很相似,但是前者的所有字段都共享同一个存储空间,意味着往 union 的某个字段写入值,会导致其它字段的值会被覆盖。

这与C中的union特性相同,在C中,union的内存占用以成员列表中最大的数据类型为准,与结构体不同的是,union的所有成员占用同一段内存,修改一个成员会影响其余所有成员的值:

1
2
3
4
5
union A {
int aa;
char bb;
long long cc;
}foo;

这里,最大的数据类型为long long,它占用8字节内存。

更多细节,查看Unions了解。

6 深入类型

6.1 类型转换

使用as转换

rust不支持不同类型之间的比较:

1
2
3
4
5
6
7
8
fn main() {
let a: i32 = 10;
let b: u16 = 100;

if a < b {
println!("Ten is less than one hundred.");
}
}

为了让它们可以比较,可以使用 as 关键字进行显式类型转换(casting):

1
2
3
4
5
6
7
8
fn main() {
let a: i32 = 10;
let b: u16 = 100;

if a < b as i32 {
println!("Ten is less than one hundred.");
}
}

在这个例子中,需要将b显式转换为i32而不是将a转换为u16,这是因为每个类型能表达的数据范围不同,如果把范围较大的类型转换成较小的类型,比如:

1
2
3
4
5
fn main() {
// 0000 0000 0000 0000 0000 0001 0000 0100
let a: i32 = 260;
println!("{}", a as i8) // 0000 0100
}

会打印出4,而不是300,因为 i8 类型能表达的的最大值为 2^7 - 1,超出这个范围的位都会被抹去(保留符号位)。使用以下代码可以查看 i8 的最大值:

1
2
3
4
fn main() {
let a = i8::MAX;
println!("{}",a); // 127
}

下面是一些类型转换的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// #![allow(overflowing_literals)]
fn main() {
// -1_i8补码: 0b 1111 1111
println!(" -1 as a u8 is : {}", -1_i8 as u8);

// 浮点型不能直接转char
// println!("{}", 65.4321_f32 as char)
// 只有u8可以转换为char
// println!("{}", 65.4321_f32 as u16 as char)
println!("65.4321 as a u8 and as a char is : {}", 65.4321_f32 as u8 as char);

// 整数数据长度变小时,截断高位,保留低位
// 1000_i32补码:0b 0000 0011 1110 1000
println!("1000 as a u8 is : {}", 1000_i32 as u8);

// 当转换到有符号类型时,可以看做:先截断高位(如果数据长度变小),将其看作原码,然后再转换为补码
// 128_i32补码:0b 0000 0000 0000 0000 0000 0000 1000 0000
println!(" 128 as a i16 is: {}", 128_i32 as i16);
println!(" 128 as a i8 is: {}", 128_i32 as i8);

println!(" 232 as a i8 is : {}", 232_i32 as i8);

// char可以直接转为i8
println!(" 'a' as a i8 is: {}", 'a' as i8);

// 内存地址转换为指针
let mut values: [i32; 2] = [1, 2];
let p1: *mut i32 = values.as_mut_ptr();
// 将p1内存地址转换为一个整数
let first_address = p1 as usize;
// 4 == std::mem::size_of::<i32>,i32类型占用4个字节,因此将内存地址 + 4
let second_address = first_address + 4;
// 访问该地址指向的下一个整数p2,将整数转为指针
let p2 = second_address as *mut i32;
unsafe {
*p2 += 1;
}
assert_eq!(values[1], 3);
}

TryInto转换

如果你想要在类型转换上拥有完全的控制而不依赖内置的转换,例如处理转换错误,那么可以使用TryInto

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::convert::TryInto;

fn main() {
let a: u8 = 10;
let b: u16 = 1500;

let b_: u8 = b.try_into().unwrap();

if a < b_ {
println!("Ten is less than one hundred.");
}
}
/* 运行时panic
thread 'main' panicked at 'msg: TryFromIntError(())', src\main.rs:5:30
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
*/

上面代码中引入了 std::convert::TryInto 特征,但是却没有使用它,主要原因在于如果你要使用一个特征的方法,那么需要引入该特征到当前的作用域中,不过std::prelude中已经包括了该特征,因此删掉第一行也不会出错。

try_into 会尝试进行一次转换,并返回一个 Result。此时就可以对其进行相应的错误处理(见错误处理)。这里为了快速测试,因此使用了 unwrap 方法,该方法在发现错误时,会直接调用 panic! 导致程序的崩溃退出。

try_into 转换会捕获大类型向小类型转换时导致的溢出错误,用match捕获:

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let b: i16 = 1500;

let b_: u8 = match b.try_into() {
Ok(b1) => b1,
Err(e) => {
println!("{:?}", e.to_string());
0
}
};
}

输出结果为 "out of range integral type conversion attempted",在这里程序捕获了错误,编译器告诉我们类型范围超出的转换是不被允许的,因为我们试图把 1500_i16 转换为 u8 类型,后者明显不足以承载这么大的值。

通用类型转换

下面会介绍一些更加复杂的类型转换,比如将一个结构体转换为另外一个结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Foo {
x: u32,
y: u16,
}

struct Bar {
a: u32,
b: u16,
}

fn reinterpret(foo: Foo) -> Bar {
let Foo { x, y } = foo;
Bar { a: x, b: y }
}

这是一种简单粗暴的方法。rust还为我们提供了更通用的方式来完成这个目的.

(1)强转

在一些情况下,rust支持类型自动强转(Type coercions),这些变化通常只是削弱类型,主要集中在指针和生命周期方面。它们的存在主要是为了让rust在更多的情况下正常工作,而且基本上是无害的。这些具体的规则可以参考Type coercions

as是强转的超集,任何允许自动强转的转换都可以由类型强制转换操作符as来显式执行。as强转需要符合强转规则,任何不符合强转规则的转换都会在编译时报错。

显式强转通常围绕着原始指针和原始数字类型。尽管它们很危险,但这些转换在运行时是不会出错的。如果一个强转触发了一些微妙的边界条件,也不会有任何迹象表明发生了这种情况,强转仍会成功。也就是说,强转必须在类型的级别上有效(符合规则),否则会在编译时被静态地阻止。例如,7u8 as bool编译会出错。

因此, 强转并不是unsafe的,因为它们本身通常不会违反内存安全。例如,将一个整数转换为一个原始指针很容易导致可怕的事情,然而,创建指针的行为本身是安全的,因为实际使用一个原始指针已经被标记为unsafe

强转不是递归的,也就是说,即使e as U1 as U2是一个有效的表达式,e as U2也不一定是。

在匹配特征时,不会做强制转换(除了接收器),如果某个类型U有一个impl,而T可以强转到U,这并不构成T的实现。换句话说,一个类型 T 可以强制转换为 U,不代表 impl T 可以强制转换为 impl U。例如,下面的内容不会通过类型检查,尽管将t强转到&T是可以的,并且有针对&Timpl

1
2
3
4
5
6
7
8
9
10
trait Trait {}

fn foo<X: Trait>(t: X) {}

impl<'a> Trait for &'a i32 {}

fn main() {
let t: &mut i32 = &mut 0;
foo(t);
}

报错如下:

1
2
3
4
5
6
7
8
9
10
error[E0277]: the trait bound `&mut i32: Trait` is not satisfied
--> src\main.rs:9:9
|
9 | foo(t);
| --- ^ the trait `Trait` is not implemented for `&mut i32`
| |
| required by a bound introduced by this call
|
= help: the trait `Trait` is implemented for `&'a i32`
= note: `Trait` is implemented for `&i32`, but not for `&mut i32`

&i32 实现了特征 Trait&mut i32 可以转换为 &i32,但是 &mut i32 依然无法作为 Trait 来使用。

(2)点操作符

点操作符(.)会执行许多类型转换的神奇操作。方法查找的详细机制可以参考Method lookup。简单描述为:假设我们有一个方法foo,它有一个接收器(一个self&self&mut self参数)。如果我们调用value.foo(),编译器需要确定Self是什么类型,然后才能调用该函数的正确实现。在这个例子中,我们假设 value 拥有类型 T

我们使用完全限定语法(后面会介绍,见完全限定语法)来更清楚地说明我们到底是在哪个类型上调用一个函数。

  • 首先,编译器会检查是否可以直接调用T::foo(value)。这被称为按值方法调用(by value)。
  • 如果它不能调用这个函数(例如,如果这个函数的类型不对,或者一个特征没有为Self实现),那么编译器就会尝试添加一个自动引用。这意味着编译器会尝试<&T>::foo(value)<&mut T>::foo(value)。这被称为自动引用方法调用(autoref)。
  • 如果这些候选方法都不奏效,它就对T解引用并再次尝试。这使用了Deref特性——如果T: Deref<Target = U>,那么它就用U而不是T类型再试。如果它不能解除对T的引用,它也可以尝试 unsizingT。这只是意味着,如果T在编译时有一个已知的大小参数,那么在解析方法时它就会“忘记”它。例如,这个 unsizing 步骤可以通过“忘记”数组的大小将[i32; 2]转换成[i32]

下面是一个方法查找算法的例子:

1
2
let array: Rc<Box<[T; 3]>> = ...;
let first_entry = array[0];

编译器是如何实际计算array[0]的呢?首先,array[0]实际上只是Index特性的语法糖——编译器会将array[0]转换成array.index(0)。接下来,编译器检查array是否实现了Index,这样它就可以调用这个函数。

然后,编译器检查Rc<Box<[T; 3]>>是否实现了Index,但它没有,&Rc<Box<[T; 3]>>&mut Rc<Box<[T; 3]>>也没有。由于这些方法都不起作用,编译器将Rc<Box<[T; 3]>解引用到Box<[T; 3]>中,并再次尝试。Box<[T; 3]>&Box<[T; 3]>&mut Box<[T; 3]>没有实现Index,所以它再次解引用。[T; 3]和它的自动引用也没有实现Index。它不能再继续解引用[T; 3],所以编译器“忘记”了它的大小,得到了[T]。最后,[T]实现了Index,所以它现在可以调用实际的index函数。

考虑一个更复杂的例子:

1
2
3
fn do_stuff<T: Clone>(value: &T) {
let cloned = value.clone();
}

cloned是什么类型?首先,编译器检查是否可以按值调用。value的类型是&T,所以clone函数的签名是fn clone(&T) -> T。由于在这个方法中,约束了T实现了Clone,因此 cloned 的类型是 T

如果取消T: Clone的约束,会发生什么?

1
2
3
fn do_stuff<T>(value: &T) {
let cloned = value.clone();
}

它将不能按值调用,因为T没有实现Clone。所以编译器会尝试通过自动搜索来调用。在这种情况下,该函数的签名是fn clone(&&T) -> &T,因为Self = &T。编译器发现&T实现了Clone(所有的引用类型都可以被复制,因为其实就是复制一份地址),然后推断出cloned: &T,所以,cloned&T 类型。最终,我们复制出一份引用指针,这很合理,因为值类型 T 没有实现 Clone,只能去复制一个指针了。

下面是另一个例子,自动搜索行为被用来创造一些微妙的效果:

1
2
3
4
5
6
7
#[derive(Clone)]
struct Container<T>(Arc<T>);

fn clone_containers<T>(foo: &Container<i32>, bar: &Container<T>) {
let foo_cloned = foo.clone();
let bar_cloned = bar.clone();
}

foo_clonedbar_cloned是什么类型?

复杂类型派生 Clone 的规则是:一个复杂类型能否派生 Clone,需要它内部的所有子类型都能进行 Clone。因此 Container<T>(Arc<T>) 是否实现 Clone 的关键在于 T 类型是否实现了 Clone 特征。

根据这个规则,由于i32 实现了Clone,因此Container<i32>实现了 Clone,所以编译器按值调用clone,得到foo_cloned 的类型是 Container<i32>

然而,bar_cloned实际上有&Container<T>类型。这肯定是不合理的,由于我们给Container添加了#[derive(Clone)],所以它必须实现Clone。看看由derive宏最终生成的代码(大致):

1
2
3
4
5
impl<T> Clone for Container<T> where T: Clone {
fn clone(&self) -> Self {
Self(Arc::clone(&self.0))
}
}

可以看出,派生 Clone 能实现的根本是 T 实现了Clone特征where T: Clone, 因此 Container<T> 就没有实现 Clone 特征。编译器接着查看&Container<T>是否实现了Clone,最终发现它实现了(所有的引用类型都可以被复制)。因此,它推断出clone是由 autoref 调用的,所以bar_cloned的类型是&Container<T>

我们可以通过手动实现Clone而不需要T实现Clone来解决这个问题:

1
2
3
4
5
impl<T> Clone for Container<T> {
fn clone(&self) -> Self {
Self(Arc::clone(&self.0))
}
}

现在,在尝试值方法调用时即可通过,因此 bar_cloned 的类型是 Container<T>

(3)Transmutes

接下来会介绍一种非常可怕的方法,这可能是你在rust中所能做的最可怕的不安全的事情,而这基本不设防。

mem::transmute接收一个T类型的值并将其重新解释为U类型。唯一的限制是TU被验证为占用相同大小的字节数。这所导致的未定义行为是令人难以置信的:

  • 首先,创建一个具有无效状态的任何类型的实例都会导致无法真正预测的任意混乱。即使根本不会去使用该bool 类型,也不要把3转化为bool
  • Transmute有一个重载的返回类型。如果你不指定返回类型,它可能会为了满足类型推导而返回一个令人惊讶的类型。
  • 将一个&转为&mut是未定义行为,尽管某些用法可能是安全的,但是需要注意,rust优化器可以自由地假设一个共享引用在它的生命周期内是不变的,而这种转换会违反这个假设。因此:
    • 将一个&转为&mut总是未定义行为
    • 不,你不能这样做
    • 不,你并不特别
  • Transmute到一个没有明确提供生命周期的引用会产生一个无限制的生命周期(Unbounded Lifetimes)
  • 当在不同的复合类型之间转换时,你必须确保它们的布局是一样的!如果布局不同,错误的字段就会被填入错误的数据,那么你怎么知道布局是否相同呢?对于repr(C)类型和repr(transparent)类型,布局是精确定义的。但是对于普通的repr(Rust)来说,它不是。即使是同一个通用类型的不同实例也可以有截然不同的布局。Vec<i32>Vec<u32>可能有相同的字段顺序,也可能没有。数据布局保证了什么,或者没保证什么的细节可以参考 UCG 工作组(Unsafe Code Guidelines Reference),然而到本篇文章发布之前,这部分的内容仍然处在WIP(work-in-progress)阶段。

进一步的,mem::transmute_copymem::transmute更不安全。它把size_of<U>字节从T中复制出来,并把它们解释为Umem::transmute尚可保证两个数据的内存大小一致,现在连这个限制也没有了(因为复制出一个前缀可能是有效的,也可能无效),不过UT大还是未定义的行为。

当然,你也可以使用原始指针转换或union来获得这些函数的所有功能,并关闭编译器提示Lint或其它基本的合理性检查。原始指针转换和union并不能避免上述规则。

下面列举两个有用的 transmute 应用场景:

  • 将裸指针变成函数指针:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    fn foo() -> i32 {
    0
    }

    let pointer = foo as *const ();
    let function = unsafe {
    // 将裸指针转换为函数指针
    std::mem::transmute::<*const (), fn() -> i32>(pointer)
    };
    assert_eq!(function(), 0);
  • 延长生命周期,或者缩短一个静态生命周期寿命:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    struct R<'a>(&'a i32);

    // 将 'b 生命周期延长至 'static 生命周期
    unsafe fn extend_lifetime<'b>(r: R<'b>) -> R<'static> {
    std::mem::transmute::<R<'b>, R<'static>>(r)
    }

    // 将 'static 生命周期缩短至 'c 生命周期
    unsafe fn shorten_invariant_lifetime<'b, 'c>(r: &'b mut R<'static>) -> &'b mut R<'c> {
    std::mem::transmute::<&'b mut R<'static>, &'b mut R<'c>>(r)
    }

6.2 高级类型

rust的类型系统有一些我们曾经提到但没有讨论过的功能。

newtype

newtype可以用于一些其他我们还未讨论的功能,包括静态的确保某值不被混淆,和用来表示一个值的单位。

简单来说,就是使用元组结构体的方式将已有的类型包裹起来:struct Meters(u32),那么此处 Meters 就是一个 newtype

使用newtype的好处如下:

  • 自定义类型可以让我们给出更有意义和可读性的类型名,例如与其使用 u32 作为距离的单位类型,我们可以使用 Meters,它的可读性要好得多。
  • 对于某些场景,newtype也可以用于抽象掉一些类型的实现细节:例如,封装类型可以暴露出与直接使用其内部私有类型时所不同的公有 API。
  • newtype可以隐藏其内部的泛型类型。例如,可以提供一个封装了 HashMap<i32, String>People 类型,用来储存人名以及相应的 ID。使用 People 的代码只需与提供的公有 API 交互即可,比如向 People 集合增加名字字符串的方法,这样这些代码就无需知道在内部我们将一个 i32 ID 赋予了这个名字了。newtype 模式是一种实现封装部分所讨论的隐藏实现细节的更轻量级方法。

类型别名

rust提供了声明类型别名(type alias)的能力,使用 type 关键字来给予现有类型另一个名字。例如,可以像这样创建 i32 的别名 Kilometers

1
2
3
4
// 类型别名
type Kilometers = i32;
// newtype
struct Meters(u32)

这意味着 Kilometersi32 的同义词(synonym)。与``struct Meters(u32)不同,Kilometers 不是一个新的、单独的类型。Kilometers类型的值将被完全当作i32` 类型值来对待:

1
2
3
4
5
6
type Kilometers = i32;

let x: i32 = 5;
let y: Kilometers = 5;

println!("x + y = {}", x + y);

因为 Kilometersi32 的别名,它们是同一类型,可以将 i32Kilometers 相加,也可以将 Kilometers 传递给获取 i32 参数的函数。但通过这种手段无法获得上一部分讨论的 newtype所提供的类型检查的好处。换句话说,如果在哪里混用 Kilometersi32 的值,编译器也不会给出错误。

类型别名的主要用途是减少重复。例如,可能会有这样很长的类型:

1
Box<dyn Fn() + Send + 'static>

在函数签名或类型注解中每次都书写这个类型将是枯燥且易于出错的。使用类型别名可以减少项目中重复代码的数量来使其更加易于控制。比如这段代码:

1
2
3
4
5
6
7
8
9
let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));

fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
// --snip--
}

fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
// --snip--
}

可以改写为:

1
2
3
4
5
6
7
8
9
10
11
type Thunk = Box<dyn Fn() + Send + 'static>;

let f: Thunk = Box::new(|| println!("hi"));

fn takes_long_type(f: Thunk) {
// --snip--
}

fn returns_long_type() -> Thunk {
// --snip--
}

为类型别名选择一个好名字也可以帮助你表达意图。可以看出,这与C/C++的typedef有异曲同工之处。

类型别名也经常与 Result<T, E> 结合使用来减少重复。考虑一下标准库中的 std::io 模块。I/O 操作通常会返回一个 Result<T, E>,因为这些操作可能会失败。标准库中的 std::io::Error 结构体代表了所有可能的 I/O 错误。std::io 中大部分函数会返回 Result<T, E>,其中 Estd::io::Error,比如 Write 特征中的这些函数:

1
2
3
4
5
6
7
8
9
10
use std::fmt;
use std::io::Error;

pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
fn flush(&mut self) -> Result<(), Error>;

fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}

这里出现了很多的 Result<..., Error>。为此,std::io 有这个类型别名声明:

1
type Result<T> = std::result::Result<T, std::io::Error>;

因为这位于 std::io 中,可用的完全限定的别名是 std::io::Result<T>;也就是说,Result<T, E>E 放入了 std::io::ErrorWrite 特征中的函数最终看起来像这样:

1
2
3
4
5
6
7
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;

fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

类型别名在两个方面有帮助:易于编写并在整个 std::io 中提供了一致的接口。因为这是一个别名,它只是另一个 Result<T, E>,这意味着可以在其上使用 Result<T, E> 的任何方法,以及像 ? 这样的特殊语法。

永不返回类型

发散函数!一节中,曾经介绍过 !! 用来说明一个函数永不返回任何值。在类型理论术语中,它被称为 empty type,因为它没有值。我们更倾向于称之为 never type。这个名字描述了它的作用:在函数从不返回的时候充当返回值。

1
2
3
fn bar() -> ! {
// --snip--
}

这读作“函数 bar 从不返回”,而从不返回的函数被称为发散函数(diverging functions)。不能创建 ! 类型的值,所以 bar 也不可能返回值。

不过一个不能创建值的类型有什么用呢?在模式匹配章节中,我们忽略了一个问题,那就是match 的分支必须返回相同的类型。如下代码不能工作:

1
2
3
4
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};

将其中的"hello"改成continue就可以通过编译:

1
2
3
4
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};

这里的 guess 必须既是整型也是字符串,而rust要求 guess 只能是一个类型。那么 continue 返回了什么呢?为什么continue可以通过编译,允许一个分支返回 u32 而另一个分支却以 continue 结束呢?

正如你可能猜到的,continue 的值是 !。也就是说,当rust要计算 guess 的类型时,它查看这两个分支。前者是 u32 值,而后者是 ! 值。因为 ! 并没有一个值,rust决定 guess 的类型是 u32

描述 ! 的行为的正式方式是 never type 可以强转为任何其他类型。允许 match 的分支以 continue 结束是因为 continue 并不真正返回一个值;相反它把控制权交回上层循环,所以在 Err 的情况,事实上并未对 guess 赋值。

never type 的另一个用途是 panic!,对于 Option<T> 上的 unwrap 函数,它产生一个值或 panic。这里是它的定义:

1
2
3
4
5
6
7
8
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("called `Option::unwrap()` on a `None` value"),
}
}
}

这里还是相同的情况,rust知道 valT 类型,panic!! 类型,所以整个 match 表达式的结果是 T 类型。这能工作是因为 panic! 并不产生一个值,它会终止程序。对于 None 的情况,unwrap 并不返回一个值,所以这些代码是有效的。

最后一个有着 ! 类型的表达式是 loop

1
2
3
4
5
print!("forever ");

loop {
print!("and ever ");
}

这里,循环永远也不结束,所以此表达式的值是 !。但是如果在其中引入 break 就不是这样了,因为循环在执行到 break 后就会终止。

6.3 动态大小类型和Sized 特征

动态大小类型

之前学过的几乎所有类型,都是固定大小的类型,包括集合 VecStringHashMap 等,而动态大小类型刚好与之相反,编译器无法在编译期得知该类型值的大小,只有到了运行时才才知道大小的类型。我们称这种类型为动态大小类型DST(Dynamic Sized Type)或者unsized类型。

box允许创建递归类型中,我们曾经创建了一个递归的、在编译时无法知道具体的大小的DST。现在,让我们继续认识一些动态大小类型。

(1)str

首先要深入研究的是一个贯穿本书都在使用的动态大小类型:str。没错,不是 &str,而是 str 本身。str 是一个 DST;直到运行时我们都不知道字符串有多长。因为直到运行时都不能知道其大小,也就意味着不能创建 str 类型的变量,也不能获取 str 类型的参数。考虑一下这些代码,它们不能工作:

1
2
let s1: str = "Hello there!";
let s2: str = "How's it going?";

rust需要知道应该为特定类型的值分配多少内存,同时所有同一类型的值必须使用相同数量的内存。如果允许编写这样的代码,也就意味着这两个 str 需要占用完全相同大小的空间,不过它们有着不同的长度。这也就是为什么不可能创建一个存放动态大小类型的变量的原因。

那么该怎么办呢?你已经知道了这种问题的答案:s1s2 的类型是 &str 而不是 str。切片数据结构仅仅储存了开始位置和切片的长度以及容量。所以虽然 &T 是一个储存了 T 所在的内存位置的单个值,&str 则是两个值:str 的地址和其长度。这样,&str 就有了一个在编译时可以知道的大小:它是 usize 长度的两倍(地址+长度占用的空间)。也就是说,我们总是知道 &str 的大小,而无论其引用的字符串是多长。这里是rust中动态大小类型的常规用法:它们有一些额外的元信息来储存动态信息的大小。这引出了动态大小类型的黄金规则:必须将动态大小类型的值置于某种指针之后。

可以将 str 与所有类型的指针结合:比如 Box<str>Rc<str>

(2)特征对象

事实上,我们在之前已经见过另一种与指针结合的DST了,那就是特征。每一个特征都是一个可以通过特征名称来引用的动态大小类型。在特征对象章节,我们提到了为了将特征用于特征对象,必须将它们放入指针之后,比如 &dyn TraitBox<dyn Trait>Rc<dyn Trait> 也可以)。

(3)数组切片

我们在前面说过需要使用切片的引用,对于数组来说,它也是DST。

1
2
3
fn main() {
let arr:[i32] = [1, 2, 3, 4, 5];
}

这里的[i32]就是数组切片,与str一样,这段代码无法通过编译,因为无法在编译期知道 [i32] 类型的大小,只有到了运行期才能动态获知,这对于强类型、强安全的rust语言来说是不可接受的。因此,我们要使用切片的引用来代替切片:

1
2
3
4
5
fn main() {
let arr = [1, 2, 3, 4, 5];

let s3: &[i32] = &arr[1..3];
}

这段代码就可以正常通过,原因在于这些切片引用的大小在编译器都是已知的。

Sized 特征

为了处理 DST,rust提供了 Sized 特征来决定一个类型的大小是否在编译时可知。这个特征自动为编译器添加在编译时就知道大小的类型实现。另外,rust隐式的为每一个泛型函数增加了 Sized 特征约束。也就是说,对于如下泛型函数定义:

1
2
3
fn generic<T>(t: T) {
// --snip--
}

实际上被当作如下处理:

1
2
3
fn generic<T: Sized>(t: T) {
// --snip--
}

这样,对于一个泛型参数 T,rust保证 T 是固定大小的类型。

几乎所有类型都实现了 Sized 特征,除了 str和特征。每一个特征都是一个可以通过名称来引用的动态大小类型。因此如果想把特征作为具体的类型来传递给函数,你必须将其转换成一个特征对象:诸如 &dyn Trait 或者 Box<dyn Trait> (还有 Rc<dyn Trait>)这些引用类型。

泛型函数默认只能用于在编译时已知大小的类型。然而可以使用如下特殊语法来放宽这个限制:

1
2
3
fn generic<T: ?Sized>(t: &T) {
// --snip--
}

?Sized 特征约束意味着 T 可能是也可能不是 Sized。同时这个注解会覆盖泛型类型必须在编译时拥有固定大小的默认规则。这种意义的 ?Trait 语法只能用于 Sized ,而不能用于任何其它特征。还有一点要注意的是,函数参数类型从 T 变成了 &T,因为 T 可能是动态大小的,因此需要用一个固定大小的指针(引用)来包裹它。

6.4 枚举和整数

==todo==

7 高级特征

7.1 关联类型

关联类型(associated types)是一个将类型占位符与特征相关联的方式,这样特征的方法签名中就可以使用这些占位符类型。特征的实现者会针对特定的实现在这个占位符类型指定相应的具体类型。如此可以定义一个使用多种类型的特征,直到实现此特征时都无需知道这些类型具体是什么。

一个带有关联类型的特征的例子是标准库提供的 Iterator 特征,我们曾经在Iterator特征和next方法这一章见过它:

1
2
3
4
5
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// ...
}

Item 是一个占位符类型,同时 next 方法定义表明它返回 Option<Self::Item> 类型的值。这个特征的实现者会指定 Item 的具体类型,然而不管实现者指定何种类型,next 方法都会返回一个包含了此具体类型值的 Option

关联类型看起来像一个类似泛型的概念,因为它允许定义一个函数而不指定其可以处理的类型。让我们通过在一个 Counter 结构体上实现 Iterator 特征的例子来检视其中的区别。Self 用来指代当前调用者的具体类型,那么 Self::Item 就用来指代该类型实现中定义的 Item 类型。比如下面这个实现中指定了 Item 的类型为 u32

1
2
3
4
5
impl Iterator for Counter {
type Item = u32;

fn next(&mut self) -> Option<Self::Item> {
// --snip--

在上述代码中,我们为 Counter 类型实现了 Iterator 特征,对于 next 方法而言,Self 是调用者的具体类型: Counter,而 Self::ItemCounter 中定义的 Item 类型: u32

这个语法类似于泛型。那么为什么不用这种方式定义呢:

1
2
3
pub trait Iterator<T> {
fn next(&mut self) -> Option<T>;
}

区别在于使用泛型时,则不得不在每一个实现中标注类型。这是因为我们也可以实现为 Iterator<String> for Counter,或任何其他类型,这样就可以有多个 CounterIterator 的实现。换句话说,当特征有泛型参数时,可以多次实现这个特征,每次需改变泛型参数的具体类型。接着当使用 Counternext 方法时,必须提供类型注解来表明希望使用 Iterator 的哪一个实现。

通过关联类型,则无需标注类型,因为不能多次实现这个特征。对于使用关联类型的定义,我们只能选择一次 Item 会是什么类型,因为只能有一个 impl Iterator for Counter。当调用 Counternext 时不必每次指定我们需要 u32 值的迭代器。当类型定义复杂时,这种写法可以极大地增加可读性。

关联类型也会成为特征契约的一部分:特征的实现必须提供一个类型来替代关联类型占位符。关联类型通常有一个描述类型用途的名字,并且在 API 文档中为关联类型编写文档是一个最佳实践。

7.2 默认泛型类型参数和运算符重载

当使用泛型类型参数时,可以为泛型指定一个默认的具体类型。为泛型类型指定默认类型的语法是在声明泛型类型时使用<PlaceholderType=ConcreteType>

这种情况的一个非常好的例子是使用运算符重载(Operator overloading),这是指在特定情况下自定义运算符(比如 +)行为的操作。

rust并不允许创建自定义运算符或重载任意运算符,不过 std::ops 中所列出的运算符和相应的特征可以通过实现运算符相关特征来重载。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use std::ops::Add;

#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
x: i32,
y: i32,
}

impl Add for Point {
type Output = Point;

fn add(self, other: Point) -> Point {
Point {
x: self.x + other.x,
y: self.y + other.y,
}
}
}

fn main() {
assert_eq!(
Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
Point { x: 3, y: 3 }
);
}

这里展示了如何在 Point 结构体上实现 Add 特征来重载 + 运算符,这样就可以将两个 Point 实例相加了。add 方法将两个 Point 实例的 x 值和 y 值分别相加来创建一个新的 PointAdd 特征有一个叫做 Output 的关联类型,它用来决定 add 方法的返回值类型。

这里默认泛型类型位于 Add 特征中。其定义如下:

1
2
3
4
5
trait Add<Rhs=Self> {
type Output;

fn add(self, rhs: Rhs) -> Self::Output;
}

这些代码看来应该很熟悉,这是一个带有一个方法和一个关联类型的 trait。比较陌生的部分是尖括号中的 Rhs=Self:这个语法叫做默认类型参数(default type parameters)。Rhs (“right hand side” 的缩写)是一个泛型类型参数(见特征泛型),它用于定义 add 方法中的 Rhs 参数。如果实现 Add 特征时不指定 Rhs 的具体类型,Rhs 的类型将是默认的 Self 类型,也就是在其上实现 Add 的类型。

当为 Point 实现 Add 时,使用了默认的 Rhs,因为我们希望将两个 Point 实例相加。让我们看看一个实现 Add特征时希望自定义 Rhs 类型而不是使用默认类型的例子:

1
2
3
4
5
6
7
8
9
10
11
12
use std::ops::Add;

struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
type Output = Millimeters;

fn add(self, other: Meters) -> Millimeters {
Millimeters(self.0 + (other.0 * 1000))
}
}

这里有两个存放不同单元值的结构体,MillimetersMeters,它们使用了newtype。我们希望能够将毫米值与米值相加,并让 Add 的实现正确处理转换。可以为 Millimeters 实现 Add 并以 Meters 作为 Rhs。为了使 MillimetersMeters 能够相加,我们指定 impl Add<Meters> 来设定 Rhs 类型参数的值而不是使用默认的 Self

默认参数类型主要用于如下两个方面:

  • 扩展类型而不破坏现有代码。
  • 在大部分用户都不需要的特定情况进行自定义。

标准库的 Add特征就是一个第二个目的例子:大部分时候你会将两个相似的类型相加,不过它提供了自定义额外行为的能力。在 Add 特征定义中使用默认类型参数意味着大部分时候无需指定额外的参数。换句话说,一小部分实现的样板代码是不必要的,这样使用特征就更容易了。

第一个目的是相似的,但过程是反过来的:如果需要为现有特征增加类型参数,为其提供一个默认类型将允许我们在不破坏现有实现代码的基础上扩展特征的功能。

7.3 完全限定语法

rust既不能避免一个特征与另一个特征拥有相同名称的方法,也不能阻止为同一类型同时实现这两个特征。甚至直接在类型上实现开始已经有的同名方法也是可能的。

不过,当调用这些同名方法时,需要告诉rust我们希望使用哪一个。考虑如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
trait Pilot {
fn fly(&self);
}

trait Wizard {
fn fly(&self);
}

struct Human;

impl Pilot for Human {
fn fly(&self) {
println!("This is your captain speaking.");
}
}

impl Wizard for Human {
fn fly(&self) {
println!("Up!");
}
}

impl Human {
fn fly(&self) {
println!("*waving arms furiously*");
}
}

fn main() {
let h = Human;
h.fly();
}

这里定义了PilotWizard 都拥有方法 fly。接着在一个本身已经实现了名为 fly 方法的类型 Human 上实现这两个特征。每一个 fly 方法都进行了不同的操作。当调用 Human 实例的 fly 时,编译器默认调用直接实现在类型上的方法,因此输出:*waving arms furiously*。 这证明了rust调用了直接实现在 Human 上的 fly 方法。

为了能够调用 Pilot 特征或 Wizard 特征的 fly 方法,我们需要使用更明显的语法以便能指定我们指的是哪个 fly 方法:

1
2
3
4
5
6
fn main() {
let person = Human;
Pilot::fly(&person); // This is your captain speaking.
Wizard::fly(&person); // Up!
person.fly(); // *waving arms furiously*
}

在方法名前指定特征名告诉rust我们希望调用哪个 fly 实现。也可以选择写成 Human::fly(&person)这等同于默认的person.fly(),不过如果无需消歧义的话这么写就有点长了。

因为 fly 方法获取一个 self 参数,如果有两个类型都实现了同一特征,rust可以根据 self 的类型计算出应该使用哪一个特征实现。

然而,不是方法的关联函数没有 self 参数。当存在多个类型或者特征定义了相同函数名的非方法函数时,rust就不总是能计算出我们期望的是哪一个类型,比如这里创建了一个希望将所有小狗叫做Spot的动物收容所的特征。Animal特征有一个关联非方法函数 baby_name。结构体 Dog 实现了 Animal,同时又直接提供了关联非方法函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
trait Animal {
fn baby_name() -> String;
}

struct Dog;

impl Dog {
fn baby_name() -> String {
String::from("Spot")
}
}

impl Animal for Dog {
fn baby_name() -> String {
String::from("puppy")
}
}

fn main() {
println!("A baby dog is called a {}", Dog::baby_name());
}

Dog 上定义的关联函数 baby_name 的实现代码将所有的小狗起名为 SpotDog 类型还实现了 Animal 特征,它描述了所有动物的共有的特征。小狗被称为 puppy,这表现为 DogAnimal特征实现中与Animal特征相关联的函数 baby_name

main 调用了 Dog::baby_name 函数,它直接调用了定义于 Dog 之上的关联函数。这段代码会打印出:A baby dog is called a Spot。这并不是我们需要的。我们希望调用的是 DogAnimal 特征实现那部分的 baby_name 函数,这样能够打印出 A baby dog is called a puppy。不过,这次我们不能使用同样的套路了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn main() {
println!("A baby dog is called a {}", Animal::baby_name());
}
/*
error[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
--> src\main.rs:20:43
|
2 | fn baby_name() -> String;
| ------------------------- `Animal::baby_name` defined here
...
20 | println!("A baby dog is called a {}", Animal::baby_name());
| ^^^^^^^^^^^^^^^^^ cannot call associated function of trait
|
help: use the fully-qualified path to the only available implementation
|
20 | println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
| +++++++ +
*/

因为 Animal::baby_name 没有 self 参数,同时这可能会有其它类型实现了 Animal 特征,rust无法计算出所需的是哪一个 Animal::baby_name 实现。在报错提示中,给出了解决方案。为了消歧义并告诉rust我们希望使用的是 DogAnimal 实现而不是其它类型的 Animal 实现,需要使用完全限定语法,这是调用函数时最为明确的方式。

1
2
3
fn main() {
println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}

我们在尖括号中向rust提供了类型注解,并通过在此函数调用中将 Dog 类型当作 Animal 对待,来指定希望调用的是 DogAnimal 特征实现中的 baby_name 函数。现在这段代码会打印出我们期望的数据:A baby dog is called a puppy

通常,完全限定语法定义为:

1
<Type as Trait>::function(receiver_if_method, next_arg, ...);

上面定义中,第一个参数是方法接收器 receiver (三种 self),只有方法才拥有,例如关联函数就没有 receiver

对于不是方法的关联函数,其没有 receiver,故只会有其它参数的列表(next_arg, ...)。可以选择在任何函数或方法调用处使用完全限定语法。然而,允许省略任何rust能够从程序中的其他信息中计算出的部分。

因此大多数时候,我们都无需使用完全限定语法。只有当存在多个同名实现而rust需要帮助以便知道我们希望调用哪个实现时,才需要使用这个较为冗长的语法。

7.4 父特征用于在另一个特征中使用某特征的功能

我们可以定义依赖于某个特征的特征,被依赖的特征被称为父特征,新定义的特征可以使用父特征中定义的功能。这部分的内容在深入理解Fn特征中曾经介绍过,故不再赘述。

7.5 在外部类型上实现外部特征

在介绍特征时提到过孤儿规则 Orphan Rule,它说明只要特征或类型对于当前crate是本地的话就可以在此类型上实现该 特征。一个绕开这个限制的方法是使用之前介绍过的newtype,它涉及到在一个元组结构体中创建一个新类型。这个元组结构体带有一个字段作为希望实现特征的类型的简单封装。接着这个封装类型对于crate是本地的,这样就可以在这个封装上实现特征。Newtype是一个源自 Haskell 编程语言的概念。使用这个模式没有运行时性能惩罚,这个封装类型在编译时就被省略了。

例如,如果想要在 Vec<T> 上实现 Display,而孤儿规则阻止我们直接这么做,因为 Display 特征和 Vec<T> 都定义于我们的crate之外。可以创建一个包含 Vec<T> 实例的 Wrapper 结构体,接着可在 Wrapper 上实现 Display 并使用 Vec<T> 的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
use std::fmt;
struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}

fn main() {
let w = Wrapper(vec![String::from("hello"), String::from("world")]);
println!("w = {}", w);
}

Display 的实现使用 self.0 来访问其内部的 Vec<T>,因为 Wrapper 是元组结构体而 Vec<T> 是结构体总位于索引0的项。接着就可以使用 WrapperDisplay 的功能了。

此方法的缺点是,任何数组上的方法,你都无法直接调用,需要先用 self.0 取出数组,然后再进行调用。因为 Wrapper 是一个新类型,它没有定义于其值之上的方法;必须直接在 Wrapper 上实现 Vec<T> 的所有方法,这样就可以代理到self.0 上 —— 这就允许我们完全像 Vec<T> 那样对待 Wrapper

如果希望新类型拥有其内部类型的每一个方法,为封装类型实现 Deref 特征(在之前实现 Deref 特征中介绍过)并返回其内部类型是一种解决方案,实现该特征后,可以自动做一层类似类型转换的操作,可以将 Wrapper 变成 Vec<String> 来使用。这样就会像直接使用数组那样去使用 Wrapper,而无需为每一个操作都添加上 self.0。如果不希望封装类型拥有所有内部类型的方法(比如为了限制封装类型的行为),则必须只自行实现(重载)所需的方法。

8 生命周期高级篇

rust通过生命周期来执行相关的规则。生命周期是指一个引用必须有效的代码区域,这些区域可能相当复杂,因为它们对应着程序中的执行路径。这些执行路径中甚至可能存在空洞(空洞是指一个引用的生命周期可能不是一个连续的代码区域,中间可能有跳跃),因为我们可能会先使一个引用失效,之后再重新初始化并使用它。包含引用(或假装包含)的类型也可以用生命周期来标记,这样rust就可以防止它们也被失效。

在之前的大多数例子中,生命周期将与作用域重合,这是因为我们的例子很简单。下面将介绍它们不重合的更复杂的情况。

8.1 引用和别名

我们知道,引用有两种类型:

  • 共享的引用(不可变引用):&
  • 可变引用:&mut

它们其实遵守以下规则:

  • 一个引用的生命周期不能超过它所引用对象的生命周期
  • 一个可变的引用不能有别名

在继续讨论之前,需要先介绍一下别名是什么。不幸的是,rust还没有真正定义其别名模型,在我们等待rust的设计者明确他们语言的语义时,先来讨论下在一般场景下别名到底是什么,以及它为什么重要。

注意,为了便于讨论,我们规定:

  • 我们将使用最广泛的别名定义。rust的定义可能会有更多限制,以考虑到可变性和有效性。
  • 我们将假设一个单线程的、无中断的执行,我们还将忽略像内存映射硬件这样的东西。rust假定这些事情不会发生,除非你明确告诉它会发生。

所以,我们现行的定义是:如果变量和指针指向内存的重叠区域,那么它们就是别名。

为什么我们需要关注别名呢?让我们看下这个例子:

1
2
3
4
5
6
7
8
9
fn compute(input: &u32, output: &mut u32) {
if *input > 10 {
*output = 1;
}
if *input > 5 {
*output *= 2;
}
// 记住一点: 如果 `input>10`,那么 `output` 永远为 `2`
}

我们希望能够把它优化成下面这样的函数:

1
2
3
4
5
6
7
8
9
10
11
12
fn compute(input: &u32, output: &mut u32) {
let cached_input = *input; // 将 `*input` 中的内容保存在寄存器中
if cached_input > 10 {
// 如果输入比 10 大, 优化之前的代码会将 output 设置为 1,然后乘以 2,
// 结果一定返回 `2` (因为 `>10` 包括了 `>5` 的情况),
// 因此这里可以进行优化,
// 不对 output 重复赋值,直接将其设置为 2
*output = 2;
} else if cached_input > 5 {
*output *= 2;
}
}

在rust中,这种优化应该是可行的。但对于几乎任何其它语言来说,它都不是这样的(除非是全局分析)。这是因为这个优化依赖于知道别名不会发生,而大多数语言在这方面是相当宽松的。具体来说,我们需要担心那些使“输入”和“输出”重叠的函数参数,如compute(&x, &mut x)

如果按照这样的输入,我们实际上执行的代码如下:

1
2
3
4
5
6
7
8
9
                    //  input ==  output == 0xabad1dea
// *input == *output == 20
if *input > 10 { // true (*input == 20)
*output = 1; // 同时覆盖了 input 引用的内容,因为它们实际上引用了同一块内存
}
if *input > 5 { // false (*input == 1)
*output *= 2;
}
// *input == *output == 1

我们的优化函数对于这个输入会产生*output == 2,所以在这种情况下,我们的优化就无法实现了。

不过,在rust中,我们知道这个输入是不可能的,因为&mut不允许被别名。所以我们可以安全地认为这种情况不会发生,并执行这个优化。在大多数其它语言中,这种输入是完全可能的,因此必须加以考虑。

这就是为什么别名分析很重要的原因:它可以让编译器进行有用的优化。比如:

  • 通过证明没有指针访问该值的内存来保持寄存器中的值
  • 通过证明某些内存在我们上次读取后没有被写入,来消除读取
  • 通过证明某些内存在下一次写入之前从未被读过,来消除写入
  • 通过证明读和写之间不相互依赖来对指令进行移动或重排序

这些优化也用于证明更大的优化的合理性,如循环矢量化、常数传播和死代码消除。

在前面的例子中,我们利用&mut u32不能被别名的事实来证明对*output的写入不可能影响*input。这让我们把*input缓存在一个寄存器中,省去了读的过程。

通过缓存这个读,我们知道在> 10分支中的写不能影响我们是否采取> 5分支,使我们在*input > 10时也能消除一个读-修改-写(加倍*output)。

关于别名分析,需要记住的关键一点是,写是优化的主要危险。也就是说,阻止我们将读移到程序的任何其它部分的唯一原因是我们有可能将其与写到同一位置重新排序。

例如,在下面这个修改后的函数中,我们不需要担心别名问题,因为我们已经将唯一一个写到*output的地方移到了函数的最后。这使得我们可以自由地重新排序在它之前发生的对*input的读取:

1
2
3
4
5
6
7
8
9
10
fn compute(input: &u32, output: &mut u32) {
let mut temp = *output;
if *input > 10 {
temp = 1;
}
if *input > 5 {
temp *= 2;
}
*output = temp;
}

我们仍然依靠别名分析来假设temp没有别名input,但是证明要简单得多:局部变量的值不能被在它被声明之前就存在的东西所别名。这是每一种语言都可以自由做出的假设,因此这个版本的函数可以在任何语言中按照我们想要的方式进行优化。

这就是为什么rust将使用的“别名”的定义可能涉及到一些有效性和可变性的概念:如果没有任何实际写入内存的情况发生,我们实际上并不关心别名是否发生。

当然,rust的完整别名模型还必须考虑到函数调用(可能会改变我们看不到的东西)、裸指针(它本身没有别名要求)和 UnsafeCell(它让&的引用被改变)等东西。

我们后面会通过一些例子去继续介绍这个规则,在开始之前,还需要先介绍如何手动标注分析生命周期。

8.2 手动标注分析生命周期

在一个函数体中,rust通常不需要你明确地命名所涉及的生命周期。这是因为一般来说,在本地环境中谈论生命周期是没有必要的;rust拥有所有的信息,并且可以尽可能地以最佳方式解决所有问题。rust还会引入许多匿名作用域和临时变量, 你不必显式写出它们,代码也可以跑通。

然而,一旦你跨越了函数的边界,你就需要开始考虑生命周期了。为了尝试理解生命周期,我们将假装我们被允许用生命周期来标记作用域。一个有趣的语法糖是,每个let语句都隐含地引入了一个作用域。在大多数情况下,这其实并不重要。然而,这对那些相互引用的变量来说确实很重要。作为一个简单的例子,让我们对这段简单的rust代码进行完全解糖:

1
2
3
4
5
fn main() {
let x = 0;
let y = &x;
let z = &y;
}

借用检查器总是试图最小化生命周期的范围,所以它很可能会脱糖为以下内容:

1
2
3
4
5
6
7
8
9
10
11
'a: {
let x: i32 = 0;
'b: {
// y 的生命周期为 'b
let y: &'b i32 = &'b x;
'c: {
// 'c 同上所示
let z: &'c &'b i32 = &'c y; // z是i32的引用的引用
}
}
}

'a: {&'b x 不是有效的语法,这里只是为了说明生命周期的概念。

实际上,传递一个引用到外部作用域将导致rust推断出一个更大的生命周期。

1
2
3
4
let x = 0;
let z;
let y = &x;
z = y;

标注生命周期为:

1
2
3
4
5
6
7
8
9
10
11
'a: {
let x: i32 = 0;
'b: {
let z: &'b i32;
'c: {
// y 的生命周期一定为 'b,因为对 x 的引用被传递到了 'b 这个作用域
let y: &'b i32 = &'b x;
z = y;
}
}
}

这里由于将x的引用传递到了zz=y),因此rust推断出y的生命周期为'b,也就是说y要至少比z活的更久,这样才能保证z不失效。

例1:悬垂引用的分析

让我们看一个悬垂引用的例子:

1
2
3
4
fn as_str(data: &u32) -> &str {
let s = format!("{}", data);
&s
}

我们通过解语法糖来分析这段代码:

1
2
3
4
5
6
fn as_str<'a>(data: &'a u32) -> &'a str {
'b: {
let s = format!("{}", data);
return &'a s;
}
}

as_str的这个签名接收了一个具有某个生命周期的u32的引用,并返回一个可以存活同样长的 str 的引用。我们已经大致能猜到为什么这个函数签名可能是个麻烦了,这意味着我们要返回的那个 str要在u32的引用所处的作用域上,或者甚至在更大的作用域上。这要求有点高。

然后我们继续计算字符串s,并返回它的一个引用。由于我们的函数的签名规定这个引用必须超过'a,这就是我们推断出的引用的生命周期。不幸的是,s被定义在作用域'b中,所以唯一合理的方法是'b包含'a,这显然是错误的,因为'a必须包含函数调用本身。因此,我们创建的引用&s,它的生命周期没有超过'a,编译器理所当然地直接报错。

为了更清楚地说明这一点,我们可以扩展这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn as_str<'a>(data: &'a u32) -> &'a str {
'b: {
let s = format!("{}", data);
return &'a s
}
}

fn main() {
'c: {
let x: u32 = 0;
'd: {
// 这里引入了一个匿名作用域,因为借用不需要在整个 x 的作用域内生效,
// 这个函数必须返回一个在函数调用之前就存在的某个字符串的引用,事实显然不是这样
println!("{}", as_str::<'d>(&'d x));
}
}
}

当然,这个函数的正确写法是这样的:

1
2
3
fn to_string(data: &u32) -> String {
format!("{}", data)
}

我们必须在函数里面产生一个拥有所有权的值才能返回。唯一可以返回一个&'a str的方法是,它在&'a u32的一个字段中,但显然不是这样的。

例2:别名一个可变引用

来看另一个例子:

1
2
3
4
let mut data = vec![1, 2, 3];
let x = &data[0];
data.push(4);
println!("{}", x);

脱糖后:

1
2
3
4
5
6
7
8
9
10
11
12
'a: {
let mut data: Vec<i32> = vec![1, 2, 3];
'b: {
// 'b 这个生命周期范围如我们所愿地小(刚好够 println!)
let x: &'b i32 = Index::index::<'b>(&'b data, 0);
'c: {
// 这里有一个临时作用域,我们不需要更长时间的 &mut 借用
Vec::push(&'c mut data, 4);
}
println!("{}", x);
}
}

在前面点操作符中我们提到过:编译器会将中括号索引data[0]转换成data.index(0)。这里的问题更微妙、更有趣。我们希望rust拒绝编译这个程序,理由如下:有一个存活的共享引用xdata的一个子集,当我们试图把data的可变引用传给push时。这将创建一个可变引用的别名,而这将违反引用的第二条规则(一个可变的引用不能有别名)。

然而,这根本不是rust认为这个程序有问题的原因。rust不理解x是对data的一个子集的引用。它根本就不理解Vec。它看到的是,x必须在'b范围内保持存活才能被打印;接下来,Index::index的签名要求我们对data的引用必须在'b范围内存活。当我们试图调用push时,它看到我们试图构造一个&'c mut data。rust知道'c包含在'b中,并拒绝了我们的程序,因为&'b data必然还存活着(一个可变引用和不可变引用不能同时存在)。

在这里我们看到,和我们真正想要保证的引用规则语义相比,生命周期系统要粗略得多。本来根据我们所讨论的引用的第二条规则就应该报错了,但是实际报错的规则却是“一个可变引用和不可变引用不能同时存在”。

在大多数情况下,这完全没问题,因为它使我们不用花整天的时间向编译器解释我们的程序。然而,这确实意味着有部分程序对于rust的真正的语义来说是完全正确的,但却被拒绝了,因为生命周期系统太傻了。

通过这两个例子,对引用的脱糖使得我们可以对生命周期有更深入的理解。

8.3 生命周期所覆盖的区域

一个引用(borrow)从它被创建到最后一次使用都是存活的。根据引用和别名介绍的第一条引用规则,被引用的值的生命周期只需要超过引用的生命周期就行。这看起来很简单,但有一些微妙之处。

下面的代码可以成功编译,因为在打印完x之后,它就不再需要了,所以它是悬空的还是别名的都无所谓(尽管变量x技术上一直存活到作用域的最后):

1
2
3
4
5
let mut data = vec![1, 2, 3];
let x = &data[0];
println!("{}", x);
// 这是可行的,因为不再使用 x,编译器也就缩短了 x 的生命周期
data.push(4);

然而,如果该值有一个析构器,析构器就会在作用域的末端运行。而运行析构器被认为是一种使用——显然是最后一次使用。所以,这将会编译报错:

1
2
3
4
5
6
7
8
9
10
11
12
13
#[derive(Debug)]
// newtype
struct X<'a>(&'a i32);

impl Drop for X<'_> {
fn drop(&mut self) {}
}

let mut data = vec![1, 2, 3];
let x = X(&data[0]);
println!("{:?}", x);
data.push(4);
// 编译器会在这里自动插入 drop 函数,也就意味着我们会访问 x 中引用的变量,因此编译失败

让编译器相信x不再有效的一个方法是在data.push(4)之前使用drop(x),这样就会缩短x的生命周期。

此外,可能会有多种最后一次的引用使用,例如在一个条件的每个分支中:

1
2
3
4
5
6
7
8
9
10
11
let mut data = vec![1, 2, 3];
let x = &data[0];

if some_condition() {
println!("{}", x); // 这是该分支中最后一次使用 x 这个引用
data.push(4); // 因此在这里 push 操作是可行的
} else {
// 这里不存在对 x 的使用,对于这个分支来说,
// x 创建即销毁
data.push(5);
}

生命周期中可以有暂停,或者你可以把它看成是两个不同的借用,只是被绑在同一个局部变量上。这种情况经常发生在循环周围(在循环结束时写入一个变量的新值,并在下一次迭代的顶部最后一次使用它)。

1
2
3
4
5
6
7
8
let mut data = vec![1, 2, 3];
// x 是可变的(通过 mut 声明),因此我们可以修改 x 指向的内容
let mut x = &data[0];

println!("{}", x); // 最后一次使用这个引用
data.push(4);
x = &data[3]; // x 在这里借用了新的变量
println!("{}", x);

rust在旧版本上一直保持着借用的生命周期,直到作用域结束,所以这些例子在旧的编译器中可能无法编译。此外,还有一些边界条件,rust不能正确地缩短借用的有效部分,即使看起来应该这样做,也不能编译。这些问题将随着时间的推移,编译器的完善而得到解决。

8.4 生命周期的局限

我们先来看看一些本以为可以编译,但是却因为生命周期系统不够聪明导致编译失败的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#[derive(Debug)]
struct Foo;

impl Foo {
fn mutate_and_share(&mut self) -> &Self {
&*self
}
fn share(&self) {}
}

fn main() {
let mut foo = Foo;
let loan = foo.mutate_and_share();
foo.share();
println!("{:?}", loan);
}
/*
error[E0502]: cannot borrow `foo` as immutable because it is also borrowed as mutable
--> src\main.rs:14:5
|
13 | let loan = foo.mutate_and_share();
| ---------------------- mutable borrow occurs here
14 | foo.share();
| ^^^^^^^^^^^ immutable borrow occurs here
15 | println!("{:?}", loan);
| ---- mutable borrow later used here
*/

上面的代码中,foo.mutate_and_share() 虽然借用了 &mut self,但是它最终返回的是一个 &self,然后赋值给 loan,因此理论上来说它最终是进行了不可变借用,同时 foo.share 也进行了不可变借用,那么根据rust的借用规则:多个不可变借用可以同时存在,因此该代码应该编译通过。但实际上却无法正常编译。

从报错中可以看出,在foo.mutate_and_share方法结束时进行了可变借用,这里有些难以理解,因为可变借用仅在 mutate_and_share 方法内部有效,出了该方法后,就只有返回的不可变借用,因此,按理来说可变借用不应该在 main 的作用范围内存在。

让我们从生命周期的角度分析这个过程,将这段代码脱糖::

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct Foo;

impl Foo {
fn mutate_and_share<'a>(&'a mut self) -> &'a Self {
&'a *self
}
fn share<'a>(&'a self) {}
}

fn main() {
'b: {
let mut foo: Foo = Foo;
'c: {
let loan: &'c Foo = Foo::mutate_and_share::<'c>(&'c mut foo);
'd: {
Foo::share::<'d>(&'d foo);
}
println!("{:?}", loan);
}
}
}

以上是模拟了编译器的生命周期标注后的代码,可以注意到 &mut fooloan 的生命周期都是 'c

根据生命周期消除(Lifetime Elision)规则,其中第三条规则是:如果方法有多个输入生命周期参数并且其中一个参数是 &self&mut self,说明是个对象的方法,那么所有输出生命周期参数被赋予 self 的生命周期。因为该规则,导致了 mutate_and_share 方法中,参数 &mut self 和返回值 &self 的生命周期是相同的,因此,若返回值的生命周期在 main 函数有效,那 &mut self 的借用也在 main 函数有效。换句话说,生命周期系统被迫将&mut foo扩展为'c的生命周期。

这就解释了可变借用为什么会在 main 函数作用域内有效。然后当我们试图调用share时,它看到我们试图别名&'c mut foo,不过,真正令程序失败的原因是生命周期系统发现它违背了可变借用与不可变借用不能同时存在的规则。

上述代码实际上完全是正确的,但由于生命周期系统不够聪明,&mut self 借用的生命周期和 loan 的生命周期相同,将持续到 println 结束。而在此期间 foo.share() 又进行了一次不可变 &foo 借用,最终导致了编译错误。

再来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use std::hash::Hash;
fn get_default<'m, K, V>(map: &'m mut HashMap<K, V>, key: K) -> &'m mut V
where
K: Clone + Eq + Hash,
V: Default,
{
match map.get_mut(&key) {
Some(value) => value,
None => {
map.insert(key.clone(), V::default());
map.get_mut(&key).unwrap()
}
}
}
}
/*
error[E0499]: cannot borrow `*map` as mutable more than once at a time
--> src\main.rs:13:17
|
5 | fn get_default<'m, K, V>(map: &'m mut HashMap<K, V>, key: K) -> &'m mut V
| -- lifetime `'m` defined here
...
10 | match map.get_mut(&key) {
| - ----------------- first mutable borrow occurs here
| _________|
| |
11 | | Some(value) => value,
12 | | None => {
13 | | map.insert(key.clone(), V::default());
| | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ second mutable borrow occurs here
14 | | map.get_mut(&key).unwrap()
15 | | }
16 | | }
| |_________- returning this value requires that `*map` is borrowed for `'m`

error[E0499]: cannot borrow `*map` as mutable more than once at a time
--> src\main.rs:14:17
|
5 | fn get_default<'m, K, V>(map: &'m mut HashMap<K, V>, key: K) -> &'m mut V
| -- lifetime `'m` defined here
...
10 | match map.get_mut(&key) {
| - ----------------- first mutable borrow occurs here
| _________|
| |
11 | | Some(value) => value,
12 | | None => {
13 | | map.insert(key.clone(), V::default());
14 | | map.get_mut(&key).unwrap()
| | ^^^^^^^^^^^^^^^^^ second mutable borrow occurs here
15 | | }
16 | | }
| |_________- returning this value requires that `*map` is borrowed for `'m`
*/

这段代码不能通过编译的原因是编译器未能精确地判断出某个可变借用不再需要,反而谨慎的给该借用安排了一个很大的作用域。分析代码可知在 match map.get_mut(&key) 方法调用完成后,对 map 的可变借用就可以结束了。但从报错看来,编译器不太聪明,它认为该借用会持续到整个 match 语句块的结束(第 16 行处),这便造成了后续借用的失败。

对于这种生命周期系统不够聪明导致的编译错误,目前没有太好的办法,只能修改代码去满足它的需求,不过这些问题最终会随着编译器的完善而得到解决。

8.5 无界(不受约束)的生命周期

不安全的代码经常会凭空产生引用或生命周期,这种生命周期是以无约束的形式出现在世界中的。最常见的原因是对原始指针的解引用,这产生了一个具有无约束生命周期的引用。这样的生命周期会随着上下文的要求而变大。这实际上比简单地标记为'static更强大,因为例如&'static &'a T将无法通过类型检查,但无约束的生命周期将根据需要完美地塑造为&'a &'a T。然而,对于大多数意图和目的来说,这样的无约束生命周期可以被看作是'static

给定一个函数,任何不来自输入的输出生命周期都是无约束的,比如说:

1
2
3
4
5
fn f<'a, T>(x: *const T) -> &'a T {
unsafe {
&*x
}
}

上述代码中,参数 x 是一个裸指针,它并没有任何生命周期,然后通过 unsafe 操作后,它被进行了解引用,变成了一个rust的标准引用类型,该类型必须要有生命周期,也就是 'a。将产生一个具有无约束生命周期的&T

避免无约束生命周期的最简单方法是在函数边界使用生命周期消除(Lifetime Elision)规则。如果一个输出的生命周期被省略了,那么它必须被一个输入的生命周期所约束。当然,它也可能被错误的生命周期所约束,但这通常只会引起编译错误,而不是让内存安全被简单地违反。

在一个函数中,对生命周期的约束更容易出错。约束生命周期的最安全和最简单的方法是从一个具有约束的生命周期的函数中返回它。然而,如果这样做是不可接受的,可以将引用放在一个有特定生命周期的位置。不幸的是,我们不可能命名一个函数中涉及的所有生命周期。

8.6 生命周期约束

生命周期约束可以应用于类型或其它生命周期。

'a: 'b

约束 'a: 'b 通常被解读为 'a'b 存活的时间久。'a: 'b意味着 'a 持续的时间比 'b 长,所以只要 &'b () 有效,引用 &'a () 就有效。

1
2
3
4
struct DoubleRef<'a,'b:'a, T> {
r: &'a T,
s: &'b T
}

例如上述代码定义一个结构体,它拥有两个引用字段,类型都是泛型 T,每个引用都拥有自己的生命周期,由于我们使用了生命周期约束 'b: 'a,因此 'b 必须存活的比 'a 久,也就是结构体中的 s 字段引用的值必须要比 r 字段引用的值存活的时间长。

T: 'a

T: 'a 意味着 T 的所有生命周期参数都比 'a 存活的时间长。

1
2
3
struct Ref<'a, T: 'a> {
r: &'a T
}

因为结构体字段 r 引用了 T,因此 r 的生命周期 'a 必须要比 T 的生命周期更短(被引用者的生命周期必须要比引用长)。

在 rust 1.30 版本之前,该写法是必须的,但是从 1.31 版本开始,编译器可以自动推导 T: 'a 类型的约束,因此我们只需这样写即可:

1
2
3
struct Ref<'a, T> {
r: &'a T
}

来看一个使用了生命周期约束的综合例子:

1
2
3
4
5
6
7
8
9
10
struct ImportantExcerpt<'a> {
part: &'a str,
}

impl<'a: 'b, 'b> ImportantExcerpt<'a> {
fn announce_and_return_part(&'a self, announcement: &'b str) -> &'b str {
println!("Attention please: {}", announcement);
self.part
}
}

上面的例子中必须添加约束 'a: 'b 后,才能成功编译,因为 self.part 的生命周期与 self的生命周期一致,将 &'a 类型的生命周期强行转换为 &'b 类型会报错,只有在 'a >= 'b 的情况下,'a 才能转换成 'b

8.7 Higher-Rank Trait Bounds(HRTB)

对于这里的内容,官方给出的例子不是很好懂,中文资料也比较欠缺。这里参考了一些资料,对于正确性还需要进一步验证(==todo==待后面有了深入理解之后重新编辑此章节):

HRTB是高阶特征约束,举几个例子来说明。例1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
use std::fmt::Debug;

trait DoSomething<T> {
fn do_sth(&self, value: T);
}

impl<'a, T: Debug> DoSomething<T> for &'a usize {
fn do_sth(&self, value: T) {
println!("{:?}", value);
}
}

fn foo<'a>(b: Box<dyn DoSomething<&'a usize>>) {
let s: usize = 10;
b.do_sth(&s)
}

fn main() {
let x = Box::new(&2usize);
foo(x);
}
/*
error[E0597]: `s` does not live long enough
--> src\main.rs:15:14
|
13 | fn foo<'a>(b: Box<dyn DoSomething<&'a usize>>) {
| -- lifetime `'a` defined here
14 | let s: usize = 10;
15 | b.do_sth(&s)
| ---------^^-
| | |
| | borrowed value does not live long enough
| argument requires that `s` is borrowed for `'a`
16 | }
| - `s` dropped here while still borrowed
*/

这段代码定义了一个DoSomething特征,其中有一个do_sth方法,该方法接收一个T类型的泛型参数value,然后我们为 usize 的引用类型实现 DoSomething 特征,特征约束要求 T 实现 Debug 特征,这是为了能够打印value

接下来定义了一个foo 函数,它接受一个 Box<dyn DoSomething<&'a usize>> 参数,这个参数是一个特征对象,表示它可以接受任何实现了DoSomething<&usize>特征的类型,并使用usize类型的整数常量的引用 &s 调用 do_sth 方法。整数常量 sfoo 函数内部定义,值为 10

main 函数中,创建了一个 Box,其中包含一个值为 2 的整数常量的引用。然后,使用这个 Box 作为 foo 函数的参数调用它。

当运行程序时,我们期望它会打印 10,这是传递给引用类型 usizeDoSomething 实现的 do_sth 方法的 s 的值。

但如你所见,它编译失败了,这与生命周期有关。我们通过直觉理解一下这里出现的问题:foo函数失败是因为函数签名中的参数b的类型引入了'a,它关联到了DoSomething<T>中的T, 进而关联到do_sth第二个参数value的生命周期上,所以在编译器进行分析时,do_sth的第二个参数的生命周期至少和do_sth一样为'a,但是代码实际传入了一个本地变量s,它的生命周期'b短于do_sth的生命周期, 即有'a : 'b的约束关系,因此编译失败。

我们修改foo的签名:

1
2
3
4
fn foo<'a>(b: Box<&'a usize>) {
let s: usize = 10;
b.do_sth(&s);
}

在这个签名中,foo接受一个 Box<&'a usize> 类型的参数。因为b的引入只是将'a关联到了do_sthself的生命周期,也就是do_sth的调用者b本身,对于value没有关联。所以do_sthvalue的生命周期的推断推迟到了do_sth(&s),此时直接与本地变量s的生命周期'b绑定了,因此编译成功。

还有另一种修改方式,即HRTB:

1
2
3
4
fn foo(b: Box<dyn for<'a> DoSomething<&'a usize>>) {
let s: usize = 10;
b.do_sth(&s)
}

因为我们想表达对于任何生命周期的T我们都希望do_sth最终能接受这样的参数。所以将b的类型改为Box<dyn for<'a> DoSomething<&'a usize>,编译器看到后就会将此处'a的推断延迟到do_sth被调用时,也就最终直接关联到&s的生命周期。

我们对比一下这三种foo的签名:

1
2
3
4
5
6
// 1.
fn foo<'a>(b: Box<dyn DoSomething<&'a usize>>) {
// 2.
fn foo<'a>(b: Box<&'a usize>) {
// 3.
fn foo(b: Box<dyn for<'a> DoSomething<&'a usize>>) {

可以发现,编译行为不同本质就在于1中的签名一上来就给定了DoSometing的泛型参数T'a的引用,也就迫使编译器给其实例化了第二个参数只接受'a引用的do_sth实现,从而拒绝一个本地变量s引用的传入,因为其生命周期明显短于这个外部约束的'a(当然这个'a的进一步实例化需要到foo的真正调用时推断出,但此处已有足够的信息拒绝这个函数实现。)

而对于2,没有给出T的任何约束,所以对于T的推断就推迟到do_sth的调用处,也就是本地变量s引用补全了信息,从而实例化了对于参数&'a usizedo_sth

对于3,这其实解释了HRTB引入的目的,它在生命周期上再进行更高阶的类型约束,提示编译器进行晚绑定(late bound),也就是检查了b调用do_sth函数的具体实现,再进行生命周期泛型参数的实例化。编译器发现b实现的do_sth函数没有返回引用,也就不需要进行生命周期的检查了,因此通过编译。

9 自引用与循环引用

rust的内存安全性保证使其难以意外地制造永远也不会被清理的内存(被称为内存泄漏(memory leak)),但并不是不可能。与在编译时拒绝数据竞争不同,rust并不保证完全地避免内存泄漏,这意味着内存泄漏在rust被认为是内存安全的。这一点可以通过 Rc<T>RefCell<T> 看出:创建引用循环的可能性是存在的。这会造成内存泄漏,因为每一项的引用计数永远也到不了0,其值也永远不会被丢弃。

9.1 制造引用循环

让我们看看引用循环是如何发生的以及如何避免它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
Cons(i32, RefCell<Rc<List>>),
Nil,
}

impl List {
fn tail(&self) -> Option<&RefCell<Rc<List>>> {
match self {
Cons(_, item) => Some(item),
Nil => None,
}
}
}

fn main() {}

我们创建了一个枚举类型 List,根据组合使用Rc<T>RefCell<T>来拥有多个可变数据所有者小节内容,我们这里结合使用Rc<T>RefCell<T> 来获得修改列表中值的能力。这里还增加了一个 tail 方法来方便我们在有 Cons 成员的时候访问其第二项。

接下来,我们使用这个List

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
fn main() {
let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));

println!("a的初始化rc计数 = {}", Rc::strong_count(&a));
println!("a指向的节点 = {:?}", a.tail());

// 创建`b`到`a`的引用
let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));

println!("在b创建后,a的rc计数 = {}", Rc::strong_count(&a));
println!("b的初始化rc计数 = {}", Rc::strong_count(&b));
println!("b指向的节点 = {:?}", b.tail());

// 利用RefCell的可变性,创建了`a`到`b`的引用
if let Some(link) = a.tail() {
*link.borrow_mut() = Rc::clone(&b);
}

println!("在更改a后,b的rc计数 = {}", Rc::strong_count(&b));
println!("在更改a后,a的rc计数 = {}", Rc::strong_count(&a));

// 下面一行println!将导致循环引用,最终造成栈溢出
// println!("a next item = {:?}", a.tail());
}

这里在变量 a 中创建了一个 Rc<List> 实例来存放初值为 5, NilList 值。接着在变量 b 中创建了存放包含值10 和指向列表 aList 的另一个 Rc<List> 实例。

最后,修改 a 使其指向 b 而不是 Nil,这就创建了一个循环。为此需要使用 tail 方法获取 aRefCell<Rc<List>> 的引用,并放入变量 link 中。接着使用 RefCell<Rc<List>>borrow_mut 方法将其值从存放 NilRc<List> 修改为 b 中的 Rc<List>

现在,保持最后的 println! 行注释并运行代码,会得到如下输出:

1
2
3
4
5
6
7
a的初始化rc计数 = 1
a指向的节点 = Some(RefCell { value: Nil })
在b创建后,a的rc计数 = 2
b的初始化rc计数 = 1
b指向的节点 = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
在更改a后,b的rc计数 = 2
在更改a后,a的rc计数 = 2

可以看到将列表 a 修改为指向 b 之后, ab 中的 Rc<List> 实例的引用计数都是 2。在 main 的结尾,rust丢弃 b,这会使 b的引用计数从 2 减为 1。然而,b 不能被回收,因为其引用计数是 1 而不是 0。接下来rust会丢弃 aa 的引用计数从 2 减为 1。这个实例也不能被回收,因为 b 依然引用它,所以其引用计数是 1。这些列表的内存将永远保持未被回收的状态。因此,发生了内存泄漏。

下面的图片解释了这种引用关系:

v2-2dbfc981f05019bf70bf81c93f956c35_1440w

如果取消最后 println! 的注释并运行程序,rust会尝试打印出 a 指向 b 指向 a 这样的无限循环直到栈溢出。

相比真实世界的程序,这个例子中创建引用循环的结果并不可怕。创建了引用循环之后程序立刻就结束了。如果在更为复杂的程序中并在循环里分配了很多内存并占有很长时间,这个程序会使用多于它所需要的内存,并有可能压垮系统并造成没有内存可供使用。

创建引用循环并不容易,但也不是不可能。如果你有包含 Rc<T>RefCell<T> 值或类似的嵌套结合了内部可变性和引用计数的类型,请务必小心确保你没有形成一个引用循环。你无法指望rust帮你捕获它们。创建引用循环是一个程序上的逻辑bug,你应该使用自动化测试、代码评审和其他软件开发最佳实践来使其最小化。

另一个解决方案是重新组织数据结构,使得一部分引用拥有所有权而另一部分没有。换句话说,循环将由一些拥有所有权的关系和一些无所有权的关系组成,只有所有权关系才能影响值是否可以被丢弃。在这个例子中,我们总是希望 Cons 成员拥有其列表,所以重新组织数据结构是不可能的。

9.2 将Rc<T>变为Weak<T>以避免循环引用

到目前为止,我们已经展示了调用 Rc::clone 会增加 Rc<T> 实例的 strong_count,和只在其 strong_count 为 0 时才会被清理的 Rc<T> 实例。你也可以通过调用 Rc::downgrade 并传递 Rc<T> 实例的引用来创建其值的弱引用(weak reference)。强引用代表如何共享 Rc<T> 实例的所有权。弱引用并不属于所有权关系,当 Rc<T> 实例被清理时其计数没有影响。它们不会造成引用循环,因为任何弱引用的循环会在其相关的强引用计数为 0 时被打断。

调用 Rc::downgrade 时会得到 Weak<T> 类型的智能指针。不同于将 Rc<T> 实例的 strong_count 加 1,调用 Rc::downgrade 会将 weak_count 加 1。Rc<T> 类型使用 weak_count 来记录其存在多少个 Weak<T> 引用,类似于 strong_count。其区别在于 weak_count 无需计数为 0 就能使 Rc<T> 实例被清理。

因为 Weak<T> 引用的值可能已经被丢弃了,为了使用 Weak<T> 所指向的值,我们必须确保其值仍然有效。为此可以调用 Weak<T> 实例的 upgrade 方法,这会返回 Option<Rc<T>>。如果 Rc<T> 值还未被丢弃,则结果是 Some;如果 Rc<T> 已被丢弃,则结果是 None。因为 upgrade 返回一个 Option<Rc<T>>,rust会确保处理 SomeNone 的情况,所以它不会返回非法指针。

创建树形数据结构:带有子节点的 Node

下面看一个由父节点和子节点构成的树形结构的例子。

在最开始,我们将会构建一个带有子节点的树。让我们创建一个用于存放其拥有所有权的 i32 值和其子节点引用的 Node

1
2
3
4
5
6
7
8
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
value: i32,
children: RefCell<Vec<Rc<Node>>>,
}

我们希望能够 Node 拥有其子节点,同时也希望通过变量来共享所有权,以便可以直接访问树中的每一个 Node,为此 Vec<T> 的项的类型被定义为 Rc<Node>。我们还希望能修改其它节点的子节点,所以 childrenVec<Rc<Node>> 被放进了 RefCell<T>

接下来,使用此结构体定义来创建一个叫做 leaf 的带有值 3 且没有子节点的 Node 实例,和另一个带有值 5 并以 leaf 作为子节点的实例 branch

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let leaf = Rc::new(Node {
value: 3,
children: RefCell::new(vec![]),
});

let branch = Rc::new(Node {
value: 5,
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
}

这里克隆了 leaf 中的 Rc<Node> 并储存在了 branch 中,这意味着 leaf 中的 Node 现在有两个所有者:leafbranch。可以通过 branch.childrenbranch 中获得 leaf,不过无法从 leafbranchleaf 没有到 branch 的引用且并不知道它们相互关联。

增加从子到父的引用

现在我们希望 leaf 知道 branch 是其父节点,需要在 Node 结构体定义中增加一个 parent 字段。问题是 parent 的类型应该是什么。我们知道其不能包含 Rc<T>,因为这样 leaf.parent 将会指向 branchbranch.children 会包含 leaf 的指针,这会形成引用循环,会造成其 strong_count 永远也不会为 0。

现在换一种方式思考这个关系,父节点应该拥有其子节点:如果父节点被丢弃了,其子节点也应该被丢弃。然而子节点不应该拥有其父节点:如果丢弃子节点,其父节点应该依然存在。这正是弱引用的例子!

所以 parent 使用 Weak<T> 类型而不是 Rc<T>,具体来说是 RefCell<Weak<Node>>。现在 Node 结构体定义看起来像这样:

1
2
3
4
5
6
7
8
9
use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
value: i32,
parent: RefCell<Weak<Node>>,
children: RefCell<Vec<Rc<Node>>>,
}

这样,一个节点就能够引用其父节点,但不拥有其父节点。我们更新 main 来使用新定义以便 leaf 节点可以通过 branch 引用其父节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});

println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});

*leaf.parent.borrow_mut() = Rc::downgrade(&branch);

println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}

由于leaf 开始时没有父节点,所以我们新建了一个空的 Weak 引用实例。此时,当尝试使用 upgrade 方法获取 leaf 的父节点引用时,会得到一个 None 值。如第一个 println! 输出所示:

1
leaf parent = None

当创建 branch 节点时,其也会新建一个 Weak<Node> 引用,因为 branch 并没有父节点。leaf 仍然作为 branch 的一个子节点。一旦在 branch 中有了 Node 实例,就可以修改 leaf 使其拥有指向父节点的 Weak<Node> 引用。这里使用了 leafparent 字段里的 RefCell<Weak<Node>>borrow_mut 方法,接着使用了 Rc::downgrade 函数来从 branch 中的 Rc<Node> 值创建了一个指向 branchWeak<Node> 引用。

当再次打印出 leaf 的父节点时,这一次将会得到存放了 branchSome 值:现在 leaf 可以访问其父节点了!当打印出 leaf 时,我们也避免了循环引用最终会导致栈溢出的循环:Weak<Node> 引用被打印为 (Weak)

1
2
3
leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) },
children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) },
children: RefCell { value: [] } }] } })

没有无限的输出表明这段代码并没有造成引用循环。这一点也可以从观察 Rc::strong_countRc::weak_count 调用的结果看出。

可视化 strong_countweak_count 的改变

让我们通过创建了一个新的内部作用域并将 branch 的创建放入其中,来观察 Rc<Node> 实例的 strong_countweak_count 值的变化。这会展示当 branch 创建和离开作用域被丢弃时会发生什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});

println!(
"leaf strong = {}, weak = {}",
Rc::strong_count(&leaf),
Rc::weak_count(&leaf),
);

{
let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});

*leaf.parent.borrow_mut() = Rc::downgrade(&branch);

println!(
"branch strong = {}, weak = {}",
Rc::strong_count(&branch),
Rc::weak_count(&branch),
);

println!(
"leaf strong = {}, weak = {}",
Rc::strong_count(&leaf),
Rc::weak_count(&leaf),
);
}

println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
println!(
"leaf strong = {}, weak = {}",
Rc::strong_count(&leaf),
Rc::weak_count(&leaf),
);
}

一旦创建了 leaf,其 Rc<Node> 的强引用计数为 1,弱引用计数为 0。在内部作用域{}中创建了 branch 并与 leaf 相关联,此时 branchRc<Node> 的强引用计数为 1,弱引用计数为 1(因为 leaf.parent 通过 Weak<Node> 指向 branch)。这里 leaf 的强引用计数为 2,因为现在 branchbranch.children 中储存了 leafRc<Node> 的拷贝,不过弱引用计数仍然为 0。

当内部作用域结束时,branch 离开作用域,Rc<Node> 的强引用计数减少为 0,所以其 Node 被丢弃。来自 leaf.parent 的弱引用计数 1 与 Node 是否被丢弃无关,所以并没有产生任何内存泄漏。

如果在内部作用域结束后尝试访问 leaf 的父节点,会再次得到 None。在程序的结尾,leafRc<Node> 的强引用计数为 1,弱引用计数为 0,因为现在 leaf 又是 Rc<Node> 唯一的引用了。

所有这些管理计数和值的逻辑都内建于 Rc<T>Weak<T> 以及它们的 Drop 特征实现中。通过在 Node 定义中指定从子节点到父节点的关系为一个Weak<T>引用,就能够拥有父节点和子节点之间的双向引用而不会造成引用循环和内存泄漏。

Weak总结

Weak 非常类似于 Rc,但是与 Rc 持有所有权不同,Weak 不持有所有权,它仅仅保存一份指向数据的弱引用:如果你想要访问数据,需要通过 Weak 指针的 upgrade 方法实现,该方法返回一个类型为 Option<Rc<T>> 的值。因为 Weak 引用不计入所有权,因此它无法阻止所引用的内存值被释放掉,而且 Weak 本身不对值的存在性做任何担保,引用的值还存在就返回 Some,不存在就返回 None

Weak 通过 use std::rc::Weak 来引入,它具有以下特点:

  • 可访问,但没有所有权,不增加引用计数,因此不会影响被引用值的释放回收
  • 可由 Rc<T> 调用 downgrade 方法转换成 Weak<T>
  • Weak<T> 可使用 upgrade 方法转换成 Option<Rc<T>>,如果资源已经被释放,则 Option 的值是 None
  • 常用于解决循环引用的问题

WeakRc 的对比如下:

WeakRc
不计数引用计数
不拥有所有权拥有值的所有权
不阻止值被释放 drop所有权计数归零,才能 drop
引用的值存在返回 Some,不存在返回 None引用的值必定存在
通过 upgrade 取到 Option<Rc<T>>,然后再取值通过 Deref 自动解引用,取值无需任何操作

9.3 结构体自引用

让我们尝试创建一个自引用的结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct SelfRef<'a> {
value: String,
// 该引用指向上面的value
pointer_to_value: &'a str,
}

fn main(){
let s = "aaa".to_string();
let v = SelfRef {
value: s,
pointer_to_value: &s
};
}
/*
error[E0382]: borrow of moved value: `s`
--> src\main.rs:12:27
|
9 | let s = "aaa".to_string();
| - move occurs because `s` has type `String`, which does not implement the `Copy` trait
10 | let v = SelfRef {
11 | value: s,
| - value moved here
12 | pointer_to_value: &s
| ^^ value borrowed here after move
*/

这里创建了一个结构体并使用它,由于我们试图同时使用值s和值的引用&s,最终所有权转移和借用一起发生了。下面介绍解决自引用的几种方法。

使用Option解决自引用

最简单的方式就是使用 Option 分两步来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#[derive(Debug)]
struct WhatAboutThis<'a> {
name: String,
nickname: Option<&'a str>,
}

fn main() {
let mut tricky = WhatAboutThis {
name: "Annabelle".to_string(),
nickname: None,
};
tricky.nickname = Some(&tricky.name[..4]);

println!("{:?}", tricky);
}

在某种程度上来说,Option 这个方法可以工作,但是这个方法的限制较多,例如从一个函数创建并返回它是不可能的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fn creator<'a>() -> WhatAboutThis<'a> {
let mut tricky = WhatAboutThis {
name: "Annabelle".to_string(),
nickname: None,
};
tricky.nickname = Some(&tricky.name[..4]);

tricky
}
/*
error[E0515]: cannot return value referencing local data `tricky.name`
--> src/main.rs:24:5
|
22 | tricky.nickname = Some(&tricky.name[..4]);
| ----------- `tricky.name` is borrowed here
23 |
24 | tricky
| ^^^^^^ returns a value referencing data owned by the current function
*/

从函数签名就能看出来端倪,'a 生命周期是凭空产生的。如果是通过方法使用,你需要一个无用 &'a self 生命周期标识,一旦有了这个标识,代码将变得更加受限,你将很容易就获得借用错误,就连 NLL 规则都没用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#[derive(Debug)]
struct WhatAboutThis<'a> {
name: String,
nickname: Option<&'a str>,
}

impl<'a> WhatAboutThis<'a> {
fn tie_the_knot(&'a mut self) {
self.nickname = Some(&self.name[..4]);
}
}

fn main() {
let mut tricky = WhatAboutThis {
name: "Annabelle".to_string(),
nickname: None,
};
tricky.tie_the_knot();

println!("{:?}", tricky);
}
/*
error[E0502]: cannot borrow `tricky` as immutable because it is also borrowed as mutable
--> src\main.rs:21:22
|
18 | tricky.tie_the_knot();
| --------------------- mutable borrow occurs here
...
21 | println!("{:?}", tricky);
| ^^^^^^
| |
| immutable borrow occurs here
| mutable borrow later used here
*/

unsafe 实现

使用unsafe可以避免借用规则的限制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#[derive(Debug)]
struct SelfRef {
value: String,
pointer_to_value: *const String,
}

impl SelfRef {
fn new(txt: &str) -> Self {
SelfRef {
value: String::from(txt),
pointer_to_value: std::ptr::null(),
}
}

fn init(&mut self) {
let self_ref: *const String = &self.value;
self.pointer_to_value = self_ref;
}

fn value(&self) -> &str {
&self.value
}

fn pointer_to_value(&self) -> &String {
assert!(!self.pointer_to_value.is_null(),
"Test::b called without Test::init being called first");
unsafe { &*(self.pointer_to_value) }
}
}

fn main() {
let mut t = SelfRef::new("hello");
t.init();
// 打印值和指针地址
println!("{}, {:p}", t.value(), t.pointer_to_value());
}

在这里,我们在 pointer_to_value 中直接存储裸指针,而不是rust的引用,因此不再受到rust借用规则和生命周期的限制,而且实现起来非常清晰、简洁。但是缺点就是,通过指针获取值时需要使用 unsafe 代码。

当然,上面的代码你还能通过裸指针来修改 String,但是需要将 *const 修改为 *mut

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#[derive(Debug)]
struct SelfRef {
value: String,
pointer_to_value: *mut String,
}

impl SelfRef {
fn new(txt: &str) -> Self {
SelfRef {
value: String::from(txt),
pointer_to_value: std::ptr::null_mut(),
}
}

fn init(&mut self) {
let self_ref: *mut String = &mut self.value;
self.pointer_to_value = self_ref;
}

fn value(&self) -> &str {
&self.value
}

fn pointer_to_value(&self) -> &String {
assert!(!self.pointer_to_value.is_null(), "Test::b called without Test::init being called first");
unsafe { &*(self.pointer_to_value) }
}
}

fn main() {
let mut t = SelfRef::new("hello");
t.init();
println!("{}, {:p}", t.value(), t.pointer_to_value());

t.value.push_str(", world");
unsafe {
(&mut *t.pointer_to_value).push_str("!");
}

println!("{}, {:p}", t.value(), t.pointer_to_value());
}

运行后输出:

1
2
hello, 0x16f3aec70
hello, world!, 0x16f3aec70

上面的 unsafe 虽然简单好用,但是它不太安全,是否还有其他选择?那就是 Pin

无法被移动的 Pin

Pin 可以固定住一个值,防止该值在内存中被移动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
use std::marker::PhantomPinned;
use std::pin::Pin;
use std::ptr::NonNull;

// 下面是一个自引用数据结构体,因为 slice 字段是一个指针,指向了 data 字段
// 我们无法使用普通引用来实现,因为违背了 Rust 的编译规则
// 因此,这里我们使用了一个裸指针,通过 NonNull 来确保它不会为 null
struct Unmovable {
data: String,
slice: NonNull<String>,
_pin: PhantomPinned,
}

impl Unmovable {
// 为了确保函数返回时数据的所有权不会被转移,我们将它放在堆上,唯一的访问方式就是通过指针
fn new(data: String) -> Pin<Box<Self>> {
let res = Unmovable {
data,
// 只有在数据到位时,才创建指针,否则数据会在开始之前就被转移所有权
slice: NonNull::dangling(),
_pin: PhantomPinned,
};
let mut boxed = Box::pin(res);

let slice = NonNull::from(&boxed.data);
// 这里其实安全的,因为修改一个字段不会转移整个结构体的所有权
unsafe {
let mut_ref: Pin<&mut Self> = Pin::as_mut(&mut boxed);
Pin::get_unchecked_mut(mut_ref).slice = slice;
}
boxed
}
}

fn main() {
let unmoved = Unmovable::new("hello".to_string());
// 只要结构体没有被转移,那指针就应该指向正确的位置,而且我们可以随意移动指针
let mut still_unmoved = unmoved;
assert_eq!(still_unmoved.slice, NonNull::from(&still_unmoved.data));

// 因为我们的类型没有实现 `Unpin` 特征,下面这段代码将无法编译
// let mut new_unmoved = Unmovable::new("world".to_string());
// std::mem::swap(&mut *still_unmoved, &mut *new_unmoved);
}

上面的代码也非常清晰,虽然使用了 unsafe,其实更多的是无奈之举,跟之前的 unsafe 实现完全不可同日而语。

其实 Pin 在这里并没有魔法,它也并不是实现自引用类型的主要原因,最关键的还是里面的裸指针的使用,而 Pin 起到的作用就是确保我们的值不会被移走,否则指针就会指向一个错误的地址。

10 宏入门

实际上在前面已经出现很多宏的身影了,比如println!vec!等等。宏(Macro)指的是rust中一系列的功能,使用 macro_rules! 的声明(Declarative)宏,和三种过程(Procedural)宏:

  • 自定义 #[derive] 宏在结构体和枚举上指定通过 derive 属性添加的代码
  • 类属性(Attribute-like)宏定义可用于任意项的自定义属性
  • 类函数(Function-like)宏看起来像函数不过作用于作为参数传递的 token

我们会依次讨论每一种宏,首先要解释一下为什么已经有了函数还需要宏。

10.1 宏和函数的区别

  • 元编程:

    从根本上来说,宏是一种为写其它代码而写代码的方式,即所谓的元编程(metaprogramming),比如println! 宏和 vec! 宏。所有的这些宏以展开的方式来生成比你所手写出的更多的代码。元编程对于减少大量编写和维护的代码是非常有用的,它也扮演了函数扮演的角色。但宏有一些函数所没有的附加能力。

  • 可变参数

    一个函数签名必须声明函数参数个数和类型。相比之下,宏能够接收不同数量的参数:用一个参数调用println!("hello")或用两个参数调用println!("hello {}", name)

  • 宏展开

    宏可以在编译器翻译代码前展开,例如,宏可以在一个给定类型上实现特征。而函数则不行,因为函数是在运行时被调用,同时特征需要在编译时实现。

  • 宏相对复杂

    宏定义要比函数定义更复杂,因为你正在编写生成rust代码的rust代码。由于这样的间接性,宏定义通常要比函数定义更难阅读、理解以及维护。

10.2 声明式宏macro_rules!用于通用元编程

rust最常用的宏形式是声明宏(declarative macros)。它们有时也被称为“macros by example”、“macro_rules!宏” 或者就是“macros”。其核心概念是,声明宏允许我们编写一些类似rustmatch 表达式的代码。match 表达式是控制结构,其接收一个表达式,与表达式的结果进行模式匹配,然后根据模式匹配执行相关代码。宏也将一个值和包含相关代码的模式进行比较;此种情况下,该值是传递给宏的rust源代码字面值,模式用于和前面提到的源代码字面值进行比较,每个模式的相关代码会替换传递给宏的代码。所有这一切都发生于编译时。

可以使用 macro_rules! 来定义宏。让我们通过查看 vec! 宏定义来探索如何使用 macro_rules! 结构。我们知道,vec! 宏来生成一个给定值的vector,例如,下面的宏用三个整数创建一个 vector:

1
let v: Vec<u32> = vec![1, 2, 3];

也可以使用 vec! 宏来构造两个整数的 vector 或五个字符串切片的 vector。但却无法使用函数做相同的事情,因为我们无法预先知道参数值的数量和类型。

下面展示了vec! 稍微简化的定义:

标准库中实际定义的 vec! 包括预分配适当量的内存的代码。这部分为代码优化,为了让示例简化,此处并没有包含在内。

1
2
3
4
5
6
7
8
9
10
11
12
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}

#[macro_export] 注解表明只要导入了定义这个宏的crate,该宏就应该是可用的。如果没有该注解,这个宏不能被引入作用域。

接着使用 macro_rules! 和宏名称开始宏定义,且所定义的宏并不带感叹号。名字后跟大括号表示宏定义体,在该例中宏名称是 vec

vec! 宏的结构和 match 表达式的结构类似。此处有一个分支模式 ( $( $x:expr ),* ) ,后跟 => 以及和模式相关的代码块。如果模式匹配,该相关代码块将被执行。这里这个宏只有一个模式,那就只有一个有效匹配方向,其它任何模式方向(不匹配这个模式)都会导致错误。更复杂的宏会有多个分支模式。

需要注意的是,宏定义中有效模式语法和match模式匹配语法是不同的,因为宏模式所匹配的是rust代码结构而不是值。对于全部的宏模式语法,请查阅这里

在上面的代码片段中,一对括号包含了整个模式。我们使用美元符号($)在宏系统中声明一个变量来包含匹配该模式的 rust代码。美元符号明确表明这是一个宏变量而不是普通rust变量。之后是一对括号,其捕获了符合括号内模式的值用以在替代代码中使用。$() 内则是 $x:expr ,其匹配rust的任意表达式,并将该表达式命名为 $x

$() 之后的逗号说明一个可有可无的逗号分隔符可以出现在 $() 所匹配的代码之后。紧随逗号之后的 * 说明该模式匹配零个或更多个 * 之前的任何模式。

举个例子来说,当以 vec![1, 2, 3]; 调用宏时,$x 模式与三个表达式 123 进行了三次匹配。

现在让我们来看看与此分支模式相关联的代码块中的模式:

1
2
3
4
5
6
7
8
9
{
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};

匹配到模式中的$()的每一部分,都会在(=>右侧)$()* 里生成temp_vec.push($x),生成零次还是多次取决于模式匹配到多少次。$x 由每个与之相匹配的表达式所替换。当以 vec![1, 2, 3]; 调用该宏时,替换该宏调用所生成的代码会是下面这样:

1
2
3
4
5
6
7
{
let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec
}

至此,我们定义了一个宏,它可以接受任意类型和数量的参数,并且理解了其语法的含义。

由于绝大多数rust开发者都是宏的用户而不是编写者,如需了解更多macro_rules的内容,可以参考 The Little Book of Rust Macros一书。

10.3 过程宏用于从属性生成代码

第二种形式的宏被称为过程宏(procedural macros),因为它们更像函数(一种过程类型)。过程宏接收rust代码作为输入,在这些代码上进行操作,然后产生另一些代码作为输出,而非像声明式宏那样匹配对应模式然后以另一部分代码替换当前代码。有三种类型的过程宏(自定义派生derive,类属性和类函数),不过它们的工作方式都类似。注意,过程宏中的 derive 宏输出的代码并不会替换之前的代码,这一点与声明宏有很大的不同!

创建过程宏时,其定义必须驻留在它们自己的具有特殊crate类型的crate中。这么做出于复杂的技术原因,将来我们希望rust能够消除这些限制。

在下面的代码中展示了如何定义过程宏,其中 some_attribute 是一个使用特定宏变体的占位符:

1
2
3
4
5
use proc_macro;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}

定义过程宏的函数接收一个TokenStream作为输入并生成TokenStream作为输出。TokenStream 是定义于proc_macro crate里代表一系列 token 的类型,rust默认携带了proc_macrocrate。这就是宏的核心:宏所处理的源代码组成了输入 TokenStream,宏生成的代码是输出 TokenStream。函数上还有一个属性;这个属性指明了我们创建的过程宏的类型。在同一 crate 中可以有多种的过程宏。

让我们看看不同种类的程序宏。我们将从一个自定义的派生宏开始,然后解释使其他形式不同的小差异。

10.4 自定义derive

让我们创建一个 hello_macrocrate包,其包含名为 HelloMacro 的特征和关联函数(静态方法) hello_macro。不同于让用户为其每一个类型实现 HelloMacro 特征,我们将会提供一个过程式宏以便用户可以使用 #[derive(HelloMacro)] 注解它们的类型来得到 hello_macro 函数的默认实现。该默认实现会打印 Hello, Macro! My name is TypeName!,其中 TypeName 为定义了特征的类型名。

换言之,我们会创建一个crate,使程序员能够写出下面的代码并正常运行:

1
2
3
4
5
6
7
8
9
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
Pancakes::hello_macro();
}

运行该代码将会打印 Hello, Macro! My name is Pancakes!

首先第一步就是创建一个新的crate

1
2
3
cargo new hello_macro --lib
cd hello_macro/
touch src/main.rs

此时,src 目录下包含两个文件 lib.rsmain.rs,前者是 lib 库,后者是二进制库。

接下来,会定义 HelloMacro 特征以及其关联函数:

文件名:hello_macro/src/lib.rs

1
2
3
pub trait HelloMacro {
fn hello_macro();
}

现在有了一个包含函数的特征。此时,crate的用户可以实现该特征以达到其期望的功能,像这样:

文件名:hello_macro/src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
use hello_macro::HelloMacro;

struct Pancakes;

impl HelloMacro for Pancakes {
fn hello_macro() {
println!("Hello, Macro! My name is Pancakes!");
}
}

fn main() {
Pancakes::hello_macro();
}

然而,用户需要为每一个它们想使用 hello_macro 的类型编写impl实现的代码块。我们希望为其节约这些工作。

另外,我们也无法为 hello_macro 函数提供一个能够打印实现了该特征的类型的名字的默认实现:rust没有反射的能力,因此其无法在运行时获取类型名。我们需要一个在编译时生成代码的宏。

因此,下一步是定义过程式宏。过程式宏目前必须在其自己的crate内。该限制最终可能被取消。构造crate和其中宏的惯例如下:对于一个 foo 的包来说,一个自定义的派生过程宏的包被称为 foo_derive 。我们按照惯例在 hello_macro 项目中新建名为 hello_macro_derive 的包。

1
cargo new hello_macro_derive --lib

现在,整个目录结构如下:

1
2
3
4
5
6
7
8
9
10
hello_macro
├── Cargo.toml
├── Cargo.lock
├── src
│ ├── main.rs
│ └── lib.rs
└── hello_macro_derive
├── Cargo.toml
└── src
└── lib.rs

由于两个crate紧密相关,因此在 hello_macro 包的目录下创建过程式宏的crate。如果改变在 hello_macro 中定义的 特征,同时也必须改变在 hello_macro_derive 中实现的过程式宏。这两个包需要分别发布,编程人员如果使用这些包,则需要同时添加这两个依赖并将其引入作用域。我们也可以只用 hello_macro 包而将 hello_macro_derive 作为一个依赖,并重新导出过程式宏的代码。但现在我们组织项目的方式使编程人员在无需 derive 功能时也能够单独使用 hello_macro

我们需要声明 hello_macro_derivecrate是过程宏 crate。我们还需要 synquote 两个crate 中的功能,需要将它们加到依赖中。将下面的代码加入到 hello_macro_deriveCargo.toml文件中。

1
2
3
4
5
6
[lib]
proc-macro = true

[dependencies]
syn = "1.0"
quote = "1.0"

这里我们在 [lib] 中设置proc-macro=true定义一个过程式宏。

接下来,添加如下代码:

文件名:hello_macro_derive/src/lib.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use proc_macro::TokenStream;
use quote::quote;
use syn;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree
// that we can manipulate
// 基于 input 构建 AST 语法树
let ast = syn::parse(input).unwrap();

// Build the trait implementation
// 构建特征实现代码
impl_hello_macro(&ast)
}

注意我们将代码分成了hello_macro_deriveimpl_macro_derive 两个函数,前者负责解析 TokenStream,后者负责转换语法树:这使得编写过程宏更方便。几乎你看到或者创建的每一个过程宏的外部函数(这里是hello_macro_derive)中的代码都跟这里是一样的。你放入内部函数(这里是impl_macro_derive)中的代码根据你的过程宏的设计目的会有所不同。

现在,我们已经引入了三个新的crateproc_macrosynquote 。rust自带proc_macrocrate,因此无需将其加到 Cargo.toml 文件的依赖中。proc_macro 是编译器用来读取和操作我们rust代码的 API。

syn 用于将字符串中的rust代码解析成为一个可以操作的数据结构。quote 则将 syn 解析的数据结构转换回rust代码。这些crate让解析任何我们所要处理的rust代码变得更简单:为rust编写整个的解析器并不是一件简单的工作。

当用户在一个类型上指定 #[derive(HelloMacro)] 时,hello_macro_derive 函数将会被调用。因为我们已经使用 proc_macro_derive 及其指定名称HelloMacrohello_macro_derive 函数进行了注解,指定名称HelloMacro就是特征名,这是大多数过程宏遵循的习惯。

该函数首先将来自 TokenStreaminput 转换为一个我们可以解释和操作的数据结构。这正是 syn 派上用场的地方。syn 中的 parse 函数获取一个 TokenStream 并返回一个表示解析出rust代码的 DeriveInput 结构体。

derive过程宏只能用在结构体(struct)/枚举(enum)/联合(union)上,多数用在结构体上。举个例子,从字符串 struct Pancakes; 中解析出来的 DeriveInput 结构体的相关部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
DeriveInput {
// --snip--

ident: Ident {
ident: "Pancakes",
span: #0 bytes(95..103)
},
// Data是一个枚举,分别可以是DataStruct,DataEnum,DataUnion
data: Struct(
DataStruct {
struct_token: Struct,
fields: Unit,
semi_token: Some(
Semi
)
}
)
}

该结构体的字段展示了我们解析的rust代码是一个类单元结构体,其 ident(identifier,表示名字)为 Pancakes。该结构体里面有更多字段描述了所有类型的rust代码,查阅 synDeriveInput 的文档 以获取更多信息。

很快我们将定义 impl_hello_macro 函数,其用于构建所要包含在内的rust新代码。但在此之前,注意hello_macro_derive返回值也是 TokenStream。所返回的 TokenStream 会被加到我们的crate用户所写的代码中,因此,当用户编译他们的crate时,他们会通过修改后的 TokenStream 获取到我们所提供的额外功能。

另外,这里当调用 syn::parse 函数失败时,我们用 unwrap 来使 hello_macro_derive 函数 panic。在错误时 panic 对过程宏来说是必须的,因为 proc_macro_derive 函数必须返回 TokenStream 而不是 Result,以此来符合过程宏的 API。这里选择用 unwrap 来简化了这个例子;在生产代码中,则应该通过 panic!expect 来提供关于发生何种错误的更加明确的错误信息。

现在我们有了将注解的rust代码从 TokenStream 转换为 DeriveInput 实例的代码,让我们来创建在注解类型上实现 HelloMacro 特征的代码。

文件名:hello_macro_derive/src/lib.rs

1
2
3
4
5
6
7
8
9
10
11
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let gen = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
gen.into()
}

我们得到一个包含以 ast.ident 作为注解类型名字(标识符)的 Ident 结构体实例。仍以刚才的例子来说,其值是 "Pancakes"

quote! 宏能让我们编写希望返回的rust代码。quote! 宏执行的直接结果并不是编译器所期望的所以需要转换为 TokenStream。为此需要调用 into 方法,它会消费这个中间表示(intermediate representation,IR)并返回所需的 TokenStream 类型值。

这个宏也提供了一些非常酷的模板机制;我们可以写 #name ,然后 quote! 会以名为 name 的变量值来替换它。你甚至可以做一些类似常用宏那样的重复代码的工作。查阅 quote 的文档 来获取详尽的介绍。

我们期望我们的过程式宏能够为通过 #name 获取到的用户注解类型生成 HelloMacro 特征的实现。该特征的实现有一个函数 hello_macro ,其函数体包括了我们期望提供的功能:打印 Hello, Macro! My name is 和注解的类型名。

此处所使用的 stringify! 为rust内置宏。其接收一个rust表达式,如 1 + 2 ,然后在编译时将表达式转换为一个字符串常量,如 "1 + 2" 。这与 format!println! 是不同的,它计算表达式并将结果转换为 String 。有一种可能的情况是,所输入的 #name 可能是一个需要打印的表达式,因此我们用 stringify!stringify! 也能通过在编译时将 #name 转换为字符串来节省内存分配。

此时,使用cargo build 应该都能成功编译 hello_macrohello_macro_derive

接下来,我们需要在项目的 src/main.rs 中引用 hello_macro_derive 包的内容?方法有两种,第一种是将 hello_macro_derive 发布到 crates.ioGitHub 中。第二种就是使用相对路径引入的本地化方式。

我们采用第二种方法,在项目根目录下新建一个二进制项目:

1
cargo new pancakes

整个的文件结构现在变成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
hello_macro
├── Cargo.toml
├── Cargo.lock
├── src
│ ├── main.rs
│ └── lib.rs
├── hello_macro_derive
│ ├── Cargo.toml
│ └── src
│ └── lib.rs
pancakes
├── Cargo.toml
├── Cargo.lock
└── src
└── lib.rs

然后需要将 hello_macrohello_macro_derive 作为依赖加到 pancakes 包的Cargo.toml文件中去。

文件名:pancakes/Cargo.toml

1
2
3
[dependencies]
hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }

最后,我们添加代码到main.rs

文件名:pancakes/src/main.rs

1
2
3
4
5
6
7
8
9
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
Pancakes::hello_macro();
}

现在执行 cargo run:其应该打印 Hello, Macro! My name is Pancakes!。其包含了该过程宏中 HelloMacro 特征的实现,而无需 pancakes 这个crate实现它;#[derive(HelloMacro)] 增加了该特征实现。

另外,学习过程更好的办法是通过展开宏来阅读和调试自己写的宏,这里需要用到一个cargo-expand的工具,可以通过下面的命令安装:

1
cargo install cargo-expand

然后,使用命令cargo expand即可展开宏。不过,这个命令依赖于不稳定的编译器标志,因此它需要安装nightly工具链。

我们可以使用如下命令查看已安装的工具链:

1
rustup toolchain list

默认情况下安装的是stable稳定版本,我们需要安装nightly版本并设置为默认:

1
rustup default nightly

查看rustc版本,验证是否切换成功

1
rustc --version

当然,也可以使用同样的方法切换回稳定版:

1
rustup default stable

我们展开宏看看代码是什么样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#![feature(prelude_import)]
#[prelude_import]
use std::prelude::rust_2021::*;
#[macro_use]
extern crate std;
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;
struct Pancakes;
impl HelloMacro for Pancakes {
fn hello_macro() {
{
::std::io::_print(
format_args!("Hello, Macro! My name is {0}!\n", "Pancakes"),
);
};
}
}
fn main() {
Pancakes::hello_macro();
}

这里从展开的代码能看出derive宏的特性,struct Pancakes;被保留下来了,也就是说最后impl_hello_macro()返回的token被加到结构体后面,输入的token没有被修改。

10.5 类属性宏

类属性过程宏跟 derive 宏类似,但是前者允许我们定义自己的属性。除此之外,derive 只能用于结构体、枚举和union,而类属性宏可以用于其它类型项,例如函数。

假设我们在开发一个 web 框架,当用户通过 HTTP GET 请求访问 / 根路径时,使用 index 函数为其提供服务:

1
2
#[route(GET, "/")]
fn index() {

如上所示,代码功能非常清晰、简洁,这里的 #[route] 属性就是一个过程宏,其宏定义的函数签名看起来像这样:

1
2
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {

derive 宏不同,类属性宏的定义函数有两个参数:

  • 第一个参数时用于说明属性包含的内容:Get, "/" 部分
  • 第二个是属性所标注的类型项,在这里是 fn index() {...},注意,函数体也被包含其中

除此之外,类属性宏跟 derive 宏的工作方式并无区别:创建一个包,类型是 proc-macro,接着实现一个函数用于生成想要的代码。

10.6 类函数宏

类函数宏可以让我们定义像函数那样调用的宏,从这个角度来看,它跟声明式宏 macro_rules 较为类似,它们比函数更灵活;例如,可以接受未知数量的参数。

区别在于,macro_rules 的定义形式与 match 匹配非常相像,而类函数宏的定义形式则类似于之前讲过的两种过程宏,即自定义派生宏的签名:获取括号中的 token,并返回希望生成的代码。

1
2
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {

而使用形式则类似于函数调用:

1
let sql = sql!(SELECT * FROM posts WHERE id=1);

这个宏会解析其中的 SQL 语句并检查其是否是句法正确的,这是比 macro_rules! 可以做到的更为复杂的处理。这个复杂的过程是 macro_rules 难以对付的,而过程宏相比起来就会灵活的多。

10.7 补充学习资料

  1. dtolnay/proc-macro-workshop,学习如何编写过程宏
  2. The Little Book of Rust Macros,学习如何编写声明宏 macro_rules!
  3. synquote ,用于编写过程宏的包,它们的文档有很多值得学习的东西
  4. Structuring, testing and debugging procedural macro crates,从测试、debug、结构化的角度来编写过程宏
  5. blog.turbo.fish,里面的过程宏系列文章值得一读
  6. Rust 宏小册中文版,非常详细的解释了宏各种知识

11 注释

在rust中,注释分为两类:

  • 非文档型注释(Non-documentation comments),用于说明某一块代码的功能。
  • 文档型注释(documentation comments),支持 Markdown,对项目描述、公共 API 等用户关心的功能进行介绍,同时还能提供示例代码,目标读者往往是想要了解你这个crate项目的人。

11.1 非文档注释

所有程序员都力求使其代码易于理解,不过有时还需要提供额外的解释。在这种情况下,程序员在源码中留下注释(comments),编译器会忽略它们,不过阅读代码的人可能觉得有用。

rust代码中的注释一般遵循 C/C++ 风格的行(//)和块(/* ... */)注释形式,也支持嵌套的块注释。非文档型注释编译时被解释为某种形式的空白符。

这是一个简单的单行注释:

1
// hello, world

对于超过一行的注释,需要在每一行前都加上 //,像这样:

1
2
3
// So we’re doing something complicated here, long enough that we need
// multiple lines of comments to do it! Whew! Hopefully, this comment will
// explain what’s going on.

注释也可以放在包含代码的行的末尾:

1
2
3
fn main() {
let lucky_number = 7; // I’m feeling lucky today
}

不过你更经常看到的是以这种格式使用它们,也就是位于它所解释的代码行的上面一行:

1
2
3
4
fn main() {
// I’m feeling lucky today
let lucky_number = 7;
}

多行注释的示例如下:

1
2
3
4
5
/* 
So we’re doing something complicated here, long enough that we need
multiple lines of comments to do it! Whew! Hopefully, this comment will
explain what’s going on.
*

11.2 文档注释

准确的crate文档有助于其他用户理解如何以及何时使用他们,所以花一些时间编写文档是值得的。

rust有特定的用于文档的注释类型,通常被称为文档型注释(documentation comments),他们通常是以三个斜线(///)开始的行文档型注释,以及块文档型注释(/** ... */)。顾名思义,文档型注释会生成 HTML 文档。这些 HTML 展示公有 API 文档注释的内容,意在让对库感兴趣的程序员理解如何使用这个crate,而不是它是如何被实现 的。

行文档注释

文档注释就位于需要文档的项的之前,举例来说:

文件名:src/lib.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
/// Adds one to the number given.
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
x + 1
}

这里,我们提供了一个 add_one 函数工作的描述,接着开始了一个标题为 Examples 的部分,和展示如何使用 add_one 函数的代码。

以上代码有几点需要注意:

  • 文档注释需要位于 lib 类型的包中,例如 src/lib.rs
  • 文档注释可以使用 markdown语法。例如 # Examples 的标题,以及代码块高亮
  • 被注释的对象需要使用 pub 对外可见,记住:文档注释是给用户看的,内部实现细节不应该被暴露出去

可以通过命令:

1
cargo doc

来生成这个文档注释的 HTML 文档。这个命令运行由rust分发的工具 rustdoc 并将生成的 HTML 文档放入target/doc目录。

块文档注释

当注释内容多时,使用块注释可以减少 /// 的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
/** Adds two to the number given.

# Examples
```
let arg = 5;
let answer = my_crate::add_two(arg);

assert_eq!(7, answer);
```
*/
pub fn add_two(x: i32) -> i32 {
x + 2
}

在编写文档时,可以使用# Examples 这样的Markdown标题在 HTML 中创建了一个以 “Examples” 为标题的部分。一些 crate作者经常在文档注释中使用的部分有:

  • Panics:这个函数可能会 panic! 的场景。并不希望程序崩溃的函数调用者应该确保他们不会在这些情况下调用此函数。
  • Errors:如果这个函数返回 Result,此部分描述可能会出现何种错误以及什么情况会造成这些错误,这有助于调用者编写代码来采用不同的方式处理不同的错误。
  • Safety:如果这个函数使用 unsafe 代码,这一部分应该会涉及到期望函数调用者支持的确保 unsafe 块中代码正常工作的不变条件(invariants)。

当然大部分文档注释不需要所有这些部分,也可能有额外的部分,这只是一个参考。

我们可以使用命令:

1
cargo doc --open

可以在生成文档后,自动在浏览器中打开网页,查看刚才编写的文档注释效果。

注释包含项的结构

文档注释风格 //! 为包含注释的项,而不是位于注释之后的项增加文档。这通常用于crate根文件(通常是 src/lib.rs)或模块的根文件为crate或模块整体提供文档。

文件名:src/lib.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//! # My Crate
//!
//! `my_crate` is a collection of utilities to make performing certain
//! calculations more convenient.

/** Adds two to the number given.

# Examples
```
let arg = 5;
let answer = my_crate::add_two(arg);

assert_eq!(7, answer);
```
*/
pub fn add_two(x: i32) -> i32 {
x + 2
}

注意 //! 的最后一行之后没有任何代码。因为他们以 //! 开头而不是 ///,这是属于包含此注释的项而不是注释之后项的文档。在这个情况下时src/lib.rs文件,也就是crate根文件。这些注释描述了整个crate

11.3 文档注释中的代码跳转

跳转到标准库

rust在文档注释中还提供了一个非常强大的功能,那就是可以实现对外部项的链接:

1
2
3
4
/// `add_one` 返回一个[`Option`]类型
pub fn add_one(x: i32) -> Option<i32> {
Some(x + 1)
}

此处的 [Option] 就是一个链接,指向了标准库中的 Option 枚举类型,有两种方式可以进行跳转:

  • 在 IDE 中(如vscode),使用 Command + 鼠标左键(macOS),CTRL + 鼠标左键(Windows)
  • 在文档中直接点击链接

再比如,还可以使用路径的方式跳转:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::sync::mpsc::Receiver;

/// [`Receiver<T>`] [`std::future`].
///
/// [`std::future::Future`] [`Self::recv()`].
pub struct AsyncReceiver<T> {
sender: Receiver<T>,
}

impl<T> AsyncReceiver<T> {
pub async fn recv() -> T {
unimplemented!()
}
}

使用完整路径跳转到指定项

除了跳转到标准库,你还可以通过指定具体的路径跳转到自己代码或者其它库的指定项,例如在 lib.rs 中添加以下代码:

1
2
3
4
5
6
7
8
9
pub mod a {
/// `add_one` 返回一个[`Option`]类型
/// 跳转到[`crate::MySpecialFormatter`]
pub fn add_one(x: i32) -> Option<i32> {
Some(x + 1)
}
}

pub struct MySpecialFormatter;

使用 crate::MySpecialFormatter 这种路径就可以实现跳转到 lib.rs 中定义的结构体上。

同名项的跳转

如果遇到同名项,可以使用标示类型的方式进行跳转:

1
2
3
4
5
6
7
8
9
10
11
12
13
/// 跳转到结构体  [`Foo`](struct@Foo)
pub struct Bar;

/// 跳转到同名函数 [`Foo`](fn@Foo)
pub struct Foo {}

/// 跳转到同名宏 [`foo!`]
pub fn Foo() {}

#[macro_export]
macro_rules! foo {
() => {}
}

跳转到链接

文档注释支持Markdown,因此你可以使用链接跳转到任意地址:

1
2
3
4
5
6
/// Adds one to the number given.
///
/// see [github](https://github.com/rust-lang/rust)
pub fn add_one(x: i32) -> i32 {
x + 1
}

当然,支持Markdown就意味着它也支持HTML,你甚至可以添加js代码

1
2
3
4
5
6
7
/// Adds one to the number given.
///
/// see <a href="https://github.com/rust-lang/rust" style="color:red">github</a>
/// <script>alert(123)</script>
pub fn add_one(x: i32) -> i32 {
x + 1
}

11.4 文档搜索别名

rust文档支持搜索功能,我们可以为自己的类型定义几个别名,以实现更好的搜索展现,当别名命中时,搜索结果会被放在第一位:

1
2
3
4
5
6
#[doc(alias = "x")]
#[doc(alias = "big")]
pub struct BigX;

#[doc(alias("y", "big"))]
pub struct BigY;

12 编写自动化测试

软件测试是证明bug存在的有效方法,但却无法证明错误不存在。

Program testing can be a very effective way to show the presence of bugs, but it is hopelessly inadequate for showing their absence.

Edsger W. Dijkstra, “The Humble Programmer” 1972

程序的正确性意味着代码如我们期望的那样运行。rust是一个相当注重正确性的编程语言,不过正确性是一个难以证明的复杂主题。rust的类型系统在此问题上下了很大的功夫,不过类型系统不可能捕获所有问题。为此,rust包含了编写自动化软件测试的功能支持。

假设我们可以编写一个叫做 add_two 的将传递给它的值加二的函数。它的签名有一个整型参数并返回一个整型值。当实现和编译这个函数时,rust会进行所有目前我们已经见过的类型检查和借用检查,例如,这些检查会确保我们不会传递 String 或无效的引用给这个函数。rust所不能检查的是这个函数是否会准确的完成我们期望的工作:返回参数加二后的值,而不是比如说参数加 10 或减 50 的值,换句话说,rust可以检查语法问题,但无法检测正确语法下的代码逻辑问题。这也就是测试出场的地方。

我们可以编写测试断言,比如说,当传递 3add_two 函数时,返回值是 5。无论何时对代码进行修改,都可以运行测试来确保任何现存的正确行为没有被改变。

12.1 如何编写测试

rust中的测试函数是用来验证非测试代码是否是按照期望的方式运行的。测试函数体通常执行如下三种操作:

  1. 设置任何所需的数据或状态
  2. 运行需要测试的代码
  3. 断言其结果是我们所期望的

让我们看看rust提供的专门用来编写测试的功能:test 属性、一些宏和 should_panic 属性。

测试函数

作为最简单例子,rust中的测试就是一个带有 test 属性注解的函数。属性(attribute)是关于rust代码片段的元数据,之前出现过的 derive 属性就是一个例子,使用它可以派生自动实现的 DebugCopy 等特征,同样的,使用 test 属性,我们也可以获取rust提供的测试特性。

为了将一个函数变成测试函数,需要在 fn 行之前加上 #[test]。当使用 cargo test 命令运行测试时,rust会构建一个测试执行程序用来调用被标注的函数,并报告每一个测试是通过还是失败。

每次使用cargo新建一个库项目时,它会自动为我们生成一个测试模块和一个测试函数。这个模块提供了一个编写测试的模板,为此每次开始新项目时不必去查找测试函数的具体结构和语法了。因为这样当然你也可以额外增加任意多的测试函数以及测试模块。在实际编写测试代码之前,让我们先通过尝试那些自动生成的测试模版来探索测试是如何工作的。接着,我们会写一些真正的测试,调用我们编写的代码并断言他们的行为的正确性。

让我们创建一个新的库项目 adder,它会将两个数字相加:

1
2
3
$ cargo new adder --lib
Created library `adder` project
$ cd adder

自动生成的文件src/lib.rs中的内容应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pub fn add(left: usize, right: usize) -> usize {
left + right
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}

现在让我们暂时忽略 tests 模块和 #[cfg(test)] 注解并只关注函数本身。注意 fn 行之前的 #[test],这个属性表明这是一个测试函数,这样测试执行者就知道将其作为测试处理。tests 模块中也可以有非测试的函数来帮助我们建立通用场景或进行常见操作,必须每次都标明哪些函数是测试。

注意在 tests 模块中有一行:use super::*;tests 是一个普通的模块,它遵循模块的可见性规则。因为这是一个内部模块,要测试外部模块中的代码,需要将其引入到内部模块的作用域中(见:使用super起始的相对路径)。这里选择使用全局导入,以便在 tests 模块中使用所有在外部模块定义的内容。

示例函数体通过使用 assert_eq! 宏来断言 2 加 2 等于 4。一个典型的测试的格式,就是像这个例子中的断言一样。接下来运行就可以看到测试通过。

我们通过cargo test命令运行项目中所有的测试:

1
2
3
4
5
6
7
8
9
10
running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

cargo编译并运行了测试。可以看到 running 1 test 这一行。下一行显示了生成的测试函数的名称,它是 it_works,以及测试的运行结果,ok。接着可以看到全体测试运行结果的摘要:test result: ok. 意味着所有测试都通过了。1 passed; 0 failed 表示通过或失败的测试数量。

可以将一个测试标记为忽略(ignore)这样在特定情况下它就不会运行,因为之前我们并没有将任何测试标记为忽略,所以摘要中会显示 0 ignored。我们也没有过滤需要运行的测试,所以摘要中会显示0 filtered out0 measured 统计是针对性能测试的。见:忽略某些测试

测试输出中的以 Doc-tests adder 开头的这一部分是所有文档测试的结果。我们现在并没有任何文档测试,不过rust会编译任何在 API 文档中的代码示例。这个功能帮助我们使文档和代码保持同步。

现在我们将忽略 Doc-tests 部分的输出,在后面会介绍,见:文档测试

让我们开始自定义测试来满足我们的需求。首先给添加一个新的测试,比如 exploration,像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pub fn add(left: usize, right: usize) -> usize {
left + right
}

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exploration() {
assert_eq!(2 + 2, 5);
}

#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}

这个exploration测试会报错,因为显然2+2不等于5。再次 cargo test 运行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
running 2 tests
test tests::it_works ... ok
test tests::exploration ... FAILED

failures:

---- tests::exploration stdout ----
thread 'tests::exploration' panicked at 'assertion failed: `(left == right)`
left: `4`,
right: `5`', src\lib.rs:10:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
tests::exploration

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

它表明 it_works 测试通过了而 exploration 失败了。在单独测试结果和摘要之间多了两个新的部分:第一个部分显示了测试失败的详细原因。在这个例子中,我们看到 exploration 因为在src/lib.rs的第 10 行未能通过断言而失败的详细信息。下一部分列出了所有失败的测试,这在有很多测试和很多失败测试的详细输出时很有帮助。我们将来可以通过使用失败测试的名称来只运行这个测试,以便调试。

最后是摘要行:总体上讲,测试结果是 FAILED。有一个测试通过和一个测试失败。

使用assert!宏来检查结果

assert! 宏由标准库提供,在希望确保测试中一些条件为 true 时非常有用。需要向 assert! 宏提供一个求值为布尔值的参数。如果值是 trueassert! 什么也不做,同时测试会通过。如果值为 falseassert! 调用 panic! 宏,这会导致测试失败。assert! 宏帮助我们检查代码是否以期望的方式运行。

比如,我们可以编写如下测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pub fn greater_than_two(num: usize) -> bool {
num > 2
}

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_three_greater_than_two() {
let result = greater_than_two(3);
assert!(result);
}

#[test]
fn is_two_greater_than_two() {
let result = greater_than_two(2);
assert!(result);
}
}

很明显,我们这个测试是希望验证greater_than_two的功能:判断num是否大于2。这里创建了两个测试分别传入32进行测试,运行测试的结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
running 2 tests
test tests::is_three_greater_than_two ... ok
test tests::is_two_greater_than_two ... FAILED

failures:

---- tests::is_two_greater_than_two stdout ----
thread 'tests::is_two_greater_than_two' panicked at 'assertion failed: result', src\lib.rs:17:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
tests::is_two_greater_than_two

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

一个成功一个失败了,这符合我们的预期。

使用assert_eq!assert_ne!宏来测试相等

测试功能的一个常用方法是将需要测试代码的值与期望值做比较,并检查是否相等。可以通过向 assert! 宏传递一个使用 == 运算符的表达式来做到。不过这个操作实在是太常见了,以至于标准库提供了一对宏来更方便的处理这些操作 —— assert_eq!assert_ne!。这两个宏分别比较两个值是相等还是不相等。当断言失败时他们也会打印出这两个值具体是什么,以便于观察测试为什么失败,而 assert! 只会打印出它从 == 表达式中得到了 false 值,而不是打印导致 false 的两个值。

我们已经见识过了assert_eq!,现在来看看assert_ne!的使用场景。assert_ne! 宏在传递给它的两个值不相等时通过,而在相等时失败。在代码按预期运行,我们不确定值会是什么,不过能确定值绝对不会是什么的时候,这个宏最有用处。例如,如果一个函数保证会以某种方式改变其输出,不过这种改变方式是由运行测试时是星期几来决定的,这时最好的断言可能就是函数的输出不等于其输入。

assert_eq!assert_ne! 宏在底层分别使用了 ==!=。当断言失败时,这些宏会使用调试格式打印出其参数,这意味着被比较的值必需实现了 PartialEqDebug 特征。所有的基本类型和大部分标准库类型都实现了这些特征。对于自定义的结构体和枚举,需要实现 PartialEq 才能断言它们的值是否相等。需要实现 Debug 才能在断言失败时打印它们的值。因为这两个特征都是派生特征,通常可以直接在结构体或枚举上添加 #[derive(PartialEq, Debug)] 注解。

自定义失败信息

你也可以向 assert!assert_eq!assert_ne! 宏传递一个可选的失败信息参数,可以在测试失败时将自定义失败信息一同打印出来。任何在 assert! 的一个必需参数和 assert_eq!assert_ne! 的两个必需参数之后指定的参数都会传递给 format! 宏,传递一个包含 {} 占位符的格式字符串和需要放入占位符的值。自定义信息有助于记录断言的意义;当测试失败时就能更好的理解代码出了什么问题。

比如说有一个根据人名进行问候的函数,而我们希望测试将传递给函数的人名显示在输出中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pub fn greeting(name: &str) -> String {
format!("Hello {}!", name)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn greeting_contains_name() {
let result = greeting("rust");
assert!(result.contains("Carol"));
}
}

运行测试,报错仅仅告诉了我们断言失败了和失败的行号,并没有更多的信息。一个更有用的失败信息应该打印出 greeting 函数的值。让我们为测试函数增加一个自定义失败信息参数:带占位符的格式字符串,以及 greeting 函数的值:

1
2
3
4
5
6
7
8
9
#[test]
fn greeting_contains_name() {
let result = greeting("rust");
assert!(
result.contains("Carol"),
"Greeting did not contain name, value was `{}`",
result
);
}

这里assert!的第二个和第三个参数会传递给format!

1
2
"Greeting did not contain name, value was `{}`",
result

运行测试,将会看到更有价值的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----
thread 'tests::greeting_contains_name' panicked at 'Greeting did not contain name, value was `Hello rust!`', src\lib.rs:12:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

可以在测试输出中看到所取得的确切的值,这会帮助我们理解真正发生了什么,而不是期望发生什么,并且详细的报错信息将帮助我们更好地进行debug。

使用should_panic检查 panic

除了检查返回值之外,检查代码是否按照期望处理错误也是很重要的,在某些情况下,我们可能想测试某个函数是否会panic。对此, rust提供了 should_panic 属性注解。这个属性在函数中的代码 panic 时会通过,而在其中的代码没有 panic 时失败。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
pub struct Guess {
value: i32,
}

impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {}.", value);
}

Guess { value }
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200);
}
}

#[should_panic] 属性位于 #[test] 之后,对应的测试函数之前。这是一个简单的猜数字游戏,Guess 结构体的 new 方法在传入的值不在 [1,100] 之间时,会直接 panic,而在测试函数 greater_than_100 中,我们传入的值 200 显然没有落入该区间,程序应该panic。运行测试:

1
2
3
4
5
6
7
8
9
10
running 1 test
test tests::greater_than_100 - should panic ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

符合预期,测试通过。现在在代码中引入 bug,移除 new 函数在值大于 100 时会 panic 的条件:

1
2
3
4
5
6
7
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!("Guess value must be between 1 and 100, got {}.", value);
}

Guess { value }
}

再次运行测试:

1
2
3
4
5
6
7
8
9
10
11
12
running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----
note: test did not panic as expected

failures:
tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

测试失败了,开发者去检查这个测试时,会发现它标注了 #[should_panic]。这个错误意味着代码中测试函数 Guess::new(200) 并没有产生 panic,进一步可以定位到出错的函数进行debug。

然而 should_panic 测试结果可能会非常含糊不清。should_panic 甚至在一些不是我们期望的原因而导致 panic 时也会通过。比如这段糟糕的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!(
"Guess value must be greater than or equal to 1, got {}.",
value
);
} else if value > 100 {
panic!(
"Guess value must be less than or equal to 100, got {}.",
value
);
}

Guess { value }
}

这里 new 函数根据其值是过大还或者过小而提供不同的 panic 信息,这会导致测试的结果模糊不清(到底是哪里造成了panic?)。为了使 should_panic 测试结果更精确,我们可以给 should_panic 属性增加一个可选的 expected 参数。测试工具会确保错误信息中包含其提供的文本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
pub struct Guess {
value: i32,
}

impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 {
panic!(
"Guess value must be greater than or equal to 1, got {}.",
value
);
} else if value > 100 {
panic!(
"Guess value must be less than or equal to 100, got {}.",
value
);
}

Guess { value }
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
#[should_panic(expected = "less than or equal to 100")]
fn greater_than_100() {
Guess::new(200);
}
}

这个测试会通过,因为 should_panic 属性中 expected 参数提供的值是 Guess::new 函数 panic 信息的子串。我们可以指定期望的整个 panic 信息,在这个例子中是 Guess value must be less than or equal to 100, got 200.expected 信息的选择取决于 panic 信息有多独特或动态,和你希望测试有多准确。在这个例子中,错误信息的子字符串足以确保函数在 else if value > 100 的情况下运行。

为了观察带有 expected 信息的 should_panic 测试失败时会发生什么,让我们再次引入一个 bug,将 if value < 1else if value > 100 的代码块对换:

1
2
3
4
5
6
7
8
9
10
11
if value < 1 {
panic!(
"Guess value must be less than or equal to 100, got {}.",
value
);
} else if value > 100 {
panic!(
"Guess value must be greater than or equal to 1, got {}.",
value
);
}

这一次运行 should_panic 测试,它会失败:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----
thread 'tests::greater_than_100' panicked at 'Guess value must be greater than or equal to 1, got 200.', src\lib.rs:13:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
panic message: `"Guess value must be greater than or equal to 1, got 200."`,
expected substring: `"less than or equal to 100"`

failures:
tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

失败信息表明测试确实如期望 panic 了,不过 panic 信息中并没有包含 expected 信息 'Guess value must be less than or equal to 100'。而我们得到的 panic 信息是 'Guess value must be greater than or equal to 1, got 200.'

Result<T, E>用于测试

目前为止,我们编写的测试在失败时都会 panic,我们也可以使用 Result<T, E> 编写测试。比如:

1
2
3
4
5
6
7
8
9
10
11
#[cfg(test)]
mod tests {
#[test]
fn it_works() -> Result<(), String> {
if 2 + 2 == 4 {
Ok(())
} else {
Err(String::from("two plus two does not equal four"))
}
}
}

现在 it_works 函数的返回值类型为 Result<(), String>。在函数体中,不同于调用 assert_eq! 宏,而是在测试通过时返回 Ok(()),在测试失败时返回带有 StringErr

这样编写测试来返回 Result<T, E> 就可以在函数体中使用问号运算符?,如此可以方便地编写任何运算符会返回 Err 成员的测试。

不能对这些使用 Result<T, E> 的测试使用 #[should_panic] 注解。为了断言一个操作返回 Err 成员,不要使用对 Result<T, E> 值使用问号表达式(?)。而是使用 assert!(value.is_err())

12.2 控制测试如何运行

就像 cargo run 会编译代码并运行生成的二进制文件一样,cargo test 在测试模式下编译代码并运行生成的测试二进制文件。cargo test 产生的二进制文件的默认行为是并发运行所有的测试,并截获测试运行过程中产生的输出,阻止他们被显示出来,使得阅读测试结果相关的内容变得更容易。不过可以指定命令行参数来改变 cargo test 的默认行为。

使用--分割命令行参数

可以将一部分命令行参数传递给 cargo test,而将另外一部分传递给生成的测试二进制文件。为了分隔这两种参数,需要先列出传递给 cargo test 的参数,接着是分隔符 --,再之后是传递给测试二进制文件的参数。运行 cargo test --help 会提示 cargo test 的有关参数,而运行 cargo test -- --help 可以提示在分隔符之后使用的有关参数。

测试用例的并行或顺序执行

当运行多个测试时,rust默认使用线程来并行运行。这意味着测试会更快地运行完毕,所以你可以更快的得到代码能否工作的反馈。因为测试是在同时运行的,你应该确保测试不能相互依赖,或依赖任何共享的状态,包括依赖共享的环境,比如当前工作目录或者环境变量。

举个例子,每一个测试都运行一些代码,假设这些代码都在硬盘上创建一个test-output.txt文件并写入一些数据。接着每一个测试都读取文件中的数据并断言这个文件包含特定的值,而这个值在每个测试中都是不同的。因为所有测试都是同时运行的,一个测试可能会在另一个测试读写文件过程中修改了文件。那么第二个测试就会失败,并不是因为代码不正确,而是因为测试并行运行时相互干扰。一个解决方案是使每一个测试读写不同的文件;另一个解决方案是一次运行一个测试。

如果你不希望测试并行运行,或者想要更加精确的控制线程的数量,可以传递 --test-threads 参数和希望使用线程的数量给测试二进制文件。例如:

1
cargo test -- --test-threads=1

这里的命令用--分隔开了,第二个参数用于传递给测试二进制文件,这里将测试线程设置为 1,告诉程序不要使用任何并行机制。这也会比并行运行花费更多时间,不过在有共享的状态时,测试就不会有潜在的相互干扰了。

显示函数输出

默认情况下,当测试通过时,rust的测试库会截获打印到标准输出的所有内容。比如在测试中调用了 println! 而测试通过了,我们将不会在终端看到 println! 的输出:只会看到说明测试通过的提示行。如果测试失败了,则会看到所有标准输出和其他错误信息。

例如这里有一个无意义的函数,它打印出其参数的值并接着返回10。接着还有一个会通过的测试和一个会失败的测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fn prints_and_returns_10(a: i32) -> i32 {
println!("I got the value {}", a);
10
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn this_test_will_pass() {
let value = prints_and_returns_10(4);
assert_eq!(10, value);
}

#[test]
fn this_test_will_fail() {
let value = prints_and_returns_10(8);
assert_eq!(5, value);
}
}

运行测试的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
running 2 tests
test tests::this_test_will_pass ... ok
test tests::this_test_will_fail ... FAILED

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8
thread 'tests::this_test_will_fail' panicked at 'assertion failed: `(left == right)`
left: `5`,
right: `10`', src\lib.rs:19:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

注意输出中不会出现测试通过时打印的内容,即 I got the value 4。因为当测试通过时,这些输出会被截获。失败测试的输出 I got the value 8 ,则出现在输出的测试摘要部分,同时也显示了测试失败的原因。

如果你希望也能看到通过的测试中打印的值,也可以在结尾加上 --show-output 告诉rust显示成功测试的输出。

1
cargo test -- --show-output

这次就可以看到成功测试的输出结果了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
running 2 tests
test tests::this_test_will_pass ... ok
test tests::this_test_will_fail ... FAILED

successes:

---- tests::this_test_will_pass stdout ----
I got the value 4


successes:
tests::this_test_will_pass

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8
thread 'tests::this_test_will_fail' panicked at 'assertion failed: `(left == right)`
left: `5`,
right: `10`', src\lib.rs:19:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

通过指定名字来运行部分测试

有时运行整个测试集会耗费很长时间。如果你负责特定位置的代码,你可能会希望只运行与这些代码相关的测试。你可以向 cargo test 传递所希望运行的测试名称的参数来选择运行哪些测试。

首先创建三个测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pub fn add_two(a: i32) -> i32 {
a + 2
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn add_two_and_two() {
assert_eq!(4, add_two(2));
}

#[test]
fn add_three_and_two() {
assert_eq!(5, add_two(3));
}

#[test]
fn one_hundred() {
assert_eq!(102, add_two(100));
}
}

如果没有传递任何参数就运行测试,所有测试都会并行运行。可以向 cargo test 传递任意测试的名称来只运行这个测试,比如:

1
cargo test one_hundred

将只有名为one_hundred的测试被运行:

1
2
3
4
running 1 test
test tests::one_hundred ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s

因为其余两个测试并不匹配这个名称。测试输出在摘要行的结尾显示了 2 filtered out 表明还存在比本次所运行的测试更多的测试没有被运行。

这种方式只能指定单个测试名称,如果想要指定两个名称,下面的方法是行不通的:

1
2
cargo test one_hundred add_two_and_two
cargo test one_hundred,add_two_and_two

我们可以通过指定部分名称的方式来过滤运行相应的测试。因为有两个测试的名称包含 add,可以指定这两个测试名称中共同包含的部分来运行这两个测试:

1
cargo test add

测试结果为:

1
2
3
4
5
running 2 tests
test tests::add_two_and_two ... ok
test tests::add_three_and_two ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

这运行了所有名字中带有 add 的测试,也过滤掉了名为 one_hundred 的测试。同时注意测试所在的模块也是测试名称的一部分,所以可以通过模块名来运行一个模块中的所有测试。比如:

1
cargo test tests

这会运行模块名为tests的所有测试:

1
2
3
4
5
6
running 3 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok
test tests::one_hundred ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

忽略某些测试

有时一些特定的测试执行起来是非常耗费时间的,所以在大多数运行 cargo test 的时候希望能排除他们。虽然可以通过参数列举出所有希望运行的测试来做到,也可以使用 ignore 属性来标记耗时的测试并排除他们,如下所示:

1
2
3
4
5
6
7
8
9
10
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}

#[test]
#[ignore]
fn expensive_test() {
// 需要运行一个小时的代码
}

对于想要排除的测试,我们在 #[test] 之后增加了 #[ignore] 行。现在如果运行测试,就会发现 it_works 运行了,而 expensive_test 没有运行:

1
2
3
4
5
6
7
8
9
10
11
running 2 tests
test expensive_test ... ignored
test it_works ... ok

test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s

Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

expensive_test 被列为 ignored,如果我们只希望运行被忽略的测试,可以使用:

1
cargo test -- --ignored

如果你希望不管是否忽略都要运行全部测试,可以运行:

1
cargo test -- --include-ignored

组合过滤运行测试

我们可以组合使用过滤方法,比如有这样的测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#[cfg(test)]
mod tests {
use super::*;

#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}

#[test]
#[ignore]
fn expensive_test() {
// 需要运行一个小时的代码
}

#[test]
#[ignore]
fn expensive_run() {
// 需要运行一个小时的代码
}
}

运行名称为 tests 的模块中的被忽略的测试函数:

1
cargo test tests -- --ignored

运行名称中带 run 且被忽略的测试函数:

1
cargo test run -- --ignored

通过组合使用这些命令可以更加灵活地运行部分测试。

开发者依赖[dev-dependencies]

如果你了解前端开发,这与package.json文件中的devdependencies一样,Cargo.toml文件中的[dev-dependencies]也是用于开发测试场景使用的依赖,这些依赖往往只在开发时使用而不需要为用户编译。

其中一个例子就是 pretty_assertions,它可以用来扩展标准库中的 assert_eq!assert_ne!,例如提供彩色字体的结果对比。

Cargo.toml 文件中添加以下内容来引入 pretty_assertions

1
2
3
# standard crate data is left out
[dev-dependencies]
pretty_assertions = "1"

然后在 src/lib.rs 文件中添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pub fn add(a: i32, b: i32) -> i32 {
a + b
}

#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq; // 该包仅能用于测试

#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
}

tests 模块中,我们通过 use pretty_assertions::assert_eq; 成功的引入之前添加的包,由于 tests 模块明确的用于测试目的,这种引入并不会报错。 如果在正常代码(非测试代码)中引入该包则不行。

生成测试二进制文件

在有些时候,我们可能希望将测试与别人分享,这种情况下生成一个类似 cargo build 的可执行二进制文件是很好的选择。

事实上,在 cargo test 运行的时候,系统会自动为我们生成一个可运行测试的二进制可执行文件:

1
2
3
$ cargo test
Finished test [unoptimized + debuginfo] target(s) in 0.00s
Running unittests (target/debug/deps/study_cargo-0d693f72a0f49166)

这里的 target/debug/deps/study_cargo-0d693f72a0f49166 就是可执行文件的路径和名称,我们直接运行该文件来执行编译好的测试:

1
2
3
4
5
6
7
8
$ target/debug/deps/study_cargo-0d693f72a0f49166

running 3 tests
test tests::add_two_and_two ... ok
test tests::add_three_and_two ... ok
test tests::one_hundred ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

如果你只想生成编译生成文件,不需要 cargo test 的输出结果,可以使用 cargo test --no-run

12.3 测试的组织结构

测试是一个复杂的概念,而且不同的开发者也采用不同的技术和组织。rust社区倾向于根据测试的两个主要分类来考虑问题:单元测试(unit tests)与集成测试(integration tests)。单元测试倾向于更小而更集中,在隔离的环境中一次测试一个模块,或者是测试私有接口。而集成测试对于你的库来说则完全是外部的。通常在单元测试的基础上,将所有的程序模块进行有序的、递增的测试。它们与其他外部代码一样,通过相同的方式使用你的代码,只测试公有接口而且每个测试都有可能会测试多个模块。

为了保证你的库能够按照你的预期运行,从独立和整体的角度编写这两类测试都是非常重要的。

单元测试

单元测试的目的是在与其他部分隔离的环境中测试每一个单元的代码,以便于快速而准确地验证某个单元的代码功能是否符合预期。单元测试与它们要测试的代码共同存放在相同的文件中。规范是在每个文件中创建包含测试函数的 tests 模块,并使用 cfg(test) 标注模块。

例如在src/lib.rs中的如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
pub fn add_two(a: i32) -> i32 {
a + 2
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn it_works() {
assert_eq!(add_two(2), 4);
}
}

add_two 是我们的项目代码,为了对它进行测试,我们在同一个文件中编写了测试模块 tests,并使用 #[cfg(test)] 进行了标注。

测试模块和#[cfg(test)]

测试模块的 #[cfg(test)] 注解告诉rust只在执行 cargo test 时才编译和运行测试代码,而在运行 cargo build 时不这么做。这在只希望构建库的时候可以节省编译时间,并且因为它们并没有包含测试,所以能减少编译产生的文件的大小。与之对应的集成测试因为位于另一个文件夹,所以它们并不需要 #[cfg(test)] 注解。然而单元测试位于与源码相同的文件中,所以你需要使用 #[cfg(test)] 来指定它们不应该被包含进编译结果中。

cfg 属性代表configuration ,它告诉rust其之后的项只应该被包含进特定配置选项中。在这个例子中,配置选项是 test,即rust所提供的用于编译和运行测试的配置选项。通过使用 cfg 属性,cargo只会在我们主动使用 cargo test 运行测试时才编译测试代码。这包括测试模块中可能存在的帮助函数,以及标注为#[test]的函数。

测试私有函数

测试社区中一直存在关于是否应该对私有函数直接进行测试的论战,而在其他语言中想要测试私有函数是一件困难的,甚至是不可能的事。不过无论你坚持哪种测试意识形态,rust的私有性规则确实允许你测试私有函数。

文件名:src/lib.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pub fn add_two(a: i32) -> i32 {
internal_adder(a, 2)
}

fn internal_adder(a: i32, b: i32) -> i32 {
a + b
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn internal() {
assert_eq!(4, internal_adder(2, 2));
}
}

internal_adder 并没有使用 pub 进行声明,因此它是一个私有函数。tests 作为另一个模块,是无法对它进行调用的,因为它们根本不在同一个模块中。在测试中,我们通过 use super::*test 模块的父模块的所有项引入了作用域,接着测试调用了 internal_adder。如果你并不认为应该测试私有函数,rust也不会强迫你这么做。

集成测试

在rust中,集成测试对于你需要测试的库来说完全是外部的。同其它使用库的代码一样使用库文件,也就是说它们只能调用一部分库中的公有 API。集成测试的目的是测试库的多个部分能否一起正常工作。一些单独能正确运行的代码单元集成在一起也可能会出现问题,所以集成测试的覆盖率也是很重要的。为了创建集成测试,你需要先创建一个tests目录。

tests目录

典型的package结构中介绍过tests目录用于集成测试文件,在这个目录中可以创建任意多的测试文件,cargo会将每一个文件当作单独的crate来编译。

现在来创建一个集成测试。保留上一节src/lib.rs文件中的内容,然后创建一个tests目录,新建一个文件 tests/integration_test.rs。整体的目录结构应该看起来像这样:

1
2
3
4
5
6
7
adder
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── tests
└── integration_test.rs

tests/integration_test.rs中的内容如下:

1
2
3
4
5
6
use adder;

#[test]
fn it_adds_two() {
assert_eq!(4, adder::add_two(2));
}

因为每一个 tests 目录中的测试文件都是完全独立的crate,所以需要在每一个文件中导入库。为此与单元测试不同,我们需要在文件顶部添加use adder

并不需要将tests/integration_test.rs中的任何代码标注为 #[cfg(test)]tests 文件夹在cargo中是一个特殊的文件夹,cargo只会在运行 cargo test 时编译这个目录中的文件。现在就运行 cargo test 试试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Running tests\integration_test.rs (target\debug\deps\integration_test-cce9c73c88782199)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

现在有了三个部分的输出:单元测试、集成测试和文档测试。注意如果一个部分的任何测试失败,之后的部分都不会运行。例如如果一个单元测试失败,则不会有任何集成测试和文档测试的输出,因为这些测试只会在所有单元测试都通过后才会执行。

其中:第一部分单元测试与我们之前见过的一样:每个单元测试一行,接着是一个单元测试的摘要行。

集成测试部分以行 Running tests/integration_test.rs开头。接下来每一行是一个集成测试中的测试函数,以及一个位于 Doc-tests adder 部分之前的集成测试的摘要行。

每一个集成测试文件有对应的测试结果部分,所以如果在tests目录中增加更多文件,测试结果中就会有更多集成测试结果部分。

我们仍然可以通过指定测试函数的名称作为 cargo test 的参数来运行特定集成测试。也可以使用 cargo test--test 后跟文件的名称来运行某个特定集成测试文件中的所有测试:

1
2
3
4
5
6
7
8
9
$ cargo test --test integration_test
Compiling adder v0.1.0 (file:///projects/adder)
Finished test [unoptimized + debuginfo] target(s) in 0.64s
Running tests/integration_test.rs (target/debug/deps/integration_test-82e7799c1bc62298)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

这个命令只运行了tests目录中我们指定的文件 integration_test.rs 中的测试。

集成测试中的子模块

随着集成测试的增加,你可能希望在 tests 目录增加更多文件以便更好的组织它们,例如根据测试的功能来将测试分组。正如之前提到的,每一个 tests 目录中的文件都被编译为单独的crate

将每个集成测试文件当作其自己的crate来对待,这更有助于创建单独的作用域,这种单独的作用域能提供更类似与最终使用者使用crate的环境,但是这样也无法使文件共享相同的行为。

当你有一些在多个集成测试文件都会用到的帮助函数,而你尝试将它们提取到一个通用的模块中时, tests 目录中不同文件的行为就会显得很明显。例如,如果我们可以创建一个 tests/common.rs 文件,在其中创建一个名叫 setup 的函数,我们希望这个函数能被多个测试文件的测试函数调用:

1
2
3
pub fn setup() {
// setup code specific to your library's tests would go here
}

如果再次运行测试,将会在测试结果中看到一个新的对应common.rs文件的测试结果部分,即便这个文件并没有包含任何测试函数,也没有任何地方调用了 setup 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Running tests/common.rs (target/debug/deps/common-92948b65e88960b4)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Running tests/integration_test.rs (target/debug/deps/integration_test-92948b65e88960b4)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

我们并不想要common 出现在测试结果中显示 running 0 tests 。我们只是希望其能被其它多个集成测试文件中调用罢了。

为了不让 common 出现在测试输出中,我们将创建tests/common/mod.rs,而不是创建tests/common.rs 。现在项目目录结构看起来像这样:

1
2
3
4
5
6
7
8
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── tests
├── common
│ └── mod.rs
└── integration_test.rs

这是一种旧的命名规范,正如代码组织总结中提到的,rust仍然支持它们。这样命名告诉rust不要将 common 看作一个集成测试文件。将 setup 函数代码移动到tests/common/mod.rs并删除tests/common.rs文件之后,测试输出中将不会出现这一部分。tests目录中的子目录不会被作为单独的crate编译或作为一个测试结果部分出现在测试输出中。

一旦拥有了tests/common/mod.rs,就可以将其作为模块以便在任何集成测试文件中使用。这里是一个 tests/integration_test.rs中调用 setup 函数的 it_adds_two 测试的例子:

1
2
3
4
5
6
7
8
9
use adder;

mod common;

#[test]
fn it_adds_two() {
common::setup();
assert_eq!(4, adder::add_two(2));
}

这里使用 mod common;声明了子模块,在代码组织总结中提到过,这是一种旧版风格。接着在测试函数中就可以调用 common::setup() 了。

二进制crate的集成测试

如果项目是二进制crate并且只包含src/main.rs而没有src/lib.rs,这样就不可能在tests目录创建集成测试并使用 extern crate 导入src/main.rs中定义的函数。只有库crate才会向其他crate暴露了可供调用和使用的函数;二进制 crate只意在单独运行。

这就是许多rust二进制项目使用一个简单的src/main.rs调用src/lib.rs中的逻辑的原因之一。因为通过这种结构,集成测试就可以通过 extern crate 测试库crate中的主要功能了,而如果这些重要的功能没有问题的话,src/main.rs中的少量代码也就会正常工作且不需要测试。

12.4 文档测试

在文档注释中增加示例代码块是一个清楚的表明如何使用库的方法,这么做还有一个额外的好处:cargo test 也会像测试那样运行文档中的示例代码。没有什么比有例子的文档更好的了,但最糟糕的莫过于写完文档后改动了代码,而导致例子不能正常工作。

文件名:src/lib.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
/// Adds one to the number given.
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = adder::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
x + 1
}

以上的注释不仅仅是文档,还可以作为单元测试的用例运行,使用 cargo test 运行测试,结果如下:

1
2
3
4
5
6
7
8
9
10
running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Doc-tests adder

running 1 test
test src\lib.rs - add_one (line 5) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.26s

可以看到,文档中的测试用例被完美运行,而且输出中也明确提示了 Doc-tests adder,意味着这些测试的名字叫 Doc test 文档测试。尝试改变函数或例子来使例子中的 assert_eq! 产生 panic。再次运行 cargo test,你将会看到文档测试捕获到了例子与代码不再同步,这时就需要我们去debug了。

13 深入所有权和借用

1 堆和栈

栈空间

rust到目前为止还没有明确它所使用的内存布局,相关工作还在进行中,但可以明确的是堆和栈一定存在,这是操作系统中的概念。堆和栈都是内存中的空间,但是rust使用它们的方式不一样。

顾名思义,栈空间是一个后进先出的内存段,一般来说,每个函数都有一个栈帧,调用函数时,将在栈中创建一个栈帧,用来保存该函数的上下文数据。一般情况下会有一个专门的寄存器(SP)跟踪栈的顶部,当调用函数创建新的栈帧时,这个寄存器的值更新为此栈帧的地址,当函数返回且返回值已被读取后,该函数栈帧被移除出栈,出栈的方式很简单,只需更新SP寄存器使其指向上一个栈帧的地址即可。

不仅栈空间中的栈帧是后进先出的,栈帧内部的数据也是后进先出的。比如函数内先创建的局部变量在栈帧的底部,后创建的局部变量在栈帧的顶部。当然,上下顺序并非一定会如此,这和编译器有关,但编写程序时可如此理解。

实际上,有一个BP寄存器专门用来跟踪调用者栈帧的位置。当在函数a中调用函数b时,首先创建函数a的栈帧,当开始调用函数b时,将在栈顶创建函数b的栈帧,并拷贝上一个SP的值到BP,这样BP寄存器就保存了函数a的栈帧地址,当函数b返回时通过BP就可以回到函数a的栈帧。

栈空间的数据一般由编译器自动分配释放,这就需要在编译期知道变量的大小。在编写rust程序时,不用刻意去思考操作系统的栈空间和栈帧的概念,只需要与堆区分开即可。

堆空间

堆空间用于在运行时分配内存。堆空间是一片无人管理的自由内存区,需要时要手动申请,不需要时要手动释放,如果不释放已经无用的堆内存,将导致内存泄漏,内存泄漏过多可能会耗尽内存。

rust不需要手动管理内存的申请与释放,它通过所有权机制来完成内存管理。

2 rust使用堆和栈的方式

有些数据适合存放于堆,有些数据适合存放于栈。

栈适合存放存活时间短的数据

函数内部的局部变量适合存放在栈中,因为函数返回后,该函数中声明的局部变量就没有意义了,随着函数栈帧的释放,该栈中的所有数据也随之消失。

与之对应的,存活时间长的数据通常应该存放在堆空间中。比如多个函数(有不同栈帧)共用的数据应该存放在堆中,这样即使一个函数返回也不会销毁这份数据。

存放在栈中的数据大小必须已知

只有这样,编译器才知道在栈中为该数据分配多少内存。与之对应的,如果无法在编译期间得知数据类型的大小,该数据将不允许存放在栈中,只能存放在堆中。

例如,i32类型的数据存放在栈中,因为i32类型的大小是固定的,无论对它做什么操作,只要它仍然是i32类型,那么它的大小就一定是4字节。而String类型的数据是存放在堆中的,因为String类型的字符串是可变而非固定大小的,最初初始化的时候可能是空字符串,但可以在后期向此空字符串中加入任意长度的字符串,编译器显然无法在编译期间就得知字符串的长度。

使用栈的效率要高于使用堆

将数据存放于栈中时,因为编译器已经知道将要存放于栈中数据的大小,所以编译器总是在栈帧中分配合适大小的内存来存放数据。另一方面,栈中数据的存放方式是后进先出。这相当于编译器总是找好各种大小合适的内存块来存放数据并将内存块放在栈的顶部,而释放栈中数据的方式则是从栈顶拿走这部分内存块的数据。

但是将数据存放于堆中时,当程序运行时会向操作系统申请一片空闲的堆内存空间,然后将数据存放进去。由于堆内存空间是无人管理的自由内存区,操作系统想要从堆中找到空闲空间需要做一些额外操作。更严重的是堆中有大量碎片内存的情况,操作系统可能会将多份小的碎片空闲内存通过链表的方式连接起来组成一个大的空闲空间分配给程序,这样的效率是不及栈的。

rust存放于栈中/堆中的数据

rust中各种类型的值默认都存储在栈中,除非显式地使用Box::new()将它们存放在堆上。但数据要存放在栈中,要求其数据类型的大小已知。对于静态大小的类型,可直接存储在栈上。

例如如下类型的数据存放在栈中:

  • 裸指针(一个机器字长)、普通引用(一个机器字长)、胖指针(除了指针外还包含其他元数据信息,智能指针也是一种带有额外功能的胖指针,而胖指针实际上又是Struct结构)
  • 布尔值
  • 字符类型char
  • 各种整数、浮点数
  • 数组(rust数组的元素数据类型和数组长度都是固定不变的)
  • 元组

除此之外,对于动态大小的类型(vector、String),则数据部分保存在堆中,并在栈中留下胖指针指向实际的数据,栈中的胖指针结构是静态大小的。

以上分类需要注意几点:

  • 将栈中数据赋值给变量时,数据直接存放在栈中。比如i32类型的数值3333直接存放在栈内,而不是在堆中存放33并在栈中存放指向33的指针
  • 因为类型的值默认都分布在栈中(即便是动态类型的数据,但也通过胖指针改变了该类型的值的表现形式),所以创建某个变量的引用时,引用的是栈中的那个值
  • 有些数据是0字节的,不需要占用空间,比如()
  • 尽管容器结构中(如数组、元组、结构体)可以存放任意数据,但保存在容器中的要么是原始类型的栈中值,要么是指向堆中数据的引用,所以这些容器类型的值也在栈中。例如,对于struct User {name: String}name字段存储的是String类型的胖指针,String类型实际的数据则在堆中
  • 尽管Box::new(T)可以将类型T的数据放入堆中,但Box类型本身是一个结构体,它是一个智能指针,保存在栈中

rust除了使用堆栈,还使用全局内存区(静态变量区和字面量区)

编译器会将全局内存区的数据直接嵌入在二进制程序文件中,当启动并加载程序时,嵌入在全局内存区的数据被放入内存的某个位置。

全局内存区的数据是编译期间就可确定的,且存活于整个程序运行期间。

字符串字面量、static定义的静态变量(全局变量)都会硬编码嵌入到二进制程序的全局内存区。

比如:

1
2
3
4
5
6
7
fn main(){
let _s = "hello"; // (1)
let _ss = String::from("hello"); // (2)
let _arr = ["hello";3]; // (3)
let _tuple = ("hello",); // (4)
// ...
}

上面代码中的几个变量都使用了字符串字面量,且使用的都是相同的字面量"hello",在编译期间,它们会共用同一个"hello",该"hello"会硬编码到二进制程序文件中。当程序被加载到内存时,该被放入到全局内存区,它在全局内存区有自己的内存地址,当运行到以上各行代码时:

  • 代码(1)、(3)、(4),将根据地址取得其引用,并分别保存到变量_s_arr各元素、_tuple元素中
  • 代码(2),将根据地址取得数据并将其拷贝到堆中(转换为Vec<u8>的方式存储,也就是String类型的底层存储方式)

常量将在编译期间直接以硬编码的方式内联(inline)插入到使用常量的地方

所谓内联,即将它代表的值直接替换到使用它的地方。

比如,定义了常量const ABC:i32 = 33,在第100行和第300行处都使用了常量ABC,那么在编译期间,会将33硬编码到第100行和第300行处。

rust中,除了const定义的常量会被内联,某些函数也可以被内联。和C语言的inline一样,rust可以通过属性将函数进行内联,表示将该函数对应的代码体直接展开并插入到调用该函数的地方,这样就没有函数调用的开销(比如没有调用函数时申请栈帧、在寄存器保存某些变量等的行为),效率会更高一些。但只有那些频繁调用的短函数才适合被内联,并且内联会导致程序的代码膨胀。具体可参考:The inline attribute

3 通过位置和值理解内存模型

在rust中,非常有必要理解的概念是位置(表达式)和值(表达式),理解这两个概念,对理解rust的内存布局、引用、指针、变量等等都有很大帮助。

  • 位置指的是某一块内存位置,它有自己的地址和空间以及自己所保存的值。位置可能位于栈中,可能位于堆中,也可能位于全局内存区。

  • 值指的是存储到位置中的数据(保存在内存某个位置中的数据)。值的类型有多种,比如数值类型、指针类型、指针(裸指针、胖指针)、字符类型等等。

简单来说,位置一般被称为左值,值一般叫做右值。值表达式引用了某个存储单元地址中的数据,它相当于数据,只能进行读操作。

从语义角度来说,位置表达式代表了持久性数据,值表达式代表了临时数据。位置表达式一般有持久的状态,值表达式要么是字面量,要么就是表达式求值过程中创建的临时值。

理解变量、位置和值的关系

下面通过一个实例来进行说明。当使用let声明变量时,需要产生一个位置来存放数据:

1
2
let a = 1;
let v = vec![1, 2, 3, 4];

内存中的示意图大致如下:

IMG_4025

对于第一句:

  • a称为变量名。变量名是语言层面上提供的一个别名,它是对内存位置的一个人类可读的代号名称,在编译期间,变量名会被移除掉并替换为更低级的代号甚至替换为内存地址
  • 变量名a对应栈中的一个位置,这个位置中保存了值1,这是由于整数类型的大小已知,默认将其保存在栈中
  • 位置有自己的内存地址,比如0x612
  • 有时候,会将这种声明变量时的位置看作是变量(注意不是变量名),或者将变量看作是位置。无论如何看待两者,我们内心需要明确的是,变量或这种位置,是栈中的一块内存
  • 每个位置或变量,都是它所存放的值的所有者。因为每个值都只能存放在一个位置中,所以每个值都只能有一个所有者

对于第二句:

  • 由于这是一个动态类型Vec,它实际保存的值在堆中
  • 这里产生了两个位置,一个位置在堆内存中,用于存放实际数据,它是由一连串空间连续的小位置组成的一个大位置,每个小位置存放了对应的值;第二个位置在栈中,它存放的是Vec的胖指针
  • 这两个位置都有自己的地址,都有自己的值。
  • 其中,栈中的那个位置,是变量声明时显式创建的位置,这个位置代表的是Vec类型的变量,而堆中的位置是自动隐式产生的,这个位置和变量没有关系,唯一的关联是栈中的那个位置中有一根指针指向这个堆中的位置。

需要说明的是,对于上面的VecVec的值指的是存放在栈中那个位置内的数据,而不是堆中的存放的实际数据。也就是说,变量v的值是那个胖指针,而不是堆中的那串实际数据。更严格地说,Vec类型的值,指的是那个胖指针数据,而不是实际数据,变量v是胖指针这个值的所有者,而不是实际数据的所有者。这种变量和值之间的关系和其它某些语言可能有所不同。

理解变量的引用

rust中的引用是一种指针,只不过rust中还附带了其它编译期特有的含义,例如是引用会区分是否可变、引用是借用概念的实现形式。

但不管如何,rust中的引用是一种原始数据类型,它的位置认在栈中,保存的值是一种地址值,这个地址指向它所引用的目标。

这个地址的目标实际上指向它所指向的那个变量(即指向位置)。

例如:

1
2
3
4
let a = 1;
let aa = &a;
let v = vec![1, 2, 3, 4];
let vv = &v;

示意图如下:

IMG_4026

对于这个示例来说,在这个示例中,变量a对应栈中的一个位置,这个位置中保存了数据值1,这个位置有一个地址0x624,而对于变量aa,它也对应栈中的一个位置0x604,这个位置中保存了一个地址值,这个地址的值为0x624,即指向变量a的位置。

为什么引用中的地址不是指向原始数据呢?这可以从位置和值的角度来理解,例如上面的let vv = &v;v是一个位置,这个位置保存的是Vec的胖指针数据,也就是说,v的值是这个胖指针而不是堆中的那块实际数据,所以vv引用v时,引用的是v的位置,而不是实际数据。

另外,rust是一门强调安全的语言,它不允许存在对堆中同一个内存的多个指向,因为这可能会导致重复释放同一块堆内存的危险。换句话说,至始至终,只有最初创建这块堆内存的v变量才指向堆中这块数据。当然,v中的值(即栈中位置中保存的值)可能会被绑定给另外一个变量,那么这个接收变量就会成为唯一一个指向堆中数据的变量。

虽然rust不允许对堆中同一个内存的多个指向,但是却允许对栈中同一个数据的多个指向:

1
2
3
let a = 1;
let b = &a;
let c = &a;

这是因为栈内存由编译器负责维护,编译器知道栈中的某个内存是否安全(比如判断变量是否离开作用域被销毁、判断生命周期),而堆内存是由程序员负责维护,程序员的行为是不安全的。

何时创建位置和值

创建位置的地方主要可以大致总结为:

  • 会产生变量的时候,就会产生位置
  • 需要保存某个值的时候,就会产生位置
  • 会产生新值的时候(例如引用会新产生一个地址值,解引用会产生对应的结果值),就会产生位置
  • 使用值的时候,就会产生位置

以上是显式产生位置的方式,还有隐式产生的位置。例如,在初始化一个Vec并赋值给变量时,堆内存中的那个位置就是隐式创建的。

位置一旦初始化赋值,就会有一个永远不变的地址,直到销毁。换句话说,变量一旦初始化,无论它之后保存的数据发生了什么变化,它的地址都是固定不变的。也说明了,编译器在编译期间就已经安排好了所有位置的分配。

1
2
3
4
5
6
7
8
9
10
fn main() {
let mut n = "hello".to_string(); // n是一个栈中的位置,保存了一个胖指针指向堆中数据
println!("n: {:p}", &n); // &n产生一个位置,该位置中保存指向位置n的地址值

let m = n; // 将n中的胖指针移给了m,m保存胖指针指向堆中数据,n变回未初始化状态
println!("m: {:p}", &m); // &m产生一个位置,该位置中保存指向位置m的地址值

n = "world".to_string(); // 重新为n赋值,位置n保存另一个胖指针,但位置n还是那个位置
println!("n: {:p}", &n); // &n产生一个位置,该位置中保存指向位置n的地址值
}

输出结果:

1
2
3
n: 0x7ffe71c47d60
m: 0x7ffe71c47dd0
n: 0x7ffe71c47d60

4 理解所有权的转移

数据的移动

在rust中没有深浅拷贝的概念,但有移动(move)、拷贝(copy)和克隆(clone)的概念。

看下面的赋值操作,在其他语言中这样赋值是正确的,但在rust中这样的语法会报错。

1
2
3
4
5
6
7
fn main(){
let s1 = String::from("hello");
let s2 = s1;

// 将报错error: borrow of moved value: `s1`
println!("{},{}", s1, s2);
}

上面的示例中,变量s1绑定了String数据,不过这里要注意的是,String数据指的是胖指针结构而不是指堆中的那些实际数据,胖指针指向堆中的实际数据。此时该数据的所有者是s1

当执行let s2 = s1;时,将不会拷贝堆中数据赋值给s2,也不会像其他语言一样让变量s2也绑定堆中数据,即不会拷贝堆数据的引用赋值给s2

因此,下图的内存引用方式不适用于rust。

1605794484331

如果rust采用这种内存引用方式,按照rust的所有权规则,变量在跳出作用域后就销毁堆中对应数据,那么在s1s2离开作用域时会导致二次释放同一段堆内存,这会导致内存污染。

rust采用非常直接的方式,当执行let s2 = s1;时,直接让s1无效(s1这个位置仍然存在,只是变成未初始化变量,rust不允许使用未初始化变量,可重新为其赋值),而是只让s2绑定堆内存的数据。也就是将s1移动到s2,也称为值的所有权从s1转移给s2

1605795164148

所有权移动后修改数据

定义变量的时候,加上mut表示变量可修改。当发生所有权转移时,后拥有所有权的变量也可以加上mut

1
2
3
4
5
6
7
8
9
10
let mut x = String::from("hello");

// x将所有权转移给y,但y无法修改字符串
let y = x;
// y.push('C'); // 本行报错

let a = String::from("hello");
// 虽然a无法修改字符串,但转移所有权后,b可修改字符串
let mut b = a;
b.push('C'); // 本行不报错

移动真的只是移动吗?

比如下面的示例:

1
2
let s1 = String::from("hello");
let s2 = s1;

上面已经分析过,值的所有权会从变量s1转移到变量s2,所有权的转移,涉及到的过程是拷贝到目标变量,同时重置原变量到未初始状态,整个过程就像是进行了一次数据的移动。但注意,上面示例中拷贝的是栈中的胖指针,而不是拷贝堆中的实际数据,因此这样的拷贝效率是相对较高的。

所有权转移之后,将只有新的所有者才会指向堆中的实际数据,而原变量将不再指向堆中实际数据,因此所有权转移之后仍然只有一个指针指向堆中数据。

Move不仅发生在变量赋值过程中,在函数传参、函数返回数据时也会Move,因此,如果将一个大对象(例如包含很多数据的数组,包含很多字段的struct)作为参数传递给函数,是否会让效率很低下?

按照上面的结论来说,确实如此。但rust编译器会对Move语义的行为做出一些优化,简单来说,当数据量较大且不会引起程序正确性问题时,它会传递大对象的指针而非内存拷贝。

此外,对于胖指针类型的变量(如VecString),即使发生了拷贝,其性能也不差,因为拷贝的只是它的胖指针部分。

总之,Move虽然发生了内存拷贝,但它的性能并不会太受影响。

Copy特征

默认情况下,在将一个值保存到某个位置时总是进行值的移动(实际上是拷贝),使得只有目标位置才拥有这个值,而原始变量将变回未初始化状态,也就是暂时不可用的状态。这是rust的Move语义。

rust还有Copy语义,和Move语义几乎相同,唯一的区别是Copy后,原始变量仍然可用。

前面说过,Move实际上是进行了拷贝,只不过拷贝后让原始变量变回未初始化状态了,而Copy的行为,就是保留原始变量。

但rust默认是使用Move语义,如果想要使用Copy语义,要求要拷贝的数据类型实现了Copy特征。

例如,i32默认就已经实现了Copy特征,因此它在进行所有权转移的时候,会自动使用Copy语义,而不是Move语义。

rust中默认实现了Copy的类型,包括但不限于:

  • 所有整数类型,比如u32
  • 所有浮点数类型,比如f64
  • 布尔类型,bool,它的值是truefalse
  • 字符类型,char
  • 元组,当且仅当其包含的类型也都是Copy的时候。比如(i32, i32)Copy的,但(i32, String)不是
  • 共享指针类型或共享引用类型

对于那些没有实现Copy的自定义类型,可以手动去实现Copy,要求同时实现Clone,方式很简单,可以通过自动派生:

1
2
#[derive(Copy, Clone)]
struct Abc(i32, i32);

如何判断变量在堆上还是栈上

这是一个很有趣的问题,见下面的例子:

1
2
3
let s1 = 5;
let s2 = vec![1,2,3,4];
let s3 = vec![Box::new(1),Box::new(2),Box::new(3),Box::new(4)];

这三个变量和变量的值分别保存在堆上还是栈上?

对于变量来说,也就是位置,它们肯定都是在栈上的;对于值,在互斥的CopyDrop章节中提到过,无法为一个类型同时实现 CopyDrop 特征,进一步地,我们又已知基本类型都实现了Copy,那么判断就有了依据:

实现了Copy特征的类型的值保存在栈上,反之,没有实现的类型的值保存在堆上。

这个例子中,5这个值保存在栈上;

由于BoxVec都实现了Drop,那么:vec[1,2,3,4]的四个值保存在堆上,直观的表示如下:

1
2
3
4
5
6
7
8
9
10
(stack)    (heap)
┌──────┐ ┌───┐
│ vec1 │──→│ 1
└──────┘ ├───┤
2
├───┤
3
├───┤
4
└───┘

Box指针保存在堆上且指针指向的值保存在堆上:

1
2
3
4
5
6
7
8
9
10
11
12
(stack)    (heap)   ┌───┐
┌──────┐ ┌───┐ ┌─→│ 1
│ vec2 │──→│ │─┘ └───┘
└──────┘ ├───┤ ┌───┐
│ │───→│ 2
├───┤ └───┘
│ │─┐ ┌───┐
├───┤ └─→│ 3
│ │─┐ └───┘
└───┘ │ ┌───┐
└─→│ 4
└───┘

Clone特征

虽然实现Copy特征可以让原变量继续拥有自己的值,但在某些需求下,不便甚至不能去实现Copy。这时如果想要继续使用原变量,可以使用clone()方法手动拷贝变量的数据,同时不会让原始变量变回未初始化状态。

1
2
3
4
5
6
fn main(){
let s1 = String::from("hello");
// 克隆s1,克隆之后,变量s1仍然绑定原始数据
let s2 = s1.clone();
println!("{},{}", s1, s2);
}

但不是所有数据类型都可以进行克隆,只有那些实现了Clone特征的类型才可以进行克隆,常见的数据类型都已经实现了Clone,因此它们可以直接使用clone()来克隆。

要注意CopyClone时的区别,如果不考虑自己实现Copy特征和Clone特征,而是使用它们的默认实现,那么:

  • Copy时,只拷贝变量本身的值,如果这个变量指向了其它数据,则不会拷贝其指向的数据
  • Clone时,拷贝变量本身的值,如果这个变量指向了其它数据,则也会拷贝其指向的数据

也就是说,Copy是浅拷贝,Clone是深拷贝,rust会对每个字段每个元素递归调用clone(),直到最底部。

1
2
3
4
5
6
7
8
fn main() {
let vb0 = vec!["s1".to_string()];
let v = vec![vb0];
println!("{:p}", &v[0][0]); // 0x21c43a20c50

let vc = v.clone();
println!("{:p}", &vc[0][0]); // 0x21c43a20b70
}

所以,使用Clone的默认实现时,clone()操作的性能是较低的。但可以自己实现自己的克隆逻辑,也不一定总是会效率低。比如Rc,它的clone用于增加引用计数,同时只拷贝少量数据,它的clone效率并不低。

函数参数和返回值的所有权移动

函数参数类似于变量赋值,在调用函数时,会将所有权移动给函数参数。

函数返回时,返回值的所有权从函数内移动到函数外变量。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
fn main(){
let s1 = String::from("hello");

// 所有权从s1移动到f1的参数
// 然后f1返回值的所有权移动给s2
let s2 = f1(s1);
// 注意,println!()不会转移参数s2的所有权
println!("{}", s2);

let x = 4;
f2(x); // 没有移动所有权,而是拷贝一份给f2参数
} // 首先x跳出作用域,
// 然后s2跳出作用域,并释放对应堆内存数据,
// 最后s1跳出作用域,s1没有所有权,所以没有任何其他影响

fn f1(s: String) -> String {
let ss = String::from("world");
println!("{},{}", s,ss);
s // 返回值s的所有权移动到函数外
} // ss跳出作用域

fn f2(i: i32){
println!("{}",i);
} // i跳出作用域

很多时候,变量传参之后丢失所有权是非常不方便的,这意味着函数调用之后,原变量就不可用了。为了解决这个问题,可以将变量的引用传递给参数。引用是保存在栈中的,它实现了Copy特征,因此在传递引用时,所有权转移的过程实际上是拷贝了引用,这样不会丢失原变量的所有权,效率也更高。

5 深入理解Move

所有权的转移并不仅仅只发生在这两种相对比较明显的情况下。例如,解引用操作也需要转移所有权:

1
2
let v = &vec![11, 22];
let vv = *v;

你会得到如下错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 --> src\main.rs:5:9
|
5 | let vv = *v;
| ^^ help: if this is intentional, prefix it with an underscore: `_vv`
|
= note: `#[warn(unused_variables)]` on by default

error[E0507]: cannot move out of `*v` which is behind a shared reference
--> src\main.rs:5:14
|
5 | let vv = *v;
| ^^
| |
| move occurs because `*v` has type `Vec<i32>`, which does not implement the `Copy` trait
| help: consider borrowing here: `&*v`

可以从位置表达式和值的角度来思考,当产生了一个位置,且需要向位置中放入值,就会发生移动(Moved and copied types),只不过,这个值可能来自某个变量,可能来自计算结果(即来自于中间产生的临时变量),这个值的类型可能实现了Copy特征。

对于上面的示例来说,&vec![11, 22]中间产生了好几个临时变量,但最终有一个临时变量是vec的所有者,然后对这个变量进行引用,将引用赋值给变量v。使用*v解引用时,也产生了一个临时变量保存解引用得到的值,而这里就出现了问题。因为变量v只是vec的一个引用,而不是它的所有者,它无权转移值的所有权。

下面是一个容易令人疑惑的示例:

1
2
3
4
5
fn main(){
let x = "hello".to_string();
x; // 发生Move
println!("{}", x); // 报错:value borrowed here after move
}

从这个示例来看,【当值需要放进位置的时候,就会发生移动】,这句话似乎不总是正确,第三行的x;取得了x的值,但是它直接被丢弃了,所以x也被消耗掉了,使得println中使用x报错。实际上,这里也产生了位置,它等价于let _tmp = x;,即将值移动给了一个临时变量。

如果上面的示例不好理解,那下面有时候会排上用场的示例,会有助于理解:

1
2
3
4
5
6
7
fn main() {
let x = "hello".to_string();
let y = {
x // 发生Move,注意没有结尾分号
};
println!("{}", x); // 报错:value borrowed here after move
}

从结果上来看,语句块将x通过返回值的方式移出来赋值给了y,所以认为x的所有权被转移给了y。实际上,语句块中那唯一的一行代码本身就发生了一次移动,将x的所有权移动给了临时变量(参考:Temporaries),然后返回时又发生了一次移动。

14 异步编程

14.1 async异步编程模型

rust采用async异步编程模型,是一种越来越多的编程语言支持的并发编程模型。

并发编程模型不如常规的顺序编程成熟和“标准化”,每个主流语言都对自己的并发模型进行过权衡取舍和精心设计,rust也不例外。下面的列表可以帮助大家理解不同并发模型的取舍:

  • OS threads(OS 线程)不需要对编程模型进行任何更改,这使得并发表达变得非常容易。但是,线程之间的同步可能很困难,而且性能开销很大。线程池可以减轻其中一些成本,但不足以支持大量 IO 绑定的工作负载。
  • Event-driven programming(事件驱动模型)与回调相结合,可以非常高效,但往往会导致冗长的“非线性”控制流。数据流和错误传播通常很难追踪,还会导致代码可维护性和可读性的大幅降低,比如js的“回调地狱”。
  • Coroutines(协程)与线程一样,不需要更改编程模型,这使得它们易于使用。go在语言层面天然支持协程,和 async模型一样,它们也可以支持大量的任务。但是,它们抽象掉了对系统编程和自定义运行时实现者很重要的低级细节。
  • The actor model(actor模型)是 erlang 的杀手锏之一,它将所有并发计算分割成一个一个单元,这些单元被称为 actor , 单元之间通过消息传递的方式进行通信和数据传递,跟分布式系统的设计理念非常相像。由于 actor 模型跟现实很贴近,因此它相对来说更容易实现,但它留下了许多实际问题没有解决,例如流量控制和重试逻辑。
  • async/await模型是rust选择的异步模型,该模型性能高,支持底层编程细节,同时又像线程和协程那样无需过多的改变编程模型,但有得必有失,async模型的问题就是内部实现机制过于复杂,对于用户来说,理解和使用起来也没有线程和协程简单。

总之,rust经过权衡取舍后,最终选择了同时提供多线程编程和 async 编程。不过,尽管许多语言都支持async异步编程(比如js、C#),但一些细节因实现而异。 rust的异步实现在几个方面与大多数语言不同:

  • Future在rust中是惰性的,只有在被轮询(poll)时才会运行, 因此丢弃一个 Future 会阻止它未来再被运行,你可以将Future理解为一个在未来某个时间点被调度执行的任务。
  • async在rust中使用开销是零, 意味着只有你能看到的代码(用户自己的代码)才有性能损耗,你看不到的(async 内部实现)都没有性能损耗,例如,你可以无需分配任何堆内存、也无需任何动态分发来使用 async ,这对于热点路径的性能有非常大的好处,正是得益于此,rust的异步编程性能才会这么高。
  • rust没有内置异步调用所必需的运行时,相反,运行时由社区维护的crate提供,比如tokio
  • rust提供单线程和多线程运行时,它们各有优缺点。

14.2 Async Rust的现状

简而言之,异步 Rust 比同步 Rust 更难使用,并且可能导致更高的维护负担,但作为回报,它会为您提供一流的性能。异步 Rust 的所有领域都在不断改进,因此这些问题的影响会随着时间的推移而减弱。当然,这并不影响我们在生产级项目中使用,因为社区中一些优秀的第三方支持已经很完善了。

语言和库的支持

虽然 Rust 本身支持异步编程,但大多数异步应用程序都依赖于社区包提供的功能。这是因为 async 的底层实现非常复杂,且会导致编译后文件体积显著增加,因此 Rust 没有选择像 Go 语言那样内置了完整的特性和运行时,而是选择了通过 Rust 语言提供了必要的特性支持,再通过社区来提供 async 运行时的支持。 因此要完整的使用 async 异步编程,你需要依赖以下特性和外部库:

  • 标准库提供了最基本的特征、类型和函数,例如 Future 特征。
  • Rust 编译器直接支持 async/await 语法。
  • 众多实用的类型、宏和函数由官方开发的 futures 包提供(不是标准库),它们可以在任何异步 Rust 应用程序中使用。
  • async 代码的执行、IO 操作、任务创建和调度等等复杂功能由社区的 async 运行时提供,例如 tokioasync-std

你可能习惯于同步 Rust 的一些语言特性,这些特性在异步 Rust 中尚不可用。值得注意的是,Rust 不允许在特征中声明异步函数。相反,需要使用变通方法来获得相同的结果,这可能会更加冗长。

自 2022 年 11 月 17 日起,编译器工具链的夜版提供了 async-fn-in-trait 的 MVP(最小化可实行产品,Minimum Viable Product),详情请参见这里

编译和调试

在大多数情况下,异步 Rust 中的编译器和运行时错误的工作方式与它们在 Rust 中的工作方式相同。有一些值得注意的差异:

  • 编译错误,由于 async 编程时需要经常使用复杂的语言特性,例如生命周期和Pin,因此相关的错误可能会出现的更加频繁
  • 运行时错误,编译器会为每一个async函数生成状态机,这会导致在栈跟踪时会包含这些状态机的细节,同时还包含了运行时对函数的调用,因此,栈跟踪记录(例如 panic 时)将变得更加难以解读
  • 一些隐蔽的错误也可能发生,例如在一个 async 上下文中去调用一个阻塞的函数,或者没有正确的实现 Future 特征都有可能导致这种错误。这种错误可能会悄无声息的通过编译检查甚至有时候会通过单元测试。需要深入学习这些异步编程的基本概念,可以尽可能避免这些陷阱,有效地降低遇到这些错误的概率

兼容性考虑

异步和同步代码不能总是自由组合。例如,无法直接从同步函数调用异步函数。同步和异步代码也倾向于促进不同的设计模式,这可能使得编写适用于不同环境的代码变得困难。

即使是异步代码也不能总是自由组合。一些 crate 依赖于特定的异步运行时来运行。如果是这样,它通常在 crate 的依赖列表中指定。

甚至于有时候,异步代码之间也存在类似的问题,如果一个库依赖于特定的 async 运行时来运行,那么这个库非常有必要告诉它的用户,它用了这个运行时。否则一旦用户选了不同的或不兼容的运行时,就会导致不可预知的麻烦。

性能特点

异步 Rust 的性能取决于您正在使用的异步运行时的实现。尽管支持异步 Rust 应用程序的运行时相对较新,但它们对于大多数实际工作负载来说表现得非常好。

目前主流的 async 运行时几乎都使用了多线程实现,相比单线程虽然增加了并发表现,但是对于执行性能会有所损失,因为多线程实现会有同步和切换上的性能开销,若你需要极致的顺序执行性能,那么 async 目前并不是一个好的选择。

另一个被忽视的用例是对延迟敏感的任务,这对驱动程序、GUI 应用程序等很重要。任务的执行次序需要能被严格掌控,而不是交由运行时去自动调度,后者会导致不可预知的延迟。此类任务取决于运行时或操作系统支持,以便进行适当的调度。

以上的两个需求,目前的 async 运行时并不能很好的支持,在未来可能会有更好的支持,但在此之前,可以尝试用多线程解决。

下面开始介绍Future执行器与任务调度。

14.3 Future 特征

Future 特征是 Rust 异步编程的核心。Future 定义为是一种可以产生值的异步计算(尽管该值可能为空,例如 () )。

简化版的 Future 特征如下:

1
2
3
4
5
6
7
8
9
trait SimpleFuture {
type Output;
fn poll(&mut self, wake: fn()) -> Poll<Self::Output>;
}

enum Poll<T> {
Ready(T),
Pending,
}

提到过 Future 需要被执行器poll(轮询)来推进运行,如果在当前 poll 中, Future 完成,则会返回 Poll::Ready(result) ;如果还不能完成,它返回 Poll::Pending ,并且安排一个 wake 函数:当未来 Future 准备好进一步执行时调用 wake 函数。当 wake 被调用时,驱动 Future 的执行者会再次调用 poll ,让 Future 有更多的进展。

如果没有 wake 方法,那执行器无法知道某个 Future 是否可以继续被执行,除非执行器定期的轮询每一个 Future,确认它是否能被执行,但这种作法效率较低。而有了 wakeFuture 就可以主动通知执行器,然后执行器就可以精确的执行该 Future。 这种方式的转变比遍历每一个Future要高效。

作为一个例子,考虑我们想要从可能有或可能没有可用数据的套接字中读取的情况。如果有数据,我们可以读入并返回 Poll::Ready(data) ,但是如果没有数据准备好,我们的Future就被阻塞了,无法再前进。当没有数据可用时,我们必须注册 wake ,以便在套接字上数据准备就绪时调用,这将告诉执行者我们的Future已准备好取得进展。一个简单的 SocketReadFuture 可能看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pub struct SocketRead<'a> {
socket: &'a Socket,
}

impl SimpleFuture for SocketRead<'_> {
type Output = Vec<u8>;

fn poll(&mut self, wake: fn()) -> Poll<Self::Output> {
if self.socket.has_data_to_read() {
// socket有数据,写入buffer中并返回
Poll::Ready(self.socket.read_buf())
} else {
// socket中还没数据
//
// 注册一个`wake`函数,当数据可用时,该函数会被调用,
// 然后当前Future的执行器会再次调用`poll`方法,此时就可以读取到数据
self.socket.set_readable_callback(wake);
Poll::Pending
}
}
}

Future 的这种模型允许将多个异步操作组合在一起而无需临时分配。一次运行多个Future或将Future链接在一起可以通过无分配的状态机实现,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
trait SimpleFuture {
type Output;
fn poll(&mut self, wake: fn()) -> Poll<Self::Output>;
}

enum Poll<T> {
Ready(T),
Pending,
}

/// 一个SimpleFuture,它会并发地运行两个Future直到它们完成
///
/// 之所以可以并发,是因为两个Future的轮询可以交替进行,一个阻塞,另一个就可以立刻执行,反之亦然
pub struct Join<FutureA, FutureB> {
// 结构体的每个字段都包含一个Future,可以运行直到完成.
// 等到Future完成后,字段会被设置为 `None`. 这样Future完成后,就不会再被轮询
a: Option<FutureA>,
b: Option<FutureB>,
}

impl<FutureA, FutureB> SimpleFuture for Join<FutureA, FutureB>
where
FutureA: SimpleFuture<Output = ()>,
FutureB: SimpleFuture<Output = ()>,
{
type Output = ();
fn poll(&mut self, wake: fn()) -> Poll<Self::Output> {
// 尝试去完成一个 Future `a`
if let Some(a) = &mut self.a {
if let Poll::Ready(()) = a.poll(wake) {
self.a.take();
}
}

// 尝试去完成一个 Future `b`
if let Some(b) = &mut self.b {
if let Poll::Ready(()) = b.poll(wake) {
self.b.take();
}
}

if self.a.is_none() && self.b.is_none() {
// 两个 Future都已完成 - 我们可以成功地返回了
Poll::Ready(())
} else {
// 至少还有一个 Future 没有完成任务,因此返回 `Poll::Pending`.
// 当该 Future 再次准备好时,通过调用`wake()`函数来继续执行
Poll::Pending
}
}
}

Future 的这种模型允许将多个异步操作组合在一起而无需中间分配。 类似的,多个Future也可以一个接一个的连续运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/// 一个SimpleFuture, 它使用顺序的方式,一个接一个地运行两个Future
//
// 注意: 由于本例子用于演示,因此功能简单,`AndThenFut` 会假设两个 Future 在创建时就可用了.
// 而真实的`Andthen`允许根据第一个`Future`的输出来创建第二个`Future`,因此复杂的多。
pub struct AndThenFut<FutureA, FutureB> {
first: Option<FutureA>,
second: FutureB,
}

impl<FutureA, FutureB> SimpleFuture for AndThenFut<FutureA, FutureB>
where
FutureA: SimpleFuture<Output = ()>,
FutureB: SimpleFuture<Output = ()>,
{
type Output = ();
fn poll(&mut self, wake: fn()) -> Poll<Self::Output> {
if let Some(first) = &mut self.first {
match first.poll(wake) {
// 我们已经完成了第一个 Future, 可以将它移除, 然后准备开始运行第二个
Poll::Ready(()) => self.first.take(),
// 第一个 Future 还不能完成
Poll::Pending => return Poll::Pending,
};
}

// 运行到这里,说明第一个Future已经完成,尝试去完成第二个
self.second.poll(wake)
}
}

这些示例展示了如何使用 SimpleFuture 特征来表达异步控制流,而不需要多个分配的对象和深度嵌套的回调。了解了基本的控制流程后,让我们来看看真正的 Future 特征及其与SimpleFuture不同之处。

1
2
3
4
5
6
7
8
9
trait Future {
type Output;
fn poll(
// 首先值得注意的地方是,`self`的类型从`&mut self`变成了`Pin<&mut Self>`:
self: Pin<&mut Self>,
// 其次将`wake: fn()` 修改为 `cx: &mut Context<'_>`:
cx: &mut Context<'_>,
) -> Poll<Self::Output>;
}

注意到的第一个变化是我们的 self 类型不再是 &mut Self ,而是变成了 Pin<&mut Self> 。我们将在后面详细讨论Pin,现在只需要知道它允许我们创建在内存中固定的Future。不可移动的对象可以在它们的字段之间存储指针,例如自引用数据结构 struct MyFut { a: i32, ptr_to_a: *const i32 } 。固定是启用 async/await 所必需的。

其次, wake: fn() 已更改为 &mut Context<'_> 。在 SimpleFuture 中,我们使用了对函数指针 ( fn() ) 的调用来告诉未来的执行者应该轮询所讨论的Future。但是,由于 fn() 只是一个函数指针,它不能存储任何关于 Future 调用了 wake 的数据。Context结构体是异步任务的上下文,使用该结构可以在唤醒任务时携带数据,当一个 Future 调用 wake 后,执行器就知道是哪个 Future 调用了 wake,从而调用这个Futurepoll

在真实场景中,像 Web 服务器这样的复杂应用程序可能有数千个不同的连接,它们的唤醒都应该单独管理。 Context 类型通过提供对 Waker 类型值的访问来解决这个问题,该值可用于唤醒特定任务。

14.4 使用 Waker 来唤醒任务

深入Waker与Context的内部

上一节简单提到了为什么要使用Context替换Waker,现在来看看这个接口的Context具体是什么:

1
2
3
4
5
pub struct Context<'a> {
waker: &'a Waker,
_marker: PhantomData<fn(&'a ()) -> &'a ()>,
_marker2: PhantomData<*mut ()>,
}

根据源码可以看到,它将Waker包装在其中,除此之外,还有两个PhantomData的标记字段,这个暂且忽略。我们下面关心的是Waker的定义:

1
2
3
pub struct Waker {
waker: RawWaker,
}

Waker结构内又包装了一个RawWaker,它定义了执行器特定的唤醒行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
pub struct RawWaker {
/// A data pointer, which can be used to store arbitrary data as required
/// by the executor. This could be e.g. a type-erased pointer to an `Arc`
/// that is associated with the task.
/// The value of this field gets passed to all functions that are part of
/// the vtable as the first parameter.
/// 数据指针,可用于存储执行器所需的任意数据。比如,这可以是一个指向[与任务关联的`Arc`]的类型擦除的指针。
/// 该字段的值会作为第一个参数传递给虚函数表中定义的所有函数。
data: *const (),
/// Virtual function pointer table that customizes the behavior of this waker.
/// 自定义此唤醒程序行为的虚函数指针表(vtable)
vtable: &'static RawWakerVTable,
}

它由一个数据指针和一个自定义 RawWaker 行为的虚函数指针表RawWakerVTable组成,虚函数指针表的定义如下:

1
2
3
4
5
6
pub struct RawWakerVTable {
clone: unsafe fn(*const ()) -> RawWaker,
wake: unsafe fn(*const ()),
wake_by_ref: unsafe fn(*const ()),
drop: unsafe fn(*const ()),
}

定义了四个接口,每一个都是函数,由不同的行为触发。具体来说就是

  • clone:当 RawWaker 被克隆时,这个函数将被调用,例如,当存储 RawWakerWaker 被克隆时。此函数的实现必须保留此附加实例 RawWaker 和关联任务所需的所有资源。并且在生成的 RawWaker 上调用 wake 应该会唤醒与原始 RawWaker 唤醒的相同任务。
  • wake:在 Waker 上调用 wake 时将调用此函数。它必须唤醒与此 RawWaker 关联的任务。此函数的实现必须确保释放与此 RawWaker 实例和关联任务关联的所有资源。
  • wake_by_ref:在 Waker 上调用 wake_by_ref 时将调用此函数。它必须唤醒与此 RawWaker 关联的任务。此功能类似于 wake ,但不得使用提供的数据指针。
  • drop:当删除 Waker 时将调用此函数。此函数的实现必须确保释放与此 RawWaker 实例和关联任务关联的所有资源。

由于rust自身并不提供异步运行时,它只在标准库里规定了一些基本的接口,至于怎么实现,可以由各个运行时(如 tokio等)自行决定。

所以在标准库源码中,你只会看到这些接口的定义,以及高层接口的实现,比如Waker下的wake方法,只是调用了 vtable里的wake()而已:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
impl Waker {
/// Wake up the task associated with this `Waker`.
#[inline]
#[stable(feature = "futures_api", since = "1.36.0")]
pub fn wake(self) {
// The actual wakeup call is delegated through a virtual function call
// to the implementation which is defined by the executor.
let wake = self.waker.vtable.wake;
let data = self.waker.data;

// Don't call `drop` -- the waker will be consumed by `wake`.
crate::mem::forget(self);

// SAFETY: This is safe because `Waker::from_raw` is the only way
// to initialize `wake` and `data` requiring the user to acknowledge
// that the contract of `RawWaker` is upheld.
unsafe { (wake)(data) };
}
}

也就是说,如果想继续深入下去查看vtable的具体实现,就需要去查看第三方异步运行时的源码了。比如futures库的waker实现

手动实现一个Future

Future在第一次被 poll 时无法完成是很常见的。发生这种情况时,Future需要确保在准备好取得更多进展后能够再次使用poll对其进行轮询。这是通过 Waker 类型完成的。

每次轮询Future时,都会将其作为task的一部分进行轮询。task是已提交给执行者的顶级Future

Waker 提供了一个 wake() 方法,可以用来告诉执行者关联的任务应该被唤醒。当调用 wake() 时,执行者知道与 Waker 关联的任务已准备好取得进展,应再次轮询其FutureWaker 还实现了 clone() ,因此它可以被复制和存储。

在接下来的小节,让我们尝试使用 Waker 实现一个简单的定时器Future

构造一个定时器

在这个例子中,我们将在创建计时器时启动一个新线程,休眠所需的时间,然后在时间窗口结束时向计时器Future发出信号。

首先,使用 cargo new --lib timer_future 启动一个新项目,并将需要用到的crate导入添加到 src/lib.rs

1
2
3
4
5
6
7
8
use std::{
future::Future,
pin::Pin,
sync::{Arc, Mutex},
task::{Context, Poll, Waker},
thread,
time::Duration,
};

让我们从定义Future类型本身开始。我们的Future需要一种方法让线程传达计时器已经完成并且Future应该完成。我们将使用 Arc<Mutex<..>> 共享状态,在线程和计时器Future之间进行通信。

1
2
3
4
5
6
7
8
9
10
11
12
pub struct TimerFuture {
shared_state: Arc<Mutex<SharedState>>,
}

/// 在Future和等待的线程间共享状态
struct SharedState {
/// 定时(睡眠)是否结束
completed: bool,

/// 当睡眠结束后,线程可以用`waker`通知`TimerFuture`来唤醒任务
waker: Option<Waker>,
}

下面是 Future 的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
impl Future for TimerFuture {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
// 通过检查共享状态,来确定定时器是否已经完成
let mut shared_state = self.shared_state.lock().unwrap();
if shared_state.completed {
// 如果已经完成,返回Ready
Poll::Ready(())
} else {
// 设置`waker`,这样新线程在睡眠(计时)结束后可以唤醒当前的任务,接着再次对`Future`进行`poll`操作,
//
// 下面的`clone`每次被`poll`时都会发生一次,实际上,应该是只`clone`一次更加合理。
// 选择每次都`clone`的原因是: `TimerFuture`可以在执行器的不同任务间移动,如果只克隆一次,
// 那么获取到的`waker`可能已经被篡改并指向了其它任务,最终导致执行器运行了错误的任务
shared_state.waker = Some(cx.waker().clone());
// 还没有准备好
Poll::Pending
}
}
}

代码很简单,只要新线程设置了 shared_state.completed = true ,那任务就能顺利结束。如果没有设置,会为当前的任务克隆一份 Waker ,这样新线程就可以使用它来唤醒当前的任务。

最后,我们需要 API 来实际构造计时器并启动线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
impl TimerFuture {
/// 创建一个新的`TimerFuture`,在指定的时间结束后,该`Future`可以完成
pub fn new(duration: Duration) -> Self {
let shared_state = Arc::new(Mutex::new(SharedState {
completed: false,
waker: None,
}));

// 创建新线程
let thread_shared_state = shared_state.clone();
thread::spawn(move || {
// 睡眠指定时间实现计时功能
thread::sleep(duration);
let mut shared_state = thread_shared_state.lock().unwrap();
// 通知执行器定时器已经完成,可以继续`poll`对应的`Future`了
shared_state.completed = true;
if let Some(waker) = shared_state.waker.take() {
waker.wake()
}
});

TimerFuture { shared_state }
}
}

一个简单的定时器 Future 已经创建成功,现在,需要一个执行器来管理Future,并让其运行起来。

创建执行器

rust 的 Future 是惰性的:除非主动驱动完成,否则它们不会做任何事情。推动 future 完成的一种方法是在 async 函数中对它进行 .await 处理,但这只会将问题推上一个层次:谁将运行从顶级 async 函数返回的 futures?答案是我们需要一个 Future 执行器。

Future 执行者获取一组顶层 Future 并在 Future 可以取得进展时通过调用 poll 来运行它们直到完成。通常,执行者将执行一次 poll 以开始运行Future。当 Future 表示它们已准备好通过调用 wake() 取得进展时,它们将被放回队列并再次调用 poll ,重复直到 Future 完成。

对于这个例子,我们依赖 futures 包来实现 ArcWake 特征,它提供了一种构造 Waker 的简单方法。编辑 Cargo.toml 添加一个新的依赖:

1
2
[dependencies]
futures = "0.3"

接下来,我们需要在 src/main.rs 的顶部导入以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
use futures::{
future::{BoxFuture, FutureExt},
task::{waker_ref, ArcWake},
};
use std::{
future::Future,
sync::mpsc::{sync_channel, Receiver, SyncSender},
sync::{Arc, Mutex},
task::Context,
time::Duration,
};
// 这个是我们在上一节编写的计时器
use timer_future::TimerFuture;

执行器需要从一个消息通道( channel )中拉取事件,执行者将从通道中拉出事件并运行它们。当一个任务准备好做更多的工作(被唤醒)时,它可以通过将自己放回通道来安排自己再次被轮询,然后等待执行器 poll

在这种设计中,执行者本身只需要任务通道的接收端。用户将获得一个发送端,以便他们可以产生新的Future。任务本身只是可以重新安排自己的Future,所以我们将它们存储为与发送者配对的Future,任务可以使用它来重新排队。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/// 任务执行器,负责从通道中接收任务然后执行
struct Executor {
ready_queue: Receiver<Arc<Task>>,
}

/// `Spawner`负责创建新的`Future`然后将它发送到任务通道中
#[derive(Clone)]
struct Spawner {
task_sender: SyncSender<Arc<Task>>,
}

/// 一个Future,它可以调度自己(将自己放入任务通道中),然后等待执行器去`poll`
struct Task {
/// 进行中的Future,在未来的某个时间点会被完成
///
/// 按理来说`Mutex`在这里是多余的,因为我们只有一个线程来执行任务。但是由于
/// Rust并不聪明,它无法知道`Future`只会在一个线程内被修改,并不会被跨线程修改。因此
/// 我们需要使用`Mutex`来证明线程的安全性。
///
/// 如果是生产级的执行器实现,不会使用`Mutex`,因为会带来性能上的开销,取而代之的是使用`UnsafeCell`
future: Mutex<Option<BoxFuture<'static, ()>>>,

/// 可以将该任务自身放回到任务通道中,等待执行器的poll
task_sender: SyncSender<Arc<Task>>,
}

fn new_executor_and_spawner() -> (Executor, Spawner) {
// 任务通道允许的最大缓冲数(任务队列的最大长度)
// 当前的实现仅仅是为了简单,在实际的执行中,并不会这么使用
const MAX_QUEUED_TASKS: usize = 10_000;
let (task_sender, ready_queue) = sync_channel(MAX_QUEUED_TASKS);
(Executor { ready_queue }, Spawner { task_sender })
}

首先,我们需要定义一个消息通道,这里采用同步的通道,关于通道我们在同步通道里介绍过。new_executor_and_spawner函数用于创建一个同步的通道,其中task_sender为发送端,ready_queue为接收端,将其分别放入SpawnerExecutor。然后基于这个通道封装了一个Task任务结构体,它具有两个参数,future就是进行中的任务,task_sender用于将自身放回到通道中。

还需要为Spawner实现一个方法,以便轻松生成新的futures。此方法将采用Future类型,将其装入Box,并在其中创建一个新的 Arc<Task> ,可以将其排队到执行程序中。

在通道中传输的类型使用Arc<Task>,是因为在多线程中传递值需要线程安全的引用计数。

1
2
3
4
5
6
7
8
9
10
impl Spawner {
fn spawn(&self, future: impl Future<Output = ()> + 'static + Send) {
let future = future.boxed();
let task = Arc::new(Task {
future: Mutex::new(Some(future)),
task_sender: self.task_sender.clone(),
});
self.task_sender.send(task).expect("too many tasks queued");
}
}

这个方法的函数签名中,有一点需要注意,future被定义为实现了Future<Output = ()> + 'static + Send特征的任何类型,但是翻遍标准库,也找不到future.boxed()方法。那么调用future.boxed()应该提示找不到方法才对,但这样写是正确的,为什么?

实际上boxed方法被定义在futures包中的FutureExt特征,boxedFutureExt特征中定义的方法,FutureExt 特征 为所有实现了 Future 特征的类型(比如这里的 impl Future<Output = ()>) 提供了一些额外的方法,例如 boxedmapthen 等等,具体可参考futures::future::FutureExt。由于一开始在 src/main.rs 的顶部导入了FutureExt,它实现了boxed方法,因此编译器可以找到该方法,反之,如果在顶部导入时去掉这个特征,编译会报错并提示:

1
error[E0599]: no method named `boxed` found for type parameter `impl Future<Output = ()> + 'static + Send` in the current scope

boxed的函数签名为:

1
fn boxed<'a>(self) -> Pin<Box<dyn Future<Output = Self::Output> + Send + 'a, Global>>

它将futureBox包装,然后将其固定(Pin)并返回,这里留下一个疑问,什么是Pin,为什么要Pin?这个问题留到下一小节介绍,见:Pin与Unpin

接下来,spawn函数创建了一个新的任务Task,并用Arc包装,然后将这个任务发送到通道中。

为了轮询Futures,我们需要创建一个 WakerWaker 负责安排一个任务,以便在调用 wake 后再次轮询。创建新 Waker 的最简单方法是实现 ArcWake 特征,然后使用 waker_ref.into_waker() 函数将 Arc<impl ArcWake> 转换为 Waker 。现在来为我们的任务实现 ArcWake ,让它们变成 Waker 并被唤醒:

1
2
3
4
5
6
7
8
9
10
impl ArcWake for Task {
fn wake_by_ref(arc_self: &Arc<Self>) {
// 通过发送任务到任务管道的方式来实现`wake`,这样`wake`后,任务就能被执行器`poll`
let cloned = arc_self.clone();
arc_self
.task_sender
.send(cloned)
.expect("任务队列已满");
}
}

当任务实现了 ArcWake 特征后,它就变成了 Waker,在调用 wake() 对其唤醒后会将任务复制一份所有权( Arc ),然后将副本发送到任务通道中。

在通道的另一头,执行者需要获取任务并轮询。让我们来实现这部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
impl Executor {
fn run(&self) {
while let Ok(task) = self.ready_queue.recv() {
// 获取一个future,若它还没有完成(仍然是Some,不是None),则对它进行一次poll并尝试完成它
let mut future_slot = task.future.lock().unwrap();
if let Some(mut future) = future_slot.take() {
// 基于任务自身创建一个 `LocalWaker`
let waker = waker_ref(&task);
let context = &mut Context::from_waker(&*waker);
// `BoxFuture<T>`是`Pin<Box<dyn Future<Output = T> + Send + 'static>>`的类型别名
// 通过调用`as_mut`方法,可以将上面的类型转换成`Pin<&mut dyn Future + Send + 'static>`
if future.as_mut().poll(context).is_pending() {
// Future还没执行完,因此将它放回任务中,等待下次被poll
*future_slot = Some(future);
}
}
}
}
}

恭喜!我们终于拥有了自己的执行器,下面再来写一段代码使用该执行器去运行之前的定时器 Future

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn main() {
let (executor, spawner) = new_executor_and_spawner();

// 生成一个任务
spawner.spawn(async {
println!("howdy!");
// 创建定时器Future,并等待它完成
TimerFuture::new(Duration::new(2, 0)).await;
println!("done!");
});

// drop掉任务,这样执行器就知道任务已经完成,不会再有新的任务进来
drop(spawner);

// 运行执行器直到任务队列为空
// 任务运行后,会先打印`howdy!`, 暂停2秒,接着打印 `done!`
executor.run();
}

执行者和系统IO

在上一节Future 特征中,我们讨论了这个在套接字上执行异步读取的 future 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pub struct SocketRead<'a> {
socket: &'a Socket,
}

impl SimpleFuture for SocketRead<'_> {
type Output = Vec<u8>;

fn poll(&mut self, wake: fn()) -> Poll<Self::Output> {
if self.socket.has_data_to_read() {
// socket有数据,写入buffer中并返回
Poll::Ready(self.socket.read_buf())
} else {
// socket中还没数据
//
// 注册一个`wake`函数,当数据可用时,该函数会被调用,
// 然后当前Future的执行器会再次调用`poll`方法,此时就可以读取到数据
self.socket.set_readable_callback(wake);
Poll::Pending
}
}
}

这个 future 将读取套接字上的可用数据,如果没有可用数据,它将让出当前线程的所有权,请求在套接字再次可读时唤醒其任务。但是,从这个例子中并不清楚 Socket 类型是如何实现的,尤其是 set_readable_callback 函数,并不知道是如何工作的。一旦套接字变得可读,我们如何安排调用 wake() ?一种选择是让一个线程持续检查 socket 是否可读,并在适当的时候调用 wake() 。然而,这将是非常低效的,需要为每个阻塞的 IO futures创建一个单独的线程。这会大大降低我们异步代码的效率。

在实践中,这类问题通常是通过操作系统提供的IO多路复用机制完成。例如 Linux 上的 epoll 、FreeBSD 和 Mac OS 上的 kqueue 、Windows 上的 IOCP 和 Fuchsia 上的 port (所有这些通过跨平台 Rust crate mio 公开)。这些原语都允许线程阻塞多个异步 IO 事件,一旦其中一个事件完成就返回。实际上,这些 API 通常看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
struct IoBlocker {
/* ... */
}

struct Event {
// Event的唯一ID,该事件发生后,就会被监听起来
id: usize,

// 一组需要等待或者已发生的信号
signals: Signals,
}

impl IoBlocker {
/// 创建需要阻塞等待的异步IO事件的集合
fn new() -> Self { /* ... */ }

/// 对指定的IO事件表示兴趣
fn add_io_event_interest(
&self,

/// 事件所绑定的socket
io_object: &IoObject,

event: Event,
) { /* ... */ }

/// 进入阻塞,直到某个事件出现
fn block(&self) -> Event { /* ... */ }
}

let mut io_blocker = IoBlocker::new();
io_blocker.add_io_event_interest(
&socket_1,
Event { id: 1, signals: READABLE },
);
io_blocker.add_io_event_interest(
&socket_2,
Event { id: 2, signals: READABLE | WRITABLE },
);
let event = io_blocker.block();

// 当socket的数据可以读取时,打印 "Socket 1 is now READABLE"
println!("Socket {:?} is now {:?}", event.id, event.signals);

Futures执行器可以使用这些原语来提供异步 IO 对象,例如可以配置在特定 IO 事件发生时运行的回调的套接字。在上面的 SocketRead 示例中, Socket::set_readable_callback 函数可能类似于以下伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
impl Socket {
fn set_readable_callback(&self, waker: Waker) {
// `local_executor` 是对本地执行器的引用
// 这可以在创建套接字时提供,但在实践中
// 为了方便,许多执行器实现通过线程本地存储空间传递它
let local_executor = self.local_executor;

// 该IO对象的唯一ID。
let id = self.id;

// 将本地waker存储在执行器的映射中,一旦IO事件到达时以便根据map获取到对应的waker然后调用
local_executor.event_map.insert(id, waker);
local_executor.add_io_event_interest(
// socker文件描述符
&self.socket_file_descriptor,
Event { id, signals: READABLE },
);
}
}

这样,我们只需要一个执行器线程,它会接收 IO 事件并将其分发到对应的 Waker 中,接着后者会唤醒相关的任务,最终通过执行器 poll 后,任务可以顺利地继续执行,这种 IO 读取流程可以不停的循环,直到 socket 关闭。

14.5 async/await语法

语法糖与脱糖

async 是rust中的关键字,它用于告诉编译器一个函数可以被异步处理,这个关键字可以看作是语法糖。

让我们用一个非常轻量级的例子来描绘它。假设我们有一个函数应该返回一个数字,但应该能够异步处理。使用提到的关键字,可以编写以下代码:

1
2
3
async fn give_number() -> u32 {
100
}

使用 async ,编译器会将函数脱糖为返回实现了 Future 的函数,类似于下面的代码所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn give_number() -> impl Future<Output = u32> {
GiveNumberFuture
}

struct GiveNumberFuture {}

impl Future for GiveNumberFuture {
// 原本异步函数的返回类型
type Output = u32;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
// 返回封装在`Poll::Ready`状态中的异步函数的值的表达式
Poll::Ready(100)
}
}

如果给一个普通的函数返回impl Future<OutPut>类型,它的行为和async fn是一致的。

两种使用acync的方式

第一种就是async fn,也就是函数定义:

1
async fn foo() -> u8 { 5 }

前面提到了,foo()会被脱糖为返回实现了Future<Output = u8>类型的值,foo().await将返回类型为u8的值。

第二种是async块:

1
2
3
4
5
6
fn bar() -> impl Future<Output = u8> {
async {
let x: u8 = foo().await;
x + 5
}
}

这里async块同样返回实现了Future<Output = u8>类型。

async的生命周期

与传统函数不同,采用引用或其他非 'static 参数的 async fn 会返回受参数生命周期限制的 Future

1
async fn foo(x: &u8) -> u8 { *x }

这等价于下面的:

1
2
3
fn foo<'a>(x: &'a u8) -> impl Future<Output = u8> + 'a {
async move { *x }
}

这意味着从 async fn 返回的Future必须被 .await 编辑,而它的非 'static 参数仍然有效。也就是说x必须比 Future活得更久。在调用函数后立即调用Future.await 的常见情况下(如 foo(&x).await ),不会有问题。但是,如果存储Future或将其发送到另一个任务或线程,这可能是一个问题。

下面这段代码会报错,因为x的生命周期只到bad函数的结尾,但是borrow_x返回的Future会活得更久:

1
2
3
4
5
6
7
8
use std::future::Future;

fn bad() -> impl Future<Output = u8> {
let x = 5;
borrow_x(&x) // ERROR: `x` does not live long enough
}

async fn borrow_x(x: &u8) -> u8 { *x }

一种解决方案是,将带有引用作为参数的 async fn 转换为 'staticFuture,方法是将参数与对 async fn 的调用捆绑在 async 块中,通过将参数移动到 async 块内, 将它的生命周期扩展到 'static, 并跟返回的Future保持了一致:

1
2
3
4
5
6
7
8
9
10
use std::future::Future;

async fn borrow_x(x: &u8) -> u8 { *x }

fn good() -> impl Future<Output = u8> {
async {
let x = 5;
borrow_x(&x).await
}
}

async move

async可以配合使用move关键字来将环境中变量的所有权转移到语句块内,就像闭包那样,好处是不用解决借用生命周期的问题,坏处就是无法跟其它代码实现对变量的共享。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 多个不同的 `async` 语句块可以访问同一个本地变量,只要它们在该变量的作用域内执行
async fn blocks() {
let my_string = "foo".to_string();

let future_one = async {
// ...
println!("{my_string}");
};

let future_two = async {
// ...
println!("{my_string}");
};

// 运行两个 Future 直到完成
let ((), ()) = futures::join!(future_one, future_two);
}

// 由于`async move`会捕获环境中的变量,因此只有一个`async move`语句块可以访问该变量,
// 但是它也有非常明显的好处: 变量可以转移到返回的 Future 中,不再受借用生命周期的限制
fn move_block() -> impl Future<Output = ()> {
let my_string = "foo".to_string();

async move {
// ...
println!("{my_string}");
}
}

14.6 Pin与Unpin

要轮询Futures,必须使用称为 Pin<T> 的特殊类型固定它们。但首先要了解什么是Pin<T>,我们先从非异步讲起。

为何需要Pin

自引用与循环引用中,介绍了如何构建一个自引用的结构,比如通过unsafe 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#[derive(Debug)]
struct Test {
a: String,
b: *const String, // 改成指针
}

impl Test {
fn new(txt: &str) -> Self {
Test {
a: String::from(txt),
b: std::ptr::null(),
}
}

fn init(&mut self) {
let self_ref: *const String = &self.a;
self.b = self_ref;
}

fn a(&self) -> &str {
&self.a
}

fn b(&self) -> &String {
unsafe {&*(self.b)}
}
}

我们来试一下这个自引用结构体的move

1
2
3
4
5
6
7
8
9
10
11
12
fn main() {
let mut test1 = Test::new("test1");
test1.init();
let mut test2 = Test::new("test2");
test2.init();

println!("a: {}, b: {}", test1.a(), test1.b());
// 使用swap()函数交换两者,这里发生了move
std::mem::swap(&mut test1, &mut test2);
test1.a = "I've totally changed now!".to_string();
println!("a: {}, b: {}", test2.a(), test2.b());
}

这里初始化了两个自引用结构,值分别为test1test2std::mem::swap用于交换两个可变位置的值,这是打印结果:

1
2
a: test1, b: test1
a: test1, b: I've totally changed now!

可以发现这与我们期望的结果不符,问题出在哪里?原因是test1结构体中的字段b是一个指向字段a的指针,它在栈上存的是字段a的地址。通过swap()函数交换两个Test结构体之后,字段ab分别移动到对方的内存区域上,但是ab本身的内容没有变。也就是指针b依然指向的是原来的地址,但是这个地址现在已经属于另外一个结构体了,这不仅不是自引用结构体,更可怕的是这个指针可能导致更危险的问题,这是rust决不允许出现的。下面这张图可以帮助理解:

swap_problem

由于rust中的异步编程async/await是基于自引用结构体实现的。如果不能从根源上解决这个问题,rust号称的内存安全的根基就完全动摇了。接下来我们找一下导致这个问题的根源,然后想办法从根源上解决它。

问题的根源

我们发现上面的例子最关键的一行代码是std::mem::swap(&mut test1, &mut test2),就是它导致了我们自引用结构体失效引发了内存安全问题。从这个角度出发,只要避免这个swap()函数应用到我们自引用结构体上,那就解决了自引用结构体移动导致内存安全的问题。

可是怎么去避免呢?我们先来看一下swap()方法的签名:

1
pub fn swap<T>(x: &mut T, y: &mut T)

它的参数要求是可变借用&mut,所以只要我们想办法在不使用unsafe的情况下不暴露可变借用即可。

还是以Test为例,它自身没办法限制自己不能可变借用,因为我们直接用&mut Test{...}就可以轻松拿到。从标准库中去找找,Box<T>呢?先不考虑它性能问题,我们把结构体T包裹在Box中,看Box能不能保证不暴露&mut T出去。看一下API文档,很遗憾不能。Box::leak()返回值就是&mut T,更甚者Box实现了DerefMut,就算不用leak()我们也可以通过* &mut Box<T>解引用轻松拿到&mut T

事实上在Pin出现之前的标准库中确实没有这样的API能够防止在仅使用安全的rust的情况下不暴露&mut T

Pin的作用

我们找到了问题的根源在哪,Pin就是从根源上解决这个问题的。Pin 是一个结构体:

1
2
3
pub struct Pin<P> {
pub pointer: P,
}

这是一种指针的包装器,能确保该指针指向的数据不会被移动,除非它实现了 Unpin 特征。例如 Pin<&mut T> , Pin<&T> , Pin<Box<T>> ,都能确保 T 不会被移动。

v2-de79f3a7a401588d671ecd121916cd90_1440w

Unpin是一个特征:

1
pub auto trait Unpin {}

如果一个类型实现了Unpin,表明这个类型可以被移动,这是一个默认实现,这意味着几乎所有类型都实现了Unpin

与之对应的,可以被 Pin 住的值实现的特征是 !Unpin! 代表没有实现某个特征的意思,!Unpin 说明类型没有实现 Unpin 特征,那自然就可以被 Pin 了。

那是不是意味着类型如果实现了 Unpin 特征,就不能被 Pin 了?其实,还是可以 Pin 的,毕竟它只是一个结构体,你可以随意使用,但是不再有任何效果而已,该值一样可以被移动。

例如 Pin<&mut u8> ,显然 u8 实现了 Unpin 特征,它可以在内存中被移动,因此 Pin<&mut u8>&mut u8 实际上并无区别,一样可以被移动。因此,一个类型如果不能被移动,它必须实现 !Unpin 特征。

举个例子来说,对于一个P<T>智能指针,如果它实现了Unpin特征,使用Pin<P<T>>包裹后,Pin会提供两种办法可以在“Safe Rust“下拿到&mut T

  • 第一种,使用:Pin::get_mut()

    1
    2
    3
    4
    5
    pub const fn get_mut(self) -> &'a mut T
    where T: Unpin,
    {
    self.pointer
    }
  • 第二种, Pin 也对数据实现了 DerefMut

    1
    2
    3
    4
    5
    impl<P: DerefMut<Target: Unpin>> DerefMut for Pin<P> {
    fn deref_mut(&mut self) -> &mut P::Target {
    Pin::get_mut(Pin::as_mut(self))
    }
    }

如果要去掉P<T>自动实现的Unpin,即(!Unpin),也有两种方法:

  • 使用PhantomPinned标记类型

    1
    2
    3
    4
    5
    6
    7
    8
    use std::marker::PhantomPinned;

    #[derive(Debug)]
    struct Test {
    a: String,
    b: *const String,
    _marker: PhantomPinned,
    }
  • 给自己手动impl !Unpin。前提是要使用nightly版本,并且需要引入#![feature(negative_impls)]

    1
    2
    3
    4
    5
    6
    7
    8
    #![feature(negative_impls)]
    #[derive(Debug)]
    struct Test {
    a: String,
    b: *const String,
    }

    impl !Unpin for Test {}

只要P<T>实现的是!UnpinPin<P<T>>就会保证没办法在“Safe Rust“下拿到可变借用&mut T,拿不到&mut T,就没办法作用到std::mem::swap()上,这样就从根源上解决了不安全的问题。

不过,Pin还提供了一个unsafeget_unchecked_mut()方法,不管P<T>有没有实现Unpin,你都可以通过调用这个方法拿到&mut T

1
2
3
pub const unsafe fn get_unchecked_mut(self) -> &'a mut T {
self.pointer
}

这个函数是不安全的。你必须保证你永远不会将数据移出你在调用此函数时收到的可变引用,以便可以维护 Pin 类型的不变量。换句话说,rust将权限交给用户,用户需要遵守需要遵守Pin的契约,否则后果自负。

Pin的契约:对于Pin<P<T>>

  • 如果P<T>符合Unpin,那P<T>从被Pin包裹到被销毁,都要一直保证P<T>不被钉住
  • 如果P<T>符合!Unpin,那P<T>从被Pin包裹到被销毁,都要一直保证P<T>被钉住

Pin的保证

上文提到Pin保证指针被“钉住”,何谓“钉住”?在标准库文档中提到:在高层次上, Pin<P> 确保任何指针类型 P 的指针在内存中都有一个稳定的位置,这意味着它不能移动到其他地方,并且在它被删除之前不能释放它的内存。这里提到了两个关键,一个是不能移动,另外一个是在被删除之前不能释放。由于“钉住”的目的是为了能够依赖一些数据在内存中的位置。要做到这一点,不仅要限制移动数据;释放、重新利用或以其他方式使用于存储数据的内存无效也受到限制。具体来说,对于固定数据,必须保持不变,即从它被固定到调用 drop 时,它的内存不会失效或被重新利用。只有当 drop 返回或出现 panic 时,内存才可能被重用。

构造Pin

在真正地解决问题之前,首先我们要梳理清楚怎样把P<T>Pin包裹起来,也就是怎样构造一个Pin

  • 使用new方法:

    1
    2
    3
    4
    5
    6
    7
    impl<P: Deref<Target: Unpin>> Pin<P> {
    pub const fn new(pointer: P) -> Pin<P> {
    // SAFETY: the value pointed to is `Unpin`, and so has no requirements
    // around pinning.
    unsafe { Pin::new_unchecked(pointer) }
    }
    }

    如果你的P指向的TUnpin的话,你可以安全地调用Pin::new()构造一个Pin。可以看到它底层实际上是调用unsafePin::new_unchecked(),之所以Pin::new()是安全的,是因为Unpin的情况下Pin的”钉住“效果是不起作用的,跟正常的指针一样。

  • 使用Pin::new_unchecked方法:

    1
    2
    3
    4
    5
    impl<P: Deref> Pin<P> {    
    pub const unsafe fn new_unchecked(pointer: P) -> Pin<P> {
    Pin { pointer }
    }
    }

    这个方法很简单,但它是unsafe的,该方法围绕对可能实现也可能不实现 Unpin 的某些数据类型的引用构造一个新的 Pin<P> 。标为unsafe的原因是编译器没办法保证使用者后续操作一定遵守Pin的契约。只要有存在违反契约的可能性,就必须用unsafe标记,因为这是使用者的问题,编译器没办法保证。如果使用者通过Pin::new_unchecked()构造一个Pin<P<T>>之后Pin的生命周期结束了,但P<T>依然存在,则后续操作依然可能被move,造成内存不安全。

  • 其它方法,比如Box::pin(), Rc::pin()Arc::pin()等,它们底层调用的都是Pin::new_unchecked方法。

使用Pin解决问题

接下来我们使用Pin来修复一下上面自引用结构体的问题。Pin可以分为栈上还是堆上,取决于你要Pin的那个指针P是在栈上还是堆上。比如Pin<&mut T>是栈上,Pin<Box<T>>是在堆上。

固定到栈上
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
use std::pin::Pin;
use std::marker::PhantomPinned;

#[derive(Debug)]
struct Test {
a: String,
b: *const String,
_marker: PhantomPinned,
}

impl Test {
fn new(txt: &str) -> Self {
Test {
a: String::from(txt),
b: std::ptr::null(),
_marker: PhantomPinned, // This makes our type `!Unpin`
}
}

fn init<'a>(self: Pin<&'a mut Self>) {
let self_ptr: *const String = &self.a;
let this = unsafe { self.get_unchecked_mut() };
this.b = self_ptr;
}

fn a<'a>(self: Pin<&'a Self>) -> &'a str {
&self.get_ref().a
}

fn b<'a>(self: Pin<&'a Self>) -> &'a String {
unsafe { &*(self.b) }
}
}

pub fn main() {
let mut test1 = unsafe { Pin::new_unchecked(&mut Test::new("test1")) };
Test::init(test1.as_mut());

let mut test2 = unsafe { Pin::new_unchecked(&mut Test::new("test2")) };
Test::init(test2.as_mut());

println!("a: {}, b: {}", Test::a(test1.as_ref()), Test::b(test1.as_ref()));
std::mem::swap(test1.get_mut(), test2.get_mut());
println!("a: {}, b: {}", Test::a(test2.as_ref()), Test::b(test2.as_ref()));
}

我们尝试把&mut Test钉在栈上,然后尝试去调用get_mut()作为参数传给std::mem::swap(),发现编译不通过。编译器从编译阶段就阻止我们去犯错了。

有一点需要注意,固定到栈上将始终依赖于你在编写 unsafe 时提供的保证,虽然我们知道 &'a mut T 的指针在 'a 的生命周期内被固定,但我们不知道 &'a mut T 指向的数据是否在 'a 结束后没有移动。如果这样做,它将违反Pin的契约。

一个容易犯的错误是忘记隐藏原始变量,因为你可以删除 Pin 并将数据移动到 &'a mut T 之后,比如:

1
2
3
4
5
6
7
8
9
10
11
12
fn main() {
let mut test1 = Test::new("test1");
let mut test1_pin = unsafe { Pin::new_unchecked(&mut test1) };
Test::init(test1_pin.as_mut());

drop(test1_pin);
println!(r#"test1.b points to "test1": {:?}..."#, test1.b);

let mut test2 = Test::new("test2");
mem::swap(&mut test1, &mut test2);
println!("... and now it points nowhere: {:?}", test1.b);
}
固定到堆上

!Unpin 类型固定到堆中可以为我们的数据提供一个稳定的地址,因此我们知道我们指向的数据在固定后无法移动。与栈固定相反,我们知道数据将在对象的生命周期内固定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
use std::pin::Pin;
use std::marker::PhantomPinned;

#[derive(Debug)]
struct Test {
a: String,
b: *const String,
_marker: PhantomPinned,
}

impl Test {
fn new(txt: &str) -> Pin<Box<Self>> {
let t = Test {
a: String::from(txt),
b: std::ptr::null(),
_marker: PhantomPinned,
};
let mut boxed = Box::pin(t);
let self_ptr: *const String = &boxed.a;
unsafe { boxed.as_mut().get_unchecked_mut().b = self_ptr };

boxed
}

fn a(self: Pin<&Self>) -> &str {
&self.get_ref().a
}

fn b(self: Pin<&Self>) -> &String {
unsafe { &*(self.b) }
}
}

pub fn main() {
let test1 = Test::new("test1");
let test2 = Test::new("test2");

println!("a: {}, b: {}",test1.as_ref().a(), test1.as_ref().b());
println!("a: {}, b: {}",test2.as_ref().a(), test2.as_ref().b());
}

一些功能要求他们使用的futures是 Unpin的 。要将不是 UnpinFutureStream 与需要 Unpin 类型的函数一起使用,首先必须使用 Box::pin (创建 Pin<Box<T>> )或 pin_utils::pin_mut! 宏(创建 Pin<&mut T> )。 Pin<Box<Fut>>Pin<&mut Fut> 都可以用作futures,并且都实现了 Unpin 。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use pin_utils::pin_mut; // `pin_utils` is a handy crate available on crates.io

// A function which takes a `Future` that implements `Unpin`.
fn execute_unpin_future(x: impl Future<Output = ()> + Unpin) { /* ... */ }

let fut = async { /* ... */ };
execute_unpin_future(fut); // Error: `fut` does not implement `Unpin` trait

// Pinning with `Box`:
let fut = async { /* ... */ };
let fut = Box::pin(fut);
execute_unpin_future(fut); // OK

// Pinning with `pin_mut!`:
let fut = async { /* ... */ };
pin_mut!(fut);
execute_unpin_future(fut); // OK

14.7 Future与Pin

现在,问题回到异步编程,为什么异步编程需要Pin,回顾一下Future特征:

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

2018年官方异步组引入Pin API的初衷就是为了解决Future内部自引用的问题。那为什么Future内部存在自引用呢,还是通过一个例子开始。

一个Future状态机的例子

来看这样一个简单的async函数:

1
2
3
4
5
6
async fn write_hello_file_async(name: &str) -> anyhow::Result<()> {
let mut file = fs::File::create(name).await?;
file.write_all(b"hello world!").await?;

Ok(())
}

首先它创建一个文件,然后往这个文件里写入hello world!。这个函数有两个await,创建文件的时候会异步创建,写入文件的时候会异步写入。最终,整个函数对外返回一个Future

函数可以这样被调用:

1
write_hello_file_async("/tmp/hello").await?;

由于处理器在处理Future时,会持续调用poll方法,于是,上面那句实际上相当于:

1
2
3
4
match write_hello_file_async.poll(cx) {
Poll::Ready(result) => return result,
Poll::Pending => return Poll::Pending
}

这是单个await的处理方法,更复杂一点,如果深入到write_hello_file_async内函数实现内部,它有两个await,处理的方法又是如何呢?显然,只有在处理完create()创建文件后,才能处理write_all()写入数据,所以,应该是类似这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let fut = fs::File::create(name);

match fut.poll(cx) {
Poll::Ready(Ok(file)) => {

// 只有在处理完 create(),才能处理 write_all()
let fut = file.write_all(b"hello world!");

match fut.poll(cx) {
Poll::Ready(result) => return result,
Poll::Pending => return Poll::Pending,
}
}
Poll::Pending => return Poll::Pending,
}

前面提到过,async函数本质上脱糖为返回impl Future类型的返回值,因此,还需要把这样的代码封装在一个Future 的实现里,对外提供出去。因此,我们需要实现一个数据结构,把内部的状态保存起来,并为这个数据结构实现Future。比如这里的类型WriteHelloFile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
enum WriteHelloFile {
// 初始阶段,用户提供文件名
Init(String),

// 等待文件创建,此时需要保存 Future 以便多次调用
// 这是伪代码,impl Future 不能用在这里
AwaitingCreate(impl Future<Output = Result<fs::File, std::io::Error>>),

// 等待文件写入,此时需要保存 Future 以便多次调用
AwaitingWrite(impl Future<Output = Result<(), std::io::Error>>),

// Future 处理完毕
Done,
}

// 实现构造方法方便创建
impl WriteHelloFile {
pub fn new(name: impl Into<String>) -> Self {
Self::Init(name.into())
}
}

// 为这个类型实现Future
impl Future for WriteHelloFile {
type Output = Result<(), std::io::Error>;

fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
todo!()
}
}

fn write_hello_file_async(name: &str) -> WriteHelloFile {
WriteHelloFile::new(name)
}

这样,我们就把刚才的write_hello_file_async异步函数,转化成了一个返回WriteHelloFile,也就是实现了Future特征的函数,这二者是等价的。

下面就是为todo!部分添加Future的具体实现了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
impl Future for WriteHelloFile {
type Output = Result<(), std::io::Error>;

fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
loop {
match this {
// 如果状态是 Init,那么就生成 create Future,把状态切换到 AwaitingCreate
WriteHelloFile::Init(name) => {
let fut = fs::File::create(name);
*self = WriteHelloFile::AwaitingCreate(fut);
}
// 如果状态是 AwaitingCreate,那么 poll create Future
// 如果返回 Poll::Ready(Ok(_)),那么创建 write Future
// 并把状态切换到 Awaiting
WriteHelloFile::AwaitingCreate(fut) => match fut.poll(cx) {
Poll::Ready(Ok(file)) => {
let fut = file.write_all(b"hello world!");
*self = WriteHelloFile::AwaitingWrite(fut);
}
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
Poll::Pending => return Poll::Pending,
},
// 如果状态是 AwaitingWrite,那么 poll write Future
// 如果返回 Poll::Ready(_),那么状态切换到 Done,整个 Future 执行成功
WriteHelloFile::AwaitingWrite(fut) => match fut.poll(cx) {
Poll::Ready(result) => {
*self = WriteHelloFile::Done;
return Poll::Ready(result);
}
Poll::Pending => return Poll::Pending,
},
// 整个 Future 已经执行完毕
WriteHelloFile::Done => return Poll::Ready(Ok(())),
}
}
}
}

可以看到,这个Future完整实现的内部结构 ,其实就是一个状态机的迁移。这也是async脱糖后的具体实现,与我们表面上看到的:

1
2
3
4
5
6
async fn write_hello_file_async(name: &str) -> anyhow::Result<()> {
let mut file = fs::File::create(name).await?;
file.write_all(b"hello world!").await?;

Ok(())
}

是等价的。

Future状态机实现存在的问题

在上面实现Future的状态机中,我们引用了file这样一个局部变量:

1
2
3
4
5
6
7
8
9
WriteHelloFile::AwaitingCreate(fut) => match fut.poll(cx) {
Poll::Ready(Ok(file)) => {
let fut = file.write_all(b"hello world!");
*self = WriteHelloFile::AwaitingWrite(fut);
}

Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
Poll::Pending => return Poll::Pending,
}

这个代码是有问题的,filefut引用,但file会在这个作用域被丢弃。所以需要把它保存在数据结构中,我们可以生成一个AwaitingWriteData数据结构,把filefut都放进去,然后在WriteHelloFile中引用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum WriteHelloFile {
// 初始阶段,用户提供文件名
Init(String),
// 等待文件创建,此时需要保存 Future 以便多次调用
AwaitingCreate(impl Future<Output = Result<fs::File, std::io::Error>>),
// 等待文件写入,此时需要保存 Future 以便多次调用
AwaitingWrite(AwaitingWriteData),
// Future 处理完毕
Done,
}

struct AwaitingWriteData {
fut: impl Future<Output = Result<(), std::io::Error>>,
file: fs::File,
}

此时,在同一个数据结构AwaitingWriteData内部,fut指向了对file的引用,发现问题了吗,这就是自引用结构!

问题的根源我们提到,我们需要避免自引用结构在“safe rust“下暴露可变引用&mut,否则就会出现问题。如果async函数中存在跨await的引用,在编译时生成这样的自引用匿名结构体会impl Future,当执行器调用Future::poll()函数查询状态的时候,需要一个可变引用(即&mut Self,因为是跨await的),如果不把它固定住,就相当于允许其随意操作自引用结构,包括但不限于调用std::mem::swap()之类的函数。这就是poll()必须要使用Pin<&mut Self>的原因。

14.8 Stream

Stream特征

Stream trait类似于 Future 但可以在完成之前产生多个值,类似于标准库中的 Iteratortrait:

1
2
3
4
5
6
7
8
9
trait Stream {
// Stream生成的值的类型
type Item;

// 尝试去解析Stream中的下一个值,
// 若无数据,返回`Poll::Pending`, 若有数据,返回 `Poll::Ready(Some(x))`, `Stream`完成则返回 `Poll::Ready(None)`
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>)
-> Poll<Option<Self::Item>>;
}

关于 Stream 的一个常见例子是消息通道( futures 包中的)的消费者 Receiver。每次有消息从 Send 端发送后,它都可以接收到一个 Some(val) 值, 一旦 Send 端关闭( drop ),且消息通道中没有消息后,它会接收到一个 None 值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async fn send_recv() {
const BUFFER_SIZE: usize = 10;
let (mut tx, mut rx) = mpsc::channel::<i32>(BUFFER_SIZE);

tx.send(1).await.unwrap();
tx.send(2).await.unwrap();
drop(tx);

// `StreamExt::next` 类似于 `Iterator::next`, 但是前者返回的不是值,而是一个 `Future<Output = Option<T>>`,
// 因此还需要使用`.await`来获取具体的值
assert_eq!(Some(1), rx.next().await);
assert_eq!(Some(2), rx.next().await);
assert_eq!(None, rx.next().await);
}

迭代与并发

与同步 Iterator 类似,有许多不同的方法可以迭代和处理 Stream 中的值。有组合器样式的方法,例如 mapfilterfold ,以及它们的错误时提前返回的版本 try_maptry_filtertry_fold

但是跟迭代器又有所不同,for 循环无法在这里使用,但是命令式风格的循环while let是可以用的,同时还可以使用nexttry_next 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async fn sum_with_next(mut stream: Pin<&mut dyn Stream<Item = i32>>) -> i32 {
use futures::stream::StreamExt; // 引入 next
let mut sum = 0;
while let Some(item) = stream.next().await {
sum += item;
}
sum
}

async fn sum_with_try_next(
mut stream: Pin<&mut dyn Stream<Item = Result<i32, io::Error>>>,
) -> Result<i32, io::Error> {
use futures::stream::TryStreamExt; // 引入 try_next
let mut sum = 0;
while let Some(item) = stream.try_next().await? {
sum += item;
}
Ok(sum)
}

上面代码是一次处理一个值的模式,但是需要注意的是:如果你选择一次处理一个值的模式,可能会造成无法并发,这就失去了异步编程的意义。要同时处理流中的多个项目,使用 for_each_concurrenttry_for_each_concurrent 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async fn jump_around(
mut stream: Pin<&mut dyn Stream<Item = Result<u8, io::Error>>>,
) -> Result<(), io::Error> {
use futures::stream::TryStreamExt; // 引入 `try_for_each_concurrent`
const MAX_CONCURRENT_JUMPERS: usize = 100;

stream.try_for_each_concurrent(MAX_CONCURRENT_JUMPERS, |num| async move {
jump_n_times(num).await?;
report_n_jumps(num).await?;
Ok(())
}).await?;

Ok(())
}

14.9 一次运行多个Future

到目前为止,我们主要通过使用 .await 来执行futures,它会阻塞当前任务,直到特定的 Future 完成。然而,真正的异步应用程序往往需要同时执行几个不同的操作。

现在将介绍一些同时执行多个异步操作的方法。

join!

futures 包中提供了很多实用的工具,其中一个就是 join! 宏, 它允许我们同时等待多个不同 Future 的完成,且可以并发地运行这些 Future

在执行多个异步操作时,很容易将它们简单地 .await 成一个系列:

1
2
3
4
5
async fn get_book_and_music() -> (Book, Music) {
let book = get_book().await;
let music = get_music().await;
(book, music)
}

但是,这会比想象中的慢,因为在 get_book 完成之前它不会开始尝试 get_music 。因为在某些语言中,Future一旦创建就开始运行,等到返回的时候,基本就可以同时结束并返回了,比如用这样的代码:

1
2
3
4
5
6
// WRONG -- don't do this
async fn get_book_and_music() -> (Book, Music) {
let book_future = get_book();
let music_future = get_music();
(book_future.await, music_future.await)
}

但是rust中的Future是惰性的,直到调用.await时,才会开始运行。而那两个await由于在代码中有先后顺序,因此它们是顺序运行的。要同时正确运行两个futures,可以使用 futures::join!

1
2
3
4
5
6
7
use futures::join;

async fn get_book_and_music() -> (Book, Music) {
let book_fut = get_book();
let music_fut = get_music();
join!(book_fut, music_fut)
}

join! 返回的值是一个元组,其中包含传入的每个 Future 的输出。

join_all

如果希望同时运行一个数组里的多个异步任务,可以使用 futures::future::join_all ,返回的Future将驱动其所有基础future的执行,按照提供的顺序将结果收集到 Vec<T>

1
2
3
4
5
6
7
use futures::future::join_all;

async fn foo(i: u32) -> u32 { i }

let futures = vec![foo(1), foo(2), foo(3)];

assert_eq!(join_all(futures).await, [1, 2, 3]);

try_join!

由于 join! 必须等待它管理的所有 Future 完成后才能完成,如果你希望在某一个 Future 报错后就立即停止所有 Future 的执行,可以使用 try_join!,特别是当 Future 返回 Result 时:

1
2
3
4
5
6
7
8
9
10
use futures::try_join;

async fn get_book() -> Result<Book, String> { /* ... */ Ok(Book) }
async fn get_music() -> Result<Music, String> { /* ... */ Ok(Music) }

async fn get_book_and_music() -> Result<(Book, Music), String> {
let book_fut = get_book();
let music_fut = get_music();
try_join!(book_fut, music_fut)
}

有一点需要注意,传给 try_join! 的所有 Future 都必须拥有相同的错误类型。如果错误类型不同,可以考虑使用来自 futures::future::TryFutureExt 模块的 map_errerr_info 方法将错误进行转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
use futures::{
future::TryFutureExt,
try_join,
};

async fn get_book() -> Result<Book, ()> { /* ... */ Ok(Book) }
async fn get_music() -> Result<Music, String> { /* ... */ Ok(Music) }

async fn get_book_and_music() -> Result<(Book, Music), String> {
let book_fut = get_book().map_err(|()| "Unable to get book".to_string());
let music_fut = get_music();
try_join!(book_fut, music_fut)
}

select!

join! 只有等所有 Future 结束后,才能集中处理结果,如果你想同时等待多个 Future ,且任何一个 Future 结束后,都可以立即被处理,可以考虑使用 futures::select!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use futures::{
future::FutureExt, // for `.fuse()`
pin_mut,
select,
};

async fn task_one() { /* ... */ }
async fn task_two() { /* ... */ }

async fn race_tasks() {
let t1 = task_one().fuse();
let t2 = task_two().fuse();

pin_mut!(t1, t2);

select! {
() = t1 => println!("task one completed first"),
() = t2 => println!("task two completed first"),
}
}

上面的函数将同时运行 t1t2 。当 t1t2 完成时,相应的处理程序将调用 println! ,并且该函数将结束而不会完成剩余的任务。

select! 的基本语法是 <pattern> = <expression> => <code>,,就像match语法一样。在 select 结束时重复尽可能多的future

如果多个futures准备就绪,将在运行时伪随机选择一个。直接传递给select!future必须实现Unpin并实现 FusedFutureUnpin 是必需的,因为 select 使用的future不是按值获取的,而是按可变引用获取的。不过,如果是将产生 Future 的表达式传递给 select!(例如 async fn 调用)而不是按名称传递,则对 Unpin 的要求放宽,因为宏会将生成的 Future 固定在堆栈。但是,表达式传递的 Future 仍必须实现 FusedFuture

至于 FusedFuture 是什么,可以把它翻译为熔断(fused), FusedFuture 是必需的,因为 select!的特性为:不能在完成后轮询futureFusedFuture 由跟踪它们是否已完成的future实现。这使得在循环中使用 select 成为可能,只轮询尚未完成的future

尚未实现FusedFuturefuturesstreams可以使用 .fuse() 方法进行融合。但是请注意,如果 select! 调用处于循环中,那么在对 select! 的调用中直接为futuresstreams实现FusedFuture将不足以阻止它在完成后被轮询,因此当 select! 在循环中时,用户应该注意循环外的 fuse()

select! 可以作为表达式使用,返回所选分支的返回值。因此, select! 中每个分支的返回类型必须相同。

examples:

1
2
3
4
5
6
7
8
9
10
use futures::future;
use futures::select;
let mut a = future::ready(4);
let mut b = future::pending::<()>();

let res = select! {
a_res = a => a_res + 1,
_ = b => 0,
};
assert_eq!(res, 5);

如前所述, select 可以直接选择返回 Future 的表达式——即使这些表达式没有实现 Unpin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use futures::future::FutureExt;
use futures::select;

// Calling the following async fn returns a Future which does not
// implement Unpin
async fn async_identity_fn(arg: usize) -> usize {
arg
}

let res = select! {
a_res = async_identity_fn(62).fuse() => a_res + 1,
b_res = async_identity_fn(13).fuse() => b_res,
};
assert!(res == 63 || res == 13);

如果在 select 之外调用类似的异步函数来生成 Future ,则必须固定 Future 以便能够将其传递给 select 。这可以通过 Box::pinFuture 固定在堆上或通过 pin_mut! 宏将 Future 固定在堆栈上来实现,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use futures::future::FutureExt;
use futures::select;
use futures::pin_mut;

// Calling the following async fn returns a Future which does not
// implement Unpin
async fn async_identity_fn(arg: usize) -> usize {
arg
}

let fut_1 = async_identity_fn(1).fuse();
let fut_2 = async_identity_fn(2).fuse();
let mut fut_1 = Box::pin(fut_1); // Pins the Future on the heap
pin_mut!(fut_2); // Pins the Future on the stack

let res = select! {
a_res = fut_1 => a_res,
b_res = fut_2 => b_res,
};
assert!(res == 1 || res == 2);

select 还接受一个 complete 分支和一个 default 分支。如果所有futuresstreams都已用完, complete 将运行。如果没有立即准备好的futuresstreamsdefault 将运行。

一个很实用但又鲜为人知的函数是 Fuse::terminated(),它允许构建一个已经终止的空future,稍后可以用需要运行的future填充。

当有一个任务需要在 select! 循环期间运行但它是在 select! 循环本身内部创建时,这会很方便。

另外这里还有 .select_next_some() 函数的使用。这可以与 select 一起使用以仅针对从stream返回的 Some(_) 值运行分支,而忽略 None

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
use futures::{
future::{Fuse, FusedFuture, FutureExt},
stream::{FusedStream, Stream, StreamExt},
pin_mut,
select,
};

async fn get_new_num() -> u8 { /* ... */ 5 }

async fn run_on_new_num(_: u8) { /* ... */ }

async fn run_loop(
mut interval_timer: impl Stream<Item = ()> + FusedStream + Unpin,
starting_num: u8,
) {
let run_on_new_num_fut = run_on_new_num(starting_num).fuse();
let get_new_num_fut = Fuse::terminated();
pin_mut!(run_on_new_num_fut, get_new_num_fut);
loop {
select! {
() = interval_timer.select_next_some() => {
// 定时器已结束,若`get_new_num_fut`没有在运行,就创建一个新的
if get_new_num_fut.is_terminated() {
get_new_num_fut.set(get_new_num().fuse());
}
},
new_num = get_new_num_fut => {
// 收到新的数字 -- 创建一个新的`run_on_new_num_fut`并丢弃掉旧的
run_on_new_num_fut.set(run_on_new_num(new_num).fuse());
},
// 运行 `run_on_new_num_fut`
() = run_on_new_num_fut => {},
// 若所有任务都完成,直接 `panic`, 原因是 `interval_timer` 应该连续不断的产生值,而不是结束
//后,执行到 `complete` 分支
complete => panic!("`interval_timer` completed unexpectedly"),
}
}
}

当某个 Future 有多个拷贝都需要同时运行时,可以使用 FuturesUnordered 类型。下面的例子跟上个例子大体相似,但是它会将 run_on_new_num_fut 的每一个拷贝都运行到完成,而不是像之前那样一旦创建新的就终止旧的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
use futures::{
future::{Fuse, FusedFuture, FutureExt},
stream::{FusedStream, FuturesUnordered, Stream, StreamExt},
pin_mut,
select,
};

async fn get_new_num() -> u8 { /* ... */ 5 }

async fn run_on_new_num(_: u8) -> u8 { /* ... */ 5 }


// 使用从 `get_new_num` 获取的最新数字 来运行 `run_on_new_num`
//
// 每当计时器结束后,`get_new_num` 就会运行一次,它会立即取消当前正在运行的`run_on_new_num` ,
// 并且使用新返回的值来替换
async fn run_loop(
mut interval_timer: impl Stream<Item = ()> + FusedStream + Unpin,
starting_num: u8,
) {
let mut run_on_new_num_futs = FuturesUnordered::new();
run_on_new_num_futs.push(run_on_new_num(starting_num));
let get_new_num_fut = Fuse::terminated();
pin_mut!(get_new_num_fut);
loop {
select! {
() = interval_timer.select_next_some() => {
// 定时器已结束,若 `get_new_num_fut` 没有在运行,就创建一个新的
if get_new_num_fut.is_terminated() {
get_new_num_fut.set(get_new_num().fuse());
}
},
new_num = get_new_num_fut => {
// 收到新的数字 -- 创建一个新的 `run_on_new_num_fut` (并没有像之前的例子那样丢弃掉旧值)
run_on_new_num_futs.push(run_on_new_num(new_num));
},
// 运行 `run_on_new_num_futs`, 并检查是否有已经完成的
res = run_on_new_num_futs.select_next_some() => {
println!("run_on_new_num_fut returned {:?}", res);
},
// 若所有任务都完成,直接 `panic`, 原因是 `interval_timer` 应该连续不断的产生值,而不是结束
//后,执行到 `complete` 分支
complete => panic!("`interval_timer` completed unexpectedly"),
}
}
}

14.10 待续

rust对于异步编程支持仍然是很新的状态,并且有一些功能仍在积极开发中,有许多不稳定的特性。如果后续异步rust有任何进展,会在这里继续补充和修改。

四、Baby steps

1 版本

rust的版本指的是rust编译器版本,主要分为三个互不冲突的概念:语义化版本(Semantic Versioning),发行版本,Edition。

1.1 语义化版本

根据语义化版本的规定,语义化版本格式为:主版本号.次版本号.修订号,版本号递增规则如下:

  1. 主版本号:当你做了不兼容的 API 修改,
  2. 次版本号:当你做了向下兼容的功能性新增,
  3. 修订号:当你做了向下兼容的问题修正。

2005年rust的第一个语义化版本0.1.0发布,在主版本号为0时进行了快速迭代,直到2015年的第一个正式版本1.0.0发布,再到如今1.x.x的版本。而自从rust有了1.x.x版本以来,它就承诺永远不会破坏用1.0.01.x.x之间的版本编写的代码,这就意味着rust是向后兼容的。

具体的语义化版本会随着项目开发更新下去,可以参考:Rust Changelogs

1.2 发行版本

无停滞稳定

作为一个语言,rust十分注重代码的稳定性。rust希望成为你代码坚实的基础,假如持续地有东西在变,这个希望就实现不了。但与此同时,如果不能实验新功能的话,在发布之前rust开发团队又无法发现其中重大的缺陷,而一旦发布便再也没有修改的机会了。

对于这个问题的解决方案被称为无停滞稳定(stability without stagnation),其指导性原则是:无需担心升级到最新的稳定版rust。每次升级应该是无痛的,并应带来新功能,更少的 bug 和更快的编译速度。

发布通道和发布时刻表(Riding the Trains)

rust开发运行于一个发布时刻表(train schedule)之上,也就是说,所有的开发工作都位于rust源码仓库的 master 分支。发布采用software release train模型,该模型已被 Cisco IOS 和其他软件项目使用。 rust有三个发布渠道:

  • Nightly夜版
  • Betaβ版
  • Stable稳定版

image-20230409152022643

如图,在rust的github源码仓库里,可以看到这三个主要的分支,其它的分支会随着项目的变化而变化。其中master分支就对应稳定版。大部分rust开发者主要采用稳定版通道,不过希望实验新功能的开发者可能会使用nightlybeta版。

如下是一个开发和发布过程如何运转的例子:假设 Rust 团队正在进行 Rust 1.5 的发布工作。该版本发布于 2015 年 12 月,不过这里只是为了提供一个真实的版本。Rust 新增了一项功能:一个 master 分支的新提交。每天晚上,会产生一个新的 nightly 版本。每天都是发布版本的日子,而这些发布由发布基础设施自动完成。所以随着时间推移,发布轨迹看起来像这样,版本一天一发:

1
2
3
4
5
nightly: * - - * - - * - - * - - * - - * - * - *

beta:

stable:

Rust 有一个为期 6 周的发布循环,每 6 周时间,就是准备发布新版本的时候。Rust 仓库的 beta 分支会从用于 nightly 的 master 分支产生。现在,有了两个发布版本:

1
2
3
4
5
nightly: * - - * - - *
|
beta: *

stable:

大部分 Rust 用户不会主要使用beta 版本,不过在 CI 系统中对beta 版本进行测试能够帮助 Rust 发现可能的回归缺陷(regression)。同时,每天仍产生nightly发布:

1
2
3
4
5
nightly: * - - * - - * - - * - - *
|
beta: *

stable:

比如我们发现了一个回归缺陷。好消息是在这些缺陷流入稳定发布之前还有一些时间来测试beta版本!fix 被合并到 master,为此nightly版本得到了修复,接着这些 fix 将 backport 到 beta 分支,一个新的beta发布就产生了:

1
2
3
4
5
nightly: * - - * - - * - - * - - * - - *
|
beta: * - - - - - - - - *

stable:

第一个 beta 版的 6 周后,是发布稳定版的时候。stable 分支从 beta 分支生成:

1
2
3
4
5
nightly: * - - * - - * - - * - - * - - * - * - *
|
beta: * - - - - - - - - *
|
stable: *

现在,Rust 1.5 发布了。然而,我们忘了些东西:因为又过了 6 周,我们还需发布新版 Rust 的 beta 版,Rust 1.6。所以从 beta 生成 stable 分支后,新版的 beta 分支也再次从 nightly 生成:

1
2
3
4
5
nightly: * - - * - - * - - * - - * - - * - * - *
| |
beta: * - - - - - - - - * *
|
stable: *

这被称为火车模型(train model),因为每 6 周,一个版本离开车站(leaves the station),不过从beta通道到达稳定通道还有一段旅程。

Rust 每 6 周发布一个版本,如时钟般准确。如果你知道了某个 Rust 版本的发布时间,就可以知道下个版本的时间:6 周后。每 6 周发布版本的一个好的方面是下一班车会来得更快。如果特定版本碰巧缺失某个功能也无需担心:另一个版本很快就会到来!这有助于减少因临近发版时间而偷偷释出未经完善的功能的压力。

多亏了这个过程,你总是可以切换到下一版本的 Rust 并验证是否可以轻易的升级:如果 beta 版不能如期工作,你可以向 Rust 团队报告并在发布稳定版之前得到修复!beta 版造成的破坏是非常少见的,不过 rustc 也不过是一个软件,可能会存在 bug。

不稳定功能

这个发布模型中另一个值得注意的地方:不稳定功能(unstable features)。Rust 使用一个被称为功能标记(feature flags)的技术来确定给定版本的某个功能是否启用。如果新功能正在积极地开发中,其提交到了 master,因此会出现在 nightly版中,不过会位于一个功能标记之后。作为用户,如果你希望尝试这个正在开发的功能,则可以在源码中使用合适的标记来开启,不过必须使用nightly版。

如果使用的是beta或稳定版 Rust,则不能使用任何功能标记。这是在新功能被宣布为永久稳定之前获得实用价值的关键。这既满足了希望使用最尖端技术的开发者,那些坚持稳定版的人也知道其代码不会被破坏。这就是所谓的无停滞稳定。

比如我们曾经见过cargo-expand这个crate,使用时需要切换到nightly版本就是一个例子。

1.3 Edition

前面提到rust有一个为期6周的发布循环,这意味着用户会稳定得到新功能的更新。其他编程语言发布大更新但不甚频繁,Rust 选择更为频繁的发布小更新。一段时间之后,所有这些小更新会日积月累。

每两到三年,Rust 团队会生成一个新的 Rust 版次(edition)。每一个版次会结合已经落地的功能,并提供一个清晰的带有完整更新文档和工具的功能包。新版次会作为常规的 6 周发布过程的一部分发布。

这为不同的人群提供了不同的功能:

  • 对于活跃的 Rust 用户,其将增量的修改与易于理解的功能包相结合。
  • 对于非用户,它表明发布了一些重大进展,这意味着 Rust 可能变得值得一试。
  • 对于 Rust 自身开发者,其提供了项目整体的集合点。

截止在2023年这篇文章编写时,Rust目前有三个可用版次:Rust 2015、Rust 2018 和 Rust 2021,并且,Rust团队已经确定今后每三年发布一个版次,因此下一个版次应该是Rust 2024。

在项目的Cargo.toml文件中的 edition 字段表明代码应该使用哪个版本编译,如果该字段不存在,其默认为 2015 以提供后向兼容性。

1
2
3
4
[package]
name = "ruststudy"
version = "0.1.0"
edition = "2021"

其中,2015版次对应1.0.0-1.30.x语义化版本,2018版次对应1.31.0-1.55.x语义化版本,2021版是从1.56.0-至今的语义化版本。

每个项目都可以选择不同于默认的 2015 的版次。这样可能会包含不兼容的修改,比如新增关键字可能会与代码中的标识符冲突并导致错误。不过除非选择兼容这些修改,旧的代码仍将能够编译,即便升级了 Rust 编译器的版本。

所有 Rust 编译器都支持任何之前存在的编译器版本,并可以链接任何支持版本的crate。编译器修改只影响最初的解析代码的过程。因此,如果你使用 Rust 2015 而某个依赖使用 Rust 2018,你的项目仍旧能够编译并使用该依赖。反之,若项目使用 Rust 2018 而依赖使用 Rust 2015 亦可工作。

大部分功能在所有版本中都能使用。开发者使用任何 Rust 版本将能继续接收最新稳定版的改进。然而在一些情况,主要是增加了新关键字的时候,则可能出现了只能用于新版本的功能。只需切换版本即可利用新版本的功能。

2 编译过程概述

rustc 是 Rust 编程语言的编译器,由项目本身提供。编译器获取源代码并生成二进制代码,作为库或可执行文件。

了解rust的编译过程是深入理解rust不可少的一部分,这里就从宏观上介绍一下rust的编译过程。

Rust 编译器在两个方面很特别:它对您的代码做了其他编译器不会做的事情(例如借用检查),并且它有很多非常规的实现选择(例如queries)。

首先,让我们看看编译器对源代码做了什么。

2.1 调用编译器

当用户以文本形式编写 Rust 源程序并在其上调用 rustc 编译器时,编译就开始了。编译器需要执行的工作由命令行选项定义。例如,可以启用nightly编译标志( -Z 标志),仅执行 check 构建,或编译为中间代码而不是编译成可执行机器代码等等。 rustc 可执行调用可以通过使用 cargo 来间接调用,事实上大部分开发者都是如此。

命令行参数解析发生在 rustc_driver 中。这个crate定义了用户请求的编译配置,并将其作为 rustc_interface::Config 传递给编译过程的其余部分。

2.2 词法分析

原始 Rust 源代码由位于 rustc_lexer 的低级词法分析器分析。在此阶段,源代码被转换为原子的代码单元流,也就是我们常说的由词法单元token组成的词法单元流token stream。词法分析器支持Unicode字符编码。

2.3 语法分析

词法单元流通过位于 rustc_parse 中的更高级别的词法分析器(解析器),为编译过程的下一阶段做准备。

解析器将来自词法分析器的token stream使用递归下降(自上而下)方法进行语法分析,翻译成抽象语法树(AST)。

2.4 语义分析 HIR

接下来,进行语义分析阶段。首先使用 AST 并将其转换为高级中间表示(High-Level Intermediate Representation,HIR),这是一种对编译器更友好的 AST 表示。这个过程称为降低(lowering)。在这个过程中涉及很多诸如 for 循环被转化为loopif let 被转为match以及 async fn 之类的脱糖(desugaring)等等。HIR 相对于 AST 更有利于编译器的分析工作。

接下来编译器会使用 HIR 进行类型推断(自动检测表达式类型的过程)、特征求解(将impl与每个对特征的引用配对的过程)和类型检查。类型检查是将 HIR ( hir::Ty ) 中发现的类型(表示用户编写的内容)转换为编译器使用的内部表示形式 ( Ty<'tcx>) 的过程。该信息用于验证程序中使用的类型的类型安全性、正确性和一致性。

2.5 语义分析 MIR

下一步,将 HIR 降低到中级中间表示(Mid-level Intermediate Representation,MIR),用于借用检查。在此过程中,还构建了过渡中间代码表示 THIR,这是一个更加脱糖的HIR。THIR用于模式和详尽检查,用它转换成 MIR 也比用 HIR 更方便。

MIR做了很多优化,这些优化在借用检查之后运行。这改进了以后生成的代码,也提高了编译速度。

关于 MIR 还需要了解它的三个关键特性:

  • 它是基于控制流图(Control Flow Graph)的。
  • 它没有嵌套表达式。
  • MIR 中的所有类型都是完全明确的,不存在隐性表达。人类可读,所以在 Rust 学习过程中,可以通过查看 MIR 来了解 Rust 代码的一些行为,比如更好地理解复杂生命周期。

Rust 代码是单态的,这意味着需要复制所有通用代码,并将类型参数替换为具体类型。为此,我们需要收集一个列表,列出要为其生成代码的具体类型。这称为单态化收集,它也发生在 MIR 级别。

2.6 代码生成

我们将最后的阶段模糊地称为代码生成阶段(code generation)。代码生成阶段是将更高级别的源表示形式转换为可执行二进制文件的阶段。 通常,rustc 使用 LLVM 进行代码生成。但也支持 Cranelift 和 GCC。

LLVM 是模块化和可重用的编译器和工具链技术的集合,LLVM 这个名称本身并不是首字母缩写词,而是项目的全名。作为编译器后端,LLVM不仅仅为rust使用,事实上C++、C、Swift等等都可以使用它。采用LLVM的一些原因如下:

  • LLVM后端支持的平台很多,我们不需要担心CPU、操作系统的问题(运行库除外)。
  • LLVM后端的优化水平较高,我们只需要将代码编译成LLVM IR,就可以由LLVM后端作相应的优化。
  • 我们不必编写整个编译器后端。这减少了实施和维护负担。
  • 可以自动将 Rust 编译到 LLVM 支持的任何平台。例如,一旦 LLVM 添加了对 WASM 的支持,那么 rustc、clang 和许多其他语言都能够编译成 WASM。
  • LLVM IR本身比较贴近汇编语言,同时也提供了许多ABI层面的定制化功能。

Rust 核心团队也会帮忙维护LLVM,发现了 Bug 也会提交补丁。虽然 LLVM 有这么多优点,但它也有一些缺点,比如编译比较慢。所以,Rust 团队在去年引入了新的后端 Cranelift ,用于加速 Debug 模式的编译。

通常,rustc_codegen_ssa包含与后端无关的代码,然后由 Cranelift 来处理。从2021年1月开始,通过rustc_codegen_ssa 又为所有后端提供了一个抽象接口以实现,以允许其他代码源后端(如 Cranelift)。这意味着,Rust 语言将来可以接入多个编译后端。

现在来看看代码生成阶段的步骤:

第一步是将 MIR 转换为 LLVM 中间表示(LLVM IR)。根据在上一步中创建的列表,这实际上是 MIR 单态化的地方。

第二步 LLVM IR 被传递给LLVM,后者对其进行了更多优化。

第三步由LLVM生成机器代码。它基本上是添加了额外的低级类型和注释的汇编代码(例如 ELF 对象或 WASM)。

最后,将不同的库或二进制文件链接在一起以生成最终的二进制文件。

以上就是 Rust 整体的编译过程,注意,这只是一个简单的概述,事实上这是一个复杂的过程,其中每一部分都有大量细节。

3 词法结构

作为大部分开发者,其实无需了解编译器每个部分的实现细节。但是作为学习,了解rust的词法结构有助于梳理rust相对复杂的语法。

词法和语法片段使用的符号如下:

表义符示例释义
CAPITALKW_IF, INTEGER_LITERAL由词法分析生成的词法单元 token
ItalicCamelCaseLetStatement, Item句法产生式(syntactical production)
stringx, while, *确切的字面字符(串)
\x\n, \r, \t, \0转义字符
x?pub?x重复零次或一次(可选项)
x*OuterAttribute*x 重复零次或多次
x+MacroMatch+x 重复一次或多次
xa..bHEX_DIGIT1..6x 重复 a 到 b 次
|u8 | u16,Block | Item
[ ][b B]列举出的任意字符
[ - ][a-z]a 到 z 范围内的任意字符
~[ ]~[b B]列举范围外的任意字符
~string~\n, ~*/此字符序列外的任意字符
( )(, Parameter)?程序项分组

3.1 关键字

Rust 将关键字分为三类:

  • 严格关键字(strict)
  • 保留关键字(reserved)
  • 弱关键字(weak)

严格关键字

这类关键字只能在正确的上下文中使用。不能将它们用于宏名、函数名、函数参数、生存周期参数等等。

词法分析如下:

KW_AS : as KW_BREAK : break KW_CONST : const KW_CONTINUE : continue KW_CRATE : crate KW_ELSE : else KW_ENUM : enum KW_EXTERN : extern KW_FALSE : false KW_FN : fn KW_FOR : for KW_IF : if KW_IMPL : impl KW_IN : in KW_LET : let KW_LOOP : loop KW_MATCH : match KW_MOD : mod KW_MOVE : move KW_MUT : mut KW_PUB : pub KW_REF : ref KW_RETURN : return KW_SELFVALUE : self KW_SELFTYPE : Self KW_STATIC : static KW_STRUCT : struct KW_SUPER : super KW_TRAIT : trait KW_TRUE : true KW_TYPE : type KW_UNSAFE : unsafe KW_USE : use KW_WHERE : where KW_WHILE : while

以下关键字从 2018 版开始启用:

KW_ASYNC : async KW_AWAIT : await KW_DYN : dyn

弱关键字

这类关键字只有在特定的上下文中才有特殊的意义。例如,可以声明名为 union 的变量或方法。

  • macro_rules 用于创建自定义宏。

  • union 用于声明联合体union,它只有在联合体声明中使用时才是关键字。

  • 'static 用于静态生命周期,不能用作通用泛型生存期参数和循环标签。

  • 在 2015 版本中,当dyn用在非 :: 开头的路径限定的类型前时,它是关键字。

词法分析如下:

KW_UNION : union KW_STATICLIFETIME : 'static

在 2015 版本是弱关键字,2018 开始被提升为严格关键字: KW_DYN : dyn

保留关键字

这类关键字目前还没有被使用,但是它们被保留以备将来使用。它们具有与严格关键字相同的限制。这样做的原因是通过禁止当前程序使用这些关键字,从而使当前程序能兼容 Rust 的未来版本。

通过保留关键字,可以推测出将来版本可能要支持的一些特性或功能。

词法分析如下:

KW_ABSTRACT : abstract KW_BECOME : become KW_BOX : box KW_DO : do KW_FINAL : final KW_MACRO : macro KW_OVERRIDE : override KW_PRIV : priv KW_TYPEOF : typeof KW_UNSIZED : unsized KW_VIRTUAL : virtual KW_YIELD : yield

以下关键字从 2018 版开始成为保留关键字:

KW_TRY : try

3.2 标识符

标识符遵循Unicode 标准附件 #31中针对 Unicode 15.0 版的规范,并添加了如下所述的内容。下面是标识符的一些示例:

  • foo
  • _identifier
  • r#true
  • Москва
  • 東京

根据该规范可以得知,默认标识符语法(Default Identifier Syntax)的巴科斯范式(BNF)如下:

1
<Identifier> := <Start> <Continue>* (<Medial> <Continue>+)*

这个正式语法提供了标识符的定义,保证向后兼容每个后续的 Unicode 版本,而且还添加了扩展任何新 Unicode 字符的能力:公式允许扩展,也称为配置文件。也就是说,语法所用的每个类别的特定代码点的集合可以根据环境的要求进行定制。

StartContinueMedial根据配置的不同而不同。比如:使用 Unicode 作为标识符的语言规则的典型模式是定义一个 ID_Start 类和一个 ID_Continue 类,分别代表标识符的首字符和后续字符的符号集合。

ID_Start字符源自 Unicode General_Category 的大写字母、小写字母、首字母大写字母等等。

ID_Continue 字符包括 ID_Start 字符,加上具有 Unicode General_Category 的非间距标记、间距组合标记、十进制数等等。

Medial为中间的可选字符,Medial中的字符不得与StartContinue 中的字符重叠。

rust采用的配置文件为:

XID_startXID_continue 涵盖了用于构成常见的 C 和 Java 语言族标识符的字符范围。

词法分析(见:Identifiers):

IDENTIFIER_OR_KEYWORD : XID_start XID_continue* | _ XID_continue+

RAW_IDENTIFIER : r#IDENTIFIER_OR_KEYWORD 排除 crate, self, super, Self

NON_KEYWORD_IDENTIFIER : IDENTIFIER_OR_KEYWORD 排除严格关键字和保留关键字

IDENTIFIER : NON_KEYWORD_IDENTIFIER | RAW_IDENTIFIER

原始标识符(RAW_IDENTIFIER)类似于普通标识符,但以 r# 为前缀。与普通标识符不同,原始标识符可以是任何严格或保留的关键字,但上面列出的 RAW_IDENTIFIER 除外。反过来说,如果没有在原始标识符中描述的 r# 前缀,标识符可能不是严格关键字或保留关键字。

这些规则不用特意去记,基本上按照其它编程语言的规范来书写就基本没有问题。

3.3 注释

词法分析:

LINE_COMMENT(行注释) : // (~[/ ! \n] | //) ~\n* | //

BLOCK_COMMENT(块注释) : /* (~[* !] | ** | BlockCommentOrDoc) (BlockCommentOrDoc | ~*/)* */ | /**/ | /***/

INNER_LINE_DOC(内部行文档型注释) : //! ~[\n IsolatedCR]*

INNER_BLOCK_DOC(内部块文档型注释) : /*! ( BlockCommentOrDoc | ~[*/ IsolatedCR] )* */

OUTER_LINE_DOC(外部行文档型注释) : /// (~/ ~[\n IsolatedCR]*)?

OUTER_BLOCK_DOC(外部块文档型注释) : /** (~* | BlockCommentOrDoc ) (BlockCommentOrDoc | ~[*/ IsolatedCR])* */

BlockCommentOrDoc(块注释或文档型注释) : BLOCK_COMMENT | OUTER_BLOCK_DOC | INNER_BLOCK_DOC

IsolatedCR(独立的制表符) : 后面没有跟 \n\r

下面是一些正确的例子:

  • 行注释要注意第三个符号不能是单独的/!\n

    1
    2
    //
    ////
  • 块注释的第三个符号不能时单独的*!

    1
    2
    3
    4
    5
    /* */
    /**/
    /***/
    /*/*支持嵌套 */*/
    /* /** 嵌套任何块注释或文档型注释*/ */
  • 内部文档型注释:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    //! 应用于此 crate 的隐式匿名模块的文档型注释

    pub mod outer_module {

    //! - 内部行文档型注释
    //!! - 仍是内部行文档型注释 (但是这样开头会更具强调性)

    /*! - 内部块文档型注释 */
    /*!! - 仍是内部块文档型注释 (但是这样开头会更具强调性) */
    pub mod inner_module {}
    }
  • 外部行文档型注释同样支持嵌套:

    1
    2
    3
    4
    ///
    /** */
    /** /** /* /** */ */ */ */
    fn main() {}

3.4 空白符

空白符是任何非空字符串,它里面只包含具有 Pattern_White_Space 属性的 Unicode 字符。即:

  • U+0009 (水平制表符, '\t'
  • U+000A (换行, '\n'
  • U+000B (垂直制表符)
  • U+000C (分页符)
  • U+000D (回车, '\r'
  • U+0020 (空格, ' '
  • U+0085 (下一行)
  • U+200E (从左到右的标记)
  • U+200F (从右到左的标记)
  • U+2028 (行分隔符)
  • U+2029 (段落分隔符)

Rust 是一种格式自由(free-form)的语言,这意味着所有形式的空格仅用于分隔语法中的token,没有语义意义。

Rust 程序中,如果将一个空白符元素替换为任何其他合法的空白符元素(例如单个空格字符),它们仍有相同的意义。

3.5 token

token 是采用非递归方式的正则文法(regular languages)定义的基本语法产生式(primitive productions)。Rust 源码输入可以被分解成以下几类 token:

具体参考见:Tokens

4 语句和表达式

4.1 语句

Rust 主要有两种语句:声明语句和表达式语句。

句法:

Statement : ; | Item | LetStatement | ExpressionStatement | MacroInvocationSemi

另外,单个分号(;)本身就是一条语句,还有一个是宏调用(MacroInvocationSemi)。

声明语句

声明语句是将一个或多个名称引入封闭语句块的语句。声明的名称可能表示新变量或新程序项

有两种声明语句是程序项声明语句和let声明语句。

程序项声明语句

程序项声明语句的句法形式与模块中的程序项声明的句法形式相同。在语句块中声明的程序项会将其作用域限制为包含该语句的块。这类程序项以及在其内声明子项(sub-items)都没有给定的规范路径。例外的是,只要程序项和 trait(如果有的话)的可见性允许,在(程序项声明语句内定义的和此程序项或 trait 关联的)实现中定义的关联项在外层作用域内仍然是可访问的。除了这些区别外,它与在模块中声明的程序项的意义也是相同的。

程序项声明语句不会隐式捕获包含它的函数的泛型参数、参数和局部变量。如下程序中,inner 不能访问 outer_var

1
2
3
4
5
6
7
fn outer() {
let outer_var = true;

fn inner() { /* outer_var 的作用域不包括这里 */ }

inner();
}
let语句

句法: LetStatement : OuterAttribute* let PatternNoTopAlt ( : Type )? (= Expression )? ;

let语句通过一个不可反驳型模式引入了一组新的变量,变量由该模式给定。模式后面有一个可选的类型标注(annotation),再后面是一个可选的初始化表达式。当没有给出类型标注时,编译器将自行推断类型,如果没有足够的信息来执行有限次的类型推断,则将触发编译器报错。由变量声明引入的任何变量从声明开始直到封闭块作用域结束都是可见的。

表达式语句

句法:

ExpressionStatement : ExpressionWithoutBlock ; | ExpressionWithBlock ;?

表达式语句是对表达式求值并忽略其结果的语句。通常,表达式语句的目的是触发对其表达式求值的效果。

如果在允许语句的上下文中使用仅包含块表达式或控制流表达式组成的表达式,则可以省略尾随分号。这有可能会导致解析歧义,因为它可以被解析为独立语句,也可以被解析为另一个表达式的一部分;在这种情况下,它被解析为一条语句。用作语句的 ExpressionWithBlock 表达式的类型必须是单元类型。

1
2
3
4
5
6
7
v.pop();          // 忽略从 pop 返回的元素
if v.is_empty() {
v.push(5);
} else {
v.remove(0);
} // 分号可以省略。
[1]; // 单独的表达式语句,而不是索引表达式。

当省略尾随分号时,结果必须是 () 类型。

1
2
3
4
5
6
7
8
9
10
11
12
// bad: 下面块的类型是i32,而不是 `()` 
// Error: 预期表达式语句的返回值是 `()`
// if true {
// 1
// }

// good: 下面块的类型是i32,(加`;`后的语句的返回值就是 `()`了)
if true {
1
} else {
2
};

语句的属性

语句接受外部属性。对语句有意义的属性是 cfglint 检查属性

4.2 表达式

句法: Expression : ExpressionWithoutBlock | ExpressionWithBlock

ExpressionWithoutBlock : OuterAttribute ( LiteralExpression | PathExpression | OperatorExpression | GroupedExpression | ArrayExpression | AwaitExpression | IndexExpression | TupleExpression | TupleIndexingExpression | StructExpression | CallExpression | MethodCallExpression | FieldExpression | ClosureExpression | ContinueExpression | BreakExpression | RangeExpression | ReturnExpression | UnderscoreExpression | MacroInvocation )

ExpressionWithBlock : OuterAttribute ( BlockExpression | AsyncBlockExpression | UnsafeBlockExpression | LoopExpression | IfExpression | IfLetExpression | MatchExpression )

可以看到,表达式可以是块表达式和非块表达式。一个表达式可能有两个角色:它总是能产生一个值,并且它可能具有效果(effects),也称为副作用(side effects)。表达式的计算结果为一个值,并在计算期间具有效果。许多表达式包含子表达式,此子表达式也被称为此表达式的操作数。每种表达式都表达了以下几点含义:

  • 在计算表达式时是否计算操作数
  • 计算操作数的顺序
  • 如何组合操作数的值以获取表达式的值

基于对这几种含义的实现要求,表达式通过其内在结构规定了其执行结构。块只是另一种表达式,所以块、语句和表达式可以递归地彼此嵌套到任意深度。

表达式优先级

Rust 运算符和表达式的优先级顺序如下,从强到弱。处于相同优先级的二元运算符按其结合(associativity)顺序进行分组。

运算符/表达式结合性
Paths(路径)
Method calls(方法调用)
Field expressions (字段表达式)从左向右
Function calls, array indexing(函数调用,数组索引)
?
Unary(一元运算符) - * ! & &mut
as从左向右
* / %从左向右
+ -从左向右
<< >>从左向右
&从左向右
^从左向右
``
== != < > <= >=需要圆括号
&&从左向右
`
.. ..=需要圆括号
= += -= *= /= %= &= `= ^= <<= >>=`
return break closures(返回、中断、闭包)

操作数的求值顺序

下面的表达式都以相同的方式计算它们的操作数,具体列表后面也有详述。其他表达式要么不接受操作数,要么按照各自约定的条件进行求值。

  • 解引用表达式(Dereference expression)
  • 错误传播表达式(Error propagation expression)
  • 取反表达式(Negation expression)
  • 算术和二进制逻辑运算(Arithmetic and logical binary operators)
  • 比较运算(Comparison operators)
  • 类型转换表达式(Type cast expression)
  • 分组表达式(Grouped expression)
  • 数组表达式(Array expression)
  • 等待表达式(Await expression)
  • 索引表达式(Index expression)
  • 元组表达式(Tuple expression)
  • 元组索引表达式(Tuple index expression)
  • 结构体表达式(Struct expression)
  • 调用表达式(Call expression)
  • 方法调用表达式(Method call expression)
  • 字段表达式(Field expression)
  • 中断表达式(Break expression)
  • 区间表达式(Range expression)
  • 返回表达式(Return expression)

在应用表达式的效果之前,将计算这些表达式的操作数。采用多个操作数的表达式按照源代码中的编写从左到右计算。

例如,两个 next 方法调用将始终按相同的顺序调用:

1
2
3
4
5
let mut one_two = vec![1, 2].into_iter();
assert_eq!(
(1, 2),
(one_two.next().unwrap(), one_two.next().unwrap())
);

由于表达式是递归执行的,这些表达式也会从最内层到最外层逐层求值,忽略兄弟表达式,直到没有(未求值的)内部子表达式为止。

位置表达式和值表达式

表达式分为两大类:位置表达式(place expressions)和值表达式(value expressions)。还有第三类次要表达式,称为被分配表达式(assignee expressions,翻译仅供参考)。它们各自形成了自己的上下文,因此,在每个表达式中,操作数可以出现在位置上下文,也可出现在值上下文中。表达式的求值既依赖于它自己的类别,也依赖于它所在的上下文。

通过位置和值理解内存模型中曾提到位置和值的概念:

  • 位置指的是某一块内存位置,它有自己的地址和空间以及自己所保存的值。位置可能位于栈中,可能位于堆中,也可能位于全局内存区。

  • 值指的是存储到位置中的数据(保存在内存某个位置中的数据)。值的类型有多种,比如数值类型、指针类型、指针(裸指针、胖指针)、字符类型等等。

对应这两个概念:

  • 位置表达式是表示内存位置的表达式。这些表达式是指向局部变量、静态变量解引用(*expr)、数组索引表达式(expr[expr])、字段引用(expr.f)、圆括号括起来的位置表达式的路径。所有其他形式的表达式都是值表达式。

  • 值表达式是代表实际值的表达式。

下面列出的的上下文是位置表达式的上下文:

被分配表达式是出现在赋值表达式的左操作数中的表达式。明确地,被分配表达式是:

被分配表达式中允许任意括号。

移动语义类型和复制语义类型

当位置表达式在值表达式上下文中被求值时,或在模式中被值绑定时,这表示此值会保存进(held in)当前表达式代表的内存地址。如果该值的类型实现 Copy,则将复制该值。如果该值的类型没有实现 Copy,但实现了 Sized,那么就有可能把该值从原来的位置移动(move)过来。从位置表达式里移出值对位置表达式也有要求,只有如下的位置表达式才可能把值从其中移出(move out):

把值从一个位置表达式里移出到一个局部变量,那此(表达式代表的)地址将被取消初始化(deinitialized),并且该地址在重新初始化之前无法再次读取该位置。

除以上列出的情况外,任何在值表达式上下文中使用位置表达式都是错误的。

位置表达式的可变性

如果一个位置表达式将会被赋值、可变借出隐式可变借出或被绑定到包含 ref mut 模式上,则该位置表达式必须是可变的(mutable)。我们称这类位置表达式为可变位置表达式。与之相对,其他位置表达式称为不可变位置表达式。

下面的表达式可以是可变位置表达式上下文:

  • 当前未被借出的可变变量
  • 可变静态(static)项
  • 临时值
  • 字段,在可变位置表达式上下文中,可以对此子表达式求值。
  • *mut T 指针的解引用
  • 对类型为 &mut T 的变量或变量的字段的解引用。注意:这条是下一条规则的必要条件的例外情况。
  • 对实现 DerefMut 的类型的解引用,那对这里解出的表达式求值就需要在一个可变位置表达式上下文中进行。
  • 对实现 IndexMut 的类型做索引,那对此检索出的表达式求值就需要在一个可变位置表达式上下文进行。注意对索引(index)本身的求值不用。

临时位置/临时变量

在大多数位置表达式上下文中使用值表达式时,会创建一个临时的未命名内存位置,并将该位置初始为该值,然后这个表达式的求值结果就为该内存位置。此过程也有例外,就是把此临时表达式提升为一个静态项(static)。(这种情况下表达式将直接在编译时就求值了,求值的结果会根据编译器要求重新选择地址存储)。临时位置/临时变量的销毁作用域(drop scope)通常在包含它的最内层语句的结尾处。

隐式借用

某些特定的表达式可以通过隐式借用一个表达式来将其视为位置表达式。例如,可以直接比较两个非固定尺寸的切片是否相等,因为 == 操作符隐式借用了它自身的操作数:

1
2
3
4
5
6
7
let a: &[i32];
let b: &[i32];
// ...
// &[i32] 解引用后是一个动态尺寸类型,理论上两个动态尺寸类型上无法比较大小的,但这里因为隐式借用此成为可能
*a == *b;
// 等价于下面的形式:
::std::cmp::PartialEq::eq(&*a, &*b);

隐式借用可能会被以下表达式采用:

五、std标准库模块

在前三章,我们将介绍输入输出、路径以及文件系统的标准库。

1 io

std::io模块包含许多在执行输入和输出时需要的常见操作。该模块中最核心的部分是 ReadWrite,它们提供用于读取和写入输入和输出的最通用接口。

1.1 ReadWrite

因为它们是 traits,所以 ReadWrite 由许多其他类型实现,也可以为你的自定义类型实现它们。 因此,有很多不同类型的 I/O: FileTcpStream,有时甚至是 Vec。 例如,Read 添加了 read 方法,我们可以在 File 上使用该方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::io;
use std::io::prelude::*;
use std::fs::File;

fn main() -> io::Result<()> {
let mut f = File::open("foo.txt")?;
let mut buffer = [0; 10];

// 最多读取 10 个字节
let n = f.read(&mut buffer)?;

println!("The bytes: {:?}", &buffer[..n]);
Ok(())
}

由于 ReadWrite十分重要,这两个特征的实现者有一个绰号:readerswriters。所以有时你会看到“一个reader”而不是“一个实现 Read 特征的类型”。这样说起来更加简便。

1.2 ReadersWriters读取和写入数据

Readers 支持面向字节的输入,它需要一个输入源,用于确定从哪里读取字节。

例如:

Writers 支持面向字节和 UTF-8 文本输出,是那些你可以把值写入的地方,例如:

  • 使用 std::fs::File::create 创建的文件;
  • 基于网络连接 std::net::TcpStream 传输数据;
  • std::io::stdout()std::io:stderr() 可以用于向标准输出和标准错误写入内容;
  • std::io::Cursor<Vec<u8>> 类似,但允许读取和写入数据,并在 vector 中寻找不同的位置;
  • std::io::Cursor<&mut [u8]> 和上面的类似,但是不能增长内部的 buffer,因为它仅仅是已存在的字节数组的引用;

1.3 Readers读取数据的方法

以读取文件为例,std::io::Read 有几个读取数据的方法,它们都以 &mut self 作为参数。

read

read从输入源中提取一些字节到指定的缓冲区中,返回读取的字节数。函数签名:

1
fn read(&mut self, buf: &mut [u8]) -> Result<usize>

该函数不提供有关是否阻塞等待数据的任何保证,但是如果对象需要阻塞读取而不能阻塞,则通常会通过 Err 返回值来发出信号。

缓冲区参数的类型是 &mut [u8],这最多读取 buffer.len() 个字节。如果此方法的返回值为 Ok(n),则实现必须保证 0 <= n <= buf.len() 。非零值 n 表示缓冲区 buf 已用来自该源的 n 字节数据填充。如果 n0 ,那么它可以表示以下两种情况之一:

  • reader已到达其“文件末尾”并且可能不再能够生成字节。请注意,这并不意味着读取器将始终无法再生成字节。例如,在 Linux 上,此方法将为 TcpStream 调用 recv 系统调用,其中返回零表示连接已正确关闭。然而对于 File ,有可能到达文件末尾并得到零结果,但如果将更多数据附加到文件,以后对 read 的调用将返回更多数据。
  • 指定的缓冲区的长度为 0 个字节。

如果返回值 n 小于缓冲区大小,这不是错误,即使读取器尚未到达流的末尾也是如此。例如,这可能是因为现在实际可用的字节较少(例如接近文件末尾)或因为 read() 被信号中断。

使用者在调用 read 之前应该确保 buf 已初始化,用未初始化的 buf来调用 read 是不安全的,并且可能导致未定义的行为。

.read() 方法是一个低级方法,甚至继承了底层操作系统的一些特性。如果你正在为一种新型数据源实现 Read,这会给你很大的余地;如果你试图读取一些数据,那会很痛苦。因此,Rust 提供了几种更高级的便利方法。它们都具有 .read() 方面的默认实现,它们都处理 ErrorKind::Interrupted,所以你的实现里无需这样做。

read_to_end

函数签名:

1
fn read_to_end(&mut self, buf: &mut Vec<u8>) -> Result<usize>

读取输入源中直到 EOF 的所有字节,将它们放入 buf 中,它是Vec<u8>类型,从输入源读取的所有字节都将附加到指定的缓冲区 buf 。此函数将不断调用 read() 以将更多数据附加到 buf ,直到 read() 返回 Ok(0) 或非 ErrorKind::Interrupted类型的错误。如果成功,此函数将返回读取的总字节数。

如果此函数遇到ErrorKind::Interrupted类型的错误,则该错误将被忽略,操作将继续。如果遇到任何其他读取错误,则此函数会立即返回。任何已读取的字节都将附加到 buf

比如,从文件读取:

1
2
3
4
5
6
7
8
9
10
11
12
use std::io;
use std::io::prelude::*;
use std::fs::File;

fn main() -> io::Result<()> {
let mut f = File::open("foo.txt")?;
let mut buffer = Vec::new();

// read the whole file
f.read_to_end(&mut buffer)?;
Ok(())
}

read_to_string

函数签名:

1
fn read_to_string(&mut self, buf: &mut String) -> Result<usize>

同上,但是追加数据到 String 中。

如果成功,此函数返回已读取并附加到 buf 的字节数。如果输入源中的数据不是有效的 UTF-8,则返回错误并且 buf 不变。

1
2
3
4
5
6
7
8
9
10
11
use std::io;
use std::io::prelude::*;
use std::fs::File;

fn main() -> io::Result<()> {
let mut f = File::open("foo.txt")?;
let mut buffer = String::new();

f.read_to_string(&mut buffer)?;
Ok(())
}

read_exact

函数签名:

1
fn read_exact(&mut self, buf: &mut [u8]) -> Result<()>

读取填充 buf 所需的确切字节数,此函数读取尽可能多的字节以完全填充指定的缓冲区 buf

如果此函数遇到ErrorKind::Interrupted类型的错误,则该错误将被忽略,操作将继续。如果此函数在完全填充缓冲区之前遇到“文件结尾”,它会返回ErrorKind::UnexpectedEof 类型的错误。在这种情况下, buf 的内容未指定。如果遇到任何其他读取错误,则此函数会立即返回。在这种情况下, buf 的内容未指定。

如果此函数返回错误,则未指定它已读取了多少字节,但它绝不会读取超过完全填满缓冲区所需的字节数。

1
2
3
4
5
6
7
8
9
10
11
12
use std::io;
use std::io::prelude::*;
use std::fs::File;

fn main() -> io::Result<()> {
let mut f = File::open("foo.txt")?;
let mut buffer = [0; 10];

// read exactly 10 bytes
f.read_exact(&mut buffer)?;
Ok(())
}

by_ref

函数签名:

1
fn by_ref(&mut self) -> &mut Self

为这个 Read 实例创建一个基于引用的适配器。返回的适配器也实现了 Read ,并且将简单地借用当前的阅读器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use std::io;
use std::io::Read;
use std::fs::File;

fn main() -> io::Result<()> {
let mut f = File::open("foo.txt")?;
let mut buffer = Vec::new();
let mut other_buffer = Vec::new();

{
let reference = f.by_ref();

// 最多读取 5 个字节
reference.take(5).read_to_end(&mut buffer)?;

} // 丢弃 &mut 引用,以便我们可以再次使用 f

// 原始文件仍然可用,请读取其余内容
f.read_to_end(&mut other_buffer)?;
Ok(())
}

bytes

函数签名:

1
fn bytes(self) -> Bytes<Self>

将此 Read 实例的字节数转换为Iterator,返回的类型实现了Iterator,其中 ItemResult<u8, io::Error>

1
2
3
4
impl<R: Read> Iterator for Bytes<R> {
type Item = Result<u8>;
// ...
}

如果一个字节被成功读取,则产生的项目为 Ok ,否则为Err 。 EOF 被映射到从此迭代器返回的 None

1
2
3
4
5
6
7
8
9
10
11
12
use std::io;
use std::io::prelude::*;
use std::fs::File;

fn main() -> io::Result<()> {
let f = File::open("foo.txt")?;

for byte in f.bytes() {
println!("{}", byte.unwrap());
}
Ok(())
}

默认实现为每个字节调用 read ,这对于不在内存中的数据来说效率非常低,例如 File 。在这种情况下应该考虑使用BufReader (见:Buffered Readers and Writers

chain

函数签名:

1
chain<R: Read>(self, next: R) -> Chain<Self, R>

创建一个适配器,将多个 Reader 连接起来。Chain的实现如下:

1
impl<T: Read, U: Read> Read for Chain<T, U>

返回的 Read 实例将首先从该对象中读取所有字节,直到遇到 EOF。之后输出等同于 next 的输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::io;
use std::io::prelude::*;
use std::fs::File;

fn main() -> io::Result<()> {
let f1 = File::open("foo.txt")?;
let f2 = File::open("bar.txt")?;

let mut handle = f1.chain(f2);
let mut buffer = String::new();

// read the value into a String. We could use any Read method here,
// this is just one example.
handle.read_to_string(&mut buffer)?;
Ok(())
}

take

函数签名:

1
fn take(self, limit: u64) -> Take<Self>

创建一个最多从中读取 limit 字节的适配器。Take的实现如下:

1
impl<T: Read> Read for Take<T>

此函数返回一个新的 Read 实例,它将最多读取 limit 个字节,之后它将始终返回 EOF ( Ok(0) )。任何读取错误都不会计入读取的字节数,以后对 read() 的调用可能会成功。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::io;
use std::io::prelude::*;
use std::fs::File;

fn main() -> io::Result<()> {
let f = File::open("foo.txt")?;
let mut buffer = [0; 5];

// read at most five bytes
let mut handle = f.take(5);

handle.read(&mut buffer)?;
Ok(())
}

1.4 Writers写入数据的方法

std::io::Write写入数据的方法如下。

write

函数签名:

1
fn write(&mut self, buf: &[u8]) -> Result<usize>

buf 中的一些字节写入writer中。此函数将尝试写入 buf 的全部内容,但整个写入可能不会成功,或者写入也可能会产生错误。对 write 的调用最多代表一次写入任何包装对象的尝试。

不能保证对 write 的调用会阻塞等待数据的写入,否则阻塞的写入可以通过Err变体来指示。

每次调用 write 都可能产生一个 I/O 错误,指示操作无法完成。如果返回错误,则缓冲区中没有字节写入此写入器。如果无法将整个缓冲区写入此写入器,则不认为是错误(允许只写入一部分)。ErrorKind::Interrupted类型的错误是非致命错误,如果没有其他事情可做,则应重试写入操作。

如果返回值为 Ok(n) ,则保证 n <= buf.len()0 的返回值通常意味着底层对象不再能够接受字节并且将来可能也不能,或者提供的缓冲区是空的。

1
2
3
4
5
6
7
8
9
10
use std::io::prelude::*;
use std::fs::File;

fn main() -> std::io::Result<()> {
let mut buffer = File::create("foo.txt")?;

// 写入字节字符串的前缀子串,不一定是全部
buffer.write(b"some bytes")?;
Ok(())
}

Reader::read() 一样,这是一种低级方法,应避免直接使用。

flush

函数签名:

1
fn flush(&mut self) -> Result<()>

刷新此输出流,确保所有缓存的数据都到达目的地。如果由于 I/O 错误或到达 EOF 而不能写入所有字节,则认为是错误。

1
2
3
4
5
6
7
8
9
10
11
use std::io::prelude::*;
use std::io::BufWriter;
use std::fs::File;

fn main() -> std::io::Result<()> {
let mut buffer = BufWriter::new(File::create("foo.txt")?);

buffer.write_all(b"some bytes")?;
buffer.flush()?;
Ok(())
}

write_all

函数签名:

1
fn write_all(&mut self, buf: &[u8]) -> Result<()>

尝试将整个缓冲区 buf 所有字节写入。

此方法将不断调用 write ,直到没有更多数据要写入或返回非ErrorKind::Interrupted类型的错误。直到整个缓冲区已成功写入或发生此类错误后,此方法才会返回。将返回由此方法生成的第一个不是ErrorKind::Interrupted类型的错误。

如果缓冲区不包含数据,则永远不会调用 write

1
2
3
4
5
6
7
8
9
use std::io::prelude::*;
use std::fs::File;

fn main() -> std::io::Result<()> {
let mut buffer = File::create("foo.txt")?;

buffer.write_all(b"some bytes")?;
Ok(())
}

by_ref

函数签名:

1
fn by_ref(&mut self) -> &mut Self

Read类似,为此 Write 实例创建一个“通过引用”适配器,返回的适配器也实现了 Write ,并且将简单地借用当前的编写器。

1
2
3
4
5
6
7
8
9
10
11
12
use std::io::Write;
use std::fs::File;

fn main() -> std::io::Result<()> {
let mut buffer = File::create("foo.txt")?;

let reference = buffer.by_ref();

// 可以像使用原始缓冲区一样使用引用
reference.write_all(b"some bytes")?;
Ok(())
}

write_vectored

函数签名:

1
fn write_vectored(&mut self, bufs: &[IoSlice<'_>]) -> Result<usize>

类似于 write ,不同之处在于它尝试将多个缓冲区写入此writer

数据按顺序从每个缓冲区复制,最后一个缓冲区可能只被部分消耗。此方法必须表现为对 write 的调用,并连接缓冲区。默认实现使用提供的第一个非空缓冲区或空缓冲区(如果不存在)调用 write

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::io::IoSlice;
use std::io::prelude::*;
use std::fs::File;

fn main() -> std::io::Result<()> {
let mut data1 = [1; 8];
let mut data2 = [15; 8];
let io_slice1 = IoSlice::new(&mut data1);
let io_slice2 = IoSlice::new(&mut data2);

let mut buffer = File::create("foo.txt")?;

// 写入字节字符串的某些前缀,不一定要全部。
buffer.write_vectored(&[io_slice1, io_slice2])?;
Ok(())
}

is_write_vectored

函数签名:

1
fn is_write_vectored(&self) -> bool

确定此 Writer 是否具有有效的 write_vectored 实现。

如果 Writer 没有覆盖默认的 write_vectored 实现,使用它的代码可能希望避免使用该方法,并合并写入单个缓冲区以获得更高的性能。

默认实现返回 false

1.5 Buffered Readers and Writers

为了提高效率,可以缓冲读取器和写入器。就像C语言中fread/fwriteread/write的区别一样——库函数提供了用户空间缓冲区,当用户空间缓冲区满或者操作结束时才进行读取/写入操作,通常可以减少系统调用的次数。

BufReader

1
2
3
4
pub struct BufReader<R> {
inner: R,
buf: Buffer,
}

BufReader<R> 结构向任何Reader添加缓冲。前面提到过,直接使用 Read 实例可能效率极低。例如, TcpStream 上对 read 的每次调用都会导致系统调用。 而BufReader<R> 对底层的 Read 执行大量、不频繁的读取,并维护结果的内存缓冲区。

BufReader<R> 可以提高对同一文件或网络套接字进行小而重复的读取调用的程序的速度。当一次阅读大量内容或只阅读一次或几次时,它无济于事。当从一个已经在内存中的源读取时,比如 Vec<u8>,它也没有任何优势 。

BufReader<R> 被删除时,其缓冲区的内容将被丢弃。在同一流上创建 BufReader<R> 的多个实例可能会导致数据丢失。用 BufReader::into_inner 展开 BufReader<R> 后从底层读取器读取数据也会导致数据丢失。

BufReader实现了BufRead

1
impl<R: Read> BufRead for BufReader<R>

BufWriter

1
2
3
4
5
6
7
8
9
10
11
12
pub struct BufWriter<W: Write> {
inner: W,
// The buffer. Avoid using this like a normal `Vec` in common code paths.
// That is, don't use `buf.push`, `buf.extend_from_slice`, or any other
// methods that require bounds checking or the like. This makes an enormous
// difference to performance (we may want to stop using a `Vec` entirely).
buf: Vec<u8>,
// #30888: If the inner writer panics in a call to write, we don't want to
// write the buffered data a second time in BufWriter's destructor. This
// flag tells the Drop impl if it should skip the flush.
panicked: bool,
}

BufWriter<W>包装一个 writer并缓冲它的输出。

直接使用实现了 Write 的东西可能会非常低效。例如, TcpStream 上对 write 的每次调用都会导致系统调用。 BufWriter<W> 保留一个内存中的数据缓冲区,并将其以大量、不频繁的批次写入底层写入器。

BufWriter<W> 可以提高对同一文件或网络套接字进行小而重复的写入调用的程序的速度。当一次写入大量数据或只写入一次或几次时,它无济于事。当写入内存中的目标时,比如 Vec<u8>,它也没有任何优势。

在删除 BufWriter<W> 之前调用 flush 至关重要。尽管丢弃会尝试刷新缓冲区的内容,但丢弃过程中发生的任何错误都将被忽略。调用 flush 确保缓冲区为空,因此丢弃操作甚至不会尝试文件操作。

1.6 BufRead新增的读取数据的方法

fill_buf

函数签名:

1
fn fill_buf(&mut self) -> Result<&[u8]>

返回内部缓冲区的内容,如果内部缓冲区为空,则使用内部reader中的更多数据填充内部缓冲区。

这个函数是一个较低级别的调用。它需要与 consume 方法配对才能正常运行。调用此方法时,不会“读取”任何内容,因为稍后调用 read 可能会返回相同的内容。因此,必须使用从此缓冲区消耗的字节数调用 consume ,以确保字节永远不会返回两次。

返回的空缓冲区表示流已达到 EOF。如果读取底层读取器但返回错误,则此函数将返回 I/O 错误。

锁定的标准输入实现 BufRead

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::io;
use std::io::prelude::*;

let stdin = io::stdin();
let mut stdin = stdin.lock();

let buffer = stdin.fill_buf().unwrap();

// 使用缓冲区
println!("{:?}", buffer);

// 确保我们处理过的字节以后不再返回
let length = buffer.len();
stdin.consume(length);

consume

函数签名:

1
fn consume(&mut self, amt: usize)

告诉此缓冲区 amt 字节已从缓冲区中消耗掉,因此不应再在对 read 的调用中返回它们。

这个函数是一个较低级别的调用。它需要与 fill_buf 方法配对才能正常运行。此函数不执行任何 I/O,它只是通知此对象从 fill_buf 返回的一些缓冲区已被消耗,不应再返回。因此,如果 fill_buf 在调用它之前没有被调用,这个函数可能会做一些奇怪的事情。

amt 必须是 <= fill_buf 返回的缓冲区中的字节数。由于 consume() 旨在与 fill_buf 一起使用,因此该方法的示例见上一小节。

read_line

函数签名:

1
fn read_line(&mut self, buf: &mut String) -> Result<usize>

读取所有字节直到到达换行符( \n或者\r\n),包含换行符,并将它们附加到提供的 String 缓冲区,简单来说就是读取一行文本。

缓冲区的先前内容将被保留。为避免附加到缓冲区,您需要先将其 clear

如果成功,此函数将返回读取的总字节数,如果此函数返回 Ok(0) ,则流已到达 EOF。

这个函数是阻塞的,应该小心使用:攻击者有可能连续发送字节而不发送换行符或 EOF。你可以使用 take 来限制读取的最大字节数。

如果读取的字节不是有效的 UTF-8,也会返回错误。如果遇到 I/O 错误,那么 buf 可能包含一些已经读取的字节,前提是到目前为止读取的所有数据都是有效的 UTF-8。

std::io::Cursor是实现 BufRead 的类型。在这个例子中,我们使用 Cursor 来读取字节切片中的所有行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::io::{self, BufRead};

let mut cursor = io::Cursor::new(b"foo\nbar");
let mut buf = String::new();

// 游标在 'f'
let num_bytes = cursor.read_line(&mut buf)
.expect("reading from cursor won't fail");
assert_eq!(num_bytes, 4);
assert_eq!(buf, "foo\n");
buf.clear();

// 游标在 'b'
let num_bytes = cursor.read_line(&mut buf)
.expect("reading from cursor won't fail");
assert_eq!(num_bytes, 3);
assert_eq!(buf, "bar");
buf.clear();

// 游标在 EOF 处
let num_bytes = cursor.read_line(&mut buf)
.expect("reading from cursor won't fail");
assert_eq!(num_bytes, 0);
assert_eq!(buf, "");

lines

函数签名:

1
fn lines(self) -> Lines<Self>

LinesBufRead 实例行上的迭代器,它实现了Iterator,如下:

1
2
3
4
5
6
7
8
pub struct Lines<B> {
buf: B,
}

impl<B: BufRead> Iterator for Lines<B> {
type Item = Result<String>;
// ...
}

从此函数返回的这个迭代器将生成 io::Result<String> 实例,返回的每个字符串末尾都没有换行符字节( \n或者\r\n)。

std::io::Cursor是实现 BufRead 的类型。在此示例中,我们使用 Cursor 遍历字节切片中的所有行。

1
2
3
4
5
6
7
8
9
use std::io::{self, BufRead};

let cursor = io::Cursor::new(b"lorem\nipsum\r\ndolor");

let mut lines_iter = cursor.lines().map(|l| l.unwrap());
assert_eq!(lines_iter.next(), Some(String::from("lorem")));
assert_eq!(lines_iter.next(), Some(String::from("ipsum")));
assert_eq!(lines_iter.next(), Some(String::from("dolor")));
assert_eq!(lines_iter.next(), None);

read_until

函数签名:

1
fn read_until(&mut self, byte: u8, buf: &mut Vec<u8>) -> Result<usize>

将所有字节读入 buf ,直到到达指定的分隔符字节byte或EOF。

此函数将从底层流中读取字节,直到找到分隔符或 EOF。一旦找到,直到并包括定界符(如果找到)的所有字节都将附加到 buf

如果成功,此函数将返回读取的总字节数。如果遇到 I/O 错误,那么到目前为止读取的所有字节都将出现在 buf 中,并且其长度将被适当调整。此函数将忽略ErrorKind::Interrupted 的所有实例,否则将返回 fill_buf 返回的任何错误。

此函数是阻塞的,应谨慎使用:攻击者可能会连续发送字节而无需发送定界符或 EOF。

在此示例中,我们使用 Cursor 读取连字符分隔段中字节片中的所有字节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::io::{self, BufRead};

let mut cursor = io::Cursor::new(b"lorem-ipsum");
let mut buf = vec![];

// 游标在 'l'
let num_bytes = cursor.read_until(b'-', &mut buf)
.expect("reading from cursor won't fail");
assert_eq!(num_bytes, 6);
assert_eq!(buf, b"lorem-");
buf.clear();

// 游标在 'i'
let num_bytes = cursor.read_until(b'-', &mut buf)
.expect("reading from cursor won't fail");
assert_eq!(num_bytes, 5);
assert_eq!(buf, b"ipsum");
buf.clear();

// 游标在 EOF 处
let num_bytes = cursor.read_until(b'-', &mut buf)
.expect("reading from cursor won't fail");
assert_eq!(num_bytes, 0);
assert_eq!(buf, b"");

split

函数签名:

1
fn split(self, byte: u8) -> Split<Self>

返回此读取器内容的迭代器,拆分为字节 byte

SplitBufRead 实例内容的迭代器,它实现了Iterator,如下:

1
2
3
4
5
6
7
8
pub struct Split<B> {
buf: B,
delim: u8,
}
impl<B: BufRead> Iterator for Split<B> {
type Item = Result<Vec<u8>>;
// ...
}

此函数返回的迭代器将返回 io::Result<Vec<u8>> 的实例。返回的每个vector末尾都没有分隔符字节。

只要 read_until 会产生错误,此函数就会产生错误。

作为示例,我们使用 Cursor 遍历字节切片中所有以连字符分隔的段:

1
2
3
4
5
6
7
8
9
use std::io::{self, BufRead};

let cursor = io::Cursor::new(b"lorem-ipsum-dolor");

let mut split_iter = cursor.split(b'-').map(|l| l.unwrap());
assert_eq!(split_iter.next(), Some(b"lorem".to_vec()));
assert_eq!(split_iter.next(), Some(b"ipsum".to_vec()));
assert_eq!(split_iter.next(), Some(b"dolor".to_vec()));
assert_eq!(split_iter.next(), None);

1.7 使用Bufwriter减少系统调用

让我们将数字 1 到 10 写入 TcpStream

1
2
3
4
5
6
7
8
use std::io::prelude::*;
use std::net::TcpStream;

let mut stream = TcpStream::connect("127.0.0.1:34254").unwrap();

for i in 0..10 {
stream.write(&[i+1]).unwrap();
}

因为我们没有缓冲,所以我们依次写入每一个,从而导致每写入一个字节就进行一次系统调用的开销。我们可以用 BufWriter<W> 解决这个问题:

1
2
3
4
5
6
7
8
9
10
use std::io::prelude::*;
use std::io::BufWriter;
use std::net::TcpStream;

let mut stream = BufWriter::new(TcpStream::connect("127.0.0.1:34254").unwrap());

for i in 0..10 {
stream.write(&[i+1]).unwrap();
}
stream.flush().unwrap();

通过用 BufWriter<W> 包装流,这十个写入都由缓冲区组合在一起,并且在刷新 stream 时将全部在一个系统调用中写出。

另外,如果要设置 buffer 大小,可以使用 BufWriter::with_capacity代替new,创建一个至少具有指定缓冲区容量的新 BufWriter<W>

1
2
// 创建一个缓冲区有15字节的缓冲区
let mut stream = BufWriter::with_capacity(15, TcpStream::connect("127.0.0.1:34254").unwrap());

1.8 Seek移动游标

Seek 特征提供了一个可以在字节流中移动的游标,流通常具有固定大小,允许相对于任一端或当前偏移量进行查找。

比如,File 实现了Seek,使用其中的seek方法以移动游标:

1
2
3
4
5
6
7
8
9
10
11
12
use std::io;
use std::io::prelude::*;
use std::fs::File;
use std::io::SeekFrom;

fn main() -> io::Result<()> {
let mut f = File::open("foo.txt")?;

// 从文件的开头将游标移动 42 个字节
f.seek(SeekFrom::Start(42))?;
Ok(())
}

seek的函数签名:

1
fn seek(&mut self, pos: SeekFrom) -> Result<u64>;

可以在流中寻找以字节为单位的偏移量。

SeekFrom是一个枚举:

1
2
3
4
5
pub enum SeekFrom {
Start(u64),
End(i64),
Current(i64),
}

其中:

  • Start将偏移量设置为提供的字节数
  • End将偏移量设置为此对象的大小加上提供的字节数
  • Current将偏移量设置为当前位置加上提供的字节数

除了seek之外,还有一些方法:

  • rewind用于回到流的开头,这是一个方便的方法,相当于 seek(SeekFrom::Start(0))

    1
    fn rewind(&mut self) -> Result<()>
  • stream_position从流的开头返回当前搜索位置,这相当于 self.seek(SeekFrom::Current(0))

    1
    fn stream_position(&mut self) -> Result<u64>

    比如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    use std::{
    io::{self, BufRead, BufReader, Seek},
    fs::File,
    };

    fn main() -> io::Result<()> {
    let mut f = BufReader::new(File::open("foo.txt")?);

    let before = f.stream_position()?;
    f.read_line(&mut String::new())?;
    let after = f.stream_position()?;

    println!("The first line was {} bytes long", after - before);
    Ok(())
    }

1.9 常用的读写类型

标准流

分为标准输入Stdin,标准输出Stdout,标准错误Stderr。标准流是多个线程共享的,因此使用需要使用互斥锁。并且提供了相应的函数来构造:

(1)标准输入

标准输入通过std::io::stdin构造:

1
pub fn stdin() -> Stdin {}

返回的每个句柄都是对共享全局缓冲区的引用,其访问通过互斥锁同步。

使用隐式同步:

1
2
3
4
5
6
7
use std::io;

fn main() -> io::Result<()> {
let mut buffer = String::new();
io::stdin().read_line(&mut buffer)?;
Ok(())
}

使用显式同步:

1
2
3
4
5
6
7
8
9
10
use std::io::{self, BufRead};

fn main() -> io::Result<()> {
let mut buffer = String::new();
let stdin = io::stdin();
let mut handle = stdin.lock();

handle.read_line(&mut buffer)?;
Ok(())
}

lock方法将此句柄锁定到标准输入流,返回一个可读保护。当返回的锁超出范围时释放锁。

另外,read_line方法锁定句柄并读取一行输入,将其附加到指定的缓冲区,它的函数签名:

1
pub fn read_line(&self, buf: &mut String) -> Result<usize>

示例:

1
2
3
4
5
6
7
8
9
10
use std::io;

let mut input = String::new();
match io::stdin().read_line(&mut input) {
Ok(n) => {
println!("{n} bytes read");
println!("{input}");
}
Err(error) => println!("error: {error}"),
}
(2)标准输出

同标准输入,可以通过std::io::stdout构造:

1
pub fn stdout() -> Stdout {}

使用隐式同步:

1
2
3
4
5
6
7
use std::io::{self, Write};

fn main() -> io::Result<()> {
io::stdout().write_all(b"hello world")?;

Ok(())
}

使用显式同步:

1
2
3
4
5
6
7
8
9
10
use std::io::{self, Write};

fn main() -> io::Result<()> {
let stdout = io::stdout();
let mut handle = stdout.lock();

handle.write_all(b"hello world")?;

Ok(())
}
(3)标准错误

可以通过std::io::stderr构造:

1
pub fn stderr() -> Stderr {}

使用隐式同步:

1
2
3
4
5
6
7
use std::io::{self, Write};

fn main() -> io::Result<()> {
io::stderr().write_all(b"hello world")?;

Ok(())
}

使用显式同步:

1
2
3
4
5
6
7
8
9
10
use std::io::{self, Write};

fn main() -> io::Result<()> {
let stderr = io::stderr();
let mut handle = stderr.lock();

handle.write_all(b"hello world")?;

Ok(())
}

游标Cursor

Cursor 包装内存缓冲区并为其提供 Seek 实现。

1
2
3
4
5
6
7
8
9
10
pub struct Cursor<T> {
inner: T,
pos: u64,
}

impl<T> Cursor<T> {
pub const fn new(inner: T) -> Cursor<T> {
Cursor { pos: 0, inner }
}
}

Cursor 们与内存缓冲区一起使用,new方法的参数inner可以是任何实现 AsRef<[u8]> 的东西,因此也可以传递 &[u8]&strVec<u8>

Cursor 的结构很简单,只有两个字段:inner 本身和一个整数,即 inner 中下一次读取将开始的偏移量,该位置最初为 0

Cursor 实现 ReadBufReadSeek,如果 inner 的类型是 &mut [u8]Vec<u8>,那么 Cursor 也会实现 Write

写入 Curosr 会覆盖 inner 中从当前位置开始的字节。如果试图写超出 &mut [u8] 的末尾,会得到一个部分写或一个 io::Error。不过,使用 Curosr 写入 Vec<u8> 的末尾是可以的,它会增大 vector。因此,Cursor<&mut [u8]>Cursor<Vec<u8>> 实现了所有 4std::io::prelude 中的特征。

TcpStream

std::net::TcpStream是本地和远程套接字之间的 TCP 流,可读可写,TcpStream::connect(("hostname", PORT)) 尝试去连接到一个 server 并且返回 io::Result<TcpStream>

1
2
3
4
5
6
7
8
9
10
use std::io::prelude::*;
use std::net::TcpStream;

fn main() -> std::io::Result<()> {
let mut stream = TcpStream::connect("127.0.0.1:34254")?;

stream.write(&[1])?;
stream.read(&mut [0; 128])?;
Ok(())
} // the stream is closed here

Command

std::process::Command:支持生成子进程并将数据传输到其标准输入,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use std::error::Error;
use std::io::Write;
use std::process::{Command, Stdio};

fn main() -> Result<(), Box<dyn Error + Send + Sync + 'static>> {
let mut child = Command::new("grep")
.arg("-e")
.arg("a.*e.*i.*o.*u")
.stdin(Stdio::piped())
.spawn()?;

let my_words = vec!["hello", "world"];
let mut to_child = child.stdin.take().unwrap();
for word in my_words {
writeln!(to_child, "{}", word)?;
}
drop(to_child); // close grep's stdin, so it will exit
child.wait()?;

Ok(())
}

其它读取器和写入器

std::io 模块还提供了一些返回实验性的读取器和写入器的函数:

  • io::sink:没有实际操作,所有写操作返回 Ok,但是数据被丢弃了:

    1
    2
    3
    4
    5
    use std::io::{self, Write};

    let buffer = vec![1, 2, 3, 5, 8];
    let num_bytes = io::sink().write(&buffer).unwrap();
    assert_eq!(num_bytes, 5);
  • io::empty:总是读取成功,但返回属于结束。不将任何内容读入缓冲区的一个有点悲伤的例子:

    1
    2
    3
    4
    5
    use std::io::{self, Read};

    let mut buffer = String::new();
    io::empty().read_to_string(&mut buffer).unwrap();
    assert!(buffer.is_empty());
  • io::repeat:返回 Reader 无止境地重复给定字节

    1
    2
    3
    4
    5
    use std::io::{self, Read};

    let mut buffer = [0; 3];
    io::repeat(0b101).read_exact(&mut buffer).unwrap();
    assert_eq!(buffer, [0b101, 0b101, 0b101]);

2 path

std::path,用于跨平台路径操作。

该模块提供两种类型, PathBufPath (类似于 Stringstr ),用于抽象地处理路径。这些类型分别是 OsStringOsStr 的薄包装,这意味着它们根据本地平台的路径语法直接处理字符串。

这些功能都涉及使用文件名,因此我们将从文件名类型开始。

2.1 OsStringOsStr

std::ffi::OsString是一种可以表示拥有的、可变的平台原生字符串的类型,但可以简单地与 Rust 字符串相互转换。

由于操作系统不会强制文件名是有效的 Unicode,下面是两个创建文本文件的 shell 命令,只有第一个使用有效的 UTF-8 文件名:

1
2
$ echo "hello world" > ô.txt
$ echo "O brave new world, that has such filenames in't" > $'\xf4'.txt

对于内核,任何字节串(不包括空字节和斜杠)都是可接受的文件名。在 Windows 上也有类似的情况,几乎任何 16 位 “宽字符” 字符串都是可接受的文件名,即使是无效的 UTF-16 字符串也是如此。操作系统处理的其他字符串也是如此,例如命令行参数和环境变量。

在 Unix 系统上,字符串通常是非零字节的任意序列,在许多情况下被解释为 UTF-8,任何字节串(不包括空字节和斜杠)都是可接受的文件名。在 Windows 上也有类似的情况,非零 16 位值的任意序列都是可接受的文件名,在有效时被解释为 UTF-16。在 Rust 中,字符串始终是有效的 UTF-8,其中可能包含零。

OsStringOsStr 通过同时表示 Rust 和平台原生字符串值来弥合这一差距,特别是如果可能的话,允许将 Rust 字符串免费转换为“操作系统”字符串。

std::ffi::&OsStrOsString 就像 &strString 一样:前者是引用,后者是具有所有权的字符串。

OsString常用方法

由于OsString 实现 From<String> ,可以使用 my_string.into() 从普通 Rust 字符串创建 OsString

1
2
3
4
5
use std::ffi::OsString;

fn main() {
let o_str: OsString = String::from("hello world!").into();
}

也可以使用 OsString::new 方法创建一个空的 OsString ,然后使用 OsString::push 方法将字符串切片压入填充它。

1
2
3
4
5
6
7
use std::ffi::OsString;

fn main() {
let mut os_string = OsString::new();
os_string.push("hello world");
println!("{:?}", os_string);
}

OsString转换为String

1
2
3
4
5
6
7
use std::ffi::OsString;

fn main() {
let os_string = OsString::from("foo");
let string = os_string.into_string();
assert_eq!(string, Ok(String::from("foo")));
}

with_capacity方法创建一个至少具有给定容量的新 OsString 。如果一开始就知道需要创建多大的字符串,就不需要创建空串然后动态添加,前者的好处是不需要重新分配堆空间:

1
2
3
4
5
6
7
8
9
10
11
use std::ffi::OsString;

fn main() {
let mut os_string = OsString::with_capacity(10);
let capacity = os_string.capacity();

// 这个push操作不需要重新分配空间
os_string.push("foo");

assert_eq!(capacity, os_string.capacity());
}

as_os_str方法将OsString转换为 OsStr 切片:

1
2
3
4
5
6
7
use std::ffi::{OsStr, OsString};

fn main() {
let os_string = OsString::from("foo");
let os_str = OsStr::new("foo");
assert_eq!(os_string.as_os_str(), os_str);
}

OsStr常用方法

&str强制转换为 OsStr 切片:

1
2
3
4
5
use std::ffi::OsStr;

fn main() {
let os_str = OsStr::new("foo");
}

to_str用于从OsStr 切片转换为&str,这种转换可能需要检查 UTF-8 的有效性:

1
2
3
4
5
6
use std::ffi::OsStr;

fn main() {
let os_str = OsStr::new("foo");
assert_eq!(os_str.to_str(), Some("foo"));
}

将切片复制到拥有的 OsString 中:

1
2
3
4
5
6
7
use std::ffi::{OsStr, OsString};

fn main() {
let os_str = OsStr::new("foo");
let os_string = os_str.to_os_string();
assert_eq!(os_string, OsString::from("foo"));
}

2.2 std::path::Path

std::path::Path是路径的一部分(类似于 str )。此类型支持许多用于检查路径的操作,包括将路径分解为其组件(在 Unix 上以 / 分隔,在 Windows 上以 /\ 分隔),提取文件名,确定路径是否是绝对的路径,等等。

这是一个未定大小的类型,这意味着它必须始终在 &Box 之类的指针后面使用。

路径操作包括从切片中解析组件和构建新的路径,要解析路径,可以从 str 切片创建 Path 切片,一个简单的例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use std::ffi::OsStr;
use std::path::Path;

fn main() {
let path = Path::new("/tmp/foo/bar.txt");

let parent = path.parent();
assert_eq!(parent, Some(Path::new("/tmp/foo")));

let file_stem = path.file_stem();
assert_eq!(file_stem, Some(OsStr::new("bar")));

let extension = path.extension();
assert_eq!(extension, Some(OsStr::new("txt")));
}

Path 提供以下常用方法。

new

直接将字符串切片包装为 Path 切片。

1
2
3
4
5
use std::path::Path;

fn main() {
Path::new("foo.txt");
}

可以从 String 甚至其它 Path 创建 Path

1
2
3
4
5
6
7
8
use std::path::Path;

fn main() {
let string = String::from("foo.txt");
let from_string = Path::new(&string);
let from_path = Path::new(&from_string);
assert_eq!(from_string, from_path);
}

parent

函数签名:

1
pub fn parent(&self) -> Option<&Path>

如果有的话,parent返回不包含其最终组成部分的 Path

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::io;
use std::path::Path;

fn main() {
let path = Path::new("/foo/bar");
let parent = path.parent().unwrap();
assert_eq!(parent, Path::new("/foo"));

let grand_parent = parent.parent().unwrap();
assert_eq!(grand_parent, Path::new("/"));
assert_eq!(grand_parent.parent(), None);

let relative_path = Path::new("foo/bar");
let parent = relative_path.parent();
assert_eq!(parent, Some(Path::new("foo")));
let grand_parent = parent.and_then(Path::parent);
assert_eq!(grand_parent, Some(Path::new("")));
let great_grand_parent = grand_parent.and_then(Path::parent);
assert_eq!(great_grand_parent, None);
}

这意味着它为具有一个组成部分的相对路径返回 Some("") 。如果路径在根或前缀中终止,或者它是空字符串,则返回 None

file_name

函数签名:

1
pub fn file_name(&self) -> Option<&OsStr>

返回 Path 的最后一个组成部分(如果有)。如果路径是普通文件,则这是文件名。如果它是目录的路径,则这是目录名称。如果路径在 .. 中终止,则返回 None

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::ffi::OsStr;
use std::path::Path;

fn main() {
assert_eq!(Some(OsStr::new("bin")), Path::new("/usr/bin/").file_name());
assert_eq!(
Some(OsStr::new("foo.txt")),
Path::new("tmp/foo.txt").file_name()
);
assert_eq!(
Some(OsStr::new("foo.txt")),
Path::new("foo.txt/.").file_name()
);
assert_eq!(
Some(OsStr::new("foo.txt")),
Path::new("foo.txt/.//").file_name()
);
assert_eq!(None, Path::new("foo.txt/..").file_name());
assert_eq!(None, Path::new("/").file_name());
}

is_absolute、is_relative、is_dir、is_file

这四个方法分别用来判断路径是否为:绝对路径、相对路径、目录、文件。

1
2
3
4
5
6
7
8
9
10
11
12
use std::path::Path;

fn main() {
assert_eq!(Path::new("./is_a_directory/").is_dir(), true);
assert_eq!(Path::new("a_file.txt").is_dir(), false);

assert_eq!(Path::new("./is_a_directory/").is_file(), false);
assert_eq!(Path::new("a_file.txt").is_file(), true);

assert!(!Path::new("foo.txt").is_absolute());
assert!(Path::new("foo.txt").is_relative());
}

join

函数签名:

1
pub fn join<P: AsRef<Path>>(&self, path: P) -> PathBuf

创建一个有所有权的 PathBuf ,其中 pathself 相连。如果 path 是绝对路径,仅仅返回 path 的副本,所以这个方法能被用于转换任何路径为绝对路径。

1
2
3
4
5
6
7
use std::path::{Path, PathBuf};

fn main() {
assert_eq!(Path::new("/etc").join("passwd"), PathBuf::from("/etc/passwd"));
let abs_path = PathBuf::from("/root/").join(PathBuf::from("/etc/passwd"));
println!("{:?}", abs_path);
}

components

在路径的 Component s上生成迭代器,包含给定路径从左至右的所有部分。内容类型是 std::path::Component,它是一个枚举,能代表一个文件路径中所有不同的片段:

1
2
3
4
5
6
7
pub enum Component<'a> {
Prefix(PrefixComponent<'a>),
RootDir,
CurDir,
ParentDir,
Normal(&'a OsStr),
}

解析路径时,存在少量路径规范化:

  • 重复的分隔符将被忽略,因此 a/ba//b 都将 ab 作为组件。
  • . 的出现被规范化,除非它们位于路径的开头。例如, a/./ba/b/a/b/.a/b 都有 ab 作为组件,但 ./a/b 以额外的 CurDir 组件开头。
  • 尾部斜杠被规范化, /a/b/a/b/ 是等效的。

请注意,除此之外不会发生其他规范化。特别是, a/ca/b/../c 是不同的,以解释 b 是符号链接的可能性(因此其父链接不是 a )。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::ffi::OsStr;
use std::path::{Component, Path};

fn main() {
let mut components = Path::new("/tmp/foo.txt").components();

assert_eq!(components.next(), Some(Component::RootDir));
assert_eq!(
components.next(),
Some(Component::Normal(OsStr::new("tmp")))
);
assert_eq!(
components.next(),
Some(Component::Normal(OsStr::new("foo.txt")))
);
assert_eq!(components.next(), None)
}

ancestors

生成基于 Path 及其祖先的迭代器。迭代器将生成在 parent 方法使用零次或多次时返回的 Path 。这意味着,迭代器将产生 &self&self.parent().unwrap()&self.parent().unwrap().parent().unwrap() 等。如果 parent 方法返回 None ,迭代器也会这样做。迭代器将始终产生至少一个值,即 &self

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::path::Path;

fn main() {
let mut ancestors = Path::new("/foo/bar").ancestors();
assert_eq!(ancestors.next(), Some(Path::new("/foo/bar")));
assert_eq!(ancestors.next(), Some(Path::new("/foo")));
assert_eq!(ancestors.next(), Some(Path::new("/")));
assert_eq!(ancestors.next(), None);

let mut ancestors = Path::new("../foo/bar").ancestors();
assert_eq!(ancestors.next(), Some(Path::new("../foo/bar")));
assert_eq!(ancestors.next(), Some(Path::new("../foo")));
assert_eq!(ancestors.next(), Some(Path::new("..")));
assert_eq!(ancestors.next(), Some(Path::new("")));
assert_eq!(ancestors.next(), None);
}

更多方法

还有一些更多的方法,比较简单,比如existscanonicalize等等,具体可以参考标准库文档。将 Path 转换为字符串有三种方法,每一个都允许 Path 中出现无效 UTF-8 的可能性:

  • path.to_str():返回 Option<&str>,如果包含无效的 UTF-8,返回 None
  • path.to_string_lossy():这基本上是同一件事,但它设法在所有情况下返回某种字符串。如果路径不是有效的 UTF-8,这些方法会创建一个副本,用 Unicode 替换字符 U+FFFD ('�') 替换每个无效的字节序列;
  • path.display():用于路径打印,它返回的值不是字符串,但它实现了 std::fmt::Display,因此它可以与 format!()println!() 等一起使用。 如果路径不是有效的 UTF-8,则输出可能包含 字符。

2.3 std::path::PathBuf

std::path::PathBuf拥有所有权的可变路径(类似于 String )。此类型提供了 pushset_extension 等方法,这些方法会就地改变路径。它还实现了 DerefPath ,这意味着 Path 切片上的所有方法也可用于 PathBuf 值。

基础的使用方法就是生成路径,总的来说有三种方法。

第一种可以使用 push 从组件构建 PathBuf

1
2
3
4
5
6
7
8
9
10
11
use std::path::PathBuf;

fn main() {
let mut path = PathBuf::new();

path.push(r"C:\");
path.push("windows");
path.push("system32");

path.set_extension("dll");
}

但是, push 最适合用于动态情况。当提前了解所有组件时,这是第二种执行此操作的更好方法:

1
2
3
4
5
use std::path::PathBuf;

fn main() {
let path: PathBuf = [r"C:\", "windows", "system32.dll"].iter().collect();
}

第三种方法,由于这些都是字符串,我们可以使用 From::from

1
2
3
4
5
use std::path::PathBuf;

fn main() {
let path = PathBuf::from(r"C:\windows\system32.dll");
}

具体使用哪种方法,取决于所处的实现情景中。

3 fs

std::fs用于文件系统操作操作。此模块包含操作本地文件系统内容的基本方法,其中的所有方法都表示跨平台文件系统操作。额外的特定于平台的功能可以在 std::os::$platform 的扩展特征中找到。

下面介绍一些常用的函数操作。

3.1 创建和删除

create_dir

在提供的路径上创建一个新的空目录。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
fs::create_dir("/some/dir")?;
Ok(())
}

此函数将在以下情况下返回错误,但不仅限于以下情况:

  • 用户缺少在 path 创建目录的权限。
  • 给定路径的父路径不存在。(若要同时创建目录及其所有缺少的父目录,请使用 create_dir_all 函数。
  • path 已存在。

create_dir_all

递归创建目录及其所有父组件(如果缺少)。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
fs::create_dir_all("/some/dir")?;
Ok(())
}

此函数将在以下情况下返回错误,但不仅限于以下情况:

  • 如果 path 指定的路径中的任何目录尚不存在,否则无法创建该目录。 fs::create_dir 概述了创建目录时(确定目录不存在后)的特定错误条件。

对于无法创建 path 中指定的任何目录的情况,这是一个值得注意的例外,因为它是同时创建的。此类案例被认为是成功的。也就是说,从多个线程或进程并发调用 create_dir_all 可以保证不会因自身的争用条件而失败。

remove_dir

删除空目录。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
fs::remove_dir("/some/dir")?;
Ok(())
}

此函数将在以下情况下返回错误,但不仅限于以下情况:

  • path 不存在。
  • path 不是目录。
  • 用户缺少删除提供的 path 处的目录的权限。
  • 目录不为空。

remove_dir_all

删除目录的所有内容后,删除此路径上的目录。这个方法一定要小心使用。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
fs::remove_dir_all("/some/dir")?;
Ok(())
}

remove_file

从文件系统中删除文件。不过不能保证立即删除文件(例如,根据平台的不同,其他打开的文件描述符可能会阻止立即删除)。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
fs::remove_file("a.txt")?;
Ok(())
}

此函数将在以下情况下返回错误,但不仅限于以下情况:

  • path 指向一个目录。
  • 该文件不存在。
  • 用户缺少删除文件的权限。

3.2 移动、拷贝和链接

copy

函数签名:

1
pub fn copy<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> Result<u64>

将一个文件的内容from复制到另一个文件to。此函数还会将原始文件的权限位复制到目标文件。

此函数将覆盖 to 的内容。请注意,如果 fromto 都指向同一个文件,则此操作可能会截断该文件。

复制成功后,将返回复制的总字节数,该字节数等于 metadata 报告的 to 文件的长度。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
fs::copy("foo.txt", "bar.txt")?; // Copy foo.txt to bar.txt
Ok(())
}

此函数将在以下情况下返回错误,但不仅限于以下情况:

  • from 既不是常规文件,也不是指向常规文件的符号链接。
  • from 不存在。
  • 当前进程没有读取 from 或写入 to 的权限。

rename

函数签名:

1
pub fn rename<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> Result<()>

将文件或目录重命名为新名称,如果 to 已存在,则替换原始文件。如果新名称位于不同的挂载点上,这将不起作用。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
fs::rename("a.txt", "b.txt")?; // Rename a.txt to b.txt
Ok(())
}

此函数将在以下情况下返回错误,但不仅限于以下情况:

  • from 不存在。
  • 用户缺少查看内容的权限。
  • fromto 位于不同的文件系统上。

函数签名:

1
2
3
4
pub fn hard_link<P: AsRef<Path>, Q: AsRef<Path>>(
original: P,
link: Q
) -> Result<()>

在文件系统上创建新的硬链接。link 路径将是指向 original 路径的链接。请注意,系统通常要求这两个路径都位于同一个文件系统上。

如果 original 命名符号链接,则是否遵循符号链接取决于平台。在可以不遵循它的平台上,它不会被遵循,并且创建的硬链接指向符号链接本身。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
fs::hard_link("a.txt", "b.txt")?; // Hard link a.txt to b.txt
Ok(())
}

此函数将在以下情况下返回错误,但不仅限于以下情况:

  • original 路径不是文件或不存在。

3.3 查看信息

canonicalize

函数签名:

1
pub fn canonicalize<P: AsRef<Path>>(path: P) -> Result<PathBuf>

返回路径的规范、绝对路径的形式,其中所有中间组件都规范化并解析了符号链接。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
let path = fs::canonicalize("../a/../foo.txt")?;
Ok(())
}

此函数将在以下情况下返回错误,但不仅限于以下情况:

  • path 不存在。
  • 路径中的非最终组成部分不是目录。

metadata

函数签名:

1
pub fn metadata<P: AsRef<Path>>(path: P) -> Result<Metadata>

给定一个路径,查询文件系统以获取有关文件、目录等元数据的信息。此函数将遍历符号链接以查询有关目标文件的信息。

1
2
3
4
5
6
7
use std::fs;

fn main() -> std::io::Result<()> {
let attr = fs::metadata("/some/file/path.txt")?;
// inspect attr ...
Ok(())
}

查询有关文件的元数据,而不遵循符号链接。

1
2
3
4
5
6
7
use std::fs;

fn main() -> std::io::Result<()> {
let attr = fs::symlink_metadata("/some/file/path.txt")?;
// inspect attr ...
Ok(())
}

read_dir

函数签名:

1
pub fn read_dir<P: AsRef<Path>>(path: P) -> Result<ReadDir>

返回指定目录中条目的迭代器。迭代器将产生 io::Result<DirEntry> 的实例。最初构造迭代器后可能会遇到新的错误。跳过当前目录和父目录(通常为 ... )的条目。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::io;
use std::fs::{self, DirEntry};
use std::path::Path;

// 一种仅通过访问文件来遍历目录的可能实现方式
fn visit_dirs(dir: &Path, cb: &dyn Fn(&DirEntry)) -> io::Result<()> {
if dir.is_dir() {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
// 递归访问目录
if path.is_dir() {
visit_dirs(&path, cb)?;
} else {
cb(&entry);
}
}
}
Ok(())
}

遍历path并收集的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::{fs, io};

fn main() -> io::Result<()> {
let mut entries = fs::read_dir(".")?
.map(|res| res.map(|e| e.path()))
.collect::<Result<Vec<_>, io::Error>>()?;

// 不保证 `read_dir` 返回条目的顺序。
// 如果需要可重复的排序,则应对条目进行显式排序。

entries.sort();

// 现在,条目已经按照路径进行了排序。

Ok(())
}

函数签名:

1
pub fn read_link<P: AsRef<Path>>(path: P) -> Result<PathBuf>

读取符号链接,返回链接指向的文件。

1
2
3
4
5
6
use std::fs;

fn main() -> std::io::Result<()> {
let path = fs::read_link("a.txt")?;
Ok(())
}

3.4 文件打开方式

std::fs::File结构体提供对文件系统上打开文件的访问的对象。

可以读取或写入 File 的实例,具体取决于打开它时使用的选项。文件还实现 Seek 来更改文件内部包含的逻辑游标。文件超出范围时会自动关闭。 Drop 的实现会忽略关闭时检测到的错误。

该结构体实现了std::io::Readstd::io::Write,因此可以使用这些特征的方法。具体这些方法已经在标准库std::io中介绍过了。

create

签名:

1
pub fn create<P: AsRef<Path>>(path: P) -> Result<File>

以只写模式打开文件。如果文件不存在,此函数将创建一个文件,如果存在,则会截断它。

比如,创建一个新文件并向其写入字节:

1
2
3
4
5
6
7
8
use std::fs::File;
use std::io::prelude::*;

fn main() -> std::io::Result<()> {
let mut file = File::create("foo.txt")?;
file.write_all(b"Hello, world!")?;
Ok(())
}

open

签名:

1
pub fn open<P: AsRef<Path>>(path: P) -> Result<File>

尝试以只读模式打开文件。如果 path 尚不存在,此函数将返回错误。

create_new

签名:

1
pub fn create_new<P: AsRef<Path>>(path: P) -> Result<File>

在读写模式下创建新文件。如果文件不存在,此函数将创建一个文件,如果存在,则返回错误。这样,如果调用成功,则返回的文件保证是新的。

目前这是实验性的方法,仍然处在nightly版本中。

1
2
3
4
5
6
7
8
#![feature(file_create_new)]

use std::fs::File;

fn main() -> std::io::Result<()> {
let mut f = File::create_new("foo.txt")?;
Ok(())
}

options

签名:

1
pub fn options() -> OpenOptions

返回一个新的OpenOptions对象。它是一个控制文件打开方式的配置项结构体。 open()create() 方法只能以只读或者只写的方式打开文件,有时不合适,此时可以使用该对象打开或创建具有特定选项的文件。

1
2
3
4
5
6
use std::fs::File;

fn main() -> std::io::Result<()> {
let mut f = File::options().append(true).open("example.log")?;
Ok(())
}

它等效于 OpenOptions::new() ,但允许你编写更具可读性的代码。代替 OpenOptions::new().append(true).open("example.log") ,你可以编写 File::options().append(true).open("example.log") 。这也避免了导入 OpenOptions 的需要。

3.5 OpenOptions文件打开配置

该结构体可用于配置文件打开方式的选项和标志。此结构体公开了配置如何打开 File 以及允许对打开的文件执行哪些操作的功能。 File::openFile::create 方法是使用此生成器的常用选项的别名。

一般来说,使用 OpenOptions 时,首先调用 OpenOptions::new ,然后将调用链式调用到设置每个选项的方法,然后调用 OpenOptions::open ,传递你尝试打开的文件的路径。这将为你提供一个 io::Result ,里面有一个 File 可以进一步操作。

比如,打开文件进行读取和写入,如果文件不存在,则创建该文件:

1
2
3
4
5
6
7
use std::fs::OpenOptions;

let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open("foo.txt");

append

如果想以追加模式打开,可以将append设置为true,表示写入将追加到文件而不是覆盖以前的内容。请注意,设置 .write(true).append(true) 与仅设置 .append(true) 具有相同的效果:

1
2
3
use std::fs::OpenOptions;

let file = OpenOptions::new().append(true).open("foo.txt");

对于大多数文件系统,操作系统保证所有写入都是原子的:不会因为另一个进程同时写入而破坏任何写入。

使用追加模式时,一个可能明显的注意事项:确保在一次操作中将属于一起的所有数据写入文件。这可以通过在将字符串传递给 write() 之前连接字符串来完成,或者使用缓冲编写器(具有足够大小的缓冲区),并在消息完成时调用 flush()

另外,如果同时具有读取和追加访问权限打开文件,请注意,在打开后和每次写入后,读取位置可能会设置在文件的末尾。因此,在写入之前,需要保存当前位置(使用 seek(SeekFrom::Current(0)) ),并在下次读取之前将其还原。

如果文件不存在,则此函数不会创建该文件,此时要使用 OpenOptions::create 方法执行此操作。

truncate

truncate设置用于截断上一个文件的选项。如果在设置此选项的情况下成功打开文件,则会将文件截断为 0 长度(如果该文件已存在)。必须使用写入权限打开文件,截断才能正常工作。

1
2
3
use std::fs::OpenOptions;

let file = OpenOptions::new().write(true).truncate(true).open("foo.txt");

4 time

该模块用于量化时间。它主要提供如下结构:

  • Duration,表示时间跨度的 Duration 类型,通常用于系统超时。
  • Instant,单调非递减时钟的测量,不透明且仅能配合 Duration 使用。
  • SystemTime,系统时钟的测量值,可用于与文件系统或其他进程等外部实体通信。

4.1 使用Duration表示时间跨度

每个 Duration 由整数秒和以纳秒表示的小数部分组成:

1
2
3
4
5
const NANOS_PER_SEC: u32 = 1_000_000_000;
pub struct Duration {
secs: u64,
nanos: Nanoseconds, // Always 0 <= nanos < NANOS_PER_SEC
}

其中,nanos部分表示纳秒,它的范围是[0,NANOS_PER_SEC],由于1_000_000_000纳秒等于1秒,如果超过则需要进位,加到secs中去。另外,如果底层系统不支持纳秒级精度,绑定系统超时的 API 通常会将纳秒数取整。

构建时间跨度

可使用Duration::new(Sec, Nano_sec)来构建Duration。例如,Duration::new(1, 300)构建了一个1秒300纳秒的Duration,即总共1_000_000_300纳秒。

特殊地,如果两个参数都指定为0,那么表示时长为0,可用is_zero()来检测某个Duration是否是0时长。0时长可用于上下文切换,例如sleep睡眠0秒,表示不用睡眠,但会交出CPU使得发生上下文切换。

还可以通过from_开头的函数进行构建:

  • Duration::from_secs(3):3秒时长
  • Duration::from_millis(300):300毫秒时长
  • Duration::from_micros(300):300微秒时长
  • Duration::from_nanos(300):300纳秒时长
  • Duration::from_secs_f32(2.3):2.3秒时长
  • Duration::from_secs_f64(2.3):2.3秒时长

通过时间跨度进行单位转换

构建出的Duration可以通过以下方法提取并转换它的秒、毫秒、微秒、纳秒单位。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::time::Duration;

fn main() {
let duration = Duration::new(1,3000);
println!("as_secs,转换为秒: {:?} ", duration.as_secs());
println!("as_millis,转换为毫秒: {:?}", duration.as_millis());
println!("as_micros,转换为微秒: {:?}", duration.as_micros());
println!("as_micros,转换为纳秒: {:?}", duration.as_nanos());
println!("as_secs_f32,转换为小数表示: {:?}", duration.as_secs_f32());
println!("as_secs_f64,转换为小数表示: {:?}", duration.as_secs_f64());
println!("subsec_millis,小数部分转换为毫秒: {:?}", duration.subsec_millis());
println!("subsec_micros,小数部分转换为微秒: {:?}", duration.subsec_micros());
println!("subsec_nanos,小数部分转换为纳秒: {:?}", duration.subsec_nanos());
}

输出结果:

1
2
3
4
5
6
7
8
9
as_secs,转换为秒: 1 
as_millis,转换为毫秒: 1000
as_micros,转换为微秒: 1000003
as_micros,转换为纳秒: 1000003000
as_secs_f32,转换为小数表示: 1.000003
as_secs_f64,转换为小数表示: 1.000003
subsec_millis,小数部分转换为毫秒: 0
subsec_micros,小数部分转换为微秒: 3
subsec_nanos,小数部分转换为纳秒: 3000

通过时间跨度进行运算和比较

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use std::time::Duration;

fn main() {
let duration = Duration::new(1,3000);
println!("checked_add,时长的加法运算,超出Duration范围时返回None: {:?} ", duration.checked_add(Duration::new(0, 1)));
println!("checked_add,时长的减法运算,超出Duration范围时返回None: {:?} ", duration.checked_sub(Duration::new(0, 1)));
println!("checked_add,时长的乘法运算,超出Duration范围时返回None: {:?} ", duration.checked_mul(2));
println!("checked_add,时长的除法运算,超出Duration范围(分母为0)时返回None: {:?} ", duration.checked_div(2));
println!("checked_add,饱和时长的除法运算,如果发生溢出则返回 Duration::MAX: {:?} ", duration.saturating_add(Duration::new(0, 1)));
println!("checked_add,饱和时长的除法运算,如果发生溢出则返回 0: {:?} ", duration.saturating_sub(Duration::new(0, 1)));
println!("checked_add,饱和时长的除法运算,如果发生溢出则返回 Duration::MAX: {:?} ", duration.saturating_mul(2));
println!("时长乘以小数,得到的结果如果超出范围或无效,则panic: {:?} ", duration.mul_f32(2.3));
println!("时长乘以小数,得到的结果如果超出范围或无效,则panic: {:?} ", duration.mul_f64(2.3));
println!("时长除以小数,得到的结果如果超出范围或无效,则panic: {:?} ", duration.div_f32(2.3));
println!("时长除以小数,得到的结果如果超出范围或无效,则panic: {:?} ", duration.div_f64(2.3));
}

4.2 使用Instant表示时间点

单调非递减时钟的测量,不透明且仅能配合 Duration 使用。

所谓不透明,指的是只能相互比较,没有办法从Instant得到“秒数”,相反,它只允许测量两个Instant之间的持续时间(或比较两个Instant)。

除非出现平台错误,否则始终保证后创建的Instant不小于任何先前创建的Instant

例如,此刻是处在某个时间点A,下一次(例如某个时长过后),处在另一个时间点B,时间点B一定不会早于时间点A,即便修改了操作系统的时钟或硬件时钟,它也不会时光倒流的现象。

创建一个“现在”时间的Instant

1
2
3
use std::time::Instant;

let now = Instant::now();

除此之外,还有一些实用的方法。

  • checked_add接受一个duration,如果 t 可以表示为 Instant (这意味着它在基础数据结构的范围内),则返回 Some(t) ,其中 t 是时间 self + duration ,否则返回 None

  • checked_sub同理,如果 t 可以表示为 Instant (这意味着它在基础数据结构的范围内),则返回 Some(t) ,其中 t 是时间 self - duration ,否则返回 None

  • elapsed返回自Instant创建以来到现在经过的时间量。

  • duration_since接受另一个Instant,返回从另一Instant到当前Instant经过的时间跨度(Duration),如果该Instant晚于这一Instant,则返回0(Duration)。

    1
    2
    3
    4
    5
    6
    7
    8
    use std::time::{Duration, Instant};
    use std::thread::sleep;

    let now = Instant::now();
    sleep(Duration::new(1, 0));
    let new_now = Instant::now();
    println!("{:?}", new_now.duration_since(now));
    println!("{:?}", now.duration_since(new_now)); // 0ns

Instant可以直接进行大小比较,也可以进行加减操作。

4.3 使用Timeout表示超时时间

5 sync

std::sync提供了有用的同步原语。主要有:

以下是可用同步对象的概述:

  • Arc:原子引用计数指针,可在多线程环境中使用,以延长某些数据的生命周期,直到所有线程都完成使用它。
  • Barrier:线程屏障,确保多个线程在继续执行之前相互等待到达程序中的某个点。
  • Condvar:条件变量,提供在等待事件发生时阻塞线程的能力。
  • mpsc:多生产者、单消费者队列,用于基于消息的通信。可以提供轻量级的线程间同步机制,但代价是一些额外的内存。
  • Mutex:互斥锁,保证一次最多有一个线程能够访问部分数据。
  • Once:用于线程安全的一次性全局初始化例程
  • OnceLock:用于线程安全的全局变量的一次性初始化
  • RwLock:读写锁,允许同时多个读取者,同时只允许一个写入者。在某些情况下,这可能比互斥锁更有用。

其中的大部分,都在并发编程中已经介绍过。

6 borrow

std::borrow用于处理借用数据。它包含的三个特征在Borrow, BorrowMut, ToOwned已经介绍过。

7 net

std::net用于处理TCP/UDP网络通信。整个模块的组织架构如下:

7.1 通过TCP通信

std::net::TcpListener是一个结构体,用于TCP套接字服务端,监听连接。

创建监听者

一般通过bind关联方法创建一个新的 TcpListener(可以称之为侦听器或监听器) ,它将绑定到指定的地址,函数签名如下:

1
pub fn bind<A: ToSocketAddrs>(addr: A) -> Result<TcpListener>

传入的addr需要指定地址和端口,比如:127.0.0.1:8080,地址类型A可以是 ToSocketAddrs 特征的任何实现者,我们马上在后面介绍。

下面的例子创建了一个监听本地80端口的TCP服务端:

1
2
3
use std::net::TcpListener;

let listener = TcpListener::bind("127.0.0.1:80").unwrap();

如果 addr 产生多个地址,则 bind 将尝试使用每个地址,直到一个成功并返回对应的监听器。如果没有一个地址成功创建监听,则返回上次尝试(最后一个地址)返回的错误。

1
2
3
4
5
6
7
8
9
use std::net::{SocketAddr, TcpListener};

fn main() {
let addrs = [
SocketAddr::from(([127, 0, 0, 1], 80)),
SocketAddr::from(([127, 0, 0, 1], 443)),
];
let listener = TcpListener::bind(&addrs[..]).unwrap();
}

在这个例子中,会首先尝试创建绑定到 127.0.0.1:80 的 TCP 监听器。如果失败,则尝试创建一个绑定到 127.0.0.1:443 的 TCP 侦听器,如果失败,返回错误。

如果端口号指定为0,则将请求操作系统为该侦听器分配一个端口。分配的端口可以通过 TcpListener::local_addr 方法查询。

1
2
3
4
5
6
use std::net::TcpListener;

fn main() {
let socket = TcpListener::bind("127.0.0.1:0").unwrap();
println!("{:?}",socket.local_addr());
}

在这个例子中,侦听器将由操作系统分配一个端口。

最后注意返回值是Result<TcpListener>,说明绑定可能会失败。比如,按照惯例,连接到端口 80 需要管理员权限(非管理员只能侦听高于 1023 的端口),因此如果我们尝试在没有管理员身份的情况下连接到端口 80,则可能会失败。再比如,我们将要绑定的端口已经被其它程序占用,也会失败。

阻塞等待新传入的连接

通过accept方法接收新传入的连接,该函数将阻塞调用线程,直到建立新的 TCP 连接。建立后,将返回相应的 TcpStream 和远程连接地址。

函数签名:

1
pub fn accept(&self) -> Result<(TcpStream, SocketAddr)>

例子:

1
2
3
4
5
6
7
8
9
use std::net::TcpListener;

fn main(){
let listener = TcpListener::bind("127.0.0.1:8086").unwrap();
match listener.accept() {
Ok((_socket, addr)) => println!("new client: {addr:?}"),
Err(e) => println!("couldn't get client: {e:?}"),
}
}

通过TCP流读写数据

连接创建后,就可以读取和写入数据了。

通过accept函数返回的结果中,第一项是TcpStream类型,它也是一个结构体,用于表示本地和远程套接字之间的 TCP 流。通过 acceptTcpListener 上建立连接创建 TcpStream 后,数据可以通过对其进行读取和写入。

第二种创建TcpStream 的方式是使用TcpStream::connect 函数连接到远程主机,函数签名如下:

1
pub fn connect<A: ToSocketAddrs>(addr: A) -> Result<TcpStream>

addr 是远程主机的地址。任何实现 ToSocketAddrs 特征的东西都可以提供给地址。

比如,打开到 127.0.0.1:8080 的 TCP 连接:

1
2
3
4
5
6
7
8
9
use std::net::TcpStream;

fn main() {
if let Ok(stream) = TcpStream::connect("127.0.0.1:8080") {
println!("Connected to the server!");
} else {
println!("Couldn't connect to server...");
}
}

如果 addr 产生多个地址,则将尝试使用每个地址 connect 直到连接成功。如果没有一个地址能够成功连接,则返回上次连接尝试(最后一个地址)返回的错误。

比如,打开到 127.0.0.1:8080 的 TCP 连接。如果连接失败,则打开到 127.0.0.1:8081 的 TCP 连接:

1
2
3
4
5
6
7
8
9
10
11
12
13
use std::net::{SocketAddr, TcpStream};

fn main() {
let addrs = [
SocketAddr::from(([127, 0, 0, 1], 8080)),
SocketAddr::from(([127, 0, 0, 1], 8081)),
];
if let Ok(stream) = TcpStream::connect(&addrs[..]) {
println!("Connected to the server!");
} else {
println!("Couldn't connect to server...");
}
}

ReadWrite中提到过,TcpStream实现了这两个特征,因此可以通过特征中定义的方法从TCP流中读写数据。

下面是一个读取和写入的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::thread;

fn main() {
let handle1 = thread::spawn(|| {
let listener = TcpListener::bind("127.0.0.1:8086").unwrap();
match listener.accept() {
Ok((mut _socket, addr)) => {
println!("new client: {addr:?}");
let mut buffer = String::new();
_socket.read_to_string(&mut buffer).unwrap();
println!("received: {}", buffer);
}
Err(e) => println!("couldn't get client: {e:?}"),
}
});
let handle2 = thread::spawn(|| {
let mut costomer = TcpStream::connect("127.0.0.1:8086").unwrap();
costomer.write_all(b"some data").unwrap();
});

handle1.join().unwrap();
handle2.join().unwrap();
}

在这个例子中,其中一个线程内创建了一个监听者listener,并使用accept阻塞并等待新的连接到来,如果有新的连接则会创建TCP流,接下来使用read_to_string方法从中读取数据到buffer

在另外一个线程则通过connect方法创建了另一个连接到listener的TCP流,然后使用wrtie_all写入了一些字节。

程序输出:

1
2
new client: 127.0.0.1:52777
received: some data

使用incoming持续监听

TcpListener 上的 incoming 方法返回一个迭代器,它为我们提供了一系列TCP流,函数签名如下:

1
pub fn incoming(&self) -> Incoming<'_>

Incoming是一个结构体,它实现了Iterator特征:

1
2
3
4
5
6
7
#[stable(feature = "rust1", since = "1.0.0")]
impl<'a> Iterator for Incoming<'a> {
type Item = io::Result<TcpStream>;
fn next(&mut self) -> Option<io::Result<TcpStream>> {
Some(self.listener.accept().map(|p| p.0))
}
}

其中,关联类型表明它返回io::Result<TcpStream>类型的值,当我们迭代它时,会产生一系列流,每个流都可以代表一个连接。并且,返回的迭代器永远不会返回 None 并且也不会产生对应的 SocketAddr 结构。迭代它相当于在循环中调用 TcpListener::accept 。你可以把它看作无限 accept 连接的迭代器。

1
2
3
4
5
6
7
8
9
10
11
use std::net::TcpListener;

fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

for stream in listener.incoming() {
let stream = stream.unwrap();

println!("Connection established!");
}
}

这样就可以持续监听一个地址并为每个TCP流创建连接,比如我们从浏览器访问127.0.0.1:7878时,可以看到程序输出:

1
2
3
4
5
6
Connection established!
Connection established!
Connection established!
Connection established!
Connection established!
Connection established!

这里可能打印多条消息,原因可能是浏览器正在请求该页面以及其他资源,例如浏览器选项卡中显示的favicon.ico图标,也可能是浏览器多次尝试连接到服务器,因为服务器没有响应任何数据。

TcpListener上的更多其它方法

具体可以参考官方文档,下面做一个简述:

  • local_addr:返回此listener的本地套接字地址
  • set_nonblocking:传入一个布尔值,将listener变为阻塞或非阻塞模式。如果IO操作成功,则返回 Ok ,无需执行任何操作。如果 IO 操作无法完成并需要重试,则返回类型为 io::ErrorKind::WouldBlock 的错误。
  • set_ttl:设置此套接字上的 IP_TTL 选项的值。该值表示从该套接字发送的每个数据包中使用的生存时间字段。
  • ttl:获取此套接字的 IP_TTL 选项的值。
  • take_error:获取此套接字上的 SO_ERROR 选项的值。这将检索底层套接字中存储的错误,清除过程中的字段。这对于检查调用之间的错误很有用。
  • try_clone:为底层套接字创建一个新的具有所有权的句柄。返回的 TcpListener 是对此对象引用的同一个套接字的引用。两个句柄都可用于接受传入连接,并且在一个侦听器上设置的选项将影响另一个侦听器。

六、Cargo

1 代理配置

受限于一些原因,可能下载速度会受到影响,需要配置proxy。

1
2
3
$ cd $HOME/.cargo/
# 第一次正常情况没有config文件,创建即可
$ vi config.toml

config.toml文件中添加:

1
2
3
4
5
[http]
proxy = "127.0.0.1:7890"

[https]
proxy = "127.0.0.1:7890"

这个是全局的代理,当我们再次使用cargo时就会走这个代理了。

如果要取消代理,只需要将文件中的配置删掉即可。

注意,如果仍然走了代理,请检查是否也给git配置了代理,cargo在自己的代理没生效时,会尝试git代理,因此需要设置一下:

1
$ vi ~/.gitconfig

改下配置,让它只对 Github 生效:

1
2
[http "https://github.com"]
proxy = http://127.0.0.1:19180

2 Getting Started

2.1 安装

获取 Cargo 的最简单方法是使用 rustup 安装 Rust 的当前稳定版本。使用 rustup 安装 Rust 也会安装 cargo

在 Linux 和 macOS 系统上,按如下方式完成此操作:

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

它将下载脚本并开始安装。

在 Windows 上,可以直接下载并运行 rustup-init.exe 。如果你是适用于 Linux 的 Windows 子系统用户,请在终端中运行以下命令,

1
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

然后按照屏幕上的说明安装 Rust。

总之,无论哪种方式,如果一切顺利,你将看到以下内容:

1
Rust is installed now. Great!

之后,可以使用 rustup 命令为 Rust 和 Cargo 安装 betanightly 通道。

有关其他安装选项和信息,访问Rust 网站的安装页面

或者,可以从源代码构建 Cargo

2.2 快速上手

在前面已经知道,要创建新的package项目,使用 cargo new

1
cargo new hello_world

默认情况下,这会默认使用 --bin 来生成二进制项(binary crate),要创建库项(library crate),需要传递 --lib

让我们看看cargo为我们生成了什么:

1
2
3
4
.
├── Cargo.toml
└── src
└── main.rs

首先,我们来看看 Cargo.toml

1
2
3
4
5
6
[package]
name = "hello_world"
version = "0.1.0"
edition = "2021"

[dependencies]

它是 Cargo 使用的配置文件,被称为manifest(清单),它包含 Cargo 编译包所需的所有元数据。

这是 src/main.rs 中的内容:

1
2
3
fn main() {
println!("Hello, world!");
}

默认为我们生成了一个“hello world”程序,在模块系统中介绍过,它被称为二进制crate(二进制包)。

我们可以使用cargo build编译它,然后运行:

1
2
$ ./target/debug/hello_world
Hello, world!

还可以使用 cargo run 一步完成编译和运行。

3 基础指南

3.1 创建项目并编译运行

书接快速上手,当使用cargo run编译运行项目时,你可能会注意到生成一个新文件 Cargo.lock ,它包含有关我们的依赖项的信息。由于目前在Cargo.toml中并没有任何依赖项,因此放到后面再讨论。

当你的项目编写完毕准备发布时,可以使用发布模式 cargo build --release 在打开优化的情况下编译文件,使用cargo build --release 将生成的二进制文件会放入 target/release 而不是 target/debug 中。

在开发时,使用cargo build默认以调试模式进行编译,由于编译器不进行优化,编译时间会更短,但代码运行速度会变慢。发布模式的编译时间较长,但实际代码运行速度会更快。

3.2 处理已有的项目

当你获得一份cargo项目时,比如从github上下载别人的源代码:

1
git clone https://github.com/rust-lang/regex.git

要构建它非常简单,只需要使用cargo build,这将获取所有依赖项,然后将它们与项目一起构建。

3.3 依赖项

crates.io是 Rust 社区维护的中心化注册服务(就像python的pip和node的npm一样),用户可以在其中寻找和下载所需的包。对于 cargo 来说,默认就是从这里下载依赖。

要依赖 crates.io 上托管的库,需要将其添加到 Cargo.toml 中。

如果你的 Cargo.toml 还没有 [dependencies] 部分,请添加该部分,然后列出你要使用的包名称和版本。下面的例子添加 time 的依赖项:

1
2
[dependencies]
time = "0.1.12"

等号左边是crate包名,右边是版本字符串,它符合SemVer规范。指定依赖项部分会介绍此处选项的更多信息。

如果我们还想添加对 regex 的依赖项,只需要在之前的依赖项下继续添加即可,整个的Cargo.toml如下:

1
2
3
4
5
6
7
8
[package]
name = "hello_world"
version = "0.1.0"
edition = "2021"

[dependencies]
time = "0.1.12"
regex = "0.1.41"

重新运行 cargo build ,cargo将获取新的依赖项及其所有依赖项,编译它们,并更新(或创建) Cargo.lock

Cargo.lock 包含有关我们使用的所有这些依赖项的哪个版本的确切信息。这个文件确保如果某个包,比如 regex 更新,我们仍将使用Cargo.lock 记录的修订版本进行构建,直到我们选择 cargo update

3.4 项目结构

典型的package结构,这是Cargo文件放置的约定。

如果二进制、示例、基准或集成测试由多个源文件组成,请将 main.rs 文件与额外模块一起放置在 src/binexamplestests 目录。可执行文件的名称将是目录名称。

有关手动配置目标的更多详细信息,请参阅配置目标。有关控制 Cargo 如何自动推断目标名称的更多信息,请参阅目标自动发现

3.5 Cargo.toml vs Cargo.lock

Cargo.tomlCargo.lock 有两个不同的用途。在我们讨论它们之前,先做一个总结:

  • Cargo.toml 是广义上描述项目的依赖关系,由用户编写
  • Cargo.lock 包含有关项目的依赖项的准确信息。它由 Cargo 维护,不应手动编辑

如果你正在构建非最终产品,例如其他 rust 包将依赖的 rust 库,请将 Cargo.lock 放入 .gitignore 中。如果你正在构建可像命令行工具或应用程序一样可执行的最终产品,或者 crate-type 为 staticlibcdylib 的系统库,需要将Cargo.lock 放入 git 。具体原因参考为什么版本控制中二进制文件有 Cargo.lock ,但库文件没有?

让我们再深入一点。

Cargo.toml 是一个清单文件(manifest),我们可以在其中指定有关包的一堆不同元数据。例如,我们可以说我们依赖于另一个包:

1
2
3
4
5
6
[package]
name = "hello_world"
version = "0.1.0"

[dependencies]
regex = { git = "https://github.com/rust-lang/regex.git" }

该包对 regex 库有一个依赖项。我们已经在本例中声明过,我们依赖于 GitHub 上的特定 Git 存储库。由于我们没有指定任何其他信息,Cargo 假设我们打算使用默认分支上的最新提交来构建我们的包。

听起来不错,但有一个问题:如果你今天构建这个包,然后向我发送一份副本,而我明天构建这个包,则可能会发生一些奇怪的事情,这是因为此时可能会有更多对 regex 的提交,我的构建将包含新的提交,而你的则不会。因此,我们会得到不同的版本。但我们想要的是可重复的构建。

可以通过在 Cargo.toml 中定义特定的 rev 值来解决这个问题,这样 Cargo 就可以准确地知道构建包时要使用哪个版本:

1
2
[dependencies]
regex = { git = "https://github.com/rust-lang/regex.git", rev = "9f9f693" }

现在我们的构建将是相同的。但有一个很大的缺点:现在我们每次想要更新库时都必须手动考虑 SHA-1。这既乏味又容易出错。

Cargo.lock此时便派上用场,由于它的存在,我们不需要手动跟踪确切的修订:Cargo 会为我们做这件事。当我们有这样的清单时:

1
2
3
4
5
6
[package]
name = "hello_world"
version = "0.1.0"

[dependencies]
regex = { git = "https://github.com/rust-lang/regex.git" }

我们第一次构建,Cargo 将获取最新的提交并将该信息写入 Cargo.lock 中。该文件将如下所示:

1
2
3
4
5
6
7
8
9
10
11
[[package]]
name = "hello_world"
version = "0.1.0"
dependencies = [
"regex 1.5.0 (git+https://github.com/rust-lang/regex.git#9f9f693768c584971a4d53bc3c586c33ed3a6831)",
]

[[package]]
name = "regex"
version = "1.5.0"
source = "git+https://github.com/rust-lang/regex.git#9f9f693768c584971a4d53bc3c586c33ed3a6831"

可以看到这里有更多信息,包括我们用来构建的确切版本。现在,当你将包交给其他人时,他们将使用完全相同的 SHA,即使我们没有在 Cargo.toml 中指定它。

当我们准备好选择新版本的库时,Cargo 可以重新计算依赖项并为我们更新内容:

1
2
cargo update            # 更新所有依赖项
cargo update -p regex # 只更新 “regex”

这将生成一个带有新版本信息的新 Cargo.lock 。需要注意的是 cargo update -p regex 传递的参数实际上是一个 Package IDregex 只是一个简写形式。

3.6 cargo运行测试

Cargo 可以使用 cargo test 命令运行测试。 Cargo 寻找在两个位置运行的测试:在每个 src 文件中以及 tests/ 中的任何测试。 src 文件中的测试应该是单元测试和文档测试。 tests/ 中的测试应该是集成测试。

这是在我们的包中运行 cargo test 的示例,目前还没有测试:

1
2
3
4
5
6
7
8
cargo test
Compiling regex v1.5.0 (https://github.com/rust-lang/regex.git#9f9f693)
Compiling hello_world v0.1.0 (file:///path/to/package/hello_world)
Running target/test/hello_world-9c2b65bbb79eabce

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

如果我们的包有测试,我们会看到更多的输出以及正确数量的测试。

还可以通过传递过滤器来运行特定测试:

1
cargo test foo

这将运行名称中带有 foo 的任何测试。

cargo test 还运行其他检查。它将编译你包含的所有示例,以确保它们仍然可以编译。它还运行文档测试以确保来自文档注释的代码示例能够编译。关于测试请参考编写自动化测试, 了解编写和组织测试的一般视图,参考配置目标

3.7 cargo home

“Cargo home”充当下载和源缓存。构建 crate 时,Cargo 将下载的构建依赖项存储在 Cargo home中。你可以通过设置 CARGO_HOME 环境变量来更改 Cargo home 的位置。默认情况下,Cargo home位于 $HOME/.cargo/ 。若你需要在项目中通过代码的方式来获取 CARGO_HOMEhome 包提供了相应的 API。

Cargo home 由以下组件组成:

文件

  • config.toml Cargo的全局配置文件。
  • credentials.toml 是通过 cargo login 提供私有化登录证书,用于登录package注册中心。
  • .crates.toml.crates2.json 这些隐藏文件包含通过 cargo install 安装的 crate 的包信息。不要手动编辑它们!

目录

  • bin 目录包含通过 cargo installrustup 安装的crate 的可执行文件。为了能够访问这些二进制文件,请将目录的路径添加到你的 $PATH 环境变量中。
  • git 中存储了 Git 的资源文件:
    • git/db 当一个crate依赖于 git 存储库时,Cargo 会将存储库作为裸存储库克隆到此目录中,并在必要时更新它。
    • git/checkouts 如果使用git存储库和commit号时,则会从 git/db 内的裸存储库将存储库所需的提交checkout到此目录中。这为编译器提供了为该依赖项指定的提交的存储库中包含的实际文件。可以对同一存储库的不同提交进行多次签出。
  • registry 包含了注册中心( 例如 crates.io )的元数据和 packages
    • registry/index 是一个 git 仓库,包含了注册中心中所有可用包的元数据( 版本、依赖等 )
    • registry/cache 中保存了已下载的依赖,这些依赖包以 gzip 的压缩档案形式保存,后缀名为 .crate
    • registry/src,若一个已下载的 .crate 档案被一个 package 所需要,该档案会被解压缩到 registry/src 文件夹下,最终 rustc 可以在其中找到所需的 .rs 文件

清除缓存

理论上,你始终可以删除缓存的任何部分,如果有 crate 需要它们,Cargo 将尽力通过重新提取存档或签出git存储库或简单地从网络重新下载源来恢复源。

或者,cargo-cache 提供了一个简单的 CLI 工具,用于仅清除缓存的选定部分或在命令行中显示其组件的大小。

3.8 构建缓存

target目录

默认情况下cargo build 的结果会被放入项目根目录下的 target 文件夹中,要更改位置,你可以设置 CARGO_TARGET_DIR 环境变量build.target-dir 配置值或 --target-dir 命令行标志。

target下的目录结构取决于你是否使用 --target 标志来针对特定平台进行构建。如果未指定 --target ,Cargo 将以针对主机架构构建的模式运行。构建结果会放入项目根目录下的 target 目录中,target 下每个子目录中包含了相应的发布配置(profile),例如 target/releasetarget/debug 是自带的profile,前者往往用于生产环境,因为会做大量的性能优化,而后者则用于开发环境,此时的编译效率和报错信息是最好的。

除此之外我们还可以定义自己想要的 profile ,例如用于测试环境的 profiletest,用于预发环境的 profilepre-prod 等。

目录描述
target/debug/包含了 dev profile 的构建输出(cargo buildcargo build --debug)
target/release/release profile 的构建输出,cargo build --release
target/foo/自定义 foo profile 的构建输出,cargo build --profile=foo

由于历史原因, devtest 发布配置文件存储在 debug 目录中, releasebench 发布配置文件存储在 release 目录中。用户定义的发布配置文件存储在与发布配置文件同名的目录中。

如果指定 --target 构建另一个目标时,输出将放置在具有目标名称的目录中:

目录例子
target/<triple>/debug/target/thumbv7em-none-eabihf/debug/
target/<triple>/release/target/thumbv7em-none-eabihf/release/

当没有使用 --target 时,Cargo 会与构建脚本和过程宏一起共享你的依赖包,对于每个 rustc 命令调用而言,RUSTFLAGS也将被共享。

而使用 --target 后,构建脚本、过程宏会针对宿主机的 CPU 架构进行各自构建,且不会共享 RUSTFLAGS

在配置文件目录(例如 debugrelease )下,包含最终的结果:

目录描述
target/debug/包含编译后的输出,例如二进制可执行文件、库对象( library target )
target/debug/examples/包含示例对象( example target )

另外某些命令将其输出放置在 target 目录顶层的专用目录中:

目录描述
target/doc/包含通过 cargo doc 生成的文档
target/package/包含 cargo packagecargo publish 生成的输出

Cargo 还创建构建过程所需的其他几个目录和文件。它们的布局被视为 Cargo 的内部布局,并且随时可能会发生变化。其中一些目录是:

目录描述
target/debug/deps依赖和其它输出成果
target/debug/incrementalrustc 增量编译的输出,该缓存可以用于提升后续的编译速度
target/debug/build/构建脚本的输出

依赖信息文件

在每一个编译成果的旁边,都有一个依赖信息文件,文件后缀是 .d。该文件是类似于 Makefile 的语法,指示重建工件所需的所有文件依赖项。这些旨在与外部构建系统一起使用,以便它们可以检测 Cargo 是否需要重新执行。默认情况下,文件中的路径是绝对路径。也可以通过修改build.dep-info-basedir 配置选项以使用相对路径。

1
2
# 关于 `.d` 文件的一个示例 : target/debug/foo.d
/path/to/myproj/target/debug/foo: /path/to/myproj/src/lib.rs /path/to/myproj/src/main.rs

共享缓存

第三方工具 sccache 可用于在不同的工作空间中共享已经构建好的依赖包。

为了设置 sccache,首先需要使用 cargo install sccache 进行安装,然后在调用 Cargo 之前将 RUSTC_WRAPPER 环境变量设置为 sccache

  • 如果用的 bash,可以将 export RUSTC_WRAPPER=sccache 添加到 .bashrc
  • 也可以使用 build.rustc-wrapper 配置项

有关更多详细信息,请参阅 sccache 文档。

4 详细指北

4.1 指定依赖项

crate 可以依赖于 crates.io 或其他注册表、 git 存储库或本地文件系统上的子目录中的其他库。你还可以临时覆盖依赖项的位置。例如,为了能够测试你正在本地处理的依赖项中的错误修复。对于不同的平台,你可以有不同的依赖项,以及仅在开发期间使用的依赖项。让我们看看如何执行这些操作。

指定来自 crates.io 的依赖项

默认情况下,Cargo 配置从 crates.io 查找依赖项。在这种情况下,只需要名称和版本字符串。比如,指定time的依赖:

1
2
[dependencies]
time = "0.1.12"

字符串 "0.1.12" 是版本要求。虽然它看起来像指定了 time 的特定版本,但它实际上指定了一系列版本并允许SemVer兼容更新。如果新版本号不修改主要、次要、补丁分组中最左边的非零数字,则允许更新。在这种情况下,如果我们运行 cargo update -p time ,如果是最新的 0.1.z 版本,cargo 应该将我们更新到版本 0.1.13 ,但不会将我们更新到 0.2.0 。相反,如果我们将版本字符串指定为 1.0 ,(如果它是最新的 1.y 版本,而不是 2.0 不被视为与任何其他版本兼容。)则应该更新为 1.1 版本。

以下是指定版本要求及其允许的版本的更多示例:

1
2
3
4
5
6
7
8
1.2.3  :=  >=1.2.3, <2.0.0
1.2 := >=1.2.0, <2.0.0
1 := >=1.0.0, <2.0.0
0.2.3 := >=0.2.3, <0.3.0
0.2 := >=0.2.0, <0.3.0
0.0.3 := >=0.0.3, <0.0.4
0.0 := >=0.0.0, <0.1.0
0 := >=0.0.0, <1.0.0

此兼容性约定与 SemVer 的不同之处在于它对待 1.0.0 之前版本的方式。虽然 SemVer 规范表示 1.0.0 之前不兼容,但 Cargo 认为 0.x.y0.x.z 兼容,其中 y ≥ zx > 0

可以使用特殊运算符进一步调整选择兼容版本的逻辑,尽管大多数时候没有必要。

使用^插入符

插入符是默认策略的替代语法, ^1.2.31.2.3 完全相同。

使用~波浪符

波形符要求指定具有一定更新能力的最小版本。如果指定主要版本、次要版本和补丁版本,或者仅指定主要版本和次要版本,则仅允许补丁级别的更改。如果仅指定主要版本,则允许进行次要版本和补丁级别更改。下面是一个示例:

1
2
3
~1.2.3  := >=1.2.3, <1.3.0
~1.2 := >=1.2.0, <1.3.0
~1 := >=1.0.0, <2.0.0
使用*通配符

通配符 * 所在的位置会替换成任何数字:

1
2
3
*     := >=0.0.0
1.* := >=1.0.0, <2.0.0
1.2.* := >=1.2.0, <1.3.0

不过 crates.io 并不允许只使用一个 * 来指定版本号 : *

使用比较符

允许手动指定版本范围或要依赖的确切版本:

1
2
3
4
>= 1.2.0
> 1
< 2
= 1.2.3
组合使用

可以组合使用,多个版本要求用逗号分隔,例如 >= 1.2, < 1.5

注意事项,请参考Multiple requirements

指定来自其他注册服务的依赖项

为了使用 crates.io 之外的注册服务,我们需要对 $HOME/.cargo/config.toml ($CARGO_HOME 下) 文件进行配置,添加新的服务提供商,有两种方式。

第一种方式,修改 .cargo/config.toml 添加以下内容:

1
2
[registries]
ustc = { index = "https://mirrors.ustc.edu.cn/crates.io-index/" }

这个是科大的镜像,在国内速度会快一些。对于这种方式,我们的项目的 Cargo.toml 中的依赖包引入方式也有所不同:

1
2
[dependencies]
time = { registry = "ustc" }

这一种使用方式在引用依赖包时要明确指定注册服务: time = { registry = "ustc" }

另外一种方式则不需要,修改 .cargo/config.toml

1
2
3
4
5
[source.crates-io]
replace-with = 'ustc'

[source.ustc]
registry = "git://mirrors.ustc.edu.cn/crates.io-index"

首先将源 source.crates-io 替换为 ustc,然后在第二部分指定了 ustc 源的地址。

不过,如果你要将自己的crate发布到 crates.io 上,那该包的依赖也必须在 crates.io 上。

指定 git 存储库的依赖项

要依赖位于 git 存储库中的库,需要指定的最少信息是使用 git 键的存储库的位置:

1
2
[dependencies]
regex = { git = "https://github.com/rust-lang/regex.git" }

Cargo 将在此位置获取 git 存储库,由于我们没有指定任何其他信息,Cargo 假设我们打算使用默认分支上的最新提交来构建我们的包,这不一定是主分支。你可以将 gitrevtagbranch 键组合来指定其他内容。

比如以下是指定你要在名为 next 的分支上使用最新提交的示例:

1
2
[dependencies]
regex = { git = "https://github.com/rust-lang/regex.git", branch = "next" }

任何不是分支(branch)或标签(tag)的内容都属于 rev 。这可以是提交哈希值,例如 rev = "4c59b707" ,也可以是远程存储库公开的命名引用,例如 rev = "refs/pull/493/head"

一旦添加了 git 依赖项,Cargo 就会将该依赖项锁定到当时的最新提交。一旦锁定到位,新的提交将不会自动拉取。但是,可以使用 cargo update 手动下拉它们。需要注意的是锁定一旦被删除,那 Cargo 依然会按照 Cargo.toml 中配置的地址和版本去拉取新的版本,如果你配置的版本不正确,那可能会拉取下来一个不兼容的新版本。

crates.io 不允许发布具有 git 依赖项的包,请参阅多种方式混合部分以获取备用替代方案。

指定路径依赖项

Cargo 支持本地路径依赖关系,一般来说,本地依赖包都是同一个项目内的内部包,例如假设我们有一个 hello_world 项目( package ),现在在其根目录下新建一个package:

1
2
#  在 hello_world/ 目录下
cargo new hello_utils

新建的 hello_utils 文件夹跟 srcCargo.toml 同级,现在修改 Cargo.tomlhello_world 项目引入新建的包:

1
2
3
4
5
[dependencies]
hello_utils = { path = "hello_utils" }
# 以下路径也可以
# hello_utils = { path = "./hello_utils" }
# hello_utils = { path = "../hello_world/hello_utils" }

这告诉 Cargo 我们依赖于一个名为 hello_utils 的包,它可以在 hello_utils 文件夹中找到(相对于它所写入的 Cargo.toml 文件夹)。

再运行cargo build 将自动构建 hello_utils 及其所有依赖项,其他人也可以开始使用该包。但是,crates.io 上不允许使用仅通过路径指定的依赖项的包。如果我们想发布 hello_world ,我们需要将 hello_utils 的版本发布到 crates.io 并在依赖项行中指定其版本,比如:

1
2
[dependencies]
hello_utils = { path = "hello_utils", version = "0.1.0" }

crates.io 不允许发布具有 path 依赖项的包,请参阅多种方式混合部分以获取备用替代方案。

多种方式混合

我们可以同时使用多种方式来引入同一个包,可以指定注册表版本和 gitpath 位置。 gitpath 依赖项将在本地使用,在发布到诸如 crates.io 时使用注册表版本。除此之外,不允许其他组合。例子:

1
2
3
4
5
6
7
8
9
10
[dependencies]
# 本地使用时,通过 path 引入,
# 发布到 `crates.io` 时,通过 `crates.io` 的方式引入: version = "1.0"
bitflags = { path = "my-bitflags", version = "1.0" }

# 本地使用时,通过 git 仓库引入
# 当发布时,通过 `crates.io` 引入: version = "1.0"
smallvec = { git = "https://github.com/servo/rust-smallvec", version = "1.0" }

# 特别注意 若 version 无法匹配,Cargo 将无法编译

根据平台引入依赖

我们还可以根据特定的平台来引入依赖:

1
2
3
4
5
6
7
8
9
10
11
[target.'cfg(windows)'.dependencies]
winhttp = "0.4.0"

[target.'cfg(unix)'.dependencies]
openssl = "1.0.1"

[target.'cfg(target_arch = "x86")'.dependencies]
native = { path = "native/i686" }

[target.'cfg(target_arch = "x86_64")'.dependencies]
native = { path = "native/x86_64" }

此处的语法跟 Rust 的 #[cfg] 语法非常相像,支持 notanyall 运算符来组合各种 cfg 名称/值对。

如果想知道你的平台上有哪些 cfg 目标可用,请从命令行运行 rustc --print=cfg 。如果想知道哪些 cfg 目标可用于其他平台(例如 64 位 Windows),请运行 rustc --print=cfg --target=x86_64-pc-windows-msvc

与 Rust 源代码不同,在这里不能使用 [target.'cfg(feature = "fancy-feature")'.dependencies] 根据自定义的条件来决定是否引入某个依赖,如有需求请改用 [features] 部分:

1
2
3
4
5
6
[dependencies]
foo = { version = "1.0", optional = true }
bar = { version = "1.0", optional = true }

[features]
fancy-feature = ["foo", "bar"]

通过 cfg(feature)cfg(debug_assertions), cfg(test)cfg(proc_macro) 的方式来条件引入依赖也是不行的,目前无法根据这些配置值添加依赖项。

除了 #[cfg] 语法之外,Cargo 还支持列出完整的依赖项应用于完整目标:

1
2
3
4
5
[target.x86_64-pc-windows-gnu.dependencies]
winhttp = "0.4.0"

[target.i686-unknown-linux-gnu.dependencies]
openssl = "1.0.1"

自定义 target 引入

如果你在使用自定义的 target :例如 --target bar.json,那么可以通过下面方式来引入依赖:

1
2
3
4
5
6
[target.bar.dependencies]
winhttp = "0.4.0"

[target.my-special-i686-platform.dependencies]
openssl = "1.0.1"
native = { path = "native/i686" }

不过,这种使用方式在 stable 版本的 Rust 中无法被使用。

开发时依赖项

可以将 [dev-dependencies] 部分添加到 Cargo.toml 中,其格式相当于 [dependencies]

1
2
[dev-dependencies]
tempdir = "0.3"

这里的依赖只会在运行测试、示例和 benchmark 时才会被引入。假设A 包引用了 B,而 B 通过 [dev-dependencies] 的方式引用了 C 包, 那 A 在发布构建时是不会引用 C 包的。

我们还可以指定平台特定的测试依赖包:

1
2
[target.'cfg(unix)'.dev-dependencies]
mio = "0.0.1"

发布包时,只有指定 version 的开发依赖项才会包含在发布的包中。对于大多数用例,发布时不需要开发依赖项,尽管某些用户(例如操作系统打包者)可能希望在包内运行测试,因此如果可能的话提供 version 仍然是一个好的习惯。

构建时依赖项

还可以指定某些依赖仅用于构建:

1
2
[build-dependencies]
cc = "1.0.3"

当然对于平台特定的也可以使用:

1
2
[target.'cfg(unix)'.build-dependencies]
cc = "1.0.3"

在这种情况下,只有当主机平台与指定的目标匹配时才会构建依赖关系。

构建脚本无法使用 [dependencies][dev-dependencies] 中的依赖,而 [build-dependencies] 中的依赖也无法被构建脚本之外的代码所使用。因此它们的依赖关系不需要一致。通过使用独立的依赖关系来实现独立的目的,可以使cargo项目依赖变得更简单、更干净。

选择 features

如果你依赖的包提供了条件性的 features,你可以指定使用哪一个:

1
2
3
4
[dependencies.awesome]
version = "1.3.5"
default-features = false # 不要包含默认的 features,而是通过下面的方式来指定
features = ["secure-password", "civet"]

重命名 Cargo.toml 中的依赖项

Cargo.toml 中编写 [dependencies] 部分时,为依赖项编写的键通常与你在代码中导入的包的名称相匹配。不过,对于某些项目,您可能希望在代码中引用不同名称的包,无论它如何在 crates.io 上发布。例如,你可能希望:

  • 避免在 Rust 源代码中使用 use foo as bar
  • 依赖于一个 crate 的多个版本。
  • 依赖于来自不同注册中心的同名crate。

可以使用 Cargo 提供的 package key 来完成这一点:

1
2
3
4
5
6
7
8
[package]
name = "mypackage"
version = "0.0.1"

[dependencies]
foo = "0.1"
bar = { git = "https://github.com/example/project.git", package = "foo" }
baz = { version = "0.1", registry = "custom", package = "foo" }

现在Rust 代码中提供了三个 crate:

1
2
3
extern crate foo; // crates.io
extern crate bar; // git repository
extern crate baz; // registry `custom`

所有这三个 crate 的 Cargo.toml 中都有包名称 foo ,我们显式的通过 package = "foo" 的方式告诉 Cargo:我们需要的就是这个 foo package,虽然它被重命名为 barbaz

有一点需要注意,当使用可选依赖时,如果你将 foo 包重命名为 bar 包,那引用foo的 feature 时的路径名也要做相应的修改:

1
2
3
4
5
[dependencies]
bar = { version = "0.1", package = 'foo', optional = true }

[features]
log-debug = ['bar/log-debug'] # 若使用 'foo/log-debug' 会导致报错

从工作区继承依赖项

通过在工作区的 [workspace.dependencies] 表中指定依赖项,可以从工作区继承依赖项。之后,使用 workspace = true 将其添加到 [dependencies] 表中。

除了 workspace 键之外,依赖项还可以包含以下键:

  • optional :请注意, [workspace.dependencies] 表不允许指定 optional
  • features :这些与 [workspace.dependencies] 中声明的功能相加

除了 optionalfeatures 之外,继承的依赖项不能使用任何其他依赖项键(例如 versiondefault-features )。

[dependencies][dev-dependencies][build-dependencies][target."...".dependencies] 部分中的依赖项支持引用 [workspace.dependencies] 定义的功能的依赖关系。

1
2
3
4
5
6
7
8
9
10
11
12
[package]
name = "bar"
version = "0.2.0"

[dependencies]
regex = { workspace = true, features = ["unicode"] }

[build-dependencies]
cc.workspace = true

[dev-dependencies]
rand = { workspace = true, optional = true }

4.2 重写依赖关系(依赖覆盖)

想要覆盖依赖的场景很常见,例如:

  • 你正在同时开发一个包和一个项目,而后者依赖于前者,你希望能在该项目中对正在开发的包进行测试
  • 你引入的一个依赖包在 master 分支发布了新的代码,恰好修复了某个 bug,因此你希望能单独对该分支进行下测试
  • 你即将发布一个包的新版本,为了确保新版本正常工作,你需要对其进行集成测试
  • 你为项目的某个依赖包提了一个 PR 并解决了一个重要 bug,在等待合并到 master 分支,但是时间不等人,因此你决定先使用自己修改的版本,等未来合并后,再继续使用官方版本

下面将介绍一些不同的用例,这些用例包含有关使用覆盖依赖项的不同方法。

测试 bugfix 版本

假设你正在使用 uuid 库,但在处理它时发现了该库一个错误。你很热情,因此决定帮助uuid修复该错误。最初Cargo.toml如下所示:

1
2
3
4
5
6
[package]
name = "my-library"
version = "0.1.0"

[dependencies]
uuid = "1.0"

要做的第一件事就是把源仓库克隆下来:

1
git clone https://github.com/uuid-rs/uuid.git

下面,修改项目的 Cargo.toml 添加以下内容以引入本地克隆的版本(这里的目录为项目同级的目录):

1
2
[patch.crates-io]
uuid = { path = "../uuid" }

我们使用自己修改过的 patch 来覆盖来自 crates.io 的版本,这会将 uuid 的本地签出版本添加到本地包的 crates.io 注册表中。

接下来,我们需要确保我们的锁定文件已更新以使用这个新版本的 uuid ,以便我们的包使用本地签出的副本,而不是来自 crates.io 的副本。 [patch] 的工作方式是,它会在 ../uuid 处加载依赖项,然后每当 crates.io 查询 uuid 的版本时,它也会返回本地版本。

这意味着本地签出的版本号意义重大,会影响补丁是否使用。我们的清单声明了 uuid = "1.0" 这意味着我们只会解析的版本号为 >= 1.0.0, < 2.0.0 ,而 Cargo 的贪婪解析算法也意味着我们将解析为该范围内的最大版本。通常这并不重要,因为 git 存储库的版本已经更高或与 crates.io 上发布的最大版本相匹配,但记住这一点很重要。

总之,接下来需要做的就是:

1
2
3
4
cargo build
Compiling uuid v1.0.0 (.../uuid)
Compiling my-library v0.1.0 (.../my-library)
Finished dev [unoptimized + debuginfo] target(s) in 0.32 secs

可以看到现在正在使用 uuid 的本地版本进行构建(请注意构建输出中括号中的路径)。如果你没有看到构建本地路径版本,则可能需要运行 cargo update -p uuid --precise $version ,其中 $version 是本地签出的 uuid 副本的版本。

一旦修复了最初发现的错误,接下来要做的就是将其作为PR提交给 uuid 仓库。完成此操作后,你还可以更新 [patch] 部分。 [patch] 内部的列表与 [dependencies] 部分类似,因此当对方合并PR后,你可以将 path 依赖项更改为:

1
2
[patch.crates-io]
uuid = { git = 'https://github.com/uuid-rs/uuid.git' }

等未来uuid新的内容更新到 crates.io 后,就可以移除这个补丁,直接更新 [dependencies] 中的 uuid 版本即可。

使用未发布的次要版本

现在让我们从错误修复转向添加功能。在处理 my-library 时,你发现 uuid 箱中需要一个全新的功能。假设你已经实现了此功能,在上面使用 [patch] 进行了本地测试,并提交了拉取请求。让我们回顾一下在实际发布之前如何继续使用和测试它。

我们还假设 crates.io 上 uuid 的当前版本是 1.0.0 ,但从那时起 git 存储库的 master 分支已更新为 1.0.1 。该分支包含你之前提交的新功能。要使用此存储库,我们需要编辑 Cargo.toml 使其变为:

1
2
3
4
5
6
7
8
9
[package]
name = "my-library"
version = "0.1.0"

[dependencies]
uuid = "1.0.1"

[patch.crates-io]
uuid = { git = 'https://github.com/uuid-rs/uuid.git' }

注意,我们将 [dependencies] 中的 uuid 版本提前修改为 1.0.1,由于该版本在 crates.io 尚未发布,因此 patch 版本会被使用。

现在,当我们的库构建完成时,它将从 git 存储库获取 uuid 并解析为存储库内的 1.0.1,而不是尝试从 crates.io 下载版本。一旦 1.0.1 在 crates.io 上发布, [patch] 部分就可以删除。

还值得注意的是 [patch] 是会传递的。比如我们有一个更大的包叫做my-binary,它使用了 my-library ,例如:

1
2
3
4
5
6
7
8
9
10
[package]
name = "my-binary"
version = "0.1.0"

[dependencies]
my-library = { git = 'https://example.com/git/my-library' }
uuid = "1.0"

[patch.crates-io]
uuid = { git = 'https://github.com/uuid-rs/uuid.git' }

patch 不仅仅对于 my-binary 项目有用,对于 my-binary 的依赖 my-library 来说,一样可以间接生效。

如果你要覆盖的依赖项不是从 crates.io 加载的,则必须稍微更改一下 [patch] 的使用方式。例如,如果依赖项是 git 依赖项,可以使用以下命令将其覆盖到本地路径:

1
2
[patch."https://github.com/your/repository"]
my-library = { path = "../my-library/path" }

使用未发布的主要版本

假设我们要发布一个大版本 2.0.0,在我们向上游提交所有更改后,我们可以更新 my-library 的清单,如下所示:

1
2
3
4
5
[dependencies]
uuid = "2.0"

[patch.crates-io]
uuid = { git = "https://github.com/uuid-rs/uuid.git", branch = "2.0.0" }

2.0.0版本实际上并不存在于 crates.io 上,但我们仍然可以通过使用 [patch] 部分将其放入 git 依赖项中。

让我们再看看上面的 my-binary 清单:

1
2
3
4
5
6
7
8
9
10
[package]
name = "my-binary"
version = "0.1.0"

[dependencies]
my-library = { git = 'https://example.com/git/my-library' }
uuid = "1.0"

[patch.crates-io]
uuid = { git = 'https://github.com/uuid-rs/uuid.git', branch = '2.0.0' }

与之前的小版本不同,大版本的 patch 不会发生间接的传递,以上配置中, my-binary 将继续使用 1.x.y 系列的版本,而 my-library 将使用最新的 2.0.0 patch。这样设计的原因是,它允许你通过依赖关系图逐步推出对crate的重大更改,而不必被迫立即更新所有内容。

将 [patch] 与多个版本一起使用

之前介绍过可以使用 package 键重命名同一个包的多个版本。例如,假设 serde 库有一个错误修复,我们希望将其用于其 1.* 系列,但我们也希望使用我们自己本地修改的 2.0.0 版本。为了满足这两个 patch,可以使用如下内容的 Cargo.toml

1
2
3
[patch.crates-io]
serde = { git = 'https://github.com/serde-rs/serde' }
serde2 = { git = 'https://github.com/example/serde', package = 'serde', branch = 'v2' }

第一个 serde = ... 指令指示应该从 git 存储库使用 serde 1.* (拉取我们需要的错误修复),第二个 serde2 = ... 指令指示serde包也应该从 https://github.com/example/serdev2 分支中提取。我们假设该分支上的 Cargo.toml 提到了版本 2.0.0 ,同时将其重命名为 serde2。这样,在代码中就可以分别通过 serdeserde2 引用不同版本的依赖库了。

[patch] 部分覆盖依赖总结

Cargo.toml[patch] 部分可用于覆盖与其他副本的依赖关系。语法类似于 [dependencies] 部分:

1
2
3
4
5
6
7
8
9
[patch.crates-io]
foo = { git = 'https://github.com/example/foo.git' }
bar = { path = 'my/local/bar' }

[dependencies.baz]
git = 'https://github.com/example/baz.git'

[patch.'https://github.com/example/baz']
baz = { git = 'https://github.com/example/patched-baz.git', branch = 'my-branch' }

[patch] 表由类似依赖关系的子表组成。 [patch] 之后的每个键都是正在修补的源的 URL,或者注册表的名称。名称 crates-io 可用于覆盖默认注册表 crates.io。上面示例中的第一个 [patch] 演示了覆盖 crates.io,第二个 [patch] 演示了覆盖 git 源。

这些表中的每个条目都是正常的依赖项规范,与清单的 [dependencies] 部分中的相同。 [patch] 部分中列出的依赖项已解析并用于修补指定 URL 处的源。上面的清单片段使用 foobar 修补 crates-io 源(例如 crates.io 本身)。它还使用来自其他地方的 my-branch 修补 https://github.com/example/baz 源。

源可以使用不存在的 crate 版本进行修补,也可以使用已存在的 crate 版本进行修补。如果使用源中已存在的 crate 版本修补源,则源的原始 crate 将被替换。

路径覆盖

有时可能只是暂时处理一个crate,并且不想像上面的 [patch] 部分那样修改 Cargo.toml 。对于此用例,Cargo 提供了一种更为有限的覆盖版本,称为路径覆盖。

路径覆盖是通过 .cargo/config.toml 而不是 Cargo.toml 指定的。在 .cargo/config.toml 内部,指定一个名为 paths 的键:

1
paths = ["/path/to/uuid"]

该数组应填充包含 Cargo.toml 的目录。在本例中,我们只是添加 uuid ,因此它将是唯一被覆盖的。此路径可以是绝对路径,也可以是相对于包含 .cargo 文件夹的目录的路径。

然而,路径覆盖比 [patch] 部分受到更多限制,因为它们无法更改依赖关系图的结构。当使用路径替换时,之前的一组依赖项必须与新的 Cargo.toml 规范完全匹配。例如,这意味着路径覆盖不能用于测试向包添加依赖项,而在这种情况下必须使用 [patch] 。因此,路径覆盖的使用通常仅限于快速错误修复,而不是较大的更改。

4.3 清单格式

每个包的 Cargo.toml 文件称为其清单。它以 TOML 格式编写。它包含编译包所需的元数据。使用命令 cargo locate-project 可以查找到目前项目使用的清单位置。

清单包含以下部分:

  • cargo-features — 不稳定,仅限夜版使用。
  • [package] — 定义一个package
    • namepackage的名称。
    • versionpackage的版本。
    • authorspackage的作者。
    • edition — Rust 版次。
    • rust-version — 支持的最低 Rust 版本。
    • descriptionpackage的描述。
    • documentationpackage文档的 URL。
    • readmepackage的 README 文件的路径。
    • homepagepackage主页的 URL。
    • repositorypackage源存储库的 URL。
    • licensepackage许可证(开源协议)。
    • license-filepackage许可证文件的路径。
    • keywordspackage的关键词。
    • categoriespackage的分类。
    • workspacepackage的工作空间路径。
    • buildpackage构建脚本的路径。
    • linkspackage本地链接库的名称。
    • exclude — 发布时要排除的文件。
    • include — 发布时要包含的文件。
    • publish — 阻止项目发布。
    • metadata — 外部工具的额外配置信息。
    • default-run — 由 cargo run 运行的默认可执行文件。
    • autobins — 禁用可执行文件的自动发现。
    • autoexamples — 禁用示例文件自动发现。
    • autotests — 禁用测试文件自动发现。
    • autobenches — 禁用bench文件自动发现。
    • resolver— 设置要使用的依赖解析器。==todo设置链接==
  • Cargo Target 列表。
  • Dependency tables(项目依赖表):
  • [badges] — 用于在注册服务(例如 crates.io ) 上显示项目的一些状态信息,例如当前的维护状态:活跃中、寻找维护者、deprecated(弃用)
  • [features] — 条件编译功能。==todo==
  • [patch] — 覆盖依赖。
  • [replace] — 覆盖依赖项(已弃用),建议使用[patch]
  • [profile] — 编译器设置和优化。==todo==
  • [workspace] — 工作空间的定义。

下面进行详细解释。

[package] 部分

Cargo.toml 中的第一部分是 [package]

1
2
3
4
[package]
name = "hello_world" # the name of the package
version = "0.1.0" # the current version, obeying semver
authors = ["Alice <a@example.com>", "Bob <b@example.com>"]

Cargo 所需的必须字段是 nameversion 。如果发布到crates.io,可能需要其他字段。

name字段

项目名用于引用一个项目( package ),它有两个用途:

  • 其它项目引用我们的 package 时,会使用该 name
  • 作为 lib 或 bin 目标的默认名称时会使用它

项目名只能使用字母数字和 -_ ,并且不能为空。cargo newcargo init 对包名称施加了一些额外的限制,例如强制它是有效的 Rust 标识符而不是关键字。crates.io 施加了更多限制,例如:

  • 仅允许使用 ASCII 字符。
  • 不能使用保留字段。
  • 不能使用特殊的 Windows 名称,例如“nul”。
  • 最多使用 64 个字符的长度。
  • 不能使用已经被使用的名称,例如 uuid 已经在 crates.io 上被使用,则无法发布(如果不准备发布则没有此限制)

version字段

Cargo 融入了语义化版本的概念。例如字符串 "0.1.12" 是一个 semver 格式的版本号,符合 "x.y.z" 的形式,其中 x 被称为主版本major, y 被称为小版本 minor ,而 z 被称为补丁 patch,可以看出从左到右,版本的影响范围逐步降低,补丁的更新是无关痛痒的,并不会造成 API 的兼容性被破坏。

使用该规则,你还需要遵循一些基本规则:

  • 使用标准的 x.y.z 形式的版本号,例如 1.0.0 而不是 1.0
  • 在版本到达 1.0.0 之前,怎么都行,但是如果有破坏性变更( breaking changes ),需要增加 minor 版本号。例如,为结构体新增字段或为枚举新增成员就是一种破坏性变更
  • 1.0.0 之后,如果发生破坏性变更,需要增加 major 版本号
  • 1.0.0 之后不要去破坏构建流程
  • 1.0.0 之后,不要在 patch 更新中添加新的 api ( pub 声明),如果要添加新的 pub 结构体、特征、类型、函数、方法等对象时,增加 minor 版本号

authors字段

authors 字段在数组中列出了被视为项目“作者”的人员或组织,它是可选的。你可以列出原作者或主要作者、当前维护者或包的所有者。每个作者条目末尾的尖括号内可以包含可选的电子邮件地址。比如:

1
2
3
[package]
# ...
authors = ["Graydon Hoare", "Fnu Lnu <no-reply@rust-lang.org>"]

该字段仅出现在包元数据和 build.rs 内的 CARGO_PKG_AUTHORS 环境变量中。它不会显示在 crates.io 用户界面中。

另外,清单中的 [package] 部分一旦发布到 crates.io 就无法进行更改,因此对于已发布的包来说,authors 字段是无法修改的。

edition字段

可选。它会影响你的项目编译时使用的 Rust 版次。在 [package] 中设置 edition 键将影响项目中的所有目标/项目,项目括测试套件、基准测试、二进制文件、示例等。

1
2
3
[package]
# ...
edition = '2021'

大多数清单都有 edition 字段,由最新稳定版本的 cargo new 自动填充。默认情况下, cargo new 当前使用 2021 版本创建清单。

如果 edition 字段不存在于 Cargo.toml 中,则假定为 2015 版本以实现向后兼容性。请注意,使用 cargo new 创建的所有清单都不会使用此历史回退,因为它们将 edition 显式指定为较新的值。

rust-version字段

可选。它告诉 Cargo 可以使用哪个版本的 Rust 语言和编译器来编译你的项目。如果当前选择的 Rust 编译器版本比指定版本旧,cargo 将退出并显示错误,告诉用户需要什么版本。

支持该字段的第一个 Cargo 版本随 Rust 1.56.0 一起发布。在旧版本中,该字段将被忽略,并且 Cargo 将显示警告。

1
2
3
[package]
# ...
rust-version = "1.56"

Rust 版本必须是包含两个或三个组成部分的裸版本号;它不能包含 semver 运算符或预发布标识符。在检查 Rust 版本时,编译器预发布标识符(例如 -nightly)将被忽略。 rust-version 必须等于或比首次引入配置的 edition 的版次更新。

[package] 中设置 rust-version 键将影响项目中的所有目标/项目,项目括测试套件、基准测试、二进制文件、示例等。使用 --ignore-rust-version 选项可以忽略 rust-version

description字段

描述是关于该包的简短介绍。 crates.io 将在您的包中显示此信息。这应该是纯文本(不是 Markdown)。

1
2
3
[package]
# ...
description = "A short description of my package"

如果你要发布到 crates.io,则需要设置 description

documentation字段

documentation 字段用于说明项目文档的地址,若没有设置,crates.io 会自动链接到 docs.rs 上的相应页面。

1
2
3
[package]
# ...
documentation = "https://docs.rs/bitflags"

readme字段

readme 字段应该指向项目的 README.md 文件,该文件应该存在项目的根目录下(跟 Cargo.toml 同级),支持 Markdown 格式。当发布到的 crates.io 上时,项目首页会基于该文件的内容进行渲染。

1
2
3
[package]
# ...
readme = "README.md"

若该字段未设置且项目根目录下存在 README.mdREADME.txtREADME 文件,则该文件的名称将被默认使用。

你也可以通过将 readme 设置为 false 来禁止该功能,若设置为 true ,则默认值 README.md 将被使用。

homepage字段

该字段用于设置项目主页的 URL:

1
2
3
[package]
# ...
homepage = "https://serde.rs/"

repository字段

设置项目的源代码仓库地址,例如 GitHub 链接:

1
2
3
[package]
# ...
repository = "https://github.com/rust-lang/cargo/"

license 和 license-file 字段

license 字段包含发布软件包所依据的软件开源协议的名称。license-file 字段包含包含开源协议文件的路径(相对于 Cargo.toml )。

crates.io 将 license 字段解释为 SPDX 2.1 许可证表达式。该名称必须是 SPDX 许可证列表 3.11 中的已知许可证。目前不支持括号。请参阅 SPDX 网站了解更多信息。

SPDX 许可证表达式支持 ANDOR 运算符来组合多个许可证。

1
2
3
[package]
# ...
license = "MIT OR Apache-2.0"

使用 OR 表示用户可以选择任一许可证。使用 AND 表示用户必须同时遵守两个许可证。 WITH 运算符表示具有特殊例外的许可证。一些例子:

  • MIT OR Apache-2.0
  • LGPL-2.1-only AND MIT AND BSD-2-Clause
  • GPL-2.0-or-later WITH Bison-exception-2.2

如果软件包使用非标准许可证,则可以指定 license-file 字段来代替 license 字段。

1
2
3
[package]
# ...
license-file = "LICENSE.txt"

发布到crates.io时,你需要设置 licenselicense-file 其中一个。

keywords 字段

该字段使用字符串数组的方式来指定项目的关键字列表,当用户在 crates.io 上搜索时,这些关键字可以提供索引的功能。

1
2
3
[package]
# ...
keywords = ["gamedev", "graphics"]

crates.io 最多支持设置 5 个关键字。每个关键字必须是 ASCII 文本,以字母开头,只能包含字母、数字、 _- ,且最多 20 个字符。

categories 字段

categories 用于描述项目所属的类别:

1
categories = ["command-line-utilities", "development-tools::cargo-plugins"]

crates.io 最多支持设置 5 个类别,每个类别必须是官方All Valid Category Slugs中的一员。

workspace 字段

workspace 字段用于配置当前项目所属的工作空间。如果未指定,则会逐级向上寻找,直至找到第一个设置了 [workspace]Cargo.toml。如果一个成员不在工作空间的子目录时,设置该字段将非常有用。

1
2
3
[package]
# ...
workspace = "path/to/workspace/root"

需要注意的是 Cargo.toml 清单还有一个 [workspace] 部分专门用于设置工作空间,若它被设置了,则 package 中的 workspace 字段将无法被指定。也就是说,一个 crate 不能既是工作空间中的根 crate(包含 [workspace] ),又是另一个工作空间的成员 crate(包含 package.workspace )。

有关更多信息,请参阅工作空间

build 字段

build 字段指定包根目录中的一个文件,它是用于构建本机代码的构建脚本。

1
2
3
[package]
# ...
build = "build.rs"

默认值为 "build.rs" ,它从包根目录中名为 build.rs 的文件加载脚本。使用 build = "custom_build_name.rs" 指定不同文件的路径,或使用 build = false 禁用构建脚本的自动检测。

用于指定项目链接的本地库的名称。链接名为“git2”的本机库(例如 Linux 上的 libgit2.a )的包可能会指定:

1
2
3
[package]
# ...
links = "git2"

exclude 和 include 字段

excludeinclude 字段可用于显式指定打包要发布的项目时包含或不包含哪些文件,往往用于发布到注册服务时。exclude 字段中指定的模式标识一组未包含的文件, include 中的模式指定显式包含的文件。你可以使用 cargo package --list 来检查哪些文件被包含在项目中。

1
2
3
[package]
# ...
exclude = ["/ci", "images/", ".*"]
1
2
3
[package]
# ...
include = ["/src", "COPYRIGHT", "/examples", "!/examples/big_example"]

如果两个字段均未指定,则默认情况下将包含包根目录中的所有文件,但下面列出的排除项除外。

如果未指定 include ,则以下文件将被排除:

  • 项目不是 git 仓库,则所有以 . 开头的隐藏文件会被排除
  • 项目是 git 仓库,通过 .gitignore 配置的文件会被排除

无论 includeexclude 是否被指定,以下文件都会被排除在外:

  • 任何包含 Cargo.toml 的子目录会被排除
  • 根目录下的 target 目录会被排除

以下文件会永远被 include ,你无需显式地指定:

  • Cargo.toml
  • 若项目包含可执行文件或示例代码,则最小化的 Cargo.lock 会自动被包含
  • license-file 指定的协议文件

这两个字段互相排斥,设置 include 将覆盖 exclude。更高级的用法请参考The exclude and include fields

publish 字段

该字段常常用于防止项目因为失误被发布到 crates.io 等注册服务上,例如在公司中保持项目的私有性。

1
2
3
[package]
# ...
publish = false

该值还可以是字符串数组,这些字符串是允许发布到的注册服务名称。

1
2
3
[package]
# ...
publish = ["some-registry-name"]

publish 数组中包含了一个注册服务名称,则 cargo publish 命令会使用该注册服务,除非你通过 --registry 来设定额外的规则。

metadata 表

默认情况下,Cargo 会警告 Cargo.toml 中未使用的键,以帮助检测拼写错误等。然而, package.metadata 表被 Cargo 完全忽略,并且不会发出警告。此部分可用于想要在 Cargo.toml 中存储包配置的工具。例如:

1
2
3
4
5
6
7
8
[package]
name = "..."
# ...

# Metadata used when generating an Android APK, for example.
[package.metadata.android]
package-name = "my-awesome-android-app"
assets = "path/to/static"

与其相似的还有 [workspace.metadata],都可以作为外部工具的配置信息来使用。

default-run 字段

[package] 部分中的 default-run 字段可用于指定 cargo run 选择的默认二进制文件。例如,当同时存在 src/bin/a.rssrc/bin/b.rs 时:

1
2
[package]
default-run = "a"

指定默认使用a.rs

[badges] 部分

该部分用于指定项目当前的状态,该状态会展示在 crates.io 的项目主页中。crates.io 之前在其网站上的项目旁边显示了状态,但该功能已被删除。软件包应将状态放入其 README 文件中,该文件将显示在 crates.io 上(请参阅readme 字段 )。

1
2
3
4
5
6
7
8
9
10
11
12
[badges]
# `maintenance` 是项目的当前维护状态,它可能会被其它注册服务所使用,但是目前还没有被 `crates.io` 使用: https://github.com/rust-lang/crates.io/issues/2437
#
# `status` 字段时必须的,以下是可用的选项:
# - `actively-developed`: 新特性正在积极添加中,bug 在持续修复中
# - `passively-maintained`: 目前没有计划去支持新的特性,但是项目维护者可能会回答你提出的 issue
# - `as-is`: 该项目的功能已经完结,维护者不准备继续开发和提供支持了,但是它的功能已经达到了预期
# - `experimental`: 作者希望同大家分享,但是还不准备满足任何人的特殊要求
# - `looking-for-maintainer`: 当前维护者希望将项目转移给新的维护者
# - `deprecated`: 不再推荐使用该项目,需要说明原因以及推荐的替代项目
# - `none`: 不显示任何 badge ,因此维护者没有说明他们的状态,用户需要自己去调查发生了什么
maintenance = { status = "..." }

4.4 Cargo目标

Cargo项目由与可以编译到crate中的源文件相对应的目标组成。项目可以是库[lib]、二进制[[bin]]、示例[[example]]、测试[[test]]和基准测试[[bench]]目标。目标列表可以在 Cargo.toml 清单中配置,大部分时候都无需手动配置,通常通过源文件的目录布局自动推断。

库目标(Library)

库目标定义了一个可以由其他库和可执行文件使用和链接的“库”。文件名默认为 src/lib.rs ,库名称默认为包名称。一个项目只能有一个库。可以在 Cargo.toml[lib] 表中自定义库目标的设置。

1
2
3
4
5
# 一个自定义库目标的示例
# in Cargo.toml
[lib]
crate-type = ["cdylib"]
bench = false

二进制目标(Binaries)

二进制目标是编译后可以运行的可执行程序。默认的二进制文件名是 src/main.rs ,默认为项目的名称。其他二进制文件存储在 src/bin/ 目录中。可以在 Cargo.toml[[bin]] 表中自定义每个二进制文件的设置。

二进制文件可以使用library库目标的公共 API。并且也可以通过 [dependencies] 来引入外部的依赖库。

可以使用 cargo run --bin <bin-name> 的方式来运行指定的二进制对象,以下是二进制对象的配置示例:

1
2
3
4
5
6
7
8
9
# Example of customizing binaries in Cargo.toml.
[[bin]]
name = "cool-tool"
test = false
bench = false

[[bin]]
name = "frobnicator"
required-features = ["frobnicate"]

示例目标(Examples)

位于 examples 目录下的文件是展示库提供的功能的示例代码,编译后,它们被放置在 target/debug/examples 目录中。与二进制目标一样,示例目标可以使用library库目标的公共 API。并且也可以通过 [dependencies] 来引入外部的依赖库。

默认情况下,示例是可执行的二进制文件,带有 fn main() 函数入口,你也可以将示例对象改成库目标的类型:

1
2
3
[[example]]
name = "foo"
crate-type = ["staticlib"]

如果想要指定运行某个示例,可以使用 cargo run --example <example-name> 命令。如果是库类型的示例目标,则可以使用 cargo build --example <example-name> 进行构建。

与此类似,还可以使用 cargo install --example <example-name> 来将示例目标编译出的可执行文件安装到默认的目录中,将该目录添加到 $PATH 环境变量中,就可以直接全局运行安装的可执行文件。

最后,cargo test 命令默认会对示例目标进行编译,以防止示例代码因为长久没运行,导致严重过期以至于无法运行。如果希望在示例中使用 cargo test 运行 #[test] 函数,需要将 test 字段设置为 true

测试目标(Tests)

Cargo 项目中有两种类型的测试:单元测试和集成测试。后者的文件位于根目录下的 tests 目录中,使用 cargo test 命令运行测试。默认情况下,Cargo 和 rustc 使用 libtest 工具,它负责收集用 #[test] 属性注释的函数并并行执行它们,报告每个测试的成功和失败。通过harness 字段可以修改默认的工具,参考harness

Cargo 中还有另一种特殊的测试方式:文档测试。它们由 rustdoc 处理,并且执行模型略有不同。

有关具体的关于测试和组织结构的内容,请参考编写自动化测试

基准测试目标(Benchmarks)

基准测试提供了一种使用 cargo bench 命令测试代码性能的方法。它们遵循与Tests目录相同的结构,每个基准函数都用 #[bench] 属性进行注释。#[bench] 属性目前不稳定,仅在夜间版本上可用。

4.5 配置目标

你可能会注意到[lib][[bin]] 的写法不一致,后者多了一个[],原因是这种语法是 TOML 提供的数组特性[[bin]] 这种写法意味着我们可以在 Cargo.toml 中创建多个 [[bin]] ,每一个对应一个二进制文件,而由于只能指定一个库目标,因此只能使用 [lib] 形式。

我们以 [lib] 为例来说明相应的配置项,其它的目标都基本相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
[lib]
name = "foo" # 对象名称: 库对象、`src/main.rs` 二进制对象的名称默认是项目名
path = "src/lib.rs" # 对象的源文件路径
test = true # 能否被测试,默认是 true
doctest = true # 文档测试是否开启,默认是 true
bench = true # 基准测试是否开启
doc = true # 文档功能是否开启
plugin = false # 是否可以用于编译器插件(deprecated).
proc-macro = false # 是否是过程宏类型的库
harness = true # 是否使用libtest harness : https://doc.rust-lang.org/stable/rustc/tests/index.html
edition = "2015" # 对象使用的 Rust Edition版次
crate-type = ["lib"] # 生成的包类型
required-features = [] # 构建对象所需的 Cargo Features (N/A for lib).

其中:

name

对于库目标和默认的二进制目标( src/main.rs ),默认的名称是项目的名称( package.name )。

对于其它类型的对象,默认是目录或文件名。

除了 [lib] 外,name 字段对于其他目标都是必须的。

path

path 字段指定crate的源相对于 Cargo.toml 文件的位置。如果未指定,则根据目标名称自动推断。

test

test 字段指示 cargo test 默认情况下是否测试目标。 库目标、二进制目标和测试目标的默认值为 true

doctest

doctest 字段指示文档示例是否默认由 cargo test 进行测试。这仅与库目标[lib]相关,对其他部分没有影响。库目标的默认值为 true

bench

bench 字段指示目标是否默认由 cargo bench 进行基准测试。库目标、二进制目标和基准测试目标的默认值为 true

doc

doc 字段指示目标是否默认包含在 cargo doc 生成的文档中。库目标和二进制目标的默认值为 true

注意:如果二进制目标的名称与库目标相同,则将跳过该二进制目标。

plugin

该字段用于 rustc 插件,该字段已被弃用。

proc-macro

proc-macro 字段表示该库是一个过程宏(具体可参考自定义derive)。这仅对 [lib] 目标有效。

harness

harness 字段指示 --test 标志将传递给 rustc ,它将自动包含 libtest 库,该库是用于收集和运行标有#[test]属性或带有 #[bench] 属性的目标。所有目标的默认值为 true

如果设置为 false ,那么用户需要负责定义 main() 函数来运行测试和基准测试。

无论是否启用该字段,测试都会启用 cfg(test) 条件表达式

edition

edition 字段定义目标将使用的 Rust 版次。如果未指定,则默认为 [package]edition 字段。通常不应设置此字段,并且仅适用于高级场景,例如逐步将大型包过渡到新版本。

crate-type

crate-type 字段定义目标将生成的crate类型。它是一个字符串数组,允许你为单个目标指定多个crate类型。只能为库目标和示例目标指定该值。二进制目标、测试目标和基准测试目标始终是“bin”包类型。默认值如下:

目标包类型
正常的库目标“lib”
过程宏的库目标“proc-macro”
示例目标“bin”

可用选项有 binlibrlibdylibcdylibstaticlibproc-macro 。可以在 Rust 参考手册中阅读有关不同 crate 类型的更多信息。

required-features

required-features 字段指定构建目标所需的features列表。如果未启用任何必需的功能,则将跳过目标。该字段只对 [[bin]][[bench]][[test]][[example]] 有效,对于 [lib] 没有任何效果。

1
2
3
4
5
6
7
8
9
[features]
# ...
postgres = []
sqlite = []
tools = []

[[bin]]
name = "my-pg-tool"
required-features = ["postgres", "tools"]

目标自动发现

默认情况下,Cargo 根据文件的package结构自动确定要构建的目标,被称为目标自动发现。而上两节介绍的配置项,例如 [lib][[bin]][[test]][[bench]][[example]] ,用于手动修改配置目标。

可以禁用自动目标发现,以便仅构建手动配置的目标。将 [package]autotestsautobenches 设置为 false 将禁用相应目标类型的自动发现。

仅在特殊情况下才需要禁用自动发现。例如,如果你有一个库,你需要一个名为 bin 的模块,这会出现问题,因为 Cargo 通常会尝试将 bin 目录中的任何内容编译为可执行文件。以下是此场景的示例布局:

1
2
3
4
5
├── Cargo.toml
└── src
├── lib.rs
└── bin
└── mod.rs

要防止 Cargo 将 src/bin/mod.rs 推断为可执行文件,需要在 Cargo.toml 中设置 autobins = false 以禁用自动发现:

1
2
3
[package]
# …
autobins = false

对于 2015 版的项目,如果在 Cargo.toml 中手动定义了至少一个目标,则自动发现的默认值为 false 。从 2018 版开始,默认值始终为 true

4.6 工作空间

工作区(或工作空间)是一个或多个package(称为工作区成员)组成的集合,这些package被一起管理。

工作空间的特点如下:

  • 通用命令可以在所有工作空间成员上运行,例如 cargo check --workspace
  • 所有package项目共享一个位于工作区根目录中的通用 Cargo.lock 文件。
  • 所有package项目共享一个公共输出目录,默认为工作区根目录中名为 target 的目录。
  • 所有package项目共享元数据,例如 workspace.package
  • Cargo.toml 中的 [patch][replace][profile.*] 部分仅在根的清单中被识别,并在成员 crate 的清单中被忽略。

Cargo.toml 中, [workspace] 表包含以下部分:

  • [workspace] — 定义工作区。
    • resolver — 设置要使用的依赖解析器。==todo设置链接==
    • members — 要包含在工作区中的package。
    • exclude — 要从工作区中排除的package。
    • default-members — 未选择特定package时要操作的package。
    • package — 用于在package中继承的键。
    • dependencies — 用于在package依赖项中继承的键。
    • metadata — 外部工具的额外设置。
  • [patch] — 覆盖依赖项。
  • [replace] — 覆盖依赖项(已弃用)。
  • [profile] — 编译器设置和优化。

[workspace] 部分

要创建工作空间,首先需要将 [workspace] 添加到 Cargo.toml

1
2
[workspace]
# ...

工作区至少必须有一个成员,可以是根项目(Root package),也可以是虚拟工作空间(Virtual workspace)。

Root package 根项目

如果将 [workspace] 部分添加到已定义 [package]项的 Cargo.toml 中,则该package是工作区的root package。工作空间根目录是工作空间的 Cargo.toml 所在的目录。示例:

1
2
3
4
5
6
[workspace]

[package]
name = "hello_world" # the name of the package
version = "0.1.0" # the current version, obeying semver
authors = ["Alice <a@example.com>", "Bob <b@example.com>"]
Virtual workspace 虚拟工作空间

如果创建带有 [workspace] 部分但没有 [package] 部分的 Cargo.toml 文件,这称为虚拟工作清单。对于没有主 package 的场景或你希望将所有的 package 组织在单独的目录中时,这种方式就非常适合。比如:

根项目的定义:

1
2
3
4
# [PROJECT_DIR]/Cargo.toml
[workspace]
members = ["hello_world"]
resolver = "2"

定义了一个工作空间,在它的下面有hello_world子项目:

1
2
3
4
5
6
# [PROJECT_DIR]/hello_world/Cargo.toml
[package]
name = "hello_world" # the name of the package
version = "0.1.0" # the current version, obeying semver
edition = "2021" # the edition, will have no effect on a resolver used in the workspace
authors = ["Alice <a@example.com>", "Bob <b@example.com>"]

请注意,在虚拟清单中,应手动指定 resolver = "2" 。它通常是从虚拟清单中不存在的 package.edition 字段推断出来的,并且成员的版本字段不会影响工作区使用的解析器。

members 和 exclude 字段

membersexclude 字段定义哪些包是工作区的成员:

1
2
3
[workspace]
members = ["member1", "path/to/member2", "crates/*"]
exclude = ["crates/foo", "path/to/other"]

在工作区目录中的所有path 依赖项 都会自动成为成员。可以使用 members 键列出其他成员,该键应该是包含带有 Cargo.toml 文件的目录的字符串数组。

members 列表还支持使用 *? 等典型文件名 glob 模式来匹配多个路径。

exclude 键可用于阻止路径包含在工作空间中。如果根本不希望某些路径依赖项出现在工作区中,或者使用 glob 模式并且你想要删除目录,这可能会很有用。

当位于工作区的子目录中时,Cargo 将自动在父目录中搜索具有 [workspace] 定义的 Cargo.toml 文件,以确定要使用的工作区。 package.workspace 清单键可以在成员中使用,以手动设置指向工作区的根目录以覆盖此自动搜索。如果成员不在工作区根目录的子目录中,则手动设置可能很有用。

Package selection

在工作区中,与package相关的 Cargo 命令(例如 cargo build )可以使用 -p / --package--workspace 命令行标志来确定要操作哪个package。如果这两个标志都没有指定,Cargo 将使用当前工作目录中的package。如果当前目录是虚拟工作空间,它将应用于所有成员(就像在命令行中指定 --workspace 一样)。另外可请参考default-members

default-members 字段

可以指定可选的 default-members 键来设置在工作区根目录中操作的成员并且不使用命令行标志:

1
2
3
[workspace]
members = ["path/to/member1", "path/to/member2", "path/to/member3/*"]
default-members = ["path/to/member2", "path/to/member3/foo"]

指定后, default-members 必须扩展为 members 的子集。

package 表

workspace.package 表是你定义可由工作区成员继承的键的位置。这些键可以通过在成员package中使用 {key}.workspace = true 定义来继承。

支持的键如下:

支持的键支持的键
authorscategories
descriptiondocumentation
editionexclude
homepageinclude
keywordslicense
license-filepublish
readmerepository
rust-versionversion

其中:

  • license-filereadme 相对于工作空间根目录
  • includeexclude 相对于你的package根目录

例子:

1
2
3
4
5
6
7
8
9
# [PROJECT_DIR]/Cargo.toml
[workspace]
members = ["bar"]

[workspace.package]
version = "1.2.3"
authors = ["Nice Folks"]
description = "A short description of my package"
documentation = "https://example.com/bar"
1
2
3
4
5
6
7
# [PROJECT_DIR]/bar/Cargo.toml
[package]
name = "bar"
version.workspace = true
authors.workspace = true
description.workspace = true
documentation.workspace = true

dependencies 表

workspace.dependencies 表是你定义要由工作区成员继承的依赖项的位置。

指定工作区依赖关系与项目依赖关系类似,除了:

  • 此表中的依赖项不能声明为 optional
  • 此表中声明的 features[dependencies] 中的 features 相加

然后,你可以将工作区依赖项继承为项目依赖项

例子:

1
2
3
4
5
6
7
8
# [PROJECT_DIR]/Cargo.toml
[workspace]
members = ["bar"]

[workspace.dependencies]
cc = "1.0.73"
rand = "0.8.5"
regex = { version = "1.6.0", default-features = false, features = ["std"] }
1
2
3
4
5
6
7
8
9
10
11
12
13
# [PROJECT_DIR]/bar/Cargo.toml
[package]
name = "bar"
version = "0.2.0"

[dependencies]
regex = { workspace = true, features = ["unicode"] }

[build-dependencies]
cc.workspace = true

[dev-dependencies]
rand.workspace = true

metadata 表

workspace.metadata 表被 Cargo 忽略,并且不会发出警告。此部分可用于想要在 Cargo.toml 中存储工作区配置的工具。例如:

1
2
3
4
5
6
7
[workspace]
members = ["member1", "member2"]

[workspace.metadata.webcontents]
root = "path/to/webproject"
tool = ["npm", "run", "build"]
# ...

package.metadata 的包级别有一组类似的表。虽然 Cargo 没有指定这些表的内容格式,但建议外部工具可能希望以一致的方式使用它们,例如如果数据丢失,且这对于相关工具有意义的话,则引用 workspace.metadata 中的数据来自 package.metadata

4.7 Cargo Features

Cargo Features提供了一种表达条件编译和可选依赖项的机制。可以在 Cargo.toml[features] 表中定义一组命名功能,每个功能都可以启用或禁用。可以使用 --features 等标志在命令行上启用正在构建的项目的功能。可以在 Cargo.toml 中的依赖项声明中启用依赖项功能。

[features] 部分

功能(features)在 Cargo.toml[features] 表中定义。每个功能都指定一系列其他功能或它启用的可选依赖项。以下示例说明了如何将功能用于 2D 图像处理库,其中可以选择包含对不同图像格式的支持:

1
2
3
[features]
# 定义一个 feature : webp, 但它并没有启用其它 feature
webp = []

当定义了 webp 后,我们就可以在代码中通过 cfg 表达式来进行条件编译。例如项目中的 lib.rs 可以使用以下代码对 webp 模块进行条件引入:

1
2
#[cfg(feature = "webp")]
pub mod webp;

#[cfg(feature = "webp")] 的含义是:只有在 webp feature 被定义后,以下的 webp 模块才能被引入进来。由于我们之前在 [features] 里定义了 webp,因此以上代码的 webp 模块会被成功引入。

Cargo.toml 中定义的 feature 会被 Cargo 通过命令行参数 --cfg 传给 rustc,最终由后者完成编译:rustc --cfg ...。若项目中的代码想要测试 feature 是否存在,可以使用 cfg 属性cfg

功能可以列出要启用的其他功能。例如,ICO 图像格式可以包含 BMP 和 PNG 图像,因此当启用它时,应该确保也启用这些其他功能:

1
2
3
4
5
[features]
bmp = []
png = []
ico = ["bmp", "png"]
webp = []

功能名称可以包含 Unicode XID 标准中的字符(包括大多数字母),并且还允许以 _ 或数字 09 开头,以及在第一个字符还可能包含 -+. 。crates.io 对功能名称语法施加了额外的限制,即它们只能是 ASCII 字母数字字符或 _-+

default 功能

默认情况下,除非明确启用,否则所有功能均被禁用。这可以通过指定 default 功能来更改:

1
2
3
4
5
6
[features]
default = ["ico", "webp"]
bmp = []
png = []
ico = ["bmp", "png"]
webp = []

使用如上配置的项目被构建时,default feature 首先会被启用,然后它接着启用了 icowebp feature。可以通过以下方式更改此行为:

  • --no-default-features 命令行标志禁用包的默认功能。
  • default-features = false 选项可以在依赖声明中指定。

选择默认功能集时要小心。默认功能很方便,可以更轻松地使用package,而无需强迫用户仔细选择启用哪些功能以供常用,但也有一些缺点。除非指定 default-features = false ,否则依赖项会自动启用默认功能。这可能会导致难以确保默认功能未启用,尤其是对于在依赖关系图中多次出现的依赖关系。每个包必须确保指定 default-features = false 以避免启用它们。

可选依赖项

依赖项可以标记为“可选”,这意味着默认情况下不会编译它们。例如,假设我们的 2D 图像处理库使用外部包来处理 GIF 图像。这可以这样写:

1
2
[dependencies]
gif = { version = "0.11.1", optional = true }

默认情况下,此可选依赖项隐式定义如下所示的功能:

1
2
[features]
gif = ["dep:gif"]

这意味着仅当启用 gif 功能时才会包含此依赖项。代码中可以使用相同的 cfg(feature = "gif") 语法,并且可以像命令行选项 --features gif 等任何功能一样启用依赖项。

当然,我们还可以通过显式定义 feature 的方式来启用这些可选依赖库,例如为了支持 AVIF 图片格式,我们需要引入两个依赖包,由于 avif 是通过 feature 引入的可选格式,因此它依赖的两个包也必须声明为可选的:

1
2
3
4
5
6
[dependencies]
ravif = { version = "0.6.3", optional = true }
rgb = { version = "0.8.25", optional = true }

[features]
avif = ["ravif", "rgb"]

之后,avif feature 一旦被启用,那这两个依赖库也将自动被引入。

依赖库自身的 feature

可以在依赖项声明中启用依赖项的功能。 features 键指示要启用的功能:

1
2
[dependencies]
serde = { version = "1.0.118", features = ["derive"] }

以上配置为 serde 依赖开启了 derive feature,还可以通过 default-features = false 来禁用依赖库的 default feature :

1
2
[dependencies]
flate2 = { version = "1.0.3", default-features = false, features = ["zlib"] }

这里我们禁用了 flate2default feature,但又手动为它启用了 zlib feature。

这种方式可能无法确保禁用默认功能。如果另一个依赖项包含 flate2 而不指定 default-features = false ,则将启用默认功能。

除此之外,还能通过下面的方式来间接开启依赖库的 feature :

1
2
3
4
5
6
[dependencies]
jpeg-decoder = { version = "0.1.20", default-features = false }

[features]
# Enables parallel processing support by enabling the "rayon" feature of jpeg-decoder.
parallel = ["jpeg-decoder/rayon"]

如上所示,我们定义了一个 parallel feature,同时为其启用了 jpeg-decoder 依赖的 rayon feature。

"package-name/feature-name" 语法是可选依赖项,则它也会启用 package-name 。通常这不是你想要的。可以像 "package-name?/feature-name" 一样添加 ? ,只有在其他内容启用可选依赖项时才会启用给定功能。

例如,假设我们在库中添加了一些序列化支持,并且需要在一些可选依赖项中启用相应的功能。可以这样做:

1
2
3
4
5
6
[dependencies]
serde = { version = "1.0.133", optional = true }
rgb = { version = "0.8.25", optional = true }

[features]
serde = ["dep:serde", "rgb?/serde"]

在此示例中,启用 serde 功能将启用 serde 依赖项。它还将为 rgb 依赖项启用 serde 功能,但前提是有其他东西已启用 rgb 依赖项。

通过命令行参数启用 feature

以下命令行标志可用于控制启用哪些功能:

  • --features 功能:启用列出的功能。多个功能可以用逗号或空格分隔。如果使用空格,且从 shell 运行 Cargo(例如 --features "foo bar" ),请务必在所有功能周围使用引号。如果在工作区中构建多个项目,则可以使用 package-name/feature-name 语法来指定特定工作区成员的功能。
  • --all-features :激活在命令行上选择的所有包的所有功能。
  • --no-default-features :不激活所选软件包的 default 功能。

功能统一

功能对于定义它们的package来说是唯一的,不同package之间的 feature 允许同名。因此,在一个package上启用 feature 不会导致另一个package的同名 feature 被误启用。

当一个依赖项被多个package使用时,Cargo 在构建它时将使用该依赖项上启用的所有功能的联合。这有助于确保仅使用依赖项的单个副本。有关更多详细信息,请参阅这里

例如,让我们看一下使用大量功能的 winapi 包。如果您的包依赖于一个包 foo ,它启用了 winapi 的“fileapi”和“handleapi”功能,并且另一个依赖项 bar 启用了“std”和 winapi 的“winnt”功能,然后 winapi 将在启用所有四个功能的情况下构建。

winapi-features

这样做的结果是features是可加的。也就是说,启用一个 feature 不应该导致某个功能被禁止,并且启用任意功能组合通常应该是安全的。功能不应引入 SemVer 不兼容的更改。

例如,如果我们想可选地支持 no_std 环境(不使用标准库),那么有两种做法:

  • 默认代码使用标准库,当 no_std feature 启用时,禁用相关的标准库代码
  • 默认代码使用非标准库,当 std feature 启用时,才使用标准库的代码

前者就是功能削减,与之相对,后者是功能添加,根据之前的内容,我们应该选择后者的做法:

1
2
3
4
5
6
7
8
9
#![no_std]

#[cfg(feature = "std")]
extern crate std;

#[cfg(feature = "std")]
pub fn function_that_requires_std() {
// ...
}
彼此互斥的 feature

某极少数情况下,features 之间可能会互相不兼容。我们应该避免这种设计,因为如果一旦这么设计了,那你可能需要修改依赖图的很多地方才能避免两个不兼容 feature 的同时启用。

如果实在没有办法,可以考虑增加一个编译错误来让报错更清晰:

1
2
#[cfg(all(feature = "foo", feature = "bar"))]
compile_error!("feature \"foo\" and feature \"bar\" cannot be enabled at the same time");

不要使用互斥的功能,而是考虑其他一些选项:

  • 将功能拆分为单独的package。
  • 当存在冲突时,选择一项功能而不是另一项。 cfg-if可以帮助编写更复杂的 cfg 表达式。
  • 构建代码以允许同时启用这些功能,并使用运行时选项来控制使用哪些功能。例如,使用配置文件、命令行参数或环境变量来选择要启用的行为。
检查已解析的 features

在复杂的依赖关系图中,有时很难理解如何在不同的包上启用不同的功能。 cargo tree 命令提供了多个选项来帮助检查和可视化启用了哪些功能。下面一些可以尝试的选项:

  • cargo tree -e features :该命令以依赖图的方式来展示已启用的 features,包含了每个依赖包所启用的特性。
  • cargo tree -f "{p} {f}" :这是一个更紧凑的视图,显示每个包上启用的功能的逗号分隔列表。
  • cargo tree -e features -i foo :这将反转树,显示功能如何流入给定的包“foo”。这可能很有用,因为查看整个图表可能会非常大且令人难以承受。当您试图找出特定软件包上启用了哪些功能以及原因时,请使用此选项。请参阅 cargo tree 页面底部的示例了解更多。

feature解析器v2版本

可以使用 Cargo.toml 中的 resolver 字段指定不同的功能解析器,如下所示:

1
2
3
4
[package]
name = "my-package"
version = "1.0.0"
resolver = "2"

有关指定解析器版本的更多详细信息,请参阅解析器版本

简单来说,版本 "2" 解析器避免在某些情况下统一功能,而这种统一可能是不需要的。比如:

  • 为特定平台开启的 features 且此时并没有被构建,启用的功能将被忽略
  • build-dependenciesproc-macros 不再跟普通的依赖共享 features
  • dev-dependenciesfeatures 不会被启用,除非正在构建的对象需要它们(例如测试对象、示例对象等)

对于部分场景而言,feature 同一化确实是需要避免的,例如,一个构建依赖开启了 std feature,而同一个依赖又被用于 no_std 环境,很明显,开启 std 将导致错误的发生。

然而,缺点是这会增加构建时间,因为依赖关系被构建多次(每次都有不同的功能)。使用版本 "2" 解析器时,建议检查多次构建的依赖项,以减少总体构建时间。如果不需要构建具有单独功能的重复包,请考虑将功能添加到依赖项声明中的 features 列表中,以便重复项最终具有相同的功能(因此 Cargo 将仅构建一次)。可以使用 cargo tree --duplicates 命令检测这些重复的依赖项。它将显示哪些包被多次构建;查找列出的任何具有相同版本的条目。有关获取已解析功能信息的更多信息,请参阅检查已解析功能。对于构建依赖项,如果使用 --target 标志进行交叉编译,则不需要这样做,因为构建依赖项始终与该场景中的正常依赖项分开构建。

构建脚本

构建脚本可以通过检查 CARGO_FEATURE_<name> 环境变量来检测包上启用了哪些功能,其中 <name> 是转换为大写的功能名称,- 被转换为 _

required features

如果未启用某个功能, required-features 字段可用于禁用特定的 Cargo 目标

语义版本兼容性

启用功能不应引入 SemVer 不兼容的更改。例如,该功能不应以可能破坏现有用途的方式更改现有 API。

添加和删除功能定义和可选依赖项时应小心,因为这些有时可能是向后不兼容的更改。简而言之,请遵循以下规则:

feature 文档和发现

我们鼓励你记录你的软件包中提供了哪些功能。这可以通过在 lib.rs 顶部添加文档注释来完成。作为示例,请参阅 regex crate,呈现后的效果可以在 docs.rs 上查看。如果你有其他文档(例如用户指南),请考虑在其中添加文档(例如,请参阅 serde.rs)。如果你有二进制项目,请考虑在自述文件或项目的其他文档中记录功能(例如,请参阅 sccache)。

特别是对于不稳定的或者不该再被使用的 feature 而言,它们应该被放在文档中进行清晰的说明。

在 docs.rs 上发布的文档可以使用 Cargo.toml 中的元数据来控制构建文档时启用哪些功能。有关更多详细信息,请参阅 docs.rs 元数据文档

Rustdoc 对文档进行注释提供实验性支持,以指示使用某些 API 需要哪些功能。有关更多详细信息,请参阅 doc_cfg 文档syn 文档就是一个例子,你可以在其中看到彩色框,其中注明了使用它需要哪些功能。

如何寻找定义的features

若依赖库的文档中对其使用的 features 做了详细描述,那你会更容易知道他们使用了哪些 features 以及该如何使用。

当依赖库的文档没有相关信息时,你也可以通过源码仓库的 Cargo.toml 文件来获取,但是有些时候,使用这种方式来跟踪并获取全部相关的信息是相当困难的。

更多feature使用示例

对于更多feature使用示例,参考这里

环境变量

package ID

发布配置

注册服务

FAQ

为什么版本控制中二进制文件有 Cargo.lock ,但库文件没有?

七、Tokio

1 介绍

1.1 总览

语言和库的支持中提到过,语言本身没有提供异步运行时,而是交由第三方实现。Tokio 就是 rust 的异步运行时之一。它提供了编写网络应用程序所需的构建块。它提供了针对各种系统的灵活性,从具有数十个内核的大型服务器到小型嵌入式设备。

在顶层上,Tokio 提供了几个主要组件:

  • 用于执行异步代码的多线程运行时
  • 标准库的异步版本
  • 一个庞大的库生态系统

1.2 Tokio的特点

  1. 快速

    Tokio 速度很快,建立在 Rust 编程语言之上,而 Rust 本身也很快。这符合 Rust 的设计理念,其目标是您不应该通过手动编写等效代码来提高性能。Tokio 是一个可扩展的库,构建在 async/await 语言特性之上,而 async/await 本身也是可扩展的。在处理网络时,由于延迟的存在,处理连接的速度存在一定的限制,因此唯一的扩展方式就是同时处理多个连接。借助 async/await 语言特性,增加并发操作的数量变得非常廉价,使你能够扩展到大量的并发任务。

  2. 可靠

    Tokio 是使用 Rust 构建的,Rust 是一种赋予每个人构建可靠高效软件能力的语言。许多研究发现,大约 70% 的高严重性安全漏洞是由于内存不安全造成的。使用 Rust 可以在应用程序中消除这一类错误。Tokio 也非常注重提供一致的行为,避免出现意外情况。Tokio 的主要目标是允许用户部署可预测的软件,在日复一日的运行中表现一致,具有可靠的响应时间,并且没有不可预测的延迟波动。

  3. 简单

    借助 Rust 的 async/await 特性,编写异步应用程序的复杂性大大降低。再加上 Tokio 提供的工具和充满活力的生态系统,编写应用程序变得轻而易举。在有意义时,Tokio 遵循标准库的命名约定。这使得可以轻松地将仅使用标准库编写的代码转换为使用 Tokio 编写的代码。得益于 Rust 强大的类型系统,轻松交付正确代码的能力无与伦比。

  4. 灵活

    Tokio 提供多种运行时的变体。从多线程的工作窃取运行时到轻量级的单线程运行时,应有尽有。每个运行时都提供了许多参数,使用户可以根据自己的需求进行调整。

1.3 何时不应使用Tokio

尽管 Tokio 对于许多需要同时执行多个任务的项目非常有用,但也有一些使用情况不适合使用 Tokio。

  • 如果需要通过在多个线程上并行运行来加速 CPU 密集型计算,那么使用 Tokio 并不是一个合适的选择。Tokio 主要设计用于 IO 密集型应用程序,在这种应用程序中,每个单独的任务大部分时间都在等待 IO 操作。如果你的应用程序只需要并行运行计算任务,那么应该使用 rayon。然而,如果需要同时处理两种类型的任务,仍然可以进行“混合和匹配“的操作。
  • 读取大量文件的情况下,尽管看起来 Tokio 对于仅需要读取大量文件的项目可能很有用,但与普通线程池相比,Tokio 在这里并没有优势。这是因为操作系统通常不提供异步文件 API。
  • 在发送单个网络请求的情况下,Tokio 的优势在于需要同时执行多个任务的情况。如果你需要使用面向异步 Rust 的库(如 reqwest),但并不需要同时执行很多任务,那么最好选择该库的阻塞版本,因为这样可以简化项目。当然,使用 Tokio 仍然可以工作,但与阻塞 API 相比,并没有真正的优势。

1.4 Tokio功能标志

Tokio 由许多模块组成,这些模块提供了一系列在 Rust 中实现异步应用程序所必需的功能。

Tokio 使用一组功能标志(feature flags)来减少编译代码的量。可以只启用其中的某些功能而不是全部启用。默认情况下,Tokio 不启用任何功能,但允许用户根据自己的用例启用其中的一个子集。每个函数、结构体和特征都有一个或多个所需的功能标志,以便使用该项。以下是可用的功能标志列表:

  • full: 启用所有下面列出的所有功能,但不包括 test-utiltracing
  • rt: 启用 tokio::spawncurrent-thread 调度器和非调度器实用工具。
  • rt-multi-thread: 启用较重的多线程工作窃取调度器。
  • io-util: 启用基于 IO 的Ext 特征。
  • io-std: 启用 StdoutStdinStderr 类型。
  • net: 启用 tokio::net 类型,如 TcpStreamUnixStreamUdpSocket,以及(在类 Unix 系统上)AsyncFd 和(在 FreeBSD 上)PollAio
  • time: 启用 tokio::time 类型,并允许调度器启用内置定时器。
  • process: 启用 tokio::process 类型。
  • macros: 启用 #[tokio::main]#[tokio::test] 宏。
  • sync: 启用所有 tokio::sync 类型。
  • signal: 启用所有 tokio::signal 类型。
  • fs: 启用 tokio::fs 类型。
  • test-util: 启用基于测试的 Tokio 运行时基础设施。
  • parking_lot: 作为潜在的优化,内部使用 parking_lot crate 的同步原语。此外,在 const 上下文中构建某些原语需要此依赖项。MSRV(最低支持 Rust 版本)可能会根据所使用的 parking_lot 发布版本而增加。

注意:AsyncRead AsyncWrite 特征不需要任何特征并且始终可用。

作为初学者,最简单的入门方法是启用所有功能。通过启用 full 功能标志来执行此操作,在Cargo.toml[dependencies]中:

1
tokio = { version = "1", features = ["full"] }

这将启用所有公共 API。但要注意,这会引入许多额外的依赖项,你可能并不需要全部。

而作为库的作者,你的目标应该是提供基于 Tokio 的最轻量级 crate。为了实现这一目标,你应该确保只启用你需要的功能。这样可以使用户在使用你的 crate 时不必启用不必要的功能,比如你可能只需要 tokio::spawn 并使用 TcpStream 的库:

1
tokio = { version = "1", features = ["rt", "net"] }

2 Tokio的核心概念

在使用tokio之前,应当先理解tokio的核心概念:Runtimetask。只有理解了这两个核心概念,才能正确地、合理地使用tokio。

2.1 runtime

与其他 Rust 程序不同,异步应用程序需要运行时支持。特别是,以下运行时服务是必需的:

  • I/O 事件循环(称为驱动程序),用于驱动 I/O 资源并将 I/O 事件分派给依赖于它们的任务。
  • 调度器,用于执行使用这些 I/O 资源的任务。
  • 定时器,用于安排在一定时间后运行的工作。

Tokio 的 Runtime 将所有这些服务捆绑为单一类型,允许它们一起启动、关闭和配置。但是,通常不需要手动配置 Runtime ,用户可以直接使用 tokio::main 属性宏,它会在后台创建 Runtime

手动创建runtime

使用tokio::runtime::Runtime创建Runtime

1
2
3
4
5
6
use tokio;

fn main() {
// 创建runtime
let rt = tokio::runtime::Runtime::new().unwrap();
}

也可以使用tokio::runtime::Builder来配置并创建Runtime

1
2
3
4
5
6
7
8
9
10
11
use tokio;

fn main() {
// 创建带有线程池的runtime
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(8) // 8个工作线程
.enable_io() // 可在runtime中使用异步IO
.enable_time() // 可在runtime中使用异步计时器(timer)
.build() // 创建runtime
.unwrap();
}

tokio提供了两种工作模式的runtime:

  • 多线程(线程池)的runtime(multi thread runtime),默认情况下,它将为系统上可用的每个 CPU 核心启动一个工作线程。这往往是大多数应用程序的理想配置。多线程调度器需要 rt-multi-thread 特性标志,默认选中。
  • 单一线程的runtime(single thread runtime,也称为current thread runtime),所有任务都将在当前线程上创建和执行,这需要 rt 功能标志。只有明确指定,才能创建出单一线程的runtime。

下面的例子创建单线程的rumtime:

1
2
3
use tokio::runtime;
// 创建单一线程的runtime
let rt = runtime::Builder::new_current_thread().build().unwrap();

这里以及上述代码中出现的的rt称为runtime Handle,它可以被clone。它可以spawn()生成异步任务,这些异步任务将绑定在其所引用的runtime中,还可以block_on()enter()进入其所引用的runtime,此外,还可以生成blocking thread。

使用 tokio::main 属性宏创建runtime

tokio提供了简化的创建方式,那就是通过 tokio::main 属性宏创建runtime。

1
2
3
4
5
6
use tokio;

#[tokio::main]
async fn main() {
println!("Hello world");
}

通过#[tokio::main]属性宏注解(annotation),使得async main自身成为一个async runtime。

与之等效的不使用属性宏的代码如下:

1
2
3
4
5
6
7
8
9
fn main() {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
println!("Hello world");
})
}

注意几点:

  • 此宏旨在简化并针对不需要复杂设置的应用程序。如果提供的功能还不够,仍然可以使用tokio::runtime::Builder,它提供了更强大的接口。
  • 此宏可用于任何函数,而不仅仅是 main 函数。不过在非main函数上使用它会使该函数表现得好像它是同步的,每次调用它时都会启动一个新的runtime。如果经常调用该函数,最好使用tokio::runtime::Builder创建运行时,以便可以在调用之间重用运行时。

默认情况下,#[tokio::main]创建的是多线程runtime,因此它实际相当于:

1
#[tokio::main(flavor = "multi_thread"]

要使用 current_thread 运行时的单线程运行时,可以使用以下命令配置宏:

1
2
3
4
5
6
7
8
9
10
#[tokio::main(flavor = "current_thread")]

// 这等价于
fn main() {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async { ... })
}

还可以手动设置线程数量:

1
2
3
4
// 完整
#[tokio::main(flavor = "multi_thread", worker_threads = 2))]
// 简写
#[tokio::main(worker_threads = 2)]

多个runtime共存

可手动创建线程,并在不同线程内创建互相独立的runtime。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::thread;
use std::time::Duration;
use tokio::runtime::Runtime;

fn main() {
// 在第一个线程内创建一个多线程的runtime
let t1 = thread::spawn(||{
let rt = Runtime::new().unwrap();
thread::sleep(Duration::from_secs(10));
});

// 在第二个线程内创建一个多线程的runtime
let t2 = thread::spawn(||{
let rt = Runtime::new().unwrap();
thread::sleep(Duration::from_secs(10));
});

t1.join().unwrap();
t2.join().unwrap();
}

对于4核8线程的电脑,此时总共有19个OS线程:16个worker-thread,2个spawn-thread,一个main-thread。

runtime实现了SendSync这两个特征,因此也可以将runtime包在Arc里,然后跨线程使用同一个runtime。

在runtime中执行异步任务

了解如何创建runtime之后,我们需要实际让它们执行一些任务。这些任务一般都是一些IO的任务(以发挥tokio的最大效能),比如发送或者处理网络IO等等。

在学习使用的过程中,暂时不需要实现这些复杂的任务逻辑,我们将这些任务抽象成睡眠操作,用tokio::time::sleep()代表这些异步IO。注意,std::time也提供了sleep(),但它会阻塞整个线程,而tokio::time中的sleep()则只是让它所在的任务放弃CPU并进入调度队列等待被唤醒,它不会阻塞任何线程,它所在的线程仍然可被用来执行其它异步任务。因此,在tokio的runtime中,应当使用tokio::time中的sleep()

在runtime中执行一段任务:

1
2
3
4
5
6
7
8
9
10
11
use tokio::runtime::Runtime;
use chrono::Local;

fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
println!("before sleep: {}", Local::now().format("%F %T.%3f"));
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
println!("after sleep: {}", Local::now().format("%F %T.%3f"));
});
}

这里使用了chrono第三方crate,用于查看开始和结束的时间,在Cargo.toml[dependencies]中添加:

1
chrono = "0.4.26"

编译运行,可以看出sleep了2秒。另外,在上面的程序中,上面调用了runtime的block_on(),该方法要求一个Future作为参数,可以像上面一样直接使用一个async {}语法来定义一个Future。每一个Future都是一个已经定义好但尚未执行的异步任务,每一个异步任务中可能会包含其它子任务。

这些异步任务被创建好后不会立即执行,需要先将它们放入到runtime环境,然后在合适的地方通过Futureawait来执行它们。await可以将已经定义好的异步任务立即加入到runtime的任务队列中等待调度执行,于此同时,await会等待该异步任务完成才返回。

将任务创建和执行拆开来看:

1
2
3
4
5
6
7
8
9
10
rt.block_on(async {
// 只是定义了Future,此时尚未执行
let task = tokio::time::sleep(tokio::time::Duration::from_secs(2));
// ...不会执行...
// ...
// 开始执行task任务,并等待它执行完成
task.await;

// 上面的任务完成之后,才会继续执行下面的代码
});

block_on会阻塞当前线程(例如阻塞住上面的main函数所在的主线程),直到其指定的异步任务树(可能有子任务)全部完成。这是运行时的入口点。

block_on的返回值为其所执行异步任务的返回值:

1
2
3
4
5
6
7
8
9
10
use tokio::{time, runtime::Runtime};

fn main() {
let rt = Runtime::new().unwrap();
let res: i32 = rt.block_on(async{
time::sleep(time::Duration::from_secs(2)).await;
3
});
println!("{}", res); // 3
}

使用spawn向runtime中添加新的异步任务

tokio::spawn用于创建一个新的异步任务,并为其返回一个 JoinHandle ,比如:

1
2
3
tokio::spawn(async {
time::sleep(time::Duration::from_secs(5)).await;
});

创建一个任务使该任务能够与其他任务并发执行。创建的任务可以在当前线程上执行,也可以发送到不同的线程执行。具体取决于当前的 Runtime 配置。 注意,该函数无法保证创建的任务会执行到完成。当runtime关闭时,所有未完成的任务都将被删除,无论该任务的生命周期如何。

另外,必须从runtime的上下文中调用此函数,在runtime内运行的任务始终在其上下文中。下面是一个错误的示例:

1
2
3
4
5
6
7
8
use tokio::{self, time};

fn main() {
// 错误,此时并不在runtime上下文中
tokio::spawn(async {
time::sleep(time::Duration::from_secs(5)).await;
});
}

必须处在runtime中,或使用 Runtime::enter 方法进入上下文,在下一小节会介绍。

但是在runtime上下文外部可以使用spawn定义任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use std::thread;

use chrono::Local;
use tokio::{self, runtime::Runtime, time};

fn now() -> String {
Local::now().format("%F %T").to_string()
}

// 在runtime外部定义一个异步任务,且该函数返回值不是Future类型
fn async_task() {
println!("create an async task: {}", now());
tokio::spawn(async {
time::sleep(time::Duration::from_secs(10)).await;
println!("async task over: {}", now());
});
}

fn main() {
let rt1 = Runtime::new().unwrap();
rt1.block_on(async {
// 调用函数,该函数内创建了一个异步任务,将在当前runtime内执行
async_task();
});
}

runtime自身也有spawn方法,因此,也可以传递runtime(注意,要传递runtime的引用),然后使用runtime的spawn()

1
2
3
4
5
6
7
8
9
10
11
12
13
use tokio::{runtime::Runtime, time};
fn async_task(rt: &Runtime) {
rt.spawn(async {
time::sleep(time::Duration::from_secs(10)).await;
});
}

fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
async_task(&rt);
});
}

使用enter进入runtime上下文

block_on()是进入runtime的主要方式。但还有另一种进入runtime的方式:enter()block_on()进入runtime时,会阻塞当前线程,enter()进入runtime时,不会阻塞当前线程,它会返回一个EnterGuardEnterGuard没有其它作用,它仅仅只是声明从它开始的所有异步任务都将在runtime上下文中执行,直到删除该EnterGuard

删除EnterGuard并不会删除runtime,只是释放之前的runtime上下文声明。因此,删除EnterGuard之后,可以声明另一个EnterGuard,这可以再次进入runtime的上下文环境。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
use tokio::{self, runtime::Runtime, time};
use chrono::Local;
use std::thread;

fn now() -> String {
Local::now().format("%F %T").to_string()
}

fn main() {
let rt = Runtime::new().unwrap();

// 进入runtime,但不阻塞当前线程
let guard1 = rt.enter();

// 生成的异步任务将放入当前的runtime上下文中执行
tokio::spawn(async {
time::sleep(time::Duration::from_secs(5)).await;
println!("task1 sleep over: {}", now());
});

// 释放runtime上下文,这并不会删除runtime
drop(guard1);

// 可以再次进入runtime
let guard2 = rt.enter();
tokio::spawn(async {
time::sleep(time::Duration::from_secs(4)).await;
println!("task2 sleep over: {}", now());
});

drop(guard2);

// 阻塞当前线程,等待异步任务的完成
thread::sleep(std::time::Duration::from_secs(10));
}

runtime和异步调度

runtime提供了异步IO驱动、异步计时器等异步API,还提供了任务的调度器(scheduler)和Reactor事件循环(Event Loop)。

简单来说,runtime就是一个控制如何执行任务的调度器。当一个异步任务需要运行,在Tokio的runtime实现中,这个任务会被放入到一个队列中,当任务不能继续下去的时候,它会让出CPU,进入睡眠状态,等待下一次被唤醒,

就绪队列中的每一个任务都是可运行的任务,可随时被调度器调度选中。调度时会选择哪一个任务,是调度器根据调度算法去决定的。tokio的作者,非常友好地提供了一篇他实现tokio调度器的思路,里面详细介绍了调度器的基本知识和tokio调度器的调度策略,见:10倍提升Tokio调度器的性能

tokio的两种线程:worker thread和blocking thread

需要注意,tokio提供了两种功能的线程:

  • 用于异步任务的工作线程(worker thread)
  • 用于同步任务的阻塞线程(blocking thread)

单个线程或多个线程的runtime,指的都是工作线程,即只用于执行异步任务的线程,这些任务主要是IO密集型的任务。tokio默认会将每一个工作线程均匀地绑定到每一个CPU核心上。

但是,有些必要的任务可能会长时间计算而占用线程,甚至任务可能是同步的,它会直接阻塞整个线程(比如thread::time::sleep()),这类任务如果计算时间或阻塞时间较短,勉强可以考虑留在异步队列中,但如果任务计算时间或阻塞时间可能会较长,它们将不适合放在异步队列中,因为它们会破坏异步调度,使得同线程中的其它异步任务处于长时间等待状态,也就是说,这些异步任务可能会被饿很长一段时间。

例如,直接在runtime中执行阻塞线程的操作,由于这类阻塞操作不在tokio系统内,tokio无法识别这类线程阻塞的操作,tokio只能等待该线程阻塞操作的结束,才能重新获得那个线程的管理权。换句话说,worker thread被线程阻塞的时候,它已经脱离了tokio的控制,在一定程度上破坏了tokio的调度系统。

1
2
3
4
rt.block_on(async{
// 在runtime中,让整个线程进入睡眠,注意不是tokio::time::sleep()
std::thread::sleep(std::time::Duration::from_secs(10));
});

因此,tokio提供了这两类不同的线程。worker thread只用于执行那些异步任务,异步任务指的是不会阻塞线程的任务。而一旦遇到本该阻塞但却不会阻塞的操作(如使用tokio::time::sleep()而不是std::thread::sleep()),会直接放弃CPU,将线程交还给调度器,使该线程能够再次被调度器分配到其它异步任务。blocking thread则用于那些长时间计算的或阻塞整个线程的任务。

blocking thread默认是不存在的,只有在调用了spawn_blocking()时才会创建一个对应的blocking thread。

blocking thread不用于执行异步任务,因此runtime不会去调度管理这类线程,它们在本质上相当于一个独立的thread::spawn()创建的线程,它也不会像block_on()一样会阻塞当前线程。它和独立线程的唯一区别,是blocking thread是在runtime内的,可以在runtime内对它们使用一些异步操作,例如await。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use std::thread;
use chrono::Local;
use tokio::{self, runtime::Runtime, time};

fn now() -> String {
Local::now().format("%F %T").to_string()
}

fn main() {
let rt1 = Runtime::new().unwrap();
// 创建一个blocking thread,可立即执行(由操作系统调度系统决定何时执行)
// 注意,不阻塞当前线程
let task = rt1.spawn_blocking(|| {
println!("in task: {}", now());
// 注意,是线程的睡眠,不是tokio的睡眠,因此会阻塞整个线程
thread::sleep(std::time::Duration::from_secs(10))
});

// 小睡1毫秒,让上面的blocking thread先运行起来
std::thread::sleep(std::time::Duration::from_millis(1));
println!("not blocking: {}", now());

// 可在runtime内等待blocking_thread的完成
rt1.block_on(async {
task.await.unwrap();
println!("after blocking task: {}", now());
});
}

输出:

1
2
3
in task: 2021-10-25 19:01:00
not blocking: 2021-10-25 19:01:00
after blocking task: 2021-10-25 19:01:10

需注意,blocking thread生成的任务虽然绑定了runtime,但是它不是异步任务,不受tokio调度系统控制。因此,如果在block_on()中生成了blocking thread或普通的线程,block_on()不会等待这些线程的完成。

1
2
3
4
5
6
rt.block_on(async{
// 生成一个blocking thread和一个独立的thread
// block on不会阻塞等待两个线程终止,因此block_on在这里会立即返回
rt.spawn_blocking(|| std::thread::sleep(std::time::Duration::from_secs(10)));
thread::spawn(||std::thread::sleep(std::time::Duration::from_secs(10)));
});

tokio允许的blocking thread队列很长(默认512个),且可以在runtime build时通过max_blocking_threads()配置最大长度。如果超出了最大队列长度,新的任务将放在一个等待队列中进行等待(比如当前已经有512个正在运行的任务,下一个任务将等待,直到有某个blocking thread空闲)。

blocking thread执行完对应任务后,并不会立即释放,而是继续保持活动状态一段时间,此时它们的状态是空闲状态。当空闲时长超出一定时间后(可在runtime build时通过thread_keep_alive()配置空闲的超时时长),该空闲线程将被释放。

blocking thread有时候是非常友好的,它像独立线程一样,但又和runtime绑定,它不受tokio的调度系统调度,tokio不会把其它任务放进该线程,也不会把该线程内的任务转移到其它线程。换言之,它有机会完完整整地发挥单个线程的全部能力,而不像worker线程一样,可能会被调度器打断。

关闭runtime

由于异步任务完全依赖于Runtime,而Runtime又是程序的一部分,它可以轻易地被删除(drop),这时Runtime会被关闭(shutdown)。

1
2
3
let rt = Runtime::new().unwrap();
...
drop(rt);

这里的变量rt,官方手册将其称为runtime的句柄(runtime handle)。

关闭Runtime时,将使得该Runtime中的所有异步任务被移除。完整的关闭过程如下:

  1. 先移除整个任务队列,保证不再产生也不再调度新任务

  2. 移除当前正在执行但尚未完成的异步任务,即终止所有的worker thread

  3. 移除Reactor,禁止接收事件通知

注意,这种删除runtime句柄的方式只会立即关闭未被阻塞的worker thread,那些已经运行起来的blocking thread以及已经阻塞整个线程的worker thread仍然会执行。但是,删除runtime又要等待runtime中的所有异步和非异步任务(会阻塞线程的任务)都完成,因此删除操作会阻塞当前线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
use std::thread;
use chrono::Local;
use tokio::{self, runtime::Runtime, time};

fn now() -> String {
Local::now().format("%F %T").to_string()
}

fn main() {
let rt = Runtime::new().unwrap();
// 一个运行5秒的blocking thread
// 删除rt时,该任务将继续运行,直到自己终止
rt.spawn_blocking(|| {
thread::sleep(std::time::Duration::from_secs(5));
println!("blocking thread task over: {}", now());
});

// 进入runtime,并生成一个运行3秒的异步任务,
// 删除rt时,该任务直接被终止
let _guard = rt.enter();
rt.spawn(async {
time::sleep(time::Duration::from_secs(3)).await;
println!("worker thread task over 1: {}", now());
});

// 进入runtime,并生成一个运行4秒的阻塞整个线程的任务
// 删除rt时,该任务继续运行,直到自己终止
rt.spawn(async {
std::thread::sleep(std::time::Duration::from_secs(4));
println!("worker thread task over 2: {}", now());
});

// 先让所有任务运行起来
std::thread::sleep(std::time::Duration::from_millis(3));

// 删除runtime句柄,将直接移除那个3秒的异步任务,
// 且阻塞5秒,直到所有已经阻塞的thread完成
drop(rt);
println!("runtime droped: {}", now());
}

输出结果(注意结果中没有异步任务中println!()输出的内容):

1
2
3
worker thread task over 2: 2021-10-25 20:08:35
blocking thread task over: 2021-10-25 20:08:36
runtime droped: 2021-10-25 20:08:36

关闭runtime可能会被阻塞,因此,如果是在某个runtime中关闭另一个runtime,将会导致当前的runtime的某个worker thread被阻塞,甚至可能会阻塞很长时间,这是异步环境不允许的。

tokio提供了另外两个关闭runtime的方式:shutdown_timeout()shutdown_background()。前者会等待指定的时间,如果正在超时时间内还未完成关闭,将强行终止runtime中的所有线程。后者是立即强行关闭runtime。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
use std::thread;
use chrono::Local;
use tokio::{self, runtime::Runtime, time};

fn now() -> String {
Local::now().format("%F %T").to_string()
}

fn main() {
let rt = Runtime::new().unwrap();

rt.spawn_blocking(|| {
thread::sleep(std::time::Duration::from_secs(5));
println!("blocking thread task over: {}", now());
});

let _guard = rt.enter();
rt.spawn(async {
time::sleep(time::Duration::from_secs(3)).await;
println!("worker thread task over 1: {}", now());
});

rt.spawn(async {
std::thread::sleep(std::time::Duration::from_secs(4));
println!("worker thread task over 2: {}", now());
});

// 先让所有任务运行起来
std::thread::sleep(std::time::Duration::from_millis(3));

// 1秒后强行关闭Runtime
rt.shutdown_timeout(std::time::Duration::from_secs(1));
println!("runtime droped: {}", now());
}

输出:

1
runtime droped: 2021-10-25 20:16:02

需要注意的是,如果强行关闭runtime,可能会使得尚未完成的任务的资源泄露(因为阻塞的任务还在运行,直到它们结束,但是此时已经无法控制它们了)。因此,应小心使用强行关闭runtime的操作。

shutdown_background()函数相当于调用 shutdown_timeout(Duration::from_nanos(0))

2.2 task

什么是tokio中的任务

任务是轻量级、非阻塞的执行单元。任务类似于 OS 线程,但它们不是由 OS 调度程序管理,而是由 Tokio 运行时管理。这种通用模式的另一个名称是绿色线程(Green thread)。如果你熟悉 Go 的 goroutines、Kotlin 的协程或 Erlang 的进程,你可以将 Tokio 的任务视为类似的东西。

这里提到的“绿色线程”指的是由运行时库或虚拟机 (VM) 而非底层操作系统 (OS) 本机调度的线程。换句话说,绿色线程是相对于OS线程而言的,OS线程由操作系统提供并调度(内核空间),而绿色线程由用户提供并调度(用户空间)。

解释了何为绿色线程后,继续回到主题,什么是任务(task)?

在rust异步编程中,可以认为每定义一个Future,就定义了一个尚未执行的task,该task放入runtime中开始运行的时候,它就是真正的task,一个真正的异步任务。任务有以下特点:

  • 任务是轻量级的。因为任务是由tokio runtime而不是操作系统调度的,所以创建新任务或在任务之间切换不需要上下文切换并且开销相当低。创建、运行和销毁大量任务的成本非常低,尤其是与 OS 线程相比。
  • 任务是协作安排的。大多数操作系统实现了抢占式的多任务处理技术。这种调度技术允许操作系统为每个线程分配一段时间运行,然后将其抢占,并暂停该线程并切换到另一个线程。相比之下,任务实现了协作式的多任务处理。在协作式多任务处理中,任务可以一直运行直到它“放弃”(yield),表示当前无法继续执行,这时Tokio运行时的调度器会切换到执行下一个任务。
  • 任务是非阻塞的。通常情况下,当一个操作系统线程执行I/O或必须与另一个线程同步时,它会被阻塞,允许操作系统调度另一个线程。而对于任务而言,当任务无法继续执行时,必须“放弃”(yield),以便Tokio运行时可以调度另一个任务。一般来说,任务不应该执行系统调用或其他可能阻塞线程的操作,否则这将阻止在同一线程上运行的其他任务被执行。

要注意,在tokio runtime中执行的并不都是异步任务,绑定在runtime中的可能是同步任务(例如一个数值计算就是一个同步任务,只是速度非常快,可忽略不计),可能会长时间计算,可能会阻塞整个线程。tokio严格区分异步任务和同步任务,只有异步任务才算是tokio task。tokio推荐的做法是将同步任务放入blocking thread中运行。

tokio::task

tokio::task模块提供了几个函数:

  • tokio::task::spawn,它和tokio::spawn是同一个函数,用于向runtime中添加新的异步任务
  • tokio::task::spawn_blocking,生成一个阻塞的线程(blocking thread)并执行指定的任务
  • tokio::task::block_in_place,在某个worker thread中执行同步任务,但是会将同线程中的其它异步任务转移走,使得异步任务不会被同步任务饥饿。此函数不能在 current_thread 运行时中使用,因为在这种情况下,没有其他工作线程可以将任务交给它,使用此函数创建的任务无法被取消,当关闭执行器时,它将无限期地等待所有阻塞操作完成。可以使用 shutdown_timeout 在特定超时后停止等待它们。请注意,这仍然不会取消任务——它们只是允许在方法返回后继续运行。
  • tokio::task::yield_now,放弃CPU,将执行权交还给 Tokio 运行时。当前任务将作为待处理任务重新添加到待处理队列的后面。将安排任何其他挂起的任务。任务继续不需要其他唤醒。通常情况下,在调用yield_now()后,不能保证运行时要调度哪个任务。特别是,运行时可以选择立即再次轮询刚刚运行yield_now()的任务,而不先轮询任何其他任务。例如,在每次轮询任务之间,运行时不会驱动IO驱动程序,这可能导致运行时立即再次轮询当前任务,即使有另一个任务正在等待来自IO驱动程序的通知也可能如此,而该任务可以继续运行。
  • tokio::task::unconstrained,将指定的异步任务声明为不受限的异步任务,它将不受tokio的协作式调度,它将一直霸占当前线程直到任务完成,不会受到tokio调度器的管理,使用它可能会使你的其他任务面临饥饿。
  • tokio::task::spawn_local,生成一个在当前线程内运行,一定不会被窃取到其它线程中运行的异步任务。这是因为生成的Future实现了!Send(即没有实现Send)。

这里的三个spawn类的方法都返回JoinHandle类型,JoinHandle类型可以通过await来等待异步任务的完成,也可以通过abort()来中断异步任务,异步任务被中断后返回JoinError类型。

task::spawn()

这个很简单,就是直接在当前的runtime中生成一个异步任务。在使用spawn向runtime中添加新的异步任务介绍过。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use chrono::Local;
use std::thread;
use tokio::{self, task, runtime::Runtime, time};

fn now() -> String {
Local::now().format("%F %T").to_string()
}

fn main() {
let rt = Runtime::new().unwrap();
let _guard = rt.enter();
task::spawn(async {
time::sleep(time::Duration::from_secs(3)).await;
println!("task over: {}", now());
});

thread::sleep(time::Duration::from_secs(4));
}

task::spawn_blocking()

生成一个blocking thread来执行指定的任务。这个也在上文tokio的两种线程:worker thread和blocking thread(#tokio的两种线程:worker thread和blocking thread)中介绍过。

1
2
3
4
5
6
7
let join = task::spawn_blocking(|| {
// do some compute-heavy work or call synchronous code
"blocking completed"
});

let result = join.await?;
assert_eq!(result, "blocking completed");

task::block_in_place()

block_in_place()的目的和spawn_blocking()类似。区别在于spawn_blocking()会新生成一个blocking thread来执行指定的任务,而block_in_place()是在当前worker thread中执行指定的可能会长时间运行或长时间阻塞线程的任务,但是它会先将该worker thread中已经存在的异步任务转移到其它worker thread,使得这些异步任务不会被饥饿。

显然,block_in_place()只应该在多线程runtime环境中运行,如果是单线程runtime,block_in_place会阻塞唯一的那个worker thread。

1
2
3
4
5
use tokio::task;

task::block_in_place(move || {
// do some compute-heavy work or call synchronous code
});

在block_in_place内部,可以使用block_on()enter()重新进入runtime环境。

1
2
3
4
5
6
7
8
use tokio::task;
use tokio::runtime::Handle;

task::block_in_place(move || {
Handle::current().block_on(async move {
// do something async
});
});

task::yield_now()

放弃CPU,将执行权交还给 Tokio 运行时。当前任务将作为待处理任务重新添加到待处理队列的后面。注意,调用yield_now()后还需await才立即放弃CPU,因为yield_now本身是一个异步任务。

1
2
3
4
5
6
7
8
9
10
11
12
use tokio::task;

async {
task::spawn(async {
// ...
println!("spawned task done!")
});

// Yield, allowing the newly-spawned task to execute first.
task::yield_now().await;
println!("main task done!");
}

task::unconstrained

tokio的异步任务都是受tokio调度控制的,tokio采用协作式调度策略来调度它所管理的异步任务。当异步任务中的执行到了某个本该阻塞的操作时(即使用了tokio提供的那些原本会阻塞的API,例如tokio版本的sleep()),将不会阻塞当前线程,而是进入等待队列,等待Reactor接收事件通知来唤醒该异步任务,这样当前线程会被释放给调度器,使得调度器能够继续分配其它异步任务到该线程上执行。

task::unconstrained则将指定的异步任务声明未不受限的异步任务,它将不受tokio的协作式调度,它将一直霸占当前线程直到任务完成,不会受到tokio调度器的管理,使用它可能会使你的其他任务面临饥饿,如果确实有这样的需求,建议使用block_in_place()spawn_blocking()

取消任务abort()

前面提到,三个spawn类的方法都返回JoinHandle类型,该类型用于管理任务。比如正在执行的异步任务可以随时被abort()取消,取消之后的任务返回JoinError类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use tokio::{self, runtime::Runtime, time};

fn main() {
let rt = Runtime::new().unwrap();

rt.block_on(async {
let task = tokio::task::spawn(async {
time::sleep(time::Duration::from_secs(10)).await;
});

// 让上面的异步任务跑起来
time::sleep(time::Duration::from_millis(1)).await;
task.abort(); // 取消任务
// 取消任务之后,可以取得JoinError
let abort_err: JoinError = task.await.unwrap_err();
println!("{}", abort_err.is_cancelled());
})
}

如果异步任务已经完成,再对该任务执行abort()操作将没有任何效果。也就是说,没有JoinError,task.await.unwrap_err()将报错,而task.await.unwrap()不会报错。

固定在线程内执行的本地异步任务

tokio::task::LocalSet是在同一线程上执行的一组任务,也就是说,它们不会被跨线程执行。在某些情况下,运行一个或多个未实现 SendFuture是有必要的,因为在线程之间发送任务不安全。在这些情况下,本地任务集可用于安排一个或多个 !SendFuture在同一线程上一起运行。

要使用tokio::task::LocalSet,需使用LocalSet::new()先创建好一个LocalSet实例,它将生成一个独立的任务队列用来存放本地异步任务。

之后,便可以使用LocalSetspawn_local()向该队列中添加异步任务。但是,添加的异步任务不会直接执行,只有对LocalSet调用await或调用LocalSet::run_until()LocalSet::block_on()的时候,才会开始运行本地队列中的异步任务。调用后两个方法会进入LocalSet的上下文环境。

例如,使用await来运行本地异步任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
use chrono::Local;
use tokio::{self, runtime::Runtime, time};

fn now() -> String {
Local::now().format("%F %T").to_string()
}

fn main() {
let rt = Runtime::new().unwrap();
let local_tasks = tokio::task::LocalSet::new();

// 向本地任务队列中添加新的异步任务,但现在不会执行
local_tasks.spawn_local(async {
println!("local task1");
time::sleep(time::Duration::from_secs(5)).await;
println!("local task1 done");
});

local_tasks.spawn_local(async {
println!("local task2");
time::sleep(time::Duration::from_secs(5)).await;
println!("local task2 done");
});

println!("before local tasks running: {}", now());
rt.block_on(async {
// 开始执行本地任务队列中的所有异步任务,并等待它们全部完成
local_tasks.await;
});
}

此外,task::spawn_local也用于生成一个在当前线程内运行,一定不会被窃取到其它线程中运行的异步任务。它实际上是在当前的 LocalSet 上生成一个 !SendFuture。但是它的使用有个限制,必须在LocalSet上下文中才能调用。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
use chrono::Local;
use tokio::{self, runtime::Runtime, time};

fn now() -> String {
Local::now().format("%F %T").to_string()
}

fn main() {
let rt = Runtime::new().unwrap();
let local_tasks = tokio::task::LocalSet::new();

local_tasks.spawn_local(async {
println!("local task1");
time::sleep(time::Duration::from_secs(2)).await;
println!("local task1 done");
});

local_tasks.spawn_local(async {
println!("local task2");
time::sleep(time::Duration::from_secs(3)).await;
println!("local task2 done");
});

println!("before local tasks running: {}", now());
// LocalSet::block_on进入LocalSet上下文
local_tasks.block_on(&rt, async {
tokio::task::spawn_local(async {
println!("local task3");
time::sleep(time::Duration::from_secs(4)).await;
println!("local task3 done");
}).await.unwrap();
});
println!("all local tasks done: {}", now());
}

需要注意的是,调用LocalSet::block_on()LocalSet::run_until()时均需指定一个异步任务(Future)作为其参数,它们都会立即开始执行该异步任务以及本地任务队列中已存在的任务,但是这两个函数均只等待其参数对应的异步任务执行完成就返回。这意味着,它们返回的时候,可能还有正在执行中的本地异步任务,它们会继续保留在本地任务队列中。当再次进入LocalSet上下文或await LocalSet的时候,它们会等待调度并运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
use chrono::Local;
use std::thread;
use tokio::{self, runtime::Runtime, time};

fn now() -> String {
Local::now().format("%F %T").to_string()
}

fn main() {
let rt = Runtime::new().unwrap();
let local_tasks = tokio::task::LocalSet::new();

local_tasks.spawn_local(async {
println!("local task1");
time::sleep(time::Duration::from_secs(2)).await;
println!("local task1 done {}", now());
});

// task2要睡眠10秒,它将被第一次local_tasks.block_on在3秒后中断
local_tasks.spawn_local(async {
println!("local task2");
time::sleep(time::Duration::from_secs(10)).await;
println!("local task2 done, {}", now());
});

println!("before local tasks running: {}", now());
local_tasks.block_on(&rt, async {
tokio::task::spawn_local(async {
println!("local task3");
time::sleep(time::Duration::from_secs(3)).await;
println!("local task3 done: {}", now());
}).await.unwrap();
});

// 线程阻塞15秒,此时task2睡眠10秒的时间已经过去了,
// 当再次进入LocalSet时,task2将可以直接被唤醒
thread::sleep(std::time::Duration::from_secs(15));

// 再次进入LocalSet
local_tasks.block_on(&rt, async {
// 先执行该任务,当遇到睡眠1秒的任务时,将出现任务切换,
// 此时,调度器将调度task2,而此时task2已经睡眠完成
println!("re enter localset context: {}", now());
time::sleep(time::Duration::from_secs(1)).await;
println!("re enter localset context done: {}", now());
});
println!("all local tasks done: {}", now());
}

输出结果:

1
2
3
4
5
6
7
8
9
10
before local tasks running: 2021-10-26 20:19:11
local task1
local task3
local task2
local task1 done 2021-10-26 20:19:13
local task3 done: 2021-10-26 20:19:14
re enter localset context: 2021-10-26 20:19:29
local task2 done, 2021-10-26 20:19:29
re enter localset context done: 2021-10-26 20:19:30
all local tasks done: 2021-10-26 20:19:30

需要注意的是,再次运行本地异步任务时,之前被中断的异步任务所等待的事件可能已经出现了,因此它们可能会被直接唤醒重新进入就绪队列等待下次轮询调度。正如上面需要睡眠10秒的task2,它会被第一次block_on中断,虽然task2已经不再执行,但是15秒之后它的睡眠完成事件已经出现,它可以在下次调度本地任务时直接被唤醒。但注意,唤醒的任务不是直接就可以被执行的,而是放入就绪队列等待调度。

这意味着,再次进入上下文时,所指定的Future中必须至少存在一个会引起调度切换的任务,否则该Future以同步的方式运行直到结束都不会给已经被唤醒的任务任何执行的机会(也就是说,上面的例子中再次进入LocalSet时,使用了await才引起了调度切换)。

将上面示例中的第二个block_on中的Future参数换成下面的async代码块,task2将不会被调度执行:

1
2
3
4
local_tasks.block_on(&rt, async {
println!("re-enter localset context, and exit context");
println!("task2 will not be scheduled");
})

下面是使用run_until()两次进入LocalSet上下文的示例,和block_on()类似,区别仅在于它只能在Runtime::block_on()内或[tokio::main]注解的main函数内部被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
use chrono::Local;
use std::thread;
use tokio::{self, runtime::Runtime, time};

fn now() -> String {
Local::now().format("%F %T").to_string()
}

fn main() {
let rt = Runtime::new().unwrap();
let local_tasks = tokio::task::LocalSet::new();

local_tasks.spawn_local(async {
println!("local task1");
time::sleep(time::Duration::from_secs(5)).await;
println!("local task1 done {}", now());
});

println!("before local tasks running: {}", now());
rt.block_on(async {
local_tasks
.run_until(async {
println!("local task2");
time::sleep(time::Duration::from_secs(3)).await;
println!("local task2 done: {}", now());
})
.await;
});

thread::sleep(std::time::Duration::from_secs(10));
rt.block_on(async {
local_tasks
.run_until(async {
println!("local task3");
tokio::task::yield_now().await;
println!("local task3 done: {}", now());
})
.await;
});
println!("all local tasks done: {}", now());
}

输出结果:

1
2
3
4
5
6
7
8
before local tasks running: 2021-10-26 21:23:18
local task2
local task1
local task2 done: 2021-10-26 21:23:21
local task3
local task1 done 2021-10-26 21:23:31
local task3 done: 2021-10-26 21:23:31
all local tasks done: 2021-10-26 21:23:31

判断任务是否已经终止

可使用JoinHandleis_finished()方法来判断任务是否已终止,它是非阻塞的。请注意,即使在任务上调用了 abort ,此方法也可以返回 false 。这是因为取消过程可能需要一些时间,并且此方法在完成之前不会返回 true

1
2
3
4
5
6
7
8
9
let task = tokio::spawn(async {
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
});

// 立即输出 false
println!("1 {}", task.is_finished());
tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
// 输出 true
println!("2 {}", task.is_finished());

is_finished()常用于在多个任务中轮询直到其中一个任务终止。

任务集合JoinSet

tokio::task::JoinSet是在 Tokio 运行时生成的任务集合。JoinSet 可用于等待集合中部分或全部任务的完成。集合没有顺序,任务将按照完成的顺序返回。它通常用于收集一系列异步任务,并判断它们是否终止。

另外,所有任务都必须具有相同的返回类型 T 。当删除 JoinSet 时, JoinSet 中的所有任务都会立即中止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use tokio::task::JoinSet;

#[tokio::main]
async fn main() {
let mut set = JoinSet::new();

for i in 0..10 {
set.spawn(async move { i });
}

let mut seen = [false; 10];
while let Some(res) = set.join_next().await {
let idx = res.unwrap();
seen[idx] = true;
}

for i in 0..10 {
assert!(seen[i]);
}
}

可以看出,如果要等待多个或所有任务完成,则循环join_next()即可。如果JoinSet为空,则该方法返回None

1
while let Some(_) = set.join_next().await {}

使用JoinSetabort_all()或直接drop JoinSet,都会对所有异步任务进行abort()操作。这不会从 JoinSet 中删除任务。要等待任务完成取消,您应该循环调用 join_next ,直到 JoinSet 为空。

使用JoinSetshutdown()方法,则中止所有任务并等待它们完成关闭。调用这个方法相当于调用 abort_all ,然后循环调用 join_next ,直到返回 None

使用JoinSetdetach_all()将从此 JoinSet 中删除所有任务而不中止它们。即使JoinSet被丢弃,被detach的任务也依然会在后台运行。

2.3 tokio宏

一次运行多个Future中介绍过一些常用的宏,比如join!等,下面要介绍的是tokio实现的版本。

tokio::join!tokio::try_join!

我们可以通过await等待某个异步任务完成,无论这个任务是正常完成还是被取消。

tokio也提供了两个宏tokio::join!tokio::try_join!,它们可以用于等待多个异步任务全部完成:

  • join!等待多个并发分支,当所有分支完成时才返回
  • try_join!等待多个并发分支,要么等待所有异步任务正常完成返回,要么在遇到第一个返回 Err(_)的任务返回

这两个宏必须在异步函数、闭包和块内部使用。

join!使用举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
async fn do_stuff_async() {
// async work
}

async fn more_async_work() {
// more here
}

#[tokio::main]
async fn main() {
let (first, second) = tokio::join!(
do_stuff_async(),
more_async_work());

// do something with the values
}

try_join!用于两个分支(OkErr):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
async fn do_stuff_async() -> Result<(), &'static str> {
// async work
}

async fn more_async_work() -> Result<(), &'static str> {
// more here
}

#[tokio::main]
async fn main() {
let res = tokio::try_join!(
do_stuff_async(),
more_async_work());

match res {
Ok((first, second)) => {
// do something with the values
}
Err(err) => {
println!("processing failed; error = {}", err);
}
}
}

try_join! 用于spawn生成的任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
use tokio::task::JoinHandle;

async fn do_stuff_async() -> Result<(), &'static str> {
// async work
}

async fn more_async_work() -> Result<(), &'static str> {
// more here
}

async fn flatten<T>(handle: JoinHandle<Result<T, &'static str>>) -> Result<T, &'static str> {
match handle.await {
Ok(Ok(result)) => Ok(result),
Ok(Err(err)) => Err(err),
Err(err) => Err("handling failed"),
}
}

#[tokio::main]
async fn main() {
let handle1 = tokio::spawn(do_stuff_async());
let handle2 = tokio::spawn(more_async_work());
match tokio::try_join!(flatten(handle1), flatten(handle2)) {
Ok(val) => {
// do something with the values
}
Err(err) => {
println!("Failed with {}.", err);
}
}
}

tokio::select!

future::select!相同,tokio::select!等待多个并发分支,在第一个分支完成时返回,同时取消其余分支。select! 宏必须在异步函数、闭包和块内部使用。

它的语法如下:

1
2
3
4
5
6
tokio::select! {
<pattern1> = <async expression 1> (, if <precondition1>)? => <handler1>, // branch 1
<pattern2> = <async expression 2> (, if <precondition2>)? => <handler2>, // branch 2
...
(else => <handler_else>)?
};

else分支是可选的,每个分支的if前置条件是可选的,如果前置条件返回 false ,则分支被禁用。提供的 <async expression> 仍然被评估,但结果 future 永远不会被轮询。在循环中使用 select! 时,此功能很有用。

去除可选项后,简化的语法为:

1
2
3
4
5
tokio::select! {
<pattern1> = <async expression 1> => <handler1>, // branch 1
<pattern2> = <async expression 2> => <handler2>, // branch 2
...
};

即,每个分支都有一个异步任务,并对异步任务完成后的返回结果进行模式匹配,如果匹配成功,则执行对应的handler。

一个简单的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async fn do_stuff_async() {
// async work
}

async fn more_async_work() {
// more here
}

#[tokio::main]
async fn main() {
tokio::select! {
_ = do_stuff_async() => {
println!("do_stuff_async() completed first")
}
_ = more_async_work() => {
println!("more_async_work() completed first")
}
};
}

下面是官方手册对select!工作流程的描述:

  1. 评估所有分支中存在的if前置条件,如果某个分支的前置条件返回false,则禁用该分支。注意,循环时,每一轮执行的select!都会清除分支的禁用标记
  2. 收集所有分支中的异步表达式(包括已被禁用的分支),并在同一个线程中推进所有未被禁用的异步任务执行,然后等待
  3. 当某个分支的异步任务完成,将该异步任务的返回值与对应分支的模式进行匹配,如果匹配成功,则执行对应分支的handler,如果匹配失败,则禁用该分支,本次select!调用不会再考虑该分支。如果匹配失败,则重新等待下一个异步任务的完成
  4. 如果所有分支最终都被禁用,则执行else分支,如果不存在else分支,则panic

默认情况下,select!会伪随机公平地轮询每一个分支,如果确实需要让select!按照任务书写顺序去轮询,可以在select!中使用biased,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#[tokio::main]
async fn main() {
let mut count = 0u8;
loop {
tokio::select! {
// 如果取消biased,挑选的任务顺序将随机,可能会导致分支中的断言失败
biased;
_ = async {}, if count < 1 => { count += 1; assert_eq!(count, 1); }
_ = async {}, if count < 2 => { count += 1; assert_eq!(count, 2); }
_ = async {}, if count < 3 => { count += 1; assert_eq!(count, 3); }
_ = async {}, if count < 4 => { count += 1; assert_eq!(count, 4); }
else => { break; }
};
}
}

另外,上面的例子中将select!放进了一个loop循环中,这是很常见的用法。对于上面的例子来说,如果注释掉biased,那么在第一轮循环中,由于select!中的4个if前置条件均为true,因此按照随机的顺序推进这4个异步任务。由于上面示例中的异步任务表达式不做任何事,因此第一个被推进的异步任务会先完成,selcet!将取消剩余3个任务,假如先完成任务的分支的断言通过,那么select!返回后将进入下一轮loop循环,重新调用一次select!宏,重新评估if条件,这次将只有3个分支通过检测,不通过的那个分支将被禁用,select!将按照随机顺序推进这3个分支。

2.4 等待任一一个异步任务的终止

虽然join!() try_join!() select!()都可以等待一个或多个异步任务完成,但是有些情况下它们并不方便使用。

例如,客户端连接到服务端时,服务端为每个客户端都开启了n个异步任务,这些异步任务被收集在一个容器中(如Vec),这些任务都是长久工作的,直到客户端断开。理所当然地,应当去等待这些任务直到任意一个任务终止,然后abort()所有剩余任务,从而避免客户端断开后仍然在后台任务运行没有意义的任务,这很容易会导致内存飞速暴涨。

因为异步任务被收集在容器中,因此无法使用join!() try_join!() select!()去等待这些异步任务中的任意一个的完成。

有几种方式处理这种情况:

  1. 可以考虑使用is_finished()来轮询判断(为了避免忙等消耗CPU,建议加上轮询间隔)。
1
2
3
4
5
6
7
8
9
10
11
12
let tasks = vec![ Some_async_tasks ];
'outer: loop {
for task in &tasks {
if task.is_finished() {
break 'outer;
}
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
for task in tasks {
task.abort();
}
  1. 考虑使用JoinSet
  2. 考虑使用futures::future::try_join_all或者futures::stream::FuturesUnordered

3 Tokio中的time计时器

tokio::time中提供了一些用于跟踪时间的实用工具。该模块提供了多种类型,用于在设定的时间段后执行代码。需要开启time特性后才可以使用:

1
tokio = {version = "1.13", features = ["rt", "rt-multi-thread", "time"]}

该模块提供的类型如下:

  • Duration,它是std::time::Duration的重导出,它用于描述时间跨度,如3秒就是一个时间跨度
  • Instant,单调非递减时钟的测量。不透明(只能相互比较,没有办法从Instant得到“秒数”)且仅能配合 Duration 使用。例如,此刻是处在某个时间点A,下一次(例如某个时长过后),处在另一个时间点B,时间点B一定不会早于时间点A,即便修改了操作系统的时钟或硬件时钟,它也不会时光倒流的现象
  • Sleep,是一个Future,通过调用sleep()sleep_until()返回,该Future本身不工作,它只在到达某个特定的 Instant 时间点时完成
  • Interval 是一个流式的间隔计时器,通过调用interval()interval_at()返回。它使用 Duration 进行初始化,表示每隔一段时间(即指定的Duration时长)后就产生一个值
  • Timeout :封装异步任务(Future或者Stream),将上限设置为允许执行的时间量。如果任务没有及时完成,那么它会被取消并返回一个错误

3.1 时间跨度tokio::time::Duration

使用Duration表示时间跨度

3.2 时间点 tokio::time::Instant

tokio::time::Instant是对std::time::Instant的封装,添加了一些对齐功能,使其能够适用于tokio runtime,关于标准库的Instant使用Instant表示时间点

Instant是严格单调递增的,绝不会出现时光倒流的现象,即之后的时间点一定晚于之前创建的时间点。但是,tokio time提供了pause()函数可暂停时间点,还提供了advance()函数用于向后跳转到某个时间点。

tokio::time::Instant::now()用于创建代表此时此刻的时间点。Instant可以直接进行大小比较,还能执行+-操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use tokio;
use tokio::time::Instant;
use tokio::time::Duration;

#[tokio::main]
async fn main() {
// 创建代表此时此刻的时间点
let now = Instant::now();

// Instant 加一个Duration,得到另一个Instant
let next_3_sec = now + Duration::from_secs(3);
// Instant之间的大小比较
println!("{}", now < next_3_sec); // true

// Instant减Duration,得到另一个Instant
let new_instant = next_3_sec - Duration::from_secs(2);

// Instant减另一个Instant,得到Duration
let duration = new_instant - next_3_sec;
}

此外tokio::time::Instant提供了如下几个方法:

  • from_std(): 将std::time::Instant转换为tokio::time::Instant
  • into_std(): 将tokio::time::Instant转换为std::time::Instant
  • elapsed(): 指定的时间点实例,距离此时此刻的时间点,经过的时间,返回Duration
  • duration_since(): 返回从另一Instant到当前Instant经过的时间跨度(Duration),如果该Instant晚于这一Instant,则返回0(Duration
  • checked_duration_since():返回从另一Instant到当前Instant经过的时间跨度(Duration),如果该Instant晚于这一Instant,则返回None
  • saturating_duration_since(): 返回从另一Instant到当前Instant经过的时间跨度(Duration),如果该Instant晚于这一Instant,则返回0(Duration
  • checked_add(): 同标准库
  • checked_sub(): 同标准库

3.3 超时时间tokio::time::Timeout

该结构体是通过tokio::time::timeout()tokio::time::timeout_at()返回的Future

tokio::time::timeout()可设置一个异步任务的完成超时时间,它的签名如下:

1
2
3
pub fn timeout<F>(duration: Duration, future: F) -> Timeout<F>
where
F: Future,

指定一个durationfuture,如果future在指定的超时时间内已完成,则返回该异步任务的返回值,如果未完成,则异步任务被撤销并返回Err

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use chrono::Local;
use tokio::{self, runtime::Runtime, time};

fn now() -> String {
Local::now().format("%F %T").to_string()
}

fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let res = time::timeout(time::Duration::from_secs(5), async {
println!("sleeping: {}", now());
time::sleep(time::Duration::from_secs(6)).await;
33
});

match res.await {
Err(_) => println!("task timeout: {}", now()),
Ok(data) => println!("get the res '{}': {}", data, now()),
};
});
}

得到结果:

1
2
sleeping: 2021-11-03 17:12:33
task timeout: 2021-11-03 17:12:38

如果将睡眠6秒改为睡眠4秒,那么将得到结果:

1
2
sleeping: 2021-11-03 17:13:11
get the res '33': 2021-11-03 17:13:15

再看看tokio::time::timeout_at()函数签名:

1
2
3
pub fn timeout_at<F>(deadline: Instant, future: F) -> Timeout<F>
where
F: Future,

这里指定的是指定一个deadlinefuture,该future必须在deadline之前完成,如果没有按时完成,则返回Err

取消超时可以通过dropTimeout实例来完成,不需要额外的清理或其他工作。

得到time::Timeout实例之后,可以通过它提供的get_refget_mut获得Timeout所封装的Future的可变和不可变引用,使用into_inner获得所封装的Future,这会消费掉该Future

3.4 时间间隔tokio::time::Interval

该结构体可以通过tokio::time::interval()tokio::time::interval_at()函数返回,它代表时间间隔,主要用于设置间隔一定时间的周期性任务。

tokio::time::interval_at()的签名如下:

1
pub fn interval_at(start: Instant, period: Duration) -> Interval

创建新的 Interval ,它以 period 为间隔,start参数用于控制间隔计时器的开始计时点。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
use tokio::time::{interval_at, Duration, Instant};

#[tokio::main]
async fn main() {
let start = Instant::now() + Duration::from_millis(50);
let mut interval = interval_at(start, Duration::from_millis(10));

interval.tick().await; // ticks after 50ms
interval.tick().await; // ticks after 10ms
interval.tick().await; // ticks after 10ms

// approximately 70ms have elapsed.
}

tokio::time::interval() 的签名如下:

1
pub fn interval(period: Duration) -> Interval {}

创建以 period 为间隔的新 Interval,它在第一次被调用的时候立即开始计时,创建后这个时间间隔将无限期地执行。任何时候都可以删除 Interval 值,这将取消间隔。

下面是一个使用 interval 每两秒执行一次任务的简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
use tokio::time::{self, Duration};

#[tokio::main]
async fn main() {
let mut interval = time::interval(Duration::from_millis(10));

interval.tick().await; // ticks immediately
interval.tick().await; // ticks after 10ms
interval.tick().await; // ticks after 10ms

// approximately 20ms have elapsed.
}

可以看出,这两个函数只是定义了间隔计时器的起始计时点和间隔的时长,要真正开始让它开始计时,还需要调用它的tick()方法生成一个Future任务,并调用await来执行并等待该任务的完成。

有几点需要注意:

  1. interval_at()第一个参数定义的是计时器的开始时间,这样描述不准确,它表述的是最早都要等到这个时间点才开始计时。例如,定义计时器5秒之后开始计时,但在第一次tick()之前,先睡眠了10秒,那么该计时器将在10秒后才开始,但如果第一次tick之前只睡眠了3秒,那么还需再等待2秒该tick()计时任务才会完成。
  2. 定义计时器时,要将计时器变量声明为mut,因为每次tick()时,都需要修改计时器内部的下一次计时起点。
  3. 不像其它语言中的间隔计时器,tokio的间隔计时器需要手动调用tick()方法来生成临时的异步任务。

看下面的示例,定义5秒后开始的计时器,但在第一次开始计时前,先睡眠10秒。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use chrono::Local;
use tokio::{
self,
runtime::Runtime,
time::{self, Duration, Instant},
};

fn now() -> String {
Local::now().format("%F %T").to_string()
}

fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
println!("before: {}", now());

let start = Instant::now() + Duration::from_secs(5);
let interval = Duration::from_secs(1);
let mut intv = time::interval_at(start, interval);

time::sleep(Duration::from_secs(10)).await;
intv.tick().await;
println!("task 1: {}", now());
intv.tick().await;
println!("task 2: {}", now());
});
}

输出结果:

1
2
3
before: 2021-11-03 19:00:10
task 1: 2021-11-03 19:00:20
task 2: 2021-11-03 19:00:20

注意输出结果中的task 1和task 2的时间点是相同的,说明第一次tick之后,并没有等待1秒之后再执行紧跟着的tick,而是立即执行之。

简单解释一下上面示例中的计时器内部的工作流程,假设定义计时器的时间点是19:00:10:

  • 定义5秒后开始的计时器intv,该计时器内部有一个字段记录着下一次开始tick()的时间点,其值为19:00:15
  • 睡眠10秒后,时间点到了19:00:20,此时第一次执行intv.tick(),它将生成一个异步任务,执行器执行时发现此时此刻的时间点已经超过该计时器内部记录的值,于是该异步任务立即完成并进入就绪队列等待调度,同时修改计时器内部的值为19:00:16
  • 下一次执行tick的时候,此时此刻仍然是19:00:20,已经超过了该计时器内部的19:00:16,因此计时任务立即完成

这里要介绍Intervalmissed_tick_behavior方法,它返回一个tokio::time::MissedTickBehavior枚举。该枚举定义了 Interval 错过一次tick的行为。默认情况下,当错过一个tick时,Interval 会尽快触发tick,直到它及时“赶上”到它应该在的位置。具体来说,有三种策略:

  1. Brust(默认策略)

    冲刺型的计时策略,tick尽可能快,直到赶上为止。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    let mut interval = interval(Duration::from_millis(50));

    // First tick resolves immediately after creation
    // 第一次tick立即开始
    interval.tick().await;
    // 执行了一个200毫秒的任务
    task_that_takes_200_millis().await;
    // 执行完毕后,此时已经错过了tick

    // 因此我们开始加速,这一次tick会立即执行
    interval.tick().await;

    // 这一次tick应该在开始后100毫秒,已经错过了,因此立即执行
    interval.tick().await;

    // 在开始后150毫秒的tick,仍需要立即执行
    interval.tick().await;

    // 立即执行
    interval.tick().await;

    // 由于我们已经赶上了,下一次tick会正常等到开始后250毫秒开始执行
    interval.tick().await;

    这看起来像这样:

    1
    2
    Expected ticks: |     1     |     2     |     3     |     4     |     5     |     6     |
    Actual ticks: | work -----| delay | work | work | work -| work -----|
  2. Delay

    延迟性的计时策略。从调用 tick 开始,而不是从 start 开始,也就是说,当出现延迟后,仍然按部就班地每隔指定的时长计时。在内部,这种策略是在每次执行tick之后,都修改下一次计时起点为Instant::now() + Duration。因此,这种策略下的任何相邻两次的tick,其中间间隔的时长都至少达到Duration

    例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    use chrono::Local;
    use tokio::{self, runtime::Runtime};
    use tokio::time::{self, Duration, Instant, MissedTickBehavior};

    fn now() -> String {
    Local::now().format("%F %T").to_string()
    }

    fn main() {
    let rt = Runtime::new().unwrap();
    rt.block_on(async {
    println!("before: {}", now());

    let mut intv = time::interval_at(
    Instant::now() + Duration::from_secs(5),
    Duration::from_secs(2),
    );
    intv.set_missed_tick_behavior(MissedTickBehavior::Delay);

    time::sleep(Duration::from_secs(10)).await;

    println!("start: {}", now());
    intv.tick().await;
    println!("tick 1: {}", now());
    intv.tick().await;
    println!("tick 2: {}", now());
    intv.tick().await;
    println!("tick 3: {}", now());
    });
    }

    输出结果:

    1
    2
    3
    4
    5
    before: 2021-11-03 19:31:02
    start: 2021-11-03 19:31:12
    tick 1: 2021-11-03 19:31:12
    tick 2: 2021-11-03 19:31:14
    tick 3: 2021-11-03 19:31:16
  3. Skip

    忽略型的计时策略。这种策略总是以定义计时器时的起点为基准,类似等差数列,每一次执行tick的时间点,一定符合Start + N * Duration

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    use chrono::Local;
    use tokio::{self, runtime::Runtime};
    use tokio::time::{self, Duration, Instant, MissedTickBehavior};

    fn now() -> String {
    Local::now().format("%F %T").to_string()
    }

    fn main() {
    let rt = Runtime::new().unwrap();
    rt.block_on(async {
    println!("before: {}", now());

    let mut intv = time::interval_at(
    Instant::now() + Duration::from_secs(5),
    Duration::from_secs(2),
    );
    intv.set_missed_tick_behavior(MissedTickBehavior::Skip);

    time::sleep(Duration::from_secs(10)).await;

    println!("start: {}", now());
    intv.tick().await;
    println!("tick 1: {}", now());
    intv.tick().await;
    println!("tick 2: {}", now());
    intv.tick().await;
    println!("tick 3: {}", now());
    });
    }

    输出结果:

    1
    2
    3
    4
    5
    before: 2021-11-03 19:34:53
    start: 2021-11-03 19:35:03
    tick 1: 2021-11-03 19:35:03
    tick 2: 2021-11-03 19:35:04
    tick 3: 2021-11-03 19:35:06

上面通过interval_at()解释清楚了tokio::time::Interval的三种计时策略。但在程序中,更大的可能是使用interval()来定义间隔计时器,它等价于interval_at(Instant::now() + Duration),表示计时起点从现在开始的计时器。

此外,Interval还提供了一些方法。可以使用period()方法获取计时器的间隔时长,使用missed_tick_behavior()获取当前的计时策略,等等,见官方文档

3.5 睡眠 tokio::time::Sleep

该结构体是一个Future,由tokio::time::sleep()tokio::time::sleep_until()返回。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
use tokio::{self, runtime::Runtime, time};

fn main(){
let rt = Runtime::new().unwrap();
rt.block_on(async {
// 睡眠2秒
time::sleep(time::Duration::from_secs(2)).await;

// 一直睡眠,睡到2秒后醒来
time::sleep_until(time::Instant::now() + time::Duration::from_secs(2)).await;
});
}

注意,std::thread::sleep()会阻塞当前线程,而tokio的睡眠不会阻塞当前线程,实际上tokio的睡眠在进入睡眠后不做任何事,仅仅只是立即放弃CPU,并进入任务轮询队列,等待睡眠时间终点到了之后被唤醒,然后进入就绪队列等待被调度。

注意,tokio的睡眠精度是毫秒,因此无法保证、也不应睡眠更低精度的时间。例如不要睡眠100微秒或100纳秒,这时无法保证睡眠的时长。

下面是一个睡眠10微秒的例子,多次执行,会发现基本上都要1毫秒多,去掉执行指令的时间,实际的睡眠时长大概是1毫秒。另外,将睡眠10微秒改成睡眠100微秒或1纳秒,结果也是接近的。

1
2
3
4
5
6
7
8
9
10
11
12
use tokio::{self, runtime::Runtime, time};

fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let start = time::Instant::now();
// time::sleep(time::Duration::from_nanos(100)).await;
// time::sleep(time::Duration::from_micros(100)).await;
time::sleep(time::Duration::from_micros(10)).await;
println!("sleep {}", time::Instant::now().duration_since(start).as_nanos());
});
}

执行的多次,输出结果:

1
2
3
4
5
6
sleep 1174300
sleep 1202900
sleep 1161200
sleep 1393200
sleep 1306400
sleep 1285300

睡眠的最长持续时间为 68719476734 毫秒(大约 2.2 年)。此外,该类型没有实现 Unpin特征,这意味着如果你将它与 select! 一起使用或通过调用 poll 来使用,你必须先固定它。

tokio::time::Sleep有三个方法:

  • deadline():返回Instant,表示该睡眠任务的睡眠终点
  • is_elapsed():可判断此时此刻是否已经超过了该sleep任务的睡眠终点
  • reset():可用于重置睡眠任务。如果睡眠任务未完成,则直接修改睡眠终点,如果睡眠任务已经完成,则再次创建睡眠任务,等待新的终点

需要注意的是,reset()要求修改睡眠终点,因此Sleep实例需要是mut的,但这样会消费掉Sleep实例,更友好的方式是使用tokio::pin!(sleep)sleeppin在当前栈中,这样就可以调用as_mut()方法获取它的可修改版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use chrono::Local;
use tokio::{self, runtime::Runtime, time};

#[allow(dead_code)]
fn now() -> String {
Local::now().format("%F %T").to_string()
}

fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
println!("start: {}", now());
let slp = time::sleep(time::Duration::from_secs(1));
tokio::pin!(slp);

slp.as_mut().reset(time::Instant::now() + time::Duration::from_secs(2));

slp.await;
println!("end: {}", now());
});
}

输出:

1
2
start: 2021-11-02 21:57:42
end: 2021-11-02 21:57:44

重置已完成的睡眠实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use chrono::Local;
use tokio::{self, runtime::Runtime, time};

#[allow(dead_code)]
fn now() -> String {
Local::now().format("%F %T").to_string()
}

fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
println!("start: {}", now());
let slp = time::sleep(time::Duration::from_secs(1));
tokio::pin!(slp);

//注意调用slp.as_mut().await,而不是slp.await,后者会move消费掉slp
slp.as_mut().await;
println!("end 1: {}", now());

slp.as_mut().reset(time::Instant::now() + time::Duration::from_secs(2));

slp.await;
println!("end 2: {}", now());
});
}

输出结果:

1
2
3
start: 2021-11-02 21:59:25
end 1: 2021-11-02 21:59:26
end 2: 2021-11-02 21:59:28

4 Tokio中的异步通信和同步

tokio提供了异步多任务的并发能力,也提供了异步任务之间的通信方式和同步机制。

要使用tokio的同步功能,需要打开sync功能标志。

4.1 sync模块简介

tokio::sync模块主要包含两部分功能:异步任务之间的消息传递模块以及异步任务之间的状态同步模块。

消息传递

tokio程序中最常见的同步形式是消息传递。两个任务独立运行,相互发送消息进行同步。这样做的好处是可以避免共享状态。

消息传递是使用通道(channel)实现的。通道支持将消息从一个生产者任务发送到一个或多个消费者任务。tokio提供了几种通道。每个通道支持不同的消息传递模式。当一个通道支持多个生产者时,许多单独的任务可能会发送消息。当一个通道支持多个消费者时,许多不同的独立任务可能会接收消息。具体来说,tokio提供了以下几种通道:

  • 单向通道(Oneshot Channel)支持从单个生产者发送一个值到单个消费者。这种通道通常用于将计算结果发送到等待方。
  • 多生产者单消费者通道(mpsc Channel)支持从多个生产者发送多个值到单个消费者。这种通道通常用于将工作发送到任务或接收多个计算的结果。
  • 广播通道(Broadcast Channel)支持从多个生产者发送多个值到多个消费者,每个消费者都会接收到每个值。这种通道可以用于实现发布/订阅或聊天系统中常见的扇出(Fan-out)模式。
  • 观察通道(Watch Channel)支持从单个生产者发送多个值到多个消费者。然而,该通道中只会存储最新的值。当新值被发送时,消费者会收到通知,但不能保证每个消费者都会看到所有的值。

总的来说,不同类型的通道,都有不同的使用场景。

状态同步

剩下的同步原语侧重于同步状态。它们是标准库提供版本的异步等价物。它们的操作方式与标准库的对应项类似,但会以异步方式等待,而不是阻塞线程。

有以下几种基本的同步原语:

  • 屏障(Barrier)确保多个任务在继续执行之前彼此等待程序中的某个点到达。这样多个任务可以同时继续执行。
  • 互斥锁(Mutex)是一种互斥机制,确保最多只有一个线程能够访问某些数据。
  • 通知(Notify)是一种基本的任务通知机制。它支持在不发送数据的情况下通知接收任务,此时任务会唤醒并恢复处理。
  • 读写锁(RwLock)提供了一种互斥机制,允许多个读者同时进行读取操作,而只允许一个写者进行写入操作。在某些情况下,这比互斥锁更高效。
  • 信号量(Semaphore)限制并发数量。信号量持有一定数量的许可证,任务可以请求这些许可证以进入关键部分。信号量适用于实现任何类型的限制或边界控制。

4.2 消息传递通道

一次性通道oneshot

这是一次性通道,用于在异步任务之间发送单个消息。oneshot::channel() 函数用于创建形成通道的 SenderReceiver 句柄对。

Sender 句柄由生产者使用来发送值。消费者使用 Receiver 句柄来接收值。每个句柄都可以用于单独的任务。

由于 send 方法不是异步的,因此它可以在任何地方使用。这包括在两个运行时之间发送,以及从非异步代码中使用它。

下面展示如何创建一个句柄对:

1
2
3
4
// 创建一个发送端tx和接收端rx的句柄对,在发送数据时,会自动推断出通道中的数据类型
let (tx, rx) = oneshot::channel();
// 使用turbofish语法创建一个可发送i32数据的通道
let (tx, rx) = oneshot::channel::<i32>();

创建通道之后,下面来看看如何发送消息。

发送者Sender

前面创建的txSender结构,其提供了发送消息的方法send()

1
pub fn send(self, t: T) -> Result<(), T>

此方法消耗 self ,因为在一次性通道上只能发送一个值。它没有被标记为异步,因为将消息发送到一次性通道不需要任何形式的等待。因此, 该方法可以毫无问题地用在同步和异步代码中。

发送不一定总是成功,如果在发送之前rx已经关闭,则返回Err

因此,发送数据的时候,通常会做如下检测:

1
2
3
if let Err(_) = tx.send(3) {
println!("the receiver dropped");
}

下面是一个发送消息的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use tokio::sync::oneshot;

#[tokio::main]
async fn main() {
let (tx, rx) = oneshot::channel();

tokio::spawn(async move {
if let Err(_) = tx.send(3) {
println!("the receiver dropped");
}
});

match rx.await {
Ok(v) => println!("got = {:?}", v),
Err(_) => println!("the sender dropped"),
}
}

可以通过is_closed方法判断对端是否已经被drop,如果被删除则返回 true ,此时调用 send 将始终导致错误。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
use tokio::sync::oneshot;

#[tokio::main]
async fn main() {
// 创建tx和rx
let (tx, rx) = oneshot::channel();
assert!(!tx.is_closed());
// drop掉接收端
drop(rx);
// 此时发送消息始终为Err
assert!(tx.is_closed());
assert!(tx.send("never received").is_err());
}

发送端可以通过closed方法来等待接收端关闭,基本用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
use tokio::sync::oneshot;

#[tokio::main]
async fn main() {
let (mut tx, rx) = oneshot::channel::<()>();

tokio::spawn(async move {
drop(rx);
});

tx.closed().await;
println!("the receiver dropped");
}

当与 select! 配合使用时,此函数非常有用,可以在接收者不再对结果感兴趣时中止计算。其中一个分支计算要发送的数据,另一个分支为closed()等待分支,如果先计算完成,则发送计算结果,而如果是先等到了对端closed()的异步任务完成,则无需再计算浪费CPU去计算结果。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use tokio::sync::oneshot;
use tokio::time::{self, Duration};

async fn compute() -> String {
// Complex computation returning a `String`
}

#[tokio::main]
async fn main() {
let (mut tx, rx) = oneshot::channel();

tokio::spawn(async move {
tokio::select! {
_ = tx.closed() => {
// 先等待到了对端关闭,不做任何事,select!会自动取消其它分支的任务
}
value = compute() => {
// 先计算得到结果,则发送给对端
// 但有可能刚计算完成,尚未发送时,对端刚好关闭,因此可能发送失败
// 此处用 `_` 表示丢弃发送失败的错误
let _ = tx.send(value);
}
}
});

// 等待10s
let _ = time::timeout(Duration::from_secs(10), rx).await;
}
接收者Receiver

Receiver结构用于从关联的 Sender 接收值,该通道没有 recv 方法,因为接收者本身实现了 Future 特征。直接接收 Result<T, error::RecvError >.await Receiver 对象,因此直接通过.await即可接收数据,但是,接收数据并不一定会接收成功,比如再发送方还没有发送之前,该通道就已经关闭,接收方将失败并显示 error::RecvError

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use tokio::sync::oneshot;

#[tokio::main]
async fn main() {
let (tx, rx) = oneshot::channel::<u32>();

tokio::spawn(async move {
drop(tx);
});

match rx.await {
Ok(_) => panic!("This doesn't happen"),
Err(_) => println!("the sender dropped"),
}
}

Receiver可以通过close方法关闭自己这一端,当然也可以直接drop,关闭操作是幂等的,即,如果关闭的是已经关闭的Receiver,不会产生任何影响。

调用 close 之后发生的任何 send 操作都肯定会失败。但需要注意,有可能在关闭操作完成之前,对端正好发送了一个数据,此时则应调用 try_recv 来接收值:

1
2
3
4
5
6
7
8
9
10
11
12
13
use tokio::sync::oneshot;

#[tokio::main]
async fn main() {
let (tx, mut rx) = oneshot::channel();

assert!(tx.send("will receive").is_ok());

rx.close();

let msg = rx.try_recv().unwrap();
assert_eq!(msg, "will receive");
}

try_recv()方法返回三种可能值:

  • Ok(T): 表示成功接收到通道中的数据
  • Err(TryRecvError::Empty): 表示通道为空,尚未发送任何值
  • Err(TryRecvError::Closed): 表示发送者在未发送值的情况下丢弃,或者消息已被接收

一个常用的场景为在 tokio::select! 循环中使用 Receiver ,需要在通道前面添加 &mut

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use tokio::sync::oneshot;
use tokio::time::{interval, sleep, Duration};

#[tokio::main]
async fn main() {
let (send, mut recv) = oneshot::channel();
let mut interval = interval(Duration::from_millis(100));

tokio::spawn(async move {
sleep(Duration::from_secs(1)).await;
send.send("shut down").unwrap();
});

loop {
// select!中无需await,因为select!会自动轮询推进每一个分支的任务进度
tokio::select! {
_ = interval.tick() => println!("Another 100ms"),
msg = &mut recv => {
println!("Got message: {}", msg.unwrap());
break;
}
}
}
}

mpsc通道

mpsc 通道支持将多个值从多个生产者发送到单个消费者。此通道通常用于将工作发送到任务或接收许多计算的结果。

如果想从单个生产者向单个消费者发送多条消息,这也是你应该使用的通道。没有专用的spsc频道。

此类通道可以发送多条消息,根据通道容量的不同,可以分为有界通道和无界通道,前者可以存储的消息数量有限制,如果达到此限制,尝试发送另一条消息将等到从通道接收到消息为止;后者具有无限容量,因此 send 方法将始终立即完成,这使得 UnboundedSender 可以在同步和异步代码中使用。

std 提供的 mpsc 通道类似,通道构造函数提供单独的发送和接收句柄, SenderReceiver 用于有界通道, UnboundedSenderUnboundedReceiver 表示无界通道。如果没有消息可读取,则发送新值时将通知当前任务。 SenderUnboundedSender 允许将值发送到通道中。如果有界通道已满,则发送将被拒绝,并且当有额外容量可用时,任务将收到通知。换句话说,通道提供背压。(关于背压,见同步通道

有界通道

通过mpsc::channel()创建有界通道,需传递一个大于1的usize值作为其参数。

例如,创建一个容量为100的通道:

1
2
3
4
use tokio::sync::mpsc;

// 接收端接收数据时需修改状态,因此声明为mut
let (tx, mut rx) = mpsc::channel(100);

该通道将缓冲最多100条数量的消息。一旦缓冲区已满,尝试发送新消息将等待,直到从通道接收到消息。

Sender 上发送的所有数据都将按照发送时的顺序在 Receiver 上可用。Sender 可以从多个代码位置克隆到 send 到同一通道。但是仅支持一个接收端 Receiver

如果在尝试 sendReceiver 断开连接,则 send 方法将返回 SendError 。同样,如果 Sender 在尝试 recv 时断开连接,则 recv 方法将返回 None

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel(100);

tokio::spawn(async move {
for i in 0..10 {
if let Err(_) = tx.send(i).await {
println!("receiver dropped");
return;
}
}
});

while let Some(i) = rx.recv().await {
println!("got = {}", i);
}
}

上面的示例中,先生成了一个异步任务,该异步任务向通道中发送10个数据, Receiver 端则在循环中不断从通道中取数据。

也可以稍微修改一下,创建10个异步任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel(100);
for i in 0..10 {
let tx1 = tx.clone();
tokio::spawn(async move {
if let Err(_) = tx1.send(i).await {
println!("receiver dropped");
return;
}
});
}
// 这里要drop掉tx,否则rx会一直阻塞等待
drop(tx);
while let Some(i) = rx.recv().await {
println!("got = {}", i);
}
}

10个异步任务发送消息的顺序是未知的,因此接收到的消息无法保证顺序。另外注意上面示例中的drop(tx),因为生成的10个异步任务中都拥有clone后的Senderclone出的tx1在每个异步任务完成时自动被drop,但原始任务中还有一个Sender,如果不关闭这个Senderrx.recv()将不会返回None,而是一直等待。

下面看一个超过通道容量的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
use chrono::Local;
use tokio::{
self,
runtime::Runtime,
sync,
time::{self, Duration},
};

fn now() -> String {
Local::now().format("%F %T").to_string()
}

fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let (tx, mut rx) = sync::mpsc::channel::<i32>(5);

tokio::spawn(async move {
for i in 1..=7 {
if tx.send(i).await.is_err() {
println!("receiver closed");
return;
}
println!("sended: {}, {}", i, now());
}
});

time::sleep(Duration::from_secs(1)).await;
while let Some(i) = rx.recv().await {
println!("received: {}, {}", i, now());
time::sleep(Duration::from_secs(1)).await;
}
});
}

如果通道已满,Sender通过send()发送消息时将等待。上面示例中,通道容量为5,但要发送7个数据,前5个数据会立即发送,发送第6个消息的时候将等待,直到1秒后Receiver开始从通道中每隔一秒消费一条数据。

下面简单介绍sync::mpsc::Sender提供的方法

  • capacity(): 获取当前通道的剩余容量(注意,不是初始化容量)
  • closed(): 等待Receiver端关闭,当Receiver端关闭后该等待任务会立即完成
  • is_closed(): 判断Receiver端是否已经关闭
  • send(): 向通道中发送消息,通道已满时会等待通道中的空闲位置,如果对端已关闭,则返回错误
  • send_timeout(): 向通道中发送消息,通道已满时只等待指定的时长
  • same_channel(): 如果此Sender与另外一个Sender是否属于同一通道,返回true,否则返回false
  • try_send(): 向通道中发送消息,但不等待,如果发送不成功,则返回错误
  • reserve(): 等待并申请一个通道中的空闲位置,返回一个Permit,申请的空闲位置被占位,且该位置只留给该Permit实例,之后该Permit可以直接向通道中发送消息,并释放其占位的位置。申请成功时,通道空闲容量减1,释放位置时,通道容量会加1
  • try_reserve(): 尝试申请一个空闲位置且不等待,如果无法申请,则返回错误
  • reserve_owned(): 与reserve()类似,它返回拥有所有权的OwnedPermit,但会按值所有权移动Sender
  • try_reserve_owned(): reserve_owned()的不等待版本,尝试申请空闲位置失败时会立即返回错误
  • blocking_send(): Sender可以在同步代码环境中使用该方法向异步环境发送消息

sync::mpsc::Receiver提供的方法

  • close(): 关闭Receiver端,为了保证没有消息被丢弃,在调用 close() 后,必须一直调用 recv() 直到返回 None
  • recv(): 接收消息,如果通道缓冲区中没有消息,但通道尚未关闭,则此方法将等待,直到发送消息或通道关闭,如果对端已全部关闭,则返回None
  • try_recv(): 尝试接收消息,不等待,如果无法接收消息(即通道为空或对端已关闭),则返回错误
  • blocking_recv(): Receiver可以在同步代码环境中使用该方法接收来自异步环境的消息
  • poll_recv(): 轮询以接收此频道中的下一条消息

注意,在这些方法中,try_xxx()方法都是立即返回不等待的(可以认为是同步代码),因此调用它们后无需await,只有调用那些可能需要等待的方法,调用后才需要await。例如rx.recv().awaitrx.try_recv()

Sender端可通过send_timeout()来设置一个等待通道空闲位置的超时时间,它和send()返回值一样,此外还添加一种超时错误:超时后仍然没有发送成功时将返回错误。至于返回的是什么错误,对于发送端来说不重要,重要的是发送的消息是否成功。因此,对于Sender端的条件判断,通常也仅仅只是检测is_err()

1
2
3
if tx.send_timeout(33, Duration::from_secs(1)).await.is_err() {
println!("receiver closed or timeout");
}

需要特别注意的是,Receiver端调用close()方法关闭通道后,只是半关闭状态,Receiver端仍然可以继续读取可能已经缓冲在通道中的消息,close()只能保证Sender端无法再发送普通的消息,但PermitOwnedPermit仍然可以向通道发送消息。只有通道已空且所有Sender端(包括PermitOwnedPermit)都已经关闭的情况下,recv()才会返回None,此时代表通道完全关闭。

与一次性通道类似,try_recv()在无法立即接收消息时会立即返回错误。返回的错误分为两种:

  • Err(TryRecvError::Empty): 表示通道为空,尚未发送任何值,但Sender端(包括PermitOwnedPermit)尚未全部关闭
  • Err(TryRecvError::Disconnected): 表示通道已空,且所有Sender端(包括PermitOwnedPermit)全部已经关闭

关于reserve()reserve_owned(),通过官方示例即可轻松理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
// 创建容量为1的通道
let (tx, mut rx) = mpsc::channel(1);
// 申请并占有唯一的空闲位置
let permit = tx.reserve().await.unwrap();
// 唯一的位置已被permit占有,tx.send()无法发送消息
assert!(tx.try_send(123).is_err());
// Permit可以通过send()方法向它占有的那个位置发送消息
permit.send(456);
// Receiver端接收到消息
assert_eq!(rx.recv().await.unwrap(), 456);


// 创建容量为1的通道
let (tx, mut rx) = mpsc::channel(1);
// tx.reserve_owned()会消费掉tx
let permit = tx.reserve_owned().await.unwrap();
// 通过permit.send()发送消息,它又返回一个Sender
let tx = permit.send(456);
assert_eq!(rx.recv().await.unwrap(), 456);
// 可以继续使用返回的Sender发送消息
tx.send(789).await.unwrap();
}
无界通道

mpsc::unbounded_channel提供无界通道,用于在异步任务之间进行通信,它无需背压,因为它可以缓存无限数量的消息,直到内存耗尽为止。

1
2
3
use tokio::sync;

let (tx, mut rx) = sync::mpsc::unbounded_channel();

由于容量无限,因此Sender端可以无需等待(无需await)地不断向通道中发送消息,这也意味着无界通道的Sender既可以在同步环境中也可以在异步环境中向通道中发送消息。只有当Receiver端已经关闭,Sender端的发送才会返回错误。

使用无界通道要关心的问题是如何避免通道积压的数据过多导致内存耗尽,比如添加监控机制,或者保证消费端的速度基本大于接收端的速度,在出现内存告警时,通过某些算法手段进行限速等等。

broadcast通道

这是一种广播通道,本质上是多生产者、多消费者广播队列,但所有消费者都可以看到每个发送的值。

使用mpsc::broadcast()创建广播通道,需要指定一个通道容量作为参数。返回发送端tx和接收端rx

1
2
3
use tokio::sync::broadcast;

let (tx, mut rx1) = broadcast::channel(16);

Sender可以克隆得到多个Sender,可以调用Sendersubscribe()方法来创建新的Receiver

当发送值时,所有 Receiver 都会收到通知并接收该值。该值在通道内存储一次,并根据每个接收器的需要进行克隆。一旦所有接收者都收到了该值的克隆,该值就会从通道中释放。

基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use tokio::sync::broadcast;

#[tokio::main]
async fn main() {
let (tx, mut rx1) = broadcast::channel(16);
let mut rx2 = tx.subscribe();

tokio::spawn(async move {
assert_eq!(rx1.recv().await.unwrap(), 10);
assert_eq!(rx1.recv().await.unwrap(), 20);
});

tokio::spawn(async move {
assert_eq!(rx2.recv().await.unwrap(), 10);
assert_eq!(rx2.recv().await.unwrap(), 20);
});

tx.send(10).unwrap();
tx.send(20).unwrap();
}

由于发送的消息必须保留到所有 Receiver 收到克隆为止,因此广播通道容易受到“接收缓慢”问题的影响。在这种情况下,除了一个接收器之外的所有接收器都能够按照发送的速率接收值。但由于一个接收器停止运行,值迟迟无法释放,通道逐渐被填满。

广播通道通过对通道在任何给定时间可以保留的值的数量设置硬上限来处理这种情况。该上限就是容量,作为参数传递给 channel 函数,可以把通道看做一个队列。

如果在通道填满时发送值,并不会阻塞(因此使用send无需await),而是则释放通道当前保存的最旧值(队列头的值)。释放的空间用于插入新值(在队列尾)。任何尚未看到释放值的 Receiver将在下次调用 recv 时返回 RecvError::Lagged 错误。返回 RecvError::Lagged 后,发生滞后 Receiver的位置将更新为当前通道包含的最旧值(队列头部),下一次调用 recv 将返回该值。

此行为使Receiver能够检测到它何时落后以至于数据已被丢弃。调用者可以决定如何对此做出响应:通过中止其任务或通过容忍丢失的消息并恢复使用通道。

下面是处理滞后的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use tokio::sync::broadcast;

#[tokio::main]
async fn main() {
// 通道容量2
let (tx, mut rx) = broadcast::channel(2);

// 写入3个数据,将出现接收端落后于发送端的情况,
// 此时,第一个数据(10)将被剔除,剔除后,20将位于队列的头部
tx.send(10).unwrap();
tx.send(20).unwrap();
tx.send(30).unwrap();

// 落后于发送端之后的第一次recv()操作,返回RecvError::Lagged错误
assert!(rx.recv().await.is_err());

// 之后可正常获取通道中的数据
assert_eq!(20, rx.recv().await.unwrap());
assert_eq!(30, rx.recv().await.unwrap());
}

Receiver也可以使用try_recv()方法尝试在不等待的情况下返回此接收器上的待处理值,如果Sender都已关闭,则返回TryRecvError::Closed错误,如果接收端已落后,则返回TryRecvError::Lagged错误,如果通道为空,则返回TryRecvError::Empty错误。

可能会出现这样一种现象:ReceiverA已经接收了通道中的第10个消息,但另一个ReceiverB可能尚未接收第一个消息,由于第一个消息还未被全部接收者所克隆,它仍会保留在通道中并占用通道的位置,假如该通道的最大容量为10,此时Sender再发送一个消息,那么第一个数据将被释放,ReceiverB接收到消息的时候将收到RecvError::Lagged错误并永远地错过第一个消息。

watch通道

该通道是只保留最后发送的值的单生产者、多消费者通道。

通道内最多只有一个值,每次Sender发送新值时,都会覆盖旧值。此通道对于监视代码库中多个点的值更改非常有用,例如配置值的更改。

1
2
3
4
5
6
7
8
9
10
11
use tokio::sync::watch;

let (tx, mut rx) = watch::channel("hello");

tokio::spawn(async move {
while rx.changed().await.is_ok() {
println!("received = {:?}", *rx.borrow());
}
});

tx.send("world")?;

Sender端可通过subscribe()创建新的Receiver

当所有Receiver均已关闭时,send()方法将返回错误。因此,send()必须要在有Receiver存活的情况下才能发送数据。但是Sender端还有一个send_replace()方法,它可以在没有Receiver的情况下将数据写入通道,并且该方法会返回通道中原来保存的值。

无论是Sender还是Receiver,都可以通过borrow()方法取得通道中当前的值。由于可以有多个Receiver,为了避免读写时的数据不一致,watch通道内部使用了读写锁:Sender端要发送数据修改通道中的数据时,需要申请写锁,无论是Sender还是Receiver,在调用borrow()或其它一些方式访问通道数据时,都需要申请读锁。因此,访问通道数据时要尽快释放读锁,否则可能会长时间阻塞Sender端的发送操作。

如果Sender未发送数据,或者隔较长时间才发送一次数据,那么通道中的数据在一段时间内将一直保持不变。如果Receiver在这段时间内去多次读取通道,得到的结果将完全相同。但有时候,可能更需要的是等待通道中的数据已经发生变化,然后再根据新的数据做进一步操作,而不是循环不断地去读取并判断当前读取到的值是否和之前读取的旧值相同。

watch通道已经提供了这种功能:Receiver可以标记通道中的数据,记录该数据是否已经被读取过。Receiverchanged()方法用于等待通道中的数据发生变化,其内部判断过程是:如果通道中的数据已经被标记为已读取过,那么changed()将等待数据更新,如果数据未标记过已读取,那么changed()认为当前数据就是新数据,changed()会立即返回。

Receiverborrow()方法不会标记数据已经读取,所以borrow()之后调用的changed()会立即返回。但是changed()等待到新值之后,会立即将该值标记为已读取,使得下次调用changed()时会进行等待。

Receiver还有一个borrow_and_update()方法,它会读取数据并标记数据已经被读取,因此随后调用chagned()将进入等待。

最后需要注意,无论是Sender还是Receiver端,访问数据的时候都会申请读锁,要尽量快地释放读锁,以免Sender长时间无法发送数据。

4.3 状态同步

Barrier屏障

屏障使多个任务能够保持进度同步。比如,同一个任务被多个异步并发执行,但每个异步任务都需要保证其它所有任务都必须完成到进度A,才可以进行下一步。 此时可以在A位置使用屏障,这样可以保证所有任务在开始第二步之前的进度是同步的。

当然,也不一定要等待所有任务的进度都同步,可以设置等待一部分任务的进度同步。也就是说,让并发任务的进度按批次进行同步。第一批的任务进度都同步后,这一批任务将通过屏障,但是该屏障依然会阻挡下一批任务,直到下一批任务的进度都同步之后才放行。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
use std::sync::Arc;
use tokio::sync::Barrier;

#[tokio::main]
async fn main() {
let mut handles = Vec::with_capacity(10);

// 参数10表示屏障宽度为10,只等待10个任务达到屏障点就放行这一批任务
// 也就是说,某时刻已经有9个任务在等待,当第10个任务调用wait的时候,屏障将放行这一批
let barrier = Arc::new(Barrier::new(10));

for _ in 0..10 {
let c = barrier.clone();
handles.push(tokio::spawn(async move {
println!("before wait");

// 在此设置屏障,保证10个任务都已输出before wait才继续向下执行
let wait_result = c.wait().await;
println!("after wait");
wait_result
}));
}

let mut num_leaders = 0;
for handle in handles {
let wait_result = handle.await.unwrap();
if wait_result.is_leader() {
num_leaders += 1;
}
}

assert_eq!(num_leaders, 1);
}

Barrier调用wait()方法时,返回BarrierWaitResult,该结构有一个is_leader()方法,可以用来判断某个任务是否是该批次任务中的第一个任务。每一批通过屏障的任务都只有一个leader,其余非leader任务调用is_leader()都将返回false

使用屏障时,一定要保证可以到达屏障点的并发任务数量是屏障宽度的整数倍,否则多出来的任务将一直等待。例如,将屏障的宽度设置为10(即10个任务一批),但是有15个并发任务,多出来的5个任务无法凑成完整的一批,这5个任务将一直等待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use std::sync::Arc;
use tokio::sync::Barrier;
use tokio::{ self, runtime::Runtime, time::{self, Duration} };

fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let barrier = Arc::new(Barrier::new(10));

for i in 1..=15 {
let b = barrier.clone();
tokio::spawn(async move {
println!("data before: {}", i);

b.wait().await; // 15个任务中,多出5个任务将一直在此等待
time::sleep(Duration::from_millis(10)).await;
println!("data after: {}", i);
});
}
time::sleep(Duration::from_secs(5)).await;
});
}

在上面的例子中,可以通过屏障的任务只有10个,剩下的5个将永远阻塞,输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
data before: 1
data before: 2
data before: 3
data before: 5
data before: 7
data before: 8
data before: 14
data before: 11
data before: 12
data before: 13
data before: 4
data before: 6
data before: 9
data before: 10
data before: 15
data after: 3
data after: 7
data after: 12
data after: 13
data after: 8
data after: 14
data after: 11
data after: 2
data after: 1
data after: 5

Mutex互斥锁

在之前介绍过多线程版本的互斥锁Mutex,下面来看看tokio版本的互斥锁tokio::sync::Mutex

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::sync::Arc;
use tokio::{self, sync, runtime::Runtime, time::{self, Duration}};

fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let mutex = Arc::new(sync::Mutex::new(0));

for i in 0..10 {
let lock = Arc::clone(&mutex);
tokio::spawn(async move {
let mut data = lock.lock().await;
*data += 1;
println!("task: {}, data: {}", i, data);
});
}

time::sleep(Duration::from_secs(1)).await;
});
}

总体的使用方法类似,使用new()来创建互斥锁,使用lock()来申请锁,申请锁成功时将返回MutexGuard,并通过超出作用域drop的方式来释放锁。

1
2
3
4
5
6
7
8
9
10
task: 0, data: 1
task: 2, data: 2
task: 3, data: 3
task: 6, data: 4
task: 7, data: 5
task: 5, data: 6
task: 8, data: 7
task: 9, data: 8
task: 1, data: 9
task: 4, data: 10

可以看出任务的调度顺序是随机的,但是数据加1的操作是依次完成的。

在tokio中也可以使用标准库中的互斥锁,与普遍的看法相反,在异步代码中使用标准库中的普通 Mutex 是可以的,而且通常是首选。与标准库的互斥锁相比,异步互斥锁提供的功能是能够在 .await 点上保持锁定。这使得异步互斥锁比阻塞互斥锁更加耗费资源,因此在可以使用阻塞互斥锁的情况下应该优先考虑它。异步互斥锁的主要用例是提供对 IO 资源(例如数据库连接)的共享可变访问。如果互斥锁后面的值只是数据,通常适合使用阻塞互斥锁,例如标准库中的互斥锁或 parking_lot(比 Rust 标准中的实现更小、更快、更灵活的阻塞的互斥锁) 。

什么情况下可以选择使用tokio的Mutex?当跨await的时候,可以考虑使用,因为这时使用标准库的Mutex将编译错误。当然,也有相应的解决方案。

什么是跨await?每个await都代表一个异步任务,跨await即表示该异步任务中出现了至少一个子任务。而每个异步任务都可能会被tokio内部窃取到不同的线程上执行,因此跨await时要求其父任务实现Send特征,这是因为子任务中可能会引用父任务中的数据。

例如,下面定义的异步函数中使用了标准库的互斥锁,且有子任务,这会编译错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
use std::sync::{Arc, Mutex, MutexGuard};
use tokio::{self, runtime::Runtime, time::{self, Duration}};

async fn add_1(mutex: &Mutex<u64>) {
let mut lock = mutex.lock().unwrap();
*lock += 1;

// 子任务,跨await,且引用了父任务中的数据
time::sleep(Duration::from_millis(*lock)).await;
}

fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let mutex = Arc::new(Mutex::new(0));

for i in 0..10 {
let lock = mutex.clone();
tokio::spawn(async move {
add_1(&lock).await;
});
}

time::sleep(Duration::from_secs(1)).await;
});
}

由于标准库std::sync::MutexGuard没有实现Send,因此它并不能在线程间安全地发送,因此父任务async move{}语句块是非Send的,于是编译报错。当然,如果上面的示例中没有子任务sleep().await子任务,则没有问题,因为已经可以明确知道该Mutex所在的任务是在当前线程执行的。

在这种场景下,可以使用tokio提供的互斥锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::sync::Arc;
use tokio::{ self, runtime::Runtime, sync::{Mutex, MutexGuard}, time::{self, Duration} };

async fn add_1(mutex: &Mutex<u64>) {
let mut lock = mutex.lock().await;
*lock += 1;
time::sleep(Duration::from_millis(*lock)).await;
}

fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let mutex = Arc::new(Mutex::new(0));
for i in 0..10 {
let lock = mutex.clone();
tokio::spawn(async move {
add_1(&lock).await;
});
}

time::sleep(Duration::from_secs(1)).await;
});
}

前面提到tokio的互斥锁性能相对较差一些,因此可以不使用tokio锁的情况下,尽量不使用它。对于上面的需求,仍然可以继续使用标准库的Mutex,但需要做一些调整,也就是在子任务await之前,把所有未实现Send的数据都drop掉,保证子任务无法引用父任务中的任何非Send数据。

1
2
3
4
5
6
7
8
9
10
use std::sync::{Arc, Mutex, MutexGuard};

async fn add_1(mutex: &Mutex<u64>) {
{
let mut lock = mutex.lock().unwrap();
*lock += 1;
}
// 子任务,跨await,不引用父任务中的数据
time::sleep(Duration::from_millis(10)).await;
}

这种方案的主要思想是让子任务和父任务不要出现不安全的数据交叉。如果可以的话,应尽量隔离子任务和非Send数据所在的任务。上面的例子已经实现了这一点,但更好的方式是将子任务sleep().await从这个函数中移走。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use std::sync::{Arc, Mutex};
#[allow(unused_imports)]
use tokio::{ self, runtime::Runtime, sync, time::{self, Duration}};

async fn add_1(mutex: &Mutex<u64>) -> u64 {
let mut lock = mutex.lock().unwrap();
*lock += 1;
*lock
} // 申请的互斥锁在此被释放

fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let mutex = Arc::new(Mutex::new(0));

for i in 0..100 {
let lock = mutex.clone();
tokio::spawn(async move {
let n = add_1(&lock).await;
time::sleep(Duration::from_millis(n)).await;
});
}

time::sleep(Duration::from_secs(1)).await;
println!("data: {}", mutex.lock().unwrap());
});
}

另外注意,标准库的Mutex存在毒锁问题。所谓毒锁,即某个持有互斥锁的线程panic了,那么这个锁有可能永远得不到释放(除非线程panic之前已经释放),也称为被污染的锁。毒锁问题可能很严重,因为出现毒锁有可能意味着数据将从此开始不再准确,所以多数时候是直接让毒锁的panic向上传播或单独处理。但出现毒锁并不总是危险的,所以标准库也提供了对应的方案。

但tokio的互斥锁不存在毒锁问题,在持有tokio的Mutex的线程panic时,tokio的做法是直接释放锁。

RwLock读写锁

在之前介绍过多线程版本的读写锁 RwLock,下面介绍tokio版本的读写锁tokio::sync::RwLock

这种类型的锁允许在任何时间点有多个读取者或最多一个写入者。下面是官方文档中的一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use tokio::sync::RwLock;

#[tokio::main]
async fn main() {
let lock = RwLock::new(5);

// 多个读锁共存
{
// read()返回RwLockReadGuard
let r1 = lock.read().await;
let r2 = lock.read().await;
assert_eq!(*r1, 5); // 对Guard解引用,即可得到其内部的值
assert_eq!(*r2, 5);
} // 读锁(r1, r2)在此释放

// 只允许一个写锁存在
{
// write()返回RwLockWriteGuard
let mut w = lock.write().await;
*w += 1;
assert_eq!(*w, 6);
} // 写锁(w)被释放
}

读写锁有几种不同的设计方式:

  • 读优先:只要有读操作申请锁,优先将锁分配给读操作。这种方式可以提供非常好的并发能力,但是大量的读操作可能会长时间阻挡写操作(饿死写者)
  • 写优先:只要有写操作申请锁,优先将锁分配给写操作。这种方式可以保证写操作不会被饿死,但会严重影响并发能力

与标准库的读写锁区别在于,tokio的读写锁的优先级策略是公平的(或写优先),以确保读者不会饿死写者。等待锁的任务采用先进先出队列,保证公平性;如果希望获取写锁的任务位于队列的头部,则在释放写锁之前不会发出读锁。这与标准库的 std::sync::RwLock 形成对比,其中优先级策略取决于操作系统的实现。

具体规则如下:

  1. 每次申请锁时都将等待,申请锁的异步任务被切换,CPU交还给调度器
  2. 如果申请的是读锁,并且此时没有写锁存在,则申请成功,对应的任务被唤醒
  3. 如果申请的是读锁,但此时有写锁(包括写锁申请)的存在,那么将等待所有的写锁释放(因为写锁总是优先)
  4. 如果申请的是写锁,如果此时没有读锁的存在,则申请成功
  5. 如果申请的是写锁,但此时有读锁的存在,那么将等待当前正在持有的读锁释放

tokio的写优先会很容易产生死锁。例如,下面的代码会产生死锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use std::sync::Arc;
use tokio::{self, runtime::Runtime, sync::RwLock, time::{self, Duration}};

fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let lock = Arc::new(RwLock::new(0));

let lock1 = lock.clone();
tokio::spawn(async move {
let n = lock1.read().await;

time::sleep(Duration::from_secs(2)).await;
let nn = lock1.read().await;
});

time::sleep(Duration::from_secs(1)).await;
let mut wn = lock.write().await;
*wn = 2;
});
}

上面示例中,按照时间的流程,首先会在子任务中申请读锁,1秒后在当前任务中申请写锁,再1秒后子任务申请读锁。

申请第一把读锁时,因为此时无锁,所以读锁n申请成功。1秒后申请写锁时,由于此时读锁n尚未释放,因此写锁申请失败,将等待。再1秒之后,继续在子任务中申请读锁nn,但是此时有写锁申请存在,因此第二次申请读锁将等待,于是读锁写锁互相等待,死锁出现。

通过这个例子可以看出,当要使用写锁时,如果要避免死锁,一定要保证同一个任务中的任意两次锁申请之间,前面已经无锁,并且写锁尽早释放。

对于上面的示例,同一个子任务中申请两次读锁,但是第二次申请读锁时,第一把读锁仍未释放,这就产生了死锁的可能。只需在第二次申请读锁前,将第一把读锁释放即可。更保险一点,在写锁写完数据后也手动释放写锁(上面的示例中写完就退出,写锁会自动释放,因此无需手动释放)。

通过上述方式来避免死锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use std::sync::Arc;
use tokio::{self, runtime::Runtime, sync::RwLock, time::{self, Duration}};

fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let lock = Arc::new(RwLock::new(0));

let lock1 = lock.clone();
tokio::spawn(async move {
let n = lock1.read().await;
drop(n); // 在申请第二把读锁前,先释放第一把读锁

time::sleep(Duration::from_secs(2)).await;
let nn = lock1.read().await;
drop(nn);
});

time::sleep(Duration::from_secs(1)).await;
let mut wn = lock.write().await;
*wn = 2;
drop(wn);
});
}

tokio还提供了一系列读写锁的方法,见官方文档

Semaphore信号量

在之前介绍过多线程时,已经介绍过tokio的信号量 Semaphore了,这里做个回顾。

使用信号量时,需在初始化时指定数量,每当任务要执行时,从中取走一个信号量,当任务完成时会归还信号量。当某个任务要执行时,如果此时信号量数量为0,则该任务将等待,直到有信号量被归还。因此,信号量通常用来提供类似于限量的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use chrono::Local;
use std::sync::Arc;
use tokio::{ self, runtime::Runtime, sync::Semaphore, time::{self, Duration}};

fn now() -> String {
Local::now().format("%F %T").to_string()
}

fn main() {
let rt = Runtime::new().unwrap();
rt.block_on(async {
// 3个信号量
let semaphore = Arc::new(Semaphore::new(3));

// 5个并发任务,每个任务执行前都先获取信号
// 同一时刻最多只有3个任务进行并发
for i in 1..=5 {
let semaphore = semaphore.clone();
tokio::spawn(async move {
let _permit = semaphore.acquire().await.unwrap();
println!("{}, {}", i, now());
time::sleep(Duration::from_secs(1)).await;
});
}

time::sleep(Duration::from_secs(3)).await;
});
}

tokio::sync::Semaphore提供了以下的方法:

  • new(): 创建固定数量的信号量
  • close(): 关闭信号量,关闭信号量时,将唤醒所有等待信号量的等待者
  • is_closed(): 检查信号量是否已经被关闭
  • acquire(): 获取一个信号量许可,如果信号量已经被关闭,则返回错误AcquireError
  • acquire_many(): 获取指定数量的信号量许可,如果信号量数量不够则等待,如果信号量已经被关闭,则返回AcquireError
  • add_permits(): 向当前信号量中额外添加N个信号量
  • available_permits(): 当前信号量中剩余的信号量数量
  • try_acquire(): 不等待地尝试获取一个信号量,如果信号量已经关闭,则返回TryAcquireError::Closed,如果目前信号量数量为0,则返回TryAcquireError::NoPermits
  • try_acquire_many(): 不等待地尝试获取指定数量的信号量
  • acquire_owned(): 获取一个信号量并消费掉信号量
  • acquire_many_owned(): 获取指定数量的信号量并消费掉信号量
  • try_acquire_owned(): 不等待地尝试获取信号量并消费掉信号量
  • try_acquire_many_owned(): 不等待地尝试获取指定数量的信号量并消费掉信号量

获取到的信号量许可类型是SemaphorePermit,它有一个forget()方法,该方法可以将信号量不归还给信号量,因此信号量中的信号量将永久性地减少(当然,可使用add_permits()添加)。

更多示例和细节,见官方文档

Notify通知

它提供了一种简单的通知唤醒功能。先看官方示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use tokio::sync::Notify;
use std::sync::Arc;

#[tokio::main]
async fn main() {
let notify = Arc::new(Notify::new());
let notify2 = notify.clone();

let handle = tokio::spawn(async move {
notify2.notified().await;
println!("received notification");
});

println!("sending notification");
notify.notify_one();

// Wait for task to receive notification.
handle.await.unwrap();
}

Notify::new()创建Notify实例,Notify 可以被认为是从0个许可开始的信号量 。每当调用notified().await时,将判断此时是否有许可,如果有,则可直接执行,否则将进入等待。因此,初始化之后立即调用notified().await将会等待。

每当调用notify_one()时,将产生一个许可,多次调用也最多只有一个许可。因此,调用notify_one()之后再调用notified().await则无需等待。

如果同时有多个等待许可的等候者,释放一个许可,在其它环境中可能会产生惊群现象,即大量等候者被一次性同时唤醒去争抢一个资源,抢到的可以继续执行,而未抢到的等候者又重新被阻塞。好在,tokio的通知没有这种问题,tokio使用队列方式让等候者进行排队,先等待的总是先获取到许可,因此不会一次性唤醒所有等候者,而是只唤醒队列头部的那个等候者。

Notify还有一个notify_waiters()方法,它不会释放许可,但是它会一次性唤醒所有正在等待的等候者。严格来说,是让当前已经注册的等候者(即已经调用notified(),但是还未await)在下次等待的时候,可以直接通过,见下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use tokio::sync::Notify;
use std::sync::Arc;

#[tokio::main]
async fn main() {
let notify = Arc::new(Notify::new());
let notify2 = notify.clone();

// 注册两个等候者
let notified1 = notify.notified();
let notified2 = notify.notified();

let handle = tokio::spawn(async move {
println!("sending notifications");
notify2.notify_waiters();
});

// 两个等候者的await都会直接通过
notified1.await;
notified2.await;
println!("received notifications");
}

八、如何实现链表

在rust的世界中,实现链表并不容易,我们一步一步来。

首先创建一个新项目:

1
cargo new --lib liststudy

1 一个不太优秀的栈实现

栈可以使用单向链表来实现,我们首先来完成它。先创建一个crate:

1
2
// in lib.rs
pub mod first;

然后新建一个first.rs,后面的代码全部写在这里。

1.1 布局

链表实际上就是逻辑上相邻而物理上不相邻的线性数据结构,它的定义类似于下面的结构:

1
List a = Empty | Elem a (List a)

显然,它是一个递推关系式,大致读作“列表要么是空的,要么是一个元素后面跟着一个列表”。

为了简单起见,我们将避免使用泛型,基于这个定义的代码如下:

1
2
3
4
pub enum List {
Empty,
Elem(i32, List),
}

cargo build编译一下看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
error[E0072]: recursive type `List` has infinite size
--> src\first.rs:1:1
|
1 | pub enum List {
| ^^^^^^^^^^^^^
2 | Empty,
3 | Elem(i32, List),
| ---- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
3 | Elem(i32, Box<List>),
| ++++ +

For more information about this error, try `rustc --explain E0072`.
error: could not compile `lists` due to previous error

很好,它报错了。在前面box允许创建递归类型中介绍过,这是一种无限大小的类型,对于编译器而言,所有栈上的类型都必须在编译期有固定的长度。一个简单地解决方案是使用间接的方式打破循环,编译器也给出了提示:

1
2
3
4
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
3 | Elem(i32, Box<List>),
| ++++ +

我们按照这个提示,使用智能指针Box改写这个链表的定义:

1
2
3
4
pub enum List {
Empty,
Elem(i32, Box<List>),
}

再次编译,成功通过。

虽然语法没有问题,但这是非常愚蠢的链表定义,原因如下:

考虑一个拥有两个元素的 List:

1
2
3
4
[] = Stack
() = Heap

[Elem A, ptr] -> (Elem B, ptr) -> (Empty, *junk*)

有两个关键问题:

  • 最后一个节点分配在了堆上,但是它看上去根本不像一个节点
  • 我们的节点之一根本不是堆分配的,这些节点的分配并不统一

我们希望所有的节点都分配在堆上,但而且,我们发现最后一个节点根本不需要堆分配。

下面考虑另一种布局方式:

1
[ptr] -> (Elem A, ptr) -> (Elem B, *null*)

在这个布局中,我们无条件地堆分配我们的节点。关键区别在于我们的布局中没有垃圾(junk)。这是什么垃圾?要理解这一点,我们需要查看枚举在内存中的布局方式。

一般来说,如果我们有一个像这样的枚举:

1
2
3
4
5
6
enum Foo {
D1(T1),
D2(T2),
...
Dn(Tn),
}

Foo会占用多少内存空间呢?首先它需要一些整数来指示它代表的枚举的标签(D1,D2,...,Dn),此外,它还需要足够的空间来保证任意一个Di都可以被容纳,因此它占用空间是这些标签中所占内存最大的那一个,最后,可能还需要一些额外的空间来满足内存对齐的需求。

这里最大的问题是,即使 Empty 只占一比特信息,它仍然会为指针和元素消耗更大的空间(所有枚举中所占内存最大的那个),因此我们的第一种布局方式的最后一个节点是额外的元素,其中充满了垃圾,它比第二种布局更消耗空间。

与其让一个节点不进行内存分配,不如让它一直进行内存分配,这是因为它我们需要一个一致的节点布局。这种一致性对于 pushpop 节点来说可能没有什么影响,但是对于链表的拆分和合并有影响。

下面就来看一下这两种布局拆分链表时的表现:

1
2
3
4
5
6
7
8
layout 1:

[Elem A, ptr] -> (Elem B, ptr) -> (Elem C, ptr) -> (Empty *junk*)

split off C:

[Elem A, ptr] -> (Elem B, ptr) -> (Empty *junk*)
[Elem C, ptr] -> (Empty *junk*)
1
2
3
4
5
6
7
8
layout 2:

[ptr] -> (Elem A, ptr) -> (Elem B, ptr) -> (Elem C, *null*)

split off C:

[ptr] -> (Elem A, ptr) -> (Elem B, *null*)
[ptr] -> (Elem C, *null*)

可以看出,布局 2 的拆分仅涉及将 B 的指针复制到堆栈并将旧值清空。而在布局 1 中,涉及同样的操作,且需要将 C 节点从堆上拷贝到栈中,而布局 2 则无需此过程。而且从分割后的布局清晰度而言,2 也要优于 1。

很不幸地是我们之前实现的就是布局1的链表,下面改进我们的链表:

1
2
3
4
5
pub enum List {
Empty,
ElemThenEmpty(i32),
ElemThenNotEmpty(i32, Box<List>),
}

但是这样做看起来更复杂了,假设现在有一个完全无效的状态: ElemThenNotEmpty(0, Box(Empty)) ,它还仍然会出现分配元素内存不均匀的情况。

我们之前所实现的每个枚举项都必须存储一个标签来指定它所占的比特位代表枚举的哪个变体。但是,如果我们有一种特殊类型的枚举:

1
2
3
4
enum Foo {
A,
B(ContainsANonNullPtr),
}

在这种情况下,会启动空指针的优化,也就是说它会消除枚举成员 A 占用的额外空间,原因在于编译器可以直接将 A 优化成 0,而 B 则不行,因为它包含了非 null 指针。这样一来,编译器就无需给 A 打 tag 进行识别了,而是直接通过 0 就能识别出这是 A 成员,非 0 的自然就是 B 成员。

其实进行这种优化的其他枚举和类型有很多,这就是 Rust 完全未指定枚举布局的原因。 Rust 将为我们做一些更复杂的枚举布局优化,但空指针优化绝对是最重要的,它意味着 &&mutBoxRcArcVec 、 Rust 中的其他几个重要类型在放入 Option 时没有任何开销。

那么我们如何避免额外的垃圾,统一分配,并获得优秀的空指针优化呢?我们需要将拥有一个元素的想法与分配另一个链表分开。为此,我们必须像 C 语言一样思考:结构体。

枚举让我们声明一个可以包含多个值之一的类型,而结构体让我们声明一个同时包含多个值的类型。让我们将链表分为两种类型:链表和节点。

1
2
3
4
5
6
7
8
9
struct Node {
elem: i32,
next: List,
}

pub enum List {
Empty,
More(Box<Node>),
}

链表要么是空的,要么有一个元素,然后后面跟着另一个链表,我们通过Node结构体这种独立的类型来表示“有一个元素后面跟着另一个链表的情况”。下面来检查一下这个定义是否符合我们的需求:

  • 链表尾部永远不会分配额外的垃圾
  • List 枚举的形式可以享受 null 指针优化
  • 所有的元素都拥有统一的内存分配

非常好,这也证明了我们最开始的定义是有问题的。编译一下试试:

1
2
3
4
5
6
7
8
error[E0446]: private type `Node` in public interface
--> src\first.rs:8:10
|
1 | struct Node {
| ----------- `Node` declared as private
...
8 | More(Box<Node>),
| ^^^^^^^^^ can't leak private type

这里的错误在于, pub enum 会要求它的所有成员必须是 pub,但是由于 Node 没有声明为 pub,因此产生了冲突。

这里最简单的解决方法就是将 Node 结构体和它的所有字段都标记为 pub :

1
2
3
4
pub struct Node {
pub elem: i32,
pub next: List,
}

但通常在 Rust 中我们倾向于将实现细节保密。让我们将 List 制作为一个结构体,以便我们可以隐藏实现细节:

1
2
3
4
5
6
7
8
9
10
11
12
13
pub struct List {
head: Link,
}

enum Link {
Empty,
More(Box<Node>),
}

struct Node {
elem: i32,
next: Link,
}

因为 List 是一个具有单个字段的结构体,所以它的大小与该字段相同,这就是rust的零成本抽象。

至此,一个相对还不错的链表布局设计完成,下面尝试使用一下它。

1.2 实现方法

新建链表new

我们想要的第一个方法是构建链表的方法。也就是为List提供一个静态方法:

1
2
3
4
5
impl List {
pub fn new() -> Self {
List { head: Link::Empty }
}
}

向链表中push

接下来,让我们编写将一个值推入列表的操作,由于这个方法会改变原链表,因此这里需要使用&mut self

1
2
3
4
5
impl List {
pub fn push(&mut self, elem: i32) {
// TODO
}
}

首先,我们需要创建一个节点来存储我们的元素:

1
2
3
4
5
6
pub fn push(&mut self, elem: i32) {
let new_node = Node {
elem: elem,
next: ?????
};
}

我们这里采用的是头插法,next应该填什么呢?好吧,它应该是头节点:

1
2
3
4
5
6
pub fn push(&mut self, elem: i32) {
let new_node = Node {
elem: elem,
next: self.head,
};
}

但这样会出现问题:

1
2
3
4
5
error[E0507]: cannot move out of `self.head` which is behind a mutable reference
--> src\first.rs:23:19
|
23 | next: self.head,
| ^^^^^^^^^ move occurs because `self.head` has type `Link`, which does not implement the `Copy` trait

这是由于head并没有实现 Copy特征,我们试图将借用的值 self 中的 head 字段的所有权转移给 next,显然这是不被允许的,因为当我们结束借用并将其“归还”给其合法所有者时,这将使 self 仅部分初始化。

如果我们在最后把东西放回去可不可以呢?

1
2
3
4
5
6
7
8
pub fn push(&mut self, elem: i32) {
let new_node = Box::new(Node {
elem: elem,
next: self.head,
});

self.head = Link::More(new_node);
}

原则上,这是 Rust 实际上可以接受的,但是由于各种原因(主要是Exception safety),这种方法并不会被接受。

我们需要一个办法,让 Rust 不再阻挠我们,其中一个可行的办法是使用 clone

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
pub struct List {
head: Link,
}

#[derive(Clone)]
enum Link {
Empty,
More(Box<Node>),
}

#[derive(Clone)]
struct Node {
elem: i32,
next: Link,
}

impl List {
pub fn new() -> Self {
List { head: Link::Empty }
}

pub fn push(&mut self, elem: i32) {
let new_node = Node {
elem: elem,
next: self.head.clone(),
};
}
}

我们为LinkNode派生clone,然后不需要转移所有权,只需要将其克隆即可。但是,这样显然每次插入都需要有额外的拷贝内存的花销,性能上并不是一个好的方案。

Rust Hacker Indiana Jones 提供了一种方法,就是采用 mem::replace 策略。这个非常有用的函数让我们可以通过用另一个值替换它来从借用中窃取一个值。

1
2
3
4
5
6
7
8
9
use std::mem;
pub fn push(&mut self, elem: i32) {
let new_node = Box::new(Node {
elem: elem,
next: mem::replace(&mut self.head, Link::Empty),
});

self.head = Link::More(new_node);
}

std::mem::replace函数可以将一个字段的值替换为另一个字段,并返回替换之前的值。它的函数签名如下:

1
pub fn replace<T>(dest: &mut T, src: T) -> T // 注意这里返回的是具有所有权的T

src 移动到引用的 dest 中,返回之前的 dest 值。两个值都不会被丢弃。比如:

1
2
3
4
5
6
7
use std::mem;

let mut v: Vec<i32> = vec![1, 2];

let old_v = mem::replace(&mut v, vec![3, 4, 5]);
assert_eq!(vec![1, 2], old_v);
assert_eq!(vec![3, 4, 5], v);

在我们的例子中,我们先暂时将self.head替换为Link::Emptymem::replace的返回值仍然是原来的头节点。最后,我们再将其替换为新的链表头:self.head = Link::More(new_node)。这种看起来偷梁换柱的做法似乎是目前rust可以实现的最好方法了。

从链表中pop

push 一样, pop 会改变链表。与 push 不同,我们实际上想要返回pop出去的元素。但是 pop 还必须处理一个棘手的极端情况:如果链表此时为空怎么办?因此,为了表示这种情况,我们使用可信的 Option 类型:

1
2
3
pub fn pop(&mut self) -> Option<i32> {
// TODO
}

判断链表是否为空可以使用match匹配:

1
2
3
4
5
6
7
8
9
10
pub fn pop(&mut self) -> Option<i32> {
match self.head {
Link::Empty => {
// TODO
}
Link::More(node) => {
// TODO
}
};
}

这段代码还不可以直接编译,这是因为pop 必须返回一个值,但我们还没有这样做。返回 unimplemented!() 是不错的主意,以表明我们尚未完成该函数的实现。该宏可以明确地表示目前的代码还没有实现,一旦代码执行到 unimplemented!() 的位置,就会发生一个 panicpanics是一种发散函数,发散函数永远不会返回给调用者,因此它们可以用在需要任何类型值的地方。因此,可以使用 unimplemented!() 代替 Option<T> 类型的值。另外,match会发生所有权转移,因此这里还需要使用引用&self.head

1
2
3
4
5
6
7
8
9
10
11
pub fn pop(&mut self) -> Option<i32> {
match &self.head {
Link::Empty => {
// TODO
}
Link::More(node) => {
// TODO
}
};
unimplemented!()
}

cargo build编译一下,可以通过。

下面开始填写内部逻辑,当链表为空时,返回None即可;当链表不为空时,我们需要返回 Some(i32) ,并更改列表的头部。

1
2
3
4
5
6
7
8
9
10
11
12
13
pub fn pop(&mut self) -> Option<i32> {
let result;
match &self.head {
Link::Empty => {
result = None;
}
Link::More(node) => {
result = Some(node.elem);
self.head = node.next;
}
};
result
}

再次编译,报错了:

1
2
3
4
5
error[E0507]: cannot move out of `node.next` which is behind a shared reference
--> src\first.rs:38:29
|
38 | self.head = node.next;
| ^^^^^^^^^ move occurs because `node.next` has type `Link`, which does not implement the `Copy` trait

发生了类似的错误,我们试图转移node.next的所有权,但我们仅仅拥有它的引用,这是rust不允许的。看来,只能故技重施了,利用上一节的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
pub fn pop(&mut self) -> Option<i32> {
let result;
match mem::replace(&mut self.head, Link::Empty) {
Link::Empty => {
result = None;
}
Link::More(node) => {
result = Some(node.elem);
self.head = node.next;
}
};
result
}

我们将 self.head 的值窃取出来,然后再将 Link::Empty 填回到 self.head 中。此时用于 match 匹配的就是一个拥有所有权的值类型,而不是之前的引用类型。

实际上,我们这里并不需要定义result,而是利用表达式返回的特性:

1
2
3
4
5
6
7
8
9
pub fn pop(&mut self) -> Option<i32> {
match mem::replace(&mut self.head, Link::Empty) {
Link::Empty => None,
Link::More(node) => {
self.head = node.next;
Some(node.elem)
}
}
}

这更加简洁和常用,Link::Empty分支只有一个表达式,因此可以将大括号去掉。

1.3 单元测试

下面为实现的链表添加一个小的单元测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#[cfg(test)]
mod test {
use super::List;
#[test]
fn basics() {
let mut list = List::new();

// Check empty list behaves right
assert_eq!(list.pop(), None);

// Populate list
list.push(1);
list.push(2);
list.push(3);

// Check normal removal
assert_eq!(list.pop(), Some(3));
assert_eq!(list.pop(), Some(2));

// Push some more just to make sure nothing's corrupted
list.push(4);
list.push(5);

// Check normal removal
assert_eq!(list.pop(), Some(5));
assert_eq!(list.pop(), Some(4));

// Check exhaustion
assert_eq!(list.pop(), Some(1));
assert_eq!(list.pop(), None);
}
}

这里需要注意将测试代码与定义代码放在一起,并且使用use super::List进行导入。运行cargo test

1
2
3
4
5
6
7
8
9
10
running 1 test
test first::test::basics ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Doc-tests liststudy

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

通过测试。

1.4 手动清理

前面已经可以创建一个基于链表实现的栈, 并测试了推入和弹出元素的功能一切正常。

下面还有一个问题,我们需要担心清理我们的列表吗?事实上并不需要,如果一个类型实现了Droptrait,则该类型具有析构函数。rust为几乎所有类型都实现了Drop,对于自定义的结构体,只要结构体的所有字段都实现了 Drop,那结构体也会自动实现 Drop。rust会自动地帮我们处理好一切。

但是,有些时候由rust自动处理会很糟糕。我们实现的链表就是一个例子,考虑以下链表:

1
list -> A -> B -> C

List 被自动 drop 后,接着会去尝试 Drop A ,然后是 B,最后是 C。这是一个递归代码,而递归代码可以炸毁堆栈。事实上确实如此,我们再添加一个测试:

1
2
3
4
5
6
7
8
#[test]
fn long_list() {
let mut list = List::new();
for i in 0..100000 {
list.push(i);
}
// drop(list);
}

测试代码会试图创建一个很长的链表,当超出作用域被自动Drop时,会直接爆栈:

1
thread 'first::test::long_list' has overflowed its stack

你可能会想“这是尾递归,编译器会确保这样的代码不会破坏堆栈”,那就大错特错了。首先,rust目前似乎还没有支持尾递归优化,其次,就算支持尾递归,也是不可行的。让我们尝试模拟编译器在Drop时进行的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
impl Drop for List {
fn drop(&mut self) {
// NOTE: you can't actually explicitly call `drop` in real Rust code;
// we're pretending to be the compiler!
self.head.drop(); // tail recursive - good!
}
}

impl Drop for Link {
fn drop(&mut self) {
match *self {
Link::Empty => {} // Done!
Link::More(ref mut boxed_node) => {
boxed_node.drop(); // tail recursive - good!
}
}
}
}

impl Drop for Box<Node> {
fn drop(&mut self) {
self.ptr.drop(); // uh oh, not tail recursive!
deallocate(self.ptr);
}
}

impl Drop for Node {
fn drop(&mut self) {
self.next.drop();
}
}

可以看出,在 self.ptr.drop 后调用的 deallocate 会导致非尾递归的情况发生。

因此,这里需要手动为 List 实现 Drop

1
2
3
4
5
6
7
8
9
10
11
impl Drop for List {
fn drop(&mut self) {
let mut cur_link = mem::replace(&mut self.head, Link::Empty);
while let Link::More(mut boxed_node) = cur_link {
cur_link = mem::replace(&mut boxed_node.next, Link::Empty);
// boxed_node 在这里超出作用域并被 drop,
// 由于它的 `next` 字段拥有的 `Node` 被设置为 Link::Empty,
// 因此这里并不会有无边界的递归发生
}
}
}

这里手动为 List 编写一个迭代丢弃节点的析构函数。

再次运行测试,通过。

事实上,我们在这里做了提前优化。drop函数与 while let Some(_) = self.pop() { } 非常相似,为什么不使用这个方法呢?这是因为,pop返回 Option<i32> ,而我们的实现仅操作 Box<Node> 。因此,我们的实现仅围绕指向节点的指针移动,而基于弹出的实现将围绕我们存储在节点中的值移动。当链表中包含的值是其他较大的类型时,这种移动的开销可能会非常高昂,Box能够原地运行其内容的 drop实现,因此它不会遇到此问题。

1.5 最终代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
// in first.rs

use std::mem;
pub struct List {
head: Link,
}

enum Link {
Empty,
More(Box<Node>),
}

struct Node {
elem: i32,
next: Link,
}

impl List {
pub fn new() -> Self {
List { head: Link::Empty }
}

pub fn push(&mut self, elem: i32) {
let new_node = Box::new(Node {
elem: elem,
next: mem::replace(&mut self.head, Link::Empty),
});

self.head = Link::More(new_node);
}

pub fn pop(&mut self) -> Option<i32> {
match mem::replace(&mut self.head, Link::Empty) {
Link::Empty => None,
Link::More(node) => {
self.head = node.next;
Some(node.elem)
}
}
}
}
impl Drop for List {
fn drop(&mut self) {
let mut cur_link = mem::replace(&mut self.head, Link::Empty);
while let Link::More(mut boxed_node) = cur_link {
cur_link = mem::replace(&mut boxed_node.next, Link::Empty);
// boxed_node 在这里超出作用域并被 drop,
// 由于它的 `next` 字段拥有的 `Node` 被设置为 Link::Empty,
// 因此这里并不会有无边界的递归发生
}
}
}
#[cfg(test)]
mod test {
use super::List;
#[test]
fn basics() {
let mut list = List::new();

// Check empty list behaves right
assert_eq!(list.pop(), None);

// Populate list
list.push(1);
list.push(2);
list.push(3);

// Check normal removal
assert_eq!(list.pop(), Some(3));
assert_eq!(list.pop(), Some(2));

// Push some more just to make sure nothing's corrupted
list.push(4);
list.push(5);

// Check normal removal
assert_eq!(list.pop(), Some(5));
assert_eq!(list.pop(), Some(4));

// Check exhaustion
assert_eq!(list.pop(), Some(1));
assert_eq!(list.pop(), None);
}

#[test]
fn long_list() {
let mut list = List::new();
for i in 0..100000 {
list.push(i);
}
// drop(list);
}
}

2 还可以的栈实现

上一小节我们编写了一个最小可行的基于链表实现的栈,然而,有一些设计决策让它变得有点糟糕。让我们添加一个名为 second.rs 的新文件:

1
2
3
4
// in lib.rs

pub mod first;
pub mod second;

并将 first.rs 中的所有内容复制到其中。

2.1 使用Option

你可能已经注意到了,我们发明了一个非常糟糕的Option

1
2
3
4
enum Link {
Empty,
More(Box<Node>),
}

它其实跟 Option<Box<Node>> 非常类似,因此没必要重复造轮子,直接使用Option替代枚举。并且我们可以使用类型别名优化这种写法,不必在任何地方都编写 Option<Box<Node>>。首先,我们将简单通过将所有内容重命名为使用 SomeNone 来完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
use std::mem;

pub struct List {
head: Link,
}

// 使用类型别名
type Link = Option<Box<Node>>;

struct Node {
elem: i32,
next: Link,
}

impl List {
pub fn new() -> Self {
List { head: None }
}

pub fn push(&mut self, elem: i32) {
let new_node = Box::new(Node {
elem: elem,
next: mem::replace(&mut self.head, None),
});

self.head = Some(new_node);
}

pub fn pop(&mut self) -> Option<i32> {
match mem::replace(&mut self.head, None) {
None => None,
Some(node) => {
self.head = node.next;
Some(node.elem)
}
}
}
}

impl Drop for List {
fn drop(&mut self) {
let mut cur_link = mem::replace(&mut self.head, None);
while let Some(mut boxed_node) = cur_link {
cur_link = mem::replace(&mut boxed_node.next, None);
}
}
}

看上去稍微好了一些,但是 Option 的好处远不止这些。

首先, mem::replace(&mut option, None) 是一个极其常见的习惯用法,以至于 Option 实际上继续将其变成一个方法:take

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
pub struct List {
head: Link,
}

type Link = Option<Box<Node>>;

struct Node {
elem: i32,
next: Link,
}

impl List {
pub fn new() -> Self {
List { head: None }
}

pub fn push(&mut self, elem: i32) {
let new_node = Box::new(Node {
elem: elem,
next: self.head.take(),
});

self.head = Some(new_node);
}

pub fn pop(&mut self) -> Option<i32> {
match self.head.take() {
None => None,
Some(node) => {
self.head = node.next;
Some(node.elem)
}
}
}
}

impl Drop for List {
fn drop(&mut self) {
let mut cur_link = self.head.take();
while let Some(mut boxed_node) = cur_link {
cur_link = boxed_node.next.take();
}
}
}

其次, match option { None => None, Some(x) => Some(y) } 是一个极其常见的习惯用法,可以直接使用 map 方法代替,map 会对 Some(x) 中的值进行映射,最终返回一个新的 Some(y) 值。

1
2
3
4
5
6
pub fn pop(&mut self) -> Option<i32> {
self.head.take().map(|node| {
self.head = node.next;
node.elem
})
}

关于map的具体用法和函数签名可以参考这里

再次运行cargo test,可以通过,说明我们的改动确保链表依然可以正常运行。

2.2 使用泛型

目前链表只能支持i32类型,我们可以使用泛型来优化这一限制:

1
2
3
4
5
6
7
8
9
10
pub struct List<T> {
head: Link<T>,
}

type Link<T> = Option<Box<Node<T>>>;

struct Node<T> {
elem: T,
next: Link<T>,
}

这实际上非常简单,只需要将所有类型标注替换为T即可,但是仅仅这样还是不够:

1
2
3
4
5
6
7
8
9
10
11
12
13
> cargo test

error[E0107]: wrong number of type arguments: expected 1, found 0
--> src/second.rs:14:6
|
14 | impl List {
| ^^^^ expected 1 type argument

error[E0107]: wrong number of type arguments: expected 1, found 0
--> src/second.rs:36:15
|
36 | impl Drop for List {
| ^^^^ expected 1 type argument

问题很清楚:泛型参数也是类型定义的一部分,我们现在要实现的是List<T>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
pub struct List<T> {
head: Link<T>,
}

type Link<T> = Option<Box<Node<T>>>;

struct Node<T> {
elem: T,
next: Link<T>,
}

impl<T> List<T> {
pub fn new() -> Self {
List { head: None }
}

pub fn push(&mut self, elem: T) {
let new_node = Box::new(Node {
elem: elem,
next: self.head.take(),
});

self.head = Some(new_node);
}

pub fn pop(&mut self) -> Option<T> {
self.head.take().map(|node| {
self.head = node.next;
node.elem
})
}
}

impl<T> Drop for List<T> {
fn drop(&mut self) {
let mut cur_link = self.head.take();
while let Some(mut boxed_node) = cur_link {
cur_link = boxed_node.next.take();
}
}
}

很好,我们所有的代码现在对于任意T的类型都是完全通用的。并且,new甚至没有改变:

1
2
3
pub fn new() -> Self {
List { head: None }
}

这是由于Self决定了构造时永远返回自身所代表的类型,因此这里返回的就是 List<T>,很方便。

2.3 实现peek

下面尝试为链表实现peek方法,该方法返回对列表头部元素的引用(如果存在)。听起来很简单,让我们尝试一下:

1
2
3
4
5
pub fn peek(&self) -> Option<&T> {
self.head.map(|node| {
&node.elem
})
}

不出意外的话就要出意外了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
error[E0515]: cannot return reference to local data `node.elem`
--> src\second.rs:34:30
|
34 | self.head.map(|node| &node.elem)
| ^^^^^^^^^^ returns a reference to data owned by the current function

error[E0507]: cannot move out of `self.head` which is behind a shared reference
--> src\second.rs:34:9
|
34 | self.head.map(|node| &node.elem)
| ^^^^^^^^^ ---------------------- `self.head` moved due to this method call
| |
| help: consider calling `.as_ref()` or `.as_mut()` to borrow the type's contents
| move occurs because `self.head` has type `Option<Box<second::Node<T>>>`, which does not implement the `Copy` trait

问题很明显,我们不能将内部值的引用返回给函数外的调用者。一个比较好的解决办法就是让 map 作用在引用上,而不是直接作用在 self.head 上,为此我们可以使用 Optionas_ref 方法:

1
2
3
impl<T> Option<T> {
pub fn as_ref(&self) -> Option<&T>;
}

它获取&self返回Option<&T>,然后再调用 map 就会对引用进行处理了:

1
2
3
4
5
pub fn peek(&self) -> Option<&T> {
self.head.as_ref().map(|node| {
&node.elem
})
}

我们还可以使用 as_mut 制作此方法的可变版本:

1
2
3
4
5
pub fn peek_mut(&mut self) -> Option<&mut T> {
self.head.as_mut().map(|node| {
&mut node.elem
})
}

测试一下,编写一个新的单元测试函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#[test]
fn peek() {
let mut list = List::new();
assert_eq!(list.peek(), None);
assert_eq!(list.peek_mut(), None);
list.push(1); list.push(2); list.push(3);

assert_eq!(list.peek(), Some(&3));
assert_eq!(list.peek_mut(), Some(&mut 3));
list.peek_mut().map(|&mut value| {
value = 42
});

assert_eq!(list.peek(), Some(&42));
assert_eq!(list.pop(), Some(42));
}

运行cargo test,报错了:

1
2
3
4
5
6
7
100 |         list.peek_mut().map(|&mut value| {
| -----
| |
| first assignment to `value`
| help: consider making this binding mutable: `mut value`
101 | value = 42
| ^^^^^^^^^^ cannot assign twice to immutable variable

编译器提示 value 是不可变的,但我们非常清楚地写了 &mut value。这是什么原因导致的?

实际上 &mut value 是一个模式匹配,它用 &mut value 模式去匹配一个可变的引用,|&mut value| 表示“参数是可变引用,但请将其指向的值复制到 value 中。此时匹配出来的 value 显然是一个值,而不是可变引用,如果我们只使用 |value|value 的类型将是 &mut i32

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#[test]
fn peek() {
let mut list = List::new();
assert_eq!(list.peek(), None);
assert_eq!(list.peek_mut(), None);
list.push(1); list.push(2); list.push(3);

assert_eq!(list.peek(), Some(&3));
assert_eq!(list.peek_mut(), Some(&mut 3));

list.peek_mut().map(|value| {
*value = 42
});

assert_eq!(list.peek(), Some(&42));
assert_eq!(list.pop(), Some(42));
}

这次我们直接匹配出来可变引用 value,然后对其修改即可。

再次运行测试,全部通过。

2.4 迭代器

rust中,你可以通过为类型实现Iteratortrait来实现迭代器,它的定义之前已经见过了,再复习一下:

1
2
3
4
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}

Item是关联类型,被用作 next 方法的返回值类型。换句话说,Item 类型将是迭代器返回元素的类型。这样说可能并不准确,因为该类型被包含在Option中,使用Option<Self::Item>的原因是因为该接口合并了 has_nextget_next 概念。调用next时,有值时返回 Some(T),无值时返回 None,这使得 API 通常更符合人体工程学,并且使用和实现更安全,同时避免 has_nextget_next 之间的冗余检查和逻辑。

遗憾的是,rust目前还没有类似 yield 语句的东西,所以我们必须自己实现逻辑。另外,迭代器有三种:

  • iter返回的迭代器会获取集合元素的不可变引用,对应Itertarit
  • iter_mut返回的迭代器会获取集合元素的可变引用,对应IterMuttarit
  • into_iter返回的迭代器会获取集合元素的所有权,对应IntoItertarit

实现IntoIter

它是最好实现的,我们通过元组结构体的方式定义IntoIter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pub struct IntoIter<T>(List<T>);

impl<T> List<T> {
pub fn into_iter(self) -> IntoIter<T> {
IntoIter(self)
}
}

impl<T> Iterator for IntoIter<T> {
type Item = T;
fn next(&mut self) -> Option<Self::Item> {
// access fields of a tuple struct numerically
self.0.pop()
}
}

只需一遍又一遍地调用 pop 即可。编写一个测试:

1
2
3
4
5
6
7
8
9
10
11
#[test]
fn into_iter() {
let mut list = List::new();
list.push(1); list.push(2); list.push(3);

let mut iter = list.into_iter();
assert_eq!(iter.next(), Some(3));
assert_eq!(iter.next(), Some(2));
assert_eq!(iter.next(), Some(1));
assert_eq!(iter.next(), None);
}

OK,可以通过。

实现Iter

现在尝试实现Iter,这次会稍微复杂点,因为这涉及到引用和生命周期。

我们实现的基本逻辑是保存一个指向我们要生成下一个的当前节点的指针。因为该节点可能不存在(列表为空或者我们已经完成迭代),所以我们希望该引用是一个Option。当产生一个元素时,我们想要继续到当前节点的 next 节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pub struct Iter<T> {
next: Option<&Node<T>>,
}

impl<T> List<T> {
pub fn iter(&self) -> Iter<T> {
Iter { next: self.head.map(|node| &node) }
}
}

impl<T> Iterator for Iter<T> {
type Item = &T;

fn next(&mut self) -> Option<Self::Item> {
self.next.map(|node| {
self.next = node.next.map(|node| &node);
&node.elem
})
}
}

编译…失败了。

1
2
3
4
5
6
7
8
9
   |
72 | next: Option<&Node<T>>,
| ^ expected lifetime parameter

error[E0106]: missing lifetime specifier
--> src/second.rs:82:17
|
82 | type Item = &T;
| ^ expected lifetime parameter

我们按照提示来修改代码,加入生命周期注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pub struct Iter<'a, T> {
next: Option<&'a Node<T>>,
}

impl<'a, T> List<T> {
pub fn iter(&'a self) -> Iter<'a, T> {
Iter { next: self.head.map(|node| &'a node) }
}
}

impl<'a, T> Iterator for Iter<'a, T> {
type Item = &'a T;
fn next(&'a mut self) -> Option<Self::Item> {
self.next.map(|node| {
self.next = node.next.map(|node| &'a node);
&'a node.elem
})
}
}

显然是无法通过编译的,仔细回忆一下生命周期的标注方法,应该这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
pub struct Iter<'a, T> {
next: Option<&'a Node<T>>,
}

// 这里无需生命周期,因为 List 没有使用生命周期的关联项
impl<T> List<T> {
// 这里我们为 `iter` 声明一个生命周期 'a , 此时 `&self` 需要至少和 `Iter` 活得一样久
pub fn iter<'a>(&'a self) -> Iter<'a, T> {
Iter { next: self.head.map(|node| &node) }
}
}

// 这里声明生命周期是因为下面的关联类型 Item 需要
impl<'a, T> Iterator for Iter<'a, T> {
type Item = &'a T;

// 这里无需更改,因为上面已经处理了.
// Self 依然是这么棒
fn next(&mut self) -> Option<Self::Item> {
self.next.map(|node| {
self.next = node.next.map(|node| &node);
&node.elem
})
}
}

编译一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
error[E0308]: mismatched types
--> src/second.rs:77:22
|
77 | Iter { next: self.head.map(|node| &node) }
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected struct `second::Node`, found struct `std::boxed::Box`
|
= note: expected type `std::option::Option<&second::Node<T>>`
found type `std::option::Option<&std::boxed::Box<second::Node<T>>>`

error[E0308]: mismatched types
--> src/second.rs:85:25
|
85 | self.next = node.next.map(|node| &node);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected struct `second::Node`, found struct `std::boxed::Box`
|
= note: expected type `std::option::Option<&'a second::Node<T>>`
found type `std::option::Option<&std::boxed::Box<second::Node<T>>>`

生命周期的问题解决了,但是又引入了新的错误。原因在于,我们希望存储 &Node 但是获取的却是 &Box<Node>。好吧,这很简单,我们只需要在获取引用之前取消引用Box即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
impl<T> List<T> {
pub fn iter<'a>(&'a self) -> Iter<'a, T> {
Iter { next: self.head.map(|node| &*node) }
}
}

impl<'a, T> Iterator for Iter<'a, T> {
type Item = &'a T;
fn next(&mut self) -> Option<Self::Item> {
self.next.map(|node| {
self.next = node.next.map(|node| &*node);
&node.elem
})
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
   |
77 | Iter { next: self.head.map(|node| &*node) }
| ^^^^^^ returns a reference to data owned by the current function

error[E0507]: cannot move out of borrowed content
--> src/second.rs:77:22
|
77 | Iter { next: self.head.map(|node| &*node) }
| ^^^^^^^^^ cannot move out of borrowed content

error[E0515]: cannot return reference to local data `*node`
--> src/second.rs:85:46
|
85 | self.next = node.next.map(|node| &*node);
| ^^^^^^ returns a reference to data owned by the current function

error[E0507]: cannot move out of borrowed content
--> src/second.rs:85:25
|
85 | self.next = node.next.map(|node| &*node);
| ^^^^^^^^^ cannot move out of borrowed content

又怎么了?原因是这里我们忘记了 as_ref ,然后值的所有权被转移到了 map 中,结果我们在内部引用了一个局部值,造成一个悬垂引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pub struct Iter<'a, T> {
next: Option<&'a Node<T>>,
}

impl<T> List<T> {
pub fn iter<'a>(&'a self) -> Iter<'a, T> {
Iter { next: self.head.as_ref().map(|node| &*node) }
}
}

impl<'a, T> Iterator for Iter<'a, T> {
type Item = &'a T;

fn next(&mut self) -> Option<Self::Item> {
self.next.map(|node| {
self.next = node.next.as_ref().map(|node| &*node);
&node.elem
})
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  --> src/second.rs:77:22
|
77 | Iter { next: self.head.as_ref().map(|node| &*node) }
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected struct `second::Node`, found struct `std::boxed::Box`
|
= note: expected type `std::option::Option<&second::Node<T>>`
found type `std::option::Option<&std::boxed::Box<second::Node<T>>>`

error[E0308]: mismatched types
--> src/second.rs:85:25
|
85 | self.next = node.next.as_ref().map(|node| &*node);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected struct `second::Node`, found struct `std::boxed::Box`
|
= note: expected type `std::option::Option<&'a second::Node<T>>`
found type `std::option::Option<&std::boxed::Box<second::Node<T>>>`

还是不行,这次是为什么?错误的原因是,as_ref 增加了一层间接引用,需要被移除,这里使用另外一种方式来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pub struct Iter<'a, T> {
next: Option<&'a Node<T>>,
}

impl<T> List<T> {
pub fn iter<'a>(&'a self) -> Iter<'a, T> {
Iter { next: self.head.as_deref() }
}
}

impl<'a, T> Iterator for Iter<'a, T> {
type Item = &'a T;

fn next(&mut self) -> Option<Self::Item> {
self.next.map(|node| {
self.next = node.next.as_deref();
&node.elem
})
}
}

as_derefas_deref_mut 函数在 Rust 1.40 版本中正式稳定下来。在那之前,你只能在 stable 版本中使用 map(|node| &**node)map(|node| &mut**node) 的方式来替代。参考官方文档

但是 Deref 在这里并不能很好的完成自己的任务,原因是在闭包中使用 Option<&T> 而不是 &T 对于它来说有些过于复杂了,因此我们需要显式地去帮助它完成任务。好在根据我的经验来看,这种情况还是相当少见的。

事实上,还可以使用另一种方式来实现:

1
self.next = node.next.as_ref().map::<&Node<T>, _>(|node| &node);

这种类型暗示的方式可以使用的原因在于 map 是一个泛型函数:

1
pub fn map<U, F>(self, f: F) -> Option<U>

turbofish 形式的符号 ::<> 可以告诉编译器我们希望用哪个具体的类型来替代泛型类型,在这种情况里,::<&Node<T>, _> 意味着: 它应该返回一个 &Node<T>。这种方式可以让编译器知道它需要对 &node 应用 deref,这样我们就不用手动的添加 ** 来进行解引用。

总之,让我们编写一个测试看看效果:

1
2
3
4
5
6
7
8
9
10
#[test]
fn iter() {
let mut list = List::new();
list.push(1); list.push(2); list.push(3);

let mut iter = list.iter();
assert_eq!(iter.next(), Some(&3));
assert_eq!(iter.next(), Some(&2));
assert_eq!(iter.next(), Some(&1));
}
1
2
3
4
5
6
7
8
9
10
running 7 tests
test second::test::basics ... ok
test first::test::basics ... ok
test second::test::iter ... ok
test second::test::into_iter ... ok
test second::test::peek ... ok
test second::test::long_list ... ok
test first::test::long_list ... ok

test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s

没有问题。

最后,应该指出的是,我们实际上可以在这里应用生命周期消除(Lifetime Elision)规则。

1
2
3
4
5
impl<T> List<T> {
pub fn iter<'a>(&'a self) -> Iter<'a, T> {
Iter { next: self.head.as_deref() }
}
}

相当于:

1
2
3
4
5
impl<T> List<T> {
pub fn iter(&self) -> Iter<T> {
Iter { next: self.head.as_deref() }
}
}

实现IterMut

再来回顾下 Iter 的实现:

1
2
3
4
5
impl<'a, T> Iterator for Iter<'a, T> {
type Item = &'a T;

fn next(&mut self) -> Option<Self::Item> { /* stuff */ }
}

它可以脱糖为:

1
2
3
4
5
impl<'a, T> Iterator for Iter<'a, T> {
type Item = &'a T;

fn next<'b>(&'b mut self) -> Option<&'a T> { /* stuff */ }
}

可以看出 next 方法的输入和输出之间的生命周期并没有关联,这样我们就可以无条件的一遍又一遍地调用 next

1
2
3
4
5
6
7
let mut list = List::new();
list.push(1); list.push(2); list.push(3);

let mut iter = list.iter();
let x = iter.next().unwrap();
let y = iter.next().unwrap();
let z = iter.next().unwrap();

对于不可变借用而言,这种方式没有任何问题,因为不可变借用可以同时存在多个,但是可变引用不能共存,你可能会认为最终结果是使用安全代码编写IterMut变得非常困难,但是令人诧异的是,事实上,我们可以使用安全的代码来为很多数据结构实现 IterMut

我们首先使用Iter代码并将所有内容更改为可变的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pub struct IterMut<'a, T> {
next: Option<&'a mut Node<T>>,
}

impl<T> List<T> {
pub fn iter_mut(&mut self) -> IterMut<'_, T> {
IterMut { next: self.head.as_deref_mut() }
}
}

impl<'a, T> Iterator for IterMut<'a, T> {
type Item = &'a mut T;

fn next(&mut self) -> Option<Self::Item> {
self.next.map(|node| {
self.next = node.next.as_deref_mut();
&mut node.elem
})
}
}
1
2
3
4
5
6
7
8
9
10
11
12
error[E0507]: cannot move out of `self.next` which is behind a mutable reference
--> src\second.rs:104:9
|
104 | self.next.map(|node| {
| _________^^^^^^^^^_-
| | |
| | help: consider calling `.as_ref()` or `.as_mut()` to borrow the type's contents
| | move occurs because `self.next` has type `Option<&mut second::Node<T>>`, which does not implement the `Copy` trait
105 | | self.next = node.next.as_deref_mut();
106 | | &mut node.elem
107 | | })
| |__________- `self.next` moved due to this method call

错误原因在于,对于有些类型是不可以Copy的,对于之前的Iter来说,因为 & 是可以被copy,所以 Option<&> 也可以被copy。但尴尬的是,可变引用 &mut T 不可以(如果复制了&mut,则会将两个&mut复制到内存中的同一位置,这是不允许的,一般可变引用只能同时存在一个),因此我们需要使用 take 方法来处理这种情况:

1
2
3
4
5
6
fn next(&mut self) -> Option<Self::Item> {
self.next.take().map(|node| {
self.next = node.next.as_deref_mut();
&mut node.elem
})
}

可以通过编译,让我们测试一下。

1
2
3
4
5
6
7
8
9
10
#[test]
fn iter_mut() {
let mut list = List::new();
list.push(1); list.push(2); list.push(3);

let mut iter = list.iter_mut();
assert_eq!(iter.next(), Some(&mut 3));
assert_eq!(iter.next(), Some(&mut 2));
assert_eq!(iter.next(), Some(&mut 1));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
> cargo test

Running target/debug/lists-5c71138492ad4b4a

running 6 tests
test first::test::basics ... ok
test second::test::basics ... ok
test second::test::iter_mut ... ok
test second::test::into_iter ... ok
test second::test::iter ... ok
test second::test::peek ... ok

test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured

2.5 最终代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
// in second.rs

pub struct List<T> {
head: Link<T>,
}

type Link<T> = Option<Box<Node<T>>>;

struct Node<T> {
elem: T,
next: Link<T>,
}

impl<T> List<T> {
pub fn new() -> Self {
List { head: None }
}

pub fn push(&mut self, elem: T) {
let new_node = Box::new(Node {
elem: elem,
next: self.head.take(),
});

self.head = Some(new_node);
}

pub fn pop(&mut self) -> Option<T> {
self.head.take().map(|node| {
self.head = node.next;
node.elem
})
}

pub fn peek(&self) -> Option<&T> {
self.head.as_ref().map(|node| {
&node.elem
})
}

pub fn peek_mut(&mut self) -> Option<&mut T> {
self.head.as_mut().map(|node| {
&mut node.elem
})
}

pub fn into_iter(self) -> IntoIter<T> {
IntoIter(self)
}

pub fn iter(&self) -> Iter<'_, T> {
Iter { next: self.head.as_deref() }
}

pub fn iter_mut(&mut self) -> IterMut<'_, T> {
IterMut { next: self.head.as_deref_mut() }
}
}

impl<T> Drop for List<T> {
fn drop(&mut self) {
let mut cur_link = self.head.take();
while let Some(mut boxed_node) = cur_link {
cur_link = boxed_node.next.take();
}
}
}

pub struct IntoIter<T>(List<T>);

impl<T> Iterator for IntoIter<T> {
type Item = T;
fn next(&mut self) -> Option<Self::Item> {
// access fields of a tuple struct numerically
self.0.pop()
}
}

pub struct Iter<'a, T> {
next: Option<&'a Node<T>>,
}

impl<'a, T> Iterator for Iter<'a, T> {
type Item = &'a T;
fn next(&mut self) -> Option<Self::Item> {
self.next.map(|node| {
self.next = node.next.as_deref();
&node.elem
})
}
}

pub struct IterMut<'a, T> {
next: Option<&'a mut Node<T>>,
}

impl<'a, T> Iterator for IterMut<'a, T> {
type Item = &'a mut T;

fn next(&mut self) -> Option<Self::Item> {
self.next.take().map(|node| {
self.next = node.next.as_deref_mut();
&mut node.elem
})
}
}

#[cfg(test)]
mod test {
use super::List;

#[test]
fn basics() {
let mut list = List::new();

// Check empty list behaves right
assert_eq!(list.pop(), None);

// Populate list
list.push(1);
list.push(2);
list.push(3);

// Check normal removal
assert_eq!(list.pop(), Some(3));
assert_eq!(list.pop(), Some(2));

// Push some more just to make sure nothing's corrupted
list.push(4);
list.push(5);

// Check normal removal
assert_eq!(list.pop(), Some(5));
assert_eq!(list.pop(), Some(4));

// Check exhaustion
assert_eq!(list.pop(), Some(1));
assert_eq!(list.pop(), None);
}

#[test]
fn peek() {
let mut list = List::new();
assert_eq!(list.peek(), None);
assert_eq!(list.peek_mut(), None);
list.push(1); list.push(2); list.push(3);

assert_eq!(list.peek(), Some(&3));
assert_eq!(list.peek_mut(), Some(&mut 3));

list.peek_mut().map(|value| {
*value = 42
});

assert_eq!(list.peek(), Some(&42));
assert_eq!(list.pop(), Some(42));
}

#[test]
fn into_iter() {
let mut list = List::new();
list.push(1); list.push(2); list.push(3);

let mut iter = list.into_iter();
assert_eq!(iter.next(), Some(3));
assert_eq!(iter.next(), Some(2));
assert_eq!(iter.next(), Some(1));
assert_eq!(iter.next(), None);
}

#[test]
fn iter() {
let mut list = List::new();
list.push(1); list.push(2); list.push(3);

let mut iter = list.iter();
assert_eq!(iter.next(), Some(&3));
assert_eq!(iter.next(), Some(&2));
assert_eq!(iter.next(), Some(&1));
}

#[test]
fn iter_mut() {
let mut list = List::new();
list.push(1); list.push(2); list.push(3);

let mut iter = list.iter_mut();
assert_eq!(iter.next(), Some(&mut 3));
assert_eq!(iter.next(), Some(&mut 2));
assert_eq!(iter.next(), Some(&mut 1));
}
}

3 持久化单链表

迄今为止,我们已经掌握了如何实现一个可变的单向链表。但是之前的链表都是单所有权的,在实际使用中,共享所有权才是更实用的方式,下面一起来看看该如何实现一个不可变的、共享所有权的持久化链表。我们将在很大程度上熟悉RcArc,首先,我们添加一个名为 third.rs 的新文件:

1
2
3
4
5
// in lib.rs

pub mod first;
pub mod second;
pub mod third;

这次我们无需拷贝之前的代码,而是从零开始构建一个新的链表。

3.1 数据布局

持久化单链表最重要的是,基本上可以自由操作列表的尾部:

例如以下是一个不太常见的持久化链表布局:

1
2
3
list1 = A -> B -> C -> D
list2 = tail(list1) = B -> C -> D
list3 = push(list2, X) = X -> B -> C -> D

从内存的角度看起来像这样:

1
2
3
4
5
6
7
list1 -> A ---+
|
v
list2 ------> B -> C -> D
^
|
list3 -> X ---+

在这种布局下,B 的所有权是共享的,因此Box无法完成任务,如果使用 Box,那么谁来负责清理释放?如果drop list2,那 B 节点会被清理释放吗?

函数式语言(实际上几乎所有其他语言)都通过使用垃圾收集(GC)来解决这个问题。借助GC的魔力,只有当所有人都停止使用B时,B才会被释放。Rust 没有像这些语言那样的垃圾收集器,相反,Rust 今天所拥有的只是引用计数。引用计数可以被认为是一个非常简单的GC,也就是标准库提供的 Rc / Arc

于很多场景来说,引用计数的数据吞吐量要远小于垃圾回收,如果你设法构建循环引用,它就会完全崩溃。但就目前而言,我们永远不会遇到循环,因此暂时不用担心这个问题。另外,使用Rc意味着我们的数据将无法被改变,因为它不具备内部可变性。

那么我们的布局会是什么样子呢?之前我们有:

1
2
3
4
5
6
7
8
9
10
pub struct List<T> {
head: Link<T>,
}

type Link<T> = Option<Box<Node<T>>>;

struct Node<T> {
elem: T,
next: Link<T>,
}

Box替换为Rc,注意需要将 Rc<T> 引入作用域,因为它不在prelude中:

1
2
3
4
5
6
7
8
9
10
11
12
use std::rc::Rc;

pub struct List<T> {
head: Link<T>,
}

type Link<T> = Option<Rc<Node<T>>>;

struct Node<T> {
elem: T,
next: Link<T>,
}

3.2 基本操作

新建链表

对于构造函数,我们可以再次复制粘贴:

1
2
3
4
5
impl<T> List<T> {
pub fn new() -> Self {
List { head: None }
}
}

prepend和tail

之前的 pushpop 已无任何意义,因为新链表是不可变的,但我们可以使用功能相似的 prependtail 来返回新的链表。

首先是prepend,与可变链表的情况类似,我们想要创建一个新节点,将旧链表作为其 next 值。唯一新颖的是如何获得下一个值,因为我们不允许改变任何东西。

答案就是Clonetrait,Rc特别使用Clone作为增加引用计数的方式,因此我们只需要克隆旧链表的头部节点即可,并且甚至不需要先匹配出内部的 Rc<Node<T>>,然后克隆,由于 Option 也提供了相应的克隆API,只需要在Option上操作即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
impl<T> List<T> {
pub fn new() -> Self {
List { head: None }
}

pub fn prepend(&self, elem: T) -> List<T> {
List {
head: Some(Rc::new(Node {
elem: elem,
next: self.head.clone(),
})),
}
}
}

继续来实现 tail,该方法会将现有链表的首个元素移除,并返回剩余的链表:

1
2
3
pub fn tail(&self) -> List<T> {
List { head: self.head.as_ref().map(|node| node.next.clone()) }
}
1
2
3
4
5
6
7
8
error[E0308]: mismatched types
--> src\third.rs:28:22
|
28 | List { head: self.head.as_ref().map(|node| node.next.clone()) }
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected struct `Rc`, found enum `Option`
|
= note: expected enum `Option<Rc<_>>`
found enum `Option<Option<Rc<_>>>`

map 期望我们返回Y,但这里我们返回 Option<Y> 。值得庆幸的是,这是另一种常见的选项模式,我们可以使用 and_then 来返回一个选项:

1
2
3
4
5
pub fn tail(&self) -> List<T> {
List {
head: self.head.as_ref().and_then(|node| node.next.clone()),
}
}

在这里可以查看详细的and_then文档

实现head

现在我们有了 tail ,还应该提供 head ,它返回对第一个元素的引用。这只是可变列表中的 peek

1
2
3
pub fn head(&self) -> Option<&T> {
self.head.as_ref().map(|node| &node.elem)
}

好了,至此,新链表的基本操作都已经实现,最后让我们写几个测试用例看看它的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#[cfg(test)]
mod test {
use super::List;

#[test]
fn basics() {
let list = List::new();
assert_eq!(list.head(), None);

let list = list.prepend(1).prepend(2).prepend(3);
assert_eq!(list.head(), Some(&3));

let list = list.tail();
assert_eq!(list.head(), Some(&2));

let list = list.tail();
assert_eq!(list.head(), Some(&1));

let list = list.tail();
assert_eq!(list.head(), None);

// Make sure empty tail works
let list = list.tail();
assert_eq!(list.head(), None);

}
}
1
2
3
4
5
6
7
8
cargo test third
Finished test [unoptimized + debuginfo] target(s) in 0.00s
Running unittests src\lib.rs (target\debug\deps\liststudy-91fb811e99388d6d.exe)

running 1 test
test third::test::basics ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 8 filtered out; finished in 0.00s

Perfect!

迭代器

Iter也与我们的可变列表实现相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pub struct Iter<'a, T> {
next: Option<&'a Node<T>>,
}

impl<T> List<T> {
pub fn iter(&self) -> Iter<'_, T> {
Iter { next: self.head.as_deref() }
}
}

impl<'a, T> Iterator for Iter<'a, T> {
type Item = &'a T;

fn next(&mut self) -> Option<Self::Item> {
self.next.map(|node| {
self.next = node.next.as_deref();
&node.elem
})
}
}

请注意,我们无法为此类型实现IntoIterIterMut。我们只能共享对元素的访问,不能获取可变引用或所有权。

3.3 实现Drop

与可变链表一样,这里也有递归析构函数问题。

之前我们是这样解决的:

1
2
3
4
5
6
7
8
impl<T> Drop for List<T> {
fn drop(&mut self) {
let mut cur_link = self.head.take();
while let Some(mut boxed_node) = cur_link {
cur_link = boxed_node.next.take();
}
}
}

问题在于循环体:

1
cur_link = boxed_node.next.take();

这里不再适用了,因为我们没办法去修改 Rc 持有的值。

但是如果当前的节点仅被当前链表所引用(引用计数为 1),那么实际上是可以将该节点移出并 drop 的。Rc有一个方法可以做到这一点: try_unwrap

1
2
3
4
5
6
7
8
9
10
11
12
impl<T> Drop for List<T> {
fn drop(&mut self) {
let mut head = self.head.take();
while let Some(node) = head {
if let Ok(mut node) = Rc::try_unwrap(node) {
head = node.next.take();
} else {
break;
}
}
}
}

该方法会判断当前的 Rc 是否只有一个强引用,若是,则返回 Rc 持有的值,否则返回一个错误。

我们会一直 drop ,直到某个节点的引用计数大于1。

1
2
3
4
5
6
7
list1 -> A ---+
|
v
list2 ------> B -> C -> D
^
|
list3 -> X ---+

例如要 drop List2,那会从头节点开始一直 drop 到 B 节点时停止,剩余的 B -> C -> D 三个节点由于引用计数不为 1 (同时被多个链表引用) ,因此不会被 drop。

完美!

3.4 最终代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
// in third.rs

use std::rc::Rc;

pub struct List<T> {
head: Link<T>,
}

type Link<T> = Option<Rc<Node<T>>>;

struct Node<T> {
elem: T,
next: Link<T>,
}

impl<T> List<T> {
pub fn new() -> Self {
List { head: None }
}

pub fn prepend(&self, elem: T) -> List<T> {
List { head: Some(Rc::new(Node {
elem: elem,
next: self.head.clone(),
}))}
}

pub fn tail(&self) -> List<T> {
List { head: self.head.as_ref().and_then(|node| node.next.clone()) }
}

pub fn head(&self) -> Option<&T> {
self.head.as_ref().map(|node| &node.elem)
}

pub fn iter(&self) -> Iter<'_, T> {
Iter { next: self.head.as_deref() }
}
}

impl<T> Drop for List<T> {
fn drop(&mut self) {
let mut head = self.head.take();
while let Some(node) = head {
if let Ok(mut node) = Rc::try_unwrap(node) {
head = node.next.take();
} else {
break;
}
}
}
}

pub struct Iter<'a, T> {
next: Option<&'a Node<T>>,
}

impl<'a, T> Iterator for Iter<'a, T> {
type Item = &'a T;

fn next(&mut self) -> Option<Self::Item> {
self.next.map(|node| {
self.next = node.next.as_deref();
&node.elem
})
}
}

#[cfg(test)]
mod test {
use super::List;

#[test]
fn basics() {
let list = List::new();
assert_eq!(list.head(), None);

let list = list.prepend(1).prepend(2).prepend(3);
assert_eq!(list.head(), Some(&3));

let list = list.tail();
assert_eq!(list.head(), Some(&2));

let list = list.tail();
assert_eq!(list.head(), Some(&1));

let list = list.tail();
assert_eq!(list.head(), None);

// Make sure empty tail works
let list = list.tail();
assert_eq!(list.head(), None);
}

#[test]
fn iter() {
let list = List::new().prepend(1).prepend(2).prepend(3);

let mut iter = list.iter();
assert_eq!(iter.next(), Some(&3));
assert_eq!(iter.next(), Some(&2));
assert_eq!(iter.next(), Some(&1));
}
}

4 一个糟糕但安全的双端队列

使用RcRefCell可以实现内部可变性,因此也许我们可以完全安全地实现双向链表。

让我们添加一个名为 fourth.rs 的新文件:

1
2
3
4
5
6
// in lib.rs

pub mod first;
pub mod second;
pub mod third;
pub mod fourth;

不需要复制之前的代码,我们从零开始编写。不过,在开始之前,你需要先复习一下内部可变性模式和RefCell<T>,这很重要,我们设计的关键就是它。

4.1 数据布局

我们想要双向链表。这意味着每个节点都有一个指向前一个和下一个节点的指针。此外,链表本身有一个指向第一个和最后一个节点的指针,这使我们能够在链表的两端快速插入和删除。

所以我们可能想要这样的东西:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use std::rc::Rc;
use std::cell::RefCell;

pub struct List<T> {
head: Link<T>,
tail: Link<T>,
}

type Link<T> = Option<Rc<RefCell<Node<T>>>>;

struct Node<T> {
elem: T,
next: Link<T>,
prev: Link<T>,
}

编译一下,可以通过。让我们尝试使用它。

4.2 基本操作

构建new

从构建列表开始,这非常简单。 不过,因为写在一起使它变得有点笨拙,这里分解出一个Node构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
impl<T> Node<T> {
fn new(elem: T) -> Rc<RefCell<Self>> {
Rc::new(RefCell::new(Node {
elem: elem,
prev: None,
next: None,
}))
}
}

impl<T> List<T> {
pub fn new() -> Self {
List { head: None, tail: None }
}
}

push_front

再来实现向链表的头部推入一个元素的功能,由于双向链表的数据结构和操作逻辑明显更加复杂,因此相比单向链表的单行实现,双向链表的 push 操作也要复杂的多。

除此之外,我们还需要处理一些关于空链表的边界问题:对于绝大部分操作而言,可能只需要使用 headtail 指针,但是对于空链表,则需要同时使用它们。

我们验证我们的方法是否有意义的一个简单方法是,我们是否保持以下不变量:每个节点应该有两个指向它的指针。链表中间的每个节点由其前任和后继节点指向,而左右两端的节点由链表本身指向。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pub fn push_front(&mut self, elem: T) {
let new_head = Node::new(elem);
match self.head.take() {
Some(old_head) => {
// 非空链表,将新的节点跟老的头部相链接
old_head.borrow_mut().prev = Some(new_head.clone());
new_head.borrow_mut().next = Some(old_head);
self.head = Some(new_head);
}
None => {
// 空链表,需要设置 tail 和 head
self.tail = Some(new_head.clone());
self.head = Some(new_head);
}
}
}

编译…通过。非常好。

pop_front

pop_front 应该与 push_front 具有相同的基本逻辑,但操作相反。试试吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pub fn pop_front(&mut self) -> Option<T> {
self.head.take().map(|old_head| {
match old_head.borrow_mut().next.take() {
Some(new_head) => {
// 非空链表
new_head.borrow_mut().prev.take();
self.head = Some(new_head);
}
None => {
// 空链表
self.tail.take();
}
}
old_head.elem
})
}
1
2
3
4
5
6
7
cargo build

error[E0609]: no field `elem` on type `Rc<RefCell<fourth::Node<T>>>`
--> src\fourth.rs:62:22
|
62 | old_head.elem
| ^^^^ unknown field

这里还是需要borrow_mut

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pub fn pop_front(&mut self) -> Option<T> {
self.head.take().map(|old_head| {
match old_head.borrow_mut().next.take() {
Some(new_head) => {
new_head.borrow_mut().prev.take();
self.head = Some(new_head);
}
None => {
self.tail.take();
}
}
old_head.borrow_mut().elem
})
}
1
2
3
4
5
error[E0507]: cannot move out of dereference of `RefMut<'_, fourth::Node<T>>`
--> src\fourth.rs:60:13
|
60 | old_head.borrow_mut().elem
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ move occurs because value has type `T`, which does not implement the `Copy` trait

borrow_mut 只能返回一个 &mut Node<T>,因此无法拿走其所有权。我们需要一个方法来拿走 RefCell<T> 的所有权,然后返回一个 T,让我们检查一下文档中是否有类似的内容:

1
2
pub fn into_inner(self) -> T
// Consumes the RefCell, returning the wrapped value.

看起来很有希望,

1
old_head.into_inner().elem
1
2
3
4
5
6
7
8
error[E0507]: cannot move out of an `Rc`
--> src\fourth.rs:60:13
|
60 | old_head.into_inner().elem
| ^^^^^^^^^------------
| | |
| | value moved due to this method call
| move occurs because value has type `RefCell<fourth::Node<T>>`, which does not implement the `Copy` trait

噢不,into_inner 想要移出RefCell,但因为它位于 Rc 中,Rc<T> 只允许我们获取其内部的不可变引用。

回忆我们之前实现 Drop 时用过的方法,Rc::try_unwrap ,如果Rc的引用计数为 1,则移出Rc的内容:

1
Rc::try_unwrap(old_head).unwrap().into_inner().elem

Rc::try_unwrap 返回一个 Result,由于我们不关心 Err 的情况(如果我们正确编写了程序,它不会错误),所以我们只需对其调用 unwrap

1
2
3
4
5
6
7
error[E0277]: `fourth::Node<T>` doesn't implement `Debug`
--> src\fourth.rs:60:13
|
60 | Rc::try_unwrap(old_head).unwrap().into_inner().elem
| ^^^^^^^^^^^^^^^^^^^^^^^^ ------ required by a bound introduced by this call
| |
| `fourth::Node<T>` cannot be formatted using `{:?}`

unwrap 要求目标类型实现了 Debug ,这样才能在报错时提供 debug 输出,而 RefCell<T> 要实现 Debug 需要它内部的 T 实现 Debug,而我们的 Node 并没有实现。我们这里不为Node实现,而是通过将结果转换为带有 ok 的选项来解决这个问题:

1
Rc::try_unwrap(old_head).ok().unwrap().into_inner().elem

再次编译,这次终于成功了。测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#[cfg(test)]
mod test {
use super::List;

#[test]
fn basics() {
let mut list = List::new();

// Check empty list behaves right
assert_eq!(list.pop_front(), None);

// Populate list
list.push_front(1);
list.push_front(2);
list.push_front(3);

// Check normal removal
assert_eq!(list.pop_front(), Some(3));
assert_eq!(list.pop_front(), Some(2));

// Push some more just to make sure nothing's corrupted
list.push_front(4);
list.push_front(5);

// Check normal removal
assert_eq!(list.pop_front(), Some(5));
assert_eq!(list.pop_front(), Some(4));

// Check exhaustion
assert_eq!(list.pop_front(), Some(1));
assert_eq!(list.pop_front(), None);
}
}
1
2
3
4
running 1 test
test fourth::test::basics ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 9 filtered out; finished in 0.00s

搞定了。

实现Drop

以前我们费心为堆栈实现Drop只是为了避免无限递归,这次更有趣一些,Rc 无法处理引用循环,而双向链表恰恰如此。双向链表只是一个由小循环组成的大链!因此,当我们删除列表时,两个端节点的引用计数将减少到 1……然后不会发生其他任何事情。好吧,如果我们的列表恰好包含一个节点,那么引用循环就一直存在。

所以,这里最好的实现就是将每个节点 pop 出去,直到获得 None:

1
2
3
4
5
impl<T> Drop for List<T> {
fn drop(&mut self) {
while self.pop_front().is_some() {}
}
}

这里可以考虑实现 pushpop_back 版本,但它们跟之前的实现差别不大,因此会在后面直接给出。现在让我们看看更多有趣的事情。

4.3 实现peek

接下来试试实现 peek_front,这在之前很容易,现在也应该…吧?于是直接复制粘贴之前的实现:

1
2
3
4
5
pub fn peek_front(&self) -> Option<&T> {
self.head.as_ref().map(|node| {
&node.elem
})
}

等等,有了之前的教训,这里要用borrow

1
2
3
4
5
6
pub fn peek_front(&self) -> Option<&T> {
self.head.as_ref().map(|node| {
// BORROW!!!!
&node.borrow().elem
})
}
1
2
3
4
5
6
7
8
error[E0515]: cannot return value referencing temporary value
--> src\fourth.rs:67:13
|
67 | &node.borrow().elem
| ^-------------^^^^^
| ||
| |temporary value created here
| returns a value referencing data owned by the current function

好吧,这与我们的单链表的逻辑完全相同。为什么不一样了?要理解这一点,我们需要回到 borrow 的函数签名:

1
2
fn borrow<'a>(&'a self) -> Ref<'a, T>
fn borrow_mut<'a>(&'a self) -> RefMut<'a, T>

这里返回的并不是 &T&mut T,而是一个 RefRefMut,它们就是在借用到的引用外包裹了一层。对于大多数场景,它们的行为与 &T&mut T 完全相同。但正是由于这一层,返回的引用与Ref的生命周期相关,也就是 Ref 和内部引用的生命周期不再和 RefCell 相同,这意味着如果保留引用,那么Ref就必须有效,但显然,它在作用域末尾就失效了,这就是错误的原因所在。

这实际上是正确性所必需的。当Ref被丢弃时,它会告诉RefCell它不再被借用。因此,如果我们确实使得保留引用的时间比Ref存在的时间长,我们就可以在这个引用存在的同时,获取到RefMut——这意味着会把rust的引用系统的规则完全破坏。

那该怎么办呢,我们只想返回一个引用,但需要保留这个Ref的东西。而一旦我们从 peek 返回引用,函数就会结束,并且 Ref 就会超出作用范围被drop。

目前来看,已经是死路了。但是…如果我们放弃完全隐藏我们的实现细节呢,比如返回Refs会怎样?

1
2
3
4
5
6
7
use std::cell::{Ref, RefCell}; // 注意将它们引入

pub fn peek_front(&self) -> Option<Ref<T>> {
self.head.as_ref().map(|node| {
node.borrow()
})
}
1
2
3
4
5
6
7
8
9
10
11
12
error[E0308]: mismatched types
--> src\fourth.rs:64:9
|
26 | impl<T> List<T> {
| - this type parameter
...
63 | pub fn peek_front(&self) -> Option<Ref<T>> {
| -------------- expected `Option<Ref<'_, T>>` because of return type
64 | / self.head.as_ref().map(|node| {
65 | | node.borrow()
66 | | })
| |__________^ expected type parameter `T`, found struct `fourth::Node`

类型不匹配了,要返回的是 Ref<T> 但是获取的却是 Ref<Node<T>>,那么现在看上去有两个选择:1,放弃所有封装并重新开始。2,使事情变得更加复杂,并将 Ref<Node<T>> 包装在新类型中,以仅公开对 &T 的访问。

但是两个选择都不是我们想要的,继续研究下去,发现了这个:

1
2
3
map<U, F>(orig: Ref<'b, T>, f: F) -> Ref<'b, U>
where F: FnOnce(&T) -> &U,
U: ?Sized

没错,就像在 ResultOption 上使用 map 一样,我们还能在 Ref 上使用 map,这太好了,我们正需要它:

1
2
3
4
5
pub fn peek_front(&self) -> Option<Ref<T>> {
self.head.as_ref().map(|node| {
Ref::map(node.borrow(), |node| &node.elem)
})
}

cargo build,终于通过了。编写一个测试吧:

1
2
3
4
5
6
7
8
9
10
#[test]
fn peek() {
let mut list = List::new();
assert!(list.peek_front().is_none());
list.push_front(1);
list.push_front(2);
list.push_front(3);

assert_eq!(&*list.peek_front().unwrap(), &3);
}
1
2
3
4
5
running 2 tests
test fourth::test::peek ... ok
test fourth::test::basics ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 9 filtered out; finished in 0.00s

很好。

4.4 镜像实现

之前我们仅实现了头部的 pushpop ,现在来补全一下从back操作。所要做的就是一些基本的文本替换:

1
2
3
tail <-> head
next <-> prev
front -> back

还需要添加 _mut 变体来返回可变引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
use std::cell::{Ref, RefCell, RefMut};

//..

pub fn push_back(&mut self, elem: T) {
let new_tail = Node::new(elem);
match self.tail.take() {
Some(old_tail) => {
old_tail.borrow_mut().next = Some(new_tail.clone());
new_tail.borrow_mut().prev = Some(old_tail);
self.tail = Some(new_tail);
}
None => {
self.head = Some(new_tail.clone());
self.tail = Some(new_tail);
}
}
}

pub fn pop_back(&mut self) -> Option<T> {
self.tail.take().map(|old_tail| {
match old_tail.borrow_mut().prev.take() {
Some(new_tail) => {
new_tail.borrow_mut().next.take();
self.tail = Some(new_tail);
}
None => {
self.head.take();
}
}
Rc::try_unwrap(old_tail).ok().unwrap().into_inner().elem
})
}

pub fn peek_back(&self) -> Option<Ref<T>> {
self.tail.as_ref().map(|node| {
Ref::map(node.borrow(), |node| &node.elem)
})
}

pub fn peek_back_mut(&mut self) -> Option<RefMut<T>> {
self.tail.as_ref().map(|node| {
RefMut::map(node.borrow_mut(), |node| &mut node.elem)
})
}

pub fn peek_front_mut(&mut self) -> Option<RefMut<T>> {
self.head.as_ref().map(|node| {
RefMut::map(node.borrow_mut(), |node| &mut node.elem)
})
}

以及完善之前写的测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#[test]
fn basics() {
let mut list = List::new();

// Check empty list behaves right
assert_eq!(list.pop_front(), None);

// Populate list
list.push_front(1);
list.push_front(2);
list.push_front(3);

// Check normal removal
assert_eq!(list.pop_front(), Some(3));
assert_eq!(list.pop_front(), Some(2));

// Push some more just to make sure nothing's corrupted
list.push_front(4);
list.push_front(5);

// Check normal removal
assert_eq!(list.pop_front(), Some(5));
assert_eq!(list.pop_front(), Some(4));

// Check exhaustion
assert_eq!(list.pop_front(), Some(1));
assert_eq!(list.pop_front(), None);

// ---- back -----

// Check empty list behaves right
assert_eq!(list.pop_back(), None);

// Populate list
list.push_back(1);
list.push_back(2);
list.push_back(3);

// Check normal removal
assert_eq!(list.pop_back(), Some(3));
assert_eq!(list.pop_back(), Some(2));

// Push some more just to make sure nothing's corrupted
list.push_back(4);
list.push_back(5);

// Check normal removal
assert_eq!(list.pop_back(), Some(5));
assert_eq!(list.pop_back(), Some(4));

// Check exhaustion
assert_eq!(list.pop_back(), Some(1));
assert_eq!(list.pop_back(), None);
}

#[test]
fn peek() {
let mut list = List::new();
assert!(list.peek_front().is_none());
assert!(list.peek_back().is_none());
assert!(list.peek_front_mut().is_none());
assert!(list.peek_back_mut().is_none());

list.push_front(1); list.push_front(2); list.push_front(3);

assert_eq!(&*list.peek_front().unwrap(), &3);
assert_eq!(&mut *list.peek_front_mut().unwrap(), &mut 3);
assert_eq!(&*list.peek_back().unwrap(), &1);
assert_eq!(&mut *list.peek_back_mut().unwrap(), &mut 1);
}

是否有一些情况我们没有测试?是的。排列组合数量爆炸了。但是我们的代码至少没有明显错误。

1
2
3
4
5
running 2 tests
test fourth::test::basics ... ok
test fourth::test::peek ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 9 filtered out; finished in 0.00s

4.5 迭代器

下面,让我们尝试一下为它实现迭代器。

实现IntoIter

一如既往,IntoIter是最简单的 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pub struct IntoIter<T>(List<T>);

impl<T> List<T> {
pub fn into_iter(self) -> IntoIter<T> {
IntoIter(self)
}
}

impl<T> Iterator for IntoIter<T> {
type Item = T;
fn next(&mut self) -> Option<Self::Item> {
self.0.pop_front()
}
}

这里要考虑一个事情,以前我们的实现都是单向的,但现在我们实际可以是双向的。rust实际上对此有一个答案:DoubleEndedIterator ,它“继承”自Iterator,二者都是迭代器,参考官方文档

1
2
3
4
5
6
7
pub trait DoubleEndedIterator: Iterator {
// Required method
fn next_back(&mut self) -> Option<Self::Item>;

// Provided methods
// ...
}

这里用到了Supertraits,因此意味着要实现该特征,首先需要实现 Iterator。并且提供了从另一端迭代的新方法: next_back 。它具有与 next 完全相同的签名,但它从另一端产生元素。需要注意的是,前后迭代工作都在同一范围内进行,并且不会交叉:当它们在中间相遇时,迭代就结束了。

不过,事实证明 next_back 并不是 DoubleEndedIterator 的使用者真正关心的东西。相反,该接口最好的部分是它公开了 rev 方法,该方法包装迭代器以创建一个以相反顺序生成元素的新迭代器。其语义相当简单:对反向迭代器上的 next 的调用只是对 next_back 的调用。

无论如何,我们先来实现一下它吧,非常简单。

1
2
3
4
5
impl<T> DoubleEndedIterator for IntoIter<T> {
fn next_back(&mut self) -> Option<T> {
self.0.pop_back()
}
}

测试:

1
2
3
4
5
6
7
8
9
10
11
12
#[test]
fn into_iter() {
let mut list = List::new();
list.push_front(1); list.push_front(2); list.push_front(3);

let mut iter = list.into_iter();
assert_eq!(iter.next(), Some(3));
assert_eq!(iter.next_back(), Some(1));
assert_eq!(iter.next(), Some(2));
assert_eq!(iter.next_back(), None);
assert_eq!(iter.next(), None);
}

实现Iter

Iter就比较麻烦了,我们将不得不再次处理那些可怕的 Ref。无法像以前那样存储 &Node 。相反,让我们尝试存储 Ref<Node>

1
2
3
4
5
6
7
pub struct Iter<'a, T>(Option<Ref<'a, Node<T>>>);

impl<T> List<T> {
pub fn iter(&self) -> Iter<T> {
Iter(self.head.as_ref().map(|head| head.borrow()))
}
}

到目前为止都没有问题。接下来实现 next

1
2
3
4
5
6
7
8
9
impl<'a, T> Iterator for Iter<'a, T> {
type Item = Ref<'a, T>;
fn next(&mut self) -> Option<Self::Item> {
self.0.take().map(|node_ref| {
self.0 = node_ref.next.as_ref().map(|head| head.borrow());
Ref::map(node_ref, |node| &node.elem)
})
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
error[E0521]: borrowed data escapes outside of closure
--> src\fourth.rs:159:13
|
157 | fn next(&mut self) -> Option<Self::Item> {
| --------- `self` declared here, outside of the closure body
158 | self.0.take().map(|node_ref| {
159 | self.0 = node_ref.next.as_ref().map(|head| head.borrow());
| ^^^^^^ -------- borrow is only valid in the closure body
| |
| reference to `node_ref` escapes the closure body here

error[E0505]: cannot move out of `node_ref` because it is borrowed
--> src\fourth.rs:160:22
|
157 | fn next(&mut self) -> Option<Self::Item> {
| --------- lifetime `'1` appears in the type of `self`
158 | self.0.take().map(|node_ref| {
159 | self.0 = node_ref.next.as_ref().map(|head| head.borrow());
| ------ -------- borrow of `node_ref` occurs here
| |
| assignment requires that `node_ref` is borrowed for `'1`
160 | Ref::map(node_ref, |node| &node.elem)
| ^^^^^^^^ move out of `node_ref` occurs here

这里提示我们,node_ref 活得不够久,与普通引用不同,rust不允许我们像这样拆分引用。我们从 head.borrow() 中得到的Ref只允许存在与 node_ref 一样长的时间,但我们最终会在 Ref::map 调用中丢弃它。

我们实际想要的函数也许是这个map_split

1
2
3
4
5
pub fn map_split<U, V, F>(orig: Ref<'b, T>, f: F) -> (Ref<'b, U>, Ref<'b, V>)
where
F: FnOnce(&T) -> (&U, &V),
U: ?Sized,
V: ?Sized,

试一试吧…

1
2
3
4
5
6
7
8
9
10
11
fn next(&mut self) -> Option<Self::Item> {
self.0.take().map(|node_ref| {
let (next, elem) = Ref::map_split(node_ref, |node| {
(&node.next, &node.elem)
});

self.0 = next.as_ref().map(|head| head.borrow());

elem
})
}
1
2
3
4
5
6
7
8
9
10
error[E0521]: borrowed data escapes outside of closure
--> src\fourth.rs:163:13
|
157 | fn next(&mut self) -> Option<Self::Item> {
| --------- `self` declared here, outside of the closure body
...
163 | self.0 = next.as_ref().map(|head| head.borrow());
| ^^^^^^ ------------- borrow is only valid in the closure body
| |
| reference to `next` escapes the closure body here

借用的内容只允许在闭包体中使用,看起来我们还是得用 Ref::map 来解决问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn next(&mut self) -> Option<Self::Item> {
self.0.take().map(|node_ref| {
let (next, elem) = Ref::map_split(node_ref, |node| {
(&node.next, &node.elem)
});

self.0 = if next.is_some() {
Some(Ref::map(next, |next| &**next.as_ref().unwrap()))
} else {
None
};

elem
})
}
1
2
3
4
5
6
7
error[E0308]: mismatched types
--> src\fourth.rs:164:22
|
164 | Some(Ref::map(next, |next| &**next.as_ref().unwrap()))
| ---- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected struct `fourth::Node`, found struct `RefCell`
| |
| arguments to this enum variant are incorrect

多了一个 RefCell ,随着我们的对链表的逐步深入,RefCell 的代码嵌套变成了不可忽视的问题。这是一个死胡同,让我们尝试离开 RefCells

Rc 怎么样?为什么我们不能克隆整个Rc呢?

1
2
3
4
5
6
7
8
9
10
pub struct Iter<T>(Option<Rc<Node<T>>>);

impl<T> List<T> {
pub fn iter(&self) -> Iter<T> {
Iter(self.head.as_ref().map(|head| head.clone()))
}
}

impl<T> Iterator for Iter<T> {
type Item =

呃…等等,现在应该返回什么? &TRef<T> ?现在我们的 Iter 已经没有生命周期了:无论是 &T 还是 Ref<T> 都需要我们在 next 之前声明好生命周期。但是我们试图从 Rc 中取出来的值其实是迭代器的引用。

也可以通过对 Rc 进行 map 获取到 Rc<T>?但是标准库并没有给我们提供相应的功能,实际上有人做了一个三方库可以让你做到这一点。

但是,即使这么做了,我们也会遇到一个更大的问题:可怕的迭代器失效幽灵。事实上,之前我们对于迭代器不合法是免疫的,但是一旦迭代器产生 Rc,那它们就不再会借用链表。这意味着人们可以在持有指向链表内部的指针时,还可以进行 pushpop 操作。push 问题不大,因为链表两端的增长不会对我们正在关注的某个子链表造成影响。但是 pop 就是另一个故事了,如果在我们关注的子链表之外 pop,那问题不大。但是如果是 pop 一个正在引用的子链表中的节点呢?那一切就完了,特别是,如果大家还试图去 unwrap try_unwrap 返回的 Result ,会直接造成整个程序的 panic

其实我们大部分的努力都是为了实现隐藏的细节和优雅的 API,典型的二八原则,八成时间花在二成的细节上。但是如果不关心这些细节,可以接受自己的平凡的话,那把节点简单的到处传递就行。但Rc<RefCell> 真的最终让我们失望了。

实际上,这种设计更适合永远不会向 API 的使用者展示的内部数据结构。内部可变性非常适合编写安全的应用程序。但是如果是写安全性高的库,那内部可变性就有些捉襟见肘了。无论如何,这就是我们在这里放弃 IterIterMut 的原因。我们也许可以做到,但是,呃,还是算了。

4.6 最终代码

好的,这就是在rust中实现 100% 安全的双向链表。实现起来是一场噩梦,泄露了实现细节,并且不支持几个基本操作。在实现中,还有大量 RcRefCell 引起的运行时检查,最终会影响链表的性能。尽管如此,这仍然做到了。特别是如果我们不关心向消费者公开内部数据结构的话。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
// in fourth.rs

use std::rc::Rc;
use std::cell::{Ref, RefMut, RefCell};

pub struct List<T> {
head: Link<T>,
tail: Link<T>,
}

type Link<T> = Option<Rc<RefCell<Node<T>>>>;

struct Node<T> {
elem: T,
next: Link<T>,
prev: Link<T>,
}


impl<T> Node<T> {
fn new(elem: T) -> Rc<RefCell<Self>> {
Rc::new(RefCell::new(Node {
elem: elem,
prev: None,
next: None,
}))
}
}

impl<T> List<T> {
pub fn new() -> Self {
List { head: None, tail: None }
}

pub fn push_front(&mut self, elem: T) {
let new_head = Node::new(elem);
match self.head.take() {
Some(old_head) => {
old_head.borrow_mut().prev = Some(new_head.clone());
new_head.borrow_mut().next = Some(old_head);
self.head = Some(new_head);
}
None => {
self.tail = Some(new_head.clone());
self.head = Some(new_head);
}
}
}

pub fn push_back(&mut self, elem: T) {
let new_tail = Node::new(elem);
match self.tail.take() {
Some(old_tail) => {
old_tail.borrow_mut().next = Some(new_tail.clone());
new_tail.borrow_mut().prev = Some(old_tail);
self.tail = Some(new_tail);
}
None => {
self.head = Some(new_tail.clone());
self.tail = Some(new_tail);
}
}
}

pub fn pop_back(&mut self) -> Option<T> {
self.tail.take().map(|old_tail| {
match old_tail.borrow_mut().prev.take() {
Some(new_tail) => {
new_tail.borrow_mut().next.take();
self.tail = Some(new_tail);
}
None => {
self.head.take();
}
}
Rc::try_unwrap(old_tail).ok().unwrap().into_inner().elem
})
}

pub fn pop_front(&mut self) -> Option<T> {
self.head.take().map(|old_head| {
match old_head.borrow_mut().next.take() {
Some(new_head) => {
new_head.borrow_mut().prev.take();
self.head = Some(new_head);
}
None => {
self.tail.take();
}
}
Rc::try_unwrap(old_head).ok().unwrap().into_inner().elem
})
}

pub fn peek_front(&self) -> Option<Ref<T>> {
self.head.as_ref().map(|node| {
Ref::map(node.borrow(), |node| &node.elem)
})
}

pub fn peek_back(&self) -> Option<Ref<T>> {
self.tail.as_ref().map(|node| {
Ref::map(node.borrow(), |node| &node.elem)
})
}

pub fn peek_back_mut(&mut self) -> Option<RefMut<T>> {
self.tail.as_ref().map(|node| {
RefMut::map(node.borrow_mut(), |node| &mut node.elem)
})
}

pub fn peek_front_mut(&mut self) -> Option<RefMut<T>> {
self.head.as_ref().map(|node| {
RefMut::map(node.borrow_mut(), |node| &mut node.elem)
})
}

pub fn into_iter(self) -> IntoIter<T> {
IntoIter(self)
}
}

impl<T> Drop for List<T> {
fn drop(&mut self) {
while self.pop_front().is_some() {}
}
}

pub struct IntoIter<T>(List<T>);

impl<T> Iterator for IntoIter<T> {
type Item = T;

fn next(&mut self) -> Option<T> {
self.0.pop_front()
}
}

impl<T> DoubleEndedIterator for IntoIter<T> {
fn next_back(&mut self) -> Option<T> {
self.0.pop_back()
}
}

#[cfg(test)]
mod test {
use super::List;

#[test]
fn basics() {
let mut list = List::new();

// Check empty list behaves right
assert_eq!(list.pop_front(), None);

// Populate list
list.push_front(1);
list.push_front(2);
list.push_front(3);

// Check normal removal
assert_eq!(list.pop_front(), Some(3));
assert_eq!(list.pop_front(), Some(2));

// Push some more just to make sure nothing's corrupted
list.push_front(4);
list.push_front(5);

// Check normal removal
assert_eq!(list.pop_front(), Some(5));
assert_eq!(list.pop_front(), Some(4));

// Check exhaustion
assert_eq!(list.pop_front(), Some(1));
assert_eq!(list.pop_front(), None);

// ---- back -----

// Check empty list behaves right
assert_eq!(list.pop_back(), None);

// Populate list
list.push_back(1);
list.push_back(2);
list.push_back(3);

// Check normal removal
assert_eq!(list.pop_back(), Some(3));
assert_eq!(list.pop_back(), Some(2));

// Push some more just to make sure nothing's corrupted
list.push_back(4);
list.push_back(5);

// Check normal removal
assert_eq!(list.pop_back(), Some(5));
assert_eq!(list.pop_back(), Some(4));

// Check exhaustion
assert_eq!(list.pop_back(), Some(1));
assert_eq!(list.pop_back(), None);
}

#[test]
fn peek() {
let mut list = List::new();
assert!(list.peek_front().is_none());
assert!(list.peek_back().is_none());
assert!(list.peek_front_mut().is_none());
assert!(list.peek_back_mut().is_none());

list.push_front(1); list.push_front(2); list.push_front(3);

assert_eq!(&*list.peek_front().unwrap(), &3);
assert_eq!(&mut *list.peek_front_mut().unwrap(), &mut 3);
assert_eq!(&*list.peek_back().unwrap(), &1);
assert_eq!(&mut *list.peek_back_mut().unwrap(), &mut 1);
}

#[test]
fn into_iter() {
let mut list = List::new();
list.push_front(1); list.push_front(2); list.push_front(3);

let mut iter = list.into_iter();
assert_eq!(iter.next(), Some(3));
assert_eq!(iter.next_back(), Some(1));
assert_eq!(iter.next(), Some(2));
assert_eq!(iter.next_back(), None);
assert_eq!(iter.next(), None);
}
}

现在,我们将关注硬币的另一面:通过使我们的实现变得不安全来夺回所有控制权。

5 一个很棒但不安全的队列

通过内部可变性实现的链表有点失控了,RcRefcell非常适合处理简单的情况,但它们可能会变得笨拙。这次,我们使用裸指针,通过unsafe的方式实现一个单向的队列。

让我们添加一个名为 fifth.rs 的新文件:

1
2
3
4
5
6
7
// in lib.rs

pub mod first;
pub mod second;
pub mod third;
pub mod fourth;
pub mod fifth;

fifth.rs 的代码会跟 second.rs 存在一定的重叠,尽管如此,我们仍将从头开始,因为我们想要解决一些布局等基本问题。

5.1 数据布局

栈和队列之间的唯一区别是队列从另一端弹出。因此,栈实现是这样的:

1
2
3
4
5
6
7
8
input list:
[Some(ptr)] -> (A, Some(ptr)) -> (B, None)

stack push X:
[Some(ptr)] -> (X, Some(ptr)) -> (A, Some(ptr)) -> (B, None)

stack pop:
[Some(ptr)] -> (A, Some(ptr)) -> (B, None)

要变成队列,只需要将pop或者push其中任意一个操作改到另一端,到底选择哪个?由于我们的链表是单链,因此这两种所做的操作是完全相同的,如果选择push

1
2
3
4
5
input list:
[Some(ptr)] -> (A, Some(ptr)) -> (B, None)

flipped push X:
[Some(ptr)] -> (A, Some(ptr)) -> (B, Some(ptr)) -> (X, None)

要将X入队,只需要从A开始一直走到末尾(None处)再将其设置为带有新元素的 Some 即可。

如果选择pop

1
2
3
4
5
input list:
[Some(ptr)] -> (A, Some(ptr)) -> (B, Some(ptr)) -> (X, None)

flipped pop:
[Some(ptr)] -> (A, Some(ptr)) -> (B, None)

要出队,只需要从A开始一直走到末尾(None处)之前的节点,然后 take 它。

无论选择哪种改变,都会发现,这两个操作都会遍历整个列表,当元素很多时,入队或出队的性能会大幅下降。

能否设计一个入队和出队都是O(1)级别的数据结构呢?当然可以,问题的关键就是如何避免在整个链表上遍历,以反转push操作为例,如果我们有一个额外的指针指向整个链表的尾部,那么就可以直接插入了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
use std::mem;

pub struct List<T> {
head: Link<T>,
tail: Link<T>, // NEW!
}

type Link<T> = Option<Box<Node<T>>>;

struct Node<T> {
elem: T,
next: Link<T>,
}

impl<T> List<T> {
pub fn new() -> Self {
List { head: None, tail: None }
}

pub fn push(&mut self, elem: T) {
let new_tail = Box::new(Node {
elem: elem,
// 在尾端推入一个新节点时,新节点的下一个节点永远是 None
next: None,
});

// 让 tail 指向新的节点,并返回之前的 old tail
let old_tail = mem::replace(&mut self.tail, Some(new_tail));

match old_tail {
Some(mut old_tail) => {
// 若 old tail 存在,则让该节点指向新的节点
old_tail.next = Some(new_tail);
}
None => {
// 否则,将 head 指向新的节点
self.head = Some(new_tail);
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
error[E0382]: use of moved value: `new_tail`
--> src\fifth.rs:33:38
|
21 | let new_tail = Box::new(Node {
| -------- move occurs because `new_tail` has type `Box<fifth::Node<T>>`, which does not implement the `Copy` trait
...
28 | let old_tail = mem::replace(&mut self.tail, Some(new_tail));
| -------- value moved here
...
33 | old_tail.next = Some(new_tail);
| ^^^^^^^^ value used here after move

Box 并没有实现 Copy 特征,因此我们不能在两个地方进行赋值。使用没有所有权的引用类型试试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
pub struct List<T> {
head: Link<T>,
tail: Option<&mut Node<T>>, // NEW!
}

type Link<T> = Option<Box<Node<T>>>;

struct Node<T> {
elem: T,
next: Link<T>,
}

impl<T> List<T> {
pub fn new() -> Self {
List { head: None, tail: None }
}

pub fn push(&mut self, elem: T) {
let new_tail = Box::new(Node {
elem: elem,
next: None,
});

let new_tail = match self.tail.take() {
Some(old_tail) => {
old_tail.next = Some(new_tail);
old_tail.next.as_deref_mut()
}
None => {
self.head = Some(new_tail);
self.head.as_deref_mut()
}
};

self.tail = new_tail;
}
}
1
2
3
4
5
error[E0106]: missing lifetime specifier
--> src\fifth.rs:5:18
|
5 | tail: Option<&mut Node<T>>, // NEW!
| ^ expected named lifetime parameter

这次提示我们,需要为类型中的引用赋予生命周期。这个引用的生命周期是多长?嗯,这看起来像IterMut,让我们尝试一下与IterMut一样,只添加一个通用的 'a

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
pub struct List<'a, T> {
head: Link<T>,
tail: Option<&'a mut Node<T>>, // NEW!
}

type Link<T> = Option<Box<Node<T>>>;

struct Node<T> {
elem: T,
next: Link<T>,
}

impl<'a, T> List<'a, T> {
pub fn new() -> Self {
List { head: None, tail: None }
}

pub fn push(&mut self, elem: T) {
let new_tail = Box::new(Node {
elem: elem,
next: None,
});

let new_tail = match self.tail.take() {
Some(old_tail) => {
old_tail.next = Some(new_tail);
old_tail.next.as_deref_mut()
}
None => {
self.head = Some(new_tail);
self.head.as_deref_mut()
}
};

self.tail = new_tail;
}
}
1
2
3
4
5
6
7
8
9
10
11
error: lifetime may not live long enough
--> src\fifth.rs:33:17
|
15 | impl<'a, T> List<'a, T> {
| -- lifetime `'a` defined here
...
20 | pub fn push(&mut self, elem: T) {
| - let's call the lifetime of this reference `'1`
...
33 | self.head.as_deref_mut()
| ^^^^^^^^^^^^^^^^^^^^^^^^ assignment requires that `'1` must outlive `'a`

强大的编译器提示,我们借用了 self ,但编译器希望它至少能持续与 'a 一样长的时间,如果我们告诉它 self 确实持续了那么久:

1
pub fn push(&'a mut self, elem: T) {

再次编译,成功了。

再来实现下 pop:

1
2
3
4
5
6
7
8
9
10
11
12
pub fn pop(&'a mut self) -> Option<T> {
self.head.take().map(|head| {
let head = *head;
self.head = head.next;

if self.head.is_none() {
self.tail = None;
}

head.elem
})
}

并为此编写一个快速测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#[cfg(test)]
mod test {
use super::List;
#[test]
fn basics() {
let mut list = List::new();

// Check empty list behaves right
assert_eq!(list.pop(), None);

// Populate list
list.push(1);
list.push(2);
list.push(3);

// Check normal removal
assert_eq!(list.pop(), Some(1));
assert_eq!(list.pop(), Some(2));

// Push some more just to make sure nothing's corrupted
list.push(4);
list.push(5);

// Check normal removal
assert_eq!(list.pop(), Some(3));
assert_eq!(list.pop(), Some(4));

// Check exhaustion
assert_eq!(list.pop(), Some(5));
assert_eq!(list.pop(), None);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
cargo test

error[E0499]: cannot borrow `list` as mutable more than once at a time
--> src/fifth.rs:68:9
|
65 | assert_eq!(list.pop(), None);
| ---- first mutable borrow occurs here
...
68 | list.push(1);
| ^^^^
| |
| second mutable borrow occurs here
| first borrow later used here

error[E0499]: cannot borrow `list` as mutable more than once at a time
--> src/fifth.rs:69:9
|
65 | assert_eq!(list.pop(), None);
| ---- first mutable borrow occurs here
...
69 | list.push(2);
| ^^^^
| |
| second mutable borrow occurs here
| first borrow later used here

error[E0499]: cannot borrow `list` as mutable more than once at a time
--> src/fifth.rs:70:9
|
65 | assert_eq!(list.pop(), None);
| ---- first mutable borrow occurs here
...
70 | list.push(3);
| ^^^^
| |
| second mutable borrow occurs here
| first borrow later used here


....

** WAY MORE LINES OF ERRORS **

....

error: aborting due to 11 previous errors

天啊,这是为什么?但编译器真的没错,因为都是我们刚才那个标记惹的祸。我们为 self 标记了 'a,意味着在 'a 结束前,无法再去使用 self,而self的生命周期…好吧,它是整个程序,也就是说我们可以调用一次push或者pop,而之后,我们创建的链表的荒谬性就会产生影响,一切都会被锁定住,无法再次调用。事实上,我们正是创建了一个之前提到过的自引用与循环引用

我们的 pop 实现暗示了为什么在我们自己内部存储对我们自己的引用可能非常危险:

1
2
3
4
// ...
if self.head.is_none() {
self.tail = None;
}

如果我们忘记这样做怎么办?然后我们的尾部将指向已从列表中删除的某个节点。这样的节点会立即被释放,我们就会有一个悬空指针,Rust 应该保护我们免受它的侵害!事实上,Rust 正在保护我们免受这种危险。只是以一种非常迂回的方式。

所以,我们能做些什么?回到 Rc<RefCell>> 地狱吗?

绝对不,相反,我们将偏离轨道并使用裸指针。我们的布局将如下所示:

1
2
3
4
5
6
7
8
9
10
11
pub struct List<T> {
head: Link<T>,
tail: *mut Node<T>, // DANGER DANGER
}

type Link<T> = Option<Box<Node<T>>>;

struct Node<T> {
elem: T,
next: Link<T>,
}

5.2 基本操作

在开始之前,你可以先复习一下不安全的rust,然后让我们进入unsafe rust的世界。

构建new

在之前我们是这么做的:

1
2
3
4
5
impl<T> List<T> {
pub fn new() -> Self {
List { head: None, tail: None }
}
}

但是我们不能再在 tail 中使用 Option

1
2
3
4
5
error[E0308]: mismatched types
--> src\fifth.rs:17:34
|
17 | List { head: None, tail: None }
| ^^^^ expected raw pointer, found enum `Option`

我们当然可以为裸指针也包裹一个Option,但是裸指针之所以成为裸指针,就是因为*mut 可以为空。它无法从空指针优化中受益。相反,我们将使用 null 来表示None

rust提供了nullnull_mut 函数创建空指针null,然后,你可以通过 *const T*mut Tis_null 方法检查指针是否为空,如果你愿意,你也可以使用 0 as *mut _ ,但这看起来很混乱。

下面是一个创建空指针的例子:

1
2
3
4
use std::ptr;

let p: *mut i32 = ptr::null_mut();
assert!(p.is_null());

因此,此处new我们这样写:

1
2
3
4
5
6
7
8
9
use std::ptr;

// defns...

impl<T> List<T> {
pub fn new() -> Self {
List { head: None, tail: ptr::null_mut() }
}
}

push

下面继续编写 push 。我们如何从普通指针创建原始指针?答案是强转:

1
let raw_tail: *mut _ = &mut *new_tail;

尝试实现push

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pub fn push(&mut self, elem: T) {
let mut new_tail = Box::new(Node {
elem: elem,
next: None,
});

let raw_tail: *mut _ = &mut *new_tail;

// .is_null 会检查是否为 null, 在功能上等价于 `None` 的检查
if !self.tail.is_null() {
// 如果 old tail 存在,那将其指向新的 tail
self.tail.next = Some(new_tail);
} else {
// 否则让 head 指向新的 tail
self.head = Some(new_tail);
}

self.tail = raw_tail;
}
1
2
3
4
5
6
7
error[E0609]: no field `next` on type `*mut fifth::Node<T>`
--> src\fifth.rs:30:23
|
30 | self.tail.next = Some(new_tail);
| ----------^^^^
| |
| help: `self.tail` is a raw pointer; try dereferencing it: `(*self.tail).next`

当我们开始使用裸指针时,一些隐式的解引用转换就不再生效了,要访问原始指针的内容,编译器坚持要求我们手动取消引用它们,因为这是一个不安全的操作。那么让我们这样做:

1
(*self.tail).next = Some(new_tail);
1
2
3
4
5
error[E0133]: dereference of raw pointer is unsafe and requires unsafe function or block
--> src\fifth.rs:30:13
|
30 | (*self.tail).next = Some(new_tail);
| ^^^^^^^^^^^^^^^^^ dereference of raw pointer

创建裸指针可以在unsafe块外面,但是使用时必须放到unsafe内,我们有两个选择。首先,我们可以将整个函数标记为不安全,在这种情况下,它就成为不安全的 Rust 函数,并且只能在 unsafe 上下文中调用。这不太好,因为我们希望我们的列表可以安全使用。其次,我们可以在函数内编写一个 unsafe 块来界定边界。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pub fn push(&mut self, elem: T) {
let mut new_tail = Box::new(Node {
elem: elem,
next: None,
});

let raw_tail: *mut _ = &mut *new_tail;

if !self.tail.is_null() {
// Hello Compiler, I Know I Am Doing Something Dangerous And
// I Promise To Be A Good Programmer Who Never Makes Mistakes.
unsafe {
(*self.tail).next = Some(new_tail);
}
} else {
self.head = Some(new_tail);
}

self.tail = raw_tail;
}

pop

让我们继续看 pop,它与之前的实现几乎没什么两样:

1
2
3
4
5
6
7
8
9
10
11
12
pub fn pop(&mut self) -> Option<T> {
self.head.take().map(|head| {
let head = *head;
self.head = head.next;

if self.head.is_none() {
self.tail = ptr::null_mut();
}

head.elem
})
}

编写单元测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#[cfg(test)]
mod test {
use super::List;
#[test]
fn basics() {
let mut list = List::new();

// Check empty list behaves right
assert_eq!(list.pop(), None);

// Populate list
list.push(1);
list.push(2);
list.push(3);

// Check normal removal
assert_eq!(list.pop(), Some(1));
assert_eq!(list.pop(), Some(2));

// Push some more just to make sure nothing's corrupted
list.push(4);
list.push(5);

// Check normal removal
assert_eq!(list.pop(), Some(3));
assert_eq!(list.pop(), Some(4));

// Check exhaustion
assert_eq!(list.pop(), Some(5));
assert_eq!(list.pop(), None);

// Check the exhaustion case fixed the pointer right
list.push(6);
list.push(7);

// Check normal removal
assert_eq!(list.pop(), Some(6));
assert_eq!(list.pop(), Some(7));
assert_eq!(list.pop(), None);
}
}
1
2
3
4
running 1 test
test fifth::test::basics ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 12 filtered out; finished in 0.01s

看起来通过了。

5.3 Miri

上一小节编写的测试似乎没有问题,但是我们现在正在编写 unsafe 代码,因此编译器也无法帮助我们捕获错误。测试可能碰巧有效,但实际上可能无法检测到一些未定义的行为。

Miri正是用来检测未定义行为的工具,我们先安装它,然后再进行介绍。

1
rustup +nightly-2022-01-21 component add miri

我们正在安装的工具 miri 与 rustc 的内部结构紧密配合,因此它仅适用于rust夜版工具链。

+nightly-2022-01-21 告诉 rustup 我们要在该日期使用 rust nightly 工具链安装 miri。这里给出了一个具体的日期,因为有时 miri 会落后,无法为一些nightly版本而建造。如果我们尚未安装它,rustup 将自动下载我们使用 + 指定的任何工具链。

2022-01-21 是我所知的 miri 可以成功运行的版本,你可以检查这个网址获取更多信息。如果你幸运的话,可以只使用 +nightly 成功安装。

下面继续运行:

1
2
3
$ cargo +nightly-2022-01-21 miri test
I will run `"cargo.exe" "install" "xargo"` to install
a recent enough xargo. Proceed? [Y/n]

选择Y

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
> y

Updating crates.io index
Installing xargo v0.3.24
...
Finished release [optimized] target(s) in 10.65s
Installing C:\Users\ninte\.cargo\bin\xargo-check.exe
Installing C:\Users\ninte\.cargo\bin\xargo.exe
Installed package `xargo v0.3.24` (executables `xargo-check.exe`, `xargo.exe`)

I will run `"rustup" "component" "add" "rust-src"` to install
the `rust-src` component for the selected toolchain. Proceed? [Y/n]

> y

info: downloading component 'rust-src'
info: installing component 'rust-src'

Compiling lists v0.1.0 (C:\Users\ninte\dev\tmp\lists)
Finished test [unoptimized + debuginfo] target(s) in 0.25s
Running unittests (lists-5cc11d9ee5c3e924.exe)

error: Undefined Behavior: trying to reborrow for Unique at alloc84055,
but parent tag <209678> does not have an appropriate item in
the borrow stack

--> \lib\rustlib\src\rust\library\core\src\option.rs:846:18
|
846 | Some(x) => Some(f(x)),
| ^ trying to reborrow for Unique at alloc84055,
| but parent tag <209678> does not have an
| appropriate item in the borrow stack
|
= help: this indicates a potential bug in the program:
it performed an invalid operation, but the rules it
violated are still experimental
= help: see https://github.com/rust-lang/unsafe-code-guidelines/blob/master/wip/stacked-borrows.md
for further information

= note: inside `std::option::Option::<std::boxed::Box<fifth::Node<i32>>>::map::<i32, [closure@src\fifth.rs:31:30: 40:10]>` at \lib\rustlib\src\rust\library\core\src\option.rs:846:18

note: inside `fifth::List::<i32>::pop` at src\fifth.rs:31:9
--> src\fifth.rs:31:9
|
31 | / self.head.take().map(|head| {
32 | | let head = *head;
33 | | self.head = head.next;
34 | |
... |
39 | | head.elem
40 | | })
| |__________^
note: inside `fifth::test::basics` at src\fifth.rs:74:20
--> src\fifth.rs:74:20
|
74 | assert_eq!(list.pop(), Some(1));
| ^^^^^^^^^^
note: inside closure at src\fifth.rs:62:5
--> src\fifth.rs:62:5
|
61 | #[test]
| ------- in this procedural macro expansion
62 | / fn basics() {
63 | | let mut list = List::new();
64 | |
65 | | // Check empty list behaves right
... |
96 | | assert_eq!(list.pop(), None);
97 | | }
| |_____^
...
error: aborting due to previous error

哇。这是一个很大的错误。在解释这个错误之前,先来解释一下miri是什么,简单来说,它是rust的中级中间表示(MIR)的实验解释器。使用它可以解释你的程序并通知你是否在runtime违反规则并执行未定义的行为。这是很有必要的,因为未定义行为通常是在运行时发生的事情。如果可以在编译时发现问题,就可以提前避免在运行时出现这些错误。

下面来看看上面发生的具体错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
error: Undefined Behavior: trying to reborrow for Unique at alloc84055, but parent tag <209678> does not have an appropriate item in the borrow stack

--> \lib\rustlib\src\rust\library\core\src\option.rs:846:18
|
846 | Some(x) => Some(f(x)),
| ^ trying to reborrow for Unique at alloc84055,
| but parent tag <209678> does not have an
| appropriate item in the borrow stack
|

= help: this indicates a potential bug in the program: it
performed an invalid operation, but the rules it
violated are still experimental

= help: see
https://github.com/rust-lang/unsafe-code-guidelines/blob/master/wip/stacked-borrows.md
for further information

我可以看到我们犯了一个错误,但这是一个令人困惑的错误消息。什么是borrow stack

5.4 借用栈

在上一节中,我们尝试在 miri 下运行我们实现的不安全的队列,它说我们违反了栈借用规则,并为我们链接了一些文档。感兴趣的话,可以浏览这些文档,但是我们并不是该文档的真正目标受众。它更多地是为致力于 Rust 语义的编译器开发人员和语法研究者而设计的。

因此,这里只是介绍“借用栈”的高级概念,然后为你提供遵循规则的简单策略。

在这之前要提出一点,目前栈借用在 Rust 语义模型中还是试验阶段,因此破坏这些规则不一定说明你的程序错了。但是除非你在做编译器开发,否则最好还是修复这些错误。对于未定义的行为,安全总比后悔好。

指针别名(Pointer Aliasing)

别名(Aliasing)指的是可以使用不同名称访问同一内存位置的情况。正如“使用别名”的人可以通过两个不同的名称来引用一样,重叠的内存片段可以通过两个不同的指针来引用。这可能会导致问题。

编译器使用有关指针别名的信息来优化对内存的访问,因此如果它所拥有的信息是错误的,那么程序将被错误编译并产生随机垃圾。

实际上,别名更多关心的是内存访问而不是指针本身,而且只有在其中一个访问是可变的时,才可能出问题。之所以说指针,是因为指针这个概念更方便跟一些规则进行关联。

编译器需要获取一个值时,是该去缓存中查询还是每次都去内存中加载呢?关于这个选择,编译器需要清晰地知道是否有一个指针在背后修改内存,如果内存值被修改了,那缓存显然就失效了。

指针别名在C/C++中也是存在的,比如下面的C程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

// C program to illustrate aliasing
#include <stdio.h>

// Function to add 10 to b
int gfg(int* a, int* b)
{
*b = *b + 10;
return *a;
}

// Driver Code
int main()
{
// Given data
int data = 20;

// Function Call with aliasing
int result = gfg(&data, &data);

// Print the data
printf("%d ", result);
}

这里,我们将data地址同时传递给ab,它们互相是对方的别名,对指针b引用的内存位置所做的更改也会反映在a中,因此,程序会输出30

显然,无论是从程序员的角度还是编译器优化的角度,都希望尽可能少出现指针别名,即不同类型的指针永远不会指向相同的内存位置,这在C语言中被称为严格的别名规则,这样的限制有助于编译器优化代码。

安全的借用栈

对于rust来说,它没有C那么狂野,因此编译器一定期望拥有良好的指针别名信息,因为rust就是为此而设计的。对于rust正常代码而言,严格的借用规则是我们的后盾:要么只存在一个可变引用,要么同时存在多个不可变引用。

但事情没这么简单。我们可以像这样“重新借用”可变指针:

1
2
3
4
5
6
7
8
let mut data = 10;
let ref1 = &mut data;
let ref2 = &mut *ref1;

*ref2 += 2;
*ref1 += 1;

println!("{}", data);

编译并运行良好,输出13。但是,如果交换解引用的顺序:

1
2
3
4
5
6
7
8
9
let mut data = 10;
let ref1 = &mut data;
let ref2 = &mut *ref1;

// ORDER SWAPPED!
*ref1 += 1;
*ref2 += 2;

println!("{}", data);
1
2
3
4
5
6
7
8
9
10
error[E0503]: cannot use `*ref1` because it was mutably borrowed
--> src\main.rs:10:5
|
7 | let ref2 = &mut *ref1;
| ---------- borrow of `*ref1` occurs here
...
10 | *ref1 += 1;
| ^^^^^^^^^^ use of borrowed `*ref1`
11 | *ref2 += 2;
| ---------- borrow later used here

突然编译错误了。当我们再借用了一个可变引用时,那原始的引用(ref1)就不能再被使用,直到借用者(ref1)使用完毕。根据NLL规则,借用者的借用有效范围并不是看作用域,而是看最后一次使用的位置,因此第一个例子中的代码中我们重新借用指针,使用新指针一段时间,然后停止使用它,然后再次使用旧指针,这并不会出现错误,但第二个例子就不行。

要想不产生借用错误,所有重新借用(reborrow)都必须明确嵌套,因此我们在任何给定时间只能考虑其中一个“生效的借用”。什么东西可以表示嵌套的事物呢?栈,没错,一个借用栈。这就是borrow stack名字的由来。

借用栈顶部的任何内容都是“生效的”,并且知道它实际上是未别名的。当你重新借用指针时,新指针将被推入栈顶,成为活动指针。当你使用旧指针时,通过弹出其上方借用栈上的所有内容,才可以继续使用。此时,指针“知道”它已被重新借用,并且内存可能已被修改,但它再次具有独占访问权限,无需担心指针别名的问题。

实际上,访问reborrow的指针总是可以的,因为我们总是可以弹出它上面的所有指针,直到它成为栈顶指针。但是,如果是访问一个已经从借用栈中弹出的指针——那么你就搞砸了。

值得庆幸的是,借用检查器的设计确保了安全的 Rust 程序遵循这些规则,正如我们在上面的示例中看到的那样,但编译器通常从栈借用的角度“向后”看待这个问题。它并没有说使用 ref1 会使 ref2 无效,而是坚持 ref2 必须对其所有用途都有效,并且 ref1 是唯一的不按顺序行事的指针。编译器的报错也是选择了这种表述方式:无法使用 *ref1,原因是它已经被可变借用了,可以看出,这种表述方式更加符合直觉。

这一切都运行良好——在safe rust下。当我们开始使用不安全的指针时,借用检查器就无法帮助我们了。

不安全的借用栈

因此,我们希望以某种方式让不安全指针参与到这个堆借用系统中,即使编译器无法正确跟踪它们。我们还希望系统具有相当的宽容性,这样就不会太容易搞砸并导致未定义行为(UB)。

这是一个难题,从事栈借用的开发人员想出了一些看似合理的东西,并且 miri 尝试实现它。

从高层次来看,当我们将一个引用转换成裸指针时,就是一种再借用。那么随后,裸指针就可以对目标内存进行操作,当再借用结束时,发生的事情跟正常的再借用结束也没有区别。但问题是,再借用何时结束?当你再次开始使用原始引用时,可能是过期的好时机。否则,这就不是一个很好的嵌套栈。

但是没这么简单,我们可以将原始指针转换为引用,并且你可以复制裸指针。如果发生了以下转换 &mut -> *mut -> &mut -> *mut,然后去访问第一个 *mut,这时候的栈借用是如何工作的?

这就是为什么事情变得复杂。事实上,它们确实如此,因为栈借用试图变得更加宽松,让更多不安全的代码按照我们期望的方式工作。因此,我们需要在 miri 下运行代码来尝试发现编译器无法发现的错误。

正因为这种情况,miri 提供了试验性的模式: -Zmiri-tag-raw-pointers。可以通过环境的方式来开启该模式:

1
MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri test

如果是 Windows,你需要设置全局变量:

1
2
$env:MIRIFLAGS="-Zmiri-tag-raw-pointers"
cargo +nightly-2022-01-21 miri test

管理借用栈

因为之前的问题,使用裸指针,应该遵守一个原则:一旦开始使用裸指针,就要尝试着只使用它。

这使得意外失去裸指针访问内存的“权限”的可能性尽可能小。

实际上这样的规则在两个方面过于简单化了:

  1. 安全指针通常断言的属性不仅仅是别名:已分配的内存,内存对齐,足够大以容纳指向对象的类型,对象已正确初始化等。因此,在状态可疑时过度地使用它们更加危险。
  2. 即使你仅限于使用原始指针,也不能随意别名任何内存。指针在概念上与特定的“分配“相关联(可以是栈上的局部变量等粒度较小的对象),不应该从一个分配中获取指针,然后偏移它,并访问另一个分配中的内存。如果允许这样做,就会随处出现微小而难以捉摸的错误。这也是“指针只是整数”观点有问题的原因之一。

现在,我们依然希望在接口中使用安全的引用去构建一个安全的抽象,例如在函数参数中使用引用而不是裸指针,这样我们的用户就可以在安全的rust下调用它们。

所以我们要做的是:

  1. 在开始时,将输入参数中的引用转换成裸指针
  2. 在函数体中只使用裸指针
  3. 返回之前,将裸指针转换成安全的指针

但是由于我们这里数据结构中的字段都是私有的,无需暴露给用户,因此不用这么麻烦,直接使用裸指针即可。

事实上,我们所犯的重大错误的一部分就是继续使用BoxBox有一个特殊的地方,它会告诉编译器:“嘿,这很像 &mut ,因为它唯一拥有该指针”。

但是我们保留在列表末尾的裸指针指向一个Box,因此每当我们正常访问Box时,我们可能都会使该裸指针的“再借用”无效!

5.5 测试借用栈

总结和回顾一下上一小节的内容:

  • Rust 通过借用栈来处理再借用(reborrow)
  • 只有栈顶的元素是处于 live 状态的( 被借用 )
  • 当访问栈顶下面的元素时,该元素会变为 live,而栈顶元素会被弹出( pop )
  • 从借用栈中弹出的元素无法再被借用
  • 借用检查器会保证我们的安全代码遵守以上规则
  • Miri 可以在一定程度上保证裸指针在运行时也遵循以上规则

现在,尝试编写一些糟糕的代码,并让miri检测出来。通过大量的例子来尝试看看我们的思维模型是否有意义,并尝试对栈借用有一个直观的感受。

在实际代码中捕捉未定义的行为是一件棘手的事情。毕竟,你正在处理编译器字面上假设不会发生的情况。

如果我们足够幸运的话,写出来的代码是可以“正常运行的“,但是一旦编译器聪明一点或者你修改了某处代码,那这些代码可能会立刻化身为一颗安静的定时炸弹。当然,如果你还是足够幸运,那程序会发生崩溃,你也就可以捕获和处理相应的错误。但是如果你不幸运,整件事情就会以奇怪和令人费解的方式被破坏

Miri 尝试通过获取 rustc 最原始且未优化的程序视图并跟踪其解释时的额外状态来解决此问题。这是一种相当确定性和稳健的方法,但它永远不会完美。你的测试程序实际上需要执行带有该未定义行为(UB)的操作,并且对于足够大的程序,很容易引入各种不确定性(比如HashMap默认使用随机数生成器,random number generator,RNG)。

我们永远不能将 miri 批准我们的程序执行视为绝对确定的声明,认为miri通过就不存在 UB,这是不对的。 miri 也有可能认为某些东西是 UB,但实际上并非如此。也就是说,只能把miri当成一个参考。但如果我们对事物如何运作有一个提前的预期,并且 miri 似乎同意我们的观点,那么这是一个好兆头,表明我们走在正确的轨道上。

基本借用

之前这段代码被编译器检查出错误了:

1
2
3
4
5
6
7
8
9
let mut data = 10;
let ref1 = &mut data;
let ref2 = &mut *ref1;

// ORDER SWAPPED!
*ref1 += 1;
*ref2 += 2;

println!("{}", data);

让我们看看将 ref2 替换为 *mut 时会发生什么:

1
2
3
4
5
6
7
8
9
10
11
unsafe {
let mut data = 10;
let ref1 = &mut data;
let ptr2 = ref1 as *mut _;

// ORDER SWAPPED!
*ref1 += 1;
*ptr2 += 2;

println!("{}", data);
}

程序正常运行,输出13,产生了我们预期的结果。

现在让我们看看 miri(严格模式下)是如何看待它的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri run

Finished dev [unoptimized + debuginfo] target(s) in 0.00s
Running cargo-miri.exe target\miri

error: Undefined Behavior: no item granting read access
to tag <untagged> at alloc748 found in borrow stack.

--> src\main.rs:9:9
|
9 | *ptr2 += 2;
| ^^^^^^^^^^ no item granting read access to tag <untagged>
| at alloc748 found in borrow stack.
|
= help: this indicates a potential bug in the program:
it performed an invalid operation, but the rules it
violated are still experimental

错误信息表明在借用栈(borrow stack)中找不到授予对标签为 <untagged>alloc748 进行读取访问的项目。这非常好,我们关于事物如何工作的直观模型得到了支持:尽管编译器无法为我们捕获问题,但 miri 做到了。

让我们尝试一些更复杂的代码,之前提到的 &mut -> *mut -> &mut -> *mut 情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
unsafe {
let mut data = 10;
let ref1 = &mut data;
let ptr2 = ref1 as *mut _;
let ref3 = &mut *ptr2;
let ptr4 = ref3 as *mut _;

// Access the first raw pointer first
*ptr2 += 2;

// Then access things in "borrow stack" order
*ptr4 += 4;
*ref3 += 3;
*ptr2 += 2;
*ref1 += 1;

println!("{}", data);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
cargo run
22

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri run

error: Undefined Behavior: no item granting read access
to tag <1621> at alloc748 found in borrow stack.

--> src\main.rs:13:5
|
13 | *ptr4 += 4;
| ^^^^^^^^^^ no item granting read access to tag <1621>
| at alloc748 found in borrow stack.
|

在严格模式下,miri 可以“区分”两个原始指针,并使用第二个指针会它上面的指针无效。让我们看看当我们删除第一次使用时一切是否正常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
unsafe {
let mut data = 10;
let ref1 = &mut data;
let ptr2 = ref1 as *mut _;
let ref3 = &mut *ptr2;
let ptr4 = ref3 as *mut _;

// Access things in "borrow stack" order
*ptr4 += 4;
*ref3 += 3;
*ptr2 += 2;
*ref1 += 1;

println!("{}", data);
}
1
2
3
4
5
cargo run
20

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri run
20

这次就没有问题了。

测试数组

让我们使用裸指针,并用指针偏移量( addsub )弄乱一些数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
unsafe {
let mut data = [0; 10];
let ref1_at_0 = &mut data[0]; // Reference to 0th element
let ptr2_at_0 = ref1_at_0 as *mut i32; // Ptr to 0th element
let ptr3_at_1 = ptr2_at_0.add(1); // Ptr to 1st element

*ptr3_at_1 += 3;
*ptr2_at_0 += 2;
*ref1_at_0 += 1;

// Should be [3, 3, 0, ...]
println!("{:?}", &data[..]);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
$ cargo run

[3, 3, 0, 0, 0, 0, 0, 0, 0, 0]

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri run

error: Undefined Behavior: no item granting read access
to tag <1619> at alloc748+0x4 found in borrow stack.
--> src\main.rs:8:5
|
8 | *ptr3_at_1 += 3;
| ^^^^^^^^^^^^^^^ no item granting read access to tag <1619>
| at alloc748+0x4 found in borrow stack.

我们命名按照借用栈的方式来完美使用了,为何 miri 还是提示了 UB 风险?当我们去 ptr -> ptr 时,会发生什么奇怪的事情吗?如果我们只是复制指针,使它们都转到相同的位置会怎么样:

1
2
3
4
5
6
7
8
9
10
11
12
13
unsafe {
let mut data = [0; 10];
let ref1_at_0 = &mut data[0]; // Reference to 0th element
let ptr2_at_0 = ref1_at_0 as *mut i32; // Ptr to 0th element
let ptr3_at_0 = ptr2_at_0; // Ptr to 0th element

*ptr3_at_0 += 3;
*ptr2_at_0 += 2;
*ref1_at_0 += 1;

// Should be [6, 0, 0, ...]
println!("{:?}", &data[..]);
}
1
2
3
4
5
cargo run
[6, 0, 0, 0, 0, 0, 0, 0, 0, 0]

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri run
[6, 0, 0, 0, 0, 0, 0, 0, 0, 0]

顺利通过。我们试试更加复杂的,但是仍指向同一个位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
unsafe {
let mut data = [0; 10];
let ref1_at_0 = &mut data[0]; // Reference to 0th element
let ptr2_at_0 = ref1_at_0 as *mut i32; // Ptr to 0th element
let ptr3_at_0 = ptr2_at_0; // Ptr to 0th element
let ptr4_at_0 = ptr2_at_0.add(0); // Ptr to 0th element
let ptr5_at_0 = ptr3_at_0.add(1).sub(1); // Ptr to 0th element

// An absolute jumbled hash of ptr usages
*ptr3_at_0 += 3;
*ptr2_at_0 += 2;
*ptr4_at_0 += 4;
*ptr5_at_0 += 5;
*ptr3_at_0 += 3;
*ptr2_at_0 += 2;
*ref1_at_0 += 1;

// Should be [20, 0, 0, ...]
println!("{:?}", &data[..]);
}
1
2
3
4
5
cargo run
[20, 0, 0, 0, 0, 0, 0, 0, 0, 0]

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri run
[20, 0, 0, 0, 0, 0, 0, 0, 0, 0]

通过。当涉及到从裸指针派生其它裸指针时,miri实际上更加宽松。它们都共享相同的“借用”(miri称之为标签)。

当代码足够简单时,编译器有可能介入跟踪所有派生的裸指针,并尽可能去优化它们。但是这套规则比引用的那套脆弱得多。

那么问题在哪里呢?

我们发现 ref1_at_0 仅借用data的第一个元素。rust允许一些复合类型的分解借用,例如一个结构体,它的多个字段可以被分开借用,来试试这里的数组可不可以:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
unsafe {
let mut data = [0; 10];
let ref1_at_0 = &mut data[0]; // Reference to 0th element
let ref2_at_1 = &mut data[1]; // Reference to 1th element
let ptr3_at_0 = ref1_at_0 as *mut i32; // Ptr to 0th element
let ptr4_at_1 = ref2_at_1 as *mut i32; // Ptr to 1th element

*ptr4_at_1 += 4;
*ptr3_at_0 += 3;
*ref2_at_1 += 2;
*ref1_at_0 += 1;

// Should be [3, 3, 0, ...]
println!("{:?}", &data[..]);
}
1
2
3
4
5
6
7
8
9
10
11
error[E0499]: cannot borrow `data[_]` as mutable more than once at a time
--> src\main.rs:5:25
|
4 | let ref1_at_0 = &mut data[0]; // Reference to 0th element
| ------------ first mutable borrow occurs here
5 | let ref2_at_1 = &mut data[1]; // Reference to 1th element
| ^^^^^^^^^^^^ second mutable borrow occurs here
6 | let ptr3_at_0 = ref1_at_0 as *mut i32; // Ptr to 0th element
| --------- first borrow later used here
|
= help: consider using `.split_at_mut(position)` or similar method to obtain two mutable non-overlapping sub-slices

看来不行。其实在调用不安全函数或方法中我们介绍过,rust编译器不会跟踪数组索引来证明这些引用是不相交的,它只知道我们同时引用了它两次。

可以使用split_at_mut来将一个数组分成多个部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
unsafe {
let mut data = [0; 10];

let slice1 = &mut data[..];
let (slice2_at_0, slice3_at_1) = slice1.split_at_mut(1);

let ref4_at_0 = &mut slice2_at_0[0]; // Reference to 0th element
let ref5_at_1 = &mut slice3_at_1[0]; // Reference to 1th element
let ptr6_at_0 = ref4_at_0 as *mut i32; // Ptr to 0th element
let ptr7_at_1 = ref5_at_1 as *mut i32; // Ptr to 1th element

*ptr7_at_1 += 7;
*ptr6_at_0 += 6;
*ref5_at_1 += 5;
*ref4_at_0 += 4;

// Should be [10, 12, 0, ...]
println!("{:?}", &data[..]);
}
1
2
3
4
5
cargo run
[10, 12, 0, 0, 0, 0, 0, 0, 0, 0]

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri run
[10, 12, 0, 0, 0, 0, 0, 0, 0, 0]

将数组切分成两个部分后,代码就成功了。像 split_at_mut 这样的操作被允许说明借用不仅仅是一个栈,更像是一个树,因为我们可以将一个大的借用分解成一堆互不重叠的小借用,而且一切都能正常工作。

如果我们直接把切片变成指针呢?该指针可以访问整个切片吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
unsafe {
let mut data = [0; 10];

let slice1_all = &mut data[..]; // Slice for the entire array
let ptr2_all = slice1_all.as_mut_ptr(); // Pointer for the entire array

let ptr3_at_0 = ptr2_all; // Pointer to 0th elem (the same)
let ptr4_at_1 = ptr2_all.add(1); // Pointer to 1th elem
let ref5_at_0 = &mut *ptr3_at_0; // Reference to 0th elem
let ref6_at_1 = &mut *ptr4_at_1; // Reference to 1th elem

*ref6_at_1 += 6;
*ref5_at_0 += 5;
*ptr4_at_1 += 4;
*ptr3_at_0 += 3;

// Just for fun, modify all the elements in a loop
// (Could use any of the raw pointers for this, they share a borrow!)
for idx in 0..10 {
*ptr2_all.add(idx) += idx;
}

// Safe version of this same code for fun
for (idx, elem_ref) in slice1_all.iter_mut().enumerate() {
*elem_ref += idx;
}

// Should be [8, 12, 4, 6, 8, 10, 12, 14, 16, 18]
println!("{:?}", &data[..]);
}
1
2
3
4
5
cargo run
[8, 12, 4, 6, 8, 10, 12, 14, 16, 18]

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri run
[8, 12, 4, 6, 8, 10, 12, 14, 16, 18]

看起来,指针不仅仅是整数:它们具有与其关联的内存范围,而使用rust,我们可以缩小该范围。

测试不可变引用

在之前的例子中,我们使用的都是可变引用,而rust中还有不可变引用(共享引用)。那么它将如何工作呢?我们已经看到裸指针可以自由复制,并且我们可以通过说它们“共享”单个引用来处理这个问题。也许我们以同样的方式看待共享引用?

由于 println 会自动对待打印的目标值进行 ref/deref 等操作,因此为了保证测试的正确性,我们将其放入一个函数中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
fn opaque_read(val: &i32) {
println!("{}", val);
}

unsafe {
let mut data = 10;
let mref1 = &mut data;
let sref2 = &mref1;
let sref3 = sref2;
let sref4 = &*sref2;

// Random hash of shared reference reads
opaque_read(sref3);
opaque_read(sref2);
opaque_read(sref4);
opaque_read(sref2);
opaque_read(sref3);

*mref1 += 1;

opaque_read(&data);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
cargo run

warning: unnecessary `unsafe` block
--> src\main.rs:6:1
|
6 | unsafe {
| ^^^^^^ unnecessary `unsafe` block
|
= note: `#[warn(unused_unsafe)]` on by default

warning: `miri-sandbox` (bin "miri-sandbox") generated 1 warning

10
10
10
10
10
11

虽然这里没有使用裸指针,但是可以看到对于不可变引用而言,上面的使用方式不存在任何问题。下面来增加一些裸指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn opaque_read(val: &i32) {
println!("{}", val);
}

unsafe {
let mut data = 10;
let mref1 = &mut data;
let ptr2 = mref1 as *mut i32;
let sref3 = &*mref1;
let ptr4 = sref3 as *mut i32;

*ptr4 += 4;
opaque_read(sref3);
*ptr2 += 2;
*mref1 += 1;

opaque_read(&data);
}
1
2
3
4
5
6
7
cargo run

error[E0606]: casting `&i32` as `*mut i32` is invalid
--> src\main.rs:11:16
|
11 | let ptr4 = sref3 as *mut i32;
| ^^^^^^^^^^^^^^^^^

可以看出,我们无法将一个不可变的引用转换成可变的裸指针。我们可以先将不可变引用强制转换为不可变的裸指针:

1
let ptr4 = sref3 as *const i32 as *mut i32;

rust的类型转换系统很棒。*const 类型几乎是一个相当无用的类型,只真正存在于描述C API并模糊地建议正确使用(事实确实如此),如上,先将不可变引用转换成不可变的裸指针,然后再转换成可变的裸指针。

1
2
3
4
cargo run

14
17

好吧,没问题。看看miri怎么说:

1
2
3
4
5
6
7
8
9
MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri run

error: Undefined Behavior: no item granting write access to
tag <1621> at alloc742 found in borrow stack.
--> src\main.rs:13:5
|
13 | *ptr4 += 4;
| ^^^^^^^^^^ no item granting write access to tag <1621>
| at alloc742 found in borrow stack.

虽然我们可以通过双重强制转换来绕过编译器的检查,但它实际上并没有允许这个操作。当我们获取共享引用时,我们承诺不修改该值。

这很重要,因为这意味着当不可变借用从借用栈中弹出时,其下方的可变指针可以假设内存没有更改,并且可变指针可以假设它们写入的最后一个值仍然存在!

用一句话总结,那就是:一旦不可变引用位于借用栈上,推入其顶部的所有内容(它后面的指针)都仅具有读取权限。

但是我们可以这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn opaque_read(val: &i32) {
println!("{}", val);
}

unsafe {
let mut data = 10;
let mref1 = &mut data;
let ptr2 = mref1 as *mut i32;
let sref3 = &*mref1;
let ptr4 = sref3 as *const i32 as *mut i32;

opaque_read(&*ptr4);
opaque_read(sref3);
*ptr2 += 2;
*mref1 += 1;

opaque_read(&data);
}

可以看到,我们仍然可以创建一个可变的裸指针,只要不去使用写操作,而是只使用读操作。

1
2
3
4
5
6
7
8
9
cargo run
10
10
13

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri run
10
10
13

再来检查下不可变的引用是否可以像平时一样正常弹出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn opaque_read(val: &i32) {
println!("{}", val);
}

unsafe {
let mut data = 10;
let mref1 = &mut data;
let ptr2 = mref1 as *mut i32;
let sref3 = &*mref1;

*ptr2 += 2;
opaque_read(sref3); // Read in the wrong order?
*mref1 += 1;

opaque_read(&data);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
cargo run
12
13

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri run

error: Undefined Behavior: trying to reborrow for SharedReadOnly
at alloc742, but parent tag <1620> does not have an appropriate
item in the borrow stack

--> src\main.rs:13:17
|
13 | opaque_read(sref3); // Read in the wrong order?
| ^^^^^ trying to reborrow for SharedReadOnly
| at alloc742, but parent tag <1620>
| does not have an appropriate item
| in the borrow stack
|

我们这次获得了一个相当具体的 miri 提示,而不是之前的某个 tag 。这其实是有道理的,因为一旦存在任何共享引用,基本上其他所有内容都只是一大堆共享引用,因此无需区分它们中的任何一个!

测试内部可变性

之前我们使用内部可变性创建的链表很糟糕,与 RefCell 类似的还有 Cell

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::cell::Cell;

unsafe {
let mut data = Cell::new(10);
let mref1 = &mut data;
let ptr2 = mref1 as *mut Cell<i32>;
let sref3 = &*mref1;

sref3.set(sref3.get() + 3);
(*ptr2).set((*ptr2).get() + 2);
mref1.set(mref1.get() + 1);

println!("{}", data.get());
}

看看miri怎么说:

1
2
3
4
5
cargo run
16

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri run
16

等等,真的吗?没有报错。Cell到底是什么?

1
2
3
pub struct Cell<T: ?Sized> {
value: UnsafeCell<T>,
}

以上是标准库中的 Cell 源码,可以看到里面有一个 UnsafeCell,点进去看看:

1
2
3
4
5
6
#[lang = "unsafe_cell"]
#[stable(feature = "rust1", since = "1.0.0")]
#[repr(transparent)]
pub struct UnsafeCell<T: ?Sized> {
value: T,
}

这是它的源码定义,在标准库文档中这样写道:

The core primitive for interior mutability in Rust. Rust 中内部可变性的核心原语。

If you have a reference &T, then normally in Rust the compiler performs optimizations based on the knowledge that &T points to immutable data. Mutating that data, for example through an alias or by transmuting an &T into an &mut T, is considered undefined behavior. UnsafeCell<T> opts-out of the immutability guarantee for &T: a shared reference &UnsafeCell<T> may point to data that is being mutated. This is called “interior mutability”. 如果您有一个不可变引用 &T ,那么通常在 Rust 中,编译器会根据 &T 指向不可变数据的事实来执行优化。改变该数据,例如通过别名或将 &T 转换为 &mut T ,被视为未定义行为。 UnsafeCell<T> 选择移除 &T 的不变性保证:不可变引用 &UnsafeCell<T> 可能指向正在改变的数据。这称为“内部可变性”。

All other types that allow internal mutability, such as Cell and RefCell, internally use UnsafeCell to wrap their data. 所有其他允许内部可变性的类型(例如 Cell<T>RefCell<T> )在内部使用 UnsafeCell 来包装其数据。

让我们看看使用UnsafeCell如何会让miri怎么样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use std::cell::UnsafeCell;

fn opaque_read(val: &i32) {
println!("{}", val);
}

unsafe {
let mut data: UnsafeCell<i32> = UnsafeCell::new(10);
let mref1: &mut i32 = data.get_mut(); // Get a mutable ref to the contents
let ptr2: *mut i32 = mref1 as *mut i32;
let sref3: &i32 = &*ptr2;

*ptr2 += 2;
opaque_read(sref3);
*mref1 += 1;

println!("{}", *data.get());
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
cargo run
12
13

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri run

error: Undefined Behavior: trying to reborrow for SharedReadOnly
at alloc748, but parent tag <1629> does not have an appropriate
item in the borrow stack

--> src\main.rs:15:17
|
15 | opaque_read(sref3);
| ^^^^^ trying to reborrow for SharedReadOnly
| at alloc748, but parent tag <1629> does
| not have an appropriate item in the
| borrow stack
|

好吧,我们做到了,但后来我们通过使用 get_mut ,它可以窥视 UnsafeCell 内部并获得正确的 &mut i32 。如果编译器必须假设 &mut i32 可以查看 UnsafeCell 内部,那么它根本无法对别名做出任何假设,换句话说,如果编译器不能排除 &mut i32 引用可能访问到 UnsafeCell 内部的情况,那么编译器就无法对别名(aliasing)进行任何假设。这是因为 UnsafeCell 允许对内部数据进行非安全的操作,可能导致别名问题,进而破坏 Rust 提供的内存安全保证。

所以我们需要做的就是将 UnsafeCell 保留在我们的指针类型中,以便编译器理解我们在做什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use std::cell::UnsafeCell;

fn opaque_read(val: &i32) {
println!("{}", val);
}

unsafe {
let mut data = UnsafeCell::new(10);
let mref1 = &mut data; // Mutable ref to the *outside*
let ptr2 = mref1.get(); // Get a raw pointer to the insides
let sref3 = &*mref1; // Get a shared ref to the *outside*

*ptr2 += 2; // Mutate with the raw pointer
opaque_read(&*sref3.get()); // Read from the shared ref
*sref3.get() += 3; // Write through the shared ref
*mref1.get() += 1; // Mutate with the mutable ref

println!("{}", *data.get());
}
1
2
3
4
5
6
7
cargo run
12
16

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri run
12
16

很好。但是还有一个问题,代码顺序不对:我们首先创建了 ptr2,然后从可变指针创建了 sref3。然后我们在共享指针sref3之前使用裸指针,这一切似乎都是错误的。

我们可以做出以下假设:

  • Miri 并不完美,这实际上仍然是未定义行为
  • 我们的简化模型实际上过于简单化了

我会选择第二个。但是为了安全起见,让我们制作一个在简化的借用栈模型中绝对无懈可击的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use std::cell::UnsafeCell;

fn opaque_read(val: &i32) {
println!("{}", val);
}

unsafe {
let mut data = UnsafeCell::new(10);
let mref1 = &mut data;
// These two are swapped so the borrows are *definitely* totally stacked
let sref2 = &*mref1;
// Derive the ptr from the shared ref to be super safe!
let ptr3 = sref2.get();

*ptr3 += 3;
opaque_read(&*sref2.get());
*sref2.get() += 2;
*mref1.get() += 1;

println!("{}", *data.get());
}
1
2
3
4
5
6
7
cargo run
13
16

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri run
13
16

我们的第一个实现实际上可能是正确的一个原因是,如果您认真考虑一下,就别名而言, &UnsafeCell<T> 确实与 *mut T 没有什么不同。

因此,从某种意义上说,我们只是创建了两个裸指针并像平常一样互换使用它们。两者都是从可变引用派生的有点粗略,所以也许第二个的创建仍然应该从借用栈中弹出第一个,但这并不是真正必要的,因为我们实际上并没有访问可变引用的内容,只是复制其地址。

let sref2 = &*mref1 是一件棘手的事情。从语法上看,我们似乎在取消引用它,但取消引用本身实际上并不是一件事?考虑 &my_tuple.0 :实际上并没有对 my_tuple.0 执行任何操作,只是使用它们来引用内存中的位置并将 & 在它前面写着“不要加载这个,只需写下地址”。

&* 是同一件事: * 只是说“嘿,让我们谈谈这个指针指向的位置”,而 & 只是说“现在写该地址下来”。这当然与原始指针的值相同。但是指针的类型已经改变了,因为类型不同。

也就是说,如果执行 &** 那么你实际上是在使用第一个 * 加载一个值。

实际上这就是我们在之前在通过位置和值理解内存模型中提到的位置和值的概念。

测试Box

还记得为什么我们引入借用栈这个概念吗?原因就在于 Box 和裸指针混合使用时出了问题。Box有点像 &mut ,因为它声明它所指向的内存的唯一所有权。

1
2
3
4
5
6
7
8
9
10
unsafe {
let mut data = Box::new(10);
let ptr1 = (&mut *data) as *mut i32;

*data += 10;
*ptr1 += 1;

// Should be 21
println!("{}", data);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
cargo run
21

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri run

error: Undefined Behavior: no item granting read access
to tag <1707> at alloc763 found in borrow stack.

--> src\main.rs:7:5
|
7 | *ptr1 += 1;
| ^^^^^^^^^^ no item granting read access to tag <1707>
| at alloc763 found in borrow stack.

到现在,相信你很容易看出这样并不符合借用栈规则,修改代码:

1
2
3
4
5
6
7
8
9
10
unsafe {
let mut data = Box::new(10);
let ptr1 = (&mut *data) as *mut i32;

*ptr1 += 1;
*data += 10;

// Should be 21
println!("{}", data);
}
1
2
3
4
5
cargo run
21

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri run
21

很好,我们终于结束了对于借用栈的讨论与思考。但是,该如何使用 Box 来解决栈借用的问题?当然,我们可以像之前的测试例子一样写一些玩具代码,但是在实际链表中中,将 Box 存储在某个地方,然后长时间持有一个裸指针才是经常遇到的。

为了回答这个问题,我们将回到我们真正的使命:编写链表。

5.6 数据布局与基本操作

总结一下,在之前部分中,将安全的指针 &&mutBox 跟不安全的裸指针 *mut*const 混用是造成未定义行为的根源之一,原因是安全指针会引入额外的约束,但是裸指针并不会遵守这些约束。

我们将很快删除之前写的大部分内容,因为我们已经在第一次尝试中讨论了设计,并且除了我们如何将安全和不安全指针混合在一起之外,我们所做的一切基本上都是正确的。

新的数据布局

回忆一下旧的布局:

1
2
3
4
5
6
7
8
9
10
11
pub struct List<T> {
head: Link<T>,
tail: *mut Node<T>, // INNOCENT AND KIND
}

type Link<T> = Option<Box<Node<T>>>; // THE REAL EVIL

struct Node<T> {
elem: T,
next: Link<T>,
}

这是我们的新布局:

1
2
3
4
5
6
7
8
9
10
11
pub struct List<T> {
head: Link<T>,
tail: *mut Node<T>,
}

type Link<T> = *mut Node<T>; // MUCH BETTER

struct Node<T> {
elem: T,
next: Link<T>,
}

当我们使用原始指针时,Option不再那么有用,因此我们不再使用它。在后面的部分中,我们将讨论 NonNull 类型,但现在不用担心。

基本操作

List::new基本上是一样的。

1
2
3
4
5
6
7
use std::ptr;

impl<T> List<T> {
pub fn new() -> Self {
List { head: ptr::null_mut(), tail: ptr::null_mut() }
}
}

push基本上也是…

1
2
pub fn push(&mut self, elem: T) {
let mut new_tail = Box::new(

等等,我们已经没有Box了,现在如何分配内存?

我们可以使用 std::alloc::alloc ,但这就像把武士刀带进厨房一样。它可以完成这件事,但有点矫枉过正和笨拙。

我们想要Box,但是没有。一种疯狂但也许可行的方案是这样:

1
2
3
4
5
struct Node<T> {
elem: T,
real_next: Option<Box<Node<T>>>,
next: *mut Node<T>,
}

先创建一个 Box ,并使用一个裸指针指向 Box 中的 Node,然后就一直使用该裸指针直到我们处理完 Node 且可以销毁它之时。最后,可以将 Boxreal_nexttake 出来,并 drop 掉。这符合我们非常简化的借用栈模型。

但这看起来不够优雅,这不是关于RcRefCell的章节,我们不会再玩这个游戏了,让我们搞些简单干净的东西。

我们将使用非常方便的Box::into_raw函数

1
pub fn into_raw(b: Box<T, A>) -> *mut T

消费掉 Box ,返回其包装的裸指针,即 *mut T 类型的指针。该方法会释放 Box<T> 指向的堆内存,并返回一个指向相同内存位置的裸指针。

调用这个函数后,调用者需要负责之前由Box管理的内存。特别是调用者应该正确销毁T并释放内存,考虑到Box使用的内存布局。最简单的方法是使用Box::from_raw函数将裸指针转换回Box,让Box的析构函数执行清理操作。

注意:这是一个关联函数,这意味着您必须这样使用 Box::into_raw(b) 而不是 b.into_raw() 。这样就不会与内部类型上的方法发生冲突。

使用 Box::from_raw 将裸指针转换回 Box 以进行自动清理:

1
2
3
let x = Box::new(String::from("Hello"));
let ptr = Box::into_raw(x);
let x = unsafe { Box::from_raw(ptr) };

当然也可以通过显式运行析构函数并释放内存来手动清理:

1
2
3
4
5
6
7
8
9
use std::alloc::{dealloc, Layout};
use std::ptr;

let x = Box::new(String::from("Hello"));
let p = Box::into_raw(x);
unsafe {
ptr::drop_in_place(p);
dealloc(p as *mut u8, Layout::new::<String>());
}

很好,这看起来确实是为我们的用例设计的。它也符合我们试图遵循的规则:从安全的东西开始,变成裸指针,然后只在最后(当我们想要删除它时)转换回安全的东西。

现在,我们就可以到处使用裸指针,也无需再注意unsafe块的范围,因为内部现在一切都不安全了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pub fn push(&mut self, elem: T) {
unsafe {
// Immediately convert the Box into a raw pointer
let new_tail = Box::into_raw(Box::new(Node {
elem: elem,
next: ptr::null_mut(),
}));

if !self.tail.is_null() {
(*self.tail).next = new_tail;
} else {
self.head = new_tail;
}

self.tail = new_tail;
}
}

现在我们坚持使用原始指针,代码实际上看起来简洁多了!

继续实现 pop,它跟之前区别不大,但是要记住必须使用 Box::from_raw 来清理堆内存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pub fn pop(&mut self) -> Option<T> {
unsafe {
if self.head.is_null() {
None
} else {
// RISE FROM THE GRAVE
let head = Box::from_raw(self.head);
self.head = head.next;

if self.head.is_null() {
self.tail = ptr::null_mut();
}

Some(head.elem)
}
}
}

告诉 takemap,我们回不去了。只不过在这里我们得手动检查和设置 null

再实现下析构器,直接循环调用 pop 即可:

1
2
3
4
5
impl<T> Drop for List<T> {
fn drop(&mut self) {
while let Some(_) = self.pop() { }
}
}

它非常简单,好了,下面就是最重要的环节——测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#[cfg(test)]
mod test {
use super::List;
#[test]
fn basics() {
let mut list = List::new();

// Check empty list behaves right
assert_eq!(list.pop(), None);

// Populate list
list.push(1);
list.push(2);
list.push(3);

// Check normal removal
assert_eq!(list.pop(), Some(1));
assert_eq!(list.pop(), Some(2));

// Push some more just to make sure nothing's corrupted
list.push(4);
list.push(5);

// Check normal removal
assert_eq!(list.pop(), Some(3));
assert_eq!(list.pop(), Some(4));

// Check exhaustion
assert_eq!(list.pop(), Some(5));
assert_eq!(list.pop(), None);

// Check the exhaustion case fixed the pointer right
list.push(6);
list.push(7);

// Check normal removal
assert_eq!(list.pop(), Some(6));
assert_eq!(list.pop(), Some(7));
assert_eq!(list.pop(), None);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cargo test

running 12 tests
test fifth::test::basics ... ok
test first::test::basics ... ok
test fourth::test::basics ... ok
test fourth::test::peek ... ok
test second::test::basics ... ok
test fourth::test::into_iter ... ok
test second::test::into_iter ... ok
test second::test::iter ... ok
test second::test::iter_mut ... ok
test second::test::peek ... ok
test third::test::basics ... ok
test third::test::iter ... ok

test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured

别高兴得太早,miri会接受吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri test

running 12 tests
test fifth::test::basics ... ok
test first::test::basics ... ok
test fourth::test::basics ... ok
test fourth::test::peek ... ok
test second::test::basics ... ok
test fourth::test::into_iter ... ok
test second::test::into_iter ... ok
test second::test::iter ... ok
test second::test::iter_mut ... ok
test second::test::peek ... ok
test third::test::basics ... ok
test third::test::iter ... ok

test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured

太棒了!但是严谨起见,还是要说明:未能找到未定义的行为并不能证明它不会引发问题,因此我们可以将其称之为100%机器验证通过。

5.7 更多的操作

现在 pushpop 已经写好了,剩下的操作与之前也没太大区别。只有会改变链表长度的操作才会使用尾指针。

但是所有的东西都是不安全的指针,我们需要重写代码来使用它们。既然我们要接触所有代码,不妨借此机会确保我们没有遗漏任何东西。

让我们从栈的实现中复制代码:

1
2
3
4
5
6
7
8
9
10
11
// ...

pub struct IntoIter<T>(List<T>);

pub struct Iter<'a, T> {
next: Option<&'a Node<T>>,
}

pub struct IterMut<'a, T> {
next: Option<&'a mut Node<T>>,
}

IntoIter看起来没问题,但是 IterIterMut 打破了我们不再在类型中使用安全指针的简单规则。为了安全起见,我们将它们更改为使用裸指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pub struct IntoIter<T>(List<T>);

pub struct Iter<'a, T> {
next: *mut Node<T>,
}

pub struct IterMut<'a, T> {
next: *mut Node<T>,
}

impl<T> List<T> {
pub fn into_iter(self) -> IntoIter<T> {
IntoIter(self)
}

pub fn iter(&self) -> Iter<'_, T> {
Iter { next: self.head }
}

pub fn iter_mut(&mut self) -> IterMut<'_, T> {
IterMut { next: self.head }
}
}

看起来不错,编译一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
error[E0392]: parameter `'a` is never used
--> src\fifth.rs:68:17
|
68 | pub struct Iter<'a, T> {
| ^^ unused parameter
|
= help: consider removing `'a`, referring to it in a field, or using a marker such as `PhantomData`

error[E0392]: parameter `'a` is never used
--> src\fifth.rs:72:20
|
72 | pub struct IterMut<'a, T> {
| ^^ unused parameter
|
= help: consider removing `'a`, referring to it in a field, or using a marker such as `PhantomData`

编译器提示'a并没有被使用,要解决这个问题,可以将其移除,也可以使用PhantomData,它是什么?在深入Waker与Context的内部中我们曾经见过它,这次有必要来简单研究一下。

PhantomData是零大小类型(Zero-sized type),用于标记“表现得像”拥有 T 的事物。

在你的类型中添加一个 PhantomData<T> 字段,可以告诉编译器你的类型就好像它存储了 T 类型的值,即使事实并非如此。该信息在计算某些安全属性时使用,当然也可以让编译器不再给出 T 未被使用的警告或者错误。

有关如何使用 PhantomData<T> 的更深入说明,请参阅 Nomicon。==todo==

也许PhantomData最常见的用例是具有未使用的生命周期参数的结构,它们通常作为某些不安全代码的一部分。

我们可以沿着使用PhantomData这个方向走,但是目前还不是很必要,真正需要它的是双向链表,这不在我们本节考虑的范围。

继续使用引用也是不错的选择。能使用引用的原因是:我们可以创建一个迭代器,在其中使用安全引用,然后再丢弃迭代器。一旦迭代器被丢弃后,就可以继续使用 pushpop 了。

事实上,在迭代期间,我们还是需要解引用大量的裸指针,但是可以把引用看作裸指针的再借用。总之,尝试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
pub struct IntoIter<T>(List<T>);

pub struct Iter<'a, T> {
next: Option<&'a Node<T>>,
}

pub struct IterMut<'a, T> {
next: Option<&'a mut Node<T>>,
}

impl<T> List<T> {
pub fn into_iter(self) -> IntoIter<T> {
IntoIter(self)
}

pub fn iter(&self) -> Iter<'_, T> {
unsafe {
Iter { next: self.head.as_ref() }
}
}

pub fn iter_mut(&mut self) -> IterMut<'_, T> {
unsafe {
IterMut { next: self.head.as_mut() }
}
}
}

为了存储引用,这里使用 Option 来包裹,并通过 ptr::as_refptr::as_mut 来将裸指针转换成引用。

通常,我会尽量避免使用 as_ref 这类方法,因为它们在做一些不可思议的转换。但是上面却是极少数可以使用的场景之一。

这两个方法的使用往往会伴随很多警告,其中最有趣的是:

你必须强制遵循 Rust 的别名规则,因为返回的生命周期 'a 是任意选择的,并不一定反映数据的实际生命周期。特别是,当此引用存在时,指针指向的内存不得发生变化( UnsafeCell 内部除外)。

这正是我们在前面花费了很长篇幅讲的借用栈规则!我们肯定可以在这里遵循此规则,但是除此之外,还要考虑的一个问题是函数签名:

1
pub unsafe fn as_mut<'a>(self) -> Option<&'a mut T>

注意这里凭空出现的 'a ,这正是无界(不受约束)的生命周期,处理这个问题的方法是将它放在有界的地方,这通常意味着“尽快从函数返回它,以便函数签名限制它”。

让我们从栈链表中再复制一些代码过来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
impl<T> Iterator for IntoIter<T> {
type Item = T;
fn next(&mut self) -> Option<Self::Item> {
self.0.pop()
}
}

impl<'a, T> Iterator for Iter<'a, T> {
type Item = &'a T;

fn next(&mut self) -> Option<Self::Item> {
unsafe {
self.next.map(|node| {
self.next = node.next.as_ref();
&node.elem
})
}
}
}

impl<'a, T> Iterator for IterMut<'a, T> {
type Item = &'a mut T;

fn next(&mut self) -> Option<Self::Item> {
unsafe {
self.next.take().map(|node| {
self.next = node.next.as_mut();
&mut node.elem
})
}
}
}

并验证测试用例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
cargo test

running 15 tests
test fifth::test::basics ... ok
test fifth::test::into_iter ... ok
test fifth::test::iter ... ok
test fifth::test::iter_mut ... ok
test first::test::basics ... ok
test fourth::test::basics ... ok
test fourth::test::into_iter ... ok
test fourth::test::peek ... ok
test second::test::basics ... ok
test second::test::into_iter ... ok
test second::test::iter ... ok
test second::test::iter_mut ... ok
test second::test::peek ... ok
test third::test::iter ... ok
test third::test::basics ... ok

test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out;

MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri test

running 15 tests
test fifth::test::basics ... ok
test fifth::test::into_iter ... ok
test fifth::test::iter ... ok
test fifth::test::iter_mut ... ok
test first::test::basics ... ok
test fourth::test::basics ... ok
test fourth::test::into_iter ... ok
test fourth::test::peek ... ok
test second::test::basics ... ok
test second::test::into_iter ... ok
test second::test::iter ... ok
test second::test::iter_mut ... ok
test second::test::peek ... ok
test third::test::basics ... ok
test third::test::iter ... ok

test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

接下来,是 peekpeek_mut

1
2
3
4
5
6
7
8
9
10
11
pub fn peek(&self) -> Option<&T> {
unsafe {
self.head.as_ref()
}
}

pub fn peek_mut(&mut self) -> Option<&mut T> {
unsafe {
self.head.as_mut()
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
error[E0308]: mismatched types
--> src\fifth.rs:66:13
|
25 | impl<T> List<T> {
| - this type parameter
...
64 | pub fn peek(&self) -> Option<&T> {
| ---------- expected `Option<&T>`
| because of return type
65 | unsafe {
66 | self.head.as_ref()
| ^^^^^^^^^^^^^^^^^^ expected type parameter `T`,
| found struct `fifth::Node`
|
= note: expected enum `Option<&T>`
found enum `Option<&fifth::Node<T>>`

这个简单,使用map就可以了。

1
2
3
4
5
6
7
8
9
10
11
pub fn peek(&self) -> Option<&T> {
unsafe {
self.head.as_ref().map(|node| &node.elem)
}
}

pub fn peek_mut(&mut self) -> Option<&mut T> {
unsafe {
self.head.as_mut().map(|node| &mut node.elem)
}
}

我想我们可能会继续犯错误,所以我们要格外小心,并添加一个新的测试,就叫做“miri food”吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#[test]
fn miri_food() {
let mut list = List::new();

list.push(1);
list.push(2);
list.push(3);

assert!(list.pop() == Some(1));
list.push(4);
assert!(list.pop() == Some(2));
list.push(5);

assert!(list.peek() == Some(&3));
list.push(6);
list.peek_mut().map(|x| *x *= 10);
assert!(list.peek() == Some(&30));
assert!(list.pop() == Some(30));

for elem in list.iter_mut() {
*elem *= 100;
}

let mut iter = list.iter();
assert_eq!(iter.next(), Some(&400));
assert_eq!(iter.next(), Some(&500));
assert_eq!(iter.next(), Some(&600));
assert_eq!(iter.next(), None);
assert_eq!(iter.next(), None);

assert!(list.pop() == Some(400));
list.peek_mut().map(|x| *x *= 10);
assert!(list.peek() == Some(&5000));
list.push(7);

// Drop it on the ground and let the dtor exercise itself
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
cargo test

running 16 tests
test fifth::test::basics ... ok
test fifth::test::into_iter ... ok
test fifth::test::iter ... ok
test fifth::test::iter_mut ... ok
test fifth::test::miri_food ... ok
test first::test::basics ... ok
test fourth::test::basics ... ok
test fourth::test::into_iter ... ok
test fourth::test::peek ... ok
test second::test::into_iter ... ok
test second::test::basics ... ok
test second::test::iter_mut ... ok
test second::test::peek ... ok
test third::test::iter ... ok
test second::test::iter ... ok
test third::test::basics ... ok

test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out



MIRIFLAGS="-Zmiri-tag-raw-pointers" cargo +nightly-2022-01-21 miri test

running 16 tests
test fifth::test::basics ... ok
test fifth::test::into_iter ... ok
test fifth::test::iter ... ok
test fifth::test::iter_mut ... ok
test fifth::test::miri_food ... ok
test first::test::basics ... ok
test fourth::test::basics ... ok
test fourth::test::into_iter ... ok
test fourth::test::peek ... ok
test second::test::into_iter ... ok
test second::test::basics ... ok
test second::test::iter_mut ... ok
test second::test::peek ... ok
test third::test::iter ... ok
test second::test::iter ... ok
test third::test::basics ... ok

test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

非常好。总算完成了。

5.8 最终代码

好吧,通过一点点的不安全性,新的实现可以获得线性的性能提升,并且我们成功地重用了安全栈实现中的几乎所有逻辑!

除了要处理栈借用和miri的那部分之外,我们不必编写任何RcRefCell的东西。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
// in fifth.rs

use std::ptr;

pub struct List<T> {
head: Link<T>,
tail: *mut Node<T>,
}

type Link<T> = *mut Node<T>;

struct Node<T> {
elem: T,
next: Link<T>,
}

pub struct IntoIter<T>(List<T>);

pub struct Iter<'a, T> {
next: Option<&'a Node<T>>,
}

pub struct IterMut<'a, T> {
next: Option<&'a mut Node<T>>,
}

impl<T> List<T> {
pub fn new() -> Self {
List { head: ptr::null_mut(), tail: ptr::null_mut() }
}
pub fn push(&mut self, elem: T) {
unsafe {
let new_tail = Box::into_raw(Box::new(Node {
elem: elem,
next: ptr::null_mut(),
}));

if !self.tail.is_null() {
(*self.tail).next = new_tail;
} else {
self.head = new_tail;
}

self.tail = new_tail;
}
}
pub fn pop(&mut self) -> Option<T> {
unsafe {
if self.head.is_null() {
None
} else {
let head = Box::from_raw(self.head);
self.head = head.next;

if self.head.is_null() {
self.tail = ptr::null_mut();
}

Some(head.elem)
}
}
}

pub fn peek(&self) -> Option<&T> {
unsafe {
self.head.as_ref().map(|node| &node.elem)
}
}

pub fn peek_mut(&mut self) -> Option<&mut T> {
unsafe {
self.head.as_mut().map(|node| &mut node.elem)
}
}

pub fn into_iter(self) -> IntoIter<T> {
IntoIter(self)
}

pub fn iter(&self) -> Iter<'_, T> {
unsafe {
Iter { next: self.head.as_ref() }
}
}

pub fn iter_mut(&mut self) -> IterMut<'_, T> {
unsafe {
IterMut { next: self.head.as_mut() }
}
}
}

impl<T> Drop for List<T> {
fn drop(&mut self) {
while let Some(_) = self.pop() { }
}
}

impl<T> Iterator for IntoIter<T> {
type Item = T;
fn next(&mut self) -> Option<Self::Item> {
self.0.pop()
}
}

impl<'a, T> Iterator for Iter<'a, T> {
type Item = &'a T;

fn next(&mut self) -> Option<Self::Item> {
unsafe {
self.next.map(|node| {
self.next = node.next.as_ref();
&node.elem
})
}
}
}

impl<'a, T> Iterator for IterMut<'a, T> {
type Item = &'a mut T;

fn next(&mut self) -> Option<Self::Item> {
unsafe {
self.next.take().map(|node| {
self.next = node.next.as_mut();
&mut node.elem
})
}
}
}

#[cfg(test)]
mod test {
use super::List;
#[test]
fn basics() {
let mut list = List::new();

// Check empty list behaves right
assert_eq!(list.pop(), None);

// Populate list
list.push(1);
list.push(2);
list.push(3);

// Check normal removal
assert_eq!(list.pop(), Some(1));
assert_eq!(list.pop(), Some(2));

// Push some more just to make sure nothing's corrupted
list.push(4);
list.push(5);

// Check normal removal
assert_eq!(list.pop(), Some(3));
assert_eq!(list.pop(), Some(4));

// Check exhaustion
assert_eq!(list.pop(), Some(5));
assert_eq!(list.pop(), None);

// Check the exhaustion case fixed the pointer right
list.push(6);
list.push(7);

// Check normal removal
assert_eq!(list.pop(), Some(6));
assert_eq!(list.pop(), Some(7));
assert_eq!(list.pop(), None);
}

#[test]
fn into_iter() {
let mut list = List::new();
list.push(1); list.push(2); list.push(3);

let mut iter = list.into_iter();
assert_eq!(iter.next(), Some(1));
assert_eq!(iter.next(), Some(2));
assert_eq!(iter.next(), Some(3));
assert_eq!(iter.next(), None);
}

#[test]
fn iter() {
let mut list = List::new();
list.push(1); list.push(2); list.push(3);

let mut iter = list.iter();
assert_eq!(iter.next(), Some(&1));
assert_eq!(iter.next(), Some(&2));
assert_eq!(iter.next(), Some(&3));
assert_eq!(iter.next(), None);
}

#[test]
fn iter_mut() {
let mut list = List::new();
list.push(1); list.push(2); list.push(3);

let mut iter = list.iter_mut();
assert_eq!(iter.next(), Some(&mut 1));
assert_eq!(iter.next(), Some(&mut 2));
assert_eq!(iter.next(), Some(&mut 3));
assert_eq!(iter.next(), None);
}

#[test]
fn miri_food() {
let mut list = List::new();

list.push(1);
list.push(2);
list.push(3);

assert!(list.pop() == Some(1));
list.push(4);
assert!(list.pop() == Some(2));
list.push(5);

assert!(list.peek() == Some(&3));
list.push(6);
list.peek_mut().map(|x| *x *= 10);
assert!(list.peek() == Some(&30));
assert!(list.pop() == Some(30));

for elem in list.iter_mut() {
*elem *= 100;
}

let mut iter = list.iter();
assert_eq!(iter.next(), Some(&400));
assert_eq!(iter.next(), Some(&500));
assert_eq!(iter.next(), Some(&600));
assert_eq!(iter.next(), None);
assert_eq!(iter.next(), None);

assert!(list.pop() == Some(400));
list.peek_mut().map(|x| *x *= 10);
assert!(list.peek() == Some(&5000));
list.push(7);

// Drop it on the ground and let the dtor exercise itself
}
}

6 生产级别可用的不安全的双向链表

7 更多的链表实现

7.1 双向单链表

7.2 栈上分配内存的链表

常用第三方crate

其它

1 turbofish

表达式中带有泛型参数的路径必须在左尖括号前加上一个::。 这种为表达泛型而结合起来形式::<>看起来有些像一条鱼,因此被称为turbofish。本节详细介绍它的使用。

消费适配器与迭代器适配器中,介绍了这样的例子

例1:

1
2
3
4
5
6
7
fn main() {
let v1: Vec<i32> = vec![1, 2, 3];

let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();

assert_eq!(v2, vec![2, 3, 4]);
}

我们在这里显式指定了类型Vec<_>,是为了告诉编译器我们想要收集的集合类型为动态数组。尝试去掉类型标注看看会发生什么

例2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fn main() {
let v1: Vec<i32> = vec![1, 2, 3];

let v2 = v1.iter().map(|x| x + 1).collect();

assert_eq!(v2, vec![2, 3, 4]);
}
/*
error[E0282]: type annotations needed
--> src\main.rs:4:9
|
4 | let v2 = v1.iter().map(|x| x + 1).collect();
| ^^
|
help: consider giving `v2` an explicit type
|
4 | let v2: _ = v1.iter().map(|x| x + 1).collect();
| +++
*/

这条消息的意思是,collect不知道你试图将迭代器收集为什么类型。它不知道是VecHashMapHashSet还是任何实现了 FromIterator的类型。

这可以通过两种不同的方式解决。

  • 通过在声明变量时声明v2的类型

    • 直接显式声明

      1
      let v2: Vec<i32> = v1.iter().map(|x| x + 1).collect();
    • 交给编译器推断:<_>,就是例1使用的方法

      1
      let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();
  • 使用turbofish语法,在这个例子中,就像这样:collect::<>

    • 直接显式声明

      1
      let v2 = v1.iter().map(|x| x + 1).collect::<Vec<i32>>();
    • 交给编译器推断:<_>

      1
      let v2 = v1.iter().map(|x| x + 1).collect::<Vec<_>>();

::<Vec<i32>>部分就是turbofish,意思是将这个迭代器收集到Vec<i32>

那么,在什么情况下需要使用turbofish?来看另外一个例子

例3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn main() {
let s = "Hello, World!";
let string = s.into();
}
/*
error[E0282]: type annotations needed
--> src\main.rs:3:9
|
3 | let string = s.into();
| ^^^^^^
|
help: consider giving `string` an explicit type
|
3 | let string: _ = s.into();
| +++
*/

尝试使用turbofish可以解决这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
fn main() {
let s = "Hello, World!";
let string = s.into::<String>();
}
/*
error[E0107]: this associated function takes 0 generic arguments but 1 generic argument was supplied
--> src\main.rs:3:20
|
3 | let string = s.into::<String>();
| ^^^^ expected 0 generic arguments
|
note: associated function defined here, with 0 generic parameters
*/

然而报了一个新的错误this associated function takes 0 generic arguments but 1 generic argument was supplied,为什么::<>可以作用于 collect但是不能作用于 into?答案就在这两个函数的类型签名中。

info的签名如下:

1
fn into(self) -> T;

collect的签名如下:

1
fn collect<B: FromIterator<Self::Item>>(self) -> B;

可以看出,collect 拥有一个泛型参数B,而into没有。这就是为什么前者可以使用turbofish语法,而后者不可以。

以此类推,如果一个函数签名是foo<A, B, C>,那么你就可以使用foo::<i32, i64, u32>()这种形式调用它:

1
2
3
4
5
fn foo<A, B, C>() {}

fn main() {
foo::<i32, u32, i64>();
}

turbofish可以用于拥有泛型参数的东西,比如泛型结构体和泛型特征等,比如结构体定义为struct SomeStruct<T> { ... },则可以使用 SomeStruct::<String>::some_method()方式调用其中的方法。

例4:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct foo<T> {
a: T,
}

impl<T> foo<T> {
fn test(&self, v: T) -> T {
v
}
}
fn main() {
let t = foo { a: 3 };
// SomeStruct::<sometype>::some_method()
foo::<i32>::test(&t, 3);
}

因此,例3中的问题可以这样解决:

1
2
3
4
fn main() {
let s = "Hello, World!";
let string:String = s.into();
}

当然,其实还有一种万能的方式来解决这些问题,就是使用完全限定语法

1
2
3
4
5
fn main() {
let s = "Hello, World!";
// <Type as Trait>::function(receiver_if_method, next_arg, ...);
let string = <&str as Into<String>>::into(&s);
}

或者仍然通过turbofish,只不过是针对特征Into的turbofish:

1
2
3
4
fn main() {
let s = "Hello, World!";
let string = Into::<String>::into(s);
}

介绍一些turbofish的趣闻。

turbofish这个词的起源最早可追溯于reddit上的这篇文章,作者是@deadstone,在同一天,在twitter上发布的一篇推文也出现了turbofish这个词,作者是@Anna Harren,经过证实,他们是同一个人。

曾经,不止一人都在质疑turbofish的必要性,比如这个PR想要去掉turbofish中的双冒号::,并给出了示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Nooper<T>(T);

impl<T> Nooper<T> {
fn noop<U>(&self, _: U) {}
}

fn id<T>(t: T) -> T {
t
}

fn main() {
id<u32>(0u32); // ok
let n = Nooper<&str>(":)"); // ok
n.noop<()>(()); // ok
}

看起来是可行的,但是在这个PR下的一个评论给出了反例:

如果一个语句是这样的:

1
let a = (b<c, d>(e));

那么它既可以看成元组:

1
let a = ((b < c), (d > e));

也可以看成是两个泛型参数:

1
let a = b::<c, d>(e);

因此::暂时是不可去掉的,因为会导致歧义。

为此,在rust官方仓库中也专门介绍了不使用::所导致的歧义的例子,见bastion-of-the-turbofish

1
2
3
4
fn main() {
let (the, guardian, stands, resolute) = ("the", "Turbofish", "remains", "undefeated");
let _: (bool, bool) = (the<guardian, stands>(resolute));
}

文件的标题叫做bastion-of-the-turbofish.rs,翻译过来就是“Turbofish的堡垒”,文件中还写了一大段对turbofish的一种夸张描述,将其描述为一只可怕的怪兽,掌控着所有rust程序员的命运。它说,在这个领域里,任何试图反对turbofish的人都将遭受它的愤怒和毁灭。这段文本还提到了创造turbofish这个词的人@Anna Harren,并将这里称为turbofish的堡垒。

2 leetcode中复杂数据结构的定义

我们在如何实现链表中介绍了链表的实现,下面来看看leetcode中是如何定义链表和树结构的,只有搞清楚定义才可以更方便地刷题。

2.1 链表结构定义

随便打开一道关于链表的题目,比如206. 反转链表,可以看到这样的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#[derive(PartialEq, Eq, Clone, Debug)]
pub struct ListNode {
pub val: i32,
pub next: Option<Box<ListNode>>
}

impl ListNode {
#[inline]
fn new(val: i32) -> Self {
ListNode {
next: None,
val
}
}
}

题目本身很简单,我们主要关注节点的定义。这是一个很简单的定义,但它只实现了new。这很不方便,我们可以参考还可以的栈实现中的方法,为其添加类似pushpop的方法。这里直接给出解答:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
struct Solution;
// Definition for singly-linked list.
#[derive(PartialEq, Eq, Clone, Debug)]
pub struct ListNode {
pub val: i32,
pub next: Option<Box<ListNode>>,
}

impl ListNode {
#[inline]
fn new(val: i32) -> Self {
ListNode { next: None, val }
}
}

impl Solution {
pub fn reverse_list(mut head: Option<Box<ListNode>>) -> Option<Box<ListNode>> {
let mut ans = head.take().map(|node| {
head = node.next;
Box::new(ListNode {
val: node.val,
next: None,
})
});
while let Some(node) = head.take() {
head = node.next;
let new_node = Box::new(ListNode {
val: node.val,
next: ans.take(),
});
ans = Some(new_node)
}

ans
}
}

fn main() {
let mut head = Some(Box::new(ListNode::new(4)));
println!("{:?}", head);
let arr = vec![3, 2, 4, 1];
for val in arr {
let new_node = Box::new(ListNode {
val: val,
next: head.take(),
});
head = Some(new_node);
}
println!("{:?}", head);
let result = Solution::reverse_list(head);
println!("{:?}", result);
}

可以看到,要想玩转这种定义下的链表,takemap方法是必须要熟练掌握的。

2.2 树结构定义

再来看看树结构定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#[derive(Debug, PartialEq, Eq)]
pub struct TreeNode {
pub val: i32,
pub left: Option<Rc<RefCell<TreeNode>>>,
pub right: Option<Rc<RefCell<TreeNode>>>,
}

impl TreeNode {
#[inline]
pub fn new(val: i32) -> Self {
TreeNode {
val,
left: None,
right: None
}
}
}

可真是复杂。我们在一个糟糕但安全的双端队列介绍过使用内部可变性的数据布局,这里的树结构也是同样的道理。不过,leetcode同样只为我们实现了new

因此,需要熟悉borrowborrow_mut等操作才可以无痛刷题。以144. 二叉树的前序遍历为例,直接给出解答,主要关注如何操作这个数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
struct Solution;
#[derive(Debug, PartialEq, Eq)]
pub struct TreeNode {
pub val: i32,
pub left: Option<Rc<RefCell<TreeNode>>>,
pub right: Option<Rc<RefCell<TreeNode>>>,
}

impl TreeNode {
#[inline]
pub fn new(val: i32) -> Self {
TreeNode {
val,
left: None,
right: None,
}
}
}
use std::cell::RefCell;
use std::rc::Rc;
impl Solution {
fn preorder(node: &Option<Rc<RefCell<TreeNode>>>, mut ans: Vec<i32>) -> Vec<i32> {
match node {
Some(n) => {
ans.push(n.borrow().val);
ans = Self::preorder(&n.borrow().left, ans);
Self::preorder(&n.borrow().right, ans)
}
None => ans,
}
}

pub fn preorder_traversal(root: Option<Rc<RefCell<TreeNode>>>) -> Vec<i32> {
let ans = Vec::new();
Self::preorder(&root, ans)
}
}
fn main() {
let tree = Some(Rc::new(RefCell::new(TreeNode::new(4))));
let left = Some(Rc::new(RefCell::new(TreeNode::new(6))));
let right = Some(Rc::new(RefCell::new(TreeNode::new(6))));

let tree = tree.unwrap();
tree.borrow_mut().left = left;
tree.borrow_mut().right = right;
let tree = Some(tree);
println!("{:?}", tree);
let result = Solution::preorder_traversal(tree);
println!("{:?}", result);
}