使用 Box<T>
指向堆上的数据
最直接的智能指针是 box,其类型写作 Box<T>
。Box 允许你将数据存储在堆上而不是栈上。栈上保留的是指向堆数据的指针。请参考第 4 章回顾栈和堆之间的区别。
除了将其数据存储在堆上而不是栈上之外,Box 没有性能开销。但它们也没有太多额外的功能。你最常在以下情况中使用它们:
- 当你有一个类型,其大小在编译时无法得知,并且你想在需要确切大小的上下文中使用该类型的值时
- 当你拥有大量数据并想转移所有权,但确保在这样做时数据不会被复制时
- 当你想拥有一个值,并且只关心它是一个实现了特定 trait 的类型,而不是一个特定类型时
我们将在 “使用 Box 启用递归类型”部分演示第一种情况。在第二种情况下,转移大量数据的所有权可能需要很长时间,因为数据在栈上被复制。为了提高这种情况下的性能,我们可以将大量数据存储在堆上的 box 中。然后,只有少量指针数据在栈上被复制,而它引用的数据则保留在堆上的一个位置。第三种情况被称为 trait 对象,第 18 章专门用一整个章节,“使用允许不同类型值的 Trait 对象”,来讨论这个主题。所以你在这里学到的东西将在第 18 章再次应用!
使用 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
的值为一个 Box
,它指向值 5
,该值分配在堆上。这个程序将打印 b = 5
;在这种情况下,我们可以像访问栈上的数据一样访问 box 中的数据。就像任何拥有所有权的值一样,当 box 超出作用域时,就像 main
函数末尾的 b
一样,它将被释放。释放会同时发生在 box(存储在栈上)和它指向的数据(存储在堆上)上。
将单个值放在堆上不是很有用,因此你不会经常单独以这种方式使用 box。在大多数情况下,将像单个 i32
这样的值放在栈上(默认情况下存储在那里)更为合适。让我们看看 box 允许我们定义如果我们没有 box 就无法定义的类型的情况。
使用 Box 启用递归类型
递归类型 的值可以将另一个相同类型的值作为自身的一部分。递归类型会带来一个问题,因为在编译时 Rust 需要知道一个类型占用多少空间。然而,递归类型值的嵌套在理论上可以无限继续,因此 Rust 无法知道该值需要多少空间。由于 box 的大小是已知的,我们可以通过在递归类型定义中插入一个 box 来启用递归类型。
作为递归类型的示例,让我们探讨 cons list。这是一种常见于函数式编程语言中的数据类型。我们将定义的 cons list 类型除了递归之外都很简单;因此,我们将使用的示例中的概念在任何时候你遇到涉及递归类型的更复杂情况时都将很有用。
关于 Cons List 的更多信息
cons list 是一种来自 Lisp 编程语言及其方言的数据结构,由嵌套对组成,是 Lisp 版本的链表。它的名字来源于 Lisp 中的 cons
函数(“construct function” 的缩写),该函数从其两个参数构造一个新的对。通过对由一个值和另一个对组成的对调用 cons
,我们可以构造由递归对组成的 cons list。
例如,这是一个包含列表 1, 2, 3 的 cons list 的伪代码表示,每个对都在括号中
(1, (2, (3, Nil)))
cons list 中的每个条目包含两个元素:当前条目的值和下一个条目。列表中的最后一个条目仅包含一个名为 Nil
的值,而没有下一个条目。cons list 通过递归调用 cons
函数产生。表示递归基本情况的规范名称是 Nil
。请注意,这与第 6 章中的 “null” 或 “nil” 概念不同,后者是无效或缺失的值。
cons list 在 Rust 中不是一种常用的数据结构。在 Rust 中,当你有项目列表时,大多数时候 Vec<T>
是更好的选择。其他更复杂的递归数据类型在各种情况下 是 有用的,但通过从本章的 cons list 开始,我们可以探索 box 如何让我们定义递归数据类型而不会有太多干扰。
列表 15-2 包含 cons list 的 enum 定义。请注意,这段代码目前还无法编译,因为 List
类型的大小未知,我们将对此进行演示。
文件名:src/main.rs
enum List {
Cons(i32, List),
Nil,
}
fn main() {}
列表 15-2:首次尝试定义一个 enum 来表示 i32
值的 cons list 数据结构
注意:为了本示例的目的,我们正在实现一个仅保存 i32
值的 cons list。我们可以使用泛型来实现它,正如我们在第 10 章中讨论的那样,以定义一个可以存储任何类型值的 cons list 类型。
使用 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 所示。
图 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
变体的外观。
图 15-2:一个不是无限大小的 List
,因为 Cons
持有 Box
Box 仅提供间接和堆分配;它们没有其他特殊功能,例如我们将在其他智能指针类型中看到的功能。它们也没有这些特殊功能带来的性能开销,因此它们在 cons list 这样的情况下非常有用,在 cons list 中,间接是我们唯一需要的功能。我们还将在第 18 章中看到 box 的更多用例。
Box<T>
类型是一种智能指针,因为它实现了 Deref
trait,这允许将 Box<T>
值视为引用。当 Box<T>
值超出作用域时,box 指向的堆数据也会被清理,这是因为实现了 Drop
trait。这两个 trait 对于我们将在本章其余部分讨论的其他智能指针类型提供的功能更为重要。让我们更详细地探讨这两个 trait。