作为控制流的例外是否被认为是严重的反模式?如果是这样,为什么?


129

上世纪90年代后期,我在使用例外作为流控制的代码库中做了很多工作。它实现了一个有限状态机来驱动电话应用程序。最近,我想起那些日子,因为我一直在做MVC Web应用程序。

它们都具有Controllers,该s确定下一个下一步并将数据提供给目标逻辑。来自老式电话域的用户操作(例如DTMF音调)成为操作方法的参数,但是他们没有返回类似a的内容ViewResult,而是抛出了a StateTransitionException

我认为主要区别在于动作方法是void功能。我不记得我对这个事实所做的所有事情,但是我一直犹豫要记住多少东西,因为自从15年前从事这项工作以来,我再也没有在生产代码中见过任何其他工作。我以为这是一个所谓的反模式迹象。

是这样吗?如果是,为什么?

更新:当我问这个问题时,我已经想到了@MasonWheeler的答案,所以我选择了最能增加我的知识的答案。我认为他也是一个正确的答案。



25
否。在Python中,将异常用作控制流被视为“ pythonic”。
user16764 2013年

4
如果我要用Java做这样的事情,我当然不会抛出异常。我将从一些非异常,非错误,可抛出的层次结构中派生出来。
Thomas Eding

2
为了增加现有的答案,以下是对我有用的简短指导:-切勿对“幸福之路”使用例外。满意的路径既可以是整个请求(对于Web),也可以是一个对象/方法。当然,所有其他理智的规则仍然适用:)
侯恩

8
异常不是总是控制应用程序的流程吗?

Answers:


109

Ward的Wiki上对此进行了详细的讨论。一般来说,使用控制流异常的是一个反模式,有许多著名的情况-和语言特有的(见例如 Python的咳嗽异常咳嗽

快速概括一下为什么通常是反模式:

  • 本质上,异常是复杂的GOTO语句
  • 因此,使用异常进行编程会导致更难以阅读和理解代码
  • 大多数语言都具有旨在解决您的问题而无需使用异常的现有控制结构
  • 对于现代编译器来说,效率的争论往往没有意义,它们在假设不将异常用于控制流的前提下进行优化。

阅读Ward Wiki上的讨论,以获取更多深入信息。


又见这个问题的一个副本,在这里


18
您的答案听起来好像在所有情况下异常都是有害的,而问题的重点是作为流控制的异常。
whatsisname 2013年

14
@MasonWheeler的区别是for / while循环清楚地包含其流控制更改,并使代码易于阅读。如果在代码中看到for语句,则不必尝试找出哪个文件包含循环的结尾。Goto的不错,因为有些上帝说他们是不错的,它们之所以糟糕,仅仅是因为它们比循环结构更难遵循。异常是相似的,不是不可能,但很难使异常混淆。
Bill K

24
@BillK,然后争论这一点,不要对异常是如何得到的做出简单的陈述。
Winston Ewert

6
好的,但认真的说,服务器端和应用程序开发人员在JavaScript中使用空的catch语句掩埋错误是怎么回事?这是一个令人讨厌的现象,花费了我很多时间,而且我不知道如何不厌其烦地问。错误是你的朋友。
Erik Reppen

12
@mattnz:ifforeach同样复杂GOTO秒。坦率地说,我认为与goto的比较没有帮助;这几乎是一个贬义词。使用GOTO并不是本质上有害的-它有实际的问题,并且异常可能共享这些问题,但可能不会。听到这些问题会更有帮助。
Eamon Nerbonne

110

专为异常设计的用例是“我此时遇到了无法正确处理的情况,因为我没有足够的上下文来处理它,而是调用了我的例程(或进一步调用的例程)堆栈)应该知道如何处理。”

第二个用例是“我刚遇到一个严重的错误,现在退出该控制流以防止数据损坏或其他损坏比尝试继续进行更重要。”

如果您出于以下两个原因之一而没有使用异常,则可能有更好的方法。


18
但是,这并不能回答问题。它们的设计目的无关紧要;唯一相关的是为什么使用它们进行控制流不好,这是您没有涉及的主题。例如,C ++模板是为一件事而设计的,但是可以很好地用于元编程,这是设计人员从未想到的用途。
Thomas Bonini

6
@Krelp:设计师从未预料到很多事情,例如偶然获得了图灵完整的模板系统! C ++模板在这里几乎不是一个很好的例子。
梅森惠勒2013年

15
@Krelp-对于元编程,C ++模板并非“完美”。在您正确使用它们之前,它们是一场噩梦,然后,如果您不是模板天才,它们就会倾向于只写代码。您可能想选择一个更好的例子。
迈克尔·科恩

异常尤其会损害功能的组成,并且通常会损害代码的简洁性。例如,当我没有经验时,我在一个项目中使用了一个异常,这导致了很长一段时间的麻烦,因为1)人们必须记住要捕获它,2)您不能编写,const myvar = theFunction因为必须从try-catch中创建myvar,因此它不再恒定。这并不意味着我不会在C#中使用它,因为无论出于何种原因它都已成为C#的主流,但是无论如何我都试图减少它们。
Hi-Angel

2
@AndreasBonini它们的设计/目的是为了解决问题,因为这通常会导致编译器/框架/运行时实现决策与设计保持一致。例如,引发异常比简单的返回要“昂贵得多”,因为它收集旨在帮助某人调试代码的信息(例如堆栈跟踪等
。– binki

29

异常与Continuations和GOTO。它们是通用控制流构造。

在某些语言中,它们是唯一的通用控制流构造。例如,JavaScript既没有Continuations也没有GOTO,甚至没有正确的尾部调用。因此,如果要在JavaScript中实现复杂的控制流,必须使用Exceptions。

Microsoft Volta项目是一个(现已停产)研究项目,用于将任意.NET代码编译为JavaScript。.NET具有其语义不能完全映射到JavaScript的Exception,但是更重要的是,它具有Threads,您必须以某种方式将其映射到JavaScript。Volta通过使用JavaScript异常实现Volta Continuations来实现此目的,然后根据Volta Continuations实现所有.NET控制流构造。他们必须使用Exceptions作为控制流,因为没有其他足够强大的控制流构造

您提到了状态机。SM通过适当的尾部调用实现起来很简单:每个状态都是一个子例程,每个状态转换都是一个子例程调用。SM也可以很容易地与GOTO协程或Continuations 一起实现。但是,Java没有这四种,但确实有例外。因此,将它们用作控制流是完全可以接受的。(实际上,正确的选择可能是使用具有适当控制流构造的语言,但有时您可能会被Java束缚。)


7
如果只有适当的尾部调用递归,也许我们可以使用JavaScript在客户端Web开发中独占followed头,然后再扩展到服务器端和移动设备,成为野火之类的最终泛平台解决方案。las,但事实并非如此。该死的我们一流的功能,闭包和事件驱动的范例还不够。我们真正需要的是真实的东西。
Erik Reppen

20
@ErikReppen:我意识到您只是在讽刺,但是。。。我真的不认为我们“已经用JavaScript主导了客户端Web开发”这一事实与该语言的功能无关。它在那个市场上处于垄断地位,因此能够摆脱许多无法用嘲讽消除的问题。
ruakh

1
尾部递归本来会是一个不错的奖励(它们已经淘汰了将来要使用的功能),但是的,我想说它由于功能相关的原因而胜过VB,Flash,Applet等。如果它不降低复杂性并像它那样方便地进行归一化,那么它在某个时候会带来真正的竞争。我当前正在运行Node.js来处理一个可怕的C#+ Java堆栈的21个配置文件的重写,我知道我不是唯一一个这样做的人。擅长擅长。
Erik Reppen

1
许多语言都没有gotos(Goto认为是有害的!),协程或延续,并且仅使用ifs,whiles和函数调用(或gosubs!)来实现完全有效的流控制。实际上,其中大多数都可以互相模仿,就像可以使用延续来表示所有上述内容一样。(例如,即使它是一种糟糕的编码方式,您也可以使用一段时间来执行if)。因此,执行高级流控制没有例外。
Shayne 2014年

2
好吧,可能您会猜对的。但是,C语言形式的“ For”在使用中时会很尴尬,而while会与if一起使用,以实现一个有限状态机,然后该有限状态机可以模拟所有其他流控制形式。再说一次,用臭气熏天的方式编码,但是,是的。再说一次,goto被认为是有害的。(我也认为,在非特殊情况下使用异常也是如此)。请记住,存在完全有效且功能强大的语言,它们既不提供goto也不提供异常,并且工作正常。
谢恩2014年

19

正如其他人多次提到的那样(例如,在此Stack Overflow问题中),最少惊讶的原则将禁止您仅出于控制流目的而过度使用异常。在另一方面,没有什么规则是100%正确的,总有那些情况下的例外是“恰到好处的工具” -就像goto自己,顺便说一句,这船的形式breakcontinue像Java,语言这通常是跳出严重嵌套循环的理想方法,而这并非总是可以避免的。

以下博客文章解释了一个非本地 的相当复杂但有趣的用例ControlFlowException

它解释了在jOOQ(一种Java的SQL抽象库)内部的方式(免责声明:我为供应商工作),在满足某些“稀有”条件时,偶尔会使用此类异常中止SQL渲染过程。

这样的条件的示例是:

  • 遇到太多的绑定值。某些数据库在其SQL语句中不支持任意数量的绑定值(SQLite:999,Ingres 10.1.0:1024,Sybase ASE 15.5:2000,SQL Server 2008:2100)。在这些情况下,jOOQ将中止SQL呈现阶段,并使用内联绑定值重新呈现SQL语句。例:

    // 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();
    }
    

    如果我们从查询AST中显式提取绑定值来每次对其进行计数,那么对于那些没有遭受此问题困扰的99.9%的查询,我们将浪费宝贵的CPU周期。

  • 某些逻辑只能通过我们仅希望“部分”执行的API间接使用。该UpdatableRecord.store()方法根据的内部标志生成一个INSERTor UPDATE语句Record。从“外部”开始,我们不知道其中包含哪种逻辑store()(例如,乐观锁定,事件监听器处理等),因此当我们在批处理语句中存储多个记录时,我们不想重复该逻辑,我们store()只想生成SQL语句,而不实际执行它。例:

    // 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());                
      }
    }
    

    如果我们已将store()逻辑外部化为可以自定义为执行SQL的“可重用” API ,那么我们将考虑创建一个很难维护且几乎不可重用的API。

结论

从本质上讲,我们对这些非本地gotos的用法与梅森·惠勒在回答中所说的相似:

“我当时遇到的情况是我无法正确处理,因为我没有足够的上下文来处理它,但是调用我的例程(或调用堆栈中更远的东西)应该知道如何处理它。”

ControlFlowExceptions与它们的替代方案相比,这两种用法都非常易于实现,从而使我们能够重用广泛的逻辑,而无需从相关内部结构中进行重构。

但是这种感觉让未来的维护者感到惊讶。代码让人感觉很精致,虽然在这种情况下它是正确的选择,但我们始终不希望对本地控制流使用异常,因为这样很容易避免使用普通的分支方式if - else


11

将异常用于控制流通常被认为是一种反模式,但是存在异常(无双关语)。

有一千次说过,例外是指特殊情况。数据库连接断开一种特殊情况。在输入字段中只允许输入数字的用户不允许

导致您使用非法参数(例如null在不允许的地方)调用函数的软件错误一种特殊情况。

通过对异常情况使用异常,您正在针对要解决的问题使用不合适的抽象。

但是也可能会有性能损失。某些语言或多或少具有高效的异常处理实现,因此,如果您选择的语言没有高效的异常处理,则在性能方面可能会非常昂贵。

但是其他语言(例如Ruby)对于控制流具有类似异常的语法。特殊情况由raise/ rescue运算符处理。但您可以将throw/ catch用于类似异常的控制流构造**。

因此,尽管通常不将异常用于控制流,但是您选择的语言可能还有其他成语。

*以性能为代价的异常使用示例:我曾经被设置为优化性能较差的ASP.NET Web窗体应用程序。原来,一个大表的渲染正在调用int.Parse()约。平均一页上有一千个空字符串,大约 一千个例外情况正在处理中。通过用int.TryParse()我替换代码来节省一秒钟!对于每个页面请求!

**这对于从其他语言到Ruby程序员非常混乱,因为两者throwcatch与其他许多语言的异常相关的关键字。


1
+1表示“通过对异常情况使用异常,您正在为要解决的问题使用不合适的抽象。”
乔治

8

完全可以在不使用异常的情况下处理错误情况。有些语言,尤其是C语言,甚至没有例外,人们仍然设法使用它创建非常复杂的应用程序。异常之所以有用的原因是,它们使您可以在同一代码中简洁地指定两个基本独立的控制流:一个发生错误,另一个发生错误。没有它们,您最终将在整个地方使用如下代码:

status = getValue(&inout);
if (status < 0)
{
    logError("message");
    return status;
}

doSomething(*inout);

或等价于您的语言,例如返回一个值作为错误状态的元组等。经常有人指出异常处理的“昂贵”程度,if如果您不这样做,则忽略上述所有需要添加的额外语句。不要使用例外。

尽管在处理错误或其他“异常情况”时这种模式最常发生,但我认为如果您在其他情况下开始看到这样的样板代码,那么您就有使用异常的很好理由。根据情况和实现,我可以看到状态机中有效地使用了异常,因为您有两个正交控制流:一个正在更改状态,另一个用于更改状态中发生的事件。

但是,这些情况很少见,如果您要对该规则进行例外(双关语意),则最好准备展示其相对于其他解决方案的优越性。没有这种理由的偏差正确地称为反模式。


2
只要那些if语句仅在“例外”情况下失败,现代CPU的分支预测逻辑就可以使其成本微不足道。这是宏可以真正提供帮助的地方,只要您小心且不要在宏中做太多事情即可。
詹姆斯

5

在Python中,异常用于生成器和迭代终止。Python具有非常有效的try / except块,但实际上引发异常会带来一些开销。

由于缺少多级中断或Python中的goto语句,我有时会使用以下异常:

class GOTO(Exception):
  pass

try:
  # Do lots of stuff
  # in here with multiple exit points
  # each exit point does a "raise GOTO()"
except GOTO:
  pass
except Exception as e:
  #display error

6
如果您必须在深层嵌套的语句中的某处终止计算并转到一些常见的延续代码,则很可能可以将这个特定的执行路径分解为一个函数,而return不是goto
9000

3
@ 9000我将要发表完全相同的评论...请不要将try:块限制为1或2行try: # Do lots of stuff
2013年

1
@ 9000,在某些情况下,可以肯定。但是,当该过程是一个连贯的线性过程时,您将无法访问局部变量,并且将代码移到另一个位置。
gahooa

4
@gahooa:我曾经像你一样思考。这表明我的代码结构不良。当我多加思考时,我注意到本地上下文可以被弄乱,整个混乱变成了简短的函数,几乎没有参数,很少的代码行和非常精确的含义。我从不回头。
9000

4

让我们来勾画这样的Exception用法:

该算法以递归方式搜索,直到找到内容为止。因此,从递归返回时,必须检查是否找到了结果,然后返回,否则继续。而且从某个递归深度反复出现。

除了需要一个额外的布尔值found(包装在一个类中,否则可能只返回一个int值),对于递归深度,也会发生相同的后奏。

这样的调用堆栈的平仓是一个异常是什么。因此,在我看来,这是一种非goto型,更直接,更合适的编码方式。不需要,很少使用,也许是不好的风格,但是要点。与Prolog切割操作相当。


嗯,是为真正相反的立场还是为不足或简短的论证而投反对票?我真的很好奇
乔普·艾根

我正面临着这种困境,希望您能看到您对以下问题的看法?递归地使用异常来积累顶级异常的原因(消息)codereview.stackexchange.com/questions/107862/…–
CL22

@Jodes我刚刚读过您有趣的技术,但是现在我很忙。希望其他人可以阐明她/他的观点。
乔普·艾根

4

编程是关于工作的

我认为回答此问题的最简单方法是了解OOP多年来取得的进展。在OOP中完成的所有工作(以及大多数编程范例)都是围绕需要完成的工作建模的。

每次调用方法时,调用者都会说:“我不知道如何进行这项工作,但是您知道如何做,所以您可以为我做。”

这带来了一个困难:当被调用的方法通常知道如何完成工作但又并非总是知道如何做时,会发生什么?我们需要一种交流的方式:“我确实想为您提供帮助,但我做不到。”

传达此问题的早期方法是简单地返回“垃圾”值。也许您期望一个正整数,所以被调用的方法返回一个负数。完成此操作的另一种方法是在某个位置设置错误值。不幸的是,这两种方法都使样板让我在这里检查以确保所有的原始代码。随着事情变得越来越复杂,该系统崩溃了。

出色的类比

假设您有一个木匠,一个水管工和一个电工。您想用水管工修理水槽,所以他来看看。如果他只告诉您“对不起,我无法解决。它已损坏。”这不是很有用地狱,如果他看一眼,离开,然后给你寄一封信说他无法解决这个问题,那就更糟了。现在,您必须检查您的邮件,然后才能知道他没有执行您想要的操作。

您更希望他告诉您:“看,我无法修复它,因为您好像泵不工作。”

有了这些信息,您就可以得出结论,希望电工来解决问题。也许电工会找到与木匠有关的东西,而您需要让木匠对其进行修复。

哎呀,您甚至可能都不知道需要电工,您可能也不知道需要。您只是家庭维修业务的中层管理人员,而您的关注点正在不断扩大。因此,您告诉您是问题的老板,然后告诉电工将其解决。

这就是例外建模:分离的复杂故障模式。水管工不需要了解电工-他甚至不需要知道链上有人可以解决问题。他只是报告遇到的问题。

那么...反模式?

好的,因此了解异常是第一步。接下来是了解什么是反模式。

要获得反模式的资格,它需要

  • 解决这个问题
  • 绝对有负面影响

第一点很容易理解-系统正常工作,对吧?

第二点是粘性。将异常用作常规控制流的主要原因是不好,因为这不是它们的目的。程序中任何给定的功能都应具有相对明确的目的,而选择该目的会导致不必要的混乱。

但这不是确定的危害。这是一种糟糕的做事方式,而且很怪异,但是是一种反模式?不,只是...奇怪。


有一个不好的结果,至少在提供完整堆栈跟踪的语言中。由于代码是经过大量内联代码优化的,因此实际的堆栈跟踪与开发人员希望看到的代码有很大不同,因此生成堆栈跟踪非常昂贵。过度使用异常对于此类语言(Java,C#)的性能非常不利。
maaartinus

“这是一种糟糕的做事方式”-将其归类为反模式还不够吗?
Maybe_Factor

@Maybe_Factor根据蚂蚁模式的定义,不。
MirroredFate
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.