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”。所以让我们修改一下单子的签名

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

所有这些在第一次阅读时可能感觉非常抽象。看看一些单子的具体例子会有所帮助。一旦你理解了几个 >>=return 操作,设计模式本身应该会更有意义。

因此,接下来的几节将介绍几个代码示例,其中可以发现单子。由于单子是一种设计模式,因此它们并不总是显而易见;可能需要一些研究才能找出单子操作在哪里使用。

Maybe 单子

正如我们之前所见,有时函数是部分的:对于某些输入,它们无法产生好的输出。例如,函数 `max_list : int list -> int` 对于空列表不一定有好的输出值要返回。一种可能性是抛出异常。另一种可能性是将返回类型更改为 `int option`,并使用 `None` 来表示函数无法产生输出。换句话说,函数 _可能_ 会产生输出,或者 _可能_ 无法产生输出,因此返回 `None`。

再举一个例子,考虑内置的 OCaml 整数除法函数 `( / ) : int -> int -> int`。如果它的第二个参数为零,它将抛出异常。但是,另一种可能性是将它的类型更改为 `( / ) : int -> int -> int option`,并在除数为零时返回 `None`。

这两个例子都涉及将部分函数的输出类型更改为选项,从而使函数变为总函数。这是一个很好的编程方式,直到你开始尝试将多个函数组合在一起。例如,由于所有整数运算(加、减、除、乘、取负等)都期望 `int`(或两个)作为输入,因此你可以用它们组成大的表达式。但是,一旦你将除法的输出类型更改为选项,你就失去了这种 _组合性_。

以下是一些代码,使这个想法具体化

(* 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

**单子在哪里?** 我们刚刚做的工作是将整数上的函数转换为可能为整数,也可能不是整数的值上的函数——即,要么是 `Some i`(其中 `i` 是整数),要么是 `None`。我们可以将这些“升级”后的函数视为可能 _产生无结果_ 的计算。它们产生隐喻的盒子,这些盒子可能装满了东西,也可能什么都没有。

在我们刚刚编写的代码中,有两个基本思想,对应于单子的 `return` 和 `bind` 操作。

第一个(诚然似乎微不足道)是通过用 `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`,
  • 然后使用 `a` 和 `b` 来构建一个返回值。

当然,那里仍然存在大量的重复。我们可以使用与之前相同的技术进行去重

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

**也许单子。** 我们刚刚发现的单子有几个名字:_也许单子_(就像“也许有一个值,也许没有”),_错误单子_(就像“要么有一个值,要么一个错误”,错误由 `None` 表示——尽管有些作者希望错误单子能够表示多种错误类型,而不是仅仅将它们全部折叠为 `None`),以及 _选项单子_(这很明显)。

以下是对也许单子的单子签名的实现

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.( / )

示例:写入单子

在尝试诊断系统中的故障时,通常情况下,_记录_ 哪些函数被调用,以及它们的输入和输出是什么,会很有帮助。

想象一下,我们有两个想要调试的函数,它们都是 `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

此时,我们已经重复了太多代码。`inc` 和 `dec` 的实现是在 `inc_log` 和 `dec_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, "")`。

**单子在哪里?** 我们刚刚做的工作是将整数上的函数转换为整数与日志消息配对的函数。我们可以将这些“升级”后的函数视为记录计算的函数。它们产生隐喻的盒子,这些盒子包含函数输出以及日志消息。

在我们刚刚编写的代码中,有两个基本思想,对应于单子的 `return` 和 `bind` 操作。

第一个是通过将值与空字符串配对来将值从 `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

**写入单子。** 我们刚刚发现的单子通常被称为 _写入单子_(就像“另外写入日志或字符串”)。以下是对它的单子签名的实现

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

正如我们在也许单子中看到的,这些与我们在上面发明的 `return` 和 `>>=` 的实现相同,但没有类型注解来强制它们只对整数起作用。实际上,我们从未需要这些注解;它们只是使上面的代码更清晰一点。

哪种版本的 `loggable` 更容易阅读是值得商榷的。当然,你需要熟悉单子风格的编程才能欣赏使用 `>>=` 的版本。但是,如果你正在开发一个更大的代码库(即,使用比 `loggable` 更多的与字符串配对的函数),使用 `>>=` 运算符很可能是一个不错的选择:这意味着你编写的代码可以专注于 `'a Writer.t` 类型的 `'a`,而不是字符串。换句话说,只要你使用 `return` 和 `>>=`,写入单子就会为你处理字符串。

示例:Lwt 单子

