使用允许不同类型值的特征对象
在第 8 章中,我们提到向量的一个限制是它们只能存储一种类型的元素。我们在列表 8-9 中创建了一个变通方法,定义了一个 SpreadsheetCell
枚举,该枚举具有存储整数、浮点数和文本的变体。这意味着我们可以在每个单元格中存储不同类型的數據,同时仍然有一个表示单元格行的向量。当我们的可互换项是在代码编译时已知的固定类型集时,这是一个非常好的解决方案。
然而,有时我们希望我们的库用户能够扩展在特定情况下有效的类型集。为了展示我们如何实现这一点,我们将创建一个示例图形用户界面(GUI)工具,该工具遍历项目列表,调用每个项目的draw
方法将其绘制到屏幕上——这是GUI工具的常见技术。我们将创建一个名为gui
的库crate,其中包含GUI库的结构。此crate可能包括一些供人们使用的类型,例如Button
或TextField
。此外,gui
用户将希望创建可以绘制的自定义类型:例如,一个程序员可能会添加一个Image
,而另一个可能会添加一个SelectBox
。
我们不会为这个例子实现一个完整的GUI库,但会展示这些部分是如何组合在一起的。在编写库时,我们无法知道和定义其他程序员可能想要创建的所有类型。但我们知道 gui
需要跟踪许多不同类型的值,并且需要在这些不同类型的值上调用 draw
方法。它不需要确切地知道当我们调用 draw
方法时会发生什么,只需要知道这些值会有该方法供我们调用。
为了在支持继承的语言中实现这一点,我们可能会定义一个名为 Component
的类,该类有一个名为 draw
的方法。其他类,如 Button
、Image
和 SelectBox
,将从 Component
继承,因此也会继承 draw
方法。它们各自可以重写 draw
方法以定义其自定义行为,但框架可以将所有类型视为 Component
实例并调用 draw
方法。但由于 Rust 没有继承,我们需要另一种方式来构建 gui
库,以允许用户使用新类型扩展它。
定义一个用于常见行为的特征
为了实现我们希望 gui
具有的行为,我们将定义一个名为 Draw
的特征,该特征将有一个名为 draw
的方法。然后我们可以定义一个接受 特征对象 的向量。特征对象指向实现我们指定特征的类型的实例和一个用于在运行时查找该类型特征方法的表。我们通过指定某种指针(如 &
引用或 Box<T>
智能指针),然后是 dyn
关键字,再指定相关的特征来创建特征对象。(我们将在第 20 章的 “动态大小类型和 Sized
特征” 部分讨论特征对象必须使用指针的原因。)我们可以在泛型或具体类型的地方使用特征对象。无论何时使用特征对象,Rust 的类型系统都会在编译时确保在该上下文中使用的任何值都实现了特征对象的特征。因此,我们不需要在编译时知道所有可能的类型。
我们提到,在 Rust 中,我们避免将结构体和枚举称为“对象”,以区别于其他语言的“对象”。在结构体或枚举中,结构体字段中的数据和 impl
块中的行为是分开的,而在其他语言中,数据和行为结合成一个概念通常被称为对象。然而,特质对象 确实 更像其他语言中的对象,因为它们结合了数据和行为。但特质对象与传统对象不同,我们不能向特质对象添加数据。特质对象不如其他语言中的对象那么通用:它们的特定目的是允许跨常见行为的抽象。
列表 18-3 显示了如何定义一个名为 Draw
的 trait,其中包含一个名为 draw
的方法:
这种语法应该让你想起我们在第 10 章讨论的如何定义特质的内容。接下来是一些新的语法:Screen
结构体定义在列表 18-4 中,它持有一个名为 components
的向量。这个向量的类型是 Box<dyn Draw>
,这是一个特质对象;它是 Box
内部实现 Draw
特质的任何类型的占位符。
在 Screen
结构体上,我们将定义一个名为 run
的方法,该方法将调用每个 components
的 draw
方法,如清单 18-5 所示:
这与定义使用带有特征约束的泛型类型参数的结构体不同。泛型类型参数一次只能被一个具体类型替代,而特征对象允许在运行时用多个具体类型填充特征对象。例如,我们可以使用泛型类型和特征约束来定义Screen
结构体,如清单18-6所示:
这将我们限制在一个 Screen
实例中,该实例包含一个所有类型为 Button
或所有类型为 TextField
的组件列表。如果你只会拥有同质集合,使用泛型和特质边界是更可取的,因为定义将在编译时被单态化以使用具体的类型。
另一方面,使用特质对象的方法,一个 Screen
实例 可以持有一个包含 Box
实现 Trait
现在我们将添加一些实现Draw
特性的类型。我们将提供Button
类型。同样,实际实现一个GUI库超出了本书的范围,因此draw
方法在其主体中不会有任何有用的实现。想象一下实现可能的样子,一个Button
结构体可能有width
、height
和label
字段,如清单18-7所示:
width
、height
和 label
字段在 Button
上会与其他组件的字段不同;例如,TextField
类型可能有相同的字段,外加一个 placeholder
字段。我们希望在屏幕上绘制的每种类型都会实现 Draw
特性,但在 draw
方法中会使用不同的代码来定义如何绘制该特定类型,就像 Button
在这里所做的那样(不包括实际的 GUI 代码,如前所述)。例如,Button
类型可能有一个额外的 impl
块,包含与用户点击按钮时发生的情况相关的方法。这类方法不适用于 TextField
等类型。
如果有人使用我们的库决定实现一个 SelectBox
结构体,该结构体具有 width
、height
和 options
字段,他们还需要在 SelectBox
类型上实现 Draw
特性,如清单 18-8 所示:
我们的库用户现在可以编写他们的 main
函数来创建一个 Screen
实例。向 Screen
实例中,他们可以通过将每个组件放入 Box<T>
中来添加一个 SelectBox
和一个 Button
,从而成为特征对象。然后,他们可以在 Screen
实例上调用 run
方法,该方法将调用每个组件的 draw
方法。清单 18-9 显示了此实现:
当我们编写库时,我们并不知道有人可能会添加SelectBox
类型,但我们的Screen
实现能够操作新类型并绘制它,因为SelectBox
实现了Draw
trait,这意味着它实现了draw
方法。
这个概念——只关心值响应的消息而不是值的具体类型——类似于动态类型语言中的鸭子类型概念:如果它像鸭子一样走路,像鸭子一样叫,那么它一定是鸭子!在清单 18-5 中 Screen
上的 run
实现中,run
不需要知道每个组件的具体类型。它不会检查组件是否是 Button
或 SelectBox
的实例,它只是调用组件上的 draw
方法。通过将 components
向量中的值类型指定为 Box<dyn Draw>
,我们定义了 Screen
需要我们可以调用 draw
方法的值。
使用 trait 对象和 Rust 的类型系统编写类似于使用鸭子类型编写的代码的优势在于,我们永远不必在运行时检查某个值是否实现了特定方法,也不必担心如果某个值没有实现某个方法但仍然调用它时会出错。如果值没有实现 trait 对象所需的 trait,Rust 不会编译我们的代码。
例如,清单 18-10 显示了如果我们尝试使用 String
作为组件创建 Screen
会发生什么:
我们将因为 String
没有实现 Draw
特性而收到此错误:
$ 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
上实现Draw
,以便Screen
能够调用其draw
方法。
Trait 对象执行动态分发
回忆在第 10 章 “使用泛型的代码性能” 部分我们关于编译器在我们对泛型使用特质边界时执行的单态化过程的讨论:编译器为每个我们用作泛型类型参数的具体类型生成非泛型的函数和方法实现。单态化生成的代码执行的是 静态分发,即编译器在编译时就知道你调用的是哪个方法。这与 动态分发 相对,即编译器在编译时无法确定你调用的是哪个方法。在动态分发的情况下,编译器会生成在运行时确定调用哪个方法的代码。
当我们使用特质对象时,Rust 必须使用动态分发。编译器不知道所有可能与使用特质对象的代码一起使用的类型,因此它不知道要调用哪个类型上实现的方法。相反,在运行时,Rust 使用特质对象内部的指针来确定要调用的方法。这种查找会产生运行时成本,而静态分发则不会发生这种情况。动态分发还阻止编译器选择内联方法的代码,这反过来又阻止了一些优化。然而,我们在示例 18-5 中编写的代码确实获得了额外的灵活性,并且能够在示例 18-9 中支持,所以这是一个需要权衡的问题。