rjk::duck: 透過 C++26 Reflection 實現類型擦除
rjk::duck: 透過 C++26 Reflection 實現類型擦除
rjk::duck 函式庫利用 C++26 reflection 來實現類型擦除(type erasure),讓開發者只需定義一次 trait 介面,即可自動將其應用於任何符合該介面的類型。這種方法消除了對數百行手動樣板程式碼或像 Boost.TypeErasure 或 Folly.Poly 等重型依賴的需求,同時保持了傳統 vtable dispatch 的效能。
使用 C++26 Reflection 自動化介面生成
rjk::duck 允許使用者透過使用 [[=rjk::trait]] 標註的 struct 來定義介面。接著,函式庫會使用 C++26 reflection 來檢查此 trait 並在編譯時期生成必要的類型擦除機制。
Trait 檢查與標記
函式庫透過使用 annotations_of 和 reflection operator ^^ 來檢查 [[=rjk::trait]] 標註,藉此識別 trait。一旦識別出 trait,members_to_tags 函式會檢查 struct 的成員,並篩選出具有識別碼的使用者宣告函式。這些函式會被轉換為內部 "tags"(例如 has_fn<"name", signature>),作為生成 vtable 的藍圖。
Vtable 程式碼生成
透過使用 consteval 區塊和 define_aggregate,rjk::duck 會生成一個包含 trait 中每個成員函式指標的 vtable struct。對於給定的類型 T,函式庫會建立一個靜態 vtable,其中每個插槽都填入了 wrapper 函式。此 wrapper 函式會執行從 void* 到 T* 的 static_cast 並呼叫原始成員函式。
進階重載解析 (Overload Resolution)
為了避免手動重新實作 C++ 重載解析,rjk::duck 使用了一種涉及 overload_set 和 candidate_wrapper 的技術。
函式庫並非搜尋精確的簽名匹配,而是生成一個包含所有符合特定識別碼之成員函式 wrapper 的可呼叫 overload_set。當 vtable 被填充時,編譯器自身的重載解析機制會在 erased_call 函式中使用,以選擇最適合底層類型的重載版本。
使用指標互換性優化記憶體
類型擦除的樸素實作通常需要每個成員函式 wrapper 儲存一個指向父物件的後向指標(back-pointer),以便存取 vtable 和底層數據。這會導致 duck 物件的大小隨著 trait 中函式數量增加而線性增長。
rjk::duck 使用 pointer-interconvertibility 來解決此問題。由於函式庫使用 standard layout types,因此它可以將成員函式 wrapper 的指標 reinterpret_cast 回父 duck 物件。這使得 vtable_function 物件可以作為空類別(使用 [[no_unique_address]]),確保 duck 實例的大小不論其 trait 中定義了多少個函式,都能保持不變。
效能調優與內聯 (Inlining)
雖然標準的 vtable dispatch 是高效的,但 rjk::duck 提供了一個選用的效能優化選項以減少間接層級。透過定義一個 [[=rjk::perf_options]] trait,使用者可以指定哪些函式應該被 "inlined"。
內聯函式會直接儲存在 duck 物件本身之中,而非儲存在外部 vtable 內。這以少量的額外記憶體(每個內聯函式 8 bytes)來換取呼叫時減少一次指標間接層級,這在 vtable 儲存在冷記憶體(cold memory)時特別有益。
技術需求與限制
目前,rjk::duck 是一個前沿實作,具有以下限制:
- 編譯器支援:僅適用於使用
-std=c++26 -freflection的 GCC。 - 編譯時期執行:雖然透過 P2738,在
constexpr上下文中進行void*轉型是變得可能的,但用於指標互換性的reinterpret_cast目前尚不允許在編譯時期使用,這限制了完整的constexpr支援。
社群觀點
圍繞該函式庫的討論突顯了現代 C++ reflection 的兩極化特性:
"我每天都在使用 C++,而這感覺起來像是完全不同的語言和哲學。"
批評者指出,靜態 reflection 可以很難除錯,且如果單個字元放錯位置,可能會導致極其冗長的編譯器錯誤訊息。其他人則質疑對編譯量時間的影響,並指出使用開發中階段的編譯器功能會使確定的基準測試變得困難。