什么是所有权?
_所有权_ 是一组规则,用于管理 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
有效位置的注释。
fn main() { { // s is not valid here, it’s not yet declared let s = "hello"; // s is valid from this point forward // do stuff with s } // this scope is now over, and s is no longer valid }
换句话说,这里有两个重要的时间点
- 当
s
进入*范围*时,它是有效的。 - 它一直有效,直到它*超出*范围。
在这一点上,范围和变量何时有效之间的关系与其他编程语言中的关系类似。现在,我们将通过引入 String
类型来进一步理解这一点。
String
类型
为了说明所有权规则,我们需要一个比我们在第 3 章“数据类型”“数据类型”部分中介绍的数据类型更复杂的数据类型。前面介绍的类型的大小是已知的,可以存储在堆栈上并在其范围结束后从堆栈中弹出,并且可以快速轻松地复制以创建一个新的独立实例,如果代码的另一部分需要在不同的范围内使用相同的值。但是我们想看看存储在堆上的数据,并探索 Rust 如何知道何时清理这些数据,而 String
类型就是一个很好的例子。
我们将专注于与所有权相关的 String
部分。这些方面也适用于其他复杂数据类型,无论它们是由标准库提供还是由您创建。我们将在第 8 章.
中更详细地讨论 String
。我们已经看到了字符串字面量,其中字符串值硬编码到我们的程序中。字符串字面量很方便,但它们并不适合于我们可能想要使用文本的每种情况。一个原因是它们是不可变的。另一个原因是,并非每个字符串值在我们编写代码时都知道:例如,如果我们想获取用户输入并存储它怎么办?对于这些情况,Rust 有第二种字符串类型,即 String
。此类型管理在堆上分配的数据,因此能够存储我们在编译时不知道数量的文本。您可以使用 from
函数从字符串字面量创建 String
,如下所示
#![allow(unused)] fn main() { let s = String::from("hello"); }
双冒号 ::
运算符允许我们在 String
类型下为此特定 from
函数命名空间,而不是使用 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 采取了不同的路径:一旦拥有内存的变量超出范围,内存就会自动返回。以下是列表 4-1 中使用 String
而不是字符串字面量的范围示例版本
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 代码的编写方式有着深远的影响。现在看起来可能很简单,但是当我们想让多个变量使用我们在堆上分配的数据时,在更复杂的情况下,代码的行为可能会出乎意料。现在让我们探索其中的一些情况。
变量和数据与移动的交互
在 Rust 中,多个变量可以以不同的方式与相同的数据交互。让我们看一下列表 4-2 中使用整数的示例。
fn main() { let x = 5; let y = x; }
我们大概可以猜到这是在做什么:“将值 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:28
|
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!("{}, world!", s1);
| ^^ 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 永远不会自动创建数据的“深层”副本。因此,任何自动复制都可以假定在运行时性能方面都很便宜。
与 Clone 交互的变量和数据
如果我们确实想要深度复制 String
的堆数据,而不仅仅是堆栈数据,我们可以使用一种称为 clone
的常用方法。我们将在第 5 章讨论方法语法,但因为方法是许多编程语言中的常见功能,你以前可能见过它们。
以下是 clone
方法的实际应用示例
fn main() { let s1 = String::from("hello"); let s2 = s1.clone(); println!("s1 = {s1}, s2 = {s2}"); }
这工作得很好,并明确地产生了图 4-3 中所示的行为,其中堆数据确实被复制了。
当你看到对 clone
的调用时,你就知道正在执行一些任意代码,并且该代码可能很昂贵。这是一个视觉指示器,表明正在发生一些不同的事情。
仅堆栈数据:Copy
我们还没有谈到的另一个问题。这段使用整数的代码(清单 4-2 中显示了其中的一部分)有效且有效
fn main() { let x = 5; let y = x; println!("x = {x}, y = {y}"); }
但是这段代码似乎与我们刚刚学到的内容相矛盾:我们没有调用 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 中有一个示例,其中包含一些注解,显示了变量进入和离开作用域的位置。
文件名:src/main.rs
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); // x would move into the function, // but i32 is Copy, so it's okay to still // 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 中的注解类似。
文件名:src/main.rs
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 所示。
文件名: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() returns the length of a String (s, length) }
但这对于一个应该很常见的概念来说太麻烦了,工作量也很大。幸运的是,Rust 有一个功能可以在不转移所有权的情况下使用值,称为引用。