
引言C 预处理器是 C 标准中的另一块重要内容。对代码进行预处理是 C 源代码在被“真正”编译并生成机器代码前的重要一环。合理使用预处理指令可以让源代码根据不同的环境信息进行动态变化并生成适合在当前环境下编译的 C 代码。这里我们提到的“环境”一般与目标操作系统、CPU 体系架构以及当前平台上各系统库的支持情况有关。除此之外预处理器还为我们提供了一定的能力可以更加高效、灵活地组织 C 源代码。比如我们可以对一个完整的 C 程序进行结构化拆分根据代码在语法结构或功能定位上的不同将它们分别整理在独立的 C 源文件中。而在包含有程序入口 main 函数的源文件内我们便可以通过 #include 预处理指令在编译器开始真正处理 C 代码前将程序运行所需要的其他代码依赖包含进来。那么今天我们就来看看有关 C 预处理器的内容。接下来我将介绍 C 预处理器的相关背景知识、预处理的基本流程以及宏编写技巧和使用注意事项。C 预处理器的相关背景知识预处理器被引入 C 标准的时间比 C 语言诞生晚了大约一年。1973 年左右在贝尔实验室研究员 Alan Snyder 的敦促下预处理器被正式整合至 C 语言中。它的最初版本只拥有基本的文件包含和字符串替换能力。而在此后不久它被进一步扩展加入了带参数的宏以及条件编译等功能。在随后发布的 ANSI C 标准中预处理器的能力再次得到了加强。另外你需要知道的是C 预处理器并不仅仅可以用在 C 语言上在 C、Objective-C 等基于 C 的“后继语言”中这套预处理器语法仍然适用。除此之外也许你也还并不清楚这一点为什么使用预处理器语法 #define 定义出来的符号被称为宏macro而那是因为在希腊语中macro 通常会被作为一个单词前缀用来表示体积或数量上的“大”和“多”。而在 C 代码中当一个宏被展开和替换时不是也有类似的效果吗到这里有关 C 预处理器的一些背景知识我就介绍完了。下面就让我们来看看编译器是如何对 C 代码进行预处理的。预处理是怎样进行的对代码进行预处理是整个 C 程序编译流程中的第一环。在这一步中编译器会对源代码进行分析并通过查找以 “#” 字符开头的代码行来确定预处理器指令的所在位置。接下来通过下面这些步骤编译器可以完成对代码的预处理工作删除源代码中的所有注释处理所有宏定义#define并进行展开和替换处理所有条件预编译指令如 #if、#elif仅保留符合条件的代码处理文件包含预编译指令#include将被包含文件的内容插入到该指令的所在位置处理其他可以识别的预处理指令如 #pragma添加其他具有辅助性功能的注释信息。为了进一步观察编译器在预处理阶段对 C 代码的处理过程这里我们可以进行一个简单的实验将下面这段代码保存在文件 “macro.c” 中并通过命令 “gcc -O0 -Wall -E ./macro.c -o macro.l” 对它进行编译。#pragma GCC warning Just FYI! #include stdbool.h #define PI 3.14 #define SQUARE(x) (x * x) int main(void) { #if defined PI // Some specific calculations. const double area SQUARE(5) * PI; const bool isAreaGT100 area 100.0; #endif return 0; }眼尖的你会发现我们在编译命令中使用到了名为 “-E” 的参数。该参数的作用是让编译器仅对源代码执行预处理阶段的一系列操作之后就停止运行。当编译完成后你可以在编译目录内找到名为 “macro.l” 的文件。而在这个文件中便包含有源代码在经过预处理阶段后得到的中间代码结果。其中的内容如下所示注意不同的编译器和版本生成的文件内容可能有所不同# 1 macro.c # 1 built-in # 1 command-line # 31 command-line # 1 /usr/include/stdc-predef.h 1 3 4 # 32 command-line 2 # 1 macro.c # 1 /usr/lib/gcc/x86_64-redhat-linux/8/include/stdbool.h 1 3 4 # 3 macro.c 2 int main(void) { const double area (5 * 5) * 3.14; const # 8 macro.c 3 4 _Bool # 8 macro.c isAreaGT100 area 100.0; return 0; }可以看到在这段文本中所有之前在 C 源代码中以 “#” 开头的预处理指令都已经被移除宏常量 PI 和宏函数 SQUARE 已经完成了展开和替换头文件 “stdbool.h” 中的内容也已经被插入到了源文件中该文件内为宏 bool 的定义这里已被替换为了 _Bool。除此之外一些带有辅助功能的信息也以 linemarker 的形式插入到了该文件中供后续编译阶段使用。因为这些信息的组成形式和作用跟编译器的具体实现密切相关这里我就不深入解读了。定义宏函数时的常用技巧预处理器在进行宏展开和宏替换时只会对源代码进行简单的文本替换。在某些情况下这可能会导致宏函数所表达的计算逻辑与替换后 C 代码的实际计算逻辑产生很大差异。因此在编写宏函数时我们要特别注意函数展开后的逻辑是否正确避免由 C 运算符优先级等因素导致的一系列问题。接下来就让我们一起看下当在 C 代码中使用预处理器时有哪些 tips 可以帮助我们避免这些问题。技巧一为宏函数的返回值添加括号对于大多数刚刚接触 C 预处理器的同学来说我们可能会在不经意间写出类似下面这样的代码#include stdio.h #define FOO(x) 1 x * x int main(void) { printf(%d, 3 * FOO(2)); return 0; }这里我们定义了一个名为 FOO 的宏函数该函数接收一个参数 x并返回这个参数在经过表达式 1 x * x 计算后的结果值。在 main 函数中我们以数值 2 作为参数调用了该宏函数并通过 printf 函数打印了数字 3 与宏函数调用结果值的乘积。按照我们对函数的理解这里的宏函数在被“调用”后会首先返回表达式的计算结果值 51 2 * 2。随后该结果值会再次参与到函数外部的乘积运算中并得到最终的打印结果值 15。但事实真是如此吗实际上宏函数的展开与 C 函数的调用过程并不相同。经过编译器的预处理后上述代码中对第四行 printf 语句的调用过程会被变更为如下形式printf(%d, 3 * 1 2 * 2);可以看到这里的宏函数 FOO 在被展开时并不会优先对其内部的表达式进行求值相反只是简单地对传入的参数进行了替换。因此当编译器按照展开后的结果进行计算时由于表达式中乘法运算符 “*” 的优先级较高进而导致整个表达式的计算顺序发生了改变所以计算结果也就出现了偏差。那如果我们为宏函数的整个返回表达式都加上括号结果会怎样呢显而易见此时表达式 (1 2 * 2) 会被优先求值。 3 * FOO(2) 的计算结果符合我们的预期。技巧二为宏函数的参数添加括号用上面的技巧我们已经对宏函数 FOO 进行了优化以保证在某些情况下“返回的”表达式能够被当作一个整体进行使用。但这样就万无一失了吗其实类似的问题还可能会出现在宏函数的参数上比如下面这个例子#include stdio.h #define FOO(x) (1 x * x) int main(void) { printf(%d, FOO(1 2)); return 0; }在这里我们改变了宏函数 FOO 的使用方式直接将表达式 1 2 作为参数传递给了它。同样地由于编译器在处理宏函数时仅会进行实参在各自位置上的文本替换传入函数的表达式并不会在函数展开前进行求值。因此经过编译器的预处理后上述代码中第四行对 printf 语句的调用过程会被变更为如下形式printf(%d, (1 1 2 * 1 2));由于乘法运算符 “*” 的存在此时整个表达式的求值顺序发生了改变。本该被优先求值的子表达式 1 2 并没有被提前计算。很明显这并不是我们在设计 FOO 函数时所期望的。而通过为宏函数定义中的每个参数都加上括号我们便可以解决这个问题。技巧三警惕宏函数导致的多次副作用那么现在的宏函数 FOO 还会有问题吗让我们继续来看这个例子#include stdio.h #define FOO(x) (1 (x) * (x)) int main(void) { int i 1; printf(%d, FOO(i)); return 0; }在上面这段代码中我们“装配”了经过两次优化升级的宏函数 FOO。只是相较于前两种使用方式这里我们在调用该函数时传入了基于自增运算符的表达式 i 。按照常规的函数调用方式变量 i 的值会首先进行自增由 1 变为 2。然后该结果值会作为传入参数参与到宏函数中表达式 1 (x) * (x) 的计算过程由此可以得到计算结果 5。但现实往往出人意料同之前的例子类似传入宏函数的表达式实际上并不会在函数被“调用”时提前求值。因此上述 C 代码中的第五行语句在宏函数实际展开时会变成如下形式printf(%d, (1 (i) * (i)));到这里我想你已经知道了问题所在经过宏替换的 C 代码导致多个自增运算符被同时应用在了表达式中而该运算符对变量 i 的副作用产生了多次。因此在使用宏函数时需要注意宏函数的“调用”与 C 函数的调用是完全不同的两种方式。前者不会产生任何栈帧而只是对源代码文本进行简单的字符替换。所以对于替换后产生的新代码其计算逻辑可能已经发生了变化而这可能会引起程序计算结果错误或副作用产生多次等问题。技巧四定义完备的多语句宏函数到这里对于定义简单宏函数时可能遇到的一系列问题相信你都能处理了。但当宏函数逐渐变得复杂函数体内不再只有一条语句时新的问题又出现了。通常情况下为了与 C 代码的风格保持一致在调用宏函数时我们也会习惯性地为每一个调用语句的末尾加上分号。但也正是因为这样当含有多行语句的宏函数与某些控制语句一起配合使用时可能会出现意想不到的结果。比如下面这个例子#include stdio.h #define SAY() printf(Hello, ); printf(world!) int main(void) { int input; scanf(%d, input); if (input 0) SAY(); return 0; }当我们习惯使用 C 代码的执行思维来看待宏函数时上述代码的执行情况应该是这样的程序接收用户输入的字符并将其转换为数字值。若该值大于 0则宏函数内的两条 printf 语句被执行并输出字符串 “Hello, world!”。否则程序直接退出。但现实的情况却是无论用户输入何值字符串 “world!” 都会被打印。而问题就出现在宏函数 SAY 被展开和替换后原本“封装”在一起的两条 printf 语句被拆分开来。其中的第一条语句成为了 if 条件控制语句的执行内容而第二条语句由于没有大括号的包裹则直接被“释放”到了 main 函数中成为了该函数返回前最后一条会被执行的语句。那么应该怎样解决这个问题呢既然我们的目的是让编译器在处理程序代码时能够将宏函数内的所有语句当作一个整体进行处理那么有没有一种合法的 C 语言结构它可以作为一种复合语句使用其内部的语句只会被执行一次并且在语法上它还需要以分号结尾很巧迭代语句 do…while 便可以满足这个要求。如下面这段代码所示我们使用该语句改写了宏函数 SAY 的实现方式。#include stdio.h #define SAY() \ do { printf(Hello, ); printf(world!); } while(0) int main(void) { int input; scanf(%d, input); if (input 0) SAY(); return 0; }可以看到通过将 while 关键字中的参数设置为 0我们可以保证整个迭代语句仅会被执行一次。而 do…while 语句“天生”需要以分号结尾的性质也正好满足了宏函数替换后的 C 语法格式要求。并且对于 while(0) 这种特殊的迭代形式大多数编译器也会通过相应的优化去掉不必要的循环控制结构以降低对程序运行时性能的影响。何时使用预处理器讲了这么多预处理器的使用技巧最后还是要提醒你预处理器是一把“双刃剑”。对它的合理使用可以让我们的程序具备更强的动态扩展能力相反如果任意乱用就会导致程序源代码的可读性大大降低甚至引入难以调试的 BUG。通常在以下三个场景中你可以视情况选择是否使用预处理器定义程序中使用到的魔数。这里提到的魔数主要是指那些用于控制程序运行状态、具有特定功能意义的参数。这些参数可以使用预处理器以宏的形式定义并在程序编译前内联到源代码中使用基于特定运行环境的条件编译。我们可以通过特定的宏比如编译器预定义宏、C 语言内置宏等来检测当前编译环境的状态并以此调整程序需要启用的特定功能封装代码模板。我们可以通过宏的形式封装需要重复出现的代码片段并将它们应用在循环展开等场景中。当然真实的使用场景并不局限于这三类。但还是要强调下在使用预处理器时保持谨慎小心是必要的。总结好了讲到这里今天的内容也就基本结束了。最后我来给你总结一下。今天我主要介绍了与 C 预处理器有关的一些内容包括相关背景知识、编译器处理方式以及使用技巧等。通过 C 预处理器我们可以让编译器在真正开始处理 C 语法代码前先对源码进行一系列必要的转换。这些转换可以让我们引入代码正常编译所需要的各类依赖项动态修改代码以适应不同的编译环境甚至根据需要自动生成部分 C 代码。对预处理器的合理使用可以让我们的程序具备一定的“弹性伸缩”能力并使程序的可配置性大大增强。另一方面宏函数的运作方式又与 C 函数有着巨大的区别。其中前者在“调用”时仅会做源代码文本上的匹配与替换。因此在处理宏函数参数以及宏函数体内的多行语句时需要通过添加括号、使用 do…while 语句等技巧来对参数、返回值与函数体进行“封装”。最后需要注意预处理器的合适使用场景。在 C 语言中预处理器通常可以被应用在程序魔数定义、代码条件编译以及代码模版封装等场景中。只有谨慎、合理地使用我们才能够享受到 C 预处理器带来的巨大价值。