深入探索 Rust 高级 trait

Beginner

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

简介

欢迎来到高级特性。本实验是《Rust 程序设计语言》的一部分。你可以在 LabEx 中练习 Rust 技能。

在本实验中,鉴于你已经对 Rust 有了更好的理解,我们将深入探讨之前在“特性:定义共享行为”中介绍过的特性的更高级细节。

高级特性

我们在“特性:定义共享行为”中首次介绍了特性,但没有讨论更深入的细节。既然你对 Rust 有了更多了解,我们就可以深入探讨具体细节了。

关联类型

关联类型 将类型占位符与一个 trait 相连接,这样 trait 方法定义就可以在其签名中使用这些占位符类型。trait 的实现者将指定要使用的具体类型,以替代特定实现中的占位符类型。这样,我们就可以定义一个使用某些类型的 trait,而无需在 trait 实现之前确切知道这些类型是什么。

我们将本章中的大多数高级特性描述为很少需要。关联类型则处于中间位置:它们的使用频率低于本书其他部分所解释的特性,但比本章讨论的许多其他特性更常见。

标准库提供的 Iterator trait 就是一个带有关联类型的 trait 的例子。关联类型名为 Item,代表实现 Iterator trait 的类型正在迭代的值的类型。Iterator trait 的定义如清单 19-12 所示。

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}

清单 19-12:带有关联类型 ItemIterator trait 的定义

类型 Item 是一个占位符,next 方法的定义表明它将返回 Option<Self::Item> 类型的值。Iterator trait 的实现者将为 Item 指定具体类型,并且 next 方法将返回一个包含该具体类型值的 Option

关联类型可能看起来与泛型是类似的概念,因为后者允许我们定义一个函数而无需指定它可以处理哪些类型。为了研究这两个概念之间的差异,我们将查看在名为 Counter 的类型上对 Iterator trait 的实现,该实现指定 Item 类型为 u32

文件名:src/lib.rs

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        --snip--

这种语法似乎与泛型的语法类似。那么为什么不使用泛型来定义 Iterator trait 呢,如清单 19-13 所示?

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}

清单 19-13:使用泛型对 Iterator trait 的假设定义

区别在于,当使用泛型时,如清单 19-13 所示,我们必须在每个实现中注释类型;因为我们也可以为 Counter 实现 Iterator<String> 或任何其他类型,所以对于 Counter 我们可以有多个 Iterator 的实现。换句话说,当一个 trait 有一个泛型参数时,它可以为一个类型多次实现,每次改变泛型类型参数的具体类型。当我们在 Counter 上使用 next 方法时,我们必须提供类型注释以指示我们想要使用 Iterator 的哪个实现。

使用关联类型时,我们不需要注释类型,因为我们不能为一个类型多次实现一个 trait。在清单 19-12 中使用关联类型的定义中,我们只能选择一次 Item 的类型是什么,因为对于 Counter 只能有一个 impl Iterator for Counter。我们不必在每次对 Counter 调用 next 的地方都指定我们想要一个 u32 值的迭代器。

关联类型也成为 trait 契约的一部分:trait 的实现者必须提供一个类型来替代关联类型占位符。关联类型通常有一个描述该类型将如何使用的名称,并且在 API 文档中记录关联类型是一种很好的做法。

默认泛型类型参数与运算符重载

当我们使用泛型类型参数时,可以为泛型类型指定一个默认的具体类型。如果默认类型适用,这样 trait 的实现者就无需指定具体类型。在使用 <占位符类型=具体类型> 语法声明泛型类型时指定默认类型。

这种技术很有用的一个绝佳例子是运算符重载,即你可以在特定情况下自定义运算符(如 +)的行为。

Rust 不允许你创建自己的运算符或重载任意运算符。但你可以通过实现与运算符相关联的 trait 来重载 std::ops 中列出的操作及相应 trait。例如,在清单 19-14 中,我们重载 + 运算符,以便将两个 Point 实例相加。我们通过在 Point 结构体上实现 Add trait 来做到这一点。

文件名:src/main.rs

use std::ops::Add;

#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    assert_eq!(
        Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
        Point { x: 3, y: 3 }
    );
}

清单 19-14:为 Point 实例实现 Add trait 以重载 + 运算符

add 方法将两个 Point 实例的 x 值和 y 值相加,以创建一个新的 PointAdd trait 有一个名为 Output 的关联类型,它决定了 add 方法返回的类型。

这段代码中的默认泛型类型在 Add trait 内部。其定义如下:

trait Add<Rhs=Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}

这段代码总体上应该看起来很熟悉:一个带有一个方法和一个关联类型的 trait。新的部分是 Rhs=Self:这种语法称为默认类型参数。泛型类型参数 Rhs(“右手边”的缩写)定义了 add 方法中 rhs 参数的类型。当我们为 Add trait 实现时,如果不指定 Rhs 的具体类型,Rhs 的类型将默认为 Self,也就是我们正在为其实现 Add 的类型。

当我们为 Point 实现 Add 时,我们使用了 Rhs 的默认值,因为我们想要将两个 Point 实例相加。让我们看一个实现 Add trait 的例子,在这个例子中我们想要自定义 Rhs 类型而不是使用默认值。

我们有两个结构体 MillimetersMeters,它们保存不同单位的值。在另一个结构体中对现有类型进行这种轻量级包装被称为新类型模式,我们将在“使用新类型模式为外部类型实现外部 trait”中更详细地描述它。我们想要将以毫米为单位的值与以米为单位的值相加,并让 Add 的实现正确地进行转换。我们可以为 Millimeters 实现 Add,将 Meters 作为 Rhs,如清单 19-15 所示。

文件名:src/lib.rs

use std::ops::Add;

struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
    type Output = Millimeters;

    fn add(self, other: Meters) -> Millimeters {
        Millimeters(self.0 + (other.0 * 1000))
    }
}

清单 19-15:在 Millimeters 上实现 Add trait 以将 MillimetersMeters 相加

为了将 MillimetersMeters 相加,我们指定 impl Add<Meters> 来设置 Rhs 类型参数的值,而不是使用默认的 Self

你将主要以两种方式使用默认类型参数:

  1. 在不破坏现有代码的情况下扩展类型
  2. 在大多数用户不需要的特定情况下允许定制

标准库的 Add trait 是第二个目的的一个例子:通常,你会将两个相同类型相加,但 Add trait 提供了超越此限制的定制能力。在 Add trait 定义中使用默认类型参数意味着大多数时候你不必指定额外的参数。换句话说,不需要一些实现样板代码,这使得使用该 trait 更容易。

第一个目的与第二个目的类似,但方向相反:如果你想向现有 trait 添加一个类型参数,可以给它一个默认值,以便在不破坏现有实现代码的情况下扩展 trait 的功能。

同名方法的歧义消除

Rust 中没有任何规定阻止一个 trait 拥有与另一个 trait 的方法同名的方法,也不阻止你在一个类型上同时实现这两个 trait。直接在类型上实现与 trait 中的方法同名的方法也是可行的。

当调用同名方法时,你需要告诉 Rust 你想要使用哪一个。考虑清单 19-16 中的代码,我们定义了两个 trait,PilotWizard,它们都有一个名为 fly 的方法。然后我们在一个已经实现了名为 fly 的方法的 Human 类型上实现这两个 trait。每个 fly 方法的行为都不同。

文件名:src/main.rs

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

清单 19-16:定义了两个都有 fly 方法的 trait,并在 Human 类型上实现它们,同时在 Human 上直接实现了一个 fly 方法。

当我们在 Human 的实例上调用 fly 时,编译器默认会调用直接在该类型上实现的方法,如清单 19-17 所示。

文件名:src/main.rs

fn main() {
    let person = Human;
    person.fly();
}

清单 19-17:在 Human 的实例上调用 fly

运行这段代码会打印 *waving arms furiously*,这表明 Rust 调用的是直接在 Human 上实现的 fly 方法。

要调用 Pilot trait 或 Wizard trait 中的 fly 方法,我们需要使用更明确的语法来指定我们想要的是哪个 fly 方法。清单 19-18 展示了这种语法。

文件名:src/main.rs

fn main() {
    let person = Human;
    Pilot::fly(&person);
    Wizard::fly(&person);
    person.fly();
}

清单 19-18:指定我们想要调用哪个 trait 的 fly 方法

在方法名之前指定 trait 名称,向 Rust 明确了我们想要调用哪个 fly 实现。我们也可以写成 Human::fly(&person),这与我们在清单 19-18 中使用的 person.fly() 等效,但如果我们不需要消除歧义,这样写会更长一些。

运行这段代码会输出以下内容:

This is your captain speaking.
Up!
*waving arms furiously*

因为 fly 方法接受一个 self 参数,如果我们有两个都实现了同一个 trait 的类型,Rust 可以根据 self 的类型来确定使用 trait 的哪个实现。

然而,非方法的关联函数没有 self 参数。当有多个类型或 trait 定义了具有相同函数名的非方法函数时,除非你使用完全限定语法,否则 Rust 并不总是知道你指的是哪个类型。例如,在清单 19-19 中,我们为一个动物收容所创建了一个 trait,该收容所希望给所有的小狗都取名为 Spot。我们创建了一个 Animal trait,它有一个关联的非方法函数 baby_nameAnimal trait 在 Dog 结构体上实现,我们也在 Dog 上直接提供了一个关联的非方法函数 baby_name

文件名:src/main.rs

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Dog::baby_name());
}

清单 19-19:一个带有关联函数的 trait 和一个具有相同名称的关联函数且也实现了该 trait 的类型

我们在 Dog 上定义的 baby_name 关联函数中实现了给所有小狗取名为 Spot 的代码。Dog 类型也实现了 Animal trait,该 trait 描述了所有动物共有的特征。小狗被称为 puppy,这在与 Animal trait 相关联的 baby_name 函数中对 Dog 实现 Animal trait 时体现出来。

main 函数中,我们调用 Dog::baby_name 函数,它直接调用在 Dog 上定义的关联函数。这段代码会输出以下内容:

A baby dog is called a Spot

这不是我们想要的输出。我们想要调用作为 Animal trait 一部分且在 Dog 上实现的 baby_name 函数,这样代码就会打印 A baby dog is called a puppy。我们在清单 19-18 中使用的指定 trait 名称的技术在这里不起作用;如果我们将 main 函数改为清单 19-20 中的代码,将会得到一个编译错误。

文件名:src/main.rs

fn main() {
    println!("A baby dog is called a {}", Animal::baby_name());
}

清单 19-20:尝试调用 Animal trait 中的 baby_name 函数,但 Rust 不知道使用哪个实现

因为 Animal::baby_name 没有 self 参数,并且可能有其他类型也实现了 Animal trait,所以 Rust 无法确定我们想要的是 Animal::baby_name 的哪个实现。我们会得到这个编译器错误:

error[E0283]: type annotations needed
  --> src/main.rs:20:43
   |
20 |     println!("A baby dog is called a {}", Animal::baby_name());
   |                                           ^^^^^^^^^^^^^^^^^ cannot infer
type
   |
   = note: cannot satisfy `_: Animal`

为了消除歧义并告诉 Rust 我们想要使用 DogAnimal 的实现,而不是其他类型对 Animal 的实现,我们需要使用完全限定语法。清单 19-21 展示了如何使用完全限定语法。

文件名:src/main.rs

fn main() {
    println!(
        "A baby dog is called a {}",
        <Dog as Animal>::baby_name()
    );
}

清单 19-21:使用完全限定语法指定我们想要调用 Dog 上实现的 Animal trait 中的 baby_name 函数

我们在尖括号内为 Rust 提供了一个类型注释,这表明我们希望通过将 Dog 类型视为 Animal 来调用 Dog 上实现的 Animal trait 中的 baby_name 方法。现在这段代码会打印出我们想要的内容:

A baby dog is called a puppy

一般来说,完全限定语法定义如下:

<Type as Trait>::function(receiver_if_method, next_arg,...);

对于不是方法的关联函数,不会有 receiver:只会有其他参数列表。你可以在调用函数或方法的任何地方使用完全限定语法。然而,如果 Rust 可以从程序中的其他信息推断出,你可以省略此语法的任何部分。只有在有多个使用相同名称的实现且 Rust 需要帮助来确定你想要调用哪个实现的情况下,才需要使用这种更冗长的语法。

使用超 trait

有时你可能会编写一个依赖于另一个 trait 的 trait 定义:为了让一个类型实现第一个 trait,你希望要求该类型也实现第二个 trait。这样做是为了让你的 trait 定义能够利用第二个 trait 的关联项。你的 trait 定义所依赖的 trait 被称为你的 trait 的超 trait

例如,假设我们想要创建一个 OutlinePrint trait,它有一个 outline_print 方法,该方法将打印一个给定的值,并将其格式设置为用星号框起来。也就是说,给定一个实现了标准库 trait Display 并输出 (x, y)Point 结构体,当我们在一个 x1y3Point 实例上调用 outline_print 时,它应该打印以下内容:

**********
*        *
* (1, 3) *
*        *
**********

outline_print 方法的实现中,我们想要使用 Display trait 的功能。因此,我们需要指定 OutlinePrint trait 仅适用于也实现了 Display 并提供 OutlinePrint 所需功能的类型。我们可以在 trait 定义中通过指定 OutlinePrint: Display 来做到这一点。这种技术类似于为 trait 添加一个 trait 约束。清单 19-22 展示了 OutlinePrint trait 的实现。

文件名:src/main.rs

use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {} *", output);
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

清单 19-22:实现需要 Display 功能的 OutlinePrint trait

因为我们指定了 OutlinePrint 需要 Display trait,所以我们可以使用为任何实现了 Display 的类型自动实现的 to_string 函数。如果我们在 trait 名称后没有添加冒号并指定 Display trait 就尝试使用 to_string,我们会得到一个错误,提示在当前作用域中为类型 &Self 未找到名为 to_string 的方法。

让我们看看当我们尝试在一个未实现 Display 的类型(如 Point 结构体)上实现 OutlinePrint 时会发生什么:

文件名:src/main.rs

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

我们会得到一个错误,提示需要 Display 但未实现:

error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:20:6
   |
20 | impl OutlinePrint for Point {}
   |      ^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Point`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for
pretty-print) instead
note: required by a bound in `OutlinePrint`
  --> src/main.rs:3:21
   |
3  | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint`

为了解决这个问题,我们在 Point 上实现 Display,以满足 OutlinePrint 所需的约束,如下所示:

文件名:src/main.rs

use std::fmt;

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

然后,在 Point 上实现 OutlinePrint trait 将成功编译,并且我们可以在 Point 实例上调用 outline_print,以在星号轮廓中显示它。

使用新类型模式实现外部 trait

在“为类型实现 trait”中,我们提到了孤儿规则,该规则指出,只有当 trait 或类型(或两者)都在我们的 crate 本地时,我们才被允许在类型上实现 trait。使用新类型模式可以绕过此限制,该模式涉及在元组结构体中创建一个新类型。(我们在“使用无命名字段的元组结构体创建不同类型”中介绍了元组结构体。)元组结构体将有一个字段,并且是我们想要为其实现 trait 的类型的轻量级包装。然后,包装类型在我们的 crate 本地,我们可以在包装上实现 trait。“新类型”一词源自 Haskell 编程语言。使用此模式没有运行时性能损失,并且包装类型在编译时会被省略。

例如,假设我们想要在 Vec<T> 上实现 Display,但孤儿规则阻止我们直接这样做,因为 Display trait 和 Vec<T> 类型是在我们的 crate 外部定义的。我们可以创建一个包含 Vec<T> 实例的 Wrapper 结构体;然后我们可以在 Wrapper 上实现 Display 并使用 Vec<T> 值,如清单 19-23 所示。

文件名:src/main.rs

use std::fmt;

struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let w = Wrapper(vec![
        String::from("hello"),
        String::from("world"),
    ]);
    println!("w = {w}");
}

清单 19-23:围绕 Vec<String> 创建一个 Wrapper 类型以实现 Display

Display 的实现使用 self.0 来访问内部的 Vec<T>,因为 Wrapper 是一个元组结构体,而 Vec<T> 是元组中索引为 0 的项。然后我们可以在 Wrapper 上使用 Display 类型的功能。

使用此技术的缺点是,Wrapper 是一个新类型,因此它没有它所包含的值的方法。我们必须直接在 Wrapper 上实现 Vec<T> 的所有方法,以便这些方法委托给 self.0,这样我们就可以将 Wrapper 完全当作 Vec<T> 来处理。如果我们希望新类型具有内部类型的每个方法,在 Wrapper 上实现 Deref trait 以返回内部类型将是一个解决方案(我们在“使用 Deref 将智能指针当作常规引用处理”中讨论了实现 Deref trait)。如果我们不希望 Wrapper 类型具有内部类型的所有方法——例如,限制 Wrapper 类型的行为——我们将不得不手动实现我们确实需要的方法。

即使不涉及 trait,这种新类型模式也很有用。让我们换个关注点,看看与 Rust 类型系统进行交互的一些高级方法。

总结

恭喜你!你已经完成了高级 trait 实验。你可以在 LabEx 中练习更多实验来提升你的技能。