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

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

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

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

我们将通过“使用盒子启用递归类型”部分来演示第一种情况。在 第二种情况下,转移大量数据的所有权可能需要很长时间,因为数据会在栈上被复制。为了在这种情况下提高性能,我们可以将大量数据存储在堆上的盒子中。 然后,只有少量的指针数据会在栈上被复制,而它引用的数据会保持在堆上的一个位置。第三种情况被称为特征对象,第18章专门有一个部分,“使用允许不同类型的值的特征对象”,专门讨论这个主题。所以你在这里学到的内容在第18章还会再次应用!

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

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

列表 15-1 显示了如何使用一个盒子在堆上存储一个 i32 值:

Filename: src/main.rs
fn main() {
    let b = Box::new(5);
    println!("b = {b}");
}
Listing 15-1: Storing an i32 value on the heap using a box

我们定义变量b具有指向值5Box,该值分配在堆上。此程序将打印b = 5;在这种情况下,我们可以像访问栈上的数据一样访问盒子中的数据。就像任何拥有值一样,当盒子超出作用域时,例如bmain结束时,它将被释放。释放包括盒子(存储在栈上)和它指向的数据(存储在堆上)。

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

使用 Box 启用递归类型

递归类型的值可以包含另一个相同类型的值。递归类型存在问题,因为在编译时 Rust 需要知道一个类型占用多少空间。然而,递归类型值的嵌套理论上可以无限继续,所以 Rust 无法知道该值需要多少空间。因为盒子有一个已知的大小,我们可以通过在递归类型定义中插入一个盒子来启用递归类型。

作为递归类型的一个例子,让我们探讨一下cons list。这是一种在函数式编程语言中常见的数据类型。我们将定义的 cons list 类型除了递归之外非常直接;因此,我们将在示例中使用的概念在涉及更复杂递归类型的情况时也会很有用。

More Information About the Cons List

一个 cons list 是一种来自 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 列表开始,我们可以探索盒子如何让我们定义递归数据类型而不受太多干扰。

列表 15-2 包含一个 cons 列表的枚举定义。请注意,此代码还不能编译,因为 List 类型没有已知的大小,我们将对此进行演示。

Filename: src/main.rs
enum List {
    Cons(i32, List),
    Nil,
}

fn main() {}
Listing 15-2: The first attempt at defining an enum to represent a cons list data structure of i32 values

注意:为了这个例子,我们实现了一个只持有i32值的 cons 列表。我们本可以使用泛型来实现它,正如我们在第 10 章中讨论的那样,定义一个可以存储任何类型值的 cons 列表类型。

使用 List 类型来存储列表 1, 2, 3 将如清单 15-3 所示:

Filename: 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)));
}
Listing 15-3: Using the List enum to store the list 1, 2, 3

第一个 Cons 值持有 1 和另一个 List 值。这个 List 值是 另一个 Cons 值,持有 2 和另一个 List 值。这个 List 值 是另一个 Cons 值,持有 3 和一个 List 值,最终是 Nil,这个非递归变体表示列表的结束。

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

Filename: output.txt
$ 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
Listing 15-4: The error we get when attempting to define a recursive enum

错误显示这种类型“具有无限大小。”原因是我们在定义List时使用了一个递归的变体:它直接持有另一个自身的值。因此,Rust 无法确定存储List值所需的空间。让我们分析为什么会发生这个错误。首先,我们将看看 Rust 是如何决定存储非递归类型值所需的空间。

Computing the Size of a Non-Recursive Type

回忆我们在第 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 尝试确定像 Listing 15-2 中的 List 枚举这样的递归类型需要多少空间时发生的情况进行对比。编译器首先查看 Cons 变体,它包含一个类型为 i32 的值和一个类型为 List 的值。因此,Cons 需要的空间量等于 i32 的大小加上 List 的大小。为了确定 List 类型需要多少内存,编译器查看变体,从 Cons 变体开始。Cons 变体包含一个类型为 i32 的值和一个类型为 List 的值,这个过程会无限继续,如图 15-1 所示。

An infinite Cons list

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

Using Box<T> to Get a Recursive Type with a Known Size

因为 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 中的代码,这样可以编译:

Filename: 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))))));
}
Listing 15-5: Definition of List that uses Box<T> in order to have a known size

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

A finite Cons list

图15-2: 一个不是无限大小的List 因为Cons持有一个Box

Boxes 只提供间接性和堆分配;它们没有其他特殊功能,比如我们将在其他智能指针类型中看到的那些。它们也没有这些特殊功能带来的性能开销,因此在只需要间接性的情况下(如 cons 列表),它们可以非常有用。我们还将在第 18 章中探讨更多使用 Boxes 的场景。

Box<T> 类型是一个智能指针,因为它实现了 Deref 特性,这使得 Box<T> 值可以像引用一样被处理。当 Box<T> 值超出作用域时,由于实现了 Drop 特性,盒子指向的堆数据也会被清理。这两个特性对于本章后续讨论的其他智能指针类型所提供的功能将更加重要。让我们更详细地探讨这两个特性。