什么是所有权?
所有权是一组规则,用于控制 Rust 程序如何管理内存。所有程序都必须管理它们在运行时使用计算机内存的方式。某些语言具有垃圾回收功能,它会在程序运行时定期查找不再使用的内存;在其他语言中,程序员必须显式分配和释放内存。Rust 使用第三种方法:内存通过所有权系统进行管理,该系统具有编译器检查的一组规则。如果违反了任何规则,则程序将不会编译。所有权的任何功能都不会在程序运行时减慢程序的速度。
因为所有权对许多程序员来说是一个新概念,所以确实需要一些时间来适应。好消息是,您对 Rust 和所有权系统的规则越有经验,您就越容易自然地开发安全高效的代码。坚持下去!
当你了解所有权时,你将为理解使 Rust 独特的功能打下坚实的基础。在本章中,您将通过一些示例来了解所有权,这些示例侧重于一个非常常见的数据结构:字符串。
堆栈和堆
许多编程语言不需要你经常考虑堆栈和堆。但是在像 Rust 这样的系统编程语言中,值是在堆栈上还是在堆上会影响语言的行为方式以及为什么你必须做出某些决定。本章后面将介绍与 stack 和 heap 相关的部分所有权,因此这里简要说明一下。
堆栈和堆都是可供代码在运行时使用的内存部分,但它们的结构方式不同。堆栈按获取值的顺序存储值,并以相反的顺序删除值。这称为后进先出。想想一堆盘子:当你添加更多盘子时,你把它们放在一堆盘子的顶部,当你需要一个盘子时,你从顶部取下一个盘子。从中间或底部添加或删除板也不起作用!添加数据称为推送
添加到堆栈上,删除数据称为 popping off the stack。堆栈上存储的所有数据都必须具有已知的固定大小。编译时大小未知或大小可能更改的数据必须存储在堆上。
堆的组织性较差:当您将数据放在堆上时,您请求了一定量的空间。内存分配器在堆中找到一个足够大的空位,将其标记为正在使用,并返回一个指针,即该位置的地址。此过程称为 在
heap 的 Pp 函数,有时缩写为 Just Alsigning(将值推送到堆栈上不被视为 allocating)。由于指向堆的指针是已知的固定大小,因此您可以将指针存储在堆栈上,但是当您需要实际数据时,必须遵循指针。想想坐在餐厅里。当你进去时,你说出你的小组中的人数,主人会找到一张适合所有人的空桌子并带你去那里。如果您的团队中有人迟到,他们可以询问您的座位位置以找到您。
推送到堆栈比在堆上分配更快,因为分配器永远不必搜索存储新数据的位置;该位置始终位于堆栈的顶部。相比之下,在堆上分配空间需要更多的工作,因为分配器必须首先找到足够大的空间来保存数据,然后执行簿记以为下一次分配做准备。
访问堆中的数据比访问堆栈上的数据慢,因为您必须跟随指针才能到达那里。如果现代处理器在内存中跳动较少,它们会更快。继续这个类比,考虑一家餐厅的服务员从许多桌子上接受订单。在进入下一桌之前,将所有订单放在一张桌子上是最有效的。从表 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 }
示例 4-1:变量及其有效范围
换句话说,这里有两个重要的时间点:
当s
进入范围时,它是有效的。
在超出范围之前,它将保持有效。
此时,范围与变量何时有效之间的关系与其他编程语言中的关系类似。现在,我们将通过引入 String
类型来建立在此理解的基础上。
字符串
类型
为了说明所有权规则,我们需要一种比第 3 章 “数据类型” 一节中介绍的数据类型更复杂的数据类型。前面介绍的类型具有已知大小,可以存储在堆栈上,并在其范围结束时从堆栈中弹出,并且如果代码的另一部分需要在不同范围内使用相同的值,则可以快速简单地复制这些类型以创建新的独立实例。但是我们想看看存储在堆上的数据,并探索 Rust 如何知道何时清理这些数据,String
类型就是一个很好的例子。
我们将专注于 String
中与所有权相关的部分。这些方面也适用于其他复杂数据类型,无论它们是由标准库提供还是由您创建。我们将在
第 8 章.
我们已经看到了字符串字面量,其中字符串值被硬编码到我们的程序中。String 字面量很方便,但它们并不适合我们可能想要使用 text 的所有情况。原因之一是它们是不可变的。另一个问题是,在我们编写代码时,并非每个字符串值都可以知道:例如,如果我们想获取用户输入并存储它怎么办?对于这些情况,Rust 有第二种字符串类型,String
。这种类型管理在堆上分配的数据,因此能够存储我们在编译时未知的文本量。您可以使用 from
函数从字符串文本创建 String
,如下所示:
#![allow(unused)] fn main() { let s = String::from("hello"); }
双冒号 ::
运算符允许我们将这个特定的 from
函数,而不是
使用某种名称,例如
string_from
。我们将在 “Method
Syntax“部分,当我们在”引用 Item 的路径“中讨论模块的命名空间时
Module Tree“的 Tree。
这种字符串可以改变:
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 可以改变,但 Literals
不能?区别在于这两种类型如何处理内存。
内存和分配
对于字符串 Literals,我们在编译时知道内容,因此文本被直接硬编码到最终的可执行文件中。这就是字符串文本快速高效的原因。但这些属性仅来自字符串 Literals 的不可变性。不幸的是,我们不能为每段在编译时大小未知且在运行程序时大小可能会改变的文本放入二进制文件中。
对于 String
类型,为了支持可变的、可增长的文本,我们需要在堆上分配一定量的内存(在编译时未知)来保存内容。这意味着:
必须在运行时从内存分配器请求内存。
我们需要一种方法,在完成String
后将此内存返回给分配器。
第一部分由我们完成:当我们调用 String::from
时,它的实现请求它需要的内存。这在编程语言中几乎是通用的。
但是,第二部分不同。在具有垃圾回收器的语言中
(GC) 中,GC 会跟踪并清理不再使用的内存,我们不需要考虑它。在大多数没有 GC 的语言中,我们有责任确定何时不再使用内存,并调用代码来显式释放它,就像我们请求它一样。正确地做到这一点历来是一个困难的编程问题。如果我们忘记了,就会浪费内存。如果我们太早这样做,我们将得到一个无效的变量。如果我们这样做两次,那也是一个错误。我们需要将 1 个 allocate
与 1 个 free
配对。
Rust 走了一条不同的路:一旦拥有内存的变量超出范围,内存就会自动返回。下面是示例 4-1 中 scope 示例的一个版本,它使用 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 调用会自动在右大括号处删除
。
注意:在 C++ 中,这种在项生命周期结束时取消分配资源的模式有时称为资源获取即初始化 (RAII)。如果你使用过 RAII 模式,你会很熟悉 Rust 中的 drop
函数。
这种模式对 Rust 代码的编写方式有深远的影响。现在看起来很简单,但是当我们希望让多个变量使用我们在堆上分配的数据时,在更复杂的情况下,代码的行为可能会出乎意料。现在让我们来探讨其中的一些情况。
与 Move 交互的变量和数据
在 Rust 中,多个变量可以以不同的方式与相同的数据交互。让我们看一个例子,在示例 4-2 中使用整数。
fn main() { let x = 5; let y = x; }
示例 4-2:将变量
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
由三个部分组成,如左侧所示:指向保存 String 内容的内存的指针、长度和容量。这组数据存储在堆栈上。右侧是堆上保存内容的内存。
图 4-1:String
内存中的表示
保持绑定到 S1
的值 “hello”
length 是 String
内容的内存量(以字节为单位)
目前正在使用。容量是
String
已从分配器收到。length 和 capacity 之间的差异很重要,但在这种情况下则不重要,因此现在忽略 capacity 是可以的。
当我们将 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
超出范围时,它们都将尝试释放相同的内存。这被称为 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
如果您在使用其他语言时听说过浅拷贝和深拷贝这两个术语,那么在不复制数据的情况下复制指针、长度和容量的概念可能听起来像是制作浅拷贝。但是因为 Rust 也使第一个变量无效,所以它被称为 move,而不是被称为 shallow copy。在此示例中,我们可以说 s1
已移至 S2
中。因此,实际发生的情况如图 4-4 所示。
图 4-4:s1
失效后内存中的表示
这解决了我们的问题!如果只有 s2
有效,当它超出范围时,它只会释放内存,这样我们就完成了。
此外,还有一个设计选择,这暗示了:Rust 永远不会自动创建数据的 “深层” 副本。因此,任何自动
可以假设 Copying 在运行时性能方面是廉价的。
范围和分配
对于通过 drop
函数释放的作用域、所有权和内存之间的关系,情况恰恰相反。当你为现有变量分配一个全新的值时,Rust 将调用 drop
并立即释放原始值的内存。例如,请考虑以下代码:
fn main() { let mut s = String::from("hello"); s = String::from("ahoy"); println!("{s}, world!"); }
我们最初声明一个变量 s
,并将其绑定到一个值为
“你好”。
然后我们立即创建一个值为 “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
的调用时,您知道正在执行一些任意代码,并且这些代码可能很昂贵。这是一个视觉指标,表明正在发生一些不同的事情。
仅堆栈数据:复制
还有另一个我们还没有讨论的皱纹。这段使用整数的代码(其中一部分如示例 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,请参阅“Derivable
性状”。
那么,哪些类型实现了 Copy
trait 呢?你可以查看给定类型的文档来确定,但作为一般规则,任何一组简单的标量值都可以实现 Copy
,并且任何需要分配或某种形式的资源都不能实现 Copy
。以下是一些实现 Copy
的类型:
所有整数类型,例如u32
。
布尔类型bool
,值为true
和false
。
所有浮点类型,例如f64
。
字符类型char
。
Tuples,如果它们仅包含也实现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.
示例 4-3:带有 owner 和 scope 注解的函数
如果我们尝试在调用 takes_ownership
之后使用 s,Rust
会抛出编译时错误。这些静态检查可以保护我们免受错误的影响。尝试将代码添加到使用 s
和 x
的 main
中,以查看可以在何处使用它们,以及所有权规则阻止在哪些位置使用它们。
返回值和范围
返回值还可以转移所有权。示例 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 }
示例 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() returns the length of a String (s, length) }
示例 4-5:返回参数的所有权
但对于一个本应普遍的概念来说,这太过仪式化和大量工作。幸运的是,Rust 有一个在不转移所有权的情况下使用值的功能,称为引用。