C++虛函數表深入探索(詳細全面)
- 2020 年 3 月 17 日
- 筆記
這篇博客可能有一點點長,代碼也有一點點多,但是仔細閱讀分析完,會對虛函數表有一個深刻的認識。
什麼是虛函數表?
對於一個類來說,如果類中存在虛函數,那麼該類的大小就會多4個位元組,然而這4個位元組就是一個指針的大小,這個指針指向虛函數表。所以,如果對象存在虛函數,那麼編譯器就會生成一個指向虛函數表的指針,所有的虛函數都存在於這個表中,虛函數表就可以理解為一個數組,每個單元用來存放虛函數的地址。
虛函數(Virtual Function)是通過一張虛函數表來實現的。簡稱為V-Table。在這個表中,主要是一個類的虛函數的地址表,這張表解決了繼承、覆蓋的問題,保證其真實反應實際的函數。這樣,在有虛函數的類的實例中分配了指向這個表的指針的內存,所以,當用父類的指針來操作一個子類的時候,這張虛函數表就顯得尤為重要了,它就像一個地圖一樣,指明了實際所應該調用的函數。 ————-百度百科
虛函數表存在的位置
由於虛函數表是由編譯器給我們生成的,那麼編譯器會把虛函數表安插在哪個位置呢?下面可以簡單的寫一個示例來證明一下虛函數表的存在,以及觀察它所存在的位置,先來看一下代碼:
#include <iostream> #include <stdio.h> using namespace std; class A{ public: int x; virtual void b() {} }; int main() { A* p = new A; cout << p << endl; cout << &p->x << endl; return 0; }
定義了一個類A,含有一個x和一個虛函數,實例化一個對象,然後輸出對象的地址和對象成員x的地址,我們想一下,如果對象的地址和x的地址相同,那麼就意味着編譯器把虛函數表放在了末尾,如果兩個地址不同,那麼就意味着虛函數表是放在最前面的。運行結果為16進制,然後我們把它轉為10進制觀察一下:

可以觀察到結果是不同的,而且正好相差了4個位元組,由此可見,編譯器把生成的虛函數表放在了最前面。
獲取虛函數表
既然虛函數表是真實存在的,那麼我們能不能想辦法獲取到虛函數表呢?其實我們可以通過指針的形式去獲得,因為前面也提到了,我們可以把虛函數表看作是一個數組,每一個單元用來存放虛函數的地址,那麼當調用的時候可以直接通過指針去調用所需要的函數就行了。我們就類比這個思路,去獲取一下虛函數表。首先先定義兩個類,一個是基類一個是派生類,代碼如下:
class Base { public: virtual void a() { cout << "Base a()" << endl; } virtual void b() { cout << "Base b()" << endl; } virtual void c() { cout << "Base c()" << endl; } }; class Derive : public Base { public: virtual void b() { cout << "Derive b()" << endl; } };
現在我們設想一下Derive類中的虛函數表是什麼樣的,它應該是含有三個指針,分別指向基類的虛函數a和基類的虛函數c和自己的虛函數b(因為基類和派生類中含有同名函數,被覆蓋),那麼我們就用下面的方式來驗證一下:
Derive* p = new Derive; long* tmp = (long*)p; // 先將p強制轉換為long類型指針tmp // 由於tmp是虛函數表指針,那麼*tmp就是虛函數表 long* vptr = (long*)(*tmp); for (int i = 0; i < 3; i++) { printf("vptr[%d] : %pn", i, vptr[i]); }
同理,我們把基類的虛函數表的內容也用這種方法獲取出來,然後二者進行比較一下,看看是否是符合我們上面所說的那個情況。先看一下完整的代碼:
#include <iostream> #include <stdio.h> using namespace std; class Base { public: virtual void a() { cout << "Base a()" << endl; } virtual void b() { cout << "Base b()" << endl; } virtual void c() { cout << "Base c()" << endl; } }; class Derive : public Base { public: virtual void b() { cout << "Derive b()" << endl; } }; int main() { cout << "-----------Base------------" << endl; Base* q = new Base; long* tmp1 = (long*)q; long* vptr1 = (long*)(*tmp1); for (int i = 0; i < 3; i++) { printf("vptr[%d] : %pn", i, vptr1[i]); } Derive* p = new Derive; long* tmp = (long*)p; long* vptr = (long*)(*tmp); cout << "---------Derive------------" << endl; for (int i = 0; i < 3; i++) { printf("vptr[%d] : %pn", i, vptr[i]); } return 0; }
運行結果如下圖所示:

可見基類中的三個指針分別指向a,b,c虛函數,而派生類中的三個指針中第一個和第三個和基類中的相同,那麼這就印證了上述我們所假設的情況,那麼這也就是虛函數表。但是僅僅只是觀察指向的地址,還不是讓我們觀察的特別清楚,那麼我們就通過定義函數指針,來調用一下這幾個地址,看看結果是什麼樣的,下面直接上代碼:
#include <iostream> #include <stdio.h> using namespace std; class Base { public: virtual void a() { cout << "Base a()" << endl; } virtual void b() { cout << "Base b()" << endl; } virtual void c() { cout << "Base c()" << endl; } }; class Derive : public Base { public: virtual void b() { cout << "Derive b()" << endl; } }; int main() { typedef void (*Func)(); cout << "-----------Base------------" << endl; Base* q = new Base; long* tmp1 = (long*)q; long* vptr1 = (long*)(*tmp1); for (int i = 0; i < 3; i++) { printf("vptr[%d] : %pn", i, vptr1[i]); } Func a = (Func)vptr1[0]; Func b = (Func)vptr1[1]; Func c = (Func)vptr1[2]; a(); b(); c(); Derive* p = new Derive; long* tmp = (long*)p; long* vptr = (long*)(*tmp); cout << "---------Derive------------" << endl; for (int i = 0; i < 3; i++) { printf("vptr[%d] : %pn", i, vptr[i]); } Func d = (Func)vptr[0]; Func e = (Func)vptr[1]; Func f = (Func)vptr[2]; d(); e(); f(); return 0; }
運行結果如下:

這樣就清晰的印證了上述所說的假設,那麼虛函數表就獲取出來了。
多重繼承的虛函數表:
虛函數的引入其實就是為了實現多態(對於多態看到了一篇很不錯的博客:傳送門),現在來研究一下多重繼承的虛函數表是什麼樣的,首先我們先來看一下簡單的一般繼承的代碼:
class Base1 { public: virtual void A() { cout << "Base1 A()" << endl; } virtual void B() { cout << "Base1 B()" << endl; } virtual void C() { cout << "Base1 C()" << endl; } }; class Derive : public Base1{ public: virtual void MyA() { cout << "Derive MyA()" << endl; } };
這是一個類繼承一個類,這段代碼如果我們通過派生類去調用基類的函數,應該結果可想而知,這裡就不再演示和贅述了。我們來分析這兩個類的虛函數表,對於基類的虛函數表其實和上面所說的虛函數表是一樣的,有自己的虛函數指針,並指向自己的虛函數表,重點是在於派生類的虛函數表是什麼樣子的,它的樣子如下圖所示:

那麼Derive的虛函數表就是繼承了Base1的虛函數表,然後自己的虛函數放在後面,因此這個虛函數表的順序就是基類的虛函數表中的虛函數的順序+自己的虛函數的順序。那麼我們現在在Derive中再添加一個虛函數,讓它覆蓋基類中的虛函數,代碼如下:
class Base1 { public: virtual void A() { cout << "Base1 A()" << endl; } virtual void B() { cout << "Base1 B()" << endl; } virtual void C() { cout << "Base1 C()" << endl; } }; class Derive : public Base1{ public: virtual void MyA() { cout << "Derive MyA()" << endl; } virtual void B() { cout << "Derive B()" << endl; } };
那麼對於這種情況的虛函數表如下圖所示:

這個是單繼承的情況,然後我們看看多重繼承,也就是Derive類繼承兩個基類,先看一下代碼:
class Base1 { public: virtual void A() { cout << "Base1 A()" << endl; } virtual void B() { cout << "Base1 B()" << endl; } virtual void C() { cout << "Base1 C()" << endl; } }; class Base2 { public: virtual void D() { cout << "Base2 D()" << endl; } virtual void E() { cout << "Base2 E()" << endl; } }; class Derive : public Base1, public Base2{ public: virtual void A() { cout << "Derive A()" << endl; } // 覆蓋Base1::A() virtual void D() { cout << "Derive D()" << endl; } // 覆蓋Base2::D() virtual void MyA() { cout << "Derive MyA()" << endl; } };
首先我們明確一個概念,對於多重繼承的派生類來說,它含有多個虛函數指針,對於上述代碼而言,Derive含有兩個虛函數指針,所以它不是只有一個虛函數表,然後把所有的虛函數都塞到這一個表中,為了印證這一點,我們下面會印證這一點,首先我們先來看看這個多重繼承的圖示:

由圖可以看出,在第一個虛函數表中首先繼承了Base1的虛函數表,然後將自己的虛函數放在後面,對於第二個虛函數表中,繼承了Base2的虛函數表,由於在Derive類中有一個虛函數D覆蓋了Base2的虛函數,所以第一個表中就沒有Derive::D的函數地址。那麼我們就用代碼來實際的驗證一下是否會存在兩個虛函數指針,以及如果存在兩個虛函數表,那麼虛函數表是不是這個樣子的。來看下面的代碼:
#include <iostream> #include <stdio.h> using namespace std; class Base1 { public: virtual void A() { cout << "Base1 A()" << endl; } virtual void B() { cout << "Base1 B()" << endl; } virtual void C() { cout << "Base1 C()" << endl; } }; class Base2 { public: virtual void D() { cout << "Base2 D()" << endl; } virtual void E() { cout << "Base2 E()" << endl; } }; class Derive : public Base1, public Base2{ public: virtual void A() { cout << "Derive A()" << endl; } // 覆蓋Base1::A() virtual void D() { cout << "Derive D()" << endl; } // 覆蓋Base2::D() virtual void MyA() { cout << "Derive MyA()" << endl; } }; int main() { typedef void (*Func)(); Derive d; Base1 &b1 = d; Base2 &b2 = d; cout << "Derive對象所佔的內存大小為:" << sizeof(d) << endl; cout << "n---------第一個虛函數表-------------" << endl; long* tmp1 = (long *)&d; // 獲取第一個虛函數表的指針 long* vptr1 = (long*)(*tmp1); // 獲取虛函數表 Func x1 = (Func)vptr1[0]; Func x2 = (Func)vptr1[1]; Func x3 = (Func)vptr1[2]; Func x4 = (Func)vptr1[3]; x1();x2();x3();x4(); cout << "n---------第二個虛函數表-------------" << endl; long* tmp2 = tmp1 + 1; // 獲取第二個虛函數表指針 相當於跳過4個位元組 long* vptr2 = (long*)(*tmp2); Func y1 = (Func)vptr2[0]; Func y2 = (Func)vptr2[1]; y1(); y2(); return 0; }
先看看運行結果,然後再去分析證明:

因為在包含一個虛函數表的時候,含有一個虛函數表指針,所佔用的大小為4個位元組,那麼這裡輸出了8個位元組,就說明Derive對象含有兩個虛函數表指針。然後我們通過獲取到了這兩個虛函數表,並調用其對應的虛函數,可以發現輸出的結果和上面的示例圖是相同的,因此就證明了上述所說的結論是正確的。
簡單的總結一下:
1. 每一個基類都會有自己的虛函數表,派生類的虛函數表的數量根據繼承的基類的數量來定。 2. 派生類的虛函數表的順序,和繼承時的順序相同。 3. 派生類自己的虛函數放在第一個虛函數表的後面,順序也是和定義時順序相同。 4. 對於派生類如果要覆蓋父類中的虛函數,那麼會在虛函數表中代替其位置。
虛函數指針和虛函數表的創建時機:
對於虛函數表來說,在編譯的過程中編譯器就為含有虛函數的類創建了虛函數表,並且編譯器會在構造函數中插入一段代碼,這段代碼用來給虛函數指針賦值。因此虛函數表是在編譯的過程中創建。
對於虛函數指針來說,由於虛函數指針是基於對象的,所以對象在實例化的時候,虛函數指針就會創建,所以是在運行時創建。由於在實例化對象的時候會調用到構造函數,所以就會執行虛函數指針的賦值代碼,從而將虛函數表的地址賦值給虛函數指針。
虛函數表的深入探索:
經過上面的學習說明,我們知道了虛函數表的作用,是用來存放虛函數的地址的,那麼我們先來看一下這個代碼:
#include <iostream> using namespace std; class A{ public: int x; A(){ memset(this, 0, sizeof(x)); // 將this對象中的成員初始化為0 cout << "構造函數" << endl; } A(const A& a) { memcpy(this, &a, sizeof(A)); // 直接拷貝內存中的內容 cout << "拷貝構造函數" << endl; } virtual void virfunc() { cout << "虛函數func" << endl; } void func() { cout << "func函數" << endl; } virtual ~A() { cout << "析構函數" << endl; } }; int main() { A a; a.virfunc(); return 0; }
在構造函數中用的是memset()函數進行初始化操作,在拷貝構造函數中使用memcpy的方式來拷貝,可能這樣的方法效率會更高,其運行結果如下圖所示:

可以運行,但是我們要對代碼進行分析,前面我們提到了虛函數表是在編譯的時候就已經生成好了,那麼對於上面的代碼中的virfunc來說,它的地址就已經存在於虛函數表中了,又根據前面我們提到的,在實例化對象的時候,編譯器會為構造函數中插入一些代碼,這些代碼用來給虛函數指針進行賦值,那麼這些操作都是在我們執行memset之前進行的,因此在執行了這些操作後,調用了memset函數,使得所有內容都清空了,那麼虛函數指針就指向了0,那為什麼我們還可以調用virfunc函數和析構函數呢?
這裡就涉及到了靜態聯編和動態聯編的問題,我們先來明確一下靜態聯編和動態聯編的定義:
靜態聯編:在編譯時所進行的這種聯編又稱靜態束定,在編譯時就解決了程序中的操作調用與執行該操作代碼間的關係。 動態聯編:編譯程序在編譯階段並不能確切知道將要調用的函數,只有在程序運行時才能確定將要調用的函數,為此要確 切知道該調用的函數,要求聯編工作要在程序運行時進行,這種在程序運行時進行聯編工作被稱為動態聯編。
由於我們把虛函數表指針設為了0,所以我們就無法通過前面的方法來獲取它,這裡我們可以通過反彙編來查看virfunc的地址:

我們發現virfunc函數的地址並不是我們設置的0,那麼它就變的和普通的函數沒有什麼區別了(普通函數採用靜態聯編,在編譯時就綁定了函數的地址),這顯然不是我們想要的虛函數,那麼肯定就無法實現多態,對於類的多態性,一定是基於虛函數表的,那麼虛函數表的實現一定是動態聯編的,因此也不可缺少虛函數指針尋址的過程,那麼我們要實現動態聯編,就需要用到指針或者引用的形式。如果按上面代碼的方式去執行,由於是非指針非引用的形式,所以編譯器採用了靜態綁定,提前綁定了函數的地址,那麼就不存在指針尋址的過程了;如果使用指針或引用的形式,那麼由於對象的所屬類不能確定,那麼編譯器就無法採用靜態編聯,所以就只有通過動態編聯的方式,在運行時去綁定調用指針和被調用地址。那麼我們把代碼改成下面的樣子:
A *a = new A; a.virfunc();
這個時候我們再運行程序,由於我們將虛函數指針置為0,從而找不到了虛函數的位置,那麼程序就會崩潰,然後我們再通過反彙編查看一下,如圖所示:

比之前的多了一些彙編指令,其中mov就是尋址的過程,這樣就實現了動態綁定,也是根據這一點來實現多態性,這裡我只用了一個類來進行展示說明,其實用兩個類更好。總之,如果要實現虛函數和多態,就需要通過指針或者引用的方式。
關於虛函數表的東西就是這麼多,如果有錯誤或者遺漏或者有疑問的地方可以在評論區中指出,這篇博客主要是自己學習後的一個總結,對於講解部分,感覺圖片太少了,單純用文字描述又過於抽象,以後應該要加以改正。