Agent X-Ray
RuntimeNotesAbout
Notes/代码工程/Rust 深度教材/第8章

第8章:智能指针 —— Box、Rc、RefCell 与自引用

11 分钟 · 更新于 2026-09-01

第8章:智能指针 —— Box、Rc、RefCell 与自引用

智能指针并不是一组需要背诵的容器名称,而是一套重新配置“数据放在哪里、由谁负责释放、能否被多人持有、何时检查可变访问”的工具。本章以场景树和文本索引器为例,从数据模型出发选择 BoxRcWeakCellRefCell,最后说明为什么自引用问题通常应先通过改造表示方式解决。

本章目标

  • 分清存储位置、所有权数量和可变性是三个独立维度。
  • 能为递归结构选择 Box<T>,而不是条件反射地使用引用计数。
  • 理解 Rc<T>Weak<T> 的生命周期语义与循环引用风险。
  • 能控制 RefCell<T> 的动态借用范围,并解释运行时 panic 的原因。
  • 理解 DerefDropPin 各自维护的契约。
  • 遇到自引用需求时,优先尝试索引、句柄和拆分数据结构。

一、从三个问题开始,而不是从类型名开始

面对一份需要长期保存的数据,先分别回答:

  1. 数据放在哪里? 栈内联、堆分配,还是其他资源中。
  2. 谁决定它何时释放? 唯一所有者、多个强所有者,还是某个外部管理器。
  3. 如何修改它? 编译期独占借用、运行时借用检查,还是线程同步。

常见类型只是在这些维度上给出不同答案:

类型所有权语义修改方式适用范围
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。只有确实存在多个独立持有者时,才引入 RcArc


三、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 不借出内部引用,而是通过 getsetreplace 等操作按值交换。它适合计数、标志和小型 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

RefRefMut 是借用 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 的同时调用可能再次借用同一对象的方法。

一种稳妥顺序是:

  1. 先找到目标位置;
  2. 在短作用域内从 children 移除;
  3. 释放父节点的 RefMut
  4. 再清空被移除节点的 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 常见误判

  1. “放到 Box 里就不会移动。” 移动 Box 不会移动堆中对象,但仍能在安全代码中把 T 取出或替换,除非额外使用 Pin 和相应约束。
  2. Rc::clone 会复制整份数据。” 它只增加强引用计数。
  3. RefCell 绕过借用规则。” 规则仍在,只是改为运行时执行。
  4. Weak 是不安全裸指针。” 它通过 upgrade 安全检查目标是否仍存在。
  5. Rc<RefCell<T>> 是通用共享方案。” 它仅适合单线程,并会把借用冲突变成运行时失败。
  6. “遇到生命周期错误就把值泄漏成 'static。” 永久泄漏通常是在掩盖所有权边界不清。
  7. Pin 能修复所有自引用。” Pin 只保证不移动;其余不变量仍需设计和证明。

九、实践练习

  1. 为表达式树增加 evaluate(),并让错误节点返回 Result
  2. TokenIndex 增加容量上限和淘汰策略,说明缓存失效如何独立于 RefCell 借用规则。
  3. 为场景树实现 detachreparent,确保父子关系始终一致。
  4. 在场景树节点上实现 Drop 日志,观察外部强引用如何影响释放顺序。
  5. 故意构造父子双向强引用,记录 strong_count,再把回边改成 Weak
  6. Record 扩展为保存多个字段的 Vec<Range<usize>>,处理 UTF-8 边界。
  7. 比较 Rc<RefCell<Node>> 与“arena + NodeId”两种树模型的错误类型、测试难度和删除语义。
  8. 写一个只允许构建阶段修改、共享阶段只读的配置对象,尝试用 Rc::get_mut 避免 RefCell
  9. 为一个资源包装器实现 DerefDrop,解释哪些方法应显式暴露,哪些不应通过 Deref 泄漏出去。

十、总结

  1. Box<T> 提供堆分配和固定大小的间接层,但仍保持唯一所有权。
  2. Deref 决定包装器如何提供目标引用,Drop 决定所有权结束时如何清理资源。
  3. Rc<T> 用强引用计数表达单线程共享寿命,不直接提供共享写入。
  4. Cell<T> 适合按值更新的小状态,RefCell<T> 适合运行时检查的复杂内部可变性。
  5. RefCell guard 应保持短小,避免借用跨越回调、递归或后续修改。
  6. 所有权图中,强边表示“保证存在”,弱边表示“存在时访问”;Weak 是打破引用环的重要工具。
  7. 循环引用可能造成内存泄漏,即使程序没有未定义行为。
  8. 自引用首先是数据表示问题。索引、范围、ID、arena 和拆分对象通常比裸指针更容易维护。
  9. Pin 只用于确实需要地址稳定的不变量,不能替代完整的安全证明。

十一、章节导航

上一章使用闭包与迭代器组织数据处理;本章补齐单线程中的间接所有权与内部可变性。下一章把这些问题扩展到多线程环境,讨论消息、锁、原子变量以及 Send / Sync

  • 上一章:第7章:函数式 Rust
  • 下一章:第9章:并发编程 —— 线程、消息、锁、原子与线程安全

  • Rust 深度教程
  • 第7章:函数式 Rust
  • 第9章:并发编程
  • Claude Code Harness 深度教程

本章目录
一、从三个问题开始,而不是从类型名开始二、Box:为值增加一层稳定大小的间接访问三、Deref 与 Drop:包装器如何表现出资源语义四、Rc:当“最后一个使用者”无法预先确定五、Cell 与 RefCell:在共享引用下修改内部状态六、Rc、RefCell 与 Weak:构建可编辑场景树七、自引用:把“地址关系”改写为“逻辑关系”八、选择策略与诊断方法九、实践练习十、总结十一、章节导航Related Documents
苏ICP备2025204887号-2