查看源代码 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 中的错误持严格的态度:它们是为不可预料和/或异常情况保留的,而不是用于控制代码流程。如果您确实需要流程控制结构,则应使用 *抛出*。接下来我们将看到这一点。

抛出

在 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

退出

所有 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 已经很不常见,而使用它来捕获退出则更为罕见。

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 块捕获。

变量作用域

与 Elixir 中的 casecondif 和其他结构类似,在 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 开发人员非常重要的主题:编写文档。