22. 预处理
一、什么是预处理命令
在编译程序之前对源文件进行简单的加工的过程就称为 预处理。预处理主要是处理以 #
开头的命令。预处理 是 C 语言的一个重大的功能,由预处理程序完成。当对一个源文件进行编译时,系统将自动调用预处理程序对源程序中的预处理部分做处理,处理完毕自动进入对源程序的编译。
C 语言提供了多种预处理功能,如 宏定义、文件包含、条件编译 等,合理地使用预处理会使编写的程序便于阅读、修改、移植 和 调试,也有利于模块化程序设计。
二、宏定义
使用 #define
命令就是要定义一个可替换的宏。宏定义是预处理命令的一种,它提供了一种可以替换源代码中字符串的机制。根据宏定义中是否有参数,可以将宏定义分为不带参数的宏定义和带参数的宏定义两种。
2.1、不带参数的宏定义
宏定义指令 #define
用来定义一个标识符和一个字符串,以这个标识符来代表这个字符串,在程序中每次遇到标识符时就会用所定义的字符串替换它。宏定义的作用相当于给指定的字符串起一个别名。
宏定义不带参数的一般格式如下:
#define 宏名 字符串
#
:表示这个一条预处理命令;宏名
:是一个标识符,必须符合 C语言 标识符的规定;字符串
:字符串中可以含任何字符,可以是常数、表达式、格式化字符串、函数等,预处理程序对它不做任何检查,如有错误,只能在编译已经被展开后的源程序时发现;
在预处理阶段,对程序中所有出现 “宏名” 的位置,预处理器都会用宏定义中的字符串去代换,这称为 “宏替换” 或 “宏展开”。宏定义是由源程序中的宏定义命令 #define
完成的,宏替换是由预处理程序完成的。
#include <stdio.h>
#define PI 3.14
int main(void)
{
double radius = 0;
double perimeter = 0;
double area = 0;
printf("请输入圆的半径:");
scanf("%lf",&radius);
perimeter = 2 * PI * radius;
printf("圆的周长为:%.2f\n",perimeter);
area = PI * radius * radius;
printf("圆的面积为:%.2f\n",area);
return 0;
}
宏定义是用宏名来表示一个字符串,在宏展开时又以该字符串取代宏名,这只是一个简单的替换;
宏定义不是 C 语句,不需要在行末加分号,如果加上分号则连分号也一起替换;
宏名定义允许嵌套,在宏定义的字符串中可以使用已经定义的宏名,在宏展开的是由预处理程序层层嵌套;
如果在字符串中含有宏名,则不进行替换;
宏定义用于预处理命令,它不同于定义的变量,它只做字符替换,不分配内存空间;
2.2、带参数的宏定义
带参数的宏定义不只是简单的字符串替换,还要进行参数的替换。在 #define 中使用参数可以创建外形和作用与函数类似的 类函数宏。类函数宏定义的圆括号中可以有一个或多个参数,随后这些参数出现在替换体中,其一般形式如下:
#define 宏名(参数) 字符串
宏定义时,字符串内的形参要加括号,如果不加括号,其结果可能出错。并且在宏扩展时,必须使用括号来保护表达式中低优先级的操作符,以确保调用时能够达到想要的效果。这是因为在对带参数的宏展开,只是将语句中的宏名后面内的实际参数字符串代替 #define
命令行中的形式参数,预处理器不做计算、不求值,只替换字符序列。
在宏定义时,形参之间可以出现空格,宏名与参数的括号之间不可以加空格,否则会将空格以后的字符都作为替代字符串的一部分。在带参数的宏定义中,形式参数不会分配内存单元,因此不必指定数据类型,而在宏调用,实参包含了具体的数据,要用它们去替换形参,因此实参必须要指明数据类型;
#include <stdio.h>
#define MAX(a,b) ((a)>(b)?(a):(b))
int main(void)
{
int num1 = 10,num2 = 20;
printf("num1: %d, num2 = %d\n",num1,num2);
printf("the max number is: %d\n",MAX(num1,num2));
return 0;
}
一般而言,不要在宏中使用递增或递减运算符。
2.3、宏定义替换规则
在程序中扩展 #define 定义符号和宏时,需要涉及以下几个步骤:
- 在替换宏时,首先对参数进行检查,看看是否包含任何由 #define 定义的符号。如果是,它们首先被替换;
- 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被它们的值所替换;
- 最后,再次对结果进行扫描,看看它们是否包含任何由 #define 定义的符号。如果是,就重复上述处理过程
宏参数和 #define 定义中可以出现其它 #define 定义的符号。但是对于宏,不能出现递归;
当预处理器搜索 #define 定义的符号的时候,字符串常量的内容并不会被搜索;
2.4、用宏参数创建字符串
C 允许字符串中包含宏参数。在类函数宏定义的替换体中,# 作为一个预处理运算符,可以把记号转换为字符串。例如,如果 x 是一个宏形参,那么 #x 就是转换为字符串 "x" 的形参名。这个过程称为 字符串化(zstringizing)。
#include <stdio.h>
#define SQUARE(x) printf("The square of " #x " is %d.\n",((x) * (x)))
int main(void)
{
SQUARE(3);
SQUARE(5);
return 0;
}
2.5、预处理粘合剂
与 # 运算符类似,## 运算符可用于类函数宏的替换部分。而且,## 还可用于对象宏的替换部分。## 运算符把两个记号组合成一个记号。例如,可以这样做:
#define XNAME(n) x ## n
然后,宏 XNAME(4) 将展开为 x4。
#include <stdio.h>
#define XNAME(n) x ## n
#define PRINT_XN(n) printf("x " #n " = %d\n",XNAME(n))
int main(void)
{
int XNAME(1) = 3;
int XNAME(2) = 5;
int x3 = 7;
PRINT_XN(1);
PRINT_XN(2);
PRINT_XN(3);
return 0;
}
2.6、变参宏
我们可以把宏参数列表中的最后的参数写成省略号(即,3 个点...)来实现变参宏这一功能。这样,预定义宏 __VA_ARGS__
可用在替换部分,表明省略号代表什么。例如,下面的定义:
#define PRINT(...) printf(__VA_ARGS__)
假设稍后这样调用该宏:
PRINT("Hello\n")
PRINT("name = %s, age = %d\n", name, age)
对于第 1 次调用,__VA_ARGS__
展开为 1 个参数:"Hello"。
对于第 2 次调用,__VA_ARGS__
展开为 3 个参数:"name = %s, age = %d"、name、age。
因此,最后的展开代码是:
printf("Hello\n")
printf("name = %s, age = %d\n", name, age)
#include <stdio.h>
#define PRINT(...) printf(__VA_ARGS__)
int main(void)
{
char *name = "Sakura";
int age = 10;
PRINT("Hello\n");
PRINT("name = %s, age = %d\n", name, age);
return 0;
}
省略号只能替换最后的宏参数;
2.7、宏和函数的选择
有些编程任务既可以用带参数的宏完成,也可以用函数完成。那我们该如何选择呢?这没有硬规定,但是我们可以参考下面的情况。
使用宏比使用普通函数复杂一些,稍有不慎会产生奇怪的副作用,例如运算符优先级的问题。而且,一些编译器规定宏只能定义一行。但是,宏的也是存在优点的,即不用担心变量类型(这是因为宏处理的是字符串,而不是实际的值)。
宏和函数的选择实际上是时间和空间的权衡。宏生成内联代码,即在程序中生成语句。如果调用 20 次宏,即在程序中插入 20 次代码。如果调用函数 20 次,程序中只有一份函数的副本,所以节省了空间。然而另一方面,程序的控制必须跳转到函数内,随后再返回主调程序,这显然比内联代码花费更多的时间。
宏展开仅仅是字符串的替换,不会对表达式进行计算;宏在编译之前就被处理掉了,它没有机会参与编译(因此,宏是没办法参与调试),也不会占用内存。函数是一段可以重复使用的代码,会被编译,会给它分配内存,每次调用函数,就是执行这块内存中的代码。用宏替换代替替代的函数的好处是宏替换增加了代码的速度,因为不存在函数调用,但是可以存在多次宏替换从而提高了代码的长度;
属性 | #define定义宏 | 函数 |
---|---|---|
代码长度 | 每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长。 | 函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码 |
执行速度 | 更快 | 存在函数的调用和返回的额外开销,所以相对慢一些 |
操作符优先级 | 宏参数的求值是在所有周围表达式的上下文环境中里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括号 | 函数参数只有函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测 |
带有副作用的参数 | 参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果 | 函数参数只在传参的时候求值一次,结果更容易控制 |
参数类型 | 宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型 | 函数的参数是与类型有关的。如果参数的类型不同,就需要不同的函数,即使它们执行的任务是相同的 |
调试 | 宏是不方便调试的 | 函数是可以逐语句调试的 |
递归 | 宏是不能递归的 | 函数是可以递归的 |
C99 提供了第 3 种可替换的方法 —— 内联函数;
三、include指令
在一个源文件中使用 #include
指令可以将另一个源文件的全部内容包含进来,也就是将另外的文件包含到本文件中。#include
使编译程序将另一源文件嵌入带有 #include
的源文件中,被读入的源文件必须用 双引号 或者 尖括号 括起来。
#include <头文件名.h>
#include "头文件名.h"
使用 尖括号 时,系统带存放 C 库函数头文件所在的目录中寻找要包含的文件;使用 双引号 时,系统先在用户当前目录中寻找要包含的文件,若找不到,再到存放 C 库函数头文件所在的目录中寻找要包含的文件。
通常情况下,如果为调用库函数用 #include
命令来包含相关的头文件,则用尖括号可以节省查找的时间。如果要包含的时用户自己编写的文件,一般用双引号,用户自己编写的文件通常是在当前目录中,如果文件不在当前目录中,双引号中可以给出为文件路径。
经常用在文件头部的被包含的文件成为 “标题文件” 或 “头部文件”,一般以 .h 为后缀。一般我们会把以下内容放在头文件中:
- 宏定义
- 结构体、联合体 和 枚举声明
- typeof 声明
- 外部函数声明
- 全局变量声明
一个 #include 命令只能指定一个被包含的文件;
文件包含是可以嵌套的,即在一个被包含文件中还可以包含另一个被包含的文件;
四、条件编译
预处理器提供了条件编译功能,一般情况下,源程序中所有的行都参与编译,但是有时候希望只对其中一部分内容在满足一定条件下时才进行编译,这时就需要使用到一些条件编译命令。使用条件编译可方便地处理程序的调试版本和正式版本,同时还会增强程序的可移植性。
4.1、#if、#elif、#else命令
#if
的基本含义是:如果 #if
命令后的参数表达式为真,则编译 #if
到 #endif
之间的程序段,否则跳过这段程序。#endif
命令用来表示 #if
段的结束。#if
命令的一般格式如下:
#if 常量表达式
语句段
#endif
#else
的作用是为 #if
为假时提供另有一种选择:
#if 常量表达式
语句段1
#else
语句段2
#endif
#elif
指用用来建立一种 “如果...或者如果...” 这样阶梯状多重编译操作选择:
#if 常量表达式1
语句段1
#elif 常量表达式2
语句段2
...
#elif 常量表达式n
语句段n
#endif
#include <stdio.h>
#define NUM 10
int main()
{
#if NUM < 10
printf("该数比10小\n");
#elif NUM > 10
printf("该数比10大\n");
#else
printf("该数等于10\n");
#endif
printf("该数为:%d\n",NUM);
return 0;
}
4.2、#ifdef、#ifndef命令
#ifdef
与 #ifndef
命令,分别表示 “如果有定义” 与 “如果没有定义”。#ifdef
的一般形式如下:
#ifdef 宏替换名
语句段
#endif
其含义是:如果宏替换名已经定义过,则对“语句段”进行编译;如果为定义 #ifdef
后面的宏替换名,则不对语句进行编译;
#ifdef
可以与 #else
连用,构成的一般形式如下:
#ifdef 宏替换名
语句段1
#else
语句段2
#endif
其含义是:如果宏替换名已经被定义过,则对“语句段1”进行编译;如果未定义 #ifdef
后面的宏替换名,则对 “语句段2” 进行编译。
#include <stdio.h>
#define STR "Believe yourself\n"
int main()
{
#ifdef STR
printf(STR);
#else
printf("Don't give up\n");
#endif
return 0;
}
#ifndef
的一般格式如下:
#ifndef 宏替换名
语句段
#endif
其含义是:如果未定义 #ifndef
后面的宏替换名,则对“语句段”进行编译;如果定义 #ifndef
后面的宏替换名,则不执行语句段。
同样,#ifndef
也可以与 #else
连用,构成的一般形式如下:
#ifndef 宏替换名
语句段1
#else
语句段2
#endif
其含义是:如果未定义 #ifndef
后面的宏替换名,则对“语句段1”进行编译;如果定义 #ifndef
后面的宏替换名,则对 “语句段2” 进行编译。
#include <stdio.h>
int main()
{
#ifndef STR
printf("Don't give up\n");
#else
printf(STR);
#endif
return 0;
}
通常,包含多个头文件时,其中的文件可能包含了相同宏定义。#ifndef 指令可以防止相同的宏被重复定义。在首次定义一个宏的头文件中 #ifndef 指令激活定义,随后在其它头文件中的定义都被忽略。
#ifndef __LED_H__
#define __LED_H__
#include "stm32f1xx_hal.h"
#endif /** __LED_H__ */
4.3、#undef命令
使用 #undef
命令可以删除事先定义的宏。#undef
命令的一般格式如下:
#undef 宏替换名
#define 宏的作用域从它在文件中声明处开始,直到 #undef 指令取消宏为止,或者延伸至文件尾(以二者中先满足的条件作为宏作用域的结束)。另外还要注意,如果宏通过头文件引入,那么 #define 在文件中的位置取决于 #include 指令的位置。
#include <stdio.h>
#define STR "Believe yourself\n"
int main()
{
#ifdef STR
printf(STR);
#else
printf("Don't give up\n");
#endif
#undef STR
#ifndef STR
printf("Don't give up\n");
#else
printf(STR);
#endif
return 0;
}
undef 的主要目的是将宏名局限在仅需要它们的代码段中;
4.4、#line命令
#line
命令可以改变 __LINE__
与 __FILE__
的内容,__LINE__
当前编译行的行号,__FILE__
存放当前编译的文件名。#line
命令的一般形式如下:
#line 行号["文件名"]
其中,行号为任一正整数,可选的文件名为任意有效文件标识符,行号为源程序中当前行号,文件名为源文件的名字。#line
命令主要用于调试及其它特殊应用。
#include <stdio.h>
#line 100 "test.c"
int main()
{
printf("当前编译的文件:%s\n",__FILE__);
printf("当前行号为:%d\n",__LINE__);
return 0;
}
ANSI 标准说明了以下 5 个预定义宏替换名:
__LINE__
:其含义是当前被编译代码的行号;__FILE__
:其含义是当前源程序的文件名称;__DATA__
:其含义是当前源程序的创建日期;__TIME__
:其含义是当前源程序的创建时间;__STDC__
:其含义是用来判断当前编译器是否为标准 C,若其值为 1 则表示符合标准 C,否则不是标准 C;
4.5、#parama命令
#parama
命令的作用是设定编译器的状态,或者指示编译器完成一些特定的动作。#parama
命令的一般形式如下:
#param 参数
其中,参数可以分为以下几种:
- messaage 参数能够在编译信息输出窗口中输出相应的信息;
- cod_seg 参数设置程序中函数代码存放的代码段;
- once 参数保证头文件被编译一次