课程概览 · 第 15 章

上一篇:模块、属性与宏:控制边界,减少重复

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

当任务列表需要被多个视图同时看到、当任务树需要递归引用自身时,普通所有权开始显得不够用。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>> 这类升级组合的代价。

默认选择:先普通所有权 + 引用;递归类型用 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>> = 共享所有权 + 互斥修改,两个升级叠加。它不是"清晰所有权的替代品",而是当所有权无法收敛到单一线程时的兜底方案(第 16 章展开)。

边界与失败场景

  • Rc 跨线程Rc<T>!Send,把它 move 进 thread::spawn 的闭包直接编译失败(第 16 章演示错误文本)。跨线程共享用 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>>,且临界区要短(第 16 章)。
  • Rc<RefCell<T>> 满天飞:这是设计妥协的信号。先问"能否让一个组件拥有数据、其他组件持有引用",多数 GUI 式共享可以通过重排所有权消除。

自测

  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 或返回新值)时它是气味。