Checked Exception 的优缺点是什么?

12

你更喜欢像Java中的检查异常处理还是像C#中的未检查异常处理?为什么?


3
也许这个链接很有趣,因为Anders Hejlsberg谈到了C#中的异常处理:http://www.artima.com/intv/handcuffsP.html - crauscher
很棒的文章。我也想听听支持检查异常的另一方的论点。 - miguel
3
检查性异常最大的好处是静态分析。但这需要你正确地使用检查性异常,而不要陷入许多Java开发人员陷入的空catch块陷阱中。 - Randolpho
1
这是一个投票,最好应该是CW(社区维基)。但由于它的性质,它属于主观和争议性的范畴。 - Eddie
1
将问题改为“检查异常的优缺点是什么?”这样是否更好?我投赞成重新开放。 - Daniel Brückner
显示剩余2条评论
10个回答

17
我认为检查异常是一个失败的实验。异常最初的目标是消除需要验证每个函数调用返回值的需求,这导致了程序难以阅读,可能也效率低下,从而阻碍程序员发出和处理异常。 虽然在理论上很好,但实际上,检查异常重新引入了异常最初应该消除的问题。它们在应用程序的层之间添加了紧密的耦合。它们使得库无法在后续版本中更改其实现。crausher发布的链接详细讨论并更好地解释了这些问题。

1
我就是不理解耦合参数的论点。也许您可以详细说明一下?如果异常未经检查,客户端代码仍然可能会寻找被抛出的内容并尝试捕获它吗?或者说,所有的catch都应该捕获Exception,并且异常类型之间没有任何重要的区别? - Yishai
1
如果组件A使用组件B,而组件B又使用组件C,那么由于异常签名的存在,A就会暴露给C的细节,因此产生了耦合的问题。如果没有这个问题,B对C的使用可以对A透明化。 - Remus Rusanu
3
B可以将C的异常包装在自己的异常中,从而使A对此透明。重新抛出异常引入了耦合,我同意。公开实现的已检查异常也引入了耦合。但是,已检查异常不要求您这样做。如果没有已检查异常,那么你难道不是在说A必须假定B可能抛出任何异常吗?因此,特定的异常层次结构成为调用方应该真正忽略的实现细节?如果是这样,为什么还允许Exception子类化? - Yishai
1
捕获/包装/抛出,我们回到了起点,因为每个组件都必须为每种异常类型添加代码,即为了人工语法原因而使代码变得过于复杂。至于异常的特定性,无论如何问题都存在。您的顶层必须有一个最后的万能情况。检查异常并不能以任何方式缓解这种情况,它们只是强制将其塞入每个函数签名中(即要么在您的签名中添加“exception”,要么面对永恒的维护炼狱)。 - Remus Rusanu
1
如果编译器告诉B它必须包装C的异常或抛出C的异常,那么这是一个显式依赖关系,编译器强制你考虑这个问题。似乎您的首选设计是在catch-all中捕获所有异常,并且其他任何事情都不需要关心。在语言层面上做出这样的决定是否合适?在我的项目中,只要我看到一个已检查的异常,我就可以立即将其包装在未经检查的异常中。 - Yishai
编译器应该发出警告而不是错误,从而满足双方的需求。然而,就像泛型一样,我们只能接受现状。 - Vadim Hagedorn

16

没什么用。

当正确使用时,被检查异常是很好的东西,但更多时候它们会导致以下问题:

doSomething();
try
{
  somethingThrowsCheckedException();
}
catch(ThatCheckedException)
{ }
doSomethingElse();    

坦率地说,那样做是不对的。你应该让你无法处理的异常上浮。

如果正确使用被检查异常,它们可以很好地发挥作用。但是非常频繁地,正确处理被检查异常的结果是出现像这样的方法签名:

public void itMightThrow() throws Exception1, Exception2, Exception3, Exception4, // ...
Exception12, Exception13, /* ... */ Exception4499379874
{
  // body
}

我夸张了吗?只是稍微有点。

编辑:

话虽如此,当涉及到异常处理时,我更喜欢C#而不是Java的一件事与受检异常无关(如果我选择Spec#,我可以得到它)。不,我喜欢的是,在C#中,堆栈跟踪是在您抛出异常时填充的,而不是像Java一样在实例化异常时填充的。

编辑2: 这是针对评论者@ Yishai,@ Eddie,@ Bill K的:

首先,您应该查看此线程以获取有关如何在不实例化异常的情况下获取堆栈跟踪的信息。请记住,遍历堆栈是一项繁重的过程,不应定期执行。

