iT邦幫忙

2024 iThome 鐵人賽

DAY 25
1
Software Development

螃蟹幼幼班:Rust 入門指南系列 第 25

Day25 - 智慧指標 :Rc<T>

  • 分享至 

  • xImage
  •  

所有權的局限性

在 Rust 中,可以把所有權想像成「誰負責管理某個資料的生命週期」,也就是說當擁有某個數值的所有權,代表這個變數不論主動或被動,有義務把這個數值清掉。一般情況下,這種明確的單一所有權的機制已經足夠,這是我們可以在編譯時期就確定各個數值關係的情況。

然而,有些情況我們不知道這個數值應該給誰當所有者,想像一下圖資料結構,有多個邊指向同一個結點,這些邊概念上擁有這個節點,為了避免空指標,這個節點必須等到沒有任何一個邊指向它的時候才能被消除。

這聽起來和多個參考有點像,兩者的關鍵差異在於,能不能在編譯就確定誰最後擁有這個數值,如果可以確定,那這個點的生命週期就是跟著那個擁有者。不過如果程式執行後才知道誰是最後一個的話,單純的參考就無法處理這種情況,因為不知道誰是擁有者。

讓我們用之前介紹 Box<T> 的cons list 來說明這個問題,下圖展示了我們想要實作的結構,有的節點同時有複數個父節點:
https://ithelp.ithome.com.tw/upload/images/20241009/20168952dSixrnx02F.png

實作的程式碼:

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)))); // a 有初始 cons list 的所有權
    let b = Cons(3, Box::new(a)); // a 的所有權轉移給 b 了
    let c = Cons(4, Box::new(a)); // 這裡會出錯,已經轉移的所有權不能再給 c
}

編譯器會報熟悉的所有權錯誤:

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

Rc 簡介

Rc<T> 就是 Rust 處理這種情況的解決方案,它是參考計數(reference counting)的簡寫,是內建計數的智慧指標,每多一個變數指向它,計數就會加一;有變數離開作用域,計數就會減一。計數歸零它指向的資料就會真的被釋放,所以只要還有人要用資料就不會被釋放。這樣可以保證不會產生任何無效參考,也可以避免沒有人去釋放。

需要注意 Rc<T> 只適用於單執行緒的情況,多執行緒要用另外一個型別 Arc<T> ,A 指的是原子性(atomic),這是一個原子性參考的計數型別,它可以安全在多執行緒間共享而不會有 race condition 的風險。

至於什麼時候要用哪個看情境而定,因為原子性保證執行緒安全的同時也帶來額外的性能開銷。
Rc<T>Arc<T>的用法相同,因此以下我們只專注在 Rc<T> 的情況。

觀察 Rc 如何共享所有權

我們直接來看 Rc<T> 的用法,我們把在所有權介紹時實作了 Drop 特徵的型別拿來用,方便觀察。

use std::rc::Rc; // Rc 不是預載入的,需要手動載入

struct MyStruct {
    name: String,
}

impl Drop for MyStruct {
    fn drop(&mut self) {
        println!("Dropping MyStruct with name: {}", self.name);
    }
}

impl MyStruct {
    fn new(name: String) -> Self {
        Self { name }
    }
}

fn main() {
    let owner1 = Rc::new(MyStruct::new(String::from("Rust")));  // 創建新的 Rc<T>
    let owner2 = Rc::clone(&owner1);  // 增加引用計數
    let owner3 = Rc::clone(&owner1);  // 再次增加引用計數

    println!("dropping owner1");
    drop(owner1);  // 減少引用計數

    println!("dropping owner3");
    drop(owner3);  // 再次減少引用計數

    println!("last line in main");
}  // owner2 離開作用域,引用計數歸零,MyStruct 被釋放

這樣的輸出如下:

dropping owner1
dropping owner3
last line in main
Dropping MyStruct with name: Rust

語法上, Rc::new 會初始化一開始的資料,把數值放到 Heap,同時計數從 1 開始,也就是 owner1 ,接著其他變數如果也想要使用這個數值可以直接用 Rc::clone 複製一份 owner1 的參考。這個動作並不會讓 Heap 上的資料複製一份,只是計數會再加一而已。

接著我們可以觀察到一個細節:不論變數是透過 new 或是 clone獲得所有權,他們的地位是相同的,可以看到即使 owner1 先被 drop 也不影響其他變數的使用,這就是共享所有權的概念。

上面的例子原有資料的釋放是在 owner2 離開作用域,計數歸零之後,會先看到 last line in main 才看到 Dropping MyStruct with name: Rust

我們再調整一下,把 drop(owner2) 加進去。

fn main() {
    let owner1 = Rc::new(MyStruct::new(String::from("Rust")));
    let owner2 = Rc::clone(&owner1);
    let owner3 = Rc::clone(&owner1);

    println!("dropping owner1");
    drop(owner1);

    println!("dropping owner3");
    drop(owner3);

    println!("dropping owner2");
    drop(owner2);

    println!("last line in main");
}

來看看結果:

dropping owner1
dropping owner3
dropping owner2
Dropping MyStruct with name: Rust
last line in main

可以看到當 owner2drop 之後 MyStruct 也被釋放了,因此 Dropping MyStruct with name: Rust 跑到 last line in main 之前。

觀察 Rc 的計數

再稍微調整一下來觀察計數的變化,Rc<T> 提供了一個關聯方法 strong_count 來確認目前的強引用計數。強引用計數決定了資源的生命週期,當強引用計數降到零時,資源會被釋放,本篇目前提到的計數都是指強引用計數。

fn main() {
    let owner1 = Rc::new(MyStruct::new(String::from("Rust")));
    println!("count after new owner1: {}", Rc::strong_count(&owner1));
    let owner2 = Rc::clone(&owner1);
    println!("count after clone owner2: {}", Rc::strong_count(&owner1));

    drop(owner2);
    println!("count after drop owner2: {}", Rc::strong_count(&owner1));

    {
        let owner3 = Rc::clone(&owner1);
        println!("count after clone owner3: {}", Rc::strong_count(&owner1));
    }
    println!("count after owner3 left: {}", Rc::strong_count(&owner1));

    drop(owner1);

    println!("last line in main");
}

輸出:

count after new owner1: 1
count after clone owner2: 2
count after drop owner2: 1
count after clone owner3: 2
count after owner3 released: 1
Dropping MyStruct with name: Rust
last line in main

這樣就可以觀察到每次 clone 計數都會加一,變數被 drop 或離開作用域就會減一。

Rc 是否違反所有權機制?

之前介紹所有權有提到其中一個規則:同時間只能有一個擁有者
乍看之下 Rc<T> 打破了這個規則,實際上並沒有。
可以把 Rc<T> 視為唯一的所有權管理者實體,但它不是一般所有權擁有者的角色,並不會獨佔所有權。並且因為多了 Rc<T> 這一層,我們在程式層次的操作沒辦法直接針對 Rc<T> 實體,同時其他變數只是引用它,任何變數都沒辦法直接代表 Rc<T> 本身。
所以從資源的角度來看,Rc<T>還是它唯一的擁有者,所有對資源的操作都必須通過 Rc<T> 來進行。
https://ithelp.ithome.com.tw/upload/images/20241009/20168952jIbdd1i63R.png

結語

Rc<T> 提供了我們一個共享所有權的方式, 讓我們可以處理編譯階段無法確認所有權擁有者的問題。
常見的使用情境是實現某些數據結構,如樹或圖,其中節點可能有多個父節點的情況。

不過相比於普通引用有些微的性能開銷,因為它需要維護引用計數,所以我們只在編譯階段無法確定所有權的情況才使用它。

Rc<T> 的機制保證即使共享所有權,只要擁有者還存在的情況下,數值就還是有效的。
然而,有一個限制是 Rc<T> 只提供不可變參考,因為它如果提供可變參考的話,可能就會造成資料競爭,而且 Rc<T> 本身沒有機制去分辨指向原始資料的是可變還是不可變參考。
需要共享所有權又可變資料的情況下,需要和 RefCell<T> 搭配使用, RefCell<T> 提供了內部可變性的機制,允許我們在不可變引用中修改數據。

同時,因為使用引用計數的機制,不可避免因為循環引用問題,例如兩個結構互相指向對方,導致記憶體洩漏(memory leak),面對這種情況可以通過引入 Weak<T> 來解決,Weak<T> 是一個不增加強引用計數的弱引用。

接下來,我們會更深入介紹如何利用 RefCell<T>Weak<T> 來解決內部可變性和循環引用問題。


上一篇
Day24 - Deref 與 Drop
下一篇
Day26 - 智慧指標:RefCell<T>
系列文
螃蟹幼幼班:Rust 入門指南30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言