Rc<T> 引用計數智慧指標

在大多數的場合,所有權是很明確的:你能確切知道哪些變數擁有哪些數值。然而還是有些情況會需要讓一個數值能有數個擁有者。舉例來說,在資料結構中數個邊可能就會指向同個節點,而該節點概念上就被所有指向它的邊所擁有。節點直到沒有任何邊指向它時才會被清除。

要有多重所有權的話,Rust 有一個型別叫做 Rc<T>,這是引用計數(reference counting) 的簡寫。Rc<T> 型別會追蹤引用其數值的數量來決定該數值是否還在使用中。如果數值沒有任何引用的話,該數值就可以被清除,因為不會產生任何無效引用。

想像 Rc<T> 是個在客廳裡的電視,當有人進入客廳要看電視時,它們就會打開它。其他人也能進來觀看電視。當最後一個人離開客廳時,它們會關掉電視,因為沒有任何人會再看了。如果當其他人還在看電視時,有人關掉了它,其他在看電視的人肯定會生氣。

Rc<T> 型別的使用時機在於當我們想要在堆積上分配一些資料給程式中數個部分讀取,但是我們無法在編譯時期決定哪個部分會最後一個結束使用數值的部分。如果我們知道哪個部分會最後結束的話,我們可以將那個部分作為資料的擁有者就好,然後正常的所有權規則就會在編譯時生效。

注意到 Rc<T> 只適用於單一執行緒(single-threaded)的場合。當我們在第十六章討論並行(concurrency)時,我們會介紹如何在多執行緒程式達成引用計數。

使用 Rc<T> 來分享資料

讓我們回顧範例 15-5 的 cons list 範例。回想一下我們當時適用 Box<T> 定義。這次我們會建立兩個列表,它們會同時共享第三個列表的所有權。概念上會如圖示 15-3 所示:

Two lists that share ownership of a third list

圖示 15-3:兩個列表 bc 共享第三個列表 a 的所有權

我們會建立列表 a 來包含 5 然後是 10。然後我們會在建立兩個列表:b 以 3 為開頭而 c 以 4 為開頭。bc 列表會同時連接包含 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

error: aborting due to previous error

For more information about this error, try `rustc --explain E0382`.
error: could not compile `cons-list`.

To learn more, run the command again with --verbose.

Cons 變體擁有它們存有的資料,所以當我們建立列表 b 時,a 會移動到 b,所以 b 就擁有 a。然後當我們嘗試再次使用 a 來建立 c 時,這就不會被允許,因為 a 已經被移走了。

我們可以嘗試改用引用來變更 Cons 的定義,但是這樣我們就必須指定生命週期參數。透過指定生命週期參數,我們會指定列表中的每個元素會至少活得跟整個列表一樣久。舉例來說,這樣借用檢查器就不會讓我們編譯過 let a = Cons(10, &Nil);,因為暫時的數值 Nil 會在 a 取得引用之前就被釋放。

我們最後可以改用 Rc<T> 來變更 List 的定義,如範例 15-18 所示。每個 Cons 變體都會存有一個數值以及一個由 Rc<T> 指向的 List。當我們建立 b 時,不會取走 a 的所有權,我們會克隆(clone) 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};
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 的列表並存入 aRc<List>。然後當我們建立 bc 時,我們會呼叫函式 Rc::clone 來將 aRc<List> 引用作為引數傳入。

當然我們可以呼叫 a.clone() 而非 Rc::clone(&a),但是在此情形中 Rust 的慣例是使用 Rc::cloneRc::clone 的實作不會像大多數型別的 clone 實作會深拷貝(deep copy)所有的資料。 Rc::clone 的呼叫只會增加引用計數,這花費的時間就相對很少。深拷貝通常會花費比較多的時間。透過使用 Rc::clone 來引用計數,我們可以以視覺辨別出這是深拷貝的克隆還是增加引用計數的克隆。當我們需要調查程式碼的效能問題時,我們就只需要考慮深拷貝的克隆,而不必在意 Rc::clone

克隆 Rc<T> 實例會增加其引用計數

讓我們改變範例 15-18 的範例,好讓我們能觀察引用計數在我們建立與釋放 aRc<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;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    println!("建立 a 後的計數 = {}", Rc::strong_count(&a));
    let b = Cons(3, Rc::clone(&a));
    println!("建立 b 後的計數 = {}", Rc::strong_count(&a));
    {
        let c = Cons(4, Rc::clone(&a));
        println!("建立 c 後的計數 = {}", Rc::strong_count(&a));
    }
    println!("c 離開作用域後的計數 = {}", Rc::strong_count(&a));
}

範例 15-19:印出引用計數

在程式中每次引用計數產生改變的地方,我們就印出引用計數,我們可以透過呼叫函式 Rc::strong_count 來取得。此函式叫做 strong_count 而非 count 是因為 Rc<T> 型別還有個 weak_count,我們會在 「避免引用循環:將 Rc<T> 轉換成 Weak<T> 段落看到 weak_count 的使用方式。

此程式碼印出以下結果:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished dev [unoptimized + debuginfo] target(s) in 0.45s
     Running `target/debug/cons-list`
建立 a 後的計數 = 1
建立 b 後的計數 = 2
建立 c 後的計數 = 3
c 離開作用域後的計數 = 2

我們可以看到 aRc<List> 會有個初始引用計數 1,然後我們每次呼叫 clone 時,計數會加 1。當 c 離開作用域時,計數會減 1。我們不必呼叫任何函式來減少引用計數,像呼叫 Rc::clone 時才會增加引用計數那樣。當 Rc<T> 數值離開作用域時,Drop 特徵的實作就會自動減少引用計數。

我們無法從此例觀察到的是當 b 然後是 amain 的結尾離開作用域時,計數會是 0,然後 Rc<List> 在此時就會完全被清除。使用 Rc<T> 能允許單一數值能有數個擁有者,然後計數會確保只要有任何擁有者還存在的狀況下,數值會保持是有效的。

透過不可變引用,Rc<T> 能讓你分享資料給程式中數個部分來只做讀取的動作。如果 Rc<T> 允許你也擁有數個可變引用的話,你可能就違反了第四章提及的借用規則:數個對相同位置的可變借用會導致資料競爭(data races)與不一致。但可變資料還是非常實用的!在下個段落,我們會討論內部可變性模式與 RefCell<T> 型別,此型別能讓你搭配 Rc<T> 使用來處理不可變的限制。