The Rust programming language 读书笔记——所有权与 Move 机制

  • 所有权概念是 Rust 语言的核心功能
  • Rust 没有垃圾回收(GC)机制
  • Rust 通过所有权和相关工具保障内存安全

所有语言都需要管理自己在运行时使用的计算机内存空间。
使用垃圾回收机制的语言(Java、Python)会在运行时定期检查并回收没有被继续使用的内存;另外一些语言(C、C++)则需要程序员手动地分配和释放内存。

Rust 采用第三种方式:它使用包含特定规则的所有权系统来管理内存。这套规则允许编译器在编译过程中执行检查工作,不会产生任何的运行时开销。

栈与堆

栈和堆都是代码在运行时可以使用的内存空间。
所有存储在栈中的数据必须拥有一个已知且固定的大小。在编译期无法确定大小的数据只能存放在堆中

堆空间的管理较为松散。当希望将数据放入堆中时,可以请求特定大小的空间,操作系统会根据请求在堆中找到一块足够大的可用空间,并把指向这块空间地址的指针返回给我们。这个过程称为分配。

由于指针(内存地址)的大小是固定的且可以在编译期确定,因此可以将指针存放在栈中。通过指针指向的地址访问指针所指向的具体数据。

由于多了指针跳转的环节,访问堆上的数据要慢于访问栈上的数据。许多系统编程语言都需要程序员去记录代码中分配的堆空间,最小化堆上的冗余,并及时清理无用数据以避免耗尽内存空间。所有权的概念就是为了将上述问题交给 Rust 处理,减轻程序员的这部分心智负担。

所有权规则

  • Rust 中的每一个值都有一个对应的变量作为它的拥有者
  • 在同一时间内,值有且只有一个拥有者
  • 当所有者离开自己的作用域时,它拥有的值就会被释放掉

变量作用域

作用域是一个对象在程序中有效的范围

如:

1
2
3
4
{                       // 变量 s 还未声明,因此在这里不可用
let s = "hello"; // 从这里开始变量 s 变得可用
// 执行与 s 相关的操作
} // 作用域到这里结束,变量 s 不再可用

  • 变量在进入作用域后变得有效
  • 变量会保持自己的有效性直到离开自己的作用域

字符串字面量(如 let s = "hello")属于被硬编码进程序的字符串值。很方便,但并不适用于所有场景。
一是因为字符串字面量是不可变的,二是因为并不是所有字符串的值都能在编写代码时确定。
比如需要获取用户的输入并保存。

Rust 提供了第二种字符串类型 String。String 会在堆上分配存储空间,因此能够处理未知大小的文本。

1
2
3
4
5
let mut s = String::from("hello");

s.push_str(", world!"); // push_str() 函数向 String 空间的尾部添加了一段字符串字面量

println!("{}", s); // 这里会输出完整的 hello, world!

对于字符串字面量而言,由于在编译时就知道其内容,这部分硬编码的文本被直接嵌入到了可执行文件中。这也是访问字符串字面量异常高效的原因。
对于 String 类型而言,为了支持一种可变的、可增长的类型,需要在堆上分配一块在编译时未知大小的内存来存放数据。
当使用完 String 时,则需要通过某种方式来将这些内存归还给操作系统

对于拥有 GC 机制的语言,GC 会替代程序员记录并清理那些不再使用的内存。而对于没有 GC 的语言,识别不再使用的内存并调用代码显式释放的工作就需要程序员来完成。
假如忘记释放内存,就会造成内存泄漏;假如过早地释放内存,就会产生一个非法变量;假如重复释放同一块内存,就会产生无法预知的后果。

Rust 提供了另外一套解决方案:内存会在拥有它的变量离开作用域后自动地进行释放

1
2
3
4
{                                     // 变量 s 还未声明,因此在这里不可用
let s = String::from("hello"); // 从这里开始变量 s 变得可用
// 执行与 s 相关的操作
} // 作用域到这里结束,变量 s 失效

Rust 会在作用域结束的地方(即 } 处)自动回收分配给变量 s 的内存。

内存与分配

对于整数类型的数据:

1
2
let x = 5;
let y = x;

上述代码将整数值 5 绑定给变量 x,再创建一个 x 值的拷贝,绑定给变量 y。由于整数是已知固定大小的简单值,两个值 5 会同时被推入栈中。

对于 String 类型的数据:

1
2
let s1 = String::from("hello");
let s2 = s1;

类似的代码,运行方式却并不一致。

String 的内存布局如下图:String

对于绑定给变量 s1 的 String 来说,该字符串的文本内容(hello)保存在了堆上,同时在栈中保存着一个指向字符串内容的指针、一个长度和一个容量信息。

当将 s1 赋值给 s2 时,便复制了一次 String 的数据。这意味着我们复制了它存储在栈上的指针、长度和容量字段,而指针指向的堆上的数据并没有被复制。

变量 s1 和 s2 的内存布局如下图:s1 & s2

前面提到过,当一个变量离开当前的作用域时,Rust 会自动将变量使用的堆内存释放和回收。但若是有两个指针指向了同一个地址,就会导致如 s2 和 s1 离开自己的作用域时,Rust 会尝试重复释放相同的内存,进而有可能导致正在使用的数据发生损坏。
为了确保内存安全,同时也避免复制分配的内存,Rust 在上述场景下会简单的将 s1 废弃。因此也就不需要在 s1 离开作用域后清理任何东西。这一行为即为 Move

试图在 s2 创建完毕后访问 s1(如下所示)会导致编译错误。

