rust入坑小记
持续更新…
作为一名理科生,这实在是太酷了,很符合我对未来编程语言的想象,科技并带有趣味。
参考资料:
- The Rust Programming Language | rust程序设计语言 简体中文版
- rust语言圣经
- rust入门秘籍
- Rust By Example | 通过例子学rust中文版
- Rust Cookbook | Rust Cookbook 中文版
- The Rust Edition Guide | Rust 版本指南
- The Rust Reference | Rust 参考手册 中文版
- The Rust Standard Library | rust标准库中文文档
- The Rustonomicon | Rust 秘典(死灵书)
- Programming Rust
- Rust Compiler Development Guide
- What is the exact definition of the for loop in Rust?
- What is the difference between iter and into_iter?
- How does mpsc’s “channel” have an “infinite” buffer? How is OS allowing this?
- Rust Apocalypse
- Learn Rust With Entirely Too Many Linked Lists
- Asynchronous Programming in Rust
- 透过 Rust 探索系统的本原:并发原语
- Crust of Rust
- C++ and Beyond 2012: Herb Sutter - atomic Weapons 2 of 2
- What is Rust’s turbofish?
- Rust 编译过程与宏展开
- Rust Async: 标准库futures api解析
- Rust 的 Pin 与 Unpin
- Pin and suffering
- Async programming in Rust with async-std
- Demystifying Async/Await in Rust
- Rust 的 Pin 机制
- 异步处理:async/await内部是怎么实现的?
- Swatinem’s Blog
- Writing an OS in Rust
- Tokio Tutorial
一、安装和配置
rust安装(linux)
1 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh |
cargo创建项目
1 | cargo new hello_world |
cargo创建的项目分为:
bin
项目:可直接运行的二进制项目lib
项目:库类型项目,不可以直接运行,而是作为第三方库被引用
当我们使用cargo new project
时,默认创建的项目为bin
项目。
helloworld
约定俗成地,需要写一个helloworld:
1 | fn main() { |
入口
以cargo创建的bin
项目入口为src/main.rs
内的main
函数。
1 | fn main() { |
在项目根目录使用cargo run
即可运行项目。
二、rust基础篇
1 常量
使用const
声明常量,通常情况下,常量命名是全大写字母,可以用下划线_
分隔单词。
1 | const PI:f64 = 3.1415926; |
- 常量可以声明在任何作用域,且在声明的作用域内一直有效
- 声明常量需要指定类型
- 常量不可以使用
mut
关键字 - 常量只可以绑定到常量表达式,而不能绑定到函数的调用结果或者是运行时才计算出的值上
2 变量
2.1 声明变量
与ts类似,我们使用let
声明变量。
1 | let a = 5; |
rust是强类型语言,由于编译器带类型推断功能,我们可以不显式指定类型,但有些情况需要显式指定:
1 | let b: i32 = 5; |
在rust中,还可以直接在数值后直接标注类型,或使用下划线_
隔开:
1 | let c = 5i32; |
2.2 可变与不可变
在默认情况下,rust的变量是不可变的。
1 | let a = 5; |
提示你无法对不可变的变量进行两次绑定。
如需可变的变量,则需要用mut
进行显式指定:
1 | let mut a = 5; |
2.3 所有权与变量绑定
上文提到了变量绑定。rust是一个无GC的语言,且又不像C/C++一样不关心内存安全,它采用"所有权系统"来保证内存安全。
简单来说,当我们声明一个变量并为它“赋值”的时候
1 | let a = String::from("hello world"); |
称这个值被变量a
所拥有。在rust中,一个值只能同时被一个变量拥有。因此,当把一个变量“赋值”给另一变量时,就会发生所有权转移,以维护该规则
1 | let a = String::from("hello world"); |
所以,使用绑定而不是赋值可以更好地描述上述过程。
但是,对于简单的数据类型,比如数字类型:
1 | fn main() { |
a
和b
都能被正确打印,这是由于在第三行let b = a
时,b
的值是直接通过内存拷贝的方式实现的,即a
和b
都被分配了内存(二者都可用),就自然没有所有权转移一说。由于数字类型直接保存在栈中,无需在堆上分配内存,这种拷贝速度非常快。但是对于上面的字符串String
,不是基本类型,它保存在堆中,对于这种复杂类型的数据不能自动拷贝,这种情况才会进行所有权的转移。
实际上,rust中的自动拷贝也叫做浅拷贝,它只发生在栈上。后面介绍特征的时候,会提到浅拷贝实际上是由于类型实现了Copy
特征;相对应的,深拷贝不会被自动调用,当我们需要深拷贝一个在堆上的数据,比如String
,需要使用Clone
:
1 | let s1 = String::from("hello"); |
2.4 shadowing
shadowing翻译为变量隐藏(变量遮蔽)。在rust中允许使用同名的变量声明
1 | fn main() { |
使用let
新声明的同名变量会将之前声明的变量遮蔽。与mut
不同的是,这两个x
都进行了内存的分配,而mut
则没有第二次的内存分配。
变量的遮蔽与作用域有关,并且声明的同名新变量类型可以与之前不同,比如
1 | fn main() { |
这个特性很好地解决了起名困难这个千古难题。
2.5 引用
rust中的引用,可以避免所有权频繁发生转移所带来的代码复杂性。与C语言的语法很接近,创建引用就是绑定变量的内存地址:
1 | let x = 5; |
在rust中,引用可以理解为:可以获取到值,但并不获取所有权。因此,当引用离开作用域之后,被引用的值不会被回收。
在默认情况下,引用是不可变的:
1 | fn main() { |
要想解决这个问题,需要使用可变引用(报错信息中,编译器也会给予提示):
1 | fn main() { |
注意,变量的可变引用在同一作用域内最多只能有一个,这是为了使rust在编译期就避免数据竞争,数据竞争可由以下行为造成:
- 两个或更多的指针同时访问同一数据
- 至少有一个指针被用来写入数据
- 没有同步数据访问的机制
通过在编译期就检查这种错误,我们可以将潜在的运行时难以追踪,并且难以诊断和修复的问题扼杀在摇篮里:rust根本不会编译存在数据竞争的代码:
1 | fn main() { |
同理,可变引用与不可变引用不能同时存在,这也是为了避免使用不可变引用的数据莫名其妙地被修改:
1 | fn main() { |
总结一下借用规则:
- 在任意给定时刻,只能拥有一个可变引用或任意数量的不可变引用之一(而不是两者)
- 引用必须总是有效的
在后续的章节中,会频繁提到引用的概念。
3 数据类型
3.1 基本类型
数值类型
整型
整数是一个没有小数部分的数字。在rust中分为有符号整型和无符号整型两类,其中有符号整型使用i
开头,无符号整型使用u
开头,具体分类如下表:
长度 | 有符号 | 无符号 |
---|---|---|
8-bit | i8 |
u8 |
16-bit | i16 |
u16 |
32-bit | i32 |
u32 |
64-bit | i64 |
u64 |
128-bit | i128 |
u128 |
arch(与架构相关) | isize |
usize |
同大多属于语言一样,有符号整数采用补码方式存储,每一个有符号类型可以储存包含从 到 在内的数字,这里n
是指所使用的位数。比如i8
可以储存从 到在内的数字,也就-128~127
;无符号数可以储存包含从到在内的数字,比如 u8
可以储存从到的数字,也就是0~255
。
isize
和 usize
类型依赖运行程序的计算机架构:64 位架构上它们是 64 位的,32 位架构上它们是 32 位的。
可以使用下表的形式编写数字字面值:
数字字面值 | 例子 |
---|---|
Decimal (十进制) | 98_222 |
Hex (十六进制) | 0xff |
Octal (八进制) | 0o77 |
Binary (二进制) | 0b1111_0000 |
Byte (单字节字符)(仅限于u8 ) |
b'A' |
如果不确定该使用哪种类型的数字,可以使用rust的默认类型。数字类型默认是i32
,isize
或 usize
主要作为某些集合的索引。
下划线_
仅做为分隔符方便观察,比如字面量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 | fn main() { |
浮点型
rust拥有两个浮点型:f32
和f64
,f32
是单精度浮点数,f64
是双精度浮点数。默认类型是 f64
,这是因为在现代CPU中,它与 f32
速度几乎一样,不过精度更高。所有的浮点型都是有符号的,浮点型根据IEEE-754
标准实现。
下面的示例展示了浮点数的定义:
1 | fn main() { |
数值运算
rust中的所有数字类型都支持基本数学运算:加法、减法、乘法、除法和取余。整数除法会向下舍入到最接近的整数。
1 | fn main() { |
这些语句中的每个表达式使用了一个数学运算符并计算出了一个值,然后绑定给一个变量。
NaN
对于数学上未定义的结果,例如对负数取平方根 -42.1.sqrt()
,会产生一个特殊的结果,rust的浮点数类型使用 NaN
(not a number)来处理这些情况。
所有跟 NaN
交互的操作,都会返回一个 NaN
,而且 NaN
不能用来比较,下面的代码会崩溃:
1 | fn main() { |
出于防御性编程的考虑,可以使用 is_nan()
等方法,可以用来判断一个数值是否是 NaN
:
1 | fn main() { |
位运算
rust中的位运算符如下,与大多数语言是相同的:
运算符 | 说明 |
---|---|
& | 相同位置均为1时则为1,否则为0 |
| | 相同位置只要有1时则为1,否则为0 |
^ | 相同位置不相同则为1,相同则为0 |
! | 把位中的0和1相互取反,即0置为1,1置为0 |
<< | 所有位向左移动指定位数,右位补0 |
>> | 所有位向右移动指定位数,带符号移动(正数补0,负数补1) |
另外,这些运算符加上=
都可以用来赋值(取反!
除外,与!=
冲突),比如:
1 | fn main() { |
布尔型
正如其他大部分编程语言一样,rust中的布尔类型有两个值:true
和 false
,布尔类型使用 bool
表示。
1 | fn main() { |
字符类型
char
类型是rust中最原生的字母类型。所有的 Unicode
值都可以作为字符,由于 Unicode
都是 4 个字节编码,因此字符类型也是占用 4 个字节。
下面是一些例子:
1 | fn main() { |
我们用单引号声明 char
字面量,而与之相反的是,使用双引号声明字符串字面量。中文、日文、韩文等字符,emoji、以及零长度的空白字符都是有效的 char
值。Unicode
标量值包含从 U+0000
到 U+D7FF
和 U+E000
到 U+10FFFF
在内的值。
单元类型
单元类型就是 ()
,它的类型和值都是()
。比如fn main()
入口函数的返回值就是()
,println!()
的返回值也是单元类型 ()
。
再比如,你可以用 ()
作为 map
的值,表示我们不关注具体的值,只关注 key
。 这种用法和 Go 语言的struct{}
类似,可以作为一个值用来占位,但是完全不占用任何内存。
3.2 复合类型
元组类型
元组(tuple)可以将多个类型组合到一起,且一旦创建,长度固定不可再变化,元组中元素的顺序也是固定的。
创建元组使用()
,用逗号,
分隔开各个元素,元素的数据类型可以不相同:
1 | fn main() { |
获取元组中的值可以按位置.0
、.1
,也可以通过解构:
1 | fn main() { |
即:使用相同形式的x,y,z
三个变量来接收元组中的值。
数组类型
rust中的数组分为两类,一种是长度固定的 array
,另一种是动态长度数组 Vector
。在内存分配方面,前者直接分配在栈上,效率非常高;后者则分配在堆上,具有更大的灵活性,但效率比前者要低。
对于array
,它的性质与大部分语言的数组性质一样,长度固定、元素的数据类型必须相同:
1 | fn main() { |
有些时候,还需要显式指定数组的类型与长度:
1 | fn main() { |
当然,对于初始化一个元素相同的数组,有一种简便写法:
1 | fn main() { |
访问数组中的元素与其他语言无异,且越界访问数组中的元素会在运行时导致panic
。
1 | fn main() { |
与go语言的切片类似,rust也可以取数组中的一部分连续片段:
1 | fn main() { |
有关动态数组的讲解放到后面,详见:动态数组 Vector
结构体
(1)结构体定义与初始化
结构体通过struct
定义,与C/C++非常类似:
1 | struct MysqlConnection { |
根据结构体定义,可以创建实例,创建实例时我们需要为每个字段都初始化:
1 | fn main() { |
通过.
操作符访问结构体,也可以直接修改这些值,如果要修改结构体,需要用mut
,rust目前不支持为结构体的每个字段设置mut
:
1 | fn main() { |
我们还可以从一个结构体中解构一些字段创建另一个结构体:
1 | fn main() { |
如果你了解typescript,这种..
语法应该不会感到陌生,但是在rust中,..
必须在结构体的结尾使用,并且未实现Copy
特征的类型会进行所有权转移。
(2)使用字段初始化简写语法
看一个例子:
1 | struct User { |
这里我们声明了一个结构体,并创建了一个 build_user
函数,它返回一个带有给定的 email
和 User
的结构体实例。active
字段的值为 true
,并且 sign_in_count
的值为 1
。
为函数参数起与结构体字段相同的名字是可以理解的,但是不得不重复 email
和 username
字段名称与变量有些啰嗦。如果结构体有更多字段,重复每个名称就更加烦人了。对于参数名与字段名都完全相同的结构体,我们可以使用字段初始化简写语法(field init shorthand)来重写 build_user
。
1 | fn build_user(email: String, username: String) -> User { |
这里我们创建了一个新的 User
结构体实例,它有一个叫做 email
的字段。我们想要将 email
字段的值设置为 build_user
函数 email
参数的值。因为 email
字段与 email
参数有着相同的名称,则只需编写 email
而不是 email: email
。
(3)元组结构体
也可以定义与元组类似的结构体,称为元组结构体(tuple structs)。元组结构体有着结构体名称提供的含义,但没有具体的字段名,只有字段的类型。当你想给整个元组取一个名字,并使元组成为与其他元组不同的类型时,元组结构体是很有用的,这时像常规结构体那样为每个字段命名就显得多余和形式化了。
要定义元组结构体,以 struct
关键字和结构体名开头并后跟元组中的类型。例如,下面是两个分别叫做 Color
和 Point
元组结构体的定义和用法:
1 | struct Color(i32, i32, i32); |
注意 black
和 origin
值的类型不同,因为它们是不同的元组结构体的实例。你定义的每一个结构体有其自己的类型,即使结构体中的字段可能有着相同的类型。例如,一个获取 Color
类型参数的函数不能接受 Point
作为参数,即便这两个类型都由三个 i32
值组成。在其他方面,元组结构体实例类似于元组,你可以将它们解构为单独的部分,也可以使用 .
后跟索引来访问单独的值,等等。
(4)没有任何字段的类单元结构体
我们也可以定义一个没有任何字段的结构体。它们被称为类单元结构体(unit-like structs)因为它们类似于单元类型()
。类单元结构体常常在你想要在某个类型上实现特征但不需要在类型中存储数据的时候发挥作用:
1 | struct AlwaysEqual; // 类单元结构体 |
我们将在后面介绍特征,见特征Trait。
(5)打印结构体
为了更方便地观察结构体,我们需要打印结构体。但直接打印会报错:
1 | struct Socket { |
编译器提示Socket
没有实现 std::fmt::Display
特征,特征的概念将会在后续讲到。继续往下看,编译器提示:
尝试使用{:?}
来代替{}
,我们按照提示试一试,还是会报错:
1 | fn main() { |
这次的错误是:Socket
没有实现Debug
特征,并给出了解决方案。一个是添加 #[derive(Debug)]
,另外一个就是手动为Socket
实现这个Debug
特征。我们尝试使用前者使用 derive
派生:
1 | // ...省略其它代码 |
运行,终于将结构体打印出来了。另外,编译器刚刚还提到使用{:#?}
,这样会pretty-print
,也就是会有更好的输出样式:
1 | // ...省略其它代码 |
枚举
相比C,rust的枚举更加好用了。首先是定义,看例子:
1 |
|
使用enum
关键字来定义枚举,Animal
是自定义的枚举类型名,枚举类型下面可以包含多种不同的类型,比如:猫和狗都属于动物,获取枚举中成员的实例使用::
操作符。
接下来,我们需要让枚举更有意义,可以给每个子类型设置一个名字:
1 |
|
现在我们有一只名叫crookshanks
的猫和一只名叫cookie
的狗。当然,我们还可以在同一个枚举类型中使用不同的数据类型,并且并不止限于基本类型,任何类型的数据都可以放入枚举成员中:
1 |
|
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程序可以直接使用它(也可以直接使用Some
,None
,无需使用 Option::
限定符)。
1 | enum Option<T> { |
好了,为什么说使用Option
比null
更安全呢?个人理解是这样的,null
把可能为空的问题隐藏掉而不去处理,使用Option
则编译器会限制你:如果你使用Option
里的值不处理空值,我就不让你通过编译。举个例子:
1 | fn main() { |
这里我们声明了一个变量b
为Some(3)
,表示b
可能为空,也可能为3
(虽然这里肯定为3,但你可以把它想象成获取用户的输入)。当我们尝试相加a
、b
,就会因为类型不同而无法相加。事实上,a
这样的值在rust中一定不为空(编译器确保它总是有一个有效的值),这意味着你无需对它们做空值防护,只有Option
类型你才需要去关心值是否为空。如果你想计算a+b
,那就必须将Some
中的值提取出来,在提取的过程中,这个值可能有效,可能为空,我们需要分别进行处理。通过这样的处理,你就可以保证a+b
是安全的。
那么该如何提取Some(T)
中的T
呢,这里需要用到模式匹配match
:
1 | fn main() { |
我们会在后面继续介绍match
相关内容(见模式匹配)。但现在,需要首先关注一下Option::None
,如果使用 None
,即你定义你的变量刚开始是空值,那你需要体谅一下编译器,它怎么知道值不为空的时候变量是什么类型的呢?所以需要需要手动指定:
1 | let v : Option<i32> = None; |
3.3 如何获取一个值的类型
rust目前还没有类似于typeof
或者type
这样判断变量类型的关键字。
参考@Boiethios 的回答,你可以使用 std::any::type_name
,示例代码如下:
1 | fn print_type_of<T>(_: T) { |
4 函数、语句与表达式
4.1 函数
函数的定义如下:
1 | fn function(a:i32,b:i32) -> f64 { |
就这么简单。
与C或python不同的是,你无需关心函数定义在哪个位置(在前还是在后),rust中的函数都可以调用到。
4.2 语句与表达式
下面介绍语句与表达式。一般语句指的是以分号结尾的一些操作,在函数中,语句并不会返回值,比如:
1 | let a = 3; |
而表达式则是指没有分号的,并且会在求值后返回一个值,比如:
1 | let x = 5; // 语句 |
在函数中,函数体包括一些语句+最后一行的零个或一个表达式。语句与表达式在写法上就差一个分号,表达式不能包含分号,因此,在函数结尾需要返回值的时候不能带分号,否则它就会变成一条语句,不会返回值。最后,如果不返回任何值,则会隐式地返回一个 ()
。
当然,你也可以在函数体中使用return
来返回值(return
带分号和不带分号都可以):
1 | fn foo() -> i32 { |
4.3 发散函数!
发散函数(diverging functions),返回值类型为特殊的!
,表示该函数永不返回,一般用于导致程序崩溃的函数:
1 | fn forever() -> ! { |
4.4 函数与所有权
当看到这个标题,相信你已经明白了,函数的参数传递也会进行所有权的转移。当然,实现了Copy
特征的基本类型是通过拷贝进行的,所以没有所有权的转移,下面的代码不会报错:
1 | fn main() { |
如果是复合类型,没有实现Copy
特征,情况就不同了:
1 | fn main() { |
上面的代码会报错,说明在传递参数的时候,进行了所有权转移,a
不再拥有值hello
,而是转移到了value
,在函数执行完毕后,函数的{}
作用域结束,value
就被drop
掉了,因此第四行再次打印a
就会报错。
解决方法也很简单,我们传递参数时使用引用即可:
1 | fn main() { |
因为并不拥有这个值,当引用离开作用域后,其指向的值也不会被丢弃。这样,程序就可以正常运行了。
5 流程控制
5.1 条件控制
通过if
和else
:
1 | fn main() { |
5.2 循环控制
rust存在三种循环。
for
首先是for
:
1 | fn main() { |
其中,0..=10
的含义是生成从0到10的连续序列,即[0,10]
这样的闭区间,如果不加=
号0..10
,则是左闭右开的区间[0,10)
。
对于for
来说,有一点需要注意,那就是所有权的问题。
首先,对于实现了Copy
特征的数组来说,使用for i in 数组
并不会将所有权转移,而是进行了内存拷贝,比如:
1 | fn main() { |
但是对于复杂的类型,则会发生所有权转移:
1 | fn main() { |
解决方法就是使用引用:
1 | fn main() { |
引用默认是不可变的,因此你无法修改元素:
1 | fn main() { |
需要使用mut
来解决:
1 | fn main() { |
如果你使用过python,那么一定很熟悉enumerate
获取可迭代对象的索引,在rust中是这样写的:
1 | fn main() { |
iter()
方法会将a
转化为迭代器,再使用enumerate()
即可在for
循环中获取索引。
while
第二种是while
,即条件循环,满足某个条件就进行循环,直到不满足条件为止:
1 | fn main() { |
loop
第三种是loop
,即无条件循环:
1 | fn main() { |
这个代码会无限循环下去。因此,对于循环,我们还需要一些可以约束循环的关键字:continue
和break
:
1 | fn main() { |
若操作返回一个值,则可能需要将其传递给代码的其余部分:将该值放在 break
之后,它就会被 loop
表达式返回。
1 | fn main() { |
5.3 循环标签
一个循环表达式可以选择设置一个标签。这类标签被标记为循环表达式之前的生命周期(标签),如:
1 | 'foo: loop { break 'foo; } |
如果循环存在标签,则嵌套在该循环中的带此标签的 break
表达式和 continue
表达式可以退出此标签标记的循环层或将控制流返回至此标签标记的循环层的头部。
比如:
1 | fn main() { |
同理可以用于continue
:
1 | fn main() { |
6 模式匹配
6.1 match
在枚举一节的最后,我们提到了取Some(T)
的方法,这里用到的就是模式匹配。首先通过一个例子来介绍match
1 | enum Animal{ |
首先创建了一个枚举类型和一个枚举成员的实例,接下来对这个实例cat
进行模式匹配,使用match
去匹配它对应的类型。下面是match
的一些特性:
-
在
match
内部我们需要将所有的可能都列出来。如果你不列出来,编译器会报错: -
=>
的左边,是我们的匹配条件,也叫做模式,右边是匹配成功后执行的代码,也叫做针对该模式进行处理的代码。 -
使用
|
表示逻辑或,也就是说只要有一个匹配上,就算匹配成功。 -
最后的
_
代表没有匹配成功的默认匹配,和C/C++/typescript中的switch
语句内的default
很像,作为兜底选项存在。 -
还需要注意一点就是
=>
右边可以也可以有多行代码,需要用{}
包裹,但无论是单行代码还是多行代码,最后一行一定是一个表达式。1
2
3
4
5
6
7
8
9match something {
case1 => do_something, // 表达式
case2 => { // 可以是多行代码
balabala; // 语句
balabala; // 语句
expression // 表达式
},
_ => println!("...")
} -
match
本身也是一个表达式,因此你可以这样写,将match
匹配到的值绑定到a
上:1
2
3let a = match {
...
} -
模式匹配从上到下按顺序执行,如果模式匹配了这个值,那么模式之后的代码将被执行。如果模式并不匹配这个值,将继续执行下一个分支。
模式匹配还有一个比较好用的功能,就是获取模式中绑定的值:
1 | enum Animal{ |
这里的name
就是绑定到枚举成员上的值。
另外,match
同样涉及到所有权转移,还是上面的例子:
1 |
|
根据所有权转移的规则,由于String
不是基本类型,没有实现Copy
特征,它的所有权先移动到cat
,然后通过模式匹配,所有权转移到name
,在离开{}
的作用域之后,name
被drop
掉了,再次打印cat
时,就会报错。
当然,rust编译器贴心地给出了提示,通过引用&
来避免所有权转移:
1 | match &cat { |
当然,也可以通过ref
,在通过 let
绑定来进行模式匹配或解构时,ref
关键字可用来创建结构体/元组的字段的引用:
1 | match cat { |
6.2 if let
在某些场景下,会遇到只有一个模式的值需要被处理,其它值直接忽略的场景,这时使用match
就显得很复杂
1 | fn main() { |
我们只想要对 Some(3)
模式进行匹配, 不想处理任何其他 Some<u8>
值或 None
值。但是为了满足 match
表达式(穷尽性)的要求,写代码时必须在处理完这唯一的成员后加上 _ => ()
,这样会增加不少无用的代码。
我们完全可以用 if let
的方式来实现,在这种只有两个情况的场景下会简洁很多:
1 | fn main() { |
if let
语法格式如下,当然else
是可选的:
1 | if let 匹配值 = 原变量 { |
你可以使用else if
来增加判断项:
1 | fn main() { |
6.3 while let
和 if let
类似,while let
也可以把别扭的 match
改写得好看一些。考虑下面这段使 i
不断增加的代码:
1 | // 将 `optional` 设为 `Option<i32>` 类型 |
使用 while let
:
1 | fn main() { |
6.4 matches!
matches!
是一个宏,它的作用是将一个表达式跟模式进行匹配,如果匹配成功,结果是 true
否则是 false
。
1 | let foo = 'f'; |
7 方法impl
impl
也可以叫做“实现”(implementation)。在面向对象语言中,方法一般是和类或者对象绑定的,rust在概念上来说也大差不差,但是方法的定义却并不在类中,而是在 impl
代码块中定义。
7.1 静态方法
首先是静态方法(这种定义在 impl
中且没有 self
的函数也叫做关联函数):
1 | struct Point { |
现在你应该可以理解,rust的对象定义和方法定义是分离的。impl Point {}
表示为 Point
实现(implementation)方法。我们为Point
实现了两个静态方法,下面来看看如何使用它:
1 | fn main() { |
7.2 实例方法
实例方法定义
接下来我们为实例实现一个方法:
1 | impl Point { |
一般情况下,每个函数参数都需要标注类型,但是self
比较特殊,它等价于self:Self
。注意大小写。
实例方法与所有权
如果你写过python,那么对这种self
肯定非常熟悉。但是,rust往往会给你惊喜:猜这段代码会不会报错?是的,它毫不留情地报错了:
1 | /* |
嗯,仔细看,就会发现还是老问题:所有权。在sum
的函数签名中,我们直接使用self
,fn sum(self)
,它其实相当于fn sum(self : Self)
的简写,大写的Self
是当前对象的类型。
实例方法具有所有权的概念,也就是说,只要你没有为当前的self
(也就是Point
实例)实现Cpoy
特征,那么就会发生所有权转移,在最后进行打印时,p
已经失去了所有权而导致报错。
当然,解决方法也很简单,之前的章节也不止一次提到了,使用引用:
1 | fn sum(self : &Self) // 也可以简写为 `&self` |
这种引用当然是不可变的,要想改变实例,需要再加上mut
:
1 | fn sum(self : &mut Self) |
方法名与字段名相同
rust允许方法名跟结构体的字段名相同:
1 |
|
当你使用p.x()
时,编译器知道你在调用方法,当你使用p.x
,它也知道你是在访问其中的字段。
多个impl
rust允许你为同一个类型定义多个impl
块,这样可以更加灵活:
1 | struct Point { |
除了结构体,我们还可以为枚举、特征实现方法。有关特征,马上就会讲到了。
8 泛型 Generic
如果你接触过C++或java的泛型,对泛型的概念应该不会陌生。泛型的应用广泛,并且可以极大地减少代码的重复。泛型最简单和常用的用法是用于类型参数。类型参数就像函数的形参一样,作为类型的代号指代一部分类型。一般情况下,类型参数是用尖括号和大驼峰命名的名称:<Aaa, Bbb, ...>
指定的,作为其他语言约定俗成的规则,rust里也一般沿用<T>
作为类型参数。要使用泛型,需要提前声明泛型的类型参数。
特征泛型会在下一章介绍完特征后再介绍,见特征泛型。
8.1 函数泛型
比如,定义一个名为 foo
的泛型函数,它可接受类型为 T
的任何参数 arg
:
1 | fn foo<T>(arg: T) { ... } |
在使用类型 T
前,在函数名后面指定泛型类型参数 <T>
,那么 T
就变成了泛型。
同理,在结构体中使用泛型:
1 | struct A<T> { |
需要注意的是,同一个泛型类型只能指代一种具体类型,比如:
1 | fn main() { |
结构体A
中的a
、b
都是T
类型,当初始化a
时,类型推断确定T
为整数类型,那么b
的类型也应该是整数类型。
要想让a
、b
都是泛型类型,且指代的具体类型不同,那就需要声明不同的泛型类型。
1 | struct A<T,U> { |
8.2 枚举泛型
在枚举一节中,介绍了Option
,它的定义是这样的:
1 | enum Option<T> { |
这里的T
就是泛型。Some(T)
表示它可以接收任意类型的值。
另外一个常用的枚举使用泛型的例子就是Result
:
1 | enum Result<T, E> { |
和Option
一样,主要用于函数返回值,当函数正确返回时,则返回Ok(T)
,T
是函数具体的返回值类型;当发生错误时,则返回 Err(E)
,E
是错误类型。
8.3 方法泛型
在方法上也可以使用泛型:
1 | struct Point<T> { |
使用泛型参数前,依然需要提前声明:impl<T>
,只有提前声明了,我们才能在Point<T>
中使用它,这样rust就知道 Point
的尖括号中的类型是泛型而不是具体类型。需要注意的是,这里的 Point<T>
不再是泛型声明,而是一个完整的结构体类型,因为我们定义的结构体就是 Point<T>
而不再是 Point
。
除了结构体中的泛型参数,我们还能在该结构体的方法中定义额外的泛型参数,就跟泛型函数一样:
1 | struct Point<T, U> { |
T,U
是定义在结构体 Point
上的泛型参数,V,W
是单独定义在方法 mixup
上的泛型参数,它们并不冲突。
8.4 使用具体类型实现方法
对于 Point<T>
类型,你不仅能定义基于 T
的方法,还能针对特定的具体类型,进行方法定义:
1 | impl Point<f32> { |
这段代码意味着 Point<f32>
类型会有一个方法 distance_from_origin
,而其他 T
不是 f32
类型的 Point<T>
实例则没有定义此方法。这个方法计算点实例与坐标(0.0, 0.0)
之间的距离,并使用了只能用于浮点型的数学运算符。
这样我们就能针对特定的泛型类型实现某个特定的方法,对于其它泛型类型则没有定义该方法。
8.5 const泛型
对于数组,[i32; 2]
和 [i32; 3]
是不同的数组类型:
1 | fn display_array(arr: [i32; 3]) { |
无法使用一个函数来接收这两个不同的类型。当然,你可以通过引用来解决:
1 | fn display_array(arr: &[i32]) { |
只要使用数组切片,然后传入 arr
的不可变引用即可。但是如果在某些场景下引用不适宜用或者干脆不能用呢,rust在后续引入的const泛型可以解决这个问题:
1 | fn display_array<T: std::fmt::Debug, const N: usize>(arr: [T; N]) { |
如上所示,我们定义了一个类型为 [T; N]
的数组,其中 T
是一个基于类型的泛型参数,这个和之前讲的泛型没有区别,而重点在于 N
这个泛型参数,它是一个基于值的泛型参数,因为它用来替代的是数组的长度。
N
就是const泛型,定义的语法是 const N: usize
,表示 const 泛型 N
,它基于的值类型是 usize
。
9 特征Trait
在之前,多次提到Copy
、Debug
等特征,特征和其他语言的接口很类似,trait
是对未知类型 Self
定义的方法集,如果不同的类型具有相同的行为,那么我们就可以定义一个特征,然后为这些类型实现该特征。定义特征是把一些方法组合在一起,目的是定义一个实现某些目标所必需的行为的集合。
9.1 定义特征
第一步是为类型定义特征,首先创建一个类型,以结构体为例:
1 | struct Dog { |
接下来,我们要定义一个特征:
1 | trait Animal { |
你可以把它理解为go/java的接口,即你只需要声明函数签名,而不需要具体实现。使用trait
关键字声明一个特征名为Animal
,并定义要实现这个特征所需要的方法集,这里定义了new
和introduce
两个方法。
9.2 为类型实现特征
声明好了特征之后,需要继续为Dog
类型实现这个特征:
1 | impl Animal for Dog { |
实现特征的语法也很简单:impl Animal for Dog
,即:为Dog
实现Animal
特征。注意,实现特征需要具体实现该特征的所有的方法,也就是说,你不能只实现new
而不实现introduce
,否则就会报错。
最后,我们就可以使用了。下面是完整代码:
1 | struct Dog { |
9.3 孤儿规则 Orphan Rule
定义特征与实现特征有一个限制,那就是只有当特征或者要实现特征的类型位于当前作用域时,才能为该类型实现特征。比如上面的例子,Dog
类型定义在当前的作用域中,符合孤儿规则,因此我们可以为Dog
实现Display
特征;同理,String
类型定义在标准库中,但是Animal
特征定义在当前作用域,因此可以为String
实现Animal
特征;但是,不能为String
实现 Display
特征,因为它们都定义于标准库中,和当前程序没有任何关系、这个限制是被称为相干性(coherence) 的程序属性的一部分,或者更具体的说是孤儿规则(orphan rule),其得名于不存在父类型。这条规则确保了其他人编写的代码不会破坏你代码,反之亦然。没有这条规则的话,两个包可以分别对相同类型实现相同的特征,而 rust将无从得知应该使用哪一个实现。
9.4 默认实现
有时为特征中的某些或全部方法提供默认的方法,而不是在每个类型的每个实现中都定义自己的方法是很有用的,因为这样其它类型无需再实现这些默认方法,并且也可以在必要时重载它们:
1 | trait Animal { |
这里我们定义一个默认方法introduce
,现在,想要为类型Dog
实现Animal
特征时,就可以不必实现introduce
(当然,你也可以重载它):
1 | struct Dog { |
默认实现允许调用相同特征中的其他方法,哪怕这些方法没有默认实现:
1 | struct Dog { |
我们只需要实现introduce_myself
即可,最终会打印出I'm an Animal! A red color dog,age is 3.
9.5 特征作为函数参数
特征可以作为函数参数:
1 | fn notify(item: impl Animal) { |
这里item
的类型是impl Animal
,即:实现了Animal
特征的类型,这意味着你可以传入任何实现了该特征的类型,同时在函数体内,还可以调用该特征的方法,完整代码如下:
1 | struct Dog { |
impl Trait
这种语法适用于简单的例子,实际上是一种较长形式语法的语法糖。我们称为特征约束(trait bound)。
9.6 特征约束
特征约束完整的形式如下,还是上面的例子:
1 | fn notify<T: Animal>(item: T) { |
和函数泛型类似,但是在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 | fn notify<T, U>(item_a: T, item_b: U) -> () |
这样看起来函数签名就更加紧凑了。
9.9 返回实现了特征的类型
也可以在返回值中使用 impl Trait
语法,来返回实现了某个特征的类型:
1 | fn notify(item: String) -> impl Animal {} |
指定notify
函数返回某个实现了Animal
的类型,但是不确定其具体的类型。返回一个只是指定了需要实现的特征的类型的能力在闭包和迭代器场景十分的有用。闭包和迭代器创建只有编译器知道的类型,或者是非常非常长的类型。impl Trait
允许你简单的指定函数返回一个 Iterator
而无需写出实际的冗长的类型。
不过这只适用于返回单一类型的情况。如果你的实现是这样的:
1 | // 假设这里Dog和Cat都实现了Animal特征 |
虽然Dog
和Cat
都实现了Animal
特征,但是返回类型不单一,无法通过编译。解决方法留到后面进行讨论,详见特征对象。
9.10 使用特征约束有条件地实现方法
在实现方法时使用特征约束,有条件地约束哪些类型可以拥有方法,哪些类型不可以拥有方法。比如,类型 Pair<T>
总是实现了 new
方法,不过只有那些为 T
类型实现了 PartialOrd
特征(来允许比较)和Display
特征(来启用打印)的 Pair<T>
才会实现 cmp_display
方法:
1 | use std::fmt::Display; |
也可以对任何实现了特定特征的类型有条件地实现特征。对任何满足特定特征约束的类型实现特征被称为通用实现(blanket implementations) ,它们被广泛的用于rust标准库中。例如,标准库为任何实现了Display
特征的类型实现了 ToString
特征。这个impl
块看起来像这样:
1 | impl<T: Display> ToString for T { |
再举个通俗的例子——学校要给学生颁奖,但是,想要获得颁奖资格,学生必须获得四级证书:对任何实现了特定特征的类型(有四级证书的学生)实现特征(有资格获奖)。
特征和特征约束让我们使用泛型类型参数来减少重复,并仍然能够向编译器明确指定泛型类型需要拥有哪些行为。因为我们向编译器提供了特征约束信息,它就可以检查代码中所用到的具体类型是否提供了正确的行为。
在动态类型语言中,如果我们尝试调用一个类型并没有实现的方法,会在运行时出现错误。rust将这些错误移动到了编译时,甚至在代码能够运行之前就强迫我们修复错误。另外,我们也无需编写运行时检查行为的代码,因为在编译时就已经检查过了,这样相比其他那些不愿放弃泛型灵活性的语言有更好的性能。
9.11 derive
还记得打印结构体时使用的#[derive(Debug)]
吗,这种是一种特征派生语法,被 derive
标记的对象会自动实现对应的默认特征代码,继承相应的功能。对于Debug
特征,rust有写好的自动实现的默认代码,当你给一个结构体标记后,就可以使用 println!("{:?}", s)
的形式打印该结构体的对象。再如 Copy
特征,它也有一套自动实现的默认代码,当标记到一个类型上时,可以让这个类型自动实现 Copy
特征,进而可以调用 copy
方法,进行自我复制。
总之,derive
派生出来的是rust默认给我们提供的特征,在开发过程中极大的简化了自己手动实现相应特征的需求,当然,如果你有特殊的需求,还可以自己手动重载该实现。
9.12 特征泛型
特征中也可以使用泛型,它的定义如下:
1 | trait A<U> {} |
举个具体的例子:
1 | trait A<T> { |
这里定义了一个特征泛型A
,其中包含一个名为foo
的方法,该方法接受一个类型参数T
,并返回一个类型为T
的值。
接下来,我们定义一个结构体B
,并为它实现了特征A
,并提供了一个foo
方法,该方法返回传递给它的参数值。然后在main
函数中,我们首先创建了一个B
对象b
,然后使用foo
方法将x
值、y
值分别传递给b.foo
并打印。
可以看出,对于不同类型的x:i32
和y:String
,foo
方法都可以接受它们,这使得特征可以不受类型的限制,减少编码冗余。
在后面的章节默认泛型类型参数和运算符重载中,我们还会使用到特征泛型。
9.13 Supertraits
Supertraits是为实现特定特征而需要为类型实现的特征,这可能有些拗口,但是如果将其比喻为面向对象中类的继承,就稍微好理解了一点(当然并不严谨)。
Supertraits是通过特征的 Self
类型上的特征约束来声明的,并且通过这种声明特征约束的方式来传递这种Supertraits关系。一个 trait 不能是它自己的Supertrait。
下面是一个声明 Shape
是 Circle
的Supertrait的例子。
1 | trait Shape { fn area(&self) -> f64; } |
下面是同改成使用where约束的等效实现:
1 | trait Shape { fn area(&self) -> f64; } |
10 集合
集合(collections)是rust中非常有用的数据结构,下面会逐个介绍三种集合。
10.1 String
字符串切片
切片在前面介绍数组时有简单提到,它是一类引用,没有所有权。
1 | let s = String::from("hello world"); |
s
是String
类型,hello
没有引用整个 String s
,而是引用了 s
的一部分内容,通过 [0..5]
的方式来指定。这就是创建切片的语法,使用方括号包括的一个序列:[开始索引..终止索引]
,其中开始索引是切片中第一个元素的索引位置,而终止索引是最后一个元素后面的索引位置,这是一个[)
右半开区间。在切片数据结构内部会保存开始的位置和切片的长度,其中长度是通过 终止索引
- 开始索引
的方式计算得来的。
..
除了用于解构,在这里是生成连续序列的意思,如果你想从索引 0 开始,可以使用如下的方式,这两个是等效的:
1 | let slice = &s[0..2]; |
同样的,如果你的切片想要包含 String
的最后一个字节,则可以这样使用:
1 | let s = String::from("hello"); |
你也可以截取完整的 String
切片:
1 | let s = String::from("hello"); |
但通常,字符串切片是非常危险的操作,具体原因请继续往下看。
字符串的字面值是切片
对于:
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
可以新建一个空字符串。但更多时候,我们需要&str
与String
相互转换。
首先是从 &str
类型生成 String
类型:
1 | let s = String::from("initial contents"); |
这两种生成方式得到的效果是一样的,String::from
和 .to_string
最终做了完全相同的工作,所以如何选择就是代码风格与可读性的问题了。
将 String
类型转为 &str
类型也非常简单,取引用即可:
1 | fn main() { |
字符串的编码
字符串是UTF-8
编码,所以可以包含任何可以正确编码的数据:
1 | let hello = String::from("السلام عليكم"); |
所有这些都是有效的 String
值。
字符串索引
在很多语言中,通过索引来引用字符串中的单独字符是有效且常见的操作。然而在 Rust 中,如果你尝试使用索引语法访问 String
的一部分,会出现错误:
1 | fn main() { |
错误提示告诉我们,rust的字符串不支持索引。这是因为字符串在内存中的存储决定的。
内部表现
字符串的底层的数据存储格式实际上是一个u8
类型的字节数组。前面举了一些UTF-8
的字符串例子,比如:
1 | fn main() { |
在这里,len
的值是4,因为Hola
中的每个字母在UTF-8
编码中仅占用1个字节,但是对于:
1 | fn main() { |
len
的值是12,因为一个中文字符需要3个字节存储。在这种情况下,假如对s1
取索引&s1[0]
,并不会返回中
,而是这3个字节中的第1个字节,这显然不是用户想要的结果。为了避免返回意外的值并造成不能立刻发现的bug,rust根本不会编译这些代码,并在开发过程中及早杜绝了误会的发生。
这引起了关于UTF-8
的另外一个问题:从rust的角度来讲,事实上有三种相关方式可以理解字符串:字节、标量值和字母。
比如这个用梵文书写的印度语单词 “नमस्ते”,它底层的字节数组是如下形式:
1 | [224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, |
这里有 18 个字节,也就是计算机最终会储存的数据。
如果从Unicode
标量值的角度理解它们,也就像rust的char
类型那样,这些字节看起来像这样:
1 | ['न', 'म', 'स', '्', 'त', 'े'] |
这里有六个 char
,不过第四个和第六个都不是字母,它们是发音符号本身并没有任何意义。最后,如果以字母的角度理解:
1 | ["न", "म", "स्", "ते"] |
rust提供了多种不同的方式来解释计算机储存的原始字符串数据,这样程序就可以选择它需要的表现方式,而无所谓是何种人类语言。
最后一个rust不允许使用索引获取 String
字符的原因是,索引操作预期总是需要常数时间O(1)
。但是对于String
不可能保证这样的性能,因为rust必须从开头到索引位置遍历来确定有多少有效的字符。
字符串切片可能造成的问题
前面提到,字符串切片是非常危险的操作,这正是由于字符串索引返回的东西不明确。因此,如果你真的希望使用索引创建字符串切片时,需要你更明确一些。为了更明确索引并表明你需要一个字符串 slice,相比使用 []
和单个值的索引,可以使用 []
和一个range
来创建含特定字节的字符串切片:
1 | let hello = "中国人"; |
这里,s
会是一个 &str
,它包含字符串的前3个字节,也就是中
。但是,如果你取错了索引:
1 | let s = &hello[0..2]; |
那么就会在运行时painc
。你应该小心谨慎地使用这个操作,因为这么做可能会使你的程序崩溃。
字符串操作
String
是动态可增加的,其内容也可以改变,下面介绍字符串的修改,添加,删除等常用方法。
追加
可以通过 push_str
方法来附加字符串切片,从而使 String
变长:
1 | fn main() { |
s2
并没有在push_str
后失效,这是因为我们使用的是字符串字面值,并不需要获取参数的所有权。
还可以使用push
,push
方法被定义为获取一个单独的字符作为参数,并附加到 String
中:
1 | fn main() { |
这两个方法都是在原有的字符串上追加,并不会返回新的字符串。由于字符串追加操作要修改原来的字符串,则被添加的字符串必须是可变的,需要用mut
。
拼接字符串
通常你会希望将两个已知的字符串合并在一起。一种办法是使用 +
运算符。
1 | fn main() { |
这里有一些细节需要注意,首先,我们将两个字符串拼接的时候,需要使用+
,左边是s1
,而右边则获取了引用&s2
。并且,如果你在最后打印s1
,会发现它不再有效,这是由于+
运算背后调用了标准库的add
方法,函数签名看起来像这样:
1 | fn add(self, s: &str) -> String {} |
根据前几章的内容可以得知,我们使用第二个字符串的引用与第一个字符串相加。这是因为 add
函数的 s
参数:只能将 &str
和 String
相加,不能将两个 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 | fn main() { |
如果需要多个字符串拼接,+
就不太方便了,此时可以使用format!
宏:
1 | fn main() { |
format!
与 println!
的工作原理相同,不过不同于将输出打印到屏幕上,它返回一个带有结果内容的 String
。这个版本就好理解的多,宏 format!
生成的代码使用引用所以不会获取任何参数的所有权。
插入
使用 insert()
方法插入单个字符 char
,也可以使用 insert_str()
方法插入字符串字面值。这两个方法需要传入两个参数,一个是待插入的值,另一个是插入的位置索引,索引从 0 开始计数,如果越界则会发生错误。由于字符串插入操作要修改原来的字符串,则该字符串必须是可变的,即字符串变量必须由mut
关键字修饰。
1 | fn main() { |
替换
如果想要把字符串中的某个字符串替换成其它的字符串,那可以使用 replace()
方法。与替换有关的方法有三个。
-
replace
。用于
String
和&str
类型。replace()
方法接收两个参数,第一个参数是要被替换的字符串,第二个参数是新的字符串。该方法会替换所有匹配到的字符串。该方法是返回一个新的字符串,而不是操作原来的字符串。1
2
3
4
5fn 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);
} -
replacen
该方法可适用于
String
和&str
类型。replacen()
方法接收三个参数,前两个参数与replace()
方法一样,第三个参数则表示替换的个数。同样返回一个新的字符串,而不是操作原来的字符串。1
2
3
4
5fn 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);
} -
replace_range
该方法仅适用于
String
类型。replace_range
接收两个参数,第一个参数是要替换字符串的范围(Range),第二个参数是新的字符串。该方法是直接操作原来的字符串,不会返回新的字符串。因此被操作字符串需要使用mut
关键字修饰。1
2
3
4
5fn 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
修饰。
-
pop
该方法删除并返回字符串的最后一个字符,返回值是一个
Option
,如果字符串为空,则返回None
。1
2
3
4
5
6
7
8fn main() {
let mut s = String::from("I like rust!");
let t = s.pop();
if let Some(x) = t {
println!("{}", x);
}
println!("{}", s);
} -
remove
该方法删除并返回字符串中指定位置的字符。接收一个参数即要删除的索引位置,如果索引越界则会
panic
:1
2
3
4
5
6fn main() {
let mut s = String::from("I like rust!");
let t = s.remove(3);
println!("{}", t);
println!("{}", s);
} -
truncate
该方法删除字符串中从指定位置开始到结尾的全部字符,无返回值,索引越界则会
panic
:1
2
3
4
5
6fn main() {
let mut s = String::from("I like rust!");
s.truncate(5);
println!("{}", s);
} -
clear
该方法清空字符串,相当于
truncate(0)
:1
2
3
4
5
6fn main() {
let mut s = String::from("I like rust!");
s.clear();
println!("{}", s);
}
更多方法,请参阅官方文档。
转义字符串
我们可以通过转义的方式\
输出ASCII
和Unicode
字符。
1 | fn main() { |
当然,在某些情况下,可能你会希望保持字符串的原样,不要转义:
1 | fn main() { |
操作UTF-8字符串
如果你想要以Unicode
字符的方式遍历字符串,最好的办法是使用 chars
方法,例如:
1 | for c in "中国人".chars() { |
以字节形式遍历,使用bytes
:
1 | for b in "中国人".bytes() { |
想要准确的从 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 | let mut v = Vec::new(); |
首先需要使用mut
将v
变成可变的,然后使用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 | let mut v = Vec::new(); |
插入元素insert
:
1 | fn main() { |
删除并返回数组中最后一个元素:
1 | fn main() { |
更多方法,可以参阅官方文档。
读取数组的元素
有两种方法引用 vector 中储存的值:通过索引或使用 get
方法:
1 | fn main() { |
使用 &
和 []
会得到一个索引位置元素的引用。当使用索引作为参数调用 get
方法时,会得到一个可以用于 match
的 Option<&T>
枚举类型。
之所以提供两种读取方法,是因为如果索引越界,程序的效果不同,如果是直接取索引:
1 | fn main() { |
rust直接会panic
,而使用get
:
1 | fn main() { |
它不会panic
而是返回 None
,当偶尔出现超过范围的访问属于正常情况的时候可以考虑使用它。接着你的代码可以有处理 Some(&element)
或 None
的逻辑,就像模式匹配里讲的那样。
一旦程序获取了一个有效的引用,借用检查器将会执行所有权和借用规则来确保vector内容的这个引用和任何其他引用保持有效。回忆一下不能在相同作用域中同时存在可变和不可变引用的规则:
1 | fn main() { |
当我们获取了数组的第一个元素的不可变引用并尝试在末尾增加一个元素的时候,如果尝试在函数的后面引用这个元素是行不通的。为什么第一个元素的引用会关心数组结尾的变化?不能这么做的原因是由于动态数组的工作方式:在动态数组的结尾增加新元素时,在没有足够空间将所有元素依次相邻存放的情况下,可能会要求分配新内存并将老的元素拷贝到新的空间中。这时,第一个元素的引用就指向了被释放的内存。借用规则阻止程序陷入这种状况。
遍历数组元素
如果想要依次访问数组中的每一个元素,我们可以遍历其所有的元素而无需通过索引一次一个的访问。
1 | fn main() { |
当然,也可以遍历可变引用,以在循环中改变原始值:
1 | fn main() { |
在其他语言中,有一种不安全的行为,比如,在python的for循环中,不断地向被迭代的对象添加元素:
1 | arr = [1, 2, 3, 4, 5] |
就会导致无限循环下去的bug。所以,在这些语言中,应当避免在循环体中对被循环的数组进行插入、删除等操作,在C/C++这样的编译型语言中,编译器也不会给出提醒报错,使得这样的错误有时难以被发现。
而rust则根本解决了这个问题。根据借用检查器的规则,无论可变还是不可变地遍历一个动态数组都是安全的。如果尝试在 for
循环体内插入或删除项,都会得到编译错误。这是因为在for
循环中已经获取了动态数组引用,阻止了同时对动态数组整体的修改,比如
1 | fn main() { |
即使改成可变引用:
1 | fn main() { |
也会由于“同一个变量的可变引用在同一作用域内最多只能有一个”这样的规则而报错。
动态数组的drop
类似于结构体,动态数组在其离开作用域时会被释放:
1 | { |
当动态数组被丢弃时,所有其内容也会被丢弃,这意味着这里它包含的元素将被清理。借用检查器确保了任何数组中内容的引用仅在数组本身有效时才可用。比如:
1 | fn main() { |
使用动态数组和枚举来存储不同类型的数据
有时候,我们需要在数组中存放不同类型的数据,就像弱类型(js)或动态类型(python)的语言一样。rust中,需要麻烦一些,通过枚举来实现这种需求。
1 | enum Number { |
这里的arr
中存储的都是枚举类型Number
,在枚举成员中会存放这些不同类型的值。rust在编译时就必须准确的知道数组中类型的原因在于,它需要知道储存每个元素到底需要多少内存。但如果在编译时不能确定有多少类型的数据,则无法使用枚举,可以通过特征对象来实现这个需求。留到后面进行讨论,详见特征对象。
10.3 哈希表 hashmap
最后介绍的常用集合类型是哈希(hashmap),HashMap<K, V>
类型储存了一个键类型 K
对应一个值类型 V
的映射。很多编程语言支持这种数据结构,不过通常有不同的名字:哈希、map、对象、哈希表、dict或者关联数组等。例如,在一个游戏中,你可以将每个团队的分数记录到hashmap中,其中键是队伍的名字而值是每个队伍的分数。给出一个队名,就能得到他们的得分。
创建hashmap
通过new
方法创建:
1 | use std::collections::HashMap; |
在这三个常用集合中,HashMap
是最不常用的,所以并没有被prelude
自动引用,需要通过use
引入HashMap
,然后创建一个空的scores
,并插入两个键值对。和String
一样,哈希表将它们的数据储存在堆上,这个 HashMap
的键类型是 String
而值类型是 i32
。类似于动态数组,哈希表是同质的:所有的键必须是相同类型,值也必须都是相同类型。
访问hashmap
可以通过 get
方法并提供对应的键来从hashmap中获取值:
1 | use std::collections::HashMap; |
这里,score
是与蓝队分数相关的值,应为 10
。get
方法返回 Option<&V>
,如果某个键在哈希 map 中没有对应的值,get
会返回 None
。程序中通过调用 copied
方法来获取一个 Option<i32>
而不是 Option<&i32>
,接着调用 unwrap_or
在 score
中没有该键所对应的项时将其设置为零。
遍历hashmap
可以使用for
循环遍历hashmap的所有值:
1 | use std::collections::HashMap; |
所有权转移
hashmap的所有权规则与其它类型没有区别,对于实现了Copy
特征的类型,该类型会被复制进hashmap;对于没实现 Copy
特征的类型,其值将被移动而hashmap会成为这些值的所有者。
1 | fn main() { |
如果将值的引用插入hashmap,这些值的所有权不会被转移。但是这些引用指向的值必须至少在hashmap有效时也是有效的,看下面的例子:
1 | fn main() { |
在{}
作用域内向hashmap中插入了一对元素,其中,key传入了引用&field_name
,field_name
的所有权没有被移动,但是在{}
作用域结束后,field_name
被自动drop后失效,hashmap的仍然保留着的这个引用所指向的值是未知的,无法通过编译。这里涉及到生命周期的概念,后面会进行介绍(见生命周期)。
更新hashmap
当我们想要改变hashmap中的数据时,必须决定如何处理一个键已经有值了的情况。有以下几种处理方法:
-
直接覆盖原先的。
如果我们插入了一个键值对,接着用相同的键插入一个不同的值,与这个键相关联的旧值将被替换。
1
2
3
4
5
6fn main() {
let mut map = HashMap::new();
map.insert(String::from("aaa"), 3);
map.insert(String::from("aaa"), 4);
println!("{:?}", map);
}最终hashmap的值为:
{"aaa": 4}
,最开始的3
被覆盖了。 -
只在键没有对应值时插入键值对。
有时可能会需要这样的操作:当插入时,hashmap中不存在这个key,则插入;hashmap存在这个key则不作任何操作。
为此hashmap提供了一个
entry
方法,它获取我们想要检查的键作为参数,它的返回值是一个枚举Entry
。这个枚举代表了可能存在也可能不存在的值。Entry
的or_insert
方法在键对应的值存在时就返回这个值的可变引用,如果不存在则将参数作为新值插入并返回新值的可变引用。1
2
3
4
5
6fn main() {
let mut map = HashMap::new();
map.insert(String::from("aaa"), 3);
map.entry(String::from("aaa")).or_insert(5);
println!("{:?}", map);
}使用
or_insert
比编写自己的逻辑要简明,另外也与借用检查器结合得更好。 -
根据旧值更新一个值。
另一个常见的应用场景是找到一个键对应的值并根据旧的值更新它。
1
2
3
4
5
6
7
8fn 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 | fn main() { |
进行编译:
1 | rustc main.rs |
在 Linux、macOS 或 Windows 的 PowerShell 上,在 shell 中输入 ls
命令可以看见这个可执行文件:
1 | $ ls |
在 Linux 和 macOS,你会看到两个文件。在 Windows PowerShell 中,你会看到同使用 CMD 相同的三个文件。在 Windows 的 CMD 上,则输入如下内容:
1 | > dir |
这展示了扩展名为.rs
的源文件、可执行文件(在 Windows 下是main.exe
,其它平台是main
),以及当使用 CMD 时会有一个包含调试信息、扩展名为.pdb
的文件。从这里开始运行main
或main.exe
文件,如下:
1 | $ ./main # Windows 是 .\main.exe |
在终端上就会打印出 Hello, world!
。
仅仅使用 rustc
编译简单程序是没问题的,不过随着项目的增长,你可能需要管理你项目的方方面面,并让代码易于分享。因此,使用rust提供的cargo
可以更好地管理项目。
11.2 模块系统
这里有一些名词需要区分,首先是crate
,它是rust在编译时最小的代码单位。如果用rustc
去编译一个文件,这个文件就被认为是一个crate
。crate
可以包含多个module
,module
可以定义在其它文件中,然后和crate
一起编译。
crate
有两种形式:二进制项(binary crate)和库(library crate)。二进制项可以被编译为可执行程序,比如一个命令行程序或者一个服务器。它们必须有一个 main
函数来定义当程序被执行的时候所需要做的事情。库并没有 main
函数,它们也不会编译为可执行程序,它们提供一些诸如函数之类的东西,使其他项目也能使用这些东西。
package
是提供一系列功能的一个或者多个crate
,可以理解为整个项目,它包含有独立的 Cargo.toml
文件,并且包含至多一个库类型的crate
(library crate),可以包含任意多个二进制crate
,而且必须至少包含一个crate
(不管是二进制的还是库的)
使用cargo new [packagename]
可以创建一个项目:
1 | $ cargo new my-project |
运行了这条命令后,Cargo会给我们的包创建一个Cargo.toml
文件,Cargo遵循的一个约定是src/main.rs
就是一个与package
同名的二进制crate
的crate
根。同样的,Cargo知道如果包目录中包含src/lib.rs
,则package
带有与其同名的库 crate
,且src/lib.rs
是crate
根。crate
根文件将由Cargo传递给rustc
来实际构建库或者二进制项目。默认情况下,创建的都是二进制的crate
,想要创建lib
类型的项目可以使用参数--lib
:
1 | $ cargo new my-lib --lib |
如果一个包同时含有src/main.rs
和src/lib.rs
,则它有两个crate
,一个二进制的和一个库的,且名字都与包相同。通过将文件放在src/bin
目录下,一个包可以拥有多个二进制crate
:每个src/bin
下的文件都会被编译成一个独立的二进制crate
。
使用cargo new
创建的项目中,package
与crate
的名字是相同的,因此有时可能容易混淆。
11.3 典型的package
结构
一个真实项目中典型的 Package
,会包含多个二进制包,这些包文件被放在 src/bin
目录下,每一个文件都是独立的二进制包,同时也会包含一个库包src/lib.rs
,该包只能存在一个 :
1 | . |
- 唯一库包:
src/lib.rs
- 默认二进制包:
src/main.rs
,编译后生成的可执行文件与Package
同名 - 其余二进制包:
src/bin/main1.rs
和src/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 | mod front_of_house { |
我们定义一个模块,是以 mod
关键字为起始,然后指定模块的名字(本例中叫做 front_of_house
),并且用花括号包围模块的主体。在模块内,我们还可以定义其他的模块,就像本例中的 hosting
和 serving
模块。模块还可以保存一些定义的其他项,比如结构体、枚举、常量、特性、或者函数。
通过使用模块,我们可以将相关的定义分组到一起,并指出他们为什么相关。程序员可以通过使用这段代码,更加容易地找到他们想要的定义,因为他们可以基于分组来对代码进行导航,而不需要阅读所有的定义。程序员向这段代码中添加一个新的功能时,他们也会知道代码应该放置在何处,可以保持程序的组织性。
在前面提到,src/main.rs
和src/lib.rs
叫做crate
根。之所以这样叫它们是因为这两个文件的内容都分别在crate
模块结构的根组成了一个名为crate
的模块,该结构被称为模块树(module tree)。
1 | crate |
这个树展示了一些模块是如何被嵌入到另一个模块的(例如,hosting
嵌套在 front_of_house
中)。这个树还展示了一些模块是互为兄弟(siblings)的,这意味着它们定义在同一模块中(hosting
和 serving
被一起定义在 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)从当前模块开始,以
self
、super
或当前模块的标识符开头。
绝对路径和相对路径都后跟一个或多个由双冒号(::
)分割的标识符。
假设希望调用 add_to_waitlist
函数,在crate
根定义了一个新函数 eat_at_restaurant
,其中使用两种方法方式调用。在src/lib.rs
:
1 | pub fn eat_at_restaurant() { |
第一种方式,我们在 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
程序。我们使用 crate
从crate
根开始就类似于在 shell 中使用 /
从文件系统根开始。
第二种方式,我们在 eat_at_restaurant
中调用 add_to_waitlist
,使用的是相对路径。这个路径以 front_of_house
为起始,这个模块在模块树中,与 eat_at_restaurant
定义在同一层级。与之等价的文件系统路径就是 front_of_house/hosting/add_to_waitlist
。以模块名开头意味着该路径是相对路径。
选择使用相对路径还是绝对路径,要取决于你的项目,也取决于你是更倾向于将项的定义代码与使用该项的代码分开来移动,还是一起移动。举一个例子,如果我们要将 front_of_house
模块和 eat_at_restaurant
函数一起移动到一个名为 customer_experience
的模块中,我们需要更新 add_to_waitlist
的绝对路径,但是相对路径还是可用的。然而,如果我们要将 eat_at_restaurant
函数单独移到一个名为 dining
的模块中,还是可以使用原本的绝对路径来调用 add_to_waitlist
,但是相对路径必须要更新。我们更倾向于使用绝对路径,因为把代码定义和项调用各自独立地移动是更常见的。
下面尝试使用cargo build
编译这个项目,会发现无法通过编译:
1 | --> src\lib.rs:34:28 |
错误信息提示 hosting
模块是私有的。换句话说,我们填写了正确的路径,但rust不允许使用,因为它不能访问私有片段。在rust中,默认所有项(函数、方法、结构体、枚举、模块和常量)对父模块都是私有的。如果希望创建一个私有函数或结构体,你可以将其放入一个模块。
父模块中的项不能使用子模块中的私有项,但是子模块中的项可以使用他们父模块中的项。这是因为子模块封装并隐藏了他们的实现详情,但是子模块可以看到他们定义的上下文。继续拿餐馆作比喻,把私有性规则想象成餐馆的后台办公室:餐馆内的事务对餐厅顾客来说是不可知的,但办公室经理可以洞悉其经营的餐厅并在其中做任何事情。
rust选择以这种方式来实现模块系统功能,因此默认隐藏内部实现细节。这样一来,你就知道可以更改内部代码的哪些部分而不会破坏外部代码。不过rust也提供了通过使用 pub
关键字来创建公共项,使子模块的内部部分暴露给上级模块。
11.6 使用pub
暴露路径
之前的例子中,hosting
模块是私有的,我们想让父模块中的 eat_at_restaurant
函数可以访问子模块中的 add_to_waitlist
函数,因此我们使用 pub
关键字来标记 hosting
模块:
1 | mod front_of_house { |
不过这还不够,在 mod hosting
前添加了 pub
关键字,使其变成公有的。伴随着这种变化,如果我们可以访问 front_of_house
,那我们也可以访问 hosting
。但是 hosting
的内容(contents)仍然是私有的;这表明使模块公有并不使其内容也是公有的。模块上的 pub
关键字只允许其父模块引用它,而不允许访问内部代码。因为模块是一个容器,只是将模块变为公有能做的其实并不太多;同时需要更深入地选择将一个或多个项变为公有。
私有性规则不但应用于模块,还应用于结构体、枚举、函数和方法。因此,还需要把函数标记为公有:
1 | mod front_of_house { |
现在代码就可以编译通过了。
11.7 使用super
起始的相对路径
我们还可以使用 super
而不是当前模块或者crate
根来开头来构建从父模块开始的相对路径。这么做类似于文件系统中以 ..
开头的语法。使用 super
允许我们引用已知的父模块中的项,当模块与父模块关联的很紧密的时候,如果某天可能需要父模块要移动到模块树的其它位置,这使得重新组织模块树变得更容易。
1 | fn deliver_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 | mod back_of_house { |
我们定义了一个公有结构体 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 | mod back_of_house { |
因为我们创建了名为 Appetizer
的公有枚举,所以我们可以在 eat_at_restaurant
中使用 Soup
和 Salad
成员。
如果枚举成员不是公有的,那么枚举会显得用处不大;给枚举的所有成员挨个添加 pub
是很令人恼火的,因此枚举成员默认就是公有的。结构体通常使用时,不必将它们的字段公有化,因此结构体遵循常规,内容全部是私有的,除非使用 pub
关键字。
还有一种使用 pub
的场景我们还没有涉及到,那就是我们最后要讲的模块功能:use
关键字。我们将先单独介绍 use
,然后展示如何结合使用 pub
和 use
。
11.9 使用use
无论是绝对路径还是相对路径,当每次想要调用某个函数时,都必须写明它的上面层级,这无疑是非常麻烦的。有一种方法可以简化这个过程。就类似于C++的using
关键字一样,我们可以使用 use
关键字创建一个短路径,然后就可以在作用域中的任何地方使用这个更短的名字。
1 | mod front_of_house { |
我们使用use
将 crate::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 | mod front_of_house { |
为了修复这个问题,可以将 use
移动到 customer
模块内,或者在子模块 customer
内通过 super::hosting
引用父模块中的这个短路径。
11.10 use
使用惯例
看下面两种引用:
1 | use crate::front_of_house::hosting; |
这两种引用都可达到使用add_to_waitlist
的效果。不过,前者是更加通用的做法,要想使用 use
将函数的父模块引入作用域,我们必须在调用函数时指定父模块,这样可以清晰地表明函数不是在本地定义的,同时使完整路径的重复度最小化;而后者则不清楚 add_to_waitlist
是在哪里被定义的。
如果想使用 use
语句将两个具有相同名称的项带入作用域,则需要引用父模块来区分它们:
1 | use std::fmt; |
如果我们是指定 use std::fmt::Result
和 use std::io::Result
,我们将在同一作用域拥有了两个 Result
类型,当我们使用 Result
时,rust则不知道我们要用的是哪个。
当然,使用 use
将两个同名类型引入同一作用域这个问题还有另一个解决办法:在这个类型的路径后面使用 as
指定一个新的本地名称或者别名:
1 | use std::fmt::Result; |
在第二个 use
语句中,我们选择 IoResult
作为 std::io::Result
的新名称,它与从 std::fmt
引入作用域的 Result
并不冲突。
11.11 重导出
使用 use
关键字,将某个名称导入当前作用域后,这个名称在此作用域中就可以使用了,但它对此作用域之外还是私有的。如果想让其他人调用我们的代码时,也能够正常使用这个名称,就好像它本来就在当前作用域一样,那我们可以将 pub
和 use
合起来使用。这种技术被称为重导出(re-exporting):我们不仅将一个名称导入了当前作用域,还允许别人把它导入他们自己的作用域。
1 | mod front_of_house { |
在这个修改之前,外部代码需要使用路径 restaurant::front_of_house::hosting::add_to_waitlist()
来调用 add_to_waitlist
函数。现在这个 pub use
从根模块重导出了 hosting
模块,外部代码现在可以使用路径 restaurant::hosting::add_to_waitlist
。
11.12 嵌套路径来消除大量的use
行
当需要引入很多定义于相同包或相同模块的项时,为每一项单独列出一行会占用源码很大的空间。
1 | use std::cmp::Ordering; |
相反,我们可以使用嵌套路径将相同的项在一行中引入作用域。这么做需要指定路径的相同部分,接着是两个冒号,接着是大括号中的各自不同的路径部分:
1 | use std::{cmp::Ordering, io}; |
在较大的程序中,使用嵌套路径从相同包或模块中引入很多项,可以显著减少所需的独立 use
语句的数量。我们可以在路径的任何层级使用嵌套路径,这在组合两个共享子路径的 use
语句时非常有用,比如:
1 | use std::io; |
两个路径的相同部分是 std::io
,这正是第一个路径。为了在一行 use
语句中引入这两个路径,可以在嵌套路径中使用 self
。
1 | use std::io::{self, Write}; |
这一行便将 std::io
和 std::io::Write
同时引入作用域。
如果希望将一个路径下所有公有项引入作用域,可以指定路径后跟 *
,即glob运算符:
1 | use std::collections::*; |
这个 use
语句将 std::collections
中定义的所有公有项引入当前作用域。
使用glob运算符时请多加小心,这会使得我们难以推导作用域中有什么名称和它们是在何处定义的。
11.13 受限可见性
对于某些情况,可能希望对于某些特定的模块可见,但是对于其他模块又不可见:
1 | pub mod a { |
我们只希望a
导出 I
、bar
和foo
,但是上述代码会报错,J
位于子模块中,对于父模块是不可见的。如果不改变代码的形式,同时满足要求,就需要限制可见性:
1 | pub mod a { |
在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 | mod front_of_house { |
我们会将模块提取到各自的文件中,而不是将所有模块都定义到crate
根文件中。在这里,crate
根文件是src/lib.rs
,不过这个过程也适用于crate
根文件是src/main.rs
的二进制crate
。
首先将 front_of_house
模块提取到其自己的文件中。删除 front_of_house
模块的大括号中的代码,只留下 mod front_of_house;
声明。
文件名:src/lib.rs
1 | mod front_of_house; |
接下来将之前大括号内的代码放入一个名叫src/front_of_house.rs
的新文件中。因为编译器找到了crate
根中名叫 front_of_house
的模块声明,它就知道去搜寻这个文件。
文件名:src/front_of_house.rs
1 | pub mod hosting { |
注意你只需在模块树中的某处使用一次 mod
声明就可以加载这个文件。一旦编译器知道了这个文件是项目的一部分(并且通过 mod
语句的位置知道了代码在模块树中的位置),项目中的其它文件应该使用其所声明的位置的路径来引用那个文件的代码。换句话说,mod
并不是某些编程语言中看到的"include"操作。
接下来我们同样将 hosting
模块提取到自己的文件中。这个过程会有所不同,因为 hosting
是 front_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 | backyard |
这个例子中的crate
根文件是src/main.rs
,该文件包括了:
文件名:src/main.rs
1 | use crate::garden::vegetables::Asparagus; |
pub mod garden;
告诉编译器应该包含在src/garden.rs
文件中发现的代码:
文件名:src/garden.rs
1 | pub mod vegetables; |
在此处, pub mod vegetables;
意味着在src/garden/vegetables.rs
中的代码也应该被包括。这些代码是:
1 |
|
12 错误处理
rust中的错误主要分为两类:
- 可恢复错误,通常用于从系统全局角度来看可以接受的错误,例如处理用户的访问、操作等错误,这些错误只会影响某个用户自身的操作进程,而不会对系统的全局稳定性产生影响
- 不可恢复错误,刚好相反,该错误通常是全局性或者系统性的错误,例如数组越界访问,系统启动时发生了影响启动流程的错误等等,这些错误的影响往往对于系统来说是致命的
12.1 使用panic!
处理不可恢复的错误
想要触发panic
一般有两种方式,一种是通过执行会造成panic
的代码(比如越界访问某数组),第二种是panic!
宏,主动触发一个不可恢复的错误。通常情况下这些panic
会打印出一个错误信息,展开并清理栈数据,然后退出。
当出现panic
时,程序默认会开始展开(unwinding),这意味着rust会回溯栈并清理它遇到的每一个函数的数据,不过这个回溯并清理的过程有很多工作。另一种选择是直接终止(abort),这会不清理数据就退出程序,那么程序所使用的内存需要由操作系统来清理。如果你需要项目的最终二进制文件越小越好,panic
时通过在Cargo.toml
的 [profile]
部分增加 panic = 'abort'
,可以由展开切换为终止。例如,如果你想要在release模式中panic
时直接终止:
1 | [profile.release] |
让我们在一个简单的程序中调用 panic!
1 | fn main() { |
在这个例子中,被指明的那一行是我们代码的一部分,而且查看这一行的话就会发现 panic!
宏的调用。在其他情况下,panic!
可能会出现在我们的代码所调用的代码中。错误信息报告的文件名和行号可能指向别人代码中的 panic!
宏调用,而不是我们代码中最终导致 panic!
的那一行。
比如:
1 | fn main() { |
越界访问数组毫无疑问会panic
,错误信息里的说明(note)行提醒我们可以设置 RUST_BACKTRACE
环境变量来得到一个 backtrace
。backtrace
是一个执行到目前位置所有被调用的函数的列表。rust的backtrace
跟其他语言中的一样:阅读backtrace
的关键是从头开始读直到发现你编写的文件。这就是问题的发源地,这一行往上是你的代码所调用的代码;往下则是调用你的代码的代码。这些行可能包含核心rust代码,标准库代码或用到的crate
代码。
下面就来设置RUST_BACKTRACE=1
并运行程序:
1 | $ RUST_BACKTRACE=1 cargo run |
这里有大量的输出。你实际看到的输出可能因不同的操作系统和rust版本而有所不同。为了获取带有这些信息的backtrace
,必须启用debug
标识。当不使用 --release
参数运行cargo build
或cargo run
时debug
标识会默认启用,就像这里默认启用了一样。
这里,编译器还会提示你如果想获取更详细的输出,可以使用RUST_BACKTRACE=full
。
12.2 使用Result
处理可恢复的错误
大部分错误并没有严重到需要程序完全停止执行。回忆一下在枚举泛型中介绍过的Result
,它定义有Ok
和 Err
:
1 | enum Result<T, E> { |
让我们调用一个返回 Result
的函数的例子:
1 | use std::fs::File; |
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 | fn main() { |
与 Option
枚举一样,Result
枚举和其成员也被导入到了prelude
中,所以就不需要在 match
分支中的 Ok
和 Err
之前指定 Result::
了。
这个模式匹配的意思是,当结果是 Ok
时,返回 Ok
成员中的 file
值,然后将这个文件句柄赋值给变量 file
。match
之后,我们可以利用这个文件句柄来进行读写;match
的另一个分支处理从 File::open
得到 Err
值的情况。在这种情况下,我们选择调用 panic!
宏。如果当前目录没有一个叫做hello.txt
的文件,当运行这段代码时会看到如下来自 panic!
宏的输出:
1 | thread 'main' panicked at 'Problem opening the file: Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" }', src\main.rs:7:19 |
匹配不同的错误
当然,你还可以细分错误的类型,如果 File::open
因为文件不存在而失败,我们希望创建这个文件并返回新文件的句柄。如果 File::open
因为任何其他原因失败,例如没有打开文件的权限,我们仍然希望 panic!
:
1 | use std::fs::File; |
File::open
返回的 Err
成员中的值类型 io::Error
,它是一个标准库中提供的结构体。这个结构体有一个返回 io::ErrorKind
值的 kind
方法可供调用。io::ErrorKind
是一个标准库提供的枚举,它的成员对应 io
操作可能导致的不同错误类型。我们感兴趣的成员是 ErrorKind::NotFound
,它代表尝试打开的文件并不存在。这样,match
就匹配完 greeting_file_result
了,不过对于 error.kind()
还有一个内层 match
。
我们希望在内层 match
中检查的条件是 error.kind()
的返回值是否为 ErrorKind
的 NotFound
成员。如果是,则尝试通过 File::create
创建文件。然而因为 File::create
也可能会失败,还需要增加一个内层 match
语句。当文件不能被打开,会打印出一个不同的错误信息。外层 match
的最后一个分支保持不变,这样对任何除了文件不存在的错误会使程序panic
。
这里有很多
match
!match
确实很强大,不过也非常的繁琐。在后面,我们会介绍闭包,这可以用于很多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
表达式且更容易阅读。
unwarp
和except
match
能够胜任它的工作,不过它可能有点冗长并且不总是能很好的表明其意图。Result<T, E>
类型定义了很多辅助方法来处理各种情况。其中之一叫做 unwrap
,它的实现就类似于上一小节的 match
语句。如果 Result
值是成员 Ok
,unwrap
会返回 Ok
中的值。如果 Result
是成员 Err
,unwrap
会为我们调用 panic!
,比如:
1 | use std::fs::File; |
如果调用这段代码时不存在hello.txt
文件,我们将会看到一个 unwrap
调用 panic!
时提供的错误信息:
1 | thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" }', src\main.rs:4:49 |
还有另一个类似于 unwrap
的方法它还允许我们选择 panic!
的错误信息:expect
。使用 expect
而不是 unwrap
并提供一个好的错误信息可以表明你的意图并更易于追踪 panic 的根源。
1 | use std::fs::File; |
expect
与 unwrap
的使用方式一样:返回文件句柄或调用 panic!
宏。expect
在调用 panic!
时使用的错误信息将是我们传递给 expect
的参数,而不像 unwrap
那样使用默认的 panic!
信息。它看起来像这样:
1 | thread 'main' panicked at 'hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "系统找不到指定的文件。" }', src\main.rs:5:10 |
在生产级别的代码中选择 expect
而不是 unwrap
并提供更多关于为何操作期望是一直成功的上下文。如此如果该假设真的被证明是错的,你也有更多的信息来用于调试。
传播错误
当编写一个其实先会调用一些可能会失败的操作的函数时,除了在这个函数中处理错误外,还可以选择让调用者知道这个错误并决定该如何处理。换句话说,在获取到这个错误后并不立即进行处理,而是将其传递到上一层调用者的手里。这称为传播(propagating)错误,这样能更好的控制代码调用,因为比起你代码所拥有的上下文,调用者可能拥有更多信息或逻辑来决定应该如何处理错误。
下面展示了一个从文件中读取用户名的函数。如果文件不存在或不能读取,这个函数会将这些错误返回给调用它的代码:
1 | use std::fs::File; |
首先注意函数的返回值:Result<String, io::Error>
。这意味着函数返回一个 Result<T, E>
类型的值,其中泛型参数 T
的具体类型是 String
,而 E
的具体类型是 io::Error
。
如果这个函数没有出任何错误成功返回,函数的调用者会收到一个包含 String
的 Ok
值 —— 函数从文件中读取到的用户名。如果函数遇到任何错误,函数的调用者会收到一个 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_file
的 read_to_string
方法来将文件的内容读取到 username
中。read_to_string
方法也返回一个 Result
因为它也可能会失败:哪怕是 File::open
已经成功了。所以我们需要另一个 match
来处理这个 Result
:如果 read_to_string
成功了,那么这个函数就成功了,并返回文件中的用户名,它现在位于被封装进 Ok
的 username
中。如果read_to_string
失败了,则像之前处理 File::open
的返回值的 match
那样返回错误值。不过并不需要显式的调用 return
,因为这是函数的最后一个表达式。
调用这个函数的代码最终会得到一个包含用户名的 Ok
值,或者一个包含 io::Error
的 Err
值。我们无从得知调用者会如何处理这些值。例如,如果他们得到了一个 Err
值,他们可能会选择 panic!
并使程序崩溃、使用一个默认的用户名或者从文件之外的地方寻找用户名。我们没有足够的信息知晓调用者具体会如何尝试,所以将所有的成功或失败信息向上传播,让他们选择合适的处理方法。
上述代码在实际的使用中很常用,因此rust提供了?
问号运算符来使其更加简便。
使用?
传播错误
使用?
来简化上一小节的代码:
1 | use std::fs::File; |
Result
值之后的 ?
被定义为上一小节中处理 Result
值的 match
表达式相同的工作方式:如果 Result
的值是 Ok
,这个表达式将会返回 Ok
中的值而程序将继续执行。如果值是 Err
,Err
中的值将作为整个函数的返回值,就好像使用了 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 | use std::fs::File; |
我们对 File::open("hello.txt")?
的结果直接链式调用了 read_to_string
,而不再创建变量 username_file
。仍然需要 read_to_string
调用结尾的 ?
,而且当 File::open
和 read_to_string
都成功没有失败时返回包含用户名 username
的 Ok
值。
当然,还有一个更简单的版本:
1 | use std::fs; |
将文件读取到一个字符串是相当常见的操作,所以rust提供了名为 fs::read_to_string
的函数,它会打开文件、新建一个 String
、读取文件的内容,并将内容放入 String
,接着返回它。
使用?
的位置
?
运算符只能被用于返回值与 ?
作用的值相兼容的函数。因为 ?
运算符被定义为从函数中提早返回一个值,让我们看看在返回值不兼容的 main
函数中使用 ?
运算符会得到什么错误:
1 | use std::fs::File; |
这段代码打开一个文件,这可能会失败。?
运算符作用于 File::open
返回的 Result
值,不过 main
函数的返回类型是 ()
而不是 Result
。这个错误指出只能在返回 Result
或者其它实现了 FromResidual
的类型的函数中使用 ?
运算符。
为了修复这个错误,有两个选择。一个是如果没有限制的话将函数的返回值改为 Result<T, E>
。另一个是使用 match
或 Result<T, E>
的方法中合适的一个来处理 Result<T, E>
。
错误信息也提到 ?
也可用于 Option<T>
值。如同对 Result
使用 ?
一样,只能在返回 Option
的函数中对 Option
使用 ?
。在 Option<T>
上调用 ?
运算符的行为与 Result<T, E>
类似:如果值是 None
,此时 None
会从函数中提前返回。如果值是 Some
,Some
中的值作为表达式的返回值同时函数继续。比如:
1 | fn last_char_of_first_line(text: &str) -> Option<char> { |
上面的例子是一个从给定文本中返回第一行最后一个字符的函数。
这个函数返回 Option<char>
因为它可能会在这个位置找到一个字符,也可能没有字符。这段代码获取 text
字符串切片作为参数并调用其 lines
方法,这会返回一个字符串中每一行的迭代器。因为函数希望检查第一行,所以调用了迭代器 next
来获取迭代器中第一个值。如果 text
是空字符串,next
调用会返回 None
,此时我们可以使用 ?
来停止并从 last_char_of_first_line
返回 None
。如果 text
不是空字符串,next
会返回一个包含 text
中第一行的字符串slice
的 Some
值。
?
会提取这个字符串切片,然后可以在字符串切片上调用 chars
来获取字符的迭代器。我们感兴趣的是第一行的最后一个字符,所以可以调用 last
来返回迭代器的最后一项。这是一个 Option
,因为有可能第一行是一个空字符串,例如 text
以一个空行开头而后面的行有文本,像是 "\nhi"
。不过,如果第一行有最后一个字符,它会返回在一个 Some
成员中。?
运算符作用于其中给了我们一个简洁的表达这种逻辑的方式。如果我们不能在 Option
上使用 ?
运算符,则不得不使用更多的方法调用或者 match
表达式来实现这些逻辑。
注意你可以在返回 Result
的函数中对 Result
使用 ?
运算符,可以在返回 Option
的函数中对 Option
使用 ?
运算符,但是不可以混合搭配。?
运算符不会自动将 Result
转化为 Option
,反之亦然;在这些情况下,可以使用类似 Result
的 ok
方法或者 Option
的 ok_or
方法来显式转换。
幸运的是 main
函数也可以返回 Result<(), E>
,下面的代码修改了 main
的返回值为 Result<(), Box<dyn Error>>
并在结尾增加了一个 Ok(())
作为返回值。可以通过编译:
1 | use std::error::Error; |
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
特征的类型,它包含了一个返回 ExitCode
的 report
函数。请查阅标准库文档了解更多为自定义类型实现 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
的类型,则程序期望它是有值的并且不是空值。你的代码无需处理 Some
和 None
这两种情况,它只会有一种情况就是绝对会有一个值。尝试向函数传递空值的代码甚至根本不能编译,所以你的函数在运行时没有必要判空。另外一个例子是使用像 u32
这样的无符号整型,也会确保它永远不为负。
总之rust的错误处理功能被设计为帮助你编写更加健壮的代码。panic!
宏代表一个程序无法处理的状态,并停止执行而不是使用无效或不正确的值继续处理。rust类型系统的 Result
枚举代表操作可能会在一种可以恢复的情况下失败。可以使用 Result
来告诉代码调用者他需要处理潜在的成功或失败。在适当的场景使用 panic!
和 Result
将会使你的代码在面对不可避免的错误时显得更加可靠。
13 生命周期
生命周期用来保证所有的引用都是有效的,它实际上是另一类泛型。一个变量的生命周期在它创建的时候开始,在它销毁的时候结束。
在引用小节中我们遗漏了一个重要的细节:rust中的每一个引用都有其生命周期,也就是引用保持有效的作用域。大部分时候生命周期是隐含并可以推断的,正如大部分时候类型也是可以推断的一样。类似于当因为有多种可能类型的时候必须注明类型,也会出现引用的生命周期以一些不同方式相关联的情况,所以rust需要我们使用泛型生命周期参数来注明他们的关系,这样就能确保运行时实际使用的引用绝对是有效的。
13.1 悬垂引用
生命周期的主要目标是避免悬垂引用(dangling references):
1 | fn main() { |
上面的代码中:
- 看似使用
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 | fn main() { |
这里将 r
的生命周期标记为 'a
并将 x
的生命周期标记为 'b
。如你所见,内部的 'b
块要比外部的生命周期 'a
小得多。在编译时,rust比较这两个生命周期的大小,并发现 r
拥有生命周期 'a
,不过它引用了一个拥有生命周期 'b
的对象。程序被拒绝编译,因为生命周期 'b
比生命周期 'a
要小:被引用的对象比它的引用者存在的时间更短。
下面我们尝试修复它:
1 | fn main() { |
这里 x
拥有生命周期 'b
,比 'a
要大。这就意味着 r
可以引用 x
:rust知道 r
中的引用在 x
有效的时候也总是有效的。或者说,从r
被创建到销毁,都可以保证它的引用是有效的。
接下来让我们看看在函数的上下文中参数和返回值的泛型生命周期。
13.3 函数中的泛型生命周期
首先来编写一个返回两个字符串切片中较长者的函数。这个函数获取两个字符串切片并返回一个字符串切片。
1 | fn main() { |
注意这个函数获取作为引用的字符串切片,而不是字符串,因为我们不希望 longest
函数获取参数的所有权。
下面就是实现这个longest
函数,一旦我们实现了该函数,运行应该会打印出 The longest string is abcd
:
1 | fn longest(x: &str, y: &str) -> &str { |
可惜这并不能编译通过,你会收到如下错误:
1 | | |
提示返回值需要一个泛型生命周期参数,这是因为在函数内无法确定到底返回x
的引用还是y
的引用。当我们定义这个函数的时候,并不知道传递给函数的具体值,所以也不知道到底是 if
还是 else
会被执行。我们也不知道传入的引用的具体生命周期,所以也不能通过观察作用域来确定返回的引用是否总是有效,借用检查器自身同样也无法确定,因为它不知道 x
和 y
的生命周期是如何与返回值的生命周期相关联的。为了修复这个错误,我们将增加泛型生命周期参数来定义引用间的关系以便借用检查器可以进行分析。也就是说,需要我们手动地为编译器标注出生命周期。
13.4 生命周期注解语法
生命周期注解并不改变任何引用的生命周期的长短。相反它们描述了多个引用生命周期相互的关系,而不影响其生命周期。与当函数签名中指定了泛型类型参数后就可以接受任何类型一样,当指定了泛型生命周期后函数也能接受任何生命周期的引用。
生命周期注解有着一个不太常见的语法:生命周期参数名称必须以撇('
)开头,其名称通常全是小写,类似于泛型其名称非常短。大多数人使用 'a
作为第一个生命周期注解。生命周期参数注解位于引用的 &
之后,并有一个空格来将引用类型与生命周期注解分隔开。
1 | &i32 // 引用 |
单个的生命周期注解本身没有多少意义,生命周期注解告诉rust编译器多个引用的泛型生命周期参数如何相互联系的。比如,如果函数有一个生命周期 'a
的 i32
的引用的参数 first
。还有另一个同样是生命周期 'a
的 i32
的引用的参数 second
。这两个生命周期注解意味着引用 first
和 second
必须与这泛型生命周期存在得一样久。
13.5 函数签名中的生命周期注解
为了在函数签名中使用生命周期注解,需要在函数名和参数列表间的尖括号中声明泛型生命周期参数,就像泛型类型参数一样。我们希望函数签名表达如下限制:也就是这两个参数和返回的引用存活的一样久。两个参数和返回的引用的生命周期是相关的。
1 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { |
这个longest
函数可以通过编译,函数签名表明对于某些生命周期 'a
,函数会获取两个参数,他们都是与生命周期 'a
存在的一样长的字符串切片,函数会返回一个同样也与生命周期 'a
存在的一样长的字符串切片。它的实际含义是 longest
函数返回的引用的生命周期与函数参数所引用的值的生命周期的较小者一致。这些关系就是我们希望rust分析代码时所使用的。
通过在函数签名中指定生命周期参数,我们并没有改变任何传入值或返回值的生命周期,而是指出任何不满足这个约束条件的值都将被借用检查器拒绝。注意 longest
函数并不需要知道 x
和 y
具体会存在多久,而只需要知道有某个可以被 'a
替代的作用域将会满足这个签名。
当在函数中使用生命周期注解时,这些注解出现在函数签名中,而不存在于函数体中的任何代码中。生命周期注解成为了函数约定的一部分,非常像签名中的类型。让函数签名包含生命周期约定意味着rust编译器的工作变得更简单了。如果函数注解有误或者调用方法不对,编译器错误可以更准确地指出代码和限制的部分。如果不这么做的话,rust编译会对我们期望的生命周期关系做更多的推断,这样编译器可能只能指出距离出现问题地方很多步之外的代码。
让我们看看如何通过传递拥有不同具体生命周期的引用来限制 longest
函数的使用:
1 | fn main() { |
在这个例子中,string1
直到外部作用域结束都是有效的,string2
则在内部作用域中是有效的,而 result
则引用了一些直到内部作用域结束都是有效的值。代码能够编译和运行,并打印出 The longest string is long string is long
。
以下代码将 result
变量的声明移动出内部作用域,但是将 result
和 string2
变量的赋值语句一同留在内部作用域中。接着,使用了变量 result
的 println!
也被移动到内部作用域之外。该例子可以看出result
的引用的生命周期必须是两个参数中较短的那个。
1 | fn main() { |
错误表明为了保证 println!
中的 result
是有效的,string2
需要直到外部作用域结束都是有效的。rust知道这些是因为longest
函数的参数和返回值都使用了相同的生命周期参数 'a
。
从人的角度来说这可能有点反直觉,因为很明显string1
更长,longest
函数会返回string1
的引用,因为 string1
尚未离开作用域,对于 println!
来说 string1
的引用仍然是有效的。然而,我们通过生命周期参数告诉rust的是longest
函数返回的引用的生命周期应该与传入参数的生命周期中较短那个保持一致。因此,借用检查器不允许代码编译通过。
13.6 深入理解生命周期
指定生命周期参数的正确方式依赖函数实现的具体功能。例如,如果将 longest
函数的实现修改为总是返回第一个参数而不是最长的字符串切片,就不需要为参数 y
指定一个生命周期。如下代码将能够编译:
1 | fn longest<'a>(x: &'a str, y: &str) -> &'a str { |
我们为参数 x
和返回值指定了生命周期参数 'a
,不过没有为参数 y
指定,因为 y
的生命周期与参数 x
和返回值的生命周期没有任何关系。
当从函数返回一个引用,返回值的生命周期参数需要与一个参数的生命周期参数相匹配。如果返回的引用没有指向任何一个参数,那么唯一的可能就是它指向一个函数内部创建的值。然而它将会是一个悬垂引用,因为它将会在函数结束时离开作用域。比如:
1 | fn longest<'a>(x: &str, y: &str) -> &'a str { |
这显然无法通过编译,因为即便我们为返回值指定了生命周期参数 'a
,result
在 longest
函数的结尾将离开作用域并被清理,而我们尝试从函数返回一个 result
的引用,但result
存活得不够长。
无法通过指定生命周期参数来改变悬垂引用,而且rust也不允许我们创建一个悬垂引用。在这种情况最好的解决方案是返回一个有所有权的数据类型而不是一个引用,这样函数调用者就需要负责清理这个值了。
综上,生命周期语法是用于将函数的多个参数与其返回值的生命周期进行关联的。一旦他们形成了某种关联,rust就有了足够的信息来允许内存安全的操作并阻止会产生悬垂指针亦或是违反内存安全的行为。
13.7 结构体定义中的生命周期注解
在之前结构体的例子中,字段中并没有涉及到引用,而是使用了自身拥有所有权的类型(比如String
)。这是因为在结构体中使用引用涉及到生命周期的概念,下面就来详细介绍。
要定义包含引用的结构体,需要为结构体定义中的每一个引用添加生命周期注解。
1 | struct ImportantExcerpt<'a> { |
这个结构体有唯一一个字段 part
,它存放了一个字符串切片,这是一个引用。类似于泛型参数类型,必须在结构体名称后面的尖括号中声明泛型生命周期参数,以便在结构体定义中使用生命周期参数。这个注解意味着 ImportantExcerpt
的实例不能比其 part
字段中的引用存在得更久。
这里的 main
函数创建了一个 ImportantExcerpt
的实例(第8行),它存放了变量 novel
所拥有的 String
的第一个句子的引用。novel
的数据在 ImportantExcerpt
实例创建之前就存在(第6行)。另外,直到 ImportantExcerpt
离开作用域之后 novel
都不会离开作用域,所以 ImportantExcerpt
实例中的引用是有效的。
如果将代码修改一下:
1 |
|
就无法通过编译了,因为结构体比它引用的字符串存在得更久,引用字符串在作用域结束被释放后(第14行),println!
依然在外面使用了该结构体,因此会导致无效的引用。
13.8 生命周期消除(Lifetime Elision)
现在我们已经知道了每一个引用都有一个生命周期,而且我们需要为那些使用了引用的函数或结构体指定生命周期。但是,下面的代码没有对生命周期标注也可以编译成功:
1 | fn first_word(s: &str) -> &str { |
这里定义了一个没有使用生命周期注解的函数,即便其参数和返回值都是引用。这个函数没有生命周期注解却能编译是由于一些历史原因:在早期版本(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
块。
- 第一条规则是编译器为每一个是引用参数都分配了一个生命周期参数。换句话说就是,有一个引用参数的函数有一个生命周期参数:
fn foo<'a>(x: &'a i32)
,有两个引用参数的函数有两个不同的生命周期参数,fn foo<'a, 'b>(x: &'a i32, y: &'b i32)
,依此类推。 - 第二条规则是如果只有一个输入生命周期参数,那么它被赋予所有输出生命周期参数:
fn foo<'a>(x: &'a i32) -> &'a i32
。 - 第三条规则是如果方法有多个输入生命周期参数并且其中一个参数是
&self
或&mut self
,说明是个对象的方法,那么所有输出生命周期参数被赋予self
的生命周期。第三条规则使得方法更容易读写,因为只需更少的符号。
下面模拟编译器如何根据这些规则计算生命周期,开始时签名中的引用并没有关联任何生命周期:
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 | struct ImportantExcerpt<'a> { |
结构体字段的生命周期必须总是在 impl
关键字之后声明并在结构体名称之后被使用,因为这些生命周期是结构体类型的一部分。这里有一个方法 level
。其唯一的参数是 self
的引用,而且返回值只是一个 i32
,并不引用任何值。因为第一条生命周期规则我们并不必须标注 self
引用的生命周期。
下面的例子展示了第三规则应用的场景:
1 | impl<'a> ImportantExcerpt<'a> { |
这里有两个输入生命周期,所以rust应用第一条生命周期省略规则并给予 &self
和 announcement
他们各自的生命周期:
1 | impl<'a> ImportantExcerpt<'a> { |
接着,因为其中一个参数是 &self
,根据第三条规则,返回值类型被赋予了 &self
的生命周期,
1 | impl<'a> ImportantExcerpt<'a> { |
这样所有的生命周期都被计算出来了。
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 恤。如果他没有设置喜爱的颜色,他们会获赠公司现存最多的颜色的款式。
有很多种方式来实现这些。例如,使用有 Red
和 Blue
两个成员的 ShirtColor
枚举(出于简单考虑限定为两种颜色)。我们使用 Inventory
结构体来代表公司的库存,它有一个类型为 Vec<ShirtColor>
的 shirts
字段表示库存中的衬衫的颜色。Inventory
上定义的 giveaway
方法获取免费衬衫得主所喜爱的颜色(如有),并返回其获得的衬衫的颜色。
1 |
|
在上面的代码中,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_pref1
是Some(ShirtColor::Red)
,则调用unwrap_or_else
时返回的就是Some
中的ShirtColor::Red
。
重点是第二个成员,他没有设置喜爱的颜色(user_pref2
是None
),则调用unwrap_or_else
时,会去调用闭包并返回闭包的返回值。
我们将闭包表达式 || self.most_stocked()
用作 unwrap_or_else
的参数。这是一个本身不获取参数的闭包(如果闭包有参数,它们会出现在两道竖杠之间)。闭包体调用了 self.most_stocked()
。我们在这里定义了闭包,而 unwrap_or_else
的实现会在之后需要其结果的时候执行闭包。对于本例来说,第二个成员就会执行该闭包,调用most_stocked
方法获取公司现存最多的颜色的款式并返回。
这里一个有趣的地方是我们传递了一个会在当前 Inventory
实例上调用 self.most_stocked()
的闭包。标准库并不需要知道我们定义的 Inventory
或 ShirtColor
类型或是在这个场景下我们想要用的逻辑。闭包捕获了一个 Inventory
实例的不可变引用到 self
,并连同其它代码传递给 unwrap_or_else
方法。相比之下,函数就不能以这种方式捕获其环境。
闭包的定义
通常,类似于lambda
表达式一样,闭包是一个匿名函数,不需要标注类型,定义形式如下:
1 | |param1, param2,...| { |
如果只有一个返回表达式:
1 | |param1| 返回表达式 |
如果没有参数:
1 | || 返回表达式 |
闭包的一些特性:
- 声明时使用
||
将输入参数括起来。 - 如果有多行,需要用
{}
,对于单个表达式则不需要。 - 闭包有能力捕获外部环境的变量
- 闭包中最后一行表达式返回的值,就是闭包执行后的返回值
- 将闭包绑定到变量上并不会执行它,比如定义
let a = || ...
,这里的a
就相当于函数一样可以调用:a()
闭包类型推断和注解
函数与闭包还有更多区别。闭包并不总是要求像 fn
函数那样在参数和返回值上注明类型。函数中需要类型注解是因为他们是暴露给用户的显式接口的一部分。严格定义这些接口对保证所有人都对函数使用和返回值的类型理解一致是很重要的。与此相比,闭包并不用于这样暴露在外的接口:他们储存在变量中并被使用,不用命名他们或暴露给库的用户调用。
闭包通常很短,并只关联于小范围的上下文而非任意情境。在这些有限制的上下文中,编译器能可靠地推断参数和返回值的类型,类似于它是如何能够推断大部分变量的类型一样;当然,同时也有编译器需要闭包类型注解的极少数情况。
类似于变量类型标注一样,如果我们希望增加明确性和清晰度也可以为闭包添加类型标注,坏处是使代码变得更啰嗦。比如:
1 | let expensive_closure = |num: u32| -> u32 { |
我们定义了一个闭包并将它保存在变量中,并为参数和返回值增加了类型注解。使用这种语法就更类似函数了,下面是函数和闭包在形式上的对比:
1 | fn add_one_v1 (x: u32) -> u32 { x + 1 } |
第一行展示了一个函数定义,第二行展示了一个完整标注的闭包定义。第三行闭包定义中省略了类型注解,而第四行去掉了可选的大括号,因为闭包体只有一个表达式。这些都是有效的闭包定义,并在调用时产生相同的行为。
调用闭包是 add_one_v3
和 add_one_v4
能够编译的必要条件,因为类型将从其用法中推断出来。这类似于 let v = Vec::new();
,rust需要类型注解或是某种类型的值被插入到 Vec
才能推断其类型。
编译器会为闭包定义中的每个参数和返回值推断一个具体类型。但是,类型推断不是泛型,一旦类型确定,编译器就会一直使用该类型,如果尝试对同一闭包使用不同类型则就会得到类型错误,比如:
1 | let example_closure = |x| x; |
这里定义了一个闭包,接受一个参数x
并直接将其返回。在闭包定义中没有增加任何类型注解,所以我们可以用任意类型来调用这个闭包,所以第一次调用闭包时,我们使用String
类型作为参数,编译器推断这个闭包中 x
的类型以及返回值的类型是 String
。接着这些类型被锁定进闭包 example_closure
中。
紧接着第二次我们使用 i32
调用闭包,这与编译器之前推导的 String
类型不符,因此报错:
1 | | |
捕获引用或者移动所有权
闭包可以通过三种方式捕获其环境,它们直接对应到函数获取参数的三种方式:不可变借用,可变借用和获取所有权。闭包会根据函数体中如何使用被捕获的值决定用哪种方式捕获,一般来说,在满足使用需求的前提下以最小的访问捕获,比如:如果不可变借用可以完成任务,就不会使用可变借用。
获取不可变引用
1 | fn main() { |
这里定义了一个捕获名为 list
的动态数组的不可变引用的闭包,因为只需不可变引用就能打印其值。因为同时可以有多个 list
的不可变引用,所以在闭包定义之前,闭包定义之后调用之前,闭包调用之后代码仍然可以访问 list
。代码可以通过编译。
获取可变引用
接下来,我们修改闭包体让它向 list
动态数组增加一个元素。
1 | fn main() { |
闭包现在捕获一个可变引用。注意在 borrows_mutably
闭包的定义和调用之间不再有 println!
,当 borrows_mutably
定义时,它捕获了 list
的可变引用。闭包在被调用后就不再被使用,这时可变引用结束。由于可变引用与不可变引用不能同时存在,当可变引用存在时(第5~8行)不允许用println!
获取不可变引用来打印,尝试取消第6行的注释并运行,看看会获得什么错误。
另外,由于在闭包中改变了外部list
的值,捕获的是它的可变引用,此时还需要将闭包绑定的变量也设置为mut
,即:let mut borrows_mutably
。
获取所有权
即使闭包体不严格需要所有权,如果希望强制闭包获取它用到的环境中值的所有权,可以在参数列表前使用 move
关键字。在将闭包传递到一个新的线程时这个技巧很有用,它可以移动数据所有权给新线程。关于并发的细节我们放到后面,现在首先来简单探讨用需要 move
关键字的闭包来生成新的线程。
1 | use std::thread; |
我们生成了新的线程,给这个线程一个闭包作为参数运行,闭包体打印出列表。这里闭包通过不可变引用捕获 list
,因为这是打印列表所需的最少的访问。这个例子中,尽管闭包体依然只需要不可变引用,我们还是在闭包定义前写上 move
关键字来指明 list
应当被移动到闭包中。新线程可能在主线程剩余部分执行完前执行完,或者也可能主线程先执行完。如果主线程维护了 list
的所有权但却在新线程之前结束并且丢弃了 list
,则在线程中的不可变引用将失效。因此,编译器要求 list
被移动到在新线程中运行的闭包中,这样引用就是有效的。如果去掉 move
关键字或在闭包被定义后在主线程中使用 list
就会报错。
将被捕获的值移出闭包和Fn
特征
闭包可以当做参数传递给函数,这就涉及到一个问题:函数的参数必须显式标注其类型,而闭包的类型随着参数和返回值的变化而变化。有没有一种方式可以统一表示一类闭包呢?没错,可以利用泛型和泛型的特征约束来做到这一点。对应于捕获其环境的三种方式:转移所有权、可变借用、不可变借用,相应的我们将其称为 Fn
特征,也有三种:
FnOnce
表示捕获方式为通过获取所有权(T
)的闭包。适用于能被调用一次的闭包,所有闭包都至少实现了这个特征,因为所有闭包都能被调用。一个会将捕获的值移出闭包体的闭包只实现FnOnce
特征,这是因为它只能被调用一次。FnMut
表示捕获方式为通过可变引用(&mut T
)的闭包。适用于不会将捕获的值移出闭包体的闭包,但它可能会修改被捕获的值。这类闭包可以被调用多次。Fn
表示捕获方式为通过不可变引用(&T
)的闭包。适用于既不将被捕获的值移出闭包体也不修改被捕获的值的闭包,当然也包括不从环境中捕获值的闭包。这类闭包可以被调用多次而不改变它们的环境,这在会多次并发调用闭包的场景中十分重要。
FnOnce
下面来看看我们在之前使用的Option<T>
上的 unwrap_or_else
方法的定义:
1 | impl<T> Option<T> { |
T
是表示 Option
中 Some
成员中的值的类型的泛型。类型 T
也是 unwrap_or_else
函数的返回值类型:举例来说,在 Option<String>
上调用 unwrap_or_else
会得到一个 String
。接下来注意到,unwrap_or_else
函数有额外的泛型参数 F
。 F
是 f
参数(即调用 unwrap_or_else
时提供的闭包)的类型。在where
中,泛型 F
的特征约束是 FnOnce() -> T
,这意味着 F
必须能够被调用一次,没有参数并返回一个 T
。
在特征约束中使用 FnOnce
表示 unwrap_or_else
将最多调用 f
一次。在 unwrap_or_else
的函数体中可以看到,如果 Option
是 Some
,f
不会被调用。如果 Option
是 None
,f
将会被调用一次。由于所有的闭包都实现了 FnOnce
,unwrap_or_else
能接收绝大多数不同类型的闭包,十分灵活。
再看一个例子:
1 | fn fn_once<F>(func: F) |
这里定义了一个函数,F
的特征约束是FnOnce(usize) -> bool
,这意味着F
有一个usize
类型的参数,返回值为bool
类型。这里仅实现了FnOnce
,因此在调用时会转移这个闭包所有权,如果将第6行取消注释,会提示转移所有权后再次调用的错误。
FnMut
1 | fn fn_mut<F>(mut func: F) |
同样定义一个函数,在这里F
的特征约束为FnMut(&str) -> ()
,意味着F
有一个&str
类型的参数,没有返回值。在闭包中,我们调用 a.push_str
去改变外部 a
的字符串值,因此这里捕获了它的可变引用。由于闭包内部捕获了可变引用,闭包变量也要添加mut
声明,即mut func
。在函数内多次调用闭包,修改外部a
的值,最终会输出helloworld!
。
Fn
1 | fn exec<F>(func: F) |
同样地,我们将闭包改为获取a
的不可变引用,并修改F
的特征约束为Fn
即可。
深入理解Fn
特征
对闭包所要捕获的每个变量,编译器会根据闭包的行为自动推断以限制最少的方式来捕获。但是,一个闭包实现了哪种Fn
特征取决于该闭包如何使用被捕获的变量,而不是取决于闭包如何捕获它们。因此,使用 move
的闭包也可能实现了Fn
或 FnMut
特征。
1 | fn exec<F>(func: F) |
对于这段代码,闭包的行为本身只需要获取a
的不可变引用即可,但这里用move
将a
的所有权转移进闭包。此时这个闭包不仅仅实现了FnOnce
,还实现了Fn
和 FnMut
特征。将FnOnce
修改为Fn
依然可以通过编译:
1 | fn exec<F>(func: F) |
可以这样理解,一个闭包实现哪几种Fn
特征,是由该闭包如何使用被捕获的变量而决定的:
- 所有的闭包都自动实现了
FnOnce
特征,因此任何一个闭包都至少可以被调用一次 - 没有移出所捕获变量的所有权的闭包自动实现了
FnMut
特征 - 不需要对捕获变量进行改变的闭包自动实现了
Fn
特征
使用move
可以强制获取捕获的变量的所有权,但与闭包实现了哪些特征没有必然联系。
通过源码来看看这三种特征的定义:
1 | pub trait FnOnce<Args: Tuple> { |
这里用到了特征“继承”,如果你接触过支持继承的语言,应该不难理解:继承通常用来描述子类拥有父类的特征和行为。在rust中,可以定义依赖于某个特征的特征(可以理解为特征继承)。比如,定义特征
A
和B
,希望B
特征也实现了A
特征,如此一来就可以在B
中使用A
特征的关联项。我们可以称特征B
继承特征A
,实现继承的方式很简单,在定义特征B
时使用冒号加上特征A
即可。
1
2 trait A {}
trait B: A {}
主要关注以下两点:
Fn
继承FnMut
,FnMut
继承FnOnce
,因此要实现Fn
就要同时实现FnMut
和FnOnce
- 另外,
Fn
获取&self
,FnMut
获取&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 | fn factory() -> impl Fn(i32) -> i32 { |
上面的代码中,函数签名impl Fn(i32) -> i32
表示返回一个闭包类型,它实现了 Fn(i32) -> i32
特征。注意需要对闭包添加move
转移被捕获变量num
的所有权,因为闭包可能比当前函数活得更久,但它借用了函数拥有的本地变量num
。
这样做可以,但正如返回实现了特征的类型提到的,这种方式有一个局限,如果返回类型不单一,则无法通过编译:
1 | fn factory(x:i32) -> impl Fn(i32) -> i32 { |
即使签名一样的闭包,类型也是不同的。不过,解决方法也和impl Trait
的解决方法一样,使用特征对象即可:
1 | fn factory(x:i32) -> Box<dyn Fn(i32) -> i32> { |
1.2 迭代器
迭代器模式允许你对一个序列的项进行某些处理。迭代器(iterator)负责遍历序列中的每一项和决定序列何时结束的逻辑。
rust中的迭代器是惰性的(lazy),在调用方法使用迭代器之前,它都不会有效果:
1 | fn main() { |
迭代器被储存在 v1_iter
变量中,此时还没有对它进行迭代。一旦创建迭代器之后,可以选择用多种方式利用它,比如对它进行遍历:
1 | fn main() { |
在标准库中没有提供迭代器的语言中(如C),我们可能会使用一个从0开始的索引变量,使用这个变量索引vector中的值,并循环增加其值直到达到vector的元素数量。而迭代器没有使用索引,无需去关心索引的起始位置、终止位置,迭代器为我们处理了所有这些逻辑,这减少了重复代码并消除了潜在的混乱。并且,对于不同的序列,如数组、动态数组、hashmap都可以使用相同的逻辑处理。
Iterator
特征和next
方法
所有的迭代器都实现了Iterator
特征,它定义于标准库中。这个特征的定义如下:
1 | pub trait Iterator { |
不过,此处有两处新的语法:type Item
和 Self::Item
,它们定义了特征的关联类型,我们会在后面介绍关联类型。现在只需知道这段代码表明实现 Iterator
特征要求同时定义一个 Item
类型,这个 Item
类型被用作 next
方法的返回值类型。换句话说,Item
类型将是迭代器返回元素的类型。
next
是 Iterator
实现者被要求定义的唯一方法。next
一次返回迭代器中的一个项,封装在Some
中,当迭代器结束时,它返回None
。可以直接使用next
方法:
1 | fn main() { |
需要为迭代器添加mut
,因为在迭代器上调用 next
方法改变了迭代器中用来记录序列位置的状态。换句话说,代码消费(consume)了,或使用了迭代器。从源码的角度来看,next
方法的签名fn next(&mut self)
,获取的是当前迭代器实例的可变引用。每一个 next
调用都会从迭代器中消费一个项。
for
循环和迭代器
这里有一个细节,为什么在for
循环时不需要将迭代器设置为可变的呢:
1 | fn main() { |
其实for
本身是迭代器的语法糖,如果没有特别指定,for
循环会对in
后面的集合隐式应用 into_iter
方法,这个方法是在IntoIterator
特征中被定义的,该特征声明如下:
1 | pub trait IntoIterator { |
根据The Rust Reference,for in
可以等价地写成:
1 | fn main() { |
通过into_iter
方法,会获得这个动态数组的迭代器,使用match
去匹配它,匹配项mut iter
说明,在内部将这个迭代器转换为可变的。然后,通过一个loop
,不停地调用next
方法,匹配它的返回值:如果是Some(x)
就打印它们,直到遇到None
,就终止循环。
对于迭代器本身,它也实现了 IntoIterator
特征,源码是这样实现的:
1 | impl<I: Iterator> const IntoIterator for I { |
因此:
- 对于非迭代器(如动态数组),
into_iter
会尝试将其转化为迭代器并返回。 - 对于迭代器,
into_iter
会返回它本身self
.
由于for
的语法糖,在内部已经自动实现了mut iter
这样的转换,所以不需要将迭代器设置为可变的。
迭代器和所有权
在前面我们介绍了IntoIterator
特征中的into_iter
方法,该方法会获取集合的所有权,因此使用for
循环遍历一个集合将消耗该集合。
1 | fn main() { |
有些时候可能希望迭代一个集合而不是获取它的所有权,许多集合提供了迭代器获取引用的方法,通常分别称为iter
和iter_mut
。顾名思义:
iter
返回的迭代器会获取集合元素的不可变引用iter_mut
返回的迭代器会获取集合元素的可变引用into_iter
返回的迭代器会获取集合元素的所有权
通常情况下,如果一个集合类型 C
提供iter
方法,那么它通常还为 &C
实现 IntoIterator
特征,而该实现只是调用 iter
方法;同样,提供iter_mut
方法的集合类型 C
通常为 &mut C
实现 IntoIterator
特征,这样操作的好处就是:
1 | fn main() { |
可以便捷地通过&
和&mut
而不需要使用方法名。
最后需要注意几点:
-
into_iter
是IntoIterator
特征必须实现的方法,另外两个方法并不要求必须实现。 -
尽管许多集合都提供
iter
方法,但并非所有集合都提供iter_mut
。例如,如果键的哈希值发生更改,则对hashset或hashmap的键进行更改可能会使该集合处于不一致状态,因此这些集合仅提供iter
。
消费适配器与迭代器适配器
在Iterator
特征中定义了一些默认实现的方法,有一些方法通过next
方法来消费元素。这些方法被称为消费适配器(consuming adaptors)。比如sum
方法:
1 | fn main() { |
这个方法获取迭代器的所有权并反复调用 next
来遍历迭代器,因而会消费迭代器。当其遍历每一个项时,它将每一个项加总到一个总和并在迭代完成时返回总和,如果是空迭代器,则会返回该类型的零值。
强调一下,sum
会获取迭代器的所有权,因此it
在调用sum
后失效,但values
仍然可用,因为我们是使用iter
方法创建的迭代器。
另外一类方法被称为迭代器适配器(iterator adaptors),它们允许我们将当前迭代器变为不同类型的迭代器。这样就可以实现链式调用,不过因为所有的迭代器都是惰性的,必须调用一个消费适配器方法以便获取迭代器适配器调用的结果。
1 | fn main() { |
这里展示了一个调用迭代器适配器方法 map
的例子,该 map
方法使用闭包来调用每个元素以生成新的迭代器。这里的闭包创建了一个新的迭代器,对vector中的每个元素都加1。不过由于迭代器是惰性的,还需要一个消费迭代器。
1 | fn main() { |
collect
方法消费迭代器并将结果收集到一个数据结构中。我们为 v2
标注了 Vec<_>
类型,就是为了告诉 collect
:请把迭代器中的元素消费掉,然后把值收集成 Vec<_>
类型,至于为何使用 _
,因为编译器会帮我们自动推导。至于为什么 collect
在消费时要指定类型?是因为该方法其实很强大,可以收集成多种不同的集合类型,Vec<T>
仅仅是其中之一,因此我们必须显式地告诉编译器我们想要收集成的集合类型。
还有一点值得注意,map
会对迭代器中的每一个值进行一系列操作,然后把该值转换成另外一个新值,该操作是通过闭包 |x| x + 1
来完成:最终迭代器中的每个值都增加了 1
,从 [1, 2, 3]
变为 [2, 3, 4]
。
从这个例子中也可以看出,迭代器的许多方法都可以使用闭包作为参数,它最大的好处不仅在于可以就地实现迭代器中元素的处理,还在于可以捕获环境值。
1 | struct Shoe { |
这里使用 filter
方法来获取一个闭包。该闭包从迭代器中获取一项并返回一个 bool
。如果闭包返回 true
,其值将会包含在 filter
提供的新迭代器中。如果闭包返回 false
,其值不会被包含。使用 filter
和一个捕获环境中变量 shoe_size
的闭包来遍历一个 Shoe
结构体集合。最终通过 collect
收集为 Vec<Shoe>
类型。
enumerate
在流程控制for
循环中,曾经介绍过enumerate
获取可迭代对象的索引,通过本章的介绍,应该会更加深入地理解这个过程。
1 | fn main() { |
这里的 a.iter()
创建迭代器,然后调用 Iterator
特征上的 enumerate
方法,该方法产生一个新的迭代器,其中每个元素均是元组 (索引,值)
。这里的enumerate
就是一个迭代器适配器,可以对其返回的迭代器继续进行链式调用:
1 | fn main() { |
这里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 | let buffer: &mut [i32]; |
为了计算 prediction
的值,这些代码遍历了 coefficients
中的 12 个值,使用 zip
方法将系数与 buffer
的前 12 个值组合在一起。接着将每一对值相乘,再将所有结果相加,然后将总和右移 qlp_shift
位。
当然,无需看懂这段代码,这里想要表达的是:这里创建了一个迭代器,使用了两个适配器,接着消费了其值。rust代码将会被编译为什么样的汇编代码呢?事实上,它被编译成与手写的效率相同的汇编代码。遍历 coefficients
的值完全用不到循环:rust知道这里会迭代 12 次,所以它展开(unroll)了循环。展开是一种移除循环控制代码的开销并替换为每个迭代中的重复代码的优化。所有的系数都被储存在了寄存器中,这意味着访问他们非常快。这里也没有运行时数组访问边界检查。
总之,大胆地使用迭代器和闭包吧,它们使得代码看起来更高级,但并不为此引入运行时性能损失。
2 智能指针
指针 (pointer)是一个包含内存地址的变量的通用概念。rust中最常见的指针是引用。引用以&
符号为标志并借用了它们所指向的值。除了引用数据没有任何其他特殊功能,也没有额外开销。
智能指针(smart pointers)是一类数据结构,它们的表现类似指针,但是也拥有额外的元数据和功能。智能指针的概念并不为rust所独有;其起源于C++并存在于其他语言中。rust标准库中定义了多种不同的智能指针,它们提供了多于引用的额外功能。为了探索其基本概念,我们来看看一些智能指针的例子,这包括引用计数 (reference counting)智能指针类型。这种指针允许数据有多个所有者,它会记录所有者的数量,当没有所有者时清理数据。在rust中因为引用和借用,普通引用和智能指针的一个额外的区别是引用是一类只借用数据的指针;相反,在大部分情况下,智能指针拥有它们指向的数据。
在之前的章节中,其实已经出现过智能指针:String
和Vec<T>
,只不过当时并没有这样称呼它们。这些类型都属于智能指针因为它们拥有一些数据并允许你修改它们。它们也拥有元数据和额外的功能或保证。例如String
存储了其容量作为元数据,并拥有额外的能力确保其数据总是有效的UTF-8
编码。
智能指针通常使用结构体实现。智能指针不同于结构体的地方在于其实现了Deref
和Drop
特征,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 | fn main() { |
这里定义了变量 b
,其值是一个指向被分配在堆上的值 5
的 Box
。这个程序会打印出 b = 5
。由于实现了Deref
,println!
可以正常打印出 a
的值,它隐式地调用了 Deref
对智能指针 a
进行了解引用。在这个例子中,我们可以像数据是储存在栈上的那样访问box中的数据。正如任何拥有数据所有权的值那样,当像 b
这样的box在main
的末尾离开作用域时,它将被释放。这个释放过程作用于box本身(位于栈上)和它所指向的数据(位于堆上),这是因为 Box<T>
实现了 Drop
特征。另外Box::new(T)
会转移T
的所有权。
不过,这段代码实际代码中其实很少会存在,因为将一个简单的值分配到堆上并没有太大的意义。将像单个 i32
这样的值储存在栈上,也就是其默认存放的地方在大部分使用场景中更为合适。
使用box避免大量数据拷贝
对于实现Copy
特征的简单类型,值存储在栈上,当栈上数据转移所有权时,会进行内存的拷贝。如果是堆上的数据则底层数据并不会被拷贝,当转移所有权时,仅仅会拷贝栈上指向堆内存的指针,然后将这个指针绑定到新的位置上,再使原来的位置的指针失效即可。
1 | fn main() { |
在希望转移所有权,但又希望大量数据不被拷贝,使用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 | enum List { |
使用枚举创建了一个List
,它要么是Cons
要么是Nil
,其中Cons
包含一个 i32
值,还包含了一个新的 List
,因此这种嵌套可以无限进行下去。
使用这个结构来储存列表 1, 2, 3
:
1 | use crate::List::{Cons, Nil}; |
错误表明这个类型有无限的大小,其原因是 List
的一个成员被定义为是递归的:它直接存放了另一个相同类型的值。
rust计算非递归类型的内存大小是这样的:
1 | enum Message { |
对于上面的枚举,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 | use crate::List::{Cons, 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 | fn main() { |
在之前的代码中,如果 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 | fn main() { |
这里 y
就是一个常规引用,包含了值 5
所在的内存地址,然后通过解引用 *y
,我们获取到了值 5
。可以断言 x
等于 5
。然而,如果希望对 y
的值做出断言,必须使用 *y
来追踪引用所指向的值(也就是解引用),一旦解引用了 y
,就可以访问 y
所指向的整型值并可以与 5
做比较。
如果尝试编写 assert_eq!(5, y);
,则会得到如下编译错误:
1 | error[E0277]: can't compare `{integer}` with `&{integer}` //无法将{integer} 与&{integer}进行比较 |
不允许比较数字的引用与数字,因为它们是不同的类型。必须使用解引用运算符追踪引用所指向的值。
像引用一样使用Box<T>
可以使用 Box<T>
代替引用重写上一小节的代码:
1 | fn main() { |
相比之下,主要不同的地方就是将 y
设置为一个指向 x
值拷贝的 Box<T>
实例,而不是指向 x
值的引用。在最后的断言中,可以使用解引用运算符以 y
为引用时相同的方式追踪 Box<T>
的指针。接下来让我们通过实现自己的类型来探索 Box<T>
能这么做有何特殊之处。
自定义智能指针
为了体会默认情况下智能指针与引用的不同,我们创建一个类似于标准库提供的 Box<T>
类型的智能指针。接着学习如何增加使用解引用运算符的功能。
1 | struct MyBox<T>(T); |
从根本上说,Box<T>
被定义为包含一个元素的元组结构体,所以这里以相同的方式定义了 MyBox<T>
类型。我们还定义了 new
函数来对应定义于 Box<T>
的 new
函数。结构体 MyBox
使用了一个泛型参数 T
,因为我们希望其可以存放任何类型的值。
MyBox
是一个包含 T
类型元素的元组结构体。MyBox::new
函数获取一个 T
类型的参数并返回一个存放传入值的 MyBox
实例。
现在,尝试用这个自定义的box替换掉标准库的Box<T>
:
1 | fn main() { |
编译,会得到一个错误:
1 | error[E0614]: type `MyBox<{integer}>` cannot be dereferenced |
MyBox<T>
类型不能解引用,因为我们尚未在该类型实现这个功能。为了启用 *
运算符的解引用功能,需要实现 Deref
特征。
实现 Deref
特征
Deref
特征由标准库提供,它要求实现名为deref
的方法,其借用self
并返回一个内部数据的引用。
1 | use std::ops::Deref; |
这里又出现了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 | use std::ops::Deref; |
这里定义了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 | fn main() { |
(*m)
将MyBox<String>
解引用为 String
。接着&
和[..]
获取了整个String
的字符串切片来匹配hello
的签名。没有Deref强制转换所有这些符号混在一起将更难以读写和理解。Deref强制转换使得rust自动的帮我们处理这些转换。
当所涉及到的类型定义了Deref
特征,rust会分析这些类型并使用任意多次Deref::deref
调用以获得匹配参数的类型。这些解析都发生在编译时,所以利用Deref强制转换并没有运行时损耗。
在使用方法、赋值中的Deref强制转换如下:
1 | fn main() { |
对于 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 | use std::ops::{Deref, DerefMut}; |
在上述代码中:
- 要实现
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 | struct A(); |
这个例子中,展示了rust何时执行drop
方法。Drop
特征包含在 prelude 中,所以无需导入它,drop
函数体是放置任何当类型实例离开作用域时期望运行的逻辑的地方,这里暂时使用println!
代替。
编译运行,会得到如下打印结果:
1 | CustomSmartPointers created. |
这说明,当实例离开作用域rust会自动调用 drop
,并调用我们指定的代码。同时可以看出drop
的顺序:
- 变量以被创建时相反的顺序被丢弃,所以
t
在s
之前被丢弃。 - 结构体内部按照字段定义的顺序丢弃,所以先丢弃
dataA
后丢弃dataB
- 先丢弃结构体再丢弃内部的字段
另外,rust为几乎所有类型都实现了drop
,因此,注释掉下面的这段代码:
1 | impl Drop for CustomSmartPointer { |
也能够正常通过编译。
通过std::mem::drop
手动丢弃
有时你可能需要提早清理某个值。一个例子是当使用智能指针管理锁时;你可能希望强制运行 drop
方法来释放锁以便作用域中的其他代码可以获取锁。rust并不允许我们主动调用 Drop
特征的drop
方法(std::ops::Drop::drop
),当我们希望在作用域结束之前就强制释放变量的话,我们应该使用的是由标准库提供的std::mem::drop
。
这二者是不同的,前者在值超出作用域后被隐式调用,并且不允许显式调用。比如:
1 | struct Foo { |
当你进行x.drop()
时,编译器会报如下错误:
1 | | |
rust不允许我们显式调用std::ops::Drop::drop
,因为rust仍然会在main
作用域的结尾对值自动调用drop
,这会导致一个double free
错误,因为rust会尝试清理相同的值两次。
这里提示你使用drop
函数,如果需要强制提早清理值,可以使用std::mem::drop
函数,它位于prelude
,无需显式导入即可使用。该方法可以通过传递希望强制丢弃的值作为参数:
1 | struct Foo { |
实际上,std::mem::drop
函数的源码是这样的:
1 | pub fn drop<T>(_x: T) { } |
这个drop
函数内部是一个空实现,它所做的仅仅是带走_x
的所有权。该值在drop
作用域的末尾仍然会自动调用std::ops::Drop::drop
,以完成丢弃工作。虽然看起来是你手动丢弃了x
,但编译器并不知道这些,它所做的只是移动x
的所有权,然后在作用域结尾将其自动丢弃。
互斥的Copy
和Drop
无法为一个类型同时实现 Copy
和 Drop
特征。因为实现了 Copy
的特征会被编译器隐式的复制,因此非常难以预测析构函数执行的时间和频率。因此这些实现了 Copy
的类型无法拥有析构函数。
1 |
|
总之,Drop
特征实现中指定的代码可以用于许多方面,来使得清理变得方便和安全:比如可以用其创建我们自己的内存分配器。通过 Drop
特征和rust所有权系统,你无需担心之后的代码清理,rust会自动考虑这些问题。
我们也无需担心意外的清理掉仍在使用的值,这会造成编译器错误:所有权系统确保引用总是有效的,也会确保 drop
只会在值不再被使用时被调用一次。
2.4 Rc<T>
引用计数智能指针
大部分情况下所有权是非常明确的:可以准确地知道哪个变量拥有某个值。然而,有些情况单个值可能会有多个所有者。例如,在图数据结构中,多个边可能指向相同的节点,而这个节点从概念上讲为所有指向它的边所拥有。节点直到没有任何边指向它之前都不应该被清理。
为了启用多所有权需要显式地使用rust类型 Rc<T>
,其为引用计数(reference counting)的缩写。引用计数意味着记录一个值引用的数量来知晓这个值是否仍在被使用。如果某个值有零个引用,就代表没有任何有效引用并可以被清理。引用计数并不是rust独有的,在C++11中,也有同样的概念。在一些语言的垃圾回收(GC)机制中,也用到了引用计数算法。
Rc<T>
用于当我们希望在堆上分配一些内存供程序的多个部分读取,而且无法在编译时确定程序的哪一部分会最后结束使用它的时候。如果确实知道哪部分是最后一个结束使用的话,就可以令其成为数据的所有者,正常的所有权规则就可以在编译时生效。
使用Rc<T>
共享数据
1 | fn main() { |
这里使用两次box存储s
,毫无疑问会报错。因为第一次调用Box::new
时已经将s
所有权移动到box。
现在,使用 Rc<T>
代替 Box<T>
:
1 | use std::rc::Rc; |
需要使用 use
语句将 Rc<T>
引入作用域,因为它不在prelude
中。使用 Rc::new
函数创建了一个新的 Rc<String>
智能指针并将s
储存到变量 t
,在调用Rc::new
函数时,会将引用计数加1。然后调用 Rc::clone
函数并传递 t
中 Rc<String>
的引用作为参数,此时的引用计数增加到 2
。
也可以调用 t.clone()
而不是 Rc::clone(&t)
,不过在这里rust的习惯是使用 Rc::clone
函数。Rc::clone
的实现并不像大部分类型的 clone
实现那样对所有数据进行深拷贝。Rc::clone
只会增加引用计数,这并不会花费多少时间。深拷贝可能会花费很长时间。通过使用 Rc::clone
进行引用计数,可以明显的区别深拷贝类的克隆和增加引用计数类的克隆。当查找代码中的性能问题时,只需考虑深拷贝类的克隆而无需考虑 Rc::clone
调用。
查看引用计数的变化
接下来,修改上一节的例子,观察引用计数的变化:
1 | use std::rc::Rc; |
使用Rc::strong_count
函数观察t
中Rc<String>
的的引用计数变化,这段代码会打印出:
1 | count after creating t = 1 |
能够看到 t
中 Rc<String>
的初始引用计数为1,接着每次调用 clone
,计数会增加1。当 u
离开作用域时,计数减1。不必像调用 Rc::clone
增加引用计数那样调用一个函数来减少计数;由于Drop
特征的实现,当 Rc<T>
值离开作用域时会自动减少引用计数。
从这个例子我们所不能看到的是,在 main
的结尾当 z
然后是 t
离开作用域时,此处计数会是0,同时 Rc<String>
被完全清理。使用 Rc<T>
允许一个值有多个所有者,引用计数则确保只要任何所有者依然存在其值也保持有效。
不可变引用
实际上,Rc<T>
是指向底层数据的不可变的引用,它允许在程序的多个部分之间只读地共享数据。如果 Rc<T>
也允许多个可变引用,则会违反借用规则之一:相同位置的多个可变借用可能造成数据竞争和不一致。
1 | use std::rc::Rc; |
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 | fn main() { |
然而,特定情况下,令一个值在其方法内部能够修改自身,而在其他代码中仍视为不可变,是很有用的。RefCell<T>
就是一个获得内部可变性的方法,它并没有完全绕开借用规则,编译器中的借用检查器允许内部可变性并相应地在运行时检查借用规则。如果违反了这些规则,会出现panic而不是编译错误。
1 | use std::cell::RefCell; |
这里引入了std::cell::RefCell
,并使用new
创建了一个RefCell<String>
的引用。当创建不可变和可变引用时,我们分别使用 &
和 &mut
语法。对于 RefCell<T>
来说,则是 borrow
和 borrow_mut
方法,这属于 RefCell<T>
安全API的一部分。borrow
方法返回 Ref<T>
类型的智能指针,borrow_mut
方法返回 RefMut<T>
类型的智能指针。这两个类型都实现了 Deref
,所以可以当作常规引用对待。它的运行结果如下:
1 | init value owner1 = RefCell { value: 4 } |
初始时,owner1
储存的值为4
,且没有使用mut
。然后通过borrow_mut
方法获取了一个可变引用,并修改原始值为3
,此时owner1
中的可变引用计数为1,当离开{}
作用域时,可变引用计数减1,然后又创建了可变引用m2
。这符合借用规则,因此在运行时也不会报错。并且,我们通过RefCell
获取了内部可变性,即使没有mut
也能够修改自身。
如果去掉{}
:
1 | use std::cell::RefCell; |
虽然代码可以通过编译,但是在运行时仍然会报错:thread 'main' panicked at 'already borrowed: BorrowMutError'
。这是因为违反了借用规则:可变引用计数为2(m1
和m2
)。
这看起来像是推迟了报错的时机:从编译时报错推迟到运行时报错。它的意义在于,rust编译器是保守的,当编译器无法判断代码是否正确,就一律按照错误处理,而RefCell
则是让编译器相信你的代码是正确的:至少可以通过编译。
总之,当你确信编译器误报但不知道该如何解决时,或者你有一个引用类型,需要被四处使用和修改然后导致借用关系难以管理时,都可以优先考虑使用 RefCell
。
RefCell<T>
记录当前有多少个活动的 Ref<T>
和 RefMut<T>
智能指针。每次调用 borrow
,RefCell<T>
将活动的不可变借用计数加1。当 Ref<T>
值离开作用域时,不可变借用计数减1,可变引用同理。就像编译时借用规则一样,RefCell<T>
在任何时候只允许有多个不可变借用或一个可变借用。如果我们尝试违反这些规则,相比引用时的编译时错误,RefCell<T>
的实现会在运行时出现panic。
Cell<T>
Cell<T>
和 RefCell<T>
在功能上没有区别,区别在于 Cell<T>
适用于 T
实现Copy
特征的情况:
1 | use std::cell::Cell; |
以上代码展示了 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
使用的并不多,以下是Cell
和RefCell
的区别:
Cell
只适用于Copy
类型,用于提供值,而RefCell
用于提供引用Cell
不会panic
,而RefCell
会Cell
没有额外的性能损耗,RefCell
有一点运行期开销,它包含了一个字大小的“借用状态”指示器,该指示器在每次运行时借用时都会被修改
总之,需要使用内部可变性时,可以首选 Cell
,只有需要存储的类型没有实现 Copy
特征,才去选择 RefCell
。
最后,Cell
和RefCell
仅用于单线程的共享引用,Mutex<T>
是一个线程安全版本的RefCell<T>
,在并发编程章节中会介绍它,详见:互斥锁Mutex
。
RefCell<T>
的使用场景
很多时候RefCell<T>
都用在结构体的字段上。这样,你就可以共享这个结构体,但是仍能够对某个字段做修改。
1 | pub trait Messenger { |
这里定义了一个消息发送器特征 Messenger
,它只有一个发送消息的功能:fn send(&self, msg: String)
,因为发送消息不需要修改自身,因此在定义时,使用了 &self
的不可变借用。
然后我们使用该特征实现一个异步消息队列,出于性能的考虑,消息先写到本地缓存(内存)中,然后批量发送出去,因此在 send
方法中,需要将消息先行插入到本地缓存 msg_cache
中。但问题是,该 send
方法的签名是 &self
,上述代码会报错:
1 | | |
虽然编译器提示修改函数签名,但我们希望在不修改这个特征定义的前提下,解决这个问题:
1 | use std::cell::RefCell; |
通过修改结构体字段的定义,包裹一层 RefCell
,让 &self
中的 msg_cache
成为一个可变值,然后实现对其的修改,并且也没有修改Messenger
特征的定义。
组合使用Rc<T>
和RefCell<T>
来拥有多个可变数据所有者
RefCell<T>
的一个常见用法是与 Rc<T>
结合。 Rc<T>
允许对相同数据有多个所有者,不过只能提供数据的不可变访问。如果有一个储存了 RefCell<T>
的 Rc<T>
的话,就可以得到有多个所有者并且可以修改的值。
1 | use std::cell::RefCell; |
上面代码中,我们使用 RefCell<String>
包裹一个字符串,同时通过Rc::new
方法创建了它的三个所有者:s
、s1
和s2
,并且通过其中一个所有者 s2
对字符串内容进行了修改。由于 Rc
的所有者们共享同一个底层的数据,因此当一个所有者修改了数据时,会导致全部所有者持有的数据都发生了变化。
运行结果也是如此:
1 | RefCell { value: "hello, world" } |
3 并发编程
不同的编程语言使用不同的线程模型,rust标准库使用1:1
线程实现,这代表程序的每一个语言级线程使用一个系统线程。在线程间通信方面,rust提供了不同程度抽象的工具,比如通道、互斥锁和原子类型,我们会在后面分别介绍它们。
3.1 多线程同时运行代码
为了创建一个新线程,需要调用 thread::spawn
函数并传递一个闭包,并在其中包含希望在新线程运行的代码:
1 | use std::thread; |
注意当rust程序的主线程结束时,新线程也会结束,而不管其是否执行完毕。这个程序的输出可能每次都略有不同,不过它大体上看起来像这样:
1 | hi number 1 from the main thread! |
调用thread::sleep
会强制线程停止执行一小段时间,这会允许其它不同的线程运行。这些线程可能会轮流运行,不过并不保证如此:这依赖操作系统如何调度线程。在这里,主线程首先打印,即便新创建线程的打印语句位于程序的开头,甚至即便我们告诉新建的线程打印直到 i=9
,它在主线程结束之前也只打印到了5。
由于主线程结束,新线程中的代码大部分时候会提早结束,在一些情况下下,由于无法保证线程运行的顺序,我们甚至不能实际保证新建线程会被执行。
使用join
等待所有线程结束
可以通过将 thread::spawn
的返回值储存在变量中来修复新建线程部分没有执行或者完全没有执行的问题。thread::spawn
的返回值类型是 JoinHandle
。JoinHandle
是一个拥有所有权的值,当对其调用 join
方法时,它会等待其线程结束。将上一节的程序改造一下:
1 | use std::thread; |
这里获取到创建的线程的 JoinHandle
类型的变量handle
,并调用 join
方法来确保新建线程在 main
退出前结束运行。通过调用handle
的 join
会阻塞当前线程直到handle
所代表的线程结束。因为我们将 join
调用放在了主线程的 for
循环之后,因此这段代码应该会产生这样的输出:
1 | hi number 1 from the main thread! |
这两个线程仍然会交替执行,不过主线程会由于 handle.join()
调用,会等待直到新建线程执行完毕。由于大部分情况下两个线程会交替执行,使用join
可以防止主线程先于其它线程结束。如果调整join
方法调用的位置:
1 | use std::thread; |
主线程会等待直到新建线程执行完毕之后,才开始执行 for
循环,所以输出将不会交替出现。因此,在使用时,需要注意join
使用的位置。
线程与move
闭包
在介绍闭包的获取所有权小节,我们已经介绍了一个使用move
的例子:
1 | use std::thread; |
先来看看如果去掉move
会发生什么:
1 | use std::thread; |
这段代码本身没有问题,闭包内仅仅打印了动态数组list
,这只需要它的不可变引用。但是rust无法确定新的线程会执行多久——这是由操作系统决定的——所以也无法知晓 list
的引用是否一直有效。换句话说,存在一种可能,在新线程执行过程中,主线程的list
已经失效(被移动或被drop
清理),此时list
的引用已经无效了。当然,这段代码也可能正常运行,但rust编译器是保守的,当可能出现问题,也可能不出现问题时,rust选择报错,拒绝编译。
下面就是一个可能会出现问题的代码:
1 | use std::thread; |
如果这段代码能够通过编译,则新建线程则可能会立刻被转移到后台并完全没有机会运行。新建线程内部有一个 v
的引用,不过主线程立刻就使用 drop
丢弃了 v
(实际上是转移了所有权),接着当新建线程开始执行,v
已不再有效,所以其引用也是无效的。
通过在闭包之前增加 move
关键字,我们强制闭包获取其使用的值的所有权,而不是任由rust推断它应该借用值。
嵌套线程的执行与结束
如果尝试在子线程中嵌套创建新线程:
1 | use std::thread; |
以上代码中,main
线程创建了一个新的线程 A
,同时该新线程又创建了一个新的线程 B
, A
线程在创建完 B
线程后就立即结束了,而 B
线程则在不停地循环输出。这说明,创建出的B
线程是独立运行的,出于安全性的考虑,rust没有提供直接杀死线程的接口,线程B
需要等到主线程结束后自动结束。
线程屏障(Barrier)
在rust中,可以使用 Barrier
让多个线程都执行到某个点后,才继续一起往后执行:
1 | use std::sync::{Arc, Barrier}; |
上面代码,我们在线程打印出 before wait
后增加了一个屏障,目的就是等所有的线程都打印出before wait
后,各个线程再继续执行。
线程局部变量(Thread Local Variable)
rust提供了标准库和第三方库支持线程局部变量。
使用 thread_local
宏可以初始化线程局部变量,然后在线程内部使用该变量的 with
方法获取变量值:
1 | use std::cell::RefCell; |
上面代码中,FOO
即是我们创建的线程局部变量,每个新的线程访问它时,都会使用它的初始值作为开始,各个线程中的 FOO
值彼此互不干扰。注意 FOO
使用 static
声明为生命周期为 'static
的静态变量。
另外线程中对 FOO
的使用是通过借用的方式,但是若我们需要每个线程独自获取它的拷贝,最后进行汇总,就有些强人所难。
你还可以在结构体中使用线程局部变量:
1 | use std::cell::RefCell; |
或者通过引用的方式使用它:
1 | use std::cell::RefCell; |
使用第三方库 thread-local ,可以解决只能使用引用的问题,每个线程持有值的独立拷贝。
1 | use thread_local::ThreadLocal; |
该库不仅仅使用了值的拷贝,而且还能自动把多个拷贝汇总到一个迭代器中,最后进行求和。
用条件控制线程的挂起和执行
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 | use std::thread; |
上述代码流程如下:
main
线程首先进入while
循环,调用wait
方法挂起等待子线程的通知,并释放了锁started
- 子线程获取到锁,并将其修改为
true
,然后调用条件变量的notify_one
方法来通知主线程继续执行
确保函数只被调用一次
有时,我们会需要某个函数在多线程环境下只被调用一次,例如初始化全局变量,无论是哪个线程先调用函数来初始化,都会保证全局变量只会被初始化一次,随后的其它线程调用就会忽略该函数:
1 | use std::thread; |
代码运行的结果取决于哪个线程先调用 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 | use std::sync::mpsc; |
这里使用 mpsc::channel
函数创建一个新的信道;mpsc
是多个生产者,单个消费者(multiple producer, single consumer)的缩写。简而言之,rust标准库实现信道的方式意味着一个信道可以有多个产生值的发送(sending)端,但只能有一个消费这些值的接收(receiving)端。
mpsc::channel
函数返回一个元组:第一个元素是发送端 – 发送者,而第二个元素是接收端 – 接收者。由于历史原因,tx
和 rx
通常作为发送者(transmitter)和接收者(receiver)的缩写,所以这就是我们将用来绑定这两端变量的名字。这里使用了一个 let
语句和模式来解构了此元组。
这里tx
和rx
的类型由编译器自动推导:tx.send("hi")
发送了String
,因此它们分别是mpsc::Sender<String>
和mpsc::Receiver<String>
类型,由于内部是泛型实现,一旦类型被推导确定,该通道就只能传递对应类型的值。
使用 thread::spawn
来创建一个新线程并使用 move
将 tx
移动到闭包中,这样新建线程就拥有 tx
了。新建线程需要拥有信道的发送端以便能向信道发送消息。信道的发送端有一个 send
方法用来获取需要放入信道的值。send
方法返回一个 Result<T, E>
类型,所以如果接收端已经被丢弃了,将没有发送值的目标,发送操作会返回错误,这里调用 unwrap
在出错的时候产生panic。
主线程使用rx
作为通道的接收者,有两个方法:recv
和 try_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 | use std::sync::mpsc; |
这里尝试在通过 tx.send
发送 val
到信道中之后将其打印出来。显然这会造成问题,一旦将值发送到另一个线程后,那个线程可能会在我们再次使用它之前就将其修改或者丢弃。其他线程对值可能的修改会由于不一致或不存在的数据而导致错误或意外的结果。rust拒绝编译,说明使用通道来传输数据时仍然需要遵循所有权规则,这可以防止在发送后再次意外地使用这个值。
发送多个值并让接收者循环等待
这次,尝试发送多个值,观察接收者的等待:
1 | use std::sync::mpsc; |
这一次,在新建线程中有一个字符串vector希望发送到主线程。我们遍历它们,单独的发送每一个字符串并通过一个 Duration
值调用 thread::sleep
函数来暂停一秒。
在主线程中,不再显式调用 recv
函数,rx
是一个迭代器,可以使用for
遍历它:
1 | for received in rx { |
还可以使用如下方式:
1 | while let Ok(received) = rx.recv() { |
这两种方式是等价的,无论是用哪种,对于每一个接收到的值,我们将其打印出来。如果到达循环顶部时通道恰好是空的,则接收线程将阻塞,直到其他线程发送一个值。当通道为空且发送者已被丢弃时,循环将正常退出。
运行整个代码,将得到如下输出,每输出一行都会暂停一秒:
1 | Got: hi |
通过克隆发送者来创建多个生产者
之前提到了mpsc
是多生产者单消费者的缩写,可以运用 mpsc
来创建向同一接收者发送值的多个线程。这可以通过克隆发送者来做到:
1 | use std::sync::mpsc; |
这一次,在创建新线程之前,我们对发送者调用了 clone
方法。这会给我们一个可以传递给第一个新建线程的发送端句柄。我们会将原始的通道发送端传递给第二个新建线程。这样就会有两个线程,每个线程将向信道的接收端发送不同的消息。
如果运行这些代码,你可能会看到这样的输出:
1 | Got: hi |
你可能会看到这些值以不同的顺序出现,这依赖于你的系统。这也是并发既有趣又困难的原因。如果通过 thread::sleep
做实验,在不同的线程中提供不同的值,就会发现他们的运行更加不确定,且每次都会产生不同的输出。
不过,同一通道的消息是有顺序的,因为它本质上是队列。
同步通道
前面通过mpsc::channel
函数创建的通道是异步的,发送一条消息,即使消息没有被接收,异步通道也不会阻塞。
rust还有另外一种同步通道mpsc::sync_channel
,同步通道与异步通道基本相同,有一点区别:异步通道的内部缓冲区(Buffer
)是无限的,在创建时无需指定大小;而同步通道在创建时需要指定内部缓冲区(Buffer
)的大小。
使用通道收发消息的程序可能会遇到这样的情况:发送值的速度超过了接收和处理的速度。这会导致越来越多的消息在内部缓冲区中累积。更糟糕的是,发送线程继续运行,占用 CPU 和其他系统资源来发送更多的值,而这些资源正好在接收端最需要这些资源。
更准确地描述就是:在数据流从上游生产者向下游消费者传输的过程中,上游生产速度大于下游消费速度,导致下游的 Buffer
溢出,这种现象叫做背压(Backpressure)。
借鉴了Unix
系统中管道的处理方法,Unix
系统上的每个管道也有固定的大小,如果一个进程试图写入一个暂时已满的管道,系统会阻塞该进程直到管道中有空间。rust也是如此:当内部缓冲区满时,同步通道将阻塞等待缓冲区打开。
1 | use std::sync::mpsc; |
这里使用mpsc::sync_channel(0)
创建了一个内部缓冲区为0
的通道,这是有效的,如果将缓冲区设为0
,它将成为会合通道(rendezvous channel),其中的每个消息均会阻塞,直到recv
接收它。
上面的代码就演示了这个过程,它的输出结果如下:
1 | main thread sleeping... |
新创建的线程发送了一条消息,由于我们在主线程睡眠了3秒,主线程没有接收这个值,因此通道阻塞。直到rx.recv
方法执行将值接收后,阻塞被解除。
需要注意的是,同步通道并不总是阻塞的,只有Buffer
满后才会阻塞。比如,使用mpsc::sync_channel(10)
代表缓冲区大小为10
,只有发送消息时缓冲区已经有10
条数据的情况下,新的消息才会阻塞。
无限的缓存空间
上一节提到了,异步通道的内部缓冲区(Buffer
)是无限的(infinite buffer),rust是如何实现的?
类似于Vec<T>
是无限大小的一样,这里的无限不是真正意义上的“无限”,你可以把元素添加到动态数组中,但是当你的物理设备资源耗尽时,无法分配足够的堆内存,此时程序可能会崩溃。通道也是同理,异步通道虽然能非常高效且不会造成发送线程的阻塞,但是存在消息未及时消费,最终内存过大的问题。在实际项目中,可以考虑使用一个带缓冲值的同步通道来避免这种风险。
传输多种类型的数据
一个消息通道只能传输一种类型的数据,如果想要传输多种类型的数据,可以为每个类型创建一个通道,也可以使用枚举类型来实现:
1 | use std::sync::mpsc::{self, Receiver, Sender}; |
如上所示,枚举类型还能让我们带上想要传输的数据,但是有一点需要注意,rust按照枚举中占用内存最大的那个成员进行内存对齐,这意味着就算传输的是枚举中占用内存最小的成员,它占用的内存依然和最大的成员相同,可能会造成内存上的浪费。
使用mpmc的第三方库
如果你需要mpmc或者需要更高的性能,可以考虑第三方库:
- crossbeam-channel,老牌强库,功能较全,性能较强,之前是独立的库,但是后面合并到了
crossbeam
主仓库中 - flume,官方给出的性能数据某些场景要比
crossbeam
更好些
3.3 共享状态并发
虽然消息传递是一个很好的处理并发的方式,但并不是唯一一个。另一种方式是让多个线程拥有相同的共享数据。
在某种程度上,任何编程语言中的通道都类似于单所有权,因为一旦将一个值传送到通道中,将无法再使用这个值。共享内存类似于多所有权:多个线程可以同时访问相同的内存位置。智能指针可以使多所有权成为可能,然而这会增加额外的复杂性,因为需要以某种方式管理这些不同的所有者。rust的类型系统和所有权规则极大的协助了正确地管理这些所有权。作为一个例子,让我们看看互斥器,一个更为常见的共享内存并发原语。
互斥锁Mutex
互斥锁(mutex)是 mutual exclusion 的缩写,也就是说,任意时刻,其只允许一个线程访问某些数据。在其他语言中,互斥锁的使用需要注意两点:
- 在需要互斥访问之前(进入临界区)获得锁
- 处理完毕数据之后(离开临界区),释放锁
使用互斥锁比较复杂,这也是许多人热衷于通道的原因。然而,在rust中,得益于类型系统和所有权,我们不会在锁和解锁上出错。
出于简单的考虑,我们从在单线程上下文使用互斥锁Mutex
开始:
1 | use std::sync::Mutex; |
像很多类型一样,我们使用关联函数 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 | use std::sync::Mutex; |
这里创建了一个 counter
变量来存放内含 i32
的 Mutex<T>
,接下来遍历 range
创建了 10 个线程。使用了 thread::spawn
并对所有线程使用了相同的闭包:它们每一个都将调用 lock
方法来获取 Mutex<T>
上的锁,接着将互斥锁中的值加一。当一个线程结束执行,num
会离开闭包作用域并释放锁,这样另一个线程就可以获取它了。
在主线程中,收集了所有线程的JoinHandle
,调用 join
方法来确保所有线程都会结束。这时,主线程会获取锁并打印出程序的结果。
然而这段代码会报错:
1 | | |
错误信息表明 counter
值在上一次循环中被移动了。rust告诉我们不能将 counter
锁的所有权移动到多个线程中。
多线程和多所有权
要想让多个所有者拥有值,在前面Rc<T>
引用计数智能指针章节中,介绍过使用智能指针 Rc<T>
来创建引用计数,以便拥有多所有者。现在来尝试一下:
1 | use std::rc::Rc; |
这次将 Mutex<T>
封装进 Rc<T>
中并在将所有权移入线程之前克隆了 Rc<T>
。但是rust仍然报错:
1 | error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely |
报错中出现了一些提示,第一行错误:
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 | use std::sync::{Arc, Mutex}; |
代码最终可以正常运行,这会打印出:
1 | Result: 10 |
你可能会好奇为什么不是所有的原始类型都是原子性的,为什么不是所有标准库中的类型都默认使用 Arc<T>
实现。原因在于线程安全带有性能惩罚,rust希望只在必要时才为此买单。如果只是在单线程中对值进行操作,原子性提供的保证并无必要,代码可以减少这部分性能损失,以运行的更快。
内部可变性
在之前组合使用Rc<T>
和RefCell<T>
来拥有多个可变数据所有者小节中,介绍了Rc<T>
和RefCell<T>
的结合,可以实现单线程中的内部可变性。
现在,我们介绍了它们线程安全的版本,即:组合使用 Mutex<T>
和 Arc<T>
在多线程中实现内部可变性。
死锁
在rust中有多种方式可能造成死锁,了解这些方式有助于你提前规避可能的风险:
首先是单线程死锁,比较好避免:
1 | use std::sync::Mutex; |
只要你在另一个锁还未被释放时去申请新的锁,线程就会被阻塞,当代码复杂后,这种情况可能就没有那么显眼。
多线程死锁就稍微复杂一些,当我们拥有两个锁,且两个线程各自使用了其中一个锁,然后试图去访问另一个锁时,就可能发生死锁:
1 | use std::{sync::{Mutex, MutexGuard}, thread}; |
在上面的描述中,我们用了"可能"二字,原因在于死锁在这段代码中不是必然发生的,总有一次运行你能看到最后一行打印输出。这是由于子线程的初始化顺序和执行速度并不确定,我们无法确定哪个线程中的锁先被执行,因此也无法确定两个线程对锁的具体使用顺序。
但是,可以简单的说明下死锁发生的必然条件:线程 1 锁住了MUTEX1
并且线程2
锁住了MUTEX2
,然后线程 1 试图去访问MUTEX2
,同时线程2
试图去访问MUTEX1
,就会死锁。 因为线程 2 需要等待线程 1 释放MUTEX1
后,才会释放MUTEX2
,而与此同时,线程 1 需要等待线程 2 释放MUTEX2
后才能释放MUTEX1
,这种情况造成了两个线程都无法释放对方需要的锁,最终死锁。
但有些情况下不会发生死锁:线程 2 在线程 1 锁MUTEX1
之前,就已经全部执行完了,随之线程 2 的MUTEX2
和MUTEX1
被全部释放,线程 1 对锁的获取将不再有竞争者。 同理,线程 1 若全部被执行完,那线程 2 也不会被锁,因此我们在线程 1 中间加一个睡眠,增加死锁发生的概率。如果你在线程 2 中同样的位置也增加一个睡眠,那死锁将必然发生。
在计算机专业课《操作系统》中对这种死锁的产生和预防都有比较详细的讲解,感兴趣的读者可以自行搜搜看。
另外,与lock
方法不同,还有一个try_lock
方法,这个方法会尝试获取一次锁,如果无法获取会返回一个错误,因此不会发生阻塞。当try_lock
失败时,会报出一个错误:Err("WouldBlock")
,接着线程中的剩余代码会继续执行,不会被阻塞。
读写锁 RwLock
Mutex
会对每次读写都进行加锁,但某些时候,我们需要大量的并发读,Mutex
就无法满足需求了,此时就可以使用RwLock
:
1 | use std::sync::RwLock; |
RwLock
在使用上和Mutex
区别不大,需要注意的是,当读写同时发生时,程序会直接panic
,因为会发生死锁。我们可以使用try_write
和try_read
来尝试进行一次写/读,若失败则返回错误Err("WouldBlock")
。
总结下RwLock
:
- 同时允许多个读,但最多只能有一个写
- 读和写不能同时存在
- 读可以使用
read
、try_read
,写使用write
、try_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 | use std::sync::{Arc,Mutex,Condvar}; |
通过主线程来触发子线程实现交替打印输出:
1 | outside counter: 1 |
信号量 Semaphore
信号量的概念是由荷兰计算机科学家艾兹赫尔·戴克斯特拉(Edsger W. Dijkstra)发明的,在很多书中将信号量称为PV操作或PV原语。V操作会增加信号标S的数值,P操作会减少它。在rust中,使用信号量可以用来控制同时访问特定资源的线程数量,通过协调各个线程,以保证合理的使用资源。
rust在标准库中有提供一个信号量实现,但是已经被弃用了。因此这里推荐使用tokio
中提供的Semaphore
实现:tokio::sync::Semaphore
。
1 | use std::sync::Arc; |
上面代码创建了一个容量为 3 的信号量,当正在执行的任务超过 3 时,剩下的任务需要等待正在执行任务完成并减少信号量后到 3 以内时,才能继续执行。acquire_owned
就相当于P操作,drop(permit)
就相当于V操作。通过PV操作来控制各个线程资源的使用。
3.4 内存模型与atomic
Mutex
用起来简单,但是无法并发读,RwLock
可以并发读,但是使用场景较为受限且性能不够。atomic
(std::sync::atomic
)则是包含用于无锁并发编程的原子类型,另外,atomic
是所有锁实现的基础。
原子指的是一系列不可被 CPU 上下文交换的机器指令,这些指令组合在一起就形成了原子操作。在多核 CPU 下,当某个 CPU 核心开始运行原子操作时,会先暂停其它 CPU 内核对内存的操作,以保证原子操作不会被其它 CPU 内核所干扰。
由于原子操作是通过指令提供的支持,因此它的性能相比锁和消息传递会好很多。相比较于锁而言,原子类型不需要开发者处理加锁和释放锁的问题,同时支持修改,读取等操作,还具备较高的并发性能,几乎所有的语言都支持原子类型。
内存模型
在具体介绍atomic
之前,先介绍一下内存模型。
(1)顺序一致性SC
看一个例子。假设有下面一个简单的程序,运行两个线程,其中A
和B
初始值都是0:
线程 1 | 线程 2 |
---|---|
1.A=1 | 3.B=2 |
2.println!(“{}”,B) | 4.println!(“{}”,A) |
这个程序的运行结果是什么呢?这应该有多种情况:
-
先执行线程1,线程1结束后,再执行线程2
语句的顺序为
1,2,3,4
,运行结果为:0,1
-
先执行线程2,线程2结束后,再执行线程1
语句的顺序为
3,4,1,2
,运行结果为:0,2
前两种情况是串行执行的,下面来看线程交替执行的情况:
- 语句的顺序为
1,3,2,4
(或3,1,2,4
),运行结果为:2,1
- 语句的顺序为
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=1 | 3.B=2 |
2.println!(“{}”,B) | 4.println!(“{}”,A) |
在全存储排序模型下,执行一个写入操作返回时,并不意味着内存中的值立即被修改:
与其等待A=1
依次经过缓存写入内存再返回,不如将其写入缓冲区之后直接返回。由于存储缓冲区在CPU核心上,因此访问速度非常快。在之后的某个时间,缓存层次结构将从存储缓冲区中提取写入并将其传播到缓存中,以便它对其他线程可见。
因此,可能出现以下情况:
- 执行操作1,写入
A=1
到CPU的存储缓冲区之后立即返回,并没有更新到所有CPU都能访问到的内存中。 - 执行操作3,写入
B=2
到CPU的存储缓冲区之后立即返回,并没有更新到所有CPU都能访问到的内存中。 - 执行操作4,读取内存中
A
的值,此时还没有被更新,所以读取到0
- 执行操作2,读取内存中
B
的值,此时还没有被更新,所以读取到0
因此,在引入了存储缓冲区之后,在顺序一致性模型下不能输出的0,0
也可以输出了。
并且,这种缓冲区保留了单线程的预期行为。考虑单线程的代码:
1 | // A初始为0 |
写入A=1
到CPU的存储缓冲区之后立即返回,执行println!
时难道也会是0
吗?并不,由于操作2的读取需要查看操作1的值,程序保留了预期的行为。如果去内存读取,那么只能拿到一个旧值,但是因为线程在同一个CPU上运行,读取可以直接检查存储缓冲区,查看它是否包含对正在读取的位置的写入,然后使用该值。因此,即使使用存储缓冲区,该程序也能正确打印出1
。
TSO保留了SC的第一条规则,即每个线程内的语句都按照程序规定的顺序依次执行。但TSO允许使用缓冲区,这些缓冲区减少了写入延迟。使语句更快地返回,程序的执行速度显著加快。SC的限制比较严格,TSO则弱化了它。
事实上,几乎每个现代架构都包含一个存储缓冲区,因此,内存模型至少与TSO一样弱。
(3)松弛型内存模型
前两种内存模型相对严格,限制了现代处理器架构下广泛使用的优化措施。例如,无法使用编译器和处理器的重排序优化松弛型内存模型(Relaxed memory models)则可以使用编译器和处理器的重排序优化,这导致程序的执行顺序并不与程序员看到的顺序为准。
1 | static mut X: u64 = 0; |
假如在C
和D
代码片段中,根本没有用到X = 1
,那么编译器很可能会将X = 1
和X = 2
进行合并:
1 | ... // A |
若代码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 | pub enum Ordering { |
Ordering
是一个枚举,它们对一致性的要求逐渐增强:
Relaxed
,对应的松弛型内存模型,对应于 C++ 20 中的memory_order_relaxed
。- 针对一个变量的读写操作是原子操作
- 不同线程之间针对该变量的访问操作先后顺序不能得到保证,即有可能乱序
Release
,用来修饰一个写操作,表示在本线程中,在本行代码之前,有任何读写内存的操作,都不能重新排序到本行语句之后。对应于 C++ 20 中的memory_order_release
。Acquire
,用来修饰一个读操作,表示在本线程中,在本行代码之后,有任何读写内存的操作,都不能重新排序到本行语句之前。对应于 C++ 20 中的memory_order_acquire
。AcqRel
,同时拥有Release
和Acquire
的保证。对应于 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 | pub fn store(&self, val: i8, order: Ordering) |
注意第二个参数都是Ordering
类型,它描述的就是该操作的内存顺序,或者说需要使用的内存栅栏。
限定内存顺序的实例
下面,通过一个例子来总结前面的知识。
1 | use std::sync::atomic::{AtomicBool, Ordering}; |
对于release
函数,如果采用松弛型内存模型,也就是Relaxed
,那么在编译优化或者CPU优化的过程中,可能会发生2
先于1
的情况:
1 | fn release() -> JoinHandle<()> { |
为了保证写入的内存序,我们改用Release
:
1 | fn release() -> JoinHandle<()> { |
这样可以告诉编译器和CPU,保证2
一定在1
后执行。另外在这里注意,对于1
和t
之间可以被乱序。
假设我们想让其它线程看到1,2
的顺序,使用Relaxed
是不够的,因为完全可能有下面的情况发生:
1 | fn aquire() -> JoinHandle<()> { |
因此需要对aquire
修改:
1 | fn aquire() -> JoinHandle<()> { |
Acquire
保证了后续的内存操作都不能放到这条指令之前,因此这段代码可以正确地输出最终结果100
。
这一小节中的很多内容与操作系统、编译器和CPU本身有关,并不是rust所独有的,比如内存模型、内存屏障,以及与C++20相同的memory order,如果有些内容不理解,不妨看看C++的实现或者内存模型的更多资料。
3.5 使用 Sync
和 Send
特征的可扩展并发
我们之前讨论的几乎所有内容,都属于标准库,而不是rust语言本身的内容。由于不需要语言提供并发相关的基础设施,并发方案不受标准库或语言所限:我们可以编写自己的或使用别人编写的并发功能。然而有两个并发概念是内嵌于语言中的:std::marker
中的 Sync
和 Send
特征,它们称为标记特征(marker trait)。它们的作用分别是:
- 实现
Send
的类型可以在线程间安全的传递其所有权 - 实现
Sync
的类型可以在线程间安全的共享(通过引用)
通过Send
允许在线程间转移所有权
Send
标记特征表明实现了 Send
的类型值的所有权可以在线程间传送。任何完全由 Send
的类型组成的类型会自动被标记为 Send
。几乎所有rust类型都是 Send
的。不过有一些例外,包括 Rc<T>
:这是不能 Send
的,因为如果克隆了 Rc<T>
的值并尝试将克隆的所有权转移到另一个线程,这两个线程都可能同时更新引用计数。为此,Rc<T>
被实现为用于单线程场景,这时不需要为拥有线程安全的引用计数而付出性能代价。
因此,正如线程安全的引用计数介绍的,rust类型系统和特征约束确保永远也不会意外的将不安全的 Rc<T>
在线程间发送。而使用标记为 Send
的 Arc<T>
时,就没有问题了。
另外一个不能 Send
的是裸指针。关于裸指针的介绍,见解引用裸指针。
Sync
允许多线程访问
Sync
标记特征表明一个实现了 Sync
的类型可以安全的在多个线程中拥有其值的引用。换一种方式来说,对于任意类型 T
,如果 &T
(T
的不可变引用)是 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 | // Rc源码片段 |
!
代表移除特征的相应实现,上面代码中Rc<T>
的Send
和Sync
特征被特地移除了实现,而Arc<T>
则相反,实现了Sync + Send
。
这些源码中的?Sized
,我们将在后面介绍,详见Sized
特征。
手动实现Send
和Sync
是不安全的
通常并不需要手动实现 Send
和 Sync
特征,因为由 Send
和 Sync
的类型组成的类型,自动就是 Send
和 Sync
的。因为他们是标记特征,甚至都不需要实现任何方法。它们只是用来加强并发相关的不可变性的。
手动实现这些标记特征涉及到编写不安全的rust代码。关于不安全的代码,在后面不安全的rust一章中会进行介绍。
3.6 为裸指针实现 Send
和 Sync
裸指针本身就没有任何安全保证,因此它没有实现 Send
和 Sync
,这意味着下面代码会报错:
1 | use std::thread; |
我们无法为其直接实现Send
特征,但是可以用newtype
(我们将在后面介绍,详见newtype
):struct MyBox(*mut u8)
。由于复合类型中有一个成员没实现Send
,该复合类型就不是Send
,因此我们需要手动为它实现:
1 | use std::thread; |
此时,我们的指针已经可以在多线程间转移所有权,需要注意的就是,Send
和Sync
是unsafe
特征,实现时需要用unsafe
代码块包裹。
下面为裸指针实现 Sync
,由于Sync
是多线程间共享一个值,你可能会这么实现:
1 | use std::thread; |
正如之前线程与move
闭包提到的,这里没有使用move
,线程如果直接去借用其它线程的变量,会报错:closure may outlive the current function
。原因在于编译器无法确定主线程main
和子线程t
谁的生命周期更长,特别是当两个线程都是子线程时,没有任何人知道哪个子线程会先结束。
因此需要配合Arc
去使用:
1 | use std::thread; |
上面代码将智能指针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 | pub struct AveragedCollection { |
注意,正如前面控制结构体和枚举的公有中介绍的,结构体自身被标记为 pub
,这样其他代码就可以使用这个结构体,但是在结构体内部的字段仍然是私有的。这是非常重要的,因为我们希望保证变量被增加到列表或者被从列表删除时,也会同时更新平均值。可以通过在结构体上实现 add
、remove
和 average
方法来做到这一点,
1 | impl AveragedCollection { |
公有方法 add
、remove
和 average
是修改 AveragedCollection
实例的唯一方式。当使用 add
方法把一个元素加入到 list
或者使用 remove
方法来删除时,这些方法的实现同时会调用私有的 update_average
方法来更新 average
字段。
list
和 average
是私有的,所以没有其他方式来使得外部的代码直接向 list
增加或者删除元素,否则 list
改变时可能会导致 average
字段不同步。average
方法返回 average
字段的值,这使得外部的代码只能读取 average
而不能修改它。
因为我们已经封装好了 AveragedCollection
的实现细节,将来可以轻松改变类似数据结构这些方面的内容。例如,可以使用 HashSet<i32>
代替 Vec<i32>
作为 list
字段的类型。只要 add
、remove
和 average
公有函数的签名保持不变,使用 AveragedCollection
的代码就无需改变。相反如果使得 list
为公有,就未必都会如此了: HashSet<i32>
和 Vec<i32>
使用不同的方法增加或移除项,所以如果要想直接修改 list
的话,外部的代码可能不得不做出修改。
如果封装是一个语言被认为是面向对象语言所必要的方面的话,那么rust满足这个要求。在代码中不同的部分使用 pub
与否可以封装其实现细节。
继承
继承(Inheritance)是一个很多编程语言都提供的机制,一个对象可以定义为继承另一个对象定义中的元素,这使其可以获得父对象的数据和行为,而无需重新定义。
如果一个语言必须有继承才能被称为面向对象语言的话,那么rust就不是面向对象的。因为没有宏则无法定义一个结构体继承父结构体的成员和方法。
然而,如果你过去常常在你的编程工具箱使用继承,根据你最初考虑继承的原因,rust也提供了其他的解决方案。
选择继承的原因是:一旦为一个类型实现了特定行为,继承可以对一个不同的类型重用这个实现。rust代码中可以使用默认特征方法实现来进行有限的共享,在默认实现这一小节中,介绍过这一点。任何实现了 Animal
特征的类型都可以使用 introduce
方法而无须进一步实现。这类似于父类有一个方法的实现,而通过继承子类也拥有这个方法的实现。当实现 Animal
特征时也可以选择覆盖 introduce
的默认实现,这类似于子类覆盖从父类继承的方法实现。
另外,在深入理解Fn
特征中,也介绍过定义依赖于某个特征的特征:
1 | trait A {} |
特征之间的继承也可以看作rust所部分实现的面向对象式功能。
多态
另外一个使用继承的原因与类型系统有关:表现为子类型可以用于父类型被使用的地方。这也被称为多态(polymorphism),这意味着如果多种对象共享特定的属性,则可以相互替代使用。
很多人将多态描述为继承的同义词。不过它是一个有关可以用于多种类型的代码的更广泛的概念。对于继承来说,这些类型通常是子类。 rust则通过泛型来对不同的可能类型进行抽象,并通过特征约束对这些类型所必须提供的内容施加约束。这有时被称为约束参数多态(bounded parametric polymorphism)。
近来继承作为一种语言设计的解决方案在很多语言中失宠了,因为其时常带有共享多于所需的代码的风险。子类不应总是共享其父类的所有特征,但是继承却始终如此。如此会使程序设计更为不灵活,并引入无意义的子类方法调用,或由于方法实际并不适用于子类而造成错误的可能性。
另外某些语言还只允许单继承(意味着子类只能继承一个父类),进一步限制了程序设计的灵活性。
因为种种原因,rust选择了一个不同的途径,使用特征对象而不是继承。
4.2 特征对象
让我们看一下rust中的特征对象如何实现多态。在前面返回实现了特征的类型小节中,曾经介绍了这样的例子:
1 | struct Dog { |
其中 Dog
和 Cat
都实现了 Animal
特征,因此上面的函数试图通过返回 impl Summary
来返回这两个类型,但是编译器却报错了,这是由于 impl Trait
的返回值类型并不支持多种不同的类型返回。
在使用动态数组和枚举来存储不同类型的数据小节中,我们也谈到了动态数组只能存储同种类型元素的局限。在当时,我们通过组合使用动态数组和枚举的替代方案解决了这一点,然而有时,对象集合并不能明确地知道,并且还希望库用户在特定情况下能够扩展有效的类型集合。
为了解决上述的问题,这里将创建一个图形用户接口(Graphical User Interface,GUI)工具的例子,它通过遍历列表并调用每一个项目的 draw
方法来将其绘制到屏幕上 —— 这是一个 GUI 工具的常见技术。
在编写代码的时候,我们不可能知晓并定义所有其他程序员希望创建的类型。我们所知晓的是需要一个统一的类型来处理这些对象,并需要能够对其中每一个值调用 draw
方法。这里无需知道调用 draw
方法时具体会发生什么,只要该值会有那个方法可供我们调用。
在拥有继承的语言中,可以定义一个名为 Component
的类,该类上有一个 draw
方法。其他的类比如 Button
、Image
和 SelectBox
会从 Component
派生并因此继承 draw
方法。它们各自都可以覆盖 draw
方法来定义自己的行为,但是框架会把所有这些类型当作是 Component
的实例,并在其上调用 draw
。不过这些在rust中需要另寻出路。
定义通用行为的特征
为了实现这个GUI,让我们定义一个 Draw
特征,其中包含名为 draw
的方法。接着可以定义一个存放特征对象(trait object)的 vector:
1 | pub trait Draw { |
我们通过指定某种指针来创建特征对象,可以使用 &
引用或 Box<T>
智能指针,还有 dyn
关键字,以及指定相关的特征(在动态大小类型中会介绍特征对象必须使用指针的原因),我们可以使用特征对象代替泛型或具体类型。任何使用特征对象的位置,rust的类型系统会在编译时确保任何在此上下文中使用的值会实现其特征对象的特征。如此便无需在编译时就知晓所有可能的类型。
具体来说,上面的代码中,定义了一个存放了名叫 components
的 vector 的结构体 Screen
。这个 vector 的类型是 Box<dyn Draw>
,此为一个特征对象:它是 Box
中任何实现了 Draw
特征的类型的替身。
接下来,我们为Screen
定义一个方法,该方法会对其 components
上的每一个组件调用 draw
方法:
1 | impl Screen { |
这与定义使用了带有特征约束的泛型类型参数的结构体不同。泛型类型参数一次只能替代一个具体类型,而特征对象则允许在运行时替代多种具体类型。
例如,如果定义
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 | pub struct Button { |
这里增加了两个实现了 Draw
特征的类型,Button
和SelectBox
,二者都拥有一些字段。由于这并不是真实的GUI库,我们只是模拟,因此draw
方法中没有具体的实现。
现在,我们就可以让使用者在他们的 main
函数中创建一个 Screen
实例。至此可以通过将 SelectBox
和 Button
放入 Box<T>
转变为特征对象来增加组件。
1 | fn main() { |
当编写库的时候,我们不知道何人会在何时增加 SelectBox
类型,不过 Screen
的实现能够操作并绘制这个新类型,因为 SelectBox
实现了 Draw
特征,这就意味着它实现了 draw
方法。
在python中有一个概念叫做鸭子类型(duck typing):如果一只鸟走起来像鸭子,叫起来像鸭子,那么它就是一只鸭子。
在上面 Screen
的 run
实现中,run
并不需要知道各个组件的具体类型是什么。它并不检查组件是 Button
或者 SelectBox
的实例。通过指定 Box<dyn Draw>
作为 components
动态数组中值的类型,我们就定义了 Screen
为需要可以在其上调用 draw
方法的值。
使用特征对象和rust类型系统来进行类似鸭子类型操作的优势是无需在运行时检查一个值是否实现了特定方法或者担心在调用时因为值没有实现方法而产生错误。如果值没有实现特征对象所需的特征则rust不会编译这些代码。比如:
1 | fn main() { |
下面是本节实例中完整的代码:
1 | pub trait Draw { |
注意 dyn
不能单独作为特征对象的定义,原因是特征对象可以是任意实现了某个特征的类型,编译器在编译期不知道该类型的大小,不同的类型大小是不同的,而 &dyn
和 Box<dyn>
都是指针,在编译期已知大小。
特征对象执行动态分发
在泛型中,当对泛型使用特征约束时编译器会执行单态化处理:编译器为每一个被泛型类型参数代替的具体类型生成了函数和方法的非泛型实现。单态化产生的代码在执行静态分发(static dispatch)。静态分发发生于编译器在编译时就知晓调用了什么方法的时候。这与动态分发 (dynamic dispatch)相对,这时编译器在编译时无法知晓调用了什么方法。在动态分发的场景下,编译器生成的代码到运行时才能确定调用了什么方法。
当使用特征对象时,rust必须使用动态分发。编译器无法知晓所有可能用于特征对象代码的类型,所以它也不知道应该调用哪个类型的哪个方法实现。为此,rust在运行时使用特征对象中的指针来知晓需要调用哪个方法。动态分发也阻止编译器有选择的内联方法代码,这会相应的禁用一些优化。尽管编写GUI示例代码的过程中确实获得了额外的灵活性,但仍然需要权衡取舍性能上的问题。
下面这张图很好地解释了静态分发 Box<T>
和动态分发 Box<dyn Trait>
的区别:
结合上文的内容和这张图可以了解:
- 特征对象大小不固定:这是因为,对于特征
Draw
,类型Button
可以实现特征Draw
,类型SelectBox
也可以实现特征Draw
,因此特征没有固定大小 - 几乎总是使用特征对象的引用方式,如
&dyn Draw
、Box<dyn Draw>
- 虽然特征对象没有固定大小,但它的引用类型的大小是固定的,它由两个指针组成(
ptr
和vptr
),因此占用两个指针大小 - 一个指针
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
)。
一定要注意,此时的 btn
是 Draw
的特征对象的实例,而不再是具体类型 Button
的实例,而且 btn
的 vtable
只包含了实现自特征 Draw
的那些方法(比如 draw
),因此 btn
只能调用实现于特征 Draw
的 draw
方法,而不能调用类型 Button
本身实现的方法和类型 Button
实现于其他特征的方法。也就是说,btn
是哪个特征对象的实例,它的 vtable
中就包含了该特征的方法。
trait 对象需要类型安全
只有对象安全(object-safe)的特征可以实现为特征对象。这里有一些复杂的规则来实现特征的对象安全,但在实践中,只有两个相关的规则。如果一个特征中定义的所有方法都符合以下规则,则该特征是对象安全的:
- 返回值不是
Self
- 没有泛型类型的参数
Self
关键字是我们在特征与方法上的实现的别称,特征对象必须是对象安全的,因为一旦使用特征对象,rust将不再知晓该实现的返回类型。如果一个特征的方法返回了一个 Self
类型,但是该特征对象忘记了 Self
的确切类型,那么该方法将不能使用原本的类型。当特征使用具体类型填充的泛型类型时也一样:具体类型成为实现特征的对象的一部分,当使用 trait 对象却忘了类型是什么时,无法知道应该用什么类型来填充泛型类型。
一个非对象安全的特征例子是标准库中的 Clone
特征:
1 | pub trait Clone { |
String
类型实现了 Clone
特征,当我们在 String
的实例对象上调用 clone
方法时,我们会得到一个 String
类型实例对象。相似地,如果我们调用 Vec<T>
实例对象上的 clone
方法,我们会得到一个 Vec<T>
类型的实例对象。clone
方法的标签需要知道哪个类型是 Self
类型,因为 Self
是它的返回类型。
当我们尝试编译一些违反特征对象的对象安全规则的代码时,我们会收到编译器的提示。
1 | pub struct Screen { |
这个错误意味着我们不能将此特征用于特征对象。如果你想了解更多有关对象安全的细节,请参考 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 | let mut num = 5; |
注意这里没有引入 unsafe
关键字。可以在安全代码中创建裸指针,只是不能在不安全块之外解引用裸指针,稍后便会看到。
这里使用 as
将不可变和可变引用强转为对应的裸指针类型。因为直接从保证安全的引用来创建他们,可以知道这些特定的裸指针是有效,但是不能对任何裸指针做出如此假设。
作为展示,接下来会创建一个不能确定其有效性的裸指针,
1 | let address = 0x012345usize; |
这里创建一个指向任意内存地址(0x012345
)的裸指针。尝试使用任意内存是未定义行为:此地址可能有数据也可能没有,编译器可能会优化掉这个内存访问,或者程序可能会出现段错误(segmentation fault)。虽然这是可行的,但通常没有理由去编写这样的代码。同样,可以在安全代码中创建裸指针,不过不能在安全代码中解引用裸指针和读取其指向的数据。
现在我们要做的就是对裸指针使用解引用运算符 *
,这需要一个 unsafe
块:
1 | fn main(){ |
创建一个指针不会造成任何危险,只有当访问其指向的值时才有可能遇到无效的值。
注意这里创建了同时指向相同内存位置 num
的裸指针 *const i32
和 *mut i32
。相反如果尝试同时创建 num
的不可变和可变引用,将无法通过编译,因为rust的所有权规则不允许在拥有任何不可变引用的同时再创建一个可变引用。通过裸指针,就能够同时创建同一地址的可变指针和不可变指针,若通过可变指针修改数据,则可能潜在造成数据竞争,因此需要非常小心这样的操作。
还有一种创建裸指针的方式,那就是基于智能指针来创建:
1 | let a: Box<i32> = Box::new(10); |
既然存在这么多的危险,为何还要使用裸指针呢?一个主要的应用场景便是调用 C 代码接口,这在下一小节调用不安全函数或方法中会讲到。另一个场景是构建借用检查器无法理解的安全抽象。
5.3 调用不安全函数或方法
定义与使用不安全函数
unsafe
的第二个超能力是调用不安全函数或方法。不安全函数和方法与常规函数方法十分类似,除了其开头有一个额外的 unsafe
。在此上下文中,关键字unsafe
表示该函数具有调用时需要满足的要求,而rust不会保证满足这些要求。通过在 unsafe
块中调用不安全函数,表明我们已经阅读过此函数的文档并对其是否满足函数自身的契约负责。
不安全的函数需要使用unsafe
关键字定义:
1 | unsafe fn dangerous() {} |
调用也需要在unsafe
块中,通过 unsafe
块,我们向rust保证了我们已经阅读过函数的文档,理解如何正确使用,并验证过其满足函数的契约。另外,unsafe
函数体本身也是有效的 unsafe
块,所以在不安全函数中进行另一个不安全操作时无需新增额外的 unsafe
块。
创建不安全代码的安全抽象
仅仅因为函数包含不安全代码并不意味着整个函数都需要标记为不安全的。事实上,将不安全代码封装进安全函数是一个常见的抽象。在标准库中有大量的安全函数,它们内部的一些实现包含了 unsafe
块。比如标准库中的函数split_at_mut
,它需要一些不安全代码,让我们探索如何可以实现它。这个安全函数定义于可变切片之上:它获取一个 切片并从给定的索引参数开始将其分为两个切片。先来看一下用法:
1 | fn main() { |
这个函数无法只通过安全rust实现,尝试自己实现一个split_at_mut
:
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) { |
此函数首先获取切片的长度,然后通过检查参数是否小于或等于这个长度来断言参数所给定的索引位于切片当中。该断言意味着如果传入的索引比要分割的切片的索引更大,此函数在尝试使用这个索引前panic。
之后我们在一个元组中返回两个可变的切片:一个从原始切片的开头直到 mid
索引,另一个从 mid
直到原切片的结尾。
如果尝试编译,会得到一个错误:
1 | error[E0499]: cannot borrow `*values` as mutable more than once at a time |
本质上借用切片的不同部分是可以的,因为这两个切片不会重叠,但是rust的借用检查器不能理解我们要借用这个切片的两个不同部分:它只知道我们借用了同一个切片两次。编译器还没有智能到能够理解这些。当我们知道某些事是可以的而 rust不知道的时候,就是触及不安全代码的时候了。
接下来,使用 unsafe
块和裸指针和一些不安全函数调用来实现 split_at_mut
:
1 | use std::slice; |
切片是一个指向一些数据的指针,并带有该切片的长度。可以使用 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_mut
和 add
放入 unsafe
块中以便能调用它们。通过观察代码,和增加 mid
必然小于等于 len
的断言,我们可以说 unsafe
块中所有的裸指针将是有效的切片中数据的指针。这是一个可以接受的 unsafe
的恰当用法。
我们无需将 split_at_mut
函数的结果标记为 unsafe
,并可以在安全rust中调用此函数。我们创建了一个不安全代码的安全抽象,其代码以一种安全的方式使用了 unsafe
代码,因为其只从这个函数访问的数据中创建了有效的指针。
与此相对 slice::from_raw_parts_mut
在使用切片时很有可能会崩溃。这段代码获取任意内存地址并创建了一个长为一万的切片:
1 | use std::slice; |
我们并不拥有这个任意地址的内存,也不能保证这段代码创建的切片包含有效的 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 | extern "C" { |
在 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 |
|
extern
的使用无需 unsafe
。
5.4 访问或修改可变静态变量
目前为止,我们都尽量避免讨论全局变量(global variables),rust确实支持他们,不过这对于rust的所有权规则来说是有问题的。如果有两个线程访问相同的可变全局变量,则可能会造成数据竞争。
全局变量在rust中被称为静态(static)变量,这种变量一般用于全局数据统计(如计数器、全局状态等):
1 | static HELLO_WORLD: &str = "Hello, world!"; |
这里定义和使用了一个不可变静态变量,静态(static
)变量类似于常量(const
),静态变量只能储存拥有 'static
生命周期的引用(见静态生命周期),这意味着rust编译器可以自己计算出其生命周期而无需显式标注。访问不可变静态变量是安全的。
常量与不可变静态变量的区别如下:
- 静态变量中的值有一个固定的内存地址,使用这个值总是会访问相同的地址。另一方面,常量则允许在任何被用到的时候复制其数据。
- 静态变量可以是可变的,常量则不可以。访问和修改可变静态变量都是不安全的。
- 存储在静态变量中的值必须要实现
Sync
特征。
1 | static mut COUNTER: u32 = 0; |
上面的例子中,展示了如何声明、访问和修改名为 COUNTER
的可变静态变量。就像常规变量一样,我们使用 mut
关键来指定可变性。任何读写 COUNTER
的代码都必须位于 unsafe
块中。这段代码可以编译并如期打印出 COUNTER: 3
,因为这是单线程的。拥有多个线程访问 COUNTER
则可能导致数据竞争。
拥有可以全局访问的可变数据,难以保证不存在数据竞争,这就是为何rust认为可变静态变量是不安全的。任何可能的情况,可以优先使用并发编程中介绍的内容,这样编译器就能检测不同线程间的数据访问是否是安全的。
5.5 实现不安全特征
unsafe
的另一个操作用例是实现不安全特征。当特征中至少有一个方法中包含编译器无法验证的不变式(invariant)时 trait 是不安全的。可以在 trait
之前增加 unsafe
关键字将特征声明为 unsafe
,同时特征的实现也必须标记为 unsafe
,比如:
1 | unsafe trait Foo { |
这样,相应的正确性由我们自己来保证。在为裸指针实现 Send
和 Sync
中的Sync
和 Send
标记特征,编译器会自动为完全由 Send
和 Sync
类型组成的类型自动实现它们。作为例子,我们还为裸指针实现了 Send
或 Sync
,其中必须用到unsafe
,这是因为rust不能验证我们的类型保证可以安全的跨线程发送或在多线程间访问,所以需要我们自己进行检查并通过 unsafe
表明。
5.6 访问union
中的字段
最后一个超能力是是访问union
中的字段,到目前为止还没有介绍过union
,它主要用于和 C 代码中的union
进行交互。 union
和 struct
类似,但是在一个实例中同时只能使用一个声明的字段。union
主要用于和 C 代码中的联合体交互。访问联合体的字段是不安全的,因为rust无法保证当前存储在联合体实例中数据的类型。
1 |
|
可以看出,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 | fn main() { |
为了让它们可以比较,可以使用 as
关键字进行显式类型转换(casting):
1 | fn main() { |