C++ 物件模型淺析

2022-06-02 06:00:54

C++ 物件模型淺析

本文希望從這個角度來理解 C++ 物件模型:假設我們作為一門程式語言的設計者,要實現物件導向的三大基本特性:封裝、繼承、多型,同時要滿足與 C 相容和 zero overhead 這兩點約束。我們將帶著這種觀點去剖析 C++ 部分語言特性的實現。

在學習物件模型的時候,要注意區分 C++ 標準和實現,程式碼的正確性不應該依賴於編譯器的實現細節。本文所述的大部分內容都是實現相關的,範例程式的實驗環境為 g++ 5.4.0。如果你使用的編譯器表現出不同的行為,不必感到意外。

筆者能力有限,如有疏漏,懇請諸君指出。

注:下文部分範例程式碼是不完整的,甚至是語法或語意上錯誤的,它們的目的是為了研究物件模型的實現,請勿作為實際編碼的參考。

C++ 設計的約束

與 C 相容

C++ 與 C 的相容性主要體現兩方面[2]:

  • 相容 C 的語法,比如隱式型別轉換;
  • 相容 C 的編譯模型和執行模型,可以直接使用 C 的標頭檔案和庫。

zero overhead

The zero-overhead principle is a C++ design principle that states:

  1. You don't pay for what you don't use.
  2. What you do use is just as efficient as what you could reasonably write by hand.

zero overhead 是 C++ 設計特性(功能)時遵循的一種原則

  • 你不需要為你不使用的特性付出(時間或空間)開銷;
  • 你使用的任何特性都應該儘可能地高效,至少要和你自己手寫程式碼實現該特性的效能一致。

C++ 中僅有兩個特性不符合 zero overhead 原則,即執行時型別識別(RTTI)和異常,因此大多數編譯器都可以關掉它們。

瞭解 C++ 的設計約束後,我們來思考該怎樣實現物件導向的三大基本特性。

物件導向的三大基本特性在 C++ 語法層面上的支援

先來看看這三種特性在 C++ 語法層面上的支援:

  • 封裝
    • 主要體現在存取控制,C++ 用存取修飾符 private、public、protected 來表達成員的可存取性;
    • 此外,C++ 支援公有、私有和保護繼承,以控制派生類使用者(包括派生類的派生類在內)對於基礎類別成員的存取許可權。
  • 繼承
    • C++ 支援單繼承、多繼承和虛繼承。
  • 多型
    • C++ 實現了動態多型(虛擬函式和動態繫結)和靜態多型(模板範例化);
    • 支援派生類指標到基礎類別指標的隱式型別轉換。

我們關注的是這些特性在 C++ 中的實現(更具體地,是這些特性對類佈局的影響以及它們帶來的執行時開銷),這些語言特性的細節請參考 《C++ Primer》 中的相關章節,在此不再贅述。

C++ 物件佈局

先來看看不考慮 OOP 特性的情況下(不包含繼承和虛擬函式),C++ 物件的佈局。

先介紹兩種可選的物件模型設計(當然,它們並沒有在實踐中被使用)。

簡單物件模型

簡單物件模型的成員都放到物件外,物件裡維護一系列 slot 指向成員,通過 slots 的索引來取物件的地址。簡單物件模型的優勢是避免了不同型別的物件需要不同大小空間的問題,該模型下物件的大小是成員的數量乘以指標的大小。

雖然簡單物件模型沒有被使用,但 slot 的思想演化為類成員指標的概念。

表驅動的物件模型

表驅動的物件模型中,引入了兩個表格,成員資料表裡放置資料成員,成員函數表裡放置成員函數的地址,物件本身持有這兩個表的地址。表驅動的物件模型的優勢是物件成員變更不會影響物件本身的大小,缺點是存取成員函數需要一次間接定址,這點違背了 zero overhead 的設計哲學。

雖然表驅動的物件模型沒有被使用,但成員函數表是虛擬函式和動態繫結機制實現的基礎。

