之前多次提到了异常处理机制。我们延迟到这里深入探讨因为异常处理要求语言实现 unwind subroutine 调用栈。

异常的定义:不符合预期的,至少是不寻常的条件在程序运行期间出现,而且无法在局部上下文中处理。可能被语言实现自动识别抛出,也可能开发者自己使用 raise 或者 throw 显式抛出。最常见的异常是多种运行时错误。比如在 I/O 库中,输入 routine 可能在读到请求值之前遇到文件结尾,或者当期望数字时发现分号或者字母。没有异常机制时,程序员有三种选择处理这种错误,都不让人满意:

  1. 当没有值可以返回时,“创造”一个caller 可以使用的值
  2. 返回一个显式的“状态”给 caller,每次调用后要处理。更常见的是,这个状态是一个额外的显式的参数。有些语言中,返回值和状态可以作为元组一起返回
  3. caller 传递闭包作为错误处理 routine。

这些方法 2,3 会影响程序的正常逻辑,1 只适用于部分场景。

很多新语言提供了异常机制。比如 C++ 中

try {
  if (something_unexpected) 
    throw my_error("oops!");
} catch(my_error e) {
  ...
}

而且 try catch 也可以嵌套。如果当前的 subroutine 没有 handler,就会继续传播,如果到了主程序都没有 handler 会终止程序。

从某种意义上说,异常处理对子历程的调用顺序的依赖性可以被认为动态绑定的一种形式。与其说是 handler 在调用 routine 过程中动态绑定到所有 routine 的错误上,不如说是 handler 词法上绑定到了 called routine 的表达式或者语句上。

实际上,异常处理一般执行三种操作。第一,最理想的,handler 可以恢复并继续执行程序。比如,对于 OOM 异常,handler 可以要求操作系统分配额外的空间,然后继续完成处理。第二,当异常出现在局部 handler 不能处理的情况,清理局部申请的空间,抛出异常,从而传播异常直到可以处理。第三,如果无法恢复,handler 至少可以打印出有帮助的日志。

9.4.1 Defining Excepitons

很多语言中,动态语义错误自动导致异常,程序可以 catch 它们。程序员也可以定义应用特定的异常。预定义的异常包括算术溢出,除零,input 中的文件结束,subcript 或者 subrange 错误,空指针解引用。这些作为异常而不是重大错误的理由是不能出现在有效的程序中。有些其他动态错误(比如没有返回指定的返回值)在大多数语言中是重大错误。在 C++ 和 Common Lisp 中,异常都是程序员定义的。

在大多数面向对象语言中,异常就是用户定义或者预定义的类。

9.4.2 Excepition Propagation

大多数语言中,代码块有异常 handler 列表。在 C++ 中

try {
  
} catch(end_of_file e1) {
  ...
} catch(io_error e2) {
  ...
} catch(...) {
  // handler for any excepiton not previously named
}

异常发生时,handler 按序检查;控制权转移给第一个匹配上的 handler。如果没有最后一个 handler ,未匹配的异常会继续“向外”传递。

递归 subroutine 中声明的异常会被最近的 handler 捕获。如果异常被传递出去,就不能被命名 handler 捕获,只能被 catch-all 捕获。在并发语言中,如果没有被线程捕获,异常在 Modula-3 和 C++ 会导致程序异常退出。Ada 和 Java 中,只有受影响的线程会退出。C# 依赖实现。

Handlers on Expression

在面向表达式的语言中,比如 ML 或者 Common Lisp,异常的 handler 绑定了一个表达式,而不是语句。在 OCaml 中,handler 像这样:

let foo = try a / b with Division_by_zero -> max_int;

a/b 是受保护的表达式,try, with 是关键字,Division_by_zero 是一个异常,max_int 是异常发生的代替受保护表达式的表达式。

Cleanup Operations

在匹配 handler 的过程中,异常处理机制必须 unwind 运行时栈。回收 frame 不仅要求将空间从栈推出,还要保存的寄存器。

在 C++ 中,异常离开 scope,不管是 subroutine 还是嵌套的 scope,要求语言实现本 scope 的对象的调用析构函数。其他语言也有类似机制。

9.4.3 Implementation of Excepitons

异常最显而易见的实现方式是维护一个 handler 的栈单链表。当控制进入了保护块,这个块的 handler 加入到链表头。当异常发生,或者调用 raise 或者 throw 语句,语言的运行时系统推出链表的第一个 handler,调用。handler 开始检查是不是匹配异常,如果没有简单的 reraise

为了实现在动态链上的传播,每个 subroutine 有一个隐式的 handler 执行 subroutine epilogue 代码的工作,然后重新抛出异常。

如果保护块代码有几种不同异常的 handler,就被实现为包含 if 语句的单个 handler。

这种实现的问题就是提升了运行时开销。每个被保护块和每个 subroutine 开始的代码都要推 handler 到 handler 列表,结束代码推出列表。我们通常可以做得更好。

handler 列表的真实目的是要决定哪个 handler 要激活。因为源码块倾向于翻译为机器语言指令的连续块,我们可以在编译器表驱动的形式捕获 handler 和受保护块的关联。表中每项包含两个字短:代码块开始的地址和关联 handler 的地址。表以第一个字短排序。当异常发生,语言的运行时系统在表中执行二分搜索,使用 PC 作为 key,发现当前块的 handler。如果 handler 重新抛出异常,处理重复:handler 本身也是代码块,可以在表中被查找。在与 subroutine 中与传播相关的隐式 handler 情况下,出现的唯一微妙之处为:这样的 handler 必须必须确保重新抛出代码使用 subroutine 的返回地址,而不是当前的 PC,作为表查询的 key。

抛出异常的开销在这第二种实现中更高,与全部 handler 数量的 log 相关。但是开销只有在异常发生时才有。假定异常是不同寻常的事件,对性能的影响显然是有益的:在通常情况下开销是零。表驱动方法的形式要求编译器可以访问整个程序,或者链接器提供粘合子表的机制。如果代码片段分开编译,我们可以利用混合方法,编译器为每个 subroutine 创建单独的表,每个 stack frame 包含了对应表的指针。

Exception Handling without Excepitions

值得注意的是,有时可以用不提供内建的语言模拟异常。在 6.2 我们注意到 Pascal 允许 goto 跳转到 subroutine 之外的标签。

一些 setjmp 和 longjmp 的叙述。C 提供了 volatile 关键字,这种变量的修改会马上回写内存,每次读都从内存读。C 通过 setjmp 和 longjmp 实现的异常如果被保护代码修改的值需要被 handler 看到,需要使用 volatile 关键字修饰。