程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> C++入門知識 >> c++編譯器對多態的實現原理總結,編譯器多態

c++編譯器對多態的實現原理總結,編譯器多態

編輯:C++入門知識

c++編譯器對多態的實現原理總結,編譯器多態


問題:定義一個空的類型,裡面沒有任何的成員變量或者成員函數,對這個類型進行 sizeof 運算,結果是?

結果是1,因為空類型的實例不包含任何信息,按道理 sizeof 計算之後結果是0,但是在聲明任何類型的實例的時候,必須在內存占有一定的空間,否則無法使用這些實例,至於占據多少內存大小,由編譯器決定。

繼續問:如果在這個類型裡添加一個構造函數和析構函數,那麼結果又是多少?

還是1,因為我們調用構造函數和析構函數,只需要知道函數的地址即可,而這些函數的地址只和類型相關,和類型的實例無關,編譯器不會為這兩個函數在實例內添加任何額外的信息。

繼續問:如果把析構函數變為虛函數呢?結果是多少?

c++編譯器發現了類型裡有虛函數,,就會為這個類型生成一個虛函數表,並在該類型的每一個實例中添加一個指向虛函數表的指針,在32位機器,指針類型大小是4字節,結果是4,64位機器中,指針大小是8字節,結果是8。

面向對象的多態的實現效果

多態:同樣的調用語句有多種不同的表現形態

看下面的代碼例子:

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方法。 這沒有實現多態性。因為C++編譯器在編譯的時候,要確定每個對象調用的函數的地址,這稱為早期綁定(early binding),當fish類的對象fh的地址賦給父類的pAn指針時,C++編譯器進行了類型轉換,它認為父類的指針變量pAn保存的就是animal對象的地址。當在main()函數中執行pAn->breathe()時,調用的就是animal對象的breathe函數。

進一步說:

在我們構造fish類的對象時,首先要調用父類:animal類的構造函數去構造animal類的對象,然後才調用fish類的構造函數完成自身部分的構造,從而拼接出一個完整的fish對象。當將fish類的對象轉換為animal類型時,該對象就被認為是原對象整個內存模型的上半部分,也就是圖中的“animal的對象所占內存”。

那麼當利用類型轉換後的對象指針去調用它的方法時,當然也就是調用它所在的內存中的方法。因此,輸出animal breathe。這不是多態的表現形式。

多態實現的三個條件

必要的前提是必須有繼承關系、然後我們需要父類指針(引用)去調用子類的對象,且關鍵是:子類有對父類的虛函數的重寫。virtual關鍵字,告訴編譯器這個函數要支持多態,我們不要根據指針類型判斷如何調用方法,而是要根據指針所指向的實際對象類型來判斷如何調用。

多態的理論基礎

前面的例子,輸出的結果是因為編譯器在編譯的時候,就已經確定了對象調用的函數的地址,要解決這個問題就要使用遲綁定(late binding)技術。當編譯器使用遲綁定時,就會在運行時再去確定對象的類型以及正確的調用函數。而要讓編譯器采用遲綁定,就要在基類中聲明函數時使用virtual關鍵字,這樣的函數我們稱為虛函數。一旦某個函數在基類中聲明為virtual,那麼在所有的派生類中該函數都是virtual,而不需要再顯式地聲明為virtual。

所謂的動態聯編:根據實際的對象類型來判斷重寫函數的調用。

C++中多態的實現原理

當類中聲明虛函數時,編譯器會在類中生成一個虛函數表,虛函數表是一個存儲類成員函數指針的數據結構,虛函數表是由編譯器自動生成與維護的,virtual成員函數會被編譯器放入虛函數表中,存在虛函數時,每個對象中都有一個指向虛函數表的指針(vptr指針)

 

如圖,編譯器為每個類的對象提供一個虛表指針vptr,這個指針指向對象所屬類的虛函數表。在程序運行時,根據對象的類型去初始化vptr,從而讓vptr正確的指向所屬類的虛表,從而在調用虛函數時,就能夠找到正確的函數。



在上例子中:

    fish fh;
    animal *pAn=&fh;
    pAn->breathe();

由於父類的指針pAn實際指向的對象類型是子類的對象,因此vptr指向的子類fish 類的vtable,當調用pAn->breathe()時,根據虛表中的函數地址找到的就是fish類的breathe()函數。正是由於每個對象調用的虛函數都是通過虛表指針來索引的,也就決定了虛表指針的正確初始化是非常重要的。換句話說,在虛表指針沒有正確初始化之前,我們不能夠去調用虛函數。

那麼虛表指針在什麼時候,或者說在什麼地方初始化呢?
c++是在構造函數中進行虛表的創建和虛表指針的初始化。

構造函數的調用順序:在構造子類對象時,要先調用父類的構造函數,此時編譯器只“看到了”父類,並不知道後面是否後還有繼承者,它初始化父類對象的虛表指針vptr,該虛表指針指向父類的虛表。當執行子類的構造函數時,子類對象的虛表指針vptr被初始化, 此時 vptr指向自身的虛表。當fish類的fh對象構造完畢後,其內部的虛表指針也就被初始化為指向fish類的虛表。

