Rust Box:把数据放到堆上,并为递归类型提供已知大小

 

概述

Box<T> 是 Rust 中最简单、最直接的一种智能指针。它的核心作用很明确:

  • 把数据存储到堆(heap)
  • 在栈上只保留一个指向堆数据的指针

和其他更“聪明”的智能指针相比,Box<T> 的额外能力并不多。它几乎只做两件事:

  • 提供一层间接访问
  • 负责堆内存的分配和释放

也正因为如此,Box<T> 很轻量,也非常适合作为学习智能指针的起点。

什么时候会用到 Box<T>

单纯把一个值塞进 Box<T> 里,看起来似乎没什么特别,但它在下面这些场景里非常有用:

1. 编译时无法知道大小的类型

如果一个类型在编译期无法确定大小,但你又必须把它放到“要求大小固定”的上下文中,就可以使用 Box<T>

最典型的例子就是递归类型

2. 希望转移大块数据的所有权,但不想复制整块数据

如果一个值很大,直接在栈上移动它可能会带来较高的复制成本。

这时可以把数据放进 Box<T>

  • 栈上只移动一个指针
  • 真正的大块数据仍然留在堆上的原位置

这样转移所有权时,代价会小很多。

3. 只关心值实现了某个 trait,而不关心具体类型

这种场景会发展到后面的trait object(trait 对象),例如 Box<dyn Draw> 这样的形式。

这里先知道一件事就够了:

Box<T> 不只是“装一个值”,它也经常出现在“隐藏具体类型、只暴露行为”的设计里。


在堆上存储数据

先看最简单的 Box<T> 示例:

fn main() {
    let b = Box::new(5);
    println!("b = {b}");
}

这里发生的事可以拆成两步理解:

  • 5 这个值被放到了堆上
  • 变量 b 本身留在栈上,保存的是一个指向堆上数据的指针

虽然这个值在堆上,但我们使用它时几乎和普通值没什么区别:

println!("b = {b}");

输出仍然是:

b = 5

这也是 Box<T> 很容易上手的地方之一:存储位置变了,但使用方式尽量保持自然。

离开作用域时会发生什么

b 离开作用域时:

  • 栈上的 Box 自身会被销毁
  • Box 指向的堆内存也会被一并释放

所以 Box<T> 的一个重要价值是:它帮我们自动管理堆上的数据,不需要手动释放。

这个例子为什么不算特别实用

把一个单独的 i32 放到堆上,其实没太大意义。

因为像 i32 这样的简单值:

  • 体积很小
  • 默认放栈上就很好
  • 不需要专门为了它做堆分配

所以单值示例主要是为了熟悉 Box::new(...) 的语法,而不是为了说明真实开发中最常见的用途。


Box<T> 为什么能解决递归类型的问题

接下来才是 Box<T> 最经典、也最值得掌握的用法:

为递归类型提供“已知大小”。

什么是递归类型

递归类型指的是:一个类型的值内部,又包含了同类型的值。

例如链表、树这类结构,天然就带有递归定义特征。

如果我们直接写一个递归枚举:

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

这段代码不能编译。

原因不是 Rust 不支持递归数据结构,而是:

Rust 必须在编译时知道每个类型到底占多少空间。


为什么上面的 List 会变成“无限大”

来看这个定义:

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

如果编译器要计算 List 的大小,就必须先看它最大的变体。

其中 Nil 很简单,几乎不存什么数据;但 Cons 变体包含:

  • 一个 i32
  • 一个 List

于是问题来了:

要算 List 的大小,得先算 Cons 的大小;要算 Cons 的大小,又得先算里面那个 List 的大小;而那个 List 里面又还有一个 Cons

这个过程会无限套下去:

List
= i32 + List
= i32 + (i32 + List)
= i32 + (i32 + (i32 + List))
= ...

所以编译器最终只能得出一个结论:

这个类型没有已知大小。

这就是为什么会报出类似下面的错误:

error[E0072]: recursive type `List` has infinite size
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle

编译器这里给出的建议非常关键:

insert some indirection

意思是:不要直接把下一个 List 值内嵌进来,而是通过某种“间接方式”指向它。


间接存储是什么意思

所谓间接存储(indirection),可以简单理解成:

  • 不直接存值本身
  • 而是存一个指向这个值的指针

这正是 Box<T> 擅长的事。

因为无论 T 有多大,Box<T> 自身的大小都是固定的。编译器只需要知道:

  • 一个 i32 占多大
  • 一个 Box<List> 指针占多大

这样 Cons 的大小就确定了。

也就是说,Box<T> 不是帮我们“消灭递归”,而是帮我们把递归从“直接包含”改成“通过指针连接”。


Box<T> 改写递归类型

把前面的定义改成下面这样:

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

这时 Cons 变体中保存的是:

  • 当前节点的值 i32
  • 指向下一个节点的 Box<List>

于是整个类型大小就变成了:

List 最大大小 = i32 + 指针大小

这已经是编译器可以接受的固定大小了。


Box<T> 构造一个 cons list

有了上面的定义后,就可以表示列表 1, 2, 3

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

