Rust的Cell、RefCell和OnceCell:靈活且安全的內部可變性

Cinea發表於2024-03-13

這一系列文章的創作目的主要是幫助我自己深入學習Rust,同時也為已經具備一定Rust程式設計經驗,但還沒有深入研究過語言和標準庫的朋友提供參考。對於正在入門Rust的同學,我更建議你們看《Rust聖經》或者《The Book》,而不是這種晦澀難懂的文章。

終於拿到了某量化公司的offer,繼續系列文章的更新。今天沒有複雜的開場白,在開始前我們先回顧一下Rust的記憶體安全機制對引用的限制:

資料爭用和引用限制

首先,什麼是資料爭用?根據《The Book》4.2章中的定義,資料爭用是指下面的一些情況:

  • 兩個或多個指標同時訪問同一資料。
  • 至少有一個指標用於寫入資料。
  • 沒有同步訪問資料的機制。

Rust用於解決資料爭用的對策很粗暴:拒絕編譯含有資料爭用的程式碼。具體來說,Rust給出了這樣的引用限制:

  • 在任意時間,都只允許存在一個物件的多個不可變引用或者一個可變引用。
  • 引用必須是有效的。

Rust的限制很好地在編譯期就消滅了資料爭用;但是,這樣的限制有些時候反而可能會成為我們的絆腳石。

侷限

我們都知道,人(在大多數時候)是比編譯器更靈活、更聰明的。例如有些時候,我們寫了一段不會發生資料爭用的程式碼,但是編譯器卻死板地以“不滿足引用限制”為由拒絕編譯。看看這個例子:

/**
LeetCode 19. 刪除連結串列的倒數第 N 個結點
給你一個連結串列,刪除連結串列的倒數第 n 個結點,並且返回連結串列的頭結點。
*/

演算法 刪除連結串列的倒數第N個節點(head, n)
輸入:連結串列的頭節點 head,整數 n
輸出:刪除倒數第n個節點後的連結串列的頭節點

1. 建立一個哨兵節點 dummy,其next指向head
2. 初始化兩個指標 first 和 second 都指向哨兵節點 dummy
3. 將 first 指標向前移動 n+1 次(因為dummy節點的存在,實際上是n+1而不是n)
4. 同時移動 first 和 second 指標,直到 first 指向連結串列末尾的null
5. 此時,second 指標的下一個節點就是要刪除的節點,進行刪除操作:second.next = second.next.next
6. 返回 dummy.next 作為新連結串列的頭節點(因為可能刪除的是頭節點本身)

這道經典的連結串列題可以用雙指標來輕鬆、高效地解決;那麼如果用Rust來實現這個雙指標演算法呢?

impl Solution {
    pub fn remove_nth_from_end(head: Option<Box<ListNode>>, n: i32) -> Option<Box<ListNode>> {
        let mut head = Some(Box::new(ListNode {
            val: 0,
            next: head
        }));  // 哨兵

        let mut slow = &mut head;
        let mut fast = &head;

        // fast先走n步
        for _ in 0..n {
            fast = &fast.as_ref().unwrap().next;
        }

        while fast.as_ref().unwrap().next.is_some() {
            fast = &fast.as_ref().unwrap().next;
            slow = &mut slow.as_mut().unwrap().next;
        }
    
    	// 刪除節點
        slow.as_mut().unwrap().next = slow.as_mut().unwrap().next.as_mut().unwrap().next.take();

        head.unwrap().next
    }
}

看起來不錯?但是當我們按下了執行按鈕時,我們就會發現Rust拒絕了我們的程式碼:

Line 30, Char 24: cannot borrow `head` as immutable because it is also borrowed as mutable (solution.rs)
   |
29 |         let mut slow = &mut head;
   |                        --------- mutable borrow occurs here
30 |         let mut fast = &head;
   |                        ^^^^^ immutable borrow occurs here

我們再次審視我們的Rust程式碼。誠然,它同時出現了&mut T&T,但我們的程式碼確實沒有資料爭用。我們可以注意到,從我們第一次修改slow的時候起,我們就再也沒有使用過fast。但是Rust是不會在乎的。它只知道我們沒有遵守引用限制。

