JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

樣板、概念與零開銷抽象

樣板是一份食譜,編譯器會為你用到的每一種型別把它印製成真實、特製的程式碼——於是一個只寫一次的容器,對 int、字串或你自己的結構都一樣快。本篇講解這套機制怎麼運作、為什麼它在執行期不花任何成本,以及概念如何讓它的錯誤訊息變得可以忍受。

問題:一個演算法,許多型別

到現在你已經能在 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
一個樣板,只寫一次。編譯器為 int 和為 double 各自產出一個相異的具體函式。

單型化:魔法(與成本)藏在哪裡

編譯器把空格填上、產出具體函式的那一步叫做具現化(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);` 時究竟做了什麼。跟著這個順序走,並留意:每一個有趣的決定都發生在程式跑起來之前。

  1. 引數推導:編譯器看著引數 2.5 與 1.5,發現兩者都是 double,於是為這次呼叫推導出 T = double。
  2. 約束檢查:如果 maxv 帶有概念(譬如要求 std::totally_ordered<T>),編譯器會驗證 double 是否滿足。若不滿足,你就在這裡、在呼叫處得到一個清楚的錯誤。
  3. 具現化:編譯器把樣板本體印製出來,將其中的 T 換成 double,產出一個具體函式 maxv<double>(double, double)。不再有任何 T 殘留。
  4. 最佳化:那個具體函式現在就是普通程式碼。最佳化器把它內聯進呼叫者,比較運算變成單一一道 double 比較指令——零抽象開銷。
  5. 重用:之後的 maxv(9.0, 4.0) 再次推導出 double,並重用同一個 maxv<double>;只有一個全新的型別才會觸發一次新的具現化。

退一步看,本級的整道弧線就對齊了。RAII 給了你不花執行期成本的確定性清理;移動語意讓資源不必複製就能遷移;智慧指標把所有權寫進了型別;而現在樣板讓你把那些擁有式、懂得清理的抽象只寫一次,並在每一種型別上都保持零開銷。下一篇、也是最後一篇,把時間軸再往左推——推到 constexpr 與 consteval,那裡整段運算在編譯期間就跑完——並看看自訂配置器與非擁有檢視,它們讓你精確掌控記憶體從何而來,同時維持你現在已從四個面向見過的「抽象卻不抽稅」的那筆交易。