C预处理详解
零.什么是预处理
预处理是程序在翻译环境中编译过程的第一步。下面来说一说预处理阶段都发生了什么。
1.处理预定义的符号
__FILE__:正在编译的源文件。
__LINE__:文件当前行号。
__DATE__:文件被编译日期。
__TIME__:文件被编译时间。
__STDC__:判断编译器是否遵循ANSI C若遵循则为1,否则未定义。
#include<stdio.h>
int main()
{
int a = 10;
int b = 20;
printf("%s\n", __FILE__);
printf("%d\n", __LINE__);
printf("%s\n", __DATE__);
printf("%s\n", __TIME__);
printf("%s\n", __STDC__);
return 0;
}
在VS2019下__STDC__没有定义,所以将它删去再运行。
可以清楚看到打印的文件路径,行号,日期,时间。
预处理阶段会将这些符号翻译成这些内容。
2.处理#define定义的标识符
我们可以把数字,类型,甚至字符串定义成标识符。
#include<stdio.h>
#define MAX 1000
#define reg register
#define int_t int
#define STR "hehe"
int main()
{
int_t a = 10;
int b = MAX;
reg int c = 30;
printf("%s", STR);
return 0;
}
在预处理阶段,int_t变成了int,MAX变成了1000,reg变成了register,STR变成了"hehe"。
3.处理#define定义的宏
1.什么是宏
#define机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏或者定义宏。
2.宏的声明方式
#define name(list) stuff
其中name为宏名,list为参数列表,stuff为执行的操作。
3.举例
#define SQU(X) ((X)*(X))
int main()
{
int a = 5;
int b = SQU(a);
printf("%d", b);
return 0;
}
即定义了一个乘法的宏SQU,这段代码打印的结果是:
4.替换规则
1.在调用宏时,首先对参数进行检查,看看是否包含由define定义的符号,若果是它们将被替换。
2.替换文本随后被插入到程序中原来文本的位置,对于宏,参数名被它们的值替换。
3.最后,再次对文本文件进行扫描,看看它是否包含任何#define定义的符号,如果是就重复上述处理过程。
5.注意:
1.为了和函数区分,宏名一般都是全部大写的,而函数一般只有几个字母大写。
2.在书写宏的表达式时,每一个参数和操作都要用()否则会出现问题,比如:
#define SQU(X) X*X
int main()
{
int a = 5;
int b = SQU(a+5);
printf("%d", b);
return 0;
}
要打印这段代码的值,我们期望打印的是100,但实际的打印结果为:
这是因为宏只是单纯的替换,即在替换之前并没有计算a+5,替换之后为a+5*a+5,所以得到的结果是35,因此在使用宏时对变量和表达式需要加()。
3.参数列表的()要与name紧邻,如果两者之间有空格,参数列表就会解释成表达式的一部分。
4.使用宏时不能出现递归,即宏中不能出现无参数的宏名,如ADD((ADD),(5))。但是有参数可以计算的可以出现,比如:ADD((ADD(5,5),5)。
5.当预处理器搜索#define定义的符号时,字符串常量的内容不会被搜索
#define MAX 100
int main()
{
int a=MAX;
printf("MAX=%d",a);
}
注意字符串中的MAX就没有被替换。
6.#和##
1.#
把一个宏参数变成相应的字符串。
在定义宏时使参数的值不会直接被替换,而是转化为带""的参数。
#define PRINT(n) printf("the value of "#n" is %d",n)
int main()
{
int a = 10;
PRINT(a);
}
注意字符串有这样的特性,当两个字符串排列在一起时,两个字符串会自动进行连接。
比如printf("hello""world");打印出来的结果就是hellworld。
所以当将n表示成字符串时也会这样进行打印。
而#就可以将n变成一个字符串,所以打印的结果为:
这样不仅使在双引号的内部的参数可以被赋值,而且使之可以链接起来。#的作用主要在想在字符串中的参数也被赋值时进行使用。
2.##
##可以把位于它两边的符号合并成一个符号,它允许宏定义从分离的文本片段创建标识符。
注意连接出来的一定要是一个合法的标识符。
#define CAT(X,Y) X##Y
int main()
{
int class103 = 100;
printf("%d", CAT(class, 103));
}
即将class和103连接在了一起。
7.带副作用的宏参数
1.副作用
int a=10;
int b=a+1;//不带副作用,b被赋值,a不变
int b=a++;//带有副作用,b被赋值,a变了
2.例子
#define MAX(X,Y) ((X)>(Y))?(X):(Y)
int main()
{
int a = 5;
int b = 8;
int m = MAX(a++, b++);
printf("%d", m);
}
打印的结果是:
这是因为宏的参数不是计算后替换进去的,而是替换之后再计算,这里预处理之后的结果是:
((a++)>(b++))?(a++):(b++),所以打印的结果是9,而函数就不一样,函数是先计算后替换。
int Max(int a, int b)
{
return ((a) > (b)) ? (a) : (b);
}
int main()
{
int a = 5;
int b = 8;
int m = Max(a++, b++);
printf("%d", m);
}
打印的结果是:
因为传入的就是5和8两个值。
8.宏与函数的对比
1.代码长度
每次使用时,宏代码都会插入到程序中,除了非常小的宏之外,程序的长度会大幅度增长。
函数代码只出现于一个地方,每次使用这个函数时,都调用那个地方的同一份代码。
2.执行速度
宏执行更快。
函数存在调用和返回的额外开销,所以相对慢一点。
3.操作符优先级
宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则近邻操作符的优先级可能产生不可预料的后果,所以建议宏在书写时多一些括号。
函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测。
4.带有副作用的参数
参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能产生不可预料的后果。
函数参数只在传参时求值一次,结果更容易被控制。
5.参数类型
宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型。
函数的参数是与类型有关的,如果参数类型不同,就需要不同的函数,即使它们执行的任务是不同的。
6.调试
宏在调试的时候是看不到的,因为在预处理阶段就已经被替换了。
函数是可以逐语句进行调试的。
7.递归
宏是不能进行递归的,函数是可以进行递归的。
8.命名约定
把宏名全部大写,函数名不要全部大写。
9.定义类型
宏可以定义类型,但是函数做不到
比如:
宏可以把一个复杂的定义操作简洁化
#define MALLOC(num,type) (type*)malloc(num*sizeof(type))
int main()
{
int* p = MALLOC(100, int);
int* q = (int*)malloc(100 * sizeof(int));
}
此时这两条语句进行的操作是相同的。如果进行多次的定义使用宏更加的方便。
9.#undef
作用为移除一个宏定义
使用#undef对MAX进行移除,再使用MAX时会报错。
4.处理命令行定义
int main()
{
int i;
int arr[SZ] = {0,1,2,3,4,5,6,7,8,9};
for (i = 0; i < 10; i++)
{
printf("%d", i);
}
}
假如有这样一段代码,注意这里是没有定义SZ的,在linux系统下可以假如一条指令:
gcc -D SZ=10 test.c
即定义了SZ为10,那么这条指令是在预编译阶段执行的,预编译之后SZ被替换为10。
5.处理条件编译
1.与if,else语句的对比
满足条件的代码进行编译,不满足就不进行编译。和if,else语句很相近只不过是在预处理阶段执行的。
#if 1
printf("hehe\n");
#endif
这是最简单的一段条件编译指令,即如果1为真,就打印hehe。endif是结束的标志。
与之对应的还有#elif和#else
即处理的结果一般为:
#if 常量表达式
//
#elif 常量表达式
//
#else
//
#endif
我们也可以对他们进行嵌套,即#if语句中还包含#if语句。
注意:#if后面必须是常量,可以是#define定义的标识符常量,而不能是变量因为变量是运行的时候创建的。
2.判断是否被定义
#if语句可以判断常量是否被定义。
#if defined(MAX);
#ifdef(MAX);
这两条语句是等价的,判断MAX是否被定义,如果被定义了,那么下面的代码就会被执行,如果没有被定义那么下面的代码就不会被执行。
与#ifdef相反的是#ifndef(MAX),即如果MAX没被定义,那么下面的代码就会被执行。
6.处理文件包含
我们已经知道,#include指令可以使另一个文件被编译,就像它实际出现在#include指令的地方一样。
这种替换的方式很简单,预处理器先删除这条指令,并用包含的文件的内容进行替换。这样一个源文件被包含十次,就实际上编译了十次。
1.头文件的包含方式
头文件一共有两种包含方式:<>包含和""包含。
1.""包含
查找策略:先在源文件所在的目录下查找,如果头文件没有找到,编译器就像查找库函数的头文件一样在标准位置查找头文件,如果找不到则编译错误。
2.<>包含
查找策略:直接在标准路径下进行查找,如果找不到则编译错误。
2.嵌套文件包含
当一个项目中某一个头文件被包含多次时:
此时com.h在test中被包含了两次,我们要避免这个情况,需要每一次在定义头文件时进行规定:
#pragma once这样就定义了只能包含一次。防止文件被多次包含。
7.总结
预处理是源文件转换为头文件的第一步,了解了预处理操作可以加深我们对编译运行以及调试的进一步理解,熟练使用预处理中的宏也可以使你的代码事半功倍。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现