本章重点
无参数的宏定义
带参数的宏定义
头文件
条件编译指令
在前面的学习中,经常遇到用#define定义符号变量的情况,其实,#define是一种预处理指令。预处理指令在C语言中占有重要的地位,它是程序从源代码到可执行文件的编译流程中的第一步,在这一步中,编译器会根据预处理指令进行宏定义替换,包含头文件,进行条件编译等操作,。本章将针对预处理的相关知识进行详细地讲解
回顾编译流程
在计算机中,编译系统负责将C语言的源代码转换成计算机的可执行文件,其执行过程分为以下四个步骤,具体如下:
预处理:处理源代码中的宏定义、头文件引入和条件编译等操作。对于宏定义,编译器在源文件中直接将出现宏定义的部分进行替换;对于头文件引入,编译器会找到相应的头文件,将头文件的内容插入到源文件中;对于条件编译,编译器会根据条件编译的内容选择编译整个工程中的部分内容。
编译:在这个阶段,预处理之后的C语言源文件被翻译为汇编语言。
汇编:编译之后得到的汇编语言在这个阶段被翻译为机器指令。汇编之后得到的文件被称为二进制文件,它的内容是只有CPU可以看懂的机器语言。
链接:在这个阶段原来项目中不同的源文件经过汇编得到的二进制文件被整合在一起,并生成一个可执行文件。可执行文件可以被载入到内存中,并被CPU执行。
宏定义
宏定义是最常用的预处理功能之一,对于预处理器而言,它在看到宏定义之后,会将随后在源代码中根据宏定义进行简单的替换操作。本节将针对宏定义的相关知识进行详细地讲解。
#define与#undef
宏定义指令以#define开头,后面跟随宏名和宏体,它的语法如下:
#define 宏名 宏体
为了和其他变量以及关键字进行区分,宏定义中的宏名一般用全大写英文字母以及下划线来完成。下面是一个例子:
#define PI 3.14
在这个宏定义中定义了一个标识符PI,它所代表的值是3.14。在随后的源代码中凡是出现了PI的地方都会被替换为3.14。接下来,通过一个案例来验证,如例程12-1所示。
例程 12?1 #define指令
- #include <stdio.h>
#define PI 3.14
int main()
{
printf("%f\n", PI);
return 0;
}
程序运行结果如图12-1所示:
图 12?1 #define指令
在上面的例程中,程序首先定义了一个宏PI,值为3.14。在main函数内部使用printf打印了PI的值。在预处理过程中,第6行里的PI会被第2行的3.14替换,最后在main函数中输出一个浮点数3.14。
除了#define之外相应地还有#undef指令。#undef指令用于取消宏定义。在#define定义了一个宏之后,如果预处理器在接下来的源代码中看到了#undef指令,那么从#undef之后这个宏就都不存在了,如例程12-2所示。
例程 12?2 #undef的使用
- #include <stdio.h>
#define PI 3.14
int main()
{
printf("%f\n", PI);
#undef PI
printf("%f\n", PI);
return 0;
}
在上面这个例子中,程序首先定义了宏PI,并且在第6行使用printf函数输出PI的值。随后在第7行中利用#undef指令取消PI这个宏,从第7行开始PI这个宏定义就不存在了。在第8行中程序依然试图使用宏定义PI并输出它的值,结果只能是报错。在Visual Studio中上述程序会显示“未声明的标识符”的错误,如图12-2所示。
图 12?2 #undef带来的错误
简单宏定义
这里的简单宏定义指的就是像上一节那样仅仅完成简单替换工作的宏。为了加深对宏定义的认识,本节中将用几个例子来展示宏定义的灵活用法。
除了像上一节那样进行简单的数值替换,宏定义还可以用来进行表达式替换。下面是一个利用宏定义替换表达式的例子:
例程 12?3 宏定义替换表达式
- #include <stdio.h>
#define ONE_PLUS_ONE 1 + 1
int main()
{
printf("1 + 1 = %d\n", ONE_PLUS_ONE);
return 0;
}
在上面的程序中,首先定义了一个宏ONE_PLUS_ONE,用来计算1+1的值。随后在main函数的内部调用printf将1+1的值输出。注意在第6行中printf的参数是ONE_PLUS_ONE,经过预处理之后它会被替换为1 + 1,也就是和下面的语句等价:
printf("1 + 1 = %d\n", 1 + 1);
程序的输出结果如下图所示:
图 12?3 宏定义替换表达式
宏定义还可以用来定义字符串。下面的例子以Hello world为例展示了宏定义的这一用法:
例程 12?4 宏定义替换字符串
- #include <stdio.h>
#define HELLO_WORLD "hello world!\n"
int main()
{
printf(HELLO_WORLD);
return 0;
}
这个程序用宏定义HELLO_WORLD定义了一个带回车的字符穿hello world,并在main函数中利用printf打印这个字符串。和之前的例子类似。在预处理过程中,预处理器看到HELLO_WORLD之后会将它自动替换为相应的字符串:
printf("hello world!\n");
程序的输出结果如下:
图 12?4 宏定义替换字符串
如果宏定义的内容过长,还可以使用\将宏定义的内容定义到下一行:
例程 12?5 宏定义字符串
- #include <stdio.h>
- #define HELLO_WORLD "HHHHHHHHHHHHHHello \
- world!\n"
- int main()
- {
- printf("%s\n", HELLO_WORLD);
- return 0;
- }
上面的程序运行结果如下:
图 12?5 宏定义字符串
宏定义还可以嵌套使用,即在一个宏定义中使用别的宏定义。下面是一个宏定义嵌套的例子。在这里例子中首先定义了一个宏PI用来表示圆周率,之后定义了一个宏COMP_CIR用来计算圆的周长:
例程 12?6 嵌套宏定义
- #include <stdio.h>
#define PI 3.14
#define COMP_CIR 2 * PI *
int main()
{
double r = 1.0;
printf("2 * pi * r = %f\n", COMP_CIR r);
return 0;
}
在第2行中程序利用宏定义定义了圆周率PI,在第3行中定义了宏COMP_CIR的值为2 * PI *,此处的宏PI在预处理时会被上面的宏定义#define PI 3.14所替换。在main函数中首先定义了一个double类型的圆半径,它的大小是1.0,随后利用printf去输出利用宏定义计算得到的圆周长COMP_CIR r。在预处理过程中,COMP_CIR会被一层层展开为:
printf("2 * pi * r = %f\n", 2 * 3.14 * r);
程序的运行结果如下:
图 12?6 嵌套宏定义
带参数的宏定义
除了无参数的宏定义之外,有的时候在程序中更希望能够使用带参数的宏定义,这样在完成替换过程的时候会有更多的灵活性。下面以刚刚计算圆周长的无参数宏定义为例,将它修改为带参数的宏定义:
例程 12?7 带参数宏定义
- #include <stdio.h>
#define PI 3.14
#define COMP_CIR(x) 2 * PI * x
int main()
{
double r = 1.0;
printf("2 * pi * r = %f\n", COMP_CIR(r));
return 0;
}
上述程序中在第3定义了一个带参数的宏定义:
#define COMP_CIR(x) 2 * PI * x
在这里x是宏定义中的参数。对于带参数的宏定义,在预处理过程中首先会将参数替换进宏定义中,再用替换参数后的宏定义在源代码中做替换。具体地说,在程序的第8行使用到了COMP_CIR(r),那么首先在第3行的宏定义中,参数x被换为r,宏定义COMP_CIR(r)的值为2 * PI * r:
printf("2 * pi * r = %f\n", 2 * PI * r);
这里还嵌套了宏定义PI,它也会被替换为3.14,最终第8行在经过预处理之后变为:
printf("2 * pi * r = %f\n", 2 * 3.14 * r);
程序最终的运行结果如下图所示:
图 12?7 带参数宏定义
由于宏定义仅仅完成文本替换的工作而不会检查运算的优先级,因此对于带参数的宏定义一般建议在宏体中用括号将参数括起来,即采用如下的形式:
#define COMP_CIR(x) 2 * PI * (x)
这样可以保证运算顺序不出错。下面是一个利用宏定义实现乘法的例子:
例程 12?8 带参数宏定义
- #include <stdio.h>
#define MUL(x, y) (x) * (y)
int main()
{
printf("%d\n", MUL(3 + 5, 4 + 2));
return 0;
}
程序中定义了一个带两个参数x和y的宏定义MUL,用来实现两个数的乘法。在main函数中MUL的两个参数分别是3+5和4+2,按照目前的定义,在第6行的printf中MUL将被替换为:
printf("%d\n", (3 + 5) * (4 + 2));
输出结果是期待的48:
图 12?8 带参数宏定义
然而,如果将宏定义改成:
#define MUL(x, y) x * y
程序的输出结果就变成了25:
图 12?9 带参数宏定义
这是因为宏定义只进行文本替换,在printf中MUL被替换为了
printf("%d\n", 3 + 5 * 4 + 2);
出于同样的原因,在带参宏的最外面也建议用括号括起来。否则也会由于运算符优先级的问题导致出现不希望的结果,下面是一个例子:
例程 12?9 带参数宏定义
- #include <stdio.h>
- #define ADD(x, y) ((x) + (y))
- int main()
- {
- printf("3 * (4 + 4) / 6 = %d\n", 3 * ADD(4, 4) / 6);
- return 0;
- }
上面的程序定义了一个加法宏ADD用来将x和y相加,运行上述程序将会得到:
图 12?10 带参数宏定义
然而,如果将宏定义改为:
#define ADD(x, y) (x) + (y)
带入到printf中将会得到:
printf("3 * (4 + 4) / 6 = %d\n", 3 * (4) + (4) / 6);
最终程序的运行结果会变成:
图 12?11 带参数宏定义
上面的例程揭示了在带参数宏定义中使用括号的必要性。一般在定义带参数宏定义的时候,宏体中的参数和最外面都要加括号以避免不期望的结果发生。
看上去使用带参数的宏定义稍有不慎就会出错,那么为什么还要使用它呢?确实,为了实现同样的功能,完全可以写一个函数来替代带参数的宏定义,但是请记住对宏定义的处理是在预处理的时候进行的,这就意味着在程序运行时这些宏定义已经被替换为具体的程序语句了,从而减少了函数调用的开销。例如,同样是求一个数的绝对值:
例程 12?10 求绝对值
- #include <stdio.h>
#define ABS(x) ((x) >= 0 ? (x) : -(x))
double compAbs(double x)
{
return x >= 0 ? x : -x;
}
int main()
{
double x = 4.5;
printf("abs(4.5) = %f %f\n", ABS(x), compAbs(x));
return 0;
}
上述程序的运行结果如下:
图 12?12 求绝对值
对于宏定义ABS来说,它在预处理的时候就被替换了,程序直接执行的是相应的三目运算符;对于函数abs来说,使用的时候需要首先将实参x拷贝给abs的形参x,然后执行三目运算符,然后将得到的结果返回。相比之下宏定义的开销要小一些。如果想要频繁调用某一个函数,而函数的实现又足够简单,程序对性能要求又非常高,那么使用宏定义不失为一种好的选择。
当然,宏定义和函数调用相比是非常不健壮的。即使是上面加了层层括号的ABS也可能返回错误的结果:
例程 12?11 错误的绝对值
- #include <stdio.h>
- #define ABS(x) ((x) >= 0 ? (x) : -(x))
- double compAbs(double x)
- {
- return x >= 0 ? x : -x;
- }
- int main()
- {
- double x = 12, y = 12;
- printf("abs(++x) = %f\n", ABS(++x));
- printf("abs(++y) = %f\n", compAbs(++y));
- return 0;
- }
程序的运行结果如下所示:
图 12?13 错误使用绝对值
可以看到两次输出的结果不一致了。显然函数abs输出的结果13是正确的,为什么ABS的宏就不对呢?还是将整个宏展开:
printf("abs(++x) = %f\n", ((++x) >= 0 ? (++x) : -(++x)));
现在想象一下如果x=12,首先在下面的逻辑判断中:
(++x) >= 0
首先x的值被自增为13,接下来这个逻辑判断的值显然为真。因此紧接着去执行:
(++x)
这个时候问题出现了:x在这里被再次自增了一次,因此返回并打印的结果变成了再次自增后的14,程序的结果偏离预期了。因此,在使用宏定义时务必要小心谨慎。
#include指令
#include指令用来引入头文件。在预处理过程中出现#include引入头文件的地方,头文件的内容会被直接插入到源文件中。
本节首先介绍头文件的定义和使用方法,随后介绍如何利用#include指令引入头文件。
头文件
头文件就是C程序中以.h结尾的文件。原则上头文件里包含的内容没有限制,但是由于在预处理中头文件的处理方式是被直接替换给所有包含了这个头文件的源文件,一般在头文件里会定义函数的原型以及在整个项目中有可能被多个源文件使用的宏定义,结构体等。
头文件的一种典型用法是用来编写需要被其他源文件广泛使用的函数。下面是一个例子:在这个例子中,程序中有一个头文件foo.h,在foo.h中定义了一个结构体类型Foo以及一个函数原型bar:
struct Foo
{
int i;
};
void bar(struct Foo f);
相应地程序中有一个源文件foo.c,在foo.c中有bar函数的实现,此处bar函数是一个空函数:
#include "foo.h"
void bar(struct Foo f)
{
}
由于在foo.c文件中使用到了struct Foo的定义,因此要包含foo.h的头文件,否则无法得知struct Foo的类型。
最后,在main函数中调用bar函数:
例程 12?12 main.c
- #include "foo.h"
int main()
{
struct Foo f = {1};
bar(f);
return 0;
}
在main函数的实现中,首先定义了一个Foo类型的变量f并将它的成员初始化为1,随后调用bar函数。由于bar函数的原型定义在foo.h中,因此main.c中需要引入bar函数原型所在的头文件foo.h,否则在main.c中是找不到bar的定义的。编译上述三个文件之后,程序可以正确执行:
图 12?14 头文件的使用
上述例程揭示了头文件的一般使用方法:将函数原型,结构体的定义等等放在头文件XXX.h中,将对应的函数实现放在另外一个源文件中。对于其他的源文件,如果需要引用XXX.h中声明的函数,只要利用include将该头文件引入即可。下面是一个更加有意义的例子:
例程 12?13 月份程序
- #include <stdio.h>
int inputMonth()
{
int month; // 输入一个月份
scanf("%d", &month);
if (month < 1)
month = 1;
if (month > 12)
month = 12;
return month;
}
int dayInMonth(int month)
{
int day;
switch (month)
{
case 1:
case 3:
case 5:
case 7:
case 8:
case 10:
case 12:
day = 31;
break;
case 4:
case 6:
case 9:
case 11:
day = 30;
break;
default: // 二月
day = 28;
break;
}
return day;
}
void outputDay(int day)
{
if (day < 28)
day = 28;
if (day > 31)
day = 31;
printf("%d\n", day);
}
int main()
{
int month = inputMonth();
int day = dayInMonth(month);
outputDay(day);
return 0;
}
除了main函数,上面的程序还定义了三个新的函数:
inputMonth:负责接收用户的输入月份,如果用户的输入月份超出了1到12的范围,函数还会将月份强制转换到这个范围内;
dayInMonth:一个转换函数,负责给出给定月份中给的天数;
outputDay:输出特定的天数。如果天数超出了28到31的范围,函数还会将天数强制转换到这个范围之内。
运行上述程序,假设用户输入了3月:
图 12?15 月份程序
看上去非常完美,程序运行十分正常。现在,假设在程序中想要加入一个新的函数用来计算一年中一共有多少天:
int dayInYear()
{
int day = 0;
int month;
for (month = 1; month <= 12; month++)
{
day += dayInMonth(month);
}
return day;
}
函数dayInYear看上去也没有什么问题,它利用一个for循环遍历1到12月份,然后对每个月调用dayInMonth函数计算出每个月的天数,最后将它们一起累加起来求出正确的结果(365天)。现在如果想要在main函数中调用dayInYear的话:
int main()
{
int month = inputMonth();
int day = dayInMonth(month);
outputDay(day);
day = dayInYear();
printf("%d\n", day);
return 0;
}
在这里我们就需要将dayInYear的定义加入到main.c中。如果程序还想进一步加入新的功能,可以预见程序的main.c只会越来越长,这对于程序的编写和维护都是非常不利的。
幸运的是,利用头文件可以将程序从main.c中解放出来。对于上面的所有函数,可以定义一个头文件date.h:
int inputMonth();
int dayInMonth(int month);
void outputDay(int day);
int dayInYear();
现在,在main.c中无需包含所有的函数实现,只要引用头文件date.h就可以了:
例程 12?14 月份程序
- #include <stdio.h>
#include "date.h"
int main()
{
int month = inputMonth();
int day = dayInMonth(month);
outputDay(day);
day = dayInYear();
printf("%d\n", day);
return 0;
}
相应地,函数的实现放在另外一个源文件date.c中。由于date.c的函数实现中包含了printf和scanf,因此在date.c中也要引用stdio.h:
#include <stdio.h>
int inputMonth()
{
int month; // 输入一个月份
scanf("%d", &month);
if (month < 1)
month = 1;
if (month > 12)
month = 12;
return month;
}
int dayInMonth(int month)
{
int day;
switch (month)
{
case 1:
case 3:
case 5:
case 7:
case 8:
case 10:
case 12:
day = 31;
break;
case 4:
case 6:
case 9:
case 11:
day = 30;
break;
default: // 二月
day = 28;
break;
}
return day;
}
void outputDay(int day)
{
if (day < 28)
day = 28;
if (day > 31)
day = 31;
printf("%d\n", day);
}
int dayInYear()
{
int day = 0;
int month;
for (month = 1; month <= 12; month++)
{
day += dayInMonth(month);
}
return day;
}
将上述三个文件放在同一工程下,运行程序,得到:
图 12?16 月份程序
这个例子更加具体地展示了头文件的用法:在头文件中声明函数之后,在一个源文件中实现声明的函数,在想要使用这些函数的源文件(main.c)里只要引用头文件即可,不需要将函数的实现全部拷贝到源文件中。
引入头文件
利用#include指令引入头文件一般有两种方法,它们的语法格式分别如下:
方法一:引用编译器自带的头文件,比如用于标准输入输出的stdio.h,用于数学计算的math.h等等头文件的引用都属于这一类:
#include <文件名>
它的格式为#include加上英文尖括号括起的文件名。此前的例程中已经多次出现了这种例子:
#include <stdio.h>
#include <stdlib.h>
方法二:引用当前项目中的头文件,格式为:
#include "文件名"
它的格式为#include加上英文双引号括起的头文件名。它和上一种引用方式的区别是利用双引号引用文件名,此时编译器会首先在当前项目的目录下搜索是否有匹配的头文件。如果没有搜索到,编译器会回到自带头文件的目录下去寻找。
合理使用两种引入头文件的方式可以提高编译的效率。比如对于编译器提供的头文件stdlib.h,尽管两种引用方式都是合法的,但是第一种方式明显比第二种方式效率要高。
下面的例程展示了上述两种引入头文件的方式。例程包括两部分:一个程序员自己编写的头文件foo.h,当中定义了一个宏NUM;一个程序员自己的源文件main.c,在源文件中引用头文件foo.h来使用宏NUM,并引用头文件stdio.h将结果输出。头文件和源文件定义在同一个文件夹下。头文件的定义如下:
// foo.h
#define NUM 15
源文件main.c的定义如下:
例程 12?15 头文件的两种引用方式
- // main.c
- #include <stdio.h>
#include "foo.h"
int main()
{
int num = NUM;
printf("num = %d\n", num);
return 0;
}
程序运行的结果是:
图 12?17 引入头文件
需要指出的是,头文件也可以引入头文件,但是如果可能的话应该尽量避免出现这样的情况。直接重复引用头文件有可能会导致诸如类型重定义等错误。如果实在无法避免,应该在引入头文件的同时尽量考虑使用本章中讲到的条件编译指令。
条件编译指令
条件编译指令用来告诉编译系统在不同的条件下,需要编译不同位置的源代码。正确合理地使用条件编译指令可以给予程序很大的灵活性。
#if/#else/#endif
#if指令,#else指令和#endif指令三者经常结合在一起使用。它们的使用方法和if else语句类似:
#if 条件
源代码1
#else
源代码2
#endif
编译器只会编译源代码1和源代码2两段中的一段。当条件为真时,编译器会编译源代码1,否则编译源代码2。一个经典的使用#if/#else/#endif的场景是当一个程序需要支持不同平台时,根据#if当中的条件可以选择编译不同段的代码,从而实现对不同平台的支持。下面是一个例子:
例程 12?16 #if指令
- #include <stdio.h>
- #define WIN32 0
- #define x64 1
#define SYSTEM WIN32
int main()
{
#if SYSTEM == win32
printf("win32\n");
#else
printf("x64\n");
#endif
return 0;
}
在这个例子中,程序里首先用宏定义SYSTEM定义了操作系统的位数是32位。在main函数中利用一个条件编译指令判断SYSTEM是否是32位,如果是的话,就输出win32,否则认为是64位系统,输出x64。程序的输出结果如下:
图 12?18 #if指令
在实际的项目中当然不会只输出printf那么简单。由于不同的平台可能需要不同的代码来处理诸如数据类型不一致等情况,#if/#else/#endif的这种框架可以用来实现在源代码中支持不同的平台,以确保程序可以兼容不同的运行环境。
#elif
#elif的作用和else if语句类似。我们可以把上面的例程进行扩展让程序支持更多的平台:
例程 12?17 #elif指令
- #include <stdio.h>
#define windows 0
#define linux 1
#define mac 2
#define SYSTEM mac
int main()
{
#if SYSTEM == windows
printf("win\n");
#elif SYSTEM == linux
printf("linux\n");
#elif SYSTEM == mac
printf("mac os\n");
#endif
return 0;
}
在上述例程中,首先定义了SYSTEM宏,随后在#if条件编译指令的部分进行了扩展:如果宏SYSTEM的值是windows,那么输出win,否则如果SYSTEM的值是linux,输出linux,最后如果SYSTEM的值是mac则输出mac os。通过在不同的#elif下编写代码可以让这个程序实现对不同操作系统平台的支持。
上述程序的输出结果如下:
图 12?19 #elif指令
和程序语言中的if else结构一样,条件编译指令中的#elif可以有多个,而且最后也可以没有#else,就像上面的例子中那样。
#ifdef
条件编译指令#ifdef用来确定某一个宏是否已经被定义了,它需要和#endif一起使用。如果这个宏已经被定义了,就编译#ifdef到#endif中的内容,否则就跳过。
和#if/#else/#endif不同的是,#if/#else/#endif用来从多段源码中选择一段编译,而#ifdef可以用来控制单独的一段源码是否需要编译,它的功能和一个单独的#if/#endif类似。
#ifdef的一个应用是用来控制是否输出调试信息。下面是一个例子:
例程 12?18 #ifdef指令
- #include <stdio.h>
#define DEBUG
int main()
{
int i = 0;
#ifdef DEBUG
printf("i = %d\n", i);
#endif
int j = 3;
#ifdef DEBUG
printf("j = %d\n", j);
#endif
int sum = i + j;
#ifdef DEBUG
printf("i + j = %d\n", sum);
#endif
return 0;
}
在上面的例程中,首先定义了宏DEBUG,用来控制是否需要输出调试信息。main函数的主体部分非常简单:定义了整型变量i和j,并定义了整型变量sum用来计算i和j的和。在每一次定义之后都有一条printf语句用来输出变量的值。由于DEBUG宏已经被定义,因此所有的printf都会被编译,程序的运行结果如下:
图 12?20 #ifdef指令
假设现在程序已经调试完成了,在发布的时候不希望有这些冗余的调试输出信息,这个时候只要将DEBUG的宏定义取消,所有#ifdef包含的printf信息都不会经过编译:
//#define DEBUG
程序的输出结果如下:
图 12?21 #ifdef指令
#ifndef
和#ifdef相反,#ifndef用来确定某一个宏是否还没有被定义,它也需要和#endif一起使用。它的用法和#ifdef相反:如果这个宏还没有被定义,那么就编译#ifndef到#endif中间的内容,否则就跳过。
#ifndef经常和#define一起使用,它们用来解决头文件中的内容被重复定义的问题。在一个源文件中如果相同的头文件被引用了两次就很有可能出现类型重定义,下面是一个具体的例子:
在这个例子中有三个头文件foo.h,bar1.h和bar2.h。有三个源文件main.c,bar1.c和bar2.c。首先是foo文件的内容:
struct Foo
{
int i;
};
在foo.h文件中定义了一个结构体Foo,它包含一个整型变量i。
接下来是bar1.h的内容:
#include "foo.h"
void bar1(struct Foo f);
bar1中定义了一个函数原型bar1,它的参数是一个结构体Foo类型的变量。在bar1.h中引用了头文件foo.h。bar1的实现在源文件bar1.c中:
void bar1()
{
}
为了简便,这里bar1是一个空函数。
类似地,在bar2.h中也定义了一个函数原型bar2,它的参数也是一个Foo类型的结构体变量:
#include "foo.h"
void bar2(struct Foo f);
bar2函数的实现在源文件bar2.c中。为了简便,bar2也是一个空函数:
void bar2()
{
}
最后,在main函数中定义一个Foo类型的变量f,并调用bar1和bar2两个函数:
例程 12?19 main.c
- #include "foo.h"
#include "bar1.h"
#include "bar2.h"
int main()
{
struct Foo f = { 1 };
bar1(f);
bar2(f);
return 0;
}
如果直接编译上述程序会发现编译无法通过,在Visual Studio中会提示struct类型重定义的错误:
图 12?22 类型重定义
这是因为在main.c的源文件中,结构体Foo的定义被多次引入了。具体地说,在第1行的#include指令将Foo的定义引入了一次,后两行引入的bar1.h和bar2.h虽然没有定义Foo,但是两个头文件都分别引用了foo.h,因此最终在main.c当中我们将会看到的是:三次Foo结构体的定义,函数bar1的声明,函数bar2的声明:
struct Foo
{
int i;
};
struct Foo
{
int i;
};
void bar1(struct Foo f);
struct Foo
{
int i;
};
void bar2(struct Foo f);
int main()
{
struct Foo f = { 1 };
bar1(f);
bar2(f);
return 0;
}
这样的main.c显然是不能编译通过的。问题就在于虽然Foo只在foo.h中被定义了一次,但是它在main函数中由于头文件之间的嵌套引用导致foo.h最终被多次引用,从而导致在main.c中多次出现了Foo的重复定义。
利用#ifndef和#define的组合可以解决这个问题。现在对foo.h做如下的修改:
#ifndef _FOO_H_
#define _FOO_H_
struct Foo
{
int i;
};
#endif
修改后的foo.h当中包含了#ifndef的条件编译指令。注意在#ifndef的编译指令内部包括一条#define指令,当这一段代码初次编译时,宏_FOO_H_尚未被定义,符合#ifndef的条件,因此结构体Foo的定义可以被编译。当foo.h的内容再次被编译时,由于在初次编译时已经定义了宏_FOO_H_,因此#ifndef的条件不符合,内容被跳过。这样就保证了在main.c中即使多次引用了foo.h,Foo结构体的定义也仅仅被编译一次。利用了#ifndef指令并经过预处理后的main.c文件相当于下面这个文件:
例程 12?27 main.c
- #ifndef _FOO_H_
#define _FOO_H_
struct Foo
{
int i;
};
#endif
#ifndef _FOO_H_
#define _FOO_H_
struct Foo
{
int i;
};
#endif
void bar1();
#ifndef _FOO_H_
#define _FOO_H_
struct Foo
{
int i;
};
#endif
void bar2();
int main()
{
struct Foo f = { 1 };
bar1(f);
bar2(f);
return 0;
}
尽管Foo的定义还是出现了三次,由于有#ifndef的保护,Foo只会被编译一次。程序这一次能够正确通过编译并执行,执行结果如下:
图 12?23 #ifndef指令
当然,如果可能的话,还是应该尽量少在头文件中嵌套引用别的自定义头文件。不过确实在有些时候头文件的嵌套引用实在难以避免了,这时候#ifndef不失为一种有效的解决方法。
本章小结
本章首先回顾了编译系统的工作流程,随后介绍了三种预处理指令:宏定义,#include指令,以及条件编译指令。在预处理完成之后,编译系统会生成不包含任何预处理指令的源文件,并交给编译器,进入编译系统的其他步骤。