2026最新Rust入门教程[15]:Box、Rc、RefCell 与内部可变性

0 阅读47分钟

Rust Book 编译版 15 · 智能指针:Box、Rc、RefCell 与内部可变性

编译自 The Rust Programming Language(2024 edition),在原文基础上做了删减与补充。© The Rust Project Developers,依据 MIT OR Apache-2.0 许可编译转载。

开始之前

第 4 章的引用 & 是 Rust 中最常见的指针:只借用数据,没有任何特殊能力,也没有任何开销。但有两个需求是引用规则覆盖不了的——一份数据要同时拥有多个所有者,以及对外不可变的对象内部需要修改。智能指针(smart pointer)就是为此准备的:它们通常是用结构体实现的数据结构,与引用「借用」不同,多数情况下拥有(own)所指向的数据,并靠实现 Deref 和 Drop 这两个 trait 获得引用没有的行为——Deref 让智能指针的实例用起来像引用,Drop 让你自定义实例离开作用域时运行的代码。这个概念起源于 C++,并非 Rust 独有;标准库之外很多库有自己的智能指针,你也可以自己写。

本章聚焦标准库中最常用的三种:Box<T>(在堆上分配值)、Rc<T>(引用计数的多重所有权)与 RefCell<T>(把借用规则的强制执行从编译期挪到运行期,经 Ref<T>/RefMut<T> 访问),外加两个专题——内部可变性(interior mutability)模式,以及引用循环(reference cycle)如何泄漏内存、Weak<T> 如何预防。

前置知识:第 14 章「深入了解 Cargo 与 crates.io」。另外第 4 章的借用规则本章会反复用到,建议顺带复习。

你会学到:

  • 用 Box<T> 把数据放到堆上,并借助它定义出大小可知的递归类型(cons 列表)
  • 手写 MyBox<T> 实现 Deref,弄清解引用强转(deref coercion)为什么能让 &String 传给 &str 参数
  • 用 Drop trait 定制清理逻辑,理解 Rust 版 RAII 的确定性释放
  • 用 Rc<T> 共享只读数据、用 RefCell<T> 在不可变外壳内修改数据,以及二者的组合 Rc<RefCell<T>>
  • 识别引用循环导致的内存泄漏,用 Weak<T> 让子节点引用父节点而不持有所有权

使用 Box<T> 指向堆上的数据

最直白的智能指针是 box(箱),类型写作 Box<T>:把数据存到堆上,栈上只留指向堆数据的指针(栈与堆的区别回顾第 4 章)。除了改变存储位置,box 没有性能开销,也没有多少额外能力,最常用的场景有三种:

  • 类型在编译期无法得知大小,却要在要求确切大小的上下文中使用
  • 持有大量数据并转移所有权,同时确保转移时数据不被复制
  • 只关心值实现了某个 trait、不关心它具体是什么类型

第一种情形马上在「借助 box 定义递归类型」演示;第二种情形里转移大量数据的所有权可能非常耗时,放进 box 后栈上被复制的只有少量指针;第三种即 trait 对象(trait object),第 18 章《面向对象语言特性》的「使用 trait 对象抽象共享行为」一节会专门讨论,这里学的到那时还会用上。

在堆上存储数据

示例 15-1 用 box 把一个 i32 值存到堆上。

fn main() {
    let b = Box::new(5);
    println!("b = {b}");
}

示例 15-1:使用 box 在堆上存储一个 i32 值

变量 b 是指向堆上值 5 的 Box,程序打印 b = 5——访问 box 中数据的方式,与数据放在栈上并无二致;b 在 main 结束时离开作用域,box 本身(栈上)和它指向的数据(堆上)都会被释放。把单个值放上堆没有多大用处,box 的真正价值在于:它让我们能定义出没有 box 就无法定义的类型。

借助 box 定义递归类型

递归类型(recursive type)的值可以把另一个同类型的值作为自身的一部分。麻烦在于:Rust 需要在编译期知道一个类型占多少空间,而递归嵌套理论上可以无限延续。box 的大小是已知的,在递归定义中插入一个 box,无限就变成了有限。我们用 cons 列表做例子——一种除了递归以外都很简单的函数式数据结构,日后遇到更复杂的递归类型时,这里的思路同样派得上用场。

理解 cons 列表

cons 列表(cons list)源自 Lisp 及其方言,由嵌套的「对」组成,是 Lisp 版的链表,名字来自 Lisp 的 cons 函数(construct function,「构造函数」的缩写)。对「一个值 + 另一个对」反复调用 cons,就构造出递归的列表。包含 1, 2, 3 的 cons 列表用伪代码表示是:

(1, (2, (3, Nil)))

每一项含两个元素:当前项的值和下一项,最后一项只放一个 Nil 标记递归终止——注意它与第 6 章讨论的 "null"/"nil"(无效或缺失的值)不是一回事。cons 列表在 Rust 中并不常用(存一系列项时 Vec<T> 几乎总是更好),选它纯粹是为了在少受干扰的情况下讲清 box 如何帮我们定义递归数据类型。示例 15-2 是它的枚举定义,注意无法通过编译,原因稍后演示:

