C++中的多態(tài)問題—理解虛函數(shù)表及多態(tài)實現(xiàn)原理
注:編譯環(huán)境為VS 2022,指針大小為4字節(jié)
一、多態(tài)的概念
概念
多態(tài),指完成某個行為,不同的對象去完成時會產(chǎn)生出不同的狀態(tài)。如:定一個一Animal類,類中包含動物的叫聲這種方法,分別定義Dog和Cat類繼承自動物類,那么Dog和Cat類中也會包含叫聲這種方法,但是他們具體實現(xiàn)是不同的,因為每種動物的聲音都不相同,這便是一種多態(tài)。
多態(tài)的分類
- 靜態(tài)多態(tài),也稱為靜態(tài)綁定或者早綁定,是指函數(shù)在編譯期間就已經(jīng)確定了函數(shù)的行為。函數(shù)重載、函數(shù)模板等都屬于靜態(tài)多態(tài)。
- 動態(tài)多態(tài),即動態(tài)綁定或者晚綁定,指程序在運行時才可以確定函數(shù)的行為。本文主要分析的是動態(tài)多態(tài)。
構(gòu)成條件
- 在繼承體系下,父類中包含虛函數(shù)
- 子類中對父類的虛函數(shù)進行重寫
- 通過父類的指針或者引用調(diào)用虛函數(shù)
多態(tài)的體現(xiàn):不同的類對象調(diào)用同一函數(shù),會產(chǎn)生不同的行為。
二、虛函數(shù)的重寫
重寫的定義
虛函數(shù):virtual關(guān)鍵字修飾的函數(shù)
子類中有一個跟父類完全相同的虛函數(shù),即返回值類型、函數(shù)名、形參列表都完全相同,則可以說子類重寫了父類的虛函數(shù)。
Student類中重寫了BuyTicket方法:
注意:只要父類中函數(shù)用virtual修飾即可,子類可以不加,且虛函數(shù)的重寫與權(quán)限無關(guān)。
重寫的特殊情況
協(xié)變——返回值類型不同
父類的虛函數(shù)返回父類對象的指針或者引用,子類虛函數(shù)返回子類對象的指針或者引用。
析構(gòu)函數(shù)重寫——父類與子類析構(gòu)函數(shù)名字不同
如果父類的析構(gòu)函數(shù)為虛函數(shù),子類的析構(gòu)函數(shù)只要定義了,都能與父類的析構(gòu)函數(shù)構(gòu)成重寫??梢岳斫鉃榫幾g器對析構(gòu)函數(shù)的名字做了特殊處理,編譯后析構(gòu)函數(shù)的名字統(tǒng)一處理成destructor。
override和final關(guān)鍵字
這兩個關(guān)鍵字的主要作用都是幫助用戶檢測是否構(gòu)成重寫。
final
:修飾虛函數(shù),表示虛函數(shù)不可被重寫;另外final也可以修飾類,表示該類不能被繼承
override
:修飾虛函數(shù),檢查子類虛函數(shù)是否重寫了父類的虛函數(shù),如果沒有構(gòu)成重寫則會報錯
區(qū)分重寫、重載、重定義
抽象類的概念
在虛函數(shù)的后面寫上=0,則這個函數(shù)為純虛函數(shù)。包含純虛函數(shù)的類叫做抽象類(也稱接口類),抽象類不能實例化對象。抽象類被集成以后如果沒有對虛函數(shù)進行重寫,則繼承的類也是抽象類。
一般情況下,抽象類必須被繼承,且必須對虛函數(shù)進行重寫,否則定義為抽象類則沒有實際意義。
Shape類:
class Shape { public: // 純虛函數(shù) virtual double GetArea() = 0; virtual double GetCircumference() = 0; };
三、多態(tài)的實現(xiàn)原理
父類對象模型
給出一個Base類,一個Derived類繼承Base類
class Base { public: virtual void Func1() { cout << "Base::Fun1c()" << endl; } virtual void Func2() { cout << "Base::Func2()" << endl; } virtual void Func3() { cout << "Base::Func3()" << endl; } public: int _a; };
class Derived:public Base { public: int _b; };
父類對象模型:
總結(jié):
- 類中定義了虛函數(shù)以后,定義對象時,編譯器會為對象創(chuàng)建一張?zhí)摫?,并將一個指向這張?zhí)摫淼闹羔槺4嬖趯ο蟮那八膫€字節(jié),無論定義幾個虛函數(shù),對象都只比多四個字節(jié)大小。這個指針稱為函數(shù)虛表指針。
- 虛表地址是在構(gòu)造對象時進行填充的,構(gòu)造函數(shù)如果顯式實現(xiàn),編譯器會對用戶實現(xiàn)的函數(shù)進行修改,增加給對象前四個字節(jié)存放虛表地址的語句。
- 虛表本質(zhì)就是一個函數(shù)指針數(shù)組,按照聲明順序依次存放虛函數(shù)的地址
補充:生成默認構(gòu)造方法的場景
在學習類與對象時我們知道構(gòu)造函數(shù)是是類的默認成員函數(shù),如果用戶沒有顯式定義,編譯器會默認生成,但是實際上并不是在所有情況下編譯器都會生成默認的構(gòu)造函數(shù),編譯器只會在需要的時候生成構(gòu)造函數(shù)。
四種生成默認構(gòu)造方法的場景
B類中包含有A類的對象,B類沒有顯式定義構(gòu)造函數(shù),A類定義了無參或者全缺省的構(gòu)造方法,則編譯器會給B類生成默認的構(gòu)造方法。
分析:因為A類有無參或者全缺省的構(gòu)造方法,需要在B類中調(diào)用A類的構(gòu)造方法對A類成員進行初始化,所以需要生成B類的構(gòu)造方法,在其初始化列表中調(diào)用A類構(gòu)造方法。
如果A類沒有顯式定義構(gòu)造函數(shù),則不會生成B類構(gòu)造方法,默認賦隨機值;如果A類定義的構(gòu)造方法不是無參或者全缺省的,則需要在初始化列表中對A類對象初始化:
繼承中,B繼承A,A中定義了無參或者全缺省的構(gòu)造方法,B未顯式定義,則編譯器會給B類生成默認的構(gòu)造方法。將B中繼承自A的部分初始化。
虛擬繼承中,B類虛擬繼承子A類,B類未顯式定義構(gòu)造方法,編譯器會給B類生成默認的構(gòu)造方法,目的是:給B類對象的前4個字節(jié)填充虛基表地址
類中包含虛函數(shù),未顯示定義構(gòu)造方法,則編譯器會自動生成構(gòu)造方法,為對象的前4個字節(jié)填充虛表地址
子類對象模型
子類虛表構(gòu)建規(guī)則
1.將父類虛表內(nèi)容拷貝一份放到子類虛表中,注意父類和子類用的不是同一張?zhí)摫?,仍以上面的Base和Derived類為例
可以看出,兩個虛表指針的地址不同,但虛表中保存的虛函數(shù)的地址都相同。
2.如果子類中將父類的虛函數(shù)進行了重寫,則用子類的虛函數(shù)地址替換虛函數(shù)表中相同偏移量的虛函數(shù)的地址。
3.子類中增加的虛函數(shù)按照其在類當中的聲明次序放在虛表的最后
子類中增加了兩個虛函數(shù):
但是由于VS監(jiān)視窗口中無法顯式新增加的子類,而內(nèi)存窗口只能顯式虛函數(shù)的地址,無法確認是哪個函數(shù),所以這里通過打印的方式進行驗證。
通過上圖中程序的方式打印出了子類對象中虛函數(shù)的分布情況,在這里VFP是一個函數(shù)指針類型,前面加typedef表示為函數(shù)指針類型,如果不加,則是函數(shù)指針變量。
所以是用VFP*接收指向第一個虛函數(shù)指針的指針,p與*p的類型:
所以最終的結(jié)論是:子類新增的虛函數(shù)按照其在類中的聲明次序放在虛函數(shù)表的最后。
子類對象的構(gòu)造過程
構(gòu)造子類對象時,在初始化列表中先調(diào)用父類的構(gòu)造函數(shù),此時對象的前4個字節(jié)保存的虛表指針指向父類的虛表,之后構(gòu)造子類自己的虛表,虛表再指針指向子類的虛表。
總結(jié)
- 虛表的本質(zhì)是函數(shù)指針數(shù)組,在編譯時生成
- 虛函數(shù)的重寫也叫覆蓋,指的是虛表中虛函數(shù)的覆蓋,重寫是語法層的叫法,覆蓋是原理層的叫法
- 對象中保存的是虛表指針,虛表中保存的是虛函數(shù)指針,虛函數(shù)和普通函數(shù)一樣保存在代碼段,在VS中虛表也保存在代碼區(qū)
- 同一個類的對象共用同一張?zhí)摫?,父類和子類各自擁有各自的虛表?/li>
多態(tài)的調(diào)用原理
父類對象,函數(shù)調(diào)用時的匯編代碼:
普通函數(shù)調(diào)用時直接傳遞函數(shù)的地址,這個地址在編譯期間就確定了,虛函數(shù)則要經(jīng)過虛表指針尋址等步驟。從上面的匯編代碼也可以看出動態(tài)多態(tài)的晚綁定的特點,在編譯期間普通函數(shù)的調(diào)用已經(jīng)確定了要調(diào)用的具體函數(shù),虛函數(shù)則無法確定,只有等程序運行起來,形參b是具體哪個對象確定了以后,才能確定要調(diào)用的函數(shù)的地址。
上面是傳遞父類對象時的調(diào)用情況,子類對象調(diào)用時的匯編代碼與父類對象相同,區(qū)別就是子類對象有自己的虛表,最終調(diào)用的是子類需表中的函數(shù)。
總結(jié)多態(tài)的原理:
創(chuàng)建對象時,編譯器會給包含虛函數(shù)的類對象創(chuàng)建一張?zhí)摫?,并將虛表地址填充在對象的?個字節(jié),子類對象會拷貝父類對象的虛表,然后再對自己重寫的虛函數(shù)進行替換,并在虛表中添加子類新增的虛函數(shù);函數(shù)調(diào)用時,編譯器會先從對象的前4個字節(jié)獲取該對象虛表的地址,然后在虛表中獲取虛函數(shù)地址進行函數(shù)調(diào)用;由于每個類對象都有屬于該類的一張?zhí)摫?,且虛函?shù)一般都進行了重寫,即函數(shù)名與父類相同,但函數(shù)執(zhí)行的內(nèi)容不同,最終產(chǎn)生的結(jié)果就是,不同類的對象調(diào)用同一函數(shù)產(chǎn)生不同的結(jié)果,由此形成了多態(tài)。
多繼承的虛函數(shù)表
給出兩個父類Base1和Base2,Derived子類繼承自兩個父類
通多監(jiān)視窗口查看子類對象的模型:
多態(tài)中多繼承的子類對象模型與多繼承的模型原理相同,但是VS的監(jiān)視窗口無法查看子類新增的虛函數(shù)在需表中的位置,按照之前但繼承中打印虛表中函數(shù)的原理進行打?。?/p>
最終得到的結(jié)果:
可以看出,子類中增加的虛函數(shù)保存在上面的虛表中。
多繼承子類對象模型及對象虛表:
四、繼承與多態(tài)中的常見問題
1.析構(gòu)函數(shù)可以設(shè)置為虛函數(shù)嗎?
可以,在繼承體系中,最好將父類的析構(gòu)函數(shù)設(shè)置為虛函數(shù);如果子類中涉及到資源管理,則必須將父類的析構(gòu)函數(shù)設(shè)置為虛函數(shù),這樣父類和子類中的析構(gòu)函數(shù)便會構(gòu)成重寫(重寫的特殊情況),形成多態(tài),通過父類指針指向子類對象時,delete父類對象的指針也會調(diào)用子類的析構(gòu)函數(shù)。
子類中涉及資源管理,調(diào)用父類析構(gòu)函數(shù)析構(gòu)子類對象,則會有內(nèi)存泄漏,如圖:
2.構(gòu)造函數(shù)可以設(shè)置為虛函數(shù)嗎?
不能,虛函數(shù)是放在虛表中的,虛表指針是在構(gòu)造方法的初始化列表中進行填充的,通過虛表指針才能找到虛函數(shù),但是不調(diào)用構(gòu)造方法就沒有虛表指針,二者矛盾。即如果構(gòu)造方法是虛函數(shù),那么調(diào)用構(gòu)造方法就要通過虛表指針,但是虛表指針是要通過調(diào)用構(gòu)造方法才能填充的??。拷貝構(gòu)造與構(gòu)造函數(shù)原理相同。
3.賦值運算符重載函數(shù)可以設(shè)置為虛函數(shù)嗎?
可以,但是沒有意義,因為賦值運算符重載函數(shù)參數(shù)和返回值都是本類類型對象的引用,設(shè)置程序函數(shù)無法進行重寫,無法構(gòu)成多態(tài)。
4.靜態(tài)函數(shù)可以設(shè)置為虛函數(shù)嗎 ?
虛函數(shù)必須在創(chuàng)建對象后,通過對象的前4個字節(jié)的虛表指針調(diào)用。而靜態(tài)成員函數(shù)可以通過 類名::成員函數(shù) 的方式進行調(diào)用,不用通過象,這樣就無法找到虛表,也無法訪問虛函數(shù)。
5.內(nèi)聯(lián)函數(shù)可以是虛函數(shù)嗎?
可以設(shè)置,但是沒有意義,因為虛函數(shù)關(guān)鍵字virtual和inline是矛盾的,inline屬性會被忽略,函數(shù)不會展開,而是放到虛函數(shù)表中。
6.友元函數(shù)可以是虛函數(shù)嗎?
不可以,因為virtual只能修飾類的成員函數(shù)。
7.為什么多態(tài)必須通過指針或者引用實現(xiàn)?
因為當我們用一個父類指針或者引用指向子類對象時,會發(fā)生內(nèi)存切割,用子類中屬于父類的部分給父類賦值:
Animal& animal1 = dog; Animal* animal2 = new Dog();
而下面的語句則不會產(chǎn)生內(nèi)存切割:
Ainmal animal3 = dog;
為什么會這樣呢?
“一個pointer或一個reference之所以支持多態(tài),是因為它們并不引發(fā)內(nèi)存任何“與類型有關(guān)的內(nèi)存委托操作; 會受到改變的。只有它們所指向內(nèi)存的大小和解釋方式 而已”。 ——《深度探索C++對象模型》
總結(jié)
以上為個人經(jīng)驗,希望能給大家一個參考,也希望大家多多支持腳本之家。
相關(guān)文章
詳解C語言中strcpy()函數(shù)與strncpy()函數(shù)的使用
這篇文章主要介紹了詳解C語言中strcpy()函數(shù)與strncpy()函數(shù)的使用,是C語言入門學習中的基礎(chǔ)知識,需要的朋友可以參考下2015-08-08深入探討:main函數(shù)執(zhí)行完畢后,是否可能會再執(zhí)行一段代碼?
本篇文章是對main函數(shù)執(zhí)行完畢后,是否可能會再執(zhí)行一段代碼,進行了詳細的分析介紹,需要的朋友參考下2013-05-05C++ OpenCV實戰(zhàn)之文檔照片轉(zhuǎn)換成掃描文件
這篇文章主要為大家介紹一個C++?OpenCV的實戰(zhàn)——文檔照片轉(zhuǎn)換成掃描文件,文中的示例代碼講解詳細,感興趣的小伙伴可以跟隨小編一起學習一下2022-09-09VSCode配置C++環(huán)境的方法步驟(MSVC)
這篇文章主要介紹了VSCode配置C++環(huán)境的方法步驟,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2020-05-05C語言順序表的基本結(jié)構(gòu)與實現(xiàn)思路詳解
順序表是用一段物理地址連續(xù)的存儲單元依次存儲數(shù)據(jù)元素的線性結(jié)構(gòu),一般情況下采用數(shù)組存儲。本文將通過示例為大家講解一下順序表的基本操作,需要的可以參考一下2023-02-02C++ STL priority_queue自定義排序?qū)崿F(xiàn)方法詳解
這篇文章主要介紹了C++ STL priority_queue自定義排序?qū)崿F(xiàn)方法詳解,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2021-03-03