天天看點

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

摘要:

本文詳細介紹了C++繼承的三種方式和相關重要概念,整理了衆多繼承與組合中的注意問題。在C++繼承存在不安全的預設實作,非虛函數的覆寫,多重繼承的函數名沖突、菱形繼承等衆多問題下,如何實作多個功能的自由組合?阿裡雲進階開發工程師采用mixin,為大家提供了更好擴充性和更高代碼複用度的解決方案。

數十款阿裡雲産品限時折扣中,

趕緊點選這裡

,領劵開始雲上實踐吧!

本次直播視訊精彩回顧,

戳這裡

! 

演講嘉賓簡介: 付哲(花名:行簡)

,阿裡雲進階開發工程師,哈爾濱工業大學微電子學碩士,主攻方向為分布式存儲與高性能伺服器程式設計,目前就職于阿裡雲表格存儲團隊,負責後端開發。

以下内容根據演講嘉賓視訊分享以及PPT整理而成。

本文将圍繞一下幾個方面進行介紹:

1. C++繼承方式

2. 繼承相關重要概念及注意問題

3. 問題及解決:如何組合正交的多個功能

一. C++繼承方式

C++有三種繼承方式:public/protected/private,這三種繼承方式中,派生類都會繼承基類的public和protected成員,但無法直接通路基類的private成員,隻能通過繼承後的方法來通路。如圖所示一個簡單的示例:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

在本例中,Base為基類,包含public/protected/private三種成員,其中public和protected成員可以被派生類繼承,而mName不可以被派生類直接通路,隻可以通過繼承後的函數F(),G(),H(),I()來通路。

1. Public繼承

采用public繼承方式時,基類的成員在派生類中的通路級别與基類中一緻,即public成員仍是public級别,protected成員仍是protected級别。如對上例中Base進行public繼承,得到下派生類:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

其中F()為純虛函數,派生類隻繼承到函數的接口,需要再進行具體實作;G()為虛函數,派生類同時繼承了接口和實作;H()為public方法,有實作但不為虛函數,無法在調用指針時觸發多态,該派生類繼承了接口和強制的實作,這是不能改寫的;I()是protected方法,它不是基類的接口,是以派生類隻繼承了它的實作。此時,該派生類可以作為一個基類對象使用,例如上圖中建立派生類對象用于兩個函數中,此時派生類引用/指針可轉換為基類引用/指針。

2. Protected繼承

protected繼承與public繼承的不同在于,基類的成員在派生類中的通路級别的改變。public和protected成員都成為protected級别。此時派生類接口不包含基類的接口,是以protected繼承不是is-a的關系。繼承後成員如下圖所示:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合
3. Private繼承

private繼承中,基類的public和protected成員都成為private成員。和protected繼承類似,派生類接口不包含基類的接口,是以private繼承也不是is-a的關系。同時派生類引用/指針不可轉換為基類引用/指針。此外,由于此時派生類成員都為private,那麼後續派生類型再也無法繼承該類型。對上例中Base進行private繼承,如下圖所示:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

那麼綜上所述,C++的繼承方式中:public繼承包括基類的接口與實作;protected繼承隻包括基類的實作,且可繼續傳遞;private繼承隻包括基類的實作,且不可繼承傳遞。這裡值得注意的是,派生類無法繼承基類private成員,這是指派生類無法直接通路,即基類private成員對派生類對象不可見,但在記憶體布局中是包含這些private成員的,且派生類的構造、析構、複制也會受到這些private成員影響。例如假設基類中有引用private成員,這不僅導緻基類方法無法進行複制和移動,也同樣會導緻派生類的無法複制和移動。

二. 相關重要概念 1. 純虛函數與抽象類

純虛函數是聲明為等于0的虛函數,具體如下圖所示:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

