编程精髓-编写高质量的C语言代码(2):自己设计并使用断言(一)

编程精粹--编写高质量的C语言代码(2):自己设计并使用断言(一)

上一篇文章<<编程精粹--编写高质量C语言代码(1):假想编译程序>>中讲述了如何利用编译程序的所有警告设施以及lint程序等来更加容易地自动发现程序中的错误。但是即使使用编译程序提供的所有警告设施,编译程序所发现的错误,也只是程序错误中的一小部分。例如以下一行代码:

  strCopy=memecpy(malloc(length),str,length));

当malloc 调用失败时,返回一个空指针,而memcpy如果没有处理空指针,程序就会出现错误。编译程序是无法查出这种或其他类似的错误。同样编译程序也无法查出算法的错误,无法验证程序员的假设。编译程序也查不出传递的参数是否有效。那如何自动寻找出这些错误呢?接着上面那个例子,最初的解决办法就是在memcpy对NULL指针进行检查。

/* memcpy:拷贝不重叠的内存块 */
void memcpy(void *pvTo, void *pvFrom, size_t size)
{
   byte* pbTo=(byte*)pvTo;
   byte* pbFrom=(byte*)pvFrom;
   if(NULL==pbTo||NULL==pbFrom)
   {
   	  fprintf(stderr,"bad args in memcpy\n");
   	  abort();
   }
   while(size-->0)
     *pbTo++=*pbFrom++;
}

当空指针调用memcpy时,函数就会检查出这个错误,并打印出错误信息。可是这样编写代码带来两个问题:1,测试空指针的代码增加了函数的代码量;2,降低了函数的执行速度。

我们应当知道,当程序正常运行时,是不应该出现把空指针传给memcpy函数的。但是当程序还处于调试过程中,这种情况是会出现的。所以应该保存两个版本,一个整洁快速用于程序的交付,另一个臃肿缓慢(因外需要包过额外的检查),用于调试。所以应当维护程序的两个版本,并利用C语言的条件编译,有条件的包含或不包含相应的检查部分。

/* memcpy:拷贝不重叠的内存块 */
void memcpy(void *pvTo, void *pvFrom, size_t size)
{
   byte* pbTo=(byte*)pvTo;
   byte* pbFrom=(byte*)pvFrom;
   #ifdef DEBUG
   if(NULL==pbTo||NULL==pbFrom)
   {
   	  fprintf(stderr,"bad args in memcpy\n");
   	  abort();
   }
   #endif
   while(size-->0)
     *pbTo++=*pbFrom++;
}


这样就同时维护了两个版本,在调试时,编译其调试版本,这样就会包含空指针的测试代码,便于自动差错,而在程序编写完成后,编译其交付版本,保证最终产品中不会包含调试代码,保证了程序的整洁快速。

         既要维护程序的交付版本,又要维护程序的调试版本。

上面的memcpy的调试代码略显复杂,聪明的程序员会把所有的调试代码隐藏在断言assert中。assert是个宏,定义在assert.h中。虽然assert 只不过是#ifdef 部分代码的替换,但是使用assert宏使代码更简洁。

/* memcpy:拷贝不重叠的内存块 */
void memcpy(void *pvTo, void *pvFrom, size_t size)
{
   byte* pbTo=(byte*)pvTo;
   byte* pbFrom=(byte*)pvFrom;
   assert(pbTo!=NULL&&pbFrom!=NULL);
   while(size-->0)
     *pbTo++=*pbFrom++;
}


assert是个只有定义了DEBUG才会起作用的宏,所以是用于程序的调试,而程序的最终产品是不会包含assert的代码。正是因为assert只会在程序的调试版本中起作用,所以为了避免程序的交付版本和调试版本之间引起重要的差别,需要对assert宏进行仔细定义。assert宏不应该弄乱内存,不应该对未初始化的数据进行初始化。也就是说assert宏不应该有其它副作用。如果assert的参数计算结果为假,就会终止程序的执行。而且还要意识到,一旦程序员学会了断言,就常常会对宏assert进行重定义。这样就可以自定义assert宏的行为。

不管断言宏最终以什么样的方法定义,都要使用它来对传递的函数参数进行确认。断言宏的最好作用是使用户在发生错误时,就可以自动地把它们检查出来。

         要使用断言对函数参数进行确认

在ANSI C 中,如果是对两个存储空间相互重叠的对象间进行拷贝,memcpy的结果是未定义的。结果未定义就意味着不同的编译程序,其结果也可能不同。因此对于无定义的特性,我们可以通过断言来对其进行确认。

void* memcpy(void *pvTo,void *pvFrom,size_t size)
{
	byte *pbTo=(byte*)pvTo;
	byte *pbFrom=(byte*)pvFrom;
	ASSERT(pbTo!=NULL&&pbFrom!=NULL);
	ASSERT(pbTo>=pvFrom+size||pbFrom>=pbTo+size);
	while(size--)
	   *pbTo++=*pvFrom++;
    return pvTo;
} 

代码中只利用了ASSERT(pbTo>=pvFrom+size||pbFrom>=pbTo+size),就可以完成两个内存块是否重叠。所以如果程序中使用了无定义的特性就要把它从相应的设计里去掉,或者在程序中包含相应的断言,以便在使用了无定义的特性,能够向程序员通报。

          要从程序中删去无定义的特性或者在程序中使用断言来检查无定义特性的非法使用。

还有一点需要注意,当我们千辛万苦地跟踪到一个断言时,却有可能不知道该断言的作用。所以为了使程序员理解断言的意图,要给不够清楚的断言加上注解。

          不要浪费别人的时间—详细说明不清楚的断言。

当程序员刚开始使用断言时,有时会错误地利用断言去检查真正地错误,而不是去检查非法的状况。

char* strdup(char *str)
{
	char *strNew;
	ASSERT(str!=NULL);
	strNew!=malloc(strlen(str)+1);
	ASSERT(strNew!=NULL);
	strcpy(strNew,str);
	return strNew; 
}


第一个断言是正确的,因为它用来检查程序正常运行时绝对不应该发生的非法状况,而第二个断言的用法是不当的,它所测试的是错误状况,程序运行时的确可能会出现内存分配失败,我们应该在最终产品中对这种错误进行处理而不是使用断言。

对于程序中使用的假定,要使用断言和条件编译进行相应的验证,例如:有时候我们会不自觉的认为一个字节占8位,或者说一个long型占据4个字节,这些都是对编译程序或操作系统做的一些假定。这使得我们需要在程序中使用断言例如ASSERT(sizeof(long)==4&&CHAR_BIT==8)。

          消除所做的隐式假定,或者利用断言检查其正确性。


总结:

1,同时维护程序的调试版本和交付版本,封装交付版本,应尽可能地利用调试版本自动查错。

2,断言是进行调试检查的简单方法。要使用断言来检查绝对不应该发生的非法情况,不要混淆非法情况和错误情况,错误情况是需要在最终产品中处理的。

3,利用断言对函数的参数进行确认,并且当程序员使用了无定义特性时向程序员报警。

4,当编写函数时,应反复问自己做了哪些假定,一旦确定了相应的假定,就要使用断言对所做的假定进行检验,或者重新编写代码去除假定。


最后用作者在这章中的一句话结束这篇文章:


          如果我告诉大家出现了断言失败是件好事,也许这个程序员就不会这么惊慌了。