为什么不能将异常用作常规的控制流程?

226
为了避免我可以通过谷歌搜索到的所有标准答案,我将提供一个例子供大家随意攻击。
C#和Java(以及太多其他语言)在许多类型中都具有某些“溢出”行为,我根本不喜欢它(例如:type.MaxValue + type.SmallestValue == type.MinValue,例如: int.MaxValue + 1 == int.MinValue)。
但是,鉴于我的恶毒天性,我会将此行为扩展到比如重写的类型。 (我知道在.NET中是sealed的,但为了这个例子,我使用了一种与C#完全相同的伪代码,除了DateTime不是sealed之外)。
重写的方法:
/// <summary>
/// Increments this date with a timespan, but loops when
/// the maximum value for datetime is exceeded.
/// </summary>
/// <param name="ts">The timespan to (try to) add</param>
/// <returns>The Date, incremented with the given timespan. 
/// If DateTime.MaxValue is exceeded, the sum wil 'overflow' and 
/// continue from DateTime.MinValue. 
/// </returns>
public DateTime override Add(TimeSpan ts) 
{
    try
    {                
        return base.Add(ts);
    }
    catch (ArgumentOutOfRangeException nb)
    {
        // calculate how much the MaxValue is exceeded
        // regular program flow
        TimeSpan saldo = ts - (base.MaxValue - this);
        return DateTime.MinValue.Add(saldo)                         
    }
    catch(Exception anyOther) 
    {
        // 'real' exception handling.
    }
}
当然,if语句同样可以轻松解决这个问题,但事实仍然是我不明白为什么不能使用异常(从逻辑上讲,我可以看出在某些情况下应该避免使用异常来提高性能)。
我认为在许多情况下,异常比if结构更清晰,并且不会违反方法所做的任何契约。
我认为,“永远不要将它们用于常规程序流程”这种反应并不像人们似乎认为的那么有根据,因为这种反应的强度无法证明。
或者我错了吗?
我已经阅读了其他帖子,涉及各种特殊情况,但我的观点是,如果你既清晰又尊重你方法的契约,那么使用异常也没有什么问题。
别打我。

3
+1 我有同感。除了性能之外,避免使用异常来控制流程的唯一好理由是当调用者代码使用返回值时会更易读。 - Iraimbilanja
4
如果发生了某事,返回-1,如果发生了其他事情,则返回-2等等……这种方式真的比异常更易读吗? - kender
2
很遗憾,一个人因为说实话而得到负面声誉:你的例子不可能用if语句来编写。(这并不意味着它是正确/完整的。) - Ingo
8
我认为,在某些情况下,抛出异常可能是您唯一的选择。例如,我有一个业务组件,它通过查询数据库在构造函数中初始化其内部状态。有时候,数据库中没有适当的数据可用。在构造函数中抛出异常是有效取消对象构造的唯一方法。这在类的合同(在我的情况下是Javadoc)中明确说明,因此,当创建该组件并从那里继续时,客户端代码可以(并且应该)捕获该异常。 - Stefan Haberl
1
既然你提出了一个假设,那么你有责任引用证明该假设的证据或理由。首先,列举一个原因,说明为什么你的代码比一个更短、自我说明的“if”语句更优秀。你会发现这非常困难。换句话说:你的前提是错误的,因此你从中得出的结论也是错误的。 - Konrad Rudolph
显示剩余14条评论
24个回答

207

你有没有试过调试一个程序,在正常操作过程中每秒引发五个异常?

我曾经有过这样的经历。

那个程序非常复杂(是一个分布式计算服务器),程序的一个小修改可能会在完全不同的地方造成问题。

我希望我可以只启动程序并等待异常发生,但在启动期间就已经出现了大约200个异常在正常操作过程中

我的观点: 如果您用异常来处理正常情况,那么如何定位异常(即异常)的情况?

当然,还有其他很重要的原因不要过度使用异常,尤其是在性能方面。


17
例如:当我调试一个 .net 程序时,我会从 Visual Studio 中启动它,并要求 VS 在所有异常发生时中断程序。如果你把异常作为预期行为的一部分,那么我就不能这样做了(因为这将导致每秒中断 5 次),而且更难定位代码中的问题所在。 - Brann
20
感谢指出您不想创建一个异常的"干草堆"来寻找一个实际的例外"针"。+1 - Grant Wagner
18
我会尽力进行翻译:我完全不理解这个答案,我认为人们在这里存在误解。它与调试完全无关,而是与设计有关。恐怕这是循环推理的最纯形式。而且你的观点实际上离题了,就像之前所述的一样。 - Peter
20
没有在异常处中断调试是很困难的,而捕捉所有异常如果设计上有很多异常的话会很繁琐。我认为,使调试变得困难的设计几乎是部分不完整的(换句话说,设计与调试有关系)。 - Brann
9
即使忽略我想要调试的大多数情况并不对应于异常被抛出,回答你的问题是:“按类型”,例如,我会告诉我的调试器仅捕获 AssertionError 或 StandardError 或其他对应于糟糕事情发生的异常类型。如果你对此有困惑,那么你如何进行日志记录呢?难道你不是通过级别和类记录日志,以便可以根据它们进行过滤吗?你认为这也是个坏主意吗? - Ken
显示剩余10条评论

180
异常基本上是具有后效的非本地goto语句。使用异常来控制流程违反了最小惊讶原则,会使程序难以阅读(请记住,程序首先是为程序员编写的)。此外,这不是编译器供应商所期望的。他们希望异常很少被抛出,并且通常会让throw代码变得相当低效。在.NET中,抛出异常是最昂贵的操作之一。然而,一些语言(特别是Python)使用异常作为流控制结构。例如,如果没有其他项,迭代器会引发StopIteration异常。甚至标准语言结构(如for)也依赖于此。

13
异常并不是令人惊讶的事情!当你说“这是个坏主意”,然后又说“但在Python中这是个好主意”时,你有点自相矛盾。 - hasen
7
我仍然完全不信服:1)效率并不重要,许多非批处理程序根本不在意(例如用户界面)2)令人惊讶的是:正如我所说,这很惊人,因为它并没有被使用,但问题仍然存在:为什么不一开始就使用id?但既然这就是答案... - Peter
6
实际上,我很高兴你指出了Python和C#之间的区别。我认为这并不矛盾。Python更加动态,以这种方式使用异常的预期已经融入到语言中。这也是Python EAFP文化的一部分。我不知道哪种方法在概念上更纯粹或更自洽,但我喜欢编写符合他人期望的代码的想法,这意味着不同语言采用不同的风格。 - Mark E. Haase
17
goto不同,异常会正确地与调用栈和词法作用域进行交互,并且不会使栈或作用域处于混乱状态。 - Lukas Eder
5
实际上,大多数虚拟机供应商都期望异常,并且能够有效地处理它们。正如@LukasEder所指出的那样,异常完全不像goto语句,因为它们是有结构的。 - Marcin
显示剩余8条评论

39
我的经验法则是:
  • 如果可以采取任何措施来恢复错误,则捕获异常
  • 如果错误非常常见(例如用户试图使用错误的密码登录),请使用返回值
  • 如果无法采取任何措施来恢复错误,请将其保留未捕获(或在主要的catcher中捕获它,以执行一些半优美的应用程序关闭操作)
关于异常的问题,我从纯语法角度看到的问题是(我相信性能开销很小)。我不喜欢到处都是try块的情况。
以这个例子为例:
try
{
   DoSomeMethod();  //Can throw Exception1
   DoSomeOtherMethod();  //Can throw Exception1 and Exception2
}
catch(Exception1)
{
   //Okay something messed up, but is it SomeMethod or SomeOtherMethod?
}

另一个例子可能是当你需要使用工厂将某个东西分配给一个句柄时,而该工厂可能会抛出异常:

Class1 myInstance;
try
{
   myInstance = Class1Factory.Build();
}
catch(SomeException)
{
   // Couldn't instantiate class, do something else..
}
myInstance.BestMethodEver();   // Will throw a compile-time error, saying that myInstance is uninitalized, which it potentially is.. :(

个人认为,您应该为罕见的错误条件(如内存不足等)保留异常,并使用返回值(值类、结构体或枚举)来进行错误检查。

希望我正确理解了您的问题 :)


4
为什么不在try块中,在Build()之后直接调用BestMethodEver呢?如果Build()抛出异常,它将不会被执行,编译器也会满意。 - Blorgbeard
3
是的,那可能是你最终得到的结果,但考虑一个更复杂的例子,其中myInstance类型本身也可能会抛出异常...而且方法作用域内的其他实例也可能会抛出异常。你最终将得到许多嵌套的try/catch块:( - cwap
你应该在catch块中进行异常翻译(转换为适合抽象级别的异常类型)。FYI: "Multi-catch" 预计将被引入Java 7。 - jasonnerothin
FYI:在C++中,您可以在try后放置多个catch来捕获不同的异常。 - RobH
2
对于收缩包软件,您需要捕获所有异常。至少要弹出一个对话框,解释程序需要关闭,并提供一些难以理解的内容,以便您可以发送错误报告。 - David Thornley

27

对许多答案的第一反应:

你是在为程序员编写程序和最小惊讶原则

当然!但是if并不总是更清晰。它不应该令人惊讶,例如:catch (divisionByZero)除法(1/x)比任何if都要清晰(就康拉德和其他人而言)。事实上,这种编程方式并不是预期的,纯粹是惯例,而且仍然相关。也许在我的例子中,if会更清晰。但是DivisionByZero和FileNotFound等异常情况比if更清晰。

当然了,如果它性能不佳,并且每秒需要使用无数次,则应避免使用它,但仍然没有读到任何避免整体设计的好理由。

至于最小惊异原则:存在循环推理的危险:假设整个社区都使用了糟糕的设计,那么这种设计将变得预期!因此,这个原则不能成为圣杯,应谨慎考虑。

针对正常情况的异常,如何定位异常(即异常)情况?

在许多回应中,像这样的问题都会浮现出来。只需要捕获它们,不是吗?您的方法应该清晰、文档化,并尊重其合同。我必须承认我不理解那个问题。

对所有异常进行调试:相同的情况只是因为不使用异常的设计很常见。我的问题是:为什么一开始就很常见?


2
  1. 你在调用 1/x 前总是检查 x 吗?
  2. 你是否将每个除法操作都包装在 try-catch 块中以捕获 DivideByZeroException
  3. 你在 catch 块中放置了什么逻辑来从 DivideByZeroException 中恢复?
- Lightman
2
除了DivisionByZero和FileNotFound,它们是应该被视为异常情况而需要特殊处理的坏例子。 - 0x6C38
1
在这里,"反异常"人士所吹嘘的文件未找到并没有什么“过于特别”的地方。openConfigFile(); 可以跟随一个被捕获的 FileNotFound,其中包含 { createDefaultConfigFile(); setFirstAppRun(); } 优雅地处理 FileNotFound 异常;没有崩溃,让我们让最终用户的体验更好,而不是更糟。你可能会说:“但如果这不是真正的第一次运行,他们每次都会遇到这个问题怎么办?”至少应用程序每次都能运行,而不是每次启动都崩溃!在1到10的“这很糟糕”中,“第一次运行”每次启动=3或4,崩溃每次启动=10。 - Loduwijk
你的例子是例外。不,你并不总是在调用1/x之前检查x,因为通常情况下都没问题。只有在不正常的情况下才需要考虑异常情况。我们并不是在谈论什么大事,但是例如对于一个基本整数给定一个随机的x,只有4294967296中的1个会无法进行除法运算。这是例外情况,而异常处理是处理这种情况的好方法。然而,您可以使用异常来实现类似于“switch”语句的等效操作,但这样做可能相当愚蠢。 - Vala

24
在C语言中,在异常出现之前,可以使用`setjmp`和`longjmp`来实现类似的堆栈帧展开。
然后,这个相同的结构被赋予了一个名字:“异常”。大多数答案都依赖于这个名字的含义来讨论它的用法,并声称异常应该在异常情况下使用。但在最初的`longjmp`中,这并不是本意。只是有一些情况下,你需要跨越多个堆栈帧来打破控制流。
异常稍微更加通用,你可以在同一个堆栈帧中使用它们。这引发了与`goto`的类比,我认为这是错误的。Goto是一个紧密耦合的一对(`setjmp`和`longjmp`也是如此)。异常遵循一种松散耦合的发布/订阅模式,更加清晰!因此,在同一个堆栈帧中使用它们几乎不同于使用`goto`。
混淆的第三个来源与它们是被检查的异常还是未检查的异常有关。当然,未检查的异常在控制流中使用起来似乎特别糟糕,也许还有很多其他问题。
然而,对于控制流来说,被检查的异常非常好用,一旦你克服了所有的维多利亚时代的顾虑,稍微放开一点。
我最喜欢的用法是在一段长代码中使用一系列的throw new Success(),它会尝试一个接一个的操作,直到找到所需的结果为止。每个操作,每个逻辑片段,都可能有任意的嵌套,所以break和任何条件测试都不适用。而if-else的模式则很脆弱。如果我删除了一个else或者在其他方面搞乱了语法,那就会出现一个棘手的错误。
使用throw new Success()可以使代码流程线性化。我使用本地定义的Success类(当然是经过检查的),这样如果我忘记捕获它,代码就无法编译。而且我也不会捕获其他方法的Success
有时候我的代码会逐个检查各种条件,只有当一切都正常时才会成功。在这种情况下,我会使用类似的线性化方法,使用throw new Failure()
使用一个单独的函数会干扰代码的自然分隔级别。所以return的解决方案并不是最佳选择。出于认知的原因,我更喜欢将一页或两页的代码放在一个地方。我不相信过于细分的代码。

对我来说,JVM或编译器的选择并不重要,除非有一个热点。我无法相信编译器在机器码级别上不能检测到本地抛出和捕获的异常,并将其简单地视为非常高效的goto

就控制流而言,即在常见情况下而非异常情况下使用它们,我无法看出它们比多个break、条件测试和返回操作更低效,而这些操作需要穿越三个堆栈帧,而不仅仅是恢复堆栈指针。

个人而言,我不会在堆栈帧之间使用这种模式,我可以理解这需要设计上的复杂性才能优雅地实现。但是,适度使用应该是可以的。

最后,关于给初学者带来惊喜,这并不是一个令人信服的理由。如果你温和地向他们介绍这种做法,他们会学会喜欢它的。我记得C++曾经让C程序员感到惊讶和害怕。


4
使用这种模式,我的大部分粗略函数都在末尾有两个小陷阱--一个是成功的,一个是失败的,那里包括了函数的封装,例如准备正确的servlet响应或准备返回值。 在同一个地方封装代码会很方便。使用“return”模式的替代方法需要为每个这样的函数编写两个函数。一个外部函数用于准备servlet响应或其他类似操作,另一个内部函数用于进行计算。PS:英语教授可能会建议我在最后一段中使用“astonishing”而不是“surprising” :-) - necromancer

