🔥 本文專欄:c++
🌸作者主頁:努力努力再努力wz
💪 今日博客勵志語錄:
你以為自己在孤獨地爬坡嗎?看看身后吧——那些被汗水浸濕的腳印,早已連成一道向上的階梯
★★★ 本文前置知識:
繼承
引入
從這篇文章開始,我們就正式進入面向對象的三大特性之一的最后一個特性,那么便是多態,面向對象的核心思想就是模擬我們現實生活中的各種場景,而多態的含義字面意思是多種形態,所以首先我們來看一下現實生活中符合多態的場景:以買火車票為例,買火車票的對象可以是成年人也可以是孕婦也可以是學生或者軍人,同樣是買票這個動作,但是卻可以根據對象的不同,得到不同的結果,比如成年人買票就是全價,學生買票就是半價,而軍人則是優先買票
又或者看演唱會,那么演唱會的門票分vip票和普通票,那么vip票相比于普通票的待遇就是可以在更靠近舞臺的內場觀看,甚至可以提前半個小時或者一個小時進場,而普通票只能到時間進入,所以同樣都是演唱會門票的持有者,但是當他們共同通過安檢的時候,那么只能持有vip票的觀眾被允許先進入,而普通票的觀眾只能在外面等待,所以同樣是安檢檢票這個動作,但是會根據對象的不同會得到不同的結果,那么只有持有vip票對象才能進入而持有普通票對象不能進入,那么以上兩個例子都是符合這里多態性的現實生活中的場景
那么通過剛才的這兩個例子,那么想必讀者就能夠理解所謂的多態:所謂的多態就是同一個動作,交給不同對象來完成,會得到不同的結果
所以這里c++要實現上面剛才的場景,那么意味著就得實現多態,所以接下來,我將會帶你實現多態,并且了解和掌握多態的原理以及相關細節
多態
語法
那么在具體講解多態的原理之前,那么我首先先來關注一下語法,也就是如何在語法層面實現多態,那么這里多態的觸發需要涉及到的條件比較多:第一你得至少得準備兩個類,并且這兩個類之間的關系是繼承關系,其次這兩個類中得定義三同的成員函數,所謂的三同必須是函數名和參數列表以及返回值相同,只要兩個類中定義的成員函數不是三同,那么多態就無法實現,第三就是父子類中這三同的成員函數還必須是虛函數,第四就是你只能用父類的指針或者引用去調用該成員函數從而實現多態
那么這些就是實現或者說觸發多態所必須具備的條件,一旦有一個條件缺失或者不滿足,那么是無法觸發多態的,所以觸發多態的條件確實很多并且要求嚴苛,那么讀者會注意上述的條件中出現了一個虛函數這個專業術語,那么這里讀者可以將虛函數先理解為一種特殊的成員函數,這里我先不解釋什么是虛函數,我們先著重圍繞語法,看看實現多態的語法是長什么樣子,接著再來具體分析背后的原理:
那么就以上文的買票的場景為例,那么這里我們可以定義一個person類來代表成人,然后再定義一個student類代表學生,那么我們知道多態的觸發必須要求繼承,那么這里我們就讓student類繼承person類,并且父子類中都定義三同的成員函數buyticket,而這里要讓該普通的成員函數是變成虛函數,就得再函數聲明前面加virtual關鍵字:
class person
{public:virtual void buyticket(){cout<<"person::buyticket"<<endl;}
};
class student : public person
{public:virtual void buyticket(){cout<<"student::butticket"<<endl;}
};
那么virtual關鍵字可以不在子類的虛函數中添加,只在與繼承的父類的三同的虛函數后面添加即可,那么編譯器識別到你子類這個成員函數和父類定義的某個虛函數是三同的,那么編譯器會默認將其處理為虛函數,但是一般建議父子類的虛函數都加上virtual關鍵字,并且父類的虛函數是一定得添加virtual關鍵字
那么這里定義完父子類以及父子類中三同的虛函數,那么多態的觸發條件已經滿足大部分了,那么最后觸發這個多態就是通過父類的指針或者引用來觸發多態
//引用
void buy(person& l1)
{l1.buyticket();
}
//指針
void buy(person* l1)
{l1->buyticket();
}
那么我們再把上面的代碼整合在一起來看一下多態的現象:
#include<iostream>
using std::cout;
using std::endl;
class person
{
public:virtual void buyticket(){cout << "person::buyticket" << endl;}
};
class student : public person
{
public:virtual void buyticket(){cout << "student::butticket" << endl;}
};
void buy(person& l1)
{l1.buyticket();
}
int main()
{person p;student s;buy(p);buy(s);return 0;
}
那么這里根據終端的結果,我們可以發現如果給buy函數傳遞的是父類對象,那么調用的就是父類的buyticket函數,那么如果傳遞的是子類對象,那么調用的就是子類的buyticket函數,那么這個結果符合多態的性質,也就是不同對象做同一個相同的動作,那么會產生不同的結果,那么這個相同的動作就是父子類定義的三同的虛函數
原理
繼承
那么知道了如何在語法層面上實現多態,那么接下來我們再來談實現多態的具體原理,也就是為什么觸發多態一定需要那幾個條件,那么我會一個一個來講解,那么首先觸發多態的第一個條件便是繼承,那么我們要實現多態,首先得準備具有繼承關系的基類和派生類,那么有的讀者可能會疑惑,那么多態的實現為什么一定需要有繼承的基礎呢,那么實現上文的買票的例子,那么我自己定義了一個person類以及繼承person類的student子類,并且兩個類中都定義了三同的buyticket虛函數,然后根據父類的指針或者引用指向的對象從而調用其指向的對象中對應的虛函數
那么有的讀者可能會認為,那么他也許不用繼承也可以實現多態,同樣是實現剛才買票的例子,那么讀者認為我可以也定義person類和student類,但是這兩個類是獨立的,然后我再這兩個類中分別定義相同函數名的buyticket成員函數,那么接著在分別定義定義student對象以及person對象,然后分別調用其定義的buyticket成員函數來實現多態:
#include<iostream>
using namespace std;
class person
{public:void buyticket(){cout<<"person::buyticket"<<endl;}
};
class student
{public:void buyticket(){cout<<"student::buyticket"<<endl;}
};
int main()
{student st;person p;p.buyticket();st.buyticket();return 0;
}
那么這里不同的獨立的對象之間定義一個相同函數名的成員函數,那么再創建完對象后,通過對象去調用該成員函數,那么不同的對象調用內部相同函數名的成員函數,那么就如同不同的對象去做相同的動作,那么根據運行結果來看,確實得到的是不同的結果,符合多態的性質
那么關于上面的這種方式,那么我想說的就是,雖然上面這種方式確實“營造”或者說呈現了一個多態的現象,但是實際上面這種方式所呈現出的現象看似符合多態,但是實際上和我們現實生活中的真正的多態的場景不是符合的,那么以我們現實生活的多態的場景為例,比如買票,那么在買票之前,系統或者買票的軟件是不知道當前買票的用戶的身份是什么,究竟是成年人還是學生,那么只有當用戶登錄然后身份認證之后,那么此時應用才知道當前購票的用戶的身份,然后根據當前用戶的身份從而得到對應的結果,比如用戶是學生,那么票價就是半價,而如果是成人,那么票價就是全價,而應用是不可能事先知道當前登錄的用戶的身份是學生還是成年人
同理上文的演唱會的例子,那么演唱會的門票也分為vip票和普通票,那么vip票持有者能夠優先提前進場,那么安保知道當前觀眾如果是vip票持有者,那么就會讓其提前入場,而如果是普通票持有者則不能讓其進場,而這里安保在檢查每一個觀眾之前,那么安保是不可能預知或者提前知道當前觀眾的身份,那么只有當觀眾給安保出示了門票之后,那么此時安保才會識別到你的身份,然后做出對應的行為
那么通過剛才我講述的這兩個現實生活中的多態的例子,那么這里我想強調的就是現實生活中的多態一定都是運行時才能觸發的多態,那么應用或者安保,它事先是無法知道當前對象的具體身份,只有當對象真正執行動作那一刻起才能知道,比如你要用買票之前一定要身份認證才能買票以及進入場地之前一定要給安保出示門票,那么只有你真正執行了買票或者經過安保的檢查的動作的時候,那么應用或者安保才能確認你的身份從而觸發多態,那么如果你只是在應用中瀏覽而不買票或者在場外徘徊不選擇立刻進場,那么此時應用或者安保是無法獲取你的身份的,那么也就自然無法觸發多態
那么我們再來結合剛才的實現方式,也就是定義了兩個獨立的對象,然后通過這兩個對象調用同名的成員函數來實現多態,那么程序在編譯階段,那么編譯器就能夠識別當前對象的類型,那么識別完這兩個對象的類型之后,那么編譯器一定知道調用的成員函數就是其類內部定義的成員函數,不用等到程序運行,那么在編譯階段就已經知道調用的是哪個成員函數了,那么這種方式就好比你還沒買票進行身份認證的時候,那么系統就告訴你,它已經知道你是學生了,不用在進行身份認證了
而繼承的方式,也就是父類和子類定義三同(函數名和參數列表和返回值都相同)的虛函數,那么通過父類的指針或者引用來調用虛函數,那么這里這里在編譯期間,那么編譯器識別到了指針或者引用的類型是父類類型,但是編譯器即使知道當前雖然是父類類型的指針或者引用,但是其調用的不一定就是父類當中定義的虛函數,那么也可能是調用子類的虛函數,那么具體是調用哪個類中的虛函數,那么虛函數的確認則是需要到程序運行時,借助一個后文要講的虛函數表才能最終確認,所以這種繼承的方式才是符合我們現實生活中的多態,也就是需要運行時才能觸發的多態,所以這里實現多態,不能按照上面的方式,那么必須得滿足繼承的基礎
虛函數&&虛函數表
那么上文我們知道了繼承的必要性,那么接下來讀者的主要的疑問就是這里多態是如何通過父類的指針或者引用來調用父類以及子類的虛函數的,那么這里就和一個虛函數表的結構有關,那么講繼承的時候,我們就提到過一個偏移量表,而這里多態同樣也有一個虛函數表,那么虛函數表的本質就是一個函數指針數組,那么數組中每一個元素就是函數指針,其記錄的就是虛函數定義所在的地址,并且當我們在一個類中定義了虛函數之后,那么該類的內存布局也會發生變化,那么我們可以寫一個簡單的代碼來驗證,那么第一次實驗時我準備了一個person類,那么其中定義了buyticket成員函數以及一個int類型的成員變量,那么最開始這個成員函數沒有被virtual修飾,那么其就是一個普通的成員函數,而第二次實驗時我則是將person對象的buyticket函數設置為虛函數,那么兩次實驗都會打印對象的首地址以及成員變量的地址以及對象的大小,來對比驗證一個類定義了虛函數其對象的內部布局會有什么變化:
//test1
#include<iostream>
using namespace std;
class person
{public:void buyticket(){cout<<"person::buyticket"<<endl;}int id;
};
int main()
{person p1;cout<<&p1<<endl;cout<<&p1.id<<endl;cout<<sizeof(p1)<<endl;return 0;
}
//test2
#include<iostream>
using namespace std;
class person
{public:virtual void buyticket(){cout<<"person::buyticket"<<endl;}int id;
};
int main()
{person p1;cout<<&p1<<endl;cout<<&p1.id<<endl;cout<<sizeof(p1)<<endl;return 0;
}
第一次實驗:
第二次實驗:
那么這里根據運行結果發現,那么類在沒有定義虛函數以及定義了虛函數的總大小是不一樣的,并且成員變量的偏移量也發生了變化,那么在沒定義虛函數之前,那么成員變量的偏移量為0,也就在對象的起始位置分配,而定義了虛函數之后,那么成員變量則是在對象位置之后的8個子節后開始分配
由于這里的代碼是在64位平臺下的機器運行,那么64位的平臺下,那么指針的大小是8個字節而不是4個字節,那么這里對象的前8個字節,其實就是分配給了一個指針,而該指針指向的內容,正是我們說的虛函數表,那么我們可以從調試窗口,看到這個指針指向的虛函數表的內容:
那么這里虛函數表的內容就是記錄了當前該類的虛函數的定義所在的地址
那么從上文的代碼驗證以及分析之后,那么讀者現在知道了一個類中如果定義了虛函數,那么該類實例化出的對象的內存布局則不再只是存儲成員變量,而是會在起始位置處分配一個指針指向一個函數指針數組,那么該函數指針數組的內容就是該類中定義的虛函數的定義所在的地址
那么接下來我們在引入較為復雜的場景,來逐漸理解虛函數表,那么接下里的場景就是我們定義了一個繼承person類的student子類,那么student類中有和person類三同的虛函數,那么此時我們來觀察person類的內存布局和子類的內存布局,那么在寫代碼具體驗證之前,那么我們可以先進行一個推導,那么根據上文,那么我們知道了一個類定義了虛函數,那么該類實例化出的對象的內存布局一定得包含一個指針指向一個虛函數表,那么這里子類也定義了虛函數表,那么不出意外,那么該子類內部也會開辟一個虛函數表指針,但是由于其繼承了person類,而person類內部也定義了虛函數,那么這里子類是否會開辟兩個虛函數表指針,一個指向父類的虛函數表,然后另一個指針指向子類的虛函數表,還是說子類只會存一個指針,那么實踐出真知,接下來就讓我寫代碼來驗證此場景下,子類的內存布局:
#include<iostream>
using namespace std;
class person
{
public:virtual void buyticket(){cout << "person::buyticket" << endl;}int id;
};
class student : public person
{
public:virtual void buyticket(){cout << "student::buyticket" << endl;}int _id;
};
int main()
{person p1;student st;cout << &p1 << endl;cout << &p1.id << endl;cout << sizeof(p1) << endl;cout << "-------------------------" << endl;cout << &st << endl;cout << &st.id << endl;cout << &st._id << endl;cout << sizeof(st) << endl;return 0;
}
那么根據運行結果,那么父類的成員變量與整個對象的起始位置相差8個字節,其次就是子類的成員變量的地址與父類的成員變量的地址相差8個字節,那么從結果我們可以發現,這里子類的內存布局還是父后子,并且我們可以驗證這里子類只會維護一個虛函數表指針,那么其位置就在對象開頭,因為整個對象是由完整的父類對象16字節加上子類的int成員變量的4個字節以及內存對齊填充的4個字節總共24個字節,所以這里子類的內存布局是先父后子,其中的父類部分就是之前上文帶有虛函數的完整的父類對象的副本
那么接下來,我們再來通過調試窗口來查看父子類對象的指針指向的虛函數表的內容:
那么這里我們就可以初見端倪,那么觀察調試窗口里的內容,我們可以對比父子類的對象中的指針指向虛函數表里面的內容,那么這里父類的虛函數表里面的內容則是記錄了父類內部定義的虛函數的定義的地址,而子類的虛函數表中則是記錄子類內部定義的虛函數的定義所在的地址,那么還沒完,那么此時我們如果再在子類中定義一個和父類不是三同的虛函數,比如fun虛函數,那么fun虛函數的除了函數名與父類的buytick虛函數不一樣外,其余返回值和參數列表都是一樣的,那么此時我們再來觀察子類對象存儲的指針指向的虛函數表里面的內容
而由于調試窗口只能顯示虛函數表的第一個條目,而不能完整顯示出虛函數表的所有條目,所以這里要驗證當前場景下的子類的虛函數表的內容,那么我們就得自己寫代碼來驗證,那么我們知道虛函數表本質就是函數指針數組,那么數組的每一個元素是一個指針,其保存了虛函數定義的地址,而子類對象中存儲了一個指針,其位置在子類對象的父類部分的開頭,那么其保存了函數指針數組的首元素的地址,而函數指針數組的每一個元素都是指針,那么意味著子類對象中存儲的指針,其保存的是一個函數指針數組中的第一個指針的地址,那么指針指向的內容是指針,所以子類對象中的該指針的類型就是二級指針,那么我們得解引用兩次,第一次先解引用子類對象中的指針獲取到函數指針數組的首元素的地址,然后再解引用函數指針數組的首元素從而獲取到虛函數的定義的地址,所以需要解引用兩次
而子類的虛函數表指針是位于子類的父類部分的起始位置,而在該場景下,父類部分person的起始位置其實就是整個子類對象的起始位置,所以這里我們直接取子類對象的地址從而直接獲取到子類對象中存儲的指針的地址,
student st;
&st;//虛函數表的地址的地址
那么該地址是二級指針也就是虛函數表指針的地址,那么意味著該地址可以視作一個三級指針,那么接下來我們就要解引用該地址,得到虛函數表的首元素的地址,但是這里地址的類型是一個student類型,那么我們還得強制類型轉化成三級函數指針類型,而由于我們定義的函數都是void類型且沒有參數,但要注意編譯器會隱藏添加一個tstudent類型的his指針,所以這里我們還得定義一個函數指針類型Fuc_Ptr
typedef void(*Fuc_Ptr)(student*);
//函數指針類型的定義:
typedef 返回值(*指針類型名)(函數的參數列表);
那么Fun_ptr是一級指針,而Fuc_ptr* 就是二級指針,那么Fuc_ptr**就是三級指針,那么這里我們將其強制類型轉化為三級函數指針類型,然后解引用該三級指針得到虛函數表的首元素的地址
接下來的思路就是獲取虛函數表的首元素的地址后,然后遍歷該虛函數表并且執行虛函數表中記錄的虛函數,那么這部分內容則是都定義到print函數中完成,那么它會接收一個二級指針,那么由于虛函數表的最后的一個元素是以NULL結尾,那么我們就可以用以for循環遍歷這個虛函數表,然后每一次循環執行對應的函數
void print(Fuc_Ptr* vptr,student* l1)
{for (int i = 0;vptr[i] != NULL;i++){vptr[i](l1);}
}
完整驗證代碼:
#include<iostream>
using namespace std;
class person
{
public:virtual void buyticket(){cout << "person::buyticket" << endl;}int id;
};
class student : public person
{
public:virtual void buyticket(){cout << "student::buyticket" << endl;}virtual void fun(){cout << "student::fun()" << endl;}int _id;
};
typedef void(*Fuc_Ptr)(student*);
void print(Fuc_Ptr* vptr,student* l1)
{for (int i = 0;vptr[i] != NULL;i++){vptr[i](l1);}
}
int main()
{student st;Fuc_Ptr* ptr = *(Fuc_Ptr**)&st;print(ptr,&st);return 0;
}
那么這里我們根據運行結果,我們就能驗證在這個場景下,子類的虛函數表的內容,那么我們能夠確認子類的虛函數表的條目有兩項,分別是記錄自己定義的buyticket虛函數以及新增了與父類不是三同的fun虛函數
這里我再增加最后一個場景,那么就是子類沒有虛函數,但是繼承的父類定義了虛函數,比如這里的person類定義了buyticket虛函數而student類沒有定義任何虛函數,那么這里我們直接按照剛才的驗證方式,來驗證這個場景:
#include<iostream>
using namespace std;
class person
{
public:virtual void buyticket(){cout << "person::buyticket" << endl;}int id;
};
class student : public person
{
public:int _id;
};
typedef void(*Fuc_Ptr)(student*);
void print(Fuc_Ptr* vptr,student* l1)
{for (int i = 0;vptr[i] != NULL;i++){vptr[i](l1);}
}
int main()
{student st;&st;Fuc_Ptr* ptr = *(Fuc_Ptr**)&st;print(ptr,&st);return 0;
}
那么這里我們根據運行結果,發現子類還是會有虛函數表,并且其虛函數表記錄的是其父類定義的虛函數的地址
那么這里我們就結合剛才的所有場景,也就是一個類定義了虛函數、父類和繼承當前父類的子類都定義了三同的虛函數、繼承當前的父類的子類定義了和父類不是三同的虛函數、父類定義了虛函數但繼承當前父類的子類沒有定義虛函數。通過這4個場景,那么我們就能夠理解并且掌握虛函數表的相關原理:
那么當一個類定義了虛函數,那么其實例化的對象內部必然有一個指針,指向虛函數表,那么如果該類沒有虛函數,但是其繼承的父類有虛函數,我們知道單繼承的子類的內存布局就是先父后子,那么其中父類部分就是完整的父類對象的副本,而如果父類定義了虛函數,那么父類對象的內存布局是會在起始位置處定義一個指針,然后之后再是父類的成員變量,并且整體按照內存對齊規則分布,所以子類中的父類部分已經有了指向虛函數表的指針,那么子類就不要再額外創建一個虛函數表的指針,那么直接復用父類部分的指針,只不過讓其指向子類自己的虛函數表
那么子類的虛函數表的條目的構成則是由兩部分組成分別數父類定義的虛函數以及子類自己定義的與父類不是三同的虛函數,并且先是排列父類部分的虛函數的定義的地址,再是排列子類內部定義的虛函數的地址
那么如果父類和子類定義了三同的虛函數,那么這里子類指向的虛函數表中,原本指向記錄父類虛函數定義的地址的位置處則會被覆蓋為子類中與父類三同的虛函數的定義的地址,那么之后就不用再新增該虛函數的條目,因為直接覆蓋了父類的三同的虛函數地址
那么這里知道虛函數表的相關原理之后,那么這里我會給讀者拋一個問題,那么這個問題也就是理解虛函數表的最后一道門檻,那么假設我現在有一個person類,那么person類定義了虛函數,那么有了上文的講解,想必你已經知道如果我實例化一個person對象,那么該person對象內部起始位置一定有一個指針,指向一個虛函數表,那么我的問題就是:如果我實例化了多份person對象,那么每一個person對象內部都有一個指針指向一個虛函數表,那么其指向的虛函數表是同一個虛函數表還是指向獨屬于每一個person對象的虛函數表?
那么這個問題,我相信很多讀者即使不知道正確答案,但是憑感覺也應該認為指向的是同一個虛函數表,那么我們也可以推測出原因,這里的原因其實和對象是否存儲成員函數的原因是差不多的,因為實例化出的不同的person對象中的虛函數表的條目都是一樣的,其記錄了person類中虛函數的定義的地址,所以這里沒必要為每一個對象開辟一份虛函數表,就和不同實例化的對象共用一個成員函數是一個道理
那么這里我也可以寫代碼來驗證這個情況:
#include<iostream>
using namespace std;
class person
{
public:virtual void buyticket(){cout << "person::buyticket" << endl;}int id;
};
class student : public person
{
public:int _id;
};
void test()
{person p2;
}
int main()
{person p1;return 0;
}
那么這里我們可以通過調試窗口,來看到這里的實例化的兩個person對象的指針指向的虛函數表的地址都是相同的。都是:0x00007ff6783dbcf8
p1:
名稱 | 值 | 類型 | |
---|---|---|---|
◢ | __vfptr | 0x00007ff6af6fbcf8 {多態.exe!void(* person::`vftable’[2])()} {0x00007ff6af6f141f {多態.exe!person::buyticket(void)}} | void * * |
p2:
名稱 | 值 | 類型 | |
---|---|---|---|
◢ | __vfptr | 0x00007ff6af6fbcf8 {多態.exe!void(* person::`vftable’[2])()} {0x00007ff6af6f141f {多態.exe!person::buyticket(void)}} | void * * |
那么接著我們還可以驗證虛函數表存放的位置,是在棧上還是堆上還是靜態區還是只讀數據段
那么實踐出真知,接下來我們就寫代碼來驗證虛函數表的位置,那么這里我們還是分別打印棧變量的地址以及const修飾的變量的地址以及堆上申請的變量的地址和static修飾的變量的地址以及虛函數表的首元素的地址,const修飾的變量位于只讀數據段,棧變量位于棧區,而new申請的變量位于堆,而static修飾的變量位于靜態區,那么我們在比較虛函數的首元素的地址和這些變量之間的位置關系,來確定其更靠近與哪個區域:
#include<iostream>
using namespace std;
class person
{
public:virtual void buyticket(){cout << "person::buyticket" << endl;}int id;
};
class student : public person
{
public:int _id;
};
typedef void(*Fuc_Ptr)(student*);
int main()
{int a;const int b = 1;int* ptr = new int;static int c;cout << &a << endl;cout << &b << endl;cout << ptr << endl;cout << &c << endl;person p;Fuc_Ptr* vptr = *(Fuc_Ptr**)&p;cout << vptr << endl;return 0;
}
那么這里根據打印的地址,那么我們可以發現,這里虛函數表的首元素的地址與靜態區的地址相差的字節是3572,而與其他地址相差的字節是遠大于這個量級的,那么我們可以通過代碼來驗證得到虛函數表是存儲在靜態區當中的
虛函數表的生成
那么這里就要補充虛函數表的生成,那么虛函數表是在編譯階段由編譯器來生成,那么根據上文的內容,那么我們知道只有當前類定義了虛函數以及繼承的父類的子類的類定義了虛函數,那么此時該類就會有指針指向該類對應的虛函數表,而子類的虛函數表的條目由兩部分組成,分別是父類定義的虛函數以及新增子類自己與父類不是三同的虛函數
那么假設現在有一個繼承鏈,那么這里編譯器要為類中定義了虛函數的類以及繼承的父類有虛函數的子類生成虛函數表,那么編譯器生成的虛函數表的順序就是先生成整個繼承鏈中最頂層且定義了虛函數的父類,那么該父類生成完了虛函數表之后,在依次沿著繼承鏈往下依次生成子類的虛函數表,那么子類的虛函數表的生成首先就需要拷貝其父類的虛函數表的所有條目,拷貝完之后,接著在檢查子類是否存在有和繼承下來的父類的三同的虛函數,如果有,那么此時該子類有與父類三同的虛函數的地址就會覆蓋到與父類三同的虛函數在虛函數表的條目,將其覆蓋為子類的虛函數的地址,那么檢查并且覆蓋完父類三同的條目之后,然后編譯器再會增加子類與父類不是三同的虛函數,那么重復這樣的步驟處理接下來的子類
根據剛才的原理,那么我們知道編譯器在生成繼承鏈中的每一個類的虛函數表的時候,都需要利用并且拷貝其繼承的父類的虛函數表條目然后接著再檢查三同然后再進行覆蓋工作,那么編譯器就得記錄代碼中定義的繼承關系,所以這里編譯器會維護一個繼承樹的數據結構,那么繼承樹就是記錄了這些類的繼承關系,那么這棵樹的根節點就是最上層的父類,那么最底層的葉子節點就是最下層的子類,那么它會根據這棵樹,從最底層的葉子節點開始往上遞歸,那么如果當前葉子節點有虛函數,那么就會遞歸先生成父類的虛函數表,那么如果掃描到父類的父類還定義了虛函數,那么就會繼續往上遞歸,直到遞歸到最頂層的定義且虛函數的父類,然后停止遞歸,生成該父類的虛函數表,然后再回溯到下一層的子類,那么子類會拷貝上層的父類的虛函數表的條目,先檢查三同,有三同的虛函數,那么就覆蓋,然后處理完之后再新增子類與父類沒有三同的虛函數,依次往下回溯到最底層的子類,所以經過剛才的講述,我們可以認識到,編譯器在編譯階段就已經為所有定義了虛函數的類以及其繼承的父類帶有虛函數的子類全部都生成了虛函數表,沒有所謂的按需創建的說法,那么即使你不創建帶有虛函數的person對象,但是編譯器在編譯階段還是已經為person對象生成了對應的虛函數表
void build_vtable(ClassMetadata* cls) {// 遞歸構建基類vtablefor (auto base : cls->bases) {build_vtable(base); // 確保基類先構建cls->vtable_size = base->vtable_size; // 繼承槽位大小}// 處理重寫函數for (auto& vfunc : cls->vfuncs) {if (vfunc.overrides) {int slot = find_base_slot(vfunc); // 在基類查找槽位cls->vtable_map[vfunc.id] = slot; // 復用原槽位}}// 追加新虛函數for (auto& vfunc : cls->vfuncs) {if (!vfunc.overrides) {cls->vtable_map[vfunc.id] = cls->vtable_size++;}}
}
那么當我們創建一個帶有虛函數或者繼承的父類帶有虛函數的子類對象的時候,那么這里由于子類的內存布局是先父后子,那么這里我們可以得到一個結論,那么就是這個單繼承鏈中從定義了虛函數的父類開始往下的所有子類,那么其對象中都只有一個虛函數表指針,指向一個屬于該類的虛函數表
但是這里的指針的存放位置是一個容易混淆的點,很多人都會默認認為指針就一定是在子類對象的起始位置處
那么注意,子類的對象是先父后子,那么先是父類部分然后再是子類部分,并且其父類部分是按照繼承的聲明順序排列,那么虛函數表的指針一定是位于這個子類對象中第一個定義虛函數的父類部分的起始位置,而注意該父類的起始位置不一定就整個子類對象的起始位置重合,因為在單繼承鏈中定義虛函數的父類不一定是從第一個父類開始定義:
那么有的人之所以有這個慣性思維,那么是因為大部分讀者都是習慣了第一個繼承的父類就定義虛函數
那么繼承帶有虛函數的父類的子類或者自己本身定義了虛函數的類,那么編譯器在這些類的構造函數中,會完成虛函數表指針的初始化,將虛函數表指針指向編譯階段已經在靜態區生成好的該類的虛函數表,那么這就是虛函數表誕生的一個過程
person::person() {// 編譯器注入的關鍵步驟 ▼this->__vptr = &person::vtable; // 初始化vptr// 用戶編寫的構造代碼
}
多繼承
那么前面我們介紹了單繼承下子類的內部布局以及虛函數表,那么這里我們來引入多繼承,那么看一下多繼承下子類的虛函數表以及內存布局會有什么變化,那么這里我定義了兩個父類b分別是base1和base2,和一個子類derive同時多繼承這兩個父類base1和base2,那么這里我們還是引入幾個場景,那么首先第一個場景就是父類base1和base2中都定義了沒有參數沒有返回值的虛函數fun1,并且子類derive也定義了沒有參數沒有返回值的fun1
#include<iostream>
using namespace std;
class base1
{public:virtual void fun1(){cout<<"base1::fun1()"<<endl;}int id1;
};
class base2
{public:virtual void fun1(){cout<<"base2::fun1()"<<endl;}int id2;
};
class derive:public base1,public base2
{public:virtual void fun1(){cout<<"derive::fun1()"<<endl;}int id3;
};
int main()
{derive d;cout<<&d<<endl;cout<<&d.id1<<endl;cout<<&d.id2<<endl;cout<<sizeof(d)<<endl;return 0;
}
那么這里我們打印了這三個地址,我們可以發現此時子類的內存布局,那么還是先父后子,并且父類部分是按照聲明順序排列,而這里base1的第一個成員變量并不在子類對象的起始位置分配,而是往后移動了8個字節,那么有了上文的講解,我們知道這里的8個字節就是指針,其指向虛函數表,而base2的成員變量與base1的成員變量的起始位置相差16個字節,那么這16個字節就是由base1的成員變量的4個字節以及內存對齊填充的4個字節以及base2中的前8個字節的指針加起來,總共16個字節,而這里base2的部分的起始位置也會存儲一個指針指向虛函數表
那么這里我們就能知道多繼承的子類的內存布局,那么還是先父后子,并且父類部分是按照聲明順序排列,那么如果父類部分定義了虛函數,那么每一個父類部分的起始位置就會分配一個指針,指向父類部分的虛函數表
那么接下來我們再來驗證這里父類部分的指針指向的虛函數表的內容,那么采取的方式還是執行虛函數表中記錄的虛函數,這里就注意父類base1中指向虛函數表的指針就在子類對象的起始位置,而父類base2的指針則是存放在base2父類部分的起始位置,那么這里要得到base2的虛函數表指針,那么我們可以先定義一個base2類型的指針指向derive對象,那么這里編譯器識別到該指針的類型是base2但其指向的卻是子類對象,所以這里編譯器會隱式的插入一行代碼將該指針往后移動一定的偏移量,從而將base2的指針指向子類對象中的base2父類部分的起始位置,那么我們可以通過這個巧妙的方式從而得到base2父類部分的虛函數表指針的地址,那么讀者也可以嘗試獲取子類對象的起始地址然后加上一定的偏移量得到base2部分的虛函數表指針的地址,
下一步就是解引用該虛函數表指針得到base2虛函數表的首元素地址,接著在print函數內遍歷虛函數表并執行對應的虛函數:
#include<iostream>
using namespace std;
class base1
{
public:virtual void fun1(){cout << "base1::fun1()" << endl;}int id1;
};
class base2
{
public:virtual void fun1(){cout << "base2::fun1()" << endl;}int id2;
};
class derive :public base1, public base2
{
public:virtual void fun1(){cout << "derive::fun1()" << endl;}int id3;
};
typedef void(*Fuc_Ptr)(derive*);
void print(Fuc_Ptr* vptr, derive* l1)
{for (int i = 0;vptr[i] != NULL;i++){vptr[i](l1);}
}int main()
{derive d;Fuc_Ptr* l1 = *(Fuc_Ptr**)&d;print(l1, &d);cout << "------------" << endl;base2* ptr =&d;Fuc_Ptr* l2 = *(Fuc_Ptr**)ptr;print(l2, &d);return 0;
}
那么這里我們根據運行結果得知,那么子類的多繼承的兩個父類部分的指針指向的虛函數表記錄的條目都是子類定義的虛函數fun1,因為這里子類的fun1和多繼承的父類的fun1是三同的,所以這里會覆蓋父類對應的虛函數表中的條目
那么此時再引入第二個場景,那么就是子類中定義與父類不是三同的虛函數fun2,那么在驗證之前,我們可以自己先進行一個推測,那么這里子類定義了與父類不是三同的虛函數fun2,那么子類的內存布局肯定不會發生變化,那么一定是先父后子,那么父類部分按照聲明順序排列,并且父類部分的第一個位置都是一個指針指向虛函數表,那么這里與之前的場景不同的是,這里由于子類定義了與父類不是三同的虛函數fun2,那么這里的兩個父類的虛表是都會新增這個fun2函數的條目還是只是其中一個父類的虛表新增該條目,那么這個場景下我們就只需驗證父類base1和base2的虛函數表的內容:
#include<iostream>
using namespace std;
class base1
{
public:virtual void fun1(){cout << "base1::fun1()" << endl;}int id1;
};
class base2
{
public:virtual void fun1(){cout << "base2::fun1()" << endl;}int id2;
};
class derive :public base1, public base2
{
public:virtual void fun1(){cout << "derive::fun1()" << endl;}virtual void fun2(){cout << "derive::fun2()" << endl;}int id3;
};
typedef void(* Fuc_Ptr)(derive*);
void print(Fuc_Ptr* vptr, derive* l1)
{for (int i = 0;vptr[i] != NULL;i++){vptr[i](l1);}
}int main()
{derive d;Fuc_Ptr* l1 = *(Fuc_Ptr**)&d;print(l1, &d);cout << "------------" << endl;base2* ptr =&d;Fuc_Ptr* l2 = *(Fuc_Ptr**)ptr;print(l2, &d);return 0;
}
那么這里我們可以驗證,那么子類內部定義的與父類不是三同的虛函數的地址則是添加在base1的虛函數表中,而沒有添加在base2的虛函數表中,所以這里結合上面的兩個場景,那么我們也能夠摸清楚多繼承的虛函數表的條目生成的機制:
那么子類多繼承的父類如果定義了虛函數,那么該父類部分就有虛函數表指針指向對應的虛函數表,并且如果子類定義了與父類三同的虛函數,那么會覆蓋父類的虛函數表中對應的條目,而如果子類定義了與父類虛函數不是三同的虛函數,那么該條目只會新增到聲明順序最靠前且定義了虛函數的父類的虛函數表中,這里讀者看到這里我添加了兩個定語,分別是聲明順序最靠前以及定義了虛函數,那么這里要注意的是,子類多繼承的所有父類不一定都定義了虛函數,比如子類先后同時繼承了base1和base2和base3
class derive:public base1,public base2,public base3
{
.....
};
那么如果base1沒有定義虛函數而base2和base3定義了虛函數,那么這里子類與父類沒有三同的虛函數條目則添加到base2的虛函數表中,這里一定要嚴謹
那么看到這里,那么讀者又會有一個疑問,那么就是這里為什么要這么設計,也就是子類與父類不是三同的虛函數只能添加到聲明順序最靠前并且定義了虛函數的父類當中呢?
那么這個問題就和虛函數的調用有關,也就是接下來下文所講的用父類指針或者引用調用虛函數的內容,那么這個問題就先埋下一個伏筆,會會在下文中解答
父類指針/父類引用
單繼承
那么我們知道觸發多態的最后一個條件就是父類的指針或者引用,那么這里為什么一定要求是父類的指針或者引用,為什么不能是子類的指針或者引用或者父類的對象來調用呢?
那么以單繼承為例,假設這里有一個derive類和base類,那么derive類繼承了base類,并且兩個類都定義了三同的函數名為fun的虛函數,那么這里如果用derive *的指針去調用,那么derive *只能指向一個derive對象,那么這里用derive指針去調用函數,那么編譯器識別到指針的類型是derive類型,那么它會自動調用derive類綁定的成員函數,而如果derive類和base類定義了三同的虛函數,那么這里虛函數之間就會構成隱藏,那么調用的永遠是子類的虛函數
#include<iostream>
using namespace std;
class base1
{
public:virtual void fun1() {cout << "base1::fun1()" << endl;}int id1;
};
class derive :public base1
{
public:virtual void fun1() {cout << "derive::fun1()" << endl;}int id2;
};
int main()
{derive d;derive* ptr = &d;ptr->fun1();return 0;
}
所以通過子類的指針調用,那么其是靜態綁定的調用,不符合多態借助虛函數表的動態綁定的機制,所以不能用子類的指針
那么再來說為什么無法用父類的對象來調用,那么注意的是對象調用不管是虛函數還是普通的成員函數,那么其機制都是調用綁定當前對象的成員函數或者虛函數,還是上面說的場景,base1和繼承base1的derive類定義了三同的虛函數fun1,而這里如果你定義了一個base1對象,那么當前對象調用的函數只能是base1內部定義的函數其中包括虛函數以及普通成員函數,和用子類指針調用的機制一樣,也是靜態綁定
#include<iostream>
using namespace std;
class base1
{
public:virtual void fun1(){cout << "base1::fun1()" << endl;}int id1;
};
class derive :public base1
{
public:virtual void fun1(){cout << "derive::fun1()" << endl;}int id3;
};
int main()
{base1 b;b.fun1();return 0;
}
那么由于對象調用函數的機制是靜態綁定,編所以譯器不會去找到對象中的虛函數指針解引用訪問虛函數表然后再找到對應的虛函數在虛函數表中的索引,那么編譯器只會調用其作用域內定義的函數,所以父類的對象是不行的,子類的對象就更不用說了,肯定更不可以
那么這里排除了子類的指針和父子類對象,那么這里要觸發多態,就只能父類的指針或者引用,那么這里我們知道子類的指針以及父類的對象為什么不行,這里我們還得知道父類的指針或者引用為什么行,那么父類的指針或者引用如果指向的是父類的對象,那么虛函數表指針就在父類對象的起始位置,然后解引用該指針訪問到虛函數表,然后根據虛函數在虛函數表中的索引,然后跳轉到定義去執行,而如果指向的是子類的對象,那么在單繼承下,那么它會首先計算出聲明順序最靠前且定義了虛函數的父類部分的偏移量,然后解引用指針訪問虛函數表,然后根據對應的虛函數在虛函數表的索引,跳轉到定義執行虛函數的代碼
所以當父類的指針指向父類對象,那么它能夠訪問父類的虛函數表,調用父類定義的虛函數,而父類的指針也能夠指向子類對象,那么它能夠訪問子類的虛函數表,從而調用子類中與父類三同的虛函數,能夠正確實現多態
多繼承
在多繼承的場景下,那么也只能用父類的指針或者引用去調用,那么相比與單繼承,那么多繼承就能夠允許我們用多個父類的指針或者引用觸發多態,比如這里有base1和base2兩個父類,那么這里derive同時繼承base1和base2,如果base1和base2分別定義了和derive類三同的虛函數fun1和fun2,那么我們可以定義base1的指針或者base2的指針來調用該虛函數,那么多繼承虛函數調用的機制則是:編譯器會識別指針的類型然后計算出在子類對象中父類部分的偏移量,然后解引用該父類部分中的虛函數表指針,在根據虛函數在虛函數表中的索引,然后跳轉執行對應的虛函數的代碼,和單繼承的調用機制是一樣
那么這里就能夠來解釋上文埋下的伏筆,也就是子類定義了不與父類三同的虛函數,那么這里為什么只在聲明順序最靠前且定義了虛函數的父類的虛函數表中添加該條目
那么假設這里derive類定義了與base1和base2不是三同的虛函數fun2,那么我們根據上文的講解,我們知道fun2的條目只添加在base1的虛函數表中,不在base2的虛函數中添加,那么原因是這個虛函數由于沒有與多繼承的父類中定義的所有虛函數出現三同,那么這里調用該子類與父類不是三同的虛函數,那么只能通過子類的指針調用,那么即使我們知道子類與父類不是三同的虛函數是添加在base1的虛函數表中,那么理論上,base1的指針指向該對象,那么能夠解引用該虛函數表指針從而到虛函數表中訪問到fun2虛函數,但是編譯器還是做了檢查,不允許我們用base1的指針指向derive對象然后訪問fun2虛函數:
所以這里只能用子類的指針去訪問fun2虛函數,那么就沒必要每一個父類部分的虛函數表添加該條目,只需要在聲明順序最前且定義了虛函數的父類部分的虛函數表添加即可
補充:析構函數/帶有虛函數的對象的切片/子類虛繼承父類且父類定義了虛函數,此時子類的內存布局/final以及override關鍵字
析構函數
那么這里c++允許我們在類的析構函數前面添加virtual關鍵字,那么意味著此時析構函數也可以作為虛函數,那么為什么這里析構函數也可以作為虛函數呢?
那么我們知道父類的指針可以指向子類的對象,那么會存在這么一個場景,那么這里我定義了一個base類和一個derive類,那么derive類繼承base類,那么我接著定義一個base類型的指針指向在堆上創建的derive對象,調用new運算符來創建,并且這里derive對象內部有一個成員變量是指針,那么其內部的構造函數會將其初始化指向堆上的一片空間
class base
{public:int ptr1;base():ptr1(new int[100]){}~base(){delete [] ptr1;ptr1=nullptr;}
};
class derive:public base
{public:int* ptr2;derive():ptr(new int[10]){}~derive(){delete[] ptr2;ptr2=nullptr;}
};
int main()
{base1* ptr=new derive;delete ptr;return 0;
}
那么這里我們知道由于該derive對象是在堆上申請的,那么在堆上申請的空間需要我們手動釋放,所以就需要我們調用delete運算符來釋放該指針指向的對象,那么這里注意new運算符其實底層是分為了兩個步驟來進行,那么第一步就是調用該對象的析構函數,那么第二步則是調用operator delete運算符重載函數,那么其內部封裝了free函數來釋放整個對象的空間,而如果這里的析構函數不是虛函數,那么編譯器識別到該指針的類型是base類型,那么編譯器會調用base綁定的析構函數,也就是只執行base類的析構函數,那么base類的析構函數只會清理父類部分的資源,那么子類部分的資源此時沒有得到清理,而在這個場景下子類部分有一個指針的成員變量并且指向了堆上的空間,那么子類部分資源沒有清理,那么就會造成內存泄漏的問題
所以這里我們期望的是調用子類的析構函數因為子類的析構函數會先執行自己的析構函數體里面的內容再自動調用父類的析構函數
而這里是父類的指針,我們卻期望調用子類的析構函數,那么這不正是我們多態的場景嗎,所以這里的析構函數自然可以被允許設置為虛函數,但是多態的觸發條件是函數名和返回值和參數列表要相同,所以為了滿足三同的要求,所以這里編譯器對析構函數進行了特殊處理,那么將所有類的析構函數的函數名統一處理為了destructor
帶有虛函數的對象的切片
那么我們知道子類對象可以賦值給父類對象,那么此時發生一個切片的行為,那么編譯器會計算出子類部分的父類部分的起始位置的偏移量和父類的大小,然后將子類對象的父類部分給拷貝過去,而其余部分通通切掉,那么這就是切片,而如果假設這里有一個父類對象和一個繼承該父類對象的子類對象,并且父子類都定義了三同的虛函數,那么這里我們知道將該子類對象賦值給父類對象會發生切片,那么對于子類對象中的父類部分的成員變量肯定是原封不動的拷貝過去,但是虛函數表指針呢?
那么這里根據上文的講解,這里編譯器肯定不可能那么把虛函數指針也原封不動拷貝過去,因為父類的對象只能調用自己的虛函數,無法調用子類的虛函數,所以這里編譯器會進行一個處理,那么在上文我們講解虛函數表的生成的原理的時候,我們知道編譯器在編譯階段就會為每一個帶有虛函數的類以及其繼承的父類帶有虛函數的子類都生成虛函數表,所以這里編譯器就會將該父類對象的指針指向在靜態區創建好的父類的虛函數表即可,不會進行虛函數表的指針的拷貝
子類虛繼承父類且父類定義了虛函數,此時子類的內存布局
那么這里補充的最后一點就是這里如果同時存在虛繼承和虛函數,那么子類的內存布局會是怎樣的,那么我們知道虛繼承子類對象內部會維護一個指針指向偏移量表,而定義了虛函數則子類內部還會維護一個指針指向虛函數表,那么這里必然會有兩個指針,并且我們知道子類采取虛繼承的方式繼承父類,那么子類的內存布局是先子后父,那么其中子類對象的起始位置會分配一個指針指向偏移量表,那么這里我們核心要驗證的就是這兩個指針的位置
那么這里我們還是寫了一個代碼來驗證,并且通過打印地址的方式來確認:
#include<iostream>
using namespace std;
class base1
{
public:virtual void fun1(){cout << "base1::fun1()" << endl;}int id1;
};
class derive :virtual public base1
{
public:virtual void fun1(){cout << "derive::fun1()" << endl;}int id2;
};
int main()
{derive d;cout << &d << endl;cout << &d.id2 << endl;cout << &d.id1 << endl;cout << sizeof(d) << endl;return 0;
}
那么根據代碼的結果,我們知道這里子類的整體的內存布局還是先子后父,那么這里子類對象則是在起始位置的8個字節后開始分配,而這里的8個字節就是指針,該指針指向的是偏移量表,而這里父類的成員變量的地址與子類第一個成員變量的地址相差16個字節,那么這16個字節就是由子類的成員變量的4個字節加上其內存對齊填充的4個字節和虛函數表指針的8個字節,總共16個字節,所以這里我們就能知道子類的內存布局
final以及override關鍵字
那么這里補充這兩個關鍵字,那么final關鍵字只能修飾虛函數,那么定義在虛函數的后面,代表該虛函數不能被重寫或者說覆蓋,那么一旦子類定義了與final修飾的三同的虛函數就會報錯:
class base1
{
public:virtual void fun1() final{cout << "base1::fun1()" << endl;}int id1;
};
class derive :public base1
{
public:virtual void fun1(){cout << "derive::fun1()" << endl;}int id2;
};
final也可以修飾在類名后面,代表該類無法被繼承,如果有繼承,則會報錯:
class base1
{
public:virtual void fun1(){cout << "base1::fun1()" << endl;}int id1;
};
class derive :public base1
{
public:virtual void fun1(){cout << "derive::fun1()" << endl;}int id2;
};
而override關鍵字則是可以檢查該子類定義的虛函數是否有與繼承的父類有三同的虛函數存在,沒有則會保了報錯,該關鍵字的作用就是檢查該虛函數是否重寫了父類的虛函數
class base1
{
public:virtual void fun1(){cout << "base1::fun1()" << endl;}int id1;
};
class derive :public base1
{
public:virtual void fun1(int i=0) override{cout << "derive::fun1()" << endl;}int id2;
};
結語
那么這就是多態的全部內容了,那么多態算是c++學習的一道大的門檻了,那么十分感謝耐心看到這里的讀者,那么恭喜你成功越過多態這道門檻,那么下一期我將更新搜索二叉樹,我會持續更新,希望你能多多關注,如果本文有幫組到你,還請三連加關注,你的支持就是我創作的最大的動力!