所有权回顾

本章介绍了许多新概念,如所有权、借用和 slice。如果您不熟悉系统编程,本章还介绍了内存分配、栈与堆、指针和未定义行为等新概念。在我们继续学习 Rust 的其余部分之前,让我们先停下来喘口气。我们将回顾和练习本章的关键概念。

所有权与垃圾回收

为了将所有权置于上下文中,我们应该谈谈垃圾回收。大多数编程语言使用垃圾回收器来管理内存,例如 Python、Javascript、Java 和 Go。垃圾回收器在运行时与正在运行的程序(至少是追踪回收器)相邻工作。回收器扫描内存以查找不再使用的数据——也就是说,运行的程序无法再从函数局部变量访问该数据。然后,回收器会释放未使用的内存以供以后使用。

垃圾回收的关键好处是它可以避免未定义行为(例如使用已释放的内存),这在 C 或 C++ 中可能会发生。垃圾回收还避免了需要复杂的类型系统来检查未定义行为,就像在 Rust 中一样。然而,垃圾回收也有一些缺点。一个明显的缺点是性能,因为垃圾回收会产生频繁的小开销(对于引用计数,如 Python 和 Swift)或不频繁的大开销(对于追踪,如所有其他 GC 语言)。

但另一个不太明显的缺点是,垃圾回收可能是不可预测的。为了说明这一点,假设我们正在实现一个 Document 类型,它表示一个可变的单词列表。我们可以用一种带有垃圾回收的语言(如 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")

考虑关于这个示例的两个关键问题

  1. words 数组何时被释放? 这个程序创建了三个指向同一数组的指针。变量 wordsdd2 都包含指向堆上分配的 words 数组的指针。因此,只有当所有三个变量都超出作用域时,Python 才会释放 words 数组。更一般地说,仅通过阅读源代码通常很难预测数据将在何处被垃圾回收。

  2. 文档 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 的所有权模型将指针置于首要位置。我们可以通过将 Document 类型翻译成 Rust 数据结构来看到这一点。通常我们会使用 struct,但我们还没有介绍过,所以我们只使用类型别名

#![allow(unused)]
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 API 在几个关键方面与 Python API 不同

  • 函数 new_document 消耗输入向量 words 的所有权。这意味着 Document 拥有单词向量。当其拥有的 Document 超出作用域时,单词向量将被可预测地释放。

  • 函数 add_word 需要一个可变引用 &mut Document 才能修改文档。它还消耗输入 word 的所有权,这意味着没有人可以修改文档的单个单词。

  • 函数 get_words 返回对文档中字符串的显式不可变引用。从这个单词向量创建新文档的唯一方法是深度复制其内容,如下所示

fn main() {
    let words = vec!["hello".to_string()];
    let d = new_document(words);

    // .to_vec() converts &[String] to Vec<String> by cloning each string
    let words_copy = get_words(&d).to_vec();
    let mut d2 = new_document(words_copy);
    add_word(&mut d2, "world".to_string());

    // The modification to `d2` does not affect `d`
    assert!(!get_words(&d).contains(&"world".into()));
}

这个例子的目的是说:如果 Rust 不是您的第一语言,那么您已经有使用内存和指针的经验了!Rust 只是使这些概念显式化。这具有双重好处:(1) 通过避免垃圾回收来提高运行时性能,以及 (2) 通过防止意外的“泄漏”数据来提高可预测性。

所有权的概念

接下来,让我们回顾一下所有权的概念。这个回顾将是快速的——目标是提醒您相关的概念。如果您意识到您忘记或不理解某个概念,那么我们将链接到您可以回顾的相关章节。

运行时的所有权

我们将首先回顾 Rust 如何在运行时使用内存

  • Rust 在栈帧中分配局部变量,栈帧在函数被调用时分配,并在调用结束时释放。
  • 局部变量可以保存数据(如数字、布尔值、元组等)或指针。
  • 指针可以通过 box(拥有堆上数据的指针)或引用(非拥有指针)创建。

此图说明了每个概念在运行时的外观

回顾此图并确保您理解每个部分。例如,您应该能够回答

  • 为什么 a_box_stack_ref 指向栈,而 a_box_heap_ref 指向堆?
  • 为什么值 2 在 L2 时不再堆上?
  • 为什么 a_num 在 L2 时具有值 5

如果您想回顾 box,请重新阅读 第 4.1 章。如果您想回顾引用,请重新阅读 第 4.2 章。如果您想查看涉及 box 和引用的案例研究,请重新阅读 第 4.3 章

Slice 是一种特殊的引用,它引用内存中连续的数据序列。此图说明了 slice 如何引用字符串中字符的子序列

如果您想回顾 slice,请重新阅读 第 4.4 章

编译时的所有权

Rust 跟踪每个变量的 R(读)、W(写)和 O(拥有)权限。Rust 要求变量具有适当的权限才能执行给定的操作。作为一个基本示例,如果一个变量未声明为 let mut,则它缺少 W 权限,并且无法被修改

如果变量被移动借用,则可以更改变量的权限。移动一个不可复制类型(如 Box<T>String)的变量需要 RO 权限,并且移动会消除变量上的所有权限。该规则阻止了已移动变量的使用

如果您想回顾移动的工作原理,请重新阅读 第 4.1 章

借用变量(创建对其的引用)会暂时删除变量的一些权限。不可变借用创建不可变引用,并禁用被借用数据被修改或移动。例如,打印不可变引用是可以的

但是修改不可变引用是不可以的

修改不可变借用的数据是不可以的

并且从引用中移出数据是不可以的

可变借用创建可变引用,这会禁用被借用数据被读取、写入或移动。例如,修改可变引用是可以的

但是访问可变借用的数据是不可以的

如果您想回顾权限和引用,请重新阅读 第 4.2 章

连接编译时和运行时的所有权

Rust 的权限旨在防止未定义行为。例如,一种未定义行为是释放后使用,即读取或写入已释放的内存。不可变借用删除 W 权限以避免释放后使用,就像在这种情况下一样

另一种未定义行为是双重释放,即内存被释放两次。对不可复制数据的引用的解引用没有 O 权限来避免双重释放,就像在这种情况下一样

如果您想回顾未定义行为,请重新阅读 第 4.1 章第 4.3 章

所有权的其余部分

当我们介绍诸如结构体、枚举和 trait 等附加功能时,这些功能将与所有权进行特定的交互。本章为理解这些交互提供了必要的基础——内存、指针、未定义行为和权限的概念将帮助我们在以后的章节中讨论 Rust 更高级的部分。

如果您想检查您的理解程度,请不要忘记参加测验!

1

事实上,所有权类型的最初发明根本不是为了内存安全。它是为了防止在类似 Java 的语言中泄漏对数据结构内部的可变引用。如果您有兴趣了解更多关于所有权类型的历史,请查看论文 “Ownership Types for Flexible Alias Protection” (Clarke et al. 1998)。