繼承與組合的比較

常聽人講「少用繼承,多用組合」,那到底這兩種方法有什麼差異呢?本篇文章來聊聊這兩個方法在不同情境下的實作、差異與比較。

首先先針對「少用繼承,多用組合」的繼承和組合做定義:

「繼承」(inheritance)是 Is-a 關係,指的是包含關係,比方說「馬爾濟斯是狗」,則狗是馬爾濟斯的一般化(generalization),指的是在講「狗」的時候也包含了「馬爾濟斯」;馬爾濟斯是狗的特殊化(specialization),意義就類似「馬爾濟斯」是「狗」的其中一種。

過去曾分享過依賴關係,以下所提到的「組合」(composition)是指文章裡面所提到的:依賴、關聯、聚合、組合,這四種關係。

繼承與組合,都是指兩個類別之間的關係。繼承與組合也可以應用在「共用程式碼」的場景上,但如何應用才能發揮「模組化」的好處呢?下面將會舉幾個情境說明,並提供個人想法做為參考。

情境一:「購物車」與「金流」

「購物車」除了增修刪商品外,還可以結帳(checkout);結帳後會觸發「金流」的扣款(debit),背後是呼叫指定金流 API。

繼承解

先不論合不合理,反正程式能動就好。先寫一個購物車父類別實作金流功能:

abstract class BaseShopcart
{
public function debit($amount)
{
// do something
}
}

然後再寫子類別:

class Shopcart extends BaseShopcart
{
public function checkout($amount)
{
$this->debit($amount);
}
}

最後直接實例化子類別:

$shopcart = new Shopcart();
$shopcart->checkout(81000);

這個做法是明顯不好的,原因如下:

  • 就需求來看,購物車和金流有它們各自的職責,使用繼承會讓職責變得混亂,比方說在使用購物車的時候,同時可以操作金流,這並不合理。
  • 繼承是 Is-a 關係。實作金流的類別(指的是上例 BaseShopcart)與購物車類別並沒有 Is-a 關係,因為購物車不是金流的一種,而且用 Base 關鍵字也不符合 Is-a 關係的語意,所以不適合這麼寫。

組合解

組合的概念是「上層模組使用下層模組」,從流程上可以從觸發事件的角度觀察,通常發動觸發的會是上層模組。

金流的實作會因不同的廠商而有所不同,可以參考設計模式的轉接器模式(Adapter Pattern)來實作

首先是下層模組介面與實作:

interface Cash
{
public function debit($amount);
}

class EcPay implements Cash
{
public function debit($amount)
{
// do something
}
}

從需求來看,上層模組沒有下層模組會無法使用,所以需要透過建構子傳入實例:

class Shopcart
{
private $cash;

public function __construct(Cash $cash)
{
$this->cash = $cash;
}

public function checkout($amount)
{
$this->cash->debit($amount);
}
}

使用時,上層模組跟下層模組需要一起使用:

$cash = new EcPay();

$shopcart = new Shopcart($cash);
$shopcart->checkout(81000);

以這個例子來說,組合是個可行且合理的選擇,可以有效的將職責區分開。下層模組還可以依不同金流,選用不同的實作。

情境二:「取貨方法」與「實作」

延續前一個例子,結帳完後就是選擇取貨的方法,不同的取貨方法,如店內取貨或是宅配到府,會使用不同的程式實作,但會共用相同的訂單流程。

繼承解

「相同的流程,不同的實作」是樣版方法模式(Template Method Pattern)可以解決的問題。它定義了一個基本的程式執行流程(即訂單流程),然後保留抽象實作空間讓子類發揮。

首先先寫一個訂單的抽象類,是在結帳後,設定取貨方法(直譯,pickup method):

abstract class Order
{
public afterCheckout()
{
// do something

$this->setupPickupMethod();

// do something
}

abstract protected setupPickupMethod();
}

接著寫店內(Store)取貨實作類,與宅配(Delivery):

class Store extends Order
{
protected setupPickupMethod()
{
// do something
}
}

class Delivery extends Order
{
protected setupPickupMethod()
{
// do something
}
}

這個做法雖然可行,但它的缺點在於抽象化的職責只能有一組,以上例來說即是指「取貨方法」。

剛好這個例子不是很好,以這個 Order 命名來說,它還會承載很多不同的職責,因此如果有其他需要抽象化的需求,如金流方法,就無能為力了。

組合解

設計模式裡,有兩個模式可以取代繼承,其中一個是裝飾者模式(Decorator Pattern)。而這裡選擇的是另一個橋接模式(Bridge Pattern),它能讓「抽象」和「實作」分離並各自擴展。這個意思是:原本是 A extends B,改使用橋接模式就能讓 A 和 B 自由抽換成 C 到 Z 了。

以下就來改寫吧,首先先定義取貨方法的介面,與實作對應的程式:

interface Pickup
{
public function setup();
}

class Store implements Pickup
{
protected setup()
{
// do something
}
}

class Delivery implements Pickup
{
protected setup()
{
// do something
}
}

接著把 Order 改成抽象類,這樣也可以針對 VIP 訂單跟一般訂單實作不同的客製化功能:

abstract class Order
{
private $pickup;

public function __construct(Pickup $pickup)
{
$this->pickup = $pickup;
}

public function checkout($price)
{
$price = $this->discount($price);

$this->pickup->setup();

// do something
}

abstract protected function discount($price);
}

class NormalOrder extends Order
{
protected function discount($price)
{
return $price;
}
}

class VipOrder extends Order
{
protected function discount($price)
{
// VIP 打九折
return (int)($price * 0.9);
}
}

最後使用的方法如下:

$order = new NormalOrder(new Store());

改用使用橋接模式後,物件的替換變得更加簡單了。

  1. 抽象層想替換,就新增 Order 實作
  2. 實作層想替換,就新增 Pickup
  3. 想新增別的實作層,只要調整抽象層的流程就行了。

繼承適用的場景

上面都是講不適合繼承的例子,這一個小節想討論的是,什麼時候適合用繼承?

分享兩個會使用繼承的場景,首先第一個比較容易理解:當沒有介面,或者依賴方所依賴的不是介面的時候。如:

class Dep
{
public function something() {}
}

class Context
{
public function call(Dep $dep)
{
$dep->something();
}
}

Context 類別直接依賴 Dep 類別,因此在不改架構的前提下,如果要擴充 Dep 的 something 方法時,「只能」使用繼承去覆寫(override):

class NewDep extends Dep
{
// override
public function something() {}
}

當依賴介面的時候,就會類似情境一的組合解--使用轉接器模式,只要有實作介面的類別,都能作為參數傳入,這是應用物件導向的多型特色。

第二個情境,這是我自己觀察多個知名框架或函式庫的結論。當有符合以下特性時,就適合作為繼承類:

  1. 符合里氏替換原則(Liskov Substitution Principle)下,擴充功能
  2. 基於原類所延伸的特定功能

條件一是必然的,因為它是物件導向設計「原則」。如 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