文中部分内容转译至Rust官方文档
所有权(O)与垃圾回收(GC)
垃圾回收
许多编程语言,例如 Python、JavaScript、Java 和 Go,采用垃圾回收机制来管理内存。垃圾回收器作为运行时程序,负责识别并释放不再使用的内存。回收器会定期扫描内存,查找无法从程序中访问的数据。这些数据通常被称为“垃圾”,因为它们不再被程序使用,且占用了宝贵的内存空间。
常见的算法包括:
- 引用计数: 每个数据项都维护一个引用计数器,该计数器记录指向该数据项的引用数量。当引用计数器为零时,表示该数据项不再被引用,可以被回收。
- 标记-清除: 垃圾回收器首先标记所有可达的内存对象,然后清除未被标记的对象。可达的对象是指可以直接或间接地从程序根部访问的对象。
垃圾回收的主要优点是它可以简化程序员的工作,无需手动释放内存。这可以减少开发人员的编码负担,并降低出现内存泄漏的风险。
然而,垃圾回收也存在一些缺点,例如可能会影响程序性能。垃圾回收需要额外的开销,具体取决于使用的算法。频繁的垃圾回收可能会导致程序出现卡顿现象。
另一个不太明显的缺点是垃圾收集可能是不可预测的,我们使用Python语言的Document
类作为示例代码:
class Document:
def __init__(self, words: List[str]):
"""Create a new document"""
self.words = words
def add_word(self, word: str):
"""Add a word to the document"""
self.words.append(word)
def get_words(self) -> List[str]:
"""Get a list of all the words in the document"""
return self.words
这里有一个我们可以使用这个Document
类的示例,它创建了一个文档d
,将其复制到一个新的文档d2
,然后修改d2
。
words = ["Hello"]
d = Document(words)
d2 = Document(d.get_words())
d2.add_word("world")
考虑这个例子的两个关键问题:
- 这个
["Hello"]
数组是何时被释放的? 这个程序创建了三个指向同一数组的指针。变量words
、d
和d2
都包含一个指向在堆上分配的words
数组的指针。因此 Python 只有在所有三个变量都不在作用域内时才会释放这个words
数组。如果我们仅仅通过阅读源代码通常很难预测数据将何时被垃圾收集。 d
的内容是什么? 因为d2
包含一个指向与d
相同的words
数组的指针,那么d2.add_word("world")
也会改变文档d
。因此,在这个例子中,d
中的单词是["Hello", "world"]
。这是因为d.get_words()
返回对d
中words
数组的可变引用。普遍的隐式可变引用很容易在数据结构可以泄漏其内部时导致不可预测的错误[1]。这里,对d2
的更改可以改变d
并不是意图中的行为。
这个问题并非只存在于 Python —— 在 C#、Java、Javascript 等语言中也会遇到类似的行为。实际上,大多数编程语言实际上都有指针的概念。问题就在于语言是如何向程序员展示指针的。垃圾收集使得很难看出哪个变量指向哪些数据。例如d.get_words()
生成了指向d
内部数据的指针这一点并不明显。
所有权
Rust 语言采用了与垃圾回收截然不同的内存管理机制:所有权机制。所有权机制的核心思想是每个数据值都由一个所有者负责。所有者控制数据的生命周期,并在超出作用域时自动释放内存。
所有权机制的主要优点是它可以避免未定义行为,例如使用已释放的内存。这使得 Rust 程序更加安全可靠。
相比之下,Rust 的所有权模型将指针置于中心位置。我们可以通过将Document
类型转换为 Rust 数据结构来看到这一点。通常我们会使用struct
,但还没有学习到那里,所以只使用类型别名,开看下面的Rust例子:
fn main() {
type Document = Vec<String>;
fn new_document(words: Vec<String>) -> Document {
words
}
fn add_word(this: &mut Document, word: String) {
this.push(word);
}
fn get_words(this: &Document) -> &[String] {
this.as_slice()
}
}
Rust 代码实现 与 Python 代码在几个关键方面不同:
- 函数
new_document
消耗了输入向量words
的所有权。这意味着Document
拥有单词向量。当拥有它的Document
离开作用域时,单词向量将可预测地被释放。 - 函数
add_word
需要一个可变引用&mut Document
才能改变一个文档。它还消耗了输入单词的所有权,意味着没有人可以改变文档的单独单词。 - 函数
get_words
返回对文档中字符串的一个明确的不可变引用。从这个单词向量创建一个新文档的唯一方式是深拷贝其内容,如以下代码所示:
fn main() {
let words = vec!["hello".to_string()];
let d = new_document(words);
// .to_vec() 通过克隆每个字符串将 &[String] 转换为 Vec<String>
let words_copy = get_words(&d).to_vec();
let mut d2 = new_document(words_copy);
add_word(&mut d2, "world".to_string());
// 对`d2`的修改不会影响`d`
assert!(!get_words(&d).contains(&"world".into()));
}
如果 Rust 不是你的第一门语言,那么你已经有了处理内存和指针的经验! Rust 只是使这些概念变得明确。这有两个好处:
(1)通过避免垃圾收集来提高运行时性能:
(2)以及通过防止数据意外“泄露”来提高可预测性;
运行时的所有权
Rust在运行时如何使用内存:
- Rust在栈帧中分配局部变量,这些栈帧在函数调用时分配,在调用结束时释放。
- 局部变量可以保存数据(如数字、布尔值、元组等)或指针。
- 指针可以通过盒子(在堆上拥有数据的指针)或引用(非拥有指针)创建。
下图解释了每个概念在运行时的样子:
fn main() {
let mut a_num = 0;
inner(&mut a_num); // L2
}
fn inner(x: &mut i32) {
let another_num = 1;
let a_stack_ref = &another_num;
let a_box = Box::new(2);
let a_box_stack_ref = &a_box;
let a_box_heap_ref = &*a_box; // L1
*x += 5;
}
切片则是一种特殊的引用,它们引用内存中连续的数据序列。下图表说明了切片如何引用字符串中的字符子序列:
fn main() {
let s = String::from("abcdefg");
let s_slice = &s[2..5]; // L1
}
编译时的所有权
Rust 跟踪每个变量的 let mut
,那么它缺少
fn main() {
let n = 0;
// «———— n ⤴ +R - +O
n += 1;
}
一个变量的权限可以在移动或借用时被改变。移动一个具有非复制类型(如Box<T>
或String
)的变量需要
fn main() {
let s = String::from("Hello world");
// «———— s ⤴ +R -- +O
consume_a_string(s);
// «———————————————————— s ↦ -R -- -O
println!("{s}"); // 移动后无法读取 `s`
}
fn consume_a_string(_s: String) {
// more...
}
借用一个变量(创建一个指向它的引用)会暂时移除一些变量的权限。不可变的借用会创建一个不可变的引用,并禁止借用的数据被改变或移动。例如,打印一个不可变引用是可以的:
fn main() {
let mut s = String::from("Hello");
// «———— s ⤴ +R +W +O
let s_ref = &s;
// «——————————————————————— s → R -W -O
// s_ref ⤴ +R -- +O
// *s_ref ⤴ +R -- --
println!("{s_ref}");
// «—————————————————— s ↺ R +W +O
// s_ref ↴ -R -- -O
// *s_ref ↴ -R -- --
println!("{s}");
// «—————————————————————— s ↴ -R -W -O
}
但是修改一个不可变引用是不允许的:
fn main() {
let mut s = String::from("Hello");
// «———— s ⤴ +R +W +O
let s_ref = &s;
// «———————————————————————— *s_ref ⤴ +R -- --
// s → R -W -O
// s_ref ⤴ +R -- +O
s_ref.push_str(" world");
println!("{s}");
}
且修改被不可变引用的数据也是不允许的:
fn main() {
let mut s = String::from("Hello");
// «———— s ⤴ +R +W +O
let s_ref = &s;
// «——————————————————————— s → R -W -O
// s_ref ⤴ +R -- +O
// *s_ref ⤴ +R -- --
s.push_str(" world");
println!("{s_ref}");
}
想将数据从引用中移出也是不允许的:
fn main() {
let mut s = String::from("Hello");
// «———— s ⤴ +R +W +O
let s_ref = &s;
// «——————————————————————— *s_ref ⤴ +R -- --
// s → R -W -O
// s_ref ⤴ +R -- +O
let s2 = *s_ref;
println!("{s}");
}
可变借用会创建一个可变引用,这会禁止对借用的数据进行读取、写入或移动。例如,修改一个可变引用是允许的:
fn main() {
let mut s = String::from("Hello");
// «———— s ⤴ +R +W +O
let s_ref = &mut s;
// «——————————————————————— s → R -W -O
// s_ref ⤴ +R -- +O
// *s_ref ⤴ +R +W --
s_ref.push_str(" world");
// «————————————————— s ↺ R -W -O
// s_ref ↴ -R -- -O
// *s_ref ↴ -R -W --
println!("{s}");
// «——————————————————————— s ↴ -R -W -O
}
但是访问已经被可变借用的数据是不允许的:
fn main() {
let mut s = String::from("Hello");
// «———— s ⤴ +R +W +O
let s_ref = &mut s;
// «——————————————————————— s → -R -W -O
// s_ref ⤴ +R -- +O
// *s_ref ⤴ +R +W --
println!("{s}");
s_ref.push_str(" world");
}
将编译时与运行时的所有权联接起来
Rust 的权限设计旨在防止未定义的行为。例如,一种未定义的行为是在释放后使用内存,即释放的内存被读取或写入。不可变借用会移除
fn main() {
let mut v = vec![1, 2, 3];
let n = &v[0]; // L1
v.push(4); // L2
println!("{n}"); // L3
}
另一种未定义的行为是双重释放,即一块内存被释放两次。针对非复制数据的引用的解引用没有
fn main() {
let v = vec![1, 2, 3];
let v_ref: &Vec<i32> = &v;
let v2 = *v_ref; // L1
drop(v2); // L2
drop(v); // L3
}