阶段二 · 智能指针

RefCell 与内部可变性

一句话总结

RefCell 提供内部可变性(interior mutability): 它让在只持有不可变引用的情况下, 也能修改内部数据.
代价是--借用规则的检查从编译期推迟到了运行期: 违反"共享不可变, 可变不共享"不再是编译错误, 而是运行时 panic.

一个看似矛盾的需求

上一篇结尾的伏笔: Rc 能共享, 但共享的数据不可变; 可现实中经常既想共享, 又想改. 再比如一个更基础的场景--只有某个对象的 &self(不可变引用), 却想修改它内部的一个缓存字段. 常规借用规则下这做不到:

struct Logger {
count: u32,
}
impl Logger {
fn log(&self, msg: &str) { // 只有 &self
println!("{msg}");
// self.count += 1; // 错误! &self 是不可变的, 不能改 count
}
}

&self 改成 &mut self 当然能解决, 但有时无法改签名--比如这个方法要满足某个只给 &self 的 trait, 或这个对象被 Rc 共享(只能拿到 &). 这就需要一种"绕过编译期借用检查, 在内部偷偷可变"的机制.

内部可变性: 把检查推迟到运行期

这是理解 RefCell 的核心思想. 普通的借用规则由编译器静态检查: 它在编译期就能证明借用安全, 代价是有些"其实安全, 但编译器证明不了"的代码也被拒绝.

RefCell 做了一个交换: 把借用规则的检查从编译期挪到运行期. 编译器不再管怎么借, 但 RefCell 自己在运行时记账--如果某一刻真的出现了"可变借用与其他借用并存", 它就 panic. 规则没有被打破, 只是检查的时机变了.

编译期借用检查像机场安检: 登机前(编译期)就拦下所有违禁品, 绝对安全但有时过于保守.
RefCell店内的电子防盗门: 进门时不查, 但真的拿了不该拿的东西(违反借用规则)走到门口时, 警报会响(panic).
安全底线一样, 只是检查从"事前"变成了"事中".

RefCell 的用法

RefCell 不像普通变量那样直接读写, 而是通过两个方法借出引用: borrow() 借出 &T(不可变), borrow_mut() 借出 &mut T(可变):

use std::cell::RefCell;

struct Logger {
count: RefCell<u32>, // 用 RefCell 包裹要"内部可变"的字段
}
impl Logger {
fn log(&self, msg: &str) { // 仍然是 &self!
println!("{msg}");
*self.count.borrow_mut() += 1; // 借出可变引用并修改 -- 编译通过
}
fn count(&self) -> u32 {
*self.count.borrow() // 借出不可变引用读取
}
}

注意 log 的签名仍是 &self--从外部看, Logger 是"不可变"的; 但它内部的 count 通过 RefCell 实现了可变. 这就是"内部可变性"名字的由来.

运行期检查: 违规就 panic

把检查推迟到运行期的代价, 是违规不再被编译器拦下, 而是在运行时炸开. 下面的代码能编译通过, 但一运行就 panic:

use std::cell::RefCell;

let cell = RefCell::new(5);
let b1 = cell.borrow_mut();
let b2 = cell.borrow_mut(); // 编译通过, 但运行时 panic:
// already borrowed: BorrowMutError

NLL 为什么救不了

看到这里可能会想起第 01 篇的 NLL(Non-Lexical Lifetimes): 编译器能智能地提前结束不再使用的引用. 那么下面这段为什么也 panic?

let c = RefCell::new(vec![1, 2, 3]);
let r = c.borrow();
// r 后面再也没用过...
let mut w = c.borrow_mut(); // 还是 panic!

因为 NLL 管的是编译期引用(&T / &mut T), 管不到 RefCell. borrow() 返回的不是裸引用, 而是一个结构体 Ref<T>(守卫 guard), 它内部藏着借用的生命周期和一个运行时计数器:

c.borrow()        // → Ref { _borrow: BorrowFlag, value: &T }
// ↑ 这个结构的构造 = 计数器 +1
// ↑ 这个结构的 drop = 计数器 -1
c.borrow_mut() // → 先读计数器: != 0? panic

NLL 能让编译器提前结束一个 &T 的生命周期, 是因为编译器看得懂引用--它知道这个引用从哪里来, 到哪里去. 但 Ref 是个普通结构体, 编译器无权在作用域中间自动 drop 它--结构体的析构时机严格遵守词法作用域, 即使它后面不再被访问, 也得活到 }.

修法: 手动提前 drop 守卫, 释放计数:

// 方案1: 用块限制作用域
{
let r = c.borrow();
// ... 用完 r ...
} // ← r 在此 drop, 计数归零

let mut w = c.borrow_mut(); // OK

