在 C 语言中,断言被定义为宏的形式,而不是函数,其原型定义在 assert.h 文件中。其中,assert 将通过检查表达式 expression 的值来决定是否需要终止执行程序。例如:assert(expression)
。也就是说,如果表达式 expression 的值为假(即为 0),那么它将首先向标准错误流 stderr 打印一条出错信息,然后再通过调用 abort 函数终止程序运行;否则,assert 无任何作用。
使用 assert 的缺点是:频繁的调用会极大的影响程序的性能,增加额外的开销。所以,默认情况下,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;
}
但这种方式的缺点是:随着函数参数或需要检查的表达式不断增多,这种检查测试代码将占据整个函数的大部分,会降低程序的执行效率。对于这个问题,我们可以想到使用 C 语言的预处理器来解决,只有当 DEBUG 开启的时候执行检查:
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;
}
但显然的,这会让程序变得臃肿,可读性差。
对于上述问题,C 语言提供了断言机制来满足需求。利用 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 宏定义成当发生错误时不是中止调用程序的执行,而是在发生错误的位置转入调试程序,又或者是允许用户选择让程序继续运行等。需要对 assert 宏进行重新定义。
但值得注意的是,不管断言宏最终是用什么样的方式进行定义,其所定义宏的主要目的都是要使用它来对传递给相应函数的参数进行确认检查。如果违背了这条宏定义原则,那么所定义的断言宏将会偏离方向,失去断言宏定义本身的意义。
其次,为不影响标准 assert 宏的使用,最好使用其他的名字。例如,下面的示例代码就展示了用户如何重定义自己的宏 ASSERT。
需要注意的是,因为在编写 C 语言代码时,需要在每个语句后面以分号 ; 结束,所以程序员很可能会在习惯性的在进行宏定义时候使用 ; 号。但实际上这样是错误的,因为用户在调用 ASSERT 宏时,已经给出了一个分号。面对这种问题,我们可以使用 do{}while(0) 结构进行宏的定义,如下述例子:
/* 使用断言 */
#ifdef DEBUG
/* 处理函数原型 */
void Assert(char * filename, unsigned int lineno);
/* 自定义断言 */
#define ASSERT(condition)\
do{ \
if(condition)\
NULL; \
else\
Assert(__FILE__ , __LINE__);\
}while(0)
/* 不使用断言 */
#else
#define ASSERT(condition) NULL
#endif
void Test(unsigned char *str)
{
ASSERT(str != NULL);
/* 函数处理代码,省略... */
}
int main(void)
{
Test(NULL);
return 0;
}
很显然,因为语句 Test(NULL)
为参数 str 错误传入一个 NULL 指针,所以 ASSERT 宏会自动检测到这个错误,同时根据宏 __FILE__
和 __LINE__
所提供的文件名和行号参数在标准错误输出设备 stderr 上打印一条错误消息,然后调用 abort 函数中止程序的执行。运行结果如下图所示。
从上面的示例中不难发现,对标准的 assert 宏来说,自定义的 ASSERT 宏将具有更大的灵活性,可以根据自己的需要打印输出不同的信息,同时也可以对不同类型的错误或者警告信息使用不同的断言,这也是在工程代码中经常使用的做法。当然,如果没有什么特殊需求,还是建议使用标准的 assert 宏。
在函数中使用断言来检查参数的合法性是断言最主要的应用场景之一,它主要体现在如下 3 个方面:
void *Memcpy(void *dest, const void *src, size_t len)
{
assert(dest!=NULL && src!=NULL);
char *tmp_dest = (char *)dest;
char *tmp_src = (char *)src;
/* 检查内存块是否重叠 */
assert(tmp_dest>=tmp_src+len||tmp_src>=tmp_dest+len);
while(len --)
*tmp_dest ++ = *tmp_src ++;
return dest;
}
在上面的 Memcpy 函数中,除了在函数的入口处检查 dest 与 src 参数是否传入 NULL 指针之外,还在函数出口处检查了两个内存块是否发生重叠。
除此之外,建议每一个 assert 宏只检验一个条件,这样做的好处就是当断言失败时,便于程序排错。最后,建议 assert 宏后面的语句应该空一行,以形成逻辑和视觉上的一致感,让代码有一种视觉上的美感。同时为复杂的断言添加必要的注释,可澄清断言含义并减少不必要的误用。
默认情况下,因为 assert 宏只有在 DEBUG 版本中才能起作用,而在 Release 版本中将被忽略。因此,在程序设计中应该避免在断言表达式中使用改变环境的语句。
在对断言的使用中,一定要遵循这样一条规定:对来自系统内部的可靠的数据使用断言,对于外部不可靠数据不能够使用断言,而应该使用错误处理代码。换句话说,断言是用来处理不应该发生的非法情况,而对于可能会发生且必须处理的情况应该使用错误处理代码,而不是断言。
在通常情况下,系统外部的数据(如:不合法的用户输入)都是不可靠的,需要做严格的检查(如:某模块在收到其他模块或链路上的消息后,要对消息的合理性进行检查,此过程为正常的错误检查,不能用断言来实现)才能放行到系统内部,这相当于一个守卫。而对于系统内部的交互(如:子程序调用),如果每次都去处理输入的数据,也就相当于系统没有可信的边界,这样会让代码变得臃肿复杂。事实上,在系统内部,传递给子程序预期的恰当数据应该是调用者的责任,系统内的调用者应该确保传递给子程序的数据是恰当且可以正常工作的。这样一来,就隔离了不可靠的外部环境和可靠的系统内部环境,降低复杂度。
但是在代码编写与测试阶段,代码很可能包含一些意想不到的缺陷,也许是处理外部数据的程序考虑得不够周全,也许是调用系统内部子程序的代码存在错误,造成子程序调用失败。这个时候,断言就可以发挥作用,用来确诊到底是哪部分出现了问题而导致子程序调用失败。在清理所有缺陷之后,就建立了内外有别的信用体系。等到发行版的时候,这些断言就没有存在的必要了。因此,不能用断言来检查最终产品肯定会出现且必须处理的错误情况。
char * Strdup(const char * source)
{
assert(source != NULL);
char * result=NULL;
size_t len = strlen(source) +1;
result = (char *)malloc(len);
assert(result != NULL);
strcpy(result, source);
return result;
}
对于 strdup 函数:
改正如下:
char * Strdup(const char * source)
{
assert(source != NULL);
char * result=NULL;
size_t len = strlen(source)+1;
result = (char *)malloc(len);
if (result != NULL)
{
strcpy(result, source);
}
return result;
}
断言是用来检查非法情况的,而不是测试和处理错误的。因此,不要混淆非法情况与错误情况之间的区别,后者是必然存在且一定要处理的。
在程序设计过程中,总会或多或少产生一些错误,这些错误有些属于设计阶段隐藏下来的,有些则是在编码中产生的。为了避免和纠正这些错误,可在编码过程中有意识地在程序中加进一些错误检查的措施,这就是防错性程序设计的基本思想。其中,它又可以分为 主动式防错程序设计 和 被动式防错程序设计 两种。
主动式防错程序设计:是指周期性地对整个程序或数据库进行搜查或在空闲时搜查异常情况。它既可以在处理输入信息期间使用,也可以在系统空闲时间或等待下一个输入时使用。如下面所列出的检查均适合主动式防错程序设计:
被动式防错程序设计:则是指必须等到某个输入之后才能进行检查,也就是达到检查点时才能对程序的某些部分进行检查。一般所要进行的检查项目如下:
虽然防错性程序设计被誉为有较好的编码风格,一直被业界强烈推荐。但防错性程序设计也是一把双刃剑,从调试错误的角度来看,它把原来简单的、显而易见的缺陷转变成晦涩的、难以检测的缺陷,而且诊断起来非常困难。从某种意义上讲,防错性程序设计隐瞒了程序的潜在错误。
当然,对于软件产品,希望它越健壮越好。但是调试脆弱的程序更容易帮助我们发现其问题,因为当缺陷出现的时候它就会立即表现出来。因此,在进行防错性程序设计时,如果 “不可能发生” 的事情的确发生了,则需要使用断言进行报警,这样,才便于程序员在内部调试阶段及时对程序问题进行处理,从而保证发布的软件产品具有良好的健壮性。
for(i=0; i<count; i++)
{
/* 处理代码 */
}
上述例子中是一个常见的 for 循环,就是一种非常常见的放错性程序设计,保证 i 始终是小于 count 的。非放错性程序设计为:
for(i=0; i!=count; i++)
{
/* 处理代码 */
}
这是,当 i > count 时,程序也是不会停止的。
但是,即便采用了放错性程序设计,也依然存在一种特殊的边界情况是:如果 for 循环中的索引 i 值确实大于 count,那么极有可能意味着代码中存在着潜在的缺陷问题。断言为我们提供了一个非常简单的解决方法:
for(i=0; i<count; i++)
{
/* 处理代码 */
}
assert(i==count);
通过断言真正实现了一举两得的目的:健壮的产品软件和脆弱的开发调试程序,即在该程序的交付版本中,相应的程序防错代码可以保证当程序的缺陷问题出现的时候,用户可以不受损失;而在该程序的内部调试版本中,潜在的错误仍然可以通过断言预警报告。
因此,无论你在哪里编写防错性代码,都应该尽量确保使用断言来保护这段代码。
在日常软件设计中,如果原先规定的一部分功能尚未实现,则应该使用断言来保证这些没有被定义的特性或功能不被使用。例如,某通信模块在设计时,准备提供 “无连接” 和 “连接” 这两种业务。但当前的版本中仅实现了 “无连接” 业务,且在此版本的正式发行版中,用户(上层模块)不应产生 “连接” 业务的请求,那么在测试时可用断言来检查用户是否使用了 “连接 ”业务。
/* 无连接业务 */
#define CONNECTIONLESS 0
/* 连接业务 */
#define CONNECTION 1
int MessageProcess(MESSAGE *msg)
{
assert(msg != NULL);
unsigned char service;
service = GetMessageService(msg);
/* 使用断言来检查用户是否使用了 “连接” 业务 */
assert(service != CONNECTION);
/* 处理代码 */
}
在程序设计中,不能够使用断言来检查程序运行时所需的软硬件环境及配置要求,它们需要由专门的处理代码进行检查处理。而断言仅可对程序开发环境(OS/Compiler/Hardware)中的假设及所配置的某版本软硬件是否具有某种功能的假设进行检查。例如,某网卡是否在系统运行环境中配置了,应由程序中正式代码来检查;而此网卡是否具有某设想的功能,则可以由断言来检查。
除此之外,对编译器提供的功能及特性的假设也可以使用断言进行检查:
/* int 类型占用的内存空间是否为 2 */
assert(sizeof(int)== 2);
/* long 类型占用的内存空间是否为 4 */
assert(sizeof(long)==4);
/* byte 的宽度是否为 8 */
assert(CHAR_BIT==8);
之所以可以这样使用断言,那是因为软件最终发行的 Release 版本与编译器已没有任何直接关系。
最后,必须保证软件的 DEBUG 与 Release 两个版本在实现功能上的一致性,同时可以使用调测开关来切换这两个不同的版本,以便统一维护,切记不要同时存在 DEBUG 版本与 Release 版本两个不同的源文件。
当然,因为频繁调用 assert 会极大影响程序的性能,增加额外的开销。因此,应该在正式软件产品(即 Release 版本)中将断言及其他调测代码关掉(尤其是针对自定义的断言宏)。
assert(nOffset>=0 && nOffset+nSize<=m_nInfomationSize);
正确:
assert(nOffset >= 0);
assert(nOffset+nSize <= m_nInfomationSize);
assert(i++ < 100)
正确:
assert(i < 100)
i++;
assert 和后面的语句应该空一行,以形成逻辑和视觉上的一致性。
断言用于检查 “不应该” 发生的情况,不能代替条件过滤。
放在函数参数的入口处检查传入参数的合法性:
int resetBufferSize(int nNewSize)
{
// 功能: 改变缓冲区大小
// 参数: nNewSize 缓冲区新长度
// 返回值: 缓冲区当前长度
// 说明: 保持原信息内容不变 nNewSize<=0 表示清除缓冲区
assert(nNewSize >= 0);
assert(nNewSize <= MAX_BUFFER_SIZE);
...
}
http://c.biancheng.net/c/assert/