C++多型實現機制剖析
技術標籤:C++
面向物件的三大概念:封裝,繼承,多型。
封裝突破了c語言函式的概念;繼承實現了程式碼的複用,那麼多型實現了什麼價值呢,簡單理解就是前人寫的程式碼(框架)可以呼叫後人寫的程式碼。
1 什麼是多型?
多型性可以簡單的概括為“1個介面,多種方法”,在程式執行的過程中才決定呼叫的機制
程式實現上是這樣:通過父類指標呼叫子類的函式,可以讓父類指標有多種形態。
2. 理解多型成立的三個條件
對比思考間接賦值成立的三個條件:1. 定義兩個變數,2.建立變數之間的聯絡,3.*p通過地址間接賦值。
多型的單個條件:1.要有繼承。2. 要有虛擬函式重寫。3.要有父類指標(引用)指向子類物件。
我們先從一個例子來探究
#include <iostream.h> class animal { public: void sleep() { cout<<"animal sleep"<<endl; } void breathe() { cout<<"animal breathe"<<endl; } }; class fish:public animal { public: void breathe() { cout<<"fish bubble"<<endl; } }; void main() { fish fh; animal *pAn=&fh; pAn->breathe(); }
答案是輸出:animal breathe
結果分析:
- 1從編譯的角度
C++編譯器在編譯的時候,要確定每個物件呼叫的函式的地址,這稱為早期繫結(early binding),當我們將fish類的物件fh的地址賦給pAn時,C++編譯器進行了型別轉換,此時C++編譯器認為變數pAn儲存的就是animal物件的地址。當在main()函式中執行pAn->breathe()時,呼叫的當然就是animal物件的breathe函式。 - 2 記憶體模型的角度
我們構造fish類的物件時,首先要呼叫animal類的建構函式去構造animal類的物件,然後才呼叫fish類的建構函式完成自身部分的構造,從而拼接出一個完整的fish物件。當我們將fish類的物件轉換為animal型別時,該物件就被認為是原物件整個記憶體模型的上半部分,也就是圖1-1中的“animal的物件所佔記憶體”。那麼當我們利用型別轉換後的物件指標去呼叫它的方法時,當然也就是呼叫它所在的記憶體中的方法。因此,輸出animal breathe,也就順理成章了。
那麼為了得到我們想要的結果,就要使用虛擬函式
前面輸出的結果是因為編譯器在編譯的時候,就已經確定了物件呼叫的函式的地址,要解決這個問題就要使用遲繫結(late binding)技術。當編譯器使用遲繫結時,就會在執行時再去確定物件的型別以及正確的呼叫函式。而要讓編譯器採用遲繫結,就要在基類中宣告函式時使用virtual關鍵字(注意,這是必須的,很多學員就是因為沒有使用虛擬函式而寫出很多錯誤的例子),這樣的函式我們稱為虛擬函式。一旦某個函式在基類中宣告為virtual,那麼在所有的派生類中該函式都是virtual,而不需要再顯式地宣告為virtual。
下面我們將上面一段程式碼進行部分修改
virtual void breathe()
{
cout<<"animal breathe"<<endl;
}
執行結果:fish bubble
結果分析
編譯器為每個類的物件都有一個指向虛擬函式表的指標(vptr指標)。在程式執行時,根據物件的型別去初始化vptr,從而讓vptr正確的指向所屬類的虛表,從而在呼叫虛擬函式時,就能夠找到正確的函式。
例中,由於pAn實際指向的物件型別是fish,因此vptr指向的fish類的vtable(虛擬函式表),當呼叫pAn->breathe()時,根據虛表中的函式地址找到的就是fish類的breathe()函式。
正是由於每個物件呼叫的虛擬函式都是通過虛表指標來索引的,也就決定了虛表指標的正確初始化是非常重要的。換句話說,在虛表指標沒有正確初始化之前,我們不能夠去呼叫虛擬函式。那麼虛表指標在什麼時候,或者說在什麼地方初始化呢?
答案是在建構函式中進行虛表的建立和虛表指標的初始化。還記得建構函式的呼叫順序嗎,在構造子類物件時,要先呼叫父類的建構函式,此時編譯器只“看到了”父類,並不知道後面是否後還有繼承者,它初始化父類物件的虛表指標,該虛表指標指向父類的虛表。當執行子類的建構函式時,子類物件的虛表指標被初始化,指向自身的虛表。
當fish類的fh物件構造完畢後,其內部的虛表指標也就被初始化為指向fish類的虛表。在型別轉換後,呼叫pAn->breathe(),由於pAn實際指向的是fish類的物件,該物件內部的虛表指標指向的是fish類的虛表,因此最終呼叫的是fish類的breathe()函式。
為了更加清楚的說明記憶體分佈:下面詳細的介紹記憶體的分佈
- 1 基類的記憶體分佈情況
請看下面的sample
class A
{
void g(){.....}
};
則sizeof(A)=1;
如果改為如下:
class A
{
public:
virtual void f()
{
......
}
void g(){.....}
}
則sizeof(A)=4! 這是因為在類A中存在virtual function,為了實現多型,每個含有virtual function的類中都隱式包含著一個靜態虛指標vfptr指向該類的靜態虛表vtable, vtable中的表項指向類中的每個virtual function的入口地址
例如 我們declare 一個A型別的object :
A c;
A d;
則編譯後其記憶體分佈如下:
轉存失敗
重新上傳
取消
從 vfptr所指向的vtable可以看出,每個virtual function都佔有一個entry,例如本例中的f函式。而g函式因為不是virtual型別,故不在vtable的表項之內。說明:vtab屬於類成員靜態pointer,而vfptr屬於物件pointer
- 2 繼承類的記憶體分佈狀況
假設程式碼如下:
public B:public A
{ public :
int f() //override virtual function
{
return 3;
}
};
則
A c;
A d;
B e;
編譯後,其記憶體分佈如下:
轉存失敗
重新上傳
取消
從中我們可以看出,B型別的物件e有一個vfptr指向vtable address:0x00400030 ,而A型別的物件c和d共同指向類的vtable address:0x00400050a
- 3 動態繫結過程的實現
我們說多型是在程式進行動態繫結得以實現的,而不是編譯時就確定物件的呼叫方法的靜態繫結。
其過程如下:
程式執行到動態繫結時,通過基類的指標所指向的物件型別,通過vfptr找到其所指向的vtable,然後呼叫其相應的方法,即可實現多型。
例如:
A c;
B e;
A *pc=&e; //設定breakpoint,執行到此處
pc=&c;
此時記憶體中各指標狀況如下:
轉存失敗
重新上傳
取消
可以看出,此時pc指向類B的虛表地址,從而呼叫物件e的方法。
繼續執行,當執行至pc=&c時候,此時pc的vptr值為0x00420050,即指向類A的vtable地址,從而呼叫c的方法。
這就是動態繫結!(dynamic binding)或者叫做遲後聯編(lazy compile)。
總結:
對於虛擬函式呼叫來說,每一個物件內部都有一個虛表指標,該虛表指標被初始化為本類的虛表。所以在程式中,不管你的物件型別如何轉換,但該物件內部的虛表指標是固定的,所以呢,才能實現動態的物件函式呼叫,這就是C++多型性實現的原理。
需要注意的幾點
總結(基類有虛擬函式):
1、每一個類都有虛表。
2、虛表可以繼承,如果子類沒有重寫虛擬函式,那麼子類虛表中仍然會有該函式的地址,只不過這個地址指向的是基類的虛擬函式實現。如果基類3個虛擬函式,那麼基類的虛表中就有三項(虛擬函式地址),派生類也會有虛表,至少有三項,如果重寫了相應的虛擬函式,那麼虛表中的地址就會改變,指向自身的虛擬函式實現。如果派生類有自己的虛擬函式,那麼虛表中就會新增該項。
3、派生類的虛表中虛擬函式地址的排列順序和基類的虛表中虛擬函式地址排列順序相同。