宏定义黑魔法-从入门到奇技淫巧 (1)

我知道 HTTP 服务器的那个坑我已经坑了快一年了…不过找完工作后实在是没有动力接着写下去(doge)。我保证年内更新完毕(๑•̀ㅂ•́)و✧

楔子

最近在研究如何在 C++ 里边实现反射,结果发现了很多有意思的技巧。可惜其中相当一部分都是依赖于宏实现的,晦涩难懂。这个过程中断断续续查了很多资料,发现网上不管是中文还是英文都很少有资料对宏的用法有一个比较完整的介绍。特别是一些「奇技淫巧」类的惯用法。少数几个也没有原理上的分析,十分可惜。所以在这里将自己搜集到的东西整理成一个教程,以飨列位。随着教程的深入,你会发现宏的能力远超一般的认识。利用各种猥琐的技巧,可以使宏具有接近图灵完备语言的能力。

一般来说在 C++ 中是不提倡使用宏的,模板已经能够很好地替代宏的绝大多数功能了。宏由于其自身设计的原因,使用起来不仅晦涩难懂,而且还有很多难以预料的坑。那么现在是不是就没有学习宏的必要了呢?笔者认为答案是否定的。理由有三:其一,仍有一小部分功能模板无法替代宏来实现,且有的功能使用宏比模板更加的直观。其二,以前的遗留代码中有大量的宏,而这些库在今天依旧有着广泛的应用。其三,宏的很多惯用发思路清奇又猥琐,却又暗合编程语言理论,用来把玩也是很有趣的。

友情提示:阅读本教程时,ISO C++ 标准 16.3 节也许会对你有所帮助。

宏的定义

绝大多数人对于宏的概念仅仅停留在简单替换的程度上,例如:

1
2
3
4
5
#define N 42
N
// ->
42

众所周知,这个宏的作用是找到源文件中所有的 N 然后将其替换为 42。然而,对于 C 和 C++ 的预处理需求来说,这种功能过于简单了。有时候我们希望能够根据不同的参数来进行某种模式的替换。这就产生了第二种宏,带参数的宏。例如:

1
2
3
4
5
#define ADD(x, y) x + y
ADD(1, 2)
// ->
1 + 2

ADD 宏中与参数一致的部分被替换为了相应的内容。事实上,我们将前一种宏叫做 object-like 的宏(下略为 obj-like),后一种叫做 function-like 的宏(下略为 func-like)。一个标准的宏定义格式如下:

1
2
3
4
5
// obj-like
#define 宏名 替换列表 换行符
//func-like
#define 宏名 ([标识符列表]) 替换列表 换行符

其中替换列表标识符列表都是将字符串 token (如果对 token 这个概念比较陌生请看文章最后) 化以后的列表。区别在于标识符列表使用,作为不同参数之间的分割符。每一个参数都是一个 token 化的列表。

这里有两点值得注意的地方:1. 宏的内容会被 token 化成一个替换列表。也就是说,预处理器在处理宏展开时并不是以字符串的形式处理,而是以 token 列表的形式处理的。这对于我们理解宏的行为非常重要。例如,在宏中空白符只起到分割 token 的作用,空白符的多少对于预处理器是没有意义的:

1
2
3
4
5
#define VAR_1 int name;
VAR_1
// ->
int name;

2.宏定义以换行符结尾,这意味着一个宏定义不论多长都只能写在一行中,如果需要分行写,请使用 \,例如:

1
2
3
4
5
#define MAIN \
int main() \
{ \
return 0; \
}

此外,宏可以重复定义,但前提是两次定义的内容完全一致。例如:

1
2
3
4
5
6
7
// 合法的二次定义
#define FOO int foo;
#define FOO int foo;
//重定义错误,有的编译器会给出警告并使用最后一次的宏定义
#define FOO int* foo;
#define FOO int foo;

宏的操作符

和 C, C++ 语言本体不同,宏有着它自己特有的两个操作符。下边简单地介绍一下。

字符串化操作符 #

有时候我们希望能够讲参数转换为字符串进行处理,# 可以将一个 token 字符串化。例如:

1
2
3
4
5
6
7
8
9
#define WARN_IF(EXP) \
if (EXP) \
{\
fprintf (stderr, "Warning: " #EXP "\n"); \
}\
WARN_IF (x/* const char* */ == "0")
// ->
if (x == "0") { fprintf (stderr, "Warning: " "x == \"0\"" "\n"); }

此处有几点需要注意:

  1. #操作符并不是简单的添加双引号,它会自动对特殊字符进行转义。
  2. #操作符只能对 func-like 的参数使用。
  3. 由于参数会转化为 token 列表,所以前后的空白符都会被忽略,中间的空白符会被压缩为一个,注释会被忽略并变成一个空白符。

Token 粘贴操作符

然后我们再学习一个新的操作符 #### 可以将两个 token 合并为一个。合并新的 token 有什么用呢?它可以提供给你动态的生成 token 的能力,例如:

1
2
3
4
5
#define GETTER(x, T) T get_ ## x() {return this->x;}
GETTER(foo, const int)
//->
const int get_foo() {return this->foo;}

此处通过GETTER生成了一个对于属性x的访问器。当然,这并不是一个明智的做法,而且还有很多漏洞。但是它展示给了我们宏动态生成 token 的能力。

有人可能注意第一个x需要##来进行连接,第二个却不需要。这是因为预处理器以 token 列表的方式处理展开过程。对于第一个x如果不使用##,那么对于预处理器来说get_x只是一个内容为get_x的 token,x 并不会被替换。若需要将 x 单独看做一个 token 则只能写成get_ x这样展开后中间会多一个空格,这显然不是我们想要的。

对于第二个x,对于预处理来说,->x天然的就是两个 token,而且按照 C 的语法两者之间是否有空格并不会产生影响。合并以后反而会产生一个内容为->foo的 token。->foo并不是一个合法的 token,标准规定这种情况属于 ill-formed。不同编译器处理方法不同,gcc, clang 的预处理器会报错停止,vc会生成两个不同的 token。感兴趣的读者可以自己动手试验一下。

这一节的内容到此结束。到目前为止内容还比较简单,下一节的内容才是宏真正烧脑的地方。下节预告: object-like 宏的递归展开。

附录:什么是 token

什么是 token?token 在编译原理中只语法符号或者语法标记。可以看做是用来标记某个语法成分的抽象,一般由 token 名和一些属性组成。比如说数字 1 可以认为是一个整数常量,token 名为integer-constant,且其属性值为 1。对于宏来说共有这么几种:

  • identifier 标识符,这个和 C 语言的标识符定义一致
  • pp-number 预处理数字,其实和 C 语言中的数字也是类似的,区别在于多了一种情况,就是某种数字开头后跟非数字标识符的情况,比如 12aa,1.2bc 这种
  • character-constant 字符常量,就是'a', '\n'
  • string-literal 字符串字面量 "hello world"
  • punctuator 标点符号 + - -> >>
  • 除了上边所列情况以外的所有非空白字符

例如下边这个宏中的替换列表 token 化以后就是 [identifier] [punctuator] [pp-number]

1
#define foo(x) x ## 12