進階特徵

我們在第十章「特徵:定義共享行為」一節首次提及特徵(trait),但對其進階細節並無著墨。現在你已熟稔 Rust ,了解箇中真諦的時機已至。

利用關聯型別在特徵定義中指定佔位符型別

關聯型別(associated types)連結了一個型別佔位符(placeholder)與一個特徵,可以將這些佔位符型別使用在這些特徵所定義的方法簽名上。對特定實作來說,特徵的實作者將指明一個具體型別來代替型別佔位符。如此一來,我們可以定義一個使用了某個型別的特徵,但直到特徵被實作之前,都不需知道實際上的型別。

多數在本章提及的進階特色都較少使用,而關聯型別則是介於其中:他們比書中其他內容來得少用,但比本章介紹的其他特色來得更常見。

一個具有關聯型別的特徵之範例是標準函式庫提供的 Iterator 特徵。這例子中的關聯型別叫做 Item,表示一型別實作 Iterator 特徵時,會被疊代的那些值的型別。範例 19-12 展示了 Iterator 特徵的定義:

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}

範例 19-12:Iterator 特徵自帶一個關聯型別

Item 型別是個佔位符,next 方法的定義顯示它會回傳型別為 Option<Self::Item> 之值。Iterator 特徵的實作者會指定 Item 的具體型別,而 next 方法則會回傳一個包含該具體型別的值的一個 Option

關聯型別可能看起來和泛型的概念非常相似,而後者允許定義函式而不需指定該函式可以處理何種型別。為了檢視以下兩者概念上的差異,我們來看這個替型別 Counter 上實作 Iterator 特徵,且 Counter 指定的 Item 的型別為 u32

檔案名稱:src/lib.rs

struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        // --snip--
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

語法似乎和泛型很像,所以為什麼我們不使用泛型定義 Iterator 特徵,如範例 19-13 所示?

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}

範例 19-13: 假設使用泛型來定義 Iterator 特徵

差別在於使用泛型時,如範例 19-13 所示,由於我們可以實作出 Iterator<String> for Counter 或以任意多個其他泛型型別來替 Counter 實作 Iterator,因此,必須在每個實作都標註該型別。換言之,當一特徵擁有泛型參數,一型別即可透過改變泛型型別參數(generic type parameter)的具體型別,進而實作該特徵多次。於是,當我們使用 next 方法時,必須提供型別標註,指名要用哪個 Iterator 的實作。

有了關聯型別,同個型別就不能實作同個特徵多次,所以我們不需要標註型別。範例 19-12 中的定義用上了關聯型別,因為只能擁有一個 impl Iterator for Counter,於是只能替 Item 選擇唯一一個型別。在任何地方呼叫 Counternext 方法就不必再明確指定我們想要 u32 疊代器了。

關聯型別也會成為該特徵的條款:該特徵的實作者必須替關聯型別佔位符提供一個型別。關聯型別通常該有個能解釋自己如何被用的名字,而且替關聯型別加上 API 技術文件會是個不錯的實踐。

預設泛型型別參數與運算子重載

我們可以透過泛型型別參數(generic type parameter)指定該泛型型別預設的具體型別。在預設型別可運作的情形下,這可省去實作者需要指定具體型別的勞動。替泛型型別指定預設型別的語法是在宣告泛型型別時寫成 <PlaceholderType=ConcreteType>

運算子重載(operator overloading)就是一個使用這個技術的好例子。你可以在特定情況下自訂運算子(如 +)的行為。

Rust 不允許建立你自己的運算子或重載任意的運算子,但你可以透過實作 std::ops 表列出的特徵與相關的運算子,來重載特定運算與相應特徵。在範例 19-14 我們重載了 + 運算子,讓兩個 Point 實例可相加。這個功能是透過對 Point 結構體實作 Add 特徵來達成:

檔案名稱:src/main.rs

use std::ops::Add;

#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    assert_eq!(
        Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
        Point { x: 3, y: 3 }
    );
}

範例 19-14:藉由實作 Add 特徵,重載 Point 實例的 + 運算子

add 方法將兩個 Point 實例的 x 值相加,兩個 y 值相加,並建立新的 Point 實例。Add 特徵有個關聯型別 Output 可以決定 add 方法回傳的型別。

這段程式碼的預設泛型型別寫在 Add 特徵中,定義如下:

#![allow(unused)]
fn main() {
trait Add<Rhs=Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}
}

