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

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

1

一个审美的原因:

尝试总是伴随着一些限制,而条件语句不必伴随着其他语句。

if (PerformCheckSucceeded())
   DoSomething();

用try/catch语句,会变得更冗长。
try
{
   PerformCheckSucceeded();
   DoSomething();
}
catch
{
}

这是6行代码太多了。

1

有几种通用机制可以使一种语言允许一个方法在不返回值的情况下退出并解开到下一个“catch”块:

  • 让该方法检查堆栈帧以确定调用点,并使用调用点的元数据来查找调用方法中的try块的信息,或者是调用方法存储其调用者地址的位置;在后一种情况下,检查调用者的元数据以确定与直接调用者相同方式,重复直到找到try块或堆栈为空。这种方法对于无异常情况几乎没有额外开销(它确实会阻止一些优化),但当出现异常时代价很高。

  • 让该方法返回一个“隐藏”的标志,将正常返回与异常区分开,并让调用者检查该标志并跳转到“异常”例程(routine)如果设置了该标志。这个例程对于无异常情况添加1-2条指令,但在出现异常时开销相对较小。

  • 让调用者将异常处理信息或代码放置在相对于堆栈返回地址的固定地址。例如,在ARM上,可以使用指令“BL subroutine”而不是使用以下序列:

        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异常,但我不希望它这样做;如果它这样做了,请将其重新抛出为“未经检查”的异常。”在一个处理已检查异常的框架中,它们可以是流程控制的有效手段,例如解析方法,在某些情况下可能具有高失败率,但失败应返回与成功根本不同的信息。然而,我不知道任何使用这种模式的框架。相反,更常见的模式是对所有异常使用上述第一种方法(在没有异常的情况下成本最小,但当抛出异常时成本很高)。

1

我认为你的例子没有任何问题。相反,忽略被调用函数抛出的异常是不道德的。

在JVM中,抛出异常并不会太耗费资源,只有使用new xyzException(...)创建异常时才会涉及到堆栈跟踪。因此,如果您提前创建了一些异常,可以多次抛出它们而不会产生额外的开销。当然,这种方式无法将数据与异常一起传递,但我认为这本身就是一件不好的事情。


在这种情况下,我喜欢使用 .net 的 Double.TryParse 方法。既没有代码重复,也没有异常。 - Brann
不,那不是我的意思。我只在正常操作过程中认为会发生错误时(通常是在用户输入的数据被解析时)才使用double.TryParse。我主张仅在异常情况下使用异常。 - Brann
1
Ingo:异常情况是指你没有预料到的情况,也就是作为程序员没有想到的情况。因此,我的原则是“编写不会抛出异常的代码” :) - Brann
1
我从不编写异常处理程序,我总是解决问题(除非我无法控制故障代码)。而且,除非我编写的代码旨在供他人使用(例如库),否则我从不抛出异常。没有显示矛盾之处吗? - Brann
1
我同意你的观点,不应该随意抛出异常。但是,“异常”的定义确实是一个问题。例如,如果String.parseDouble无法提供有用的结果,则抛出异常是可以的。它还应该做什么?返回NaN?那非IEEE硬件怎么办? - Ingo
显示剩余5条评论

1

但是你并不总是知道调用的方法中发生了什么。你不知道异常抛出的确切位置。如果不对异常对象进行更详细的检查......


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