提问人:Peter 提问时间:4/8/2009 最后编辑:Peter 更新时间:9/25/2023 访问量:62802
为什么不使用异常作为常规控制流?
Why not use exceptions as regular flow of control?
问:
为了避免我本来可以在谷歌上搜索的所有标准答案,我将提供一个你们都可以随意攻击的例子。
C# 和 Java(以及许多其他类型)有很多类型,我根本不喜欢一些“溢出”行为(例如:)。type.MaxValue + type.SmallestValue == type.MinValue
int.MaxValue + 1 == int.MinValue
但是,看到我的恶毒本性,我会通过将这种行为扩展到,比方说被覆盖的类型来增加一些侮辱。(我知道是密封在 .NET 中的,但为了这个例子,我使用的是与 C# 完全一样的伪语言,除了 DateTime 没有被密封)。DateTime
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 结构更清晰,并且不会破坏该方法正在制定的任何合同。
恕我直言,“永远不要将它们用于常规程序流”的反应似乎并没有那么好,因为这种反应的强度可以证明是合理的。
还是我弄错了?
我读过其他帖子,处理各种特殊情况,但我的观点是,如果你们两者都是,那就没有错:
- 清楚
- 履行您的方法的合同
向我开枪。
答:
例外基本上是非本地语句,具有后者的所有后果。使用异常进行流控制违反了最小惊讶原则,使程序难以阅读(请记住,程序首先是为程序员编写的)。goto
此外,这不是编译器供应商所期望的。他们希望很少抛出异常,而且他们通常让代码效率低下。引发异常是 .NET 中最昂贵的操作之一。throw
但是,某些语言(尤其是 Python)使用异常作为流控制构造。例如,如果没有其他项,迭代器将引发异常。甚至标准语言结构(例如)也依赖于此。StopIteration
for
评论
goto
我的经验法则是:
- 如果可以执行任何操作来从错误中恢复,请捕获异常
- 如果错误非常常见(例如,用户尝试使用错误的密码登录),请使用 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.. :(
因此,就我个人而言,我认为您应该保留罕见错误情况(内存不足等)的异常,并使用返回值(值类、结构或枚举)来执行错误检查。
希望我理解你的问题正确:)
评论
你有没有试过调试一个程序,在正常操作过程中每秒引发五个异常?
我有。
该程序非常复杂(它是一个分布式计算服务器),在程序的一侧稍作修改就很容易在完全不同的地方破坏某些东西。
我希望我能启动程序并等待异常发生,但在正常操作过程中,启动期间大约有 200 个异常
我的观点:如果你在正常情况下使用异常,你如何定位异常(即异常)情况?
当然,还有其他充分的理由不要过多地使用异常,尤其是在性能方面
评论
我认为您可以使用 Exceptions 进行流量控制。然而,这种技术也有另一面。创建异常是一件代价高昂的事情,因为它们必须创建堆栈跟踪。因此,如果您想更频繁地使用 Exceptions,而不仅仅是为了发出异常情况的信号,则必须确保构建堆栈跟踪不会对性能产生负面影响。
降低创建异常成本的最佳方法是重写 fillInStackTrace() 方法,如下所示:
public Throwable fillInStackTrace() { return this; }
此类异常不会填充堆栈跟踪。
评论
raise
rescue
throw
catch
我觉得你的例子没有错。相反,忽略被调用函数抛出的异常将是一种罪过。
在 JVM 中,抛出异常的成本并不高,只需使用 new xyzException(...) 创建异常,因为后者涉及堆栈遍历。因此,如果您提前创建了一些异常,则可以多次抛出它们,而无需支付任何费用。当然,这样你就不能在异常的情况下传递数据,但我认为无论如何这是一件坏事。
评论
标准的 anwser 是例外不是常规的,应该在特殊情况下使用。
一个对我来说很重要的原因是,当我在我维护或调试的软件中读取控制结构时,我试图找出为什么原始编码人员使用异常处理而不是结构。我希望能找到一个好的答案。try-catch
if-else
请记住,您不仅要为计算机编写代码,还要为其他编码人员编写代码。有一个与异常处理程序相关的语义,你不能仅仅因为机器不介意就丢弃它。
评论
我真的不明白你是如何控制你引用的代码中的程序流的。除了 ArgumentOutOfRange 异常之外,您永远不会看到其他异常。(所以你的第二个捕获条款永远不会被击中)。你所要做的就是使用一个极其昂贵的投掷来模仿一个if语句。
此外,您没有执行更险恶的操作,您只是抛出一个异常,纯粹是为了在其他地方捕获它以执行流控制。您实际上正在处理一个特殊情况。
但是,您并不总是知道在调用的方法中会发生什么。你不会确切地知道异常是在哪里引发的。无需更详细地检查异常对象...。
对很多答案的第一反应:
你是在为程序员写作,也是最小惊讶原则
答案是肯定的!但是,如果只是并不总是更清楚。
这不应该令人惊讶,例如:除法 (1/x) 捕获 (divisionByZero) 对我来说(在 Conrad 和其他人)比任何都更清楚。这种编程不是预期的,这纯粹是传统的,事实上,仍然相关。也许在我的例子中,如果会更清楚。
但就此而言,DivisionByZero 和 FileNotFound 比 ifs 更清晰。
当然,如果它的性能较差并且每秒需要数以百万计的时间,您当然应该避免它,但我仍然没有读到任何充分的理由来避免整体设计。
就最小惊讶原则而言:这里存在循环推理的危险:假设整个社区使用糟糕的设计,这种设计将成为预期的!因此,该原则不能成为圣杯,应谨慎考虑。
正常情况下的例外情况,您如何定位异常(即特殊)情况?
在许多反应中。像这样闪耀着低谷。只要抓住他们,不是吗?你的方法应该很清楚,有据可查,并且遵守合同。我不明白这个问题,我必须承认。
对所有异常进行调试:相同,有时只是这样做,因为不使用异常的设计很常见。我的问题是:为什么它首先很常见?
评论
x
1/x
DivideByZeroException
DivideByZeroException
openConfigFile();
{ createDefaultConfigFile(); setFirstAppRun(); }
x
1/x
x
switch
假设您有一个执行一些计算的方法。它必须验证许多输入参数,然后返回一个大于 0 的数字。
使用返回值来表示验证错误,这很简单:如果方法返回的数字小于 0,则发生错误。如何判断哪个参数没有验证?
我记得在我的 C 时代,很多函数都返回了这样的错误代码:
-1 - x lesser then MinX
-2 - x greater then MaxX
-3 - y lesser then MinY
等。
它真的不如抛出和捕捉异常可读性吗?
评论
因为代码很难读,调试起来可能会有麻烦,长时间修复bug时会引入新的bug,在资源和时间方面更昂贵,如果你在调试代码时,调试器会在每次异常发生时停止;)
通常,在低级别处理异常本身并没有什么问题。异常是一条有效消息,它提供了大量详细信息,说明无法执行操作的原因。如果你能处理它,你应该处理它。
一般来说,如果您知道故障的可能性很高,您可以检查...你应该做检查...即 if(obj != null) obj.method()
就您而言,我对 C# 库不够熟悉,无法知道日期时间是否有一种简单的方法来检查时间戳是否越界。如果是这样,只需调用 if(.isvalid(ts)) 否则你的代码基本上没问题。
所以,基本上它归结为哪种方式可以创建更干净的代码......如果防范预期异常的操作比仅处理异常更复杂;而不是你有我的许可来处理异常,而不是到处创建复杂的守卫。
评论
性能如何?在对 .NET Web 应用进行负载测试时,我们最多只能为每个 Web 服务器 100 个模拟用户,直到我们修复了一个常见的异常,并且该数字增加到 500 个用户。
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,仍会抛出软异常,但是:
- 您无需将其记录下来。
- 你不需要测试它(取决于你的侵略性 单元测试策略是)。
- 您不需要调用方处理三个用例。
最根本的一点是,异常不应该被用作返回代码,主要是因为你不仅使你的 API 复杂化,而且使调用方的 API 也变得复杂。
当然,做正确的事是有代价的。代价是每个人都需要了解他们需要阅读和遵循文档。无论如何,希望情况是这样。
如果将异常处理程序用于控制流,则过于笼统和懒惰。正如其他人所提到的,如果您在处理程序中处理处理,您就会知道发生了一些事情,但究竟是什么?实质上,如果将 else 语句用于控制流,则将 exception 用于 else。
如果您不知道可能发生的状态,则可以将异常处理程序用于意外状态,例如,当您必须使用第三方库时,或者您必须捕获 UI 中的所有内容以显示漂亮的错误消息并记录异常。
但是,如果您确实知道可能出错的地方,并且您没有放置 if 语句或其他东西来检查它,那么您只是懒惰。让异常处理程序成为你知道可能发生的事情的包罗万象是懒惰的,它稍后会回来困扰你,因为你将试图根据一个可能错误的假设来修复异常处理程序中的情况。
如果你在异常处理程序中放置逻辑来确定到底发生了什么,那么你如果不将该逻辑放在 try 块中,那将是非常愚蠢的。
异常处理程序是最后的手段,因为当你用完了阻止某些事情出错的想法/方法,或者事情超出了你的控制能力时。例如,服务器已关闭并超时,您无法阻止引发该异常。
最后,预先完成所有检查可以显示您知道或期望会发生什么,并使其明确。代码的意图应该明确。你更愿意读什么?
评论
你可能有兴趣看看Common Lisp的条件系统,它是一种对正确完成的异常的泛化。因为你可以以受控的方式展开或不展开堆栈,所以你也可以“重启”,这非常方便。
这与其他语言的最佳实践没有太大关系,但它向你展示了在(大致)你所考虑的方向上,用一些设计思想可以做些什么。
当然,如果你像溜溜球一样在堆栈上上下弹跳,仍然需要考虑性能,但这是一个比大多数捕捉/抛出异常系统所体现的“哦,废话,让我们保释”的方法更普遍的想法。
我不认为使用 Exceptions 进行流量控制有什么问题。异常有点类似于延续,在静态类型语言中,异常比延续更强大,因此,如果您需要延续,但您的语言没有它们,您可以使用异常来实现它们。
好吧,实际上,如果你需要延续,而你的语言没有它们,你选择了错误的语言,你应该使用不同的语言。但有时你别无选择:客户端 Web 编程就是最好的例子——根本没有办法绕过 JavaScript。
举个例子:Microsoft Volta 是一个允许在简单的 .NET 中编写 Web 应用程序的项目,并让框架负责确定哪些位需要在哪里运行。这样做的一个结果是,Volta 需要能够将 CIL 编译为 JavaScript,以便您可以在客户端上运行代码。但是,存在一个问题:.NET 具有多线程,而 JavaScript 没有。因此,Volta 使用 JavaScript 异常在 JavaScript 中实现延续,然后使用这些延续实现 .NET Threads。这样,使用线程的 Volta 应用程序就可以被编译为在未经修改的浏览器中运行,而无需 Silverlight。
除了上述原因之外,不对流控制使用异常的一个原因是,它会使调试过程变得非常复杂。
例如,当我尝试在 VS 中跟踪错误时,我通常会打开“中断所有异常”。如果您使用异常进行流控制,那么我将定期中断调试器,并且必须继续忽略这些非异常异常,直到我找到真正的问题。这很可能会把人逼疯!!
评论
您可以使用锤子的爪子来转动螺丝,就像您可以使用异常来控制流一样。这并不意味着这是该功能的预期用途。该语句表示条件,其预期用途是控制流。if
如果您以非预期的方式使用某项功能,同时选择不使用为此目的设计的功能,则会产生相关成本。在这种情况下,清晰度和性能不会因真正的附加值而受到影响。使用例外对被广泛接受的陈述有什么好处?if
换一种说法:仅仅因为你可以并不意味着你应该这样做。
评论
if
public class ExitHelper{ public static void cleanExit() { cleanup(); System.exit(1); } }
ExitHelper.cleanExit();
if
在异常之前,在 C 语言中,存在 和 可用于完成堆栈帧的类似展开。setjmp
longjmp
然后,相同的构造被命名为:“Exception”。大多数答案都依赖于这个名称的含义来争论它的用法,声称例外是为了在特殊情况下使用。这从来都不是原著的意图。在某些情况下,您需要中断多个堆栈帧的控制流。longjmp
例外情况稍微通用一些,因为您也可以在同一堆栈帧中使用它们。这引发了我认为是错误的类比。Gotos 是一对紧密耦合的对(和 也是如此)。例外情况遵循松散耦合的发布/订阅,这要干净得多!因此,在同一个堆栈帧中使用它们与使用 s 几乎是一回事。goto
setjmp
longjmp
goto
混淆的第三个来源涉及它们是选中的还是未选中的例外。当然,未经检查的异常似乎特别糟糕,可用于控制流以及许多其他事情。
然而,选中的异常对于控制流非常有用,一旦你克服了所有维多利亚时代的挂断并生活了一点。
我最喜欢的用法是一长段代码中的一系列,它一个接一个地尝试一件事,直到它找到它要找的东西。每件事 - 每个逻辑 - 都可能有 arbritrary 嵌套,所以 就像任何类型的条件测试一样。图案很脆。如果我以其他方式编辑或搞砸语法,那么就会有一个毛茸茸的错误。throw new Success()
break
if-else
else
使用线性化代码流。我使用本地定义的类 - 当然是检查的 - 这样如果我忘记捕获它,代码将无法编译。而且我没有抓住其他方法的 es。throw new Success()
Success
Success
有时我的代码会一个接一个地检查一件事,只有在一切正常的情况下才会成功。在这种情况下,我使用进行了类似的线性化。throw new Failure()
使用单独的函数会扰乱自然的区隔级别。所以解决方案不是最优的。出于认知原因,我更喜欢在一个地方有一两页代码。我不相信超精细划分的代码。return
除非有热点,否则 JVM 或编译器所做的事情与我不太相关。我不相信编译器有任何根本原因不检测本地抛出和捕获的异常,而只是将它们视为机器代码级别的非常有效的异常。goto
至于跨函数使用它们来控制流 - 即用于常见情况而不是特殊情况 - 我看不出它们如何比多次中断、条件测试、返回以涉足三个堆栈帧而不是仅仅恢复堆栈指针。
我个人不会在堆栈框架中使用这种模式,我可以看到它需要设计复杂性才能优雅地做到这一点。但谨慎使用应该没问题。
最后,关于令人惊讶的新手程序员,这不是一个令人信服的理由。如果你温柔地向他们介绍这种做法,他们就会学会爱上它。我记得C++曾经让C程序员感到惊讶和害怕。
评论
return
有几种通用机制,通过这些机制,语言可以允许方法在不返回值的情况下退出并展开到下一个“捕获”块:
让该方法检查堆栈帧以确定调用站点,并使用调用站点的元数据查找有关调用方法中块的信息,或调用方法存储其调用方地址的位置;在后一种情况下,检查调用方的调用方的元数据,以与直接调用方相同的方式进行确定,重复直到找到块或堆栈为空。这种方法对无异常情况增加的开销非常小(它确实排除了一些优化),但在发生异常时成本很高。
try
try
让该方法返回一个“隐藏”标志,用于区分正常返回和异常,并让调用方检查该标志并分支到“异常”例程(如果已设置)。此例程将 1-2 条指令添加到无异常情况中,但在发生异常时开销相对较小。
让调用方将异常处理信息或代码放在相对于堆叠返回地址的固定地址。例如,对于 ARM,可以使用以下序列,而不是使用指令“BL 子例程”:
adr lr,next_instr b subroutine b handle_exception next_instr:
要正常退出,子例程只需执行 或 ;如果出现异常退出,子程序将在执行返回或使用之前从 LR 中减去 4(取决于 ARM 变体、执行模式等)。如果调用方的设计不适应此方法,则此方法将出现非常严重的故障。bx lr
pop {pc}
sub lr,#4,pc
使用已检查异常的语言或框架可能会受益于使用上述 #2 或 #3 等机制处理这些异常,而使用未经检查的异常使用 #1 进行处理。尽管在 Java 中实现 checked 异常是相当令人讨厌的,但如果有一种方法可以让调用站点从本质上说“此方法被声明为抛出 XX,但我不希望它这样做;如果是这样,则作为“未选中”异常重新抛出。在以这种方式处理已检查异常的框架中,它们可以成为诸如解析方法之类的有效流控制手段,在某些情况下,这些方法可能很有可能失败,但失败应该返回与成功截然不同的信息。但是,我不知道有任何使用这种模式的框架。相反,更常见的模式是使用上述第一种方法(无异常情况的成本最低,但引发异常时成本高)用于所有异常。
一个美学原因:
尝试总是伴随着一个问题,而一个如果不一定伴随着其他问题。
if (PerformCheckSucceeded())
DoSomething();
有了 try/catch,它变得更加冗长。
try
{
PerformCheckSucceeded();
DoSomething();
}
catch
{
}
这 6 行代码太多了。
正如其他人多次提到的那样,最小惊讶原则将禁止您仅出于控制流目的过度使用异常。另一方面,没有一个规则是 100% 正确的,而且总是有一些例外是“恰到好处的工具”的情况——顺便说一句,它很像它本身,它以 Java 等语言的形式提供,这通常是跳出大量嵌套循环的完美方式,这并不总是可以避免的。goto
break
continue
下面的博客文章解释了一个相当复杂但也相当有趣的非本地用例: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()
方法生成一个 or 语句,具体取决于 的内部标志。从“外部”来看,我们不知道包含什么样的逻辑(例如乐观锁定、事件侦听器处理等),因此当我们在批处理语句中存储多条记录时,我们不想重复该逻辑,我们希望只生成 SQL 语句,而不是实际执行它。例:INSERT
UPDATE
Record
store()
store()
// 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()); } }
如果我们将逻辑外部化为“可重用”的 API,可以自定义为选择不执行 SQL,我们将考虑创建一个相当难以维护、几乎不可重用的 API。
store()
结论
从本质上讲,我们对这些非局部 s 的使用只是沿着 [Mason Wheeler][5] 在他的回答中所说的:goto
“我刚刚遇到了一个我现在无法正确处理的情况,因为我没有足够的上下文来处理它,但打电话给我的例程(或调用堆栈中更上层的东西)应该知道如何处理它。”
与它们的替代方案相比,这两种用法都相当容易实现,允许我们重用各种逻辑,而无需从相关内部重构它。ControlFlowExceptions
但是,对于未来的维护者来说,这有点令人惊讶的感觉仍然存在。代码感觉相当微妙,虽然在这种情况下它是正确的选择,但我们总是不希望对本地控制流使用异常,因为很容易避免使用普通的分支。if - else
以下是我在博客文章中描述的最佳实践:
- 引发异常以说明软件中的意外情况。
- 使用返回值进行输入验证。
- 如果您知道如何处理库抛出的异常,请在尽可能低的级别捕获它们。
- 如果出现意外异常,请完全放弃当前操作。不要假装你知道如何对付他们。
评论
if