# 什么是所有权
所有权是一组规则,用于控制 Rust 程序如何管理内存。所有程序都必须管理运行时使用计算机内存的方式。有些语言具有垃圾收集功能,可在程序运行时定期查找不再使用的内存;在其他语言中,程序员必须明确分配和释放内存。Rust 使用第三种方法:通过所有权系统管理内存,其中包含一组编译器检查的规则。如果违反任何规则,程序将无法编译。所有权的任何功能都不会在程序运行时减慢程序速度。
因为所有权对于许多程序员来说是一个新概念,所以需要一些时间来适应。好消息是,你对 Rust 和所有权系统规则越熟悉,你就会越容易自然而然地开发出安全高效的代码。坚持下去!
当你理解所有权时,你将为理解 Rust 的独特功能奠定坚实的基础。在本章中,你将通过一些专注于非常常见的数据结构的示例来学习所有权:字符串。
# 栈和堆
许多编程语言不需要你经常考虑堆栈和堆。但在 Rust 这样的系统编程语言中,值是在堆栈上还是在堆上会影响语言的行为方式以及你必须做出某些决定的原因。本章后面将结合堆栈和堆描述所有权的部分内容,因此这里先简要介绍一下。 堆栈和堆都是代码在运行时可以使用的内存的一部分,但它们的结构不同。堆栈按获取值的顺序存储值,按相反的顺序删除值。这称为后进先出。想象一叠盘子:当你添加更多盘子时,你把它们放在堆的顶部,当你需要一个盘子时,你就从顶部取一个。从中间或底部添加或移除盘子是行不通的!添加数据称为压入堆栈,移除数据称为弹出堆栈。存储在堆栈上的所有数据必须具有已知的固定大小。编译时大小未知或大小可能会改变的数据必须存储在堆上。 堆的组织性较差:将数据放入堆时,需要请求一定量的空间。内存分配器会在堆中找到一个足够大的空位,将其标记为正在使用,并返回一个指针,即该位置的地址。此过程称为在堆上分配,有时简称为分配(将值推送到堆栈上不被视为分配)。由于指向堆的指针是已知的固定大小,因此你可以将指针存储在堆栈上,但是当你想要实际数据时,必须遵循指针。想象一下在餐厅就座。当你进入时,请说明你的团队人数,主人会找到一张可容纳所有人的空桌子并带你到那里。如果你的团队中有人迟到了,他们可以询问你的座位在哪里来找到你。 推送到堆栈比在堆上分配更快,因为分配器永远不必搜索存储新数据的位置;该位置始终位于堆栈的顶部。相比之下,在堆上分配空间需要更多工作,因为分配器必须先找到足够大的空间来容纳数据,然后执行簿记(一种记账的形式)以准备下一次分配。 访问堆中的数据比访问堆栈中的数据要慢,因为你必须遵循指针才能到达那里。如果现代处理器在内存中跳转的次数更少,那么它们的速度会更快。继续类比,假设一个餐厅的服务员正在接受许多餐桌的订单。最有效的方法是先在一张桌子上取完所有订单,然后再去下一张桌子。从 A 桌取一份订单,然后从 B 桌取一份订单,然后再从 A 桌取一份订单,然后再从 B 桌取一份订单,这个过程会慢得多。同样,如果处理器处理靠近其他数据的数据(因为它在堆栈上),而不是远离其他数据的数据(因为它在堆上),那么它可以更好地完成工作。 当你的代码调用一个函数时,传递给函数的值(可能包括指向堆上数据的指针)和函数的局部变量会被推送到堆栈上。当函数结束时,这些值会从堆栈中弹出。 跟踪代码的哪些部分正在使用堆上的哪些数据、尽量减少堆上的重复数据量以及清理堆上未使用的数据以避免空间不足,这些都是所有权要解决的问题。一旦你理解了所有权,就不需要经常考虑堆栈和堆,但了解所有权的主要目的是管理堆数据,可以帮助解释它为什么以这种方式工作。
# 所有权规则
首先,让我们看一下所有权规则。在我们研究说明这些规则的示例时,请记住这些规则:
- Rust 中的每个值都有一个所有者。
- 一次只能有一个所有者。
- 当所有者超出范围时,该值将被删除。
# 变量作用域
现在我们已经了解了 Rust 的基本语法,我们不会将所有 fn main() {
代码包含在示例中,因此如果你继续学习,请确保手动将以下示例放入 main 函数中。因此,我们的示例将更加简洁,让我们专注于实际细节而不是样板代码。
作为所有权的第一个示例,我们将研究一些变量的作用域。作用域是程序中某项有效的范围。以以下变量为例:
let s = "hello";
变量 s 指的是字符串文字,其中字符串的值被硬编码到程序的文本中。变量从声明时起直到当前作用域结束都有效。示例 4-1 显示了带有注释的程序,注释了变量 s 的有效位置。
{
// s 在这里无效,它尚未声明
let s = "hello"; // s 从此时起有效
// 使用 s 执行操作
} // 此范围现已结束,s 不再有效
示例 4-1:变量及其有效范围
换句话说,这里有两个重要的时间点:
- 当 s 进入范围时,它是有效的。
- 它一直有效,直到它超出范围。
此时,范围与变量何时有效的关系与其他编程语言中的关系类似。现在我们将在此理解的基础上,介绍 String 类型。
# 字符串类型
为了说明所有权规则,我们需要一种比第 3 章“数据类型”部分中介绍的数据类型更复杂的数据类型。前面介绍的类型具有已知大小,可以存储在堆栈中,并在其范围结束时弹出堆栈,并且可以快速轻松地复制以创建新的独立实例,如果代码的另一部分需要在不同范围内使用相同的值。但我们想看看存储在堆上的数据,并探索 Rust 如何知道何时清理这些数据,String 类型就是一个很好的例子。
我们将集中讨论 String 中与所有权相关的部分。这些方面也适用于其他复杂数据类型,无论它们是由标准库提供的还是由你创建的。我们将在第 8 章中更深入地讨论字符串。
我们已经看到了字符串文字,其中字符串值被硬编码到我们的程序中。字符串文字很方便,但它们并不适合我们可能想要使用文本的所有情况。一个原因是它们是不可变的。另一个原因是,当我们编写代码时,并不是每个字符串值都是已知的:例如,如果我们想要获取用户输入并存储它怎么办?对于这些情况,Rust 有第二种字符串类型 String。此类型管理在堆上分配的数据,因此能够存储我们在编译时不知道的文本量。你可以使用 from 函数从字符串文字创建一个 String,如下所示:
let s = String::from("hello");
双冒号 ::
运算符允许我们将这个特定的 from 函数置于 String 类型下,而不是使用某种名称(如 string_from)。我们将在第 5 章的“方法语法”部分以及第 7 章“模块树中引用项目的路径”中讨论模块命名空间时进一步讨论此语法。
这种字符串可以改变:
let mut s = String::from("hello");
s.push_str(", world!"); // push_str() 将文字添加加到字符串
println!("{s}"); // 这将会打印 `hello, world!`
那么,这里有什么区别呢?为什么 String 可以改变而文字不能?区别在于这两种类型如何处理内存。
# 内存和分配
对于字符串文字,我们在编译时就知道其内容,因此文本直接硬编码到最终的可执行文件中。这就是字符串文字快速高效的原因。但这些属性仅来自字符串文字的不变性。不幸的是,我们无法将一块内存放入二进制文件中,用于编译时大小未知且在运行程序时大小可能会发生变化的每段文本。
对于 String 类型,为了支持可变且可增长的文本,我们需要在堆上分配一定量的内存(编译时未知)来保存内容。这意味着:
- 必须在运行时从内存分配器请求内存。
- 我们需要一种方法来在处理完字符串后将内存返回给分配器。
第一部分由我们完成:当我们调用 String::from
时,它的实现会请求所需的内存。这在编程语言中几乎是通用的。
但是,第二部分有所不同。在具有垃圾收集器 (GC) 的语言中,GC 会跟踪并清理不再使用的内存,我们无需考虑这一点。在大多数没有 GC 的语言中,我们有责任确定何时不再使用内存,并调用代码来显式释放它,就像我们请求它一样。正确地做到这一点历来是一个困难的编程问题。如果我们忘记了,我们就会浪费内存。如果我们做得太早,我们就会有一个无效变量。如果我们做了两次,那也是一个错误。我们需要将一个分配与一个释放配对。
Rust 采用了不同的方法:一旦拥有它的变量超出范围,内存就会自动返回。下面是示例 4-1 中范围示例的一个版本,使用 String 而不是字符串文字:
{
let s = String::from("hello"); // 从此时起 s 有效
// 使用 s 执行操作
} // 此范围现已结束,并且 s 不再有效
有一个理所当然的观点,我们可以将 String 所需的内存返回给分配器:当 s 超出范围时。当变量超出范围时,Rust 会为我们调用一个特殊函数。此函数称为 drop (opens new window),String 的作者可以在此放置返回内存的代码。Rust 在结束花括号处自动调用 drop。
注意:在 C++ 中,这种在项目生命周期结束时释放资源的模式有时称为资源获取即初始化 (RAII)。如果你使用过 RAII 模式,那么 Rust 中的 drop 函数对你来说应该很熟悉。
这种模式对 Rust 代码的编写方式有着深远的影响。现在看起来很简单,但当我们希望多个变量使用我们在堆上分配的数据时,代码的行为在更复杂的情况下可能会出乎意料。现在让我们探讨一下其中的一些情况。
# 变量和数据的移动
Rust 中,多个变量可以以不同的方式与同一数据交互。让我们看一个使用整数的示例,如示例 4-2 所示。
let x = 5;
let y = x;
示例 4-2:将变量 x 的整数值赋给 y
我们大概能猜出这是做什么的:“将值 5 绑定到 x;然后复制 x 中的值并将其绑定到 y。”现在我们有两个变量,x 和 y,都等于 5。这确实是正在发生的事情,因为整数是具有已知固定大小的简单值,这两个 5 值被推送到堆栈上。
现在让我们看看 String 版本:
let s1 = String::from("hello");
let s2 = s1;
这看起来非常相似,所以我们可以假设它的工作方式是相同的:也就是说,第二行将复制 s1 中的值并将其绑定到 s2。但事实并非如此。
看一下图 4-1,了解 String 内部发生了什么。String 由三部分组成,如左侧所示:指向保存字符串内容的内存的指针、长度和容量。这组数据存储在堆栈上。右侧是保存内容的堆上的内存。
图 4-1:将值“hello”绑定到 s1 的字符串在内存中的表示
长度是字符串内容当前使用的内存量(以字节为单位)。容量是字符串从分配器获得的内存总量(以字节为单位)。长度和容量之间的差异很重要,但在本例中并不重要,因此目前可以忽略容量。
当我们将 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 时会发生什么;它不起作用:
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 所示。
图 4-4:s1 失效后在内存中的表示
这解决了我们的问题!只有 s2 有效,当它超出范围时,它会独自释放内存,我们就完成了。
此外,还有一个设计选择暗示:Rust 永远不会自动创建数据的“深层”副本。因此,可以假设任何自动复制在运行时性能方面都是廉价的。
# 变量和数据的克隆
如果我们确实想深度复制字符串的堆数据,而不仅仅是堆栈数据,我们可以使用一种称为 clone 的常用方法。我们将在第 5 章中讨论方法语法,但由于方法是许多编程语言中的常见功能,因此你可能以前见过它们。
以下是 clone 方法的实际示例:
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {s1}, s2 = {s2}");
这会生效,并且明确产生了图 4-3 所示的行为,其中堆数据确实被复制了。
当你看到对 clone 的调用时,你就知道正在执行一些任意代码,并且该代码可能很昂贵。这是一个视觉指示器,表明发生了一些不同的事情。
# 仅堆栈数据:复制
还有另一个问题我们还没有讨论。这段使用整数的代码(其中一部分如示例 4-2 所示)可以工作并且有效:
let x = 5;
let y = x;
println!("x = {x}, y = {y}");
但这段代码似乎与我们刚刚学到的相矛盾:我们没有调用 clone,但 x 仍然有效,并没有被移入 y。
原因是,在编译时具有已知大小的类型(例如整数)完全存储在堆栈中,因此可以快速复制实际值。这意味着我们没有理由在创建变量 y 后阻止 x 有效。换句话说,这里深复制和浅复制没有区别,因此调用 clone 不会做与通常的浅复制不同的任何事情,我们可以将其省略。
Rust 有一个特殊的注解,称为 Copy
特征,我们可以将其放在存储在堆栈中的类型上,就像整数一样(我们将在第 10 章中详细讨论特征)。如果类型实现了 Copy 特征,则使用它的变量不会移动,而是被简单地复制,使得它们在分配给另一个变量后仍然有效。
如果类型或其任何部分已实现 Drop 特征,Rust 将不允许我们用 Copy 注释该类型。如果类型需要在值超出范围时发生一些特殊情况,并且我们向该类型添加 Copy 注释,我们将收到编译时错误。要了解如何将 Copy 注释添加到你的类型以实现该特征,请参阅附录 C 中的“可派生特征”。
那么,哪些类型实现了 Copy 特征?你可以查看给定类型的文档以确定,但一般来说,任何一组简单标量值都可以实现 Copy,并且任何需要分配或某种形式的资源都不能实现 Copy。以下是一些实现 Copy 的类型:
- 所有整数类型,例如 u32。
- 布尔类型,bool,值为 true 和 false。
- 所有浮点类型,例如 f64。
- 字符类型,char。
- 元组,如果它们仅包含也实现 Copy 的类型。例如,(i32, i32) 实现 Copy,但 (i32, String) 不实现。
# 所有权与函数
将值传递给函数的机制与将值赋给变量的机制类似。将变量传递给函数将移动或复制,就像赋值一样。示例 4-3 中有一个例子,其中有一些注释显示了变量进入和离开范围的位置。
文件名:src/main.rs:
fn main() {
let s = String::from("hello"); // s 进入作用域
takes_ownership(s); // s 的值移入函数...
// ... 因此此处不再有效
let x = 5; // x 进入作用域
makes_copy(x); // x 将移入函数,
// 但 i32 是 Copy,因此之后仍然可以使用 x
} // 此处,x 超出作用域,然后是 s。但由于 s 的值被移动,因此没有发生任何特殊情况。
fn takes_ownership(some_string: String) { // some_string 进入作用域
println!("{some_string}");
} // 此处,some_string 超出作用域并调用 `drop`。后台内存被释放。
fn makes_copy(some_integer: i32) { // some_integer 进入范围
println!("{some_integer}");
} // 这里,some_integer 超出范围。没有发生任何特殊情况。
示例 4-3:带有所有权和作用域注解的函数
如果我们在调用 takes_ownership 之后尝试使用 s,Rust 会抛出编译时错误。这些静态检查可以防止我们犯错。尝试在 main 中添加使用 s 和 x 的代码,看看在哪里可以使用它们,以及所有权规则在哪里阻止你这样做。
# 返回值与上下文
返回值也可以转移所有权。示例 4-4 展示了一个返回某个值的函数示例,其注释与示例 4-3 中的注释类似。
文件名:src/main.rs:
fn main() {
let s1 = give_ownership(); // gives_ownership 将其返回值移入 s1
let s2 = String::from("hello"); // s2 进入作用域
let s3 = takes_and_gives_back(s2); // s2 被移入 takes_and_gives_back,后者也将其返回值移入 s3
} // 此处,s3 超出作用域并被删除。s2 被移动,因此不会发生任何事情。s1 超出作用域并被删除。
fn give_ownership() -> String { // gives_ownership 会将其返回值移入调用它的函数中
let some_string = String::from("yours"); // some_string 进入作用域
some_string // some_string 被返回并且
// 移出到调用函数
}
// 此函数接受一个字符串并返回一个字符串
fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域
a_string // a_string 被返回并且移出到调用函数
}
示例 4-4:转移返回值的所有权
变量的所有权每次都遵循相同的模式:将值赋给另一个变量会移动它。当包含堆上数据的变量超出范围时,除非数据的所有权已移至另一个变量,否则将通过 drop 清除该值。
虽然这可行,但获取所有权然后使用每个函数返回所有权有点繁琐。如果我们想让函数使用值但不获取所有权怎么办?除了我们可能想要返回的函数主体产生的任何数据之外,如果我们想再次使用它,我们传入的任何内容也需要传回,这很烦人。
Rust 确实允许我们使用元组返回多个值,如示例 4-5 所示。
文件名:src/main.rs:
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() 返回一个字符串的长度
(s, length)
}
示例 4-5:返回参数的所有权
但对于一个本应普遍的概念来说,这太过繁琐,工作量太大。幸运的是,Rust 有一个无需转移所有权即可使用值的功能,称为引用。