使用 Box<T> 指向堆上的数据

最直接的智能指针是box,其类型写为 Box<T>。Box 允许你将数据存储在堆上而不是栈上。栈上保留的是指向堆数据的指针。回顾第 4 章,复习栈和堆之间的区别。

除了将数据存储在堆而不是栈上之外,Box 没有性能开销。但它们也没有太多额外的功能。你最常在以下情况下使用它们:

  • 当你的类型大小在编译时无法确定,并且你希望在需要精确大小的上下文中使用该类型的值时
  • 当你拥有大量数据,并且希望转移所有权但确保在这样做时不会复制数据时
  • 当你想要拥有一个值,并且只关心它是实现特定 trait 而不是特定类型的类型时

我们将在“使用 Box 启用递归类型”部分演示第一种情况。在第二种情况中,转移大量数据的所有权可能需要很长时间,因为数据在栈上被复制来复制去。为了提高这种情况下的性能,我们可以将大量数据存储在堆上的 box 中。然后,只有少量指针数据在栈上复制,而它引用的数据则保留在堆上的一个位置。第三种情况被称为trait 对象,第 17 章专门用一整节“使用允许不同类型值的 Trait 对象”来讲解这个主题。所以你在这里学到的东西将在第 17 章再次应用!

使用 Box<T> 在堆上存储数据

在我们讨论 Box<T> 的堆存储用例之前,我们将介绍语法以及如何与存储在 Box<T> 中的值进行交互。

列表 15-1 展示了如何使用 box 在堆上存储 i32

文件名:src/main.rs

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

列表 15-1:使用 box 在堆上存储 i32

我们定义变量 b 的值为指向值 5Box,该值分配在堆上。这个程序将打印 b = 5;在这种情况下,我们可以像访问栈上的数据一样访问 box 中的数据。就像任何拥有的值一样,当 box 超出作用域时,比如 main 函数末尾的 b,它将被释放。释放操作同时发生在 box(存储在栈上)和它指向的数据(存储在堆上)上。

将单个值放在堆上不是很有用,所以你不会经常以这种方式单独使用 box。像单个 i32 这样的值放在默认存储它们的栈上,在大多数情况下更合适。让我们看一个 box 允许我们定义如果我们没有 box 就无法定义的类型的情况。

使用 Box 启用递归类型

递归类型的值可以将相同类型的另一个值作为自身的一部分。递归类型会带来一个问题,因为在编译时 Rust 需要知道类型占用多少空间。但是,递归类型值的嵌套理论上可以无限继续,因此 Rust 无法知道该值需要多少空间。因为 box 具有已知的大小,所以我们可以通过在递归类型定义中插入 box 来启用递归类型。

作为递归类型的示例,让我们探索一下cons 列表。这是一种在函数式编程语言中常见的的数据类型。我们将定义的 cons 列表类型除了递归之外都很简单;因此,我们在示例中使用的概念在您遇到涉及递归类型的更复杂情况时会很有用。

有关 Cons 列表的更多信息

cons 列表是一种来自 Lisp 编程语言及其方言的数据结构,由嵌套对组成,是 Lisp 版本的链表。它的名字来源于 Lisp 中的 cons 函数(“构造函数”的缩写),该函数从其两个参数构造一个新的对。通过在由一个值和另一个对组成的对上调用 cons,我们可以构造由递归对组成的 cons 列表。

例如,这是一个包含列表 1、2、3 的 cons 列表的伪代码表示,每对用括号括起来

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

cons 列表中的每个项目都包含两个元素:当前项目的值和下一个项目。列表中的最后一个项目仅包含一个称为 Nil 的值,没有下一个项目。cons 列表是通过递归调用 cons 函数产生的。表示递归基本情况的标准名称是 Nil。请注意,这与第 6 章中的“null”或“nil”概念不同,后者是无效或不存在的值。

cons 列表不是 Rust 中常用的数据结构。大多数时候,当你在 Rust 中有一个项目列表时,Vec<T> 是更好的选择。其他更复杂的递归数据类型在各种情况下有用的,但是通过在本章中从 cons 列表开始,我们可以探索 box 如何让我们定义递归数据类型而不会分散太多注意力。

列表 15-2 包含 cons 列表的枚举定义。请注意,此代码尚无法编译,因为 List 类型的大小未知,我们将对此进行演示。

文件名:src/main.rs

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

fn main() {}

列表 15-2:首次尝试定义一个枚举来表示 i32 值的 cons 列表数据结构

注意:为了本示例的目的,我们正在实现一个仅保存 i32 值的 cons 列表。我们可以使用我们在第 10 章中讨论的泛型来实现它,以定义可以存储任何类型值的 cons 列表类型。

使用 List 类型来存储列表 1, 2, 3 看起来像列表 15-3 中的代码

文件名:src/main.rs

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

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

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

列表 15-3:使用 List 枚举来存储列表 1, 2, 3

第一个 Cons 值保存 1 和另一个 List 值。此 List 值是另一个 Cons 值,它保存 2 和另一个 List 值。此 List 值是再一个 Cons 值,它保存 3 和一个 List 值,最后是 Nil,这是表示列表结尾的非递归变体。

如果我们尝试编译列表 15-3 中的代码,我们会得到列表 15-4 中显示的错误

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0072]: recursive type `List` has infinite size
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
2 |     Cons(i32, List),
  |               ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

error[E0391]: cycle detected when computing when `List` needs drop
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
  |
  = note: ...which immediately requires computing when `List` needs drop again
  = note: cycle used when computing whether `List` needs drop
  = note: see https://rustc-dev-guide.rust-lang.org/overview.html#queries and https://rustc-dev-guide.rust-lang.org/query.html for more information

Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 2 previous errors

列表 15-4:尝试定义递归枚举时得到的错误

该错误显示此类型“具有无限大小”。原因是我们定义了具有递归变体的 List:它直接保存另一个自身的值。因此,Rust 无法计算出存储 List 值需要多少空间。让我们分解一下为什么会得到这个错误。首先,我们将了解 Rust 如何决定存储非递归类型的值需要多少空间。

计算非递归类型的大小

回顾一下我们在第 6 章讨论枚举定义时在列表 6-2 中定义的 Message 枚举

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

为了确定为 Message 值分配多少空间,Rust 会遍历每个变体以查看哪个变体需要的空间最多。Rust 看到 Message::Quit 不需要任何空间,Message::Move 需要足够的空间来存储两个 i32 值,依此类推。因为只会使用一个变体,所以 Message 值需要的最大空间将是存储其最大变体所需的空间。

对比一下当 Rust 尝试确定递归类型(如列表 15-2 中的 List 枚举)需要多少空间时会发生的情况。编译器首先查看 Cons 变体,该变体保存一个 i32 类型的值和一个 List 类型的值。因此,Cons 需要的空间量等于 i32 的大小加上 List 的大小。为了确定 List 类型需要多少内存,编译器会查看变体,从 Cons 变体开始。Cons 变体保存一个 i32 类型的值和一个 List 类型的值,此过程无限继续,如图 15-1 所示。

An infinite Cons list

图 15-1:由无限 Cons 变体组成的无限 List

使用 Box<T> 获取具有已知大小的递归类型

由于 Rust 无法计算出为递归定义类型分配多少空间,编译器会给出此有用的建议,并显示错误

help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

在此建议中,“间接”意味着我们应该通过存储指向该值的指针而不是直接存储该值来更改数据结构,从而间接存储该值。

因为 Box<T> 是一个指针,所以 Rust 总是知道 Box<T> 需要多少空间:指针的大小不会根据它指向的数据量而变化。这意味着我们可以将 Box<T> 放入 Cons 变体中,而不是直接放入另一个 List 值。Box<T> 将指向下一个 List 值,该值将位于堆上,而不是在 Cons 变体内部。从概念上讲,我们仍然有一个列表,该列表由保存其他列表的列表创建,但是此实现现在更像是将项目彼此相邻放置,而不是彼此内部放置。

我们可以将列表 15-2 中 List 枚举的定义以及列表 15-3 中 List 的用法更改为列表 15-5 中的代码,该代码将可以编译

文件名:src/main.rs

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))))));
}

列表 15-5:为了具有已知大小而使用 Box<T>List 定义

Cons 变体需要一个 i32 的大小加上存储 box 指针数据所需的空间。Nil 变体不存储任何值,因此它需要的空间小于 Cons 变体。现在我们知道任何 List 值都将占用一个 i32 的大小加上 box 的指针数据的大小。通过使用 box,我们打破了无限的递归链,因此编译器可以计算出存储 List 值需要的大小。图 15-2 显示了现在的 Cons 变体。

A finite Cons list

图 15-2:一个不是无限大小的 List,因为 Cons 保存一个 Box

Box 仅提供间接和堆分配;它们没有任何其他特殊功能,例如我们将在其他智能指针类型中看到的那些功能。它们也没有这些特殊功能带来的性能开销,因此它们在像 cons 列表这样的情况下很有用,在这种情况下,间接是我们唯一需要的功能。我们还将在第 17 章中介绍 box 的更多用例。

Box<T> 类型是一种智能指针,因为它实现了 Deref trait,这允许将 Box<T> 值像引用一样对待。当 Box<T> 值超出作用域时,该 box 指向的堆数据也会被清理,这是因为实现了 Drop trait。这两个 trait 对于我们在本章其余部分讨论的其他智能指针类型提供的功能将更加重要。让我们更详细地探讨这两个 trait。