use crate::List::{Cons, Nil};

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

这个结构可以从外向里理解:

  • 第一个 Cons 保存 1
  • 并指向下一个 List
  • 下一个 List 是保存 2Cons
  • 再往后是保存 3Cons
  • 最后用 Nil 表示链表结束

如果写成更接近数学结构的形式,它表达的是:

(1, (2, (3, Nil)))

这就是一个典型的 cons list。


Box<T> 在这里到底解决了什么

可以把这个问题压缩成一句话:

递归类型本身没问题,问题在于“直接递归包含”会让大小无法确定。

Box<T> 的作用就是:

  • 让递归结构中的下一层节点放到堆上
  • 当前层只保存一个固定大小的指针
  • 从而让整个类型在编译期拥有确定大小

所以这里真正重要的不是“堆”本身,而是:

Box<T> 提供了固定大小的间接层。


Box<T> 的特点:能力少,但很有用

和后面要学的 Rc<T>RefCell<T> 相比,Box<T> 并不复杂。

它没有:

  • 引用计数
  • 共享所有权
  • 运行时借用检查

它做的事情非常朴素:

  • 在堆上存储值
  • 通过指针间接访问值
  • 在离开作用域时自动清理堆数据

也正因为能力边界很清楚,Box<T> 在很多场景下反而是最合适的选择。


为什么说 Box<T> 也是智能指针

Box<T> 之所以属于智能指针,不只是因为它“像指针”,还因为它实现了智能指针最重要的两个 trait:

Deref

DerefBox<T> 的值可以像引用一样使用。

这也是为什么我们经常感觉 Box<T> 用起来并不笨重。

Drop

DropBox<T> 在离开作用域时,自动释放它所指向的堆数据。

这保证了我们既能使用堆内存,又不需要手动管理释放逻辑。

后面学习 DerefDrop 时,你会更清楚这两个 trait 为什么是智能指针的基础。


小结

Box<T> 是 Rust 中最基础的智能指针。它最重要的价值不是“把一个值放上堆”这么简单,而是:

  • 通过堆分配实现间接存储
  • 让大型数据的所有权转移更轻量
  • 为递归类型提供固定大小
  • 为后面的 trait object 打基础

如果只记住这一节的一句话,可以记成:

Box<T> 的核心意义,是用一个固定大小的指针,把原本不好直接存放的数据安全地接起来。


知识自测

尝试独立回答下面这些课后习题,看看这一节的关键点是否已经真正掌握。


第 1 题Box<T> 最核心的作用是什么?它和普通引用最大的区别是什么?

第 2 题:为什么下面这个递归类型不能编译?

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

第 3 题:编译器提示里的 insert some indirection 是什么意思?为什么把 List 改成 Box<List> 之后,编译器就能计算它的大小了?

第 4 题Box<T> 最常见的使用场景有哪些?哪些场景只是“能用”,哪些场景才是“真正有价值”的用法?

第 5 题:为什么说 Box<T> 也是智能指针?DerefDrop 在这里分别起什么作用?

第 6 题:如果用一句话总结 Box<T> 在这一节里的价值,你会怎么说?


📖 点击查看答案

第 1 题答案

Box<T> 的核心作用是把数据放到堆上,并在栈上保留一个固定大小的指针。它和普通引用最大的区别是:引用只是借用数据,而 Box<T> 通常拥有它所指向的数据,并负责这块堆内存的生命周期。


第 2 题答案

因为 Cons 变体里直接包含了另一个 List。编译器为了计算 List 的大小,就必须先计算里面那个 List 的大小,而里面又有一个 List,于是会无限递归下去,最终得到“类型大小无限”的错误。


第 3 题答案

它的意思是:不要直接把值本身嵌进去,而要通过某种“间接层”去指向它,比如 BoxRc 或引用。对于 Box<List> 来说,编译器不需要知道整个递归结构到底有多深,它只需要知道一个 Box 指针的大小是固定的。于是 Cons 的大小就能确定为“一个 i32 + 一个固定大小的指针”。


第 4 题答案

常见场景有三类:

  • 编译时无法确定大小的类型,例如递归类型
  • 希望转移大块数据的所有权,但不想复制整块数据
  • 只关心实现了某个 trait 的行为,而不关心具体类型,为后面的 trait object 做准备

其中最有代表性的用法是递归类型和大数据所有权转移。把单个小值例如 i32 放进 Box<T> 虽然能用,但主要是语法演示,不是最有价值的实际场景。


第 5 题答案

因为 Box<T> 不只是一个普通结构体,它还能像指针一样工作,并且实现了智能指针最关键的两个 trait:

  • Deref:让它在很多场景下像引用一样使用
  • Drop:让它离开作用域时自动清理堆上的数据

第 6 题答案

可以这样总结:

Box<T> 用一个固定大小的堆指针,把原本不适合直接内嵌的数据安全地组织起来,尤其适合递归类型和大数据所有权转移。


参考资料

  • [The Rust Programming Language - Using Box to Point to Data on the Heap](https://doc.rust-lang.org/book/ch15-01-box.html)
  • std::boxed::Box