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

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个回答

6
我不太明白你在引用的代码中如何控制程序流程。除了ArgumentOutOfRange异常,您将永远不会看到其他异常。(因此,您的第二个catch子句将永远不会被触发)。您所做的只是使用极其昂贵的抛出来模拟if语句。
此外,您没有执行更加阴险的操作,即仅仅为了在其他地方捕获并执行流程控制而抛出异常。您实际上正在处理一个异常情况。

5
除了已经提到的原因,不将异常用于流程控制的另一个原因是它会极大地复杂化调试过程。
例如,当我在VS中跟踪错误时,通常会打开“所有异常中断”。如果您正在使用异常来进行流程控制,那么我将会在调试器中经常中断,并且必须一直忽略这些非异常性异常,直到找到真正的问题。这很可能会让人发疯!!

1
我已经处理了更高级的问题: 所有异常调试:同样,这只是因为不使用异常的设计很常见。我的问题是:为什么一开始就很常见呢? - Peter
那么你的答案基本上是“因为Visual Studio有这个功能,所以它很糟糕”吗?我已经编程约20年了,甚至没有注意到有“在所有异常处中断”的选项。不过,“因为这一个功能!”听起来像是一个薄弱的理由。只需跟踪异常到其源头;希望您正在使用一种使此操作变得容易的语言 - 否则,您的问题就与异常本身的一般用法无关,而是与语言特性有关。 - Loduwijk

4
假设您有一个执行某些计算的方法。它有许多输入参数需要验证,然后返回一个大于0的数字。
使用返回值来表示验证错误很简单:如果方法返回小于0的数字,则发生错误。那么如何告诉哪个参数没有通过验证呢?
我从我的C语言时代记得很多函数会像这样返回错误代码:
-1 - x lesser then MinX
-2 - x greater then MaxX
-3 - y lesser then MinY

它真的比抛出和捕获异常更难读懂吗?


这就是为什么他们发明了枚举类型 :) 但神奇数字是完全不同的话题.. http://en.wikipedia.org/wiki/Magic_number_(programming)#Unnamed_numerical_constant - Isak Savo
很好的例子。我正要写同样的东西。@IsakSavo:如果方法预期返回某个含义值或对象,则枚举对于这种情况没有帮助。例如,getAccountBalance() 应该返回一个 Money 对象,而不是 AccountBalanceResultEnum 对象。 很多 C 程序都有类似的模式,其中一个哨兵值(0 或 null)表示错误,然后您必须调用另一个函数以获取单独的错误代码,以便确定为什么发生错误。(MySQL C API 就是这样。) - Mark E. Haase

4

由于代码难以阅读,您可能会在调试时遇到麻烦,长时间修复漏洞后会引入新的漏洞,这在资源和时间上更加昂贵,并且如果您正在调试代码并且调试器在每个异常发生时停止,那么它会使您感到恼火 ;)


3
正如其他人多次提到的那样,最小惊奇原则 将禁止您仅出于控制流目的过度使用异常。另一方面,没有规则是100%正确的,总有那些情况下异常是“恰当的工具”,就像 goto 本身一样,顺便说一下,在诸如 Java 等语言中以 breakcontinue 的形式存在,通常是跳出嵌套循环的完美方式,这是不可避免的。

以下博客文章解释了一个相当复杂但也相当有趣的非局部 ControlFlowException 的用例:

这段文字解释了在Java的SQL抽象库jOOQ中,有时会使用这样的异常来提前终止SQL渲染过程,当满足某些“罕见”的条件时。

这些条件的例子包括:

  • Too many bind values are encountered. Some databases do not support arbitrary numbers of bind values in their SQL statements (SQLite: 999, Ingres 10.1.0: 1024, Sybase ASE 15.5: 2000, SQL Server 2008: 2100). In those cases, jOOQ aborts the SQL rendering phase and re-renders the SQL statement with inlined bind values. Example:

    // Pseudo-code attaching a "handler" that will
    // abort query rendering once the maximum number
    // of bind values was exceeded:
    context.attachBindValueCounter();
    String sql;
    try {
    
      // In most cases, this will succeed:
      sql = query.render();
    }
    catch (ReRenderWithInlinedVariables e) {
      sql = query.renderWithInlinedBindValues();
    }
    

    If we explicitly extracted the bind values from the query AST to count them every time, we'd waste valuable CPU cycles for those 99.9% of the queries that don't suffer from this problem.

  • Some logic is available only indirectly via an API that we want to execute only "partially". The UpdatableRecord.store() method generates an INSERT or UPDATE statement, depending on the Record's internal flags. From the "outside", we don't know what kind of logic is contained in store() (e.g. optimistic locking, event listener handling, etc.) so we don't want to repeat that logic when we store several records in a batch statement, where we'd like to have store() only generate the SQL statement, not actually execute it. Example:

    // Pseudo-code attaching a "handler" that will
    // prevent query execution and throw exceptions
    // instead:
    context.attachQueryCollector();
    
    // Collect the SQL for every store operation
    for (int i = 0; i < records.length; i++) {
      try {
        records[i].store();
      }
    
      // The attached handler will result in this
      // exception being thrown rather than actually
      // storing records to the database
      catch (QueryCollectorException e) {
    
        // The exception is thrown after the rendered
        // SQL statement is available
        queries.add(e.query());                
      }
    }
    

    If we had externalised the store() logic into "re-usable" API that can be customised to optionally not execute the SQL, we'd be looking into creating a rather hard to maintain, hardly re-usable API.

结论

本质上,我们对这些非本地的goto的使用只是沿着[Mason Wheeler][5]在他的回答中所说的路线:

"我遇到了一个无法正确处理的情况,因为我没有足够的上下文来处理它,但调用我的例程(或更高层次的调用堆栈)应该知道如何处理它。"

与它们的替代方案相比,ControlFlowExceptions的两种用法都比较容易实现,使我们能够重用各种逻辑而不需要将其重构为相关的内部逻辑。

但是,这种感觉会让未来的维护者感到有点惊讶。代码感觉相当脆弱,虽然在这种情况下选择了正确的方法,但我们总是更愿意在本地控制流中避免使用异常,因为可以通过if-else普通分支轻松避免使用。


3
你可以使用锤子的爪子拧螺丝,就像你可以使用异常来控制流程。那并不意味着这是该特性的预期使用方式if语句表达条件,其预期用途控制流程。
如果您在选择不使用为此目的设计的特性的同时以非预期的方式使用某个功能,则会产生相关成本。在这种情况下,清晰度和性能会受到影响,而实际上没有增加任何真正的价值。相比被广泛接受的if语句,使用异常有什么优势?
换句话说:仅仅因为你能够,并不意味着你应该

1
你是说在我们已经有了正常使用的 if 之后,就不需要异常处理了吗?或者说因为没有意图而不打算使用异常处理(循环论证)? - Val
1
@Val:异常是用于特殊情况的 - 如果我们检测到足够的异常来抛出并处理它,那么我们有足够的信息来抛出它,仍然可以处理它。我们可以直接进入处理逻辑并跳过昂贵、多余的try/catch。 - Bryan Watts
按照这种逻辑,你可能不需要异常,而是始终执行系统退出而不是抛出异常。如果您想在退出之前执行任何操作,请创建一个包装器并调用它。Java示例:public class ExitHelper{ public static void cleanExit() { cleanup(); System.exit(1); } } 然后只需调用它而不是抛出:ExitHelper.cleanExit(); 如果您的论点正确,那么这将是首选方法,也就不会有异常了。您基本上是在说“异常的唯一原因是以不同的方式崩溃”。 - Loduwijk
@Aaron:如果我既抛出又捕获异常,我就有足够的信息来避免这样做。这并不意味着所有异常突然变得致命。我无法控制的其他代码可能会捕获它,这是可以接受的。我的论点仍然站得住脚,即在同一上下文中抛出和捕获异常是多余的。我没有说过,也不会说所有异常都应该退出进程。 - Bryan Watts
@BryanWatts 已确认。许多人已经说过,你只应该在无法恢复的情况下使用异常,因此应始终在异常上崩溃。这就是为什么讨论这些事情很困难的原因;不仅有两种观点,而且有很多观点。我仍然不同意你的看法,但并不强烈。有时候,throw/catch一起使用是最可读、可维护、最美观的代码;通常情况下,如果你已经捕获了其他异常,那么添加1或2个catch比通过if进行单独的错误检查更加清晰。 - Loduwijk
@Aaron:我当然不会反驳这一点。恢复有很多形式,有时候 try/catch 就是所需的。尊重锋利的刀子,它会为你服务得很好 :-) - Bryan Watts

3
如果你用异常处理来控制流程,那么你就太笼统和懒惰了。如其他人所提到的,如果你在处理程序中处理处理,则知道发生了某些事情,但是具体是什么?如果您将其用于控制流,则基本上是将异常用作else语句。
如果您不知道可能发生什么状态,则可以使用异常处理程序处理意外状态,例如当您必须使用第三方库或者必须捕获UI中的所有内容以显示漂亮的错误消息并记录异常时。
但是,如果您知道可能出现什么问题,并且没有放置if语句或其他东西进行检查,则只是在偷懒。允许异常处理程序成为您知道可能发生的所有事情的万能代码块是很懒的,它将在以后回来困扰您,因为您将尝试根据可能错误的假设在异常处理程序中修复情况。
如果您在异常处理程序中放置逻辑以确定发生了什么事情,那么您不将该逻辑放置在try块中就会非常愚蠢。
异常处理程序是最后的手段,用于当您无法控制或无法停止某些事情发生时。比如,服务器关机并超时,您无法防止抛出异常。
最后,预先完成所有检查显示您所知道的或预期发生的情况,并使其显式。代码应该在意图上清晰明了。您更愿意阅读什么?

1
完全不正确:如果您在控制流中使用异常,实际上是在使用异常作为else语句。 如果您将其用于控制流,则知道确切捕获什么,永远不会使用通用的catch,而是当然使用特定的catch! - Peter

2
通常情况下,在低层处理异常是没有问题的。异常是一个有效的消息,提供了很多细节,说明为什么无法执行操作。如果您能够处理它,那么您应该这样做。
通常情况下,如果您知道有很高的失败概率可以进行检查,那么您应该进行检查,例如 if(obj != null) obj.method()
在您的情况下,我不熟悉C#库是否有一种简单的方法来检查时间戳是否超出范围。如果有,只需调用 if(.isvalid(ts)),否则您的代码基本上是正确的。
因此,基本上取决于哪种方式创建更清晰的代码……如果防止预期异常的操作比处理异常复杂,则可以处理异常而不是在各处创建复杂的保护。

附加提示:如果您的异常提供了失败捕获信息(例如“Param getWhatParamMessedMeUp()”这样的getter),它可以帮助API用户做出下一步的正确决策。否则,您只是为错误状态命名。 - jasonnerothin

2
您可能会对Common Lisp的条件系统感兴趣,它是一种正确实现的异常的泛化。因为您可以控制地解开堆栈,也可以不解开,所以您还可以得到“重启”,这非常方便。
这与其他语言中的最佳实践没有太大关系,但它向您展示了在(大致)您正在思考的方向上可以做些什么,只要经过一些设计思考。
当然,如果您像溜溜球一样在堆栈上下弹跳,仍然需要考虑性能问题,但这比大多数catch / throw异常系统体现的“糟糕,让我们退出”方法更具普适性。

2
我认为使用异常进行流程控制并没有什么问题。异常有点类似于continuations,在静态类型语言中,异常比continuations更强大。所以,如果你需要continuations但你的语言没有提供,你可以使用异常来实现。
实际上,如果你需要continuations而你的语言没有提供,那么你选择了错误的语言,应该换一种不同的语言。但有时你别无选择:客户端Web编程是一个最好的例子 - 没有办法绕过JavaScript。
例如:Microsoft Volta是一个允许在直接的.NET中编写Web应用程序的项目,并让框架负责确定哪些部分需要在哪里运行。这样做的一个结果是,Volta需要能够将CIL编译成JavaScript,以便您可以在客户端上运行代码。然而,有一个问题:.NET具有多线程,而JavaScript没有。因此,Volta使用JavaScript异常来实现JavaScript中的continuations,然后使用这些continuations来实现.NET线程。这样,使用线程的Volta应用程序可以编译为在未修改的浏览器中运行 - 不需要Silverlight。

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