# 11 | 预处理命令(下):必须掌握的“黑魔法”,让编译器帮你写代码 你好,我是胡光,欢迎回来。最近为了防范疫情,很多人应该都窝在家里吧?春节假期除了娱乐放松,也不要忘记学习提高呀! 上次呢,我们知道了,原来程序的编译,是一个复杂的过程,其中重要的是三个阶段:**预处理阶段**,**编译阶段**和**链接阶段** 。 同时,我们也搞清楚了“源代码”和“待编译源码”两个概念的区别,其中“待编译源码”是由“源代码”经过预处理阶段所产生的代码,并且“待编译源码”才是决定程序最终功能的终版代码。 今天呢,我们继续上节课的知识,来具体学习几个重要的,能够影响“待编译源码”内容的预处理命令吧。 ## 本次任务 在正式开始今天课程之前,我们先来回顾一下任务内容:实现一个使用方法和 printf 函数一样的,但是输出信息却比 printf 更加人性化的,更加具体的 log 方法。 具体代码及事例,参考如下: ``` #include void func(int a) { log("a = %d\n", a); } int main() { int a = 123; printf("a = %d\n", a); log("a = %d\n", a); func(a); return 0; } ``` ``` a = 123 [main, 10] a = 123 [func, 4] a = 123 ``` 通过文稿代码可以看到,经过log方法后,我们获得了更多程序信息。但我们的任务是设计完 log 方法以后,请再给这个 log 方法提供一个小开关,使其很方便的打开或者关闭程序中所有 log 的输出信息。 回顾完了任务以后,就让我们一起来进行具体的预处理命令的学习吧。 ## 必知必会,查缺补漏 在上一节,我们明确了 include 文件包含预处理命令的作用。今天,我们将来着重讲解两种预处理命令**宏定义**与**条件编译**。它们是什么意思呢?不要着急,听我一个个给你解释。 #### 1\. 初识宏定义 宏定义在预处理阶段的作用,就是做简单的替换,将 A 内容,替换成 B 内容,这里需要你特别注意的是,一个宏定义只能占一行代码,这可不是你所认为的一行代码,而是编译器所认为的一行代码,这里在后面,我们会详细来介绍一下。 这里先给你准备了一张示意图,用来说明宏定义的基本用法: ![](https://static001.geekbang.org/resource/image/93/c8/939a45667cbb4daca048c2e052b371c8.jpg "图1:宏定义语法结构示意图") 正如上图所示,宏定义以 #define 作为语句的开头,之后两部分,用空格分隔,在预处理阶段期间,会把代码中的 A 内容替换成 B 内容,以此来最终生成“待编译源码”。 下面我们就使用宏来实现一个读入圆的半径,输出圆面积的程序: ``` #include #define PI 3.1415926 int main() { double r; scanf("%lf", &r); printf("%lf\n", PI * r * r); return 0; } ``` 在上面程序中,我们定义了一个名字为 PI 的宏,其替换内容为3.1415926,也就是圆周率π的相似值。在主函数中,我们读入一个圆的半径值,存储在 r 变量中,然后输出圆的面积,在计算圆面积公式的时候,我们没有使用圆周率本来值来进行程序书写,而是使用刚刚上面定义的宏 PI 代替了圆周率的作用。 面对这份源代码,在预处理阶段的时候,编译器会把代码中所有使用 PI 的地方,都替换成3.1415926,也就是说,上述代码中的输出函数中,原本的 PI \* r \* r 的代码内容,会被编译器改写成为 3.1415926 \* r \* r 作为“待编译源码”。 通过这个例子,我想你就能差不多明白了,什么叫做“宏定义在预处理阶段做的就是简单的替换”以及“宏定义在代码中,只能占一行”,简单来说,就是**宏定义关键字**、**原内容和替换内容** **三者必须写到一行**。 #### 2.宏定义之傻瓜表达式 前面呢,我们说的是宏定义的最基本用法。其实,宏定义中的“原内容”的形式,不仅仅有刚才的类似于 PI 这种简单符号,还有一种更加灵活实用的带参数的形式,如图所示: ![](https://static001.geekbang.org/resource/image/63/70/632274dbc1188cf9dddf607647871270.jpg "图2:傻瓜表达式结构示意图") 可以看到,我们定义了一个支持两个参数的宏,名字为 mul,替换的内容为 a \* b。注意,替换内容中的 a 是宏参数中的 a,b 也是宏参数中的 b。这里我再强调一下,理解宏的工作过程,始终离不开那句话:**宏做的就是简单替换**。 下面给你举个例子: ``` #include #define mul(a, b) a * b int main() { printf("mul(3, 5) = %d\n", mul(3, 5)); printf("mul(3 + 4, 5) = %d\n", mul(3 + 4, 5)); return 0; } ``` 上面代码中,使用了 mul 宏,分别输出了 mul(3, 5) 的值,和 mul(3 + 4, 5) 的值。如果你把 mul 当成函数看待的话,你应该会觉得,第一行输出的值应该是 15,即 3 \* 5 结果;第二行应该是 35,计算的应该是 7 \* 5 的结果。 可如果你在你的环境中运行这个代码,你会看到第一行输出的结果确实是 15,和我们的预期一样,可第二行输出的却是 23,这个离我们预想的可就有点儿不一样了。 想要理解为什么输出的是 23,而不是 35 的话,我们需要综合以下两点来进行思考: * “待编译源码”决定了最终程序的功能。 * 宏做的就是简单的替换。 宏在预处理阶段将被展开,变成“待编译源码”中的内容,并且做的仅仅是简单的替换。也就是说,mul(a, b) 这个宏,替换的形式是 a \* b;而 mul(3 + 4, 5) 中 3 + 4 是参数 a 的内容,5 是 b 的内容,依次替换为 a\*b 式中的 a,b 的话,最终得到的替换内容应该是 “3 + 4 \* 5”,这个才是“待编译源码”中真正的内容。面对这个替换以后的表达式,你就知道为什么输出的结果是 23,而不是 35 了吧。 所以,正如你所看到的,**mul 的使用形式虽然和函数类似,可实际运行原理和函数完全不一样**,甚至显得有些机械化。因为 mul 是宏,而宏做的就是简单的替换操作,变成最终的“待编译源码”中的内容。这个过程机械且简单,所以,我们有时也称其为**傻瓜表达式**。 再回来看上面的 mul 宏,使用形式像函数,但函数可以在代码中写成多行的一段代码。可宏呢,只能写成一行,就会使得当我们面对稍微复杂一点的替换内容,宏代码的可读性就会变得特别差。 还好,C 语言给我们提供了一种在行尾加 \\(反斜杠)的语法,以此来告诉编译器,本行和下一行其实是同一行内容。这样就做到了:人在阅读代码的时候,看到的是两行代码,而编译器在解析的时候,会认为是一行代码,也就解决了复杂的宏定义的可读性的问题。 具体事例,看如下代码: ``` #include #define swap(a, b) { \ __typeof(a) __temp = a; \ a = b, b = __temp; \ } int main() { int num_a = 123, num_b = 456; swap(num_a, num_b); printf("num_a = %d\n", num_a); printf("num_b = %d\n", num_b); return 0; } ``` 如上代码中,我们定义了一个用于交换两个变量值的宏 swap,代码的第 2、3、4 行的末尾都有一个反斜杠,编译器就会认为把程序的这几行内容当成一行内容来对待。这样,既保证了宏定义的只占用一行的语法要求,又兼顾了代码可读性。 需要特别注意的是,**代码中反斜杠的后面,不能出现任何其他内容**。作为新手的话,这里是最容易出错的,很多人会在反斜杠后面多打一个空格,会导致反斜杠失去原本的作用,代码查错的时候,也不容易被发现,这里一定要十分小心。 此外,你看到上述代码中,多了一个`__typeof`方法,关于这个方法的作用呢,给你留个小的作业题,请你自行查阅相关资料,并用一句话描述 `__typeof` 的作用。欢迎在专栏的留言区里面写下你认为足够简洁的 `__typeof`的功能描述。 #### 3\. 初识条件编译 看完了宏定义之后,下面来让我们看看另一个使用的比较频繁的预处理命令:条件编译。说到条件编译,光看名字,你也许会联想到 if 条件分支语句。对,条件编译,就是预处理阶段的条件分支语句,其主要作用是根据条件,决定“源代码”中的哪些代码,接下来会被预处理继续进行处理。 我们先来从最容易理解的条件编译开始看起,来了解一下条件编译的语法格式: ![](https://static001.geekbang.org/resource/image/9b/4a/9bac2450536bc8dfaa3569a8986de94a.jpg "图3:条件编译的语法结构示意图") 如图所示,这个条件编译以指令 #ifdef 作为开头,后面接了一个 Debug。意思是如果定义了Debug 这个宏,就让预处理器继续处理“代码内容1”,否则就处理“代码内容2”。记住,条件编译,可以没有 #else 部分,可最后一定要以 #endif 作为结束。 下面给你举个简单的例子: ``` #include #define Debug #ifdef Debug #define MAX_N 1000 #else #define MAX_N 5000 #endif int main() { printf("MAX_N = %d\n", MAX_N); return 0; } ``` 如果你运行上面这段代码,你的程序一定会输出 MAX\_N = 1000,那是因为当代码运行到条件编译的时候,由于之前定义了 Debug 宏,条件编译的条件成立,保留的是第 4 行代码内容,所以主函数中的 MAX\_N 宏最终就会被替换成为 1000。 如果你将第2行代码去掉的话,那么条件编译的条件就不成立了,最终被保留下来的是第 6 行代码,程序就会输出 MAX\_N = 5000,关于这点,你可以自行尝试一下。 其实在条件编译中,除了我们刚才讲到的三个指令:#ifdef、#else、#endif 之外,还有 #if、#ifndef 以及 #elif 等指令。关于剩下的三个指令的含义和作用,有了这个基础之后,你就可以很轻松的学会了,我就不再赘述了。 我在这里给你准备了一张对照表,以说明这 6 个指令各自的作用: ![](https://static001.geekbang.org/resource/image/64/0b/64ca97df0c65c079a62919dc362b0f0b.jpg) ## 一起动手,搞事情 #### 思考题:没有 Bug 的 MAX 宏 > 请你完善下面代码中的 MAX 宏,MAX 宏的作用,就是接受两个元素,选择出两个元素中的最大值。完善以后的 MAX 宏,输出需要与如下给出的输出样例一致,注意,只能修改 MAX 宏的定义内容,不可以修改主函数中的内容。 ``` #include #define P(item) printf("%s = %d\n", #item, item); #define MAX(a, b) // TODO int main() { int a = 6; P(MAX(2, 3)); P(5 + MAX(2, 3)); P(MAX(2, MAX(3, 4))); P(MAX(2, 3 > 4 ? 3 : 4)); P(MAX(a++, 5)); P(a); return 0; } ``` 输出结果参考: ``` MAX(2, 3) = 3 5 + MAX(2, 3) = 8 MAX(2, MAX(3, 4)) = 4 MAX(2, 3 > 4 ? 3 : 4) = 4 MAX(a++, 5) = 6 a = 7 ``` ## 输出漂亮的日志格式 准备完了上面的这些基础知识以后,下面来让我们回到最开始的那个任务。 首先我们来思考,要实现一个和 printf 使用方式一样的 log 方法, printf 函数是一个变参函数,那么 log 也需要支持变参,而 log 方法又比 printf 输出的更人性化一些,其中包括了可以输出所在的函数信息,以及所在的代码位置信息。这里,我们选择使用宏定义来实现所谓的 log 方法。 下面,就给你再补充一个小知识点,就是如何定义一个支持可变参数的 log 宏,看如下代码: ``` #define log(frm, args...) // 假装这里有内容,后续展开讲解 ``` 如上代码所示,在最后一个参数后面,加上三个点,就代表,这个宏除了第一个 frm 参数以外,后面接收的参数个数是可变的,那么后面的参数内容,统一存放在参数 args 中。 这样,我们就可以设计如下代码,使得 log 方法的使用方式与 printf 类似了: ``` #define log(frm, args...) printf(frm, args) ``` 此时,log 方法的输出内容,只是和 printf 方法的输出内容是一致的,还无法输出所在函数以及所在代码位置的相关信息。 下面,我们来补充最后一个知识点,就是编译器会预设一些宏,这些宏会为我们提供很多与代码相关的有用信息,具体如下表所示: ![](https://static001.geekbang.org/resource/image/b4/04/b4c736f3cbb0638d3d9171944841d904.jpg) 我们看到表中有两个宏,是我们这个任务所需要的,一个是 `__func__`代表了当前所在的函数名,另一个是`__LINE__`代表了当前行号。 其中宏`__func__`后面的说明中,注明了是“非标准”,什么叫做非标准呢,也就是说,在不同的编译器中,这个宏的名称可能是不同的,甚至某些编译器不提供这个宏,也是有可能的。例如在 VC 6.0 的环境中就没有`__func__`宏,因为这个宏不是 C 语言标准里面的东西。 通过这个`__func__`宏,我想让你初步认识到什么是代码的 **“可移植性”**,也就是说,你写了一份代码,当你的运行环境发生改变时,你的代码到底要不要做修改?如果要做修改,到底要做多少修改?这是代码的可移植性所讨论的问题。 放到今天这个例子中,就是说,如果你在你的代码中,不做任何处理的,直接使用`__func__`宏,那么就会影响你代码的可移植性。如果还不清楚什么是代码的可移植性,你就回想一下,当初我们输出彩色文字的那个代码,是不是在有些人的环境中,无法输出彩色文字? 最后,有了这些基础知识以后,就不难完成这个任务了,下面是我给出的 log 宏的参考代码: ``` #define log(frm, args...) { printf("[%s : %d] ",__func__,__LINE__); \ printf(frm, args); \ } ``` 正如你看到的,log 宏的定义中,使用了编写多行宏的技巧,就是在行尾添加反斜杠,以达到增强代码可读性的目的。然后 log 宏中,包含两个 printf 输出语句,第一个 printf 语句,输出函数以及代码位置信息;第二个 printf语句,输出 log 宏所接收的内容。 至此,我们看似完成了最初的任务,可不要高兴太早,所有与宏相关的东西,都没那么简单。上面的这个实现,其实是有 Bug,不信的话,你就在你的环境中,尝试像如下代码一样调用 log 宏: ``` log("hello world\n"); ``` 这个就是今天给你留的最后一个需要自己独立解决的小 Bug,记住,勤用及善用搜索引擎,会大大提升你的学习效率和效果。至于如何方便的开关日志输出,参考今天的条件编译,思考一下,我相信这个难不倒你。 ## 课程小结 通过这个任务呢,我们大体的认识了预处理命令家族,算是全方位地了解了宏及条件编译相关的内容。下面呢,我来给你做一下今天这节课的课程小结: 1. 宏定义只占用一行代码,为了增强宏定义的代码可读性,我们可以采用在行尾加反斜杠的技巧,来使得上下两行代码,变成编译器眼中的一行代码。 2. 宏的作用,就是替换,要想理解最终的代码行为,必须从宏替换以后的代码入手分析。 3. 条件编译相当于一种预处理阶段的代码剪裁技巧。 4. 编译器预设的宏,有标准的,也有非标准的,非标准的代码会影响其可移植性。 至此,我们就完成了“语言基础篇”的全部内容,从下一节开始呢,我们将进入注重培养编程思维“编码能力训练篇”的学习。届时,我们的学习更偏重于思维方式的训练和讲解,不会像语言基础篇一样,有这么多零零碎碎的知识点。我也相信,只要你勤于思考,就一定跟得上学习节奏。 好了,今天就到这里了,我是胡光,我们“编码能力训练篇”,不见不散。