Rust 学习笔记:trait 对象
Rust 学习笔记:trait 对象
- Rust 学习笔记:trait 对象
- 定义共同行为的特征
- 实现 trait
- trait 对象执行动态分派
Rust 学习笔记:trait 对象
假设我们创建一个名为 gui 的库 crate。这个 crate 可能包括一些供人们使用的类型,比如 Button 或 TextField。此外,gui 用户希望创建自己的可绘制类型:例如,一个程序员可能会添加 Image,而另一个程序员可能会添加 SelectBox。在编写库时,我们不可能知道并定义其他程序员可能想要创建的所有类型。但是我们知道 gui 需要跟踪许多不同类型的值,它需要对这些不同类型的值调用一个 draw 方法。
要在具有继承的语言中做到这一点,我们可以定义一个名为 Component 的类,并在其上有一个名为 draw 的方法。其他类,如 Button、Image 和 SelectBox,将从 Component 继承,从而继承 draw 方法。它们都可以重写 draw 方法来定义它们的自定义行为,但是框架可以将所有类型视为组件实例并对它们调用 draw。
因为 Rust 没有继承,我们需要另一种方式来构建 gui 库,以允许用户使用新类型扩展它。
定义共同行为的特征
Rust 提供泛型来支持抽象编程,但泛型要求类型在编译期可知。
trait 对象是另一种抽象方式:在运行时支持不同类型的值,前提是它们实现了某种 trait。
我们可以使用 trait 对象来代替泛型或具体类型。无论我们在哪里使用 trait 对象,Rust 的类型系统都会在编译时确保在该上下文中使用的任何值都将实现 trait 对象的 trait。因此,我们不需要在编译时知道所有可能的类型。
在 Rust 中,我们避免将结构体和枚举称为“对象”,以区别于其他语言的对象。在结构体或枚举中,结构体字段中的数据和 impl 块中的行为是分开的,而在其他语言中,组合成一个概念的数据和行为通常被标记为对象。
trait 对象更像其他语言中的对象,因为它们结合了数据和行为。但 trait 对象与传统对象的不同之处在于,我们不能向 trait 对象添加数据。trait 对象不像其他语言中的对象那样普遍有用,它们主要用于在通用行为上实现抽象。
通过指定某种指针(如 & 或 Box<T>),然后指定 dyn 关键字,然后指定相关的 trait,可以创建 trait 对象。
为了实现我们希望 gui 拥有的行为,我们将定义一个 Draw trait,它将有一个名为 draw 的方法。然后我们可以定义一个接受 trait 对象的 Vec。trait 对象既指向实现指定 trait 的类型的实例,也指向用于在运行时查找该类型的 trait 方法的表。
Draw trait 的定义:
pub trait Draw {fn draw(&self);
}
定义一个名为 Screen 的结构体,它包含一个名为 components 的 Vec。这个 Vec 的类型是 Box<dyn draw>,它是一个 trait 对象,是实现 Draw trait 的 Box 内任何类型的替代品。
pub struct Screen {pub components: Vec<Box<dyn Draw>>,
}
在 Screen 结构体中,我们将定义一个名为 run 的方法,该方法将对它的每个组件调用 draw 方法.
impl Screen {pub fn run(&self) {for component in self.components.iter() {component.draw();}}
}
这与定义使用带有 trait 约束的泛型类型参数的结构体不同。泛型类型参数一次只能被一个具体类型替代,而 trait 对象允许在运行时为 trait 对象填充多个具体类型。
例如,我们可以使用泛型类型和 trait 约束来定义 Screen 结构体:
pub struct Screen<T: Draw> {pub components: Vec<T>,
}impl<T> Screen<T>
whereT: Draw,
{pub fn run(&self) {for component in self.components.iter() {component.draw();}}
}
这将我们限制在一个 Screen 实例中,该实例具有所有类型为 Button 或所有类型为 TextField 的组件列表。如果只使用同构集合,那么使用泛型和 trait 约束是可取的,因为在编译时定义将是单态的,以便使用具体类型。
另一方面,使用 trait 对象的方法,一个 Screen 实例可以保存 Vec<T>,其中包含 Box<Button> 和 Box<TextField>。让我们看看这是如何工作的,然后我们将讨论运行时性能的含义。
实现 trait
现在我们将添加一些实现 Draw trait 的类型。例如 Button 类型,想象一下实现的样子,一个 Button 结构体可能有 width, height 和 label 字段。
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}
}
Button 上的宽度、高度和标签字段将不同于其他组件上的字段。
我们想要在屏幕上绘制的每种类型都将实现 Draw trait ,但将在 draw 方法中使用不同的代码来定义如何绘制该特定类型。
例如,在其他库上实现一个具有宽度、高度和选项字段的 SelectBox 结构体,也在 SelectBox 类型上实现 Draw trait。
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}
}
库的用户现在可以编写他们的 main 函数来创建 Screen 实例。对于 Screen 实例,添加一个 SelectBox 和一个 Button,方法是将它们分别放入 Box<T > 中,从而成为一个 trait 对象。然后,它们可以调用 Screen 实例上的 run 方法,该方法将在每个组件上调用 draw。
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();
}
run 不需要知道每个组件的具体类型是什么。它不会检查组件是什么类型的实例,它只是调用组件上的 draw 方法。通过指定 Box<dyn Draw> 作为组件 Vec 中值的类型,我们已经定义了 Screen 需要的值,我们可以在这些值上调用 draw 方法。
如果传入了没有实现 Draw trait 的类型(例如 String 类型),会得到编译错误。
use gui::Screen;fn main() {let screen = Screen {components: vec![Box::new(String::from("Hi"))],};screen.run();
}
error[E0277]: the trait bound `String: Draw` is not satisfied
trait 对象执行动态分派
回忆一下编译器对泛型执行的单态过程:编译器为我们用来代替泛型类型参数的每个具体类型生成函数和方法的非泛型实现。由单态生成的代码正在执行静态分派,即编译器在编译时知道你正在调用哪个方法。这与动态分派相反,动态分派是指编译器在编译时无法判断你调用的是哪个方法。在动态分派的情况下,编译器发出的代码将在运行时确定要调用哪个方法。
当我们使用 trait 对象时,Rust 使用动态分派。编译器不知道使用 trait 对象的代码可能使用的所有类型,因此它不知道调用哪个类型上实现的哪个方法。相反,在运行时,Rust 使用 trait 对象内部的指针来知道要调用哪个方法。这种查找会产生静态分派所没有的运行时成本。动态分派还阻止编译器选择内联方法的代码,这反过来又阻止了一些优化。
Rust 有一些规则,称为 dyn 兼容性,关于在哪里可以和不可以使用动态分派。参考:https://doc.rust-lang.org/reference/items/traits.html#dyn-compatibility。
对比:
特性 | 泛型 | trait 对象 |
---|---|---|
派发方式 | 静态派发(编译期) | 动态派发(运行时) |
性能 | 更高(有内联优化) | 略低(有运行时开销) |
类型统一要求 | 只能是一种类型 | 可以是多种类型 |
用于异构集合 | 适合 | 不适合 |