enum List {
    Cons(i32, List),
    Nil,
}

示例 15-2:第一次尝试定义一个枚举,表示由 i32 值组成的 cons 列表数据结构

提示: 为了示例的需要,这里实现的 cons 列表只保存 i32 值。我们本可以像第 10 章讨论的那样使用泛型,定义一个能保存任意类型值的 cons 列表类型。

用 List 存储列表 1, 2, 3 如示例 15-3:

// --省略--

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——后者又是持有 2 的 Cons,再往里是持有 3 的 Cons,最终落到标志列表结束的非递归变体 Nil 上。编译得到示例 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>),
  |               ++++    +

For more information about this error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 1 previous error

示例 15-4:尝试定义递归枚举时得到的错误

错误说这个类型「具有无限大小」:List 的某个变体直接持有另一个自身值,Rust 就算不出存储一个 List 需要多少空间。为什么?先看非递归类型的大小怎么算。

计算非递归类型的大小

回顾第 6 章示例 6-2 的 Message 枚举:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

Rust 遍历每个变体找出需要空间最大的:Message::Quit 不占空间,Message::Move 要装下两个 i32,依此类推;实际只会用到其中一个变体,所以一个 Message 值的空间就是其最大变体所需的空间。

递归类型是另一回事:编译器看 Cons——一个 i32 加一个 List;那 List 多大?又去看它的变体,还是从 Cons 开始……这个过程无限进行下去,如图 15-1。

image.png

图 15-1:由无限个 Cons 变体构成的无限 List

得到大小已知的递归类型

编译器在错误里附了这条有用的建议:

help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

建议中的间接(indirection)是指:不直接存储值,而是存储一个指向该值的指针。指针的大小不随其指向数据量变化,所以 Rust 始终知道 Box<T> 需要多少空间——在 Cons 变体里放一个 Box<T> 而不是直接放另一个 List,下一个 List 值就存到了堆上。概念上我们仍是「由持有其他列表的列表」构成的列表,只是实现从层层相套变成了依次排开。改成示例 15-5 就能通过编译:

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 不存储任何值;任何 List 值的占用都是「一个 i32 + 一个 box 指针」——无限递归链被打断,如图 15-2。

image.png 图 15-2:Cons 持有 Box,List 因此不再是无限大小

box 只提供间接与堆分配,没有其他特殊能力,也就没有相应开销——在只需「间接」这一种特性的 cons 列表里恰好够用。而 Box<T> 之所以是智能指针,正因为它实现了 Deref(值可以像引用一样对待)和 Drop(离开作用域时清理指向的堆数据);这两个 trait 对本章余下的智能指针类型更加重要,我们逐个深入研究。

编译注: C++ 读者可以直接对照:Box<T> ≈ std::unique_ptr(独占所有权),Rc<T> ≈ std::shared_ptr(引用计数),Weak<T> ≈ std::weak_ptr。两个显著差异值得先记下:其一,C++ 的 shared_ptr 计数默认是原子的,单线程也得付这个开销;Rust 把它拆成 Rc<T>(非原子、更快、仅单线程)和 Arc<T>(原子、可跨线程,第 16 章)。其二,shared_ptr 允许随时改写所指数据,而 Rc<T> 只给不可变访问——想改就得叠 RefCell<T>,把借用检查推迟到运行期,这正是本章的后半条主线。

像常规引用一样使用智能指针

实现 Deref trait 可以自定义解引用运算符(dereference operator)* 的行为(不要与乘法运算符或 glob 运算符混淆)。只要按「智能指针可以像常规引用一样对待」来实现 Deref,你写出的操作引用的代码就也能用于智能指针。接下来先看 * 如何作用于常规引用,再定义一个行为类似 Box<T> 的自定义类型看它差在哪,然后用 Deref 补上这块能力,最后讲 Rust 的解引用强转(deref coercion)特性。

沿着引用找到值

常规引用是一种指针,可以看作一根指向存放在别处的值的箭头。示例 15-6 创建一个指向 i32 值的引用,再用解引用运算符沿着引用找到值。

fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

示例 15-6:使用解引用运算符沿引用找到 i32 值