另一種侷限

除了上面提到的“雙指標”問題之外,當專案體量過大時,一些過大的結構體也會導致引用限制衝突的問題。最好的例子就是《Rust聖經》中給出的例子:Rust編譯器中的ctxt結構體。這種結構體的欄位的使用分散在了整個專案中,如果某次使用時需要對欄位作修改,那麼整個程式中其他的&ctxt都必須避開這個&mut ctxt,這對Rust編譯器這種體量的專案來說顯然是不現實的。

另一個例子是配置檔案的管理。在專案中我們也許會使用一個HashMap來儲存配置(例如HTTP的監聽埠、監聽地址,和資料庫的地址、使用者名稱、密碼等等),並直接把這個map的引用傳遞給負責各種功能的類。例如,假設我有一個負責啟動HTTP伺服器的類HttpServer,為了讀取配置方便,它將配置表的引用作為其中一項內部欄位;另一個負責封裝資料庫連線的類Database,也在內部儲存了配置表的引用以方便讀取。

現在,假設Database類在初始化資料庫連線時發現配置項不完整,需要往配置項裡填充預設值。但是問題出現了:Database類持有的僅僅是&HashMap,它不能直接對配置表做修改。它很顯然也不能持有&mut HashMap,因為這樣HttpServer類就不能持有配置表的引用了。比較容易想到的解決方法是我們不再讓DatabaseHttpServer持有同一張配置表的引用,而是讓它們各自把需要用到的欄位複製到一個獨立的的HashMap中。但是,這種解決方法並不算特別完美(畢竟額外多了一次複製),配置的互相剝離也使得在執行時更改配置變得複雜很多。而如果想要繼續持有引用的話,我們就需要將填充預設值的步驟放在建立DatabaseHttpServer之前,這種方法需要我們比較大地更改程式碼架構。想一想,如果有一種辦法可以迴避引用限制,是不是會讓一切都方便很多?

這個例子在後文會用程式碼寫出來,如果看不明白的話可以去後面看看

解決:內部可變性

Rust在設計之初就已經考慮到了這些問題;對於不可避免地需要“打破”引用限制的情況,Rust提出了一個概念:內部可變性。具體來說,和大多數型別不同,具有內部可變性的型別T可以直接用&T來變化。透過使用這些型別,使用者可以無視Rust的引用限制——反正我都是用的&T,怎麼限制都無所謂。

有的朋友可能會疑惑:既然開了這麼大的口子,那麼Rust還安全嗎?資料爭用豈不是說來就來?其實,和很多人想的不同,標準庫中有內部可變性的型別都沒有徹底放下對記憶體安全的追求————正相反,它們仍然會檢查使用者的操作會不會導致資料爭用,並在使用者的操作違反引用限制時將程式panic。後面會有具體的例子。

RefCell:動態借用內部的值

最經典也最常用的“內部可變性”型別是RefCell,它支援對內部的值進行“動態借用”,也就是對內部值進行臨時、獨佔、可變地訪問。

用法

RefCell的用法很簡單:它使用new來構造自己,使用borrow來獲取內部值的不可變引用,使用borrow_mut來獲取內部值的可變引用。

use std::cell::*;

fn main() {
    let a = RefCell::new(1);
    println!("{}", a.borrow());     // 1
    
    *a.borrow_mut() = 2;
    println!("{}", a.borrow());     // 2
}

此外,值得注意的是,RefCell還提供了一個更高效的API:get_mut,它需要RefCell&mut引用,作用和borrow_mut相同。由於它所需的&mut引用保證了程式中沒有其他該物件的引用存在,因此它不需要進行執行時檢查,效能也比borrow_mut更高。

Cell:來自移動(Move)的內部可變性

RefCell的兄弟Cell也是一個具有“內部可變性”的型別,但是它的內部可變性是透過在記憶體中移動值來實現的。Cell的使用也很簡單:它使用new來構造自己,使用get來獲得內部的值,使用set來改變內部的值。

use std::cell::*;

