3.7 多文件程序结构

一个C++程序称为一个项目。一个项目由一个或多个文件组成。文件结构便于程序按逻辑功能划分,便于程序测试。

一个文件可以包含多个函数定义,但一个函数的定义必须完整地存在于一个文件中。

多文件程序的上机操作过程参见附录A。

3.7.1 多文件结构

程序员经常使用两类文件:扩展名为“.h”的头文件和扩展名为“.cpp”源程序文件。

一个能够表达特定程序功能的模块由两部分构成:规范说明和实现部分。规范说明描述一个模块与其他模块的接口,一般包括:函数原型、类说明、类型说明、全局量说明、包含指令、宏定义、注释等。规范说明通常集中在头文件,各模块通过头文件的接口产生引用。实现部分则放在.cpp文件中,通常称为实现文件。

一个好的软件系统,应该分解为各种同构文件,如图3.10所示。

图3.10 多文件结构

下面以一个简单例子说明如何构造一个项目。

【例3-35】计算圆面积和矩形面积。

本例程序由4个文件组成:myArea.h包含两个函数原型,myArea.cpp是计算圆面积的实现函数,myRect.cpp是计算矩形面积的实现函数,myMain.cpp包含启动应用程序的main函数。文件结构如图3.11所示。

各文件的代码如下:

            //myArea.h
            double circle(double radius);
            double rect(double width, double length);
            //myCircle.cpp
            const double PI = 3.14;
            double circle (double radius)
            {  return PI*radius*radius;  }
            //myRect.cpp
            double rect (double with, double length)
            {  return with*length;  }
            //myMain.cpp
            #include<iostream>
            using namespace std;
            #include "myArea.h"
            int main()
            {  double width,length;
              cout<<"Please enter the width and length of a rectangle: \n";
              cin>>width >> length;
              cout<<"Area of rectangle is: "<<rect(width, length)<<endl;
              double radius;
              cout<<"Please enter the radius of a circle:\n";
              cin>>radius;
              cout<<"Area of circle is: "<<circle(radius)<<endl;
            }

图3.11 计算圆面积和矩形面积的文件结构

3.7.2 预处理指令

C++语言中,不论是.h文件还是.cpp文件,都是可以阅读的文本文件。要把它们翻译成可执行文件,主要经过三个步骤:预处理、编译和连接。

预处理器的功能是,阅读源程序,执行预处理指令,嵌入指定源文件。预处理器生成新的临时文件,提供给编译器进行语法分析、语义分析,生成目标代码。最后,连接器连接标准库,生成可执行文件。

预处理指令不是C++的语句,但它们可以改善程序的组织和管理,是程序员常用的工具。预处理指令以“#”号开始,每一条指令独占一行。预处理指令可以根据需要出现在程序的任何位置。

1.文件包含

#include 指令实现文件包含,在编译之前把指定文件的文本抄到该命令所在位置,用于支持多文件形式组织的C++程序。形式为:

#include <文件名>

或 #include "文件名"

其中,include为关键字。文件名是被包含文件的全名,按操作系统的要求定义,可以给定盘符和目录路径。

第一种形式用尖括号相括文件名,用于C++提供的系统标准头文件。这些文件存放在C++系统目录中的include子目录下。C++编译器识别这条指令后,直接从include子目录中查找尖括号相括的文件,嵌入指令所在的文件。

例如,前面程序经常使用的文件包含命令:

            #include <iostream>

它的作用是把C++标准头文件iostream包含到程序中。使用C++的标准头文件还需指定名空间或对特定组件指定所属的名空间。具体见本章3.8.1节。又如:

            #include <cmath>

它的作用是把C标准头文件cmath包含到程序中。

第二种形式用双引号相括文件名,一般用于包含程序员自己建立的头文件。C++编译器识别这条指令后,首先搜索当前子目录,如果没有找到,再去搜索C++的系统子目录。自定义头文件需要用.h作为扩展名。

例如,在例3-35中,include指令包含用户自定义的头文件:

            #include "myArea.h"

文件包含指令一般放在程序的开头。

2.宏定义指令

宏定义指令#define用来指定正文替换程序中出现的标识符。形式为:

            #define 标识符 文本

在C语言中,不带参数#define常用于定义常量,带参数#define用于定义简单函数。

【例3-36】用宏指令定义常量和函数。

            #include<iostream>
            using namespace std;
            //不带参数宏替换。在程序正文中,用3.1415926代替PI
            #define PI  3.1415926
            //带参数宏替换。在程序正文中,用PI*r*r代替area(x),x是参数
            #define area(r)  PI*r*r
            int main()
            {  double x,s;
              x=3.6;
              s=area(x);
              cout<<"s="<<s<<endl;
            }

由于宏指令是在程序正式编译之前执行的,所以不能对替换内容进行语法检查。C++的关键字const定义常量和inline定义的内联函数代替了#define定义常量和函数的作用。例3-36的程序如果改为用const定义标识常量和用inline定义内联函数,则得到例3-37形式的代码。宏指令使程序员便于处理C语言的代码。

【例3-37】定义标识常量和内联函数。

            #include<iostream>
            using namespace std;
            //在C++中定义标识常量
            const double PI=3.1415926;
            //在C++中定义内联函数
            inline double area(double r) {return PI*r*r;}
            int main()
            {  double x,s;
              x=3.6;
              s=area(x);
              cout<<"s="<<s<<endl;
            }

3.条件编译

条件编译指令可以根据一个常量值作为判断条件,决定源程序中某一段代码是否参加编译。条件编译指令的结构与if选择结构非常相似。下面介绍3种常用的形式。

(1)形式1

            #if  常量表达式
            程序文本
            #endif