到目前为止,我们讨论过的 Lwt 承诺库也是一个单子,这可能已经很明显了。承诺类型 'a Lwt.t 具有 returnbind 操作,它们具有正确的类型,可以成为一个单子。

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

并且 Lwt.Infix.( >>= )Lwt.bind 的同义词,因此该库确实提供了一个中缀绑定运算符。

现在我们开始看到单子设计模式的一些强大功能。我们之前看到的 'a treturn 的实现涉及创建引用,但这些引用完全隐藏在单子接口后面。此外,我们知道 bind 涉及注册回调,但该功能(正如你可能想象的那样,涉及维护回调集合)完全封装了。

比喻地说,正如我们之前讨论的那样,这里涉及的盒子最初是空的,但最终将被类型为 'a 的值填充。这些计算中的“更多内容”是值是异步产生的,而不是立即产生的。

单子定律

每个数据结构不仅有签名,还有某些预期行为。例如,堆栈有一个 push 和一个 pop 操作,我们期望这些操作满足某些代数定律。当我们学习等式规范时,我们看到了堆栈的那些定律。

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

然而,单子不仅仅是一个单一的数据结构。它是一种数据结构的设计模式。因此,不可能为一般的单子编写 return>>= 的规范:规范需要讨论特定的单子,例如 writer 单子或 Lwt 单子。

另一方面,事实证明,我们可以写下一些应该适用于任何单子的定律。其原因可以追溯到我们对单子的其中一个直觉,即它们代表具有副作用的计算。例如,考虑 Lwt。我们可能会使用 bind 在承诺 X 上注册一个回调 C。这会产生一个新的承诺 Y,我们可以在其上注册另一个回调 D。我们期望这些回调按顺序排序:C 必须在 D 之前运行,因为 Y 必须在 X 之前才能解析。

这种顺序顺序的概念是单子定律规定的部分。我们将在下面说明这些定律。但首先,让我们暂停一下,考虑一下命令式语言中的顺序顺序。

*顺序顺序。在 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;,这就是我们通常所做的事情。在数学上,我们说分号是结合的。

单子定律与顺序顺序。 以上三条定律体现了与我们现在要说明的单子定律完全相同的直觉。单子定律只是抽象了一些,因此一开始更难理解。

假设我们有一个单子,它通常必须具有以下签名

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

三条单子定律如下

  • 定律 1:return x >>= f 的行为与 f x 相同。

  • 定律 2:m >>= return 的行为与 m 相同。

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

这里,“行为与……相同”意味着两个表达式都将计算为相同的值,或者它们都将进入无限循环,或者它们都将引发相同的异常。

这些定律在数学上与我们上面看到的 skip、分号和花括号的定律相同:return>>= 的左恒等式和右恒等式,而 >>= 是结合的。让我们更详细地看一下每条定律。

定律 1 表示对一个值施加微不足道的副作用,然后在该值上绑定一个函数,与只是对该值调用该函数相同。考虑一下 maybe 单子:return x 将是 Some x,而 >>= f 将提取 x 并对其应用 f。或者考虑一下 Lwt 单子:return x 将是一个已经使用 x 解析的承诺,而 >>= f 将注册 f 作为要在 x 上运行的回调。

定律 2 表示对微不足道的副作用进行绑定与没有副作用相同。考虑一下 maybe 单子: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 -> ... 来使类型正确。

组合与单子定律

还有一个名为 compose 的单子运算符可用于组合单子函数。例如,假设您有一个类型为 'a t 的单子,以及两个函数

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

这些函数的组合将是

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

也就是说,组合将接受一个类型为 'a 的值,对其应用 f,从结果中提取 'b,对其应用 g,并返回该值。

我们可以使用 >>= 来编写 compose;我们不需要了解有关单子的内部工作原理的任何其他信息

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

let ( >=> ) = compose

正如最后一行所示,compose 可以用中缀运算符 >=> 表示。

回到我们使用安全除法运算符的 maybe 单子的示例,假设我们有递增和递减函数

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

单子组合运算符将使我们能够将这两个函数组合成一个恒等函数,而无需编写任何其他代码

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

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

使用组合运算符,单子定律有一个更简洁的表述

  • 定律 1:return >=> f 的行为与 f 相同。

  • 定律 2:f >=> return 的行为与 f 相同。

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

在该表述中,立即清楚的是 return 是左恒等式和右恒等式,而组合是结合的。

帮助改进我们的文档

鼓励您为 CS3110 GitHub 存储库中此页面的原始来源做出贡献。

OCaml

创新。社区。安全。