写点什么

Rust 从 0 到 1- 智能指针 -Box<T>

用户头像
关注
发布于: 2021 年 07 月 07 日
Rust从0到1-智能指针-Box<T>

指针(pointer)通常是指一个包含内存地址的变量。这个地址引用或是说指向(points at)一些内存中的数据。Rust 中最常见的指针是我们在所有权章节介绍过的引用。引用以 & 符号作为标识,借用了其所指向的值。它们只是对数据的引用,没有任何其他特殊功能。因此,也不会带来任何额外的开销,是我们使用最多的指针。

而智能指针(smart pointers)除了和指针类似包含内存地址以外,还拥有额外的元数据(metadata)和功能。智能指针并不是 Rust 特有的概念,其最早是在 C++ 语言中提出来的,而且在其它一些语言中也有。Rust 标准库中包含的不同的智能指针为我们提供了除引用以外的其它功能。譬如我们后面将会讨论到的引用计数(reference counting)智能指针。这种智能指针允许数据有多个所有者,它会跟踪记录当前所有的所有者,并在没有任何所有者时进行数据清理。这也体现了 Rust 中引用和智能指针的另一个区别,即引用只是借用数据,而在很多情况下,智能指针拥有(own)他们指向的数据。

虽然前面的章节我们没有提到智能指针,但实际上已经介绍过一些智能指针了,譬如,String 和 Vec<T>。这些类型被当作是智能指针,是因为它们拥有某些内存空间并允许我们对其进行操作。并且,它们还带有元数据(如,容量)和额外的功能或保证(如,String 会确保其存储的数据都是有效的 UTF-8 编码)。

智能指针通常通过结构体实现。智能指针区别于普通结构体的特征在于其实现了 Deref 和 Drop trait。Deref trait 使智能指针结构体实例拥有像引用一样的行为,这样我们就可以编写适用于引用或智能指针的代码。Drop trait 使我们可以自定义在智能指针实例离开作用域时运行的代码。后面我们会更详细的讨论这两个 trait 以及为什么它们对于智能指针来说很重要。

鉴于智能指针是一种通用的设计模式,并且在 Rust 经常会使用到,我们不会在本章覆盖所有的智能指针。很多库都有自己的智能指针而我们也可以编写属于自己的智能指针。后面我们会介绍标准库中最常用的一些智能指针:

  • Box<T>,用于在堆上存储数据

  • Rc<T>,引用计数,可以有多个所有者

  • Ref<T> 和 RefMut<T>,通过 RefCell<T> ,强制运行时而不是编译时执行借用规则。

此外,我们还会介绍内部可变性(interior mutability),即不可变类型暴露出改变其内部值的 API。我们还会讨论循环引用(reference cycles):它们如何导致内存泄漏的,以及如何避免。下面我们首先来看看 Box<T>。

最简单的智能指针是 box,其类型是 Box<T>。box 使我们可以将一个值放在堆上而不是栈上,栈上存储的的则是指向堆上数据的指针(在所有权章节我们对堆和栈的概念做过介绍)。

除了数据被储存在堆上而不是栈上之外,box 没有额外的性能损失,同时也没有附加很多额外的功能。它通常用于以下场景:

  • 当有某个类型的数据在编译时无法获知其大小,而其所在的上下文又需要知道其确切的大小

  • 当数据量很大,我们又希望在转移所有权时确保数据不发生拷贝

  • 当我们希望获得某个数据,但是只关心它的类型是否实现了特定 trait 而不是其具体类型

第三种场景我们将在后面的 trait 对象章节讨论。

在堆上储存数据

在我们讨论 Box<T> 的应用场景之前,让我们先熟悉一下它的语法以及如何访问储存在 Box<T> 中的值。参考下面的例子:

fn main() {    let b = Box::new(5);    println!("b = {}", b);}
复制代码

上面的例子中,我们定义了变量 b,其值是指向存储在堆上的数据 5 的 Box 实例,程序会打印出 b = 5。我们可以像数据是储存在栈上的那样访问 box 中的数据。和任何拥有数据所有权的值一样,当 b 在 main 的末尾离开作用域时,它将被释放。这个释放包括 box 本身(栈上)和它所指向的数据(堆上)。

将单独的一个值存放在堆上没有太大意义,因此我们很少像上面的例子那样使用 Box。像 i32 这样类型的单个值,按照其默认的方式储存在栈上,适用于大部分场景。下面,让我们看看一个不得不使用 box 的例子。

递归类型

在编译时 Rust 就需要知道类型会占用多少空间。而有一种类型无法在编译时知道其大小,即递归类型(recursive type),它的值中包含相同类型的另一个值,这种嵌套理论上可以无限的进行下去,所以 Rust 不知道递归类型需要占用多少空间。由于 box 大小是已知的,我们可以通过在递归类型定义中使用 box 进行包装来创建它。

下面让我们以一个函数式编程语言中常见的类型 cons list 为例对其进行讨论。在例子中,我们仅关注于递归类型相关的内容,因此相关的概念也可以用于其它递归类型的场景。

Cons List

cons list 数据结构来源于 Lisp 编程语言及其方言。在 Lisp 中,cons 函数(construct function)通过它的两个参数来构造一对新值,这两个参数通常是一个单独的值和另外一对值。