变量 x 持有 5,y 是指向 x 的引用。断言 x 等于 5 没问题;要对 y 中的值做断言则必须写 *y 沿引用取得值(所以叫解引用)。写成 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}`
  = help: the following other types implement trait `PartialEq<Rhs>`:
            f128
            f16
            f32
            f64
            i128
            i16
            i32
            i64
          and 8 others

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>

把示例 15-6 改用 Box<T> 代替引用(示例 15-7),解引用运算符的工作方式完全相同:

fn main() {
    let x = 5;
    let y = Box::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

示例 15-7:对 Box<i32> 使用解引用运算符

区别只在:y 现在是 box 实例,指向 x 值的拷贝而非指向 x 的引用;最后一条断言依旧用 * 沿指针找到值。Box<T> 凭什么享受引用的待遇?我们定义自己的 box 类型看看。

定义我们自己的智能指针

构建一个与标准库 Box<T> 类似的包装类型,体会智能指针类型默认与引用有何不同。

提示: 接下来要构建的 MyBox<T> 类型与真正的 Box<T> 有一个很大的区别:我们的版本不会把数据存到堆上。这个例子聚焦于 Deref,数据实际存储在哪里并不重要,重要的是类似指针的行为。

Box<T> 最终被定义为一个只含一个元素的元组结构体(tuple struct),示例 15-8 照此定义 MyBox<T>,并配套一个与 Box<T> 上定义保持一致的 new 函数。

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

示例 15-8:定义 MyBox<T> 类型

MyBox 是只有一个 T 类型元素的元组结构体;MyBox::new 接收一个 T 类型的参数,返回持有传入值的实例。把示例 15-7 的 main 换成使用 MyBox<T>(示例 15-9),无法通过编译——Rust 不知道如何对 MyBox 解引用。

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

正如第 10 章「为类型实现 trait」讨论的,实现 trait 要提供其必需方法。Deref 要求实现一个 deref 方法:借用 self,返回一个指向内部数据的引用。示例 15-10 可以添加到 MyBox<T> 的定义中:

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

示例 15-10:为 MyBox<T> 实现 Deref

type Target = T; 定义了供 Deref trait 使用的关联类型(associated type)——声明泛型参数的另一种方式,第 20 章详细讲解。deref 返回 &self.0,即指向元组结构体第一个值的引用(.0 的用法见第 5 章「用元组结构体创建不同类型」)。现在示例 15-9 中的 main 可以编译了,断言全部通过。

没有 Deref,编译器只能对 & 引用解引用;有了 deref 方法,编译器就能对任何实现了 Deref 的类型调用它,得到一个它知道如何解引用的引用。写 *y 时,幕后 Rust 实际运行的是:

*(y.deref())

Rust 把 * 替换成一次 deref 方法调用加一次普通解引用,我们不必自己判断要不要调 deref——这让代码面对常规引用和 Deref 类型行为完全一致。deref 返回引用而非值,是出于所有权考虑:直接返回值会把值从 self 中移出,而多数场景并不想夺走 MyBox<T> 内部值的所有权。另外,每次 * 只被替换成一次 deref 加一次 *,不会无限递归,最终得到的 i32 数据正好匹配断言里的 5。

在函数和方法中使用解引用强转

解引用强转会把指向某个实现了 Deref 的类型的引用,转换成指向另一个类型的引用——例如把 &String 转换成 &str,因为 String 的 Deref 实现返回的就是 &str。当引用类型与函数或方法的参数类型不匹配时,Rust 自动插入一串对 deref 方法的调用,把你的类型转换成参数需要的类型。这个特性让函数调用不必显式写出那么多 & 和 *,也让代码既适用于引用又适用于智能指针。

沿用 MyBox<T> 与它的 Deref 实现。示例 15-11 定义一个接收字符串切片参数的函数:

fn hello(name: &str) {
    println!("Hello, {name}!");
}

示例 15-11:带有 &str 类型参数 name 的 hello 函数

传字符串切片调用当然可以,比如 hello("Rust");;解引用强转还允许传入一个指向 MyBox<String> 值的引用(示例 15-12):

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&m);
}

示例 15-12:因为解引用强转,我们可以用指向 MyBox<String> 值的引用调用 hello

&m 是 &MyBox<String>。Rust 先调用 deref 把它变成 &String,再利用标准库为 String 提供的 Deref 实现(返回字符串切片,见 Deref 的 API 文档)变成 &str,正好匹配 hello 的签名。若没有解引用强转,就得写出示例 15-13 这样的代码:

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&(*m)[..]);
}

示例 15-13:如果 Rust 没有解引用强转,我们不得不写的代码

(*m) 把 MyBox<String> 解引用成 String,& 和 [..] 再取出覆盖整个字符串的切片。没有强转时符号成堆,读写理解都费劲;有了强转,这些转换由 Rust 自动完成。只要相关类型都实现了 Deref,Rust 就会在编译期分析出需要插入多少次 Deref::deref——享受便利没有任何运行时开销。

编译注: 解引用强转体现了一种代码中的高频模式:函数参数是 &str,传 &String 甚至 &MyBox<String> 都能过编译——编译器在幕后链式插入 deref。同一机制也作用于方法调用:第 5 章「-> 运算符去哪了?」提过的自动解引用,让 value.borrow_mut() 这种跨 Rc<T> 到 RefCell<T> 的链式调用不必手写 *,后面的示例 15-24 就用到了。

可变引用下的解引用强转

与用 Deref 重载不可变引用上的 * 运算符类似,可以用 DerefMut trait 重载可变引用上的 *。Rust 在发现相应的类型和 trait 实现时,会在三种情形下执行解引用强转:

  1. 当 T: Deref<Target=U> 时,从 &T 到 &U
  2. 当 T: DerefMut<Target=U> 时,从 &mut T 到 &mut U
  3. 当 T: Deref<Target=U> 时,从 &mut T 到 &U

前两种本质相同,只是第二种引入了可变性。第三种更微妙:可变引用可以强转成不可变引用,反过来不行——借用规则保证可变引用必须是该数据的唯一引用(否则无法通过编译),把它转成不可变引用永远不会破坏规则;而不可变引用转成可变引用,要求它是该数据唯一的不可变引用,借用规则并不保证这一点,Rust 不能做这种假设。

使用 Drop trait 在清理时运行代码

对智能指针模式同样重要的第二个 trait 是 Drop:自定义值即将离开作用域时发生的事情,可用来释放文件、网络连接之类的资源。实现智能指针时几乎总会用到——Box<T> 被丢弃时,释放的就是它所指向的堆上空间。

有些语言要求程序员每次用完实例都手动调用释放代码(文件句柄、套接字、锁都是例子),忘了系统就可能过载崩溃。Rust 的做法是指定一段代码在值离开作用域时运行,由编译器自动插入——不必在每个使用点布置清理代码,照样不会泄漏资源。

Drop trait 要求实现一个 drop 方法,接收 self 的可变引用。示例 15-14 暂时用 println! 语句实现它,直观展示 Rust 何时调用:

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:一个实现了 Drop trait 的 CustomSmartPointer 结构体,清理代码就放在这里

Drop 已包含在 prelude 中,无需手动引入;想让实例离开作用域时运行的任何逻辑都可放进 drop 方法体,这里打印只为看清调用时机。main 创建两个实例并打印 CustomSmartPointers created;main 结束时实例离开作用域,Rust 自动调用 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`!

