哪种报告故障的方式更好?

8

很多时候,你会有一个函数,对于给定的参数无法生成有效的结果或者无法执行某些任务。除了异常,在C/C++世界中不太常用之外,基本上有两种报告无效结果的方法。

第一种方法是将有效返回值与不属于函数值域的值(通常为-1)混合在一起,并指示出错。

int foo(int arg) {
    if (everything fine)
        return some_value;
    return -1; //on failure
}

第二种方法是返回函数状态并通过引用传递结果。
bool foo(int arg, int & result) {
     if (everything fine) {
         result = some_value;
         return true;
     }
     return false;  //on failure
}

你更喜欢哪种方式?为什么?第二种方法中的附加参数是否会带来明显的性能开销?

23
异常在C++中常被使用。我认为它们比错误码更为普遍。 - Staffan
9
异常在C++中被广泛使用,但在C语言中不可用。因此,谈论“C/C++”世界并不完全正确。 - Fred Larson
12
没有所谓的“C/C++世界”。有C世界和C++世界。异常并非“新特性”。 - Nikko
5
@Fred:除非“C/C++”指的是当你拿走C++语言的一半特性(从异常处理开始)以消除它们低效的错误信念时所得到的残缺语言。可悲的是,这种情况在嵌入式软件开发中很常见。 - Mike Seymour
4
我相信它们是在1993年的C++版本4中引入的。当语言在1998年被标准化时,它们肯定已经得到了很好的发展。 - Mike Seymour
显示剩余8条评论
17个回答

15

不要忽略异常,因为它们代表的是非常罕见且意外的错误。

然而,仅仅回答你的问题,这个问题最终是主观的。关键问题是考虑哪种方式对你的消费者来说更容易使用,同时悄悄地提示他们记得检查错误条件。在我看来,这几乎总是"返回一个状态码,并将值放在单独的引用中",但这完全是一个人的个人观点。我提供的理由是...

  1. 如果你选择返回混合值,则已经过载了返回的概念,即“一个有用的值或一个错误代码”。过载单个语义概念可能会导致困惑,不知道应该如何处理它。
  2. 你通常无法轻松找到函数域中可用于错误代码的值,因此需要在单个API中混合和匹配两种错误报告样式。
  3. 几乎没有机会,如果他们忘记检查错误状态,他们会使用错误代码作为实际有用结果。可以返回错误代码,并将类似空值的概念插入返回引用中,在使用时会轻易失败。如果使用错误值混合返回模型,很容易将其传递到另一个函数中,其中协域的错误部分是有效输入(但在上下文中毫无意义)。

返回混合错误代码/值模型的论据可能是简单性--没有额外的变量浮动,这是其中之一。但对我来说,危险比有限的收益更糟糕-很容易忘记检查错误代码。这是使用异常的一个论据-如果没有处理它们,你的程序将会崩溃。


1
只要你只在真正的异常情况下使用异常,就可以使用+1。它们的解开和清理是一项昂贵的操作(相对而言)。提示就在名字中。 - Ragster
如果有一个明确定义的无效返回值的概念,那么这种方式的重载并不是什么大问题。例如,CreateFile在失败时返回一个NULL句柄,其中NULL始终被理解为无效。当没有这样的概念时,例如atoi在失败时返回0,这就不太好了。 - Steven Sudit
@Steven:我猜这个设计还有另一个问题——使用了错误的魔数。CreateFile在失败时返回的是INVALID_HANDLE_VALUE(~0)而不是NULL(0)。通常情况下,NULL用于未初始化但未分配的句柄变量,而INVALID_HANDLE_VALUE用于表示失败。 - Ben Voigt
1
@Steven:我并不是在争论你的观点,而是在举例说明我的新观点。使用魔法值不仅存在调用者忘记测试它们的风险,还存在编写测试代码错误的风险。另一个例子是将COM的HRESULT值与S_OK进行比较,而不是使用SUCCEEDED宏。 - Ben Voigt
@Ben:如果我误解了,对不起。是的,你说得对,所有这些魔术值都很危险,因为我们实际上是将一个原始类型假装具有丰富的语义。当然,将操作系统句柄保存在实际上(如果不是实际上)是void*的变量中是一种糟糕的方式。正确的答案是将这些返回值包装到实现适当的析构函数和有效性检查器的类中。因此,例如,其他地方提到的“ComResult”类确实在其“DidFail”函数中使用了SUCCEEDED宏。 - Steven Sudit
显示剩余3条评论

8
是一种优秀的技术。下面通过一个例子来说明。
假设你有一个返回 类型值的函数,当无法计算时,你想表示出一个错误。
double divide(double a, double b){
    return a / b;
}

当 b 为 0 时应该怎么做:

boost::optional<double> divide(double a, double b){
    if ( b != 0){
        return a / b;
    }else{
        return boost::none;
    }
}

请按照以下方式使用。

boost::optional<double> v = divide(a, b);
if(v){
    // Note the dereference operator
    cout << *v << endl;
}else{
    cout << "divide by zero" << endl;
}

3
+1,当我看到这个问题时,我的直觉告诉我是这样的,我很高兴我不是唯一一个这样想的人。这与Haskell中的“Maybe”结构完全对应,肯定非常自然。 - Matthieu M.
根据我的理解,实际上它不是一个合适的单子。http://xtargets.heroku.com/2010/06/03/using-boostoptional-as-a-range/将迭代支持添加到 boost::optional 中,这可能会使其更接近。 - bradgonesurfing

6
特殊返回值的概念在使用模板时完全崩溃了。请考虑以下内容:
template <typename T>
T f( const T & t ) {
   if ( SomeFunc( t ) ) {
      return t;
   }
   else {         // error path
     return ???;  // what can we return?
   }
}

在这种情况下,我们没有明显的特殊值可以返回,所以抛出异常真的是唯一的方法。返回布尔类型需要检查,并通过引用传递真正有趣的值会导致可怕的编码风格。


2
https://dev59.com/VHA75IYBdhLWcg3wsrSC#3157182 - JUST MY correct OPINION
1
我之所以做出了这个评价,是因为它有误导性。异常并不是唯一的方式。boost::optional 可以将值与错误分离开来。你可以把 optional 看作是一个只能包含零个或一个元素的容器,尽管你可能认为它应该可以迭代。实际上,我还因为他的 Maybe monad 对评论进行了点赞,这基本上只是 boost optional 加上一些额外的东西。我在这里有一个概念证明的实现。http://xtargets.heroku.com/2010/06/03/using-boostoptional-as-a-range/ - bradgonesurfing
抱歉投票否决了。我是新来的,刚刚获得了100个可用于投票否决的积分。我会谨慎使用这个新的权力的;)实际上,我在某个地方看到过boost::optional和异常技术的组合在一个单一的包中。它非常酷,但我现在想不起来了。基本类就像boost optional一样。然而,如果返回在检查之前被销毁,则会抛出异常。如果检查了它,即使出现错误,也不会抛出异常。它在单个API中为您提供了选择。 - bradgonesurfing
@bradgonesurfing,我已经绕过了这个问题,通过使用Microsoft扩展程序,您可以调用来确定您是否已经在异常处理中。 - Mark Ransom
@Mike 可能只是一个检查的选项而不是使用。即使Herb Sutter也表示不要使用。http://gotw.ca/gotw/047.htm - DumbCoder
显示剩余5条评论

4

许多书籍和文章都强烈建议使用第一种方式,这样你就不会混淆角色并强制返回值携带两个完全无关的信息。

虽然我同情这种观点,但实践中我发现第一种方法通常更好。显而易见的是,在第一种情况下,您可以将赋值链接到任意数量的接收者,但在第二种情况下,如果您需要/想要将结果分配给多个接收者,则必须进行调用,然后单独进行第二次赋值。即,

 account1.rate = account2.rate = current_rate();

vs.:

set_current_rate(account1.rate);
account2.rate = account1.rate;

或者:

set_current_rate(account1.rate);
set_current_rate(account2.rate);

布丁是否好吃,要靠品尝才能知道。例如,Microsoft的COM函数就只选择了后一种形式。我认为,正是因为这个决定,导致使用本地COM API的所有代码几乎都很难看和难以理解。这些概念并不特别难,但接口风格把本应简单的代码变成了几乎无法阅读的混乱。
通常情况下,异常处理比上述两种方式更好。它有三个具体的优点,都非常好。首先,它使主流逻辑不会被错误处理污染,因此代码的真实意图更加清晰。其次,它将错误处理与错误检测分离开来。检测问题的代码通常处于无法很好地处理该错误的位置。第三,与返回错误的两种形式不同,它基本上不可能忽略抛出的异常。对于返回代码,程序员总是有一个几乎恒定的诱惑(经常屈服于这个诱惑),即假设成功并不做任何捕获问题的尝试 - 特别是由于程序员根本不知道如何在代码的那部分处理错误,并且深知即使他捕获并从函数中返回错误代码,也有很大可能会被忽略。

不仅是COM,整个Win32 API都使用单独的失败和结果返回,有几个例外规则(但没有C++异常):返回HANDLE的函数具有INVALID_HANDLE_VALUE,而IUknown::AddRefIUnknown::Release不返回HRESULT。这也有一个很好的原因:这些API被用于调用各种语言编写的消费者。 - Ben Voigt
1
+1 是为了认识到需要将检测与处理分离。 - Steven Sudit
@Ben:Win32的其余部分对此的依赖性远不如你所暗示的那样可靠。有足够多的函数返回句柄,这本身就是一个巨大的例外。还有相当多的函数返回NULL来指示失败,包括HeapAllocCreateWindowCreateDCCreateICRegisterClassRegisterClassEx返回一个ATOM,或者0表示失败。RegisterWindowMessage返回一个消息标识符,或者0表示失败。这个列表还可以继续下去... - Jerry Coffin
@Steven:是的,完全可以(而且几乎必须)隐藏设计有多糟糕——但这并不改变它是一个糟糕的设计的事实... - Jerry Coffin
@Ben:我觉得你误解了我的意思。POSIX使用errno,Windows使用GetLastError,它们都是线程本地错误代码,但由于COM的线程模型,它无法这样做,因此它会在每次调用时返回HRESULT。你说的ISupportErrorInfo类似于GetLastError,因为它是一个额外的调用,我们可以在错误后进行调用以获取更多信息,但它并不完全相同,因为HRESULT不仅仅是它的最高位。 - Steven Sudit
显示剩余5条评论

2
在C语言中,我看到的一种较为常见的技术是函数在成功时返回零,在发生错误时返回非零值(通常是一个错误代码)。如果函数需要将数据传递回调用者,则通过作为函数参数传递的指针进行传递。这也可以使返回多个数据片段给用户的函数更加直观易用(与通过返回值和指针返回某些数据相比)。
另一种我看到的C语言技术是在成功时返回0,在错误时返回-1并设置errno以指示错误。
你提供的这些技术各有优缺点,因此决定哪种技术“最好”始终是主观的(至少部分是如此)。然而,我可以毫不保留地说:最好的技术是在整个程序中保持一致的技术。在程序的不同部分使用不同的错误报告代码风格很快就会成为维护和调试的噩梦。

1

你错过了一个方法:返回失败指示并需要额外调用以获取错误的详细信息。

这个方法有很多值得称赞的地方。

例如:

int count;
if (!TryParse("12x3", &count))
  DisplayError(GetLastError());

编辑

这个答案引起了相当多的争议和负评。坦白地说,我完全不被反对的论点所说服。将调用是否成功与失败原因分开已被证明是一个非常好的想法。将两者结合在一起会迫使你遵循以下模式:

HKEY key;
long errcode = RegOpenKey(HKEY_CLASSES_ROOT, NULL, &key);
if (errcode != ERROR_SUCCESS)
  return DisplayError(errcode);

与此形成对比的是:

HKEY key;
if (!RegOpenKey(HKEY_CLASSES_ROOT, NULL, &key))
  return DisplayError(GetLastError());

(GetLastError版本与Windows API的工作方式一致,但直接返回代码的版本是实际工作方式,因为注册表API不遵循该标准。)

无论如何,我建议错误返回模式使人们很容易忘记函数失败的原因,导致出现以下代码:

HKEY key;
if (RegOpenKey(HKEY_CLASSES_ROOT, NULL, &key) != ERROR_SUCCESS)
  return DisplayGenericError();

编辑

看了 R. 的请求后,我找到了一个实际可以满足该请求的场景。

对于一个通用的 C 风格 API,例如在我的示例中使用的 Windows SDK 函数,没有非全局上下文可用来存储错误代码。因此,我们别无选择,只能使用一个全局的 TLV(类型-长度-值),以便在失败后进行检查。

但是,如果将范围扩展到类的方法,则情况就不同了。如果有一个变量 regRegistryKey 类的实例,那么调用 reg.Open 返回 false 就是完全合理的,这时需要调用 reg.ErrorCode 来检索详细信息。

我认为这样就满足了 R. 的关于错误代码作为上下文的要求,因为实例提供了上下文。如果我们不是调用 RegistryKey 实例,而是调用静态的 Open 方法,则在失败后检索错误代码也必须是静态的,这意味着它必须是一个 TLV,虽然不是完全全局的。类本身作为上下文。

在这两种情况下,面向对象编程提供了一个自然的上下文来存储错误代码。话虽如此,如果没有自然的上下文,我仍然会坚持使用全局变量,而不是试图强制调用者传递输出参数或其他人工上下文,或直接返回错误代码。

5
除非您已经传递了可以保存错误的上下文结构,否则此方法非常糟糕!否则,它需要使用全局变量,并且除非采取令人讨厌的措施(无论是慢还是不可移植,或者两者兼而有之),才能保持每个线程的错误状态。 - R.. GitHub STOP HELPING ICE
2
说句实话,我不是那个给这个回答投反对票的人。如果修订一下以反映全局状态是不好的并建议将状态保留在上下文结构中,我实际上会认为这是一个好答案。 - R.. GitHub STOP HELPING ICE
@Ben:这在功能上与第二个代码块是相同的,但更加难以理解,因为它将赋值和条件测试组合在谓词中。我知道这种编码在C中通常被视为可接受的,但我并不是很喜欢。我在C ++中做了类似但更简洁的事情,使用了一个名为“ComResult”的帮助类。它的构造函数接受一个HRESULT并将其存储在TLV中。该实例可以隐式转换为bool以进行错误检测。默认构造函数从TLV检索值。它还具有“DidFail”、“DidSuccess”和“ThrowOnFail”等方法。 - Steven Sudit
1
坚持使用全局变量/全局状态继续倡导是不可取的。Windows(和POSIX)都有一种以线程安全方式在内部维护此状态的方法,但这并不能改变这样做对于设计自己的API来说是非常糟糕的实践事实,特别是因为无法以既快速又可移植的方式创建这样的线程本地状态。 - R.. GitHub STOP HELPING ICE
1
Steven说:“这几乎无关紧要,因为只有在指示失败后才进行检查。” 实际上,TLS变量必须在失败和成功时都进行分配,因此您也需要在快速路径上支付TLS查找惩罚。 - Ben Voigt
显示剩余22条评论

1

这两者之间不应该有太大的性能差异,选择取决于具体的使用情况。如果没有适当的无效值,则不能使用第一个。

如果使用C ++,则除了这两种方法之外,还有许多其他可能性,包括异常和使用类似boost :: optional作为返回值。


1

C语言通常使用第一种方法在有效结果中编码魔术值,这就是为什么你会得到像strcmp()在匹配时返回false(= 0)这样有趣的东西。

许多标准库函数的新版本采用第二种方法- 明确返回状态。

并且在这里不适合使用异常。异常是用于代码可能无法处理的特殊情况 - 你不能因为strcmp()未匹配就引发异常。


3
strcmp在匹配时不会返回"false"。与任何比较函数一样,它返回一个正数、负数或零值,反映第一个项目是否大于、小于或等于给定排序中的第二个项目。这并没有什么神奇的地方,它是常识。你是否希望(a-b)(用于数字ab的比较函数)在匹配时返回"true",否则返回"false"? - R.. GitHub STOP HELPING ICE
1
strcmp 的所有可能返回值都不表示错误,因此它既不是魔数的好例子,也不是异常使用的好例子。也许 fgetc(它返回魔数 EOF)会是一个更好的例子。 - Ben Voigt
1
这就是为什么当测试的值不是布尔值时,显式表达被认为是良好风格的原因:if (strcmp()==0) (pointer!=NULL) (number>0)。可悲的是,那些不阅读标准库函数定义的程序员也是那些不阅读有关良好和不良风格建议的人... ;) - Secure
@Martin:它们是不同的东西。他说的是引发错误,而不是问题是函数操作固有部分的函数。你是对的,有些函数失败并不是异常情况-但是,它们也不返回错误代码。我们谈论的是错误,而不是失败。 - Puppy
@安全:这就是为什么C#和Java不会自动将int转换为bool的原因。 :-) - Steven Sudit
显示剩余2条评论

1

虽然不总是可能,但无论使用哪种错误报告方法,最佳实践是尽可能设计一个函数,使其没有失败情况,并在不可能的情况下最小化可能的错误条件。以下是一些示例:

  • 不要通过许多函数调用深入传递文件名,而是设计程序,使调用者打开文件并传递FILE *或文件描述符。这消除了每个步骤中“无法打开文件”的检查并将其报告给调用者。

  • 如果有一种廉价的方法来检查(或找到上限)函数将需要为其构建和返回的数据结构分配的内存量,请提供一个函数来返回该数量,并让调用者分配内存。在某些情况下,这可能允许调用者简单地使用堆栈,大大减少内存碎片化并避免malloc中的锁定。

  • 当函数执行的任务需要大量工作空间时,请询问是否有具有O(1)空间要求的替代(可能更慢)算法。如果性能不关键,则只需使用O(1)空间算法。否则,实现回退案例以在分配失败时使用它。

这只是一些想法,但在整个应用相同的原则可以真正减少您需要处理并向上传播多个调用级别的错误条件的数量。


1
我觉得这不现实也没有帮助。 - Steven Sudit
我保证我的反应不是膝跳反应。第1点将禁止实用程序,例如允许您从文件名加载的XML DOM。第2点充其量只是一个小众答案,在一般情况下具有很少的适用性。第3点并非如此虚假而是无关紧要。归根结底,这是我在这里最大的抱怨:问题是如何报告*将 *发生的错误,而不是如何缩小该集合。虽然我完全赞成减少错误,但它没有解决手头的问题,也不总是值得这个代价。 - Steven Sudit
我从一开始就强调过,你无法消除所有错误(因此需要错误报告)。当可能的错误集合非常庞大时,大多数使用你的函数的编码人员会忽略错误或者只有一个思考不周的捕获所有错误的处理程序。如果可能的错误集合非常有限,调用者更容易处理它们。 - R.. GitHub STOP HELPING ICE
无论集合有多大,只要它是空的就没关系。如果调用者不知道如何处理特定的错误,它的责任就是记录并失败。异常可以自动完成这个过程。 - Steven Sudit
我已经厌倦了争论。由于这个问题涉及C/C++,我对覆盖没有异常的C情况很感兴趣。即使您有异常,调用者往往处理不当或根本不处理,因此减少错误案例的数量是值得的。顺便说一句,对于我的第二个具体示例(正则表达式编译器),您可以轻松地限制总编译大小和所需的工作空间。然后唯一的失败情况是如果整个初始分配失败,在这种情况下,调用者在进行调用之前就要负责。信不信由你,像这样的情况是常态而不是例外。 - R.. GitHub STOP HELPING ICE
显示剩余4条评论

1

对于C ++,我倾向于使用模板解决方案,可以避免输出参数的"难看"和联合答案/返回代码中"魔术数字"的"难看"。 我在回答另一个问题时阐述了这一点。 请看一下。

对于C语言,我认为丑陋的输出参数比丑陋的"魔术数字"更容易接受。


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