【Rust精彩blog】Rust 中幾個智慧指針的異同與使用場景
- 2020 年 2 月 20 日
- 筆記
原文地址:Rust 中幾個智慧指針的異同與使用場景
想必寫過 C 的程式設計師對指針都會有一種複雜的情感,與記憶體相處的過程中可以說是成也指針,敗也指針。一不小心又越界訪問了,一不小心又讀到了記憶體里的臟數據,一不小心多執行緒讀寫數據又不一致了……我知道講到這肯定會有人覺得「出這種問題還不是因為你菜」云云,但是有一句話說得好:「自由的代價就是需要時刻保持警惕」。
Rust 幾乎把「記憶體安全」作為了語言設計哲學之首,從多個層面(編譯,運行時檢查等)極力避免了許多記憶體安全問題。所以比起讓程式設計師自己處理指針(在 Rust 中可以稱之為 Raw Pointer),Rust 提供了幾種關於指針的封裝類型,稱之為智慧指針(Smart Pointer),且對於每種智慧指針,Rust 都對其做了很多行為上的限制,以保證記憶體安全。
- Box<T>
- Rc<T> 與 Arc<T>
- Cell<T>
- RefCell<T>
我在剛開始學習智慧指針這個概念的時候有非常多的困惑,Rust 官方教程本身對此的敘述並不詳盡,加之 Rust 在中文互聯網上內容匱乏,我花了很久才搞清楚這幾個智慧指針封裝的異同,在這裡總結一下,以供參考,如有錯誤,煩請大家指正。
以下內容假定本文的讀者了解 Rust 的基礎語法,所有權以及借用的基本概念:相關鏈接。
Box<T>
Box<T>
與大多數情況下我們所熟知的指針概念基本一致,它是一段指向堆中數據的指針。我們可以通過這樣的操作訪問和修改其指向的數據:
let a = Box::new(1); // Immutable println!("{}", a); // Output: 1 let mut b = Box::new(1); // Mutable *b += 1; println!("{}", b); // Output: 2
然而 Box<T>
的主要特性是單一所有權,即同時只能有一個人擁有對其指向數據的所有權,並且同時只能存在一個可變引用或多個不可變引用,這一點與 Rust 中其他屬於堆上的數據行為一致。
let a = Box::new(1); // Owned by a let b = a; // Now owned by b println!("{}", a); // Error: value borrowed here after move let c = &mut a; let d = &a; println!("{}, {}", c, d); // Error: cannot borrow `a` as immutable because it is also borrowed as mutable
Rc<T> 與 Arc<T>
Rc<T>
主要用於同一堆上所分配的數據區域需要有多個只讀訪問的情況,比起使用 Box<T>
然後創建多個不可變引用的方法更優雅也更直觀一些,以及比起單一所有權,Rc<T>
支援多所有權。
Rc
為 Reference Counter 的縮寫,即為引用計數,Rust 的 Runtime 會實時記錄一個 Rc<T>
當前被引用的次數,並在引用計數歸零時對數據進行釋放(類似 Python 的 GC 機制)。因為需要維護一個記錄 Rc<T>
類型被引用的次數,所以這個實現需要 Runtime Cost。
use std::rc::Rc; fn main() { let a = Rc::new(1); println!("count after creating a = {}", Rc::strong_count(&a)); let b = Rc::clone(&a); println!("count after creating b = {}", Rc::strong_count(&a)); { let c = Rc::clone(&a); println!("count after creating c = {}", Rc::strong_count(&a)); } println!("count after c goes out of scope = {}", Rc::strong_count(&a)); }
輸出依次會是 1 2 3 2。
需要注意的主要有兩點。首先, Rc<T>
是完全不可變的,可以將其理解為對同一記憶體上的數據同時存在的多個只讀指針。其次,Rc<T>
是只適用於單執行緒內的,儘管從概念上講不同執行緒間的只讀指針是完全安全的,但由於 Rc<T>
沒有實現在多個執行緒間保證計數一致性,所以如果你嘗試在多個執行緒內使用它,會得到這樣的錯誤:
use std::thread; use std::rc::Rc; fn main() { let a = Rc::new(1); thread::spawn(|| { let b = Rc::clone(&a); // Error: `std::rc::Rc<i32>` cannot be shared between threads safely }).join(); }
如果想在不同執行緒中使用 Rc<T>
的特性該怎麼辦呢?答案是 Arc<T>
,即 Atomic reference counter。此時引用計數就可以在不同執行緒中安全的被使用了。
use std::thread; use std::sync::Arc; fn main() { let a = Arc::new(1); thread::spawn(move || { let b = Arc::clone(&a); println!("{}", b); // Output: 1 }).join(); }
Cell<T>
Cell<T>
其實和 Box<T>
很像,但後者同時不允許存在多個對其的可變引用,如果我們真的很想做這樣的操作,在需要的時候隨時改變其內部的數據,而不去考慮 Rust 中的不可變引用約束,就可以使用 Cell<T>
。Cell<T>
允許多個共享引用對其內部值進行更改,實現了「內部可變性」。
fn main() { let x = Cell::new(1); let y = &x; let z = &x; x.set(2); y.set(3); z.set(4); println!("{}", x.get()); // Output: 4 }
這段看起來非常不 Rust 的 Rust 程式碼其實是可以通過編譯並運行成功的,Cell<T>
的存在看起來似乎打破了 Rust 的設計哲學,但由於僅僅對實現了 Copy
的 T
,Cell<T>
才能進行 .get()
和 .set()
操作。而實現了 Copy
的類型在 Rust 中幾乎等同於會分配在棧上的數據(可以直接按比特進行連續 n 個長度的複製),所以對其隨意進行改寫是十分安全的,不會存在堆數據泄露的風險(比如我們不能直接複製一段棧上的指針,因為指針指向的內容可能早已物是人非)。也是得益於 Cell<T>
實現了外部不可變時的內部可變形,可以允許以下行為的發生:
use std::cell::Cell; fn main() { let a = Cell::new(1); { let b = &a; b.set(2); } println!("{:?}", a); // Output: 2 }
如果換做 Box<T>
,則在中間出現的 Scope 就會使 a
的所有權被移交,且在執行完畢之後被 Drop
。最後還有一點,Cell<T>
只能在單執行緒的情況下使用。
RefCell<T>
因為 Cell<T>
對 T
的限制:只能作用於實現了 Copy
的類型,所以應用場景依舊有限(安全的代價)。但是我如果就是想讓任何一個 T
都可以塞進去該咋整呢?RefCell<T>
去掉了對 T
的限制,但是別忘了要牢記初心,不忘繼續踐行 Rust 的記憶體安全的使命,既然不能在讀寫數據時簡單的 Copy
出來進去了,該咋保證記憶體安全呢?相對於標準情況的靜態借用,RefCell<T>
實現了運行時借用,這個借用是臨時的,而且 Rust 的 Runtime 也會隨時緊盯 RefCell<T>
的借用行為:同時只能有一個可變借用存在,否則直接 Painc。也就是說 RefCell<T>
不會像常規時一樣在編譯階段檢查引用借用的安全性,而是在程式運行時動態的檢查,從而提供在不安全的行為下出現一定的安全場景的可行性。
use std::cell::RefCell; use std::thread; fn main() { thread::spawn(move || { let c = RefCell::new(5); let m = c.borrow(); let b = c.borrow_mut(); }).join(); // Error: thread '<unnamed>' panicked at 'already borrowed: BorrowMutError' }
如上程式所示,如同一個讀寫鎖應該存在的情景一樣,直接進行讀後寫是不安全的,所以 borrow
過後 borrow_mut
會導致程式 Panic。同樣,ReCell<T>
也只能在單執行緒中使用。
如果你要實現的程式碼很難滿足 Rust 的編譯檢查,不妨考慮使用 Cell<T>
或 RefCell<T>
,它們在最大程度上以安全的方式給了你些許自由,但別忘了時刻警醒自己自由的代價是什麼,也許獲得喘息的下一秒,一個可怕的 Panic 就來到了你身邊!
組合使用
如果遇到要實現一個同時存在多個不同所有者,但每個所有者又可以隨時修改其內容,且這個內容類型 T
沒有實現 Copy
的情況該怎麼辦?使用 Rc<T>
可以滿足第一個要求,但是由於其是不可變的,要修改內容並不可能;使用 Cell<T>
直接死在了 T
沒有實現 Copy
上;使用 RefCell<T>
由於無法滿足多個不同所有者的存在,也無法實施。可以看到各個智慧指針可以解決其中一個問題,既然如此,為何我們不把 Rc<T>
與 RefCell<T>
組合起來使用呢?
use std::rc::Rc; use std::cell::RefCell; fn main() { let shared_vec: Rc<RefCell<_>> = Rc::new(RefCell::new(Vec::new())); // Output: [] println!("{:?}", shared_vec.borrow()); { let b = Rc::clone(&shared_vec); b.borrow_mut().push(1); b.borrow_mut().push(2); } shared_vec.borrow_mut().push(3); // Output: [1, 2, 3] println!("{:?}", shared_vec.borrow()); }
通過 Rc<T>
保證了多所有權,而通過 RefCell<T>
則保證了內部數據的可變性。
參考
- Wrapper Types in Rust: Choosing Your Guarantees
- 內部可變性模式
- 如何理解Rust中的可變與不可變?
- Rust 常見問題解答