使用断言或者异常按合同进行设计?

时间:2020-03-06 14:33:41  来源:igfitidea点击:

通过合同进行编程时,功能或者方法首先要检查其准备工作是否已满足,然后再开始执行其职责,对吗?进行这些检查的两种最突出的方法是"断言"和"异常"。

  • assert仅在调试模式下失败。为了确保对所有单独的合同前提条件(单元)进行测试(以查看它们是否确实失败)至关重要。
  • 异常在调试和发布模式下失败。这样做的好处是,测试的调试行为与发布行为相同,但是会导致运行时性能下降。

我们认为哪一个更可取?

在这里查看相关问题

解决方案

在发行版本中禁用assert就像说"发行版本中我永远不会有任何问题",通常不是这种情况。因此,不应在发行版本中禁用assert。但是,我们也不想在发生错误时崩溃发行版本,不是吗?

因此,请使用异常并很好地使用它们。使用一个良好的,可靠的异常层次结构,并确保我们可以捕获,并且可以将异常投掷到调试器中以捕获异常,并且在发布模式下,我们可以补偿错误,而不是直接崩溃。这是更安全的方法。

我们正在询问设计时错误和运行时错误之间的区别。

断言是"嘿,程序员,这是坏的"通知,它们在那里提醒我们发生错误时我们不会注意到的错误。

例外是"嘿,用户,出了点问题"通知(显然,我们可以编写代码来捕获它们,以使用户永远不会被告知),但是这些通知旨在在Joe用户使用应用程序的运行时发生。

因此,如果我们认为可以消除所有错误,请仅使用异常。如果我们认为自己无法...,请使用例外。当然,我们仍然可以使用调试断言来减少异常数量。

不要忘记许多前提条件是用户提供的数据,因此我们将需要一种很好的方式来告知用户他的数据不好。为此,我们通常需要将错误数据从调用堆栈中返回到与之交互的位。断言会变得毫无用处,因此如果应用程序是n层的,那么它就会变得更加有用。

最后,对于我们认为会定期发生的错误,我不会使用任何一种错误代码都无法胜任。 :)

经验法则是,当我们尝试捕获自己的错误时应使用断言,而在尝试捕获其他人的错误时应使用异常。换句话说,每当我们获取系统外部的任何数据时,都应使用异常来检查公共API函数的前提条件。我们应该对系统内部的功能或者数据使用断言。

我喜欢第二个。尽管测试可能运行良好,但墨菲说,出乎意料的事情会出问题。因此,我们没有在实际的错误方法调用中获得异常,而最终追查了更深10个堆栈帧的NullPointerException(或者等效)。

我遵循的原则是:如果可以通过编码实际上避免这种情况,请使用断言。否则,请使用异常。

断言是为了确保遵守合同。合同必须公平,以便客户必须能够确保遵守合同。例如,我们可以在合同中声明URL必须有效,因为关于有效URL是无效的规则是已知且一致的。

客户端和服务器无法控制的情况除外。异常意味着出了点问题,没有什么可以避免的。例如,网络连接在应用程序控制之外,因此无法做任何事情来避免网络错误。

我想补充一点,断言/异常区别并不是真正考虑它的最佳方法。我们真正要考虑的是合同及其执行方式。在上面的URL示例中,最好的办法是有一个封装URL的类,该类可以为Null或者有效URL。字符串转换为URL会强制执行合同,如果无效,则会引发异常。带有URL参数的方法比带有String参数和指定URL的断言的方法要清晰得多。

另请参阅以下问题:

I some cases, asserts are disabled when building for release. You may
  not have control over this (otherwise, you could build with asserts
  on), so it might be a good idea to do it like this.
  
  The problem with "correcting" the input values is that the caller will
  not get what they expect, and this can lead to problems or even
  crashes in wholly different parts of the program, making debugging a
  nightmare.
  
  I usually throw an exception in the if-statement to take over the role
  of the assert in case they are disabled

assert(value>0);
if(value<=0) throw new ArgumentOutOfRangeException("value");
//do stuff

前面的答案是正确的:对公共API函数使用异常。我们唯一希望改变此规则的时间是检查的计算量很大时。在这种情况下,我们可以将其放入断言中。

如果我们认为可能违反该准备工作,请将其保留为例外,或者将其重构。

我们应该同时使用两者。断言是为我们提供方便的开发人员。异常捕获我们在运行时遗漏或者未曾料到的事情。

我已经很喜欢glib的错误报告功能,而不是普通的旧断言。它们的行为类似于assert语句,但是它们并没有停止程序,而只是返回一个值并让程序继续运行。它的功能出奇的好,而且,当功能没有返回"应有的结果"时,我们还可以看到程序的其余部分发生了什么。如果崩溃,我们将知道错误检查在以后的其他地方比较松懈。

在我的上一个项目中,我使用了这些样式的函数来实现前提条件检查,如果其中一个失败,我将在日志文件中显示堆栈跟踪信息,但仍可继续运行。当其他人在运行我的调试版本时会遇到问题时,为我节省了很多调试时间。

#ifdef DEBUG
#define RETURN_IF_FAIL(expr)      do {                      \
 if (!(expr))                                           \
 {                                                      \
     fprintf(stderr,                                        \
        "file %s: line %d (%s): precondition `%s' failed.", \
        __FILE__,                                           \
        __LINE__,                                           \
        __PRETTY_FUNCTION__,                                \
        #expr);                                             \
     ::print_stack_trace(2);                                \
     return;                                                \
 };               } while(0)
#define RETURN_VAL_IF_FAIL(expr, val)  do {                         \
 if (!(expr))                                                   \
 {                                                              \
    fprintf(stderr,                                             \
        "file %s: line %d (%s): precondition `%s' failed.",     \
        __FILE__,                                               \
        __LINE__,                                               \
        __PRETTY_FUNCTION__,                                    \
        #expr);                                                 \
     ::print_stack_trace(2);                                    \
     return val;                                                \
 };               } while(0)