14

标准答案是异常不规则,并且应该在特殊情况下使用。

一个我认为很重要的原因是,当我在维护或调试软件时读到try-catch控制结构时,我会尝试找出原始编码器为什么使用异常处理而不是if-else结构,并期望找到一个好的答案。

请记住,你写代码不仅是为了计算机,也是为了其他程序员。与异常处理程序相关联的语义不能仅仅因为机器不介意就被丢弃。


我认为这是一个被低估的答案。当计算机发现异常被吞噬时,它可能不会减慢太多速度,但当我在处理别人的代码时,如果我遇到这种情况,它会让我停下来,因为我需要弄清楚是否有重要的东西我不知道,或者是否实际上没有理由使用这种反模式。 - Tim Abell

12

Josh Bloch在《Effective Java》一书中广泛探讨了这个主题。他的建议很有启发性,也适用于.NET(除了细节方面)。

特别地,异常应该只用于非常规情况。原因主要是与可用性相关。为了使给定的方法最大限度地可用,它的输入和输出条件应该受到最大限度的约束。

例如,第二种方法比第一种更容易使用:

/**
 * Adds two positive numbers.
 *
 * @param addend1 greater than zero
 * @param addend2 greater than zero
 * @throws AdditionException if addend1 or addend2 is less than or equal to zero
 */
