进阶:绕开借用检查器的正当手段

智能指针与内部可变性

它在解决什么

所有权那一章立下三条规则:一个值一个所有者、 要么多人读要么一人写、借用不能比被借的活得久。

但真实代码里有些结构天生违反它们 —— 图、观察者、树的父指针、共享缓存。 这一章讲的就是怎么正当地绕开,以及每种绕法的价格。

「智能指针」这个名字听着玄,其实就是实现了 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 里的东西改不了

想改就是 E0596。

想想也合理:如果多个所有者都能改,那就是数据竞争。 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>> 先别急着用,它有相当一部分出现是因为 数据结构选错了,而不是真的需要共享可变。

⇒ 三个自问:

  1. 能不能改成传参? 很多「全局共享状态」其实只要把它当参数传下去。
  2. 能不能用索引代替指针? 图和树用 Vec<Node> + usize 索引, 比 Rc<RefCell<Node>> 简单得多,也不会有循环引用。
  3. 真的需要多个所有者,还是只需要多个借用? 后者用 & 就够了。

🚨 另外一个真实风险: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`。