15-预处理器
预处理器
Every program has (at least) two purposes: the one for which it was written and another for which it wasn’t.[^1]
目录
[TOC]
预处理器
零 前言
前面我们用到的 #define
和#include
指令都是由预处理器处理的。预处理器是一个小软件,它可以在编译前处理 C 程序。C 语言(和 C++ 语言)因为依赖预处理器而不同于其他的编程语言。
预处理器是一个强大的工具,但它同时也可能是许多难以发现的错误的根源。尽管有些 C 程序员十分依赖于于预处理器,我依然建议适度使用,就像对待生活中的许多其他事物一样。
一 预处理器的工作原理
预处理器的行为是由预处理指令(由 #
字符开头的一些命令)控制的。
如图说明了预处理器在编译过程中的作用。
为了展示预处理器的作用,我们写一个 c 程序(.c 文件),我们来看一下预处理后的文件(. i 文件):
链接:https://blog.csdn.net/weixin_33708432/article/details/85824803
我们写一个程序:
test.c
// Converts a Fahrenheit temperature to Celsius |
打开生成的 .i 文件(我的在 Debug 目录中),拉到结尾,看到下面的代码:
test.i文件部分代码:
空行 |
我们可以发现,预处理器做了这些事情:
- 预处理器通过引入 stdio.h 的内容来响应 #define 指令,然后删除该指令
- 替换了该文件中稍后出现在任何位置上的 FREEZING_PT 和 SCALE_FACTOR
- 请注意预处理器并没有删除包含指令的行,而是简单地将它们替换为空
- 每一处注释都替换为一个空格字符
- 有一些预处理器还会删除不必要的空白字符,包括每一行开始用于缩进的空格符和制表符
在 C 语言较为早期的时期,预处理器是一个单独的程序,它的输出提供给编译器。如今,预处理器通常和编译器集成在一起。
二 预处理指令
- 宏定义:
#define
指令为一个宏。#undef
指令删除一个宏定义。 - 文件包含:
#include
指令导致一个指定文件的内容被包含到程序中。 - 条件编译:
#if
,#ifdef
,#ifndef
,#elif
,#else
和#endif
指令可以根据预处理器可以测试的条件来确定是将一段文本块包含到程序中还是将其排除在程序之外。 - 剩下的:
#error
,#line
,#pragma
指令是更特殊的指令,较少用到。
其中,文件包含指令会放到下一章节中介绍。
适用于所有指令的规则:
-
指令都以
#
开始 -
在指令的各部分之间可以插入任意数量的空格或水平制表符
-
指令总在第一个换行符处结束,除非明确地指明要延续
-
指令可以出现在程序中的任何地方 但我们通常放在程序的开始
-
注释可以和指令放在同一行 事实上,这样做是个好习惯:
三 宏定义
1. 简单的宏
简单的宏(C 标准中称为对象式宏)
替换列表可以包含标识符,关键字,数值常量,字符常量,字符串字面量,操作符和排列。
在宏后面的程序内容中,预处理器会用替换列表替换标识符
注意:
不要在宏定义中放置任何额外的符号,否则它们会被作为替换列表的一部分。
宏定义中使用 =
|
结尾使用分号;
|
编译器可以检测到宏定义中绝大多数由多余符号所导致的错误。但是,编译器只会讲每一个使用这个宏的地方标为错误,而不会直接找到错误的根源——宏定义本身,因为宏定义已经被预处理器删除了。
简单的宏主要用来定义那些被 K,R 称为“明示常量”(manifest constant)的东西。比如:
使用#define
来为常量命名由许多显著的优点:
-
程序会更加易读 帮助读者理解常量的含义,减少“魔法数”。
-
程序会易于修改
-
可以避免前后不一致或键盘输入错误
-
对 C 语法做小的修改 比如:
当然这样的做法可能会让别人难以阅读你的程序。
-
对类型重命名
但是要知道,类型定义仍然是定义新类型的最佳方法。
-
控制条件编译
注意:
-
宏定义中的替换列表为空是合法的
-
当宏作为常量使用时,C 程序员习惯在名字中只使用大写字母。
2. 带参数的宏
带参数的宏(也称为函数式宏)
比如:
如果程序中有如下语句:
max = MAX(a, b); |
预处理器会将这些行替换为:
max = ((a) > (b) ? (a) : (b)); |
如这个例子所示,带参数的宏经常用来作为简单的函数使用。
ctype.h 头文件中的 toupper 的一种实现:
带参数的宏也可以包含空的参数列表:
使用带参数的宏替代函数有两个优点:
- 程序可能稍微快一些
- 宏更为通用 与函数不同,宏的参数没有类型。
但是带参的宏也有一些缺点:
-
编译后的代码通常会变大
比如用 MAX 宏来找出三个数中的最大值:
max = MAX(i, MAX(j, k));
下面是预处理后的语句:
max = ((i) > (((j) > (k) ? (j) : (k))) ? (i) : (((j) > (k) ? (j) : (k))))
-
宏参数没有类型检查 预处理器不会检查参数类型,也不会进行类型转换。
-
无法用指针指向宏 C 语言允许指针指向函数。因为宏在预处理过程中被删除,所以不存在指向宏的指针。
-
宏可能不止一次地计算它的参数。 函数对它的参数只会计算一次,宏可能会计算多次。
max = MAX(i++, j);
预处理后:
max = ((i++) > (j) ? (i++) : (j));
如果 i 大于 j ,那么 i 可能会被(错误的)增加两次,同时 n 可能被赋予错误的值。
所以说,最好避免使用自增自减的参数
宏定义还可用于需要重复书写的代码段模式:
|
3. 宏的通用属性
-
宏的替换列表可以包含对其他宏的调用
-
预处理器只会替换完整的记号,而不会替换记号的片段
int BUFFER_SIZE;
if(BUFFER_SIZE > SIZE)
puts("Error: SIZE exceeded");预处理后:
int BUFFER_SIZE;
if(BUFFER_SIZE > 256)
puts("Error: SIZE exceeded");标识符
BUFFER_SIZE
和字符串字面量中的 SIZE 不会被替换 -
宏定义的作用范围通常到出现这个宏的文件末尾 由于宏是预处理器处理的,他不遵从通常的作用域规则。
-
宏不可以被定义两遍,除非新的定义与旧的定义是一样的
-
宏可以使用
#undef
指令“取消定义”比如:
会删除宏 N 当前的定义。(如果 N 没有被定义成为一个宏,#undef 指令没有任何作用。)#undef 指令的一个用途是取消宏的现有定义,以便重新给出新的定义。
4. #
运算符
#
运算符将一个宏的一个参数转换为字符串字面量。它仅允许出现在带参数的宏的替换列表中。
#
运算符有多种用途,这里只讨论一种。
我们讲这个宏改写为:
如果我们调用:
PRINT_INT(i / j); |
会变为:
printf("i / j" " = %d\n", i / j); |
我们知道,C 语言相邻的字符串字面量会被合并。因此上面的语句等价于:
printf("i / j = %d\n", i / j); |
下面是完整的程序演示:
|
输出:
3 / 4 = 0 |
5. ##
运算符
##
运算符可以将两个记号(如标识符)“粘合”在一起,称为一个记号。
比如:
|
预处理后:
int i1, i2, i3; |
##
运算符并不属于预处理器最经常使用的特性。为了找到一种使用它的情况,我们思考之前我们定义的 MAX 宏。当 MAX 的参数中含有自增自减运算时无法正常工作。一种解决方法是写一个函数实现 MAX 的功能,遗憾的是,仅仅一个函数是不够的,我们可能需要实参是 int 类型的函数,也可能是 double,char 等等。这些函数的功能相似,按照参数类型再定义一个函数似乎比较蠢。
为了解决这个问题,我们用 ##
运算符为每一个版本的 max 函数构造不同的函数名以及参数类型。下面是宏的形式:
如果我们需要定义一个针对 float 类型的 max 函数:
GENERIC_MAX(float) |
预处理后:
float float_max(float x, float y){ |
下面是一段完整的程序:
|
6. 宏定义中的圆括号
对于一个宏定义中哪里要加圆括号有两条规则要遵守:
-
如果宏的替换列表中有运算符,那么要将替换列表放在圆括号中
-
如果宏有参数,每个参数每次在替换列表中出现时都要放在圆括号中
没有括号的话,编译器可能会不按我们预期的方式应用运算符的优先级和结合性。比如:
|
变为:
conversion_factor = 360 / 2 * 3.14159; |
除法会在乘法之前执行(我们希望的是:360 / (2 * 3.14159)
)。
|
变为:
j = (i + 1 * 10) |
加法会在乘法后执行(我们希望的是:(i + 1) * 10
)。
7. 创建较长的宏
在创建较长的宏时,逗号运算符会十分有用。比如,下面的宏会读入一个字符串,然后再把它显示出来:
比如:
int main(void) { |
如果不想在 ECHO 的定义中使用逗号运算符,我们可以将函数调用放在花括号中形成复合语句:
需要注意的是,如果你这样写 if 语句:
if (echo_flag) |
替换后:
if (echo_flag) |
编译器会将跟在后面的分号作为空语句,并对 else 子句产生出错消息,因为它不属于 if 语句。所以,正确的写法如下:
int main(void) { |
但是这样做会使程序看起来有些怪异。
如果一个宏需要包含一系列的语句,而不仅仅是一系列表达式,这时逗号运算符就不起作用了,因为它只能连接表达式,不能连接语句。解决的办法很简单:
使用 ECHO 宏时,需要加上分号使 do 语句完整:
ECHO(str); |
8. 预定义宏
名字 | 描述 |
---|---|
__LINE__ |
被编译的文件中的行号 |
__FILE__ |
被编译的文件名 |
__DATE__ |
编译的日期 |
__TIME__ |
编译的时间 |
__STDC__ |
如果编译器复合 C 标准,值为 1 |
比如:
int main(void) { |
输出:
58 |
我们可以使用 __LINE__
和 __FILE__
来找到错误,比如:
|
如果 j 是 0,程序会显示如下形式的信息:
*** Attempt to divide by zero on line 70 of file D:\vscode\C必知必会\预处理开始\预处 理\1.c *** |
类似这样的错误检测的宏非常有用。实际上,C 语言库提供了一个通用的,用于错误检测的宏 —— assert 宏
C99 中新增的__func__
标识符。它与预处理器无关。但是,与许多预处理特性一样,它也有助于调试。比如:
|
__func__
的另一个用法:作为参数传给函数,让它知道调用它的函数名。
四 条件编译
1. #if
指令和 #endif
指令
当预处理器遇到
#if
指令时,会计算常量表达式的值。如果表达式的值为 0 ,那么 #if 和 #endif 之间的行将在预处理过程中从程序中删除;否则,#if 和 #endif 之间的行会被保留,继续留给编译处理——#if 和 #endif 会在预处理中被删除。
|
如果将宏 ISPRINT 定义为 0 ,程序执行结果不会输出 1
#if
指令会把没有定义过的标识符当作是值为 0 的宏对待,所以如果省略 ISPRINT 的定义,测试:
会失败,而测试:
会成功
2. defined
运算符
当 defined 引用于标识符时,如果标识符是一个定义过的宏则返回 1,否则返回 0 。 defined 运算符通常与 #if 指令结合使用。
可以写作:
|
仅当 ISPRINT 被定义为宏时,保留中间的代码。也可以去掉 宏 两边的括号:
|
由于 defined 运算符仅检测 ISPRINT 是否有定义,所以不需要给 ISPRINT 赋值:
3. #ifdef
指令和#ifndef
指令
#ifdef
指令测试一个标识符是否已经定义为宏:
|
其实这和
|
是等价的
#ifndef
指令测试的是标识符是否没有被定义为宏:
|
等价于:
4. #elif
指令和#else
指令
#elif
和 #else
可以和 #if
,#ifdef
,#ifndef
结合使用:
|
其实 #elif 像 else if ,整体结构和层级式 if 语句十分相似
5. 使用条件编译
-
编写在多台机器或多种操作系统之间可移植的程序 比如:
...
...
...定义 LINUX 宏可以指明程序将运行在 linux 操作系统下。
-
编写可以用不同的编译器编辑的程序 比如:
函数原型
老式的函数声明 -
为宏提供默认定义
检测一个宏当前是否已经被定义了,如果没有提供一个默认定义。例如:
-
临时屏蔽包含注释的代码 我们不能用
/* ... */
直接注释掉已经包含/* ... */
的代码。然而,我们可以用 #if 指令来实现:
包含注释的代码行
将代码以这种方式屏蔽掉经常称为“条件屏蔽”。
下一节我们会讨论条件编译的另一个用途:保护头文件以避免重复包含
五 其他指令
1. #error
指令
比如:
如果在一台 16 位存储整数的机器上运行这个程序,将会产生一条出错提示:
Error directive: int type is to small |
但是 VS 2019 编译器会直接提示错误:
#error
通常出现在 #if-#elif-#else
序列中的 #else
部分:
|
2. #line
指令
只指定行号:
这条指令导致程序中后续的行被编号为:n, n + 1, n + 2, …
指定行号和文件名:
指令后面的行会被认为来自文件,行号由 n 开始。n 和 文件字符串 可以用宏指定。
#line
的一种作用是改变 __LINE__
宏(可能还有__FILE__
宏);更重要的是,大多数编译器会使用来自#line
指令的信息生成出错消息。例如,假设下列指令出现在文件 foo.c 的开头:
现在,假设编译器在 foo.c 的第五行发现了一个错误。出错消息会指向 bar.c 的第 13 行,而不是 foo.c 的第五行。(为什么是第 13 行?因为 foo.c 指令占据了一行,因此对 foo.c 的编号从第 2 行开始,并将这一行看作是 bar.c 的第 10 行。)
应用:yacc(bison)
3. #pragma
指令
略
4. _pragma
运算符(C99)
略
[^1]: 程序都有至少两个目的:一个是写它的目的,另一个不是。Epigrams on Programming 编程警句
参考资料:《C语言程序设计:现代方法》