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

在第 8 章中,我们提到向量(vector)的一个限制是它们只能存储单一类型的元素。我们在 Listing 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,该 trait 将有一个名为 draw 的方法。然后我们可以定义一个接受 trait 对象的向量。trait 对象 既指向实现我们指定 trait 的类型的实例,也指向在运行时用于查找该类型上的 trait 方法的表。我们通过指定某种指针(例如 & 引用或 Box<T> 智能指针)来创建 trait 对象,然后是 dyn 关键字,最后指定相关的 trait。(我们将在第 20 章的 “动态大小类型和 Sized Trait” 中讨论 trait 对象必须使用指针的原因。)我们可以使用 trait 对象来代替泛型或具体类型。无论我们在哪里使用 trait 对象,Rust 的类型系统都会在编译时确保在该上下文中使用的任何值都实现了 trait 对象的 trait。因此,我们不需要在编译时知道所有可能的类型。

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

Listing 18-3 展示了如何定义一个名为 Draw 的 trait,该 trait 有一个名为 draw 的方法。

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

这种语法应该从我们在第 10 章讨论如何定义 trait 时看起来很熟悉。接下来是一些新语法:Listing 18-4 定义了一个名为 Screen 的结构体,它包含一个名为 components 的向量。这个向量的类型是 Box<dyn Draw>,这是一个 trait 对象;它是 Box 中实现 Draw trait 的任何类型的替身。

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

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

Screen 结构体上,我们将定义一个名为 run 的方法,该方法将在其 components 中的每个组件上调用 draw 方法,如 Listing 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();
        }
    }
}

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

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

这将我们限制为具有所有组件类型为 Button 或所有组件类型为 TextFieldScreen 实例。如果你只会有同质集合,使用泛型和 trait 约束是更可取的,因为这些定义将在编译时单态化为使用具体类型。

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

实现 Trait

现在我们将添加一些实现 Draw trait 的类型。我们将提供 Button 类型。再次强调,实际实现 GUI 库超出了本书的范围,因此 draw 方法在其主体中不会有任何有用的实现。为了想象实现可能是什么样子,Button 结构体可能有 widthheightlabel 字段,如 Listing 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
    }
}

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

如果使用我们库的人决定实现一个具有 widthheightoptions 字段的 SelectBox 结构体,他们将在 SelectBox 类型上实现 Draw trait,如 Listing 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() {}

我们库的用户现在可以编写他们的 main 函数来创建一个 Screen 实例。他们可以向 Screen 实例添加一个 SelectBox 和一个 Button,方法是将每个放入 Box<T> 中以成为 trait 对象。然后他们可以调用 Screen 实例上的 run 方法,该方法将在每个组件上调用 draw。Listing 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 方法。

这个概念——只关心值响应的消息而不是值的具体类型——类似于动态类型语言中的 鸭子类型 概念:如果它走起来像鸭子,叫起来像鸭子,那么它一定是鸭子!在 Listing 18-5 中 Screen 上的 run 实现中,run 不需要知道每个组件的具体类型是什么。它不检查组件是 Button 还是 SelectBox 的实例,它只是在组件上调用 draw 方法。通过指定 Box<dyn Draw> 作为 components 向量中值的类型,我们定义了 Screen 需要我们可以调用 draw 方法的值。

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

例如,Listing 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`
  = 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 对象时,Rust 必须使用动态分派。编译器不知道可能与使用 trait 对象的代码一起使用的所有类型,因此它不知道要调用哪个类型上的哪个方法。相反,在运行时,Rust 使用 trait 对象内部的指针来确定要调用哪个方法。这种查找会产生运行时成本,而静态分派不会产生这种成本。动态分派还阻止编译器选择内联方法的代码,这反过来又阻止了一些优化,Rust 有一些关于在哪里可以使用和不能使用动态分派的规则,称为 dyn 兼容性。这些规则超出了本次讨论的范围,但你可以在 参考 中阅读更多关于它们的信息。然而,我们在 Listing 18-5 中编写的代码确实获得了额外的灵活性,并且能够在 Listing 18-9 中支持,因此这是一个需要考虑的权衡。