You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

241 lines
16 KiB
Markdown

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 09编译准备预处理器是怎样处理程序代码的
你好,我是于航。
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 程序编译流程中的第一环。在这一步中,编译器会对源代码进行分析,并通过查找以 “#” 字符开头的代码行,来确定预处理器指令的所在位置。接下来,通过下面这些步骤,编译器可以完成对代码的预处理工作:
1. 删除源代码中的所有注释;
2. 处理所有宏定义(#define并进行展开和替换
3. 处理所有条件预编译指令(如 #if、#elif仅保留符合条件的代码
4. 处理文件包含预编译指令(#include将被包含文件的内容插入到该指令的所在位置
5. 处理其他可以识别的预处理指令(如 #pragma
6. 添加其他具有辅助性功能的注释信息。
为了进一步观察编译器在预处理阶段对 C 代码的处理过程,这里我们可以进行一个简单的实验:将下面这段代码保存在文件 “macro.c” 中,并通过命令 “gcc -O0 -Wall -E ./macro.c -o macro.l” 对它进行编译。
```c++
#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” 的文件。而在这个文件中,便包含有源代码在经过预处理阶段后得到的中间代码结果。其中的内容如下所示(注意,不同的编译器和版本生成的文件内容可能有所不同):
```c++
# 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 的形式插入到了该文件中,供后续编译阶段使用。
因为这些信息的组成形式和作用跟编译器的具体实现密切相关,这里我就不深入解读了。如果你对 GCC 如何使用 linemarker 感兴趣,可以点击[这个链接](https://gcc.gnu.org/onlinedocs/gcc-9.1.0/cpp/Preprocessor-Output.html#Preprocessor-Output)获得更多信息。
## 定义宏函数时的常用技巧
预处理器在进行宏展开和宏替换时,只会对源代码进行简单的文本替换。在某些情况下,这可能会导致**宏函数所表达的计算逻辑与替换后 C 代码的实际计算逻辑产生很大差异**。因此,在编写宏函数时,我们要特别注意函数展开后的逻辑是否正确,避免由 C 运算符优先级等因素导致的一系列问题。
接下来,就让我们一起看下:当在 C 代码中使用预处理器时,有哪些 tips 可以帮助我们避免这些问题。
### 技巧一:为宏函数的返回值添加括号
对于大多数刚刚接触 C 预处理器的同学来说,我们可能会在不经意间,写出类似下面这样的代码:
```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` 语句的调用过程会被变更为如下形式:
```c++
printf("%d", 3 * 1 + 2 * 2);
```
可以看到,这里的宏函数 FOO 在被展开时,并不会优先对其内部的表达式进行求值,相反,只是简单地对传入的参数进行了替换。因此,当编译器按照展开后的结果进行计算时,由于表达式中乘法运算符 “\*” 的优先级较高,进而导致整个表达式的计算顺序发生了改变,所以,计算结果也就出现了偏差。那如果我们为宏函数的整个返回表达式都加上括号,结果会怎样呢?显而易见,此时表达式 `(1 + 2 * 2)` 会被优先求值。 `3 * FOO(2)` 的计算结果符合我们的预期。
### 技巧二:为宏函数的参数添加括号
用上面的技巧,我们已经对宏函数 FOO 进行了优化,以保证在某些情况下,“返回的”表达式能够被当作一个整体进行使用。但这样就万无一失了吗?其实,类似的问题还可能会出现在宏函数的参数上,比如下面这个例子:
```c++
#include <stdio.h>
#define FOO(x) (1 + x * x)
int main(void) {
  printf("%d", FOO(1 + 2));
  return 0;
}
```
在这里,我们改变了宏函数 FOO 的使用方式,直接将表达式 `1 + 2` 作为参数传递给了它。同样地,由于编译器在处理宏函数时,仅会进行实参在各自位置上的文本替换,传入函数的表达式并不会在函数展开前进行求值。因此,经过编译器的预处理后,上述代码中第四行对 `printf` 语句的调用过程会被变更为如下形式:
```c++
printf("%d", (1 + 1 + 2 * 1 + 2));
```
由于乘法运算符 “\*” 的存在,此时整个表达式的求值顺序发生了改变。本该被优先求值的子表达式 `1 + 2` 并没有被提前计算。很明显,这并不是我们在设计 FOO 函数时所期望的。而通过为宏函数定义中的每个参数都加上括号,我们便可以解决这个问题。
### 技巧三:警惕宏函数导致的多次副作用
那么,现在的宏函数 FOO 还会有问题吗?让我们继续来看这个例子:
```c++
#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 代码中的第五行语句在宏函数实际展开时,会变成如下形式:
```c++
printf("%d", (1 + (++i) * (++i)));
```
到这里,我想你已经知道了问题所在:经过宏替换的 C 代码导致多个自增运算符被同时应用在了表达式中,而该运算符对变量 `i` 的副作用产生了多次。
因此,在使用宏函数时需要注意,宏函数的“调用”与 C 函数的调用是完全不同的两种方式。前者不会产生任何栈帧,而只是对源代码文本进行简单的字符替换。所以,对于替换后产生的新代码,其计算逻辑可能已经发生了变化,而这可能会引起程序计算结果错误,或副作用产生多次等问题。
### 技巧四:定义完备的多语句宏函数
到这里,对于定义简单宏函数时可能遇到的一系列问题,相信你都能处理了。但当宏函数逐渐变得复杂,函数体内不再只有一条语句时,新的问题又出现了。
通常情况下,为了与 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 的实现方式。
```c++
#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 预处理器带来的巨大价值。
## 思考题
你知道 C 预处理运算符 “#” 与 “##” 的用法吗?欢迎在评论区跟我讨论。
今天的课程到这里就结束了,希望可以帮助到你,也希望你在下方的留言区和我一起讨论。同时,欢迎你把这节课分享给你的朋友或同事,我们一起交流。