【写在前面】
C/C++ 中使用 #define 的地方似乎越来越少。
最开始接触的时候,常常被告诉,#define 只是普通的文本替换,一般用来定义一些常量。
后来又学习到,使用 const 代替 #define 来定义真正的常量。
随着经验的积累,#define 现在更多的用来做一些代码的预处理工作和小函数定义。
然而,宏远没有想象的那么简单,实际上,预处理阶段同样有着相当复杂的运算规则。
本篇主要内容:
1、预处理运算符 # ## #@ 。
2、预处理器展开规则。
3、递归宏展开。
【缘起】
一个偶然的机会,想在 Qt 中利用宏定义做一些预处理的工作:
我们知道, Qt 的 qmake 阶段是在预处理阶段之前,因此,我打算在 qmke 中传入 $$PWD,然后就可在编译前获得当前项目目录。
例如,当前项目目录为:D:/program/Qt,那么 $$PWD 的值即为 D:/program/Qt。
可以利用 DEFINES 变量向 C/C++ 传入一个宏定义,它会被附加到编译器命令中,即 MSVC / GCC中加入 [-D] 选项。
DEFINES += PROJECT_PATH=$$PWD
接着,我们在 C/C++ 中可以获得 PROJECT_PATH 为 D:/program/Qt,然而,实际上,该宏并非是字符串,它不能直接使用。
于是,我们定义一个简单的宏:
#define STRINGIFY(x) #x
然后使用它:
char path[] = STRINGIFY(PROJECT_PATH);
令人惊讶的是,path 中保存的字符串为:“PROJECT_PATH”。
带着好奇心,我查阅了很多资料,学习了很多,最终对预处理有了相当深入的理解,它其实并没有那么简单。
【正文开始】
-
基础
首先,介绍三个运算符:
1、字符串化运算符(#):导致将相应的实参括在双引号中。
2、字符化运算符(#@):使相应的参数括在单引号中,并被视为一个字符( Microsoft 特定 ),GCC/G++无此运算符。
3、 标记粘贴运算符(##) 允许用于连接到其他标记的实参的标记。
每个运算符具体用法见:
MS官方介绍:https://docs.microsoft.com/zh-cn/cpp/preprocessor/preprocessor-operators?view=msvc-160
然后,我们需要引入一些规则:
1、每次宏展开的结果会被重复扫描,直到没有任何可展开的宏为止。
2、当每个宏出现在另一个宏的定义中时,它们将被展开,但是当它间接出现在其自己的定义中时,则不会被展开。
自引用宏:https://gcc.gnu.org/onlinedocs/cpp/Self-Referential-Macros.html#Self-Referential-Macros
3、除非宏参数中包含字符串化运算符 [#] 或 标记粘贴运算符 [##],否则在将宏参数替换为宏主体之前,它们会完全进行宏扩展, 注意参数展开的结果中即使有逗号 [,] 也不视为参数的分隔符。
4、如果宏定义中带有参数,而代码中出现同样标识符时没有参数,不视为宏。
所有规则来自:https://gcc.gnu.org/onlinedocs/cpp/Macros.html#Macros
对于GCC适用,而对于MSVC,没有找到相关说明,但符合此规则,即:预期行为一致。
现在,回到我们的代码:对 STRINGIFY(PROJECT_PATH) 引入规则3:
宏展开经过如下:
STRINGIFY(PROJECT_PATH) => #PROJECT_PATH
此时,PROJECT_PATH 前有 [#] 运算符,将不会再被展开,最终导致结果为:"PROJECT_PATH"。
明白原因后,我们需要一个能达到这样的效果的宏:STRINGIFY(D:/program/Qt)。
因此,引入一个间接宏:
#define STRINGIFY2(x) #x
#define STRINGIFY(x) STRINGIFY2(x)
现在宏展开的经过如下:
#define STRINGIFY2(x) #x
#define STRINGIFY(x) STRINGIFY2(x)
int main()
{
char path[] = STRINGIFY(PROJECT_PATH);
=> STRINGIFY2(D:/program/Qt)
-> #D:/program/Qt
=> "D:/program/Qt"
}
然后我们来看结果:
要想只进行预处理而不编译,需要改变编译选项:
MSVC编译器( 生成 *.i 文件 ): cl.exe -DPROJECT_PATH=D:/program/Qt -EP -P test.cpp
GCC/G++编译器:gcc -DPROJECT_PATH=D:/program/Qt -E test.cpp -o test.e
具体选项请自行查阅对应的帮助 [-help] / [--help]。
main.i 内容如下:
int main()
{
char path[] = "D:/program/Qt";
=> "D:/program/Qt"
-> #D:/program/Qt
=> "D:/program/Qt"
}
path 得到了正确的结果!
实际上,Qt 中实现了此宏,在 qglobal.h 中:
/* These two macros makes it possible to turn the builtin line expander into a
* string literal. */
#define QT_STRINGIFY2(x) #x
#define QT_STRINGIFY(x) QT_STRINGIFY2(x)
现在确实解决了我的问题,但如果还想深入,请接着往下看。
- 进阶
给你下面一段代码,根据展开规则,想想看,它会输出什么:
#define STRINGIFY2(x) #x
#define STRINGIFY(x) STRINGIFY2(x)
#define CONCAT2(x, y) x##y
#define CONCAT(x, y) CONCAT2(x, y)
#ifdef _MSC_VER
#define MAKE_CHAR(c) #@c
#else
#define _CONCAT3(x, y, z) x##y##z
#define CONCAT3(x, y, z) _CONCAT3(x, y, z)
#define QUOTE '
#define MAKE_CHAR(c) CONCAT3(QUOTE, c , QUOTE)
#endif
#define ABC A \
B \
C
#include <cstdio>
int main(int argc, char **argv)
{
char code[] = STRINGIFY(let c = MAKE_CHAR(1234););
char str[] = STRINGIFY(CONCAT(CONCAT(A, B), C));
std::printf("%s\n", code);
std::printf("%s\n", str);
}
结果如下:
现在我们开始分析:
这里我定义 -> 为临时展开,=> 为实际展开。
1、对于 code,经过如下展开:
int main(int argc, char **argv)
{
char code[] = STRINGIFY(let c = MAKE_CHAR(1234););
-> STRINGIFY2(let c = (#@1234);)
=> STRINGIFY2(let c = '1234';)
-> #let c = '1234';
=> "let c = '1234';"
}
预处理后结果为:
int main(int argc, char **argv)
{
char code[] = "let c = '1234';";
-> "let c = (#@1234);"
=> "let c = '1234';"
-> #(let c = '1234';)
=> "let c = '1234';"
}
2、对于 str,经过如下展开:
int main(int argc, char **argv)
{
char str[] = STRINGIFY(CONCAT(CONCAT(A, B), C));
=> STRINGIFY(CONCAT(CONCAT2(A, B), C))
-> STRINGIFY(CONCAT(A##B, C))
=> STRINGIFY(CONCAT2(AB, C))
-> STRINGIFY(A##B##C)
=> STRINGIFY(ABC)
=> STRINGIFY2(A B C)
-> #A B C
=> "A B C"
}
预处理后结果为:
int main(int argc, char **argv)
{
char str[] = "A B C";
=> "A B C"
-> "A##BC"
=> "A B C"
-> "A##B##C"
=> "A B C"
=> "A B C"
-> #A B C
=> "A B C"
}
实际上,只需依据规则2,我们可以轻松处理递归宏展开。
【结语】
呼~总算写完了,虽然已经讲得非常详细了,但仍然有许多情况没有考虑,例如:
MAKE_CHAR 的 GCC 实现在遭遇空格时会失败,MSVC 则不会( 原谅我太菜了 T T)。
另一方面,需要简单介绍一下行继续符( 反斜杠符号 ) [\]:
每个反斜杠字符[ \ ]的实例会删除紧跟的换行符,拼接物理源代码行以形成逻辑源代码行。只有在任何物理源行上的最后一个反斜杠才有资格成为此类拼接的一部分。
值得注意的一点是,上面的 ABC宏展开后会有空格,是因为AB后插入了空格( \ 之前) ,实际上,无论插入多少个空格都会被预处理器合并,即:拼接物理源代码。
最后,还有一点,不同编译器的预处理器是有细微差别的,一定要注意注意注意。