允許不同型別數值的特徵物件

在第八章中,我們提及向量其中一項限制是它儲存的元素只能有一種型別。我們在範例 8-9 提出一個替代方案,那就是我們定義 SpreadsheetCell 列舉且其變體能存有整數、浮點數與文字。這讓我們可以對每個元素儲存不同的型別,且向量仍能代表元素的集合。當我們的可變換的項目有固定的型別集合,而且我們在編譯程式碼時就知道的話,這的確是完美的解決方案。

然而,有時我們會希望函式庫的使用者能夠在特定的情形下擴展型別的集合。為了展示我們如何達成,我們來建立個圖形使用者介面(graphical user interface,GUI)工具範例來遍歷一個項目列表,呼叫其內每個項目的 draw 方法將其顯示在螢幕上,這是 GUI 工具常見的技巧。我們會建立個函式庫 crate 叫做 gui,這會包含 GUI 函式庫的結構體。此 crate 可能會包含一些人們會使用到的型別,像是 ButtonTextField。除此之外,gui 使用者也能夠建立他們自己的型別來顯示出來。舉例來說,有些開發者可以加上 Image 而其他人可能會加上 SelectBox

我們在此例中不會實作出整個 GUI 函式庫,但會展示各個元件是怎麼組合起來的。在寫函式庫時,我們無法知道並定義開發者想建立出來的所有型別。但我們知道 gui 需要追蹤許多不同型別的數值,且它需要能夠對這些不同的型別數值呼叫 draw 方法。它不需要知道當我們呼叫 draw 方法時實際發生了什麼事,只需要知道該數值有我們可以呼叫的方法。

在有繼承的語言中,我們可能會定義一個類型(class)叫做 Component 且其有個方法叫做 draw。其他的類型像是 ButtonImageSelectBox 等等,可以繼承 Component 以取得 draw 方法。它們可以覆寫 draw 方法來定義它們自己的自訂行為,但是整個框架能將所有型別視為像是 Component 實例來對待,並對它們呼叫 draw。但由於 Rust 並沒有繼承,我們需要其他方式來組織 gui 函式庫,好讓使用者可以透過新的型別來擴展它。

定義共同行為的特徵

要定義我們希望 gui 能擁有的行為,我們定義一個特徵叫做 Draw 並有個方法叫做 draw。然後我們可以定義一個接收特徵物件(trait object)的向量。一個特徵物件會指向有實作指定特徵的型別以及一個在執行時尋找該型別方法的尋找表(look up table)。要建立特徵物件,我們指定一些指標,像是參考 & 或者智慧指標 Box<T>,然後加上 dyn 關鍵字與指定的相關特徵。(我們會在第十九章的「動態大小型別與 Sized 特徵」段落討論特徵物件必須使用指標的原因)我們可以對泛型或實際型別使用特徵物件。當我們使用特徵物件時,Rust 的型別系統會確保在編譯時該段落使用到的任何數值都有實作特徵物件的特徵。於是我們就不必在編譯時知道所有可能的型別。

我們提到在 Rust 中,我們避免將結構體和列舉稱為「物件」,來與其他語言的物件做區別。在結構體或列舉中,結構體欄位中的資料與 impl 區塊的行為是分開來的。在其他語言中,資料與行為會組合成一個概念,也就是所謂的物件。然而特徵物件才比較像是其他語言中的物件,因為這才會將資料與行為組合起來。但特徵物件與傳統物件不同的地方在於,我們無法向特徵物件新增資料。特徵物件不像其他語言的物件那麼通用,它們是特別用於對共同行為產生的抽象概念。

範例 17-3 定義了一個特徵叫做 Draw 以及一個方法叫做 draw

檔案名稱:src/lib.rs

pub trait Draw {
    fn draw(&self);
}

範例 17-3:Draw 特徵的定義

此語法和我們在第十章介紹過的特徵定義方式相同。接下來才是新語法用到的地方,範例 17-4 定義了一個結構體叫做 Screen 並持有個向量叫做 components。此向量的型別為 Box<dyn Draw>,這是一個特徵物件,這代表 Box 內的任何型別都得有實作 Draw 特徵。

檔案名稱:src/lib.rs

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

範例 17-4:定義結構體 Screen 且有個 components 欄位來持有一個實作 Draw 特徵的特徵物件向量

Screen 結構體中,我們定義了一個方法叫做 run 來對其 components 呼叫 draw 方法,如範例 17-5 所示:

檔案名稱:src/lib.rs

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

範例 17-5:Screen 的方法 run 會呼叫每個 componentdraw 方法

這與定義一個結構體並使用附有特徵界限的泛型型別參數的方式不相同。泛型型別參數一次只能替換成一個實際型別,特徵物件則是在執行時允許數個實際型別能填入特徵物件中。舉例來說,我們可以使用泛型型別與特徵界限來定義 Screen,如範例 17-6 所示:

檔案名稱:src/lib.rs

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();
        }
    }
}

範例 17-6:Screen 結構體的另種實作方式,它的方法 run 則使用泛型與特徵界限

這樣我們會限制 Screen 實例必須擁有一串全是 Button 型別或全是 TextField 型別的列表。如果你只會有同型別的集合,使用泛型與特徵界限的確是比較合適的,因為其定義就會在編譯時單型化為使用實際型別。

另一方面,透過使用特徵物件的方法,Screen 實例就能有個同時包含 Box<Button>Box<TextField>Vec<T>。讓我們看看這如何辦到的,然後我們會討論其對執行時效能的影響。

實作特徵

現在我們來加上一些有實作 Draw 特徵的型別。我們會提供 Button 型別。再次重申 GUI 函式庫的實際實作超出了本書的範疇,所以 draw 的本體不會有任何有意義的實作。為了想像該實作會像是什麼,Button 型別可能會有欄位 widthheightlabel,如範例 17-7 所示:

檔案名稱:src/lib.rs

pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // 實際畫出按鈕的程式碼
    }
}

範例 17-7:結構體 Button 實作了 Draw 特徵

Button 中的 widthheightlabel 欄位會與其他元件不同,像是 TextField 可能就會有前面所有的欄位在加上 placeholder 欄位。每個我們想在螢幕上顯示的型別都會實作 Draw 特徵,但在 draw 方法會使用不同程式碼來定義如何印出該特定型別,像是這裡的 Button 型別(不包含實際 GUI 程式碼)。舉例來說,Button 可能會有額外的 impl 區塊來包含使用者點擊按鈕時該如何反應的方法。這種方法就不適用於 TextField

如果有人想用我們的函式庫來實作個 SelectBox 結構體並擁有 widthheightoptions 欄位的話,他們也可以對 SelectBox 實作 Draw 特徵,如範例 17-8 所示:

檔案名稱:src/main.rs

use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // 實際畫出選擇框的程式碼
    }
}

fn main() {}

範例 17-8:別的 crate 使用 gui 來對 SelectBox 結構體實作 Draw 特徵

我們的函式庫使用者現在可以在他們的 main 建立個 Screen 實例。在 Screen 實例中,他們可以透過將 SelectBoxButton 放入 Box<T> 來成為特徵物件並加入元件中。他們接著就可以對 Screen 實例呼叫 run 方法,這會呼叫每個元件的 draw 方法。如範例 17-9 所示:

檔案名稱:src/main.rs

use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // 實際畫出選擇框的程式碼
    }
}

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:使用特徵物件來儲存實作相同特徵的不同型別數值

我們在寫函式庫時,我們並不知道有人會想要新增 SelectBox 型別,但我們的 Screen 實作能夠運用新的型別並顯示出來,因為 SelectBox 有實作 Draw 特徵,這代表它就有實作 draw 方法。

這種只在意數值回應的訊息而非數值實際型別的概念,類似於動態型別語言中鴨子型別(duck typing)的概念。如果它走起來像隻鴨子、叫起來像隻鴨子,那它必定是隻鴨子!在範例 17-5 中 Screenrun 實作不需要知道每個元件的實際型別為何。它不會檢查一個元件是 Button 還是 SelectBox 實例,它只會呼叫元件的 draw 方法。透過指定 Box<dyn Draw> 來作為 components向量中的數值型別,我們定義 Screen 需要我們能夠呼叫 draw 方法的數值。

我們使用特徵物件與 Rust 型別系統能寫出類似鴨子型別的程式碼,這樣的優勢在於我們在執行時永遠不必檢查一個數值有沒有實作特定方法,或擔心我們會不會呼叫了一個沒有實作該方法的數值而產生錯誤。如果數值沒有實作特徵物件要求的特徵的話,Rust 不會編譯通過我們的程式碼。

舉例來說,範例 17-10 展示了要是我們嘗試使用 String 作為元件來建立 Screen 的話會發生什麼事:

檔案名稱:src/main.rs

use gui::Screen;

fn main() {
    let screen = Screen {
        components: vec![Box::new(String::from("嗨"))],
    };

    screen.run();
}

範例 17-10:嘗試使用沒有實作特徵物件的特徵的型別

我們會因為 String 沒有實作 Draw 特徵而得到錯誤:

$ 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("嗨"))],
  |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
  |
  = help: the trait `Draw` is implemented for `Button`
  = note: required for the cast from `String` 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 的型別所以應該要傳遞其他型別,要麼我們應該要對 String 實作 Draw,這樣 Screen 才能對其呼叫 draw

特徵物件執行動態調度

回想一下第十章的「使用泛型的程式碼效能」段落我們討論過,當我們對泛型使用閉包時,編譯器會執行單型化(monomorphization)的過程。編譯器會在我們對每個用泛型型別參數取代的實際型別產生非泛型的函式與方法實作。單型化產生程式碼的動作會稱為「靜態調度(static dispatch)」,這代表編譯器在編譯時知道我們呼叫的方法為何。與其相反的則是動態調度(dynamic dispatch),這種方式時編譯器在編譯時無法知道你呼叫的方法為何。在動態調度的情況下,編譯器會產生在執行時能夠確定會呼叫何種方法的程式碼。

當我們使用特徵物件時,Rust 必須使用動態調度。編譯器無法知道使用特徵物件的程式碼會使用到的所有型別為何,所以它會不知道該呼叫哪個型別的哪個實作方法。取而代之的是,Rust 在執行時會使用特徵物件內部的指標來知道該呼叫哪個方法。這樣尋找的動作會產生靜態調度所沒有的執行時開銷。動態調度也讓編譯器無法選擇內聯(inline)方法的程式碼,這樣會因而阻止一些優化。不過我們的確對範例 17-5 的程式碼增加了額外的彈性,並能夠支援範例 17-9,所以這是個權衡取捨。