限制级内容!

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。

二、基础语法#

不管学什么编程语言,总之先学标准输入与标准输出就对了。

// 程序入口函数
fn main() {

    // 定义一个字符串类型的变量
    let mut input = String::new();

    // 永不退出的循环
    loop {

        // 从标准输入读取字符串,并将其附加至 input 末尾
        std::io::stdin().read_line(&mut input).ok();

        // 宏定义函数,输出至标准输出。其中 {} 为占位符
        print!("input = {}", input);
        // 也可以使用如下语句
        // print!("input = {input}");

        // 清空字符串
        input.clear();

    }

}

上述命令行中出现的 error 是 Ctrl+C 导致的,属正常现象。

1. 变量与常量#

在 Rust 中,声明变量应使用let关键字。但仅仅使用let关键字声明的变量的值一旦确定(被首次赋值后)就不可更改,除非再添加一个mut关键字。

let a = 114514;             // 不可变变量,默认
let mut b = "1919810";      // 可变变量
println!("a = {}, b = {}", a, b);
b = "114514";
a = 1919810; // 静态检查时报错
println!("a = {}, b = {}", a, b);

上述代码中将出现一个错误。错误原因是尝试更改不可变变量a

变量遮蔽(Variable Shadowing)

在上述修正后的代码中,仅添加了一个let关键字就可以使代码编译通过。在大多数编程语言中,同一作用域内声明多个同名变量是错误的语法,但 Rust 允许这么做,甚至允许声明为不同类型的变量。如果你这么做了,则后声明的变量将遮蔽之前的同名变量。这意味着,在重新声明的语句之后,将不能访问之前的变量。

不可变变量似乎类似于其它编程语言中的常量。但 Rust 不仅有不可变变量,还有常量。常量必须显式标注其类型、不可被遮蔽、不可与变量同名、不可被mut关键字修饰。

2. 基本类型#

Rust 是强类型语言,但一般情况下编译器都会智能地推导类型,在多数情况下程序员不需要显式标注类型,除非编译器无法自动推导。

(1) 整型#

Rust 的整型也分为有符号和无符号两种,如下所示。

长度有符号类型无符号类型
1 字节i8u8
2 字节i16u16
4 字节i32u32
8 字节i64u64
16 字节i128u128
目标平台的 CPU 位数isizeusize

其中,isize类型和usize类型的长度取决于目标平台的 CPU 的位数。对于 64 位的 CPU,其长度就是 8 字节;对于 32 位的 CPU,其长度就是 4 字节,以此类推。

显式类型转换

Rust不允许不同类型的数值类型直接做数学运算,必须使用as关键字将变量显式转换为同一类型才可进行运算。

let a: i8 = 114;
let b: i32 = 514 * a; // 静态检查时报错
println!("a = {}, b = {}", a, b);

对于数值类型的字面量,可以使用_(下划线)做分割符使字面量更直观,也可以在字面量之后添加类型标注。

let a = 114514_f32;
let b = 1_1451_4191_9810_i64;

Rust 的整型当然也无可避免地存在溢出现象(浮点数会变成INFINITY)。在 debug 模式下,Rust 会检查整型的溢出,一旦发生溢出,则当前线程将崩溃。在 release 模式下,溢出则会以经典 C/C++ 的方式处理(补码循环溢出)。若要处理可能出现的溢出,则可使用下列 4 类方法中的其中一个。

let a = 114514i32;

// 1. checked_* 类方法
// 若溢出,则返回 None
let b = a.checked_mul(1919810);         // 乘法运算
match b {
    None => println!("b = None"),       // 当 b 为 None 时,执行此行
    Some(i) => println!("b = {}", i),   // 当 b 不为 None 时,执行此行
}

// 2. overflowing_* 类方法
// 返回正常运算结果,和一个表示是否有溢出的 bool 型变量
let b = a.overflowing_shl(114);         // 左移运算
println!("b = ({}, {})", b.0, b.1);

// 3. saturating_* 类方法
// 若溢出,则返回最大值或最小值(取决于溢出方式)
let b = a.saturating_add(i32::MAX);     // 加法运算
println!("b = {}", b);

// 4. wrapping_* 类方法
// 按照补码循环方式处理
let b = a.wrapping_pow(11);             // 次方运算
println!("b = {}", b);

需要注意的是,移位运算是逻辑移位而不是算数移位,因此不会触发有符号整型溢出至符号位时的溢出。

(2) 浮点型#

浮点型类型只有两种:f32f64,分别占 4 字节和 8 字节。浮点类型不会出现溢出,但是会出现inf(infinite,无限)和NaN(Not a Number,不是数字) 。

let a: f64 = 1.0 / 0.0;
println!("a = {}, a.is_infinite() = {}", a, a.is_infinite());
let mut b = 0;
b = 1 / b; // 编译通过,但是运行时崩溃
println!("b = {}", b);

整型变量在除以 0 时会直接触发错误;但浮点型变量只会变为infNaN则常发生于对复数开平方时:

let a = (-4.0f64).sqrt();
println!("a = {}, a.is_nan() = {}", a, a.is_nan());

(3) 字符型#

Rust 的字符类型仅有char类型,该类型占 4 字节。所有的 Unicode 字符(包括单个汉字)均可以作为 Rust 的字符。若只希望只用 ASCII 字符,则需要使用u8类型。

let a = b'a';
let b = '啊';
println!("a = {}, b = {}", a, b);

(4) 布尔、字符串字面量与单元类型#

Rust 的布尔类型平平无奇,仅有bool类型。该类型占 1 字节,仅有truefalse两个可选值。

字符串字面量的类型是str。当将字符串字面量赋值给变量(或作参数)时,变量(或参数)的类型只能是&str,意为对字符串字面量的不可变引用。关于不可变引用,请参考下文的所有权一节。

单元类型仅有()一个值,不占用任何空间。任意没有返回值、但又会返回的函数,其返回类型均为单元类型。永远不会返回的函数(例如固定触发错误的函数)不会返回任何值(包括单元类型)。

3. 函数#

Rust 要求为函数的每个参数、函数的返回值显式标注类型。一个简单函数的示例如下:

fn safe_add(a: i32, b: i32) -> i32 {
    match a.checked_add(b) {
        None => return a,
        Some(i) => return i,
    }
}

fn main() {
    println!("114514 + 1919810 = {}", safe_add(114514, 1919810));
}

Rust 的函数不一定要声明在前使用在后,只要定义了函数就行。另外,若最后一行代码不加;(分号),则该行代码的运算结果就是此函数的返回值,等价于在其开头添加return关键字并在末尾添加;。当函数永不返回时,其返回类型应当标注为!(感叹号)。

4. 流程控制#

if语句在前文的代码示例中已经出现过了。Rust 的if语句可以像 Kotlin 一样使用:

let a = 5;

let b = if a == 1 {
    "0079"      // 若 a == 1,则 b 会被赋予 "0079" 值,以此类推
} else if a == 9 {
    "0087"
} else if a == 5 {
    "0083"
} else {
    "NOT UC"
};

println!("b = {}", b);

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变量失去所有权,因为该函数需要的参数仅仅是一个引用。引用并不拥有所有权,只有读写权。引用也分为不可变引用和可变引用,不可变引用只读,而可变引用可读可写。

let mut a = String::from("114514");
let b = &a;                                     // b 为不可变引用
println!("a = {}, b = {}, *b = {}", a, b, *b);  // *b 为解引用
let c = &mut a;                                 // c 为可变引用
c.push_str("1919810");
println!("c = {}, *c ={}", c, *c);
println!("a = {}", a);

在上述的代码中,引用和解引用似乎没有什么行为上的差异,但它们的类型是不同的。变量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)#

切片是对数组的某部分的引用。

let mut a = [0, 1, 2, 3, 4];
let b = &mut a[0..3];   // b 是 a 的可变引用

b[2] = 514;
println!("a[2] = {}", a[2]);

上述代码中,变量b是对变量a[0,3)[0, 3)区间的元素的引用。

for in循环遍历可迭代对象

可迭代的对象都可以使用for in循环遍历,定长数组与切片就是可迭代的。

let a = [0, 1, 2, 3, 4];
let b = &a[0..3];

for i in a {
    println!("a: i = {}", i)
}

for i in b {
    println!("b: i = {}", i)
}

需要注意的是,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) 结构体#

结构体默认没有实现拷贝特征。

// 定义结构体类型
struct Double {
    negative: bool,
    integer: u32,
    decimal: u32,
}

