除錯時 Step Into 明明呼叫 A() 卻跳進 B(),呼叫堆疊也顯示在 B() 內。這不是編譯器錯誤,而是 虛函數表 (vtable) 布局變更導致的二進位不相容。大型專案中極常見,後果從邏輯錯誤到安全性漏洞皆有可能。
// Project1: 核心庫 v1.0
class KuQin_COM {
public:
virtual void f1(); // vtable[0]
virtual void f2(); // vtable[1]
virtual void f3(); // vtable[2]
};
// Project2: 應用層,編譯時見 v1.0 頭檔
pDbgNow->f2(); // 編譯為:pDbgNow->vptr->vtable[1]()
後來核心庫加入新虛函式:
// Project1: 核心庫 v1.1
class KuQin_COM {
public:
virtual void f1(); // vtable[0]
virtual void f1_5(); // vtable[1] ← 新增!
virtual void f2(); // vtable[2] ← 索引右移
virtual void f3(); // vtable[3]
};
災難發生:Project2 未重新編譯,f2() 仍對應 vtable[1],實際呼叫到 f1_5()。函式名稱無關,純粹是索引錯位。
| 方案 | 做法 | 優點 | 缺點 |
|---|---|---|---|
| 永遠在尾部新增 | 新虛函式只加在類別定義末尾 | 簡單、無額外成本 | 派生類 vtable 仍會被打亂;多層繼承體系難保證 |
| 全專案重建 | 任何虛函數變更觸發完整重建 | 徹底解決 | 大型專案耗時數小時,CI/CD 瓶頸 |
class KuQin_COM {
public:
virtual void f1();
virtual void f2();
virtual void f3();
// 預留 10 個槽位,未來擴充直接替換
virtual void reserved_virtual_1() {}
virtual void reserved_virtual_2() {}
// ...
virtual void reserved_virtual_10() {}
};
操作原則:
1. 槽位耗盡前,批量追加新預留。
2. 需要新虛函式時,覆寫現有預留槽位而非新增。
3. 模組穩定發布後,清理未使用槽位(釋出 vtable 空間)。
abi-compliance-checker、abi-dumper (GCC/Clang)、Microsoft dumpbin /exports 對比 vtable 佈局。IKuQin_COM_v1, IKuQin_COM_v2 介面繼承,QueryInterface 協商版本(COM 風格)。CreateInstance(),實作類別隱藏於庫內部,vtable 變更不外洩。static_assert(sizeof(KuQin_COM) == EXPECTED_SIZE) 捕獲佈局變更。移除中間虛函式同樣導致後續索引左移。同樣用預留槽位覆寫為 void dummy() {} 保持佈局不變。
虛函數表是 C++ 多型的執行期基石,也是二進位相容性的阿喀琉斯之踵。預留槽位、介面版本化、自動化 ABI 檢查、語義版本控管,四管齊下,才能在大型長壽命專案中安全演進介面契約。