《C和指针》读书笔记(第十四章 预处理器)

0 简介

编译一个C程序涉及到很多步骤,其中第一个步骤就是预处理阶段(preprocessing)阶段。C预处理器在源代码编译之前对其进行一些文本性质的操作。它的主要任务包括删除注释、插入被#including指令包含的文件的内容、定义和替换由#define指令定义的符号以及确定代码的部分内容是否应该根据一些条件编译指令进行编译。

本期内容框架图:
在这里插入图片描述

1 预定义符号

预处理器定义了一些符号,给我i们的程序调试和版本生成等提供了很大的便利。举个例子:

#include <stdio.h>

int main()
{
    
    

	printf("This obj file name is:%s\n",__FILE__);
	printf("Current line is:%d\n", __LINE__);
	printf("-------------------------\n");
	printf("Today is:%s\n", __DATE__);
	printf("The time now is:%s\n", __TIME__);


	system("pause");
	return 0;
}

打印输出:
在这里插入图片描述
可以看到我们当前运行文件的名称,打印语句的行编号,还有编译的日期和时间都被打印了出来。

注意:此处的日期和时间是文件被编译的日期和时间,而不是运行时候的日期和时间。

2 #define

第二章是我们的重中之重,其中包含了宏定义的方法,意义和需要注意的地方。

2.1 宏

#define机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或者定义宏(defined macro)。
举个例子,我们采用宏定义了一个平方运算:

#include <stdio.h>
#define SQUARE(x) x*x
int main()
{
    
    
	int a = 5;
	printf("%d\n",SQUARE(a));

	system("pause");
	return 0;
}

打印输出:
在这里插入图片描述
但这样的方法有时候会出现问题,比方说:

#include <stdio.h>
#define SQUARE(x) x*x
int main()
{
    
    
	int a = 5;
	printf("%d\n",SQUARE(a + 1));

	system("pause");
	return 0;
}

打印输出:
在这里插入图片描述
按照我们的预想,应该输出6的平方,也就是36的,这是为什么呢?

与函数的实现方式不同,宏这里仅仅做了简单的替换,而没有做参数传递等工作。
可以做个对比:

#include <stdio.h>
#define SQUARE(x) x*x
int square(const int x)
{
    
    
	return x * x;
}
int main()
{
    
    
	int a = 5;
	printf("%d\n", SQUARE(a + 1));
	printf("%d\n", square(a + 1));

	system("pause");
	return 0;
}

打印输出:
在这里插入图片描述
可以发现,如果是函数的形式,执行结果就与我们的想法一致,这是因为,宏仅仅做了简单的替换工作,也就是说,执行了5+1*5+1,自然运行结果就是11

但我们也可以使用宏定义达到和预想一致的结果:

#include <stdio.h>
#define SQUARE(x) (x)*(x)
int square(const int x)
{
    
    
	return x * x;
}
int main()
{
    
    
	int a = 5;
	printf("%d\n", SQUARE(a + 1));
	printf("%d\n", square(a + 1));

	system("pause");
	return 0;
}

打印输出:
在这里插入图片描述
问题解决!

2.2 #define替换

这部分主要讲了如何将宏参数插入到字符串常量中,比方说,我们要用宏重新定义一个打印函数:

#include <stdio.h>
#define PRINT(FORMAT, VALUE)  \
		printf("The value of "VALUE" is "FORMAT"\n",VALUE)
int main()
{
    
    
	int a = 5;
	PRINT("%d", a);
	PRINT("%d", a + 3);

	system("pause");
	return 0;
}

这个时候就程序就会报错,因为无法做到准确的传值,这时候就需要我们进行简单的转换,将传入的表达式转换为字符串。

