c++編譯器對多態(tài)的實現(xiàn)原理總結
問題:定義一個空的類型,里面沒有任何的成員變量或者成員函數(shù),對這個類型進行 sizeof 運算,結果是?
結果是1,因為空類型的實例不包含任何信息,按道理 sizeof 計算之后結果是0,但是在聲明任何類型的實例的時候,必須在內存占有一定的空間,否則無法使用這些實例,至于占據(jù)多少內存大小,由編譯器決定。
繼續(xù)問:如果在這個類型里添加一個構造函數(shù)和析構函數(shù),那么結果又是多少?
還是1,因為我們調用構造函數(shù)和析構函數(shù),只需要知道函數(shù)的地址即可,而這些函數(shù)的地址只和類型相關,和類型的實例無關,編譯器不會為這兩個函數(shù)在實例內添加任何額外的信息。
繼續(xù)問:如果把析構函數(shù)變?yōu)樘摵瘮?shù)呢?結果是多少?
c++編譯器發(fā)現(xiàn)了類型里有虛函數(shù),,就會為這個類型生成一個虛函數(shù)表,并在該類型的每一個實例中添加一個指向虛函數(shù)表的指針,在32位機器,指針類型大小是4字節(jié),結果是4,64位機器中,指針大小是8字節(jié),結果是8。
面向對象的多態(tài)的實現(xiàn)效果
多態(tài):同樣的調用語句有多種不同的表現(xiàn)形態(tài)
看下面的例子:
- class animal
- {
- public:
- void sleep()
- {
- cout<<"animal sleep"<<endl;
- }
- void breathe()
- {
- cout<<"animal breathe"<<endl;
- }
- };
- class fish:public animal
- {
- public:
- void breathe()
- {
- cout<<"fish bubble"<<endl;
- }
- };
- int main(void)
- {
- fish fh;
- animal *pAn=&fh;
- pAn->breathe();
- return 0;
- }
父類指針指向了子類對象,調用了 breathe 方法,那么結果是animal breathe,也就是說調用的是父類的breathe方法。 這沒有實現(xiàn)多態(tài)性。因為C++編譯器在編譯的時候,要確定每個對象調用的函數(shù)的地址,這稱為早期綁定(early binding),當fish類的對象fh的地址賦給父類的pAn指針時,C++編譯器進行了類型轉換,它認為父類的指針變量pAn保存的就是animal對象的地址。當在main函數(shù)中執(zhí)行pAn->breathe時,調用的就是animal對象的breathe函數(shù)。
#p#
進一步說:
在我們構造fish類的對象時,首先要調用父類:animal類的構造函數(shù)去構造animal類的對象,然后才調用fish類的構造函數(shù)完成自身部分的構造,從而拼接出一個完整的fish對象。當將fish類的對象轉換為animal類型時,該對象就被認為是原對象整個內存模型的上半部分,也就是圖中的“animal的對象所占內存”。
那么當利用類型轉換后的對象指針去調用它的方法時,當然也就是調用它所在的內存中的方法。因此,輸出animal breathe。這不是多態(tài)的表現(xiàn)形式。
多態(tài)實現(xiàn)的三個條件
必要的前提是必須有繼承關系、然后我們需要父類指針(引用)去調用子類的對象,且關鍵是:子類有對父類的虛函數(shù)的重寫。virtual關鍵字,告訴編譯器這個函數(shù)要支持多態(tài),我們不要根據(jù)指針類型判斷如何調用方法,而是要根據(jù)指針所指向的實際對象類型來判斷如何調用。
多態(tài)的理論基礎
前面的例子,輸出的結果是因為編譯器在編譯的時候,就已經(jīng)確定了對象調用的函數(shù)的地址,要解決這個問題就要使用遲綁定(late binding)技術。當編譯器使用遲綁定時,就會在運行時再去確定對象的類型以及正確的調用函數(shù)。而要讓編譯器采用遲綁定,就要在基類中聲明函數(shù)時使用virtual關鍵字,這樣的函數(shù)我們稱為虛函數(shù)。一旦某個函數(shù)在基類中聲明為virtual,那么在所有的派生類中該函數(shù)都是virtual,而不需要再顯式地聲明為virtual。
所謂的動態(tài)聯(lián)編:根據(jù)實際的對象類型來判斷重寫函數(shù)的調用。
C++中多態(tài)的實現(xiàn)原理
當類中聲明虛函數(shù)時,編譯器會在類中生成一個虛函數(shù)表,虛函數(shù)表是一個存儲類成員函數(shù)指針的數(shù)據(jù)結構,虛函數(shù)表是由編譯器自動生成與維護的,virtual成員函數(shù)會被編譯器放入虛函數(shù)表中,存在虛函數(shù)時,每個對象中都有一個指向虛函數(shù)表的指針(vptr指針)
如圖,編譯器為每個類的對象提供一個虛表指針vptr,這個指針指向對象所屬類的虛函數(shù)表。在程序運行時,根據(jù)對象的類型去初始化vptr,從而讓vptr正確的指向所屬類的虛表,從而在調用虛函數(shù)時,就能夠找到正確的函數(shù)。
fish fh; animal*pAn=&fh; pAn->breathe;
由于父類的指針pAn實際指向的對象類型是子類的對象,因此vptr指向的子類fish 類的vtable,當調用pAn->breathe時,根據(jù)虛表中的函數(shù)地址找到的就是fish類的breathe函數(shù)。正是由于每個對象調用的虛函數(shù)都是通過虛表指針來索引的,也就決定了虛表指針的正確初始化是非常重要的。換句話說,在虛表指針沒有正確初始化之前,我們不能夠去調用虛函數(shù)。
#p#
那么虛表指針在什么時候,或者說在什么地方初始化呢?
c++是在構造函數(shù)中進行虛表的創(chuàng)建和虛表指針的初始化。
構造函數(shù)的調用順序:在構造子類對象時,要先調用父類的構造函數(shù),此時編譯器只“看到了”父類,并不知道后面是否后還有繼承者,它初始化父類對象的虛表指針vptr,該虛表指針指向父類的虛表。當執(zhí)行子類的構造函數(shù)時,子類對象的虛表指針vptr被初始化, 此時 vptr指向自身的虛表。當fish類的fh對象構造完畢后,其內部的虛表指針也就被初始化為指向fish類的虛表。
在類型轉換后,調用pAn->breathe,由于pAn實際指向的是fish類的對象,該對象內部的虛表指針指向的是fish類的虛表,因此最終調用的是fish類的breathe函數(shù)。
說明:
通過虛函數(shù)表指針VPTR調用重寫函數(shù)是在程序運行時進行的,因此需要通過尋址操作才能確定真正應該調用的函數(shù)。而普通成員函數(shù)是在編譯時就確定了調用的函數(shù)。在效率上,虛函數(shù)的效率要低很多。出于效率考慮,沒有必要將所有成員函數(shù)都聲明為虛函數(shù)
對象在創(chuàng)建的時,由編譯器對VPTR指針進行初始化,只有當對象的構造完全結束后VPTR的指向才最終確定,到底是父類對象的VPTR指向父類虛函數(shù)表還是子類對象的VPTR指向子類虛函數(shù)表。
回到開始的問題:
- class A
- {
- void g(){.....}
- };
- 則sizeof(A)=1;如果改為如下:
- class A
- {
- public:
- virtual void f()
- {
- ......
- }
- void g(){.....}
- }
則 sizeof(A)=4,這是因為在類A中存在virtual function,為了實現(xiàn)多態(tài),每個含有virtual function的類中都隱式包含著一個靜態(tài)虛指針vptr指向該類的靜態(tài)虛表vtable, vtable中的表項指向類中的每個virtual function的入口地址。
多態(tài)是在程序進行動態(tài)綁定得以實現(xiàn)的,而不是編譯時就確定對象的調用方法的靜態(tài)綁定。
程序運行到動態(tài)綁定時,通過基類的指針所指向的對象類型,通過vptr找到其所指向的vtable,然后調用其相應的方法,即可實現(xiàn)多態(tài)。這就是動態(tài)綁定(dynamic binding)或者叫做遲后聯(lián)編(lazy compile)。
- class base;
- base *pbase;
- class base
- {
- public:
- base()
- {
- pbase=this;
- }
- virtual void fn()
- {
- cout<<"base"<<endl;
- }
- };
- class derived:public base
- {
- void fn()
- {
- cout<<"derived"<<endl;
- }
- };
- derived aa;
- int main(void)
- {
- pbase->fn();
- return 0;
- }
在base類的構造函數(shù)中將this指針保存到pbase全局變量中。在定義全局對象aa,即調用derived aa;時,要調用基類的構造函數(shù),先構造基類的部分,然后是子類的部分,由這兩部分拼接出完整的對象aa。
這個this指針指向的當然也就是aa對象,那么我們在main函數(shù)中利用pbase調用fn,因為pbase實際指向的是aa對象,而aa對象內部的虛表指針指向的是自身的虛表,最終調用的當然是derived類中的fn函數(shù)。
在derived類中聲明fn函數(shù)時,忘了加public關鍵字,導致聲明為了private(默認為private),但通過前面我們所講述的虛函數(shù)調用機制,也就明白了這個地方并不影響它輸出正確的結果。不知道這算不算C++的一個Bug,因為虛函數(shù)的調用是在運行時確定調用哪一個函數(shù),所以編譯器在編譯時,并不知道pbase指向的是aa對象,所以導致這個奇怪現(xiàn)象的發(fā)生。如果直接用aa對象去調用,由于對象類型是確定的(注意aa是對象變量,不是指針變量),編譯器往往會采用早期綁定,在編譯時確定調用的函數(shù),于是就會發(fā)現(xiàn)fn是私有的,不能直接調用。
#p#
如果直接在基類的構造函數(shù)中調用虛函數(shù),會怎樣?
在調用基類的構造函數(shù)時,編譯器只“看到了”父類,并不知道后面是否后還有繼承者,它只是初始化父類對象的虛表指針,讓該虛表指針指向父類的虛表,所以看到結果當然不正確。只有在子類的構造函數(shù)調用完畢后,整個虛表才構建完畢,此時才能真正應用C++的多態(tài)性。換句話說,不要在構造函數(shù)中去調用虛函數(shù)實現(xiàn)多態(tài),當然如果只是想調用本類的函數(shù),也無所謂。
得到一個結論:
虛函數(shù)和純虛函數(shù)比較
虛函數(shù)
引入原因:為了方便使用多態(tài)特性,我們常常需要在基類中定義虛函數(shù)。
純虛函數(shù)
引入原因:為了實現(xiàn)多態(tài)性,純虛函數(shù)有點像java中的接口,自己不去實現(xiàn)過程,讓繼承他的子類去實現(xiàn)。在很多情況下,基類本身生成對象是不合情理的。例如,動物作為一個基類可以派生出老虎、孔雀等子類,但動物本身生成對象明顯不合常理。 這時我們就將動物類定義成抽象類,也就是包含純虛函數(shù)的類,純虛函數(shù)就是基類只定義了函數(shù)體,沒有實現(xiàn)過程:
- virtual void Eat() = 0; 直接=0 不要 在cpp中定義就可以了
虛函數(shù)和純虛函數(shù)的區(qū)別
虛函數(shù)中的函數(shù)是實現(xiàn)的哪怕是空實現(xiàn),它的作用是這個函數(shù)在子類里面可以被重載,運行時動態(tài)綁定實現(xiàn)動態(tài),而純虛函數(shù)是個接口,是個函數(shù)聲明,在基類中不實現(xiàn),要等到子類中去實現(xiàn)
虛函數(shù)在子類里可以不重載,但是虛函數(shù)必須在子類里去實現(xiàn)。
總結:
對于虛函數(shù)調用來,每一個對象內部都有一個虛表指針,該虛表指針被初始化為本類的虛表。所以在程序中,不管你的對象類型如何轉換,但該對象內部的虛表指針是固定的,所以才能實現(xiàn)動態(tài)的對象函數(shù)調用,這就是C++多態(tài)性實現(xiàn)的原理。
如果基類有虛函數(shù):
1、每一個類都有虛表。
2、虛表可以繼承,如果子類沒有重寫虛函數(shù),那么子類虛表中仍然會有該函數(shù)的地址,只不過這個地址指向的是基類的虛函數(shù)實現(xiàn)。如果基類3個虛函數(shù),那么基類的虛表中就有三項(虛函數(shù)地址),派生類也會有虛表,至少有三項,如果重寫了相應的虛函數(shù),那么虛表中的地址就會改變,指向自身的虛函數(shù)實現(xiàn)。如果派生類有自己的虛函數(shù),那么虛表中就會添加該項。
3、派生類的虛表中虛函數(shù)地址的排列順序和基類的虛表中虛函數(shù)地址排列順序相同。





















