预处理器

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 文件):

VS 查看预处理后的文件方法

链接:https://blog.csdn.net/weixin_33708432/article/details/85824803

我们写一个程序:

test.c

// Converts a Fahrenheit temperature to Celsius

#include<stdio.h>

#define FREEZING_PT 32.0f

#define SCALE_FACTOR (5.0f / 9.0f)

int main(void) {

float fahrenheit, celsius;

printf("Enter Fahrenheit temperature: ");
scanf("%f", &fahrenheit);

celsius = (fahrenheit - FREEZING_PT) * SCALE_FACTOR;

printf("Celsius equivalent is %.1f\n", celsius);

return 0;
}

打开生成的 .i 文件(我的在 Debug 目录中),拉到结尾,看到下面的代码:

test.i文件部分代码:

空行
空行
从 stdio 中引入的行
空行
空行
空行
空行
空行
int main(void) {

float fahrenheit, celsius;

printf("Enter Fahrenheit temperature: ");
scanf("%f", &fahrenheit);

celsius = (fahrenheit - 32.0f) * (5.0f / 9.0f); // 宏已经被替换

printf("Celsius equivalent is %.1f\n", celsius);

return 0;
}

我们可以发现,预处理器做了这些事情:

  • 预处理器通过引入 stdio.h 的内容来响应 #define 指令,然后删除该指令
  • 替换了该文件中稍后出现在任何位置上的 FREEZING_PT 和 SCALE_FACTOR
  • 请注意预处理器并没有删除包含指令的行,而是简单地将它们替换为空
  • 每一处注释都替换为一个空格字符
  • 有一些预处理器还会删除不必要的空白字符,包括每一行开始用于缩进的空格符和制表符

在 C 语言较为早期的时期,预处理器是一个单独的程序,它的输出提供给编译器。如今,预处理器通常和编译器集成在一起。

二 预处理指令

  • 宏定义:#define指令为一个宏。#undef指令删除一个宏定义。
  • 文件包含: #include指令导致一个指定文件的内容被包含到程序中。
  • 条件编译: #if#ifdef#ifndef#elif#else#endif指令可以根据预处理器可以测试的条件来确定是将一段文本块包含到程序中还是将其排除在程序之外。
  • 剩下的:#error#line#pragma指令是更特殊的指令,较少用到。

其中,文件包含指令会放到下一章节中介绍。

适用于所有指令的规则:

  • 指令都以#开始

  • 在指令的各部分之间可以插入任意数量的空格或水平制表符

    #			define			 A			 1 
  • 指令总在第一个换行符处结束,除非明确地指明要延续

    #define ADD (A + \
    B)
  • 指令可以出现在程序中的任何地方 但我们通常放在程序的开始

  • 注释可以和指令放在同一行 事实上,这样做是个好习惯:

    #define FREEZING_PT 32.0f // freezing point of water

三 宏定义

1. 简单的宏

简单的宏(C 标准中称为对象式宏)

#define 标识符 替换列表

替换列表可以包含标识符,关键字,数值常量,字符常量,字符串字面量,操作符和排列。

在宏后面的程序内容中,预处理器会用替换列表替换标识符

注意:

不要在宏定义中放置任何额外的符号,否则它们会被作为替换列表的一部分。

宏定义中使用 =

#define N = 100 // wrong
int a[N]; // becomes int a[= 100];

结尾使用分号;

#define N 100; // wrong 
int a[N] // becomes 100;

编译器可以检测到宏定义中绝大多数由多余符号所导致的错误。但是,编译器只会讲每一个使用这个宏的地方标为错误,而不会直接找到错误的根源——宏定义本身,因为宏定义已经被预处理器删除了。

简单的宏主要用来定义那些被 K,R 称为“明示常量”(manifest constant)的东西。比如:

#define STR_LEN 80
#define TRUE 1
#define PI 3.14159
#define CR '\r'
#define EOS '\0'
#define MEM_ERR "Error: not enough money"

使用#define来为常量命名由许多显著的优点:

  • 程序会更加易读 帮助读者理解常量的含义,减少“魔法数”。

  • 程序会易于修改

  • 可以避免前后不一致或键盘输入错误

  • 对 C 语法做小的修改 比如:

    #define BEGIN {
    #define END }
    #define LOOP for(;;)

    当然这样的做法可能会让别人难以阅读你的程序。

  • 对类型重命名

    #define BOOL int

    但是要知道,类型定义仍然是定义新类型的最佳方法。

  • 控制条件编译

