C语言最后一讲——预处理超详解


1. 预定义符号

C语言设置了一些预定义符号,可以直接使用,预定义符号也是在预处理期间处理的。

__FILE__ //进行编译的源文件
__LINE__ //文件当前的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义

使用举例:

printf("file:%s line:%d\n", __FILE__, __LINE__);

运行结果:
结果

2. #define 定义常量

基本语法:

#define name stuff

使用举例:

//1. 定义常数
#define MAX 1000
int a[MAX];
//2. 定义关键字
#define wl while //为 while 这个关键字,创建一个简短的名字
#include<stdio.h>
int main()
{
	int a = 10;
	int b = 5;
	wl(a-- > b)
		printf("%d ", a);
}

结果:
输出

//3. 
#define do_forever for(;;) 
//用更形象的符号来替换,这个 for 循环等价于 while(1)
#include<stdio.h>
int main()
{
	do_forever
		printf("1");
}

输出:(死循环地打印1
输出

//4. 
#define CASE break;case //在写case语句的时候自动把 break写上。
#include<stdio.h>
int main()
{
	int a = 10;
	switch (a)
	{
		CASE 10:
			printf("10");
		CASE 1:
			printf("1");
		CASE 2:
			printf("2");
	}
	return 0;
}

输出:
输出
如果这里没有使用预定义,而是直接使用 case 的话,输出结果是什么呢?

//5. 
//如果定义的 stuff 过⻓,可以分成几行写,
//除了最后一行外,每行的后面都加一个反斜杠(续行符)。
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
 date:%s\ttime:%s\n" ,\
 __FILE__,__LINE__ , \
 __DATE__,__TIME__ )

思考:在define定义标识符的时候,要不要在最后加上;?
比如:

#define MAX 1000;
#define MAX 1000

建议不要加上 ;,这样容易导致问题。
比如下面的场景:

if (condition)
	max = MAX;
else
	max = 0;

上篇博客中我们讲过:#define 会在预处理阶段展开,而对于预定义来说,展开就是把出现 name 的地方直接替换为 stuff,所以如果是第一行定义,这个代码展开后就是这样的:

if (condition)
	max = 1000;;
else
	max = 0;

这样看似乎并没有太大的不妥,但实际上,if 后面如果没有花括号,只能跟着一句代码,但是这里的两个;实际上代表着两句代码,只不过第二个代码是空的,所以会导致语法报错

3. #define定义宏

#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为定义宏
下面是宏的申明方式:

#define name( parament-list ) stuff

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

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

使用举例:

#define SQUARE( x ) x * x

这个宏接收一个参数 x.如果在上述声明之后,你把 SQUARE(5);置于程序中,预处理器就会用下面这个表达式替换上面的表达式:5 * 5

但是注意宏定义有一个非常重要的问题:
请看:

int a = 5;
printf("%d\n" ,SQUARE( a + 1) );

乍一看,你可能觉得这段代码将打印36,事实上它将打印11,为什么呢?
替换文本时,参数x被替换成a+1,所以这条语句实际上变成了:

 printf ("%d\n",a + 1 * a + 1 );

根据运算符的优先级,很容易发现实际运算顺序与我们预期的并不相同。

这个问题也很好解决,只要多带一些括号就可以了:

#define SQUARE(x) (x) * (x)

这样,结果就变为了:

printf ("%d\n",(a + 1) * (a + 1) );

和我们预期的就一样了。

这样就大功告成了吗?我们再来看一个:

#define DOUBLE(x) (x) + (x)
int a = 5;
printf("%d\n" ,10 * DOUBLE(a));

输出结果是多少?
我们来替换一下:

 printf ("%d\n",10 * (5) + (5));

通过运算符的优先级,很容易发现这与我们预期的顺序依然不一样。
这个问题的解决办法是在宏定义表达式两边加上一对括号就可以了。

#define DOUBLE( x) ( ( x ) + ( x ) )

这样就能得到预期的结果了。

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

4. 带有副作用的宏参数

宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。
副作用就是表达式求值的时候出现的永久性效果

例如:

x+1;//不带副作用
x++;//带有副作用

下面的MAX宏可以证明具有副作用的参数所引起的问题:

#define MAX(a, b) ( (a) > (b) ? (a) : (b) )
#include<stdio.h>
int main()
{
	int x = 5;
	int y = 8;
	int z = MAX(x++, y++);
	printf("x=%d y=%d z=%d\n", x, y, z);//输出的结果是什么?
	return 0;
}

解决这道题需要知道预处理之后的结果是什么:

z = ( (x++) > (y++) ? (x++) : (y++));

可以发现,x自增了两次,且在第二次自增之前被赋值给了z,而y只自增了一次。所以结果是:
结果

5.宏替换的规则

在程序中扩展#define定义符号和宏时,需要涉及几个步骤

  1. 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。
  2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被它们的值所替换
  3. 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程

注意:

  1. 宏参数#define 定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归
  2. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索

6. 宏函数的对比

宏通常被应用于执行简单的运算
比如在两个数中找出较大的一个时,写成下面的宏,更有优势一些:

#define MAX(a, b) ((a)>(b)?(a):(b))

那为什么不用函数来完成这个任务?
原因有二(和函数相比宏的优势):

  1. 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多(感兴趣可以自行了解:函数栈帧的创建和销毁)。所以宏比函数在程序的规模和速度方面更胜一筹。
  2. 更为重要的是函数的参数必须声明为特定的类型,所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以使用>来比较的类型。宏的参数是类型无关的

和函数相比宏的劣势:

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

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

#define MALLOC(num ,type)\
 (type*)malloc(num * sizeof(type))

//使用
MALLOC(10, int);//类型作为参数
//预处理器替换之后:
(int*)malloc(10 * sizeof(int));

函数与宏的对比:

属性#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 10

7. 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 (x>y?x:y); \
}

