如何使用 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:相當於
Client
IPrice:定義
MabookPro
、IPadAir
、AppleWatch
與AppleCombo
的共用 interface,相當於Component
interfaceMacbookPro:單一產品,相當於
Leaf
IPadAir:單一產品,相當於
Leaf
AppleWatch:單一產品,相當於
Leaf
AppleCombo:套餐組合,相當於
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 產品就叫做I
PadAir
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 的關鍵,也因為都有實踐
IPrice
interface,我們能安心使用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 上找到