1
2
3
4
5
fn main() {
let s1 = String::from("hello");
let s2 = s1; // 变量 s1 在这里被废弃
print!("{}, world", s1); // 错误
}

Rust 会报出 borrow of moved value: s1 错误。

Rust 永远不会自动创建数据的深度拷贝

对于栈上数据的复制,比如:

1
2
3
4
let x = 5;
let y = x;

println!("x = {}, y = {}", x, y);

上面的代码是完全合法的。因为整型的数据可以在编译时确定自己的大小,能够将数据完整地存储在栈中。对于这些类型而言,深度拷贝与浅度拷贝没有任何区别。

所有权与函数

将值传递给函数在语义上类似于对变量进行赋值。将变量传递给函数将会触发移动或复制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn main() {
let s = String::from("hello"); //变量 s 进入作用域
takes_ownership(s); // s 的值被移动进了函数
// 变量 s 从这里开始不再有效

let x = 5; // 变量 x 进入作用域
makes_copy(x); // 变量 x 被传递进了函数
// 但 i32 类型不受 Move 机制影响,因此这里 x 依旧可用
}

fn takes_ownership(some_string: String) {
// some_string 进入作用域
print!("{}", some_string);
} // some_string 离开作用域,占用的内存被释放

fn makes_copy(some_integer: i32) {
print!("{}", some_integer);
} // some_integer 离开作用域,没有特别的事情发生

在上述代码中,尝试在调用 takes_ownership 后使用变量 s 会导致编译错误。

函数在返回值的过程中也会发生所有权的转移

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn main() {
let s1 = gives_ownership(); // gives_ownership 将它的返回值移动至变量 s1 中
let s2 = String::from("hello"); // 变量 s2 进入作用域
let s3 = takes_and_gives_back(s2); // s2 被移动进函数 takes_and_gives_back,而这个函数的返回值又被移动到了变量 s3 上
} // s3 和 s1 在这里离开作用域并被销毁,而 s2 已经移动了,因此不会发生任何事情

fn gives_ownership() -> String {
let some_string = String::from("hello"); // some_string 进入作用域
some_string // some_string 作为返回值移动至调用方
}

// takes_and_gives_back 将取得一个 String 的所有权并将它作为结果返回
fn takes_and_gives_back(a_string: String) -> String {
a_string // a_string 作为返回值移动至调用方
}

变量的所有权转移总是遵循相同的模式:将一个值赋值给另一个变量时就会转移所有权。当一个持有堆数据的变量离开作用域时,它的数据就会被清理回收,除非这些数据的所有权被移动到了另一个变量上

引用与借用

参考如下示例代码:

1
2
3
4
5
6
7
8
9
10
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
print!("The length of '{}' is {}", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
let length = s.len();
(s, length)
}

由于调用 caculate_length 会导致 String 移动到函数体内部,我们又需要在调用后继续使用该 String,因此不得不通过元组将 String 作为元素继续返回。

这种写法未免过于笨拙。在下面的代码中,新的 calculate_length 函数使用了 String 的引用作为参数而不会直接转移值的所有权。

1
2
3
4
5
6
7
8
9
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
print!("The length of '{}' is {}", s1, len);
}

fn calculate_length(s: &String) -> usize {
s.len()
}

在新的代码中,调用 calculate_length 函数时使用了 &s1 作为参数,且在该函数的定义中使用 &String 替代了 String
& 代表引用,允许在不获取所有权的情况下使用值
引用

&s1 语法允许在不转移所有权的前提下创建一个指向 s1 值的引用。由于引用不持有值的所有权,当引用离开当前作用域时,它指向的值也不会被丢弃。
当一个函数使用引用而不是值本身作为参数时,我们就不需要为了归还所有权而特意去返回值。毕竟引用根本没有取得所有权。

这种通过引用传递参数给函数的方法也称作借用

可变引用

与变量类似,引用默认是不可变的。Rust 不允许修改引用指向的值(除非声明为 mut)。

1
2
3
4
5
6
7
8
9
fn main() {
let mut s = String::from("hello");
change(&mut s);
print!("{}", s)
}

fn change(some_string: &mut String) {
some_string.push_str(", world");
}

对于特定作用域中的特定数据,一次只能声明一个可变引用
比如:

1
2
3
4
5
6
fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{}", r1)
}

就会出现 cannot borrow s as mutable more than once at a time 编译错误。这个规则使得引用的可变性只能以一种受到严格限制的方式使用。但另一方面,遵循这条限制性规则可以在编译时避免数据竞争。即不允许两个或两个以上的指针同时访问(且至少有一个指针会写入数据)同一空间
数据竞争会导致未定义的行为,往往难以在运行时进行跟踪,也就使得出现的 bug 更加难以被诊断和修复。

不能在拥有不可变引用的同时创建可变引用。编译时会报出 cannot borrow s as immutable because it is also borrowed as mutable 错误。

1
2
3
4
5
6
7
fn main() {
let mut s = String::from("hello");
let r1 = &s; // 没问题
let r2 = &s; // 没问题
let r3 = &mut s; // 错误
println!("{}", r2)
}

不能在拥有不可变引用的同时创建可变引用,但可以同时存在多个不可变引用。因为对数据的只读操作不会影响到其他读取数据的用户。

Rust 编译器可以为用户提早(编译时而不是运行时)暴露那些潜在的 bug,并且明确指出出现问题的地方。用户就不再需要去追踪调试为何数据会在运行时发生了非预期的变化

参考资料

The Rust Programming Language