為使用不同類型的值而設計的Trait對象
ch17-02-trait-objects.md
commit 872dc793f7017f815fb1e5389200fd208e12792d
在第8章,我們談到了vector的局限是vectors只能存儲同種類型的元素。我們在Listing 8-1有一個例子,其中定義了一個SpreadsheetCell
枚舉類型,可以存儲整形、浮點型和text,這樣我們就可以在每個cell存儲不同的數據類型了,同時還有一個代表一行cell的vector。當我們的代碼編譯的時候,如果交換地處理的各種東西是固定的類型是已知的,那么這是可行的。
<!-- The code example I want to reference did not have a listing number; it's
the one with SpreadsheetCell. I will go back and add Listing 8-1 next time I
get Chapter 8 for editing. /Carol -->
有時,我們想我們使用的類型集合是可擴展的,可以被使用我們的庫的程序員擴展。比如很多圖形化接口工具有一個條目列表,從這個列表迭代和調用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<T>
智能指針。
我們提到,我們不會調用結構體和枚舉的對象,從而區分于其他語言的對象。在結構體的數據或者枚舉的字段和impl
塊中的行為是分開的,而其他語言則是數據和行為被組合到一個概念里。Trait對象更像其他語言的對象,在這種場景下,他們組合了由指針組成的數據到實體對象,該對象帶有在trait中定義的方法行為。但是,trait對象是和其他語言是不同的,因為我們不能向一個trait對象增加數據。trait對象不像其他語言那樣有用:它們的目的是允許從公有的行為上抽象。
trait定義了在給定場景下我們所需要的行為。在我們會使用一個實體類型或者一個通用類型的地方,我們可以把trait當作trait對象使用。Rust的類型系統會保證我們為trait對象帶入的任何值會實現trait的方法。我們不需要在編譯階段知道所有可能的類型,我們可以把所有的實例統一對待。Listing 17-03展示了如何定義一個名為Draw
的帶有draw
方法的trait。
<span class="filename">Filename: src/lib.rs</span>
pub trait Draw {
fn draw(&self);
}
<span class="caption">Listing 17-3:Draw
trait的定義</span>
因為我們已經在第10章討論過如何定義trait,你可能比較熟悉。下面是新的定義:Listing 17-4有一個名為Screen
的結構體,里面有一個名為components
的vector,components
的類型是Box<Draw>。Box<Draw>
是一個trait對象:它是一個任何Box
內部的實現了Draw
trait的類型的替身。
<span class="filename">Filename: src/lib.rs</span>
# pub trait Draw {
# fn draw(&self);
# }
#
pub struct Screen {
pub components: Vec<Box<Draw>>,
}
<span class="caption">Listing 17-4: 定義一個Screen
結構體,帶有一個含有實現了Draw
trait的components
vector成員
</span>
在Screen
結構體上,我們將要定義一個run
方法,該方法會在它的components
上調用draw
方法,如Listing 17-5所示:
<span class="filename">Filename: src/lib.rs</span>
# pub trait Draw {
# fn draw(&self);
# }
#
# pub struct Screen {
# pub components: Vec<Box<Draw>>,
# }
#
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
<span class="caption">Listing 17-5:在Screen
上實現一個run
方法,該方法在每個組件上調用draw
方法
</span>
這是區別于定義一個使用帶有trait綁定的通用類型參數的結構體。通用類型參數一次只能被一個實體類型替代,而trait對象可以在運行時允許多種實體類型填充trait對象。比如,我們已經定義了Screen
結構體使用通用類型和一個trait綁定,如Listing 17-6所示:
<span class="filename">Filename: src/lib.rs</span>
# pub trait Draw {
# fn draw(&self);
# }
#
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}
impl<T> Screen<T>
where T: Draw {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
<span class="caption">Listing 17-6: 一種Screen
結構體的替代實現,它的run
方法使用通用類型和trait綁定
</span>
這個例子只能使我們有一個Screen
實例,這個實例有一個組件列表,所有的組件類型是Button
或者TextField
。如果你有同種的集合,那么可以優先使用通用和trait綁定,這是因為為了使用具體的類型,定義是在編譯階段是單一的。
而如果使用內部有Vec<Box<Draw>>
trait對象的列表的Screen
結構體,Screen
實例可以同時包含Box<Button>
和Box<TextField>
的Vec
。我們看它是怎么工作的,然后討論運行時性能的實現。
來自我們或者庫使用者的實現
現在,我們增加一些實現了Draw
trait的類型。我們會再次提供Button
,實際上實現一個GUI庫超出了本書的范圍,所以draw
方法的內部不會有任何有用的實現。為了想象一下實現可能的樣子,Button
結構體可能有 width、
height和
label`字段,如Listing 17-7所示:
<span class="filename">Filename: src/lib.rs</span>
# 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
}
}
<span class="caption">Listing 17-7: 實現了Draw
trait的Button
結構體</span>
在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所示:
<span class="filename">Filename: src/main.rs</span>
extern crate rust_gui;
use rust_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
}
}
<span class="caption">Listing 17-8: 另外一個crate中,在SelectBox
結構體上使用rust_gui
和實現了Draw
trait
</span>
我們的庫的使用者現在可以寫他們的main
函數來創建一個Screen
實例,然后通過把自身放入Box<T>
變成trait對象,向screen增加SelectBox
和Button
。它們可以在每個Screen
實例上調用run
方法,這會調用每個組件的draw
方法。 Listing 17-9展示了實現:
<span class="filename">Filename: src/main.rs</span>
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();
}
<span class="caption">Listing 17-9: 使用trait對象來存儲實現了相同trait的不同類型
</span>
雖然我們不知道有些人可能有一天會增加SelectBox
類型,但是我們的Screen
有能力操作SelectBox
和繪制,因為SelectBox
實現了Draw
類型,這意味著它實現了draw
方法。
只關心值響應的消息,而不關心值的具體類型,這類似于動態類型語言中的duck typing:如果它像鴨子一樣走路,像鴨子一樣叫,那么它肯定是只鴨子!在Listing 17-5的Screen
的run
方法的實現中,run
不需要知道每個組件的具體類型。它也不檢查是否一個組件是Button
或者SelectBox
的實例,只是調用組件的draw
方法即可。通過指定Box<Draw>
作為components
vector中的值類型,我們定義了:Screen
需要可以被調用其draw
方法的值。
使用trait對象和支持duck typing的Rust類型系統的好處是,我們永遠不需要在運行時檢查一個值是否實現了一個特殊方法,或者擔心因為調用了一個值沒有實現方法而遇到錯誤。如果值沒有實現trait對象需要的trait,Rust不會編譯我們的代碼。
比如,Listing 17-10展示了當我們創建一個把String
當做其成員的Screen
時發生的情況:
<span class="filename">Filename: src/main.rs</span>
extern crate rust_gui;
use rust_gui::Draw;
fn main() {
let screen = Screen {
components: vec![
Box::new(String::from("Hi")),
],
};
screen.run();
}
<span class="caption">Listing 17-10: 嘗試使用一種沒有實現trait對象的trait的類型
</span>
我們會遇到這個錯誤,因為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