檢視原始碼 try、catch 和 rescue

Elixir 有三種錯誤機制:錯誤、拋出和退出。在本章中,我們將探討每一個機制,並說明每一個機制應該在何時使用。

錯誤

當程式碼中發生例外情況時,會使用錯誤(或例外)。可以透過嘗試將數字新增到原子來擷取範例錯誤

iex> :foo + 1
** (ArithmeticError) bad argument in arithmetic expression
     :erlang.+(:foo, 1)

隨時可以使用 raise/1 引發執行時期錯誤

iex> raise "oops"
** (RuntimeError) oops

可以使用 raise/2 引發其他錯誤,傳遞錯誤名稱和關鍵字引數清單

iex> raise ArgumentError, message: "invalid argument foo"
** (ArgumentError) invalid argument foo

您也可以透過建立模組和在其中使用 defexception/1 建構來定義自己的錯誤。這樣一來,您將建立一個與定義它的模組同名的錯誤。最常見的情況是使用訊息欄位定義自訂例外

iex> defmodule MyError do
iex>   defexception message: "default message"
iex> end
iex> raise MyError
** (MyError) default message
iex> raise MyError, message: "custom message"
** (MyError) custom message

可以使用 try/rescue 建構來救援錯誤

iex> try do
...>   raise "oops"
...> rescue
...>   e in RuntimeError -> e
...> end
%RuntimeError{message: "oops"}

上述範例救援執行時期錯誤並傳回例外本身,然後在 iex 會話中列印出來。

如果您不需要使用例外,則不必傳遞變數給 rescue

iex> try do
...>   raise "oops"
...> rescue
...>   RuntimeError -> "Error!"
...> end
"Error!"

實際上,Elixir 開發人員很少使用 try/rescue 建構。例如,許多語言會強制您在無法成功開啟檔案時救援錯誤。Elixir 則提供 File.read/1 函數,傳回包含檔案是否成功開啟的資訊的元組

iex> File.read("hello")
{:error, :enoent}
iex> File.write("hello", "world")
:ok
iex> File.read("hello")
{:ok, "world"}

這裡沒有 try/rescue。如果您想處理開啟檔案的各種結果,可以使用 case 建構進行模式比對

iex> case File.read("hello") do
...>   {:ok, body} -> IO.puts("Success: #{body}")
...>   {:error, reason} -> IO.puts("Error: #{reason}")
...> end

對於您預期檔案存在(而檔案不存在確實是錯誤)的情況,您可以使用 File.read!/1

iex> File.read!("unknown")
** (File.Error) could not read file "unknown": no such file or directory
    (elixir) lib/file.ex:272: File.read!/1

最後,由你的應用程式決定開啟檔案時出現錯誤是否為例外狀況。這也是 Elixir 不會在 File.read/1 和許多其他函式中強制使用例外的緣故。相反地,它讓開發人員自行選擇最佳的處理方式。

標準函式庫中的許多函式都遵循一個模式,即有一個相對應的函式會引發例外,而不是回傳元組供比對。慣例是建立一個函式 (foo),回傳 {:ok, result}{:error, reason} 元組,以及另一個函式 (foo!,名稱相同但後面加上 !),它會使用與 foo 相同的引數,但如果發生錯誤,它會引發例外。如果一切順利,foo! 應回傳結果 (未包裝在元組中)。File 模組就是這個慣例的一個好範例。

快速失敗 / 讓它崩潰

在 Erlang 社群和 Elixir 社群中,有一句常見的說法是「快速失敗」/「讓它崩潰」。讓它崩潰背後的想法是,如果發生意外狀況,最好讓例外發生,不要救援它。

強調意外這個字眼很重要。例如,假設你正在建立一個處理檔案的指令碼。你的指令碼接收檔案名稱作為輸入。預期使用者可能會出錯並提供未知的檔案名稱。在這種情況下,雖然你可以使用 File.read!/1 來讀取檔案,並在無效的檔案名稱情況下讓它崩潰,但使用 File.read/1 並向指令碼使用者提供明確且精確的錯誤回饋,可能更有意義。

其他時候,你可能會完全預期某個檔案存在,如果它不存在,表示其他地方發生了非常嚴重的錯誤。在這種情況下,File.read!/1 就是你需要的。

第二種方法也適用,因為正如在 程序 章節中所討論的,所有 Elixir 程式碼都在隔離的程序中執行,而且預設不會共用任何東西。因此,程序中未處理的例外絕不會崩潰或損毀另一個程序的狀態。這讓我們可以定義監督程序,其目的是觀察程序何時意外終止,並在它的位置啟動一個新的程序。

最後,「快速失敗」/「讓它崩潰」是一種說法,表示當意外狀況發生時,最好在一個新的程序中從頭開始,由監督程序重新啟動,而不是盲目地嘗試救援所有可能的錯誤狀況,而沒有完全了解它們何時以及如何發生。

重新引發

雖然我們通常避免在 Elixir 中使用 try/rescue,但我們可能想要使用此類建構的其中一個情況是為了可觀察性/監控。假設你想要記錄某件事出錯,你可以這樣做

try do
  ... some code ...
rescue
  e ->
    Logger.error(Exception.format(:error, e, __STACKTRACE__))
    reraise e, __STACKTRACE__
end

在上述範例中,我們救援了例外狀況,記錄它,然後重新引發它。我們在格式化例外狀況和重新引發時,都使用 __STACKTRACE__ 建構。這確保我們以原樣重新引發例外狀況,而不會變更值或其來源。

