什么是所有权
所有权是一组规则,它决定了 Rust 程序如何管理内存。所有运行中的程序都必须管理它们对计算机内存的使用方式。某些语言使用垃圾回收(GC),在程序运行时定期查找不再使用的内存;另一些语言则要求程序员显式地分配和释放内存。Rust 采用第三种方式:通过一套编译期检查的“所有权系统”来管理内存。一旦违反这些规则,程序就无法通过编译。所有权机制的任何特性都不会在运行时拖慢程序。
对许多开发者来说,所有权是一个全新概念,确实需要一定时间适应。好消息是:随着你对 Rust 及所有权规则愈发熟悉,你会自然而然写出既安全又高效的代码。坚持下去!
理解了所有权,你就掌握了理解 Rust 独特特性的基石。本章将通过一种非常常见的数据结构——字符串(String)——的示例来学习所有权。
栈(Stack)与堆(Heap)
很多高级语言很少要求你关注栈和堆。但在像 Rust 这样的系统编程语言里,值位于栈还是堆,会直接影响语言的行为以及你为何必须做出某些决策。后面讲解所有权时,会结合栈和堆的概念,因此先简要说明。
栈和堆都是可供代码在运行时使用的内存区域,但组织方式不同。
- 栈按“后进先出”顺序存取值:就像一摞盘子,后放的在上,先取最上面的。往栈里加数据叫“压栈”(push),移除叫“弹栈”(pop)。栈上所有数据的大小必须在编译期已知且固定。
- 堆则没那么有序:把数据放入堆时,先向内存分配器申请一块足够大的空间,分配器标记该空间为“已用”,并返回指向此位置的指针。这个过程叫“堆分配”(简称分配),压栈不算分配。由于指针大小固定,可以把指针存在栈上;真正访问数据时,必须顺着指针去堆里拿。就像进餐厅时,告诉服务员你们几个人,他找一张空桌领你们过去,后来者问服务员即可找到你们。
压栈比堆分配更快,因为无需寻找空闲位置;栈顶永远是下一个位置。堆分配需要找到足够大的空间,并记录元数据以备后续分配,工作量更大。
访问堆数据也比栈慢,需要一次指针跳转。现代处理器在内存连续时更快。类似地,服务员若逐桌收齐一桌的订单再换下一桌,效率最高;若来回穿插,则慢得多。同理,处理器处理栈上紧密排布的数据更高效。
当函数被调用,传入的值(可能包括指向堆数据的指针)以及局部变量都会压栈;函数结束时,这些值被弹栈。
追踪哪些代码正在使用堆上的哪些数据、减少堆上重复数据、及时清理不再使用的数据以免耗尽内存——这些正是所有权要解决的问题。理解所有权后,你无须时刻惦记栈和堆,但明白“所有权主要用来管理堆数据”有助于理解其设计初衷。
所有权规则
先记住三条核心规则,后面示例会逐一阐释:
- Rust 中每个值都有且仅有一个所有者(owner)。
- 同一时间只能有一个所有者。
- 所有者离开作用域(scope)时,该值被丢弃(drop)。
变量的作用域
我们不再在每个示例中写 fn main() { ... }
,请自行把代码放进 main
函数。先看变量作用域:作用域指一个项在程序中有效的范围。例如:
{ // s 尚未声明,不可用let s = "hello"; // 从这里开始 s 有效// 使用 s
} // 作用域结束,s 失效
重点:
- s 进入作用域时生效。
- 离开作用域时失效。
这与多数语言类似。接下来引入 String
类型,以进一步说明所有权。
String 类型
为了展示所有权规则,我们需要比第 3 章更复杂的数据类型。之前提到的类型大小已知,可放栈上,作用域结束时弹出,且易于按位复制出独立副本。现在我们想研究存放在堆上的数据,以及 Rust 如何决定何时清理它们——String
是很好的例子。
我们已见过字符串字面量("hello"
),其值在编译期已知并直接写入可执行文件,速度快、效率高,但不可变,且无法在编译期确定所有文本(如用户输入)。于是 Rust 提供第二种字符串类型 String
,它在堆上管理数据,允许存储编译期大小未知的文本。可用 String::from
由字面量创建:
let s = String::from("hello");
::
语法把 from
置于 String
命名空间下,第 5 章与第 7 章会再谈。
String
可被修改:
let mut s = String::from("hello");
s.push_str(", world!");
println!("{s}"); // 输出 `hello, world!`
为何 String
可变,而字面量不行?关键在于二者内存处理方式不同。
内存与分配
- 字面量内容在编译期已知,直接嵌入可执行文件,因此不可变。
String
需支持可增长文本,于是:- 在运行时向内存分配器申请未知大小的堆内存。
- 用完后需将此内存归还(释放)。
第一步由 String::from
完成,与多数语言相同。第二步则不同:
- 有 GC 的语言由 GC 清理;
- 无 GC 的语言通常需程序员显式释放,易出错:忘了解放会泄漏,过早释放为悬垂指针,重复释放是 bug。
Rust 的做法:变量离开作用域时自动归还内存。例如:
{let s = String::from("hello"); // 申请内存// 使用 s
} // 作用域结束,Rust 自动调用 drop 释放内存
C++ 中类似模式叫 RAII(资源获取即初始化)。Rust 的 drop
函数即此思想的体现。
变量与数据:移动(Move)
Rust 中多个变量可与同一数据交互。先看整数示例:
let x = 5;
let y = x;
整数大小固定,直接复制值压栈,于是 x
、y
均为 5。
再看 String
:
let s1 = String::from("hello");
let s2 = s1;
看起来相似,实则不然。如图 4-1 所示,String
由三部分组成(存栈上):指向堆内容的指针、长度、容量;右侧堆上才是真正的字符数据。
图 4-1:变量 s1
绑定到值为 "hello"
的 String
在内存中的表示
- length(长度)表示该
String
的内容当前占用的字节数。 - capacity(容量)表示该
String
从分配器处获得的堆内存总字节数。
二者有区别,但在本节并不重要,可先忽略容量。
当我们执行 let s2 = s1;
时,复制的是栈上的那三部分数据(指针、长度、容量),而不会复制指针所指向的堆上的实际内容。换句话说,内存中的数据表示如图 4-2 所示。
图 4-2:变量 s2
复制了 s1
的指针、长度和容量后的内存示意图
(并没有复制堆上的实际数据)
这种表示并不是图 4-3 所展示的情况——图 4-3 表示的是“连堆上的数据也一并深拷贝”后的内存布局。
如果 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 runCompiling 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)。在这个例子中,我们会说s1被移动到了s2。因此,实际发生的情况如图4-4所示。
图4-4:s1失效后内存中的表示
这解决了我们的问题!只有s2是有效的,当它超出作用域时,它将独自释放内存,任务完成。
此外,这里还隐含了一个设计选择:Rust永远不会自动创建数据的“深拷贝”。因此,任何自动拷贝都可以假设在运行时性能方面是廉价的。
作用域与赋值
这一规则的反面也适用于作用域、所有权以及通过drop函数释放内存之间的关系。当你将一个全新的值赋给一个已存在的变量时,Rust会立即调用drop并释放原始值的内存。考虑以下代码,例如:
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!”。
变量和数据的克隆操作
如果我们确实需要深度复制 String
的堆数据,而不仅仅是栈数据,我们可以使用一个常见的方法,称为 clone
。我们将在第 5 章讨论方法的语法,但由于方法是许多编程语言中的常见特性,你可能之前已经见过。
以下是一个 clone
方法的示例:
let s1 = String::from("hello");
let s2 = s1.clone();println!("s1 = {s1}, s2 = {s2}");
这可以正常工作,并明确地表现出图 4-3 中所示的行为,即堆数据确实被复制了。
当你看到对 clone
的调用时,你应该知道正在执行一些任意代码,而这些代码可能代价高昂。它是一个视觉提示,表明正在发生一些不同的事情。
仅在栈上的数据:Copy
特性
我们还没有提到的另一个细节是,使用整数的代码——其中一部分在清单 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
的类型,则元组也实现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); // 因为 i32 实现了 Copy 特性,// x 没有被移动到函数中,println!("{}", x); // 因此之后仍然可以使用 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 = gives_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 gives_ownership() -> String { // gives_ownership 将其返回值移动到调用它的函数中let some_string = String::from("yours"); // some_string 进入作用域some_string // some_string 被返回并移动到调用函数中
}// 这个函数接收一个 String 并返回一个 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!("'{}' 的长度是 {}。", s2, len);
}fn calculate_length(s: String) -> (String, usize) {let length = s.len(); // len() 返回一个 String 的长度(s, length)
}
清单 4-5:返回参数的所有权
但这仍然过于繁琐,对于一个应该很常见的概念来说,工作量太大了。幸运的是,Rust 有一个特性,可以在不转移所有权的情况下使用值,称为引用。