深入探討 F# 之 Discriminated Union
將實質上不同的型別,在邏輯上看成相同的型別。如 function 可能回傳 int
或 bool
兩種型別,可為此 function 特別建立 IntOrBool
型別,同時包含 int
與 bool
,這就是 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-name
與 case-identifier
為必須,其他都可省略。
簡單來說,
|
之後稱為 case,of
之後稱為 fieldCase 不可省略,但 field 可省略
Case
Case of Primitive Type
1 | type OrderId = |
如 function 找得到資料會傳回 int
型態的的 orderId
,若找不到則傳回 bool
型態的 false
,也就是回傳型態可能是 int
或 bool
,可將此型態重新定義為 OrderId
union,則無論傳回 int
或 bool
都是 OrderId
union,且也只能傳回 int
或 bool
。
1 | type OrderId = Int of int | Bool of bool |
若 case 很少,也可以寫成一行,則第一個 |
可省略。
Case of Unnamed Type
1 | type Shape = |
of
之後的型別,如是 unnamed type,可以直接 inline 表示,如直接指定為 tuple
。
如打算將 Rectangle
、Circle
與 Prism
三個型別定義出一個新的 Shape
union,只要是 Rectangle
或 Circle
或 Prism
之一,都算是 Shape
。
Rectangle
為int
*int
組合的tuple
Circle
為int
Prism
為int
int
int
組合的tuple
type * type 為 tuple 的型別定義方式
1 | type MixedType = |
Collection
也屬於 unnamed type,亦可直接 inline 表示。
int list
表示為list
型別,其 element 型別為int
。
1 | let rectangle = Rectangle (1, 2) |
當建立 union
時,以類似 constructor 的方式建立,稱為 case constructor,唯沒有 new
, class
換成 case
,且必須要照 定義順序
傳入。
1 | type C = |
Case contructor 本質就是 function,因此任何可傳入 function 之處,就可傳入 case constructor。
Case of Named Type
1 | type Person = { first: string; last: string } |
of
之後的型別若是 named type,則必須先用 type
定義好型別,如 record
或 union
,不能以 inline 的方式表示。
Field
若 case 的型別為 tuple
,雖能在 of
之後簡單的宣告 int * int
,有幾個缺點 :
- 要建立
union
時,只能依照定義順序
傳入,可讀性較差 - 無法由
tuple
看出其 domain 上的意義
若我們加上 field,則清楚許多。
1 | type Shape = |
在 of
之後加上 field,可明確表達出 tuple
的每個 element 的 domain 意義。
1 | let rectangle = Rectangle (length = 1, width = 2) |
建立 union
時,可在 case constructor 明確指定其 field,如此可讀性更高,且不用依照 定義順序
傳入。
Empty Case
1 | type Directory = |
Case 並不一定要搭配 type,若該 case 並不需要任何型態的值傳入,可以不指定 type。
1 | let myDir1 = Root |
沒有 type 的 case,其 case constructor 就不用傳入任何值。
1 | type Size = Small | Medium | Large |
當全部 case 都沒有 type 時,其功能等效於 enum
。
1 | type Size = Small | Medium | Large // DU |
union
與 enum
都使用 type 定義,沒有指定 int
值為 union
,有則為 enum
。
Q : F# 也有
enum
,我該用union
還是enum
呢 ?
F# 的 union
功能較強,enum
只是 union
的特例,實務上應優先使用 union
,除非有以下需求:
- Case 必須搭配
int
union
必須與其他 .NET 語言搭配時
才必須使用 enum
。
F# 的
enum
與 .NET 的enum
是相同的
Single Case
雖然 union
原本的用途是用在將不同的型別整合成單一型別,也就是將不同的 case 整合成一個 union
,但實務上有一種應用是一個 union
只有一個 case,所謂的 single case。
1 | type CustomerId = int |
第 1 行
1 | type CustomerId = int |
type
能對 primitive type 取 alias,所以我們分別對 int
定義成 CustomerId
type 與 OrderId
type。
第 4 行
1 | let printOrderId (orderId: OrderId) = |
建立 printOrderId
function,傳入參數的型別為 OrderId
。
第 7 行
1 | let custId = 1 |
custId
的型別為 int
,傳入 printOrderId
compiler 也沒報錯,明明要的是 OrderId
型別。
因為 OrderId
與 CustomerId
都只能算是 int
的 alias,還不算是個型別。
1 | type CustomerId = CustomerId of int |
第 1 行
1 | type CustomerId = CustomerId of int |
- 定義
CustomerId
union,其 case 為CustomerId
,型別為int
- 定義
OrderId
union,其 case 為OrderId
,型別為int
當使用 single case 的
union
時,type 會與 case 相同
第 4 行
1 | let printOrderId (OrderId orderId) = |
建立 printOrderId
function,傳入參數的型別為 OrderId
。
與之前的 printOrderId
function 一樣。
第 7 行
1 | let custId = CustomerId 1 |
custId
型別不再是 int
,而是 CustomerId
,因為使用了 CustomerId
的 case constructor 建立。
custId
傳入 printOrderId
後,如願出現 compiler error,因為 OrderId
與 CustomerId
都是具體的 type,而不只是 alias。
Destructor
1 | let getShapeHeight shape = |
當 union
傳入 function 後,可使用 Pattern Matching 與 field 將 tuple
的值取出。
with
之後配合的 union
的 case,()
內配合 field,可以直接取出該 field 的值。
使用 field 之後,可輕易的配合 Pattern Matching 取出
tuple
內的值
1 | let getCustomerId (CustomerId customerId) = |
在 function 的 paramter 使用 ()
,將 case 寫在 parameter 之前,則自動會將傳入的 union
destruct 成 value。
語法雖然很類似 C#,但別忘了 F# 的 type 是在
:
之後,所以CustomerId
寫在前面並不是型別,而是union
的 case
1 | let (CustomerId customerId) = custId |
custId
為 CustomerId
union,會直接 destruct 成 customerId
。
使用 destructor 時,一定要加上
()
,否則會誤以為是新的 function
Equality
1 | type Contact = |
雖然 union
為 reference type,但 union
的比較卻像 value type,只要 type 一樣,value 一樣,union
就算一樣。
Representation
1 | type Contact = Email of string | Phone of int |
printfn
使用 %A
支援 union
。
Object Hierarchy
1 | type Shape = |
若使用 OOP,會設計 Shape
interface,再由 Circle
、EquilateralTriangle
、Square
與 Rectangle
實踐 Shape
,如此需要開 5 個檔案。
若使用 union
,只要 5 行就可解決。
1 | let pi = 3.141592654 |
若使用 OOP,由於各種形狀計算面積的公式不同,勢必在 Shape
interface 開 area()
,再由 Circle
、EquilateralTriangle
、Square
與 Rectangle
各自實作 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