Rust 的面向对象编程特征 - 为使用不同类型的值而设计的 trait 对象

优质
小牛编辑
126浏览
2023-12-01

在第八章中,我们谈到了 vector 只能存储同种类型元素的局限。示例 8-10 中提供了一个定义 枚举来储存整型,浮点型和文本成员的替代方案。这意味着可以在每个单元中储存不同类型的数据,并仍能拥有一个代表一排单元的 vector。这在当编译代码时就知道希望可以交替使用的类型为固定集合的情况下是完全可行的。

然而有时,我们希望库用户在特定情况下能够扩展有效的类型集合。为了展示如何实现这一点,这里将创建一个图形用户接口(Graphical User Interface, GUI)工具的例子,其它通过遍历列表并调用每一个项目的 draw 方法来将其绘制到屏幕上;此乃一个 GUI 工具的常见技术。我们将要创建一个叫做 rust_gui 的库 crate,它含一个 GUI 库的结构。这个 GUI 库包含一些可供开发者使用的类型,比如 ButtonTextField。在此之上,rust_gui 的用户希望创建自定义的可以绘制于屏幕上的类型:比如,一个程序员可能会增加 Image,另一个可能会增加 SelectBox

这个例子中并不会实现一个功能完善的 GUI 库,不过会展示其中各个部分是如何结合在一起的。编写库的时候,我们不可能知晓并定义所有其他程序员希望创建的类型。我们所知晓的是 rust_gui 需要记录一系列不同类型的值,并需要能够对其中每一个值调用 draw 方法。这里无需知道调用 draw 方法时具体会发生什么,只需提供可供这些值调用的方法即可。

在拥有继承的语言中,可以定义一个名为 Component 的类,该类上有一个 draw 方法。其他的类比如 ButtonImageSelectBox 会从 Component 派生并因此继承 draw 方法。它们各自都可以覆盖 draw 方法来定义自己的行为,但是框架会把所有这些类型当作是 Component 的实例,并在其上调用 draw。不过 Rust 并没有继承,我们得另寻出路。

为了实现 rust_gui 所期望拥有的行为,定义一个 Draw trait,其包含名为 draw 的方法。接着可以定义一个存放 trait 对象trait object) 的 vector。trait 对象指向一个实现了我们指定 trait 的类型实例。我们通过指定某些指针,比如 & 引用或 Box<T> 智能指针,接着指定相关的 trait(第十九章动态大小类型部分会介绍 trait 对象必须使用指针的原因)。我们可以使用 trait 对象代替泛型或具体类型。任何使用 trait 对象的位置,Rust 的类型系统会在编译时确保任何在此上下文中使用的值会实现其 trait 对象的 trait。如此便无需在编译时就知晓所有可能的类型。

之前提到过,Rust 刻意不将结构体与枚举称为 “对象”,以便与其他语言中的对象相区别。在结构体或枚举中,结构体字段中的数据和 impl 块中的行为是分开的,不同于其他语言中将数据和行为组合进一个称为对象的概念中。trait 对象将数据和行为两者相结合,从这种意义上说 其更类似其他语言中的对象。不过 trait 对象不同于传统的对象,因为不能向 trait 对象增加数据。trait 对象并不像其他语言中的对象那么通用:其(trait 对象)具体的作用是允许对通用行为的抽象。

示例 17-3 展示了如何定义一个带有 draw 方法的 trait Draw

文件名: src/lib.rs

示例 17-3:Draw trait 的定义

因为第十章已经讨论过如何定义 trait,这看起来应该比较眼熟。接下来就是新内容了:实例 17-4 定义了一个存放了名叫 components 的 vector 的结构体 Screen。这个 vector 的类型是 Box<Draw>,此为一个 trait 对象:它是 Box 中任何实现了 Draw trait 的类型的替身。

文件名: src/lib.rs

  1. # pub trait Draw {
  2. # fn draw(&self);
  3. # }
  4. #
  5. pub struct Screen {
  6. pub components: Vec<Box<Draw>>,
  7. }

示例 17-4: 一个 Screen 结构体的定义,它带有一个字段components,其包含实现了 Draw trait 的 trait 对象的 vector

