你更喜欢异常还是返回代码?为什么?

106

我的问题是大多数开发人员在错误处理方面更喜欢异常还是错误返回码。请具体说明语言(或语言系)以及为什么您更喜欢其中之一。

我出于好奇问这个问题。个人更喜欢错误返回码,因为它们不会像异常那样爆炸,并且如果用户不想要,也不会强制使用户代码支付异常性能损失。

更新:感谢所有回答!虽然我不喜欢异常的代码流的不可预测性。但有关返回代码(以及其前辈处理程序)的答案确实为代码添加了许多噪音。


2
软件世界中的鸡生蛋问题......永远存在争议。 :) - Gishu
抱歉,但希望有多种不同的意见可以帮助人们(包括我自己)做出适当的选择。 - Robert Gould
通常情况下,使用异常来控制程序流程是一种反模式。为什么这样说呢?请参考以下链接:https://softwareengineering.stackexchange.com/questions/189222/are-exceptions-as-control-flow-considered-a-serious-antipattern-if-so-why - Michael Freidgeim
26个回答

122

对于某些语言(如C ++),资源泄漏不应该是一个理由

C ++基于RAII。

如果您有可能失败的代码,需要返回或抛出异常(也就是大多数正常代码),那么您应该将指针包装在智能指针内(假设您有一个非常好的理由不在堆栈上创建对象)。

返回代码更冗长

它们很冗长,往往会发展成类似以下内容:

if(doSomething())
{
   if(doSomethingElse())
   {
      if(doSomethingElseAgain())
      {
          // etc.
      }
      else
      {
         // react to failure of doSomethingElseAgain
      }
   }
   else
   {
      // react to failure of doSomethingElse
   }
}
else
{
   // react to failure of doSomething
}

最终,你的代码是一组缩进的指令(我在生产代码中看到了这种代码)。

这段代码很可能会被翻译成:

try
{
   doSomething() ;
   doSomethingElse() ;
   doSomethingElseAgain() ;
}
catch(const SomethingException & e)
{
   // react to failure of doSomething
}
catch(const SomethingElseException & e)
{
   // react to failure of doSomethingElse
}
catch(const SomethingElseAgainException & e)
{
   // react to failure of doSomethingElseAgain
}

代码和错误处理可以干净地分离,这样做可能是一件好事情。

返回码更加脆弱

除了来自某个编译器的某些晦涩警告(参见“phjr”的评论)之外,它们很容易被忽略。

使用上述示例,假设有人忘记处理其可能的错误(这种情况会发生...)。当“返回”时,错误被忽略,并且可能在以后引起问题(例如,空指针)。异常则不会出现同样的问题。

错误不会被忽略。但有时,您希望它不会引起问题...因此,您必须仔细选择。

返回码有时必须进行转换

假设我们有以下函数:

  • doSomething,它可以返回名为NOT_FOUND_ERROR的int
  • doSomethingElse,它可以返回布尔值“false”(表示失败)
  • doSomethingElseAgain,它可以返回一个Error对象(包括__LINE__,__FILE__和一半的堆栈变量)。
  • doTryToDoSomethingWithAllThisMess,它...使用上述函数,并返回类型的错误代码...

如果其中一个调用的函数失败,则doTryToDoSomethingWithAllThisMess的返回类型是什么?

返回码不是通用解决方案

操作符无法返回错误代码。C++构造函数也无法返回错误代码。

返回码意味着您无法链接表达式

上述观点的必然结果。如果我想编写:

CMyType o = add(a, multiply(b, c)) ;

由于返回值已经被使用(有时无法更改),所以我不能这样做。因此,返回值成为第一个参数,并作为引用发送...或者不是。

异常是有类型的

您可以为每种异常发送不同的类。资源异常(即内存不足)应该很轻,但其他任何异常都可以尽可能重(我喜欢 Java 异常给出整个堆栈)。

然后每个 catch 就可以进行特化。

永远不要使用 catch(...) 而不重新抛出

通常,不应该隐藏错误。如果您不重新抛出,至少在文件中记录错误,在消息框中打开它,或者采取其他措施...

异常是...核弹

异常的问题在于过度使用它们会产生充满 try/catch 的代码。但问题在于:谁使用 STL 容器来 try/catch 他/她的代码?尽管如此,这些容器仍然可以发送异常。

当然,在 C++ 中绝不要让异常从析构函数中退出。

异常是...同步的

确保在它们使您的线程跪下之前捕获它们,或在您的 Windows 消息循环内传播。

解决方案可以是将它们混合使用吗?

所以我想解决方案是当某些事情 不应该 发生时抛出异常。当某些事情可能发生时,然后使用返回代码或参数来使用户对其做出反应。

因此,唯一的问题是“什么是不应该发生的事情?”

这取决于您的函数契约。如果函数接受指针,但指定指针必须为非 NULL,则在用户发送 NULL 指针时抛出异常是可以的(问题是,在 C++ 中,函数作者何时没有使用引用而使用指针,但...)

另一种解决方法是显示错误

有时,问题在于您不想要错误。使用异常或错误返回代码很酷,但是......您需要知道这一点。

在我的工作中,我们使用一种“断言”的方式。根据配置文件的值,在不管是调试还是发布编译选项的情况下,它将:

  • 记录错误
  • 打开一个消息框,上面写着“嘿,您有一个问题”
  • 打开一个消息框,上面写着“嘿,您有一个问题,要调试吗”

在开发和测试中,这使用户能够在检测到问题时准确定位问题,而不是之后(当某些代码关心返回值或在 catch 内部时)。

可以轻松添加到遗留代码中。例如:

void doSomething(CMyObject * p, int iRandomData)
{
   // etc.
}

领导一种类似于以下代码的代码:

void doSomething(CMyObject * p, int iRandomData)
{
   if(iRandomData < 32)
   {
      MY_RAISE_ERROR("Hey, iRandomData " << iRandomData << " is lesser than 32. Aborting processing") ;
      return ;
   }

   if(p == NULL)
   {
      MY_RAISE_ERROR("Hey, p is NULL !\niRandomData is equal to " << iRandomData << ". Will throw.") ;
      throw std::some_exception() ;
   }

   if(! p.is Ok())
   {
      MY_RAISE_ERROR("Hey, p is NOT Ok!\np is equal to " << p->toString() << ". Will try to continue anyway") ;
   }

   // etc.
}

(我有一些类似的宏,它们仅在调试状态下激活。)

请注意,在生产环境中,配置文件不存在,因此客户端永远不会看到此宏的结果......但在需要时很容易激活它。

结论

当您使用返回代码编码时,您正在为失败做准备,并希望您的测试堡垒足够安全。

当您使用异常编码时,您知道您的代码可能会失败,并通常在代码中选择的战略位置放置反击捕获。但通常情况下,您的代码更多地涉及“它必须做什么”而不是“我担心会发生什么”。

但是,当您编写代码时,必须使用最好的可用工具,有时最好的方法是“永远不要隐藏错误,并尽快显示错误”。我上面提到的宏遵循这一哲学。