此處0填充在虛表中,這會導緻純虛函數的虛表為0項,即無法建立虛表,無法執行個體化。包含純虛函數的類稱為抽象類,此處Base即為一個抽象類。但這裡需要注意的是,純虛函數不等于無定義的虛函數。如果這裡将圖中的等于0去掉,即F() = 0改為F(),那麼Base類是無法派生的,派生類會報錯F()無法使用。

2. 接口繼承與實作繼承

接口是類與外界的通信協定,是抽象的;實作是類對協定的反應,是具體的。當稱派生類繼承了基類的某接口時,表示派生類對外的協定中也包含了基類對外的協定,調用該接口時,派生類對象就會被當做基類對象使用。當稱派生類繼承了基類的某實作時,指派生類可以調用基類的某種行為。與Java和C#不同的是,無論是繼承接口還是實作,C++中隻有一種繼承文法,并且如上所述,public繼承包括基類的接口與實作,protected和private繼承隻包括基類的實作,不包含接口。例如下圖中:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

當派生類繼承基類public方法時,draw()方法為純虛函數,隻能繼承到接口,即派生類必須改寫該函數,否則不能執行個體化對象;error()方法為虛函數,繼承到接口與預設實作,即若派生類改寫了該函數,那麼該函數就失效了,若派生類未改寫,則可以直接使用該函數;id()方法為非虛函數,繼承到接口與強制實作,即派生類無法改寫該方法。

3. 安全的預設實作

大家可能覺得派生類需要給每個純虛函數進行實作,太過繁瑣,虛函數效果更佳。若該純虛函數較為通用,可能在派生類中需要重寫多遍,而虛函數提供了一個預設實作,隻需在需要時改寫即可。但請注意,這其實是非常危險的。因為在編寫基類時是不知道未來将會産生哪些派生類,預設實作不一定适用所有派生類。例如下例中:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

ModelA和ModelB都可以使用基類的預設實作,但後續加入了不能使用基類的預設實作的ModelC,此時理應為ModelC改寫該實作但被程式設計人員遺忘了。編譯時由于存在基類的預設實作,是以不會報錯。運作初期可能不會出現問題,但會為後續埋下非常危險的隐患。是以嚴格的代碼規範中禁止虛函數提供預設實作,即必須使用純虛函數。但虛函數可以避免代碼的重複,是以仍存在一定的價值。實際運用中,存在很多派生類需要使用預設實作,那麼如何強制要求派生類顯式地使用每個接口,又可以為派生類準備一個可調用的預設實作呢?一種方法就是為純虛函數提供定義。這種定義不會存入虛表,是以基類本身仍然是抽象類,并且派生類仍然需要提供一個顯式實作。但更簡便的方法是在派生類中調用基類的實作,注意此處不能使用虛函數,而是直接使用函數名調用。如下圖示例所示:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

如此,需要使用預設實作時隻需簡單調用該函數;而不需要使用預設實作時,若程式員忘記編寫具體實作,編譯時便會報錯,強制要求提供函數實作。是以,便可以避免上述預設實作的隐患。

4. 純接口繼承與接口類

純接口繼承是指基類隻提供接口,不提供定義,即嚴格代碼規範下,基類的所有函數都是純虛函數,不提供具體實作,派生類需要對所有方法進行自定義,這樣的類型稱為純接口類。純接口繼承完全分離了接口與實作,依賴更少,如下例所示:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

這樣的接口類有以下三個特點:一,沒有非靜态成員變量;二,所有成員都是public成員;三,所有成員都是純虛函數,析構函數除外,是以在上例Interface類中存在一個有定義的虛的析構函數。純接口繼承的優點是最小化調用處的依賴,且接口與實作完全分離,這樣在隻有實作發生變化時,調用處不會受到任何影響。而它的缺點是不利于代碼複用,如果多個派生類都要實作相差不多的方法F(),就需要重複編寫多遍F()的代碼。

5. 確定接口繼承是“is-a”關系

