针对已检查异常的案例

2022-08-31 04:23:45

多年来,我一直无法得到以下问题的体面答案:为什么有些开发人员如此反对检查异常?我有过无数次对话,在博客上读过一些东西,读过布鲁斯·埃克尔(Bruce Eckel)所说的话(我看到的第一个人反对他们)。

我目前正在编写一些新代码,并非常仔细地关注我如何处理异常。我试图看到“我们不喜欢检查例外”人群的观点,但我仍然看不到它。

我的每一次谈话都以同样的问题结束,这个问题没有得到解答......让我设置它:

一般来说(从Java的设计方式来看),

  • Error适用于永远不应该被抓住的东西(VM对花生过敏,有人在上面掉了一罐花生)
  • RuntimeException是针对程序员做错的事情(程序员走下数组的末尾)
  • Exception(除了 )是程序员无法控制的事情(磁盘在写入文件系统时填满,进程的文件句柄限制已达到,您无法再打开任何文件)RuntimeException
  • Throwable只是所有异常类型的父级。

我听到的一个常见论点是,如果发生异常,那么开发人员要做的就是退出程序。

我听到的另一个常见论点是,检查的异常使重构代码变得更加困难。

对于“我要做的就是退出”的论点,我说即使你退出,你也需要显示一个合理的错误消息。如果您只是在处理错误时犹豫不决,那么当程序退出而没有明确说明原因时,您的用户不会过于高兴。

对于“这使得重构变得困难”的人群来说,这表明没有选择适当的抽象级别。与其声明一个方法抛出一个,不如将 转换为更适合正在发生的事情的异常。IOExceptionIOException

