卷 IV · 当一个所有者不够CH 15深度 15/24

共享:Rc、RefCell 与内部可变性

「一个所有者」的铁律有它管不了的场景:一棵树的节点被父节点和好几个子节点共同引用、一个观察者列表里的对象被多处持有。这时你需要「逃生口」。Rust 诚实地给了你几个 —— 但它们不是免费的,也不是让你逃避所有权,而是把某些保证从编译期挪到运行期,让你用「可能 panic」换「更灵活」。这一章讲清代价,也讲清什么时候该掏出它们。

RcRefCell内部可变性运行时借用

先承认:有时候一个主人真的不够

卷 II 的规则是「一个值只有一个所有者」。绝大多数代码这样就够了,而且更清晰。但确实有些数据结构,本质上就需要多个持有者 —— 最典型的是图和树里的共享节点:一个节点被多条边指着,你没法说它「归」哪一条边独有。硬套单一所有权,会写得极其别扭。

Rust 不假装这种需求不存在。它给你 Rc<T>(Reference Counted,引用计数):

use std::rc::Rc;

let a = Rc::new(String::from("共享的一本书"));
let b = Rc::clone(&a);      // 不是深拷贝!只是「多一个持有者」,计数 +1
let c = Rc::clone(&a);      // 计数 +1,现在 3 个人持有同一本书

println!("持有者数量:{}", Rc::strong_count(&a));   // 3

Rc::clone 不复制堆上的数据,它只是把引用计数加一 —— 又多一个人登记为持有者。每当一个 Rc 离开作用域,计数减一。计数归零的那一刻,堆上的值才被真正释放。这就是「多个所有者」:谁都不独占,最后一个走的人负责关灯。

但 Rc 只给你「共享读」

这里有个卡点。Rc<T> 给你的是共享的、只读的访问 —— 因为如果好几个人都持有它、又都能改它,那不就正好违反了「共享 XOR 可变」的铁律吗?所以默认你不能通过 Rc 改里面的值。

可现实里你往往就是既要共享、又要改(树节点要更新、观察者状态要变)。怎么办?这就要请出第二件工具,以及它背后那个精妙的主意 ——内部可变性

内部可变性:把借用检查搬到运行时

RefCell<T> 干了一件听起来有点犯规的事:它让你通过一个共享引用去修改内部数据。它怎么还能保证安全?答案是 —— 它没有放弃「多读 XOR 一写」这条规则,只是把检查的时机从编译期推迟到了运行期

  • cell.borrow() —— 要一个共享读引用。运行时记一笔「多了个读者」。
  • cell.borrow_mut() —— 要一个可变写引用。运行时检查:此刻有没有别的读者或写者?有 → 当场 panicBorrowMutError)。

换句话说,RefCell 是一个运行时的借用检查器。编译期那台(第 7 章)静态地证明你不违规;RefCell 这台,则在每次 borrow 时动态地数一数、违规就崩。规则一模一样,只是从「编译不过」变成了「一跑就 panic」。

▶ 亲手让它 panic

下面这台 demo 就是 Rc<RefCell<T>> 的运行时状态:Rc::clone 加计数,borrow / borrow_mut 数读者写者。试试先 borrow()borrow_mut() —— 编译期这台 demo 拦不住你,但一执行就 panic。这正是「把保证挪到运行时」的代价,眼见为实。

两者合体 Rc<RefCell<T>> 是最常见的组合,它的含义可以逐层读出来:Rc 提供「多个持有者」,RefCell 提供「运行时检查的可变性」 —— 加起来就是「一个能被多处共享、又能被修改的值」。(还有个轻量版 Cell<T>,专给 Copy 类型用,直接 get/set,连借用都不用记。)

这是「逃生口」,用它要清醒

◆ 你到底在拿什么换什么

Rc<RefCell<T>>,你放弃的是:编译期的静态保证(借用错误现在会到运行时才 panic)、一点性能(计数和借用标记有开销)。你换来的是:多所有权 + 共享可变的灵活性。

这是个值得的交易,但要清醒地做 —— 别把它当成「借用检查器烦我,我用它绕过去」的万金油。每次伸手拿它之前,先问:「我真的需要多个所有者吗?还是只是没理顺借用?」(回想第 7 章的四步修法,前两步能解决绝大多数「看起来需要共享」的情况。)

⚠ Rc 会漏内存:循环引用

引用计数有个经典弱点:循环引用。如果 A 持有 B、B 又持有 A(比如树节点的子指回父),它俩的计数永远不会归零 —— 谁也等不到对方先走,于是内存泄漏。Rust 的解法是 Weak<T>:一种不增加计数的「弱引用」,用它来打破环(典型:子节点用 Rc 指向父,父用 Weak 指向子,或反过来)。这是用 Rc 建带环结构时必须知道的一课 —— 否则你会造出 Rust 里少见的、真正的内存泄漏。

回流

⟲ 回流 · 别的语言把「逃生口」设成了默认

Java / Kotlin / Python / JS:值得停下来想一件事 —— 这些语言里,每一个对象都是 Rc<RefCell<T>>对象默认在堆上、默认被多处共享(引用语义)、默认可变、由 GC 管生命周期(相当于一个更聪明的引用计数)。也就是说,你在这些语言里天天在用的、习以为常的对象模型,正是 Rust 特意标成「逃生口」、要你显式选用的东西。

区别在于两点:一,Rust 让这个选择显式且可见(你写出了 Rc<RefCell>,读代码的人一眼知道「这里有共享可变」),而别的语言把它藏成了默认,于是那些「A 改了 B 没料到」的别名 bug 防不胜防。二,Rust 逼你只在真需要时付这份成本,其余时候用零成本的单一所有权。那个让你觉得 Rust「啰嗦」的 Rc<RefCell>,其实是你在别处一直在无意识地、全局地付着的账 —— Rust 只是把账单摊开给你看了。

C++:shared_ptr<T> 就是 Rc(多线程版对应 Arc),也有同样的循环引用泄漏问题,解法也一样叫 weak_ptr(对应 Weak)。这套引用计数的智慧,两门语言是相通的。

这一章的一句话

Rc 让一个值有多个持有者(归零才释放),RefCell 把「多读 XOR 一写」搬到运行时来查(违反就 panic)—— 合起来是「共享 + 可变」的逃生口。它值得用,但要清醒:你是在拿编译期保证换灵活性,还要提防循环引用漏内存。

RcRefCellBox…… 这些都被叫做「智能指针」。下一章我们看最基础的那个 —— Box,并借它讲清两个一直在你背后默默工作的 trait:让 * 生效的 Deref,和让 drop 生效的 Drop