详解C语言预处理

1. 程序的翻译环境和执行环境

我们用VS的经常创建一个test.c文件这个叫做源文件(源程序),这个test.c的文件经过详细的处理之后就会变成我们的可执行程序test.exe。而这个详细处理的过程包括两个过程一个是编译,一个是链接。而产生了这个可执行程序,这个程序又是怎么运行的。我们在这个章节也会讲到,但是我们的重点还是在产生可执行程序过程中的编译和链接这两个步骤。

在ANSICD的任何一种是实现中,存在两个不同的环境。
第一种是翻译环境,在这个环境中有源代码转换为可执
行的机器指令(二进制代码)。
第二种是执行环境,让用于实际执行(运行)代码。

1.1 翻译环境

我们把一个(.c)文件编译最后生成一个可执行程序的时候,她所依赖的整个过程或者说这个环境叫做翻译环境。而我们(.c)文件里面放的是我们的C代码是文本文件是以ASCLL码的形式展示出来的,我们能看懂的。

编译本身又分为几个阶段:
在这里插入图片描述

我们在实现一个程序的时候肯定不止写一个源文件的,而是写很多个单独源文件,最后将每一个源文件一一的链接起来,而我们的每个源文件都会单独的经过编译器处理生成单独的目标文件(.obj),而我们的每个目标文件都经过连接器加上链接库处理将所有的目标文件链接起来形成我们的可执行文件。
总上:

1:组成一个程序的每个源文件通过编译过程分别
转换成目标代码(object code)(目标文件)(test.obj)。
2:每个目标文件由连接器(linder)捆绑在一起,形
成一个单一而完整的可执行程序。
3:连接器同时也会引入标准C函数库中的任意被该
程序所用到的函数,而且它可以搜
索程序员个人的程序库,将其需要的函数也连接到
程序中。

1.2 执行环境

而我们产生一个可执行程序的时候,这个可执行程序如果想运行起来,最终想产生一个我们想要的结果,而这个运行起来所依赖的环境叫做执行环境。而我们产生的可执行文件里面存放的其实是二进制的信息,你可以把这个(.exe)文件理解成一个二进制文件。
程序执行过程:

1:程序必须载入内存中,在有操作系统的环境中:
一般这个由操作系统完成,在独立的环境中,程
序的载入必须由手工安排,也可能是通过可执行代
码置入只读内存来完成。
2:程序的执行便开始,接着就便调试main函数
3:开始执行程序代码,这个时候程序使用一个运行
时堆栈(stack),存储函数的局部变量和返回地
址,程序同时也可以使用静态(static)内存,存储
于静态内存中的变量在程序的整个过程一直保留它
们的值。
4:种植程序。正常终止main函数:也有可能是意外终止。

用一张图来总结我们的可执行程序的生成:
在这里插入图片描述

2. 预处理(编译)详解

2.1 预定义符号

__FILE__ //进行编译的源文件的路径
__LINE__//输出文件当前的行号
__DATE__//输出文件编译的日期
__TIME__//输出文件编译的时间
__STDC__//如果编译器遵循ANSIC C ,其值为1,
否则未定义。这里VS不完全遵循,所以未定义

我们来实现一下:

int main()
{
	printf("%s\n", __FILE__);
	printf("%d\n", __LINE__);
	printf("%s\n", __DATE__);
	printf("%s\n", __TIME__);
	//printf("%d\n", __STDC__);//未定义
	return 0;
}

效果展示:
在这里插入图片描述

2.2 #define

2.2.1 #define定义标识符

语法:

#define name stuff

这个意思是有个name代替stuff。
我们有一个简单的例子来实现一下:

#include <stdio.h>
#define MAX 100
int main()
{
	int max=0;
	max=MAX;
	printf("%d",max);
	return 0;
}

#define不仅仅可以定义数字,也可以定义关键字创建一个简短的名字,也可以是函数等。

如果定义的stuff过长,可以分成几行写,除了最后一行外,每行的后面都要加上一个反斜杠(继续符)。
例如:

#define PRINT printf("file:%s\tline:%d\t\
						date:%s\time:%s",\
						__FILE__,__LINE__,\
						__DATE__,__TIME__);

到这里我们思考一个问题,就是在define定义标识符的时候,要不要在后面加上(;)
比如:

#define MAX 100 ;
#define MAX 100

答案是:尽量不要加,因为有时候可能会出错。

#define MAX 100;
int main()
{
	printf("%d", MAX);
	return 0;
}

我们看上述代码,我们的目的是打印出MAX所代替的100,但是这里会报错的,因为#define定义的标识符起的是代替的作用,也就是说这里是把100;用MAX代替了,而不是100用MAX代替了,他会将后面的分号一同替换过去,所以我们本质上大一的是:

printf("%d", 100; );

这样显然会出现错误的,所以我们在用define定义标识符的时候尽量不要加上分号。

2.2.2 #define定义宏

#define机制包括了一个规定,允许把阐述替换到文本中,
这种实现通常称为宏(macro)或者定义宏(define macro))。

下面是宏的申明方式:

#define name(parament-list) stuff 其中的parament-list是一个由逗号隔开的符号表,它们
可能出现在stuff中。

注意:参数列表的左括号必须与name紧邻。如果两者之间由任何空白存在,参数列表就会被解释为stuff的一部分。

例如:#define SQUARE(x) x*x

我们来简单的使用一下:

#define SQUARE(X) X*X
int main()
{
	int ret = SQUARE(3);
	printf("ret = %d \n", ret);
	return 0;
}

效果展示:
在这里插入图片描述
这里我们分析一下,首先我们定义的宏#define SQUARE(X) XX它的意思是用SQUARE(X)代替XX,所以我们在main函数里面的ret=SQUARE(X),其实就是ret=XX,到了我们的打印阶段打印ret的内容是就是33=9。

那我们想一下如果我们把上述代码的3改成2+1会怎能办,我们就可能回想了,既然值都是一样的那结果肯定一样的,但是这里我想说的是,结果可不是我们想象的那么简单。
在这里插入图片描述
答案显示的是5不是我们想要的9,这是为什么呢。这里需要考虑到两点:第一就是我们的宏定义不是传参的过程,虽然这里 SQUARE(2+1)很像一个函数,把2+1当作参数传过去,但是我们要知道的是宏定义的本质是替换,就是字面上的意思,就是单单的替换。第二就是优先级,如果这个宏的表达式里面的操作符的优先级和替换过去的表达式里面的某些操作符的优先级不相同,就可能导致表达式的计算顺序发生改变,所计算出来的结果不是我们所期望的。
上述的ret其实是:ret=2+1*2+1这样的,因为宏定义是替换的结果,所以这里由于优先级的关系先计算了乘法后算了加法,导致计算顺序发生改变,伴随着计算结果出现错误。

所以上面的本质结果是优先级的不同导致了计算顺序不同进而导致了计算结果不同,这里我们就可以针对优先级来进行改进,改进后的计算方式是先计算2+1=3,在计算乘法。

#define SQUARE(X)  ((X)*(X))

在这里插入图片描述
这我们通过加上括号的方式提升优先级来进行计算就是个很好的方法。

所以为了明确的表明它的计算顺序希望你能够给这个宏替换过去的这个内容分别加上括号,让它称为独立的部分,这样就不容易出错。

总的来将就是当多对数值表达式进行求值的宏定义都应该对其进行加上括号的方式来提升它的优先级,避免在使用宏时由于参数的操作符或者邻近操作符之间不可预料的互相作用。

2.2.3 #define的替换规则

在程序中扩展#define定义符号和宏时,需要涉及几个步骤。
1:在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号,如果是,他们首先被替换。
2:在替换文本随后插入到程序中原来文本的位置,对于宏,参数名被他们的值替换。
3:最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号,如果是,就重复上述处理过程。
注意:
1:宏参数和#define定义中可以出现其他#define定义的变量,但是对于宏,不能出现递归。
2:当预处理器搜索#define定义的符号时候,字符串常量的内容并不被搜索。

2.2.4 #和##的作用

#的作用:
如何把宏的参数插入到字符串中呢?
举个例子:

void print(int x)
{
	printf("the value of x is %d\n", x);
}
int main()
{
	int a = 10;
	int b = 20;
	print(a);
	print(b);
	return 0;
}

我们本来的意图是打印两行字符串,分别是:
the value of a is 10
the value of b is 20
在这里插入图片描述
但是我们打印的却是没有把x用a,b代替。
在讲#的作用的时候我们先了解一下字符串的打印:
在这里插入图片描述
从上述的printf我们可以看到的是无论我们多少对字符串,最后字符串都会拼接在一起,这里我们呢知道了,字符串有自动拼接的特点。

这个时候我们就可以对代码进行改进一下:

#define PRINT(X) printf("the value of "#X" is %d\n",X)
int main()
{
	int a = 10;
	int b = 20;
	PRINT(a);
	PRINT(b);
	return 0;
}

得到的结果就是:
在这里插入图片描述
这里的#X,中的X不会进行替换,#X标识的是X所表达的那个内容所对应的字符串

##的作用:
##可以把位于两边的符号合成一个字符号,它允许定义从分离的文本片段创建标识符。
举个例子:

#define AB(X,Y) X##Y
int main()
{
	printf("%s", AB("hello", " world"));
	return 0;
}

展示效果:
在这里插入图片描述

2.2.5 带副作用的宏参数

定义:副作用就是表达式求值的时候出现的永久性效果。

当宏参数在宏的定义中出现超过一的时候,如果参数带有副作用,那么你在使用的时候就可能出现危险,导致不可预测的后果。

下面我们举一个简单的例子:

#define MAX(X,Y) ((X)>(Y)?(X):(Y))
int main()
{
	int a = 10;
	int b = 11;
	printf("%d\n", MAX(a, b));
	printf("%d\n", a);
	printf("%d\n", b);
	return 0;
}

这个结果显然是可以知道的,打印得结果是10,11,但是如果将printf里面的a和b改进一下问题就出现了。

#define MAX(X,Y) ((X)>(Y)?(X):(Y))
int main()
{
	int a = 10;
	int b = 11;
	printf("%d\n", MAX(a++, b++));
	printf("%d\n", a);
	printf("%d\n", b);
	return 0;
}

你认为这个打印的结果还是原来的吗?我们来分析一下,我们已经知道了宏定义的本质是替换,那我们就进行替换一下,MAX(a++, b++)==((a++)>(b++)?(a++):(b++))。这是一个条件语句,首先先判断(a++)>(b++)?,a和b都是前置++,是先使用后++,就是10>11?,当这条判断过后a和b都++,这个时候a=11,b=12,而刚才的判断为假所以输出b++,这里也是个前置++,先使用后++,所以第一个printf输出的就是12,第二个printf输出的就是11,的三个输出的就是13.
我们看一下输出结果:
在这里插入图片描述

2.2.6 宏的参数对比

像上述的求最大值的方法我们用函数也能做到。

int Max(int x, int y)
{
	return (x > y ? x : y);
}
int main()
{
	int a = 10;
	int b = 11;
	int max = Max(a, b);
	printf("%d\n", max);
	max = MAX(a, b);
	printf("%d\n", max);
	return 0;
}

宏通常被应用与执行简单的运算,比如在两个数中找较大的一个。
#define MAX(X,Y) ((X)>(Y)?(X):(Y))
那为什么不用函数来是是实现这个任务呢?原因有两个:
1.用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度上更胜一筹。
2.更重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。
反之宏可以适用与整形,长整型,浮点型等可以用>来比较的类型。宏时类型无关的。

当然宏相比于函数也有劣势的地方:
1.每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏别较短,否则可能大幅度增加代码的长度
2.宏是没法调试的。
3.宏由于类型无关,也就不够严谨 。
4.宏可能会带来运算符优先级的问题,导致程序容易出现错误。

宏有时候可以做到函数做不到的事情,比如:宏的参数可以出现类型。

#include <stdlib.h>
#define MALLOC(num,type) (type*)malloc(10*sizeof(type))
int main()
{
	int* q = (int*)malloc(10 * sizeof(int));
	int* p = MALLOC(10, int);
	return 0;
}

总结一下:

属性#define函数
代码长度每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增加。函数代码只会出现一个地方;每次使用这个函数时,都会调用那个地方的同一份代码。
执行速度更快存在函数的调用和返回的额外开销,所以相对慢一些。
操作优先级宏参数的求值是在所有周围表达式的上下文环境,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多写括号。函数参数只在函数调用的时候求值一次,它的结果值传递个函数,表达式的求值结果更容易预测。
带有副作用的参数函数参数只在函数调用的时候求值一次,它的结果值传递个函数,表达式的求值结果更容易预测。函数只在传参的时候求值一次,结果更容易控制。
参数类型宏的参数与类型无关,只要对参数的操作是合法的它就可以适用于任何参数类型。函数的参数是与类型有关的如果参数的类型和函数的类型不同,就需要不同的函数,即使他们执行的任务是不同的。
调试宏是不方便调试的函数是可以逐个语句调试的。
递归宏不能递归函数可以递归

2.3 命名约定

一般来讲函数的宏的使用语法很相似。所以语言本身没
法帮助我们区分二者,那我们平时的一个习惯是:把宏
的名全部用大写 函数名不要全部大写。

2.4 #undef

作用:用于移除一个宏定义。

如果现存一个名字需要重定义,那么它的旧名字首先要被移除。

例如:

#define MAX 100
int main()
{
	printf("%d", MAX);
#undef MAX
	printf("%d", MAX);
	return 0;
}
这里你可以认为#undef把#define MAX 100给删掉了。

2.5 命令行定义