C++ 物件模型

C++ 物件模型由簡單物件模型演化而來(優化了空間和存取速度),它將靜態資料成員和成員函數(包括靜態的和非靜態的)放在物件外,因為它們是在類物件之間共用的;將非靜態資料成員放在物件內。

在此之上,我們再來考慮如何實現 OOP 特性。

封裝

存取修飾符的檢查是在編譯期完成,不會做執行時的檢查。

下面這個程式如果你直接用物件 a 或指標 pa 存取成員 x,編譯時都會報 error: ‘int A::x’ is private within this context 的錯誤,但用指標 pi 就可以存取這塊記憶體區域(這是很危險的行為),因此可以看出 C++ 並沒有真的對這塊記憶體區域做執行時的保護措施。

class A {
public:
  A(int x) : x(x) {}

private:
  int x;
};

int main() {
  A a(1);
  // cout << a.x << endl;
  A *pa = &a;
  // cout << pa->x << endl;
  int *pi = (int *)&a;
  cout << *pi << endl;
}

Any number of access specifiers may appear within a class, in any order. Member access specifiers may affect class layout: the addresses of non-static data members are only guaranteed to increase in order of declaration for the members not separated by an access specifier (until C++11) with the same access (since C++11).

存取修飾符對類佈局的影響主要體現在處於同一個存取區域(access section,即兩個存取修飾符之間的區域)中的非靜態成員變數保證以其宣告順序出現在記憶體佈局中,也就是說後宣告的變數,應該出現在高地址。這點在 C++11 中做了修改,改為了相同存取許可權的變數都要滿足該限制。

繼承

繼承對類佈局的影響

C++ 支援單繼承、多繼承和虛繼承,這裡我們先不管虛繼承,因為它的存在會引入太多的複雜度。

派生類物件中包含基礎類別的所有資料成員,讓我們來考慮這些成員應該放置在什麼位置上。

先考慮兩種可選的做法:

  • 給每個基礎類別在派生類物件內分配一個 slot,slot 指向基礎類別子物件。這種做法的缺點是存取基礎類別子物件是間接存取,有額外開銷;好處是派生類大小不受基礎類別物件大小影響
  • 另一種做法是表驅動的思路,把所有基礎類別子物件的地址放到一張表裡,派生類物件裡包含一個 bptr 指標指向這張表。缺點仍然是有間接存取開銷;好處是派生類大小不受基礎類別的數量影響。

兩種做法的共同的弊端是間接存取的開銷隨繼承鏈長度增加,一個可能的改進是空間換時間,讓派生類持有它直接基礎類別和所有間接基礎類別的參照(無論是放到 slot 裡還是放到基礎類別表裡)。

再來看看 C++ 物件模型對該問題的答案,C++ 將基礎類別子物件直接放在派生類物件內部。這樣的好處是空間緊湊、存取高效。相較於上面的兩種方案,C++ 的模型去掉了所有的間接存取,符合 zero overhead 的設計原則。

C++ 保證出現在派生類中的基礎類別子物件保持其原樣,即基礎類別子物件的記憶體佈局和一個獨立的基礎類別物件是相同的(包括為了對齊而插入的填充)。

基礎類別和派生類資料的佈局沒有先後的強制規定,但一般都會把基礎類別放到前面。

一般來說,具體繼承(與虛繼承相對)並不會引入空間和時間上的開銷。

如果你使用的是 g++ 編譯器,可以用 -fdump-class-hierarchy 選項讓它輸出類的記憶體佈局(新版本 g++ 中該選項變為 -fdump-lang-class)。

多繼承下的地址調整

多繼承中,如果將派生類指標轉換為基礎類別指標,需要編譯器的干涉。

這種問題主要發生在派生類物件和它第二和後續基礎類別之間的轉換:

  • 將派生類指標賦值給它第一基礎類別的指標,只需要單純的賦值就行,因為它們兩者的地址是相同的;
  • 但如果想要賦值給第二和後續基礎類別的指標,就需要修改地址,加上(或減去)中間的基礎類別子物件大小。
