第2章 自己设计并使用断言
利用编译程序自动查错固然好,但我敢说只要你观察一下项目中那些比较明显的错误,就会发现编译程序所查出的只是其中的一小部分。我还敢说,如果排除掉了程序中的所有错误那么在大部分时间内程序都会正确工作。
还记得第1章中的下面代码吗?
strCopy = memcpy(malloc(length), str, length);
该语句在多数情况下都会工作得很好,除非malloc的调用产生失败。当malloc失败时,就会给memcpy返回一个NULL指针。由于memcpy处理不了NULL指针,所以出现了错误。如果你很走运,在交付之前这个错误导致程序的瘫痪,从而暴露出来。但是如果你不走运,没有及时地发现这个错误,那某位顾客就一定会“走运”了。
编译程序查不出这种或其他类似的错误。同样,编译程序也查不出算法的错误,无法验证程序员所作的假定。或者更一般地,编译程序也查不出所传递的参数是否有效。
寻找这种错误非常艰苦,只有技术非常高的程序员或者测试者才能将它们根除并且不会引起其他的问题。
然而假如你知道应该怎样去做的话,自动寻找这种错误就变得很容易了。
两个版本的故事
让我们直接进入memcpy,看看怎样才能查出上面的错误。最初的解决办法是使memcpy对NULL指针进行检查,如果指针为NULL,就给出一条错误信息,并中止memcpy的执行。下面是这种解法对应的程序。
/* memcpy ─── 拷贝不重叠的内存块 */
void memcpy(void* pvTo, void* pvFrom, size_t size)
{
void* pbTo = (byte*)pvTo;
void* pbFrom = (byte*)pvFrom;
if(pvTo == NULL | | pvFrom == NULL)
{
fprintf(stderr, “Bad args in memcpy\n”);
abort();
}
while(size-->0)
*pbTo++ == *pbFrom++;
return(pvTo);
}
只要调用时错用了NULL指针,这个函数就会查出来。所存在的唯一问题是其中的测试代码使整个函数的大小增加了一倍,并且降低了该函数的执行速度。如果说这是“越治病越糟”,确实有理,因为它一点不实用。要解决这个问题需要利用C的预处理程序。
如果保存两个版本怎么样?一个整洁快速用于程序的交付;另一个臃肿缓慢件(因为包括了额外的检查),用于调试。这样就得同时维护同一程序的两个版本,并利用C的预处理程序有条件地包含或不包含相应的检查部分。
void memcpy(void* pvTo, void* pvFrom, size_t size)
{
void* pbTo = (byte*)pvTo;
void* pbFrom = (byte*)pvFrom;
#ifdef DEBUG
if(pvTo == NULL | | pvFrom == NULL)
{
fprintf(stderr, “Bad args in memcpy\n”);
abort();
}
#endif
while(size-->0)
*pbTo++ == *pbFrom++;
return(pvTo);
}
这种想法是同时维护调试和非调试(即交付)两个版本。在程序的编写过程中,编译其调试版本,利用它提供的测试部分在增加程序功能时自动地查错。在程序编完之后,编译其交付版本,封装之后交给经销商。
当然,你不会傻到直到交付的最后一刻才想到要运行打算交付的程序,但在整个的开发工程中,都应该使用程序的调试版本。正如在这一章和下一章所建,这样要求的主要原因是它可以显著地减少程序的开发时间。读者可以设想一下:如果程序中的每个函数都进行一些最低限度的错误检查,并对一些绝不应该出现的条件进行测试的活,相应的应用程序会有多么健壮。
这种方法的关键是要保证调试代码不在最终产品中出现。
利用断言进行补救
说老实话memcpy中的调试码编得非常蹩脚,且颇有点喧宾夺主的意味。因此尽管它能产生很好的结果,多数程序员也不会容忍它的存在,这就是聪明的程序员决定将所有的调试代码隐藏在断言assert中的缘故。assert是个宏,它定义在头文件assert.h中。assert虽然不过是对前面所见#ifdef部分代码的替换,但利用这个宏,原来的代码从7行变成了1行。
void memcpy(void* pvTo, void* pvFrom, size_t size)
{
void* pbTo = (byte*)pvTo;
void* pbFrom = (byte*)pvFrom;
assert(pvTo != NULL && pvFrom != NULL);
while(size-->0)
*pbTo++ == *pbFrom++;
return(pvTo);
}
aasert是个只有定义了DEBUG才起作用的宏,如果其参数的计算结果为假,就中止调用程序的执行。因此在上面的程序中任何一个指针为NULL都会引发assert。
assert并不是一个仓促拼凑起来的宏,为了不在程序的交付版本和调试版本之间引起重要的差别,需要对其进行仔细的定义。宏assert不应该弄乱内存,不应该对未初始化的数据进行初始化,即它不应该产主其他的副作用。正是因为要求程序的调试版本和交付版本行为完全相同,所以才不把assert作为函数,而把它作为宏。如果把assert作为函数的话,其调用就会引起不期望的内存或代码的兑换。要记住,使用assert的程序员是把它看成一个在任何系统状态下都可以安全使用的无害检测手段。
读者还要意识到,一旦程序员学会了使用断言,就常常会对宏assert进行重定义。例如,程序员可以把assert定义成当发生错误时不是中止调用程序的执行,而是在发生错误的位置转入调试程序。assert的某些版本甚至还可以允许用户选择让程序继续运行,就仿佛从来没有发生过错误一样。
如果用户要定义自己的断言宏,为不影响标准assert的使用,最好使用其它的名字。本书将使用一个与标准不同的断言宏,因为它是非标准的,所以我给它起名叫做ASSERT,以使它在程序中显得比较突出。宏assert和ASSERT之间的主要区别是assert是个在程序中可以随便使用的表达式,而ASSERT则是一个比较受限制的语句。例如使用assert,你可以写成:
if(assert(p != NULL), p->foo!=bar)
……
但如果用ASSERT试试就会产生语法错误。这种区别是作者有意造成的。除非打算在表达式环境中使用断言,否则就应该将ASSERT定义为语句。只有这样,编译程序才能够在它被错误地用到表达式时产生语法错误。记住,在同错误进行斗争时每一点帮助都有助于错误的发现。我们为什么要那些自己从来用不着的灵活性呢?
下面是一种用户自己定义宏ASSERT的方法:
#ifdef DEBUG
void _Assert(char* , unsigned); /* 原型 */
#define ASSERT(f) \
if(f) \
NULL; \
else \
_Assert(__FILE__ , __LINE__)
#else
#define ASSERT(f) NULL
#endif
从中我们可以看到,如果定义了DEBUG,ASSERT将被扩展为一个if语句。if语句中的NULL语句让人感到很奇怪,这是因为要避免if不配对,所以它必须要有else语句。也许读者认为在_Assert调用的闭括号之后需要一个分号,但并不需要。因为用户在使用ASSERT时,已经给出了一个分号.
当ASSERT失败时,它就使用预处理程序根据宏__FILE__和__LINE__所提供的文件名和行号参数调用_Assert。_Assert在标准错误输出设备stderr上打印一条错误消息,然后中止:
void _Assert(char* strFile, unsigned uLine)
{
fflush(stdout);
fprintf(stderr, “\nAssertion failed: %s, line %u\n”,strFile, uLine);
fflush(stderr);
abort();
}
在执行abort之前,需要调用fflush将所有的缓冲输出写到标准输出设备stdout上。同样,如果stdout和stderr都指向同一个设备,fflush stdout仍然要放在fflush stderr之前,以确保只有在所有的输出都送到stdout之后,fprintf才显示相应的错误信息。
现在如果用NULL指针调用memcpy,ASSERT就会抓住这个错误,并显示出如下的错误信息:
Assertion failed: string.c , line 153
这给出了assert与ASSERT之间的另一点不同。标准宏assert除了给出以上信息之外,还显示出已经失败了的测试条件。例如对这个问题,我通常所用编译程序的assert会显示出如下信息:
Assertion failed: pvTo != NULL && pbFrom != NULL
File string.c , line 153
在错误消息中包括测试表达式的唯一麻烦是每当使用assert时,它都必须为_Assert产生一条与该条件对应的正文形式打印消息。但问题是,编译程序要在哪儿存储这个字符串呢?Macintosh、DOS和Windows上的编译程序通常在全局数据区存储字符串,但在Macintosh上,通常把最大的全局数据区限制为32K,在DOS和Windows上限制为64K。因此对于象Microsoft Word和Excel这样的大程序,断言字符串立刻会占掉这块内存。
关于这个问题存在一些解决的办法,但最容易的办法是在错误信息中省去测试表达式字符串。毕竟只要查看了string.c的第153行,就会知道出了什么问题以及相应的测试条件是什么。
如果读者想了解标准宏assert的定义方法,可以查看所用的编译系统的assert.h文件。ANSI C标准在其基本原理部分也谈到了assert并且给出了一种可能的实现。P. J. Plauger在其“The Standard C library”一书中也给出了一种略微不同的标准assert的实现。
不管断言宏最终是用什么样方法定义的,都要使用它来对传递给相应函数的参数进行确认。如果在函数的每个调用点都对其参数进行检查,错误很快就会被发现。断言宏的最好作用是使用户在错误发生时,就可以自动地把它们检查出来。
“无定义”意味着“要避开”
如果读者停下来读读 ANSI C中memcpy函数的定义,就会看到其最后一行说:“如果在存储空间相互重叠的对象之间进行了拷贝,其结果无定义”。在其它的书中,对此的描述有点不同。例如在P. J. Plauger和Jim Brodie的“Standard C”中相应的描述是:“可以按任何次序访问和存储这两个数组的元素”。
总之,这些书都说如果依赖于以按特定方式工作的memcpy,那么当使用相互重叠的内存块凋用该函数时,你实际上是做了一个编译程序不同(包括同一编译程序的不同版本)、结果可能也不同的荒唐的