断言的使用(2014)

2020-07-23 19:56:53

通过减小错误的执行和其效果的表现之间的距离来使调试更容易,

这篇文章将详细介绍断言,目的是帮助开发人员最大限度地提高收益,最大限度地降低成本。

我所见过的对断言的最佳定义来自C2维基:

断言是程序中特定点的布尔表达式,除非程序中存在错误,否则该表达式将为真。

这个定义直接告诉我们,断言不能用于错误处理。与断言相反,错误是与程序中的错误不对应的错误。例如,我们可能会问这个断言是否正确:

该定义告诉我们,除非程序中存在错误,否则表达式结果!=-1将为TRUE。显然(通常)情况并非如此:对open()的调用将会失败,具体取决于文件系统的内容,文件系统的内容并不完全在我们的程序的控制之下。因此,这个断言是一个糟糕的断言。能够清楚地区分程序错误和外部触发的错误条件是有效使用断言的最重要前提。

注意第一个定义-它告诉我们断言在程序正确性方面的实际含义-和这个定义之间的区别,这个定义是可操作的,根本没有提到bug。这是语言设计者和编译器作者对断言的看法。它非常有用,因为它清楚地表明,虽然断言可能会被执行,但我们不能指望它会被执行。这预示着我将在下面讨论的一个问题,即断言不能更改程序的状态。

当断言失败时,“发生了什么是不确定的”是对事物有相当强烈的看法。首先,这表示断言失败可能会导致将消息记录到控制台、向应用程序发送致命信号、抛出异常,或者根本没有效果。第二,“p为假时的未定义行为”等价于“p可被假定为真”。因此,编译器应该可以在断言条件成立的假设下随意优化程序。虽然这可能是我们想要的-事实上,如果添加断言使我们的代码更快而不是更慢,那将是非常酷的-但这并不是一种通用的解释。作为开发人员,当断言失败时,我们可能希望依靠某种行为。例如,Linux的bug_on()被定义为触发内核死机。如果我们削弱Linux的行为,例如,通过记录错误消息并继续执行,我们很容易添加可利用的漏洞。

当我们编写断言时,我们是在教导程序如何诊断自身的错误。学生在断言方面遇到的第二个最常见的问题(第一个最常见的是未能区分错误和错误条件)是找出要断言什么。这一开始并不那么容易,但(至少对许多人来说)它很快就变成了第二天性。断言从何而来的简短答案是“在核心程序逻辑之外”。具体地说,断言来自:

数学。基本的数学给我们提供了简单的方法-比如求出9-来检查复杂操作中的错误。在数学计算机程序中,通常有很多机会利用类似的技巧。如果我们正在计算三角形边之间的角度,那么断言这些角度之和为180度是合理的(如果我们使用浮点数,则角度总和接近180度)。如果我们正在实现一个复杂的计算平方根的方法,我们可能会断言结果的平方等于原始参数。如果我们要实现一个快速而棘手的余弦函数,那么断言它的输出在[-1,1]将是一个有用的健全性检查。一般说来,CS理论告诉我们,可能有许多问题本质上比验证更难解决。断言验证任何困难问题的解决方案(无论它是否在NP中)都是一件很好的事情。

前提条件。当我们的代码必须为真才能正确执行时,预先断言条件通常是值得的。这将记录需求,并使诊断故障以满足需求变得更容易。示例包括在计算数字的平方根之前断言数字是非负数,在取消引用指针之前断言指针是非空的,以及在依赖该事实之前断言列表不包含重复元素。

后置条件。通常,在函数接近尾声时,会做出某种非平凡但易于检查的保证,例如搜索树是平衡的,或者矩阵是上三角形的。如果我们认为我们的逻辑不尊重这一保证的可能性微乎其微,我们可以断言后置条件以确保这一点。

不变量。数据和数据结构通常具有在无bug执行中需要保留的属性。例如,在双向链表中,如果存在n->;next->;prev!=n的节点,则事情就出错了。类似地,二叉搜索树通常要求附加到左侧节点的值不大于附加到右侧节点的值。当然,这些都是简单的例子:诸如编译器等执行大量数据结构操作的程序可以具有几乎任意精心设计的数据结构不变量。

规范。因为根据定义,如果一个程序不实现规范,它就是有错误的,所以规范可以成为断言条件的强大来源。例如,如果我们的财务程序是为平衡账簿而设计的,我们可能会断言,当天的交易全部处理完毕后,没有创建或销毁任何货币。在排版程序中,我们可能会断言页面之外没有放置任何文本。

如上一节所示,断言填充各种角色。关于在模块边界保持的前置条件和后置条件的断言通常称为契约。诸如Eiffel、D、Racket和Ada等编程语言为合同提供了一流的支持。在其他语言中,契约支持可以通过库获得,或者我们可以简单地以类似契约的方式使用断言。

让我们看看两个复杂代码库中的断言。这里有一个由66个不同的bug触发的Mozilla中的超级坏蛋断言。Jesse Ruderman补充说,“大约一半触发断言的bug可能会导致可利用的崩溃,但如果没有专门制作的测试用例,它们根本不会崩溃。”下面是另一个很棒的Mozilla断言,其中有33个可能触发它的bug。

LLVM项目(不包括Clang,也不包括“examples”和“unittest”目录)在.cpp文件中包含大约500,000个SLOC和大约7,000个断言。每70行代码中有一个断言听起来是高还是低?在我看来差不多是对的。

我随意选择了LLVM中断言数量位于第90个百分位数的C++文件(断言数量中值的文件只包含一个!)。这个文件可以在这里找到,它包含18个断言,即使不查看周围的代码,也不难理解它们的含义:

每个连接词右侧的字符串都很不错;它使断言失败消息比其他情况下更易于自我记录。Jesse Ruderman指出,带可选解释字符串的可变断言不容易出错,他还指出Mozilla实现了这一点,这让我很恼火。

LLVM中的断言会失败吗?他们当然会这么做。到目前为止,LLVM bug数据库中有422个打开的bug与搜索字符串“assertion”匹配。

GCC(省略了它的testSuite目录)的.c文件中包含1,228,865个SLOC,大约有9,500个断言,或者说大约每130行代码就有一个断言。

您最喜欢的断言您最喜欢的代码库的断言比率,如果您有可用的数据并可以共享它的话。

在编写断言时只会犯一个非常非常糟糕的错误:在计算被断言的布尔条件时更改程序的状态。这很可能通过两种方式之一来实现。首先,在C/C++程序中,我们有时会意外地编写这样的代码:

这可以使用Yoda条件来避免,或者-更好的是-只要小心就可以避免。意外更改程序状态的第二种方法如下所示:

但不幸的是,treeDepth()更改了某个变量或堆单元中的值,可能是通过较长的调用链。

如果不是完全清楚,断言副作用的问题是,我们将测试我们的程序一段时间,确定它是好的,并在关闭断言的情况下进行发布构建,当然突然它就不起作用了。或者,它可能是可以工作的发布版本,但是我们的调试版本被副作用的断言破坏了。处理这些问题非常令人泄气,因为断言应该是为了节省时间,而不是把它吃掉。我确信有静态分析器可以警告这类事情。事实上,关于成为Coverity工具的工作的原始论文在4.1节中恰好提到了这种分析,并且还给出了大量关于这个bug的例子。这是一个控制副作用的语言支持会很有用的领域。这种支持在C/C++中非常原始。

我觉得有必要补充说,当然,每个断言都会改变机器的状态,例如,通过使用时钟周期、翻转分支预测器中的位,以及通过导致加载或驱逐高速缓存线。在某些情况下,这些更改将反馈到程序逻辑中。例如,在使用断言编译时运行速度降低2%的程序可能会与网络超时冲突,其行为可能与其非断言表亲完全不同。作为开发人员,我们希望不要太频繁地遇到这样的情况。

