课程概览 · 第 17 章

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

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

当任务链需要递归包含自身、任务列表需要被多个视图持有时,关键不是找一个“更方便的引用”,而是决定数据归谁、谁能修改、检查发生在何时。本章的示例彼此独立,只声明当前操作需要的字段,不复刻完整任务领域模型。

Box、Rc、Arc、Weak、Cell、RefCell 和锁的所有权、线程条件与可变性选择
图:所有权共享与内部可变性是独立能力;跨线程还必须满足内部类型的 Send/Sync 条件。

前置知识与学习目标

需要第 06 章的所有权、借用与作用域,以及第 10 章的 trait 约束。学完本章应能:

  • 按独占、共享、修改能力与线程条件选择指针,而不是遇到借用错误就升级。
  • 用 Box 构造有限大小的递归类型,用 Rc 保持多个独立句柄。
  • 预测 RefCell 的运行期借用冲突,区分 Cell 各方法的约束。
  • 解释 Deref、方法查找与 as_deref 的边界。

先按能力选型

默认让一个组件拥有数据,其他组件借用 &T 或 &mut T。只有需要独立存活的共享句柄时才使用引用计数;需要通过共享引用修改内部数据时,再选择内部可变性。

类型归属与修改能力线程条件检查方式
Box<T>堆上独占所有权,普通借用可修改T: Send 时 Send,T: Sync 时 Sync编译期借用检查
Rc<T>单线程共享所有权不实现 Send/Sync非原子引用计数;修改能力由 T 决定
Arc<T>原子计数的共享所有权通常跨线程共享要求 T: Send + Sync不会自动让 T 的访问线程安全
RefCell<T>共享引用可申请动态借用T: Send 时 Send,但不 Sync多读或一写,冲突 panic
Cell<T>共享引用可按值替换内部值T: Send 时 Send,但不 Sync不交出内部共享引用;get 要求 Copy

Send 是值可以跨线程移动,Sync 是共享引用可以跨线程共享。Cell/RefCell 可以在满足条件时整体移动到另一线程,不等于可以由多个线程同时访问。参见 Arc 的线程安全条件。

Box:打破递归大小

直接包含 Option<TaskNode> 会使大小递归依赖自身;Option<Box<TaskNode>> 通过固定大小的间接层打破这个循环。若包含的是 Vec<TaskNode>,Vec 描述符的大小已经确定,不必为这个原因再套 Box;Vec 也不是只有数据指针和长度的“胖指针”。

独立示例(仅标准库;保存为 src/main.rs,执行 cargo +stable run):

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
struct TaskNode {
title: String,
next: Option<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 edges = 0;
while let Some(node) = current.next.as_deref() {
edges += 1;
current = node;
}
assert_eq!(edges, 2); // 三个节点之间有两条边
assert_eq!(first.title, "发布 1.0");
assert_eq!(current.title, "回归测试");
}

Box 是独占所有者,不是共享指针。销毁 Box 会释放其持有的值;克隆 Box<T>(要求 T 可克隆)会克隆内容,而非只增加一个共享句柄。

Rc 与 Arc:共享归属,不自动共享修改

Rc::clone 增加强引用计数,不复制内部数据。最后一个强引用销毁后,内部值才被销毁。跨线程时使用 Arc 的原子引用计数,但它仍不提供内部数据的同步:Arc<RefCell<T>> 不会因此变成线程安全的共享可变状态。

独立示例(仅标准库;src/main.rs,cargo +stable run):

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

fn main() {
let title = Rc::new(String::from("写发布说明"));
let viewer = Rc::clone(&title);
assert_eq!(Rc::strong_count(&title), 2);
assert!(Rc::ptr_eq(&title, &viewer));
let copied: String = (*title).clone();
assert_eq!(copied, *title);
drop(viewer);
assert_eq!(Rc::strong_count(&title), 1);
}

title.clone() 同样先匹配 Rc 自己的 clone 方法;(*title).clone() 则把接收者换成 String,复制字符串内容。若两个 Rc 相互持有强引用,计数可能永远不归零。图结构中的反向边通常使用 Weak:Rc::downgrade 不增加强计数,Weak::upgrade 返回 Option,必须处理对象已销毁的情况。

RefCell 与 Cell:通过共享引用修改

RefCell:借用规则在运行期检查

borrow() 返回共享借用 guard,borrow_mut() 返回独占借用 guard;guard 的销毁结束对应借用。规则仍是多读或一写,但违规从编译错误变成运行期 panic。输入驱动的借用冲突需要返回错误时,可以用 try_borrow/try_borrow_mut 而不是靠 panic 分支控制流程。

故意失败:运行期 panic(仅标准库;独立 src/main.rs。编译应成功,第二次 borrow_mut 冲突):

1
2
3
4
5
6
7
8
use std::cell::RefCell;

fn main() {
let values = RefCell::new(vec![1]);
let mut first = values.borrow_mut();
let _second = values.borrow_mut();
first.push(2);
}

独立示例(仅标准库;src/main.rs,cargo +stable run):

1
2
3
4
5
6
7
8
9
10
use std::cell::RefCell;

fn main() {
let values = RefCell::new(vec![1]);
{
let mut first = values.borrow_mut();
first.push(2);
}
assert_eq!(*values.borrow(), vec![1, 2]);
}

Cell:按值换入换出

Cell::get 要求 T: Copy,适合计数器;Cell 本身并不限于 Copy 类型。set 替换并销毁旧值,replace 换出旧值,into_inner 消费整个 Cell 取出 T;take 用默认值替换,要求 T: Default。它不提供通过共享引用取得 &String 的接口。

