1. 程式人生 > >C++中虛擬函式工作原理和 虛 繼承類的記憶體佔用大小計算

C++中虛擬函式工作原理和 虛 繼承類的記憶體佔用大小計算

                      虛擬函式的實現要求物件攜帶額外的資訊,這些資訊用於在執行時確定該物件應該呼叫哪一個虛擬函式。典型情況下,這一資訊具有一種被稱為 vptr(virtual table pointer,虛擬函式表指標)的指標的形式。vptr 指向一個被稱為 vtbl(virtual table,虛擬函式表)的函式指標陣列,每一個包含虛擬函式的類都關聯到 vtbl。當一個物件呼叫了虛擬函式,實際的被呼叫函式通過下面的步驟確定:找到物件的 vptr 指向的 vtbl,然後在 vtbl 中尋找合適的函式指標。      虛擬函式的地址翻譯取決於物件的記憶體地址,而不取決於資料型別(編譯器對函式呼叫的合法性檢查取決於資料型別)。如果類定義了虛擬函式,該類及其派生類就要生成一張虛擬函式表,即vtable。而在類的物件地址空間中儲存一個該虛表的入口,佔4個位元組,這個入口地址是在構造物件時由編譯器寫入的。所以,由於物件的記憶體空間包含了虛表入口,編譯器能夠由這個入口找到恰當的虛擬函式,這個函式的地址不再由資料型別決定了。故對於一個父類的物件指標,呼叫虛擬函式,如果給他賦父類物件的指標,那麼他就呼叫父類中的函式,如果給他賦子類物件的指標,他就呼叫子類中的函式(取決於物件的記憶體地址)。
      虛擬函式需要注意的大概就是這些個地方了,之前在More effective C++上好像也有見過,不過這次在Visual C++權威剖析這本書中有了更直白的認識,這本書名字很牛逼,看看內容也就那麼回事,感覺名不副實,不過說起來也是有其獨到之處的,否則也沒必要出這種書了。      每當建立一個包含有虛擬函式的類或從包含有虛擬函式的類派生一個類時,編譯器就會為這個類建立一個虛擬函式表(VTABLE)儲存該類所有虛擬函式的地址,其實這個VTABLE的作用就是儲存自己類中所有虛擬函式的地址,可以把VTABLE形象地看成一個函式指標陣列,這個陣列的每個元素存放的就是虛擬函式的地址。在每個帶有虛擬函式的類 中,編譯器祕密地置入一指標,稱為v p o i n t e r(縮寫為V P T R),指向這個物件的V TA B L E。 當構造該派生類物件時,其成員VPTR被初始化指向該派生類的VTABLE。所以可以認為VTABLE是該類的所有物件共有的,在定義該類時被初始化;而VPTR則是每個類物件都有獨立一份的,且在該類物件被構造時被初始化。
      通過基類指標做虛擬函式調 用時(也就是做多型呼叫時),編譯器靜態地插入取得這個V P T R,並在V TA B L E表中查詢函式地址的程式碼,這樣就能呼叫正確的函式使晚捆綁發生。為每個類設定V TA B L E、初始化V P T R、為虛擬函式呼叫插入程式碼,所有這些都是自動發生的,所以我們不必擔心這些。
#include<iostream>using namespace std;class A{publicvirtual void fun1() {  cout << "A::fun1()" << endl; } virtual void fun2
() 
{  cout << "A::fun2()" << endl; }};class B : public A{publicvoid fun1() {  cout << "B::fun1()" << endl; } void fun2() {  cout << "B::fun2()" << endl; }};int main(){ A *pa = new B; pa->fun1(); delete pa; system("pause");  return 0;}
             毫無疑問,呼叫了B::fun1(),但是B::fun1()不是像普通函式那樣直接找到函式地址而執行的。真正的執行方式是:首先取出pa指標所指向的物件的vptr的值,這個值就是vtbl的地址,由於呼叫的函式B::fun1()是第一個虛擬函式,所以取出vtbl第一個表項裡的值,這個值就是B::fun1()的地址了,最後呼叫這個函式。因此只要vptr不同,指向的vtbl就不同,而不同的vtbl裡裝著對應類的虛擬函式地址,所以這樣虛擬函式就可以完成它的任務,多型就是這樣實現的。      而對於class A和class B來說,他們的vptr指標存放在何處?其實這個指標就放在他們各自的例項物件裡。由於class A和class B都沒有資料成員,所以他們的例項物件裡就只有一個vptr指標。      虛擬函式使用的缺點  優點講了一大堆,現在談一下缺點,虛擬函式最主要的缺點是執行效率較低,看一看虛擬函式引發的多型性的實現過程,你就能體會到其中的原因,另外就是由於要攜帶額外的資訊(VPTR),所以導致類多佔的記憶體空間也會比較大,物件也是一樣的     含有虛擬函式的物件在記憶體中的結構如下:
class A{privateint a; int b;publicvirtual void fun0() {  cout<<"A::fun0"<<endl; }};
1、直接繼承那我們來看看編譯器是怎麼建立VPTR指向的這個虛擬函式表的,先看下面兩個類:
class base{privateint a;publicvoid bfun() { } virtual void vfun1() { } virtual void vfun2() { }};class derived : public base{privateint b;publicvoid dfun() { } virtual void vfun1() { } virtual void vfun3() { }};
兩個類的VPTR指向的虛擬函式表(VTABLE)分別如下:base類                     ——————VPTR——>    |&base::vfun1 |                      ——————                    |&base::vfun2 |                    ——————      derived類                      ———————VPTR——>    |&derived::vfun1 |                     ———————                   |&base::vfun2     |                    ———————                   |&derived::vfun3 |                    ———————      每當建立一個包含有虛擬函式的類或從包含有虛擬函式的類派生一個類時,編譯器就為這個類建立一個VTABLE,如上圖所示。在這個表中,編譯器放置了在這個類中或在它的基類中所有已宣告為virtual的函式的地址。如果在這個派生類中沒有對在基類中宣告為virtual的函式進行重新定義,編譯器就使用基類 的這個虛擬函式地址。(在derived的VTABLE中,vfun2的入口就是這種情況。)然後編譯器在這個類中放置VPTR。當使用簡單繼承時,對於每個物件只有一個VPTR。VPTR必須被初始化為指向相應的VTABLE,這在建構函式中發生。       一旦VPTR被初始化為指向相應的VTABLE,物件就"知道"它自己是什麼型別。但只有當虛擬函式被呼叫時這種自我認知才有用。        沒有虛擬函式類物件的大小正好是資料成員的大小,包含有一個或者多個虛擬函式的類物件編譯器向裡面插入了一個VPTR指標(void *),指向一個存放函式地址的表就是我們上面說的VTABLE,這些都是編譯器為我們做的我們完全可以不關心這些。所以有虛擬函式的類物件的大小是資料成員的大小加上一個VPTR指標(void *)的大小。總結一下VPTR 和 VTABLE 和類物件的關係:       每一個具有虛擬函式的類都有一個虛擬函式表VTABLE,裡面按在類中宣告的虛擬函式的順序存放著虛擬函式的地址,這個虛擬函式表VTABLE是這個類的所有物件所共有的,也就是說無論使用者聲明瞭多少個類物件,但是這個VTABLE虛擬函式表只有一個。       在每個具有虛擬函式的類的物件裡面都有一個VPTR虛擬函式指標,這個指標指向VTABLE的首地址,每個類的物件都有這麼一種指標。2、虛繼承     這個是比較不好理解的,對於虛繼承,若派生類有自己的虛擬函式,則它本身需要有一個虛指標,指向自己的虛表。另外,派生類虛繼承父類時,首先要通過加入一個虛指標來指向父類,因此有可能會有兩個虛指標。二、(虛)繼承類的記憶體佔用大小     首先,平時所宣告的類只是一種型別定義,它本身是沒有大小可言的。 因此,如果用sizeof運算子對一個型別名操作,那得到的是具有該型別實體的大小。計算一個類物件的大小時的規律:    1、空類、單一繼承的空類、多重繼承的空類所佔空間大小為:1(位元組,下同);    2、一個類中,虛擬函式本身、成員函式(包括靜態與非靜態)和靜態資料成員都是不佔用類物件的儲存空間的;    3、因此一個物件的大小≥所有非靜態成員大小的總和;     4、當類中聲明瞭虛擬函式(不管是1個還是多個),那麼在例項化物件時,編譯器會自動在物件裡安插一個指標vPtr指向虛擬函式表VTable;    5、虛承繼的情況:由於涉及到虛擬函式表和虛基表,會同時增加一個(多重虛繼承下對應多個)vfPtr指標指向虛擬函式表vfTable和一個vbPtr指標指向虛基表vbTable,這兩者所佔的空間大小為:8(或8乘以多繼承時父類的個數);    6、在考慮以上內容所佔空間的大小時,還要注意編譯器下的“補齊”padding的影響,即編譯器會插入多餘的位元組補齊;    7、類物件的大小=各非靜態資料成員(包括父類的非靜態資料成員但都不包括所有的成員函式)的總和+ vfptr指標(多繼承下可能不止一個)+vbptr指標(多繼承下可能不止一個)+編譯器額外增加的位元組。示例一:含有普通繼承
class A   {   };  class B   { char ch;    virtual void func0()  {  } }; class C  { char ch1; char ch2; virtual void func()  {  }   virtual void func1()  {  } };class D: public A, public C{    int d;    virtual void func()  {  }  virtual void func1()  {  }};   class E: public B, public C{    int e;    virtual void func0()  {  }  virtual void func1()  {  }};int main(void)cout<<"A="<<sizeof(A)<<endl;    //result=1 cout<<"B="<<sizeof(B)<<endl;    //result=8     cout<<"C="<<sizeof(C)<<endl;    //result=8 cout<<"D="<<sizeof(D)<<endl;    //result=12 cout<<"E="<<sizeof(E)<<endl;    //result=20 return 0;}
前面三個A、B、C類的記憶體佔用空間大小就不需要解釋了,注意一下記憶體對齊就可以理解了。求sizeof(D)的時候,需要明白,首先VPTR指向的虛擬函式表中儲存的是類D中的兩個虛擬函式的地址,然後存放基類C中的兩個資料成員ch1、ch2,注意記憶體對齊,然後存放資料成員d,這樣4+4+4=12。求sizeof(E)的時候,首先是類B的虛擬函式地址,然後類B中的資料成員,再然後是類C的虛擬函式地址,然後類C中的資料成員,最後是類E中的資料成員e,同樣注意記憶體對齊,這樣4+4+4+4+4=20。示例二:含有虛繼承
class CommonBase{ int co;};class Base1: virtual public CommonBase{publicvirtual void print1() {  } virtual void print2() {  }privateint b1;};class Base2: virtual public CommonBase{publicvirtual void dump1() {  } virtual void dump2() {  }privateint b2;};class Derived: public Base1, public Base2{publicvoid print2() {  } void dump2() {  }privateint d;};
sizeof(Derived)=32,其在記憶體中分佈的情況如下:
class Derived size(32):     +---     | +--- (base class Base1) | | {vfptr} | | {vbptr} | | b1     | +---     | +--- (base class Base2) | | {vfptr} | | {vbptr} | | b2    | +--- | d    +---    +--- (virtual base CommonBase) | co    +---
示例3:
class A{publicvirtual void aa() {  } virtual void aa2() {  }privatechar ch[3];};class B: virtual public A{publicvirtual void bb() {  } virtual void bb2() {  }};int main(void)cout<<"A's size is "<<sizeof(A)<<endlcout<<"B's size is "<<sizeof(B)<<endlreturn 0;}
執行結果:A's size is 8              B's size is 16      說明:對於虛繼承,類B因為有自己的虛擬函式,所以它本身有一個虛指標,指向自己的虛表。另外,類B虛繼承類A時,首先要通過加入一個虛指標來指向父類A,然後還要包含父類A的所有內容。因此是4+4+8=16。兩種多型實現機制及其優缺點除了c++的這種多型的實現機制之外,還有另外一種實現機制,也是查表,不過是按名稱查表,是smalltalk等語言的實現機制。這兩種方法的優缺點如下:(1)、按照絕對位置查表,這種方法由於編譯階段已經做好了索引和表項(如上面的call *(pa->vptr[1]) ),所以執行速度比較快;缺點是:當A的virtual成員比較多(比如1000個),而B重寫的成員比較少(比如2個),這種時候,B的vtableB的剩下的998個表項都是放A中的virtual成員函式的指標,如果這個派生體系比較大的時候,就浪費了很多的空間。比如:GUI庫,以MFC庫為例,MFC有很多類,都是一個繼承體系;而且很多時候每個類只是1,2個成員函式需要在派生類重寫,如果用C++的虛擬函式機制,每個類有一個虛表,每個表裡面有大量的重複,就會造成空間利用率不高。於是MFC的訊息對映機制不用虛擬函式,而用第二種方法來實現多型,那就是:(2)、按照函式名稱查表,這種方案可以避免如上的問題;但是由於要比較名稱,有時候要遍歷所有的繼承結構,時間效率效能不是很高。(關於MFC的訊息對映的實現,看下一篇文章)3、總結:如果繼承體系的基類的virtual成員不多,而且在派生類要重寫的部分佔了其中的大多數時候,用C++的虛擬函式機制是比較好的;但是如果繼承體系的基類的virtual成員很多,或者是繼承體系比較龐大的時候,而且派生類中需要重寫的部分比較少,那就用名稱查詢表,這樣效率會高一些,很多的GUI庫都是這樣的,比如MFC,QT。PS:其實,自從計算機出現之後,時間和空間就成了永恆的主題,因為兩者在98%的情況下都無法協調,此長彼消;這個就是電腦科學中的根本瓶頸之所在。軟體科學和演算法的發展,就看能不能突破這對時空權衡了。呵呵。。何止電腦科學如此,整個宇宙又何嘗不是如此呢?最基本的宇宙之謎,還是時間和空間。C++如何不用虛擬函式實現多型 可以考慮使用函式指標來實現多型
#include<iostream>using namespace std;typedef void (*fVoid)();class A{publicstatic void test() {  printf("hello A\n"); } fVoid print; A() {  print = A::test; }};class B : public A{publicstatic void test() {  printf("hello B\n"); } B() {  print = B::test; }};int main(void){ A aa; aa.print(); B b; A* a = &b; a->print(); return 0;}
這樣做的好處主要是繞過了vtable。我們都知道虛擬函式表有時候會帶來一些效能損失。