Rust 语法辨析:所有权

但是,古尔丹,代价是什么呢?

#引言

有编程语言都无法回避内存管理问题,我们理想的编程语言应该有以下两个特点:

  • 内存对象能在正确的时机及时释放,这使我们能控制程序的内存消耗
  • 在内存对象被释放后,不应该继续使用指向它的指针,这会导致崩溃和安全漏洞

由此诞生出两大阵营:1)以 C/C++/Zig 为代表,手动管理内存的申请和释放,但避免内存泄漏和悬空指针是程序员的责任。2)依靠垃圾回收机制(Garbage Collection)自动管理,在所有指向内存对象的指针都消失后,自动释放对应内存。但这会严重影响程序性能,几乎所有现代编程语言,从 Java/Python/Haskell 到 Go/Javascript 都在此列。

为了同时兼顾安全与性能,Rust 选择了第三种方式:由编译器管理内存(编译期 GC),即编译时就决定何时释放内存,并将相关指令在恰当位置写入可执行程序。这种方式不会带来任何运行时开销,也可以保证内存安全和并发安全(虽然 Rust 无法完全避免内存泄漏,但至少大大降低了它发生的概率,这是后话)。

为了满足要求,Rust 语言提出了两个核心概念,即所有权(Ownership)和生命周期(Lifetimes)。这两大概念本质是对语法的一种限制,目的是在混沌中建立足够的秩序,以便让 Rust 在编译期有能力验证程序是否安全。所有权系统解决了内存释放以及二次释放的问题,生命周期系统则解决了悬垂指针问题。

当然,在工程领域,一切选择皆是权衡。Rust 不是完美的等边三角,兼顾了安全和性能,则必然要付出一些代价,包括更长的编译时间、更高的心智负担,还有要命的语法限制——你很难用 safe rust 写出一个双向链表或红黑树。但这并非不可接受,不仅因为手写它们的机会越来越少,也因为你可以在 unsafe 中封装这些“不安全”的结构。记住,unsafe 不是 nosafe,只是将安全保证由编译器交到程序员手中,它与 C/C++ 没什么区别,甚至还更安全和现代一点。

#Rust 内存模型

在程序运行时,操作系统会为程序分配内存空间,并将之加载进内存。Rust 尚未严格定义其内存模型,但可以按下图粗略理解,包括了堆区、栈区、静态数据区、只读数据区和只读指令区,如图所示:

2024-05-23-17-54-54.png

对于每个部分存储的内容,大致有如下分类:

  1. Stack(栈)
    • 栈用于存储函数参数和局部变量,内存对象的数据类型及其大小必须在编译时确定
    • 栈内存分配是连续的,操作系统对栈内存大小有所限制,因此你无法创建过长的数组
    • 栈内存的分配效率要高于堆内存
  2. Heap(堆)
    • 程序员主动申请使用,一般做大量数据读写时使用,相比栈,堆分配效率较低
  3. Static Data(静态数据区)
    • 存放一般的静态函数、静态局部变量和静态全局变量,在程序启动时初始化
  4. Literals(只读数据区)
    • 存放代码的文字常量,比如字符串字面量
  5. Instructions(只读代码区)
    • 存放可执行指令

#所有权规则

Rust 的所有权系统基于以下事实:

  1. 编译器能够解析局部变量的生命周期,正确管理栈内存的入栈和出栈
  2. 堆内存最终都是通过栈变量(指针和引用)来读取和修改

那么,能否让堆内存管理和栈内存管理一样轻松,成为编译期就生成好的指令呢?根据这个思路,Rust 将堆内存的生命周期和栈变量绑定在一起,当函数栈被回收,局部变量被销毁时,其对应的堆内存(如果有的话)也会被析构函数 drop() 回收。

在 C++ 中,这种 item 在生命周期结束时释放资源的模式被称作资源获取即初始化 Resource Acquisition Is Initialization (RAII)

考虑一个普通的初始化语句:

fn main() {
    let Variable: Type = Value;
    // ...
    // Variable 离开作用域
}

Variable 被称为变量,Type 是其类型,而 Value 被称为内存对象,也叫做值。每一个赋值操作称为值绑定,因为此时不仅仅对变量进行了赋值,我们还把内存对象的所有权一并给予了变量。此处的内存对象 Value 可以是栈内存,也可以是堆内存(但它一定有一个栈指针)。

重点辨析:既然堆内存对象都是由栈上指针进行管理的,那么当 Value 包含 String::from("xxx")Box::new(xxx) 这样的堆内存时,严格来说,Variable 拥有的是栈上指针的所有权,而非堆内存字符串(Value)。但因为 Variable 实现了指向内存的释放逻辑,Variable 实质上拥有指向内存的所有权。Rust 所有权的本质,就是明确谁负责释放资源的责任

Rust 所有权的核心规则很简单:

  1. 每一个内存对象,在任意时刻,都有且只有一个称作所有者(owner)的变量
  2. 当所有者(变量)离开作用域时,这个内存对象将被释放

编译器知道本地变量 Variable 何时离开作用域,自然也就知道何时执行对内存对象 Value 的回收。而所有者唯一,保证了不会出现二次释放同一内存的错误。

切记,所有权是一个编译器抽象的概念,它不存在于实际的代码中,仅仅是一种思想和规则。

#所有权转移

其他语言往往有深拷贝和浅拷贝两个概念,浅拷贝是只拷贝数据对象的引用,深拷贝是根据引用递归到最终的数据并拷贝数据。

Rust 为了适应所有权系统,没有采用深浅拷贝,而是提出了移动(Move)、拷贝(Copy)和克隆(Clone)的概念。

#Move 语义

考虑以下代码:

let mut s1 = String::from("big str");
let s2 = s1;

// 下面将报错 error: borrow of moved value: `s1`
println!("{},{}", s1, s2); 

// 重新赋值
// "big str" 被自动释放,为 "new str" 分配新的堆内存
s1 = String::from("new str");

s1 赋值给 s2 会发生什么?如果将 s1 宽指针复制一份给 s2,这违反了所有者唯一的规则,会导致内存的二次释放;如果拷贝一份 s1 指向的堆内存交给 s2,这又违背了 Rust 性能优先的原则。

实际上,这时候 Rust 会进行所有权转移(Move):直接让 s1 无效(s1 仍然存在,只是失去所有权,变成未初始化的变量,只要 s1 是可变的,你还可以重新为其初始化),同时将 s1 栈内存对象复制一份,再将内存对象的所有权交给 s2,这样 s2 就指向了同一个堆内存对象,如图所示:

2024-05-23-20-48-24.png

编译期的 mut 标识是作用在变量名上,而不是那个内存对象。因此下面的例子中 s1 不可变,并不妨碍我们定义另外一个可变的变量名 s2 来写这块内存:

// s1 不可变
let s1 = String::from("big str");

let mut s2 = s1;
s2.push('C');  // 正确

#所有权检查

所有权检查是编译期的静态检查,编译器通常不会考虑你的程序将怎样运行,而是基于代码结构做出判断,这使得它看上去不那么聪明。比如依次写两个条件互斥的 if,编译器不会考虑那么多,直接告诉你不能移动 x 两次:

fn foobar(n: isize, x: Box<i32>) {
    if n > 1 {
        let y = x;
    }
    if n < 1 {
        let z = x; // error[E0382]: use of moved value: `x`
    }
}

甚至把 Move 操作放在循环次数固定为 1 的 for 循环内,编译器也傻傻看不出来:

fn foobar(x: Box<i32>) {
    for _ in 0..1 {
        let y = x; // error[E0382]: use of moved value: `x`
    }
}

#编译器优化

Move 语义不仅出现在变量赋值过程中,在函数传参、函数返回数据时也会发生,因此,如果将一个大对象(例如过长的数组,包含很多字段的 struct)作为参数传递给函数,可能会影响程序性能。

因此 Rust 编译器会对 Move 语义的行为做出一些优化,简单来说,当数据量较大且不会引起程序正确性问题时,它会优化为传递大对象的指针而非内存拷贝。[1]

总之,Move 语义虽然发生了栈内存拷贝,但性能并不会受太大影响。

#Copy 语义

你可能会想,如果每次赋值都要令原变量失效,是否太麻烦了?为此,Rust 提出了 Copy 语义,和 Move 语义的唯一区别是,Copy 后原变量仍然可用。换言之,Copy 等同于“浅拷贝”,会对栈内存做按位复制,而不对任何堆内存负责,原变量和新变量各自绑定独立的栈内存,并拥有其所有权。显然,如果变量负责管理堆内存对象,Copy 语义会导致二次释放的错误,因而 Rust 默认使用 Move 语义,只有实现了 Copy Trait 的类型赋值时 Copy。

例如,标准库的 i32 类型已经实现了 Copy Trait,因此它在进行所有权转移的时候,会自动使用 Copy 而非 Move 语义,即赋值后原变量仍可用。

