Rust 的核心哲学在于在不放弃任何底层兼容性(汇编&系统调用)的同时,用一些 富有指导性的的最佳实践包装代替脑子内与纯粹的指针、变量和内存打交道,从而 兼顾了安全、最佳实践、代码可读性。

Rust 内有一个富有指导性的语义:每一层包装都意味着正确处理必要的开销(无论是 运行时意义上还是逻辑语义的意义上),因此通过代码能够轻松预测代码的性能和逻辑。

数据驱动流程

Rust 一大亮点是通过 enum 和模式匹配,让我们可以超级优雅地包装、处理任何数据。 过去的大量 if-else 通过流程分支出不同操作,因而流程控制变量与内容变量通常需要 分离。Rust 的模式匹配可以将数据和我们的注意力汇聚到 enum 内。

此外,模式匹配加上 Rust 的最后一行作为值的语法,使得对于单个变量的赋值语义集中化了, 而不像过去在每一个分支中都可能会发生改动,因而在读代码或是重构的时候十分困难。 这很 ergonomic。

例如对于这样一个(检查前序迭代器为满二叉树的)递归:

enum BranchStatus{Empty, Full, NotFull}
// self: (Iterator<Item = bool>)
fn is_full(&mut self)->BranchStatus{
    match self.0.next(){
    None | Some(false) => Empty,
    Some(true) => { // non-empty
        match (self.is_full(), self.is_full()){
        (Empty, Empty) | (Full, Full) => Full,
        _ => NotFull
        }
    }}
}

用 C 写起来是这样的

// bool empty(); bool next();
enum BranchStatus{Empty, Full, NotFull};
BranchStatus isfull(){
    if(empty()) return Empty;
    char node=next();
    if(node==false) return Empty;
    char left=isfull(), right=isfull();
    if(left==Empty && right==Empty || left==Full && right==Full)
        return Full;
    else return NotFull;
}

数据主导和流程主导的差别可窥一二。

异常处理

这里很明显能看到 ES6 的影子。Rust 通过约定了 Result<T, E>作为函数返回值, 以及 ? 语法糖,使得 Rust 的异常处理异常简单。另一方面,enumed union 的底层实现既优雅, 性能又高。

当然,约定的坏处就是一旦要实现不符合约定的情况就变得繁琐起来了。

项目组织

crate - module 树结构。伟大,无需多言。cargo doc 的文档生成更是一绝。

所有权和引用

要理解所有权和引用,以及各种抽象包装的作用,就要理解他们的作用:

  • 实现完美的编译期内存管理
  • 阻止 Drop(生命周期结束)和 mut(内存重分配)引起的悬空指针
  • imuttable 对象缓存友好,可以优化掉重复内存读和多线程锁。

所有权一般包括 内存生命周期 和 数据可变性 两个方面。

Rust 的约定有以下限制:

  • 默认的 所有权即生命 语义。有时我们会以指针为生命周期(尤其堆内存),在所有引用消失之时才 Drop。
  • mut 地狱。如果一个函数需要改变为 mut,那么所有上游函数都得全部手动修改为 mut。
  • mut 污染&冲突。有时一个组合体内只特定部分需要 mut,但是不得不将整个结构体 mut,从而引起了 大范围的 mut 冲突。
  • unsafe mut。有时多个结构体需要保存一份公有的可修改的东西;代码实现不会/允许产生数据竞争,但是 Rust 一刀切地 阻止了编译。

因此 Rust 引入了以下的小工具

  • 生命周期管理
    • Box: Box 更类似于普通变量,其所有权与 Box 相同。Box 单纯实现了堆内存对象,零开销。
    • Rc: Rc 引入了引用计数器,从而使数据的生命周期为多个引用的总生命周期,解引用零开销,而引用的创建和销毁 有操作计数器开销(极小)。 注意,与 Box 完整的所有权不同,Rc 只包括了生命周期,而并未实现可变性(参见下面 mut 问题)。
    • Weak: 相比 Rc 可怜的生命周期权限,Weak 连生命都不管,唯一的作用就是作为引用访问,解决循环引用中 Rc 计数器导致的内存泄漏问题。零开销。
    • Arc: 将 Rc 的计数器换为 atomic 变量,实现借用和销毁的线程安全。解引用仍是零开销的,Arc 只有创建销 毁开销。
      不要使用 Arc<RefCell> 这种东西,因为 Arc 并不保证数据的线程安全,它只保证了内存管理安全。数据的 线程安全请用 Arc<Mutex> 实现。
  • mut 问题
    • UnsafeCell: 单纯实现了可变性隔离,零开销,代价是毁灭了可变性语义,因而本质上 unsafe,需要算法保证安全。
    • Cell: UnsafeCell 加上有 Copy Trait 的内部元素,实现了安全的可变性隔离,零开销。
    • RefCell: 通过提供可变/不可变引用来实现内部可变性。引入了 mut 计数器,实现了运行时竞争管理,从而实现了 可变性隔离。有引用计数器开销。 单线程的函数内使用是很安全的。
      注意,RefCell 的使用并没有违背借用规则,只是把编译期报错推迟到了运行时 panic。毕竟与其产生 bug,不如 彻底毁灭(typical Rust)。
    • Mutex: RefCell 的线程安全平替是 Mutex,通过引入互斥锁来防止竞争读写。不正确的算法会导致死锁。
  • 线程安全
    • Send Trait: 多线程内存管理安全。默认的所有类型由于单所有权,因此很安全。Rc 会有多线程中引用计数器的 竞争修改,内存管理不安全。
    • Sync Trait: 不可变引用的多线程竞争安全。默认单所有权类型很安全。手动内存竞争管理的 CellRefCell 不安全,因为多线程可能会竞争修改 mut 计数器,从而竞争修改。Rc 由于内部引用计数器用了 Cell,因而连带竞争 不安全。
      事实上只有用了 CellRefCell (裸指针)的东西会竞争不安全,因为只有这样不可变引用才会可变。

Static

static 变量在很多单线程软件中很好用,它通过声明一个全局位置(.BSS/.DATA segment)的变量,使得任何函数都可以 访问到它并修改它。

它不受 Rust 的所有权管辖,因为它既可以被任何地方修改,也无需内存管理。由于竞争,它属于 unsafe 区。

一个容易混淆的概念是 'static 引用。它单纯指内容永远不会被 Rust Drop 掉,因而生命周期足够长。 因此除了堆栈外变量,堆上手动 leak 的变量,或是 unsafe alloc 的东西事实上也可以是 'static 引用。

'static 引用可以被转换成更短生命周期的引用,但是对无所有权对象而言,这就成了真正的内存泄漏了。

数据同步

多线程之间的数据传输有两种方式:共享,和拷贝。

所有权和 Send Trait 的严格限制让多线程之间的数据共享变得十分繁琐。这是在告诉我们,绝大多数情况下, 安全实现的数据共享性能反而不及拷贝一份。

这是因为多核共享这件事开销极大。一次原子操作是单个普通指令的数十倍开销(30ns)。 [^https://arxiv.org/pdf/2010.09852.pdf]。 更可怕的是,为了保证安全性,每一次读写都要至少经过一次核内验证(10ns)+可能的同步(20ns)。

对于锁而言,由于有竞争的状态,开销更是巨大。

相较之下,拷贝一份使得数据被存在了更快的 L1/L2 Cache 内,只有初始的拷贝开销,而读写完全不用考虑竞争, 因此性能高得多。最好的情况是,“分配”任务而不是竞争任务。

const fn 和内联函数

这是两种编译期优化函数的方式。

通过把函数声明为 const fn,使得当输入是常量 时,编译期会对输出进行计算。

内联函数会减少 call 的跳转和内存读取开销,但是增加了代码编译结果大小。当一个函数内部很小, 编译后只有不超过 10 条指令时,且会被大量调用时,内联才是有意义的。当然,正常时候不需要 inline,只有当发布后,性能测试发现某个小函数反复调用时,再用 inline 作为优化。

生命周期运算

对于一个函数而言,生命周期有三种:参数的,内部的,返回的。

最基本的生命周期是内部引用生命周期,它从声明处延伸到最后一次使用处。

参数的生命周期由调用处唯一决定,它绑定着实参本身的生命周期。

返回的生命周期需要手动标注/赋值,除了单引用参数-单引用返回的显然情况。这个 生命周期会被赋给调用函数中的接受的那个局部变量。

生命周期的运算包括

  • 声明。通过在泛型定义区定义 <'a>,我们可以定义出一个新的生命周期变量
  • 绑定。通过 param: &'a T->&'a T'a 与对应变量绑定
  • 拓展。通过在泛型定义区标明 'a:'b+'c,将 'a 生命周期拓展到比 'b, 'c 都长

生命周期赋值的唯一规则就是短的不能赋给长的,只能长的变短。因此实际上通常返回比输入生命要短, 拓展也一般是把输入变量的生命周期拓展到输出变量,可以理解成他们的生命周期被一起计算了。

PhantomData

在结构体内声明一个 PhantomData<T> 类型的 field 可以占用一个泛型变量,包括类型和生命周期, 以解决 parameter x is never used 的问题。这在实现一些抽象类型,例如没有 T 类型或是 ‘a 生命周期的 field,但是需要使用到该信息的时候很有用。

例如一个场景中,我们要不可变借用一个小类型,但是为了减少解引用开销我们要把它 copy 过来。

use std::marker::PhantomData;

struct FastRef<'a, T: Copy>{
    a: T,
    phantom: PhantomData<&'a T>
}

impl<'a, T: Copy> FastRef<'a, T>{
    pub fn new(a: &'a T)->Self{
        Self { a: *a, phantom: PhantomData }
    }
}

fn test_fastref(){
    let mut a=1;
    let foo=FastRef::new(&a);
}

这样 foo 就仿佛作为 a 的一个不可变引用一样拥有生命周期, 但是底层是一份 copy。

Rust 标准库中有些泛型列表类型(比如 Vec)配合 #[may_dangle] 使用 PhantomData, 具体 Argument 请参考Rustonomicon。 我个人的理解是它为了强行兼容以引用为泛型类型,且允许 dangle 的情况。老实来说,这是很烂的 私货,因为它显然破坏了引用语义。

在过去为了兼容 Rust 还不成熟的 drop checker,标准库编写者不得不加上 PhantomData<T> 字段 声明拥有 T 的所有权,这样 drop 才不会认为 struct 不存在 T 内容而优化掉 drop。现在则不需要了。

组合体 field 的引用

Rust 支持组合体 field 引用生命周期的分离。因此下面的代码可以成功工作。

struct Pair{
  a:i32, b:i32,
}
let mut p=Pair{a:1, b:2};
let pa=&mut p.a; // 'a
let pb=&mut p.b; // 'b
drop(pa)

然而数组是不能的,主要是数组的 indexing 可以通过变量访问,因而难以确定,若要实现类似于 &a[1]&a[2] 的生命周期分离,则不得不引入运行时外部计数器,那样应该手动用 RefCell

动态数组 Vec<T> 更不能,因为随时可能发生内存的重分配,因而一次可变引用过去的引用可能会 变得悬空。