在實行接口繼承時,需要確定接口繼承是“is-a”關系。當派生類以public方式繼承一個基類時,它也繼承了這個基類的所有接口,那麼所有使用基類接口處都可以使用派生類。從這個角度說,派生類對象是一個(“is-a”)基類對象。這也是基類接口對派生類的限制,派生類需要嚴格保證其所有行為都符合基類接口的要求。但有時派生類并沒有達到這一要求,如下經典示例所示:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

本例中,基類Bird包含接口fly,正如大家了解,鳥都會飛。Penguin繼承自Bird,如果采用預設實作那麼Penguin也要繼承接口fly。但大家知道企鵝不會飛,也就意味着它不能有fly接口。如果這裡給Penguin一個空的fly方法,雖然可實行,但這是不合常理的,必須要向使用者顯示Penguin是不含有fly行為的。如果此處不給出具體實作的話,隻有在運作時才會抛出異常,這種意外的異常也是不友好的。這個問題其實源于對基類接口的設計。當已知不是所有鳥都會飛之後,那麼便不應該給Bird類一個fly接口,基類的這種接口是一種不合理的強加的限制。一種解決方法是中間再加一個層次:Bird類本身是沒有接口的,而Bird的派生類FlyingBird才會提供fly接口,那麼Penguin便繼承Bird類,而不是繼承FlyingBird類。如此若調用Penguin類的fly接口,編譯時就會報錯,便可以防止上述問題的發生。另一個示例為矩形的實作,如下所示:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

長方形類中有一提前假設,即它的長和寬是獨立的,改變其中一個值,另一個值不會随之改變。makeBigger就展現了這種假設,這也是對所有長方形的派生類的要求。但正方形卻不滿足這個要求,它的長和寬必須是相等的。是以正方形根本不應該是長方形的派生類,這種繼承是錯誤的。由此可見,C++中的繼承比現實中的繼承更加嚴格,需要程式設計人員謹慎的選擇基類與派生類,任何适用于基類的性質都需要适用于派生類。存在任何不滿足“is a”關系的繼承都是不合理的。

6. 不要覆寫基類的非虛函數

當派生類public繼承一個有非虛函數的基類時,派生類也會繼承這個非虛函數,并且是繼承了強制實作。然而與虛函數不同的是,派生類沒辦法改寫這個函數,相反,如果自定義編寫一個同名函數,基類的版本就被“覆寫了”,如下例所示:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

派生類和基類中都有函數F(),但此處不是改寫而是覆寫,基類接口被覆寫會導緻調用産生的行為不一緻。直接通過派生類對象調用F()與通過基類指針調用F(),會産生不一樣的行為!這種不一緻就表明派生類與基類不再是is a的關系。是以,不要覆寫基類的非虛函數。

7. 實作繼承與組合

當派生類以protected或private繼承一個基類時,派生類沒有繼承到基類的接口,而是繼承到了基類的實作。這種方式被稱為實作繼承。實作繼承意味着派生類與基類不是is-a的關系,而隻是需要複用其實作或功能。例如,若有一個類Password,它需要使用std::string功能,那麼一種解決方法就是讓它繼承自std::string,如圖所示:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

此時Password類便可以直接調用string方法。但string類本身并沒有設計為可以成為一個基類,可能存在其析構函數不是虛函數,那麼使用基類指針指向析構函數時,會忽略派生類對象中的内容。但這裡還有另一種選擇,那就是将std::string變成Password的一個成員,而不是Password的基類,這樣仍能使用std::string的各種功能,且不需要增加一種繼承關系。這種方法被稱為“組合”,它是比繼承更靈活的複用方法。一般在可以用組合達到目的時,要盡量避免使用實作繼承。然而,在某些場景下,實作繼承有它獨特的用途。一是在改寫基類的某些功能時,如下例所示:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

