使用 trait 对象对共享行为进行抽象
在第 8 章中,我们提到过 vector 的一个局限:它们只能
存储单一类型的元素。在示例 8-9 中我们曾用变通方法
定义了一个 SpreadsheetCell 枚举,其变体可以存放整数、
浮点数和文本。这意味着我们可以在每个单元格中存储不同类型
的数据,同时仍用一个 vector 来表示一行单元格。当我们的
可互换项是编译时已知的一组固定类型时,这是一个非常好的
解决方案。
然而,有时我们希望库的用户能够扩展在特定情况下有效的
类型集合。为了展示如何实现这一点,我们将创建一个图形
用户界面(graphical user interface,GUI)工具示例,它遍历
一个条目列表,并对每一项调用 draw 方法将其绘制到
屏幕上——这是 GUI 工具的常见技术。我们将创建一个名为
gui 的库 crate,其中包含 GUI 库的结构。这个 crate
可能包含一些供人们使用的类型,例如 Button 或
TextField。此外,gui 的用户还会想要创建自己的
可绘制类型:例如,一位程序员可能添加 Image,另一位
可能添加 SelectBox。
在编写这个库时,我们无法知道并定义其他程序员可能想要
创建的所有类型。但我们确切知道的是:gui 需要跟踪
许多不同类型的值,并且需要对这些不同类型的值分别调用
draw 方法。它不需要确切知道调用 draw 方法时会发生
什么,只需知道该值有这个方法可供我们调用即可。
在有继承机制的语言中,我们可能会定义一个名为
Component 的类,其上有一个名为 draw 的方法。其他
类,如 Button、Image 和 SelectBox,会继承自
Component,从而继承 draw 方法。它们各自可以覆盖
draw 方法来定义自定义行为,但框架可以把所有这些类型
都当作 Component 实例来对待,并对其调用 draw。但
由于 Rust 没有继承,我们需要另一种方式来组织 gui
库,以允许用户创建与该库兼容的新类型。
为共享行为定义 trait
为了实现我们希望 gui 具备的行为,我们将定义一个
名为 Draw 的 trait,其中包含一个名为 draw 的方法。
然后,我们就可以定义一个接收 trait 对象的 vector。
trait 对象(trait object)同时指向一个实现了我们指定的
trait 的类型的实例,以及一张用于在运行时查找该类型 trait
方法的表。我们通过指定某种指针(例如引用或
Box<T> 智能指针),加上 dyn 关键字,再指定相关
trait 来创建 trait 对象。(我们将在第 20 章的「动态大小类型与
Sized trait」一节讨论 trait 对象必须使用
指针的原因。)我们可以在泛型或具体类型的位置使用 trait
对象。无论在哪里使用 trait 对象,Rust 的类型系统都会在
编译时确保在该上下文中使用的任何值都实现了 trait 对象的
trait。因此,我们无需在编译时知道所有可能的类型。
我们提到过,在 Rust 中,我们避免把结构体和枚举称为
「对象」,以便与其他语言中的对象区分开来。在结构体或
枚举中,结构体字段中的数据与 impl 块中的行为是分离的;
而在其他语言中,数据与行为结合为一个概念,通常被标记为
对象。trait 对象与其他语言中对象的不同之处在于:我们无法
向 trait 对象添加数据。trait 对象不像其他语言中的对象那样
具有广泛的通用性:它们的特定用途是实现跨共享行为的抽象。
示例 18-3 展示了如何定义一个名为 Draw、带有一个
名为 draw 方法的 trait。
pub trait Draw {
fn draw(&self);
}
Draw trait 的定义这种语法应该看起来很熟悉,我们在第 10 章讨论过如何定义
trait。接下来是一些新语法:示例 18-4 定义了一个名为
Screen 的结构体,它持有一个名为 components 的 vector。
这个 vector 的类型是 Box<dyn Draw>,这是一个 trait 对象;
它是 Box 中任何实现了 Draw trait 的类型的替身。
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
Screen 结构体的定义,其 components 字段存放一个由实现了 Draw trait 的 trait 对象组成的 vector在 Screen 结构体上,我们将定义一个名为 run 的方法,
它会对 components 中的每一项调用 draw 方法,如示例
18-5 所示。
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
Screen 上的 run 方法,对每个组件调用 draw 方法这与定义使用带 trait 边界的泛型类型参数的结构体的工作方式
不同。泛型类型参数一次只能被一个具体类型替代,而 trait 对象
允许在运行时用多个具体类型来填充 trait 对象。例如,我们本可以
像示例 18-6 那样,使用泛型类型和 trait 边界来定义 Screen
结构体。
pub trait Draw {
fn draw(&self);
}
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}
impl<T> Screen<T>
where
T: Draw,
{
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
Screen 结构体及其 run 方法的另一种实现这限制了我们只能拥有组件列表全部同为 Button 类型或全部
同为 TextField 类型的 Screen 实例。如果你永远只会有同构
集合,那么使用泛型和 trait 边界更可取,因为这些定义会在编译时
被单态化(monomorphize)以使用具体类型。
另一方面,使用 trait 对象的方法,一个 Screen 实例可以持有
一个同时包含 Box<Button> 和 Box<TextField> 的
Vec<T>。让我们看看这是如何工作的,然后再谈谈其运行时
性能影响。
实现该 trait
现在我们将添加一些实现 Draw trait 的类型。我们提供
Button 类型。需要再次说明的是,真正实现一个 GUI 库超出了
本书的范围,所以 draw 方法的方法体中不会有任何有用的实现。
要想象实现可能的样子,一个 Button 结构体可能有
width、height 和 label 字段,如示例 18-7 所示。
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self) {
// code to actually draw a button
}
}
Draw trait 的 Button 结构体Button 上的 width、height 和 label 字段会与其他
组件上的字段不同;例如,TextField 类型可能拥有这些相同
字段,外加一个 placeholder 字段。我们想要在屏幕上绘制的
每种类型都会实现 Draw trait,但会在 draw 方法中使用不同
的代码来定义如何绘制该特定类型,就像这里的 Button 一样
(如前所述,不含实际的 GUI 代码)。例如,Button 类型可能
还有一个额外的 impl 块,包含与用户点击按钮时发生的行为
相关的方法。这类方法不适用于 TextField 这样的类型。
如果使用我们库的人决定实现一个带有 width、height
和 options 字段的 SelectBox 结构体,他们同样会在
SelectBox 类型上实现 Draw trait,如示例 18-8 所示。
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// code to actually draw a select box
}
}
fn main() {}
gui 并在 SelectBox 结构体上实现 Draw trait我们库的用户现在可以编写他们的 main 函数来创建一个
Screen 实例。他们可以通过将 SelectBox 和 Button
各自放入 Box<T> 使之成为 trait 对象,来将它们添加到
Screen 实例中。然后他们可以调用 Screen 实例的
run 方法,该方法会对每个组件调用 draw。示例 18-9
展示了这一实现。
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// code to actually draw a select box
}
}
use gui::{Button, Screen};
fn main() {
let screen = Screen {
components: vec![
Box::new(SelectBox {
width: 75,
height: 10,
options: vec![
String::from("Yes"),
String::from("Maybe"),
String::from("No"),
],
}),
Box::new(Button {
width: 50,
height: 10,
label: String::from("OK"),
}),
],
};
screen.run();
}
在编写这个库时,我们并不知道有人可能会添加 SelectBox
类型,但我们的 Screen 实现能够操作这个新类型并绘制它,
因为 SelectBox 实现了 Draw trait,这意味着它实现了
draw 方法。
这个概念——只关心值所响应的消息,而不关心值的具体
类型——类似于动态类型语言中鸭子类型(duck typing)的
概念:如果它走起来像鸭子,叫起来像鸭子,那它就是一只鸭子!
在示例 18-5 中 Screen 的 run 实现里,run 不需要知道
每个组件的具体类型。它不检查组件是 Button 的实例还是
SelectBox 的实例,只是对组件调用 draw 方法。通过将
Box<dyn Draw> 指定为 components vector 中值的类型,
我们就定义了 Screen 需要的是可以对其调用 draw 方法的值。
使用 trait 对象和 Rust 类型系统来编写类似鸭子类型代码的 优势在于:我们永远不必在运行时检查一个值是否实现了某个 特定方法,也不必担心某个值没有实现某个方法而我们仍然调用 它时会出错。如果值没有实现 trait 对象所需的 trait,Rust 就 不会编译我们的代码。
例如,示例 18-10 展示了如果我们尝试用 String 作为组件
来创建 Screen 会发生什么。
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Hi"))],
};
screen.run();
}
我们会得到这个错误,因为 String 没有实现 Draw trait:
$ cargo run
Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
--> src/main.rs:5:26
|
5 | components: vec![Box::new(String::from("Hi"))],
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
|
help: the trait `Draw` is implemented for `Button`
--> src/lib.rs:23:1
|
23 | impl Draw for Button {
| ^^^^^^^^^^^^^^^^^^^^
= note: required for the cast from `Box<String>` to `Box<dyn Draw>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error
这个错误告诉我们:要么我们向 Screen 传入了本不想传入的
东西,应该改传其他类型;要么我们应该在 String 上实现
Draw,以便 Screen 能够对它调用 draw。
执行动态派发
回想一下第 10 章「使用泛型的代码的 性能」中我们关于编译器对泛型执行单态化 过程的讨论:编译器会为我们用来替代泛型类型参数的每个 具体类型生成函数和方法的非泛型实现。单态化产生的代码执行 的是静态派发(static dispatch),即编译器在编译时就知道 你在调用哪个方法。与之相对的是动态派发(dynamic dispatch), 即编译器在编译时无法判断你在调用哪个方法。在动态派发 的情况下,编译器会生成在运行时能够知道该调用哪个方法的 代码。
当我们使用 trait 对象时,Rust 必须使用动态派发。编译器 并不知道使用 trait 对象的代码可能涉及的所有类型,所以它 不知道应该调用在哪个类型上实现的哪个方法。取而代之的是, 在运行时,Rust 使用 trait 对象内部的指针来知道该调用哪个 方法。这种查找会产生静态派发中不存在的运行时开销。动态 派发还会阻止编译器选择内联方法的代码,这进而阻碍了某些 优化;同时 Rust 对于哪些地方可以使用、哪些地方不可以使用 动态派发有一些规则,称为 dyn 兼容性(dyn compatibility)。 这些规则超出了本次讨论的范围,但你可以在参考文档中阅读 更多相关内容。不过,我们确实在示例 18-5 编写的代码中获得了 额外的灵活性,并得以在示例 18-9 中提供支持,所以这是一个 需要权衡的取舍。