這段程式碼大體上看起來很眼熟:具有一個方法與一個關聯型別的特徵。新朋友是 Rhs=Self,這部分叫做預設型別參數(default type parameter)Rhs 泛型參數(「右運算元 right hand side」的縮寫)定義了 add 方法中 rhs 參數的型別。若我們未在實作 Add 特徵時指定 Rhs 的具體型別,這個 Rhs 的型別預設會是 Self,也就是我們正在實作 Add 的型別。

當我們對 Point 實作 Add,因為我們想要將兩個 Point 實例相加,所以用到預設的 Rhs。讓我們看一個實作 Add 的範例,如何不用預設值,轉而自訂 Rhs

我們有兩個結構體,MillimetersMeters,分別儲存不同單位的值。這樣在現有的型別簡單地封裝進另一個結構體的模式叫做新型別模式(newtype pattern),我們會在「使用新型別模式替外部型別實作外部特徵」段落做詳細介紹。我們想將公釐透過 Add 做好正確單位轉換來加至公尺,這可透過對 Millimeters 實作 Add 並將 Rhs 設為 Meters 達成,如範例 19-15。

檔案名稱:src/lib.rs

use std::ops::Add;

struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
    type Output = Millimeters;

    fn add(self, other: Meters) -> Millimeters {
        Millimeters(self.0 + (other.0 * 1000))
    }
}

範例 19-15:藉由替 Millimeters 實作 Add 特徵,使 Millimeters 可與 Meters 相加 Point

欲相加 MillimetersMeters,先將 Rhs 型別參數指定為 impl Add<Meters>,替代預設的 Self

你會在下列兩種情況下使用預設型別參數:

  • 擴充一個型別但不破壞既有程式碼
  • 提供大多數使用者不會需要的特殊狀況之自訂空間

標準函式庫是第二種情況的範例:通常你會將兩個相同的型別相加,但 Add 特徵提供超乎預設的自訂能力。Add 特徵定義中的預設型別參數讓我們大多數時候不需要指定額外的參數。換句話說,不用再寫部分重複的樣板,讓該特徵更易用。

第一種情況和第二種類似,但概念相反:若你想替既有特徵加上新的型別參數,可以給它一個預設值,允許擴充該特徵的功能,而不破壞既有的程式實作。

消除歧義的完全限定語法:呼叫同名的方法

Rust 並沒有限制不同特徵之間不能有同名的方法,也沒有阻止你對同一個型別實作這兩個特徵。有可能實作一個型別,其擁有多個從多個特徵而來的同名方法的型別。

當呼叫這些同名方法,你必須告訴 Rust 你想呼叫誰。試想範例 19-16 的程式碼,我們定義了兩個特徵 PilotWizard,兩者都有 fly 方法。當我們對一個已經擁有 fly 方法的 Human 型別分別實作這兩個特徵時,每個 fly 方法的行為皆不同。

檔案名稱:src/main.rs

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("這裡是艦長發言。");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("起!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*狂揮雙臂*");
    }
}

fn main() {}

範例 19-16:Human 分別實作了兩個特徵的 fly 方法,且 Human 自己實作了一個 fly 方法

當我們對一個 Human 實例呼叫 fly,編譯器預設會呼叫直接在該型別上實作的方法,如範例 19-17 所示:

檔案名稱:src/main.rs

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("這裡是艦長發言。");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("起!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*狂揮雙臂*");
    }
}

fn main() {
    let person = Human;
    person.fly();
}

範例 19-17:對 Human 實例呼叫 fly

執行這段程式碼會印出 *狂揮雙臂*,表示 Rust 呼叫直接在 Human 上實作的 fly 方法。

欲呼叫在 PilotWizard 特徵上的 fly 方法,我們要用更明確的語法指定我們想要的 fly 方法。範例 19-18 展示了這個語法。

檔案名稱:src/main.rs

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    Pilot::fly(&person);
    Wizard::fly(&person);
    person.fly();
}

範例 19-18:指定想要呼叫哪個特徵的 fly 方法

在你要呼叫的方法名前指定特徵名稱,可以讓 Rust 清楚得知我們要呼叫哪個實作 fly。我們也可以寫成 Human::fly(&person),同義於在範例 19-18 的 person.fly(),只是為了消歧義而寫得長一點罷了。

執行這段程式碼會印出:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished dev [unoptimized + debuginfo] target(s) in 0.46s
     Running `target/debug/traits-example`
這裡是艦長發言。
起!
*狂揮雙臂*

因為 fly 方法有個 self 參數,所以我們若有兩個型別都實作了同個特徵,Rust 可以透過 self 的型別理出該用哪個特徵的實作。

然而不屬於方法的關聯函式(associated function)則沒有 self 參數。當同個作用域下的多個型別或特徵皆定義了非方法且同名的函式時,除非使用完全限定語法(fully qualified syntax),否則 Rust 無法推斷你指涉哪個型別。舉例來說,範例 19-19 中,我們替動物庇護所建立了一個特徵,會將所有小狗狗命名為小不點。我們建立 Animal 特徵並帶有一個關聯非方法的函式 baby_nameAnimal 特徵有個在 Dog 上的實作,也提供了關聯非方法的函式 baby_name

檔案名稱:src/main.rs

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("小不點")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("小狗狗")
    }
}

fn main() {
    println!("幼犬被稱作{}", Dog::baby_name());
}

範例 19-19: 一個特徵和一個型別分別擁有同名關聯函式,並且該型別實作了該特徵

我們在 Dog 中的 baby_name 關聯函式實作了一段程式碼,將所有小狗狗命名為小不點。這個 Dog 型別同時實作了 Animal 特徵,Animal 特徵則描述了所有動物都有的習性。在實作了 Animal 特徵的 Dog 上,透過與 Animal 特徵關聯的 baby_name 函式中,表達了幼犬被稱作小狗狗這一概念。

main 中我們呼叫 Dog::baby_name 函式,最終會直接呼叫 Dog 上的關聯函式。這段程式碼會印出:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished dev [unoptimized + debuginfo] target(s) in 0.54s
     Running `target/debug/traits-example`
幼犬被稱作小不點

這個輸出結果不符合我們預期。我們想呼叫的 baby_name 函式應該是我們在 Dog 上實作的 Animal 特徵,所以程式碼應該印出 幼犬被稱作小狗狗。我們在範例 19-18 所使用的指明特徵的技巧不適用於此,如果我們更改 main 成範例 19-20,會得到一個編譯錯誤:

檔案名稱:src/main.rs

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("小不點")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("小狗狗")
    }
}

fn main() {
    println!("幼犬被稱為{}", Animal::baby_name());
}

範例 19-20:嘗試呼叫 Animal 特徵上的 baby_name 函式,但 Rust 不知道該用哪個實作

因為 Animal::baby_name 沒有 self 參數,且其他型別也可能有 Animal 的實作,所以 Rust 無法推斷出我們想要哪個 Animal::baby_name 實作。我們會得到這個編譯錯誤:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
  --> src/main.rs:20:43
   |
2  |     fn baby_name() -> String;
   |     ------------------------- `Animal::baby_name` defined here
...
20 |     println!("A baby dog is called a {}", Animal::baby_name());
   |                                           ^^^^^^^^^^^^^^^^^ cannot call associated function of trait
   |
help: use the fully-qualified path to the only available implementation
   |
20 |     println!("A baby dog is called a {}", <::Dog as Animal>::baby_name());
   |                                           +++++++++       +

For more information about this error, try `rustc --explain E0790`.
error: could not compile `traits-example` due to previous error

欲消除歧義,告訴 Rust 我們想用 Dog 上的 Animal 實作,而不是其他型別的 Animal 實作,我們必須使用完全限定語法。範例 19-21 展示了如何使用完全限定語法。

檔案名稱:src/main.rs

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("小不點")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("小狗狗")
    }
}

fn main() {
    println!("幼犬被稱作{}", <Dog as Animal>::baby_name());
}

範例 19-21:使用完全限定語法指定呼叫實作了 AnimalDog 上的 baby_name 函式

我們提供一個用角括號包住的型別詮釋(type annotation),這個詮釋透過將此函式呼叫的 Dog 型別視為 Animal,來指明我們想要呼叫有實作 Animal 特徵的 Dog 上的 baby_name 方法。這段程式碼現在會印出我們所要的:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/traits-example`
幼犬被稱作小狗狗

普遍來說,完全限定語法定義如下:

<Type as Trait>::function(receiver_if_method, next_arg, ...);

對於不是方法的關聯函式來說,不會有 receiver,只會有其他引數的列表。你可以在任何呼叫函式或方法之處使用完全限定語法。然而,你亦可在 Rust 能透過程式其他資訊推斷出的地方省略這個語法。只需要在有多個同名實作且需要協助 Rust 指定呼叫哪個實作時,才需要使用這囉嗦冗長的語法。

使用超特徵要求在一個特徵內有另一特徵的功能

有些時候,你會定義一個依賴到其他特徵的特徵:對於已實作第一個特徵的型別,你想要求它同時實作第二個特徵。當你做了這件事,你的特徵定義就可以利用第二個特徵的關聯項目。你定義的特徵依賴的特徵就稱為超特徵(supertrait)。

假設我們想要建立一個 OutlinePrint 特徵,它有一個 outline_print 方法會印出一個被星號包圍的值。換句話說,給定一個實作 Display 而會產生 (x, y)Point 結構體,當我們對 x1y3Point 實例呼叫 outline_print,它印出如下:

**********
*        *
* (1, 3) *
*        *
**********

在這個 outline_print 實作中,我們想要使用到 Display 特徵的功能。因此,我們需要指明 OutlinePrint 特徵只會在型別同時實作 Display 且提供 OutlinePrint 所需功能時才會成功。這件事可以在特徵定義中做到,透過指明 OutlinePrint: Display。這項技巧很類似特徵上的特徵約束(trait bound)。範例 19-22 展示了 OutlinePrint 特徵的實作。

檔案名稱:src/main.rs

use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {} *", output);
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

fn main() {}

範例 19-22: 實作要求 Display 功能的 OutlinePrint 特徵

因為我們已指明 OutlinePrint 需要 Display 特徵,且只要有實作 Display 的型別都會自動實作 to_string 這個函式,所以我們可以使用 to_string。若我們嘗試使用 to_string 但並沒有在該特徵後加上冒號並指明 Display,會得到一個錯誤,告訴我們在當前作用域下的 &Self 型別找不到名為 to_string 函數。

我們嘗試看看在一個沒有實作 Display 的型別上實作 OutlinePrint(如 Point 結構體)會發生什麼事:

檔案名稱:src/main.rs

use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {} *", output);
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

於是得到 Display 為必須但沒實作的錯誤:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:20:6
   |
20 | impl OutlinePrint for Point {}
   |      ^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Point`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint`
  --> src/main.rs:3:21
   |
3  | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `traits-example` due to previous error

我們可以透過對 Point 實作 Display 並滿足 OutlinePrint 要求的約束(constraint),如下:

檔案名稱:src/main.rs

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {} *", output);
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

use std::fmt;

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

於是對 Point 實作 OutlinePrint 特徵就成功編譯,我們即可以對 Point 實例呼叫 outline_print 來顯示一個星號外框框住它。

使用新型別模式替外部型別實作外部特徵

在第十章「為型別實作特徵」一節中,我們提及孤兒規則(orphan rule),這個規則指出只要型別或特徵其一是在本地的 crate 中定義,就允許我們對該型別實作該特徵。使用新型別模式(newtype pattern),即可繞過這項規則,此模式涉及建立一個元組結構體(tuple struct)型別(我們在「使用無名稱欄位的元組結構體來建立不同型別」說明了元組結構體)。元組結構體包含一個欄位,在我們想要實作該特徵的型別外作一層薄薄的封裝。這封裝型別對 crate 來說算作在本地定義,因此可以對該封裝實作該特徵。新型別是一個源自 Haskell 程式語言的術語。使用此模式不會有任何執行時效能的耗損,這個封裝型別會在編譯期刪略。

舉個例子,我們想要對 Vec<T> 實作 Display,但孤兒規則限制我們不能這樣做,因為 Display 特徵與 Vec<T> 都是在我們的 crate 之外定義。我們可以建立一個 Wrapper 結構體,帶有一個 Vec<T> 實例,接下來再對 Wrapper 實作 Display 並使用 Vec<T> 之值,如範例 19-23 所示。

檔案名稱:src/main.rs

use std::fmt;

struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let w = Wrapper(vec![String::from("hello"), String::from("world")]);
    println!("w = {}", w);
}

範例 19-23:建立一個 Wrapper 型別封裝 Vec<STring> 以實作 Display

因為 Wrapper 是一個元組結構體而 Vec<T> 是該元組在索引 0 上的項目,所以該 Display 的實作使用 self.0 存取內部的 Vec<T>。我們就可以在 Wrapper 上使用 Display 的功能了。

使用這個技術的缺點是 Wrapper 是個新型別,並無它封裝的值所擁有的方法。我們不得不在 Wrapper 上實作所有 Vec<T> 的方法,委派這些方法給 self.0,讓我們可以將 Wrapper 作為 Vec<T> 一樣對待。如果我們想要新型別得到所有內部型別擁有的所有方法,一個解法是透過對 Wrapper 實作 Deref 特徵(在第十五章「透過 Deref 特徵將智慧指標視為一般參考」一節有相應討論)並回傳內部型別。如果我們不想要 Wrapper 擁有所有內部型別的方法,例如限制 Wrapper 型別之行為,就僅須實作那些我們想要的方法。

現在,你知道如何將新型別模式與特徵相關聯,縱使不涉及特徵,新型別模式仍非常實用。接下來我們將目光轉移到其他與 Rust 型別系統互動的方法吧。