进阶:绕开借用检查器的正当手段
智能指针与内部可变性
它在解决什么
所有权那一章立下三条规则:一个值一个所有者、 要么多人读要么一人写、借用不能比被借的活得久。
但真实代码里有些结构天生违反它们 —— 图、观察者、树的父指针、共享缓存。 这一章讲的就是怎么正当地绕开,以及每种绕法的价格。
「智能指针」这个名字听着玄,其实就是实现了 Deref 和 Drop 的普通类型。
String 和 Vec<T> 本身就是智能指针。
Box<T>:最简单的一个
它只做一件事:把数据放到堆上,自己是个固定大小的指针。
不可替代的场景只有一个 —— 递归类型:
enum List {
Cons(i32, Box<List>), // 去掉 Box 就是 E0072
Nil,
}
编译器要算出 List 占多少字节,而它里面又装着一个 List,算不出来。
Box 是个指针,大小固定,递归就有了尽头。
📌 另外两个常见用途:装 dyn Trait(trait 对象大小不定),
以及「这个结构体特别大,别在栈上搬来搬去」。
Box<Point> 用起来几乎和 Point 一样,
靠的是 Deref —— 和 String 能当 &str 用是同一个机制。
⚠️ 但它只在引用上自动发生,整个传进去就是 E0308。
Rc<T>:多个所有者
它是「一个值一个所有者」那条规则的正当例外: 靠引用计数让多个名字共同拥有一份数据,最后一个走的时候才释放。
let a = Rc::new(vec![1, 2, 3]);
let b = Rc::clone(&a); // 计数 2,一个字节都没复制
drop(b); // 计数回到 1
⚠️ 写 Rc::clone(&a) 而不是 a.clone() —— 两者等价,
但前者一眼就能看出「这是一次计数加一,不是深拷贝」。
这是社区约定,不是语法要求。
🚨 但 Rc 里的东西改不了
想想也合理:如果多个所有者都能改,那就是数据竞争。
Rc 只给共享只读访问。
RefCell<T>:把借用检查挪到运行期
要「多个所有者 + 能改」,就得再套一层:
let a = Rc::new(RefCell::new(vec![1, 2, 3]));
let b = Rc::clone(&a);
a.borrow_mut().push(4);
// a 和 b 看到的是同一份:两边 len() 都是 4
实测就是这样 —— 改一边,另一边也变了。
代价:违规从编译错误变成 panic
这是整篇的重心。
RefCell 并没有取消借用规则。「要么多人读、要么一人写」照旧,
只是这条规则的检查从编译期挪到了运行期:
两次 borrow_mut() 重叠不再是编译错误,
而是 already borrowed 的 panic。
⇒ 代价说白了就是:编译器帮不了你了,出错要等到跑起来。
| 编译期检查 | 运行期检查 | |
|---|---|---|
| 谁 | 普通的 & / &mut |
RefCell |
| 违规时 | 编译不过 | panic |
| 开销 | 零 | 一个计数器 + 每次借用检查一次 |
📌 组合速查:
| 组合 | 含义 |
|---|---|
Box<T> |
一个所有者,数据在堆上 |
Rc<T> |
多个所有者,只读 |
RefCell<T> |
一个所有者,能在不可变引用后面改 |
Rc<RefCell<T>> |
多个所有者 + 能改(单线程) |
Arc<Mutex<T>> |
同上,但跨线程(见并发那篇) |
什么时候该用,什么时候是设计问题
⚠️ 看到 Rc<RefCell<T>> 先别急着用,它有相当一部分出现是因为
数据结构选错了,而不是真的需要共享可变。
⇒ 三个自问:
- 能不能改成传参? 很多「全局共享状态」其实只要把它当参数传下去。
- 能不能用索引代替指针? 图和树用
Vec<Node>+usize索引, 比Rc<RefCell<Node>>简单得多,也不会有循环引用。 - 真的需要多个所有者,还是只需要多个借用? 后者用
&就够了。
🚨 另外一个真实风险:Rc 会泄漏。两个 Rc 互相指着对方,
计数永远回不到 0,那块内存就再也不会被释放。
标准库给了 Weak<T> 来断开环 —— 父指向子用 Rc,子指回父用 Weak。
下一步
见《迭代器与闭包》:
那三个 Fn trait 到底是怎么被决定的,以及为什么「不 collect 就什么都不会发生」。
全部篇目见 Rust 教程首页。
本篇示例
下面每一条都是完整的、能单独编译的程序,由npm run test:rust 在每次构建前用真的 rustc 跑一遍。 「编译不过、报 E0382」这种话在这里是被验证过的断言。 报错原文和对照项的结果由同一道闸门自动回写,会随工具链更新,但不作为断言。
`Box` 的第一个刚需:递归类型
enum List {
Cons(i32, Box<List>),
Nil,
}
fn sum(l: &List) -> i32 {
match l {
List::Cons(v, rest) => v + sum(rest),
List::Nil => 0,
}
}
fn main() {
let l = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
println!("{}", sum(&l));
}编译通过 · 输出 "3\n"
对照:去掉 `Box`,直接放 `List`
enum List {
Cons(i32, List),
Nil,
}
fn sum(l: &List) -> i32 {
match l {
List::Cons(v, rest) => v + sum(rest),
List::Nil => 0,
}
}
fn main() {
let l = List::Cons(1, List::Cons(2, List::Nil));
println!("{}", sum(&l));
}编译不过:error[E0072]
对照项报 `E0072: recursive type has infinite size` —— 编译器要算出 `List` 占多少字节,而它里面又装着一个 `List`,算不出来。`Box` 是个**指针**,大小固定,于是递归就有了尽头。⇒ 这是 `Box` 最不可替代的用途;其它场景下它只是「把这块数据放堆上」。
`Rc`:让一份数据有多个所有者
use std::rc::Rc;
fn main() {
let a = Rc::new(vec![1, 2, 3]);
let b = Rc::clone(&a);
println!("{} {}", Rc::strong_count(&a), b.len());
drop(b);
println!("{}", Rc::strong_count(&a));
}编译通过 · 输出 "2 3\n1\n"
对照:不用 `Rc`,直接把 `a` 移动给 `b`
fn main() {
let a = vec![1, 2, 3];
let b = a;
println!("{} {}", a.len(), b.len());
}编译不过:error[E0382]
[所有权那一章](/rust/ownership/move/)的规则是「同一时刻只能有一个所有者」,而 `Rc` 是那条规则的**正当例外**:它靠引用计数让多个名字共同拥有一份数据,最后一个走的时候才释放。`drop(b)` 之后计数从 2 掉回 1,这一点是直接打印出来的。
🚨 `Rc` 里的东西改不了 —— 除非套一层 `RefCell`
use std::rc::Rc;
fn main() {
let a = Rc::new(vec![1, 2, 3]);
let b = Rc::clone(&a);
a.push(4);
println!("{} {}", a.len(), b.len());
}编译不过 · error[E0596]
rustc 原文
error[E0596]: cannot borrow data in an `Rc` as mutable
--> ptr-rc-is-read-only.rs:6:5
|
6 | a.push(4);
| ^ cannot borrow as mutable
|
= help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `Rc<Vec<i32>>`对照:套一层 `RefCell`
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let a = Rc::new(RefCell::new(vec![1, 2, 3]));
let b = Rc::clone(&a);
a.borrow_mut().push(4);
println!("{} {}", a.borrow().len(), b.borrow().len());
}编译通过,输出:"4 4\n"
`Rc` 只给共享**只读**访问 —— 想想也合理:如果多个所有者都能改,那就是数据竞争。对照项输出 `4 4`:两个名字看到的是**同一份**数据,改一边另一边也变了。⇒ `Rc<RefCell<T>>` 这个组合出现时,它在说「多个所有者 + 要能改」。
🚨 `RefCell` 的代价:违规从编译错误变成 panic
use std::cell::RefCell;
fn main() {
let c = RefCell::new(5);
let a = c.borrow_mut();
let b = c.borrow_mut();
println!("{} {}", a, b);
}编译通过,但运行时 panic · 退出码 101
运行时说了什么
thread 'main' panicked at ptr-refcell-panics-at-runtime.rs:6:15:
RefCell already borrowed对照:让两次借用不重叠
use std::cell::RefCell;
fn main() {
let c = RefCell::new(5);
{
let mut a = c.borrow_mut();
*a += 1;
}
let b = c.borrow();
println!("{b}");
}编译通过,输出:"6\n"
⭐ 这条是整篇的重心。`RefCell` 并**没有取消**借用规则(「要么多人读、要么一人写」),它只是把检查从编译期挪到了运行期 —— 违规不再是编译错误,而是 `already borrowed` 的 panic。⇒ 代价说白了就是:**编译器帮不了你了,出错要等到跑起来。**
智能指针用起来像普通引用,靠的是 `Deref`
struct Point {
x: i32,
}
fn show(p: &Point) -> i32 {
p.x
}
fn main() {
let b = Box::new(Point { x: 7 });
println!("{} {}", b.x, show(&b));
}编译通过 · 输出 "7 7\n"
对照:把 `show(&b)` 改成 `show(b)`
struct Point {
x: i32,
}
fn show(p: &Point) -> i32 {
p.x
}
fn main() {
let b = Box::new(Point { x: 7 });
println!("{} {}", b.x, show(b));
}编译不过:error[E0308]
`b.x` 能直接写、`&b` 能当 `&Point` 传,都是 `Deref` 在起作用(和 `String` 能当 `&str` 用是[同一个机制](/rust/ownership/slices/))。但它只在**引用**上自动发生 —— 对照项把 `Box<Point>` 整个传进去就是 `E0308`。