仔细研究就会发现,这个宏定义可以在调用后留下一个函数,如果传递的是 int ,就会留下一个名为 int_max 的函数,可以接受两个 int 类型的值,并返回较大值。
而这个函数是可以正常被调用的。

//宏定义
#define GENERIC_MAX(type) \
type type##_max(type x, type y)\
{ \
	return (x>y?x:y); \
}

GENERIC_MAX(int) //替换到宏体内后 int##_max 生成了新的符号 int_max 作函数名

GENERIC_MAX(float) //替换到宏体内后 float##_max 生成了新的符号 float_max 作函数名

#include<stdio.h>
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;
}

运行结果:
结果
可以看到,这两句宏产生的函数顺利完成了任务。

8. 命名约定

一般来讲函数的宏的使用语法很相似,所以语言本身没法帮我们区分二者。

那我们平时的一个习惯是:
把宏名全部大写
函数名不要全部大写

9. #undef

这条指令用于移除一个宏定义。

#undef NAME

这个命令之后的代码中,NAME 的宏定义失效。
如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除。

10. 命令行定义

许多C的编译器提供了一种能力,允许在命令行中定义符号,用于启动编译过程

例如:当我们根据同一个源文件要编译出一个程序的不同版本的时候,这个特性就会有点用处。
(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大些,我们需要一个数组能够大些。)

//注意:在 VS 等集成开发环境中这个代码是无法编译的!!
#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;
}

编译指令:

gcc -D ARRAY_SIZE=10 programe.c

上面这个就是将代码中这个没有赋值的符号赋值为10,再进行编译。

11. 条件编译

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

#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__
	}	//#endif 用于终止 #ifdef 后的语句
	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
#endif

12. 头文件的包含

12. 1 头文件被包含的方式:

12. 1. 1 本地文件包含

#include "filename"

查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在
标准位置查找头文件
。如果找不到就提示编译错误

Linux环境下的标准头文件的路径:

 /usr/include

VS环境的标准头文件的路径:

C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\include
//这是 VS2013 的默认路径

注意按照自己的安装路径去找。
另外,在比较新的VS版本中,标准头文件不是都存放在这个文件夹中了,如果想要找到它们,有两种办法:

  1. 在VS编译器的代码中包含这个头文件,#include<stdio.h>然后按住 ctrl ,点击头文件名,就会跳转到定义,也就是这个头文件的代码,就可以找到头文件的地址了。
    1
  2. 使用 everything 这样的工具查找,下载地址点这里(注意 everything 完全免费!
    ·
    首次加载需要一些时间,加载好之后,就可以在搜索栏搜索文件了,比如搜索 stdio.h,可能会出来很多个,找到你要找的编译器的那个就可以了,下图框起来的是 VS 编译器的。
    2

12. 1. 2 库文件包含

#include <filename.h>

查找头文件直接去标准路径下去查找,如果找不到就提示编译错误
这样是不是可以说,对于库文件也可以使用“”的形式包含?
答案是肯定的,可以,但是这样做查找的效率就低些,当然这样也不容易区分是库文件还是本地文件。

12. 2 嵌套文件包含

我们已经知道, #include 指令可以使另外一个文件被编译。就像它实际出现于 #include 指令的地方一样。
这种替换的方式很简单:预处理器先删除这条指令,并用包含文件的内容替换
一个头文件被包含10次,那就实际被编译10次,如果重复包含,对编译的压力就比较大

如何解决头文件被重复引入的问题?
答案是条件编译

每个头文件的开头写:

#ifndef __TEST_H__
#define __TEST_H__
//头文件的内容
#endif //__TEST_H__

或者

#pragma once
//VS2022中新建有文件会自动带上这个代码

这两个代码均可以避免头文件的重复包含。

高质量C/C++编程指南》中附录的考试试卷 笔试题:
|
1.头文件中的 ifndef/define/endif是干什么用的?
2. #include <filename.h>#include "filename.h"有什么区别?

13. 其他预处理指令

#error
#pragma
#line
//不做介绍,自己去了解。
#pragma pack()在结构体博客中介绍过。

谢谢你的阅读,喜欢的话来个点赞收藏评论关注吧!
我会持续更新更多优质文章

  • 22
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值