一般來說,我們在 Elixir 中會依字面意思處理錯誤:它們是保留給意外和/或例外狀況,從來不會用來控制我們程式碼的流程。如果你實際上需要流程控制建構,應該使用 throws。那就是我們接下來要看的地方。

Throws

在 Elixir 中,可以拋出一個值,然後再捕獲它。 throwcatch 是保留給那些無法使用 throwcatch 擷取值的情況。

在實務上,這些情況相當罕見,除非與未提供適當 API 的函式庫介面時。例如,讓我們想像 Enum 模組未提供任何 API 來尋找值,而且我們需要在數字清單中找出 13 的第一個倍數

iex> try do
...>   Enum.each(-50..50, fn x ->
...>     if rem(x, 13) == 0, do: throw(x)
...>   end)
...>   "Got nothing"
...> catch
...>   x -> "Got #{x}"
...> end
"Got -39"

由於 Enum 確實 提供了一個適當的 API,因此在實務上 Enum.find/2 是可行的方法

iex> Enum.find(-50..50, &(rem(&1, 13) == 0))
-39

Exits

所有 Elixir 程式碼都在彼此通訊的程序中執行。當一個程序因「自然原因」(例如,未處理的例外狀況)而終止時,它會傳送一個 exit 訊號。一個程序也可以透過明確傳送一個 exit 訊號而終止

iex> spawn_link(fn -> exit(1) end)
** (EXIT from #PID<0.56.0>) shell process exited with reason: 1

在上述範例中,連結的程序透過傳送一個值為 1 的 exit 訊號而終止。Elixir shell 會自動處理那些訊息,並將它們列印到終端機。

exit 也可以使用 try/catch 來「捕獲」

iex> try do
...>   exit("I am exiting")
...> catch
...>   :exit, _ -> "not really"
...> end
"not really"

使用 try/catch 已經不常見了,而使用它來捕獲 exits 更為罕見。

exit 訊號是 Erlang VM 所提供的容錯系統中一個重要的部分。程序通常在監督樹下執行,而監督樹本身就是一個程序,用來偵聽來自受監督程序的 exit 訊號。一旦接收到 exit 訊號,監督策略就會啟動,並重新啟動受監督的程序。

正是這個監督系統讓建構體像 try/catchtry/rescue 在 Elixir 中如此罕見。我們寧願「快速失敗」,也不願救援錯誤,因為監督樹會保證我們的應用程式在錯誤後回到已知的初始狀態。

之後

有時需要確保在某些可能會引發錯誤的動作後清理資源。try/after 建構體允許你這麼做。例如,我們可以開啟一個檔案並使用 after 子句關閉它,即使有些事情出錯

iex> {:ok, file} = File.open("sample", [:utf8, :write])
iex> try do
...>   IO.write(file, "olá")
...>   raise "oops, something went wrong"
...> after
...>   File.close(file)
...> end
** (RuntimeError) oops, something went wrong

無論嘗試區塊是否成功,after 子句都會執行。但是請注意,如果連結的程序退出,這個程序也會退出,after 子句將不會執行。因此 after 僅提供軟性保證。幸運的是,Elixir 中的檔案也連結到目前的程序,因此如果目前的程序崩潰,它們將始終關閉,與 after 子句無關。你會發現 ETS 表格、套接字、埠等其他資源也是如此。

有時你可能想將函式的整個主體包在 try 建構體中,通常是為了保證之後會執行某些程式碼。在這種情況下,Elixir 允許你省略 try

iex> defmodule RunAfter do
...>   def without_even_trying do
...>     raise "oops"
...>   after
...>     IO.puts "cleaning up!"
...>   end
...> end
iex> RunAfter.without_even_trying
cleaning up!
** (RuntimeError) oops

每當指定 afterrescuecatch 之一時,Elixir 會自動將函式主體包在 try 中。

否則

如果存在 else 區塊,它將與 try 區塊的結果相符,只要 try 區塊在沒有拋出或錯誤的情況下完成。

iex> x = 2
2
iex> try do
...>   1 / x
...> rescue
...>   ArithmeticError ->
...>     :infinity
...> else
...>   y when y < 1 and y > -1 ->
...>     :small
...>   _ ->
...>     :large
...> end
:small

else 區塊中的例外不會被捕獲。如果 else 區塊內沒有任何模式相符,將會引發例外;這個例外不會被目前的 try/catch/rescue/after 區塊捕獲。

變數範圍

casecondif 和 Elixir 中的其他建構體類似,在 try/catch/rescue/after 區塊內定義的變數不會洩漏到外部環境。換句話說,這段程式碼無效

iex> try do
...>   raise "fail"
...>   what_happened = :did_not_raise
...> rescue
...>   _ -> what_happened = :rescued
...> end
iex> what_happened
** (CompileError) undefined variable "what_happened"

相反地,你應該傳回 try 表達式的值

iex> what_happened =
...>   try do
...>     raise "fail"
...>     :did_not_raise
...>   rescue
...>     _ -> :rescued
...>   end
iex> what_happened
:rescued

此外,在 try 的 do 區塊中定義的變數在 rescue/after/else 中也無法使用。這是因為 try 區塊可能會在任何時刻失敗,因此變數可能從一開始就從未繫結。因此,這也不成立

iex> try do
...>   raise "fail"
...>   another_what_happened = :did_not_raise
...> rescue
...>   _ -> another_what_happened
...> end
** (CompileError) undefined variable "another_what_happened"

這完成了我們對 trycatchrescue 的介紹。你會發現它們在 Elixir 中的使用頻率低於其他語言。接下來,我們將討論對 Elixir 開發人員來說非常重要的主題:撰寫文件。