vatt'ghern jaskier's ballads
本文 7 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

一次 virtual call 在組合語言裡只有兩條指令——movq (%rdi), %rax 把 vptr 讀進來,jmp *(%rax) 跳過去。問題不在那兩條指令,而在於它們踩著的那張表,是 ABI 用負偏移、多個 vptr、runtime 讀出來的偏移量、還有兩種建構子三種解構子,硬生生在編譯期攤平出來的。

Itanium C++ ABI 的 vtable——virtual dispatch 真正的代價

virtual dispatch 在 C++ 程式設計師的腦中通常只是一句「透過 vtable 找到正確的 override」。但 Itanium C++ ABI——Linux、macOS、幾乎所有非 MSVC 平台採用的那份規範——把這句話拆成一組相當講究的記憶體佈局約定:vtable 不是從頭開始讀的陣列,vptr 指向的是表中間的 address point,typeinfo 與 offset-to-top 躺在負偏移;多重繼承讓一個物件帶上不只一個 vptr,呼叫非主要 base 的虛擬函式得先把 this 調回去;虛擬繼承更狠,連「base 在哪裡」都得在 runtime 讀一個 vcall offset 算出來。本文沿著這些元件走一遍,最後回到那兩條 dispatch 指令——理解了佈局,那兩條指令踩著的整座機器才看得清楚。

為什麼值得花力氣讀懂這套佈局?因為它是一條「規則退化成事實」的捷徑。C++ 有一長串關於多型的規則需要背:建構子裡呼叫虛擬函式不會分派到 derived、純虛擬函式不能在建構期間被呼叫、虛擬繼承的菱形 base 只有一份、移除或重排虛擬函式會 break ABI、dynamic_cast 的成本遠高於虛擬呼叫。這些規則彼此看似無關,但一旦你看清 vtable 與 vptr 在記憶體裡的實際形狀,它們全部變成可以從佈局直接推導出來的必然結果——不再需要背,只需要想「此刻 vptr 物理上指向哪張表」。本文的目標不是教你寫 ABI,而是把這層心智模型建起來;所有的偏移、register、指令都用 x86-64 System V(LP64,指標 8 bytes)的實際產物來談。

先把最基本的形狀立起來。一個有兩個虛擬函式的 Base,它的 vtable 是一塊放在 .rodata 的靜態資料,符號名是 _ZTV4Base。但物件裡的 vptr 不指向這塊資料的開頭——它指向所謂的 address point,也就是第一個函式指標所在的位置。在這個 +16 的位置之前,藏著兩個(虛擬繼承下是三個)負偏移欄位。下面的互動圖把每個 slot 攤開:點任何一格看它的責任,特別注意 vptr 那支箭頭落在哪裡。

_ZTV4Base —— vptr 指向 address point,header 躺在負偏移 object vptr points to +16 offset-to-top = 0 vptr[-2] · +0 typeinfo = &_ZTI4Base vptr[-1] · +8 ↑ address point (vptr 落點) ↓ &Base::foo vptr[0] · +16 &Base::bar vptr[1] · +24 ~Base · D1 (complete-object) vptr[2] · +32 ~Base · D0 (deleting) vptr[3] · +40 負偏移是 header(往上讀),正偏移是函式指標(依宣告順序往下);解構子佔兩格

click any slot above · 6 slots

offset-to-top · vptr[-2]

記錄「從這個 vptr 到完整物件起點的距離」。在最基本的單一繼承下它是 0——vptr 就在物件起頭。它存在的理由要到多重繼承才顯現:當 dynamic_cast<void*> 或 RTTI 需要從一個 base subobject 找回最派生物件的位址,就讀這個欄位往回退。

不知道的事:它不知道函式指標是什麼,只是一個帶號的 byte 距離。

typeinfo pointer · vptr[-1]

指向這個型別的 std::type_info 物件(符號 _ZTI4Base),RTTI 的根。typeid(obj)dynamic_cast 都從這裡出發:先 movq (%rdi), %rax 取 vptr,再讀 vptr[-1] 得到 type_info,然後沿 type_info 的繼承鏈比對。

不知道的事:它不參與任何虛擬呼叫的快路徑——dispatch 完全不碰它。

address point · vptr[0]

vptr 真正指向的位置,也是第一個虛擬函式 &Base::foo。把 vptr 指到「中間」而非表頭,是為了讓 vptr[0]vptr[1] 直接是函式指標、vptr[-1]vptr[-2] 直接是 header——index 即 slot,dispatch 不必先跳過 header。

不知道的事:它不知道自己被哪個 derived 覆寫了——override 只是換掉這格的內容,位置不變。

function slot · vptr[1]

&Base::bar,依宣告順序排在 foo 之後。slot 的順序是 ABI 約定的一部分:只要 base 的虛擬函式宣告順序不變,derived 就能把同名 slot 留在同一個 index、在後面 append 新的虛擬函式。這正是「在不重編 base 的前提下加虛擬函式必須加在尾端」這條相容性規則的來源。

不知道的事:它不知道 caller 是誰,呼叫慣例由 this in %rdi 約定。

destructor slots · vptr[2], vptr[3]

一個解構子在 vtable 裡佔兩格:D1 complete-object dtor(只解構、不釋放記憶體)排在前,D0 deleting dtor(先呼 D1 再呼 operator delete)排在後。delete p 走 D0 slot;stack 物件離開 scope 走 D1(通常直接 inline,不必查表)。

不知道的事:vtable 裡看不到第三個變體 D2 base-object dtor——它只在 base subobject 解構時被直接呼叫,不需要虛擬分派。

單一繼承下 Base 的 vtable。vptr 指向 +16 的 address point;offset-to-top 與 typeinfo 在它之前的負偏移;函式指標依宣告順序往下,解構子佔 D1/D0 兩格。byte 偏移為 LP64(指標 8 bytes)。

單一繼承下 Base 的 vtable

vptr 指向 address point 而非表頭;typeinfo 在 -1、offset-to-top 在 -2;解構子佔 D1/D0 兩格。

負偏移的 header:vptr 為什麼指向表中間

把 vptr 設計成指向 address point 而非 vtable 的實體起點,是整個佈局裡最容易誤解、卻最關鍵的一步。直覺上你會覺得 vptr 應該指向那塊 .rodata 的開頭,然後 dispatch 用 vptr[0] 拿到第一個欄位。但 ABI 不這麼做:vptr 指向第一個函式指標,於是 vptr[0] 直接就是 &foovptr[1] 直接就是 &bar。所有的 dispatch 都是「正偏移 × 8」,不必在每次呼叫前先加上一個固定的 header 大小。

header 因此被推到負偏移。在 LP64 下,vptr[-1](位元組偏移 −8)放 typeinfo pointer,vptr[-2](−16)放 offset-to-top。虛擬繼承下還會多一個 vptr[-3](−24)放 vbase offset 或 vcall offset——這格只在有虛擬繼承時存在,後面會專門講。讀 header 是「往負方向讀」,讀函式是「往正方向讀」,兩種存取在 ABI 層面被明確區分開。

把這個「負偏移 header、正偏移函式」的座標系拿來算具體的 byte,最快看清 address point 落在哪。下面的計算器讓你撥虛擬函式個數、再切換有沒有虛擬繼承(多一格 vptr[-3]);左邊把每個欄位放到以 address point 為原點的數線上,offset-to-top/typeinfo 永遠在原點左側(負),函式指標永遠在右側(正)。

2
address point · +0(vptr 落點) ← 負偏移:header 正偏移:函式指標 →

以 address point 為原點的 vtable 數線。每格寬度對應 8 bytes(LP64 指標)。header 欄位(offset-to-top、typeinfo、選用的 vbase/vcall offset)排在原點左側的負偏移;解構子佔 D1/D0 兩格,排在 N 個函式 slot 之後。撥 N 看正側變長、勾虛擬繼承看左側多出 vptr[-3]

以 address point 為原點的 vtable 數線

N 個虛擬函式加上 D1/D0 往正偏移延伸,header(typeinfo、off-top)永遠在負側,虛擬繼承再多一格 vbase。

這個選擇的回報在 dispatch 的指令數上立刻看得到。一次普通虛擬呼叫,編譯器產生:

    movq    (%rdi), %rax    ; %rdi = this, 載入 vptr 到 %rax
    jmp     *(%rax)          ; 呼叫第一個虛擬函式 (index 0)

呼叫第二個虛擬函式只是把偏移從 0 換成 8:

    movq    (%rdi), %rax    ; 載入 vptr
    jmp     *8(%rax)         ; 呼叫第二個虛擬函式 (index 1)

沒有任何「跳過 header」的加法、沒有對 typeinfo 的存取——只要 dispatch 不需要 RTTI,那兩個負偏移欄位在快路徑上完全不被觸碰。它們只在 dynamic_casttypeid、以及多重/虛擬繼承的指標調整時才登場。把它們放在「正常情況下不讀到的方向」,正是這個佈局的優雅之處。值得強調的是 jmp 而非 call——這是 tail call:dispatch 點本身常常就是函式的最後一個動作,編譯器用 jmp 直接把 return address 交給被呼叫的虛擬函式,省一層 stack frame。

把這兩條指令逐步走一遍,「間接跳躍」就不再抽象。下面的步進器把一次 p->bar()(index 1)的 dispatch 拆成三拍:先從 this 讀出 vptr、再用 index 算出 slot 位址、最後 jmp 過去。拖拉滑桿或按 step,看每一拍動的是哪個 register、踩的是哪一格 slot。

step 1/3
%rdi = this 物件起點,內含 vptr %rax = ? vtable(vptr 落點 = vptr[0]) [-1] typeinfo · [-2] off-top vptr[0] &foo +16 vptr[1] &bar +24 Base::bar movq (%rdi), %rax lea 8(%rax), %r11 jmp *8(%rax)

一次 p->bar()(index 1)的 dispatch 三拍。第一拍 movq (%rdi), %rax 把 vptr 載進 %rax;第二拍用 index×8 算出 slot 位址(這裡常被摺進定址模式,未必獨立成指令);第三拍 jmp *8(%rax) tail-call 到 bar 的 slot。index 在編譯期已定,runtime 完全不查名字。

一次 p->bar()(index 1)的 dispatch 三拍

virtual dispatch 只有兩條指令:movq 讀 vptr 再 jmp;slot index 是編譯期常數,runtime 無名字查詢。

純虛擬函式在這個佈局裡有個具體的表現:抽象類別的 vtable 在對應的 slot 放的不是真正的函式,而是 __cxa_pure_virtual——runtime 提供的一個會 abort 的錯誤處理函式。所以「abstract class 不能實例化」這條規則,在 ABI 層面其實是「它的 vtable 存在、只是某些 slot 填了自殺函式」;當建構途中物件的動態型別恰好是這個抽象層、又有程式碼間接呼到那個 slot,撞上的就是它。同樣地,-fno-rtti 編譯時,編譯器仍然生成 vtable 與其函式 slot(dispatch 還是要能跑),但會把 vptr[-1] 的 typeinfo 指標省略或留空,並讓 dynamic_casttypeid 無法使用——這再次印證 dispatch 與 RTTI 是兩條獨立的路徑,砍掉一條另一條照常運作。

typeinfo 那格指向的東西,是另一個由 ABI 規範佈局的物件,符號形如 _ZTI4BaseTI = type info)。它自己也是多型的——每個 type_info 物件前面也有一個 vptr,指向某個 __cxxabiv1 內部類別(__class_type_info__si_class_type_info 單一繼承用、__vmi_class_type_info 多重/虛擬繼承用)的 vtable。type_info 物件裡帶一個指向型別名稱字串的指標,那個字串符號是 _ZTS4BaseTS = type string,內容就是 mangled name 4Base)。於是同一個型別牽出三個關聯符號:_ZTV(vtable)、_ZTI(type_info)、_ZTS(type string)。dynamic_cast 在 runtime 做的事,就是從來源指標的 vptr[-1] 取出 type_info,沿著它記錄的 base 清單往目標型別比對;若命中且涉及指標調整,再配合 offset-to-top 與來源/目標的相對位置算出新指標。這條路徑跟 dispatch 完全分離,也因此 dynamic_cast 的成本級別跟一次虛擬呼叫完全不在同一個量級——它是一趟繼承圖的搜尋,不是一次間接跳躍。

單一繼承的 slot 重用:override 換內容,不換位置

單一繼承是這套機制最乾淨的形態。derived 的 vtable 是 base vtable 的延伸:繼承來的 slot 留在原本的 index,override 換掉該格的函式指標,新的虛擬函式 append 到尾端。看這對父子:

struct Base {
    virtual void foo() {}
    virtual void bar() {}
};
struct Derived : Base {
    void foo() override {}   // 取代 Base::foo 的 slot
    virtual void baz() {}    // append 到尾端
};

Derived 的 vtable 在 index 0 仍是「foo 的 slot」,但內容換成 &Derived::foo;index 1 仍是 bar(沒被 override,內容還是 &Base::bar);baz 排在 index 2。因為 slot 的 index 由「宣告順序」固定,呼叫端在編譯期就知道「foo 是 index 0」,無論手上拿的是 Base* 還是 Derived*,產生的都是 jmp *(%rax)——同一個偏移,落在不同物件的 vptr 上會解析到不同的實作。這就是 override 的本質:呼叫端的程式碼一字不變,換的只是 vptr 指向的那張表裡 index 0 的內容。

為什麼一個解構子要在 vtable 裡佔兩格(D1 與 D0),而不是一格?因為「解構」與「釋放記憶體」是兩件必須能分開觸發的事。stack 上的物件離開 scope,要解構但絕不能 operator delete(那塊記憶體不在 heap 上);delete p 一個多型指標,則要先解構再釋放,而且釋放需要正確的「完整物件起點 + 大小」,這在多型脈絡下只有透過 vtable 才能拿到。把兩種需求各給一格——D1 只解構、D0 解構後釋放——呼叫端就能按情境挑正確的入口,而不必在每個解構點塞一個「要不要釋放」的旗標。這也是為什麼一個多型基底類別的虛擬解構子,會讓 vtable 多出兩格而非一格。

這也解釋了一條讓很多人踩坑的 ABI 相容性規則:在 base 的虛擬函式列表「中間」插入一個新虛擬函式,會把它後面所有 slot 的 index 往後推一格——任何已經編譯、寫死了舊 index 的呼叫端,全部 dispatch 到錯的 slot。所以二進位相容的做法永遠是「append 到尾端」。同理,移除一個虛擬函式、或調換兩個的宣告順序,都是 ABI break:呼叫端寫死的是 index,不是名字。這條規則對 library 作者是硬約束——一個發布過的多型介面,它的虛擬函式佈局基本上就凍結了,這也是為什麼成熟的 C++ library 傾向用 pimpl 或非虛擬介面把 vtable 藏在實作背後,留出演進空間。

另一個容易忽略的點是覆寫與回傳型別 covariance 的互動。當 derived override 的回傳型別是 base 回傳型別的 derived(covariant return),而這兩個型別在物件裡的偏移不同時,編譯器會在這個 slot 放一個會調整回傳指標的 thunk,而非函式本身——這跟多重繼承調整 this 是同一套機制,只是調整的對象從引數換成回傳值。單一繼承下 offset-to-top 恆為 0、沒有指標調整、沒有 thunk——這是最便宜的情形,多重與虛擬繼承的所有複雜度,都是為了在更難的拓樸下維持「jmp *(%rax) 這條快路徑仍然成立」這個不變量。

多個 vptr 與指標調整:non-virtual thunk 的 subq

多重繼承打破「一個物件一個 vptr」。考慮 MultiDerived : Left, Right,兩個 base 都是多型的:

struct Left  { virtual void left_func(); };
struct Right { virtual void right_func(); };
struct MultiDerived : Left, Right {
    void left_func() override;
    void right_func() override;
};

物件記憶體裡,Left subobject 在 offset 0、Right subobject 在 offset 16,各自帶一個 vptr。兩個 vptr 都指向同一個符號 _ZTV12MultiDerived,但落在不同的 address point:primary 段給 Left(offset-to-top = 0),secondary 段給 Right(offset-to-top = -16)。secondary 段的 offset-to-top 之所以是 −16,正是要讓 RTTI 從 Right subobject 退回完整物件起點。

