可靠软件:早崩溃、常崩溃

我越来越多地注意到软件工程中一个令人不安的趋势:任何整个程序崩溃(通过段错误、恐慌、空指针异常、断言等)都说明一个软件编写得很糟糕并且不可信任。

虽然在某些情况下,崩溃可能表明软件不可靠和以及开发方法欠佳,但崩溃也是一种有效的错误处理方法,如果使用得当可以增加软件整体质量,可靠性以及速度。

代码即责任

最终,程序中的每一行代码都可能导致软件缺陷。一个相当有害的代码缺陷是很少(或从未)使用错误处理代码。

def doSomethingWithBar(bar):
  if bar is None:
    # Can this ever actually happen?
    return False
  bar.doSomething()
  return True

在上面的 Python 例子中,我在使用 bar 之前先检查了它是否为空值。为表示失败(bar 是空值),函数会返回一个布尔值()。

这个例子可能看似故意为之,但我在代码审查中经常看到这种代码,我的评论总是:这真的会发生吗?它有时可能发生,但通常不会.工程师已经习惯以这种方式编写代码,因为他们错误地认为所有代码都必须进行错误检查。

需要检查的错误是那些正常控制流中会发生的错误。

在前面的示例中,如果 bar 永远不是空值,则 doSomethingWithBar() 可以完全从代码中删除,并且直接 doSomething()。此外,函数的调用者不再需要去了解是否需要且如何处理 doSomethingWithBar() 的错误

最后一点是至关重要的:即使这个例子是故意设计的,但类似的代码经常出现在现实的代码库中,并且这些不必要的错误处理形成的调用链,很快就使得代码逻辑无法理解了。这种无关的调用链逻辑使得代码库更大、更难以理解,更难以维护和改进,并且更可能包含错误。

如果代码的作者出错了并且 bar 实际上就是空值怎么办?那就让程序崩溃!崩溃结果和堆栈跟踪将非常明显,易于调试和修复。很明显,bar 的总是为空肯定是不正确的。同样明显的是,doSomething() 的调用者是有问题的,它应该传递一个正确的 bar。无论哪种情况,代码将保持尽可能简单,并且没有难以理解的无关逻辑。

早断言、常断言

“早崩溃、常崩溃”的必然形式是“早断言、常断言”。断言是一种非常强大的机制,用来验证代码的不变量(应该始终为真)。我喜欢两种类型的断言:

  • 调试断言:这些断言在发布版本中编译出来的,并且确信应该永远不会发生
  • 发行断言:这些断言不在发布版本编译出来的。它们用于检查理论上可能发生的情况,但如果发生了,则最好是崩溃并重启而不是尝试处理错误。

调试断言应该比发行断言多好多。

我把断言当作降低代码复杂度的强有力工具,是因为:

  1. 断言就像文档一样。当运行到断言时,读者能清晰地理解程序期望的状态
  2. 定义断言可以减少不必要的分支和错误处理。为什么要处理一个不应该发生的状态呢?

对于没有内建断言语法的语言(比如 Golang),我的推荐是做一个断言包装,如果不满足条件就让程序崩溃。

如果开发者出错了,导致一个在所有情况下都断言无效,那怎么办呢?就让程序崩溃呗。就像之前章节的空值检查,断言失败相对于调试来说是微不足道的。修复的方法可以是删除断言并添加处理代码,或者让调用代码遵循断言。

使用代码覆盖率和风格指南限制错误处理复杂度

尽管代码覆盖并不完美(高覆盖百分比并不一定意味着程序具有质量测试覆盖率),确保非常高级别的覆盖非常有用,可以自由地使用断言并将错误处理限制在必要的范围内。

Envoy 项目的错误处理指南 里我们有这么一条:

提示:如果想要添加额外的测试覆盖率,日志记录和统计数据来处理错误并继续下去看起来似乎很荒谬,因为“这应该永远不会发生”,这迹象表明更好的做法是终止进程而不处理错误。如有疑问,请讨论。

实质上,我们强制所有错误分支和断言都被测试覆盖。开发者可能觉得这个时间很浪费,但对于代码逻辑简化来说这是一个非常有用的强制功能。

用于防止崩溃的所有权语义可能导致复杂性和错误

我经常看到额外增加 复杂性 和 bug 以看起来防止崩溃的另一个地方是对象/数据所有权语义。从根本上说,在程序中可以通过三种不同的方式分配和跟踪数据:

  1. 单一属主的堆(如 C++ 中的 std::unique_ptr<>,Rust 中的标准借用检查 等)。要注意的是,在很多流行语言中这种所有权类型实际是不可用的,因为所有堆上分配的对象都是引用计数的,并且允许可能是无意的共享(如 Java,Golang,Python,JS 等)。
  2. 多属主的堆(如 C++ 中的 std::shared_ptr<>,Java,Go,Python,Rust 中的共享指针 等)

堆栈分配相对简单且易于理解,因此我将主要讨论(2)和(3)如何与尽早崩溃和代码复杂性相关。

在较高层次来看,使用单一属主的堆对象的代码比使用引用计数对象的代码更容易理解。单个代码分配数据,单个代码释放它,非常简单。另一个方案是共享所有权。共享所有权可能使代码极难理解。一个对象如何以及何时被释放?由于循环引用会不会有任何内存泄漏?(有点讽刺的是,我看到用 Java 和 Python 编写的软件中因为循环共享引用而有更多的生产内存泄漏,而大量使用单一属主语意的 C++ 却很好。)

单一属主的缺点是在 C/C++ 中容易产生“释放后使用”的情况。Rust 通过借用检查器完全避免了“释放后使用”,同时仍允许单个属主语义。从正确性和单一数据属主的角度来看,这是非常强大的,我期待着大多数代码都是用具有类似 Rust 语义的语言编写的那一天。也就是说,鉴于世界上大多数代码仍然是用 C/C++,Java,Python,JS和类似语言编写的,我将从这个角度继续讨论。

在 C/C++ 中,“释放后使用”导致的崩溃有时可能难以调试(从生产力的角度来看,再次显得 Rust 的借用语义非常具有诱惑力),但这显然说明程序崩溃了并且违反了不变量。我有时在 C/C++ 中尝试过的替代方案是类似 Java/Python 的共享对象属主,以避免这种类型的崩溃。这个想法是,如果一个对象一直有一个引用它就永远不会被释放,程序将永远不会崩溃。然而根据我的经验,这不可避免地由于循环引用而导致更大的代码复杂性和更多的错误,造成很难推理逻辑等。

仅在程序逻辑实际调用它时才使用共享内存所有权。

出于与上面提倡的限制错误处理和额外断言相同的原因,首选是使用单一属主语义。在 Rust 中,编译器将验证正确性。但在 C/C++ 中编译器则不会,但就让程序崩溃并修复未覆盖的不变违规比引入不必要的所有权和代码复杂性以试图完全避免这种类型的崩溃更可取。

对于不允许显式使用单一属主语义的语言,我的建议是将不再使用的对象的引用设置为空值。这减少了循环引用的机会,并且应该在释放问题更清晰之后有效使用。

总结

限制软件复杂度是我们用于限制缺陷的主要机制之一。通常,相对于一上来就防止崩溃的复杂代码,导致致命崩溃的代码更容易调试和修复。具体来说,我建议使用下面三种方法来限制错误处理和代码复杂性:

  • 只处理正常控制流中实际会发生的错误,其他的就崩溃吧
  • 多多使用断言来指明不变状态,如果违反了就崩溃吧
  • 尽可能使用单一属主语意来限制代码复杂度,如果在 C/C++ 中就让程序崩溃吧

上面这 3 种方法可以限制代码复杂度,并能让 bug 更明显且更容易修复。


本文翻译自 Matt Klein 发表于 meduum 的文章,原文地址在 Crash early and crash often for more reliable software

hah
Copyleft - All rights reversed. The internet is free and so is my content.