学 Rust 时,Box<T> 很容易被一句话带过:它可以把数据放到堆上。

这句话没错,但如果只记住这一句,后面看到 Box<dyn Trait>Box<List>Pin<Box<T>>Box::leak 时,还是会觉得它像一个突然冒出来的语法补丁。

更好的理解方式是:Box<T> 不是“逃离所有权系统”的工具,而是 Rust 所有权系统里最基础的一种拥有型指针。它让一个值住在堆上,同时让所有权仍然清清楚楚地归某个变量管理。

先看 Box 解决了什么问题

普通变量通常可以这样理解:

let n = 42;

n 这个值本身就放在当前栈帧里。栈很快,进入函数时分配,函数返回时回收,生命周期也很清楚。

但有些场景光靠栈不够舒服:

  • 一个类型递归地包含自己,编译器算不出它的大小。
  • 你想把不同具体类型放进同一个集合,只要求它们实现同一个 trait。
  • 一个值很大,你希望移动时只移动一个指针。
  • 某些 API 需要一个固定地址的拥有型对象,比如配合 Pin 使用。

这时 Box<T> 就出现了。

let n = Box::new(42);

可以把它粗略想成这样:

栈上变量 n
  -> 保存一个指针
  -> 指向堆上的 42

Box<T> 本身在栈上,里面保存指向堆数据的指针。真正的 T 在堆上。变量离开作用域时,Box<T> 会自动释放堆上的 T

所以 Box<T> 同时有两个特点:

  • 它是指针,可以间接访问堆上的值。
  • 它拥有这个值,离开作用域时负责释放它。

这和 C 里的裸指针很不一样。你不用手写 free,也不能随便复制出多个拥有者。Rust 仍然会检查所有权、移动和借用。

最基本的用法:Box::new

创建一个 Box<T> 最常见的方式是 Box::new

fn main() {
    let name = Box::new(String::from("cloudside"));

    println!("{name}");
}

这里 String 这个值由 Box 放到堆上管理,name 是一个拥有它的 Box<String>。注意,String 自己内部还会管理一块字符串缓冲区;这里说的是 String 这个三字段结构本身的位置。

访问里面的值时,很多时候你甚至感觉不到 Box 的存在,因为 Box<T> 实现了 Deref

fn print_len(s: &String) {
    println!("{}", s.len());
}

fn main() {
    let boxed = Box::new(String::from("rust"));

    print_len(&boxed);
}

print_len 需要的是 &String,我们传进去的是 &Box<String>。Rust 会通过 deref coercion 自动把它转成 &String

如果确实要取出里面的值,可以解引用:

fn main() {
    let boxed = Box::new(String::from("hello"));

    let value: String = *boxed;

    println!("{value}");
}

这一步会把 StringBox 里移动出来。移动之后,原来的 boxed 就不能再用了。

Box 不是引用,也不是 Rc

看到 Box<T> 里有个指针,容易把它和引用混在一起。

区别其实很关键:

let value = String::from("rust");

let borrowed = &value;
let boxed = Box::new(value);

borrowed 是借用。它不拥有 String,只是临时看一眼。

boxed 是拥有。执行 Box::new(value) 之后,value 的所有权被移动进 Box,后面由 boxed 负责释放。

再看 Rc<T>

use std::rc::Rc;

let a = Rc::new(String::from("rust"));
let b = Rc::clone(&a);

Rc<T> 是引用计数,可以有多个拥有者。Box<T> 只有一个拥有者。

所以大致可以这样分:

类型所有权含义常见用途
&T借用,不拥有临时读取
&mut T可变借用,不拥有临时修改
Box<T>单一拥有者堆分配、递归类型、trait object
Rc<T>单线程多个拥有者共享不可变数据
Arc<T>多线程多个拥有者跨线程共享数据

看到这里,Box<T> 的定位就比较清楚了:它不是共享工具,而是“我拥有一个堆上的值”。

场景一:递归类型必须用 Box 打断无限大小

Box<T> 最经典的用途,是定义递归类型。

比如我们想写一个最简单的链表:

enum List {
    Cons(i32, List),
    Nil,
}

这段代码不能通过编译。原因是 List 里面又直接包含一个 List,编译器没法算出它到底有多大。

可以展开想一下:

List
  = i32 + List
  = i32 + i32 + List
  = i32 + i32 + i32 + List
  = ...

这个类型的大小没有尽头。

Box 之后就不一样了:

enum List {
    Cons(i32, Box<List>),
    Nil,
}

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

现在 Cons 里放的不是完整的下一个 List,而是一个指向下一个 ListBox<List>

编译器看到的是:

List
  = i32 + 一个固定大小的指针

指针大小是固定的,所以整个 enum 的大小也能确定。

