对于断言,相信大家都不陌生,大多数编程语言也都有断言这一特性。简单地讲,断言就是对某种假设条件进行检查。 在 C 语言中,断言被定义为宏的形式(assert(expression)),而不是函数,其原型定义在 <assert.h> 文件中。 其中,assert 将通过检查表达式 expression 的值来决定是否需要终止执行程序。也就是说,如果表达式 expression 的值为假(即为 0),那么它将首先向标准错误流 stderr 打印一条出错信息,然后再通过调用 abort 函数终止程序运行;否则,assert 无任何作用。 原型定义: - #include <assert.h>
- void assert( int expression );
默认情况下,assert 宏只有在 Debug 版本(内部调试版本)中才能够起作用,而在 Release 版本(发行版本)中将被忽略。 当然,也可以通过定义宏或设置编译器参数等形式来在任何时候启用或者禁用断言检查(不建议这么做)。同样,在程序投入运行后,最终用户在遇到问题时也可以重新起用断言。 这样可以快速发现并定位软件问题,同时对系统错误进行自动报警。对于在系统中隐藏很深,用其他手段极难发现的问题也可以通过断言进行定位,从而缩短软件问题定位时间,提高系统的可测性。
尽量利用断言来提高代码的可测试性
在讨论如何使用断言之前,先来看下面一段示例代码: - void *Memcpy(void *dest, const void *src, size_t len)
- {
- char *tmp_dest = (char *)dest;
- char *tmp_src = (char *)src;
- while(len --)
- *tmp_dest ++ = *tmp_src ++;
- return dest;
- }
对于上面的 Memcpy 函数,毋庸置疑,它能够通过编译程序的检查成功编译。从表面上看,该函数并不存在其他任何问题,并且代码也非常干净。 但遗憾的是,在调用该函数时,如果不小心为 dest 与 src 参数错误地传入了 NULL 指针,那么问题就严重了。轻者在交付之前这个潜在的错误导致程序瘫痪,从而暴露出来。否则,如果将该程序打包发布出去,那么所造成的后果是无法估计的。 由此可见,不能够简单地认为“只要通过编译程序成功编译的就都是安全的程序”。当然,编译程序也很难检查出类似的潜在错误(如所传递的参数是否有效、潜在的算法错误等)。 面对这类问题,一般首先想到的应该是使用最简单的if语句进行判断检查,如下面的示例代码所示: - void *Memcpy(void *dest, const void *src, size_t len)
- {
- if(dest == NULL)
- {
- fprintf(stderr,"dest is NULL\n");
- abort();
- }
- if(src == NULL)
- {
- fprintf(stderr,"src is NULL\n");
- abort();
- }
- char *tmp_dest = (char *)dest;
- char *tmp_src = (char *)src;
- while(len --)
- *tmp_dest ++ = *tmp_src ++;
- return dest;
- }
现在,通过“if(dest == NULL)与if(src == NULL)”判断语句,只要在调用该函数的时候为 dest 与 src 参数错误地传入了NULL指针,这个函数就会检查出来并做出相应的处理,即先向标准错误流 stderr 打印一条出错信息,然后再调用 abort 函数终止程序运行。 从表面看来,上面的解决方案应该堪称完美。但是,随着函数参数或需要检查的表达式不断增多,这种检查测试代码将占据整个函数的大部分(这一点从上面的 Memcpy 函数中就不难看出)。 这样代码看起来非常不简洁,甚至可以说很“糟糕”,而且也降低了函数的执行效率。 面对上面的问题,或许可以利用 C 的预处理程序有条件地包含或不包含相应的检查部分进行解决,如下面的代码所示: - void *MemCopy(void *dest, const void *src, size_t len)
- {
- #ifdef DEBUG
- if(dest == NULL)
- {
- fprintf(stderr,"dest is NULL\n");
- abort();
- }
- if(src == NULL)
- {
- fprintf(stderr,"src is NULL\n");
- abort();
- }
- #endif
- char *tmp_dest = (char *)dest;
- char *tmp_src = (char *)src;
- while(len --)
- *tmp_dest ++ = *tmp_src ++;
- return dest;
- }
这样,通过条件编译“#ifdef DEBUG”来同时维护同一程序的两个版本(内部调试版本与发行版本),即在程序编写过程中,编译其内部调试版本,利用其提供的测试检查代码为程序自动查错。而在程序编完之后,再编译成发行版本。 上面的解决方案尽管通过条件编译“#ifdef DEBUG”能产生很好的结果,也完全符合我们的程序设计要求,但是仔细观察会发现,这样的测试检查代码显得并不那么友好,当一个函数里这种条件编译语句很多时,代码会显得有些浮肿,甚至有些糟糕。 因此,对于上面的这种情况,多数程序员都会选择将所有的调试代码隐藏在断言 assert 宏中。其实,assert 宏也只不过是使用条件编译“#ifdef”对部分代码进行替换,利用 assert 宏,将会使代码变得更加简洁,如下面的示例代码所示: - void *MemCopy(void *dest, const void *src, size_t len)
- {
- assert(dest != NULL && src !=NULL);
- char *tmp_dest = (char *)dest;
- char *tmp_src = (char *)src;
- while(len --)
- *tmp_dest ++ = *tmp_src ++;
- return dest;
- }
现在,通过“assert(dest !=NULL && src !=NULL)”语句既完成程序的测试检查功能(即只要在调用该函数的时候为 dest 与 src 参数错误传入 NULL 指针时都会引发 assert),与此同时,对 MemCopy 函数的代码量也进行了大幅度瘦身,不得不说这是一个两全其美的好办法。 实际上,在编程中我们经常会出于某种目的(如把 assert 宏定义成当发生错误时不是中止调用程序的执行,而是在发生错误的位置转入调试程序,又或者是允许用户选择让程序继续运行等)需要对 assert 宏进行重新定义。 但值得注意的是,不管断言宏最终是用什么样的方式进行定义,其所定义宏的主要目的都是要使用它来对传递给相应函数的参数进行确认检查。 如果违背了这条宏定义原则,那么所定义的宏将会偏离方向,失去宏定义本身的意义。与此同时,为不影响标准 assert 宏的使用,最好使用其他的名字。例如,下面的示例代码就展示了用户如何重定义自己的宏 ASSERT: - /*使用断言测试*/
- #ifdef DEBUG
- /*处理函数原型*/
- void Assert(char * filename, unsigned int lineno);
- #define ASSERT(condition)\
- if(condition)\
- NULL; \
- else\
- Assert(__FILE__ , __LINE__)
- /*不使用断言测试*/
- #else
- #define ASSERT(condition) NULL
- #endif
- void Assert(char * filename, unsigned int lineno)
- {
- fflush(stdout);
- fprintf(stderr,"\nAssert failed: %s, line %u\n",filename, lineno);
- fflush(stderr);
- abort();
- }
如果定义了 DEBUG,ASSERT 将被扩展为一个if语句,否则执行“#define ASSERT(condition) NULL”替换成 NULL。 这里需要注意的是,因为在编写 C 语言代码时,在每个语句后面加一个分号“;”已经成为一种约定俗成的习惯,因此很有可能会在“Assert(__FILE__,__LINE__)”调用语句之后习惯性地加上一个分号。 实际上并不需要这个分号,因为用户在调用 ASSERT 宏时,已经给出了一个分号。面对这种问题,我们可以使用“do{}while(0)”结构进行处理,如下面的代码所示: - #define ASSERT(condition)\
- do{ \
- if(condition)\
- NULL; \
- else\
- Assert(__FILE__ , __LINE__);\
- }while(0)
现在,将不再为分号“;”而担心了,调用示例如下:
- void Test(unsigned char *str)
- {
- ASSERT(str != NULL);
- /*函数处理代码*/
- }
- int main(void)
- {
- Test(NULL);
- return 0;
- }
很显然,因为调用语句“Test(NULL)”为参数 str 错误传入一个 NULL 指针的原因,所以 ASSERT 宏会自动检测到这个错误,同时根据宏 __FILE__ 和 __LINE__ 所提供的文件名和行号参数在标准错误输出设备 stderr 上打印一条错误消息,然后调用 abort 函数中止程序的执行。运行结果如图 1 所示。
图 1 调用自定义 ASSERT 宏的运行结果 如果这时候将自定义 ASSERT 宏替换成标准 assert 宏结果会是怎样的呢?如下面的示例代码所示: - void Test(unsigned char *str)
- {
- assert(str != NULL);
- /*函数处理代码*/
- }
毋庸置疑,标准 assert 宏同样会自动检测到这个 NULL 指针错误。与此同时,标准 assert 宏除给出以上信息之外,还能够显示出已经失败的测试条件。运行结果如图 2 所示。
图 2 调用标准 assert 宏的运行结果 从上面的示例中不难发现,对标准的 assert 宏来说,自定义的 ASSERT 宏将具有更大的灵活性,可以根据自己的需要打印输出不同的信息,同时也可以对不同类型的错误或者警告信息使用不同的断言,这也是在工程代码中经常使用的做法。当然,如果没有什么特殊需求,还是建议使用标准 assert 宏。
|