let negative = true;
let a = Double {
    negative,   // 简写
    integer: 114,
    decimal: 514,
};

// 结构体的解构
let Double {
    negative: b,
    integer: c,
    decimal: d,
} = a;

println!(
    "a = {}{}.{}",
    if a.negative { "-" } else { "" },
    a.integer,
    a.decimal,
);
println!("b = {}, c = {}, d = {}", b, c, d);

在初始化结构体实例时,必须初始化所有字段;当字段名与变量名相同时,可以像 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) 函数#

函数本身也是一种类型,且实现了拷贝特征。

fn hello() {
    println!("test");
}

let a = hello;  
let b = a;
a();            
b();            

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循环,当模式匹配成功时,循环将一直持续。上述代码中的例子仅为模式匹配的冰山一角,常用的模式匹配有:

match x {
    1 => println!("x = 1"),
    2 => println!("x = 2"),
}

三、面向对象#

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类型)或其引用类型,则该成员方法为静态方法。&selfself: &Self的简写;&mut selfself: &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)。

// 变量 a 丢失了类型,只知道是一个实现了 CodedWeapon 特征的类型
let a = make_weapon(false, String::from("Gundam"));

a.code();

特征对象的使用必须要满足栈空间可计算原则。因此,若某个特征中的某方法的参数类型或返回值类型为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>实例的生命周期被标记为'lfnext字段(引用)的生命周期也被标记为'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更长。即,ab'a\subset'b。若不满足这个标记条件,编译器将报错。另,泛型参数也可以有生命周期约束:

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)值表示函数执行失败,并将异常信息包裹后返回。

let a = String::from("114514");
// 编译器不知道要解析为哪种数值类型,所以必须标注 u32
let b = a.parse::<u32>();
match b {
    Ok(i) => println!("b = {}", i), 
    Err(i) => println!("Error: {:?}", i.source()),
}



let a = String::from("-114514");
let b = a.parse::<u32>();
match b {
    Ok(i) => println!("b = {}", i),
    Err(i) => println!("Error: {:?}", i), 
}

3. unwrap()方法和expect()方法#

这两个方法都是Result<T, E>类型的成员方法(Option<T>类型也有这两个成员方法),功能也是类似的。

// a 为 u32 类型
let a = String::from("114514").parse::<u32>().unwrap();
println!("a = {}", a);

let a = String::from("114.514").parse::<u32>().expect("Not u32");
println!("a = {}", a);

这两个函数将判断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>类型可以互相转换。

let res_ok: Result<String, String> = Ok(String::from("Ok"));
// ok() 方法:若调用者为 Ok(T) 则返回 Some(T),否则返回 None
match res_ok.clone().ok() {
    Some(i) => println!("res_ok.ok() = {}", i),
    None => println!("res_ok.ok() = None"),
}
// err() 方法:若调用者为 Err(T) 则返回 Some(T),否则返回 None
match res_ok.clone().err() {
    Some(i) => println!("res_ok.err() = {}", i),
    None => println!("res_ok.err() = None"),
}


let res_err: Result<String, String> = Err(String::from("Err"));
match res_err.clone().ok() {
    Some(i) => println!("res_err.ok() = {}", i),
    None => println!("res_err.ok() = None"),
}
match res_err.clone().err() {
    Some(i) => println!("res_err.err() = {}", i),
    None => println!("res_err.err() = None"),
}

let opt_some = Some(String::from("Some"));
// ok_or() 方法:若调用者为 Some(T) 则返回 Ok(T),否则返回 Err(参数)
match opt_some.clone().ok_or(String::from("None")) {
    Ok(i) => println!("opt_some.ok_or() = {}", i),
    Err(i) => println!("opt_some.ok_or() = {}", i),
}

let opt_none: Option<String> = None;
match opt_none.clone().ok_or(String::from("None")) {
    Ok(i) => println!("opt_none.ok_or() = {}", i),
    Err(i) => println!("opt_none.ok_or() = {}", i),
}

上述代码中的ok()方法、err()方法和ok_or()方法均会获取调用者(和参数)的所有权。

Result<T, E>类型和Option<T>类型还都可以做 “布尔运算”:

// “或”运算
let ok: Result<String, String> = Ok(String::from("ok"));
let err: Result<String, String> = Err(String::from("err"));
match err.or(ok) {
    Ok(i) => println!("err or ok = {}", i),
    Err(i) => println!("err or ok = {}", i),
}

