「zero-cost abstraction」是 systems 圈最常拿來背書的口號,但 Odin 作者最近把帳攤開來算:四個型別、五個引數上限的印表機,編譯期就能生出 1365 份特化——而執行期查表的版本,程式碼永遠只有一份。
編譯期展開是指數,執行期查表是線性
CTTI(compile-time type information,編譯期型別資訊)跟 RTTI(runtime type information,執行期型別資訊)是兩種完全不同形狀的成本模型。Odin 語言作者 gingerbill 在 9 月 2 日發表的文章裡把話講白:CTTI 被賣成「zero-cost」,但它其實一點都不是零成本;RTTI 有代價,卻是一個溫和、可預期的代價。他自己在 2016 年設計 Odin 時就直接選了 RTTI,這篇文章某種程度上是在替那個十年前的決定算一次總帳。選擇落在哪一邊,決定的是程式在編譯期跟 binary 裡要付出多陡的成長曲線。
這件事不是純學術辯論。C++ 樣板、Rust 泛型這類語言長期把 CTTI 賣成效能不打折的甜蜜點——inline、沒有虛擬呼叫、編譯期就把型別檢查做完。但「甜蜜點」的前提是型別參數的組合數量始終很小;一旦某個 API 想支援任意型別(印表機、序列化、反射式的表單綁定),組合數就會從小到可以忽略滑向大到讓編譯器跟 binary 一起變慢,而這條滑坡通常沒有明顯的警示燈提醒你踩過了門檻。
把兩邊的成本攤開放進同一張表最直接——下面四個維度是文章反覆落在的落點:語意檢查、程式碼生成、binary 大小、執行期成本。
| 維度 | CTTI | RTTI |
|---|---|---|
| 語意檢查 | 每個型別組合各自重新檢查一次 | 實質上零額外成本,沒有東西需要特化 |
| 程式碼生成 | 最壞情況生成 N^K 份函式本體 | 只有一份程式碼,永遠同樣大小與形狀 |
| Binary 大小 | 組合數本身是指數的,每份都要塞進執行檔 | 只多一張表,通常放進唯讀資料段,大小跟型別數線性相關 |
| 執行期成本 | 已特化,不需要額外查表 | 多一次表格查找/間接跳轉 |
這篇文章發表後很快被轉貼到 Lobsters,掛在 compilers 與 plt 兩個標籤下,累積 16 點、10 則留言——這只能算是同一篇文章的熱度訊號,不是另一方獨立查證,但足以說明這個論點在系統程式語言圈子裡引起的討論,不只是作者自說自話。
表格第一眼就能看出落點:CTTI 那一欄幾乎每一格都在講「每個組合各自重做一次」,RTTI 那一欄幾乎每一格都在講「一份、一次、線性」。這不是修辭上的巧合,是兩種模型從根本上把型別資訊放在不同地方造成的結果——一個編入程式碼的結構裡,一個存進資料裡。後面四段把這四格背後的演算法跟機制拆開講。
K 個型別參數會生出幾份特化:N^K 的演算法
公式本身很單純:K 個型別參數,每一個都可能是 N 個型別裡的任何一種,最壞情況下的特化數量上界就是 N 的 K 次方。文章給的錨點例子是 K=2:「In the case of `K=2`, the maximum number of instances of a procedure is `N²`.」——兩個型別參數的操作最多要生 N² 份。K 繼續往上加,這條曲線不是線性疊加,是逐級相乘。
拿一個具體語言場景套進去更清楚:假設一個語言只有 4 個型別——int、float、string、bool——外加一個 variadic、parametrically polymorphic 的印表機函式(「Consider a language with only 4 types (e.g. `int`, `float`, `string`, `bool`) and a variadic, parametrically polymorphic printing procedure.」)。只要把引數上限定在 5 個,最多就能生出 1365 份函式特化(「suppose you never print more than 5 arguments, that allows up to 1365 instantiations」)——這還只是 4 種型別、5 個引數位置的排列組合,日常一個 debug print 函式就能踩到。
這條曲線對輸入極度敏感。同一個 5 引數上限下,把型別數從 4 種加到 5 種,數字從 1365 跳到 3906(「Add another type and it becomes 3906.」);再把這 5 種型別的引數上限從 5 個推到 6 個,數字繼續衝到 19531(「Raise the maximum to 6 arguments and it becomes 19531.」)。這兩個數字——1365 跟 19531——不是同一組座標算出來的:前者是 4 種型別、5 個引數,後者是 5 種型別、6 個引數,但方向完全一致,不管是加一種型別還是加一個引數位置,曲線往上走的速度都遠比直覺快。
下面這個 widget 把 N 固定在文章原本的 4 種型別,讓你拖 K 從 1 一路拉到 6,看實際特化數字怎麼跳。RTTI 那條參考線幾乎貼著底部——因為它的表格大小只跟型別數 N 有關,跟 K 完全無關。
拖動滑桿改變 K(型別參數個數)· N 固定為 4 種型別 · 6 個離散值
從 K=1 到 K=6,CTTI 那排柱狀從 5 一路衝上 5461;RTTI 那條參考線始終停在 4——就算把引數上限拉到 6,它也不會挪動半格,因為 RTTI 從來沒有要為每個組合各生一份特化。
指數成本打在三個地方:語意檢查、程式碼生成、binary 大小
這個爆炸不是單一階段的帳。文章講得很直接:「CTTI's worst case is exponential, in three places at once (semantic checking, code generation, and binary size).」——語意檢查、程式碼生成、binary 大小,三個地方同時被同一條指數曲線打到,不是某個階段吃掉全部代價、其他階段照舊。編譯器要為每個組合各跑一次型別檢查,要為每個組合各生一份程式碼,最後每一份都要塞進同一個執行檔——三筆帳疊在一起算。
語意檢查這一步常常被工程師低估,因為它不會出現在最後的執行檔裡,卻實實在在地吃掉編譯時間。CTTI 底下,每一種可能出現的型別組合都要被編譯器各自檢查一次,組合數是多少,這一步就要重做幾次,跟執行期實際會不會用到這份特化無關。RTTI 在這一步的成本是「effectively **zero** additional cost during semantic-checking, because there is nothing to specialize」——實質上不用重跑這一步,因為程式裡自始至終只有一份走查表邏輯的程式碼,型別檢查只需要對這一份程式碼跑一次,不管執行期實際會遇到幾種型別。
三個戰場不是先後發生、可以互相替代的關係,是同一份原始碼在編譯管線的三個不同階段各自留下一筆帳——語意檢查、程式碼生成、binary 大小分別發生在編譯的不同時間點,指數成本在三個時間點各記一次,不是把同一筆代價攤開來算三次。下面這組卡片把三個戰場攤開,每張卡對照 CTTI 跟 RTTI 在同一個階段各自的樣子——點開任一張看細節。
點選任一戰場看 CTTI 跟 RTTI 的對照 · 3 個戰場
指數成本的三個戰場
點選任一戰場看細節
語意檢查
CTTI 每個 K、N 組合都要各自重新做一次型別檢查,組合數等於要重跑幾次。RTTI 在這階段是「effectively zero additional cost during semantic-checking, because there is nothing to specialize」——實質上沒有東西要特化,自然沒有東西要多檢查一次。
程式碼生成
CTTI 最壞情況生成 N^K 份函式本體。RTTI 版本的程式碼「will always be the same size and shape, as the only thing that grows is the type-table itself」——處理型別資訊的程式碼永遠只有一份。
Binary 大小
CTTI 生成的每一份特化都要塞進最終執行檔,組合數本身是指數的,binary 自然跟著炸。RTTI 通常只多一張表,「Typically (and hopefully) this type-table then resides in a read-only data section」,表格大小跟型別數 N 線性相關。
三張卡片攤開後會發現一個規律:CTTI 每一格都在為每個型別組合重做一次工作,RTTI 每一格都只做一次、然後讀表。這不是巧合,是兩種模型的根本差別——一個把型別資訊編入程式碼結構,一個把型別資訊存進資料。
RTTI 的反例:一份程式碼、一張唯讀表
RTTI 版本的程式碼「will always be the same size and shape, as the only thing that grows is the type-table itself which it reads from」——不管程式裡用了幾種型別,處理型別資訊的那份程式碼永遠只有一份,唯一會變大的是表格本身,而表格通常會被放進唯讀資料段(「Typically (and hopefully) this type-table then resides in a read-only data section (e.g. `@(rodata)` in Odin)」),也就是說執行期不會有任何寫入動作碰它。
表格一筆項目裡存的欄位也不是隨便塞的——size、alignment、kind、fields,加上「語言設計者覺得值得留的其他資訊」(「such as its size, its alignment, its kind, its fields, and whatever the language/language-designer decides is worth keeping」)。這個「其他資訊」不是固定死的欄位清單,原文特別用「whatever the language/language-designer decides is worth keeping」把它留成一個開放集合——換句話說,表格的欄位設計本身也是語言設計者的一個選擇,不是規格書上鎖死的東西。這裡的「通常」也值得注意:作者用「Typically (and hopefully)」而不是斷言,代表把型別表放進唯讀資料段是常見作法,不是語言規範保證的行為,如果某個實作把表放進可寫記憶體,理論上大小仍然是線性成長,只是少了唯讀帶來的快取局部性與防止意外覆寫的保證。
RTTI 換來的編譯期跟 binary 優勢,代價落在執行期:每一次用到型別資訊的呼叫,都要先查一次表,再從查到的位置間接跳轉到實際要執行的邏輯,文章原文把這筆帳記在「table lookup/indirection」這一項。CTTI 版本因為每個型別組合各自有一份寫死的程式碼,執行期完全不需要查表或間接跳轉——這筆帳從一開始就不存在,不是被省下來的。這是 RTTI 唯一沒辦法完全甩掉的成本:查表本身很便宜,但終究不是零。
Odin 官方文件把這套機制的實際 API 攤開來看:一個 typeid 「A typeid is a unique identifier for an Odin type. This construct is used by the any type to denote what the underlying data's type is.」——每個型別的唯一識別碼。 是每個型別的唯一識別碼; any 「An any type can reference any data type. Internally it contains a pointer to the underlying data and its relevant typeid.」——內部就是一個資料指標加上這個 typeid,合法期綁在底層資料的生命週期上,文件警告「do not use this unless you know what you are doing」。 內部就是一個資料指標加上這個 typeid;要拿到完整的型別資訊,得透過 @(rodata) 唯讀資料段。文章原文:「this type-table then resides in a read-only data section (e.g. @(rodata) in Odin)」——型別表通常整段放進這裡,執行期不會有寫入動作碰它。 裡那張表,用 typeid_of 跟 type_info_of 查出來。
也正因為 RTTI 不需要特化,語意檢查這一階段的成本是「effectively zero additional cost during semantic-checking, because there is nothing to specialize」——實質上沒有東西要特化,自然沒有東西要多檢查一次。這跟 CTTI 每個型別組合都要重新跑一次型別檢查,是完全相反的起點。RTTI 的代價沒有消失,只是換了位置:Odin 文件坦白 any 的合法期綁死底層資料的生命週期,字面值傳進去會在目前 stack frame 配置——這是生命週期風險,不是編譯期爆炸。
Rust 跟 Odin 各自止血:compiler edge case、serde、struct field tags
不是所有語言都選 RTTI。用 CTTI 做印表機的語言(文章點名 Rust)「try to mitigate this disaster with an explicit edge case in the compiler that tries to minimize this explosion in compiler complexity」——Rust compiler 內建一條特殊路徑去壓縮印表機這個常見場景的組合爆炸。文章緊接著保留一句:這條路不必然解決中大型專案的 binary 大小問題,緩解的只是編譯器複雜度那一面。
另一條路是顯式 tagging,把爆炸換成另一種形狀的複雜度:「Some languages also try to mitigate the combinatorial explosion with explicit tagging to produce the CTTI-related code generation, forcing a multiplicative complexity instead. For example, `serde` in Rust can be used for CLI parameters, GUI forms, pretty printing, etc.」合理的推測是:serde 大概率是靠在編譯期展開 trait 實作來處理序列化,把「N^K 排列組合」問題換成「每個型別各自宣告一次 tag」的乘積式複雜度——文章本身沒有描述 serde 的內部機制,只說顯式 tagging「forcing a multiplicative complexity instead」;複雜度沒有消失,只是換了一個比較容易管理的形狀。這句話值得多讀一次,因為它把兩種解法擺在同一個句子裡對照:serde 用顯式 tagging 換來乘積式複雜度,Odin 則直接用 RTTI 加 struct field tags 處理同一批需求。兩者都在承認 CTTI 原生做法行不通,只是選的替代路線不一樣——一個留在編譯期展開的世界裡換一種複雜度形狀,一個乾脆把問題整個搬到執行期查表解決。
作者自己在 Odin 做的是另一個選擇:「All of this I implement in Odin with RTTI and struct field tags, which I find a lot easier to deal with.」同樣是「型別要攜帶額外中繼資料」這個需求,Odin 用 RTTI 加 struct field tags 處理,不必為每個型別組合各自生一份程式碼。這句話帶著作者本人的偏好色彩,不是量化 benchmark,但反映了他選 RTTI-by-default 的理由:同一批需求,查表比展開好維護。
文章推導語意檢查成本時提到的那句話——「Assuming C++/Odin style parametric polymorphism」——說明整套 N^K 推導本來就是拿 C++ 樣板系統當標準案例在寫,不是只套用在 Odin 這個相對小眾的語言上。合理的推測是:C++ 樣板系統沒有 Rust 那種印表機專用的 compiler edge case,也沒有 serde 這樣成熟的顯式 tagging 生態,樣板特化爆炸出來的每一份程式碼,通常就是原封不動地被吃進最終的 object file 跟 debug info 裡,而且正好同時踩中前面講的三個戰場——語意檢查、程式碼生成、binary 大小——只是文章本身沒有針對 C++ 給出獨立的量化數字,這裡用的是同一套 N^K 公式套用到 C++ 樣板系統上的邏輯推論,不是文章直接測出來的 benchmark。
完整看一次作者的總結,會更清楚兩邊各自把成本記在哪本帳上:「RTTI has a linear cost in the number of types, which is then paid at runtime (table lookup/indirection), binary size, and memory usage. CTTI has a exponential cost in the number of types in the worst-case, and multiplicative in the general-case, which is then paid at compile-time (checking and code gen), and binary size.」RTTI 的三筆帳——查表/間接跳轉、binary 大小、記憶體用量——全部是線性的;CTTI 的帳記在編譯期(檢查跟 codegen)跟 binary 大小上,最壞是指數,一般情況是乘積式。兩邊都不是免費,差別在於帳單付款的時間點,跟成長曲線的形狀。
這不是紙上談兵的原則。作者在 2016 年設計 Odin 時就直接選了 RTTI(「When I started Odin back in 2016, I went straight for RTTI without hesitation.」),文章十年後把這筆帳重新算一次,用具體數字撐起當年的直覺——1365 跟 19531 不是事後編出來的教學案例,是照文章假想語言的設定(4 個型別、印表機函式)算出來的結果,文章沒有宣稱這是 Odin 實際會遇到的場景。
怎麼選:CTTI 什麼時候划算
作者的立場很鮮明:「when designing a language, I'd argue for using RTTI by default pretty much always, and only using CTTI when you absolutely require it」——這是他個人主張的語言設計原則,不是業界共識,但背後的判斷邏輯值得工程師借用。先問一個操作會不會被 K 個獨立的型別參數呼叫,再問這些參數各自可能落在幾個型別(N)身上。
如果 K 跟 N 都小、組合數量在編譯期就能窮舉完——例如一個固定簽名的 hash 函式套三種整數型別——CTTI 換來的型別安全與不必查表的執行路徑通常划算,這種場景組合數不會失控,指數曲線根本還沒起飛。如果 K 或 N 會隨使用場景自然增長——通用印表機、序列化、任何「支援任意型別」的 API——N^K 就會在沒注意的時候把編譯時間、語意檢查負擔、binary 大小一起拖走,這時候一份程式碼、一張線性表的 RTTI 才是更安全的預設。Rust 的 compiler edge case 跟 serde 的顯式 tagging 都是在承認這個事實之後找出路,而不是證明 CTTI 本來就沒問題。
具體一點的判斷方法是:先寫下操作簽名裡有幾個獨立的型別參數位置(K),再寫下這些位置實際可能吃進幾種型別(N),把兩個數字套進 N^K 估一個上界。如果這個上界還停在兩位數,CTTI 通常還在編譯器負擔得起的範圍;如果它輕鬆衝到三位數以上,而且 K 或 N 會隨 API 使用者的需求持續增加,那就是該把型別資訊搬進資料、換一張 RTTI 表的訊號——四型別、五引數的印表機衝到 1365 這件事本身,就是最好的門檻示範。
把這張總帳擺在眼前,工程師真正要做的判斷其實只有一件事:能不能預先幫每一種可能的型別組合都準備好對應的知識?如果可以,而且組合數不多,CTTI 把這份知識編入程式碼結構、執行期不用再查表,是穩賺的。如果組合數會隨使用者需求持續變化、沒辦法在編譯的當下窮舉所有情況,RTTI 把知識放進一張隨時可以查的表,反而是唯一撐得住的做法。
Take-away:判斷該不該為某個操作做泛型特化,先算 K(型別參數個數)跟 N(可能型別數)會不會隨場景增長——會的話,一張唯讀型別表換一份不變的程式碼,通常比讓編譯器展開 N^K 份特化更划算。