#引言
所有编程语言都无法回避内存管理问题,我们理想的编程语言应该有以下两个特点:
- 内存对象能在正确的时机及时释放,这使我们能控制程序的内存消耗
- 在内存对象被释放后,不应该继续使用指向它的指针,这会导致崩溃和安全漏洞
由此诞生出两大阵营: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 尚未严格定义其内存模型,但可以按下图粗略理解,包括了堆区、栈区、静态数据区、只读数据区和只读指令区,如图所示:
对于每个部分存储的内容,大致有如下分类:
- Stack(栈)
- 栈用于存储函数参数和局部变量,内存对象的数据类型及其大小必须在编译时确定
- 栈内存分配是连续的,操作系统对栈内存大小有所限制,因此你无法创建过长的数组
- 栈内存的分配效率要高于堆内存
- Heap(堆)
- 程序员主动申请使用,一般做大量数据读写时使用,相比栈,堆分配效率较低
- Static Data(静态数据区)
- 存放一般的静态函数、静态局部变量和静态全局变量,在程序启动时初始化
- Literals(只读数据区)
- 存放代码的文字常量,比如字符串字面量
- Instructions(只读代码区)
- 存放可执行指令
#所有权规则
Rust 的所有权系统基于以下事实:
- 编译器能够解析局部变量的生命周期,正确管理栈内存的入栈和出栈
- 堆内存最终都是通过栈变量(指针和引用)来读取和修改
那么,能否让堆内存管理和栈内存管理一样轻松,成为编译期就生成好的指令呢?根据这个思路,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 所有权的核心规则很简单:
- 每一个内存对象,在任意时刻,都有且只有一个称作所有者(owner)的变量
- 当所有者(变量)离开作用域时,这个内存对象将被释放
编译器知道本地变量 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
就指向了同一个堆内存对象,如图所示:
编译期的 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
呢?Copy
与 Drop
是两个互斥的 Trait,任何自身或部分实现了 Drop
的类型都不可实现 Copy
,如 String
和 Vec<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); // 错误
}