如何使用 C# 實現 Strategy Pattern ?
Strategy Pattern 是 OOP 中最著名的 Design Pattern,幾乎可以說是 OOP 中使用 interface
最經典的應用,隨著 FP 逐漸受到重視,Strategy Pattern 在實作上也有了新的面貌。
Version
macOS High Sierra 10.13.3
.NET Core SDK 2.1.101
Rider 2017.3.1
C# 7.2
User Story
假設你在處理訂單,訂單的折扣方式有兩種
- 不到 1000 元,則
全館八折
- 超過 1000 元,則
滿千送百
Task
先使用一般 if else
寫法完全需求,最後再分別以 OOP 與 FP 手法重構成 Strategy Pattern。
Definition
Strategy Pattern
將不同演算法抽象化成相同 interface,讓高階模組與實際演算法解耦合,而彼此僅相依於 interface,進而可動態切換演算法
此為 OOP
多型
最典型應用,可以一次看到單一職責原則
、開放封閉原則
、依賴反轉原則
、、最小知識原則
- Client :
Context
的 user,實務上可能是 component 或 controller - Context : 提供 client 呼叫的 class,實務上可能是 service
- Strategy : 定義
ConcreteStrategy
的 interface,只有Execute()
,負責要封裝的演算法邏輯 - ConcreteStrategy : 封裝演算法邏輯
適用時機
- 需要使用
if else
在 run-time 切換不同的演算法
優點
- 每個演算法使用一個 strategy class,符合
單一職責原則
- 將來若有新的演算法要加入,不用修改 service,而是新增
ConcreStrategy
,符合開放封閉原則
- Client 與演算法解耦合,兩者都緊相依於 interface,符合
依賴反轉原則
- Client 不需知道有哪些 strategy,符合
最小知識原則
缺點
- 若可選擇的演算法過多,容易造成 strategy class 數量爆炸
在 GoF 的 Strategy Pattern 中,要求 Client 去 new strategy 傳入 Context
。
1 | var context = new Context(new ConcreteStrage1()); |
但實務上,Context
、Strategy
、ConcreteStrategy
都會在 Class Library 內,根據 最小知識原則
,client 應該知道越少 class 越好,還要 client 知道 Class Library 有哪些 strategy class 似乎違反 最小知識原則
。
實務上作法
1 | var context = new Context(); |
Client 不必負責知道 strategy,但會將 enum
開放給 client,client 只需將 enum
傳入 Context
即可。
1 | var strategy = StrategyFactory.Create(strategyEnum); |
至於 Context.Requst()
內部會透過 StrategyFactory.Create()
傳回適當的 strategy。
若真的要由 client 決定 strategy,也可以將 StrategyFactory
開放給 client 使用,最少 client 只要知道 StrategyFactory
即可,而不需知道所有 strategy class,符合 最小知識原則
要求。
實務上 Strategy Pattern 會與 Factory Pattern 搭配,至於 Factory 是否要暴露給 client,可視需求決定
Architecture
OrderService
相當於Context
StrategyFactory
負責根據需求選擇 strategyStrategyInterface
定義個演算法抽象化的 method 名稱RebateStrategy
實作買千送百
DiscountStrategy
實作全館八折
Implementation
Program.cs
1 | using System; |
10 行
1 | var orderService = new OrderService(); |
將商業邏輯都寫在 OrderService
,當 originalPrice
傳入 GetPrice()
後,應回傳 滿千送百
或 全館八折
後的 realPrice
。
If Else
OrderService
OrderService.cs
1 | namespace OrderLibrary |
使用 if else
很直覺的寫出程式碼,在不同 price 條件下,會有不同的計算 price 商業邏輯。
若計算 price 商業邏輯選擇不多,基本上使用 if else
無傷大雅,若選擇很多,使用 if else
的方式就會有以下問題 :
GetPrice()
含有太多計算 price 商業邏輯,將來不容易維護,違反單一職責原則
- 將來若要增加 price 商業邏輯,勢必繼續修改
GetPrice()
與if else
,將來不容易違誤,違反開放封閉原則
Unit Test
在重構之前,必須要有測試保護,才能確保沒把原本的商業邏輯重構壞,因此我們先準備好 OrderService
的 Unit Test,確保每個 if else
的 path 都有測到。
UnitTest1.cs
1 | using Microsoft.VisualStudio.TestTools.UnitTesting; |
由於本文重點不是在講 Unit Test,因此就不浪費篇幅解釋以上程式碼。
Strategy Pattern
StrategyInterface
StrategyInterface.cs
1 | namespace OrderLibrary |
定義 StrategyInterface
有 CalculatePrice()
,將來其他 strategy 必須遵守此 interface。
RebateStrategy
RebateStrategy.cs1
2
3
4
5
6
7
8
9
10namespace OrderLibrary
{
public class RebateStrategy : StrategyInterface
{
public double CalculatePrice(double price)
{
return price - 100.0;
}
}
}
實作 StrategyInterface
,實現 買千送百
。
DiscountStrategy
DiscountStrategy.cs
1 | namespace OrderLibrary |
實作 StrategyInterface
,實現 全館八折
。
StrategyFactory
StrategyFactory.cs
1 | using System.Dynamic; |
目前有 RebateStrategy
與 DiscountStrategy
兩個 strategy,到底 OrderService.GetPrice()
該選擇拿個 strategy 呢 ?
特別新增 StrategyFactory
專責負責根據不同的 price,選擇不同的 strategy。
Strategy Pattern 實務上都會搭配 Factory Pattern,由
factory
根據條件選擇適當的 strategy
OrderService
OrderService.cs
1 | using System; |
OrderService
可輕易的根據 Create(price)
回傳的 strategy,呼叫 calculatePrice()
。
Delegate
從 Strategy Pattern,我們看到了 OOP 幾個缺點:
- 原本簡單的
if ... else
被拆成很多檔案,導致 class 爆炸 interface
雖然有制定 spec 與 compiler 編譯檢查的優點,但是只有一個 method 的 interface,是否有有開interface
的需要?
雖然 StrategyInterface
定義了 CalculatePrice()
,但整個 interface
只有一個 method,顯然使用 interface
有殺雞用牛刀之嫌,此時可將 interface
退化成 delegate
。
CalculatePriceDelegate
CalculatePriceDelegate.cs
1 | namespace OrderLibrary |
定義 CalculatePriceDelegate
delegate,其 signature 為 double => double
,也就是 input 為 double
,return 為 double
。
Q : Delegate 到底是什麼 ?
Delegate 在 C# 1.0 就已經提出,C 對於 function,只提供了一個 function pointer ,C# 想為 function pointer 提供 type safety 功能,因此提出了 delegate
。
簡單的說,delegate
就是 function 的 named type,將 function 取一個 有名稱
的 型別
,之後就可以用 delegate 所定義的型別當作 function 的 spec,若 function 不符合 delegate
的 signature,則 compiler 會報錯,達到 type safety 目標。
delegate
是 C# 支援 FP 的濫觴,所有的 C# 的 Functional 支援,都是從 delegate
開始。
實務上 Strategy Pattern 通常只有一個 method,所以就很適合將 interface 退化成 delegate
PriceStrategy
PriceStrategy.cs
1 | namespace OrderLibrary |
將每個 strategy 以 function 表示,此時改用 static function 即可。
一般來說,OOP 都建議不要使用 static
,但這裡是例外,此時是將 class 當成 module 看待,static function
是當成 FP 的 pure function
使用,也就是這種 class 將沒有 field,也不使用 OOP 的 繼承
與 組合
。
如此我們就 Strategy Pattern 的 strategy class 爆炸問題解決,無論幾個 strategy,都永遠只有一個 strategy class。
OOP 將所有的 strategy 都拆成一個 class,很容易因為 strategy 過多,而造成 class 爆炸,這也是 OOP 一直被人詬病的問題之一,FP 將 strategy class 退化成 strategy function,並將所有 strategy 放在同一個 class 內,避免 class 滿天飛
StrategyFactory
StrategyFactory.cs
1 | namespace OrderLibrary |
StrategyFactory
的 Create()
從原本回傳 strategy object 改成回傳 strategy function,也就是所有的 function 都必須符合 CalculatePriceDelegate
delegate 型別,若違反 delegate
,將會 compiler 錯誤。
簡單的說,Delegate 就是 function 的 interface 或 function 的 type,這樣 compiler 才能幫我們檢查
Func
雖然 CalculatePriceDelegate
定義 function 的 spec 的理念很不錯,若 delegate
只使用一遍,真的需要開一個檔案建立 delegate
嗎 ?
StrategyFactory
StrategyFactory.cs
1 | using System; |
將 delegate
退化成 Func<T>
,其中泛型第一個參數為 input 型別,最後一個參數為 return 型別,也就是 Func<double, double>
其實與 delegate double CalculatePriceDelegate(double price);
意義完全相同,只是 Func<T>
沒有型別名稱,而 delegate
有型別名稱而已。
如此我們就將 CalculatePriceDelegate
消滅了。
Q : Delegate vs. Func
vs. Action vs. Predicate
- Delegate : C# 1.0,可為 function 定義型別,並且定義型別名稱
- Func
: C# 3.0,可為 function 定義型別,但不用定應型別名稱,適用於有 return 值的 funciton - Action
: C# 3.0,可為 function 定義型別,但不用定義型別名稱,適用無 return 值 (void) 的 function - Predicate
: C# 3.0,可為 function 定義型別,但不用定義型別名稱,適用無 return bool 的 function
若 function type 需要重複使用,則建議使用
delegate
,並取一個型別名稱若 function type 只用一次,不需要型別名稱,則建議使用
Func<T>
、Action<T>
或Predicate<T>
,可視為 anonymous function type 或 unnamed funciton type
Lambda
StrategyFactory
回傳 strategy function 的理念雖然不錯,若 StrategyFactory
在整個 project 只使用一次而已,是否真的要另外開一個 class ?
OrderService
OrderService.cs
1 | using System; |
C# 3.0 提出了 Lambda,讓我們可以直接在 function 定義新的 function,而不一定要將 function 建立在 class 內。
直接在 GetPrice()
內定義 CreateStrategy()
。
Func<>
第一個參數 double
為 CreateStrategy()
的 input 參數型別。
因為 CreateStrategy()
是回傳 Func<double, double>
的 funciton,故第 2 個參數為 Func<double, double>
。
如此我們就將 StrategyFactory
class 消滅了。
Local Function
OrderService
OrderService.cs
1 | using System; |
Lambda 雖然讓我們可以在 function 內定義 funtion,但 Func<double, Func<double, double>>
寫法有點恐怖。
C# 7.0 提出了 Local Function,類似宣告 function 的寫法,讓 function 的定義更人性化。
實務上建議使用 Local Function 取代 Lambda
Summary
以 SOLID 角度重新審視經過 FP 二次重構後的 Strategy Pattern:
- 單一職責原則:將所有的 strategy 統一整理在
PriceStrategy
,符合 SRP - 開放封閉原則:將來若有新的 strategy,只要統一加在
PriceStrategy
即可,符合 OCP - 里氏替換原則:因為沒用到繼承,所以沒有違反 LSP 問題
- 最小知識原則:strategy 並沒有暴露到 client,符合 LKP
- 介面隔離原則:因為從
interface
退化成delegate
,FP 天生符合 ISP - 依賴反轉原則:service 與 strategy 之間的耦合僅限於
delegate
與Func<T>
,而不是直接耦合與特定 function,符合 DIP
Conclusion
- Strategy Pattern、State Pattern 與 Chain of Responsibility 都是在解決
if else
問題,但 intention 不太一樣 - FP 的出現,讓 Design Pattern 的實踐方式,不再只有 OOP 一途,可視實際需求決定該使用 OOP 或 FP
- OOP 讓我們以
抽象設計
的角度看系統,但 FP 讓我們以簡化設計
的角度看系統,實務上建議以 OOP 做第一階段的重構,再輔以 FP 做第二階段的重構,可解決 OOP 容易 Over Design 的問題 - C# 1.0 主要是 OOP,C# 2.0 主要是泛型,C# 3.0 之後主要是 FP 部分,如 Lambda、LINQ,C# 也是在 3.0 開始與 Java 分道揚鑣,朝向 OOP + FP 雙 hybrid 語言目標邁進,尤其 C# 7.0 非常明顯,如 Tuple、Descontruction、Pattern Matching、Local Function … 都是 FP 語言很基本的機制
Sample Code
完整的範例可以在我的 GitHub 上找到