rust 内部可变性
1. 从一个“不能编译“的场景出发
我们有一个用 Rc<T> 共享的数据,希望多个持有者都能读取甚至修改它:
#![allow(unused)]
fn main() {
// 期望的行为(伪代码):
let shared_data = Rc::new(100); // 多个模块共享一个计数器
// 在模块 A 中:
*shared_data += 1; // 想通过 Rc 修改数据 → 编译错误!
// 在模块 B 中:
*shared_data += 1; // 也想修改 → 编译错误!
}
为什么 Rust 编译器不允许这样做?用所有权和借用规则来分析:
Rc<T>允许多个所有者共享数据,但它只提供不可变访问(&T)。- 要修改数据,你需要可变引用(
&mut T),但借用规则规定:同一时刻只能有一个可变引用。 - 然而
Rc的场景中,多个持有者同时存在——编译器无法在编译期确定“此刻是否只有一个持有者想修改数据“。于是编译器拒绝了所有修改操作。
这就带来了一个困境:逻辑上“多个持有者轮流修改同一份数据“是完全合理的需求(比如计数器、缓存、共享配置),但借用规则在编译期无法区分“轮流修改“和“同时修改“,于是一刀切地禁止了。
如果 Rust 只有编译期借用检查,代码的灵活性就到此为止了。为此,Rust 引入了内部可变性(Interior Mutability)。
2. 什么是内部可变性?
内部可变性是一种设计模式:允许你通过不可变引用(&self / &T)来修改内部数据。
这听起来像是在“破坏规则“,但 Rust 的策略不是“开个后门就完事了“,而是:把借用规则的检查从编译期推迟到运行时。
编译期借用检查(默认):
&T → 一定不会修改数据 ← 编译器静态保证
内部可变性(推迟到运行时):
&T → 可能修改内部数据 ← 运行时检查借用规则
如果违规 → panic!(而不是未定义行为)
设计理念:编译器不是万能的——有些正确的代码模式在编译期无法被证明安全。与其禁止这些模式,不如提供一种受控的方式,让程序员在运行时承担检查责任。这很像是数据库中的事务隔离级别:编译期检查 = 严格模式,零误判但有局限;运行时检查 = 宽松模式,能覆盖更多场景,但违规会 panic。
Rust 标准库提供了两种单线程内部可变性类型:
| 类型 | 适用场景 | 检查时机 | 性能 |
|---|---|---|---|
Cell<T> | 整体读写/替换,尤其适合 Copy 类型(整数、bool 等) | 无运行时借用检查 | 接近普通读写成本 |
RefCell<T> | 任意类型 | 运行时检查借用规则 | 轻微运行时开销 |
3. Cell<T> —— 整体读取和替换
3.1 功能定位
Cell<T> 可以存放任意 T,但它最常用于实现了 Copy trait 的类型(如 i32、bool、char、指针等)。它的核心思路是:不提供内部数据的引用,只允许整体读取(get,要求 T: Copy)和整体替换(set / replace / take)。
因为不暴露 &T 或 &mut T,所以不存在运行时借用冲突:Copy 类型可以通过 get() 读出拷贝,非 Copy 类型则通常用 replace() / take() 整体搬出或替换。Cell<T> 本身仍是单线程内部可变性工具,不实现 Sync。
3.2 内部结构
Cell<T> {
value: UnsafeCell<T>, // 底层使用 UnsafeCell 绕过编译期借用检查
}
Cell<T> 的大小等于 T 的大小,直接内联存储数据(无额外堆分配)。
3.3 核心接口
创建 Cell — Cell::new
功能:创建一个新的 Cell,用 value 初始化内部数据。
接口签名:
#![allow(unused)]
fn main() {
pub const fn new(value: T) -> Cell<T>
}
- 参数:
value— 初始值。 - 返回值:
Cell<T>实例。
简单调用示例:
#![allow(unused)]
fn main() {
use std::cell::Cell;
let c = Cell::new(42);
}
读取值 — Cell::get
功能:返回内部值的拷贝。要求 T 实现了 Copy trait。
接口签名:
#![allow(unused)]
fn main() {
pub fn get(&self) -> T
where
T: Copy,
}
- 参数:
&self(不可变引用即可调用)。 - 返回值:
T— 内部值的拷贝。
简单调用示例:
#![allow(unused)]
fn main() {
let c = Cell::new(10);
let val = c.get(); // val = 10
println!("{}", c.get()); // 可以多次调用
}
修改值 — Cell::set
功能:用新值整体替换内部数据。旧值被丢弃。
接口签名:
#![allow(unused)]
fn main() {
pub fn set(&self, val: T)
}
- 参数:
&self(不可变引用)+val— 新值。 - 返回值:无。
简单调用示例:
#![allow(unused)]
fn main() {
let c = Cell::new(10);
c.set(20); // 内部值变为 20
c.set(c.get() + 1); // 读-改-写:内部值变为 21
}
替换并取出旧值 — Cell::replace
功能:用新值替换内部数据,并返回旧值。
接口签名:
#![allow(unused)]
fn main() {
pub fn replace(&self, val: T) -> T
}
- 参数:
&self+val— 新值。 - 返回值:
T— 被替换出来的旧值。
取出值 — Cell::take
功能:取出内部值,并用 T::default() 替换。要求 T: Default。
接口签名:
#![allow(unused)]
fn main() {
pub fn take(&self) -> T
where
T: Default,
}
简单调用示例:
#![allow(unused)]
fn main() {
let c = Cell::new(String::from("hello"));
// 注意:String 不是 Copy,但可以用 replace/take
let old = c.take(); // old = "hello", 内部值变为 ""
let old2 = c.replace(String::from("world")); // old2 = "", 内部值变为 "world"
}
3.4 典型示例:不可变计数器
use std::cell::Cell;
struct Counter {
count: Cell<i32>, // 不是 mut count: i32
}
impl Counter {
fn new() -> Self {
Counter { count: Cell::new(0) }
}
fn increment(&self) { // &self, 不是 &mut self!
let current = self.count.get();
self.count.set(current + 1);
}
fn value(&self) -> i32 {
self.count.get()
}
}
fn main() {
let counter = Counter::new(); // 不需要 mut
counter.increment(); // 通过 &self 修改了内部状态
counter.increment();
println!("Count: {}", counter.value()); // 2
}
关键观察:counter 不需要声明为 mut,increment 方法的签名是 &self 而非 &mut self,但它成功修改了内部计数器的值。这就是“内部可变性“的含义——数据在“不可变的外壳“下拥有“可变的内核“。
4. RefCell<T> —— 运行时借用检查
4.1 功能定位
RefCell<T> 适用于任意类型。与 Cell<T> 不同,RefCell 允许你获取内部数据的引用(&T 和 &mut T),但借用规则在运行时检查:
borrow()→ 返回Ref<T>(不可变借用),允许多个同时存在borrow_mut()→ 返回RefMut<T>(可变借用),同一时刻只能有一个- 如果违反规则(如在已有不可变借用时调用
borrow_mut()),运行时 panic
4.2 内部结构
RefCell<T> {
borrow_flag: Cell<isize>, // 运行时借用计数器(0=无借用,>0=不可变借用数,-1=有一个可变借用)
value: UnsafeCell<T>, // 内部数据
}
RefCell<T> 的大小等于 T 的大小 + 一个 isize(借用标志位),数据直接内联存储。
4.3 核心接口
创建 RefCell — RefCell::new
功能:创建一个新的 RefCell,用 value 初始化内部数据。
接口签名:
#![allow(unused)]
fn main() {
pub const fn new(value: T) -> RefCell<T>
}
- 参数:
value— 初始值。 - 返回值:
RefCell<T>实例。
简单调用示例:
#![allow(unused)]
fn main() {
use std::cell::RefCell;
let rc = RefCell::new(vec![1, 2, 3]);
}
获取不可变借用 — RefCell::borrow
功能:获取内部数据的不可变引用 Ref<T>(它实现了 Deref<Target = T>,可以直接当 &T 用)。运行时检查:如果当前已存在可变借用,则 panic。允许多个不可变借用同时存在。
接口签名:
#![allow(unused)]
fn main() {
pub fn borrow(&self) -> Ref<'_, T>
}
- 参数:
&self。 - 返回值:
Ref<'_, T>— 一个包装了不可变借用的守卫,离开作用域时自动归还借用。
简单调用示例:
#![allow(unused)]
fn main() {
let rc = RefCell::new(42);
let r1 = rc.borrow();
let r2 = rc.borrow(); // 多个不可变借用 OK
println!("{} {}", *r1, *r2);
}
获取可变借用 — RefCell::borrow_mut
功能:获取内部数据的可变引用 RefMut<T>(实现了 DerefMut)。运行时检查:如果当前已存在任何借用(不可变或可变),则 panic。
接口签名:
#![allow(unused)]
fn main() {
pub fn borrow_mut(&self) -> RefMut<'_, T>
}
- 参数:
&self。 - 返回值:
RefMut<'_, T>— 一个包装了可变借用的守卫,离开作用域时自动归还借用。
简单调用示例:
#![allow(unused)]
fn main() {
let rc = RefCell::new(42);
let mut r = rc.borrow_mut();
*r += 1;
println!("{}", *r); // 43
// r 离开作用域,归还借用
}
安全的不可变借用 — RefCell::try_borrow
功能:和 borrow() 一样,但失败时不 panic,而是返回 Err。适用于生产环境中不确定借用状态的场景。
接口签名:
#![allow(unused)]
fn main() {
pub fn try_borrow(&self) -> Result<Ref<'_, T>, BorrowError>
}
- 返回值:
Ok(Ref<T>)表示借用成功;Err(BorrowError)表示当前不满足借用规则。
安全可变借用 — RefCell::try_borrow_mut
功能:和 borrow_mut() 一样,但失败时返回 Err 而非 panic。
接口签名:
#![allow(unused)]
fn main() {
pub fn try_borrow_mut(&self) -> Result<RefMut<'_, T>, BorrowMutError>
}
简单调用示例:
#![allow(unused)]
fn main() {
let rc = RefCell::new(42);
let r = rc.borrow();
// 尝试获取可变借用——此时不可变借用还在,应该失败
match rc.try_borrow_mut() {
Ok(_) => println!("获取成功"),
Err(_) => println!("已经存在不可变借用,无法获取可变借用"),
}
}
4.4 运行时借用规则演示
use std::cell::RefCell;
fn main() {
let data = RefCell::new(42);
// ✅ 多个不可变借用可以共存
let b1 = data.borrow();
let b2 = data.borrow();
println!("{} {}", b1, b2);
// ❌ 不可变借用未释放时尝试可变借用 → panic!
// let b3 = data.borrow_mut(); // 运行时 panic: already borrowed
// ✅ 正确做法:先释放不可变借用
drop(b1);
drop(b2);
let mut b3 = data.borrow_mut(); // OK
*b3 += 1;
println!("{}", b3);
}
4.5 典型示例:带缓存的延迟计算
#![allow(unused)]
fn main() {
use std::cell::RefCell;
struct DataCache {
computed: RefCell<Vec<i32>>, // 缓存:可能被延迟填充
raw_data: Vec<i32>, // 原始数据:不可变
}
impl DataCache {
fn new(data: Vec<i32>) -> Self {
DataCache {
computed: RefCell::new(Vec::new()),
raw_data: data,
}
}
fn get_computed(&self) -> Vec<i32> { // &self, 不是 &mut self!
let mut cache = self.computed.borrow_mut();
if cache.is_empty() {
// 首次访问,执行计算并填充缓存
for &val in &self.raw_data {
cache.push(val * 2);
}
}
// 释放可变借用,切换为不可变借用以返回结果
drop(cache);
self.computed.borrow().clone()
}
}
}
关键观察:get_computed 的签名是 &self,外部调用者以为自己只是在“读取“数据。但在方法内部,首次调用时悄悄地修改了 computed 缓存。后续调用发现缓存已有数据,跳过计算直接返回。整个过程中,调用者完全不需要 &mut self——这就是内部可变性在“惰性初始化“场景中的经典应用。
5. 内部可变性不止于 Cell/RefCell
5.1 Rust 的 Mutex<T> 和 RwLock<T> 也是内部可变性
很多从 Java、C++ 过来的开发者习惯这样的模式:
// Java:锁和数据是分离的
class SharedData {
private int value;
private final Lock lock = new ReentrantLock();
void increment() {
lock.lock();
try { value++; } finally { lock.unlock(); }
}
}
在这个模式中,**数据(value)和锁(lock)**是独立的对象。锁只是“门卫“,数据本身没有任何保护——如果你忘了加锁就访问数据,编译器和运行时都不会阻止你。
Rust 的 Mutex<T> 和 RwLock<T> 采用了完全不同的设计:
#![allow(unused)]
fn main() {
use std::sync::Mutex;
let data = Mutex::new(42); // 数据被"包裹"在 Mutex 内部
let mut guard = data.lock().unwrap(); // lock() 返回一个智能指针
*guard += 1; // 通过守卫访问/修改数据
// guard 离开作用域 → 自动解锁
}
核心差异:
- 数据和锁是一体的:
Mutex<T>包裹了数据T。你无法绕过锁直接访问数据——不调用lock()就看不到里面的值。 lock()返回的是一个智能指针守卫(MutexGuard<T>),它实现了Deref和DerefMut,让你可以像使用普通引用一样读写内部数据。守卫离开作用域时自动解锁——永远不会忘记unlock()。Mutex<T>本身提供内部可变性:你可以通过&Mutex<T>(不可变引用)来修改内部数据。lock()方法的签名是&self,不是&mut self。
这和 RefCell<T> 的设计理念一脉相承:
Cell<T> → 单线程,整体读写,无运行时借用检查
RefCell<T> → 单线程,运行时借用检查
Mutex<T> → 多线程,通过操作系统锁保证独占访问
RwLock<T> → 多线程,读写锁(多读单写)
它们都实现了同一个模式:在“不可变“的外壳下提供“可变“的内部。区别只在于保证安全的方式不同——RefCell 用运行时计数器,Mutex 用操作系统锁,RwLock 用读写锁。
5.2 Rust 的设计理念
Rust 这种“用容器包裹数据、通过守卫访问“的设计,体现了一条核心哲学:
把安全约束编码为类型,让编译器强制执行。数据的所有权和访问权限,不应该依赖于程序员的记忆或编码规范,而应该是类型系统的一部分。
- 传统语言:锁和数据分离 → 安全靠“记得加锁“
- Rust:
Mutex<T>包裹数据 → 不加锁就拿不到数据,安全靠类型系统保证
同样的哲学也贯穿在 Cell/RefCell 中:你想修改不可变引用背后的数据?用 RefCell<T> 包裹它,借用规则从编译期推迟到运行时,但依然被检查——违规即 panic,不会静默地产生未定义行为。
6. 实验代码示例
配套代码位于 src/study/interior_mutability.rs。
6.1 Cell<T> — 不可变结构体的内部计数
#![allow(unused)]
fn main() {
use std::cell::Cell;
struct Counter {
count: Cell<i32>,
}
impl Counter {
fn new() -> Self {
Self { count: Cell::new(0) }
}
fn increment(&self) {
let current = self.count.get();
self.count.set(current + 1);
}
fn value(&self) -> i32 {
self.count.get()
}
}
fn cell_demo() {
let counter = Counter::new(); // 不需要 mut
counter.increment(); // &self 也能修改内部值
counter.increment();
println!("Cell 计数器结果: {}", counter.value()); // 2
}
}
- 实验:定义
Counter结构体,内部用Cell<i32>而非i32,所有方法签名均为&self。创建不可变的counter实例,调用increment()多次。 - 现象:编译通过,输出
Cell 计数器结果: 2。counter未声明为mut,但值确实被修改了。 - 分析:
Cell::get()返回值的拷贝,Cell::set()整体替换内部值——这两个操作都不需要引用内部数据,因此不存在引用冲突。Cell不提供&T或&mut T,只提供值语义的读写,所以无需运行时借用检查,成本接近普通读写。 - 结论:
Cell<T>适合Copy类型的内部可变性场景(计数器、标志位等)。使用体验接近“正常的可变变量“,但允许你通过&self修改它。
6.2 RefCell<T> — 带缓存的延迟计算
#![allow(unused)]
fn main() {
use std::cell::RefCell;
struct DataCache {
computed: RefCell<Vec<i32>>,
raw_data: Vec<i32>,
}
impl DataCache {
fn new(data: Vec<i32>) -> Self {
Self { computed: RefCell::new(Vec::new()), raw_data: data }
}
fn get_computed(&self) -> Vec<i32> {
let mut cache = self.computed.borrow_mut();
if cache.is_empty() {
for &val in &self.raw_data {
cache.push(val * 2);
}
}
drop(cache); // 释放可变借用
self.computed.borrow().clone() // 获取不可变借用,克隆返回
}
}
fn refcell_cache_demo() {
let cache = DataCache::new(vec![1, 2, 3]);
println!("第一次计算缓存: {:?}", cache.get_computed()); // [2, 4, 6]
println!("第二次直接复用缓存: {:?}", cache.get_computed()); // [2, 4, 6]
}
}
- 实验:
DataCache内部用RefCell<Vec<i32>>缓存计算结果。首次调用get_computed()(&self)时填充缓存,后续调用直接返回缓存内容。 - 现象:两次调用输出相同结果
[2, 4, 6]。首次调用触发了计算(在&self方法内修改了computed),第二次调用跳过计算直接复用缓存。调用者始终只需要&self,完全感知不到内部的修改行为。 - 分析:
borrow_mut()在运行时检查——首次调用时没有其他借用,成功获取可变引用并填充缓存。drop(cache)显式释放可变借用后,borrow()才能成功。如果忘记drop(cache),下一行的borrow()会 panic(因为可变借用还未归还)。这种局部的“先写后读“模式在编译期无法被证明安全,但RefCell让你在运行时安全地执行它。 - 结论:
RefCell<T>允许任意类型享受内部可变性。运行时检查确保了“违规即 panic“,代价是轻微的运行开销和需要程序员注意借用归还的时机。
6.3 RefCell 的运行时借用规则验证
#![allow(unused)]
fn main() {
use std::cell::RefCell;
fn refcell_borrow_rule_demo() {
let data = RefCell::new(42);
let b1 = data.borrow();
let b2 = data.borrow();
println!("多个不可变借用可以共存: {} {}", b1, b2);
// 不可变借用还在 → try_borrow_mut 返回 Err,而不是 panic
if data.try_borrow_mut().is_err() {
println!("已经存在不可变借用,无法获取可变借用");
}
drop(b1);
drop(b2);
let mut b3 = data.borrow_mut();
*b3 += 1;
println!("不可变借用释放后,可变借用成功: {}", b3);
}
}
- 实验:先获取多个不可变借用,用
try_borrow_mut()尝试获取可变借用,释放不可变借用后再获取可变借用并修改。 - 现象:不可变借用共存正常;
try_borrow_mut()返回Err而非 panic;释放后borrow_mut()成功,值从 42 变为 43。 - 分析:这个实验直接验证了
RefCell的运行时借用规则——和编译期借用规则完全一致(多读或单写),区别只是违规时编译期报错 vs 运行期 panic(或try_*返回Err)。try_borrow_mut()是生产环境推荐的做法——因为 panic 会终止线程,而Err可以被优雅处理。 - 结论:
RefCell的运行时借用规则和编译期规则完全对应。try_borrow/try_borrow_mut提供了不 panic 的安全替代方案。
7. 总结:内部可变性的选择指南
| 场景 | 使用 | 原因 |
|---|---|---|
内部数据是 Copy 类型,只需整体读写 | Cell<T> | 无运行时借用检查,成本接近普通读写 |
| 内部数据是任意类型,需要获取引用 | RefCell<T> | 运行时借用检查,灵活 |
| 想避免 panic,优雅处理借用冲突 | RefCell + try_borrow / try_borrow_mut | 失败返回 Err |
| 多线程共享可变数据 | Mutex<T> / RwLock<T> | 同样基于内部可变性模式 |
核心理解:内部可变性不是 Rust 的“妥协“,而是一种精心的设计——把无法在编译期证明的安全规则推迟到运行时执行,在灵活性和安全性之间找到了平衡点。
Cell、RefCell、Mutex、RwLock共享同一个设计哲学:数据被容器包裹,访问通过守卫,守卫离开时自动归还权限。