ch17-02-trait-objects.md
commit 727ef100a569d9aa0b9da3a498a346917fadc979
在第八章中,我們談到了 vector 只能存儲同種類型元素的局限。示例 8-10 中提供了一個定義 SpreadsheetCell
枚舉來儲存整型,浮點型和文本成員的替代方案。這意味著可以在每個單元中儲存不同類型的數(shù)據(jù),并仍能擁有一個代表一排單元的 vector。這在當編譯代碼時就知道希望可以交替使用的類型為固定集合的情況下是完全可行的。
然而有時我們希望庫用戶在特定情況下能夠擴展有效的類型集合。為了展示如何實現(xiàn)這一點,這里將創(chuàng)建一個圖形用戶接口(Graphical User Interface, GUI)工具的例子,它通過遍歷列表并調(diào)用每一個項目的 draw
方法來將其繪制到屏幕上 —— 此乃一個 GUI 工具的常見技術(shù)。我們將要創(chuàng)建一個叫做 gui
的庫 crate,它含一個 GUI 庫的結(jié)構(gòu)。這個 GUI 庫包含一些可供開發(fā)者使用的類型,比如 Button
或 TextField
。在此之上,gui
的用戶希望創(chuàng)建自定義的可以繪制于屏幕上的類型:比如,一個程序員可能會增加 Image
,另一個可能會增加 SelectBox
。
這個例子中并不會實現(xiàn)一個功能完善的 GUI 庫,不過會展示其中各個部分是如何結(jié)合在一起的。編寫庫的時候,我們不可能知曉并定義所有其他程序員希望創(chuàng)建的類型。我們所知曉的是 gui
需要記錄一系列不同類型的值,并需要能夠?qū)ζ渲忻恳粋€值調(diào)用 draw
方法。這里無需知道調(diào)用 draw
方法時具體會發(fā)生什么,只要該值會有那個方法可供我們調(diào)用。
在擁有繼承的語言中,可以定義一個名為 Component
的類,該類上有一個 draw
方法。其他的類比如 Button
、Image
和 SelectBox
會從 Component
派生并因此繼承 draw
方法。它們各自都可以覆蓋 draw
方法來定義自己的行為,但是框架會把所有這些類型當作是 Component
的實例,并在其上調(diào)用 draw
。不過 Rust 并沒有繼承,我們得另尋出路。
為了實現(xiàn) gui
所期望的行為,讓我們定義一個 Draw
trait,其中包含名為 draw
的方法。接著可以定義一個存放 trait 對象(trait object) 的 vector。trait 對象指向一個實現(xiàn)了我們指定 trait 的類型的實例,以及一個用于在運行時查找該類型的trait方法的表。我們通過指定某種指針來創(chuàng)建 trait 對象,例如 &
引用或 Box<T>
智能指針,還有 dyn
keyword, 以及指定相關(guān)的 trait(第十九章 “動態(tài)大小類型和 Sized trait” 部分會介紹 trait 對象必須使用指針的原因)。我們可以使用 trait 對象代替泛型或具體類型。任何使用 trait 對象的位置,Rust 的類型系統(tǒng)會在編譯時確保任何在此上下文中使用的值會實現(xiàn)其 trait 對象的 trait。如此便無需在編譯時就知曉所有可能的類型。
之前提到過,Rust 刻意不將結(jié)構(gòu)體與枚舉稱為 “對象”,以便與其他語言中的對象相區(qū)別。在結(jié)構(gòu)體或枚舉中,結(jié)構(gòu)體字段中的數(shù)據(jù)和 impl
塊中的行為是分開的,不同于其他語言中將數(shù)據(jù)和行為組合進一個稱為對象的概念中。trait 對象將數(shù)據(jù)和行為兩者相結(jié)合,從這種意義上說 則 其更類似其他語言中的對象。不過 trait 對象不同于傳統(tǒng)的對象,因為不能向 trait 對象增加數(shù)據(jù)。trait 對象并不像其他語言中的對象那么通用:其(trait 對象)具體的作用是允許對通用行為進行抽象。
示例 17-3 展示了如何定義一個帶有 draw
方法的 trait Draw
:
文件名: src/lib.rs
pub trait Draw {
fn draw(&self);
}
示例 17-3:Draw
trait 的定義
因為第十章已經(jīng)討論過如何定義 trait,其語法看起來應(yīng)該比較眼熟。接下來就是新內(nèi)容了:示例 17-4 定義了一個存放了名叫 components
的 vector 的結(jié)構(gòu)體 Screen
。這個 vector 的類型是 Box<dyn Draw>
,此為一個 trait 對象:它是 Box
中任何實現(xiàn)了 Draw
trait 的類型的替身。
文件名: src/lib.rs
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
示例 17-4: 一個 Screen
結(jié)構(gòu)體的定義,它帶有一個字段 components
,其包含實現(xiàn)了 Draw
trait 的 trait 對象的 vector
在 Screen
結(jié)構(gòu)體上,我們將定義一個 run
方法,該方法會對其 components
上的每一個組件調(diào)用 draw
方法,如示例 17-5 所示:
文件名: src/lib.rs
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
示例 17-5:在 Screen
上實現(xiàn)一個 run
方法,該方法在每個 component 上調(diào)用 draw
方法
這與定義使用了帶有 trait bound 的泛型類型參數(shù)的結(jié)構(gòu)體不同。泛型類型參數(shù)一次只能替代一個具體類型,而 trait 對象則允許在運行時替代多種具體類型。例如,可以定義 Screen
結(jié)構(gòu)體來使用泛型和 trait bound,如示例 17-6 所示:
文件名: src/lib.rs
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();
}
}
}
示例 17-6: 一種 Screen
結(jié)構(gòu)體的替代實現(xiàn),其 run
方法使用泛型和 trait bound
這限制了 Screen
實例必須擁有一個全是 Button
類型或者全是 TextField
類型的組件列表。如果只需要同質(zhì)(相同類型)集合,則傾向于使用泛型和 trait bound,因為其定義會在編譯時采用具體類型進行單態(tài)化。
另一方面,通過使用 trait 對象的方法,一個 Screen
實例可以存放一個既能包含 Box<Button>
,也能包含 Box<TextField>
的 Vec<T>
。讓我們看看它是如何工作的,接著會講到其運行時性能影響。
現(xiàn)在來增加一些實現(xiàn)了 Draw
trait 的類型。我們將提供 Button
類型。再一次重申,真正實現(xiàn) GUI 庫超出了本書的范疇,所以 draw
方法體中不會有任何有意義的實現(xiàn)。為了想象一下這個實現(xiàn)看起來像什么,一個 Button
結(jié)構(gòu)體可能會擁有 width
、height
和 label
字段,如示例 17-7 所示:
文件名: src/lib.rs
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
}
}
示例 17-7: 一個實現(xiàn)了 Draw
trait 的 Button
結(jié)構(gòu)體
在 Button
上的 width
、height
和 label
字段會和其他組件不同,比如 TextField
可能有 width
、height
、label
以及 placeholder
字段。每一個我們希望能在屏幕上繪制的類型都會使用不同的代碼來實現(xiàn) Draw
trait 的 draw
方法來定義如何繪制特定的類型,像這里的 Button
類型(并不包含任何實際的 GUI 代碼,這超出了本章的范疇)。除了實現(xiàn) Draw
trait 之外,比如 Button
還可能有另一個包含按鈕點擊如何響應(yīng)的方法的 impl
塊。這類方法并不適用于像 TextField
這樣的類型。
如果一些庫的使用者決定實現(xiàn)一個包含 width
、height
和 options
字段的結(jié)構(gòu)體 SelectBox
,并且也為其實現(xiàn)了 Draw
trait,如示例 17-8 所示:
文件名: src/main.rs
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
}
}
示例 17-8: 另一個使用 gui
的 crate 中,在 SelectBox
結(jié)構(gòu)體上實現(xiàn) Draw
trait
庫使用者現(xiàn)在可以在他們的 main
函數(shù)中創(chuàng)建一個 Screen
實例。至此可以通過將 SelectBox
和 Button
放入 Box<T>
轉(zhuǎn)變?yōu)?trait 對象來增加組件。接著可以調(diào)用 Screen
的 run
方法,它會調(diào)用每個組件的 draw
方法。示例 17-9 展示了這個實現(xiàn):
文件名: src/main.rs
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();
}
示例 17-9: 使用 trait 對象來存儲實現(xiàn)了相同 trait 的不同類型的值
當編寫庫的時候,我們不知道何人會在何時增加 SelectBox
類型,不過 Screen
的實現(xiàn)能夠操作并繪制這個新類型,因為 SelectBox
實現(xiàn)了 Draw
trait,這意味著它實現(xiàn)了 draw
方法。
這個概念 —— 只關(guān)心值所反映的信息而不是其具體類型 —— 類似于動態(tài)類型語言中稱為 鴨子類型(duck typing)的概念:如果它走起來像一只鴨子,叫起來像一只鴨子,那么它就是一只鴨子!在示例 17-5 中 Screen
上的 run
實現(xiàn)中,run
并不需要知道各個組件的具體類型是什么。它并不檢查組件是 Button
或者 SelectBox
的實例。通過指定 Box<dyn Draw>
作為 components
vector 中值的類型,我們就定義了 Screen
為需要可以在其上調(diào)用 draw
方法的值。
使用 trait 對象和 Rust 類型系統(tǒng)來進行類似鴨子類型操作的優(yōu)勢是無需在運行時檢查一個值是否實現(xiàn)了特定方法或者擔心在調(diào)用時因為值沒有實現(xiàn)方法而產(chǎn)生錯誤。如果值沒有實現(xiàn) trait 對象所需的 trait 則 Rust 不會編譯這些代碼。
例如,示例 17-10 展示了當創(chuàng)建一個使用 String
做為其組件的 Screen
時發(fā)生的情況:
文件名: src/main.rs
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Hi"))],
};
screen.run();
}
示例 17-10: 嘗試使用一種沒有實現(xiàn) trait 對象的 trait 的類型
我們會遇到這個錯誤,因為 String
沒有實現(xiàn) rust_gui::Draw
trait:
$ cargo run
Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
--> src/main.rs:5:26
|
5 | components: vec![Box::new(String::from("Hi"))],
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
|
= note: required for the cast to the object type `dyn Draw`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` due to previous error
這告訴了我們,要么是我們傳遞了并不希望傳遞給 Screen
的類型并應(yīng)該提供其他類型,要么應(yīng)該在 String
上實現(xiàn) Draw
以便 Screen
可以調(diào)用其上的 draw
。
回憶一下第十章 “泛型代碼的性能” 部分討論過的,當對泛型使用 trait bound 時編譯器所執(zhí)行的單態(tài)化處理:編譯器為每一個被泛型類型參數(shù)代替的具體類型生成了函數(shù)和方法的非泛型實現(xiàn)。單態(tài)化產(chǎn)生的代碼在執(zhí)行 靜態(tài)分發(fā)(static dispatch)。靜態(tài)分發(fā)發(fā)生于編譯器在編譯時就知曉調(diào)用了什么方法的時候。這與 動態(tài)分發(fā) (dynamic dispatch)相對,這時編譯器在編譯時無法知曉調(diào)用了什么方法。在動態(tài)分發(fā)的場景下,編譯器生成的代碼到運行時才能確定調(diào)用了什么方法。
當使用 trait 對象時,Rust 必須使用動態(tài)分發(fā)。編譯器無法知曉所有可能用于 trait 對象代碼的類型,所以它也不知道應(yīng)該調(diào)用哪個類型的哪個方法實現(xiàn)。為此,Rust 在運行時使用 trait 對象中的指針來知曉需要調(diào)用哪個方法。動態(tài)分發(fā)也阻止編譯器有選擇的內(nèi)聯(lián)方法代碼,這會相應(yīng)的禁用一些優(yōu)化。盡管在編寫示例 17-5 和可以支持示例 17-9 中的代碼的過程中確實獲得了額外的靈活性,但仍然需要權(quán)衡取舍。
只有對象安全(object-safe)的trait可以實現(xiàn)為特征對象。這里有一些復(fù)雜的規(guī)則來實現(xiàn)trait的對象安全,但在實踐中,只有兩個相關(guān)的規(guī)則。如果一個 trait 中定義的所有方法都符合以下規(guī)則,則該 trait 是對象安全的:
Self
?Self
關(guān)鍵字是我們在 trait 與方法上的實現(xiàn)的別稱,trait 對象必須是對象安全的,因為一旦使用 trait 對象,Rust 將不再知曉該實現(xiàn)的返回類型。如果一個 trait 的方法返回了一個 Self
類型,但是該 trait 對象忘記了 Self
的確切類型,那么該方法將不能使用原本的類型。當 trait 使用具體類型填充的泛型類型時也一樣:具體類型成為實現(xiàn) trait 的對象的一部分,當使用 trait 對象卻忘了類型是什么時,無法知道應(yīng)該用什么類型來填充泛型類型。
一個非對象安全的 trait 例子是標準庫中的 Clone
trait。Clone
trait 中的 clone
方法的聲明如下:
pub trait Clone {
fn clone(&self) -> Self;
}
String
類型實現(xiàn)了 Clone
trait,當我們在 String
的實例對象上調(diào)用 clone
方法時,我們會得到一個 String
類型實例對象。相似地,如果我們調(diào)用 Vec<T>
實例對象上的 clone
方法,我們會得到一個 Vec<T>
類型的實例對象。clone
方法的標簽需要知道哪個類型是 Self
類型,因為 Self
是它的返回類型。
當我們嘗試編譯一些違反 trait 對象的對象安全規(guī)則的代碼時,我們會收到編譯器的提示。例如,我們想實現(xiàn)17-4的 Screen
結(jié)構(gòu)體來保存一個實現(xiàn)了 Clone
trait 而不是 Draw
trait 的類型,如下所示
pub struct Screen {
pub components: Vec<Box<dyn Clone>>,
}
我們將會收到如下錯誤:
$ cargo build
Compiling gui v0.1.0 (file:///projects/gui)
error[E0038]: the trait `Clone` cannot be made into an object
--> src/lib.rs:2:29
|
2 | pub components: Vec<Box<dyn Clone>>,
| ^^^^^^^^^ `Clone` cannot be made into an object
|
= note: the trait cannot be made into an object because it requires `Self: Sized`
= note: for a trait to be "object safe" it needs to allow building a vtable to allow the call to be resolvable dynamically; for more information visit <https://doc.rust-lang.org/reference/items/traits.html#object-safety>
For more information about this error, try `rustc --explain E0038`.
error: could not compile `gui` due to previous error
這個錯誤意味著我們不能將此 trait 用于 trait 對象。如果你想了解更多有關(guān)對象安全的細節(jié),請移步至 Rust RFC 255 或查看 Rust Reference
更多建議: