一、野心和经验
Ambitions and Learnings
这是一篇译文。原文链接:http://joeduffyblog.com/2016/02/07/the-error-model/
让我们首先来看看现有的系统,总结一下我们需要哪些东西,并从中学习一些架构方面的经验。
准则
首先,我们需要定义什么是一个好的错误模型:
-
易用性:在开发人员面对程序错误的时候,他们必须能够非常方便、甚至不加思考地做出“正确的事”。一个朋友(也是同事)说这就叫做“好坑(The Pit of Success)”。错误模型不能为编码工作增添大量的额外负担。正常情况下,开发人员应该对它非常熟悉。
-
可靠性:错误模型是整个系统可靠性的基础。毕竟我们正在编写一个操作系统,可靠性是最重要的。有人可能会觉得我们太过于重视这一点了。我们提倡“构建正确”(Correct by Construction),很多编程模型的开发都基于这个理念。
-
性能:在大多数情况下,我们都希望代码能够足够快。也就是说,在没有发生错误的时候,我们需要尽可能地减少性能损失。而对于发生错误的时候,任何额外的性能开销必须是“按需付费”。与很多愿意在发生错误时过度损失性能的现代系统不同,我们有一些对性能非常敏感的组件,当发生错误时它们也需要足够快才行。
-
并发:我们的整个系统都是高并发、分布式的。对于其他的错误模型来说,这通常是事后才考虑的事情,但我们需要提前正视这个问题。
-
诊断:不论是交互式调试还是事后调试,都需要简单而高效。
-
协调:从根本上讲,错误模型是一种编程语言的功能,是开发人员表达代码逻辑的重要部分。因此,错误模型必须与系统中的其他功能协调一致。不同组件之间的集成必须是自然的、可靠的,结果是可预测的。
这些要求也许有些严格,但我认为最终我们在所有的方面都取得了成功。
经验
现有的错误模型并没有满足我们上面提到的要求,至少没有全部满足。通常某个错误模型可以在一个方面做的很好,但在另一个方面却很差。比如,错误码可以有很高可靠性,但很多程序员发现错误码在使用时很可能会出错。具体来说,它很容易导致程序员做出错误的事,比如忘记检查错误码。这明显违背了“好坑”原则。
考虑到我们寻求的是最高等级的可靠性,大多数模型都不能满足我们的需求也不足为奇了。
相比于可靠性,如果易用性是更优先的目标(例如你在使用一门脚本语言),那么结论可能会完全不同。Java 和 C## 之类的语言的纠结之处在于,它们的使用场景可能很复杂——有时候用于系统编程,有时候用于应用编程。总的来说,它们的错误模型很不满足我们的需求。
最后需要说明的是,我们的故事开始于十几年前,那时还没有 Go、Rust、Swift 等语言供我们参考。这三门语言在错误模型方面取得了很大的进步。
错误码
错误码大概可以说是最简单的错误模型了,它背后的原理非常基础,甚至不需要语言或运行时的支持。函数返回一个值,通常来说是一个整数,来表明成功或失败。
int foo() {
// 执行一些语句
if (failed) {
return 1;
}
return 0;
}
这就是很一种典型的模式,返回0表示成功,非零值表示失败。调用时必须检查返回值:
int err = foo();
if (err) {
// 出错了!赶紧搞定
}
大多数系统支持使用常量来表示一组错误码,而不是直接使用具体的数字。也可能会有一些函数用于获取最近一次错误的详细信息(比如 C 语言中的errno,以及 Win32 中的 GetLastError)。返回一个错误码确实不是什么特殊的事——就是返回一个值。
C 语言一直在使用错误码。因此,大多数基于 C 语言的生态系统也用错误码。很多底层系统的代码也使用了返回错误码的方法,Linux 如此,不计其数的关键任务系统、实时系统也如此。公平地说,错误码模型有着深远的历史和广泛的影响。
在 Windows 上,HRESULT
也是一样,它就只是一个整数“句柄”,还附带了一堆常量和宏,例如S_OK
、E_FAULT
、SUCCEEDED()
,定义在winerror.h
里面。这些常量和宏用来创建或检查返回值。Windows 最核心的部分都使用了返回错误码的方式。内核里面不会使用异常,至少不会刻意去使用。
在一些需要手动管理内存的环境下,发生错误时释放内存尤其困难。考虑到这种情况,返回错误码的方式相对可以接受。C++ 使用 RAII 来自动解决这个问题,但是除非你非常认同整个 C++ 的编程模型(很多系统程序员并不),在 C 语言中没什么好的方法来添加上 RAII 的功能。
Go 语言选择了使用错误码。虽然 Go 的方式与 C 很像,但它提供了更加现代化的语法和库的支持。
很多函数式编程语言也使用返回码——它们可能被掩盖在 Monad、Option<T>
、Maybe<T>
、或是 Error<T>
里面。它们与数据流、模式匹配相结合,使用起来更加自然。这种方法解决了错误码的很多弊端(我们会在下文提到),尤其是与 C 相对比更是如此。Rust 大范围地采用了这个模型,同时也提供了令很多系统程序员非常欣赏的新功能。
尽管错误码具有非常简洁的优势,它也会带来一些包袱,比如:
- 性能可能会受到影响。易
- 用性可能会很差。
- 最重要的一点:你可能会忘记检查错误码。
我们会结合上面提到的那些语言逐条讨论。
性能
错误码并不满足“对于大部分场景提供性能零负担,对于小部分场景按需‘付费’”的标准:
-
对调用约定的影响。现在,对于非 void 返回值类型的函数来说,你需要返回两个值:一个是真正的返回值,一个是可能发生的错误。这就消耗了更多的寄存器和/或栈空间,让函数调用变得不高效。当然,对于可以内联的函数来说,内联可以帮忙解决一些问题。
-
当被调用方可能产生错误的时候,调用方需要有很多的分支来进行处理。我把这种开销叫做“花生酱”,因为分支检查随意分布在代码中,很难直接测量它的影响。在 Midori 中,我们曾做过实验,并且确认了这些分支确实会带来性能损失。分支太多还会带来另一方面的影响——编译器中的优化器可能会被这么多的分支搞蒙。
也许对很多人来说这个结论有点让人惊讶,毕竟每个人都一定听说过“异常很慢”。事实证明,未必如此。而且,在使用正确的情况下,异常会让错误处理代码和数据与热路径(Hot Path)分开,这就增加了 L 缓存和 TLB 的性能。而在上述错误码的模型中,L 缓存和 TLB 的性能会明显受到影响。
你可能会觉得我在吹毛求疵,对这种模型太过苛刻,毕竟很多高性能系统都在使用错误码模型。然而,下面我们将看到错误码模型的更严重的问题。
忘记检查错误码
开发人员非常容易忘记检查函数返回的错误码。比如,有这样一个函数:
int foo() { ... }
在调用时,如果我们默默地忽略返回值并且继续执行呢?
foo();
// 继续执行 —— 但是 foo 可能已经失败了!
在这个时候,你已经掩盖了程序中的一个潜在关键错误。这是错误码最烦人、也是后果最严重的问题。后面我会提到,一些函数式语言使用了“Option”类型来解决这一点不足。但是对于基于 C 的语言,甚至对于具备现代语法的 Go 来说,这是一个确实存在的问题。
我不是在纸上谈兵。我遇到过无数的 Bug,都是由于忽略了返回值引起的,我相信你也遇到过。事实上在使用错误码模型时,我的团队同事遇到了一些很有趣的 Bug。比如,当时我们在往 Midori 上移植微软的语音服务(Microsoft’s Speech Server),结果发现80%的繁体中文请求会失败——不是那种立刻就抛出来的错误,而是表现为客户端收到了一些垃圾数据。一开始我们以为是我们自己的问题,但最终我们发现是原始代码中忽略了HRESULT
返回值。当移植到 Midori 上时,这个 Bug 就突然出现了。这一段经历佐证了我们关于错误码的观点。
对于 Go 语言,我有点小惊讶。Go 语言认为未使用的import
是一种错误,但居然在返回码这种关键得多的地方不做要求。太可惜了!
当然,你可以使用静态分析器,或者在编译时给出一个“未使用的返回值”的警告(很多商业 C++ 编译器都有支持)。但是如果它不是一门语言的核心功能、作为一项强制要求,由于代码分析的噪声存在,这些方法都不会起到根本性的作用。
由于这些原因,在我们的语言里,没有使用的返回值是一种编译错误。你需要显示地忽略他们。一开始我们我们使用了 API 来搞定这个问题,但是最终我们还是设计了专门的语法——等同于>/dev/null
:
ignore foo();
虽然我们不使用错误码模型,但无法隐式忽略一个函数的返回值对于提高系统的整体可靠性来说非常重要。你有多少次调试之后才发现,根本问题只是在于忘记使用了返回值?当然,让程序员必须使用ignore并没有真正解决问题,他们还是可能会做错事。但是这至少需要明确地说出来,并且便于代码审查。
编程模型的易用性
在基于 C 的、使用错误码的语言中,你会发现在调用函数之后需要很多if
检查语句。C 语言程序中,由于申请内存失败也是采用了返回错误码来表示,因此很多函数都可能会返回错误信息,这让写那些if变得更没意思。而如果需要返回多个值,也会让代码变得更臃肿。
提示:这是我个人主观的想法。其实有很多方法,可以让你优雅地使用错误码。不过你所能用的只是一些非常简单的结构——整数,return
,if
分支,它们也会在其他地方使用。以我的拙见,错误处理的重要性足以让它在编程语言特性当中占有一席之地,编程与语言应当辅助你完成这些工作。
Go 提供了一种语法捷径,能够是标准的错误码检查 稍微地 优雅一些:
if err := foo(); err != nil {
// 错误处理
}
我们在一行代码中实现了调用foo、并检查错误是否非nil
。非常简洁。
然而,错误码在易用性方面并不止存在上述这些问题。
很常见的是,一个函数通常会共享很多错误恢复和补救的逻辑。很多 C 语言程序员使用标签和goto来组织这些代码。比如:
int error;
// ...
error = step1();
if (error) {
goto Error;
}
// ...
error = step2();
if (error) {
goto Error;
}
// ...
// 函数正常退出
return 0;
// ...
Error:
// 根据`error`值进行一些额外处理
return error;
不用说,这样的代码天下间只有母亲才可能会喜欢。
在一些语言中,例如 D、C#、Java,finally
代码块可以用来直接实现这种“在作用域结束之前执行”的模式。类似的还有微软的 C++ 私有扩展 __finally
,即便你不认同 RAII 和异常,也可以单独使用这个扩展。D 语言提供了scope
,Go 语言提供了defer
。所有的这些都是在帮助从根本上去除goto Error
模式。
接下来考虑一下,如果我的函数想要同时返回一个值以及可能发生的错误,怎么办?我们已经占用了返回值,所以下面是两种显而易见的方案:
-
我们可以使用返回值返回两个值当中的一个(通常是错误码),而另一个(通常是真正的返回值)则通过其他方式——例如指针参数——进行返回。这在 C 语言中非常常见。
-
我们可以返回一个数据结构,这种结构既可能携带真正的返回值,也可能携带错误码。但是像是在 C 语言、甚至是 Go 语言中,由于没有参数多态,返回值会丢失类型信息,因此这种做法不常见。C++ 有模板,因此理论来说它是可以做到的,但由于 C++ 支持异常,很少有围绕返回错误码构建的生态。
结合上文中提到的性能问题,想一想这两种方法对你的程序编译后产生的汇编代码有什么影响。
从旁路返回的返回值
第一种方案,用 C 语言来实现的话,就像这样:
int foo(int* out) {
// 执行一些操作
if (failed) {
return 1;
}
*out = 42;
return 0;
}
返回值只能从旁路返回,让调用的代码显得很笨:
int value;
int ret = foo(&value);
if (ret) {
// 出错了!
}
else {
// 使用value...
}
除此之外,这种模式还会对编译器的确定性赋值分析造成干扰,例如可能会影响编译器无法给出“使用未初始化的变量”之类的警告。
针对这个问题,Go 同样提供了很漂亮的语法,得益于多返回值:
func foo() (int, error) {
if failed {
return 0, errors.New("Bad things happened")
}
return 42, nil
}
调用方也因此变得简洁很多。与上面提到的单行if检查将结合的话——虽然有点奇怪,因为初看下面的代码可能会以为value不在作用域中,但它确实在——错误检查也变得更优雅:
if value, err := foo(); err != nil {
// Error! Deal with it.
} else {
// Use value ...
}
值得注意的是,这同样有助于提醒你检查错误码。当然这也没有从根本上解决这个问题,因为函数可能会只返回错误码,这时就和 C 语言一样,很容易忘记检查。
就像上面提到的一样,一些人可能会在易用性这一点上反对我。我猜尤其是 Go 语言的设计师们。其实,Go 语言使用错误码这一点非常吸引我,它就像是对当今世界过于复杂的语言的一种反叛。我们已经丢失了 C 语言的优雅性——通常看到一行 C 语言代码,我们就能够猜出它被翻译成机器码后是什么样子的。我不会反对这些观点。事实上,相比于不受检查的异常和 Java 典型的受检查异常,我更喜欢 Go 语言的模型。最近我也写了很多 Go 代码,在我写下这篇文章的时候,想起 Go 的简洁性,我甚至在反思:Midori 是不是在 try
和 require
的道路上走的太远了?我不知道。Go 的错误模型似乎是这门语言中最具争议的一部分,也许很大程度上是因为你不能像在其他语言里那样草率地对待错误。但是,Midori 也不允许程序员草率地对待错误,可是程序员们确实很喜欢在 Midori 上编程。所以我很难做出比较。我只能确信这两种方式都能够写出可靠的代码。
使用数据结构包裹返回值
使用数据结构返回函数值函数式编程语言通常这样来解决易用性的问题:一个数据结构,既可能包含了函数返回值,也可能包含了错误码。在调用者使用返回值的时候,你必须把这个数据结构拆开,检查是否有错误,才能继续使用函数的返回值。这对多亏了数据流的编程风格。这样就很容易地避免了忘记检查错误这类严重的问题易用
举一个现代语言的例子,你可以看看 Scala 的 Option
类型。不过很遗憾的是,一些语言(例如 ML 家族,以及 Scala —— 这得归咎于 JVM)把这种优雅的模型跟不受检查的异常混合在了一起。这破坏了使用数据结构返回数据的优雅性。
Haskell 甚至更酷,它使用了错误值和局部控制流来给人一种“异常处理”的假象:
C++程序员在异常或错误返回代码谁对谁错的问题上存在着一个古老的争论。Niklaus Wirth 认为异常就是另一种 GOTO,因此在他的语言中不支持异常。Haskell 用一种折中的方式解决了这个问题:函数返回错误码,但是错误码的处理不会使代码变丑。
(译注:Niklaus Wirth,Pascal 之父,1984年图灵奖获得者。)
这里的技巧在于,Haskell 仍然支持熟悉的 throw
和 catch
模式,但是使用 Monad 而不是控制流来实现。
虽然 Rust 也使用错误码,但它提供了一种函数式的错误类型。例如,在 Go 语言中,我们有一个bar函数:我们在其中调用 foo
函数,并且简单地把foo的错误传播回调用者:
func bar() error {
if value, err := foo(); err != nil {
return err
} else {
// Use value ...
}
}
在 Rust 中,如果想写的比较长,那么与 Go 相比并没有更加简洁。下面的代码对于 C 语言程序员来说可能像是在看外语,因为我们使用了模式匹配的语法(这是个问题,但不那么严重)。如果你熟悉函数式编程,那么你应该马上就能理解这段代码,它会时刻提醒你需要处理错误:
fn bar() -> Result<(), Error> {
match foo() {
Ok(value) => /* Use value ... */,
Err(err) => return Err(err)
}
}
不过 Rust 做的更好之处在于:它提供了一个 try!
宏,减少了不必要的繁文缛节,上面那一堆代码就变成了一句:
fn bar() -> Result<(), Error> {
let value = try!(foo);
// Use value ...
}
这简直就像是个世外桃源。不过确实,这种方法仍然存在我们提到的性能问题,但它在所有其他的方面都做得非常好。此外,只有这一种模型还不够——我们还需要快速失败(Fail-Fast),即“放弃”(Abandonment)——不过就像我们马上会介绍的,Rust 的方式比其他任何广泛应用的、基于异常的模型都要好。
异常
异常的历史有点意思。在这段旅途上,我花费了不计其数的时间跟随工业界的步伐重新走了一遍,包括读了一些古老的论文——比如1975年的经典:Exception Handling: Issues and a Proposed Notation 。除此之外我还参考了几种其他语言的实现方式:Ada、Eiffel、Modula-2和3、ML、以及最受启发的, CLU。很多论文对这段漫长而艰辛的历史的总结要比我讲的好,所以我就不在这里多说了。我会主要讲述为了构建高可靠的系统,哪些方法好用、哪些方法不行。
在开发错误模型的时候,可靠性是上面列举的几个需求当中最重要的。如果你不能合理地处理故障(Failure),根据定义,你的系统就称不上可靠。正常来说,操作系统需要可靠,然而最常见的模型——不受检查的异常——在这一方面差无可差,不能再差。
由于这些原因,大多数高可靠的系统使用返回码来取代异常。这使得调用者能够进行局部分析并根据不同情况分别应对发生的错误。
不受检查的异常(Unchecked Exceptions)
快速回顾一下。在一个不受检查的异常模型中,你可以throw或是catch异常,而异常并不是类型系统或函数签名的一部分。例如:
// Foo 会抛出一个未处理的异常:
void Foo() {
throw new Exception(...);
}
// Bar 调用 Foo, 并且处理了那个异常:
void Bar() {
try {
Foo();
}
catch (Exception e) {
// Handle the error.
}
}
// Baz 也调用了 Foo, 但是没有处理那个异常:
void Baz() {
Foo(); // 异常会继续抛给调用者
}
在这个模型里,任何函数调用——有时甚至是任何 语句 ——都可以抛出一个异常,把控制流导向了不知道什么地方。没有任何标注、或是类型系统的数据能够辅助分析。所以结果是,在抛出异常的时候,任何人都很难分析出程序的状态,也很难说清在异常向调用栈上方抛出的时候程序的状态如何改变(在一个并发程序中,这可能会跨越线程之间的鸿沟),同样很难说清异常是否会被 catch,以及它被 catch 时的状态。
不过,分析程序状态还是可以一试的。这需要去读 API 的文档、做一些代码审计、严重依赖于 Code Review,并且还需要一些运气。编程语言不会给你提供丝毫帮助。由于故障很少发生,这种模型似乎并不像它听起来那么烂。我的结论是:这就是为什么在工业界很多人认为不受检查的异常“足够好”,因为这些东西在执行正常的情况下不会映入你的眼帘,而且由于大多数人在非系统程序里不会编写非常健壮的错误处理程序,这使得 通常情况下, 直接抛一个异常能够使你最快地摆脱困境。Catch 住异常然后继续执行的话,程序往往也能正常工作。无害就不受罚。从统计上来讲,程序能“工作”。
也许统计意义上的“正确”对于脚本语言来说是 OK 的,但对于最底层的操作系统来说,或者对于任何关键任务程序和应用来说,它不是一个合适的方案。我相信这没什么争议。
由于异步异常的存在,.NET 的处境非常糟糕。C++ 也有着所谓的“异步异常”:这些故障是由硬件错误出发的,例如非法访问。然而在 .NET 里,这一部分肮脏不堪:任何一个线程都可以让代码中的任何地方发生故障(Failure),甚至可以在一个赋值语句的左右操作数之间!所以在源代码中看起来像是原子操作的地方,其实不是。 关于这个话题,我在 10 年前阐述过,但如今它仍然存在。不过这个问题已经被不那么严重了,因为 .NET 终于逐渐认识到线程终止是有问题的。新的 CoreCLR 甚至不再有 AppDomain,新的 ASP.NET Core 1.0 也不会在像以前那样终止一个线程了。但是相关的 API 仍然存在。
C## 的主设计师 Anders Hejlsberg 有一段非常有名的采访,叫做 The Trouble with Checked Exceptions。从一个系统程序员的角度来看,大部分的论断都会让你抓耳挠腮。下面这一段话足以证明 C## 的目标群体是RAD 开发人员了:
Bill Venners:但是即便在一个使用不受检查的异常模型的语言里,你不是也同样 break 了他们的代码吗?如果 foo的新版本抛出了一个新的异常,使用者应该知道他们需要来处理这个新异常。他们的代码不也是被在写代码时没有纳入考量的异常破坏了吗?
Anders Hejlsberg:不。因为在大多数情况下,人们不在乎。他们不会处理任何异常。在消息循环的最底层,有一段错误处理程序,这段程序会弹出一个框框说“XX出错了”然后继续执行。程序员们总是随意地写上try finally,所以当异常发生后还是可以保持程序的正确性,但他们真的对处理异常不敢兴趣。
这让我想起了 Visual Basic 里面的 On Error Resume Next
,以及 Windows Forms 自动捕捉并忽略应用程序的异常然后尝试继续执行。我不是在指责 Anders 的观点;相反,鉴于 C## 的广泛流行,我确信在那个时间、那个环境,这是一个正确的决定。但是它显然不能用来编写一个操作系统。
C++ 至少还尝试过通过 throw
异常规范(throw exception specifications)来改善不受检查的异常。不幸的是,这个功能依赖于动态执行,这就直接给这个功能敲响了丧钟。
如果我有一个函数 void f() throw(SomeError)
,f
的函数体仍然可以调用抛出其他异常的函数。同样,如果我使用 void f() throw()
声明了 f
不会抛出异常,他仍然有可能调用会抛出异常的函数。为了实现声称的合约,编译器和运行时必须确保:如果这种情况发生,需要调用 std::unexpected
以确保进程终止。
我不是唯一一个发现这个设计是个错误的人。throw已经被抛弃了。WG21 文件 Deprecating Exception Specifications 详细描述了 C++ 是如何在这条路上停下来的。下面是这份文件的开篇陈述:
异常规范已经被证明了在实践中几乎毫无用处,同时还为程序增加了可见的负担。
作者列举了 3 条放弃支持 throw
的原因,其中两条都与动态执行有关:运行时检查(以及与其相关的不透明的失败模式)和运行时的性能负担。第三条原因是,缺乏与普通代码的协调性,这应该通过合适的类型系统来处理(当然需要付出一定的代价)。
然而,解决这个问题的手段却依赖于另一个动态构建的概念—— noexcept
标识符——而它在我眼里,跟上一个问题一样糟糕。
“异常安全”是一个在 C++ 社区经常讨论的话题。它清晰地从调用者的角度根据调用会产生的影响对函数进行划分,包括是否会失败、程序状态的转移以及内存管理。函数调用分为四种类型:no-throw 意味着之前的程序状态不会改变,而且不会有异常发生;strong safety 意味着状态的修改是原子性的,失败不会造成状态部分修改或是破坏不变量(Invariant);basic safety 意味着虽然这个函数可能会部分地修改程序状态,但不变量还是会保证正确,同时也不会发生内存泄漏;最后,no safety 意味着一切皆有可能。
(译注:不变量(Invaraint)指的是某些程序状态,它们应当始终满足约定的条件。例如,如果约定了栈顶指针始终指向栈顶,no-throw、strong safety、basic safety 函数调用都应当能够保证不论发生什么情况,栈顶指针仍然指向栈顶。下文还会有相关内容。)
这种分类方法非常好,如果你希望严格要求一个函数在发生错误时的行为,我建议你使用这种或是类似的方法对函数进行分类。就算你在使用错误码模型,这种分类也是有好处的。不过问题在于,当一个系统使用了不受检查的异常模型时,几乎不太可能遵循上述的准则。只有当一个函数处在叶子节点的位置,只调用了一些小的、便于审计的函数时,这些准则才可行。想想就知道了:为了能在所有地方都确保 strong safety,你需要考虑所有的函数调用抛出异常的可能性,然后把这段代码安全地保护起来。这通常意味着编程时需要小心谨慎、信任英语文档(也就是说计算机无法检查)、只调用noexcept函数,或是心中默默祈祷。RAII (以及更现代的智能指针)能够帮助我们解决内存泄漏问题,因此实现 basic safety 可以相对轻松一点。然而,避免破坏不变量仍然是一个麻烦事。这篇文章 Exception Handling: A False Sense of Security 很好地总结了这这些问题。
对于 C++ 来说,真正的解决方案非常容易想到,而且也很直接:对健壮的系统程序来说,不要使用异常。这是 Embedded C++ 所采用的方案,也是无数实时系统和关键任务系统的 C++ 准则,例如 NASA 的喷气推进实验室所也采用这种方案。C++ on Mars sure ain’t using exceptions anytime soon。
如果这样的话,假设你可以安全地避免使用异常,并且像 C 语言一样只返回错误码,有什么不好吗?
整个 C++ 的生态都在使用异常。为了遵循上面提到的方案,你必须避免使用异常——这是这门语言中非常重要的部分,也就导致了无法使用 C++ 生态环境中的很多库。想使用 STL?不行,它用了异常。想用 Boost?不行,它用了异常。你的内存分配器也很可能会抛出bad_alloc异常,等等。这导致了很多人都会 fork 一套已有的库,然后魔改去掉异常。例如 Windows 内核就有一套它自己的不使用异常的 STL。生态系统的这种分歧既让人难受,又难以为继。
这简直就是一团乱麻,相当多的语言都使用了不受检查的异常模型。结论很明显,这些语言不适合编写底层、高可靠的系统代码。(我这么坦率肯定会得罪很多 C++ 程序员。)在 Midori 上写了很多年代码之后,再回过头来使用不受检查的异常模型来写程序,对我简直就是一种折磨。不过值得“庆幸”的是,还有 Java 这种使用受检查异常模型的语言,也许我们可以从中学到点什么?
受检查的异常(Checked Exceptions)
啊哈,受检查的异常。它就像一个碎布娃娃,每个 Java 程序员、甚至非 Java 程序员都想凑上去揍他一顿!不过在我看来,这不太公平,毕竟还有个不受检查的异常在那垫底呢。
在 Java 中,你能知道一个方法所能抛出的大部分异常,因为它必须显示地声明出来:
void foo() throws FooException, BarException {
...
}
这样的话,foo
的调用者就能够知道它可能会抛出FooException或者BarException。程序员在调用它时必须选择:1)将这些异常原封不动地再抛给上层;2)catch 住这些异常然后处理;3)把这些异常转换为其他异常然后再抛出(很有可能会“不小心”抹掉异常的类型)。比如:
// 1) 原封不动地抛给上层:
void bar() throws FooException, BarException {
foo();
}
// 2) Catch 住然后处理
void bar() {
try {
foo();
}
catch (FooException e) {
// Deal with the FooException error conditions.
}
catch (BarException e) {
// Deal with the BarException error conditions.
}
}
// 3) 把异常的类型转换一下然后抛出:
void bar() throws Exception {
foo();
}
这与我们所期望的模型很接近了。但是,Java 的模型仍然在某些方面有所不足:
-
异常也用来传递不可恢复的 bug,比如空指针解引用、零除错误,等等。
-
其实你没办法真正知道一个方法能抛出的所有异常。这得归功于我们的RuntimeException小朋友。由于 Java 用异常来处理所有的错误(包括 Bug),语言的设计者们已经察觉到了大家会对这些异常的显式说明感到恼怒。所以他们就引入了一类不受检查的异常,也就是说,一个方法可以不加说明地抛出这些异常,调用者调用这个方法时也可以不做任何修改。
-
尽管异常类型是函数签名的一部分,在方法调用时却没什么东西来指明这个方法是否会抛出异常。
-
大家都烦它。
最后一点很有意思。在后面介绍 Midori 的错误模型时,我们会再回来回顾这一点。简单来说,人们对 Java 的受检查异常模型的厌恶感来自于前面那 3 点。最终的结果是,这个异常模型似乎在可靠性和易用性两个方面都是最差的。它并没有对写出高可靠的代码提供了什么帮助,同时还非常难用。在代码里你只能写下一大堆啰嗦的语句,而得到的好处却寥寥无几。而且需要对接口进行版本控制简直蛋疼。我们后面会看到,这个模型还有改进的空间。
“版本控制”这一点值得多说两句。如果你只抛出一种类型的异常,那么它跟错误码方案没什么区别。一个函数要么失败,要么成功。如果你的 API 的第一个版本不会失败,而第二个版本可能会失败,那么这两个版本就是不兼容的。我认为这种思路其实是正确的。API 的失败模型是 API 设计最重要的部份之一,理应与调用者说清楚,这就好像你不会悄悄地、不跟调用者说明白就把一个 API 的返回值类型改了一样。后面会有更多关于这个话题的讨论。
Barbara Liskov 在她1979年的论文 Exception Handling in CLU 中描述了 CLU 使用的一种很有意思的方法。他们很关注“语言学”——换句话说他们想要做出一门大家都喜欢的语言。在 CLU 中,调用方检查以及传播错误的方式跟错误码方案很像,同时他们的编程模型还一点声明式的味道,也就是我们现在所熟知的异常。最重要的是,signal
(即 throw
)是受检查的。程序如果抛出了一个没有写明的 signal
,他们还提供了一种很方便的方式来结束程序。
异常的通病
大多数异常系统都有几点很重要的事情做错了,不论是受检查的还是不受检查的异常。
首先,抛出一个异常总是很慢,不可思议的慢。这通常与收集栈信息有关。在一些托管平台上,收集栈信息需要遍历元数据,以便能够创建函数符号名。如果一个错误被抓住之后被合理地处理了,我们就根本不需要这些信息!诊断信息最好在与日志和诊断相关的基础设施里来实现,而不是在异常系统里。这两点问题是同时存在的。不过,要真正能够满足上面提到的诊断需求,还是需要有人能够恢复栈信息的——永远不要小看 printf 调试大法,以及栈信息对它的重要性。
其次,异常会极大地损害代码质量。我在最近的一篇文章中提到过这一点,另外还有很多论文从 C++ 的角度讨论过这个问题。缺少静态类型信息,意味着编译器很难对控制流进行建模,进而导致编译器更加倾向于保守地优化代码。
异常系统的另一个问题在于它鼓励粗粒度地处理错误。很多人喜欢返回错误码的方案,就是因为调用一个函数后就必须进行错误处理。(我也喜欢这一点。)而在异常处理系统中,人们经常用一个的try/catch块包住一大坨代码,而不是小心地去应对每一处可能出现的错误。这样的代码很脆弱,而且几乎都是错的;就算现在看起来没问题,将来等代码一重构,问题就会暴露出来。这个问题很大程度上是因为语言没有提供合适的语法导致的。
最后,throw
的控制流通常都不可见。就算是在 Java 里,异常是方法签名的一部分,也不可能通过分析函数体就能准确知道异常是从哪抛出来的。不明显的控制流带来的问题就跟 goto
、setjump/longjmp
一样大,增加了编写可靠的代码的难度。
小结
在继续到下一部分之前,我们先来总结一下吧:
优点 | 缺点 | 不是很好的地方 | |
---|---|---|---|
错误码 |
| 你可能会忘记检查错误码不发生错误时代码的性能会有所影响 | 易用性不太好 |
所有的异常系统 | 语言原生支持 |
| |
不受检查的异常 | 它有助于快速开发(Rapid Development), 即错误处理不是最重要的事情 | 任何东西都可能会失败,编译器也不会有警告 | 可靠性很差 |
受检查的异常 | 所有可能会失败的函数都会显式标注 |
| 遭人恨(至少 Java 是这样) |
如果我们能集成所有优点、再扔掉所有缺点,岂不美哉?
这是我们向前迈出的一大步,不过这还不够。我们第一次觉得思路清晰起来,能把事情都想清楚。对于某一类的错误,这些方法一个合适的都没有!