繼承

——“面向對象的三大特性是什麼?”
——“封裝、繼承、多態。”

這大概是最容易回答的面試題了。但是,封裝、繼承、多態到底是什麼?它們在面向對象中起到了什麼樣的作用呢?

繼承

繼承(inherit)關係是對象之間的一種層級結構關係。在這個層級結構中,低層級的對象可以獲得、也可以修改高層級對象所定義的一些行爲和數據。其中,低層級對象“獲得”高層級對象的行爲和數據的這個動作,也被稱爲“繼承”(做動詞用)。

webp

↑ 小x繼承老x,則小x和老x之間就構成了繼承關係。

當然,出於封裝的考慮,高層級對象也可以把一部分行爲“封”起來,不讓低層級對象去繼承。這並不破壞兩類對象之間的繼承關係。就好比說,老王的錢終將是小王的錢,但老王的媳婦永遠只是老王的媳婦。

Java中有狹義和廣義兩種繼承。狹義的繼承就是通過extends關鍵字實現的類與類之間的繼承關係;而廣義上講,通過implements關鍵字達成的類與接口之間的實現關係也是一種繼承:實現類獲得了接口中的方法定義,並修改了方法的具體實現。

除了繼承之外,對象之間還有一種關係叫組合關係。組合關係是更爲普遍、也更爲簡單的一種關係:類A把類B當做自己的數據(或者叫屬性、字段)來使用,我們就說類A組合了類B,或者說是類A依賴於類B。對於只支持單繼承的語言來說,繼承會形成一個樹形結構的類關係網絡,而組合則會構建出一個網狀結構。

webp

↑ 繼承的樹狀結構與組合的網狀結構

例如,上面這張圖的左側,是一套標準的接口(SomeService類)-模板(SomeServiceAsSkeleton類)-具體實現(SomeServiceDefaultImpl類和SomeService4BizAImp類)、外加一個分發器(SomeServiceAsDispatcher類)的類。這些類按照繼承關係組成了一棵樹形結構。

而這張圖的右側,是一套與BusinesA有組合關係的類。這這些類按照組合關係組成了一個網狀結構。當然,考慮到代碼的分層結構(例如上圖就是一種business-service/helper-dao的分層結構),在組合關係比較簡單的情況下,網狀結構也有可能退化成樹形結構。但是,就如上圖中的ServiceA和HelperA所表達的那樣:在組合關係中,它們可以相互依賴;但是在繼承關係中,不允許出現相互繼承。


繼承與面向對象

如果說封裝是面向對象的基礎,那麼繼承就是面向對象的核心。有了封裝,我們才能構建對象;有了繼承,我們才能構建起完整的對象體系。

面向對象思想是一種模擬現實的編程思想。它模擬現實的方式,則是模擬現實世界中的分類體系來構建一套對象體系,用以描述對象是什麼、對象能做什麼,進而藉助這些對象及其能力和關係來實現所需功能。面向對象解決方法的思路、能力以及優勢,都是建立在它的對象體系上的。沒有這套對象體系,面向對象什麼都做不了。

webp

↑ 就如奪冠靠的不是一個球員,而是一整套戰術體系一樣,面向對象思想靠的不是單個對象,而是一整套對象體系。

繼承就是面向對象用來構建對象體系的方案。在現實世界的分類體系中,任何一個事物都可以用它的上層類別來描述、並擁有上層類別所描述的特性。例如,我是個Java程序員,也是一個程序員、一個IT從業者;《有彩虹的風景》是一幅浪漫主義油畫,也是一副畫作、一件藝術品……類似的,在面向對象的對象體系中,子類也都可以安全的轉爲父類。在對象體系中,每一個子類都是一個父類,都擁有父類的數據和能力:ArrayList是一個AbstractList,擁有AbstractList的方法實現;LinkedHashMap是一個HashMap,也是一個AbstractMap,擁有HashMap和AbstractMap的方法實現……

webp

↑ 左邊是現實生活中的分類體系;右邊是面向對象中的繼承體系。你就說像不像吧。

我們可以設想一下,如果沒有繼承,面向對象思想要怎樣構建這個對象體系呢?一個可選的方案是組合。但是,與繼承相比,組合是一種更弱、更鬆散的關聯關係。在現實中,老王就可以因爲資產貶值而把它賣掉,也可以因爲下屬犯錯而把他炒掉;但是老王不能因爲小王闖禍了就甩手不管,因爲他們之間的關係非常強、非常緊密。繼承比組合更適合於描述這種類之間的“強關係”——也就是我們常說的“is-a”關係。而且,繼承關係組成的樹狀結構比組合關係形成的網狀結構也更簡單明瞭、更易於管理和降低複雜度。如果我們不使用繼承、而完全使用組合的話,就很容易陷入類爆炸以及類關係網的泥潭中,光是梳理清楚調用關係就已經暈頭轉向了。

不過,這幾個方面的因素只能說是“組合有而繼承好”;真正“組合無而繼承有”的、讓面向對象非使用繼承不可的,是繼承所帶來的抽象能力。


繼承與抽象

繼承與抽象是同一枚硬幣的兩面:子類繼承父類,那麼我們就可以說,父類是子類的一個抽象。我們以下面這張圖爲例:這是組合責任鏈模式和模板模式以實現業務功能的一個類圖。

webp

↑ 左責鏈,右模板,接口在當中。

先看這張圖的上半部分,我們首先可以看到:ProductServiceAsChain和ProductServiceAsSkeleton這兩個類都實現了ProductService接口。顯然的,ProductService接口是這兩個類的一個抽象:它定義了這兩個子類“做什麼”——從方法名上看,這個接口是要確定使用哪種產品;同時把它們“怎樣做“的細節隱藏起來了。

然後我們看圖的左半部分,ProductServiceAsFindFirst繼承了ProductServiceAsChain,因此我們可以說,ProductServiceAsChain是ProductServiceAsFindFirst的一個抽象:它定義了這一套類要“做什麼”——通過遍歷serviceList來確定使用哪種產品;而具體“怎樣做”才能確定產品,ProductServiceAsChain雖然提供了一個默認實現,但還是隱藏了子類的實現細節。從上圖中就可以看到,ProductServiceAsFindFirst通過重寫ProductServiceAsChain的方法,提供了另一種方法實現。

最後我們看圖的右半部分。ProductService4P1~ProductService4P1都繼承了ProductServiceAsSkeleton,所以,ProductServiceAsSkeleton就是這些子類的抽象:它定義了這些子類要“做什麼”——先判斷應不應該由自己處理,然後再填充產品的具體數據。但是,具體“怎麼做”能確定是否由自己處理、“怎麼做”才能填充上各產品字段,這些實現細節被ProductServiceAsSkeleton隱藏起來了,只有到具體的子類中才能看到。

從繼承的這個角度,可以看到抽象的一個顯著特點:它也是分層次的。越是高層級的抽象,其中的細節就越少,對業務的概括能力就越高,可維護和可擴展性也就越好;越是低層級的抽象就越“反其道而行之”:細節信息就越多、業務概括能力越低、可維護和可擴展性也就越差。這也是爲什麼我們鼓勵“面向接口編程”的一個原因。一般來說,接口都是頂層抽象。在它的基礎上編程,維護和擴展所受到的限制也就越少。

webp

↑ 這有點像職業發展之路:職位/職級越高,離代碼的具體實現、系統的細節邏輯就越遠。

與“面向對象的三大特性是什麼”這個面試題一起出現的,還有“這三個特性中最核心的是什麼”。理解了繼承與面向對象、與抽象的關係之後,應該就能理解爲什麼第二道題的答案是“繼承”了。


繼承與高內聚低耦合

如前所述,繼承是一種非常強的關係。因而,藉由繼承關係構建的模塊,其內聚性也非常的高:嚴格遵守繼承規範的情況下,子類和父類只有共同協作才能實現業務功能,二者相輔相成、缺一不可。

