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<T>等智能指针,我们在后面章节会介绍必须使用指针的原因) + 关键字 dyn + 指定 trait,来定义 trait 对象。我们可以使用 trait 对象替代泛型或具体类型,并且在代码的任何位置使用它时,Rust 的类型机制会在编译时确保任何使用到它的实例都实现了其指定的 trait,而无需像之前那样在编译时就知晓所有可能的类型。
在 Rust 语言中为了与其它语言中的“对象”区别,我们避免将结构体与枚举称为 “对象”。在结构体或枚举中,与其它语言将数据和行为组合在一起称为“对象”的概念不同,结构体字段中的数据和 impl 块中的行为是分离的。从这方面来说 trait 对象与其他语言中的“对象”类似,但是又不相同,因为我们不能向 trait 对象中添加数据,其主要还是用于对通用行为的抽象。下面我们先定义包含 draw 方法的 Draw trait :
前面我们已经介绍过如何定义 trait。接下来我们将定义一个新的类型,结构体 Screen ,其包含一个用于存放图形组件的列表。根据前面介绍的 trait 对象语法,我们指定列表中的元素类型为实现了 Draw 的 trait 对象。参考下面的例子:
接下来我们为结构体 Screen 添加一个 run 方法,它会调用 components 中的每个组件的 draw 方法进行绘图,参考下面的例子:
这与利用 trait bound 约束泛型参数的结构体定义不同。泛型参数在其上下文范围内只能替代某一个具体类型,而 trait 对象则没有这个限制,可以在运行时替代多种具体类型。使用泛型 和 trait bound 定义的 Screen 结构体可以参考下面的例子:
这限制了 Screen 实例只能拥有一个包含同一种类型组件的列表,譬如, Button 或 TextField 。如果我们在运行时并不会用到多种类型,则更偏向于采用泛型和 trait bound,因为其在编译时会被单态化,性能会相对更好。反之,我们应该使用 trait 对象,这样 Screen 实例中的 components 列表就可以包含多种组件类型,譬如,Box<Button> 和 Box<TextField>,下面我们看看它是如何工作的,接着讨论其对运行时性能的影响。
实现 Draw trait
下面让我们增加一些实现 Draw trait 的类型。首先是 Button 类型(我们不会在此实现正真的 GUI 功能库,主要目的是讨论 trait 对象,所以我们不会在这里讨论 draw 方法应该如何实现)。参考下面的例子:
上面我们在例子中定义的 Button 类型包含 width、height 和 label 字段,这可能和其它组件并不相同,譬如,TextField 可能另外还包含 placeholder 字段。我们希望所有能在屏幕上绘制的类型都实现 Draw trait 的 draw 方法来定义如何将其自身绘制出来。除了 Draw trait 之外,组件可能需要实现另外一些特定的行为,譬如, Button 需要包含如何响应点击的方法实现,而这类方法可能并不适用于其它全部类型。
假如其他使用者希望扩展一些库中没有的组件,譬如,一个包含 width、height 和 options 字段的类型 SelectBox,那么可以像下面的例子这样做:
最后,我们在 main 函数中创建一个 Screen 实例,并放入 SelectBox 和 Button 组件,然后调用 Screen 的 run 方法,它会调用每个组件的绘制方法 draw 。参考下面的例子:
在我们编写这个图形库的时候,我们无法写出所有的图形组件或者说也不知道其他人会添加什么样的图形组件,不过借助 trait 对象,Screen 的实现能够绘制这个新类型,因为其实现了 Draw trait,也就实现了 draw 方法。
这个概念(只关心事物的外部行为而不是其具体类型),类似于动态类型语言中所说的鸭子类型(duck typing):如果它走起来像一只鸭子,叫起来像一只鸭子,那么它就是一只鸭子!就像我们前面例子中 Screen 的 run 方法,它并不知道组件的具体类型是什么,也不检查组件具体是 Button 还是 SelectBox 类型。通过指定 Box<dyn Draw> 作为 components 列表中元素的类型,我们就约束了 Screen 需要的是实现了 draw 方法的组件类型。这么做的优点是,我们无需在运行时检查一个实例是否实现了某个方法或者由于其没有实现某个方法而产生运行时错误,借助 trait 对象和 类型机制,Rust 会在编译时就提醒我们错误。参考下面的例子:
如果尝试编译上面的代码,由于 String 没有实现 Draw trait ,我们会得到类似下面的错误:
根据错误提示,要么是我们传递了错误的数据给 Screen ,要么我们需要为 String 实现 Draw trait,这样 Screen 才可以使用它。
trait 对象和动态分派
前面章节中我们讨论过编译器对泛型的单态化处理,当我们使用 trait bound 时:编译器会生成每个具体类型的方法和函数,用于替代泛型参数。单态化处理的方式称作 static dispatch(静态分派、静态调用),这时编译器在编译时就知道调用的具体方法时。与之相对的是 dynamic dispatch(动态分派、动态调用),这时编译器在编译时无法知道调用的具体方法,此时,编译器就会生成在运行时确定调用哪个具体方法的代码。
当使用 trait 对象时,Rust 只能使用动态分派。编译器无法在编译时就知道所有可能用于 trait 对象参数的类型,它也不知道应该调用哪个类型的哪个方法实现。因此,Rust 在运行时,通过 trait 对象中的指针来获得需要调用的具体方法。动态分派会阻止编译器对方法进行内联(inline a method’s code),这会导致一些优化被禁用。通过 trait 对象我们获得了灵活性,但是这并不是没有代价的,在实际使用的时候,需要我们根据实际场景进行取舍。
trait 对象需要是对象安全的
只有对象安全(object-safe)的 trait 才可以成为 trait 对象。关于 trait 对象的安全有一系列复杂的规则,不过在实践中,只有两条规则比较有价值。如果一个 trait 中定义的所有方法都满足以下两个规则,就认为该 trait 是对象安全的:
返回值类型不是 Self(Self 关键字是实现某个 trait 的类型的别名)
没有使用泛型参数
对象安全对于 trait 对象是必须的,因为一旦使用 trait 对象,Rust 就不再知道实现了该 trait 的具体类型是什么。这时候,如果方法返回的是 Self 类型,也就是实现了该 trait 的具体类型。这就发生了矛盾,因为 Rust 已经忘记了实现该 trait 的具体类型。对于泛型参数来说也是这样,当我们使用泛型参数时,泛型参数会被替换为具体的类型,并作为实现了该 trait 的类型的一部分;但是,当作为 trait 对象使用时,实现了该 trait 的具体类型已经被忘记,也就无法知道被替换的泛型参数的具体类型。
作为 trait 的方法不是对象安全的例子,我们来看看标准库中的 Clone trait。Clone trait 的 clone 方法定义看起来类似下面这样:
String 类型实现了 Clone trait,当我们调用一个 String 实例的 clone 方法时会得到一个 String 类型实例。类似的,当调用一个 Vec<T> 类型的实例的 clone 方法会得到一个 Vec<T> 类型的实例。clone 方法需要知道什么类型会代替 Self,因为这是它的返回值。
如果我们尝试违反对象安全的规则,编译器会提示我们错误。参考下面的例子:
在上面的例子中,我们尝试将 Screen 结构体中 components 列表包含的元素类型改为 Box<dyn Clone>,即实现了 clone trait 的类型。尝试进行编译的话,我们将得到类似下面的错误:
如我们所预期的,不能使用 Clone trait 作为 trait 对象。如果希望进一步了解对象安全方面的细节,可以查看官方文档 Rust RFC 255。
版权声明: 本文为 InfoQ 作者【山】的原创文章。
原文链接:【http://xie.infoq.cn/article/5b41955f5565207f89b1545f9】。文章转载请联系作者。
评论