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

时间:2009-04-08 10:25:02

标签: exception language-agnostic

为了避免我可以用谷歌搜索所有标准答案,我将提供一个你可以随意攻击的例子。

C#和Java(以及太多其他人)有很多类型的某些“溢出”行为我根本不喜欢(例如type.MaxValue + type.SmallestValue == type.MinValue,例如:int.MaxValue + 1 == int.MinValue)。

但是,看到我的恶性,我会通过扩展这种行为来增加对这种伤害的侮辱,让我们说一个被覆盖的DateTime类型。 (我知道DateTime是用.NET密封的,但是为了这个例子,我使用的伪语言与C#完全相同,只是DateTime没有被密封。

重写的Add方法:

/// <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-structures更清晰,并且不会破坏该方法的任何合同。

恕我直言“永远不要将它们用于正常的程序流程”反应,每个人似乎都没有那么好的建设,因为这种反应的力量是合理的。

或者我错了?

我已阅读其他帖子,处理各种特殊情况,但我的观点是如果你们两个都没有错:

  1. 清除
  2. 尊重您的方法合同
  3. 拍我。

24 个答案:

答案 0 :(得分:160)

您是否曾尝试调试在正常操作过程中每秒产生五个异常的程序?

我有。

程序非常复杂(它是一个分布式计算服务器),在程序的一侧稍作修改就可以轻易地在完全不同的地方破坏某些东西。

我希望我可以启动程序并等待异常发生,但在正常运行过程中启动期间有大约200个异常

我的观点:如果您在正常情况下使用例外,您如何找到异常(即例外 al)情况?

当然,还有其他强有力的理由不过多使用异常,尤其是性能方面

答案 1 :(得分:150)

异常基本上是非本地goto语句,具有后者的所有后果。使用流控制的异常违反principle of least astonishment,使程序难以阅读(请记住,程序首先是为程序员编写的)。

此外,这不是编译器供应商所期望的。他们希望很少抛出异常,他们通常会让throw代码效率很低。抛出异常是.NET中最昂贵的操作之一。

但是,某些语言(特别是Python)使用异常作为流控制结构。例如,如果没有其他项,迭代器会引发StopIteration异常。甚至标准语言结构(例如for)依赖于此。

答案 2 :(得分:25)

我的经验法则是:

  • 如果您可以执行任何操作以从错误中恢复,请捕获异常
  • 如果错误是非常常见的(例如,用户尝试使用错误的密码登录),请使用returnvalues
  • 如果你无法做任何事情来从错误中恢复过来,那么就把它保留下来(或者在你的主捕获器中抓住它来对应用程序进行一些半正常的关闭)。

我看到异常的问题是从纯语法的角度来看(我很确定性能开销很小)。我不喜欢整个地方的试用块。

举个例子:

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.. :(

Soo,就个人而言,我认为你应该为罕见的错误条件(内存不足等)保留异常,并使用returnvalues(valueclasses,structs或enums)代替进行错误检查。

希望我理解你的问题是正确的:)

答案 3 :(得分:22)

对许多答案的第一反应:

  

你是为程序员写的,也是最不惊讶的原则

当然!但是,如果不是一直都不清楚。

它不应该是惊人的例如:divide(1 / x)catch(divisionByZero)比任何if(在Conrad和其他人)都更清晰。不期望这种编程的事实纯粹是传统的,实际上仍然是相关的。也许在我的例子中,if会更清楚。

但是DivisionByZero和FileNotFound比ifs更清楚。

当然,如果它的性能较差并且每秒需要大量的时间,你当然应该避免它,但我仍然没有找到任何有理由避免整体设计。

就最不惊讶的原则而言:这里存在循环推理的危险:假设整个社区使用了糟糕的设计,这种设计将成为预料之中!因此,原则不能是一个圣杯,应该仔细斟酌。

  

正常情况下的例外情况,您如何找到异常情况(即特殊情况)?

在许多反应中......像这样闪耀着低谷。抓住他们,不是吗?你的方法应该清晰,记录良好,并且能够签订合同。我必须承认,我没有得到这个问题。

对所有异常进行调试:相同,有时只是因为不使用异常的设计很常见。我的问题是:为什么它首先是常见的?

答案 4 :(得分:15)

在异常之前,在C中,有setjmplongjmp可用于完成类似的堆栈帧展开。

然后给同一个结构命名:“Exception”。大多数答案都依赖于此名称的含义来争论它的用法,并声称异常旨在用于特殊情况。这绝不是原始longjmp中的意图。有些情况下你需要打破许多堆栈帧的控制流。

异常略微更通用,因为您也可以在同一堆栈框架中使用它们。这引起了goto的类比,我认为这是错误的。 Gotos是一对紧耦合的(setjmplongjmp)。异常遵循松散耦合的发布/订阅更加清晰!因此,在同一堆栈帧中使用它们与使用goto s几乎不同。

混淆的第三个原因是它们是被检查还是未检查的异常。当然,未经检查的异常似乎特别难以用于控制流程,也许还有很多其他的东西。

一旦你克服了维多利亚时代的所有挂断并活了一点,那么经过检查的异常非常适合控制流程。

我最喜欢的用法是代码长片段中的throw new Success()序列,它会一个接一个地尝试,直到找到它要查找的内容。每一件事 - 每一条逻辑 - 都可能有任意的嵌套,所以break也可以进行任何条件测试。 if-else模式很脆弱。如果我编辑出else或以其他方式弄乱语法,那么就会有一个毛茸茸的错误。

使用throw new Success() 线性化 代码流程。我使用本地定义的Success类 - 当然检查 - 所以如果我忘记捕获它,代码将无法编译。我没有抓住另一种方法的Success es。

有时我的代码会检查一个接一个的东西,只有一切正常才能成功。在这种情况下,我使用throw new Failure()进行类似的线性化。

使用单独的功能会混淆自然级别的分隔。所以return解决方案并不是最优的。出于认知原因,我更喜欢在一个地方有一两页代码。我不相信超细分的代码。

除非有热点,否则JVM或编译器所做的事与我的关系不大。我无法相信编译器没有任何根本原因来检测本地抛出和捕获的异常,并且只是在机器代码级别将它们视为非常有效的goto

就跨控制流的功能使用它们而言 - i。即对于常见情况而不是特殊情况 - 我看不出它们如何效率低于多次中断,条件测试,返回通过三个堆栈帧而不是仅仅恢复堆栈指针。

我个人不会在堆栈帧中使用该模式,我可以看到它如何优雅地需要设计复杂性。但是节制地使用它应该没问题。

最后,关于令人惊讶的处女程序员,这并不是一个令人信服的理由。如果你轻轻地将它们介绍给练习,他们就会学会喜欢它。我记得C ++曾经惊讶并吓跑了C程序员。

答案 5 :(得分:11)

标准的anwser是异常不规则,应该在特殊情况下使用。

对我来说很重要的一个原因是,当我在我维护或调试的软件中读取try-catch控件结构时,我试图找出原始编码器为什么使用异常处理而不是{ {1}}结构。我期待找到一个好的答案。

请记住,您不仅要为计算机编写代码,还要为其他编码器编写代码。有一个与异常处理程序相关的语义,你不能因为机器不介意而丢弃它。

答案 6 :(得分:8)

性能怎么样?在对.NET Web应用程序进行负载测试时,每个Web服务器的模拟用户数达到100个,直到我们修复了一个常见的异常并且该数量增加到500个用户。

答案 7 :(得分:8)

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. 你不需要测试它(取决于你的攻击性如何)   单元测试策略是)。
  4.   
  5. 您不需要调用者处理三个用例。
  6.   

