← Rust:从入门到精通
全栈开发Rust:从入门到精通

智能指针:每一条「不」背后,都有一扇明码标价的门

Rust 从入门到精通系列第 6 篇:Box 为什么不是逃生舱而是递归类型的入场券、Rc 如何用计数共享所有权、RefCell 怎样把借用检查挪到运行期、Rc<RefCell<T>> 这个经典组合各自解决什么,以及每笔交易的价码与多线程亲戚。

RustSmart PointersBoxRcRefCellInterior Mutability

到这一篇为止,你遇到的借用检查器都在编译期执法,而你迟早会写出它表达不了的结构:一个双向链表、一张图、一个被八个组件共享的缓存。编译器照旧说“不”——但它从没说过“永远”。标准库在每一条“不”的背后都留了一扇门,门上挂着价签:有的把数据搬个家,有的用计数换共享,有的把检查从编译期挪到运行期、用 panic 换灵活。本篇就是这三扇门的说明书:BoxRcRefCell——它们是什么、各自放松哪条规则、价码是多少,以及为什么你大多数时候应该走最便宜的那扇。

Box<T>:不是逃生舱,是入场券

先纠正一个流传最广的误会:Box<T> 不放松任何所有权规则。它就是一个指向堆上数据的指针,单所有者、编译期检查、离开作用域连数据一起清理——第 2 篇的每一条规则原封不动。它解决的问题完全不同:让一个值有已知的大小

试试不用 Box 写一个链表:

enum BadList {
    Cons(i32, BadList),  // error[E0072]: recursive type has infinite size
    Nil,
}

一个 Cons 里装着一个 BadList,里面又装着一个 BadList——编译器算不出这个类型该占多少字节,因为答案是“无穷”。Box 把这个无穷截断成一个指针的大小:

enum List {
    Cons(i32, Box<List>),   // Box 占 8 字节,大小已知,类型合法
    Nil,
}

use List::{Cons, Nil};

let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
let mut total = 0;
let mut cursor = &list;
while let Cons(value, next) = cursor {
    total += value;
    cursor = next;      // &Box<List> 自动解引用成 &List
}
println!("sum: {total}");  // 6

注意遍历里那行 cursor = nextnext 的类型是 &Box<List>,被静默当成 &List 用——这是第 5 篇埋下的机制在工作。Box 实现了 Deref trait,所以在所有期望引用的地方,它的行为就是引用;实现了 Drop,所以所有者离开时堆上的数据跟着死亡。智能指针的“智能”两个字,全部来自这两个 trait——它们不是新语言特性,是你已经认识的合同。

Box 的价码:一次堆分配,加一层间接。三个正当用途:递归类型、把大数据的所有权转来转去而不搬动数据本身、装 trait 对象(Box<dyn Error>,第 7 篇细讲)。它能解决的事到此为止——它不共享,也不让你绕过可变性规则。

Rc<T>:共享所有权,检查仍在编译期

有些数据结构里,“谁是唯一所有者”这个问题本身就没有答案:一份配置被十个组件持有,一个图节点被三条边指向。编译期说不准谁最后用完,而“说不准”恰恰是编译器拒绝的理由。Rc<T>(reference counted)的回答是:那就不指定所有者——数着就行。

use std::rc::Rc;

let config = Rc::new(String::from("production"));
let for_handler = Rc::clone(&config);   // 计数 2——没有复制任何数据
let for_logger = Rc::clone(&config);    // 计数 3
println!("count = {}", Rc::strong_count(&config)); // 3
drop(for_handler);
println!("count = {}", Rc::strong_count(&config)); // 2

三个性质按重要性排:

  1. Rc::clone 克隆的是句柄,不是数据。Rc::clone(&config) 而不是 config.clone() 是社区惯例——前者一眼看出“只是计数加一”,后者会让人担心发生了深拷贝。这个命名约定是写给三个月后读代码的你。
  2. 数据随最后一个句柄一起死。 没有所有者,只有计数归零时刻。规则被放松了,但清理依然是确定性的——Drop 照常运行。
  3. Rc 诚实地拒绝跨线程。 它的计数不是原子的,所以 Rc 不实现 Send——试图把它发给 std::thread::spawn编译错误。多线程版本叫 Arc(atomic Rc),第 9 篇见。这不是缺陷,是这个系列反复出现的那个主题:把真相编码进类型,让错误停在构建期。

两个价码要知道:计数本身有开销(每次克隆和丢弃都要改动它);而 Rc 环——A 持有 B、B 持有 A——会让计数永远到不了零,内存静静泄漏。解法是 Weak<T>:一种不增加强计数的“弱”句柄,父子结构里让孩子的回指用 Weak。知道它存在就够,需要时文档会接住你。

