錯誤處理

在 OCaml 中,錯誤可以用幾種方式處理。本文件介紹了大多數可用的方法。然而,使用 OCaml 5 中引入的 effect handlers 來處理錯誤尚未在本文件中提及。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!".

在這裡,我們向類型 exn 添加一個變體 Foo,並創建一個將引發此例外的函式。現在,我們如何處理例外?建構是 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 *)

當實作一個公開引發例外的函式的軟體元件時,必須做出設計決策

  • 使用現有的例外
  • 引發自訂例外

兩者都有道理,沒有通用的規則。如果使用標準函式庫的例外,則必須在預期的條件下引發它們,否則處理常式將難以處理它們。使用自訂例外將迫使客戶端程式碼包含專用的 catch 條件。對於必須在客戶端層級處理的錯誤,這可能是理想的。

使用 Fun.protect

由於處理例外會中斷正常的控制流程,因此使用它們可能會使某些需要嚴格排序和耦合的流程的任務複雜化。對於這些情況,標準函式庫的 Fun 模組提供了

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

當某些事情總是需要在運算完成完成時,無論成功還是失敗,都應該使用此函式。首先呼叫未標記的函式引數,然後呼叫作為標記引數 finally 傳遞的函式。然後,protect 返回與未標記的引數相同的值,或引發該函式引發的相同例外。

請注意,傳遞給 protect 的函式僅將 () 作為參數。這不會限制可以使用 protect 的情況。任何運算 f x 都可以包裝在函式 fun () -> f x 中。與往常一樣,函數體只有在呼叫該函式後才會進行評估。

finally 函式僅預期執行某些副作用,不應自行引發任何例外。如果 finally 確實拋出例外 eprotect 將引發 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 的 A Guide to recover from interrupts

文件

可以引發例外的函式應按如下方式記錄

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

堆疊追蹤

若要在未處理的例外導致程式崩潰時取得堆疊追蹤,您需要在偵錯模式下編譯程式(呼叫 ocamlc 時使用 -g,或呼叫 ocamlbuild 時使用 -tag 'debug')。然後

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)

語言錯誤

當崩潰不是來自

  • 原生程式碼編譯器的限制
  • 本質上不安全的函式,例如在 MarshalObj 模組中找到的那些

它可能是一個語言錯誤。這是會發生的。以下是當懷疑發生這種情況時該怎麼做

  1. 確保崩潰影響到兩個編譯器:位元組碼和原生程式碼
  2. 編寫一個自我包含且最小的概念驗證程式碼,該程式碼除了觸發崩潰之外不做任何事情
  3. GitHub 上的 OCaml Bug Tracker 中提交問題

這是一個此類錯誤的範例:https://github.com/ocaml/ocaml/issues/7241

安全 vs. 不安全的函式

未捕獲的例外會導致執行階段崩潰。因此,有一種趨勢是使用以下術語

  • 引發例外的函式:不安全
  • 處理資料中錯誤的函式:安全

編寫此類安全錯誤處理函式的主要方法是使用 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_argumentvalue 返回內容,如果應用於 None,則返回其 default 引數。fold 返回其 some 引數應用於選項的內容,如果應用於 None,則返回其 none 引數。

作為註解,請注意 value 可以使用 fold 來實作

# 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.mapOption.bind

讓我們從一個範例開始:想像一下,需要編寫一個函式,該函式返回以字串形式提供的電子郵件地址的 主機名稱 部分。例如,給定字串 "gaston.lagaffe@courrier.dupuis.be",它將返回字串 "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.mapOption.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

相對於 mapbind 反轉參數順序,使其可用作 繫結運算子,這是 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_optlengthsub) 的呼叫是相同的,並且順序相同
    • 相同的區域名稱以相同的型別使用
  • 沒有任何剩餘的縮排或模式比對
  • 第 1 行
    • = 的右側:如果 String.index_opt 沒有失敗,Option.map 允許將 1 加到其結果中
    • = 的左側:let* 語法將所有其餘程式碼 (從第 2 行到末尾) 轉換為採用 fqdn_pos 作為參數的匿名函式的主體,並且使用 fqdn_pos 和該匿名函式呼叫函式 ( let* )
  • 第 2 和 3 行:與原始程式碼中相同
  • 第 4 行:trymatch 已移除
  • 第 5 行:如果上一步沒有失敗,則應用 String.sub,否則會轉發錯誤
  • 第 6 行:如果之前沒有找到任何東西,並且如果不是空的,則將 fqdn 作為後備返回

當使用 catch 陳述式處理錯誤時,需要一些時間才能習慣後一種風格。關鍵的想法是避免或延遲直接查看選項值。相反,使用臨時管道 (例如 mapbind) 傳遞它們。Erik Meijer 將這種風格稱為「遵循快樂路徑」。在視覺上,它也類似於 C 中常見的「提前返回」模式。

選項型別的限制之一是它不會記錄阻止返回值的理由。None 是靜默的,它沒有說明發生了什麼錯誤。因此,返回選項值的函式應記錄它們可能返回 None 的情況。此類文件可能類似於使用 @raise 的例外所需的文件。下一節中描述的 result 型別旨在填補這一空白:像選項值一樣管理資料中的錯誤,並像例外一樣提供錯誤資訊。

使用 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 時,這兩個函數的行為都像是恆等函數。這是唯一可能的情況,因為這些參數不攜帶任何資料 - 不像具有 Error 建構子的 result。儘管如此,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 執行從 optionresult 的轉換。
  • 最後,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,在這種情況下,結果類型只是明確地表示例外狀況
  • string,其中錯誤情況是一個訊息,指出失敗的原因
  • string Lazy.t,一種更詳細的錯誤訊息形式,僅在需要列印時才會求值
  • 一些多態變體,每個可能的錯誤對應一個情況。這非常準確(每個錯誤都可以明確處理並出現在類型中),但多態變體的使用有時會使程式碼更難閱讀。

請注意,有些人說 resultEither.t 類型是 同構 的。具體來說,這意味著總是可以用另一個替換一個,就像在完全中性的重構中一樣。類型為 resultEither.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 程式設計指南

錯誤之間的轉換

optionresult 拋出例外狀況

這是通過使用以下函數完成的

  • optionInvalid_argument 例外狀況,使用函數 Option.get

    val get : 'a option -> 'a
    
  • resultInvalid_argument 例外狀況,使用函數 Result.get_okResult.get_error

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

要引發其他例外狀況,必須使用模式匹配和 raise

optionresult 之間的轉換

這是通過使用以下函數完成的

  • optionresult,使用函數 Option.to_result
    val to_result : none:'e -> 'a option -> ('a, 'e) result
    
  • resultoption,使用函數 Result.to_option
    val to_option : ('a, 'e) result -> 'a option
    

將例外狀況轉換為 optionresult

標準函式庫不提供此類函數。這必須使用 try ... withmatch ... 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 只會停止您的程式。此慣用語有時用於指示死程式碼,即必須編寫(通常用於類型檢查或模式匹配完整性)但在執行時無法到達的程式碼部分。

斷言應理解為可執行的註解。除非在除錯期間或真正阻止執行取得任何進展的特殊情況下,否則它們不應該失敗。

當執行達到無法處理的條件時,正確的做法是通過調用 failwith "error message" 拋出 Failure。斷言並非旨在處理這些情況。例如,在以下程式碼中

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 文件都是開源的。發現錯誤或不清楚的地方?提交一個 pull request。

OCaml

創新。社群。安全。