Bootstrap

Rust从0到1-面向对象编程-Trait 对象

在前面章节我们介绍集合的时候,我们提到过 vector 类型的一个局限,即只能存储相同类型的元素。并在例子中,定义了一个枚举类型 SpreadsheetCell 来作为同时存储整型,浮点型和文本成员的替代方案。通过这种方式我们可以在表格中的每个单元储存不同类型的数据,并将它们存储在代表一行数据的 vector 类型中。这种方案,对于在编译时我们就可以确定的有限类型的场景是完全可行的。

但是,在有些场景下,我们会希望用户可以自己扩展可以放入 vector 中的类型集合,也就是说哪些类型可以同时存储到 vector 中不再受到事先定义的枚举类型所包含的成员的限制。那么,如何突破 vector 的局限做到这一点呢?下面我们以一个图形界面接口(Graphical User Interface, GUI)工具为例来说明,它会遍历列表并调用每一个元素的 draw 方法将其绘制到屏幕上 —— 这是 GUI 库中的常见技术。我们会在例子中创建一个包含 GUI 库的 library crate —— gui。在这个 crate 中我们会提供一些可供其它人使用的类型,譬如,Button 或 TextField。此外,gui 的使用者还会希望可以创建自定义的图形类型:譬如,一个用户可能会增加一个 Image 类型,另一个用户可能又增加 一个 SelectBox 类型。

我们不会实现一个功能完善的 GUI 库,不过会展示其中各个部分是如何组合在一起的;同时,在编写库的时候,我们不可能知道并定义出所有期望的类型。但是,我们知道的是 gui 需要能记录很多不同类型的图形组件实例,并能调用它们各自的 draw 方法(我们无需知道每个实例 draw 方法的具体实现,只需要其提供该方法可供我们调用)。

在支持继承的语言中,我们可以通过以下方案实现:定义一个包含 draw 方法的父类 Component,其它子类,如 Button、Image 和 SelectBox 等可以通过继承 Component 类从而继承 draw 方法。在子类中可以通过覆盖 draw 方法来定义自己的行为,在使用时我们会把所有这些类型看作是 Component 的实例,并调用其 draw 方法。

然而 Rust 中并没有继承,那么我们应该如何做到可以灵活的扩展图形组件呢?

定义 trait 抽象共同行为

为了让所有 gui 都具备我们所期望的行为,我们可以定义一个 Draw trait,其包含 draw 方法。之后我们就可以使用它定义用于存放 trait 对象的列表。Trait 对象(trait object)同时指向实现了指定 trait 的类型的实例,以及用于在运行时查找其实现的 trait 方法的 table 。我们通过指针(例如,& 或 Box等智能指针,我们在后面章节会介绍必须使用指针的原因) + 关键字 dyn + 指定 trait,来定义trait 对象。我们可以使用 trait 对象替代泛型或具体类型,并且在代码的任何位置使用它时,Rust 的类型机制会在编译时确保任何使用到它的实例都实现了其指定的 trait,而无需像之前那样在编译时就知晓所有可能的类型。

在 Rust 语言中为了与其它语言中的“对象”区别,我们避免将结构体与枚举称为 “对象”。在结构体或枚举中,与其它语言将数据和行为组合在一起称为“对象”的概念不同,结构体字段中的数据和 impl 块中的行为是分离的。从这方面来说 trait 对象与其他语言中的“对象”类似,但是又不相同,因为我们不能向 trait 对象中添加数据,其主要还是用于对通用行为的抽象。下面我们先定义包含 draw 方法的 Draw trait :

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

前面我们已经介绍过如何定义 trait。接下来我们将定义一个新的类型,结构体Screen ,其包含一个用于存放图形组件的列表。根据前面介绍的 trait 对象语法,我们指定列表中的元素类型为实现了 Draw 的 trait 对象。参考下面的例子:

pub struct Screen {
    pub components: Vec>,
}

接下来我们为结构体 Screen 添加一个 run 方法,它会调用 components 中的每个组件的 draw 方法进行绘图,参考下面的例子:

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

这与利用 trait bound 约束泛型参数的结构体定义不同。泛型参数在其上下文范围内只能替代某一个具体类型,而 trait 对象则没有这个限制,可以在运行时替代多种具体类型。使用泛型 和 trait bound 定义的 Screen 结构体可以参考下面的例子:

pub struct Screen {
    pub components: Vec,
}

impl Screen
where
    T: Draw,
{
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

这限制了 Screen 实例只能拥有一个包含同一种类型组件的列表,譬如, Button 或 TextField 。如果我们在运行时并不会用到多种类型,则更偏向于采用泛型和 trait bound,因为其在编译时会被单态化,性能会相对更好。反之,我们应该使用 trait 对象,这样 Screen 实例中的 components 列表就可以包含多种组件类型,譬如,Box