class X {
private:
  int x_;
};

class Y {
private:
  double y_;
};

class A : public X, public Y {
private:
  int a_;
};

int main(int argc, char const *argv[]) {
  A a;
  X *xp = &a;
  Y *yp = &a;
  printf("%p\n", &a);
  printf("%p\n", xp);
  printf("%p\n", yp);
}

範例程式的輸出為:

0x7ffc1542efd0
0x7ffc1542efd0
0x7ffc1542efd8

明明 X 型別的大小是 4 位元組,但為什麼指標 yp 移動了 8 呢?這裡我們列印類的佈局:

Class X
   size=4 align=4
   base size=4 base align=4
X (0x0x7f2dd596d2a0) 0

Class Y
   size=8 align=8
   base size=8 base align=8
Y (0x0x7f2dd596d300) 0

Class A
   size=24 align=8
   base size=20 base align=8
A (0x0x7f2dd59f8070) 0
  X (0x0x7f2dd596d360) 0
  Y (0x0x7f2dd596d3c0) 8

可以看到 Y 的對齊要求是 8 位元組,因此編譯器在 X 子物件後面插入了 4 位元組的 padding。

只要不存在虛繼承,多繼承中存取資料成員並不會帶來額外的開銷,因為所有資料成員的偏移量在編譯期就已經確定了。

虛繼承的影響

預設情況下,派生類中含有繼承鏈上每個類對應的子部分,如果某個類在派生過程中出現了多次,則派生類中將包含該類的多個子物件。

虛繼承主要用來解決鑽石繼承(或菱形繼承)帶來的問題,比如標準庫中的 IO 類:

base_ios 抽象基礎類別負責儲存流的緩衝內容並管理流的狀態,且 iostream 希望在同一個緩衝區中進行讀寫操作,也要求條件狀態能同時反映輸入和輸出操作的情況。因此如果 iostream 包含有兩份 base_ios 的子物件,顯然是會產生歧義的。

虛繼承使得某個類做出宣告,承諾願意共用它的基礎類別。不管虛基礎類別在繼承體系中出現了多少次,在派生類中都只包含唯一一個共用的虛基礎類別子物件。

虛繼承和建構函式

在虛繼承中,虛基礎類別是由「最底層的派生類」初始化的。

繼承體系中的每個類都可能在某個時刻成為「最底層的派生類」。比如,當我們創造 iostream 的物件時,它是最底層, base_ios 的初始化由它負責;但當我們構造 istreamostream 的物件時,它們就變成了最底層,base_ios 的初始化由它們的建構函式負責。

因此任何繼承自虛基礎類別的派生類別建構函式都應該初始化它的虛基礎類別。

物件的構造流程

因此考慮虛繼承的情況下,物件的構造流程是

  • 初始化該物件的虛基礎類別部分,一個類可以有多個虛基礎類別,這些虛的子物件按照它們在派生列表中出現的位置從左向右依次構造;
  • 按照直接基礎類別在派生類列表中出現的次序依次對其進行初始化,要注意,直接基礎類別的構造順序與它們在初始化列表中出現的順序無關;
  • 設定 vptr (參考章節 《vptr 的設定》);
  • 構造初始化列表中的資料成員;
  • 如果有類型別的資料成員不在初始化列表中,它們的預設建構函式會按照宣告順序被呼叫,但內建型別的非靜態資料成員的初始化是程式設計師的責任;
  • 呼叫建構函式體。

此外,編譯器也會插入程式碼做適當的設定,使得虛擬函式和虛繼承機制能生效。

虛繼承的開銷

本章節內容和虛擬函式表相關,如果不瞭解這部分知識,請先閱讀《多型》這一章節。

