深入剖析C++多態、VPTR指針、虛函數表
在講多態之前,我們先來說說關於多態的一個基石------類型兼容性原則。
一、背景知識
1.類型兼容性原則
類型兼容規則是指在需要基類對象的任何地方,都可以使用公有派生類的對象來替代。通過公有繼承,派生類得到了基類中除構造函數、析構函數之外的所有成員。這樣,公有派生類實際就具備了基類的所有功能,凡是基類能解決的問題,公有派生類都可以解決。類型兼容規則中所指的替代包括以下情況:
子類對象可以當作父類對象使用
子類對象可以直接賦值給父類對象
子類對象可以直接初始化父類對象
父類指針可以直接指向子類對象
父類引用可以直接引用子類對象
在替代之後,派生類對象就可以作為基類的對象使用,但是只能使用從基類繼承的成員。類型兼容規則是多態性的重要基礎之一。
#include <iostream> using namespace std; class Parent{ public: void printP(){ cout<<"Parent"<<endl; } Parent(const Parent& obj){ cout<<"拷貝構造函數"<<endl; } private: int a; }; class Son:public Parent { public:void printC(){ cout<<"Son"<<endl; } private: int b; }; void howtoPrint(Parent *base){ base->printP(); //只能執行父類的成員函數 } void howtoPrint2(Parent &base){ base.printP(); //只能執行父類的成員函數 } void main(int argc, char const *argv[]) { Parent p1; p1.printP(); Son s1; s1.printC(); s1.printP();//類型兼容性原則 //1-1.基類指針(引用)指向子類對象 Parent *p = NULL; p1 = &s1; p1->printP(); //2.指針做函數參數 howtoPrint(&p1);//完全沒問題 howtoPrint(&s1);//完全沒問題,兼容性原則 //3.引用做函數參數 howtoPrint2(p1); howtoPrint2(s1); //第二層含義:可以讓子類對象初始化父類對象 //子類就是一種特殊的父類 Parent p2 = c1;//調用拷貝構造函數 return 0; }
2.多態產生的背景探究
我們先來看一個實例,然後慢慢引出為什麽會有多態這一需求。
class Parent { public: Parent(int a){ this->a = a; } printP(){ cout<<"Parent"<<endll; } private: int a; }; class Son:public Parent{ public: Son(int b):Parent(10){ this->b = b; } printP(){ cout<<"Son"<<endll; } private: int b; }; void howtoPrint(Parent *base){ base->printP(); } void howtoPrint2(Parent &base){ base.printP(); }
上面定義了兩個類,並且子類與父類都有一個同名函數printP(),現在,我們來看看測試案例:
情形一:定義一個基類指針,讓該指針分別指向基類和子類對象,然後調用printP():
void main(int argc, char const *argv[]) { Parent *p = NULL; Parent p1(20); Son s1(30);// p = &p1;//指針執行基類 p1->printP();// p = &s1;//類型兼容性原則 p->printP(); }
對於第一個p1->printP(),我很快知道,執行的是父類的PrintP()函數;但是對於第二個,是執行子類還是父類?
結果,我測試發現,也是執行的父類的printP()函數。
情形二:定義基類別名和子類別名,然後使用別名來調用該函數:
void main(int argc, char const *argv[]) { Parent &base = p;//父類的別名 base.printP();//執行父類 Parent &base2 = s1;//別名 base2.printP();//執行父類 }
答案也是執行父類的printP()函數。
情形三:定義一個函數,也就是上面寫的howtoPrint(),函數參數為基類指針,然後定義一個指向基類指針,讓該指針分別指向基類對象和子類對象:
void main(int argc, char const *argv[]) { Parent *p = NULL; Parent p1(20); Son s1(30); p = &p1; howtoPrint(&p1); p = &s1; howtoPrint(&s1); }
答案都是執行父類的printP()函數。
情形四:定義一個函數,也就是上面寫的howtoPrint2(),函數參數為基類對象的引用,然後分別傳入基類對象引用和子類對象引用:
void main(int argc, char const *argv[]) { Parent *p = NULL; Parent p1(20); Son s1(30); howtoPrint2(p1); howtoPrint2(s1); }
答案依然是調用基類的printP()函數。
上面四種情形,不管我們怎麽改變調用方式,始終都是調用的基類的函數,那麽問題就來了,怎麽樣才能解決,傳入子類對象調用子類函數,傳入基類對象調用基類函數呢?
因此,C++給我們提供了多態這一個解決方案。下面,我們來看看,多態是如何解決我們的需求的。
2.多態
要理解多態,首先要搞明白,什麽是多態?
多態:就是根據實際的對象類型決定函數調用語句的具體調用目標。
總結一句話就是,同樣的調用語句有多種不同的表現形態。
在了解什麽什麽事多態以後,那麽問題就來了,C++編譯器是如何實現上述需求的呢?各位看官,接著往下看。
二、虛函數
1.定義:類成員函數前面添加virtual關鍵字以後,該函數被稱為虛函數。
2.聲明
class Parent { public: Parent(int a){ this->a = a; } virtual print(){ cout<<"Parent"<<endll; } private: int a; };
3.多態的實現
上面我們說道,如果解決C++中,根據傳入對象的不同來調用同樣的函數呢?
這就用到了C++提供的虛函數!關於虛函數實現機制,在下面我會慢慢剖析。
下面,我們來看看多態的效果:
class Parent { public: Parent(int a){ this->a = a; } virtual printP(){ cout<<"Parent"<<endll; } private: int a; }; class Son:public Parent{ public: Son(int b):Parent(10){ this->b = b; } printP(){ //子類的virtual寫可不寫,只需要父類寫就可以了 cout<<"Son"<<endll; } private: int b; };
上面的繼承關系很清晰,這裏要註意一點:基類與子類的同名函數,要想實現多態,基類同名函數必須聲明為virtual函數
在定義了虛函數後,我在來看看上面出現的問題解決掉了沒?我們寫出測試案例:
測試案例一:成功解決了問題
void main(int argc, char const *argv[]) { Parent *p = NULL; Parent p1(20); Son s1(30); p = &p1; p1->printP();//執行父類的打印函數 p = &s1; p->printP();//執行子類的打印函數 }
測試案例二:也成功解決問題
void main(int argc, char const *argv[]) { Parent p1(20); Son s1(30); Parent &base = p1;//父類的別名 base.printP();//執行父類 Parent &base2 = s1;//別名 base2.printP();//執行子類 }
測試案例三:
void howtoPrint(Parent *base){ //一個調用語句執行不同的函數 base->printP(); } void main(int argc, char const *argv[]) { Parent p1(20); Son s1(30); howtoPrint(&p1);//父類 howtoPrint(&s1);//子類 }
在測試案例三種,我們定義了一個函數howtoprint(),函數參數為基類指針,在主函數中,我們分別傳入了子類對象和基類對象,輸出結果顯示:當傳入基類對象,調用基類的printP()語句,當傳入子類對象的時候,調用的是子類的printP()函數,結果也正好是我們想要的。也就是說,在執行howtoPrint()函數的時候,發生了多態。
最後,我們在看看測試案例四:
void howtoPrint2(Parent &base){//一個調用語句執行不同的函數 base.printP(); } void main(int argc, char const *argv[]) { Parent *p = NULL; Parent p1(20); Son s1(30); howtoPrint2(p1);//父類 howtoPrint2(s1);//子類 }
正如我們想要的,一個調用語句執行不同的函數。
哦,最後我成功的得到了我們想要的結果,但我們應該至此為止麽?顯然不能,多態的強大之處,我們還沒真正的感受到,而且對於多態的實現機制,難道你就不想挖一挖嗎?為什麽會出現這種情況,難道你就沒有疑惑嗎?各位,別著急,繼續往下看!
三、從一個案例中體會多態的強大之處
場景:現在我們有三輛戰機,兩輛英雄戰機和一輛敵機,其關系圖如下:
功能:當我們傳入初級戰機對象和敵機對象進行戰鬥時,判斷輸贏;當我們傳入高級戰機和敵機戰鬥,判斷輸贏;
我們用兩種方式來做做看,看看多態的強大之處:
class HeroFighter{ public: virtual int power(){ return 10; } }; class AdvHeroFighter:public HeroFighter { public: int power(){ return 20; } }; class EnemyFighter { public: int attack(){ return 15; } };
上面定義了三個戰機類,我們來看看測試代碼:
非多態使用方式:
void main(int argc, char const *argv[]) { HeroFighter hf; AdvHeroFighter ahf; EnemyFighter ef; if (hf.power() > ef.attack()) { cout<<"英雄勝"<<endl; } else{ cout<<"英雄負"<<endl; } if (ahf.power() > ef.attack()) { cout<<"英雄勝"<<endl; } else{ cout<<"英雄負"<<endl; } }
上面這種方式,代碼雖然結構很清晰,但是代碼相似度很高,而且,一旦需求變化,又要新添許多邏輯判斷。下面,我們看看多態是如何實現的。
void play(HeroFighter *hf,EnemyFighter *ef){ if (hf->power() > ef->attack())//hf->power()調用會發生多態 cout<<"英雄勝"<<endl; else cout<<"英雄負"<<endl; } void main(int argc, char const *argv[]) { HeroFighter hf; AdvHeroFighter ahf; EnemyFighter ef; play(&hf,&ef); play(&ahf,&ef); }
有沒有發現,使用多態來實現,發現代碼簡潔多!我們在play()中定義了兩個參數:一個是英雄基類指針,一個是敵機指針。當我們傳入不同英雄戰機對象時,在hf->power()這裏會發生多態,根據對象的不同,調用不同類的同名函數。我們可以把play()函數看成一個框架,不管創建多少英雄戰機,我們都能使用hf->power()來調用屬於對象自己的函數。而且play()這個函數,我們並不需要做任何的改動!有沒有發現多態很強大,其實在設計模式中,基本上都是依靠多態來的,可以說多態是設計模式的基石,這一點都不為過。
有了上面的描述,我們很容易總結出多態實現的基礎:
1.要有繼承
2.要有虛函數重寫
3.父類指針(引用)指向子類對象
四、多態理論基礎
1.靜態聯編與動態聯編
聯編:是指一個程序模塊、代碼之間相互關聯的過程
靜態聯編(static binding):是程序的匹配、連接在編譯階段實現,重載函數使用靜態聯編
動態聯編:是指程序聯編推遲到運行時進行,又稱為遲聯編。switch和if語句是動態聯編的例子
關於靜態聯編和動態聯編,我們下面拿上面的例子進行說明一下:
class HeroFighter{ public: virtual int power(){ return 10; } }; class AdvHeroFighter:public HeroFighter { public: int power(){ return 20; } }; class EnemyFighter { public: int attack(){ return 15; } };三個戰鬥機類
void play(HeroFighter *hf,EnemyFighter *ef){ if (hf->power() > ef->attack())//hf->power()調用會發生多態 cout<<"英雄勝"<<endl; else cout<<"英雄負"<<endl; }
我們知道在if判斷裏面會發生多態,如果power函數沒有定義為virtual函數,那麽這裏稱為的是靜態聯編,也就是說,hf->power()這裏的調用關系,會在編譯階段根據函數參數會綁定到HeroFighter對象身上,這也就是為什麽,不管以何種方式傳入子類對象,都只能調用到基類的power()函數,因為這種調用關系,在編譯的時候就已經確定了!
如果power()函數被定義為虛函數,那麽就稱為動態聯編
即這種綁定關系並不會依靠函數參數來確定,而是在程序運行期間進行綁定。這也就是為什麽當我們傳入對象不同,會調用不同的函數。
2.重載、重寫、重定義
函數重載
-
- 函數重載必須在同一個類中進行
- 子類無法重載父類函數,父類同名函數將被名稱覆蓋
- 重載是在編譯器期間根據參數類型和個數決定函數調用(靜態聯編)
函數重寫
-
- 函數重寫必須發生在父類與子類之間
- 父類與子類的函數原型完全一樣
- 使用virtual聲明之後能夠產生多態(如果不寫virtual關鍵字,稱為重定義)
- 多態是在運行期間根據具體對象的類型來決定函數調用
--------------非虛函數重寫 --->重定義
重寫
--------------虛函數重寫----->重寫(會發生多態)
class Parent { public: //以下三個函數在同一個類中表現為重載 virtual void fun(){ cout<<"func1()"<<endl; } virtual void fun(int i){ cout<<"func2()"<<endl; } virtual void fun(int i,int j){ cout<<"func3()"<<endl; } int abc(){ cout<<"abc"<<endl; } virtual void fun(int i,int j,int k,int r){ cout<<"fun(i,j,k,r)" } };
class Parent { public: int abc(){ return 0; } }; class Son:public Parent { public: int abc(){//非虛函數重寫--->重定義 cout<<"abc"<<endl; return 1 } };
class Parent { public: virtual void fun(int i,int j){ cout<<"func3()"<<endl; } }; class Son:public Parent { public: virtual void fun(int i,int j){//與父類相同,這是虛函數重寫 cout<<"fun(int i,int j)"<<endl; } }
註意:以下問題
class Parent { public: virtual void fun(){ cout<<"func1()"<<endl;} virtual void fun(int i,int j,int k,int r){ cout<<"fun(i,j,k,r)"} }; class Son:public Parent { public: virtual void fun(int i,int j,int k){ cout<<"func(int i,int j,int k)"<<endl;} }; void main(int argc, char const *argv[]) { Son s; s.fun();//這會調用哪個? s.fun(1,2,3,5);//編譯會咋樣? return 0; }
分析:上面執行編譯都不能通過。我們慢慢分析,s.fun(),這裏會報錯,為什麽呢?原因很簡單,編譯器首先會在子類的名稱空間找,看自己有沒有叫fun()的函數,如果有名字沒fun()函數,就會執行自己的,至於參數個數問題,編譯器可不管。它只管有沒有叫這個名字,所以,在編譯的時候,父類同名函數將被名稱覆蓋。如果想調用父類的函數,我們只有通過域作用符來調用s.parent::fun();
同理s.fun(1,2,3,5);也不會執行父類的!
五、多態原理探究
1.VPTR指針與虛函數表
這裏我們主要來探究一下,編譯器在什麽地方動了手腳,從而支持了多態?
從一段代碼來分析:
下面代碼,我把C++編譯器可能動手腳地方標註出來,看看編譯器到底是在什麽時候就實現不同對象能調用同名函數綁定關系。
class Parent{ public: Parent(int a=0){ this->a = a;} virtual void print(){ //地方1 cout<<"parent"<<endl;} private: int a; }; class Son:public Parent{ public: Son(int a=0,int b=0):Parent(a){ this->b = b;} void print(){ cout<<"Son"<<endl;} private: int b; }; void play(Parent *p){ //地方2 p->print();} void main(int argc, char const *argv[]) { Parent p; //地方3 Son s; play(&s) return 0; }
在地方1處,我們知道,既然函數被聲明了virtual,編譯器會做特殊處理,會不會是這裏確定了綁定關系呢?
在地方2處,我們知道,當傳來子類對象, 執行子類函數, 傳來父類對象,執行父類對象,多態就是在這裏發生的,那會不會在這裏呢?
但是,恰恰我們最容易忽視就是在地方3處,真正確定綁定關系的地方,就是創建對象的時候!!這時候C++編譯器會偷偷的給對象添加一個vptr指針。
只要我們在類中定義了virtual函數,那麽我們在定義對象的時候,C++編譯器會在對象中存儲一個vptr指針,類中創建的虛函數的地址會存放在一個
虛函數表中,vptr指針就是指針這個表的首地址。
void play(Parent *p){ p->print(); }
在發生多態的地方,也就上面的,編譯器根本不會去區分,傳進來的是子類對象還是父類對象。而是關心print()是否為虛函數,如果是虛函數,就根據不同對象的vptr指針找屬於自己的函數。而且父類對象和子類對象都會有vptr指針,傳入對象不同,編譯器會根據vptr指針,到屬於自己虛函數表中找自己的函數。即:vptr--->虛函數表------>函數的入口地址,從而實現了遲綁定(在運行的時候,才會去判斷)。
如果不是虛函數,那麽這種綁定關系在編譯的時候就已經確定的,也就是靜態聯編!
這裏,關於虛函數表要說明兩點:
說明1:通過虛函數表指針VPTR調用重寫函數是在程序運行時進行的,因此需要通過尋址操作才能確定真正應該調用的函數。而普通成員函數是在編譯時就確定了調用的函數。在效率上,虛函數的效率要低很多。
說明2:出於效率考慮,沒有必要將所有成員函數都聲明為虛函數
說明3 :C++編譯器,執行play函數,不需要區分是子類對象還是父類對象
最後,我們來總結一下多態的實現原理:
- 當類中聲明虛函數時,編譯器會在類中生成一個虛函數表
- 虛函數表是一個存儲類成員函數指針的數據結構
- 虛函數表是由編譯器自動生成與維護的,virtual成員函數會被編譯器放入虛函數表中
2.如何證明VPTR指針的存在
class Parent1{ public: Parent1(int a=0){ this->a = a;} void print(){ cout<<"parent"<<endl;} private: int a;}; class Parent2{ public: Parent2(int a=0){ this->a = a;} virtual void print(){ cout<<"parent"<<endl;} private: int a;}; void main(int argc, char const *argv[]){ cout<<"Parent1"<<sizeof(Parent1)<<endl; //4 cout<<"Parent2"<<sizeof(Parent2)<<endl; //8 return 0; }
六、深入剖析VPTR指針
問題:構造函數中能調用虛函數,實現多態麽?
等價於:對象中的vptr指針什麽時候初始化?
我們看一段代碼:
class Parent{ public: Parent(int a=0){ this->a = a; print();} virtual void print(){cout<<"Parent"<<endl;} private: int a;
}; class Son:public Parent{ Son(int a=0,int b=0):Parent(a){ this->b = b; print();} virtual void print(){cout<<"Son"<<endl;} }; void main(int argc, char const *argv[]){ Son s; return 0; }
當我們定義對象的時候,會執行構造函數,但是,在構造函數裏面,我們調用了虛函數print(),那麽這裏的print()會執行哪個?會發生多態麽??
測試發現:兩個類中構造函數中,都只會調用自己類中的print()函數。
為什麽會這樣?為什麽沒有發生多態?
深入剖析C++多態、VPTR指針、虛函數表