詳解C++中多態(tài)的底層原理
前言
要了解C++多態(tài)的底層原理需要我們對(duì)C指針有著深入的了解,這個(gè)在打印虛表的時(shí)候就可以見(jiàn)功底,理解了多態(tài)的本質(zhì)我們才能記憶的更牢,使用起來(lái)更加得心應(yīng)手。
1.虛函數(shù)表
(1)虛函數(shù)表指針
首先我們?cè)诨怋ase中定義一個(gè)虛函數(shù),然后觀察Base類型對(duì)象b的大?。?/p>
class Base
{
public:
virtual void Func1()
{
cout << "Func1" << endl;
}
virtual void Func2()
{
cout << "Func2" << endl;
}
void f()
{
cout << "f()" << endl;
}
protected:
int b = 1;
char ch = 1;
};
int main()
{
Base b;
cout << sizeof(b);
return 0;
}
我們發(fā)現(xiàn),如果按照對(duì)齊數(shù)原則來(lái)計(jì)算b的大小時(shí),得到的結(jié)果是8,而我們打印的結(jié)果是:

這說(shuō)明帶有虛函數(shù)的類所定義的對(duì)象中,除了成員變量之外還有其他的東西被加入進(jìn)去了(成員函數(shù)默認(rèn)不在對(duì)象內(nèi),在代碼段)。
我們可以通過(guò)調(diào)試來(lái)觀察b中的內(nèi)容:

我們發(fā)現(xiàn)對(duì)象中多了一個(gè)__vfptr,即為虛函數(shù)表指針。簡(jiǎn)稱為虛表指針。
(2)虛函數(shù)表
仍然看上圖,我們發(fā)現(xiàn)虛函數(shù)表指針下方有兩個(gè)地址,這兩個(gè)地址分別對(duì)應(yīng)的就是Base中兩個(gè)虛函數(shù)的地址,構(gòu)成了一個(gè)虛函數(shù)表。所以虛函數(shù)表本質(zhì)是一個(gè)指針數(shù)組,數(shù)組中每一個(gè)元素是一個(gè)虛函數(shù)的地址。
VS2019封裝更為嚴(yán)密,在底層的匯編代碼中,虛函數(shù)表中的地址并不一定是虛函數(shù)的地址,可能存放的是跳轉(zhuǎn)到虛函數(shù)的地址的指令的地址。這個(gè)在后面會(huì)加以演示。
因此當(dāng)我們調(diào)用普通函數(shù)和虛函數(shù)時(shí),它們的本質(zhì)是不同的:
Base* bb=nullptr;
bb->f();
bb->Func1();
其中bb調(diào)用f()的過(guò)程沒(méi)有發(fā)生解引用操作,非虛函數(shù)在公共代碼段中,直接對(duì)其進(jìn)行調(diào)用即可。而bb調(diào)用Func1()的過(guò)程中,需要通過(guò)虛表指針來(lái)找到Func1(),而拿到虛表指針需要對(duì)bb進(jìn)行解引用操作,而bb是空,因此程序會(huì)崩潰。
我們知道對(duì)象中只存儲(chǔ)成員變量,成員函數(shù)存儲(chǔ)在公共代碼段中,其實(shí)虛函數(shù)也是一樣存儲(chǔ)在公共代碼段,只不過(guò)尋找虛函數(shù)需要通過(guò)虛表來(lái)確定位置。普通函數(shù)直接就可以確定位置。
2.虛函數(shù)表的繼承–重寫(覆蓋)的原理
還拿上一節(jié)中買票的例子舉例,其中父類中有兩個(gè)虛函數(shù),子類重寫了其中的一個(gè),子類中還有自己的函數(shù)。
class Person
{
public:
virtual void BuyTicket()
{
cout << "全價(jià)" << endl;
}
virtual void Func1()
{
cout << "Func1" << endl;
}
protected:
int _a;
};
class Student :public Person
{
public:
virtual void BuyTicket()
{
cout << "半價(jià)" << endl;
}
virtual void Func2()
{
cout << "Func2" << endl;
}
protected:
int _b;
};
int main()
{
Person a;
Student b;
return 0;
}我們可以通過(guò)調(diào)試來(lái)觀察一下他們的虛表和虛表指針。

顯然父類對(duì)象__vfptr[0]中存放的是BuyTicket的地址,__vfptr[1]中存放的是Func1()的地址。子類對(duì)象中__vfptr[0]中存放的是繼承并重寫的BuyTicket的地址,__vfptr[1]中存放的是繼承下來(lái)但沒(méi)有進(jìn)行重寫的Func1()的地址。通過(guò)對(duì)比我們發(fā)現(xiàn):對(duì)于沒(méi)有進(jìn)行重寫的Func1()來(lái)說(shuō),子類中虛表中的地址和父類中的是一樣的,可以說(shuō)是直接拷貝下來(lái)的。而對(duì)于進(jìn)行了重寫的BuyTicket來(lái)說(shuō),子類中虛表的地址與父類中明顯不一樣,其實(shí)是在拷貝了父類的地址后又進(jìn)行了覆蓋的。因此重寫從底層的角度來(lái)說(shuō)又叫做覆蓋。
同時(shí)我們又發(fā)現(xiàn)了一個(gè)問(wèn)題,那就是子類對(duì)象的虛表中為什么沒(méi)有寫它自己的虛函數(shù)地址Func2()呢?
其實(shí)是寫了的,只不過(guò)通過(guò)VS的監(jiān)視窗口并不能看到,我們可以通過(guò)內(nèi)存來(lái)進(jìn)行觀察:
3.觀察虛表的方法
(1)內(nèi)存觀察

我們可以通過(guò)觀察內(nèi)存來(lái)觀察虛函數(shù)表的情況,這里觀察的是父類對(duì)象,會(huì)發(fā)現(xiàn)在虛函數(shù)指針的地址存放的是父類對(duì)象中兩個(gè)虛函數(shù)的地址。
我們也可以觀察一下子類對(duì)象:

與父類對(duì)象中存儲(chǔ)的相同,唯一有區(qū)別的地方就是紫色的部分,存放的其實(shí)是子類虛函數(shù)Func2()的地址。這說(shuō)明Func2()也在虛表中只不過(guò)在監(jiān)視窗口沒(méi)有看不到而已。
(2)打印虛表
虛表的地址
通過(guò)觀察內(nèi)存,對(duì)于單繼承來(lái)說(shuō),我們只需要打印對(duì)象的首元素的地址即可找到虛表,并進(jìn)行打印。

我們發(fā)現(xiàn)對(duì)象的前四個(gè)字節(jié)存儲(chǔ)的就是虛表的地址??梢酝ㄟ^(guò)這一點(diǎn)來(lái)打印虛表。
我們關(guān)閉一下調(diào)試來(lái)重新寫一下代碼(關(guān)閉調(diào)試后再進(jìn)行運(yùn)行地址會(huì)發(fā)生變化,但是規(guī)律是不變的)
typedef void(*vfptr)();
void Printvfptr(vfptr* table)
{
for (int i = 0; table[i] != nullptr; i++)
{
printf("%d:%p\n",i,table[i]);
}
cout << endl;
}
int main()
{
Person a;
Student b;
Printvfptr((vfptr*)*(void**)&a);
Printvfptr((vfptr*)*(void**)&b);
return 0;
}下面來(lái)解釋一下如何打印的虛表,分為兩部分,一部分是函數(shù),一部分是傳參:
函數(shù)
首先我們明確,虛函數(shù)指針是一個(gè)函數(shù)指針,因此為了簡(jiǎn)便我們可以將函數(shù)指針重命名為vfptr。
通過(guò)接收虛表指針,并依次打印指針數(shù)組中的內(nèi)容(虛函數(shù)的地址)。
傳參
拿父類對(duì)象a舉例,我們要找到a的前四個(gè)字節(jié)的內(nèi)容,即為虛表指針,然后再傳入函數(shù)中。
首先使用(void**)對(duì)a的地址進(jìn)行強(qiáng)制類型轉(zhuǎn)換,這其中發(fā)生了切割。使用(void**)的原因在于,由于不知道是使用的32位還是64位系統(tǒng),但我們可以通過(guò)指針的大小來(lái)判斷。首先將&a轉(zhuǎn)換成一個(gè)指針,再將其轉(zhuǎn)換成一個(gè)指針類型,再進(jìn)行解引用就得到了a的前4或者8個(gè)字節(jié)。但同時(shí)我們需要傳遞的是一個(gè)vfptr類型的函數(shù)指針,所以還需要進(jìn)行(vfptr*)類型的強(qiáng)制轉(zhuǎn)換。
有了前面的解釋,我們就可以理解打印虛表的原理了,我們把這段代碼運(yùn)行一下:

發(fā)現(xiàn)分別打印出了a和b的虛函數(shù)表。
如果打印的虛函數(shù)數(shù)量不對(duì),這是VS編譯器的bug,我們可以重新生成解決方案,再重新運(yùn)行代碼。
(3)虛表的位置
我們還可以觀察一下虛表的位置,在哪個(gè)區(qū)域:
使用其他區(qū)域的變量進(jìn)行對(duì)比:
Person per;
Student std;
int* p = (int*)malloc(4);
printf("堆:%p\n", p);
int a = 0;
printf("棧:%p\n", &a);
static int b = 1;
printf("數(shù)據(jù)段:%p\n", &b);
const char* c = "aaa";
printf("常量區(qū):%p\n", &c);
printf("虛表:%p\n", *(void**)&std);
打印的結(jié)果是:

我們發(fā)現(xiàn)虛表的位置在數(shù)據(jù)段和常量區(qū)之間。大致屬于數(shù)據(jù)段。
4.多態(tài)的底層過(guò)程
class Person
{
public:
virtual void BuyTicket()
{
cout << "全價(jià)" << endl;
}
virtual void Func1()
{
cout << "Func1" << endl;
}
protected:
int _a;
};
class Student :public Person
{
public:
virtual void BuyTicket()
{
cout << "半價(jià)" << endl;
}
virtual void Func2()
{
cout << "Func2" << endl;
}
protected:
int _b;
};
void F(Person& p)
{
p.BuyTicket();
}
int main()
{
Person per;
Student std;
F(per);
F(std);
return 0;
}
我們還使用這一段代碼來(lái)舉例,首先復(fù)習(xí)一下多態(tài):使用父類的指針或者引用去接收子類或者父類的對(duì)象,使用該指針或者引用調(diào)用虛函數(shù),調(diào)用的是父類或子類中不同的虛函數(shù)。
下面來(lái)分析原理:
父類對(duì)象原理:
首先用父類引用p來(lái)接收父類對(duì)象per,此時(shí)p中的虛表和per中的虛表一模一樣,只需要訪問(wèn)__vfptr中的BuyTicket()的地址即可調(diào)用該函數(shù)。
子類對(duì)象的原理:
用p來(lái)接收子類對(duì)象std,發(fā)生切片處理,會(huì)將子類中的虛表內(nèi)容拷貝到父類引用p中,然后再調(diào)用其中的__vfptr中的BuyTicket地址。此時(shí)的p不是新創(chuàng)建了一個(gè)父類對(duì)象,而是子類對(duì)象std切片后構(gòu)成的,其中就將重寫之后的BuyTicket()的地址也隨之切入了p??梢园裵看成原std的包含__vfptr的一部分。
總結(jié):基類的指針或者引用,指向誰(shuí)就去誰(shuí)的虛函數(shù)表中找到對(duì)應(yīng)位置的虛函數(shù)進(jìn)行調(diào)用。
5.幾個(gè)原理性問(wèn)題
了解了多態(tài)原理之后,就可以分析出在上一節(jié)中出現(xiàn)的一些現(xiàn)象規(guī)律。
(1)虛表中函數(shù)是公用的嗎?
虛表中的函數(shù)和類中的普通函數(shù)一樣是放在代碼段的,只是虛函數(shù)還需要將地址存一份到虛表,方便實(shí)現(xiàn)多態(tài)。這也就說(shuō)明同一類型的不同對(duì)象的虛表指針是相同的,我們還可以通過(guò)調(diào)試觀察:
Person per;
Person pper;

(2)為什么必須傳入指針或引用而不能使用對(duì)象?
當(dāng)我們使用父類對(duì)象去接收時(shí),父類對(duì)象本身就具有一個(gè)虛表了,當(dāng)子類對(duì)象傳給父類對(duì)象的時(shí)候,其他內(nèi)容會(huì)發(fā)生拷貝,但是虛表不會(huì),C++這樣處理的原因在于,如果虛表也會(huì)發(fā)生拷貝的話,那么該父類對(duì)象的虛表就存了子類對(duì)象的虛表,這是不合理的。
我們同樣可以通過(guò)調(diào)試來(lái)進(jìn)行觀察:
void F(Person p)
{
p.BuyTicket();
}
int main()
{
Person per;
Student std;
F(std);
}
這是std中的虛表內(nèi)容。

