所有权是什么?
所有权是一套管理 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 类型就是一个很好的例子。在第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 章的“引用模块树中的项的路径” 中讨论模块命名空间时。在第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 的语言中,识别何时内存不再使用并调用代码显式释放它是我们的责任,就像我们请求内存时一样。正确地做到这一点在历史上一直是一个困难的编程问题。如果我们忘了释放,就会浪费内存。如果我们释放得太早,就会得到一个无效的变量。如果我们释放两次,那也是一个 bug。我们需要让一次 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++ 中,这种在项生命周期结束时释放资源的模式有时被称为资源获取即初始化(Resource Acquisition Is Initialization, RAII)。如果你使用过 RAII 模式,Rust 中的 drop 函数对你来说会很熟悉。
这种模式对 Rust 代码的编写方式产生了深远影响。现在它看起来可能很简单,但在更复杂的情况下,当我们想要让多个变量使用我们在堆上分配的数据时,代码的行为可能会出乎意料。现在让我们来探索一些这样的情况。
变量与数据交互:移动(Move)
在 Rust 中,多个变量可以以不同的方式与同一数据进行交互。让我们看看清单 4-2 中使用整数的例子。
fn main() { let x = 5; let y = x; }
x 的整数值赋给 y我们大概可以猜到这是在做什么:“将值 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 由三部分组成,如图左侧所示:一个指向存储字符串内容的内存的指针、一个长度和一个容量。这组数据存储在栈上。右侧是存储内容的堆内存。
图 4-1:绑定到 s1 的 String 在内存中表示为持有值 "hello"
长度是 String 内容当前使用的内存量(以字节为单位)。容量是 String 从分配器那里获得的总内存量(以字节为单位)。长度和容量之间的区别很重要,但在此上下文中不重要,所以现在可以忽略容量。
当我们将 s1 赋给 s2 时,String 数据会被复制,这意味着我们复制栈上的指针、长度和容量。我们不复制指针指向的堆上的数据。换句话说,内存中的数据表示形式如图 4-2 所示。
图 4-2:变量 s2 在内存中的表示形式,它具有 s1 的指针、长度和容量的副本
表示形式不是图 4-3 那样,如果 Rust 也复制堆数据,内存会看起来像图 4-3。如果 Rust 这样做了,操作 s2 = s1 在运行时性能方面可能会非常昂贵,如果堆上的数据很大的话。
图 4-3:如果 Rust 也复制堆数据,s2 = s1 可能会做的另一种可能性
前面我们提到,当一个变量超出作用域时,Rust 会自动调用 drop 函数并清理该变量的堆内存。但图 4-2 显示两个数据指针都指向同一个位置。这是一个问题:当 s2 和 s1 超出作用域时,它们都会试图释放同一块内存。这被称为二次释放(double free)错误,是之前提到的内存安全 bug 之一。释放内存两次可能导致内存损坏,从而可能导致安全漏洞。
为了确保内存安全,在 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
如果你在使用其他语言时听过浅拷贝(shallow copy)和深拷贝(deep copy)这两个术语,那么只复制指针、长度和容量而不复制数据的概念听起来可能像进行浅拷贝。但由于 Rust 也使得第一个变量无效,因此这不被称为浅拷贝,而是被称为移动(move)。在此示例中,我们会说 s1 被移动到了 s2 中。所以,实际发生的情况如图 4-4 所示。
图 4-4:s1 无效后的内存表示
这就解决了我们的问题!只有 s2 有效,当它超出作用域时,只有它会释放内存,我们就完成了。
此外,这里还隐含着一个设计选择:Rust 永远不会自动创建数据的“深”拷贝。因此,可以假定任何自动复制在运行时性能方面都是廉价的。
作用域与赋值
作用域、所有权以及通过 drop 函数释放内存之间的关系也存在这种逆向关系。当你将一个全新的值赋给一个现有变量时,Rust 会立即调用 drop 并释放原始值的内存。例如,考虑这段代码:
fn main() { let mut s = String::from("hello"); s = String::from("ahoy"); println!("{s}, world!"); }
我们最初声明了一个变量 s 并将其绑定到一个值为 "hello" 的 String。然后我们立即创建一个值为 "ahoy" 的新 String 并将其赋给 s。此时,没有任何东西引用堆上的原始值。
图 4-5:初始值完全替换后的内存表示。
因此,原始字符串立即超出作用域。Rust 会对其运行 drop 函数,其内存会立即被释放。当我们最后打印该值时,它将是 "ahoy, world!"。
变量与数据交互:克隆(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,使用它的变量就不会移动,而是被简单地复制,使得它们在赋值给另一个变量后仍然有效。)。如果一个类型实现了 Copy trait,使用它的变量不会发生移动,而是会进行简单的复制,这使得它们在被赋值给另一个变量后仍然有效。
如果类型本身或其任何部分实现了 Drop trait,Rust 将不允许我们用 Copy 注解该类型。如果该类型在值超出作用域时需要发生一些特殊的事情,而我们却给它添加了 Copy 注解,就会得到一个编译时错误。要了解如何向你的类型添加 Copy 注解以实现该 trait,请参阅 附录 C 中的“可派生的 Trait”。在附录 C 中。
那么,哪些类型实现了 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 a String. 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 提供了一种功能,可以在不转移所有权的情况下使用值,这称为引用(references)。