Rust从0到1-高级特性-Traits 进阶

在前面那的章节我们已经介绍过 trait,但是和生命周期一样,我们并没有讨论更多的进阶内容的细节。现在我们更加了解 Rust 了,可以深入讨论这些重要的细节。

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

关联类型(associated types)将类型占位符与 trait 关联起来,从而可以在 trait 的方法定义中使用这些类型占位符。在实现 trait 的时候,实现者将针对其自身的实现场景指定类型占位符对应的具体类型。这样我们就可以定义一个支持多种类型的 trait,而无需确切的知道是哪个类型,直到实现此 trait 时。

相对于本章所讨论的其它大部分高级特性,关联类型要更为常用到。一个使用关联类型的 trait 的例子是标准库提供的 Iterator trait。它使用一个名字为 Item 的关联类型来替代实现该 trait 的类型中所遍历的值的类型。参考下面的 Iterator 定义:

pub trait Iterator {    type Item;
    fn next(&mut self) -> Option<Self::Item>;}

复制代码

类型 Item 是一个占位符,并且 next 方法的定义也是返回 OptionSelf::Item 类型。实现这个 trait 的类型会指定 Item 的具体类型,而 next 方法会返回一个包含了该具体类型值的 Option。关联类型可能看起来和泛型类似,后者也允许我们定义一个函数而不指定其可以处理的具体类型。那么为什么要使用关联类型呢?我们先来看一个例子:

