使用允许不同类型值的 Trait 对象

在第 8 章中,我们提到向量的一个限制是它们只能存储一种类型的元素。我们在列表 8-9 中创建了一个解决方法,定义了一个 SpreadsheetCell 枚举,其中包含用于保存整数、浮点数和文本的变体。这意味着我们可以在每个单元格中存储不同类型的数据,并且仍然拥有一个表示单元格行的向量。当我们的可互换项是一组在编译代码时已知的固定类型时,这是一个完美的解决方案。

但是,有时我们希望我们的库用户能够扩展在特定情况下有效的类型集。为了展示我们如何实现这一点,我们将创建一个示例图形用户界面 (GUI) 工具,该工具遍历项目列表,对每个项目调用 draw 方法以将其绘制到屏幕上——这是 GUI 工具的常用技术。我们将创建一个名为 gui 的库 crate,其中包含 GUI 库的结构。此 crate 可能包含一些供人们使用的类型,例如 ButtonTextField。此外,gui 用户将希望创建他们自己的可以绘制的类型:例如,一位程序员可能会添加一个 Image,另一位可能会添加一个 SelectBox

我们将不为本示例实现一个功能完整的 GUI 库,但将展示如何将各个部分组合在一起。在编写库时,我们无法知道和定义其他程序员可能希望创建的所有类型。但我们知道 gui 需要跟踪许多不同类型的值,并且需要在每个不同类型的值上调用 draw 方法。它不需要知道当我们调用 draw 方法时会发生什么,只需要知道该值将具有可供我们调用的方法。

要在具有继承的语言中执行此操作,我们可能会定义一个名为 Component 的类,该类在其上有一个名为 draw 的方法。其他类(例如 ButtonImageSelectBox)将从 Component 继承,从而继承 draw 方法。它们都可以重写 draw 方法来定义其自定义行为,但框架可以将所有类型视为 Component 实例并在其上调用 draw。但是,由于 Rust 没有继承,我们需要另一种方法来构建 gui 库以允许用户使用新类型对其进行扩展。

为通用行为定义 Trait

为了实现我们希望 gui 具有的行为,我们将定义一个名为 Draw 的 trait,它将有一个名为 draw 的方法。然后,我们可以定义一个采用 *trait 对象* 的向量。 trait 对象指向实现我们指定 trait 的类型实例以及用于在运行时查找该类型的 trait 方法的表。我们通过指定某种指针(例如 & 引用或 Box<T> 智能指针),然后是 dyn 关键字,然后指定相关的 trait 来创建 trait 对象。(我们将在第 19 章的 “动态大小类型和 Sized Trait” 部分讨论 trait 对象必须使用指针的原因。)我们可以使用 trait 对象来代替泛型或具体类型。无论我们在哪里使用 trait 对象,Rust 的类型系统都会在编译时确保在该上下文中使用的任何值都将实现该 trait 对象的 trait。因此,我们不需要在编译时知道所有可能的类型。

我们已经提到,在 Rust 中,我们避免将结构体和枚举称为“对象”,以将其与其他语言的对象区分开来。在结构体或枚举中,结构体字段中的数据和 impl 块中的行为是分开的,而在其他语言中,数据和行为组合成一个概念通常被标记为对象。但是,trait 对象 *更像* 其他语言中的对象,因为它们结合了数据和行为。但是,trait 对象与传统对象的不同之处在于,我们无法向 trait 对象添加数据。 trait 对象不像其他语言中的对象那样普遍有用:它们的特定目的是允许跨通用行为进行抽象。

列表 17-3 显示了如何定义具有一个名为 draw 的方法的 trait,名为 Draw

文件名:src/lib.rs

pub trait Draw {
    fn draw(&self);
}

列表 17-3:Draw trait 的定义

从我们在第 10 章中讨论如何定义 trait 时起,此语法应该很熟悉。接下来是一些新的语法:列表 17-4 定义了一个名为 Screen 的结构体,该结构体保存一个名为 components 的向量。此向量的类型为 Box<dyn Draw>,这是一个 trait 对象;它是 Box 中实现 Draw trait 的任何类型的占位符。

文件名:src/lib.rs

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

列表 17-4:Screen 结构体的定义,其中 components 字段保存实现 Draw trait 的 trait 对象的向量

Screen 结构体上,我们将定义一个名为 run 的方法,该方法将调用其每个 components 上的 draw 方法,如列表 17-5 所示

文件名:src/lib.rs

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

列表 17-5:Screen 上调用每个组件上的 draw 方法的 run 方法

这与定义使用带有 trait 边界的泛型类型参数的结构体的工作方式不同。泛型类型参数一次只能替换为一个具体类型,而 trait 对象允许在运行时为 trait 对象填充多个具体类型。例如,我们可以使用如列表 17-6 所示的泛型类型和 trait 边界定义 Screen 结构体

文件名:src/lib.rs

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

列表 17-6:使用泛型和 trait 边界的 Screen 结构体及其 run 方法的替代实现

