第8章:智能指针 —— Box、Rc、RefCell 与自引用
约 11 分钟 · 更新于 2026-09-01
第8章:智能指针 —— Box、Rc、RefCell 与自引用
智能指针并不是一组需要背诵的容器名称,而是一套重新配置“数据放在哪里、由谁负责释放、能否被多人持有、何时检查可变访问”的工具。本章以场景树和文本索引器为例,从数据模型出发选择 Box、Rc、Weak、Cell 与 RefCell,最后说明为什么自引用问题通常应先通过改造表示方式解决。
本章目标
- 分清存储位置、所有权数量和可变性是三个独立维度。
- 能为递归结构选择 Box<T>,而不是条件反射地使用引用计数。
- 理解 Rc<T>、Weak<T> 的生命周期语义与循环引用风险。
- 能控制 RefCell<T> 的动态借用范围,并解释运行时 panic 的原因。
- 理解 Deref、Drop、Pin 各自维护的契约。
- 遇到自引用需求时,优先尝试索引、句柄和拆分数据结构。
一、从三个问题开始,而不是从类型名开始
面对一份需要长期保存的数据,先分别回答:
- 数据放在哪里? 栈内联、堆分配,还是其他资源中。
- 谁决定它何时释放? 唯一所有者、多个强所有者,还是某个外部管理器。
- 如何修改它? 编译期独占借用、运行时借用检查,还是线程同步。
常见类型只是在这些维度上给出不同答案:
| 类型 | 所有权语义 | 修改方式 | 适用范围 |
|---|
| Box<T> | 唯一所有权 | 通常通过 &mut | 堆分配、递归类型、特征对象 |
| Rc<T> | 多个强所有者 | 默认共享读取 | 单线程共享 |
| Weak<T> | 不延长目标寿命 | 先 upgrade | 回指、缓存观察者、打破环 |
| Cell<T> | 不改变外部所有权 | 按值读写 | 单线程小型 Copy 状态 |
| RefCell<T> | 不改变外部所有权 | 运行时借用检查 | 单线程内部可变性 |
| Arc<T> | 多线程共享所有权 | 默认共享读取 | 跨线程共享 |
| Mutex<T> | 通常由外层指针拥有 | 加锁后独占修改 | 跨线程可变状态 |
把类型机械地套在一起容易得到难以维护的 Rc<RefCell<Vec<Box<...>>>>。更好的做法是先画出所有权箭头,再决定每条边表达“拥有”“借用”还是“仅观察”。
二、Box:为值增加一层稳定大小的间接访问
2.1 Box<T> 改变布局,不增加所有者
rust
let payload = Box::new([7_u8; 2048]);
let next_owner = payload;
assert_eq!(next_owner[0], 7);
// println!("{}", payload[0]); // payload 已经移动
数组位于堆上,栈中保存的是固定大小的指针;但所有权仍然只有一份。移动 Box 是移动指针的所有权,不是复制其中的数据。
因此,Box<T> 的典型价值是:
- 给大对象一个堆上的稳定容器;
- 让递归定义具有可计算的大小;
- 持有 Box<dyn Trait>;
- 隔离对象布局,降低外层值移动时的复制量。
它不自动提供共享,也不保证“使用堆就更快”。堆分配、释放和指针间接访问都有成本。
2.2 用表达式树理解递归类型
假设我们要保存过滤规则:
text
(price > 100) AND (country == "CN")
规则节点可能继续包含规则节点。如果直接内联递归,编译器无法计算 Expr 的大小:
rust
// enum Expr {
// And(Expr, Expr),
// Number(i64),
// }
加入固定大小的间接层即可:
rust
#[derive(Debug)]
enum Expr {
Number(i64),
GreaterThan(Box<Expr>, Box<Expr>),
And(Box<Expr>, Box<Expr>),
}
let rule = Expr::And(
Box::new(Expr::GreaterThan(
Box::new(Expr::Number(120)),
Box::new(Expr::Number(100)),
)),
Box::new(Expr::Number(1)),
);
println!("{rule:#?}");
每个 Box<Expr> 的尺寸固定,树的深度则成为运行时数据。
决策提示
递归结构只有一个明确根节点时,优先考虑 Box。只有确实存在多个独立持有者时,才引入 Rc 或 Arc。
三、Deref 与 Drop:包装器如何表现出资源语义
3.1 Deref 提供目标类型的共享访问
rust
use std::ops::Deref;
struct Label(String);
impl Label {
fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
}
impl Deref for Label {
type Target = str;
fn deref(&self) -> &Self::Target {
self.0.as_str()
}
}
let label = Label::new("render-node");
assert_eq!(label.len(), 11);
assert!(label.starts_with("render"));
Deref 返回的是对目标的引用,而不是目标值本身。这样既能使用目标类型的方法,又不会从包装器中搬走内容。
Deref coercion 还会在函数调用处逐层转换:
rust
fn show(text: &str) {
println!("{text}");
}
let text = Box::new(String::from("scene"));
show(&text); // &Box<String> -> &String -> &str
不要为了少写一个方法就滥用 Deref。它会让包装类型被读者理解为“本质上就是目标类型”。业务对象若只想暴露少量能力,显式方法往往更清楚。
3.2 Drop 把清理动作绑定到所有权结束
rust
struct Session(&'static str);
impl Drop for Session {
fn drop(&mut self) {
println!("close session: {}", self.0);
}
}
fn run() {
let _outer = Session("outer");
let inner = Session("inner");
drop(inner); // 在作用域结束前主动清理
}
资源释放由所有权和作用域驱动,这就是 RAII。锁、文件、网络连接和临时目录都可以使用同一模式。
需要提前释放时调用 drop(value);不能直接调用 value.drop()。另外,实现了 Drop 的类型不能实现 Copy,否则隐式复制会让析构责任失去唯一归属。
四、Rc:当“最后一个使用者”无法预先确定
4.1 场景:多组件共享一份样式配置
渲染器、属性面板和导出器都需要读取同一份主题配置,三者结束顺序并不固定。此时可以让它们各持有一个 Rc<Theme>:
rust
use std::rc::Rc;
#[derive(Debug)]
struct Theme {
accent: String,
}
let theme = Rc::new(Theme {
accent: String::from("indigo"),
});
let renderer_theme = Rc::clone(&theme);
let panel_theme = Rc::clone(&theme);
assert_eq!(Rc::strong_count(&theme), 3);
assert_eq!(renderer_theme.accent, "indigo");
assert_eq!(panel_theme.accent, "indigo");
Rc::clone 复制的是持有句柄并增加强引用计数,不会深拷贝 Theme。使用显式的 Rc::clone(&value),能够提醒读者这里发生的是共享所有权变化。
4.2 Rc<T> 不等于共享可写
多个持有者都能读取并不意味着每个持有者都能取得 &mut T。否则两个组件可能同时修改同一对象,破坏 Rust 的别名规则。
若初始化后不再变化,Rc<T> 本身就是合适的终点。只有确实需要在共享引用下修改时,才加入内部可变性。
4.3 Rc::get_mut 适合尚未共享的阶段
如果当前只有一个强引用,可以安全取得可变引用:
rust
use std::rc::Rc;
let mut settings = Rc::new(Vec::<String>::new());
Rc::get_mut(&mut settings)
.expect("settings is still uniquely owned")
.push(String::from("dark-mode"));
let shared = Rc::clone(&settings);
assert_eq!(shared.len(), 1);
这是一种有用的“两阶段”设计:构建阶段保持唯一所有权,冻结后再共享,避免从一开始就引入 RefCell。
五、Cell 与 RefCell:在共享引用下修改内部状态
5.1 Cell<T> 适合按值更新的小状态
rust
use std::cell::Cell;
struct Probe {
visits: Cell<u64>,
}
impl Probe {
fn visit(&self) {
self.visits.set(self.visits.get() + 1);
}
}
Cell 不借出内部引用,而是通过 get、set、replace 等操作按值交换。它适合计数、标志和小型 Copy 值。
5.2 RefCell<T> 将借用检查延迟到运行时
rust
use std::cell::RefCell;
let notes = RefCell::new(vec![String::from("draft")]);
{
let mut writer = notes.borrow_mut();
writer.push(String::from("reviewed"));
}
let reader = notes.borrow();
assert_eq!(reader.len(), 2);
动态规则仍然是:
- 可以同时存在多个 Ref<T>;
- 或存在一个 RefMut<T>;
- 二者不能重叠。
冲突不是编译错误,而是运行时 panic:
rust
let data = RefCell::new(String::from("index"));
let _read = data.borrow();
// let _write = data.borrow_mut(); // 运行到这里会 panic
Ref 和 RefMut 是借用 guard。它们的生命周期越长,发生冲突的机会越大。
5.3 文本索引器:内部缓存不改变外部身份
rust
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
struct TokenIndex {
cache: RefCell<HashMap<String, usize>>,
hits: Cell<u64>,
}
impl TokenIndex {
fn new() -> Self {
Self {
cache: RefCell::new(HashMap::new()),
hits: Cell::new(0),
}
}
fn token_count(&self, text: &str) -> usize {
let cached = {
let cache = self.cache.borrow();
cache.get(text).copied()
};
if let Some(value) = cached {
self.hits.set(self.hits.get() + 1);
return value;
}
let count = text.split_whitespace().count();
self.cache
.borrow_mut()
.insert(text.to_owned(), count);
count
}
}
查询接口只需要 &self,因为对调用者而言,索引器仍是同一个对象;缓存填充属于实现细节。代码显式缩短了不可变借用的作用域,随后才能进行可变借用。
内部可变性不负责业务正确性
RefCell 只保证动态借用规则。缓存何时失效、状态是否一致、递归调用是否重入,仍需要单独设计。
六、Rc、RefCell 与 Weak:构建可编辑场景树
6.1 先确定所有权方向
场景树有以下语义:
- 父节点负责保存子节点,因此父到子是强所有权。
- 子节点可以查找父节点,但不应阻止父节点释放,因此子到父是弱引用。
- 节点创建后仍可调整父子关系,因此关系集合使用 RefCell。
text
Scene --Rc--> Panel --Rc--> Button
^ ^
| Weak | Weak
+--------------+
6.2 完整实现
rust
use std::cell::RefCell;
use std::rc::{Rc, Weak};
#[derive(Debug)]
struct SceneNode {
id: String,
parent: RefCell<Weak<SceneNode>>,
children: RefCell<Vec<Rc<SceneNode>>>,
}
impl SceneNode {
fn new(id: impl Into<String>) -> Rc<Self> {
Rc::new(Self {
id: id.into(),
parent: RefCell::new(Weak::new()),
children: RefCell::new(Vec::new()),
})
}
fn attach(parent: &Rc<Self>, child: Rc<Self>) {
if child.parent.borrow().upgrade().is_some() {
panic!("child already belongs to a parent");
}
*child.parent.borrow_mut() = Rc::downgrade(parent);
parent.children.borrow_mut().push(child);
}
fn breadcrumb(node: &Rc<Self>) -> String {
let mut parts = vec![node.id.clone()];
let mut cursor = node.parent.borrow().upgrade();
while let Some(parent) = cursor {
parts.push(parent.id.clone());
cursor = parent.parent.borrow().upgrade();
}
parts.reverse();
parts.join("/")
}
}
let root = SceneNode::new("scene");
let panel = SceneNode::new("toolbar");
let button = SceneNode::new("save");
SceneNode::attach(&root, Rc::clone(&panel));
SceneNode::attach(&panel, Rc::clone(&button));
assert_eq!(SceneNode::breadcrumb(&button), "scene/toolbar/save");
Weak::upgrade() 返回 Option<Rc<T>>。这迫使调用者处理“目标已经释放”的情况,而不会产生悬垂引用。
6.3 为什么双向强引用会泄漏
如果子节点也用 Rc 持有父节点,父子离开外部作用域后仍互相维持强引用计数:
text
parent --strong--> child
parent <--strong-- child
值不会被释放,但程序仍可能完全内存安全。Rust 防止的是非法内存访问,不保证所有逻辑环都能被回收。
图结构中应明确指定:
- 哪些边决定目标必须存在;
- 哪些边只是导航或观察;
- 是否存在统一的 arena、仓库或 ID 管理器可以替代引用图。
6.4 删除节点时维护双向关系
只从父节点的 children 中移除节点还不够,还要清空子节点的父引用。更新多处状态时,应避免在持有一个 RefMut 的同时调用可能再次借用同一对象的方法。
一种稳妥顺序是:
- 先找到目标位置;
- 在短作用域内从 children 移除;
- 释放父节点的 RefMut;
- 再清空被移除节点的 parent。
这体现了 RefCell 编程的核心习惯:让 guard 尽快离开作用域。
七、自引用:把“地址关系”改写为“逻辑关系”
7.1 生命周期标注不能固定地址
下面的目标看似自然:结构体拥有一段文本,同时保存指向文本内部的切片。
rust
// struct Record<'a> {
// source: String,
// key: &'a str,
// }
问题不只是“生命周期怎么写”。结构体在返回、赋值或容器扩容时可能移动;如果内部引用保存旧地址,就会失效。生命周期描述引用能活多久,却不会让被引用对象停止移动。
7.2 优先保存范围
rust
use std::ops::Range;
#[derive(Debug)]
struct Record {
source: String,
key_range: Range<usize>,
}
impl Record {
fn parse(source: String) -> Self {
let end = source.find('=').unwrap_or(source.len());
Self {
source,
key_range: 0..end,
}
}
fn key(&self) -> &str {
&self.source[self.key_range.clone()]
}
}
let record = Record::parse(String::from("timeout=30"));
assert_eq!(record.key(), "timeout");
结构体移动不会破坏字节范围;真正的引用只在调用 key() 时临时创建。
其他常见替代方案包括:
- 保存数组索引或字节偏移;
- 保存稳定 ID,通过仓库查询对象;
- 把拥有者和视图拆成两个生命周期明确的类型;
- 使用 arena,使元素由统一容器管理;
- 使用 Rc/Arc 共享独立分配的数据,而不是引用自身字段。
7.3 何时才需要 Pin
当某个类型确实维护“指针指向自身字段”的不变量,并且外部协议要求地址稳定时,才需要 Pin。
rust
use std::marker::PhantomPinned;
use std::pin::Pin;
use std::ptr::NonNull;
struct StableBuffer {
bytes: Vec<u8>,
bytes_ptr: NonNull<Vec<u8>>,
_pin: PhantomPinned,
}
impl StableBuffer {
fn new(bytes: Vec<u8>) -> Pin<Box<Self>> {
let mut value = Box::pin(Self {
bytes,
bytes_ptr: NonNull::dangling(),
_pin: PhantomPinned,
});
let ptr = NonNull::from(&value.bytes);
// SAFETY: value 已在 Pin<Box<_>> 中;后续安全 API 不会移动内部值。
unsafe {
Pin::get_unchecked_mut(value.as_mut()).bytes_ptr = ptr;
}
value
}
}
这里的安全性来自一组共同条件:
- 先固定对象,再建立内部指针;
- 类型为 !Unpin;
- 安全接口不暴露可移动内部值的能力;
- 析构前内部指针始终指向有效字段。
Pin 只约束移动,不会验证裸指针是否初始化正确,也不会自动解决别名、释放顺序和线程安全。
八、选择策略与诊断方法
8.1 一条实用选择路径
text
是否需要递归或堆分配?
是 -> Box
是否有多个独立所有者?
单线程 -> Rc
多线程 -> Arc
是否要从属对象回看拥有者?
是 -> Weak 或 ID
是否要在共享引用下修改?
小型 Copy 状态 -> Cell
单线程复杂状态 -> RefCell
多线程复杂状态 -> Mutex / RwLock
是否出现自引用?
先改成索引、范围、ID、arena 或拆分对象
确实要求地址稳定 -> 再评估 Pin
8.2 常见误判
- “放到 Box 里就不会移动。” 移动 Box 不会移动堆中对象,但仍能在安全代码中把 T 取出或替换,除非额外使用 Pin 和相应约束。
- “Rc::clone 会复制整份数据。” 它只增加强引用计数。
- “RefCell 绕过借用规则。” 规则仍在,只是改为运行时执行。
- “Weak 是不安全裸指针。” 它通过 upgrade 安全检查目标是否仍存在。
- “Rc<RefCell<T>> 是通用共享方案。” 它仅适合单线程,并会把借用冲突变成运行时失败。
- “遇到生命周期错误就把值泄漏成 'static。” 永久泄漏通常是在掩盖所有权边界不清。
- “Pin 能修复所有自引用。” Pin 只保证不移动;其余不变量仍需设计和证明。
九、实践练习
- 为表达式树增加 evaluate(),并让错误节点返回 Result。
- 给 TokenIndex 增加容量上限和淘汰策略,说明缓存失效如何独立于 RefCell 借用规则。
- 为场景树实现 detach 和 reparent,确保父子关系始终一致。
- 在场景树节点上实现 Drop 日志,观察外部强引用如何影响释放顺序。
- 故意构造父子双向强引用,记录 strong_count,再把回边改成 Weak。
- 把 Record 扩展为保存多个字段的 Vec<Range<usize>>,处理 UTF-8 边界。
- 比较 Rc<RefCell<Node>> 与“arena + NodeId”两种树模型的错误类型、测试难度和删除语义。
- 写一个只允许构建阶段修改、共享阶段只读的配置对象,尝试用 Rc::get_mut 避免 RefCell。
- 为一个资源包装器实现 Deref 与 Drop,解释哪些方法应显式暴露,哪些不应通过 Deref 泄漏出去。
十、总结
- Box<T> 提供堆分配和固定大小的间接层,但仍保持唯一所有权。
- Deref 决定包装器如何提供目标引用,Drop 决定所有权结束时如何清理资源。
- Rc<T> 用强引用计数表达单线程共享寿命,不直接提供共享写入。
- Cell<T> 适合按值更新的小状态,RefCell<T> 适合运行时检查的复杂内部可变性。
- RefCell guard 应保持短小,避免借用跨越回调、递归或后续修改。
- 所有权图中,强边表示“保证存在”,弱边表示“存在时访问”;Weak 是打破引用环的重要工具。
- 循环引用可能造成内存泄漏,即使程序没有未定义行为。
- 自引用首先是数据表示问题。索引、范围、ID、arena 和拆分对象通常比裸指针更容易维护。
- Pin 只用于确实需要地址稳定的不变量,不能替代完整的安全证明。
十一、章节导航
上一章使用闭包与迭代器组织数据处理;本章补齐单线程中的间接所有权与内部可变性。下一章把这些问题扩展到多线程环境,讨论消息、锁、原子变量以及 Send / Sync。
- 上一章:第7章:函数式 Rust
- 下一章:第9章:并发编程 —— 线程、消息、锁、原子与线程安全
- Rust 深度教程
- 第7章:函数式 Rust
- 第9章:并发编程
- Claude Code Harness 深度教程