什么是所有权?
所有权 是一组规则,用于管理 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 采取了不同的路径:一旦拥有它的变量超出作用域,内存就会自动返回。这是我们列表 4-1 中作用域示例的版本,使用了 String
而不是字符串字面量
在 s
超出作用域时,我们有一个自然的点可以将 String
需要的内存返回给分配器。当变量超出作用域时,Rust 会为我们调用一个特殊函数。这个函数被称为 drop
,它是 String
的作者可以放置代码以返回内存的地方。Rust 在右花括号处自动调用 drop
。
注意:在 C++ 中,这种在项目生命周期结束时释放资源的模式有时称为资源获取即初始化 (RAII)。如果您使用过 RAII 模式,Rust 中的 drop
函数对您来说会很熟悉。
这种模式对 Rust 代码的编写方式产生了深远的影响。现在看来可能很简单,但是在我们想要让多个变量使用我们在堆上分配的数据的更复杂情况下,代码的行为可能会出乎意料。现在让我们探索其中的一些情况。
变量和数据与移动的交互
在 Rust 中,多个变量可以以不同的方式与相同的数据交互。让我们看一个在列表 4-2 中使用整数的示例。
x
的整数值赋值给 y
我们可能可以猜到这是在做什么:“将值 5
绑定到 x
;然后复制 x
中的值并将其绑定到 y
。”我们现在有两个变量 x
和 y
,它们都等于 5
。这确实是正在发生的事情,因为整数是具有已知固定大小的简单值,并且这两个 5
值被压入栈。
现在让我们看一下 String
版本
这看起来非常相似,因此我们可能会认为它的工作方式是相同的:也就是说,第二行将复制 s1
中的值并将其绑定到 s2
。但这并不是完全发生的事情。
查看图 4-1 以了解 String
在底层发生了什么。String
由三个部分组成,显示在左侧:指向保存字符串内容的内存的指针、长度和容量。这组数据存储在栈上。右侧是堆上保存内容的内存。
图 4-1:绑定到 s1
的值 "hello"
的 String
在内存中的表示
长度是 String
的内容当前正在使用的内存量(以字节为单位)。容量是 String
从分配器接收到的内存总量(以字节为单位)。长度和容量之间的差异很重要,但在此上下文中并不重要,因此目前,忽略容量就可以了。
当我们把 s1
赋值给 s2
时,String
数据会被复制,这意味着我们复制栈上的指针、长度和容量。我们不复制指针指向的堆上的数据。换句话说,内存中的数据表示如图 4-2 所示。
图 4-2:变量 s2
在内存中的表示,它具有 s1
的指针、长度和容量的副本
表示不像图 4-3 那样,如果 Rust 也复制了堆数据,则内存看起来会像那样。如果 Rust 这样做,如果堆上的数据很大,则操作 s2 = s1
在运行时性能方面可能会非常昂贵。
图 4-3:如果 Rust 也复制了堆数据,s2 = s1
可能做的另一种可能性
早些时候,我们说过,当变量超出作用域时,Rust 会自动调用 drop
函数并清理该变量的堆内存。但是图 4-2 显示两个数据指针都指向相同的位置。这是一个问题:当 s2
和 s1
超出作用域时,它们都将尝试释放相同的内存。这被称为双重释放错误,是我们之前提到的内存安全漏洞之一。两次释放内存可能导致内存损坏,这可能会导致安全漏洞。
为了确保内存安全,在行 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 所示。
图 4-4:s1
失效后内存中的表示
这解决了我们的问题!只有 s2
有效,当它超出作用域时,它将单独释放内存,我们就完成了。
此外,这还暗示了一个设计选择:Rust 永远不会自动创建数据的“深”拷贝。因此,可以假定任何自动复制在运行时性能方面都是廉价的。
作用域和赋值
对于作用域、所有权以及通过 drop
函数释放内存之间的关系,反过来也是正确的。当您为现有变量分配一个全新的值时,Rust 将立即调用 drop
并释放原始值的内存。例如,考虑以下代码
我们最初声明一个变量 s
并将其绑定到值为 "hello"
的 String
。然后我们立即创建一个新的 String
,值为 "ahoy"
,并将其赋值给 s
。此时,没有任何东西引用堆上的原始值。
图 4-5:初始值被完全替换后内存中的表示。
因此,原始字符串立即超出作用域。Rust 将在其上运行 drop
函数,其内存将立即释放。当我们打印末尾的值时,它将是 "ahoy, world!"
。
变量和数据与 Clone 的交互
如果我们确实想深度复制 String
的堆数据,而不仅仅是栈数据,我们可以使用一个名为 clone
的常用方法。我们将在第 5 章中讨论方法语法,但是由于方法是许多编程语言中的常见功能,因此您可能以前见过它们。
这是 clone
方法在操作中的示例
这可以正常工作,并显式生成图 4-3 中所示的行为,其中堆数据确实被复制了。
当您看到对 clone
的调用时,您就知道正在执行一些任意代码,并且该代码可能很昂贵。这是一个视觉指示器,表明正在发生一些不同的事情。
仅栈数据:Copy
还有另一个我们尚未讨论的细节。使用整数的这段代码(其中一部分在列表 4-2 中显示)可以工作并且有效
但是这段代码似乎与我们刚刚学到的内容相矛盾:我们没有调用 clone
,但是 x
仍然有效,并且没有移动到 y
中。
原因是像整数这样的在编译时具有已知大小的类型完全存储在栈上,因此实际值的副本可以快速制作。这意味着我们没有理由阻止在创建变量 y
后 x
仍然有效。换句话说,这里深拷贝和浅拷贝之间没有区别,因此调用 clone
与通常的浅拷贝没有任何不同,我们可以将其省略。
Rust 有一个名为 Copy
trait 的特殊注解,我们可以将其放在存储在栈上的类型上,就像整数一样(我们将在 第 10 章中详细讨论 trait)。如果类型实现了 Copy
trait,则使用它的变量不会移动,而是被简单地复制,从而在赋值给另一个变量后仍然有效。
如果类型或其任何部分实现了 Drop
trait,Rust 将不允许我们使用 Copy
注解类型。如果类型在值超出作用域时需要发生一些特殊的事情,并且我们向该类型添加 Copy
注解,我们将收到编译时错误。要了解如何将 Copy
注解添加到您的类型以实现 trait,请参阅 附录 C 中的“可派生 Trait”。
那么,哪些类型实现了 Copy
trait 呢?您可以查看给定类型的文档以确保,但作为一般规则,任何简单标量值的组都可以实现 Copy
,并且任何需要分配或某种形式的资源的类型都不能实现 Copy
。以下是一些实现 Copy
的类型
- 所有整数类型,例如
u32
。 - 布尔类型
bool
,具有值true
和false
。 - 所有浮点类型,例如
f64
。 - 字符类型
char
。 - 元组,如果它们仅包含也实现
Copy
的类型。例如,(i32, i32)
实现了Copy
,但(i32, String)
没有实现。
所有权和函数
将值传递给函数的机制与将值赋值给变量的机制类似。将变量传递给函数将移动或复制,就像赋值一样。列表 4-3 有一个示例,其中一些注释显示了变量进入和超出作用域的位置。
fn main() {
let s = String::from("hello"); // s comes into scope
takes_ownership(s); // s's value moves into the function...
// ... and so is no longer valid here
let x = 5; // x comes into scope
makes_copy(x); // because i32 implements the Copy trait,
// x does NOT move into the function,
println!("{}", x); // so it's okay to use x afterward
} // Here, x goes out of scope, then s. But because s's value was moved, nothing
// special happens.
fn takes_ownership(some_string: String) { // some_string comes into scope
println!("{some_string}");
} // Here, some_string goes out of scope and `drop` is called. The backing
// memory is freed.
fn makes_copy(some_integer: i32) { // some_integer comes into scope
println!("{some_integer}");
} // Here, some_integer goes out of scope. Nothing special happens.
如果我们尝试在调用 takes_ownership
之后使用 s
,Rust 将抛出编译时错误。这些静态检查保护我们免受错误的影响。尝试在 main
中添加使用 s
和 x
的代码,以查看您可以在哪里使用它们,以及所有权规则在何处阻止您这样做。
返回值和作用域
返回值也可以转移所有权。列表 4-4 显示了一个返回某些值的函数的示例,其注释与列表 4-3 中的注释类似。
fn main() {
let s1 = gives_ownership(); // gives_ownership moves its return
// value into s1
let s2 = String::from("hello"); // s2 comes into scope
let s3 = takes_and_gives_back(s2); // s2 is moved into
// takes_and_gives_back, which also
// moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
// happens. s1 goes out of scope and is dropped.
fn gives_ownership() -> String { // gives_ownership will move its
// return value into the function
// that calls it
let some_string = String::from("yours"); // some_string comes into scope
some_string // some_string is returned and
// moves out to the calling
// function
}
// This function takes a String and returns one
fn takes_and_gives_back(a_string: String) -> String { // a_string comes into
// scope
a_string // a_string is returned and moves out to the calling function
}
变量的所有权每次都遵循相同的模式:将值赋值给另一个变量会移动它。当包含堆上数据的变量超出作用域时,除非数据的所有权已移动到另一个变量,否则该值将被 drop
清理。
虽然这可行,但是对于每个函数都获取所有权然后返回所有权有点乏味。如果我们想让一个函数使用一个值但不获取所有权怎么办?如果我们想再次使用它,除了我们可能想要返回的函数体产生的任何数据之外,我们传入的任何东西也需要传递回去,这真是太烦人了。
Rust 确实允许我们使用元组返回多个值,如列表 4-5 所示。
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("The length of '{s2}' is {len}.");
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len(); // len() returns the length of a String
(s, length)
}
但这对于一个应该很常见的概念来说,仪式感太强,工作量也太大。幸运的是,Rust 有一个在不转移所有权的情况下使用值的功能,称为引用。