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就是尋址的過程,這樣就實現了動態綁定,也是根據這一點來實現多態性,這裡我只用了一個類來進行展示說明,其實用兩個類更好。總之,如果要實現虛函數和多態,就需要通過指針或者引用的方式

       關於虛函數表的東西就是這麼多,如果有錯誤或者遺漏或者有疑問的地方可以在評論區中指出,這篇博客主要是自己學習後的一個總結,對於講解部分,感覺圖片太少了,單純用文字描述又過於抽象,以後應該要加以改正。