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

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

但是,有时我们希望我们的库用户能够扩展 类型。展示我们如何实现 为此,我们将创建一个示例图形用户界面 (GUI) 工具,该工具迭代 通过一个项目列表,对每个项目调用一个方法,将其绘制到 screen - GUI 工具的常用技术。我们将创建一个名为 library crate,其中包含 GUI 库的结构。此 crate 可能包含 一些类型供人们使用,例如 或 。此外,用户将希望创建自己的类型,这些类型可以绘制:for 实例中,一个程序员可能会添加一个,另一个程序员可能会添加一个 .drawguiButtonTextFieldguiImageSelectBox

我们不会为此示例实现一个成熟的 GUI 库,但将显示 这些碎片如何组合在一起。在编写库时,我们不能 了解并定义其他程序员可能想要创建的所有类型的类型。但我们确实如此 知道 需要跟踪许多不同类型的值,并且 需要对每个不同类型的值调用一个方法。它 不需要确切地知道当我们调用该方法时会发生什么, 只是该值将具有可供我们调用的方法。guidrawdraw

要在具有继承的语言中执行此作,我们可以定义一个名为 (class) 的类,该类上有一个名为 (name) 的方法。其他类(如 、 和 )将继承自 和 因此 inherit 方法。他们都可以覆盖方法来定义 它们的自定义行为,但框架可以将所有类型视为 他们是实例,并调用他们。但是因为 Rust 没有继承,我们需要另一种方法来构建库以 允许用户使用新类型扩展它。ComponentdrawButtonImageSelectBoxComponentdrawdrawComponentdrawgui

定义常见行为的特征