7
关于第一个例子,可以简单地写成:如果(!doSomething()) { puts("ERROR - doSomething failed"); 返回;// 或者对doSomething的失败做出反应 } 如果(!doSomethingElse()) { // 对doSomethingElse的失败做出反应() } - bobobobo
3
你的第一点展示了异常处理的问题所在。控制流变得违反常规,与实际问题分离。异常处理会将一些缩进级别替换为一系列catch块。我建议使用返回码(或包含详细信息的返回对象)来处理可能出现的错误,而对于不是“预期”的情况则使用异常处理。 - Peter
3
@Peter Weber: 这并不奇怪。因为它不是正常执行流程的一部分,所以它与实际问题分离开来。它是一种异常执行。而且,关于异常的重点在于,对于异常错误,要经常抛出而很少捕获,甚至从不捕获。因此,catch块在代码中很少出现。 - paercebal
1
这种辩论中的例子非常简单。通常,“doSomething()”或“doSomethingElse()”实际上会执行一些操作,比如改变某个对象的状态。异常代码不能保证将对象返回到先前的状态,尤其是当catch与throw相距很远时更是如此...例如,想象一下doSomething被调用两次,并在抛出之前递增计数器。当捕获异常时,您如何知道应该递减一次还是两次?总的来说,编写除玩具示例以外的任何东西的异常安全代码都非常困难(不可能?)。 - xryl669
@paercebal:你说得完全正确。我的观点是,关于“使用异常”或“返回错误代码”的争论过于简单化,因为真正的争论在于如何确保程序按照预期运行(就像你展示的那样,采用异常=>事务模型),或者仅仅采用“在第一个错误处中止”的方式。编写安全的异常代码,如果达到了,是最好的解决方案。但是,所有底层代码都必须具有相同的严谨性,否则你会被它击败。找到良好的异常安全代码很少见,这往往倾向于根本不使用异常,因为在这种情况下无法保证你的代码。 - xryl669
显示剩余8条评论

43

我实际上两种都使用。

如果是已知的可能出现错误的情况,我会使用返回代码。如果是我知道会发生的情况,则会发送一个代码。

异常仅用于我没有预料到的事情。


+1 对于非常简单的方法来说,这是一个很好的选择。至少它足够短,我可以很快地阅读它。 :-) - Ashish Gupta
2
“异常仅用于我不期望的情况。” 如果您不期望它们,那么为什么或如何使用它们呢? - anar khalilov
6
我并不期望它会发生,但这并不意味着我看不出它可能会发生。我期望我的SQL服务器开启并能响应请求,但我仍然编写代码以满足我失败时的优雅处理,即使意外宕机发生。 - Stephen Wrighton
2
一个无响应的 SQL Server 不就应该轻易地归类为“已知、可能的错误”吗? - tkburbidge
1
@StephenWrighton,如果你能看到某件事情的发生方式,那么你怎么能不期望它呢?这是双向的。你似乎在做出不同的区分。 - symbiont
@symbiont 也许最好谈一谈哪些在你的业务中有意义,哪些没有。例如,你有一个预定系统,在服务器宕机时会抛出错误,但当用户尝试预订已被其他人预订的东西时会返回代码。 - Brewal

25
根据《Framework Design Guidelines: Conventions, Idioms, and Patterns for Reusable .NET Libraries》第7章“异常”所述,对于像C#这样的面向对象框架,使用异常而不是返回值是必要的,并给出了许多理由。
可能最具有说服力的原因是(第179页):
“异常很好地集成了面向对象语言。面向对象语言倾向于对成员签名施加约束,而这些约束不会被非面向对象语言中的函数施加。例如,在构造函数、操作符重载和属性的情况下,开发人员没有选择返回值的权利。因此,不可能在面向对象框架中标准化基于返回值的错误报告。只有一种超出方法签名的错误报告方法,如异常,才是唯一的选择。”

1
强烈推荐阅读本章。该章节提供了一个非常系统化的错误/异常处理指南,包含许多在线上找不到的注意事项和建议。 - Zhe

11

我偏好使用异常来处理C++和Python中的错误。这些语言提供的功能使得抛出、捕获和(必要时)重新抛出异常成为一个明确定义的过程,使得模型易于理解和使用。从概念上讲,使用异常比返回代码更清晰,因为特定的异常可以通过它们的名称进行定义,并且伴随着额外的信息。如果使用返回代码,则仅限于错误值(除非你想定义一个ReturnStatus对象或类似的东西)。

除非您正在编写时间关键性的代码,否则与展开栈帧相关的开销不足以担忧。


3
请记住,使用异常会使程序分析变得更加困难。 - Paweł Hajdan
1
使用异常可以使程序分析变得更容易。这不是一个规则。当调用堆栈变得很深时,错误代码返回可能会成为一个噩梦。 - undefined

9

默认情况下始终使用异常,但考虑提供额外的测试者-执行者选项(TryX)!

对我来说,答案非常明确。当上下文需要尝试或测试者-执行者模式(即CPU密集型或公共API)时,我会额外提供这些方法以实现异常抛出版本。我认为避免异常的普遍规则是误导性的、不被支持的,并且可能导致更多的错误成本,而不是所声称的任何性能问题。

不,Microsoft 没有说不要使用异常(常见的误解)。

它说如果你正在设计一个 API,请提供帮助该 API 的用户避免抛出异常的方法(尝试和测试者-执行者模式)。

如果可能的话,不要在正常控制流程中使用异常。

除了系统故障和具有潜在竞争条件的操作之外,框架设计人员应该设计 API,以便用户可以编写不会抛出异常的代码。例如,您可以提供一种在调用成员之前检查先决条件的方法,以便用户可以编写不会抛出异常的代码。

推断出的是非测试者/执行者或非 try 实现应在失败时抛出异常,然后用户可以将其更改为您的测试者/执行者或 try 方法以提高性能。成功之坑保持安全,用户选择更危险但性能更佳的方法。

Microsoft 在这里链接1明确表示不要使用返回代码:

❌ 不要返回错误代码。

异常是报告框架中错误的主要方式。

✔️ 通过引发异常来报告执行失败。

并且在这里链接2

❌ 不要使用错误码,因为担心异常会对性能产生负面影响。

为了提高性能,可以使用 Tester-Doer 模式或 Try-Parse 模式,这两种模式在下面的两个部分中进行了描述。

如果您不使用异常,则可能违反了从非测试器/非尝试实现返回返回代码或布尔值的另一条规则。再次强调,TryParse 不能替代 Parse。它是作为 Parse 的补充提供的。

主要原因:返回代码几乎每次都无法通过 "Pit of Success" 测试。

  • 很容易忘记检查返回代码,然后在后面出现红鱼问题。
    • var success = Save()?为了这里忘记if检查值得多少性能?
    • var success = TrySave()?更好的选择,但是我们会滥用TryX模式吗?您是否仍然提供了Save方法?
  • 返回代码没有任何调试信息,如调用堆栈、内部异常。
  • 返回代码不会传播,这与上述情况一起倾向于驱动过度和交织的诊断日志记录,而不是在一个集中的位置记录日志(应用程序和线程级别的异常处理程序)。
  • 返回代码倾向于以嵌套的“if”块形式驱动混乱的代码。
  • 开发人员花费时间调试未知问题,否则这将是一个明显的异常(成功之坑)是昂贵的。
  • 如果C#团队没有打算使用异常来控制流程,则异常不会被分类,catch语句上不会有“when”筛选器,并且不需要无参数的“throw”语句。

关于性能:

  • 相对于不抛出异常,异常可能是计算上昂贵的,但它们之所以被称为异常,是有原因的。速度比较总是假定100%的异常率,这应该永远不会发生。即使异常慢100倍,如果只发生1%的时间,那真的有多重要呢?

  • 上下文是一切。例如,为避免唯一键冲突而使用测试-执行或尝试选项往往会浪费更多平均时间和资源(在冲突很少时检查存在性)而不是假设成功输入并捕获这种罕见的冲突。

  • 除非我们谈论图形应用程序等浮点算术,否则相对于开发人员时间,CPU周期是便宜的。

  • 从时间角度看,成本也有同样的论点。相对于数据库查询、Web服务调用或文件加载,正常应用程序时间将使异常时间相形见绌。2006年,异常几乎只需要微秒级

  • 我敢挑战任何在.NET工作的人,将您的调试器设置为在所有异常上中断,并禁用我的代码,看看有多少异常已经发生,您甚至不知道。

  • Jon Skeet说"[Exceptions are] not slow enough to make it worth avoiding them in normal use"。链接的回复还包含Jon撰写的两篇文章。他的概括主题是异常很好,如果你将它们作为性能问题体验到,那么可能存在更大的设计问题。


1
我无论如何也不会从微软那里接受编程建议。该公司拥有业内最大的预算和时间,却开发出了一些最糟糕的软件。 - undefined
我明白这是一个与语言无关的讨论,但我相信这些参考资料对于那些在MS平台上进行编程的人来说是值得引用的。此外,我提供这些参考资料更多是为了反驳那种应该避免使用异常的观点,我认为这是由于对文档的错误解读而产生的常见误解。 - undefined
我并不是在质疑你的结论,@b_levitt,只是对从微软那里获取设计建议的想法有所反应。但是仔细阅读你的帖子后,我明白了,微软可能是在给出如何更好地使用他们软件的特定功能的建议,以避免性能问题。真庆幸我没有使用那套软件。 - undefined

8

只有在发生意料之外的情况下才应该返回异常。

历史上,异常的另一个重要作用是因为返回代码本质上是专有的。有时从C函数返回0表示成功,有时返回-1或其中任何一个表示失败,而1表示成功。即使它们被枚举,枚举也可能会存在歧义。

异常还可以提供更多信息,特别是明确说明“出了什么问题,这是什么,堆栈跟踪以及一些支持上下文的信息”

