使用 FP 將有不同的實現方式

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 多型 最典型應用,可以一次看到 單一職責原則開放封閉原則依賴反轉原則、、最小知識原則

trategy01

  • 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
2
var context = new Context(new ConcreteStrage1());
var result = context.Request();

但實務上,ContextStrategyConcreteStrategy 都會在 Class Library 內,根據 最小知識原則,client 應該知道越少 class 越好,還要 client 知道 Class Library 有哪些 strategy class 似乎違反 最小知識原則

實務上作法

trategy01

1
2
var context = new Context();
var ressult = context.Request(StrategyEnum.ConcreteStrategy1);

Client 不必負責知道 strategy,但會將 enum 開放給 client,client 只需將 enum 傳入 Context 即可。

1
2
var strategy = StrategyFactory.Create(strategyEnum);
return strategy.Execute();

至於 Context.Requst() 內部會透過 StrategyFactory.Create() 傳回適當的 strategy。

若真的要由 client 決定 strategy,也可以將 StrategyFactory 開放給 client 使用,最少 client 只要知道 StrategyFactory 即可,而不需知道所有 strategy class,符合 最小知識原則 要求。

實務上 Strategy Pattern 會與 Factory Pattern 搭配,至於 Factory 是否要暴露給 client,可視需求決定

Architecture


trategy00

  • OrderService 相當於 Context
  • StrategyFactory 負責根據需求選擇 strategy
  • StrategyInterface 定義個演算法抽象化的 method 名稱
  • RebateStrategy 實作 買千送百
  • DiscountStrategy 實作 全館八折

Implementation


trategy00

Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using System;
using OrderLibrary;

namespace ConsoleApp
{
class Program
{
static void Main(string[] args)
{

var orderService = new OrderService();
var originalPrice = 1200;
var realPrice = orderService.GetPrice(originalPrice);
Console.WriteLine("Original price:{0}, Real price:{1}", originalPrice, realPrice);

originalPrice = 800;
realPrice = orderService.GetPrice(originalPrice);
Console.WriteLine("Original price:{0}, Real price:{1}", originalPrice, realPrice);
}
}
}

10 行

1
2
3
var orderService = new OrderService();
var originalPrice = 1200;
var realPrice = orderService.GetPrice(originalPrice);

將商業邏輯都寫在 OrderService,當 originalPrice 傳入 GetPrice() 後,應回傳 滿千送百全館八折 後的 realPrice

If Else

OrderService

trategy00

OrderService.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace OrderLibrary
{
public class OrderService
{
public double GetPrice(double price)
{

if (price < 1000)
{
return price * 0.8;
}
else
{
return price - 100;
}
}
}
}

使用 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace OrderLibrary.Tests
{
[TestClass]
public class UnitTest1
{
[TestMethod]
public void 當價錢為1200送百後應為1100()
{
// Arrange
var target = new OrderService();

// Act
var actual = target.GetPrice(1200);

// Assert
var expected = 1100;
Assert.AreEqual(expected, actual);
}

[TestMethod]
public void 當價錢為800打八折後應為640()
{
// Arrange
var target = new OrderService();

// Act
var actual = target.GetPrice(800);

// Assert
var expected = 640;
Assert.AreEqual(expected, actual);
}
}
}

由於本文重點不是在講 Unit Test,因此就不浪費篇幅解釋以上程式碼。

trategy00

Strategy Pattern

StrategyInterface

trategy00

StrategyInterface.cs

1
2
3
4
5
6
7
namespace OrderLibrary
{
public interface StrategyInterface
{
double CalculatePrice(double price);
}
}

定義 StrategyInterfaceCalculatePrice(),將來其他 strategy 必須遵守此 interface。

RebateStrategy

trategy00

RebateStrategy.cs

1
2
3
4
5
6
7
8
9
10
namespace OrderLibrary
{
public class RebateStrategy : StrategyInterface
{
public double CalculatePrice(double price)
{

return price - 100.0;
}
}
}

實作 StrategyInterface,實現 買千送百

DiscountStrategy

trategy00

DiscountStrategy.cs

1
2
3
4
5
6
7
8
9
10
namespace OrderLibrary
{
public class DiscountStrategy : StrategyInterface
{
public double CalculatePrice(double price)
{

return price * 0.80;
}
}
}

實作 StrategyInterface,實現 全館八折

StrategyFactory

trategy00

StrategyFactory.cs

1
2
3
4
5
6
7
8
9
10
11
12
using System.Dynamic;

namespace OrderLibrary
{
public static class StrategyFactory
{
public static StrategyInterface Create(double price)
{

return price < 1000 ? (StrategyInterface) new DiscountStrategy() : new RebateStrategy();
}
}
}

目前有 RebateStrategyDiscountStrategy 兩個 strategy,到底 OrderService.GetPrice() 該選擇拿個 strategy 呢 ?

特別新增 StrategyFactory 專責負責根據不同的 price,選擇不同的 strategy。

Strategy Pattern 實務上都會搭配 Factory Pattern,由 factory 根據條件選擇適當的 strategy

OrderService

trategy00

OrderService.cs

1
2
3
4
5
6
7
8
9
10
11
12
using System;

namespace OrderLibrary
{
public class OrderService
{
public double GetPrice(double price)
{

return StrategyFactory.Create(price).calculatePrice(price);
}
}
}

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

trategy00

CalculatePriceDelegate

trategy01

CalculatePriceDelegate.cs

1
2
3
4
namespace OrderLibrary
{
public delegate double CalculatePriceDelegate(double price);
}

定義 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

trategy01

PriceStrategy.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace OrderLibrary
{
public static class PriceStrategy
{
public static double CalculateRebatePrice(double price)
{

return price - 100.0;
}

public static double CalculateDiscountPrice(double price)
{

return price * 0.8;
}
}
}

將每個 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

trategy01

StrategyFactory.cs

1
2
3
4
5
6
7
8
9
10
11
12
namespace OrderLibrary
{
public static class StrategyFactory
{
public static CalculatePriceDelegate Create(double price)
{

return price < 1000
? (CalculatePriceDelegate) PriceStrategy.CalculateDiscountPrice
: PriceStrategy.CalculateRebatePrice;
}
}
}

StrategyFactoryCreate() 從原本回傳 strategy object 改成回傳 strategy function,也就是所有的 function 都必須符合 CalculatePriceDelegate delegate 型別,若違反 delegate ,將會 compiler 錯誤。

簡單的說,Delegate 就是 function 的 interface 或 function 的 type,這樣 compiler 才能幫我們檢查

Func

雖然 CalculatePriceDelegate 定義 function 的 spec 的理念很不錯,若 delegate 只使用一遍,真的需要開一個檔案建立 delegate 嗎 ?

StrategyFactory

trategy01

StrategyFactory.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using System;

namespace OrderLibrary
{
public static class StrategyFactory
{
public static Func<double, double> Create(double price)
{
return price < 1000
? (Func<double, double>) PriceStrategy.CalculateDiscountPrice
: PriceStrategy.CalculateRebatePrice;
}
}
}

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 ?

trategy01

OrderService

trategy01

OrderService.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using System;

namespace OrderLibrary
{
public class OrderService
{
public double GetPrice(double price)
{

Func<double, Func<double, double>> CreateStrategy =
(orignalPrice) => price > 1000
? (Func<double, double>) PriceStrategy.CalculateRebatePrice
: PriceStrategy.CalculateRebatePrice;

var strategy = CreateStrategy(price);
return strategy(price);
}
}
}

C# 3.0 提出了 Lambda,讓我們可以直接在 function 定義新的 function,而不一定要將 function 建立在 class 內。

直接在 GetPrice() 內定義 CreateStrategy()

Func<> 第一個參數 doubleCreateStrategy() 的 input 參數型別。

因為 CreateStrategy() 是回傳 Func<double, double> 的 funciton,故第 2 個參數為 Func<double, double>

如此我們就將 StrategyFactory class 消滅了。

Local Function

OrderService

trategy01

OrderService.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using System;

namespace OrderLibrary
{
public class OrderService
{
public double GetPrice(double price)
{

Func<double, double> CreateStrategy(double originPrice) =>
originPrice > 1000
? (Func<double, double>) PriceStrategy.CalculateRebatePrice
: PriceStrategy.CalculateDiscountPrice;

var strategy = CreateStrategy(price);
return strategy(price);
}
}
}

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 之間的耦合僅限於 delegateFunc<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 上找到

2018-03-20