檢視原始碼 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 中,可以拋出一個值,然後再捕獲它。 throw
和 catch
是保留給那些無法使用 throw
和 catch
擷取值的情況。
在實務上,這些情況相當罕見,除非與未提供適當 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/catch
和 try/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
每當指定 after
、rescue
或 catch
之一時,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
區塊捕獲。
變數範圍
與 case
、cond
、if
和 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"
這完成了我們對 try
、catch
和 rescue
的介紹。你會發現它們在 Elixir 中的使用頻率低於其他語言。接下來,我們將討論對 Elixir 開發人員來說非常重要的主題:撰寫文件。