impl Iterator for Counter {    type Item = u32;
    fn next(&mut self) -> Option<Self::Item> {        // --snip--

复制代码

在语法上和泛型类似。那么为什么上例不像下面这么定义呢:

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

复制代码

区别在于当使用泛型时,我们不得不在每一个实现中标注类型,因为我们也可以实现 Iterator<String> for Counter,或任何其他类型,我们可以有多个 Iterator trait 的 Counter 实现。也就是说,当 trait 的参数为泛型时,它可以被一个类型多次实现,每次只需要改变泛型参数的具体类型。而我们使用 Counter 的 next 方法时,则需要提供类型注解来表明希望使用其 Iterator 的哪一个实现。

而使用关联类型我们则不要注明类型,因为我们不能为一个类型多次实现一个 trait。对于上面的例子来说,也就是我们只能选择一次 Item 的类型,因为只能有一个 impl Iterator for Counter。这样不管在什么场景下调用 Counter 的 next 方法都不需要每次指定我们需要针对 u32 类型值的迭代器。

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

当使用泛型参数时,可以为泛型指定一个默认的具体类型。这样我们就不必再为具体类型实现 trait,如果只需要用到默认类型。在声明泛型类型时指定默认类型的语法是:<PlaceholderType=ConcreteType>,如 pub trait add<T=String>。

适用于这种场景的一个很好的例子是运算符重载(operator overloading)。即在特定情况下自定义运算符(比如 +)行为的操作。

在 Rust 中并不允许创建自定义运算符或任意的重载运算符。但是可以通过实现运算符相关的 traits 来重载操作和 std::ops 中所列出的相应的 traits。参考下面的例子:

use std::ops::Add;
#[derive(Debug, 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 }    );}

复制代码

上面的例子展示了如何通过在 Point 结构体上实现 Add trait 来重载 + 运算符,以实现两个 Point 实例相加。add 方法通过将两个 Point 实例的 x 值和 y 值分别相加来创建一个新的 Point。Add trait 包含一个名字为 Output 的关联类型,它决定了 add 方法的返回类型。

默认泛型参数在 Add trait 中的定义类似下面这样:

trait Add<Rhs=Self> {    type Output;
    fn add(self, rhs: Rhs) -> Self::Output;}

复制代码

上面例子中的代码应该看起来很熟悉:一个包含一个方法和一个关联类型的 trait。陌生的部分是 Rhs=Self:默认类型参数(default type parameters)语法。Rhs 是泛型参数(“right hand side” 的缩写),用于定义 add 方法参数 rhs 的类型。如果我们在实现 Add trait 时不指定 Rhs 的具体类型,那么其默认为 Self ,也就是实现 Add trait 的类型。

当我们为 Point 类型实现 Add trait 时,Rhs 就使用了默认类型,因为我们希望将两个 Point 实例相加。下面让我们看一个实现 Add trait 时自定义 Rhs 类型的例子:

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

复制代码

在上例中有两个结构体类型 Millimeters 和 Meters。我们希望能够通过 Add 方法,实现毫米类型的值与米类型的值直接相加。我们为 Millimeters 类型实现了 Add  trait ,并指定 Rhs 的具体类型为 Meters 。

默认参数类型主要用于以下两方面:

  • 扩展现有类型而不破坏现有代码
  • 允许用户通过自定义满足一些个性化场景

前面我们介绍的标准库中的 Add trait 属于第二个目的的例子:大部分时候我们将两个相同的类型相加,但是它也提供了自定义的能力。在 Add trait 的定义中使用默认类型让我们在大部分时候无需指定额外的参数,使 trait 的使用更容易了。

第一个目的于第二个是相似的,不过是反过来的:如果要为现有 trait 增加类型参数,为其增加一个默认类型将使我们可以在不破坏现有实现代码的基础上扩展它的功能。

调用相同名称的方法

Rust 不会阻止两个 trait 包含相同名称的方法,也不会阻止我们为一个类型同时实现这两个 trait。甚至也可以直接在类型上实现和 trait 所包含的方法同名的方法!

当我们调用这些同名方法时,需要告诉 Rust 到底需要的是哪一个方法。参考下面的例子:

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

复制代码

上例中我们定义的 trait Pilot 和 Wizard 拥有同名方法 fly。然后我们为一个已经实现同名方法 fly 的类型 Human 实现了这两个 trait。每一个 fly 方法的实现都不一样,那么当我们需要调用 fly 方法时会发生什么呢?

当我们调用 Human 实例的方法 fly 时,编译器默认会调用直接在类型上实现的方法,参考下面的例子:

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

复制代码

尝试运行上面的代码会打印出 *waving arms furiously*,这印证了我们前面的说法。

为了能够调用我们实现的 Pilot trait 或 Wizard trait 的 fly 方法,我们需要使用更明确的语法来指定具体是哪个 fly 方法。参考下面的例子:

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

复制代码

我们通过在方法名前指定 trait 名向 Rust 表明我们具体希望调用哪个 fly 方法的实现。等同于 person.fly() ,我们也可以写为 Human::fly(&person),不过如果没有消除歧义的必要,这么写有点啰嗦。尝试运行上面的代码我们得到类似下面的结果:

$ 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 作为参数,如果有两个类型都实现了这个 trait,Rust 可以根据 self 的具体类型计算出应该调用哪一个 trait 的实现。

然而,关联函数也可能包含在 trait 中,但是并没有 self 参数。这种情况下,当两个类型实现了同一个 trait 并处于同一个作用域时,Rust 就无法计算出我们希望调用的具体是哪一个类型,这时候就需要使用完全限定语法(fully qualified syntax)。参考下面的例子:

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

复制代码

在上例中 Animal trait 中定义了关联函数 baby_name,结构体 Dog 实现了 Animal trait,同时在 Dog 类型上也直接实现了同名关联函数 baby_name。最后我们在 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 trait 中定义的 baby_name 函数,参考我们前面的做法修改 main 函数:

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

复制代码

上面的这段代码无法编译通过,因为 Animal::baby_name 是关联函数而不是方法,没有 self 参数,Rust 无法计算出具体是哪一个 Animal::baby_name 实现。我们会得到类似下面的编译时错误:

$ cargo run   Compiling traits-example v0.1.0 (file:///projects/traits-example)error[E0283]: type annotations needed  --> src/main.rs:20:43   |2  |     fn baby_name() -> String;   |     ------------------------- required by `Animal::baby_name`...20 |     println!("A baby dog is called a {}", Animal::baby_name());   |                                           ^^^^^^^^^^^^^^^^^ cannot infer type   |   = note: cannot satisfy `_: Animal`
error: aborting due to previous error
For more information about this error, try `rustc --explain E0283`.error: could not compile `traits-example`
To learn more, run the command again with --verbose.

复制代码

为了消歧义,明确告诉 Rust 我们希望调用的是 Dog 中的 Animal trait 实现,需要使用完全限定语法,参考下面的例子:

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

复制代码

我们在尖括号中向 Rust 说明了具体的类型及其实现的 trait,来说明我们希望调用的是 Dog 类型中实现的 Animal trait 的 baby_name 函数。上面这段代码会打印出类似下面的结果:

$ 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 需要帮助以才知道我们希望调用的具体是哪一个时,才需要使用比较冗长的语法。

父 trait

有时候,我们可能需要在一个 trait 中使用另一个 trait 的功能。这时候将依赖所使用的 trait 也需要被实现。这个所依赖的 trait 被称为我们所定义的 trait 的 父 trait(supertrait)。

例如,我们定义了一个包含 outline_print 方法的 trait OutlinePrint,我们希望它可以打印出带有星号框的值。也就是说,如果 Point 实现了 Display trait 并返回 (x, y),当我们调用 Point(1,3)实例的 outline_print 方法时会显示:

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

复制代码

在 outline_print 的实现中,我们希望能够使用 Display trait 的功能。因此,我们需要指明 OutlinePrint trait 只能用于实现了 Display trait 并提供了 OutlinePrint 需要的功能的类型。我们可以通过在 trait 定义中使用类似 OutlinePrint: Display 来做到这一点。参考下面的例子:

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

复制代码

因为我们指定了 OutlinePrint trait 需要依赖 Display trait,所以我们可以在 outline_print 方法中使用 Display trait 中的 to_string 方法, 任何实现了 Display trait 的类型会实现该方法。如果不在 trait 名后增加 : Display 并尝试在 outline_print 中调用 to_string 方法,则会产生错误:在当前作用域中无法找到类型 &self 的方法 to_string。下面让我们尝试在一个没有实现 Display trait 的类型上实现 OutlinePrint trait ,看看会发生什么:

struct Point {    x: i32,    y: i32,}
impl OutlinePrint for Point {}

复制代码

我们将得到类似下面的编译时错误:

$ 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:6   |3  | trait OutlinePrint: fmt::Display {   |                     ------------ required by this bound in `OutlinePrint`...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
error: aborting due to previous error
For more information about this error, try `rustc --explain E0277`.error: could not compile `traits-example`
To learn more, run the command again with --verbose.

复制代码

我们可以通过在 Point 上实现 Display trait 来满足 OutlinePrint trait 的要求,以解决这个错误。参考下面的例子:

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

复制代码

现在我们可以成功编译了,并可以在 Point 实例上调用 outline_print 方法来显示它。

“新类型”模式

在前面我们介绍 trait 的时候,我们提到过孤儿规则(orphan rule),即只要 trait 或类型之一属于 crate 本地(local)的话就可以在此类型上实现该 trait,否则不可以。我们可以通过“新类型”模式(newtype pattern,newtype 是一个源自 Haskell 编程语言的术语)绕过这个约束,它在一个元组结构体中创建一个新的类型(在介绍结构体的时候我们提到过使用没有命名字段的结构体区分不同类型)。这个元组结构体包含一个字段作为我们希望为其实现某个 trait 的类型的简单封装。这样这个被封装的类型对于我们的 crate 来说就是本地的了,我们就可以为这个简单的封装实现 trait。这个模式不会带来任何运行时性能惩罚,在编译时封装类型会被忽略。

例如,在介绍 trait 的章节所提到的如果想要在 Vec<T> 上实现 Display trait,孤儿规则会阻止我们这么做,因为 Display trait 和 Vec<T> 都是在标准库中定义的,位于我们的 crate 之外。利用“新类型”模式我们可以创建一个包含 Vec<T> 类型的元组结构体类型 Wrapper,然后为 Wrapper 实现 Display trait。参考下面的例子:

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

复制代码

因为 Wrapper 是元组结构体而 Vec<T> 是其于索引位置 0 的元素,所以我们在 Display trait 的实现中通过 self.0 来访问 Wrapper 内部的 Vec<T>。这样基于  Wrapper 我们就可以使用 Display trait 所定义的功能了。

这个模式也有缺点:由于 Wrapper 是一个新类型,它并不包含 Vec<T> 原有的方法;如果要像 Vec<T> 那样使用 Wrapper,需要为 Wrapper 实现 Vec<T> 的所有方法。如果希望新类型拥有其内部类型的所有方法,为其实现 Deref trait 并返回其内部类型是一种解决方案(在介绍 Deref trait 的章节我们讨论过)。如果不希望封装类型拥有所有内部类型的方法则只能手动实现所需的方法(譬如为了限制封装类型的行为)。

现在我们知道“新类型”模式是如何与 trait 结合使用的了,在不用于实现 trait 时它也是一种很有用的模式。

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/c4f69592099a094b7d26a8c9a
  • 如有侵权,请联系 yunjia_community@tencent.com 删除。

扫码关注云+社区

领取腾讯云代金券