• C语言进阶——程序环境和预处理


    🌳🌲🌱本文已收录至:C语言——梦想系列

    更多知识尽在此专栏中!

    🎉🎉🎉欢迎点赞、收藏、关注 🎉🎉🎉

    目录

    🌳前言

    🌳正文

    🌲1、程序环境

       🌱1.1、翻译环境

    🪴1.1.1、预编译

    🪴1.1.2、编译

    🪴1.1.3、汇编

    🪴1.1.4、链接

    🪴1.1.5、关于操作指令

       🌱1.2、运行环境

    🪴1.2.1、执行

             ——————分割线——————

    🌲2、预编译

       🌱2.1、预定义符号

       🌱2.2、#define

    🪴2.2.1、定义标识符常量

    🪴2.2.2、定义宏

    🪴2.2.3、#define 替换规则

    🪴2.2.4、# 与 ##

    🪴2.2.5、带有副作用的宏参数

    🪴2.2.6、宏定义命名约定

       🌱2.3、宏与函数的比较

    🪴2.3.1、代码长度

    🪴2.3.2、执行速度

    🪴2.3.3、操作符优先级

    🪴2.3.4、带有副作用的参数

    🪴2.3.5、参数类型

    🪴2.3.6、能否调试

    🪴2.3.7、能否递归

    🪴2.3.8、结论

       🌱2.4、#undef 移除宏定义

       🌱2.5、命令行定义

       🌱2.6、条件编译

    🪴2.6.1、单分支条件编译

    🪴2.6.2、多分支条件编译

    🪴2.6.3、判断是否定义过宏

    🪴2.6.4、嵌套使用条件编译

       🌱2.7、文件包含

    🪴2.7.1、自定义头文件的包含

    🪴2.7.2、库函数头文件的包含

    🪴2.7.3、避免多次展开同一头文件

    🌳总结


    🌳前言

      在C/C++中,所有的代码在输出结果前都需要经过这五个阶段:预编译—>编译—>汇编—>链接—>执行代码。其中前四个阶段是在翻译环境下进行,因为在翻译环境中有编译器链接器这两个重要工具,二者配合能将文本形式的代码转化为对应的二进制代码和可执行文件;而最后一个阶段是在执行环境中进行的,代码在这个阶段已经打包好了,只需要执行器运行此代码,结果就能很好的输出。可以看出,整个代码运行逻辑是极其严谨和巧妙的。除程序环境外,C/C++在预处理阶段还有各式各样的预处理指令等着我们去发掘,一起来看看吧!

    本文主要分为两部分:程序环境讲解和预处理指令详解,其中程序环境需要在Linux环境下用gcc编译器展示,光是环境配置就比较麻烦,因此这部分会偏向于理论知识,不需要去实践,理解性记忆就好了;预处理指令在VS上就能展示,这部分知识偏向于实践,篇幅会比较长


    🌳正文

    🌲1、程序环境

       🌱1.1、翻译环境

      翻译环境的主要目标是把 test.c 转化为 test.exe,其中 test.c 需要先经过预编译转为 test.i ,然后再经过编译转为 test.s ,接着经过汇编翻译为 test.o ,最后再由链接器将目标文件 test.o 与其他目标文件、库函数文件链接后生成可执行程序 test.exe。其中前三步由编译器完成,最后一步由链接器完成(这两个工具已经集成于VS中了),每个不同的源文件都需要分开编译,最后由链接器合并,下图很好的演示了整个翻译过程,当然更详细的在后面。

    🪴1.1.1、预编译

    预编译阶段会干这些事:

    • 1.包含头文件
    • 2.删除注释
    • 3.替换 #define 定义的符号

      干完这些事后会生成一个 .i 文件,此时的文件仍然是C语言形式的文本文件,举个例子(通过其他手段在VS中演示,相关链接:VS 如何查看预处理后的文件?

      下面是源代码

    1. #define _CRT_SECURE_NO_WARNINGS 1
    2. #include
    3. #define MAX 100
    4. //测试预编译阶段
    5. int Add(int x, int y)
    6. {
    7. return x + y;
    8. }
    9. int main()
    10. {
    11. int x = 10;
    12. int y = 20;
    13. int z = Add(x, y);
    14. printf("%d\n", z + MAX);
    15. return 0;
    16. }

      下面是经过预编译后生成的 .i 文件

    此时的代码我们还能看懂 ,还是普通C语言代码

    🪴1.1.2、编译

    编译阶段干的事比较多:

    • 1.语法分析
    • 2.词法分析
    • 3.语义分析
    • 4.符号汇总

      经过上述步骤后,可以把文本代码转成成汇编代码,即后缀为 .s 的汇编文件,此时代码我们已经看不懂了,文件格式为 elf,需要用其他工具来解析查看此文件,这里就不展示了。

      如果想要深究,推荐《编译原理》这本书

    编译阶段需要注意的是符号汇总这个操作,此操作会把各种符号汇总,方便后续符号表的形成。

    🪴1.1.3、汇编

    汇编阶段:

    • 把已经生成的汇编指令转换成二进制指令
    • 形成符号表

      最终生成 .o 目标文件,此时的文件格式仍然为 elf

      比如上面的代码,会生成这两个符号表:

    🪴1.1.4、链接

    链接阶段,会干这两件事:

    • 1.合并段表
    • 2.将符号表进行合并和重定位

      如果在合并符号表后,发现信息不匹配,就会报错,原因为某些函数不存在。链接完成后,会生成一个.exe 可执行文件,最终交给执行器运行代码就行了。

    🪴1.1.5、关于操作指令

      在 Linux 环境下使用 gcc 编译代码(假设源文件为 test.c ):

    • 1.输入 gcc -E test.c -o test.i  可以把预编译阶段生成的代码放到 test.i 这个文件中
    • 2.输入 gcc -S test.c -o test.s 可以将编译阶段生成的汇编代码放到 test.s
    • 3.输入 gcc -c test.c -o test.o 可以把汇编阶段生成的二进制代码放到 test.o

      关于查看利用 VIM 查看 elf 格式的文件

    VIM学习资料

       🌱1.2、运行环境

    🪴1.2.1、执行

      此时的代码已经变成了一个.exe 可以执行程序 ,在 Windows 下双击也能直接打开

      运行环境中需要注意

    • 1.程序必须载入到内存中
    • 2.找 main 函数后,开始执行程序
    • 3.程序运行时,会调用一个运行堆栈,存储局部变量和返回地址等信息,主函数在堆栈中
    • 4.程序终止后,有两种情况:正常结束和异常终止
    • 5.推荐优质书籍《程序员的自我修养》

             ——————分割线——————

    🌲2、预编译

      下面来介绍一下本文的重头戏:各种预编译指令,预编译是一个强大的工具,要学会使用。

       🌱2.1、预定义符号

    首先介绍下C语言中内置的几个预定义符号

    __FILE__当前进行编译的源文件
    __LINE__当前代码所在的行数
    __DATE__当前代码被编译的日期
    __TIME__当前代码被编译的时间
    __STDC__如果遵循ANSI C 标准,为1
    1. printf("%s\n", __FILE__);//打印当前编译源文件信息
    2. printf("%d\n", __LINE__);//打印当前的行数,为24
    3. printf("%s\n", __DATE__);//打印当前的日期,现在是10月25日
    4. printf("%s\n", __TIME__);//打印当前时间,为20:39
    5. //printf("%d\n", __STDC__);//这个用不了,VS中没定义

       🌱2.2、#define

      #define 定义的符号,在翻译环境中的预编译阶段,会被替换。

      #define 的知识就比较丰富了,之前在初始C语言中,我们已经见过了 #define 定义的宏实现加法

    #define ADD(x,y) ((x) + (y))	//#define 定义两个数加法宏

      在三子棋和扫雷中,还见过 #define 定义标识符常量,有效避免了大小固定的问题

    1. #define ROW 3
    2. #define COL 3 //#define 定义标识符常量

      这两个功能是 #define 最基础的功能,除此之外,#define 还能干很多事,一起来看看吧:

    🪴2.2.1、定义标识符常量

    语法:

    • #define name stuff       

    // name 是定义的标识符   

    // stuff 是待定义的常量

       举个栗子:

    1. //#define 定义标识符常量
    2. #define YEAR 2022
    3. #define MONTH 10
    4. #define DAY 15
    5. int main()
    6. {
    7. printf("今天是%d年%d月%d号\n", YEAR, MONTH, DAY);
    8. return 0;
    9. }

    最终输出结果为:今天是2022年10月15号 

      错误示例

    1. //#define 定义标识符常量
    2. #define YEAR 2022; //错误示范,在定义后加 ; 号
    3. #define MONTH 10; //除非是特殊需求,否则是不会加 ; 号的
    4. #define DAY 15; //现在代码连编译阶段都过不去
    5. int main()
    6. {
    7. printf("今天是%d年%d月%d号\n", YEAR, MONTH, DAY);
    8. return 0;
    9. }

     结果:        没有结果,代码编译错误

    此时的代码相当于

    	printf("今天是%d年%d月%d号\n", 2022;, 10;, 15;);    //莫名其妙多了几个分号

    这代码能运行,那肯定编译器睡着了~

    注意事项:

    • #define 定义标识符常量时,顺序不要写反了,先写标识符,再写常量值
    • #define 定义标识符常量时,不能在后面加 ; 号,这是非常坑爹的写法! 

    🪴2.2.2、定义宏

      #define 定义符号时,不带参数时是在定义标识符常量,带参数时就是在定义宏(有点像函数),关于宏和函数的比较,后面会专门讲(很详细!)

    语法:

    • #define name( parament-list ) stuff

    //name 是宏名

    //parament-list 是参数表,可以是单个或多个,多个需要用 , 号隔开

    //stuff 是宏功能实现的主体

    注意事项:

    • name 旁边的 ( 必须与 name 紧紧相连,如果有空格,那么(parament-list )stuff 会被解释为一个 struff 

      来看个问题例子

    1. //#define 定义宏
    2. #define MUL(x,y) x * y //宏定义乘法,有瑕疵
    3. int main()
    4. {
    5. printf("%d\n", MUL(1 + 2, 3 + 4));
    6. return 0;
    7. }

    结果:        11 

    并不是预想中的7,原因很简单,宏定义时,是直接替换的,此时代码是这个样子

    	printf("%d\n", 1 + 2 * 3 + 4);    //直接替换后的样子
    

    如何避免使用宏时出现类似问题呢?

    答:勤加括号 

    此时的宏定义可以优化为:

    #define MUL(x,y) ((x) * (y))	//宏定义乘法,完美版
    

     注意事项:

    • 所有用于对数值表达式进行求值的宏定义都应该勤加括号,避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料的相互作用

    🪴2.2.3、#define 替换规则

    来简单总结一下 #define 的替换规则

    • 1.当宏在进行替换时,会对其中的参数进行检查,看是否有 #define 定义的符号,如果有的话,先优先替换参数
    • 2.替换文本会被插入到程序中原来文本的位置;对于宏,参数名被他们的值所替换
    • 3.最后,再对结果文件进行扫描,看看是否还有 #define 定义的符号,如果有的话,就重复上述步骤

    注意:

    • 1. 宏的参数和 #define 定义中可以出现其他 #define 定义的符号,也就是说 #define 可以嵌套使用,但要合法。对于宏,不能使用递归。
    #define ADD(x,y) ((ADD(x, y)), y)	//定义宏时,不允许出现递归!

    结果: 运行出错,显示第二个 ADD 未定义

    • 2. 当预处理器搜索 #define 定义的符号的时候,字符串常量的内容并不会被搜索。
    #define ABC abcdefg	//预编译搜索替换时,只会搜索标识符 ABC

    🪴2.2.4、# 与 ##

      这是两个比较奇葩的预编译指令,在实际中很少用但是真实存在的

    #

      这个东西比较有意思,就是在宏定义中,把某个参数变成对应的字符串,再配合上 " " 号,就能    插入到后面的字符串中,比如下面这个例子,实现了全数据类型的打印

    1. //奇葩预定义指令 #
    2. //实现全类型数据打印
    3. #define PRINT(format,value) printf("the value of "#value" is "#format"\n",value)
    4. int main()
    5. {
    6. int i = 10;
    7. PRINT(%d, i);
    8. char c = 'a';
    9. PRINT(%c, c);
    10. float f = 5.5f;
    11. PRINT(%.2f, f);
    12. return 0;
    13. }

    结果:the value of i is 10
               the value of c is a
               the value of f is 5.50 

    # 这个东西配合上宏定义和字符串插入的特征,完成了一个函数无法实现的任务 

    tips:对于原字符串 "abc" ,直接在 a 后插入 "123" ,原字符串就变成了 "a123bc"

    ##

      ## 把两个互不相干的符号合成一个符号,然后就能使用这个符号了,但前提是这个符号必须合法(存在且可用),比较奇怪,但也能使用:

    1. //奇葩预处理指令 ##
    2. //实现间接加法,有些费解…………
    3. #define ADD_TO_SUM(num, value) sum##num += value
    4. int main()
    5. {
    6. int sum1 = 0;
    7. ADD_TO_SUM(1, 5);
    8. printf("%d\n", sum1);
    9. return 0;
    10. }

    结果: 5

    代码经过预处理后,变成了这样:

    1. //ADD_TO_SUM(1, 5);
    2. sum1 += 5;
    3. printf("%d\n", sum1);

     结果肯定是 5,其实这个程序就是在计算 sumn 自加某个数后的和,n 指第一个宏参数

     小结

    • 这两个符号一看还挺唬人,但实际也就那样,属于纸老虎类型,理解知道怎么用就行了,因为在实际开发中,是很少有人会这样写代码的,不过当别人写出这样的代码时,我们要能读懂。

    🪴2.2.5、带有副作用的宏参数

      所谓副作用就是指经过宏运算后,会对宏参数本身造成影响,所造成的效果是难以预料的

    1. //带有副作用的宏参数
    2. #define MAX(x,y) ((x) > (y) ? (x) : (y))
    3. int main()
    4. {
    5. int x = 1;
    6. int y = 2;
    7. int z = MAX(x++, y++); //求两数+1后的较大值,有副作用
    8. printf("x = %d y = %d z = %d\n", x, y, z); //x、y的值也发生了改变
    9. return 0;
    10. }

    结果: x = 2, y = 3, z = 3

    仅仅是通过一个宏计算,变量 x、y 的值就发生了改变,如果后续使用这两个变量进行运算时,极有可能会运算错误。为避免出现这种副作用,我们可用将宏传参修改为:

    	int z = MAX(x + 1, y + 1);	//求两数+1后的较大值,无副作用

    注意:

    • 在使用传递宏参数时,不要使用自增/自减的方式传递(函数传参时也不推荐),这样会导致不可预料的后果
    • 当然,在设计宏时也不要出现自增/自减,因为这样也是带有副作用的
    #define ADD(x, y) ((x++) + (y++))	//计算两数+1后的和,有副作用

    🪴2.2.6、宏定义命名约定

      宏和函数都能实现简单逻辑运算,为了将两者区分开,有这样的语法规定:定义宏时,宏名要全部大写定义函数时,函数名不要全部大写

      这是一个宏

    #define ADD(x, y) ((x) + (y))	//宏,实现两数相加
    

      这是一个函数

    1. //函数,实现两个整型相加
    2. int add(int x, int y)
    3. {
    4. return x + y;
    5. }

    可以看出,宏名和函数名是很容易区分的。在实现同一功能时,宏比函数简洁得多,并且宏能适用于所有数据,那么宏与函数究竟有哪些区别?该如何选择呢?下面会告诉你答案: 

       🌱2.3、宏与函数的比较

      这两个东西可以从多个维度进行比较,综合比较结束后,我们就能清楚宏和函数的使用场景了

    🪴2.3.1、代码长度

      首先是代码长度方面,函数会好一些

    宏:宏的原理是直接替换,每次都是直接将宏插入到程序中。除了很短的宏,否则每次调用都会大幅度增加代码的长度

    示例:求三数较大值

    1. //宏定义,求三数中较大值
    2. #define MAX(x, y, z) (((x) > (y) ? (x) : (y)) > (z) ? ((x) > (y) ? (x) : (y)) : (z))
    3. int main()
    4. {
    5. int a = MAX(1, 2, 3);
    6. int b = MAX(4, 5, 6);
    7. int c = MAX(7, 8, 9);
    8. printf("%d %d %d\n", a, b, c);
    9. return 0;
    10. }

    下面是经过预编译后的代码长度

    代码还是比较长的(横向长度),这仅仅是替换了三次,如果替换100次,那就更长了 

    函数:函数只需要一份代码,就能被其他函数随意调用,对代码长度影响不大

    示例:求三数较大值

    1. //函数求三数中较大值
    2. int max(int x, int y, int z)
    3. {
    4. int max = x;
    5. if (max < y)
    6. max = y;
    7. return max > z ? max : z;
    8. }
    9. int main1()
    10. {
    11. int a = max(1, 2, 3);
    12. int b = max(4, 5, 6);
    13. int c = max(7, 8, 9);
    14. printf("%d %d %d\n", a, b, c);
    15. return 0;
    16. }

    下面是经过预编译后的代码长度

    可以看到,在调用函数时,都是直接使用的,即使调用100次,代码也不会很长。其实在长度方面一直是函数的强项,毕竟函数的作用就是功能定义,代码复用

    🪴2.3.2、执行速度

      其次是代码执行速度方面,宏比函数快得多!

    宏:宏在预编译阶段就已经完成了代码的替换,在后面无需进行操作

    因此对运行速度有追求的程序会大量使用宏

    函数:函数在使用时,存在调用和返回这两个操作,会造成额外的开销

    C语言中函数调用需要经过一系列的操作,比如记录当前位置、传递参数、进入函数、计算后将返回值带回起始位置,这就比较浪费时间了

    🪴2.3.3、操作符优先级

      受优先级影响,宏相对于函数,计算结果不容易预料,并且宏在设计时需要大量括号

    宏:宏的直接替换属性很快,但也可能会因为优先级问题带来错误,比如下面段代码

    1. #define TEST(x,y) x + y
    2. int main()
    3. {
    4. int z = 2;
    5. z = z * TEST(2, 5);
    6. printf("%d\n", z);
    7. return 0;
    8. }

    结果: 9

    因为操作优先级导致的运算错误,当替换完成后,计算部分代码变成了这样:

    z = z * 2 + 3;    //替换后

    因此宏在设计时,要注意潜在的优先级问题,如果不放心,可以多用括号解决

    #define TEST(x,y) ((x) + (y))    //改进后,有效避免了优先级问题
    

    函数:函数在参数优先级上不需要考虑太多,另外函数的计算相对来说比宏清晰,返回值就是预料值,就拿上面的代码来举例:

    1. int test(int x, int y)
    2. {
    3. return x + y;
    4. }
    5. int main()
    6. {
    7. int z = 2;
    8. z = z * test(2, 5);
    9. printf("%d\n", z);
    10. return 0;
    11. }

    结果: 14

    函数的计算结果是容易预料到的,并且不容易出问题,因此函数不需要加很多括号

    🪴2.3.4、带有副作用的参数

      宏不仅会因为优先级问题造成影响,还会因为参数传递导致副作用,因为宏参数在传递后,会原封不动的进行替换,某些操作会对参数本身造成影响,而函数就没有这种问题

    宏:举一个比较极端的例子,来说明宏传参有副作用这件事

    1. //计算两数+1后的较大值
    2. #define MAX(x, y) ((x) > (y) ? (x) : (y))
    3. int main()
    4. {
    5. int x = 1;
    6. int y = 2;
    7. int z = MAX(++x, ++y);
    8. printf("x = %d y = %d z = %d\n", x, y, z);
    9. return 0;
    10. }

    结果: x = 2 y = 4 z = 4

    这次的宏体没有问题,问题就出在宏参数上,下面是替换后的代码

    	int z = ((++x) > (++y) ? (++x) : (++y));    //预编译处理后
    

    走读代码:

    • 1.首先明确为前置++,先+1,再使用
    • 2.把 x 和 y 进行比较,因为是前置++,此时 x = 2  y = 3,显然为假,走后面的语句
    • 3.找出较大值后,执行 ++y ,结果为 4,将这个值返回给 z
    • 4.此时 x = 2,y = 4,z = 4 

    按照预料值,计算结果应该为 x = 2,y = 3,z = 3,因为宏参数计算存在副作用,所以计算结果才会有出入,即使换成后置,也会有问题。较为稳妥的做法,就是先把 x、y 分别+1,再作为宏参数传递使用。

    函数:跟上面一样的功能,不过换成了函数形式

    1. int max(int x, int y)
    2. {
    3. return x > y ? x : y;
    4. }
    5. int main()
    6. {
    7. int x = 1;
    8. int y = 2;
    9. int z = max(++x, ++y);
    10. printf("x = %d y = %d z = %d\n", x, y, z);
    11. return 0;
    12. }

    结果: x = 2 y = 3 z = 3

    可以看到此时的运算结果是正确的,原因很简单,函数在传递参数时,只会进行一次运算,也就是说,实参 ++x、++y 在传递给形参后,已经变成了 2、3,所以运算就没有问题。

    当然这里是有意设置的(对参数 x、y造成了影响),实际使用中,最好不要传递自增和自减,避免出现副作用

    🪴2.3.5、参数类型

      宏是没有规定参数类型的,而函数规定了参数类型,这就注定函数只能完成指定类型的操作,而宏可以适用于所有数据类型,这点上宏是比较好的

    宏:比如模拟实现一个适用于所有数据类型的加法程序

    1. #define ADD(x, y) ((x) + (y))
    2. int main()
    3. {
    4. printf("%d\n", ADD(2, 3));
    5. printf("%f\n", ADD(1.1, 2.2)); //甚至能用于浮点型
    6. printf("%c\n", ADD('A', 32));
    7. return 0;
    8. }

    结果: 5

               3.30000

               a

    函数:一样的操作,不过把宏换成了函数

    1. int add(int x, int y)
    2. {
    3. return x + y;
    4. }
    5. int main()
    6. {
    7. printf("%d\n", add(2, 3));
    8. printf("%f\n", add(1.1, 2.2)); //调用失败,类型不匹配
    9. printf("%c\n", add('A', 32));
    10. return 0;
    11. }

    结果: 5

               0.000000

               a

    浮点型在内存中存储规则不同与整型,强行计算会出问题,因此编译器直接给出了 0.000000 这个结果

    🪴2.3.6、能否调试

      宏是直接替换的,代码都在一行中,是调试不了的(当然可以通过汇编指令观察到细节);而函数相对来说比较独立,能够进入到函数体中,进行逐步调试

    宏:直接替换后,无法调试,或者说想调试很困难,只能通过汇编代码逐细节观察

    函数:函数具有完整的躯干,可以进入到函数体内,逐语句调试

    🪴2.3.7、能否递归

      宏是不能递归的,因为递归需要两个必要条件:限制条件和接近条件,而宏定义是直接替换的,条件是不能设置的;函数能实现递归,递归思想:大事化小,可以通过对同一个函数的不断调用,来完成复杂的任务。

    宏:在一个宏体内放入当前宏体名,这是不合理的(参考 2.2.3 宏的替换规则),举个栗子

    1. //递归打印数位,从低位开始
    2. #define TEST(x) (if(x < 10) printf("%d\n",(x)); else { printf("%d ",x % 10) ; TEST(x / 10);})
    3. int main()
    4. {
    5. TEST(123456);
    6. return 0;
    7. }

    结果: 编译失败,报了很多种错误

    函数:一模一样的代码(除了宏名部分),让函数来完成

    1. void test(int x)
    2. {
    3. //当然这是不好的代码风格,这里是为了和上面对比
    4. if (x < 10) printf("%d\n", (x)); else { printf("%d ", x % 10); test(x / 10); }
    5. }
    6. int main()
    7. {
    8. test(123456);
    9. return 0;
    10. }

    结果: 6 5 4 3 2 1

    函数成功完成了任务,这个例子很好的说明了宏是无法使用递归的

    🪴2.3.8、结论

      函数和宏各有各的好处,要根据实际需求选择使用,使用时要注意优先级和副作用问题

    属性#define 定义的宏函数
    代码长度如果多次调用,替换后代码会很长一份代码,多次调用
    运行速度预处理阶段直接替换,比函数更快需要进行调用、返回等操作
    操作符优先级可能存在隐藏的优先级问题相对隔离,不必担心此问题
    带有副作用的参数替换后,可能会进行多次运算只有在传递时进行运算
    参数类型没有固定类型,合法就行类型固定,使用时要与之相匹配
    能否调试不方便调试可以进行逐语句调试
    能否递归不能递归可以递归

      函数和宏都是很好的工具关于函数的更多知识可以点击这里,宏的优缺点如下:

    优点:

    • 1.宏的运行速度比函数更快
    • 2.宏与类型无关,涉及多类型的简单算法推荐使用宏

    缺点:

    • 1.当多次调用宏时,除非宏体很短,否则会大幅度增加程序的长度
    • 2.宏是不方便调试的
    • 3.宏没有类型,不够严谨
    • 4.宏在使用时,可能会出现优先级和副作用问题

       🌱2.4、#undef 移除宏定义

      除了能 #define 定义符号外,还能 #undef 移除宏定义

    语法:

    • #undef name    

    //name 是已经定义好的符号名或宏名,必须合法(存在)

    1. #define MAX 100
    2. #define ADD(x,y) (x + y)
    3. int main()
    4. {
    5. #undef MAX //取消定义的标识符 MAX
    6. printf("%d\n", MAX);
    7. #undef ADD //取消定义的宏 ADD
    8. printf("%d\n", ADD(2, 3));
    9. return 0;
    10. }

    结果: 报错, 显示 MAX ADD未定义

       🌱2.5、命令行定义

      C语言中允许在命令行中定义符号,当然是在编译前定义(得在 Linux 上用 gcc 命令行编译的方式展示)

    举个栗子

    1. //命令行定义
    2. int main()
    3. {
    4. int arr[ARR_SIZE];
    5. int i = 0;
    6. for (i = 0; i < ARR_SIZE; i++)
    7. arr[i] = i;
    8. for (i = 0; i < ARR_SIZE; i++)
    9. printf("%d ", arr[i]);
    10. printf("\n");
    11. return 0;
    12. }

    在 Linux 环境下 gcc 中输入指令: gcc -D ARR_SIZE=10 test.c        //假设文件为 test.c

    此时程序中的数组大小就变为了10,显然命令行定义的方式能让程序更加灵活,环境适配性更强

    结果: 1 2 3 4 5 6 7 8 9 10

       🌱2.6、条件编译

      之前学过分支与循环,其中的条件满足时才会执行语句。C语言中还提供了一组条件编译函数,这些函数能决定后续语句是否需要编译

    🪴2.6.1、单分支条件编译

    语法:

    • #if   #endif

    // #if 后面跟条件表达式,当条件成立,后续代码才会编译

    // #endif 条件编译块结束的标志,每个 #if 都必须有一个 #endif 与之匹配

    1. int main()
    2. {
    3. #if 1 > 2
    4. printf("hello "); //条件不成立,此条语句不参与编译
    5. #endif
    6. printf("world\n");
    7. return 0;
    8. }

     结果: world

    看看预编译产生的 .i 文件

      注意:有 #if 就要有 #endif ,二者互为彼此存在的必要条件

    🪴2.6.2、多分支条件编译

      多分支是在单分支基础上增加了两条语句:否则(#else)否则如果(#elif)

    语法:

    • #if   #elif   #else   #endif

    //其中,#if   #elif 后面都需要跟条件表达式

    //如果前两个都为假,那就编译 #else 后的语句

    // #endif 服务于 #if ,不可缺失

    //当然多分支可写的更细,这就不就展开叙述

    1. int main()
    2. {
    3. #if 1>2
    4. printf("1\n");
    5. #elif 4>3
    6. printf("2\n"); //只有这条语句参与编译
    7. #else
    8. printf("3\n");
    9. #endif
    10. return 0;
    11. }

     结果: 2

    看看预编译产生的 .i 文件

    不难看出,多分支条件编译就跟多分语句一样,只会选择一个通道进行编译 

      注意:在使用多分支编译语句时,逻辑要严谨,设计要合理 

    🪴2.6.3、判断是否定义过宏

      我们可以定义宏、取消宏,还可以判断宏是否已定义

    语法:

    • #if defined( )   #endif

    //这个是判断宏有没有定义过,如果定义了,就执行后续语句

    • #if !defined( )   #endif

    //这个是上面的反面,逻辑取反嘛,如果没定义,就执行后续语句

    下面这俩是上面判断语句的另一种写法(个人比较推荐下面的写法,不需要加 ( ) 号)

    • #ifdef   #endif

    //判断是否定义过,如果定义过,执行后续语句

    • #ifndef   #endif

    //判断是否没有定义过,如果没有定义,执行后续语句

    1. #define ADD(x, y) ((x) + (y))
    2. int main()
    3. {
    4. //判断是否定义过
    5. #if defined(ADD)
    6. printf("Yes\n"); //这个宏是已经定义了的
    7. #endif
    8. //判断是否没定义
    9. #ifndef SUB
    10. printf("Yes\n"); //这个宏没定义
    11. #endif
    12. return 0;
    13. }

    结果: Yes

               Yes 

    两种写法我都展示了,展示的还是不同的逻辑,判断定义就是这么用的

    🪴2.6.4、嵌套使用条件编译

      下面演示一段三种条件编译语句混合的代码:

    1. //#define OS_UNIX
    2. #define OS_MSDOS
    3. int main()
    4. {
    5. #if defined(OS_UNIX)
    6. #ifdef UNIX_1
    7. printf("Welcome to UNIX_1\n");
    8. #endif
    9. #ifndef UNIX_2
    10. #define UNIX_2
    11. printf("Increase UNIX_2\n");
    12. #endif
    13. #elif defined(OS_MSDOS)
    14. printf("Welcome to Windows\n");
    15. #else
    16. printf("The system does not exist");
    17. #endif
    18. return 0;
    19. }

    结果: Welcom to Windows

    这段代码中包含了单分支、多分支、判断定义的知识 ,可以嵌套使用,灵活强大

      那么这种条件编译在现实中存在吗?

      答:存在,且使用很频繁,比如下图为VS中某头文件的定义截图

       🌱2.7、文件包含

      最后再来谈谈C语言中头文件的包含方式,分为自定义头文件和库文件的包含

    🪴2.7.1、自定义头文件的包含

      自定义头文件在包含时,只能用 " " 引出自定义头文件名,如果像库函数头文件那样包含,是不会成功的,因为< >这种包含方式,是在标准路径下寻找头文件(C语言自带库函数头文件位于此目录下),显然这个路径中是不会有我们自定义头文件的,因此只能使用 " " 引出自定义头文件。

      " " 包含头文件的查找策略是:先在当前目录下寻找目标头文件,找到了就打开,如果没找到,就会跑到标准路径下寻找,再找不到,就打开失败。显然,如果是头文件不存在的情况下,需要查找两次,效率会比较低。

    🪴2.7.2、库函数头文件的包含

      库函数头文件在包含时,一般使用 < > 引出库文件名,被< >引出的头文件,编译器会直接去标准路径下寻找,只要没写错,那一般都能找到。" " 引头文件时,虽然要查找两次,但最终也会找到标准路径下,那么能否使用 " " 引库函数头文件呢?

      答案是不推荐,如果使用 " " 引库函数头文件的话,可以正常打开,但会拖慢运行速度,毕竟要查找两次。同时我们在使用时,就不能一眼辨别出,哪些是自定义头文件,哪些是库函数头文件了

    1. #include //库函数头文件的包含风格
    2. #include"Add.h" //自定义头文件的包含风格

    🪴2.7.3、避免多次展开同一头文件

      头文件在被成功调用后,在预编译阶段会被展开(详情转至 1.1.1),光是 stdio.h 这个头文件就被展开了一万多行代码,如果不做特殊处理,然后多包含几次头文件,那光是在预编译阶段就会出现很多很多行代码了,并且这些代码还是重复的,为此要对头文件做一些特殊处理,避免其被多次展开

    方法1(比较远古的方法)

      通过给头文件打标记,避免多次展开,会用到条件编译

    1. #if !defined __TEST_H__ //打个标记,如果是第一次被引用
    2. #define __TEST_H__ //就会创建一个标识符,然后开始预处理头文件中的内容
    3. //预处理头文件中的内容
    4. #endif

    结果: 当第一次展开头文件时,没有识别到标记 __TEST_H__  之后会定义标记,再展开头文件;等后续在次文件中再次展开头文件时,识别到标记,不会继续展开代码,这样在预编译阶段就不会重复展开头文件了(不得不佩服前人的智慧)

    方法2(现在比较常用的方法)

      这个就比较简单了,在头文件第一行直接使用提前定义好的工具就行,底层原理还是条件编译

    #pragma once	//在头文件首行放置此条语句,可以避免重复展开
    

    实际运用

      比如我们在VS中创建一个头文件,当文件创建完成后,编译器会自动在首行添加方法2中的语句,现在编译器太智能了,再比如下图为 stdio.h 这个头文件的首行

      足以看出这个东西是真实存在的,我们在创建自定义头文件时,可不敢把首行代码删除。

    推荐了解其他预处理指令

    • #error
    • #pragma
    • #line 
    • ……

    🌳总结

      以上就是关于C语言程序环境和预处理的所有内容了,如果你在看完此文后能对C语言代码的运行有一个新的认识,那么本文就值了;如果你对涉及命令行的操作还不太熟悉,没有关系,现在可以先了解,等以后学习了 Linux 的相关知识后,再回来解决就行了。

      如果你觉得本文写的还不错的话,期待留下一个小小的赞👍,你的支持是我分享的最大动力!

      如果本文有不足或错误的地方,随时欢迎指出,我会在第一时间改正

      写在最后:本文结束后,我们C语言的学习就可以宣布毕业了!从7月16日发布第一篇文章,到10月16结束最后一篇文章,历时三个月,此专栏的文章数达到了19篇,收获了近一万的文章阅读量和大量的点赞、收藏和评论,在此感谢一直支持我博客创作的朋友们,你们的支持是我坚持创作的最大动力!感谢那个拥有坚定信念的自己,一直坚持学习,砥砺前行。

      当然,只是C语言整体知识系列划上了句号,其他文章还是会继续更新下去的, 比如 数据结构 | C 这个系列,还有高深一些的 C语言高阶——函数栈帧的创建和销毁 ,其他好玩的小程序、有意义的题解等。新的旅途即将开始,就像章北海一样,我们的征途将是更加广袤的大海!

     优质文章推荐

    C语言进阶——文件操作

    C语言进阶——动态内存管理

    C语言进阶——数据在内存中的存储 

  • 相关阅读:
    Redis常见面试问题总结
    校园招聘面试精典博文java
    Spring的事务控制-编程式事务控制相关对象
    设计模式——策略模式
    Java基础之ArrayList集合(最简单最详细)
    木舟0基础学习Java的第十六天(异常,分类,自定义异常,注意事项)
    通用与垂直大模型:应用、挑战与未来发展
    url转二维码处理以及常见问题
    java基础篇—基础语法
    【刷题篇】笔试真题
  • 原文地址:https://blog.csdn.net/weixin_61437787/article/details/127331477