11 | 预处理命令(下):必须掌握的“黑魔法”,让编译器帮你写代码

上次呢,我们知道了,原来程序的编译,是一个复杂的过程,其中重要的是三个阶段:预处理阶段,编译阶段和链接阶段 。

同时,我们也搞清楚了“源代码”和“待编译源码”两个概念的区别,其中“待编译源码”是由“源代码”经过预处理阶段所产生的代码,并且“待编译源码”才是决定程序最终功能的终版代码。

今天呢,我们继续上节课的知识,来具体学习几个重要的,能够影响“待编译源码”内容的预处理命令吧。

本次任务

在正式开始今天课程之前,我们先来回顾一下任务内容:实现一个使用方法和 printf 函数一样的,但是输出信息却比 printf 更加人性化的,更加具体的 log 方法。

具体代码及事例,参考如下:

#include <stdio.h>

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 内容,这里需要你特别注意的是,一个宏定义只能占一行代码,这可不是你所认为的一行代码,而是编译器所认为的一行代码,这里在后面,我们会详细来介绍一下。

这里先给你准备了一张示意图,用来说明宏定义的基本用法:

图1:宏定义语法结构示意图

正如上图所示,宏定义以 #define 作为语句的开头,之后两部分,用空格分隔,在预处理阶段期间,会把代码中的 A 内容替换成 B 内容,以此来最终生成“待编译源码”。

下面我们就使用宏来实现一个读入圆的半径,输出圆面积的程序: 

#include <stdio.h>
#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 这种简单符号,还有一种更加灵活实用的带参数的形式,如图所示:

图2:傻瓜表达式结构示意图

可以看到,我们定义了一个支持两个参数的宏,名字为 mul,替换的内容为 a * b。注意,替换内容中的 a 是宏参数中的 a,b 也是宏参数中的 b。这里我再强调一下,理解宏的工作过程,始终离不开那句话:宏做的就是简单替换

下面举个例子:

#include <stdio.h>
#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 <stdio.h>
#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 条件分支语句。对,条件编译,就是预处理阶段的条件分支语句,其主要作用是根据条件,决定“源代码”中的哪些代码,接下来会被预处理继续进行处理。

我们先来从最容易理解的条件编译开始看起,来了解一下条件编译的语法格式:

图3:条件编译的语法结构示意图

如图所示,这个条件编译以指令 #ifdef 作为开头,后面接了一个 Debug。意思是如果定义了 Debug 这个宏,就让预处理器继续处理“代码内容 1”,否则就处理“代码内容 2”。记住,条件编译,可以没有 #else 部分,可最后一定要以 #endif 作为结束。

下面举个简单的例子: 

#include <stdio.h>
#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 个指令各自的作用:

一起动手,搞事情

思考题:没有 Bug 的 MAX 宏

请你完善下面代码中的 MAX 宏,MAX 宏的作用,就是接受两个元素,选择出两个元素中的最大值。完善以后的 MAX 宏,输出需要与如下给出的输出样例一致,注意,只能修改 MAX 宏的定义内容,不可以修改主函数中的内容。 

#include <stdio.h>
#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 方法的输出内容是一致的,还无法输出所在函数以及所在代码位置的相关信息。

下面,我们来补充最后一个知识点,就是编译器会预设一些宏,这些宏会为我们提供很多与代码相关的有用信息,具体如下表所示:

我们看到表中有两个宏,是我们这个任务所需要的,一个是 __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. 编译器预设的宏,有标准的,也有非标准的,非标准的代码会影响其可移植性。

至此,我们就完成了“语言基础篇”的全部内容,从下一节开始呢,我们将进入注重培养编程思维“编码能力训练篇”的学习。届时,我们的学习更偏重于思维方式的训练和讲解,不会像语言基础篇一样,有这么多零零碎碎的知识点。我也相信,只要你勤于思考,就一定跟得上学习节奏。



本文介绍了预处理命令中的宏定义和条件编译两种重要概念。宏定义通过简单替换实现代码优化,但在复杂替换内容时可读性较差,文章介绍了在行尾加反斜杠的语法以提高可读性。同时,还提到了`__typeof`方法的作用。另外,文章详细介绍了条件编译的语法格式和指令,以及如何定义支持可变参数的log宏。最后,总结了宏定义的基本用法和条件编译的作用,强调了编译器预设的宏对代码可移植性的影响。整体而言,本文通过实例和图示生动地介绍了预处理命令的基本概念和技术特点,为读者提供了深入理解预处理命令的知识。 

  • 17
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值