閱讀621 返回首頁    go 技術社區[雲棲]


Way on c & c++ 小記 [八] – 自底向上地探究虛函數

自底向上地探究虛函數


作者:Jason Lee @https://blog.csdn.net/jasonblog

日期:2010-05-19

環境:Visual C++ Express2008

聲明:本文發表在csdn博客,如有轉載,請注明出處

 

[1]C++對象模型基礎

一個類中可以包含靜態數據成員、靜態成員函數、非靜態成員函數和非靜態數據成員以及虛函數。其中,前三者(靜態數據成員、靜態成員函數、非靜態成員函數)都並沒有被放到對象的布局中,可以從以下兩段代碼得到驗證:

#include <iostream> using namespace std; class Base { }; int main(){ Base a; cout << sizeof (a) << endl;// 輸出1 return 0; }

上述的 Base 類是一個空類,占據了一個字節的內存空間,這是為了保證每個類實例化後都擁有獨一無二的內存空間。接著我們往 Base 類中添加靜態數據成員、靜態成員函數和非靜態成員函數:

#include <iostream> using namespace std; class Base { public : Base(){} ~Base(){} static int v1; static void f1(){} void f2(){} }; int main(){ Base a; cout << sizeof (a) << endl;// 仍然輸出1 return 0; }

在經過內容填充後, Base 類的實例 a 仍然僅占據 1 個字節的內存空間,與空類無異,這說明了靜態數據成員、靜態成員函數和非靜態成員函數並未被放在對象的內存布局當中

接下來往類中添加非靜態數據成員:

#include <iostream> using namespace std; class Base { public : int a; int b; }; int main(){ Base a; cout << sizeof (a) << endl;// 輸出8 cout << hex << &a << endl;// 輸出0012F 3CC cout << hex << &a.a << endl;// 輸出0012F 3CC cout << hex << &a.b << endl;// 輸出0012F 3D0 return 0; }

從上麵的代碼可以看出:一,非靜態數據成員是會被放到對象的內存布局中;二,數據成員是根據聲明順序有序地在內存中進行分布的;三,在沒有虛函數的情況下對象所占據的內存大小就是數據成員所占據的空間之和。布局如下圖:

a

b

那麼如果添加了虛函數以後呢?先看一段代碼:

#include <iostream> using namespace std; class Base { public : int a; int b; void doit(){ } protected : virtual void vf(){ cout << "hi" << endl; } virtual void vf_(){} }; int main(){ Base a; cout << sizeof (a) << endl;// 輸出12 cout << hex << &a << endl;// 輸出0012F 3C 8 cout << hex << &a.a << endl;// 輸出0012F 3CC cout << hex << &a.b << endl;// 輸出0012F 3D0 return 0; }

Base 基類中有兩個虛函數,這兩個虛函數(如果隻有一個虛函數情況也一樣)出現後使得對象 a 的大小變為 12 ,相較於沒有虛函數的情況多了 4 個字節,即 32 位,相當於一個指針所占的內存大小。

包含虛函數的類的對象實例中會在內存布局中多添加了一個 vptr 指針,這個指針指向(不僅)存放虛函數地址的虛表 vtbl ,所以不管類中隻有一個虛函數或者有多個,在對象實例中隻會多出一個指針需要的空間大小,即 4 個字節。

此外, vptr 通常存放在對象內存布局中的起始處 。從上一段代碼輸出的地址就可以看出成員變量 a 占據 0012F 3CC 0012F 3CF 的空間,成員變量 b 占據 0012F 3D0 0012F 3D3 的空間,而對象首址 0012F 3C 8 0012F 3CB 則是用來存放 vptr 的。總計 12 字節,布局如下圖:

vptr

a

b

 

[2] 繼承關係下的模型和指針類型

在單繼承的情況下,對象實例的內存布局中,基類部分位於子類部分前;而對於多繼承,不同的基類部分會按照繼承聲明順序在內存中陸續分布。如下是一個代碼示例:

#include <iostream> using namespace std; class Base { public : void doit(){ vf(); } int bv; protected : virtual void vf(){ cout << "Base" << endl; } }; class Base1 { public : void doit1(){ vf1(); } int b1v; protected : virtual void vf1(){ cout << "Base1" << endl; } }; class Demo : public Base, public Base1 { protected : void vf(){ cout << "Demo" << endl; } virtual void vf1(){ cout << "Demo1" << endl; } virtual void vf2(){} public : int dv; }; int main(){ Demo d; cout << sizeof (d) << endl;// 輸出 cout << hex << &d << endl;// 輸出F3B8 cout << hex << &d.bv << endl;// 輸出F3BC cout << hex << &d.b1v << endl;// 輸出F3C4 cout << hex << &d.dv << endl;// 輸出F3C8 Base *p = &d; p->doit();// 輸出Demo ,即執行子類重寫的虛函數 cout << hex << p << endl;// 輸出F3B8 Base1 *p1 = &d; cout << hex << p1 << endl;// 輸出F3C0 return 0; }

