我发现在我甚至可以编译代码之前,Rust已经迫使我学习了许多我在C/C++中慢慢学习的"好实践(goog practice)"......我想强调的是,Rust不是那种几天就能学会的,以后才去处理难的/技术性的/良好实践的东西的语言.你将被迫立即学习严格的安全性,可能一开始会感到不舒服.然而,根据我自己的经验.这让我感觉编译代码对我来说又有了意义. --Mitchell Nordine
Rust提供了以下两个承诺,这对于安全系统编程语言至关重要:
- 你可以决定程序中每个值的生命周期.Rust会在你控制的某个时刻立即释放属于某个值的内存和其他资源.
- 即便如此,你的程序永远不会在对象被释放后使用指向它的指针.使用 悬空指针(dangling pointer) 是C和C++中的一个常见错误:如果你很幸运,你的程序会崩溃.如果你运气不好,你的程序有一个安全漏洞.Rust在编译时捕获这些错误.
C和C++遵守第一个承诺:你可以随时在动态分配堆中的任何对象上调用free
或delete
.但作为交换,第二个承诺被搁置一旁:确保不会使用指向你已释放的值的指针,完全是你的责任.有充分的经验证据表明,这是一项难以承担的责任:根据收集到的数据,指针滥用已是报告安全问题的公共数据库的常见罪犯.
很对语言都使用垃圾收集来实现第二个承诺,只有当所有可到达的指针都消失时才自动释放对象.但是作为交换,你将对象被释放的精确时间的控制权完全移交给收集器.一般来说,垃圾收集器是出乎意料的东西,理解为什么内存没有在你预期的时候释放是一个挑战.如果你正在处理表示文件,网络连接或其他操作系统资源的对象,却无法相信它们将在你预期的时候被释放,并且它们的底层资源与它们一起被清理,这是令人失望的.
Rust不接受这些妥协:程序员应该控制值的生命周期, 并且(and) 语言应该是安全的.但这是一个探索性很强的语言设计领域.没有一些根本性的改变,你无法做出重大改进.
Rust以令人惊讶的方式打破僵局:限制程序如何使用指针.本章和下一章致力于准确解释这些限制是什么以及它们的工作原理.现在,我只想说,你习惯使用的一些常见结构可能不符合规则,你需要寻找替代方案.但这些限制的实际效果是为混乱带来足够的秩序,以允许Rust的编译时检查来验证你的程序没有内存安全错误:悬空指针,双重释放,使用未初始化的内存,等等.在运行时,指针是内存中的简单地址,就像它们在C和C++中一样.不同之处在于你的代码已被证明可以安全地使用它们.
这些相同的规则也构成了Rust对安全并发编程支持的基础.使用Rust精心设计的线程原语,确保代码正确使用内存的规则也可以证明它没有数据竞争.Rust程序中的错误不会导致一个线程破坏另一个线程的数据,从而在系统的不相关部分引入难以重现的故障.多线程代码中固有的不确定性行为与设计用来处理它的特性--互斥锁,消息通道,原子值,等等隔离开来--而不是出现在普通的内存引用中.C和C++中的多线程代码已经获得了丑恶的名声,但Rust很好地修复了它.
Rust的激进赌注,它成功的断言,以及形成语言根源,是即使有这些限制,你会发现这种语言对于几乎所有的任务来说都非常灵活,并且有益处--消除广泛的内存管理和并发错误--将证明你需要对你风格进行调整.由于我们在C和C++方面的丰富经验,本书的作者对Rust非常乐观.对我们来说,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
缓冲区中的字符的指针,但是当字符串被销毁时,你的指针就会失效,你必须确保不再使用它.所有者确定所有物的生命周期,其他人必须尊重其决定.
Rust从注释中采用了这个原则,并使其在语言中明确.在Rust中,每个值都有一个所有者来确定其生命周期.当所有者被释放时-- dropped ,在Rust术语中--所拥有的值也会被释放.这些规则旨在让你只需检查代码,即可轻松找到任何给定值的生命周期,从而使你可以控制系统语言应提供的生命周期.
变量拥有它的值.当控制离开变量声明的块时,变量将被释放,因此它的值也随之释放,例如:
fn print_padovan() {
let mut padovan = vec![1,1,1]; // allocated here
for i in 3..10 {
let next = padovan[i-3] + padovan[i-2];
padovan.push(next);
}
println!("P(1..10) = {:?}", padovan
);
} // dropped here
变量padovan
的类型是std::vec::Vec<i32>
,一个32位整数向量.在内存中,padovan
的最终值如图4-2所示.
图4-2. 栈中的Vec32,指向它的堆中缓冲区.
这与我们之前展示的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 allocated here
let label = format!("{:?}", point); // label allocated here
assert_eq!(label, "(0.625, 0.5)");
} // both dropped here
当程序调用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);
}
这里,composers
是一个Vec<Person>
,一个结构的向量,它的每个元素都包含一个字符串和一个数字.在内存中,最终composers
的值如图4-4所示.
图4-4 更复杂的所有权树.
这里有很多所有权关系,但每一个都很简单:composers
拥有一个向量;向量拥有其元素,每个元素都是Person
结构体;每个结构体都拥有自己的字段;并且字符串字段拥有其文本.当控制器离开声明composers
的作用域时,程序会丢弃它的值,并随之带走整个排列.如果图片中还有其他的集合类型--或许是HashMap
,或者是BTreeSet
--故事将是一样的.
在这一点上,后退一步,考虑一下下到目前为止我们提出的所有权关系的结果.每个值都有一个所有者,因此可以很容易决定何时删除它.但是单个值可能拥有许多其他值:例如,向量composers
拥有其所有元素.这些值可能依次拥有其他值:composers
的每个元素都拥有一个字符串,该字符串拥有其文本.
因此,所有者及其拥有的值形成 树(tree) :你的所有者是你的父母,你拥有的值是你的孩子.每棵树的最终根是一个变量;当该变量超出作用域时,整个树都随之而去.我们可以在图表中看到composers
的这种所有权树:它不是搜索树数据结构意义上的"树",也不是由DOM元素构成的HTML文档.相反,我们有一个由类型混合构建的树,Rust的单一所有者规则禁止任何结构的重新连接,这可能使排列比树更为复杂.Rust程序中的每个值都是某个树的成员,以某个变量为根.
Rust程序通常不会显式地删除值,就像C和C++程序使用free
和delete
一样.在Rust中删除值的方法是以某种方式从所有权树中移除它:通过保留变量的作用域,或从向量中删除元素,或者那种类型的东西.此时,Rust确保正确删除值,以及它拥有的所有内容.
从某种意义上说,Rust不如其它语言强大:每一种其他实用的编程语言都可以让你以任何你认为合适的方式构建对象的任意图形(对象互相指).但正是因为Rust不那么强大,语言可以对你的程序进行的分析可以更强大.Rust的安全保证可能正是因为它在代码中可能会遇到的关系更容易处理.这是我们之前提到的Rust的"激进赌注(radical wager)"的一部分:在实践中,Rust声称,在解决问题方面通常有足够的灵活性,以确保至少一些完美的解决方案符合语言的限制规定.
也就是说,到目前为止我们讲过的故事仍然过于严格,无法使用.Rust以几种方式扩展了这张图:
- 你可以将值从一个所有者移动到另一个所有者.这允许你构建,重新排列和拆除树.
- 标准库提供了引用计数指针类型
Rc
和Arc
,它们允许值在一些限制下具有多个所有者. - 你可以对一个值"借用一个引用(borrow a reference)";引用是没有所有权的指针(nonowning pointers),生命周期有限.
这些策略中的每一个都为所有权模型提供了灵活性,同时仍然坚持Rust的承诺.我们将依次解释每一个,下一章将介绍引用.
在Rust中,对于大多数类型,诸如为变量赋值,将其传递给函数或从函数返回它等操作都不会复制值:它们会 移动(move) 它.来源放弃了值所有权给了目标,并且变为没有初始化;目标现在控制值的生命周期.Rust程序建立和拆除复杂结构一次一个值,一次一个移动.
你可能会惊讶于Rust会改变这种基本操作的含义;当然,在历史的这一点上,赋值应该是非常明确的.但是,如果仔细观察不同语言如何选择处理赋值,你会发现不同学校之间实际上存在显着差异.这种比较也使Rust的选择的意义和结果更容易看到.
考虑下面的Python代码:
s = ['udon', 'ramen', 'soba']
t = s
u = s
每个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;
s
的原始值在内存中如图4-7所示.
图4-7. C++怎样在内存中表示字符串向量.
当程序将s
赋值给t
和u
时会发生什么?赋值std::vector
会在C++中生成向量的副本;std::string
行为相似.因此,当程序到达此代码的末尾时,它实际上已分配了3个向量和9个字符串(图4-8).
图4-8. 在C++中将s赋值给t和u的结果.
根据所涉及的值,C++中的赋值可能会消耗无限量的内存和处理器时间.然而,优点是程序很容易决定何时释放所有这些内存:当变量超出作用域,此处分配的所有内容都会自动清理.
从某种意义上说,c++和Python选择了相反的权衡:Python使赋值变得便宜,代价是需要引用计数(在一般情况下,是垃圾收集).c++保持所有内存的所有权是清晰的,赋值的代价是执行对象的深拷贝(deep copy).c++程序员通常不太热衷于这个选择:深拷贝可能很昂贵,而且通常有更实用的替代方案.
那么Rust的类似程序会做什么?这是代码:
let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string()];
let t = s;
let u = s;
与C和C++一样,Rust在只读存储器其中放置了像"udon"
这样的普通字符串文字,因此为了与C++和Python示例进行更清晰的比较,我们在这里调用to_string
来获取堆分配的String
值.
在执行s
的初始化之后,由于Rust和C++对向量和字符串使用类似的表示,情况看起来就像在C++中一样(图4-9).
图4-9. Rust怎样在内存中表示字符串向量.
但请记住,在Rust中,大多数类型的赋值会将值从源 移动(move) 到目标,从而使源未初始化.因此在初始化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`
--> ownership_double_move.rs:9:9
|
8 | let t = s;
| - value moved here
9 | let u = s;
| ^ value used here after move
|
考虑一下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();
你还可以使用Rust的引用计数指针类型重新创建Python的行为;我们将在第90页的"Rc和Arc:共享所有权(Rc and Arc: Shared Ownership)"中讨论这些内容.
在到目前为止的示例中,我们已经显示了初始化,在变量进入作用域时在let
语句中为其提供值.给变量赋值略有不同,如果将值移动到已初始化的变量中,Rust将删除变量的先前值.例如:
let mut s = "Govinda".to_string();
s = "Siddhartha".to_string(); // value "Govinda" dropped here
在此代码中,当程序将字符串"Siddhartha"
赋值给s
时,其先前值"Govinda"
将首先被删除.但请考虑以下代码:
let mut s = "Govinda".to_string();
let t = s;
s = "Siddhartha".to_string(); // nothing is dropped here
这一次,t
拿走了s
中原始字符串的所有权,因此在我们赋值给s
时,它是未初始化的.在这种情况下,不会删除任何字符串.
我们在这里的示例中使用了初始化和赋值,因为它们很简单,但Rust将移动语义应用于几乎任何值的使用.将参数传递给函数会将所有权移到函数的参数中;从函数返回值会将所有权移至调用者.构建元组会将值移动到元组中.等等.
你现在可以更好地了解我们在上一节中提供的示例中实际发生的情况.例如,当我们构建我们的作曲家向量时,我们写道:
struct Person { name: String, birth: i32 }
let mut composers = Vec::new();
composers.push(Person { name: "Palestrina".to_string(),
birth: 1525 });
此代码显示了几个发生移动的地方,超出了初始化和赋值:
从函数返回值
调用Vec::new()
构造一个新的向量,并返回,不是指向向量的指针,而是向量本身:它的所有权从Vec::new
移动到变量composers
.类似地,to_string
调用返回一个新的String
实例.
构造新值
新Person
结构体的name
字段使用to_string
的返回值来初始化.结构体取得字符串的所以权.
传递值给函数
整个Person
结构体,不仅仅是一个指针,传递给了向量的push
方法,将其移动到了结构(指向量)的末尾.向量取得Person
的所有权,因此也成为名称String
的间接所有者.
像这样移动值可能听起来效率低下,但要记住两件事.首先,移动始终适用于适当的值,而不是它们拥有的堆存储.对于向量和字符串, (适当的值)value proper 是单独的三字标题;潜在的大型元素数组和文本缓冲区位于堆中的位置.其次,Rust编译器的代码生成擅长"透视(seeing through)"所有这些移动;实际上,机器代码通常将值直接存储在它所属的位置.
前面的例子都有非常简单的控制流;移动如何与更复杂的代码交互?一般的原则是,如果一个变量的值可能被移走了,而且从那以后它肯定没有被赋予新的值,那么它就被认为是未初始化的.例如,如果一个变量在求if
表达式的条件的值后仍然有一个值,那么我们可以在两个分支中使用它:
let x = vec![10, 20, 30];
if c {
f(x); // ... ok to move from x here
} else {
g(x); // ... and ok to also move from x here
}
h(x) // bad: x is uninitialized here if either path uses it
出于类似的原因,禁止在循环中的变量移动:
let x = vec![10, 20, 30];
while f() {
g(x); // bad: x would be moved in first iteration,
// uninitialized in second
}
也就是说,除非我们在下一次迭代中确实给它一个新值:
let mut x = vec![10, 20, 30];
while f() {
g(x); // move from x
x = h(); // give x a fresh value
}
e(x);
我们已经提到过,移动会使其源未初始化,因为目标取得了值的所有权.但并非每一种值的所有者都准备好成为未初始化的.例如,请考虑以下代码:
// Build a vector of the strings "101", "102", ... "105"
let mut v = Vec::new();
for i in 101 .. 106 {
v.push(i.to_string());
}
// Pull out random elements from the vector.
let third = v[2];
let fifth = v[4];
为了实现这一点,Rust会以某种方式需要记住向量的第三个和第五个元素已经变为未初始化,并跟踪该信息直到向量被删除.在最一般的情况下,向量需要随身携带额外的信息,以指示哪些元素是活的,哪些元素是未初始化的.这显然不是系统编程语言的正确行为,向量应该只是一个向量.实际上,Rust拒绝前面的代码,并出现以下错误:
error[E0507]: cannot move out of indexed content
--> ownership_move_out_of_vector.rs:14:17
|
14 | let third = v[2];
| ^^^^
| |
| help: consider using a reference instead `&v[2]`
| cannot move out of indexed content
移动到fifth
,它也有类似的抱怨.在错误消息中,Rust建议使用引用,假如你想要访问元素而不移动它.这通常是你想要的.但是如果你真的想要把一个元素从向量中移出来呢?你需要找到一种方法,以一种尊重类型限制的方式来实现这一点.这有三种可能性:
// Build a vector of the strings "101", "102", ... "105"
let mut v = Vec::new();
for i in 101 .. 106 {
v.push(i.to_string());
}
// 1. Pop a value off the end of the vector:
let fifth = v.pop().unwrap();
assert_eq!(fifth, "105");
// 2. Move a value out of the middle of the vector, and move the last
// element into its spot:
let second = v.swap_remove(1);
assert_eq!(second, "102");
// 3. Swap in another value for the one we're taking out:
let third = std::mem::replace(&mut v[2], "substitute".to_string());
assert_eq!(third, "103");
// Let's see what's left of our vector.
assert_eq!(v, vec!["101", "104", "substitute"]);
这些方法中的每一个都将一个元素移出向量,但这样做会使向量处于完全填充的状态(有可能更小).
像Vec
这样的集合类型通常也提供了在loop
中使用其所有元素的方法:
let v = vec!["liberté".to_string(),
"égalité".to_string(),
"fraternité".to_string()];
for mut s in v {
s.push('!');
println!("{}", s);
}
当我们将向量直接传递给循环时,就像在for ... in v
中,这会将向量移出(moves) 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 });
你不能这样做:
let first_name = composers[0].name;
这只会引出前面所示的"无法移出索引的内容(cannot move out of indexed content)"错误.但是因为你已将名称字段的类型从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);
replace
调用移出composer[0].name
的值,将None
留在其位置,并将原始值的所有权传递给调用者.实际上,这种使用Option
的方式非常常见,因此类型为这种目的提供了take
方法.你可以更清晰地编写前面的操作,如下:
let first_name = composers[0].name.take();
此take
调用与之前的replace
调用具有相同的效果.
到目前为止我们已经展示的值被移动的示例涉及向量,字符串和其他类型,这些类型可能会占用大量内存并且复制起来很昂贵.
移动使这些类型的所有权保持清晰,并且赋值成本低廉.但对于像整数或字符这样更简单的类型,这种谨慎处理实际上并不是必需的.
比较当我们给String
赋值时在内存中发生的事情和当我们为i32
赋值时发生的事情:
let str1 = "somnambulance".to_string();
let str2 = str1;
let num1: i32 = 36;
let num2 = num1;
运行此代码后,内存如图4-11所示.
图4-11. 赋值字符串会移动值,而赋值i32会复制它.
与前面的向量一样,赋值将str1
移动(moves) 到str2,因此我们最终不会有两个字符串负责释放相同的缓冲区.但是,num1
和num2
的情况不同.i32
只是内存中的一个位模式;它不拥有任何堆资源,或者真正依赖于它所包含的字节以外的任何东西.当我们将它的位移动到num2
时,我们已经制造了一个完全独立的num1
的副本.
移动一个值会留下未初始化的移动源.但是,尽管把str1
当作没有价的东西是有必要的,但是这样对待num1
是没有意义的;继续使用它不会造成任何伤害.移动的好处在这里并不适用,而且也不方便.
之前我们谨慎地说 大多数(most) 类型都被移动:现在我们来看看例外,Rust指定为 Copy
类型(Copy
type) 的类型.给Copy
类型赋值会复制该值,而不是移动它.赋值的源仍然已初始化并可用,其值与之前相同.将Copy
类型传递给函数和构造函数的行为类似.
标准Copy
类型包括所有机器整数和浮点数字类型,char
和bool
类型,以及一些其它类型.Copy
类型的元组或固定大小的数组本身就是Copy
类型.
只有满足简单的逐位复制的类型才是Copy
.正如我们已经解释过的,String
不是Copy
类型,因为它拥有堆分配的缓冲区.出于类似的原因,Box<T>
不是Copy
;它拥有堆分配的指示对象.表示操作系统文件句柄的File
类型不是Copy
;复制这样的值将需要向操作系统询问另一个文件句柄.类似地,表示锁定互斥锁的MutexGuard
类型不是Copy
:此类型完全没有意义复制,因为某一时刻只有一个线程可以持有互斥锁.
根据经验,在删除值时需要执行某些特殊操作的任何类型都不能是Copy
.Vec
需要释放它的元素;File
需要关闭其文件句柄;MutexGuard
需要解锁其互斥锁.这种类型的逐位复制将使得不清楚哪个值现在负责原始资源.
你定义自己的类型呢?默认情况下,struct
和enum
类型不是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);
这并不能编译,Rust抱怨:
error[E0382]: use of moved value: `l.number`
--> ownership_struct.rs:12:40
|
11 | print(l);
| - value moved here
12 | println!("My label number is: {}", l.number);
| ^^^^^^^^ value used here after move
|
= note: move occurs because `l` has type `main::Label`, which does not
implement the `Copy` trait
由于Label
不是Copy
,因此将其传递给print
会将值的所有权移至print
函数,然后在返回之前将其删除.但这很愚蠢;Label
只不过是带有自命不凡的i32
而已.将l
传递给print
没有理由应当移动值.
但是用户定义的类型是非Copy
的只是默认情况.如果struct
的所有字段本身都是Copy
,那么你也可以通过将属性#[derive(Copy, Clone)]
放在定义之上来使类型Copy
,如下所示:
#[derive(Copy, Clone)]
struct Label { number: u32 }
通过此更改,前面的代码可以毫无怨言地编译.但是,如果我们在一个其字段不全是Copy
的类型上尝试此操作,则它不起作用.编译以下代码:
#[derive(Copy, Clone)]
struct StringLabel { name: String }
引发这个错误:
error[E0204]: the trait `Copy` may not be implemented for this type
--> ownership_string_label.rs:7:10
|
7 | #[derive(Copy, Clone)]
| ^^^^
8 | struct StringLabel { name: String }
| ------------ this field does not implement `Copy`
为什么用户自定义类型不会自动Copy
,假设它们符合条件?类型是否为复制对于允许代码如何使用它有很大影响:Copy
类型更灵活,因为赋值和相关操作不会使原来的未初始化.但是对于类型的实现者,情况恰恰相反:Copy
类型可以包含的类型非常受限制,而非Copy
类型可以使用堆分配并拥有其他类型的资源.因此,制作类型Copy
代表了实现者的严肃承诺:如果有必要将其更改为非Copy
,则可能需要调整使用它的大部分代码.
虽然C++允许你重载赋值运算符并定义专门的复制和移动构造函数,但Rust不允许这种自定义.在Rust中,每次移动都是一个逐字节的浅拷贝,使源成为未初始化状态.复制是相同的,除了源保持初始化.这确实意味着C++类可以提供Rust类型无法提供的方便接口,在这种情况下,外表普通的代码隐式地调整引用计数,推迟昂贵的复制以供日后使用,或者使用其他复杂的实现技巧.
但是这种灵活性对C++作为一种语言的影响是使基本操作如赋值,传递参数和从函数中返回值更不可预测.例如,在本章前面我们展示了如何在C++中将一个变量赋值给另一个变量可能需要任意数量的内存和处理器时间.Rust的原则之一是成本应该对程序员来说是显而易见的. 基本操作必须保持简单.潜在的昂贵操作应该是明确的,就像前面的例子中的clone
调用那样,那会生成向量及其包含的字符串的深拷贝.
在本节中,我们以模糊的术语讨论了Copy
和Clone
作为类型可能具有的特征.它们实际上是 trait 的例子,Rust的开放式类型分类基于你能用它们做什么.我们将在第11章中详细描述traits,在第13章中特别描述Copy
和Clone
.
虽然在典型的Rust代码中,大多数值都有唯一的所有者,但在某些情况下,很难找到具有所需生命周期的,单个所有者的每个值;你希望在每个人都完成使用之前,只需要活着.对于这些情况,Rust提供了引用计数指针类型Rc
和Arc
.正如你对Rust的期望一样,这些都是完全可以安全使用的:你不可能忘记调整引用计数,或者创建指向Rust没有注意到的引用的其他指针,或者在在c++中伴随引用计数指针类型的任何其他问题上出错.
Rc
和Arc
类型非常相似;它们之间唯一的区别是Arc
可以直接在线程之间安全共享--名称Arc
是 原子引用计数(atomic reference count) 的缩写--而普通的Rc
使用更快的非线程安全代码来更新其引用计数.如果你不需要在线程之间共享指针,则没有理由支付Arc
的性能损失,因此你应该使用Rc
,Rust会阻止你意外地穿过线程边界传递一个Rc
.这两种类型在其他方面是等效的,因此对于本节的其余部分,我们只讨论Rc
.
在本章的前面部分,我们展示了Python如何使用引用计数来管理其值的生命周期.在Rust中,你可以使用Rc
获得类似的效果.考虑以下代码:
use std::rc::Rc;
// Rust can infer all these types; written out for clarity
let s: Rc<String> = Rc::new("shirataki".to_string());
let t: Rc<String> = s.clone();
let u: Rc<String> = s.clone();
对于任何类型T
,Rc<T>
值是指向堆分配的T的指针,该T
具有附加的引用计数.克隆Rc<T>
值不会复制T
;相反,它只是创建另一个指向它的指针,并增加引用计数.因此前面的代码在内存中产生图4-12所示的情况.
三个Rc<String>
指针中的每一个都指向相同的内存块,该内存块保存引用计数和String的空间.通常的所有权规则适用于Rc
指针本身,当最后一个现存的Rc
被删除时,Rust也会丢弃字符串.
图4-12. 一个引用计数的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);
Rc
指针拥有的值是不可变的.如果你尝试在字符串的末尾添加一些文本:
s.push_str(" noodles");
Rust会拒绝:
error: cannot borrow immutable borrowed content as mutable
--> ownership_rc_mutability.rs:12:5
|
12 | s.push_str(" noodles");
| ^ cannot borrow as mutable
Rust的内存和线程安全保证依赖于,,确保任何值都不会同时共享和可变.Rust假设Rc
指针的引用通常是共享的,所以它不能是可变的.我们在第5章解释为什么这个限制很重要.
使用引用计数来管理内存的一个众所周知的问题是,如果有两个引用计数的值指向彼此,则每个引用计数值将保持在零以上,因此这些值永远不会释放(图4-13).
图4-13. 一个引用计数循环;这些对象不能释放.
以这种方式泄露Rust中的值是可能的,但这种情况很少见.如果没有在某个时刻使较旧的值指向较新的值,则无法创建循环.这显然要求较旧的值是可变的.由于Rc
指针使它们的指示对象不可变,因此通常不可能创建一个循环.但是,Rust确实提供了创建其他不可变值的可变部分的方法;这被称为 内部可变性(interior mutability) ,我们在第205页的"内部可变性(Interior Mutability)"中介绍它.如果将这些技术与Rc
指针结合使用,则可以创建循环和泄漏内存.
有时,你可以通过使用 弱指针(weak pointers) std::rc::Weak
来避免创建Rc
指针的循环.但是,我们不会在本书中涵盖这些内容;有关详细信息,请参阅标准库的文档.
移动和引用计数指针是放松所有权树的死板的两种方法.在下一章中,我们将看第三种方式:借用对值的引用. 一旦你对所有权和借用都感到满意,你就会攀登上Rust学习曲线中最陡峭的部分,并且你将准备好利用Rust的独特优势.