Programming in Lua(二)- 异常与错误码

我不喜欢编程语言用「异常处理 (exception handling) 」的方式处理错误。从以往经历看,先有 C++ 创造了最差的异常实现 —— 没有 GC 帮助内存管理,扰乱 C 的二进制接口 (Application Binary Interface, ABI)。为了绕过这个拖累,维护 C++ 代码往往要花费双重开销来完成没有异常的语言可以免费获得的东西:code review 必须保证代码的「异常安全 (exception-safty)」[1],同时不写会抛出异常的新代码。

Java 提供了 GC,解决了安全实现异常处理最大的的先决条件。不过凡事皆 checked-exception 的方式令人毫无好感 [2]。Objective-C/Cocoa 中没有跨越 block 的异常机制,基本上采取传统的返回错误码方式,让我舒了一口气。但是接下来,Lua 通过 longjmp 实现跨函数的类似异常处理。一方面,让我怀疑 Lua 以简洁著称的设计是否在这点上采取了错误方式;另一方面,longjmp 并未实际引起麻烦,让我好奇异常处理是否也有某些价值。

异常处理和传统的返回错误码 (error code) 两种处理错误的方式是一场持久的争论。就在最近,围绕 Go 语言完全抛弃异常处理的语言特性,《Why I’m not leaving Python for Go》的作者表了极大失望。

Russ Cox 针对上文为 Go 语言进行了辩护。其中提到了 Raymond Chen 两篇旧日的 blog:

Raymond Chen 用严密的逻辑和实例说明了编写正确异常处理的代码 [3] 非常非常困难。特别要注意 (但不限于) 以下两点:

  • 正确管理资源,特别是资源的回收;
  • 关键数据的修改尽可能滞后。在中间可能抛出异常的步骤中,随时保证数据处于一致 (integral) 的合法状态。

关注第一点也许会令人假定,如果程序不涉及内存以外的资源,并有成熟的内存管理机制,就足以保证写出正确的异常处理代码。毕竟把异常处理放到 feature list 中的语言无不首先重视提供 GC 机制。由于需要根据异常的 stack unwinding 情形考虑内存回收,这些语言一般采用 root-tracing GC 而非 ref-counting [4]。但是,将资源管理局限于内存并不足以对第二条豁免,比如复杂的图结构 (graph structure),或者更常见的情形:对象需要向多个相互关联的全局表注册自身的引用。而且话说回来,「纯」内存数据操作除了内存用尽 (out of memory) 之外又有什么值得担忧的错误需要处理呢?归根结底异常处理是一个主要面向 I/O 问题的机制。

在「纯」内存无 I/O 的环境下,能体现异常处理价值的领域并不多,仅存的适用领域之一是语言虚拟机。这正是 Lua 采用 longjmp 类似异常处理的原因,主要用于类型检查和数组越界等语言虚拟机问题。而且这时处理的错误往往不是最终产品代码 (production code) 期待的行为,并不真正用来弥补错误,只是起一些辅助作用,比如揭示 bug 和收集诊断信息,防止应用完全退出,在多文档应用中让用户有机会保存其它信息,或者让应用以 reset 之后的状态接受其它请求。类似于 Go 中的 panic 机制和 Java 中的 runtime-exception (unchecked excpetion)。

GC 虽然是实现安全的异常处理机制的先决条件之一,但只是朝向最终解决问题的很小一步。因为真正能体现异常处理价值的地方是 I/O 密集程序。有哪些 I/O 机制目前可以做到「关键数据的修改尽可能滞后。在中间可能抛出异常的步骤中,随时保证数据处于一致的合法状态」呢?作为 naive 的尝试,C++ 提出了 RAII。但是很遗憾,异常安全的需求明显超出了 RAII 的能力。除了关系型数据库事务处理 (RDBMS transaction) 的二步式提交 (two-phase commit),我不知道还有什么 I/O 机制满足这个要求。也就是说,在日常需要的软件工具中包括图形化窗口化 UI,网络,打印等等常见 I/O 操作中,只有纯粹的数据库 CRUD 系统这个特殊领域适于异常处理机制。正因为如此,非数据库的 I/O API 的错误处理都应该采取返回错误码形式。特别是,以异常处理文件访问错误的 API 都是失败的设计 [5]。Java 正是被鼓吹适合数据库 CRUD 领域,所以其异常处理机制获得了一些正面评价。但是当其野心不限于此时,将仅限于数据库领域用的还不错的异常处理机制匆忙的推广到其它问题就招致了恶名。

某些系统通过异常处理或者类似异常处理的机制来解决某些问题,而且解决得还不错。这是它们的设计者针对一些能体现异常处理价值的特定领域选择的方案。这些成功案例并不能简单地推广。保守地说,要采用异常处理,必须保证所有资源置于二步式提交的事务管理之下;或者限于虚拟机内部对类型检查等非 I/O 操作的「粗粒度」错误处理。「粗粒度」表示一旦发生错误,系统采取的应对策略是放弃整个粒度较大的操作,异常处理仅仅保证程序不退出,收集 bug 诊断信息,或者保留机会处理其它请求,而不是去弥补刚发生的错误。特别是对于 Lua,这个问题还有一层含义。Lua 允许用 C 编写扩展。这种情况下要把基于 longjmp 的异常处理部分限于开始的参数类型检查,置于触及关键数据和 I/O 操作之前,一旦 C 代码涉及了实质的数据和 I/O 操作,错误处理方式就必须变为返回错误码机制。Lua 支持多返回值特性正是为返回错误码方式的应用提供便利。显然,Lua 的可扩展性也是其基于 longjmp 的机制彰显天下的原因,对于 Java 来说,虚拟机内部的具体实现和使用它的程序员是毫不相关的。

脚注:

  1. C++ 中所谓的「异常安全」也不过就是尽量使用 on-stack 对象 (以及基于 on-stack 对象的「智能」指针) 和 RAII (下文还有涉及) 而已。
  2. 错误处理有经常被人混淆的两个方面。一是如何保证程序员不忽略可能的错误;二是在程序员意识到可能的错误时,如何编写正确的处理代码。本文只讨论第二个方面。因为,如何「不忽略可能的错误」属于程序员掌控应用逻辑的问题,已经超出了编程语言的能力。Java 的 checked-exception 试图用语言解决这个问题,但是即便是 checked-exception,也允许程序员相当容易的把异常遗留给上层 caller。其结果是,越多的错误集中在一处处理,而且远离错误发生的地点,这段异常处理代码的正确性就越难保证 (或者这段代码除了 crash/quit 无法做任何其它有意义的工作)。也许,这正是没有任何其它语言借鉴 checked-exception 机制的原因。
  3. 注意这里的「异常处理的代码」指程序员用具备异常处理机制的语言编写处理实际错误的代码,不要和异常机制本身的实现混淆。
  4. Objective-C/Cocoa 舍弃异常处理的可能原因之一。另一方面,如果在 stack unwinding 时进行特定的处理,也可以用 ref-counting GC 配合异常。比如 C++ 调用 destructor 以及由此衍生的「智能」指针,还有 Python 的机制。但是我不喜欢这种将 unwinding 复杂化的机制。
  5. 导致每行一个 try-catch block。

一条回应 to “Programming in Lua(二)- 异常与错误码”

  1. Exception Reconsidered | 技术奇异点 Says:

    […] C++ exception 的反对者。《Programming in Lua(二)- 异常与错误码》提到 Russ Cox 谈论 C++ exception 的所谓「根本缺陷」。我以前的认识是返回 […]

留下评论