天天看點

【Rust精彩blog】Rust 中幾個智能指針的異同與使用場景

想必寫過 C 的程式員對指針都會有一種複雜的情感,與記憶體相處的過程中可以說是成也指針,敗也指針。一不小心又越界通路了,一不小心又讀到了記憶體裡的髒資料,一不小心多線程讀寫資料又不一緻了……我知道講到這肯定會有人覺得“出這種問題還不是因為你菜”雲雲,但是有一句話說得好:“自由的代價就是需要時刻保持警惕”。

Rust 幾乎把“記憶體安全”作為了語言設計哲學之首,從多個層面(編譯,運作時檢查等)極力避免了許多記憶體安全問題。是以比起讓程式員自己處理指針(在 Rust 中可以稱之為 Raw Pointer),Rust 提供了幾種關于指針的封裝類型,稱之為智能指針(Smart Pointer),且對于每種智能指針,Rust 都對其做了很多行為上的限制,以保證記憶體安全。

  • Box<T>
  • Rc<T> 與 Arc<T>
  • Cell<T>
  • RefCell<T>

我在剛開始學習智能指針這個概念的時候有非常多的困惑,Rust 官方教程本身對此的叙述并不詳盡,加之 Rust 在中文網際網路上内容匮乏,我花了很久才搞清楚這幾個智能指針封裝的異同,在這裡總結一下,以供參考,如有錯誤,煩請大家指正。

以下内容假定本文的讀者了解 Rust 的基礎文法,所有權以及借用的基本概念:相關連結。

Box

​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 與 Arc

​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

​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

因為 ​

​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 就來到了你身邊!

組合使用

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());
}      

參考

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