// 方案2: std::mem::drop 提前销毁
let r = c.borrow();
// ... 用完 r ...
drop(r); // 手动 drop, 计数归零
let mut w = c.borrow_mut(); // OK

Ref / RefMut 守卫像一个房间钥匙: 拿到钥匙时门锁记录"有人持钥匙", 还钥匙(守卫 drop)时记录才清零.
NLL 能管"走没走出房间", 但管不到"手上有几把钥匙"--那是门锁自己的账本, 只有主动还回去才行.

这是 RefCell 的真实风险

用了 RefCell, 就把"借用安全"的责任从编译器手里接了过来. 编译器不再帮拦, 一旦运行时同时存在冲突借用, 程序直接 panic 崩溃.

所以 RefCell 不是"万能解锁", 而是"确信这里安全, 请编译器让路, 出错自己担". 能用普通借用(编译期保证)解决的, 就不要用 RefCell.

何时该用, 何时不该用

场景 该用 RefCell 吗
能用 &mut 普通可变借用解决 不该. 优先编译期保证
受 trait 签名限制只能拿 &self, 却需改内部状态 合适
Rc 共享的数据需要可变(下一篇) 合适, 经典组合 Rc<RefCell<T>>
多线程共享可变 不行! RefCell 非线程安全, 要用 Mutex(第 10 篇)

一张对照表理清'可变性'工具

Rust 把"可变性"切成了几个正交的工具, 各管一摊:

  • Box: 单一所有者, 编译期借用检查(普通可变).
  • Rc: 多所有者, 只读.
  • RefCell: 单一所有者, 运行期借用检查(内部可变).
  • Rc<RefCell<T>>: 多所有者 + 内部可变(下一篇主角).
  • Mutex / Arc<Mutex<T>>: 多线程版的内部可变(第 10 篇).

RefCellMutex 在思想上是同构的--都是"运行时检查的内部可变", 区别只是后者跨线程, 且冲突时阻塞而非 panic. 理解了 RefCell, Mutex 就懂了一半.

快速回顾

  • 内部可变性: 持有 &T 也能改内部数据; 用于无法改 &mut 签名, 或数据被共享的场景.
  • 核心思想: 把借用检查从编译期挪到运行期, 规则没变, 时机变了.
  • 用法: borrow()&T, borrow_mut()&mut T.
  • 代价: 违规不再是编译错误, 而是运行时 panic(如 BorrowMutError); 责任从编译器转到程序员.
  • 定位: 能用编译期借用就别用 RefCell; 它与 Mutex 思想同构(运行时检查的内部可变, 后者跨线程).

动手练习

  1. 下面这段会发生什么? 编译错误还是运行 panic? 为什么?

    use std::cell::RefCell;
    let c = RefCell::new(vec![1, 2, 3]);
    let r = c.borrow();
    let mut w = c.borrow_mut();
    w.push(4);
参考答案

编译通过, 但运行时 panic: already borrowed: BorrowMutError. rRef 守卫结构体(不是裸引用), 它持有不可变计数直到 drop.

即使 r 之后再未被使用, NLL 也管不到结构体的析构时机--守卫默认活到作用域末尾, 所以 borrow_mut() 看到计数仍为 1, panic. 用块限制作用域或 drop(r) 手动释放即可解决.

  1. 一个 trait 方法签名是 fn record(&self, n: i32)(只能 &self), 但需要在实现里累加一个内部计数. 怎么做?
参考答案

把计数字段用 RefCell 包裹, 在 &self 方法里用 borrow_mut() 修改:

use std::cell::RefCell;

struct Counter {
total: RefCell<i32>,
}

impl Counter {
fn record(&self, n: i32) {
*self.total.borrow_mut() += n; // &self 也能改内部
}
}

这是内部可变性的典型用途: 外部接口不可变(受 trait 约束), 内部状态可变.

  1. 判断: 能不能用 RefCell 在两个线程间共享可变数据?
参考答案

不能. RefCell 的借用记账不是线程安全的(它没实现 Sync), 编译器会直接拒绝把它跨线程共享.

多线程下的"内部可变"要用 MutexRwLock(第 10 篇)--它们是 RefCell 的线程安全对应物, 冲突时阻塞等待而非 panic.

  1. 为什么说"能用普通 &mut 解决就不该用 RefCell"?
参考答案

因为普通 &mut 由编译器在编译期保证借用安全--错误根本无法通过编译. 而 RefCell 把检查推迟到运行期, 违规会变成运行时 panic, 即把一类本可被编译器消灭的 bug 留到了线上.

RefCell 是在"编译期证明不了, 但确信安全"时的退路, 不是默认选择. 优先享受编译期保证.