其他断言错误没有那么严重。我们可能会意外地编写一个空洞的断言,这会让我们对代码产生错误的信任感。我们可能不小心编写了一个过于严格的断言;它会在某个时候错误地失败,需要重新拨回。根据经验,我们不想写太明显正确的断言:

用层层冗余的断言把程序搞得一团糟也是没有用的。这会使代码变得缓慢且难以阅读。来自LLVM的1/70的数字是一个合理的目标。有些代码自然需要更多的断言;有些代码几乎不需要这么多。

Ben Titzer的评论说明了断言可能被误用来破坏模块性并使代码更加混乱的一些方式。

断言的一个危险是,当它们的行为是无条件地终止进程时,它们不能很好地组合。被有缺陷的库代码中的断言绊倒是非常令人沮丧的。另一方面,使用NDEBUG编译一个有错误的库是否有很大的改进并不是很明显,因为现在错误的结果很可能会渗透到应用程序代码中。在任何情况下,如果我们编写库代码,我们必须比编写独立应用程序时更小心地正确使用断言。在库代码中断言某些内容的唯一时间是当代码确实无法继续执行而没有可怕的后果时。

最后-我已经说过了-断言不是用于错误处理的。即便如此,我经常编写代码断言,对close()的调用返回0,对malloc()的调用返回非空指针。我对此并不感到自豪,但我觉得这比流行的替代方法(1)忽略malloc()和close()可能失败的事实,以及(2)编写假装处理它们的失败的不可靠代码要好。无论如何,作为一名教授,我有时被允许编写学术质量的代码。在高质量的代码库中,在遇到我们不在乎处理的错误时可能仍然可以崩溃,但我们希望使用单独的机制来实现。Jesse提到Mozilla有一个用于此目的的moz_crash(消息)构造。

几年前,在一个用Java编写作业的班级上,我注意到学生们没有在代码中放太多断言,所以我问了他们这个问题。他们中的大多数人都有点茫然地看着我,但有一个学生直言不讳地说,他认为例外是对断言的适当替代。我说过“异常用于错误处理,断言用于捕获错误”,但实际上这不是最好的答案。最好的答案应该是这样的:

异常是与方法分派和切换语句属于同一类别的低级控制流机制。异常的一个常见用途是支持结构化错误处理。但是,异常也可以用于实现断言失败。断言和结构化错误处理都是需要映射到低级语言功能的高级编程任务。

另一个误解(我从未从学生那里听到过,但在网上见过)是单元测试比断言更好的替代。只有在我们的单元测试非常完美的情况下才是这样,因为它们捕获了所有的bug。在现实世界中,单元测试和断言都找不到所有的bug,所以我们应该两者都使用。事实上,正如我在上一篇文章中试图说明的那样,断言和单元测试之间存在很强的协同效应。这里有一篇关于断言如何干扰单元测试的博客文章。我的观点是,如果发生这种情况,您可能做错了什么,比如编写对实现过于友好的测试,或者编写实际上不符合bug条件的假断言。本页询问单元测试和断言哪个更重要,下面是一些额外的讨论。

我认为,在实现任何重要的数据结构时,创建一个表示检查器-通常称为checkRep()-遍历整个结构并断言它能断言的一切,这是一个好主意。或者,可以实现一个名为repOK()的方法;它不包含任何断言,而是返回一个布尔值,指示数据结构是否处于一致状态。然后我们会这样写,例如:

其想法是,在对数据结构进行单元测试时,我们可以在每次操作后调用checkRep()或assert repOK(),以便积极捕获数据结构的方法中的bug。例如,下面是我为红黑树实现的checkRep():

希望即使您从未实现过红黑树,此代码也有一定意义。它几乎检查了我能想到的每一个不变量。完整的代码在这里。

通常,Switch或Case语句应该涵盖所有可能性,我们不希望陷入缺省情况。这里有一个解决问题的方法:

