11.6 错误的报告与处理

你可能已经有所感悟,一个人是不可能不生病的。在一些时候,即便从表面上看没有什么明显的症状,也不能保证内里没有任何问题。可以这样说,疾病是人生的伙伴,如影随形。

对于计算机程序来讲,也是类似的。我们不能也不应该期望某人(包括我们自己)可以编写出不会出现任何错误的程序。同时,我们也不应该奢望可以解决掉程序中的所有错误。我们应该关注的是,当错误发生时,程序自身应该怎样去辨别、报告和处理。在程序自身无法处理的情况下,它应该怎样去记录,以便我们可以获知详尽的信息并据此找到有效且合理的解决方案。

对于任何的程序而言,错误的报告和处理都是一门学问。而且,几乎所有的编程语言都会非常的重视这一方面。它们的缔造者都会不遗余力地为程序开发者提供各种各样的辅助工具。反过来讲,不重视错误处理的编程语言是根本没有生存空间和发展的可能的。Julia 肯定是一门重视错误处理的编程语言。否则它也不可能走出 MIT 并发展到现在了。

11.6.1 程序错误的载体

在 Julia 语言里,程序错误被统称为异常(exception)。而且,与普通的数据一样,异常也需要由值来承载。我们以下称之为异常值。很显然,每一个异常值都会有类型。我们可以称之为异常类型。

Julia 中所有的异常类型都直接或间接地继承自Exception类型。通过执行调用表达式subtypes(Exception),你会发现仅仅是Exception的直接子类型就多达近 60 个。其中有不少我们之前见过的异常类型,比如:代表函数参数错误的ArgumentError、代表索引越界错误的BoundsError、代表类型转换错误的InexactError,以及在字典中不存在指定键时报出的KeyError、在衍生方法不存在时报出的MethodError、在变量未定义时报出的UndefVarError,等等。

可以看到,我们碰到过的这些异常类型的名称都是以Error为后缀的。其实,这些类型所代表的异常都有一个共同的特点,那就是:它们都会因程序编写的不恰当或不正确而被引发。此外,一些异常类型的名称会以Exception结尾。这些异常往往会因为一些真正的意外而被引发。比如,当有人在终端上强行地中断正在运行的程序时会引发InterruptException。又比如,当数据中意外地出现缺失值(即missing)时会引发MissingException。另外,其中还有一个通用的异常类型,叫做ErrorException。在你实在不知道用哪一个异常类型的时候,可以以它作为缺省的类型。

大多数异常类型都有对应的构造函数,而这些构造函数很多都是有参数的。以ErrorException为例,它有一个名为msg的字段,用于存放包含了错误描述的字符串。相应的,它的构造函数也有一个叫做msg的参数。在我们调用这个构造函数并传入一个参数值之后,其结果值就会是这样的:

  1. julia> ex = ErrorException("Something wrong!")
  2. ErrorException("Something wrong!")
  3. julia> ex.msg
  4. "Something wrong!"
  5. julia>

你可能会觉得 Julia 中异常的种类太多了,几乎无从记起。别担心,你无需像背课文那样把它们都记下来。学习编程其实也是一个试错的过程。所以,在你的程序真正地引发了某种异常之后,你再去了解相应的异常类型也不迟。只要我们能够吃一堑长一智,尽量在今后避免犯下同样的错误就好了。除此之外,我们需要更加关注的是,当碰到或引发了一个异常的时候应该怎么办。

11.6.2 异常的抛出

绝大多数正式的程序都需要根据一些外界的输入,经过一定的处理过程,最后产生必要的输出。外界的输入除了可以提供指令、设定条件和约束、构建程序的运行环境之外,还可能会引入各种有可能引发异常的隐患。所以,我们的程序对输入的前期检查是非常有必要的。

我们常常把用于在前期检查输入的代码称为防卫语句。防卫语句并不拘泥于某种形式,而重在其防卫功能。下面是一个很简单的例子:

  1. julia> # 用于打印某人的体重的函数。
  2. function print_weight(kg::Int)
  3. if kg <= 0
  4. throw(DomainError(kg, "The argument is too small!"))
  5. elseif kg > 500
  6. throw(DomainError(kg, "The argument is too big!"))
  7. end
  8. println("$(kg) kg")
  9. end
  10. print_weight (generic function with 1 method)
  11. julia> print_weight(100)
  12. 100 kg
  13. julia> print_weight(-1)
  14. ERROR: DomainError with -1:
  15. The argument is too small!
  16. Stacktrace:
  17. [1] print_weight(::Int64) at ./REPL[1]:4
  18. [2] top-level scope at REPL[3]:1
  19. julia>

