错误处理

在 OCaml 中,错误可以通过多种方式处理。本文档介绍了大多数可用方法。但是,尚未介绍在 OCaml 5 中引入的效应处理器来处理错误的方法。Yaron Minsky 和 Anil Madhavapeddy 编写的《Real World OCaml》一书中的《错误处理》章节也讨论了此主题(请参阅参考文献)。

错误作为特殊值

不要这样做。

某些语言,最具代表性的是 C 语言,将某些值视为错误。例如,在 Unix 系统中,以下是 man 2 read 中包含的内容

read - 从文件描述符读取

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);

[...]

发生错误时,返回 -1,并设置 errno 以指示错误。

使用这种风格编写了许多优秀的软件。但是,由于无法区分预期的返回值和表示错误的值,只有程序员的纪律才能确保不会忽略错误。这导致了许多错误,其中一些错误后果严重。这不是在 OCaml 中处理错误的正确方法。

在 OCaml 中,有三种主要方法可以防止忽略错误

  1. 异常
  2. option
  3. result

使用它们。不要在数据内部编码错误。

异常提供了一种在控制流级别处理错误的方法,而 optionresult 使错误与正常返回值区分开来。

本文档的其余部分介绍并比较了错误处理方法。

异常

从历史上看,在 OCaml 中处理错误的第一种方法是异常。标准库大量依赖于它们。

异常最大的问题是它们不会出现在类型中。必须阅读文档才能了解 List.findString.sub 确实是可能通过引发异常而失败的函数。

但是,异常具有编译成高效机器码的优点。在实现可能经常回溯的试错方法时,可以使用异常来获得良好的性能。

异常属于类型 exn,它是一个可扩展的和类型

# exception Foo of string;;
exception Foo of string

# let i_will_fail () = raise (Foo "Oh no!");;
val i_will_fail : unit -> 'a = <fun>

# i_will_fail ();;
Exception: Foo "Oh no!".

在这里,我们将一个变体 Foo 添加到类型 exn 中,并创建一个将引发此异常的函数。现在,我们如何处理异常?构造是 try ... with ...

# try i_will_fail () with Foo _ -> ();;
- : unit = ()

预定义异常

标准库预定义了一些异常,请参阅 Stdlib。以下是一些示例

# 1 / 0;;
Exception: Division_by_zero.
# List.find (fun x -> x mod 2 = 0) [1; 3; 5];;
Exception: Not_found.
# String.sub "Hello world!" 3 (-2);;
Exception: Invalid_argument "String.sub / Bytes.sub".
# let rec loop x = x :: loop x;;
val loop : 'a -> 'a list = <fun>
# loop 42;;
Stack overflow during evaluation (looping recursion?).

尽管最后一个看起来不像异常,但它实际上是异常。

# try loop 42 with Stack_overflow -> [];;
- : int list = []

在标准库的预定义异常中,以下异常旨在由用户编写的函数引发

  • Exit 可用于终止迭代,就像 break 语句一样
  • Not_found 应该在搜索失败时引发,因为找不到任何令人满意的内容
  • Invalid_argument 应该在参数不可接受时引发
  • Failure 应该在无法生成结果时引发

标准库提供了使用字符串参数引发 Invalid_argumentFailure 的函数

val invalid_arg : string -> 'a
(** @raise Invalid_argument *)
val failwith : string -> 'a
(** @raise Failure *)

在实现公开引发异常的函数的软件组件时,必须做出设计决策

  • 使用预先存在的异常
  • 引发自定义异常

两者都有意义,并且没有普遍的规则。如果使用标准库异常,则必须在预期的条件下引发它们,否则处理程序将难以处理它们。使用自定义异常将强制客户端代码包含专用的捕获条件。对于必须在客户端级别处理的错误,这可能是可取的。

使用 Fun.protect

由于处理异常会中断正常的控制流,因此使用它们可能会使某些需要严格排序和耦合过程的任务复杂化。对于这些场景,标准库的 Fun 模块提供了

val protect : finally:(unit -> unit) -> (unit -> 'a) -> 'a

此函数旨在用于在计算完成后始终需要执行某些操作,无论其成功与否。首先调用未标记的函数参数,然后调用作为标记参数 finally 传递的函数。然后,protect 返回与未标记参数相同的值或引发该函数引发的相同异常。

请注意,传递给 protect 的函数仅接受 () 作为参数。这不会限制 protect 可以使用的场景。任何计算 f x 都可以包装在函数 fun () -> f x 中。与往常一样,函数体在函数被调用之前不会被求值。

finally 函数仅期望执行某些副作用,并且本身不应引发任何异常。如果 finally 确实抛出异常 e,则 protect 将引发 Finally_raised e,将其包装起来以明确表明异常不是来自受保护的函数。

让我们用一个尝试读取文本文件的前 n 行的函数(如 Unix 命令 head)来演示。如果文件少于 n 行,则该函数必须抛出 End_of_file。无论如何,文件描述符都必须随后关闭。以下是使用 Fun.protect 的可能实现

# let head_channel chan =
  let rec loop acc n = match input_line chan with
    | line when n > 0 -> loop (line :: acc) (n - 1)
    | _ -> List.rev acc in
  loop [];;
val head_channel : in_channel -> int -> string list = <fun>
# let head_file filename n =
  let ic = open_in filename in
  let finally () = close_in ic in
  let work () = head_channel ic n in
  Fun.protect ~finally work;;
val head_file : string -> int -> string list = <fun>

当调用 head_file 时,它会打开一个文件描述符,定义 finallywork,然后 Fun.protect ~finally work 按顺序执行两个计算:work () 然后 finally (),并且具有与 work () 相同的结果,要么返回值,要么引发异常。无论哪种方式,文件描述符都在使用后关闭。

异步异常

某些异常并非由于程序尝试执行的操作失败而引发,而是由于外部因素阻碍了其执行而引发。这些异常称为异步异常。这些包括

  • Out_of_memory
  • Stack_overflow
  • Sys.Break

当用户中断交互式执行时,会抛出后者。由于它们与程序逻辑的关系松散或根本没有关系,因此跟踪异步异常抛出的位置通常没有意义,因为它可能位于任何位置。决定应用程序是否需要捕获这些异常以及如何捕获这些异常超出了本教程的范围。感兴趣的读者可以参考 Guillaume Munch-Maccagnoni 的中断恢复指南

文档

可能引发异常的函数应按如下方式进行文档说明

val foo : a -> b
(** [foo] does this and that, here is how it works, etc.
    @raise Invalid_argument if [a] doesn't satisfy ...
    @raise Sys_error if filesystem is not happy
*)

堆栈跟踪

要获取未处理异常导致程序崩溃时的堆栈跟踪,需要在调试模式下编译程序(使用 `-g` 调用 `ocamlc`,或使用 `-tag 'debug'` 调用 `ocamlbuild`)。然后

OCAMLRUNPARAM=b ./myprogram [args]

您将获得一个堆栈跟踪。或者,您可以在程序内部调用:

let () = Printexc.record_backtrace true

打印

要打印异常,`Printexc` 模块非常有用。例如,下面的函数 `notify_user : (unit -> 'a) -> 'a` 调用一个函数,如果失败,则在 `stderr` 上打印异常。如果启用了堆栈跟踪,此函数也将显示它。

let notify_user f =
  try f () with e ->
    let msg = Printexc.to_string e
    and stack = Printexc.get_backtrace () in
      Printf.eprintf "there was an error: %s%s\n" msg stack;
      raise e

OCaml 知道如何打印其内置异常,但您也可以告诉它如何打印您自己的异常

exception Foo of int

let () =
  Printexc.register_printer
    (function
      | Foo i -> Some (Printf.sprintf "Foo(%d)" i)
      | _ -> None (* for other exceptions *)
    )

每个打印器应处理其了解的异常,返回 `Some <打印的异常>`,否则返回 `None`(让其他打印器处理)。

运行时崩溃

尽管 OCaml 是一种非常安全的语言,但在运行时仍可能触发不可恢复的错误。

未引发的异常

编译器和运行时尽最大努力引发有意义的异常。但是,某些错误条件可能未被检测到,这可能导致段错误。对于 `Out_of_memory` 尤其如此,它不可靠。`Stack_overflow` 以前也是如此

但是,在类 Unix 系统和 Windows 下捕获堆栈溢出都很棘手,因此 OCaml 中的当前实现是一种偶尔会出现错误的尽力而为的方法。

Xavier Leroy,2021 年 10 月

这种情况已经有所改善。只有链接的 C 代码才能触发未检测到的堆栈溢出。

固有危险函数

一些 OCaml 函数本质上是不安全的。谨慎使用它们,不要像这样使用

> echo "fst Marshal.(from_string (to_string 0 []) 0)" | ocaml -stdin
Segmentation fault (core dumped)

语言错误

当崩溃不是来自

  • 原生代码编译器的限制
  • 诸如 `Marshal` 和 `Obj` 模块中发现的固有危险函数

它可能是语言错误。这种情况会发生。以下是怀疑出现这种情况时应采取的措施

  1. 确保崩溃影响两个编译器:字节码和原生代码
  2. 编写一个独立且最小的概念验证代码,该代码除了触发崩溃之外什么也不做
  3. GitHub 上的 OCaml 错误跟踪器 中提交问题

这是一个此类错误的示例:https://github.com/ocaml/ocaml/issues/7241

安全函数与危险函数

未捕获的异常会导致运行时崩溃。因此,倾向于使用以下术语

  • 引发异常的函数:危险
  • 处理数据中错误的函数:安全

编写此类安全错误处理函数的主要方法是使用 **`option`**(下一节)或 **`result`**(下一节)值。尽管使用这些类型处理数据中的错误可以避免错误值和异常的问题,但它需要在每一步提取封闭的值,这可能导致样板代码并产生运行时成本。

使用 **`option`** 类型处理错误

**`option`** 模块提供了异常的第一个替代方案。`'a option` 数据类型表示类型 `'a` 的数据 - 例如,`Some 42` 的类型为 `int option` - 或由于任何错误而导致的数据缺失,表示为 `None`。

使用 **`option`**,可以编写返回 `None` 而不是抛出异常的函数。以下是用这种函数的两个示例

# let div_opt m n =
  try Some (m / n) with
    Division_by_zero -> None;;
val div_opt : int -> int -> int option = <fun>

# let find_opt p l =
  try Some (List.find p l) with
    Not_found -> None;;
val find_opt : ('a -> bool) -> 'a list -> 'a option = <fun>

我们可以尝试这些函数

# 1 / 0;;
Exception: Division_by_zero.
# div_opt 42 2;;
- : int option = Some 21
# div_opt 42 0;;
- : int option = None
# List.find (fun x -> x mod 2 = 0) [1; 3; 5];;
Exception: Not_found.
# find_opt (fun x -> x mod 2 = 0) [1; 3; 4; 5];;
- : int option = Some 4
# find_opt (fun x -> x mod 2 = 0) [1; 3; 5];;
- : int option = None

如今,当一个函数在并非错误的情况下(即,不是 `assert false`,而是网络故障、密钥不存在等)可能失败时,倾向于认为返回诸如 `'a option` 或 `('a, 'b) result`(参见下一节)之类的类型而不是抛出异常是良好的实践。

命名约定

对于命名具有相同基本行为的函数对(其中一个可能引发异常,另一个返回选项)有两种约定。在上面的示例中,使用了标准库的约定:在返回选项而不是引发异常的函数版本的名称中添加 `_opt` 后缀。

val find: ('a -> bool) -> 'a list -> 'a
(** @raise Not_found *)
val find_opt: ('a -> bool) -> 'a list -> 'a option

这是从标准库的 `List` 模块中提取的。

但是,一些项目倾向于避免或减少异常的使用。在这种情况下,相反的约定相对常见:引发异常的函数版本以 `_exn` 作为后缀。使用相同的函数,这将给出规范

val find_exn: ('a -> bool) -> 'a list -> 'a
(** @raise Not_found *)
val find: ('a -> bool) -> 'a list -> 'a option

组合返回选项的函数

函数 `div_opt` 不会引发异常。但是,由于它不返回类型 `int` 的结果,因此不能替代 `int`。同样,OCaml 不会将整数提升为浮点数,也不会自动将 `int option` 转换为 `int`,反之亦然。

# 21 + Some 21;;
Error: This expression has type 'a option
       but an expression was expected of type int

为了将选项值与其他值组合,需要转换函数。以下是 **`option`** 模块提供的用于提取选项中包含数据的函数

val get : 'a t -> 'a
val value : 'a t -> default:'a -> 'a
val fold : none:'a -> some:('b -> 'a) -> 'b t -> 'a

`get` 返回内容,如果应用于 `None` 则引发 `Invalid_argument`。`value` 返回内容,如果应用于 `None` 则返回其 `default` 参数。`fold` 返回其 `some` 参数应用于选项的内容,如果应用于 `None` 则返回其 `none` 参数。

作为说明,请注意可以使用 `fold` 实现 `value`

# let value ~default = Option.fold ~none:default ~some:Fun.id;;
val value : default:'a -> 'a option -> 'a = <fun>
# Option.value ~default:() None = value ~default:() None;;
- : bool = true
# Option.value ~default:() (Some ()) = value ~default:() (Some ());;
- : bool = true

还可以对选项值执行模式匹配

match opt with
| None -> ...    (* Something *)
| Some x -> ...  (* Something else *)

但是,此类表达式的顺序会导致深度嵌套,这通常被认为是不好的

如果您需要超过 3 层缩进,无论如何您都遇到了麻烦,应该修复您的程序。

Linux 内核风格指南

避免这种情况的推荐方法是避免或延迟尝试访问选项值的的内容,如下一小节所述。

使用 `Option.map` 和 `Option.bind`

让我们从一个例子开始:假设需要编写一个函数,返回作为字符串提供的电子邮件地址的主机名部分。例如,给定字符串 `"[email protected]"`,它将返回字符串 `"courrier"`(有人可能认为这种设计不合理,但这只是一个例子)。

这是一个使用异常的可疑但简单的实现

# let host email =
  let fqdn_pos = String.index email '@' + 1 in
  let fqdn_len = String.length email - fqdn_pos in
  let fqdn = String.sub email fqdn_pos fqdn_len in
  try
    let host_len = String.index fqdn '.' in
    String.sub fqdn 0 host_len
  with Not_found ->
    if fqdn <> "" then fqdn else raise Not_found;;
val host : string -> string = <fun>

如果对 `String.index` 的第一次调用失败,则可能会引发 `Not_found`,如果输入字符串中没有 `@` 字符,则可能发生这种情况,表示它不是电子邮件地址。但是,如果对 `String.index` 的第二次调用失败,表示未找到点字符,我们可以返回整个完全限定域名 (FQDN) 作为后备,但前提是它不是空字符串。

请注意,通常 `String.sub` 可能会抛出 `Invalid_argument`。幸运的是,在计算 `fqdn` 时不会发生这种情况。在最坏的情况下,`@` 字符是最后一个字符,此时 `fqdn_pos` 超出范围一个位置,但 `fqdn_len` 为零,并且参数的这种组合会生成空字符串而不是异常。

以下是使用相同逻辑但使用 **`option`** 而不是异常的等效函数

# let host_opt email =
  match String.index_opt email '@' with
  | Some at_pos -> begin
      let fqdn_pos = at_pos + 1 in
      let fqdn_len = String.length email - fqdn_pos in
      let fqdn = String.sub email fqdn_pos fqdn_len in
      match String.index_opt fqdn '.' with
      | Some host_len -> Some (String.sub fqdn 0 host_len)
      | None -> if fqdn <> "" then Some fqdn else None
    end
  | None -> None;;
val host_opt : string -> string option = <fun>

尽管它符合安全的标准,但其可读性并没有提高。有些人甚至会声称它更糟糕。

在展示如何改进此代码之前,我们需要解释 `Option.map` 和 `Option.bind` 的工作原理。以下是它们的类型

val map : ('a -> 'b) -> 'a option -> 'b option
val bind : 'a option -> ('a -> 'b option) -> 'b option

`Option.map` 将函数 `f` 应用于选项参数(如果它不是 `None`)

let map f = function
| Some x -> Some (f x)
| None -> None

如果可以将 `f` 应用于某些内容,则其结果将重新包装到一个新的选项中。如果没有要提供给 `f` 的内容,则转发 `None`。

如果不考虑参数顺序,`Option.bind` 几乎完全相同,只是我们假设 `f` 返回一个选项。因此,无需重新包装其结果,因为它已经是选项值

let bind opt f = match opt with
| Some x -> f x
| None -> None

`bind` 相对于 `map` 改变了参数顺序,允许将其用作 绑定运算符,这是 OCaml 的一个流行扩展,提供创建“自定义 `let`” 的方法。以下是其工作原理

# let ( let* ) = Option.bind;;
val ( let* ) : 'a option -> ('a -> 'b option) -> 'b option = <fun>

使用这些机制,以下是一种重写 `host_opt` 的可能方法

# let host_opt email =
  let* fqdn_pos = Option.map (( + ) 1) (String.index_opt email '@') in
  let fqdn_len = String.length email - fqdn_pos in
  let fqdn = String.sub email fqdn_pos fqdn_len in
  String.index_opt fqdn '.'
  |> Option.map (fun host_len -> String.sub fqdn 0 host_len)
  |> function None when fqdn <> "" -> Some fqdn | opt -> opt;;
val host_opt : string -> string option = <fun>

选择此版本是为了说明如何使用和组合选项上的操作,允许用户在可理解性和鲁棒性之间取得平衡。一些观察结果

  • 与原始的 `host` 函数(带有异常)相同
    • 对 `String` 函数(`index_opt`、`length` 和 `sub`)的调用是相同的,并且顺序相同
    • 使用相同的局部名称和相同的类型
  • 没有剩余的缩进或模式匹配
  • 第 1 行
    • `=` 右侧:`Option.map` 允许在 `String.index_opt` 的结果(如果未失败)上加 1。
    • `=` 左侧:`let*` 语法将其余代码(从第 2 行到结尾)转换为一个匿名函数的主体,该函数以 `fqdn_pos` 作为参数,并且函数 `( let* )` 使用 `fqdn_pos` 和该匿名函数调用。
  • 第 2 行和第 3 行:与原始代码相同
  • 第 4 行:删除了 `try` 或 `match`
  • 第 5 行:如果前一步没有失败,则应用 `String.sub`,否则转发错误
  • 第 6 行:如果之前没有找到任何内容,并且它不是空的,则返回 `fqdn` 作为后备

当用于使用 catch 语句处理错误时,需要一些时间才能适应后一种风格。关键思想是避免或推迟直接查看选项值。相反,使用特定于此目的的管道(如 `map` 和 `bind`)传递它们。Erik Meijer 将这种风格称为“遵循快乐路径”。从视觉上看,它也类似于 C 中常见的“提前返回”模式。

option 类型的一个限制是它不记录导致没有返回值的原因。None 是沉默的,它没有说明任何出错的信息。因此,返回 option 值的函数应该记录它们可能返回 None 的情况。此类文档可能类似于使用 @raise 要求的异常文档。下一节中描述的**result** 类型旨在填补这一空白:像 option 值一样管理数据中的错误,并像异常一样提供错误信息。

使用**result** 类型处理错误

标准库的**result** 模块定义了以下类型

type ('a, 'b) result =
  | Ok of 'a
  | Error of 'b

Ok x 表示计算成功并生成了 x,值 Error e 表示计算失败,而 e 表示在此过程中收集到的任何错误信息。模式匹配可用于处理这两种情况,就像任何其他和类型一样。但是,使用 mapbind 可能更方便,甚至可能比使用**option** 更方便。

在查看 Result.map 之前,让我们从一个新的角度思考 List.mapOption.map。这两个函数在分别应用于 []None 时都表现为恒等函数。这是唯一可能的情况,因为这些参数不携带任何数据——不像**result** 具有其 Error 构造函数。但是,Result.map 的实现类似:在 Error 上,它也表现为恒等函数。

以下是它的类型

val map : ('a -> 'b) -> ('a, 'c) result -> ('b, 'c) result

以下是它的写法

let map f = function
| Ok x -> Ok (f x)
| Error e -> Error e

**result** 模块有两个 map 函数:我们刚刚看到的那个和另一个具有相同逻辑的函数,应用于 Error

以下是它的类型

val map_error : ('c -> 'd) -> ('a, 'c) result -> ('a, 'd) result

以下是它的写法

let map_error f = function
| Ok x -> Ok x
| Error e -> f e

同样的推理也适用于 Result.bind,只是没有 bind_error。使用这些函数,以下是用 Anil Madhavapeddy 的 OCaml YAML 库 编写的代码的假设示例

let file_opt = File.read_opt path in
let file_res = Option.to_result ~none:(`Msg "File not found") file_opt in begin
  let* yaml = Yaml.of_string file_res in
  let* found_opt = Yaml.Util.find key yaml in
  let* found = Option.to_result ~none:(`Msg (key ^ ", key not found")) found_opt in
  found
end |> Result.map_error (Printf.sprintf "%s, error: %s: " path)

以下是相关函数的类型

val File.read_opt : string -> string option
val Yaml.of_string : string -> (Yaml.value, [`Msg of string]) result
val Yaml.Util.find : string -> Yaml.value -> (Yaml.value option, [`Msg of string]) result
val Option.to_result : none:'e -> 'a option -> ('a, 'e) result
  • File.read_opt 应该打开一个文件,读取其内容并将其作为包含在 option 中的字符串返回,如果出现任何错误,则返回 None
  • Yaml.of_string 解析字符串并将其转换为特定于应用程序的 OCaml 类型
  • Yaml.find 在 YAML 树中递归搜索键。如果找到,则返回相应的数据,并将其包装在 option
  • Option.to_result 执行将**option** 转换为**result** 的操作。
  • 最后,let* 代表 Result.bind

由于 Yaml 模块中的这两个函数都返回**result** 数据,因此更容易编写一个始终处理该类型的管道。这就是为什么需要使用 Option.to_result 的原因。产生**result** 的阶段必须使用 bind 连接;不产生**result** 的阶段必须使用某些 map 函数将其值包装回**result** 中。

**result** 模块的 map 函数允许处理数据或错误,但使用的例程不能失败,因为 Result.map 永远不会将 Ok 转换为 Error,而 Result.map_error 永远不会将 Error 转换为 Ok。另一方面,传递给 Result.bind 的函数允许失败。如前所述,不存在 Result.bind_error。理解这种缺失的一种方法是考虑其类型,它必须是

val Result.bind_error : ('a, 'e) result -> ('e -> ('a, 'f) result) -> ('a, 'f) result

我们将有

  • Result.map_error f (Ok x) = Ok x
  • 以及
    • Result.map_error f (Error e) = Ok y
    • Result.map_error f (Error e) = Error e'

这意味着错误将被转换回有效数据或更改为另一个错误。这几乎就像从错误中恢复一样。但是,当恢复失败时,可能更希望保留最初的失败原因。可以通过定义以下函数来实现此行为

# let recover f = Result.(fold ~ok:ok ~error:(fun (e : 'e) -> Option.to_result ~none:e (f e)));;
val recover : ('e -> 'a option) -> ('a, 'e) result -> ('a, 'e) result = <fun>

尽管任何类型的数据都可以作为**result** Error 包装,但建议使用此构造函数来承载实际的错误,例如

  • exn,在这种情况下,result 类型只是使异常显式化
  • string,其中错误情况是指示哪个操作失败的消息
  • string Lazy.t,一种更详细的错误消息形式,仅在需要打印时才进行评估
  • 一些多态变体,每个可能的错误对应一个情况。这非常准确(每个错误都可以明确处理并在类型中出现),但多态变体的使用有时会使代码更难阅读。

请注意,有些人说**result** 和 Either.t 类型是同构的。具体来说,这意味着始终可以将一个替换为另一个,就像在完全中性的重构中一样。**result** 和 Either.t 类型的值可以相互转换,并且按任何顺序连续应用这两种转换都会返回到起始值。但是,这并不意味着应该用**result** 代替 Either.t,反之亦然。命名事物很重要,正如 Phil Karlton 的名言所暗示的那样

计算机科学中只有两件难事:缓存失效和命名事物。

处理错误必然会使代码复杂化,使其比在异常情况下表现不正确或失败的简单代码更难阅读和理解。正确的工具、数据和函数可以帮助您确保正确的行为,同时最大程度地减少清晰度的损失。请使用它们。

bind 作为二元运算符

当使用 Option.bindResult.bind 时,它们通常会被别名为自定义绑定运算符,例如 let*。但是,也可以将其用作二元运算符,通常写成 >>=。必须详细说明以这种方式使用 bind,因为它在其他函数式编程语言(尤其是在 Haskell 中)中非常流行。

假设 ab 是有效的 OCaml 表达式,则以下三段源代码在功能上是相同的

bind a (fun x -> b)
let* x = a in b
a >>= fun x -> b

这可能看起来毫无意义。要理解这一点,必须查看链接了多个 bind 调用的表达式。以下三个表达式也等价

bind a (fun x -> bind b (fun y -> c))
let* x = a in
let* y = b in
c
a >>= fun x -> b >>= fun y -> c

变量 xy 可能会出现在这三种情况下的 c 中。第一种形式不太方便,因为它使用了大量的括号。由于其与常规局部定义的相似性,第二种形式通常更受青睐。第三种形式更难阅读,因为 >>= 为了避免在这种特定情况下使用括号而向右结合,但很容易迷失方向。但是,当使用命名函数时,它具有一定的吸引力。它有点像老式的 Unix 管道

a >>= f >>= g

看起来比

let* x = a in
let* y = f x in
g y

编写 x >>= f 非常接近于在具有方法和接收器的函数式污染的编程语言(如 Kotlin、Scala、Go、Rust、Swift,甚至现代 Java)中找到的内容,在那里它看起来像:x.bind(f)

以下是上一节末尾所示的相同代码,使用 Result.bind 作为二元运算符重写

File.read_opt path
|> Option.to_result ~none:(`Msg "File not found")
>>= Yaml.of_string
>>= Yaml.Util.find key
>>= Option.to_result ~none:(`Msg (key ^ ", key not found"))
|> Result.map_error (Printf.sprintf "%s, error: %s: " path)

顺便说一下,这种风格称为默示编程。由于 >>=|> 运算符的结合优先级,没有括号表达式超出单行。

OCaml 具有严格的类型规范,而不是严格的样式规范;因此,选择正确的样式由作者决定。这适用于错误处理,因此请明智地选择样式。有关这些事项的更多详细信息,请参阅OCaml 编程指南

错误之间的转换

从**option** 或**result** 中抛出异常

这是通过使用以下函数完成的

  • 从**option** 到 Invalid_argument 异常,使用函数 Option.get

    val get : 'a option -> 'a
    
  • 从**result** 到 Invalid_argument 异常,使用函数 Result.get_okResult.get_error

    val get_ok : ('a, 'e) result -> 'a
    val get_error : ('a, 'e) result -> 'e
    

要引发其他异常,必须使用模式匹配和 raise

**option** 和**result** 之间的转换

这是通过使用以下函数完成的

  • 从**option** 到**result**,使用函数 Option.to_result
    val to_result : none:'e -> 'a option -> ('a, 'e) result
    
  • 从**result** 到**option**,使用函数 Result.to_option
    val to_option : ('a, 'e) result -> 'a option
    

将异常转换为**option** 或**result**

标准库不提供此类函数。这必须使用**try ... with** 或 match ... exception 语句完成。例如,以下是如何创建 Stdlib.input_line 的一个版本,该版本返回**option** 而不是抛出异常

let input_line_opt ic = try Some (input_line ic) with End_of_file -> None

对于**result** 来说也是一样的,只是必须向 Error 构造函数提供一些数据。

有些人可能希望将其转换为更高阶的通用函数

# let catch f x = try Some (f x) with _ -> None;;
val catch : ('a -> 'b) -> 'a -> 'b option = <fun>

断言

内置的 assert 指令以表达式作为参数,如果提供的表达式计算结果为 false,则抛出 Assert_failure 异常。假设您没有捕获此异常(特别是对于初学者来说,捕获此异常可能是不明智的),这会导致程序停止并打印发生错误的源文件和行号。一个例子

# assert (Sys.os_type = "Win32");;
Exception: Assert_failure ("//toplevel//", 1, 0).

当然,在 Win32 上运行此代码不会引发错误。

编写 assert false 将只会停止您的程序。此习惯用法有时用于指示死代码,即必须编写(通常用于类型检查或模式匹配完整性)但在运行时无法访问的程序部分。

断言应该被理解为可执行的注释。它们不应该失败,除非在调试期间或真正异常的情况下,这些情况绝对阻止执行取得任何进展。

当执行到达无法处理的条件时,正确的做法是抛出一个Failure,通过调用failwith "error message"。断言不打算处理这些情况。例如,在以下代码中

match Sys.os_type with
| "Unix" | "Cygwin" ->   (* code omitted *)
| "Win32" ->             (* code omitted *)
| "MacOS" ->             (* code omitted *)
| _ -> failwith "this system is not supported"

使用failwith是正确的,不支持其他操作系统,但它们是可能的。以下是双重示例

function x when true -> () | _ -> assert false

这里,使用failwith是不正确的,因为需要损坏的系统或编译器出现错误才能执行第二条代码路径。语言语义的破坏属于特殊情况。这很灾难性!

总结

正确处理错误是一个复杂的问题。它是一个横切关注点,涉及应用程序的所有部分,并且不能在一个专门的模块中隔离。与其他几种主流语言相比,OCaml 提供了几种处理异常事件的机制,所有这些机制都具有良好的运行时性能和代码可理解性。正确使用它们需要一些最初的学习和实践。之后,它总是需要一些思考,这是有益的,因为正确的错误管理永远不应该被忽视。没有一种错误处理机制总是优于其他机制,选择使用哪一种应该是一个适应上下文而不是品味的问题。但有主见 OCaml 代码也没问题,所以这是一个平衡。

外部资源

  • “异常” 在“OCaml 手册,核心语言”中,第 1 章,第 6 节,2022 年 12 月
  • 模块 option 在 OCaml 库中
  • 模块 result 在 Ocaml 库中
  • “错误处理” 在“Real World OCaml”中,第 7 部分,Yaron Minsky 和 Anil Madhavapeddy,第 2 版,剑桥大学出版社,2022 年 10 月
  • “在 Pervasives 中添加“finally”函数”,Marcello Seri,GitHub PR,ocaml/ocaml/pull/1855
  • “中断恢复指南”,Guillaume Munch-Maccagnoni,memprof-limits 文档的一部分

仍然需要帮助?

帮助改进我们的文档

所有 OCaml 文档都是开源的。看到错误或不清楚的地方?提交一个拉取请求。

OCaml

创新。社区。安全。