深入淺出C++:聊一聊虛繼承相關
????各位好,我是回聲,今天我們來聊一聊虛繼承(也叫虛擬繼承)相關的東西。
????相較于不久之前我聊過的移動語義和完美轉(zhuǎn)發(fā),虛繼承這一部分我認為會更加抽象和晦澀,并且在生產(chǎn)之中,它的應用還不是那么廣泛。
??? 虛繼承的存在是為了解決菱形繼承導致的內(nèi)存重復問題,而引發(fā)這一問題的操作則是多重繼承。要知道,多重繼承這種操作在應用十分寬泛的JAVA與C#中甚至是不允許的,這就側(cè)面說明了:咱們真的想解決什么問題其實是完全可以不去使用菱形繼承的。
??? 高級編程語言之所以好用是因為它能夠以更接近人思維的方式來操作內(nèi)存來進行數(shù)學處理,只要你不怕麻煩,其實C語言就已經(jīng)能干幾乎所有事情了,所謂面向?qū)ο缶褪菍γ嫦蜻^程的封裝,在語言機制上沒有OOP的C語言更不存在虛繼承和多重繼承的概念,一切只是一種更便捷的以某種模型或是方式操作內(nèi)存的手段。
??? 之所以這么說是想表達:虛繼承在實際應用中使用的場景少,且需要虛繼承解決的問題也完全可以使用其他手段。
????相較于很多C++的知識點我個人認為它真的是其中一個性價比比較低的概念,我甚至覺得哪怕掌握了它我都不會去使用。就像之前所說,遇到菱形繼承能夠解決的問題時,肯定也有其他的解決方案——謹慎的設計更加合理的類和數(shù)據(jù)結(jié)構(gòu),或是謄寫一些文本量多但能讓開發(fā)者與閱讀者更容易去理解和把握的代碼更加符合我的編碼習慣。
??? 但這并不意味著虛繼承沒有學習的必要。
????C++是一門修煉內(nèi)功的語言,它的設計理念幾乎輻射到了計算機體系的所有領域,透過學習這些特性和設計,更是能助力對計算機科學這門學科的認知。對于虛繼承而言,它是C++編譯器為實現(xiàn)繼承多態(tài)而設計的重要組成部分,它既是編譯器設計思路的冰山一角也是深刻理解C++對象模型的重要一環(huán)。
??? 說到這里,本文將采用我閱讀的<<深度探索C++對象模型>>中學習得到的總結(jié),它是<<C++ Primer>>的作者,Lippman大神的另一本傳世經(jīng)典書籍。
????說實話,如果各位有幸看過我特別尊敬的另一位大神——Scott Meyers的Effective系列,再對比一下Lippman先生寫的書,我估計你會和我一樣的有苦難言。因為Lippman先生真的是從來不把他的讀者當成智障,他描述的知識要更加晦澀和抽象,甚至充滿跳躍性。在經(jīng)過侯捷老師那么一譯制,它的閱讀體驗真的是稱不上愉快(但是侯捷老師譯制的Effective是效果是很好的)。因此,我不能保證我的理解不出現(xiàn)偏差。我只能說,在我有限的實驗以及和前輩朋友們的交流里,我的結(jié)論是可以有助于對虛繼承內(nèi)存理解的,當然,如果您有您自己的見解以及對我的一些觀點有疑問和質(zhì)疑,我歡迎您在評論區(qū)進行禮貌的交流。
????好,那我們從現(xiàn)在,正式開始。
????我在學習虛繼承的時候,從網(wǎng)上找到的資料基本上大同小異。