Screen 结构体上,我们将定义一个 run 方法,该方法会对其 components 上的每一个组件调用 draw 方法,如示例 17-5 所示:

文件名: src/lib.rs

  1. # pub trait Draw {
  2. # fn draw(&self);
  3. # }
  4. #
  5. # pub struct Screen {
  6. # pub components: Vec<Box<Draw>>,
  7. # }
  8. #
  9. impl Screen {
  10. pub fn run(&self) {
  11. for component in self.components.iter() {
  12. component.draw();
  13. }
  14. }
  15. }

示例 17-5:在 Screen 上实现一个 run 方法,该方法在每个 component 上调用 draw 方法

文件名: src/lib.rs

  1. # pub trait Draw {
  2. # fn draw(&self);
  3. # }
  4. #
  5. pub struct Screen<T: Draw> {
  6. pub components: Vec<T>,
  7. }
  8. impl<T> Screen<T>
  9. pub fn run(&self) {
  10. for component in self.components.iter() {
  11. component.draw();
  12. }
  13. }
  14. }

示例 17-6: 一种 Screen 结构体的替代实现,其 run 方法使用泛型和 trait bound

这限制了 Screen 实例必须拥有一个全是 Button 类型或者全是 TextField 类型的组件列表。如果只需要同质(相同类型)集合,则倾向于使用泛型和 trait bound,因为其定义会在编译时采用具体类型进行单态化。

另一方面,通过使用 trait 对象的方法,一个 Screen 实例可以存放一个既能包含 Box<Button>,也能包含 Box<TextField>Vec。让我们看看它是如何工作的,接着会讲到其运行时性能影响。

现在来增加一些实现了 Draw trait 的类型。我们将提供 Button 类型。再一次重申,真正实现 GUI 库超出了本书的范畴,所以 draw 方法体中不会有任何有意义的实现。为了想象一下这个实现看起来像什么,一个 Button 结构体可能会拥有 widthheightlabel 字段,如示例 17-7 所示:

文件名: src/lib.rs

示例 17-7: 一个实现了 Draw trait 的 Button 结构体

Button 上的 widthheightlabel 字段会和其他组件不同,比如 TextField 可能有 widthheightlabel 以及 placeholder 字段。每一个我们希望能在屏幕上绘制的类型都会使用不同的代码来实现 Draw trait 的 draw 方法来定义如何绘制特定的类型,像这里的 Button 类型(并不包含任何实际的 GUI 代码,这超出了本章的范畴)。除了实现 Draw trait 之外,比如 Button 还可能有另一个包含按钮点击如何响应的方法的 impl 块。这类方法并不适用于像 TextField 这样的类型。

一些库的使用者决定实现一个包含 widthheightoptions 字段的结构体 SelectBox。并也为其实现了 Draw trait,如示例 17-8 所示:

文件名: src/main.rs

  1. extern crate rust_gui;
  2. use rust_gui::Draw;
  3. struct SelectBox {
  4. width: u32,
  5. height: u32,
  6. options: Vec<String>,
  7. }
  8. impl Draw for SelectBox {
  9. fn draw(&self) {
  10. // Code to actually draw a select box
  11. }
  12. }

示例 17-8: 另一个使用 rust_gui 的 crate 中,在 SelectBox 结构体上实现 Draw trait

库使用者现在可以在他们的 main 函数中创建一个 Screen 实例。至此可以通过将 SelectBoxButton 放入 Box<T> 转变为 trait 对象来增加组件。接着可以调用 Screenrun 方法,它会调用每个组件的 draw 方法。示例 17-9 展示了这个实现:

文件名: src/main.rs

  1. use rust_gui::{Screen, Button};
  2. fn main() {
  3. let screen = Screen {
  4. components: vec![
  5. width: 75,
  6. height: 10,
  7. options: vec![
  8. String::from("Yes"),
  9. String::from("Maybe"),
  10. String::from("No")
  11. ],
  12. }),
  13. Box::new(Button {
  14. height: 10,
  15. label: String::from("OK"),
  16. }),
  17. ],
  18. };
  19. screen.run();
  20. }

示例 17-9: 使用 trait 对象来存储实现了相同 trait 的不同类型的值

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

