课程概览 · 第 17 章

上一篇:宏:从声明宏到过程宏的边界

下一篇:并发与异步:先划清所有权和取消边界

当任务列表需要被多个视图同时看到、当任务树需要递归引用自身时,普通所有权开始显得不够用。Rust 的答案不是放宽规则,而是给指针加上明确语义:智能指针回答"数据归谁、谁能改、借用检查发生在什么时候"。本章把 Box/Rc/Arc/RefCell/Cell 放回任务队列/CLI 这个熟悉的业务域,讲清什么时候该用、什么时候不该升级。

Box、Rc、Arc、Weak、Cell、RefCell 和锁在所有权、共享、线程与可变性上的选型关系
图:先确认是否真的需要堆、共享、跨线程或内部可变;每增加一种能力,都要承担对应的约束与成本。

学习目标与默认选择

学完本章你应当能够:

  • 所有权(唯一/共享)、线程可用性(单线程/跨线程)、运行期借用行为(有无运行期检查)三个维度为 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/ArcRefCell/Cell 是把借用检查移到运行期的升级选择。

概念讲解:智能指针是"带语义的所有权"

指针选择表

先给结论表,随后逐个用可运行示例验证:

类型所有权线程可用性运行期借用行为典型场景
Box<T>唯一所有者,值在堆上Send(若 T: Send无(编译期检查)递归类型、大值转移、trait 对象
Rc<T>共享所有(引用计数)仅单线程(!Send!Sync无(共享不可变)单线程多视图共享
Arc<T>共享所有(原子引用计数)跨线程无(共享不可变)多线程共享只读配置
RefCell<T>唯一,但允许运行期可变借用仅单线程运行期检查,违规 panicRc 组合实现单线程共享可变
Cell<T>唯一仅单线程无借用,仅 Copy 值的取/放计数器、标志位

读表的要点:Rc/Arc 只解决所有权共享,不提供修改能力;RefCell/Cell 只解决运行期可变性,不解决共享。两者正交,所以才有 Rc<RefCell<T>> 这种组合——但它是两个升级叠加,应当显式承认。

Box<T>:递归类型的唯一所有者

任务树的节点要么是叶子,要么包含子节点。如果 TaskNode 直接包含 Vec<TaskNode>,类型大小可以确定(Vec 本身是胖指针),不需要 Box;但当子节点是单数时,就必须用 Box 打破"大小包含自身"的无限递归:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#[derive(Debug)]
struct TaskNode {
title: String,
next: Option<Box<TaskNode>>, // 去掉 Box 会让 TaskNode 的大小无限递归
}

fn main() {
let third = TaskNode { title: "回归测试".to_string(), next: None };
let second = TaskNode { title: "合并主干".to_string(), next: Some(Box::new(third)) };
let first = TaskNode { title: "发布 1.0".to_string(), next: Some(Box::new(second)) };

let mut current = &first;
let mut count = 0;
while let Some(node) = current.next.as_deref() {
count += 1;
current = node;
}
assert_eq!(count, 2); // 链上共 3 个节点,走 2 步到尾部
assert_eq!(first.title, "发布 1.0");
}

Box 的全部语义就这些:堆分配、唯一所有者、离开作用域自动释放。它不是 Rc 的廉价版,也不能共享。

Rc<RefCell<T>>:单线程共享可变(升级选择)

场景:一个任务列表同时被"统计视图"和"编辑器"持有,谁都不拥有谁。用 Rc 共享 Vec<Task>,用 RefCell 允许其中一个句柄修改内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct TaskId(u64);

impl TaskId {
fn new(value: u64) -> Result<Self, String> {
if value == 0 {
Err("TaskId 不能为 0".to_string())
} else {
Ok(TaskId(value))
}
}
}

#[derive(Debug, PartialEq)]
enum TaskState {
Todo,
InProgress { started_at: u64 },
Done { finished_at: u64 },
Cancelled { reason: String },
}

#[derive(Debug, PartialEq)]
struct Task {
id: TaskId,
title: String,
state: TaskState,
labels: Vec<String>,
}

fn main() {
let shared: Rc<RefCell<Vec<Task>>> = Rc::new(RefCell::new(vec![Task {
id: TaskId::new(1).expect("1 is a valid id"),
title: "写发布说明".to_string(),
state: TaskState::Todo,
labels: vec!["docs".to_string()],
}]));

// 两个共享句柄,引用计数都是 2
let viewer = Rc::clone(&shared);
let editor = Rc::clone(&shared);
assert_eq!(Rc::strong_count(&shared), 3);

// 修改通过 RefCell 的运行期借用检查完成
editor.borrow_mut()[0].state = TaskState::InProgress { started_at: 900 };
assert_eq!(viewer.borrow()[0].state, TaskState::InProgress { started_at: 900 });

drop(editor);
drop(viewer);
assert_eq!(Rc::strong_count(&shared), 1);

// 只有最后一个所有者存在时数据仍然可用
assert_eq!(shared.borrow().len(), 1);
assert_eq!(shared.borrow()[0].title, "写发布说明");
}

注意这被明确标注为升级选择:这个例子完全可以用"一个拥有者 + 传递 &mut"改写。Rc<RefCell<T>> 适用的时刻是:多个句柄的生命周期互相独立、无法找到一个自然的唯一拥有者。即便如此也要付出代价——借用冲突从编译错误变成运行期 panic:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
无法编译(运行期 panic,编译可通过;这里展示的是违规代码模式):

let shared: Rc<RefCell<Vec<Task>>> = /* 同上 */;

let first = shared.borrow_mut(); // 运行期可变借用开始
let second = shared.borrow_mut(); // 已有可变借用:BorrowMutError
first[0].title.push('!');

修正(可运行):可变借用作用域结束后再取第二个:

let mut first = shared.borrow_mut();
first[0].title.push('!');
drop(first); // 可变借用在此结束
let second = shared.borrow();
assert!(second[0].title.ends_with('!'));

(上例中 Task 等类型定义与前一示例相同,请在同一程序内复制使用;各示例间不共享定义。)

Cell<T>Copy 值的内部可变性

Cell<T> 不提供借用,只提供 get/set,因此只适合小型 Copy 值。典型用途是在不可变结构中藏一个计数器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::cell::Cell;

#[derive(Debug, Default)]
struct Stats {
checks: Cell<u64>,
}

fn main() {
let stats = Stats::default();
let shared = &stats; // 仅有共享引用
shared.checks.set(shared.checks.get() + 1); // 仍然可以修改 Cell 内的值
shared.checks.set(shared.checks.get() + 1);
assert_eq!(stats.checks.get(), 2);
}

Cell 避开了"运行期借用记账",代价是值必须 Copy——你不能从 Cell<String> 拿出 &String

Arc<Mutex<T>> 也是升级选择

多线程版本同样成立:Arc<Mutex<T>> = 共享所有权 + 互斥修改,两个升级叠加。它不是"清晰所有权的替代品",而是当所有权无法收敛到单一线程时的兜底方案(第 18 章展开)。

自动 Deref 的原理:编译器插入的 &*,不是继承

前文的示例一直在依赖一个尚未拆开的机制:shared.borrow()[0].state 直接穿透了 RefVec 两层;而本章第一个示例的 current.next 又必须显式 .as_deref() 才能拿到 &TaskNode。要同时解释这两件事,得先承认一个常被混为一谈的事实:Rust 里有两个"自动 Deref",发生的位置和规则都不同

机制发生位置规则
deref 强制转换(coercion)类型期望已知的"转换点":函数实参、带类型标注的 letreturn、字段初始化期望 &U、提供 &TT: 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
2
3
4
5
6
7
8
9
10
11
12
fn byte_len(s: &str) -> usize {
s.len()
}

fn main() {
let boxed: Box<String> = Box::new(String::from("发布 1.0"));
assert_eq!(byte_len(&boxed), 10); // &Box<String> -> &String -> &str:两步转换
let r: &str = &boxed; // 带类型标注的 let 同样是转换点
assert_eq!(byte_len(r), 10);
let double: &&Box<String> = &&boxed;
assert_eq!(byte_len(double), 10); // &&Box<String> -> &Box<String> -> &String -> &str:三步转换
}

三个性质值得记住:

  • 零运行期成本Deref::deref 只是"取出内部引用"(Box 取出堆指针,Rc 剥一层),转换前后 &Box<String>&String 指向堆上同一份数据,没有拷贝。
  • 单向:只能"剥层",不能"包装"。反向 &str&String 需要一次堆分配,编译器不会替你做:
1
2
3
4
5
无法编译(E0308):

let owned: String = String::from("发布 1.0");
let r: &String = owned.as_str();
| ^ expected `&String`, found `&str`
  • 只产引用,不转移所有权:转换结果永远是 &Ufn consume(v: Vec<Task>) 不会自动接受 Box<Vec<Task>> 的值本身(那是 move),必须显式 *boxed 拆箱传值。

机制二:方法调用沿链搜索,先命中即停

x.m(),编译器的解析顺序是固定的:

  1. x 的类型 T 出发构造候选链:T*T**T……每剥一层用一次 Deref
  2. 对链上每个候选类型 U,依次尝试按值 self&self(必要时自动补取引用)、&mut self
  3. 第一个能命中的方法获胜,搜索立即停止–即使更深处还有"更贴切"的方法。

这解释了本章前文的两个现象。其一,方法会穿透智能指针shared.borrow() 返回的是 Ref<Vec<Task>>,但 .len() 和索引运算沿链下到 Vec<Task> 命中,所以 shared.borrow()[0].state 可以直接写;boxed_node.title 这种字段访问同样沿链解引用。其二,外层类型的方法优先rc.clone() 在候选链第一层 Rc<String> 上就命中 Rc::clone,克隆的是引用计数而非内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use std::rc::Rc;

fn main() {
let rc: Rc<String> = Rc::new(String::from("写发布说明"));

// 候选链第一层是 Rc<String>:Rc::clone 命中,只增加引用计数
let shallow = rc.clone();
assert_eq!(Rc::strong_count(&rc), 2);

// 想要内容的深拷贝:显式 *rc 把 receiver 换成 String,命中 String::clone
let deep: String = (*rc).clone();
assert_eq!(deep.len(), 15); // "写发布说明" 5 个汉字,UTF-8 共 15 字节
assert_eq!(Rc::strong_count(&rc), 2); // 深拷贝不经过引用计数
let _ = shallow;
}

链条断在没实现 Deref 的类型上,并且永远不会钻进泛型容器内部Option 是普通枚举、没有实现 Deref,所以 Option<Box<TaskNode>> 的候选链在第一层就断了。这正是本章第一个示例必须写 current.next.as_deref() 的原因:current.nextOption<Box<TaskNode>>,自动机制无法进入 Option 内部对 Box 做转换,as_deref()Option<&Box<TaskNode>> 手动映射成 Option<&TaskNode>–等于替编译器在容器内部做了一次机制一的转换:

1
2
3
4
5
6
7
8
无法编译(E0609):

let opt: Option<Box<TaskNode>> = Some(Box::new(TaskNode { /* … */ }));
let bad = opt.title;
^ no field `title` on type `Option<Box<TaskNode>>`

可行(即本章第一个示例的写法):
if let Some(node) = current.next.as_deref() { /* node: &TaskNode */ }

它不是继承:trait 约束不沿 Deref 链推导

两类自动 Deref 都只在"引用与方法接收者"层面生效。泛型函数的 trait 约束是独立的类型判断,编译器不会沿着 Deref 链去找:

1
2
3
4
5
6
7
8
9
无法编译(E0277):

struct Wrapper { inner: String }
impl Deref for Wrapper { type Target = String; /* … */ }

fn show(t: impl Display) { println!("{t}") }

show(Wrapper { inner: "发布".to_string() });
^ `Wrapper` doesn't implement `std::fmt::Display`

同一个 Wrapperw.len() 能沿链命中 String::len(机制二),show(w) 却报缺 DisplayBox<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 放非 CopyCell<String> 无法 get()String 不是 Copy),只能 take/replace,此时通常应改用 RefCell
  • 引用环Rc 相互引用形成环后引用计数永不清零,内存泄漏。需要环(如图结构)时用 Weak 打破。

为什么可行:借用检查只换了位置,没有消失

普通引用的"多读或一写"由编译器在编译期证明;RefCell 把同样的规则搬进运行期——borrow()/borrow_mut() 仍是那套规则,只是违规从编译错误变成 panicRc/Arc 并没有引入"任意共享修改":它们共享的是不可变访问,修改仍要再叠一层 RefCell/Mutex。换句话说,所有智能指针都只是把同一个所有权模型在不同维度上重新分配检查位置;不存在绕过该模型的指针。

常见误区

  • Box 当共享用Box 是唯一所有者,克隆它的唯一途径是克隆内容。
  • Arc 被当成"线程安全的可变全局"Arc<T> 只共享 T 的只读视图;修改要 Arc<Mutex<T>>,且临界区要短(第 18 章)。
  • Rc<RefCell<T>> 满天飞:这是设计妥协的信号。先问"能否让一个组件拥有数据、其他组件持有引用",多数 GUI 式共享可以通过重排所有权消除。
  • 把自动 Deref 当继承:trait 约束不沿 Deref 链推导(见上文 Wrapper 反例);Box<T>Display 是手写转发。方法能穿透不等于类型实现了能力。

自测

  1. Box<TaskNode>Rc<RefCell<Vec<Task>>>Arc<Mutex<Vec<Task>>> 各自的"所有权/线程/运行期借用"三维坐标是什么?
    答的方向:唯一/单线程/编译期;共享/单线程/运行期检查;共享/多线程/互斥(阻塞等待而非 panic)。

  2. 为什么 Cell<String> 不能 get()?什么类型可以?
    答的方向get 返回 T 需要 T: Copy,否则会把值移出且原值失效;只有 Copy 类型适用 Cell 的取/放模型。

  3. 什么时候 Rc<RefCell<T>> 是正确选择,什么时候它是设计气味?
    答的方向:多个生命周期独立的句柄确实需要共享可变数据时正确;当能找到自然唯一拥有者(改为传递 &mut 或返回新值)时它是气味。

  4. rc.clone()rc: Rc<String>)与 (*rc).clone() 各做了什么?Option<Box<TaskNode>> 上的方法为什么不能穿透到 TaskNode
    答的方向:方法解析沿 Deref 链先命中外层,前者只增加引用计数,后者解一层后命中 String::clone 深拷贝内容;Option 没有实现 Deref,链在第一层断掉,需要 as_deref() 手动在容器内部做一次转换。