第2章 防御性编程

编写“完美的软件”对开发者来说可能是一个难以达到的目标,但是应用一些常规的防御性技术,对于提高代码的质量将会大有帮助。

尽管典型的软件产品的复杂性保证了测试人员总有做不完的工作,然而,程序设计人员仍然渴望创造零缺陷的软件。面向对象设计技术为开发大型项目解决了很多困难,但是最终用户还得自己编写循环和函数。这些“细微处编程”(programming in the small)的详细内容成为用户设计的较大组件所需的构建块。如果循环偶尔突然退出,或者函数只是在“大多数”时候能够计算出正确的结果,那么,不管系统的整体结构(方法论)是多么的优秀,最终还是会陷入麻烦之中。本章中读者将会学习到一些经验,不管项目是什么规模的,这些经验都能够帮助程序员创建出健壮的代码。

代码的其中一个内涵是对问题解决方法的描述。在设计循环的时候,程序员应该能够清楚地告诉读者(包括程序员自己)其准确的想法是什么。在程序中某个特定的地方,应该能够大胆地声明某些条件或其他一些控制方法。(如果不能做出这种声明,只能说明实际上还未解决这个问题。)这种声明称为不变量(invariant),因为在代码中它们出现的那一点上它们应该恒为真;否则,要么是设计有缺陷,要么是代码没有正确地反映程序设计人员的设计意图。

考虑这样一个实现Hi-Lo猜谜游戏的程序。某个人在1到100之间任选一个数,另一个人猜这个数。(现在我们让计算机猜这个数。)选数的人告诉猜数的人他猜的数比正确的数大,还是比正确的数小或是正好相等。对猜数的人来说,最好的策略是进行二分查找,选择待查找数字范围的中间点。根据选数的人回答的“大”或“小”,猜数的人能够知道这个数到底在该范围的哪一半中。重复这个过程,每次重复都能够把范围缩小一半。那么怎么样编写这个循环来正确模拟猜数过程呢?像下面这样写是不够的:

第2章 防御性编程 - 图1

因为恶意的用户可能会欺骗编程者,使他们花整天的时间进行猜测。然而每次猜测时能做什么样的假设呢?换句话说,在每个循环中设计什么样的条件来控制循环的迭代次数呢?

现假定:秘密的数字是在当前有效的未猜过的数的范围之内:[1,100]。假设用low和high两个变量标记数字范围的端点。每次进入循环,在循环开始的时候,需要确定该秘密的数字在范围[low, high]之内,每次迭代结束之后重新计算数的范围,使其在进行下一次循环迭代时仍然含有该秘密数字。

这样做的目标是在编写代码的时候表示出循环的不变量条件,使得程序可以在运行的时候检测到违背条件的情况。遗憾的是,由于计算机不知道秘密的数字,程序员不能在代码中直接表达这个条件,但是至少能够写一段这种效果的注释:

第2章 防御性编程 - 图2

当用户回答猜测结果太大或太小(即超出秘密数字的可能范围)时,会出现什么情况呢?这样的欺骗会造成新计算出来的子范围不包括秘密数字。因为一个谎言总会导致另一个谎言,最终会使猜数范围缩减到不包含任何数字(由于每次将猜数范围缩减一半,而且秘密数字并不在范围内)。下面的程序可以表示这种情况。

第2章 防御性编程 - 图3

条件表达式if(low>high)可以发现违反不变量条件的情况,因为如果用户总是说实话,那么在用完这些猜测前总能找到这个秘密数字。

也可以使用标准C的技术通过从main()函数中返回不同的值来向调用者报告程序的状态。用语句return 0的可携带值来表示程序执行成功,但是没有可携带的值可作为表示程序执行失败的返回值。因此,可以在这种情况下使用<cstdlib>中声明的宏:EXIT_FAILURE表示程序执行失败的返回值。为了代码的一致性,无论何时使用EXIT_FAILURE,我们也使用EXIT_SUCCESS来表示程序执行成功,虽然EXIT SUCCESS总是被定义成0。

2.1 断言

