Skip to main content

第 8 章:错误处理(Error Handling)

原文:Anil Madhavapeddy and Yaron Minsky, Real World OCaml: Functional Programming for the Masses, Second Edition, Chapter 8。维护者已确认本书为开源书籍,可翻译并发布用于学习研究。

没有人喜欢处理错误。它乏味、容易出错,而且通常远不如思考程序如何成功运行来得有趣。但错误处理很重要;无论你多么不愿意考虑它,软件因为糟糕的错误处理而失败,后果都会更糟。

幸好,OCaml 提供了强大的工具,让我们可以可靠地处理错误,同时尽量少受折磨。本章会讨论 OCaml 中处理错误的几种不同方式,并给出一些接口设计建议,让错误处理更轻松。

我们会先介绍 OCaml 中报告错误的两种基本方式:带错误信息的返回类型和异常。

8.1 带错误信息的返回类型(Error-Aware Return Types)

在 OCaml 中,发出错误信号的最佳方式,是把错误包含在返回值里。考虑 List 模块中 find 函数的类型:

# open Base;;
# List.find;;
- : 'a list -> f:('a -> bool) -> 'a option = <fun>

返回类型中的 option 表明,这个函数可能无法找到合适的元素:

# List.find [1;2;3] ~f:(fun x -> x >= 2);;
- : int option = Some 2
# List.find [1;2;3] ~f:(fun x -> x >= 10);;
- : int option = None

把错误包含在函数返回值中,会要求调用者显式处理错误;调用者因此可以选择从错误中恢复,或者把错误继续向上传递。

考虑下面的 compute_bounds 函数。它接收一个列表和一个比较函数,通过找出列表中最小和最大的元素来返回上下界。这里使用 List.hdList.last 提取列表的第一个和最后一个元素;当遇到空列表时,它们都会返回 None

# let compute_bounds ~compare list =
let sorted = List.sort ~compare list in
match List.hd sorted, List.last sorted with
| None,_ | _, None -> None
| Some x, Some y -> Some (x,y);;
val compute_bounds : compare:('a -> 'a -> int) -> 'a list -> ('a * 'a) option =
<fun>

这里用 match 表达式处理错误情况,把 hdlast 中的 None 传播为 compute_bounds 的返回值。

另一方面,在下面的 find_mismatches 中,计算过程中遇到的错误并不会传播到函数的返回值。find_mismatches 接收两个哈希表作为参数,查找那些在一个表中与另一个表中数据不同的键。因此,在某个表里找不到键并不算任何意义上的失败:

# let find_mismatches table1 table2 =
Hashtbl.fold table1 ~init:[] ~f:(fun ~key ~data mismatches ->
match Hashtbl.find table2 key with
| Some data' when data' <> data -> key :: mismatches
| _ -> mismatches
);;
val find_mismatches :
('a, int) Hashtbl.Poly.t -> ('a, int) Hashtbl.Poly.t -> 'a list = <fun>

用 option 编码错误,也突出了一个事实:某个结果到底是错误,还是另一个有效结果,并不总是清楚。例如,在列表中找不到某个元素,究竟意味着失败,还是只是正常结果的一种?这取决于程序的更大上下文,因此不是通用库能够提前知道的事情。带错误信息的返回类型的一个优点,就是它们在这两种情况下都能很好地工作。

8.1.1 使用 Result 编码错误(Encoding Errors with Result)

option 并不总是足够有表达力的错误报告方式。具体来说,当你把错误编码为 None 时,没有地方说明错误的性质。

Result.t 旨在弥补这一缺陷。它的类型定义如下:

module Result : sig
type ('a,'b) t = | Ok of 'a
| Error of 'b
end

Result.t 本质上是一个增强版 option,它允许在错误分支存储额外信息。和 option 的 SomeNone 一样,构造器 OkError 也在顶层可用。因此,我们可以这样写:

# [ Ok 3; Error "abject failure"; Ok 4 ];;
- : (int, string) result list = [Ok 3; Error "abject failure"; Ok 4]

