目录
正文开始
1. 何为预处理?
当我们运行一段代码的时候,它会经历以下过程:预处理 -> 编译 -> 汇编 -> 链接 -> 生成可执行程序。只有经过这些操作,才能将开发者写的文字代码,转换为计算机能看懂的机器语言。而今天我们所学的就是第一个阶段:预处理阶段
在预处理阶段,源文件和头文件会被处理成后缀为.i的文件。预处理阶段主要处理那些源文件中以#开始的预编译指令。例如:#include、#define,处理规则如下:
- 将所有#define删除,并展开所有的宏定义。就是将定义的常量和宏替换到对应位置
- 处理所有的条件编译指令,如:#if、#ifdef、#elif、#else、#endif
- 处理#include预编译指令,将包含的头文件的內容插入到该预编译指令的位置。这个过程是递归进行的,也就是说被包含的头文件也可能包含其他文件
- 删除所有注释
- 添加行号和文件名标识,方便后续编译器生成调试信息
- 保留所有的#pragma的编译器指令,方便编译器后续使用。
这些內容我们接下来会详细讲解,看完文章不要忘记回来复习呦
2. 预定义符号
C语言预设了一些预定义符号,这些预定义符号也是在预处理期间处理的:
__FILE__ //源文件地址
__LINE__ //当前的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__STDC__ //如果编译器遵循ANSR C则为1,否则未定义
我们打印一下看看:
#include <stdio.h>
int main()
{
printf("%s\n", __FILE__);
printf("%d\n", __LINE__);
printf("%s\n", __DATE__);
printf("%s\n", __TIME__);
return 0;
}
运行结果:
3. #define 定义常量
我们可以通过#define来给对象重命名:
#define name stuff
#define 用法:
- 将stuff重命名为name
- 重命名的实现方式是在预处理阶段,将所有的name原封不动的替换为stuff
例如:
//定义常量
#define MAX 1000
//为 register 关键字起一个简短的名字
#define reg register
//用更形象的符号来替换一种实现
#define do_forever for(;;)
//若定义的 stuff 过长,可将內容分
//为几行写,每行最后通过续行符\连接
#define DEBUG_PRINT printf("file:%s\nline:%d \
\ndate:%s\ntime:%s\n",\
__FILE__,__LINE__,\
__DATE__,__TIME__)
#include <stdio.h>
int main()
{
printf("%d\n", MAX);
DEBUG_PRINT;
return 0;
}
运行结果:
4. #define 定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)
宏的申明方式:
#define name(parament-list) stuff
宏的用法:
- name为宏的名字
- parament-list是一个由逗号隔开的符号表,它们可能出现在 stuff 中
- stuff是替换进 parament-list 中的表达式
例如:
#include <stdio.h>
#define SQUARE( x ) x * x
int main()
{
int a = SQUARE(5);
printf("%d\n", a);
return 0;
}
上述代码中调用宏的时候,首先将5传递进SQUARE( x )中去,然后将5代入表达式x * x中计算结果。程序中的宏,预处理器会将把表达式直接替换为5 * 5
但这样的书写方法存在一些问题,比如:
#include <stdio.h>
#define SQUARE( x ) x * x
int main()
{
int a = 2;
int b = SQUARE(a + 1);
printf("%d\n", b);
return 0;
}
运行结果:
上述代码的目的是计算a + 1的平方,但结果并不是我们预期的9。这是因为,宏定义是直接将参数替换掉的,也就是说,实际上计算的是a + 1 * a + 1结果自然就是5了。
所以为了避免产生上述代码的问题,我们在定义宏时,要加上括号,来规整优先级的问题,上述代码可改为:
#include <stdio.h>
#define SQUARE( x ) ( ( x ) * ( x ) )
int main()
{
int a = 2;
int b = SQUARE(a + 1);
printf("%d\n", b);
return 0;
}
所以用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料的相互作用
5. 宏的副作用
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么在使用宏的时候就可能出现不可预测的后果。副作用就是表达式求值的时候出现自身改变的情况
例如:
x + 1;//不带副作用
x++;//带有副作用
可能会产生的问题:
#include <stdio.h>
#define MAX(a,b) ( (a) > (b) ? (a) : (b) )
int main()
{
int x = 5;
int y = 7;
int z = MAX(x++, y++);
printf("x=%d y=%d z=%d\n", x, y, z);
return 0;
}
运行结果:
上述代码中,使用宏时,首先将参数传递进表达式(x++) > (y++) ? (x++) : (y++),首先进行 x 与 y 的比较,比较完成后,x 与 y 都加一,此时 x=6,y=8,而后返回表达式y++,也就是8,返回后 y 再加一。所以最后的结果就是 x=6,y=9,z=8。
上述代码中的宏参数在定义的时候出现超过了一次,并且参数带有副作用,这就导致了最终结果的偏差。
6. 宏替换的规则
在程序中拓展 #define 定义符号和宏时,有以下步骤:
- 在调用宏时,首先对参数进行检查,看看是否包含任何由 #define 定义的符号。如果有,他们首先被替换
- 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换
- 最后,再次对结果文件进行扫描,看看它是否包含任何由 #define 定义的符号。如果是,则重复上述处理过程
注:
- 宏参数和 #define 定义中可以出现其他 #define 定义的符号。但是对于宏,不能出现递归
- 当预处理器搜索 #define 定义的符号时,字符串常量的內容并不被搜索。例如:定义#define MAX 10,并有printf(“MAX is ten.”),那么字符串常量中的MAX并不会被搜索和替换。
7. 宏与函数
从刚才的学习中我们了解到,宏的作用其实就是能够接收参数,并按照指定表达式输出。这与函数的作用似乎很是相似。
宏通常用于执行简单的运算
宏相对于函数的优势:
- 调用函数需要创建并销毁栈帧,还要涉及到传参、函数返回等步骤,而宏则是直接替换,所以宏所需时间更短
- 更为重要的是,函数的参数必须声明为特定的类型,所以函数只能在类型合适的表达式上使用。但因为宏的直接替换的特点,所以宏的参数类型是更为灵活的
例如:
//宏
#define MAX(a,b) ( (a) > (b) ? (a) : (b) )
//函数
int Max(int a , int b)
{
if(a > b)
return a;
else
return b;
}
上述代码中,宏和函数的功能是相同的,都是求出两个数的较大值,但这个函数只能用于比较整型元素,而宏可以适用于整型、长整型、浮点型等等。
宏相对于函数的劣势:
- 预处理阶段会将宏直接替换掉,所以每次使用宏的时候,一份宏定义的代码将会插入到程序中,若宏比较长,那就会大幅增加程序的长度;而函数只出现在一个地方,每次使用都去那个地方调用
- 调试是无法进入到宏的内部的,所以宏是无法调试的
- 宏由于类型无关,所以不够严谨
- 宏可能会带来运算符优先级的问题,导致程序出现不可预料的错误
- 宏不能递归,而函数可以递归
一般来说,函数和宏的使用语法很相似,所以我们通过一个小习惯来区分宏和函数:
- 宏名全部大写
- 函数名不要全部大写
当然这只是一种约定俗成的规则,并不具有硬性要求
8. #和##
8.1 # 运算符
#运算符可以将宏的一个参数转换为字符串字面量。它仅允许出现在带参数的宏的替换列表中。
#运算符所执行的操作可以理解为“字符串化”
比如,我们有一个变量int a = 2我们想打印the value of a is 2,那么我们可以这样实现:
#include<stdio.h>
#define PRINT(x) printf("the value of "#x " is %d", x)
int main()
{
int a = 2;
PRINT(a);
return 0;
}
运行结果:
简单来说,#所产生的效果就是,将宏参数的名字原封不动的替换进表达式,而不是将宏参数的值替换进表达式
8.2 ## 运算符
##运算符可以把位于它两边的符号合成一个符号,它允许宏定义从分离的文本片段创建标识符。所以它又被称为记号粘合
这样的连接必须产生一个合法的标识符,否则结果未定义
例如:
#include <stdio.h>
#define X(n) xn
#define Y(n) y##n //将符号 y 和 n 合并为一个记号
int X(1) = 10;
int Y(1) = 10;
int main()
{
return 0;
}
预处理后结果:
9. #undef
使用#undef这条预处理指令可以移除一个宏定义,例如:
#define MAX 10 //定义
//...
#undef MAX //移除定义
//...
int a = MAX;//error未定义
10. 条件编译
我们可以通过预处理指令来进行条件编译,它的逻辑与之前所学的分支语句基本相同,下面我们学习一下常见的条件编译指令:
//单个分支的条件编译
#if 常量表达式 //该表达式由预处理器求值
//...
#endif
___________________________________
//多个分支的条件编译
#if 常量表达式1
//...
#elif 常量表达式2
//...
#else
//...
#endif
___________________________________
//判断是否被定义
#if defined(判断对象)
//...
#endif
//或
#ifdef 判断对象
//...
#endif
___________________________________
//判断是否没被定义
#if !defined(判断对象)
//...
#endif
//或
#ifndef 判断对象
//...
#endif
需要注意的是,上述一些条件编译的判断式都为常量表达式,不能使用变量,例如:
#include <stdio.h>
#define MAX 10
int main()
{
//int MAX = 10; error
#if MAX > 5
printf("hello");
#elif MAX == 5
printf("world");
#else
printf("haha");
#endif
return 0;
}
11. 头文件的包含
11.1 本地文件包含
#include "filename.h"
查找文件策略:先在源文件所在目录下查找,如果未找到,则编译器就像查找库函数头文件一样在标准位置查找头文件。若仍找不到则编译错误。
Linux 环境的标准头文件的路径:
/usr/include
VS环境下标准头文件的路径:
C:\Program Files (x86)\Microsfot Visual Studio 12.0\VC\include
标准头文件路径与用户安装路径有关,这里仅供参考
11.2 库文件包含
#include <filename.h>
查找文件策略:直接去标准路径下去查找,若找不到则提示编译错误。
也就是说使用“”查找文件的范围更为广泛,所以可以使用“”替换<>,但这样做的效率就会变低,因为“”的会查找两个位置的文件。而且这样也不易区分库文件和本地文件,所以不建议用“”替换<>
11.3 嵌套文件的包含
我们使用#include引用文件时,在预处理阶段就会将这个文件的所有内容替换到对应位置,那如果重复编译了同一个文件,这样就降低了效率,所以我们可以通过如下方式确保一个文件只被编译一次:
#ifndef __TEST_H__
#define __TEST_H__
//头文件內容...
#endif
上述代码所在文件第一次被引用后会定义常量TEST_H,并编译头文件內容,当第二次被引用时,并不会编译头文件內容,这样就确保了一个文件只被编译一次。
我们还可以通过以下指令实现上述功能:
#pragma once
完