我先定义了一个叫做print_weight的函数。这个函数的功能非常简单,只是打印一下某人的体重而已。它有一个参数,名称为kg,类型为Int

针对这个函数的功能,我已经通过参数的类型对输入进行了一定的约束。但这显然还不够。所以我又添加了一条防卫语句,也就是处于该函数的函数体最上面的那条if语句。其含义是,参数kg的值既不能小于或等于0也不能大于500,否则就主动抛出一个异常。注意,这里的条件有两个。我分别为这两个条件创建了不同的异常值。

通常,当由于参数的值超出了有效的值域而需要抛出异常的时候,我们通常会使用DomainError类型的值来表述异常。有两个构造函数可以产生此类型的异常值。其中的一个构造函数只有一个名为var的参数,而另一个构造函数的参数除了var还有msg。顾名思义,参数var应该被赋予的就是那个超出了值域的参数值,而参数msg则应该被赋予关于此异常的描述信息。

我在上面的例子中使用的是拥有两个参数的构造函数DomainError。因为单单给予print_weight函数所接受的参数值还不足以说明问题。紧接着,我把刚刚创建的异常值传给了throw函数。到了这里,异常就即将被抛出了。

严格来说,throw并不是一个通常意义上的函数。它的不普通之处在于,它被调用之后会立即中断当前程序正在执行的正常流程。你应该也看到了,在我向print_weight函数传入了超出值域的参数值之后,该函数并没有执行完它的正常流程(或者说没有打印出任何内容),而是直接使 REPL 环境显示出了一段异常提示信息。

在解释throw函数都做了什么之前,我们先来认识一个概念——调用栈。调用栈是编程语言用来实时记录和控制应用程序的执行过程的一种辅助工具。它基于的是一种被称为栈的数据结构。你可能已经知道,栈其实也是一种容器,而且它是先进后出的。更具体地讲,调用其他代码的代码(以下简称调用代码)会先被放入调用栈,然后被调用的代码才会入栈。另一方面,在通常情况下,调用代码要等到被调用代码执行完毕之后才会继续执行。所以被调用代码会先出栈,然后才是调用代码。也就是说,出栈的顺序与入栈的顺序是完全相反的。此外,调用栈通常无法描绘出应用程序运行过程的全貌。因为只有正在执行的代码调用才可能会出现在调用栈中。

现在,让我们来一起看一下上例中的异常提示信息。通过查看其中的前两行内容我们可以知道,被引发的异常的类型是DomainError,而引发的原因是参数值-1太小了。它比参数kg的有效值域中的最小值还要小。显然,这两行内容恰恰源自我在print_weight函数中传给throw函数的那个异常值。

接着往下看。紧挨在Stacktrace:下面的、左边以序号开头的那几行内容就是 Julia 向我们展示的调用栈信息。注意,这里的信息是以出栈的顺序展示的。也就是说,与序号1对应的是最后被调用的代码。

在包含了[1]的这行内容当中,我们需要关注两个地方。第一个地方是左边的print_weight(::Int64)。它是print_weight函数的签名,由函数名称、参数列表以及可选的结果声明组成。更宽泛地说,它代表的是被调用代码的标识。第二个地方是右边的./REPL[1]:4。它代表着被调用代码中抛出异常的语句的具体位置。在这里它显示了,那条语句处于当前的 REPL 环境所解析的第 1 段代码中的第 4 行,即:

  1. throw(DomainError(kg, "The argument is too small!"))

相应的,包含了[2]的内容告诉我们,在异常抛出时,调用print_weight函数的那条语句处于当前 REPL 环境所解析的第 3 段代码中的第 1 行。这条语句正是print_weight(-1)。而这行内容中的top-level scope是在告诉我们,这条语句是顶层作用域中的代码。所谓的顶层作用域指的就是Main模块所代表的作用域。已知,我们在 REPL 环境中直接写入的代码就都属于Main模块。如果这些代码未被包含在更小的作用域里,那么我们就可以说它们是顶层作用域中的代码。上例中的print_weight(100)print_weight(-1)就都是这样的代码,但是print_weight函数中的代码却不是。