// 与运算
let some = Some(String::from("some"));
let none: Option<String> = None;
match some.and(none) {
    Some(i) => println!("some and none = {}", i),
    None => println!("some and none = none"),
}

上述代码中,当调用者为 “真” 时,or()方法立即返回之,否则返回参数;当调用者为 “假” 时,and()方法立即返回之,否则返回参数。简单地说,“布尔运算” 遵循短路运算原则。同样地,这两个方法都会获取调用者和参数的所有权。

这两个类型还有其它的方法,此处不做赘述,详细信息请参考官方文档的std::option 模块std::result 模块

六、工程化#

模块化是每个程序员的必修课。没学的建议通通抓去维护一个函数三千行的屎山项目,体会一下什么叫做地狱。

一个函数三千行传说

模块化是为了项目结构清晰,便于维护。而像一个函数三千行,一个目录放两千份源代码文件的项目,就是纯纯的__山。

1. 项目结构#

通过 Cargo 创建的项目,其目录结构应当如下:

Rust 工程目录样例

所有的源代码都应放在src目录下;所有的集成测试代码都应放在tests目录下;所有的编译缓存都会放在target目录下。Cargo.toml文件内存放着项目信息,类似于package.json文件。

一个 Rust 项目允许输出多个可执行文件,但仅允许输出一个库文件。可执行文件一般是由src/main.rssrc/bin/main[num].rs源代码文件编译而成,这些源代码文件内均包含一个main()函数。唯一的库输出,由src/lib.rs文件编译而成。

2. 模块(Module)#

每一份源代码文件都是一个模块,并且源代码文件内还可以再创建多个内部模块及其套娃。

use std::num::ParseFloatError;

// 这个函数前文出现过
pub fn str_add(base: &String, index: &String) -> Result<String, ParseFloatError> {
    Ok((base.parse::<f64>()? + index.parse::<f64>()?).to_string())
}

// 在文件内再定义一个模块
pub mod hello {
    pub fn hello() {
        println!("Hello, world!");
    }
}

若要把源代码文件作为模块,则要么这个模块与使用者处于同一目录,要么这个模块位于含有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模块内。

// math 模块
pub mod math {
    // 求最大公因数
    pub fn gcd(a: u32, b: u32) -> u32 {
        let (mut max, mut min) = if a > b { (a, b) } else { (b, a) };
        let mut temp = max % min;
        while temp != 0 {
            max = min;
            min = temp;
            temp = max % min;
        }
        return min;
    }
}

// 单元测试模块
#[cfg(test)] 
mod test {
    use crate::math::gcd;

    #[test] // 单元测试函数必须有此属性
    fn gcd_test01() -> Result<(), u32> {
        let res = crate::math::gcd(114, 514);
        if res != 2 {
            Err(res)
        } else {
            Ok(())
        }
    }
}

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 表达式默认实现了此特征。

三者关系大致为:FnFnMutFnOnceFn\subsetneqq FnMut\subsetneqq FnOnce

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);

上述代码中,变量fun1mut关键字修饰,不是意味着该变量的值可变,而是表示该 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 语言做演示。

#include <malloc.h>
#include <stdio.h>
#include <string.h>

/* lambda 表达式所捕获的变量
 * 由编译器自动创建对应结构体
 */
typedef struct Args {
    size_t repeat;
    const char *str;
} Args;

/* lambda 表达式
 * 由编译器自动转换为函数
 *
 * 将 str 重复 repeat 次,并添加前缀 prefix 与后缀 suffix
 */
static char *lambda(const Args *const restrict args, const char *const restrict prefix,
             const char *const restrict suffix) {
    char *result = NULL;
    size_t arg_str_len = strlen(args->str), prefix_len = strlen(prefix),
           suffix_len = strlen(suffix),
           len = prefix_len + arg_str_len * args->repeat + suffix_len;

    result = (char *)calloc(len, sizeof(char));

    strcpy(result, prefix);
    for (size_t i = 0; i <CodeGroup args->repeat; i++) {
        strcpy(result + prefix_len + i * arg_str_len, args->str);
    }
    strcpy(result + prefix_len + arg_str_len * args->repeat, suffix);

    return result;
}

int main() {
    size_t repeat = 3;
    const char *str = "1919";

    // 表达式的创建与调用部分,由编译器自动转换
    Args args = {repeat, str};
    char *result = lambda(&args, "114", "514");

    printf("result = %s\n", result);
    free(result);
    return 0;
}

捕获变量这一过程将由编译器自动转换为:

  1. 创建对应的结构体
  2. 将被捕获的变量赋值给结构体实例对应的字段
  3. 将结构体实例作为参数传递给 lambda 表达式

对于作为函数参数的 lambda 表达式也是一样的,只不过在 C 语言中,需要再修改此函数的参数列表,添加一个结构体指针的形式参数。

八、标准容器#

Rust 共有四类标准容器:序列(Sequences)、映射(Maps)、集合(Sets)、其它(Misc)。标准容器都可迭代,它们都定义了iter()into_iter()方法。

let a = vec![114514; 5];

for i in a.iter() {
    println!("i = {}", i);
}

for j in a.into_iter() {
    println!("j = {}", j);
}

请注意,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()向容器尾添加一个元素摊销 O(1)O(1)
pop()删除最后一个元素并返回O(1)O(1)
append()将另一个容器的元素转移至该容器尾部O(m)O(m)
insert()插入元素至指定下标O(n)O(n)
remove()删除指定下标元素并返回O(n)O(n)
truncate()仅保留指定个数的元素
clear()删除所有元素
resize()设置容器的容量
sort()稳定排序,从小到大O(nlog2(n))O(nlog_2(n))
sort_by()稳定排序,根据给定的比较函数O(nlog2(n))O(nlog_2(n))
len()获取当前元素个数
capacity()获取当前容量
is_empty()判断容器是否为空
get()获取指定下标的元素
contains()检查容器内是否含有指定元素O(n)O(n)
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()更新键对应的值并返回旧值摊销 O(1)O(1)
remove()删除键值对并返回值O(1)O(1)
get()获取键对应的值O(1)O(1)
clear()删除所有键值对
capacity()获取当前容量
is_empty()判断容器是否为空
len()获取当前键值对个数
keys()获取键迭代器
values()获取值迭代器

3. 集合#

集合基本上就是没有值的映射,Rust 提供的集合类型也只有哈希实现(HashSet<T, S>)和 B 树实现(BTreeSet<T, A>)。由于其与映射高度相似,本文对集合不做过多赘述。

4. 其它#

只有BinaryHeap<T, A>(二叉堆)类型被单独划分为其它。当你需要:

  • 优先队列

时,BinaryHeap<T, A>是唯一选择(或者手搓一个)。该类型底层也是数组实现,因此也存在潜在的扩容开销。下表为其部分常用方法:

方法名作用时间复杂度
push()将元素插入容器中随机插入时 O(1)O(1)
pop()删除第一个元素并返回O(log2(n))O(log_2(n))
append()将另一个容器的元素转移至该容器
peek()获取第一个元素O(1)O(1)
clear()删除所有键值对
capacity()获取当前容量
is_empty()判断容器是否为空
len()获取当前键值对个数
keys()获取键迭代器
values()获取值迭代器

在默认情况下,二叉堆是最大堆。通过使用Reverse<T>类型,可以将二叉堆改造成最小堆。

// 最大堆
let mut max = BinaryHeap::from([0, 1, 2, 3, 4]);
let mut i = 0;
while !max.is_empty() {
    let temp = max.pop().unwrap();
    println!("max[{}] = {}", i, temp);
    i += 1;
}

// 最小堆
let mut min = BinaryHeap::with_capacity(5);
min.push(Reverse(0));
min.push(Reverse(1));
min.push(Reverse(2));
min.push(Reverse(3));
min.push(Reverse(4));
let mut i = 0;
while !min.is_empty() {
    let temp = min.pop().unwrap();
    println!("min[{}] = {}", i, temp.0);
    i += 1;
}

包裹类型(Newtype)

Reverse<T>类型就是一种包裹类型。其基本思路是将某个类型放到新的元组结构体中,然后让这个元组结构体实现一些特征,从而改变原有类型的行为。Reverse<T>类型就反转了其内部类型的比大小结果,从而使最大堆变成了最小堆。

九、智能指针#

Rust 中的某些类型可以被称作智能指针。智能指针,即可以自动解引用的指针。上文提到的Box<T, A>类型就是一种智能指针(由于已经介绍过了,此章就不再赘述)。