??? 咱們簡單過一遍菱形繼承在網(wǎng)上普遍都會講到的概念,就是當B和C都同時以沒有修飾的方式繼承自A時,多重繼承B、C的D內(nèi)就會存在兩個A的副本,這就是內(nèi)存重復問題,它將導致程序運行的時候產(chǎn)生一些【在使用A時無法精準確定具體是哪個A】的情況。解決方案很簡單,就是B和C繼承A的時候謄寫虛繼承關鍵字virtual,讓B與C以虛繼承的方式繼承自A,這樣,多重繼承自B、C的D內(nèi),就會只有一份A的實際副本,這樣就不會因為在D中存在復數(shù)個A的副本而導致在使用資源的時候發(fā)生歧義的情況。我們簡單看一下這種情況的代碼1。
????在這段代碼中,我們使用了不一樣的類名,其繼承體系如下所示。

????在其中,我們是用A_Base作為基類,兩個虛繼承的子類分別是A_l1和A_r1,l代表left,r代表right,后面的數(shù)字體現(xiàn)著咱們所謄寫的繼承體系中的繼承深度。實驗的代碼中我使用了先聲明再定義的編碼,為的是各位可以不那么關注于函數(shù)定義,每一個類只有兩個函數(shù),構(gòu)造函數(shù)和一個打印函數(shù),用來輸出相應的信息,其中打印的信息在標注了函數(shù)名稱以外,打印他們基類A_base中的成員變量base的值和地址。當我貼出輸出的截圖的時候,各位大可以不那么關注信息具體是什么,只需要看在整個信息里面有幾個不一樣的地址輸出即可。

????可以看到,無論怎么打印,地址只會存在一個,說明base這個成員變量只存在一個,即A_Base這個基類產(chǎn)生的對象副本在對象a2里只存在一個。
????這段代碼中需要注意,A_2的構(gòu)造函數(shù)中,需要在初始化列表里對A_Base的構(gòu)造函數(shù)進行顯式的調(diào)用,哪怕兩個A_r2和A_l2的構(gòu)造函數(shù)都對如何調(diào)用A_Base的構(gòu)造函數(shù)進行了顯式的定義,但是在菱形繼承的體系里,A_Base的實例只存在一個,從語境的角度上說,沒法說A_l2和A_r2這兩個類哪個對A_Base的構(gòu)造規(guī)則定義擁有更高的權重,那么索性編譯器對它們各自A_Base的構(gòu)造規(guī)則都不使用,而是要求程序員在A_2中顯式定義A_Base的構(gòu)造規(guī)則,否則編譯器會拋出錯誤。
????這段代碼跟網(wǎng)上普遍對虛繼承使用的例子基本相同,區(qū)別主要是因為定義了構(gòu)造函數(shù)后,程序員需要自己在最下層的子類中規(guī)定最上層基類的構(gòu)造規(guī)則。(指在初始化列表里定義調(diào)用的構(gòu)造函數(shù))
????那么,虛繼承是對對象的內(nèi)存進行了什么樣的編制,才能讓基類和諧的在對象內(nèi)存里只存在一個副本呢?這個也是本篇文章重點討論的問題,讓我們在代碼1的基礎上修改一下,改成如下的代碼2。
????代碼的改動看起來會比較麻煩,我用圖來說明一下這段代碼的繼承體系。

????l1與r1(我省略了前面的A_,后面出現(xiàn)此符號都是指A_l1和A_r1兩個類名,如果出現(xiàn)了l2和r2同理)不再虛繼承自A_Base,而是由A_2虛繼承l(wèi)1和r1,結(jié)果輸出會變得大相徑庭。

????輸出之中存在兩個不一樣的地址,說明A_Base實例化的基類對象在a2這個對象中存在兩份副本,菱形繼承出現(xiàn)的內(nèi)存重復問題在這段代碼里得到了復現(xiàn),virtual關鍵字在繼承之中并沒有起到它該有的作用。
????暫且擱置著問題,我們接著看代碼3。
? ? 這段代碼的繼承體系如下圖所示:

????它的繼承體系其實與沒有l(wèi)2和r2是一樣的,核心思路就是一邊虛繼承,另一邊不使用虛繼承。

????代碼3的輸出結(jié)果如下:

????可以看到,a2的內(nèi)存中仍然存在兩個【A_Base產(chǎn)生的對象】的副本,內(nèi)存重復的問題也沒有得到解決。
????原因是這樣的:編譯器為虛基類對象構(gòu)造內(nèi)存的方式與普通基類構(gòu)造內(nèi)存的方式是不一樣的,它需要依托一個重要的【虛基指針】(也叫【虛基表指針】)。
????需要注意,【虛基指針】(vbptr)與【虛表指針】(vfptr)是兩個不一樣的名詞,虛基表和虛表也是兩個不一樣的表。前者主要用于虛繼承,而后者則主要用于多態(tài)的實現(xiàn)。為了方便討論,我沒有為類內(nèi)任何函數(shù)標注virtual關鍵字,類內(nèi)沒有任何虛函數(shù),那么對象實例化的時候它的內(nèi)存中就不會存在【虛表指針】。本文的場景中我們只探討【虛基指針】。
????當咱們在定義一個類時,我們對一個類的所有編輯都會體現(xiàn)在內(nèi)存上。因為不同編譯器對如何分配一個對象的內(nèi)存有不一樣的實現(xiàn),我們暫時不去考慮編譯器是以怎樣的順序去放置對象內(nèi)的數(shù)據(jù)的。就比如,可能編譯器A會把基類放在整個對象的起始內(nèi)存里,而編譯器B則會把基類放在對象的末尾內(nèi)存里。我們也暫時不要考慮內(nèi)存對齊的情況,只需要知道,我們定義的類實例化的對象中,完整的擁有自己的成員數(shù)據(jù),也完整擁有它所繼承的父類對象數(shù)據(jù),我們不考慮它們安置的順序,也不考慮它們的內(nèi)存對齊情況,它們肯定客觀的擺在對象的內(nèi)存中。
????就像這個內(nèi)存簡圖1中,咱們不要考慮順序的對錯以及內(nèi)存對齊的情況,A實例化的對象a中一定有一個B的部分,C的部分以及A自己的成員變量memberA,自然而然,B的部分中是擁有類B的成員memberB的,C的部分中也有memberC。這樣說并不標準,但是意思大致如此。

????如此的內(nèi)存是最基本的繼承體系,但如果某一個繼承的類被用virtual修飾了的話,情況就不一樣了,被virtual修飾的類將不再以傳統(tǒng)的形式存在在對象的內(nèi)存中,而是【虛基指針】+數(shù)據(jù)的形式。

? ? 這個從【虛基指針】指向【B的成員部分】的畫法并不準確,此處以及接下來我用這樣的畫法是為了簡化一下咱們考慮的情況。對于真實的情況而言,其實是【B的虛基指針】指向的【虛基表】記錄了【B的成員部分】在類內(nèi)存中的位置。在下面的一段中這個區(qū)別會有所體現(xiàn)。
????假如class A中的B是虛繼承來的,那么A對象的內(nèi)存中就會出現(xiàn)一個【虛基指針】,這個【虛基指針】指向一個【虛基表】,而【虛基表】則以某種數(shù)學手段記錄著【B實際的成員部分】存放在內(nèi)存中的哪個地方。【虛基表】記錄成員數(shù)據(jù)具體位置的數(shù)學手段無關緊要,這個東西編譯器說了算,它可能維護一個指針,也可能維護一個偏移offset,一般來說是后者。但是總的說,【虛基指針】的存在能夠提供【該虛類產(chǎn)生的對象數(shù)據(jù)實際存放在哪一部分內(nèi)存中】的信息,進而讓計算機找到實際數(shù)據(jù)部分。
????在內(nèi)存簡圖2的基礎上,我們假設C也虛繼承了B,于是就有了內(nèi)存簡圖3。