在查看了这些调用栈信息之后你可能会意识到,throw函数不但会中断当前代码的执行,还会沿着调用栈的反方向(即与入栈顺序相反的方向)传播异常,直到碰到能够处理此异常的程序为止。对于上面的例子,REPL 环境本身会处理掉我们写入的代码所抛出的异常。也正因为如此,REPL 环境才能依然良好地运行着,并不会受到如此异常的影响。而且,我们可以看到,上例中的调用栈信息只有 2 行。这正说明此异常并没有被传播到 Julia 语言本身的程序当中。

如果我们把上述代码写入到一个源码文件中,并使用julia命令来运行,那么就可以在异常抛出后获得更多的信息。实际上,我已经把几乎一模一样的代码写进了Programs项目的src/ch11/exception/throw/main.jl文件中。现在,我们在命令行中运行一下它,结果如下:

  1. $ julia main.jl
  2. 100 kg
  3. ERROR: LoadError: DomainError with 501:
  4. The argument is too big!
  5. Stacktrace:
  6. [1] print_weight(::Int64) at /Users/haolin/Projects/Programs.jl/src/ch11/exception/throw/main.jl:11
  7. [2] top-level scope at /Users/haolin/Projects/Programs.jl/src/ch11/exception/throw/main.jl:17
  8. [3] include at ./boot.jl:328 [inlined]
  9. [4] include_relative(::Module, ::String) at ./loading.jl:1105
  10. [5] include(::Module, ::String) at ./Base.jl:31
  11. [6] exec_options(::Base.JLOptions) at ./client.jl:287
  12. [7] _start() at ./client.jl:460
  13. in expression starting at /Users/haolin/Projects/Programs.jl/src/ch11/exception/throw/main.jl:17

这个异常的抛出是由于我传给print_weight函数的参数值501太大了。还记得吗?参数kg的有效值域是(0, 500]。注意,Julia 这次给出的调用栈信息有 7 行。

需要我们分清楚的是,在这 7 行内容当中,前 2 行是关于用户级代码的,而后 5 行则与语言级代码有关。这里所说的用户指的是使用 Julia 代码编写应用程序的我们。而所谓的语言级代码,指的就是在 Julia 语言内部用于驱使和维护应用程序运行的那些代码。

你现在可以同时打开对应的源码文件作为参照。第 1 行调用栈信息显示,抛出异常的那条语句处在源码文件中的第 11 行,即:

  1. throw(DomainError(kg, "The argument is too big!"))

而第 2 行调用栈信息则显示,调用(包含了上述语句的)print_weight函数的代码处在源码文件中的第 17 行,即:

  1. print_weight(501)

同时,这条语句也是顶层作用域(即top-level scope)中的代码。

再下面几行的调用栈信息对应的都是语言级的代码。你现在倒不用深究这些代码都代表了什么以及都具体做了些什么。你只要知道,在我们使用julia命令运行源码文件的时候,Julia 是会先做一些准备工作的,如读取命令行参数、加载环境配置、导入Core模块等等。

在上述异常提示信息的最后,有一行总结性的内容。它表明,在异常抛出时,最靠近调用栈深处(或者说底端)的代码调用处于源码文件main.jl的第 17 行,也正是我们刚才提到的print_weight(501)

注意,由于我们使用julia命令直接执行了源码文件中的代码,这里并没有像 REPL 环境那样的可以处理掉异常的中间程序,所以这里的异常就被传播到了 Julia 语言本身的程序当中,从而导致了我们的应用程序的崩溃(或者说中断并终止运行)。

至此,我一直在借着讲throw函数的机会向你介绍与异常的抛出有关的重要概念,包括防卫语句、调用栈、异常的传播,还有顶层作用域、用户级代码、语言级代码和程序的崩溃。我们可以使用防卫语句对外界的输入进行前期检查,并在它们不符合预期的时候抛出异常。被抛出的异常会沿着调用栈的反方向(或者说向着调用代码的一方)传播,直到碰到能够处理它的程序为止。另外,在我们的应用程序中,顶层作用域可以说是用户级代码和语言级代码的分水岭。如果我们写在那里的代码还不能捕获并处理掉异常,那么异常就会继续向外传播。这要是在 REPL 环境中倒还好,但要是在源码文件中那就比较糟糕了。因为后一种情况就意味着程序会因异常的抛出而崩溃。

好了,一些基本的东西你应该已经都了解了。我们现在接着往下说。既然异常已经被抛出来了,那怎样才能处理掉呢?

11.6.3 异常的处理

一旦明白了异常是怎样被抛出来的,异常的处理就很好理解了。在使用 Julia 语言编写的应用程序中,没有任何代码可以自动地处理异常。我们需要使用try-catch语句专门地去做异常的捕获和处理。

我估计你只看名字也能猜得出来,try-catch语句是由两部分组成的。其中的try子句用于包裹可能会引发异常的代码。如此一来,一旦其中的代码真的引发了异常,try子句就会将该异常传递给catch子句,以便后者去捕获这个异常。catch子句可以携带一个代表了变量的标识符,用于绑定被捕获的那个异常值。示例如下:

  1. julia> # 用于获取 BMI(身体质量指数)函数。
  2. function get_bmi(weight::Int, height::Float64)::Float64
  3. if weight <= 0 || weight > 500
  4. throw(DomainError(weight, "Invalid weight! (range: (0, 500])"))
  5. elseif height <= 0.0 || height > 3.0
  6. throw(DomainError(height, "Invalid height! (range: (0.0, 3.0])"))
  7. end
  8. return weight / height^2
  9. end
  10. get_bmi (generic function with 1 method)
  11. julia> try
  12. bmi = get_bmi(0, 1.78)
  13. catch e
  14. println("WARNING: captured an exception: $e")
  15. end
  16. WARNING: captured an exception: DomainError(0, "Invalid weight! (range: (0, 500])")
  17. julia>

函数get_bmi可以计算并返回一个人的身体质量指数(BMI)。参数weight代表体重,单位是公斤。参数height代表身高,单位是米。

我们可以看到,这里的try子句和catch子句的拼接方式与if语句中的if分支和else分支的拼接方式是一样的。它们都是紧挨在一起的,并且只在最后有一个end。不过,这两种语句的处理逻辑就大相径庭了。

在这条try-catch语句被执行之后,REPL 环境并没有显示任何的异常提示信息。这说明这段代码的执行成功完成了。并且,我们可以看到,对这段代码的执行使 REPL 环境打印出了一行表示了警告信息的内容。很显然,相应的打印语句正是在catch子句当中的那一条语句。在细看这行警告信息之后,我们也可以确定,catch子句捕获到的异常值恰恰代表了当我们调用get_bmi函数并为它的weight参数传入0时应该抛出的那种异常。

这里有一点需要我们特别注意。try子句中的正常流程依然会因异常的抛出而被中断。只不过,try子句在异常即将被传播出去的时候将其拦下并传递给了catch子句。这使得try-catch语句重新获得了流程的控制权。可即使是这样,程序也不会再从抛出异常的那条语句那里继续执行下去了。我再向上例的代码中添加两行打印语句,你肯定就可以看出端倪了:

  1. julia> try
  2. println("Invoke `get_bmi` (before)")
  3. bmi = get_bmi(0, 1.78)
  4. println("Invoke `get_bmi` (after)")
  5. catch e
  6. println("WARNING: captured an exception: $e")
  7. end
  8. Invoke `get_bmi` (before)
  9. WARNING: captured an exception: DomainError(0, "Invalid weight! (range: (0, 500])")
  10. julia>

很显然,由于异常的发生,在try子句中且在bmi = get_bmi(0, 1.78)语句之后的代码并没有得到执行的机会。

一旦能够捕获到被抛出的异常,我们就可以去做相应的处理了。至于怎样处理,就要依据实际的场景和情况去做决定了。我们可以像上面那样仅仅打印出一行警告信息。我们也可以把异常值作为普通的结果值返回给调用方。如果确实有必要,我们还可以打印或返回一些有利于程序调试的东西。比如,通过调用Base.catch_stack函数,我们可以获取到包含了异常调用栈信息的数组对象。又比如,通过调用catch_backtrace函数,我们可以得到只包含了回溯信息的数组对象。