注意:

  • 宏定义中的替换列表为空是合法的

    #define DEBUG
  • 当宏作为常量使用时,C 程序员习惯在名字中只使用大写字母

2. 带参数的宏

带参数的宏(也称为函数式宏

#define 标识符(x1, x2,...,xn) 替换列表

比如:

#define MAX(x, y) ((x) > (y) ? (x) : (y))
#define IS_EVEN(n) ((n) % 2 == 0)

如果程序中有如下语句:

max = MAX(a, b);
if(IS_EVEN(i))
i++;

预处理器会将这些行替换为:

max = ((a) > (b) ? (a) : (b));
if(((i) % 2 == 0))
i++;

如这个例子所示,带参数的宏经常用来作为简单的函数使用

ctype.h 头文件中的 toupper 的一种实现:

#define TOUPPER(c) ('a' <= (c) && (c) <= 'z' ? (c) - 'a' + 'A' : (c))

带参数的宏也可以包含空的参数列表:

#define getchar() getc(stdin)

使用带参数的宏替代函数有两个优点

  • 程序可能稍微快一些
  • 宏更为通用 与函数不同,宏的参数没有类型。

但是带参的宏也有一些缺点

  • 编译后的代码通常会变大

    比如用 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 可能被赋予错误的值。

    所以说,最好避免使用自增自减的参数

宏定义还可用于需要重复书写的代码段模式:

#define PRINT_INT(i) printf("%d\n", i)
PRINT_INT(i / j); // becomes printf("%d", i / j);

3. 宏的通用属性

  • 宏的替换列表可以包含对其他宏的调用

    #define PI 3.1415926
    #define TWO_PI (2 * PI)
  • 预处理器只会替换完整的记号,而不会替换记号的片段

    #define SIZE 256
    int BUFFER_SIZE;
    if(BUFFER_SIZE > SIZE)
    puts("Error: SIZE exceeded");

    预处理后:

    #define SIZE 256
    int BUFFER_SIZE;
    if(BUFFER_SIZE > 256)
    puts("Error: SIZE exceeded");

    标识符 BUFFER_SIZE 和字符串字面量中的 SIZE 不会被替换

  • 宏定义的作用范围通常到出现这个宏的文件末尾 由于宏是预处理器处理的,他不遵从通常的作用域规则。

  • 宏不可以被定义两遍,除非新的定义与旧的定义是一样的

  • 宏可以使用#undef指令“取消定义”

    #undef 标识符

    比如:

    #undef N

    会删除宏 N 当前的定义。(如果 N 没有被定义成为一个宏,#undef 指令没有任何作用。)#undef 指令的一个用途是取消宏的现有定义,以便重新给出新的定义。

4. # 运算符

#运算符将一个宏的一个参数转换为字符串字面量。它仅允许出现在带参数的宏的替换列表中。

# 运算符有多种用途,这里只讨论一种。

#define PRINT_INT(n) printf("%d\n", n)

我们讲这个宏改写为:

#define PRINT_INT(n) printf(#n " = %d\n", n)

如果我们调用:

PRINT_INT(i / j);

会变为:

printf("i / j" " = %d\n", i / j);

我们知道,C 语言相邻的字符串字面量会被合并。因此上面的语句等价于:

printf("i / j = %d\n", i / j);

下面是完整的程序演示:

#include<stdio.h>

#define PRINT_INT(n) printf(#n " = %d\n", n)

int main(void) {

PRINT_INT(3 / 4);

return 0;
}

输出:

3 / 4 = 0

5. ##运算符

## 运算符可以将两个记号(如标识符)“粘合”在一起,称为一个记号。

比如:

#define MK_ID(n) i##n

int MK_ID(1), MK_ID(2), MK_ID(3);

预处理后:

int i1, i2, i3;

##运算符并不属于预处理器最经常使用的特性。为了找到一种使用它的情况,我们思考之前我们定义的 MAX 宏。当 MAX 的参数中含有自增自减运算时无法正常工作。一种解决方法是写一个函数实现 MAX 的功能,遗憾的是,仅仅一个函数是不够的,我们可能需要实参是 int 类型的函数,也可能是 double,char 等等。这些函数的功能相似,按照参数类型再定义一个函数似乎比较蠢。

为了解决这个问题,我们用 ## 运算符为每一个版本的 max 函数构造不同的函数名以及参数类型。下面是宏的形式:

#define GENERIC_MAX(type) 			\
type type##_max(type x, type y){ \
return x > y ? x : y; \
} \

如果我们需要定义一个针对 float 类型的 max 函数:

GENERIC_MAX(float)

预处理后:

float float_max(float x, float y){	
return x > y ? x : y;
}

下面是一段完整的程序:

#include<stdio.h>

#define GENERIC_MAX(type) \
type type##_max(type x, type y){ \
return x > y ? x : y; \
}

GENERIC_MAX(double)
GENERIC_MAX(int)

int main(void) {

printf("%f\n", double_max(1.2, 3.6));
printf("%d\n", int_max(1, 3));

return 0;
}

6. 宏定义中的圆括号

对于一个宏定义中哪里要加圆括号有两条规则要遵守:

  • 如果宏的替换列表中有运算符,那么要将替换列表放在圆括号中

    #define TWO_PI (2 * PI)
  • 如果宏有参数,每个参数每次在替换列表中出现时都要放在圆括号中

    #define SCALE ((x) * 10)

没有括号的话,编译器可能会不按我们预期的方式应用运算符的优先级和结合性。比如:

#define TWO_PI 2 * PI
#define PI 3.14159

conversion_factor = 360 / TWO_PI;

变为:

conversion_factor = 360 / 2 * 3.14159;

除法会在乘法之前执行(我们希望的是:360 / (2 * 3.14159))。

#define SCALE (x * 10)

j = SCALE(i + 1);

变为:

j = (i + 1 * 10)

加法会在乘法后执行(我们希望的是:(i + 1) * 10)。

7. 创建较长的宏

在创建较长的宏时,逗号运算符会十分有用。比如,下面的宏会读入一个字符串,然后再把它显示出来:

#define ECHO(s) (gets(s), puts(s))

比如:

int main(void) {

char str[100];

ECHO(str); // becomes (gets(s), puts(s))

return 0;
}

如果不想在 ECHO 的定义中使用逗号运算符,我们可以将函数调用放在花括号中形成复合语句:

#define ECHO(s) {gets(s); puts(s);}

需要注意的是,如果你这样写 if 语句:

if (echo_flag)
ECHO(str);
else
printf("Not echo\n");

替换后:

if (echo_flag)
{gets(s); puts(s);};
else
printf("Not echo\n");

编译器会将跟在后面的分号作为空语句,并对 else 子句产生出错消息,因为它不属于 if 语句。所以,正确的写法如下:

int main(void) {

char str[100];
int echo_flag = 1;

if (echo_flag)
ECHO(str)
else
printf("Not echo\n");

return 0;
}

但是这样做会使程序看起来有些怪异。

如果一个宏需要包含一系列的语句,而不仅仅是一系列表达式,这时逗号运算符就不起作用了,因为它只能连接表达式,不能连接语句。解决的办法很简单:

#define ECHO(s)			\
do{ \
gets(s); \
puts(s); \
}while(0) // 将条件设置为假(语句只会执行一次)

使用 ECHO 宏时,需要加上分号使 do 语句完整:

ECHO(str);

8. 预定义宏

名字 描述
__LINE__ 被编译的文件中的行号
__FILE__ 被编译的文件名
__DATE__ 编译的日期
__TIME__ 编译的时间
__STDC__ 如果编译器复合 C 标准,值为 1

比如:

int main(void) {
printf("%d\n%s\n%s\n%s\n", __LINE__, __FILE__, __DATE__, __TIME__);
return 0;
}

输出:

58
D:\vscode\C必知必会\预处理开始\预处理\1.c
Apr 15 2020
20:09:36

我们可以使用 __LINE____FILE__来找到错误,比如:

#define CHECK_ZERO(divisor)		\
if(divisor == 0) \
printf("*** Attempt to divide by zero on line %d " \
"of file %s ***\n", __LINE__, __FILE__)

CHECK_ZERO(j);
k = i / j;

如果 j 是 0,程序会显示如下形式的信息:

*** Attempt to divide by zero on line 70 of file D:\vscode\C必知必会\预处理开始\预处 理\1.c *** 

类似这样的错误检测的宏非常有用。实际上,C 语言库提供了一个通用的,用于错误检测的宏 —— assert 宏

C99 中新增的__func__ 标识符。它与预处理器无关。但是,与许多预处理特性一样,它也有助于调试。比如:

#define FUNCTION_CALLED() printf("%s called\n", __func__)
#define FUNCTION_RETURNED() printf("%s returned\n", __func__)

void f() {
FUNCTION_CALLED();

FUNCTION_RETURNED();
}

int main(void) {

f();

return 0;
}

__func__ 的另一个用法:作为参数传给函数,让它知道调用它的函数名。

四 条件编译

1. #if指令和 #endif指令

#if 常量表达式

#endif

当预处理器遇到 #if指令时,会计算常量表达式的值。如果表达式的值为 0 ,那么 #if 和 #endif 之间的行将在预处理过程中从程序中删除;否则,#if 和 #endif 之间的行会被保留,继续留给编译处理——#if 和 #endif 会在预处理中被删除。

#define ISPRINT 1

int main(void) {

int i = 1;

#if ISPRINT
printf("%d\n", i);
#endif

return 0;
}

// 程序输出:
1

如果将宏 ISPRINT 定义为 0 ,程序执行结果不会输出 1

#if指令会把没有定义过的标识符当作是值为 0 的宏对待,所以如果省略 ISPRINT 的定义,测试:

#if ISPRINT

会失败,而测试:

#if !ISPRINT

会成功

2. defined运算符

当 defined 引用于标识符时,如果标识符是一个定义过的宏则返回 1,否则返回 0 。 defined 运算符通常与 #if 指令结合使用。

可以写作:

#if define(ISPRINT)
...
#endif

仅当 ISPRINT 被定义为宏时,保留中间的代码。也可以去掉 宏 两边的括号:

#if define ISPRINT
...
#endif

由于 defined 运算符仅检测 ISPRINT 是否有定义,所以不需要给 ISPRINT 赋值:

#define ISPRINT

3. #ifdef指令和#ifndef指令

#ifdef指令测试一个标识符是否已经定义为宏:

#ifdef 标识符
...
#endif

其实这和

#if defined 标识符
...
#endif

是等价的

#ifndef指令测试的是标识符是否没有被定义为宏:

#ifndef 标识符
...
#endif

等价于:

#if !defined 标识符

4. #elif指令和#else指令

#elif#else可以和 #if#ifdef#ifndef结合使用:

#if 表达式1
当表达式10时需要包含的代码
#elif 表达式2
当表达式10而表达式20时需要包含的代码
#else
其他情况下需要包含的代码
#endif

其实 #elif 像 else if ,整体结构和层级式 if 语句十分相似

5. 使用条件编译

  • 编写在多台机器或多种操作系统之间可移植的程序 比如:

    #if defined(WIN32)
    ...
    #elif defined(MAC_OS)
    ...
    #elif defined(LINUX)
    ...
    #endif

    定义 LINUX 宏可以指明程序将运行在 linux 操作系统下。

  • 编写可以用不同的编译器编辑的程序 比如:

    #if __STDC__
    函数原型
    #else
    老式的函数声明
    #endif
  • 为宏提供默认定义

    检测一个宏当前是否已经被定义了,如果没有提供一个默认定义。例如:

    #ifndef BUFFER_SIZE
    #define BUFFER_SIZE 256
    #endif
  • 临时屏蔽包含注释的代码 我们不能用 /* ... */直接注释掉已经包含/* ... */的代码。然而,我们可以用 #if 指令来实现:

    #if 0
    包含注释的代码行
    #endif

将代码以这种方式屏蔽掉经常称为“条件屏蔽”。

下一节我们会讨论条件编译的另一个用途:保护头文件以避免重复包含

五 其他指令

1. #error指令

#error 消息

比如:

#if INT_MAX < 100000
#error int type is to small
#endif

如果在一台 16 位存储整数的机器上运行这个程序,将会产生一条出错提示:

Error directive: int type is to small

但是 VS 2019 编译器会直接提示错误:

#error:int type is to small

#error 通常出现在 #if-#elif-#else 序列中的 #else 部分:

#if defined(WIN32)
...
#elif defined(MAC_OS)
...
#elif defined(LINUX)
...
#else
#error No operating system specified
#endif

2. #line指令

只指定行号:

#line n

这条指令导致程序中后续的行被编号为:n, n + 1, n + 2, …

指定行号和文件名:

#line n ”文件“

指令后面的行会被认为来自文件,行号由 n 开始。n 和 文件字符串 可以用宏指定。

#line的一种作用是改变 __LINE__宏(可能还有__FILE__宏);更重要的是,大多数编译器会使用来自#line指令的信息生成出错消息。例如,假设下列指令出现在文件 foo.c 的开头:

#line 10 "bar.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语言程序设计:现代方法》