[珠玑之椟]浅谈代码正确性:循环不变式、断言、debug
这个主题和代码的实际写作有关,而且内容和用法相互交织,以下只是对于其内容的一个划分。《编程珠玑》上只用了两个章节20页左右的篇幅介绍,如果希望能获得更多的实例和技巧,我比较推崇《程序设计实践》 (Practise of Programming)、《编程精粹:编写高质量C语言代码》(Writing Solid Code)这两本书,只要有一般的C语言基础就能读懂,而且读起来比较快,读完后能提高不少coding的实践水平。
目录
循环不变式(invariant)
循环不变式主要用来帮助理解算法的正确性,具体来看,比较针对于循环迭代。形式上很类似与数学归纳法,它是一个需要保证正确断言。对于循环不变式,必须证明它的三个性质:
初始化:它在循环的第一轮迭代开始之前,应该是正确的。
保持:如果在循环的某一次迭代开始之前它是正确的,那么,在下一次迭代开始之前,它也应该保持正确。
终止:循环能够终止,并且可以得到期望的结果。
具体的使用实例可以参考我的旧作一篇:如何写出正确的二分查找?——利用循环不变式理解二分查找及其变体的正确性以及构造方式
debug之脚手架
听起来挺玄乎,其实所谓的脚手架,就是在debug版本里加入的为了验证程序是否正确的额外代码,比如一条为了确定循环中临时变量是否按期望变化的printf语句,这比复杂的调试器更快。我相信很多人在写代码时都这样做过,看到这里,我们并不需要为自己过去“简陋”的调试方式而不安,而是继续合理地使用它。
脚手架能完成更多的工作,不仅限于变量追踪。利用断言,可以建立脚手架进行程序的自动测试而不是人为的追踪,也可以利用clock()建立脚手架,把待测的代码放在两个clock()中间测试运行时间(相比之下,我更喜欢用gettimeofday())。以上三种脚手架的用法是《编程珠玑》提到的例子,关于这种代码可以做更多的引申,以下内容结合了Practise of Programming和Writing Solid Code的相关内容。
1.#ifdef DEBUG
很多人都使用过下面这样的代码:
#ifdef DEBUG ... #endif
这种想法是同时维护调试和非调试(即交付)两个版本,在调试版本中自动地查错;最后提交交付版本。当然,这种方法的关键是保证调试代码不在最终产品中出现。只是这种代码在原来的代码里看起来十分突兀(排版不好看,而且可能看上去喧宾夺主),使用断言assert()是一个代替方案之一,它只在定义了DEBUG时才有效,在我的Ubuntu的assert.h里是如果定义了NDEBUG就无效。关于断言会在后面详写,这里点到为止。
2.外壳函数/包裹函数
做法是把待测试或者可能发生错误而需要检测的函数用一段代码加一个外壳,或者说是包裹起来,在其中增加出错检查和处理代码,并提供合理的返回值。这两种名称前者出自《程序设计实践》,后者见于UNP,举一个UNP上的简单的例子:
int Socket(int family, int type, int protocol) { int n; if( (n = socket(family,type,protocol)) < 0) err_sys("socket error"); return n; }
3.用不同的算法验证正确性
如果编写了一个较快的算法又担心其不正确,想要检查其正确性怎么办?在脚手架中构造一个包括能提供同样功能并且正确但速度较慢的算法(往往是旧版本中所留下的),比较两者的执行结果。
4.提高代码覆盖度
在if判断中,总有一部分代码未必执行。如果想要测试不常执行的代码的正确性,可以用脚手架强制执行这部分代码。
5.消除随机性,使错误重现
利用脚手架对分配的内存块用garbage值0xA3而不是0填充,这样当发现指针指向0xA3A3或内容是连续的0xA3A3时,显然是个未定义的值,需要排错。具体的取值和系统有关,0xA3是早期macintosh的建议用的garbage值。
6.不要担心脚手架带来的性能损失
正如Writing Solid Code所言,不要把对交付版本的约束应用到相应的调试版本上,要用大小和速度来换取错误检查能力。脚手架只是为了查错,在交付版本中它们是不存在的。
断言(assert)
正如在脚手架中提到的,断言可以对程序正确性的测试。除此以外,在一段小型的代码demo中,编写断言远比精心编制一套完整的出错处理机制或者繁复的#ifdef DEBUG要简单的多。
为了帮助理解这个宏,Writing Solid Code探讨了assert宏的一种实现机制:
#ifdef DEBUG void _Assert(char*, unsigend); #define ASSERT(f) if(f) NULL; else _Assert(__FILE__,__LINE__) #else #define ASSERT(f) NULL #endif
而真正的处理函数是
void _Assert(char* strFile, unsigned uLine) { fflush(stdout); fprintf(stderr," Assertion failed:%s, line %u ",strFile,uLine); fflush(stderr); abort(); }
为什么要用宏定义+函数实现,并将宏中的__LINE__传递给后面实现的函数而不是仅仅靠宏本身实现?因为__LINE__用其所在的行号替换内容,使用函数则只会变成该函数内部的行号,而使用宏则只是把__LINE__放到对应需要检查的位置。更具体的说明可以参考以下链接:
http://stackoverflow.com/questions/11214260/behavior-of-line-in-inline-functions
http://stackoverflow.com/questions/7929291/get-code-line-with-line
另外,这个断言宏可以作为《编程珠玑(续)》习题3.5的完善解答。
“珠玑之椟”系列简介与索引
往期回顾: