繼承與組合的比較
常聽人講「少用繼承,多用組合」,那到底這兩種方法有什麼差異呢?本篇文章來聊聊這兩個方法在不同情境下的實作、差異與比較。
首先先針對「少用繼承,多用組合」的繼承和組合做定義:
「繼承」(inheritance)是 Is-a 關係,指的是包含關係,比方說「馬爾濟斯是狗」,則狗是馬爾濟斯的一般化(generalization),指的是在講「狗」的時候也包含了「馬爾濟斯」;馬爾濟斯是狗的特殊化(specialization),意義就類似「馬爾濟斯」是「狗」的其中一種。
過去曾分享過依賴關係,以下所提到的「組合」(composition)是指文章裡面所提到的:依賴、關聯、聚合、組合,這四種關係。
繼承與組合,都是指兩個類別之間的關係。繼承與組合也可以應用在「共用程式碼」的場景上,但如何應用才能發揮「模組化」的好處呢?下面將會舉幾個情境說明,並提供個人想法做為參考。
情境一:「購物車」與「金流」
「購物車」除了增修刪商品外,還可以結帳(checkout);結帳後會觸發「金流」的扣款(debit),背後是呼叫指定金流 API。
繼承解
先不論合不合理,反正程式能動就好。先寫一個購物車父類別實作金流功能:
abstract class BaseShopcart |
然後再寫子類別:
class Shopcart extends BaseShopcart |
最後直接實例化子類別:
$shopcart = new Shopcart(); |
這個做法是明顯不好的,原因如下:
- 就需求來看,購物車和金流有它們各自的職責,使用繼承會讓職責變得混亂,比方說在使用購物車的時候,同時可以操作金流,這並不合理。
- 繼承是 Is-a 關係。實作金流的類別(指的是上例
BaseShopcart
)與購物車類別並沒有 Is-a 關係,因為購物車不是金流的一種,而且用 Base 關鍵字也不符合 Is-a 關係的語意,所以不適合這麼寫。
組合解
組合的概念是「上層模組使用下層模組」,從流程上可以從觸發事件的角度觀察,通常發動觸發的會是上層模組。
金流的實作會因不同的廠商而有所不同,可以參考設計模式的轉接器模式(Adapter Pattern)來實作
首先是下層模組介面與實作:
interface Cash |
從需求來看,上層模組沒有下層模組會無法使用,所以需要透過建構子傳入實例:
class Shopcart |
使用時,上層模組跟下層模組需要一起使用:
$cash = new EcPay(); |
以這個例子來說,組合是個可行且合理的選擇,可以有效的將職責區分開。下層模組還可以依不同金流,選用不同的實作。
情境二:「取貨方法」與「實作」
延續前一個例子,結帳完後就是選擇取貨的方法,不同的取貨方法,如店內取貨或是宅配到府,會使用不同的程式實作,但會共用相同的訂單流程。
繼承解
「相同的流程,不同的實作」是樣版方法模式(Template Method Pattern)可以解決的問題。它定義了一個基本的程式執行流程(即訂單流程),然後保留抽象實作空間讓子類發揮。
首先先寫一個訂單的抽象類,是在結帳後,設定取貨方法(直譯,pickup method):
abstract class Order |
接著寫店內(Store)取貨實作類,與宅配(Delivery):
class Store extends Order |
這個做法雖然可行,但它的缺點在於抽象化的職責只能有一組,以上例來說即是指「取貨方法」。
剛好這個例子不是很好,以這個 Order
命名來說,它還會承載很多不同的職責,因此如果有其他需要抽象化的需求,如金流方法,就無能為力了。
組合解
設計模式裡,有兩個模式可以取代繼承,其中一個是裝飾者模式(Decorator Pattern)。而這裡選擇的是另一個橋接模式(Bridge Pattern),它能讓「抽象」和「實作」分離並各自擴展。這個意思是:原本是 A extends B
,改使用橋接模式就能讓 A 和 B 自由抽換成 C 到 Z 了。
以下就來改寫吧,首先先定義取貨方法的介面,與實作對應的程式:
interface Pickup |
接著把 Order 改成抽象類,這樣也可以針對 VIP 訂單跟一般訂單實作不同的客製化功能:
abstract class Order |
最後使用的方法如下:
$order = new NormalOrder(new Store()); |
改用使用橋接模式後,物件的替換變得更加簡單了。
- 抽象層想替換,就新增 Order 實作
- 實作層想替換,就新增 Pickup
- 想新增別的實作層,只要調整抽象層的流程就行了。
繼承適用的場景
上面都是講不適合繼承的例子,這一個小節想討論的是,什麼時候適合用繼承?
分享兩個會使用繼承的場景,首先第一個比較容易理解:當沒有介面,或者依賴方所依賴的不是介面的時候。如:
class Dep |
Context 類別直接依賴 Dep 類別,因此在不改架構的前提下,如果要擴充 Dep 的 something 方法時,「只能」使用繼承去覆寫(override):
class NewDep extends Dep |
當依賴介面的時候,就會類似情境一的組合解--使用轉接器模式,只要有實作介面的類別,都能作為參數傳入,這是應用物件導向的多型特色。
第二個情境,這是我自己觀察多個知名框架或函式庫的結論。當有符合以下特性時,就適合作為繼承類:
- 符合里氏替換原則(Liskov Substitution Principle)下,擴充功能
- 基於原類所延伸的特定功能
條件一是必然的,因為它是物件導向設計「原則」。如 Carbon 繼承 DateTime,Carbon 做的事是擴充 DataTime 的功能。因符合里氏替換原則,所以 Carbon 可以取代所有 DateTime 出現的場景。
而條件二就比較難說明,以 Symfony Response 類與 Laravel Response 類的繼承鏈為例:
Symfony Response 是基礎類,而繼承它的 JsonResponse 與 RedirectResponse 都是為了「特定功能」擴充的,而且這個特定功能是基於原本 Response 所延伸的:Json 是 HTTP Body 的格式,Redirect 是 HTTP status code 與 Location Header 的組合。
因為是原本基礎類別的延伸,所以實際在使用這三個 Symfony 類別時,可以看有沒有符合特定功能來選擇適合的類別。
而 Laravel 繼承類則是擴充功能的應用,當然也符合里氏替換原則。
小結
以實作應用程式的角度來說,適用繼承的場景不多,主要是因為需求會變,因此使用繼承會擴大影響範圍,反而是適合使用組合類的設計模式解決。
而框架或其他解決特定功能的底層工具,因為需求變化不大,所以情境符合的前提下是可以使用繼承的,目前在 Laravel 框架也很常看到,如 RateLimiting。