例如,我們有這樣的一套工廠類:

webp

↑ Messager的一套工廠類

在上面這套類中,AbstractFactory定義了一套工廠類的模板:首先調用instance()方法創建一個Messager實例,然後通過builderBaseMessager()方法針對Messager的父類屬性賦值,最後調用buildDetails()方法對子類屬性賦值。其中,instance()和buildeDetails()是兩個抽象方法。兩個子類分別實現了這兩個方法,用以創建不同的對象實例並進行賦值。

在這套工廠類中,如果我們只有AbstractFactory,顯然是無法獲取正確的實例的:它並不知道我們需要哪個Messager實例,也不知道怎樣爲這個子類實例賦值。如果只有子類、並且不調用父類方法,我們會遇到類似的問題:雖然能夠創建實例,並且能夠給它賦值,但是最終得到的實例會缺少一部分數據。

只有父類和子類協作:父類爲子類提供基本的框架、子類填補上父類留下的空白,才能得到完整的、正確的結果。而這正是內聚性最高的“功能內聚”。

Functional cohesion is when parts of a module are grouped because they all contribute to a single well-defined task of the module .
功能內聚是指一個模塊內所有組件共同完成一個功能、缺一不可。
花園的景昕,公衆號:景昕的花園《細說幾種內聚》

但是在開發實踐中,大多數的父類就可以“獨當一面”完成功能了;子類所做的並不是“完成未竟的事業”,而是“偷樑換柱”地用另一種方式完成業務功能:似乎父子類之間也並非沒你不行啊。

這是我們在遵守繼承規範和提高開發效率這一對“原則性”和“靈活性”之間進行取捨的結果。使用繼承的規範相當的“嚴苛”。嚴格遵守規範會增加開發的工作量、會提高類結構的複雜度、會增加類的個數,最終會降低開發效率、影響項目排期。

與超高的聚合性類似,繼承關係的類之間耦合度也非常強。人們甚至造了一個專有名詞來描述父子類之間的這種超強耦合:子類耦合(subclass coupling)。

Describes the relationship between a child and its parent. The child is connected to its parent, but the parent is not connected to the child.
子類耦合描述的是子類與父類之間的關係:子類鏈接到父類,但是父類並沒有鏈接到子類。
花園的景昕,公衆號:景昕的花園《細說幾種耦合》

子類耦合在《細說幾種耦合》一文中已有討論,所以這裏就不多贅述了。


繼承與其它

“is-a”與“like-a”

由於繼承會帶來超高的耦合性,所以很多時候我們都建議儘量少用繼承——尤其是狹義的繼承,即extends。我們聽得最多的,就是隻有“is-a”關係纔可以使用繼承;如果只是“like-a”關係,那麼應該使用組合。

但是,怎樣算“is-a”、怎樣算“like-a”呢?在我看來,如果兩個類在“做什麼”方面如出一轍、只是在“怎麼做”上大同小異,那麼它們就是“is-a”的關係;如果他們在“做什麼”上大相徑庭、而只是在“怎麼做”上有些異曲同工,那麼它們就是“like-a”的關係。

例如,在下圖中,在我們的系統已有一個類ApplyServiceImpl可以對身份證號做基本校驗了。隨着業務發展,我們有兩個新的類(暫時化名爲NewServiceA和NewServiceA,否則看到類名就能猜到答案了)需要複用並擴展ApplyServiceImpl的身份證校驗邏輯:在基本校驗之外,它們都需要增加ID5認證。那麼,這兩個新的類與原先的那個類之間,是“is-a”還是"like-a“呢?

webp

↑ is-a or like-a, that's a question.

雖然隱去了類名,但是從兩個新類所提供的方法上,我們還是可以看出些端倪來。NewServiceA和ApplyServiceImpl的主方法是一樣的:apply(User):Apply,這說明它們所做的是同一件事情,只是在做這件事情的過程中有一些差異。而NewServiceB和它倆做的幾乎完全是兩碼事,只是在個別細節上恰好“英雄所見略同”。所以,這三個類之間的關係可以說是一目瞭然的, 最後的類結構則是這樣的:

webp

↑ is-a用繼承,like-a用組合

面向對象思想中還有一句名言,可以用做“like-a”與“is-a”的參考:“如果它看上去像只(like-a)鴨子,聽上去像只(like-a)鴨子,飛起來也像只(like-a)鴨子,那麼它就是隻(is-a)鴨子”。也就是說,只有“全方位”的“like-a”的時候,纔可以轉爲“is-a”關係;否則,還是慎用“is-a”這種強關係。

webp

↑ 比如程序員的好朋友小黃鴨,看上去、聽上去都“like-a”鴨子,但是飛起來“not like-a”鴨子,所以它“not is-a”鴨子。


類與接口

細心看了上文的話,就會發現,在ApplyServiceImpl-ApplyService4ProductBImpl-BankCardServiceImpl最後的類圖中,多了一個新增的接口:IdCardService。

但是,我們爲什麼一定要新增一個接口呢?按前文所述,ApplyServiceImpl同樣是ApplyService4ProductBImpl的一個抽象,爲什麼BankCardServiceImpl一定要使用新的接口IdCardService、而不直接使用類BankCardServiceImpl呢?換句話說,爲什麼一定要面向接口編程呢?

細心看了上文的話,就會發現,其實前面已經回答過這個問題了。

首先,抽象“也是分層次的。……一般來說,接口都是頂層抽象。在它的基礎上編程,維護和擴展所受到的限制也就越少”。而類雖然也有一定的抽象能力,但是其中已經包含了相當多的細節,不如接口那麼富有“生命力”。

例如,使用Map<Enum, String>接口時,我們既可以用HashMap<Enum,String>,也可以用EnumMap<Enum>、ConcurrentHashMap<Enum, String>。但如果一開始就使用了HashMap,那麼後續想要變更實現類時,可能就舉步維艱了。我們有位同事曾經在代碼中用HashMap來封裝數據、並把它在Controller、Service、Dao中層層傳遞和擴散開來。然而,在code review中我們發現這個map存在併發風險,需要替換成ConcurrentHashMap。此時我們才發現,這一整套代碼都跟HashMap綁定了,就像船隻被章魚海怪死死纏住一樣,進退維谷,左右爲難。

webp

↑ 感受恐懼吧

其次,面向對象思想通過繼承“構建一套對象體系,用以描述對象是什麼、對象能做什麼”。具體到類和接口上來說:類同時描述了它是什麼和它能做什麼;而接口僅僅描述了它能做什麼。在代碼中,類A調用類B時,其實A並不關心B是什麼,而只關心它能做什麼——如果你養寵物的目標只是捉住老鼠,那就不要管養的是黑貓白貓、甚至是狼是狗了。反過來想想,如果類A在調用類B時,不光要求它“能做什麼”,還限定了它必須“是什麼”,那麼在後續的功能擴展中就難免受到限制。設想一下,如果你養寵物時,不僅要求能捉住老鼠,還限定它必須是貓,那麼當遇上吃貓鼠時,就“勿謂言之不預”了。

webp

↑ 謹以此文悼念英勇的白貓班長

對於“類同時描述了它是什麼和它能做什麼;而接口僅僅描述了它能做什麼”,我們還可以這樣理解。在繼承結構中,類是給它的子類看的:子類需要從父類定義中知道我們“是什麼”、我們“能做什麼”;並在逐層細化、重寫與擴展中找到我“是什麼”、我“能做什麼”的定位。接口的定義是給外部調用者看的:只需要告訴它們自己“能做什麼”,至於自己“是什麼”,“不足爲外人道也”。

webp

↑ 他的父母告訴他:你是氪星人;他的內心告訴他:我是地球人。他只告訴人們“我能拯救世界”,卻從不提起“我是克拉克·肯特”。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章