第七章 错误处理

优质
小牛编辑
131浏览
2023-12-01

没有人愿意处理错误。处理错误很乏味,还容易出出错,并且也没有计划程序如何正确运行有乐趣。但是,错误处理非常重要,无论你多么不喜欢,软件因为薄弱的错误处理而失败要更糟糕。

庆幸的是,OCaml提供了强大的工具来可靠地处理错误,且把痛处降至最低。本章我们会讨论OCaml中的几种处理错误的方法,并且给出了一些如何设计接口以简化错误处理的建议。

开始,我们先介绍OCaml中报告错误的两种基本方法:带错误的返回值和异常。

Error-Aware return types

Error-Aware返回值类型

OCaml中抛出一个错误最好的方法就是把这个错误包含到返回值中。考虑List模块中find函数的类型:

# 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

在函数返回值中包含错误信息就要求调用者显式处理它,允许调用者来决定是从这个错误中恢复还是将其向前传播。

下面看一下computer_bounds函数。该函数接收一个列表和一个比较函数,通过查找最大和最小的列表元素返回列表的上下边界。提取列表中最大和最小元素用的是List.hdList.last,它们在遇到空列表时会返回None

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

match语句用以处理错误分支,将hdlast返回的None传递给computer_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, 'b) Hashtbl.t -> ('a, 'b) Hashtbl.t -> 'a list =
<fun>

使用option编码错误凸显了这样一个事实:一个特定的结果,如在列表中没有找到某元素,是一个错误还是一个合理的结果,这一点并不明确。这依赖于你程序中更大的上下文,是一个通用库无法预知的。error-aware返回值类型的优势就在于两种情况它都适用。

编码错误和结果

option在报告错误方面不总是有足够的表达力。特别是当你把一个错误编码成None时,就没有地方来说明错误的性质了。

Result.t就是为了解决此不足的。类型定义如下:

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

Result.t本质上是一个参数化的option,给错误实例赋于存储其它信息的能力。像SomeNone一样,构造器OkErrorCore.Std提升到顶层作用域。因此,我们可以这样写:

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

而不需要先打开Result模块。

ErrorOr_error

Result.t给了你完全的自由来选择错误值的类型,但通常规范错误类型是有用的。别的不说,它会简化自动化通用错误处理模式的工具函数。

但选什么类型呢?把错误表示为字符串会更好呢?还是像XML这样更加结构化的表达?或者其它的什么?

Core的答案是Error.t类型,它试图在效率、方便性和对错误表达的控制力之间取得一个很好的平衡。

效率问题一开始并不明显。但生成错误消息是很昂贵的。一个值的ASCII表达可能相当耗时,特别是它包含不易转换的数值数据时。

Error使用惰性求值避开了这个问题,Error.t允许你推迟错误生成,除非你需要它,这就意味着很多时候你根本不需要去构造它。当然你也可以直接从一个字符串构造一个错误:

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

但你也可以从一个代码块(thunk)构造Error.t,即,一个接收单独一个unit类型参数的函数:

# Error.of_thunk (fun () ->
    sprintf "something went wrong: %f" 32.3343);;
- : Error.t = something went wrong: 32.334300

这时,我们就可以从Error的惰性求值获益,因为除非Error.t被转换为字符串,否则代码块不会被调用。

创建Error.t最常用的方式是使用S-表达式。S-表达式是一个由小括号包围的表达式,表达式的叶子是字符串。这里有一个简单例子:

(This (is an) (s expression))

Core中的S-表达式由Sexplib包支持,该包随Core发布,是Core最常用的序列化格式。实际上,Core中的多数类型都自带了内建的S-表达式转换器。下例中,使用时间的sexp转换器(Time.sexp_of_t)创建错误:

# Error.create "Something failed a long time ago" Time.epoch Time.sexp_of_t;;
- : Error.t =
Something failed a long time ago: (1970-01-01 01:00:00.000000+01:00)

注意,错误在被打印之前是不会真正序列化成S-表达式的。

这种错误报告并不只局限于内建类型。这会在第17章讨论,但是Sexplib带了一个语言扩展,可以为新创建的类型自动生成sexp转换器:

# let custom_to_sexp = <:sexp_of<float * string list * int>>;;
val custom_to_sexp : float * string list * int -> Sexp.t = <fun>
# custom_to_sexp (3.5, ["a";"b";"c"], 6034);;
- : Sexp.t = (3.5 (a b c) 6034)

我们可以使用相同的惯用法创建错误:

# Error.create "Something went terribly wrong"
    (3.5, ["a";"b";"c"], 6034)
<:sexp_of<float * string list * int>> ;;
- : Error.t = Something went terribly wrong: (3.5(a b c)6034)