Base 基類因為有一個 vptr 和一個數據成員 bv ,所以占據了 8 個字節; Base1 基類同樣有 vptr 和數據成員 b1v ,所以也占據了 8 個字節;而子類 Demo 繼承了 Base Base1 ,又新增了一個數據成員 dv ,所以總共占據了 20 個字節的空間。

對象 d 占據了從 0012F 3B8 0012F 3CB 20 個字節內存空間,其中 Base 基類的部分位於 0012F 3B8 0012F 3BF 8 個字節, Base1 基類部分緊隨其後,占據 0012F 3C 0 0012F 3C 7 的內存空間,最後是子類本身的數據成員 dv 。布局如下圖所示:

vptr_Base

bv

vptr_Base1

b1v

dv

注意到指針 p p1 的聲明和賦值,以及所指向的首址。指針 p 的類型是 Base * ,它指向了對象 d 中的 Base 基類部分;指針 p1 的類型是 Base1 * ,它指向了對象 d 中的 Base1 部分。指針類型的作用是給予編譯器信息,表明指針指向的對象類型 (包含首址以及大小等信息),因為子類中含有基類部分,所以基類指針可以指向子類,更實質地講是指向子類中的基類部分。

既然 Base * 類型的指針指向的是內存中 Base 基類的部分,那麼為什麼運行下述語句會執行子類中的虛函數呢?

Base *p = &d; p->doit();// 輸出Demo ,即執行子類重寫的虛函數

首先我們確定指針 p 能訪問的隻有 vptr_Base bv 這兩個成員,其中 vptr_Base 指向一個虛表,虛表中存放著類型信息和虛函數的地址。這裏不妨認為 vptr_Base[1] 存放著虛函數 vf 的地址。

在編譯階段可以針對虛函數機製做的工作有:一,確定虛表的地址,即 vptr 的指向;二,確定虛表的大小和內容;三,針對不同虛函數的調用,轉換為對虛表不同表項的索引。在確定虛表的內容時,如果子類重寫了基類的虛函數,那麼虛表中對應的表項會被修改指向子類重新實現的函數地址;否則的話,仍舊指向基類中定義的虛函數。

在上述代碼中,由於 Demo 類中重寫了虛函數 vf ,所以 vptr_Base[1] 指向了子類中的虛函數實體,而非 Base 中定義的 vf

經過編譯後,我們知道對 vf 函數的調用相當於調用 vptr_Base[1] 所指向的函數,但是在這個階段無法知道調用的虛函數到底是基類定義的還是子類中重寫的,因為 Base * 類型的指針可以指向一個 Base 類型對象,也可以指向子類 Demo 中的 Base 基類部分 。因此,隻有在執行期才可以知道 vptr_Base[1] 到底指向哪一個 vf 函數實體。

 

[3] 編碼層次的虛函數

如果基類希望某個成員函數由子類重定義,那麼應該將其聲明為 virtual 類型。虛函數是動態綁定的基礎,(隻有)通過基類類型的指針或引用進行虛函數的調用才可以觸發動態綁定,這體現了多態這一麵向對象的關鍵思想。可以看出,基類類型的指針或引用既可以指向基類類型對象也可以指向子類類型對象的特性是動態綁定的關鍵

通過虛函數可以實現運行時多態,這有利於公共接口的實現。即在繼承體係中處於不同層次的類使用同一接口,但運行時會根據具體對象類型的差異而采取不同的策略。這種風格在良好的軟件體係結構可以經常發現,比如 Qt

在實際編碼設計過程中需要注意以下幾點:

1、  要發生動態綁定,實現運行時的多態,需要通過基類的指針或者引用調用虛函數,這樣在運行時才會根據指針或引用所指向的對象的實際類型調用相應的目標函數。

2、  關鍵字 virtual 隻能在類內部的成員聲明中出現,而不能出現在類定義體外部。

3、  一經聲明為虛函數,則一直是虛函數;子類可以不用顯示聲明 virtual ,但虛函數的特性不會改變。

4、  子類中虛函數的聲明必須和基類的定義方式匹配,除了基類中虛函數返回對基類類型的指針或引用,在這種情況下,子類中的虛函數可以返回基類函數所返回類型的子類的指針或引用。

5、  子類中虛函數調用基類的虛函數必須使用顯示作用域聲明,或者會遞歸調用自身。

6、  純虛函數僅作為抽象接口以供覆蓋,包含純虛函數的類是抽象基類,不能被實例化。

 

[4] 參考資料

C++ Primer

Inside The C++ Object Model

 

最後更新:2017-04-02 06:51:17

  上一篇:go AVL樹的研究
  下一篇:go MD5加密隨機增強保護隱私安全