注意变量按创建顺序的逆序被丢弃(d 先于 c),我们没显式调用任何东西;实际项目里 drop 放的通常是真正的清理逻辑而非打印。

想提前清理一个值怎么办?一个例子是管理锁的智能指针:你可能想强制执行释放锁的 drop 方法,让同一作用域中的其他代码获得这把锁。Rust 不允许手动调用 Drop trait 的 drop 方法——示例 15-15 的尝试行不通:

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

错误信息里的析构函数(destructor)是「清理实例的函数」的通用称呼,与创建实例的构造函数(constructor)相对。不允许显式调用 drop 的原因是:即便手动调了,main 结束时 Rust 仍会自动再调一次,导致二次释放(double free)错误——同一个值被清理两次。我们既无法禁用自动插入的 drop,也无法显式调用 drop 方法,因此要提前清理,请用标准库的 std::mem::drop 函数:它与 Drop trait 中的 drop 方法是两回事,把想强制丢弃的值作为参数传入即可。这个函数也在 prelude 中(示例 15-16):

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 之间,说明 c 正是在那一刻被丢弃的。Drop 实现中指定的代码有很多用法,可以让清理既方便又安全——你甚至可以拿它打造自己的内存分配器。配合所有权系统,你既不必惦记着做清理(Rust 自动完成),也不必担心「不小心清理了还在使用的值」——所有权系统同样确保 drop 只会在值不再使用时被调用一次。

编译注: 这套「离开作用域自动清理」就是教科书里的 RAII(Resource Acquisition Is Initialization),C++ 析构函数的同款思路。写过 Java/Go 的人要特别体会差别:finalizer / GC 的回收时机不确定,而 Rust/C++ 的 Drop/析构是确定性的、按作用域逆序发生的——锁、文件描述符、连接的释放时机可以被推理。数据库内核里这类确定性释放是常态(页面缓冲、锁、文件描述符),这也是 Rust 这几年持续渗透进基础设施领域的原因之一。

Rc<T>:引用计数智能指针

大多数情况下所有权是明确的:你确切地知道哪个变量拥有某个值。但也存在一个值拥有多个所有者的情形——图数据结构中,多条边可能指向同一个节点,节点在概念上为所有指向它的边共同拥有;只要还有任何边指向它,它就不应被清理。

要启用多所有权,必须显式使用 Rc<T> 类型——引用计数(reference counting)的缩写。它跟踪指向某值的引用数量,以此判断值是否仍在使用:引用数量为零就可以清理,且不会有任何引用随之失效。

当想在堆上分配一些数据供程序的多个部分读取、又无法在编译时确定哪个部分会最后用完这份数据时,就用 Rc<T>;能确定的话,直接让那个部分成为所有者,编译期强制执行的常规所有权规则自会生效。注意 Rc<T> 只适用于单线程场景,多线程程序中如何做引用计数,等第 16 章《无畏并发》讨论并发时再讲。

共享数据

回到示例 15-5 的 cons 列表。这次创建两个列表,让它们共享第三个列表的所有权,概念上如图 15-3。

image.png

图 15-3:两个列表 b 和 c 共享第三个列表 a 的所有权

