Rust从入门到放弃
这篇博客我将详细介绍 Rust 这门语言。从创建本博客至此刻大更新(2024-5),我的 Rust 开发经验已是两年有余了——我认为它是互联网厂商泛工程开发中最合适的语言。根据我的体验,它至少拥有以下优点:
- 好用的语言特性。可提高安全性的强语言类型、提高开发效率的自动类型推导、成熟的流操作等等。
- 舒适的开发环境。VSCode+RA,rust-fmt 和 rust-clippy 等工具让代码开发成为一种享受。
- 良好的异步引擎。不用基于麻烦的回调或 Webflux,像同步代码一样写异步!
- 高效。据传 Rust 和 C 的代码速度比是 1.07:1。这个特性让 Rust 能用在量化交易系统和链上系统。
- 安全。这是 Rust 被吹得最多的一点,诸如“内存绝对安全“、”只要过编译就罕有 bug”。我对这个特性的认知比较保守:至少,Rust 拥有一个强大的编译器,借用检查、生命周期等机制能有效降低内存泄漏的可能性。
- 优秀的包管理系统和良好的生态。Cargo 不能说是最强的包管理系统,但肯定是好用者之一。
Rust 的缺点不多,体感最强烈的是编译速度太慢。一般我敲入编译或测试的命令时就会去茶水间打水。
环境安装和资料推荐
Rust 的编译工具是 rustc(2024-04-09 版本 1.77.2),由工具 rustup 进行管理(2024-03-08 版本 1.27.0)。
rustup 按 官网 指示进行下载安装(注意 Windows 环境下没必要用默认的方式安装庞大的 Visual Studio)。安装完成后可以在 ~/.cargo/bin
看到 rustup、rustc、cargo、rustfmt、rust-gdb 等一系列工具。
Rust 依赖 C 的编译环境,所以针对不同的操作系统和 C 语言依赖有 不同的版本。用 rustup toolchain
来管理可能存在的多个版本。Linux 下自然依赖 gnu,而 Windows 推荐依赖 msvc。
Rust 开发环境推荐 VSCode + Rust Analyzer 插件或 Intellij-Rust,我用的是前者。
下面推荐一些 Rust 的教程和资料:
网址 | 描述 |
---|---|
https://crates.io/ | Rust 官方的包平台 |
https://rust.godbolt.org/ | 将 rust 代码翻译为汇编的工具 |
The Rust Progamming Language(Rust 程序设计语言) | Rust 官方教程,有中文翻译版 |
Rust 语言圣经(Rust Course) | 中文社区教程, |
Rust by Example | 带有大量代码示例的教程 |
Rust Async Book | Rust 中的异步编程 |
The Rustonomicon | Rust 前沿特性教程,不推荐初学者 |
常见的代码测试、格式化等命令参考:
1 | cargo test [options] [testname] [-- test-options] |
附:何柱指导的一种 Windows 下防止 Rust 编译时候爆内存的方法:
1 | cargo install -f cargo-binutils |
包管理
一个 Rust 项目里通常含有以下几项:
路径 | 功能 |
---|---|
Cargo.toml |
声明本项目直接依赖的三方库版本,以及一些构建配置。 |
Cargo.lock |
由编译器自动生成,往往很大,记录了直接依赖和间接依赖的三方库信息。 |
src/* |
源码,包括程序入口 main.rs ,提供给外界用的 lib.rs 等。 |
tests/* |
测试文件。一般用于集成测试,小型的功能测试可能直接附于源码内。 |
examples/* |
一些典型的使用示例。 |
bench/* |
用于测速。 |
包(package)是提供一系列功能的若干个 crate。crate 分为库(library crate)和二进制项(binary crate)。src/main.rs
是默认的二进制项,src/lib.rs
是默认的库。src/bin/
下可添加任意多的二进制项。
模块(mod)可以对一个 crate 里的代码进行分组和嵌套。模块、函数、结构体、结构体成员等默认私有(子模块可以调用父模块内容,父模块无法调用子模块内容,模块之间可以调用),枚举成员默认公有。可以用 pub
关键词修饰公有,修饰后上一层父模块可调用子模块内容(如果想在更上层调用,需要继续添加 pub
关键词)。
调用模块树里的内容时,可以使用绝对路径(crate::...
)或相对路径(用 self::
表示当前模块,也可以直接以同级的某个模块名开始;用 super::
表示父模块)。
用 use
导入(创建路径的软链接),用 as
重命名,用 .*
引入全部公有项。一般函数的导入会保留最后一层模块,而结构体直接引用到具体内容。 use
前也可以用 pub
修饰,称为重导出(re-exporting)。
可以用嵌套路径来消除大量的 use
行。如果涉及和子路径合并,可以使用 self
关键词。
1 | use std::cmp::Ordering; |
cargo tree
可以查看已使用的 crate 的树型依赖关系。
基本语法
Rust 支持类型推理。用 let
指定不可变变量,let mut
指定可变变量。
- 用
const
修饰(全局)常量。需指明类型,没有固定的内存地址,值无法改变(不能加mut
前缀)。 - 用
static
修饰(全局)静态变量。需指明类型,具有固定的内存地址。可以加mut
前缀表示可变的静态变量,但访问和修改可变静态变量是非安全的(只能通过unsafe
块),因为有多线程下的数据竞争问题。
1 | let x = 5; |
Rust 支持变量遮蔽(shadow),遮蔽前后类型可以不同,作用域结束后遮蔽自动结束。
1 | let x = 1; |
函数参数和返回值(如果有)必须指定类型。函数定义的位置不作要求。如果没有返回值,默认返回 ()
。
if
判断的表达式必须返回布尔类型,表达式无须强制套小括号。if
表达式返回的结果必须同类型。
1 | let number = if condition { 5 } else { 6 }; |
用 loop
,while
和 for
来表示循环。支持 continue
和 break
。break
后可添加返回值来构建表达式。
1 | let result = loop { |
基本数据类型
Rust 是一种强类型(strongly typed)和静态类型(statically typed)语言。
Rust 的原生类型(primitives type)分为标量类型(scalar type)和复合类型(compound type)。
标量类型主要有以下四种:
- 整型:
i8, i16, i32, i64, i128, isize; u8, u16, u32, u64, u128, usize
。字面量未声明默认是i32
。整型溢出(integer overflow):debug 模式下报 panic 错误,release 模式下用补码包裹。 - 浮点类型:
f32, f64
。字面量未声明默认是f64
。与Nan
交互都会变成NaN
,NaN
参与比较会 panic。 - 布尔类型:
bool
。大小为 1 字节。 - 字符类型:
char
。大小为 4 字节,Unicode 编码,用单引号表示。用b'x'
表示字符 x 的 ASCII 值。
序列(range)用来生成连续的数值,默认前闭后开。如 1..5
,1..=5
。常用于 for...in...
。
范围表达式 | 类型 | 范围 |
---|---|---|
s..t |
std::ops::Range |
|
s.. |
std::ops::RangeFrom |
|
..t |
std::ops::RangeTo |
|
.. |
std::ops::RangeFull |
|
s..=t |
std::ops::RangeInclusive |
|
..=t |
syd::ops::RangeToInclusive |
元组(tuple)可以将多个不同类型的值复合在一起,可以用模式匹配或 .
配合下标来索引。
单元类型(unit type)是一种特殊的元组,只有一个值 ()
,称为单元值(unit value),不占用内存。如果表达式或函数不返回任何其他值,就隐式地返回单元值。如果 Map 不关心键值,也可以用 ()
占位。
1 | let tuple = (500, 6.4, 1); |
数组(array)只能组合同类型的元素,长度是不可变的常量。数组内容会被分配至栈空间中。
数组切片(slice)可以部分或全部地引用数组,前闭后开。用 &
获得指向的地址,不实际拥有数据。切片相比原始数组的好处是长度确定,其本身一个胖指针(fat pointer),包含了地址和长度两个量。
1 | let months = ["January", "February", "March"]; |
never 类型(never type)是一个没有值的类型,表示永远不会完成计算的结果,用 !
标记。panic!
、一直循环的 loop、match 分支里的 break/return 会被视为 never 类型。never 类型可以被转化成任何类型。
Rust 不提供原生类型之间的隐式类型转换。可以用 as
进行显式转换,原生类型中只支持四种转换:整数和浮点数之间互相转换,枚举类型转成整型,布尔类型或字符类型转成整型,u8
转成字符类型。
结构体和函数
结构体用 struct
定义,用 .
索引内容,包括具名结构体、元组(成员匿名)和单元结构体(没有任何成员)。具名结构体用花括号定义成员字段(单元结构体可以省略花括号)。结构体必须整个可变或整个不可变。
- 具名结构体在初始化时,若赋值变量和待赋值的成员字段同名,可以简化赋值。
- 可以用类似初始化的方式将具名结构体里的字段移动出来。这种方式要求该结构体未定义 drop。
- 把结构体里某个字段的所有权转移出去后,就无法访问该字段,但仍能正常访问其他字段。
- 将
..
置于初始化的最后部分,来根据另一个实例创建新实例。 - 用
impl
块来定义结构体的专属方法。一个结构体可以定义多个impl
块。用impl
块定义的函数被称为关联函数(associated functions),用Self
和self
指代关联结构体和其实例。
1 | struct User { |
函数(function)用关键词 fn
声明,函数体是个块表达式。函数每个输入参数和返回值都要声明类型,若不声明则默认返回 ()
。同一模块下的函数名称不能相同(即使参数不同)。
方法(method)是以 self
(包括 &self
,mut self
等) 作为第一个参数的关联函数。结构体实例调用结构体方法时,会根据方法的定义进行自动引用和解引用(automatic referencing and dereferencing)。
常量函数(constant function)用 const fn
标识,定义在常量上下文(const context)中,编译阶段就能执行:
- 常量函数计算出的结果必须是确定的,即不能将随机数生成器编写为常量函数。
- 常量函数里不能包含 for 循环(loop 和 while 可以),也不能执行浮点数运算。
函数、结构体和结构体方法都支持泛型,编译时进行单态化(monomorphization),即泛型不影响效率。
1 | struct Point<T, U> { |
枚举类型
枚举类型用 enum
定义,每个成员可以选择绑定一个数据类型。可以像结构体一样用 impl
定义方法。
1 | enum Message { |
枚举的每一个成员都有自己的判别式(discriminant)。
- 如果枚举类型的每个成员都没有绑定数据类型,可以通过
Enum::Foo as isize
获取判别式的数值。 - 标准库提供了
std::mem::discriminant()
方法来获取判别式的抽象值,其只能进行相等比较和哈希。
用 repr(T)
指定判别式在存储时实际的整型。当显式指定 repr
或每个成员都没有绑定数据类型时:成员的判别式数值可以手动指定,未指定的字段标号默认是前一位标号+1(第一个是 0)。
1 |
|
整个枚举类型的占用空间一般是 size(T) + max(size(field))
。如果没有显式指定判别式的存储类型,Rust 编译器可能使用更小的类型或方法来实现判别式(但保证能正常转为 isize
)。例如:
Enum{A, B}
默认使用一个字节,而Option<T>
只需要size(T)
。Trick<T>
只需要等同于 usize 的空间(用 0 表示 Foo,其他值表示堆上 Box 的地址)。
1 | // the size is same as usize |
Vec 和 String
vec<T>
是可变长数组,数据存储存储在堆上,栈上会保留 pointer, capacity 和 length 三个参数。
- 下标索引元素时:如果
T
没有实现 copy trait,所有权会移动;越界时会报 panic 错误。 - 用
.get()
索引,可以得到Option<&T>
类型。
注意获得某个下标引用的同时禁止对 vector 进行 push,因为 push 要获取整个 vector 的可变引用。
可以用 Vec::new()
新建或 vec![]
宏初始化构造,根据下一次使用进行类型推理。
1 | let v = Vec::<i32>::new(); |
可以用 for...in...
简化对 vector 的遍历,包括不可变引用遍历和可变引用遍历。
1 | let v = vec![100, 32, 57]; |
可以用枚举类型来变相让 vector 支持不同类型。
1 | enum SpreadsheetCell { |
字符串(string)由 UTF-8 编码,本质上是 Vec<u8>
的封装,数据保存在堆里。因为每个元素占用的字节数可能不同,字符串 不支持下标索引 以防止歧义发生。字符串和字符(Unicode)互转时会有性能开销。
- 用
.chars()
返回字符串的实际字符集合;用.chars().count()
统计实际字符个数,复杂度线性。 - 用
.bytes()
返回每一个原始字节信息;用.len()
返回实际字节数,复杂度 。
1 | let mut s1 = String::from("hello"); |
字符串切片(str)是最原始的字符串类型,能保证内部是有效的 UTF-8 编码。
字符串切片通常以借用的方式(&str)存在。&str 和数组切片一样,是一个包含地址、长度两个量的胖指针。
&str 可以源于对字符串执行切片操作。注意长度按原始字节来处理,切断 utf-8 编码后会 panic。
1 | let s = "Hello, World!"; |
同时接受 &str
和 String
的函数签名可以写成:
1 | pub fn parse<S>(s: S) where S: AsRef<str> // 都视为 &str |
1 | use std::str; |
模式匹配
Match 是模式匹配的经典案例,允许将一个值与一系列模式进行比较,并执行对应的流程。
- 每一种模式的处理结果的返回值必须相同。允许某个分支里使用
continue/return/panic!
,其返回类型会被标注成!
,然后被强制转化成需要的类型。 - 模式之间范围可以重叠(优先取第一个),但必须穷尽所有情况。可使用
<name>/_
表示其他情况。 - 由于编译时要确定每个模式是否非空,当模式使用序列时仅支持表达式是数值类型和字符类型。当前稳定版本的 Rust 仅支持 inclusive 的区间表达式,即必须使用
a..=b
、..=b
和a..
三种形式。 - 用
|
表示并列;用@
将模式内容绑定到变量上供分支流程里使用,如n @ 1..=12
和Some(n @ 42)
。 - 值的表达式可以表示结构体,用花括号取其中的若干字段,用
..
表示忽略剩余字段。 - 匹配守卫(match guard)应用于某个分支后,只有当额外的
if
条件成立才会执行对应语句。当同一句模式里匹配守卫和 | 结合时,| 前后所有的可能都会执行 if 条件。
if let
是 Match 的语法糖(没有定义 PartialEq 的 enum 也能使用),可选的 else
相当于 Match 里的 _
。
1 | let mut count = 0; |
Rust 1.65 支持了 let else
语法。
1 | fn get_count_item(s: &str) -> &str { |
let
,while let
和 for
中也可以使用模式匹配来简化代码。
1 | struct Foo { |
模式的可反驳性(refutability):不可反驳的 指能匹配任何传递的值,可反驳的 指会存在匹配失败。
- 函数参数,
let
和for
只能接受不可反驳的模式,因为不匹配时是无意义的。 if let
、while let
和let else
被设计成只能接受可反驳的模式。match
中最多只能有一个分支使用不可反驳模式。
切片模式可以匹配固定大小的数组和动态大小的数组切片。
- 匹配数组时,只要每个元素都是不可反驳的,切片模式就是不可反驳的。
- 匹配数组切片时,只有当模式是有标识符和后置的
..
构成时,才是不可反驳的。
1 | let v = vec![1, 2, 3]; |
切片模式内部如果用范围模式表达某个元素:
- 和 Match 一样,当前 Rust 稳定版不允许使用 inclusive 的范围模式。
- 没有上下界的必须用括号括起来,如
(a..)
;具有上下界的范围模式则无须括号,如a..=b
。
Option 和 Result
Option 和 Result 是 Rust 内置的泛型枚举类型。
1 | enum Result<T, E> { |
用 Result<T, E>
处理可恢复的错误。
.unwrap()
可以解包Result
直接获得其返回值,若产生 Error 会转化成 panic。.expect()
与上述类似,只是会把 panic 错误信息替换成给出的字符串参数。
1 | use std::fs::File; |
调用函数的末尾加上 ?
后可以简单地起到错误传播的作用。即若 Result 返回值是 Ok,? 不起任何作用;若返回值是 Err,会把值做一个 into()
强制转换并向外传递。?
本质上是所有实现了 try trait
的类型的语法糖。最常见的实现了 try trait
的类型是 Result<Ok, Err>
,Option<T>
和 ControlFlow<B, C>
。
1 | fn read_username_from_file() -> Result<String, io::Error> { |
当开发者遇到不可处理的错误时,Rust 会产生 panic,此时有两种处理方式可选。
- 栈展开(unwind),默认的处理方式。Rust 会回溯栈上的数据和函数调用,终止出错所在线程,并调用析构函数。栈展开可以设置 RUST_BACKTRACE 环境变量来得到 backtrace。在没有使用 unsafe 代码的情况下,栈展开能保证 panic 时不会导致内存不安全。
panic::catch_unwind
用于捕捉栈展开方式下的 panic,但要求传递过来的闭包必须是 UnwindSafe 的。若无标记,可以用 AssertUnWindSafe 这个包装器强制转换。 - 终止(abort)。程序会不清理数据直接退出,由操作系统来清理。可在配置项里配置
panic = 'abort'
。
用 Option<T>
表达可能为空的数据,它和 Result 一样可以在函数里里用 ?
抛出。
如果要用到 Option<T>
的引用,一般来说 Option<&T>
比 &Option<T>
更好。
- 若返回值里返回 Option 可变引用,
Option<&mut T>
和&mut Option<T>
语义不同,酌情选取。 - 若返回值里返回 Option 不可变引用,前者更优。从使用侧考虑,对 Option 做操作(如 map)时前者的变式更方便。此外在需求变化时,前者的兼容性也更好:当
Option<T>
改为Option<Box<T>>
时,前者的函数签名不用变化;前者可以在函数里进一步 filter 而后者不行。 - 若参数里传入 Option 的可变引用,也是前者更优,因为后者在构造时会移动 T。
前者的内存空间似乎比后者多了个标记位,但其实 Rust 做了 Option 套引用的内存优化,举例如下:
Option<i32>
会占用两个连续的字节,一个是标记位,一个存具体的 i32。&Option<i32>
会占用一个固定的内存指针的空间,指向Option<i32>
的地址。Option<&i32>
只需一个内存指针,不再需要标记位:全零地址表示 None,非全零地址指向 i32 地址。
黑科技:If let Some(a) = b
可以写成 for a in b
(但是不推荐)。
Option 和 Result 之间可以灵活转化,Rust 提供了很多类似流操作的 API。
trait
trait 类似于其他语言的接口(interfaces),用来定义一系列函数的集合。任何类型都可以是 trait 的实例。
定义 trait 时可为函数实现默认方法。注意,无法从相同方法的重载实现中调用默认方法。
1 | pub trait Summary { |
使用泛型时,可以用 :
指定要求实现的 trait,称为泛型约束(trait bound)。多个 trait 之间用 +
连接。函数参数中可以用 impl
简化声明,也可以用 where
语法把参数里的泛型约束更清晰地提取出来。
1 | pub fn notify(item1: impl Summary, item2: impl Summary) |
trait 没有继承功能,但可以要求作为若干个 trait 的超集,用 :
和 +
连接,例如 trait Subtrait: Supertrait1 + Supertrait2
,那么实现 Subtrait
时必须同时实现前两个 trait。
如果参数或返回值只有在实现 trait 时才能确定,可以用 type
关键字定义关联类型(associated types)。关联类型和泛型很像,但是在实现 trait 时只需指定一次,不必在每一处声明泛型类型。
1 | trait Animal { |
如果在关联类型定义中声明泛型参数,被称为泛型关联类型(Generic Associated Types,GAT)。
1 | trait Calculator { |
Rust 定义满足以下条件的 trait 被认为是对象安全(object safe)的:
- 没有任何关联类型,也不包含泛型参数。
- 所有 super trait 均是对象安全的。
- 所有函数正好只有第一个参数是涉及 self 的。
当我们希望使用一个 trait 的实例而不确定到底是哪种类型时,可以用 dyn trait 来实现动态绑定。常见的使用方法是 &dyn trait
或 Box<dyn trait>
。注意如果 dyn trait 用于非对象安全的 trait,会触发 rust 编译器的报错。可以用 where Self: Sized
来将未满足对象安全条件的函数排除在外(dyn trait 时就无法调用)。
1 | trait Animal { |
Rust 不允许为外部类型实现外部 trait,即只有当 trait 或者要实现 trait 的类型位于 crate 的本地作用域时,才能为该类型实现 trait。这个限制被称为孤儿规则(orphan rule),主要有两个目的:防止 crate 间的 trait 互相冲突;实现向前兼容,即不会因为依赖 crate 中 trait 实现的变化造成无法编译。
一个绕开孤儿规则的方法是使用 newtype 模式,对待实现 trait 的类型做一个简单的封装。如下:
1 | use std::fmt; |
当和 trait 的方法出现歧义时,可以用完全限定语法(fully qualified syntax)指定。 如果结构体 Human 定义了 fly(&self)
方法,它又实现了带有 fly(&self)
方法的 Pilot trait,h.fly()
默认使用前者, Pilot::fly(h)
指定使用后者;如果 fly()
方法里不带有 &self
,用 <Pilot as Human>::fly()
指定使用后者。
所有权和生命周期
Rust 中的每一个值都有一个对应的所有者(owner)的变量。当变量离开其作用域时,值会被丢弃。
- 对未实现 copy trait 的类型进行赋值或函数传参时所有权会转移(move),原变量不能再被使用。
- 对于结构体或元组等编译期间可以确定的复合类型,允许部分成员发生所有权的转移,其他成员仍然可以正常使用,但结构体整体不允许再被使用;而数组、Vec 不允许部分成员转移,因为编译器无法准确统计。
Rust 用 &
表示引用(borrow),不发生所有者的转移;用 &mut
表示可变引用,可以改变值但是不发生所有者的转移。借用检查规定:一个值在同一个作用域内只能有一个可变引用或多个不可变引用。
Rust2018 引入非词法作用域生命周期(Non-Lexical Lifetimes,NLL)特性。如果某个引用在以后的代码中不再被使用,它会在最后一句相关的语句执行之后提前被消除。即以下代码能正确运行。
1 | let mut s = String::from("hello"); |
数组元素的借用会使整体被标记成借用。为了对多个元素进行可变操作,有以下几种解决方案:
- 使用循环或其他迭代结构,这样可以在不同迭代步骤的作用域中分别可变借用不同的元素。
- 使用切片的
split_at_mut
方法,可以获得指向数组不同部分的两个可变切片。 - 使用
Cell
或RefCell
这种提供内部可变性的类型,但会造成一些运行时的开销。
生命周期(lifetime)用于表示多个引用的泛型生命周期参数如何相互联系。生命周期参数本质用泛型来标注,参数以 '
开头,通常是较短的小写字母(如 'a
)。返回值引用和某个参数引用如果有相同的标记,意味着其生命周期不得短于该参数。注意使用泛型参数时,生命周期参数始终在类型参数之前。
下面的例子暗示了 longest
返回的引用的生命周期与传入两个引用的较小者保持一致。
1 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { |
结构体的每个引用也要标注。下例暗示 ImportantExcerpt
实例不能比其 part
字段引用的字符串存在得更久。
1 | struct ImportantExcerpt<'a> { |
结构体方法里的引用也要标注。下例展示了两组(执行生命周期省略规则后)合法的方法标注示例。
1 | impl<'a> ImportantExcerpt<'a> { |
早期 Rust 要求每个引用都标注明确的生命周期,后来编译器会执行生命周期省略规则(lifetime elision rules)。Rust 有一条输入生命周期(input lifetimes)省略规则和两条输出生命周期(output lifetimes)省略规则。它会根据这三条规则对当前代码进行不完整推断,如果推断完后仍然残留未知生命周期的参数就会编译报错。
- 默认每一个是引用的参数都有它自己默认的生命周期参数。
- 如果输入生命周期参数只有一种,那么它会被赋予给所有输出生命周期参数。
- 如果方法有多个输入生命周期参数且其中一个参数是
&self
或&mut self
(说明是对象的方法),那么所有输出生命周期参数会被赋予self
的生命周期。
根据 RFC 599 和 RFC1156,Trait 的默认生命周期会被加上 'static
,显式加上 '_
才能用于常规的省略规则。
静态生命周期用 'static
标注,其生命周期能够存活于整个程序期间。字符串字面量默认是静态生命周期。
1 | let s: &'static str = "I have a static lifetime."; |
Map 和 Iterator
std::collections::HashMap
比 ahash::HashMap
更慢但更(密码学)安全。
HashMap 的键和值的类型必须各自相同。
.insert(key, value)
:插入一组元素,若存在则会覆盖。.entry(key).or_insert(value)
:如果 key 不存在,就插入指定 value。返回 value 的可变索引。
对于基本类型这种实现了 copy trait
的类型,其值会直接拷贝至 map,否则会转移所有权至 map。
1 | let mut scores = HashMap::new(); |
迭代器都实现了一个叫做 Iterator
的标准 trait,其定义如下:
1 | pub trait Iterator { |
type Item
和 Self::Item
定义了 trait 的关联类型(associated type),表示 next 返回的是元素的类型。
用 v.iter()/iter_mul()/into_iter()
获取 vector 对应的迭代器。后者会获取 vector 的所有权。
map
能遍历(消费)迭代器并返回一个新迭代器,sum/collect/filter
等能获得迭代器所有权并消费它。
1 | let v1: Vec<i32> = vec![1, 2, 3]; |
闭包
所谓闭包,就是能够捕获作用域里变量的匿名函数。
定义闭包时,||
里定义参数列表,后接 {}
定义函数体。函数参数和返回值的类型可以省略,根据第一次使用的情况自动推理。闭包和函数最大的不同是它可以自动捕捉作用域里的变量。
1 | let add_one_v2 = |x: u32| -> u32 { x + 1 }; |
编译器倾向于优先通过不可变借用来捕获外部变量,其次是可变借用,最后才是移动所有权。
在闭包参数前加上 move
关键词后,闭包会强制获取作用域里所需变量的所有权(copy trait 仍然是拷贝),一般用于跨线程数据传输。这种闭包在调用一次之后无法再次捕获作用域里的变量,所以只能被 FnOnce()
接受。
- 当 move 闭包使用了数组元素,且数组元素不支持 copy trait:获取了整个数组的所有权。
- 当 move 闭包使用了结构体、元组、枚举的字段,且该字段不支持 copy trait:如果该结构体未实现 drop trait,则捕获的是单个字段的所有权,否则是整个结构体的所有权。
闭包作为函数参数时,可以用三种 trait 承接:
trait 名 | 原理 |
---|---|
FnOnce(self) |
只能调用一次。所有闭包都会实现 FnOnce trait。 |
FnMut(&mut self) |
可多次调用。获取作用域里变量的可变引用,生存期间外界无法获取引用 |
Fn(&self) |
可多次调用。获取作用域里变量的不可变引用,生存期间外界无法获取可变引用 |
普通函数的 fn
无须捕获作用域里的变量,因此可以随时随地调用。函数指针可将函数作为参数,其同时实现了闭包的三个 trait。不捕获环境中的任何变量的闭包可以通过匹配签名的方式自动转化成函数指针。
1 | fn main() { |
闭包返回引用类型的参数时,需要显式指定生命周期。
智能指针
智能指针通常用结构体实现,通过实现 Deref trait 和 Drop trait 来做到自动引用和清理。
最简单的智能指针是 Box<T>
,实际数据会被转移到堆上存储,栈上仅保留指针。内存布局取决于 T 本身:如果是 T 是 Sized 栈上就只需记录地址,如果是 Slice 栈上还需记录长度,如果是 dyn trait 栈上还需记录虚表指针。
Box 可以解决递归类型的问题(Rust 不允许直接递归定义结构体,因为这样就无法计算所需空间)。
1 | enum List { |
Box 会实现 Deref 实现自动解引用。对 Box 执行解引用时,会转移指针内部数据的所有权。
1 | impl<T> Deref for MyBox<T> { |
Rc<T>
是引用计数指针,支持多所有权机制。用 Rc::clone
克隆时计数器会加一,离开作用域计数器就减一,当计数值为 0 时数据会被清理。 注意 Rc<T>
仅支持多个部分 只读地 共享数据,否则会违反所有权规则。
Rc<T>
可能会引起引用循环导致内存泄漏,因为只有当 strong_count=0
才会清理。可以调用 Rc::downgrade
创建 Weak<T>
。弱引用也会进行计数,区别在于 weak_count
无须计数为 0 就能使 Rc<T>
实例被清理。
Cow
智能指针包含 Borrowed 和 Owned 两种类型,前者是对原始数据的引用,后者拥有所有权。
1 | pub enum Cow<'a, B> |
Cow 支持 clone-on-write 功能,在需要 Clone 时才进行 Clone。下面是官网的例子:
1 | fn abs_all(input: &mut Cow<[i32]>) { |
还有个经典例子是 B = str
。Cow<&str>
一般用于函数的返回值,取决于是否对参数 &str
做了修改。
1 | enum Cow { |
小课堂:size_of::<Cow<str>>()
在 Rust 版本里的变迁。
Unsafe 和内部可变性
unsafe 关键字可以修饰函数、代码块、trait 定义和 impl 块。
- 用 unsafe 修饰的 trait 在 impl 的时候必须也加上 unsafe,不能和 safe 的混合使用。
- unsafe 函数以为这个函数在某些场景下安全、某些场景下不安全,其调用必须在 unsafe 上下文中。和其他语言交互的 ffi 函数
extern "C"
会被隐式地带上 unsafe 修饰。 - unsafe block 指需要使用 unsafe 代码作为内部实现(且当前上下文已经满足安全条件),如解裸指针。
内部可变性(interior mutability)是 Rust 的一个设计模式,需要用 unsafe
标记,以模糊可变性和借用规则。
UnsafeCell<T>
提供了一种在不可变值内部进行修改的方法。不基于其开发的内部可变性都是未定义行为。
1 |
|
Cell<T>
基于所有权转移提供内部可变性接口,内部直接基于 UnsafeCell 做了封装,不会导致 panic。
1 |
|
RefCell<T>
基于引用计数提供内部可变性接口,内部也基于 UnsafeCell 开发。
- 它有
borrow
和borrow_mul
两个方法,分别创建一个不可变借用和可变借用。任何时候仅能有多个不可变借用或者一个可变借用,这个检查从编译时推迟到运行时,违反会 panic。 - 内部维护计数器:初始值为 ;不可变借用时值 +1,释放时值 -1;可变借用时值直接置为 -1。
- 使用
try_borrow()
可尝试获取不可变借用,若内部计数器值为 -1 则返回 BorrowError。
结合 Rc
和 RefCell
来拥有多个可变数据所有者,即 Rc<RefCell<T>>
。
1 | enum List { |
裸指针
裸指针为基本原生类型,分 *const T
和 *mut T
表示是否能修改指向数据。
- 没有生命周期,不会自动执行任何内存安全检查,不保证指向有效的内存。
- 不能实现任何自动清理功能(不能自动 drop)。注意
*ptr = data
会调用旧值的 drop。 - 允许为空,用
ptr::NonNull<T>
表示非空且协变的可变裸指针。 - 不支持隐式转换,需要用
as
关键字转换。 - 只能在 unsafe 上下文中解引用。
裸指针的获取方式:直接获取、Box::into_raw()
、std::ptr::addr_of!()
、从 C 中传递。
1 | let my_num: i32 = 10; |
ptr 模块常用的安全抽象方法:
1 | // 会按字节对齐的 T 的大小计算偏移量,内部没有做边界检查 |
FFI
FFI(Foreign Function Interface)是一种编程技术,允许在一个语言中调用另一个语言便携的函数。Rust 的 FFI 特点是安全和高效。Rust 提供了丰富的类型和抽象来安全地、无额外开销地处理 C 语言的数据和函数。
ABI(Application Binary Interface)定义了函数调用细节(包括参数传递、返回值和寄存器等),调用双方要同时遵守。Rust 中的 FFI 安全性主要依赖于正确的 ABI 和正确的类型转换:函数签名(参数类型、参数顺序和返回类型)需要完全一致;数据布局要保持一致,Rust 结构体用 #[repr(C)]
表示 C 布局;交互时对象的生命周期需要正确地手动管理;Rust 的 Panic 和 C++ 的异常是互相不兼容的,在 ABI 边界要处理好所有异常。
用 extern <abi>
声明 FFI 时的 ABI,常见的包括:
Rust
:Rust 自身的函数,可以认为fn f()
带有extern "Rust"
。C
:与 C 交互的标准 ABI,extern fn f()
时的默认值。stdcall
:部分与 Win32 强相关的与 C 交互的 ABI。system
:一般等价于C
,在不同系统里自动转化(如 Win32 系统里相当于stdcall
)。
类型对照表:
C 类型 | Rust 类型 |
---|---|
void |
() |
void * |
*mut c_void |
int8_t, uint8_t, int16_t, ..., uint64_t |
i8, u8, i16, ..., u64 |
float, double |
f32, f64 |
char, unsigned char, signed char |
c_char, c_uchar, c_schar |
(unsigned) short/int/long/long long |
c_(u)short/int/long/longlong |
size_t, ptrdiff_t |
usize, isize |
char *, const char * |
*mut c_char, *const c_char |
struct, union, enum |
struct, union, enum/i32 |
Rust 里的 String
和 &str
表示一定有效的 UTF-8 字符串数据。
ffi::CString
用于表示传递给 C 的字符串,末尾带有\0
,对应的切片是CStr
。ffi::OsString
用于表示平台原生字符串类型,对应的切片是OsStr
:Windows 系统中是非零的 16 位值的任意序列,一般为 UTF-16;Unix 系统中由非零字节组成的 8 位值的任意序列,一般为 UTF-8。
智能指针、宽指针和函数在 Rust 和 C 转换时需要和 *mut c_void
交互,可用 Box 中转。
1 | pub extern "C" fn create_my_struct_for_c(value: i32) -> *mut c_void { |
MaybeUninit<T>
是 Rust 标准库提供的结构,表示可能没有被初始化的内存,适合和 C 交互。
1 | let mut x = MaybeUninit::<i32>::uninit(); |
ManuallyDrop<T>
是 Rust 标准库提供的包装类型,用于阻止其包装的类型 T 的析构函数的自动调用。
GlobalAlloc
是 Rust 中用于自定义内存分配器的 trait,当前仅能全局替换所有类型的分配方式。
内存布局
如果一个结构体实现了 Sized,就在编译时有了确定的大小和对齐量,可以使用 std::mem::size_of()
和 std::mem::align_of()
计算对应的类型大小和对齐量。用 repr
更换对齐方式。
- 结构体和元组 布局规则一致:对齐量等于所有成员中对齐量的最大值,成员之间可能会留空隙。
- 枚举 的 Tag 固定占一个字节,占用空间是每个成员的布局最大值加上一字节(没有优化的情况下)。
- Option 类型有特殊的优化,可以用零值表示 None,从而压缩掉 Tag 的字节。
repr 规则 | 含义 |
---|---|
default | Rust 默认排布风格,不保证成员的排列顺序和稳定性。 |
C |
C 风格排布,无优化。成员有序,会根据对齐量填空隙,枚举类型是枚举值和 union 两段 |
packed(n) |
在基本对齐规则上,将结构体的对齐量减少到 n ,成员之间的间隙按新对齐量填充。 |
align(n) |
在基本对齐规则上,保证结构体的对齐量至少是 n (n 是 2 的幂次)。 |
<int_type> |
声明枚举类型的 tag 的存储类型 |
transparent |
用于单成员的结构体/枚举,表明内外有相同的内存布局。 |
Union 类型通常只用于和 C 交互,所有字段共享同一段存储,对某个字段的 mut 引用等价于对整个结构体。
Union 类型不能实现 Drop Drait,其内部的成员类型也不能,即其成员类型必须满足以下之一:实现了 Copy Trait 的类型,&T
或者 &mut T
类型,ManuallyDrop<T>
类型,以上类型组成的元组或数组类型。
对 Union 类型的成员变量 读操作 必须 unsafe,包括常见的模式匹配来读取。
宏
宏(macro)分为 声明宏(declarative macro)和三种 过程宏(procedural macro)。
声明宏用于编写一些类 match 的表达式来生成代码,类似于 C 中的 define
。以下是 vec!
简化后的声明宏。#[macro_export]
标注说明:只要将定义了宏的 crate 引入作用域,宏就应当是可用的。
1 |
|
过程宏(procedural macros)更像是函数,接收 Rust 代码作为输入,操作后产生另一些代码作为输出。过程宏分为自定义派生(derive)、类属性和类函数三种。
举个例子,我们希望实现一个自定义 derive 宏,使得只要在结构体定义前加上 derive(HelloMacro)
的标记,就能为结构体自动定义一个能够打印结构体名字的 trait 方法。
- 当用户在一个结构体前指定了
derive(HelloMacro)
标记后,hello_macro_derive
会被调用。 syn::parse
负责解析把 rust 代码解析成可操作的数据结构。若失败,会直接 panic。impl_hello_macro()
读入可操作的数据结构,转化成新代码的 TokenStream。quote!
宏可以让我们编写需要返回的 Rust 代码。最后的into()
方法会将其从原来的中间表示(IR)转化成TokenStream
。
1 | extern crate proc_macro; |
类属性宏不仅可以为属性生成代码,还可以创建新的属性。类函数宏能比声明宏更灵活地操控函数。
serde 和 serde_json
serde 用于将 Rust 结构体序列化和反序列化,serde_json 则专注于和 Json 的交互。
作用在结构体某个字段上的常见标记:
1 | // 将序列化和反序列化的名字重命名成 name |
作用在结构体或 enum 外层的常见标记:
1 | // 修改整个结构体/enum 的名字 |
对于 enum,默认使用 externally tagged 进行解析,也就是把每种类型名作为 map 的 key 来解析。
1 | // internally tagged,类型名放在名为 type 的 key 下。 |
有一个关于 &str
进行反序列化的坑。由于转化成 Rust 的 &str
后长度缩小了,str
的表示失败会 unwrap。解决方法是全程使用 String
。如果真的想优化内存,可以使用 Cow<&str>
+ #[serde(borrow)]
。
1 |
|
用 serde_json::to_string
对 struct 序列化时结果是唯一的,字段之间会按定义顺序排序。但是如果字段里有无序结构(如 HashMap),则不能保证序列化结果的唯一性。
1 | let v = json!({ |
测试时,常常需要比较 Value
和某 rust 结构(String 或 Hashmap 等)。可以使用 json!()
包裹后者。
Cargo Insta
Insta 库是 Rust 测试里比较好用的库,能够为结果生成类似快照的内容,系统性地管理测试的输出文件。当第一次测试或结果有更新时会生成 .snap.new
为后缀的文件,归档后会生成 .snap
为后缀的文件。
1 | assert_snapshot!() |
使用 cargo install cargo-insta
安装 insta 的命令行工具,便于管理 snapshots 文件。
1 | cargo-insta review # review all pending snapshots |
Insta glob!
宏支持一次管理多个文件,用 globset 通配。生成的 snapshots 文件名字包含test_file_path
,test_function
和 input_path
三部分,其中 input_path
会取所有读取的文件的 lcp 的后面部分。
1 | use std::fs; |
附录:常见 trait
常见 trait | 效果 |
---|---|
Add/Sub/… | 通过实现对 std::ops 里的运算符 trait,可以实现运算符的重载。 |
Clone | 标记结构体是否能被整个复制。 |
Copy | Clone 超集。标记性质,表示类型可以按位复制,和 Drop trait 不能一起实现。已实现的类型包括:整型,布尔型,浮点型,字符型,成员已实现的元组或数组,不可变引用。 |
Display | 定义当前类型如何支持 Formatter 来打印。实现了 Display 会自动实现 ToString。 |
Drop | 定义方法 drop() ,在对象离开作用域时自动调用。析构是有顺序的。 |
Deref | 重载解引用运算符,使得调用 *mb 等价于调用 *(mb.deref()) 。 |
Into<T> |
定义当前类型如何转化成类型 T。 |
From<T> |
定义当前类型如何从类型 T 转化而来。实现了 From 会自动实现 Into。 |
TryInto<T> |
定义当前类型如何转化成类型 T,返回结果是 Result。 |
TryFrom<T> |
定义当前类型如何从类型 T 转化而来,返回结果是 Result。 |
FromStr<T> |
定义如何从 str 类型转化成当前类型,实现后通过 .parse() 触发。 |
Eq | PartialEq 超集,比较同类型的实例,要求满足自反性、对称性和传递性。 |
Ord | PartialOrd 和 Eq 超集,比较同类型的实例,要求比较顺序确定(浮点数未实现 Ord) |
PartialEq | 比较类型 A 的实例 a 是否和类型 B 的实例 b 是否相同。 |
PartialOrd | PartialEq 超集,比较类型 A 的实例 a 和类型 B 的实例 b 的大小关系。 |
Send | 标记该类型能安全地发送至另一个线程 |
Sized | 表示实例占用的空间大小在编译时确定。函数参数要求必须是 Sized。无长度的参数数组、不含 Sized 约束的 dyn trait 和含有 ?Sized 约束的结构体泛型参数被认为是 Unsized 的。 |
Sync | 标记该类型能够安全地在线程之间共享 |
ToString | 定义当前类型如何转化成 String 类型,实现后通过 .to_string() 触发。 |
Unpin | 表示类型能够安全地在内存中移动 |
UnwindSafe | 标记发生 panic 时的类型是否仍然安全。 |
Sized
动态大小类型(dynamically sized types,DST)允许我们处理只有在运行时才知道大小的类型。Rust 不允许直接创建 DST 的变量,也不能直接获取 DST 的参数。str
就是一个典型的 DST,我们常用引用的形式使用它(记录地址和长度,所以 &str
的大小是 usize
大小的两倍),或者用 Box<str>
或 Rc<str>
等指针使用它。
为了处理 DST,Rust 有一个特定的 trait Sized
来确定一个类型的大小是否在编译时可知。而且 Rust 隐式地为每一个泛型函数增加了 Sized
bound。只有当 T
使用了 T: ?Sized
时才表明 T
的大小可能在编译时无法确定。
Drop
Rust 不允许自发调用 Drop::drop
方法,只能通过 std::mem::drop(x)
显式释放资源。析构顺序如下:
- 作用域里的变量按定义的逆序析构,函数参数同样按定义的逆序析构。
- 嵌套的作用域按深度优先的顺序(inside-out order)析构。
- 结构体、枚举的字段按代码中声明的顺序析构,元组和数组按元素的先后顺序析构。
- 注意闭包通过移动捕获的变量的析构顺序目前是未定义的。
Deref
如果没有实现 Deref trait,编译器只会解引用 & 引用类型。deref 方法提供了其他解引用场景的功能。
1 | pub trait Deref { |
Rust 支持以下三种解引用的强制转换(deref coercions),如 &String
能够强转为 &str
。
T: Deref<Target=U>
时从&T
到&U
。- 当
T: DerefMut<Target=U>
时从&mut T
到&mut U
。 - 当
T: Deref<Target=U>
时从&mut T
到&U
。
Pin 和 UnPin
参考资料:Pin Module,Pin-Rust Async Book
Unpin
是一种 trait,表示类型能够安全地在内存中移动。Rust 的类型默认会加上 Unpin
这个 trait,所以标准库中绝大部分类型都是 Unpin
的。异步编程里 async/await
产生的 Future
类是典型的 !Unpin
。
Pin<P>
是一种包裹类,意为 P
指向的地址 T
不会发生移动,常用来解决自引用的场景。注意只有当 T:!Unpin
时 Pin<P>
的功能才生效,即对 Unpin
的类型套上 Pin
后依然能正常将其移动。
Pin
的实现原理可从源码中窥得一二。简单来说,Pin
占有了 P
指向地址的可变引用,外界只能通过 unsafe
的方式改变/移动它。如果我们通过把 Pin<&mut T>.as_mut()
传入 std::mem::swap(a, b)
来尝试移动呢?注意到 Pin
不允许 !Unpin
结构进行 get_mut()
,而 as_mut()
的返回结果也被包裹成 Pin
,所以编译不通过。从源码中也能看出,若 T
是 Unpin
类型,Pin<'a, T>
完全等价于 &'a mut T
,能正常进行移动。
1 | impl<P> Pin<P> where P: Deref, <P as Deref>::Target: Unpin |
如果想标记一个类型是 !Unpin
的,目前 stable 版本中只能添加 std::marker::PhantomPinned
来达成。
Send 和 Sync
Send
和 Sync
用来刻画结构体在多线程模型下的线程安全性的 trait,是 rust 语言层面提供的标记。
Trait 名 | 定义 |
---|---|
Send |
该类型能安全地发送至另一个线程 |
Sync |
该类型能够安全地在线程之间共享 |
两者的关系是: T
是 Sync
的当且仅当 &T
是 Send
的。
- 几乎所有基本类型(裸指针)都是
Send
和Sync
的。 UnsafeCell
不是Sync
的,这也导致Cell
和RefCell
不是Sync
的。Rc
既不是Send
也不是Sync
的,它本身就被设计成单线程下的多引用,多线程下对应模型是Arc
。
这两个 trait 能自动推导:如果一个类型的所有成员都是 Send
或 Sync
的,那么它也是 Send
或 Sync
的。如果自行给类型标注 Send
或 sync
,需要在前面加上 unsafe
标记。