Rust基础拾遗
- 前言
- 1.所有权与移动
- 1.1 所有权
- 1.2 移动
- 1.2.1 更多移动类操作
- 1.2.2 移动与控制流
- 1.2.3 移动与索引内容
- 1.3 Copy 类型:关于移动的例外情况
- 1.4 Rc 与 Arc:共享所有权
- 2.引用
- 3.特型与泛型
- 简介
- 3.1 使用特型
- 3.2 特型对象
- 3.3 泛型函数与类型参数
- 4.实用工具特型
- 5.闭包
前言
通过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)); // 在此分配了pointlet label = format!("{:?}", point); // 在此分配了labelassert_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 还建议使用引用,因为你可能只是想访问该元素而不是移动它,这通常确实是你想要做的。但是,**如果真想将一个元素移出向量该怎么办呢?**需要找到一种在遵循类型限制的情况下执行此操作的方法。以下是 3 种可能的方法:
// 构建一个由字符串"101"、"102"……"105"组成的向量
let mut v = Vec::new();
for i in 101 .. 106 {v.push(i.to_string());
}// 方法一:从向量的末尾弹出一个值:
let fifth = v.pop().expect("vector empty!");
assert_eq!(fifth, "105");// 方法二:将向量中指定索引处的值与最后一个值互换,并把前者移动出来:
let second = v.swap_remove(1);
assert_eq!(second, "102");// 方法三:把要取出的值和另一个值互换:
let third = std::mem::replace(&mut v[2], "substitute".to_string());
assert_eq!(third, "103");// 看看向量中还剩下什么
assert_eq!(v, vec!["101", "104", "substitute"]);
每种方法都能将一个元素移出向量,但仍会让向量处于完全填充状态,只是向量可能会变小。
像 Vec 这样的集合类型通常也会提供在循环中消耗所有元素的方法:
let v = vec!["liberté".to_string(),"égalité".to_string(),"fraternité".to_string()];for mut s in v {s.push('!');println!("{}", s);
}
当我们将向量直接传给循环(如 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 });
但不能像下面这样做:
let first_name = composers[0].name;
这只会引发与前面一样的“无法移动到索引结构之外”错误。但是因为已将 name 字段的类型从 String 改成了 Option,所以这意味着 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 调用会移出 composers[0].name
的值,将 None 留在原处,并将原始值的所有权转移给其调用者。事实上,这种使用 Option 的方式非常普遍,所以该类型专门为此提供了一个 take 方法,以便更清晰地写出上述操作,如下所示:
let first_name = composers[0].name.take();
这个 take 调用与之前的 replace 调用具有相同的效果。
1.3 Copy 类型:关于移动的例外情况
**本章所展示的值移动示例都涉及向量、字符串和其他可能占用大量内存且复制成本高昂的类型。**移动能让这些类型的所有权清晰且赋值开销极低。但对于像整数或字符这样的简单类型,如此谨小慎微的处理方式确实没什么必要。下面来比较一下用 String 进行赋值和用 i32 进行赋值时内存中有什么不同:
let string1 = "somnambulance".to_string();
let string2 = string1;let num1: i32 = 36;
let num2 = num1;
运行这段代码后,内存如图 所示(用String赋值会移动值,而用i32赋值会复制值
)。
与前面的向量一样,赋值会将 string1转移给string2,这样就不会出现两个字符串负责释放同一个缓冲区的情况。但是,num1 和 num2 的情况有所不同。i32 只是内存中的几字节,它不拥有任何堆资源,也不会实际依赖除本身的字节之外的任何内存。当我们将它的每一位转移给 num2 时,其实已经为 num1 制作了一个完全独立的副本。
移动一个值会使移动的源变成未初始化状态。不过,尽管将 string1 视为未初始化变量确实符合其基本意图,但以这种方式对待 num1 毫无意义,继续使用 num1 也不会造成任何问题。移动在这里并无好处,反而会造成不便。
之前我们谨慎地说过,大多数类型会被移动,现在该谈谈例外情况了,即那些被 Rust 指定成 Copy 类型的类型。对 Copy 类型的值进行赋值会复制这个值,而不会移动它。赋值的源仍会保持已初始化和可用状态,并且具有与之前相同的值。把 Copy 类型传给函数和构造器的行为也是如此。
标准的 Copy 类型包括所有机器整数类型和浮点数类型、char 类型和 bool 类型,以及某些其他类型。Copy 类型的元组或固定大小的数组本身也是 Copy 类型。
只有那些可以通过简单地复制位来复制其值的类型才能作为 Copy 类型。前面解释过,String 不是 Copy 类型,因为它拥有从堆中分配的缓冲区。出于类似的原因,Box 也不是 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 会报错,可以自己实际实验一番
由于 Label 不是 Copy 类型,因此将它传给 print 会将值的所有权转移给 print 函数,然后在返回之前将其丢弃。这样做显然是愚蠢的,Label 中只有一个 u32,因此没有理由在将 l 传给 print 时移动这个值。
但是用户定义的类型不是 Copy 类型这一点只是默认情况而已。如果此结构体的所有字段本身都是 Copy 类型,那么也可以通过将属性 #[derive(Copy, Clone)]
放置在此定义之上来创建 Copy 类型,如下所示:
#[derive(Copy, Clone)]
struct Label { number: u32 }
经过此项更改,前面的代码可以顺利编译了。但是,如果试图在一个其字段不全是 Copy 类型的结构体上这样做,则仍然行不通。假设要编译如下代码,那么就会引发如下错误:
#[derive(Copy, Clone)]
struct StringLabel { name: String }
为什么符合条件的用户定义类型不能自动成为 Copy 类型呢?
这是因为类型是否为 Copy 对于在代码中使用它的方式有着重大影响:Copy 类型更灵活,因为赋值和相关操作不会把原始值变成未初始化状态。但对类型的实现者而言,情况恰恰相反:Copy 类型可以包含的类型非常有限,而非 Copy 类型可以在堆上分配内存并拥有其他种类的资源。因此,创建一个 Copy 类型代表着实现者的郑重承诺:如果以后确有必要将其改为非 Copy 类型,则使用它的大部分代码可能需要进行调整。
虽然 C++ 允许重载赋值运算符以及定义专门的复制构造函数和移动构造函数,但 Rust 并不允许这种自定义行为。在 Rust 中,每次移动都是字节级的一对一浅拷贝,并让源变成未初始化状态。复制也是如此,但会保留源的初始化状态。这确实意味着 C++ 类可以提供 Rust 类型所无法提供的便捷接口,比如可以在看似普通的代码中隐式调整引用计数、把昂贵的复制操作留待以后进行,或使用另一些复杂的实现技巧。
但这种灵活性的代价是,作为一门语言,C++ 的基本操作(比如赋值、传参和从函数返回值)变得更难预测。例如,本章的前半部分展示过在 C++ 中将一个变量赋值给另一个变量时可能需要任意数量的内存和处理器时间。Rust 的一个原则是:各种开销对程序员来说应该是显而易见的。基本操作必须保持简单,而潜在的昂贵操作应该是显式的,比如前面例子中对 clone 的调用就是在对向量及其包含的字符串进行深拷贝。
1.4 Rc 与 Arc:共享所有权
尽管在典型的 Rust 代码中大多数值会有唯一的拥有者,但在某些情况下,很难为每个值都找到具有所需生命周期的单个拥有者,你会希望某个值只要存续到每个人都用完它就好。对于这些情况,Rust 提供了引用计数指针类型 Rc 和 Arc [Arc 是原子引用计数(atomic reference count) 的缩写 ]。正如你对 Rust 的期待一样,这些类型用起来完全安全:你不会忘记调整引用计数,不会创建 Rust 无法注意到的指向引用目标的其他指针,也不会偶遇那些常与 C++ 中的引用计数指针如影随形的各种问题。
Rc 类型和 Arc 类型非常相似,它们之间唯一的区别是 Arc 可以安全地在线程之间直接共享,而普通 Rc 会使用更快的非线程安全代码来更新其引用计数。如果不需要在线程之间共享指针,则没有理由为 Arc 的性能损失“埋单”,因此应该使用 Rc,Rust 能防止你无意间跨越线程边界传递它。这两种类型在其他方面都是等效的,所以本节的其余部分只会讨论 Rc。
使用 Rc 在 Rust 中获得引用计数来管理值的生命周期类似的效果:
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();
对于任意类型 T,Rc 值是指向附带引用计数的在堆上分配的 T 型指针。克隆一个 Rc 值并不会复制 T,相反,它只会创建另一个指向它的指针并递增引用计数。所以前面的代码在内存中会生成图所示的结果。
这 3 个 Rc < String> 指针指向了同一个内存块,其中包含引用计数和 String 本身的空间。通常的所有权规则适用于 Rc 指针本身,当丢弃最后一个现有 Rc 时,Rust 也会丢弃 String。
可以直接在 Rc 上使用 String 的任何常用方法:
assert!(s.contains("shira"));
assert_eq!(t.find("taki"), Some(5));
println!("{} are quite chewy, almost bouncy, but lack flavor", u);
Rc 指针拥有的值是不可变的。如果你试图将一些文本添加到字符串的末尾,那么 Rust 会拒绝
:
s.push_str(" noodles");
Rust 的内存和线程安全保证的基石是:确保不会有任何值是既共享又可变的。Rust 假定 Rc 指针的引用目标通常都可以共享,因此就不能是可变的。
为什么这个限制很重要?
使用引用计数管理内存的一个众所周知的问题是,如果有两个引用计数的值是相互指向的,那么其中一个值就会让另一个值的引用计数保持在 0 以上,因此这些值将永远没机会释放,如图所示。
以这种方式在 Rust 中造成值的泄漏也是有可能的,但这种情况非常少见。只要不在某个时刻让旧值指向新值,就无法建立循环。这显然要求旧值是可变的。由于 Rc 指针会保证其引用目标不可变,因此通常不可能建立这种循环引用。但是,Rust 确实提供了创建其他不可变值中的可变部分的方法,这称为内部可变性。如果将这些技术与 Rc 指针结合使用,则确实可以建立循环并造成内存泄漏。
移动和引用计数指针是缓解所有权树严格性问题的两种途径。
2.引用
Rust 还有一种名为引用(reference)的非拥有型指针,这种指针对引用目标的生命周期毫无影响。
3.特型与泛型
简介
本章展示特型的用法、工作原理,以及如何定义你自己的特型。
标准库提供的公共特型。之后的闭包、迭代器、输入 / 输出和并发。特型和泛型在所有这些主题中都扮演着核心角色。
Rust 通过两个相关联的特性来支持多态:特型和泛型。
特型是 Rust 体系中的接口或抽象基类。
为什么向类型添加特型不需要额外的内存?
如何在不需要虚方法调用开销的情况下使用特型?
泛型是 Rust 中多态的另一种形式。
泛型和特型紧密相关:泛型函数会在限界中使用特型来阐明它能针对哪些类型的参数进行调用。
3.1 使用特型
特型代表着一种能力,即一个类型能做什么。
- 实现了 std::io::Write 的值能写出一些字节。
- 实现了 std::iter::Iterator 的值能生成一系列值。
- 实现了 std::clone::Clone 的值能在内存中克隆自身。
- 实现了 std::fmt::Debug 的值能用带有 {:?} 格式说明符的 println!() 打印出来。
特型方法类似于虚方法。
3.2 特型对象
在 Rust 中使用特型编写多态代码有两种方法:特型对象和泛型。
在 Rust 中,引用是显式的:
let mut buf: Vec<u8> = vec![];
let writer: &mut dyn Write = &mut buf; // 正确
对特型类型(如 writer)的引用叫作特型对象。
特型对象的内存布局
在内存中,特型对象是一个胖指针,由指向值的指针和指向表示该值类型的虚表的指针组成。
C++ 也有这种运行期类型信息,叫作虚表或 vtable。
在 C++ 中,虚表指针或 vptr 是作为结构体的一部分存储的,而 Rust 使用的是胖指针方案。结构体本身只包含自己的字段。这样一来,每个结构体就可以实现几十个特型而不必包含几十个 vptr 了。甚至连 i32 这样大小不足以容纳 vptr 的类型都可以实现特型。
3.3 泛型函数与类型参数
4.实用工具特型
5.闭包
fn sort_cities(cities: &mut Vec<City>) {cities.sort_by_key(|city| -city.population);
}
|city| -city.population
就是闭包。它会接受一个参数 city 并返回 -city.population
。Rust 会从闭包的使用方式中推断出其参数类型和返回类型。
闭包相关问题:
Rust 的闭包与匿名函数有何不同?
如何将闭包与标准库方法一起使用?
闭包如何“捕获”其作用域内的变量?
如何编写自己的以闭包作为参数的函数和方法?
何存储闭包供以后用作回调?
Rust 闭包是如何实现的,以及它们为什么比你预想的要快?