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类型一样。

之所以我们可以完全无感知的使用智能指针Box,原因就在于它实现了这个trait:Deref。

2.3 Deref trait

实现 Deref trait 的好处在于,它可以让我们自定义解引用操作符 *

比如下面这个简单的代码:

fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

这是完全可以正常执行的,因为y是x的引用,然后后面使用了解引用符号*,所以解引用之后的*y实际上就是x。

可如果将解引用去掉,那么就会报错:

image.png

报错意识很明显,左边是数值、右边是数值的引用,由于我们没有为其实现相关的trait,导致两者没有可比性。

将上面代码中的&号更改为Box类型,除了它x的值将被拷贝到堆中、不再是x本身之外,其它都是一样的:

fn main() {
    let x = 5;
    let y = Box::new(x); //会在堆上分配内存将x拷贝过去

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

为了能够更好的理解Box类型的原理,这里我们来自定义一个简单的MyBox:

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

我们定义了一个结构体 MyBox,它唯一的字段是一个元组值,并声明了泛型参数 T,使它可以容纳任意类型的值。

MyBox 是一个元组结构体,包含一个类型为 T 的字段。MyBox::new 函数接受一个类型为 T 的参数,并返回一个包装了该值的 MyBox 实例。

此时我们使用上面相同的代码,就会发现会报错:

image.png

原因就是我们没有为其实现Deref这个trait,导致其没有解引用的能力。

而实现的方式并不难:

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

type Target = T; 定义了 Deref trait 所需的关联类型,这里暂时不必深入理解关联类型,简单来说就是自定义它要处理的类型,这里是泛型T,因为我们结构体使用的泛型。

deref 方法的实现体使用 &self.0,即返回元组结构体中第一个元素的引用。这意味着使用 * 解引用时会访问我们希望公开的内部值。

此时上面的代码就能够正常使用了,当使用解引用时,实际上就会调用这个函数拿到返回值:

image.png

在没有 Deref 的情况下,编译器只能解引用 & 引用。实现 deref 方法后,编译器可以将任意实现了 Deref 的类型转换为 & 引用,并执行解引用。

当写下 *y 时,Rust 实际上做了如下转换:

*(y.deref())

Rust 将 * 替换为 deref 方法调用后再进行解引用,这样我们无需显式地调用 deref 方法。

之所以 deref 返回的是引用而不是值本身,是因为 Rust 的所有权系统。

如果返回的是值,则意味着值会从 self 中移动出去,而我们通常并不想获取 MyBox<T> 内部值的所有权,仅需访问它。

注意,这种替换只发生一次,即每次使用 * 时最多替换一次,并不会无限递归。因此我们最终得到的是 i32 类型的数据,可以与 assert_eq! 中的 5 进行比较。

2.4 函数与方法中的隐式解引用

隐式解引用指的是将实现了 Deref trait 的类型的引用,自动转换为另一个类型的引用。例如,&String 可以自动转换为 &str,因为 String 实现了 Deref,其目标类型为 str

这是 Rust 编译器在函数和方法调用时为我们自动执行的转换,仅适用于实现了 Deref 的类型。当我们传入一个类型的引用作为参数,但该参数类型不完全匹配时,编译器会自动调用一系列 deref 方法,将其转换为所需类型。

这个功能的目的是让我们编写函数/方法调用时,减少显式的 &*,同时让代码对引用和智能指针都兼容。

比如下面这个简单的函数:

fn hello(name: &str) {
    println!("Hello, {name}!");
}