为了实现我们想要的行为,我们将定义一个名为 的 trait,该 trait 将具有一个名为 的方法。然后我们可以定义一个向量,该向量 接受一个 trait 对象。trait 对象同时指向一个类型的实例 实现我们指定的 trait 和一个用于查找 trait 方法的表 该类型。我们通过指定某种 pointer,例如 reference 或 smart pointer,然后是 keyword,然后指定相关 trait。(我们将讨论原因 trait 对象必须使用第 19 章 “动态 Sized 类型和 Sized trait。我们可以 使用 trait 对象代替泛型或具体类型。无论我们在何处使用 trait 对象时,Rust 的类型系统将确保在编译时任何值 used 将实现 trait 对象的 trait。因此,我们 不需要在编译时知道所有可能的类型。guiDrawdraw&Box<T>dyn

我们已经提到,在 Rust 中,我们避免调用结构和枚举 “objects” 来区分它们与其他语言的对象。在结构体或 enum 中,结构体字段中的数据和块中的行为是 分开的,而在其他语言中,数据和行为合二为一 概念通常被标记为对象。但是,trait 对象更像 对象,因为它们结合了数据和行为。 但是 trait 对象与传统对象的不同之处在于,我们不能向 trait 对象。trait 对象通常不如其他 languages:它们的具体目的是允许跨公共 行为。impl

示例 17-3 展示了如何使用一个名为 :Drawdraw

文件名: src/lib.rs

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

示例 17-3:Draw trait 的定义

从我们关于如何定义 trait 的讨论中,这种语法应该看起来很熟悉 在第 10 章中。接下来是一些新的语法:示例 17-4 定义了一个名为 的结构体,它包含一个名为 .此 vector 的类型为 ,它是一个 trait 对象;它是任何类型的内部替代品 a 实现 trait。ScreencomponentsBox<dyn Draw>BoxDraw

文件名: 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 对象向量

在结构体上,我们将定义一个名为 的方法,该方法将在其每个 上调用该方法,如示例 17-5 所示:Screenrundrawcomponents

文件名: 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 bound 定义结构 如示例 17-6:Screen

文件名: 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 方法的替代实现,使用泛型和 trait bounds

这将我们限制到一个实例,该实例具有所有组件列表 type 或全部为 type .如果您只拥有同构 集合中,使用泛型和 trait bounds 是可取的,因为 定义将在编译时被单态化以使用具体类型。ScreenButtonTextField

另一方面,对于使用 trait objects 的方法,一个实例 可以容纳包含 a 和 .让我们看看它是如何工作的,然后我们将讨论 运行时性能影响。ScreenVec<T>Box<Button>Box<TextField>

实现 Trait

现在我们将添加一些实现 trait 的类型。我们将提供类型。同样,实际实现 GUI 库超出了范围 中,因此该方法在其 身体。为了想象实现可能是什么样子,一个 struct 可能有 、 和 的字段,如示例 17-7 所示:DrawButtondrawButtonwidthheightlabel

文件名: 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 结构体

上的 、 和 字段将与 其他组件上的字段;例如,类型可能具有这些 相同的字段加上一个字段。我们想要利用的每种类型 界面将实现 trait,但会在方法中使用不同的代码来定义如何绘制该特定类型,就像这里一样 (如前所述,没有实际的 GUI 代码)。例如,类型 可能有一个额外的块,其中包含与 what 相关的方法 在用户单击按钮时发生。这些类型的方法不适用于 类型(如 .widthheightlabelButtonTextFieldplaceholderDrawdrawButtonButtonimplTextField

如果有人使用我们的库决定实现一个具有 、 和 fields 的结构体,他们也会在该类型上实现 trait,如示例 17-8 所示:SelectBoxwidthheightoptionsDrawSelectBox

文件名: 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 的 crate 并实现 SelectBox 结构体上的 Draw trait

我们库的用户现在可以编写他们的函数来创建实例。对于实例,他们可以通过将 a 和 a 放在 a 中来添加 a 和 a 以成为 trait 对象。然后,他们可以在实例上调用该方法,该方法将调用每个 组件。示例 17-9 显示了这个实现:mainScreenScreenSelectBoxButtonBox<T>runScreendraw

文件名: 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 对象存储 实现相同特征的不同类型

当我们编写库时,我们不知道有人可能会添加该类型,但我们的实现能够在 new 类型并绘制它,因为实现了 trait,该 trait 表示它实现该方法。SelectBoxScreenSelectBoxDrawdraw

这个概念 — 只关心值响应的消息 而不是值的具体类型 - 类似于 Duck 的概念 使用动态类型语言键入:如果它走路像鸭子和嘎嘎声 像鸭子一样,那一定是鸭子!在示例 17-5 中 on 的实现中,不需要知道每个 组件是。它不会检查组件是 a 还是 a 的实例,它只调用组件上的方法。由 指定为 vector 中值的 type 时,我们定义了 need 我们可以调用该方法的值。runScreenrunButtonSelectBoxdrawBox<dyn Draw>componentsScreendraw

使用 trait objects 和 Rust 的类型系统编写代码的优势 与使用 duck 类型的代码类似,我们永远不必检查 value 在运行时实现特定方法,或者担心出错 如果一个值没有实现方法,但我们仍然调用它。Rust 无法编译 如果值没有实现 trait 对象需要的 trait,则我们的代码。

例如,示例 17-10 展示了如果我们尝试创建一个以 a 作为组件的 a 会发生什么:ScreenString

文件名: src/main.rs

use gui::Screen;

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

    screen.run();
}

示例 17-10:尝试使用不 实现 trait 对象的 trait

我们会收到这个错误,因为它没有实现 trait:StringDraw

$ 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

这个错误让我们知道,要么 we 将一些东西传递给 we 没有传递的意思,所以应该传递一个不同的类型,或者我们应该实现 on,这样它就可以调用它。ScreenDrawStringScreendraw

特征对象执行动态调度

回想一下 “Performance of Code Using Generics“ 部分 第 10 章 我们对 compiler:编译器会生成 每个具体类型的函数和方法的非泛型实现 我们使用 in 代替泛型类型参数。生成的代码 monomorphization 执行静态调度,这是编译器知道 在编译时调用的方法。这与动态相反 dispatch,即编译器在编译时无法判断是哪个方法 你在打电话。在动态调度情况下,编译器发出的代码在 runtime 将确定要调用的方法。

当我们使用 trait 对象时,Rust 必须使用动态 dispatch。编译器不会 了解可能与使用 trait 对象的代码一起使用的所有类型, 所以它不知道要调用哪个方法在哪个类型上实现。相反,在 runtime,Rust 使用 trait 对象内的指针来知道要 叫。此查找会产生 static 不会发生的运行时成本 遣。动态调度还会阻止编译器选择内联 方法的代码,这反过来又会阻止某些优化。但是,我们确实得到了 我们在示例 17-5 中编写的代码具有额外的灵活性,并且能够 support 在示例 17-9 中,所以这是一个需要考虑的权衡。

本文档由官方文档翻译而来,如有差异请以官方英文文档(https://doc.rust-lang.org/)为准