Rust程序設計語言第二版ch17-02

為使用不同類型的值而設計的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方法。其他的類比如ButtonImageSelectBox會從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內部的實現了Drawtrait的類型的替身。

<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結構體,帶有一個含有實現了Drawtrait的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。我們看它是怎么工作的,然后討論運行時性能的實現。

來自我們或者庫使用者的實現

現在,我們增加一些實現了Drawtrait的類型。我們會再次提供Button,實際上實現一個GUI庫超出了本書的范圍,所以draw方法的內部不會有任何有用的實現。為了想象一下實現可能的樣子,Button結構體可能有 widthheightlabel`字段,如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上的 widthheightlabel會和其他組件不同,比如TextField可能有widthheight,
labelplaceholder字段。每個我們可以在屏幕上繪制的類型會實現Drawtrait,在draw方法中使用不同的代碼,定義了如何繪制Button(GUI代碼的具體實現超出了本章節的范圍)。除了Draw trait,Button可能也有另一個impl塊,包含了當按鈕被點擊的時候的響應方法。這類方法不適用于TextField這樣的類型。

有時,使用我們的庫決定了實現一個包含widthheightoptions``SelectBox結構體。它們在SelectBox類型上實現了Drawtrait,如 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增加SelectBoxButton。它們可以在每個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的Screenrun方法的實現中,run不需要知道每個組件的具體類型。它也不檢查是否一個組件是Button或者SelectBox的實例,只是調用組件的draw方法即可。通過指定Box<Draw>作為componentsvector中的值類型,我們定義了: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沒有實現 Drawtrait:

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容