C语言的预处理介绍
前言
编译一个C语言程序涉及很多步骤。其中第一个步骤被称为预处理。C语言的预处理器在源代码编译之前对其进行一些文本性质的操作。它的主要任务包括删除注释、插入被#include指令包含的文件内容、定义和替换由#define指令定义的符号,同时确定代码的部分内容是否应该根据一些条件编译指令进行编译。
一、预定义符号
下表为C语言预处理器定义的符号。他们的值有的是字符串常量,有的是十进制数字常量。
符号 | 示例值 | 含义 |
---|---|---|
__ FILE__ | “test.c” | 当前编译的源文件名 |
__ LINE__ | 25 | 本文件当前行号 |
__ DATE__ | “Dec 27 2021” | 文件被编译日期 |
__ TIME__ | “21:30:23” | 文件被编译时间 |
__ STDC__ | 1 | 如果编译器遵循ANSI C,其值就为1,否则未定义 |
二、#define
我们先来看一下它的用法
#define name stuff
有了这条指令以后,每当有符号name出现在这条指令之后时,预处理器就会把它替换为stuff。
如果定义中的stuff非常长,那就可以将它分成几行,除了最后一行,每行的末尾都要加一个反斜杠,如下面例子所示:
#define DEBUG_PRINT printf("File %s line %d:" \ "x = %d, y = %d, z = %d, \ __FILE__, __LINE__, x, y, z)
这里利用了一个特性:相邻的字符串常量被自动连接为一个字符串。在调试一个存在许多涉及一组变量的不同计算过程的程序时,这种类型的声明非常有用。我们可以很容易的插入一条测试语句,打印出它们的当前值。
x *= 2; y += x; z = x * y; DEBUG_PRINT;
1.宏
#define机制包括一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或者定义宏(define macro)。下面是宏的声明方式:
#define name(parameter-list) stuff
其中,parameter-list(参数列表)是一个由逗号分隔的符号列表,它们可能出现在stuff 中。参数列表的左括号必须与name紧邻。否则,参数列表就会被解释为stuf的一部分。
当宏被调用时,名字后面是一个由逗号分隔的值的列表,每个值都与宏定义中的一个参数相对应。当参数出现在程序中时,与每个参数对应的实际值都将会被替换到stuff中。例如:
#define SQUARE(x) x*x SQUARE(5)
当这两条语句位于程序中时,预处理器就会用上面的表达式替换下面的表达式,就会变成:5 * 5。
但是上面这个宏存在一个问题,请大家观察下面的代码:
a = 5; printf("%d\n", SQUARE(a + 1));
可能我们直观的觉得这段代码将打印36这个值。但是事实上,它会打印11。Why?来,我们按照宏的规则做一个替换,这条语句将变成:
printf("%d\n", a + 1 * a + 1);
发现问题了吗,这里由替换而产生的表达式并没有按照预想的次序进行求值。所以,我们要对宏定义的参数加上括号,包括stuff整体。这样就能产生我们预期的结果了。
在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
1.在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果存在,它们会首先被替换掉。
2.替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被它们的值所替代。
3.最后,才气对结果文本进行扫描,看看它是否包含了任何由#define定义的符号。如果是的话,就重复上述处理过程。
2.宏与函数
宏非常频繁的用于执行简单的计算,比如在两个表达式中寻找其中较大的一个(或较小):
#define MAX(a, b) ((a) > (b) ? (a) : (b))
这个功能好像我们也能用函数来实现,那为什么不使用函数呢?有两个原因。首先,用于调用和从函数返回的代码很可能比实际执行这个小型工作的代码更大,所以使用宏要比使用函数在程序中的规模和速度都更胜一筹。
其次,更为重要的是,函数的参数必须声明为一种特定的类型,所以它只能在类型合适的表达式中使用。但是宏是与类型无关的。
有优点就会有缺点,和使用函数相比,使用宏的不利之处在于每次使用宏时,一份宏定义代码的副本都将会插入到程序中。除非宏非常短,否则使用宏可能会大幅度增加程序的长度。
还有一些任务无法用函数来实现,比如下面这个代码:
#define MALLOC(n, type) ((type*) malloc ((n) * sizeof(type)))
type是一个数据类型,而函数是无法将类型作为参数传递的。
3.带副作用的宏参数
当宏参数在宏定义中出现的次数吵过一次时,如果这个参数具有副作用,那么在使用这个宏时就可能出现危险,导致不可预料的后果。副作用就是在表达式求值时出现永久性的后果。如下:
x + 1;
这个表达式无论执行几百次都是一样的,所以它没有副作用。
x++;
但是这个表达式就不同了,每次执行都会改变x的值,每一次执行都是一个不同的结果。所以,这个表达式是具有副作用的。我们看下面的例子,你觉得它会打印出什么:
#define MAX(a, b) ((a) > (b) ? (a) : (b)) x = 5; y = 8; z = MAX(x++, y++); printf("x =%d, y = %d, z = %d\n", x, y, z);
其结果是: x = 6, y = 10, z = 9。产生这个结果的原因是那个较小的值只增加了一次,而那个较大的值却增加了两次——第一次是在比较的时候,第二次是在执行?后面的表达式时。这就是一个具有副作用的宏参数,我们在使用的时候一定要注意。
4. 宏和函数的不同
通过一个表格来分析:
属性 | #define宏 | 函数 |
---|---|---|
代码长度 | 每次使用时,宏代码都被插入到程序中。除了非常小的宏,程序的长度将大幅增长 | 函数代码只出现在一个地方,每次使用函数时,都调用那个地方的同一份代码 |
执行速度 | 更快 | 存在函数调用和返回的额外开销 |
操作符优先级 | 宏参数的求值是在所有周围表达式的上下文环境里,除非它们加上括号,否则临近操作符的优先级可能会发生改变 | 函数参数指在函数调用时求值一次,其结果传递给函数,求值结果更容易预测 |
参数求值 | 参数每次用于宏定义时,它们都将重新求值。由于多次求值,具有副作用的参数可能会产生不可预料的后果 | 参数在函数被调用前只求值一次,在函数中多次使用参数并不会导致多个求职过程。参数的副作用并不会造成任何特殊的问题 |
参数类型 | 宏与类型无关。只要对参数的操作是合法的,它可以使用任何类型的参数 | 函数的参数是与类型有关的,如果参数的类型不同,就需要使用不同的函数,即使它们执行的任务是相同的 |
5.#undef
#undef这个预处理指令用于移除一个宏定义:
#undef name
如果现存的名字需要被重新定义,那么首先必须用#undef移除它的旧定义。
三、条件编译
在编译一个程序时,如果可以翻译或忽略选定的某条语句或某组语句,会给我们带来极大的便利。而条件编译(conditional compilation)可以实现这个目的。使用条件编译,可以选择代码的一部分是被正常编译还是完全忽略。用于支持条件编译的基本结构是#if指令和#endif指令。一下是其使用方式:
#if constant-expression statements #endif
当常量表达式(constant-expression)的值是非零,那么statements部分就被正常编译,否则预处理器就静默的删除它们。
同时,条件编译的另一个用途是在编译时选择不同的代码部分。所以#if指令还具有可选的#elif和#else子句。如下:
#if constant-expression statements #elif constant-expression other statements #else other statements #endif
#elif子句出现的次数可以不限。但是每个常量表达式(constant-expression)只有当前面所有常量表达式的值都为假时才会被编译。#else子句中的语句只有当前面所有常量表达式的值都为假时才会被编译。
四、文件包含
#include指令使另一个文件的内容被编译,就好像它实际出现在#include指令出现的位置一样。这种替换执行的方式很简单:预处理器删除这条指令,并用包含文件的内容取而代之。如果一个头文件被包含到十个源文件中,那它实际上被编译了十次。
1.函数库文件包含
C语言编译器支持两种不同类型的#include文件包含:函数库文件和本地文件。事实上,他们之间的区别很小。
函数库文件的包含使用以下语法:
#include<filename>
对于filename(文件名),并没有任何的限制,不过根据规定,标准库文件以一个.h后缀结尾。
编译器通过观察由编译器定义的“一系列标准位置”查找函数库头文件。你所使用的编译器文档会说明这些标准的位置是什么,以及怎样修改它们或者在列表中添加其他位置。
2.本地文件包含
以下是#include指令的另外一种形式:
#include"filename"
标准允许编译器自行决定是否把本地形式的#include和函数库形式的#include区别对待。可以先对本地头文件使用一种特殊的处理方式,如果失败,编译器再按照函数库头文件的处理方式对待它们进行处理。
处理本地头文件的一种常见策略就是在源文件所在的当前目录进行查找,如果该头文件并未找到,编译器就像查找函数库头文件一样在标准位置查找本地头文件。
总结
不要在一个宏定义的末尾加上分号,使其成为一条完整语句。在宏定义中使用参数,不要忘了在其周围加上括号。同时不要忘了在宏定义两边加上括号.