高级特征

我们首先在 “特征:定义共享行为”第 10 章的章节中介绍了特征,但我们没有讨论更高级的细节。现在您已经对 Rust 有了更多了解,我们可以深入研究细节。

使用关联类型在特征定义中指定占位符类型

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

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

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

pub trait Iterator {
    type Item;

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

清单 19-12:具有关联类型 ItemIterator 特征的定义

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

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

文件名:src/lib.rs

struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        // --snip--
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

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

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

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

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

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

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

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

当我们使用泛型类型参数时,我们可以为泛型类型指定一个默认的具体类型。这消除了特征实现者在默认类型有效时指定具体类型的需要。您在使用 <PlaceholderType=ConcreteType> 语法声明泛型类型时指定默认类型。

此技术非常有用的一种情况是运算符重载,您可以在特定情况下自定义运算符(如 +)的行为。

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

文件名: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:实现 Add 特征以重载 Point 实例的 + 运算符

add 方法将两个 Point 实例的 x 值和两个 Point 实例的 y 值加在一起,以创建一个新的 PointAdd 特征有一个名为 Output 的关联类型,它确定从 add 方法返回的类型。

此代码中的默认泛型类型位于 Add 特征中。以下是它的定义

#![allow(unused)]
fn main() {
trait Add<Rhs=Self> {
    type Output;

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

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

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

我们有两个结构体,MillimetersMeters,它们分别保存不同单位的值。这种将现有类型用另一个结构体进行简单包装的方式被称为新类型模式,我们将在“使用新类型模式在外部类型上实现外部特征”部分中详细介绍。我们想要将毫米值加到米值上,并让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特征,将Millimeters加到Meters

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

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

  • 扩展类型而不破坏现有代码
  • 在大多数用户不需要的特定情况下允许自定义

标准库的Add特征是第二个目的的例子:通常,您会添加两个相同类型的元素,但Add特征提供了超出此范围的自定义能力。在Add特征定义中使用默认类型参数意味着您不必在大多数情况下指定额外的参数。换句话说,不需要一些实现样板代码,这使得使用该特征更加容易。

第一个目的类似于第二个目的,但方向相反:如果您想在现有特征中添加类型参数,可以为其提供默认值,以允许扩展特征的功能,而不会破坏现有的实现代码。

用于消除歧义的完全限定语法:调用具有相同名称的方法

Rust 中没有任何东西可以阻止一个特征拥有与另一个特征的方法同名的方法,也不能阻止您在一个类型上同时实现这两个特征。也可以直接在类型上实现一个与特征方法同名的方法。

当调用具有相同名称的方法时,您需要告诉 Rust 您想要使用哪一个。考虑清单 19-16 中的代码,我们在其中定义了两个特征,PilotWizard,它们都具有一个名为fly的方法。然后,我们在一个名为Human的类型上同时实现了这两个特征,该类型已经直接在其上实现了名为fly的方法。每个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*");
    }
}

fn main() {}

清单 19-16:定义了两个特征,它们都有一个fly方法,并在Human类型上实现,并且直接在Human上实现了一个fly方法

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

文件名: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*");
    }
}

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

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

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

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

文件名: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*");
    }
}

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

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

在方法名称之前指定特征名称可以向 Rust 说明我们想要调用哪个fly方法的实现。我们也可以写Human::fly(&person),它等同于我们在清单 19-18 中使用的person.fly(),但如果我们不需要消除歧义,这种写法有点长。

运行这段代码将打印以下内容

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished dev [unoptimized + debuginfo] target(s) in 0.46s
     Running `target/debug/traits-example`
This is your captain speaking.
Up!
*waving arms furiously*

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

但是,非方法的关联函数没有self参数。当有多个类型或特征定义了具有相同函数名称的非方法函数时,Rust 并不总是知道您指的是哪个类型,除非您使用完全限定语法。例如,在清单 19-19 中,我们为一个动物收容所创建了一个特征,该收容所希望将所有小狗命名为Spot。我们创建了一个Animal特征,它有一个关联的非方法函数baby_nameAnimal特征在结构体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:一个带有关联函数的特征和一个带有相同名称的关联函数的类型,该类型也实现了该特征

我们在Dog上定义的baby_name关联函数中实现了将所有小狗命名为 Spot 的代码。Dog类型还实现了Animal特征,该特征描述了所有动物的特征。小狗被称为幼犬,这在Dog上实现的Animal特征的baby_name函数(与Animal特征相关联)中得到了体现。

