【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 的設計哲學,但由於僅僅對實現了 CopyTCell<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> 則保證了內部數據的可變性。

參考

  1. Wrapper Types in Rust: Choosing Your Guarantees
  2. 內部可變性模式
  3. 如何理解Rust中的可變與不可變?
  4. Rust 常見問題解答