为使用不同类型的值而设计的Trait对象
ch17-02-trait-objects.md
commit 872dc793f7017f815fb1e5389200fd208e12792d
在第8章,我们谈到了vector的局限是vectors只能存储同种类型的元素。我们在Listing 8-1有一个例子,其中定义了一个SpreadsheetCell
枚举类型,可以存储整形、浮点型和text,这样我们就可以在每个cell存储不同的数据类型了,同时还有一个代表一行cell的vector。当我们的代码编译的时候,如果交换地处理的各种东西是固定的类型是已知的,那么这是可行的。
有时,我们想我们使用的类型集合是可扩展的,可以被使用我们的库的程序员扩展。比如很多图形化接口工具有一个条目列表,从这个列表迭代和调用draw方法在每个条目上。我们将要创建一个库crate,包含称为rust_gui
的CUI库的结构体。我们的GUI库可以包含一些给开发者使用的类型,比如Button
或者TextField
。使用rust_gui
的程序员会创建更多可以在屏幕绘图的类型:一个程序员可能会增加Image
,另外一个可能会增加SelectBox
。我们不会在本章节实现一个完善的GUI库,但是我们会展示如何把各部分组合在一起。
当要写一个rust_gui
库时,我们不知道其他程序员要创建什么类型,所以我们无法定义一个enum
来包含所有的类型。我们知道的是rust_gui
需要有能力跟踪所有这些不同类型的大量的值,需要有能力在每个值上调用draw
方法。我们的GUI库不需要确切地知道当调用draw
方法时会发生什么,只要值有可用的方法供我们调用就可以。
在有继承的语言里,我们可能会定义一个名为Component
的类,该类上有一个draw
方法。其他的类比如Button
、Image
和SelectBox
会从Component
继承并继承draw
方法。它们会各自覆写draw
方法来自定义行为,但是框架会把所有的类型当作是Component
的实例,并在它们上调用draw
。
定义一个带有自定义行为的Trait
不过,在Rust语言中,我们可以定义一个名为Draw
的trait,其上有一个名为draw
的方法。我们定义一个带有trait对象的vector,绑定了一种指针的trait,比如&
引用或者一个Box
智能指针。
我们提到,我们不会调用结构体和枚举的对象,从而区分于其他语言的对象。在结构体的数据或者枚举的字段和impl
块中的行为是分开的,而其他语言则是数据和行为被组合到一个概念里。Trait对象更像其他语言的对象,在这种场景下,他们组合了由指针组成的数据到实体对象,该对象带有在trait中定义的方法行为。但是,trait对象是和其他语言是不同的,因为我们不能向一个trait对象增加数据。trait对象不像其他语言那样有用:它们的目的是允许从公有的行为上抽象。
trait定义了在给定场景下我们所需要的行为。在我们会使用一个实体类型或者一个通用类型的地方,我们可以把trait当作trait对象使用。Rust的类型系统会保证我们为trait对象带入的任何值会实现trait的方法。我们不需要在编译阶段知道所有可能的类型,我们可以把所有的实例统一对待。Listing 17-03展示了如何定义一个名为Draw
的带有draw
方法的trait。
Filename: src/lib.rs
pub trait Draw {
fn draw(&self);
}
因为我们已经在第10章讨论过如何定义trait,你可能比较熟悉。下面是新的定义:Listing 17-4有一个名为Screen
的结构体,里面有一个名为components
的vector,components
的类型是BoxBox
是一个trait对象:它是一个任何Box
内部的实现了Draw
trait的类型的替身。
Filename: src/lib.rs
# pub trait Draw {
# fn draw(&self);
# }
#
pub struct Screen {
pub components: Vec>,
}
在Screen
结构体上,我们将要定义一个run
方法,该方法会在它的components
上调用draw
方法,如Listing 17-5所示:
Filename: src/lib.rs
# pub trait Draw {
# fn draw(&self);
# }
#
# pub struct Screen {
# pub components: Vec>,
# }
#
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
这是区别于定义一个使用带有trait绑定的通用类型参数的结构体。通用类型参数一次只能被一个实体类型替代,而trait对象可以在运行时允许多种实体类型填充trait对象。比如,我们已经定义了Screen
结构体使用通用类型和一个trait绑定,如Listing 17-6所示:
Filename: src/lib.rs
# pub trait Draw {
# fn draw(&self);
# }
#
pub struct Screen {
pub components: Vec,
}
impl Screen
where T: Draw {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
这个例子只能使我们有一个Screen
实例,这个实例有一个组件列表,所有的组件类型是Button
或者TextField
。如果你有同种的集合,那么可以优先使用通用和trait绑定,这是因为为了使用具体的类型,定义是在编译阶段是单一的。
而如果使用内部有Vec
trait对象的列表的Screen
结构体,Screen
实例可以同时包含Box
和Box
的Vec
。我们看它是怎么工作的,然后讨论运行时性能的实现。
来自我们或者库使用者的实现
现在,我们增加一些实现了Draw
trait的类型。我们会再次提供Button
,实际上实现一个GUI库超出了本书的范围,所以draw
方法的内部不会有任何有用的实现。为了想象一下实现可能的样子,Button
结构体可能有 width、
height和
label`字段,如Listing 17-7所示:
Filename: src/lib.rs
# pub trait Draw {
# fn draw(&self);
# }
#
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
上的 width
、height
和label
会和其他组件不同,比如TextField
可能有width
、height
,
label
和 placeholder
字段。每个我们可以在屏幕上绘制的类型会实现Draw
trait,在draw
方法中使用不同的代码,定义了如何绘制Button
(GUI代码的具体实现超出了本章节的范围)。除了Draw
trait,Button
可能也有另一个impl
块,包含了当按钮被点击的时候的响应方法。这类方法不适用于TextField
这样的类型。
有时,使用我们的库决定了实现一个包含width
、height
和options``SelectBox
结构体。它们在SelectBox
类型上实现了Draw
trait,如 Listing 17-8所示:
Filename: src/main.rs
extern crate rust_gui;
use rust_gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec,
}
impl Draw for SelectBox {
fn draw(&self) {
// Code to actually draw a select box
}
}
我们的库的使用者现在可以写他们的main
函数来创建一个Screen
实例,然后通过把自身放入Box
变成trait对象,向screen增加SelectBox
和Button
。它们可以在每个Screen
实例上调用run
方法,这会调用每个组件的draw
方法。 Listing 17-9展示了实现:
Filename: src/main.rs
use rust_gui::{Screen, Button};
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();
}
虽然我们不知道有些人可能有一天会增加SelectBox
类型,但是我们的Screen
有能力操作SelectBox
和绘制,因为SelectBox
实现了Draw
类型,这意味着它实现了draw
方法。
只关心值响应的消息,而不关心值的具体类型,这类似于动态类型语言中的duck typing:如果它像鸭子一样走路,像鸭子一样叫,那么它肯定是只鸭子!在Listing 17-5的Screen
的run
方法的实现中,run
不需要知道每个组件的具体类型。它也不检查是否一个组件是Button
或者SelectBox
的实例,只是调用组件的draw
方法即可。通过指定Box
作为components
vector中的值类型,我们定义了:Screen
需要可以被调用其draw
方法的值。
使用trait对象和支持duck typing的Rust类型系统的好处是,我们永远不需要在运行时检查一个值是否实现了一个特殊方法,或者担心因为调用了一个值没有实现方法而遇到错误。如果值没有实现trait对象需要的trait,Rust不会编译我们的代码。
比如,Listing 17-10展示了当我们创建一个把String
当做其成员的Screen
时发生的情况:
Filename: src/main.rs
extern crate rust_gui;
use rust_gui::Draw;
fn main() {
let screen = Screen {
components: vec![
Box::new(String::from("Hi")),
],
};
screen.run();
}
我们会遇到这个错误,因为String
没有实现 Draw
trait:
error[E0277]: the trait bound `std::string::String: Draw` is not satisfied
-->
|
4 | Box::new(String::from("Hi")),
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not
implemented for `std::string::String`
|
= note: required for the cast to the object type `Draw`
这个报错让我们知道,或者我们传入了本来不想传给Screen
的东西,我们应该传入一个不同的类型,或者是我们应该在String
上实现Draw
,这样,Screen
才能调用它的draw
方法。
Trait对象执行动态分发
回忆一下第10章,我们讨论过当我们使用通用类型的trait绑定时,编译器执行单类型的处理过程:在我们需要使用通用类型参数的地方,编译器为每个实体类型产生了非通用的函数实现和方法。由于非单类型而产生的代码是 static dispatch:当方法被调用,代码会执行在编译阶段就决定的方法,这样寻找那段代码是非常快速的。
当我们使用trait对象,编译器不能执行单类型的,因为我们不知道可能被代码调用的类型。而,当方法被调用的时候,Rust跟踪可能被使用的代码,然后在运行时找出为了方法被调用时该使用哪些代码。这也是我们熟知的dynamic dispatch,当运行时的查找发生时是比较耗费资源的。动态分发也防止编译器选择内联函数的代码,这样防止了一些优化。虽然我们写代码时得到了额外的代码灵活性,不过,这是一个权衡考虑。
https://github.com/rust-lang/book/blob/master/second-edition/src/ch17-02-trait-objects.md
https://github.com/itfanr/rust-book-2rd-en