iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0
Rust

Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計系列 第 16

(Day16) Rust 內部可變性與封裝風險:Cell、RefCell

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250917/20124462KA2M7PfuNm.png

Rust 逼我成為更好的工程師 內部可變性與封裝風險:Cell、RefCell

Rust 的借用檢查器(borrow checker)是個好東西,它在編譯期就幹掉了整整一大類的 bug,但是現實是殘酷的。

有時候,為了榨出最後一點效能,比如實現一個快取,我們需要在一個「看起來」不可變的結構內部,修改某些資料。

這就是 RefCell 這種東西存在的地方。
它不是一個優雅的設計模式,它是一個後門,一個妥協。

什麼時候需要「繞過」編譯期借用檢查?

  • API 需要對外呈現不可變,但內部需要延遲初始化或快取。
  • 需要在單執行緒下,於執行期檢查借用規則。

Cell<T>:值語義的複製進出;
RefCell<T>:執行期動態借用(違規就 panic)。

困境:當規則擋住你的路

這個問題很常見。
假如有個結構,有個方法應該只是讀取資訊,但你想在內部偷偷快取結果。

struct Report {
    // ... 大量資料
    cache: Option<String>,
}

impl Report {
    // 從外部看,這就是個讀取操作,所以簽名是 &self
    fn get_summary(&self) -> String {
        if let Some(cached) = &self.cache {
            return cached.clone();
        }

        let summary = self.compute_summary(); // 昂貴的計算
        self.cache = Some(summary.clone()); // ❌ 編譯器報錯。你違背了 &self 的承諾。
        summary
    }

    fn compute_summary(&self) -> String { /* ... */ "summary".into() }
}

編譯器阻止了你,這是它的職責。
在99%的情況下,你應該聽它的,回去修改你的設計。但如果,且只有如果,效能數據證明這個快取至關重要,你就需要一個方法告訴編譯器:「好了,我知道我在做什麼。」

工具:CellRefCell

這兩個東西讓你可以在執行期繞過編譯器的靜態檢查。
它們都只用於單執行緒。

  • Cell<T>:用於可 Copy 的型別 (如 i32) 這東西很直接。它就是一個值容器,你可以直接用 set() 換掉裡面的值,用 get() 拿一份拷貝出來。沒有借用,沒有恐慌,風險很低。能用它就用它。

  • RefCell<T>:用於無法 Copy 的型別 (如 String) 這是我們要重點關注的危險品。它做的事情很簡單:把借用規則的檢查,從編譯期推遲到執行期。

    • .borrow():在執行期取得一個共享參考 (&T)。

    • .borrow_mut():在執行期取得一個獨佔參考 (&mut T)。

    代價是什麼?如果你在執行時違反了借用規則(比如,在一個 .borrow_mut() 的生命週期內又來了一次 .borrow()),你的程式會直接崩潰 (panic)。沒有商量的餘地。

    核心差異Cell 是低成本的位元交換。RefCell 則是一場豪賭,賭你在執行期的邏輯永遠不會違背借用規則。賭輸的代價是整個程式掛掉。

原則:隔離危險,而不是隱藏它

使用 RefCell 的唯一準則,就是把它當成核廢料處理:用最厚的牆把它封起來,並標上警告標誌。

  • 只在「邊界內部」使用,對外仍以 &T/&mut T 暴露安全界面。
  • 把可變性集中在小範圍,避免傳染到呼叫端。
use std::cell::RefCell;

struct Report {
    // ...
    cache: RefCell<Option<String>>, // 把不穩定因素關進 RefCell
}

impl Report {
    fn get_summary(&self) -> String {
        // 請求讀取權限
        if let Some(cached) = self.cache.borrow().as_ref() {
            return cached.clone();
        }

        let summary = self.compute_summary();
        
        // 請求寫入權限,用完立刻釋放
        *self.cache.borrow_mut() = Some(summary.clone());
        summary
    }

    fn compute_summary(&self) -> String { /* ... */ "summary".into() }
}

get_summary 對外的 &self 簽名是個謊言。

任何內部使用了 RefCell 並進行寫入操作的函式,即使其簽名是 &self,在程式碼審查(Code Review)中也必須被當成一個 &mut self 的可變函式來同等對待。

審查者必須假設它有副作用,並以最嚴格的標準去檢查所有可能的借用衝突。

它告訴呼叫者「這很安全」,但其內部正在處理隨時可能引爆的炸藥。
這種封裝的目的不是為了「優雅」,而是為了損害控制——確保爆炸只發生在這個函式內部,不會洩漏出去。

反模式清單(你以為的捷徑)

  • RefCell 當萬能解鎖器 → 真問題是資料結構設計錯。
  • 對外曝露 RefCell<T> 本身 → 把風險外送給使用者。
  • 在多執行緒用 RefCell → 執行期踩雷,應改用 Mutex/RwLock

在碰 RefCell 之前

  1. 先問自己: 能不能透過重構,或改變資料所有權來避免內部可變性?

  2. 如果不行,且是單執行緒,再考慮 Cell (用於 Copy 型別) 或 RefCell (用於非 Copy 型別)。

  3. 拿出數據證明。 這個快取到底有多重要?是讓程式快了 1 奈秒還是 10 秒?如果沒有 benchmark 數據來證明這 hack 是絕對、非做不可的,就不需要考慮 RefCell

  4. 如果非用不可…… 如果通過了以上所有考驗,確定這是唯一解法的話。

    • 那麼就用它,但要在註解裡寫清楚,我們為什麼被迫做了這個妥協,並確保這個模組有最嚴格的程式碼審查和單元測試。

RefCell 不會讓你成為一個更好的工程師。
**知道何時絕對不要用它,才會。

延伸閱讀


上一篇
(Day15) Rust Trait 泛型與最小承諾:AsRef、Borrow、Into
系列文
Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言