C语言|详解C语言预处理阶段发生的那些事

前言 预编译是源文件编译为可执行文件中的一个步骤之一,而在预编译中发生了很多事情:头文件展开,宏替换,条件编译的处理,去注释等;但是其中还有许许多多的易错点,用不好的话可能会给程序带来无法预料的后果;所以这篇文章就详细的聊聊常见预编译的那些事情。

文章目录

  • 前言
  • 一些预定义的符号
  • #define 定义标识符
  • #define 定义宏
  • #define的替换规则
  • 预处理是先去注释还是先宏替换的问题
  • 在宏中 #和## 的作用
  • 宏与函数的比较
  • #undef是移除#define定义
  • 宏是否被定义vs宏是否为真假
  • 条件编译
  • 为什么要有条件编译
  • 常见的条件编译
  • 多分枝条件编译的常见场景
  • 头文件的包含
  • 头文件仅包含一次的做法

一些预定义的符号 预定义的符号就是C语言本身就有的符号,这些符号有自己的含义。
一般这些符号都是全部字母大小,且以双下划线开头和双下划线结束;
预定义符号 含义
_ FILE _ 进行编译的源文件
_ LINE _ 文件当前的行号
_ DATE _ 文件被编译的日期
_ TIME _ 文件被编译的时间
_ STDC _ 如果编译器遵循ANSI C,其值为1,否则未定义,vs2013编译器下不支持,而Linux下支持
这东西有什么用呢?
一般当C语言的工程比较大的时候,就会比较难以调试,所以这些预定义符号就可以发挥作用:
在我们写程序的时候,可以记录一些信息,这些信息可以帮助你调试时候,分析问题的所在;
举个例子,下面我创建一个data.txt文件,为的是:对main.c文件编译的信息保存在data.txt文件中;
#define _CRT_SECURE_NO_WARNINGS 1 //vs2013要加这个东西要不然不会通过编译一些特定的C库函数 #include int main() { FILE* pf = fopen("data.txt", "a+"); if (NULL == pf){perror("fopen:"); return -1; } int i = 1; fprintf(pf, "编译的文件名:%s\n 编译的文件行号: %d\n" \ "编译的文件日期: %s\n 编译的文件时间:%s\n 编译的文件的数据:%d\n", __FILE__, __LINE__, __DATE__, __TIME__,i); fclose(pf); pf = NULL; return 0; }

C语言|详解C语言预处理阶段发生的那些事
文章图片

#define 定义标识符 语法:
#define name stuff 作用是:在程序中:用name表示 stuff

在C程序中,一旦看到是全字母大小的符号,一般都是#define定义的标识符,此时,要反应过来,这个标识符就是在#define中定义的,在预处理编译阶段会替换成标识符所表示的东西。
#define可以定义多种标识符的形式:
  1. 定义常量;
  2. 定义关键字;
  3. 定义语句;
  4. 甚至只是定义一个标识符,标识符后面不跟任何东西都行;
    都是可以的。
#define __TEST_H__//定义一个标识符__TEST_H__,后面不跟任何东西 #define MAX 1000//定义一个标识符MAX,表示常量1000 #define reg register//为 register这个关键字,创建一个简短的名字 #define do_forever for(; ; )//用更形象的符号来替换一种实现 #define CASE break; case//在写case语句的时候自动把 break写上。 // 如果定义的 stuff过长,可以分成几行写,除了最后一行外,每行的后面都加一个反斜杠(续行符)。 #define DEBUG_PRINT printf("file:%s\tline:%d\t \ date:%s\ttime:%s\n" ,\ __FILE__,__LINE__ ,\ __DATE__,__TIME__ )

#define 定义宏 #define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定
义宏(define macro)。
语法:
#define name( parament-list ) stuff 作用:用name( parament-list ) 表示stuff 其中:name( parament-list )类型函数调用的写法; stuff 类型函数的函数体;

举例
#define ADD(X,Y) X+Y int main() { printf("sum = %d\n", ADD(10, 20)); //结果为30 return 0; }

C语言|详解C语言预处理阶段发生的那些事
文章图片

替换的过程:
先把参数10 20传递到宏的参数列表中;
然后宏参数列表接受到后在传给 X+Y ;
X 和 Y接受后就变为 10+20;
此时就替换printf中ADD(10,20) ----> 10+20;
注意事项:
宏的参数列表的左括号不可以与宏标识符分开,即不可以有空格,括号要和标识符紧挨在一起,要不然就会被解释错误为stuff;
宏的stuff中,每个参数最好都加上括号(),因为这样不容易出错;
比如:
#define SQUARE(x) x * x //x * x中没加括号,很容易产生与预期结果不符int main() { int a = 5; printf("ret = %d", SQUARE(a + 1)); return 0; }

