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

最简单的智能指针是盒子,其类型写为 Box<T>。盒子允许你将数据存储在堆上而不是栈上。栈上剩下的只是指向堆数据的指针。请参考第 4 章回顾栈和堆之间的区别。

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

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

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

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

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

清单 15-1 展示了如何使用盒子在堆上存储一个 i32

文件名:src/main.rs

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

清单 15-1:使用盒子在堆上存储一个 i32

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

将单个值放在堆上并没有什么用处,所以你不会经常单独使用盒子。在大多数情况下,将像单个 i32 这样的值放在栈上,它们默认情况下存储在那里,更合适。让我们看看盒子允许我们定义哪些类型,而如果没有盒子,我们就无法定义这些类型。

使用盒子启用递归类型

具有递归类型的值可以包含另一个与其类型相同的自身值。递归类型会带来问题,因为 Rust 需要在编译时知道一个类型的占用空间。但是,递归类型值的嵌套理论上可以无限地进行,因此 Rust 无法知道该值需要多少空间。由于盒子具有已知的大小,我们可以通过在递归类型定义中插入一个盒子来启用递归类型。

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

关于 Cons 列表的更多信息

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

例如,以下是一个包含列表 1、2、3 的 cons 列表的伪代码表示,每个配对都在括号中

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

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

cons 列表在 Rust 中不是常用的数据结构。在 Rust 中,当您有一个项目列表时,Vec<T> 通常是更好的选择。其他更复杂的递归数据类型在各种情况下确实有用,但是从本章的 cons 列表开始,我们可以探索盒子如何让我们定义递归数据类型,而不会造成太多干扰。

清单 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>),
  |               ++++    +

For more information about this error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 1 previous error

清单 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 的大小加上存储盒子的指针数据所需的空间。Nil 变体不存储任何值,因此它需要的空间比 Cons 变体少。我们现在知道任何 List 值都将占用 i32 的大小加上盒子指针数据的大小。通过使用盒子,我们打破了无限的递归链,因此编译器可以确定存储 List 值所需的大小。图 15-2 显示了 Cons 变体现在的样子。

A finite Cons list

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

盒子只提供间接和堆分配;它们没有任何其他特殊功能,比如我们在本章后面将看到的其他智能指针类型提供的功能。它们也没有这些特殊功能带来的性能开销,因此它们可以在像 cons 列表这样的情况下使用,在这种情况下,间接是唯一需要的功能。我们将在第 17 章中进一步探讨盒子的更多用例。

Box<T> 类型是一个智能指针,因为它实现了 Deref 特性,该特性允许将 Box<T> 值视为引用。当 Box<T> 值超出作用域时,盒子指向的堆数据也会被清理,这是由于 Drop 特性实现造成的。这两个特性对于我们在本章剩余部分讨论的其他智能指针类型提供的功能来说更为重要。让我们更详细地探索这两个特性。