第二,我喜欢C#的异常堆栈跟踪在抛出异常时填充而不是在实例化时填充的原因是您可以做以下事情:

private MyException NewException(string message)
{
   MyException e = new MyException(message);
   Logger.LogException(message, e);
   return e;
}

// and elsewhere...
if(mustThrow)
{
   throw NewException("WHOOOOPSIEE!");
}

如果没有在堆栈跟踪中包含NewException方法,那么在Java中你无法执行这个技巧。


5
任何语言都可以写出糟糕的代码。至少在这些情况下,你能立刻看出程序员是个白痴。 - Tom Hawtin - tackline
在C#中,如何获取上一个堆栈帧而不抛出异常(比如说,如果你想要获取调用该方法的类的名称)? - Yishai
@Yishai:没错。我发现使用log4J进行诊断非常有用,例如:logger.warn("This shouldn't happen", new Exception("How did we get here")); 在这里,异常并没有被抛出,只是被实例化了,但这给了我需要的诊断堆栈跟踪。 - Eddie
@Yishai,@Eddie,@Bill K:看看如何在没有异常的情况下获取堆栈跟踪的问题:https://dev59.com/FHRB5IYBdhLWcg3wuZjo - Randolpho
在Java中,你只需执行newException("WHOOOOPSIEE!").fillInStackTrace()。它会返回可抛出对象,因此你可以在throw语句中使用它。我真的认为这就像是Tomayto/Tomahto的事情。 - Yishai
显示剩余4条评论

11

对于那些无法预测的事情出错时,我更喜欢使用已检查异常。例如,IOException或SQLException。它告诉程序员他们必须考虑到可能会发生不可预测的错误,无论他们如何努力编写健壮的代码都无法避免抛出异常。

然而,太多时候程序员认为已检查异常是一种语言机制要去处理,但实际上(在良好设计的API中)它是表明操作中存在不可预测行为的指示,你应该依赖于确定性结果,也就是在相同的输入条件下始终能够正常运行该操作。

话虽如此,在实践中,已检查异常遇到了两个问题:

  1. 并非所有使用Java编写的应用都需要这种健壮性。可以添加一个编译器级别的标志来关闭已检查异常 - 虽然这可能导致API在开发者关闭标志后滥用已检查异常。经过我的进一步思考,我认为最好的平衡是使用编译器警告。如果将已检查异常作为编译器警告,则包括忽略警告几层之后的编译器警告(所以忽略警告的事实被编译进类中),以便调用者至少知道要捕获异常,即使他无法确定是哪个异常。
  2. 异常链接花了太长时间(在版本1.4中)才引入。缺乏异常链接导致很多不良习惯在早期就形成了,而不是每个人都这样做:

throw new RuntimeException(e);

当他们不知道该怎么做时。

此外,已检查异常是另一个API设计元素,可能出现设计缺陷,而API的用户必须忍受这种缺陷。

