據(jù)冗余到虛繼承解決方案)
1. 項(xiàng)目概述為什么菱形繼承是C面試的“常客”如果你在準(zhǔn)備C面試或者正在學(xué)習(xí)C面向?qū)ο缶幊棠敲础傲庑卫^承”這個(gè)詞你大概率繞不過去。它就像一個(gè)經(jīng)典的“面試官快樂題”既能考察你對(duì)繼承機(jī)制的理解深度又能檢驗(yàn)?zāi)銓?duì)內(nèi)存布局、虛函數(shù)表等底層概念的掌握程度。我第一次在項(xiàng)目里踩到這個(gè)坑是在設(shè)計(jì)一個(gè)圖形編輯器的類層次結(jié)構(gòu)時(shí)當(dāng)時(shí)為了復(fù)用代碼讓一個(gè)Circle類同時(shí)繼承了Shape圖形和Drawable可繪制兩個(gè)基類而這兩個(gè)基類又都繼承自同一個(gè)Object對(duì)象基類。編譯沒問題但運(yùn)行時(shí)對(duì)象的內(nèi)存大小和虛函數(shù)調(diào)用出現(xiàn)了詭異的行為調(diào)試了半天才定位到是菱形繼承導(dǎo)致的數(shù)據(jù)冗余和二義性問題。簡單來說菱形繼承描述的是這樣一種類關(guān)系一個(gè)派生類通過兩條或以上的路徑最終繼承自同一個(gè)基類形成了一個(gè)像鉆石菱形一樣的繼承圖譜。這聽起來像是代碼復(fù)用的“捷徑”但實(shí)際上它引入了C對(duì)象模型中兩個(gè)最棘手的問題數(shù)據(jù)冗余和二義性。理解并解決這兩個(gè)問題是掌握C多重繼承和虛繼承的關(guān)鍵。本文將從實(shí)際案例出發(fā)掰開揉碎地講解菱形繼承的原理、問題、以及C提供的解決方案——虛繼承并分享一些在大型項(xiàng)目中處理復(fù)雜繼承關(guān)系的實(shí)戰(zhàn)心得。2. 菱形繼承的核心問題與原理拆解2.1 一個(gè)典型的菱形繼承案例讓我們用一個(gè)更貼近業(yè)務(wù)的例子來具象化這個(gè)問題。假設(shè)我們?cè)陂_發(fā)一個(gè)游戲里面有各種角色。我們?cè)O(shè)計(jì)了一個(gè)Character角色基類它有一個(gè)name_成員和一個(gè)printInfo()函數(shù)。class Character { public: std::string name_; Character(const std::string name) : name_(name) {} void printInfo() { std::cout Character: name_ std::endl; } };接著我們派生出兩個(gè)中間類FlyingCharacter飛行角色和FightingCharacter戰(zhàn)斗角色它們分別添加了飛行高度和戰(zhàn)斗力的屬性。class FlyingCharacter : public Character { public: int flyHeight_; FlyingCharacter(const std::string name, int height) : Character(name), flyHeight_(height) {} }; class FightingCharacter : public Character { public: int combatPower_; FightingCharacter(const std::string name, int power) : Character(name), combatPower_(power) {} };現(xiàn)在我們想創(chuàng)建一個(gè)既會(huì)飛行又會(huì)戰(zhàn)斗的角色比如“龍”很自然地會(huì)讓Dragon類同時(shí)繼承FlyingCharacter和FightingCharacter。class Dragon : public FlyingCharacter, public FightingCharacter { public: Dragon(const std::string name, int height, int power) : FlyingCharacter(name, height), FightingCharacter(name, power) {} };至此一個(gè)標(biāo)準(zhǔn)的菱形繼承結(jié)構(gòu)就形成了Dragon-FlyingCharacter-Character 同時(shí)Dragon-FightingCharacter-Character。Character基類在Dragon對(duì)象中出現(xiàn)了兩次。2.2 問題一數(shù)據(jù)冗余與內(nèi)存浪費(fèi)這是最直接的問題。由于Character在繼承路徑上出現(xiàn)了兩次Dragon對(duì)象中將包含兩份Character子對(duì)象的副本也就是兩份name_成員。這不僅浪費(fèi)內(nèi)存更重要的是邏輯錯(cuò)誤一條龍不應(yīng)該有兩個(gè)名字。我們可以用sizeof運(yùn)算符和內(nèi)存地址來驗(yàn)證Dragon dragon(Smaug, 1000, 999); std::cout Size of Dragon: sizeof(dragon) std::endl; // 輸出會(huì)比預(yù)期大 // 嘗試打印兩個(gè)基類子對(duì)象的地址 FlyingCharacter* flyPtr dragon; FightingCharacter* fightPtr dragon; std::cout FlyingCharacter subobject address: static_castvoid*(flyPtr) std::endl; std::cout FightingCharacter subobject address: static_castvoid*(fightPtr) std::endl; // 你會(huì)發(fā)現(xiàn)這兩個(gè)地址不同它們各自指向自己繼承路徑上的那個(gè)Character副本。注意在涉及多重繼承時(shí)將派生類指針轉(zhuǎn)換為不同基類指針編譯器可能會(huì)進(jìn)行地址偏移調(diào)整this指針。上面代碼中flyPtr和fightPtr的值不同正是因?yàn)樗鼈冎赶虻氖荄ragon對(duì)象中不同的子對(duì)象起始位置。2.3 問題二訪問的二義性當(dāng)你想通過Dragon對(duì)象訪問Character的成員時(shí)編譯器會(huì)“懵掉”因?yàn)樗恢滥阒傅氖菑腇lyingCharacter這條線下來的name_還是從FightingCharacter那條線下來的name_。Dragon dragon(Smaug, 1000, 999); // dragon.name_ “NewName”; // 編譯錯(cuò)誤對(duì)成員‘name_’的請(qǐng)求不明確 dragon.printInfo(); // 編譯錯(cuò)誤對(duì)成員‘printInfo’的請(qǐng)求不明確編譯器報(bào)錯(cuò)信息類似于“request for member ‘name_’ is ambiguous”。為了解決這個(gè)編譯錯(cuò)誤你必須顯式地指定訪問路徑dragon.FlyingCharacter::name_ “NewName_Fly”; dragon.FightingCharacter::name_ “NewName_Fight”; dragon.FlyingCharacter::printInfo(); // 輸出 Character: NewName_Fly dragon.FightingCharacter::printInfo(); // 輸出 Character: NewName_Fight這顯然不是我們想要的。我們只希望Dragon有一個(gè)統(tǒng)一的name_。這種二義性在函數(shù)調(diào)用、賦值等任何涉及基類成員的操作中都會(huì)出現(xiàn)使得代碼冗長且極易出錯(cuò)。3. 解決方案虛繼承與虛基類C為了解決菱形繼承帶來的數(shù)據(jù)冗余和二義性問題引入了虛繼承的機(jī)制。其核心思想是讓最終派生類如Dragon只包含一份共享的基類子對(duì)象無論這個(gè)基類在繼承路徑上被聲明了多少次。3.1 語法與聲明虛繼承的語法是在繼承時(shí)使用virtual關(guān)鍵字。我們需要在可能成為“菱形腰部”的中間類即直接繼承公共基類的類的繼承聲明中做出修改。修改我們的例子class Character { /* 保持不變 */ }; // 使用虛繼承 class FlyingCharacter : virtual public Character { // 注意 virtual 關(guān)鍵字 public: int flyHeight_; FlyingCharacter(const std::string name, int height) : Character(name), flyHeight_(height) {} }; class FightingCharacter : virtual public Character { // 注意 virtual 關(guān)鍵字 public: int combatPower_; FightingCharacter(const std::string name, int power) : Character(name), combatPower_(power) {} }; class Dragon : public FlyingCharacter, public FightingCharacter { public: Dragon(const std::string name, int height, int power) : Character(name), // 關(guān)鍵點(diǎn)虛基類由最終派生類直接初始化 FlyingCharacter(name, height), FightingCharacter(name, power) {} };這里有幾個(gè)至關(guān)重要的變化FlyingCharacter和FightingCharacter現(xiàn)在是以virtual public的方式繼承Character。Character因此被稱為虛基類。在最終派生類Dragon的構(gòu)造函數(shù)初始化列表中必須直接調(diào)用虛基類Character的構(gòu)造函數(shù)。即使FlyingCharacter和FightingCharacter的構(gòu)造函數(shù)也嘗試初始化Character但在虛繼承體系下這些對(duì)虛基類的初始化調(diào)用在最終派生類的構(gòu)造函數(shù)中被忽略。這確保了Character只被構(gòu)造一次。如果Dragon不顯式初始化Character那么Character將使用其默認(rèn)構(gòu)造函數(shù)。如果Character沒有默認(rèn)構(gòu)造函數(shù)編譯器會(huì)報(bào)錯(cuò)。3.2 虛繼承如何解決問題應(yīng)用虛繼承后之前的問題迎刃而解數(shù)據(jù)冗余消失Dragon對(duì)象中現(xiàn)在只包含一份Character子對(duì)象。FlyingCharacter和FightingCharacter子對(duì)象內(nèi)部會(huì)包含一個(gè)指針通常是虛基類表指針vbptr指向共享的Character部分。二義性消失因?yàn)閚ame_和printInfo()在內(nèi)存中只有一份實(shí)體通過Dragon對(duì)象訪問它們不再有任何歧義。Dragon dragon(Smaug, 1000, 999); dragon.name_ “Glaurung”; // 正確無二義性 dragon.printInfo(); // 正確輸出 Character: Glaurung std::cout “Size with virtual inheritance: “ sizeof(dragon) std::endl; // 大小會(huì)比非虛繼承時(shí)小省去了一份name_的開銷但可能會(huì)多出虛基類表指針的開銷。3.3 底層原理淺析虛基類表指針理解虛繼承有必要稍微深入一下其實(shí)現(xiàn)機(jī)制這對(duì)面試和調(diào)試很有幫助。編譯器通常會(huì)為每個(gè)含有虛基類的類生成一個(gè)虛基類表。該類的對(duì)象中會(huì)包含一個(gè)額外的指針——虛基類表指針?biāo)赶蜻@個(gè)表。這個(gè)表里存儲(chǔ)了什么主要是該對(duì)象的各個(gè)虛基類子對(duì)象相對(duì)于該指針?biāo)谖恢玫钠屏俊.?dāng)我們需要訪問虛基類的成員如dragon.name_時(shí)首先通過對(duì)象的虛基類表指針找到虛基類表。然后從表中查到Character子對(duì)象相對(duì)于當(dāng)前Dragon對(duì)象this指針的偏移量。最后通過this指針加上這個(gè)偏移量定位到唯一的Character子對(duì)象進(jìn)而訪問其成員。這也是為什么虛繼承會(huì)帶來一定的運(yùn)行時(shí)開銷多一次間接尋址和空間開銷每個(gè)對(duì)象多一個(gè)指針。在內(nèi)存布局上共享的虛基類子對(duì)象通常被放置在派生類對(duì)象的末尾。實(shí)操心得不要濫用虛繼承。它解決了菱形繼承問題但增加了對(duì)象模型復(fù)雜性和運(yùn)行時(shí)開銷。在設(shè)計(jì)類體系時(shí)優(yōu)先考慮使用組合has-a而非繼承is-a來復(fù)用代碼。如果必須使用繼承應(yīng)仔細(xì)審視是否真的需要多重繼承以及是否形成了菱形結(jié)構(gòu)。很多情況下通過重新設(shè)計(jì)類層次例如將公共部分抽離成另一個(gè)類然后讓需要它的類包含一個(gè)實(shí)例或指針可以完全避免菱形繼承。4. 虛繼承的陷阱與實(shí)戰(zhàn)注意事項(xiàng)虛繼承并非銀彈它引入了一些新的復(fù)雜性和容易踩坑的地方。4.1 構(gòu)造與析構(gòu)順序在含有虛繼承的復(fù)雜層次中構(gòu)造和析構(gòu)順序有嚴(yán)格規(guī)定且與普通多重繼承不同虛基類優(yōu)先所有虛基類按照它們?cè)诶^承圖中出現(xiàn)的深度優(yōu)先、從左到右的順序被構(gòu)造。而且它們只被最終派生類構(gòu)造一次。非虛基類其次然后非虛基類按照聲明的順序被構(gòu)造。成員對(duì)象接著類自身的成員對(duì)象按照聲明的順序被構(gòu)造。最終派生類自身最后執(zhí)行最終派生類構(gòu)造函數(shù)的函數(shù)體。析構(gòu)順序完全相反。在我們的Dragon例子中構(gòu)造順序是Character虛基類 -FlyingCharacter非虛基類 -FightingCharacter非虛基類 -Dragon的成員本例無 -Dragon構(gòu)造函數(shù)體。常見坑點(diǎn)如果虛基類的構(gòu)造函數(shù)依賴于某個(gè)非虛基類或成員對(duì)象的狀態(tài)那么程序?qū)⒊霈F(xiàn)未定義行為因?yàn)樘摶悩?gòu)造時(shí)那些依賴對(duì)象尚未被初始化。這種設(shè)計(jì)本身就是有問題的需要從架構(gòu)上避免。4.2 類型轉(zhuǎn)換與指針偏移由于虛繼承改變了對(duì)象的內(nèi)存布局指針轉(zhuǎn)換變得更加微妙。使用static_cast或dynamic_cast在菱形繼承層次中進(jìn)行向上、向下或橫向轉(zhuǎn)換時(shí)編譯器可能需要做復(fù)雜的指針偏移計(jì)算。Dragon dragon; Character* charPtr dragon; // 正確向上轉(zhuǎn)換到虛基類 FlyingCharacter* flyPtr charPtr; // 錯(cuò)誤不能直接從虛基類指針向下轉(zhuǎn)換到派生類除非使用dynamic_cast且基類有多態(tài)性 FlyingCharacter* flyPtr2 static_castFlyingCharacter*(dragon); // 正確編譯器知道如何計(jì)算偏移注意dynamic_cast在涉及虛繼承的跨繼承分支轉(zhuǎn)換即“橫向”轉(zhuǎn)換如從FlyingCharacter*轉(zhuǎn)FightingCharacter*時(shí)非常有用但它要求基類至少有一個(gè)虛函數(shù)以擁有RTTI信息。4.3 與虛函數(shù)的交互虛繼承和虛函數(shù)是兩個(gè)獨(dú)立的概念但經(jīng)常一起使用。當(dāng)一個(gè)類同時(shí)擁有虛函數(shù)和虛基類時(shí)其對(duì)象可能包含兩個(gè)指針虛函數(shù)表指針和虛基類表指針。這進(jìn)一步增加了對(duì)象的開銷。如果虛基類本身含有虛函數(shù)那么情況會(huì)怎樣這通常是安全的并且是常見的設(shè)計(jì)。最終派生類會(huì)覆蓋虛基類中的虛函數(shù)并且由于虛基類只有一份所以通過任何路徑調(diào)用該虛函數(shù)都會(huì)解析到最終派生類的覆蓋版本行為是一致的。class Character { public: virtual void attack() { std::cout “Character attacks!” std::endl; } virtual ~Character() {} // 虛析構(gòu)函數(shù)重要 }; class Dragon : public FlyingCharacter, public FightingCharacter { public: void attack() override { std::cout “Dragon breathes fire!” std::endl; } }; Dragon dragon; Character* c dragon; FlyingCharacter* f dragon; c-attack(); // 輸出Dragon breathes fire! f-Character::attack(); // 仍然輸出Dragon breathes fire! (通過FlyingCharacter路徑調(diào)用虛函數(shù))重要提示在有多態(tài)性的繼承體系中即基類有虛函數(shù)基類的析構(gòu)函數(shù)必須聲明為虛函數(shù)。這在菱形虛繼承中同樣至關(guān)重要以確保通過基類指針刪除派生類對(duì)象時(shí)整個(gè)對(duì)象能被正確且完整地析構(gòu)。5. 替代方案與設(shè)計(jì)模式探討雖然虛繼承提供了解決方案但在現(xiàn)代C設(shè)計(jì)和大型項(xiàng)目中工程師們往往傾向于尋找更清晰、耦合度更低的替代方案以避免菱形繼承的復(fù)雜性。5.1 組合優(yōu)于繼承這是最根本的準(zhǔn)則。重新審視“龍”的例子Flying飛行能力和Fighting戰(zhàn)斗能力是否一定要用“是一個(gè)is-a”的關(guān)系來表達(dá)用“有一個(gè)has-a”可能更合適。class FlyCapability { /* 封裝飛行相關(guān)屬性和行為 */ }; class FightCapability { /* 封裝戰(zhàn)斗相關(guān)屬性和行為 */ }; class Dragon : public Character { private: FlyCapability flyAbility_; FightCapability fightAbility_; public: // ... 通過成員對(duì)象調(diào)用相關(guān)功能 };這種方式完全避免了繼承的菱形問題提高了代碼的模塊化和可測試性。FlyCapability和FightCapability可以獨(dú)立開發(fā)、測試并被其他類復(fù)用。5.2 使用接口純虛類另一種常見做法是使用只包含純虛函數(shù)的抽象類作為接口。C中沒有直接的interface關(guān)鍵字但可以通過包含所有純虛函數(shù)和虛析構(gòu)函數(shù)的類來模擬。類可以實(shí)現(xiàn)多個(gè)接口而接口通常不包含數(shù)據(jù)成員從而避免了數(shù)據(jù)冗余和二義性。class IFlyable { // 飛行接口 public: virtual void fly() 0; virtual ~IFlyable() default; }; class IFightable { // 戰(zhàn)斗接口 public: virtual void fight() 0; virtual ~IFightable() default; }; class Dragon : public Character, public IFlyable, public IFightable { public: void fly() override { /* 實(shí)現(xiàn)飛行 */ } void fight() override { /* 實(shí)現(xiàn)戰(zhàn)斗 */ } };這種方式下Dragon實(shí)現(xiàn)了多個(gè)接口但接口本身沒有狀態(tài)數(shù)據(jù)成員因此即使這些接口都繼承自某個(gè)共同的基接口形成菱形只要那個(gè)基接口也是純虛的、無狀態(tài)的問題就不大。但若公共基接口有狀態(tài)則又可能回到需要虛繼承的老路。5.3 重新設(shè)計(jì)類層次有時(shí)菱形繼承的出現(xiàn)意味著類層次設(shè)計(jì)可以優(yōu)化。例如是否可以將FlyingCharacter和FightingCharacter中的公共部分進(jìn)一步上移或下移或者將Character中的某些屬性拆分到更細(xì)粒度的組件中一個(gè)思考方向是Flying和Fighting是不是Character的一種屬性或技能而非一種“是”的關(guān)系如果是那么使用組件化設(shè)計(jì)類似于游戲開發(fā)中的ECS架構(gòu)思想會(huì)更靈活。6. 面試常見問題與排查技巧實(shí)錄菱形繼承是C八股文中的經(jīng)典題目。下面整理了幾個(gè)高頻問題和實(shí)戰(zhàn)中排查相關(guān)Bug的技巧。6.1 面試高頻問題速查什么是菱形繼承它會(huì)導(dǎo)致什么問題答菱形繼承指一個(gè)類通過多條路徑繼承自同一個(gè)基類。會(huì)導(dǎo)致數(shù)據(jù)冗余多份基類子對(duì)象和訪問二義性編譯器無法確定使用哪份數(shù)據(jù)。C如何解決菱形繼承問題答使用虛繼承。在中間類的繼承聲明中使用virtual關(guān)鍵字使得最終派生類只包含一份共享的虛基類子對(duì)象。虛繼承下構(gòu)造函數(shù)的調(diào)用順序有什么特別之處答虛基類的構(gòu)造函數(shù)由最終派生類直接調(diào)用且優(yōu)先于所有非虛基類的構(gòu)造函數(shù)執(zhí)行。這確保了虛基類只被初始化一次。虛繼承有什么缺點(diǎn)答增加了對(duì)象模型復(fù)雜度引入了額外的空間開銷虛基類表指針和時(shí)間開銷通過指針間接訪問虛基類成員。同時(shí)構(gòu)造函數(shù)初始化規(guī)則變得更復(fù)雜。如何避免使用菱形繼承答遵循“組合優(yōu)于繼承”的原則使用接口純虛類來定義行為重新審視類設(shè)計(jì)看是否能用更扁平化的層次結(jié)構(gòu)或組件化設(shè)計(jì)來替代。6.2 實(shí)戰(zhàn)調(diào)試與問題排查當(dāng)項(xiàng)目中疑似出現(xiàn)菱形繼承相關(guān)Bug時(shí)可以按以下步驟排查確認(rèn)繼承結(jié)構(gòu)使用IDE的類圖工具或手動(dòng)畫出繼承關(guān)系圖檢查是否存在菱形路徑。檢查編譯錯(cuò)誤如果遇到“request for member ‘xxx’ is ambiguous”錯(cuò)誤這幾乎是菱形繼承的典型標(biāo)志。立即檢查相關(guān)類的繼承關(guān)系。使用調(diào)試器查看內(nèi)存布局在GDB或LLDB中對(duì)于可疑對(duì)象使用p /x object以十六進(jìn)制打印或x /[長度]xb object查看內(nèi)存命令。觀察對(duì)象起始部分看是否有重復(fù)的相似內(nèi)存模式可能是重復(fù)的基類成員。對(duì)于有虛繼承的類觀察是否存在多個(gè)vptr/vbptr。驗(yàn)證對(duì)象大小在代碼中打印sizeof(MyClass)。如果對(duì)象大小遠(yuǎn)大于你根據(jù)成員變量估算的大小很可能包含了多余的基類子對(duì)象。審查構(gòu)造函數(shù)初始化列表在虛繼承體系中如果最終派生類沒有正確初始化虛基類或者非虛基類試圖初始化虛基類都可能引發(fā)問題。確保只有最終派生類直接初始化虛基類。謹(jǐn)慎使用類型轉(zhuǎn)換在調(diào)試與多重繼承、虛繼承相關(guān)的指針問題時(shí)記錄下不同視圖不同基類指針下的地址值。理解它們之間的偏移量。使用dynamic_cast在支持RTTI的情況下進(jìn)行安全的跨分支轉(zhuǎn)換并檢查返回值是否為nullptr。我個(gè)人在調(diào)試一個(gè)大型遺留代碼庫的崩潰問題時(shí)曾遇到一個(gè)棘手的案例。崩潰發(fā)生在通過一個(gè)Character*指針調(diào)用虛函數(shù)時(shí)。最終發(fā)現(xiàn)在一個(gè)復(fù)雜的菱形繼承層次中有一個(gè)中間類的構(gòu)造函數(shù)沒有正確傳遞參數(shù)給虛基類導(dǎo)致共享的虛基類子對(duì)象處于部分初始化狀態(tài)。當(dāng)另一個(gè)完全不相關(guān)的派生類通過另一條路徑轉(zhuǎn)換到該虛基類并調(diào)用虛函數(shù)時(shí)訪問了未初始化的vptr導(dǎo)致段錯(cuò)誤。這個(gè)問題的教訓(xùn)是在虛繼承體系中必須嚴(yán)格保證最終派生類對(duì)虛基類的初始化是完備和正確的因?yàn)樗兄虚g類對(duì)此的初始化嘗試都會(huì)被忽略。同時(shí)對(duì)于復(fù)雜的對(duì)象層次在調(diào)試時(shí)畫出完整的內(nèi)存布局圖和構(gòu)造函數(shù)調(diào)用序列圖是理清思路的最有效方法。