cons 函数的概念用更通用的函数式编程术语来说就是:“to cons x onto y”  ,可以理解为构建一个新的容器实例,把元素 x 放在新容器的开头,其后是容器 y。

cons list 的每个实例都包含两个元素:当前值和下一个实例。其最后一个实例没有下一项,相应的值为 Nil。cons list 通过递归调用 cons 函数产生,预示递归结束的值是 Nil。注意它不同于前面我们介绍过的 null 或 nil (他们代表无效或缺失)。

虽然函数式编程语言中经常使用 cons list,但是在 Rust 并不常用。在我们需要列表的时候,Vec<T> 会是一个更好的选择。但是,在其它场景中我们会用到其它相对更为复杂的递归数据类型。这里我们以 cons list 作为例子,来展示如何使用 box 定义一个递归数据类型。

我们先来看看不使用 box 的 cons list 定义:

enum List {    Cons(i32, List),    Nil,}
复制代码

使用这个 cons list 来储存列表 1, 2, 3 :

use crate::List::{Cons, Nil};
fn main() { let list = Cons(1, Cons(2, Cons(3, Nil)));}
复制代码

第一个 Cons 储存了 1 和第二个 List 。第二个 Cons 存储了 2 和第三个 List 值。第三个 Cons 存储了 3 和 Nil(代表递归结束)。如果我们尝试编译,会得到类似下面的错误:

$ 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 {  | ^^^^^^^^^ recursive type has infinite size2 |     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>),  |               ^^^^    ^
error[E0391]: cycle detected when computing drop-check constraints for `List` --> src/main.rs:1:1 |1 | enum List { | ^^^^^^^^^ | = note: ...which again requires computing drop-check constraints for `List`, completing the cycle = note: cycle used when computing dropck types for `Canonical { max_universe: U0, variables: [], value: ParamEnvAnd { param_env: ParamEnv { caller_bounds: [], reveal: UserFacing }, value: List } }`
error: aborting due to 2 previous errors
Some errors have detailed explanations: E0072, E0391.For more information about an error, try `rustc --explain E0072`.error: could not compile `cons-list`
To learn more, run the command again with --verbose.
复制代码

上面的错误表明类型 List  “有无限的大小”(infinite size)。这是因为 List 是递归类型:它包含了另一个相同类型的值。Rust 无法计算为了存放 List 实例到底需要多少空间。接下来让我们来了解一下 Rust 如何计算非递归类型数据所需要的空间。

计算非递归类型大小

我们以前面章节例子中的枚举类型 Message 为例:

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

Rust 需要知道要为 Message 实例分配多少空间,它会检查每一个成员所需空间的大小,在上面的例子中 Message::Quit 并不需要任何空间,Message::Move 需要两个 i32 值的空间,依此类推。由于只会用到 Message 中的一个值,因此所需的空间等于占用空间最大的成员。

按照上面的方式,那么当 Rust 编译器检查递归类型 List 时会发生什么呢。编译器会尝试计算出 List 枚举需要多少空间,它会检查 Cons 成员所需要的空间, 即 i32 加上 List 的大小,而计算 List 的大小,又需要从 Cons 成员开始,Cons 成员所需的空间又是 i32 加上 List 的大小 。。。这样的计算将无限进行下去,如下图所示(来自官网):

使用 Box<T>

Rust 无法计算出要为递归类型分配多少空间,所以编译无法通过,同时编译器也给出了建议:

help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to make `List` representable  |2 |     Cons(i32, Box<List>),  |               ^^^^    ^
复制代码

indirection 意味着间接的储存一个指向值的指针,而不是直接存储这个值。由于 Box<T> 是一个指针,它所需的空间是固定的(指针的大小并不会根据其指向的数据大小而改变),因此我们可以用 Box 来替代 Cons 成员中的 List。Box 会指向另一个位于堆上的 List 值,可以认为我们仍然是在列表中 “存放” 了另一个列表,不过这种实现更像是把它们一个挨着一个,而不是一个包含另一个。参考下面使用 Box<T> 的例子:

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))))));}
复制代码

现在,Cons 成员所需要的空间就是一个 i32 加上储存 box 指针数据的空间。Nil 成员不储存值,它比 Cons 成员需要的空间更小。因此 List 所需的空间是占用空间最大的成员,即 Cons(i32 加上指针所需的空间)。通过使用 box ,打破了无限递归,这样编译器就能够计算出储存 List 所需要的大小了。如下图所示(来自官网):

Box 只提供了将数据存储在堆上的功能,没有任何其他特殊的功能,因此也没有这些特殊功能带来的额外性能损失,所以适用于像 cons list 这样仅需要间接的储存一个指向值的指针的场景。在后面的章节我们还会看到 box 的更多应用场景。

Box<T> 类型是一个智能指针,它实现了 Deref trait,使得 Box<T> 具备引用的行为;而当 Box<T> 值离开作用域时,由于其实现了 Drop trait ,其所指向的堆数据同时也会被清除。下面让我们更详细的对这两个 trait 进行讨论。

发布于: 2021 年 07 月 07 日阅读数: 6
用户头像

关注

公众号"山 顽石"欢迎大家关注;) 2021.03.23 加入

IT老兵,关注Rust、Java、前端、架构、大数据、AI、算法等;平时喜欢美食、旅游、宠物、健身、篮球、乒乓球等。希望能和大家交流分享。

评论

发布
暂无评论
Rust从0到1-智能指针-Box<T>