或者,某些代码库使用等同于Assert(False)的unreacable()宏。例如,LLVM大约2500次使用llvm_unreacable()(然而,它们的不可达构造具有相当强的语义-在非调试构建中,它被转换为指示死代码的编译器指令)。

在许多情况下,断言自然分为两类。轻量级断言在较小的恒定时间内执行,并且往往只涉及元数据。让我们以维护缓存列表长度的链表为例。轻量级断言将确保当列表指针为NULL时长度为零,而当列表指针为非NULL时长度为非零。相反,繁重的断言将检查长度是否实际上等于元素的数量。对于排序例程的繁重断言将确保对输出进行排序,并且可能还确保在排序期间没有修改数组的任何元素。CheckRep()几乎总是包含大量断言。

一般来说,繁重的断言在测试期间最有用,可能必须在生产构建中禁用。可以在部署的软件中启用轻量级断言。对于大型软件库(如LLVM和Microsoft Windows)来说,同时支持“检查的”和“发布的”构建并不少见,其中检查的构建-通常不在开发软件的组织之外使用-执行繁重的断言,并且比发布构建慢得多。发布构建-如果它确实包括轻量级断言-通常只比完全省略断言时慢一点。

这完全取决于情况。让我们看几个例子。Jesse传递了这个例子,在这个例子中,Mozilla中的一个有用的检查由于性能下降了3%而被取消。另一方面,朱利安·西沃德说:

Valgrind加载了断言检查和内部健全性检查器,它们定期检查关键数据结构。这些都是永久启用的。我不在乎在这些检查中是否花费了总运行时间的5%甚至10%-自动化调试是大势所趋。因此,Valgrind几乎从不分段-相反,它在死之前会发出某种有用的错误消息。这是我相当自豪的事。

无论发生什么,Linux内核通常都希望继续运行,但即便如此,它也包含了超过11,000次BUG_ON()宏的使用,这基本上是一个断言-失败时它会打印一条消息,然后在不刷新脏缓冲区的情况下触发内核死机。我认为,这样做的想法是,我们宁愿丢失一些最近生成的数据,也不愿冒险将损坏的数据刷新到稳定的存储中。像GCC和llvm这样的编译器都启用了断言,这使得编译器更有可能死掉,而不太可能发出错误的目标代码。另一方面,我从NASA的一位飞行软件工程师那里听说,一些棘手的火星着陆已经在断言关闭的情况下完成,因为违反断言会导致系统重新启动,到重新启动完成时,航天器已经撞上了行星。当检测到内部错误时,停止还是继续运行更好,这不是一个简单的问题要回答的问题。我很有兴趣听到读者讲述在部署的软件中断言或缺少断言会导致有趣的结果的情况。

使用断言不能有效地检测某些类别的程序错误。没有明显的方法来断言没有竞争条件或无限循环。在C/C++中,不可能断言指针的有效性,也不可能断言存储已初始化。由于断言存在于编程语言中,因此它们不能用于表示诸如量词之类的数学概念-例如,因为它们支持对排序例程的部分后置条件的简明规范,所以非常有用:

∀i,j:0≤i≤j<;Length⇒数组[i]≤数组[j]。

排序例程的后置条件的另一半(通常被省略)要求输出数组是输入数组的排列。

原则上可以通过断言检测到一些错误,但在实践中更好地检测到其他方法。这方面的一个很好的例子是C/C++程序中未定义的整数操作:为每个数学操作断言前提条件会使程序变得不可接受地混乱。编译器插入的指令插入是一种更好的解决方案。断言最好保留给对人类有意义的条件。

回想一下我对断言的首选定义:程序点上的表达式,如果它的计算结果为false,则表示存在bug。使用谓词逻辑,我们可以颠倒这个定义,并看到如果我们的程序不包含bug,那么任何断言都不会失败。因此,至少在原则上,我们应该能够证明我们程序中的每个断言都不会失败。为非TRI做这些证明。

.