Checked vs. Unchecked Exceptions: The Debate Is Not Over

The following text is a partial translation of the original English article, performed by ChatGPT (gpt-3.5-turbo) and this Jekyll plugin:

我们真的需要检查异常吗?辩论已经结束了,不是吗?对我来说并不是这样。虽然大多数面向对象的语言都没有它们,大多数程序员认为检查异常是Java的错误,但我相信相反的观点——非检查异常才是错误。此外,我还认为多个异常类型也是一个坏主意。

首先,让我解释一下我对面向对象编程中异常的理解。然后,我将把我的理解与“传统”方法进行比较,并讨论它们之间的差异。所以,先说说我的理解。

假设有一个方法,将一些二进制数据保存到文件中:

当一切顺利时,该方法只是保存数据并返回控制权。当出现问题时,它会抛出Exception,我们必须对此采取一些措施:

当一个方法说它“抛出”异常时,我理解这个方法并不“安全”。它有时可能会失败,而我有责任要么处理这个失败,要么将自己声明为同样“不安全”。

我知道每个方法都是根据单一职责原则设计的。这对我来说是一个保证,如果方法save()失败,那意味着整个保存操作无法完成。如果我需要知道这个失败的原因是什么,我会解开异常链—遍历包含在ex中的异常链和堆栈跟踪。

我从不将异常用于流程控制,这意味着我从不恢复抛出异常的情况。当异常发生时,我让它传递到应用程序的最高级别。有时我会重新抛出它,以添加更多语义信息到异常链中。这就是为什么对于save()抛出的异常的原因对我来说无关紧要。我只知道方法失败了。对我来说,这就足够了。总是如此。

出于同样的原因,我不需要区分不同的异常类型。我就不需要那种层次结构。Exception对我来说已经足够了。再说一遍,这是因为我不使用异常来进行流程控制。

这就是我对异常的理解。

根据这种范式,我会说我们必须:

  • 永远不要抛出/使用未经检查的异常。

  • 只使用 Exception,不要使用任何子类型。

  • 始终在throws块中声明一种异常类型。

  • 永远不要捕获异常而不重新抛出;在这里阅读更多相关内容。

这种范例与我在这个主题上找到的许多其他文章不同。让我们进行比较和讨论。

Oracle表示一些异常应该是API的一部分(被检查的异常),而另一些异常是运行时异常,不应该是API的一部分(未检查的异常)。它们将在JavaDoc中进行记录,但不会在方法签名中显示。

我不明白这里的逻辑,我确信Java设计者也不明白。为什么有些异常重要而其他的不重要?为什么其中一些值得在方法签名的throws块中占据合适的API位置,而其他的不值得?标准是什么?

我在这里有一个答案。通过引入检查的和未检查的异常,Java开发人员试图解决方法过于复杂和混乱的问题。当一个方法太大并且同时做了太多的事情(违反了单一职责原则)时,让我们保持一些异常“隐藏”(也就是未检查的异常)肯定是更好的选择。但这并不是一个真正的解决方案,它只是一个短期的修补程序,对我们所有人都更有害处——方法不断增长并变得越来越复杂。

未检查的异常是Java设计中的一个错误,而不是检查的异常。

隐藏一个方法可能在某个点失败的事实是一个错误。这正是未检查的异常所做的。

相反,我们应该让这个事实可见。当一个方法做了太多的事情时,就会出现太多的失败点,方法的作者会意识到有些地方出了问题——一个方法不应该在这么多的情况下抛出异常。这将导致重构。未检查异常的存在导致了混乱。顺便说一下,在Ruby、C#、Python、PHP等语言中根本不存在检查的异常。这意味着这些语言的创建者理解面向对象编程的程度比Java的作者还要低。

另一个反对使用检查异常的常见论点是它使我们的代码更冗长。我们必须到处放置try/catch,而不能专注于主要逻辑。Bozhidar Bozhanov甚至在此处提出了解决这个冗长问题的技术解决方案。

我对这种逻辑仍然感到困惑。如果我想在save()方法失败时执行某些操作,我会捕获异常并处理这种情况。如果我不想这么做,我只需要声明我的方法也会throws异常,并不关注异常处理。问题在哪里?冗长从何而来?

我也有一个答案。冗长来自于未检查异常的存在。我们无法总是忽略失败,因为我们使用的接口不允许我们这样做。就是这样。例如,Runnable类广泛用于多线程编程,它的方法run()不应该抛出任何异常。这就是为什么我们总是必须在方法内部捕获一切并将检查异常重新抛出为未检查异常。

如果所有Java接口中的所有方法都声明为“安全”的(不抛出任何异常)或“不安全”的(throws Exception),一切都会变得合乎逻辑和清晰明了。如果你想保持“安全”,就要对失败处理负责。否则,就“不安全”起来,让你的用户担心安全问题。

没有噪音,非常干净的代码,逻辑明显。

【有人说】将一个已检查异常放在方法签名的throws中,而不是在此处捕获并重新抛出新类型的异常,鼓励我们在方法签名中有太多无关的异常类型。例如,我们的方法save()可能声明它可能抛出OutOfMemoryException,即使它似乎与内存分配无关。但它确实分配了一些内存,对吧?所以在文件保存操作期间可能发生内存溢出。

然而,我不明白这个论点的逻辑。如果所有异常都是已检查的,并且我们没有多个异常类型,我们只需随处抛出Exception,就行了。我们为什么需要关心异常类型呢?如果我们不使用异常来控制流程,我们就不会这样做。

如果我们真的想要使我们的应用程序具有抵抗内存溢出的能力,我们会引入一些内存管理器,它将有类似bigEnough()方法,用来告诉我们我们的堆是否足够大以进行下一步操作。在这种情况下,使用异常是面向对象编程中异常管理的完全不适当的方法。

在《Effective Java》一书中,Joshua Bloch提到要“对于可恢复的情况使用受检异常,而对于编程错误使用运行时异常。”他的意思是类似于这样的情况:

这跟一个名为“不要使用异常来控制流程”的著名反模式有什么不同呢?Joshua,恕我直言,你错了。在面向对象编程中不存在可恢复的条件。异常表示从一个方法到另一个方法的调用链被中断,现在是时候沿着这个链向上走并停在某个地方了。但是一旦发生异常,我们就不会再返回了。

我们可以重新开始这个链,但是在throw之后我们不会回退。换句话说,在catch块中我们不会执行任何操作。我们只是报告问题并结束执行。我们永远不会“恢复!”

所有反对检查异常的论点都只能证明它们的作者对面向对象编程有严重的误解。在Java和许多其他语言中,错误在于存在未检查的异常,而不是已检查的异常。

Translated by ChatGPT gpt-3.5-turbo/42 on 2023-12-27 at 10:20

sixnines availability badge   GitHub stars