通過物件來存取虛基礎類別成員是不會引發開銷的,因為偏移量在編譯期就能確定下來,開銷發生在用指標和參照取用虛基礎類別成員時。

class X {
public:
  int i;
};

class A : public virtual X {
public:
  int j;
};

class B : public virtual X {
public:
  double d;
};

class C : public A, public B {
public:
  int k;
};

// cannot resolve location of pa->X::i at compile-time
void foo(A *pa) { pa->i = 1024; }

int main() {
  foo(new A);
  foo(new C);
  // ...
}

範例程式碼中,pa->i 在物件中的偏移量在編譯期無法確定,因為無法知道 pa 所指物件的具體型別,編譯器會插入程式碼以允許在執行時確定它(比如在派生類中放置一個指標指向虛基礎類別的位置)。

虛繼承的實現

一般來說虛繼承的實現會將類分割為兩部分:不變部分和可變部分

  • 不變部分的偏移量是確定的,存取時不會有額外開銷;
  • 可變部分即虛基礎類別,只能間接存取(各家編譯器對間接存取的實現是不同的)。

cfront 的實現方案

在每個派生類物件中安插一些指向虛基礎類別的指標。

這種方案的缺點在於:

  • 指標的個數隨虛基礎類別的數量上升;
  • 虛繼承鏈層數增加,間接存取的次數也會增加。

MetaWare 的改進

為了解決上面的第二個問題,MetaWare 將虛繼承鏈中每一個虛基礎類別的指標都放到派生類裡,付出一些空間的開銷,這樣間接存取的時間就是固定的了。

微軟的改進

為了解決第一個問題,一般有兩種方案,微軟採取引入一個虛基礎類別表,將虛基礎類別的指標都放在表格裡,並在物件內放置一個虛基礎類別表指標,指向這個表。

另一種改進方法

解決第一個問題的另一種方法也是用表格,不過表格裡放的不是指標,而是偏移量。(和微軟的方法沒有本質上區別,選擇這種方法的原因好像是因為智慧財產權方面的問題)

Effective C++ 關於虛繼承給我們的建議是:使用虛繼承的建議是:非必須不使用虛繼承。如果要使用虛繼承,儘可能不要在虛基礎類別中放入資料成員。

我使用的編譯器採用的就是這種方法的變體,不過它沒有使用單獨的虛基礎類別表,而是作為虛擬函式表的一部分。我們檢視範例程式碼 dump 出來的類結構:

Vtable for C
C::_ZTV1C: 6u entries
0     36u
8     (int (*)(...))0
16    (int (*)(...))(& _ZTI1C)
24    20u
32    (int (*)(...))-16
40    (int (*)(...))(& _ZTI1C)

虛表中有兩項 36u 和 20u,表示在 this 指標的基礎上加上這個偏移量就能拿到虛基礎類別。

多型

Wiki 上將多型定義為:在程式語言和型別論中,多型(英語:polymorphism)指為不同資料型別的實體提供統一的介面,或使用一個單一的符號來表示多個不同的型別

C++ 中支援兩種多型機制:動態多型表現為通過基礎類別指標或參照呼叫虛擬函式時會執行動態繫結,根據繫結到指標或參照上的動態型別來決定呼叫呼叫虛擬函式的哪個版本;靜態多型(編譯期多型)表現為用不同的模板實參範例化模板會導致不同的函數呼叫,STL 的實現中大量使用靜態多型。

本文主要關注動態多型的實現,即虛擬函式和動態繫結機制是怎麼體現在物件模型中的。

虛擬函式表

實現上,C++ 通過虛擬函式表來支援虛擬函式機制,繼承體系中的每個類都會有一張虛擬函式表(繼承體系中的類一定要有虛擬函式,關於這一點請看 Effective C++ 條款 7),裡面記錄的是虛擬函式範例的地址,包括:

  • 這個類定義的函數範例
    • 可能會 override 掉基礎類別的函數範例
  • 繼承自基礎類別的函數範例
  • 純虛擬函式的表項指向pure_virtual_called()
    • 當它被錯誤地呼叫時,該程式會被結束掉

