【Rust知识】Rust 核心概念
1. 生命周期
在任何语言里,栈上的值都有自己的生命周期,它和帧的生命周期一致,而 Rust,进一步明确这个概念,并且为堆上的内存也引入了生命周期。
- 一般来说,堆内存的生命周期,会默认和其栈内存的生命周期绑定在一起。
- 默认情况下,在每个函数的作用域中,编译器就可以对比值和其引用的生命周期,来确保“引用的生命周期不超出值的生命周期”。
值的生命周期
静态生命周期
如果一个值的生命周期贯穿整个进程的生命周期,那么我们就称这种生命周期为静态生命周期。
当值拥有静态生命周期,其引用也具有静态生命周期。比如: &'static str 代表这是一个具有静态生命周期的字符串 引用。
一般来说,全局变量、静态变量、字符串字面量(string literal)等,都拥有静态生命周 期。我们上文中提到的堆内存,如果使用了 Box::leak 后,也具有静态生命周期。
动态生命周期
如果一个值是在某个作用域中定义的,也就是说它被创建在栈上或者堆上,那么其生命周期是动态的。
当这个值的作用域结束时,值的生命周期也随之结束。对于动态生命周期,我们约定用 'a 、'b 或者 'hello 这样的小写字符或者字符串来表述。 ' 后面具体是什么名字不重要, 它代表某一段动态的生命周期,其中, &'a str 和 &'b str 表示这两个字符串引用的生 命周期可能不一致。
当出现了多个参数,它们的生命周期可能不一致时,返回值的生命周期就不好确定了。编译器在编译某个函数时,并不知道这个函数将来有谁调用、怎么调用,所以,函数本身携带的信息,就是编译器在编译时使用的全部信息。
此时,就需要我们在函数签名中提供生命周期的信息,也就是生命周期标注(lifetime specifier)。在生命周期标注时,使用的参数叫生命周期参数(lifetime parameter)。 通过生命周期标注,我们告诉编译器这些引用间生命周期的约束。
生命周期参数的描述方式和泛型参数一致,不过只使用小写字母。这里,两个入参 s1、 s2,以及返回值都用 'a 来约束。生命周期参数,描述的是参数和参数之间、参数和返回 值之间的关系,并不改变原有的生命周期。
总结
- 所有引用类型的参数都有独立的生命周期 'a 、'b 等。
- 如果只有一个引用型输入,它的生命周期会赋给所有输出。
- 如果有多个引用类型的参数,其中一个是 self,那么它的生命周期会赋给所有输出。
- 使用数据结构时,数据结构自身的生命周期,需要小于等于其内部字段的所有引用的生命周期。
2. 内存管理
Rust 的创造者们,重新审视了堆内存的生命周期,发现大部分堆内存的需求在于动态大 小,小部分需求是更长的生命周期。
值的创建
struct
Rust 在内存中排布数据时,会根据每个域的对齐(aligment)对数据进行重排,使其内存大小和访问效率最好。
如果结构体的定义考虑地不够周全,会为了对齐浪费很多空间。
enum
enum是一个标签联合体(tagged union),它的大小是标 签的大小,加上最大类型的长度。定义 enum 数据结构时,有 Option 和 Result<T, E> 两种设计举例。Rust 编译器会对 enum 做一些额外的优化,让某些常用结构的内存布局更紧凑。引用类型的第一个域是个指针,而指针是不可能等于 0 的,但是我们可以复用这个指针:当其为 0 时,表示 None,否则是 Some,减少了内存占用。
vec 和 String
String 结构内部就是一个 Vec;Vec 结构是 3 个 word 的胖指针,包含:一个指向堆内存的指针 pointer、分配的堆内存的容量 capacity,以及数据在堆内存的长度 length。
很多动态大小的数据结构,在创建时都有类似的内存布局:栈内存放的胖指针,指向堆内 存分配出来的数据。
值的使用
一个值如果没有实现 Copy,在赋值、传 参以及函数返回时会被 Move。其实 Copy 和 Move 在内部实现上,都是浅层的按位做内存复制,只不过 Copy 允许你访 问之前的变量,而 Move 不允许。
内存复制是个很重的操作,效率很低。但是,如果你要复制的只是原生类型(Copy)或者栈上的胖指针(Move),不涉及堆内 存的复制也就是深拷贝(deep copy),那这个效率是非常高的,我们不必担心每次赋值 或者每次传参带来的性能损失。所以,无论是 Copy 还是 Move,它的效率都是非常高的。
在使用值的过程中,除了 Move,你还需要注意值的动态增长。因为 Rust 下,集合类型的数据结构,例如:Vec,都会在使用过程中自动扩展。但只使用了很小部分的容量,内存的使用效率很低,所以你要考虑使用,比如 shrink_to_fit 方法,来节约对内存的使用。
值的销毁
当一个值要被释 放,它的 Drop trait 会被调用。如果要释放的值是一个复杂的数据结构,比如一个结构体,那么这个结构体在调用 drop() 时,会依次调用每一个域的 drop() 函数,如果域又是一个复杂的结构或者集合类型,就会 递归下去,直到每一个域都释放干净。整个释放顺序从内到外是:先释放 HashMap 下的 key,然后释放 HashMap 堆上的表结构,最后释放栈上的内存。
3. 类型系统
类型系统其实就是,对类型进行定义、检查和处理的系统。
生命周期标注也是泛型的一部分,一个生命周期 'a 代表任意 的生命周期,和 T 代表任意类型是一样的。
Rust 中的核心数据结构,通示例来理解它们在实际开发中的应用:
数组(Array)
Rust 中的数组是一个由相同类型元素组成的固定大小的集合。
fn main() {
let arr: [i32; 5] = [1, 2, 3, 4, 5]; // 声明一个i32类型的5个元素的数组
// 访问数组中的元素
println!("第一个元素: {}", arr[0]);
// 遍历数组元素
for i in &arr {
println!("{}", i);
}
}
数组长度在编译时就已经确定,且不可变。如果需要变长数组,我们通常使用 vector。
向量(Vector)
Vector 类似于数组但它是动态的,可以在运行时增长或缩小。
fn main() {
let mut vec = Vec::new(); // 创建一个空的 vector
// 向 vector 添加元素
vec.push(1);
vec.push(2);
vec.push(3);
// 移除并返回最后一个元素
if let Some(last) = vec.pop() {
println!("最后一个元素是: {}", last);
}
// 遍历 vector
for i in &vec {
println!("{}", i);
}
}
切片(Slice)
切片是对数组或 vector 的部分连续引用,它使得可以高效地访问序列的子部分而不需要复制。
fn main() {
let arr = [1, 2, 3, 4, 5];
let slice: &[i32] = &arr[1..4]; // slice 引用数组中的元素 2, 3 和 4
for &i in slice {
println!("{}", i);
}
}
切片对于函数参数非常有用,因为它们允许函数处理数组或向量的任何部分。
元组(Tuple)
元组是一种将多种类型的元素组合到一起的数据结构。 rust
fn main() {
let tup: (i32, f64, u8) = (500, 6.4, 1); // 一个包含不同类型元素的元组
// 解构元组
let (x, y, z) = tup;
println!("元组的第一个元素是: {}", x);
println!("元组的第二个元素是: {}", y);
println!("元组的第三个元素是: {}", z);
}
结构体(Struct)
结构体用于创建自定义的数据类型。
// 定义一个结构体
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
fn build_user(email: String, username: String) -> User {
User {
email,
username,
active: true,
sign_in_count: 1,
}
}
fn main() {
// 创建一个 User 实例
let user1 = build_user(String::from("someone@example.com"), String::from("someusername123"));
// 访问结构体字段
println!("用户名: {}", user1.username);
}
在 Rust 中,结构体对于组织相关联数据是十分重要的。
枚举(Enum)
枚举允许定义一个类型,它可以是有限集合中的多个不同值中的一个。
// 定义一个枚举,包括若干个变体
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn main() {
// 创建一个 Write 枚举变体的实例
let msg = Message::Write(String::from("hello"));
// 匹配枚举变体
match msg {
Message::Write(text) => println!("{}", text),
_ => println!("其他消息类型"),
}
}
在 Rust 中,枚举的 match
表达式确保所有可能的情况都被处理。
Map(HashMap)
HashMap 存储一组键值对,允许根据键快速检索值。
use std::collections::HashMap;
fn main() {
// 创建一个 HashMap
let mut scores = HashMap::new();
// 插入键值对
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
// 通过键检索值
let team_name = String::from("Blue");
if let Some(score) = scores.get(&team_name) {
println!("{} 分数是: {}", team_name, score);
}
}
HashMap 在存储和快速检索数据时非常有用。
4. 智能指针
智能指针一定是一个胖指针,但胖指针不一定是一个 智能指针。比如 &str 就只是一个胖指针,它有指向堆内存字符串的指针,同时还有关于字 符串长度的元数据。
智能指针 String 和 普通胖指针 &str 的区别:
- String 比 &str 多一个 capacity 字段
- 但 String 对堆上的值有所有权,而 &str 是没有所有权的,这是 Rust 中智能指针和普通胖指针的区别。
在 Rust 中,凡是需要做资源回收的数据结构,且实现了 Deref/DerefMut/Drop,都是智能指针。
以三个使用智能指针的数据结构为例: 在堆上创建内存的 Box、提供写时克隆的 Cow<'a, B>,以及用于数据加锁的 MutexGuard。
Box
Box是 Rust 中最基本的在堆上分配内存的方式,绝大多数其它包含堆内 存分配的数据类型,内部都是通过 Box 完成的,比如 Vec。
设计内存分配器的目的除了保证正确性之外,就是为了有效地利用剩余内存,并控制内存在分配和释放过程中产生的碎片的数量。在多核环境下,它还要能够高效地处理并发请求。
堆上分配内存的 Box 其实有一个缺省的泛型参数 A,就需要满足 Allocator trait, 并且默认是 Global:
Allocator trait 提供很多方法:
- allocate 是主要方法,用于分配内存;
- deallocate,用于释放内存;
- 还有 grow / shrink,用来扩大或缩小堆上已分配的内存。
Cow<'a, B>
Cow 是 Rust 下用于提供写时克隆(Clone-on-Write)的一个智能指针,它跟虚拟内存管 理的写时复制(Copy-on-write)有异曲同工之妙:包裹一个只读借用,但如果调用者需 要所有权或者需要修改内容,那么它会 clone 借用的数据。
虽然 Cow 是一个 enum,但是通过 Deref 的实现,我们可以获得统一的 体验,比如 Cow,使用的感觉和 &str / String 是基本一致的。注意,这种根据 enum 的不同状态来进行统一分发的方法是第三种分发手段,还有可以使用泛型参数 做静态分发和使用 trait object 做动态分发。
MutexGuard
String、Box、Cow<'a, B> 等智能指针,都是通过 Deref 来提 供良好的用户体验,那么 MutexGuard 是另外一类很有意思的智能指针:它不但通过 Deref 提供良好的用户体验,还通过 Drop trait 来确保,使用到的内存以外的资源在退出 时进行释放。
MutexGuard这个结构是在调用 Mutex::lock 时生成的:
pub fn lock(&self) -> LockResult<MutexGuard<'_, T>> {
unsafe {
self.inner.raw_lock();
MutexGuard::new(self);
}
}
首先,它会取得锁资源,如果拿不到,会在这里等待;如果拿到了,会把 Mutex 结构的引 用传递给 MutexGuard。
当 MutexGuard 结束时,Mutex 会做 unlock,这样用户在使用 Mutex 时,可以不必关心何时释放这个互斥锁。因为无论你在调用栈上怎样传递 MutexGuard ,哪怕在错误处理流程上提前退出,Rust 有所有权机制,可以确保只要 MutexGuard 离开作用域,锁就会被释放。
5. 切片以及和切片相关的容器
在 Rust 里,切片是描述一组属于同一类型、长度不确定的、在内存中连续存放的数据结 构,用 [T] 来表述。因为长度不确定,所以切片是个 DST(Dynamically Sized Type)。
切片一般只出现在数据结构的定义中,不能直接访问,在使用中主要用以下形式:
- &[T]:表示一个只读的切片引用。
- &mut [T]:表示一个可写的切片引用。
- Box<[T]>:一个在堆上分配的切片。
在使用的时候,支持切片的具体数据类型,你可以根据需要,解引用转换成切片类型。比 如 Vec 和 [T; n] 会转化成为 &[T],这是因为 Vec 实现了 Deref trait,而 array 内建了到 &[T] 的解引用。
切片和迭代器 Iterator
- 通过切片的 iter() 方法,我们可以生成一个迭代器,对切片进行迭代。(用 collect 方法把过滤出来的数 据形成新列表)
- 对 Vec 使用 iter() 方法,并进行各种 map / filter / take 操作。
fn main() {
// 这里 Vec<T> 在调用 iter() 时被解引用成 &[T],所以可以访问 iter()
let result = vec![1, 2, 3, 4]
.iter()
.map(|v| v * v)
.filter(|v| *v < 16)
.take(1)
.collect::<Vec<_>>();
println!("{:?}", result);
}
Rust 下的迭代器是个懒接口(lazy interface),也就是说这段代码直到运 行到 collect 时才真正开始执行,之前的部分不过是在不断地生成新的结构,来累积处理 逻辑而已。
Iterator 大部分方法都返回一个实现了 Iterator 的数据结构,所以可以这样一路链 式下去。
特殊的切片:&str
String 是一个特殊的 Vec,所以在 String 上做切片,也是一个特殊的结构 &str。String 在解引用时,会转换成 &str。
Box<[T]>
Box<[T]> 是一个比较有意思的存在,它和 Vec 有一点点差别:Vec 有额外的
capacity,可以增长;而 Box<[T]> 一旦生成就固定下来,没有 capacity,也无法增长。
Box<[T]> 和切片的引用 &[T] 也很类似:它们都是在栈上有一个包含长度的胖指针,指向 存储数据的内存位置。区别是:Box<[T]> 只会指向堆,&[T] 指向的位置可以是栈也可以 是堆;此外,Box<[T]> 对数据具有所有权,而 &[T] 只是一个借用
当我们需要在堆上创建固定大小的集合数据,且不希望自动增长,那么,可以先创 建 Vec,再转换成 Box<[T]>。
6. 哈希表 HashMap
哈希表最核心的特点就是:巨量的可能输入和有限的哈希表容量。
这就会引发哈希冲突,也就是两个或者多个输入的哈希被映射到了同一个位置,所以我们 要能够处理哈希冲突。 如何解决冲突?
如何解决冲突?
理论上,主要的冲突解决机制有链地址法(chaining)和开放寻址法(open addressing)。
链地址法
链地址法,就是把落在同一个哈希上的数据用单链表或者双链表连接起 来。这样在查找的时候,先找到对应的哈希桶(hash bucket),然后再在冲突链上挨个比 较,直到找到匹配的项。
冲突链处理哈希冲突非常直观,很容易理解和撰写代码,但缺点是哈希表和冲突链使用了 不同的内存,对缓存不友好。
开放寻址法
开放寻址法 把整个哈希表看做一个大数组,不引入额外的内存,当冲突产生时,按照一定 的规则把数据插入到其它空闲的位置。比如线性探寻(linear probing)在出现哈希冲突 时,不断往后探寻,直到找到空闲的位置插入。
而二次探查,理论上是在冲突发生时,不断探寻哈希位置加减 n 的二次方,找到空闲的位置插入。
Rust 哈希表不是用冲突链来解 决哈希冲突,而是用开放寻址法的二次探查来解决的。
HashMap 的基本使用方法
use std::collections::HashMap;
fn main() {
let mut map = HashMap::new();
explain("empty", &map);
map.insert('a', 1);
explain("added 1", &map);
map.insert('b', 2);
map.insert('c', 3);
explain("added 3", &map);
map.insert('d', 4);
explain("added 4", &map);
// get 时需要使用引用,并且也返回引用
assert_eq!(map.get(&'a'), Some(&1));
assert_eq!(map.get_key_value(&'b'), Some((&'b', &2)));
map.remove(&'a');
// 删除后就找不到了
assert_eq!(map.contains_key(&'a'), false);
assert_eq!(map.get(&'a'), None);
explain("removed", &map);
// shrink 后哈希表变小
map.shrink_to_fit();
explain("shrinked", &map);
}
fn explain<K, V>(name: &str, map: &HashMap<K, V>) {
println!("{}: len: {}, cap: {}", name, map.len(), map.capacity());
}
以上可以看出,当 HashMap::new() 时,它并没有分配空间,容量为零,随着哈希表不断插入 数据,它会以 2 的幂减一的方式增长,最小是 3。当删除表中的数据时,原有的表大小不 变,只有显式地调用 shrink_to_fit,才会让哈希表变小。
HashMap 的内存布局
当 HashMap 插入和删除数据,以及因此导致重新分配的时候,主要工作就是在维 护 ctrl 表和数据的对应。
因为 ctrl 表是所有操作最先触及的内存,所以,在 HashMap 的结构中,堆内存的指针直 接指向 ctrl 表,而不是指向堆内存的起始位置,这样可以减少一次内存的访问。
7. Rust 闭包
Rust 支持三种不同的闭包 trait:FnOnce、FnMut 和 Fn。FnOnce 是 FnMut 的 super trait,而 FnMut 又是 Fn 的 super trait。从这些 trait 的接口可以看出,
- FnOnce 只能调用一次;
- FnMut 允许在执行时修改闭包的内部数据,可以执行多次;
- Fn 不允许修改闭包的内部数据,也可以执行多次。
闭包本质上是什么?
闭包是一种匿名类型,一旦声明,就会产生一个新的类型,但这个类型无法被其它地方使 用。这个类型就像一个结构体,会包含所有捕获的变量。
闭包的大小跟参数、局部变量都无关,只跟捕获的变量有关。闭包是存储在栈上,并且除了捕获的数据外,闭包本身不 包含任何额外函数指针指向闭包的代码。
Rust 为每个闭包生成一个新的类型,又使得调用闭包时可以直接和代码对应,省去了使 用函数指针再转一道手的额外消耗。
任何需要 FnOnce 或者 FnMut 的场合,都可以传入满足 Fn 的闭包。
转载自:https://juejin.cn/post/7344282440724824101