另外,我们也可以在做出简单的处理之后,把异常重新抛出去。这就需要用到rethrow函数了。下面的例子是在一个新的 REPL 环境中执行的:

  1. julia> # 用于获取 BMI(身体质量指数)函数。
  2. function get_bmi(weight::Int, height::Float64)::Float64
  3. if weight <= 0 || weight > 500
  4. throw(DomainError(weight, "Invalid weight! (range: (0, 500])"))
  5. elseif height <= 0.0 || height > 3.0
  6. throw(DomainError(height, "Invalid height! (range: (0.0, 3.0])"))
  7. end
  8. return weight / height^2
  9. end
  10. get_bmi (generic function with 1 method)
  11. julia> try
  12. println("Invoke `get_bmi` (before)")
  13. bmi = get_bmi(0, 1.78)
  14. println("Invoke `get_bmi` (after)")
  15. catch e
  16. println("WARNING: captured an exception: $e")
  17. println("Invoke `rethrow` (before)")
  18. rethrow(e)
  19. println("Invoke `rethrow` (after)")
  20. end
  21. Invoke `get_bmi` (before)
  22. WARNING: captured an exception: DomainError(0, "Invalid weight! (range: (0, 500])")
  23. Invoke `rethrow` (before)
  24. ERROR: DomainError with 0:
  25. Invalid weight! (range: (0, 500])
  26. Stacktrace:
  27. [1] get_bmi(::Int64, ::Float64) at ./REPL[1]:4
  28. [2] top-level scope at REPL[2]:3
  29. julia>

根据 REPL 环境输出的前几行内容,你应该已经可以分析出这段代码的执行流程了。try子句中的调用语句get_bmi(0, 1.78)抛出了异常,使得控制流直接从那里跳到了catch子句。但由于其中的调用语句rethrow(e)的存在,异常又被重新抛了出去。最后,REPL 环境捕获并处理掉了这个异常。

从后面的那几行异常提示信息我们可以看出,虽然这个异常是被catch子句中的代码重新抛出来的,但它的各项信息都没有丝毫改变,包括引发异常的那个参数值、异常值本身的描述信息,以及调用栈信息中的所有细节,如同异常没有被捕获过一样。这就是rethrow函数所起到的作用。

我们现在知道了,catch子句在try-catch语句当中起到了举足轻重的作用。怎样处理可能发生的异常,几乎完全取决于catch子句以及其中的代码。不过,除了catch子句,try子句还可以后接finally子句。而后者在某些方面更有用处。

finally子句只能被编写在try子句和catch子句的下面。在这种情况下就形成了try-catch-finally语句。而且,一旦后接了finally子句,我们就可以不编写catch子句,而直接把try子句和finally子句拼接在一起。但是,从功能上说,这只适用于特定的情况。我在后面会讲到。

为了方便讲解,我在下面会把以这几种形式编写出的代码统称为try语句。因为无论怎样,try子句总是要写的,而且总是会写在最上面。

finally子句有一个特权,那就是:不论try子句中的代码是否抛出了异常,在它里面的语句都一定会被执行。具体的执行时机是,在try子句和catch子句中的代码被执行完毕之后,且在try语句的整体被执行完毕之前。即便其中的异常会被抛到外界,Julia 也会保证在这之前执行完finally子句。显然,finally子句在这方面与catch子句截然不同。Julia 只会在异常真的被抛出时执行catch子句。不过,finally子句却不能像catch子句那样捕获和处理异常。

正因为如此,finally子句非常适合做一些善后的处理工作。比如,记录日志、检查并修正计算结果、释放不再需要的计算资源,等等。下面是一个没有抛出异常的例子:

  1. julia> bmi = 0;
  2. julia> try
  3. global bmi = get_bmi(65, 1.78)
  4. catch e
  5. println("WARNING: captured an exception: $e")
  6. finally
  7. global bmi
  8. println("BMI: $(bmi)")
  9. end
  10. BMI: 20.515086478979924
  11. 20.515086478979924
  12. julia>