列表 a 依次是 5 和 10;b 以 3 开头、c 以 4 开头,两者都衔接到 a 上。沿用之前用 Box<T> 定义的 List 实现这个场景是行不通的(示例 15-17):

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,再创建 c 时就不允许使用已被移走的 a。改成持有引用也行不通——那必须引入生命周期参数,等于声明列表中每个元素都至少与整个列表活得一样久,并非所有场景都满足。

改为在 List 定义中用 Rc<T> 替换 Box<T>(示例 15-18):每个 Cons 变体持有一个值和一个指向 List 的 Rc<T>。创建 b、c 时克隆 a 持有的 Rc<List>,引用计数从 1 到 2 再到 3;计数不为 0,数据就不会被清理。

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 定义

Rc<T> 不在 prelude 中,需要 use 语句引入。main 里先创建持有 5 和 10 的列表放进新的 Rc<List> 存入 a,再创建 b 和 c 时调用 Rc::clone 传入 a 中 Rc<List> 的引用。

这里本可以写 a.clone() 而不是 Rc::clone(&a),但惯例用 Rc::clone:它不像大多数类型的 clone 那样做深拷贝,只增加引用计数,几乎不花时间;数据的深拷贝则可能相当耗时。用 Rc::clone 做引用计数,就能在代码里一眼区分「深拷贝式的克隆」与「增加引用计数式的克隆」——排查性能问题时只关注前者,大可无视 Rc::clone 的调用,这是一个良好的编码习惯。

通过克隆增加引用计数

修改示例 15-18,给列表 c 套上一个内部作用域,观察引用计数随创建与丢弃如何变化(示例 15-19):

// --省略--

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> 防止引用循环」一节。程序输出:

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 的初始计数是 1,每 clone 一次加 1;c 离开作用域时减 1——减少计数不需要专门函数,Rc<T> 值离开作用域时 Drop trait 的实现会自动减。输出里看不到的是:b、随后是 a 在 main 结束时离开作用域,计数归 0,Rc<List> 被彻底清理。Rc<T> 让一个值可以有多个所有者,计数机制确保只要还有任何所有者存在,这个值就保持有效。

不过,Rc<T> 借助不可变引用共享的是只读数据——若它允许持有多个可变引用,就可能违反第 4 章的借用规则(对同一处的多个可变借用可能导致数据竞争和不一致)。但能修改数据毕竟非常有用!下一节的内部可变性模式,以及可以与 Rc<T> 配合使用的 RefCell<T>,就是突破这一限制的路径。

RefCell<T> 与内部可变性模式

内部可变性(interior mutability)是 Rust 中的一种设计模式:允许你在存在不可变引用的情况下修改数据——按照借用规则,这种操作通常是不被允许的。该模式在数据结构内部使用 unsafe 代码绕开 Rust 管制可变性与借用的规则(unsafe 向编译器表明「规则由我们人工检查,不依赖编译器代劳」,第 20 章《高级特性》详谈),再把这些 unsafe 代码包装在安全的 API 里,外层类型依然是不可变的。前提是你能确保借用规则在运行时得到遵守——尽管编译器无法给出这种保证。我们通过遵循这一模式的 RefCell<T> 来探究它。

在运行时强制执行借用规则

与 Rc<T> 不同,RefCell<T> 表示单一所有权。那它与 Box<T> 这样的类型差在哪?回想第 4 章的借用规则:

  • 在任意给定时刻,要么只能有一个可变引用,要么可以有任意多个不可变引用(二者不可兼得)。
  • 引用必须始终有效。

用引用和 Box<T> 时,这些不变量在编译时强制执行;用 RefCell<T> 时在运行时强制执行——违反规则,前者得到编译错误,后者程序 panic 并退出。

编译时检查的好处是错误在开发中更早暴露,且所有分析提前完成、对运行时性能没有影响——大多数情况下这是最佳选择,也是 Rust 的默认做法。运行时检查的好处则是某些内存安全的场景因此变得可行:静态分析(比如 Rust 编译器)天生保守,代码的某些性质原理上就无法靠分析代码判定(最著名的例子是停机问题,Halting Problem)。正因如此,编译器无法确定代码符合所有权规则时,可能拒绝一个本来正确的程序——接受一个错误的程序,用户就无法信任 Rust 的保证;拒绝一个正确的程序,程序员只是有些不便,不会发生灾难。当你确信自己的代码遵守了借用规则、而编译器无法理解和保证这一点时,RefCell<T> 就能派上用场。

与 Rc<T> 一样,RefCell<T> 只适用于单线程场景,多线程上下文中使用会得到编译错误;多线程中获得同等功能的方案同样见第 16 章。

选择 Box<T>、Rc<T> 或 RefCell<T> 的依据:

  • Rc<T> 允许同一份数据有多个所有者;Box<T> 和 RefCell<T> 只有单一所有者。
  • Box<T> 允许可变或不可变借用,并在编译时检查;Rc<T> 只允许不可变借用,并在编译时检查;RefCell<T> 允许可变或不可变借用,但在运行时检查。
  • 由于 RefCell<T> 允许在运行时检查的可变借用,即使 RefCell<T> 本身不可变,你也能修改其中的值。