独立示例(仅标准库;src/main.rs,cargo +stable run):

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

fn main() {
let checks = Cell::new(0u64);
let shared = &checks;
shared.set(shared.get() + 1);
shared.set(shared.get() + 1);
assert_eq!(checks.get(), 2);

let title = Cell::new(String::from("旧标题"));
let old = title.replace(String::from("新标题"));
assert_eq!(old, "旧标题");
title.set(String::from("最终标题"));
assert_eq!(title.take(), "最终标题");
assert_eq!(title.into_inner(), "");
}

能力组合:Rc<RefCell> 与 Arc<Mutex>

一个编辑器与一个统计视图确实需要独立持有任务列表时,可组合共享归属与动态修改能力。如果可以找到自然的唯一拥有者,传递 &mut 通常更简单。

独立示例(仅标准库;src/main.rs,cargo +stable run):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::cell::RefCell;
use std::rc::Rc;

struct Task {
title: String,
started_at: Option<u64>,
}

fn main() {
let shared = Rc::new(RefCell::new(vec![Task {
title: "写发布说明".to_string(),
started_at: None,
}]));
let viewer = Rc::clone(&shared);
let editor = Rc::clone(&shared);
assert_eq!(Rc::strong_count(&shared), 3);
editor.borrow_mut()[0].started_at = Some(900);
assert_eq!(viewer.borrow()[0].started_at, Some(900));
drop(editor);
drop(viewer);
assert_eq!(Rc::strong_count(&shared), 1);
assert_eq!(shared.borrow()[0].title, "写发布说明");
}

跨线程共享修改通常组合 Arc<Mutex<T>>,并要求内部 T 满足对应的 Send 条件。锁保护访问,Arc 保护共享归属,两者不能互相替代。短临界区的标准库锁不要跨 await;确实需要等待期间持锁时,另行评估异步锁与死锁风险,第 18 章展开。

深入阅读:Deref 与自动解引用

引用强制转换与方法查找是两种机制

在函数实参、带类型标注的绑定等转换点,&Box<String> 可以通过 Deref 强制转换成 &str;转换不移动 String,也不复制其字节。本例的标准库 Deref 实现只是取得内部引用,自定义 Deref 仍可能执行代码,不能概括成任意 Deref 都没有计算成本。

独立示例(仅标准库;src/main.rs,cargo +stable run):

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::new(String::from("发布 1.0"));
assert_eq!(byte_len(&boxed), 10);
let text: &str = &boxed;
assert_eq!(text.len(), 10);
let double: &&Box<String> = &&boxed;
assert_eq!(byte_len(double), 10);
}

&[T; N] 到 &[T] 是 unsize 强制转换,不是 Deref。引用强制转换也不会反过来自动分配 String。

故意失败:编译错误(仅标准库;独立 src/main.rs。预期类型不匹配,不是生命周期错误):

1
2
3
4
fn main() {
let owned = String::from("发布 1.0");
let _text: &String = owned.as_str();
}

方法调用会按规则构造自动解引用及借用候选,再查找适用方法;外层方法可以先匹配。它不是继承,也不承诺一个通用的固定自动解引用层数。遇到递归上限应检查类型设计,而非依赖某个编译器默认数字。

Option 不会自动穿透,trait bound 也不会转发

Option<Box<TaskNode>> 没有 Deref 到 TaskNode,因此不能直接访问 .title。as_deref 借用 Option 内的 Box,再把其目标引用映射为 Option<&TaskNode>;本章 Box 链已经演示这条路径。

故意失败:编译错误(仅标准库;独立 src/main.rs。Option 没有 title 字段):

1
2
3
4
5
6
7
8
struct TaskNode {
title: String,
}

fn main() {
let node = Some(Box::new(TaskNode { title: "发布".into() }));
let _title = node.title;
}

Deref 也不会让泛型 trait 约束自动成立。下面 Wrapper 可以调用 String 的 len 方法,但自身仍没有 Display 实现。newtype 不应仅为省略 .0 就暴露底层全部可见方法。

故意失败:编译错误(仅标准库;独立 src/main.rs。Wrapper 未实现 Display):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use std::fmt::Display;
use std::ops::Deref;

struct Wrapper(String);

impl Deref for Wrapper {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}

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

fn main() {
let value = Wrapper("发布".into());
assert_eq!(value.len(), 6);
show(value);
}

Box/Rc 的 Display 等转发来自标准库对相应 trait 的实现,而不是 Deref 自动推导。

自测

  1. Box、Rc、Arc的跨线程条件有什么不同?
  2. 为什么 RefCell 的两次可变借用能编译,却可能运行期 panic?
  3. Cell不能 get,是否意味着它不能存 String?如何换出旧值?
  4. Rc<RefCell> 分别增加了什么能力,何时应保留普通所有权?
  5. 为什么 Option<Box> 需要 as_deref,Wrapper 的 Deref 又不能满足 Display?

参考答案

  1. Box 分别随 T: Send/T: Sync 实现对应能力;Rc 不 Send/Sync;通常 Arc 跨线程共享要求 T: Send + Sync,原子计数不保证 T 本身线程安全。
  2. guard 重叠违反运行期多读或一写规则,第二次 borrow_mut 检查失败;显式缩短 guard 作用域可避免本例冲突。
  3. 可以存。get 要求 Copy;replace 返回旧 String,take 要求 Default,into_inner 消费 Cell。
  4. Rc 增加独立共享句柄,RefCell 增加共享引用下的动态借用。能找到自然唯一拥有者时优先传引用。
  5. Option 不 Deref 到内部目标,as_deref 显式在容器内部映射;方法查找可解引用,不等于外层类型实现了 trait。