C语言|详解C语言预处理阶段发生的那些事
文章图片

结果为11;可能我们会认为结果为 36;即我们认为是这么计算的 a+1 = 6,把6传入SQUARE(6)中;然后6*6 =36;其实这是错误的;
正确的是这样替换:a+1 不做任何计算直接传递给 SQUARE(a+1); 然后就为 a+1*a+1 = 11;
所以很重要的一点是:宏的参数是先替换过后,才计算的,因为宏的处理过程是在预编译阶段,预编译阶段是不完成任何表达式计算的过程的;
为了避免这种误解发生我们都会在stuff中给每一个参数加上括号()即:最好是每一层都有括号
#define SQUARE(x) ( (x) * (x) ) //这样就不会发生与结果不不符了 比如:上面的替换后为 ((a+1)*(a+1)) = 36;

#define的替换规则 在程序中扩展**#define定义符号和定义宏**时,需要涉及几个步骤。
  1. 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。
  2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值替换。
  3. 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程。
注意:
  • 宏参数和#define 定义中可以出现其他#define定义的变量。但是对于宏,不能出现递归。
  • 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
举例:
构建一个场景来说明#define的替换规则
#define M 100 #define MAX(X,Y) (X) > (Y) ? (X):(Y) int main() { printf("M = %d", MAX(101, M)); //结果M = 101; return 0; }

C语言|详解C语言预处理阶段发生的那些事
文章图片

预处理是先去注释还是先宏替换的问题 结论:先去注释,再宏替换。
验证过程:
首先我们构建一个场景代码:
#include#define BSC // int main() { BSC printf("you can see me\n"); return 0; }

上面的代码要表达的意思是:
假如预处理是先宏替换的话,那么BSC printf("you can see me\n"); 代码就会在预处理阶段变成// printf("you can see me\n"); 于此同时再去注释:那么\\ printf("you can see me\n"); 就被注释掉了;
那么我们在Linux上用 gcc -E 预处理.c文件得到.i文件的话,.i文件是没有 代码的,那么也就是说
gcc编译时候,是没有输出的;
假如预处理是先去注释:那么上面的代码#define BSC //就会变成 #deine BSC,即注释没有了;然后再宏替换,那么BSC printf("you can see me\n"); 再宏替换时候就会变成printf("you can see me\n"); 因为此时 #deine BSC相当于没有任何东西;
那么我们在Linux上用 gcc -E 预处理.c文件得到.i文件的话,.i文件是有代码的,那么也就是说
gcc编译时候,是有输出的:you can see me
经过上面的论证,我们来编译看看
C语言|详解C语言预处理阶段发生的那些事
文章图片

很明显:.i文件中有 printf的代码,说明:
先去注释再宏替换。
先去注释再宏替换。
先去注释再宏替换。

在宏中 #和## 的作用 在宏中,# 可以把宏参数转化为对应的字符串
#define PRINT(FORMAT,VALUE)\ printf("the value of " #VALUE " is " FORMAT"\n", VALUE); int main() { int i = 10; PRINT("%d", i+3); //结果为the value of i+3 is 13 return 0; }

怎么理解上面的代码:首先我需要一个功能是:想打印出一句话:“the value of XXX is YYY”;
即我需要 XXX 和 YYY 位置为我需要什么模样就什么模样;
如何做到呢?
比如我希望得到
“the value of 1+2 is 3” ;
或者"the value of 1.0+2.0 is 3.0"
或者"the value of 10+20 is 30";
如果设计函数的话,要做到这一点功能,并没有那么容易,而宏的话就很容易了
那么就可以用#在宏中的参数转化为对应字符串的功能
#define PRINT(FORMAT,VALUE)\ printf("the value of " #VALUE " is " FORMAT"\n", VALUE); 这个表示的意思:PRINT(FORMAT,VALUE)代替 printf("the value of " #VALUE " is " FORMAT"\n", VALUE); 这打印语句 而其中FORMAT为程序传入的格式参数; 而VAULE为传入的表达式;#VALUE可以把传入的参数变成字符串: 比如传入 PRINT("%d",1+2) 在程序中表示为 printf("the value of " "1+2" " is " "%d" "\n", 1+2); 其中这里的 ”1+2“ 是宏参数1+2 通过#VALUE 替换的,把参数变成了有双引号的字符串; ”%d“ 很自然的就替换了 FORMAT ;

在宏中 :## 可以用来连接宏参数为一个新的标识符;
注意:
连接成功后的表示符号:必须符合标识符的命名规则才可以;
连接成为的是表示符号,不是字符串;
#define EMAIL(WHO,ID) WHO##ID int main() { const char* my_email = "1914508551@qq.com"; printf("%s\n", EMAIL(my_,email)); return 0; }

