一個函式,只寫一次,給每一個型別
在上一篇你認識了呼叫一個 trait 方法的兩種方式。像 `&dyn Draw` 這樣的trait 物件帶著一個指向 vtable 的指標,在執行期查出正確的方法——一份程式碼適用於任何型別,代價是一次間接呼叫。這一篇講的是另一條路,也就是 Rust 預設走的那條。當你寫下一個泛型(generic)函式,例如 `fn largest<T: Ord>(items: &[T]) -> &T`,你並不是在寫一個於執行期處理多種型別的函式。你寫的是一個範本,編譯器將用它替你實際呼叫的每一個型別,製造出一個獨立的、具體的函式。
那個 `<T: Ord>` 是一個trait 界限,它就是讓這一切成為可能的契約。它告訴編譯器:T 可以是任何東西,但無論它是什麼,它都必須實作 Ord,所以我可以拿它的兩個值來比較。`largest` 的本體只被型別檢查一次,而且只對著那份契約檢查——不是對著任何特定的 T。於是編譯器能在它還完全不知道你會用數字還是字串來呼叫之前,就證明這個函式是正確的。這一次只對界限、而非對每個具體型別所做的檢查,正是泛型有別於你親手複製貼上函式的地方。
單型化:把副本壓印出來
這就是核心的機器。當編譯器做完型別檢查、開始產生程式碼時,它會走過你的程式,找出每一個泛型實際被用到的具體型別。對每一個不同的型別,它會做出函式的一份新副本,把 T 換成那個真實型別,然後把那份副本當作你親手寫出來的一樣編譯。這個過程有個沉重的名字——單型化——但觀念很樸素:把一個多型(poly-morphic,多種形態)的函式變成好幾個單型(mono-morphic,單一形態)的函式。這個詞照字面就是「化為單一形態」。
把它變具體。假設你用一個 i32 的切片呼叫 `largest` 一次,又用一個 char 的切片呼叫一次。單型化之後,二進位檔裡會包含兩個函式,大致是 `largest_i32` 和 `largest_char`,各自把比較與解參考都針對那個型別特製過編譯。產生的程式碼裡哪裡都不再有 T,沒有描述你拿的是哪個型別的執行期標記,沒有查找。下面這張草圖顯示了編譯器實際上從單一份泛型原始碼產出了什麼。
// you write ONE generic:
fn largest<T: Ord>(items: &[T]) -> &T { /* compare with < */ }
// call sites:
largest(&[3i32, 7, 1]); // uses T = i32
largest(&['c', 'a', 't']); // uses T = char
// compiler stamps out TWO concrete functions (conceptually):
fn largest_i32 (items: &[i32]) -> &i32 { /* i32 compare, inlined */ }
fn largest_char(items: &[char]) -> &char { /* char compare, inlined */ }
// no T, no vtable, no runtime dispatch anywhere因為每一份副本都只是一個帶具體型別的普通函式,最佳化器就能把它當成任何其他普通函式來對待。它可以內聯(inline)那個比較、摺疊常數、把值留在像 rax 這樣的暫存器裡而不必溢出,還能展開迴圈。這些透過 vtable 指標一概辦不到,因為在那裡,被呼叫的程式碼要到執行期才知道。這正是第二篇那個取捨的裡外翻轉:動態分派是一份程式碼副本搭配一次執行期查找;經由單型化的靜態分派則是多份副本、沒有查找,且每一份都被完整最佳化。
零成本:這句口號到底主張什麼
Rust 向 C++ 借了一句口號:零成本抽象(zero-cost abstraction)。人們聽到就以為它是說「免費」,那會是個謊言,所以讓我們照零開銷原則的方式,把真正的主張精確地說出來。它是兩個承諾。第一:你沒用到的東西,你不付錢。第二,也是這裡要緊的那部分:你用到的東西,你親手寫也合理地寫不出更好的版本。為 i32 編譯出來的泛型 `largest`,和你自己用樸素的、型別專屬的 Rust 寫出 `largest_i32` 所產生的,是同一份機器碼。那個抽象——把演算法為所有 Ord 型別只寫一次——在編譯期消失了,沒有在執行期留下任何殘渣。
對代價究竟跑到哪去要誠實,因為它並沒有從宇宙裡消失——它只是搬家了。執行期變快了,而另外三件事替它付了帳。編譯器做了更多工,因為它現在編譯的是 N 份副本而不是一份。二進位檔變大了,因為那 N 份副本都佔空間——這就是程式碼膨脹(code bloat),在一個大量使用泛型的程式上它是真實且可量測的。而編譯時間也因為同樣的理由變長。「零成本」只是一個關於執行期的陳述。對一個熱的內層迴圈它是對的取捨,對一個很少被呼叫卻在五十個型別上具現化的函式它是個糟糕的取捨,而這正是 Rust 也把 `dyn` 留著的原因。
編譯器實際上如何決定要壓印什麼
走一遍這條流水線會很有幫助,因為它同時解釋了你會看到的速度與錯誤訊息風格。單型化不是魔法;它是一道具體的處理過程,發生在泛型已經被檢查過之後。以下是單一一次泛型呼叫的事件順序。
- 把泛型檢查一次。`largest<T: Ord>` 的本體只對著界限 Ord 做型別檢查。如果它試圖做任何 Ord 沒承諾的事,你就在這裡得到錯誤,此時還沒有任何 T 被釘死。
- 蒐集真實型別。編譯器掃描這次建置中所有可達的呼叫處,記錄下每個泛型被具現化時用的具體型別:在這裡是 i32 與 char。
- 每個不同型別壓印一份副本。對 i32 它產出一個全程把 T 代換為 i32 的函式;對 char,再產出一個 T=char 的。兩個原始型別,兩個函式。
- 獨立地最佳化每一份副本。現在它們是普通的具體函式,於是最佳化器內聯比較、配置暫存器、展開迴圈——全速,逐型別。
- 去除重複的相同副本。如果兩個不同型別產出位元組完全相同的程式碼(對同樣記憶體佈局的型別很常見),連結器可以把它們摺回成一個,討回一點點膨脹。
從這個順序掉出兩個後果,兩個都值得帶在身上。第一,只有你實際拿來呼叫的型別才會被壓印——一個沒用到的泛型在二進位檔裡不花任何成本,因為沒有任何具體型別走到第三步。第二,這就是為什麼一個泛型能在你沒寫多少程式碼的情況下把你的編譯時間炸開:一個簡短的 `<T>` 函式被二十個型別呼叫,就是二十個真實函式要最佳化。靜態對動態分派的選擇,歸根究柢,是一個關於這兩種成本你寧可付哪一種的選擇。
泛型與 Rust 其餘部分的交會處
單型化不是一個角落功能;它是標準函式庫底下大部分東西的引擎。`Vec<T>`、`Option<T>`、`HashMap<K, V>`,以及整套迭代器機制都是泛型的,每一個都會為你填進去的型別重新壓印出來——在編譯後的程式裡,`Vec<u8>` 和 `Vec<String>` 是真正不同的具體型別,不是一個被抹除的容器。這就是為什麼一串像 `.map(...).filter(...).sum()` 的迭代器轉接器,會編譯成和你親手寫一樣緊湊的同一個迴圈:每個閉包與轉接器都是一個具體型別,被單型化並內聯,什麼都沒剩下。那種高階、近乎函數式的風格在執行期不花成本,正是因為這一篇講的這套機制。
它也解釋了一對你會反覆遇到的特性。一個像 `impl<T: Display> ToString for T` 的涵蓋實作(blanket implementation),一次就為每個滿足某界限的型別定義了一個方法;接著由單型化為你實際用到它的每個型別產出一個具體的 `to_string`。而這個設計刻意呼應了 C++ 範本——兩者都在編譯期壓印出逐型別的程式碼,兩者也都可能讓二進位檔膨脹——差別就在前面那則註記說的,Rust 先對著界限檢查泛型,而不是只在具現化時才檢查。同樣的零成本執行期,更友善的編譯期故事。
於是你現在握有第二篇那個岔路的完整圖像。當程式碼很熱、型別在編譯期就已知、而且你想讓最佳化器看穿這個抽象時,就去拿泛型與靜態分派——代價是較大的二進位檔與較慢的建置。當你需要一個異質的集合、想讓二進位檔保持小巧,或真的要到執行期才知道型別時,就去拿 `&dyn Trait`——代價是每次使用一次間接呼叫。兩者都不是「進階」的那個選項;它們是兩個誠實的取捨,而一個成熟的 Rust 程式會刻意地兩者都用。