C语言预处理详解
文章目录
- 一、预定义符号
- 二、#define定义符号(常量)
- 三、#define定义宏
- 四、带有副作用的宏参数
- 五、宏替换的规则
- 六、宏和函数的对比
- 七、#和##
- 1. #运算符
- 2. ##运算符
- 八、命名约定
- 九、#undef
- 十、命令行定义
- 十一、条件编译
- 十二、头文件的包含
- 1.头文件被包含的方式
- ①本地文件包含
- ②库文件包含
- 2.嵌套文件包含
- 十三、其他预处理指令
一、预定义符号
C语言设置了一些预定义符号,可以直接使用,预定义符号也是在预处理期间处理的。
1 _ _FILE_ _ //进行编译的源文件
2 _ _LINE_ _ //文件当前的行号
3 _ _DATE_ _ //文件被编译的日期
4 _ _TIME_ _ //文件被编译的时间
5 _ _STDC_ _ //如果编译器遵循ANSI C,其值为1,否则未定义
注意上面的是两个下划线紧挨着,我们可以打印这几个预定义符号看一看:
#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
int main()
{
printf("%s\n", __FILE__);
printf("%d\n", __LINE__);
printf("%s\n", __DATE__);
printf("%s\n", __TIME__);
return 0;
}
程序运行结果:
由于VS2022这个环境底下,是不完全遵循ANSI C标准的,所以在VS2022环境下打印__STDC__的值是会报错的。在gcc环境下是遵循ANSI C的,打印出__STDC__的值就为1。这些预定义符号在预处理阶段就会被替换成上面的值。
二、#define定义符号(常量)
基本语法:
#define name stuff
举一个例子:
#define MAX 1000
#define reg register //为register这个关键字,创建⼀个简短的名字
#define do_forever for(;;) //⽤更形象的符号来替换⼀种实现
#define CASE break;case //在写case语句的时候⾃动把break写上。
//如果定义的stuff过⻓,可以分成⼏⾏写,除了最后⼀⾏外,每⾏的后⾯都加⼀个反斜杠(续航符)。
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
date:%s\ttime:%s\n" ,\
__FILE__,__LINE__ ,\
__DATE__,__TIME__ )
在#define定义标识符的时候,要不要在最后加上 ; ?
例如:
#define MAX 1000;
#define MAX 1000
建议不要加上 ; , 这样容易导致问题。比如下面的场景:
if(condition)
max = MAX;
else
max = 0;
如果是加了分号的情况,等替换后,if和else之间就是2条语句,而没有大括号的时候,if后边只能有一条语句。这里会出现语法错误。所以用#define定义的符号,会替换stuff掉中的所有部分。
三、#define定义宏
#define机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。
下面是宏的声明方式:
#define name( parament-list ) stuff
其中的 parament-list 是一个由逗号隔开的符号表,它们可能出现在stuff中。
🥐注意🥐:
参数列表的左括号必须与name紧邻,如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。
举例:
#define SQUARE( x ) x * x
这个宏接收一个参数 x ,如果在上述声明之后,你把 SQUARE(5) 置于程序中,预处理器就会用下面这个表达式替换上面的表达式:5*5
🍓警告🍓:
上面的宏是存在一定的问题的,观察下面的代码段:
int a = 5;
printf("%d\n", SQUARE( a + 1 ));
乍一看,你可能会觉得这段代码将打印36,事实上它打印的是11,为什么呢?替换文本时,参数x被替换成a+1,所以这条语句实际上变成了:
printf ("%d\n", a + 1 * a + 1 );
这样就比较清晰了,由替换产生的表达式并没有按照预想的次序进行求值,所以宏就是纯粹的文本替换。给宏参数加上( )就可以解决上面的问题:
#define SQUARE(x) (x) * (x)
这样预处理之后就产生了预期的效果:
printf ("%d\n",(a + 1) * (a + 1) );
虽然给宏参数加上了括号,但是上面的代码还是存在一些问题,看下面的宏定义:
#define DOUBLE(x) (x) + (x)
定义中我们使用了括号,想避免之前的问题,但是这个宏可能会出现新的错误。
int a = 5;
printf("%d\n", 10 * DOUBLE(a));
我们想要的效果其实是10*(5+5)=100,但是我们知道宏定义就是纯粹的文本替换,替换后的效果是:
printf ("%d\n", 10 * (5) + (5));
由于乘法运算先于宏定义的加法,所以实际计算出来的是55,解决这个问题的办法就是在宏定义表达式的两边加上一对括号就可以了:
#define DOUBLE( x ) ( ( x ) + ( x ) )
🥪提示🥪:
用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料的相互作用(比如优先级问题)。
四、带有副作用的宏参数
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果。
例如:
x+1; //不带副作⽤
x++; //带有副作⽤
上面的第二条代码就带有副作用,x++会使x的值自增1。
MAX宏可以证明具有副作用的参数所引起的问题:
#include<stdio.h>
//写一个宏求两个数的最大值
#define MAX(X,Y) ((X)>(Y)?(X):(Y))
int main()
{
int a = 3;
int b = 5;
int m = MAX(a++, b++);
printf("%d\n", a);
printf("%d\n", b);
printf("%d\n", m);
return 0;
}
程序运行结果:
这里我们得知道预处理器处理之后的结果是什么:
m = ( (a++) > (b++) ? (a++) : (b++));
分析一下上面的代码,因为是后置++,所以会先使用a和b的值,然后会让a和b的值自增1;因为3不大于5,所以整个表达式的值就为b++,因为前面a和b自增了1,所以现在的a==4,b因为在前一次自增1的基础上变成了6,再执行b++赋给m后,m==6,b就变成了7。所以输出的结果就是:a==4、b==7、m==6。就像这里既使用了宏,宏参数中又包含着自增操作符,宏参数在宏定义的时候出现不止一次。可能就会导致不可预测的结果。
五、宏替换的规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
🍎1.在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。
🍎2.替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
🍎3.最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号(比如宏)。如果是,就重复上3述处理过程。
注意:
1.宏参数和#define定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归(宏不支持递归)。
比如:
#define MAX(X,Y) ((X)>(Y)?(X):(Y))
#define M 10
int main()
{
int a = 5;
int b = 3;
int m1 = MAX(a,M);
int m2 = MAX(a,MAX(b,M));
return 0;
}
2.当预处理器搜索#define定义的符号的时候,字符串常量的内容并不会被搜索(也就是在字符串内部出现#define定义的符号,这个符号是不会被搜索的,只是单纯的字符或者字符串)。
比如:
#include<stdio.h>
#define M 10
#define MAX(X,Y) ((X)>(Y)?(X):(Y))
int main()
{
int a = 3;
int b = 4;
printf("M = %d MAX(a,b) = %d",M,MAX(a,b));//字符串中的M和MAX(a,b)在预处理过程中是不会被扫描的
return 0;
}
六、宏和函数的对比
🍌宏通常被应用于执行简单的运算。
比如在找两个数的最大值时,写成下面的宏,更有优势一些。
#define MAX(a, b) ((a)>(b)?(a):(b))
那为什么不用函数来完成这个任务?原因有二:
1.用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹。
2.更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之宏可以适用于整形、长整型、浮点型等可以用 > 来比较的类型。宏的参数是类型无关的。
🍅和函数相比宏的劣势🍅:
① 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能会大幅度增加程序的长度。
② 宏是没法调试的。
③ 宏由于类型无关,也就不够严谨。
④ 宏可能会带来运算符优先级的问题,导致程容易出现错。
宏有时候可以做函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到。
#define Malloc(num,type) (type*)malloc(num*sizeof(type))
int main()
{
int* p = Malloc(10, int);//类型作为参数
return 0;
}
宏和函数的对比:
七、#和##
在讲解#运算符之前,先讲一个小小的知识点:
#include<stdio.h>
int main()
{
printf("hello""world""\n");
printf("helloworld\n");
return 0;
}
程序运行结果:
可以看到上面的两行代码打印的字符串效果一样,说明在printf函数中,多个字符串之间可以合并成一个字符串。
1. #运算符
🍑#运算符会将宏的一个参数转换为字符串字面量。它仅允许出现在带参数的宏的替换列表中。
🍑#运算符所执行的操作可以理解为"字符串化"。
当我们有一个变量int a = 10的时候,我们想打印出:the value of a is 10
用宏就可以写成:
#define PRINT(n,format) printf("the value of "#n" is "format"\n", n);
在宏的参数的替换列表中,如果在参数前加上#运算符,意思就是这个参数不做替换,而是转换成它相应的字符串。这样我们只要传相应的变量符号和占位符,就可以打印我们想要的效果:
#include<stdio.h>
#define PRINT(n,format) printf("the value of "#n" is "format"\n", n);
int main()
{
int a = 10;
float f = 3.14f;
PRINT(a,"%d");
PRINT(f, "%f");
return 0;
}
程序运行结果:
上面的代码中,用宏打印上面的a和f的两段字符串,#a和#f其实就转换成了 “a” 和 “f” ,然后用printf打印的时候,多个字符串就可以合并,实现上面的打印效果。
2. ##运算符
##可以把位于它两边的符号合成一个符号,它允许宏定义从分离的文本片段创建标识符。##被称为记号粘合。
这样的连接必须产生一个合法的标识符。否则其结果就是未定义的。
这里我们想一想,写一个函数求2个数的较大值的时候,针对不同的数据类型就需要写不同的函数,才能求2个数的最大值。这样就太繁琐了。我们可以尝试用宏来定义一个函数模版:
#include<stdio.h>
#define GENERIC_MAX(type) \
type type##_max(type x,type y)\
{ \
return (x>y?x:y); \
}
GENERIC_MAX(int); //替换到宏体内后int##_max⽣成了新符号int_max做函数名
GENERIC_MAX(float); //替换到宏体内后float##_max⽣成了新符号float_max做函数名
int main()
{
int r1 = int_max(3,5);
printf("%d\n", r1);
float r2 = float_max(3.1f, 4.5f);
printf("%f\n", r2);
return 0;
}
程序运行结果:
通过上面的宏定义,其实就定义出了一个函数模版,因为都是实现求2个数的最大值这个功能,只不过是因为数据的类型不同,就要写不同的函数,这样太繁琐。现在有了上面的宏定义,只需要给宏的参数传一个类型,就能定义出不同的函数。
在上面的宏定义中用##运算符链接了type和_max两个符号,可能会有人会想为什么不直接写成type_max这个样子呢?那这样就是一个符号,宏的参数是不会替换这里type的,所以要用##连接两个符号。
🍊🍊上面宏定义中的 ‘\’ 是C语言中的续行符,表示续行符的下一行与续行符所在行的代码是同一行。注意:在应用续行符的时候,在 ‘\’ 的后面是不能有任何字符(包括空格、注释)的,只能紧挨着换行符(‘\n’),也就是续行符的后面紧接着只能敲回车键。
八、命名约定
一般来讲函数和宏定义函数的使用语法是很相似的。所以语言本身没法帮我们区分二者。那我们平时的一个习惯是:
🍐把宏名全部大写
🍐函数名不要全部大写
九、#undef
#undef这条指令用于移除一个宏定义。
比如:
如果不想再使用#define定义的符号或者宏,就可以用#undef移除这个符号或者宏定义。
十、命令行定义
许多C的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。例如:当我们根据同一个源文件要编译出一个程序的不同版本的时候,这个特性就有点用处。(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器的内存要大些,我们需要的数组就能够大些。)
#include<stdio.h>
int main()
{
int array[ARRAY_SIZE];
int i = 0;
for (i = 0; i < ARRAY_SIZE; i++)
{
array[i] = i;
}
for (i = 0; i < ARRAY_SIZE; i++)
{
printf("%d ", array[i]);
}
printf("\n");
return 0;
}
编译指令:
//Linux环境底下的gcc演⽰
gcc -D ARRAY_SIZE=10 program.c -o program
上面的代码中,array数组的长度ARRAY_SIZE就是未定义的,通过上面的编译指令在编译过程中指定数组的长度,这就是允许在命令行中定义符号。(注意:这种在编译时定义符号,在VS上是做不到的)
十一、条件编译
在编译一个程序的时候,如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。设置一个条件编译指令,如果满足条件就参与编译;如果不满足条件,就不参与编译。
比如说:
调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。
#include<stdio.h>
#define __DEBUG__
int main()
{
int i = 0;
int arr[10] = { 0 };
for (i = 0; i < 10; i++)
{
arr[i] = i;
#ifdef __DEBUG__
printf("%d\n", arr[i]); //为了观察数组是否赋值成功。
#endif //结束#ifdef __DEBUG__
}
return 0;
}
上面的代码中,在开头#define定义了一个__DEBUG__符号,在之后的程序中定义了一个数组,并且为数组的元素进行了赋值,为了观察数组是否赋值成功,就设置了一个条件编译指令#ifdef __DEBUG__,这条指令的意思就是如果(前面)定义了__DEBUG__符号,那printf(“%d\n”, arr[i])这条语句就执行,如果没有定义__DEBUG__这个符号,那#ifdef __DEBUG__这条语句就为假,就不执行printf(“%d\n”, arr[i])这条语句。后面的#endif是跟#ifdef相匹配的指令,意思是到这里就结束#ifdef指令。当我们不想执行#ifdef - #endif之间的语句了,把开头#define __DEBUG__这条给注释掉就可以啦。
所以🥝设置了条件编译指令,那开头的#define定义符号这条语句就像开关一样,当我们想执行#ifdef - #endif之间的语句,那就在开头#define定义符号,当我们不想用#ifdef - #endif之间的语句了,就把开头的#define定义符号这条给注释掉🥝。
C语言中常见的条件编译指令:
1.条件编译指令
#if 常量表达式
//...
#endif //结束#if
//常量表达式由预处理器求值
//如:
#define __DEBUG__ 1
#if __DEBUG__ //__DEBUG__为1,语句为真,执行后面的语句。
//...
#endif
对于#if - #endif这条条件编译指令,当常量表达式的值为真(非零),那么#if和#endif之间的代码将会被编译。如果常量表达式的值为假(零),那么#if和#endif之间的代码将会被忽略,作用就相当于注释。
2.多个分支的条件编译指令
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif //结束上面的多分支条件编译指令
#if-#elif-#else-#endif跟多分支语句是很相似的,虽然上面有多个分支的条件编译指令,但是只会执行其中一个指令后的语句,就是哪个常量表达式的值为真,就会执行那条指令后面的语句。
3.判断是否被定义
#if defined(symbol)
#ifdef symbol
#if !defined(symbol)
#ifndef symbol
🍎与上面两种表达相反的两个条件编译指令为:#if !defined(symbol) 和 #ifndef symbol,意思都是如果没有定义symbol这个符号,就执行#if !defined(symbol)-#endif或者#ifndef-#endif之间的语句。
4.嵌套指令
#if defined(OS_UNIX)
#ifdef OPTION1
unix_version_option1();
#endif
#ifdef OPTION2
unix_version_option2();
#endif
#elif defined(OS_MSDOS)
#ifdef OPTION2
msdos_version_option2();
#endif
#endif
条件编译指令之间是可以嵌套的,就像多分支的条件判断语句一样,每个条件编译指令都有一个与其最近的相匹配的#endif。注意:如果#if-#elif-#else-#endif之间有多条语句,可以不用加{ }。
十二、头文件的包含
1.头文件被包含的方式
①本地文件包含
#include “filename”
查找策略:先在源文件所在的工程目录下查找,如果该头文件未找到,编译器就会像查找库函数的头文件一样在标准位置(路径)查找头文件。
如果找不到就提示编译错误。
Linux环境的标准头文件的路径:
/usr/include
VS环境的标准头文件的路径:
C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\include
//这是VS2013的默认路径
注意按照自己的安装路径去找。
②库文件包含
#include <filename.h>
查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。
那是不是可以说,对于库文件也可以使用 “ ” 的形式包含?
🍆🍆🍆答案是可以的,但是这样做查找的效率就低些(因为要先去工程目录底下查找,再去标准路径下查找),当然这样也不容易区分是库文件还是本地文件。
2.嵌套文件包含
我们已经知道, #include指令可以使另外一个文件被编译。就像那个文件实际出现在有 #include 指令的地方一样。
这种替换的方式很简单:预处理器先删除这条指令,并用包含文件的内容替换。
如果一个头文件被重复的包含10次,那这个头文件就实际被编译10次。如果重复包含一个头文件,那对编译的压力就比较大。
就像上面,test.c文件中将test.h包含5次,那么test.h文件的内容将会被拷贝5份在test.c中。如果test.h文件比较大,这样预处理后的代码量将会剧增。如果工程比较大,有公共使用的头文件,被大家都在使用,又不做任何的处理,那么后果真的不堪设想。那如何解决头文件被重复引入的问题呢? 答案是:🍊条件编译🍊。
//代码1:
#ifndef __TEST_H__
#define __TEST_H__
//头⽂件的内容
#endif //结束#ifndef __TEST_H__
或者是
//代码2:
#pragma once
//头文件的内容
上面的第一个代码就是当预处理的时候,第一次包含这个头文件的时候,并没有定义__TEST_H__这个符号,那#ifndef __TEST_H__这条语句就为真,就会执行后面的代码,紧接着#define定义了__TEST_H__这个符号,将头文件的内容包含进一个.c或者.h文件中。如果上面的头文件被二次包含进其他.c或者.h文件中时,由于第一次已经定义了__TEST_H__该符号,所以之后遇到#ifndef __TEST_H__这条语句,就为假;就不会再将此头文件被二次包含进其他的.c文件或者.h文件中啦。
上面的第二个代码中的#pragma once指令的功能就是让该指令所在的头文件在被其他.c文件或者.h文件包含时,只被包含一次,不会被重复包含。上面的两种方法就可以避免头文件的重复引入。
注:推荐《高质量C/C++编程指南》中附录的考试试卷(很重要)笔试题:
🍇1.头文件中的 ifndef/define/endif是干什么用的?
🍇2. #include <filename.h> 和 #include “filename.h” 有什么区别?
十三、其他预处理指令
#error
#pragma
#line
...
#pragma pack() //修改默认对齐数
#error:当预处理器处理到#error这个命令时将停止编译,并输出用户自定义的错误消息。
#pragma:设置编译器的状态或者是只是编译器完成一些特定的动作。
#line:更改编译器对源代码行号的追踪。语法如下:
#line 行号 “文件名”
关于上面的预处理命令的详细解释,可以参考学习《C语言深度解剖》这本书。