什么是所有权?
所有权是一组管理 Rust 程序如何管理内存的规则。 所有程序在运行时都必须管理它们使用计算机内存的方式。 有些语言具有垃圾回收机制,可以在程序运行时定期查找不再使用的内存;而在其他语言中,程序员必须显式地分配和释放内存。Rust 采用第三种方法:通过所有权系统管理内存,该系统有一组编译器检查的规则。如果违反了任何规则,程序将无法编译。所有权的任何特性都不会在程序运行时减慢程序的速度。
因为所有权对于许多程序员来说是一个新概念,确实需要一些时间来适应。好消息是,随着您对 Rust 和所有权系统规则的了解越来越深入,您会发现编写安全且高效的代码变得更加自然。继续加油!
当你理解了所有权,你将为理解使 Rust 独特的特性打下坚实的基础。在本章中,你将通过一些专注于非常常见的数据结构:字符串的示例来学习所有权。
栈和堆
许多编程语言并不要求你经常考虑栈和堆。但在像 Rust 这样的系统编程语言中,值是在栈上还是在堆上会影响语言的行为以及为什么你必须做出某些决定。所有权的某些部分将在本章后面与栈和堆的关系中描述,因此这里是一个简要的解释作为准备。
堆栈和堆都是在运行时可供您的代码使用的内存部分,但它们的结构方式不同。堆栈按照获取值的顺序存储值,并以相反的顺序移除值。这被称为后进先出。想象一下一叠盘子:当您添加更多盘子时,您将它们放在堆的顶部,当您需要一个盘子时,您从顶部取下一个。从中间或底部添加或移除盘子效果不佳!添加数据称为推入堆栈,移除数据称为从堆栈弹出。堆栈上存储的所有数据都必须具有已知的固定大小。编译时大小未知或可能变化的数据必须存储在堆上。
堆内存的组织性较差:当你将数据放入堆中时,你会请求一定数量的空间。内存分配器会在堆中找到一个足够大的空位,将其标记为正在使用,并返回一个指针,即该位置的地址。这个过程被称为在堆上分配,有时简称为分配(将值推入栈中不被视为分配)。因为指向堆的指针是一个已知的固定大小,你可以将指针存储在栈上,但当你需要实际数据时,必须跟随指针。想象一下在餐厅就座的情景。当你进入时,你会说明你团队中的人数,服务员会找到一个适合所有人的空桌并引导你到那里。如果你团队中有人晚到,他们可以询问你坐在哪里来找到你。
将数据压入栈比在堆上分配内存更快,因为分配器不需要寻找存储新数据的位置;该位置总是在栈的顶部。相比之下,在堆上分配空间需要更多的工作,因为分配器必须首先找到一个足够大的空间来容纳数据,然后进行簿记以准备下一次分配。
访问堆中的数据比访问栈中的数据要慢,因为您需要通过指针来访问。现代处理器在内存中跳转较少时速度更快。继续这个比喻,考虑一家餐厅的服务员从多个桌子接订单的情况。最有效的方法是在移动到下一个桌子之前先收集完一个桌子的所有订单。从A桌接一个订单,然后从B桌接一个订单,再回到A桌接一个订单,然后再回到B桌接一个订单,这样的过程会慢得多。同样地,处理器在处理靠近其他数据的数据(如在栈上)时,比处理远离其他数据的数据(如在堆上)时工作得更好。
当您的代码调用函数时,传递给函数的值(包括可能指向堆上数据的指针)和函数的局部变量会被推入栈中。当函数结束时,这些值会被从栈中弹出。
跟踪代码的哪些部分正在使用堆上的哪些数据,最小化堆上的重复数据量,以及清理堆上未使用的数据以防止空间耗尽,这些都是所有权要解决的问题。一旦你理解了所有权,你就不会经常需要考虑栈和堆,但知道所有权的主要目的是管理堆数据,这有助于解释它为何如此运作。
所有权规则
首先,让我们看一下所有权规则。在我们通过示例来说明这些规则时,请记住这些规则:
- Rust 中的每个值都有一个 所有者。
- 一次只能有一个所有者。
- 当所有者超出作用域时,值将被丢弃。
变量作用域
现在我们已经过了基本的 Rust 语法,所以在示例中将不再包含所有的 fn main() {
代码,因此如果你在跟着做,请确保将以下
示例手动放在一个 main
函数中。因此,我们的示例将更加简洁,让我们能够专注于实际的细节而不是
样板代码。
作为所有权的第一个例子,我们将看看一些变量的作用域。作用域是程序中一个项目有效的范围。考虑以下变量:
#![allow(unused)] fn main() { let s = "hello"; }
变量s
引用一个字符串字面量,其中字符串的值被硬编码到我们程序的文本中。该变量从声明点开始有效,直到当前作用域的结束。清单4-1显示了一个带有注释的程序,注释说明了变量s
有效的范围。
换句话说,这里有两个重要的时间点:
- 当
s
进入作用域时,它是有效的。 - 它保持有效,直到它超出作用域。
在这一点上,作用域与变量何时有效之间的关系与其他编程语言相似。现在我们将通过引入String
类型来加深这一理解。
字符串类型 String
为了说明所有权的规则,我们需要一个比我们在第 3 章“数据类型”部分讨论的更复杂的数据类型。之前讨论的类型是已知大小的,可以存储在栈上,并且当它们的作用域结束时可以从栈中弹出,如果另一部分代码需要在不同的作用域中使用相同的值,可以快速且简单地复制以创建一个新的独立实例。但我们现在想查看存储在堆上的数据,并探讨 Rust 是如何知道何时清理这些数据的,String
类型是一个很好的例子。
我们将专注于与所有权相关的String
部分。这些方面也适用于其他复杂数据类型,无论是标准库提供的还是你创建的。我们将在第8章中更深入地讨论String
。
我们已经看到了字符串字面量,其中字符串值被硬编码到我们的程序中。字符串字面量很方便,但它们并不适用于我们可能想要使用文本的每种情况。一个原因是它们是不可变的。另一个原因是并非每个字符串值在我们编写代码时都能知道:例如,如果我们想接收用户输入并存储它怎么办?对于这些情况,Rust 有第二种字符串类型,String
。这种类型管理在堆上分配的数据,因此能够存储在编译时对我们未知的文本量。你可以使用 from
函数从字符串字面量创建一个 String
,如下所示:
#![allow(unused)] fn main() { let s = String::from("hello"); }
双冒号 ::
操作符允许我们将这个特定的 from
函数命名空间化在 String
类型下,而不是使用类似 string_from
这样的名称。我们将在第 5 章的 “方法语法” 部分更详细地讨论这种语法,以及在第 7 章的 “模块树中引用项的路径” 部分讨论使用模块进行命名空间化。
这种字符串可以被修改:
fn main() { let mut s = String::from("hello"); s.push_str(", world!"); // push_str() appends a literal to a String println!("{s}"); // This will print `hello, world!` }
所以,这里有什么区别?为什么 String
可以被修改但字面量不能?区别在于这两种类型处理内存的方式。
内存和分配
在字符串字面量的情况下,我们在编译时就知道其内容,因此文本会被直接硬编码到最终的可执行文件中。这就是为什么字符串字面量快速且高效的原因。但这些特性仅来源于字符串字面量的不可变性。不幸的是,我们不能为每个在编译时大小未知且在程序运行时大小可能会改变的文本块在二进制文件中分配一块内存。
使用 String
类型,为了支持可变的、可增长的文本片段,我们需要在堆上分配一块编译时未知大小的内存来存储内容。这意味着:
- 内存必须在运行时从内存分配器请求。
- 我们需要一种在使用完
String
后将内存返回给分配器的方法。
那第一部分是由我们完成的:当我们调用String::from
时,其实现会请求所需的内存。这在编程语言中几乎是普遍的。
然而,第二部分是不同的。在具有垃圾收集器 (GC)的语言中,GC 会跟踪并清理不再使用的内存,我们不需要考虑这个问题。在大多数没有 GC 的语言中,我们需要识别何时内存不再被使用,并调用代码显式地释放它,就像我们请求它时所做的那样。正确地做到这一点历来是一个困难的编程问题。如果我们忘记了,我们会浪费内存。如果我们做得太早,我们会有一个无效的变量。如果我们做两次,那也是一个错误。我们需要将一个allocate
与一个free
精确配对。
Rust 采取了不同的路径:一旦拥有内存的变量超出作用域,内存就会自动返回。这是使用 String
而不是字符串字面量的 Listing 4-1 作用域示例的版本:
fn main() { { let s = String::from("hello"); // s is valid from this point forward // do stuff with s } // this scope is now over, and s is no // longer valid }
有一个自然的时机,我们可以将String
所需的内存返回给分配器:当s
超出作用域时。当一个变量超出作用域时,Rust会为我们调用一个特殊函数。这个函数被称为drop
,这是String
的作者可以放置释放内存代码的地方。Rust会在闭合的大括号处自动调用drop
。
注意:在 C++ 中,这种在项目生命周期结束时释放资源的模式有时被称为 资源获取即初始化 (RAII)。如果您使用过 RAII 模式,Rust 中的 drop
函数对您来说应该很熟悉。
这种模式对 Rust 代码的编写方式有着深远的影响。现在看来可能很简单,但在更复杂的情况下,当我们希望多个变量使用我们在堆上分配的数据时,代码的行为可能会出乎意料。现在让我们探讨一些这样的情况。
Variables and Data Interacting with Move
多个变量可以以不同的方式与相同的数据交互在 Rust 中。 让我们看一个使用整数的例子,如清单 4-2 所示。
我们可以猜到这是在做什么:“将值5
绑定到x
;然后复制x
中的值并将其绑定到y
。”现在我们有两个变量,x
和y
,它们都等于5
。确实如此,因为整数是具有已知固定大小的简单值,这两个5
值被推送到栈上。
现在让我们看看String
版本:
fn main() { let s1 = String::from("hello"); let s2 = s1; }
这看起来非常相似,所以我们可能会认为它的运作方式也相同:也就是说,第二行会复制 s1
中的值并将其绑定到 s2
。但事实并非如此。
查看图4-1,了解String
在幕后发生了什么。一个String
由三部分组成,如左所示:一个指向存储字符串内容的内存的指针、一个长度和一个容量。这组数据存储在栈上。右边是堆上存储内容的内存。
长度是指 String
的内容当前正在使用的内存量,以字节为单位。容量是指 String
从分配器那里获得的总内存量,以字节为单位。长度和容量之间的差异很重要,但在这个上下文中不重要,所以现在可以忽略容量。
当我们把 s1
赋值给 s2
时,String
数据被复制,这意味着我们复制了栈上的指针、长度和容量。我们不会复制指针所指向的堆上的数据。换句话说,内存中的数据表示如图 4-2 所示。
该表示形式不像图4-3所示,如果Rust也复制堆数据,内存看起来就会是这样。如果Rust这样做,当堆上的数据量很大时,操作s2 = s1
在运行时性能方面可能会非常昂贵。
早些时候,我们说过当一个变量超出作用域时,Rust 会自动调用 drop
函数并清理该变量的堆内存。但是图 4-2 显示两个数据指针指向同一位置。这是一个问题:当 s2
和 s1
超出作用域时,它们都会尝试释放同一块内存。这被称为 双重释放 错误,是我们之前提到的内存安全漏洞之一。两次释放内存可能导致内存损坏,这可能会导致安全漏洞。
为确保内存安全,在 let s2 = s1;
这一行之后,Rust 认为 s1
不再有效。因此,当 s1
超出作用域时,Rust 不需要释放任何东西。看看在创建 s2
之后尝试使用 s1
会发生什么;它不会工作:
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{s1}, world!");
}
您会遇到这样的错误,因为 Rust 防止您使用已失效的引用:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:15
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{s1}, world!");
| ^^^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
|
3 | let s2 = s1.clone();
| ++++++++
For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
如果您在使用其他语言时听说过浅拷贝和深拷贝这些术语,那么复制指针、长度和容量而不复制数据的概念听起来就像是进行浅拷贝。但由于 Rust 还会使第一个变量失效,因此这不被称为浅拷贝,而是被称为移动。在这个例子中,我们会说s1
被移动到了s2
。所以,实际发生的情况如图4-4所示。
这解决了我们的问题!只有 s2
有效,当它超出作用域时,它将独自释放内存,我们就完成了。
此外,这里还隐含了一个设计选择:Rust 永远不会自动创建数据的“深层”副本。因此,任何自动复制都可以认为在运行时性能上是廉价的。
Scope and Assignment
这种关系的反面也适用于作用域、所有权和通过 drop
函数释放内存。当你将一个全新的值赋给一个已存在的变量时,Rust 会立即调用 drop
并释放原始值的内存。例如,考虑以下代码:
fn main() { let mut s = String::from("hello"); s = String::from("ahoy"); println!("{s}, world!"); }
我们最初声明一个变量s
并将其绑定到一个值为"hello"
的String
。然后我们立即创建一个值为"ahoy"
的新String
并将其赋值给s
。此时,根本没有东西引用堆上的原始值。
原始字符串因此立即超出作用域。Rust 将在它上面运行 drop
函数,其内存将立即被释放。当我们最后打印值时,它将是 "ahoy, world!"
。
Variables and Data Interacting with Clone
如果我们确实想要深度复制String
的堆数据,而不仅仅是栈数据,我们可以使用一个常见的方法叫做clone
。我们将在第5章讨论方法语法,但因为方法是许多编程语言中的常见特性,你可能之前已经见过它们。
这里是一个 clone
方法的示例:
fn main() { let s1 = String::from("hello"); let s2 = s1.clone(); println!("s1 = {s1}, s2 = {s2}"); }
这完全没有问题,并且显式地产生了图4-3中所示的行为,其中堆数据确实被复制了。
当你看到对 clone
的调用时,你知道某些任意的代码正在执行,并且这些代码可能是昂贵的。这是一个视觉指示,表明有些不同的事情正在发生。
Stack-Only Data: Copy
还有我们尚未讨论的一个细节。这段使用整数的代码——部分在清单4-2中展示——可以工作并且是有效的:
fn main() { let x = 5; let y = x; println!("x = {x}, y = {y}"); }
但这段代码似乎与我们刚刚学到的内容相矛盾:我们没有调用 clone
,但 x
仍然有效,并且没有被移动到 y
中。
原因是像整数这样的类型在编译时大小已知,因此完全存储在栈上,所以实际值的副本制作起来很快。这意味着我们没有理由阻止在创建变量y
之后x
仍然有效。换句话说,在这里深拷贝和浅拷贝之间没有区别,因此调用clone
不会与通常的浅拷贝有什么不同,我们可以省略它。
Rust 有一个称为 Copy
特性的特殊注解,我们可以将其放在存储在栈上的类型上,例如整数(我们将在 第 10 章 中进一步讨论特性)。如果一个类型实现了 Copy
特性,使用它的变量不会移动,而是会被简单地复制,因此在赋值给另一个变量后仍然有效。
Rust 不会让我们用 Copy
注解一个类型,如果该类型或其任何部分已经实现了 Drop
特性。如果类型在值超出作用域时需要发生一些特殊的事情,而我们又给该类型添加了 Copy
注解,我们将会得到一个编译时错误。要了解如何给你的类型添加 Copy
注解以实现特性,请参见 “可派生特性” 附录 C。
所以,哪些类型实现了 Copy
特性?您可以检查给定类型的文档以确认,但作为一个通用规则,任何一组简单的标量值都可以实现 Copy
,而任何需要分配或是一些形式的资源的类型都不能实现 Copy
。以下是一些实现了 Copy
的类型:
- 所有整数类型,如
u32
。 - 布尔类型,
bool
,具有值true
和false
。 - 所有浮点类型,如
f64
。 - 字符类型,
char
。 - 元组,如果它们只包含也实现了
Copy
的类型。例如,(i32, i32)
实现了Copy
,但(i32, String)
没有。
所有权和函数
将值传递给函数的机制与将值赋给变量时的机制相似。将变量传递给函数会移动或复制,就像赋值一样。清单 4-3 有一个带有注释的示例,显示了变量何时进入和退出作用域。
如果我们试图在调用 takes_ownership
之后使用 s
,Rust 会在编译时抛出错误。这些静态检查可以保护我们免于出错。尝试向 main
添加使用 s
和 x
的代码,看看你可以在哪里使用它们,以及所有权规则在哪里阻止你这样做。
返回值和作用域
返回值也可以转移所有权。清单 4-4 显示了一个返回某些值的函数示例,其注释与清单 4-3 中的类似。
变量的所有权每次遵循相同的模式:将值赋给另一个变量时,会移动该值。当包含堆上数据的变量超出作用域时,除非数据的所有权已移交给另一个变量,否则该值将由drop
清理。
虽然这可以工作,但每次函数都获取所有权然后再返回所有权有点繁琐。如果我们希望让函数使用一个值但不获取其所有权怎么办?我们传递的任何内容如果还想再次使用,也需要被传回,这相当麻烦,此外,我们可能还希望返回函数体产生的任何数据。
Rust 确实允许我们使用元组返回多个值,如列表 4-5 所示。
但这对于一个应该常见的概念来说,太过繁琐和费力。幸运的是,Rust 有一个特性,可以在不转移所有权的情况下使用值,称为引用。