碼神營地-C++的多態之虛函數

每一個學習C++的人都會知道C++的三大特性:封裝,繼承,多態。封裝主要體現在對對象私有數據和私有成員函數隱藏和保護,保護底層數據或接口,從而封裝以另一種接口來呈獻給用戶,繼承最大的作用即就是實現代碼重用,減少代碼量。看了相關書籍和自己的使用經驗,對於多態在此談談自己的理解和認識。推薦書籍《深度探索C++對象模型》。
更多關於C++相關學習教程請進碼神營地官網:www.icodegod.com

首先個人理解對於多態這個詞廣義上來說分爲編譯時多態和運行時多態,對於編譯時的多態又有重載和模板兩種體現,重載只是針對類的成員函數的,相同函數名,而有不同的參數列表(包括參數類型和參數個數)從而構成重載;對於模板又分爲函數模板,類模板,它相當於是C++中針對類型的多態(包括參數類型和返回值類型)。而對於運行時多態則就是虛函數的體現了。事實上這個纔是C++多態的重頭戲,個人理解和總結如下:

  • 虛函數表
    對C++ 瞭解的人都應該知道虛函數(Virtual Function)是通過一張虛函數表(Virtual Table)來實現的。簡稱爲V-Table。在這個表中,主是要一個類的虛函數的地址表,這張表解決了繼承、覆蓋的問題,保證其容真實反應實際的函數。這樣,在有虛函數的類的實例中這個表被分配在了這個實例的內存中,所以,當我們用父類的指針來操作一個子類的時候,這張虛函數表就顯得由爲重要了,它就像一個地圖一樣,指明瞭實際所應該調用的函數。

所以對於虛函數的幾種情況分類如下:

一般繼承(無虛函數覆蓋)
一般繼承(有虛函數覆蓋)
多重繼承(無虛函數覆蓋)
多重繼承(有虛函數覆蓋)

假設我們有這樣的一個類:

class Base
 {
 public:
      virtual void f() { cout << "Base::f" << endl; }
      virtual void g() { cout << "Base::g" << endl; }
      virtual void h() { cout << "Base::h" << endl; }
};

我們可以通過Base的實例來得到虛函數表。 下面是實際例程:

int main()
{
	typedef void(*Fun)(void);
    Base b;
    Fun pFun = NULL;
    cout << "虛函數表地址:" << (int*)(&b) << endl;
    cout << "虛函數表 — 第一個函數地址:" << (int*)*(int*)(&b) << endl;
    pFun = (Fun)*((int*)*(int*)(&b));
    pFun();

	return 0;
}

編譯環境爲VC++6.0
結果如下:

這裏寫圖片描述

我們可以看到,我們可以通過強行把&b轉成int ,取得虛函數表的地址,然後,再次取址就可以得到第一個虛函數的地址了,也就是Base::f(),這在上面的程序中得到了驗證(把int 強制轉成了函數指針)。通過這個示例,我們就可以知道如果要調用Base::g()和Base::h(),其代碼如下:

        (Fun)*((int*)*(int*)(&b)+0);  // Base::f()
        (Fun)*((int*)*(int*)(&b)+1);  // Base::g()
        (Fun)*((int*)*(int*)(&b)+2);  // Base::h()

當程序中中加入調用後兩個虛函數的代碼:

	typedef void(*Fun)(void);
    Base b;
    Fun pFun = NULL;
    cout << "虛函數表地址:" << (int*)(&b) << endl;
    cout << "虛函數表 — 第一個函數地址:" << (int*)*(int*)(&b) << endl;
    pFun = (Fun)*((int*)*(int*)(&b));
    pFun();

	pFun=(Fun)*((int*)*(int*)(&b)+1);// Base::g()
	pFun();

	pFun=(Fun)*((int*)*(int*)(&b)+2);// Base::h()
	pFun();

可以得到結果如下:
這裏寫圖片描述

在調試器中可以看到:
這裏寫圖片描述

那麼可以知道虛函數表是這樣子的:
這裏寫圖片描述

在上面這個圖中,虛函數表的最後多加了一個結點,這是虛函數表的結束結點,就像字符串的結束符“/0”一樣,其標誌了虛函數表的結束。這個結束標誌的值在不同的編譯器下是不同的。在VC++ 6.0下,這個值是NULL。而在 GCC下,這個值是如果1,表示還有下一個虛函數表,如果值是0,表示是最後一個虛函數表。

下面說說“無覆蓋”和“有覆蓋”時的虛函數表的樣子。沒有覆蓋父類的虛函數是毫無意義的。我之所以要講述沒有覆蓋的情況,主要目的是爲了給一個對比。在比較之下,我們可以更加清楚地知道其內部的具體實現。

####一般繼承(無虛函數覆蓋)

下面,再讓我們來看看繼承時的虛函數表是什麼樣的。假設有如下所示的一個繼承關係:

這裏寫圖片描述

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

這裏寫圖片描述

我們可以看到:
1)虛函數按照其聲明順序放於表中。
2)父類的虛函數在子類的虛函數前面。
這種繼承關係比較簡單分析起來也比較容易。

####一般繼承(有虛函數覆蓋)

覆蓋父類的虛函數是很顯然的事情,不然,虛函數就變得毫無意義。下面,我們來看一下,如果子類中有虛函數重載了父類的虛函數,會是一個什麼樣子?假設,我們有下面這樣的一個繼承關係。

這裏寫圖片描述

爲了讓大家看到被繼承過後的效果,在這個類的設計中,我只覆蓋了父類的一個函數:f()。那麼,對於派生類的實例,其虛函數表會是下面的一個樣子:

這裏寫圖片描述

我們從表中可以看到下面幾點,
1)覆蓋的f()函數被放到了虛表中原來父類虛函數的位置。
2)沒有被覆蓋的函數依舊。

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

        Base *b = new Derive();

        b->f();

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

####多重繼承(無虛函數覆蓋)

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

這裏寫圖片描述

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

這裏寫圖片描述

我們可以看到:
1) 每個父類都有自己的虛表。
2) 子類的成員函數被放到了第一個父類的表中。(所謂的第一個父類是按照聲明順序來判斷的)

這樣做就是爲了解決不同的父類類型的指針指向同一個子類實例,而能夠調用到實際的函數。

####多重繼承(有虛函數覆蓋)

下面我們再來看看,如果發生虛函數覆蓋的情況。

下圖中,我們在子類中覆蓋了父類的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()

總之,虛函數的理解使用起來不是很容易,當然如果深入理解了並動手試驗過就能對它有一定的認識,理解和掌握虛函數是學習C++的一個必經之路,如果不懂虛函數,那麼就談不上熟悉C++。
當然,虛函數也不是沒有缺陷的,它在安全性方面還是有一定危害,如果不正確的使用虛函數和函數的調用也將會帶來重大問題。比如常見的兩種不安全的做法:

  • 通過基類指針來訪問派生類獨有的虛函數(任何妄圖使用父類指針想調用子類中的未覆蓋父類的成員函數的行爲都會被編譯器視爲非法,將會調用失敗)
  • 訪問非public的虛函數(如果父類的虛函數是private或是protected的,但這些非public的虛函數同樣會存在於虛函數表中,所以,我們同樣可以使用訪問虛函數表的方式來訪問這些non-public的虛函數,這噹噹然對於編譯器來說是拒絕的)。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章