main中,我们调用了Dog::baby_name函数,该函数调用了直接在Dog上定义的关联函数。这段代码将打印以下内容

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished dev [unoptimized + debuginfo] target(s) in 0.54s
     Running `target/debug/traits-example`
A baby dog is called a Spot

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

文件名: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 {}", Animal::baby_name());
}

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

因为Animal::baby_name没有self参数,并且可能存在其他类型实现了Animal特征,所以 Rust 无法确定我们想要使用哪个Animal::baby_name的实现。我们将得到以下编译错误

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
  --> src/main.rs:20:43
   |
2  |     fn baby_name() -> String;
   |     ------------------------- `Animal::baby_name` defined here
...
20 |     println!("A baby dog is called a {}", Animal::baby_name());
   |                                           ^^^^^^^^^^^^^^^^^ cannot call associated function of trait
   |
help: use the fully-qualified path to the only available implementation
   |
20 |     println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
   |                                           +++++++       +

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

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

文件名: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 as Animal>::baby_name());
}

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

我们在尖括号内为 Rust 提供了一个类型注释,这表明我们想要调用Animal特征在Dog上实现的baby_name方法,方法是说我们想要将Dog类型视为Animal来进行此函数调用。这段代码现在将打印我们想要的内容

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/traits-example`
A baby dog is called a puppy

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

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

对于非方法的关联函数,将没有receiver:只有其他参数列表。您可以在调用函数或方法的任何地方使用完全限定语法。但是,您可以省略 Rust 可以从程序中其他信息推断出的任何部分。您只需要在存在多个使用相同名称的实现并且 Rust 需要帮助来识别您想要调用哪个实现的情况下使用这种更详细的语法。

使用超特征在另一个特征中要求一个特征的功能

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

例如,假设我们想要创建一个OutlinePrint特征,它有一个outline_print方法,该方法将以星号包围的格式打印给定的值。也就是说,给定一个实现了标准库特征DisplayPoint结构体,其结果为(x, y),当我们在一个x1y3Point实例上调用outline_print时,它应该打印以下内容

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

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

文件名: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));
    }
}

fn main() {}

清单 19-22:实现OutlinePrint特征,该特征需要Display的功能

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

让我们看看当我们尝试在没有实现Display的类型上实现OutlinePrint时会发生什么,例如Point结构体

文件名: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));
    }
}

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

impl OutlinePrint for Point {}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

我们得到一个错误,提示需要Display,但没有实现

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:20:23
   |
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`

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

为了解决这个问题,我们在Point上实现了Display,并满足了OutlinePrint的要求,如下所示

文件名:src/main.rs

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

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

impl OutlinePrint for Point {}

use std::fmt;

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

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

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

使用新类型模式在外部类型上实现外部特征

在第 10 章的“在类型上实现特征”部分中,我们提到了孤儿规则,该规则规定我们只允许在类型上实现特征,前提是特征或类型是我们的板条箱的本地类型。可以使用新类型模式来绕过这个限制,该模式涉及在元组结构体中创建一个新类型。(我们在“使用没有命名字段的元组结构体来创建不同的类型”(第 5 章的某个部分。)元组结构体将只有一个字段,并且是我们要为其实现特性的类型的薄包装器。然后,包装器类型在我们自己的 crate 中是局部的,我们可以为包装器实现特性。Newtype 是一个源于 Haskell 编程语言的术语。使用此模式不会产生运行时性能损失,并且包装器类型在编译时会被省略。

例如,假设我们要在 Vec<T> 上实现 Display,而孤儿规则阻止我们直接这样做,因为 Display 特性与 Vec<T> 类型是在我们的 crate 之外定义的。我们可以创建一个 Wrapper 结构体,它包含一个 Vec<T> 的实例;然后,我们可以为 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 的项目。然后,我们可以使用 WrapperDisplay 特性的功能。

使用此技术的缺点是 Wrapper 是一个新类型,因此它没有它所持有的值的任何方法。我们必须直接在 Wrapper 上实现 Vec<T> 的所有方法,以便这些方法委托给 self.0,这将允许我们像对待 Vec<T> 一样对待 Wrapper。如果我们希望新类型具有内部类型的所有方法,那么实现 Deref 特性(在第 15 章的 “使用 Deref 特性将智能指针视为普通引用”部分)在 Wrapper 上返回内部类型将是一个解决方案。如果我们不希望 Wrapper 类型具有内部类型的所有方法——例如,为了限制 Wrapper 类型的行为——我们必须手动实现我们想要的方法。

即使没有涉及特性,这种 newtype 模式也很有用。让我们转换焦点,看看与 Rust 类型系统交互的一些高级方法。