C语言|详解C语言预处理阶段发生的那些事
文章图片

上面 EMAIL(my_,email),把 my_email 两个宏参数传入到 EMAIL(WHO,ID) ,然后通过 WHO##ID 这里的 ## 连接两个宏参数 ’ my_ ’ 和’ email ’ ,所以 EMAIL(my_,email)被替换成----> my_email这个新的表示符号。
宏与函数的比较
属性 #define定义宏 函数
代码长度 每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长 函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码
执行速度 更快 存在函数的调用和返回的额外开销,所以相对慢一些
操作符优先级 参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括号。 函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式求值结果更容易预测。
带有副作用的参数 参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果。 函数参数只在传参的时候求值一次,结果更容易控制。
参数类型 宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型。 函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使他们执行的任务是不同的。
调试 宏是不方便调试的 函数是可以逐语句调试的
递归 宏是不能递归的 函数是可以递归的
#undef是移除#define定义 #undef 语法
#undef NAME 表示要移除在这句代码之前定义过的宏NAME 效果就是:#undef NAME下面的代码都无法再使用NAME宏了

#undef 这条指令用于移除一个宏定义。
#include #define M 100 int main() { int a = M; //这里使用了宏 M //..... #undef M //这里移除了M宏 printf("%d",M); //错误:由于上面移除了宏,这里就不能使用了,如果还使用就会报错 }