Rust 默认实现 Copy Trait 的类型,包括但不限于:

  • 所有整数类型
  • 所有浮点数类型
  • 布尔类型
  • 字符类型
  • 元组,当且仅当其包含的类型也都实现 Copy 的时候。比如 (i32, i32)Copy 的,但 (i32, String) 不是
  • 共享指针类型 *const T 或共享引用类型 &T(无论 T 是否实现 Copy

对于那些没有实现 Copy Trait 的自定义类型,可以通过 #[derive] 手动派生实现(必须同时实现 Clone Trait),方式很简单:

#[derive(Copy, Clone)]
struct MyStruct(i32, i32);

你也可以手动实现:

struct MyStruct;

impl Copy for MyStruct { }

impl Clone for MyStruct {
    fn clone(&self) -> MyStruct {
        *self
    }
}

注意,只有当某类型的所有成员都实现了 Copy,该类型才能实现 Copy。“成员(Member)”的含义取决于类型,例如:结构体的字段、枚举的变量、数组的元素、元组的项,等等。

Rust 标准库文档提到[2],一般来说,如果你的类型可以实现 Copy,它就应该实现。但实现 Copy 是你的类型公共 API 的一部分。如果该类型可能在未来变成非 Copy,那么现在省略 Copy 的实现可能会是明智的选择,以避免 API 的破坏性改变。

那么哪些类型不能实现 Copy 呢?CopyDrop 是两个互斥的 Trait,任何自身或部分实现了 Drop 的类型都不可实现 Copy,如 StringVec<T> 类型。

Drop Trait 的定义如下:

pub trait Drop {
    // Required method
    fn drop(&mut self);
}

当一个值不再被需要(比如离开作用域时),Rust 会运行 "destructor" 将其释放。如果该类型实现了 Drop Trait,destructor 会调用 Drop::drop 析构函数,但即使没有实现 Drop,destructor 也会自动生成 "drop glue",递归地为这个值的所有成员调用析构函数。因此多数情况下,你不必为你的类型实现 Drop

但是在某些情况下,手动实现是有用的,例如对于直接管理资源的类型。这个资源可能是内存,也可能是文件描述符,也可能是网络套接字。一旦不再使用该类型的值,它应该通过释放内存或关闭文件或套接字来“清理”其资源。这就是 destructor 的工作,因此也就是 Drop::drop 的职责。

除此之外,Copy 一个可变引用 &mut T 也不被允许,这违背了引用的借用规则。

#Clone 语义

看见上面的 Clone Trait 了吗?它也是一个常见的 Trait,实现了 Clone 的类型变量可以调用 clone() 方法手动拷贝内存对象,它对复本的整体有效性负责,所以【栈】与【堆】都是 clone() 的复制目标,这相当于“深拷贝”。Clone 还是 Copy 的 Supertrait,看看 Copy Trait 的定义:

pub trait Copy: Clone { }

这表明,实现 Copy 的类型必须先实现 Clone。这是因为实现了 Copy 的类型在赋值时,会自动调用其 clone() 方法。如果一个类型是 Copy 的,那么它的 clone() 实现只需要返回 *self(参见上例)。

let s1 = String::from("big str");
// 克隆 s1 之后,变量 s1 仍然绑定原始数据
let s2 = s1.clone();
println!("{},{}", s1, s2);

Copy 的细节被封装在编译器内,无法自行定制和实现,不可重载;而 Clone 可由开发者自行实现。所以调用 Clone 的默认实现时,操作的性能是较低的。但你可以实现自己的克隆逻辑,也不一定总是会效率低。比如 Rc,它的 clone() 用于增加引用计数,同时只拷贝少量数据,效率并不低。

#小结

Move 语义等于“浅拷贝” + 原变量失效,复制栈内存并移动所有权;Copy 语义只进行“浅拷贝”,复制栈内存和所有权;Clone 语义必须显式调用,进行“深拷贝”,复制栈内存、堆内存和所有权。

另外,在 1)赋值 2)参数传入 3)返回值传出时 Move 和 Copy 行为被隐式触发,而 Clone 行为必须显示调用 Clone::clone(&self) 成员方法。

#所有权树

Rust 每个拥有所有权的容器类型(tuple/array/vec/struct/enum等)变量和它的成员(以及成员的成员),会形成一棵所有权树。树中任何一个成员(假设叫 A)离开作用域或转移所有权,其全部子成员将与其保持行为一致——销毁内存对象或 Move 给新变量。

当成员 A 所有权转移后,除非你为 A 重新初始化,否则 A 的所有父成员(包括树的根成员)将失去所有权(但不属于 A 子成员的仍然可用)。

fn main() {
    let mut tup = (5, String::from("hello"));

    let (x, y) = tup; // tup.1 转移所有权给 y,tup.0 copy 给 x
    println!("{}, {}", x, y); // 正确
    println!("{}", tup.0); // 正确
    // tup.1 = String::from("world"); // 重新初始化 tup 可以修正错误
    println!("{}", tup.1); // 错误
    println!("{:?}", tup); // 错误
}
updatedupdated2024-12-132024-12-13