30.rust智能指针详解

1 前言

如果你了解C/C++的话,应该对指针非常熟悉了。

指针是一种通用概念,用于表示一个变量,该变量中存储的是某个内存地址。

这个地址指向的是其他某些数据。在 Rust 中最常见的指针类型是引用(reference),引用通过 & 符号表示,它们借用了所指向的数据。引用本身没有任何特殊功能,仅用于指向数据,并且没有额外开销。

而所谓的智能指针,指的便是帮助我们更好的去使用指针的一种类型,你可以将其看作是对指针的一种封装,它可以帮我们记录关于指针的一切数据、并能准确知道它自己应该在合适去释放掉内存,从而减少使用纯指针可能出现的安全风险。

在 Rust 中,由于其所有权与借用的设计理念,引用与智能指针之间还有一个额外的区别:引用只会借用数据,而许多情况下智能指针会拥有它们所指向的数据。

事实上,我们之前的章节中就已经用过一些智能指针,比如最常见的String、Vec。

之所以说它们是智能指针,因为它们内部都指向一块内存,并允许我们对内存执行操作,并且我们不需要关心其内部内存的管理、比如何时去释放它们。

智能指针通常是通过结构体(struct)实现的。与普通结构体不同,智能指针实现了 DerefDrop 这两个 trait。

其中Deref trait 允许智能指针像引用一样使用,因此你可以编写既适用于引用也适用于智能指针的代码。Drop trait 则允许你自定义在智能指针超出作用域时所运行的清理代码。在本章中,我们将讨论这两个 trait,并展示它们对智能指针为何重要。

由于智能指针模式是 Rust 中一种通用的设计模式,而且使用频率很高,因此本章不会涵盖所有现有的智能指针。许多第三方库中都有自定义的智能指针类型,甚至你也可以自己实现一个。我们将重点介绍标准库中最常见的智能指针类型:

  • Box:用于在堆上分配值
  • Rc:一种引用计数类型,允许多个所有者
  • RefRefMut:通过 RefCell 提供,它在运行时而非编译时强制借用规则

此外,我们还将介绍内部可变性(interior mutability)模式:即一个不可变类型通过提供 API 来允许修改其内部值。我们还会讨论引用循环的问题:它们如何导致内存泄漏,以及如何避免它们。

2 Box 用法

最直接的智能指针是 Box,其类型写作 Box<T>。Box 允许你将数据存储在堆上而不是栈上。栈上只保留一个指向堆数据的指针。

Box 本身没有额外的性能开销,同时它也没有太多额外的功能,平时开发中用的最多的场景如下:

  • 当你有一个在编译时大小不确定的类型,并希望在需要确定大小的上下文中使用该类型的值;
  • 当你有大量数据并想转移所有权,但又希望在转移过程中不发生数据复制;
  • 当你想拥有一个值的所有权,并且你只关心该值是否实现了某个特定 trait,而不关心其具体类型时。

第一种情况出现的经典场景是递归。

第二种情况比较常见,相当于将原本在栈上的数据转移到堆上,此时就只需要复制少量的指针数据节约

第三种情况是所谓的 trait 对象,章节rust中Trait详解中对其有一些简单的介绍,比如一个函数可以接收实现了某个trait的对象,学习了本章之后,我们还可以通过Box实现。

2.1 在堆上存储数据

在讨论 Box 用于堆存储的用法之前,我们先介绍它的语法以及如何与 Box 中的值交互:

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

上面的代码中,我们定义了变量 b,它的值是一个 Box,指向堆上分配的值 5,该程序将输出 b = 5

在这个例子中,我们访问 Box 中的数据与访问栈上的数据没有任何区别,就像任何拥有所有权的值一样,当 Box 在作用域结束时,它将被释放。

释放过程既包括 Box(栈上存储)本身,也包括它指向的堆数据。

仅仅将一个值放在堆上并没有太大意义,因此你很少会单独这样使用 Box。在绝大多数情况下,将像 i32 这样的值直接放在默认的栈上会更合适,因为它太小了。

2.2 递归类型

所谓递归类型,指的是一个类型本身包含了另一个自己类型的值,这在 Rust 中是个严重的问题。

因为 Rust 需要在编译时知道一个类型所占的空间,然而递归类型的嵌套理论上可以无限进行,因此 Rust 无法确定其所需的空间大小。

但由于 Box 的大小是已知的(将其看作一个指针),因此我们就可以通过在递归类型定义中插入 Box 来使用递归类型。

一个简单的链表类型定义如下:

struct Node {
    next: Node,
}

它的其中一个字段指向的是下一个节点,而这样做就会导致rust编译器无法知道这个结构体到底有多大,从而报错:

image.png

所以此时我们就需要用Box将其包裹起来,这样这个字段的大小就知道了:一个指针的大小,一般为8字节。

struct Node {
	value:i32,
    next: Box<Option<Node>>,
}

这里顺便还多包裹了一个Option类型,如果为None就代表这个链表的结束。

而且rust中一个很好的点就是,即使这样写,我们也能像正常使用Node这个类型,几乎可以完全忽视掉Box这个壳子:

image.png

比如上面代码中,我直接访问了next这个字段上的value值,而完全看不出有Box的存在,就像一个正常的Node类型一样。