Rust 学习笔记
限制级内容!
Rust 语言仅适合有编程基础的人学习,会的编程语言越多越好。另外,由于 Rust 没有 GC,因此也最好有一定的操作系统或者计算机组成原理的基础。
零、参考文献#
一、简介#
A language empowering everyone to build reliable and efficient software.
Rust 是一门近年来声名鹊起的编程语言,它号称兼具 C/C++ 的性能,又可以做到内存安全,并且有编译通过即可运行 10 年不出 BUG 的传说(先怀疑一手,毕竟是传说)。总之,某不可名状不堪入目不三不四的项目的源代码里使用了 Rust,正好也早已久仰 Rust 大名,所以就有了这篇笔记。
Rust 的安装方法请自行搜索,本文不做赘述。截至本文撰写之日,Rust 最新稳定版本为 1.79.0。
二、基础语法#
不管学什么编程语言,总之先学标准输入与标准输出就对了。
上述命令行中出现的 error 是 Ctrl+C 导致的,属正常现象。
1. 变量与常量#
在 Rust 中,声明变量应使用let
关键字。但仅仅使用let
关键字声明的变量的值一旦确定(被首次赋值后)就不可更改,除非再添加一个mut
关键字。
上述代码中将出现一个错误。错误原因是尝试更改不可变变量a
。
变量遮蔽(Variable Shadowing)
在上述修正后的代码中,仅添加了一个let
关键字就可以使代码编译通过。在大多数编程语言中,同一作用域内声明多个同名变量是错误的语法,但 Rust 允许这么做,甚至允许声明为不同类型的变量。如果你这么做了,则后声明的变量将遮蔽之前的同名变量。这意味着,在重新声明的语句之后,将不能访问之前的变量。
不可变变量似乎类似于其它编程语言中的常量。但 Rust 不仅有不可变变量,还有常量。常量必须显式标注其类型、不可被遮蔽、不可与变量同名、不可被mut
关键字修饰。
2. 基本类型#
Rust 是强类型语言,但一般情况下编译器都会智能地推导类型,在多数情况下程序员不需要显式标注类型,除非编译器无法自动推导。
(1) 整型#
Rust 的整型也分为有符号和无符号两种,如下所示。
长度 | 有符号类型 | 无符号类型 |
---|---|---|
1 字节 | i8 | u8 |
2 字节 | i16 | u16 |
4 字节 | i32 | u32 |
8 字节 | i64 | u64 |
16 字节 | i128 | u128 |
目标平台的 CPU 位数 | isize | usize |
其中,isize
类型和usize
类型的长度取决于目标平台的 CPU 的位数。对于 64 位的 CPU,其长度就是 8 字节;对于 32 位的 CPU,其长度就是 4 字节,以此类推。
显式类型转换
Rust不允许不同类型的数值类型直接做数学运算,必须使用as
关键字将变量显式转换为同一类型才可进行运算。
对于数值类型的字面量,可以使用_
(下划线)做分割符使字面量更直观,也可以在字面量之后添加类型标注。
let a = 114514_f32;
let b = 1_1451_4191_9810_i64;
Rust 的整型当然也无可避免地存在溢出现象(浮点数会变成INFINITY
)。在 debug 模式下,Rust 会检查整型的溢出,一旦发生溢出,则当前线程将崩溃。在 release 模式下,溢出则会以经典 C/C++ 的方式处理(补码循环溢出)。若要处理可能出现的溢出,则可使用下列 4 类方法中的其中一个。
需要注意的是,移位运算是逻辑移位而不是算数移位,因此不会触发有符号整型溢出至符号位时的溢出。
(2) 浮点型#
浮点型类型只有两种:f32
和f64
,分别占 4 字节和 8 字节。浮点类型不会出现溢出,但是会出现inf
(infinite,无限)和NaN
(Not a Number,不是数字) 。
整型变量在除以 0 时会直接触发错误;但浮点型变量只会变为inf
。NaN
则常发生于对复数开平方时:
(3) 字符型#
Rust 的字符类型仅有char
类型,该类型占 4 字节。所有的 Unicode 字符(包括单个汉字)均可以作为 Rust 的字符。若只希望只用 ASCII 字符,则需要使用u8
类型。
(4) 布尔、字符串字面量与单元类型#
Rust 的布尔类型平平无奇,仅有bool
类型。该类型占 1 字节,仅有true
和false
两个可选值。
字符串字面量的类型是str
。当将字符串字面量赋值给变量(或作参数)时,变量(或参数)的类型只能是&str
,意为对字符串字面量的不可变引用。关于不可变引用,请参考下文的所有权一节。
单元类型仅有()
一个值,不占用任何空间。任意没有返回值、但又会返回的函数,其返回类型均为单元类型。永远不会返回的函数(例如固定触发错误的函数)不会返回任何值(包括单元类型)。
3. 函数#
Rust 要求为函数的每个参数、函数的返回值显式标注类型。一个简单函数的示例如下:
Rust 的函数不一定要声明在前使用在后,只要定义了函数就行。另外,若最后一行代码不加;
(分号),则该行代码的运算结果就是此函数的返回值,等价于在其开头添加return
关键字并在末尾添加;
。当函数永不返回时,其返回类型应当标注为!
(感叹号)。
4. 流程控制#
if
语句在前文的代码示例中已经出现过了。Rust 的if
语句可以像 Kotlin 一样使用:
for in
循环则涉及到所有权概念,将于下文的切片一节叙述。while
循环与loop
循环相对简单,前者是满足条件则维持循环,后者是无条件循环。
let mut i = 0;
// while 循环
while i < 114 {
if i == 37 {
continue;
}
println!("i = {}", i);
i += 1;
}
// loop 循环
loop {
i += 1;
if i == 1919 {
break;
}
}
5. 所有权(Ownership)#
在堆上分配空间后却又不释放,是最常见的破坏内存安全的一种操作。Rust 为了可以及时释放堆上空间,但又不想引入 GC 造成性能开销,所以使用了所有权概念:同一时刻,只能有一个变量保存合法的、指向同一片堆内存的指针。对于基本类型的实例而言,所有的值都是放在栈上的;但对于String
这种类型的实例,其值有一部分需要存放于堆上。
let a = 114;
let b = a;
println!("a = {}, b = {}", a, b); // 可以编译通过并正常运行
let a = String::from("114");
let b = a;
println!("a = {}, b = {}", a, b); // 静态检查时报错
上述代码中将出现一个错误:借用已被移除的变量。因为变量a
对堆内存的所有权已被转移至变量b
。当调用函数时,也可能会发生所有权的转移:
fn wash(black: String) -> String {
return black;
}
fn main() {
let a = String::from("114514");
let b = wash(a);
println!("a = {}, b = {}", a, b); // 静态检查时报错
}
调用wash()
函数时,所有权发生了两次转移:第一次从变量a
转移至形参black
,然后再从形参转移至接受返回值的变量b
。
Rust 为了使程序员不需要关注底层实现,贴心地添加了特征这个概念。所有实现了拷贝特征的类型,在拷贝时均会深拷贝;否则仅会转移所有权。所有基本类型和任意类型的不可变引用都实现了拷贝特征。关于特征,将在下文的特征一节详述。
(1) 引用(Reference)#
在最开始的标准输入输出的代码示例中,read_line()
方法并没有让input
变量失去所有权,因为该函数需要的参数仅仅是一个引用。引用并不拥有所有权,只有读写权。引用也分为不可变引用和可变引用,不可变引用只读,而可变引用可读可写。
在上述的代码中,引用和解引用似乎没有什么行为上的差异,但它们的类型是不同的。变量b
的类型是&String
,而解引用后的类型是String
。如果在使用引用时报了类型不匹配的错,或许使用解引用即可解决。
(2) 引用作用域#
上一小节的代码中没有同时输出变量a
,变量b
和变量c
,因为会触发错误。当可变引用存在时,不允许同时存在其它引用,类似于读写锁:当存在 “写者” 时,不允许其它 “读者” 同时读,更不允许其它 “写者” 同时写。但代码中变量b
和变量c
却又似乎同时存在于同一作用域?不,引用有其独特的作用域。在编译时,Rust 会追踪引用的使用,引用的作用域只会到其最后一次使用后。
let mut a = String::from("114514");
let b = &a;
println!("a = {}, b = {}, *b = {}", a, b, *b);
// 此时,b 的作用域结束
let c = &mut a;
c.push_str("1919810");
println!("c = {}, *c ={}", c, *c);
// 此时,c 的作用域结束
println!("a = {}", a);
没有让变量a
和变量c
同时输出也是因为违反了 “读写锁” 规则:println!()
宏函数会创建对变量a
的引用,使 “读者” 和 “写者” 同时存在。
6. 复杂类型#
(1) 枚举#
在默认情况下,枚举类型没有实现拷贝特征。比起 C/C++ 的枚举,Rust 的枚举更像 C/C++ 的union
。
// 定义一个枚举类型
enum HIE {
Kouka,
Lacia,
}
// 定义一个枚举类型的变量
let a = HIE::Kouka;
其实早在整型类型的溢出处理部分就已经使用到了枚举。checked_mul()
方法的返回值类型为Option<T>
,该类型就是一个(使用了泛型的)枚举。Rust 的枚举可以整很多花活儿:
// 定义一个字符枚举类型
enum Char {
ASCII(u8), // ASCII 编码
UTF8(u32), // UTF-8 编码
GBK(u16), // GBK 编码
}
// 使用了泛型的枚举
enum Promise<T, E> {
Resolve(T),
Reject(E),
}
// 整点儿花活儿
enum Troll<T> {
Enum, // 一般通过枚举项
Switch(u32), // 带个参数的枚举项
Collection(T), // 泛型枚举项
Struct { meme: String }, // 结构体枚举项
Tuple(u32, String), // 元组枚举项
}
这些花活儿恐怕就是枚举类型默认没有实现拷贝特征的原因。关于泛型,请参考下文的泛型一节。
(2) 定长数组#
当定长数组内的元素类型实现了拷贝特征时,定长数组类型本身也实现了拷贝特征;否则就没有实现拷贝特征。另,仅有被mut
关键词修饰的数组可以更改其元素值,或被重新赋值。
let a = [0, 1, 2, 3, 4]; // 手动指定元素值
let b = ["114514"; 5]; // 每个元素值都是 "114514",共 5 个元素
其中,变量a
的类型为[i32; 5]
类型;变量b
的类型为[&str; 5]
类型。数组类型遵循[元素类型;元素个数]
格式。需要注意的是,若要像变量b
那样初始化,则元素类型必须是实现了拷贝特征的类型。
数组生成方式
在上述代码中,变量b
在初始化时,会不断拷贝字面量字符串"114514"
的不可变引用。但对于复杂类型,就没有这么简单了:
enum HIE {
Kouka,
}
fn main() {
let a = [HIE::Kouka; 5]; // 静态检查时报错
}
由于复杂类型默认的拷贝是浅拷贝(即:转移所有权),因此初始化数组时将导致除了最后一个元素以外的元素均因失去所有权而导致废弃。
(3) 切片(Slice)#
切片是对数组的某部分的引用。
上述代码中,变量b
是对变量a
的区间的元素的引用。
for in
循环遍历可迭代对象
可迭代的对象都可以使用for in
循环遍历,定长数组与切片就是可迭代的。
需要注意的是,for in
循环本身会浅拷贝被遍历对象。如果该对象实现了拷贝特征,则无事发生;若没有,则所有权转移到for in
循环内的临时对象上,循环结束后对象销毁。因此,在遍历可迭代对象时,推荐使用不可变引用而非对象本身;若要在循环内更改对象内的值,则需使用可变引用。
(4) 元组(Tuple)#
定长数组中的元素类型必须一致,而元组则是元素类型可以不一致的、不可迭代的定长数组。当元组内的所有类型均实现了拷贝特征时,则该元组也实现了拷贝特征;否则就没有实现拷贝特征。
let a = (114, "514", 1919u32, 810u16);
let (b, c, d, e) = a; // 解构赋值
println!("a.0 = {}", a.0); // 访问元素
println!("b = {},c = {}, d = {}, e = {}\n", b, c, d, e);
元组常见于函数返回值中。当一个函数需要返回多个值时,往往会采用元组形式组织返回值,然后再将返回值解构赋值给若干新变量。同样可以解构的还有定长数组与切片。
(5) 结构体#
结构体默认没有实现拷贝特征。
在初始化结构体实例时,必须初始化所有字段;当字段名与变量名相同时,可以像 JavaScript 一样简写;结构体可以被解构;结构体仅允许其实例被mut
关键字修饰,不允许仅某个字段被mut
关键字修饰;结构体内的字段也可能存在所有权问题,但所有权被转移后,其余字段仍可正常使用。
若设计结构体时,觉得其中的字段没必要逐个命名,则可以使用元组结构体或单元结构体:
// 定义一个元组结构体类型
struct Double(bool, u32, u32);
// 定一个元组结构体的实例
let a = Double(true, 114, 514);
println!("a = {}{}.{}", if a.0 { "-" } else { "" }, a.1, a.2);
struct Interface; // 定义一个单元结构体类型,没有任何字段
let b = Interface; // 定义一个单元结构体实例
单元结构体不包含任何字段,它往往作为包含静态方法的静态类。
(6) 函数#
函数本身也是一种类型,且实现了拷贝特征。
7. 模式匹配(Pattern Matching)#
模式匹配在前文中已经有过使用,即match
关键字,属于流程控制的一种。它与 C/C++ 的switch
关键字类似,但远比其强大。
// 定义一个枚举类型
enum Char {
ASCII(u8),
UTF8(u32),
GBK(u16),
}
// 定义一个枚举类型变量
let a = Char::ASCII(b'?');
// 模式匹配,根据 a 的值返回结果
let b = match a {
Char::ASCII(_) => 114,
Char::UTF8(i) => i,
// 其余可能分支,other 为形式参数
other => {
if matches!(other, Char::GBK(114)) {
514
} else {
1919
}
}
};
match
语句必须覆盖所有可能分支。与if
语句类似,match
语句同样可以返回值。若某些分支可以被忽略,则可以使用通配符_
替换形式参数。上述代码中使用了matches!()
宏函数,该宏函数使用match
语句实现。当第一参数与第二参数匹配时,返回值为true
,否则为false
。
在仅需要处理少部分分支时,match
语句,显得过于笨重。这种情况建议使用if let
语句:
enum Char {
ASCII(u8),
UTF8(u32),
GBK(u16),
}
let a = Char::ASCII(b'?');
if let Char::GBK(114) = a {
// 当 a 为 Char::GBK(114) 时进入
println!("branch 1");
} else if let Char::UTF8(i) = a {
// 当 a 为 Char::UTF8 时进入,i 为形式参数
println!("branch 2, i = {}", i);
} else {
// 其余情况
println!("default");
}
类似的,循环也有while let
循环,当模式匹配成功时,循环将一直持续。上述代码中的例子仅为模式匹配的冰山一角,常用的模式匹配有:
三、面向对象#
Rust 的面向对象可以用枚举或者结构体实现,这一部分可能与 Go 语言比较接近。比较遗憾的是,Rust 不支持继承。
1. 方法#
Rust 将类(枚举或结构体)的字段定义与其成员方法的定义分离开,成员方法需要定义在impl
块内,一个类可以有多个impl
块。比较遗憾的是 Rust 不支持函数重载,但是可以通过特征来实现方法重载。
// 结构体定义
struct HIE {
name: String,
age: u32,
}
// 成员方法定义
impl HIE {
// 静态方法,构造方法
fn constructor(name: &str, age: u32) -> Self {
HIE {
name: String::from(name),
age: age,
}
}
// 成员方法
fn say_hello(&self) {
println!("Hello, I'm {}", self.name);
}
// getter
fn name(&self) -> &String {
return &self.name;
}
// setter
fn age(&mut self, value: u32) {
self.age = value;
}
}
在上述代码中,位于impl
块中的方法均为类型HIE
的成员方法。其中,若参数列表中第一个参数不为Self
类型(此处等价于HIE
类型)或其引用类型,则该成员方法为静态方法。&self
为self: &Self
的简写;&mut self
为self: &mut Self
的简写。需要注意的是,成员方法的self
也存在所有权问题,定义时应考虑究竟是获取所有权、获取不可变引用还是获取可变引用。Rust 编译器会自动引用或解引用,因此调用成员方法仅需使用.
运算符。
2. 特征(Trait)#
Rust 的特征类似于其它编程语言的接口。一个类可以同时实现多个特征,一个特征也可以被多个类同时实现。与其它编程语言不同的是,Rust 的特征内可以有被实现的方法,该方法将作为默认的实现方法。
// 定义一个结构体
struct Gundam {
producer: String,
code_name: String,
service_age: u32,
}
// 定义一个特征
trait Artefact {
// 无默认实现
fn maker(&self) -> &String;
// 有默认实现
fn service_age(&self) -> u32 {
0
}
}
// 为 Gundam 结构体实现 Artefact 特征
impl Artefact for Gundam {
fn maker(&self) -> &String {
&self.producer
}
}
若要为某个类实现特征,则要么该类是在当前作用域下定义的,要么该特征是在当前作用域下定义的。与接口一样,特征也可以作为函数的参数或返回值。
// 给大家整个活儿
fn troll(a: &mut impl Artefact, b: &impl Artefact, c: impl Artefact) -> impl Artefact {
Gundam {
producer: a.maker().clone(),
code_name: String::from("Aerial"),
service_age: b.service_age(),
}
}
上述代码中的三个形式参数和返回值均为实现了Artefact
特征的对象(的引用)。从这个例子也能看出,这种函数依然需要注意所有权问题。
使用特征实现成员方法重载
虽然 Rust 不允许成员方法同名,但若成员方法通过实现特征的方式实现,则可以同名。
struct Gundam {
producer: String,
code_name: String,
service_age: u32,
}
// 所有的 getter 方法
trait GundamGetter {
fn producer(&self) -> &String;
fn code_name(&self) -> &String;
fn service_age(&self) -> u32;
}
// 所有的 setter 方法
trait GundamSetter {
fn producer(&mut self, value: String);
fn code_name(&mut self, value: String);
fn service_age(&mut self, value: u32);
}
impl GundamGetter for Gundam {
fn producer(&self) -> &String {
&self.producer
}
fn code_name(&self) -> &String {
&self.code_name
}
fn service_age(&self) -> u32 {
self.service_age
}
}
impl GundamSetter for Gundam {
fn producer(&mut self, value: String) {
self.producer = value;
}
fn code_name(&mut self, value: String) {
self.code_name = value;
}
fn service_age(&mut self, value: u32) {
self.service_age = value;
}
}
let mut a = Gundam {
producer: String::from("114"),
code_name: String::from("514"),
service_age: 2,
};
println!("{}", a.code_name());
// 必须显式声明是哪个特征的方法
GundamSetter::code_name(&mut a, String::from("1919810"));
上述重载和使用方式着实有点别扭,只能祈祷开发者们有空闲能考虑考虑为 Rust 添加方法重载特性了。
Rust 标准库里有一些含有默认实现的特征,例如Debug
特征、Copy
特征和Clone
特征。这些特征可以通过derive
宏直接为类实现。
#[derive(Clone, Debug)]
struct Gundam {
producer: String,
code_name: String,
service_age: u32,
}
let a = Gundam {
producer: String::from("Shinsei Kaihatsu"),
code_name: String::from("Aerial"),
service_age: 2,
};
println!("a = {:?}", a);
实现了Debug
特征的类,可以通过println!()
宏函数输出其实例对象。
3. 泛型#
泛型是面向对象的特色,不能不品尝。
// 泛型结构体
struct ArrayQueue<T> {
array: [T; 10],
head: usize,
tail: usize,
size: usize,
}
// 指定默认泛型参数的泛型结构体
struct Queue<T = u32> {
array: [T; 10],
head: usize,
tail: usize,
size: usize,
}
// 泛型特征
trait IConstructable<T> {
fn construct() -> T;
}
// 泛型枚举
enum Promise<T, E> {
Resolve(T),
Reject(E),
}
// 泛型函数
fn troll<T>(a: T) {
println!("troll");
}
与 C++ 一样,Rust 的泛型类型会在编译时搜索所有使用到的类型,然后仅实现被使用了的类型。
特征的关联类型
泛型特征还有另一种写法:
trait IConstructable {
type T;
fn construct() -> Self::T;
}
不论哪种写法,均需要在实现该特征时指定T
的类型(可以是泛型参数)。
(1) 泛型的特征约束#
在某些情况下,泛型需要限定类型实现了某些特征。Rust 提供了两种途径实现这种需求:
// 第一种,传统方法
enum Promise<T, E: Error + Debug> {
Resolve(T),
Reject(E),
}
// 第二种,使用 where 关键字
struct ArrayQueue<T>
where
T: Debug + PartialEq + PartialOrd,
{
array: [T; 10],
head: usize,
tail: usize,
size: usize,
}
当然,特征也可以使用特征约束:
trait IPrintableError: Error {
fn print_stack(&self) {
println!("{:?}", self.source())
}
}
上述代码中, 由于限定了能实现IPrintableError
特征的类型必须也实现了Error
特征,因此可以在特征方法的默认实现内调用Error
特征声明的方法。
(2) 常量泛型(Const Generics)#
但是上面这个数组队列ArrayQueue<T>
还是不太让人满意,因为它的存储上限被固定为了10
。若希望能够在初始化类型实例的时候才确定队列存储上限,则需要使用常量泛型。
struct ArrayQueue<T, const S: usize>
where
T: Debug + PartialEq + PartialOrd,
{
array: [T; S], // 此时队列上线还未被确定
head: usize,
tail: usize,
size: usize,
}
// 初始化一个 ArrayQueue 类型的实例
let a = ArrayQueue {
array: [0u32; 114],
head: 0,
tail: 0,
size: 0,
};
上述代码中的泛型参数S
代表了一个usize
类型的常量,该值需要等到实例初始化时能确定。常量泛型仅允许整型、布尔型和char
类型。
(3) 泛型类的方法#
Rust 允许为某一确定的泛型类型实现特有的方法。
// 通用方法,任意符合条件的 T,S 类型都可以调用
impl<T: Debug + PartialEq + PartialOrd, const S: usize> ArrayQueue<T, S> {
fn push(&mut self, value: T) -> bool {
if self.size == self.array.len() {
return false;
} else if self.size == 0 {
self.array[self.head] = value;
self.size += 1;
return true;
} else {
self.tail = (self.tail + 1) % self.array.len();
self.array[self.tail] = value;
self.size += 1;
return true;
}
}
}
// 仅为 T = u32 时的泛型类实现 sort 方法
impl<const S: usize> ArrayQueue<u32, S> {
fn sort(&mut self) {
self.array.sort();
}
}
4. 多态#
Rust 没有继承特性,因此其多态通过特征(接口)实现。在特征一节的代码示例中,troll()
函数的返回类型不是某个特定类型,而是一个特征。该函数可以通过编译,因为其返回的类型确定为Gundam
类型。但下述代码则会报错:
// 结构体定义
struct MobileSuit { // 机动战士
code: String,
}
struct MobileArmour { // 机动要塞
code: String,
}
// 特征定义
trait CodedWeapon { // 带编号的武器
fn code(&self);
}
// 为结构体实现特征
impl CodedWeapon for MobileSuit { // MS 是带编号的武器
fn code(&self) {
println!("code = {}", self.code)
}
}
impl CodedWeapon for MobileArmour { // MA 是带编号的武器
fn code(&self) {
println!("code = {}", self.code)
}
}
// 制造武器
fn make_weapon(t: bool, code: String) -> impl CodedWeapon {
if t {
return MobileArmour { code };
} else {
return MobileSuit { code }; // 静态检查时报错
}
}
报错原因是 Rust 编译器判定make_weapon()
函数的返回类型是MobileArmour
类型,但函数内却试图返回MobileSuit
实例。解决这个问题需要引入一个新的泛型类型Box<T, A>
和一个新的关键字dyn
。
fn make_weapon(t: bool, code: String) -> Box<dyn CodedWeapon> {
if t {
return Box::new(MobileArmour { code }); // 创建对象
} else {
return Box::new(MobileSuit { code });
}
}
Box<T, A>
类型的作用是把T
实例通过伪函数A
存放在堆上,栈上仅保留指向堆内存的指针(智能指针的一种)。通过套一层Box<T, A>
类型就能解决这个报错的根本原因是,Rust 必须在编译时确定每一个栈上值所占用的字节数。对于仅实现了某个特征的类型,Rust 无法确定它的实例到底需要多少栈空间;而Box<T, A>
类型的实例则明确地仅在栈上保留指针。
dyn
关键字表明Box<T, A>
里可能存放任意实现了CodedWeapon
特征的类型的实例,这种不固定类型的实例被称为特征对象(Trait Objects)。
特征对象的使用必须要满足栈空间可计算原则。因此,若某个特征中的某方法的参数类型或返回值类型为Self
类型,则此特征不可用于特征对象。另,含有泛型方法的特征也不可用于特征对象。
四、生命周期#
在泛型一章的示例代码中,有一个用数组实现的队列。若尝试将该队列替换为链表实现,则在实现链表结点时会出现报错:
struct ListNode<T> {
value: T,
next: &ListNode<T>, // 静态检查时报错
}
产生错误的原因是 Rust 编译器不知道next
字段引用的对象在何时被释放,且 Rust 不允许存在对象已经被释放的引用。所以此处需要显式地标注引用的生命周期:
// 别管生命周期对不对,反正静态检查不报错了
struct ListNode<'lf, T> {
value: T,
next: &'lf ListNode<'lf, T>,
}
生命周期,即对象或引用在何时会被创建或释放。在上述代码中,ListNode<T>
实例的生命周期被标记为'lf
,next
字段(引用)的生命周期也被标记为'lf
。编译器在编译时通过标注知道了next
字段和ListNode<T>
实例的生命周期一样,前者一定在后者被释放前都是有效引用。但是,生命周期标注毕竟是标注,并不会实际改变运行时的生命周期,仅仅会在静态检查和编译时改变报错判定。
另,上述链表结点的实现方式是有问题的,但这并不妨碍接下来使用有问题的链表结点做演示。与特征约束类似,生命周期也有约束:
struct ListNode<'a, 'b: 'a, T> {
value: T,
previous: &'a ListNode<'a, 'b, T>,
next: &'b ListNode<'a, 'b, T>,
}
在上述代码中,生命周期'b
应当比生命周期'a
更长。即,。若不满足这个标记条件,编译器将报错。另,泛型参数也可以有生命周期约束:
struct ListNode<'a, 'b: 'a, T: PartialOrd + PartialEq + 'b> {
value: T,
previous: &'a ListNode<'a, 'b, T>,
next: &'b ListNode<'a, 'b, T>,
}
泛型参数的生命周期约束一般可以省略,因为编译器会自动推导。对于特殊情况,生命周期可以使用'static
标注,该标注意为此引用(或对象)将从进程(或线程)开始运行后,到进程(或线程)终止前都将有效。
五、异常处理#
Rust 的异常大致分为两种,一种是触发了直接导致当前线程崩溃的错误,另一种是Result<T, E>
类型的Err(E)
。
1. panic!()
宏函数#
该宏函数的参数与println!()
宏函数一致,但区别在于这个宏函数一旦调用,当前线程就直接崩溃。一般情况下,当环境变量中的RUST_BACKTRACE
值为1
时,调试模式编译的二进制可以在崩溃时打印栈信息。
2. Result<T, E>
类型#
该类型是一个枚举类型,其定义的简化版代码如下:
enum Result<T, E> {
Ok(T),
Err(E),
}
和前文代码示例中的Promise<T, E>
类型类似。该类型常常出现在各个函数的返回类型中,Ok(T)
值表示函数执行成功,并将值包裹后返回;Err(E)
值表示函数执行失败,并将异常信息包裹后返回。
3. unwrap()
方法和expect()
方法#
这两个方法都是Result<T, E>
类型的成员方法(Option<T>
类型也有这两个成员方法),功能也是类似的。
这两个函数将判断Result<T, E>
实例究竟包裹了数据还是异常。若为异常,则直接调用panic!()
宏函数;若为数据,则将其返回。唯一不同之处在于,expect()
方法会在触发异常时打印给定的参数。当确信不会返回异常,或出现异常程序就不该再继续运行时,可以直接使用这两个方法。
4. 抛出异常#
异常不一定非要在哪发生就在哪处理。Rust 提供了一个非常优雅的异常处理方式:
// 将字符串转换为数值,以字符串形式返回其相加的结果
fn str_add(base: &String, index: &String) -> Result<String, ParseFloatError> {
// b 和 i 的类型都是 f64
let b = base.parse::<f64>()?;
let i = index.parse::<f64>()?;
Ok((b + i).to_string())
}
// 可以再简化一下
fn str_add(base: &String, index: &String) -> Result<String, ParseFloatError> {
Ok((base.parse::<f64>()? + index.parse::<f64>()?).to_string())
}
若运算结果是Ok(T)
,则?
运算符会取被包裹的值;若运算结果是Err(T)
,则?
运算符会直接return
异常本身。?
运算符也可用于Option<T>
类型,效果类似。另,若Err(T)
的类型与函数返回类型不一致,且T
类型实现了返回类型的std::convert::From
特征,则?
会自动做类型转换。
在main()
函数里也可以抛出错误,但函数返回类型需为Result<(), E>
,并且正常返回时的返回值应为Ok(())
。
5. Result<T, E>
类型和Option<T>
类型的方法#
Result<T, E>
类型和Option<T>
类型可以互相转换。
上述代码中的ok()
方法、err()
方法和ok_or()
方法均会获取调用者(和参数)的所有权。
Result<T, E>
类型和Option<T>
类型还都可以做 “布尔运算”:
上述代码中,当调用者为 “真” 时,or()
方法立即返回之,否则返回参数;当调用者为 “假” 时,and()
方法立即返回之,否则返回参数。简单地说,“布尔运算” 遵循短路运算原则。同样地,这两个方法都会获取调用者和参数的所有权。
这两个类型还有其它的方法,此处不做赘述,详细信息请参考官方文档的std::option 模块和std::result 模块。
六、工程化#
模块化是每个程序员的必修课。没学的建议通通抓去维护一个函数三千行的屎山项目,体会一下什么叫做地狱。
模块化是为了项目结构清晰,便于维护。而像一个函数三千行,一个目录放两千份源代码文件的项目,就是纯纯的__山。
1. 项目结构#
通过 Cargo 创建的项目,其目录结构应当如下:
所有的源代码都应放在src
目录下;所有的集成测试代码都应放在tests
目录下;所有的编译缓存都会放在target
目录下。Cargo.toml
文件内存放着项目信息,类似于package.json
文件。
一个 Rust 项目允许输出多个可执行文件,但仅允许输出一个库文件。可执行文件一般是由src/main.rs
和src/bin/main[num].rs
源代码文件编译而成,这些源代码文件内均包含一个main()
函数。唯一的库输出,由src/lib.rs
文件编译而成。
2. 模块(Module)#
每一份源代码文件都是一个模块,并且源代码文件内还可以再创建多个内部模块及其套娃。
若要把源代码文件作为模块,则要么这个模块与使用者处于同一目录,要么这个模块位于含有mod.rs
文件的子目录内,且mod.rs
文件内声明了此模块。
pub
关键字表明模块 / 函数 / 结构体等可以被外部导入,类似于 C++ 的public
关键字。未被pub
关键字修饰的模块 / 函数 / 结构体则默认不可被外部导入(文件作为模块时,本身默认是pub
的)。
上述代码实现了模块的定义与声明分离。导入模块时,应首先声明此模块(父模块或标准库的模块不需要此步骤),然后使用use
关键字按照相对路径或绝对路径导入。
use self::hello::{self, hello}; // 相对路径,导入此模块下定义的 hello 模块
use super::super::main; // 相对路径,导入父模块的父模块的 main() 函数
pub use crate::main; // 绝对路径,导入根模块下的 main() 函数
上述代码中,self
类似于文件系统的.
;super
类似于文件系统的..
;crate
类似于文件系统的/
。所有父模块的项,不论是否被pub
关键字修饰,均可以被子模块导入;子模块的项必须被pub
关键字修饰才能被父模块导入。use
关键字也可以被pub
关键字修饰,表明导入的项会被再导出。
若仅希望某个项仅对某些模块可见,则可使用类似如下的代码:
pub(crate) struct MobileSuit; // 仅在此项目内可见
pub(self) struct MobileArmour; // 仅在此模块内可见
pub(super) struct Gundam; // 仅在父模块内可见
pub(in super::super) struct Guntank; // 仅在父模块的父模块内可见
pub(in crate::math) struct Guncannon; // 仅在 math 模块内可见
3. 测试#
Rust 的单元测试一般是在所处源代码文件内的test
模块内。
与main()
函数一样,单元测试函数可以不返回值,直接在错误时调用panic!()
宏函数;或者返回一个Result<T, E>
的实例。在cargo test
命令之后还可以输入字符串来过滤要执行的单元测试,所有从根模块(不含)到函数(含)的 “路径” 完整包含字符串的单元测试函数都会被执行。
在运行测试时,命令行参数中--
之前的参数将被认为是cargo
的参数,其后的参数将被认为是测试函数的参数。
#[cfg(test)]
mod test {
use std::env;
#[test]
fn env_test() {
panic!("args = {:?}", env::args().collect::<Vec<String>>());
}
}
运行测试后,上述代码应当抛出错误并打印所有的命令行参数。
集成测试一般放在工程根目录下的test
文件夹下,其中的每一份.rs
文件都将被视为一个独立的模块。若要编写部分集成测试共用的代码,则应当在测试目录下新建文件夹并在其中添加mod.rs
文件,然后按照一般模块导入即可。由于集成测试不能导入main.rs
内的内容,因此代码应尽量写在其它文件中,尽可能保持main()
函数所在的文件简洁。
七、lambda 表达式#
在 Rust 中,每个 lambda 表达式的类型都不一致,但一定都实现了Copy
特征和FnOnce
特征。lambda 表达式类似于函数,其完整语法如下:
let a = |x: u32, y: u32| -> u32 {
return x + y;
};
其中,变量a
的类型为实现了Fn(u32, u32) -> u32
特征的类型。编译器将在编译时自动生成该类型,因此尝试对变量a
进行类型标注会触发报错。虽然 lambda 表达式不能像函数一样导出,但编译器可以根据上下文自动推导其类型:
// 极简 lambda 表达式
let add = |x, y| x + y;
// 根据这行代码推断 add 的类型
println!("114 + 514 = {}", add(114, 514));
与普通函数相比,lambda 表达式的强大之处在于闭包。
let ok: Result<String, String> = Ok(String::from("114"));
let err: Result<String, String> = Err(String::from("514"));
// or_else() 方法:若调用者为 Ok(T) 则返回之,否则返回 lambda 表达式的返回值
let a = err.or_else(|e| {
println!("error = {}", e);
ok // 这个变量未在 lambda 表达式内定义
});
println!("a = {}", a.unwrap());
lambda 表达式会自动收集在其内部被使用,但又未在其内部被定义的符号。or_else()
方法的参数类型为任意实现了FnOnce(E) -> Result<T, F>
特征的类型。与 lambda 表达式相关的特征有三种:
FnOnce
特征。所有 lambda 表达式默认实现了此特征。FnMut
特征。所有修改捕获变量的值,但不移交所有权的 lambda 表达式默认实现了此特征。Fn
特征。所有不修改捕获变量值,且不移交所有权的 lambda 表达式默认实现了此特征。
三者关系大致为:。
let mut a = String::from("114514");
// 仅实现了 FnOnce 特征
let fun0 = || a;
let mut a = String::from("114514");
// 实现了 FnOnce 和 FnMut 特征
let mut fun1 = || a.push_str("1919810");
// 实现了 FnOnce、FnMut 和 Fn 特征
let fun2 = || println!("a = {}", a);
上述代码中,变量fun1
被mut
关键字修饰,不是意味着该变量的值可变,而是表示该 lambda 表达式需要修改捕获变量的值。lambda 表达式的关键字还有move
,该关键字表示所有被捕获的变量均会移交所有权给 lambda 表达式。
let a = String::from("114514");
let fun = || println!("a = {}", a);
println!("a = {}", a);
let fun = move || println!("a = {}", a);
println!("a = {}", a); // 静态检查时报错
在默认情况下,lambda 表达式捕获变量时均会使用引用形式(自动判断是不可变引用还是可变引用)。而move
关键字则表示 lambda 表达式将会直接获取变量的所有权。
lambda 表达式的实现
了解 lambda 表达式的实现,有助于理解move
关键字的意义,以及为什么 lambda 表达式的mut
关键字没有和move
关键字放在同一位置。此处用C 语言做演示。
捕获变量这一过程将由编译器自动转换为:
- 创建对应的结构体
- 将被捕获的变量赋值给结构体实例对应的字段
- 将结构体实例作为参数传递给 lambda 表达式
对于作为函数参数的 lambda 表达式也是一样的,只不过在 C 语言中,需要再修改此函数的参数列表,添加一个结构体指针的形式参数。
八、标准容器#
Rust 共有四类标准容器:序列(Sequences)、映射(Maps)、集合(Sets)、其它(Misc)。标准容器都可迭代,它们都定义了iter()
和into_iter()
方法。
请注意,into_iter()
方法的参数类型是Self
,即该方法会获取调用者的所有权。
1. 序列#
Rust 标准库提供的序列共有三种实现:Vec<T, A>
类型(变长数组)、VecDeque<T, A>
类型(双向队列)和LinkedList<T, A>
类型(双向链表)。其中,泛型参数A
是一个伪函数,用于自定义如何在堆上分配内存。一般情况下,该类型不需要指定,使用默认指定的类型即可。
最佳适用范围:
- 仅在尾部附近做插入 / 删除时(例如栈)
- 可变长的数组
- 需要排序
Vec<T, A>
类型的底层实现是数组,其实例会在存储的元素超出存储上限前自动扩容,并重新分配各个元素的空间。该类型的实例存储于栈上的值仅有一个指针、一个表示当前元素个数的usize
类型的值和一个表示当前变长数组容量的usize
类型的值。
初始化一个Vec<T, A>
实例有四种常用方法:
// 宏函数初始化
let a = vec![0; 5];
// from() 静态方法,与宏函数等价
let a = Vec::from([0; 5]);
// 构造函数
let a: Vec<i32> = Vec::new();
// with_capacity() 静态方法,设置初始容量的初始化
let a: Vec<i32> = Vec::with_capacity(5);
下表为Vec<T, A>
类型常用的部分方法:
方法名 | 作用 | 时间复杂度 |
---|---|---|
push() | 向容器尾添加一个元素 | 摊销 |
pop() | 删除最后一个元素并返回 | |
append() | 将另一个容器的元素转移至该容器尾部 | |
insert() | 插入元素至指定下标 | |
remove() | 删除指定下标元素并返回 | |
truncate() | 仅保留指定个数的元素 | |
clear() | 删除所有元素 | |
resize() | 设置容器的容量 | |
sort() | 稳定排序,从小到大 | |
sort_by() | 稳定排序,根据给定的比较函数 | |
len() | 获取当前元素个数 | |
capacity() | 获取当前容量 | |
is_empty() | 判断容器是否为空 | |
get() | 获取指定下标的元素 | |
contains() | 检查容器内是否含有指定元素 | |
first() | 获取第一个元素 | |
last() | 获取最后一个元素 |
关于上述函数的参数类型与返回类型此处略去。反正 IDE 会告诉你的,没告诉你就去查官方文档罢(无慈悲)。
2. 映射#
Rust 标准库提供的映射仅有HashMap<K, V, S>
(哈希表)和BTreeMap<K, V, A>
(B 树)。同样地,第三个泛型参数均有默认值,在使用时保持默认即可。
最佳适用范围:
- 不需要对键排序
- 仅存储键值对
By default, HashMap uses a hashing algorithm selected to provide resistance against HashDoS attacks. The algorithm is randomly seeded, and a reasonable best-effort is made to generate this seed from a high quality, secure source of randomness provided by the host without blocking the program. Because of this, the randomness of the seed depends on the output quality of the system’s random number coroutine when the seed is created. In particular, seeds generated when the system’s entropy pool is abnormally low such as during system boot may be of a lower quality.
(个人译自官方文档)在默认情况下,HashMap<K, V, S>
使用特定的、可以抵抗哈希碰撞攻击的哈希算法。此算法使用随机种子,并且合理地尽最大努力从高质量且可信的、由主机提供的、不会阻塞程序运行的随机性源生成种子。因此,种子的随机性取决于生成种子时系统的随机数协程的输出质量。具体而言,在系统内核熵池异常低效时(例如系统启动时)生成的种子质量可能比较低。
HashMap<K, V, S>
类型底层实现使用了数组,因此也存在潜在的扩容开销。下表为HashMap<K, V, S>
类型常用的部分方法:
方法名 | 作用 | 时间复杂度 |
---|---|---|
insert() | 更新键对应的值并返回旧值 | 摊销 |
remove() | 删除键值对并返回值 | |
get() | 获取键对应的值 | |
clear() | 删除所有键值对 | |
capacity() | 获取当前容量 | |
is_empty() | 判断容器是否为空 | |
len() | 获取当前键值对个数 | |
keys() | 获取键迭代器 | |
values() | 获取值迭代器 |
3. 集合#
集合基本上就是没有值的映射,Rust 提供的集合类型也只有哈希实现(HashSet<T, S>
)和 B 树实现(BTreeSet<T, A>
)。由于其与映射高度相似,本文对集合不做过多赘述。
4. 其它#
只有BinaryHeap<T, A>
(二叉堆)类型被单独划分为其它。当你需要:
- 优先队列
- 堆
时,BinaryHeap<T, A>
是唯一选择(或者手搓一个)。该类型底层也是数组实现,因此也存在潜在的扩容开销。下表为其部分常用方法:
方法名 | 作用 | 时间复杂度 |
---|---|---|
push() | 将元素插入容器中 | 随机插入时 |
pop() | 删除第一个元素并返回 | |
append() | 将另一个容器的元素转移至该容器 | |
peek() | 获取第一个元素 | |
clear() | 删除所有键值对 | |
capacity() | 获取当前容量 | |
is_empty() | 判断容器是否为空 | |
len() | 获取当前键值对个数 | |
keys() | 获取键迭代器 | |
values() | 获取值迭代器 |
在默认情况下,二叉堆是最大堆。通过使用Reverse<T>
类型,可以将二叉堆改造成最小堆。
包裹类型(Newtype)
Reverse<T>
类型就是一种包裹类型。其基本思路是将某个类型放到新的元组结构体中,然后让这个元组结构体实现一些特征,从而改变原有类型的行为。Reverse<T>
类型就反转了其内部类型的比大小结果,从而使最大堆变成了最小堆。
九、智能指针#
Rust 中的某些类型可以被称作智能指针。智能指针,即可以自动解引用的指针。上文提到的Box<T, A>
类型就是一种智能指针(由于已经介绍过了,此章就不再赘述)。
1. 解引用#
在深入智能指针之前,必须熟悉 Rust 的解引用。
上述代码中,变量a
的类型为Box<Vec<i32>>
类型,但是却可以直接调用Vec<i32>
类型的方法。当使用.
运算符时,Rust 编译器在编译时会自动进行如下操作:
- 尝试解引用并查找成员
- 判断当前类型是否有该成员
- 尝试创建引用并查找成员
当无论如何都找不到成员时,编译器就会报错。
struct Kouka {}
#[derive(Clone)]
struct Lacia {}
let a = Kouka {};
let b = Lacia {};
let c = (&a).clone();
let d = (&b).clone();
在 Rust 中,任意的不可变引用类型都自动实现了Clone
特征。在上述代码中,由于Kouka
类型并没有实现Clone
特征,因此变量c
只能是不可变引用的拷贝(即类型是&Kouka
)。而Lacia
类型实现了Clone
特征,且在搜索成员时优先解引用,所以变量d
的类型是Lacia
。
Deref
特征
可以自动解引用的类型必须要实现Deref
特征。在自动解引用时,会以Deref
特征声明的deref()
方法的返回值作为解引用。另,若需要自动解可变引用,则需要实现DerefMut
特征。
2. 自动释放#
Rust 编译器为几乎所有非引用的类型(包括智能指针)都默认实现了Drop
特征。当一个变量的作用域结束后,编译器将在该处自动插入调用Drop
特征声明的drop()
方法的代码。
当需要手动结束变量的作用域时,可以使用drop()
函数。
let a = HIE {};
drop(a);
// 变量 a 的作用域已结束
println!("a = {:?}", a); // 静态检查时报错
需要注意的时,Copy
特征和Drop
特征互斥,即不论是何类型,上述两个特征只能实现其中一个。
3. 智能指针类型#
把Rc<T, A>
类型和RefCell<T>
类型结合起来变成Rc<RefCell<T>, A>
,就可以得到功能非常强大的智能指针。既可以有多个智能指针共享一份数据,每个智能指针也可以不受限制地获取可变借用。但智能指针不是法外之地,无论如何都必须遵守 “读写锁” 规则。
4. 弱引用指针#
Weak<T, A>
类型是Rc<T, A>
类型的弱化版本,前者是弱引用,后者是强引用。当且仅当强引用计数为 0 时,Rc<T, A>
实例内的值会被回收,即便此时弱引用计数大于 0。因此,Weak<T, A>
实例有可能指向了一个不存在的值,取值之前必须判断值是否存在。
let a = Rc::new(String::from("114514"));
let b = Rc::downgrade(&a);
println!("b = {}", b.upgrade().unwrap());
最常见的创建Weak<T, A>
实例的手段是调用downgrade()
静态方法,该方法参数为一个不可变引用;而读取其中的值前必须先调用upgrade()
方法,该方法返回类型为Option<Rc<T, A>>
。
有强引用计数,也就意味着有弱引用计数:
let a = Rc::new(String::from("114514"));
let b = Rc::downgrade(&a);
println!(
"Rc::strong_count(&a) = {}, Rc::weak_count(&a) = {}",
Rc::strong_count(&a),
Rc::weak_count(&a)
);
println!(
"b.strong_count() = {}, b.weak_count() = {}",
b.strong_count(),
b.weak_count()
);
强引用在使用时有可能出现循环引用(例如双向链表),导致内存泄漏或者递归爆栈,在这种时候就得考虑是否要使用弱引用。
另外,Arc<T, A>
类型也有其对应的弱引用类型Weak<T, A>
,该类型在std::sync
包内(Rc<T, A>
类型对应的弱引用在std::rc
包内)。
十、多线程#
Rust 提供了跨平台的多线程标准库。设计者希望 Rust 一方面足够 “上层建筑”,即代码与目标平台无关;另一方面又足够 “经济基础”,即控制粒度足够细。所以,Rust 里启动线程很简单,但启动一个线程就是真启动了一个线程(不像某些语言给你整个协程当线程,或者用 “线程池”“偷工减料”)。在使用线程前,请务必仔细考虑新开线程的开销究竟值不值得。
// 使用静态函数 spawn 创建线程
let a = thread::spawn(|| {
println!(
"thread id = {:?}, thread name = {:?}, thread = {:?}",
thread::current().id(), // 获取当前线程 ID
thread::current().name(), // 获取当前线程名,可能为 None
thread::current() // 获取当前线程信息
);
return 114514;
});
// 等待线程结束并接收结果
let result = a.join();
match result {
Ok(i) => println!("OK(i) = {}", i),
Err(e) => println!("Err(e) = {:?}", e),
}
Rust 的线程创建好后就会自动开始执行。若不调用join()
方法,则默认为 detach 的线程。看不懂?去给我学 C++ 的 std::thread 或者 C 的 pthread!在 Rust 中,子线程即使开启了新的线程,新线程与之并无父子关系,且不一定哪个线程先结束。
1. spawn()
方法#
#[stable(feature = "rust1", since = "1.0.0")]
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T,
F: Send + 'static,
T: Send + 'static,
{
Builder::new().spawn(f).expect("failed to spawn thread")
}
上述代码是 Rust 标准库中spawn()
静态方法的源代码。其内部实现使用了thread::Builder
类。通过此类可以做到更细粒度的线程控制:
let a = Builder::new()
.name("child thread".to_string()) // 设置线程名
.stack_size(4096) // 设置线程栈大小(单位字节)
.spawn(|| {
println!("Hello, world!");
return 114514;
})
.expect("thread failed.");
let result = a.join();
match result {
Ok(i) => println!("OK(i) = {}", i),
Err(e) => println!("Err(e) = {:?}", e),
}
注意,thread::Builder
类的spawn()
成员方法的返回类型为io::Result<JoinHandle<T>>
,必须要 “脱壳” 以后才是线程句柄。但不论是哪种spawn()
方法,其参数均为一个实现了FnOnce() -> T
特征的函数。
在 lambda 表达式一章有叙述过move
关键字。没有此关键字的 lambda 表达式,其中使用的外部变量仅仅是引用而不会获取所有权,在多线程环境下这可能会造成意想不到的结果。所以此时 Rust 编译器会在程序员使用闭包但是又没有使用move
关键字时报错。如果希望共享变量的值,则需要使用Arc<T, A>
类型。
let shared = Arc::new(String::from("114514"));
let shared_clone = shared.clone();
// 注意,此时变量 shared 的所有权仍然会被转移
let a = thread::spawn(move || println!("[child thread] shared = {}", shared));
a.join().expect("thread error.");
println!("[main thread] shared = {}", shared_clone);
2. 线程局部(Thread Local)变量#
有时每个线程都需要独立的数据副本,对这些数据副本的写入并不会影响其它线程的数据,这些数据也不需要共享,在这种情况下就可以使用线程局部变量。
// 使用 thread_local! 宏函数定义一个线程局部变量 GLOBAL_MSG
thread_local! (static GLOBAL_MSG:RefCell<String> = RefCell::new(String::from("114514")));
let a = thread::spawn(move || {
GLOBAL_MSG.with_borrow(|v| println!("[child thread] GLOBAL_MSG = {}", v)); // 不可变借用线程局部变量
GLOBAL_MSG.with_borrow_mut(|v| *v = String::from("1919810")); // 可变借用线程局部变量
GLOBAL_MSG.with_borrow(|v| println!("[child thread] GLOBAL_MSG = {}", v));
});
a.join().expect("thread error.");
GLOBAL_MSG.with_borrow(|v| println!("[main thread] GLOBAL_MSG = {}", v));
即便 lambda 表达式被move
关键字修饰,线程局部变量的所有权仍不会转移,在 lambda 表达式中的线程局部变量是其不可变借用。当该线程结束时,所使用的线程局部变量会自动释放。目前 Rust 线程局部变量唯一的问题,是没有代码自动补全与智能提示(悲)。
一般情况下,通过thread_local!()
宏函数创建的线程局部变量是LocalKey<T>
类型。线程局部变量常用的方法如下:
实现类型 | 泛型限定 | 方法名 | 作用 |
---|---|---|---|
LocalKey<T> | 无 | with() | 访问不可变引用 |
LocalKey<Cell<T>> | 实现Copy | get() | 获得值拷贝 |
LocalKey<Cell<T>> | 无 | set() | 设置值 |
LocalKey<Cell<T>> | 实现Default | take() | 取走值 |
LocalKey<Cell<T>> | 无 | replace() | 替换值 |
LocalKey<RefCell<T>> | 无 | with_borrow() | 访问不可变引用 |
LocalKey<RefCell<T>> | 无 | with_borrow_mut() | 访问可变引用 |
LocalKey<RefCell<T>> | 无 | set() | 设置值 |
LocalKey<RefCell<T>> | 实现Default | take() | 取走值 |
LocalKey<RefCell<T>> | 无 | replace() | 替换值 |
需要注意的是,线程局部变量是懒初始化的,即只有在首次使用前一刻才会初始化。set()
方法会设置线程局部变量的值,若在首次访问前调用它,则会跳过线程局部变量的初始化。
由于各个平台的线程实现不同,导致线程局部变量在各个平台上的行为也不太一致。
- On Unix systems when pthread-based TLS is being used, destructors will not be run for TLS values on the main thread when it exits. Note that the application will exit immediately after the main thread exits as well.
- On all platforms it’s possible for TLS to re-initialize other TLS slots during destruction. Some platforms ensure that this cannot happen infinitely by preventing re-initialization of any slot that has been destroyed, but not all platforms have this guard. Those platforms that do not guard typically have a synthetic limit after which point no more destructors are run.
- When the process exits on Windows systems, TLS destructors may only be run on the thread that causes the process to exit. This is because the other threads may be forcibly terminated.
(个人译自官方文档)
- 在 Unix 系统上使用基于 pthread 的线程局部存储时,位于主线程的线程局部存储的析构函数(
Drop
特征声明的drop()
方法)不会执行,因为主线程结束后程序就会立即退出。 - 在所有平台上,线程局部存储析构时可能会重新初始化其它的线程局部存储。某些平台会通过阻止已销毁的线程局部存储重新初始化来确保这不会无限循环,但不是所有平台都会这么做。没有这么做的平台一般在超过某个限制之后就不再执行析构函数。
- 在 Windows 系统上,进程结束时线程局部存储的析构函数可能只会在造成进程退出的线程上运行。这是因为其它线程可能都被强制结束了。
另外,在 Windows 平台上也不应在线程局部变量的析构函数内进行线程同步(例如join()
方法),因为这容易造成死锁。在 Windows 平台上,线程启动和退出、DLL 加载和卸载前都必须获取一个锁,而线程局部变量的析构函数执行前会获取这个锁,导致在析构函数内等待线程结束会出现死锁:析构函数等待线程退出,线程等待析构函数释放锁。
#[derive(Debug)]
struct ThreadDeadLocker;
impl Drop for ThreadDeadLocker {
fn drop(&mut self) {
thread::spawn(|| println!("It should be dead locked."))
.join()
.expect("thread error");
}
}
thread_local! {static STORAGE:ThreadDeadLocker = ThreadDeadLocker;};
// 必须访问一次线程局部变量,让它初始化
thread::spawn(move || STORAGE.with(|v| println!("v = {:?}", v)))
.join()
.expect("thread error.");
上述代码尝试在drop()
方法中新开一个线程,但很不幸在 Windows 平台上,线程无法初始化,并且程序陷入了死锁,甚至拒绝 Ctrl+C 中断信号;而经测试,在 WSL 上此代码则会正常运行。
3. 锁#
Rust 标准库提供了两种常用锁,Mutex<T>
(互斥锁)和RwLock<T>
(读写锁)。实际上,这两个锁也是一种实现了内部可变性的智能指针,且一般情况下都会和Arc<T, A>
类型搭配使用。
互斥锁:同一时间只能有一个线程拥有访问权(读与写)。其部分常用方法如下:
方法名 | 参数类型 | 作用 |
---|---|---|
new() | T | 构造函数 |
get_mut() | &mut Self | 获取内部值的可变引用 |
lock() | &Self | 阻塞到成功获取锁 |
try_lock() | &Self | 尝试获取锁 |
into_inner() | Self | 获取内部值所有权 |
is_poisoned() | &Self | 判断锁是否中毒 |
clear_poison() | &Self | 清除锁的中毒状态 |
锁“中毒”
在 Rust 中,若一个线程在获取锁后释放锁前崩溃退出了,则该锁中毒(永不释放的锁)。尝试获取一个中毒的锁会返回PoisonError
类型的错误,可以从该错误访问锁中数据的内容。
// 既是锁也是数据
let data = Arc::new(Mutex::new(String::from("114514")));
let data_copy = data.clone();
// 必定会让锁中毒的线程
if let Err(i) = thread::spawn(move || {
data_copy.lock().expect("lock failed.").push_str("1919");
panic!("force the lock to be poisoned.");
})
.join()
{
println!("i = {:?}", i);
}
let mut data_ref = match data.lock() {
Ok(i) => i,
Err(e) => {
data.clear_poison();
e.into_inner() // 获得锁中的数据
}
};
// 再次访问锁保护的数据
data_ref.push_str("810");
println!("data = {}", data_ref);
Rust 的锁一般不需要手动释放。lock()
方法的返回类型为Result<MutexGuard, PoisonError<MutexGuard>>
。MutexGuard
是一种实现了Drop
特征的类型,锁会在drop()
方法内自动释放。下面是正常使用互斥锁的代码示例:
let data = Arc::new(Mutex::new(0));
let data_copy = data.clone();
// 只加 0~100 的偶数
let helper = thread::spawn(move || {
for i in (0..=100).filter(|x| x % 2 == 0) {
*data_copy.lock().expect("lock poisoned.") += i;
// <-- 注意作用域,每次循环结束后锁将自动释放
}
});
// 只加 0~100 的奇数
for i in (0..=100).filter(|x| x % 2 != 0) {
*data.lock().expect("lock poisoned.") += i;
}
// 等待线程执行完毕
helper.join().expect("thread error");
println!(
"0 + 1 + .. + 100 = {}",
data.lock().expect("lock poisoned.")
);
4. 条件变量#
当希望各个线程中的部分代码按照一定先后顺序执行时,就需要使用Condvar
类型。线程可以等待某个条件变量(进入阻塞状态),当条件变量在其它地方被 “触发” 时,线程便可以继续运行。
let tasks = Arc::new(Mutex::new(vec![]));
let tasks_copy = tasks.clone();
let flag = Arc::new(Condvar::new());
let flag_copy = flag.clone();
let worker = thread::spawn(move || {
let mut task_ref = tasks_copy.lock().expect("lock poisoned.");
for i in 0..=10 {
println!("task = {:?}", task_ref);
flag_copy.notify_one(); // 触发信号,通知主线程已读
if i == 10 { // 避免死锁!
break;
}
// 等待已写信号
task_ref = flag_copy.wait(task_ref).expect("lock poisoned.");
}
});
let mut task_ref = tasks.lock().expect("lock poisoned.");
for i in 0..=10 {
task_ref.push(i);
flag.notify_one(); // 触发信号,通知子线程已写
if i == 10 {
break;
}
// 等待已读信号
task_ref = flag.wait(task_ref).expect("lock poisoned");
}
drop(task_ref);
worker.join().expect("thread error");
wait()
方法的参数是一个已经获取了的互斥锁。调用该方法后,锁将被自动释放并且线程将阻塞,直到有其它线程调用了notify_one()
方法或notify_all()
方法(前者只唤醒一个线程,后者会唤醒所有线程),并在唤醒后等待锁,成功获取锁后将其返回。另外,Rust 官方文档描述了该函数存在的一个问题:
Note that this function is susceptible to spurious wakeups. Condition variables normally have a boolean predicate associated with them, and the predicate must always be checked each time this function returns to protect against spurious wakeups.
(个人翻自官方文档)请注意,此函数容易被假唤醒影响。条件变量通常会关联一个布尔值,并且必须每次在此函数返回后检查该布尔值以防假唤醒。
所以上述代码是有点问题的。将上述代码修改为:
let tasks = Arc::new(Mutex::new(vec![]));
let tasks_copy = tasks.clone();
// 已读条件变量
let rflag = Arc::new((Mutex::new(false), Condvar::new()));
let rflag_copy = rflag.clone();
// 已写条件变量
let wflag = Arc::new((Mutex::new(false), Condvar::new()));
let wflag_copy = wflag.clone();
let worker = thread::spawn(move || {
let (is_written, wcond) = wflag_copy.deref();
let (is_read, rcond) = rflag_copy.deref();
for _ in 0..=10 {
// 触发已读条件
let mut ok = is_read.lock().unwrap();
*ok = true;
drop(ok);
rcond.notify_one();
// 等待已写条件
let mut ok = wcond
.wait_while(is_written.lock().unwrap(), |v| !*v)
.unwrap();
*ok = false;
drop(ok);
// 读取
let task_ref = tasks_copy.lock().unwrap();
println!("task = {:?}", task_ref);
drop(task_ref);
}
});
let (is_written, wcond) = wflag.deref();
let (is_read, rcond) = rflag.deref();
for i in 0..=10 {
// 等待已读条件
let mut ok = rcond.wait_while(is_read.lock().unwrap(), |v| !*v).unwrap();
*ok = false;
drop(ok);
// 写入
let mut task_ref = tasks.lock().unwrap();
task_ref.push(i);
drop(task_ref);
// 触发已写条件
let mut ok = is_written.lock().unwrap();
*ok = true;
drop(ok);
wcond.notify_one();
}
worker.join().expect("thread error");
wait_while()
方法是wait()
方法的一个变种,其第二个参数是一个实现了FnMut(&mut T) -> bool
特征的函数。只有当该函数返回值为false
时,wait_while()
方法才会返回。wait()
方法还有可超时的变种等,此处不做赘述,关于这些方法的细节请参考官方文档的Condvar 结构体。
5. 屏障(barrier)#
线程屏障可以让若干线程在同一位置阻塞,并同时从此位置开始执行。
let mut threads = Vec::with_capacity(3);
// 当有三个线程阻塞时,同时取消其阻塞
let barrier = Arc::new(Barrier::new(3));
for i in 0..3 {
let temp = barrier.clone();
threads.push(thread::spawn(move || {
for _ in 0..5 {
println!("[thread {}] initialized.", i);
// 等待其它线程
let result = temp.wait();
println!(
"[thread {}, is_leader() = {}] run free.",
i,
result.is_leader()
);
}
}));
}
for i in threads {
i.join().unwrap();
}
线程屏障可以重用。wait()
方法返回类型为BarrierWaitResult
,该类型仅有一个成员方法is_leader()
。每一批阻塞的线程中,有且仅有一个线程是 leader 线程,is_leader()
方法在该线程内会返回true
,其余线程会返回false
。
6. 原子类型#
原子类型保证多线程环境下即便不加锁,对其进行读写操作也不会造成竞态条件。现代 CPU 提供了支持原子操作的指令,运行这些指令时 CPU 将阻止上下文切换与其它核心访问内存。与锁相比,原子类型的开销更低,但代价是原子类型个数有限。
类型名 | 含义 |
---|---|
AtomicBool | 布尔值 |
AtomicPtr | 指针类型 |
AtomicI8 | 有符号 8 位整型 |
AtomicI16 | 有符号 16 位整型 |
AtomicI32 | 有符号 32 位整型 |
AtomicI64 | 有符号 64 位整型 |
AtomicIsize | 有符号整型,,长度取决于目标平台 CPU 位数 |
AtomicU8 | 无符号 8 位整型 |
AtomicU16 | 无符号 16 位整型 |
AtomicU32 | 无符号 32 位整型 |
AtomicU64 | 无符号 64 位整型 |
AtomicUsize | 无符号整型,长度取决于目标平台 CPU 位数 |
这些类型支持的操作也相当有限:
方法名 | 作用 |
---|---|
store() | 设置内部值 |
load() | 返回值拷贝 |
swap() | 设置内部值,并返回旧值 |
fetch_add() | 加法,并返回旧值 |
fetch_sub() | 减法,并返回旧值 |
fetch_and() | 按位与 | 逻辑与,并返回旧值 |
fetch_nand() | 按位与非 | 逻辑与非,并返回旧值 |
fetch_or() | 按位或 | 逻辑或,并返回旧值 |
fetch_xor() | 按位或非 | 逻辑或非,并返回旧值 |
fetch_max() | 取两个值中的最大值,并返回旧值 |
fetch_min() | 取两个值中的最小值,并返回旧值 |
上述方法的参数列表中均含有一个Ordering
类型(枚举类型)的形参,它代表了对原子类型的操作应该遵守什么顺序。现代 CPU 如此高效的原因是一方面 CPU 内部晶体管数量越来越多,另一方面现代 CPU 有分支预测与乱序执行这两个 “工具”。前者导致 Spectre 漏洞,后者导致 Intel 的芯片出现 Meltdown 漏洞。乱序执行,指局部代码的执行顺序是不确定的(但不会乱序到影响运行结果)。在进行一系列原子操作的时候,偶尔需要原子操作的代码执行顺序是确定(或部分确定)的。因此,对原子类型的操作都必须指明上下文的执行顺序。
可选值 | 限制条件 | 含义 |
---|---|---|
Relaxed | 无 | CPU 可以以任意顺序执行上下文 |
Release | 包含写入的操作 | 在此之前的读写一定先执行 |
Acquire | 包含读取的操作 | 在此之后的读写一定后执行 |
AcqRel | 包含读和写的操作 | Acquire +Release |
SeqCst | 无 | 完全顺序执行 |
上述含义是当前线程下的视角,线程之间的读写顺序则是随机的。
7. 消息传递#
Rust 标准库只提供了多发送者单接收者的消息传递模式。消息传递遵循 FIFO 原则,先发送的消息必定先被接收。
// 异步消息通道
let (sender, listener) = mpsc::channel();
let mut threads = Vec::with_capacity(10);
let barrier = Arc::new(Barrier::new(10));
for i in 0..10 {
let cbr = barrier.clone();
let csdr = sender.clone();
threads.push(thread::spawn(move || {
cbr.wait();
csdr.send(i).unwrap();
}));
}
// 必须调用 drop 函数,否则“死锁”
drop(sender);
for i in threads {
i.join().unwrap();
}
for i in listener {
println!("i = {}", i);
}
调用channel()
函数后会返回一个Sender<T>
类型和Receiver<T>
类型组成的元组。其中,Sender<T>
类型类似于Arc<T, A>
类型,调用clone()
方法时不会完全拷贝一份一模一样的数据,因此多线程环境下可以有多个发送者。当且仅当所有的发送者都结束其作用域时,接收者才会取消阻塞。发送者在调用send()
方法后,若此时接收者已被释放则会返回Err(SendError<T>)
;否则返回OK(())
。返回OK(())
不等于接收者一定接收到了消息,因为该方法是异步的,在这之后接收者有可能不接收消息并被直接释放。
Receiver<T>
类型并没有实现Clone
特征,该类型的变量仅能被一个线程获得所有权。但该类型实现了IntoIterator
特征。迭代器在执行next()
方法以后会阻塞至接收到发送者的消息。当所有发送者都被释放时,接收者将停止阻塞并返回None
,此时for in
循环就会结束。除了使用for in
循环之外,还可以调用接收者的recv()
方法、recv_timeout()
方法和try_recv()
方法来接收消息。由于这三个方法比较简单,此处便不做赘述。
上述代码创建的消息通道是异步的。发送者不会阻塞,理论上消息缓冲区无限大。而同步消息通道则需要指定缓冲区大小,当缓冲区满时发送者将阻塞至接收者读取,否则与异步的行为基本一致。缓冲区大小可以为 0,此时就是真同步通信了。
// 同步通信通道
let (sender, listener) = mpsc::channel();
let (sender, listener) = mpsc::sync_channel(0);
let mut threads = Vec::with_capacity(10);
let barrier = Arc::new(Barrier::new(10));
for i in 0..10 {
let cbr = barrier.clone();
let csdr = sender.clone();
threads.push(thread::spawn(move || {
cbr.wait();
csdr.send(i).unwrap();
}));
}
drop(sender);
// 必须先接收再等待线程结束,否则“死锁”
for i in listener {
println!("i = {}", i);
}
for i in threads {
i.join().unwrap();
}
8. Send
特征与Sync
特征#
实现了Send
特征的类型可以在线程间转移所有权。该特征无需手动实现,编译器将决定哪些类型应该实现。基本上,自定义的复杂类型中只要全都是实现了Send
特征的成员,该复杂类型也就自动实现了Send
特征。没有实现此特征的一个例子是Rc<T, A>
。
实现了Sync
特征的类型可以在线程间共享引用,或者说,该类型的不可变引用实现了Send
特征。根据官方文档的描述,有如下的规则:
&T
实现了Send
T
实现了Sync
&mut T
实现了Send
T
实现了Send
&T
和&mut T
实现了Sync
T
实现了Sync
一般没有实现Sync
特征的类型都具有内部可变性。当编译器错误地为某个不应该实现这两种特征的类型实现了它们时,可以像 Rust 标准库源代码中一样显式地声明不要实现它们:
impl<T: ?Sized, A: Allocator> !Sync for Rc<T, A> {}
impl<T: ?Sized, A: Allocator> !Send for Rc<T, A> {}
十一、异步#
新开线程以及线程上下文切换是非常昂贵的。对于 I/O 密集型任务,往往会出现线程还没在 CPU 上执行多久就因为 I/O 任务进入阻塞,然后切换到其它线程上下文再重复一次这个过程。线程频繁阻塞会导致上下文频繁切换,这在网络服务中往往是不可接受的。而异步编程可以看作是多线程的 I/O 密集型任务特化改进版,Rust 提供的异步实现底层仍然是多线程,但将上下文切换转换成了任务切换,减少了上下文切换开销(协程)。由于异步运行时比较大,Rust 官方并未将异步实现纳入标准库,而是单独做成了futures
库。在命令行中执行如下代码以安装此库:
> cargo add futures
一个简单的异步代码样例如下:
async fn task0() {
println!("Hello, task0!");
}
async fn task1() {
println!("Hello, task1!");
}
// 等待异步任务执行完毕
block_on(async { join!(task0(), task1()) });
被async
关键字修饰的函数就是异步函数。所有异步函数的返回类型都是一个实现了Future
特征的类型。请注意,在异步环境中的同步代码会以同步的方式执行,且会一直占用线程控制权。
1. async
与await
#
async
关键字既可以修饰函数,也可以修饰代码块。被修饰的代码块内均为异步代码,并且异步代码块总是会返回一个实现了Future
特征的对象(下文将简称为 “Future
”)。await
关键字则主要在处理Future
时使用。
async fn task() -> u32 {
println!("Hello, task0!");
return 114514;
}
block_on(async { println!("num = {}", task().await) });
上述代码中,task()
函数的返回类型不是u32
而是impl Future<Output = u32>
。await
关键字只能在异步函数或异步代码块中使用,使用该关键字后表达式的值将不再是Future
,而是其内部包裹的值。
Rust 中异步代码是 “懒” 的。异步函数与异步代码块仅仅产生一个Future
,而不会执行其中的代码。要执行其中的代码,要么使用同步的block_on()
函数,要么使用异步的await
关键字。如果使用await
关键字等待某个Future
执行完毕时,该Future
发生了阻塞,则该Future
会交出线程控制权。当前线程仍保留在 CPU 上执行,只不过会执行其它的Future
。当该Future
不再阻塞时,它将会被某个执行器重新拉起。上述过程将一直重复直至Future
执行完毕。
异步代码块类似于 lambda 表达式,且可以被move
关键字修饰。
let a = String::from("114514");
let task = async move {
println!("a = {}", a); // 取走变量 a 所有权
1919810
};
println!("task return {}", block_on(task));
相对于多线程时的 lambda 表达式,异步代码块要宽松很多。
2. 生命周期#
如果异步函数中含有引用,则引用的生命周期必须至少和返回的Future
一样长。
// 此异步函数参数为切片(引用)
async fn task(p: &[u8]) {
println!("p = {:?}", p);
}
let a = vec![0, 1, 2, 3, 4, 5];
let b = task(&a);
drop(a); // 静态检查时报错
block_on(b);
如果有时某个变量的生命周期不论如何都要比Future
短时,使用被move
关键字修饰的异步代码块获取该变量的所有权即可。
async fn task(p: &[u8]) {
println!("p = {:?}", p);
}
let b;
{ // 同步代码块
let a = vec![0, 1, 2, 3, 4, 5];
b = async move {
task(&a).await;
};
} // <-- 变量 a 的作用域到此结束
block_on(b);
3. FutureExt
特征#
FutureExt
特征是Future
特征的扩展版,引入futures
库后所有的Future
默认实现了此特征。FutureExt
特征提供了更多方便的方法:
FutureExt
特征的常用方法boxed()
方法会把Future
用Box<T, A>
智能指针包裹并将其内存地址用Pin<T>
类型固定。关于Pin<T>
类型,请参见下文。该方法需要调用它的Future
实现Send
特征。
有时会使用pin_mut!()
宏函数而非boxed()
方法。pin_mut!()
宏函数的参数类型是T
,返回值类型为Pin<&mut T>
。
4. 并发执行#
在异步块或者异步函数内使用await
关键字时,后续代码都会等待该关键字修饰的Future
执行完毕。若希望多个Future
并发执行,则需要使用由futures
库提供的一些宏。
join!()
宏函数可以接受任意个Future
作为参数,所有传入的Future
都会并发执行,直到所有Future
全部完成。该函数的返回类型是元组,依次为各个Future
的结果。
该函数还有一个变种try_join()
。当Future
返回类型为Result<T, E>
时,建议使用此变种。try_join()
宏函数要求所有作为其参数的Future
的结果必须拥有相同的泛型参数E
。
请注意,以上所有宏只能在异步环境中使用。
5. 内部实现#
(1) Future
特征#
Rust 的异步编程核心是Future
特征。Rust 标准库内的Future
特征源代码如下:
enum Poll<T> {
Ready(T), // Future 已执行完毕并返回结果
Pending, // Future 还可以执行,但已经阻塞
}
pub trait Future {
type Output;
// 每次运行此方法,即意味着将线程控制权交给此 Future
// 每次此函数返回,即意味着此 Future 交出了线程控制权
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
poll()
方法不需要程序员手动调用。当有多个Future
时,执行器需要知道哪些Future
挂起,并且在它们可以再次执行时,执行器需要收到 “通知”。作为poll()
方法的形式参数的cx
变量表示上下文,其类型为&mut Context
。通过该变量即可通知执行器某个Future
阻塞完成了。
// 异步函数
async fn task1() {
println!("task1 finished.")
}
struct Task0 {
first: bool, // 是否为第一次调用
}
// 为结构体 Task0 实现 Future 特征
impl Future for Task0 {
type Output = ();
fn poll(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Self::Output> {
// 如果是第一次调用此方法,则挂起 1s
if self.first {
let waker = cx.waker().clone(); // 获取 Waker 类型的实例
thread::spawn(move || {
thread::sleep(Duration::from_secs(1));
waker.wake(); // 通知执行器,此 Future 可再次执行
});
self.get_mut().first = false;
return Poll::Pending;
}
// 如果已经调用过一次,则完成此 Future
println!("task0 finished.");
return Poll::Ready(());
}
}
// 同时等待两个异步任务完成
block_on(async { join(Task0 { first: true }, task1()).await });
Pin<T>
泛型类的作用是 “固定住” 某个指针指向的值,防止该指针指向的值被转移所有权或被回收,除非该指针的类型实现了Unpin
特征。Rust 编译器会为所有对内存地址不敏感的类型自动实现Unpin
特征。当类型T
实现了Unpin
特征时,在功能上Pin<Box<T>>
与Box<T>
一致且Pin<&mut T>
与&mut T
一致。关于此类型的方法,请参见官方文档的Pin 结构体,此处不作赘述。
poll()
方法内必须使用Pin<&mut Self>
的原因是在异步环境中某些Future
可能会被 “移动”(改变内存地址),且其内部的Future
仍需执行。若内部的Future
引用了外部Future
的字段,就可能导致错误。
async fn task<T: Debug>(v: &[T]) {
println!("v = {:?}", v);
}
block_on(async {
let a = [114514; 5];
task(&a).await;
});
在上述代码中,async
块会在编译时由编译器转换为一个实现了Future
特征的结构体,变量a
会变为该类型的一个字段;异步函数task()
的返回类型也会被转换为实现了Future
特征的结构体,该结构体内会含有一个引用类型的字段,引用变量a
。当在更复杂的异步环境中,async
块生成的Future
就有可能被移动。为了防止内存地址改变,就需要Pin<T>
泛型类固定其内存地址。
(2) 执行器#
Future
只能在异步环境下被await
,而异步环境(async
块和异步函数)又会产生一个Future
,造成Future
套娃。但不论如何总有一个顶层的Future
,执行该顶层Future
的代码就是执行器,例如block_on()
函数。在执行器内执行顶层Future
时应调用poll()
方法。
// 异步任务
async fn task0() {
println!("task0 finished.")
}
// 异步任务
async fn task1() {
println!("task1 finished.")
}
// 顶层 Future 类型,采用消息传递方式实现线程间通信
struct TopFuture<'a, T> {
future: Mutex<BoxFuture<'a, T>>, // 内部的 Future
sender: Sender<Arc<TopFuture<'a, T>>>, // 异步消息队列的发送者
}
// 实现 ArcWake 特征,顶层 Future 实现此特征以方便唤醒
impl<'a, T> ArcWake for TopFuture<'a, T> {
// 调用此方法时表示此顶层 Future 可再次执行
fn wake_by_ref(arc_self: &Arc<Self>) {
arc_self
.sender
.send(arc_self.clone()) // 将此顶层 Future 重新发送至异步消息队列
.expect("send failed.");
}
}
// Future 竞争(执行器):只要有一个 Future 结束,立刻舍弃其余所有 Future
fn future_race<T: 'static, const S: usize>(tasks: [BoxFuture<'static, T>; S]) {
let (sender, listener) = channel();
for task in tasks {
sender
.send(Arc::new(TopFuture {
future: Mutex::new(task),
sender: sender.clone(),
}))
.expect("send error");
}
thread::spawn(move || {
for task in listener {
// 获取顶层 Future 的内部 Future
let mut future = task.future.lock().expect("lock error");
if future
.as_mut()
.poll(&mut Context::from_waker(&waker_ref(&task)))
.is_ready()
{
break;
}
}
})
.join()
.expect("thread error");
}
future_race([task1().boxed(), task0().boxed()]);
在上述代码中,BoxFuture<'a, T>
类型是Pin<Box<dyn Future<Output = T> + Send+ 'a>>
类型的别名。在调用poll()
方法时,需要传入一个Context
实例的可变引用,该实例可以通过调用from_waker()
静态方法来构造。该静态方法需要传入一个Waker
实例的不可变引用。Waker
实例的不可变引用可以通过调用waker_ref()
函数后自动解引用得到。waker_ref()
函数的参数是被Arc<T, A>
智能指针 “包裹” 的任意实现了ArcWake
特征的实例的不可变引用。即,上述代码中的顶层Future
本身就可以用于产生上下文对象。
6. Stream
特征#
与Future
特征类似,Stream
特征也是用于异步执行的特征。但区别在于,后者可以产出多个值。
async fn task() {
let (mut sender, mut listener) = futures::channel::mpsc::channel(10);
sender.send(114514).await.unwrap();
sender.send(1919810).await.unwrap();
drop(sender);
while let Some(i) = listener.next().await {
println!("i = {}", i);
}
}
block_on(task());
当Stream
结束时,其产出的值将为None
。请注意,上述代码中的channel()
函数不是标准库提供的同步实现,而是由futures
库提供的异步实现(注:该函数生成的消息队列有上限。创建无上限消息队列请使用unbounded()
函数)。listener
变量实现了Stream
特征。比较可惜的是目前for in
循环还不支持异步。下述代码为futures
库内Stream
特征的源代码:
pub trait Stream {
type Item;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>>;
fn size_hint(&self) -> (usize, Option<usize>) {
(0, None)
}
}
Stream
也有其扩展特征StreamExt
。该特征下声明的部分常用方法有:
StreamExt
特征的部分常用方法在Stream
产出的一系列值中,若有值满足给定条件,则此方法返回异步的true
,否则返回异步的false
。
let flow = stream::iter(1..=5);
block_on(
flow.any(|v| async move { v == 5 })
.inspect(|v| println!("result = {}", v)),
);
StreamExt
特征还声明了一些其它方法,此处不作赘述,若有需求请参考官方文档。