當Widget隻需要複用Timer的其他功能,但不需要Timer的onTick()時,就可以使用private繼承Timer,然後改寫onTick()函數,這是對象組合無法輕易完成的。當然該場景下,結合内部類,組合也可以達到類似效果。用内部類private繼承Timer,與Widget構成組合關系,如此來避免Widget直接繼承Timer。

第二種場景是當基類是空類型時,繼承可以應用到空基類優化,在派生類中不占空間,而對象組合則沒有這種優化,空類型成員至少要占用一位元組的空間,一般會在八位元組及以上。該場景最經典的例子是boost::noncopyable,無論是private繼承,還是将其作為成員變量,都可以令自定義類型無法複制,但private繼承時,因為該基類是空類型,是以在派生類中不占空間。實作繼承的該特性在标準類型庫中被廣泛使用。

8. 多重繼承的問題

C++允許一個派生類繼承自多個基類,但逐漸大家意識到這種自由會導緻一些棘手的問題,是以Java等語言取消了這種特性,而是派生類隻允許有一個基類。多重繼承會導緻以下兩個問題。一是不同基類間的名字沖突或者歧義,如下例所示:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

此處有A和B兩基類,C同時繼承A和B。雖然A類中的Func()是public函數,B中的func()是private函數,但調用C類的func()函數時,它理應調用A的func(),但C++的名字查找規則是優先于通路級别檢查的,是以先查找名字,進行重載決議,再檢查通路級别,而在重載決議時編譯器檢查到了兩個相同優先級的func(),這就産生了沖突。解決這個問題就需要顯式調用某個基類的版本,指定調用函數的namespace:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

第二個問題是菱形繼承問題,這比上述問題更加棘手。當語言允許多繼承時,一個基類可能會多次出現在同一個派生類的基類樹中,如下例所示:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

InputFile和OutputFile同時繼承于File類,而下一層IOFile類同時繼承InputFile和OutputFile,即繼承了兩次File,這就是菱形繼承。當出現菱形繼承時,意味着派生類對象中有多個相同類型的基類子對象,此時調用該基類方法時會産生如何選擇子對象的混亂。并且有些屬性對派生類是唯一的,比如File屬性,在IOFile中隻應有一份。為了解決這個問題,C++增加了虛繼承,虛繼承的基類在派生類中隻會有一份。但虛繼承被認為是比多重繼承更糟糕的特性,它比虛函數的開銷更大,且反直覺地要求最終派生類型的構造函數來構造整個繼承鍊條中所有虛繼承的基類。是以,為了避免菱形繼承,一些程式設計規範規定:不能使用虛繼承;盡量不要使用多重繼承;如果要用多重繼承,盡量模仿Java語言,至多隻能有一個基類有實作,其它基類都是接口類。

三. 問題及解決:如何組合正交的多個功能

假設有若幹個彼此獨立,或說正交的功能,如何将它們組合起來?例如有一個TaskManager類,負責管理所有擁有ITask接口的對象,如下所示:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

現在需要為ITask類型增加兩個功能:一是timing功能,即在ITask對象執行Execute方法前後計時;二是logging功能,即在ITask對象對待Execute方法前後列印日志。那這該如何解決呢?

1. 繼承

第一種方式是通過繼承複用功能。具體如下所示:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

從ITask類派生出一個ILoggingTask類,它增加OnExecute()接口,其派生類隻要實作這個接口,在調用ITask::Execute時就能列印日志了。同樣的方法,這裡也通過增加ITimingTask類實作timing的功能:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

然而當需要同時複用timing和logging該如何解決呢?假設使用上述方法,那麼ITimingTask的Execute()與ILoggingTask的Execute()是沖突的,無法同時複用兩個功能。這就展現了通過繼承來複用代碼的缺陷,對于單個功能,可以将需要複用的實作代碼放在基類中,但如果需要同時複用多個功能,通過繼承複用功能就無法解決了。另外,本例中因為增加了一層虛函數,而且還是在虛函數中調用另一個虛函數,這就導緻編譯器無法inline代碼,進而增加運作期的開銷。

