大家好!上一篇文章,主要是說了多态的概念和使用。這篇文章就會說一下多态的底層原理,如果對多态的使用和概念不清的可以看一下上篇文章(多态概念)。

文章目錄
- 1. 多态的原理
-
- 1.1 虛函數表
- 1.2 多态的原理
- 1.3 動态綁定與靜态綁定
- 2. 多繼承關系的虛函數表
- 3. 一些其餘問題
1. 多态的原理
1.1 虛函數表
首先,我們先看下面的例子:
可能有的同學會很疑惑,成員函數不是不在類裡面嗎?為什麼這裡不是4個位元組,而是8個位元組。我們調試來看一下:
除了_a成員,還多一個__vfptr放在對象的前面(注意有些平台可能會放到對象的最後面,這個跟平台有關),對象中的這個指針我們叫做虛函數表指針(v代表virtual,f代表function),其實是一個指針數組,裡面存的是虛函數指針。一個含有虛函數的類中都至少都有一個虛函數表指針,因為虛函數的位址要被放到虛函數表中,虛函數表也簡稱虛表。
那麼派生類中這個表放了些什麼呢?我們接着往下分析。
從上圖,我們可以看出:
1. 派生類對象中也有一個虛表指針,派生類對象由兩部分構成,一部分是父類繼承下來的成員,一部分是自己的成員。
子類和父類間Func2函數的位址是一樣的,但是Func1函數的位址是不一樣的,原因是子類把Func1虛函數重寫了。
2. 基類對象和派生類對象虛表是不一樣的,Func1完成了重寫,是以子類的虛表中存的是重寫的B::Func1,是以虛函數的重寫也叫作覆寫。覆寫就是指虛表中虛函數的覆寫。
重寫是文法的叫法(派生類對繼承基類虛函數實作進行了重寫),覆寫是原理層的叫法(子類的虛表,拷貝父類的虛表進行修改,覆寫那個虛函數)。
3.另外Func2繼承下來後是虛函數,是以放進了虛表,Func3也繼承下來了,但是不是虛函數,是以不會放進虛表。
4. 虛函數表本質是一個存虛函數指針的指針數組,一般情況這個數組最後面放了一個nullptr。
總結一下派生類的虛表生成:a.先将基類中的虛表内容拷貝一份到派生類虛表中 b.如果派生類重寫了基類中某個虛函數,用派生類自己的虛函數覆寫虛表中基類的虛函數。
派生類自己新增加的虛函數放在哪裡呢?
我們調試來看一下:
你會發現找不到虛函數Func3,我們可以看一下記憶體視窗:
我們可以看到第三個是有一個位址的,但是我們不确定它是不是Func3。我們可以這樣去确定:取記憶體值,列印并調用,确認是否是func3
那麼我們該如何找到這個虛表呢?
首先,為了找到這個虛表,我們必須先找到vfptr。而這個vfptr是一個指向函數數組的指針。那麼現在就有一個問題:如何去取出這個位址呢?
因為這個vfptr位址在vs編譯器下是在類裡面首位,而且位址是4個位元組,我們可以先這樣:
B b;
(int*)&b)
這樣的話我們就能取出4個位元組,但這裡我們不能強轉成int,因為B類型和int類型沒有任何關系。但位址間它們都是一串數字,是可以互相強轉的。
然後我們在解引用:
B b;
*((int*)&b)
這樣就能取出4個位元組了。但是它解引用是一個int,我們需要再次強轉成函數數組指針類型。
當我們取到這個vfptr(函數指針數組),我們就可以列印這個虛表了。由于在VS下數組最後面放了一個nullptr,我們可以這樣:
需要說明的是這個列印虛表的代碼經常會崩潰,因為編譯器有時對虛表的處理不幹淨,虛表最後面沒有放nullptr,導緻越界,這是編譯器的問題。我們隻需要點目錄欄的-生成-清了解決方案,再編譯就好了。
然後,有了函數位址,我們再去調用一下:
然後,我們來看一下結果:
是以,在VS編譯器監視視窗下,它是優化過的現象,有點不準确。因為Func3沒有形成多态,它就沒有顯示出來。但通過驗證,虛函數一定是放在虛表裡的。
可能有的同學會問:虛函數存在哪的?虛表存在哪的?
虛函數存在虛表,虛表存在對象中。注意上面的回答的錯的。
注意虛表存的是虛函數指針,不是虛函數,虛函數和普通函數一樣的,都是存在代碼段的,隻是它的指針存到了虛表中。另外對象中存的不是虛表,存的是虛表指針。而虛表是存在代碼段的。
我們可以來驗證一下:
這裡的虛函數位址,首先函數名就是函數位址,我們需要指定類域,然後要加上這個&,這是規定。從上圖可以看出,虛表和虛函數比較接近代碼段。
1.2 多态的原理
那麼多态又是如何實作的呢?
我們來看一下這個運作結果:
虛函數的調用産生了多态的效果,而普通函數的調用隻調用基類的函數。這是為什麼呢?
原因是:
多态調用:運作時決議——運作時,去指向的對象虛表中确定調用函數的位址。
普通調用:編譯時決議——編譯時,确定調用函數的位址。
我們可以看一下反彙編:
看出滿足多态以後的函數調用,不是在編譯時确定的,是運作起來以後到對象的中取找的。不滿足多态的函數調用時編譯時确認好的。
那麼引用行不行呢?
引用也可以實作多态。
那麼對象指派也可以切片,為什麼不能形成多态呢?
原因是:對象切片的時候,子類隻會拷貝成員給父類對象,不會拷貝虛表指針。
如果拷貝的話,會怎麼樣呢?
那麼子類和父類都會指向子類的虛表。如果我們在遇到這樣的代碼:
此時,ptr去找Func1,它就不知道找的是父類的虛函數,還是找子類的虛函數了。就會發生混亂了。是以,它不允許對象指派實作多态。
1.3 動态綁定與靜态綁定
1. 靜态綁定又稱為前期綁定(早綁定),在程式編譯期間确定了程式的行為,也稱為靜态多态。比如:函數重載,模闆。
2. 動态綁定又稱後期綁定(晚綁定),是在程式運作期間,根據具體拿到的類型确定程式的具體行為,調用具體的函數,也稱為動态多态。
2. 多繼承關系的虛函數表
上面我們已經說過單繼承關系的虛函數表,下面我們就說一下多繼承的虛函數表吧。
看下面的例子:
因為是多繼承,那麼肯定會繼承Base1一個,Base2一個。然後我們看一下調試結果:
我們可以看到,是存在兩個Base,每個Base都存在一個虛表。但現在有一個問題:子類的Func3存在那個虛表裡呢?
我們現在需要把兩個虛表裡的内容都列印出來:那麼Base1我們已經會列印了,Base2我們該如何列印呢?
這裡我們不能直接加8個位元組,因為可能會有記憶體對齊啥的。我們可以直接加一個sizeof(Base1)。
但是這樣還不行,因為&d加1是加上整個d,我們需要強轉成char*類型。
然後我們在看一下運作結果:
可以看到多繼承派生類的未重寫的虛函數放在第一個繼承基類部分的虛函數表中
但是這裡存在一個問題:
Func1函數的位址不一樣。原因是可能不是真正的函數,它是被封裝過的。
這裡我們用printf,因為cout不能識别函數指針,是以無法配對。但是,我們發現這裡的函數位址和兩個都不一樣。這是為什麼呢?
原因是:在VS下,它進行了一些封裝,我們看到的虛表裡的位址,并不是真正的函數位址,而是另外一些指令的位址,而通過這些調用指令去完成這個函數的内容,可能都不會直接去調用函數位址去使用。至于到底為什麼這樣做,很複雜,感興趣可以研究。
3. 一些其餘問題
1. inline函數可以是虛函數嗎?
可以,不過在多态調用時,編譯器就忽略inline屬性,這個函數就不再是inline,因為虛函數要放到虛表中去
注意:一定是在多态調用時,才會忽略内聯函數,其它情況下調用都還是内聯。
2. 靜态成員可以是虛函數嗎?
不能,因為靜态成員函數沒有this指針,使用類型::成員函數的調用方式無法通路虛函數,是以靜态成員函數無法放進虛函數表。
3. 構造函數可以是虛函數嗎?
不能,因為對象中的虛函數表指針是在構造函數初始化清單階段才初始化的。
注意:虛表是在編譯的時候就出現了,但是虛函數表指針還是一個随機值。而虛函數的意義是多态,多态調用時到虛函數表裡找,但構造函數之前還沒初始化,如何去找呢?
4. 析構函數可以是虛函數嗎?
可以,并且最好把基類的析構函數定義成虛函數。
5. 對象通路普通函數快還是虛函數更快?
首先如果是普通對象,是一樣快的。如果是指針對象或者是引用對象,則調用的普通函數快,因為構成多态,運作時調用虛函數需要到虛函數表中去查找。
6. 虛函數表是在什麼階段生成的,存在哪的?
虛函數表是在編譯階段就生成的,一般情況下存在代碼段(常量區)的。
7. C++菱形繼承的問題?虛繼承的原理?
注意這裡不要把虛函數表和虛基表搞混了。虛函數表存的是虛函數位址是為了實作多态,虛基表存的是偏移量是為了解決資料備援和二義性。