這裡有一個值得停下來看的設計選擇:為什麼 Left 是 primary 而 Right 是 secondary?ABI 規定第一個非空、且其 vptr 落在 offset 0 的多型直接 base 當 primary base,它的 vtable 段與最派生型別的 vtable 段共用同一個 vptr——也就是說 MultiDerived 自己新增的虛擬函式,直接 append 在 primary(Left)那一段的尾端,不另開新段。這讓「透過 Left* 或透過 MultiDerived* 呼叫」走的是同一個 vptr、同一段表、零調整——primary base 的呼叫跟單一繼承一樣便宜。代價全部集中到 secondary base:Right 的 vptr 在 offset 16,它那段表的每個被 MultiDerived override 的 slot 都得掛 thunk。換句話說,多重繼承的指標調整成本不是平均分攤的——它偏心地壓在「非主要」的那些 base 上,這也是「把最常透過 base 指標呼叫的型別擺在繼承列表第一位」這條微優化建議的根據。

真正的麻煩在於 this 指標。當你拿著一個 Right*(它指向 offset 16)呼叫 right_func,但實際執行的 MultiDerived::right_func 期待的 this 是完整物件的起點(offset 0)。secondary vtable 裡那個 slot 不直接指向 MultiDerived::right_func,而是指向一個 non-virtual thunk,它先把 this 調回去再跳:

_ZThn16_N12MultiDerived10right_funcEv:
    subq    $16, %rdi       ; this 往回退 16 bytes,回到完整物件起點
    jmp     .LTHUNK0         ; 跳到真正的 MultiDerived::right_func

mangled name 把整件事寫得很清楚:Th 是 non-virtual thunk,n16 是 −16 的調整量。關鍵字是 non-virtual——這個 16 是編譯期就知道的常數,因為 RightMultiDerived 裡的固定偏移在編譯期已定。整個 thunk 只有一條 subq 加一條 jmp,沒有任何記憶體存取。把一個 MultiDerived* 轉成 Right* 同樣是編譯期常數加法(+16),轉回來則是 −16——指標調整在多重繼承下無所不在,但只要 base 的位置是固定的,調整量就永遠是常數。

把整個 _ZTV12MultiDerived 符號攤平在記憶體裡,能更清楚看到「一個符號、兩個 address point」是什麼意思。它是一塊連續的 .rodata,物件的兩個 vptr 各指進它的不同位置:

_ZTV12MultiDerived  // 一塊連續資料,兩個 address point
  +0   offset-to-top = 0          // ── primary 段 header
  +8   typeinfo = &_ZTI12MultiDerived
  +16  &MultiDerived::left_func    // ← Left subobject 的 vptr 指這(off 0)
  +24  &MultiDerived::right_func   // MultiDerived 新增的也 append 在此段
  +32  offset-to-top = -16        // ── secondary 段 header
  +40  typeinfo = &_ZTI12MultiDerived
  +48  &_ZThn16_...right_func      // ← Right subobject 的 vptr 指這(off 16)

注意兩段的 typeinfo 指標指向同一個 _ZTI12MultiDerived——無論你手上的是 Left* 還是 Right* 形態的 vptr,typeid 都能正確回答「這是個 MultiDerived」。差別只在 offset-to-top:primary 段是 0、secondary 段是 −16,RTTI 用它把任一 subobject 指標退回完整物件起點,再從那裡做 cross-cast。這就是 dynamic_cast<Left*>(rightPtr) 這種「跨同層 base 的橫向轉型」能成立的底層機制:先用 offset-to-top 退回完整物件,再用目標 base 的已知偏移前進。

下面這個分頁工具把三種繼承拓樸並排:切換 single / multiple / virtual,看 vtable group 從「一張表」長到「多個 vptr」再到「runtime 才能算出偏移」。每一頁附上對應的 layout 與決定性的那一行組語。

switch tabs to compare 3 inheritance topologies · 3 tabs

single · 一個 vptr,零調整

物件只有一個 vptr 在 offset 0,offset-to-top 恆為 0。derived vtable 是 base vtable 的延伸,override 換內容、新虛擬函式 append。沒有 thunk,沒有指標調整,dispatch 就是兩條指令。

vtable _ZTV7Derived (一張表):
  [-2] offset-to-top = 0
  [-1] typeinfo      = &_ZTI7Derived
  [ 0] &Derived::foo   // override,內容換、index 不變
  [ 1] &Base::bar      // 沒 override
  [ 2] &Derived::baz   // 新虛擬函式 append
    movq    (%rdi), %rax    ; 唯一的 vptr
    jmp     *(%rax)          ; 直達,無調整

multiple · 多個 vptr,編譯期常數調整

MultiDerived : Left, Right 物件帶兩個 vptr:Left 在 offset 0、Right 在 offset 16。同一個 vtable 符號的兩個段,secondary 段的 offset-to-top = -16。呼叫 Right 的虛擬函式經過 non-virtual thunk,用編譯期常數 subq $16this 調回去。

_ZTV12MultiDerived (一個符號,兩段):
  primary (Left, off 0):
    [-2] offset-to-top = 0
    [ 0] &MultiDerived::left_func
  secondary (Right, off 16):
    [-2] offset-to-top = -16   // 給 RTTI 退回
    [ 0] &_ZThn16_...right_func // thunk,非直接函式
    subq    $16, %rdi       ; 常數調整 this
    jmp     .LTHUNK0         ; 再進真正的 right_func

virtual · 偏移要 runtime 才知道

虛擬繼承下,virtual base 的位置依最派生型別而變——編譯期不知道常數。vtable 多出 vptr[-3] 放 vbase offset/vcall offset。virtual thunk 不能用常數,得先 movq (%rdi), %r10 載入 vptr,再 addq -24(%r10), %rdivptr[-3] 讀出 vcall offset 加到 this。多一次記憶體存取。

各 subobject 段的 vptr[-3]:
  Left 段:   vbase offset = 16   // Base 在 Left vptr +16
  Right 段:  vbase offset = 8    // Base 在 Right vptr +8
  Base 段:   vcall offset = -16  // 退回完整物件
  // 同一個 Base,從不同 subobject 看偏移不同
    movq    (%rdi), %r10    ; 載入 vptr
    addq    -24(%r10), %rdi ; 從 vptr[-3] 讀 vcall offset
    jmp     .LTHUNK0         ; runtime 算完才跳
三種繼承拓樸下 vtable group 的形狀。single 是一張表;multiple 是一個符號分多段、用編譯期常數調整;virtual 多出 vptr[-3],調整量要 runtime 從表裡讀出。

三種繼承拓樸下 vtable group 的形狀

單繼承零調整;多重繼承靠常數 subq 修正 this;虛擬繼承需 runtime 讀 vcall offset,多一次記憶體存取。

虛擬繼承的 vcall offset:runtime 才知道 base 在哪

多重繼承的指標調整雖然煩,但調整量是編譯期常數。虛擬繼承把這個前提也拿掉了。虛擬繼承的目的是讓共同 base 在最派生物件裡只有一份(解決菱形繼承的 base 重複),代價是這份共同 base 的位置不再固定——它取決於最終的派生型別是誰。同一個 Base,在 Derived : Left, Right 裡的偏移,跟它單獨被 Left 繼承時的偏移完全不同。編譯 Left 的程式碼時,根本不知道未來它會被嵌進哪個更大的物件。

ABI 的解法是把偏移從「編譯期常數」升級成「runtime 從表裡讀出來的值」。這就是 vptr[-3] 這格的用途。在非虛擬 base 的 vtable 段裡,它放 vbase offset——「從這個 vptr 走到 virtual base 的距離」;在 virtual base 自己的 vtable 段裡,它放 vcall offset——「從這裡走回完整物件的距離」。以 Derived : Left, Right、兩者都 virtual Base 為例:

// 各段 vptr[-3] 的值(最派生型別 Derived 決定)
Left 的段:    vptr[-3] = 16     // Base 在 Left vptr 之上 +16
Right 的段:   vptr[-3] = 8      // Base 在 Right vptr 之上 +8
Base 的段:    vptr[-3] = -16    // 完整物件在 Base vptr 之下 -16

當你透過 Base* 呼叫一個在 Derived 裡被 override 的虛擬函式,slot 指向的是一個 virtual thunk。它不能寫死偏移,得在 runtime 把 vcall offset 讀出來:

_ZTv0_n24_N7Derived1fEv:
    movq    (%rdi), %r10    ; 載入 this 的 vptr 到 %r10
    addq    -24(%r10), %rdi ; 從 vptr[-3] (即 -24) 讀 vcall offset,加到 this
    jmp     .LTHUNK0         ; 跳到 Derived::f()

mangled name 是 _ZTv0_n24_...Tv 是 virtual thunk,0 表示沒有額外的 non-virtual 調整,n24 指向 vptr[-3](−24 byte)那格的 vcall offset。和 non-virtual thunk 的 subq $16 對比,差別一目了然:那邊是「立即數」,這邊是「先 movq 載入 vptr、再從 -24(%r10) 間接讀出調整量」——多一次記憶體存取,這就是虛擬繼承在 dispatch 路徑上額外付的代價。理解了這一點,「為什麼大家勸你避免虛擬繼承除非真的需要菱形去重」就有了具體的微觀根據:不是抽象的「複雜」,而是每次跨 virtual base 的呼叫多一次 load。

vbase offset 與 vcall offset 雖然都放在 vptr[-3] 的位置,用途要分清楚。vbase offset 解的是「資料存取」問題:當你在 Left 的成員函式裡存取 virtual base Base 的成員變數,編譯器不能用固定偏移(因為 Left 可能被嵌進任何更大的物件),於是它載入 Left 的 vptr、讀出 vbase offset、加到 this,得到 Base subobject 的位址,再從那裡存取成員。vcall offset 解的是「呼叫時的 this 調整」問題:當虛擬函式透過 virtual base 的 slot 被呼叫、而真正的實作在最派生型別,thunk 需要把 thisBase subobject 調回完整物件——但這個距離同樣依最派生型別而變,所以也得 runtime 讀出。兩者是「同一個 runtime-offset 機制」的兩種應用:一個給資料、一個給呼叫,都因為虛擬繼承讓位置不再是編譯期常數而存在。

實務上這牽出一個常被忽略的成本:在虛擬繼承的階層裡,光是「從派生型別存取一個 virtual base 的成員」就要先 load 一次 vbase offset。一個寫得密集、在迴圈裡反覆觸碰 virtual base 成員的函式,這些 load 會累積成可量測的開銷。編譯器在能證明 this 的動態型別時可以把 vbase offset hoist 出迴圈,但跨 translation unit 往往證不出來。這就是為什麼 virtual inheritance 在效能敏感的程式裡基本上被當成「最後手段」——它要解的菱形去重問題是真的,但凡能用組合(composition)或介面分離繞開的,通常都值得繞。

construction vtable 與 VTT:建構途中的臨時表

虛擬繼承還在物件的「建構期間」製造一個微妙的問題。建構子由內而外執行:先建 base subobject,再建 derived。但 base 的建構子執行時,物件還不是完整的 Derived——此時若 base 建構子呼叫一個虛擬函式,它應該解析到 Base 自己的版本,而不是還沒準備好的 Derived override。而且建構途中,virtual base 相對於正在建構的 subobject 的偏移,跟完成後的偏移可能不同。如果整個建構期間都用最終的 vtable,這些偏移會錯。

ABI 的對策是 construction vtable:對每個會被當成「base subobject」嵌入的型別,編譯器額外生成一份建構專用的 vtable,裡面的 vbase/vcall offset 是針對「在這個外層型別的建構脈絡下」校正過的。符號形如 _ZTC7Derived0_4Left——意思是「Derived 內、byte offset 0 處的 Left 的 construction vtable」。建構子在執行 base 建構的那一刻,先把 base subobject 的 vptr 指向這份 construction vtable,建完再換成最終 vtable。

把這些 construction vtable 餵給各個 base 建構子的調度表,叫做 VTT(Virtual Table Table)。Derived 的 VTT 是一個指標陣列,大致長這樣:

VTT for Derived (指標陣列):
  [0] Derived 的 primary vtable
  [1] Left 的 construction vtable    // sub-VTT[0]
  [2] Base 的 construction vtable    // sub-VTT[1],給 Left 脈絡
  [3] Right 的 construction vtable   // sub-VTT[0]
  [4] Base 的 construction vtable    // sub-VTT[1],給 Right 脈絡
  [5] 最終 Base vtable 指標
  [6] 最終 Right vtable 指標

建構子因此分裂成兩個變體。C1 是 complete-object constructor——public 入口,它負責先建好所有 virtual base(這是「只有最派生物件才建 virtual base」這條規則的執行點),再呼叫各 base 的 C2,最後裝上最終 vtable。C2 是 base-object constructor——內部用,它收到一片 sub-VTT 切片,按切片裡的指標裝上 construction vtable,並且建構 virtual base(因為那是 C1 的責任,C2 只負責自己這層)。傳給 Left 的 C2 的 sub-VTT,就是上面 VTT 的 [1][2] 兩個指標——分別給 Left 自己和它脈絡下的 Base。(規範裡還有 C3 allocating constructor,但多數實作不生成它,故 vtable 與 VTT 的調度只涉及 C1/C2。)

new Derived 整個建構流程攤成四階段,VTT 怎麼餵 construction vtable 就清楚了。點任一階段看 C1 在那一刻做什麼、物件的 vptr 此時指向哪張表。

Derived 的 C1 建構流程——VTT 怎麼餵 construction vtable

C1 由內向外建構,VTT 切片逐層餵 construction vtable,最後才裝最終表 ① C1 先建 virtual base(Base) vptr 暫指 Base construction vtable · 只有最派生 C1 做這步 C1 only ② 呼 Left::C2,傳 sub-VTT = VTT[1],[2] C2 裝 Left construction vtable,不建 virtual base VTT[1],[2] ③ 呼 Right::C2,傳 sub-VTT = VTT[3],[4] 同樣裝 Right construction vtable,仍不建 virtual base VTT[3],[4] ④ C1 裝最終 Derived vtable(VTT[0]) 所有 vptr 改指最終表 · 之後虛擬呼叫才分派到 override VTT[0]

click any stage above · 4 stages

① C1 建 virtual base

complete-object constructor 的第一件事是建好全物件唯一的那份 virtual base Base。這是「只有最派生型別才建 virtual base」這條規則的執行點——任何中間層的 C2 都被告知別碰它。建完後,Base subobject 的 vptr 先指向 Base 的 construction vtable,而非最終表。

此刻 vptr:指向 construction vtable,虛擬呼叫分派到 Base 自己的版本。

② Left::C2 + sub-VTT

C1 呼叫 Left 的 base-object constructor,並把 VTT 的 [1][2] 兩個指標當成 sub-VTT 切片傳進去。C2 用切片裡的指標把 Left subobject 的 vptr 裝成 Left 脈絡的 construction vtable——裡面的 vbase offset 是「在 Derived 裡的 Left」校正過的值。C2 不建 virtual base。

此刻 vptrLeft 段指向 Left construction vtable,呼到 Left 的版本。

③ Right::C2 + sub-VTT

Right 重複同一套:傳 VTT 的 [3][4] 切片,裝上 Right 脈絡的 construction vtable。關鍵不變量是——在這個視窗裡,物件的動態型別「就是正在建的那層」,虛擬呼叫絕不會跳進尚未初始化的 Derived override。

此刻 vptrRight 段指向 Right construction vtable。

④ 裝最終 vtable

所有 base 都建完,C1 做最後一步:把每個 subobject 的 vptr 從 construction vtable 改指最終的 Derived vtable(VTT 的 [0])。從這一刻起,物件正式「成為 Derived」,虛擬呼叫才會分派到 Derived 的 override。這正是「建構子裡呼虛擬函式不會分派到 derived」的物理原因——那一刻 vptr 還沒走到第④步。

此刻 vptr:指向最終 Derived vtable,多型完全就位。

菱形 Derived : Left, Right(兩者 virtual Base)的 C1 建構流程。C1 先建唯一的 virtual base,再用 VTT 切片把 construction vtable 餵給各 base 的 C2,最後才把全部 vptr 改指最終 vtable。建構途中 vptr 逐層「升級」,這就是建構子內虛擬呼叫不分派到 derived 的根本機制。

菱形 Derived : Left, Right(兩者 virtual Base)的 C1 建構流程

C1 先建 virtual base 再逐層餵 construction vtable;建構子裡呼虛擬函式不分派到 derived,vptr 尚未就位。

為什麼非得在建構途中換 vtable?因為「建構期間呼叫虛擬函式」這件合法但危險的事必須有明確定義。設想 Base 的建構子裡呼叫了一個虛擬函式 f,而 Derived override 了 f。標準規定:在 Base 建構子執行期間,物件的動態型別「就是 Base」,所以呼到的必須是 Base::f,不是 Derived::f——後者會碰到還沒初始化的 Derived 成員。要讓這條規則成立,Base 建構子執行時物件的 vptr 必須指向 Base 的(或建構脈絡校正過的)vtable,而不是最終的 Derived vtable。於是建構過程中 vptr 被逐步「升級」:每進入一層 base 建構子就指向那層的 vtable,全部建完後 C1 才裝上最終表。construction vtable 與 VTT 就是這套「逐步升級」在虛擬繼承下的延伸——因為虛擬繼承還額外要求建構途中的 vbase/vcall offset 也得是當下脈絡正確的值。

這套機制有一個直接的可觀察後果,值得寫進每個 C++ 工程師的肌肉記憶:在建構子或解構子裡呼叫虛擬函式,永遠不會分派到 derived 的 override。很多人把這當成一條要背的規則,但從 ABI 看它根本不是規則,而是 vptr 在那一刻「物理上就還沒指向 derived vtable」的必然結果。同理,純虛擬函式如果在建構期間被(間接)呼叫,會撞上 __cxa_pure_virtual——因為建構脈絡的 vtable 在那個 slot 放的正是這個錯誤處理函式。理解佈局,這些「規則」就退化成可以推導出來的事實。

解構走對稱的三變體,正好對應前面 vtable 圖裡看到的兩格加上一個隱藏的第三格。下面這個工具把三個變體攤開——點任一個看它解構什麼、誰擁有 virtual base。

三個解構子變體——誰拆什麼、誰擁有 virtual base

解構三變體——只有最派生的 D1 擁有 virtual base 的拆解 D0 · deleting destructor 呼 D1 → 呼 operator delete 釋放記憶體 · delete p 走此 slot in vtable D1 · complete-object destructor 解構成員 + base + virtual base,不釋放記憶體 · stack 物件離 scope in vtable · owns vbase D2 · base-object destructor 只拆 subobject,跳過 virtual base · 不出現在 vtable,直接呼叫 not in vtable 對稱於建構:C1 建 virtual base、C2 不建;D1 拆 virtual base、D2 不拆

click any variant above · 3 variants

D0 · deleting destructor

排在 vtable 的解構子第二格(vptr[3])。delete pp 是多型指標)透過虛擬分派找到的就是它:先執行 D1 把物件拆乾淨,再呼 operator delete 把那塊記憶體還給 allocator。把「拆」與「釋放」綁成一格,是因為釋放需要知道完整物件的起點與大小,而那只有 D0 在多型脈絡下能正確取得。

誰擁有 virtual base:透過它呼到的 D1 擁有;D0 自己只多管一個 operator delete

D1 · complete-object destructor

排在 vtable 解構子第一格(vptr[2])。它解構成員、呼叫各 base 的 D2、最後解構 virtual base——但不釋放記憶體。stack 上的物件離開 scope 時走這條(通常 inline,不查表)。「最派生的 D1 才拆 virtual base」是整套機制的關鍵不變量:因為 virtual base 在物件裡只有一份,必須恰好被拆一次。

誰擁有 virtual base:就是它。所有 base 的 D2 都被告知「別碰 virtual base」,留給最派生的 D1 統一處理。

D2 · base-object destructor

不出現在 vtable。當 D1 解構各個 base subobject 時,直接(非虛擬地)呼叫每個 base 的 D2。D2 只拆 subobject 自己的成員與它的非虛擬 base,跳過 virtual base。如果 D2 也去拆 virtual base,菱形繼承下同一份 virtual base 會被拆兩次——double free。

誰擁有 virtual base:不是它。D2 的存在意義恰恰是「不擁有」,把擁有權集中到最派生的 D1。

三個解構子變體。D0/D1 進 vtable(佔兩格),D2 只在 base subobject 拆解時被直接呼叫。virtual base 的拆解權集中在最派生的 D1,避免菱形繼承下的 double free。

三個解構子變體

D0 負責釋放記憶體,D1 擁有拆 virtual base 的責任,D2 跳過 virtual base 防止菱形繼承下 double free。

dispatch 路徑與兩種 thunk:一次呼叫到底踩過什麼

把所有元件接回最開頭那兩條指令。最理想的情形——單一繼承、呼叫的虛擬函式定義在物件自己的型別上——dispatch 就是字面上的兩條:

    movq    (%rdi), %rax    ; 載入 vptr
    jmp     *(%rax)          ; tail-call 第一個虛擬函式

沒有 thunk、沒有指標調整、沒有 header 存取。但只要拓樸變複雜,slot 指向的就不再是函式本身,而是一段 thunk,而 thunk 有兩種,差別正是前面累積下來的所有東西的縮影。non-virtual thunk 用編譯期常數調整 thissubq $16, %rdi),出現在多重繼承呼叫非主要 base 的虛擬函式時;virtual thunk 必須在 runtime 從 vptr[-3] 讀出 vcall offset 再調整(movq (%rdi), %r10 / addq -24(%r10), %rdi),出現在虛擬繼承跨 virtual base 呼叫時。下面的表把這兩種 thunk 與直接 dispatch 放在一起比較——點欄位標題排序,注意「記憶體存取次數」這一欄如何隨拓樸增加。

click column header to sort · 5 columns × 4 rows

dispatch 與兩種 thunk 的對比。「mem 存取」指調整 this 額外讀記憶體的次數(不含 dispatch 必有的 vptr 載入)。點欄位標題排序。
路徑 出現拓樸 this 調整 調整的 mem 存取 mangle 前綴
direct dispatch single inheritance 0 (無 thunk)
primary-base 呼叫 multiple(主要 base) 0 (無 thunk)
non-virtual thunk multiple(非主要 base) subq $16, %rdi(常數) 0 _ZThn16_
virtual thunk virtual inheritance addq -24(%r10), %rdi(runtime 讀) 1 _ZTv0_n24_
四種 dispatch 路徑。直接 dispatch 與主要 base 呼叫零調整;non-virtual thunk 用編譯期常數調整(仍是零額外 mem 存取);只有 virtual thunk 必須在 runtime 多讀一次 vcall offset。這一欄就是虛擬繼承在熱路徑上付出的全部代價。

四種 dispatch 路徑

non-virtual thunk 用常數調整 this;virtual thunk 須多一次 runtime 讀 vcall offset。

把這張表豎著讀一遍,virtual dispatch「真正的代價」就清楚了。代價不是那兩條 dispatch 指令本身——movq 加一個間接 jmp,分支預測器熟悉得很,在現代 CPU 上幾乎免費。代價也不在 non-virtual thunk——一條 subq 立即數,連記憶體都不碰。真正多出來的東西只有兩處:其一是 vtable 佔的那塊 .rodata 與每個多型物件多出的 vptr 欄位(空間,不是時間);其二是虛擬繼承的 virtual thunk 那一次額外的 addq -24(%r10), %rdi 間接讀取(時間,但只在虛擬繼承下)。日常的單一繼承多型,付的幾乎只是「一個指標的空間 + 一次可預測的間接跳躍」。

順帶釐清一個常見誤解:vtable dispatch 之所以難被 inline,不是因為查表慢,而是因為編譯器在呼叫點通常無法靜態證明 vptr 指向哪張表,於是無法把目標函式攤平進來——這是去虛擬化(devirtualization)優化想解決的問題,跟 dispatch 本身的指令成本是兩回事。理解了佈局,你也就明白為什麼 final、為什麼 LTO 下的 speculative devirtualization 能把熱路徑上的虛擬呼叫消掉:它們都是在幫編譯器把「vptr 一定指向這張表」這個事實補上。

還有一層成本是這張表沒畫出來、但在真實系統裡常常更貴的:cache 與分支預測。dispatch 的 movq (%rdi), %rax 要先把物件的 vptr 讀進來,再 jmp *(%rax) 從 vtable 讀目標位址——這是兩次相依的記憶體讀取,若 vptr 或 vtable 不在 cache 裡,dispatch 會 stall 在 load 上,而不是 stall 在「分派邏輯」上。間接 jmp 的目標若不被分支目標預測器(BTB)命中,還會吃一次 mispredict 的 pipeline flush。一個在多型集合上跑的迴圈,如果每次 iteration 物件的動態型別都不同,BTB 命中率會掉、dispatch 的實際成本遠高於「兩條指令」的字面值。這也是為什麼「把同型別物件排在一起批次處理」(type-based sorting)能讓多型迴圈顯著加速——它讓間接跳躍變得可預測。virtual dispatch 真正的代價,到頭來更多是記憶體階層與預測器的代價,而非 ABI 攤出來那幾條指令本身。

最後值得記住的對照:ABI 把這一切設計成「以空間換時間」。每個多型物件多帶一個指標寬度的 vptr,每個多型型別在二進位裡多一塊 vtable/typeinfo/VTT——這些是固定的空間成本,換來的是 dispatch 在時間上盡可能便宜。對絕大多數應用,這個交換划算到不必多想;只有在物件數量極大(每個物件 8 bytes 的 vptr 開始佔記憶體)、或在 dispatch 落在最內層熱迴圈且型別高度發散時,才需要回頭考慮「是否真的需要多型」。這正是 data-oriented design、ECS 那一派把多型換成 tagged union 或 SoA 的微觀動機:他們不是討厭多型,是在特定 workload 下不願付這套佈局的空間與預測成本。

這些東西都不是只能在腦中推演——它們全部是可以直接看到的真實符號。g++ -fdump-lang-class(舊版是 -fdump-class-hierarchy)會把每個型別的 vtable 佈局、VTT 結構、construction vtable 全部 dump 成文字,offset-to-top、vbase offset、vcall offset 的具體值一覽無遺。編譯產物上用 nmobjdump -d 搭配 c++filt,能看到 _ZTV_ZTI_ZTS_ZTC_ZTT、以及 _ZThn16_ / _ZTv0_n24_ 這些 thunk 符號被 c++filt 還原成人類可讀的形式。下次你對某個多型呼叫的成本沒把握,與其猜,不如直接 dump 出來數那幾條指令、看那個 slot 指向函式還是 thunk。

# dump 完整的 vtable / VTT / construction vtable 佈局
$ g++ -std=c++20 -fdump-lang-class -c diamond.cpp
$ less diamond.cpp.001l.class      // offset-to-top、vbase/vcall offset 全在裡面

# 從產物看 thunk 與 vtable 符號(c++filt 還原 mangled name)
$ objdump -dC diamond.o | grep -A2 thunk
$ nm -C diamond.o | grep -E 'vtable|typeinfo|VTT|construction'

What this enables:把繼承拓樸的全部複雜度——多個 vptr、編譯期常數調整、runtime 讀出的 vcall offset、兩種建構子三種解構子——在編譯期攤平成靜態的 vtable/VTT,C++ 才能在「物件導向的多型」與「jmp *(%rax) 這條兩指令熱路徑」之間同時站住;讀懂這張表,你手上的 devirtualization、final、虛擬繼承取捨,全都從玄學變成可以算的 byte 與 load。