#引言
所有编程语言都无法回避内存管理问题,我们理想的编程语言应该有以下两个特点:
- 内存对象能在正确的时机及时释放,这使我们能控制程序的内存消耗
- 在内存对象被释放后,不应该继续使用指向它的指针,这会导致崩溃和安全漏洞
由此诞生出两大阵营: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
拥有的是栈上指针的所有权,而非堆内存。但因为Type
类型实现了指向内存的释放逻辑(Drop::drop
),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 是一个没有任何可重写方法的标记 marker Trait,它只是告诉编译器“可以对这个类型执行按位复制”。因此,程序员无法自定义 Copy
的实现逻辑,对于需要实现 Copy
的自定义类型,通常通过 #[derive]
派生实现(必须同时派生 Clone
Trait):
#[derive(Copy, Clone)]
struct MyStruct(i32, i32);
也可以通过 impl
关键字实现:
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
呢?Drop
与 Copy
是两个互斥的 Trait,任何自身或部分成员实现了 Drop
的类型都不可实现 Copy
,如 String
和 Vec<T>
类型。
Drop
Trait 的定义如下:
pub trait Drop {
// Required method
fn drop(&mut self);
}
当一个值离开作用域时或进行赋值操作,Rust 会运行 "destructor" 将其释放:
- 如果该类型具有
T: Drop
约束,会调用<T as std::ops::Drop>::drop
析构函数。 - 若没有实现
Drop
,"destructor" 会自动生成 "drop glue",递归运行其所有成员的析构函数:- 结构体(struct)的字段按照声明顺序被销毁。
- 活动枚举变体的字段按声明顺序销毁。
- 元组中的字段按顺序销毁。
- 数组或拥有所有权的切片的元素的销毁顺序是从第一个元素到最后一个元素。
- 闭包通过移动(move)语义捕获的变量的销毁顺序未明确指定。
- Trait 对象的销毁会运行其非具名基类(underlying type)的析构函数。
- 其他类型不会导致任何进一步的销毁动作发生。
因而多数情况下,你不必为你的类型实现 Drop
。但有时手动实现仍是有用的,例如对于直接管理资源的类型。这个资源可能是内存,也可能是文件描述符,也可能是网络套接字。一旦不再使用该类型的值,它应该通过释放内存或关闭文件或套接字来“清理”其资源。这就是 destructor 的工作,因此也就是 Drop::drop
的职责。
为了避免重复释放资源,Rust 编译器不允许开发者手动调用 Drop::drop
析构函数,但偶尔又需要提前结束一个变量的生命周期(比如 Mutex
的守卫变量),为此 Rust 标准库还提供了能手动调用的 std::mem::drop
函数:
pub fn drop<T>(_x: T) { }
这就是一个函数体为空的函数,接受任意类型的参数,但不做任何操作。显然它利用了 Rust 的 Move 语义,原内存对象的所有权传入 std::mem::drop
函数后,会自动调用 "destructor"。
除此之外,可变引用 &mut T
也没有实现 Copy,这会违反引用类型的借用规则。
#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);
当调用 Clone
的默认实现时,操作的性能往往不高,但开发者可以自定义 Clone
的实现逻辑,比如 Rc
的 clone()
用于增加引用计数,同时只拷贝少量数据,效率并不低。
#小结
Move 语义等于“浅拷贝” + 原变量失效,复制栈内存并移动所有权;Copy 语义只进行“浅拷贝”,复制栈内存和所有权;Clone 语义必须显式调用,进行“深拷贝”,复制栈内存、堆内存和所有权。
另外,在 1)赋值 2)参数传入 3)返回值传出时 Move 和 Copy 行为被隐式触发,而 Clone 行为必须显示调用 Clone::clone(&self)
成员方法。
#所有权树
每个复合类型(tuple
/array
/vec
/struct
/enum
)变量,和它的子成员会形成一棵所有权树,其中任何一个成员节点 A
转移所有权或离开作用域,A
的子成员将与其保持行为一致。当成员 A
所有权转移后,除非为 A
重新初始化,否则 A
的所有父成员将失去所有权(但不属于 A
子成员的仍然可用):
#[derive(Debug)]
struct Vector {
x: Vec<i32>,
y: Vec<i32>,
}
fn main() {
let mut v1 = Vector {
x: vec![1, 2, 3, 4, 5],
y: vec![6, 7, 8, 9, 10],
};
let x = v1.x;
println!("{:?}", x);
println!("{:?}", v1.y);
// println!("{:?}", v1); // 报错: borrow of partially moved value: `v1`
v1.x = vec![11, 12, 13, 14, 15]; // 重新初始化 v1.x,这样可以避免报错
println!("{:?}", v1);
}