「修改不可变值内部的值」正是内部可变性模式。来看一个它大有用武之地的场景,并弄明白它何以可行。

使用内部可变性

借用规则的一个推论:持有不可变值时,就不能可变地借用它。下面的代码无法通过编译:

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> 是获得内部可变性能力的一种途径,但它并没有完全绕开借用规则:只是编译器的借用检查器放行了这种内部可变性,规则改为在运行时检查,违反了得到的是 panic! 而不是编译错误。

用 mock 对象做测试

测试期间有时会用一个类型代替另一个类型,以便观察特定的行为并断言其实现正确。这类占位类型叫测试替身(test double);mock 对象(mock object)是测试替身中的一种具体类型:记录测试过程中发生了什么,让你可以断言正确的操作确实发生了。Rust 并不像其他语言那样拥有相同意义上的对象,标准库也不内置 mock 功能,但完全可以创建一个起到同样作用的 struct。

场景:写一个库,跟踪某个值与最大值的接近程度,并按阈值发送消息——比如跟踪用户被允许发起的 API 调用次数配额。库只负责「跟踪值有多接近最大值」和「什么时间点该发送什么消息」两件事;怎么发(展示给用户、发邮件、发短信)由使用方自行提供,库需要的只是某个实现了 Messenger trait 的东西。示例 15-20 是库的代码:

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:一个库,跟踪值与最大值的接近程度,并在值达到特定水平时发出警告

要点有二:Messenger trait 的 send 方法接收 self 的不可变引用和消息文本——这是 mock 对象要实现的接口;而 set_value 不返回任何可供断言的东西。我们想表达的是:用实现了 Messenger 的东西和某个 max 值创建 LimitTracker 后,给 value 传入不同的数字时,messenger 会被要求发送相应的消息。

需要的 mock 在调用 send 时不发邮件、不发短信,只记录被告知要发送的消息。示例 15-21 是一次尝试,但借用检查器不会放行:

#[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 用一个装着 String 的 Vec 记录消息,并为它实现了 Messenger;测试里创建空的 mock、创建 LimitTracker(max 为 100)、以 80 调用 set_value(80 超过 100 的 75%),再断言消息列表里应有一条记录。问题出在:

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 it 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

send 接收的是 self 的不可变引用,无法对 sent_messages 调用 push。也不能采纳错误文本的建议改成 &mut self——那要连 trait 定义一起改,我们不希望仅为测试的缘故改动 Messenger 的设计。

这正是内部可变性大显身手的地方:把 sent_messages 放进一个 RefCell<T>,send 方法就能修改它来记录消息。示例 15-22 展示了具体做法:

#[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() {
        // --省略--

        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}

示例 15-22:使用 RefCell<T> 在外部值被视为不可变的情况下修改内部值

sent_messages 字段从 Vec<String> 变成 RefCell<Vec<String>>。send 的第一个参数仍是对 self 的不可变借用,与 trait 定义保持一致;对 RefCell<Vec<String>> 调用 borrow_mut 得到指向内部 vector 的可变引用,再调用 push 记录消息;断言处则改用 borrow 拿不可变引用查看内部 vector 的长度。trait 的设计纹丝未动。

在运行时跟踪借用

创建不可变引用和可变引用分别用 & 和 &mut 语法;对 RefCell<T> 则用 borrow 和 borrow_mut 方法——它们是 RefCell<T> 自带安全 API 的一部分:borrow 返回智能指针 Ref<T>,borrow_mut 返回 RefMut<T>,两者都实现了 Deref,可以当作普通引用使用。

RefCell<T> 跟踪当前有多少个 Ref<T> 和 RefMut<T> 处于活动状态:每次调用 borrow 不可变借用计数加 1,某个 Ref<T> 离开作用域时减 1;与编译期的借用规则一样,任一时刻允许多个不可变借用或一个可变借用。违反规则不报编译错误,而是 RefCell<T> 的实现在运行时 panic。示例 15-23 故意在同一作用域内创建两个同时活动的可变借用:

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));
    }
}

示例 15-23:在同一作用域内创建两个可变引用,看看 RefCell<T> 会 panic

one_borrow 和 two_borrow 都是 borrow_mut 返回的 RefMut<T>,这就让同一作用域出现了两个可变引用。代码能毫无错误地通过编译,但运行测试会失败:

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' (6028024) 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> 在运行时处理借用规则违规的方式。选择在运行时而非编译时捕获借用错误,意味着错误可能到开发流程的后期、甚至部署到生产环境后才暴露,且运行时跟踪会带来一点性能损失;换来的是在只允许不可变值的上下文中,写出可以自我修改、记录见过的消息的 mock 对象。权衡利弊后使用 RefCell<T>,你能获得普通引用所不具备的更多功能。

允许可变数据拥有多个所有者

RefCell<T> 的一种常见用法是与 Rc<T> 组合:Rc<T> 让一份数据可以有多个所有者,但只提供不可变访问;让一个 Rc<T> 持有 RefCell<T>,就能得到一个既可有多个所有者、又可修改的值。

