什么是所有权?
所有权是一种确保 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
是一个布尔值,即数字 0
或 1
。但是 edi
可以是任何值:2
、100
、0x1337BEEF
。当 read
想要将它的参数 y
用于任何目的时,它将立即导致未定义行为!
Rust 没有指定如果您尝试运行 if y { .. }
而 y
不是 true
或 false
时会发生什么。该行为,或执行指令后发生的事情,是未定义的。可能会发生一些事情,例如:
- 代码执行而没有崩溃,并且没有人注意到问题。
- 代码立即崩溃,原因是 段错误 或其他类型的操作系统错误。
- 代码执行而没有崩溃,直到恶意行为者创建正确的输入来删除您的生产数据库、覆盖您的备份并偷走您的午餐钱。
Rust 的一个基本目标是确保您的程序永远不会出现未定义行为。 这就是 “安全” 的含义。对于可以直接访问内存的底层程序来说,未定义行为尤其危险。大约 70% 的报告的安全漏洞 是由内存损坏引起的,而内存损坏是未定义行为的一种形式。
Rust 的第二个目标是在编译时而不是运行时阻止未定义行为。这个目标有两个动机:
- 在编译时捕获错误意味着在生产环境中避免这些错误,从而提高软件的可靠性。
- 在编译时捕获错误意味着减少对这些错误的运行时检查,从而提高软件的性能。
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 编译器可能会将
n
或x
放入寄存器而不是栈帧中。但这种区别是实现细节。它不应改变您对 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 数组现在已绑定到 a
和 b
。根据我们 “几乎正确” 的原则,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(如 Vec
、String
和 HashMap
)用于保存可变数量的元素。例如,这是一个创建、移动和修改字符串的程序:
这个程序更复杂,所以请确保您遵循每个步骤:
- 在 L1 位置,字符串 “Ferris” 已在堆上分配。它由
first
拥有。 - 在 L2 位置,函数
add_suffix(first)
已被调用。这会将字符串的所有权从first
移动到name
。字符串数据不会被复制,但指向数据的指针会被复制。 - 在 L3 位置,函数
name.push_str(" Jr.")
调整了字符串的堆分配大小。这做了三件事。首先,它创建了一个新的更大的分配。其次,它将 “Ferris Jr.” 写入新的分配。第三,它释放了原始的堆内存。first
现在指向已释放的内存。 - 在 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_clone
被 add_suffix
移动和失效时,原始的 first
变量保持不变。继续使用 first
是安全的。
总结
所有权主要是一种堆管理的准则:2
- 所有堆数据必须由恰好一个变量拥有。
- Rust 在其所有者超出作用域后释放堆数据。
- 所有权可以通过移动来转移,移动发生在赋值和函数调用时。
- 堆数据只能通过其当前所有者访问,而不能通过以前的所有者访问。
我们不仅强调了 Rust 的保护措施如何工作,而且还强调了它们为什么避免未定义行为。当您收到来自 Rust 编译器的错误消息时,如果您不理解 Rust 为什么抱怨,很容易感到沮丧。这些概念基础应该可以帮助您解释 Rust 的错误消息。它们还应该帮助您设计更符合 Rust 风格的 API。
这些数据结构不使用字面意义上的 Box
类型。例如,String
是使用 Vec
实现的,而 Vec
是使用 RawVec
而不是 Box
实现的。但是像 RawVec
这样的类型仍然类似于 box:它们拥有堆中的内存。
在另一种意义上,所有权是一种指针管理的准则。但是我们还没有描述如何创建指向堆之外任何地方的指针。我们将在下一节中介绍。