键盘快捷键

按 ← 或 → 在章节之间导航

按 S 或 / 在书中搜索

按 ? 显示此帮助

按 Esc 隐藏此帮助

使用 trait 对象对共享行为进行抽象

在第 8 章中,我们提到过 vector 的一个局限:它们只能 存储单一类型的元素。在示例 8-9 中我们曾用变通方法 定义了一个 SpreadsheetCell 枚举,其变体可以存放整数、 浮点数和文本。这意味着我们可以在每个单元格中存储不同类型 的数据,同时仍用一个 vector 来表示一行单元格。当我们的 可互换项是编译时已知的一组固定类型时,这是一个非常好的 解决方案。

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

在编写这个库时,我们无法知道并定义其他程序员可能想要 创建的所有类型。但我们确切知道的是:gui 需要跟踪 许多不同类型的值,并且需要对这些不同类型的值分别调用 draw 方法。它不需要确切知道调用 draw 方法时会发生 什么,只需知道该值有这个方法可供我们调用即可。

在有继承机制的语言中,我们可能会定义一个名为 Component 的类,其上有一个名为 draw 的方法。其他 类,如 Button、Image 和 SelectBox,会继承自 Component,从而继承 draw 方法。它们各自可以覆盖 draw 方法来定义自定义行为,但框架可以把所有这些类型 都当作 Component 实例来对待,并对其调用 draw。但 由于 Rust 没有继承,我们需要另一种方式来组织 gui 库,以允许用户创建与该库兼容的新类型。

为共享行为定义 trait

为了实现我们希望 gui 具备的行为,我们将定义一个 名为 Draw 的 trait,其中包含一个名为 draw 的方法。 然后,我们就可以定义一个接收 trait 对象的 vector。 trait 对象(trait object)同时指向一个实现了我们指定的 trait 的类型的实例,以及一张用于在运行时查找该类型 trait 方法的表。我们通过指定某种指针(例如引用或 Box<T> 智能指针),加上 dyn 关键字,再指定相关 trait 来创建 trait 对象。(我们将在第 20 章的「动态大小类型与 Sized trait」一节讨论 trait 对象必须使用 指针的原因。)我们可以在泛型或具体类型的位置使用 trait 对象。无论在哪里使用 trait 对象,Rust 的类型系统都会在 编译时确保在该上下文中使用的任何值都实现了 trait 对象的 trait。因此,我们无需在编译时知道所有可能的类型。

我们提到过,在 Rust 中,我们避免把结构体和枚举称为 「对象」,以便与其他语言中的对象区分开来。在结构体或 枚举中,结构体字段中的数据与 impl 块中的行为是分离的; 而在其他语言中,数据与行为结合为一个概念,通常被标记为 对象。trait 对象与其他语言中对象的不同之处在于:我们无法 向 trait 对象添加数据。trait 对象不像其他语言中的对象那样 具有广泛的通用性:它们的特定用途是实现跨共享行为的抽象。

示例 18-3 展示了如何定义一个名为 Draw、带有一个 名为 draw 方法的 trait。

文件名:src/lib.rs
pub trait Draw {
    fn draw(&self);
}
示例 18-3:Draw trait 的定义

这种语法应该看起来很熟悉,我们在第 10 章讨论过如何定义 trait。接下来是一些新语法:示例 18-4 定义了一个名为 Screen 的结构体,它持有一个名为 components 的 vector。 这个 vector 的类型是 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>>,
}
示例 18-4:Screen 结构体的定义,其 components 字段存放一个由实现了 Draw trait 的 trait 对象组成的 vector

在 Screen 结构体上,我们将定义一个名为 run 的方法, 它会对 components 中的每一项调用 draw 方法,如示例 18-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();
        }
    }
}
示例 18-5:Screen 上的 run 方法,对每个组件调用 draw 方法

这与定义使用带 trait 边界的泛型类型参数的结构体的工作方式 不同。泛型类型参数一次只能被一个具体类型替代,而 trait 对象 允许在运行时用多个具体类型来填充 trait 对象。例如,我们本可以 像示例 18-6 那样,使用泛型类型和 trait 边界来定义 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();
        }
    }
}
示例 18-6:使用泛型和 trait 边界实现 Screen 结构体及其 run 方法的另一种实现

这限制了我们只能拥有组件列表全部同为 Button 类型或全部 同为 TextField 类型的 Screen 实例。如果你永远只会有同构 集合,那么使用泛型和 trait 边界更可取,因为这些定义会在编译时 被单态化(monomorphize)以使用具体类型。

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

实现该 trait

现在我们将添加一些实现 Draw trait 的类型。我们提供 Button 类型。需要再次说明的是,真正实现一个 GUI 库超出了 本书的范围,所以 draw 方法的方法体中不会有任何有用的实现。 要想象实现可能的样子,一个 Button 结构体可能有 width、height 和 label 字段,如示例 18-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
    }
}
示例 18-7:实现 Draw trait 的 Button 结构体

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

如果使用我们库的人决定实现一个带有 width、height 和 options 字段的 SelectBox 结构体,他们同样会在 SelectBox 类型上实现 Draw trait,如示例 18-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() {}
示例 18-8:另一个 crate 使用 gui 并在 SelectBox 结构体上实现 Draw trait

我们库的用户现在可以编写他们的 main 函数来创建一个 Screen 实例。他们可以通过将 SelectBox 和 Button 各自放入 Box<T> 使之成为 trait 对象,来将它们添加到 Screen 实例中。然后他们可以调用 Screen 实例的 run 方法,该方法会对每个组件调用 draw。示例 18-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();
}
示例 18-9:使用 trait 对象存储实现了同一 trait 的不同类型的值

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

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

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

例如,示例 18-10 展示了如果我们尝试用 String 作为组件 来创建 Screen 会发生什么。

文件名:src/main.rs
use gui::Screen;

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

    screen.run();
}
示例 18-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`
  --> src/lib.rs:23:1
   |
23 | impl Draw 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 章「使用泛型的代码的 性能」中我们关于编译器对泛型执行单态化 过程的讨论:编译器会为我们用来替代泛型类型参数的每个 具体类型生成函数和方法的非泛型实现。单态化产生的代码执行 的是静态派发(static dispatch),即编译器在编译时就知道 你在调用哪个方法。与之相对的是动态派发(dynamic dispatch), 即编译器在编译时无法判断你在调用哪个方法。在动态派发 的情况下,编译器会生成在运行时能够知道该调用哪个方法的 代码。

当我们使用 trait 对象时,Rust 必须使用动态派发。编译器 并不知道使用 trait 对象的代码可能涉及的所有类型,所以它 不知道应该调用在哪个类型上实现的哪个方法。取而代之的是, 在运行时,Rust 使用 trait 对象内部的指针来知道该调用哪个 方法。这种查找会产生静态派发中不存在的运行时开销。动态 派发还会阻止编译器选择内联方法的代码,这进而阻碍了某些 优化;同时 Rust 对于哪些地方可以使用、哪些地方不可以使用 动态派发有一些规则,称为 dyn 兼容性(dyn compatibility)。 这些规则超出了本次讨论的范围,但你可以在参考文档中阅读 更多相关内容。不过,我们确实在示例 18-5 编写的代码中获得了 额外的灵活性,并得以在示例 18-9 中提供支持,所以这是一个 需要权衡的取舍。