文章目录
- 一、#和##
- 1.#运算符
- 2.##运算符
- 二、预处理指令#undef
- 三、条件编译
- 1.单分支条件编译
- 2.多分支条件编译
- 3.判断符号是否被定义
- 4.判断符号是否没有被定义
- 四、头文件的包含
- 1.库头文件的包含
- 2.本地头文件的包含
- 3.嵌套包含头文件的解决方法
- 使用条件编译指令
- 使用预处理指令#pragma
- 五、其它预处理指令
- 六、C语言更新结束感言
一、#和##
1.#运算符
这里的#不是#include和#define前面的那个#,它是一个运算符,这个运算符将宏的⼀个参数转换为字符串字⾯量,它仅允许出现在带参数的宏的替换列表中,#运算符所执⾏的操作可以理解为”字符串化“
在讲解这个运算符之前我们先来看一段代码,如下:
#include <stdio.h>int main()
{printf("hello world!\n");printf("hello"" world!\n");return 0;
}
这里的第二个printf中的字符串由两个字符串组成,这样写和第一种printf的效果有什么不同呢?我们来看看代码运行结果:
可以看到,两个printf打印出来的内容是一致的,所以我们可以得出,如果两个字符串挨着一起写,会实现字符串的合并
为了讲清楚#运算符,我们先来看看这样一个例子,我们定义一个变量a = 3,然后我们想要在屏幕上打印 the value of a is 3,该怎么做呢?经过前面的学习,我们已经可以直接写出来了,如下:
int a = 3;
printf("the value of a is %d\n",a);
这个时候我们随便更改a的值都可以使得这句话是正确的,它会随着a的改变而改变,然后我们这个时候说,再创建几个变量,也要以这种形式进行打印,那么每打印一次我们都要写这么长的一串,有没有什么办法简化一下呢?
这个时候我们就可以定义一个宏来解决,在上面那个字符串中,有三个变化的地方,一个就是of后面的a,还有就是占位符%d,然后就是最后面的a,那么我们就可以定义一个宏PRINT,它的参数就是我们要打印的那个值和占位符,如下:
#define PRINT(x , format) printf("the value of x is %d\n",x);
首先我们写出来这个宏后可以发现最后面的那个x会随着我们提供的变量变化,所以已经解决了,还有两个地方要处理,就是of后面的x和后面的占位符,因为它们都在字符串中,所以替换的时候不会替换它们,那么我们怎么办呢
对于占位符的替换,我们可以使用上面学习的两个字符串可以合并的思想,如下:
#define PRINT(x , format) printf("the value of x is "format"\n",x);
这样,前面的the value of x is 就变成一个字符串,后面的\n也变成了一个字符串,这个时候只需要我们传参的时候传上我们的占位符就可以了,如下:
int a = 3;
PRINT(a, "%d");
经过传参后format会被替换成"%d",又是一个字符串,前面一个,中间一个,后面一个字符串,最后合并成了一个字符串
然后就是最后一个要解决的问题,就是of后面的x,我们怎么能够使得它随着我们的传参变化而变化呢?这个时候就要请出我们的#运算符了,它可以使得宏的参数字符串化,只需要将x左右的字符串分开,然后在它前面加上#即可,如下:
#define PRINT(x , format) printf("the value of "#x" is "format"\n",x)
这里将原本的参数x字符串化了,最后也变成了一个字符串,和其它几个字符串合并在了一起,但是这个时候x的内容就会随着我们的传参改变而改变了,比如把变量a传给x,那么of后面就是a,把变量b传给x,那么of后面就是b
然后我们来看看完整代码:
#include <stdio.h>#define PRINT(x , format) printf("the value of "#x" is "format"\n",x)int main()
{int a = 3;float b = 5.1f;PRINT(a, "%d");PRINT(b, "%f");return 0;
}
运行结果:
这样我们就使用#运算符实现了参数的字符串化,#运算符基本上用不到,但是我们还是要了解一下,毕竟它还是挺有趣的
2.##运算符
##运算符又是一个完全不同的运算符了,#运算符的作用是是参数字符串化,##运算符则是可以把位于它两边的符号合成⼀个符号,它允许宏定义从分离的⽂本⽚段创建标识符。##被称为记号粘合运算符
比如有一个变量class115,我们就可以通过class和115的粘合来得到,虽然看起来没有意义,但是我们这里也只是举例,后面我们会使用##运算符实现一个很有趣的功能
现在我们就来使用class115这个来举个例子,我们定义一个宏,它的作用就是帮我们粘合两个符号,如下:
#define CAT(x,y) x##y
CAT这个宏就可以帮助我们粘合我们传过去的参数x和y,比如我们传参class和115,那么它就可以帮我们粘合成class115,如下:
CAT(class,115);
//经过预处理后变成
class115
我们现在写一段代码测试一下,看看它能否实现我们的要求:
#include <stdio.h>#define CAT(x,y) x##yint main()
{int class115 = 5;printf("%d\n", CAT(class, 115));//这里CAT(class,115)相当于class115//因为CAT的作用就是粘合两个符号//这句话就变成了printf("%d\n",class115)//会直接打印5return 0;
}
接下来我们来看看代码运行结果:
可以看到这里确实实现了我们的想法,将class和115两个符号粘合到了一起,组合成了变量名class115
上面我们举的例子很简单,甚至有点大聪明,因为我们只是想要说明##运算符的作用,就是起到两边符号的粘合作用,接下来我们就来实现一些有趣的宏
我们就以求最大值这个函数为例,当我们要找出两个整型数据的最大值时,我们需要一个函数,当我们要找出两个浮点型数据的最大值时,又需要一个函数来实现,但是其实这两个函数的实现内容是非常一致的,如下:
int int_max(int x, int y){return x>y?x:y;}float float_max(float x, float y){return x>yx:y;}
现在我们就来写一个宏,宏名为:GENERATE_MAX,这个宏的作用就是,根据我们传过去的数据类型,自动生成一个找两个数据中最大值的函数,它们的形式类似于:类型_max,这个时候我们就可以用到我们的##运算符,如下:
#define GENERATE_MAX(type) \
type type##_max(type x, type y) \
{\return x>y?x:y; \
}
上面就是我们写出的创建函数的宏,它可以根据参数type来确定函数名和函数类型,其中的\是续航符,可以使内容看起来隔开了,实际上是连贯起来的
这里我们也使用到了##运算符,如果我们单纯写一个type_max,那么在处理时会把它当作一个整体,每次我们创建的函数名就都是type_max了,而不会根据type的变化生成不同的函数名
而如果我们使用了##运算符就可以使得type成为一个独立的参数,而后面的_max就是粘合的符号,这样type这个类型在变化,我们创建的函数名也就跟着变化了
现在我们就使用这个宏来分别创建一个比较整型最大值和浮点型最大值的函数,如下:
GENERATE_MAX(int)
GENERATE_MAX(float)
接着在VS2022中,我们将鼠标指向这个宏,随后我们就可以看到我们使用这个宏后会发生什么,可以看出来这两条语句经过处理后会替换成两个函数,如下图:
如上图,从扩展到后面的信息可以看出,经过预处理后,这个宏会被替换成两个名为int_max和float_max的函数,实现求最大值的功能,是不是特别神奇呢?代码可以写的这么有趣
接着我们就赶紧使用一下这两个函数来验证一下是否正确,如下:
#include <stdio.h>#define GENERATE_MAX(type) \
type type##_max(type x, type y) \
{\return x>y?x:y; \
}GENERATE_MAX(int)
GENERATE_MAX(float)int main()
{int a = 4;int b = 5;float c = 5.3f;float d = 6.2f;int ret1 = int_max(a, b);float ret2 = float_max(c, d);printf("%d %f\n", ret1, ret2);return 0;
}
我们来看看运行结果:
可以看到我们使用##运算符写的宏确实帮我们生成了两个不同类型的最大值函数,并且函数名也随着类型进行改变,是不是非常美,非常神奇呢
二、预处理指令#undef
#undef指令的作用是移除一个#define的定义,它的使用格式如下:
#undef NAME
其中的NAME就是我们要移除的宏的名称,当我们想要更换一下宏名的定义时,就可以使用#undef指令先移除它原本的定义,然后再重新定义它,如下:
#include <stdio.h>
#define N 100int main()
{ printf("%d\n", N);
#undef N
#define N "hello"printf("%s\n", N);return 0;
}
这里我们首先使用#define将N定义为了100,将它打印后我们想要改变它的定义,就使用#undef把它原本的定义移除,然后将它重新定义成了一个字符串,然后再重新打印,我们来看看它的运行结果:
三、条件编译
条件编译有点类似于我们的分支语句,不过条件编译是在预处理阶段进行的,它会根据我们的条件来决定是否编译某些语句,接下来我们就来学习条件编译
1.单分支条件编译
单分支条件编译就是我们只有一条分支需要进行条件判断,使用格式如下:
#if 常量表达式
//如果条件为真那么就编译这里的语句
//如果条件为假就不会编译这里的语句
#endif
//结束的标志
可以看到单分支条件编译从#if开始,然后再#endif结束,它和分支语句类似,但是它有一个结束标志,就是#endif,这是分支语句中没有的
如果条件为真,那么就会编译中间的语句,也就是说最后会执行那些语句,如果为假则不会执行,但是这个条件需要一个常量表达式,我们等下来解释为什么不能使用变量,现在我们可以先来测试一下#if和#endif,如下:
#include <stdio.h>#define N 5int main()
{
#if N == 6printf("hello\n");
#endifreturn 0;
}
这段代码很简单,最后运行它应该什么都不会打印,因为我们定义的N是5,这里当然不等于6,所以不会编译语句printf(“hello\n”),也就不会执行它,那么经过预处理后这条语句跑哪里去了呢?
如果条件编译的结果为假,那么条件编译中的语句经过预处理后会被直接删除,就像我们的注释一样,也是经过预处理后直接删除,所以后面编译就不会带上条件编译中的语句,最后运行生成的可执行程序也就不会执行这段语句
现在我们来看看这段代码的运行结果:
现在我们再回到之前的那个问题,为什么#if后面必须跟一个常量表达式,不能是变量呢?这是因为#if是在预处理阶段进行处理的预处理指令,在预处理阶段还没有给变量分配空间,也就是变量在这个阶段都不存在,自然不能使用变量了,只能使用常量
2.多分支条件编译
多分支条件编译也与分支语句中的多分支语句原理差不多,我们来看看在多分支条件编译中需要用到哪些语句:
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif
//...
这里我们列出的结构就是多分支条件编译的结构,这样看有点陌生,我们拿分支语句中的多分支语句跟它们进行一一对应,来进行类比学习:
#if -- if
#elif -- else if
#else -- else
#endif -- 分支语句中没有,是条件编译结束的标志
这样看是否就简单多了,它们的用法都差不多,只是条件编译在预处理阶段进行处理,不能使用含变量的表达式,我们现在就来看一个例子,看看它的结果是什么,如下:
#include <stdio.h>#define N 10int main()
{
#if N == 5printf("hehe\n");
#elif N == 10printf("haha\n");
#elseprintf("hello\n");
#endifreturn 0;
}
可以先自行思考,这里我们直接给出答案,最后程序只会打印haha,首先第一个#if中判断N是不是5,很明显不是,所以hehe将不会被打印,然后到了#elif,判断N是否等于10,条件成立,最后就会打印10,也就不会走到#else,它的内容也就不会被打印
我们来看看代码运行结果:
3.判断符号是否被定义
在编译⼀个程序的时候我们如果要将⼀条语句(⼀组语句)编译或者放弃编译,就可以使用条件编译,比如调试性的代码删掉很浪费,保留又很碍事,我们就可以使用条件编译,在编译的时候不编译这些调试性的代码
在这里我们就可以使用一个技巧,在最开头使用#define定义一个符号,如果我们没有注释或者删除这个符号,那么我们就可以编译里面的调试性代码,进行正常调试,如果我们注释或者删除这个符号,那么我们就不编译里面的调试性代码,不影响代码的正常运行
在实现这个功能之前,我们先来学习如何判断一个符号是否被定义,有两种方式:
- 使用#if defined进行判断:从字面意思来也很容易理解,判断符号是否已经被定义,它的使用格式如下:
#if defined(符号)
#if defined后面的小括号里面就要写上要判断是否被定义过的符号
- 使用#ifdef进行判断:#ifdef实际上就是#if defined的缩写,只是缩写后它的使用方法有点不同,如下:
#ifdef 符号
在使用#ifdef就不再需要小括号了,而是直接在后面写上我们要判断是否被定义过的符号
现在我们学习了如何判断一个符号是否被定义过,现在就来实现一下上面的我们提出的功能,首先我们定义一个符号DEBUG来表示调试,当我们注释掉它的时候,调试信息跟着一起不会执行了
我们现在就引入一个场景,使用循环往数组里面存放信息,为了保证我们往数组里存放数据成功了,我们每存放一次数据就将它打印一次,这个打印就是我们的调试信息,为了检查我们是否成功往数组存放信息的调试性代码
现在我们就来看这样一个场景,如何使用#ifdef或者是#if defined,如下:
#include <stdio.h>#define DEBUG
//debug的意思是调试int main()
{int arr[5] = { 0 };for (int i = 0; i < 5; i++){//往数组存放数据arr[i] = i + 1;
#ifdef DEBUG//等价于#if defined(DEBUG)//打印一下数据,看看数据是否存放进去了printf("%d ", arr[i]);
#endif}return 0;
}
如果我们定义过DEBUG这个符号,那么就会执行调试性语句printf("%d ", arr[i]),这里我们定义了DEBUG这个符号,那么代码就会编译中间的调试性语句,我们来看看代码运行结果是否是这样的:
可以看到数据被打印出来了,说明这个调试性的语句参与编译了,现在我们把定义DEBUG这个符号的语句注释掉,看看数组中的数据还会不会被打印:
可以看到数组中的数据没有被打印了,也就是中间调试性的语句没有被编译和执行,于是我们就通过#ifdef或者#if defined实现根据符号是否被定义,来确实是否编译代码了
4.判断符号是否没有被定义
这里我们就简单介绍一下判断符号是否没有被定义的两个方法,不再举例了,因为它和上面的判断符号是否被定义用法差不多
- 使用#if !defined:这个条件编译语句就是在上面我们讲过的#if defined的defined前加上一个!,表示否定,所以这个条件编译语句就是判断符号是否没有被定义,格式还是和#if defined一致,这里就不再赘述了
- 使用#ifndef:这个条件编译语句就是在上面我们讲过的#ifdef的def前面加上一个n,表示no,也是否定含义,所以这个条件编译语句就是判断符号是否没有被定义,格式还是和#ifdef一致,这里就不再赘述了
这里我们就不再举例使用了,因为我们后面讲到包含头文件还会用到它们,在那里的使用就更加常见了,我们耐心往下看
四、头文件的包含
头文件的包含的本质就是拷贝,当我们包含一个头文件后,会直接将头文件的内容拷贝过来,接下来我们就来学习头文件的包含方式,以及嵌套包含头文件时,如何解决代码冗余的问题
1.库头文件的包含
库头文件里面包含了C语言帮我们实现的功能,我们只需要包含库头文件就可以使用相应的功能和函数,如标准输入输出头文件stdio.h,我们在包含这种库头文件时,一般会使用尖括号<>来进行包含,如下:
#include <stdio.h>
当我们使用<>来进行包含头文件时,程序会直接去我们IDE的标准路径下去查找,如果找不到就提示编译错误
2.本地头文件的包含
本体头文件就是我们自己写的头文件,比如add.h,这种手动实现的头文件,我们在包含这种本体头文件时常常使用双引号""来进行包含,如下:
#include "add.h"
那么程序就会先在源⽂件所在⽬录下查找,如果该头⽂件未找到,编译器就像查找库函数头⽂件⼀样在标准位置查找头⽂件
那么问题来了,我们可不可以使用双引号"“这种方式来包含库头文件,答案是可以,那么为什么我们还要使用<>来包含库头文件,而不是统一使用双引号来包含头文件
这可能是我们平常写代码没有涉及到的原因,我们平常写代码最多创建3到5个头文件,所以使用双引号包含库头文件也没什么影响,但是如果在一个大型工程中,创建了3到5万个头文件呢?
如果使用”“包含库头文件,那么每次都要去当前文件夹里找这个头文件,但是我们知道实际上不可能找得到,然后再去标准路径找这个头文件,我们平常使用的头文件较少,可能影响不大, 但是在大型工程中,这多余的步骤将会造成不小的开销
所以我们还是要养成良好的习惯,包含库头文件使用尖括号<>,包含本地头文件使用双引号”"
最后我们来总结一下使用尖括号<>包含头文件和使用双引号""包含头文件的不同:
- 尖括号<>包含头文件的查找策略:直接去标准路径下去查找,如果找不到就提⽰编译错误
- 双引号""包含头文件的查找策略: 先在源⽂件所在⽬录下查找,如果该头⽂件未找到,编译器就像查找库函数头⽂件⼀样在标准位置查找头⽂件
3.嵌套包含头文件的解决方法
在最开始我们提到了,我们包含头文件的本质就是进行代码的拷贝,将头文件的所有内容拷贝进我们的源文件,那么我们如果嵌套包含,也就是可能多次包含了同一个头文件多次,势必会造成代码的冗余
我们先来看看包含一个头文件多次可能的场景,如图:
在上图场景中,我们每个功能实现都包含了头文件add.h,最后进行汇总时势必会包含三次头文件add.h,造成代码的冗余
可能有的同学就提出疑问了,一般头文件也不会有太多内容呀,就算多拷贝几次也还好啊,其实不然,我们一般会在本地头文件包含我们需要使用的库头文件,就拿最常使用的头文件stdio.h来举例,我们来看看这个库头文件有多少行代码
方法就是正常包含头文件stdio.h,然后使用ctrl加单击的方法就可以点进这个头文件了,我们拉到最后发现它居然有两千多行代码,如下图:
那么如果此时我们多次包含了这个头文件,势必会造成代码冗余,所以我们要想办法来解决这个问题
使用条件编译指令
我们可以使用刚刚学习的条件编译指令解决,具体就是#ifndef或者#if !defined这两个指令,这里我们就选择#ifndef,比较好写
由于头文件的包含就是代码的拷贝,所以我们可以根据这个特点来设计一个功能,就是:一旦包含头文件,我们就判断是否定义了某个符号,如果没有定义我们就定义一下它,然后执行后面的头文件包含,如果这个符号已经被定义了那么就跳过头文件的包含不执行,如下:
#ifndef __TEST_H__#define __TEST_H__//头⽂件的内容#endif //放到头文件最后
这个思路是不是很妙呢?这就是我们避免头文件嵌套包含的第一种方法
使用预处理指令#pragma
这种方法就更为简单了,我们可以在VS2022上创建一个头文件,我们发现头文件中自动包含了一条语句,如下:
我们可以看到,一创建头文件,这条语句就出现了,这就是今天要介绍的第二个方法,在头文件开头写下预处理指令#pragma once,那么就可以解决头文件嵌套包含的问题,是不是特别简单呢?
当然,在VS上帮我们自动写上这条指令了,如果在其它编译器上创建头文件没有这条语句,那么我们直接加上就可以了,也十分简单
五、其它预处理指令
除了我们本文介绍的预处理指令,其实还有非常多的预处理指令,把它们讲完也不太现实,所以我们就只讲了一些常用的预处理指令
如果还想要学习更多的预处理指定就需要大家自己去自行了解了,这里我就推荐一本书,想要深入学习预处理指令就可以参考一下这本书:《C语⾔深度解剖》
六、C语言更新结束感言
那么我们C语言的学习就到这里就结束了,一路走来也真是不容易,不知道大家是否有收获呢?如果有的话也不枉我这么努力的更新
从下一篇文章我们就开始学习数据结构了,在里面我们会手动实现那些数据结构,可以体会到二级指针和递归的暴力美学,狠狠期待一下吧!
那么今天就到这里,感谢观看,有疑问欢迎提出
bye~