問題:一個演算法,許多型別
到現在你已經能在 C++ 裡造出一個不會洩漏的擁有式容器——一個 unique_ptr、一個小型向量、一個持有資源的節點。但仔細看你會發現,每一個都和單一型別綁死了。一個 `int` 的堆疊和一個 `double` 的堆疊,是一字不差的同一個演算法,只差在型別這個字。在 C 裡你有兩條老實卻醜陋的逃生路:把程式碼寫兩遍(然後永遠維護兩份),或用 `void *` 把型別抹掉、從而失去所有型別檢查。兩者都不好,而你在前面幾級早已嘗過兩者的刺痛。
樣板(template)是 C++ 的第三條解答,也是那條不花成本的解答。你把演算法只寫一次,把型別留成一個之後再填的空格——一個叫 `T` 的參數,而非某個具體型別。這就是泛型程式設計:以型別為參數、而不只是以值為參數的程式碼。首先要抓住的關鍵是:樣板本身並不是會執行的程式碼。它是一個模式,編譯器讀它,然後從它產生程式碼,每一個你真正用到的相異型別產生一份。把它想成一張型版:型版本身不是顏料,但你想壓幾次真顏料穿過它都行。
template <typename T>
T max_of(T a, T b) {
return (a < b) ? a : b == b ? (a < b ? b : a) : a; // illustrative; see below
}
// the honest, normal version:
template <typename T>
T maxv(T a, T b) { return (a < b) ? b : a; }
int x = maxv(3, 7); // compiler stamps out maxv<int>(int, int)
double y = maxv(2.5, 1.5); // and a SEPARATE maxv<double>(double, double)
// two real functions now exist in the binary, each compiled for its own type單型化:魔法(與成本)藏在哪裡
編譯器把空格填上、產出具體函式的那一步叫做具現化(instantiation),而為每個型別產生一份全新、完整定型副本的整體策略叫做單型化(monomorphization)——字面意思是「把它變成單一型別」。當你呼叫 `maxv(3, 7)`,編譯器推導出 `T = int`、具現化出 `maxv<int>`,從那一刻起它就只是一個收兩個 int 的普通函式。執行期不再有 `T` 殘留、沒有型別標記隨資料一起跑、程式執行時也不會做任何「該呼叫哪個版本」的決定。型別完全在編譯期就解決了。
這正是為什麼 C++ 的泛型(以及同樣採用單型化的 Rust)很快。每一份具現化的副本都針對它確切的型別特製,於是最佳化器看見的是它能內聯與調校的具體操作。把這和另一條策略型別抹除(type erasure)比一比:後者用單一一份已編譯的函式、透過指標或虛擬表來處理所有型別——那是動態分派,每次呼叫都付一次間接跳轉。樣板則把一切靜態地解決,於是一次進入樣板容器的呼叫,會被編譯成你若針對那一個型別親手寫、會得到的同一批指令。
樣板:把零開銷原則具體化
回想 RAII 那篇講過、由 Bjarne Stroustrup 分成兩半陳述的零開銷原則:你不為你沒用到的東西付費,而你用到的東西其效率等同手寫程式碼。樣板正是後半句針對抽象的兌現。一個以泛型寫成的 `std::sort`,當為你的型別具現化時,產出的機器碼緊湊得如同你針對那一個型別親手寫的排序——往往更緊湊,因為比較運算可以被直接內聯進迴圈,完全沒有函式呼叫的開銷。
把它擺在系統程式設計師真正會碰到的那些替代方案旁邊,這份勝利就格外鮮明。C 裡以 `void *` 為基礎的泛型無法內聯它的回呼——比較運算要穿過一個最佳化器看不透的函式指標,於是每一次元素比較都是一次真正的間接呼叫。一個有垃圾回收的語言則為了讓小值泛型化,把它們裝箱(box)到堆積上。樣板兩種代價都不付:型別在編譯期就已知,於是最佳化器看見一切、能把迴圈完全特製化。這就是 C++ 樣板與 Rust 泛型之所以能讓你寫出一個富表達力的抽象、卻仍能達到客製程式碼效能的深層原因。
概念:為空格加上約束
樣板有一個出了名的銳利邊緣。因為 `T` 只是個空格,編譯器唯一能做的檢查就是試著代入、看本體能不能編譯。如果你用一個沒有 `<` 運算子的型別呼叫 `maxv`,錯誤不會出現在你的呼叫處——它會在樣板本體深處爆發,往往是一大片提及你從未寫過的具現化的文字牆。數十年來這就是你為泛型程式碼付的稅,並用一個叫 SFINAE(「代入失敗不算錯誤」)的脆弱技巧勉強遮掩,那技巧讓函式庫作者能藏起某型別無法滿足的多載,代價卻是難以閱讀的程式碼。
概念(concepts),於 C++20 加入,誠實地修好了這件事。一個概念是一個具名、由編譯器檢查的「型別述詞」:一種在樣板自己的簽章裡就說出「`T` 必須支援 `<`」或「`T` 必須是整數」的方式。如果你用一個無法通過該概念的型別去呼叫函式,錯誤現在會指向你的呼叫處、並讀起來像一句話——「該型別不滿足 `std::totally_ordered`」——而非把樣板的內臟吐了一地。關鍵在於,概念是純編譯期的檢查:它約束哪些具現化被允許,卻不為執行中的程式增加一個位元組、也不增加一個週期。它讓泛型程式碼更安全、診斷更有人性,而執行期成本為零。
一步步讀懂一次樣板具現化
為了把這一切變具體,讓我們追蹤編譯器第一次遇到呼叫 `auto v = maxv(2.5, 1.5);` 時究竟做了什麼。跟著這個順序走,並留意:每一個有趣的決定都發生在程式跑起來之前。
- 引數推導:編譯器看著引數 2.5 與 1.5,發現兩者都是 double,於是為這次呼叫推導出 T = double。
- 約束檢查:如果 maxv 帶有概念(譬如要求 std::totally_ordered<T>),編譯器會驗證 double 是否滿足。若不滿足,你就在這裡、在呼叫處得到一個清楚的錯誤。
- 具現化:編譯器把樣板本體印製出來,將其中的 T 換成 double,產出一個具體函式 maxv<double>(double, double)。不再有任何 T 殘留。
- 最佳化:那個具體函式現在就是普通程式碼。最佳化器把它內聯進呼叫者,比較運算變成單一一道 double 比較指令——零抽象開銷。
- 重用:之後的 maxv(9.0, 4.0) 再次推導出 double,並重用同一個 maxv<double>;只有一個全新的型別才會觸發一次新的具現化。
退一步看,本級的整道弧線就對齊了。RAII 給了你不花執行期成本的確定性清理;移動語意讓資源不必複製就能遷移;智慧指標把所有權寫進了型別;而現在樣板讓你把那些擁有式、懂得清理的抽象只寫一次,並在每一種型別上都保持零開銷。下一篇、也是最後一篇,把時間軸再往左推——推到 constexpr 與 consteval,那裡整段運算在編譯期間就跑完——並看看自訂配置器與非擁有檢視,它們讓你精確掌控記憶體從何而來,同時維持你現在已從四個面向見過的「抽象卻不抽稅」的那筆交易。