简介
欢迎来到「使用 Box<T> 指向堆上的数据」。本实验是 《Rust 程序设计语言》 的一部分。你可以在 LabEx 中练习 Rust 技能。
在本实验中,我们将学习在以下几种情况下,如何使用 Box<T> 智能指针在堆上而非栈上存储数据:类型大小在编译时未知、转移大量数据的所有权以避免复制,或者拥有实现特定 trait 的值。
欢迎来到「使用 Box<T> 指向堆上的数据」。本实验是 《Rust 程序设计语言》 的一部分。你可以在 LabEx 中练习 Rust 技能。
在本实验中,我们将学习在以下几种情况下,如何使用 Box<T> 智能指针在堆上而非栈上存储数据:类型大小在编译时未知、转移大量数据的所有权以避免复制,或者拥有实现特定 trait 的值。
Box<T> 指向堆上的数据最直接的智能指针是「盒指针」,其类型写作 Box<T>。盒指针允许你将数据存储在堆上而非栈上。留在栈上的是指向堆数据的指针。请参考第 4 章回顾栈和堆的区别。
除了将数据存储在堆上而非栈上之外,盒指针没有性能开销。但它们也没有太多额外的功能。你最常在以下几种情况中使用它们:
我们将在「使用盒指针实现递归类型」中演示第一种情况。在第二种情况下,转移大量数据的所有权可能需要很长时间,因为数据在栈上被复制来复制去。为了在这种情况下提高性能,我们可以将大量数据存储在堆上的一个盒指针中。然后,只有少量的指针数据在栈上被复制,而它所引用的数据则留在堆上的一个位置。第三种情况被称为「trait 对象」,「使用允许不同类型值的 trait 对象」将专门讨论这个主题。所以你在这里学到的知识将在该部分再次应用!
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,其值为一个指向堆上分配的 5 的 Box。这个程序会打印 b = 5;在这种情况下,我们可以像访问栈上的数据一样访问盒指针中的数据。就像任何拥有所有权的值一样,当盒指针超出作用域时,就像 b 在 main 函数结束时那样,它会被释放。释放操作会同时作用于盒指针(存储在栈上)及其指向的数据(存储在堆上)。
将单个值存储在堆上并不是很有用,所以你不会经常以这种方式单独使用盒指针。在大多数情况下,将像单个 i32 这样的值存储在默认的栈上会更合适。让我们来看一个例子,在这个例子中,如果没有盒指针,我们将无法定义某些类型,而盒指针使我们能够定义这些类型。
「递归类型」的值可以将另一个相同类型的值作为自身的一部分。递归类型带来了一个问题,因为在编译时 Rust 需要知道一个类型占用多少空间。然而,递归类型的值的嵌套理论上可以无限延续,所以 Rust 无法知道该值需要多少空间。由于盒指针具有已知的大小,我们可以通过在递归类型定义中插入一个盒指针来实现递归类型。
作为递归类型的一个示例,我们来探讨一下「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 列表开始,我们可以探索盒指针如何让我们定义一个递归数据类型而不会有太多干扰。
清单 15-2 包含了一个 cons 列表的枚举定义。请注意,这段代码还不能编译,因为 List 类型的大小未知,我们将对此进行演示。
文件名:src/main.rs
enum List {
Cons(i32, List),
Nil,
}
清单 15-2:定义一个枚举来表示 i32 值的 cons 列表数据结构的首次尝试
注意:为了这个示例的目的,我们正在实现一个只包含
i32值的 cons 列表。正如我们在第 10 章中讨论的,我们可以使用泛型来实现它,以定义一个可以存储任何类型值的 cons 列表类型。
使用 List 类型来存储列表 1, 2, 3 看起来会像清单 15-3 中的代码。
文件名:src/main.rs
--snip--
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 值,最后这个 List 值是 Nil,即表示列表结束的非递归变体。
如果我们尝试编译清单 15-3 中的代码,我们会得到清单 15-4 中所示的错误。
error[E0072]: recursive type `List` has infinite size
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^ recursive type has infinite size
2 | Cons(i32, List),
| ---- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to make `List`
representable
|
2 | Cons(i32, Box<List>),
| ++++ +
清单 15-4:尝试定义递归枚举时得到的错误
错误显示这个类型「大小无限」。原因是我们定义的 List 有一个递归变体:它直接包含自身的另一个值。因此,Rust 无法确定存储一个 List 值需要多少空间。让我们来分析一下为什么会得到这个错误。首先,我们来看看 Rust 如何确定存储一个非递归类型的值需要多少空间。
回想一下我们在第 6 章讨论枚举定义时在清单 6-2 中定义的 Message 枚举:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
为了确定为一个 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 make `List`
representable
|
2 | Cons(i32, Box<List>),
| ++++ +
在这个建议中,「间接性」意味着我们不应直接存储值,而是应该通过存储指向该值的指针来间接存储值,从而改变数据结构。
因为 Box<T> 是一个指针,Rust 始终知道一个 Box<T> 需要多少空间:指针的大小不会因其所指向的数据量而改变。这意味着我们可以在 Cons 变体中放入一个 Box<T>,而不是直接放入另一个 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 变体的样子。
图 15-2:一个大小不是无限的 List,因为 Cons 包含一个 Box
盒子只提供间接性和堆分配;它们没有任何其他特殊功能,比如我们将在其他智能指针类型中看到的那些功能。它们也没有这些特殊功能所带来的性能开销,所以在像 cons 列表这样间接性是我们唯一需要的功能的情况下,它们可能会很有用。我们将在第 17 章中查看盒子的更多用例。
Box<T> 类型是一个智能指针,因为它实现了 Deref 特性,这使得 Box<T> 值可以像引用一样被处理。当一个 Box<T> 值超出作用域时,由于 Drop 特性的实现,盒子所指向的堆数据也会被清理。这两个特性对于我们在本章剩余部分将讨论的其他智能指针类型所提供的功能来说将更加重要。让我们更详细地探讨这两个特性。
恭喜你!你已经完成了「使用 Box