fn main() {
    let a = Cell::new(1);

    println!("{}", a.get());     // 1
    a.set(2);
    println!("{}", a.get());     // 2
}

RefCell不同的是,Cell不提供內部值的引用,呼叫getset時返回和提供的都是值本身。也正因此,Cell通常用於一些簡單的型別,複製和移動值不會消耗太多的資源。並且,只有內部值實現了Copy特徵後,才能使用Cellget方法(未實現Copy特徵的內部值可以使用Cellreplace等方法來獲得內部值)。

Cell不佔用額外的記憶體空間,效能也比RefCell更優(因為不需要檢查引用限制),但是使用上比RefCell受限,在操作複雜型別時不如RefCell方便。建議讀者根據使用場景來靈活判斷使用RefCell還是Cell

OnceCell:一次性使用的RefCell

這裡討論的是Rust在1.70.0中引入標準庫的型別,而不是包once_cell中的同名型別。

OnceCellCellRefCell的混合體,它既可以在不移動和不復制的情況下獲得內部值的引用(與Cell不同),又不需要在執行時進行引用限制檢查(與RefCell不同)。但是,它的便利也有代價:一旦其中的值被設定了,就不能再被改變。

// 官方文件的示例
use std::cell::OnceCell;

let cell = OnceCell::new();
assert!(cell.get().is_none());

let value: &String = cell.get_or_init(|| {
    "Hello, World!".to_string()
});
assert_eq!(value, "Hello, World!");
assert!(cell.get().is_some());

OnceCell的用途相比CellRefCell都更加侷限,它的內部可變性也僅僅體現在那一次性的set上。相對而言它的執行緒安全版本OnceLock就更常用也更有用,因為我們可以用它來取代lazy_static,儲存程式的全域性/靜態變數。

內部可變性還能保證記憶體安全嗎?

雖然直接使用&T來改變內部值的做法看起來很暴力,但標準庫中提供的這些內部可變性型別都是記憶體安全的。

RefCell中,當我們呼叫borrowborrow_mut時,它會在執行時檢查我們是否違反了Rust的引用限制(也就是僅允許同時存在一個可變或多個不可變)。例如,下面的幾段程式碼都會panic:

use std::cell::*;

fn main() {
    let a = RefCell::new(1);

    let a_ref = a.borrow_mut();
    println!("{}", a.borrow());
    println!("{}", a_ref);
}

// thread 'main' panicked at src/main.rs:7:22:
// already mutably borrowed: BorrowError
use std::cell::*;

fn main() {
    let a = RefCell::new(1);

    let a_ref = a.borrow();
    println!("{}", a.borrow_mut());
    println!("{}", a_ref);
}

// thread 'main' panicked at src/main.rs:7:22:
// already borrowed: BorrowMutError

毫無疑問,這種在執行時panic的做法會降低程式的穩定性,因此這就要求使用RefCell的程式設計師小心謹慎,在寫程式碼時主動檢查自己的用法是否滿足引用限制,不要把RefCell當作萬能的銀彈來用,更不要把RefCell當做逃避編譯錯誤的手段。

相比RefCell還能拿到內部物件的可變引用,連可變引用都見不到的CellOnceCell就更安全了,它們主動放棄了很多靈活性,換取了無需執行時檢查的效能。

執行緒安全

std::cell內的三個型別並不是執行緒安全的。不過,RefCell有執行緒安全版本的對應:RwLockOnceCell也有執行緒安全的版本OnceLock。相信RwLock要比RefCell常用和常見很多,因為多執行緒間的資料同步是一個比引用限制更容易遇到的問題,這也導致了許多人在學習Rust之初就已經在接觸MutexRwLock這樣的鎖型別了。

內部可變性的應用

內部可變性的應用就非常廣泛了!除了之前提到的兩個情況之外,我們也會在這樣的場景下需要使用內部可變性:

在實現Clone等要求物件不可變的特徵時改變物件

大家每天都在用的RcArc.clone()時會增加引用計數,但是大家想過RcArc為什麼可以在Clone這個接受&self的特徵裡改變引用計數的值嗎?看到這裡相信讀者都能猜到原因了:它使用Cell來儲存引用計數的值。下面是簡化版的Rc實現,來自標準庫的文件:

