1. 程式人生 > >C++中動多型實現之虛擬函式與虛表指標

C++中動多型實現之虛擬函式與虛表指標

1、靜多型與命名傾軋,動多型與虛擬函式:

(1)概述:
我們知道,C++的多型有靜多型(Static polymorphism)與動多型(Dynamic polymorphism)之分,靜多型是依靠函式過載(function overloading)實現的,而且這種依靠函式過載的多型的實現是採用命名傾軋的方式,是在編譯階段就已經完成了的;而動多型(動態聯編、動態關聯)是在執行階段才會確定的,是依靠虛擬函式來實現的,並且動多型是在有父子類才會產生的多型(虛擬函式脫離類是毫無意義的),而靜多型則不必需要有類的繼承。實現函式的動態聯編其本質核心則是虛表指標與虛擬函式表。

(2)靜多型:
關於靜多型的命名傾軋,我們再用一個簡單的例子驗證:
該例為swap()函式的過載測試(圖中簡單做以說明):

這裡寫圖片描述

(3)動多型:
那麼對於動多型我們首先總結一下有關其實現前提條件:

實現前提: 賦值相容
賦值相容是動多型能夠產生的前提。所謂賦值相容顧名思義:不同型別的變數之間互相賦值的相容現象。就像隱式型別轉換一樣,而對於父子類物件之間的賦值相容是由嚴格規定的,只有在以下幾種情況下才能賦值相容:

①派生類的物件可以賦值給基類物件。
②派生類的物件可以初始化基類的引用。
③派生類物件的地址可以賦給指向基類的指標

但是由於基類物件與基類引用的侷限性,我們一般採用基類指標進行派生類物件的函式呼叫。

實現條件:

①父類中有虛擬函式。
②子類 override(覆寫/覆蓋)父類中的虛擬函式。
③通過己被子類物件賦值的父類指標或引用,呼叫共用介面。

virtual type func(引數列表) = 0;為純虛擬函式的宣告方式,純虛擬函式所在基類我們稱為抽象基類,抽象基類不能例項化物件,只能為子類物件提供介面(並非只有純虛擬函式才能實現動多型,但是一般我們不在純虛擬函式所在的類中對虛擬函式具體化其功能。而僅設為純虛以提供介面)我們所說的抽象基類提供介面,就是指特定子類物件通過抽象基類的純虛擬函式介面,去匹配本物件對應的子類覆寫的抽象基類的虛擬函式(子類中覆寫的父類函式也是虛擬函式,只不過可以不寫virtual修飾)。一般要將抽象基類中的解構函式也宣告為虛基類,以解決物件析構時的析構不徹底問題(在 delete 父類指標的時候,會呼叫子類的解構函式,實現完整析構)。

2、虛擬函式表與虛表指標剖析:

我們之前說多型條件中:派生類中與抽象基類同名的成員函式會覆寫(override)其父類的虛擬函式,那麼覆寫是如何實現的呢?我們得先來看看虛擬函式表(Virtual function table)與虛表指標(Virtual pointer,vptr)的問題:

一個類在產生物件時,會根據類中成員來為物件分配一定的空間,無論是棧空間還是堆空間,其必定遵循一定的規律,就是什麼樣的成員需要分配空間,什麼樣的成員應該在什麼樣的位置。就這句話,我們來做個小實驗:

#include <iostream>
using namespace std;

class Base{/*該類中函式均為虛擬函式*/
public:
    virtual void f(){cout<<"Base::f()"<<endl;}
    virtual void g(){cout<<"Base::g()"<<endl;}
    virtual void h(){cout<<"Base::h()"<<endl;}
private:
    int a;
    int b;
};
class Base_Two{/*該類中函式均為非虛擬函式*/
public:
    void f(){cout<<"Base::f()"<<endl;}
    void g(){cout<<"Base::g()"<<endl;}
    void h(){cout<<"Base::h()"<<endl;}
private:
    int a;
    int b;
};
int main()
{
    Base b;
    cout<<"Virtual function:"<<sizeof(Base)<<endl;
    cout<<"Virtual function:"<<sizeof(b)<<endl;

    Base_Two b2;
    cout<<"Ordinary function:"<<sizeof(Base_Two)<<endl;
    cout<<"Ordinary function:"<<sizeof(b2)<<endl;

    return 0;
}

執行後我們發現,兩個成員個數完全相同的類/物件列印結果竟然不同:

這裡寫圖片描述

並且在我們將Base類中的三個虛擬函式改為兩個或者一個時,其結果仍然是12,而若是三個虛擬函式均改為普通函式,則大小就與Base_Two類完全相同,這是為什麼呢?函式不是應該不佔用堆記憶體/棧記憶體嗎?如果不是虛擬函式佔用的,那麼多出來的四個位元組是提供給誰的?我們畫張圖來說明:

這裡寫圖片描述

既然如上圖所說,那麼我們豈不是可以根據物件b的地址來訪問vptr的值,進而訪問三個虛擬函式的地址?答案是當然可以,測試程式碼如下:

int main()
{
    Base b;
    Base_Two b2;

    cout<<"Object start address:"<<&b<<endl;//物件起始地址
    cout<<"Virtual function table start address:";//V-Table起始地址
    cout<<(int **)(*(int *)(&b))<<endl;

    cout<<"Function address in virtual function table:"<<endl;

    cout<<((int **)(*(int *)&b))[0]<<endl;
    cout<<((int **)(*(int *)&b))[1]<<endl;
    cout<<((int **)(*(int *)&b))[2]<<endl;
    cout<<((int **)(*(int *)&b))[3]<<endl;
     /****************************
     * 表示式分析:
     * (int *)&b:取物件b地址的前四個位元組,即vptr的地址
     * *(int *)&b:取vptr的儲存的地址值
     * (int **)(*(int *)&b):將該地址值轉換成二級指標,即存放虛擬函式地址(一級指標)的虛表陣列地址
     * ((int **)(*(int *)&b))[i]:根據該虛表地址進行下標運算取具體的(第i個)虛擬函式地址
    *******************************/
    return 0;
}

我們在程式return 0;之前設定一個斷點,除錯並與執行結果進行對比:

這裡寫圖片描述

發現程式列印的結果與除錯中的變數地址是一致的,並且我們可以在除錯框中看到[vptr]這一標誌,其三個成員對應的函式名與其所屬類一清二楚,由於虛擬函式表中最後一個儲存值為NULL,列印就是0。當然我們也可以根據函式指標以及獲取到的地址對其進行函式呼叫:

int main(void){
...
    typedef void(*PFUNC)(void);

    PFUNC pf = ((PFUNC*)(int **)(*(int *)(&b)))[0];
    pf();
    PFUNC pg = ((PFUNC*)(int **)(*(int *)(&b)))[1];
    pg();
    PFUNC ph = ((PFUNC*)(int **)(*(int *)(&b)))[2];
    ph();

    return 0;
}

結果如下:

這裡寫圖片描述

3、虛擬函式表與動多型的實現:

根據以上分析,我們知道了虛擬函式的地址是放在虛擬函式表中的,而物件可以根據其所擁有的虛表指標以及相應的偏移量進行虛擬函式的訪問呼叫。那麼對於基於虛擬函式的動多型的實現又是怎樣的?我們繼續往下分析:

我們先將上面的程式稍作修改,讓 Derive繼承有虛擬函式的Base,此時的 Derive中並沒有覆寫父類的虛擬函式。

#include <iostream>

using namespace std;
typedef void(*PFUNC)(void);

class Base{
public:
    virtual void f(){cout<<"Base::f()"<<endl;}
    virtual void g(){cout<<"Base::g()"<<endl;}
    virtual void h(){cout<<"Base::h()"<<endl;}
private:
    int a;
    int b;
};
class Derive:public Base
{
public:
    virtual void f1(){cout<<"Derive::f1()"<<endl;}
    virtual void g1(){cout<<"Derive::g1()"<<endl;}
    virtual void h1(){cout<<"Derive::h1()"<<endl;}
private:
    int a;
    int b;
};
int main()
{
    Derive b;

    cout<<"Object start address:"<<&b<<endl;//物件起始地址
    cout<<"Virtual function table start address:";//V-Table起始地址
    cout<<(int **)(*(int *)(&b))<<endl;//int **:二級指標,表示虛表指標為函式指標陣列

    cout<<"Function address in virtual function table:"<<endl;
    for(int i=0;i<6;i++){
        cout<<((int **)(*(int *)(&b)))[i]<<endl;
    }

    PFUNC pfunc;
    for(int i=0;i<6;i++){
        pfunc = ((PFUNC*)(int **)(*(int *)(&b)))[i];
        pfunc();
    }

    return 0;
}

對於沒有覆寫父類虛擬函式的這段例子,我們依然設定斷點除錯,結果如下:

這裡寫圖片描述

在修改Derive::f1()、Derive::g1()、Derive::h1()三個函式名為Derive::f()、Derive::g()、Derive::h()及其輸出內容之後,我們再進行測試,發現結果產生段錯誤:段錯誤的提示資訊是由於此時覆寫之後,虛表中只存在三個有效指標,我們迴圈時的條件未進行修改越界訪問而導致的。本來想將迴圈條件值修改後測試截圖,但是覺得這個段錯誤包含了覆寫時的虛表大小改變的情況,就留了下來,我們不予理睬即可(段錯誤截圖如下,收到SIGSEGV訊號):

這裡寫圖片描述

我們只分析除錯資訊與輸出資訊:

這裡寫圖片描述

可以看到,父類的Base::f()、Base::g()、Base::h()函式已經不存在了,而被子類的同名函式Derive::f()、Derive::g()、Derive::h()給覆寫了。

對上面的輸出結果,根據其列印的地址,我們再畫兩張圖來分析:
下圖為未覆寫的記憶體圖:

這裡寫圖片描述

下圖為覆寫後的記憶體圖(注意:兩次編譯執行後列印的地址雖然相近但是無必然聯絡):

這裡寫圖片描述

由這兩張圖,我們可以很好地看清,原來覆寫時,vptr的地址由Base::f()的地址變成了Derive::f()的地址,因此在通過vptr指標與偏移量向低地址定址的過程中,就不能再找到Base的函數了,不同子類其物件中在執行時修改vptr的值(也就是虛擬函式表的起始地址),也就實現了覆寫與多型。
注意:虛擬函式表的建立在抽象基類的建構函式之後才完成,虛擬函式表在子類的解構函式執行以後就已經不再有效。因此,在子類建立與銷燬物件時,如果在抽象基類的建構函式與解構函式中,去呼叫虛擬函式,列印的資訊是抽象基類的成員虛擬函式資訊,而在抽象基類中的其他成員函式中呼叫虛擬函式列印的是呼叫方(子類中物件)覆寫的虛擬函式資訊。(即子類覆寫的抽象基類虛擬函式作用域不包括抽象基類的構造器與析構器)