地面点是异常应该用作返回代码,主要是因为你不仅复杂了你的API,还复杂了调用者的API。

当然,做正确的事是有代价的。成本是每个人都需要了解他们需要阅读并遵循文档。希望无论如何都是这样。

答案 8 :(得分:7)

我认为您可以使用Exceptions进行流量控制。然而,这种技术有另一面。创建异常是一件昂贵的事情,因为他们必须创建堆栈跟踪。因此,如果您想要更频繁地使用异常,而不仅仅是发出特殊情况,您必须确保构建堆栈跟踪不会对性能产生负面影响。

降低创建异常的成本的最佳方法是覆盖fillInStackTrace()方法,如下所示:

public Throwable fillInStackTrace() { return this; }

这样的例外将没有填写堆栈跟踪。

答案 9 :(得分:5)

我真的没有看到你如何控制你引用的代码中的程序流程。除了ArgumentOutOfRange异常之外,你永远不会看到另一个异常。 (所以你的第二个catch子句永远不会被击中)。你所做的只是使用极其昂贵的投掷来模仿if语句。

此外,您不会执行更加险恶的操作,只是因为它只是为了在其他地方捕获它而执行流控制而抛出异常。你实际上正在处理一个特例。

答案 10 :(得分:4)

由于代码难以阅读,您可能会遇到调试问题,在长时间修复错误后会引入新的错误,在资源和时间方面会更加昂贵,如果您正在调试它会让您烦恼你的代码和调试器会因每个异常的出现而暂停;)

答案 11 :(得分:4)

除了上述原因之外,不使用流控制异常的一个原因是它可能会使调试过程大大复杂化。

例如,当我试图追踪VS中的错误时,我通常会打开“中断所有异常”。如果您正在使用流控制的异常,那么我将定期打破调试器,并且在我遇到真正的问题之前必须不断忽略这些非异常的异常。这可能会让某人发疯!