每個虛擬函式都會被分配一個索引值(由編譯器來決定),通過該索引值查虛擬函式表以實現動態繫結。假設 normalize() 是一個虛擬函式,則對它的呼叫 ptr->normalize() 會被轉換為 ( * ptr->vptr[1])( ptr )

那麼物件該怎麼拿到自己的虛擬函式表呢,為此,如果某個型別有虛擬函式,它的物件內就會包含虛擬函式表指標(vptr),指向該型別的虛擬函式表。此外,RTTI 相關的資訊往往也是通過 vptr 獲得的。新增 vptr 後,C++ 物件的佈局如下圖所示:

這裡我們展示一個範例程式,希望讀者能對虛擬函式表的實現能有更形象的感知:

using VoidFunc = void();

class A {
public:
  virtual void vfunc1() { cout << "A::vfunc1()" << endl; }
  virtual void vfunc2() { cout << "A::vfunc2()" << endl; }
};

class B : public A {
public:
  virtual void vfunc1() override { cout << "B::vfunc1()" << endl; }
};

class C : public B {
public:
  virtual void vfunc1() override { cout << "C::vfunc1()" << endl; }
};

int main() {
  A a;
  VoidFunc **vptr_a = ((VoidFunc **)(*(long *)&a));
  vptr_a[0]();
  vptr_a[1]();
  printf("%p\n", vptr_a);
  printf("%p\n", vptr_a[0]);
  printf("%p\n", vptr_a[1]);

  B b;
  VoidFunc **vptr_b = ((VoidFunc **)(*(long *)&b));
  vptr_b[0]();
  vptr_b[1]();
  printf("%p\n", vptr_b);
  printf("%p\n", vptr_b[0]);
  printf("%p\n", vptr_b[1]);

  C c;
  VoidFunc **vptr_c = ((VoidFunc **)(*(long *)&c));
  vptr_c[0]();
  vptr_c[1]();
  printf("%p\n", vptr_c);
  printf("%p\n", vptr_c[0]);
  printf("%p\n", vptr_c[1]);
}

這個範例程式的輸出為:

A::vfunc1()
A::vfunc2()
0x55f619c17d30
0x55f619c15400
0x55f619c1543c
B::vfunc1()
A::vfunc2()
0x55f619c17d10
0x55f619c15478
0x55f619c1543c
C::vfunc1()
A::vfunc2()
0x55f619c17cf0
0x55f619c154b4
0x55f619c1543c

讓我們將這個程式的輸出畫出來,可以看到 B 和 C override 了 vfun1 後,虛擬函式表中的表象指向了各自的函數範例。

分析 -fdump-class-hierarchy 的輸出我們也能得到同樣的結論:

Vtable for A
A::_ZTV1A: 4u entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI1A)
16    (int (*)(...))A::vfunc1
24    (int (*)(...))A::vfunc2

Vtable for B
B::_ZTV1B: 4u entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI1B)
16    (int (*)(...))B::vfunc1
24    (int (*)(...))A::vfunc2

Vtable for C
C::_ZTV1C: 4u entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI1C)
16    (int (*)(...))C::vfunc1
24    (int (*)(...))A::vfunc2

c++filt 命令解析這裡的符號會發現,_ZTV 是虛表符號的字首,比如 _ZTV1B 表示 vtable for B_ZTI 是型別資訊的字首,比如 _ZTI1C 表示 typeinfo for C,虛擬函式表中這個表項就是指向 typeinfo 的指標。所以這裡也驗證了我們之前說的,可以通過 vptr 拿到 RTTI 相關資訊。

