阶段二 · 智能指针

Box 与 Deref

一句话总结

Box 是最朴素的智能指针: 把数据放到上, 栈上只留一个指针.
它遵守所有权规则(唯一所有者, 离开作用域自动释放), 不增加任何运行时开销.
它解决两类问题: 编译期大小未知的类型(如递归类型)和把大数据搬到堆上以廉价移动.
"智能"则来自它实现的 DerefDrop 两个 trait.

什么是智能指针

从本篇起进入智能指针. 先建立一个统领全阶段的视角: 智能指针是"行为像指针, 但附带额外能力"的类型. 它们通常拥有所指向的数据(而普通引用 & 只是借用), 并通过实现特定 trait 获得"智能":

  • Deref: 让它能像普通引用一样用 * 解引用, 自动当作 &T 使用.
  • Drop: 让它离开作用域时自动执行清理(如释放堆内存).

普通引用 &T 像一张写着地址的便签, 只指路, 不负责房子; 智能指针像带着房产证和物业服务的钥匙: 它拥有房子(数据), 还自带"到期自动退租清理"(Drop)和"用起来跟住在里面一样方便"(Deref)的服务.
本阶段的 Box, Rc, RefCell 都是不同物业套餐的钥匙.

Box: 把数据放到堆上

Box<T> 的作用一句话概括: 在堆上分配一个 T, 栈上持有指向它的指针. 用法极简:

let b = Box::new(5); // 5 被分配在堆上, b 是栈上指向它的指针
println!("{b}"); // 5 -- 用起来和普通值一样(靠 Deref)
// b 离开作用域时, 堆上的 5 被自动释放(靠 Drop)

i32 这种小数据用 Box 没意义(本就该在栈上). Box 的价值体现在两类真实场景.

场景一: 编译期大小未知的递归类型

这是 Box 最经典的用途. 设想要定义一个链表节点--它包含"下一个同类型节点":

// 错误: 递归类型, 编译器无法计算 List 的大小
enum List {
Cons(i32, List), // List 里又含 List, 大小 = i32 + List 大小 = 无限递归!
Nil,
}

编译器报 "recursive type has infinite size": 要算 List 占多少字节, 得先知道里面那个 List 占多少字节...无限套娃. Box 打破这个循环--指针的大小是固定的(就一个机器字), 无论它指向多大的数据:

enum List {
Cons(i32, Box<List>), // 现在节点里存的是"指向下一个 List 的指针", 大小固定
Nil,
}

use List::{Cons, Nil};
let list = Cons(1, Box::new(Cons(2, Box::new(Nil))));

思想: 用'固定大小的指针'换'无限大小的数据'

递归类型的大小之所以无法确定, 是因为数据被内联展开. 把递归部分包进 Box, 等于说"这里存的不是下一个节点本身, 而是它的地址"--地址大小恒定, 递归的大小问题迎刃而解.

任何"编译期不知道有多大"的类型, Box 都能把它"装箱"成已知大小.

场景二: 廉价移动大数据

第二类场景: 当一个值很大(比如一个含大数组的结构体), 按值移动/传递会复制栈上的全部字节. 把它装进 Box, 移动时只需复制那个指针(一个机器字), 数据本身在堆上原地不动:

struct Huge { data: [u8; 1_000_000] } // 1MB, 放栈上移动很贵

let h = Box::new(Huge { data: [0; 1_000_000] });
let h2 = h; // 只移动了 8 字节的指针, 1MB 数据没被复制

Deref: 为什么 Box 用起来像普通值

前面 println!("{b}") 直接打印了 Box 里的 5, *b 也能取出值--这都靠 Deref trait. 它定义了"当对这个类型用 * 时, 该返回什么". Box<T> 实现了 Deref<Target = T>, 于是 *b 得到里面的 T.

let b = Box::new(5);
let x = *b + 1; // *b 解引用得到 5, 再 +1
println!("{x}"); // 6

更妙的是解引用强制转换(Deref coercion): 编译器会自动把 &Box<T> 转成 &T. 所以一个接收 &str 的函数, 能直接传 &Box<String> 进去:

fn greet(name: &str) { println!("Hi, {name}"); }

let boxed = Box::new(String::from("Alice"));
greet(&boxed); // &Box<String> → &String → &str, 编译器自动转两次

Deref coercion 像一套自动适配的转接头: 拿着 &Box 这个"三层插头", 函数要的是 &str 这个"标准接口", 编译器默默套上转接头让它们对接.
这让智能指针用起来几乎和裸值无差别--"智能"二字名副其实.

Drop: 离开作用域自动清理

Drop trait 定义"值离开作用域时要做的清理". Box 实现它来释放堆内存, 无需手动 free. 也能为自己的类型实现 Drop, 挂上自定义清理逻辑(关文件, 断连接等):

struct Guard { name: String }

impl Drop for Guard {
fn drop(&mut self) {
println!("清理 {}", self.name); // 离开作用域时自动调用
}
}

{
let _g = Guard { name: "资源A".into() };
println!("使用中");
} // 这里自动打印 "清理 资源A"

不能手动调用 .drop()

不能写 g.drop() 手动清理(编译器禁止, 否则会导致离开作用域时二次清理). 需要提前释放, 用标准库函数 std::mem::drop(g).

这与 Go 的 defer 形成对比: Go 的清理靠显式 defer 且可控制时机, Rust 的 Drop 由作用域自动, 确定性触发.

快速回顾

  • 智能指针: 行为像指针, 通常拥有数据, 靠实现 Deref/Drop 获得"智能".
  • Box: 数据放堆上, 栈上留指针, 遵守所有权, 零运行时开销.
  • 两大用途: 1. 让递归等"编译期大小未知"的类型变成已知大小; 2. 廉价移动大数据(只移指针).
  • Deref: 让 *box 取值, 并支持解引用强制转换(&Box<String> 自动当 &str 用).
  • Drop: 离开作用域自动清理; 不能手动 .drop(), 提前释放用 std::mem::drop.

动手练习

  1. 下面的枚举为什么编译不过? 如何用 Box 修复?

    enum Tree {
    Node(i32, Tree, Tree),
    Leaf,
    }
参考答案

递归类型大小无限: Tree 含两个 Tree, 要算它的大小需先知道子 Tree 的大小, 无限递归. 用 Box 把递归部分变成固定大小的指针:

enum Tree {
Node(i32, Box<Tree>, Box<Tree>),
Leaf,
}
  1. 下面代码会打印什么? 顺序如何?

    struct D(i32);
    impl Drop for D {
    fn drop(&mut self) { println!("drop {}", self.0); }
    }
    fn main() {
    let _a = D(1);
    let _b = D(2);
    println!("end");
    }
参考答案
end
drop 2
drop 1

先打印 end. 然后 main 结束, 局部变量按声明的逆序(后声明的先 drop)依次清理: _b(2)先于 _a(1). 这种 LIFO 的确定性析构顺序是 Rust Drop 的重要特性.

  1. 一个函数 fn print_len(s: &str), 手上有 let b: Box<String> = Box::new("hi".into());. 能直接 print_len(&b) 吗? 为什么?
参考答案

能. 靠解引用强制转换(Deref coercion): 编译器自动把 &Box<String> 转成 &String, 再转成 &str. 无需手动解引用, 智能指针用起来和裸值几乎无差别.

  1. 想在某个变量作用域结束之前就触发它的 Drop, 该怎么做? 为什么不能调 x.drop()?
参考答案

std::mem::drop(x) 提前释放. 不能调 x.drop(), 是因为那样会导致变量在作用域结束时被二次 drop(析构两次), Rust 为防止这种内存错误而禁止手动调用 Drop::drop.

std::mem::drop 通过获取所有权并立即结束其作用域来安全地提前清理.