Rust并没有继承,因为在 Rust 设计目标中,零成本抽象是非常重要的一条,它让 Rust 具备高级语言表达能力的同时,又不会带来性能损耗。因此我们通过其它方法来实现类似继承的功能。为了展示如何实现这一点,这里将创建一个图形用户接口(Graphical User Interface)工具的例子,它通过遍历列表并调用每一个项目的draw方法来将其绘制到屏幕上--此是一个gui工具的常见技术。我们所知晓的是gui需要记录一系列不同类型的值,并需要能够对其中每一个值调用draw方法。这里无需知道调用draw方法时具体会发生什么,只要该值会有那个方法可供我们调用。
在拥有继承的语言中,要以定义一个名为Component的类,该类上有一个draw方法。其他的类比如Button、Image会从Component 派生并因此继承draw方法。它们各自都可以覆盖draw方法来定义自已的行为,但是框架会把所有这些类型当作是Component的实例,并在其上调用draw。
定义通用行为的trait
trait的主要作要是用来抽象行为,类似于其它编程序语言中的接口。
为了实现gui所期望的行为,让我们定义一个Draw trait,其中包含名为draw的方法。接着可以定义一个存放trait对象(trait object)的vector。trait对象指向一个实现了我们指定trait的类型的实例,以及一个用于在运行时查代该类型的trait方法的表。我们通过指定某种指针来创建trait对象,例如 & 引用或 Box<T> 智能指针,珲有dyn keyword,以及指定相关的trait。我们可以使用trait对象代替泛型或具体类型。任何使用trait对象的位置,Rust的类型系统会在编译时确保任何在此上下文中使用的值会实现其trait对象的trait。如此便无需在编译时就知晓所有可能的类型。
Rust刻意不将结构体与枚举称为“对象”,以便与其它语言中的对象相区别。在结构体或枚举中,结构体字段中的数据和impl块中的行为是分开的,不同于其它语言中将数据和行为组合进一个称为对象的概念中。trait对象将数据和行为两者相结合,从这种意义上说则其更类似其它语言中的对象。不过trait对象不同于传统的对象,因为不能向trait对象增加数据。trait对象并不像其它语言中的对象那么通用:其(trait对象)具体的作用是允许对通用行为进行抽象。
示例1定义了一个带有draw方法的trait Draw:
pub trait Draw{ fn draw(&self); }
示例2定义了一个存放了名叫components的vector的结构体Screen。这个vector的类型是 Box<dyn Draw>,此为一个trait对象:它是Box中任何实现了Draw trait的类型的替身。
pub struct Screen{ pub components: Vec<Box<dyn Draw>>, }
在Screen结构体上,我们将定义一个run方法,该方法会对其components上的每一个组件调用draw方法,如示例3所示:
impl Screen { pub fn run(&self){ for component in self.components.iter(){ component.draw(); } } }
这与定义使用了带有trait bound的泛型类型参数的结构体不同。泛型类型参数一次只能替代一个具体类型,而trait对象则允许在运行时替代多种具体类型。例如,可以定义Screen结构体来使用泛型和trait bound,如示例4所示:
impl<T> Screen<T> where T: Draw { pub fn run(&self) { for component in self.components.iter() { component.draw(); } } }
这限制了Screen实例必须拥有一个全是Button类型或者全是TextField类型的组件列表。如果只需要同质(相同类型)集合,则多数使用泛型和trait bound,因为其定义会在编译时采用具体类型进行单态化。
另一方面,通过使用trait对象的方法,一个Screen实例可以存放一个既能包含Box<Button>,也能包含 Box<TextField> 的 Vec<T>。
实现trait
现在来增加一些实现了Draw trait的类型。我们将提供Button类型。本例的draw方法体中不会有任何有意义的实现。为了想象一下这个实现看起来像什么,一个Button结构体可能会有 width, height 和 lable字段,如示例5所示:
pub struct Button { pub i32, pub height: i32, pub label: String, } impl Draw for Button { fn draw(&self) { } }
示例5: 实现了Draw trait的Button结构体
在Button上的width, height和label字段会和其它组件不同,比如TextField可能有width, height, label以及placeholder字段。每一个我们希望能在屏幕上绘制的类型都会使用不同的代码来实现Draw trait的draw方法来定义如何绘制特定的类型,像这里的Button类型。除了实现Draw trait之外,比如Button还可能有另一个包含按钮点击如何响应的方法的impl块。这类方法并不适用于像TextField这样的类型。
如果一些库的使用者决定实现一个包含width, height和options字段的结构体SelectBox,并且也为其实现了Draw trait,如示例6:
文件名: src/main.rs
struct SelectBox{ i32, height:i32, options:Vec<String>, } impl Draw for SelectBox { fn draw(&self) { } }
库使用者现在可以在他们的main函数中创建一个Screen实例。至此可以通过将SelectBox和Button放入Box<T>转变为trait对象来增加组件。接着可以调用Screen的run方法,它会调用每个组件的draw方法。示例7展示了这个实现:
let screen = Screen { components: vec![ Box::new(SelectBox { 75, height: 10, options: vec![ String::from("yes"), String::from("Maybe"), String::from("no") ], }), Box::new(Button { 50, height: 10, label: String::from("ok"), }), ], }; screen.run();
当编写库的时候,我们不知道何人会在何时增加SelectBox类型,不过Screen的实现能够操作并绘制这个新类型,因为SelectBox实现了Draw trait,这意味着它实现了draw方法。
使用trait对象和Rust类型系统来进行类似鸭子类型操作的优势是无需在运行时检查一个值是否实现了特定方法或者担心在调用时因为值没有实现方法而产生错误。如果值没有实现trait对象所需的trait则Rust不会编译这些代码。
如下示例8展示了一个使用String做为其组件的Screen时发生的情况:
let screen = Screen { components:vec![Box::new(String::from("hihi"))], }; screen.run();
我们会遇到这个错误,因为String没有实现 Draw trait:
error[E0277]: the trait bound `String: Draw` is not satisfied --> src/main.rs:89:25 | 89 | components:vec![Box::new(String::from("hihi"))], | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String` | = note: required for the cast to the object type `dyn Draw`