Rust
在 Rust 日趋流行的今日,我这个老顽固在部门同事的推动下,艰难地学习着 Rust。
本文主要总结自 Rust 圣经 和 官方教程 Rust Book 中文翻译。
基本语法
Rust 支持类型推理。用 let
指定不可变变量,let mut
指定可变变量,const
指定(全局)常量,static mut
指定(全局)变量/静态变量。静态变量的内存地址保持不变,只能通过 unsafe
语句进行访问和修改。
1 | let x = 5; |
Rust 支持变量遮蔽(shadow),遮蔽前后类型可以不同,作用域结束后遮蔽自动结束。
1 | let x = 1; |
函数参数和返回值(如果有)必须指定类型。函数定义的位置不作要求。如果没有返回值,默认返回 ()
。特殊地,如果返回值表示为 !
,表示这是一个永不返回的函数(diverge function),常用于输出 panic。
1 | fn five() -> i32{ |
if
判断的表达式必须返回布尔类型,表达式无须强制套小括号。if
表达式返回的结果必须同类型。
1 | let number = if condition { 5 } else { 6 }; |
用 loop
,while
和 for
来表示循环。支持 continue
和 break
。break
后可添加返回值来构建表达式。
1 | let result = loop { |
基本数据类型
Rust 是 强类型语言。基本类型(Scalar Type)如下:
- 整型:
i8, i16, i32, i64, i128, isize; u8, u16, u32, u64, u128, usize
。默认是i32
。整型溢出在 debug 模式下会报 panic 错误,release 模式下会用补码包裹。 - 浮点类型:
f32, f64
。默认是f64
。与Nan
交互都会变成NaN
,NaN
参与比较会 panic。 - 布尔类型:
bool
。大小为 1 字节。 - 字符类型:
char
。大小为 4 字节,Unicode 编码。用单引号表示。
序列(Range)用来生成连续的数值,默认前闭后开。如 1..5
,1..=5
。常用于 for...in...
。
元组(Tuple)可以将多个不同类型的值复合在一起,也可用 struct 来定义。用模式匹配或 .
和下标来索引。
单元类型(unit type)是一种特殊的元组,只有一个值 ()
,该值被称为单元值(unit value)。如果表达式或函数不返回任何其他值,就隐式地返回单元值。如果 map
不关心 value,也可以用 ()
占位,不占用任何内存。
1 | let tuple = (500, 6.4, 1); |
字符串(String)不定长,存储在堆里,本质上是 Vec<u8>
的封装。注意字面量会被保存在栈中。
字符串的元素用 UTF-8 编码。因为每个元素占用的字节数可能不同,字符串不支持下标索引以防止歧义发生。
- 用
.chars()
返回字符串的实际字符集合。用.chars().count()
统计实际字符个数,复杂度线性。 - 用
.bytes()
返回每一个原始字节信息。用.len()
返回实际字节数,复杂度 。
1 | let mut s1 = String::from("hello"); |
切片(Slice)可以部分或全部地引用字符串(str),前闭后开。若切片错误地切断了某个 UTF-8 的元素会报错。
1 | let s = String::from("hello"); |
数组(Array)只能组合同类型的元素,长度不可变。数组内容会被分配至栈空间中。数组也可以进行切片操作,且我们往往用引用的形式去获取切片,因为切片本身大小不确定,而引用的大小是确定的。
1 | let months = ["January", "February", "March"]; |
类型转换
From
和 Into
是 Rust 里两个相关联的 trait。From
语义是“怎么根据另一种类型生成自己”,Into
反之。如果实现了 From
,也就免费获得了 Into
。使用 into
时如果编译器不能推断,就要指明具体转换到的类型。
1 | struct Number {value: i32} |
TryFrom
和 TryInto
也是类似的类型转换 trait,通常用于易出错的转换,返回值是 Result
型。
若要把任何类型转换成 String
,需实现 ToString
trait。建议直接实现 fmt::Display
trait,还能用来打印。
如果目标类型实现了 FromStr
trait,就可以用 parse()
把字符串转化成该类型。
1 | impl fmt::Display for Number { |
结构体
结构体用 struct
定义,用 .
索引内容。结构体必须整个可变或整个不可变。
- 变量和字段同名时可以简化初始化。
- 把结构体里某个字段的所有权转移出去后,就无法访问该字段,但仍能正常访问其他字段。
- 用
..
来根据另一个实例创建新实例。注意是简单的浅拷贝,堆元素的拷贝会使原实例对应字段失效。 - 用
impl
来定义结构体的专属方法。如果作用于某个实例,第一个参数用&self
表示。调用结构体方法时,会根据方法的定义进行自动引用和解引用(automatic referencing and dereferencing)。
1 | struct User { |
在结构体前加上 #[derive(Debug)]
,可以用 {:?}
和 {:#?}
来格式化输出结构体的内容。
元组结构体是一种特殊的结构体,字段不需要命名,如 struct Color(i32, i32, i32)
。
枚举类型和匹配
枚举(Enum)可以为每个成员绑定一个具体的数据类型。可以像结构体一样用 impl
定义方法。
1 | enum Message { |
Match 匹配的模式执行相应代码。Match 必须穷尽所有情况,或者使用 Other/_
表示其他情况。
用 |
表示并列情况的等同处理,../..=
列举一个区间。如果是结构体的匹配,用 ..
表示忽略剩余字段。
匹配守卫(match guard)是位于某个分支后的额外 if
条件,只有当这个 if
也成立才会执行对应语句。
1 | let mut count = 0; |
If let 是 Match 的语法糖。else
相当于 Match 里的 _
,如果没有 else
也是允许的。
1 | let mut count = 0; |
错误处理
panic 指的是不可恢复的错误。panic 出现时 Rust 的默认行为是展开(unwinding),回溯栈并清理数据。可以在配置项里增加 panic = 'abort'
,这样在出现 panic 后直接终止(abort),由操作系统清理内存。
用 Result<T, E>
处理可恢复的错误,它是个枚举类型,Ok(T)
和 Err(E)
。
.unwrap()
可以解包Result
直接获得其返回值,若产生 Error 会转化成 panic。.expect()
与上述类似,只是会把 panic 错误信息替换成给出的字符串参数。
1 | use std::fs::File; |
调用函数的末尾加上 ?
后可以简单地起到错误传播的作用。
1 | fn read_username_from_file() -> Result<String, io::Error> { |
作用域和生命周期
Rust 中的每一个值都有一个对应的所有者(owner)的变量。当变量离开其作用域时,值会被丢弃。
- 对未实现 copy trait 的类型浅拷贝时会转移(move),前者不能再被使用;用
.clone()
做到深拷贝。 - 未实现 copy trait 的值在函数传参时如果不用
&
标记,会把所有者转移至函数内部,原变量不再能使用。 - 用
&
表示引用(borrow),不发生所有者的转移;用&mut
表示可变引用,可以改变值但是不发生所有者的转移。对于一个值,要么只能有一个可变引用,要么只能有多个不可变引用。 - Rust2018 引入非词法作用域生命周期(Non-Lexical Lifetimes,简称 NLL)特性。如果某个引用在以后的代码中不再被使用,它会在最后一句相关的语句执行之后提前被消除。即以下代码能正确运行。
实现 copy trait 的类型有:整型、布尔型、浮点型、字符型、元素都实现 copy trait 的元组、不可变引用。
1 | let mut s = String::from("hello"); |
Rust 可以标注引用的生命周期(lifetime),以 '
开头,通常是较短的小写字母,如 'a
。生命周期标注会告诉 Rust,多个引用的泛型生命周期参数如何相互联系。
函数参数如果带有引用,需要对其进行生命周期标注。泛型生命周期参数声明在函数名和参数列表间的尖括号中。下面的例子暗示了 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
的生命周期。
静态生命周期用 'static
标注,其生命周期能够存活于整个程序期间。字符串字面量默认是静态生命周期。
1 | let s: &'static str = "I have a static lifetime."; |
泛型和 trait
函数、结构体和结构体方法都支持泛型。编译时进行单态化(monomorphization),即泛型不影响效率。
1 | struct Point<T, U> { |
trait 类似于其他语言的接口(interfaces)。使用 trait bounds 指定泛型是任何拥有特定行为的类型。
定义 trait 时可以只有声明,或者带上默认的方法。注意,无法从相同方法的重载实现中调用默认方法。
1 | pub trait Summary { |
可以在某个函数里定义 trait。
- 用
:
指定参数要求实现的 trait,多个 trait 之间用+
连接,不同泛型之间用,
连接。 - 也可以用
impl
简化声明,不过默认对应参数是任意类型。 - 还可以用
where
语法把参数里的 trait bound 更清晰地提取出来。
1 | pub fn notify(item1: impl Summary, item2: impl Summary) |
可以使用 trait bound 为泛型有条件地实现方法。
1 | struct Pair<T> { |
泛型类型参数、trait bounds 和生命周期可以混合在一起使用,如:
1 | fn longest_with_an_announcement<'a, T>(x: &'a str, y: &'a str, ann: T) -> &'a str |
Derive 关键词可以作用于 struct 定义前,等价于在实际代码里增加对应的 trait。
1 |
|
例如, Debug
实现了 {:?}
后的格式化输出,PartialEq
等效于增加了以下代码:
1 | impl<T: PartialEq> PartialEq for Foo<T> { |
Vector 和 Map
vec<T>
是可变长数组,只能存放相同类型的元素。可以用 vec!
宏直接构造 vector,自动进行类型推理。
1 | let v: Vec<i32> = Vec::new(); |
可以用索引或者 .get()
的方式访问 vector 内的元素。前者在越界时会报 panic 错误,后者是 Optional<&T>
类型。注意,如果直接使用下标访问且 vector 中的元素没有实现 copy trait,所有权会 move 到使用者那里。
1 | let third: &i32 = &v[2]; |
获得某个下标的引用后禁止对 vector 进行 push,因为 push 要获取整个 vector 的可变引用。
可以用 for...in...
简化对 vector 的遍历。可以选择不可变引用遍历和可变引用遍历。
1 | let v = vec![100, 32, 57]; |
可以用枚举类型来变相让 vector 支持不同类型。vector 会为每个元素开 max(diff_types)
的空间。
1 | enum SpreadsheetCell { |
HashMap 用 use std::collections::HashMap
引用。键和值的类型必须各自相同。
.insert(key, value)
:插入一组元素,若存在则会覆盖。.entry(key).or_insert(value)
:如果 key 不存在,就插入指定 value。返回 value 的可变索引。
对于基本类型这种实现了 copy trait
的类型,其值会直接拷贝至 map,否则会转移所有权至 map。
1 | let mut scores = HashMap::new(); |
闭包和迭代器
定义闭包时,||
里定义参数列表,后接 {}
定义函数体。函数参数和返回值的类型可以省略,按第一次使用时的情况自动推理。闭包和函数最大的不同时它可以自动捕捉作用域里的变量。
1 | let add_one_v2 = |x: u32| -> u32 { x + 1 }; |
可以用结构体去承接一个闭包。
1 | struct Cacher<T> where T: Fn(u32) -> u32 |
闭包可以通过三种方式捕获其环境,对应函数的三种获取参数的方法:获得所有权、可变借用和不可变借用。
方式 | 原理 |
---|---|
FnOnce(self) |
闭包结束后捕获的变量会被释放。只能调用一次。所有闭包都默认实现该 trait。 |
FnMut(&mut self) |
获得作用域变量的可变引用。可以被调用多次。 |
Fn(&self) |
获得作用域变量的不可变引用。可以被调用多次。 |
在闭包参数前加 move
关键词后,可以强制闭包获取作用域里变量的所有权。
迭代器都实现了一个叫做 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]; |
智能指针
最简单的智能指针是 Box<T>
,实际数据会被转移到堆上存储,栈上仅保留指针。
Rust 不允许直接递归定义结构体,因为这样就无法计算所需空间,但我们可以利用 Box 间接做到这一点。
1 | enum List { |
智能指针通常需要实现 Deref trait 和 Drop trait 来做到引用和清理。
Deref Trait 用来重载解引用运算符(dereference operator)。调用 *mb
后等价于调用 *(mb.deref())
。
1 | impl<T> Deref for MyBox<T> { |
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
。
Drop Trait 用于离开作用域时自动清理空间。注意不能在作用域结束前显式调用 c.drop()
提早清理,会收到编译错误。不过可以用 std::mem::drop(c)
来做到提早清理。
1 | impl Drop for CustomSmartPointer { |
引用计数指针 Rc<T>
支持多所有权机制。用 Rc::clone
克隆时计数器会加一,离开作用域计数器就减一,当计数值为 0 时数据会被清理。 注意 Rc<T>
仅支持多个部分之间只读地共享数据,否则会违反所有权规则。
Rc<T>
可能会引起引用循环导致内存泄漏,因为只有当 strong_count=0
才会清理。可以调用 Rc::downgrade
创建 Weak<T>
。弱引用也会进行计数,区别在于 weak_count
无须计数为 0 就能使 Rc<T>
实例被清理。
内部可变性(interior mutability)是 Rust 的一个设计模式,需要用 unsafe
标记,以模糊可变性和借用规则。
RefCell<T>
就是遵从内部可变性的指针。它是唯一所有权模式,但是能修改其指向的原本不可变的数据。它有 borrow
和 borrow_mul
两个方法,分别创建一个不可变借用和可变借用。任何时候仅能有多个不可变借用或者一个可变借用,只是这个规则从编译时检查变成了运行时检查(违反会 panic)。
结合 Rc
和 RefCell
来拥有多个可变数据所有者,即 Rc<RefCell<T>>
。
1 | enum List { |
unsafe
unsafe
有以下特殊的“超能力”,但是依然受到 Rust 编译器对于借用规则的制约。
解引用裸指针。除了引用和智能指针外,Rust 还支持裸指针。裸指针可以绕开借用规则同时拥有一个数据的多个可变/不可变指针。裸指针和 C
语言的指针很像,可以是 null
,不保证指向的内存合法,没有实现自动 drop。
1 | let a: Box<i32> = Box::new(10); |
调用 unsafe
函数或者 FFI 的函数。FFI
(Foreign Function Interface)可以用来与其它语言进行交互。
1 | extern "C" { |
访问或修改一个可变的静态变量。实现一个 unsafe
的 trait。访问 union
中的字段(常用于与 C 交互)。
宏
宏(macro)分为 声明宏(Declarative Macro)和三种 过程宏(Procedural Macro)。
声明宏用于编写一些类 match
的表达式来生成代码。以下是 vec!
简化后的声明宏。
#[macro_export]
标注说明,只要将定义了宏的 crate 引入作用域,宏就应当是可用的。macro_rules!
和宏名称完成对宏的定义。
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; |
类属性宏不仅可以为属性生成代码,还可以创建新的属性。类函数宏能比声明宏更灵活地操控函数。
包管理
crate 是一个二进制项(binary crate)或者库(library crate)。
包(package)是提供一系列功能的一个或多个 crate。Cargo.toml
描述了包的具体信息,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; |