使用断言还是异常进行设计合约?

131

在按照契约式编程时,一个函数或方法会首先检查其前置条件是否已满足,然后才会开始处理自己的职责,对吗?目前最常用的两种检查方法是通过assertexception实现的。

  1. assert只在调试模式下失败。要确保其关键是(单元)测试所有单独的合同前提条件,以查看它们是否实际上失败了。
  2. exception在调试和发布模式下都会失败。这种方式的好处是,经过测试的调试行为与发布行为相同,但它会产生运行时性能损失。

你认为哪个方法更可取?

相关问题请参见此处


3
设计契约的整个目的在于,你不需要(并且可以说不应该)在运行时验证前提条件。你在将输入传递到具有前提条件的方法之前验证输入,这是尊重您自己的契约的方式。如果输入无效或违反了您的契约,程序通常会通过其正常操作方式失败(这是您想要的)。 - void.pointer
不错的问题,但我认为你应该真正地切换已接受的答案(正如投票所示)! - DaveFar
永远晚了,我知道,但是这个问题真的应该有C ++标签吗? 我正在寻找这个答案,以在另一种语言(Delpih)中使用,我无法想象任何具有异常和断言功能的语言不遵循相同的规则。(仍在学习Stack Overflow指南。) - Eric G
在这个回答中,给出了非常简洁的回应:“换句话说,异常处理解决了应用程序的健壮性问题,而断言则解决了其正确性问题。” - Shmuel Levine
14个回答

199

经验之谈是:当你试图捕获自己的错误时,应该使用断言(assertions),而在试图捕获其他人的错误时,则需要使用异常(exceptions)。换句话说,你应该使用异常来检查公共API函数的前提条件以及任何来自于你系统外部的数据。而对于内部函数或数据则应该使用断言。


1
序列化/反序列化在不同的模块/应用程序中,最终会失去同步,这该怎么办呢?我的意思是,在读取器部分,如果我试图以错误的方式读取东西,那么这总是我的错误,所以我倾向于使用断言。但另一方面,我有外部数据,可能会在没有通知的情况下改变格式。 - Slava
如果数据是外部的,那么你应该使用异常处理。在这种情况下,你可能还应该捕获这些异常,并以某种合理的方式处理它们,而不仅仅是让程序崩溃。此外,我的答案只是一个经验法则,而不是自然法则。 :) 因此,你必须单独考虑每个情况。 - Dima
如果你的函数f(int* x)中包含一行x->len,那么当v被证明为空时调用f(v)将会导致崩溃。此外,即使在更早的时候v被证明为空但仍然调用了f(v),这就产生了逻辑矛盾。这就像a/b其中b最终被证明为0一样。理想情况下,这样的代码应该无法编译通过。关闭假设检查是完全愚蠢的,除非问题是检查的成本,因为它会掩盖假设被违反的位置。它至少应该被记录下来。你应该有一个重启设计以防止崩溃。 - Rob
既然我们应该使用异常处理外部错误,断言处理内部错误,那么我们是不是应该对于前置条件使用异常或者断言,而对于后置条件和不变量则总是使用断言呢? - Géry Ogam

39
禁用 release 版本中的断言就像是说 "在 release 版本中永远不会出现任何问题",但事实上通常并非如此。因此,在 release 版本中不应禁用断言。但您也不希望在发生错误时使发布版本崩溃,对吗?
所以要使用异常,并且要使用得当。使用一个好的、可靠的异常层次结构,并确保您捕获并可以在调试器中打印异常内容,以便在 release 模式下可以补偿错误而不是直接崩溃。这是更安全的做法。

