一句话总结
RefCell 提供内部可变性(interior mutability): 它让在只持有不可变引用的情况下, 也能修改内部数据.
代价是--借用规则的检查从编译期推迟到了运行期: 违反"共享不可变, 可变不共享"不再是编译错误, 而是运行时 panic.
一个看似矛盾的需求
上一篇结尾的伏笔: Rc 能共享, 但共享的数据不可变; 可现实中经常既想共享, 又想改. 再比如一个更基础的场景--只有某个对象的 &self(不可变引用), 却想修改它内部的一个缓存字段. 常规借用规则下这做不到:
|
把 &self 改成 &mut self 当然能解决, 但有时无法改签名--比如这个方法要满足某个只给 &self 的 trait, 或这个对象被 Rc 共享(只能拿到 &). 这就需要一种"绕过编译期借用检查, 在内部偷偷可变"的机制.
内部可变性: 把检查推迟到运行期
这是理解 RefCell 的核心思想. 普通的借用规则由编译器静态检查: 它在编译期就能证明借用安全, 代价是有些"其实安全, 但编译器证明不了"的代码也被拒绝.
RefCell 做了一个交换: 把借用规则的检查从编译期挪到运行期. 编译器不再管怎么借, 但 RefCell 自己在运行时记账--如果某一刻真的出现了"可变借用与其他借用并存", 它就 panic. 规则没有被打破, 只是检查的时机变了.
编译期借用检查像机场安检: 登机前(编译期)就拦下所有违禁品, 绝对安全但有时过于保守.
RefCell 像店内的电子防盗门: 进门时不查, 但真的拿了不该拿的东西(违反借用规则)走到门口时, 警报会响(panic).
安全底线一样, 只是检查从"事前"变成了"事中".
RefCell 的用法
RefCell 不像普通变量那样直接读写, 而是通过两个方法借出引用: borrow() 借出 &T(不可变), borrow_mut() 借出 &mut T(可变):
|
注意 log 的签名仍是 &self--从外部看, Logger 是"不可变"的; 但它内部的 count 通过 RefCell 实现了可变. 这就是"内部可变性"名字的由来.
运行期检查: 违规就 panic
把检查推迟到运行期的代价, 是违规不再被编译器拦下, 而是在运行时炸开. 下面的代码能编译通过, 但一运行就 panic:
|
NLL 为什么救不了
看到这里可能会想起第 01 篇的 NLL(Non-Lexical Lifetimes): 编译器能智能地提前结束不再使用的引用. 那么下面这段为什么也 panic?
|
因为 NLL 管的是编译期引用(&T / &mut T), 管不到 RefCell. borrow() 返回的不是裸引用, 而是一个结构体 Ref<T>(守卫 guard), 它内部藏着借用的生命周期和一个运行时计数器:
|
NLL 能让编译器提前结束一个 &T 的生命周期, 是因为编译器看得懂引用--它知道这个引用从哪里来, 到哪里去. 但 Ref 是个普通结构体, 编译器无权在作用域中间自动 drop 它--结构体的析构时机严格遵守词法作用域, 即使它后面不再被访问, 也得活到 }.
修法: 手动提前 drop 守卫, 释放计数:
|
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 篇).
RefCell 与 Mutex 在思想上是同构的--都是"运行时检查的内部可变", 区别只是后者跨线程, 且冲突时阻塞而非 panic. 理解了 RefCell, Mutex 就懂了一半.
快速回顾
- 内部可变性: 持有
&T也能改内部数据; 用于无法改&mut签名, 或数据被共享的场景. - 核心思想: 把借用检查从编译期挪到运行期, 规则没变, 时机变了.
- 用法:
borrow()借&T,borrow_mut()借&mut T. - 代价: 违规不再是编译错误, 而是运行时
panic(如BorrowMutError); 责任从编译器转到程序员. - 定位: 能用编译期借用就别用 RefCell; 它与
Mutex思想同构(运行时检查的内部可变, 后者跨线程).
动手练习
-
下面这段会发生什么? 编译错误还是运行 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. r 是 Ref 守卫结构体(不是裸引用), 它持有不可变计数直到 drop.
即使 r 之后再未被使用, NLL 也管不到结构体的析构时机--守卫默认活到作用域末尾, 所以 borrow_mut() 看到计数仍为 1, panic. 用块限制作用域或 drop(r) 手动释放即可解决.
- 一个 trait 方法签名是
fn record(&self, n: i32)(只能&self), 但需要在实现里累加一个内部计数. 怎么做?
参考答案
把计数字段用 RefCell 包裹, 在 &self 方法里用 borrow_mut() 修改:
|
这是内部可变性的典型用途: 外部接口不可变(受 trait 约束), 内部状态可变.
- 判断: 能不能用
RefCell在两个线程间共享可变数据?
参考答案
不能. RefCell 的借用记账不是线程安全的(它没实现 Sync), 编译器会直接拒绝把它跨线程共享.
多线程下的"内部可变"要用 Mutex 或 RwLock(第 10 篇)--它们是 RefCell 的线程安全对应物, 冲突时阻塞等待而非 panic.
- 为什么说"能用普通
&mut解决就不该用 RefCell"?
参考答案
因为普通 &mut 由编译器在编译期保证借用安全--错误根本无法通过编译. 而 RefCell 把检查推迟到运行期, 违规会变成运行时 panic, 即把一类本可被编译器消灭的 bug 留到了线上.
RefCell 是在"编译期证明不了, 但确信安全"时的退路, 不是默认选择. 优先享受编译期保证.