用 Box 打破递归类型的大小无限,用 Rc 实现共享所有权、用 RefCell 把借用检查挪到运行时,并说清 Rc<RefCell> 为什么能同时表达多个所有者与可修改
前面有一条规则被反复强调:同一时刻只能有一个所有者。它堵住的是 double free——两个所有者各释放一次,内存就损坏了。但现实里总有些结构天然是"多处指向同一份数据":链表的每个节点被前一个节点指着,图里的节点互相引用,一份配置要被多个模块同时读写。
Rust 的做法不是放宽规则,而是提供几种受控的例外。它们的共同名字叫智能指针(smart pointer):本质上都是结构体,但行为像指针——可以直接当引用用(靠Deref),其中Box和Rc还会在离开作用域时自动释放持有的数据。
这一篇依次看三个:Box、Rc、RefCell。
Box:把"无限大"变成"一个地址"
第一个问题跟共享无关,是大小。看这个试图描述链表的枚举:
enumList{Cons(i32,List),Nil,}编译直接报 E0072:recursive typeListhas infinite size。原因要看编译器怎么算大小:它从Cons开始,这个变体需要一块i32加上一块List;那List多大?去看变体,Cons需要一块i32加一块List……这个追问永远不结束。
麻烦在于 Rust 的类型大小必须在编译期确定——值要按大小摆在栈上,字段要按大小排布,所有权转移时要按大小搬运。一个大小无限的类型没法参与这些事。
修法是加一层间接(indirection):不直接存值,改成存一个指向值的指针。
enumList{Cons(i32,Box<List>),Nil,}Box把T放到堆上,而Box本身就是一个指针——指针大小是固定的(64 位下 8 字节),和它指向多少数据无关。于是List的大小 = 一个i32加一个指针,再加上一点点空间记"这次是哪个变体",算得出来。概念上仍然是"列表里装着列表",但实现从"嵌在彼此里面"变成了"摆在彼此旁边"。
Box还有两个用途:把一个很大的值整体搬走时,移动的只是指针,而不是把那份数据复制一遍;以及Box——动态分发那篇里你已经见过,那里也是同一条规律:大小未知的类型必须待在指针后面。
Rc:多个所有者,靠数数决定何时释放
Box解决了大小,但每个Box里的值仍然只有一个所有者。回到之前提到的那个难题:既然两个变量共享同一块堆内存就会 double free,所有权规则就只能禁止共享——怎么破?
Rc(reference counted,引用计数)的做法是在堆上多存一个计数器:
usestd::rc::Rc;leta=Rc::new(String::from("hi"));letb=Rc::clone(&a);// 计数变成 2,里面的 String 没有被复制letc=Rc::clone(&a);// 计数变成 3println!("{}",Rc::strong_count(&a));// 3关键在Rc::clone不复制里面的数据,它只把计数加一,然后返回另一个指向同一块分配的值。只有计数归零、最后一个 Rc 离开作用域时,数据才被释放。所以"两个所有者各释放一次"这种事故不会发生——释放只发生一次,由最后一个离开的人执行。这不是取消了"谁负责释放"这个问题,而是把释放时机交给计数来决定。
代价有两条:
第一,Rc 只给出不可变借用。标准库std::rc的文档说得很直接:你一般无法从Rc里拿到可变引用。理由正是借用规则本身——多个所有者同时存在时,只要任何一个能改,你要么得放宽可变借用的独占性(编译器不允许),要么得让编译器在编译期就知道此刻有几个所有者(它做不到)。想改,需要下一个工具——RefCell。
第二,Rc 不能跨线程。原因是它的计数器是非原子的。clone里那个加一就是一次普通的读—改—写;如果两个线程同时对同一个计数器加一,两次操作可能互相覆盖,结果少算一次——计数提前归零,就会释放掉还在用的内存。改成原子操作能解决,但每次加一都要付同步代价。所以标准库把它拆成两个类型:Rc用非原子计数,Arc用原子计数。
由此推出一个很实用的结论:Rc不实现 Send(Send是表示"这个值可以安全地移交到另一个线程"的标记 trait)。所以"把Rc拿到别的线程去用"不是运行时才出错,而是根本编译不过。这就是"Rc 不是线程安全"这句话的确切含义——它由类型系统在编译期拦住,而不是留给你去记。
RefCell:把借用检查挪到运行时
Rc给了共享,只差可变性。缺口出在借用规则上:可变借用必须独占,而Rc天生就是多个。编译器要判断"此刻只有这一处拿着可变引用",就必须知道当前有几个Rc存在——而这个数字是运行期才确定的。
RefCell的应对方式是把这道检查从编译期挪到运行时。它在内部记一个借用状态,提供两个方法:
usestd::cell::RefCell;letc=RefCell::new(5);letm=c.borrow_mut();// 可变借用letb=c.borrow();// 试图再借 → panic: already borrowed: BorrowMutErrorborrow和borrow_mut分别对应&和&mut:前者允许多个共存,后者独占,规则和编译期那条一模一样——多个不可变,或者一个可变,不能混。
区别只在于违反时得到的是运行时 panic,而不是编译错误。返回的Ref和RefMut都实现了Deref,用起来和普通引用一样。
由此得到RefCell最值得记住的性质:即使你手里只有 &RefCell,也能修改里面的数据。
但这在编译器看来是自相矛盾的——共享引用默认只读——所以这个模式有专门的名字:内部可变性(interior mutability),意思是"从外面看不可变,但它能在内部改自己"。
代价也要说清楚:错误从编译期推迟到了运行期,可能上线后才发现;每次借用还有运行时记账的开销。标准库为此提供了不 panic 的版本try_borrow/try_borrow_mut,返回Result——正好用上错误处理那篇的用法。
顺带一个能加深理解的细节:Rc::clone的参数是&self,但它必须修改计数器——这本身就是内部可变性的一个例子。
Rc<RefCell>:把两个需求叠起来
现在拼装就顺理成章了:
- Rc给你多个所有者,但只读;
- RefCell给你可变性,但它自己仍然只有一个所有者;
- Rc<RefCell>内外各取一半,"多个所有者 + 可修改"就同时具备了。
letshared=Rc::new(RefCell::new(String::from("hi")));leta=Rc::clone(&shared);letb=Rc::clone(&shared);a.borrow_mut().push_str("!");// a 修改println!("{}",b.borrow());// b 看到 "hi!"注意这里的两层分工:外层Rc负责让a和b都指向同一份数据,内层RefCell负责允许修改。可以a.borrow_mut()是因为Deref让Rc<RefCell>能直接调到RefCell的方法——你在结构体那篇见过的自动解引用,这里再次上场。
用法上有一条纪律值得现在就养成:borrow_mut() 的结果要放进一个小块里及时用掉。因为它一旦和别的借用重叠就是 panic。标准库文档里的写法是给每次borrow_mut套一对花括号,让RefMut尽快离开作用域,再去做下一次借用。
还有两个边界需要知道。一是这整套组合仍然受"Rc不能跨线程"的约束,Rc<RefCell>一样不实现Send。二是Rc的计数只看强引用,如果两个值互相用Rc指向对方,计数永远归不了零,内存就泄漏了——这种循环要用Weak打断,属于更后面的话题。
四个类型放一起看
回到这一篇的起点,把账摆清楚:
| 类型 | 所有权 | 可变性检查 | 线程 |
|---|---|---|---|
| Box | 单一所有者 | 编译期 | 可以(看 T) |
| Rc | 多个所有者 | 只读,编译期 | 不行 |
| RefCell | 单一所有者 | 运行期 | 不行 |
| Rc<RefCell> | 多个所有者 | 运行期 | 不行 |
三个工具各自解决一个具体缺口,没有一个是在放宽规则。
Box绕开的是"类型大小必须已知",Rc绕开的是"所有者只能有一个",RefCell绕开的是"借用检查必须在编译期完成"——而每绕开一条,都有一笔明确的账记在别处。
Box多一次堆分配和间接访问,Rc多一份计数器、丢掉可变借用和跨线程能力,RefCell把错误推向运行期。
Rust 的惯例在这里又一次成立:默认严格,需要时显式放开,而放开的代价写在类型上。
术语表
- 智能指针(smart pointer):本质是结构体,但可以直接当引用使用,其中Box和Rc还会在离开作用域时自动释放持有的数据。
- 间接层(indirection):不直接存值,而存一个指向值的指针,用来打破递归类型的大小无限问题。
- 引用计数:为堆上的数据配一个计数器,clone 时加一、离开作用域时减一,归零才释放,从而让一份数据可以有多位所有者。
- 非原子计数:不加同步的加一操作,多线程同时进行可能丢失更新,这是Rc不能跨线程的根本原因。
- 内部可变性(interior mutability):从外部看只有共享引用,却仍能修改内部数据;RefCell通过把借用检查挪到运行期来实现它。
- 运行时借用检查:借用规则不变,但由RefCell在运行期记账,违反时 panic 而不是编译失败。
- BorrowMutError:RefCell在同一作用域出现冲突借用时抛出的 panic 消息。
下一篇讲并发:Send、Sync这两个标记 trait 到底在管什么,以及为什么前面攒下的所有权与借用规则,在多线程里会兑现成"无畏并发"这份承诺。
参考来源
- The Rust Book 第 15.1 章「Using Box to Point to Data on the Heap」— cons list 例子、E0072 报错原文与 insert some indirection 提示、Box 大小固定的解释:https://doc.rust-lang.org/book/ch15-01-box.html
- E0072 错误页 — 递归类型必须让自身出现的位置落在指针之后,以及各字段大小的推导:https://doc.rust-lang.org/error_codes/E0072.html
- std::rc 模块文档 — Rc 只给出共享引用、非原子计数、不实现 Send、多线程需用 Arc、循环引用与 Weak:https://doc.rust-lang.org/std/rc/
- RefCell 官方文档 — borrow/borrow_mut 的语义、冲突借用时的 panic 与 try_borrow 系列变体:https://doc.rust-lang.org/stable/core/cell/struct.RefCell.html
- std::cell 模块文档 — 运行时借用检查的说明、RefCell 不实现 Sync、Rc 与 RefCell 的分工:https://doc.rust-lang.org/stable/std/cell/
- The Rust Book 第 15.5 章「RefCell and the Interior Mutability Pattern」— 内部可变性模式、RefMut 守卫的作用域写法、Rc<RefCell> 的组合用法与取舍:https://doc.rust-lang.org/book/ch15-05-interior-mutability.html