答案 12 :(得分:4)

以下是我在blog post中描述的最佳做法:

  • 在软件中引发异常以声明意外情况
  • 使用返回值进行输入验证
  • 如果您知道如何处理库引发的异常,可以在最低级别捕获它们
  • 如果您遇到意外异常,请完全放弃当前操作。 不要假装你知道如何处理它们

答案 13 :(得分:3)

让我们假设您有一个方法可以进行一些计算。它必须验证许多输入参数,然后返回大于0的数字。

使用返回值来表示验证错误,这很简单:如果方法返回的数字小于0,则会发生错误。如何告诉哪个参数没有验证?

我记得在我的C日,很多函数都返回了错误代码:

-1 - x lesser then MinX
-2 - x greater then MaxX
-3 - y lesser then MinY

除了抛出和捕获异常之外,它是否真的不那么可读了?

答案 14 :(得分:3)

您可以使用锤子的爪子来旋转螺丝,就像您可以使用控制流程的例外一样。这并不意味着它是该功能的预期用途if语句表示条件,其用途 控制流。

如果您在选择不使用为此目的设计的功能时以非预期的方式使用某项功能,则会产生相关费用。在这种情况下,清晰度和性能不会受到实际附加价值的影响。在广泛接受的if声明中,使用例外可以为您带来什么?

另一种说法:只是因为你可以并不意味着你应该

答案 15 :(得分:2)

通常,处理低级别的异常本身没有任何错误。异常是一种有效消息,它提供了无法执行操作的详细信息。如果你能处理它,你应该。

一般情况下,如果你知道失败的概率很高,你可以检查......你应该做检查......即if(obj!= null)obj.method()

在你的情况下,我对C#库不太熟悉,不知道日期时间是否有一种简单的方法来检查时间戳是否超出范围。如果是,只需调用if(.isvalid(ts)) 否则你的代码基本上没问题。

所以,基本上它归结为以任何方式创建更清晰的代码......如果防止预期异常的操作比仅处理异常更复杂;你获得处理异常的许可,而不是到处创建复杂的守卫。

答案 16 :(得分:2)

如果您正在使用异常处理程序来控制流程,那么您过于笼统和懒惰。正如其他人提到的,如果你在处理程序中处理处理,你知道发生了什么事,但到底是什么?基本上,如果您将其用于控制​​流,则使用else语句的异常。

如果您不知道可能发生的状态,那么您可以使用异常处理程序来处理意外状态,例如,当您必须使用第三方库时,或者您必须捕获UI中的所有内容以显示一个很好的错误消息并记录异常。

但是,如果您确实知道可能出现的问题,并且您没有使用if语句或其他内容来检查它,那么您只是在偷懒。允许异常处理程序成为你知道可能发生的事情的全部内容是懒惰的,它会在以后再次困扰你,因为你将尝试根据可能的错误假设修复异常处理程序中的情况。 / p>

如果你在你的异常处理程序中放置逻辑以确定究竟发生了什么,那么你不会把这个逻辑放在try块中就太愚蠢了。

异常处理程序是最后的手段,因为当你没有想法/方法来阻止出错时,或者事情超出你的控制能力。比如,服务器已关闭并超时,您无法阻止抛出该异常。

最后,预先完成所有检查会显示您知道或期望会发生的事情并使其明确。代码应该明确意图。你还想读什么?

答案 17 :(得分:2)

您可能有兴趣了解Common Lisp的条件系统,它是对异常做法的一种推广。因为您可以以受控方式展开堆栈,所以也可以“重启”,这非常方便。

这与其他语言的最佳实践没有多大关系,但它向您展示了(大致)您正在考虑的方向可以通过一些设计思想来做些什么。

当然,如果你像溜溜球一样在堆栈中上下跳动,仍然存在性能方面的考虑因素,但它比大多数捕获/抛出异常的“哦废话,让保释”更为一般的想法系统体现。

答案 18 :(得分:2)

我认为使用Exceptions进行流量控制没有任何问题。异常有点类似于continuation和静态类型语言,异常比continuation更强大,因此,如果你需要continuation但你的语言没有它们,你可以使用Exceptions来实现它们。

嗯,实际上,如果你需要延续而你的语言没有,你选择了错误的语言而你应该使用不同的语言。但有时你没有选择:客户端网络编程是 的主要例子 - 没有办法绕过JavaScript。

一个例子:Microsoft Volta是一个允许在简单的.NET中编写Web应用程序的项目,让框架负责确定哪些位需要在哪里运行。这样做的一个结果是Volta需要能够将CIL编译为JavaScript,以便您可以在客户端上运行代码。但是,存在一个问题:.NET具有多线程,而JavaScript则没有。因此,Volta使用JavaScript Exceptions在JavaScript中实现continuation,然后使用这些continuation实现.NET Threads。这样,使用线程的Volta应用程序可以编译为在未修改的浏览器中运行 - 不需要Silverlight。