4
断言至少在以下情况下非常有用:检查正确性要么低效,要么难以正确实现。 - Casebash
95
断言的目的不是为了纠正错误,而是为了警示程序员。因此,在发布版本中启用它们是没有意义的:如果一个断言触发了,你会得到什么好处呢?开发者无法立即跳进去进行调试。断言是一种调试辅助工具,它们不能替代异常(异常也不能替代断言)。异常用于警示程序出现错误条件。而断言则是为了提醒开发者。 - jalf
12
如果内部数据已经损坏到无法修复的程度,应该使用断言。如果断言触发,你不能对程序状态做任何假设,因为这意味着出现了问题。如果断言已经触发,你不能假设任何数据是有效的。这就是为什么发布版本应该使用断言——不是为了告诉程序员问题出在哪里,而是为了让程序关闭并避免更大的问题。程序应该尽力促进以后的恢复,当数据可以被信任时再进行恢复。 - coppro
5
@jalf,虽然你不能在发布版本中的调试器中设置断点,但是你可以利用日志记录相关信息,以便开发人员看到与你断言失败相关的信息。在这份文件中(http://martinfowler.com/ieeeSoftware/failFast.pdf),Jim Shore指出:“请记住,发生在客户现场的错误已经通过了你的测试过程。你可能很难复现它们。这些错误最难找到,一个恰当放置的断言能够解释问题,可能会节省你几天的努力。” - StriplingWarrior
5
个人而言,我更喜欢采用断言的设计合约方法。异常处理是一种防御性机制,它在函数内部进行参数检查。另外,设计合约中的前置条件并没有说“如果使用超出工作范围的值,我就不工作”,而是说“我不能保证提供正确的答案,但仍可能会提供”。断言能够向开发人员提供反馈,告诉他们正在使用条件有误的函数,但如果他们认为自己更清楚,也不会阻止他们使用。条件有误可能会导致异常发生,但我认为这是两回事。 - Matt_JD
显示剩余3条评论

25
我遵循的原则是:如果一个情况可以通过编程实现以避免,则使用断言。否则,使用异常。
断言用于确保合同得到遵守。合同必须公平,客户必须有能力确保其符合合同。例如,您可以在合同中声明URL必须有效,因为关于什么是有效URL和什么不是有效URL的规则是已知且一致的。
异常用于处理客户端和服务器都无法控制的情况。异常意味着出现了错误,没有任何方法可以避免它。例如,网络连接性是应用程序无法控制的,因此无法避免网络错误。
我想补充一下,断言/异常区分并不是最好的思考方式。您真正想考虑的是合同及其如何执行。在上面的URL示例中,最好的做法是拥有一个封装URL的类,该类可以为Null或有效的URL。将字符串转换为URL才能执行合同,并且如果无效,则引发异常。带有URL参数的方法比具有指定URL的断言的String参数的方法更清晰。

7

断言(Asserts)用于捕捉开发人员犯的错误(不仅是你自己,还有团队中的其他开发人员)。如果用户的错误可能导致这种情况,那么它应该是一个异常。

同样要考虑后果。一个断言通常会关闭应用程序。如果有任何现实的期望条件可以从中恢复,那么你应该使用异常。

另一方面,如果问题只能由程序员的错误引起,则使用断言,因为你希望尽快知道它。异常可能会被捕获和处理,你永远不会发现它。是的,在发布代码中应该禁用断言,因为在那里,如果有最小的机会,你希望应用程序恢复。即使你的程序状态非常糟糕,用户也可能能够保存他们的工作。


5
不完全正确地说,“断言仅在调试模式下失败”并不准确。在Bertrand Meyer的《面向对象软件构造第二版》中,作者为在发布模式下检查前提条件留了一扇门。在这种情况下,当一个断言失败时会发生什么呢?答案是... 会引发断言违规异常! 在这种情况下,无法恢复到正常情况:但是可以做一些有用的事情,例如自动生成错误报告,并且在某些情况下重新启动应用程序。
其背后的动机是,与不变量和后置条件相比,前提条件通常更便宜测试,并且在某些情况下,在发布构建中的正确性和“安全性”比速度更重要。即对于许多应用程序来说,速度不是问题,但容错能力(程序在行为不正确时以安全方式运行的能力,即当合同被打破时)是重要的。
你应该总是保持前提条件检查吗?这取决于你。没有通用的答案。如果你为银行制作软件,最好中断执行并显示警报消息,而不是将100万美元转移而不是1000美元。但是如果你正在编写游戏呢?也许你需要尽可能提高速度,如果由于前提条件未捕获到错误而使某人得分1000分而不是10分(因为它们未启用),那么就只能认倒霉了。
在两种情况下,理想情况下应该在测试过程中捕获这个错误,并且应该启用断言的重要部分。这里讨论的是在生产代码中前提条件失败的情况下,在之前由于测试不完整而未检测到的最佳策略。
总之,如果你保持启用,就可以同时拥有断言和自动异常。至少在Eiffel中是这样。我认为在C++中实现相同的功能需要自行输入代码。
参见:When should assertions stay in production code?

1
你的观点确实很有道理。SO并没有指定一种特定的编程语言 - 就C#而言,标准的assertSystem.Diagnostics.Debug.Assert,它在Debug版本中只会失败,并且将在Release版本中在编译时删除。 - yoyo

3

我在这里阐述了我的看法:如何验证对象的内部状态?通常,要断言你的声明并对他人的违规行为进行抛出。对于禁用发布构建中的断言,您可以执行以下操作:

  • 禁用昂贵检查的断言(例如检查范围是否有序)
  • 保持启用的简单检查(例如检查空指针或布尔值)

当然,在发布构建中,失败的断言和未捕获的异常应该以另一种方式处理,而不是在调试构建中(在那里它只能调用std :: abort)。将错误日志写入某个地方(可能是文件),告诉客户发生了内部错误。客户将能够向您发送日志文件。


2

在comp.lang.c++.moderated上有一个巨大的线程,关于在发布版本中启用/禁用断言,如果你有几周时间,可以看到对此的意见有多种多样。 :)

