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

在第 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 库,以允许用户使用新类型扩展它。

定义一个用于常见行为的特征

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

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

列表 18-3 显示了如何定义一个名为 Draw 的 trait,其中包含一个名为 draw 的方法:

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}
Listing 18-3: Definition of the Draw trait

这种语法应该让你想起我们在第 10 章讨论的如何定义特质的内容。接下来是一些新的语法:Screen 结构体定义在列表 18-4 中,它持有一个名为 components 的向量。这个向量的类型是 Box<dyn Draw>,这是一个特质对象;它是 Box 内部实现 Draw 特质的任何类型的占位符。

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}
Listing 18-4: Definition of the Screen struct with a components field holding a vector of trait objects that implement the Draw trait

Screen 结构体上,我们将定义一个名为 run 的方法,该方法将调用每个 componentsdraw 方法,如清单 18-5 所示:

Filename: 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();
        }
    }
}
Listing 18-5: A run method on Screen that calls the draw method on each component

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

Filename: 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();
        }
    }
}
Listing 18-6: An alternate implementation of the Screen struct and its run method using generics and trait bounds

这将我们限制在一个 Screen 实例中,该实例包含一个所有类型为 Button 或所有类型为 TextField 的组件列表。如果你只会拥有同质集合,使用泛型和特质边界是更可取的,因为定义将在编译时被单态化以使用具体的类型。

另一方面,使用特质对象的方法,一个 Screen 实例 可以持有一个包含 Box

实现 Trait

现在我们将添加一些实现Draw特性的类型。我们将提供Button类型。同样,实际实现一个GUI库超出了本书的范围,因此draw方法在其主体中不会有任何有用的实现。想象一下实现可能的样子,一个Button结构体可能有widthheightlabel字段,如清单18-7所示:

Filename: 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
    }
}
Listing 18-7: A Button struct that implements the Draw trait

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

如果有人使用我们的库决定实现一个 SelectBox 结构体,该结构体具有 widthheightoptions 字段,他们还需要在 SelectBox 类型上实现 Draw 特性,如清单 18-8 所示:

Filename: 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() {}
Listing 18-8: Another crate using gui and implementing the Draw trait on a SelectBox struct

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

Filename: 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();
}
Listing 18-9: Using trait objects to store values of different types that implement the same trait

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

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

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

例如,清单 18-10 显示了如果我们尝试使用 String 作为组件创建 Screen 会发生什么:

Filename: src/main.rs
use gui::Screen;

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

    screen.run();
}
Listing 18-10: Attempting to use a type that doesn’t implement the trait object’s trait

我们将因为 String 没有实现 Draw 特性而收到此错误:

$ 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 章 “使用泛型的代码性能” 部分我们关于编译器在我们对泛型使用特质边界时执行的单态化过程的讨论:编译器为每个我们用作泛型类型参数的具体类型生成非泛型的函数和方法实现。单态化生成的代码执行的是 静态分发,即编译器在编译时就知道你调用的是哪个方法。这与 动态分发 相对,即编译器在编译时无法确定你调用的是哪个方法。在动态分发的情况下,编译器会生成在运行时确定调用哪个方法的代码。

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