將不同型別 OR 起來

將實質上不同的型別,在邏輯上看成相同的型別。如 function 可能回傳 intbool 兩種型別,可為此 function 特別建立 IntOrBool 型別,同時包含 intbool,這就是 Discriminated Union,簡稱 union

若說 tuple 是將不同型別加以 AND,則 union 是將不同型別加以 OR。

Version


.NET Core SDK 2.1.101
F# 4.1

Definition


將不同的型別,整合成單一型別。

Q : 為什麼要稱為 Discriminated Union?

因為不是簡單的將不同型別加以 union 而已,而可以將不同的型別取 case-identifier 加以區別 (discriminated),所以稱為 Discriminated Union。

Syntax


1
[ attributes ]
type type-name =
    | case-identifier1 [of [ fieldname1 : ] type1 [ * [ fieldname2 : ] type2 ...]
    | case-identifier2 [of [fieldname3 : ]type3 [ * [ fieldname4 : ]type4 ...]
...
  • type-name : 定義 union 的型別名稱
  • I : 因為 union 包含多種型別,所以使用 |,第一個 | 可省略
  • case-identifider : 為每個型別取一個別名,必須為 大駝峰
  • of : 每個別名的型別,可以是內建型別,也可以是自己定義的其他 type
  • * : 若 of 之後為 tuple,不同 type 以 * 區別不同型別
  • fieldname : 若 of 型別是 tuple,可為 tuple 內每個型別取別名

以上只有 type-namecase-identifier 為必須,其他都可省略。

簡單來說,| 之後稱為 case,of 之後稱為 field

Case 不可省略,但 field 可省略

Case


Case of Primitive Type

1
2
3
type OrderId = 
| Int of int
| Bool of bool

如 function 找得到資料會傳回 int 型態的的 orderId,若找不到則傳回 bool 型態的 false,也就是回傳型態可能是 intbool,可將此型態重新定義為 OrderId union,則無論傳回 intbool 都是 OrderId union,且也只能傳回 intbool

1
type OrderId = Int of int | Bool of bool

若 case 很少,也可以寫成一行,則第一個 | 可省略。

Case of Unnamed Type

1
2
3
4
type Shape =
| Rectangle of int * int
| Circle of int
| Prism of int * int * int

of 之後的型別,如是 unnamed type,可以直接 inline 表示,如直接指定為 tuple

如打算將 RectangleCirclePrism 三個型別定義出一個新的 Shape union,只要是 RectangleCirclePrism 之一,都算是 Shape

  • Rectangleint * int 組合的 tuple
  • Circleint
  • Prismint int int 組合的 tuple

type * type 為 tuple 的型別定義方式

1
2
3
type MixedType = 
Tuple of int * int
List of int list

Collection 也屬於 unnamed type,亦可直接 inline 表示。

int list 表示為 list 型別,其 element 型別為 int

1
2
3
let rectangle = Rectangle (1, 2)
let circle = Circle 1
let prism = Prism (1, 2, 3)

當建立 union 時,以類似 constructor 的方式建立,稱為 case constructor,唯沒有 newclass 換成 case,且必須要照 定義順序 傳入。

1
2
3
4
5
6
7
8
9
10
type C = 
| Circle of int
| Rectangle of int * int

[1..10]
|> List.map Circle

[1..10]
|> List.zip [21..30]
|> List.map Rectangle

Case contructor 本質就是 function,因此任何可傳入 function 之處,就可傳入 case constructor。

Case of Named Type

1
2
3
4
5
6
7
8
9
10
type Person = { first: string; last: string }
type IntOrBool = Int of int | Bool of bool

type MixedType =
| Person of Person
| IntOrBool of IntOrBool

type MixedType =
| Person of { first: string; last: string } // error
| IntOrBool of (Int of int | Bool of bool) // error

of 之後的型別若是 named type,則必須先用 type 定義好型別,如 recordunion,不能以 inline 的方式表示。

Field


若 case 的型別為 tuple,雖能在 of 之後簡單的宣告 int * int,有幾個缺點 :

  1. 要建立 union 時,只能依照 定義順序 傳入,可讀性較差
  2. 無法由 tuple 看出其 domain 上的意義

若我們加上 field,則清楚許多。

1
2
3
4
type Shape =
| Rectangle of width : int * length : int
| Circle of radius: int
| Prism of width: int * length : int * height: int

of 之後加上 field,可明確表達出 tuple 的每個 element 的 domain 意義。

1
2
3
let rectangle = Rectangle (length = 1, width = 2)
let circle = Circle (radius = 1)
let prism = Prism (width = 1, length = 2, height = 3)

建立 union 時,可在 case constructor 明確指定其 field,如此可讀性更高,且不用依照 定義順序 傳入。

Empty Case


1
2
3
4
5
6
7
type Directory = 
| Root
| Subdirectory of string

type Result =
| Success
| ErrorMessage of string

Case 並不一定要搭配 type,若該 case 並不需要任何型態的值傳入,可以不指定 type。

1
2
3
4
5
let myDir1 = Root
let myDir2 = Subdirectory "bin"

let myResult1 = Success
let myResult2 = ErrorMessage "not found"

沒有 type 的 case,其 case constructor 就不用傳入任何值。

1
2
type Size = Small | Medium | Large
let mySize = Small

當全部 case 都沒有 type 時,其功能等效於 enum

1
2
type Size = Small     | Medium     | Large     // DU
type Size = Small = 0 | Medium = 1 | Large = 2 // enum

unionenum 都使用 type 定義,沒有指定 int 值為 union,有則為 enum

Q : F# 也有 enum,我該用 union 還是 enum 呢 ?

F# 的 union 功能較強,enum 只是 union 的特例,實務上應優先使用 union,除非有以下需求:

  1. Case 必須搭配 int
  2. union 必須與其他 .NET 語言搭配時

才必須使用 enum

F# 的 enum 與 .NET 的 enum 是相同的

Single Case


雖然 union 原本的用途是用在將不同的型別整合成單一型別,也就是將不同的 case 整合成一個 union,但實務上有一種應用是一個 union 只有一個 case,所謂的 single case。

1
2
3
4
5
6
7
8
type CustomerId = int
type OrderId = int

let printOrderId (orderId: OrderId) =
printfn "The orderId is %i" orderId

let custId = 1
printOrderId custId

第 1 行

1
2
type CustomerId = int
type OrderId = int

type 能對 primitive type 取 alias,所以我們分別對 int 定義成 CustomerId type 與 OrderId type。

第 4 行

1
2
let printOrderId (orderId: OrderId) = 
printfn "The orderId is %i" orderId

建立 printOrderId function,傳入參數的型別為 OrderId

第 7 行

1
2
let custId = 1
printOrderId custId

custId 的型別為 int,傳入 printOrderId compiler 也沒報錯,明明要的是 OrderId 型別。

因為 OrderIdCustomerId 都只能算是 int 的 alias,還不算是個型別。

1
2
3
4
5
6
7
8
type CustomerId = CustomerId of int
type OrderId = OrderId of int

let printOrderId (OrderId orderId) =
printfn "The orderId is %i" orderId

let custId = CustomerId 1
printOrderId custId // Error

第 1 行

1
2
type CustomerId = CustomerId of int
type OrderId = OrderId of int
  • 定義 CustomerId union,其 case 為 CustomerId,型別為 int
  • 定義 OrderId union,其 case 為 OrderId,型別為 int

當使用 single case 的 union 時,type 會與 case 相同

第 4 行

1
2
let printOrderId (OrderId orderId) =
printfn "The orderId is %i" orderId

建立 printOrderId function,傳入參數的型別為 OrderId

與之前的 printOrderId function 一樣。

第 7 行

1
2
let custId = CustomerId 1
printOrderId custId // Error

custId 型別不再是 int,而是 CustomerId,因為使用了 CustomerId 的 case constructor 建立。

custId 傳入 printOrderId 後,如願出現 compiler error,因為 OrderIdCustomerId 都是具體的 type,而不只是 alias。

Destructor


1
2
3
4
5
let getShapeHeight shape =
match shape with
| Rectangle(height = h) -> h
| Circle(radius = r) -> 2. * r
| Prism(height = h) -> h

union 傳入 function 後,可使用 Pattern Matching 與 field 將 tuple 的值取出。

with 之後配合的 union 的 case,() 內配合 field,可以直接取出該 field 的值。

使用 field 之後,可輕易的配合 Pattern Matching 取出 tuple 內的值

1
2
let getCustomerId (CustomerId customerId) = 
printfn "The CustomerId is %i" customerId

在 function 的 paramter 使用 (),將 case 寫在 parameter 之前,則自動會將傳入的 union destruct 成 value。

語法雖然很類似 C#,但別忘了 F# 的 type 是在 : 之後,所以 CustomerId 寫在前面並不是型別,而是 union 的 case

1
2
let (CustomerId customerId) = custId 
let CustomerId customerIdInt = custId // error

custIdCustomerId union,會直接 destruct 成 customerId

使用 destructor 時,一定要加上 (),否則會誤以為是新的 function

Equality


1
2
3
4
5
6
7
8
type Contact = 
| Email of string
| Phone of int

let email1 = Email "bob@example.com"
let email2 = Email "bob@example.com"

let areEqual = (email1=email2) // true

雖然 union 為 reference type,但 union 的比較卻像 value type,只要 type 一樣,value 一樣,union 就算一樣。

Representation


1
2
3
4
type Contact = Email of string | Phone of int
let email = Email "bob@example.com"

printfn "%A" email // nice

printfn 使用 %A 支援 union

Object Hierarchy


1
2
3
4
5
type Shape =
| Circle of float
| EquilateralTriangle of double
| Square of double
| Rectangle of double * double

若使用 OOP,會設計 Shape interface,再由 CircleEquilateralTriangleSquareRectangle 實踐 Shape,如此需要開 5 個檔案。

若使用 union,只要 5 行就可解決。

1
2
3
4
5
6
7
8
let pi = 3.141592654

let area myShape =
match myShape with
| Circle radius -> pi * radius * radius
| EquilateralTriangle s -> (sqrt 3.0) / 4.0 * s * s
| Square s -> s * s
| Rectangle (h, w) -> h * w

若使用 OOP,由於各種形狀計算面積的公式不同,勢必在 Shape interface 開 area(),再由 CircleEquilateralTriangleSquareRectangle 各自實作 area()

但在 FP 的 F#,只需使用 pattern matching 根據 union 的不同 case 實作即可,6 行即可解決。

Conclusion


  • F# 的 union 非常強大,可以算是 enum 的威力加強版,搭配 Pattern Matching 更是如虎添翼
  • union 配合 tuple 可以定義出複雜的 domain model
  • Single case 的 union 可以替 domain 定義一個更有意義的型別名稱,且兼具 type safety 與 compiler 保護

Reference

F#, Discriminated Unions
F# for fum and profit, Discriminated Unions

2018-03-17