2. 組合

第二種方式是通過組合複用功能。具體如下所示:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

這裡LoggingTask不再作為基類存在,而是作為代理,把對LoggingTask的請求轉發給它持有的task成員,由task來解決請求。同樣地,這裡也增加一個TimingTask類:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

接下來就可以通過鍊式傳遞來組合這兩個功能。

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

通過組合來複用功能仍然也存在一些問題。一是這種方法依然有一些運作期的開銷,比如需要在堆上配置設定每個對象,多次調用虛函數。但它解決了組合多個功能的問題,不同功能間也耦合較低。二是LonggingTask需要實作一些不用的接口。像LoggingTask這樣純粹的功能,本是不需要實作GetName這樣的接口,但它繼承自ITask,就需要實作ITask所有的接口。假如ITask還有其它接口,LoggingTask也都需要實作,這就增加了代碼的複雜度,使得該子產品特别臃腫。那麼這該如何解決呢?

3. 重返繼承

這次仍然嘗試用繼承來解決該問題,但與上述第一種繼承方法相比,做出一些變化。前面的方法中增加了兩個基類,且把需要複用的部分放在基類中。而這裡把需要複用的部分放在派生類中:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

這裡将MyTask作為基類,TimingTask中繼承MyTask來執行計時的操作。而LoggingTask繼承TimingTask,如此LoggingTask便同時具有計時和列印兩個功能。但這種方法仍然有很多缺陷:一是不同功能之間因為繼承完全耦合在一起,功能之間無法分割;二是這兩個功能綁定在MyTask類中,導緻這兩個功能完全無法被其它類型複用。雖然有這麼多緻命缺陷,但這種方法仍然有獨特的優勢:首先,不需要堆配置設定;其次,沒有多餘的虛函數定義及其調用;最後,編譯器有機會做更多内聯優化。那麼,該如何解決這個方法的缺點呢?由代碼分析可知,上例中的兩個缺點都是因為基類是固定的,無法變化,如果能用模闆将基類作為參數傳遞,上述缺點便解決了。

4. Mixin

最後一種方法是通過mixin複用功能。mixin本身是面向對象領域的一個非常寬泛的概念,它是有一系列被稱為mixin的類型,這些類型分别實作一個單獨的功能,且這些功能本身是正交的。當需要使用這些功能時,就可以将不同的mixin組合在一起,像搭積木一樣,完成功能複用。一個更清晰的解釋是這樣的:一個mixin就是類裡的一小塊,可以用來與其它類或mixin做組合;一個獨立的類與一個mixin的差別在于,一個mixin隻模組化小的功能點(如timing或printing),并不是用來獨立使用,而是給其它需要這個功能的類做組合。

在C++中最常用的實作mixin的方式叫“參數化模闆”。這裡可以将TimingTask和LoggingTask的基類都換成模闆參數:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

此時便可以解決前述所有問題,TimingTask和LoggingTask實作完全不耦合,所有代碼獨立,且所有含有Execute()方法的類型都可以組合這兩個功能。組合過程如下所示:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

首先建立MyTask對象,将該對象傳遞進入TimingTask類中,生成對象t1,然後可以将t1傳遞進LoggingTask類中生成對象t2。t2便同時具備Timing和Logging功能。

C++可以通過繼承方法來支援mixin,而不像其他語言,如Ruby,顯式的支援mixin。如此使用mixin便能夠實作自由組合多個功能,并且囊括了之前方法的所有優點,有更好的擴充性和更高的代碼複用度。

當然這個類并不是最終希望得到的,因為它沒有實作ITask接口。是以仍然可以增加一個新的mixin,來将任意含有Execute和GetName方法的類型适配為ITask的派生類:

C++繼承群組合——帶你讀懂接口和mixin,實作多功能自由組合

本文由雲栖志願小組郭雪整理,編輯百見