编辑:另一个答案指出了两个激发C#设计决策(不使用已检查异常)的问题。在我看来,这两个论点都非常糟糕,所以我认为有必要进行处理/平衡。

  • 版本控制。有人认为,如果您更改API实现并想添加其他已检查的异常,则会破坏现有客户端代码。
  • 可伸缩性。在不知不觉中,您可能会有一个抛出15个已检查异常的方法。
  • 我认为这两个版本都存在未解决的问题,因为当那些评论被发表时,人们已经接受了处理已检查异常上移至下一级的正确方法是通过包装适用于API抽象的不同已检查异常。例如,如果您有一个存储API可以处理IOException、SQLException或XML相关异常,那么一个正确设计的API将在通用的PersistanceException或类似内容后面隐藏这些差异。

    除了这些一般的设计指导方针之外,在具体情况下,这些论点确实引起了许多关于替代方案的问题:

    1. 版本控制。因此,开发人员根据您的数据库API进行开发,认为他们捕获和处理了相关异常(例如DatabaseException),然后您决定在下一个版本中添加NetworkException以捕获与数据库的网络级通信问题。现在,您刚刚打破了所有与现有代码的兼容性,而编译器甚至不会抱怨。每个人都可以在回归测试中发现它,如果他们很幸运。
    2. 可伸缩性。在C#解决方案中,如果在三个API级别下存在访问易失资源的潜在风险,那么您完全依赖于API文档,因为编译器不会告诉您。

    这是Web应用程序的一个很好的设计,因为死亡并显示给用户一个漂亮的错误500页面就足够了(因为交易已由容器处理)。但并非所有应用程序都考虑到这些要求。

    论点最终归结为(至少对我来说):不要担心异常,任何事情都可能出错,只需构建一个catch-all。

    那好吧。这就是已检查和未检查异常方法之间的核心区别。已检查异常向程序员发出提示,指出易变且不可预测的调用。未检查异常方法则假定所有错误条件都属于同一类别,它们只是具有不同的名称,并使它们未经检查,以便没有人会绕过它们。

    现在这些争论在CLR层面上确实有道理。我同意所有检查异常都应该在编译器层面上,而不是运行时层面上。


    2
    没错。检查异常和接口一样,都是关于 API 的。接口也有版本控制的问题,但它们仍然是不可或缺的。 - mcjabberz
    我对Java检查异常设计最大的不满可能是:(1)异常应该是检查还是未检查,应该是catch和throw站点的函数,而不是异常类型属于两个真正层次结构中的哪一个;“checked” -ness应该是实例属性; (2)声明如果嵌套方法调用引发“意外”的已检查异常,则应将其视为未经检查的异常,应该不比声明它应该通过更难,因为前者的行动可能更常见正确的。 - supercat

    7

    我从未使用过Java,但自从我阅读了以下内容:

    我非常确定我不喜欢受检异常(在当前实现中)。

    提到的两个主要观点如下。

    版本兼容性

    Anders Hejlsberg:让我们从版本控制开始,因为那里的问题很容易看到。假设我创建了一个名为foo的方法,声明它会抛出异常A、B和C。在foo的第二个版本中,我想添加一堆功能,现在foo可能会抛出异常D。为了将D添加到该方法的throws子句中,我必须进行破坏性更改,因为调用该方法的现有客户端几乎肯定不会处理该异常。

    在新版本中向throws子句添加新异常会破坏客户端代码。这就像向接口添加方法一样。发布接口后,它在所有实际用途上都是不可变的,因为任何实现可能具有您想要在下一个版本中添加的方法。因此,您必须创建一个全新的接口。同样,在异常情况下,您要么必须创建一个名为foo2的全新方法,该方法会抛出更多异常,要么必须在新foo中捕获异常D,并将其转换为A、B或C。

    可扩展性

    Anders Hejlsberg:可扩展性问题与版本兼容性问题有些相关。从小的方面来看,受检异常非常诱人。通过一个简单的示例,您可以显示您已经检查了FileNotFoundException,这不是很好吗?当您只调用一个API时,这很好。但是,当您开始构建涉及四到五个不同子系统的大型系统时,问题就开始了。每个子系统都会抛出四到十个异常。现在,每次您走上聚合阶梯时,都会有一个指数级别的异常层次结构在您下面。您最终不得不声明40个可能抛出的异常。一旦将其与另一个子系统聚合,您就会在throws子句中拥有80个异常。它就会失控。

    在大型系统中,受检异常变得非常烦人,以至于人们完全绕过了该功能。他们要么在所有地方都说“throws Exception”,要么——我无法告诉您我见过多少次——他们说:“尝试,da da da da da,catch curly curly。”他们认为,“哦,我会回来处理这些空的catch子句的”,然后当然他们从未这样做。在这种情况下,受检异常实际上降低了大型系统的质量。


    3
    你确定自己不喜欢一个从未使用过的东西吗?只因为听信某个有说服力的人说它不好?以上两点都有非常合理的反驳观点。 - Eddie
    1
    Anders Hejlsberg并不认为检查异常是不好的——他表示,它们通常是一个很好的想法(我也同意这个看法),但他认为它们并不适用于所有情况(在当前的实现中)。因此,他认为,不应该引入它们,因为它们会在某些情况下有帮助,但会使其他情况更加复杂。那么有什么反对上述说法的论点吗? - Daniel Brückner

    6

    好的,我本来不想回复,但这个问题处理太长时间了,一方面有很多答案,另一方面我觉得有必要发表一下另一方面的看法。

    我支持使用已检查异常——当它们被正确使用时——并认为它们是一件好事。我听到了上面所有的论点,对于一些反对使用已检查异常的论点,确实有一定的道理。但总体而言,我认为它们是积极的。我用过C#和Java两种编程语言,发现在C#中,程序更难以稳定地处理异常。使用已检查异常的最好之处在于,JavaDoc可以保证告诉你从该方法中可能抛出哪些异常。而在C#中,你需要依赖程序员记得告诉你任何一个给定方法可能抛出哪些异常,以及从该方法调用的任何方法可能抛出哪些异常等等。

    如果你想创建五九可靠的代码,你需要知道从你调用的代码中可能抛出哪些异常,这样你就可以推断出什么可以恢复,什么必须让你放弃正在做的事情。在C#中,你可以做到这一点,但这需要大量的试错,直到你看到所有可能抛出的异常。或者你只是捕获异常并尽力而为。

    Java和C#两种方法都有其优缺点。可以为Java和C#两种方法提出合理的论点,也可以反对它们。总之,我更喜欢Java所选择的方法,但如果我今天要重新编写Java,我会将一些已检查异常更改为运行时异常。Java API在使用已检查异常方面并不一致。正如其他人所说,Exception chaining作为标准API功能和JVM的一部分出现的时间太晚了。

    然而,关于已检查异常的指责往往落入“懒惰的程序员滥用这个语言特性”的范畴。这是真的。但这也适用于许多语言及其特性。 “懒惰的程序员”论点是一个薄弱的论点。

    让我们来看看那些不属于“懒惰的程序员”范畴的主要投诉:

    1. 可版本性 - 是的,在您代码的新版本中抛出一个新异常会打破那些盲目使用您的新JAR文件的客户端的编译。在我看来,这是一件好事情(只要您有一个抛出附加已检查异常的充分理由),因为您的库的客户必须推理出他们需要如何处理这种行为变化。如果所有异常都未经检查,那么您的客户不一定知道(直到发生异常时)您的行为已经发生了改变。如果您正在更改代码的行为,则您的客户需要知道这一点是合理的。您曾经更新过第三方库的新版本,结果发现它的行为已经不可见地改变了,现在您的程序已经无法运行?如果您对库进行破坏性行为的更改,那么您应该中断自动与使用您的库早期版本的客户机的兼容性。

    2. 可扩展性 - 如果您通过将其转换为适合于API层的特定已检查(或未经检查的)异常来正确处理已检查的异常,则此问题将成为非问题。也就是说,如果您编写得当,这个问题将消失。通过这样做,您适当地隐藏了您的实现细节,而您的调用者也不应该关心这些细节。

    往往,这只是人们的宗教信仰问题,这就是我感到(不必要的,我知道)烦恼的原因。如果您对已检查异常有宗教上的厌恶,那没关系。如果您有合理的反对已检查异常的论点,那没有关系。我见过很多合理的论据(大部分我不同意,但还是……)。但是,我经常看到一些反对已检查异常的不好的论点,其中一些论点在Java 1.0时是公平和合理的,但在现代版本的Java中已经不适用了。


    2

    实践中最好使用已检查异常处理,因为当您的应用程序在凌晨2点开始记录错误日志并且您接到调试电话时,它可以提供更详细的信息...


    我认为他的意思是,检查异常会强制你考虑如何处理异常,而不是通常的忽略它们(这种方法在一切正常时有效,但一旦出现问题,你就会陷入困境)。 - Tom Hawtin - tackline
    感谢您澄清我的评论。如果您使用已检查的异常,则必须在调用某些方法时处理X异常。在设计和编写代码时,这增加了另一个思考层面。 - Mr. Will

    2

    在我看来,存在适合使用已检查异常的情况。在Java中可能有一些特性可以用不同的方式进行实现以更好地支持它们。这并非没有困难(例如,在某些情况下,您可能希望检查异常,在其他情况下则不需要)。当然,Java也支持未检查异常类型。

    适合检查的异常类型通常应该进行文档记录。最好的记录位置是在代码中。通常只考虑成功的情况,这种民粹主义方法是错误的。


    0

    只要它们是可恢复的或不是由编程错误引起的,例如对 ResultSet 的无效索引访问,检查异常就非常好。否则,它们往往会通过强制程序员在许多方法签名中声明诸如 IOException 之类的内容来污染代码层和 API,并且对客户端代码没有真正有用的东西。


    0

    我认为在大多数情况下,检查异常都是浪费时间的。它们会陷入像Randolpho提到的反模式或广泛创建自定义异常以将实现与使用的库解耦等问题中。 摆脱这种“特性”可以让您专注于想要做的事情。


    0
    我希望编译器为我检查的唯一事情是函数是否会抛出异常。具体可以抛出哪些异常并不重要。我的经验告诉我,有很多函数根本不会抛出任何异常,如果在函数规范中有这个说明,那就太好了。对于这些函数,您无需担心异常处理。

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接