到这一篇为止,你遇到的借用检查器都在编译期执法,而你迟早会写出它表达不了的结构:一个双向链表、一张图、一个被八个组件共享的缓存。编译器照旧说“不”——但它从没说过“永远”。标准库在每一条“不”的背后都留了一扇门,门上挂着价签:有的把数据搬个家,有的用计数换共享,有的把检查从编译期挪到运行期、用 panic 换灵活。本篇就是这三扇门的说明书:Box、Rc、RefCell——它们是什么、各自放松哪条规则、价码是多少,以及为什么你大多数时候应该走最便宜的那扇。
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 = next:next 的类型是 &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
三个性质按重要性排:
Rc::clone克隆的是句柄,不是数据。 写Rc::clone(&config)而不是config.clone()是社区惯例——前者一眼看出“只是计数加一”,后者会让人担心发生了深拷贝。这个命名约定是写给三个月后读代码的你。- 数据随最后一个句柄一起死。 没有所有者,只有计数归零时刻。规则被放松了,但清理依然是确定性的——
Drop照常运行。 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 篇
- 把上面的
List补完:写一个递归的sum(&self) -> i32,再写一个append(self, v: i32) -> List。然后把Box去掉,完整读一遍 E0072——“infinite size”这四个字从此对你有具体的形状。 - 做
Rc演练:共享一个Rc<String>,在 clone 和 drop 前后各打印一次strong_count,观察计数的呼吸。然后把它发给std::thread::spawn,读编译器的拒绝——那句“不是Send“就是单线程诚实的化身,也是第 9 篇的入场预告。 - 用
Rc<RefCell<Vec<String>>>实现一个共享日志:两个假装是“组件”的句柄都能追加和读取。然后故意让borrow_mut()与borrow()的生命期重叠,完整读 panic 信息——注意它精确告诉你第一次借用发生在哪一行。运行期的执法者,口供依然清晰。
第 7 篇把“任何签署了合同的东西”这个说法展开成完整语法:泛型与 trait bound、impl Trait 在参数和返回位置的两个方向、静态分发与 dyn 动态分发的取舍,以及 2024 edition 里 + use<...> 修好的返回位置生命周期捕获——第 2 篇欠下的细节,到时候连本带利偿还。