Error也支持错误转换操作。如,给错误一个带有上下文信息的参数或把多个错误组合在一起通常是很有用的。Error.tagError.list即可用于此:

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

'a Or_error.t只是('a,Error.t) Result.t的简写,它是Coreoption之外最常用的返回错误方式。

bind和其它错误处理惯用法

随着用OCaml编写越来越多的错误处理代码,你会发现有几个特定的模式凸显出来。其中的一些通用模式已经被编进了OptionResult模块中的函数里。一个特别有用的模式是围绕bind函数构建的,bind既是普通函数又是中缀操作符>>=。下面是optionbind定义:

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

如你所见,bind None f返回None而不调用f,而bind (Some x) f会返回f x。使用bind可以把生成错误的函数串连起来,这样第一个产生错误的函数就会终止计算。下面是使用嵌套的bind序列重写的compute_bounds

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

在语法层面上,上面的代码有点晦涩。我们可以通过使用bind的中缀操作符形式来去掉括号以使代码更易读,中缀操作符通过局部打开Option.Monad_infix访问。模块名叫Monad_infix是因为bind操作符是Monad子接口的一部分,我们会在第18章再次讨论:

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

这里使用bind本质上确实不比开始时那个版本好,实际上,像这样的小例子,直接使用option通常比使用bind好。但是对于巨大的、复杂的、有许多层错误处理的例子,bind惯用法更清晰也更容易管理。

Option的函数中还有其它有用的惯用法。Option.both是其中之一,它接收两个option值,生成一个新的option序对,如果参数中有一个是None,那么就返回None。使用Option.both可以使compute_bounds更简短:

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

这些错误处理函数的价值在于它们可以使你的显式并且简洁地表达错误处理。我们只讨论了Option模块上下文中的函数,但是在ResultOr_error模块中有更多这种功能的函数。

异常

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 -> printf "%d\n%!" x; 100 / x) [1;3;0;4];;
1
3
0
Exception: Division_by_zero.

除了像Divide_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 = [ Not_found; Division_by_zero; Key_not_found "b" ];;
val exceptions : exn list = [Not_found; Division_by_zero; Key_not_found("b")]
# List.filter exceptions  ~f:(function
    | Key_not_found _ | Not_found -> true
    | _ -> false);;
- : exn list = [Not_found; Key_not_found("b")]

异常都是同一个类型的,exnexn类型是OCaml类型系统中的特例。它和我们第六章遇到的变体类似,但前者是开放的,就是说任何地方都没有其完整定义。新的标签(即新的异常)可以在程序的任何地方添加进来。这和普通变体不同,后者把可用标签定义在一个封闭域里。一个结果就是你永远都不会完整匹配exn类型,因为可能的异常全集是未知的。

下面的函数使用我们之前定义的Key_not_found异常发射一个错误:

# let rec find_exn alist key = match alist with
    | [] -> raise (Key_not_found key)
    | (key',data) :: tl -> if 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是为了提醒使用者这个函数会抛出异常,这个惯用法在Core中使用很广。

上面的例子中,raise抛出异常,因此也终止了计算。raise类型乍一看很令人吃惊:

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

返回类型'a,看起来像是raise生成一个类型完全不受约束的返回值。这看起来是不可能的,也确实是不可能的。实际上,raise返回类型'a是因为它从不返回。这种行为不限于像raise这样通过抛异常终止的函数。下面例子是另一个不返回的函数:

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

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

这很重要,因为这意味着raise的返回类型可以是任何适应调用上下文的类型。因此,类型系统才可能允许我们在程序任何地方抛出异常。

使用sexp声明异常

OCaml并不总能为一个异常生成有用的文本表达。如:

# exception Wrong_date of Date.t;;
exception Wrong_date of Date.t
# Wrong_date (Date.of_string "2011-02-23");;
- : exn = Wrong_date(_)

但是如果我们使用sexp(以及有sexp转换器的类型)来声明异常,就可以协带更多的信息:

# exception Wrong_date of Date.t with sexp;;
exception Wrong_date of Date.t
# Wrong_date (Date.of_string "2011-02-23");;
- : exn = (//toplevel//.Wrong_date 2011-02-23)

Wong_date前面有句号是因为sexp生成的的表示包含定义异常的模块的全路径。本例中,字符串//toplevel//表示异常是在toplevel中而不是一个模块中定义的。

这些都是Sexplib库和语法扩展对S表达式支持的一部分,在17章会描述更多细节。

抛出异常的辅助函数

OCaml和Core都提供了一些辅助函数来简化抛出异常的操作。最简单的一个是failwith,可以像下面这样定义:

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

还有好几个其它有用的函数可以用于抛出异常,可以在Core的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// 5 13).

这里展示了assert的一个特性:它可以捕获断言在源代码中的行数和列数。

异常处理器

目前为止,我们见到的异常都是完全终止了计算的执行。但我们经常想要能响应异常并能从中恢复。这是通过异常处理器实现的。

在OCaml中,异常处理器由try/with语句声明。下面是基本语法.

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

try/with子句先求值其主体,expr。如果没有异常抛出,求值主体的结果就是整个try/with子句的值。

但如果求值主体是抛出了一个异常,这个异常会被喂给with后的模式匹配语句。如果它匹配了一个模式,我们就认为异常被捕获了,try/with子句就会求值模式后面的表达式。

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

清理异常现场

异常一个令人头疼的地方是它可能会在意想不到的地方终止你的程序,使你的程序处于危险状态。考虑下面函数,加载一个全是提醒的文件,格式化成S表达式:

# let reminders_of_sexp =
    <:of_sexp<(Time.t * string) list>>
  ;;
val reminders_of_sexp : Sexp.t -> (Time.t * string) list = <fun>
# let load_reminders filename =
    let inc = In_channel.create filename in
    let reminders = reminders_of_sexp (Sexp.input_sexp inc) in
    In_channel.close inc;
    reminders
  ;;
val load_reminders : string -> (Time.t * string) list = <fun>

这段代码的问题是这个函数加载S表达式并将其解析成一个Time.t/string序对列表,在文件格式畸形时可能会抛异常。不幸的是,这意味着打开的In_chnnel.t永远都不会关闭了,这会导致文件描述符泄漏。

我们可以使用Core中的protect函数修正此问题,它接收两个参数:一个代码块f,是要进行的计算的主体;一个是代码块finally,在f退出时调用,无论是正常退出还是异常退出。这和许多编程语言中的try/finally结构类似,但OCaml中是库实现的,而不是内建原语。下面就是如何使用它来修正load_reminders:

# let load_reminders filename =
    let inc = In_channel.create filename in
    protect ~f:(fun () -> reminders_of_sexp (Sexp.input_sexp inc))
      ~finally:(fun () -> In_channel.close inc)
  ;;
val load_reminders : string -> (Time.t * string) list = <fun>

这个问题很常见,In_channel中有一个叫with_file的函数可以自动完成这个模式:

# let reminders_of_sexp filename =
    In_channel.with_file filename ~f:(fun inc ->
      reminders_of_sexp (Sexp.input_sexp inc))
  ;;
val reminders_of_sexp : string -> (Time.t * string) list = <fun>

In_channel.with_file构建在protect之上,所以在发生异常会自己清理。

捕捉特定异常

OCaml的异常处理系统允许你针对特定的错误调整错误恢复逻辑。例如,List.find_exn在元素未找到时会抛出Not_found。让我们通过一个例子看一下如何利用这一点。考虑下面的函数:

# let lookup_weight ~compute_weight alist key =
    try
      let data = List.Assoc.find_exn alist key in
      compute_weight data
    with
      Not_found -> 0. ;;
val lookup_weight :
  compute_weight:('a -> float) -> ('b, 'a) List.Assoc.t -> 'b -> float =
<fun>

从类型可以看出,lookup接收一个关联列表、一个要查找其对应数据的键值和一个用以计算浮点权重的函数。如果没有找到,应该返回权重为0.

上面代码中对异常的使用有点问题。就是当compute_weight也抛出异常时会怎样呢?理想情况下,lookup_weight应该向上传播这个异常,但如果这个异常恰好是Not_found,情况就不是这样了:

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

这类问题很难预先检查,因为类型系统不会告诉你一个函数可能会抛出什么异常。因此,通常更好的办法是避免依赖异常标识来确实错误内容。一个更好的方法是窄化异常处理的作用域,这样当其被触发时,哪部分代码出错就非常清晰了:

# let lookup_weight ~compute_weight alist key =
    match
      try Some (List.Assoc.find_exn alist key)
      with _ -> None
    with
    | None -> 0.
    | Some data -> compute_weight data ;;
val lookup_weight :
  compute_weight:('a -> float) -> ('b, 'a) List.Assoc.t -> 'b -> float =
  <fun>

不过在这个问题上,使用不抛异常的函数List.Assoc.find更合适:

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

回溯

异常的很大一部分价值是它们以栈回溯的形式提供了有用的调试信息。看下面这个简单程序:

open Core.Std
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 [])

如果我们编译并运行此程序,会得到一个栈回溯,提供发生错误的位置及错误发生时的函数调用栈等信息:

$ corebuild blow_up.byte
$ ./blow_up.byte
3
Fatal error: exception Blow_up.Empty_list
Raised at file "blow_up.ml", line 5, characters 16-26
Called from file "blow_up.ml", line 10, characters 17-28

你也可以通过Exn.backtrace在程序内部捕获一个回溯,它返回最近抛出的异常的回溯。这在报告详细错误信息又不终止程序时很有用。

如果你打开了回溯功能,这可以很好地工作,但事情不总是如此。实际上,OCaml默认是关闭回溯的,即使你在运行时打开了,也不能获得回溯信息,除非编译时带了调试符号。Core默认相反,所以如果你链接了Core,默认就已经打开了回溯。

即使是使用了Core并且带调试符号编译,你也可以通过将OCAMLRUNPARAM环境变量设为空来关闭回溯:

$ corebuild blow_up.byte
$ OCAMLRUNPARAM= ./blow_up.byte
3
Fatal error: exception Blow_up.Empty_list

这样返回的错误消息信息就少多了。你也可以在代码中调用Backtrace.Exn.set_recording false来关闭回溯。

不使用回溯的理由是:速度。OCaml的异常已经很快了,但是禁掉回溯会更快。下面的简单基准测试展示了这种效果,使用了core_bench包:

open Core.Std
open Core_bench.Std

let simple_computation () =
  List.range 0 10
  |> List.fold ~init:0 ~f:(fun sum x -> sum + x * x)
  |> ignore

let simple_with_handler () =
  try simple_computation () with Exit -> ()

let end_with_exn () =
  try
    simple_computation ();
    raise Exit
  with Exit -> ()

let () =
  [ Bench.Test.create ~name:"simple computation"
      (fun () -> simple_computation ());
    Bench.Test.create ~name:"simple computation w/handler"
      (fun () -> simple_with_handler ());
    Bench.Test.create ~name:"end with exn"
      (fun () -> end_with_exn ());
  ]
  |> Bench.make_command
  |> Command.run

这里我们测试了三种情况:没有异常的简单计算;同样的计算,使用了异常处理器但没有抛出异常;最后也是同样计算,使用了异常来完成返回调用者的控制流。

打开栈回溯运行,基准测试结果如下:

$ corebuild -pkg core_bench exn_cost.native
$ ./exn_cost.native -ascii cycles
Estimated testing time 30s (change using -quota SECS).

 Name                           Cycles   Time (ns)   % of max 
------------------------------ -------- ----------- ---------- 
 simple computation                279         117      76.40 
 simple computation w/handler      308         129      84.36 
 end with exn                      366         153      100.00

可以看到添加异常处理器大约损失了30个循环,真正捕获并处理了异常则损失了60多。如果关闭回溯,结果如下:

$ OCAMLRUNPARAM= ./exn_cost.native -ascii cycles
Estimated testing time 30s (change using -quota SECS).

 Name                           Cycles   Time (ns)   % of max 
------------------------------ -------- ----------- ---------- 
 simple computation                279         116      83.50 
simple computation w/handler       308         128      92.09 
end with exn                       334         140      100.00

处理器开销不变,但是异常本身开销只有25个额外循环,而不是60。总之,只有在你把异常作为日常控制流使用时这才有意义,但大多数情况下这都是一种错误的风格。

从异常再一次回到error-aware类型

异常和error-aware类型都是OCaml编程必须的。因此,你经常需要在两者间跳转。幸运的是,OCaml自带了一些有用的辅助函数来帮助你。如,有一段会抛异常的代码,就可以像下面这样把异常捕获进一个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 = None
# find ["a",1; "b",2] "b";;
- : int 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 = Core_kernel.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\")").

选择错误处理策略

既然OCaml既支持异常又支持error-aware返回类型,我们该如何选择呢?关键是要权衡简洁性和明确性。

异常更简洁,因为它们允许你将错误处理推迟到一个更大的作用域去执行,它们还不会干扰你的类型。但是这种简洁是有代价的:异常太容易被忽略了。另一方面,error-aware返回值类型,清楚地出现在你的类型定义中,使代码中可能产生的错误更明显且难以忽略。

正确的权衡依赖于你的程序。如果你在写一个粗糙的程序,目标是快速实现,出错也不是那昂贵,那么大量使用异常可能是个办法。但,如果你在写一个产品级软件,出错很昂贵,那么你可能应该倾向于使用error-aware返回值类型。

需要声明一下,没有心要完全避免异常。"只在异常条件下使用异常"的格言还是适用的。如果一个错误特别不易发生,那么抛一个异常通常是正确的行为。

同样,对于无处不在的错误,使用error-aware返回值类型可能就过分了。一个典型例子是内存耗尽错误,它可能发生在任何地方,所以你需要在任何位置都使用error-aware返回类型来捕获它们。把每个操作都标记成可能失败并不比不标记更明确。

一句话,对于可预知的、是你生产代码执行的正常部分且不是无处不在的错误,error-aware返回类型通常是正确的方案。