话虽如此,良好枚举的返回代码对于已知的结果集可能是有用的,简单地说就是“这个函数有n种结果,并且它只是按照这种方式运行”


1
这是不正确的。这取决于领域和设计。在Python中,使用异常来控制流程是正常的,并且已经内置在语言中。 - undefined

7
在Java中,我按以下顺序使用:
  1. 设计契约(确保在尝试可能失败的任何操作之前满足前提条件)。这可以捕获大多数问题,并返回错误代码。

  2. 在处理工作时返回错误代码(如果需要,则执行回滚)。

  3. 异常,但仅用于意外情况。


1
使用断言来进行合约不是更加正确吗?如果合约被破坏了,那么就没有什么可以拯救你了。 - Paweł Hajdan
@PawełHajdan,我相信断言默认是禁用的。这与C语言中的assert有同样的问题,即除非始终使用断言运行生产代码,否则无法捕获问题。我倾向于将断言视为在开发过程中捕获问题的一种方式,但仅适用于会始终断言或不断言的内容(例如具有常量的内容,而不是具有变量或任何其他可能在运行时更改的内容)。 - paxdiablo
1
十二年才回答你的问题,我应该开个帮助台 :-) - paxdiablo

7

我不喜欢返回码,因为它们会导致以下模式在你的代码中繁衍蔓延。

CRetType obReturn = CODE_SUCCESS;
obReturn = CallMyFunctionWhichReturnsCodes();
if (obReturn == CODE_BLOW_UP)
{
  // bail out
  goto FunctionExit;
}

很快,一个由4个函数调用组成的方法调用会因为12行错误处理而变得臃肿不堪...其中一些永远不会发生。如果有大量的if和switch语句,就会更加繁琐。
如果你使用异常处理得当,异常处理会更加简洁...可以用来表示异常事件...在此之后,执行路径无法继续。它们通常比错误代码更具描述性和信息性。
如果在方法调用之后有多个状态需要以不同的方式处理(并且不是异常情况),请使用错误代码或输出参数。虽然我个人认为这种情况很少发生...
我已经对“性能惩罚”反驳进行了一些调查...主要是在C++ / COM领域,但在新的编程语言中,我认为差异并不大。无论如何,当某些事情爆炸时,性能问题都被放在次要位置 :)

4

我之前写过一篇关于这个的博客文章

抛出异常所带来的性能开销不应该影响你的决策。毕竟,如果你做得对,一个异常是异常的。


这篇博客文章更多地涉及到“三思而后行”(检查)与“宁可请求原谅,而不是事先获得许可”(异常或返回代码)之间的问题。我已经在那里回答了关于这个问题的想法(提示:TOCTTOU)。但是这个问题是关于在什么条件下使用语言的异常机制而不是返回具有特殊属性的值的问题。 - Damian Yerrick
我完全同意。看来在过去的九年里,我学到了不少东西 ;) - Thomas
1
异常并不一定是异常的。在Python中,异常是正常流程控制的一部分(我认为这是众所周知的)。如果符合你的设计,你可以决定使用它们,但也有其他模型可供选择。比较两者之间的行为差异,并基于此进行决策是一个好主意。请参考其他答案和评论。 - undefined
1
在Python中,异常并不昂贵,有时甚至可能更快更轻巧。这种声誉来自于像Java这样的旧语言。 - undefined

4
我有一套简单的规则:
1)对于您希望立即调用者做出反应的事情,请使用返回代码。
2)对于范围更广的错误,可以合理地期望由许多层次以上的调用者来处理异常,以便错误意识不必通过许多层面上升,使代码更加复杂。在Java中,我只使用未经检查的异常,经过我的经验,检查的异常最终只是另一种形式的返回代码,而且方法调用可能会“返回”的二元性通常更多的是妨碍而不是帮助。

1
这绝对是异常提供的一个重大区别:能够将流程控制回退到几个层级之上。我在一个需要执行多个层级的服务中使用了这个功能,根据输入调用进一步的层级,寻找问题集的完成情况。因此,这个执行过程自然会有很多死胡同的路径,最高效和简单的方法就是对任何证明是无效的执行线程引发异常,从而自动退出整个执行树。如果使用返回码来实现,将会是一场噩梦。 - undefined

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