这就是 Box 在递归类型里的作用:不是让递归消失,而是用一层指针把“无限嵌套的值”变成“固定大小的指针”。

场景二:Box 用来做动态分发

另一个常见场景是 trait object。

假设我们有一组组件,它们都能 draw

trait Draw {
    fn draw(&self);
}

struct Button;
struct Text;

impl Draw for Button {
    fn draw(&self) {
        println!("draw button");
    }
}

impl Draw for Text {
    fn draw(&self) {
        println!("draw text");
    }
}

如果想把 ButtonText 放进同一个 Vec,不能直接写:

// 这不是可行写法
// let components = vec![Button, Text];

因为 Vec<T> 要求所有元素是同一个具体类型。ButtonText 都实现了 Draw,但它们不是同一个类型。

这时可以用 Box<dyn Draw>

trait Draw {
    fn draw(&self);
}

struct Button;
struct Text;

impl Draw for Button {
    fn draw(&self) {
        println!("draw button");
    }
}

impl Draw for Text {
    fn draw(&self) {
        println!("draw text");
    }
}

fn main() {
    let components: Vec<Box<dyn Draw>> = vec![
        Box::new(Button),
        Box::new(Text),
    ];

    for component in components {
        component.draw();
    }
}

这里 dyn Draw 表示“某个实现了 Draw 的具体类型,但编译期不知道它到底是哪一个”。

为什么还要套一层 Box

因为 dyn Draw 的大小在编译期不确定。不同实现类型可能有不同大小,不能直接放进 Vec。但 Box<dyn Draw> 是固定大小的指针,所以可以放进 Vec

这时调用 draw 会走动态分发:运行时根据具体类型找到对应的方法实现。

泛型和 Box 怎么选

如果具体类型在编译期就能确定,优先考虑泛型:

trait Draw {
    fn draw(&self);
}

fn render<T: Draw>(component: T) {
    component.draw();
}

泛型会让编译器为具体类型生成代码,通常没有动态分发开销,也能保留更强的类型信息。

如果你需要“不同类型放在同一个集合里”,或者 API 的调用方只关心 trait 行为,不关心具体类型,就可以考虑 Box<dyn Trait>

trait Draw {
    fn draw(&self);
}

fn build_component(kind: &str) -> Box<dyn Draw> {
    match kind {
        "button" => Box::new(Button),
        "text" => Box::new(Text),
        _ => Box::new(Text),
    }
}

struct Button;
struct Text;

impl Draw for Button {
    fn draw(&self) {}
}

impl Draw for Text {
    fn draw(&self) {}
}

一个简单判断是:

  • 类型固定、追求静态分发:用泛型。
  • 类型不固定、需要统一装进容器或返回统一接口:用 Box<dyn Trait>

场景三:移动大对象时只移动指针

Rust 的 move 语义经常会让新手担心:一个很大的结构体被 move,是不是会复制很多内存?

先看普通结构体:

struct BigData {
    bytes: [u8; 1024 * 1024],
}

fn consume(data: BigData) {
    println!("{}", data.bytes.len());
}

fn main() {
    let data = BigData {
        bytes: [0; 1024 * 1024],
    };

    consume(data);
}

这里 BigData 的值很大。把它作为参数传递时,语义上是 move。编译器可能会优化实际拷贝,但从设计上说,这个类型本身确实很大。

如果改成 Box<BigData>

struct BigData {
    bytes: [u8; 1024 * 1024],
}

fn consume(data: Box<BigData>) {
    println!("{}", data.bytes.len());
}

fn main() {
    let data = Box::new(BigData {
        bytes: [0; 1024 * 1024],
    });

    consume(data);
}

移动 Box<BigData> 时,移动的是指针这类固定大小的元信息,而不是把整个大对象在栈上搬来搬去。

不过这不是说“大对象都应该 Box”。堆分配也有成本,间接访问也可能影响缓存局部性。Box 是一个工具,不是性能万能药。真的关心性能时,应该结合数据大小、调用路径和基准测试判断。

场景四:Box 和闭包、回调

闭包也经常和 Box<dyn Trait> 一起出现。

比如我们想保存一组回调:

fn main() {
    let handlers: Vec<Box<dyn Fn(i32) -> i32>> = vec![
        Box::new(|x| x + 1),
        Box::new(|x| x * 2),
    ];

    let mut value = 10;

    for handler in handlers {
        value = handler(value);
    }

    println!("{value}");
}

每个闭包都有自己独立的匿名类型。就算参数和返回值一样,两个闭包的具体类型也不一样。

Box<dyn Fn(i32) -> i32> 把它们统一成“实现了 Fn(i32) -> i32 的对象”,于是可以放进同一个 Vec

这类写法在插件系统、事件处理、路由表、策略表里都很常见。

Box 会自动释放,但释放时机仍然按所有权来

