阶段二 · 智能指针

Rc:共享所有权

一句话总结

Rc(Reference Counted)突破了所有权"唯一所有者"的限制, 允许多个变量共同拥有同一份数据.
它内部维护一个引用计数: 每 clone 一次计数加一, 每个 Rc 销毁时计数减一, 归零时才释放数据.
代价是: 它只能用于单线程, 且共享的数据默认不可变.

所有权的"唯一所有者"撞墙了

到目前为止, 所有权规则要求每份数据有且只有一个所有者. 这在大多数时候很好, 但有些数据结构天然需要"被多个地方共同拥有". 最经典的例子是图或共享列表:

// 设想: 两个列表 b 和 c 都想以 a 为尾部(共享同一段数据)
// b → 3 ┐
// ├→ a(5 → 10 → Nil)
// c → 4 ┘

Box 做不到--因为 Box 是唯一所有权, a 一旦被移进 b, 就不能再移进 c:

let a = Box::new(/* ... */);
let b = Cons(3, a); // a 的所有权移入 b
let c = Cons(4, a); // 错误! a 已经被移走了

这里"谁该是 a 的唯一所有者"根本没有答案--bc 都需要它. 所有权模型在这类共享结构面前, 需要一个"放宽限制"的工具.

单一所有权像"一件东西只能登记在一个人名下". 可现实中有些东西本就该共有--像一份共享文档的所有权.
Rc 就是 Rust 的"共有产权登记": 多个主人共同持有, 直到最后一个主人离开, 东西才被处置.

Rc: 引用计数的共享所有权

Rc<T> 让多个所有者共享数据. "克隆"一个 Rc 不复制底层数据, 只是增加引用计数并得到一个新的指向同一数据的指针:

use std::rc::Rc;

let a = Rc::new(String::from("shared")); // 计数 = 1
let b = Rc::clone(&a); // 计数 = 2, b 和 a 指向同一数据
let c = Rc::clone(&a); // 计数 = 3

println!("计数: {}", Rc::strong_count(&a)); // 3
// a, b, c 共享同一个 String, 没有发生数据复制

数据在引用计数归零时才被释放. 只要还有任何一个 Rc 活着, 数据就不会被清理--这正好满足"多个所有者"的需求.

Rc::clone 不是深拷贝, 别怕它贵

Rc::clone(&a) 只是把引用计数加一, 复制一个指针, 极廉价, 绝不会复制底层数据.

社区惯例特意写成 Rc::clone(&a) 而非 a.clone(), 就是为了在视觉上和"深拷贝的 clone"区分开--看到 Rc::clone 就知道"这只是加个计数".

关键限制: 共享的数据不可变

这是 Rc 最重要的约束, 也直接呼应借用规则. 既然多个 Rc 同时持有数据(等于多个共享引用同时存在), 根据"共享不可变"--Rc 只给不可变访问:

let a = Rc::new(String::from("hi"));
let b = Rc::clone(&a);
// a.push_str("!"); // 错误! 不能通过 Rc 修改数据
println!("{a} {b}"); // 只能读

为什么必须不可变

设想若允许通过 Rc 修改: ab 指向同一数据, 通过 a 改它, b 那边就会看到"数据在脚下变了"--这正是借用规则要杜绝的"共享 + 可变". 所以 Rc 坚持只读.

但"既想共享又想改"是真实需求--下一篇的 RefCell 就是来解决这个矛盾的. 记住这个伏笔: Rc 给共享, RefCell 给可变, 二者常组合.

第二个限制: 仅限单线程

Rc 的引用计数增减不是原子操作, 多线程同时改计数会产生数据竞争. 因此 Rc 被设计为不能跨线程(它没实现 Send/Sync, 第 09 篇详谈). 多线程下的共享所有权要用它的线程安全版本 Arc(Atomic Rc, 第 10 篇):

Rc<T> Arc<T>
共享所有权
计数操作 普通(快) 原子(略慢但线程安全)
可跨线程

设计思想: 不为不用的安全付费

为什么不让 Rc 直接用原子计数, 一劳永逸? 因为原子操作有性能开销.

Rust 的哲学是"不为不需要的东西付费": 单线程场景占多数, 就给它零开销的 Rc; 需要跨线程时, 显式选 Arc 并接受那点原子开销. 把选择权和成本都摆在明面上--这正是 Rust 把 Go 隐藏在运行时里的取舍暴露给程序员的又一例.

快速回顾

  • 解决的问题: 突破"唯一所有者"限制, 让多个变量共同拥有同一份数据(如共享列表, 图).
  • 机制: 引用计数, Rc::clone 加一, 销毁减一, 归零才释放; clone 极廉价, 非深拷贝.
  • 限制一: 共享数据默认不可变(遵守"共享不可变"); 要可变需配 RefCell(下一篇).
  • 限制二: 计数非原子, 仅限单线程; 跨线程用 Arc.
  • 设计思想: Rc/Arc 分立体现"不为不用的安全付费"--单线程零开销, 跨线程才付原子成本.

动手练习

  1. 下面代码为什么编译不过? 用 Rc 修复, 使 b, c 能共享 a.

    let a = Box::new(5);
    let b = vec![a];
    let c = vec![a];
参考答案

Box 是唯一所有权, a 被移进 b 后就不能再移进 c. 改用 Rc 共享:

use std::rc::Rc;
let a = Rc::new(5);
let b = vec![Rc::clone(&a)];
let c = vec![Rc::clone(&a)]; // OK, 计数变为 3
  1. 预测这段代码各处的引用计数.

    use std::rc::Rc;
    let a = Rc::new(1); // (A)
    let b = Rc::clone(&a); // (B)
    {
    let c = Rc::clone(&a); // (C)
    println!("{}", Rc::strong_count(&a)); // 此处?
    } // (D)
    println!("{}", Rc::strong_count(&a)); // 此处?
参考答案

第一个打印 3(a, b, c 三个 Rc 都活着). 内层作用域结束, c 被 drop, 计数减一. 第二个打印 2(只剩 a, b).

引用计数随 Rc 的创建与销毁动态增减, 归零时数据才释放.

  1. 为什么下面会报错? 这反映了 Rc 的什么限制?

    use std::rc::Rc;
    let a = Rc::new(String::from("hi"));
    a.push_str("!");
参考答案

报错: 不能通过 Rc 获得可变访问. Rc 允许多个所有者共享, 等于多个共享引用并存, 遵守"共享不可变", 所以只给只读访问, push_str(需要 &mut)被拒.

要既共享又可变, 需要把数据包成 Rc<RefCell<T>>(下一篇).

  1. 什么时候该用 Rc, 什么时候该用 Arc? 为什么不统一用 Arc?
参考答案

单线程内共享用 Rc, 需要跨线程共享用 Arc. 不统一用 Arc, 是因为 Arc 的引用计数是原子操作, 有性能开销; 单线程场景不需要这种线程安全, 用零开销的 Rc 更合适.

这体现 Rust "不为不用的东西付费"的设计哲学--把成本和选择权交给程序员.