回到示例 15-18 的 cons 列表:Rc<T> 只持有不可变值,列表一旦创建,其中的值就无法更改。在 Cons 定义中引入 RefCell<T> 后(示例 15-24),我们可以修改存储在所有列表中的值:

#[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,便于之后直接访问;再把持有 value 克隆的 List(共享内部 5 的所有权,而非把所有权从 value 转移给 a)包进新的 Rc 存入 a,这样 b 和 c 都能引用 a——与示例 15-18 相同。随后 *value.borrow_mut() += 10:借助第 5 章「-> 运算符去哪了?」一节讨论过的自动解引用把 Rc<T> 解引用到内部的 RefCell<T>,borrow_mut 返回 RefMut<T> 智能指针,对它使用解引用运算符修改内部的值。打印 a、b、c:

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))

三者内部的 5 都变成了修改后的 15。这招相当漂亮:表面上不可变的 List 值,借助 RefCell<T> 提供内部可变性访问的方法,就能在需要时修改数据;借用规则的运行时检查让我们免于数据竞争——有时为了这种灵活性牺牲一点速度是值得的。再次强调:RefCell<T> 不能用于多线程代码!它的线程安全版本是 Mutex<T>,第 16 章讨论。

至此,本章的几种「外壳」可以收进一张速查表:

类型            所有权     借用检查     可变访问
──────────────────────────────────────────────
Box<T>          单一       编译期       可(需自身为 mut)
Rc<T>           多个       编译期       否(只读共享)
RefCell<T>      单一       运行期       是(内部可变性)
Rc<RefCell<T>>  多个       运行期       是

引用循环可能导致内存泄漏

Rust 的内存安全使意外创建永远不会被清理的内存(即内存泄漏,memory leak)变得困难,但并非不可能——彻底防止内存泄漏并不在 Rust 的保证之列,换句话说,在 Rust 中内存泄漏也是内存安全的。借助 Rc<T> 和 RefCell<T> 就能看到 Rust 允许内存泄漏:有可能创建出其中的项彼此循环指向的引用,循环中每一项的引用计数永远不会变为 0,这些值也就永远不会被丢弃。

创建引用循环

看引用循环如何发生、又该如何避免。先从示例 15-25 的 List 枚举定义和一个 tail 方法开始:

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,
        }
    }
}

示例 15-25:一个持有 RefCell<T> 的 cons 列表定义,让我们能够修改 Cons 变体所指向的对象

这是示例 15-5 中 List 定义的又一个变体:Cons 变体的第二个元素是 RefCell<Rc<List>>,要修改的不再是示例 15-24 中那样的 i32 值,而是 Cons 变体所指向的 List 值;tail 方法让我们在持有 Cons 变体时方便地访问其中的第二项。示例 15-26 添加 main:先在 a 中创建一个列表,再在 b 中创建一个指向 a 的列表,然后修改 a 让它指向 b,形成引用循环;穿插的 println! 展示各处的引用计数:

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));

    // 取消下一行的注释可以看到我们创建了一个循环;
    // 它会导致栈溢出。
    // println!("a next item = {:?}", a.tail());
}

示例 15-26:创建由两个相互指向的 List 值构成的引用循环

a 持有初始列表 5, Nil;b 包含值 10 并指向 a。然后把 a 改成指向 b 而非 Nil:先用 tail 方法取得 a 中 RefCell<Rc<List>> 的引用存入变量 link,再对它调用 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 两个 Rc<List> 实例的引用计数都是 2。main 结束时,Rust 丢弃 b,计数从 2 减到 1——堆上的内存不会丢弃,因为计数不是 0;接着丢弃 a,同样从 2 减到 1,也无法丢弃,因为另一个 Rc<List> 实例仍然引用着它。分配给这个列表的内存将永远得不到回收,如图 15-4。

image.png 图 15-4:列表 a 和 b 相互指向形成的引用循环

如果取消最后一个 println! 的注释并运行,Rust 会尝试打印这个循环:a 指向 b、b 指向 a,如此往复,直到栈溢出。

这个例子的后果不算太严重——创建循环之后程序立刻就结束了;但更复杂的程序若在循环中分配大量内存并长期持有,就会占用超出实际需要的内存,可能压垮系统、耗尽可用内存。创建引用循环并不容易,但也并非不可能:持有包含 Rc<T> 值的 RefCell<T> 值(或内部可变性与引用计数类似的其他嵌套组合)时,必须自己确保不创建出循环——不能指望 Rust 替你发现它们。这是程序中的逻辑 bug,应当借助自动化测试、代码评审等软件开发实践来减少。

避免引用循环的另一种方案是重新组织数据结构,让一部分引用表达所有权、另一部分不表达:循环可以由所有权关系和非所有权关系组成,只有所有权关系才会影响值能否被丢弃。示例 15-25 中我们始终希望 Cons 变体拥有它的列表,无法重组;下面看一个由父节点和子节点构成的图的例子,看什么时候非所有权关系是防止引用循环的合适手段。