还有一条规则 Rc 从头到尾没有碰:共享引用照旧只读。 十个句柄指向同一份数据,十份都是共享借用——第 2 篇的读者-写者规则仍在编译期站岗。想改数据?那是下一扇门。

RefCell<T>:把借用检查挪到运行期

RefCell<T> 做的事一句话说完:第 2 篇那条“任意多读者 XOR 一个写者”的规则,原封不动,但执法时间从编译期改到运行期。 这叫内部可变性(interior mutability)——值本身声称不可变(let 而非 let mut),内部却允许你申请可变访问:

use std::cell::RefCell;

let log: RefCell<Vec<String>> = RefCell::new(Vec::new());
log.borrow_mut().push("started".into());  // 运行期批准的可变借用
{
    let snapshot = log.borrow();          // 共享借用照常共存
    println!("{} entries", snapshot.len());
    // log.borrow_mut();                  // 这里会 PANIC:已被可变借用
}                                          // snapshot 在此死亡
log.borrow_mut().push("stopped".into());  // 又可以了

borrow()borrow_mut() 返回的守卫对象(Ref / RefMut)同样靠 Drop 在作用域结束时归还“借用名额”。而违规的代价换了币种:编译错误变成 panic。 borrow_mut() 撞上存活的 borrow(),线程立刻 already mutably borrowed: BorrowMutError 而死。规则没有消失——消失的是编译器的提前证明,换来的是“编译器证明不了、但我知道安全”的那类代码可以写出来。如果你的旧语言本来就在运行期检查一切,这听起来稀松平常;区别在于,在 Rust 里这是你明确选择的一笔交易,而不是默认税。

Rc 一样,RefCell 只活在单线程——跨线程的对应物是 Mutex / RwLock,同样留给第 9 篇。而两个单线程工具叠起来,就是 Rust 生态里出现频率极高的经典组合:

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Counter { hits: RefCell<u32> }

let counter = Rc::new(Counter { hits: RefCell::new(0) });
let handler_view = Rc::clone(&counter);
let logger_view = Rc::clone(&counter);
*handler_view.hits.borrow_mut() += 1;   // 经一个共享句柄修改
*logger_view.hits.borrow_mut() += 1;    // 经另一个共享句柄修改
println!("{counter:?}");                // Counter { hits: RefCell { value: 2 } }

分工精确:Rc 负责“很多地方能持有”,RefCell 负责“持有后还能改”。Rc<RefCell<T>> 就是其他语言里“一个普通对象引用”的 Rust 等价物——区别是每个能力都在类型上明码标价,读者一眼看全。GUI 状态、测试替身、回调注册表里,你会反复见到它。

价目表:规则从不是免费的,但可以挑便宜的

三扇门按“应该先想到谁”排序,恰好也是按价格排序:

  • Box<T>——放松了零条规则,买了一张堆上的入场券。价格:一次分配。递归类型、大值转移、trait 对象,想到它不需要理由。
  • Rc<T>——放松了“单一所有者”,买了共享所有权。价格:计数开销、单线程限定、循环引用泄漏风险(Weak 可解)。
  • RefCell<T>——放松了“编译期借用检查”,买了运行期的灵活。价格:违规即 panic,错误从构建期推迟到测试(或更糟,生产)。

工程判断浓缩成一句:能用编译期证明的,不要花钱挪到运行期。Box 问起——“我真的需要共享吗?“不需要,Box 就是终点;需要共享但不需要改,Rc 加不可变数据常常比想象走得远;只有”共享且必须改“同时成立时,Rc<RefCell<T>> 才登场。借用检查器的每一条”不“,背后那扇门的钥匙你都有了——而知道门价的人,才会珍惜门前那些免费的路。

练习,以及第 7 篇

  1. 把上面的 List 补完:写一个递归的 sum(&self) -> i32,再写一个 append(self, v: i32) -> List。然后把 Box 去掉,完整读一遍 E0072——“infinite size”这四个字从此对你有具体的形状。
  2. Rc 演练:共享一个 Rc<String>,在 clone 和 drop 前后各打印一次 strong_count,观察计数的呼吸。然后把它发给 std::thread::spawn,读编译器的拒绝——那句“不是 Send“就是单线程诚实的化身,也是第 9 篇的入场预告。
  3. Rc<RefCell<Vec<String>>> 实现一个共享日志:两个假装是“组件”的句柄都能追加和读取。然后故意让 borrow_mut()borrow() 的生命期重叠,完整读 panic 信息——注意它精确告诉你第一次借用发生在哪一行。运行期的执法者,口供依然清晰。

第 7 篇把“任何签署了合同的东西”这个说法展开成完整语法:泛型与 trait bound、impl Trait 在参数和返回位置的两个方向、静态分发与 dyn 动态分发的取舍,以及 2024 edition 里 + use<...> 修好的返回位置生命周期捕获——第 2 篇欠下的细节,到时候连本带利偿还。

guest@swangnice:~$