本文在 Ubuntu 16.04 (GCC 5.3.1, x86_64) 環境下,透過 sizeof 實測、GDB 記憶體視窗、反組譯碼追蹤,實證 C++ 動態多態的核心機制:虛表指標 (vptr)、虛表 (vtable)、動態綁定跳轉流程,並繪製單繼承/多重繼承物件記憶體佈局圖,還原編譯器視角的多態真相。
Target: x86_64-linux-gnu gcc version 5.3.1 20160413 (Ubuntu 5.3.1-14ubuntu2.1)
sizeof 差異class Empty { }; // sizeof = 1 (佔位)
class WithVirtual { virtual void f(); }; // sizeof = 8 (x64 指標 8 bytes)
class WithData { int a; virtual void f(); }; // sizeof = 16 (vptr 8 + int 4 + padding 4)
結論:含 virtual 函數的類別,每個物件**開頭固定包含一個 vptr** (指向虛表),導致體積增加指標大小。
class Person { public: virtual void Go() {} };
class Man : public Person { public: void Go() override { cout << "Man"; } };
int main() {
Man man;
Person* p = &man;
p->Go(); // 動態綁定呼叫
}
&man 位址 (假設 0x7fffffffe000)。0x555555559000 (虛表位址,唯讀段)。0x555555559000):[0] = 0x5555555551a9 (Man::Go), [1] = 0 (結束標記)。; p->Go() 對應機器碼 mov rax, QWORD PTR [rbp-8] ; rax = p (man 位址) mov rax, QWORD PTR [rax] ; rax = *p = vptr (虛表位址) mov rax, QWORD PTR [rax] ; rax = vtable[0] = Man::Go 位址 call rax ; 動態跳轉執行 Man::Go
核心邏輯:vptr → vtable[索引] → 函數位址 → call。索引由編譯器根據虛函數宣告順序在編譯期固定,執行期零開銷查找。
Man man; // 輸出建構順序 Person() // 基類建構:vptr → Person::vtable Man() // 派生類建構:vptr → Man::vtable (覆寫槽位已更新)
解構逆序:~Man() 執行前 vptr 仍指向 Man::vtable;~Man() 結束後編譯器插入代碼將 vptr 恢復指向 Person::vtable,再執行 ~Person()。建構/解構期間呼叫虛函數為靜態綁定 (vptr 尚未/已恢復指向最終虛表)。
class Base { virtual void f(); int a; };
class Derived : public Base { virtual void f(); virtual void g(); int b; };
Derived object (x64):
+0x00: vptr ──────────────→ Derived vtable
+0x08: Base::a
+0x10: Derived::b
(size = 24, 含 padding)
Derived vtable (唯讀段):
[0] Base::f → Derived::f (覆寫)
[1] Derived::g (新增)
[2] nullptr
class Base0 { virtual void f(); int a; };
class Base1 { virtual void f(); int b; };
class Derived : public Base0, public Base1 { virtual void f(); virtual void h(); int c; };
Derived object:
+0x00: Base0 subobject
| vptr0 ──────────→ Derived vtable [區段 0]
| Base0::a
+0x10: Base1 subobject
| vptr1 ──────────→ Derived vtable [區段 1]
| Base1::b
+0x20: Derived::c
(size = 40)
Derived vtable (雙區段):
區段 0 (Base0 視角):
[0] Base0::f → Derived::f
[1] Derived::h
[2] nullptr
區段 1 (Base1 視角):
[0] Base1::f → Derived::f
[1] Derived::h
[2] nullptr
※ Base1* 指向 Derived 時,編譯器插入 thunk: this += 0x10 (Base0 大小) 再呼叫虛函數
class VBase { int v; };
class B1 : virtual public VBase { int b1; };
class B2 : virtual public VBase { int b2; };
class D : public B1, public B2 { int d; };
D object:
+0x00: B1 subobject
| vptr/vbptr
| B1::b1
+0x10: B2 subobject
| vptr/vbptr
| B2::b2
+0x20: D::d
+0x28: VBase (共享實例)
| VBase::v
(size = 48)
※ VBase 僅出現一次,位於最尾端。中繼類透過 vbptr 或虛表位移定位 VBase。
※ VBase 建構子由 D 直接呼叫 (最尾端派生類負責),B1/B2 列表中對 VBase 的調用被忽略。
| 條件 | 說明 | 違反後果 |
|---|---|---|
| 函數為 virtual | 基類宣告 virtual,派生類自動為虛 | 靜態綁定 (早期綁定) |
| 覆寫簽名完全一致 | 含參數、回傳、cv/ref 資格;協變允許共變回傳 | 隱藏而非覆寫,靜態綁定 |
| 透過基類指標/參考呼叫 | Person* p = &man; p->Go() | 物件直接呼叫 man.Go() 為靜態綁定 |
| 非建構/解構期間 | vptr 已指向最終虛表 | 靜態綁定至當前建構層級虛表 |
| 誤區 | 真相 |
|---|---|
| virtual 函數會大幅降低效能 | 單次間接跳轉 (極快),現代 CPU 分支預測極佳;熱路徑可用 final/__attribute__((optimize)) 輔助去虛擬化 |
| 多重繼承一定產生菱形問題 | 只有共同基類且非虛繼承才會重複;虛繼承解決共享但引入 vbptr 開銷 |
| 建構子可為 virtual | 語法錯誤:建構時動態型別未定,無法動態分派 |
| 所有類別都有虛表 | 只有含 virtual 函數 (或繼承自含虛函數基類) 的類別才有 |
| 虛表在堆疊/堆上 | 虛表位於**唯讀資料段 (.rodata)**,全程序共用唯一實例 |
info vtbl obj 直接列印虛表 (需 debug info)。-fdump-class-hierarchy 輸出類別階層與虛表結構文字報告。readelf -s a.out | grep vtable 檢視符號表中虛表符號 (_ZTV...)。valgrind --tool=memcheck 偵測 vptr 被覆寫 (緩衝區溢位常見後果)。透過底層實證理解多態機制,不僅能在面試中游刃有餘,更能在效能調優 (去虛擬化、資料導向設計避免虛函數)、記憶體損壞分析 (vptr 損壞識別)、二進位相容性維護 (虛表佈局變更規則) 等系統級工程中做出精準判斷。