Rust 基础
Basics
Rust 与其他语言区别较大的点
- 对于表达式与语句的严格区别
- 引用与借用
- 模式匹配
- 强大的 trait 与泛型系统
- 强大的宏
-
let
声明的是不可变变量,let mut
声明的是可变变量。同时 Rust 中还有常量,必须用const
声明且指定类型。从编译原理的知识中我们知道,常量必须在编译期被估值,但是不可变变量不用 -
与 Java,Go 等不同,使用
let
声明的变量不会被赋予零值,必须手动对其进行初始化才能使用(亦即未被使用的变量可以不用初始化) -
let a: i32 = 3;
,这其中的类型声明不是必须的 -
println!()
其实不是一个函数,而是一个宏(名字后面是!
) -
在格式化输出语句中,
{}
可以当做任何类型的占位符,它输出的是变量的std::fmt::Display
这个 trait;{:?}
输出的是变量的std::fmt::Debug
这个 trait
可以简单地认为 Rust 中的 trait 就是其他语言中的 Interface
-
加了
;
就是语句,不加就是表达式。结合编译原理的知识可知,表达式可以看做有返回值。这在 Rust 的函数中尤为重要,函数体内最后一条表达式的结果将会被当成函数的返回值,因此无需显式的return
-
Rust 中存在元组类型,元组中的元素可以不是一个类型的。可以通过将函数的返回值设置成一个元组,来实现 Go 中类似的函数多返回值特性
-
与 Java/C++/C 中的
for
不同,Rust 中的 for 语句的格式为for <element> in <iterator>
-
Rust 中的 mod 与 use,十分类似 Java 中的 package 与 import
-
size_of::<String>
在一台 64 位的机器上运行的结果为何是 24?首先一个 String 对象的字符是存储在堆中的,这部分的大小是无法获知的。使用 ize_of 得到的其实只是这个对象在栈上管理的元数据的大小:一个指向堆的指针,length,capacity。这三个字段的长度都是
usize
,在 64 位机器上,usize
就是u64
,占据 8 个字节,因此这三个栈上的字段总共占据 24 字节 -
Rust 中的宏比 C/C++ 中的宏更强大,它的本质上是代码生成器,类似于 Java 中的 Lombok 插件(如 Rust 中的
#[derive(PartialEq)]
)。C/C++ 中的宏完全基于文本层面的替换,而 Rust 中的宏是带有语义特征的,直接在 AST 层面上进行替换
所有权与借用
-
基本类型可以不考虑所有权与借用,因为变量之间的赋值其实执行了拷贝在栈上赋值
-
可以认为只有堆上的值(也许称为对象更能与已有的认知联系在一起。常见的堆上的值有 String,Vec 等)才要考虑所有权与借用的关系。当我们把一个值赋给一个变量时,此时这个变量就称为这个值的拥有者,一个值的拥有者是唯一的,这种拥有关系可以进行转移,转移过后,原有的变量不再有效;在离开一个作用域时,Rust 会自动调用 drop 函数,它将清理所有的有效的所有者所引用的堆内存。下面是一段示例代码,它可能与常见的面向对象语言(如 Java)的行为不一致
1
2
3
4
5fn main() {
let x: String = String::from("Hello");
let y = x;
println!("{}", x)
}这段代码将不能通过编译,原因是第 3 行代码使得值的所有权被转移到 y,x 不再有效;
编译器告诉我们,move occurs because
x
has typeString
, which does not implement theCopy
trait。意思是说,如果 x 变量绑定的值具有 Copy 这个 trait,那么拷贝是发生在栈上的,执行第 3 行代码后,x 与 y 均有效。哪些类型的值是 Copy 的呢?任何基本类型的组合可以Copy
,不需要分配内存或某种形式资源的类型是可以Copy
的另外,编译器提示我们可以使用
clone()
方法,以下是修改后的代码,这个代码可以顺利通过编译:1
2
3
4
5fn main() {
let x: String = String::from("Hello");
let y = x.clone();
println!("{}", x)
}这很像其他编程语言中浅拷贝和深拷贝的关系。在 Rust 中,除非显式地使用
clone()
,否则变量间的赋值永远都是浅拷贝(即发生所有权的转移)除了上述的变量间的赋值语句,函数调用也会转移值的所有权:
1
2
3
4
5
6
7
8fn main() {
let x: String = String::from("Hello");
take_ownership(x);
println!("{}", x)
}
fn take_ownership(s: String) {
println!("{}", s)
}执行第 3 行代码调用函数后,x 所绑定的值的所有权被转移到函数的形参 s 中,第 7 行代码执行结束后,由于 s 离开其作用域,将导致其绑定的值被回收。值得注意的是,尽管第 7 行代码可以顺利执行到,但是运行这段代码并不能看到这个输出。因为上述关于所有权与借用的讨论,编译器在编译期进行检查。上述代码未能通过编译,因此不会执行。
使用
for in
迭代集合对象时也会发生所有权的转移,因此要尽量使用引用此外,函数的返回值也可以转移其所有权到函数的调用者中,可以将它形象地理解成被外部接收的返回值幸运地逃脱了函数调用结束后的回收
-
如果将一个变量的引用赋给另一个变量,此时值的所有权发生了借用。借用只允许获得值的使用权,但不能获得值的所有权。因为并不拥有这个值,当引用离开作用域后,其指向的值也不会被丢弃
-
通过引用修改原始的值?
1
2
3
4
5
6
7
8
9
10fn main() {
let mut s = String::from("Hello");
let s1: &mut String = &mut s;
foo(s1);
println!("{}", s)
}
fn foo(s: &mut String) {
s.push_str(" World");
}正如可变值的声明一样,可变引用必须显示使用
mut
关键字进行声明只能通过可变引用修改借用的值,并且这个值必须也是可变的!并且对于一个可变值的可变引用,同一个作用域下至多存在一个!
在一个不可变引用的作用域内,可以存在其他的不可变引用,但不能存在对于同一个值的可变引用!
这里又要格外区分引用的作用域与值的作用域!(然而在 < 1.31 的 Rust 编译器中,这两者是不加区分的,带来了一些不便之处)
- 值的作用域总是结束在某个花括号
- 引用的作用域,结束在它最后一次被使用的地方。例如上面代码的引用
s1
,它的作用域结束在第 4 行,而不是某个花括号
这个优化也被称为 NLL(Non-Lexical Lifetimes),这个名词可以结合编译原理的知识理解~
Rust 的编译器检查可以保证不可能出现 C/C++ 中的悬垂指针(尝试访问一块已经被释放的区域)
-
一些函数调用也会转移值的所有权,原因是这个函数虽然是通过
.
操作符调用,但是隐含了一个self
参数,这个参数就是这个对象的引用(联想 Python 类的方法定义中的self
?),而且这个引用往往是可变引用,可能会十分隐晦地违反上述引用的冲突规则,例如:1
2
3
4
5
6
7
8
9fn main() {
let mut s = String::from("Hello");
let first = first_char(&s); // immutable borrow here
s.clear(); // mutable borrow here
println!("{}", first) // immutable borrow here
}
fn first_char(s: &String) -> &str {
&s[..1]
}在第 4 行,由于 first 仍旧活跃(因为第 5 行将被使用),并且,这个切片的底层与 s 的底层是一致的,因此可以看做是一个活跃的对于 s 的不可变引用。但是在调用 clear 方法时,实际上是对这个方法提供了一个 s 的可变引用。这就违反了上述的引用冲突的规则。尽管
first
(即&str
切片)只是指向s
的部分数据,它仍然是基于s
的不可变引用。在s
的部分(通过first
)仍然被不可变引用时,不能对s
进行任何可变操作,如clear()
-
Rust 采取的内存回收机制更像是 C++ 中的 RAII 思想
回忆:C++ 中的 RAII 思想:
RAII = Resource Acquisition Is Initialization
我们知道,C++ 中,一个作用域结束后,会自动清理在这个作用域中声明的全部局部变量。对于在这个函数中声明的局部对象(是的,C++ 中对象可以创建在栈上,此即局部对象),它会自动调用该对象的析构函数,释放其占有的资源。于是使用局部对象来实现 RAII 是简单的。但是,将对象全部创建成局部对象也许不太合适。如果我们想对创建在堆上的对象也使用 RAII 的思想进行管理呢?在 C++ 11 以后,提供了智能指针的概念。智能指针本身是创建在栈上的,它所指的对象创建在堆上。但是,当智能指针离开其作用域后,其析构函数被调用时,不仅会释放自己所占的内存,也会自动调用它所指的堆上对象的析构函数,从而实现 RAII
类型系统
结构体定义了一个新的类型,一个枚举也是一个新的类型,带有不同泛型参数的结构体也是不同的类型
切片
-
Rust 中的切片语法与 Go 中的相似,只不过使用
..
连接 -
对字符串使用切片语法时需要注意,切片索引的是字节,如果存在某些非英文字符,此时需要考虑每个 Unicode 字符占据多少个字节
-
字符串切片的类型是
&str
(可以看做是对底层 String 的引用);i32 数组的切片类型是&[i32]
。可以看出这些引用都是不可变引用 -
应该将切片看作是底层数组(或 String)的引用
-
一个比较 tricky 的点是,我们可以使用
&mut[u32]
去修改这个切片的底层数组,但是并不适合用&mut str
去修改底层字符串的内容(虽然使用 unsafe 依旧可以办到)
字符串
- 字符串字面量的类型是字符串切片(
&str
),它是在栈上分配内存的,因为字面量的大小肯定在编译时是可知的 - 可以将
String
理解为一个容器类,它是在堆上分配内存的!
数组
-
与 Go 中类似,Rust 中数组的长度也是其类型的一部分
1
2// Array type syntax: [ <type> ; <number of elements> ]
let numbers: [u32; 3] = [1, 2, 3]; -
因为数组的大小是编译时可知的,因此其被分配在栈上
Vec
1 |
|
Vec 在栈区域中存储:指向堆区域的指针,len,cap
迭代器
1 |
|
使用迭代器进行遍历时,编译器可以确认不会引起越界访问,因此不会进行越界检查。因此,使用迭代器进行遍历一般要比使用下标进行遍历更快
枚举
相比 Java 中的枚举类型更为强大,每种变体(variants)可以关联不同的数据类型。枚举往往与模式匹配 match
一起使用
1 |
|
Option
Option 是一个内置的枚举类:
1 |
|
它其实是类型 T 的一个包装类,Some(T)
是一个元组结构体(即结构体中的字段不具名,只能通过索引进行访问)。这里的 Some
和 None
只是一个名字。Option 常用来封装那些可能为 null 的值,以强制程序员对空字段进行处理,比如 map get 的返回值类型就是一个 Option。如何处理 Option
有四种方式
-
使用
unwrap()
方法unwrap()
方法会从Some(T)
中取出T
,但如果值是None
,它会引发 panic。这种方法在你确定Option
中一定有值时使用是安全的,但如果可能存在None
的情况,使用这个方法会有风险。1
2
3
4
5
6
7
8
9
10let x = Some(5);
let y = x.unwrap(); // y 的类型是 i32,值为 5
- 使用 `expect()` 方法
`expect()` 方法与 `unwrap()` 类似,但它允许你指定一个错误消息。如果 `Option` 是 `None`,它会引发 panic 并显示你提供的错误消息,这在调试时非常有用。
```rust
let x = Some(5);
let y = x.expect("Failed to unwrap the value"); // y 的类型是 i32,值为 5 -
使用匹配模式(Pattern Matching)
你可以使用
match
语句对Option<T>
进行匹配处理。这是一种更安全和灵活的处理方式,因为你可以显式地处理None
的情况。1
2
3
4
5let x = Some(5);
let y = match x {
Some(value) => value, // 当 x 是 Some(T) 时,返回 T
None => panic!("Found None"), // 当 x 是 None 时,引发 panic
}; -
使用
if let
构造如果你只关心
Some(T)
的情况,并想忽略None
,if let
是一个简洁的选择。1
2
3
4
5
6let x = Some(5);
let y = if let Some(value) = x {
value
} else {
panic!("Found None")
};
Result
Result 是一个内置的枚举类:
1 |
|
它用于实现可恢复的错误处理,即让调用者显式地知道可能抛出怎样的错误,且强制其处理,如标准库中的 parse_int 方法:
1 |
|
集合类型
集合类型包含:Vector,HashMap
放入集合类型中的元素,这个集合类型拥有它的所有权;如果放入集合中的元素是一个引用,那么需要确保这个引用的生命周期至少跟这个集合的生命周期一样久
-
HashMap 的 get 方法,参数和返回值均是引用类型。使用
for in
语法进行迭代时,迭代的键值也都是引用 -
考虑我们要使用一个哈希表实现统计单词词频的任务,这包含创建不存在的单词的键值对、更新已经存在的单词的词频,在 Java 中,可以使用以下方式
1
2
3
4
5
6var map = new HashMap<String, Integer>();
for (String word: text) {
map.put(word, map.getOrDefault(word, 0)+1);
// optional
map.merge(word, 1, Integer::sum);
}在 Rust 中实现类似的功能
1
2
3
4
5
6
7
8
9
10use std::collections::HashMap;
fn main() {
let text = String::from("text text text");
let mut map = HashMap::new();
for word in text.split_whitespace() {
let cnt = map.entry(word).or_insert(0); // 返回的cnt时一个可变的引用
*cnt += 1 // 通过这个可变的引用实现value++
}
}
控制流程
- if 块是一个表达式,可以有返回值,这在每个分支的末尾使用一个不带分号的表达式完成(不能使用 return,因为这会结束整个函数的调用)
- loop 等于 while true,loop 块也是一个表达式
模式匹配
-
使用 match 进行模式匹配的一般形式是:
1
2
3
4
5
6
7
8
9match target {
模式1 => 表达式1,
模式2 => {
语句1;
语句2;
表达式2
},
_ => 表达式3
}- 其中可以使用 | 枚举多种模式
- match 是一个表达式,具有返回值
- 必须穷尽所有的分支,使用
_
代表默认分支
类与对象
Rust 中类(结构体)的字段定义与类的方法定义是分离的:
1 |
|
可以将第一个参数设置为 self: &Self
(更常见的情况是简写为 &self
,注意区分大小写。这里第二个 Self 值得注意一下,它将绑定调用这个方法的实例的类型)来使用调用这个方法的实例。此时由于是一个不可变引用,则不能通过在这个引用去修改对象。可变引用是 &mut self
。实际上第一个参数也可以设置为 self
,不过这将导致对象所有权的转移,因此并不常用;
在 impl 中定义的所有函数,全部称为关联函数。只要含有 self
参数,它还是一个方法,可以使用 .
操作符进行调用。除方法以外的关联函数,常见于对象的构造函数,比如使用一个名为 new 的函数定义类的构造器(Rust 中 new 不是一个关键字,只是习惯上用 new 命名构造函数)。对于这样的关联函数,使用 ::
调用,即 Foo::new()
或许可以简单的将上面的两种函数的区别理解成 Java 中的静态方法(从属于类)和非静态方法(从属于类的实例)的区别
值得指出的是还有一种简单的结构体叫「元组结构体」,它实际上是为一类元组起了一个名称:
1 |
|
如果想为一个 struct 实现一种 triat,使用 impl ... for ...
1 |
|
trait 与泛型
-
trait 的使用也要遵循 mod 的作用域规则
-
由标准库定义的 traits
-
Operator traits (e.g.
Add
,Sub
,PartialEq
, etc.)(C++ 中用操作符重载实现类似的功能。一种简单的理解方式是将内置操作符看成是一种特殊的方法名,x <op> y
其实是x.op(y)
) -
From
andInto
, for infallible conversions(无损的类型转换) -
Clone
andCopy
, for copying values -
Deref
and deref coercion这其实是一个很方便编程的 trait,具体来说可能有两种作用
- 将对
Vec<T>
的访问转换为对切片&[T]
的访问,因为切片类型可以调用迭代器提供的一系列方法 - 将对智能指针的访问转换为对其包裹类型的访问
这些行为全都是编译器自动实现的
1
2
3
4
5
6
7mod std::ops {
pub trait Deref {
type Target: ?Sized;
fn deref(&self) -> &Self::Target;
}
} - 将对
-
Sized
, to mark types with a known size -
Drop
, for custom cleanup logic
-
-
在 Rust 中,可以为任何一个内置类,通过实现一个自定义的 trait 以附加新方法。在 Java 中,要为内置类添加新方法并不是一件自然的事
-
trait 的定义,实现该 trait 的 type 的定义,与具体对于 trait 的实现,这三者的作用域要符合孤儿规则中的至少一点
1
2
3
4
5trait t_name {} // trait 的定义
struct s_name {} // type 的定义
impl t_name for s_name {} // 具体的实现-
本地类型:trait 或 type 至少有一个是在当前 crate 中定义的。这意味着你可以在你的 crate 中为任何类型实现你自己定义的 trait,无论这个类型是在你的 crate 中定义的,还是在其他 crate 中定义的
-
外部类型和外部 trait:如果你想为一个外部类型(例如来自标准库或第三方库的类型)实现一个外部 trait(同样可能来自标准库或第三方库),你不能这么做,除非这种实现是在类型或 trait 的原始定义 crate 中进行的。
例如下面的代码将无法通过编译:
1
2
3
4
5impl PartialEq for u32 {
fn eq(&self, _other: &Self) -> bool {
todo!()
}
}因为 trait
PartialEq
和 typeu32
均是在std
中定义的
-
-
在 Rust 中,trait 还用于支持泛型编程
泛型编程的本质就是将类型当成函数的额外参数(写在尖括号中)
一个泛型函数的定义形如
1
2
3
4
5
6fn sum_of_2<T>(val1: T, val2: T) -> T
where
T: Add // trait bounds
{
val1 + val2
}上面的代码等价于
1
2
3fn sum_of_2<T: Add>(val1: T, val2: T) -> T {
val1 + val2
}结构体和枚举的定义也能使用泛型参数
-
Marker Trait/ Auto Trait。这类 trait 一般是标准库内置的,且 trait 的体是空的,意味着声明该类型具有某种性质,以便编译器进行优化;如
Sized
,它表明这个类型的实例的大小在编译期是可知的;再如Copy
,它的声明如下:1
pub trait Copy: Clone { }
即使不需要为
Copy
本身实现方法,但是得保证为Clone
实现了方法 -
trait 也具有继承关系
1
2
3
4
5
6
7pub trait From<T>: Sized {
fn from(value: T) -> Self;
}
pub trait Into<T>: Sized {
fn into(self) -> T;
}其中
From
与Into
均是Sized
这个 trait 的 subtrait,意思是实现 subtrait 的类型必须同时实现 supertrait;值得注意的是
From
和Into
这两个 trait 是互补的,一般只用实现From
就够了 -
一个符合 Rust 风格的泛型示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52trait Power<M> {
fn power(&self, arg: M) -> Self; // Self绑定至for后面指定的类型,如下面的u32
}
impl Power<u16> for u32 {
fn power(&self, arg: u16) -> Self {
let mut res = 1;
for _ in 0..arg {
res *= *self;
}
res
}
}
impl Power<u32> for u32 {
fn power(&self, arg: u32) -> Self {
let mut res = 1;
for _ in 0..arg {
res *= *self;
}
res
}
}
impl Power<&u32> for u32 {
fn power(&self, arg: &u32) -> Self {
let mut res = 1;
for _ in 0..*arg {
res *= *self;
}
res
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_power_u16() {
let x: u32 = 2_u32.power(3u16);
assert_eq!(x, 8);
}
#[test]
fn test_power_u32() {
let x: u32 = 2_u32.power(3u32);
assert_eq!(x, 8);
}
#[test]
fn test_power_ref_u32() {
let x: u32 = 2_u32.power(&3u32);
assert_eq!(x, 8);
}
}
并发
-
利用
std::thread::spawn
开启一个新线程,它的参数是一个闭包(但是这个闭包几乎不用指定参数,如果要捕获外部参数,要使用move
关键字),返回值是一个句柄1
2
3
4
5pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T,
F: Send + 'static,
T: Send + 'static -
注意在 main 函数中开启的其他线程,一旦 main 函数执行结束(这个函数也叫主线程),它开启的所有线程也将立即结束;
其余的线程与其开启的线程之间没有这样的约束关系
-
use std::thread; fn main() { let handle = thread::spawn(|| { println!("Hello from a thread!"); }); handle.join().unwrap(); // the main thread will wait for the spawned thread to finish before exiting // also get the result from the newly-spawned thread } <!--code30-->
-
多线程编程中要格外小心生命周期带来的约束,因为 spawn 出的线程的生命周期很可能超出原来的线程的生命周期。设父线程未 A,spawn 出的线程为 B,B 中不应该借用(引用)那些 A 中可能被提前 drop 的值。我们结合上面的第 9 行 move 关键字来理解。move 关键字将外部的 v1 的所有权转移到新线程中来。
如果确实需要在线程中使用外部的引用,一定要保证它是
'static
的,如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15pub fn sum(slice: &'static [i32]) -> i32 {
if slice.len() <= 1 {
return slice.iter().sum();
}
let (s1, s2) = slice.split_at(slice.len() / 2); // s1与s2均是 'static 引用
let h1 = thread::spawn(move || {
s1.iter().sum::<i32>()
});
let h2 = thread::spawn(move || {
s2.iter().sum::<i32>()
});
let sum1 = h1.join().unwrap();
let sum2 = h2.join().unwrap();
sum1 + sum2
}'static 声明的值的声明周期是整个程序的运行时间
还有一种方式,即使用
leak
,告知编译器,程序员永远不会手动释放一个堆上的值的内存,因此编译器可以为其返回一个'static
的引用1
2
3
4
5
6
7
8
9
10
11pub fn sum(v: Vec<i32>) -> i32 {
let tmp: &'static[i32] = v.leak(); // deliberately leak
let (v1, v2) = tmp.split_at(tmp.len()/2);
let h1 = thread::spawn(move || {
v1.iter().sum::<i32>()
});
let h2 = thread::spawn(move || {
v2.iter().sum::<i32>()
});
h1.join().unwrap()+h2.join().unwrap()
}还有一种方式,使用
scope
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15pub fn sum(v: Vec<i32>) -> i32 {
if v.len() <= 1 {
return v.iter().sum();
}
let (v1, v2) = v.split_at(v.len()/2);
thread::scope(|scope| {
let h1 = scope.spawn(|| {
v1.iter().sum::<i32>()
});
let h2 = scope.spawn(|| {
v2.iter().sum::<i32>()
});
h1.join().unwrap()+h2.join().unwrap()
})
} -
channel
Rust 原生的 channel 是 mpsc 风格的,即 multiple producer, single consumer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel(); // tx允许clone,rx不允许
// 创建多个生产者
for i in 0..3 {
let tx_clone = tx.clone();
thread::spawn(move || {
let val = format!("hello from thread {}", i);
tx_clone.send(val).unwrap();
println!("Thread {} finished", i);
});
}
// 创建单个消费者
thread::spawn(move || {
for received in rx { // rx的所有权被转移
println!("Got: {}", received);
}
});
// 主线程暂停一段时间,确保子线程运行完毕
thread::sleep(Duration::from_secs(1));
}