C++ 面向对象(二)多态 : 虚函数、多态原理、抽象类、虚函数表、继承与虚函数表
目錄
- 多態
- 多態的概念
- 多態的構成條件
- 虛函數
- 虛函數的重寫
- 協變(返回值不同)
- 析構函數的重寫(函數名不同)
- final和override
- final
- override
- 重載, 重寫, 重定義對比
- 抽象類
- 多態的原理
- 虛函數表
- 虛函數表的存儲位置
- 動態綁定和靜態綁定
- 繼承與虛函數表
- 單繼承與虛函數表
- 多繼承與虛函數表
多態
多態的概念
什么是多態呢?就是一種事物,多種形態。就是對于同一個行為,不同的對象去完成就會產生不同的結果。
舉個生活中的例子,當你去旅游景點游玩時,不同的身份買票的價格也不一樣。比如對于普通人是原價購買,而對于學生和孩子則是半價購買,對于軍人則是優先購買。明明同樣是購買,不同身份帶來的不同結果,就是多態的作用。
在C++中,多態就是對于同一個函數,當調用的對象不同,他的操作也不同。就是指針和引用指向指向哪一個對象,就調用哪一個對象的虛函數
例如:
class Human { public:virtual void print(){cout << "i am a human" << endl;} };class Student : public Human { public:virtual void print(){cout << "i am a student" << endl;} };class Teacher : public Human { public:virtual void print(){cout << "i am a teacher" << endl;} };void ShowIdentity(Human& human) {human.print(); }int main() {Human h;Teacher t;Student s;ShowIdentity(h);ShowIdentity(t);ShowIdentity(s); }多態的構成條件
這里先給出條件,底下的原理解析那一塊會具體講原因
多態是繼承體系中的一個行為,如果要在繼承體系中構成多態,需要滿足兩個條件
1. 必須通過基類的指針或者引用調用虛函數
2. 被調用的函數必須是虛函數,并且派生類必須要對繼承的基類的虛函數進行重寫
虛函數
虛函數就是被virtual修飾的類成員函數(這里的virtual和虛繼承的virtual雖然是同一個關鍵字,但是作用不一樣)
如:
class Human { public:virtual void print(){cout << "i am a human" << endl;} };虛函數的重寫
當派生類中有一個和基類完全相同的虛函數(函數名,返回值,參數完全相同),則說明子類的虛函數重寫了基類的虛函數(只重寫了函數實現)
如:
class Human { public:virtual void print(){cout << "i am a human" << endl;} };class Student : public Human { public:virtual void print(){cout << "i am a student" << endl;} };void ShowIdentity(Human &human) {human.print(); }int main() {Human h;Student s;ShowIdentity(h); ShowIdentity(s); }
如果不滿足上面的條件,例如參數不同則會變成重定義。
注意:
#include <iostream>class Base{ public:virtual void Show(int n = 10)const{ //提供缺省參數值std::cout << "Base:" << n << std::endl;} };class Base1 : public Base{ public:virtual void Show(int n = 20)const{ //重新定義繼承而來的缺省參數值std::cout << "Base1:" << n << std::endl;} };int main(){Base* p1 = new Base1; p1->Show(); return 0; }此時輸出的是Base1:10, 這是出自Effective C++中的一個問題
如果子類重寫了缺省值,此時的子類的缺省值是無效的,使用的還是父類的缺省值
原因是因為多態是動態綁定,而缺省值是靜態綁定。對于P1,他的靜態類型也就是這個指針的類型是Base,所以這里的缺省值是Base的缺省值,而動態類型也就是指向的對象是Base1,所以這里調用的虛函數則是Base1中的虛函數,所以這里就是Base1中的虛函數,Base中的缺省值,也就是Base1:10。
或者可以更簡單的一句話描述,虛函數的重寫只重寫函數實現,不重寫缺省值。
這道題最近考試做錯了,就拿出來講了一下
但是也存在兩種例外的情況。
協變(返回值不同)
當基類和派生類的返回值類型不同時,如果基類對象返回基類對象的引用或者指針,派生類對象也返回的是派生類對象的引用或者指針時,就會引起協變。協變也能完成虛函數的重寫
例如:
class Human { public:virtual Human& print(){cout << "i am a human" << endl;return *this;} };class Student : public Human { public:virtual Student& print(){cout << "i am a student" << endl;return *this;} };如果返回值不是引用或者指針則不會構成協變
class Student : public Human { public:virtual Student print(){cout << "i am a student" << endl;return *this;} };析構函數的重寫(函數名不同)
析構函數雖然函數名不同,但是也能構成重寫,因為編譯器為了讓析構函數實現多態,會將它的名字處理成destructor,這樣就能也能構成重寫。
為什么編譯器要通過這種方式讓析構函數也能構成重寫呢?
假設存在這種情況,我用一個基類指針或者引用指向派生類對象,如果不構成多態會怎樣
可以看到,如果不構成多態,那么指針是什么類型的,就會調用什么類型的析構函數,這也就導致了一種情況,如果派生類的析構函數中有資源釋放,而這里卻沒有釋放掉那些資源,就會導致內存泄漏的問題。
所以為了防止這種情況,必須要將析構函數定義為虛函數。這也就是編譯器將析構函數重命名為destructor的原因
final和override
final和override是C++11中提供給用戶用來檢測是否進行重寫的兩個關鍵字。
final
使用final修飾的虛函數不能被重寫。
如果某一個虛函數不想被派生類重寫,就可以用final來修飾這個虛函數
override
override關鍵字是用來檢測派生類虛函數是否構成重寫的關鍵字。
在我們寫代碼的時候難免會出現些小錯誤,如基類虛函數沒有virtual或者派生類虛函數名拼錯等問題,這些問題不會被編譯器檢查出來,發生錯誤時也很難一下子鎖定,所以C++增添了override這一層保險,當修飾的虛函數不構成重寫時就會編譯錯誤。
重載, 重寫, 重定義對比
重載:
1.在同一作用域
2.函數名相同,參數的類型、順序、數量不同。
重寫(覆蓋):
1.作用域不同,一個在基類一個在派生類
2.函數名,參數,返回值必須相同(協變和析構函數除外)
3.基類和派生類必須都是虛函數(派生類可以不加virtual,基類的虛函數屬性可以繼承,但是最好要加上virtual)
重定義(隱藏):
1.作用域不同,一個在基類一個在派生類
2.函數名相同
3.派生類和基類同名函數如果不構成重寫那就是重定義
抽象類
如果在虛函數的后面加上 =0,并且不進行實現,這樣的虛函數就叫做純虛函數。而包含純虛函數的類,也叫做抽象類或者接口類。抽象類不能實例化出對象,因為他所具有的信息不足以描述一個對象,派生類繼承后也只有在重寫純虛函數后才能實例化出對象。
抽象類就像是一個藍圖,為派生類描述好一個大概的架構,派生類必須實現完這些架構,至于要在這些架構上面做些什么,增加什么,就屬于派生類自己的問題。
例如:
class Human { public:virtual void print() = 0; };class Student : public Human { public:virtual void print(){cout << "i am a student" << endl;} };class Teacher : public Human { public:virtual void print(){cout << "i am a teacher" << endl;} };普通函數的繼承是一種實現繼承,派生類繼承了基類函數,可以使用函數,繼承的是函數的實現。虛函數的
繼承是一種接口繼承,派生類繼承的是基類虛函數的接口,目的是為了重寫,達成多態,繼承的是接口。所以如果不實現多態,不要把函數定義成虛函數。
多態的原理
虛函數表
這里還是用這兩個類舉例子
class Human { public:virtual void print(){cout << "i am a human" << endl;}virtual void test1(){cout << "1test1" << endl;}void test2(){cout << "1test1" << endl;}int _age; };class Student : public Human { public:virtual void print() {cout << "i am a student" << endl;}void test2(){cout << "2test2" << endl;}int _stuNum; };還是和上次一樣,首先看看h的大小,按照正常情況,因為h中只有一個成員變量_age,大小應該是四個字節。
但是這里卻是8個。
打開監視窗口觀察
可以看到里面除了_age以外,還有個指針_vfptr,這個指針指向了一個函數指針數組,這個函數指針數組也就是虛函數表,其中的每一個成員指向的都是之前我們實現的虛函數,這個_vfptr也被稱為虛函數表指針。
而不是虛函數的test2則沒有被放入表中。
多態的實現也正是借助了這個虛函數表。
首先觀察這個虛函數表,我們可以看到,如果派生類實現了某個虛函數的重寫,那么在派生類的虛函數表中,重寫的虛函數就會覆蓋掉原有的函數,如Student::print。而沒有完成重寫的test1則依舊保留著從基類繼承下來的虛函數Human::test1。
為了進一步驗證基類和派生類虛函數表的關系,我將派生類所有的虛函數重寫去掉
結合上面的內容可以發現,派生類會繼承基類的虛函數表,如果派生類完成了重寫,則會將重寫的虛函數覆蓋掉原有的函數。所以指針或引用指向哪一個對象,就調用對象中虛函數表中對應位置的虛函數,來實現多態。
這也就是為什么需要派生類函數也為虛函數,并且必須要重寫才能實現的原因
繼續分析構成多態的另一個條件,為什么必須要指針或者引用才能構成多態。
int main() {Student s;Human h1 = s;Human* h2 = &s; }
這里可以看到,如果將派生類對象賦值給基類對象,會因為對象切割,導致他的內存布局整個被修改,完全轉換為基類對象的類型,虛函數表也與基類相同,所以不能實現多態。
而如果用基類指針或者引用指向派生類對象,雖然指向的是派生類對象,但是他們的內存布局是兼容的,他不會像賦值一樣改變派生類對象的內存結構,所以派生類對象的虛函數表得到了保留,所以他可以通過訪問派生類對象的虛函數表來實現多態。
總結一下派生類虛函數表的生成過程:
1.首先派生類會將基類的虛函數表拷貝過來
2.如果派生類完成了對虛函數的重寫,則用重寫后的虛函數覆蓋掉虛函數表中繼承下來的基類虛函數
3.如果派生類自己又新增了虛函數,則添加在虛函數表的最后面
常見問題解析:
內聯函數可以是虛函數嗎?
不可以,內聯函數沒有地址,無法放進虛函數表中。
靜態成員函數可以是虛函數嗎?
不可以,靜態成員函數沒有this指針,無法訪問虛函數表。
構造函數可以是虛函數嗎?
不可以,虛函數表指針也是對象的成員之一,是在構造函數初始化列表初始化時才生成的,不可能是虛函數
析構函數可以是虛函數嗎?
可以,上面有寫,最好把基類析構函數聲明為虛函數,防止使用基類指針或者引用指向派生類對象時,派生類的析構函數沒有調用,可能導致內存泄漏。
對象訪問虛函數快還是普通函數快?
如果不構成多態的話,虛函數和普通函數的訪問是一樣快的,但是如果構成多態,調用虛函數就得到虛函數表中查找,就會導致速度變慢,所以普通函數更快一些。
虛函數表的存儲位置
從上面的觀察可以看出來,虛函數存于虛函數表中,那么虛函數又存儲在哪里呢?
這里就來驗證一下
通過對比可以看到,虛函數表與常量,函數一樣存儲于代碼段中。
所以得出結論,虛函數表在編譯階段生成,存儲于代碼段。
動態綁定和靜態綁定
對象的靜態類型:對象在聲明時采用的類型。是在編譯期確定的。(比如下面的h1,Human也就是他原本的類型就是靜態類型,而他指向的對象的類型Student也就是動態類型)
對象的動態類型:目前所指對象的類型。是在運行期決定的。對象的動態類型可以更改,但是靜態類型無法更改
靜態綁定:綁定的是對象的靜態類型,某特性(比如函數)依賴于對象的靜態類型,發生在編譯期。
動態綁定:綁定的是對象的動態類型,某特性(比如函數)依賴于對象的動態類型,發生在運行期。
接著我們通過匯編代碼,來觀察多態是在哪個階段實現的, 就可以知道它是靜態還是動態。
int main() {Student s1;Human& h1 = s1;Human h2 = s1;h1.print();h2.print();return 0; }
可以看到h1的print是滿足多態的,這里調用的函數是在
這一階段中找到eax中存儲的虛函數指針,所以可以發現,滿足多態的調用是在運行的時候,到對象中的找到虛函數指針來完成的調用
而下面h2的print則不滿足多態,所以是直接在編譯時從符號表中找到函數的地址后調用。
所以可以得出的結論是,滿足多態的函數調用時在運行的時候調用的,也就是動態多態。而之前重載那一章節也曾經說過重載也是一種多態的表現,只不過重載是在編譯的時候完成的調用,所以也被靜態多態。
繼承與虛函數表
單繼承與虛函數表
class Human { public:virtual void print(){cout << "Human::print" << endl;}int _age; };class Student : public Human { public:virtual void print(){cout << "Student::print" << endl;}virtual void test1(){cout << "Student::test1" << endl;}int _stuNum; };
對于單繼承的虛函數表,他會直接繼承基類的虛函數表,如果完成了重寫,則會覆蓋掉原來的虛函數,如果有新的虛函數test1(),則會加在基類虛函數表的尾部。但是由于編譯器的問題所以這里并不會顯示出來。
所以可以通過代碼直接從內存中查看
typedef void(*vfPtr) ();void Print(vfPtr vfTable[]) {cout << " 虛表地址>" << vfTable << endl;for (int i = 0; vfTable[i] != nullptr; ++i){printf(" 第%d個虛函數地址 :0X%x,->", i, vfTable[i]);vfPtr f = vfTable[i];f();}cout << endl; }int main() {Human b;Student d;vfPtr* vfTable = (vfPtr*)(*(int*)& b);Print(vfTable);vfPtr* vfTable = (vfPtr*)(*(int*)& d);Print(vfTable);return 0; }
可以看到,如果派生類有新的虛函數,則會加在虛函數表的尾部。
多繼承與虛函數表
class Human { public:virtual void print(){cout << "i am a human" << endl;}virtual void test1(){cout << "1test1" << endl;}int _age; };class Student : public Human { public:virtual void print() {cout << "i am a student" << endl;}virtual void test1(){cout << "2test1" << endl;}int _stuNum; };class Test : public Human, public Student { public:virtual void print(){cout << "i am a Test" << endl;}virtual void test2(){cout << "2test2" << endl;} };對于多繼承來說,派生類會拷貝兩個基類的虛函數表
同樣的,編譯器無法顯示,所以繼續用代碼從內存中讀取。
同樣的,重寫的虛函數會覆蓋原有虛函數,而派生類未重寫的虛函數test2()則會放到第一個繼承基類部分的虛函數表中,也就是這里的Human的虛函數表中。
對于多態和對象模型這一部分的問題,我還有很多地方理解的不夠好,可以參考一些陳皓大佬的這幾篇博客來進一步學習。
C++ 虛函數表解析
C++ 對象的內存布局(上)
C++ 對象的內存布局(下)
總結
以上是生活随笔為你收集整理的C++ 面向对象(二)多态 : 虚函数、多态原理、抽象类、虚函数表、继承与虚函数表的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 计算机网络 | 网络基础 :网络协议,协
- 下一篇: C++ STL : 模拟实现STL中的关