一、何為多型
多型(polymorphism)指為不同資料型別的實體提供統一的介面,或使用單一的符號來表示多個不同的型別。比如我們熟悉的函式過載、模板技術,都屬於多型。無論是模板還是函式過載,都是靜態繫結的。也就是說,究竟該呼叫哪個過載函式或者說呼叫哪個模板類的例項化,在編譯期就是確認的。虛擬函式也是多型的一種,它是執行時的多型。
下面的程式碼演示了透過虛擬函式實現的多型:
1 #include<iostream> 2 using namespace std; 3 class Base 4 { 5 public: 6 virtual void f() 7 { 8 cout<<"Base::f()"<<endl; 9 } 10 }; 11 class Derived: public Base 12 { 13 public: 14 void f() 15 { 16 cout<<"Derived::f()"<<endl; 17 } 18 }; 19 int main() { 20 Derived x; 21 Base* p = &x; 22 p->f(); //輸出Derived::f()。 23 Base &b = x; 24 b.f(); //輸出Derived::f()。 25 Base b1 = x; 26 b1.f();//輸出Base::f(),值語義,不能表現出多型 27 return 0; 28 }
執行結果:
[root@VM-16-4-opencloudos vtable]# ./main Derived::f() Derived::f() Base::f()
用法是:基類的指標或引用,用不同的子類賦值時,就表現不同的行為。而值語義是不能表現出多型的。
實現的機制是因編譯器而異,但基本上使用虛擬函式表來實現的,這個後面再介紹。
這會我想談的是:為什麼說虛擬函式是執行時的多型,基類的指標指向的型別需要在執行期間才能確定?
其實單看上面的程式碼,也不需要在執行時才知道p->f()呼叫的是Devied中的函式呀,程式碼裡已經明確了Base的指標p就是指向子類Derived的,我直接看程式碼都知道了,難道編譯器是傻的嗎,還要等到執行期時才去透過虛擬函式表找到p->f()實際呼叫的是Derived中的函式?其實不是的,對於編譯期能確定呼叫目標的虛擬函式,最終生成的程式碼並不會傻乎乎的去查虛表,編譯器會執行一些最佳化,進行靜態繫結。具體的討論可以參考下面這個回答:虛擬函式一定是執行期才繫結麼?
那為什麼都說是執行期繫結的?
其實上面這份程式碼看不出來,可以看下面這份:
1 #include<iostream> 2 using namespace std; 3 class Base 4 { 5 public: 6 virtual void f() 7 { 8 cout<<"Base::f()"<<endl; 9 } 10 }; 11 class Derived: public Base 12 { 13 public: 14 void f() 15 { 16 cout<<"Derived::f()"<<endl; 17 } 18 }; 19 int main() { 20 char k = getchar(); 21 Base *p = NULL; 22 if(k == 'a') { 23 p = new Base(); 24 } else { 25 p = new Derived(); 26 } 27 p->f(); //輸出Base::f() or Derived::f() ? 28 return 0; 29 }
在編譯期間無法分析出這個指標究竟指向什麼型別的物件,只有等到程式執行時,使用者從鍵盤輸入字元之後才能確定。此時只能是透過執行期動態繫結了。
之所以要在執行期動態繫結來實現的原因就是執行期外部 IO。參考知乎的回答:為什麼C++實現多型必須要虛擬函式表?
搞清楚了為什麼需要執行期動態繫結了,那下面就來說說怎麼實現的吧。
二、虛擬函式表
我們來看以下的程式碼。類 A 包含虛擬函式vfunc1
,vfunc2
,由於類 A 包含虛擬函式,故類 A 擁有一個虛表。
1 class A { 2 public: 3 virtual void vfunc1(); 4 virtual void vfunc2(); 5 void func1(); 6 void func2(); 7 private: 8 int m_data1, m_data2; 9 };
類 A 的虛表如圖 1 所示。
圖 1:物件它的虛表示意圖
虛表是一個指標陣列,其元素是虛擬函式的指標,每個元素對應一個虛擬函式的函式指標。需要指出的是,普通的函式即非虛擬函式,其呼叫並不需要經過虛表,所以虛表的元素並不包括普通函式的函式指標。
虛表內的條目,即虛擬函式指標的賦值發生在編譯器的編譯階段,也就是說在程式碼的編譯階段,虛表就可以構造出來了。
虛表是屬於類的,而不是屬於某個具體的物件,一個類只需要一個虛表即可。同一個類的所有物件都使用同一個虛表。
為了指定物件的虛表,物件內部包含一個虛表的指標,來指向自己所使用的虛表。為了讓每個包含虛表的類的物件都擁有一個虛表指標,編譯器在類中新增了一個指標,*__vptr
,用來指向虛表。這樣,當類的物件在建立時便擁有了這個指標,且這個指標的值會自動被設定為指向類的虛表。
為什麼弄懂虛表的記憶體佈局,我找了很多資料,認為這兩個知乎的回答我能理解清楚:
一個回答是:單繼承下的虛擬函式表的記憶體佈局;
另一個回答是我不太明白的地方:物件在其起始地址處存放了虛表指標(vptr),vptr指向虛表,虛表中儲存了實際的函式地址。原來我以為虛表中儲存的只有虛擬函式的地址,但其實不是的,vptr指向的並不是虛表的表頭,而是直接指向虛擬函式的位置。實際上虛表中虛擬函式的位置之前,還有兩個槽位,每個槽位佔8個位元組,這篇回答說明瞭這兩個槽位的作用:多繼承下的虛擬函式表的記憶體佈局。
另外之前我有個疑惑:派生類中會有幾個vptr呢?比如B繼承A,然後C繼承了B,C類中會有幾個vptr呢?是有3個嗎?(A、B、C各一個),還是隻有1個?
答案是:在單鏈繼承中,每一個派生型別都包含了其基型別的資料以及虛擬函式,這些虛擬函式可以按照繼承順序,依次排列在同一張虛表之中,因此只需要一個虛指標即可。並且由於每一個派生類都包含它的直接基類,且沒有第二個直接基類,因此其資料在記憶體中也是線性排布的,這意味著實際型別與它所有的基型別都有著相同的起始地址。
而對於多繼承而言,假設型別C同時繼承了兩個獨立的基類A和B,比如:
1 struct A 2 { 3 int ax; 4 virtual void f0() {} 5 }; 6 7 struct B 8 { 9 int bx; 10 virtual void f1() {} 11 }; 12 13 struct C : public A, public B 14 { 15 int cx; 16 void f0() override {} 17 void f1() override {} 18 };
與單鏈繼承不同,由於A
和B
完全獨立,它們的虛擬函式沒有順序關係,即f0
和f1
有著相同對虛表起始位置的偏移量,不可以順序排布。 並且A
和B
中的成員變數也是無關的,因此基類間也不具有包含關係。這使得A
和B
在C
中必須要處於兩個不相交的區域中,同時需要有兩個虛指標分別對它們虛擬函式進行索引。 其記憶體佈局如下所示:
C Vtable (7 entities) +--------------------+ struct C | offset_to_top (0) | object +--------------------+ 0 - struct A (primary base) | RTTI for C | 0 - vptr_A -----------------------------> +--------------------+ 8 - int ax | C::f0() | 16 - struct B +--------------------+ 16 - vptr_B ----------------------+ | C::f1() | 24 - int bx | +--------------------+ 28 - int cx | | offset_to_top (-16)| sizeof(C): 32 align: 8 | +--------------------+ | | RTTI for C | +------> +--------------------+ | Thunk C::f1() | +--------------------+
在上面的記憶體佈局中,C將A作為主基類,也就是將它虛擬函式“併入”A的虛表之中,並將A的vptr作為C的記憶體起始地址。
上面的記憶體佈局中,offset_to_top(-16)用於確保如果將類C例項的地址賦給類B的指標p,呼叫p->f0()時,能找到對應的虛擬函式,其內部會將指標偏移offset_to_top個位元組,找到類C的虛表指標(vptr_A),然後找到對應的虛擬函式來呼叫。至於為什麼是16個位元組,是因為vptr本身佔8個位元組,另外還有int ax; 雖然int是4位元組的,但因為記憶體對齊,所以總共佔16個位元組。
假設型別C
同時繼承了兩個獨立的基類A
和B
C++的編譯器保證虛擬函式表的指標存在於物件例項中最前面的位置, 這意味著我們透過物件例項的地址得到這張虛擬函式表,然後就可以遍歷其中函式指標,並呼叫相應的函式。
下面的程式用於驗證包含虛擬函式的類物件記憶體的首地址是vptr,並演示如何透過vptr找到虛擬函式表並訪問虛擬函式:
#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; } }; typedef void(*Fun)(void); int main(int argc, char *argv[]) { Base b; Fun pFun = NULL; cout << "虛擬函式表地址:" << (int*)(&b) << endl; //vptr cout << "虛擬函式表 — 函式指標陣列的首地址:" << (int*)*(int*)(&b) << endl; // Invoke the first virtual function pFun = (Fun)*((int*)*(int*)(&b)+0); // Base::f() pFun(); return 0; }
執行結果:
[root@VM-16-4-opencloudos vtable]# ./main 虛擬函式表地址:0x7ffdb2560880 虛擬函式表 — 第一個函式地址:0x400b78 Base::f
&b取到了虛擬函式表的地址(vptr),*(int*)(&b)是對虛擬函式表地址的解引用,得到的是虛擬函式表。虛擬函式表中存放了一個函式指標陣列,即陣列中的每個元素都是一個函式指標,指向每一個虛擬函式。直接訪問(int*)*(int*)(&b)得到的是函式指標陣列的首地址。對其進行解引用,即*(int*)*(int*)(&b),則可以得到函式指標陣列的首地址指向的元素,該元素是一個函式指標,因為虛擬函式按照其宣告順序放於虛擬函式表中,所以*(int*)*(int*)(&b)對應的是Base::f()函式的地址。