以 FP 重新思考 Composite Pattern

Composite Pattern 是 OOP 中著名的 Design Pattern,無論是 物件容器,都能使用相同 interface 一視同仁的操作,F# 既然是 Function First Language,就讓我們以 function 的角度重新實現 Composite Pattern。

Version


macOS High Sierra 10.13.3
.NET Core SDK 2.1.101
Rider 2018.1
F# 4.1

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 萬

composite000

若同時買 MacBook Pro + iPad Air + Apple Watch + Apple 套餐為 15.2 萬。

Task


直接使用 FP 的思維完成需求。

Definition


Composite Pattern

單一資料組合資料 同時存在時,讓 client 有一致性的操作方式

composite002

首先思考 Composite Pattern 的本質:

  1. Leaf 與 Composite 之間的 interface 必須相同
  2. Client 操作 Leaf 與 Composite 都一視同仁

只要能達到這兩個目標,就算完成了 Composite Pattern。

OOP 思考方式


  • 為了讓 Leaf 與 Composite 的 interface 要相同,所以必須訂出共同的 interface
  • Composite 必須以 state 記住 Leaf,並根據 state 加以操作
  • Client 以相同方式操作 Composite 與 Leaf

FP 思考方式


  • Data 與 function 分開
  • 將 Leaf 與 Composite 都設定成 type
  • Composite 無需以 state 記住 Leaf,在建立 data 時就建立 Leaf
  • 只要建立單一 function 操作所有 data 即可,如此 Composite 與 Leaf 很自然地 interface 都相同
  • 由於 Composite 與 Leaf 的 interface 都相同,所以 Composite 會以 recursive 方式呼叫 Leaf 的 function

Implementation


Product.fs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
namespace ClassLibrary

type Apple =
| MacBookPro of double
| IPadAir of double
| AppleWatch of double
| AppleCombo of Apple List

module Product =
let macbookPro = MacBookPro 60000.0
let ipadAir = IPadAir 10000.0
let appleWatch = AppleWatch 10000.0
let appleCombo = (AppleCombo) [ macbookPro; ipadAir; appleWatch ]

let rec getPrice product =
match product with
| MacBookPro(price)
| IPadAir(price)
| AppleWatch(price) -> price
| AppleCombo(products) -> products
|> List.sumBy (fun elm -> getPrice elm)
|> (fun elm -> elm * 0.9)

第 3 行

1
2
3
4
5
type Apple = 
| MacBookPro of double
| IPadAir of double
| AppleWatch of double
| AppleCombo of Apple List

將各產品都設定成 type,其中 AppleCombo of Apple List 表示 AppleCombo 為各種 Apple 產品的組合。

10 行

1
2
3
let macbookPro = MacBookPro 60000.0
let ipadAir = IPadAir 10000.0
let appleWatch = AppleWatch 10000.0

設定各產品的定價。

13 行

1
let appleCombo = (AppleCombo) [ macbookPro; ipadAir; appleWatch ]

設定 Apple 套餐該包含哪些產品,(AppleCombo) 轉型是必要的,若沒有轉型,Type Inference 會推斷成 Apple list,導致編譯錯誤,我們實際需要的型別為 Apple

composite001

再次證明 F# 寫程式不需要型別,但型別的檢查依然非常嚴格,這就是 Type Inference 優越的地方。

16 行

1
2
3
4
5
6
7
8
let rec getPrice product = 
match product with
| MacBookPro(price)
| IPadAir(price)
| AppleWatch(price) -> price
| AppleCombo(products) -> products
|> List.sumBy (fun elm -> getPrice elm)
|> (fun elm -> elm * 0.9)

getPrice() 計算單一商品與 Apple 套餐。

1
2
3
4
match product with
| MacBookPro(price)
| IPadAir(price)
| AppleWatch(price) -> price

Pattern Matching 會使用型別判斷,且將該型別的值 price 直接 extract 出來。

1
2
3
| AppleCombo(products) -> products
|> List.sumBy (fun elm -> getPrice elm)
|> (fun elm -> elm * 0.9)

AppleCombo extract 出 products,其型別為 List,可由 List.sumBy() 去計算總和,值得注意的是再度使用 getPrice() 計算每個產品的價錢,因此 getPrice() 必須宣告成 rec

最後再將 List.sumBy() 的結果打九折。

我們可以發現在處理 多型 的議題上,OOP 與 FP 採用不同的方式,由於 OOP 是 data 與 function 合一,因此 getPrice() 會寫在各 data class 上,然後用 interface 強迫各 class 都要實作 getPrice();但 FP 是 data 與 function 分開,因此只要一個 getPrice(),然後使用 Pattern Matching 根據不同型別有相對應的處理。

但無論 OOP 或 FP,儘管處理方式不同,但對於 client 來說,只要是 Apple 型別,都使用相同的 getPrice(),符合 開放封閉原則 的要求。

ShoppingCart.fs

1
2
3
4
5
6
namespace ClassLibrary

module ShoppingCart =
let calculatePrice products =
products
|> List.sumBy (fun elm -> Product.getPrice elm)

products 的型別為 Apple list,因此每個 element 都是 Apple 型別,都可放心使用 getPrice(),這就是 Composite Pattern 的精華。

Program.fs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Learn more about F# at http://fsharp.org

open System
open ClassLibrary

[<EntryPoint>]
let main argv =
[
Product.macbookPro
Product.ipadAir
Product.appleWatch
Product.appleCombo
]
|> ShoppingCart.calculatePrice
|> printf "%.0f"

0

Client 呼叫 ShoppingCart.calculatePrice() 計算購物車內所有商品。

Summary


回想 Composite Pattern 的本質:

  1. Leaf 與 Composite 之間的 interface 必須相同
  2. Client 操作 Leaf 與 Composite 都一視同仁

FP 雖然沒有特別定義 interface,但所有 Apple type 的 data 統一都適用於 getPrice() function,由於在 getPrice() 內使用了 Pattern Matching,只要其中有一個 type 回傳型別不同,compiler 編譯就會報錯,與原本 Composite Pattern 定義 interface 本質相同。

對於 Apple type,統一使用 getPrice() function 處理,與原本 Composite Pattern 在 Component interface 下的 LeafComposite 都一視同仁的本質相同。

Conclusion


  • Composite Pattern 本質就是 多型,OOP 使用 interface 讓 物件容器 實踐相同 interface,讓兩者能使用相同的方式操作;FP 使用單一 function,再配合 Pattern Matching 對相同 type 的各種物件有不同的實作。雖然方式不同,但讓 client 以相同方式操作的本質都相同,也符合 開放封閉原則 的要求

Sample Code


完整的範例可以在我的 GitHub 上找到

2018-04-21