许多C的编译器提供了一种功能,允许在命令中定义符号。
用于启动编译过程。例如:当我们根据同一个文件要
编译出不同的一个程序的不同版本的时候,这个特性有点
用处。(假定某个程序中声明了一个某个长度的数
组,如果机器内存有限,我们需要一个很小的数组,但是
另一个机器内存大写,我们需要一个数组能够大写。

2.6 条件编译

在编译一个程序的时候我们如果要将一条语句(一组语句))编译或者放弃是很方便的。因为我们有条件编译指令。
调试的代码,删除可惜,保留又碍事,所以我们呢可以选择性的编译。

1:

 #if 常量表达式
 ·····
 #endif
int main()
{
	int arr[100] = { 1,2,3,4,5,6,7,8,9,0 };
	int i = 0;
	for (i = 0; i < 10; i++)
	{
#if 1//1为真执行,0为假不执行
		printf("%d ", arr[i]);//定义后就可是使用了。(预处理阶段处理)
#endif
	}
	return 0;
}

2.多个分支的条件编译


 ····
 #if 常量表达式
 ····
 #elif 常量表达式
 ····
 #endif
int main()
{
#if 1==1
	printf("haha\n");
#elif 1==2
	printf("hehe\n");
#else
	printf("hello\n");
#endif
	return 0;
}

3.判断是否被定义


 #if defined(symbol)
 #ifdef symbol
 ····
 #endif
#define DEBUG
int main()
{
#if defined(DEBUG)
	printf("hehe\n");
#endif
//	//一样的写法
#ifdef DEBUG
	printf("hehe\n");
#endif
}
int main()
{
#if !defined(DEBUG)
   printf("hehe\n");
#endif
   //一样的写法
#ifndef DEBUG //#ifnfef比#ifdef就是由not的意思
   printf("hehe\n");
#endif
}

4.嵌套指令


 #if defined(OS_UNIT)
		    #ifdef OPTION1
					unix_version_option1()
		    #endif
		    ifdef OPTION2
					unix_version_option2()
			#endif
 #elif defined(OS_MSDOS)
			#ifdef OPTION2
					msdos_version_option2();
			#endif
 #endif

2.7 文件包含

#include <filename.h>
查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。
这样是不是可以说,对于库文件也可以使用" "的形式包含?答案是肯定的,可以。
但是这个样查找的效率就会降低,当然这样也不容易区分是库文件还是本地文件了。

头文件的包含方式:

  1. #include <filename.h>
  2. #include “filename.h”
    这两种文件包含方式主要有两个区别。
    1.应用场景不同:<>是专门引用库函数的头文件的,而"“一般是应用自定义的头文件。
    2.查找策略不同:<>首先是在编译器自带的库里面查找,找不到在去自定义文件里面查找。
    而” "就与之相反,先在自定义文件里面的查找,再到编译器自带的库里面查找。

2.7.1嵌套式包含

当我们在写一个大型的程序的时候,难免会写很多个源文件甚至是头文件,这个时候如果每个文件都是由不同的程序员所编写 ,那程序员在自己的编译器可能会引用相同的头文件,这个时候入股将所有的文件都链接起来,也就是互相引用,那这个时候就会出现同一个头文件被多次的引用,难免编译的时候可能出现错误,而且影响代码的可读性。
一下有两个条件编译解决此方法:
1:

 #ifndef __TEST_H__
 #define __TEST_H__
 ···(头文件内容)
 #endif

这个理解起来也很好首先 #ifndef _ TEST_H_判断是否定义,没定义就定义,之后再引用的时候就不会被引用了。
2:

 方法2:
 #pragma once
 ···(头文件内容)

2.8 offsetof

我们之前学习结构体的时候有一个函数是计算结构体的偏移量,就是offsetof
我们来实现一下:

#include <stddef.h>
struct S
{
	char c1;
	int a;
	char c2;
};
int main()
{
	printf("%d\n", offsetof(struct S, c1));
	printf("%d\n", offsetof(struct S, a));
	printf("%d\n", offsetof(struct S, c2));
	return 0;
}

展示效果:
在这里插入图片描述
现在我们来用宏定义模拟实现一下offsetof。

struct S
{
	char c1;
	int a;
	char c2;
};
#define OFFSETOF(struct_name,member_name) (int)&(((struct_name*)0)->member_name)
int main()
{
	printf("%d\n", OFFSETOF(struct S, c1));
	printf("%d\n", OFFSETOF(struct S, a));
	printf("%d\n", OFFSETOF(struct S, c2));
	return 0;
}

在这里插入图片描述

我们分析一下既然要计算偏移量,就是计算某个地址相对于初始地址的位置,那我们就可以将初始的地址用0标识也就是说将0强制传换成初始位置的地址,这样计算偏移量的时候其实就是输出他所在的地址了。

  • 11
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

初阳hacker

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值