Ocaml Programming: Correct + Efficient + Beautiful

單子

這是出自 「單子」 頁面的摘錄,該頁面來自書籍 OCaml 程式設計:正確 + 高效 + 優美,經許可在此重製。

單子 是一種設計模式,而非資料結構。也就是說,有許多資料結構,如果你以正確的方式看待它們,就會發現它們是單子。

「單子」這個名稱來自數學的範疇論領域,該領域研究數學結構的抽象。如果你修過程式語言理論的博士班課程,你可能會更詳細地接觸到這個概念。然而,在這裡,我們將省略大部分數學理論,而專注於程式碼。

單子在程式設計界流行起來,是因為它們在 Haskell 中的使用,Haskell 是一種比 OCaml 更純粹的函數式程式語言——也就是說,Haskell 比 OCaml 更避免副作用和命令式特性。但沒有實用的語言可以沒有副作用。畢竟,列印到螢幕就是一種副作用。因此,Haskell 開始透過單子設計模式來控制副作用的使用。從那時起,單子就被認為在其他函數式程式語言中很有用,甚至開始出現在命令式語言中。

單子用於對計算建模。將計算想像成一個函式,它將輸入映射到輸出,但也做「更多事情」。更多的事情是函式在計算後產生的效果。例如,效果可能涉及列印到螢幕。單子提供效果的抽象,並有助於確保效果以受控制的順序發生。

單子簽名

為了我們的目的,單子是一種滿足兩個屬性的結構。首先,它必須符合以下簽名

module type Monad = sig
  type 'a t
  val return : 'a -> 'a t
  val bind : 'a t -> ('a -> 'b t) -> 'b t
end

其次,單子必須遵守所謂的單子定律。我們會在稍後研究過 returnbind 操作後,再回頭討論這些定律。

將單子想像成一個包含某些值的盒子。該值的型態為 'a,而包含它的盒子的型態為 'a t。我們之前也使用過類似的盒子比喻來表示選項和承諾。這並非偶然:選項和承諾都是單子的範例,我們將在下面詳細說明。

Return。 return 操作將一個值放入盒子中,這是一種比喻的說法。你可以從它的型態中看到:輸入的型態為 'a,輸出的型態為 'a t

就計算而言,return 旨在產生某種微不足道的效果。例如,如果單子表示其副作用是列印到螢幕的計算,則微不足道的效果將是不列印任何東西。

Bind。 bind 操作將以下內容作為輸入,這也是一種比喻的說法

  • 一個盒裝的值,其型態為 'a t,以及

  • 一個函式,該函式本身以型態為 'a未盒裝值作為輸入,並傳回型態為 'b t盒裝值作為輸出。

bind 將其第二個參數套用到第一個參數。這需要從盒子中取出 'a 值,將函式套用到它,並傳回結果。

就計算而言,bind 旨在依序執行效果。繼續列印的例子,依序執行將意味著先列印一個字串,然後再列印另一個字串,而 bind 將確保列印以正確的順序發生。

bind 的常用符號是中綴運算子 >>=,發音仍然是 "bind"。所以讓我們修改一下 monad 的簽名。

module type Monad = sig
  type 'a t
  val return : 'a -> 'a t
  val ( >>= ) : 'a t -> ('a -> 'b t) -> 'b t
end

初次閱讀時,以上所有內容可能感覺非常抽象。看一些 monad 的具體範例會有幫助。一旦您理解了幾個 >>=return 操作,設計模式本身應該會更有意義。

因此,接下來的幾個章節將探討幾個不同的程式碼範例,在這些範例中可以發現 monad。由於 monad 是一種設計模式,因此它們並不總是顯而易見;可能需要一些研究才能理清 monad 操作在哪裡被使用。

Maybe Monad

正如我們之前所見,有時函數是部分的:對於某些輸入,它們無法產生良好的輸出。例如,函數 max_list : int list -> int 對於空列表不一定有好的輸出值可以返回。一種可能性是引發例外。另一種可能性是將返回類型更改為 int option,並使用 None 來表示函數無法產生輸出。換句話說,也許函數會產生輸出,或者也許它無法產生輸出,因此返回 None

另一個範例,考慮內建的 OCaml 整數除法函數 ( / ) : int -> int -> int。如果它的第二個引數為零,它會引發例外。不過,另一種可能性是將其類型更改為 ( / ) : int -> int -> int option,並在除數為零時返回 None

