相信大家对断言都很熟悉,大多数编程语言也都有这个功能。 简而言之,断言是对某些假设的检查。 在C语言中,断言被定义为宏(())而不是函数,其原型定义在文件中。
其中,会检查表达式的值来确定是否需要终止执行程序。 也就是说,如果表达式的值为假(即0),那么它首先会向标准错误流打印一条错误消息,然后通过调用abort函数终止程序; 否则没有效果。
原型定义:
#include
void assert( int expression );
默认情况下,宏仅在调试版本(内部调试版本)中起作用,并且在版本(发布版本)中将被忽略。 当然,您也可以随时通过定义宏或设置编译器参数来启用或禁用断言检查(不推荐这样做)。 同样,程序运行后,最终用户如果遇到问题可以重新启用断言。
这样可以快速发现和定位软件问题,并自动报警系统错误。 对于系统隐藏较深、用其他手段极难发现的问题,也可以通过断言来定位,从而缩短定位软件问题的时间,提高系统的可测试性。
尽可能使用断言来提高代码的可测试性
在讨论如何使用断言之前,我们先看一下下面的示例代码:
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;
}
对于上面的函数来说,通过编译器的检查毫无疑问是可以编译成功的。 从表面上看,这个函数没有任何其他问题,而且代码也很干净。
不幸的是,如果在调用这个函数时不小心给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指针时,该函数就会检查出来并进行相应的处理,即先打印一条错误信息到标准错误流,然后调用abort函数终止程序。
从表面上看,上述解决方案应该是完美的。 然而,随着需要检查的函数参数或表达式的数量不断增加,这个检查测试代码将占据整个函数的大部分(这一点从上面的函数中很容易看出)。 这段代码看上去非常不简单,甚至可以说是“糟糕”,而且还降低了函数的执行效率。
面对上述问题,或许可以利用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”可以产生很好的结果,完全满足我们的编程需求,但是如果仔细观察,你会发现这样的测试检查代码并不是那么友好。 当一个函数中有很多这样的条件编译语句时,代码会显得有些臃肿甚至糟糕。
因此,对于上述情况,大多数程序员会选择将所有调试代码隐藏在断言宏中。 其实宏只是用条件编译“#ifdef”来替换部分代码。 使用宏将使代码更加简洁,如下示例代码所示:
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;
}
现在,程序的测试检查功能是通过“(dest !=NULL && src !=NULL)”语句完成的(即调用该函数时,每当dest和src参数错误时传入NULL指针) ,与此同时,函数的代码量也大大减少了。 我不得不说,这是一个两全其美的好方法。
事实上,在编程中,我们经常会出于某种目的(比如定义一个宏,以便在发生错误时不中断调用程序的执行,而是在发生错误的位置转移到调试器,或者允许用户选择程序继续运行等)宏需要重新定义。
但值得注意的是,无论断言宏最终如何定义,定义的宏的主要目的是用它来确认传递给相应函数的参数。
如果违反宏定义的这个原则,定义的宏就会偏离方向,失去宏定义本身的意义。 同时最好使用其他名称,以免影响标准宏的使用。 例如,以下示例代码展示了用户如何重新定义自己的宏:
/*使用断言测试*/
#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,则会将其扩展为if语句,否则将执行“#()NULL”并替换为NULL。
这里需要注意的是,在编写C语言代码时,添加分号“;” 每条语句后面已经成为约定俗成的习惯,所以很有可能在“(,)”调用语句后面加一个分号。
实际上不需要这个分号,因为用户在调用宏时已经给出了分号。 面对这个问题,我们可以使用“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指针,宏会自动检测到这个错误,同时根据宏和提供的文件名和行号参数,在标准错误输出设备上打印错误消息,然后调用abort函数来中止程序执行。 运行结果如图1所示。
在此插入图片描述
图1 调用自定义宏的结果
这时候如果把自定义宏换成标准宏会出现什么结果呢? 如下示例代码所示:
void Test(unsigned char *str)
{
assert(str != NULL);
/*函数处理代码*/
}
不用说,标准宏也会自动检测这个 NULL 指针错误。 同时,标准宏除了给出上述信息外,还可以显示失败的测试条件。 运行结果如图2所示。
在此插入图片描述
图2 标准宏调用结果
从上面的例子中不难发现,相比于标准宏,定制宏将会具有更大的灵活性。 它们可以根据自己的需要打印出不同的信息,也可以打印出不同类型的错误或警告信息。 使用不同的断言,这也是工程代码中的常见做法。 当然,如果没有特殊需要,建议使用标准宏。
尝试在函数中使用断言来检查参数的有效性
在函数中使用断言来检查参数的合法性是断言最重要的应用场景之一。 主要体现在以下三个方面:
1、在代码执行前或函数入口处使用断言来检查参数的有效性。 这称为前提条件断言。
2. 代码执行后或函数退出时,使用断言检查参数是否正确执行。 这称为后置条件断言。
3. 使用断言来检查代码执行前后或函数进入和退出时参数是否发生变化。 这称为不变断言。
例如,在上面的函数中,除了使用“(dest !=NULL && src!=NULL);” 语句检查函数入口处dest和src参数是否传入NULL指针,也可以使用“(> =+len||>=+len);” 语句检查两个内存块是否重叠。 如下示例代码所示:
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;
}
另外,建议每个宏只检查一个条件。 这样做的好处是,断言失败时方便程序调试。 试想一下,如果在一个断言中同时测试多个条件,当断言失败时,我们将很难直观地判断是哪个条件失败了。 因此,下面的断言代码应该更好,尽管它有些不必要:
assert(dest!=NULL);
assert(src!=NULL);
最后,建议宏后面的语句应该是空行,以形成逻辑和视觉上的一致性,赋予代码视觉上的美感。 同时,对复杂的断言添加必要的注释可以明确断言的含义,减少不必要的误用。
避免在断言表达式中使用环境更改语句
默认情况下,由于宏仅在调试版本中起作用,因此它们将在构建中被忽略。 因此,在编程时应避免在断言表达式中使用改变环境的语句。 如下示例代码所示:
int Test(int i)
{
assert(i++);
return i;
}
int main(void)
{
int i=1;
printf("%d\n",Test(i));
return 0;
}
对于上面的示例代码,由于“(i++)”语句的原因,不同的编译版本会产生不同的结果。 如果是在 Debug 版本中,因为这里赋给变量 i 的初始值为 1,所以执行“(i++)”语句时会通过条件检查,然后继续执行“i++”,最终输出结果值为 2 ; 如果是在版本中,函数中的断言语句“(i++)”将被忽略,因此表达式“i++”不会被执行,导致输出结果值仍然为1。
因此,您应该避免在断言表达式中使用像“i++”这样改变环境的语句,并将其替换为以下代码:
int Test(int i)
{
assert(i);
i++;
return i;
}
现在,调试版本和版本的输出都将为2。
避免使用断言来检查程序错误
使用断言时,必须遵循以下规则:对来自系统内部的可靠数据使用断言。 您不能对外部不可靠数据使用断言。 相反,您应该使用错误处理代码。 换句话说,断言用于处理不应该发生的非法情况,而错误处理代码,而不是断言,应该用于可能发生且必须处理的情况。
一般情况下,系统外部的数据(如非法用户输入)是不可靠的,需要严格检查(例如,一个模块从另一个模块或链路接收到消息后,必须检查该消息的有效性。这个过程是一个正常的错误检查,不能用断言实现)才可以释放到系统中,相当于一个守卫。
对于系统内部的交互(比如子程序调用),如果每次都处理输入数据,则意味着系统没有可信边界,这会让代码变得臃肿、复杂。
事实上,在系统内部,调用者有责任传递子例程所需的适当数据。 系统内的调用者应确保传递给子例程的数据是适当的并且正常工作。 这样就隔离了不可靠的外部环境和可靠的内部系统环境,降低了复杂度。
然而,在代码编写和测试阶段,代码很可能包含一些意想不到的缺陷。 也许是处理外部数据的程序考虑得不够周到,也可能是调用系统内部子程序的代码有错误,导致子程序调用失败。
这时候断言就可以发挥作用来诊断是哪个部分出现问题导致子程序调用失败。 清除了所有缺陷后,建立了区分内部和外部的信用体系。 当版本发布时,这些断言将不再必要。 因此,断言不能用于检查最终产品中肯定会出现且必须进行处理的错误情况。
看下面的示例代码:
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;
}
以上功能相信大家都很熟悉了。 其中,第一个断言语句“(!=NULL)”用于检查程序正常工作时绝对不应该出现的非法情况。
换句话说,如果调用代码正确,则传递给参数的值一定不能为 NULL。 如果断言失败,则意味着调用代码有错误,必须修改。 因此,这是断言的正常用例。
第二个断言语句“(!=NULL)”的使用方式有所不同。 它测试错误条件,这些错误条件肯定会出现在最终产品中并且必须进行处理。
也就是说,对于函数来说,当内存不足导致内存分配失败时,会返回NULL,所以这里不应该使用宏进行处理,而应该使用错误处理代码。 例如,使用if判断语句会处理以下问题:
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 循环,如以下示例代码所示:
for(i=0;i {
/*处理代码*/
}
几乎所有 for 循环示例中,行为都是从 0 迭代到“count-1”,因此每个人都自然会编写这个防错版本。 但问题是:如果for循环中的索引i值确实大于count,那么很可能意味着代码中存在潜在的缺陷。
由于上面的for循环示例采用了防错编程,因此即使在内测阶段出现这样的缺陷,也很难发现问题,更不可能出现系统报警。 同时,由于这种潜在的程序缺陷,很可能让我们在未来遭受很大的损失,并且诊断起来非常困难。
那么如果没有防错的话编程会是什么样子呢? 如下示例代码所示:
for(i=0;i!=count;i++)
{
/*处理代码*/
}
显然,这种写法是肯定不行的。 当for循环中的索引i值确实大于count时,仍然不会停止循环。
对于上面的问题,断言为我们提供了一个非常简单的解决方案,如下面的示例代码所示:
for(i=0;i {
/*处理代码*/
}
assert(i==count);
不难发现,断言真正达到了一箭双雕的目的:产品软件健壮,开发调试程序脆弱。 即在程序的交付版本中,具有相应的程序防错代码,可以保证当程序出现缺陷时,用户受到保护; 在程序的内部调试版本中,仍然可以通过断言警报报告潜在的错误。
因此,“无论您在何处编写防错代码,都应该尝试确保使用断言来保护该代码。” 当然,对此也不必太死板。 例如,如果每次执行for循环时索引i的值简单地加1,那么索引i几乎不可能超过count并导致问题。 在这种情况下,相应的断言没有任何意义,应该从程序中删除。
然而,如果索引 i 的值被以其他方式处理,则必须使用断言来警告。 可见,防错编程中是否需要使用断言进行错误报警,需要根据具体情况而定。 在编码之前,你必须问自己:“在设计防错编程时,错误是否隐藏在程序中?” “如果答案是肯定的,那么你必须在程序中添加相应的断言来提醒你这些错误。否则,就不用理会它了。
使用断言确保未定义的特性或函数不被使用
在日常的软件设计中,如果有些原来指定的功能还没有实现,就应该使用断言来保证这些未定义的特性或功能不被使用。 例如,设计某个通信模块时,准备提供“无连接”和“连接”两种服务。 但当前版本只实现了“无连接”服务,而在该版本正式发布时,用户(上层模块)不应该请求“有连接”服务,因此测试时可以使用断言来检查用户是否使用“连接”业务。 如下示例代码所示:
/*无连接业务*/
#define CONNECTIONLESS 0
/*连接业务*/
#define CONNECTION 1
int MessageProcess(MESSAGE *msg)
{
assert(msg != NULL);
unsigned char service;
service = GetMessageService(msg);
/*使用断言来检查用户是否使用了“连接”业务*/
assert(service != CONNECTION);
/*处理代码*/
}
谨慎使用断言来检查程序开发环境中的假设
在编程中,断言不能用来检查程序运行时所需的软硬件环境和配置要求。 它们需要通过特殊的处理代码进行检查和处理。 断言只能检查程序开发环境(OS//)中的假设以及所配置的软硬件版本是否具有某种功能。 例如,系统运行环境中是否配置了某个网卡,应通过程序中的正式代码来检查; 通过断言可以检查网卡是否具有某种预期的功能。
此外,还可以使用断言来检查有关编译器提供的功能和特性的假设,如以下示例代码所示:
/*int类型占用的内存空间是否为2*/
assert(sizeof(int)== 2);
/*long类型占用的内存空间是否为4*/
assert(sizeof(long)==4);
/*byte的宽度是否为8*/
assert(CHAR_BIT==8);
之所以可以这样使用断言,是因为软件的最终版本与编译器没有直接关系。
最后,要保证Debug版本的软件在功能上与两个版本一致。 同时可以通过调试开关在两个不同版本之间切换,进行统一维护。 切记不要同时有两个不同的Debug版本和版本。 源文件。
当然,频繁的调用会极大地影响程序的性能并增加额外的开销。 因此,断言和其他调试代码(尤其是自定义断言宏)应该在官方软件产品(即版本)中关闭。 调试完成后,可以在含有#的语句前插入#来禁用调用,示例代码如下:
#include
#define NDEBUG
#include