use std::cell::Cell;
use std::ptr::NonNull;
use std::process::abort;
use std::marker::PhantomData;

struct Rc<T: ?Sized> {
    ptr: NonNull<RcBox<T>>,
    phantom: PhantomData<RcBox<T>>,
}

struct RcBox<T: ?Sized> {
    strong: Cell<usize>,
    refcount: Cell<usize>,
    value: T,
}

impl<T: ?Sized> Clone for Rc<T> {
    fn clone(&self) -> Rc<T> {
        self.inc_strong();
        Rc {
            ptr: self.ptr,
            phantom: PhantomData,
        }
    }
}

trait RcBoxPtr<T: ?Sized> {

    fn inner(&self) -> &RcBox<T>;

    fn strong(&self) -> usize {
        self.inner().strong.get()
    }

    fn inc_strong(&self) {
        self.inner()
            .strong
            .set(self.strong()			// 重點在這一行
                     .checked_add(1)
                     .unwrap_or_else(|| abort() ));
    }
}

impl<T: ?Sized> RcBoxPtr<T> for Rc<T> {
   fn inner(&self) -> &RcBox<T> {
       unsafe {
           self.ptr.as_ref()
       }
   }
}

在不可變的“內部”引入內部可變性

繼續以RcArc為例,這樣的共享指標型別提供了在不同的位置克隆和共享的容器;為了避免這種對同一物件的共享訪問導致資料競爭,我們只能使用&而不是&mut來借用RcArc的內部變數。如果沒有支援內部可變性的容器的話,我們就幾乎不可能改變RcArc中的值了。

出於上面的原因,在RcArc中使用內部可變性型別的用法————例如Rc<RefCell<T>>Arc<Mutex<T>>)在Rust世界中就特別特別常見。例如,當我們需要維護一個在全域性範圍使用的配置列表時,比起直接傳遞HashMap,傳遞一個Rc<RefCell<HashMap>>顯然會幫助我們降低很多心智負擔和開發難度————畢竟它不用處理所有權(作為引數傳給其他函式的時候直接clone就完事),更不用處理可變引用(需要改變內部值的時候borrow_mut一下就好了,不用特意避開其他&引用)。

例項:使用Rc<RefCell>管理配置檔案

在下面的文章中,我們將會以之前作為例子提到的“配置檔案管理”來示範Rc<RefCell<T>>的強大作用。回顧一下“配置檔案管理”案例的場景:

在專案中我們也許會使用一個HashMap來儲存配置(例如HTTP的監聽埠、監聽地址,和資料庫的地址、使用者名稱、密碼等等),並直接把這個map的引用傳遞給負責各種功能的類。例如,假設我有一個負責啟動HTTP伺服器的類HttpServer,為了讀取配置方便,它將配置表的引用作為其中一項內部欄位;另一個負責封裝資料庫連線的類Database,也在內部儲存了配置表的引用以方便讀取。

不使用Rc<RefCell>

首先,如果不使用Rc<RefCell<T>>,而是使用傳統的&引用的話,程式碼像什麼樣子呢?

use std::collections::*;

/// 從配置檔案讀取配置
fn load_configs(config: &mut HashMap<String, String>) {
    // 假設我們從檔案裡面讀取了配置,這裡為了模擬演示需要就不實際讀取了
    config.insert("host".to_owned(), "0.0.0.0".to_owned());
    config.insert("port".to_owned(), "8080".to_owned());
    config.insert("db_url".to_owned(), "mysql://localhost:3306".to_owned());
    config.insert("db_username".to_owned(), "root".to_owned());
}

/// 用於處理Http伺服器的類
struct HttpServer<'a> {
    config: &'a HashMap<String, String>,
}

impl<'a> HttpServer<'a> {
    /// 在配置表中為缺失的配置項填充預設值
    fn fill_defaults(config: &mut HashMap<String, String>) {
        // 這裡為了舉例方便,只放一條
        if !config.contains_key("port") {
            config.insert("port".to_owned(), "8080".to_owned());
        }
    }

