今天去图书馆坐了坐,看罢《Essential C++》,觉得过分基础,实在没什么意思。碰巧包里还有一本神作《C标准库》,详述了实现ANSI C标准库的所有过程。第一章讲的便是assert.h的实现。这个宏本身没有什么难度,无非是在一个函数的基础上包装一下,说不定GCC或者Clang直接把这个函数做成builtin了。不过不管是书上还是musl库的代码,都有一个让我注意到的地方:

#ifdef NDEBUG
#define	assert(x) (void)0
#else
#define assert(x) ((void)((x) || (__assert_fail(#x, __FILE__, __LINE__, __func__),0)))
#endif

这里的这个__assert_fail就如同我上文所说,是一个用于输出内容的函数,作用是输出错误信息然后调用abort函数退出。

关键在于这里有个奇怪的(void)0表达式。首先我们可以判定表达式的类型是void,对吧?不过这个void类型的表达式有什么意义呢?我们学习C语言的教材对这一点基本都语焉不详。我尝试了一下,把(void)0赋给一个int类型的变量,编译器是这样给我抱怨的:

test.c:3:9: error: initializing 'int' with an expression of incompatible type 'void'
  int i = (void)0;
      ^   ~~~~~~~

反正意思就是无法把这个类型转换成匹配的int啦。

那我们转念一想,尝试用void来定义一个变量呢?得到这样的错误提示:

test.c:4:10: error: variable has incomplete type 'void'
  void j;
       ^

等等!incomplete type?哪里见过这个?对啦!如果在一个结构体内部定义一个以这个结构体为类型的对象,编译器就会抱这个错误,提示类型还没有定义完。所以写链表或者二叉树的时候,里面存储的其实是「指向节点的指针」。

继续带着疑惑,我查询了C11的标准草案(正式版是收费的,不过两者在这些基础问题上相差无几),其中有三处提到了我想知道的「void类型」:

…The void type comprises an empty set of values; it is an incomplete object type that cannot be completed…

…An lvalue is an expression (with an object type other than void) that potentially designates an object…

The (nonexistent) value of a void expression (an expression that has type void) shall not be used in any way, and implicit or explicit conversions (except to void) shall not be applied to such an expression. If an expression of any other type is evaluated as a void expression, its value or designator is discarded. (A void expression is evaluated for its side effects.)

而《C程序设计语言》里这样描述:

void对象的(不存在的)值不能够以任何方式使用,也不能被显式或隐式转换为任一非空类型。因为空(void)表达式表示一个不存在的值,这样的表达式只可以用在不需要值的地方,例如作为一个表达式语句(参见A.9.2节)或作为逗号运算符的左操作数(参见A.7.18节)。

可以通过强制类型转换将表达式转换为void类型。例如,在表达式语句中,一个空的强制类型转换将丢掉函数调用的返回值。

这下终于明白了!那么稍微总结一下:

  1. 在C语言中,void可以作为一个合法表达式的类型,亦即它在语法结构里可以作为一个表达式
  2. void类型的表达式不能转换为其他任何类型的表达式,也就没有了赋值的可能
  3. 编译器会特殊看待void类型,将其作为一个「未被定义完整」的类型,也就没有了定义变量的可能
  4. 尽管void未被定义完整,但是如同其他结构体一样,我们是可以正常使用void的,并且直接对一个void解引用,结果是void类型的表达式
  5. void类型和其他类型不相容,但是该有的表达式副作用还是会有

可以说这里面的逻辑是非常自洽且合理的。所以不得不佩服设计C语言和C++的人,这些概念就像物理定律,看上去复杂,但是用这套逻辑推导下去很多看似不同的东西都可以得到统一解释。

现在回头看assert宏的实现代码,不难理解啦。因为__assert_fail函数的返回值类型是void,但是它需要被用在一个逻辑表达式里。于是它巧妙地结合了逗号运算符,配合短路求值的规定实现了assert需要的效果。至于把最后的表达式类型也转换为void,是为了不让它作为值被赋给变量。而在定义了NDEBUG的状态下,用(void)0占位也比什么也不写来得好,编译器会提示类型不相容,而直接替换成空的话,在代码复杂的地方错误类型也许会莫名其妙。