如何使用 C# 實現 Decorator Pattern ?
Decorator Pattern 是 OOP 中著名的 Design Pattern,尤其可在不改變 interface 的前提下,動態對原有物件增加功能,隨著 FP 逐漸受到重視,Decorator Pattern 在實作上也有了新的面貌。
Version
macOS High Sierra 10.13.3
.NET Core SDK 2.1.101
JetBrains Rider 2017.3.1
C# 7.2
User Story
假設你在處理訂單,訂單的折扣方式有兩種
- 超過 1000 元,則
全館八折
再滿千送百
- 不到 1000 元,則
全館八折
Task
先使用一般 if else
寫法完全需求,最後再分別以 OOP 與 FP 手法重構成 Decorator Pattern。
Definition
Decorator Pattern
在不改變原有 interface 的前提下,動態增加原有的功能
- Client:
Context
的 user,實務上可能是 component 或 controller - Context:提供 client 呼叫的 class,實務上可能是 service
- ComponentInterface:定義
ConcreteComponent
與ConcreteDecorator
的共同 interface,只有Operation()
- AbstractDecorator:負責處理
ConcreteDecorator
間共用的 constructor - ConcreteDecorator:實際要 decorate 的功能
適用時機
- 在不改變原有 interface 的前提下,動態增加原有的功能
- Decorator 的功能常會排列組合變動
優點
- 無論怎麼增加新功能,interface 都不會改變
- 可以隨意的組合 decorator,方便維護
缺點
- 若 decorator 過多,容易造成 decorator class 數量爆炸
Architecture
OrderService
相當於Context
PriceInterface
相當於ComponentInterface
,定義DiscountComponent
與RebateDecorator
的共同 interfaceDiscountComponent
相當於ConcreteComponent
,實作全館八折
功能AbstractDecorator
實作ConcreteComponent
間共用的 constructorRebateDecorator
相當於ConcreteDecorator
,實作滿千送百
功能
Implementation
Program.cs
1 | using System; |
10 行
1 | var orderService = new OrderService(); |
將商業邏輯都寫在 OrderService
,當 originalPrice
傳入 GetPrice()
後,應回傳 滿千送百
或 全館八折
後的 realPrice
。
If Else
OrderService.cs
1 | namespace OrderLibrary |
使用 if else
很直覺的寫出程式碼,在不同 price 條件下,會有不同的計算 price 商業邏輯。
在以上的程式碼我們發現了幾件事情:
- 無論任何價錢都是
全館八折
,所以price * 0.8
已經重複 - 100
只有發生在超過 1000
事實上在電子商務領域,所有的促銷折扣都可能根據需求而排列組合動態調整,目前看起來 全館八折
算是基本,但 滿千送百
算是附加上去的促銷折扣,且隨時可能調整。
對於 滿千送百
來說,算是由 全館八折
附加上去的,因此我們可以將 滿千送百
看成是 全館八折
的 decorator,也就是在原有的 全館八折
的基礎下,附加 滿千送百
的功能,這就是 Decorator Pattern。
Unit Test
在重構之前,必須要有測試保護,才能確保沒把原本的商業邏輯重構壞,因此我們先準備好 OrderService
的 Unit Test,確保每個 if else
的 path 都有測到。
UnitTest1.cs
1 | using Microsoft.VisualStudio.TestTools.UnitTesting; |
由於本文重點不是在講 Unit Test,因此就不浪費篇幅解釋以上程式碼。
Decorator Pattern
PriceInterface
PriceInterface.cs
1 | namespace OrderLibrary |
定義 PriceInterface
必須有 CalculatePrice()
,將來其他 component 與 decorator 必須遵守此 interface。
DiscountComponent
DiscountComponent.cs
1 | namespace OrderLibrary |
遵守 PriceInterface
下的 DiscountComponent
,實現 全館八折
。
AbstractDecorator
AbstractDecorator.cs
1 | namespace OrderLibrary |
第 3 行
1 | public abstract class AbstractDecorator: PriceInterface |
Decorator
也要遵守 PriceInterface
,因此對 client 而言,無論是 DiscountComponent
或 RebateDecorator
,都視為 PriceInterface
物件,這樣 client 就可以在 interface 沒有變動的前提下,替 component 增加 decorator 功能,符合 開放封閉原則
要求。
第 5 行
1 | protected PriceInterface _component; |
所有 decorator 必須由其 constructor 傳入 component 或 decorator,但因為這兩個 class 都是實作 PriceInterface
, 可以抽象化視為 PriceInterface
型別物件。
由於每個 decorator 都會使用相同的 constructor 處理 PriceInterface
型別物件,所以特別抽出來寫在 AbstractDecorator
。
Decorator Pattern 之所以會特別有
AbstractDecorator
設計,主要也是要避免各 decorator 的 constructor 程式碼重複問題
12 行
1 | public abstract double CalculatePrice(double price); |
caluculatePrice()
為 PriceInterface
所定義,因為各 Decorator
會有自己的 calculatePrice()
方式,因此不需由 AbstractDecorator
實作,宣告為 abstract
即可,交由 ConcreateDecorator
自行實作。
RebateDecorator
RebateDecorator.cs
1 | namespace OrderLibrary |
第 3 行
1 | public class RebateDecorator: AbstractDecorator |
因為 AbstractDecorator
已經幫我們處理共用 constructor 部分,因此要繼承 AbstractDecorator
,但別忘了 AbstractDecorator
也是實作 PriceInterface
,根據 里式替換原則
,因此 RebateDecorator
也還是 PriceInterface
型別。
第 5 行
1 | public RebateDecorator(PriceInterface component) : base(component) |
當 component 或 decorator 透過 constructor 傳入時,再透過 base
傳入 AbstractDecorator
的 constructor,因為共用的 constructor 已經抽到 AbstractDecorator
了。
第 9 行
1 | public override double CalculatePrice(double price) |
真正實現 滿千送百
部分,但別忘 滿千送百
是 decorator,是依附在 全館八折
下,因此必須先執行 _component.calculatePrice(price)
,也就是先計算 全館八折
後,再 -100
實現 滿千送百
。
OrderService
OrderService.cs
1 | namespace OrderLibrary |
第 9 行
1 | PriceInterface discountComponent = new DiscountComponent(); |
計算 全館八折
。
建立 DiscountComponent
物件,並執行 CalculatePrice()
計算。
值得注意 discountComponent
的型別為 PriceInterface
。
Q :
PriceInterface
可用var
取代嗎?
這裏 var
會將 discountComponent
推導為 DiscountComponent
型別,雖然不會執行錯誤,但 intention 不對。
因為這裡所要表現的就是 ConcreteComponent
與 ConcreteDecorator
都是相同的 ComponentInterface
,也就是物件導向的 多型
,但卻被 var
的 Type Inference 推導為 PriceInterface
,與我們預期不合。
var
適合用在 primitive type,如 (int
、string
…),或具體的class
type,這些都能被 Type Inference 所正確推導,但不適合用在使用interface
與abstract class
展現多型
時,就算執行不會錯,但 intention 與語意
不佳
14 行
1 | PriceInterface discountComponent = new DiscountComponent(); |
計算 全館八折
+ 滿千送百
。
先建立 DiscountComponent
,此為 全館八折
。
再建立 RebateDecorator
,並將 DiscountComponent
傳入,此為以 全館八折
為基底,再附加 滿千送百
計算。
值得注意的是無論怎麼 decorate,最後都還是 PriceInterface
型別,因此可使用相同的 CalculatePrice()
繼續計算。
Decorator Pattern 可貴之處就在於沒破壞原本 interface,就能增加新功能,如可以將 component 與經過 decorator 裝飾過的物件都放在
List
內,因為 interface 都相同,所以型別也相同,可抽象化廣義是為同型別
物件加以操作,符合開放封閉原則
要求
Dependency Injection
很多 Design Pattern 都是以 constructor 作為傳入 初始值
,在 Decorator Pattern 也不例外,直接將 component 或 decorator 傳入 ConcreteDecorator
。
但在目前 DI 的世界則面臨挑戰,Decorator Pattern 不再適合使用 constructor 傳入任何物件,而必須將 constructor 讓給 DI。
新增 DecoratorInterface
描述 Decorator()
,AbstractDecorator
必須同時實現 PriceInterface
與 DecoratorInterface
兩個 interface。
DecoratorInterface
DecoratorInterface.cs
1 | namespace OrderLibrary |
定義 DecoratorInterface
必須有 Decorate()
,將來其他 decorator 必須遵守此 interface。
原本 Decorator Pattern 是透過 constructor 傳入原有物件,現在要改透過 Decorate()
。
值得注意的是 Decorate()
的 input 為 PriceInterface
型別,回傳也是 PriceInterface
型別,也就是經過 decorate 的物件,仍然有相同的 interface,不會改變型別,可順便做 fluent interface 操作。
Q : 為什麼不在
PriceInterface
新增Decorate()
即可,還要新增DecorateInterface
?
由於 DiscountComponent
與 RebateDecorator
共用 PriceInterface
,且 Decorate()
主要是為了 decorator 所用,加在 PriceInterface
會造成 DiscountComponent
有 Decorate()
的空實作,這違反了 介面隔離原則
,所以必須另外開新的 interface。
AbstractDecorator
AbstractDecorator.cs
1 | namespace OrderLibrary |
第 3 行
1 | public abstract class AbstractDecorator: PriceInterface, DecoratorInterface |
AbstractDecorator
除了必須實作原有的 PriceInterface
外,為了解決 DI 問題,還必須同時實作 DecoratorInterface
。
第 5 行
1 | protected PriceInterface _component; |
PriceInterface
物件由原本的 constructor 傳入,改透過 Decorate()
傳入,最後傳回 this
,可順便做 fluent interface 操作。
RebateDecorator
RebateDecorator.cs
1 | namespace OrderLibrary |
因為已經改由 Decorate()
傳入物件,將原有的 constructor 刪除。
OrderService
OrderService.cs
1 | using System; |
16 行
1 | PriceInterface discountComponent = new DiscountComponent(); |
原本 DiccountComponent
是由 RebateDecorator
的 constructor 傳入,現在改由 Decorate()
傳入。
Delegate
從 Decorator Pattern,我們看到了 OOP 幾個缺點:
- 原本簡單的
if ... else
被拆成很多檔案,導致 class 爆炸 ComponentInterface
、DecoratorInterface
、AbstractDecorator
等新增的 class 與需求無關,算是因為使用 Decorator Pattern 所產生的額外 classinterface
雖然有制定 spec 與 compiler 編譯檢查的優點,但是只有一個 method 的 interface,是否有有開interface
的需要?
雖然 PriceInterface
定義了 CalculatePrice()
,但整個 interface
只有一個 method,顯然使用 interface
有殺雞用牛刀之嫌,此時可將 interface
退化成 delegate
。
PriceDelegate
PriceDelegate.cs
1 | namespace OrderLibrary |
定義 PriceDelegate
delegate,其 signature 為 double => double
,也就是 input 為 double
,return 為 double
。
實務上 Decorator Pattern 通常只有一個 method,所以就很適合將 interface 退化成 delegate
PriceComponent
PriceComponent.cs
1 | namespace OrderLibrary |
從 DiscountComponent
退化成 PriceComponent
,將 CalculatePrice()
改成 CalculateDiscountPrice()
。
將每個 ConcreteComponent
改用 function 表示,也由於當 pure function 用,此時使用 static 即可。
PriceDecorator
PriceDecorator.cs
1 | namespace OrderLibrary |
將每個 decorator 以 function 表示,此時改用 static function 即可。
orginalFn
即為原本的 function,CalculateRebatePrice()
則為 Higher Order Function。
CalculateRebatePrice()
回傳的是 PriceDelegate
,所以要 return Lambda。
一般來說,OOP 都建議不要使用
static
,但這裡是例外,此時是將 class 當成 module 看待,static function
是當成 FP 的pure function
使用,也就是這種 class 將沒有 field,也不使用 OOP 的繼承
與組合
。
OrderService
OrderService.cs
1 | namespace OrderLibrary |
14 行
1 | PriceDelegate calculateDiscountPrice = PriceComponent.CalculateDiscountPrice; |
- 在 OOP 是
ConcreteComponent
與ConcreteDecorator
的型別都是ComponentInterface
- 在 FP 化之後,
CalculateDiscountPrice()
與CalculateRebatePrice()
的型別都是PriceDelegate
將原來的 CalculateDiscountPrice
傳入 CalculateRebatePrice()
後,就相當於以 CalculateRebatePrice()
去 decorate 原來的 CalculateDiscountPrice()
。
我們可以發現 OOP 的 Decorator Pattern,對於 FP 本質來說只是 Higher Order Function 的應用;將 function 傳入 Higher Order Function,就相當於以 Higher Order Function 加以 decorate。
Func
雖然 PriceDelegate
定義 function 的 spec 的理念很不錯,若 delegate
只使用一遍,真的需要開一個檔案建立 delegate
嗎 ?
將 PriceDelegate
刪除。
PriceDecorator
PriceDecorator.cs
1 | using System; |
直接以 Func<double, double>
取代 PriceDelegate
,其餘不變。
OrderService
OrderService.ts
1 | using System; |
直接以 Func<double, double>
取代 PriceDelegate
,其餘不變。
我們可以發現 FP 化的 Decorator Pattern,基本上已經沒有 interface,所有的
ConcreteComponent
都簡化成PriceComponent
;而所有的ConcreteComponent
都簡化成PriceDecorator
,而且也沒用到什麼高深的技巧,就只有 FP 的基本招式:Higher Order Function。也就是 function 的組合遠比 object 組合容易,因此並不需要動用到 interface 與 abstract class 等複雜的機制。
Summary
以 SOLID 角度重新審視經過 FP 二次重構後的 Decorator Pattern:
- 單一職責原則:將所有的 decorator function 統一整理在
PriceDecorator
,符合 SRP - 開放封閉原則:將來若有新的 decorator function,只要統一加在
PriceDecorator
即可,符合 OCP - 里氏替換原則:因為沒用到繼承,所以沒有違反 LSP 問題
- 最小知識原則:decorator function 並沒有暴露到 client,符合 LKP
- 介面隔離原則:因為從
interface
退化成delegate
,FP 天生符合 ISP - 依賴反轉原則:service 與 decorator 之間的耦合僅限於
delegate
與Func<T>
,而不是直接耦合與特定 function,符合 DIP
Conclusion
- Decorator 本質就是 object 的組合,但 object 的組合沒 function 簡單直覺,所以需要搭配 interface 與 abstract class,但若純 function,只要使用 Higher Order Function 即可簡單完成
- FP 的出現,讓 Design Pattern 的實踐方式,不再只有 OOP 一途,可視實際需求決定該使用 OOP 或 FP
- OOP 讓我們以
抽象設計
的角度看系統,但 FP 讓我們以簡化設計
的角度看系統,實務上建議以 OOP 做第一階段的重構,再輔以 FP 做第二階段的重構,可解決 OOP 容易 Over Design 的問題
Sample Code
完整的範例可以在我的 GitHub 上找到