#include <stdio.h>
#define PRINT(FORMAT, VALUE)  \
		printf("The value of "#VALUE" is "FORMAT"\n",VALUE)
int main()
{
    
    
	int a = 5;
	PRINT("%d", a);
	PRINT("%d", a + 3);

	system("pause");
	return 0;
}

打印输出
在这里插入图片描述
这个时候,宏定义的优势就体现了出来,发现要实现这样的功能,函数的封装就稍显乏力了。

2.3 宏与函数

通过以上的例子我们可以发现,在很多时候,宏与函数的作用都很类似,可以互相替换。但是二者也有不同之处,书上有个表格看着十分清楚:

属性 #define宏 函数
代码长度 每次使用都会被插入到程序中,所以宏的内容不宜过多 函数每次调用都是同一份代码,对内存相对更加友好
执行速度 更快 存在函数调用/返回的额外开销
操作符优先级 宏参数的求值是在所有周围表达式的操作环境里,所以必须加上括号 表达式的结果更加容易预测
参数求值 参数每次用于宏定义时,都会被重新求值。由于多次求值,具有副作用的参数可能产生不可预料的结果 参数的副作用并不会造成任何忒书的问题
参数类型 宏与类型无关,只要操作是合法的,可以使用任何参数类型 函数的参数与类型强相关,参数的类型不同,就需要定义不同的函数,或者需要对实参进行强转

可以看出,宏与函数各有千秋,各有胜负,所以在合适的场合选择合适的实现方法,量体裁衣才是最好的选择。

2.4 带副作用的宏参数

当参数有副作用的时候,我们的宏往往能产生意想不到的结果,在我们的实际开发中需要格外注意。比方下面这个例子:

#include <stdio.h>
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int main()
{
    
    
	int x = 2, y = 5, z = 0;
	z = MAX(x++, y++);
	printf("x = %d, y = %d, z = %d",x, y, z);
	system("pause");
	return 0;
}

z的值应该是多少呢?如果这个max用函数进行实现,肯定可以很容易地得出答案,然而在宏定义中似乎就没有这么简单了,因为宏定义需要多次用两个表达式替换ab

但是多加思考,发现其实也不难,x++被执行了两次,而y++被执行了三次,所以答案就一目了然了,打印输出:
在这里插入图片描述
那,如果是++x和++y呢?我们可以自己进行探索。。。

2.5 命名约定

很多时候为了更好地开发,从形式上对宏和函数进行区分是十分必要的,一种常见的方式是将宏名字全部大写。相信这点很多人在开发中已经体会到了。

2.6 #undef

这条预处理指令用于移除一个宏定义。
声明方式:

#undef name

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

2.7 命令行定义

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

在windows的常见编译器中,并不会用到相关内容,故略去。

3 条件编译

3.1 是否被定义

3.2 嵌套指令

即使在大型的项目开发中,嵌套指令也很少用到。而且即使遇到了也不难理解。书上给出了基本形式,本文不再赘述。

#ifdef OS_UNIX
	#ifdef OPTION1
		unix_version_of_option1();
	#endif
	#ifdef OPTION2
		unix_version_of_option2();
	#endif
#elif defined OS_MSDOS
	#ifdef OPTION2
		msdos_version_of_option2();
	#endif	
#endif

同条件语句的嵌套类似,所以相对来时比较容易读懂。

4 文件包含

在实际开发中,稍大的项目都会采用模块化开发的方式,这样可以避免了很多麻烦。使得代码的开发,迭代,阅读都方便很多。

文件包含便很好地诠释了这一思想。

4.1 函数库文件包含

所谓的函数库文件就是我们在编程之前,已经为我们写好的一些程序(一般是函数)。我们有时候称其为接口。我们一般采用尖括号进行包含。如我们此前的程序:

#include <stdio.h>

这样在程序编译和运行的时候,电脑才能够识别printf等语句。

4.2 本地文件包含

所谓的本地文件包含一般指我们自己写的本地文件。举个例子:
我们创建一个print.c文件,并写如下函数:

#include <stdio.h>
#include "print.h"
void fun_print(char *x)
{
    
    
	if (x != NULL)
	{
    
    
		printf("%s",x);
	}
}

然后创建print.h文件,对其进行声明:

#pragma once
void fun_print(char *x);

最后,在main.c文件中进行调用:

#include <stdio.h>
#include "print.h"
int main()
{
    
    
	fun_print("Hello world!");
	system("pause");
	return 0;
}

这就是本地文件包含,为了与系统文件包含进行区分,一般会采用双引号

4.3 嵌套文件包含

在大型的项目开发中,存在大量的源文件和头文件,显然也存在着错综复杂的包含关系,嵌套的包含关系便成了一种普遍存在的现象,这本身没什么,但是如果出现了多重包含的现象,在编译的时候编译器就会报错,无法编译通过。

也就是说,多重包含会直接影响到程序的运行效率或者内存大小。具体的内容可以查相关资料。那应该如何避免这个问题呢?
一般为了防止头文件多重包含,然后编译的时候出现多重定义的情况,我们可以采如下的方法:

#ifndef __HEADERNAME__H
#define __HEADERNAME__H 1

或者

#pragma once

#pragma once是一种用于头文件保护的预处理指令。它的作用是确保同一个文件不会被多次包含,避免在编译过程中引发重定义错误。可以说,它的作用类似于#ifndef和#define的组合。相比于#ifndef的方式,#pragma once更加简洁、高效,并且可以针对整个文件进行保护。

然而,需要注意的是,#pragma once是非标准的方式,虽然被大多数编译器广泛支持,但也存在兼容性问题。一些较老的编译器可能不支持#pragma once,因此在选择使用时需要考虑到项目的兼容性。

综上所述,选择使用哪种方式取决于具体情况,并可以根据团队的开发规范进行约定。只要能合理地避开缺点,这两种方式都是可以接受的。

5 其他指令

#error是C语言预处理指令之一,在编译阶段检查源代码中是否存在错误,并在编译时输出错误信息。当编译器遇到#error指令时,会停止编译并显示#error后的错误信息。

#include <stdio.h>
#include "print.h"	
int main()
{
    
    
#ifdef PRINT
	fun_print("Hello world!");
#else
	#error Print is not defined!
#endif
	system("pause");
	return 0;
}

点击运行,发现无法执行编译,直接输出错误内容:
在这里插入图片描述
当然,不同的IDE可能会以不同的形式报错。

#progma指令是另一种机制,用于支持因编译器而异的特性。比方说我们前文提到的内容

#pragma once

就可以防止头文件重包含。

所谓的支持因编译器而异的特性就是说,同样的#pragma指令,在不同的编译器中可能会产生不同的效果,要视具体情况而定。

6 总结

编译C程序的第一步就是对它进行预处理,预处理器共支持5个符号。

#define指令可以用于”重写“C语言,使它看上去像是其他语言。

条件编译可以在不同条件下执行不同的代码段,这样做比大面积屏蔽程序方便很多,在大型项目开发中有广泛的使用。

在实际开发中,要尽量避免头文件的多重包含,尽管有时候开发环境并不会直接报错误或者警告。

猜你喜欢

转载自blog.csdn.net/weixin_43719763/article/details/132798855