不需要先打开 Result 模块。

8.1.2 Error 与 Or_error(Error and Or_error)

Result.t 让你可以完全自由地选择用于表示错误的值类型,但在很多时候,统一采用一种错误类型很有用。这样做的好处之一,是更容易编写工具函数,自动化常见的错误处理模式。

但该选择哪种类型呢?把错误表示为字符串更好吗?表示为 XML 之类更结构化的形式?还是完全不同的东西?

Base 对这个问题的答案是 Error.t 类型。例如,可以从字符串构造一个 Error.t

# Error.of_string "something went wrong";;
- : Error.t = something went wrong

Or_error.t 只是把错误分支特化为 Error.tResult.t。下面是一个例子。

# Error (Error.of_string "failed!");;
- : ('a, Error.t) result = Error failed!

Or_error 模块提供了一组有用的运算,用于构造错误。例如,可以用 Or_error.try_with 捕获一次计算中抛出的异常。

# let float_of_string s =
Or_error.try_with (fun () -> Float.of_string s);;
val float_of_string : string -> float Or_error.t = <fun>
# float_of_string "3.34";;
- : float Or_error.t = Base__.Result.Ok 3.34
# float_of_string "a.bc";;
- : float Or_error.t =
Base__.Result.Error (Invalid_argument "Float.of_string a.bc")

创建 Error.t 最常见的方式,或许是使用 S 表达式(s-expressions)。S 表达式是一种平衡括号表达式,表达式的叶子是字符串。下面是一个简单例子:

(This (is an) (s expression))

S 表达式由随 Base 一起分发的 Sexplib 包支持,也是 Base 中最常用的序列化格式。事实上,Base 中的大多数类型都内置了 S 表达式转换器。

# Error.create "Unexpected character" 'c' Char.sexp_of_t;;
- : Error.t = ("Unexpected character" c)

我们并不局限于只用内置类型做这种错误报告。正如第 21 章“使用 S 表达式进行数据序列化(Data Serialization with S-Expressions)”会更详细讨论的,Sexplib 带有一个语法扩展,可以为特定类型自动生成 sexp 转换器。我们可以在顶层用 #require 语句启用 ppx_jane;它是一个会引入多个不同语法扩展的包,其中包括这里需要的 ppx_sexp_value。(由于顶层中的一些技术问题,我们不容易单独启用这些语法扩展。)

# #require "ppx_jane";;
# Error.t_of_sexp
[%sexp ("List is too long",[1;2;3] : string * int list)];;
- : Error.t = ("List is too long" (1 2 3))

Error 也支持转换错误的操作。例如,经常有必要用错误发生的上下文信息增强某个错误,或者把多个错误组合到一起。Error.tagError.of_list 就承担这些角色:

# Error.tag
(Error.of_list [ Error.of_string "Your tires were slashed";
Error.of_string "Your windshield was smashed" ])
~tag:"over the weekend";;
- : Error.t =
("over the weekend" "Your tires were slashed" "Your windshield was smashed")

生成错误的一种非常常见方式是 %message 语法扩展。它提供了一种紧凑语法,可以给出描述错误的字符串,并附带更多以 S 表达式呈现的值。下面是一个例子。

# let a = "foo" and b = ("foo",[3;4]);;
val a : string = "foo"
val b : string * int list = ("foo", [3; 4])
# Or_error.error_s
[%message "Something went wrong" (a:string) (b: string * int list)];;
- : 'a Or_error.t =
Base__.Result.Error ("Something went wrong" (a foo) (b (foo (3 4))))

这是生成 Error.t 最常见的惯用方式。

8.1.3 bind 与其他错误处理惯用法(bind and Other Error Handling Idioms)

随着你在 OCaml 中编写越来越多错误处理代码,会发现某些模式开始反复出现。这些常见模式中有不少已经由 OptionResult 这类模块中的函数编码下来。一个特别有用的模式围绕 bind 函数构建;它既是一个普通函数,也是中缀运算符 >>=。下面是 option 上 bind 的定义:

# let bind option ~f =
match option with
| None -> None
| Some x -> f x;;
val bind : 'a option -> f:('a -> 'b option) -> 'b option = <fun>

可以看到,bind None f 会直接返回 None,而不会调用 fbind (Some x) ~f 则返回 f xbind 可用于串联一组可能产生错误的函数,让第一个产生错误的函数终止整个计算。下面把 compute_bounds 改写为使用一串嵌套的 bind

# let compute_bounds ~compare list =
let sorted = List.sort ~compare list in
Option.bind (List.hd sorted) ~f:(fun first ->
Option.bind (List.last sorted) ~f:(fun last ->
Some (first,last)));;
val compute_bounds : compare:('a -> 'a -> int) -> 'a list -> ('a * 'a) option =
<fun>

不过,从语法层面看,前面的代码有点难以下咽。我们可以使用 bind 的中缀运算符形式,让它更容易阅读,并减少一些括号;通过局部打开 Option.Monad_infix 就能访问这个形式。这个模块叫 Monad_infix,是因为 bind 运算符属于一个叫 Monad 的子接口;第 17 章“使用 Async 的并发编程(Concurrent Programming with Async)”中还会再次见到它。

# let compute_bounds ~compare list =
let open Option.Monad_infix in
let sorted = List.sort ~compare list in
List.hd sorted >>= fun first ->
List.last sorted >>= fun last ->
Some (first,last);;
val compute_bounds : compare:('a -> 'a -> int) -> 'a list -> ('a * 'a) option =
<fun>

这种 bind 用法并没有比最开始的版本实质上好多少;事实上,对于这样的小例子,直接匹配 option 通常比使用 bind 更好。但在包含许多错误处理阶段的大型复杂例子中,bind 惯用法会变得更清晰、更容易管理。

8.1.3.1 单子与 Let_syntax(Monads and Let_syntax

我们可以用一个专门为单子 bind 设计的语法扩展,让这段代码看起来更普通一些。这个扩展叫 Let_syntax。下面是前面的例子使用该扩展后的样子。

# #require "ppx_let";;
# let compute_bounds ~compare list =
let open Option.Let_syntax in
let sorted = List.sort ~compare list in
let%bind first = List.hd sorted in
let%bind last = List.last sorted in
Some (first,last);;
val compute_bounds : compare:('a -> 'a -> int) -> 'a list -> ('a * 'a) option =
<fun>

注意,我们需要一条 #require 语句来启用这个扩展。

要理解这里发生了什么,需要知道 let%bind x = some_expr in some_other_expr 会被重写为 some_expr >>= fun x -> some_other_expr

Let_syntax 的好处是,它让单子 bind 看起来更像普通的 let 绑定。这一点之所以自然,是因为你可以把这里的单子 bind 理解成一种特殊形式的 let 绑定,只不过它内置了一些错误处理语义。

Option 中还编码了其他有用惯用法。一个例子是 Option.both:它接收两个可选值,产生一个新的可选对;只要任一参数是 None,结果就是 None。使用 Option.both,可以把 compute_bounds 写得更短:

# let compute_bounds ~compare list =
let sorted = List.sort ~compare list in
Option.both (List.hd sorted) (List.last sorted);;
val compute_bounds : compare:('a -> 'a -> int) -> 'a list -> ('a * 'a) option =
<fun>

这些错误处理函数很有价值,因为它们让你能够既显式又简洁地表达错误处理。这里我们只在 Option 模块的语境中讨论了这些函数,但 ResultOr_error 模块中也可以找到更多类似功能。

8.2 异常(Exceptions)

OCaml 中的异常与 Java、C#、Python 等许多其他语言中的异常并没有太大不同。异常是一种终止计算并报告错误的方式,同时提供一种机制,用来捕获和处理由子计算触发的异常,并且有时可以从中恢复。

例如,整数除以零会触发一个异常:

# 3 / 0;;
Exception: Division_by_zero.

即使异常深藏在某个计算内部,也能终止整个计算:

# List.map ~f:(fun x -> 100 / x) [1;3;0;4];;
Exception: Division_by_zero.

如果在计算中间放一个 printf,就能看到 List.map 在执行途中被中断,根本没有到达列表末尾:

# List.map ~f:(fun x -> Stdio.printf "%d\n%!" x; 100 / x) [1;3;0;4];;
1
3
0
Exception: Division_by_zero.

除了 Division_by_zero 这样的内置异常,OCaml 还允许你定义自己的异常:

# exception Key_not_found of string;;
exception Key_not_found of string
# raise (Key_not_found "a");;
Exception: Key_not_found("a").

异常是普通值,可以像其他 OCaml 值一样被操作:

# let exceptions = [ Division_by_zero; Key_not_found "b" ];;
val exceptions : exn list = [Division_by_zero; Key_not_found("b")]
# List.filter exceptions ~f:(function
| Key_not_found _ -> true
| _ -> false);;
- : exn list = [Key_not_found("b")]

所有异常都具有同一种类型 exn,它本身在 OCaml 类型系统中有些特殊。它类似第 7 章“变体(Variants)”中遇到的变体类型,只不过它是开放的(open),也就是说它不是在某一个地方被完整定义的。具体来说,程序的不同部分都可以向它添加新标签,也就是新异常。这与普通变体不同;普通变体是在一个封闭的可用标签集合上定义的。由此带来的一个结果是,你永远无法对 exn 做穷尽匹配,因为所有可能异常的完整集合是未知的。

下面的函数使用前面定义的 Key_not_found 异常来发出错误信号:

# let rec find_exn alist key = match alist with
| [] -> raise (Key_not_found key)
| (key',data) :: tl -> if String.(=) key key' then data else find_exn tl key;;
val find_exn : (string * 'a) list -> string -> 'a = <fun>
# let alist = [("a",1); ("b",2)];;
val alist : (string * int) list = [("a", 1); ("b", 2)]
# find_exn alist "a";;
- : int = 1
# find_exn alist "c";;
Exception: Key_not_found("c").

注意,我们把函数命名为 find_exn,用来警告用户这个函数会例行抛出异常。这是 Base 中大量使用的约定。

在前面的例子中,raise 抛出异常,从而终止计算。第一次看到 raise 的类型时,可能会觉得有点意外:

# raise;;
- : exn -> 'a = <fun>

返回类型 'a 看起来像是 raise 会制造出一个返回值,而且这个返回值的类型完全不受约束。这似乎不可能,事实也确实如此。真正的原因是,raise 的返回类型是 'a,因为它根本不会返回。这种行为并不限于像 raise 这样通过抛出异常来终止的函数。下面是另一个不返回值的函数:

# let rec forever () = forever ();;
val forever : unit -> 'a = <fun>

forever 不返回值的原因不同:它是一个无限循环。

这一点很重要,因为它意味着 raise 的返回类型可以是上下文所需要的任何类型。因此,类型系统允许我们在程序的任何位置抛出异常。

8.2.1 使用 [@@deriving sexp] 声明异常(Declaring Exceptions Using [@@deriving sexp]

OCaml 并不总是能为异常生成有用的文本表示。例如:

# type 'a bounds = { lower: 'a; upper: 'a };;
type 'a bounds = { lower : 'a; upper : 'a; }
# exception Crossed_bounds of int bounds;;
exception Crossed_bounds of int bounds
# Crossed_bounds { lower=10; upper=0 };;
- : exn = Crossed_bounds(_)

但如果我们用 [@@deriving sexp] 声明异常及其依赖的类型,就会得到信息更丰富的表示:

# type 'a bounds = { lower: 'a; upper: 'a } [@@deriving sexp];;
type 'a bounds = { lower : 'a; upper : 'a; }
val bounds_of_sexp : (Sexp.t -> 'a) -> Sexp.t -> 'a bounds = <fun>
val sexp_of_bounds : ('a -> Sexp.t) -> 'a bounds -> Sexp.t = <fun>
# exception Crossed_bounds of int bounds [@@deriving sexp];;
exception Crossed_bounds of int bounds
# Crossed_bounds { lower=10; upper=0 };;
- : exn = (//toplevel//.Crossed_bounds ((lower 10) (upper 0)))

Crossed_bounds 前面的句点之所以存在,是因为 [@@deriving sexp] 生成的表示会包含定义相关异常的完整模块路径。在这个例子中,字符串 //toplevel// 用来表明这是在 utop 提示符下声明的,而不是在某个模块里声明的。

这些都属于 Sexplib 库和语法扩展提供的 S 表达式支持;第 21 章“使用 S 表达式进行数据序列化(Data Serialization with S-Expressions)”会更详细介绍它。

8.2.2 用于抛出异常的辅助函数(Helper Functions for Throwing Exceptions)

Base 提供了若干辅助函数,用来简化抛出异常这件事。最简单的一个是 failwith,它可以按如下方式定义:

# let failwith msg = raise (Failure msg);;
val failwith : string -> 'a = <fun>

Base 的 CommonExn 模块的 API 文档中,还可以找到其他几个有用的异常抛出函数。

另一种重要的异常抛出方式是 assert 指令。assert 用于这样一些情形:如果相关条件被违反,就说明程序中存在 bug。考虑下面这段把两个列表逐项合并的代码:

# let merge_lists xs ys ~f =
if List.length xs <> List.length ys then None
else
let rec loop xs ys =
match xs,ys with
| [],[] -> []
| x::xs, y::ys -> f x y :: loop xs ys
| _ -> assert false
in
Some (loop xs ys);;
val merge_lists : 'a list -> 'b list -> f:('a -> 'b -> 'c) -> 'c list option =
<fun>
# merge_lists [1;2;3] [-1;1;2] ~f:(+);;
- : int list option = Some [0; 3; 5]
# merge_lists [1;2;3] [-1;1] ~f:(+);;
- : int list option = None

这里我们使用 assert false,意思是只要执行到这个 assert,它就一定会触发。一般来说,可以在断言中放入任意条件。

在这个例子中,assert 永远不会被触发,因为在调用 loop 之前,我们已经检查并确保两个列表长度相同。如果修改代码,去掉这个检查,就可以触发 assert

# let merge_lists xs ys ~f =
let rec loop xs ys =
match xs,ys with
| [],[] -> []
| x::xs, y::ys -> f x y :: loop xs ys
| _ -> assert false
in
loop xs ys;;
val merge_lists : 'a list -> 'b list -> f:('a -> 'b -> 'c) -> 'c list = <fun>
# merge_lists [1;2;3] [-1] ~f:(+);;
Exception: "Assert_failure //toplevel//:6:14".

这说明了 assert 的特殊之处:它会捕获触发断言的源位置中的行号和字符偏移。

8.2.3 异常处理器(Exception Handlers)

到目前为止,我们只见过异常完全终止某个计算的执行。但有时,我们希望程序能够响应异常并从中恢复。这可以通过使用异常处理器来实现。

在 OCaml 中,异常处理器用 try/with 表达式声明。基本语法如下。

try <expr> with
| <pat1> -> <expr1>
| <pat2> -> <expr2>
...

try/with 子句首先计算它的主体,也就是 expr。如果没有异常被抛出,那么主体的计算结果就是整个 try/with 子句的结果。

但如果主体求值过程中抛出了异常,那么该异常会被交给 with 后面的模式匹配子句。如果异常匹配某个模式,就认为该异常已被捕获,try/with 子句会求值为匹配模式右侧的表达式。

否则,原始异常会继续沿函数调用栈向上传播,由外层的下一个异常处理器处理。如果异常始终没有被捕获,它就会终止程序。

8.2.4 在异常存在时清理资源(Cleaning Up in the Presence of Exceptions)

异常的一个麻烦之处在于,它们可能在意想不到的位置终止执行,让程序处于尴尬状态。考虑下面这个函数,它用于加载一个包含数值数据的文件。这段代码解析一种简单的逗号分隔文件格式,其中每个字段都是浮点数。在这个例子中,我们打开 Stdio,以便访问从文件读取的例程。

# open Stdio;;
# let parse_line line =
String.split_on_chars ~on:[','] line
|> List.map ~f:Float.of_string;;
val parse_line : string -> float list = <fun>
# let load filename =
let inc = In_channel.create filename in
let data =
In_channel.input_lines inc
|> List.map ~f:parse_line
in
In_channel.close inc;
data;;
val load : string -> float list list = <fun>

这段代码的一个问题是,如果相关文件格式不正确,解析函数可能抛出异常。不幸的是,这意味着已经打开的 In_channel.t 永远不会被关闭,从而导致文件描述符泄漏。

我们可以用 Base 的 Exn.protect 函数修复这个问题。它接收两个参数:一个 thunk f,即要运行的计算主体;另一个 thunk finally,无论 f 是正常退出还是因异常退出,它都会被调用。这类似许多编程语言中的 try/finally 构造,但它是通过库实现的,而不是内置原语。下面展示如何用它修复 load 函数:

# let load filename =
let inc = In_channel.create filename in
Exn.protect
~f:(fun () -> In_channel.input_lines inc |> List.map ~f:parse_line)
~finally:(fun () -> In_channel.close inc);;
val load : string -> float list list = <fun>

这个问题很常见,所以 In_channel 提供了一个名为 with_file 的函数,自动化这种模式:

# let load filename =
In_channel.with_file filename ~f:(fun inc ->
In_channel.input_lines inc |> List.map ~f:parse_line);;
val load : string -> float list list = <fun>

In_channel.with_file 建立在 protect 之上,因此即便存在异常,它也能完成自身清理。

8.2.5 捕获特定异常(Catching Specific Exceptions)

OCaml 的异常处理系统允许你把错误恢复逻辑调整到具体抛出的错误上。例如,本章前面定义的 find_exn 会在找不到相关元素时抛出 Key_not_found。我们来看一个可以利用这一点的例子。具体来说,考虑下面的函数:

# let lookup_weight ~compute_weight alist key =
try
let data = find_exn alist key in
compute_weight data
with
Key_not_found _ -> 0.;;
val lookup_weight :
compute_weight:('a -> float) -> (string * 'a) list -> string -> float =
<fun>

从类型可以看出,lookup_weight 接收一个关联列表、一个用于在列表中查找对应值的键,以及一个根据查到的值计算浮点权重的函数。如果没有找到值,就应返回权重 0.

不过,这段代码使用异常会带来一些问题。具体来说,如果 compute_weight 抛出异常会怎样?理想情况下,lookup_weight 应该把该异常继续传播出去;但如果这个异常恰好是 Key_not_found,结果就不是这样了:

# lookup_weight ~compute_weight:(fun _ -> raise (Key_not_found "foo"))
["a",3; "b",4] "a";;
- : float = 0.

这类问题很难提前发现,因为类型系统不会告诉你某个函数可能抛出哪些异常。因此,通常最好避免依赖异常的身份来判断失败的性质。更好的做法是缩小异常处理器的作用范围,让它触发时可以非常明确地知道究竟是哪一段代码失败了:

# let lookup_weight ~compute_weight alist key =
match
try Some (find_exn alist key)
with _ -> None
with
| None -> 0.
| Some data -> compute_weight data;;
val lookup_weight :
compute_weight:('a -> float) -> (string * 'a) list -> string -> float =
<fun>

这种在 match 表达式中嵌套 try 的写法既别扭,也涉及一些不必要的计算,特别是 option 的分配。幸好,OCaml 允许 match 表达式直接捕获异常,从而可以把它写得更简洁:

# let lookup_weight ~compute_weight alist key =
match find_exn alist key with
| exception _ -> 0.
| data -> compute_weight data;;
val lookup_weight :
compute_weight:('a -> float) -> (string * 'a) list -> string -> float =
<fun>

注意,exception 关键字用于标记异常处理分支。

最好的是完全避免异常。这里可以改用 Base 中不抛异常的函数 List.Assoc.find

# let lookup_weight ~compute_weight alist key =
match List.Assoc.find ~equal:String.equal alist key with
| None -> 0.
| Some data -> compute_weight data;;
val lookup_weight :
compute_weight:('a -> float) ->
(string, 'a) Base.List.Assoc.t -> string -> float = <fun>

8.2.6 栈回溯(Backtraces)

异常的一大价值在于,它们能以栈回溯的形式提供有用的调试信息。考虑下面这个简单程序:

open Base
open Stdio
exception Empty_list

let list_max = function
| [] -> raise Empty_list
| hd :: tl -> List.fold tl ~init:hd ~f:(Int.max)

let () =
printf "%d\n" (list_max [1;2;3]);
printf "%d\n" (list_max [])

如果构建并运行这个程序,会得到一段栈回溯,提供一些关于错误发生位置的信息,以及错误发生时存在的函数调用栈:

dune exec -- ./blow_up.exe
3
Fatal error: exception Dune__exe__Blow_up.Empty_list
Raised at Dune__exe__Blow_up.list_max in file "blow_up.ml", line 6, characters 10-26
Called from Dune__exe__Blow_up in file "blow_up.ml", line 11, characters 16-29
[2]

你也可以在程序内部调用 Backtrace.Exn.most_recent 来捕获回溯;它会返回最近一次抛出异常的回溯。这对于报告那些没有导致程序失败的错误的详细信息很有用。

如果你启用了回溯,这会工作得很好,但并非总是如此。事实上,默认情况下 OCaml 会关闭回溯;即使你在运行时打开了回溯,如果编译时没有带调试符号,也无法得到回溯。Base 会反转这个默认行为,所以如果链接了 Base,就会默认启用回溯。

即便使用 Base 并带调试符号编译,你也可以通过 OCAMLRUNPARAM 环境变量关闭回溯,如下所示。

OCAMLRUNPARAM=b=0 dune exec -- ./blow_up.exe
3
Fatal error: exception Dune__exe__Blow_up.Empty_list
[2]

得到的错误消息信息量会少很多。你也可以在代码中调用 Backtrace.Exn.set_recording false 来关闭回溯。

无回溯运行有一个正当理由:速度。OCaml 的异常已经相当快,但如果禁用回溯,会更快。下面是一个简单基准,展示这种影响;它使用 core_bench 包:

open Core
open Core_bench

exception Exit

let x = 0

type how_to_end = Ordinary | Raise | Raise_no_backtrace

let computation how_to_end =
let x = 10 in
let y = 40 in
let _z = x + (y * y) in
match how_to_end with
| Ordinary -> ()
| Raise -> raise Exit
| Raise_no_backtrace -> raise_notrace Exit

let computation_with_handler how = try computation how with Exit -> ()

let () =
[
Bench.Test.create ~name:"simple computation" (fun () ->
computation Ordinary);
Bench.Test.create ~name:"computation w/handler" (fun () ->
computation_with_handler Ordinary);
Bench.Test.create ~name:"end with exn" (fun () ->
computation_with_handler Raise);
Bench.Test.create ~name:"end with exn notrace" (fun () ->
computation_with_handler Raise_no_backtrace);
]
|> Bench.make_command
|> Command_unix.run

这里测试了四种情况:

  • 没有异常的简单计算;
  • 同一个计算,但设置了异常处理器,并且没有异常被抛出;
  • 同一个计算,但抛出了异常;
  • 最后一种,同一个计算,但使用 raise_notrace 抛出异常;它是 raise 的一个版本,会在局部避免跟踪回溯的成本。

下面是结果。

dune exec --
./exn_cost.exe -ascii -quota 1 -clear-columns time cycles
Estimated testing time 4s (4 benchmarks x 1s). Change using '-quota'.

Name Time/Run Cycls/Run
----------------------- ---------- -----------
simple computation 1.84ns 3.66c
computation w/handler 3.13ns 6.23c
end with exn 27.96ns 55.69c
end with exn notrace 11.69ns 23.28c

注意,设置异常处理器只会损失很少几个周期,这意味着未使用的异常处理器确实相当便宜。真正抛出异常会损失更大一块,大约 55 个周期。如果我们显式以无回溯方式抛出异常,成本大约是 25 个周期。

如前所述,我们也可以用 OCAMLRUNPARAM 禁用回溯。这会让结果略有变化。

OCAMLRUNPARAM=b=0 dune exec --
./exn_cost.exe -ascii -quota 1 -clear-columns time cycles
Estimated testing time 4s (4 benchmarks x 1s). Change using '-quota'.

Name Time/Run Cycls/Run
----------------------- ---------- -----------
simple computation 1.71ns 3.41c
computation w/handler 3.04ns 6.05c
end with exn 19.36ns 38.57c
end with exn notrace 11.48ns 22.86c

这里唯一显著的变化是,普通方式抛出异常稍微便宜了一些:从 55 个周期变成约 20 个周期。但它仍然不如显式使用 raise_notrace 快。

只有当你经常把异常作为控制流的一部分使用时,这种尺度上的差异才重要。这不是常见模式;而当你确实需要这么做时,从性能角度看,最好使用 raise_notrace。所有这些意味着:几乎总是应该保持栈回溯开启。

8.2.7 从异常到带错误信息的类型,再反过来(From Exceptions to Error-Aware Types and Back Again)

异常和带错误信息的类型都是 OCaml 编程不可或缺的一部分。因此,你经常需要在这两个世界之间来回转换。好在 Base 提供了一些有用的辅助函数,帮助你做到这一点。例如,给定一段可能抛出异常的代码,可以像下面这样把异常捕获到 option 中:

# let find alist key =
Option.try_with (fun () -> find_exn alist key);;
val find : (string * 'a) list -> string -> 'a option = <fun>
# find ["a",1; "b",2] "c";;
- : int option = Base.Option.None
# find ["a",1; "b",2] "b";;
- : int option = Base.Option.Some 2

ResultOr_error 也有类似的 try_with 函数。所以我们可以写:

# let find alist key =
Or_error.try_with (fun () -> find_exn alist key);;
val find : (string * 'a) list -> string -> 'a Or_error.t = <fun>
# find ["a",1; "b",2] "c";;
- : int Or_error.t = Base__.Result.Error ("Key_not_found(\"c\")")

然后,我们可以重新抛出那个异常:

# Or_error.ok_exn (find ["a",1; "b",2] "b");;
- : int = 2
# Or_error.ok_exn (find ["a",1; "b",2] "c");;
Exception: Key_not_found("c").

8.3 选择错误处理策略(Choosing an Error-Handling Strategy)

既然 OCaml 同时支持异常和带错误信息的返回类型,该如何在二者之间选择?关键是思考简洁性和显式性之间的权衡。

异常更简洁,因为它们允许你把错误处理工作推迟到更大的作用域中完成,也因为它们不会污染类型。但这种简洁是有代价的:异常太容易被忽视。另一方面,带错误信息的返回类型会完整体现在类型定义中,让代码可能产生的错误变得显式,而且不可能被忽略。

正确的权衡取决于你的应用。如果你正在编写一个粗略但实用的程序,关键是尽快完成,而且失败代价不高,那么大量使用异常可能就是合适的选择。另一方面,如果你正在编写失败代价高昂的生产软件,那么大概应该倾向于使用带错误信息的返回类型。

需要说明的是,完全避免异常并不合理。“把异常用于异常情况”这条格言仍然适用。如果某个错误足够罕见,那么抛出异常通常就是正确行为。

另外,对于无处不在的错误,带错误信息的返回类型可能也有些过度。一个很好的例子是内存不足错误;它可能发生在任何位置,所以如果要捕获它,就需要在所有地方都使用带错误信息的返回类型。让每一个操作都标记为可能失败,并不比什么都不标记更显式。

简而言之,对于那些在生产代码执行过程中可预见、普通、但又并非无处不在的错误,带错误信息的返回类型通常是正确方案。