Skip to content

智能指针与内部可变性

1. 这是什么

Rust 里的智能指针,本质上是:像指针一样间接持有数据,但额外带着一套资源管理或访问规则

常见的几类包括:

  • Box<T>:把数据放到堆上
  • Rc<T>:单线程共享所有权
  • Arc<T>:多线程共享所有权
  • RefCell<T>:在运行时做可变借用检查
  • Mutex<T> / RwLock<T>:并发场景下的受控可变访问

而“内部可变性”说的是:

  • 从外部看,值似乎是不可变持有的
  • 但在类型内部,仍然允许受控地修改数据

一句话理解:

  • 智能指针解决“这个值该怎么存、怎么共享、怎么释放”
  • 内部可变性解决“表面不可变时,内部还能不能安全修改”

2. 为什么重要

如果只会所有权和借用,你会发现很多代码虽然理论上安全,但实际很难写:

  • 想把大对象放在堆上
  • 想让多个地方共享同一份数据
  • 想在不可变外壳里维护少量内部状态
  • 想在并发场景下安全共享数据

这时就需要智能指针和内部可变性模型。

它们重要的原因在于:

  • 让 Rust 不只是“严格”,还能“可用”
  • 让复杂数据结构可以被表达
  • 让 GUI、缓存、图结构、共享配置、并发状态这些场景能写得出来

很多人真正感觉 Rust “开始有工程能力”,往往就是从理解这些工具开始的。

3. 先建立直觉

可以先把几种常见类型理解成不同的“控制器”:

  • Box<T>:我一个人拥有,只是数据放堆上
  • Rc<T>:好几个人一起看,但只能单线程用
  • Arc<T>:好几个人一起看,可以跨线程用
  • RefCell<T>:编译期先放宽,运行时再检查借用规则

所以它们不是“更多语法”,而是在表达不同的资源关系:

  • 是独占,还是共享
  • 是单线程,还是多线程
  • 是编译期检查,还是运行时检查

理解这一点,比背 API 更重要。

4. 核心内容

4.1 Box<T>:最基础的智能指针

Box<T> 最容易理解。
它表示:把值分配到堆上,但所有权依然很清楚,还是唯一拥有者。

典型用途:

  • 大对象不想直接放栈上
  • 递归类型需要间接层
  • 想明确表达“堆分配对象”

例如:

rust
let x = Box::new(42);
println!("{}", x);

直觉上可以理解成:

  • 42 在堆上
  • 栈上有一个管理它的盒子
  • Box 离开作用域时,自动释放

4.2 为什么递归类型常需要 Box

像链表、树这种递归结构,如果直接写:

rust
enum List {
    Cons(i32, List),
    Nil,
}

编译器会报错,因为它不知道这个类型到底有多大。
这时要用 Box 打断无限展开:

rust
enum List {
    Cons(i32, Box<List>),
    Nil,
}

所以 Box 很常见的一个角色就是:给递归结构提供固定大小的间接层

4.3 Rc<T>:共享所有权

普通所有权模型强调“一个值只有一个拥有者”。
但现实里经常会有多个地方都想持有同一份只读数据。

Rc<T> 解决的就是这个问题:

  • 允许多个拥有者共享同一份数据
  • 通过引用计数决定何时释放
  • 适用于单线程场景

例如:

rust
use std::rc::Rc;

let a = Rc::new(String::from("hello"));
let b = Rc::clone(&a);
let c = Rc::clone(&a);

这里不是深拷贝三份字符串,而是多个所有者共同指向同一份数据。

4.4 Arc<T>:线程安全版共享所有权

Arc<T> 可以看成线程安全版的 Rc<T>
它解决的是:多个线程都想共享同一份数据

区别在于:

  • Rc<T> 只适合单线程
  • Arc<T> 适合多线程

因此常见模式是:

  • Arc<T>:共享所有权
  • Mutex<T>:在共享之上提供可变访问控制

例如常见组合:

rust
use std::sync::{Arc, Mutex};

这在并发代码里几乎是入门标配。

4.5 什么是内部可变性

Rust 通常强调:

  • 不可变引用不能修改数据
  • 可变引用同一时刻只能有一个

但有些场景下,我们明明想让外部接口保持简单,却又需要在内部改一点状态,比如:

  • 缓存
  • 统计计数
  • 延迟初始化
  • mock 对象记录调用次数

这时就会用到“内部可变性”。

它的核心思想是:

  • 从外部 API 看,不一定暴露 &mut
  • 但内部借助特殊类型,仍然可以做受控修改

4.6 RefCell<T>:单线程里的运行时借用检查

RefCell<T> 是内部可变性的经典工具。
它把原本编译期完成的借用检查,推迟到运行时。

这意味着:

  • 编译器先允许你这样写
  • 但如果你在运行时违反借用规则,会 panic

所以它的特点是:

  • 更灵活
  • 但一部分安全检查从编译期挪到了运行时

适合场景:

  • 单线程内部状态维护
  • 测试替身 / mock
  • 某些树和图结构的局部可变状态

4.7 Rc<RefCell<T>> 为什么常见

很多 Rust 初学者很快就会见到:

rust
Rc<RefCell<T>>

它其实就是两个需求叠加:

  • Rc:我要共享所有权
  • RefCell:我要在共享下还能修改内部状态

也就是:多个地方共同持有,并且允许单线程下的受控可变访问

这在 GUI、图结构、树结构里很常见。

不过一旦看到这种组合,也应该提醒自己:

  • 当前设计是不是已经开始变复杂了
  • 有没有更简单的所有权组织方式

4.8 多线程里的内部可变性:Mutex<T> / RwLock<T>

在并发场景下,RefCell<T> 不够用。
因为它不是线程安全的。

这时通常要用:

  • Mutex<T>:同一时刻只允许一个线程修改
  • RwLock<T>:多个读者或一个写者

直觉上理解:

  • RefCell 像“单线程运行时借用检查”
  • Mutex / RwLock 像“多线程访问控制门”

所以并发里的典型共享状态模型常写成:

  • Arc<Mutex<T>>
  • Arc<RwLock<T>>

4.9 关键不是记类型,而是先判断关系

很多人学智能指针时容易掉进一个误区:看到需求就先想“该用哪个类型”。
更实用的做法是先判断场景关系:

  1. 需要堆分配吗
  2. 需要共享所有权吗
  3. 共享发生在单线程还是多线程
  4. 共享下还需要修改吗
  5. 可变性是编译期检查还是运行时/锁机制检查

把这几个问题问完,合适的类型自然会浮出来。

5. 常见误区

5.1 误区一:智能指针只是“更高级的指针”

不准确。
Rust 的智能指针更重要的是“规则”,不是“地址”。

5.2 误区二:Rc<T>Arc<T> 只是性能差异

不是。
它们最大的区别首先是线程语义不同,不是单纯快慢问题。

5.3 误区三:RefCell<T> 很方便,所以可以到处用

RefCell<T> 的确让代码更灵活,但它把一部分借用错误从编译期拖到了运行时。
如果滥用,代码会变得更脆弱。

5.4 误区四:看到共享就直接 Rc<RefCell<T>>

这是一种常见“新手万能胶”。
它能解决一些问题,但也可能掩盖更深的建模问题。
先想清楚所有权边界,再决定是否真的需要它。

6. 一个更实用的判断思路

遇到 Rust 里的“数据不好放、不好共享、不好修改”时,可以这样判断:

  • 只是需要堆分配 / 递归结构 → 先看 Box<T>
  • 需要多个所有者,但只在单线程 → 先看 Rc<T>
  • 需要多个所有者,并且跨线程 → 先看 Arc<T>
  • 单线程下共享且还要改内部状态 → 先看 Rc<RefCell<T>>
  • 多线程下共享且还要改内部状态 → 先看 Arc<Mutex<T>>Arc<RwLock<T>>

注意,这只是第一步判断,不是机械套模板。
真正要紧的是:你有没有把“所有权、共享、可变性、线程边界”这几件事分开想清楚。

7. 学习建议

建议按这个顺序学:

  1. 先彻底理解 Box<T>
  2. 再学 Rc<T> 和共享所有权
  3. 再学 RefCell<T> 和内部可变性
  4. 再理解 Rc<RefCell<T>> 为什么会出现
  5. 最后进入 Arc<T>Mutex<T>RwLock<T> 的并发组合

这样会更容易看懂各种常见 Rust 项目代码,而不是一上来就被一堆组合类型吓到。

8. 自测标准

  • 能解释 Box<T>Rc<T>Arc<T> 分别在解决什么问题
  • 能说清为什么递归类型常需要 Box<T>
  • 能解释什么是内部可变性
  • 能理解 RefCell<T> 为什么把一部分检查放到运行时
  • 能看懂 Rc<RefCell<T>>Arc<Mutex<T>> 这两类常见组合的大致意图
  • 能在“堆分配 / 共享 / 可变 / 并发”之间做基本判断