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


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


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


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


要在具有继承的语言中执行此作,我们可以定义一个名为 具有名为 draw 的方法的组件。其他类(例如 ButtonImageSelectBox 将从 Component 继承,从而继承 draw 方法。它们都可以重写 draw 方法来定义其自定义行为,但框架可以将所有类型视为 Component 实例,并对它们调用 draw。但是因为 Rust 没有继承,我们需要另一种方法来构建 gui 库,允许用户使用新类型来扩展它。


定义常见行为的特征


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


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


示例 17-3 展示了如何使用一个名为 Draw 的方法定义一个名为 Draw 的 trait 平局


文件名: src/lib.rs

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


示例 17-3:Draw trait 的定义


从我们关于如何定义 trait 的讨论中,这种语法应该看起来很熟悉 在第 10 章中。接下来是一些新的语法:示例 17-4 定义了一个名为 Screen 中保存名为 components 的向量。此向量的类型为 Box<dyn Draw>,这是一个 trait 对象;它是 Box 中实现 Draw trait 的任何类型的替代项。


文件名: src/lib.rs

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

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


示例 17-4:使用 components 字段,其中包含实现 Draw 的 trait 对象的 vector 特性


Screen 结构体中,我们将定义一个名为 run 的方法,它将调用 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 方法


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


这将我们限制在一个 Screen 实例中,该实例具有一个组件列表,这些组件都是 Button 类型或全部是 TextField 类型。如果你只有同构集合,最好使用泛型和 trait bounds,因为定义将在编译时被单态化以使用具体类型。


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


实现 Trait


现在,我们将添加一些实现 Draw trait 的类型。我们将提供 按钮类型。同样,实际实现 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:实现 绘制特征


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


如果有人使用我们的库决定实现一个 SelectBox 结构体,该结构体具有 widthheightoptions 字段,它们会在 SelectBox 类型,如示例 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 trait 的 crate


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


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


这个概念(只关心值响应的消息,而不是值的具体类型)类似于 duck 的概念 用动态输入的语言打字:如果它走路像鸭子,叫声像鸭子,那么它一定是鸭子!在 run on Screen 的实现中 在示例 17-5 中,run 不需要知道每个组件的具体类型是什么。它不会检查组件是否是 Button 的实例 或 SelectBox,它只调用组件上的 draw 方法。通过指定 Box<dyn Draw> 作为组件中值的类型 vector 中,我们将 Screen 定义为需要可以调用 draw 的值 method 打开。


使用 trait 对象和 Rust 的类型系统来编写类似于使用 duck types 的代码的代码的好处是,我们永远不必在运行时检查一个值是否实现了一个特定的方法,或者担心如果一个值没有实现一个方法,但我们仍然调用它,那么会得到错误。如果 values 没有实现 trait 对象需要的 trait,Rust 就不会编译我们的代码。


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


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

$ 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 上绘制,以便 Screen 能够对其调用 draw


特征对象执行动态调度


回想一下 “Performance of Code Using 泛型“部分,我们讨论了当我们在泛型上使用 trait bounds 时编译器执行的单态化过程:编译器为我们使用的每个具体类型生成函数和方法的非泛型实现,以代替泛型类型参数。单态化产生的代码正在进行静态调度,即编译器知道您在编译时调用的方法。这与动态相反 dispatch,即编译器在编译时无法判断你调用的是哪个方法。在动态调度情况下,编译器会发出代码,这些代码将在运行时确定要调用的方法。


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