Elixir 有三种错误机制:errors, throws, and exits。在本章中,我们将探索每种机制,并说明何时应使用它们。
Errors
当代码中发生异常时,就会使用错误(或异常)。可以通过尝试将数字添加到原子来检索示例错误:
可以使用 raise/1 随时引发运行时错误:
可以使用 raise/2 传递错误名称和关键字参数列表来引发其他错误:
您还可以通过创建模块并在其中使用 defexception/1 构造来定义自己的错误。这样,您将创建一个与定义它的模块同名的错误。最常见的情况是使用消息字段定义自定义异常:
可以使用 try/rescue 构造来rescue错误:
上面的示例挽救了运行时错误并返回了异常本身,然后在 iex 会话中打印出来。
如果您对异常没有任何用处,则不必传递变量来rescue:
实际上,Elixir 开发人员很少使用 try/rescue 构造。例如,许多语言会强制您在无法成功打开文件时挽救错误。 Elixir 提供了一个 File.read/1 函数,该函数返回一个元组,其中包含有关文件是否已成功打开的信息:
这里没有 try/rescue。如果您想要处理打开文件的多个结果,可以使用 case 构造进行模式匹配:
对于您确实希望文件存在的情况(并且缺少该文件确实是一个错误),您可以使用 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,但我们可能希望使用此类构造的一种情况是可观察性/监控。假设您想要记录出现错误的情况,您可以这样做:
在上面的例子中,我们挽救了异常,记录了它,然后重新引发了它。我们在格式化异常和重新引发时都使用 __STACKTRACE__ 构造。这确保我们按原样重新引发异常,而不会更改值或其来源。
一般来说,我们在 Elixir 中按字面意思理解错误:它们保留用于意外和/或异常情况,从不用于控制代码流程。如果您确实需要流控制构造,则应使用 throws。这就是我们接下来要看到的内容。
Throws
在 Elixir 中,可以抛出一个值,然后将其捕获。throw 和 catch 保留用于除非使用 throw 和 catch 否则无法检索值的情况。
这些情况在实践中并不常见,除非与未提供适当 API 的库交互。例如,假设 Enum 模块没有提供任何用于查找值的 API,而我们需要在数字列表中找到 13 的第一个倍数:
由于 Enum 确实提供了适当的 API,因此在实践中 Enum.find/2 是可行的方法:
Exits
所有 Elixir 代码都在相互通信的进程内运行。当进程因“自然原因”(例如未处理的异常)死亡时,它会发送退出信号。进程也可以通过明确发送退出信号而死亡:
在上面的例子中,链接进程通过发送值为 1 的退出信号而死亡。Elixir shell 会自动处理这些消息并将其打印到终端。
也可以使用 try/catch“捕获”退出:
catch 也可以在函数体内使用,而无需匹配 try。
但是,使用 try/catch 已经不常见了,使用它来捕获退出就更少了。
退出信号是 Erlang VM 提供的容错系统的重要组成部分。进程通常在监督树下运行,而监督树本身就是监听受监督进程的退出信号的进程。一旦收到退出信号,监督策略就会启动,受监督进程就会重新启动。
正是这种监督系统使得 try/catch 和 try/rescue 等结构在 Elixir 中如此罕见。我们宁愿“快速失败”,而不是挽救错误,因为监督树将保证我们的应用程序在发生错误后回到已知的初始状态。
After
有时,需要确保在执行某些可能引发错误的操作后清理资源。try/after 构造允许您这样做。例如,我们可以打开一个文件并使用 after 子句将其关闭 - 即使出现问题:
无论 tried 块是否成功,after 子句都会执行。但请注意,如果链接的进程退出,则该进程将退出,并且 after 子句将不会运行。因此 after 仅提供软保证。幸运的是,Elixir 中的文件也链接到当前进程,因此如果当前进程崩溃,它们将始终被关闭,与 after 子句无关。您会发现其他资源(如 ETS 表、套接字、端口等)也是如此。
有时您可能希望将函数的整个主体包装在 try 构造中,通常是为了保证之后会执行一些代码。在这种情况下,Elixir 允许您省略 try 行:
每当指定 after、rescue 或 catch 之一时,Elixir 都会自动将函数主体包装在 try 中。
Else
如果存在 else 块,则每当 try 块完成且没有抛出或错误时,它都会与 try 块的结果匹配。
else 块中的异常不会被捕获。如果 else 块内没有匹配的模式,则会引发异常;当前 try/catch/rescue/after 块不会捕获此异常。
变量作用域
与 Elixir 中的 case、cond、if 和其他构造类似,在 try/catch/rescue/after 块内定义的变量不会泄漏到外部上下文。换句话说,此代码无效:
相反,您应该返回 try 表达式的值:
此外,在 try 的 do 块中定义的变量在 rescue/after/else 内也不可用。这是因为 try 块可能随时失败,因此变量可能从未被绑定过。所以这也不有效:
至此我们对 try、catch 和 rescue 的介绍就结束了。您会发现它们在 Elixir 中的使用频率低于在其他语言中。接下来我们将讨论对 Elixir 开发人员来说非常重要的一个主题:编写文档。