文章目录
- 📖预定义符号
- 📖预处理指令
- 📖#define
- 🔖#define定义标识符
- 🔖#define定义宏
- 🔖#define替换规则
- 🔖#和##
- 🔖带有副作用的宏参数
- 🔖宏和函数的对比
- 🔖命名约定
- 🔖#undef
- 📖命令行定义
- 📖条件编译
- 📖文件包含
- 🔖头文件被包含的两种方式
- 🔖头文件被重复包含
- 📖模拟实现offsetof
- 📖交换一个二进制数的奇数位和偶数位
前言
上一次分享了与程序有关的两种环境,分别是 翻译环境和 执行环境,在执行环境中又细分出了 预处理(预编译)、 编译、 汇编、 链接几个过程,今天就让我们来深入了解一下 预处理过程都干了些什么,话不多说,让我们开启今天的学习吧!
📖预定义符号
__FILE__
----当前源文件所在的路径__LINE__
----文件当前的行号__DATE__
----文件被编译的日期__TIME__
----文件被编译的时间__STDC__
----如果编译器遵循ANSI C ,其值为1,否则未定义__FUNCTION__
----__FUNCTION__所在函数的函数名
int main()
{
printf("%s\n", __FILE__);
printf("%d\n", __LINE__);
printf("%s\n", __DATE__);
printf("%s\n", __TIME__);
return 0;
}
//上面这段代码是在VS2019这个环境下运行的,__STDC__显示未定义
//说明VS2019不支持ANSI C标准
这些预处理符号在预处理阶段就会被具体的值替换,如下图所示:
📖预处理指令
我们常见的下面这些符号都被叫做预处理指令:
#define
----定义宏和标识符常量#include
----头文件的包含#pragma
对这些预处理指令都是在预处理阶段执行的。
📖#define
🔖#define定义标识符
语法:
#define name stuff
//表示代码中所有name的地方都用stuff来进行替换
实例:
#define MAX 1000//把代码中所有的MAX用1000来替换
#define reg register//为register这个关键字,创建一个简短的名称
#define do_forever for(;;)//用更形象的符号来替换一种实现
#define CASE break;case//在写case语句的时候自动把 break写上。
//如果定义的stuff过长,可以分成几行写,除了最后一行外,每行的后面都加一个反斜杠(续航符)
#define定义标识符的时候,要不要在最后加;
?
比如:
#define MAX 1000
#define MAX 1000;
建议不要加上;
,这样容易导致问题,比如下面的场景:
- 情景一:(加上
;
没有影响)
#define MAX 100;
int main()
{
int min = MAX;
return 0;
}
上面的代码在预处理阶段,用100;
去替换程序中的MAX
,这就导致在text.i
文件中100的后面有两个分号,其中一个时我们在源代码(text.c)中写的,另一个是在对MAX
替换后得到的。这两个分号并不会对程序造成什么影响,第二个分号会被当成一条空语句去执行。
- 情景二:(加上
;
导致异常)
#define MAX 100;
int main()
{
int i = 1;
int n = 0;
if(i > 0)
n = MAX;
else
n = 0;
return 0;
}
可以看出,此时程序报错了。为什么呢?因为根据语法规定:if
语句后面如果没有大括号的话只能有一条语句,但是从预编译的得到的text.i
文件中可以看出if
语句后面跟了两条语句,分别是赋值语句n = 100;
和空语句;
,这显然不符合语法规定,报错也是理所当然。当然针对上面的错误也有以下几种修改手段:
- 在#define的时候,后面不加
;
,这是一本万利的方法 - 在写源代码的时候
MAX
后面不写;
,这种方法虽然可行,但是不符合我们的日常使用习惯,一条语句结束没有;
给我们的第一感觉就是代码写的有问题 - 在写源代码的时候对
if else
子句加上大括号,当然这种方法也仅仅是针对当前的情况有效,如果是其他情况还是需要另寻它路。
通过分析我们得出结论:在用#define定义标识符的时候不要加;
。
🔖#define定义宏
#define
机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro) 或定义宏(#define macro)。
语法:
#define name(parament-list) stuff
name
是宏的名字parament-list
是一个用逗号隔开的符号表,它们可能会出现在stuff
中(类似于参数,没有类型)stuff
会用parament-list来实现一定的功能
注意: 参数列表必须的左括号必须与name
紧邻,如果两者之间有任何空白存在,参数列表就会被解释为stuff
的一部分。
实例:
#define SQUARE(x) x*x
int main()
{
printf("%d\n", SQUARE(2));
printf("%f\n", SQUARE(5.0));
return 0;
}
工作原理:
可以看出在预处理阶段对源程序中的SQUARE(2)
和SQUARE(5.0)
进行了替换。以SQUARE(2)
为例在预处理阶段宏的参数x就是2,然后用x*x
就变成了2*2
再用2*2
去替换SQUARE(2)
最终就得到了text.i
文件中的结果。
存在的陷阱一:
#define SQUARE(x) x*x
int main()
{
printf("%d\n", SQUARE(5 + 1));
return 0;
}
按照一般思维去看上面的代码,首先5+1=6
我们会以为是把6
传给x
,然后用6*6
来替换SQUARE(5 + 1)
最终得到36,可结果并不像我们想的那样,正确结果是11,为什么会这样呢?去看看预处理后得到的文件我们就会恍然大悟
可以看出真实的替换结果并不是我们想的用6*6
去替换SQUARE(5 + 1)
,而是用5+1*5+1
去替换的。这说明在预处理的时候并没有执行5+1,而是直接把5+1传了过去,最终得到的结果就是11。
总结: 宏的参数是不加运算直接进行替换的
如何得到我们想要的36呢?有以下两种方法供大家参考:
- 对宏调用进行修改:
SQUARE((5 + 1))
,给5+1再加一层括号,此时在替换的时候,x就是(5+1)
,SQUARE((5 + 1))
就会被替换成(5+1)*(5+1)
最终得到的结果就是36。 - 对定义的宏进行修改:
#define SQUARE(x) (x)*(x)
,此时在替换的时候,x是5+1
,SQUARE(5+1)
会被替换成(5+1)*(5+1)
最终得到的结果也是36。
对比上面的两种方案,第一种方案带两个括号看起来比较别扭,所以更推荐第二种方案,也就是在定义宏的时候给参数带上括号。
存在的陷阱二:
//我们希望计算一个数的二倍再乘10
#define DOUBLE(x) (x)+(x)//这里定义了一个宏来计算一个数的二倍
int main()
{
printf("%d\n", 10 * DOUBLE(3));
return 0;
}
根据需求描述,我们希望得到的应该是60,但实际却得到的是33,本质原因就是宏只会进行替换,先进行参数的替换,再进行宏体的替换。让我们来看看上面的代码经过预处理会得到什么
不难看出,为了得到我们希望的结果,应该对宏替换后得到的内容加上括号,也就是在定义宏的时候对处理结果加上括号:#define DOUBLE(x) ((x)+(x))
通过上面介绍的两种陷阱,我们可以得出一个结论:在宏定义的时候,千万不要吝啬括号!!,先给每个参数带上括号,再给stuff
整体带上括号。
对于#define
定义宏的注意事项总结如下:
- 参数列表必须的左括号必须与宏的名字
name
紧邻 - 宏的参数都是不加计算直接替换的
- 不要吝啬括号
🔖#define替换规则
在程序中扩展#define定义符号和宏时,需要涉及以下几个步骤:
- 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号,如果有,它们首先被替换。
- 替换文本随后被插入到程序中原来文本的位置。对于宏、参数名被他们的值所替换。
- 最后再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号,如果有,就重复上面述处理过程
注意:
- 宏参数和#define定义中可以出现其他#define定义的符号,但是对于宏,不能出现递归
- 当预处理器搜索#define定义的符号的时候,字符串常量的值并不被搜索
🔖#和##
先来看下面的代码
int main()
{
char* p = "hello ""world\n";
printf("hello ""world\n");
printf("%s", p);
return 0;
}
通过结果可以看出:对于两个字符串"hello "
和"world\n"
编译器会自动对它们进行合并得到一个字符串hello world\n
。字符串是有自动连接的特点我们是否可以写出下面这种代码?
//这里我们写了一个宏用来打印信息
//我们希望传a的时候它可以打印出:
//a的值是:0
//传b的时候它可以打印出:
//b的值是:10
#define PRINT(x) printf("x的值是:%d\n", x)
int main()
{
int a = 0;
PRINT(a);
int b = 10;
PRINT(b);
return 0;
}
结果不尽如人意,printf("x的值是:%d\n", x)
中的第一个x
并没有被替换掉,那如何实现我们的需求呢?此时就需要用到#
,它可以把宏参数转换成字符串,不信我们试试,对上面的代码稍作修改:
#define PRINT(x) printf(#x"的值是:%d\n", x)
int main()
{
int a = 0;
PRINT(a);
int b = 10;
PRINT(b);
return 0;
}
此时我们的需求就得以实现,以PRINT(a);
为例分析一下过程:首先用a
去替换宏参数x
,再把x
带入到后面的宏体中,此时a
是一个整型,再通过#
把整型a转换成一个字符串"a"
,再利用两个字符串可以自动合并的特性,最终就实现了我们的需求。再来看看预处理后得到的结果:
此时我们定义的宏PRINT
只能打印整型变量,因为宏体里的格式化打印已经被固定为%d
了,我们可以对当前的宏稍作修改,使它也可以打印浮点型数据,即把格式也当作宏参数传递:
#define PRINT(format, x) printf(#x"的值是:"format"\n", x)
int main()
{
int a = 0;
PRINT("%d",a);
int b = 10;
PRINT("%d",b);
float c = 2.5f;
PRINT("%f",c);
return 0;
}
经过预处理替换后得到下面的结果:
总结: #
可以把宏参数转换成对应的字符串来进行显示,了解了#
的作用,接下来了解##
,它可以把位于它两边的符号合并成一个符号,它允许宏定义从分离的文本片段创建标识符。如下面的代码:
#define GET(str1, str2) str1##str2
int main()
{
int Addstudent = 10;
printf("%d\n", GET(Add, student));
return 0;
}
再看一下他们在编译替换后得到的结果:(printf("%s\n", GET("Hello ", "world"));
再gcc环境下会报错)
🔖带有副作用的宏参数
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现永久有效性
先来解释一下什么叫副作用,例如:
int main()
{
int a = 10;
int b = ++a;
return 0;
}
上面代码中定义了a = 10
,然后想给b
赋值成11,这里是通过++a
来实现的,它存在的问题是在给b赋值11的同时,a的值也发生了永久性的变化,变成了11,这就叫做副作用,如果写成这样:int b = a + 1;
这样就没有副作用了。
接下来我们看一个带有副作用的宏参数的实例:
#define MAX(x, y) ((x)>(y) ? (x) : (y))
int main()
{
int a = 4;
int b = 5;
int c = MAX(a++, b++);
printf("c = %d\n", c);
printf("a = %d\n", a);
printf("b = %d\n", b);
return 0;
}
出现这种结果的原因在于:宏参数是不加运算进行替换的,所以会把副作用传到宏定义中,下面来看看预处理阶段替换后的结果:
与宏参数不同的是:函数的实参会经过运算把最终的结果传递给形参
int GetMax(int x, int y)
{
return x > y ? x : y;
}
int main()
{
int a = 4;
int b = 5;
//int c = MAX(a++, b++);
int c = GetMax(a++, b++);
printf("c = %d\n", c);
printf("a = %d\n", a);
printf("b = %d\n", b);
return 0;
}
🔖宏和函数的对比
宏通常被应用于执行简单的运算,主要有以下两个方面的原因:
- 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹。
- 更重要的是函数的参数必须声明为特定的类型,所以函数只能在类型合适的表达式上使用。而宏参数是与类型无关的,宏可以适用于整型、长整型、浮点型等可以用于>来比较的类型
宏的缺点:
- 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度
- 宏是没法调试的(调试是在已经生成可执行程序后进行的,宏早已被替换)
- 宏由于与类型无关,也就不够严谨
- 宏可能带来运算符优先级的问题,导致程序容易出现错误
宏有时候可以做函数做不到的事情,比如:宏的参数可以出现类型,但是函数做不到,例如:
#define MALLOC(type, num) (type*)malloc(sizeof(type)*num)
int main()
{
int* arr = MALLOC(int, 10);
char* str = MALLOC(char, 10);
float* ret = MALLOC(float, 10);
return 0;
}
将宏和函数做如下对比:
属性 | #define定义宏 | 函数 |
---|---|---|
代码长度 | 每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅增长 | 函数代码只出现于一个地方,每次使用这个函数时,都调用那个地方的同一份代码 |
执行速度 | 更快 | 存在函数的调用和返回的额外开销,所以相对慢一些 |
操作符优先级 | 宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的结果,所以建议宏在书写的时候不要吝啬括号 | 函数的参数只在函数调用的时候求一次值,它的结果值传递给函数。表达式的求值结果更容易预测 |
带有副作用的参数 | 参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果 | 函数参数在传参的时候求值一次,结果更容易控制 |
参数类型 | 宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型 | 函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使它们执行的任务是相同的 |
调试 | 宏是不方便调试的 | 函数是可以逐语句进行调试的 |
递归 | 宏是不能递归的 | 函数是可以递归的 |
总结:代码逻辑比较简单可以写成宏,如果代码逻辑比较复杂就写成函数。
🔖命名约定
- 把宏名全部大写
- 函数名不要全部大写
🔖#undef
这条指令用于移除一个宏定义,例如:
#define MAX 100
int main()
{
printf("%d\n", MAX);
#undef MAX
printf("%d\n", MAX);//这里的MAX就成未定义了
return 0;
}
📖命令行定义
许多C的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。例如:当我们根据同一个源文件要编译出一个程序的不同版本的时候,这个特性很有用处。(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大一些,我们需要一个大一点的数组)
#include <stdio.h>
int main()
{
int arr[SIZE];//这里并没有给SIZE任何初值
for(int i = 0; i < SIZE; i++)
{
arr[i] = i;
}
for(int i = 0; i < SIZE; i++)
{
printf("%d ",arr[i]);
}
return 0;
}
通过下面这条指令对这段代码进行编译gcc text.c -D SIZE=10 -o text.exe
。
这就是在命令行中指定某些符号的大小,指定大小后的替换动作也是在预处理阶段完成的,我们可以看看预处理后得到的文件
📖条件编译
借用条件编译指令,我们在编译一个程序的时候可以对一条语句或者一组语句选择性的进行编译。举个🌰:
#define PRINT s//只要定义了就行,可以是任何数字或者字母,甚至没有也可以
int main()
{
#ifdef PRINT
printf("hehe\n");//只要PRINT定义了,这段代码就可以被编译,没定义就不能被编译
#endif
return 0;
}
#ifdef
和endif
必须是成对出现的。只要#ifdef
后面的标识符被定义了,那么#ifdef
和endif
之间的代码就可以被编译,反知则不能被编译。
上图中的源文件中没有对PRINT
的定义,所以经过预编译,#ifdef
和endif
之间的代码块就会被删掉。
常见的条件编译指令:
- 单分支
#if 常量表达式
//...
#endif
//常量表达式由预处理器求值,
//值为真编译中间的代码块,否则不编译
- 多分支的条件编译
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif
- 判断是否被定义
#if defined(symbol)//判断symbol是否被定义
//...
#endif
#ifdef symbol//和上面的语句等价,判断symbol是否被定义
//...
#endif
#if !defined(symbol)//如果定义了symbol则不编译下面的代码块
//...
#endif
#ifbdef symbol//如果定义了symbol则不编译代码块
//...
#endif
- 嵌套指令
#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
📖文件包含
对于#include
相信大家都不陌生,这条指令可以使另外一个文件被编译,就像它实际出现于#include
指令的地方一样。这种替换方式很简单,预处理器先删除这条指令,并用包含文件的内容替换,这样一个源文件被包含几次,就会被编译几次。
🔖头文件被包含的两种方式
- 本地文件包含
#include "filename"
查找策略: 先在源文件所在目录下找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件,如果找不到就提示编译错误。
- 库文件包含
#include <filename.h>
查找策略: 查找头文件直接去标准路径下查找,如果找不到就提示编译错误。这意味着:对库文件的包含也可以用双引号,但是这样查找的效率就低一些,当然这样也不容易区分是库文件还是本地文件了。
🔖头文件被重复包含
上面提到过,一个头文件被包含几次,就会被编译几次,就像下面这样:
当然,大家在写代码的时候应该不会出现上面这种极端情况,在一个源文件里边显式的把一个头文件包含多次。我们可能遇到的是:a包含了b,c也包含了b,d包含了a和c,那这样一来d就间接的把b给包含了两次。如何解决这种问题呢?有以下两种方案:
- 条件编译
#ifndef _THE_TEXT_//如果_THE_TEXT_没有被定义就编译下面的代码,否则就不编译
//第一次包含的时候,由于_THE_TEXT_没有定义,所以会编译下面的代码
//当第二次包含的时候,由于第一次包含已经定义了_THE_TEXT_,第二次就不再编译
#define _THE_TEXT_
struct S
{
int a;
char c;
};
#endif
可见此时虽然包含了三次,但是预处理的时候只拷贝了一份,这将大大的降低代码冗余。
#pragma once
#pragma once
也可以解决头文件被包含多次的问题,比条件编译更简洁,只需要这一条预处理指令:
#pragma once
struct S
{
int a;
char c;
};
📖模拟实现offsetof
#define OFFSETOF(type, number) (size_t)&(((type*)0)->number)//把0变成一个结构体变量的地址,找到其中的成员再取地址,就是偏移量
struct S
{
char a;
int b;
char c;
};
int main()
{
printf("%d\n", OFFSETOF(struct S, a));
printf("%d\n", OFFSETOF(struct S, b));
printf("%d\n", OFFSETOF(struct S, c));
}
📖交换一个二进制数的奇数位和偶数位
#define SWAP(x) (((x&0x55555555)<<1)+((x&0xaaaaaaaa)>>1))
//(x&0x55555555)<<1:保留所有的奇数位,然后左移一位
//(x&0xaaaaaaaa)>>1:保留所有的偶数位,然后右移一位
int main()
{
int a = 10;
int b = SWAP(a);
printf("%d\n", b);
return 0;
}
今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,您的支持就是春人前进的动力!