二、Bug 不是可恢复错误!

Bugs Aren’t Recoverable Errors!

Cover Image

关于可恢复错误和 Bug,我们之前提到过二者之间明确的界限:

  • 可恢复错误通常都是程序化数据验证的结果。有时候程序会检查这个世界的状态,但认为当前的状态不能接受——比如解析一些标签语言、解析用户在网站上的输入、或是网络不稳定等等。在这些情况下,程序应该恢复执行。写程序的开发人员必须提前安排好要怎么处理这些情况,因为不论我们怎么做,都避免不了这些状况。程序给出的响应可能是给用户返回一些信息、重试或者直接放弃这个操作。虽然它们也被称作“错误”,但这些错误都是可预测的,而且对于这些状况,程序通常是有准备的。

  • Bug 是一类程序员无法预测的错误。用户输入没有被正确地验证,或是逻辑写错了,等等。这类问题一旦发生,就会极大地损害程序状态;然而它们有时候很隐蔽,甚至要等到它们让别处的代码出现了问题后,才会被间接发现。由于程序员并没有想到会发生这些情况,我们就前功尽弃了。那段有问题的代码所能修改的所有的数据结构都可能已经损坏了,同时因为这些问题没有被直接检测到,很大可能所有的东西都已经出了问题。根据你使用的语言所提供的隔离性,也许整个进程都已经被污染了。

这些区别至关重要。令人惊讶的是,很多系统完全不区分它们,至少没有以一种原则性的方式来区分!正如我们所见,Java、C#以及很多动态语言使用异常、 而 C 和 Go 使用错误码,来同时应对这两种错误。C++ 则使用了一种混合的方式,这取决于使用者如何选择,但常见的情况是一个项目只选择一种方法使用。通常来说,没什么语言建议使用两种不同的方法来处理错误。

考虑到 Bug 本身是不可恢复的,我们不需要尝试去恢复执行。在运行时检测到的所有 Bug 需要触发“放弃”,这是 Midori 中的术语,用来指代“Fail-Fast”。

上面提到的所有系统都提供了类似于放弃的机制。C# 提供了 Environment.FailFast;C++ 提供了 std::terminate;Go 提供了 panic,Rust 也有 panic!,等等。它们都能让你迅速脱离当前的上下文。上下文的范围取决于系统——比如 C# 和 C++ 会结束进程,Go 会结束当前的 Goroutine,Rust 会结束当前的线程,还可选附带一个错误处理程序来拯救整个进程。

虽然我们对放弃机制的使用比一般语言更多,但我们显然不是第一个注意到这种方案的。这篇 Haskell 文章就讲的非常好:

我曾经参与过编写一个 C++ 库。有一个开发人员对我说,开发者们分成了两派:一派喜欢使用异常,一派喜欢使用返回值。在我看来,返回值派赢得了这场战争的胜利。然而,我却觉得他们所讨论的论点就是错误的:异常和返回值在表达能力上是等价的,它们不应该用来表示错误。很多返回值包含了这样的常量定义:ARRAY_INDEX_OUT_OF_RANGE,但我很不解:如果在调用其他函数时遇到了一个这样的错误,我的函数究竟能做点什么呢?需要发一封邮件给我吗?当然,这个错误码还可以返回给更上层的函数,但是更上层的函数也会不知所措。更糟糕的是,由于我不清楚所有的细节,我只能假设我所调用的所有函数都会返回数组越界的错误。我的结论是:数据越界是一种(编程)错误,在运行时它无法被处理或是修复,只能被开发人员手工修正。因此它不应该使用返回值来表示,而应该使用断言(asserts)。

我有点怀疑只放弃细粒度的可变共享内存(例如 Goroutine、线程之类的东西)的正确性,除非你的系统对潜在损害的范围有所保证。不管怎么说,有这些机制就是件好事!这说明了在这些语言里实践“放弃”原则是可行的。

然而,如果想让这种方法在大规模的程序上也可行,我们还需要一点架构设计上的思考。我敢肯定你现在在想:“如果在我的 C# 程序里,每遇到一次空指针错误就结束掉整个进程,我肯定会被客户怼的”,或者“这根本就不叫可靠!”。可靠性,也许并不是你想的那样。