编译预处理
本章详细介绍了一个C语言项目的组成以及一个可执行文件的生成步骤。以这些知识点为开始,先后提出了宏定义、文件包含、条件编译三个C语言所提供的编译预处理功能。
宏定义为程序编写的灵活性带来了极大的便利。
文件包含为减少重复代码的编写提供了必要的支持。
条件编译所支持的跨平台特性使编写的程序不需要任何代码改动就能够在不同的操作系统平台上直接编译运行。
宏定义
针对一个项目,有两种说法:
- 一个项目,由一个或者多个源程序文件组成。
- 一个项目,可以通过编译、链接最终生成一个可执行文件。
这里谈到的编译,是以一个一个的源程序文件(.cpp
文件)为单位进行的,每个源程序文件都会编译成一个目标文件(目标文件扩展名可能是.o
也可能是.obj
等,这与操作系统类型有关),如果源程序文件有多个,则会编译生成多个目标文件,然后将这些目标文件进行链接,最终生成一个可执行文件。
一般来说,编译阶段会做如下几件事:
- 预处理。
- 编译。包括词法分析、语法分析、目标代码生成、优化等。
- 汇编。产生
.0
(.obj
)目标文件。
在软件开发过程中,根据实际需要,会在源程序文件中写入一些特殊代码(特殊命令),这些特殊代码有一些特殊能力,提供一些特殊功能,编译系统会先对这些代码做到预先的处理,这就叫“预处理”。处理的结果再和源程序代码一起进行编译、汇编等一系列动作。
C语言所提供的三种预处理功能也是通过在源程序文件写入代码来实现的,这些代码以“#”开头。
不带参数的宏定义
不带参数的宏定义:用一个指定的宏名来代表一串内容。
宏名,其实就是一个标识符。其一般形式为:
#define 宏名 被替换的内容
#define PI 3.1415926 // 末尾没有分号
作用:在程序源码中写的是PI,在预处理阶段,所有在该#define
语句行之后的代码中出现的PI都会被替换成3.1415926
。
说明:
#define
就是宏定义命名,实现了用一个简单的名字(宏名)代替一个很长的内容的效果,在预处理时将宏名替换成指定内容的过程称为“宏展开”,也可以称为“宏替换”。在进行宏展开时,并不做语法检查。- 利用
#define
,增加了代码修改的方便性,为代码修改提供极大便利,这种能力在开发中被频繁地使用。例如,如果将来PI不等于3.1415926,而是等于2.58了,那么只需要修改一行代码,整个程序中出现PI的地方就都被替换成了2.58,这也叫作提高了程序的可移植性。 - 宏名一般用大写字母表示,这是一种习惯,建议遵照这个习惯。
- 一般来说,
#define
命令都写在程序文件开头部分,函数之前。宏名的有效范围是#define
之后到本源程序文件结束,不能跨文件使用,如果在另外一个源程序文件中使用,则需要在另外一个源程序文件中也做相同定义,或者把这些#define
定义统一放到一个公共头文件里。 - 可以用
#undef
命令终止宏定义的作用域,不过#undef
用得比较少。
带参数的宏定义
前面讲的是不带参数的宏定义,只是进行简单的内容替换,而带参数的宏定义,不仅进行简单的内容替换,还要进行参数的替换。一般形式为:
#define 宏名(参数表) 被替换的内容
被替换的内容中,一般都会包含参数表中所指定的参数,不包含也是可以的,但不包含,那么通过参数表传进去这个参数就没什么意义了。例:
#define S(a, b) a * b
...
int Area = S(3, 2) // int Area = 3 * 2;
#define S(a, b) a
int Area = S(3, 2);
// 宏展开后相当于“int Area = 3”,显然2毫无意义了。
如果“被替换的内容”中的项并不是“宏名”后列出的形参,则保留,如上面a * b中的 ‘*’ 就会被保留。
#define PI 3.1415926
#define S(r) PI * r * r
float area = S(3.6); // 相当于float area = 3.1415926 * 3.6 * 3.6
说明:
-
如果代码中出现
“area = S(1+5);
,被替换后变成3.1415926 * 1+5 * 1+ 5
,这肯定是不对的,程序代码的愿意是替换后变成3.1415926 * (1+5) * (1+5)
。为了解决这个问题,要在形参外面加一个括号:#define S(r) PI * (r) * (r)
-
宏定义时,宏名的带参数的括号之间不能加空格,否则,空格之后的内容都被作为被替换内容的一部分。
#define S (r) PI * (r) * (r)
这样,S成为不带参数的宏定义,显然是不对的。
函数与宏的差异区别:
-
函数调用是先求出实参表达式的值,然后传递给形参,而带参数的宏只进行简单的内容替换,宏展开时并不求值。
-
函数调用是在程序运行阶段执行到该函数时才执行其中的代码,这涉及比如所调用的函数分配临时内存等一系列工作。但宏展开是在编译阶段进行的,而且展开时也并不分配内存,当然也不存在”值传递“、”返回值“等只有在函数调用中才存在的说法。
-
宏的参数没有类型这个说法,只是一个符号,展开时用指定内容替换。
-
宏展开每进行一次,源程序代码都会有所增多,如
”area = S(1+5);
,在宏展开时会被替换成area = 3.1415926 * (1+5) * (1+5);
,显然代码变多了。所以使用宏的次数如果增多,源程序代码就会增多,但函数调用不会使源程序代码增多。
-
宏展开只占用编译时间,不占用运行时间,而函数调用占用运行时间(分配内存、传递参数、执行函数体、返回值等)。
还有能代替多行语句的宏定义写法:
#define MACROTEST do { \
printf("test\b"); \
} while(0);
末尾的“\”,用来表示下一行代码和本行代码本是同一行,这种用法在一定程度上能简化程序书写。
文件包含和条件编译
文件包含
所谓“文件包含”,是指一个文件可以将另外一个文件的全部内容包含进来,也就是将另外的文件包含到本文件中。C/C++语言中,通过#include
命令来实现,其一般形式为:
#include "文件名"
虽然可以用#include
把任何一个其他文件的内容包含到当前文件中,但是最常见的做法还是一些源程序文件用#include
把一些头文件(扩展名为.h
或者.hpp
等)包含进来。把一些公用的内容写成一个文件,可以节省大量的重复劳动。
要认识到,使用了#include
之后,就等价于把其他文件的内容包含到当前文件中来,所以当前文件的程序代码长度增加了。
几点说明:
-
虽然很多公共内容都可以写到这个
.h
文件中,但是一但修改了这个.h
文件,也就相当于修改了#include
这个.h
文件的所有源程序文件,那在编译的时候这些源程序文件显然就得重新编译了(花费比较多的编译时间)。因为一旦修改了源程序文件内容,Visual Studio会进行自动检测和重新编译。
-
#include
所包含的文件名可以用""
,也可以用<>
。它们有什么区别吗?<>
是去系统目录中找所包含的文件,所以诸如要包含标准的iostream
头文件(系统提供的)就用<>
。""
的含义是首先在当前目录查找要包含的文件,如果找不到,再到系统目录中查找。所以,""
常用于自己写的一些想被其他文件#include
的文件,让系统优先到当前目录中寻找所要包含的文件。
条件编译
一般情况下,在生成可执行文件的过程中,源程序文件中的所有代码行都参加编译,但有时候希望对其中的一部分内容只在满足一定的条件下才进行编译,也有的时候,希望当满足某条件时才对一组语句进行编译,而当条件不满足时编译另外一组语句,这都叫做条件编译。
条件编译用得也比较频繁,尤其是写一些跨操作系统平台的代码。例如,当程序代码中有些特殊的系统调用函数只能在Windows下编译运行或者只能在Linux下编译运行,此时,就有必要使用条件编译。
条件编译有几种形式:
-
形式一
#ifdef 标识符 程序段 1(一堆代码) #else 程序段 2(一堆代码) #endif
作用:当标识符被定义过(
#define
来定义),则对程序段1进行编译,否则对程序段2进行编译。#else 程序段2
这部分也可以没有。在进行程序调试的时候,常常需要输出一些信息,调试完毕后,不再输出这些信息。
#define DEBUG 1 // 后面1其实可以省略,不想输出调试信息时可以把这行注释掉
然后在其他一些需要输出调试信息的地方(如main函数中),可以些类似如下代码;
#ifdef DEBUG printf("输出一些变量信息作为调试信息\n"); #endif
-
形式二
#ifndef 标识符 程序段 1(一堆代码) #else 程序段 2(一堆代码) #endif
作用:若标识符未被定义过(未用
#define
来定义),则对程序段1进行编译,否则对程序段2进行编译。与形式1正好相反。 -
形式三
#if 表达式 程序段1 #else 程序段2 #endif
作用:当指定的表达式值为真时就编译程序段1,否则编译程序段2。
还可以将上述形式扩展一下,如下:
#if 表达式 程序段1 #elif 程序段2 #else 程序段3 #endif
项目开发也许会面临跨平台的问题,为了增加程序代码在各平台之间的可移植性,往往采用条件编译,如果不用条件编译,就很难解决同一套程序代码在Windows
平台下和Linux
平台下都能够在不修改源代码的情况下编译通过并生成可执行文件的问题。