一道题理解 #undef
int main() {//注意:宏是可写在代码的任意位置的; #define X 3 #define Y X*3 #undef X #define X 2 int z = Y; printf("%d",z); //6 return 0; }

请问结果z = 多少?
Y要被替换成 X*3 ,但是由于 有#undef X,所以在该代码#undef X上面的X都被取消了,即#define X 3 ;那么 X*3X就只能是#define X 2; 所以 z = X*3 = 2 * 3 =6;
宏是否被定义vs宏是否为真假 C语言|详解C语言预处理阶段发生的那些事
文章图片

条件编译 在我们的C代码中可能有这样的场景:可能有一些代码我们是不希望它参与编译的,但是我们又不想抛弃它,所以可以使用条件编译的方式,选择性的对这段代码进行编译:也就是说,当我用条件编译的方式时候,可以对一些不需要的代码屏蔽掉,让他们不参与编译;相当于对源代码透明。
常见的场景是:调试时候的代码:删了可惜,由于是调试用的代码,也不需要,所以可以用条件编译把它们屏蔽掉,裁剪掉。
#include #define __DEBUG__ int main() { int i = 0; int arr[10] = { 0}; for(i=0; i<10; i++) {arr[i] = i; #ifdef __DEBUG__ //如果定义了 __DEBUG__,则下面的代码到#endif之间都是不可以被编译 //相当于被屏蔽,很明显我们在上面定义过了 __DEBUG__;#define__DEBUG__ printf("%d\n", arr[i]); //为了观察数组是否赋值成功。 #endif //__DEBUG__ } return 0; }

有个细节:条件编译的结束标志位 #endif ,一般我们会在这个#endif后面加个注释,表示是对哪个条件编译做的#endif,这样增加代码可读性。是一个很好的编程习惯。
如上面的例子:#endif //__DEBUG__ 这个注释就表示了对__DEBUG__做了#endif;
为什么要有条件编译 条件编译从字面理解就是,满足一定条件的代码可以进行编译:直接体现就是对代码可以进行裁剪功能。
那么条件编译的主要作用其实还是:
  1. 可以对版本的控制:
    比如一个软件可以有收费版本,和免费版本,收费版本和免费版本本质上是同一个软件,你说这个软件回有两份代码吗?应该不会吧,它们处理的方式应该就是用条件编译控制免费版本和收费版本的功能选项。
  2. 可以保证跨平台性:
    用条件编译很好的控制,某些代码在一些平台无法编译的事情,比如在windows平台,某一段代码无法在Linux下跑,那么我们就可以用条件编译这段代码,然它满足一定条件时候才可以在Windows平台跑,而在Linux平台不可以跑。这种代码就跨平台性好。
  3. 可以进行功能控制:
    这个也很好理解,通过条件编译,使得一些代码的功能不被编译。
常见的条件编译
#if 常量表达式 //如果常量表达式为真,就编译下面的代码 //... #endif //常量表达式由预处理器求值。 ___________________________________________________________ 如: #define __DEBUG__ 1 #if __DEBUG__ //如果定义了__DEBUG 为真就编译 //.. #endif ___________________________________________________________ 2.多个分支的条件编译 #if 常量表达式 //如果表达式为真就编译if的代码 //... #elif 常量表达式 //如果if的表达式为假,而elif为真就编译elif的代码 //... #else //上面的条件都为加就编译else的代码 //... #endif ___________________________________________________________ 3.判断是否被定义 #ifdef symbol //如果定义了 symbol 就编译下面的代码 //... #endif ___________________________________________________________ 效果和上面的一样 #if defined(symbol) //... #endif ___________________________________________________________ #ifndef symbol //如果没定义就编译下面代码 //... #endif ___________________________________________________________ 效果和上面的一样 #if !defined(symbol) //... #endif

条件编译的结束都要有#endif :表示条件编译的代码段结束。
#ifdef 和 #ifndef 是用来检测宏是否被定义,不需要判断是是否为真假!
#if 和 #elif 是用来检测宏是否被定义,同时需要判断是否为真假,如果宏定义时候,没有真假表达式,那么会报错
**#ifdef 和 #ifndef
理解代码:
int main() {#define DEBUG#ifdef DEBUG printf("%d",10); //由于宏DEBUG被定义过:所以这代码参与编译 #endif return 0; }

int main() {#define DEBUG 0 #ifdef DEBUG printf("%d",10); //尽管上面DEBUG被定义为假,但是#ifdef不关心真假,只关心是否被定义 //由于宏DEBUG被定义过:所以这代码参与编译 #endif return 0; }

#if 和 #elif 理解
#include #define C int main() { #if C printf("%d",10); #else printf("%d",20); #endif return 0; }

C语言|详解C语言预处理阶段发生的那些事
文章图片

虽然有定义C,但是C没有真假,所以会编译报错。
#include int main() { #if C printf("%d",10); #else printf("%d",20); #endif return 0; }

这里的执行结果为20:原因是,假如没有定义过C,那么就说明C是假,那么就执行#else;
多分枝条件编译的常见场景 假如有个场景:希望某一个代码在C和Cpp两个地方都被编译如何做到呢?
答:可以通过多分枝条件编译和与的功能完成。
#if define来模拟实现 #ifdef的场景.
#include #define C #define CPP int main() {#if defined(C) && defined(CPP) //如果C和CPP宏同时被定义,编译 #if 的代码 printf("hello c&&cpp\n"); #else printf("hello other\n"); #endif return 0; }

总结:条件编译可以和逻辑算术表达式一起用。
头文件的包含 头文件的包含有两种方式
一种是以#include<.h>尖括号的形式包含;
一种是以#include".h"双引号的形式包含;
两者的区别在于:搜索的路径方式不一样。
<.h>尖括号包含的搜索路径是:先去搜索C语言库的文件路径,再去搜索当前路径。如果在C库路径找到了,直接包含进源文件,如果没找到就会搜索当前路径(源文件所在的路径),如果找到了就包含进去,如果没找到,就包含不进去源文件。
".h "双引号的搜索路径是:直接到当前路径下搜索头文件。
我们推荐使用习惯是:是C库的头文件就用<.h>尖括号的方式;是自己编写的头文件就用".h"双引号的方式。
头文件仅包含一次的做法 一般写头文件我们不希望该头文件在源文件中被包含多次。
虽然编译也没什么毛病:但是会影响编译效率呀,你想想在源文件中,有重复拷贝的头文件代码,要编译多次,肯定会对编译效率有影响;其次多次头文件展开,源文件的代码也会变的冗余,多项,没必要,所以我们应该避免。
避免的方式就是在一开始编写头文件时候就写上下面那三句条件编译。
其中:__TEST.H__是头文件名,写这个是因为大家的命名习惯,只要你的头文件名是什么,那么你条件编译时候就怎么写,我这里的头文件名是test.h,所以我就这么定义了。
//test.h头文件#ifndef __TEST.h__ #define __TEST.h__//....头文件内容 //....头文件内容#endif //__TEST.h

防止头文件重复包含的原理
假如我在源文件包含了一次 #include。那么在源文件中进来就会判断#ifndef__TEST.h__看看__TEST.h__是否被定义,很明显我们的源文件是不会定义这个__TEST.h__,所以就会继续执行下面的代码:#define> __TEST.h__,然后__TEST.H__就被定义了:之后,假如又重复包含:#include,那么又会判断#ifndef __TEST.h__,看看__TEST.h__是否被定义,一看,发现在第一次头文件包含时候,已经定义过了,所以,就不会再编译重复包含头文件的内容了。
还有一种做法:再头文件直接写一句:#progam once
#progam once //....头文件内容 //....头文件内容

【C语言|详解C语言预处理阶段发生的那些事】上面的方式也是可以防止头文件重复包含。

    推荐阅读