在Hi-Lo程序执行依赖于用户输入的情况下,无法防止在程序运行过程中出现违反不变量条件的事件发生。然而,不变量条件通常仅仅依赖于编写的代码,所以这些不变量条件始终持有程序设计是否已经正确实现的证据。在这种情况下,可以明确地使用断言(assertion),断言是一个肯定的语句,(只要能证明在程序的执行过程中断言恒真,就证明了程序的正确性。)用来肯定显示设计意图。

假设现在正在实现一个整数向量(vector):一个可以按照需求扩展的数组。添加一个元素到向量中的函数必须首先检查在数组的下面的位置是否有空闲的单元;如果没有空闲单元,函数必须请求更多的堆空间,而将现有的元素拷贝到新分配的内存空间中,最后把这个新元素添加到数组中(并且释放旧的数组)。如下所示:

第2章 防御性编程 - 图4

在这个例子中,data是一个整型的动态数组,有capacity个单元,前nextSlot个单元已经被使用了。grow()函数的作用是扩大data数组,使新数组的capacity值比nextSlot大。MyVector的行为是否正确依赖于设计决定,如果其他支持代码正确,MyVector就不会出错。可以使用定义在头文件<cassert>中的assert()宏断言这种情况。

标准C库中的assert()宏是简明扼要的并且也可携带返回信息。如果参数赋值得到的条件为非零值,程序将不受干扰地继续运行;否则,引发断言错误的表达式和其所在的源文件的文件名、这个断言所在行的行号都会被一起送到标准错误输出信道打印出来,然后程序异常终止。这种反应方式太过激烈吗?实际上,当基本设计中的假定已经失败时,让程序继续执行会造成更加剧烈的反应。如果出现了这种情况,就应该修改程序。

如果所有的事情都进行得很好,则应该在配置最终产品之前完整地测试代码,在测试过程中不应该出现触发断言错误的情况。(本章随后会讲更多有关测试的问题。)视应用程序的性质而定,运行时检测所有断言所耗费的机器周期可能会大大降低程序的执行效率,以至严重影响这个系统在该领域的应用。如果是这样的话,定义NDEBUG宏并重新编译程序,会自动去掉所有断言代码。

现在来看看断言是如何工作的吧,注意,典型的assert()实现如下所示:

第2章 防御性编程 - 图5

当定义了NDEBUG宏的时候,这段代码蜕化成表达式(void)0,再加上写在每个assert()后面的分号,所以最终留在编译器流中的内容仅仅是一条无意义的语句。如果没有定义NDEBUG宏,assert(cond)被扩展成条件语句,当cond的值为零时,调用与编译器相关的函数(称为assertImpl()),调用这个函数时需要三个参数,这三个参数分别是:断言语句所在文件的文件名、断言语句所在行的行号和一个字符串,这个字符串表示cond的文本形式。(在这个例子中,使用“???”来代替这些参数,上面提到的字符串文本是在这里确定的,断言语句所在文件的文件名和断言语句所在行的行号也是在文件中宏出现的位置确定的。如何得到这些值对于这个问题的讨论来说并不重要。)如果想开启或关闭程序中某些位置的断言,不但必须包含#define或#undef NDEBUG,而且必须重新包含<cassert>。当预处理器遇见它们时对宏进行赋值,并且无论NDEBUG是什么状态都将其应用在包含的位置上。最常用的定义NDEBUG的方式是作为编译器选项给整个程序定义,不管是通过可视化开发环境的项目设置还是通过命令行,如下所示:

第2章 防御性编程 - 图6

大多数编译器使用-D标记来定义宏的名字。(为上面的编译器mycc替换要编译的可执行文件的文件名。)这种方法的好处是,可以把断言留在源文件中作为不可多得的珍贵文档使用,而不会在运行时造成性能损失。因为当定义了NDEBUG,断言中的代码就会消失,所以确保不在断言中做额外操作是至关重要的。断言中只能包含不会修改程序状态的测试条件。

