likes
comments
collection
share

【Rust知识】Rust 核心概念

作者站长头像
站长
· 阅读数 7

1. 生命周期

在任何语言里,栈上的值都有自己的生命周期,它和帧的生命周期一致,而 Rust,进一步明确这个概念,并且为堆上的内存也引入了生命周期。

  • 一般来说,堆内存的生命周期,会默认和其栈内存的生命周期绑定在一起。
  • 默认情况下,在每个函数的作用域中,编译器就可以对比值和其引用的生命周期,来确保“引用的生命周期不超出值的生命周期”。

值的生命周期

静态生命周期

如果一个值的生命周期贯穿整个进程的生命周期,那么我们就称这种生命周期为静态生命周期

当值拥有静态生命周期,其引用也具有静态生命周期。比如: &'static str 代表这是一个具有静态生命周期的字符串 引用。

一般来说,全局变量、静态变量、字符串字面量(string literal)等,都拥有静态生命周 期。我们上文中提到的堆内存,如果使用了 Box::leak 后,也具有静态生命周期。

动态生命周期

如果一个值是在某个作用域中定义的,也就是说它被创建在栈上或者堆上,那么其生命周期是动态的

当这个值的作用域结束时,值的生命周期也随之结束。对于动态生命周期,我们约定用 'a 、'b 或者 'hello 这样的小写字符或者字符串来表述。 ' 后面具体是什么名字不重要, 它代表某一段动态的生命周期,其中, &'a str 和 &'b str 表示这两个字符串引用的生 命周期可能不一致。

当出现了多个参数,它们的生命周期可能不一致时,返回值的生命周期就不好确定了。编译器在编译某个函数时,并不知道这个函数将来有谁调用、怎么调用,所以,函数本身携带的信息,就是编译器在编译时使用的全部信息

此时,就需要我们在函数签名中提供生命周期的信息,也就是生命周期标注(lifetime specifier)。在生命周期标注时,使用的参数叫生命周期参数(lifetime parameter)。 通过生命周期标注,我们告诉编译器这些引用间生命周期的约束。

生命周期参数的描述方式和泛型参数一致,不过只使用小写字母。这里,两个入参 s1、 s2,以及返回值都用 'a 来约束。生命周期参数,描述的是参数和参数之间、参数和返回 值之间的关系,并不改变原有的生命周期

总结

  1. 所有引用类型的参数都有独立的生命周期 'a 、'b 等。
  2. 如果只有一个引用型输入,它的生命周期会赋给所有输出。
  3. 如果有多个引用类型的参数,其中一个是 self,那么它的生命周期会赋给所有输出。
  4. 使用数据结构时,数据结构自身的生命周期,需要小于等于其内部字段的所有引用的生命周期。

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知识】Rust 核心概念

Rust 中的核心数据结构,通示例来理解它们在实际开发中的应用:

数组(Array)

Rust 中的数组是一个由相同类型元素组成的固定大小的集合。

fn main() {
    let arr: [i325] = [12345]; // 声明一个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 = [12345];
    let slice: &[i32] = &arr[1..4]; // slice 引用数组中的元素 2, 3 和 4

    for &i in slice {
        println!("{}", i);
    }
}

切片对于函数参数非常有用,因为它们允许函数处理数组或向量的任何部分。

元组(Tuple)

元组是一种将多种类型的元素组合到一起的数据结构。 rust

fn main() {
    let tup: (i32f64u8) = (5006.41); // 一个包含不同类型元素的元组

    // 解构元组
    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(i32i32i32),
}

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 的闭包。