本篇從 C++ 初學者遇到的一個有趣的問題開始。
考慮下面的 C++ 程式:
class A{ void func(){}};class B:public A{ void func(){}};int main(void){ cout << sizeof(A) << " " << sizeof(B) << endl; return 0;}
輸出結果是:1 1
再考慮下面很相似的程式:
class A{ virtual void funcA(){}};class B:public A{ virtual void funcB(){}};int main(void){ cout << sizeof(A) << " " << sizeof(B) << endl; return 0;}
輸出結果是:4 4
再來考慮下面的形似的程式:
class A{ virtual void funcA(){}};class B:virtual public A{ virtual void funcB(){}};int main(void){ cout << sizeof(A) << " " << sizeof(B) << endl; return 0;}
輸出結果是:4 12
對於第一種情況,沒有出現虛函數,也無任何成員變數,因此是一個空類,空類理論上可以進行執行個體化,每個執行個體在記憶體中都有獨一無二的地址來標明,所以會佔用 1B 的空間,無可厚非。
但第二種情況和第三種情況加入了虛函數(virtual function),而且在第三種情況當中,引入了虛基類(virtual base class)的概念,所得到的結果大相徑庭,這是 C++ 引入了 virtual function 和 virtual base class,即多態,更形象的解釋是「以一個 public base class 的指標或者引用,定址出一個 derived class object」,但多態帶了一定空間上的開銷,在效率上也有折損。
最為簡單的物件模型:
靜態/非靜態 成員函數 和 靜態/非靜態 成員變數 的地址都儲存在一個表當中,通過表記憶體儲的地址指向相應的部分。這樣的設計簡易,便於理解,類的執行個體只需要維護這張表就好了,賠上的是空間和執行效率:
空間上:沒必要為每一個執行個體都儲存靜態成員變數和成員函數
效率上:每次執行執行個體的一個成員函數都要在表內進行搜尋
這是最初的假設,實際的實現肯定沒有那麼簡單,下面是將變數和函數分割儲存的模型(表格驅動物件模型):
簡易物件模型經改良後可以的得到這種。sizeof(A) 的結果是 8。
為支撐 virtual function ,引入了現在的 C++ 物件模型:
非靜態成員變數同指向虛擬函數表的指標(vptr),和靜態成員變數/函數,非靜態成員函數分離儲存。類的每一個執行個體都存有 vptr 和 非晶態成員函數,他們獨立擁有這些資料,並不和其他的執行個體共用。這時候,回到第二種情況,class A 和 繼承自 A 的 class B 都擁有虛函數,因此都會有一個 vptr,因此 sizeof 運算得到的結果都為 4.然而,如果往裡面添加一個非靜態 int 型變數,那麼相應可以得到 8B 的大小;但往裡面添加靜態 int 型變數,大小卻沒有改變。
單一繼承
下面是單一繼承裡經常看到的一個程式:
class A{public:int a;void foo(){}virtual void funcA(){}virtual void func(){cout << "class A's func." << endl;}};classB : public A{public:int b;void foo(){}virtual void funcB(){}virtual void func(){cout << "class B's func." << endl;}};int main(void){A *pa = newB;pa->func();}
輸出結果是:class B'sfunc.
多態就是多種狀態,一個事物可能有多種表現形式,譬如動物,有十二生肖甚至更多的表現形式。當基類裡實現了某個虛函數,但衍生類別沒有實現,那麼類 B 的執行個體裡的虛函數表中放置的就是 &A::func。此外,衍生類別也實現了虛函數,那麼類 B 執行個體裡的虛函數表中放置的就是 B::func。A *pa = new B; 因為 B 實現了 func,那麼它被放入 A 執行個體的虛擬函數表中,從而代替 A 執行個體本身的虛擬函數。pa->func(); 調用的結果就不稀奇了,這是虛函數機制帶來的。
class A 和 class B 的記憶體布局和 vptr 可能是下面的樣子:
- ----------
- | int a |
- ----------
- | vptr | -------->| &A::funcA()
- ---------- -------------------------------------------------
- | &A::func()
- -------------------------------------------------
- ----------
- | int a |
- ----------
- | vptr | -------->| &A::funcA() 依舊是 A 的虛函數
- ---------- -------------------------------------------------
- | int b | | &B::func() A::func()
- ---------- -------------------------------------------------
- | &B::funcB()
- -------------------------------------------------
倘若 虛函數 以外的就沒有「多態」效果了,除非進行強制類型轉換:
- pa->a; // 成功,因為 pa 的類型就是 A
- pa->b; // 失敗,因為 B::b
- pa->funcB(); // 失敗,因為B::funcB() 不是虛函數
- pa->funcA(); // 成功,因為A::funcA()
總結一下:
- 當引入虛函數的時候,會添加 vptr 和 其指向的一個虛擬函數表從而增加額外的空間,這些資訊在編譯期間就已經確定,而且在執行期不會插足修改任何內容。
- 在類的構造和解構函式當中添加對應的代碼,從而能夠為 vptr 設定初值或者調整 vptr,這些動作由編譯器完成,class 會產生膨脹。
- 當出現繼承關係時,虛擬函數表可能需要改寫,即當用基類的指標指向一個衍生類別的實體地址,然後通過這個指標來調用虛函數。這裡要分兩種情況,當衍生類別已經改寫同名虛函數時,那麼此時調用的結果是衍生類別的實現;而如果衍生類別沒有實現,那麼調用依然是基類的虛函數實現,而且僅僅在多態僅僅在虛函數上表現。
- 多態僅僅在虛函數上表現,意即倘若同樣用基類的指標指向一個衍生類別的實體地址,那麼這個指標將不能訪問和調用衍生類別的成員變數和成員函數。
- 所謂執行期確定的東西,就是基類指標所指向的實體地址是什麼類型了,這是唯一執行期確定的。以上是單一繼承的情況,在多重繼承的情況會更為複雜。
多重繼承
下面是少有看到的程式碼:
class A{public:virtual ~A(){cout << "A destruction" << endl;}int a;void fooA(){}virtual void func(){cout << "A func." << endl;};virtual void funcA(){cout << "funcA." << endl;}};class B{public:virtual ~B(){cout << "B destruction" << endl;}int b;void fooB(){}virtual void func(){cout << "B func." << endl;};virtual void funcB(){cout << "funcB." << endl;}};class C : public A,public B{public:virtual ~C(){cout << "C destruction" << endl;}int c;void fooC(){}virtual void func(){cout << "C func." << endl;};virtual void funcC(){cout << "funcC." << endl;}};int main(void) { return 0;}
當用基類的指標指向一個衍生類別的實體地址,基類有兩種情況,一種是 class A 和 class B,如果是 A,問題容易解決,幾乎和上面單一繼承情況類似;但倘若是 B,要做地址上的轉換,情況會比前者複雜。先展現class A,B,C 的記憶體布局和 vptr:
- ----------
- | int a |
- ----------
- | vptr | -------->| &A::~A()
- ---------- -------------------------------------------------
- | &A::func()
- -------------------------------------------------
- | &A::funcA()
- -------------------------------------------------
- ----------
- | int b |
- ----------
- | vptr | -------->| &B::~B()
- ---------- -------------------------------------------------
- | &B::func()
- -------------------------------------------------
- | &B::funcB()
- --------------------------------------------------
- | &C::~C() &A::~A()
- ---------- -------------------------------------------------
- | int a | | &C::func() &A::func()
- ---------- -------------------------------------------------
- ---------- | &C::funcC()
- | vptr | -------->-------------------------------------------------
- ---------- | &A::funcA()
- ---------- -------------------------------------------------
- | int b | | &B::funcB() 跳
- ---------- -------------------------------------------------
- ----------
- | vptr | -------->| &C::~C() &B::~B() 跳
- ---------- -------------------------------------------------
- | int c | | &C::func() &B::func() 跳
- ---------- -------------------------------------------------
- | &B::funcB()
- --------------------------------------------------
多重繼承中,會有保留兩個虛擬函數表,一個是與 A 共用的,一個是與 B 相關的,他們都在原有的基礎上進行了修改:
對於 A 的虛擬函數表:
- 覆蓋衍生類別實現的同名虛函數,並用衍生類別實現的解構函式覆蓋原有虛函數
- 添加了衍生類別專屬的虛函數
- 添加了右端父類即 B 的專屬虛函數,需跳轉
對於 B 的虛擬函數表:
- 覆蓋衍生類別實現的同名虛函數,並用衍生類別實現的解構函式覆蓋原有虛函數,但需跳轉
- int main(void)
- {
- A *pa = new C;
- B *pb = new C;
- C *pc = new C;
- pa->func();
- pb->func();
- pc->funcC();
- delete pb;
- delete pa;
- delete pc;
- }
輸出結果是:
C func.
C func.
funcC.
C destruction
B destruction
A destruction
C destruction
B destruction
A destruction
C destruction
B destruction
A destruction
7 行和 8 行的行為有很大的區別,7 行的調用和上面的單一繼承的情況類似,不贅述。8 行的 pb->func(); 中,pb 所指向的是第 9 行的位置,編譯器已在內部做了轉換,也就是 pa 和 pb 所指的位置不一樣,pa 指向的是第 3 行的位置。接著需要注意的是,pb->func(); 調用時,在虛擬函數表中找到的地址需要再進行一次跳轉,目標是 A 的虛擬函數表中的 &C::func(),然後才真正執行此函數。所以,上面的情況作了指標的調整。
那什麼時候會出現跳,常見的有兩種情況:
- 右端基類,對應上面的具體是 B,調用衍生類別虛擬函數,比如 pb->~C() 和 pb->func()
- 衍生類別調用右端基類的虛擬函數,比如 pc->funcB()
所以 delete pa; 和 delete pa; 的操作是不一樣的,pb->funcB(); 和 pc->funcB(); 也不一樣。
C++ 為實現多態引入虛函數機制,帶來了空間和執行上的折損。
單一繼承和多重繼承的構造和析構
單一繼承中,建構函式調用順序是從上到下(單一繼承),從左至右(多重繼承),解構函式調用順序反過來。在上一段程式中,
- delete pa;
- delete pb;
- delete pc;
都自動調用了基類和衍生類別的解構函式,其中只有 delete pc; 涉及了虛擬函數機制。《Effective C++》中07條款中有這樣一句話:當derived class 對象經由一個 base 指標被刪除,而該對象帶有一個 non-virtual 解構函式,其結果未有定義---實際執行時通常發生的是對象的 derived 成分未被銷毀。
特地,寫了下面的程式:
class A{public:~A(){cout << "A destruction" << endl;}int a;};class B{public:~B(){cout << "B destruction" << endl;}};class C : public A,public B{public:~C(){cout << "C destruction" << endl;}};int main(void){A *pa = new C;B *pb = new C;C *pc = new C;delete pa; // 沒有問題delete pb; // 出錯delete pc; // 沒有問題}
所說的「未定義」就在 delete pa; 和 delete pb; 體現出來。
強烈建議,在設計繼承關係的時候,為每一個基類實現 virtual 解構函式。
回到開始的問題:
- 第一種情況是因為編譯器安插了一個位元組,為的是一個類的對象能再記憶體有獨一無二的地址,無可厚非。
- 第二種情況是因為編譯器安插了 vptr。
- 第三種情況是因為編譯器除了安插 A 和 B 的 vptr 外,還有一個指向虛基類的指標。
另外,虛擬繼承在應用比較少應用,一個例子就是:
class ios {...};class istream : public virtual ios {...};calss ostream : public virtual ios {...};class iostream : public istream,public ostream {...};
這裡 istream,ostream,iostream 共用同一份 ios。要和下面的情況區分開來:
class ios {...};class istream : public ios {...};calss ostream : public ios {...};class iostream : public istream,public ostream {...};
這裡實際有兩份 ios !全文完。daoluan.net