我对包装Main没有问题(或者在某些情况下确保程序可以优雅地退出 - 但我总是捕捉到我需要的特定异常。这样做至少可以让我显示适当的错误消息。catch(Exception)catch(Throwable)

人们从不回答的问题是这样的:

如果你抛出子类而不是子类,那么你怎么知道你应该抓住什么?RuntimeExceptionException

如果答案是 catch,那么您也以与系统异常相同的方式处理程序员错误。这在我看来是错误的。Exception

如果捕获,则以相同的方式处理系统异常和 VM 错误(等)。这在我看来是错误的。Throwable

如果答案是你只捕获你知道被抛出的异常,那么你怎么知道抛出的异常是什么?当程序员 X 抛出新的异常并忘记捕获它时会发生什么?这对我来说似乎非常危险。

我会说显示堆栈跟踪的程序是错误的。不喜欢检查异常的人不会有这种感觉?

所以,如果你不喜欢检查的例外,你能解释一下为什么不,并回答没有得到回答的问题吗?

我不是在寻找关于何时使用任何一种模型的建议,我正在寻找的是为什么人们从中扩展,因为他们不喜欢扩展和/或为什么他们捕获异常然后重新抛出而不是在他们的方法中添加抛出。我想了解不喜欢检查异常的动机。RuntimeExceptionExceptionRuntimeException


答案 1

我想我读了你做的布鲁斯·埃克尔(Bruce Eckel)的采访 - 它总是困扰着我。事实上,这个论点是由受访者(如果这确实是你正在谈论的帖子)Anders Hejlsberg提出的,他是.NET和C#背后的MS天才。

http://www.artima.com/intv/handcuffs.html

虽然我是海耶斯伯格和他的作品的粉丝,但这个论点总是让我感到虚假。它基本上可以归结为:

“检查的异常是不好的,因为程序员只是通过总是抓住它们并忽略它们来滥用它们,这会导致问题被隐藏和忽略,否则这些问题就会呈现给用户”。

通过“以其他方式呈现给用户”,我的意思是如果你使用运行时异常,懒惰的程序员就会忽略它(而不是用一个空的捕获块来捕获它),用户就会看到它。

这个论点的总结是“程序员不会正确地使用它们,没有正确使用它们比没有它们更糟糕”。

这个论点有一定的道理,事实上,我怀疑Goslings没有在Java中放置运算符覆盖的动机来自类似的论点 - 它们混淆了程序员,因为它们经常被滥用。

但最终,我发现这是海尔斯伯格的虚假论点,可能是为解释缺乏而创建的事后论点,而不是经过深思熟虑的决定。

我认为,虽然过度使用检查的异常是一件坏事,并且往往会导致用户草率地处理,但是正确使用它们可以使API程序员为API客户端程序员带来巨大好处。

现在,API程序员必须小心不要到处抛出经过检查的异常,否则它们只会惹恼客户端程序员。非常懒惰的客户端程序员将诉诸于Hejlsberg警告的捕获,所有的好处都将丧失,地狱将随之而来。但在某些情况下,一个好的检查异常是无可替代的。(Exception) {}

对我来说,经典的例子是文件开放API。语言历史上的每一种编程语言(至少在文件系统上)都有一个API,可以让你打开一个文件。每个使用此API的客户端程序员都知道,他们必须处理他们尝试打开的文件不存在的情况。让我再说一遍:每个使用此API的客户端程序员都应该知道他们必须处理这种情况。还有一个问题:API程序员能否帮助他们知道他们应该通过单独评论来处理它,或者他们是否真的坚持让客户处理它。

在C中,成语是这样的

  if (f = fopen("goodluckfindingthisfile")) { ... } 
  else { // file not found ...

其中,通过返回 0 和 C(愚蠢地)指示失败,允许您将 0 视为布尔值,并且...基本上,你学会了这个成语,你没事。但是,如果你是一个菜鸟,你没有学过这个成语呢?然后,当然,你从fopen

   f = fopen("goodluckfindingthisfile");
   f.read(); // BANG! 

并以艰难的方式学习。

请注意,我们在这里只谈论强类型语言:在强类型语言中,API有一个清晰的概念:它是功能(方法)的大杂烩,你可以为每个语言使用一个明确定义的协议。

该明确定义的协议通常由方法签名定义。这里 fopen 要求你给它传递一个字符串(在 C 的情况下是一个字符*)。如果你给它一些别的东西,你会得到一个编译时错误。您没有遵循协议 - 您没有正确使用API。

在某些(晦涩难懂的)语言中,返回类型也是协议的一部分。如果您尝试在某些语言中调用等效项,而不将其分配给变量,则还会收到编译时错误(您只能使用void函数执行此操作)。fopen()

我想说的是:在静态类型语言中,API程序员鼓励客户端正确使用API,如果客户端代码犯了任何明显的错误,则阻止客户端编译。

(在动态类型语言中,如Ruby,你可以传递任何东西,比如一个float,作为文件名 - 它将编译。如果您甚至不打算控制方法参数,为什么要用检查的异常来困扰用户。此处提出的论点仅适用于静态类型语言。

那么,检查的异常呢?

好吧,这是可用于打开文件的Java API之一。

try {
  f = new FileInputStream("goodluckfindingthisfile");
}
catch (FileNotFoundException e) {
  // deal with it. No really, deal with it!
  ... // this is me dealing with it
}

看到那个陷阱了吗?下面是该 API 方法的签名:

public FileInputStream(String name)
                throws FileNotFoundException

请注意,这是一个已检查的异常。FileNotFoundException

API程序员对你说:“你可以用这个构造函数来创建一个新的FileInputStream,但你

a) 必须将文件名作为字符串
传入 b) 必须接受在运行时可能无法找到该文件的可能性”

就我而言,这就是重点。

关键基本上是问题所说的“程序员无法控制的事情”。我的第一个想法是,他/她的意思是API程序员无法控制的事情。但实际上,正确使用时检查的异常应该真正适用于客户端程序员和API程序员无法控制的事情。我认为这是不滥用检查异常的关键。

我认为文件打开很好地说明了这一点。API程序员知道你可能会给他们一个文件名,在调用API时,这个文件名是不存在的,他们将无法返回你想要的东西,但必须抛出一个异常。他们也知道这种情况会经常发生,客户端程序员可能希望文件名在他们编写调用时是正确的,但由于他们无法控制的原因,在运行时也可能是错误的。

因此,API明确了这一点:在某些情况下,当您打电话给我时,此文件不存在,并且您已经很好地处理了它。

这一点在反案中会更清楚。想象一下,我正在编写一个表 API。我在某个地方有一个包含此方法的API的表模型:

public RowData getRowData(int row) 

现在,作为一名 API 程序员,我知道在某些情况下,某些客户端会传入行的负值或表外的行值。因此,我可能会尝试抛出一个已检查的异常并强制客户端处理它:

public RowData getRowData(int row) throws CheckedInvalidRowNumberException

(当然,我不会真的称之为“已检查”。

这是对已检查异常的不当使用。客户端代码将充满获取行数据的调用,每个调用都必须使用 try/catch,而用于什么目的?他们是否要向用户报告搜索了错误的行?可能不是 - 因为无论我的表视图周围的UI是什么,它都不应该让用户进入请求非法行的状态。所以这是客户端程序员的一个错误。

API 程序员仍然可以预测客户端将编写此类 bug,并应使用运行时异常(如 .IllegalArgumentException

在 中有一个选中的例外,这显然是一个会导致 Hejlsberg 的懒惰程序员简单地添加空捕获的情况。发生这种情况时,即使对于测试人员或调试的客户端开发人员来说,非法行值也不会很明显,而是会导致难以确定其来源的连锁错误。阿里安娜火箭将在发射后爆炸。getRowData

好吧,这就是问题所在:我是说检查的异常不仅是一件好事,而且是API程序员工具箱中必不可少的工具,用于以对客户端程序员最有用的方式定义API。但这是一个很大的不便,导致糟糕的编程,应该避免。但是如何区分。FileNotFoundExceptionCheckedInvalidRowNumberException

我想这不是一门精确的科学,我想这是海尔斯伯格论点的基础,也许在某种程度上是合理的。但是我不高兴把婴儿和洗澡水一起扔在这里,所以请允许我在这里提取一些规则,以区分好的检查的例外和坏的例外:

  1. 超出客户端控制或已关闭与打开:

    仅当错误情况不受 API 客户端程序员的控制时,才应使用已检查的异常。这与系统的开放程度或封闭程度有关。在受约束的UI中,客户端程序员可以控制,例如,从表视图(封闭系统)添加和删除行的所有按钮,键盘命令等,如果它尝试从不存在的行中提取数据,则这是客户端编程错误。在基于文件的操作系统中,任意数量的用户/应用程序都可以添加和删除文件(开放系统),可以想象客户端请求的文件在他们不知情的情况下被删除,因此应该期望他们处理它。

  2. 到处存在:

    已检查的异常不应用于客户端频繁进行的 API 调用。我经常是指从客户端代码中的很多地方 - 不经常及时。因此,客户端代码通常不会经常尝试打开同一文件,但我的表视图从不同的方法中到处都是。特别是,我将编写很多代码,例如RowData

    if (model.getRowData().getCell(0).isEmpty())
    

每次都必须在尝试/捕获中包装将是痛苦的。

  1. 通知用户:

    在可以想象向最终用户显示有用的错误消息的情况下,应使用已检查的异常。这就是我上面提出的“当它发生时你会怎么做?”的问题。它还涉及项目1。由于您可以预测客户端 API 系统外部的某些内容可能会导致文件不存在,因此您可以合理地告诉用户:

    "Error: could not find the file 'goodluckfindingthisfile'"
    

    由于您的非法行号是由内部错误引起的,并且不是用户的过错,因此您实际上无法向他们提供有用的信息。如果你的应用不让运行时异常传递到控制台,它最终可能会给他们一些丑陋的消息,比如:

    "Internal error occured: IllegalArgumentException in ...."
    

    简而言之,如果您认为您的客户端程序员无法以有助于用户的方式解释您的异常,那么您可能不应该使用已检查的异常。

所以这些是我的规则。有点人为的,毫无疑问会有例外(如果你愿意,请帮我完善它们)。但我的主要论点是,在某些情况下,选中的异常与参数类型一样重要且有用的API协定的一部分。因此,我们不应该仅仅因为它被滥用而放弃它。FileNotFoundException

抱歉,不是故意要让它变得如此漫长和华而不实。最后,我要提出两点建议:

答:API 程序员:谨慎使用已检查的异常以保持其有用性。如有疑问,请使用未选中的异常。

B:客户端程序员:养成在开发早期创建包装异常(谷歌搜索)的习惯。JDK 1.4 及更高版本为此提供了一个构造函数,但您也可以轻松创建自己的构造函数。下面是构造函数:RuntimeException

public RuntimeException(Throwable cause)

然后养成这样一个习惯:每当你必须处理一个已检查的异常并且你感到懒惰时(或者你认为API程序员首先过于热衷于使用已检查的异常),不要只是吞下异常,包装它并重新抛出它。

try {
  overzealousAPI(thisArgumentWontWork);
}
catch (OverzealousCheckedException exception) {
  throw new RuntimeException(exception);  
}

将其放在IDE的一个小代码模板中,并在您感到懒惰时使用它。这样,如果您确实需要处理已检查的异常,则在运行时看到问题后,您将被迫返回并处理它。因为,相信我(和Anders Hejlsberg),你永远不会回到你的TODO

catch (Exception e) { /* TODO deal with this at some point (yeah right) */}

答案 2

关于已检查的异常的事情是,根据对概念的通常理解,它们并不是真正的异常。相反,它们是 API 替代返回值。

异常的整个概念是,在调用链的某个地方抛出的错误可以冒泡并由更远的某个地方的代码处理,而干预代码不必担心它。另一方面,检查的异常要求抛出器和捕获器之间的每个代码级别声明它们知道可以通过它们的所有形式的异常。这在实践中与检查的异常只是调用方必须检查的特殊返回值几乎没有什么不同。例如。[伪代码]:

public [int or IOException] writeToStream(OutputStream stream) {
    [void or IOException] a= stream.write(mybytes);
    if (a instanceof IOException)
        return a;
    return mybytes.length;
}

由于Java不能执行替代返回值,或者不能执行简单的内联元组作为返回值,因此检查的异常是合理的响应。

问题在于,许多代码,包括大量标准库,误用检查异常,用于真正的异常条件,你可能非常想赶上几个级别。为什么 IOException 不是 RuntimeException?在所有其他语言中,我都可以让IO异常发生,如果我不做任何事情来处理它,我的应用程序将停止,我将获得一个方便的堆栈跟踪来查看。这是可能发生的最好的事情。

也许从示例中可以看出两种方法,您希望从整个写入流过程中捕获所有IOExceptions,中止该过程并跳转到错误报告代码;在Java中,如果不在每个调用级别添加“throws ioException”,即使是本身不执行IO的级别,您也无法做到这一点。这些方法不需要知道异常处理;必须在其签名中添加例外:

  1. 不必要地增加耦合;
  2. 使接口签名非常难以更改;
  3. 使代码的可读性降低;
  4. 非常烦人,以至于程序员的常见反应是通过执行一些可怕的事情来击败系统,例如“throws Exception”,“catch (Exception e) {}”,或者将所有内容包装在 RuntimeException 中(这使得调试更加困难)。

然后有很多荒谬的库例外,例如:

try {
    httpconn.setRequestMethod("POST");
} catch (ProtocolException e) {
    throw new CanNeverHappenException("oh dear!");
}

当你不得不用像这样的荒谬的粗陋来混乱你的代码时,难怪检查的异常会收到一堆仇恨,即使这真的只是简单的糟糕的API设计。

另一个特别不好的影响是在控制反转上,组件A向通用组件B提供回调,组件A希望能够让异常从其回调回它调用组件B的地方,但它不能,因为这会改变由B修复的回调接口。A 只能通过将真正的异常包装在 RuntimeException 中来做到这一点,RuntimeException 是要编写的更多异常处理样板。

在Java及其标准库中实现的已检查异常意味着样板,样板,样板。在已经很冗长的语言中,这不是一场胜利。