前言
通过Rust程序设计-第二版
笔记的形式对Rust相关重点知识
进行汇总,读者通读此系列文章就可以轻松的把该语言基础捡起来。
1.所有权与移动
谈及内存管理,希望编程语言具备两个特点:
- 希望内存能在我们选定的时机及时释放,这使我们能控制程序的内存消耗;
- 在对象被释放后,不希望继续使用指向它的指针,这是未定义行为,会导致崩溃和安全漏洞。
Rust 通过限制程序使用指针的方式出人意料地打破了这种困局。
Rust 的编译期检查器有能力验证程序中是否存在内存安全错误:悬空指针、重复释放、使用未初始化的内存等。
Rust 程序中的缺陷不会导致一个线程破坏另一个线程的数据,进而在系统中的无关部分引入难以重现的故障。
相关问题:
- 看看所有权在概念层和实现层分别意味着什么?
- 如何在各种场景中跟踪所有权的变化?
- 哪些情况下要改变或打破其中的一些规则,以提供更大的灵活性?
1.1 所有权
拥有对象意味着可以决定何时释放此对象:当销毁拥有者时,它拥有的对象也会随之销毁。
变量拥有自己的值,当控制流离开声明变量的块时,变量就会被丢弃,因此它的值也会一起被丢弃。例如:
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);
} // 在此丢弃
变量 padovan 的类型为 Vec<i32>
。在内存中,padovan 的最终值如图所示。
跟C++ std::string 非常相似,不过缓冲区中的元素都是 32 位整数,而不是字符。
Rust 的 Box 类型是所有权的另一个例子。Box 是指向存储在堆上的 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)");
} // 在此全都丢弃了
当程序调用 Box::new
时,它会在堆上为由两个 f64
值构成的元组分配空间,然后将其参数 (0.625, 0.5)
移进去,并返回指向该空间的指针。当控制流抵达对 assert_eq!
的调用时,栈帧如图 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 的最终值如图所示。
这里有很多所有权关系,但每个都一目了然: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 从
所有权概念仍然过于严格,还处理不了某些场景,但是,Rust 从几个方面扩展了这种简单的思想。
- 可以将值从一个拥有者转移给另一个拥有者。这允许你构建、重新排列和拆除树形结构。
- 像整数、浮点数和字符这样的非常简单的类型,不受所有权规则的约束。这些称为 Copy 类型。
- 标准库提供了引用计数指针类型 Rc 和 Arc,它们允许值在某些限制下有多个拥有者。
- 可以对值进行“借用”(borrow),以获得值的引用。这种引用是非拥有型指针,有着受限的生命周期。
1.2 移动
在 Rust 中,对大多数类型来说,像为变量赋值、将其传给函数或从函数返回这样的操作都不会复制值,而是会移动值。源会把值的所有权转移给目标并变回未初始化状态,改由目标变量来控制值的生命周期。Rust 程序会以每次只移动一个值的方式建立和拆除复杂的结构。
类比C++ 代码:
using namespace std;
vector<string> s = {
"udon", "ramen", "soba" };
vector<string> t = s;
vector<string> u = s;
s 的原始值在内存中如图所示。
当程序将 s 赋值给 t 和 u 时会发生什么?
在 C++ 中,把 std::vector
赋值给其他元素会生成一个向量的副本,std::string
的行为也类似。所以当程序执行到这段代码的末尾时,它实际上已经分配了 3 个向量和 9 个字符串,如图 所示(在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;
与 C 和 C++ 一样,Rust 会将纯字符串字面量(如 “udon”)放在只读内存中,因此为了与 C++ 示例和 Python 示例进行更清晰的比较,此处调用了 to_string 以获取堆上分配的 String 值。
在执行了 s 的初始化之后,由于 Rust 和 C++ 对向量和字符串使用了类似的表示形式,因此情况看起来就和 C++ 中一样,如图 所示(Rust如何表示内存中的字符串向量
)。
但要记住,在 Rust 中,大多数类型的赋值会将值从源转移给目标,而源会回到未初始化状态。因此在初始化 t 之后,程序的内存如图 所示(Rust中将s赋值给t的结果
)。
这里发生了什么?
初始化语句 let t = s;
将向量的 3 个标头字段从 s 转移给了 t,现在 t 拥有此向量。向量的元素保持原样,字符串也没有任何变化。每个值依然只有一个拥有者,尽管其中一个已然易手。整个过程中没有需要调整的引用计数,不过编译器现在会认为 s 是未初始化状态。
那么当我们执行初始化语句 let u = s;
时会发生什么呢?这会将尚未初始化的值 s 赋给 u。Rust 明智地禁止使用未初始化的值,因此编译器会拒绝此代码并报告如下错误:
error: use of moved value: `s`
思考一下 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();
1.2.1 更多移动类操作
在先前的例子中,我们已经展示了如何初始化工作——在变量进入 let 语句的作用域时为它们提供值。给变量赋值则与此略有不同,如果你将一个值转移给已初始化的变量,那么 Rust 就会丢弃该变量的先前值。例如:
let mut s = "Govinda".to_string();
s = "Siddhartha".to_string(); // 在这里丢弃了值"Govinda"
在上述代码中,当程序将字符串 “Siddhartha” 赋值给 s 时,它的先前值 “Govinda” 会首先被丢弃。但请考虑以下代码:
let mut s = "Govinda".to_string();
let t = s;
s = "Siddhartha".to_string(); // 这里什么也没有丢弃
这一次,t 从 s 接手了原始字符串的所有权,所以当给 s 赋值时,它是未初始化状态。这种情况下不会丢弃任何字符串。
这个例子中使用了初始化和赋值,因为它们很简单,但 Rust 还将“移动”的语义应用到了几乎所有对值的使用上。例如,将参数传给函数会将所有权转移给函数的参数、从函数返回一个值会将所有权转移给调用者、构建元组会将值转移给元组。
例如,我们在构建 composers 向量时,是这样写的:
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 的所有权,因此也间接接手了 name 这个 String 的所有权。
像这样移动值乍一看可能效率低下,但有两点需要牢记。首先,移动的永远是值本身,而不是这些值拥有的堆存储。对于向量和字符串,值本身就是指单独的“三字标头”,幕后的大型元素数组和文本缓冲区仍然位于它们在堆中的位置。其次,Rust 编译器在生成代码时擅长“看穿”这一切动作。在实践中,机器码通常会将值直接存储在它应该在的位置。
1.2.2 移动与控制流
前面的例子中都有非常简单的控制流,**那么该如何在更复杂的代码中移动呢?**一般性原则是,如果一个变量的值有可能已经移走,并且从那以后尚未明确赋予其新值,那么它就可以被看作是未初始化状态。如果一个变量在执行了 if 表达式中的条件后仍然有值,那么就可以在这两个分支中使用它:
let x = vec![10, 20, 30];
if c {
f(x); // ……可以在这里移动x
} else {
g(x); // ……也可以在这里移动x
}
h(x); // 错误:只要任何一条路径用过它,x在这里就是未初始化状态
出于类似的原因,禁止在循环中进行变量移动:
let x = vec![10, 20, 30];
while f() {
g(x); // 错误:x已经在第一次迭代中移动出去了,在第二次迭代中,它成了未初始化状态
}
也就是说,除非在下一次迭代中明确赋予 x 一个新值,否则就会出错。
let mut x = vec![10, 20, 30];
while f() {
g(x); // 从x移动出去了
x = h(); // 赋予x一个新值
}
e(x);
1.2.3 移动与索引内容
移动会令其来源变成未初始化状态,因为目标将获得该值的所有权。但并非值的每种拥有者都能变成未初始化状态。例如,考虑以下代码:
// 构建一个由字符串"101"、"102"……"105"组成的向量
let mut v = Vec::new();
for i in 101 .. 106 {
v.push(i.to_string());
}
// 从向量中随机抽取元素
let third = v[2]; // 错误:不能移动到 Vec 索引结构之外<sup><b>3</b></sup>
let fifth = v[4]; // 这里也一样
// 注:v[2] 而非 &v[2]
为了解决这个问题,Rust 需要以某种方式记住向量的第三个元素和第五个元素是未初始化状态,并要跟踪该信息直到向量被丢弃。通常的解决方案是,让每个向量都携带额外的信息来指示哪些元素是活动的,哪些元素是未初始化的。这显然不是系统编程语言应该做的。向量应该只是向量,不应该携带额外的信息或状态。
事实上,Rust 会拒绝前面的代码并报告如下错误:
error: cannot move out of index of `Vec`
移动第五个元素时 Rust 也会报告类似的错误。在这条错误消息中,Rust 还建议使用引用,因为你可能只是想访问该元素而不是移动它,这通常确实是你想要做的。但是,**如果真想将一个元素移出向量该怎么办呢?**需要