答案 19 :(得分:2)

一个美学原因:

尝试总是带有一个捕获,而一个if不一定带有别的。

if (PerformCheckSucceeded())
   DoSomething();

使用try / catch,它会变得更加冗长。

try
{
   PerformCheckSucceeded();
   DoSomething();
}
catch
{
}

这6行代码太多了。

答案 20 :(得分:2)

正如其他人多次提到的,the principle of least astonishment将禁止您过度使用异常仅用于控制流。另一方面,没有任何规则是100%正确的,并且总是存在例外情况的情况&#34;只是正确的工具&#34; - 顺便说一句,就像goto本身一样,它以breakcontinue的形式出现在像Java这样的语言中,这通常是跳出重度嵌套循环的完美方式,这些并不总是可以避免的。

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

它解释了jOOQ (a SQL abstraction library for Java)的内部,这些异常偶尔用于在某些&#34;稀有&#34;之前提前中止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间接获得&#34;部分&#34;。 UpdatableRecord.store()方法生成INSERTUPDATE语句,具体取决于Record的内部标记。从&#34;外部&#34;,我们不知道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()逻辑外部化为&#34;可重复使用&#34;可自定义的API可选择执行SQL,我们正在考虑创建一个难以维护,难以重复使用的API。

结论

从本质上讲,我们对这些非本地goto的使用与[Mason Wheeler] [5]在答案中所说的一致:

  

&#34;我刚遇到一种情况,我现在无法妥善处理,因为我没有足够的上下文来处理它,但是调用我的例程(或者调用堆栈的其他内容) )应该知道如何处理它。&#34;

与其替代方案相比,ControlFlowExceptions的两种用法都相当容易实现,允许我们重用各种逻辑而无需从相关内部构造中重构它。

但这种感觉对未来的维护者来说有点意外。代码感觉相当精致,虽然在这种情况下它是正确的选择,但我们始终不希望对本地控制流使用异常,因为很容易避免使用普通的分支{ {1}}。

答案 21 :(得分:1)

但是你不会总是知道你所调用的Method / s中会发生什么。您不会确切地知道抛出异常的位置。没有更详细地检查异常对象....

答案 22 :(得分:1)

我觉得你的例子没有错。相反,忽略被调用函数抛出的异常将是一种罪过。

在JVM中,抛出异常并不是那么昂贵,只是用新的xyzException(...)创建异常,因为后者涉及堆栈遍历。因此,如果您事先创建了一些例外,您可以多次抛出它们而不需要任何费用。当然,这种方式你不能传递数据和异常,但我认为无论如何都要做坏事。

答案 23 :(得分:1)

有一些通用机制可以让语言允许方法退出而不返回值并放松到下一个“catch”块:

  • 让方法检查堆栈帧以确定调用站点,并使用调用站点的元数据来查找有关调用方法中try块的信息或调用的位置方法存储其调用者的地址;在后一种情况下,检查调用者的调用者的元数据以与直接调用者相同的方式确定,重复直到找到try块或堆栈为空。这种方法为无异常情况增加了很少的开销(它确实排除了一些优化),但是在发生异常时代价很高。

  • 让方法返回一个“隐藏”标志,该标志区分正常返回和异常,并让调用者检查该标志并分支到“异常”例程(如果已设置)。此例程向无异常情况添加1-2条指令,但发生异常时开销相对较小。

  • 让调用者将异常处理信息或代码放在相对于堆栈返回地址的固定地址处。例如,使用ARM,可以使用序列:

    ,而不是使用“BL子程序”指令
        adr lr,next_instr
        b subroutine
        b handle_exception
    next_instr:
    

要正常退出,子程序只会bx lrpop {pc};在异常退出的情况下,子程序将在执行返回之前从LR中减去4或使用sub lr,#4,pc(取决于ARM变化,执行模式等)如果调用者不是,则此方法将非常严重地发生故障旨在容纳它。

使用已检查异常的语言或框架可能会受益于使用上述#2或#3等机制处理的语言或框架,而使用#1处理未经检查的异常。尽管在Java中实现检查的异常是相当麻烦的,但如果有一种方法可以说呼叫站点可以说,它们也不是一个坏概念,实质上,“这种方法被宣布为抛出XX,但我不指望它永远这样做;如果确实如此,重新抛出一个“未经检查”的异常。在以这种方式处理已检查异常的框架中,它们可能是一种有效的流控制方法,用于解析方法,在某些情况下可能具有失败的可能性很大,但失败应该返回根本不同的信息而不是成功。但是我并不知道任何使用这种模式的框架。相反,更常见的模式是使用上面的第一种方法(最小的成本为no)所有例外情况都是例外情况,但抛出异常时成本很高。