C++虛擬函式、虛繼承、物件記憶體模型
虛擬函式的實現要求物件攜帶額外的資訊,這些資訊用於在執行時確定該物件應該呼叫哪一個虛擬函式。典型情況下,這一資訊具有一種被稱為 vptr(virtual table pointer,虛擬函式表指標)的指標的形式。vptr 指向一個被稱為 vtbl(virtual table,虛擬函式表)的函式指標陣列,每一個包含虛擬函式的類都關聯到 vtbl。當一個物件呼叫了虛擬函式,實際的被呼叫函式通過下面的步驟確定:找到物件的 vptr 指向的 vtbl,然後在 vtbl 中尋找合適的函式指標。
如果類定義了虛擬函式,該類及其派生類就要生成一張虛擬函式表,即vtable。而在類的物件地址空間中儲存一個該虛表的入口,佔4個位元組,這個入口地址是在構造物件時由編譯器寫入的。所以,由於物件的記憶體空間包含了虛表入口,編譯器能夠由這個入口找到恰當的虛擬函式。故對於一個父類的物件指標,呼叫虛擬函式,如果給他賦父類物件的指標,那麼他就呼叫父類中的函式,如果給他賦子類物件的指標,他就呼叫子類中的函式(取決於物件的記憶體地址)。
每當建立一個包含有虛擬函式的類或從包含有虛擬函式的類派生一個類時,編譯器就會為這個類建立一個虛擬函式表(VTABLE)儲存該類所有虛擬函式的地址,其實這個VTABLE的作用就是儲存自己類中所有虛擬函式的地址,可以把VTABLE形象地看成一個函式指標陣列,這個陣列的每個元素存放的就是虛擬函式的地址。在每個帶有虛擬函式的類 中,編譯器祕密地置入一指標,稱為vpointer(縮寫為VPTR),指向這個物件的VTABLE。 當構造該派生類物件時,其成員VPTR被初始化指向該派生類的VTABLE。所以可以認為VTABLE是該類的所有物件共有的,在定義該類時被初始化;而VPTR則是每個類物件都有獨立一份的,且在該類物件被構造時被初始化。
通過基類指標做虛擬函式呼叫時(也就是做多型呼叫時),編譯器靜態地插入取得這個VPTR,並在VTABLE表中查詢函式地址的程式碼,這樣就能呼叫正確的函式使晚捆綁發生。為每個類設定VTABLE、初始化VPTR、為虛擬函式呼叫插入程式碼,所有這些都是自動發生的。
#include<iostream> using namespace std; class A { public: virtual void fun1() { cout << "A::fun1()" << endl; } virtual void fun2() { cout << "A::fun2()" << endl; } }; class B : public A { public: void 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),所以導致類多佔的記憶體空間也會比較大,物件也是一樣的。這也是MFC中採用訊息對映表而不採用虛擬函式表的原因。
VPTR
和 VTABLE 和類物件的關係:
每一個具有虛擬函式的類都有一個虛擬函式表VTABLE,裡面按在類中宣告的虛擬函式的順序存放著虛擬函式的地址,這個虛擬函式表VTABLE是這個類的所有物件所共有的,也就是說無論使用者聲明瞭多少個類物件,但是這個VTABLE虛擬函式表只有一個。
在每個具有虛擬函式的類的物件裡面都有一個VPTR虛擬函式指標,這個指標指向VTABLE的首地址,每個類的物件都有這麼一種指標。
含有虛擬函式的物件記憶體模型
class A
{
private:
int a;
int b;
public:
virtual void fun0()
{
cout<<"A::fun0"<<endl;
}
};
1、直接繼承
那我們來看看編譯器是怎麼建立VPTR指向的這個虛擬函式表的,先看下面兩個類:
class base
{
private:
int a;
public:
void bfun()
{
}
virtual void vfun1()
{
}
virtual void vfun2()
{
}
};
class derived : public base
{
private:
int b;
public:
void 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 *)的大小。
2、虛繼承
這個是比較不好理解的,對於虛繼承,若派生類有自己的虛擬函式,則它本身需要有一個虛指標,指向自己的虛表。另外,派生類虛繼承父類時,首先要通過加入一個虛指標來指向父類,因此有可能會有兩個虛指標。
示例一:含有普通繼承
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
{
public:
virtual void print1() { }
virtual void print2() { }
private:
int b1;
};
class Base2: virtual public CommonBase
{
public:
virtual void dump1() { }
virtual void dump2() { }
private:
int b2;
};
class Derived: public Base1, public Base2
{
public:
void print2() { }
void dump2() { }
private:
int 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
{
public:
virtual void aa() { }
virtual void aa2() { }
private:
char ch[3];
};
class B: virtual public A
{
public:
virtual void bb() { }
virtual void bb2() { }
};
int main(void)
{
cout<<"A's size is "<<sizeof(A)<<endl;
cout<<"B's size is "<<sizeof(B)<<endl;
return 0;
}
執行結果:A's size is 8B's size is 16
說明:對於虛繼承,類B因為有自己的虛擬函式,所以它本身有一個虛指標,指向自己的虛表。另外,類B虛繼承類A時,首先要通過加入一個虛指標來指向父類A,然後還要包含父類A的所有內容。因此是4+4+8=16。
(虛)繼承類的記憶體佔用大小首先,平時所宣告的類只是一種型別定義,它本身是沒有大小可言的。 因此,如果用sizeof運算子對一個型別名操作,那得到的是具有該型別實體的大小。
1、空類、單一繼承的空類、多重繼承的空類所佔空間大小為:1(位元組,下同);
2、一個類中,虛擬函式本身、成員函式(包括靜態與非靜態)和靜態資料成員都是不佔用類物件的儲存空間的;
3、因此一個物件的大小≥所有非靜態成員大小的總和;
4、當類中聲明瞭虛擬函式(不管是1個還是多個),那麼在例項化物件時,編譯器會自動在物件裡安插一個指標vPtr指向虛擬函式表VTable;
5、虛承繼的情況:由於涉及到虛擬函式表和虛基表,會同時增加一個(多重虛繼承下對應多個)vfPtr指標指向虛擬函式表vfTable和一個vbPtr指標指向虛基表vbTable,這兩者所佔的空間大小為:8(或8乘以多繼承時父類的個數);
6、在考慮以上內容所佔空間的大小時,還要注意編譯器下的“補齊”padding的影響,即編譯器會插入多餘的位元組補齊;
7、類物件的大小=各非靜態資料成員(包括父類的非靜態資料成員但都不包括所有的成員函式)的總和 + vfptr指標(多繼承下可能不止一個) + vbptr指標(多繼承下可能不止一個) + 位元組對齊時編譯器額外增加的位元組。
參考: