虛函數詳解


一、多態與重載

1、多態的概念

  面向對象的語言有三大特性:繼承、封裝、多態。虛函數作為多態的實現方式,重要性毋庸置疑。

  多態意指相同的消息給予不同的對象會引發不同的動作(一個接口,多種方法)。其實更簡單地來說,就是“在用父類指針調用函數時,實際調用的是指針指向的實際類型(子類)的成員函數”。多態性使得程序調用的函數是在運行時動態確定的,而不是在編譯時靜態確定的。

2、重載—編譯期多態的體現

  重載,是指在一個類中的同名不同參數的函數調用,這樣的方法調用是在編譯期間確定的。

3、虛函數—運行期多態的體現

  運行期多態發生的三個條件:繼承關系、虛函數覆蓋、父類指針或引用指向子類對象。

二、虛函數實例

 

#include <iostream>
#include <conio.h>
using namespace std;

class Base
{
public:
    virtual void vir_fun() { cout << "vitrual function,this is class Bass" <<endl;}
    void fun(){ cout << "normal function,this is class Bass" <<endl;}
};
class A : public Base
{
public:
    virtual void vir_fun() { cout << "vitrual function,this is class A" <<endl;}
    void fun(){ cout << "normal function,this is class A" <<endl;}
};

class B : public Base
{
public:
    virtual void vir_fun() { cout << "vitrual function,this is class B" <<endl;}
    void fun(){ cout << "normal function,this is class B" <<endl;}
};

int main()
{
    Base * b1 = new (Base);
    Base *b2 = new (A);
    Base *b3 = new (B);
    b1->fun();  //調用的都是基類base的函數
    b2->fun();   //調用的都是基類base的函數
    b3->fun ();  //調用的都是基類base的函數

    cout << "############################## " << endl ;
    b1->vir_fun();  //調用的是指針指向的實際類型的函數   BASE
    
    b2->vir_fun();  //調用的是指針指向的實際類型的函數   A
    
    b3->vir_fun();  //調用的是指針指向的實際類型的函數   B
    cout << "############################## " << endl ;
    ((A*) b2)->vir_fun();    //A
    ((B *)b3)->vir_fun();    //B

    cout << "############################## " << endl ;
    ((A*) b2)->fun();       //A
    ((B *)b3)->fun();       //B

    //當使用類的指針調用成員函數時,普通函數由指針類型決定,
    //而虛函數由指針指向的實際類型決定
}

 

顯示的內容

/* 顯示內容
    normal function,this is class Bass
    normal function,this is class Bass
    normal function,this is class Bass
    ##############################
    vitrual function,this is class Bass
    vitrual function,this is class A
    vitrual function,this is class B
    ##############################
    vitrual function,this is class A
    vitrual function,this is class B
    ##############################
    normal function,this is class A
    normal function,this is class B
    */

在上述例子中,我們首先定義了一個基類base,基類有一個名為vir_func的虛函數,和一個名為func的普通成員函數。而類A,B都是由類base派生的子類,並且都對成員函數進行了重載。然后我們定義三個base類型的指針Base、a、b分別指向類base、A、B。可以看到,當使用這三個指針調用func函數時,調用的都是基類base的函數。而使用這三個指針調用虛函數vir_func時,調用的是指針指向的實際類型的函數。最后,我們將指針b做強制類型轉換,轉換為A類型指針,然后分別調用func和vir_func函數,發現普通函數調用的是類A的函數,而虛函數調用的是類B的函數。

  以上,我們可以得出結論當使用類的指針調用成員函數時,普通函數由指針類型決定,而虛函數由指針指向的實際類型決定。

  虛函數的實現過程:通過對象內存中的vptr找到虛函數表vtbl,接着通過vtbl找到對應虛函數的實現區域並進行調用。

 

三、虛函數的實現(內存布局)

 

  虛函數表中只存有一個虛函數的指針地址,不存放普通函數或是構造函數的指針地址。只要有虛函數,C++類都會存在這樣的一張虛函數表,不管是普通虛函數亦或是純虛函數,亦或是派生類中隱式聲明的這些虛函數都會生成這張虛函數表。

 

  虛函數表創建的時間:在一個類構造的時候,創建這張虛函數表,而這個虛函數表是供整個類所共有的。虛函數表存儲在對象最開始的位置。虛函數表其實就是函數指針的地址。函數調用的時候,通過函數指針所指向的函數來調用函數。

 

1、無繼承情況

 

#include <iostream>
using namespace std;
 
class Base
{
public:
    Base(){cout<<"Base construct"<<endl;}
    virtual void f() {cout<<"Base::f()"<<endl;}
    virtual void g() {cout<<"Base::g()"<<endl;}
    virtual void h() {cout<<"Base::h()"<<endl;}
    virtual ~Base(){}
};
 
int main()
{
    typedef void (*Fun)();  //定義一個函數指針類型變量類型 Fun
    Base *b = new Base();
    //虛函數表存儲在對象最開始的位置
    //將對象的首地址輸出
    cout<<"首地址:"<<*(int*)(&b)<<endl;
 
    Fun funf = (Fun)(*(int*)*(int*)b);
    Fun fung = (Fun)(*((int*)*(int*)b+1));//地址內的值 即為函數指針的地址,將函數指針的地址存儲在了虛函數表中了
    Fun funh = (Fun)(*((int *)*(int *)b+2));
 
    funf();
    fung();
    funh();
 
    cout<<(Fun)(*((int*)*(int*)b+4))<<endl; //最后一個位置為0 表明虛函數表結束 +4是因為定義了一個 虛析構函數
 
    delete b;
    return 0;
}

 

 

 

2、單繼承情況(無虛函數覆蓋)

  假設有如下所示的一個繼承關系:

 

 


  請注意,在這個繼承關系中,子類沒有重載任何父類的函數。那么,在派生類的實例中,其虛函數表如下所示:

 

 

【Note】:

  • 覆蓋的f()函數被放到了虛表中原來父類虛函數的位置。

  • 沒有被覆蓋的函數依舊在原來的位置。

這樣,我們就可以看到對於下面這樣的程序,

 

Base *b = new Derive();
b->f();

 

由b所指的內存中的虛函數表的f()的位置已經被Derive::f()函數地址所取代,於是在實際調用發生時,是Derive::f()被調用了。這就實現了多態。

4、多重繼承情況(無虛函數覆蓋)

  下面,再讓我們來看看多重繼承中的情況,假設有下面這樣一個類的繼承關系。注意:子類並沒有覆蓋父類的函數。

 

 對於子類實例中的虛函數表,是下面這個樣子:

 

 

Note】:

  • 每個父類都有自己的虛表(有幾個基類就有幾個虛函數表)。

  • 子類的成員函數被放到了第一個父類的表中。(所謂的第一個父類是按照聲明順序來判斷的)。

5、多重繼承情況(有虛函數覆蓋)

  下面我們再來看看,如果發生虛函數覆蓋的情況。下圖中,我們在子類中覆蓋了父類的f()函數。

 

 

下面是對於子類實例中的虛函數表的圖:

 

 我們可以看見,三個父類虛函數表中的f()的位置被替換成了子類的函數指針。這樣,我們就可以任一靜態類型的父類來指向子類,並調用子類的f()了。如:

Derive d;
Base1 *b1 = &d;
Base2 *b2 = &d;
Base3 *b3 = &d;
b1->f(); //Derive::f()
b2->f(); //Derive::f()
b3->f(); //Derive::f()
b1->g(); //Base1::g()
b2->g(); //Base2::g()
b3->g(); //Base3::g()

四、虛函數的相關問題

1、構造函數為什么不能定義為虛函數

  構造函數不能是虛函數。

  首先,我們已經知道虛函數的實現則是通過對象內存中的vptr來實現的。而構造函數是用來實例化一個對象的,通俗來講就是為對象內存中的值做初始化操作。那么在構造函數完成之前,vptr是沒有值的,也就無法通過vptr找到作為虛函數的構造函數所在的代碼區。

2、析構函數為什么要定義為虛函數?

  析構函數可以是虛函數且推薦最好設置為虛函數。

 

class B
{
public:
    B() { printf("B()\n"); }
    virtual ~B() { printf("~B()\n"); }
private:
    int m_b;
};
 
class D : public B
{
public:
    D() { printf("D()\n"); }
    ~D() { printf("~D()\n"); }
private:
    int m_d;
};
 
int main()
{
    B* pB = new D();
    delete pB;
    return 0;
}

 

C++中有這樣的約束:執行子類構造函數之前一定會執行父類的構造函數;同理,執行子類的析構函數后,一定會執行父類的析構函數,這也是為什么我們一直建議類的析構函數寫成虛函數的原因。

3、如何去驗證虛函數表的存在

 

typedef void(*Fun)(void);
// 取類的一個實例
Base b;
Fun pFun = NULL;
// 把&b轉成int ,取得虛函數表的地址
cout << "虛函數表地址:" << (int*)(&b) << endl;
// 再次取址就可以得到第一個虛函數的地址了
cout << "虛函數表 — 第一個函數地址:" << (int*)*(int*)(&b) << endl;
pFun = (Fun)*((int*)*(int*)(&b));
pFun();

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM