[為你自己學 Rust] 泛型(Generics)

[為你自己學 Rust] 泛型(Generics)

從前面的「所有權(Ownership)」章節開始,接著生命週期(Lifetime)、特徵(Trait)、列舉(Enum),對平常只有撰寫前端或是只有寫 CRUD 的工程師來說(例如我就是),應該開始有一點學習上爬坡感了,這個章節會再繼續的增加一點坡度。

假設我定義了一個長方型的 Struct:

struct Rectangle {
    width: u32,
    height: u32,
}

裡面有 widthheight 這兩個欄位,都是 u32 型別。Rust 對型別的要求是很嚴格的,說好的 u32 就是只能放整數,給它小數的話馬上 Rust 編譯器就會跳出來抱怨了。

let rect_a = Rectangle{ width: 100, height: 50 };      // 沒問題
let rect_b = Rectangle{ width: 38.5, height: 19.5 };   // 不行!

假設我希望這個 Rectangle 的欄位也可以使用小數的話,你可能想說可以改成寫成這樣:

struct RectangleU32 {
    width: u32,
    height: u32,
}

struct RectangleF32 {
    width: f32,
    height: f32,
}

這樣就沒問題了:

let rect_a = RectangleI32{ width: 100, height: 50 };
let rect_b = RectangleF32{ width: 38.5, height: 19.5 };

但如果這樣寫,後續如果想要寫一個可以計算面積的函數,這兩個不同的 Struct 要怎麼傳進去當參數?難道也要寫兩份?

不同的程式語言在面臨這樣的情境,都有類似的設計,有些程式語言稱之「模板(Template)」,有些則稱之「泛型(Generics)」,就字面上的解釋來說泛型並不是特定指某一種型別,而是用來「代表」某個型別。如果還是覺得有點抽象,我們來看看程式碼:

struct Rectangle<T> {
    width: T,
    height: T,
}

首先,在 Struct 名字的最後面的 <T> 就是 Rust 裡泛型的標記寫法,表示在這個 Struct 裡會有一種 T 型別。

蛤?什麼是 T 型別?這你不用管,反正它就是一種型別,或說它就是個佔位置的 placeholder 也行,它可以是整數、浮點數、布林值或其它型別都可以,沒有限定。以上面的範例來說,唯一確定的就是 widthheight 這兩個欄位是「相同的 T 型別」。正是因為它可以廣「泛」的代表某種「型」別,才稱之「泛型」。

如果你喜歡的話,你可以把這個 T 換成任意的大寫開頭的字,像這樣:

struct Rectangle<RRRRRR> {
    width: RRRRRR,
    height: RRRRRR,
}

Rust 編譯器不會抱怨,但通常會看到大家使用 T 是因為它比較短而且可以用來代表「型別(Type)」,甚至在其它有支援泛型的程式語言也都會用 T。把原本固定寫死的型別抽換成 T 型別之後,使用起來就更有彈性了:

let rect_a = Rectangle{ width: 100, height: 50 };
let rect_b = Rectangle{ width: 38.5, height: 19.5 };

現在 Rectangle 的欄位可以用整數,也可以用小數了。這樣的泛型寫法可以讓原本 Struct 更有彈性了,不需要一樣的東西寫很多次。

有趣的是,雖然這裡並沒有指定特定型別,但當 Rust 在編譯過程中發現你要 u32 型別,那它就在背後幫你做一個 u32 的 Struct 出來,你帶 f32 給它,它就幫你做一個欄位是 f32 的 Struct 給你,這個行為有個專有名詞叫「單型化 (Monomorphization)」:

struct Rectangle_i32 {
    width: i32,
    height: i32,
}

struct Rectangle_f32 {
    width: f32,
    height: f32,
}

實際產生的名字應該更複雜,但這些都是在編譯時期 Rust 編譯器幫你做掉的,所以雖然在編譯的階段會多花一點點的時間,但執行的時候不會有額外的成本,效能不會因為這樣而有所折扣,也就是 Rust 官方手冊裡所提到「零成本抽象(Zero-Cost Abstractions)」的概念,你不用手動寫出這些資料結構的實際型別。

多個不同型別

承上,如果 widthheight 這兩個欄位我想要給它們不同的型別呢?沒問題,你可以這樣寫:

struct Rectangle<T, U> {
    width: T,
    height: U,
}

那個 U 就是表示另一種型別。同樣的,你想用什麼名字都行,不過慣例上會使用 U,因為它正好是英文字母 T 的下一個字,如果還有更多型別參數,可以繼續往下使用 VW 之類的字母。(就跟為什麼迴圈裡常會用 i 變數,然後迴圈裡的迴圈會用 j 變數一樣的道理)

泛型不是只能用在 Struct 上,Enum 跟 Trait 也都能用。現在各位再回想一下在上個章節介章提到的 OptionResult

pub enum Option<T> {
    None,
    Some(T),
}

pub enum Result<T, E> {
    Ok(T),
    Err(E),
}

它們本身也是用了泛型的寫法,另外我們在介紹生命週期的時候也出現過 boss_cat<'a> 這樣的寫法,其中的 <> 也是泛型。現在是不是比較能看的懂了呢?

參數也能泛型

不只如此,泛型還能用在函數上。我從前面的章節借個範例來用:

fn add_number(a: i32, b: i32) -> i32 {
    a + b
}

因為這裡的 i32 都一樣,如果改用泛型的寫法可以變成這樣:

fn add_number<T>(a: T, b: T) -> T {
    a + b
}

這麼一來這個 add_number() 函數可以處理整數也能處理浮點數,好像不錯。但編譯的時候會出現錯誤訊息:

$ cargo run          
error[E0369]: cannot add `T` to `T`
23 |     a + b
   |     - ^ - T
   |     |
   |     T

Rust 告訴我們它沒辦法把 TT 加在一起。咦?我本來以為 Rust 編譯器這麼聰明,應該能夠猜的出來 T 是什麼型別,看來我們得跟它講的更清楚一點。但其實再仔細想想也合理,想想看,因為這個 T 可以是任意型別,如果我寫成 add_number(true, false) 硬是給它 2 個 bool 型別的值給它,你是要它們兩個怎麼相加?

所以我們要告訴 Rust,這個 T 型別不能是隨便阿貓阿狗型別都可以,得給它一些「限制(Constraints)」,在 Rust 裡我們可以給這個 T 型別某些特徵(Trait)。以上面的這個加法的範例來說,在 Rust 的標準函式庫裡有一個 Add 特徵可以借來用:

fn add_number<T: std::ops::Add<Output = T>>(a: T, b: T) -> T {
    a + b
}

我知道這個語法寫起來有點複雜了,std::ops::Add 是指在 std 模組裡的 ops 模組裡有個 Add 特徵,把它掛在 T 後面表示這個 T 型別現在就有 Add 的特徵了,而且 Rust 內建就已經幫 Add 特徵實作了可以相加的功能。

如果覺得 std::ops::Add 寫起來有點囉嗦,可透過 use 關鍵字稍微來簡化它:

use std::ops::Add;

fn add_number<T: Add<Output = T>>(a: T, b: T) -> T {
    a + b
}

讓我們來追一點點 Rust 的原始碼,驗證一下我們剛剛介紹的這段內容。追進原始碼就會發現,Add 就真的只是一個 Trait:

pub trait Add<Rhs = Self> {
    type Output;
    fn add(self, rhs: Rhs) -> Self::Output;
}

裡面需要實作 add 這個方法,這裡也可以看到這個 Trait 本身也有用到泛型,剛剛寫到的 Output 也可以在這裡看到它。再往下看一點會看到 Rust 幫我們寫好的巨集(Macro):

macro_rules! add_impl {
    ($($t:ty)*) => ($(
        impl Add for $t {
            type Output = $t;
            fn add(self, other: $t) -> $t { self + other }
        }

        forward_ref_binop! { impl Add, add for $t, $t }
    )*)
}

add_impl! { usize u8 u16 u32 u64 u128 isize i8 i16 i32 i64 i128 f32 f64 }

