【C语言知识精讲】程序员必会的预编译知识

118 阅读10分钟

​本文已参与「新人创作礼」活动,一起开启掘金创作之路

一、预定义符号讲解

__DATA__: 文件被编译的日期
__TIME__: 文件被编译的时间
__FILE__: 进行编译的源文件
__LINE__: 文件当前的行数
__STDC__: 如果编译器遵循ANSI C,其值为1,否则未定义
以上符号都是语言内置的。一个简单的应用就是用来写日志,如下:

	int main()
	{
		FILE* pf = fopen("log.txt", "w");
		if (pf == NULL)
		{
			printf("%s", strerror(errno));
			return 0;
		}
		printf("%s %s %s %d", __DATE__, __TIME__, __FILE__, __LINE__);
		return 0;
	}

大家可以自行尝试检测其打印结果。需要注意的是,在VS编译器下,__STDC__是未定义的。

二、#define详解

①#define的替换规则

 无论是定义宏还是定义表示符,本质上都只是做一个替换。首先查找代码中是否有#define替换的字符,如果有则将其替换,替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的所替换。

注意点:

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

②#define定义标识符

#define可以替换的内容多种多样,既可以帮助我们简化代码,也可以增强代码的可读性。大家可以看以下的例子:

	#define ROW 10
	#define DO_FOEVER while(1)     //用更形象的符号来表示
	#define CASE break;case        //妈妈再也不用担心我忘记写break了
	//如果定义的 stuff过长,可以分成几行写
	//除了最后一行外,每行的后面都加一个反斜杠(续行符)
	#define LOGPRINTF printf("%s %s %s %d", \
	__DATE__, __TIME__, __FILE__, __LINE__)

注意

#define后面最好不要加上 ";" ,否则容易产生意想不到的错误:

	#define MAX 100;
	int main()
	{
		int a = 10;
		int b = 20;
		int c = 1;
		if(a < b)
			c = MAX;
		else
			c = 0;
	} 

我们依据替换的原则对上面的代码进行替换后的得到的代码是这样的:

	int main()
	{
		int a = 10;
		int b = 20;
		int c = 1;
		if(a < b)
			c = 100;;
		else
			c = 0;
	} 

c = 100 后面跟了个空语句,else还怎么匹配呢?发生这样错误的时候一时半会还不容易发现错误,所以我还是建议大家#define后面都不要加上分号了。

③#define定义宏

什么是宏呢?

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

我们定义一个简单的宏:

	#define MAX(x, y) x > y ? x: y;
	int main()
	{
		int a = 10;
		int b = 20;
		int c = MAX(a, b);
		return 0;
	}

依照替换的原则我们进行还原:

	int main()
	{
		int a = 10;
		int b = 20;
		int c = a > b? a:b;
		return 0;
	}

想必大家理解了宏定义的使用。但需要注意的是,上面的写法是相当不严谨的,我们看下面的代码:

	#define DOUBLE(x) x * x
	int main()
	{
		int a = 10;
		int b = DOUBLE(2 + 2);
		printf("%d", b);
		return 0;
	}

上面的结果会使16吗?很遗憾,打印的结果是8。为什么是8呢?我们仍然依照替换的的规则进行还原

	int main()
	{
		int a = 10;
		int c = 2 + 2 * 2 + 2;
		printf("%d", c);
		return 0;
	}

我们需要原封不动的还原,所以用2 + 2替换所有的x,而不是4。那么我想你对结果就不会怀疑了吧。那么我们如何调整我们的宏定义来实现我们的预期功能呢?答案是尽可能多的使用括号,这样就可以确保万无一失了。

	#define DOUBLE(x) ((x) * (x))

宏定义注意事项总结

①尽可能多的使用扩号。否则还需要在使用的时候考虑优先级和结合性的影响 ②左括号必须于与替换名紧邻,中间不可以有括号。否则就会被理解成表示符而不是宏了。如上面的DOUBLE(x),左括号与DOUBLE之间是不可以有空格

④'#'的作用

首先介绍一下字符串一些有趣的性质: 我们可以这样打印:

	printf("hello ", "world","\n");

我们也可以这样玩:

	char* p = "hello ""world\n";
	printf("%s", p);

最终打印的结果都是 hello world。由此我们不难猜想,字符串具有自动连接的特点。那我们甚至可以这样玩——在字符串里嵌套一个字符串(注意只有宏 才可以实现)。

	#define PRINT(format, val)  printf("the value is "format"", val)
	int main()
	{
		PRINT("%d\n", 10);
		PRINT("%lf\n", 9.99);
		return 0;
	}

# 的作用:将一个宏参数变成字符串

我们利用'#'来玩出一些新花样吧,注意别把#val放在双引号内了,否则就会被认作是"#val",而不是"i"。

	#define PRINT(val)  printf("the value of " #val " is %d", val)
	int main()
	{
		int i = 10;
		PRINT(i);
		return 0;
	}

⑤'##'的作用

## 的作用:将两个符号合成一个符号。但是注意连接成的必须是一个合法的表示符,否则是未定义的。

听起来是不是很不可思议!我们来演示一下。

	#define CON(a, b) a##e##b
	
	int main()
	{
		printf("%d", (int)CON(2, 2));
	}

打印结果:200

⑥宏展开规则的补充

学习完 ### ,我们需要对宏函数的展开规则加以补充:

在展开当前宏函数时,如果 "形参"### 则不进行宏参数的展开,否则先展开宏参数,再展开当前宏。

举个例子,我们不难发下A并没有被展开为2而只是被当做是符号A 在这里插入图片描述

解决办法:多加一层中间转换宏. 加这层宏的用意是把所有宏的参数在这层里全部展开,因为在这层形参中没有出现 "#""##" ,所以这这一层先将宏展开,那么在转换宏里的那一个宏就能得到正确的宏参数.

在这里插入图片描述

⑦带副作用的宏参数

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

x + 1 不具有副作用 x++     具有副作用 我们也来写一段代码让大家认识到副作用的影响:

	#define MAX(x, y) ((x) > (y)? (x) : (y))
	
	int main()
	{
		int a = 5;
		int b = 10;
		int c = MAX(++a, ++b);
		printf("%d", c);
	}

这里需要大家对宏和函数进行区分,宏只是做一个替换,因此每个x都会被理解为a++,因此会有“副作用”的影响之说。

⑧宏和函数的对比(精华)

 上面提到了宏和函数的区别之一,现在我们来做系统的梳理。

属性#define定义宏函数
代 码 长 度宏的本质是替换,所以每次使用时,宏代码都会被插入到程序中。除了非常小的宏之外,程序的长度会大幅度增长函数代码只出现于一个地方;每次使用这个函数时,都调用那个地方的同一份代码
执行速度宏不存在执行速度,它只是查找替换存在函数的调用返回的额外开销,所以相对慢一些
操 作 符 优 先宏参数的求值是在所有周围表达式的上下文环境里,除非加上括号,否则邻近操作符的优先级可能会产生不可预料的后果,所以建议宏在书写的时候多些括号函数参数只在函数调用的时候求值一次,它的结果值传递给函数。表达式的求值结果更容易预测
有 副 作 用 的 参 数参数可能被替换到宏体中的多个位置,所以带有副作用的参数求值可能会产生不可预料的结果函数参数只在传参的时候求值一次,结果更容易控制
参 数 类 型宏的参数与类型无关,只要对参数的操作是合法的,它就可以使用于任何参数类型函数的参数是与类型有关的,如果参数的类型不同,就需要不同的函数,即使他们执行的任务是不同的
调 试宏是不能调试的,因为在预处理阶段就将全部的#define全部替换了,而调试是在生成可执行程序之后才能进行的函数是可以逐语句调试的
递归宏是不能出现递归的函数是可以递归的
命名 惯例宏名习惯全部大写函数名不要全部大写(大小驼峰)

 对于参数类型一栏我们还可以多说几句。宏的参数与类型无关这可以说是一把双刃剑:从坏的方面讲因为与类型无关,所以不严谨;而从好的方面说,这使得#define有更好的灵活性,甚至可以实现函数不可能做到的功能,例如宏的参数可以是某种类型

	#define MALLOC(num, type) malloc(num * sizeof(type))
	int main()
	{
		int* p = (int*)MALLOC(10, int);
		free(p);
		return 0;
	}

再来看一道百度面试真题:offsetof宏的实现

#define OFFSET(structname, membername) (int)&(((structname*)0)->membername)
//解释:访问0地址是不允许的。但这里并并没有访问0地址处,只是取出地址

⑨条件编译指令

1.undef指令

虽然undef不是条件编译指令,但很短就不额外增加副标题了。

作用:移除一个宏定义

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

2.最基本的条件编译指令

	#if 常量表达式
	//……
	#endif

注意点

  • #if#endif是配套使用的,缺一不可
  • #if 后面必须是常量表示式,但不可以是变量。很好理解,变量是在生成可执行程序后所生成的,而对 #if 的处理是在预编译阶段就完成了。

3.多分支的条件编译指令

	#if + 常量表达式
		//……
	#elif + 常量表达式
		//……
	#elif + 常量表达式
		//……
	#else + 常量表达式
		//……
	#endif

4.判断是否被定义

	//判断定义没定义的两种写法
	#if defined(SYMBOL)
	#if !defined(SYMBOL)
	
	#ifdef SYMBOL
	#ifndef SYMBOL
	//#endif都要加上

5.嵌套定义

	#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

三、文件包含

① <> 与 "" 的区别

1.本质区别:查找策略不同 2. ""的策略:源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准库位置查找头文件。如果找不到就提示编译错误。 3. <>的策略:直接标准库路径下去查找,如果找不到就提示编译错误。

从上面的结论我们可以得出,即使是头文件也可以使用 "" 来包含,但是不建议这样做:一来这样会查找两次降低代码效率,二来库函数里的头文件和我们自己写的头文件不容易区分。

② 嵌套文件包含

 在公司里进行合作开发的时候,难免会出现以下的情况

在这里插入图片描述  comm.h是公共的基础模块,分别被test1.h和test2.h所包含。而当test.h包含test1.h和test2.h的时候,就会出现对头文件comm.h的重复包含。  重复包含一来会使的代码冗余,二来在使用的时候会出错,所以我们应该将其避免。如何解决呢?我们只需让在头文件头部加上这么几句话:

	#ifndef __COM_H__
	#define __COM_H__
	//……
	#endif

或者使用pragma,但pragma高级的编译器才有,低版本的可能就没有。

	#pragma once

想深入理解更多预编译知识,给大家分享一本书:《C语言深度剖析》,网盘资源,需要自取,提取码:8x1h