用于异构值的 Trait 对象

Beginner

This tutorial is from open-source community. Access the source code

简介

欢迎来到「使用允许不同类型值的 trait 对象」。本实验是 Rust 程序设计语言 的一部分。你可以在 LabEx 中练习 Rust 技能。

在本实验中,我们将探索如何使用 trait 对象,以便在库中,特别是在图形用户界面 (GUI) 工具的上下文中,允许不同类型的值。

使用允许不同类型值的 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 对象。(我们将在“动态大小类型和 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 struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

清单 17-4:Screen 结构体的定义,其 components 字段包含一个实现 Draw trait 的 trait 对象向量

Screen 结构体上,我们将定义一个名为 run 的方法,它将对其每个 components 调用 draw 方法,如清单 17-5 所示。

文件名:src/lib.rs

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 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 struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // 实际绘制按钮的代码
    }
}

清单 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) {
        // 实际绘制选择框的代码
    }
}

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

我们库的用户现在可以编写他们的 main 函数来创建一个 Screen 实例。对于 Screen 实例,他们可以通过将每个组件放入 Box<T> 中以成为 trait 对象的方式来添加一个 SelectBox 和一个 Button。然后他们可以在 Screen 实例上调用 run 方法,该方法将对每个组件调用 draw 方法。清单 17-9 展示了这个实现。

文件名:src/main.rs

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:

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`
  |
  = note: required for the cast to the object type `dyn Draw`

这个错误让我们知道,要么我们向 Screen 传递了不应该传递的东西,所以应该传递不同的类型,要么我们应该在 String 上实现 Draw trait,以便 Screen 能够对其调用 draw 方法。

trait 对象执行动态分派

回忆一下在“使用泛型的代码性能”中,我们关于编译器在对泛型使用 trait 边界时执行的单态化过程的讨论:编译器为我们用来代替泛型类型参数的每个具体类型生成函数和方法的非泛型实现。单态化产生的代码执行的是静态分派,即编译器在编译时就知道你正在调用哪个方法。这与动态分派相反,动态分派是指编译器在编译时无法确定你正在调用哪个方法。在动态分派的情况下,编译器生成的代码在运行时会确定要调用哪个方法。

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

总结

恭喜你!你已经完成了“使用允许不同类型值的 trait 对象”实验。你可以在 LabEx 中练习更多实验来提升你的技能。