若“常量表达式”的值为真(非0),则“程序文本”参与编译。

(2)形式2

            #if  常量表达式
            程序文本1
            #else
            程序文本2
            #endif

若“常量表达式”为真(非0),则“程序文本1”参与编译;否则,“程序文本2”参与编译。

条件编译指令中的“常量表达式”必须在编译时(程序执行之前)就有确定值。不能在“常量表达式”中进行强制类型转换,或作sizeof计算,也不能是枚举常量。

(3)形式3

            #ifndef 标识符
              #define标识符
                程序文本
              #endif

若“标识符”没有定义,则“程序文本”被编译;若“标识符”已经定义,则“程序文本”被忽略。

第1种和第2种形式的条件编译指令通常用于在程序调试阶段注释掉一大段待调试的代码,其作用相当于/*…*/,但显得更为清晰。例如:

            /*
                待调试代码段
            */

可以写成以下结构:

            #if 0
                待调试代码段
            #endif

当需要这段代码时,把“#if 0”改为“#if 1”就可以了。

第 3 种形式的条件编译通常用于多文件结构的头文件中,避免 include 指令嵌入文本导致联编时出现重定义的错误。例如,为了方便起见,头文件会有一些变量说明、函数代码的定义。如果一个cpp文件中已经有了这些定义,则直接包含头文件会产生重定义错误。在头文件中使用条件编译指令,起编译时阻隔作用。

声明语句是可以在同一个文件中重复出现的。

【例3-38】#define和条件编译在多文件程序中的应用。

            //ex3_38.cpp
            #include<iostream>
            using namespace std;
            #include"calculate.h"
            #include"calculate_1.h"
            int main()
            {  double r,h;
              cout << "input radius :\n";
              cin >> r;
              cout << "input height :\n";
              cin >> h;
              cout << "circle area : " << circle(r) << endl
                    << "cylinder volume : " << cylinder(r, h) << endl
                    << "cone volume : " << cone(r, h) << endl;
            }
            //cylinder.cpp
            //计算圆柱体体积
            #include"calculate_2.h"
            double cylinder(double radius, double height)
            {  return circle(radius)*height;  }
            //cone.cpp
            //计算圆锥体体积
            #include"calculate_2.h"
            double cone(double radius, double height)
            {  return cylinder(radius,height)/3;  }
            //calculate.h
            #ifndef CIRCLE_FUN
            //条件编译,若标识符CALCULATE_FUN未定义,则执行下一条宏指令
            #define CIRCLE_FUN        //用后续4行正文代替标识符CALCULATE_FUN
            double circle(double radius)
            {  const double PI=3.14159;
              return PI * radius * radius;
            }
            #endif
            double cone(double radius, double height);
            //calculate_1.h
            #ifndef CIRCLE_FUN
            #define CIRCLE_FUN
            double circle(double radius)
            {  const double PI=3.14159;
              return PI * radius * radius;
            }
            #endif
            double cone(double radius, double height);
            double cylinder(double radius, double height);
            //calculate_2.h
            double circle(double radius);
            double cone(double radius, double height);
            double cylinder(double radius, double height);

程序运行结果:

            input radius :
            12
            input height :
            7
            circle area : 452.389
            cylinder volume : 3166.72
            cone volume : 1055.57

该程序由3个cpp文件和3个头文件构成。在calculate.h和calculate_1.h两个文件中,都有函数circle的定义。而ex_39.cpp中有两条包含指令:

            #include"calculate.h"
            #include"calculate_1.h"

如果无条件地把函数circle的定义抄入两次,将出现重定义的错误。所以,在头文件calculate.h和calculate_1.h中,对函数circle的文本以标识符CIRCLE_FUN使用宏指令和条件编译配合定义。当 ex_38.cpp 执行第一条包含指令,抄入了函数定义后,执行第二条包含指令,由于标识符CIRCLE_FUN 已经定义,#ifndef 指令阻挡了再次企图嵌入的函数定义内容。CALCULATE_FUN是用户自定义标识符。

为避免多文件结构的重定义错误,除了在头文件中使用条件编译指令外,还应该尽量做到声明和定义分离,在头文件中只写数据类型、函数原型声明,把变量的定义和函数定义放在cpp文件中,养成良好的程序书写习惯。

3.7.3 多文件程序使用全局变量

从3.6节的讨论中可知,在所有函数之外定义的全局变量在默认情况下具有静态存储特性。全局变量可以被同一个文件中该变量说明之后的所有函数访问。程序的其他文件也能够访问全局变量,但必须在使用该全局变量的每一个文件中用关键字extern予以声明。

例如,在file1.cpp中说明了全局变量global:

            //file1.cpp
            …
            int global;
            …

若要在file2.cpp中使用它,则要求有声明:

            //file2.cpp
            …
            extern int global;
            …

存储说明符extern告诉编译器,变量global或者在同一个文件中稍后定义,或者在另一个文件中定义。编译器会通知连接程序,查找global的说明位置,从而解决对该变量的引用。

因为全局量可以被所有函数访问,所以使用全局量会降低函数之间传递数据的开销。但这样做违背了程序结构化和信息隐蔽的原则。若不是应用程序的执行效率至关重要的情况,不应该使用全局变量。

函数原型默认为 extern,即一个文件中只要声明了函数原型,函数定义就可以放在同一个文件或另外的文件中。例如,用include指令把函数原型嵌入当前文件之后,程序员就不需去关心函数定义的位置了。

如果希望全局变量或函数的作用范围限制在定义它的文件中,可以使用存储说明符static。例如:

            //f.cpp
            …
            static int max = 10000;
            static int fun (int, int);
            …

变量max和函数fun产生内部连接,其他文件不能访问max和调用fun。