卷 IV · 当一个所有者不够CH 16深度 16/24

智能指针:Box、Deref、Drop

上一章的 RcRefCell,加上这一章的 Box,都被叫做「智能指针」—— 它们像指针一样指向数据,又「智能」在会自动管理(计数、释放、检查)。这一章拆开最简单的 Box,顺便讲清两个默默支撑着这一切的 trait:让 * 能用的 Deref,和让 drop 能自动跑的 Drop。它俩解释了很多你一直觉得「理所当然」的魔法。

BoxDerefDrop递归类型

Box:把一个值搬到堆上

Box<T> 是最朴素的智能指针 —— 它做的事只有一件:T 放到堆上,栈上只留一个指向它的指针。它仍然遵守单一所有权(不像 Rc 能多主人),drop 时释放堆上的值。

let boxed = Box::new(5);      // 5 被放到堆上,boxed 是指向它的指针
println!("{}", *boxed);       // * 解引用,拿回那个 5

「把一个整数放堆上」听着没什么用(整数放栈上明明更好),所以问题是:什么时候你需要 Box?有三个真实场景:

  • 递归类型 —— 最经典的理由。一个类型如果直接包含自己,编译器算不出它多大(无限套娃)。Box 是个固定大小的指针,用它打断无限,大小就确定了。
  • trait 对象。第 13 章的 Box<dyn Summary> —— 想把不同大小的类型统一装进一个集合,用 Box 把它们都变成「指针」这个统一大小。
  • 把一个很大的值搬上堆,避免移动时大块拷贝栈内存。

看第一个场景,它最能说明 Box 的必要性。想定义一个表达式树(或链表),节点里要装子节点:

enum Expr {
    Num(i32),
    Add(Box<Expr>, Box<Expr>),    // 没有 Box 的话:Expr 里套 Expr,无限大,编译不过
}

// (1 + 2) 长这样:
let tree = Expr::Add(
    Box::new(Expr::Num(1)),
    Box::new(Expr::Num(2)),
);

如果写成 Add(Expr, Expr),编译器要算 Expr 的大小:它等于 Add 变体的大小,而 Add 里又装着两个 Expr…… 无限递归,算不出来。换成 Box<Expr>,节点里存的是指针(固定 8 字节),大小当场确定,实际的子树在堆上。凡是「自己包含自己」的数据结构(树、链表、图),在 Rust 里都要靠 Box(或 Rc)来打断无限。

Deref:为什么 *box 能拿到里面的值

你注意到 *boxed 能像普通引用一样解引用吗?Box 明明是个结构体,凭什么支持 *?因为它实现了 Deref trait —— 这个 trait 的作用,就是让一个类型「表现得像指针」,规定 * 该返回什么。所有智能指针都实现它,这也是它们「用起来像指针」的原因。

Deref 有个更常被你无意识享受的福利,叫「解引用强制转换」(deref coercion)。还记得第 9 章那个悬念吗 —— 为什么参数写 &str 时,你能把 &String 传进去?答案就是它:

fn greet(name: &str) { /* ... */ }

let s = String::from("Ferris");
greet(&s);      // 传的是 &String,但 String 实现了 Deref<Target=str>,
                // 编译器自动把 &String 转成了 &str。你什么都没写

因为 String 实现了 Deref<Target = str>,编译器在需要 &str 的地方,会自动把 &String 顺着 Deref 转过去。Box<T>Rc<T> 也一样 —— 你能对一个 Box<String> 直接调 .len(),就是因为编译器顺着 Deref 一层层「穿透」到了底下的 String很多「智能指针用起来和普通值几乎没差别」的丝滑感,都是 Deref 在背后免费提供的。

Drop:为什么 drop 能自动发生

第 5 章我们说「值离开作用域就自动 drop」。那个「自动 drop」具体调用的,就是 Drop trait 的 drop 方法。默认它释放该类型占用的资源;你也可以自己实现它,规定「这个值临终前要做什么」:

struct Conn { id: u32 }

impl Drop for Conn {
    fn drop(&mut self) {
        println!("连接 {} 关闭", self.id);   // 离开作用域自动跑,你无需手动调
    }
}

fn main() {
    let _c = Conn { id: 42 };
}   // 这里自动打印「连接 42 关闭」

这就是第 5 章说的 RAII 的实现细节:BoxDrop 释放堆内存,FileDrop 关文件,MutexGuardDrop 解锁 —— 每种资源的「归还动作」,都写在它的 Drop 实现里,由作用域的结束自动触发。你不能手动调 x.drop()(会 double drop),要提前释放就用标准库的 drop(x) 函数。

◆ 「智能指针」到底智能在哪

BoxRcRefCellStringVec 放一起看,你会发现它们是同一个模式:一个结构体,实现了 Deref(所以用起来像指针 / 像里面的值),实现了 Drop(所以离开作用域自动清理),可能还管着计数或借用标记。「智能指针」不是一种内置的特殊东西,它就是「用 Deref + Drop 把『指向 + 自动管理』打包好的普通类型」。理解了这两个 trait,你甚至能造自己的智能指针。

回流

⟲ 回流 · 显式的堆,与自动的清理

C++:对应关系几乎一一对上 —— Box<T>unique_ptr<T>(独占、堆上、自动释放),Rc<T>shared_ptr<T>Deref ≈ C++ 的 operator* / operator-> 重载,Drop ≈ 析构函数。Rust 把这套「智能指针」的智慧原样继承,区别是借用检查保证了你不会拿着一个已经被 move 走的 Box 去解引用(C++ 里 moved-from 的 unique_ptr 是 nullptr,解引用就崩)。

Java / Kotlin:这里的对比很有意思 —— 在 JVM 上,几乎所有对象都在堆上,且都是「指针」(引用),你根本没有「值在栈上还是堆上」的选择权,一切交给 JIT 和 GC。Rust 反过来:默认在栈上(快、无分配),上堆是你显式写 Box::new 的动作。这份「默认栈上、按需上堆」的控制力,是 Rust 性能可预测的重要来源 —— 你能精确知道哪次分配发生在堆上。而「自动清理」这件事,JVM 靠 GC(不确定何时),Rust 靠 Drop(作用域一结束就确定发生),第 5 章已经对比过。

这一章的一句话

Box 把值放上堆(递归类型、trait 对象靠它打断无限);Deref 让智能指针「用起来像里面的值」,也是 &String 能当 &str 用的幕后功臣;Drop 定义临终清理,是 RAII 的实现。所有智能指针,都是 Deref + Drop 打包出来的普通结构体。

卷 IV 还剩最后、也最让人好奇的一个逃生口。前面所有的安全保证,都建立在借用检查器之上。那有没有一扇门,能拆掉护栏?有,它叫 unsafe。但它拆掉的到底是什么、又保留了什么,和你想的很可能不一样。