1. 解引用#

在深入智能指针之前,必须熟悉 Rust 的解引用。

let mut a = Box::new(vec![114514; 5]);
a.push(1919810);
println!("a = {:?}", a);

上述代码中,变量a的类型为Box<Vec<i32>>类型,但是却可以直接调用Vec<i32>类型的方法。当使用.运算符时,Rust 编译器在编译时会自动进行如下操作:

  1. 尝试解引用并查找成员
  2. 判断当前类型是否有该成员
  3. 尝试创建引用并查找成员

当无论如何都找不到成员时,编译器就会报错。

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()方法的代码。

#[derive(Debug)]
struct HIE {}

// 实现 Drop 特征
impl Drop for HIE {
    fn drop(&mut self) {
        println!("HIE dropped.");
    }
}

{
    let _a = HIE {};
} // <-- 变量 _a 的作用域到此结束

println!("test.");

当需要手动结束变量的作用域时,可以使用drop()函数。

let a = HIE {};
drop(a);

// 变量 a 的作用域已结束

println!("a = {:?}", a); // 静态检查时报错

需要注意的时,Copy特征和Drop特征互斥,即不论是何类型,上述两个特征只能实现其中一个。

3. 智能指针类型#

智能指针类型

Rc<T, A>类型为引用计数器(Reference Counter)版本的智能指针。其clone()方法为浅拷贝,仅拷贝栈上的值并使强引用计数 + 1;其drop()方法会使强引用计数 - 1,当强引用计数归零时释放堆内存。初始时引用计数为 1。

let a = Rc::new(vec![0, 1, 2, 3, 4]);
println!("a = {:?}, counter = {}", a, Rc::strong_count(&a));

let b = a.clone();
println!("a = {:?}, counter = {}", a, Rc::strong_count(&a));
println!("b = {:?}, counter = {}", b, Rc::strong_count(&b));

非常遗憾的是,Rc<T, A>类型既不能更改其中存储的值,也不能用于多线程。

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>>实现Copyget()获得值拷贝
LocalKey<Cell<T>>set()设置值
LocalKey<Cell<T>>实现Defaulttake()取走值
LocalKey<Cell<T>>replace()替换值
LocalKey<RefCell<T>>with_borrow()访问不可变引用
LocalKey<RefCell<T>>with_borrow_mut()访问可变引用
LocalKey<RefCell<T>>set()设置值
LocalKey<RefCell<T>>实现Defaulttake()取走值
LocalKey<RefCell<T>>replace()替换值

需要注意的是,线程局部变量是懒初始化的,即只有在首次使用前一刻才会初始化。set()方法会设置线程局部变量的值,若在首次访问前调用它,则会跳过线程局部变量的初始化。

由于各个平台的线程实现不同,导致线程局部变量在各个平台上的行为也不太一致。

  1. 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.
  2. 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.
  3. 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.

(个人译自官方文档)

  1. 在 Unix 系统上使用基于 pthread 的线程局部存储时,位于主线程的线程局部存储的析构函数(Drop特征声明的drop()方法)不会执行,因为主线程结束后程序就会立即退出。
  2. 在所有平台上,线程局部存储析构时可能会重新初始化其它的线程局部存储。某些平台会通过阻止已销毁的线程局部存储重新初始化来确保这不会无限循环,但不是所有平台都会这么做。没有这么做的平台一般在超过某个限制之后就不再执行析构函数。
  3. 在 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>类型搭配使用。

Rust 标准库中的锁

互斥锁:同一时间只能有一个线程拥有访问权(读与写)。其部分常用方法如下:

方法名参数类型作用
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 漏洞。‌乱序执行,指局部代码的执行顺序是不确定的(但不会乱序到影响运行结果)。在进行一系列原子操作的时候,偶尔需要原子操作的代码执行顺序是确定(或部分确定)的。因此,对原子类型的操作都必须指明上下文的执行顺序。

可选值限制条件含义
RelaxedCPU 可以以任意顺序执行上下文
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    \iffT实现了Sync
  • &mut T实现了Send    \iffT实现了Send
  • &T&mut T实现了Sync    \iffT实现了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. asyncawait#

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()方法会把FutureBox<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特征还声明了一些其它方法,此处不作赘述,若有需求请参考官方文档