本篇從 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」,但多態帶了一定空間上的開銷,在效率上也有折損。
其實, 多態機制可以歸結為下面三這句話:
- 一般而言, 我們無法知道指針 ptr 所指的對象的真正類型. 但經由 ptr 總是可以存取到對象的 virtual table.
- 虛函數 fn() 總是放在 virtual table 中的固定位置, 用一個固定的索引值就可以 fetch 到.
- 唯一一個執行期需要知道的是 ptr 所指的對象.
下面是 C++ 多態機制實現詳解.
從最簡單的對象模塊開始
最為簡單的對象模型:
靜態/非靜態 成員函數 和 靜態/非靜態 成員變量 的地址都存儲在一個表當中,通過表內存儲的地址指向相應的部分。這樣的設計簡易,便於理解,類的實例只需要維護這張表就好了,賠上的是空間和執行效率:
空間上:沒必要為每一個實例都存儲靜態成員變量和成員函數
效率上:每次執行實例的一個成員函數都要在表內進行搜索
這是最初的假設,實際的實現肯定沒有那么簡單,下面是將變量和函數分割存儲的模型(表格驅動對象模型):
簡易對象模型經改良后可以的得到這種。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