雖然我們的範例程式在物件的開頭拿到了 vptr,但事實上標準並沒有指定 vptr 的位置,甚至沒有規定實現必須通過虛擬函式表的方式來實現動態繫結(雖然基本上所有編譯器都是這麼做的)。編譯器可以將 vptr 放在物件的開頭、結尾,甚至中間的某個位置上(雖然並沒有編譯器這樣做),因此你的程式碼不應該依賴於 vptr 的位置,否則是沒有可移植性的。

vptr 的設定

我們現在知道動態繫結是用虛擬函式表和 vptr 來實現的,但問題在於 vptr 是由誰來設定的呢?很顯然,我們自己的程式碼裡沒有做這樣的設定。其實是編譯器將初始化 vptr 的程式碼插入到我們的建構函式中。這會帶來一些微妙的後果,主要發生在我們在建構函式和解構函式中呼叫虛擬函式的情況下。(參考 Effective C++ 條款 9)

我們先來看一個範例程式:

class Base {
public:
  Base() {
    cout << "In Base ctor" << endl;
    func();
  }
  virtual ~Base() {
    cout << "In Base destructor" << endl;
    func();
  }
  virtual void func() { cout << "type is Base" << endl; }
};

class D : public Base {
public:
  D() {
    cout << "In D ctor" << endl;
    func();
  }
  virtual ~D() {
    cout << "In D destructor" << endl;
    func();
  }

  virtual void func() override { cout << "type is D" << endl; }
};

int main(int argc, char const *argv[]) { D d; }

範例程式的輸出為:

In Base ctor
type is Base
In D ctor
type is D
In D destructor
type is D
In Base destructor
type is Base

可以看到在基礎類別 Base 的構造和解構函式中呼叫虛擬函式時,呼叫到的是基礎類別的版本;而在派生類 D 的構造和解構函式中呼叫虛擬函式時,呼叫到的是派生類的版本。

原因是顯而易見的,不考慮虛繼承的情況下,C++ 中物件的構造順序是先按派生列表中的順序構造基礎類別、再構造派生類;解構時的順序與構造相反。(參考章節《物件的構造流程》,裡面我們給出了一個較為完善的物件構造流程)在基礎類別建構函式中,派生類的成員還沒有被初始化,而派生類虛擬函式可能會去存取這些成員,此時呼叫它是危險的;同理,在解構函式中,派生類的成員已經被銷燬了,也不應該允許派生類虛擬函式被呼叫。

其實這個特殊規則是通過在合理的時機設定 vptr 來實現的:編譯器插入的程式碼會在呼叫完虛基礎類別和直接基礎類別的建構函式後,再將 vptr 指向當前物件型別的虛擬函式表。這樣產生的效果就是,在 Base 的建構函式中,vptr 指向的是 Base 的虛擬函式表;在 D 的建構函式中,vptr 指向的是 D 的虛擬函式表。同樣地,在解構物件時,也會對 vptr 做相應的調整。

為了避免這種場景下的 BUG,《Effective C++》給我們的建議是 Never call virtual functions during construction or destruction

動態繫結的開銷

講完動態繫結的實現,我們來看看它會帶來哪些開銷:

空間開銷上,繼承體系中的每個類都需要虛擬函式表,且這些類的每個物件都需要 vptr。

The time it takes to call a virtual member function is a few clock cycles more than it takes to call a non-virtual member function, provided that the function call statement always calls the same version of the virtual function. If the version changes then you may get a misprediction penalty of 10 - 20 clock cycles.

時間開銷上,虛擬函式呼叫需要查詢虛表做間接定址,要比一般的函數呼叫更慢,而且如果 CPU 分支預測錯誤,需要衝刷流水線,就會消耗更多的時鐘週期。

另一個可能影響程式效能的問題是,編譯器可能沒法對虛擬函式呼叫做內聯優化,因為具體呼叫的版本是在執行時才確定的。但某些情況下,如果編譯期能確定下來你呼叫的到底是哪個版本的虛擬函式(比如通過類物件,而不是類參照來呼叫),其實是可以做內聯優化的。因此將函數宣告為 inline virtual 其實是有意義的[7],這也從另一個側面說明了,inline 關鍵字是對編譯器的建議,而不是一種強制性要求。(關於內聯的更多細節請看 Effective C++ 條款 30)