????????C的部分中,除了擁有C自己的成員數(shù)據(jù)外,擁有一個B的【虛基指針】,這個【虛基指針對應的虛基表】記錄了外面B的成員部分,最終導致該對象可能擁有復數(shù)個【虛基指針】,但實際數(shù)據(jù)只有一份。
?????。?!但是,這樣理解并不準確?。?!
? ? 負責任的說,我使用visual studio的調(diào)試功能打印一下如此設計的類的內(nèi)存構(gòu)造,可以發(fā)現(xiàn)它只擁有一個【虛基指針】,并不存在兩個,而這部分之所以如此的原因我目前沒有從權威的資料中找到說法,只能說有自己的理解。
????將內(nèi)存簡圖3的情況與代碼1進行一下對比,有點類似,但代碼1的內(nèi)存結(jié)構(gòu)能夠明顯的看到存在2個【虛基指針】。
????我猜測一下原因:【虛基指針】是可以合并的。
????畢竟,【虛基指針】指向了【虛基表】,【虛基表】存放著某個虛基類在對象內(nèi)存中的【存儲位置信息】,那么編譯器就可以通過某種算法、某種規(guī)則來擴充【虛基表】的內(nèi)容,最終讓一個【虛基表】可以承載復數(shù)個【虛繼承類對象的位置信息】,而不必在對象中通過添加復數(shù)個【虛基指針】來完成虛繼承的實現(xiàn)。
????要知道64位系統(tǒng)中,一個指針8字節(jié),且內(nèi)存分配【虛基表】的數(shù)量與【被定義的類】的數(shù)量直接相關(而且類不實例化為對象,也就不會分配虛基表的空間),【虛基指針】的數(shù)量卻是與程序中產(chǎn)生對象的數(shù)量直接相關,擴充【虛基表】對內(nèi)存的消耗要遠遠低于在每個對象里放置【虛基指針】的消耗。這個思想感覺與智能指針的設計思路有異曲同工之妙。
????這大概可以理解為,所有虛繼承的部分,都會產(chǎn)生一個坑,這些坑或是反應在【虛基表】里,或是反應在【虛基指針】上,編譯器分析類的定義后,確定了該對象虛基類的成分,在分配內(nèi)存的時候保證被virtual修飾過的父類在內(nèi)存中只占有一份拷貝,而整個繼承體系下該父類出現(xiàn)了多少次則會由【虛基指針】和【虛基表】的內(nèi)容共同體現(xiàn)。
????假如,在內(nèi)存簡圖3的基礎上,C直接繼承自B。就會產(chǎn)生大致如內(nèi)存簡圖4的情況。B的成員部分重復出現(xiàn)在了A實例化的對象中,這也是代碼3所產(chǎn)生的問題,即針對一個類,菱形的兩個分支父類一個采用了虛繼承一個采用了直接繼承所造成的內(nèi)存重復問題。不過如果你設計出來了這樣的代碼,編譯器大概率會給你一個警告,亦或是在編譯時產(chǎn)生錯誤。

????回過頭來看代碼2,因為A_2虛繼承自l1和r1,則A_2內(nèi)的【虛基指針所指向的虛基表】會承載l1和r1兩個對象的偏移,【虛基表】的信息各自引導向r1和l1的實際數(shù)據(jù)位置,而r1和l1各自的內(nèi)存里,就存在著互為重復的Base部分,畢竟,virtual修飾的不是Base,而是A_l1和A_r1。
????文章的最后,讓我們看最后一個情況。
????它的繼承體系如下所示:

? ? 在該繼承體系中,l1虛繼承了Base,而r1沒有,可是最下層的子類A_2則虛繼承了r1。再這樣的情況下,從r1到A_2的Base并沒有被直接使用virtual修飾,那么A_2中的內(nèi)存會是什么樣呢?
????在這樣的情況下,GCC編譯器能夠成功的編譯,而MSVC編譯器則報出了對A_Base訪問不明確的錯誤。