    fn new(config: &'a HashMap<String, String>) -> Self {
        Self { config }
    }

    fn listen(&self) {
        println!("Listening on {}:{}", self.config.get("host").unwrap(), self.config.get("port").unwrap())
    }
}

/// 用於處理資料庫連線的類
struct Database<'a> {
    config: &'a HashMap<String, String>,
}

impl<'a> Database<'a> {
    /// 在配置表中為缺失的配置項填充預設值
    fn fill_defaults(config: &mut HashMap<String, String>) {
        // 這裡為了舉例方便,只放一條
        if !config.contains_key("db_password") {
            config.insert("db_password".to_owned(), "admin".to_owned());
        }
    }

    fn new(config: &'a HashMap<String, String>) -> Self {
        Self { config }
    }

    fn connect(&self) {
        println!("Connected to Database: {}, user:{}, password:{}", self.config.get("db_url").unwrap(), self.config.get("db_username").unwrap(), self.config.get("db_password").unwrap())
    }
}


fn main() {
    let mut config = HashMap::new();

    // 讀取配置
    load_configs(&mut config);

    // 填充預設值
    HttpServer::fill_defaults(&mut config);
    Database::fill_defaults(&mut config);

    let db = Database::new(&config);
    let http = HttpServer::new(&config);

    db.connect();
    http.listen();
}

最終輸出是這樣的:

Connected to Database: mysql://localhost:3306, user:root, password:admin
Listening on 0.0.0.0:8080

現在讓我們分析一下不使用Rc<RefCell<T>>帶來的不便之處:

首先,最明顯的就是因為我們使用了引用,因此我們必須顯式地管理引用的宣告週期。例如程式碼的這幾行:

struct Database<'a> {
    config: &'a HashMap<String, String>,
}

impl<'a> Database<'a> {
    fn new(config: &'a HashMap<String, String>) -> Self {
        // ...
    }
}

我們為了保證配置表的生命週期比Database長,需要作很多額外的宣告。當然,針對這個問題,我們可以做一層改良:

struct Database {
    config: Rc<HashMap<String, String>>,	// 換成Rc
}

impl Database {
    fn new(config: Rc<HashMap<String, String>>) -> Self {
        Self { config: config.clone() }
    }
}

但是這次改良並不能解決另一個問題:我們在new之前就已經在呼叫DatabaseHttpServer的靜態方法了。其實常理上講,我們應該在new中呼叫這些靜態方法的。

那麼改成這樣可以嗎?我們也許可以試試給&mut降級:

fn new(config: &'a mut HashMap<String, String>) -> Self {
    // 填充預設值
    Self::fill_defaults(config);

    Self { config: &*config }
}

但是Rust編譯器似乎認為這種用法下,在DatabaseHttpServer的整個生命週期內都在持有config的可變引用:

如果說上述的問題都還僅僅屬於“不優雅”和“不方便”的範疇話,接下來的問題就很麻煩了:在DatabaseHttpServer初始化之後,我們就不能修改config了。這看起來似乎不是很嚴重的問題,但如果專案後期有不停機修改配置的需求的話,那麼就真的徹底無從下手了————因為DatabaseHttpServer持有了config的不可變引用,這就導致獲取config的可變引用會直接導致違反引用限制,編譯失敗。

使用Rc<RefCell>

帶著上面提到的各種各樣的問題,我們將程式碼改造為使用Rc<RefCell<T>>的版本:

use std::cell::*;
use std::rc::*;
use std::collections::*;

/// 從配置檔案讀取配置
fn load_configs(config: Rc<RefCell<HashMap<String, String>>>) {
    // 假設我們從檔案裡面讀取了配置,這裡為了模擬演示需要就不實際讀取了
    let mut map = config.borrow_mut();
    map.insert("host".to_owned(), "0.0.0.0".to_owned());
    map.insert("port".to_owned(), "8080".to_owned());
    map.insert("db_url".to_owned(), "mysql://localhost:3306".to_owned());
    map.insert("db_username".to_owned(), "root".to_owned());
}

/// 用於處理Http伺服器的類
struct HttpServer {
    config: Rc<RefCell<HashMap<String, String>>>,
}

impl HttpServer {
    /// 在配置表中為缺失的配置項填充預設值
    fn fill_defaults(config: &Rc<RefCell<HashMap<String, String>>>) {
        // 這裡為了舉例方便,只放一條
        let mut map = config.borrow_mut();
        if !map.contains_key("port") {
            map.insert("port".to_owned(), "8080".to_owned());
        }
    }

    fn new(config: Rc<RefCell<HashMap<String, String>>>) -> Self {
        // 填充預設值
        Self::fill_defaults(&config);

        Self { config }
    }

    fn listen(&self) {
        let map = self.config.borrow();
        println!("Listening on {}:{}", map.get("host").unwrap(), map.get("port").unwrap())
    }
}

/// 用於處理資料庫連線的類
struct Database {
    config: Rc<RefCell<HashMap<String, String>>>,
}

impl Database {
    /// 在配置表中為缺失的配置項填充預設值
    fn fill_defaults(config: &Rc<RefCell<HashMap<String, String>>>) {
        // 這裡為了舉例方便,只放一條
        let mut map = config.borrow_mut();
        if !map.contains_key("db_password") {
            map.insert("db_password".to_owned(), "admin".to_owned());
        }
    }

    fn new(config: Rc<RefCell<HashMap<String, String>>>) -> Self {
        // 填充預設值
        Self::fill_defaults(&config);

        Self { config }
    }

    fn connect(&self) {
        let map = self.config.borrow();
        println!("Connected to Database: {}, user:{}, password:{}", map.get("db_url").unwrap(), map.get("db_username").unwrap(), map.get("db_password").unwrap())
    }
}


fn main() {
    let config = Rc::new(RefCell::new(HashMap::new()));

    // 讀取配置
    load_configs(config.clone());

    let db = Database::new(config.clone());
    let http = HttpServer::new(config.clone());

    db.connect();
    http.listen();
}

輸出還是一樣的:

Connected to Database: mysql://localhost:3306, user:root, password:admin
Listening on 0.0.0.0:8080

大家可以看看程式碼的實現,我們不再需要考慮&mut&T誰先誰後的問題,更不用擔心引用限制對程式造成的影響。此外,程式碼的結構也更合理了,我們將配置項檢查和預設值填充放在了DatabaseHttpServer物件的構造過程中,這是比剛剛的做法更符合常理的。此外,這樣的設計使得我們在DatabaseHttpServer物件構造之後仍然可以修改config的內容,為將來的開發留出了很大空間。

番外:連結串列和二叉樹

總是有人說Rust不能寫連結串列和二叉樹,讀到這裡之後螢幕前的讀者可以轉身向這些人大喊一聲“Naive”了~如果讀者之前沒有使用RefCell的經驗的話,可以考慮用Rc<RefCell<T>>實現一個連結串列來鍛鍊一下,以下是連結串列節點的參考定義:

pub struct ListNode {
    val: i32,
    next: Option<Rc<RefCell<ListNode>>>
}

(你也可以加上一個prev,然後寫個雙向連結串列)

進階:完成力扣146題《LRU快取》,實現一個LinkedHashMap,具體演算法可以參考用Java的題解(演算法不是難點),然後用Rust實現出來。這題做完之後,你一定就能自信地告訴別人自己已經掌握了Rc<RefCell<T>>,乃至Rust的大部分內容。

關於二叉樹也是一樣的,力扣上有很多Rust的二叉樹題,也是用Rc<RefCell<TreeNode>>作為指標,大家意猶未盡 的話也可以去試試。

總結

這些具有內部可變性的型別,例如CellRefCell,扮演著在Rust安全模型中繞過不可變性規則的角色,但是它們總體上仍然是安全且受控地。在使用這些工具時,使用者必須仔細地權衡它們提供的靈活性與潛在的執行時成本和錯誤風險。正確地應用CellRefCell可以在保持程式碼安全性和效能的同時,提供靈活的可變性以滿足特定的程式設計需求。

相關文章