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

Beginner

This tutorial is from open-source community. Access the source code

简介

欢迎来到 Rc,引用计数智能指针。本实验是 Rust 程序设计语言 的一部分。你可以在 LabEx 中练习你的 Rust 技能。

在本实验中,我们将探索 Rust 中 Rc(引用计数)的用法,通过跟踪对某个值的引用数量,并确保只有在没有所有者时才清理该值,从而实现一个值的多个所有者。

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

在大多数情况下,所有权是明确的:你确切地知道哪个变量拥有给定的值。然而,在某些情况下,一个值可能有多个所有者。例如,在图数据结构中,多条边可能指向同一个节点,并且从概念上讲,该节点由所有指向它的边所拥有。除非没有任何边指向该节点,即没有所有者,否则该节点不应被清理。

你必须通过使用 Rust 类型 Rc<T> 来显式地启用多重所有权,Rc<T> 是 _引用计数_(reference counting)的缩写。Rc<T> 类型会跟踪对一个值的引用数量,以确定该值是否仍在使用。如果对一个值没有引用,那么该值可以被清理,而不会使任何引用变得无效。

Rc<T> 想象成家庭娱乐室里的一台电视。当一个人进入房间看电视时,他们会打开电视。其他人可以进入房间并观看电视。当最后一个人离开房间时,他们会关掉电视,因为电视不再被使用了。如果有人在其他人还在看电视的时候关掉电视,剩下的电视观众会抗议的!

当我们想要在堆上为程序的多个部分分配一些数据以供读取,并且在编译时无法确定哪个部分将最后使用该数据时,我们就使用 Rc<T> 类型。如果我们知道哪个部分将最后完成使用,我们可以让该部分成为数据的所有者,并且编译时执行的正常所有权规则将会生效。

请注意,Rc<T> 仅用于单线程场景。当我们在第 16 章讨论并发时,我们将介绍如何在多线程程序中进行引用计数。

使用 Rc<T> 来共享数据

让我们回到清单 15-5 中的链表示例。还记得我们是使用 Box<T> 来定义它的。这次,我们将创建两个链表,它们都共享对第三个链表的所有权。从概念上讲,这类似于图 15-3。

图 15-3:两个链表 bc,共享对第三个链表 a 的所有权

我们将创建包含 5 然后是 10 的链表 a。然后我们再创建另外两个链表:以 3 开头的 b 和以 4 开头的 c。然后 bc 链表都将继续连接到包含 510 的第一个 a 链表。换句话说,两个链表将共享包含 510 的第一个链表。

尝试使用我们用 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))));
  1 let b = Cons(3, Box::new(a));
  2 let c = Cons(4, Box::new(a));
}

清单 15-17:演示使用 Box<T> 时不允许有两个链表尝试共享对第三个链表的所有权

当我们编译这段代码时,会得到如下错误:

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

Cons 变体拥有它们所包含的数据,所以当我们创建 b 链表时 [1],a 被移动到 b 中,并且 b 拥有 a。然后,当我们在创建 c 时再次尝试使用 a 时 [2],我们不被允许这样做,因为 a 已经被移动了。

我们可以将 Cons 的定义改为持有引用,但那样我们就必须指定生命周期参数。通过指定生命周期参数,我们将指定链表中的每个元素至少与整个链表存活的时间一样长。清单 15-17 中的元素和链表就是这种情况,但并非在每个场景中都是如此。

相反,我们将把 List 的定义改为使用 Rc<T> 来代替 Box<T>,如清单 15-18 所示。现在每个 Cons 变体将持有一个值和一个指向 ListRc<T>。当我们创建 b 时,我们不会获取 a 的所有权,而是克隆 a 所持有 Rc<List>,从而将引用数量从一增加到二,并让 ab 共享该 Rc<List> 中的数据所有权。我们在创建 c 时也会克隆 a,将引用数量从二增加到三。每次我们调用 Rc::clone 时,Rc<List> 中数据的引用计数都会增加,并且除非对其没有引用,否则数据不会被清理。

文件名:src/main.rs

enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
1 use std::rc::Rc;

fn main() {
  2 let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
  3 let b = Cons(3, Rc::clone(&a));
  4 let c = Cons(4, Rc::clone(&a));
}

清单 15-18:使用 Rc<T>List 定义

我们需要添加一个 use 语句将 Rc<T> 引入作用域 [1],因为它不在 prelude 中。在 main 函数中,我们创建包含 510 的链表并将其存储在 a 中的一个新的 Rc<List> 中 [2]。然后,当我们创建 b [3] 和 c [4] 时,我们调用 Rc::clone 函数并将对 aRc<List> 的引用作为参数传递。

我们本可以调用 a.clone() 而不是 Rc::clone(&a),但在这种情况下 Rust 的惯例是使用 Rc::cloneRc::clone 的实现不像大多数类型的 clone 实现那样对所有数据进行深拷贝。对 Rc::clone 的调用只会增加引用计数,这不会花费太多时间。数据的深拷贝可能会花费很多时间。通过使用 Rc::clone 进行引用计数,我们可以在视觉上区分深拷贝类型的克隆和增加引用计数的克隆类型。在查找代码中的性能问题时,我们只需要考虑深拷贝克隆,并且可以忽略对 Rc::clone 的调用。

克隆 Rc<T> 会增加引用计数

让我们修改清单 15-18 中的示例代码,以便我们可以看到在创建和丢弃对 aRc<List> 的引用时引用计数的变化情况。

在清单 15-19 中,我们将修改 main 函数,使其在链表 c 周围有一个内部作用域;这样我们就可以看到当 c 超出作用域时引用计数是如何变化的。

文件名:src/main.rs

--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 的用途。

这段代码会输出以下内容:

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 来增加引用计数那样调用一个函数来减少引用计数:当一个 Rc<T> 值超出作用域时,Drop 特性的实现会自动减少引用计数。

在这个示例中我们看不到的是,当 b 然后是 amain 函数结束时超出作用域时,计数变为 0,并且 Rc<List> 会被完全清理。使用 Rc<T> 允许一个值有多个所有者,并且计数确保只要有任何一个所有者仍然存在,该值就仍然有效。

通过不可变引用,Rc<T> 允许你在程序的多个部分之间只读共享数据。如果 Rc<T> 也允许你有多个可变引用,那么你可能会违反第 4 章中讨论的一条借用规则:对同一位置的多个可变借用可能会导致数据竞争和不一致。但是能够修改数据是非常有用的!在下一节中,我们将讨论内部可变性模式以及可以与 Rc<T> 一起使用以处理这种不可变性限制的 RefCell<T> 类型。

总结

恭喜你!你已经完成了「Rc,引用计数智能指针」实验。你可以在 LabEx 中练习更多实验来提升你的技能。