什么是所有权?
所有权是一组规则,用于管理 Rust 程序如何管理内存。所有程序在运行时都必须管理它们使用计算机内存的方式。有些语言具有垃圾回收机制,在程序运行时定期查找不再使用的内存;而在其他语言中,程序员必须显式分配和释放内存。Rust 采用了第三种方法:通过所有权系统管理内存,编译器会检查一组规则。如果违反了任何规则,程序将无法编译。所有权的特性在程序运行时不会减慢其速度。
由于所有权对许多程序员来说是一个新概念,确实需要一些时间来适应。好消息是,随着你对 Rust 及其所有权系统规则的了解加深,你会发现自然而然地编写出安全高效的代码会变得越来越容易。坚持下去!
当你理解了所有权,你就为理解使 Rust 独特的功能打下了坚实的基础。在本章中,你将通过一些专注于非常常见的数据结构——字符串的示例来学习所有权。
栈与堆
许多编程语言并不要求你经常考虑栈和堆的问题。但在像 Rust 这样的系统编程语言中,值位于栈上还是堆上会影响语言的行为方式以及为何需要做出某些决策。本章后续部分将结合栈和堆来阐述所有权的相关内容,因此这里先做一个简短的预备说明。
栈和堆都是代码在运行时可供使用的内存部分,但它们的结构方式不同。栈按照获取值的顺序存储值,并以相反的顺序移除值。这被称为后进先出。想象一下一叠盘子:当你添加更多盘子时,你会把它们放在堆的顶部,当你需要一个盘子时,你会从顶部取一个。从中间或底部添加或移除盘子效果不会那么好!添加数据被称为压入栈,移除数据被称为弹出栈。存储在栈上的所有数据必须具有已知的固定大小。在编译时大小未知或大小可能变化的数据必须存储在堆上。
堆的组织性较差:当你将数据放入堆中时,你需要请求一定大小的空间。内存分配器会在堆中找到一个足够大的空闲位置,将其标记为已使用,并返回一个指针,即该位置的地址。这个过程称为在堆上分配内存,有时简称为分配(将值压入栈不被视为分配)。由于指向堆的指针是已知的固定大小,你可以将指针存储在栈上,但当你需要实际数据时,必须跟随指针。想象一下在餐厅就座的情景。当你进入时,你会说明你们团队的人数,服务员会找到一个适合所有人的空桌并带你过去。如果你们团队中有人迟到,他们可以询问你们被安排在哪里以找到你们。
将数据压入栈比在堆上分配内存更快,因为分配器无需寻找存储新数据的位置;该位置始终位于栈顶。相比之下,在堆上分配空间需要更多工作,因为分配器必须先找到足够大的空间来容纳数据,然后进行簿记以准备下一次分配。
访问堆中的数据比访问栈中的数据要慢,因为必须通过指针来定位。当代处理器在内存中跳跃较少时速度更快。继续类比,想象餐厅服务员从多张桌子接收订单。最有效的方式是在移动到下一张桌子之前,先收集完一张桌子的所有订单。如果从桌子 A 取一个订单,然后从桌子 B 取一个订单,接着再从 A 取一个,再从 B 取一个,这个过程会慢得多。同理,处理器处理彼此靠近的数据(如栈中的数据)比处理相距较远的数据(如堆中的数据)能更高效地完成工作。
当你的代码调用一个函数时,传递给函数的值(可能包括指向堆上数据的指针)以及函数的局部变量会被压入栈中。当函数结束时,这些值会从栈中弹出。
跟踪代码的哪些部分在使用堆上的哪些数据、最小化堆上重复数据的数量以及清理堆上未使用的数据以避免空间耗尽,这些都是所有权所要解决的问题。一旦理解了所有权,你就不需要经常考虑栈和堆了,但了解所有权的主要目的是管理堆数据,这有助于解释其工作原理。
所有权规则
首先,让我们来看看所有权规则。在我们通过示例说明这些规则时,请牢记这些规则:
- Rust 中的每个值都有一个所有者。
- 同一时间只能有一个所有者。
- 当所有者超出作用域时,该值将被丢弃。
变量作用域
既然我们已经掌握了基本的 Rust 语法,接下来的示例中将不再包含所有` fn main() {
`代码,因此如果你正在跟随学习,请确保手动将以下示例放入` main
`函数中。这样一来,我们的示例会更加简洁,让我们能够专注于实际细节,而非样板代码。
作为所有权的第一个示例,我们将查看一些变量的作用域。作用域是程序中项目的有效范围。以以下变量为例:
变量 s
引用了一个字符串字面量,其中字符串的值被硬编码到我们程序的文本中。该变量从声明处开始有效,直到当前作用域结束。清单 4-1 展示了一个带有注释的程序,注释标注了变量 s
在何处有效。
换句话说,这里有两个重要的时间点:
- 当
s
进入作用域时,它是有效的。 - 它在超出作用域之前保持有效。
此时,作用域与变量有效性的关系与其他编程语言类似。现在,我们将在此基础上引入 String
类型。
String
类型
为了说明所有权的规则,我们需要一个比第 3 章“数据类型”部分所涉及的类型更复杂的数据类型。之前介绍的类型都是已知大小的,可以存储在栈上,并在其作用域结束时从栈上弹出,如果需要在不同作用域中使用相同的值,可以快速且简单地复制以创建一个新的独立实例。但我们想要研究存储在堆上的数据,并探讨 Rust 如何知道何时清理这些数据,而 String
类型就是一个很好的例子。
我们将重点介绍与所有权相关的 String
部分。这些方面同样适用于其他复杂数据类型,无论它们是由标准库提供的还是由您创建的。我们将在第 8 章更深入地讨论 String
。
我们已经见过字符串字面值,即字符串值被硬编码到程序中。字符串字面值很方便,但它们并不适用于所有我们希望使用文本的场景。一个原因是它们是不可变的。另一个原因是并非所有字符串值在编写代码时都是已知的:例如,如果我们想获取用户输入并存储它呢?对于这些情况,Rust 有第二种字符串类型, String
。这种类型管理堆上分配的数据,因此能够存储编译时未知大小的文本。你可以使用 from
函数从字符串字面值创建一个 String
,如下所示:
双冒号 ` ::
` 运算符允许我们将这个特定的 ` from
` 函数命名空间放在 ` String
` 类型下,而不是使用类似 ` string_from
` 的名称。我们将在第 5 章的“方法语法”部分以及第 7 章的“模块树中引用项的路径”中讨论模块命名空间时,进一步讨论这种语法。
这种字符串可以被改变:
那么,这里的区别是什么?为什么 String
可以被改变而字面量却不能?区别在于这两种类型处理内存的方式。
内存与分配
对于字符串字面量,我们在编译时就知道其内容,因此文本会直接硬编码到最终的可执行文件中。这就是为什么字符串字面量既快速又高效。但这些特性仅源于字符串字面量的不可变性。遗憾的是,我们无法为每一段在编译时大小未知且可能在程序运行时大小发生变化的文本,在二进制文件中放入一块内存。
使用 String
类型时,为了支持可变、可增长的文本片段,我们需要在堆上分配一定量的内存,这在编译时是未知的,用于存储内容。这意味着:
- 内存必须在运行时从内存分配器请求。
- 我们需要一种方法,在使用完我们的
String
后,将这部分内存返回给分配器。
第一部分由我们完成:当我们调用 String::from
时,其实现会请求所需的内存。这在编程语言中几乎是普遍存在的。
然而,第二部分有所不同。在拥有垃圾回收器(GC)的语言中,GC 会跟踪并清理不再使用的内存,我们无需为此操心。而在大多数没有 GC 的语言中,识别内存何时不再使用并调用代码显式释放它,就如同我们申请内存时那样,是我们的责任。正确做到这一点历来是编程中的一大难题。如果我们忘记释放,就会浪费内存;如果过早释放,变量将无效;如果重复释放,同样会引发错误。我们必须确保每次 allocate
与 free
精确配对。
Rust 采取了不同的路径:一旦拥有内存的变量离开作用域,内存就会自动被回收。这是我们作用域示例的一个版本,使用 ` String
` 而不是字符串字面量:
有一个自然的时机可以将我们的` String
`所需的内存返回给分配器:当` s
`超出作用域时。当一个变量超出作用域时,Rust 会为我们调用一个特殊的函数。这个函数叫做` drop
`,在这里` String
`的作者可以放置返回内存的代码。Rust 在右大括号处自动调用` drop
`。
注意:在 C++中,这种在对象生命周期结束时释放资源的模式有时被称为资源获取即初始化(RAII)。如果你使用过 RAII 模式,Rust 中的` drop
`函数会让你感到熟悉。
这种模式对 Rust 代码的编写方式有着深远的影响。现在看起来可能很简单,但在更复杂的情况下,当我们希望多个变量使用堆上分配的数据时,代码的行为可能会出乎意料。现在让我们来探讨一些这样的情况。
变量和数据与 Move 交互
在 Rust 中,多个变量可以以不同方式与同一数据交互。让我们通过一个使用整数的例子来看看,如代码清单 4-2 所示。
我们大概可以猜出这段代码在做什么:“将值 5
绑定到 x
;然后复制 x
中的值并将其绑定到 y
。”现在我们有两个变量, x
和 y
,它们都等于 5
。这确实是正在发生的事情,因为整数是具有已知固定大小的简单值,这两个 5
值被压入栈中。
现在让我们看看 String
版本:
这看起来非常相似,因此我们可能会假设它的工作方式相同:也就是说,第二行会复制 s1
中的值并将其绑定到 s2
。但实际情况并非如此。
查看图 4-1 以了解 String
背后的运作机制。 String
由三部分组成,如左侧所示:指向存储字符串内容内存的指针、长度和容量。这组数据存储在栈上。右侧则是堆上存储内容的内存。
长度表示 String
内容当前使用的内存量,以字节为单位。容量是 String
从分配器获得的总内存量,以字节为单位。长度与容量之间的差异很重要,但在此上下文中无关紧要,因此目前可以忽略容量。
当我们把 s1
赋值给 s2
时, String
的数据被复制,这意味着我们复制了栈上的指针、长度和容量。我们不会复制指针所指向的堆上的数据。换句话说,内存中的数据表示如图 4-2 所示。
该表示方式与图 4-3 不同,后者展示了如果 Rust 也复制堆数据时内存的样子。如果 Rust 这样做,当堆上的数据量很大时,操作 s2 = s1
在运行时性能方面可能会非常昂贵。
早些时候,我们提到当一个变量离开作用域时,Rust 会自动调用 `drop` 函数并清理该变量的堆内存。但图 4-2 显示两个数据指针指向了同一位置。这就带来了一个问题:当 `s1` 和 `s2` 离开作用域时,它们都会尝试释放相同的内存。这就是所谓的二次释放(double free)错误,也是我们之前提到的内存安全性 bug 之一。两次释放内存会导致内存损坏,从而可能引发安全漏洞。
为确保内存安全,在行 let s2 = s1;
之后,Rust 认为 s1
不再有效。因此,当 s1
超出作用域时,Rust 无需释放任何内容。尝试在 s2
创建后使用 s1
时,看看会发生什么;它将无法工作:
你会得到这样的错误,因为 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
并立即释放原值的内存。例如,考虑以下代码:
我们最初声明一个变量 s
并将其绑定到一个值为 "hello"
的 String
。然后我们立即创建一个值为 "ahoy"
的新 String
并将其赋值给 s
。此时,堆上的原始值已没有任何引用。
原始字符串因此立即超出作用域。Rust 会对其运行 drop
函数,其内存将立即被释放。当我们最后打印该值时,它将是 "ahoy, world!"
。
变量和数据与克隆交互
如果我们确实想要深度复制 String
的堆数据,而不仅仅是栈数据,我们可以使用一种称为 clone
的常见方法。我们将在第 5 章讨论方法语法,但由于方法是许多编程语言中的常见特性,你可能之前已经见过它们。
以下是 clone
方法的一个示例:
这完全可行,并明确产生了如图 4-3 所示的行为,其中堆数据确实被复制了。
当你看到对 clone
的调用时,你就知道正在执行一些任意代码,这些代码可能代价高昂。这是一个视觉指示器,表明正在发生一些不同的事情。
仅栈数据:复制
我们还没有讨论过另一个问题。这段使用整数的代码——部分内容如代码清单 4-2 所示——是有效的:
但这段代码似乎与我们刚刚学到的内容相矛盾:我们没有调用 clone
,但 x
仍然有效,并且没有被移动到 y
。
原因是像整数这样在编译时已知大小的类型完全存储在栈上,因此复制实际值非常快。这意味着我们没有理由在创建变量 y
后阻止 x
保持有效。换句话说,这里深拷贝和浅拷贝没有区别,因此调用 clone
与通常的浅拷贝不会有任何不同,我们可以省略它。
Rust 有一个特殊的注解叫做 ` Copy
` trait,我们可以将其用于存储在栈上的类型,就像整数那样(我们将在第 10 章详细讨论 trait)。如果一个类型实现了 ` Copy
` trait,使用它的变量不会移动,而是简单地复制,使得它们在赋值给另一个变量后仍然有效。
Rust 不会让我们在类型或其任何部分实现了 ` Drop
` 特性的情况下,用 ` Copy
` 来注解类型。如果类型需要在值离开作用域时发生一些特殊操作,而我们为该类型添加了 ` 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 提供了一种无需转移所有权即可使用值的特性,称为引用。