朋友們、伙計們,我們又見面了,本期來給大家解讀一下有關多態的知識點,如果看完之后對你有一定的啟發,那么請留下你的三連,祝大家心想事成!
C 語 言 專 欄:C語言:從入門到精通
數據結構專欄:數據結構
個? 人? 主? 頁?:stackY、
C + + 專 欄? ?:C++
Linux 專?欄? :Linux
目錄
1. 多態的概念
1.1 概念
2. 多態的定義及實現
2.1 多態的構成條件
2.2 虛函數
2.3 虛函數的重寫
2.3.1 虛函數重寫的兩個例外?
2.4?C++11 override 和 final
2.5 重載、重寫(覆蓋)、重定義(隱藏)的對比
3. 抽象類?
3.1 概念
3.2 接口繼承和實現繼承
4. 多態的原理
4.1 虛函數表?
4.2 多態的原理
4.3 動態綁定與靜態綁定
5. 單繼承和多繼承關系的虛函數表
5.1 單繼承中的虛表
5.2 多繼承中的虛函數表?
6. 總結
1. 多態的概念
1.1 概念
多態的概念:通俗來說,就是多種形態,具體點就是去完成某個行為,當不同的對象去完成時會產生出不同的狀態。
?
舉個栗子:比如買票這個行為,當普通人買票時,是全價買票;學生買票時,是半價買票;軍人買票時是優先買票。
2. 多態的定義及實現
2.1 多態的構成條件
多態是在不同繼承關系的類對象,去調用同一函數,產生了不同的行為。比如Student繼承了
Person。Person對象買票全價,Student對象買票半價。
那么在繼承中要構成多態還有兩個條件:
- 1. 必須通過基類的指針或者引用調用虛函數
- 2. 被調用的函數必須是虛函數,且派生類必須對基類的虛函數進行重寫
class Person { public:virtual void BuyTicket(){cout << "票價->全價" << endl;} }; class Student : public Person { public:virtual void BuyTicket(){cout << "票價->半價" << endl;} }; void Func(Person& p) {p.BuyTicket(); }int main() {Person p;Func(p);Student s;Func(s);return 0; }
?
?
2.2 虛函數
虛函數:即被virtual修飾的類成員函數稱為虛函數。
class Person { public:virtual void BuyTicket(){cout << "票價->全價" << endl;} };
2.3 虛函數的重寫
虛函數的重寫(覆蓋):派生類中有一個跟基類完全相同的虛函數(即派生類虛函數與基類虛函數的返回值類型、函數名字、參數列表完全相同),稱子類的虛函數重寫了基類的虛函數。
class Person { public:virtual void BuyTicket(){cout << "票價->全價" << endl;} }; class Student : public Person { public://void BuyTicket()virtual void BuyTicket(){cout << "票價->半價" << endl;} };
注意:
在重寫基類虛函數時,派生類的虛函數在不加virtual關鍵字時,雖然也可以構成重寫(因為繼承后基類的虛函數被繼承下來了在派生類依舊保持虛函數屬性),但是該種寫法不是很規范,不建議這樣使用。
2.3.1 虛函數重寫的兩個例外?
1. 協變(基類與派生類虛函數返回值類型不同)
派生類重寫基類虛函數時,與基類虛函數返回值類型不同。即基類虛函數返回基類對象的指
針或者引用,派生類虛函數返回派生類對象的指針或者引用時,稱為協變。
?class A {}; class B :public A {};class Person { public:virtual A* BuyTicket(){cout << "票價->全價" << endl;return nullptr;} }; class Student : public Person { public://void BuyTicket()virtual B* BuyTicket(){cout << "票價->半價" << endl;return nullptr;} };
2. 析構函數的重寫(基類與派生類析構函數的名字不同)
如果基類的析構函數為虛函數,此時派生類析構函數只要定義,無論是否加virtual關鍵字,
都與基類的析構函數構成重寫,雖然基類與派生類析構函數名字不同。雖然函數名不相同,看起來違背了重寫的規則,其實不然,這里可以理解為編譯器對析構函數的名稱做了特殊處理,編譯后析構函數的名稱統一處理成estructor。
class Person { public:virtual ~Person(){ cout << "~Person()" << endl; } }; class Student : public Person { public:virtual ~Student() { cout << "~Student()" << endl; }//~Student(){ cout << "~Student()" << endl; } };int main() {Person* p = new Person;Student* s = new Student;delete p;delete s;return 0; }
2.4?C++11 override 和 final
從上面可以看出,C++對函數重寫的要求比較嚴格,但是有些情況下由于疏忽,可能會導致函數名字母次序寫反而無法構成重寫,而這種錯誤在編譯期間是不會報出的,只有在程序運行時才能發現端倪,因此:C++11提供了override和final兩個關鍵字,可以幫助用戶檢測是否重寫。
1. final:修飾虛函數,表示該虛函數不能再被重寫
class Car { public:virtual void Drive() final {} }; class Benz :public Car { public:virtual void Drive() { cout << "Benz-舒適" << endl; } };
?
?
拓展:
final修飾類,則該類不能被繼承
class A final { public:int _a; }; class B final :public A { public:int _b; }; class C : public B { public:int _c; };
?
?
2. override: 檢查派生類虛函數是否重寫了基類某個虛函數,如果沒有重寫編譯報錯。
class Car { public:virtual void Drive() {} }; class Benz :public Car { public:virtual void Drive(int i) override { cout << "Benz-舒適" << endl; } };
?
?
2.5 重載、重寫(覆蓋)、重定義(隱藏)的對比
3. 抽象類?
3.1 概念
在虛函數的后面寫上 =0 ,則這個函數為純虛函數。包含純虛函數的類叫做抽象類(也叫接口類),抽象類不能實例化出對象。派生類繼承后也不能實例化出對象,只有重寫純虛函數,派生類才能實例化出對象。純虛函數規范了派生類必須重寫,另外純虛函數更體現出了接口繼承。
class Car //抽象類 { public:virtual void Drive() = 0; //純虛函數 }; class Benz :public Car { public:virtual void Drive(){cout << "Benz-舒適" << endl;} }; class BMW :public Car { public:virtual void Drive(){cout << "BMW-操控" << endl;} };void TestCar() {Car* pBenz = new Benz;pBenz->Drive();Car* pBMW = new BMW;pBMW->Drive(); }
3.2 接口繼承和實現繼承
普通函數的繼承是一種實現繼承,派生類繼承了基類函數,可以使用函數,繼承的是函數的實現。虛函數的繼承是一種接口繼承,派生類繼承的是基類虛函數的接口,目的是為了重寫,達成多態,繼承的是接口。所以如果不實現多態,不要把函數定義成虛函數。![]()
4. 多態的原理
首先先來看一下這道題:
// 這里常考一道筆試題:sizeof(Base)是多少? class Base { public:virtual void Func1(){cout << "Func1()" << endl;} private:int _b = 1; };
根據之前的判斷,成員函數在公共代碼區,所以這個類的大小只有4字節,如果我們打印出來看一下的話并不是4字節,而是8字節,那這是為什么呢?我們可以調試來觀察一波:
通過調試可以觀察到在b里面還存在一個__vfptr,這個玩意叫做虛函數表,到底有什么用呢?這就是我們接下來的話題:
4.1 虛函數表?
通過上面的觀察可以發現在Base對象中的這個指針我們叫做虛函數表指針(v代表virtual,f代表function)。一個含有虛函數的類中都至少都有一個虛函數表指針,因為虛函數的地址要被放到虛函數表中,虛函數表也簡稱虛表。那么派生類中這個表放了些什么呢?我們接著往下分析
我們可以將上面的代碼進行改造一下:
// 1.我們增加一個派生類Derive去繼承Base // 2.Derive中重寫Func1 // 3.Base再增加一個虛函數Func2和一個普通函數Func3 class Base { public:virtual void Func1(){cout << "Base::Func1()" << endl;}virtual void Func2(){cout << "Base::Func2()" << endl;}void Func3(){cout << "Base::Func3()" << endl;} private:int _b = 1; }; class Derive : public Base { public:virtual void Func1(){cout << "Derive::Func1()" << endl;} private:int _d = 2; };
通過觀察可以發現:
- 1. 派生類對象d中也有一個虛表指針,d對象由兩部分構成,一部分是父類繼承下來的成員,虛表指針也就是存在部分的另一部分是自己的成員。
- 2. 基類b對象和派生類d對象虛表是不一樣的,這里我們發現Func1完成了重寫,所以d的虛表中存的是重寫的Derive::Func1,所以虛函數的重寫也叫作覆蓋,覆蓋就是指虛表中虛函數的覆蓋。重寫是語法的叫法,覆蓋是原理層的叫法。
- 3. 另外Func2繼承下來后是虛函數,所以放進了虛表,Func3也繼承下來了,但是不是虛函數,所以不會放進虛表。
- 4. 虛函數表本質是一個存虛函數指針的指針數組,一般情況這個數組最后面放了一個nullptr。
- 5. 總結一下派生類的虛表生成:a.先將基類中的虛表內容拷貝一份到派生類虛表中 b.如果派生類重寫了基類中某個虛函數,用派生類自己的虛函數覆蓋虛表中基類的虛函數 c.派生類自己新增加的虛函數按其在派生類中的聲明次序增加到派生類虛表的最后。
- 6. 這里還有存在一個很容易混淆的問題:虛函數存在哪的?虛表存在哪的? 注意虛表存的是虛函數指針,不是虛函數,虛函數和普通函數一樣的,都是存在代碼段的,只是他的指針又存到了虛表中。另外對象中存的不是虛表,存的是虛表指針。那么虛表存在哪的呢?實際我們去驗證一下會發現vs下是存在代碼段的。
4.2 多態的原理
在前面的代碼中我們關于買票做了區分,在那里Func函數傳Person調用Person::BuyTicket(全價),傳Student調用的是Student::BuyTicket(半價)
- 1. 觀察上圖的紅色箭頭我們看到,p是指向基類對象時,p->BuyTicket在基類的虛表中找到虛函數是Person::BuyTicket。
- 2. 觀察上圖的紫色箭頭我們看到,p是指向派生類對象時,p->BuyTicket在派生類的虛表中找到虛函數是Student::BuyTicket。
- 3. 這樣就實現出了不同對象去完成同一行為時,展現出不同的形態。
- 4. 反過來思考我們要達到多態,有兩個條件,一個是虛函數覆蓋,一個是對象的指針或引用調用虛函數。
- 5. 再通過下面的匯編代碼分析,看出滿足多態以后的函數調用,不是在編譯時確定的,是運行起來以后到對象的中取找的。不滿足多態的函數調用時編譯時確認好的。
4.3 動態綁定與靜態綁定
1. 靜態綁定又稱為前期綁定(早綁定),在程序編譯期間確定了程序的行為,也稱為靜態多態,比如:函數重載。
2. 動態綁定又稱后期綁定(晚綁定),是在程序運行期間,根據具體拿到的類型確定程序的具體行為,調用具體的函數,也稱為動態多態。
3. 本小節之前(4.2小節)買票的匯編代碼很好的解釋了什么是靜態(編譯器)綁定和動態(運行時)綁定
5. 單繼承和多繼承關系的虛函數表
5.1 單繼承中的虛表
//單繼承 class Base { public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; } private:int a; }; class Derive :public Base { public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; }virtual void func4() { cout << "Derive::func4" << endl; } private:int b; };
通過觀察監視窗口中我們發現看不見func3和func4。這里是編譯器的監視窗口故意隱藏了這兩個函數,也可以認為是他的一個小bug。那么我們如何查看d的虛表呢?下面我們使用代碼打印出虛表中的函數。
typedef void(*VFPTR) (); void PrintVTable(VFPTR vTable[]) {// 依次取虛表中的虛函數指針打印并調用。調用就可以看出存的是哪個函數cout << " 虛表地址>" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf(" 第%d個虛函數地址 :0X%x,->", i, vTable[i]);VFPTR f = vTable[i];f();}cout << endl; } int main() {Base b;Derive d;//1.先取b的地址,強轉成一個int*的指針//2.再解引用取值,就取到了b對象頭4bytes的值,這個值就是指向虛表的指針//3.再強轉成VFPTR*,因為虛表就是一個存VFPTR類型(虛函數指針類型)的數組。//4.虛表指針傳遞給PrintVTable進行打印虛表VFPTR * vTableb = (VFPTR*)(*(int*)&b);PrintVTable(vTableb);VFPTR* vTabled = (VFPTR*)(*(int*)&d);PrintVTable(vTabled);return 0; }
思路:取出b、d對象的頭4bytes,就是虛表的指針,前面我們說了虛函數表本質是一個存虛函數指針的指針數組,這個數組最后面放了一個nullptr。
- 1.先取b的地址,強轉成一個int*的指針
- 2.再解引用取值,就取到了b對象頭4bytes的值,這個值就是指向虛表的指針
- 3.再強轉成VFPTR*,因為虛表就是一個存VFPTR類型(虛函數指針類型)的數組。
- 4.虛表指針傳遞給PrintVTable進行打印虛表
- 5.需要說明的是這個打印虛表的代碼經常會崩潰,因為編譯器有時對虛表的處理不干凈,虛表最后面沒有放nullptr,導致越界,這是編譯器的問題。我們只需要點目錄欄的-生成-清理解決方案,再編譯就好了
5.2 多繼承中的虛函數表?
//多繼承 class Base1 { public:virtual void func1() { cout << "Base1::func1" << endl; }virtual void func2() { cout << "Base1::func2" << endl; } private:int b1; }; class Base2 { public:virtual void func1() { cout << "Base2::func1" << endl; }virtual void func2() { cout << "Base2::func2" << endl; } private:int b2; }; class Derive : public Base1, public Base2 { public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; } private:int d1; };typedef void(*VFPTR) (); void PrintVTable(VFPTR vTable[]) {cout << " 虛表地址>" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf(" 第%d個虛函數地址 :0X%x,->", i, vTable[i]);VFPTR f = vTable[i];f();}cout << endl; } int main() {Derive d;VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);PrintVTable(vTableb1);VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));PrintVTable(vTableb2);return 0; }
觀察上圖可以看出:多繼承派生類的未重寫的虛函數放在第一個繼承基類部分的虛函數表中
6. 總結
1. 什么是多態?
多態分為靜態多態和動態多態,靜態多態在編譯時就已經確定好了,動態多態在運行時才會確定,常見靜態多態是函數重載,動態多態則是通過父類的指針或者引用調用虛函數,對基類虛函數的重寫,指向誰,就調用誰的虛函數,以此來實現多種形態。
2.?什么是重載、重寫(覆蓋)、重定義(隱藏)?
重載:同一作用域內,函數名相同參數不同(參數類型/數量/順序)。
重寫:子類和父類的虛函數,名稱、返回值(協變例外)、參數都相同,叫做子類重寫了父類的虛函數。
重定義:子類和父類的函數名相同,稱子類隱藏了父類的某個函數。?
3.? 多態的原理
父類和子類之中保存的虛表指針是不一樣的,通過傳入指針或者引用確定去子類還是父類中去尋找虛表指針,最后達到調用不同虛函數的目的。
4.?inline函數可以是虛函數嗎?
可以,在VS之下,如果構成多態,編譯器會放棄inline的屬性,這個函數就不是內聯函數了,因為內聯函數會被展開,是沒有地址的。而虛函數會將其地址放入至虛表之中。
普通調用:inline起作用;多態調用:inline不起作用。
5. 靜態成員可以是虛函數嗎?
不可以,編譯報錯,因為靜態成員函數沒有this指針,使用類型::成員函數的調用方式無法訪問虛函數表,但是它可以指定類域調用,所以靜態成員函數無法放進虛函數表。
6.?構造函數可以是虛函數嗎?
不可以,編譯報錯,對象中虛表指針是構造函數初始化列表階段才初始化的。
虛函數多態調用,要到虛表中找,但是虛表指針還未初始化。
7. 析構函數可以是虛函數嗎?
最好將析構函數定義成虛函數,否則會出現指向子類對象的父類指針調用父類析構函數而不會調用子類虛構函數的情況,出現內存泄漏。
8.?對象訪問普通函數快還是虛函數更快?
首先如果是普通對象,是一樣快的。如果是指針對象或者是引用對象,則調用的普通函數快,因為構成多態,運行時調用虛函數需要到虛函數表中去查找。
9.?虛函數表是在什么階段生成的,存在哪的?
虛函數表是在編譯階段就生成的,一般情況下存在代碼段(常量區)的。
10.?C++菱形繼承的問題?虛繼承的原理?
菱形繼承因為子類對象當中會有兩份父類的成員,因此會導致數據冗余和二義性的問題。
通過虛基表中的偏移量來找到對應的成員,從而解決了數據冗余和二義性的問題。
11.?什么是抽象類?抽象類的作用?
抽象類不能實例化出對象,抽象類強制重寫了虛函數
另外抽象類體現出了接口繼承關系。
?
朋友們、伙計們,美好的時光總是短暫的,我們本期的的分享就到此結束,欲知后事如何,請聽下回分解~,最后看完別忘了留下你們彌足珍貴的三連喔,感謝大家的支持!????