智能指针:让资源归属可组合
课程概览 · 第 17 章
当任务列表需要被多个视图同时看到、当任务树需要递归引用自身时,普通所有权开始显得不够用。Rust 的答案不是放宽规则,而是给指针加上明确语义:智能指针回答"数据归谁、谁能改、借用检查发生在什么时候"。本章把 Box/Rc/Arc/RefCell/Cell 放回任务队列/CLI 这个熟悉的业务域,讲清什么时候该用、什么时候不该升级。
学习目标与默认选择
学完本章你应当能够:
- 按所有权(唯一/共享)、线程可用性(单线程/跨线程)、运行期借用行为(有无运行期检查)三个维度为
Box<T>、Rc<T>、Arc<T>、RefCell<T>、Cell<T>做选型; - 判断"多个所有者"是真实需求还是设计偷懒,并说出
Rc<RefCell<T>>这类升级组合的代价。 - 区分deref 强制转换(转换点上编译器插入
&*x)与方法调用自动解引用(沿Deref链先命中即停)两种机制,并解释rc.clone()克隆的是引用计数、Option<Box<T>>为什么必须as_deref()。
默认选择:先普通所有权 + 引用;递归类型用 Box;只有当"多个所有者"是真实需求(不是设计偷懒)时才用 Rc/Arc;RefCell/Cell 是把借用检查移到运行期的升级选择。
概念讲解:智能指针是"带语义的所有权"
指针选择表
先给结论表,随后逐个用可运行示例验证:
| 类型 | 所有权 | 线程可用性 | 运行期借用行为 | 典型场景 |
|---|---|---|---|---|
Box<T> | 唯一所有者,值在堆上 | Send(若 T: Send) | 无(编译期检查) | 递归类型、大值转移、trait 对象 |
Rc<T> | 共享所有(引用计数) | 仅单线程(!Send 且 !Sync) | 无(共享不可变) | 单线程多视图共享 |
Arc<T> | 共享所有(原子引用计数) | 跨线程 | 无(共享不可变) | 多线程共享只读配置 |
RefCell<T> | 唯一,但允许运行期可变借用 | 仅单线程 | 运行期检查,违规 panic | 与 Rc 组合实现单线程共享可变 |
Cell<T> | 唯一 | 仅单线程 | 无借用,仅 Copy 值的取/放 | 计数器、标志位 |
读表的要点:Rc/Arc 只解决所有权共享,不提供修改能力;RefCell/Cell 只解决运行期可变性,不解决共享。两者正交,所以才有 Rc<RefCell<T>> 这种组合——但它是两个升级叠加,应当显式承认。
Box<T>:递归类型的唯一所有者
任务树的节点要么是叶子,要么包含子节点。如果 TaskNode 直接包含 Vec<TaskNode>,类型大小可以确定(Vec 本身是胖指针),不需要 Box;但当子节点是单数时,就必须用 Box 打破"大小包含自身"的无限递归:
1 | |
Box 的全部语义就这些:堆分配、唯一所有者、离开作用域自动释放。它不是 Rc 的廉价版,也不能共享。
Rc<RefCell<T>>:单线程共享可变(升级选择)
场景:一个任务列表同时被"统计视图"和"编辑器"持有,谁都不拥有谁。用 Rc 共享 Vec<Task>,用 RefCell 允许其中一个句柄修改内容:
1 | |
注意这被明确标注为升级选择:这个例子完全可以用"一个拥有者 + 传递 &mut"改写。Rc<RefCell<T>> 适用的时刻是:多个句柄的生命周期互相独立、无法找到一个自然的唯一拥有者。即便如此也要付出代价——借用冲突从编译错误变成运行期 panic:
1 | |
(上例中 Task 等类型定义与前一示例相同,请在同一程序内复制使用;各示例间不共享定义。)
Cell<T>:Copy 值的内部可变性
Cell<T> 不提供借用,只提供 get/set,因此只适合小型 Copy 值。典型用途是在不可变结构中藏一个计数器:
1 | |
Cell 避开了"运行期借用记账",代价是值必须 Copy——你不能从 Cell<String> 拿出 &String。
Arc<Mutex<T>> 也是升级选择
多线程版本同样成立:Arc<Mutex<T>> = 共享所有权 + 互斥修改,两个升级叠加。它不是"清晰所有权的替代品",而是当所有权无法收敛到单一线程时的兜底方案(第 18 章展开)。
自动 Deref 的原理:编译器插入的 &*,不是继承
前文的示例一直在依赖一个尚未拆开的机制:shared.borrow()[0].state 直接穿透了 Ref 和 Vec 两层;而本章第一个示例的 current.next 又必须显式 .as_deref() 才能拿到 &TaskNode。要同时解释这两件事,得先承认一个常被混为一谈的事实:Rust 里有两个"自动 Deref",发生的位置和规则都不同:
| 机制 | 发生位置 | 规则 |
|---|---|---|
| deref 强制转换(coercion) | 类型期望已知的"转换点":函数实参、带类型标注的 let、return、字段初始化 | 期望 &U、提供 &T 且 T: Deref<Target = U> 时插入一次 &*x;插入后仍不匹配就继续插 |
| 方法调用自动解引用(autoderef/autoref) | x.method() 的 receiver | 从 receiver 类型沿 Deref 链逐层搜索方法,先命中即停 |
(顺带区分:&[T; N] 到 &[T] 是另一种强制转换,走 unsize 规则,与 Deref 无关。)
机制一:deref 强制转换 = 编译器替你写 &*x
条件:某处需要 &U,你提供 &T,且 T: Deref<Target = U>。编译器插入 &*x;插入后仍不匹配就继续插,可以连剥多层。&mut T 可以转成 &mut U(要求 T: DerefMut),也可以降级成 &U。引用自身也实现了 Deref(&T: Deref<Target = T>),所以三层包装也能一路转到底:
1 | |
三个性质值得记住:
- 零运行期成本:
Deref::deref只是"取出内部引用"(Box取出堆指针,Rc剥一层),转换前后&Box<String>和&String指向堆上同一份数据,没有拷贝。 - 单向:只能"剥层",不能"包装"。反向
&str到&String需要一次堆分配,编译器不会替你做:
1 | |
- 只产引用,不转移所有权:转换结果永远是
&U。fn consume(v: Vec<Task>)不会自动接受Box<Vec<Task>>的值本身(那是 move),必须显式*boxed拆箱传值。
机制二:方法调用沿链搜索,先命中即停
对 x.m(),编译器的解析顺序是固定的:
- 从
x的类型T出发构造候选链:T、*T、**T……每剥一层用一次Deref; - 对链上每个候选类型
U,依次尝试按值self、&self(必要时自动补取引用)、&mut self; - 第一个能命中的方法获胜,搜索立即停止–即使更深处还有"更贴切"的方法。
这解释了本章前文的两个现象。其一,方法会穿透智能指针:shared.borrow() 返回的是 Ref<Vec<Task>>,但 .len() 和索引运算沿链下到 Vec<Task> 命中,所以 shared.borrow()[0].state 可以直接写;boxed_node.title 这种字段访问同样沿链解引用。其二,外层类型的方法优先:rc.clone() 在候选链第一层 Rc<String> 上就命中 Rc::clone,克隆的是引用计数而非内容:
1 | |
链条断在没实现 Deref 的类型上,并且永远不会钻进泛型容器内部。Option 是普通枚举、没有实现 Deref,所以 Option<Box<TaskNode>> 的候选链在第一层就断了。这正是本章第一个示例必须写 current.next.as_deref() 的原因:current.next 是 Option<Box<TaskNode>>,自动机制无法进入 Option 内部对 Box 做转换,as_deref() 把 Option<&Box<TaskNode>> 手动映射成 Option<&TaskNode>–等于替编译器在容器内部做了一次机制一的转换:
1 | |
它不是继承:trait 约束不沿 Deref 链推导
两类自动 Deref 都只在"引用与方法接收者"层面生效。泛型函数的 trait 约束是独立的类型判断,编译器不会沿着 Deref 链去找:
1 | |
同一个 Wrapper,w.len() 能沿链命中 String::len(机制二),show(w) 却报缺 Display。Box<T>/Rc<T> 看起来"自动"实现了 Display/Debug,其实是标准库里逐个 trait 手写的转发实现,不是 Deref 的功劳。newtype 想借 Deref 模拟继承时底层方法全部泄漏(第 7、17 章的反例),根源也在这里:机制二没有选择性,链一旦连通,Target 上所有可见方法都能命中。
链长有上限:机制二的自动解引用层数受编译器递归上限约束(默认 128 层),超限会报 reached the recursion limit 并提示用 #![recursion_limit = "…"] 提高。正常代码远达不到;真撞上这个上限,通常说明类型嵌套设计出了问题。
边界与失败场景
Rc跨线程:Rc<T>是!Send,把它 move 进thread::spawn的闭包直接编译失败(第 18 章演示错误文本)。跨线程共享用Arc。RefCell双重借用:同一作用域内两次borrow_mut()在运行期 panic(见上文 text 示例)。这是把编译期保证换成运行期检查的代价,测试必须覆盖借用重叠路径。Cell放非Copy值:Cell<String>无法get()(String不是Copy),只能take/replace,此时通常应改用RefCell。- 引用环:
Rc相互引用形成环后引用计数永不清零,内存泄漏。需要环(如图结构)时用Weak打破。
为什么可行:借用检查只换了位置,没有消失
普通引用的"多读或一写"由编译器在编译期证明;RefCell 把同样的规则搬进运行期——borrow()/borrow_mut() 仍是那套规则,只是违规从编译错误变成 panic。Rc/Arc 并没有引入"任意共享修改":它们共享的是不可变访问,修改仍要再叠一层 RefCell/Mutex。换句话说,所有智能指针都只是把同一个所有权模型在不同维度上重新分配检查位置;不存在绕过该模型的指针。
常见误区
Box当共享用:Box是唯一所有者,克隆它的唯一途径是克隆内容。Arc被当成"线程安全的可变全局":Arc<T>只共享T的只读视图;修改要Arc<Mutex<T>>,且临界区要短(第 18 章)。Rc<RefCell<T>>满天飞:这是设计妥协的信号。先问"能否让一个组件拥有数据、其他组件持有引用",多数 GUI 式共享可以通过重排所有权消除。- 把自动 Deref 当继承:trait 约束不沿
Deref链推导(见上文Wrapper反例);Box<T>的Display是手写转发。方法能穿透不等于类型实现了能力。
自测
Box<TaskNode>、Rc<RefCell<Vec<Task>>>、Arc<Mutex<Vec<Task>>>各自的"所有权/线程/运行期借用"三维坐标是什么?
答的方向:唯一/单线程/编译期;共享/单线程/运行期检查;共享/多线程/互斥(阻塞等待而非 panic)。为什么
Cell<String>不能get()?什么类型可以?
答的方向:get返回T需要T: Copy,否则会把值移出且原值失效;只有Copy类型适用Cell的取/放模型。什么时候
Rc<RefCell<T>>是正确选择,什么时候它是设计气味?
答的方向:多个生命周期独立的句柄确实需要共享可变数据时正确;当能找到自然唯一拥有者(改为传递&mut或返回新值)时它是气味。rc.clone()(rc: Rc<String>)与(*rc).clone()各做了什么?Option<Box<TaskNode>>上的方法为什么不能穿透到TaskNode?
答的方向:方法解析沿Deref链先命中外层,前者只增加引用计数,后者解一层后命中String::clone深拷贝内容;Option没有实现Deref,链在第一层断掉,需要as_deref()手动在容器内部做一次转换。






