使用 Deref 特性将智能指针视为普通引用

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

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

注意:我们即将构建的 MyBox<T> 类型与标准库提供的真正的 Box<T> 之间有一个很大的区别:我们的版本不会在堆上存储其数据。我们专注于此示例中的 Deref,因此数据实际存储在哪里并不像指针行为那样重要。

沿着指针指向值

普通引用是一种指针类型,我们可以将指针看作指向存储在其他地方的值的箭头。在清单 15-6 中,我们创建了一个指向 i32 值的引用,然后使用解引用运算符沿着引用指向值

文件名:src/main.rs

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

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

清单 15-6:使用解引用运算符沿着引用指向 i32

变量 x 持有一个 i325。我们将 y 设置为指向 x 的引用。我们可以断言 x 等于 5。但是,如果我们想对 y 中的值进行断言,我们必须使用 *y 沿着引用指向它指向的值(因此是解引用),以便编译器可以比较实际的值。一旦我们解引用 y,我们就可以访问 y 指向的整数值,我们可以将其与 5 进行比较。

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

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
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}`
  = help: the following other types implement trait `PartialEq<Rhs>`:
            isize
            i8
            i16
            i32
            i64
            i128
            usize
            u8
          and 6 others
  = note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0277`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error

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

像引用一样使用 Box<T>

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

文件名:src/main.rs

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

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

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

清单 15-7 和清单 15-6 之间的主要区别在于,这里我们将 y 设置为指向 x 的副本值的 Box<T> 实例,而不是指向 x 值的引用。在最后一个断言中,我们可以使用解引用运算符沿着 Box<T> 的指针指向,就像 y 是一个引用时一样。接下来,我们将探索 Box<T> 的特殊之处,它使我们能够通过定义自己的类型来使用解引用运算符。

定义我们自己的智能指针

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

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

文件名:src/main.rs

struct MyBox<T>(T);

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

fn main() {}

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

我们定义了一个名为 MyBox 的结构体,并声明了一个泛型参数 T,因为我们希望我们的类型可以保存任何类型的值。MyBox 类型是一个元组结构体,它只有一个类型为 T 的元素。MyBox::new 函数接受一个类型为 T 的参数,并返回一个保存传入值的 MyBox 实例。

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

文件名:src/main.rs

struct MyBox<T>(T);

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

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

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

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

以下是生成的编译错误

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
  --> src/main.rs:14:19
   |
14 |     assert_eq!(5, *y);
   |                   ^^

For more information about this error, try `rustc --explain E0614`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error

我们的 MyBox<T> 类型无法取消引用,因为我们还没有在我们的类型上实现该功能。要使用 * 运算符启用取消引用,我们实现 Deref 特性。

通过实现 Deref 特性将类型视为引用

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

文件名:src/main.rs

use std::ops::Deref;

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

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

struct MyBox<T>(T);

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

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

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

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

type Target = T; 语法为 Deref 特性定义了一个关联类型。关联类型是声明泛型参数的一种略有不同的方式,但你现在不必担心;我们将在第 19 章中详细介绍它们。

我们在 deref 方法的主体中填充 &self.0,以便 deref 返回对我们想要使用 * 运算符访问的值的引用;回想一下第 5 章 “使用没有命名字段的元组结构体创建不同的类型”部分,.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 转换

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

添加 Deref 转换到 Rust 中是为了让编写函数和方法调用的程序员不必添加太多显式引用和取消引用,使用 &*。Deref 转换功能还使我们能够编写更多适用于引用或智能指针的代码。

要查看 Deref 转换的实际操作,让我们使用我们在清单 15-8 中定义的 MyBox<T> 类型以及我们在清单 15-10 中添加的 Deref 的实现。清单 15-11 显示了定义一个具有字符串切片参数的函数

文件名:src/main.rs

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

fn main() {}

清单 15-11:一个 hello 函数,其参数 name 的类型为 &str

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

文件名:src/main.rs

use std::ops::Deref;

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

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

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

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

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

清单 15-12:使用对 MyBox<String> 值的引用调用 hello,这得益于 Deref 转换

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

如果 Rust 没有实现 Deref 转换,我们将不得不编写清单 15-13 中的代码,而不是清单 15-12 中的代码,才能使用 &MyBox<String> 类型的调用 hello

文件名:src/main.rs

use std::ops::Deref;

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

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

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

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

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

清单 15-13:如果 Rust 没有 Deref 转换,我们将不得不编写的代码

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

当为所涉及的类型定义 Deref 特性时,Rust 将分析这些类型并尽可能多次使用 Deref::deref 来获取与参数类型匹配的引用。Deref::deref 需要插入的次数在编译时解决,因此利用 Deref 转换不会产生运行时开销!

Deref 转换如何与可变性交互

与您使用 Deref 特性来覆盖不可变引用上的 * 运算符类似,您可以使用 DerefMut 特性来覆盖可变引用上的 * 运算符。

当 Rust 在三种情况下找到类型和特性实现时,它会执行 Deref 转换

  • &T&U,当 T: Deref<Target=U>
  • &mut T&mut U,当 T: DerefMut<Target=U>
  • &mut T&U,当 T: Deref<Target=U>

前两种情况与彼此相同,只是第二种情况实现了可变性。第一种情况指出,如果您有一个 &T,并且 T 实现 Deref 为某个类型 U,那么您可以透明地获得一个 &U。第二种情况指出,相同的 Deref 转换适用于可变引用。

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