如果虛擬函式是程式的效能瓶頸,可以考慮不使用多型來實現類似的功能(Effective C++ 條款 35 中給出了一些虛擬函式以外的其他選擇),或嘗試編譯期多型,它的效率更高,因為模板引數的解析的在編譯期完成的,不涉及執行時的開銷,但缺點在於模板的語法過於複雜。

多繼承和虛擬函式

接下來,我們討論多繼承下虛擬函式的實現。多繼承對虛擬函式的影響主要體現在兩方面:調整 this 指標和產生額外的虛擬函式表。

調整 this 指標

為了在多繼承下支援虛擬函式,其困難來自於第二和後續的基礎類別,必須在執行期調整 this 指標。對 this 指標的調整會發生在三種情況下:

  • 通過第二和後續的基礎類別型別的指標呼叫派生類虛擬函式;
  • 通過派生類指標,呼叫從第二和後續的基礎類別型別繼承來的虛擬函式;
  • 如果某個虛擬函式的返回值是基礎類別指標型別,派生類重寫它的時候,可以改變它的返回值型別為派生類指標型別。
class Base1 {
public:
  virtual ~Base1() {}
  virtual Base1 *clone() { cout << "Base1::clone()" << endl; }

private:
  int i_;
};
class Base2 {
public:
  virtual ~Base2() {}
  virtual Base2 *clone() { cout << "Base2::clone()" << endl; }

private:
  int j_;
};
class Derived : public Base1, public Base2 {
public:
  virtual ~Derived() {}
  virtual Derived *clone() { cout << "Derived::clone()" << endl; }

private:
  int k_;
};

int main() {
  Base2 *bp2 = new Derived;    // <1>
  Base2 *other = bp2->clone(); // <2>
  // ...
  delete bp2; // <3>
}

第 <1> 行動態分配 bp2 時,需要將新分配的 Derived 物件地址調整到 Base2 子物件的地址上再賦值給 bp2,這個調整必須在編譯期完成的。否則,即使通過非多型的方式(比如用 bp2 來存取 Base2 的資料成員)來使用 bp2 指標也會造成錯誤。

編譯器會插入類似的程式碼來做調整:

Derived *temp = new Derived;  
Base2 *pbase2 = temp ? temp + sizeof( Base1 ) : 0;

第 <2> 行 clone() 返回的 Derived 指標要被調整為 Base2 子物件的地址;第 <3> 行通過 bp2 刪除 Derived 物件時,必須調整 bp2 的值,讓它指向物件的開頭。這些調整,並不能在編譯期完成,因為此時無法確定 bp2 指向的具體物件的型別,這種調整一般放到執行期來完成,為了實現這點編譯器必須在某處插入調整的程式碼。

實現調整的一種做法

擴大虛擬函式表,讓它包括虛擬函式地址和可能的調整值(不需要做調整的情況下,offset 等於 0)。

使用這種方法,虛擬函式呼叫 ( *pbase2->vptr[1])( pbase2 ); 會被編譯器轉換為:

( *pbase2->vptr[1].faddr)  
   ( pbase2 + pbase2->vptr[1].offset );

這種做法的缺陷在於它事實上給所有虛擬函式呼叫都帶來了開銷,不管它是否需要做調整。

thunk 技術

thunk 是指一段組合程式碼,它需要做兩件事:用適當的偏移量調整 this 指標,然後再跳到虛擬函式去。

比如用 Base2 指標呼叫 Derived 解構函式的 thunk 可能類似於:

// Pseudo C++ code  
pbase2_dtor_thunk:  
   this += sizeof( base1 );
   Derived::~Derived( this );

使用 thunk 技術,虛擬函式表中仍可以僅包含指標,它可以指向虛擬函式,(需要調整 this 指標時)也可以指向一個 thunk。