這兩個範例都涉及將部分函數的輸出類型更改為 option,從而使該函數變成完整的。這是一種不錯的編程方式,直到您開始嘗試將許多函數組合在一起。例如,由於所有整數運算(加法、減法、除法、乘法、負數等)都預期輸入為 int(或兩個),您可以從它們形成大型表達式。但是一旦您將除法的輸出類型更改為 option,您就會失去這種可組合性

以下是一些程式碼來具體說明這個概念

(* works fine *)
let x = 1 + (4 / 2)
let div (x:int) (y:int) : int option =
  if y = 0 then None else Some (x / y)

let ( / ) = div

(* won't type check *)
let x = 1 + (4 / 2)

問題在於我們無法將 int 加到 int option:加法運算子預期它的第二個輸入的類型為 int,但是新的除法運算子返回類型為 int option 的值。

一種可能性是重新編寫所有現有的運算子,以接受 int option 作為輸入。例如,

let plus_opt (x:int option) (y:int option) : int option =
  match x,y with
  | None, _ | _, None -> None
  | Some a, Some b -> Some (Stdlib.( + ) a b)

let ( + ) = plus_opt

let minus_opt (x:int option) (y:int option) : int option =
  match x,y with
  | None, _ | _, None -> None
  | Some a, Some b -> Some (Stdlib.( - ) a b)

let ( - ) = minus_opt

let mult_opt (x:int option) (y:int option) : int option =
  match x,y with
  | None, _ | _, None -> None
  | Some a, Some b -> Some (Stdlib.( * ) a b)

let ( * ) = mult_opt

let div_opt (x:int option) (y:int option) : int option =
  match x,y with
  | None, _ | _, None -> None
  | Some a, Some b ->
    if b=0 then None else Some (Stdlib.( / ) a b)

let ( / ) = div_opt
(* does type check *)
let x = Some 1 + (Some 4 / Some 2)

但是這會產生大量的程式碼重複。我們應該應用抽象原則並消除重複。四個運算子中的三個可以透過抽象一個函數來處理,該函數只會進行一些模式比對來傳播 None

let propagate_none (op : int -> int -> int) (x : int option) (y : int option) =
  match x, y with
  | None, _ | _, None -> None
  | Some a, Some b -> Some (op a b)

let ( + ) = propagate_none Stdlib.( + )
let ( - ) = propagate_none Stdlib.( - )
let ( * ) = propagate_none Stdlib.( * )

不幸的是,除法更難消除重複。我們不能僅僅將 Stdlib.( / ) 傳遞給 propagate_none,因為這兩個函數都不會檢查除數是否為零。如果我們可以將我們的函數 div : int -> int -> int option 傳遞給 propagate_none,那就太好了,但是 div 的返回類型使得這不可能。

因此,讓我們重寫 propagate_none 以接受與 div 相同類型的運算子,這使得實現除法變得容易

let propagate_none
  (op : int -> int -> int option) (x : int option) (y : int option)
=
  match x, y with
  | None, _ | _, None -> None
  | Some a, Some b -> op a b

let ( / ) = propagate_none div

實現其他三個運算需要更多的工作,因為它們的返回類型是 int 而不是 int option。我們需要用 Some 包裝它們的返回值

let wrap_output (op : int -> int -> int) (x : int) (y : int) : int option =
  Some (op x y)

let ( + ) = propagate_none (wrap_output Stdlib.( + ))
let ( - ) = propagate_none (wrap_output Stdlib.( - ))
let ( * ) = propagate_none (wrap_output Stdlib.( * ))

最後,我們可以重新實現 div 以使用 wrap_output

let div (x : int) (y : int) : int option =
  if y = 0 then None else wrap_output Stdlib.( / ) x y

let ( / ) = propagate_none div

Monad 在哪裡? 我們剛才所做的工作是將整數上的函數轉換為可能不是整數的值上的函數——也就是說,值可能是 Some i,其中 i 是一個整數,也可能是 None。我們可以將這些「升級」後的函數視為可能沒有產生任何結果的計算。它們產生了隱喻的盒子,而這些盒子可能裝滿了東西,也可能什麼都沒有。

我們剛剛編寫的程式碼中有兩個基本概念,它們對應於 returnbind 的 monad 操作。

第一個(雖然看起來很瑣碎)是透過使用 Some 包裝一個值,將值從 int 升級到 int option。這就是 wrap_output 的主體所做的事情。我們可以透過定義以下函數來更清楚地揭示這個概念

let return (x : int) : int option = Some x

這個函數具有將值放入隱喻盒子的微不足道的效果

第二個概念是將處理所有針對 None 的模式比對的程式碼提取出來。我們必須將輸入類型為 int 的函數升級為接受輸入類型為 int option 的函數。以下是將該概念表達為它自己的函數

let bind (x : int option) (op : int -> int option) : int option =
  match x with
  | None -> None
  | Some a -> op a

let ( >>= ) = bind

bind 函數可以理解為完成將 op 從接受 int 作為輸入的函數升級為接受 int option 作為輸入的函數的核心工作。事實上,我們甚至可以使用 bind 來編寫一個為我們執行升級的函數

let upgrade : (int -> int option) -> (int option -> int option) =
  fun (op : int -> int option) (x : int option) -> (x >>= op)

所有這些類型註釋都是為了幫助讀者理解該函數。當然,它可以寫得更簡單,如下所示

let upgrade op x = x >>= op

僅使用 return>>= 函數,我們可以重新實現上面的算術運算

let ( + ) (x : int option) (y : int option) : int option =
  x >>= fun a ->
  y >>= fun b ->
  return (Stdlib.( + ) a b)

let ( - ) (x : int option) (y : int option) : int option =
  x >>= fun a ->
  y >>= fun b ->
  return (Stdlib.( - ) a b)

let ( * ) (x : int option) (y : int option) : int option =
  x >>= fun a ->
  y >>= fun b ->
  return (Stdlib.( * ) a b)

let ( / ) (x : int option) (y : int option) : int option =
  x >>= fun a ->
  y >>= fun b ->
  if b = 0 then None else return (Stdlib.( / ) a b)

回想一下,從我們在 Lwt 中對 bind 運算子的討論中,上面的語法應該被您的眼睛解析為

  • x 並從中提取值 a
  • 然後取 y 並從中提取 b
  • 然後使用 ab 來建構一個返回值。

當然,這裡仍然存在相當多的重複。我們可以像以前一樣使用相同的技術來消除重複

let upgrade_binary op x y =
  x >>= fun a ->
  y >>= fun b ->
  op a b

let return_binary op x y = return (op x y)

let ( + ) = upgrade_binary (return_binary Stdlib.( + ))
let ( - ) = upgrade_binary (return_binary Stdlib.( - ))
let ( * ) = upgrade_binary (return_binary Stdlib.( * ))
let ( / ) = upgrade_binary div

Maybe Monad。 我們剛才發現的 monad 有幾個名稱:maybe monad(如「也許有值,也許沒有」),錯誤 monad(如「要么有值,要么有錯誤」,錯誤用 None 表示——儘管有些作者希望錯誤 monad 能夠表示多種錯誤,而不僅僅是將它們全部摺疊到 None),以及option monad(這是顯而易見的)。

以下是 maybe monad 的 monad 簽名的實現

module Maybe : Monad = struct
  type 'a t = 'a option

  let return x = Some x

  let (>>=) m f =
    match m with
    | None -> None
    | Some x -> f x
end

這些是與我們上面發明的 return>>= 相同的實現,但是沒有類型註釋來強制它們僅適用於整數。實際上,我們從來不需要這些註釋;它們只是幫助使上面的程式碼更清晰一些。

實際上,這裡的 return 函數非常瑣碎,並非真正必要。但是,正如我們在上面算術運算子的最終實現中所見,>>= 運算子可以用來替換大量樣板模式比對。只有一個模式比對,它位於 >>= 內。將其與 plus_opt 等的原始實現進行比較,它們具有許多模式比對。

結果是我們獲得的程式碼(一旦您了解如何讀取 bind 運算子)更容易閱讀和維護。

現在我們已經完成了對整數運算子的處理,我們應該恢復它們在本檔案中其餘部分的原始含義

let ( + ) = Stdlib.( + )
let ( - ) = Stdlib.( - )
let ( * ) = Stdlib.( * )
let ( / ) = Stdlib.( / )

範例:Writer Monad

在嘗試診斷系統中的故障時,通常情況下,函數的呼叫記錄以及它們的輸入和輸出會很有幫助。

假設我們有兩個要除錯的函數,它們的類型都是 int -> int。例如

let inc x = x + 1
let dec x = x - 1

(好的,這些函數非常簡單;我們可能不需要任何幫助來除錯它們。但是想像一下它們計算更複雜的東西,例如整數的加密或解密。)

一種記錄函數呼叫的方法是擴充每個函數以返回一個對:函數通常會返回的整數值,以及包含記錄訊息的字串。例如

let inc_log x = (x + 1, Printf.sprintf "Called inc on %i; " x)
let dec_log x = (x - 1, Printf.sprintf "Called dec on %i; " x)

但是這會更改兩個函數的返回類型,這使得很難組合這些函數。以前,我們可以編寫如下程式碼

let id x = dec (inc x)

甚至更好

let id x = x |> inc |> dec

甚至更好的是,使用組合運算子 >>

let ( >> ) f g x = x |> f |> g
let id = inc >> dec

這會正常運作。但是嘗試使用可記錄版本的函數執行相同的操作會產生類型檢查錯誤

let id = inc_log >> dec_log

這是因為 inc_log x 將是一個對,但是 dec_log 僅預期輸入為整數。

我們可以編寫一個升級版本的 dec_log,它可以接受一個對作為輸入

let dec_log_upgraded (x, s) =
  (x - 1, Printf.sprintf "%s; Called dec on %i; " s x)

let id x = x |> inc_log |> dec_log_upgraded

這可以正常運作,但是如果我們想以相反的順序呼叫它們,例如 let id = dec_log >> inc_log,我們還需要編寫一個類似的升級版本 f_log。因此我們必須編寫

let inc_log_upgraded (x, s) =
  (x + 1, Printf.sprintf "%s; Called inc on %i; " s x)

let id = dec_log >> inc_log_upgraded

而且在此時,我們重複了太多程式碼。incdec 的實現在 inc_logdec_log 內部以及函數的兩個升級版本內部都重複了。而且兩個升級都重複了將記錄訊息連結在一起的程式碼。我們想要使可記錄的函數越多,這種重複情況就會變得越糟!

因此,讓我們重新開始,並提取一些輔助函數。第一個輔助函數呼叫一個函數並產生記錄訊息

let log (name : string) (f : int -> int) : int -> int * string =
  fun x -> (f x, Printf.sprintf "Called %s on %i; " name x)

第二個輔助函數從不可記錄的函數中產生一個類型為 'a * string -> 'b * string 的記錄函數

let loggable (name : string) (f : int -> int) : int * string -> int * string =
  fun (x, s1) ->
    let (y, s2) = log name f x in
    (y, s1 ^ s2)

使用這些輔助函數,我們可以實現記錄版本的函數,而無需任何涉及對或模式比對或字串連結的程式碼重複

let inc' : int * string -> int * string =
  loggable "inc" inc

let dec' : int * string -> int * string =
  loggable "dec" dec

let id' : int * string -> int * string =
  inc' >> dec'

以下是一個使用範例

id' (5, "")

請注意,很難在整數上呼叫我們的可記錄函數,因為我們必須將整數與字串配對。因此,讓我們再編寫一個函數來幫助解決這個問題,方法是將整數與記錄配對

let e x = (x, "")

現在我們可以編寫 id' (e 5) 而不是 id' (5, "")

Monad 在哪裡? 我們剛才所做的工作是將整數上的函數轉換為與記錄訊息配對的整數上的函數。我們可以將這些「升級」後的函數視為記錄的計算。它們產生了隱喻的盒子,而這些盒子包含函數輸出以及記錄訊息。

我們剛剛編寫的程式碼中有兩個基本概念,它們對應於 returnbind 的 monad 操作。

第一個是透過將值與空字串配對,將值從 int 升級到 int * string。這就是 e 所做的事情。我們可以將其重新命名為 return

let return (x : int) : int * string = (x, "")

此函數具有將值與空記錄訊息一起放入隱喻盒子的微不足道的效果

第二個概念是將處理針對對和字串連結的模式比對的程式碼提取出來。以下是將該概念表達為它自己的函數

let ( >>= ) (m : int * string) (f : int -> int * string) : int * string =
  let (x, s1) = m in
  let (y, s2) = f x in
  (y, s1 ^ s2)

使用 >>=,我們可以重新實現 loggable,這樣在其主體中就不會使用任何對或模式比對

let loggable (name : string) (f : int -> int) : int * string -> int * string =
  fun m ->
    m >>= fun x ->
    log name f x

Writer Monad(寫入 Monad)。 我們剛發現的 monad 通常稱為 writer monad(意即,「額外寫入日誌或字串」)。以下是針對它的 monad 簽章實作:

module Writer : Monad = struct
  type 'a t = 'a * string

  let return x = (x, "")

  let ( >>= ) m f =
    let (x, s1) = m in
    let (y, s2) = f x in
    (y, s1 ^ s2)
end

如同我們在 maybe monad 中看到的,這些都是 return>>= 的相同實作,就如同我們在上面發明的一樣,只是沒有型別註解來強制它們只能用於整數。事實上,我們從來不需要這些註解;它們只是讓上面的程式碼稍微清晰一點。

關於哪個版本的 loggable 更容易閱讀,這點仍有爭議。當然,您需要熟悉 monad 程式設計風格才能理解使用 >>= 的版本。但是,如果您正在開發一個更大的程式碼庫(例如,有比 loggable 更多的函式涉及成對的字串),使用 >>= 運算子可能會是個不錯的選擇:這表示您編寫的程式碼可以專注於 'a 的型別 'a Writer.t 上,而不是字串。換句話說,只要您使用 return>>=,writer monad 就會為您處理字串。

範例:Lwt Monad

到目前為止,我們討論過的 Lwt 承諾函式庫顯然也是一個 monad。承諾的型別 'a Lwt.t 有一個 returnbind 運算,它們的型別正確,可以成為一個 monad。

val return : 'a -> 'a t
val bind : 'a t -> ('a -> 'b t) -> 'b t

Lwt.Infix.( >>= )Lwt.bind 的同義詞,因此該函式庫確實提供了 infix bind 運算子。

現在我們開始看到 monad 設計模式的一些強大之處。我們之前看到的 'a treturn 的實作涉及建立參考,但這些參考完全隱藏在 monad 介面之下。此外,我們知道 bind 涉及註冊回呼,但該功能(您可以想像,這涉及維護回呼集合)已完全封裝。

如同我們之前討論的,這裡涉及的盒子是一種開始時為空,但最終會填入 'a 型別值的盒子。這些計算中的「更多內容」是這些值是以非同步方式產生,而不是立即產生。

Monad 定律

每個資料結構不僅有簽章,還有一些預期的行為。例如,堆疊有 push 和 pop 運算,我們預期這些運算會滿足某些代數定律。當我們研究等式規格時,我們看到了堆疊的這些定律。

  • peek (push x s) = x
  • pop (push x empty) = empty
  • 等等。

但是,monad 不僅僅是一個單一的資料結構。它是一種資料結構的設計模式。因此,不可能為一般的 monad 編寫 return>>= 的規格:規格需要討論特定的 monad,例如 writer monad 或 Lwt monad。

另一方面,事實證明,我們可以寫下任何 monad 都應該遵守的一些定律。這樣做的原因可以追溯到我們對 monad 的直覺之一,即它們代表具有副作用的計算。例如,考慮 Lwt。我們可以使用 bind 在承諾 X 上註冊回呼 C。這會產生新的承諾 Y,我們可以在其上註冊另一個回呼 D。我們預期這些回呼有一個循序的順序:C 必須在 D 之前執行,因為 Y 不能在 X 之前解析。

循序順序的概念是 monad 定律規定的部分內容。我們將在下面說明這些定律。但首先,讓我們停下來思考命令式語言中的循序順序。

循序順序。 在 Java 和 C 等語言中,有一個分號可以對陳述式施加循序順序,例如

System.out.println(x);
x++;
System.out.println(x);

首先列印 x,然後遞增,然後再次列印。這些陳述式產生的影響必須按該循序順序發生。

讓我們想像一個假設的陳述式,它不會產生任何影響。例如,assert true 在 Java 中不會導致任何事情發生。(有些編譯器會完全忽略它,甚至不會為它產生位元組碼。)在大多數組合語言中,同樣有一個「無運算」指令,其助憶符通常為 NOP,它也不會導致任何事情發生。(從技術上講,會經過一些時鐘週期。但暫存器或記憶體不會有任何變化。)在程式語言理論中,此類陳述式通常稱為 skip,意思是「跳過我,因為我不會做任何有趣的事情。」

以下是 skip 和分號應遵守的兩個定律

  • skip; s; 的行為應該與僅 s; 相同。

  • s; skip; 的行為應該與僅 s; 相同。

換句話說,您可以刪除任何出現的 skip,因為它沒有任何影響。在數學上,我們說 skip 是分號的 左單位元(第一個定律)和 右單位元(第二個定律)。

命令式語言通常也有一種方法將陳述式分組到區塊中。在 Java 和 C 中,這通常使用大括號來完成。以下是一個區塊和分號應遵守的定律

  • {s1; s2;} s3; 的行為應該與 s1; {s2; s3;} 相同。

換句話說,順序始終是 s1 然後 s2 然後 s3,無論您是否將前兩個陳述式分組到一個區塊中,或者將後兩個分組到一個區塊中。因此,您甚至可以刪除大括號,而只寫 s1; s2; s3;,這也是我們通常的做法。在數學上,我們說分號是 結合律

使用 Monad 定律的循序順序。 以上三個定律完全體現了與 monad 定律相同的直覺,我們現在將說明這些定律。monad 定律只是稍微抽象一點,因此一開始更難理解。

假設我們有任何 monad,它們像往常一樣必須具有以下簽章

module type Monad = sig
  type 'a t
  val return : 'a -> 'a t
  val ( >>= ) : 'a t -> ('a -> 'b t) -> 'b t
end

三個 monad 定律如下

  • 定律 1: return x >>= f 的行為與 f x 相同。

  • 定律 2: m >>= return 的行為與 m 相同。

  • 定律 3: (m >>= f) >>= g 的行為與 m >>= (fun x -> f x >>= g) 相同。

在這裡,「行為相同」表示兩個運算式都將求值為相同的值,或者它們都將進入無限迴圈,或者它們都將引發相同的例外。

這些定律在數學上說的是與我們在上面看到的 skip、分號和大括號定律相同的事情:return>>= 的左單位元和右單位元,而 >>= 是結合律。讓我們更詳細地看看每個定律。

定律 1 表示對值產生微不足道的效果,然後在其上繫結函式,與直接在值上呼叫函式相同。考慮 maybe monad:return x 將會是 Some x,而 >>= f 會提取 x 並將 f 套用至它。或考慮 Lwt monad:return x 將會是已使用 x 解析的承諾,而 >>= f 會註冊 f 作為在 x 上執行的回呼。

定律 2 表示繫結在微不足道的效果上與不產生效果相同。考慮 maybe monad:m >>= return 將取決於 mSome x 還是 None。在前一種情況下,繫結會提取 x,而 return 只會使用 Some 重新包裝它。在後一種情況下,繫結只會傳回 None。類似地,對於 Lwt,繫結在 m 上會註冊 return 作為回呼,以便在解析 m 的內容後在其上執行,而 return 只會取出這些內容並將它們放回已解析的承諾中。

定律 3 表示繫結正確地對效果進行排序,但與上面的分號和大括號版本相比,在此定律中更難以看出。如果我們將定律 3 重寫為

(m >>= f) >>= g 的行為與 m >>= (f >>= g) 相同。

但問題是這無法進行型別檢查:f >>= g 沒有正確的型別放在 >>= 的右側。因此,我們必須插入一個額外的匿名函式 fun x -> ... 才能使型別正確。

組合與 Monad 定律

還有另一個稱為 compose 的 monad 運算子,可用於組合 monad 函式。例如,假設您有一個型別為 'a t 的 monad,以及兩個函式

  • f : 'a -> 'b t
  • g : 'b -> 'c t

這些函式的組合將為

  • compose f g : 'a -> 'c t

也就是說,組合會採用 'a 型別的值,將 f 套用至它,從結果中提取 'b,將 g 套用至它,並傳回該值。

我們可以使用 >>= 來編碼 compose;我們不需要了解更多關於 monad 內部運作的任何資訊

let compose f g x =
  f x >>= fun y ->
  g y

let ( >=> ) = compose

如最後一行所示,compose 可以表示為 infix 運算子,寫成 >=>

回到我們使用安全除法運算子的 maybe monad 範例,想像一下我們有遞增和遞減函式

let inc (x : int) : int option = Some (x + 1)
let dec (x : int) : int option = Some (x - 1)
let ( >>= ) x op =
  match x with
  | None -> None
  | Some a -> op a

monad 組合運算子可讓我們將這兩個函式組合到一個恆等函式中,而無需編寫任何額外的程式碼

let ( >=> ) f g x =
  f x >>= fun y ->
  g y

let id : int -> int option = inc >=> dec

使用組合運算子,可以更清楚地表述 monad 定律

  • 定律 1: return >=> f 的行為與 f 相同。

  • 定律 2: f >=> return 的行為與 f 相同。

  • 定律 3: (f >=> g) >=> h 的行為與 f >=> (g >=> h) 相同。

在該表述中,可以立即清楚地看出 return 是左單位元和右單位元,並且組合是結合律。

協助改善我們的文件

我們鼓勵您在 CS3110 GitHub 儲存庫中貢獻此頁面的原始來源。

OCaml

創新。社群。安全。