? ? 無論是MSVC的報錯還是GCC的輸出都很明顯的看出,A_Base在A_2中有兩份拷貝,也就是說,通過virtual修飾【子類】并不能保證【父類】在【最終子類】中只存在一份拷貝。
????讓我們看一下visual studio對A_2類輸出的內(nèi)存圖。
? ? 我來為各位簡單解釋一下,首先這個內(nèi)存里只有一個【虛基指針】(vbptr),編譯器依舊使用了【某種咱們暫不需要考慮的算法和機制】將兩個虛基類的信息合并在了一個虛基表上。這一個vbptr指向的【虛基表】記錄著A_Base和A_r1兩個虛基類的位置信息。
????用圖來畫大概是這個樣子的:

????這個問題并非不好理解,我記得好像是Effective系列的某本書上有提到過,讓我們盡可能的不要在類內(nèi)定義【類型轉(zhuǎn)換操作符】,在那里有體現(xiàn)過編譯器面對類型轉(zhuǎn)換操作符的一些機制,就比如如下代碼:
????在main函數(shù)中的函數(shù)調(diào)用中,SomFuncB的調(diào)用沒有問題,而SomeFuncC的調(diào)用則會產(chǎn)生錯誤。那是因為當傳入?yún)?shù)為TypeA類型的a時,編譯器會查看類內(nèi)TypeA的定義中有沒有【類型轉(zhuǎn)換操作符】的定義,它擁有轉(zhuǎn)向TypeB的定義,則SomeFuncB的調(diào)用便可以成功,可是SomeFuncC不行,哪怕從咱們的角度上說,TypeA轉(zhuǎn)向TypeB后,TypeB能夠轉(zhuǎn)向TypeC,但是編譯器無法進行二階的類型轉(zhuǎn)換。
????想想看,這其實完全可以理解,如果編譯器能支持多階的類型轉(zhuǎn)換操作,那么意味著編譯器的實現(xiàn)會產(chǎn)生一個可以無視展開層數(shù)的遞歸邏輯,這固然會降低編譯器在編譯時的性能。哪怕這個遞歸邏輯最終實現(xiàn)了,但是在你的目標代碼里,也會存在一個龐大量的遞歸函數(shù)調(diào)用,讓目標程序的性能大打折扣。所以自然而然,細化這部分的設計的責任理應落在程序員的身上而非編譯器上,否則就會加重C++歷史上的一個通?。壕幾g器干了很多程序員不知道的事情,而這更是絕絕對對的負面操作。
????好,讓我們回過頭來看虛繼承這部分,編譯器的設計與【類型轉(zhuǎn)換操作符】其實是有異曲同工之妙的,即編譯器只保證有【virtual直接修飾的類】不會重復出現(xiàn)在【最終子類】的內(nèi)存中,并不能保證【virtual直接修飾的類的某一個父類】不會重復出現(xiàn)在【最終子類】的內(nèi)存里,否則那也是需要在編譯器中實現(xiàn)一個遞歸查看【虛繼承類中父類】的功能,這不難想象是一個低效的解決方案。
????到此為止,有關虛繼承的講解就結(jié)束了,在這篇文中我書寫了我自己對于虛繼承機制的理解,我不敢百分百的確定說它是準確的,我建議各位也可以通過代碼以及visual studio的調(diào)試工具自己做實驗,我歡迎各位與我一同溝通和辯論。
????當然,在虛繼承的體系上,如果考慮到多態(tài)和【虛表】,內(nèi)存的模型便可能更加復雜,在沒有實際準備著一部分的情況下,我就不單獨展開了。但其實,【虛表】與【虛基指針】的存在我認為是可以分開來看待以簡化相應的內(nèi)存模型的,如果有一天我有靈感,我再嘗試著書寫這一部分的內(nèi)容。
????十分感謝您的閱讀,歡迎您在評論區(qū)留下自己的注解與感受,祝愿您在新的一年里生活更加美好,朋友們,我們有機會再見。