C++26 反射讓 type erasure 不必手寫 vtable
Ryan Keane(isocpp.org 轉載) · 2026-09-30
C++ 的 type erasure 過去得手寫一整套 concept/model/vtable 樣板,或是背上 Boost.TypeErasure 這類重型依賴;C++26 反射讓這層樣板改由編譯器生成。Ryan Keane 的 header-only 函式庫 rjk::duck 只要在介面上標 [[=rjk::trait]],就能產生 vtable 並保留 vtable 分派。
該函式庫目前只支援帶 -std=c++26 -freflection 的 GCC(gcc-trunk),授權為 Boost Software License 1.0。
原本的問題
想讓 vector、string、map 這些互不相干的型別藏在同一個值型別後面,傳統做法有兩條。一條是繼承:要求每個型別都繼承同一個基底類別,第三方型別根本改不動。另一條是手寫 type erasure:自己寫抽象基底、模板 model、包裝類別,每加一個成員函式就要在三處同步修改。
函式庫方案(Boost.TypeErasure、Folly.Poly)可以省掉手寫,但文章指出這些方案帶來額外依賴,而且客製化空間有限。沒有反射,編譯器無法列舉介面的成員函式,所以樣板只能由人或巨集補上。
核心改動
使用者只需宣告一個普通的 struct 描述介面,再用 rjk::duck<Container> 存放任何符合該介面的型別:
struct Container {
auto size() const -> std::size_t;
auto empty() const -> bool;
};
rjk::duck<Container> c{std::vector<int>{1, 2, 3}};
c = std::string{"hello"}; // 執行期換掉底層型別背後有三個機制,都來自該文章的描述:
- 註解加反射:以
[[=rjk::trait]]標記介面,函式庫用std::meta::info走訪成員函式,轉成像has_fn<"foo", auto() -> int>這樣的編譯期標籤。 - 生成 vtable:用
define_aggregate產生存放函式指標的結構,每個 trait 成員一個欄位,並處理 const 限定。 - 重載解析交給編譯器:不重寫 C++ 的解析規則,而是為符合的成員函式生成
candidate_wrapper函式物件,組成overload_set,由編譯器原生機制挑選。
物件大小與效能旋鈕
文章提到一個少見的標準特性:pointer interconvertibility。兩個條件是物件與其第一個成員都是 standard-layout,此時兩者的指標可以互相 reinterpret_cast。函式庫借此省掉每個成員函式的回指標,所以 duck 物件大小不會隨 trait 變複雜而增加。
對熱路徑,可以用選用的 perf_options trait 把常呼叫的函式直接內嵌進 duck 物件,用稍大的物件換掉 vtable 間接呼叫。文章與 README 都沒有提供基準測試數字,所以「與手寫 vtable 同等效能」目前只是作者的設計主張,並無量測佐證。
其他設計
rjk::duck擁有物件;rjk::duck_view不擁有,可接duck或原始物件。- 多個 trait 可在模板參數中組合,函式可只要求需要的子集(trait narrowing)。
- 對不能修改的既有類別,特化
rjk::impl就能補上 trait 支援。 rjk::like把以繼承為基礎的舊介面轉成 trait 版本,遷移改動很小。
影響範圍
最直接受益的是維護自家 type-erased 包裝類別的函式庫作者,例如手寫 any_callable、any_range、plugin 介面的人,以及被迫為第三方型別加 adapter 的程式碼。介面只剩一份 struct 宣告,加減成員函式不用再同步三處。
但短期內不能拿來進產品。編譯器只有 GCC trunk 加 -freflection,CI 若跑 Clang、MSVC 或發行版 GCC 都編不過。constexpr 用法也被標為實驗性,原因是編譯期不允許 reinterpret_cast,而上面的大小優化正依賴它。
另外,同名重載需要變通寫法。若你的介面有多個同名成員函式,要先看 專案 repo 的處理方式再評估。現階段比較實際的用法,是拿它當 C++26 反射能做到什麼的參考實作,用來判斷自家樣板哪些可以在標準落地後砍掉。
原始來源:作者原文、rjk-duck repo、isocpp.org