通常,程序通过两种方式分配内存:
-
栈:用于存储局部变量的连续内存区域。
- 在编译时就已知值的固定大小。
- 速度极快:只需移动栈指针。
- 易于管理:遵循函数调用。
- 内存局部性良好。
-
堆:用于存储函数调用之外的值。
- 值的大小在运行时动态确定。
- 速度比栈稍慢:需要进行一些簿记操作。
- 无法保证内存局部性。
创建一个字符串时,会将固定大小的元数据放在栈上,而将动态大小的数据(即实际的字符串)放在堆上:
fn main() {
let s1 = String::from("Hello");
}
上述代码的内存布局大概是这样:
Rust 中String 是基于 Vec 的,因此它有容量和长度,并且如果是可变的(mut String),可通过在堆上重新分配内存来增长其大小。
传统上,编程语言大致分为两大类:
- 通过手动内存管理实现完全控制:C、C++、Pascal 等。
- 程序员决定何时分配或释放堆内存。
- 程序员必须确定指针是否仍指向有效的内存。
- 研究表明,程序员容易犯错。
- 通过运行时自动内存管理实现完全安全:Java、Python、Go、Haskell 等。
- 运行时系统确保在内存不再被引用之前不会被释放。
- 通常通过引用计数或垃圾回收来实现。
Rust 则使用了一种全新的结合方式:
- 通过在编译时强制内存管理的正确性,实现完全控制和安全性。
Rust 通过所有权来做到这一点。
C 语言必须使用 malloc 和 free 手动管理堆内存。常见错误包括忘记调用 free、对同一个指针多次调用 free,或者在指针所指向的内存被释放后再解引用该指针。
C++ 有诸如智能指针(unique_ptr、shared_ptr)等工具,这些工具利用了语言在调用析构函数方面的保障机制,以确保函数返回时内存被释放。然而,仍然很容易误用这些工具,从而产生与 C 语言类似的错误。
Java、Go 和 Python 依靠垃圾回收器来识别不再可达的内存并将其丢弃。这保证了任何指针都可以被解引用,从而消除了 “使用后释放” 以及其他类型的错误。但是,垃圾回收有运行时开销,并且很难进行合理优化。
在很多情况下,Rust 的所有权和借用模型能够实现与 C 语言相当的性能,仅在需要的地方进行分配和释放操作,即零开销。它还提供了类似于 C++ 智能指针的工具。必要时,也有诸如引用计数之类的其他选项可用,甚至有支持运行时垃圾回收的库。
以上仅仅讨论各种编程语言的内存管理特点,并非比较优劣
所有权 Ownership
所有变量绑定都有其有效的作用域,在变量作用域之外使用变量属于错误行为:
struct Point(i32, i32);
fn main() {
{
let p = Point(3, 4);
dbg!(p.0);
}
// dbg!(p.1); // 编译错误:not found in this scope
}
我们说变量拥有那个值的所有权。在任何时刻,每个 Rust 值都有且仅有一个所有者。
在作用域结束时,变量会被释放,其数据也会被回收。此时可能会运行析构函数以释放资源。
你看,这就是 Rust 与其他语言不同的地方。无论是 Java 还是 C++,在编译期间是不会检查代码在内存管理上的缺陷的,例如检测 C++ 的多次 free ,或者 Java 的内存泄露。
部分读者可能知道垃圾收集器的实现——从一组 “根” 开始,找到所有可达的内存。Rust 的 “单一所有者” 原则也是类似的思路。
Move
赋值操作会在变量之间转移所有权:
fn main() {
let s1 = String::from("Hello!");
let s2 = s1;
dbg!(s2);
// dbg!(s1); // 编译错误:value used here after move
}
- 将
s1赋值给s2会转移所有权。 - 当
s1超出作用域时,不会发生任何事情:它不拥有任何东西。 - 当
s2超出作用域时,字符串数据会被释放。
意思是说,当我们 let s2 = s1 的时候,s1 将不再拥有这个字符串的所有权,所有权会转移到 s2 上面,即 Move(移动)。
用几张示意图表示即可:
let s1 = String::from("Hello!");
let s2 = s1;
同样,当你将一个值传递给一个函数时,该值会被赋给函数参数。这也会转移所有权:
fn say_hello(name: String) {
println!("Hello {name}")
}
fn main() {
let name = String::from("Alice");
say_hello(name);
// say_hello(name); // 此时,name 的所有权已经在第一次 say_hello 时转移了
}
在第一次调用 say_hello 时,main 放弃了 name 的所有权。之后,name 在 main 函数中就不能再使用了。
为 name 分配的堆内存将在 say_hello 函数结束时释放。
如果 main 以引用的形式传递 name(即 &name),并且 say_hello 函数接受引用作为参数,那么 main 就可以保留 name 的所有权。
或者,main 可以在第一次调用时传递 name 的一个克隆(即 name.clone())。
Rust 默认使用移动语义,克隆操作需要程序员显式使用,因此与 C++ 相比,更难无意中创建副本。
当然对于简单的值类型,例如整数,浮点数,Rust 的处理情况有所不同。
Clone
有时你想要创建一个值的副本。Clone trait 就能实现这一点。
fn say_hello(name: String) {
println!("Hello {name}")
}
fn main() {
let name = String::from("Alice");
say_hello(name.clone());
say_hello(name); // Ok now.
}
// Output
// Hello Alice
// Hello Alice
通过克隆来通过检查器的检查是很常见的做法,之后再尝试优化掉这些克隆操作。
一般来说,clone 会对值执行深拷贝,也就是说,例如克隆一个数组,数组的所有元素也都会被克隆。
Copy
虽然移动语义是默认的,但某些类型默认会被复制:
fn main() {
let x = 42;
let y = x;
dbg!(x); // 如果不是 `Copy` 类型将无法访问(因为所有权转移给了 y)
dbg!(y);
}
这些类型实现了 Copy 特性,所有此处是复制,而不是移动所有权。
你可以让自己的类型选择使用复制语义:
#[derive(Copy, Clone, Debug)]
struct Point(i32, i32);
fn main() {
let p1 = Point(3, 4);
let p2 = p1;
println!("p1: {p1:?}");
println!("p2: {p2:?}");
}
赋值之后,p1 和 p2 都拥有自己的数据,他们的数据的值相等。
我们也可以使用 p1.clone() 来显式地复制数据。
需要注意的是,复制和克隆不是一回事:
- 复制指的是对内存区域进行按位复制,不适用于任意对象。
- 复制不允许自定义逻辑(这与 C++ 中的复制构造函数不同),即,你不能自己实现
copytrait。 - 克隆是一种更通用的操作,并且通过实现 Clone 特性也允许自定义行为。
- 复制不适用于实现了 Drop 特性的类型。
Drop
实现了 Drop 特性的值可以指定在其超出作用域时运行的代码:
struct Droppable {
name: &'static str,
}
impl Drop for Droppable {
fn drop(&mut self) {
println!("Dropping {}", self.name);
}
}
fn main() {
let a = Droppable { name: "a" };
{
let b = Droppable { name: "b" };
{
let c = Droppable { name: "c" };
let d = Droppable { name: "d" };
println!("Exiting innermost block");
}
println!("Exiting next block");
}
drop(a);
println!("Exiting main");
}
输入如下:
// Output
Exiting innermost block
Dropping d
Dropping c
Exiting next block
Dropping b
Dropping a
Exiting main
值在超出作用域时会自动被丢弃。
当一个值被丢弃时,如果它实现了 std::ops::Drop,那么其 Drop::drop 实现会被调用。
无论其字段是否实现了 Drop,所有字段也都会被丢弃。