#else
#define RETURN_IF_FAIL(expr)
#define RETURN_VAL_IF_FAIL(expr, val)
#endif

如果需要对参数进行运行时检查,可以这样做:

char *doSomething(char *ptr)
{
    RETURN_VAL_IF_FAIL(ptr != NULL, NULL);  // same as assert(ptr != NULL), but returns NULL if it fails.
                                            // Goes away when debug off.

    if( ptr != NULL )
    {
       ...
    }

    return ptr;
}

断言是用于捕获开发人员做错了的事情(不仅仅是我们自己团队中的另一个开发人员)。如果用户错误可能造成这种情况是合理的,那么这应该是一个例外。

同样考虑一下后果。断言通常会关闭应用程序。如果有现实的期望可以从中恢复该条件,则我们可能应该使用一个例外。

另一方面,如果问题仅是由于程序员错误引起的,则请使用断言,因为我们想尽快了解它。可能会捕获并处理异常,我们将永远无法找到它。是的,我们应该在发布代码中禁用断言,因为如果可能的可能性很小,我们希望在那里恢复应用程序。即使程序状态被严重破坏,用户也可能能够保存其工作。

关于在comp.lang.c ++。moderated的发布版本中启用/禁用断言有一个巨大的思路,如果我们有几个星期的时间,我们会发现对此的看法有何不同。 :)

与coppro相反,我相信如果我们不确定在发布版本中可以禁用断言,那么它就不应该是断言。断言是为了防止程序不变式被破坏。在这种情况下,就代码客户端而言,将有两种可能的结果之一:

  • 死于某种操作系统类型故障,导致调用中止。 (没有断言)
  • 通过直接调用死去中止。 (带断言)

用户之间没有区别,但是,断言有可能在代码中增加不必要的性能成本,而这些代码在绝大部分未失败的运行中都存在。

问题的答案实际上更多地取决于API的客户端。如果要编写提供API的库,则需要某种形式的机制来通知客户他们错误地使用了API。除非我们提供的库的两个版本(一个断言,一个没有)则断言是不太合适的选择。

但是,就我个人而言,我不确定在这种情况下我也不会例外。例外更适合可以进行适当恢复的地方。例如,可能我们正在尝试分配内存。当我们遇到" std :: bad_alloc"异常时,可能可以释放内存并重试。

"仅在调试模式下断言失败"并不是完全正确的。

在Bertrand Meyer撰写的《面向对象的软件构造》第二版中,作者为检查发布模式中的前提条件打开了一扇门。在这种情况下,断言失败时会发生以下情况:引发断言冲突异常!在这种情况下,无法从这种情况中恢复:但是可以做一些有用的事情,它可以自动生成错误报告,并在某些情况下重新启动应用程序。

其背后的动机是,准备工作通常比不变条件和后置条件便宜,并且在某些情况下,发布版本中的正确性和"安全性"比速度更重要。即,对于许多应用程序来说,速度不是问题,但健壮性(程序行为不正确(即,合同违约时)以安全方式运行的能力)是关键。

我们是否应该始终启用前提条件检查?这取决于。由你决定。没有普遍的答案。如果我们正在为银行开发软件,则最好以一条警报消息来中断执行,而不是转移$ 1,000,000而不是$ 1,000。但是,如果我们正在编写游戏,该怎么办?也许我们需要获得所有的速度,并且如果某人由于未满足前提条件的错误(因为未启用)而获得了1000分而不是10分,那么运气就很糟糕。

在两种情况下,理想情况下,我们都应该在测试期间捕获到该错误,并且应该在启用断言的情况下进行很大一部分测试。这里讨论的是在那些由于测试不完整而导致早期无法检测到的情况下,前提条件在生产代码中失败的罕见情况的最佳策略。

总而言之,如果我们至少在Eiffel中将其保持启用状态,则可以拥有断言,并且仍然可以自动获取异常。我认为要在C ++中执行相同的操作,我们需要自己输入。

另请参阅:断言何时应保留在生产代码中?

我在这里概述了对此问题的看法:如何验证对象的内部状态? 。通常,声明主张并抛出他人的侵犯。要在发行版本中禁用断言,我们可以执行以下操作:

  • 禁用断言以进行昂贵的检查(例如检查范围是否已订购)
  • 使琐碎的检查保持启用状态(例如检查空指针或者布尔值)

当然,在发布版本中,失败的断言和未捕获的异常应该以除调试版本(可以仅调用std :: abort)以外的其他方式处理。将错误日志写入某处(可能写入文件中),告诉客户发生内部错误。客户将能够向我们发送日志文件。

我尝试用自己的观点在这里综合其他几个答案。

在断言要在生产中禁用它的情况下使用断言,以免将其留在生产中。在生产中而不是在开发中禁用的唯一真正原因是加快程序的速度。在大多数情况下,这种提高速度并不明显,但是有时代码是时间紧迫的,或者测试的计算量很大。如果代码是关键任务,那么尽管速度变慢,但异常可能是最好的。

如果有真正的恢复机会,请使用异常,因为断言并非旨在从中恢复。例如,代码很少被设计来从编程错误中恢复,但是代码被设计来从网络故障或者锁定文件等因素中恢复。错误不应仅仅因为不受编程人员控制而作为异常处理。相反,与编码错误相比,这些错误的可预测性使它们更易于恢复。

再说一下,调试断言更容易:来自正确命名的异常的堆栈跟踪与断言一样容易读取。好的代码应该只捕获特定类型的异常,因此异常不应因为被捕获而被忽视。但是,我认为Java有时会迫使我们捕获所有异常。