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

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

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

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

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

为通用行为定义特征

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

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

清单 17-3 显示了如何定义一个名为Draw的特征,其中包含一个名为draw的方法

文件名:src/lib.rs

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

清单 17-3:Draw特征的定义

这种语法应该看起来很熟悉,因为我们之前讨论过如何在第 10 章中定义特征。接下来是一些新的语法:清单 17-4 定义了一个名为Screen的结构体,它包含一个名为components的向量。这个向量是Box<dyn Draw>类型,它是一个特征对象;它代表Box中实现Draw特征的任何类型。

文件名:src/lib.rs

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

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

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

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方法

这与定义使用具有特征约束的泛型类型参数的结构体不同。泛型类型参数一次只能用一个具体类型替换,而特征对象允许在运行时使用多个具体类型来填充特征对象。例如,我们可以使用泛型类型和特征约束来定义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:使用泛型和特征约束的Screen结构体及其run方法的替代实现

这将我们限制为一个Screen实例,它有一个组件列表,所有组件都是Button类型或都是TextField类型。如果你只使用同构集合,使用泛型和特征约束是更好的选择,因为定义将在编译时被单态化以使用具体类型。

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

实现特征

现在我们将添加一些实现Draw特征的类型。我们将提供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特征的Button结构体

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

如果使用我们库的人决定实现一个具有widthheightoptions字段的SelectBox结构体,他们也会在SelectBox类型上实现Draw特征,如清单 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特征的板条箱

我们库的用户现在可以编写他们的main函数来创建一个Screen实例。对于Screen实例,他们可以通过将每个组件放入Box<T>中使其成为特征对象来添加一个SelectBox和一个Button。然后,他们可以在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:使用特征对象来存储实现相同特征的不同类型的值

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

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

使用特征对象和 Rust 的类型系统来编写类似于使用鸭子类型编写的代码的优势在于,我们永远不需要在运行时检查值是否实现了特定方法,也不需要担心如果值没有实现方法,但我们仍然调用它,就会出现错误。如果值没有实现特征对象所需的特征,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:尝试使用没有实现特征对象特征的类型

我们将收到此错误,因为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方法。

特征对象执行动态分派

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

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