什么是所有权?

所有权是一种确保 Rust 程序安全的机制。为了理解所有权,我们首先需要理解是什么使 Rust 程序安全(或不安全)。

安全是不存在未定义行为

让我们从一个例子开始。这个程序可以安全执行

fn read(y: bool) {
    if y {
        println!("y is true!");
    }
}

fn main() {
    let x = true;
    read(x);
}

我们可以通过在定义 x 之前移动 read 的调用来使这个程序不安全地执行

fn read(y: bool) {
    if y {
        println!("y is true!");
    }
}

fn main() {
    read(x); // oh no! x isn't defined!
    let x = true;
}

注意:在本章中,我们将使用许多无法编译的代码示例。如果不确定程序是否应该编译,请务必留意问号螃蟹。

第二个程序是不安全的,因为 read(x) 期望 x 具有 bool 类型的值,但 x 还没有值。

当这样的程序被解释器执行时,在定义 x 之前读取它会引发异常,例如 Python 的 NameError 或 Javascript 的 ReferenceError。但是异常是有代价的。每次解释型程序读取变量时,解释器都必须检查该变量是否已定义。

Rust 的目标是将程序编译成高效的二进制文件,这些二进制文件需要尽可能少的运行时检查。因此,Rust 不在运行时检查变量在使用前是否已定义。相反,Rust 在编译时进行检查。如果您尝试编译不安全的程序,您将收到此错误

error[E0425]: cannot find value `x` in this scope
 --> src/main.rs:8:10
  |
8 |     read(x); // oh no! x isn't defined!
  |          ^ not found in this scope

您可能直觉上认为 Rust 确保变量在使用前已定义是件好事。但是为什么呢?为了证明这个规则是合理的,我们必须问:如果 Rust 允许被拒绝的程序编译,会发生什么?

