C++ 虚函数和虚表
幾篇寫的不錯的文章,本文是整合了這幾篇文章,感謝這些大佬
https://www.jianshu.com/p/00dc0d939119
https://www.cnblogs.com/hushpa/p/5707475.html
https://www.jianshu.com/p/91227e99dfd7
多態:
多態是面相對象語言一個重要的特性,多態即讓同一個用戶自定義類型的對象在不同的決策時機呈現不同的行為實現。
C++中的多態就分為
- 編譯時多態:就包括類成員函數重寫和operator函數重載
- 運行時多態:C++編譯器在運行時,根據決策邏輯判斷傳入所對象的類型,然后查找并根據該類虛表中的虛成員函數的地址,以進行動態調度目標類中的成員函數。
接下來就說下運行時多態的核心,虛函數和其背后的虛表。
虛函數
用virtual關鍵字修飾的函數就叫虛函數。
因為vTable(虛表)是C++利用runtime來實現多態的工具,所以我們需要借助virtual關鍵字將函數代碼地址存入vTable來躲開靜態編譯期。這里我們先不深入探究,后面我會細說。
首先我們先來看一個沒有虛函數,即沒有用到vTable的例子:
#include <iostream> #include <ctime> using std::cout; using std::endl;struct Animal { void makeSound() { cout << "動物叫了" << endl; } };struct Cow : public Animal { void makeSound() { cout << "牛叫了" << endl; } }; struct Pig : public Animal { void makeSound() { cout << "豬叫了" << endl; } }; struct Donkey : public Animal { void makeSound() { cout << "驢叫了" << endl; } };int main(int argc, const char * argv[]) {srand((unsigned)time(0));int count = 4;while (count --) {Animal *animal = nullptr;switch (rand() % 3) {case 0:animal = new Cow;break;case 1:animal = new Pig;break;case 2:animal = new Donkey;break;}animal->makeSound();delete animal;}return 0; }程序中有一個基類Animal,它有一個makeSound()函數。有三個繼承自Animal的子類,分別是牛、豬、驢,并且實現了自己的makeSound()方法。很簡單的代碼,是吧。
我們運行程序,你覺得輸出結果會是什么呢?不錯,這里會連續執行4次Animal的makeSound()方法,結果如下:
為什么?因為我們的基類Animal的makeSound()方法沒有使用Virtual修飾,所以在靜態編譯時就makeSound()的實現就定死了。調用makeSound()方法時,編譯器發現這是Animal指針,就會直接jump到makeSound()的代碼段地址進行調用。
ok,那么我們把Animal的makeSound()改為虛函數,如下:
struct Animal { virtual void makeSound() { cout << "動物叫了" << endl; } };運行會是怎樣?如你所料,多態已經成功實現:
?
接下來就是大家最關心的部分,這是怎么回事?編譯器到底做了什么?
虛表
為了說明方便,我們需要修改一下基類Animal的代碼,不改變其他子類,修改如下:
struct Animal {virtual void makeSound() { cout << "動物叫了" << endl; }virtual void walk() {}void sleep() {} };struct Cow : public Animal { void makeSound() { cout << "牛叫了" << endl; } }; struct Pig : public Animal { void makeSound() { cout << "豬叫了" << endl; } }; struct Donkey : public Animal { void makeSound() { cout << "驢叫了" << endl; } };首先我們需要知道幾個關鍵點:
我們怎么理解?從本例來看,我們的Animal、Cow、Pig、Donkey類都有自己的虛表,并且虛表里都有兩個地址指針指向makeSound()和walk()的函數地址。一個指針4個字節,因此每個vTable的大小都是8個字節。如圖:
?
他們的虛表中記錄著不同函數的地址值。可以看到Cow、Pig、Donkey重寫了makeSound()函數但是沒有重寫walk()函數。因此在調用makeSound()時,就會直接jump到自己實現的code Address。而調用walk()時,則會jump到Animal父類walk的Code Address。
虛指針
現在我們已經知道虛表的數據結構了,那么我們在堆里實例化類對象時是怎么樣調用到相應的函數的呢?這就要借助到虛指針了(vPointer)。
虛指針是類實例對象指向虛表的指針,存在于對象頭部,大小為4個字節,比如我們的Donkey類的實例化對象數據結構就如下:
?
我們修改main函數里的代碼,如下:
int main(int argc, const char * argv[]) {int count = 2;while (count --) {Animal *animal = new Donkey;animal->makeSound();delete animal;}return 0; }我們在堆中生成了兩個Donkey實例,運行結果如下:
驢叫了 驢叫了 Program ended with exit code: 0?
沒問題。然后我們再來看看堆里的結構,就變成了這樣:
?進一步探究虛表的內存布局
#include <iostream> class Employee{ public:bool iService=true;virtual ~Employee(){};virtual void add_salary(){std::cout<<"add_salary method in Employee"<<std::endl;} };class Teamer:public Employee{ public:int idNo=1000;virtual ~Teamer(){}void add_salary(){std::cout<<"add_salary method in Teamer"<<std::endl;}virtual void info(){std::cout<<"Teamer info for Teamer"<<std::endl;}void show(){std::cout<<"show method in Teamer"<<std::endl;} }; int main(void){Employee *tm1=new Teamer();Employee *tm2=new Teamer();Employee *pp1=new Employee();Employee *pp2=new Employee();delete tm1,tm2,pp1,pp2; }在這里我們可以嘗試打印*tm1,*tm2,*pp1和 *pp2,如下圖所示
從上圖的輸出中,我們要引入一個虛指針(_vptr)的概念
- 虛類的對象初始化時會自動創建一個隱藏的數據成員_vptr指針指向虛表,此前聲明該虛類的對象編譯器也創建了該虛類的虛表。
- 后續同一個虛類所有對象實例共享同一個虛表,截圖中的tm1和tm2的隱藏指針指向同一個地址0x400cf0,pp1和pp2的虛表是同理如是.
- 虛表表當前的地址是一個已經+16字節偏移后的內存地址
另外我們還打印出所有Teamer對象和Employee對象,他們獲得內存分配都為16個字節。因此我們不妨在查看我們剛才實例化的所有對象。
查看對象的內存數據
現在我們不妨看看剛才實例化的各個對象的內存布局,使用x命令,因為每個對象的堆內存塊尺寸都為16個字節,因此我們使用x/16xb將他們的內存數據轉存到屏幕中,如下圖所示。
- _vptr在虛類的對象中就占用8個字節,該_vptr存儲了指向該虛類的虛表的內存地址值。
- iService是一個bool類型僅占用1個字節,另外高位的3個字節空間由于內存對齊的原因都以0填充。
-
idNo是一個4字節的int類型,對于Teamer的對象0x03e8的值就是十進制的1000,對于Employee的對象這里的4個字節由于按8字節內存對齊,僅作為填充位之用。
?
備注:這里我們回顧了內存對齊的相關知識。
探究虛表的內存布局
我們從前文打印的第一個Teamer對象 tm1的信息中,可以知道其_vptr指針指向0x400cf0,你是否發現“<虛表 for Teamer+16>”的字樣。這個其實表明0x400cf0是已經+16字節偏移后的地址值
我們已經在前文提到在首個的新的虛類對象且初始化時,編譯器會該類動態創建一個虛表,但為什么每個不同虛類的虛表都要額外偏移16個字節呢? 在本示例中,我們不妨減去這個偏移量,也即得到0x400ce0這個地址,然后使用x命令,該命令將300字節的內存數據轉儲到屏幕。
(gdb) x/300xb 0x400ce0上面的命令以十六進制格式打印300字節,從0x400d00開始。 為什么要這個地址? 因為在上面我們看到類Teamer的虛表指針指向0x400d10,該地址已經偏移0x10個字節,即減去0x10就能得到原本虛表的地址。
下圖中_ZTV是虛表的前綴,_ZTS是type-string(名稱)的前綴,_ZTI是type-info的前綴。
我們從下圖可以得到很多虛表的內存細節。
- 每個Teamer虛表存在一個虛表表頭占用16個字節,前8個字節0填充,后8個字節包含一個指向與該類對應的typeinfo表的地址(沒必要理會,只需知道他們占用16個字節即可)。
- 每個typeinfo表的前面也包含一個typeinfo name的信息(沒必要理會,l羅列出來只是讓你知道有這么一個描述字段)
-
綠色的部分就是不同虛類的虛表,虛表就是包含了該類定義的所有virtual成員函數的函數地址。
?
我們可以從上圖中綠色部分的內存數據中即每行冒號之后的8字節空間提取有用的數據,例如
- 0x400cf0到0x400d08的內存區域中的內存數據,對應的是Teamer類類虛表中virtual成員函數地址的條目。
- 0x400d30到0x400d40的內存區域中的內存數據,對應的是Employee類虛表中virtual成員函數地址的條目
我們這兩個內存區域的數據分別整理成如下表,注意寫本文時使用的是CentOS 7的x64小端機器,因此讀取圖中的內存數據時,是從右向左讀取,因此整理下表每個內存位置對應的值,并且分別是有info symbol命令 再次查看每個內存位置的值對應的具體含義。
結合整理如下表可知:虛表中的地址值分別代表虛擬類中對應虛函數的地址。
虛表內存布局
?
更簡單獲取虛類的虛表條目的另外一條命令就是info vtbl,這里就不展示了,我們看到上圖的虛表中的虛解構函數都成對地出現,我們先暫不討論為什么會這樣,因為我日后會令起一文再闡述該問題。
- 第一個解構函數,稱為完整對象解構函數(complete object destructor),執行銷毀操作時無需在對象上調用delete()。
- 第二個解構函數稱為刪除析構函數( deleting destructor),在銷毀對象后調用delete()。
- 兩者都摧毀了任何虛擬基類.一個獨立的非虛函數稱為基類對象解構函數(base object destructor)執行對象的銷毀操作,但不執行其虛擬基類子對象的銷毀操作,并且不調用delete()。
- 非虛函數是靜態綁定的(編譯時綁定),因此在虛表中不存在任何非虛函數。
虛表構建細節
我們仍然使用上文的調用示例代碼
int main(void){//Employee *tm1=new Teamer();Employee *tm2=new Teamer();Employee *pp1=new Employee();Employee *pp2=new Employee();delete tm1,tm2,pp1,pp2; }從上面的示例代碼中我們已經知道
?
- 首先,每個使用虛函數的類或從基類派生的虛函數的類都被賦予自己的虛表。該表只是C++編譯器在“編譯時”設置的靜態數組。虛表包含當前類中所有虛成員函數的函數指針的相關條目,那么填入虛表的虛成員函數指針有四種來源。
-
派生類本身原創定義的虛函數,例如上圖的Teamer::info()函數。
-
從父類繼承的虛成員函數,且該函數未被派生類重寫。
-
從父類繼承的虛成員函數,但該函數已被派生類重寫。值的注意的是,虛表的虛成員函數指針始終指向該類中的最新的派生版本的虛成員函數。理解這句話非常重要!舉個例子Teamer類從Employee類繼承了add_salary()函數,但Teamer類重寫(注意:不是重載)了該add_salary()函數,對于Teamer虛表來說,填入表中的add_salary()函數的地址是0x400b3e,而不是父類的add_salary()的地址0x400ab4。
-
若當前類定義了虛解構函數,那么該類的虛解構函數的解構函數的地址會“成雙成對”地填入虛表中。按照慣例,由于定義類時優先定義解構函數,再實現其他成員函數,因此該虛解構函數對的地址通常會出現在表中頭兩行,上圖是很好的例證。
- 然后,當類對象實例化時會將*_vptr設置為指向該類的虛表。例如,當創建類型為Teamer的對象時*_vptr設置為指向Teamer的虛表。構造類型為Employee對象時,*_vptr設置為指向的Employee的虛表。我們這里先不討論virtual解構函數,目前只針對其他虛函數進行討論。
- 對于基類Employee類型的對象,它只能訪問Employee的成員,Employee類型的對象無法訪問Teamer類的的成員函數,因為地址為0x400ab4的地址僅指向Employee::salary()
- 同理,Teamer類型的對象也只能訪問Teamer::add_salary()和Teamer::info()。
總結:
用一張圖說明一切
?
總結
以上是生活随笔為你收集整理的C++ 虚函数和虚表的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: POS机一清机是什么意思?一清机和二清机
- 下一篇: 信用卡账单分期影响提额吗?这几个提额技巧