REPL 环境在最后回显的第一行内容是finally子句中的打印语句打印出来的,而第二行内容表示的则是try语句的结果值。没错,try语句也属于一种复合表达式。显然,上面这条try语句的结果值就是变量bmi的值。这是由try子句或catch子句中的最后一条语句决定的。

你可以自行调整一下传给get_bmi函数的参数值,让它们可以引发异常,然后看一看程序执行的结果会有什么不同。下面,我们将对这条try语句进行另外一项调整——删除catch子句:

  1. julia> try
  2. global bmi = 0
  3. bmi = get_bmi(0, 1.78)
  4. finally
  5. global bmi
  6. println("BMI: $(bmi)")
  7. end
  8. BMI: 0
  9. ERROR: DomainError with 0:
  10. Invalid weight! (range: (0, 500])
  11. Stacktrace:
  12. [1] get_bmi(::Int64, ::Float64) at ./REPL[1]:4
  13. [2] top-level scope at REPL[5]:3
  14. julia>

一旦删除了catch子句,try子句中抛出的异常就无法被捕获了。这个异常会继续向外传播。但即使是这样,在异常被传到外界之前,finally子句仍然会被执行。REPL 环境在这里回显的内容就可以很好地证明这个过程。处于第一行的BMI: 0表明finally子句中的那条打印语句被执行了。但由于对get_bmi函数的调用未能成功完成,所以变量bmi的值依然是0。在这之后的异常提示信息则说明,异常被传播到了 REPL 环境那里,且被后者捕获并处理掉了。

你若足够细心的话就一定会发现,我在前面的try子句和finally子句当中都在使用关键字global去修饰标识符bmi。你应该还记得,这是在局部作用域中引用全局变量时需要运用的编写手法。这说明try子句和finally子句都会自成一个作用域。不但如此,try语句中的catch子句也会形成一个局部作用域。

由此可以推断,try语句中的各个子句肯定是无法访问到彼此定义的局部变量的。我们下面通过一个例子来验证这一点:

  1. julia> try
  2. bmi2 = get_bmi(0, 1.78)
  3. catch e
  4. try bmi2 catch e1 println("[INNER ERROR 1] $e1") end
  5. finally
  6. try bmi2 catch e2 println("[INNER ERROR 2] $e2") end
  7. try e catch e3 println("[INNER ERROR 3] $e3") end
  8. end
  9. [INNER ERROR 1] UndefVarError(:bmi2)
  10. [INNER ERROR 2] UndefVarError(:bmi2)
  11. [INNER ERROR 3] UndefVarError(:e)
  12. julia>

为了让代码更加整洁,我在其中使用了try语句的简写形式,如:

  1. try bmi2 catch e1 println("[INNER ERROR 1] $e1") end

我一共编写了三条像这样只占用一行的try语句。它们的作用都是,在引用无法访问到的变量时及时地打印出提示信息。由 REPL 环境回显的内容可知,它们都奏效了。理所当然,我们在这些子语句中定义的局部变量在try语句之外也都是不可见的。

好了,你现在应该已经对try语句以及 Julia 应用程序中的异常足够熟悉了。让我们再一起简单地回顾一下。try语句可由三个部分组成,即:用于包裹可能会引发异常的代码的try子句、用于捕获和处理异常的catch子句,以及无论是否有异常发生都会执行的finally子句。try子句可以后接catch子句,也可以后接finally子句,且至少要拼接一种子句。另外,这三种子句都会自成一个局部作用域。所以,如果你要在其中定义或引用变量,那就要多一份考虑了。

Julia 应用程序中的异常可能是由 Julia 语言抛出的,也可能是由应用程序中的某段代码自行抛出的。但无论怎样,Julia 中的异常都会由值来承载。我们称之为异常值。这些异常值的类型一定都是Exception类型的某个子类型。我们在应用程序中的任何地方都可以使用throw函数来抛出异常。并且,我们也可以在try语句的catch子句中使用rethrow函数重新抛出已经捕获到的异常。在抛出异常的时候,我们应该仔细斟酌异常的类型和异常值的构造细节,以求尽量为异常的识别、定位和处理提供有利的条件。这也是我们在编写应用程序时必须要考虑的一个很重要的方面。