在 Rust 中,可以把所有權想像成「誰負責管理某個資料的生命週期」,也就是說當擁有某個數值的所有權,代表這個變數不論主動或被動,有義務把這個數值清掉。一般情況下,這種明確的單一所有權的機制已經足夠,這是我們可以在編譯時期就確定各個數值關係的情況。
然而,有些情況我們不知道這個數值應該給誰當所有者,想像一下圖資料結構,有多個邊指向同一個結點,這些邊概念上擁有這個節點,為了避免空指標,這個節點必須等到沒有任何一個邊指向它的時候才能被消除。
這聽起來和多個參考有點像,兩者的關鍵差異在於,能不能在編譯就確定誰最後擁有這個數值,如果可以確定,那這個點的生命週期就是跟著那個擁有者。不過如果程式執行後才知道誰是最後一個的話,單純的參考就無法處理這種情況,因為不知道誰是擁有者。
讓我們用之前介紹 Box<T>
的cons list 來說明這個問題,下圖展示了我們想要實作的結構,有的節點同時有複數個父節點:
實作的程式碼:
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<T>
就是 Rust 處理這種情況的解決方案,它是參考計數(reference counting)的簡寫,是內建計數的智慧指標,每多一個變數指向它,計數就會加一;有變數離開作用域,計數就會減一。計數歸零它指向的資料就會真的被釋放,所以只要還有人要用資料就不會被釋放。這樣可以保證不會產生任何無效參考,也可以避免沒有人去釋放。
需要注意 Rc<T>
只適用於單執行緒的情況,多執行緒要用另外一個型別 Arc<T>
,A 指的是原子性(atomic),這是一個原子性參考的計數型別,它可以安全在多執行緒間共享而不會有 race condition 的風險。
至於什麼時候要用哪個看情境而定,因為原子性保證執行緒安全的同時也帶來額外的性能開銷。Rc<T>
和Arc<T>
的用法相同,因此以下我們只專注在 Rc<T>
的情況。
我們直接來看 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
可以看到當 owner2
被 drop
之後 MyStruct
也被釋放了,因此 Dropping MyStruct with name: Rust 跑到 last line in main 之前。
再稍微調整一下來觀察計數的變化,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<T>
打破了這個規則,實際上並沒有。
可以把 Rc<T>
視為唯一的所有權管理者實體,但它不是一般所有權擁有者的角色,並不會獨佔所有權。並且因為多了 Rc<T>
這一層,我們在程式層次的操作沒辦法直接針對 Rc<T>
實體,同時其他變數只是引用它,任何變數都沒辦法直接代表 Rc<T>
本身。
所以從資源的角度來看,Rc<T>
還是它唯一的擁有者,所有對資源的操作都必須通過 Rc<T>
來進行。
Rc<T>
提供了我們一個共享所有權的方式, 讓我們可以處理編譯階段無法確認所有權擁有者的問題。
常見的使用情境是實現某些數據結構,如樹或圖,其中節點可能有多個父節點的情況。
不過相比於普通引用有些微的性能開銷,因為它需要維護引用計數,所以我們只在編譯階段無法確定所有權的情況才使用它。
Rc<T>
的機制保證即使共享所有權,只要擁有者還存在的情況下,數值就還是有效的。
然而,有一個限制是 Rc<T>
只提供不可變參考,因為它如果提供可變參考的話,可能就會造成資料競爭,而且 Rc<T>
本身沒有機制去分辨指向原始資料的是可變還是不可變參考。
需要共享所有權又可變資料的情況下,需要和 RefCell<T>
搭配使用, RefCell<T>
提供了內部可變性的機制,允許我們在不可變引用中修改數據。
同時,因為使用引用計數的機制,不可避免因為循環引用問題,例如兩個結構互相指向對方,導致記憶體洩漏(memory leak),面對這種情況可以通過引入 Weak<T>
來解決,Weak<T>
是一個不增加強引用計數的弱引用。
接下來,我們會更深入介紹如何利用 RefCell<T>
和 Weak<T>
來解決內部可變性和循環引用問題。