int addPositiveNumbers(int addend1, int addend2) throws AdditionException{
  if( addend1 <= 0 ){
     throw new AdditionException("addend1 is <= 0");
  }
  else if( addend2 <= 0 ){
     throw new AdditionException("addend2 is <= 0");
  }
  return addend1 + addend2;
}

/**
 * Adds two positive numbers.
 *
 * @param addend1 greater than zero
 * @param addend2 greater than zero
 */
public int addPositiveNumbers(int addend1, int addend2) {
  if( addend1 <= 0 ){
     throw new IllegalArgumentException("addend1 is <= 0");
  }
  else if( addend2 <= 0 ){
     throw new IllegalArgumentException("addend2 is <= 0");
  }
  return addend1 + addend2;
}

无论哪种情况,你都需要检查调用者是否适当地使用了你的API。但在第二种情况下,你需要它(隐式)。如果用户没有阅读Javadoc,则仍将抛出软异常,但是:

  1. 您不需要对其进行文档记录。
  2. 您不需要测试它(取决于您有多么积极的单元测试策略)。
  3. 您不要求调用者处理三个用例。

实质上,异常不应该被用作返回代码,这是一个底线观点,因为这不仅会使得你的API变得复杂,而且还会使调用方的API变得复杂。

当然,做正确的事情也有代价。代价是每个人都需要了解他们需要阅读和遵循文档。希望这种情况已经发生了。


9

性能如何?在对.NET Web应用程序进行负载测试时,我们的模拟用户数一直停留在每个Web服务器100个,直到我们修复了一个常见的异常,这个数字才增加到500个用户。


8

我认为您可以使用异常来控制程序流程。然而,这种技术也有一个反面。创建异常是一件代价高昂的事情,因为它们必须创建一个堆栈跟踪。所以,如果您想更经常地使用异常而不仅仅是用于发信号表示异常情况,您必须确保构建堆栈跟踪不会对性能产生负面影响。

降低创建异常成本的最佳方法是像这样覆盖 fillInStackTrace() 方法:

public Throwable fillInStackTrace() { return this; }

这样的异常将不会填充堆栈跟踪信息。

堆栈跟踪还需要调用者“了解”(即依赖于)堆栈中的所有Throwables。这是个坏事。根据抽象级别抛出适当的异常(在Services中使用ServiceExceptions,在Dao方法中使用DaoExceptions等)。 - jasonnerothin
除了使用 raiserescue 处理常规异常外,编程语言 Ruby 还有第二个功能,允许您 throwcatch 符号。我认为这自然避免了创建堆栈跟踪的影响,只是将一小段文本抛给堆栈中的下一个 catcher。 - aef

8

以下是我在博客文章中描述的最佳实践:

  • 在软件中抛出异常以说明意外情况
  • 使用返回值进行输入验证
  • 如果您知道如何处理库抛出的异常,请尽可能在最底层捕获它们
  • 如果出现意外异常,请完全放弃当前操作。 不要假装自己知道如何处理异常

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