使用 Trait 对象处理不同类型的值

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

然而,有时我们希望我们的库用户能够扩展在特定情况下有效的类型集。为了展示我们如何实现这一点,我们将创建一个示例图形用户界面 (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 对象的 vector。trait 对象指向实现我们指定 trait 的类型的实例和一个表,该表用于在运行时查找该类型上的 trait 方法。我们通过指定某种类型的指针(例如 & 引用或 Box<T> 智能指针),然后是 dyn 关键字,然后指定相关的 trait 来创建 trait 对象。(我们将在第 20 章的“动态大小类型和 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 的 vector。这个 vector 的类型是 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 对象的 vector

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 上的 run 方法,该方法在每个组件上调用 draw 方法

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

文件名: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 实例可以保存一个 Vec<T>,其中包含一个 Box<Button> 以及一个 Box<TextField>。让我们看看这是如何工作的,然后我们将讨论运行时性能影响。

实现 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:另一个 crate 使用 gui 并在 SelectBox 结构体上实现 Draw trait

使用 Trait

我们库的用户现在可以编写他们的 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 不需要知道每个组件的具体类型是什么。它不检查组件是 Button 还是 SelectBox 的实例,它只是调用组件上的 draw 方法。通过将 Box<dyn Draw> 指定为 components vector 中值的类型,我们定义了 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 对象和类型推断

使用 trait 对象的一个缺点是它们与类型推断的交互方式。例如,考虑 Vec<T> 的类型推断。当 T 不是 trait 对象时,Rust 只需要知道 vector 中单个元素的类型即可推断 T。因此,空 vector 会导致类型推断错误

fn main() {
let v = vec![];
// error[E0282]: type annotations needed for `Vec<T>`
}

但是添加元素使 Rust 能够推断 vector 的类型

fn main() {
let v = vec!["Hello world"];
// ok, v : Vec<&str>
}

类型推断对于 trait 对象来说更棘手。例如,假设我们尝试将列表 17-9 中的 components 数组分解为一个单独的变量,如下所示

fn main() {
    let components = vec![
        Box::new(SelectBox { /* .. */ }),
        Box::new(Button { /* .. */ }),
    ];
    let screen = Screen { components };
    screen.run();
}

列表 17-11:分解 components 数组会导致类型错误

此重构导致程序不再编译!编译器拒绝此程序并显示以下错误

error[E0308]: mismatched types
   --> test.rs:55:14
    |
55  |       Box::new(Button {
    |  _____--------_^
    | |     |
    | |     arguments to this function are incorrect
56  | |       width: 50,
57  | |       height: 10,
58  | |       label: String::from("OK"),
59  | |     }),
    | |_____^ expected `SelectBox`, found `Button`

在列表 17-09 中,编译器理解 components vector 必须具有 Vec<Box<dyn Draw>> 类型,因为这是在 Screen 结构体定义中指定的。但是在列表 17-11 中,编译器在定义 components 的点丢失了该信息。要解决此问题,您必须为类型推断算法提供提示。可以通过对 vector 的任何元素进行显式转换来实现,如下所示

  let components = vec![
        Box::new(SelectBox { /* .. */ }) as Box<dyn Draw>,
        Box::new(Button { /* .. */ }),
  ];

或者可以通过 let 绑定的类型注释来实现,如下所示

  let components: Vec<Box<dyn Draw>> = vec![
        Box::new(SelectBox { /* .. */ }),
        Box::new(Button { /* .. */ }),
  ];

一般来说,应该意识到,在类型推断的情况下,使用 trait 对象可能会导致 API 客户端更糟糕的开发人员体验。

Trait 对象执行动态分发

回想一下第 10 章中“使用泛型代码的性能”部分,我们讨论了当我们对泛型使用 trait 约束时编译器执行的单态化过程:编译器为我们在泛型类型参数的位置使用的每种具体类型生成函数和方法的非泛型实现。单态化产生的代码正在执行静态分发,即编译器在编译时知道您要调用的方法。这与动态分发相反,动态分发是指编译器无法在编译时判断您要调用的方法。在动态分发的情况下,编译器会发出代码,该代码将在运行时确定要调用的方法。

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