第4章 所有权与移动
# 第4章 所有权与移动
在管理内存方面,我们期望编程语言具备两个特性:
- 希望能在我们选择的时机及时释放内存,这样我们就能控制程序的内存消耗。
- 绝不希望在对象被释放后还使用指向它的指针。这属于未定义行为,会导致程序崩溃和安全漏洞。
但这两个特性似乎相互矛盾:在存在指向某个值的指针时释放该值,必然会使这些指针悬空。几乎所有主流编程语言都根据放弃其中哪一个特性,而归入两个阵营之一:
- “安全优先”阵营:使用垃圾回收机制来管理内存,当指向对象的所有可达指针都消失时,自动释放对象。这种方式通过在对象还有指针指向时一直保留它们,从而避免悬空指针的出现。几乎所有现代语言都属于这个阵营,从Python、JavaScript、Ruby到Java、C#和Haskell。但依赖垃圾回收意味着将对象具体何时被释放的控制权交给了垃圾回收器。一般来说,垃圾回收器的行为难以预测,要弄清楚为什么内存没有在你预期的时候被释放可能是个挑战。
- “控制优先”阵营:由你负责释放内存。程序的内存消耗完全由你掌控,但避免悬空指针也完全成了你的责任。C和C++是这个阵营中仅有的主流语言。如果你从不犯错,这当然很好,但有证据表明,最终你还是会出错。只要有相关数据记录,指针误用一直是安全问题的常见原因。
Rust旨在兼顾安全性和性能,所以这两种妥协方案都不可接受。但如果调和这两个特性很容易,早就有人做到了。一些根本性的东西需要改变。
Rust以一种惊人的方式打破了这个僵局:通过限制程序使用指针的方式。本章和下一章将详细解释这些限制具体是什么,以及它们为什么有效。目前,只需知道你习惯使用的一些常见结构可能不符合这些规则,你需要寻找替代方案。但这些限制的最终效果是,为混乱的内存管理带来了足够的秩序,使Rust的编译时检查能够验证你的程序没有内存安全错误,比如悬空指针、双重释放、使用未初始化的内存等等。在运行时,你的指针就像在C和C++中一样,只是内存中的简单地址。不同之处在于,你的代码已被证明能安全地使用这些指针。
同样这些规则也是Rust支持安全并发编程的基础。使用Rust精心设计的线程原语时,确保代码正确使用内存的规则,也能用来证明代码不存在数据竞争。Rust程序中的一个错误不会导致一个线程破坏另一个线程的数据,从而在系统的其他无关部分引发难以重现的故障。多线程代码中固有的不确定性行为被限制在那些专门处理它的特性中,比如互斥锁、消息通道、原子值等等,而不会出现在普通的内存引用中。C和C++中的多线程代码名声不佳,但Rust很好地改善了这种情况。
Rust做出了大胆的尝试,这也是它成败的关键所在,并且是这门语言的根基:即使有这些限制,你会发现这门语言对于几乎任何任务来说都足够灵活,而且其带来的好处,比如消除大量内存管理和并发方面的错误,足以让你适应编程风格上的改变。本书的作者之所以看好Rust,正是因为我们在C和C++方面有着丰富的经验。对我们来说,Rust的优势显而易见。
Rust的规则可能与你在其他编程语言中看到的不同。在我们看来,学习如何运用这些规则并使其为你所用,是学习Rust的核心挑战。在本章中,我们首先会通过展示在其他语言中同样的潜在问题是如何表现的,来深入理解Rust规则背后的逻辑和意图。然后,我们将详细解释Rust的规则,探讨所有权在概念和机制层面的含义,在各种场景下所有权的变化是如何被跟踪的,以及一些为了提供更多灵活性而对这些规则进行调整或突破的类型。
# 所有权
如果你读过很多C或C++代码,可能会遇到这样的注释:某个类的实例“拥有”它所指向的另一个对象。这通常意味着拥有者对象有权决定何时释放被拥有的对象:当拥有者被销毁时,它会一并销毁其拥有的对象。
例如,假设你编写了以下C++代码:
std::string s = "frayed knot";
字符串s
在内存中的表示通常如图4 - 1所示。
图4 - 1 栈上的C++ std::string值,指向其在堆上分配的缓冲区
这里,实际的std::string
对象本身长度始终恰好为三个字,包含一个指向堆上分配的缓冲区的指针、缓冲区的总容量(即文本在字符串必须分配更大的缓冲区来容纳它之前可以增长到多大),以及它当前存储的文本长度。这些是std::string
类的私有字段,字符串的使用者无法访问。
std::string
拥有自己的缓冲区:当程序销毁字符串时,字符串的析构函数会释放该缓冲区。过去,一些C++库在多个std::string
值之间共享单个缓冲区,通过引用计数决定何时释放缓冲区。但较新的C++规范实际上排除了这种表示方式;现在所有现代C++库都采用此处展示的方式。
在这些情况下,通常大家都明白,虽然其他代码可以创建指向已拥有内存的临时指针,但确保在所有者决定销毁所拥有的对象之前,这些指针不再使用是该代码的责任。你可以创建一个指向std::string
缓冲区中字符的指针,但当字符串被销毁时,你的指针会失效,你需要确保不再使用它。所有者决定所拥有对象的生命周期,其他所有代码都必须尊重这一点。
我们在这里以std::string
为例,展示C++中的所有权概念:这只是标准库通常遵循的一种约定,虽然语言鼓励你遵循类似的做法,但如何设计自己的类型最终还是由你决定。
然而,在Rust中,所有权的概念内置于语言本身,并由编译时检查强制执行。每个值都有一个唯一的所有者,所有者决定其生命周期。当所有者被释放(在Rust术语中称为“丢弃”)时,被拥有的值也会被丢弃。这些规则旨在让你通过查看代码就能轻松确定任何给定值的生命周期,赋予你作为系统语言应有的对值生命周期的控制权。
变量拥有其值。当控制流离开声明变量的代码块时,变量会被丢弃,其值也会随之丢弃。例如:
fn print_padovan() {
let mut padovan = vec![1, 1, 1]; // 在此处分配
for i in 3..10 {
let next = padovan[i - 3] + padovan[i - 2];
padovan.push(next);
}
println!("P(1..10) = {:?}", padovan);
} // 在此处丢弃
2
3
4
5
6
7
8
变量padovan
的类型是Vec<i32>
,即32位整数的向量。在内存中,padovan
的最终值看起来大致如图4 - 2所示。
图4 - 2. 栈上的Vec<i32>,指向堆中的缓冲区
这与我们之前展示的C++ std::string
非常相似,只是缓冲区中的元素是32位值,而不是字符。注意,保存padovan
的指针、容量和长度的信息直接存放在print_padovan
函数的栈帧中;只有向量的缓冲区是在堆上分配的。
与前面的字符串s
一样,向量拥有存放其元素的缓冲区。当变量padovan
在函数末尾超出作用域时,程序会丢弃该向量。由于向量拥有其缓冲区,缓冲区也会随之被丢弃。
Rust的Box
类型是所有权的另一个示例。Box<T>
是指向存储在堆上的T
类型值的指针。调用Box::new(v)
会分配一些堆空间,将值v
移动到其中,并返回一个指向该堆空间的Box
。由于Box
拥有它所指向的空间,当Box
被丢弃时,它所指向的空间也会被释放。
例如,你可以像这样在堆上分配一个元组:
{
let point = Box::new((0.625, 0.5)); // 在此处分配point
let label = format!("{:?}", point); // 在此处分配label
assert_eq!(label, "(0.625, 0.5)");
} // 在此处丢弃两者
2
3
4
5
当程序调用Box::new
时,它会在堆上为包含两个f64
值的元组分配空间,将其参数(0.625, 0.5)
移动到该空间中,并返回一个指向它的指针。当控制流到达assert_eq!
调用时,栈帧看起来如图4 - 3所示。
图4 - 3. 两个局部变量,每个变量都在堆中拥有内存
栈帧本身保存着变量point
和label
,它们各自指向自己拥有的堆分配。当它们被丢弃时,它们所拥有的分配也会随之被释放。
就像变量拥有其值一样,结构体拥有其字段,元组、数组和向量拥有其元素:
struct Person {
name: String,
birth: i32
}
let mut composers = Vec::new();
composers.push(Person {
name: "Palestrina".to_string(),
birth: 1525
});
composers.push(Person {
name: "Dowland".to_string(),
birth: 1563
});
composers.push(Person {
name: "Lully".to_string(),
birth: 1632
});
for composer in &composers {
println!("{}, born {}", composer.name, composer.birth);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
这里,composers
是Vec<Person>
类型,即一个结构体向量,每个结构体都包含一个字符串和一个数字。在内存中,composers
的最终值看起来如图4 - 4所示。
图4 - 4. 更复杂的所有权关系树
这里有许多所有权关系,但每一个都相当直观:composers
拥有一个向量;向量拥有其元素,每个元素都是一个Person
结构体;每个结构体拥有其字段;字符串字段拥有其文本。当控制流离开声明composers
的作用域时,程序会丢弃其值,并将整个结构一并丢弃。如果涉及其他类型的集合,比如HashMap
或BTreeSet
,情况也是如此。
此时,让我们退一步思考一下目前介绍的所有权关系所带来的影响。每个值都有一个唯一的所有者,这使得确定何时丢弃它变得很容易。但单个值可能拥有许多其他值:例如,向量composers
拥有其所有元素。而这些值又可能依次拥有其他值:composers
的每个元素都拥有一个字符串,而这个字符串又拥有其文本。
由此可见,所有者及其拥有的值形成了树状结构:你的所有者就像是你的“父节点”,而你拥有的值就像是你的“子节点”。每棵树的最终根节点是一个变量;当该变量超出作用域时,整个树都会被丢弃。我们可以在composers
的示意图中看到这样一个所有权树:它不是搜索树数据结构意义上的“树”,也不是由DOM元素构成的HTML文档。相反,我们有一个由多种类型混合构建的树,Rust的单一所有者规则禁止任何可能使结构比树更复杂的结构合并。Rust程序中的每个值都是某个以某个变量为根的树的一部分。
Rust程序通常不会像C和C++程序使用free
和delete
那样显式地丢弃值。在Rust中,丢弃一个值的方式是通过某种方式将其从所有权树中移除:比如离开变量的作用域,从向量中删除一个元素等等。在那时,Rust会确保该值及其所拥有的一切都被正确丢弃 。
从某种意义上说,Rust比其他语言的功能“更弱”:其他实际使用的编程语言都允许你以任何你认为合适的方式构建相互指向的任意对象图。但正是因为Rust的功能“更弱”,它对程序进行的分析才可以更强大。Rust能够提供安全保证,正是因为它在你的代码中遇到的关系更易于处理。这是我们前面提到的Rust “激进的赌注” 的一部分:Rust声称,在实际应用中,解决问题的方式通常有足够的灵活性,足以确保至少有一些完美的解决方案符合语言所施加的限制。
话虽如此,到目前为止我们所解释的所有权概念仍然过于严格,不太实用。Rust通过以下几种方式扩展了这个简单的概念:
- 你可以将值从一个所有者移动到另一个所有者。这使你能够构建、重新排列和拆解所有权树。
- 像整数、浮点数和字符这样非常简单的类型不受所有权规则的约束。这些被称为
Copy
类型。 - 标准库提供了引用计数指针类型
Rc
和Arc
,在一定限制下,它们允许值有多个所有者。 - 你可以“借用对一个值的引用”;引用是不拥有所有权的指针,具有有限的生命周期。
这些策略中的每一个都为所有权模型增加了灵活性,同时仍然信守Rust的承诺。
我们将依次解释每一个策略,下一章将介绍引用。
# 移动
在Rust中,对于大多数类型,像给变量赋值、将值传递给函数或从函数返回值这样的操作,并不会复制值:它们会移动值。源变量放弃对该值的所有权,将其交给目标变量,并且源变量会变为未初始化状态;目标变量现在控制该值的生命周期。Rust程序通过一次移动一个值,逐步构建和拆解复杂的结构。
你可能会惊讶于Rust改变了这些基本操作的含义;在如今这个时代,赋值的含义肯定应该是相当确定的。然而,如果你仔细观察不同语言处理赋值的方式,就会发现不同流派之间实际上存在很大差异。这种对比也让Rust选择的含义和结果更加清晰。
考虑以下Python代码:
s = ['udon', 'ramen','soba']
t = s
u = s
2
3
每个Python对象都有一个引用计数,用于跟踪当前指向它的值的数量。所以在给s
赋值之后,程序的状态看起来如图4 - 5所示(注意省略了一些字段)。
图4 - 5. Python在内存中表示字符串列表的方式
由于只有s
指向这个列表,所以列表的引用计数为1;由于列表是唯一指向这些字符串的对象,所以每个字符串的引用计数也为1。
当程序执行对t
和u
的赋值时会发生什么呢?Python通过让目标变量指向与源变量相同的对象,并增加对象的引用计数来实现赋值操作。所以程序的最终状态大致如图4 - 6所示。
图4 - 6. 在Python中将s赋值给t和u的结果
Python将s
中的指针复制到t
和u
中,并将列表的引用计数更新为3。Python中的赋值操作成本很低,但由于它创建了对对象的新引用,所以我们必须维护引用计数,以便知道何时可以释放该值。
现在考虑类似的C++代码:
using namespace std;
vector<string> s = { "udon", "ramen", "soba" };
vector<string> t = s;
vector<string> u = s;
2
3
4
在内存中,s
的初始值看起来如图4 - 7所示。
图4 - 7. C++在内存中表示字符串向量的方式
当程序将s
赋值给t
和u
时会发生什么呢?在C++中,给std::vector
赋值会生成该向量的一个副本;std::string
的行为类似。所以当程序执行到这段代码的末尾时,实际上已经分配了三个向量和九个字符串(图4 - 8)。
图4 - 8. 在C++中将s赋值给t和u的结果
根据涉及的值的不同,C++中的赋值操作可能会消耗大量的内存和处理器时间。然而,其优点是程序很容易确定何时释放所有这些内存:当变量超出作用域时,这里分配的所有内容都会自动清理。
从某种意义上说,C++和Python做出了相反的权衡:Python让赋值操作成本很低,但代价是需要引用计数(一般情况下还需要垃圾回收机制)。C++则将所有内存的所有权管理得很清晰,但代价是赋值操作要对对象进行深度复制。C++程序员通常对这种选择并不十分满意:深度复制可能开销很大,而且通常有更实用的替代方案。
那么在Rust中,类似的程序会怎样呢?代码如下:
let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string()];
let t = s;
let u = s;
2
3
和C、C++一样,Rust把像"udon"
这样的普通字符串字面量放在只读内存中。为了更清晰地与C++和Python的示例进行比较,我们在这里调用to_string
来获取在堆上分配的String
值。
在完成对s
的初始化后,由于Rust和C++对向量和字符串的表示方式类似,此时的情况看起来和C++中一样(图4 - 9)。
图4 - 9. Rust在内存中表示字符串向量的方式
但要记住,在Rust中,对大多数类型的赋值操作会将值从源变量移动到目标变量,使源变量变为未初始化状态。所以在初始化t
之后,程序的内存状态看起来如图4 - 10所示。
图4 - 10. 在Rust中将s赋值给t的结果
这里发生了什么呢?初始化语句let t = s;
将向量的三个头部字段从s
移动到了t
;现在t
拥有了这个向量。向量的元素位置不变,字符串也没有变化。
每个值仍然只有一个所有者,只是所有者发生了变更。这里不需要调整引用计数。而且编译器现在认为s
是未初始化的。
那么当执行到初始化语句let u = s;
时会发生什么呢?这会将未初始化的值s
赋值给u
。Rust谨慎地禁止使用未初始化的值,所以编译器会拒绝这段代码,并给出以下错误:
error[E0382]: use of moved value: `s`
7 | let s = vec!["udon".to_string(), "ramen".to_string(),
- move occurs because `s` has type `Vec<String>`,
"so|ba".to_string()];
| which does not implement the `Copy` trait
8 | let t = s;
| - value moved here
9 | let u = s;
| ^ value used here after move
2
3
4
5
6
7
8
9
考虑一下Rust这里使用移动语义的结果。和Python一样,赋值操作成本很低:程序只是将向量的三字头部从一个地方移动到另一个地方。但和C++一样,所有权始终是清晰的:程序不需要引用计数或垃圾回收机制来确定何时释放向量元素和字符串内容。
你需要付出的代价是,当你需要复制时,必须显式地请求复制操作。如果你希望最终状态和C++程序一样,即每个变量都持有该结构的独立副本,就必须调用向量的clone
方法,它会对向量及其元素进行深度复制:
let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string()];
let t = s.clone();
let u = s.clone();
2
3
你也可以通过使用Rust的引用计数指针类型来重现Python的行为;我们将在“Rc和Arc:共享所有权”中很快讨论这些内容。
# 更多涉及移动的操作
在目前的示例中,我们展示了初始化操作,即在let
语句中变量进入作用域时为其提供值。给变量赋值稍有不同,如果你将一个值移动到一个已经初始化的变量中,Rust会丢弃该变量之前的值。例如:
let mut s = "Govinda".to_string();
s = "Siddhartha".to_string(); // 此处丢弃值 "Govinda"
2
在这段代码中,当程序将字符串"Siddhartha"
赋值给s
时,它之前的值"Govinda"
会先被丢弃。但考虑以下情况:
let mut s = "Govinda".to_string();
let t = s;
s = "Siddhartha".to_string(); // 此处没有字符串被丢弃
2
3
这一次,t
从s
那里获取了原始字符串的所有权,所以当我们给s
赋值时,s
已经是未初始化状态。在这种情况下,没有字符串被丢弃。
我们在这里使用初始化和赋值操作作为示例,是因为它们很简单,但实际上Rust几乎对任何使用值的操作都应用了移动语义。将参数传递给函数会将所有权移动到函数的参数中;从函数返回一个值会将所有权移动到调用者。构建一个元组会将值移动到元组中。诸如此类。
现在你可能对我们在上一节给出的示例有了更深入的理解。例如,当我们构建作曲家向量时,我们编写了以下代码:
struct Person {
name: String,
birth: i32
}
let mut composers = Vec::new();
composers.push(Person {
name: "Palestrina".to_string(),
birth: 1525
});
2
3
4
5
6
7
8
9
10
除了初始化和赋值,这段代码还有几个涉及移动的地方:
- 从函数返回值:调用
Vec::new()
会构建一个新向量,返回的不是指向向量的指针,而是向量本身:其所有权从Vec::new
移动到变量composers
。类似地,to_string
调用会返回一个新的String
实例。 - 构建新值:新
Person
结构体的name
字段使用to_string
的返回值进行初始化。结构体获取了字符串的所有权。 - 将值传递给函数:整个
Person
结构体(而不是指向它的指针)被传递给向量的push
方法,该方法将其移动到结构体的末尾。向量获取了Person
的所有权,从而也间接成为了name
字段中String
的所有者。
像这样移动值可能听起来效率不高,但有两点需要记住。第一,移动操作始终作用于值本身,而不是它们在堆上占用的存储。对于向量和字符串,值本身只是三字头部;潜在的大元素数组和文本缓冲区仍留在堆中的原有位置。第二,Rust编译器的代码生成功能很擅长“看透”这些移动操作;在实际情况中,机器代码通常会将值直接存储到它所属的位置。
# 移动与控制流
前面的示例控制流都非常简单;移动操作与更复杂的代码是如何交互的呢?一般原则是,如果一个变量的值有可能被移动走,并且从那之后它没有被明确赋予新值,那么它就被视为未初始化。例如,如果一个变量在计算完if
表达式的条件后仍然有值,那么我们可以在两个分支中都使用它:
let x = vec![10, 20, 30];
if c {
f(x); // ... 此处从x移动值是可以的
} else {
g(x); // ... 此处从x移动值也是可以的
}
h(x); // 错误:如果前面任何一个分支使用了x,这里x就是未初始化的
2
3
4
5
6
7
出于类似的原因,在循环中从变量移动值是被禁止的:
let x = vec![10, 20, 30];
while f() {
g(x); // 错误:x的值会在第一次迭代中被移动,第二次迭代时处于未初始化状态
}
2
3
4
也就是说,除非我们在下次迭代前明确给它赋予了新值:
let mut x = vec![10, 20, 30];
while f() {
g(x); // 从x移动值
x = h(); // 给x赋予一个新值
}
e(x);
2
3
4
5
6
# 移动与索引内容
我们提到过,移动操作会使源变量变为未初始化状态,因为目标变量获取了值的所有权。但并非每种类型的值所有者都能接受变为未初始化状态。例如,考虑以下代码:
// 构建一个包含字符串 "101", "102", ... "105" 的向量
let mut v = Vec::new();
for i in 101..106 {
v.push(i.to_string());
}
// 从向量中取出随机元素
let third = v[2]; // 错误:无法从Vec的索引中移出值
let fifth = v[4]; // 这里也不行
2
3
4
5
6
7
8
为了使这段代码正常工作,Rust需要以某种方式记住向量的第三个和第五个元素已经变为未初始化状态,并跟踪这些信息,直到向量被丢弃。在最一般的情况下,向量需要携带额外的信息,以指示哪些元素是有效的,哪些已经变为未初始化状态。
对于系统编程语言来说,这显然不是正确的行为;向量就应该只是一个向量。实际上,Rust会拒绝上述代码,并给出以下错误:
error[E0507]: cannot move out of index of `Vec<String>`
14 | let third = v[2];
| |
| move occurs because value has type `String`,
| which does not implement the `Copy` trait
| help: consider borrowing here: `&v[2]`
2
3
4
5
6
对于将值移动到fifth
的操作,Rust也会给出类似的错误提示。在错误信息中,Rust建议使用引用,如果你想在不移动元素的情况下访问它的话。
这通常是你想要的做法。但如果你确实想从向量中移出一个元素呢?你需要找到一种方法,在尊重类型限制的前提下实现这一点。以下是三种可能的方法:
// 构建一个包含字符串 "101", "102", ... "105" 的向量
let mut v = Vec::new();
for i in 101..106 {
v.push(i.to_string());
}
// 1. 从向量末尾弹出一个值:
let fifth = v.pop().expect("vector empty!");
assert_eq!(fifth, "105");
// 2. 从向量的给定索引中移出一个值,并将最后一个元素移动到该位置:
let second = v.swap_remove(1);
assert_eq!(second, "102");
// 3. 用另一个值替换我们要移出的值:
let third = std::mem::replace(&mut v[2], "substitute".to_string());
assert_eq!(third, "103");
// 让我们看看向量中还剩下什么。
assert_eq!(v, vec!["101", "104", "substitute"]);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这些方法中的每一种都能从向量中移出一个元素,但同时会使向量处于一个完整(可能变小了)的状态。
像Vec
这样的集合类型通常也提供在循环中消耗其所有元素的方法:
let v = vec!["liberté".to_string(),
"égalité".to_string(),
"fraternité".to_string()];
for mut s in v {
s.push('!');
println!("{}", s);
}
2
3
4
5
6
7
8
当我们像for... in v
这样直接将向量传递给循环时,这会将向量从v
中移出,使v
变为未初始化状态。for
循环的内部机制获取了向量的所有权,并将其分解为各个元素。在每次迭代中,循环将另一个元素移动到变量s
中。由于s
现在拥有了这个字符串,我们可以在循环体中对其进行修改,然后再打印。而且由于向量本身在代码中不再可见,所以在循环过程中,不会有任何代码观察到它处于部分清空的状态。
如果你确实发现自己需要从编译器无法跟踪的所有者中移出一个值,你可以考虑将所有者的类型更改为能够动态跟踪其是否持有值的类型。例如,这是前面示例的一个变体:
struct Person {
name: Option<String>,
birth: i32
}
let mut composers = Vec::new();
composers.push(Person {
name: Some("Palestrina".to_string()),
birth: 1525
});
2
3
4
5
6
7
8
9
10
你不能这样做:
let first_name = composers[0].name;
这只会引发和前面一样的“无法从索引中移出值”的错误。但是因为你将name
字段的类型从String
更改为了Option<String>
,这意味着None
是该字段的合法值,所以这样做是可行的:
let first_name = std::mem::replace(&mut composers[0].name, None);
assert_eq!(first_name, Some("Palestrina".to_string()));
assert_eq!(composers[0].name, None);
2
3
replace
调用移出了composers[0].name
的值,在其位置留下None
,并将原始值的所有权传递给调用者。实际上,以这种方式使用Option
非常常见,所以该类型为此提供了一个take
方法。你可以更清晰地将前面的操作写为:
let first_name = composers[0].name.take();
这个对take
的调用和前面调用replace
的效果是一样的。
# Copy类型:移动的例外情况
到目前为止,我们展示的涉及值移动的示例,都包含向量、字符串和其他可能占用大量内存且复制成本较高的类型。移动操作能让这些类型的所有权清晰明确,并且赋值操作的成本较低。但对于像整数或字符这样更简单的类型,这种细致的处理其实没有必要。
对比一下给String
赋值和给i32
值赋值时内存中的情况:
let string1 = "somnambulance".to_string();
let string2 = string1;
let num1: i32 = 36;
let num2 = num1;
2
3
4
运行这段代码后,内存情况如图4 - 11所示。
图4 - 11. 给String赋值会移动值,而给i32赋值会复制值
和前面的向量示例一样,赋值操作将string1
移动到string2
,这样就不会出现两个字符串都负责释放同一缓冲区的情况。然而,num1
和num2
的情况有所不同。i32
只是内存中的一组二进制位模式,它不拥有任何堆资源,实际上除了自身所包含的字节外,不依赖于其他任何东西。当我们将num1
的二进制位移动到num2
时,就创建了一个与num1
完全独立的副本。
移动一个值会使移动操作的源变量变为未初始化状态。虽然将string1
视为无值状态有其重要意义,但对num1
这样处理就毫无意义;继续使用num1
不会产生任何问题。移动操作的优势在这里并不适用,反而带来了不便。
之前我们特意强调大多数类型是移动的;现在我们来看看例外情况,即Rust指定为Copy
类型的那些类型。给Copy
类型的值赋值时会复制该值,而不是移动它。赋值操作的源变量保持初始化状态且仍然可用,其值与之前相同。将Copy
类型的值传递给函数和构造函数时,行为也是如此。
标准的Copy
类型包括所有机器整数和浮点数字类型、char
和bool
类型,以及其他一些类型。由Copy
类型组成的元组或固定大小的数组本身也是Copy
类型。
只有那些简单的按位复制就足够的类型才能是Copy
类型。正如我们已经解释过的,String
不是Copy
类型,因为它拥有在堆上分配的缓冲区。出于类似的原因,Box<T>
不是Copy
类型,它拥有其在堆上分配的指向对象。表示操作系统文件句柄的File
类型不是Copy
类型,复制这样的值需要向操作系统请求另一个文件句柄。同样,表示锁定的互斥锁的MutexGuard
类型也不是Copy
类型:复制这个类型毫无意义,因为同一时间只有一个线程可以持有互斥锁。
一般来说,任何在值被丢弃时需要执行特殊操作的类型都不能是Copy
类型:Vec
需要释放其元素,File
需要关闭其文件句柄,MutexGuard
需要解锁其互斥锁等等。对这样的类型进行按位复制会导致不清楚哪个值负责管理原始值的资源。
那么你自己定义的类型呢?默认情况下,结构体和枚举类型不是Copy
类型:
struct Label {
number: u32
}
fn print(l: Label) {
println!("STAMP: {}", l.number);
}
let l = Label { number: 3 };
print(l);
println!("My label number is: {}", l.number);
2
3
4
5
6
7
8
9
10
11
这段代码无法编译,Rust会报错:
error: borrow of moved value: `l`
|
- move occurs because `l` has type `main::Label`,
10 | let l = Label { number: 3 };
| which does not implement the `Copy` trait
- value moved here
11 | print(l);
^^^^^^^^
12 | println!("My label number is: {}", l.number);
| value borrowed here after move
2
3
4
5
6
7
8
9
10
由于Label
不是Copy
类型,将其传递给print
函数会把值的所有权移动到print
函数中,该函数在返回前会丢弃这个值。但这很不合理,Label
只不过是一个带有一些修饰的u32
。将l
传递给print
函数不应该移动这个值。
但用户定义的类型默认不是Copy
类型只是一种设定。如果结构体的所有字段本身都是Copy
类型,那么你可以通过在定义上方添加#[derive(Copy, Clone)]
属性,使该类型也成为Copy
类型,如下所示:
#[derive(Copy, Clone)]
struct Label {
number: u32
}
2
3
4
有了这个修改,前面的代码就能顺利编译。然而,如果我们对一个字段并非都是Copy
类型的结构体尝试这样做,就行不通了。假设我们编译以下代码:
#[derive(Copy, Clone)]
struct StringLabel {
name: String
}
2
3
4
它会引发这个错误:
error[E0204]: the trait `Copy` may not be implemented for this type
--> ownership_string_label.rs:7:10
|
^^^^
7 | #[derive(Copy, Clone)]
------------ this field does not
8 | struct StringLabel { name: String }
implement `Copy`
2
3
4
5
6
7
8
为什么符合条件的用户定义类型不会自动成为Copy
类型呢?一个类型是否为Copy
类型,对代码使用它的方式有很大影响:Copy
类型更加灵活,因为赋值和相关操作不会使原始值变为未初始化状态。但对于类型的实现者来说,情况正好相反:Copy
类型在可包含的类型方面非常有限,而非Copy
类型可以使用堆分配并拥有其他类型的资源。所以将一个类型设为Copy
类型,对于实现者来说是一个重大决策:如果之后需要将其改为非Copy
类型,那么使用该类型的大部分代码可能都需要调整。
C++允许你重载赋值运算符并定义专门的复制和移动构造函数,而Rust不允许这种定制。在Rust中,每次移动都是逐字节的浅复制,会使源变量变为未初始化状态。复制操作与之类似,只是源变量会保持初始化状态。这确实意味着C++类可以提供Rust类型无法提供的便捷接口,在这些接口中,看似普通的代码可以隐式调整引用计数、推迟昂贵的复制操作,或者使用其他复杂的实现技巧。
但这种灵活性对C++语言的影响是,使得赋值、传递参数和从函数返回值等基本操作变得难以预测。例如,在本章前面我们展示了在C++中,将一个变量赋值给另一个变量可能需要任意数量的内存和处理器时间。Rust的原则之一是,成本应该对程序员显而易见。基本操作必须保持简单。
潜在的高成本操作应该是显式的,就像前面示例中调用clone
对向量及其包含的字符串进行深度复制那样。
在本节中,我们大致介绍了Copy
和Clone
,将它们作为类型可能具备的特性。实际上,它们是特性(trait)的示例,特性是Rust基于类型的可操作性对类型进行分类的开放式机制。我们将在第11章全面介绍特性,在第13章专门介绍Copy
和Clone
。
# Rc和Arc:共享所有权
虽然在典型的Rust代码中,大多数值都有唯一的所有者,但在某些情况下,很难为每个值找到一个具有所需生命周期的单一所有者;你可能希望某个值一直存在,直到所有使用它的地方都不再使用它。针对这些情况,Rust提供了引用计数指针类型Rc
和Arc
。正如你对Rust的预期,使用它们是完全安全的:你不会忘记调整引用计数,不会创建Rust检测不到的指向引用对象的其他指针,也不会遇到C++中引用计数指针类型常出现的其他问题。
Rc
和Arc
类型非常相似,它们之间唯一的区别是Arc
可以直接在线程之间安全共享(Arc
这个名字是“原子引用计数”的缩写),而普通的Rc
使用速度更快但线程不安全的代码来更新其引用计数。如果你不需要在线程之间共享指针,就没有理由承担Arc
带来的性能损失,所以应该使用Rc
;Rust会防止你意外地将Rc
跨线程传递。除此之外,这两种类型是等效的,所以在本节的剩余部分,我们只讨论Rc
。
前面我们展示了Python如何使用引用计数来管理值的生命周期。在Rust中,你可以使用Rc
获得类似的效果。考虑以下代码:
use std::rc::Rc;
// Rust可以推断出所有这些类型;为了清晰起见,这里写了出来
let s: Rc<String> = Rc::new("shirataki".to_string());
let t: Rc<String> = s.clone();
let u: Rc<String> = s.clone();
2
3
4
5
6
对于任何类型T
,Rc<T>
值是一个指向在堆上分配的T
的指针,并且该指针附带了一个引用计数。克隆一个Rc<T>
值不会复制T
,而是简单地创建另一个指向它的指针,并增加引用计数。所以前面的代码在内存中产生的情况如图4 - 12所示。
图4 - 12. 一个有三个引用的引用计数型字符串
这三个Rc<String>
指针都指向同一块内存,这块内存保存着一个引用计数和存储String
的空间。通常的所有权规则适用于Rc
指针本身,当最后一个现存的Rc
被丢弃时,Rust也会丢弃String
。
你可以直接在Rc<String>
上使用String
的任何常用方法:
assert!(s.contains("shira"));
assert_eq!(t.find("taki"), Some(5));
println!("{} are quite chewy, almost bouncy, but lack flavor", u);
2
3
由Rc
指针所指向的值是不可变的。假设你尝试在字符串末尾添加一些文本:
s.push_str(" noodles");
Rust会拒绝:
error: cannot borrow data in an `Rc` as mutable
- ownership/ownership_rc_mutability.rs:13:5
13 | s.push_str(" noodles");
^ cannot borrow as mutable
2
3
4
Rust的内存和线程安全保证依赖于确保任何值都不会同时被共享且可变。Rust假定Rc
指针指向的对象通常可能是被共享的,所以它不能是可变的。我们将在第5章解释为什么这个限制很重要。
使用引用计数管理内存时,一个众所周知的问题是,如果有两个引用计数值相互指向对方,那么每个值都会使对方的引用计数保持在大于零的状态,这样这些值就永远不会被释放(图4 - 13)。
图4 - 13. 引用计数循环;这些对象将不会被释放
在Rust中,确实有可能以这种方式导致值泄漏,但这种情况很少见。如果不使较旧的值指向较新的值,就不可能创建循环引用。显然,这需要较旧的值是可变的。由于Rc
指针使它们指向的对象是不可变的,所以通常情况下不可能创建循环引用。然而,Rust确实提供了在其他方面不可变的值中创建可变部分的方法,这被称为内部可变性(interior mutability),我们将在“内部可变性”中介绍。如果你将这些技术与Rc
指针结合使用,就可以创建循环引用并导致内存泄漏。
有时,你可以通过使用弱指针std::rc::Weak
来替代某些链接,从而避免创建Rc
指针的循环引用。不过,我们在本书中不会介绍这些内容;详细信息请查看标准库的文档。
移动和引用计数指针是两种放宽所有权树严格性的方式。在下一章,我们将介绍第三种方式:借用值的引用。
一旦你对所有权和借用都得心应手,就意味着你已经跨越了Rust学习曲线中最陡峭的部分,准备好利用Rust的独特优势了。