高级 Trait
我们首先在第 10 章的“Trait:定义共享行为”部分介绍了 trait,但我们没有讨论更高级的细节。既然你对 Rust 有了更多的了解,我们就可以深入探讨细节了。
使用关联类型在 Trait 定义中指定占位符类型
关联类型将类型占位符与 trait 连接起来,这样 trait 方法定义就可以在它们的签名中使用这些占位符类型。trait 的实现者将指定具体的类型来代替特定实现中的占位符类型。这样,我们就可以定义一个使用某些类型的 trait,而无需确切知道这些类型是什么,直到 trait 被实现。
在本章中,我们已经将大多数高级特性描述为很少需要。关联类型介于两者之间:它们的使用频率低于本书其余部分解释的特性,但高于本章讨论的许多其他特性。
标准库提供的 Iterator
trait 是具有关联类型的一个例子。关联类型名为 Item
,代表实现 Iterator
trait 的类型正在迭代的值的类型。Iterator
trait 的定义如清单 20-13 所示。
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
Item
的 Iterator
trait 的定义类型 Item
是一个占位符,next
方法的定义表明它将返回 Option<Self::Item>
类型的值。Iterator
trait 的实现者将为 Item
指定具体的类型,next
方法将返回一个 Option
,其中包含该具体类型的值。
关联类型可能看起来与泛型概念相似,因为后者允许我们定义一个函数,而无需指定它可以处理哪些类型。为了检查这两种概念之间的区别,我们将查看在名为 Counter
的类型上实现 Iterator
trait 的示例,该示例指定 Item
类型为 u32
这个语法看起来与泛型的语法相当。那么,为什么不直接使用泛型定义 Iterator
trait,如清单 20-14 所示?
pub trait Iterator<T> {
fn next(&mut self) -> Option<T>;
}
Iterator
trait 的假设定义区别在于,当使用泛型时,如清单 20-14 所示,我们必须在每个实现中注释类型;因为我们也可以为 Counter
实现 Iterator<String>
或任何其他类型,所以我们可以为 Counter
实现多个 Iterator
。换句话说,当 trait 具有泛型参数时,它可以为一个类型实现多次,每次都更改泛型类型参数的具体类型。当我们在 Counter
上使用 next
方法时,我们将必须提供类型注释来指示我们要使用哪个 Iterator
实现。
使用关联类型,我们不需要注释类型,因为我们不能在一个类型上多次实现一个 trait。在清单 20-13 中,使用关联类型的定义,我们只能选择 Item
的类型一次,因为只能有一个 impl Iterator for Counter
。我们不必在每次在 Counter
上调用 next
时都指定我们想要一个 u32
值的迭代器。
关联类型也成为 trait 契约的一部分:trait 的实现者必须提供一个类型来代替关联类型占位符。关联类型通常有一个描述类型将如何使用的名称,并且在 API 文档中记录关联类型是一个好的实践。
默认泛型类型参数和运算符重载
当我们使用泛型类型参数时,我们可以为泛型类型指定一个默认的具体类型。如果默认类型有效,这消除了 trait 实现者指定具体类型的需要。当使用 <PlaceholderType=ConcreteType>
语法声明泛型类型时,可以指定默认类型。
这种技术非常有用的一个例子是运算符重载,您可以在特定情况下自定义运算符(例如 +
)的行为。
Rust 不允许您创建自己的运算符或重载任意运算符。但是,您可以通过实现与运算符关联的 trait 来重载 std::ops
中列出的操作和相应的 trait。例如,在清单 20-15 中,我们重载 +
运算符以将两个 Point
实例相加。我们通过在 Point
结构体上实现 Add
trait 来做到这一点
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 }
);
}
Add
trait 以重载 Point
实例的 +
运算符add
方法将两个 Point
实例的 x
值和两个 Point
实例的 y
值相加,以创建一个新的 Point
。Add
trait 具有一个名为 Output
的关联类型,该类型确定从 add
方法返回的类型。
此代码中的默认泛型类型位于 Add
trait 中。这是它的定义
这段代码应该看起来大致熟悉:一个具有一个方法和一个关联类型的 trait。新部分是 Rhs=Self
:此语法称为默认类型参数。Rhs
泛型类型参数(“right hand side”的缩写)定义了 add
方法中 rhs
参数的类型。如果我们没有在实现 Add
trait 时为 Rhs
指定具体类型,则 Rhs
的类型将默认为 Self
,这将是我们正在其上实现 Add
的类型。
当我们为 Point
实现 Add
时,我们使用了 Rhs
的默认值,因为我们想要添加两个 Point
实例。让我们看一个实现 Add
trait 的例子,我们想要自定义 Rhs
类型而不是使用默认值。
我们有两个结构体,Millimeters
和 Meters
,它们以不同的单位保存值。这种将现有类型薄封装在另一个结构体中的做法被称为 newtype 模式,我们在“使用 Newtype 模式在外部类型上实现外部 Trait”部分中更详细地描述了这一点。我们想要将毫米值添加到米值,并让 Add
的实现正确地进行转换。我们可以为 Millimeters
实现 Add
,并将 Meters
作为 Rhs
,如清单 20-16 所示。
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
上实现 Add
trait,以将 Millimeters
添加到 Meters
为了添加 Millimeters
和 Meters
,我们指定 impl Add<Meters>
以设置 Rhs
类型参数的值,而不是使用默认值 Self
。
您将在两种主要方式中使用默认类型参数
- 在不破坏现有代码的情况下扩展类型
- 允许在大多数用户不需要的特定情况下进行自定义
标准库的 Add
trait 是第二种用途的示例:通常,您会添加两个相似的类型,但 Add
trait 提供了超出此范围的自定义能力。在 Add
trait 定义中使用默认类型参数意味着您不必在大多数情况下指定额外的参数。换句话说,不需要一些实现样板代码,从而更容易使用 trait。
第一种用途与第二种用途类似,但方向相反:如果您想向现有 trait 添加类型参数,您可以为其提供一个默认值,以便在不破坏现有实现代码的情况下扩展 trait 的功能。
消除歧义的完全限定语法:调用同名方法
Rust 并没有阻止一个 trait 拥有与另一个 trait 的方法同名的方法,Rust 也没有阻止你在一个类型上实现这两个 trait。也可以直接在类型上实现一个与 trait 中的方法同名的方法。
当调用同名方法时,你需要告诉 Rust 你想使用哪一个。考虑清单 20-17 中的代码,我们在其中定义了两个 trait,Pilot
和 Wizard
,它们都有一个名为 fly
的方法。然后我们在一个类型 Human
上实现了这两个 trait,Human
类型已经有一个名为 fly
的方法直接在其上实现。每个 fly
方法都做不同的事情。
method 并在
Human类型上实现,并且在
Human` 上直接实现了一个 fly
方法当我们在 Human
的实例上调用 fly
时,编译器默认调用直接在类型上实现的方法,如清单 20-18 所示。
Human
的实例上调用 fly
运行此代码将打印 *waving arms furiously*
,表明 Rust 调用了直接在 Human
上实现的 fly
方法。
要从 Pilot
trait 或 Wizard
trait 调用 fly
方法,我们需要使用更明确的语法来指定我们指的是哪个 fly
方法。清单 20-19 演示了这种语法。
fly
方法在方法名称之前指定 trait 名称向 Rust 明确了我们要调用哪个 fly
实现。我们也可以写 Human::fly(&person)
,这等同于我们在清单 20-19 中使用的 person.fly()
,但是如果我们不需要消除歧义,这种写法会更长。
运行这段代码会打印以下内容
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
Finished `dev` profile [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 的实现。
但是,不是方法的关联函数没有 self
参数。当有多个类型或 trait 定义了具有相同函数名称的非方法函数时,除非你使用完全限定语法,否则 Rust 并不总是知道你指的是哪个类型。例如,在清单 20-20 中,我们为一个动物收容所创建了一个 trait,该收容所想将所有幼犬命名为 Spot。我们创建了一个具有关联非方法函数 baby_name
的 Animal
trait。Animal
trait 为结构体 Dog
实现,我们在 Dog
上也直接提供了一个关联非方法函数 baby_name
。
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());
}
我们在 Dog
上定义的 baby_name
关联函数中实现了将所有幼犬命名为 Spot 的代码。Dog
类型也实现了 trait Animal
,它描述了所有动物都具有的特征。幼犬被称为 puppies,这在 Dog
上实现的 Animal
trait 的 baby_name
函数中表达出来,该函数与 Animal
trait 关联。
在 main
中,我们调用 Dog::baby_name
函数,它调用直接在 Dog
上定义的关联函数。这段代码打印以下内容
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.54s
Running `target/debug/traits-example`
A baby dog is called a Spot
这个输出不是我们想要的。我们想要调用作为我们在 Dog
上实现的 Animal
trait 的一部分的 baby_name
函数,以便代码打印 A baby dog is called a puppy
。我们在清单 20-19 中使用的指定 trait 名称的技术在这里没有帮助;如果我们将 main
更改为清单 20-21 中的代码,我们将收到编译错误。
Animal
trait 调用 baby_name
函数,但 Rust 不知道要使用哪个实现因为 Animal::baby_name
没有 self
参数,并且可能有其他类型实现了 Animal
trait,所以 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 我们想要使用 Animal
为 Dog
实现的实现,而不是 Animal
为其他类型实现的实现,我们需要使用完全限定语法。清单 20-22 演示了如何使用完全限定语法。
Animal
trait 中为 Dog
实现的 baby_name
函数我们在尖括号内提供了一个类型注释,这表明我们想要调用 Animal
trait 中为 Dog
实现的 baby_name
方法,通过说我们希望将 Dog
类型视为此函数调用的 Animal
。这段代码现在将打印我们想要的内容
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
Finished `dev` profile [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 需要帮助来识别您想要调用的实现的情况下使用这种更冗长的语法。
使用 Supertrait 在另一个 Trait 中要求一个 Trait 的功能
有时,你可能会编写一个依赖于另一个 trait 的 trait 定义:为了让一个类型实现第一个 trait,你希望要求该类型也实现第二个 trait。这样做是为了让你的 trait 定义可以使用第二个 trait 的关联项。你的 trait 定义所依赖的 trait 被称为你的 trait 的supertrait。
例如,假设我们想创建一个 OutlinePrint
trait,其中包含一个 outline_print
方法,该方法将打印给定的值,格式化后用星号框起来。也就是说,给定一个实现了标准库 trait Display
以产生 (x, y)
的 Point
结构体,当我们在一个 Point
实例上调用 outline_print
,该实例的 x
为 1
,y
为 3
时,它应该打印以下内容
**********
* *
* (1, 3) *
* *
**********
在 outline_print
方法的实现中,我们想要使用 Display
trait 的功能。因此,我们需要指定 OutlinePrint
trait 仅适用于也实现了 Display
并提供 OutlinePrint
需要的功能的类型。我们可以在 trait 定义中通过指定 OutlinePrint: Display
来做到这一点。这种技术类似于向 trait 添加 trait 约束。清单 20-23 显示了 OutlinePrint
trait 的实现。
Display
功能的 OutlinePrint
trait因为我们已经指定 OutlinePrint
需要 Display
trait,所以我们可以使用为任何实现 Display
的类型自动实现的 to_string
函数。如果我们尝试使用 to_string
而没有添加冒号并在 trait 名称后指定 Display
trait,我们将收到一个错误,提示在当前作用域中找不到类型 &Self
的名为 to_string
的方法。
让我们看看当我们尝试在未实现 Display
的类型(例如 Point
结构体)上实现 OutlinePrint
时会发生什么情况
我们收到一个错误,提示需要 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`
error[E0277]: `Point` doesn't implement `std::fmt::Display`
--> src/main.rs:24:7
|
24 | p.outline_print();
| ^^^^^^^^^^^^^ `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::outline_print`
--> src/main.rs:3:21
|
3 | trait OutlinePrint: fmt::Display {
| ^^^^^^^^^^^^ required by this bound in `OutlinePrint::outline_print`
4 | fn outline_print(&self) {
| ------------- required by a bound in this associated function
For more information about this error, try `rustc --explain E0277`.
error: could not compile `traits-example` (bin "traits-example") due to 2 previous errors
为了解决这个问题,我们在 Point
上实现 Display
并满足 OutlinePrint
要求的约束,如下所示
然后,在 Point
上实现 OutlinePrint
trait 将成功编译,我们可以调用 Point
实例上的 outline_print
以在星号轮廓内显示它。
使用 Newtype 模式在外部类型上实现外部 Trait
在第 10 章的“在一个类型上实现 Trait”部分中,我们提到了孤儿规则,该规则规定,只有当 trait 或类型是本地于我们的 crate 时,我们才被允许在一个类型上实现 trait。可以使用 newtype 模式来规避此限制,该模式涉及在元组结构体中创建一个新类型。(我们在第 5 章的“使用没有命名字段的元组结构体来创建不同的类型”部分中介绍了元组结构体。)元组结构体将有一个字段,并且是我们要为其实现 trait 的类型的薄包装器。然后包装器类型是本地于我们的 crate 的,我们可以在包装器上实现 trait。Newtype 是一个起源于 Haskell 编程语言的术语。使用这种模式没有运行时性能损失,并且包装器类型在编译时被省略。
例如,假设我们想要在 Vec<T>
上实现 Display
,孤儿规则阻止我们直接这样做,因为 Display
trait 和 Vec<T>
类型是在我们的 crate 之外定义的。我们可以创建一个 Wrapper
结构体来保存 Vec<T>
的实例;然后我们可以在 Wrapper
上实现 Display
并使用 Vec<T>
值,如清单 20-24 所示。
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}");
}
Vec<String>
创建 Wrapper
类型以实现 Display
Display
的实现使用 self.0
来访问内部的 Vec<T>
,因为 Wrapper
是一个元组结构体,而 Vec<T>
是元组中索引 0 的项。然后我们可以在 Wrapper
上使用 Display
trait 的功能。
使用这种技术的缺点是 Wrapper
是一种新类型,因此它没有它所持有的值的方法。我们必须在 Wrapper
上直接实现 Vec<T>
的所有方法,以便这些方法委托给 self.0
,这将允许我们将 Wrapper
完全像 Vec<T>
一样对待。如果我们希望新类型具有内部类型的所有方法,则在 Wrapper
上实现 Deref
trait(在第 15 章的“使用 Deref Trait 将智能指针视为常规引用”部分中讨论)以返回内部类型将是一种解决方案。如果我们不希望 Wrapper
类型具有内部类型的所有方法——例如,为了限制 Wrapper
类型的行为——我们将必须手动实现我们想要的方法。
即使不涉及 trait,这种 newtype 模式也很有用。让我们转移注意力,看看与 Rust 类型系统交互的一些高级方法。