是否应该在发行版中使用NDEBUG仍然有争论。Tony Hoare是最有影响的计算机科学家之一,[1]他比喻说,关掉类似断言的运行时检查,就像一个热衷于航海的人,当他在陆地上训练的时候穿着救生衣,然而当他下海的时候却脱掉了救生衣。断言在软件产品中失灵所造成的问题远比效率降低要严重得多,因此要做出明智的选择。

不是所有情况下都应该使用断言。如第1章所示,用户造成的错误和运行时资源故障应该用抛出异常以信号的方式来通知系统。读者可能希望当粗略描述代码的时候,在大多数错误情况下使用断言,并决心在随后的编码过程中用健壮的异常处理来代替它们。这是一种很诱人想法。由于在随后的修改过程中,可能会漏掉某些断言,所以,像对待其他的诱惑一样,必须要十分小心。记住:断言的意图是验证设计决定,造成它失败的惟一原因应该是程序逻辑有缺陷。理想的结果是在开发阶段就解决掉所有违背断言的情况。如果某个条件不完全在程序的控制之下,那么不要对这个条件使用断言(例如,依赖于用户输入的条件)。特别是不应该使用断言来验证函数的参数;在参数错误的情况下,应该抛出logic_error异常。

用断言作为工具来确保程序的正确性是Bertrand Meyer在其所著的《Design by Contract methodology》书中正式提出来的。[2]每一个函数都有一个隐含的与客户程序的约定,给定某一个前置条件,保证会出现某一个后置条件。换句话说,前置条件是使用该函数的必要条件,例如提供某一范围内的参数,后置条件是该函数提供的结果,通过返回值或通过副作用给出。

当客户程序给出了一个无效的输入,必须告诉这些客户程序,它们违反了约定。这并不是终止程序的最好时机(尽管这样做是正当的,因为它们违反了约定),在这种情况下,应该抛出异常。这就是为什么标准C++库有从logic_error类派生的异常类,例如out_of_range异常类,用以抛出异常。[3]如果这些函数只被程序设计人员自己调用,例如自己设计的类中的私有函数,因为能够控制整体情况并且希望在发行代码之前进行调试,所以使用assert()宏是适当的。

后置条件的失败表明程序中有错误,在任何时间使用断言来测试任何不变量都是适当的,包括在一个函数结束的时候测试后置条件。将这种方法应用于维护对象状态的类成员函数中特别合适。在先前提到的MyVector例子中,对于所有的公有成员函数来说合理的不变量应该是:

第2章 防御性编程 - 图7

或者,如果nextSlot是一个无符号整数,简化的结果是

第2章 防御性编程 - 图8

这样的不变量称为类不变量(class invariant),可以使用断言对它进行适度的强制。对于基类来说,它们的子类扮演了一个分包人的角色,因为它们必须维持基类与其客户之间最初的约定。因此,派生类的前置条件不能超过基类与其客户的约定而再强加额外的要求,并且派生类的后置条件必须至少与基类的后置条件一样多。[4]

确认返回给客户的结果是否正确与测试没有什么不同,所以在这种情况下使用后置条件断言(post-condition assertions)就与测试工作重复了。当然,这是一种好的文档,但是不止一个软件开发者被这种用法愚弄了,他们错误地把后置条件断言当成单元测试的一种替代品。

[1]他发明了快速排序算法和其他一些东西。

[2]引用自“Programming Language Pragmatics”,Michael L.Scott, Morgan-Kaufmann,2000。参考他所著的书籍《Object-Oriented Software Construction》,Prentice-Hall,1994。

[3]这在概念上仍然是一种断言,但是由于不想终止程序的运行,使用assert()宏并不恰当。例如,Java 1.4在断言失败的时候抛出异常。

[4]有一个比较好的短语能帮忙记住这种现象:“不要只索取不付出(Require no more;promise no less)”,这个短语首先在《C++FAQs》中被Marshall Cline和Greg Lomow(Addison-Wesley,1994)创造出来。由于前置条件在派生类中被弱化了,所以称其为逆变的(contravariant),相反,后置条件是协变的(covariant)(这就解释了为什么我们在第1章中提到了异常规格说明的协变)。