这会将我们限制为 Screen 实例,该实例具有类型为 Button 或类型为 TextField 的组件列表。如果您只需要同构集合,则最好使用泛型和 trait 边界,因为定义将在编译时进行单态化以使用具体类型。

另一方面,使用 trait 对象的方法,一个 Screen 实例可以保存一个包含 Box<Button>Box<TextField>Vec<T>。让我们看看这是如何工作的,然后我们将讨论运行时性能的影响。

实现 Trait

现在我们将添加一些实现 Draw trait 的类型。我们将提供 Button 类型。同样,实际实现 GUI 库超出了本书的范围,因此 draw 方法在其主体中没有任何有用的实现。要想象一下实现可能是什么样子,Button 结构体可能具有 widthheightlabel 的字段,如列表 17-7 所示

文件名:src/lib.rs

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

列表 17-7:实现 Draw trait 的 Button 结构体

Button 上的 widthheightlabel 字段与其他组件上的字段不同;例如,TextField 类型可能具有相同的字段加上 placeholder 字段。我们要在屏幕上绘制的每种类型都将实现 Draw trait,但在 draw 方法中使用不同的代码来定义如何绘制该特定类型,如这里的 Button (没有实际的 GUI 代码,如上所述)。例如,Button 类型可能具有包含与用户单击按钮时发生的情况相关的方法的附加 impl 块。这些类型的方法不适用于 TextField 等类型。

如果使用我们库的某人决定实现具有 widthheightoptions 字段的 SelectBox 结构体,他们也会在 SelectBox 类型上实现 Draw trait,如列表 17-8 所示

文件名:src/main.rs

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

列表 17-8:另一个使用 gui 并在 SelectBox 结构体上实现 Draw trait 的 crate

我们库的用户现在可以编写他们的 main 函数来创建 Screen 实例。对于 Screen 实例,他们可以通过将每个实例放入 Box<T> 来使其成为 trait 对象,从而添加 SelectBoxButton。然后,他们可以调用 Screen 实例上的 run 方法,该方法将调用每个组件上的 draw。列表 17-9 显示了此实现

文件名:src/main.rs

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

列表 17-9:使用 trait 对象来存储实现相同 trait 的不同类型的值

当我们编写库时,我们不知道有人可能会添加 SelectBox 类型,但我们的 Screen 实现能够在新类型上运行并绘制它,因为 SelectBox 实现了 Draw trait,这意味着它实现了 draw 方法。

这个概念——只关注值响应的消息,而不是值的具体类型——类似于动态类型语言中的 *鸭子类型* 的概念:如果它像鸭子一样走路并且像鸭子一样嘎嘎叫,那么它一定是鸭子!在列表 17-5 中 Screenrun 的实现中,run 不需要知道每个组件的具体类型是什么。它不会检查组件是否是 ButtonSelectBox 的实例,它只会调用该组件上的 draw 方法。通过将 Box<dyn Draw> 指定为 components 向量中值的类型,我们已定义 Screen 需要我们可以调用 draw 方法的值。

使用 trait 对象和 Rust 的类型系统来编写类似于使用鸭子类型的代码的优点是,我们永远不必在运行时检查值是否实现了特定的方法,或者担心如果值没有实现方法但我们仍然调用它时会出现错误。如果值没有实现 trait 对象需要的 trait,Rust 将不会编译我们的代码。

例如,列表 17-10 显示了如果我们尝试创建带有 String 作为组件的 Screen 会发生什么

文件名:src/main.rs

use gui::Screen;

fn main() {
    let screen = Screen {
        components: vec![Box::new(String::from("Hi"))],
    };

    screen.run();
}

列表 17-10:尝试使用未实现 trait 对象 trait 的类型

我们会收到此错误,因为 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`
  = 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

Trait 对象执行动态分发

回顾一下第 10 章的“使用泛型代码的性能”章节中,我们讨论了当在泛型上使用 trait bounds 时编译器执行的单态化过程:编译器为我们使用的每个具体类型生成函数和方法的非泛型实现,以替代泛型类型参数。单态化产生的代码执行的是静态分发,即编译器在编译时就知道你要调用哪个方法。这与动态分发相反,动态分发是指编译器在编译时无法确定你要调用哪个方法。在动态分发的情况下,编译器会发出代码,这些代码在运行时会确定要调用哪个方法。

当我们使用 trait 对象时,Rust 必须使用动态分发。编译器不知道所有可能与使用 trait 对象的代码一起使用的类型,因此它不知道要调用哪个类型实现的哪个方法。相反,在运行时,Rust 使用 trait 对象内的指针来确定要调用哪个方法。这种查找会产生运行时成本,而静态分发不会产生这种成本。动态分发还会阻止编译器选择内联方法的代码,这反过来又阻止了一些优化。然而,我们在列表 17-5 中编写的代码中获得了额外的灵活性,并且能够在列表 17-9 中支持这种灵活性,所以这是一个需要权衡的折衷。