這裡不需要每個語法都看懂,但大概能猜的出來 add_impl! 這個巨集會幫所有的整數型別以及浮點數型別都實作 Add 這個特徵所需的功能。另外,從原始碼應該也看的出來在這個 add() 方法的參數是 self 並不是 &self 參照,所以執行了加法之後就會轉移所有權。

回到原本的程式,因為現在我們的 add_number() 函數已經加上限制了,所以這樣執行:

println!("{}", add_number(1, 2));
println!("{}", add_number(3.1, 9.8));

就都能正常運作了。如果這時候故意帶個 bool 型別給它呢?

$ cargo run
error[E0277]: cannot add `bool` to `bool`
   |
7  |     println!("{}", add_number(true, true));
   |                    ^^^^^^^^^^ no implementation for `bool + bool`

因為 bool 型別並沒有實作 Add 特徵(只有數字們有),所以無法通過編譯。我們再試著讓它複雜一點,如果在函數裡不只做加法,還要做減法呢?寫起來可能就會變這樣:

fn calc<T: std::ops::Add<Output = T> + std::ops::Sub<Output = T>>(a: T, b: T, c: T) -> T {
    a + b - c
}

這回總共有 3 個參數,它們都是 T 型別,但為了要可以 + 也可以 -,所以同時需要 AddSub 這兩個特徵。這樣寫起來讓這個 T 看起來有夠囉嗦,利用 use 關鍵字可以讓整個看起來短一點點:

use std::ops::{Add, Sub};

fn calc<T: Add<Output = T> + Sub<Output = T>>(a: T, b: T, c: T) -> T {
    a + b - c
}

但這個 calc() 函數看起來還是太囉嗦了,Rust 有提供另一種寫法,可以使用 where 關鍵字,把這些囉嗦的限制移到方法定義的後半段:

fn calc<T>(a: T, b: T, c: T) -> T
where T: Add<Output = T> + Sub<Output = T>
{
    a + b - c
}

以結果來說,雖然需要寫的程式碼並沒有比較少,但至少讓 calc 的函數簽名看起來清楚一些。

靜態分發 vs 動態分發

在「特徵(Trait)」章節曾經介紹過「多型」的概念,在 Rust 可以透過 Trait Object 來做到多型的效果,當時的範例程式碼大概是這樣寫的:

fn bungee(someone: &dyn Flyable) {
    someone.fly();
}

我不在乎傳進來的 someone 是什麼型別,只要你有實作 Flyable 的特徵就好。現在大家看完泛型的介紹,會不會覺得這種 Trait Object 的寫法好像跟泛型有一點點像?另外,前面的 &dyn 又是什麼意思?

其實使用泛型也能做到多型的效果,我可以把原本的程式碼用泛型的方式改寫:

fn bungee<T: Flyable>(someone: &T) {
    someone.fly()
}

傳進來的 someone 參數雖然可以是任意的 T 型別,Rust 也不在乎它是什麼型別,但它必須有 Flyable 的特徵,這樣執行才不會出錯。

不管是藉由 Trait Object 或是泛型,都能實現多型的效果。不過 Trait Object 一次只能有一種 Trait,而泛型可以透過加號 + 串連多個 Trait,像是前面介紹的 Add<Output = T> + Sub<Output = T>

另外一個比較大的差別,是泛型是使用「靜態分發(Static Dispatch)」的方式來實現多型,而 Trait Object 則是使用「動態分發(Dynamic Dispatch)」,前面看到的 &dyn 就是動態的意思。

靜態分發是指在編譯階段就會確定函數呼叫的方式以及參數、回傳值的型別,前面提到 Rust 在處理泛型的時候,會在編譯階段會建立該建立的資料結構,所以實際執行的時候效能不會因此而有折扣。靜態分發的好處是執行時期效率好,但因為 Rust 在編譯過程都幫你生成相對應的資料及函數,缺點就是整體的程式碼會變的比較肥一點(Code Bloat)。

相對的動態分發是在運行時才決定這些事,跟靜態分發相比,動態分發的靈活性較大,也不會像靜態分發這樣產生所有用到的資料,但也因此需要在執行的時候查找及選擇具體實作的方式,靈活的代價就是需要額外付出一些效能。