第15章 智能指针
指针(pointer)是一个包含内存地址的变量的通用概念。这个地址会引用,或者说 “指向”(points at)一些其它数据。Rust 中最常见的指针是第四章介绍的引用(reference)。引用使用 & 符号来表示,而且会借用它们指向的值。它们除了引用数据之外没有任何其他特殊功能,也没有额外开销。
另一方面,智能指针(smart pointers)是一类数据结构,它们的行为类似指针,但还拥有额外的元数据和功能。智能指针的概念并不为 Rust 所独有:它起源于 C++,在其他语言中也存在。Rust 标准库中定义了多种不同的智能指针,它们提供了超出引用所能提供的功能。为了探索这一通用概念,我们会看几个不同的智能指针示例,其中包括引用计数(reference counting)智能指针类型。这种指针通过跟踪所有者的数量,让数据能够拥有多个所有者;当所有者都不存在时,它还会清理相应的数据。
在 Rust 中,由于所有权和借用的概念,引用和智能指针之间还有一个额外区别:引用只会借用数据,而智能指针在很多情况下会拥有它们所指向的数据。
智能指针通常使用结构体实现。智能指针不同于普通结构体的地方在于它实现了 Deref 和 Drop trait。Deref trait 允许智能指针结构体实例表现得像引用一样,这样就可以编写既用于引用、又用于智能指针的代码。Drop trait 允许我们自定义当智能指针离开作用域时运行的代码。本章会讨论这些 trait 以及为什么它们对智能指针很重要。
考虑到智能指针模式是在 Rust 中经常使用的一种通用设计模式,本章不会覆盖所有现有的智能指针。很多库都有自己的智能指针,而且你甚至也可以编写自己的智能指针。这里会介绍标准库中最常用的几种:
Box<T>,用于在堆上分配值Rc<T>,一个引用计数类型,其数据可以有多个所有者Ref<T>和RefMut<T>,它们是通过RefCell<T>访问的。而RefCell<T>是一个在运行时而非编译时执行借用规则的类型。
另外我们会讲到内部可变性(interior mutability)模式,这种模式允许一个不可变的类型暴露出能够修改其内部值的 API。我们也会讨论引用循环(reference cycles)是怎样导致内存泄漏的,以及如何避免它们。
让我们开始吧!
使用 Box<T> 指向堆上数据
使用 Box<T> 指向堆上的数据
最简单直接的智能指针是 box,其类型写作 Box<T>。Box 允许你将数据存储在堆上而不是栈上。留在栈上的则是指向堆数据的指针。如果你想回顾一下栈和堆之间的区别,可以参考第四章。
除了把数据存储在堆上而不是栈上之外,box 没有性能开销。不过,它们也没有太多额外能力。你最常在以下这些场景中使用它们:
- 当有一个在编译时未知大小的类型,而又想要在需要确切大小的上下文中使用这个类型值的时候
- 当有大量数据并希望在确保数据不被拷贝的情况下转移所有权的时候
- 当希望拥有一个值并只关心它的类型是否实现了特定 trait 而不是其具体类型的时候
我们会在“Box 允许创建递归类型”一节中展示第一种场景。在第二种情况下,转移大量数据的所有权可能会花费很长时间,因为数据会在栈上被复制。为了改善这种场景下的性能,我们可以把大量数据放进 box 中存储到堆上。这样,只有少量指针数据会在栈上被复制,而它所指向的数据则会一直留在堆上的同一位置。第三种情况被称为trait 对象(trait object),第十八章中的“使用 trait 对象来抽象共享行为”专门讨论了这个主题。所以你在这里学到的内容,还会在那一节中再次用到!
在堆上存储数据
在讨论 Box<T> 的堆存储用例之前,让我们熟悉一下语法以及如何与存储在 Box<T> 中的值进行交互。
示例 15-1 展示了如何使用 box 在堆上存储一个 i32 值。
文件名:src/main.rs
fn main() {
let b = Box::new(5);
println!("b = {b}");
}
示例 15-1:使用 box 在堆上储存一个 i32 值
我们将变量 b 定义为一个 Box,它指向值 5,而这个值被分配在堆上。这个程序会打印 b = 5;在这个例子里,我们访问 box 中数据的方式,和数据位于栈上时的方式类似。和任何拥有所有权的值一样,当 box 离开作用域时,就像 b 在 main 结束时那样,它会被释放。释放时既会清理 box 本身(存储在栈上),也会清理它指向的数据(存储在堆上)。
把单个值放到堆上并没有太大意义,所以你不会经常像示例 15-1 那样单独使用 box。对于像单个 i32 这样的值来说,把它们放在默认存储位置栈上,在大多数情况下更合适。接下来,我们来看一个如果没有 box 就无法定义的类型。
Box 允许创建递归类型
递归类型(recursive type)的值可以拥有另一个同类型的值作为其自身的一部分。但是这会产生一个问题,因为 Rust 需要在编译时知道类型占用多少空间。递归类型的值嵌套理论上可以无限地进行下去,所以 Rust 不知道递归类型需要多少空间。因为 box 有一个已知的大小,所以通过在递归类型定义中插入 box,就可以创建递归类型了。
作为递归类型的例子,让我们来看看 cons list。这是一种在函数式编程语言中常见的数据类型。我们将定义的 cons list 除了递归之外都很简单,因此这个例子里的概念,在你遇到更复杂的递归类型场景时也会很有用。
理解 cons list
cons list 是一种来自 Lisp 编程语言及其方言的数据结构,由嵌套的 pair 组成,也是 Lisp 版本的链表。它的名字来源于 Lisp 中的 cons 函数(即 construct function 的缩写),这个函数用它的两个参数构造一个新的 pair。通过对一个由某个值和另一个 pair 组成的 pair 调用 cons,我们就能构造出由递归 pair 组成的 cons list。
例如这里有一个包含列表 1, 2, 3 的 cons list 的伪代码表示,其每个对在一个括号中:
(1, (2, (3, Nil)))
cons list 中的每一项都包含两个元素:当前项的值,以及下一项。列表中的最后一项只包含一个名为 Nil 的值,而没有下一项。cons list 是通过递归调用 cons 函数构造出来的。用来表示递归基例的规范名称是 Nil。注意,这和第六章讨论过的 “null” 或 “nil” 概念并不相同,后者表示无效或缺失的值。
cons list 并不是一个 Rust 中常见的类型。大部分在 Rust 中需要列表的时候,Vec<T> 是一个更好的选择。其他更为复杂的递归数据类型确实在 Rust 的很多场景中很有用,不过通过以 cons list 作为开始,我们可以探索如何使用 box 毫不费力地定义一个递归数据类型。
示例 15-2 包含一个 cons list 的枚举定义。注意这还不能编译因为这个类型没有已知的大小,之后我们会展示:
文件名:src/main.rs
enum List {
Cons(i32, List),
Nil,
}
fn main() {}
示例 15-2:第一次尝试定义一个代表 i32 值的 cons list 数据结构的枚举
注意:出于示例的需要我们选择实现一个只存放
i32值的 cons list。也可以用泛型,正如第十章讲到的,来定义一个可以存放任何类型值的 cons list 类型。
使用这个 cons list 来储存列表 1, 2, 3 将看起来如示例 15-3 所示:
文件名:src/main.rs
enum List {
Cons(i32, List),
Nil,
}
// --snip--
use crate::List::{Cons, Nil};
fn main() {
let list = Cons(1, Cons(2, Cons(3, Nil)));
}
示例 15-3:使用 List 枚举储存列表 1, 2, 3
第一个 Cons 储存了 1 和另一个 List 值。这个 List 是另一个包含 2 的 Cons 值和下一个 List 值。接着又有另一个存放了 3 的 Cons 值和最后一个值为 Nil 的 List,非递归变体代表了列表的结尾。
如果尝试编译示例 15-3 的代码,会得到如示例 15-4 所示的错误:
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0072]: recursive type `List` has infinite size
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^
2 | Cons(i32, List),
| ---- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Cons(i32, Box<List>),
| ++++ +
error[E0391]: cycle detected when computing when `List` needs drop
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^
|
= note: ...which immediately requires computing when `List` needs drop again
= note: cycle used when computing whether `List` needs drop
= note: see https://rustc-dev-guide.rust-lang.org/overview.html#queries and https://rustc-dev-guide.rust-lang.org/query.html for more information
Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 2 previous errors
示例 15-4:尝试定义一个递归枚举时得到的错误
这个错误表明,这个类型“有无限大小”。原因在于,我们把 List 的一个变体定义成了递归的:它直接持有另一个同类型的值。因此,Rust 无法判断存储一个 List 值到底需要多少空间。让我们拆开看看为什么会出现这个错误。首先,先来看看 Rust 是如何决定存储非递归类型的值需要多少空间的。
计算非递归类型的大小
回忆一下第六章讨论枚举定义时示例 6-2 中定义的 Message 枚举:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn main() {}
当 Rust 需要知道要为 Message 值分配多少空间时,它可以检查每一个变体并发现 Message::Quit 并不需要任何空间,Message::Move 需要足够储存两个 i32 值的空间,依此类推。因为 enum 实际上只会使用其中的一个变体,所以 Message 值所需的空间等于储存其最大变体的空间大小。
与之相对的是,当 Rust 试图确定像示例 15-2 中 List 枚举这样的递归类型需要多少空间时,会发生什么。编译器先查看 Cons 变体,它持有一个 i32 类型的值和一个 List 类型的值。因此,Cons 所需的空间等于一个 i32 的大小再加上一个 List 的大小。为了算出 List 类型需要多少内存,编译器又要继续查看它的变体,并再次从 Cons 开始。Cons 又持有一个 i32 和一个 List,这个过程就会无限持续下去,如图 15-1 所示:
图 15-1:一个包含无限个 Cons 变体的无限 List
获取一个已知大小的给递归类型
因为 Rust 无法计算出要为定义为递归的类型分配多少空间,所以编译器给出了一个包括了有用建议的错误:
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Cons(i32, Box<List>),
| ++++ +
在这里,indirection 的意思是:不要直接存储一个值,而是通过存储一个指向该值的指针,间接地存储它。
因为 Box<T> 是一个指针,Rust 总是知道 Box<T> 需要多少空间:指针的大小并不会随它指向的数据量而变化。这意味着我们可以在 Cons 变体中放一个 Box<T>,而不是直接再放一个 List 值。这个 Box<T> 会指向下一个位于堆上的 List 值,而不是把这个 List 值直接放在 Cons 变体内部。从概念上说,我们仍然有一个“由列表组成的列表”,但现在这种实现更像是把这些项彼此相连,而不是一层层相互包含。
我们可以修改示例 15-2 中 List 枚举的定义和示例 15-3 中对 List 的应用,如示例 15-5 所示,这是可以编译的:
文件名:src/main.rs
enum List {
Cons(i32, Box<List>),
Nil,
}
use crate::List::{Cons, Nil};
fn main() {
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}
示例 15-5:为了拥有已知大小而使用 Box<T> 的 List 定义
Cons 变体将会需要一个 i32 的大小加上储存 box 指针数据的空间。Nil 变体不储存值,所以它比 Cons 变体需要更少的空间。现在我们知道了任何 List 值最多需要一个 i32 加上 box 指针数据的大小。通过使用 box,打破了这无限递归的连锁,这样编译器就能够计算出储存 List 值需要的大小了。图 15-2 展示了现在 Cons 变体看起来像什么:
图 15-2:因为 Cons 存放一个 Box 所以 List 不是无限大小的了
Box 只提供间接存储和堆分配;它没有我们将在其他智能指针类型中看到的那些额外特殊能力。它也没有那些特殊能力带来的性能开销,因此在像 cons list 这样我们只需要“间接存储”这一特性的场景里,Box 就很有用。我们还会在第十八章看到更多 Box 的用例。
Box<T> 类型之所以是智能指针,是因为它实现了 Deref trait,这让 Box<T> 的值可以像引用一样被处理。当 Box<T> 的值离开作用域时,由于 Drop trait 的实现,box 指向的堆数据也会被清理掉。这两个 trait 对本章余下将讨论的其他智能指针类型所提供的功能会更加重要。接下来,我们更详细地看看这两个 trait。
使用 Deref Trait 将智能指针当作常规引用处理
将智能指针视作常规引用
实现 Deref trait 允许你自定义解引用运算符(dereference operator)* 的行为(不要把它和乘法运算符或通配符运算符混淆)。通过以某种方式实现 Deref,使智能指针能够像常规引用一样被对待,你就可以编写操作引用的代码,并同样把它用于智能指针。
先来看看解引用运算符是如何作用于常规引用的。然后,我们会尝试定义一个行为类似 Box<T> 的自定义类型,并看看为什么解引用运算符在我们新定义的类型上不能像引用那样工作。接着,我们会探讨实现 Deref trait 如何让智能指针能够像引用一样工作。最后,我们会看看 Rust 的 Deref 强制转换(deref coercions)特性,以及它如何让我们既能处理引用,也能处理智能指针。
追踪引用的值
常规引用是一个指针类型,一种理解指针的方式是将其看成指向储存在其他某处值的箭头。在示例 15-6 中,创建了一个 i32 值的引用,接着使用解引用运算符来跟踪所引用的值:
文件名:src/main.rs
fn main() {
let x = 5;
let y = &x;
assert_eq!(5, x);
assert_eq!(5, *y);
}
示例 15-6:使用解引用运算符来跟踪 i32 值的引用
变量 x 存放了一个 i32 值 5。y 等于 x 的一个引用。可以断言 x 等于 5。然而,如果希望对 y 的值做出断言,必须使用 *y 来追踪引用所指向的值(也就是解引用),这样编译器就可以比较实际的值了。一旦解引用了 y,就可以访问 y 所指向的整型值并可以与 5 做比较。
相反如果尝试编写 assert_eq!(5, y);,则会得到如下编译错误:
$ cargo run
Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0277]: can't compare `{integer}` with `&{integer}`
--> src/main.rs:6:5
|
6 | assert_eq!(5, y);
| ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
|
= help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
= note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)
For more information about this error, try `rustc --explain E0277`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error
不允许比较数字的引用与数字,因为它们是不同的类型。必须使用解引用运算符追踪引用所指向的值。
像引用一样使用 Box<T>
可以使用 Box<T> 代替引用来重写示例 15-6 中的代码,示例 15-7 中 Box<T> 上使用的解引用运算符与示例 15-6 中引用上使用的解引用运算符有着一样的功能:
文件名:src/main.rs
fn main() {
let x = 5;
let y = Box::new(x);
assert_eq!(5, x);
assert_eq!(5, *y);
}
示例 15-7:在 Box<i32> 上使用解引用运算符
示例 15-7 相比示例 15-6 主要不同的地方就是将 y 设置为一个指向 x 值拷贝的 Box<T> 实例,而不是指向 x 值的引用。在最后的断言中,可以使用解引用运算符以 y 为引用时相同的方式追踪 Box<T> 的指针。接下来让我们通过实现自己的类型来探索 Box<T> 能这么做有何特殊之处。
定义我们自己的智能指针
为了体会默认情况下智能指针与引用的不同,让我们创建一个类似于标准库提供的 Box<T> 类型的智能指针。接着学习如何增加使用解引用运算符的功能。
注意:我们即将构建的
MyBox<T>类型与真正的Box<T>有一个很大的区别:我们的版本不会把数据存储在堆上。因为这个示例关注的是Deref,所以数据实际存储在哪里并不像这种“类似指针的行为”那样重要。
从根本上说,Box<T> 被定义为包含一个元素的元组结构体,所以示例 15-8 以相同的方式定义了 MyBox<T> 类型。我们还定义了 new 函数来对应定义于 Box<T> 的 new 函数:
文件名:src/main.rs
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
fn main() {}
示例 15-8:定义 MyBox<T> 类型
这里定义了一个结构体 MyBox 并声明了一个泛型参数 T,因为我们希望其可以存放任何类型的值。MyBox 是一个包含 T 类型元素的元组结构体。MyBox::new 函数获取一个 T 类型的参数并返回一个存放传入值的 MyBox 实例。
尝试将示例 15-7 中的代码加入示例 15-8 中并修改 main 使用我们定义的 MyBox<T> 类型代替 Box<T>。示例 15-9 中的代码不能编译,因为 Rust 不知道如何解引用 MyBox:
文件名:src/main.rs
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
fn main() {
let x = 5;
let y = MyBox::new(x);
assert_eq!(5, x);
assert_eq!(5, *y);
}
示例 15-9:尝试以使用引用和 Box<T> 相同的方式使用 MyBox<T>
下面是相应的编译错误:
$ cargo run
Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
--> src/main.rs:14:19
|
14 | assert_eq!(5, *y);
| ^^ can't be dereferenced
For more information about this error, try `rustc --explain E0614`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error
MyBox<T> 类型不能解引用,因为我们尚未在该类型上实现这个功能。为了启用 * 运算符的解引用功能,需要实现 Deref trait。
实现 Deref trait
如第十章“为类型实现 Trait”所讨论的,为了实现 trait,我们需要为 trait 所要求的方法提供实现。标准库提供的 Deref trait 要求我们实现一个名为 deref 的方法,该方法借用 self 并返回一个指向内部数据的引用。示例 15-10 展示了要添加到 MyBox<T> 定义上的 Deref 实现:
文件名:src/main.rs
use std::ops::Deref;
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
fn main() {
let x = 5;
let y = MyBox::new(x);
assert_eq!(5, x);
assert_eq!(5, *y);
}
示例 15-10:MyBox<T> 上的 Deref 实现
type Target = T; 语法定义了用于此 trait 的关联类型。关联类型是一个稍有不同的定义泛型参数的方式,现在还无需过多地担心它;第二十章会详细介绍。
deref 方法体中写入了 &self.0,这样 deref 返回了我希望通过 * 运算符访问的值的引用。回忆一下第五章 “使用元组结构体创建不同的类型” 部分 .0 用来访问元组结构体的第一个元素。示例 15-9 中的 main 函数中对 MyBox<T> 值的 * 调用现在可以编译并能通过断言了!
没有 Deref trait 的话,编译器只会解引用 & 引用类型。deref 方法向编译器提供了获取任何实现了 Deref trait 的类型的值,并且调用这个类型的 deref 方法来获取一个它知道如何解引用的 & 引用的能力。
当我们在示例 15-9 中输入 *y 时,Rust 事实上在底层运行了如下代码:
*(y.deref())
Rust 将 * 运算符替换为先调用 deref 方法再进行普通解引用的操作,如此我们便不用担心是否还需手动调用 deref 方法了。Rust 的这个特性可以让我们写出行为一致的代码,无论面对的是常规引用还是实现了 Deref 的类型。
deref 方法返回值的引用,以及 *(y.deref()) 括号外边的普通解引用仍为必须的原因在于所有权。如果 deref 方法直接返回值而不是值的引用,其值将被移出 self。在这里以及大部分使用解引用运算符的情况下我们并不希望获取 MyBox<T> 内部值的所有权。
注意,每次当我们在代码中使用 * 时, * 运算符都被替换成了先调用 deref 方法再接着使用 * 解引用的操作,且只会发生一次,不会对 * 操作符无限递归替换,解引用出上面 i32 类型的值就停止了,这个值与示例 15-9 中 assert_eq! 的 5 相匹配。
在函数和方法中使用 Deref 强制转换
Deref 强制转换(deref coercions)将实现了 Deref trait 的类型的引用转换为另一种类型的引用。例如,Deref 强制转换可以将 &String 转换为 &str,因为 String 实现了 Deref trait 因此可以返回 &str。Deref 强制转换是 Rust 在函数或方法传参上的一种便利操作,并且只能作用于实现了 Deref trait 的类型。当这种特定类型的引用作为实参传递给和形参类型不同的函数或方法时将自动进行。这时会有一系列的 deref 方法被调用,把我们提供的类型转换成了参数所需的类型。
Deref 强制转换的加入使得 Rust 程序员编写函数和方法调用时无需增加过多显式使用 & 和 * 的引用和解引用。这个功能也使得我们可以编写更多同时作用于引用或智能指针的代码。
作为展示 Deref 强制转换的实例,让我们使用示例 15-8 中定义的 MyBox<T>,以及示例 15-10 中增加的 Deref 实现。示例 15-11 展示了一个有着字符串 slice 参数的函数定义:
文件名:src/main.rs
fn hello(name: &str) {
println!("Hello, {name}!");
}
fn main() {}
示例 15-11:hello 函数有着 &str 类型的参数 name
可以使用字符串 slice 作为参数调用 hello 函数,比如 hello("Rust");。Deref 强制转换使得用 MyBox<String> 类型值的引用调用 hello 成为可能,如示例 15-12 所示:
文件名:src/main.rs
use std::ops::Deref;
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
fn hello(name: &str) {
println!("Hello, {name}!");
}
fn main() {
let m = MyBox::new(String::from("Rust"));
hello(&m);
}
示例 15-12:因为 Deref 强制转换,使用 MyBox<String> 的引用调用 hello 是可行的
这里使用 &m 调用 hello 函数,其为 MyBox<String> 值的引用。因为示例 15-10 中在 MyBox<T> 上实现了 Deref trait,Rust 可以通过 deref 调用将 &MyBox<String> 变为 &String。标准库中提供了 String 上的 Deref 实现,其会返回字符串 slice,这可以在 Deref 的 API 文档中看到。Rust 再次调用 deref 将 &String 变为 &str,这就符合 hello 函数的定义了。
如果 Rust 没有实现 Deref 强制转换,为了使用 &MyBox<String> 类型的值调用 hello,则不得不编写示例 15-13 中的代码来代替示例 15-12:
文件名:src/main.rs
use std::ops::Deref;
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
fn hello(name: &str) {
println!("Hello, {name}!");
}
fn main() {
let m = MyBox::new(String::from("Rust"));
hello(&(*m)[..]);
}
示例 15-13:如果 Rust 没有 Deref 强制转换则必须编写的代码
(*m) 将 MyBox<String> 解引用为 String。接着 & 和 [..] 获取了整个 String 的字符串 slice 来匹配 hello 的签名。没有 Deref 强制转换所有这些符号混在一起将更难以读写和理解。Deref 强制转换使得 Rust 自动的帮我们处理这些转换。
当所涉及到的类型定义了 Deref trait,Rust 会分析这些类型并使用任意多次 Deref::deref 调用以获得匹配参数的类型。这些解析都发生在编译时,所以利用 Deref 强制转换并没有运行时开销!
处理可变引用的 Deref 强制转换
类似于如何使用 Deref trait 重载不可变引用的 * 运算符,Rust 提供了 DerefMut trait 用于重载可变引用的 * 运算符。
Rust 在发现类型和 trait 实现满足三种情况时会进行 Deref 强制转换:
- 当
T: Deref<Target=U>时从&T到&U。 - 当
T: DerefMut<Target=U>时从&mut T到&mut U。 - 当
T: Deref<Target=U>时从&mut T到&U。
头两个情况除了第二种实现了可变性之外是相同的:第一种情况表明如果有一个 &T,而 T 实现了返回 U 类型的 Deref,则可以透明地得到 &U。第二种情况表明对于可变引用也有着相同的行为。
第三个情况有些微妙:Rust 也会将可变引用强转为不可变引用。但反之是不可能 的:不可变引用永远也不能强转为可变引用。因为根据借用规则,如果有一个可变引用,其必须是这些数据的唯一引用(否则程序将无法编译)。将一个可变引用转换为不可变引用永远也不会打破借用规则。将不可变引用转换为可变引用则需要初始的不可变引用是数据唯一的不可变引用,而借用规则无法保证这一点。因此,Rust 无法假设将不可变引用转换为可变引用是可能的。
使用 Drop Trait 运行清理代码
使用 Drop Trait 运行清理代码
对智能指针模式来说,第二个重要的 trait 是 Drop,它允许你自定义一个值即将离开作用域时要发生的事情。你可以为任何类型提供 Drop trait 的实现,而其中的代码可以用来释放诸如文件或网络连接之类的资源。
我们在智能指针的上下文中介绍 Drop,是因为实现智能指针时几乎总会用到 Drop trait。例如,当一个 Box<T> 被丢弃时,它会释放 box 所指向的堆空间。
在某些语言里,对于某些类型,程序员每次在使用完这些类型的实例后,都必须调用代码去释放内存或其他资源。常见例子包括文件句柄、套接字和锁。如果程序员忘了这么做,系统就可能因为负担过重而崩溃。在 Rust 中,你可以指定某段代码在值离开作用域时运行,而编译器会自动插入这段代码。这样一来,你就不必小心翼翼地在程序各处都放置清理代码来处理某个类型实例结束使用时的情况,同时也不会泄漏资源!
指定在值离开作用域时应该执行的代码的方式是实现 Drop trait。Drop trait 要求实现一个叫做 drop 的方法,它获取一个 self 的可变引用。为了能够看出 Rust 何时调用 drop,让我们暂时使用 println! 语句实现 drop。
示例 15-14 展示了唯一定制功能就是当其实例离开作用域时,打印出 Dropping CustomSmartPointer! 的结构体 CustomSmartPointer,这会演示 Rust 何时运行 drop 方法:
文件名:src/main.rs
struct CustomSmartPointer {
data: String,
}
impl Drop for CustomSmartPointer {
fn drop(&mut self) {
println!("Dropping CustomSmartPointer with data `{}`!", self.data);
}
}
fn main() {
let c = CustomSmartPointer {
data: String::from("my stuff"),
};
let d = CustomSmartPointer {
data: String::from("other stuff"),
};
println!("CustomSmartPointers created");
}
示例 15-14:结构体 CustomSmartPointer,其实现了放置清理代码的 Drop trait
Drop trait 包含在 prelude 中,因此无需将其引入作用域。我们在 CustomSmartPointer 上实现了 Drop trait,并提供了一个调用 println! 的 drop 方法实现。drop 函数体是放置任何当类型实例离开作用域时期望运行的逻辑的地方。这里选择打印一些文本以可视化地展示 Rust 何时调用 drop。
在 main 中,我们新建了两个 CustomSmartPointer 实例并打印出了 CustomSmartPointer created.。在 main 的结尾,CustomSmartPointer 的实例会离开作用域,而 Rust 会调用放置于 drop 方法中的代码,打印出最后的信息。注意无需显式调用 drop 方法:
当运行这个程序,会出现如下输出:
$ cargo run
Compiling drop-example v0.1.0 (file:///projects/drop-example)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.60s
Running `target/debug/drop-example`
CustomSmartPointers created
Dropping CustomSmartPointer with data `other stuff`!
Dropping CustomSmartPointer with data `my stuff`!
当实例离开作用域时,Rust 会自动替我们调用 drop,并运行我们指定的代码。变量会按照创建顺序的逆序被丢弃,所以 d 会先于 c 被丢弃。这个例子的目的,是让你直观地看到 drop 方法是如何工作的;而通常在真实代码里,你会写的是类型所需的清理逻辑,而不是一条打印消息。
不幸的是,禁用自动 drop 功能并不是一件容易的事。通常也不需要禁用 drop ;整个 Drop trait 存在的意义在于其是自动处理的。然而,有时你可能需要提早清理某个值。一个例子是当使用智能指针管理锁时;你可能希望强制运行 drop 方法来释放锁以便作用域中的其他代码可以获取锁。Rust 并不允许我们主动调用 Drop trait 的 drop 方法;当我们希望在作用域结束之前就强制释放变量的话,我们应该使用的是由标准库提供的 std::mem::drop 函数。
如果我们像是示例 15-14 那样尝试调用 Drop trait 的 drop 方法,就会得到像示例 15-15 那样的编译错误:
文件名:src/main.rs
struct CustomSmartPointer {
data: String,
}
impl Drop for CustomSmartPointer {
fn drop(&mut self) {
println!("Dropping CustomSmartPointer with data `{}`!", self.data);
}
}
fn main() {
let c = CustomSmartPointer {
data: String::from("some data"),
};
println!("CustomSmartPointer created");
c.drop();
println!("CustomSmartPointer dropped before the end of main");
}
示例 15-15:尝试手动调用 Drop trait 的 drop 方法提早清理
如果尝试编译代码会得到如下错误:
$ cargo run
Compiling drop-example v0.1.0 (file:///projects/drop-example)
error[E0040]: explicit use of destructor method
--> src/main.rs:16:7
|
16 | c.drop();
| ^^^^ explicit destructor calls not allowed
|
help: consider using `drop` function
|
16 - c.drop();
16 + drop(c);
|
For more information about this error, try `rustc --explain E0040`.
error: could not compile `drop-example` (bin "drop-example") due to 1 previous error
错误信息表明,我们不被允许显式调用 drop。错误信息中使用了术语析构函数(destructor),这是编程中对“清理某个实例的函数”的通用称呼。析构函数与构造函数(constructor)相对应,后者用于创建实例。Rust 中的 drop 函数就是一种特定的析构函数。
Rust 不允许我们显式调用 drop,因为 Rust 仍然会在 main 结束时自动对该值调用 drop。这会导致二次释放(double free)错误,因为 Rust 会尝试清理同一个值两次。
因为不能禁用当值离开作用域时自动插入的 drop,并且不能显式调用 drop 方法。如果我们需要强制提早清理值,可以使用 std::mem::drop 函数。
std::mem::drop 函数与 Drop trait 中的 drop 方法不同。我们通过把想要强制提前丢弃的值作为参数传给它来调用。这个函数位于 prelude 中,因此我们可以修改示例 15-15 里的 main,改为调用 drop 函数,如示例 15-16 所示:
文件名:src/main.rs
struct CustomSmartPointer {
data: String,
}
impl Drop for CustomSmartPointer {
fn drop(&mut self) {
println!("Dropping CustomSmartPointer with data `{}`!", self.data);
}
}
fn main() {
let c = CustomSmartPointer {
data: String::from("some data"),
};
println!("CustomSmartPointer created");
drop(c);
println!("CustomSmartPointer dropped before the end of main");
}
示例 15-16:在值离开作用域之前调用 std::mem::drop 显式清理
运行这段代码将打印出如下内容:
$ cargo run
Compiling drop-example v0.1.0 (file:///projects/drop-example)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
Running `target/debug/drop-example`
CustomSmartPointer created
Dropping CustomSmartPointer with data `some data`!
CustomSmartPointer dropped before the end of main
文本 Dropping CustomSmartPointer with data `some data`! 会出现在 CustomSmartPointer created. 和 CustomSmartPointer dropped before the end of main. 之间,这表明 drop 方法的代码在那个时刻被调用,以丢弃 c。
你可以以多种方式利用 Drop trait 实现里指定的代码,让清理既方便又安全。例如,你可以用它来创建自己的内存分配器!有了 Drop trait 和 Rust 的所有权系统,你就不必记住何时进行清理,因为 Rust 会自动替你完成这些工作。
你也不必担心由于不小心清理仍在使用的值而导致的问题:所有权系统确保引用总是有效的,也会确保 drop 只会在值不再被使用时被调用一次。
现在我们已经了解了 Box<T> 以及智能指针的一些特征,接下来看看标准库中定义的其他几种智能指针。
Rc<T> 引用计数智能指针
Rc<T> 引用计数智能指针
大部分情况下所有权是非常明确的:可以准确地知道哪个变量拥有某个值。然而,有些情况单个值可能会有多个所有者。例如,在图数据结构中,多个边可能指向相同的节点,而这个节点从概念上讲为所有指向它的边所拥有。节点在没有任何边指向它从而没有任何所有者之前,都不应该被清理掉。
为了启用多所有权需要显式地使用 Rust 类型 Rc<T>,其为引用计数(reference counting)的缩写。引用计数意味着记录一个值的引用数量来知晓这个值是否仍在被使用。如果某个值有零个引用,就代表没有任何有效引用并可以被清理。
可以将其想象为客厅中的电视。当一个人进来看电视时,他打开电视。其他人也可以进来看电视。当最后一个人离开房间时,他关掉电视因为它不再被使用了。如果某人在其他人还在看的时候就关掉了电视,正在看电视的人肯定会抓狂的!
Rc<T> 用于当我们希望在堆上分配一些内存供程序的多个部分读取,而且无法在编译时确定程序的哪一部分会最后结束使用它的时候。如果确实知道哪部分是最后一个结束使用的话,就可以令其成为数据的所有者,正常的所有权规则就可以在编译时生效。
注意,Rc<T> 只能用于单线程场景。等到我们在第十六章讨论并发时,会介绍如何在多线程程序中进行引用计数。
共享数据
让我们回到示例 15-5 中使用 Box<T> 定义 cons list 的例子。这一次,我们希望创建两个共享第三个列表所有权的列表,其概念将会看起来如图 15-3 所示:
图 15-3: 两个列表,b 和 c, 共享第三个列表 a 的所有权
列表 a 包含 5 之后是 10,之后是另两个列表:b 从 3 开始而 c 从 4 开始。b 和 c 会接上包含 5 和 10 的列表 a。换句话说,这两个列表会尝试共享第一个列表所包含的 5 和 10。
尝试使用 Box<T> 定义的 List 实现并不能工作,如示例 15-17 所示:
文件名:src/main.rs
enum List {
Cons(i32, Box<List>),
Nil,
}
use crate::List::{Cons, Nil};
fn main() {
let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
let b = Cons(3, Box::new(a));
let c = Cons(4, Box::new(a));
}
示例 15-17: 展示不能用两个 Box<T> 的列表尝试共享第三个列表的所有权
编译会得出如下错误:
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0382]: use of moved value: `a`
--> src/main.rs:11:30
|
9 | let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
| - move occurs because `a` has type `List`, which does not implement the `Copy` trait
10 | let b = Cons(3, Box::new(a));
| - value moved here
11 | let c = Cons(4, Box::new(a));
| ^ value used here after move
|
note: if `List` implemented `Clone`, you could clone the value
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^ consider implementing `Clone` for this type
...
10 | let b = Cons(3, Box::new(a));
| - you could clone this value
For more information about this error, try `rustc --explain E0382`.
error: could not compile `cons-list` (bin "cons-list") due to 1 previous error
Cons 变体拥有它所持有的数据,因此当我们创建 b 列表时,a 会被移动进 b,于是 b 就拥有了 a。接着,当我们再次尝试在创建 c 时使用 a,这就不被允许了,因为 a 的所有权已经被移动。
可以改变 Cons 的定义来存放一个引用,不过接着必须指定生命周期参数。通过指定生命周期参数,表明列表中的每一个元素都至少与列表本身存在的一样久。这是示例 15-17 中元素与列表的情况,但并不是所有情况都如此。
相反,我们会把 List 的定义改为使用 Rc<T> 来代替 Box<T>,如示例 15-18 所示。现在每个 Cons 变体都持有一个值,以及一个指向 List 的 Rc<T>。当我们创建 b 时,不再获取 a 的所有权,而是克隆 a 持有的 Rc<List>,从而把引用计数从 1 增加到 2,并让 a 和 b 共享这个 Rc<List> 中数据的所有权。创建 c 时,我们也会克隆 a,使引用计数从 2 增加到 3。每次调用 Rc::clone,Rc<List> 内部数据的引用计数都会增加,只有当引用数为零时,数据才会被清理。
文件名:src/main.rs
enum List {
Cons(i32, Rc<List>),
Nil,
}
use crate::List::{Cons, Nil};
use std::rc::Rc;
fn main() {
let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
let b = Cons(3, Rc::clone(&a));
let c = Cons(4, Rc::clone(&a));
}
示例 15-18: 使用 Rc<T> 定义的 List
需要使用 use 语句将 Rc<T> 引入作用域,因为它不在 prelude 中。在 main 中创建了存放 5 和 10 的列表并将其存放在 a 的新的 Rc<List> 中。接着当创建 b 和 c 时,调用 Rc::clone 函数并传递 a 中 Rc<List> 的引用作为参数。
我们本来也可以调用 a.clone(),而不是 Rc::clone(&a),但 Rust 的惯例是在这种情况下使用 Rc::clone。Rc::clone 的实现不像大多数类型的 clone 实现那样,会对所有数据进行深拷贝。调用 Rc::clone 只会增加引用计数,这并不耗时;而数据的深拷贝可能会花费不少时间。通过使用 Rc::clone 来表示引用计数操作,我们可以在视觉上区分“深拷贝式的 clone”和“只增加引用计数的 clone”。这样在查找性能问题时,我们只需要关注深拷贝那类 clone,而可以忽略 Rc::clone 调用。
克隆会增加引用计数
让我们修改示例 15-18 的代码以便观察创建和丢弃 a 中 Rc<List> 的引用时引用计数的变化。
在示例 15-19 中,修改了 main 以便将列表 c 置于内部作用域中,这样就可以观察当 c 离开作用域时引用计数如何变化。
文件名:src/main.rs
enum List {
Cons(i32, Rc<List>),
Nil,
}
use crate::List::{Cons, Nil};
use std::rc::Rc;
// --snip--
fn main() {
let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
println!("count after creating a = {}", Rc::strong_count(&a));
let b = Cons(3, Rc::clone(&a));
println!("count after creating b = {}", Rc::strong_count(&a));
{
let c = Cons(4, Rc::clone(&a));
println!("count after creating c = {}", Rc::strong_count(&a));
}
println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}
示例 15-19:打印出引用计数
在程序中每个引用计数发生变化的地方,我们都会打印当前的引用计数,这个值是通过调用 Rc::strong_count 函数得到的。这个函数之所以叫 strong_count 而不是 count,是因为 Rc<T> 还拥有 weak_count;我们会在“使用 Weak<T> 避免引用循环”一节中看到 weak_count 的用途。
这段代码会打印出:
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.45s
Running `target/debug/cons-list`
count after creating a = 1
count after creating b = 2
count after creating c = 3
count after c goes out of scope = 2
我们能够看到 a 中 Rc<List> 的初始引用计数为 1,接着每次调用 clone,计数会增加 1。当 c 离开作用域时,计数减 1。不必像调用 Rc::clone 增加引用计数那样调用一个函数来减少计数;Drop trait 的实现当 Rc<T> 值离开作用域时自动减少引用计数。
从这个例子我们所不能看到的是,在 main 的结尾当 b 然后是 a 离开作用域时,此处计数会是 0,同时 Rc<List> 被完全清理。使用 Rc<T> 允许一个值有多个所有者,引用计数则确保只要任何所有者依然存在其值也保持有效。
通过不可变引用, Rc<T> 允许在程序的多个部分之间只读地共享数据。如果 Rc<T> 也允许多个可变引用,则会违反第四章讨论的借用规则之一:相同位置的多个可变借用可能造成数据竞争和不一致。不过可以修改数据是非常有用的!在下一部分,我们将讨论内部可变性模式和 RefCell<T> 类型,它可以与 Rc<T> 结合使用来处理不可变性的限制。
RefCell<T> 与内部可变性模式
RefCell<T> 和内部可变性模式
内部可变性(Interior mutability)是 Rust 中的一个设计模式,它允许你即使在有不可变引用时也可以改变数据,这通常是借用规则所不允许的。为了改变数据,该模式在数据结构中使用 unsafe 代码来模糊 Rust 通常的可变性和借用规则。不安全代码表明我们在手动检查这些规则而不是让编译器替我们检查。第二十章会更详细地介绍不安全代码。
当可以确保代码在运行时会遵守借用规则,即使编译器不能保证的情况,可以选择使用那些运用内部可变性模式的类型。所涉及的 unsafe 代码将被封装进安全的 API 中,而外部类型仍然是不可变的。
让我们通过遵循内部可变性模式的 RefCell<T> 类型来探索这个概念。
在运行时强制借用规则
不同于 Rc<T>,RefCell<T> 代表其数据的唯一的所有权。那么是什么让 RefCell<T> 不同于像 Box<T> 这样的类型呢?回忆一下第四章所学的借用规则:
- 在任意给定时刻,只能拥有一个可变引用或任意数量的不可变引用之一(而不是两者)。
- 引用必须始终有效。
对于引用和 Box<T>,借用规则的不可变性(invariants)在编译时就会被强制执行。对于 RefCell<T>,这些不可变性作用于运行时。对于引用,如果违反这些规则,会得到一个编译错误。而对于 RefCell<T>,如果违反这些规则程序会 panic 并退出。
在编译时检查借用规则的优势是这些错误将在开发过程的早期被捕获,同时对运行时没有性能影响,因为所有的分析都提前完成了。为此,在编译时检查借用规则是大部分情况的最佳选择,这也正是其为何是 Rust 的默认行为。
相反在运行时检查借用规则的好处则是允许出现特定内存安全的场景,而它们在编译时检查中是不允许的。静态分析,正如 Rust 编译器,是天生保守的。但代码的一些属性不可能通过分析代码发现:其中最著名的就是停机问题(Halting Problem),这超出了本书的范畴,不过如果你感兴趣的话这是一个值得研究的有趣主题。
因为一些分析是不可能的,如果 Rust 编译器不能通过所有权规则编译,它可能会拒绝一个正确的程序;从这种角度考虑它是保守的。如果 Rust 接受不正确的程序,那么用户也就不会相信 Rust 所做的保证了。然而,如果 Rust 拒绝正确的程序,虽然会给程序员带来不便,但不会带来灾难。RefCell<T> 正是用于当你确信代码遵守借用规则,而编译器不能理解和确定的时候。
类似于 Rc<T>,RefCell<T> 只能用于单线程场景。如果尝试在多线程上下文中使用 RefCell<T>,会得到一个编译错误。第十六章会介绍如何在多线程程序中实现 RefCell<T> 的功能。
如下为选择 Box<T>,Rc<T> 或 RefCell<T> 的理由:
Rc<T>允许相同数据有多个所有者;Box<T>和RefCell<T>则只有单一所有者。Box<T>允许在编译时执行不可变或可变借用检查;Rc<T>仅允许在编译时执行不可变借用检查;RefCell<T>允许在运行时执行不可变或可变借用检查。- 因为
RefCell<T>允许在运行时执行可变借用检查,所以我们可以在即便RefCell<T>自身是不可变的情况下修改其内部的值。
在不可变值内部改变值就是内部可变性(interior mutability)模式。让我们看看何时内部可变性是有用的,并讨论这是如何成为可能的。
使用内部可变性
借用规则的一个推论是当有一个不可变值时,不能可变地借用它。例如,如下代码不能编译:
fn main() {
let x = 5;
let y = &mut x;
}
如果尝试编译,会得到如下错误:
$ cargo run
Compiling borrowing v0.1.0 (file:///projects/borrowing)
error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable
--> src/main.rs:3:13
|
3 | let y = &mut x;
| ^^^^^^ cannot borrow as mutable
|
help: consider changing this to be mutable
|
2 | let mut x = 5;
| +++
For more information about this error, try `rustc --explain E0596`.
error: could not compile `borrowing` (bin "borrowing") due to 1 previous error
然而,特定情况下,令一个值在其方法内部能够修改自身,而在其他代码中仍视为不可变,是很有用的。值方法外部的代码就不能修改其值了。RefCell<T> 是一个获得内部可变性的方法。RefCell<T> 并没有完全绕开借用规则,编译器中的借用检查器允许内部可变性并相应地在运行时检查借用规则。如果违反了这些规则,会出现 panic 而不是编译错误。
让我们通过一个实际的例子来探索何处可以使用 RefCell<T> 来修改不可变值并看看为何这么做是有意义的。
使用 mock 对象测试
有时在测试中程序员会用某个类型替换另一个类型,以便观察特定的行为并断言它是被正确实现的。这个占位符类型被称为 测试替身(test double)。就像电影制作中的替身演员(stunt double)一样,替代演员完成高难度的场景。测试替身在运行测试时替代某个类型。mock 对象 是特定类型的测试替身,它们记录测试过程中发生了什么以便可以断言操作是正确的。
Rust 并不像其他语言那样在标准库中提供内建的对象模型,Rust 也没有像其他语言那样在标准库中内建 mock 对象功能,不过我们确实可以创建一个与 mock 对象有着相同功能的结构体。
如下是一个我们想要测试的场景:我们在编写一个记录某个值与最大值的差距的库,并根据当前值与最大值的差距来发送消息。例如,这个库可以用于记录用户所允许的 API 调用数量限额。
该库只提供记录与最大值的差距,以及何种情况发送什么消息的功能。使用此库的程序则期望提供实际发送消息的机制:程序可以选择记录一条消息、发送 email、发送短信等等。库本身无需知道这些细节;只需实现其提供的 Messenger trait 即可。示例 15-20 展示了库代码:
文件名:src/lib.rs
pub trait Messenger {
fn send(&self, msg: &str);
}
pub struct LimitTracker<'a, T: Messenger> {
messenger: &'a T,
value: usize,
max: usize,
}
impl<'a, T> LimitTracker<'a, T>
where
T: Messenger,
{
pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
LimitTracker {
messenger,
value: 0,
max,
}
}
pub fn set_value(&mut self, value: usize) {
self.value = value;
let percentage_of_max = self.value as f64 / self.max as f64;
if percentage_of_max >= 1.0 {
self.messenger.send("Error: You are over your quota!");
} else if percentage_of_max >= 0.9 {
self.messenger
.send("Urgent warning: You've used up over 90% of your quota!");
} else if percentage_of_max >= 0.75 {
self.messenger
.send("Warning: You've used up over 75% of your quota!");
}
}
}
示例 15-20:一个记录某个值与最大值差距的库,并根据此值的特定级别发出警告
这些代码中一个重要部分是拥有一个方法 send 的 Messenger trait,其获取一个 self 的不可变引用和文本信息。这个 trait 是 mock 对象所需要实现的接口库,这样 mock 就能像一个真正的对象那样使用了。另一个重要的部分是我们需要测试 LimitTracker 的 set_value 方法的行为。可以改变传递的 value 参数的值,不过 set_value 并没有返回任何可供断言的值。我们希望能够说,如果我们创建一个实现了 Messenger trait 和具有特定 max 值的 LimitTracker 时,当传递不同 value 值时,消息发送者应被告知发送合适的消息。
我们所需的 mock 对象是,调用 send 并不实际发送 email 或消息,而是只记录信息被通知要发送了。可以新建一个 mock 对象实例,用其创建 LimitTracker,调用 LimitTracker 的 set_value 方法,然后检查 mock 对象是否有我们期望的消息。示例 15-21 展示了一个如此尝试的 mock 对象实现,不过借用检查器并不允许:
文件名:src/lib.rs
pub trait Messenger {
fn send(&self, msg: &str);
}
pub struct LimitTracker<'a, T: Messenger> {
messenger: &'a T,
value: usize,
max: usize,
}
impl<'a, T> LimitTracker<'a, T>
where
T: Messenger,
{
pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
LimitTracker {
messenger,
value: 0,
max,
}
}
pub fn set_value(&mut self, value: usize) {
self.value = value;
let percentage_of_max = self.value as f64 / self.max as f64;
if percentage_of_max >= 1.0 {
self.messenger.send("Error: You are over your quota!");
} else if percentage_of_max >= 0.9 {
self.messenger
.send("Urgent warning: You've used up over 90% of your quota!");
} else if percentage_of_max >= 0.75 {
self.messenger
.send("Warning: You've used up over 75% of your quota!");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockMessenger {
sent_messages: Vec<String>,
}
impl MockMessenger {
fn new() -> MockMessenger {
MockMessenger {
sent_messages: vec![],
}
}
}
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
self.sent_messages.push(String::from(message));
}
}
#[test]
fn it_sends_an_over_75_percent_warning_message() {
let mock_messenger = MockMessenger::new();
let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);
limit_tracker.set_value(80);
assert_eq!(mock_messenger.sent_messages.len(), 1);
}
}
示例 15-21:尝试实现 MockMessenger,借用检查器不允许这么做
测试代码定义了一个 MockMessenger 结构体,其 sent_messages 字段为一个 String 值的 Vec 用来记录被告知发送的消息。我们还定义了一个关联函数 new 以便于新建从空消息列表开始的 MockMessenger 值。接着为 MockMessenger 实现 Messenger trait 这样就可以为 LimitTracker 提供一个 MockMessenger。在 send 方法的定义中,获取传入的消息作为参数并储存在 MockMessenger 的 sent_messages 列表中。
在测试中,我们测试了当 LimitTracker 被告知将 value 设置为超过 max 值 75% 的某个值。首先新建一个 MockMessenger,其从空消息列表开始。接着新建一个 LimitTracker 并传递新建 MockMessenger 的引用和 max 值 100。我们使用值 80 调用 LimitTracker 的 set_value 方法,这超过了 100 的 75%。接着断言 MockMessenger 中记录的消息列表应该有一条消息。
然而,这个测试存在一个问题,如下所示:
$ cargo test
Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
error[E0596]: cannot borrow `self.sent_messages` as mutable, as it is behind a `&` reference
--> src/lib.rs:58:13
|
58 | self.sent_messages.push(String::from(message));
| ^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be borrowed as mutable
|
help: consider changing this to be a mutable reference in the `impl` method and the `trait` definition
|
2 ~ fn send(&mut self, msg: &str);
3 | }
...
56 | impl Messenger for MockMessenger {
57 ~ fn send(&mut self, message: &str) {
|
For more information about this error, try `rustc --explain E0596`.
error: could not compile `limit-tracker` (lib test) due to 1 previous error
不能修改 MockMessenger 来记录消息,因为 send 方法接收的是对 self 的不可变引用。我们也不能采纳错误提示中将 &self 改为 &mut self 的建议,因为那样既要在 impl 方法中修改签名,也要在 Messenger trait 定义中修改签名。我们并不希望仅为了测试而改变 Messenger trait。相反,我们需要想办法让测试代码与现有设计兼容,正常工作。
这正是内部可变性可以派上用场的地方!我们会把 sent_messages 存储在一个 RefCell<T> 里,然后 send 方法就能修改 sent_messages 来保存我们见到的消息。示例 15-22 展示了它的写法:
文件名:src/lib.rs
pub trait Messenger {
fn send(&self, msg: &str);
}
pub struct LimitTracker<'a, T: Messenger> {
messenger: &'a T,
value: usize,
max: usize,
}
impl<'a, T> LimitTracker<'a, T>
where
T: Messenger,
{
pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
LimitTracker {
messenger,
value: 0,
max,
}
}
pub fn set_value(&mut self, value: usize) {
self.value = value;
let percentage_of_max = self.value as f64 / self.max as f64;
if percentage_of_max >= 1.0 {
self.messenger.send("Error: You are over your quota!");
} else if percentage_of_max >= 0.9 {
self.messenger
.send("Urgent warning: You've used up over 90% of your quota!");
} else if percentage_of_max >= 0.75 {
self.messenger
.send("Warning: You've used up over 75% of your quota!");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct MockMessenger {
sent_messages: RefCell<Vec<String>>,
}
impl MockMessenger {
fn new() -> MockMessenger {
MockMessenger {
sent_messages: RefCell::new(vec![]),
}
}
}
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
self.sent_messages.borrow_mut().push(String::from(message));
}
}
#[test]
fn it_sends_an_over_75_percent_warning_message() {
// --snip--
let mock_messenger = MockMessenger::new();
let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);
limit_tracker.set_value(80);
assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
}
}
示例 15-22:使用 RefCell<T> 能够在外部值被认为是不可变的情况下修改内部值
现在 sent_messages 字段的类型是 RefCell<Vec<String>> 而不是 Vec<String>。在 new 函数中在空 vector 外层创建了一个 RefCell<Vec<String>> 实例。
对于 send 方法的实现,第一个参数仍为 self 的不可变借用,这是符合 trait 定义的。我们调用 self.sent_messages 中 RefCell<Vec<String>> 的 borrow_mut 方法来获取 RefCell<Vec<String>> 中值的可变引用,这是一个 vector。接着可以对 vector 的可变引用调用 push 以便记录测试过程中看到的消息。
最后必须做出的修改位于断言中:为了看到其内部 vector 中有多少个项,需要调用 RefCell<Vec<String>> 的 borrow 以获取 vector 的不可变引用。
现在我们见识了如何使用 RefCell<T>,让我们研究一下它怎样工作的!
在运行时记录借用
当创建不可变和可变引用时,我们分别使用 & 和 &mut 语法。对于 RefCell<T> 来说,则是 borrow 和 borrow_mut 方法,这属于 RefCell<T> 安全 API 的一部分。borrow 方法返回 Ref<T> 类型的智能指针,borrow_mut 方法返回 RefMut<T> 类型的智能指针。这两个类型都实现了 Deref,所以可以当作常规引用对待。
RefCell<T> 记录当前有多少个活动的 Ref<T> 和 RefMut<T> 智能指针。每次调用 borrow,RefCell<T> 将活动的不可变借用计数加一。当 Ref<T> 值离开作用域时,不可变借用计数减一。就像编译时借用规则一样,RefCell<T> 在任何时候只允许有多个不可变借用或一个可变借用。
如果我们尝试违反这些规则,相比引用时的编译时错误,RefCell<T> 的实现会在运行时出现 panic。示例 15-23 展示了对示例 15-22 中 send 实现的修改,这里我们故意尝试在相同作用域创建两个可变借用以便演示 RefCell<T> 不允许我们在运行时这么做。
文件名:src/lib.rs
pub trait Messenger {
fn send(&self, msg: &str);
}
pub struct LimitTracker<'a, T: Messenger> {
messenger: &'a T,
value: usize,
max: usize,
}
impl<'a, T> LimitTracker<'a, T>
where
T: Messenger,
{
pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
LimitTracker {
messenger,
value: 0,
max,
}
}
pub fn set_value(&mut self, value: usize) {
self.value = value;
let percentage_of_max = self.value as f64 / self.max as f64;
if percentage_of_max >= 1.0 {
self.messenger.send("Error: You are over your quota!");
} else if percentage_of_max >= 0.9 {
self.messenger
.send("Urgent warning: You've used up over 90% of your quota!");
} else if percentage_of_max >= 0.75 {
self.messenger
.send("Warning: You've used up over 75% of your quota!");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct MockMessenger {
sent_messages: RefCell<Vec<String>>,
}
impl MockMessenger {
fn new() -> MockMessenger {
MockMessenger {
sent_messages: RefCell::new(vec![]),
}
}
}
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
let mut one_borrow = self.sent_messages.borrow_mut();
let mut two_borrow = self.sent_messages.borrow_mut();
one_borrow.push(String::from(message));
two_borrow.push(String::from(message));
}
}
#[test]
fn it_sends_an_over_75_percent_warning_message() {
let mock_messenger = MockMessenger::new();
let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);
limit_tracker.set_value(80);
assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
}
}
示例 15-23:在同一作用域中创建两个可变引用并观察 RefCell<T> 将会 panic
这里为 borrow_mut 返回的 RefMut 智能指针创建了 one_borrow 变量。接着用相同的方式在变量 two_borrow 创建了另一个可变借用。这会在相同作用域中创建两个可变引用,这是不允许的。当运行库的测试时,示例 15-23 编译时不会有任何错误,不过测试会失败:
$ cargo test
Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
Running unittests src/lib.rs (target/debug/deps/limit_tracker-e599811fa246dbde)
running 1 test
test tests::it_sends_an_over_75_percent_warning_message ... FAILED
failures:
---- tests::it_sends_an_over_75_percent_warning_message stdout ----
thread 'tests::it_sends_an_over_75_percent_warning_message' panicked at src/lib.rs:60:53:
RefCell already borrowed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::it_sends_an_over_75_percent_warning_message
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
注意代码 panic 和信息 already borrowed: BorrowMutError。这也就是 RefCell<T> 如何在运行时处理违反借用规则的情况。
像这里这样选择在运行时而不是编译时捕获借用错误,意味着你可能会在开发过程的更后期才发现错误,甚至直到代码部署到生产环境后才暴露出来。与此同时,你的代码还会因为在运行时而不是编译时跟踪借用而承担一点运行时性能开销。不过,使用 RefCell<T> 能让我们在只允许不可变值的上下文中,写出一个可以修改自身来记录消息的 mock 对象。尽管存在这些权衡,RefCell<T> 仍能提供比常规引用更多的能力。
允许多个可变数据所有者
RefCell<T> 的一个常见用法是与 Rc<T> 结合。回忆一下 Rc<T> 允许对相同数据有多个所有者,不过只能提供数据的不可变访问。如果有一个储存了 RefCell<T> 的 Rc<T> 的话,就可以得到有多个所有者并且可以修改的值了!
例如,回忆示例 15-18 中的 cons list 例子,我们使用 Rc<T> 让多个列表共享另一个列表的所有权。由于 Rc<T> 只持有不可变值,所以一旦创建这些列表之后,就无法再修改其中的值。现在让我们加入 RefCell<T>,借助它来修改列表中的值。示例 15-24 展示了:通过在 Cons 定义中使用 RefCell<T>,我们就能修改所有列表中存储的值:
文件名:src/main.rs
#[derive(Debug)]
enum List {
Cons(Rc<RefCell<i32>>, Rc<List>),
Nil,
}
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let value = Rc::new(RefCell::new(5));
let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));
let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));
*value.borrow_mut() += 10;
println!("a after = {a:?}");
println!("b after = {b:?}");
println!("c after = {c:?}");
}
示例 15-24:使用 Rc<RefCell<i32>> 创建可以修改的 List
这里创建了一个 Rc<RefCell<i32>> 实例并储存在变量 value 中以便之后直接访问。接着在 a 中用包含 value 的 Cons 变体创建了一个 List。需要克隆 value 以便 a 和 value 都能拥有其内部值 5 的所有权,而不是将所有权从 value 移动到 a 或者让 a 借用 value。
我们将列表 a 封装进了 Rc<T> 这样当创建列表 b 和 c 时,它们都可以引用 a,正如示例 15-18 一样。
一旦创建了列表 a、b 和 c,我们将 value 的值加 10。为此对 value 调用了 borrow_mut,这里使用了第五章讨论的自动解引用功能(“-> 运算符到哪去了?” 部分)来解引用 Rc<T> 以获取其内部的 RefCell<T> 值。borrow_mut 方法返回 RefMut<T> 智能指针,可以对其使用解引用运算符并修改其内部值。
当我们打印出 a、b 和 c 时,可以看到它们都拥有修改后的值 15 而不是 5:
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.63s
Running `target/debug/cons-list`
a after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil))
这个技巧相当巧妙!通过使用 RefCell<T>,我们可以拥有一个对外看起来不可变的 List 值,但在需要时仍然能够使用 RefCell<T> 提供的内部可变性方法来修改数据。借用规则在运行时进行检查,这也确实保护了我们免受数据竞争的影响;有时候,为了换取数据结构上的这种灵活性,付出一点性能代价是值得的。注意,RefCell<T> 不适用于多线程代码!Mutex<T> 是 RefCell<T> 的线程安全版本,我们会在第十六章讨论 Mutex<T>。
引用循环会导致内存泄漏
引用循环与内存泄漏
Rust 的内存安全性保证使其难以意外地制造永远也不会被清理的内存(被称为 内存泄漏,memory leak),但并非不可能。Rust 并不保证完全防止内存泄漏,这意味着内存泄漏在 Rust 中被认为是内存安全的。这一点可以通过 Rc<T> 和 RefCell<T> 看出 Rust 允许出现内存泄漏:创建引用循环的可能性是存在的。这会造成内存泄漏,因为每一项的引用计数永远也到不了 0,持有的数据也就永远不会被释放。
制造引用循环
让我们看看引用循环可能是如何发生的,以及如何避免它。先从示例 15-25 中 List 枚举和 tail 方法的定义开始:
文件名:src/main.rs
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
enum List {
Cons(i32, RefCell<Rc<List>>),
Nil,
}
impl List {
fn tail(&self) -> Option<&RefCell<Rc<List>>> {
match self {
Cons(_, item) => Some(item),
Nil => None,
}
}
}
fn main() {}
示例 15-25:一个持有 RefCell<T> 的 cons list 定义,这样我们就能修改 Cons 变体所引用的内容
这里采用了示例 15-5 中 List 定义的另一种变体。现在 Cons 变体的第二个元素是 RefCell<Rc<List>>,这意味着不同于像示例 15-24 那样能够修改 i32 的值,我们希望能够修改 Cons 变体所指向的 List。这里还增加了一个 tail 方法来方便我们在有 Cons 变体的时候访问其第二项。
在示例 15-26 中,我们添加了一个 main 函数,它使用了示例 15-25 中的定义。这段代码会先在 a 中创建一个列表,再创建一个指向 a 中列表的 b 列表。然后,它会修改 a 中的列表,使其指向 b,从而创建一个引用循环。沿途加入的 println! 语句会展示这一过程中不同位置的引用计数。
文件:src/main.rs
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
enum List {
Cons(i32, RefCell<Rc<List>>),
Nil,
}
impl List {
fn tail(&self) -> Option<&RefCell<Rc<List>>> {
match self {
Cons(_, item) => Some(item),
Nil => None,
}
}
}
fn main() {
let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));
println!("a initial rc count = {}", Rc::strong_count(&a));
println!("a next item = {:?}", a.tail());
let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));
println!("a rc count after b creation = {}", Rc::strong_count(&a));
println!("b initial rc count = {}", Rc::strong_count(&b));
println!("b next item = {:?}", b.tail());
if let Some(link) = a.tail() {
*link.borrow_mut() = Rc::clone(&b);
}
println!("b rc count after changing a = {}", Rc::strong_count(&b));
println!("a rc count after changing a = {}", Rc::strong_count(&a));
// Uncomment the next line to see that we have a cycle;
// it will overflow the stack.
// println!("a next item = {:?}", a.tail());
}
示例 15-26:创建两个彼此互相指向的 List 值,从而形成引用循环
我们在变量 a 中创建了一个 Rc<List> 实例,它持有一个值为 5, Nil 的 List。接着,又在变量 b 中创建了另一个 Rc<List> 实例,它持有一个值为 10、并指向 a 中列表的 List。
然后,我们修改 a,让它指向 b 而不是 Nil,这样就创建了一个循环。为此,我们使用 tail 方法获取 a 中 RefCell<Rc<List>> 的引用,并把它放到变量 link 中。接着,调用这个 RefCell<Rc<List>> 上的 borrow_mut 方法,把它内部的值从持有 Nil 的 Rc<List> 改成 b 中的 Rc<List>。
如果保持最后的 println! 行注释并运行代码,会得到如下输出:
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.53s
Running `target/debug/cons-list`
a initial rc count = 1
a next item = Some(RefCell { value: Nil })
a rc count after b creation = 2
b initial rc count = 1
b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b rc count after changing a = 2
a rc count after changing a = 2
我们可以看到,当把 a 中的列表改为指向 b 之后,a 和 b 中 Rc<List> 实例的引用计数都变成了 2。在 main 结束时,Rust 会先丢弃变量 b,这会使 b 中那个 Rc<List> 实例的引用计数从 2 减到 1。由于引用计数不是 0,所以此时分配在堆上的内存不会被丢弃。然后,Rust 再丢弃 a,这会使 a 中那个 Rc<List> 实例的引用计数也从 2 减到 1。这个实例的内存同样无法被清理,因为另一个 Rc<List> 实例仍然引用着它。分配给这些列表的内存将会永远留在那里而不会被回收。为了更直观地展示这个引用循环,我们创建了图 15-4 所示的示意图:
图 15-4:列表 a 和 b 彼此互相指向,从而形成引用循环
如果取消最后 println! 的注释并运行程序,Rust 会尝试打印出 a 指向 b 指向 a 这样的循环直到栈溢出。
相比真实世界的程序,这个例子中创建引用循环的结果并不可怕:创建了引用循环之后程序立刻就结束了。如果在更为复杂的程序中并在循环里分配了很多内存并占有很长时间,这个程序会使用多于它所需要的内存,并有可能压垮系统并造成没有内存可供使用。
创建引用循环并不容易,但也不是不可能。如果你有包含 Rc<T> 的 RefCell<T> 值或类似的嵌套结合了内部可变性和引用计数的类型,请务必小心确保你没有形成一个引用循环;你无法指望 Rust 帮你捕获它们。创建引用循环是一个程序上的逻辑 bug,你应该使用自动化测试、代码评审和其他软件开发最佳实践来使其最小化。
另一个解决方案是重新组织数据结构,使得一部分引用拥有所有权而另一部分没有。换句话说,循环将由一些拥有所有权的关系和一些无所有权的关系组成,只有所有权关系才能影响值是否可以被丢弃。在示例 15-25 中,我们总是希望 Cons 变体拥有其列表,所以重新组织数据结构是不可能的。让我们看看一个由父节点和子节点构成的图的例子,观察何时是使用无所有权的关系来避免引用循环的合适时机。
使用 Weak<T> 防止引用循环
到目前为止,我们已经展示了调用 Rc::clone 会增加 Rc<T> 实例的 strong_count,和只在其 strong_count 为 0 时 Rc<T> 实例才会被清理。你也可以通过调用 Rc::downgrade 并传递 Rc<T> 实例的引用来创建其值的弱引用(weak reference)。强引用代表如何共享 Rc<T> 实例的所有权;弱引用不表达所有权关系,当 Rc<T> 实例被清理时其计数没有影响。它们不会造成引用循环,因为任何涉及弱引用的循环会在其相关的值的强引用计数为 0 时被打断。
调用 Rc::downgrade 时会得到 Weak<T> 类型的智能指针。不同于将 Rc<T> 实例的 strong_count 加 1,调用 Rc::downgrade 会将 weak_count 加 1。Rc<T> 类型使用 weak_count 来记录其存在多少个 Weak<T> 引用,类似于 strong_count。其区别在于 weak_count 无需计数为 0 就能使 Rc<T> 实例被清理。
因为 Weak<T> 引用的值可能已经被丢弃了,为了使用 Weak<T> 所指向的值,我们必须确保其值仍然有效。为此可以调用 Weak<T> 实例的 upgrade 方法,这会返回 Option<Rc<T>>。如果 Rc<T> 值还未被丢弃,则结果是 Some;如果 Rc<T> 已被丢弃,则结果是 None。因为 upgrade 返回一个 Option<Rc<T>>,Rust 会确保处理 Some 和 None 的情况,所以它不会返回无效指针。
作为示例,我们不再使用只知道下一个元素的列表,而是创建一个既知道子节点又知道父节点的树结构。
创建树形数据结构
首先,我们将构建一棵树,其中节点能够知道自己的子节点。我们会创建一个名为 Node 的结构体,它存放自己的 i32 值,以及对子 Node 值的引用:
文件名:src/main.rs
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
struct Node {
value: i32,
children: RefCell<Vec<Rc<Node>>>,
}
fn main() {
let leaf = Rc::new(Node {
value: 3,
children: RefCell::new(vec![]),
});
let branch = Rc::new(Node {
value: 5,
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
}
我们希望 Node 能拥有它的子节点,同时也希望能与变量共享这种所有权,以便能够直接访问树中的每个 Node。为此,我们将 Vec<T> 中元素的类型定义为 Rc<Node>。我们还希望能够修改某个节点的子节点,因此把 children 中的 Vec<Rc<Node>> 包装进了 RefCell<T>。
接下来,使用此结构体定义来创建一个叫做 leaf 的带有值 3 且没有子节点的 Node 实例,和另一个带有值 5 并以 leaf 作为子节点的实例 branch,如示例 15-27 所示:
文件名:src/main.rs
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
struct Node {
value: i32,
children: RefCell<Vec<Rc<Node>>>,
}
fn main() {
let leaf = Rc::new(Node {
value: 3,
children: RefCell::new(vec![]),
});
let branch = Rc::new(Node {
value: 5,
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
}
示例 15-27:创建没有子节点的 leaf 节点和以 leaf 作为子节点的 branch 节点
这里克隆了 leaf 中的 Rc<Node> 并储存在 branch 中,这意味着 leaf 中的 Node 现在有两个所有者:leaf 和 branch。可以通过 branch.children 从 branch 中获得 leaf,不过无法从 leaf 得到 branch。leaf 没有到 branch 的引用且并不知道它们相互关联。我们希望 leaf 知道 branch 是其父节点。接下来我们会这么做。
增加从子到父的引用
为了使子节点知道其父节点,需要在 Node 结构体定义中增加一个 parent 字段。问题是 parent 的类型应该是什么。我们知道其不能包含 Rc<T>,因为这样 leaf.parent 将会指向 branch 而 branch.children 会包含 leaf 的指针,这会形成引用循环,会造成其 strong_count 永远也不会为 0。
换一种方式来思考这种关系:父节点应该拥有它的子节点。如果父节点被丢弃了,它的子节点也应该被丢弃。然而,子节点不应该拥有它的父节点。如果我们丢弃一个子节点,父节点仍然应该存在。这正是弱引用适用的场景!
所以 parent 使用 Weak<T> 类型而不是 Rc<T>,具体来说是 RefCell<Weak<Node>>。现在 Node 结构体定义看起来像这样:
文件名:src/main.rs
use std::cell::RefCell;
use std::rc::{Rc, Weak};
#[derive(Debug)]
struct Node {
value: i32,
parent: RefCell<Weak<Node>>,
children: RefCell<Vec<Rc<Node>>>,
}
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});
println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);
println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}
这样,一个节点就能够引用其父节点,但不拥有其父节点。在示例 15-28 中,我们更新 main 来使用新定义以便 leaf 节点可以通过 branch 引用其父节点:
文件名:src/main.rs
use std::cell::RefCell;
use std::rc::{Rc, Weak};
#[derive(Debug)]
struct Node {
value: i32,
parent: RefCell<Weak<Node>>,
children: RefCell<Vec<Rc<Node>>>,
}
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});
println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);
println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}
示例 15-28:一个 leaf 节点,其拥有指向其父节点 branch 的弱引用
创建 leaf 节点类似于示例 15-27,除了 parent 字段有所不同:leaf 开始时没有父节点,所以我们新建了一个空的 Weak<Node> 引用实例。
此时,当尝试使用 upgrade 方法获取 leaf 的父节点引用时,会得到一个 None 值。如第一个 println! 输出所示:
leaf parent = None
当创建 branch 节点时,其也会新建一个 Weak<Node> 引用,因为 branch 并没有父节点。leaf 仍然作为 branch 的一个子节点。一旦在 branch 中有了 Node 实例,就可以修改 leaf 使其拥有指向父节点的 Weak<Node> 引用。这里使用了 leaf 中 parent 字段里的 RefCell<Weak<Node>> 的 borrow_mut 方法,接着使用了 Rc::downgrade 函数来从 branch 中的 Rc<Node> 值创建了一个指向 branch 的 Weak<Node> 引用。
当再次打印出 leaf 的父节点时,这一次将会得到存放了 branch 的 Some 值:现在 leaf 可以访问其父节点了!当打印出 leaf 时,我们也避免了如示例 15-26 中最终会导致栈溢出的循环:Weak<Node> 引用被打印为 (Weak):
leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) },
children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) },
children: RefCell { value: [] } }] } })
没有无限的输出表明这段代码并没有造成引用循环。这一点也可以从观察 Rc::strong_count 和 Rc::weak_count 调用的结果看出。
可视化 strong_count 和 weak_count 的变化
让我们通过创建了一个新的内部作用域并将 branch 的创建放入其中,来观察 Rc<Node> 实例的 strong_count 和 weak_count 值的变化。这会展示当 branch 创建和离开作用域被丢弃时会发生什么。这些修改如示例 15-29 所示:
文件名:src/main.rs
use std::cell::RefCell;
use std::rc::{Rc, Weak};
#[derive(Debug)]
struct Node {
value: i32,
parent: RefCell<Weak<Node>>,
children: RefCell<Vec<Rc<Node>>>,
}
fn main() {
let leaf = Rc::new(Node {
value: 3,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![]),
});
println!(
"leaf strong = {}, weak = {}",
Rc::strong_count(&leaf),
Rc::weak_count(&leaf),
);
{
let branch = Rc::new(Node {
value: 5,
parent: RefCell::new(Weak::new()),
children: RefCell::new(vec![Rc::clone(&leaf)]),
});
*leaf.parent.borrow_mut() = Rc::downgrade(&branch);
println!(
"branch strong = {}, weak = {}",
Rc::strong_count(&branch),
Rc::weak_count(&branch),
);
println!(
"leaf strong = {}, weak = {}",
Rc::strong_count(&leaf),
Rc::weak_count(&leaf),
);
}
println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
println!(
"leaf strong = {}, weak = {}",
Rc::strong_count(&leaf),
Rc::weak_count(&leaf),
);
}
示例 15-29:在内部作用域创建 branch 并检查其强弱引用计数
一旦创建了 leaf,其 Rc<Node> 的强引用计数为 1,弱引用计数为 0。在内部作用域中创建了 branch 并与 leaf 相关联,此时 branch 中 Rc<Node> 的强引用计数为 1,弱引用计数为 1(因为 leaf.parent 通过 Weak<Node> 指向 branch)。这里 leaf 的强引用计数为 2,因为现在 branch 的 branch.children 中储存了 leaf 的 Rc<Node> 的拷贝,不过弱引用计数仍然为 0。
当内部作用域结束时,branch 离开作用域,Rc<Node> 的强引用计数减少为 0,所以其 Node 被丢弃。来自 leaf.parent 的弱引用计数 1 与 Node 是否被丢弃无关,所以并没有产生任何内存泄漏!
如果在内部作用域结束后尝试访问 leaf 的父节点,会再次得到 None。在程序的结尾,leaf 中 Rc<Node> 的强引用计数为 1,弱引用计数为 0,因为现在 leaf 又是 Rc<Node> 唯一的引用了。
所有这些管理计数和值的逻辑都内建于 Rc<T> 和 Weak<T> 以及它们的 Drop trait 实现中。通过在 Node 定义中指定从子节点到父节点的关系为一个 Weak<T> 引用,就能够拥有父节点和子节点之间的双向引用而不会造成引用循环和内存泄漏。
总结
这一章涵盖了如何使用智能指针来做出不同于 Rust 常规引用默认所提供的保证与取舍。Box<T> 有一个已知的大小并指向分配在堆上的数据。Rc<T> 记录了堆上数据的引用计数从而允许多个所有者。RefCell<T> 类型及其内部可变性允许我们在保持类型不可变的前提下更改其内部值;它也在运行时而非编译时执行借用规则检查。
我们还讨论了 trait Deref 和 Drop,它们实现了智能指针的许多功能。同时探索了会造成内存泄漏的引用循环,以及如何使用 Weak<T> 来避免它们。
如果本章内容引起了你的兴趣并希望现在就实现你自己的智能指针的话,请阅读 “The Rustonomicon” 来获取更多有用的信息。
接下来,让我们谈谈 Rust 的并发。届时甚至还会学习到一些新的对并发有帮助的智能指针。