使用 trait 对象和 Rust 类型系统来进行类似鸭子类型操作的优势是无需在运行时检查一个值是否实现了特定方法或者担心在调用时因为值没有实现方法而产生错误。如果值没有实现 trait 对象所需的 trait 则 Rust 不会编译这些代码。

例如,示例 17-10 展示了当创建一个使用 String 做为其组件的 Screen 时发生的情况:

文件名: src/main.rs

  1. extern crate rust_gui;
  2. use rust_gui::Screen;
  3. fn main() {
  4. let screen = Screen {
  5. components: vec![
  6. Box::new(String::from("Hi")),
  7. ],
  8. };
  9. screen.run();
  10. }

示例 17-10: 尝试使用一种没有实现 trait 对象的 trait 的类型

我们会遇到这个错误,因为 String 没有实现 rust_gui::Draw trait:

这告诉了我们,要么是我们传递了并不希望传递给 Screen 的类型并应该提供其他类型,要么应该在 String 上实现 Draw 以便 Screen 可以调用其上的 draw

回忆一下第十章讨论过的,当对泛型使用 trait bound 时编译器所进行单态化处理:编译器为每一个被泛型类型参数代替的具体类型生成了非泛型的函数和方法实现。单态化所产生的代码进行 静态分发static dispatch)。静态分发发生于编译器在编译时就知晓调用了什么方法的时候。这与 动态分发dynamic dispatch)相对,这时编译器在编译时无法知晓调用了什么方法。在这种情况下,编译器会生成在运行时确定调用了什么方法的代码。

当使用 trait 对象时,Rust 必须使用动态分发。编译器无法知晓所有可能用于 trait 对象代码的类型,所以它也不知道应该调用哪个类型的哪个方法实现。为此,Rust 在运行时使用 trait 对象中的指针来知晓需要调用哪个方法。动态分发也阻止编译器有选择的内联方法代码,这会相应的禁用一些优化。尽管在编写和支持代码的过程中确实获得了额外的灵活性,但仍然需要权衡取舍。

只有 对象安全object safe)的 trait 才可以组成 trait 对象。围绕所有使得 trait 对象安全的属性存在一些复杂的规则,不过在实践中,只涉及到两条规则。如果一个 trait 中所有的方法有如下属性时,则该 trait 是对象安全的:

  • 返回值类型不为 Self
  • 方法没有任何泛型类型参数

Self 关键字是我们要实现 trait 或方法的类型的别名。对象安全对于 trait 对象是必须的,因为一旦有了 trait 对象,就不再知晓实现该 trait 的具体类型是什么了。如果 trait 方法返回具体的 Self 类型,但是 trait 对象忘记了其真正的类型,那么方法不可能使用已经忘却的原始具体类型。同理对于泛型类型参数来说,当使用 trait 时其会放入具体的类型参数:此具体类型编程了实现改 trait 的类型的一部分。当使用 trait 对象时其具体类型被抹去了,故无从得知放入泛型参数类型的类型是什么。

一个 trait 的方法不是对象安全的例子是标准库中的 Clone trait。Clone trait 的 clone 方法的参数签名看起来像这样:

  1. pub trait Clone {
  2. fn clone(&self) -> Self;
  3. }

String 实现了 Clone trait,当在 String 实例上调用 clone 方法时会得到一个 String 实例。类似的,当调用 Vec 实例的 clone 方法会得到一个 Vec 实例。clone 的签名需要知道什么类型会代替 Self,因为这是它的返回值。

如果尝试做一些违反有关 trait 对象的对象安全规则的事情,编译器会提示你。例如,如果尝试实现示例 17-4 中的 Screen 结构体来存放实现了 Clone trait 而不是 Draw trait 的类型,像这样:

  1. pub struct Screen {
  2. pub components: Vec<Box<Clone>>,
  3. }

将会得到如下错误:

  1. error[E0038]: the trait `std::clone::Clone` cannot be made into an object
  2. -->
  3. |
  4. 2 | pub components: Vec<Box<Clone>>,
  5. | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `std::clone::Clone` cannot be
  6. made into an object

这意味着不能以这种方式使用此 trait 作为 trait 对象。如果你对对象安全的更多细节感兴趣,请查看 Rust RFC 255