額外的虛擬函式表

Base1 *bp1 = new Derived;
Base2 *bp2 = new Derived;
delete bp1;
delete bp2;

雖然上面兩個 delete 最終呼叫的都是同一個解構函式,但它們需要不同的虛擬函式表 slot(一個指向虛擬函式範例,一個指向 thunk)。

由於派生類自己的虛擬函式表是和第一個基礎類別的虛擬函式表共用的,因此多繼承中一個派生類包含 n-1 個額外的虛擬函式表,n 表示直接基礎類別的個數。每個虛擬函式表都會以外部物件的形式產生出來,並被給予一個獨一無二的名字。

另一種可選的做法是將多個虛擬函式表連為一個,通過偏移量來獲得額外表格的內容。我所使用的編譯器就是採取了這種做法:

Class Derived
   size=32 align=8
   base size=32 base align=8
Derived (0x0x7f8b8239e8c0) 0
    vptr=((& Derived::_ZTV7Derived) + 16u)
  Base1 (0x0x7f8b82255900) 0
      primary-for Derived (0x0x7f8b8239e8c0)
  Base2 (0x0x7f8b82255960) 16
      vptr=((& Derived::_ZTV7Derived) + 56u)

Vtable for Derived
Derived::_ZTV7Derived: 10u entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI7Derived)
16    (int (*)(...))Derived::~Derived
24    (int (*)(...))Derived::~Derived
32    (int (*)(...))Derived::clone
40    (int (*)(...))-16
48    (int (*)(...))(& _ZTI7Derived)
56    (int (*)(...))Derived::_ZThn16_N7DerivedD1Ev
64    (int (*)(...))Derived::_ZThn16_N7DerivedD0Ev
72    (int (*)(...))Derived::_ZTchn16_h16_N7Derived5cloneEv

從偏移量 16 開始是 Derived 和 Base1 共用的虛擬函式表,從偏移量 56 開始是 Base2 的虛擬函式表,_ZThn8_N7DerivedD1Ev 表示 non-virtual thunk to Derived::~Derived()_ZTchn8_h8_N7Derived5cloneEv 表示 covariant return thunk to Derived::clone(),由此可以判斷該編譯器是通過 thunk 技術來做 this 指標的調整。

因此當你用 bp2 調解構函式時,就會去查詢 Base2 的虛擬函式表,呼叫到的就是 thunk,而用 bp1 呼叫時,呼叫到的是真正的解構函式範例。

兩個虛表的 typeinfo 上面都有一個數位,一個是 0,一個是 -16,這個數位稱為 top_offset,這個數位就是說當前的 this 指標要調整多少個位元組,才能拿到整個物件的開始位置,如果是 Base2*,就需要減 16 個位元組才能拿到物件的開頭,可以試著增加 Base1 的大小,觀察這個數位的變化。

至於這裡的虛表裡為什麼有兩個虛解構函式,其實是 Itanium C++ ABI 的要求,感興趣的讀者可以看參考材料[9]。

虛表和 vague linkage

之前我們講過 C++ 繼承了 C 語言的編譯模型,也就是說 C++ 程式要先將每個 .cpp 檔案編譯為 .o 檔案,再連結在一起成為可執行檔案。這種編譯模型決定了編譯的時候看不到其他編譯單元的資訊,因此在某些情況下[8],編譯器(這裡說的是狹義的編譯器,只產生 .o 檔案,不做連結)可能沒法確定其他編譯單元是否可能會產生虛擬函式表,只能自己產生一份,結果是多個編譯單元中都可能產生同一張虛擬函式表的定義。

這種同一個符號有多份互不衝突的定義的情形稱為 vague linkage。這些重複的定義最終由連結器來處理,連結器往往會消除重複程式碼,只留下一份定義。除虛擬函式表以外,vague linkage 還可能發生在行內函式、模板範例化和 type_info 物件上。

參考材料