像对待普通引用一样处理智能指针

RustRustBeginner
立即练习

This tutorial is from open-source community. Access the source code

💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版

简介

欢迎来到「像对待普通引用一样对待智能指针(Treating Smart Pointers Like Regular References With Deref)」。本实验是 《Rust 程序设计语言》 的一部分。你可以在 LabEx 中练习你的 Rust 技能。

在本实验中,我们将探索如何通过实现 Deref 特性来让智能指针像普通引用一样被对待,以及 Rust 的解引用强制转换特性如何使我们能够使用引用或智能指针。

像对待普通引用一样对待智能指针(Treating Smart Pointers Like Regular References with Deref)

实现 Deref 特性可以让你自定义解引用运算符 * 的行为(不要与乘法运算符或通配符运算符混淆)。通过以某种方式实现 Deref,使得智能指针可以像普通引用一样被对待,你就可以编写操作引用的代码,并将其用于智能指针。

让我们首先看看解引用运算符如何与普通引用一起工作。然后,我们将尝试定义一个行为类似于 Box<T> 的自定义类型,并看看为什么解引用运算符在我们新定义的类型上的行为不像引用。我们将探索实现 Deref 特性如何使智能指针能够以类似于引用的方式工作。然后,我们将研究 Rust 的解引用强制转换特性,以及它如何让我们能够使用引用或智能指针。

注意:我们即将构建的 MyBox<T> 类型与真正的 Box<T> 有一个很大的区别:我们的版本不会将其数据存储在堆上。我们将这个示例的重点放在 Deref 上,所以数据实际存储的位置不如类似指针的行为重要。

跟随指针找到值

普通引用是一种指针类型,而将指针看作是指向存储在其他地方的值的箭头是一种思考方式。在清单 15-6 中,我们创建了一个指向 i32 值的引用,然后使用解引用运算符来跟随该引用找到值。

文件名:src/main.rs

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

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

清单 15-6:使用解引用运算符跟随指向 i32 值的引用

变量 x 存储了一个 i325 (第 1 行)。我们将 y 设置为指向 x 的引用(第 2 行)。我们可以断言 x 等于 5 (第 3 行)。然而,如果我们想要对 y 中的值进行断言,我们必须使用 *y 来跟随引用找到它所指向的值(即解引用),这样编译器才能比较实际的值(第 4 行)。一旦我们对 y 进行解引用,我们就可以访问 y 所指向的整数值,然后将其与 5 进行比较。

如果我们尝试写成 assert_eq!(5, y);,我们会得到如下编译错误:

error[E0277]: can't compare `{integer}` with `&{integer}`
 --> src/main.rs:6:5
  |
6 |     assert_eq!(5, y);
  |     ^^^^^^^^^^^^^^^^ no implementation for `{integer} ==
&{integer}`
  |
  = help: the trait `PartialEq<&{integer}>` is not implemented
for `{integer}`

不允许比较一个数字和一个指向数字的引用,因为它们是不同的类型。我们必须使用解引用运算符来跟随引用找到它所指向的值。

像使用引用一样使用 Box<T>

我们可以重写清单 15-6 中的代码,使用 Box<T> 而不是引用;清单 15-7 中对 Box<T> 使用的解引用运算符,其功能与清单 15-6 中对引用使用的解引用运算符相同。

文件名:src/main.rs

fn main() {
    let x = 5;
  1 let y = Box::new(x);

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

清单 15-7:对 Box<i32> 使用解引用运算符

清单 15-7 和清单 15-6 的主要区别在于,这里我们将 y 设置为一个指向 x 的复制值的盒子实例,而不是指向 x 的值的引用(第 1 行)。在最后一个断言中(第 2 行),我们可以使用解引用运算符来跟随盒子的指针,就像 y 是引用时一样。接下来,我们将通过定义自己的盒子类型来探索 Box<T> 的特殊之处,正是这种特殊之处使我们能够使用解引用运算符。

定义我们自己的智能指针

让我们构建一个类似于标准库提供的 Box<T> 类型的智能指针,以体验智能指针在默认情况下与引用的行为有何不同。然后我们将看看如何添加使用解引用运算符的能力。

Box<T> 类型最终被定义为一个带有一个元素的元组结构体,所以清单 15-8 以相同的方式定义了一个 MyBox<T> 类型。我们还将定义一个 new 函数,以匹配在 Box<T> 上定义的 new 函数。

文件名:src/main.rs

 1 struct MyBox<T>(T);

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

清单 15-8:定义一个 MyBox<T> 类型

我们定义了一个名为 MyBox 的结构体,并声明了一个泛型参数 T (第 1 行),因为我们希望我们的类型能够持有任何类型的值。MyBox 类型是一个带有一个 T 类型元素的元组结构体。MyBox::new 函数接受一个 T 类型的参数(第 2 行),并返回一个持有传入值的 MyBox 实例(第 3 行)。

让我们尝试将清单 15-7 中的 main 函数添加到清单 15-8 中,并将其修改为使用我们定义的 MyBox<T> 类型而不是 Box<T>。清单 15-9 中的代码不会编译,因为 Rust 不知道如何对 MyBox 进行解引用。

文件名:src/main.rs

fn main() {
    let x = 5;
    let y = MyBox::new(x);

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

清单 15-9:尝试以使用引用和 Box<T> 的相同方式使用 MyBox<T>

这是产生的编译错误:

error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
  --> src/main.rs:14:19
   |
14 |     assert_eq!(5, *y);
   |                   ^^

我们的 MyBox<T> 类型不能被解引用,因为我们还没有在我们的类型上实现这种能力。为了能够使用 * 运算符进行解引用,我们需要实现 Deref 特性。

实现 Deref 特性

如“在类型上实现特性”中所讨论的,要实现一个特性,我们需要为该特性的所需方法提供实现。标准库提供的 Deref 特性要求我们实现一个名为 deref 的方法,该方法借用 self 并返回对内部数据的引用。清单 15-10 包含了一个要添加到 MyBox<T> 定义中的 Deref 实现。

文件名:src/main.rs

use std::ops::Deref;

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

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

清单 15-10:在 MyBox<T> 上实现 Deref

type Target = T; 语法(第 1 行)为 Deref 特性定义了一个关联类型以供使用。关联类型是声明泛型参数的一种稍有不同的方式,但目前你无需担心;我们将在第 19 章更详细地介绍它们。

我们在 deref 方法的主体中填入 &self.0,这样 deref 就返回一个对我们想用 * 运算符访问的值的引用(第 2 行);回想一下“使用没有命名字段的元组结构体创建不同类型”,.0 用于访问元组结构体中的第一个值。清单 15-9 中对 MyBox<T> 值调用 *main 函数现在可以编译了,并且断言通过!

如果没有 Deref 特性,编译器只能对 & 引用进行解引用。deref 方法让编译器能够获取任何实现了 Deref 的类型的值,并调用 deref 方法以获得一个它知道如何解引用的 & 引用。

当我们在清单 15-9 中输入 *y 时,在幕后 Rust 实际上运行了这段代码:

*(y.deref())

Rust 用对 deref 方法的调用替换 * 运算符,然后进行一次普通的解引用,这样我们就不必考虑是否需要调用 deref 方法。这个 Rust 特性让我们编写的代码,无论使用的是普通引用还是实现了 Deref 的类型,其功能都是相同的。

deref 方法返回对一个值的引用,并且在 *(y.deref()) 中括号外的普通解引用仍然是必要的,这与所有权系统有关。如果 deref 方法直接返回值而不是对值的引用,那么值就会从 self 中被移出。在这种情况下,或者在大多数我们使用解引用运算符的情况下,我们都不想获取 MyBox<T> 内部值的所有权。

请注意,每次我们在代码中使用 * 时,* 运算符都会被替换为对 deref 方法的调用,然后再进行一次对 * 运算符的调用。因为 * 运算符的替换不会无限递归,所以最终我们得到的是 i32 类型的数据,这与清单 15-9 中 assert_eq! 里的 5 相匹配。

函数和方法中的隐式解引用强制转换

解引用强制转换 将对实现了 Deref 特性的类型的引用转换为对另一种类型的引用。例如,解引用强制转换可以将 &String 转换为 &str,因为 String 实现了 Deref 特性,使其返回 &str。解引用强制转换是 Rust 对函数和方法的参数执行的一种便利操作,并且仅适用于实现了 Deref 特性的类型。当我们将对特定类型值的引用作为参数传递给函数或方法,而该参数类型与函数或方法定义中的参数类型不匹配时,解引用强制转换会自动发生。对 deref 方法的一系列调用会将我们提供的类型转换为参数所需的类型。

Rust 中添加解引用强制转换是为了让编写函数和方法调用的程序员无需使用 &* 添加那么多显式的引用和解引用操作。解引用强制转换特性还使我们能够编写更多既适用于引用又适用于智能指针的代码。

为了看到解引用强制转换的实际效果,我们使用在清单 15-8 中定义的 MyBox<T> 类型以及在清单 15-10 中添加的 Deref 实现。清单 15-11 展示了一个具有字符串切片参数的函数定义。

文件名:src/main.rs

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

清单 15-11:一个具有 &str 类型参数 namehello 函数

例如,我们可以用字符串切片作为参数调用 hello 函数,比如 hello("Rust");。解引用强制转换使得可以用对 MyBox<String> 类型值的引用调用 hello,如清单 15-12 所示。

文件名:src/main.rs

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

清单 15-12:使用对 MyBox<String> 值的引用调用 hello,这由于解引用强制转换而可行

在这里,我们用参数 &m 调用 hello 函数,&m 是对 MyBox<String> 值的引用。因为我们在清单 15-10 中为 MyBox<T> 实现了 Deref 特性,Rust 可以通过调用 deref&MyBox<String> 转换为 &String。标准库为 String 提供了一个返回字符串切片的 Deref 实现,这在 Deref 的 API 文档中。Rust 再次调用 deref&String 转换为 &str,这与 hello 函数的定义相匹配。

如果 Rust 没有实现解引用强制转换,那么我们将不得不编写清单 15-13 中的代码,而不是清单 15-12 中的代码,来用 &MyBox<String> 类型的值调用 hello

文件名:src/main.rs

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

清单 15-13:如果 Rust 没有解引用强制转换我们将不得不编写的代码

(*m)MyBox<String> 解引用为 String。然后 &[..]String 中获取一个等于整个字符串的字符串切片,以匹配 hello 的签名。这段没有解引用强制转换的代码,由于涉及所有这些符号,更难阅读、编写和理解。解引用强制转换允许 Rust 为我们自动处理这些转换。

当为涉及的类型定义了 Deref 特性时,Rust 会分析类型,并根据需要多次使用 Deref::deref 以获得与参数类型匹配的引用。Deref::deref 需要插入的次数在编译时确定,所以利用解引用强制转换不会有运行时开销!

解引用强制转换如何与可变性交互

与你使用 Deref 特性来重载不可变引用上的 * 运算符的方式类似,你可以使用 DerefMut 特性来重载可变引用上的 * 运算符。

当 Rust 在三种情况下找到类型和特性实现时,会进行解引用强制转换:

  • T: Deref<Target=U> 时,从 &T 转换为 &U
  • T: DerefMut<Target=U> 时,从 &mut T 转换为 &mut U
  • T: Deref<Target=U> 时,从 &mut T 转换为 &U

前两种情况是相同的,只是第二种情况实现了可变性。第一种情况表明,如果你有一个 &T,并且 T 实现了到某个类型 UDeref,那么你可以透明地获得一个 &U。第二种情况表明,对于可变引用也会发生相同的解引用强制转换。

第三种情况更棘手:Rust 也会将可变引用强制转换为不可变引用。但反过来是不可能的:不可变引用永远不会强制转换为可变引用。由于借用规则,如果你有一个可变引用,那么该可变引用必须是对该数据的唯一引用(否则,程序将无法编译)。将一个可变引用转换为一个不可变引用永远不会违反借用规则。将一个不可变引用转换为一个可变引用将要求初始的不可变引用是对该数据的唯一不可变引用,但借用规则并不能保证这一点。因此,Rust 不能假设将不可变引用转换为可变引用是可能的。

总结

恭喜你!你已经完成了“使用 Deref 像对待普通引用一样处理智能指针”实验。你可以在 LabEx 中练习更多实验来提升你的技能。