让我们首先考虑一下安全程序是如何编译和执行的。在使用 x86 架构处理器的计算机上,Rust 为安全程序中的 main 函数生成以下汇编代码(在此处查看完整的汇编代码

main:
    ; ...
    mov     edi, 1
    call    read
    ; ...

注意:如果您不熟悉汇编代码,那也没关系!本节仅包含一些汇编示例,向您展示 Rust 在底层是如何实际工作的。您通常不需要了解汇编来理解 Rust。

这段汇编代码将执行以下操作:

  • 将数字 1(代表 true)移动到一个名为 edi 的 “寄存器”(一种汇编变量)中。
  • 调用 read 函数,该函数期望其第一个参数 y 位于 edi 寄存器中。

如果允许不安全的函数编译,其汇编代码可能如下所示:

main:
    ; ...
    call    read
    mov     edi, 1    ; mov is after call
    ; ...

这个程序是不安全的,因为 read 将期望 edi 是一个布尔值,即数字 01。但是 edi 可以是任何值:21000x1337BEEF。当 read 想要将它的参数 y 用于任何目的时,它将立即导致未定义行为!

Rust 没有指定如果您尝试运行 if y { .. }y 不是 truefalse 时会发生什么。该行为,或执行指令后发生的事情,是未定义的。可能会发生一些事情,例如:

  • 代码执行而没有崩溃,并且没有人注意到问题。
  • 代码立即崩溃,原因是 段错误 或其他类型的操作系统错误。
  • 代码执行而没有崩溃,直到恶意行为者创建正确的输入来删除您的生产数据库、覆盖您的备份并偷走您的午餐钱。

Rust 的一个基本目标是确保您的程序永远不会出现未定义行为。 这就是 “安全” 的含义。对于可以直接访问内存的底层程序来说,未定义行为尤其危险。大约 70% 的报告的安全漏洞 是由内存损坏引起的,而内存损坏是未定义行为的一种形式。

Rust 的第二个目标是在编译时而不是运行时阻止未定义行为。这个目标有两个动机:

  1. 在编译时捕获错误意味着在生产环境中避免这些错误,从而提高软件的可靠性。
  2. 在编译时捕获错误意味着减少对这些错误的运行时检查,从而提高软件的性能。

Rust 无法阻止所有错误。如果应用程序公开了一个公共且未经身份验证的 /delete-production-database 端点,那么恶意行为者不需要可疑的 if 语句来删除数据库。但是,与使用保护较少的语言相比,Rust 的保护措施仍然可能使程序更安全,例如 Google 的 Android 团队 发现的那样。

所有权作为内存安全的准则

由于安全是不存在未定义行为,并且由于所有权是关于安全的,那么我们需要从所有权防止的未定义行为方面来理解所有权。《Rust 参考手册》维护了一个 “被认为是未定义的行为” 的长列表。现在,我们将重点关注一个类别:内存操作。

内存是在程序执行期间存储数据的空间。有很多种思考内存的方式:

  • 如果您不熟悉系统编程,您可能会从较高的层面思考内存,例如 “内存是我计算机中的 RAM” 或 “如果我加载太多数据,内存就会耗尽”。
  • 如果您熟悉系统编程,您可能会从较低的层面思考内存,例如 “内存是字节数组” 或 “内存是我从 malloc 获取的指针”。

这两种内存模型都是有效的,但它们不是思考 Rust 如何工作的有用方式。高级模型过于抽象,无法解释 Rust 的工作原理。例如,您将需要理解指针的概念。低级模型过于具体,无法解释 Rust 的工作原理。例如,Rust 不允许您将内存解释为字节数组。

Rust 提供了一种特定的内存思考方式。所有权是一种在这种思考方式中安全使用内存的准则。本章的其余部分将解释 Rust 的内存模型。

变量存在于栈中

这是一个类似于您在 3.3 节中看到的程序,它定义了一个数字 n 并对 n 调用函数 plus_one。程序下方是一种新的图表。此图表可视化了程序在三个标记点执行期间的内存内容。

变量存在于中。帧是从变量到单个作用域(例如函数)内的值的映射。例如:

  • 位置 L1 的 main 帧持有 n = 5
  • L2 位置的 plus_one 帧持有 x = 5
  • L3 位置的 main 帧持有 n = 5; y = 6

帧被组织成一个,栈中存放着当前调用的函数。例如,在 L2 位置,main 的帧位于被调用函数 plus_one 的帧之上。函数返回后,Rust 会释放该函数的帧。(释放也称为释放drop,我们交替使用这些术语。)帧的这个序列称为栈,因为最近添加的帧始终是下一个被释放的帧。

注意: 此内存模型并未完全描述 Rust 的实际工作原理!正如我们之前在汇编代码中看到的那样,Rust 编译器可能会将 nx 放入寄存器而不是栈帧中。但这种区别是实现细节。它不应改变您对 Rust 安全性的理解,因此我们可以专注于更简单的仅帧变量的情况。

当表达式读取变量时,变量的值会从其在栈帧中的槽中复制出来。例如,如果我们运行这个程序:

a 的值被复制到 b 中,即使在更改 b 之后,a 也保持不变。

Box 存在于堆中

但是,复制数据可能会占用大量内存。例如,这是一个略有不同的程序。此程序复制一个包含 100 万个元素的数组:

观察到将 a 复制到 b 中会导致 main 帧包含 200 万个元素。

为了在不复制数据的情况下传递对数据的访问权限,Rust 使用指针。指针是一个描述内存位置的值。指针指向的值称为其被指物。创建指针的一种常见方法是在中分配内存。堆是一个独立的内存区域,数据可以在其中无限期地存在。堆数据不绑定到特定的栈帧。Rust 提供了一个名为 Box 的构造,用于将数据放在堆上。例如,我们可以像这样将包含一百万个元素的数组包装在 Box::new 中:

观察到现在,一次只有一个数组。在 L1 位置,a 的值是一个指针(用带箭头的点表示),指向堆内的数组。语句 let b = a 将指针从 a 复制到 b,但指向的数据不会被复制。请注意,a 现在显示为灰色,因为它已被移动——我们稍后将看到这意味着什么。

Rust 不允许手动内存管理

内存管理是分配内存和释放内存的过程。换句话说,它是找到未使用的内存,并在不再使用时返回该内存的过程。栈帧由 Rust 自动管理。当调用函数时,Rust 会为被调用的函数分配一个栈帧。当调用结束时,Rust 会释放栈帧。

正如我们在上面看到的,堆数据是在调用 Box::new(..) 时分配的。但是堆数据何时被释放呢?假设 Rust 有一个 free() 函数来释放堆分配。假设 Rust 允许程序员在他们想要的时候调用 free。这种 “手动” 内存管理很容易导致错误。例如,我们可以读取指向已释放内存的指针:

注意: 您可能想知道我们是如何执行这个无法编译的 Rust 程序的。我们使用 特殊工具 来模拟 Rust,就好像借用检查器被禁用一样,用于教育目的。这样我们就可以回答假设性问题,例如:如果 Rust 允许这个不安全的程序编译会怎样?

在这里,我们在堆上分配一个数组。然后我们调用 free(b),它会释放 b 的堆内存。因此,b 的值是指向无效内存的指针,我们将其表示为 “⦻” 图标。尚未发生未定义行为!程序在 L2 位置仍然是安全的。拥有一个无效指针不一定是问题。

未定义行为发生在当我们尝试通过读取 b[0]使用指针时。这将尝试访问无效内存,这可能会导致程序崩溃。或者更糟的是,它可能不会崩溃并返回任意数据。因此,此程序是不安全的

Rust 不允许程序手动释放内存。此策略避免了上面显示的各种未定义行为。

Box 的所有者管理释放

相反,Rust 自动释放 box 的堆内存。以下是关于 Rust 释放 box 策略的几乎正确的描述:

Box 释放原则(几乎正确): 如果变量绑定到一个 box,当 Rust 释放变量的帧时,Rust 也会释放 box 的堆内存。

例如,让我们跟踪一个分配和释放 box 的程序:

在 L1 位置,在调用 make_and_drop 之前,内存状态只是 main 的栈帧。然后在 L2 位置,在调用 make_and_drop 时,a_box 指向堆上的 5。一旦 make_and_drop 完成,Rust 就会释放其栈帧。make_and_drop 包含变量 a_box,因此 Rust 也会释放 a_box 中的堆数据。因此,堆在 L3 位置是空的。

box 的堆内存已成功管理。但是,如果我们滥用此系统会怎样?回到我们之前的示例,当我们把两个变量绑定到一个 box 时会发生什么?

fn main() {
let a = Box::new([0; 1_000_000]);
let b = a;
}

boxed 数组现在已绑定到 ab。根据我们 “几乎正确” 的原则,Rust 将尝试代表这两个变量两次释放 box 的堆内存。这也是未定义行为!

为了避免这种情况,我们最终得到了所有权。当 a 绑定到 Box::new([0; 1_000_000]) 时,我们说 a 拥有该 box。语句 let b = a 将 box 的所有权从 a 移动b。鉴于这些概念,Rust 释放 box 的策略更准确地描述为:

Box 释放原则(完全正确): 如果变量拥有一个 box,当 Rust 释放变量的帧时,Rust 也会释放 box 的堆内存。

在上面的示例中,b 拥有 boxed 数组。因此,当作用域结束时,Rust 只代表 b 释放 box 一次,而不是 a

集合使用 Box

Box 被 Rust 数据结构1(如 VecStringHashMap)用于保存可变数量的元素。例如,这是一个创建、移动和修改字符串的程序:

这个程序更复杂,所以请确保您遵循每个步骤:

  1. 在 L1 位置,字符串 “Ferris” 已在堆上分配。它由 first 拥有。
  2. 在 L2 位置,函数 add_suffix(first) 已被调用。这会将字符串的所有权从 first 移动到 name。字符串数据不会被复制,但指向数据的指针会被复制。
  3. 在 L3 位置,函数 name.push_str(" Jr.") 调整了字符串的堆分配大小。这做了三件事。首先,它创建了一个新的更大的分配。其次,它将 “Ferris Jr.” 写入新的分配。第三,它释放了原始的堆内存。first 现在指向已释放的内存。
  4. 在 L4 位置,add_suffix 的帧已消失。此函数返回了 name,并将字符串的所有权转移到 full

变量在移动后不能再使用

字符串程序有助于说明所有权的关键安全原则。想象一下,在调用 add_suffix 之后在 main 中使用了 first。我们可以模拟这样一个程序,并查看由此产生的未定义行为:

调用 add_suffix 后,first 指向已释放的内存。因此,在 println! 中读取 first 将违反内存安全(未定义行为)。请记住:first 指向已释放的内存不是问题。问题是我们试图在 first 变为无效后使用它。

值得庆幸的是,Rust 会拒绝编译此程序,并给出以下错误:

error[E0382]: borrow of moved value: `first`
 --> test.rs:4:35
  |
2 |     let first = String::from("Ferris");
  |         ----- move occurs because `first` has type `String`, which does not implement the `Copy` trait
3 |     let full = add_suffix(first);
  |                           ----- value moved here
4 |     println!("{full}, originally {first}"); // first is now used here
  |                                   ^^^^^ value borrowed here after move

让我们逐步分析这个错误。Rust 说我们在第 3 行调用 add_suffix(first) 时移动了 first。该错误澄清说,first 被移动是因为它的类型为 String,而 String 没有实现 Copy。我们稍后将讨论 Copy——简而言之,如果您使用 i32 而不是 String,您就不会收到此错误。最后,该错误说我们在 first 被移动后使用了它(它被 “借用” 了,我们将在下一节讨论)。

因此,如果您移动了一个变量,Rust 将阻止您稍后使用该变量。更概括地说,编译器将强制执行此原则:

移动堆数据原则: 如果变量 x 将堆数据的所有权移动到另一个变量 y,则 x 在移动后不能再使用。

现在您应该开始看到所有权、移动和安全性之间的关系。移动堆数据的所有权避免了从读取已释放内存中产生的未定义行为。

克隆避免移动

避免移动数据的一种方法是使用 .clone() 方法克隆它。例如,我们可以使用克隆来修复上一个程序中的安全问题:

观察到在 L1 位置,first_clone 没有 “浅” 复制 first 中的指针,而是 “深” 复制了字符串数据到一个新的堆分配中。因此,在 L2 位置,当 first_cloneadd_suffix 移动和失效时,原始的 first 变量保持不变。继续使用 first 是安全的。

总结

所有权主要是一种堆管理的准则:2

  • 所有堆数据必须由恰好一个变量拥有。
  • Rust 在其所有者超出作用域后释放堆数据。
  • 所有权可以通过移动来转移,移动发生在赋值和函数调用时。
  • 堆数据只能通过其当前所有者访问,而不能通过以前的所有者访问。

我们不仅强调了 Rust 的保护措施如何工作,而且还强调了它们为什么避免未定义行为。当您收到来自 Rust 编译器的错误消息时,如果您不理解 Rust 为什么抱怨,很容易感到沮丧。这些概念基础应该可以帮助您解释 Rust 的错误消息。它们还应该帮助您设计更符合 Rust 风格的 API。

1

这些数据结构不使用字面意义上的 Box 类型。例如,String 是使用 Vec 实现的,而 Vec 是使用 RawVec 而不是 Box 实现的。但是像 RawVec 这样的类型仍然类似于 box:它们拥有堆中的内存。

2

在另一种意义上,所有权是一种指针管理的准则。但是我们还没有描述如何创建指向堆之外任何地方的指针。我们将在下一节中介绍。