這是p中的虛表內(nèi)容,而且在調(diào)試過(guò)程中,程序是進(jìn)入父類中進(jìn)行調(diào)用函數(shù)的。
(3)為什么私有虛函數(shù)也能實(shí)現(xiàn)多態(tài)?
這是因?yàn)榫幾g器調(diào)用了父類的public接口,由于是父類的引用或者指針,因此編譯器發(fā)現(xiàn)是public之后就不再進(jìn)行檢查了,只要在虛表中可以找到就能調(diào)用函數(shù)。
(4)VS中的虛表中存的是指令地址?
在VS2019中,為了封裝嚴(yán)密,其實(shí)虛表中存入的是跳轉(zhuǎn)指令,我們可以通過(guò)反匯編進(jìn)行觀察:
我們將虛表中的地址輸入反匯編,看到的是這樣的一條語(yǔ)句:

這是一條跳轉(zhuǎn)指令,會(huì)跳轉(zhuǎn)到BuyTicket()的實(shí)際地址處。
6.多繼承中的虛表
談到多繼承就要談到菱形虛擬繼承,這是一個(gè)龐大而復(fù)雜的問(wèn)題,需要更大的大佬來(lái)解釋。
這里只介紹多繼承中虛表的內(nèi)容:
class Base1
{
public:
virtual void Func1()
{
cout << "Func1" << endl;
}
virtual void Func2()
{
cout << "Func2" << endl;
}
protected:
int _a;
};
class Base2
{
public:
virtual void Func3()
{
cout << "Func3" << endl;
}
virtual void Func4()
{
cout << "Func4" << endl;
}
};
class Derive :public Base1, Base2
{
public:
virtual void Func5()
{
cout << "Func5" << endl;
}
};
int main()
{
Derive a;
}
我們可以使用調(diào)試來(lái)觀察a中的虛表內(nèi)容:

通過(guò)調(diào)試我們可以看到a中有兩個(gè)虛表指針?lè)謩e存放的是Base1中虛函數(shù)的地址和Base2中虛函數(shù)的地址,那么a中特有的類Func5()存在哪個(gè)虛表呢?這需要通過(guò)內(nèi)存進(jìn)行觀察:

我們發(fā)現(xiàn)它被存放在了第一個(gè)虛表指針指向的虛表中。
我們知道打印第一個(gè)虛表指針指向虛表的方法,那么第二個(gè)虛表指針的該怎樣進(jìn)行處理呢:
Printvfptr((vfptr*)*(void**)((char*)&a+sizeof(Base1));
注意需要先將&a轉(zhuǎn)換成char*類型,這樣對(duì)其加一,才代表加一個(gè)字節(jié)。
以上就是詳解C++中多態(tài)的底層原理的詳細(xì)內(nèi)容,更多關(guān)于C++多態(tài)原理的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
C++基礎(chǔ)知識(shí)之運(yùn)算符重載詳解
這篇文章主要為大家詳細(xì)介紹了C++基礎(chǔ)知識(shí)之運(yùn)算符重載,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下,希望能夠給你帶來(lái)幫助2022-02-02
詳解C語(yǔ)言中strcpy函數(shù)與memcpy函數(shù)的區(qū)別與實(shí)現(xiàn)
這篇文章主要介紹了C語(yǔ)言中字符串拷貝函數(shù)(strcpy)與內(nèi)存拷貝函數(shù)(memcpy)的不同及內(nèi)存拷貝函數(shù)的模擬實(shí)現(xiàn),感興趣的小伙伴可以跟隨小編一起學(xué)習(xí)一下2022-12-12
Qt如何設(shè)置窗口屏幕居中顯示以及設(shè)置大小
這篇文章主要介紹了Qt如何設(shè)置窗口屏幕居中顯示以及設(shè)置大小的相關(guān)資料,需要的朋友可以參考下2017-01-01
C++報(bào)錯(cuò):Id?returned?1exit?status的解決辦法
最近剛學(xué)c語(yǔ)言,不止一次遇到了同一種報(bào)錯(cuò),經(jīng)過(guò)總結(jié)分享給大家,下面這篇文章主要給大家介紹了關(guān)于C++報(bào)錯(cuò):Id?returned?1exit?status的解決辦法,需要的朋友可以參考下2023-04-04
C語(yǔ)言內(nèi)存函數(shù) memcpy,memmove ,memcmp
這篇文章主要介紹了C語(yǔ)言內(nèi)存函數(shù) memcpy,memmove ,memcmp,文章圍繞主題展開詳細(xì)的內(nèi)容介紹,具有一定的參考價(jià)值,需要的小伙伴可以參考一下2022-09-09
C++ HLSL實(shí)現(xiàn)簡(jiǎn)單的圖像處理功能
本文主要介紹了HLSL實(shí)現(xiàn)簡(jiǎn)單的圖像處理功能的方法,具有很好的參考價(jià)值,下面跟著小編一起來(lái)看下吧2017-02-02