Box<T> 离开作用域时会自动释放里面的值。

struct Resource;

impl Drop for Resource {
    fn drop(&mut self) {
        println!("drop resource");
    }
}

fn main() {
    {
        let _resource = Box::new(Resource);
        println!("inside scope");
    }

    println!("outside scope");
}

输出顺序大致是:

inside scope
drop resource
outside scope

这说明 Box 不是垃圾回收。它不会等到某个后台线程来清理,而是在所有权离开作用域时确定地执行 drop

如果你把 Box move 到另一个函数,释放时机也跟着所有权走:

fn take(value: Box<String>) {
    println!("{value}");
}

fn main() {
    let value = Box::new(String::from("rust"));

    take(value);

    // 这里不能再使用 value
}

value 的所有权进入 take,函数结束时释放。

Box::leak:故意让值活到程序结束

偶尔你会看到 Box::leak

fn main() {
    let config: &'static str = Box::leak(String::from("debug").into_boxed_str());

    println!("{config}");
}

Box::leak 会把 Box<T> 变成一个引用,并且不再自动释放那块堆内存。这个引用可以拥有 'static 生命周期,因为内存被故意泄漏了。

这个名字已经把风险写出来了:leak 就是泄漏。

它适合少数场景,比如程序启动时构造一次全局配置,之后整个进程生命周期都要用。它不适合在请求处理、循环、频繁调用的路径里随手使用。

如果你只是想共享全局只读数据,通常应该先看看 OnceLockLazyLockArc 这些选择。

Pin<Box>:让堆上的值不要再被移动

还有一个常见组合是 Pin<Box<T>>

Box<T> 负责把值放到堆上并拥有它。Pin 负责表达另一个约束:这个值被固定住了,不能再被移动。

在普通业务代码里,你不一定经常手写 Pin<Box<T>>,但在 async、Future、自引用结构、底层库里会经常遇到它。

可以先记住这个分工:

Box<T>       负责堆分配和所有权
Pin<Box<T>> 负责堆分配、所有权,以及“不要移动这个值”的承诺

不要把 Pin 理解成“更高级的 Box”。它解决的是移动语义的问题,不是普通堆分配问题。

不要为了“看起来高级”到处 Box

Box<T> 很基础,但不应该到处用。

这些情况通常不需要 Box

  • 一个普通的小整数、小结构体。
  • 只是为了把参数传给函数。
  • 只是为了绕开借用检查。
  • 只是觉得堆上更“稳定”。

比如这样就没必要:

fn add(a: Box<i32>, b: Box<i32>) -> i32 {
    *a + *b
}

直接传值更简单:

fn add(a: i32, b: i32) -> i32 {
    a + b
}

如果你发现自己为了让代码编译,开始把很多东西都塞进 Box,那通常说明还没有想清楚真正的问题:

  • 是需要共享所有权?那可能是 RcArc
  • 是需要内部可变性?那可能是 RefCellMutexRwLock
  • 是需要 trait object?那 Box<dyn Trait> 才合理。
  • 是递归类型大小无法确定?那 Box 很合适。
  • 是生命周期借用关系没理清?那 Box 可能只是把问题换了个地方。

Box 能改变值放在哪里,但不能让所有权问题消失。

一个实用判断

写代码时可以按这个顺序问:

这个值需要在堆上拥有吗?

如果答案是否,先别用 Box

如果答案是,再继续问:

为什么必须在堆上?

常见合理答案有三类:

  • 类型大小编译期无法确定,比如递归类型或 trait object。
  • 需要稳定的间接拥有,比如和 Pin、FFI、某些库 API 配合。
  • 值很大,移动拥有者时只想移动指针。

再问最后一个问题:

这个值需要多个拥有者吗?

如果只需要一个拥有者,Box<T> 很合适。

如果需要多个拥有者,Box<T> 就不够了,应该考虑 Rc<T>Arc<T>

最后再压缩成一句话

Box<T> 是 Rust 里最基础的拥有型智能指针。

它做的事很具体:

把 T 放到堆上
让 Box 拥有 T
通过所有权自动释放 T

它常用在这些地方:

  • 递归类型:用固定大小的指针打断无限大小。
  • trait object:用 Box<dyn Trait> 承载编译期大小未知的具体类型。
  • 大对象:移动拥有者时只移动指针。
  • 回调和闭包:把不同闭包统一成同一种 trait object。
  • Pin<Box<T>>:配合需要固定地址的类型。

所以不要把 Box 理解成“Rust 里的堆内存开关”这么简单。

更准确地说,Box<T> 是 Rust 在不放弃所有权检查的前提下,提供给你的最小堆分配工具。它让值住到堆上,但仍然让谁拥有、谁释放、什么时候失效这些问题保持清楚。

参考资料