在類型轉換後,調用pAn->breathe(),由於pAn實際指向的是fish類的對象,該對象內部的虛表指針指向的是fish類的虛表,因此最終調用的是fish類的breathe()函數。
 

說明:

通過虛函數表指針VPTR調用重寫函數是在程序運行時進行的,因此需要通過尋址操作才能確定真正應該調用的函數。而普通成員函數是在編譯時就確定了調用的函數。在效率上,虛函數的效率要低很多。出於效率考慮,沒有必要將所有成員函數都聲明為虛函數

對象在創建的時,由編譯器對VPTR指針進行初始化,只有當對象的構造完全結束後VPTR的指向才最終確定,到底是父類對象的VPTR指向父類虛函數表還是子類對象的VPTR指向子類虛函數表。

回到開始的問題:

class A
{
    void g(){.....}
};
則sizeof(A)=1;如果改為如下:
class A
{
public:
    virtual void f()
    {
       ......
    }
    void g(){.....}
}

則 sizeof(A)=4,這是因為在類A中存在virtual function,為了實現多態,每個含有virtual function的類中都隱式包含著一個靜態虛指針vptr指向該類的靜態虛表vtable, vtable中的表項指向類中的每個virtual function的入口地址。

多態是在程序進行動態綁定得以實現的,而不是編譯時就確定對象的調用方法的靜態綁定。

程序運行到動態綁定時,通過基類的指針所指向的對象類型,通過vptr找到其所指向的vtable,然後調用其相應的方法,即可實現多態。這就是動態綁定(dynamic binding)或者叫做遲後聯編(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類的構造函數中將this指針保存到pbase全局變量中。在定義全局對象aa,即調用derived aa;時,要調用基類的構造函數,先構造基類的部分,然後是子類的部分,由這兩部分拼接出完整的對象aa。

這個this指針指向的當然也就是aa對象,那麼我們在main()函數中利用pbase調用fn(),因為pbase實際指向的是aa對象,而aa對象內部的虛表指針指向的是自身的虛表,最終調用的當然是derived類中的fn()函數。

在derived類中聲明fn()函數時,忘了加public關鍵字,導致聲明為了private(默認為private),但通過前面我們所講述的虛函數調用機制,也就明白了這個地方並不影響它輸出正確的結果。不知道這算不算C++的一個Bug,因為虛函數的調用是在運行時確定調用哪一個函數,所以編譯器在編譯時,並不知道pbase指向的是aa對象,所以導致這個奇怪現象的發生。如果直接用aa對象去調用,由於對象類型是確定的(注意aa是對象變量,不是指針變量),編譯器往往會采用早期綁定,在編譯時確定調用的函數,於是就會發現fn()是私有的,不能直接調用。

如果直接在基類的構造函數中調用虛函數,會怎樣?

在調用基類的構造函數時,編譯器只“看到了”父類,並不知道後面是否後還有繼承者,它只是初始化父類對象的虛表指針,讓該虛表指針指向父類的虛表,所以看到結果當然不正確。只有在子類的構造函數調用完畢後,整個虛表才構建完畢,此時才能真正應用C++的多態性。換句話說,不要在構造函數中去調用虛函數實現多態,當然如果只是想調用本類的函數,也無所謂。

得到一個結論:

構造函數中調用多態函數,不能實現多態。

虛函數和純虛函數比較

虛函數

引入原因:為了方便使用多態特性,我們常常需要在基類中定義虛函數。

純虛函數

引入原因:為了實現多態性,純虛函數有點像java中的接口,自己不去實現過程,讓繼承他的子類去實現。在很多情況下,基類本身生成對象是不合情理的。例如,動物作為一個基類可以派生出老虎、孔雀等子類,但動物本身生成對象明顯不合常理。 這時我們就將動物類定義成抽象類,也就是包含純虛函數的類,純虛函數就是基類只定義了函數體,沒有實現過程:

 virtual void Eat() = 0; 直接=0 不要 在cpp中定義就可以了 

虛函數和純虛函數的區別
虛函數中的函數是實現的哪怕是空實現,它的作用是這個函數在子類裡面可以被重載,運行時動態綁定實現動態,而純虛函數是個接口,是個函數聲明,在基類中不實現,要等到子類中去實現
虛函數在子類裡可以不重載,但是虛函數必須在子類裡去實現。

總結:

對於虛函數調用來,每一個對象內部都有一個虛表指針,該虛表指針被初始化為本類的虛表。所以在程序中,不管你的對象類型如何轉換,但該對象內部的虛表指針是固定的,所以才能實現動態的對象函數調用,這就是C++多態性實現的原理。

如果基類有虛函數:

1、每一個類都有虛表。

2、虛表可以繼承,如果子類沒有重寫虛函數,那麼子類虛表中仍然會有該函數的地址,只不過這個地址指向的是基類的虛函數實現。如果基類3個虛函數,那麼基類的虛表中就有三項(虛函數地址),派生類也會有虛表,至少有三項,如果重寫了相應的虛函數,那麼虛表中的地址就會改變,指向自身的虛函數實現。如果派生類有自己的虛函數,那麼虛表中就會添加該項。

3、派生類的虛表中虛函數地址的排列順序和基類的虛表中虛函數地址排列順序相同。

 

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved