零基础入门C语言之预处理详解
在阅读本篇文章之前,建议读者先阅读本专栏内前面的文章。
目录
前言
一、预定义与#define
二、#和##
三、#undef、命令行定义与条件编译
四、头文件的包含
总结
前言
本篇文章主要介绍与C语言预处理相关的知识。
一、预定义与#define
在C语言中,我们设置了一些预定义符号,可以直接使用,预定义符号也是在预处理期间处理的。
__FILE__ //进⾏编译的源⽂件
__LINE__ //⽂件当前的⾏号
__DATE__ //⽂件被编译的⽇期
__TIME__ //⽂件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义
举个例子:
#include <stdio.h>
int main() {printf("file:%s line:%d\n", __FILE__, __LINE__);return 0;
}
其运行结果如下:

上面这些预定义的符号为我们交代除了代码被编译的时候一系列相关的信息。这个概念是相对简单的,然后让我们来介绍一下#define,它有两个功能,第一个是可以定义常量,而第二个则是可以定义宏。它的基本语法就是如下:
#define name stuff
我们举个例子:
#define 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__ )
我们来详细拆解一下上面这段代码,第一行很明显就是来定义常量,就像我们在上篇文章中所说,它会直接在预处理阶段将语句中所有的MAX都替换为1000这个值,然后将这条语句删除。而第二行则是针对关键字去寻找一个更为简短的替换方式。第三行则是对我们的循环语句来重新命名,由于我们在定义的时候省略了初始化部分、调整部分和判断部分这三个部分,这就导致了判断条件恒为真,也就是造成了一个死循环。同样地,第四行也是将语句转换为了更为简便好写的方式。最后一行是直接将一个相对复杂的语句进行定义,我们试着运行一下:
#include <stdio.h>#define 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__ )int main() {DEBUG_PRINT;return 0;
}
其运行结果如下:

这时候可能有读者会有疑惑,你这个#define语句后面要不要加一个;啊?也就是下面这个意思:
#define MAX 1000;
#define MAX 1000
我这里是建议不要加分号的,否则可能会导致一系列的问题。因为我们在上面讲的用法本质上是一种替换的感觉,我们看看下面的场景:
if(condition)max = MAX;
elsemax = 0;
如果说我们加了分号的话,那在这里发生替换之后,if-else语句中if代码块中就是两条语句,但是我们并没有写大括号,那么这时候我们就会发生语法报错。在上面我们详细地介绍了用#define定义常量的过程,接下来我们使用它来定义宏。那么什么是宏呢?我们这个#define有一个机制就是可以把参数替换到文本当中,这种实现就是称为宏。我们以下面这种方式来声明宏:
#define name( parament-list ) stuff
需要注意的是,括号中的部分是一个由逗号隔开的符号表,它们可能出现在stuff中。并且参数列表的左括号必须与name紧邻,如果二者之间有任何空白,参数列表就会被解释为stuff的一部分。比如说下面这个例子:
#define SQUARE( x ) x * x
这个宏实现的功能就是在接收到一个参数x之后预处理器就会直接用后面的x*x来进行替换SQUARE(x)表达式。但是这个宏存在着一个很严重的问题,比如说键入如下的代码:
#include <stdio.h>#define SQUARE(x) x * xint main() {printf("%d\n", SQUARE(5));int a = 4;printf("%d\n", SQUARE(a + 1));return 0;
}
其运行结果如下:

按照常理来说,这两个方式计算的结果都应该是25,但是为什么第二种方式出错了呢?这是因为我们直接替换文本,使代码变为了如下这种形式:
printf ("%d\n",a + 1 * a + 1 );
这也就导致了它并没有按照我们设想的方式求值,那我们该如何解决这个问题呢?其实只要像下面这样稍微修改一下即可:
#define SQUARE(x) (x) * (x)
我们再次运行一下,结果就变得正确了:

那么我们再看一下下面这个宏,思考一下这个定义是否有问题:
#define DOUBLE(x) (x) + (x)
我们这里给两个参数都加了参数想要避免之前的问题,但是很有可能,它又会出现新的问题,比如下面这个代码:
#include <stdio.h>#define DOUBLE(x) (x) + (x)int main() {int a = 5;printf("%d\n", 10 * DOUBLE(a));
}
其运行结果如下:

这是因为发生替换之后,会出现下面这种形式:
printf ("%d\n",10 * (5) + (5));
而要想解决这种问题我们该如何去做呢?解决方法也很简单:
#define DOUBLE( x) ( ( x ) + ( x ) )
这回结果就正确了:

通过上面这两个例子,我们就可以知道对于针对数值表达式求值的宏定义来说,我们都应该使用最后的这种双重括号的形式,来避免使用宏时由于参数中的操作符或临近操作符之间的不可预料的相互作用。
我们这里需要了解一个事,就是当宏参数在宏的定义中出现超过一次的时候,如果说参数具有副作用,那么我们在使用这个宏的时候就可能会出现一定的危险,导致一些不可预测的后果。那么什么是副作用呢?副作用指的就是表达式在求值的时候出现的永久性效果,比如说下面这个例子:
a = 10;
b = 1;
b = a + 1; //a = 10, b = 11
b = ++a; //a = 11, b = 11
在这个例子之中,第二种方式就是具有副作用的,它不仅改变了b的值,同时也改变了我们原来没有想改变的a的值。对于宏来说,这种情况是很容易发生的,比如说下面这种情况:
#include <stdio.h>#define MAX(a, b) ( (a) > (b) ? (a) : (b) )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++));
那么对于宏来说,替换的规则究竟是什么呢?在程序中扩展#define定义符号和宏时,需要涉及几个步骤。在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号,如果是,它们首先被替换;然后替换文本随后被插入到程序中原来文本的位置,对于宏,参数名被他们的值所替换;最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号,如果是,就重复上述处理过程。 我们需要额外注意,如果宏参数和#define定义中可以出现其他#define定义的符号,但是对于宏,不能出现递归;当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
看到现在,我们发现其实宏干的事和函数感觉是一样的啊,那为什么不用函数呢?我们来给二者区分一下。宏通常被应用于执行简单的运算。比如在两个数中找出较大的一个时,写成下面的宏,更有优势一些。
#define MAX(a, b) ((a)>(b)?(a):(b))
那为什么不用函数来完成这个任务?原因有二,首先是用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹。更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整形、长整型、浮点型等可以用于来比较的类型。宏的参数是类型无关的。
和函数相比宏的劣势则有下面这些,每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度;宏是没法调试的;宏由于类型无关,也就不够严谨;宏可能会带来运算符优先级的问题,导致程序容易出错。
有的时候宏可以做到函数做不到的事情,比如宏的参数可以出现类型,但是函数做不到:
#define MALLOC(num, type)\(type )malloc(num sizeof(type))...
//使⽤MALLOC(10, int);//类型作为参数
//预处理器替换之后:(int *)malloc(10 sizeof(int));
那我们总结一下二者的区别:

二、#和##
#运算符将宏的一个参数转换为字符串字面量。它仅允许出现在带参数的宏的替换列表中。#运算符所执行的操作可以理解为 “字符串化”。当我们有一个变量int a = 10的时候,我们想打印出the value of a is 10,就可以写:
#define PRINT(n) printf("the value of "#n " is %d", n);
##可以把位于它两边的符号合成一个符号,它允许宏定义从分离的文本片段创建标识符。##被称为记号粘合,这样的连接必须产生一个合法的标识符。否则其结果就是未定义的。 这里我们想想,写一个函数求2个数的较大值的时候,不同的数据类型就得写不同的函数。比如说:
int int_max(int x, int y)
{return x>y?x:y;
}
float float_max(float x, float y)
{return x>yx:y;
}
但是这么写太过繁琐了,我们就可以修改为这样:
#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做函数名
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;
}
其运行结果如下:

但在实际情况之下,我们对于##的使用是十分少的,所以很难举出相当贴切的例子。
一般来说,函数和宏的使用语法十分相似,所以语言本身没法帮我们区分二者,我们平时的命名习惯就是把宏名全部大写,函数名不全部大写。
三、#undef、命令行定义与条件编译
#undef专门用于移除一个宏定义。
#undef NAME
如果现存的⼀个名字需要被重新定义,那么它的旧名字首先要被移除。
那什么是命令行定义呢?许多C的编译器提供了一种能力,允许在命令行中定义符号,用于启动编译过程。 例如:当我们根据同一个源文件要编译出一个程序的不同版本的时候,这个特性有点用处。(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一个机器内存大些,我们需要一个数组能够大些。)我们键入如下代码:
#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;
}
然后在Linux的命令行中输入如下指令:
gcc -D ARRAY_SIZE=10 programe.c
然后是条件编译,它指的是在编译一个程序的时候我们如果要将一条语句或一组语句编译或放弃,进行选择性的编译。比如说我们的调试性的代码,删除十分可惜,保留又有些碍事,所以我们可以选择性进行编译。比如我们键入如下代码:
#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__}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 OPTION1unix_version_option1();#endif#ifdef OPTION2unix_version_option2();#endif#elif defined(OS_MSDOS)#ifdef OPTION2msdos_version_option2();#endif
#endif
四、头文件的包含
首先是本地文件包含:
#include "filename"
它的查找策略就是先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。如果找不到,我们就会提示编译错误。而对于Linux环境,标准头文件的路径如下:
/usr/include
然后是库函数包含:
#include <filename.h>
它在查找头文件的时候,直接去标准路径下寻找,如果再找不到,就提示编译错误。那这也就说明其实我们对于库文件也可以使用""的形式来包含,但是这样就会让查找的效率降低,同时也难以区分是库文件还是本地文件。
最后是嵌套文件包含:
我们已经知道,#include指令可以使另外一个文件被编译。就像它实际出现于#include指令的地方一样。这种替换的方式很简单:预处理器先删除这条指令,并用包含文件的内容替换。一个头文件被包含10次,那就实际被编译10次,如果重复包含,对编译的压力就比较大。比如说我们现在有两个文件:
test.c:
#include "test.h"
#include "test.h"
#include "test.h"
#include "test.h"
#include "test.h"
int main()
{return 0;
}
test.h:
void test();
struct Stu
{int id;char name[20];
};
如果直接这样写,test.c文件中将test.h包含5次,那么test.h文件的内容将会被拷贝5份在test.c中。如果test.h文件比较大,这样预处理后代码量会剧增。如果工程比较大,有公共使用的头文件,被大家都能使用,又不做任何的处理,那么后果真的不堪设想。如何解决头文件被重复引入的问题?答案就是使用条件编译。可以在每个头文件开头写:
#ifndef __TEST_H__
#define __TEST_H__
//头⽂件的内容
#endif //__TEST_H__
或者也可以写:
#pragma once
这样就可以避免头文件被重复引入。
我们这里推荐一本书,《高质量C/C++编程指南》。
当然,我们还有其他的一些预处理指令,我们也不多做介绍,有兴趣的读者可以之后自行了解。
#error
#pragma
#line
...
总结
本文详细介绍了C语言预处理相关知识,主要包括预定义符号的使用(如__FILE__、__LINE__等)和#define的两种功能(定义常量和宏);宏与函数的区别,包括宏在类型无关性和效率上的优势,以及可能产生的副作用;#和##运算符的特殊用法;#undef、命令行定义和条件编译的实用场景;头文件包含的方式及避免重复包含的解决方案。文章通过大量代码示例,深入剖析了预处理阶段的各种技术要点和常见问题,对理解C语言编译过程有很大帮助。
