欢迎大家来到本系列别再瞎学 C 语言了真・胎教级入门教程 | NO.万字详解预处理的文章学习.早在本系列第一章就曾提到,由C语言写成的源代码要得到最终可运行的可执行程序,详细的中间要经历预处理Preprocessing→ 编译Compilation生成汇编代码.s→ 汇编Assembly生成目标文件.o/.obj→ 链接Linking生成可执行文件.out/.exe)四个过程.我们就要来一起学习并实践下其中的预处理(preprocessing)部分.1. 预定义符号C语⾔设置了⼀些预定义符号可以直接使⽤这些预定义符号也是在预处理期间处理的。1. __FILE__ //正在进行编译的源文件 2. __LINE__ //这行代码的当前行号 3. __DATE__ //文件被编译的日期 4. __TIME__ //文件被编译的时间 5. __DTDC__ //如果编译器遵循ANSI C,其值为1,否则未定义下面来看下具体代码及实现来帮助理解:由此可见我当前代码的信息,含兴趣的同学可以自己来写打印出来看.而为什么我将第五行注释掉了?就应该是我上述提到的情况,我的编译器默认没有启用严格的 ANSI C 标准兼容模式2. #define定义常量基本语法:#define name stuff2.1具体示例:来个例子帮助理解:1. #define MAX 7777 2. #define reg register //为关键字register创建了一个简短的别名 3. #define do_forever for( ; ; ) //用更形象的符号来替换一种实现 4. #define CASE break;case //帮你在写case时自动写上break 5. #define DEBUG_PRINT printf(file: %s\nline: %d\ndate: %s\ntime: %s\n\ , __FILE__\ , __LINE__\ , __DATE__\ , __TIME__) //这里介绍续行符的用法示例一:在预处理阶段,编译器就会自动把MAX替换成777了.示例二:register是 C 语言的一个存储类说明符用来给编译器一个 “建议”尽量把这个变量放到 CPU 寄存器里而不是内存里以此提升访问速度。注意:(1). 这只是建议,而不是强制的 (2). 不能取这个变量的地址,因为变量在寄存器里没有内存地址所以num是非法操作会直接报错 .示例三:#define do_forever for( ; ; )do_forever 正如其名:无限循环,看看它的实现方式: for( ; ; ).先来复习下for循环的用法:for ( 初始化 ; 条件判断 ; 每次循环后执行 ) { 循环体要重复做的事; }如果将括号里的初识化,条件判断和循环后执行都设为空,本质上就是一个永远不会退出的for循环,这是C语言中约定俗成的无限循环写法,功能上等价于 while( 1 ).示例四:#define CASE break;case这是一个C 语言中很巧妙的宏定义技巧,作用让你在写switch-case语句时省略掉重复的break;让代码更简洁。#define CASE break;case int main(void) { int n 0; scanf(%d, n); switch (n) { case 1: //语句 CASE 2: //语句 CASE 3: //语句 break; } } //经过预处理器后就变成: switch (n) { case 1: //语句 break;case 2: //语句 break;case 3: //语句 break; } //格式化之后就非常清晰了. switch (n) { case 1: //语句 break; case 2: //语句 break; case 3: //语句 break; }示例五:5. #define DEBUG_PRINT printf(file: %s\nline: %d\ndate: %s\ntime: %s\n\ , __FILE__\ , __LINE__\ , __DATE__\ , __TIME__) //这里介绍续行符的用法当我们想让原来在一行的内容分行展示,这时就要使用续行符 \.看看运行结果,仍能正确打印要求内容..作用是把多行代码在预处理阶段 “拼接成一行”。使用起来也非常简单,在想断开的地方断开,并在这行代码后添加上\注意!续行符\后面绝对不能加空格 /tab/ 任何字符必须直接换行否则会直接报错2.2 关于 ; 的小思考在#define 定义标识符的时候,要不要在最后加上 ; ?比如:#define MAX 7777; #define MIN 777;说结论:不建议加上 ;,因为这样容易出现问题,if (condition) { max MAX; } else { max 0; } //如果按以上方式定义MAX,预处理后就是下面的样子 if (condition) { max 7777;; //这里有语法错误 } else { max 0; }上面说过#define进行定义常量时就是直接的纯文本替换,预处理器会在编译前把所有出现宏名的地方原封不动地替换成你定义的文本完全不做任何计算、语法检查或类型转换.所以这里替换后代码尾就已经有 ; 了,但我们习惯在每行代码后自己加分号,这样就可能出现语法错误,写了两个分号.3. #define定义宏3.1 概念用法#define 机制包括了⼀个规定允许把参数替换到⽂本中这种实现通常称为宏macro或定义宏 definemacro。下⾯是宏的申明⽅式#define name(parament-list) stuff其中的parament-list 是⼀个由逗号隔开的符号表它们可能出现在stuff中注意:参数列表的左括号必须与name紧邻如果两者之间有任何空⽩存在参数列表就会被解释为stuff的 ⼀部分。举一个实际例子来确切的理解吧.当我们要求两个整数的平方时,通常不会用math.h中的pow()函数,这时也可以定义一个宏来简化代码:#define SQUARE( x ) x * x这其中SQUARE就是name,x是parament-list参数列表,后面的x*x就是内容stuff了.这个宏接收⼀个参数x,如果在上述声明之后,你把SQUARE(6);放置于程序中的话,在预处理阶段预处理器就会用 6 * 6 去替换掉上面的表达式.int n 6; printf(%d, SQUARE(n)); 等同于以下代码: int n 6l printf(%d, 6 * 6);3.2 常见错误当涉及到算术运算和表达式参数时要格外注意!因为前面提到过宏定义就是直接的文本替换,计算机并不会理解你的想法并帮你调整,例如下:int a 5; printf(%d\n ,SQUARE( a 1) );第一眼,就能读出来这个代码的使用者是想将a1这个整体进行平方对吧,但想到直接的文本替换出来就不是一回事了.printf(%d, a 1 * a 1);这样就⽐较清晰了由替换产⽣的表达式并没有按照预想的次序进⾏求值。所以这么写的结果是2*a 1 ,而不是预料中的a^2 2*a 1.这种情况下就可以在宏定义中使用括号()来强制a1为一个整体,问题就迎刃而解了.#define SQUARE(x) (x) * (x); printf(%d, (a 1) * (a 1));再来一个DOUBLE宏定义:#define DOUBLE(x) (x) (x)定义中我们使⽤了括号想避免之前的问题但是这个宏可能会出现新的错误。int a 5; printf(%d\n ,10 * DOUBLE(a));这将打印什么值呢看上去好像打印100但事实上打印的是55.我们发现替换之后:printf (%d\n,10 * (5) (5));乘法运算先于宏定义的加法所以出现了 55 .这个问题的解决办法是在宏定义表达式两边加上⼀对括号就可以了。#define DOUBLE(x) ( ( x ) ( x ) )提⽰所以⽤于对数值表达式进⾏求值的宏定义都应该⽤这种⽅式加上括号避免在使⽤宏时由于参数中的 操作符和邻近操作符之间不可预料的相互作⽤.4. 带有副作⽤的宏参数当宏参数在宏的定义中出现超过⼀次的时候如果参数带有副作⽤那么你在使⽤这个宏的时候就可 能出现危险导致不可预测的后果。副作⽤就是表达式求值的时候出现的永久性效果。例如:#define MAX(a, b) ( (a) (b) ? (a) : (b) ) ... x 5; y 8; z MAX(x, y); printf(x%d y%d z%d\n, x, y, z);//输出的结果是什么这⾥我们得知道预处理器处理之后的结果是什么:z ( (x) (y) ? (x) : (y));所以输出的结果是x6 y10 z9, 而不是预想中的 x 6 y 9 z 9了;5. 宏替换的规则在程序中扩展#define定义符号和宏时需要涉及⼏个步骤。1. 在调⽤宏时⾸先对参数进⾏检查看看是否包含任何由#define定义的符号。如果是它们⾸先 被替换。2. 替换⽂本随后被插⼊到程序中原来⽂本的位置。对于宏参数名被他们的值所替换。3. 最后再次对结果⽂件进⾏扫描看看它是否包含任何由#define定义的符号。如果是就重复上 述处理过程注意1. 宏参数和#define定义中可以出现其他#define定义的符号。但是对于宏不能出现递归。 2. 当预处理器搜索#define定义的符号的时候字符串常量的内容并不被搜索6. 宏和函数的对比宏通常被应⽤于执⾏简单的运算。⽐如在两个数中找出较⼤的⼀个时写成下⾯的宏更有优势⼀些。#define MAX(a, b) ((a)(b)?(a):(b))6.1那为什么不⽤函数来完成这个任务原因有⼆:1. ⽤于调⽤函数和从函数返回的代码可能⽐实际执⾏这个⼩型计算⼯作所需要的时间更多。所以宏⽐ 函数在程序的规模和速度⽅⾯更胜⼀筹。2. 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使⽤。反之 这个宏怎可以适⽤于整形、⻓整型、浮点型等可以⽤于来比较的类型,宏的参数是类型无关的。6.2 和函数相⽐宏的劣势:1. 每次使⽤宏的时候⼀份宏定义的代码将插⼊到程序中。除⾮宏⽐较短否则可能⼤幅度增加程序 的⻓度。2. 宏是没法调试的.3. 宏由于类型⽆关也就不够严谨.4. 宏可能会带来运算符优先级的问题导致程容易出现错。6.3 宏和函数的⼀个对⽐属性#define 定义宏函数代码长度每次使用时宏代码都会被插入到程序中。除了非常小的宏之外程序的长度会大幅度增长函数代码只出现于一个地方每次使用这个函数时都调用那个地方的同一份代码执行速度更快存在函数的调用和返回的额外开销所以相对慢一些操作符优先级宏参数的求值是在所有周围表达式的上下文环境里除非加上括号否则邻近操作符的优先级可能会产生不可预料的后果所以建议宏在书写的时候多写括号。函数参数只在函数调用的时候求值一次表达式的求值结果容易预测。带有副作用的参数参数可能被替换到宏体中的多个位置如果宏的参数被多次计算带有副作用的参数求值可能会产生不可预料的结果。函数参数只在传参的时候求值一次结果容易控制。参数类型宏的参数与类型无关只要对参数的操作是合法的它就可以使用于任何参数类型。函数的参数是与类型有关的如果参数的型不同就需要不同的函数即使他们执行的任务是不同的。调试宏是不方便调试的函数是可以逐语句调试的递归宏是不能递归的函数是可以递归的7. #和##7.1 #运算符#运算符将宏的⼀个参数转换为字符串字⾯量。它仅允许出现在带参数的宏的替换列表中。#运算符所执⾏的操作可以理解为”字符串化“。当我们有⼀个变量 int a 10; 的时候我们想打印出 the value of a is 10 .就可以写#define PRINT(n) printf(the value of #n is %d, n);当我们按照下⾯的⽅式调⽤的时候PRINT(a);//当我们把a替换到宏的体内时就出现了#a⽽#a就是转换为a时⼀个字符串代码就会被预处理为printf(the value of a is %d, a);运⾏代码就能在屏幕上打印the value of a is 107.2 ##运算符## 可以把位于它两边的符号合成⼀个符号它允许宏定义从分离的⽂本⽚段创建标识符。 ## 被称 为记号粘合.这样的连接必须产⽣⼀个合法的标识符。否则其结果就是未定义的。这⾥我们想想写⼀个函数求2个数的较⼤值的时候不同的数据类型就得写不同的函数。⽐如int int_max(int x, int y) { return x y ? x : y; } float float_max(float x, float y) { return x y ? x : y; }但是这样写起来太繁琐了现在我们这样写代码试试://宏定义 #define GENERIC_MAX(type) \ type type##_max(type x, type y)\ { \ return (xy?x:y); \ }使⽤宏定义不同函数.GENERIC_MAX(int) //替换到宏体内后int##_max ⽣成了新的符号 int_max做函数名 GENERIC_MAX(float) //替换到宏体内后float##_max ⽣成了新的符号 float_max做函数名 int main() { //调⽤函数 int m int_max(2, 3); printf(%d\n, m); float fm float_max(3.5f, 4.5f); printf(%f\n, fm); return 0; }输出3 4.500000在实际开发过程中##使⽤的很少很难取出⾮常贴切的例⼦。8. 命名约定⼀般来讲函数和宏的使⽤语法很相似。所以语⾔本⾝没法帮我们区分⼆者。那我们平时的⼀个习惯是把宏名全部⼤写函数名不要全部大写9. #undef这条指令⽤于移除⼀个宏定义。#undef NAME //如果现存的⼀个名字需要被重新定义那么它的旧名字⾸先要被移除10. 命令⾏定义许多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 -D ARRAY_SIZE10 programe.c11. 条件编译在编译⼀个程序的时候我们如果要将⼀条语句⼀组语句编译或者放弃是很⽅便的。因为我们有条件编译指令.⽐如说调试性的代码删除可惜保留⼜碍事所以我们可以选择性的编译。#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 //__DEBUG__ } return 0; }常⻅的条件编译指令1. #if 常量表达式 //... #endif // 常量表达式由预处理器求值。 如 #define __DEBUG__ 1 #if __DEBUG__ //.. #endif 2.多个分⽀的条件编译 #if 常量表达式 //... #elif 常量表达式 //... #else #endif //... 3.判断是否被定义 #if defined(symbol) #ifdef symbol #if !defined(symbol) #ifndef symbol 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 #endif12. 头⽂件的包含12.1 头⽂件被包含的⽅式12.1.1 本地⽂件包含#include filename查找策略先在源⽂件所在⽬录下查找如果该头⽂件未找到编译器就像查找库函数头⽂件⼀样在标准位置查 找头⽂件。如果找不到就提⽰编译错误。Linux环境的标准头⽂件的路径/usr/includeVS环境的标准头⽂件的路径C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\include// 这是 VS2013 的默认路径注意按照⾃⼰的安装路径去找。12.1.2 库⽂件包含#include filename.h查找头⽂件直接去标准路径下去查找如果找不到就提⽰编译错误。这样是不是可以说对于库⽂件也可以使⽤ “” 的形式包含答案是肯定的可以但是这样做查找的效率就低些当然这样也不容易区分是库⽂件还是本地⽂件 了。12.2 嵌套⽂件包含我们已经知道 #include 指令可以使另外⼀个⽂件被编译。就像它实际出现于#include指令的地⽅⼀样。这种替换的⽅式很简单预处理器先删除这条指令并⽤包含⽂件的内容替换。⼀个头⽂件被包含10次那就实际被编译10次如果重复包含对编译的压⼒就⽐较⼤。test.c#include test.h #include test.h #include test.h #include test.h #include test.h int main(void) { return 0; }test.hint main() { void test(); struct Stu { int id; char name[20]; }; return 0; }13. 其他预处理指令如果直接这样写test.c⽂件中将test.h包含5次那么test.h⽂件的内容将会被拷⻉5份在test.c中。如果test.h⽂件⽐较⼤这样预处理后代码量会剧增。如果⼯程⽐较⼤有公共使⽤的头⽂件被⼤家 都能使⽤⼜不做任何的处理那么后果真的不堪设想。如何解决头⽂件被重复引⼊的问题答案条件编译.每个头⽂件的开头写#ifndef __TEST_H__ #define __TEST_H__ //头⽂件的内容 #endif //__TEST_H__或者:#pragma once就可以避免头⽂件的重复引⼊。