在C语言程序开发中,程序员写代码时应该考虑的“面面俱到”,这样才能写出功能稳定的程序。例如,在实现 open() 函数时,先完成它的功能固然是重要的,但是程序员还需要考虑各种“意外”,比如下面这种情况。
假设不存在 /dev/sth 这个文件,仍然调用 open() 函数打开它:
int fd = open("/dev/sth", O_RDONLY);
此时 open() 函数不应该感到迷惑,而是具备处理这种“意外”的能力。标准库的 open() 函数在遇到这种情况时,会返回一个错误码,对应着“文件不存在”的错误信息。
所以我们在开发C语言程序的过程中,写出的代码也应具备这种处理“意外”的能力。处理“意外”最常用的方式之一就是返回一个错误码,输出一段错误提示信息,这一点其实之前的文章讨论过。
使用 assert
在C语言程序开发阶段,为了方便,我们可以在可能出现不预期的“意外”处使用 assert()。assert() 的C语言原型如下:
#include <assert.h>
void assert(scalar expression);
使用它需要包含 assert.h,assert() 接收一个参数 expression,可以是一个表达式,如果 expression 为真,则什么都不会发生。如果 expression 为假,则 assert() 会终止C语言程序,并且输出 assert 失败的代码位置。
例如下面这段C语言代码:
int fd = open("/dev/sth", O_RDONLY);
assert(fd > 0);
printf("fd = %d\n", fd);
编译并执行,得到如下结果:
# gcc t.c
# ./a.out
a.out: t.c:11: main: Assertion `fd > 0' failed.
Aborted
可以看出,第 12 行的 printf() 函数并没有被执行。这是因为程序运行环境里并没有 "/dev/sth" 这个文件,所以 open() 函数执行失败,传递给 assert() 的参数为假,C语言程序被终止,并且输出 t.c 源文件第 11 行代码 assert 失败。
assert() 可以输出出错的代码位置,这个特性在较为大型的C语言程序开发中是非常好用的,因为无需程序员再去手工调试代码,排查出错代码的位置了。
不过,assert() 在遇到假参数时,直接将C语言程序终止太过于死板。比如某个C语言程序有两套逻辑,第一套逻辑在 open() 函数成功打开文件时运行,第二套逻辑则在 open() 函数打开文件失败时运行。要是使用 assert() 判断 open() 函数是否成功打开文件,则第二套逻辑永远没有机会运行。
所以,assert() 一般仅用于开发阶段帮助程序员定位错误,不能依赖 assert() 处理“意外”。事实上,为了便于使用,在定义了 NDEBUG 宏之后,assert() 就不再生成代码了,此时 assert() 相当于一个空格。请看下面这段C语言代码:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#define NDEBUG
#include <assert.h>
int main()
{
int fd = open("/dev/sth", O_RDONLY);
assert(fd > 0);
printf("fd = %d\n", fd);
return 0;
}
编译上述C语言代码并执行,得到如下输出:
# gcc t.c
# ./a.out
fd = -1
编译时 assert
可以看出,assert() 用于处理C语言程序可能出现诸多预期之外的“意外”时很有用,它能够自己输出究竟哪一个“意外”发生。但是 assert() 也是死板的,它在遇到假条件时直接把程序终止,剩余的代码逻辑不再有机会执行。
另外还有一点要说明,assert() 本身也会影响C语言程序的运行效率,这也是它常常只被使用在开发阶段的另一个原因。
其实仔细想想,使用 assert() 的目的其实只是希望它能够在C语言程序遇到不预期的“意外”时提醒程序员,我们并不关心 assert() 是否参与程序运行。如果使用 assert() 判断的是常量表达式,那我们可以自己定义一个 static_assert() 宏,并且让它在编译时就判断条件表达式是否成立,这样的宏可能在某些场合更加好用。
那该如何实现编译时 assert 这个功能呢?
其实很简单,首先应该明白数组的长度不可能是负数,基于这一点,static_assert() 宏就容易实现了,请看下面的C语言代码:
#define static_assert(expr) \
do{ char tmp[(expr)?1:-1]; }while(0)
如果条件表达式为真,则 static_assert() 宏会定义一个长度为 1 的数组,否则就会尝试定一个长度为 -1 的数组,此时必定无法编译通过。这里值得一提的一个小技巧是使用 {} 符号将定义的 tmp 数组的作用域限定在本次调用的 static_assert 宏里,避免多次调用 static_assert 时出现重复定义。
写出如下C语言代码测试之:
int main()
{
static_assert(2>1);
printf("assert 2>1\n");
static_assert(2<1);
printf("assert 2<1\n");
return 0;
}
编译这段C语言代码,得到如下输出:
显然,static_assert() 宏在编译阶段就将假条件表达式找出来了。可能有些读者会觉得如果 assert 成功,就会定义一个 tmp 数组,虽然它的长度很短,但是仍然浪费了栈空间。其实这里可以把长度为零的数组,即:
#define static_assert(expr) \
do{ char tmp[(expr)?0:-1]; }while(0)
在 assert 成功时会执行 char tmp[0];,它的长度为 0,感兴趣的读者可以使用 sizeof() 测试一下。到这里,我们就较为粗略的定义好了 static_assert 宏,它在编译阶段就能发现假条件。
小结
本节主要介绍了 assert() 的使用,应该能够发现,在开发阶段,它能够帮助程序员快速的定位“意外”,也讨论了 assert() 的不足之处,并在此基础上自己定义了“编译时”的static_assert 宏。按照这样的思路,其实还有很多定义 static_assert() 宏的其他方法,具体哪些方法留给读者自己思考了。