coppro相反,我认为,如果您不确定一个断言在发布版本中是否可以被禁用,那么它就不应该是一个断言。断言是用来保护程序不变量被破坏的。在这种情况下,就客户端代码而言,有两种可能的结果:

  1. 因某种类似操作系统的故障而死亡,导致调用abort。(没有assert)
  2. 通过直接调用abort死亡。(有assert)
用户并不会感受到任何差别,但是在代码的绝大多数运行中,断言可能会给代码带来不必要的性能成本。
实际上,这个问题的答案更取决于API的客户端是谁。如果你正在编写提供API的库,那么你需要某种机制来通知你的客户端他们使用了不正确的API。除非你提供两个版本的库(一个有断言,一个没有),否则使用assert几乎不是合适的选择。
然而,就我个人而言,我也不确定我会为这种情况选择异常。异常更适用于可以进行适当恢复的情况。例如,可能正在尝试分配内存。当你捕获一个'std::bad_alloc'异常时,可能可以释放内存并重试。

1

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

断言是“嘿,程序员,这里出错了”的通知,它们存在的目的是在发生错误时提醒你注意到那些你可能没有注意到的错误。

异常是“嘿,用户,出了点问题”的通知(显然你可以编写代码来捕获它们,以便用户不会被告知),但这些异常是设计为在 Joe 用户使用应用程序时发生的。

所以,如果你认为你可以解决所有的错误,只使用异常。如果你认为你不能......还是使用异常吧。当然,你仍然可以使用调试断言来减少异常的数量。

不要忘记,许多前提条件将是用户提供的数据,所以你需要一个很好的方式来告知用户他的数据有问题。为了做到这一点,你经常需要将错误数据返回到与之交互的部分的调用堆栈中。断言在这种情况下将无用 - 尤其是如果你的应用程序是多层的。

最后,我都不会使用 - 错误码对于你认为会经常发生的错误来说更加优越。:)


0

对我来说,经验法则是使用断言表达式来查找内部错误,并使用异常处理外部错误。您可以从这里的Greg的以下讨论中获得很多好处。

Assert表达式用于发现编程错误:程序逻辑本身的错误或对应实现中的错误。Assert条件验证程序仍处于定义状态。 “定义状态”基本上是与程序假设一致的状态。请注意,“程序的定义状态”不必是“理想状态”,甚至不是“通常状态”,甚至不是“有用状态”,但稍后会更多关注这一重要点。
要了解断言如何适用于程序,请考虑C++程序中将要取消引用指针的例程。现在,例程应该在进行取消引用之前测试指针是否为NULL,还是应该断言指针不为空,然后继续取消引用而不管它?
我想大多数开发人员都想执行两者,添加assert,但也检查指针是否为NULL值,以避免在断言条件失败时崩溃。从表面上看,执行测试和检查可能是最明智的决定。
与其所断言的条件不同,程序的错误处理(异常)不是指程序中的错误,而是指程序从其环境中获得的输入。这些通常是某些人的“错误”,例如用户尝试登录帐户而没有输入密码。即使错误可能会防止程序成功完成任务,但并没有程序故障。程序无法登录没有密码的用户,因为存在外部错误-用户的错误。如果情况不同,用户输入正确的密码而程序无法识别,则尽管结果仍然相同,但失败现在属于程序。
错误处理(异常)的目的有两个。第一个是向用户(或其他客户端)传达已检测到程序输入中的错误以及其含义。第二个目的是在检测到错误后将应用程序恢复到定义良好的状态。请注意,在这种情况下,程序本身没有错误。可以承认,程序可能处于非理想状态,甚至可能处于无法做任何有用工作的状态,但没有编程错误。相反,由于错误恢复状态是程序设计所预期的状态,因此程序可以处理该状态。

PS:你可能想查看类似的问题:异常 vs 断言


0

我更喜欢第二个。虽然你的测试可能运行得很好,但Murphy说会出现一些意外情况。因此,你不是在实际错误的方法调用处得到异常,而是在深入10个堆栈帧后跟踪出一个NullPointerException(或等效物)。


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