如何使用 C# 實現 Composite Pattern ?
Composite Pattern 是 OOP 中著名的 Design Pattern,無論是 物件 或 容器,都能使用相同 interface 一視同仁的操作。
Version
macOS High Sierra 10.13.3
.NET Core SDK 2.1.101
Rider 2017.3.1
C# 7.2
User Story
由購物車計算商品價錢,但在活動期間,Apple 產品組合有特惠 :
- MacBook Pro 15”:6 萬
- iPad Air:1 萬
- Apple Watch:1 萬
- Apple 套餐組合 : (Macbook Pro + iPad Air + Apple Watch) 總價打九折:
(6 + 1 + 1) * 0.9= 7.2 萬
若同時買 MacBook Pro + iPad Air + Apple Watch + Apple 套餐為 15.2 萬。
Task
在不改變產品 interface 的原則下,計算購物車價錢。
Definition
Composite Pattern
當
單一資料與整體資料同時存在時,讓 client 有一致性的操作方式
Client:
Leaf與Composite的 user,實務上可能是 component 或 controllerComponent:定義
Leaf與Composite的共用 interfaceLeaf:表示
單一資料Composite:表示
整體資料
由於 Composite 表示 整體資料,實務上可能還包含其他 Composite,而產生類似 tree 的結構。
Composite Pattern 的確可以處理 tree 結構,但 tree 並非必要條件
適用時機
- 同時存在
單一資料與整體資料時 - 想讓 client 不需分辨
單一資料與整體資料,都以相同的方式操作資料 - 不想使用
繼承來描述樹狀結構
優點
- Client 程式碼精簡,不需寫一堆
if else判斷是單一資料或整體資料,完全發揮 OOP 的多型 - 增加新的
Leaf或Composite,也不用修改 client 程式碼,符合開放封閉原則
Architecture
ShoppingService:相當於
ClientIPrice:定義
MabookPro、IPadAir、AppleWatch與AppleCombo的共用 interface,相當於ComponentinterfaceMacbookPro:單一產品,相當於
LeafIPadAir:單一產品,相當於
LeafAppleWatch:單一產品,相當於
LeafAppleCombo:套餐組合,相當於
Composite
Implementation
Program.cs
1 | using System; |
無論是 MacBook Pro、IPad Air、Apple Watch 或 AppleCombo 套餐組合,都統一用 AddCart() 新增。
且無論是 單一產品 或 套餐組合,都使用 CalculatePrice() 統一計算。
可以猜想
AddCart()與CalculatePrice()應該遵循特定 interface 使用,且單一產品與套餐組合也應該遵循特定 interface 實踐,client 才有可能這麼漂亮的實作
Composite Pattern
IPrice
IPrice.cs
1 | namespace ShoppingCartLibrary |
訂定 單一產品 與 套餐組合 所遵守的共同 interface,這也是 Composite Pattern 的關鍵。
MacBookPro
MacBookPro.cs
1 | namespace ShoppingCartLibrary |
單一產品 MacBookPro 實踐 IPrice。
IPadAir
IPadAir.cs
1 | namespace ShoppingCartLibrary |
單一產品 IPadAir 實踐 IPrice。
在 C# 世界習慣 interface 以
I為 prefix,理論上 class name 不建議以I為開頭,這裡是因為 Apple 產品就叫做IPadAir
AppleWatch
AppleWatch.cs
1 | namespace ShoppingCartLibrary |
單一產品 AppleWatch 實踐 IPrice。
AppleCombo
AppleCombo.cs
1 | using System.Collections.Generic; |
第 5 行
1 | public class AppleCombo : IPrice |
Apple 套餐組合 也要實踐 IPrice,重點在於不再是 單一產品 要實踐 interface,而是連 套餐組合 也要實踐 interface。
也就是在將來,我們會將 單一產品 與 套餐組合 都一視同仁使用,也就是 OOP 的 多型 的實踐。
第 7 行
1 | private readonly List<IPrice> _products; |
將套餐中所有產品 封裝 在 _products field,注意 List 的泛型為 IPrice interface,也就是只有實踐 IPrice 的單一產品才能加入,若該產品沒有實踐 IPrice,compiler 在編譯階段就可以擋掉。
這也是 OOP 處理 side effect 方式,使用 private field 封裝在 class 內,避免資料被外界修改
第 9 行
1 | public AppleCombo() |
因為 Apple 套餐組合同時包含 MacBook Pro、iPad Air 與 Apple Watch,因此在 constructor 同時將產品建立好。
19 行
1 | public double GetPrice() |
將所有產品加總,並打九折。
也因為所有產品都遵守 IPrice() interface 的 GetPrice(),因此可以使用 foreach() 一視同仁以 GetPrice() 計算,這就是 interface 與 OOP 多型 的優點。
ShoppingCartService
ShoppingCartService.cs
1 | using System.Collections.Generic; |
第 7 行
1 | private readonly List<IPrice> _carts = new List<IPrice>(); |
將購物車的購物資料封裝在 _carts field,注意 List 的泛型為 IPrice interface,也就是無論是 單一產品 或者 套餐組合,只要實踐 IPrice interface 就一視同仁,只要任何產品或套餐沒實踐 interface 而加入 List,compiler 在 compile-time 就會報錯。
第 9 行
1 | public void AddCart(IPrice product) |
AddCart() 新增至購物車,只要實踐 IPrice 的 單一產品 或 套餐組合 都可加入購物車。
14 行
1 | public double CalculatePrice() |
CalculatePrice() 計算購物車內的產品總金額,也由於 單一產品 與 套餐組合 都實踐 IPrice interface,因此可以安心用 foreach() 一視同仁的使用 GetPrice() 計算。
這就是 Composite Pattern 的關鍵,也因為都有實踐
IPriceinterface,我們能安心使用IPrice的GetPrice(),且 IDE 的 intellisense 也能顯示
Refactoring
目前為止 Composite Pattern 已經完成,但我們發現有不少 code smell,需要進一步的重構。
MacBookPro
MacBookPro.cs
1 | namespace ShoppingCartLibrary |
之前將 60000.0 hardcode 在 GetPrice(),這樣會造成將來維護上的困然,應該使用 Introduce Field 重構到 field。
IPadAir
IPadAir.cs
1 | namespace ShoppingCartLibrary |
之前將 10000.0 hardcode 在 GetPrice(),這樣會造成將來維護上的困然,應該使用 Introduce Field 重構到 field。
AppleWatch
AppleWatch.cs
1 | namespace ShoppingCartLibrary |
之前將 10000.0 hardcode 在 GetPrice(),這樣會造成將來維護上的困然,應該使用 Introduce Field 重構到 field。
AppleCombo
AppleCombo.cs
1 | using System.Collections.Generic; |
21 行
1 | public double GetPrice() |
將 0.9 使用 Introduce Field 重構到 field,並將 for loop 重構成 LINQ 的 Sum() 。
實務上若搭配 List,使用
foreach()的機率幾乎為 0,因為 LINQ 對IEnumerable的支援已經很完整,可以使用 FP 的方式使用 List,更加精簡,可讀性也更高
ShoppingCartService
ShoppingCartService.cs
1 | using System.Collections.Generic; |
15 行
1 | public double CalculatePrice() |
將 for loop 重構成 LINQ 的 Sum() 。
Summary
Q:Proxy Pattern、Decorator Pattern 與 Composite Pattern 有何差異?
Proxy Pattern
Decorator Pattern
Composite Pattern
以 class diagram 角度,Proxy Pattern、Decorator Pattern 與 Composite Pattern 的確非常類似。
學 Design Pattern 不能以 class diagram 的角度去思考,而要以他要解決什麼問題來思考
Similarity
- 都在解決
繼承濫用,將原本垂直結構變成水平結構 - Interface 都不會改變
Difference
- Proxy 目的在對 service 做
控制存取 - Decorator 目的在對 service
增加功能 - Composite 目的在於對單一 model/DTO 與套餐組合 model/DTO
一視同仁
Conclusion
單一產品與套餐組合,原本是完全不同的東西,但藉由實踐相同的 interface,在邏輯上視為相同的物件,因此可藉由 OOP 的多型加以操作,而不須在 client 做任何判斷,將來新增任何單一產品或套餐組合,也不用修改 client,符合開放封閉原則的要求- 計算 List 的總和可使用 LINQ 的
Sum(),可讀性更高也更精簡 - 大部分書籍資料都將 Composite Pattern 用來實作 tree 資料結構,因此很多初學者以為此 pattern 就是為了用來實作 tree,但 tree 並非 Composite Pattern 的必要條件,事實上只要同時需要處理
單一資料與整體資料,就適合使用 Composite Pattern,因此也稱為 Part-Whole Pattern,讓單一資料與整體資料有一致操作方式,這就是 Composite Pattern 的本質
Sample Code
完整的範例可以在我的 GitHub 上找到