内部可变性(interior mutability) 允许类型通过共享引用修改受控状态。Cell<T>以取出或替换整个值的方式工作,RefCell<T>则在运行时检查共享借用与独占借用。
RefCell<T>没有取消借用规则。活跃的Ref或RefMut与新借用冲突时,borrow()和borrow_mut()会panic;回调重入尤其容易触发这种冲突。
小型值或可整体替换的状态优先考虑Cell<T>。需要借用内部数据时才用RefCell<T>,缩短守卫作用域,并在冲突属于正常控制流时使用try_borrow*()。
是什么,为什么存在
Rust通常不允许通过&T修改T。这条规则让共享引用只能观察普通数据,使编译器可以在编译期排除悬垂引用和冲突访问。不过,有些类型的公开语义确实是共享的,而内部仍需更新计数、缓存、测试记录或回调注册表。
Cell<T>与RefCell<T>是标准库std::cell中的安全内部可变性类型。调用方拿到的仍是共享引用,但包装器把修改限制在自己的API内。它们没有绕开Rust的别名规则,而是用不同方式履行这些规则。
Cell<T>不会从共享的&Cell<T>返回内部T的引用。它让你复制、取出或替换整个值,因此不会产生一个与写入重叠的内部借用。get()需要T: Copy,但Cell<T>本身可以保存String等非Copy类型;这类值可用replace()、take()或into_inner()处理。
RefCell<T>会返回Ref<'_, T>或RefMut<'_, T>。这些 借用守卫(borrow guard) 代表运行时的共享或独占访问权,守卫销毁时归还访问权。规则和普通引用相同,只是检查时机从编译期移到了运行时。
你会在接受&self的缓存、测试替身、单线程GUI状态、回调注册表,以及Rc<RefCell<T>>形式的共享对象中遇到它们。若方法本来可以合理接收&mut self,直接使用普通字段通常更清楚;内部可变性不应成为回避所有权设计的默认办法。
| 需求 | 合适的起点 | 访问模型 | 失败方式 |
|---|---|---|---|
通过&self更新计数或标志 | Cell<T> | 复制或整体替换 | 普通操作不会因动态借用冲突而panic |
通过&self修改集合或结构体 | RefCell<T> | 运行时共享/独占借用 | 冲突的borrow*()会panic |
| 多线程共享可变状态 | Mutex<T>、RwLock<T>或原子类型 | 同步访问 | 阻塞、错误或原子操作语义 |
已有&mut T或拥有T | 普通T | 编译期独占访问 | 编译器拒绝冲突借用 |
工作原理
Cell<T>的整体值操作
Cell<T>可以保存非Copy值,但cell的共享引用不能产生指向内部值的普通引用。set()写入新值并丢弃旧值,replace()写入新值并返回旧值,take()在T: Default时用默认值替换并返回旧值。拥有包装器时,into_inner()可以直接取回T。
get()只在T: Copy时可用,因为它要把值复制给调用方。若已经有&mut Cell<T>,get_mut()可以返回普通&mut T;此时独占性已经由外层可变引用证明,不需要内部可变性参与检查。选API时应看你需要复制、替换还是借用,不要只看T的大小。
Cell<T>操作 | 所需条件 | 结果 |
|---|---|---|
get() | T: Copy | 返回内部值的副本 |
set(value) | 无额外trait约束 | 替换并丢弃旧值 |
replace(value) | 无额外trait约束 | 替换并返回旧值 |
take() | T: Default | 放入默认值并返回旧值 |
get_mut() | 调用方持有&mut Cell<T> | 返回&mut T |
into_inner() | 调用方拥有Cell<T> | 消费包装器并返回T |
RefCell<T>的动态借用
RefCell<T>在概念上维护三种状态:未借用、存在一个或多个共享借用、存在一个独占借用。borrow()在没有独占借用时增加一个共享访问,borrow_mut()只在完全未借用时取得独占访问。具体计数表示属于标准库实现细节,应用代码只应依赖公开行为。
借用成功后,Ref<T>通过Deref提供&T式访问,RefMut<T>通过DerefMut提供&mut T式访问。守卫存活多久,动态借用就持续多久。把守卫存进结构体、从方法返回或跨越回调调用,都会扩大冲突窗口。
borrow()与borrow_mut()把冲突视为程序错误并panic。try_borrow()与try_borrow_mut()返回Result,适合冲突确实属于接口允许的状态,例如一个非阻塞的“当前忙碌”查询。若冲突按设计不该发生,用try_*丢弃错误只会把panic改成静默漏写。
当调用方拥有&mut RefCell<T>时,get_mut()直接返回&mut T,不执行动态检查。消费RefCell<T>的into_inner()也无需检查并返回内部值。这两种API能让初始化、批量更新和销毁路径回到普通编译期借用。
所有权与访问权是两条轴
RefCell<T>只回答“现在谁能访问T”,并不提供多个所有者。Rc<T>只回答“单线程中有多少个强所有者”,并不允许修改T。组合成Rc<RefCell<T>>后,多个Rc句柄共享一个动态借用点;所有克隆都可能在运行时互相冲突。
组合类型要从外向内阅读。Rc<RefCell<T>>表示共享所有权加单线程动态借用,Arc<Mutex<T>>表示跨线程共享所有权加互斥访问。把前者机械替换为后者会引入阻塞、锁顺序和中毒策略等新问题,并不只是“线程安全版本”。
Cell<T>与RefCell<T>都不实现Sync,所以不能通过共享引用在多个线程中同时使用。包装器在T: Send时可以作为一个整体移动到另一线程;这和跨线程共享不是一回事。Rc<T>本身既不是Send也不是Sync。
示例
下面四个程序依次展示Cell<T>的整体替换、RefCell<T>的守卫、可重入回调,以及Rc<RefCell<T>>的共享所有权。代码使用Rust 1.98.0工具链执行,输出块是实际结果。
用Cell<T>替换值
completed是Copy计数,因此可以用get()读出后再set()。phase是String,它仍可放进Cell<T>,但要通过replace()、take()与into_inner()转移整个值。
use std::cell::Cell;
struct JobState {
completed: Cell<u32>,
phase: Cell<String>,
}
impl JobState {
fn finish_one(&self) -> u32 {
let next = self.completed.get() + 1;
self.completed.set(next);
next
}
}
fn main() {
let state = JobState {
completed: Cell::new(0),
phase: Cell::new(String::from("queued")),
};
println!("completed: {}", state.finish_one());
println!("completed: {}", state.finish_one());
let old_phase = state.phase.replace(String::from("running"));
println!("phase: {old_phase} -> {}", state.phase.take());
state.phase.set(String::from("done"));
println!("final phase: {}", state.phase.into_inner());
}completed: 1
completed: 2
phase: queued -> running
final phase: donefinish_one()只拿到&self,但计数修改被限制在Cell<u32>内。加法是否允许溢出是另一个契约问题;实际计数可能到达上限时,应选checked_add()、saturating_add()或更宽类型,并说明所需语义。
take()返回"running",同时把String::default(),也就是空字符串,留在cell中。示例随后立即写入"done",没有依赖这个临时空状态。若空值不满足类型不变量,应使用replace()放入一个明确有效的新值。
观察RefCell<T>守卫
entries()用Ref::map()把整个向量的共享守卫映射成切片守卫。只要view仍存活,try_record()就不能取得独占借用;显式drop(view)后,同一次写入可以成功。
use std::cell::{Ref, RefCell};
struct AuditLog {
entries: RefCell<Vec<String>>,
}
impl AuditLog {
fn record(&self, event: &str) {
self.entries.borrow_mut().push(event.to_owned());
}
fn entries(&self) -> Ref<'_, [String]> {
Ref::map(self.entries.borrow(), Vec::as_slice)
}
fn try_record(&self, event: &str) -> bool {
if let Ok(mut entries) = self.entries.try_borrow_mut() {
entries.push(event.to_owned());
true
} else {
false
}
}
}
fn main() {
let log = AuditLog { entries: RefCell::new(Vec::new()) };
log.record("created");
log.record("validated");
let view = log.entries();
println!("entries: {:?}", &*view);
println!("write while view lives: {}", log.try_record("committed"));
drop(view);
println!("write after drop: {}", log.try_record("committed"));
println!("entries: {:?}", &*log.entries());
}entries: ["created", "validated"]
write while view lives: false
write after drop: true
entries: ["created", "validated", "committed"]返回Ref<'_, [String]>避免复制整个日志,但也把动态借用期限纳入公开API。若调用方只需要长度、一个布尔判断或少量可复制数据,直接返回拥有型结果通常能减小冲突面。需要暴露守卫时,应在文档中说明它会阻止哪些方法。
在回调前释放借用
回调可能再次调用总线。publish()先克隆一份轻量的Rc句柄列表,随后共享守卫在赋值语句末尾销毁;执行回调时,subscribe()因而可以取得新的独占借用。新订阅者从下一次发布开始生效。
use std::cell::RefCell;
use std::rc::Rc;
type Listener = Rc<dyn Fn(&EventBus)>;
struct EventBus {
listeners: RefCell<Vec<Listener>>,
}
impl EventBus {
fn subscribe(&self, listener: Listener) {
self.listeners.borrow_mut().push(listener);
}
fn publish(&self) {
let snapshot = self.listeners.borrow().clone();
for listener in snapshot {
listener(self);
}
}
fn listener_count(&self) -> usize {
self.listeners.borrow().len()
}
}
fn main() {
let bus = EventBus { listeners: RefCell::new(Vec::new()) };
bus.subscribe(Rc::new(|bus| {
println!("primary");
bus.subscribe(Rc::new(|_| println!("secondary")));
}));
bus.publish();
println!("listeners after first: {}", bus.listener_count());
bus.publish();
println!("listeners after second: {}", bus.listener_count());
}primary
listeners after first: 2
primary
secondary
listeners after second: 3如果直接写for listener in self.listeners.borrow().iter(),共享守卫会覆盖整个循环体。第一个回调尝试订阅时,内部的borrow_mut()会panic。快照语义还必须写进契约,因为它决定发布过程中新增或删除的监听器何时可见。
用Rc<RefCell<T>>分开所有权与借用
Queue的克隆只增加Rc强引用计数,两个句柄指向同一个VecDeque。每次方法调用再通过RefCell短暂取得访问权,因此生产者写入的任务能被工作者取出。
use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;
#[derive(Clone)]
struct Queue {
jobs: Rc<RefCell<VecDeque<String>>>,
}
impl Queue {
fn new() -> Self {
Self { jobs: Rc::new(RefCell::new(VecDeque::new())) }
}
fn push(&self, job: &str) {
self.jobs.borrow_mut().push_back(job.to_owned());
}
fn pop(&self) -> Option<String> {
self.jobs.borrow_mut().pop_front()
}
fn owner_count(&self) -> usize {
Rc::strong_count(&self.jobs)
}
}
fn main() {
let producer = Queue::new();
let worker = producer.clone();
producer.push("index");
producer.push("publish");
println!("owners: {}", producer.owner_count());
println!("worker took: {}", worker.pop().unwrap());
println!("producer sees: {}", producer.pop().unwrap());
}owners: 2
worker took: index
producer sees: publish这个队列只适用于单线程协作。若工作者是真实操作系统线程,Rc<RefCell<_>>无法跨越线程边界;应重新选择消息传递、Arc<Mutex<_>>或其他同步结构。选择哪一个取决于阻塞、所有权和关闭语义,而不是让类型检查通过的最短改动。
陷阱
把Cell<T>误写成只支持Copy
修复: 若操作可以表达为整体替换,非Copy类型也可使用set()、replace()、take()或into_inner()。只有确实需要借用内部字段或原地操作集合时,才选择RefCell<T>。
让守卫跨过未知调用
修复: 在调用用户代码前提取所需的拥有型数据,并结束Ref或RefMut。若采用快照,明确新增、删除和嵌套发布的可见性;不要为了缩短守卫而悄悄改变事件语义。
把动态借用冲突当成锁竞争
修复: 先画出守卫的创建点、最后使用点和所有可能重入的调用。冲突不应存在时,调整作用域或API;只有“忙碌”本来就是有效状态时,才把BorrowMutError转换为领域结果。
过早采用Rc<RefCell<T>>
修复: 先尝试缩短普通借用、拆分结构体字段、改变方法接收者或让一个组件拥有状态。只有领域确实需要多个单线程所有者共享可变对象时,才使用该组合,并把反向非拥有边建模为Weak<T>。
把单线程包装器送进并发任务
修复: 先确认任务是否会跨线程,以及状态能否改成消息所有权转移。确需共享时使用与访问模式匹配的同步原语,并审查临界区、锁顺序和失败策略;不要把Arc<Mutex<_>>当作语法替换。
从API泄漏过长的Ref或RefMut
修复: 优先返回计算后的标量、克隆出的必要值,或接受一个只在短借用期间执行的闭包。必须返回守卫时,在名称、类型和文档中暴露该约束,并测试持有守卫时哪些操作会失败。
UnsafeCell<T>与安全边界
UnsafeCell<T> 是Rust语言认可的内部可变性底层原语。通过共享的&UnsafeCell<T>可以调用get()取得原始*mut T,但解引用和写入仍属于unsafe操作。它只关闭编译器对“共享引用指向的数据不可变”的部分假设,不会自动证明引用有效、访问不重叠或没有数据竞争。
Cell<T>与RefCell<T>都在内部使用这个原语,并各自提供安全契约。Cell<T>通过不从共享引用产生内部普通引用来避免别名冲突;RefCell<T>则在创建守卫时动态执行共享与独占检查。包装器持续维护的限制提供了安全性,只有一个UnsafeCell<T>字段并不能证明什么。
实现自定义内部可变性类型时,unsafe代码必须说明哪些指针可同时存在、何时可以读写、值是否已初始化,以及跨线程时如何排除数据竞争。只把字段放进UnsafeCell<T>而不写出这些不变量,等于把编译器原本承担的证明责任留成空白。
UnsafeCell<T>也不会让内部的T自动变成线程安全。标准库的并发原语还需要原子操作、锁或其他同步协议。若业务代码只需现有安全包装器,应使用Cell<T>、RefCell<T>、Mutex<T>、RwLock<T>或原子类型,而不是直接操作原始指针。
包装器保证与业务不变量
安全内部可变性只保证内存安全,不保证业务更新正确。两个依次成功的短借用仍可能组成错误事务,例如先读余额、释放守卫,再根据旧余额写回。借用检查无法判断这个读改写序列是否必须原子完成。
因此,公开方法还要维护领域不变量。把验证与提交放在同一个RefMut作用域中,可以避免同一线程内的重入观察到半完成状态;但在持有守卫时调用未知代码又可能造成panic。常见做法是在进入借用前准备输入,在短借用中完成状态转换,释放守卫后再通知外部回调。
这个顺序并非普适模板。若回调必须能够取消操作或读取旧状态,可能需要显式事件对象、两阶段提交或队列化通知。重点是把“何时状态可见”与“何时守卫存活”作为同一份契约审查。
动态借用的生命周期
守卫由值的生命周期控制
RefCell<T>的借用跟随返回的守卫,而不是只存在于调用borrow()的一行。具名守卫通常保持到其销毁作用域结束,即使普通读取已经完成,因为销毁守卫才会释放动态借用。把守卫返回给调用方或放进容器会延长这段时间;用更小的代码块或显式drop(guard)可以提前结束。
临时值的销毁位置有时比表面代码更长,尤其是在match、if let、迭代器链和语句尾表达式中。怀疑冲突时,不要靠缩进猜测;给守卫命名,检查最后使用点,必要时用小代码块或drop(guard)明确结束访问权。
显式drop()适合表达“后续调用必须在守卫释放后发生”。若代码频繁依赖它才能工作,API可能返回了过宽的数据或把太多字段塞进一个RefCell。拆成多个独立cell能缩小冲突域,但也会让跨字段不变量更难原子维护。
映射守卫仍是同一次借用
Ref::map()与RefMut::map()可以把守卫投影到内部字段或切片,而不重新借用RefCell。映射后的守卫仍保持原来的动态借用状态;它看起来只暴露一个字段,并不意味着包装器的其他部分可以同时取得独占借用。
Ref::filter_map()和对应的可变版本允许投影失败时返回原守卫。这些API适合实现精确读取接口,但会把守卫类型与生命周期写进签名。若调用方只需一个Copy值、长度或判定结果,直接计算后返回通常更简单。
拆分守卫的API只能在安全规则能证明目标不重叠时使用。不要用原始指针伪造两个RefMut来绕过动态检查。动态借用检查被跳过后,违反别名规则会从可诊断的panic变成未定义行为。
panic不是调度机制
borrow()失败会panic,因为该API把冲突定义为程序逻辑错误。panic策略可能是展开,也可能是直接终止进程,因此不能把catch_unwind当作普通的“稍后重试”控制流。库代码还可能不满足安全跨越展开边界所需的trait。
try_borrow()把同一检查变成显式错误,但不会等待守卫释放。需要等待另一个线程或任务时,RefCell<T>不是合适的原语。单线程异步代码即使只运行在一个线程上,也可能在.await处交错;长期保存守卫会让后续轮询路径发生动态冲突。
在异步代码中,先把所需数据提取成拥有型值并释放守卫,再执行.await,通常更容易审查。若状态必须跨挂起点保持独占访问,应重新考虑任务所有权、消息传递或异步感知的同步原语,而不是假设单线程就没有重入。
组合类型的图与线程语义
Rc<RefCell<T>>不会阻止环
Rc<T>通过强引用计数决定何时销毁值。若两个节点通过Rc<RefCell<Node>>互相保存强引用,它们的计数都不会归零,内存可以保持可达但业务上已无用。Rust的内存安全允许这种泄漏。
树或有向拥有关系通常让子节点持有父节点的Weak<T>,父节点持有子节点的Rc<T>。Weak::upgrade()返回Option<Rc<T>>,调用方必须处理所有者已经销毁的情况。哪条边拥有目标是领域设计,不应由代码生成器按字段名称猜测。
动态借用与引用计数解决的是不同问题。把反向边改成Weak<RefCell<T>>可以打破强引用环,却不会减少借用冲突;缩短RefMut也不会让强计数归零。诊断时应分别画所有权图和守卫时间线。
条件Send不等于Sync
当T: Send时,可以把一个未共享的Cell<T>或RefCell<T>整体移动到另一线程。它们不实现Sync,所以&Cell<T>与&RefCell<T>不能安全地被多个线程共享。这个区别经常被简写成“它们不是线程安全的”,但简写会掩盖合法的所有权转移。
Rc<T>不能跨线程发送;Arc<T>提供线程安全的引用计数,但只有当内部类型满足相应约束时,Arc<T>才能共享。Arc<RefCell<T>>仍不满足所需的Sync,因为Arc不会改变内部访问协议。
选择同步类型前,应先确定是单一任务拥有状态、多个任务发送命令,还是多个线程确实要直接共享。消息通道可以转移操作或数据所有权;互斥锁提供独占临界区;读写锁允许特定读写模式;原子类型只支持其定义的原子操作。它们不是可互换的包装层。
API设计与测试
让共享方法说明隐藏写入
一个接收&self的方法若会更新缓存、统计或日志,类型文档应说明这个可观察副作用。调用方可能在重入、测试断言或性能敏感路径中依赖“读取不会修改”的直觉。方法名、错误类型和文档应让隐藏写入可被发现。
不要向调用方承诺RefCell<T>是实现细节,却又返回Ref<T>。守卫类型会暴露包装器、生命周期和冲突行为,未来改用锁或普通字段时会破坏API。若需要抽象实现,可返回拥有型结果,或让调用方提供只在内部短借用期间执行的闭包。
闭包式访问也有代价:闭包可能panic、重入或执行很久。API可以限制闭包拿到的视图,并确保内部状态在调用前处于有效状态。安全Rust会防止悬垂引用,却不会替你定义回调期间的业务语义。
测试冲突而不依赖panic文本
测试RefCell<T>时,优先通过公开API验证状态变化与错误结果。若某个方法按契约会在冲突时返回错误,就先持有一个守卫,调用该方法,并断言错误变体;不要匹配BorrowError的展示文本,因为文字不是核心契约。
若冲突代表实现缺陷,测试应构造真实重入路径,例如监听器在回调中订阅或取消订阅。只在同一函数里连续写两个borrow_mut()能证明基础规则,却不能覆盖实际调用图。回归测试还应确认修复后事件顺序与快照语义没有改变。
对Rc<RefCell<T>>再增加两个测试维度:多个句柄是否看见同一状态,以及非拥有边是否会阻止销毁。可以用Rc::strong_count()辅助定位,但业务测试更应观察节点或资源是否按契约释放。引用计数值容易因测试本身的临时克隆而变化。
选择最窄的能力
若只有一个所有者并能取得&mut self,普通字段提供最强的静态保证。需要通过&self替换整体值时,Cell<T>的能力比RefCell<T>更窄;需要借用内部结构时,再接受RefCell<T>的动态失败面。多个所有者属于另一项决定,应单独考虑Rc<T>或Arc<T>。
能力越宽,调用方需要审查的状态越多。Rc<RefCell<T>>同时允许克隆所有者与延迟访问冲突,Arc<Mutex<T>>又加入线程调度和锁行为。让类型准确表达实际共享关系,通常比先选最灵活的包装器、再靠约定限制使用更可靠。
遇到编译器拒绝借用时,先描述期望的所有权与访问时间线。若期望本身可以在编译期表达,就重构数据或控制流;只有访问关系确实要到运行时才知道时,内部可变性才是在表达问题,而不是隐藏问题。
延伸阅读
4个问题 · 2道输出预测题 · 1道找错题