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}!");
}

我们可以像 hello("Rust"); 这样传入字符串字面量。而通过隐式解引用处理后,也可以传入 MyBox<String> 类型的引用:

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&m);
}

我们将 &m 传入 hello,其中 mMyBox<String>,Rust 先调用我们实现的 deref,将 &MyBox<String> 转换为 &String,然后再使用标准库对 StringDeref 实现转换为 &str,最终匹配函数参数类型。

如果没有隐式解引用的话,我们就不得不这样写:

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&(*m)[..]);
}

这里 *mMyBox<String> 解引用为 String,然后 &[..] 提取字符串切片以匹配函数签名,这段代码明显更复杂、更难理解。

当涉及的类型实现了 Deref,Rust 会分析类型并插入所需次数的 deref 调用,直到得到符合函数参数类型的引用,这个过程在编译期完成,不带来运行时开销。

2.5 隐式解引用与可变性

与为不可变引用实现 Deref 类似,我们可以通过实现 DerefMut trait 来覆盖对可变引用的 * 操作符。

Rust 在以下三种情况中会执行隐式解引用:

  1. &T&U(当 T: Deref<Target = U>
  2. &mut T&mut U(当 T: DerefMut<Target = U>
  3. &mut T&U(当 T: Deref<Target = U>

前两种很直观,第三种稍微复杂一些,Rust 允许将可变引用强制转换为不可变引用,但反过来不行。原因在于 Rust 的借用规则:可变引用必须是唯一引用,如果允许将不可变引用转换为可变引用,就不能保证这是唯一引用,可能会违反借用规则。

因此 Rust 不能假设从不可变引用转换为可变引用是安全的。

这就是 Rust 中 Deref 与解引用强制的全部内容。这一特性极大地提升了智能指针的可用性,使它们与引用几乎等价。

2.6 Drop Trait

智能指针模式中另一个重要的 trait 是 Drop,它允许你自定义某个值即将离开作用域时发生的行为。

你可以为任何类型实现 Drop trait,其对应的代码可以用于释放诸如文件、网络连接等资源。

我们在介绍智能指针的背景下引入 Drop,是因为在实现智能指针时,几乎总是会使用这个 trait。例如,当一个 Box<T> 被丢弃时,它会释放堆上的内存空间。

在C/C++中,对于某些类型,程序员必须在每次使用完一个实例后手动调用代码来释放内存或资源。例如文件句柄、套接字和锁等资源,如果忘记调用,系统可能资源耗尽甚至崩溃(C++可以使用类避免)。

而在 Rust 中,你可以指定一段代码在值离开作用域时自动运行,编译器会自动插入这段代码。这样一来,你就不需要在程序的各处手动添加清理代码,也不会导致资源泄漏。

你可以通过实现 Drop trait 来指定值离开作用域时需要执行的代码。这个 trait 要求你实现一个名为 drop 的方法,它接受一个对自身的可变引用。为了观察 Rust 在何时调用 drop,我们暂时用 println! 来演示。

下面是一个简单的示例代码:

struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {