對原文的内容加了一些自己的了解,測試編譯環境為win10+vs2015
前言
C++中的虛函數的作用主要是實作了多态的機制。關于多态,簡而言之就是用父類型的指針指向其子類的執行個體,然後通過父類的指針調用實際子類的成員函數。這種技術可以讓父類的指針有“多種形态”,這是一種泛型技術。所謂泛型技術,說白了就是試圖使用不變的代碼來實作可變的算法。比如:模闆技術,RTTI技術(Run-Time Type Identification),虛函數技術,要麼是試圖做到在編譯時決議,要麼試圖做到運作時決議。
虛函數表
對C++ 了解的人都應該知道虛函數(Virtual Function)是通過一張虛函數表(Virtual Table)來實作的。簡稱為V-Table。在這個表中,主要是一個類的虛函數的位址表,這張表解決了繼承、覆寫的問題,保證其内容真實反應實際的函數。這樣,在有虛函數的類的執行個體中這個表被配置設定在了這個執行個體的記憶體中,是以,當我們用父類的指針來操作一個子類的時候,這張虛函數表就顯得尤為重要了,它就像一個地圖一樣,指明了實際所應該調用的函數。
這裡我們着重看一下這張虛函數表。C++的編譯器應該是保證虛函數表的指針存在于對象執行個體中最前面的位置(這是為了保證取到虛函數表的有最高的性能——如果有多層繼承或是多重繼承的情況下)。 這意味着我們通過對象執行個體的位址得到這張虛函數表,然後就可以周遊其中函數指針,并調用相應的函數。
假設我們有這樣的一個類:
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的執行個體來得到虛函數表。 下面是實際例程:
typedef void(*Fun)(void);
Base b;
Fun pFun = NULL;
cout << "虛函數表位址:" << (int*)*(int*)(&b) << endl;
cout << "虛函數表 — 第一個函數位址:" << (int*)*(int*)*(int*)(&b) << endl;
// Invoke(調用) the first virtual function
pFun = (Fun)*((int*)*(int*)(&b));
pFun();
實際運作經果如下:(windows10+vs2015)

調試結果如下:
對比發現虛函數表的位址和虛函數表中第一個函數的位址是相同的。
通過這個示例,我們可以看到,我們可以通過強行把&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()
這個時候你應該懂了吧。什麼?還是有點暈。也是,這樣的代碼看着太亂了。沒問題,讓我畫個圖解釋一下。如下所示:
注意:在上面這個圖中,我在虛函數表的最後多加了一個結點,這是虛函數表的結束結點,就像字元串的結束符'\0'一樣,其标志了虛函數表的結束。這個結束标志的值在不同的編譯器下是不同的。在WinXP+VS2003下,這個值是NULL。而在Ubuntu 7.10 + Linux 2.6.22 + GCC 4.1.3下,這個值如果是1,表示還有下一個虛函數表,如果值是0,表示是最後一個虛函數表(這一部分是原作者測試的結果)。
一般繼承(無虛函數覆寫)
下面,再讓我們來看看繼承時的虛函數表是什麼樣的。假設有如下所示的一個繼承關系:
請注意,在這個繼承關系中,子類沒有重載任何父類的函數。那麼,在派生類的執行個體中,其虛函數表如下所示:
對于執行個體:Derive d; 的虛函數表如下:
我們可以看到下面幾點:
1)虛函數按照其聲明順序放于表中。
2)父類的虛函數在子類的虛函數前面。
一個具體的執行個體:
#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; }
};
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; }
};
typedef void(*Fun)(void);
int main()
{
Base b;
Derive d;
system("pause");
return 0;
}
斷點檢視父類和子類的虛函數表位址如下:
調試狀态下子類的虛函數表中隻能看到包含父類的虛函數表,且位址相同。
一般繼承(有虛函數覆寫)
覆寫父類的虛函數是很顯然的事情,不然,虛函數就變得毫無意義。下面,我們來看一下,如果子類中有虛函數重載了父類的虛函數,會是一個什麼樣子?假設,我們有下面這樣的一個繼承關系。
為了讓大家看到被繼承過後的效果,在這個類的設計中,我隻覆寫了父類的一個函數: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()
安全性
一、通過父類型的指針通路子類自己的虛函數
我們知道,子類沒有重載父類的虛函數是一件毫無意義的事情。因為多态也是要基于函數重載的。雖然在上面的圖中我們可以看到Base1的虛表中有Derive的虛函數,但我們根本不可能使用下面的語句來調用子類的自有虛函數:
Base1 *b1 = new Derive();
b1->f1(); //編譯出錯
任何妄圖使用父類指針想調用子類中的未覆寫父類的成員函數的行為都會被編譯器視為非法,是以,這樣的程式根本無法編譯通過。但在運作時,我們可以通過指針的方式通路虛函數表來達到違反C++語義的行為。(關于這方面的嘗試,通過閱讀後面附錄的代碼,相信你可以做到這一點)
二、通路non-public的虛函數
另外,如果父類的虛函數是private或是protected的,但這些非public的虛函數同樣會存在于虛函數表中,是以,我們同樣可以使用通路虛函數表的方式來通路這些non-public的虛函數,這是很容易做到的。
class Base {
private:
virtual void f() { cout << "Base::f" << endl; }
};
class Derive : public Base{
};
typedef void(*Fun)(void);
void main() {
Derive d;
Fun pFun = (Fun)*((int*)*(int*)(&d)+0);
pFun();
}
附錄
如想檢視虛函數完整表,可按以下步驟:VS2015為例
打開上圖的“VS2015開發人員指令提示”
使用cl指令的"/d1 reportAllClassLayout或reportSingleClassLayoutXXX"選項。這裡的reportAllClassLayout選項會列印大量相關類的資訊,一般用處不大。而reportSingleClassLayoutXXX選項的XXX代表要編譯的代碼中類的名字(這裡XXX類),列印XXX類的記憶體布局和虛函數表(如果代碼中沒有對應的類,則選項無效)。
我的源檔案為test.cpp,是以輸入:
cl /d1 reportSingleClassLayoutDerive test.cpp
輸出如下(該裡對應):
對應的執行個體如下:
#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; }
};
class Derive : public Base
{
public:
virtual void f() { cout << "Derive::f1" << endl; } //重載了父類的函數
virtual void g1() { cout << "Derive::g1" << endl; }
virtual void h1() { cout << "Derive::h1" << endl; }
};
typedef void(*Fun)(void);
int main()
{
Base b;
Derive d;
b.f();
d.f();
Base* b1 = new Derive;
b1->f();
system("pause");
return 0;
}
可看出用到了thunk技術 (thunk技術即是:虛函數表中的slot仍然繼續放一個虛函數實體位址,但是如果調用這個虛函數需要進行this調整的話,該slot中的位址就指向一個thunk而不是一個虛函數實體的位址。)
個人總結
1)一個類隻有包含虛函數才會存在虛函數表,同屬于一個類的對象共享虛函數表,但是有各自的vptr(虛函數表指針),當然所指向的位址(虛函數表首位址)相同。
2)父類中有虛函數就等于子類中有虛函數。換句話來說,父類中有虛函數表,則子類中肯定有虛函數表。因為你是繼承父類的。也有人認為,如果子類中把父類的虛函數的virtual去掉,是不是這些函數就不再是虛函數了?隻要在父類中是虛函數,那麼子類中即便不寫virtual,也依舊是虛函數。但不管是父類還是子類,都隻會有一個虛函數表,不能認為子類中有一個虛函數表+父類中有一個虛函數表,得到一個結論:子類中有兩個虛函數表。子類中是否可能會有多個虛函數表呢?後續我們講解這個事;
3)如果子類中完全沒有新的虛函數,則我們可以認為子類的虛函數表和父類的虛函數表内容相同。但,僅僅是内容相同,這兩個虛函數表在記憶體中處于不同位置,換句話來說,這是内容相同的兩張表。虛函數表中每一項,儲存着一個虛函數的首位址,但如果子類的虛函數表某項和父類的虛函數表某項代表同一個函數(這表示子類沒有覆寫父類的虛函數),則該表項所執行的該函數的位址應該相同。
4)超出虛函數表部分内容不可知;
總結: 虛函數表是跟着類走的,虛函數表指針是跟着具體對象走的