Rust 智能指標 – RefCell<T>(內部可變性)
簡介
在 Rust 中,編譯器會透過所有權與借用檢查,保證資料在執行期間不會發生競爭條件與資料毀損。這套安全機制在大多數情況下都非常好用,但也會遇到「需要在不可變參考下修改資料」的需求。
RefCell<T> 正是為了這類情境而設計的,它提供內部可變性(interior mutability):即使 RefCell<T> 本身是透過不可變參考 (&) 取得,我們仍然可以在執行時動態檢查借用規則,安全地取得可變參考 (&mut)。這讓資料結構在編譯期保持不可變,卻能在執行期根據需求改變內容,常見於 GUI 事件處理、測試 mock、以及需要「共享可變」的場景。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,最後帶出實務應用,幫助你在 Rust 專案中正確、有效地使用 RefCell<T>。
核心概念
1. 為什麼需要 RefCell<T>?
- 編譯期不可變:
let x = Rc::new(RefCell::new(5));中的Rc<T>只能提供不可變參考 (&),但RefCell允許在執行時取得RefMut。 - 動態借用檢查:與編譯期的借用檢查不同,
RefCell在執行時檢查是否同時存在多個可變或不可變借用,違規時會 panic。
2. 基本 API
| 方法 | 說明 |
|---|---|
new(value: T) -> RefCell<T> |
建立新 RefCell |
borrow(&self) -> Ref<T> |
取得不可變借用 (Ref<T>) |
borrow_mut(&self) -> RefMut<T> |
取得可變借用 (RefMut<T>) |
try_borrow(&self) -> Result<Ref<T>, BorrowError> |
非 panic 版的不可變借用 |
try_borrow_mut(&self) -> Result<RefMut<T>, BorrowMutError> |
非 panic 版的可變借用 |
注意:
Ref<T>與RefMut<T>皆實作Deref,使用上與普通的&T、&mut T幾乎相同。
3. RefCell 與 Rc 的常見組合
Rc<RefCell<T>> 讓多個所有者共享同一筆可變資料,這是「共享可變」的典型寫法。
程式碼範例
範例 1:最簡單的 RefCell 使用
use std::cell::RefCell;
fn main() {
// 建立 RefCell,內部值為 10
let cell = RefCell::new(10);
// 取得不可變借用
{
let borrowed = cell.borrow(); // Ref<i32>
println!("不可變值 = {}", *borrowed);
} // borrowed 在此離開作用域,借用結束
// 取得可變借用
{
let mut borrowed_mut = cell.borrow_mut(); // RefMut<i32>
*borrowed_mut += 5;
println!("修改後的值 = {}", *borrowed_mut);
}
}
這個例子展示了 同一個
RefCell只能同時擁有一個可變借用,若同時呼叫borrow_mut()兩次會 panic。
範例 2:Rc<RefCell<T>> 共享可變資料
use std::rc::Rc;
use std::cell::RefCell;
fn main() {
// 多個所有者共享同一筆資料
let shared_vec = Rc::new(RefCell::new(vec![1, 2, 3]));
// 第一個所有者
let a = Rc::clone(&shared_vec);
// 第二個所有者
let b = Rc::clone(&shared_vec);
// a 端推入新元素
a.borrow_mut().push(4);
// b 端讀取資料
println!("b 看見的 vec = {:?}", b.borrow());
// 輸出: b 看見的 vec = [1, 2, 3, 4]
}
透過
Rc的引用計數與RefCell的動態借用檢查,我們可以在多個所有者之間安全地共享可變容器。
範例 3:try_borrow 防止 panic
use std::cell::{RefCell, BorrowError};
fn main() {
let cell = RefCell::new(0);
// 先取得一個不可變借用
let _first = cell.borrow();
// 嘗試再取得另一個不可變借用(成功)
match cell.try_borrow() {
Ok(v) => println!("第二次借用成功: {}", *v),
Err(e) => println!("借用失敗: {}", e),
}
// 嘗試取得可變借用(失敗,會回傳 Err 而不是 panic)
match cell.try_borrow_mut() {
Ok(mut v) => *v = 42,
Err(e) => println!("無法取得可變借用: {}", e),
}
}
使用
try_系列方法可以在 借用失敗時取得錯誤資訊,避免程式直接 panic,適合在需要容錯的情境(例如 UI 事件迴圈)使用。
範例 4:在結構體中使用 RefCell
use std::cell::RefCell;
#[derive(Debug)]
struct Counter {
value: RefCell<i32>,
}
impl Counter {
fn new(v: i32) -> Self {
Counter { value: RefCell::new(v) }
}
fn inc(&self) {
*self.value.borrow_mut() += 1;
}
fn get(&self) -> i32 {
*self.value.borrow()
}
}
fn main() {
let c = Counter::new(5);
c.inc();
c.inc();
println!("計數結果 = {}", c.get()); // 7
}
這裡
Counter::inc只需要&self(不可變參考),卻能改變內部值,正是RefCell的典型應用。
範例 5:避免死鎖的技巧(使用作用域)
use std::cell::RefCell;
fn main() {
let cell = RefCell::new(vec![1, 2, 3]);
// 先取得不可變借用,做完事立即釋放
{
let r = cell.borrow();
println!("長度 = {}", r.len());
} // r 在此離開作用域
// 再取得可變借用
{
let mut w = cell.borrow_mut();
w.push(4);
} // w 離開作用域,借用結束
println!("最終 = {:?}", cell.borrow());
}
將借用限制在最小作用域,可以降低同時持有不可變與可變借用的機會,避免 panic。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
在同一作用域內同時持有 borrow() 與 borrow_mut() |
會觸發 RefCell panic,因為同時存在不可變與可變借用。 |
使用 區塊作用域({})或 drop() 明確釋放 Ref/RefMut。 |
過度使用 RefCell 隱藏設計缺陷 |
若每個地方都用 RefCell,程式會失去編譯期安全檢查,變成「動態檢查」的語言。 |
先思考是否可以透過 所有權轉移、借用切片 或 &mut 參考解決;僅在真的需要共享可變時才使用。 |
在多執行緒環境使用 RefCell |
RefCell 只在單執行緒安全,跨執行緒會產生資料競爭。 |
需要跨執行緒共享時,改用 Mutex<T> 或 RwLock<T>(在 std::sync 中)。 |
忘記 RefCell 內部的 panic 會 unwind |
在 no_std 或 panic=abort 環境下,RefCell panic 會直接 abort,可能導致服務不可用。 |
在這類環境中避免使用 RefCell,或使用 try_borrow 手動處理錯誤。 |
過度嵌套 RefCell<RefCell<T>> |
會讓程式變得難以閱讀且易錯。 | 重新設計資料結構,或使用 enum、Option 直接包裝需要的可變性。 |
最佳實踐
- 限定作用域:盡量把
borrow()/borrow_mut()包在最小的{}區塊內。 - 使用
try_系列:在可能會產生衝突的程式路徑,使用try_borrow/try_borrow_mut取得Result,自行決定錯誤處理方式。 - 結合
Rc:若需要多所有者共享,可使用Rc<RefCell<T>>;若是多執行緒,則改用Arc<Mutex<T>>。 - 僅在「需要內部可變」的地方使用:例如 GUI 元件的狀態、測試 stub、或在
Iterator中累計統計資訊。 - 寫測試捕捉 panic:利用
#[should_panic]測試確保不會意外觸發RefCell的借用違規。
實際應用場景
GUI 框架的事件系統
- 每個 UI 元件通常以
Rc<RefCell<Widget>>存放,事件處理器在取得&self時仍能修改 widget 的屬性(如顏色、可見性)。
- 每個 UI 元件通常以
遞迴資料結構(樹、圖)
- 樹的節點可能需要在父節點持有
Rc<RefCell<Node>>,子節點則可以在遍歷時修改父節點的子集合。
- 樹的節點可能需要在父節點持有
測試 Mock / Stub
- 在單元測試中,使用
RefCell包裝一個計數器或旗標,讓測試函式在只接受&self的介面下仍能記錄呼叫次數。
- 在單元測試中,使用
Lazy 初始化或暫存
Option<RefCell<T>>可在第一次使用時填入實際值,之後的存取不需要mut。
計算統計資料
- 在資料流處理 pipeline 中,使用
RefCell<HashMap<...>>收集統計資訊,讓每個 pipeline 階段只需要不可變參考即可更新統計。
- 在資料流處理 pipeline 中,使用
總結
RefCell<T> 為 Rust 提供了一條 在編譯期保持不可變、執行期允許可變 的捷徑。透過動態借用檢查,它讓我們在單執行緒環境下安全地共享可變資料,尤其搭配 Rc 時,能輕鬆實作「多所有者 + 可變」的模式。
使用時要注意:
- 只在單執行緒 使用,跨執行緒請改用
Mutex/RwLock。 - 限制借用作用域,避免同時持有不可變與可變借用。
- 適度使用
try_系列,避免不必要的 panic。
掌握這些原則後,你就能在 GUI、遞迴結構、測試 mock 等多種實務情境中,靈活運用 RefCell<T>,寫出既安全又具彈性的 Rust 程式碼。祝你玩得開心,寫程式愉快!