使用 Weak<T> 防止引用循环

前面只演示了 Rc::clone 会增加 Rc<T> 实例的 strong_count,且只有 strong_count 为 0 时实例才被清理。此外还可以调用 Rc::downgrade(传入 Rc<T> 的引用)为 Rc<T> 实例中的值创建弱引用(weak reference)。强引用(strong reference)是分享 Rc<T> 实例所有权的方式;弱引用不表达所有权关系,其计数不影响值何时被清理,因此不会导致引用循环:一旦强引用计数变为 0,任何包含弱引用的循环都会被打破。

调用 Rc::downgrade 得到 Weak<T> 类型的智能指针,它把 weak_count 加 1 而非 strong_count。Rc<T> 用 weak_count 跟踪存在多少个 Weak<T> 引用,与 strong_count 的区别在于:weak_count 不必为 0,Rc<T> 实例也可以被清理。正因 Weak<T> 所引用的值可能已被丢弃,想对它指向的值做任何操作都必须先确认值仍然存在——在 Weak<T> 实例上调用 upgrade 方法,它返回 Option<Rc<T>>:值尚未被丢弃得到 Some,已被丢弃得到 None。因为返回 Option,Rust 确保 Some 和 None 两种情况都得到处理,不会出现无效指针。

例子来了:不再是每一项只知道下一项的列表,而是一棵树——树中的每一项既知道它的子项,也知道它的父项。

创建树形数据结构

先构建一棵节点知道自身子节点的树。创建一个名为 Node 的 struct,持有自己的 i32 值和指向其子 Node 值的引用:

文件名:src/main.rs

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

我们希望 Node 拥有它的子节点,并把这份所有权共享给各个变量,以便直接访问树中的每个 Node,所以 Vec 的元素定义为 Rc<Node> 类型的值;还希望能修改「哪些节点是另一个节点的子节点」,于是在 children 中用 RefCell<T> 包住了 Vec<Rc<Node>>。

接下来用这个定义创建值为 3、没有子节点的 leaf,和值为 5、以 leaf 作为子节点之一的 branch(示例 15-27):

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 不用 Rc<T> 而用 Weak<T>,确切地说是 RefCell<Weak<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>>>,
}

节点就能引用它的父节点,但不拥有它。示例 15-28 更新 main 使用这个新定义,让 leaf 节点有办法引用父节点 branch:

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 节点时,它的 parent 同样是一个新的 Weak<Node> 引用(branch 没有父节点),而 leaf 仍然是它的子节点之一。有了 branch 中的 Node 实例后,对 leaf 的 parent 字段中的 RefCell<Weak<Node>> 使用 borrow_mut,用 Rc::downgrade 函数把 branch 的 Rc<Node> 创建的弱引用赋进去。再次打印 leaf 的父节点,这次得到持有 branch 的 Some——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> 实例的两种计数如何变化,完整看到 branch 被创建、又在离开作用域时被丢弃的过程(示例 15-29):

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 创建之后:强引用计数 1、弱引用计数 0。内部作用域中创建 branch 并关联 leaf 后:branch 的 Rc<Node> 强 1、弱 1(leaf.parent 以一个 Weak<Node> 指向它);leaf 强 2(branch.children 中存着它的克隆)、弱 0。

内部作用域结束时,branch 离开作用域,其 Rc<Node> 的强引用计数降为 0,其中的 Node 被丢弃;leaf.parent 带来的弱引用计数 1 并不影响 Node 是否被丢弃——我们没有任何内存泄漏!作用域结束后再访问 leaf 的父节点会再次得到 None;程序结束时,leaf 的 Rc<Node> 强 1、弱 0——变量 leaf 又成了指向它的唯一引用。

所有管理计数和值丢弃的逻辑都内建于 Rc<T>、Weak<T> 以及它们对 Drop trait 的实现之中。通过在 Node 的定义中把子节点到父节点的关系指定为 Weak<T> 引用,父节点指向子节点、子节点也指向父节点,而不会创建引用循环和内存泄漏。

小结与下一步

  • Box<T>:把数据放上堆、提供间接,让递归类型(cons 列表)大小可知;独占所有权,借用检查在编译期
  • Deref 与 Drop 是智能指针的两大支柱:解引用强转让 &String 能无感传给 &str 参数且零运行时开销;Drop 在作用域结束时逆序、确定性地清理——Rust 版 RAII
  • Rc<T>:引用计数带来多重所有权的只读共享;Rc::clone 只加计数不做深拷贝
  • RefCell<T>:内部可变性——对外不可变、内部可修改,借用检查挪到运行期,违规 panic;与 Rc<T> 组合成 Rc<RefCell<T>> 得到「多所有者 + 可变」
  • 引用循环是内存泄漏的来源(且泄漏属于内存安全);Weak<T> 加 upgrade() 让父子互相引用而不成环

想实现自己的智能指针,The Rustonomicon 是进阶读物。

下一篇:第 16 章「无畏并发」——所有权与类型系统如何让多线程不再可怕。