优秀的编程知识分享平台

网站首页 > 技术文章 正文

C++编程自学宝典:你所不知道的软件项目的目录结构和文件结构

nanyue 2024-07-19 23:59:43 技术文章 5 ℃

1.4 C++项目结构简介

C++项目中可以包含几千个文件,并且管理这些文件甚至可以成为一个单独的工作任务。当构建项目时,如果应该编译某个文件,那么选择哪种工具编译它?文件应该按照什么顺序编译?这些编译器生成的输出结果又是什么?编译后的文件应该如何组织到一起构造可执行文件?

编译器工具还拥有大量的选项,比如调试信息、优化类型、为多种语言特性提供支持以及处理器特性。编译器选项的不同组合将会用于不同场景(比如版本构建和版本调试)。如果用户是在命令行上执行编译任务的,那么务必确保选择了正确的选项,并在编译所有源代码的过程中始终应用它们

文件和编译器选项的管理可以变得很复杂。这也是用户应该使用一款构建工具处理即将上线的产品代码的原因。与Visual Studio一起安装的构建工具有: MSBuild和nmake两款。当用户在Visual Studio环境下构建一个Visual C++项目时,将使用MSBuild ,并且会把编译规则存放在一个XML文件中。用户甚至可以在命令行中调用MSBuild ,将XML项目文件传递给它。 mmake 是Microsoft在多个编译器之间维护程序多个版本的实用性工具。在本章中,读者将学习如何充分利用mmake 的实用性编写一个简单的makefile文件。

在介绍项目管理的基础知识之前,我们必须先了解用户通常会在C++项目中找到哪些文件以及编译器会如何处理这些文件。

1.4.1 编译器

C++是一门高级程序语言,旨在为用户提供丰富的语言特性,以及为用户和其他开发人员提供良好的可读性。计算机的处理器执行底层代码,并且这也是编译器将C++代码转化成处理器的机器码的主要目的。单个编译器也许可以兼容多种处理器,如果代码是符合C++规范的,那么它们还可以被其他编译器编译,以便兼容其他处理器。

不过,编译器的功能远不止于此。如第4章所述, C++允许用户将代码分割成若干函数,这些函数可以接收参数并返回一个值,因此编译器可以配置内存来传递这些数据。此外,函数可以声明只在函数内部使用的变量(第5章将介绍更多细节) ,并且它将只在函数被调用时才存在。编译器配置的内存称为栈帧( stackframe ),编译器中包含如何创建栈帧的选项,比如Microsof的编译器选项 /Gd 、/Gr和/Gz决定了函数参数被推送到堆栈上的次序,以及调用方函数或被调用函数在调用结束时是否应该从堆栈中移除这些参数。当我们编写的代码需要和其他人共享时,这些选项将非常重要(不过基于本书的目的,应该会使用默认的堆栈结构)。这只是冰山一角,不过编译器选项为用户提供的强大功能和灵活性应该会让读者印象深刻。

编译器编译C++代码,如果遇到代码中的错误,将向用户发送编译器错误提示信息。它是对代码的语法检查。这对于确保用户从语法角度编写完美的C++代码非常重要,不过这仍然可能是在做无用功。编译器的语法检查对于检查代码来说非常重要,不过用户应该总是使用其他方法检查代码。比如下列代码声明了一个整数类型变量并为它赋值:

int i=1/0:

编译器将向用户提示C2124 错误: divide or mod by zero (除数不能为0)。不过,下列代码将使用额外的变量执行相同的操作,但是编译器不会报错:

int j =0;
inti=1/ j;

当编译器提示出现错误时将停止编译。这意味两件事:首先,你将无法得到编译输出结果,因此将不会在一个可执行文件中找到该错误;其次,如果源代码中存在其他错误,我们只有在修复当前错误重新编译代码时才会发现它。如果你希望对代码执行语法检查并退出编译,可以使用/zs选项开关。

编译器还会生成警告信息。一个警告意味着代码将被编译,但是代码中的某个问题可能会对生成的可执行文件产生潜在的不良影响。Microsof编译器将警告分为4个级别:级别1是最严重的(应该立刻解决),级别4是信息性的。警告通常用于向用户声明被编译的语言特性可以正常运行,不过它需要的某个特定编译器选项,开发者并没有使用

在开发代码期间,我们将会经常忽略警告信息,因为这可能是在测试某些语言特性。

不过,当开发的代码准备上线发布时,你最好对警告信息多加留意。默认情况下, Microsof编译器将显示1级警告信息,你可以使用/选项和一个数字来声明希望看到的警告信息级别(比如, /M2表示用户希望看到2级警告以及1级警告)。在正式上线的产品代码中,你可能会使用/wx选项,这是告知编译器将警告信息也当作错误来看待,我们必须修复所有问题,以便能够顺利编译代码。你还可以使用pragma编译器( pragma 的概念将稍后介绍) ,并且编译器的选项还可以忽略特定警告信息。

1.4.2 链接代码

编译器将生成一个输出。对于C++代码来说,这将是对象代码,不过你可能还会得到一些其他的编译器输出,比如被编译的资源文件。对于它们自身来说,这些文件无法被执行,尤其是操作系统需要设置特定的结构时。一个C++项目将始终包含两个阶段:先将源代码编译成一个或者多个对象文件,然后将这些对象文件链接到一个可执行程序中。这意味着C++编译器将提供另外一种工具,即链接器。

链接器也有决定它如何工作并指定输出和输入的选项供用户选择,并且它还会向我们发出错误和警告信息。与编译嚣类似, Microsof的链接器也有一个选项nx ,它可以将预览版程序中的警告信息当作错误来处理。

1.4.3源文件

在最基本的层面,一个C++项目将只包含一个文件,即C++源代码文件。该文件一般是以cpp或者cxx后缀结尾的。

1.一个简单示例

一个最简单的C++程序如下:

#include <iostream>
//程序的入口点
int main()
{
std;scout <s "Hello, world!n":
}

第一点需要注意的是,以1/开头的行是注释。编译器将忽略直到该行末尾的所有文本。如果你希望使用多行注释,则注释的每行都必须以//开头。你还可以使用C语言风格的注释。一个C语言风格的注释是以1"开头、以./结尾的,这两个标识符之间的内容就是一个注释,包括换行符。

C语言风格的注释是一种对部分代码进行快速说明解释的方式。

大括号1}表示一个代码块。在这种情况下, C++代码就是函数main 。我们可以根据基本的格式判断这是一个函数,首先,它声明了返回值类型,然后具有一对括号的函数名,括号中常用于声明传递给该函数的参数(和它们的类型)。在这个示例中,函数名是main ,括号内是空的,说明该函数没有参数。函数名之前的标识符( int )表示该函数将返回一个整数。

C+中约定名为main 的函数是可执行文件的入口,即在命令行中调用该可执行程序时,该函数将是项目代码中首个被调用的函数。

main 函数只包含一行代码:这个单条语句是以std开头,然后以一个分号(;)作为结尾的。C++中空格的使用非常灵活,与之相关的详情将在下一章介绍。不过,有一点读者必须特别留意,那就是在使用文本字符串时(比如本文中使用的) ,每个语句都是用分号分隔的。语句末尾缺少分号是编译器错误的常见来源。一个额外的分号只表示一个空语句,因此对于新手来说,项目代码中分号太少的问题比分号过多更致命。

示例中的单个语句会在控制台上打印输出字符串" He110, world! "(以及一个换行符)。我们知道这是一个字符串,因为它是用双引号标记包裹起来的( ." )。该语句的含义是使用运算符<<将上述字符串添加到流对象std:scout中。该对象名中的std表示一个命名空间,实际上代表一组包含类似目的的代码集合,或者来自单个供应源。在这种情况下, std表示cout流对象是C++标准库的一部分。双冒号::是域解析运算符,并表示你希望访问的 cout 对象是在sta命名空间下声明的。你还可以定义属于自己的命名空间,并且在一个大型项目中用户应该定义自己的命名空间,因为它允许我们使用可能已经存在于其他命名空间的名称进行变量定义,并且这种语法使我们可以消除标识符的歧义。

对象cout是ostream类的一个实例,并且在main 函数被调用之前已经创建。<<表示一个名为运算符<<的函数被调用,并传递了相关的字符串(它是一个字符型数组)。该函数会将字符串中的每个字符打印输出到控制台上,直到遇到一个NML字符。

这是一个演示C++灵活性的示例,即被称为运算符重载的特性。运算符<<经常会与整数一起使用,它被用于将某个整数向左移动指定数目的位置; x<<y将返回一个将x向左移动y位后的值,实际上返回的值是x乘以2y后的值。不过,在上述代码中,代替整数x的是流对象std::cout ,并且代替左移索引的是一个字符串。很明显,运算符<<在C++中的定义并未生效。当运算符<<出现在一个ostream 对象的左边时,C++规范已经高效地对它进行了重新定义。此外,代码中的运算符<<将在控制台上打印输出一个字符串,因此它会接收位于右边的一个字符串。C++标准库中还定义了其他的<<运算符,使得用户可以将其他类型的数据打印输出到控制台。它们的调用方式都是一样的,编译器会根据使用的参数类型来决定使用哪个函数。

如前文所述, std:cout 对象已经作为 ostream 类的一个实例被创建,但是没有告知用户这是如何发生的。这将引出我们对这个简单源码文件没有解释的最后一个部分:以#include开头的第一行代码。这里#会高效地向编译器声明某种类型的信息。

可供发送的信息有多种(比如 #define 、#ifdef 、#pragma ,本书后续的内容将会涉及它们)。在这种情况下, #include 告知编译器在此处将特定文件的内容拷贝到该源代码文件中,实际上这意味着上述文件的内容也将被编译。这种特定的文件也叫头文件,并且在文件管理和通过库复用代码方面很重要。

文件<iostream)是标准库的一部分,可以在C++编译器附带的include目录下找到。尖括号( <> )表示编译器应该到用于存储头文件的标准目录中查找相关内容,不过我们可以通过双引号("" )提供头文件的绝对路径(或者当前文件的相对路径)。C++标准库按照惯例不使用文件的扩展名。你在命名自己的头文件时,最好使用h (或者hpp ,但很少使用hxx )作为文件的扩展名。C运行时库(也可以在C++代码中运行)中对它的头文件也会使用h作为其扩展名。

2.创建源文件

首先在“开始"菜单中找到Visual Studio 2017文件夹,然后单击"Developer Command Prompt for

vS2017"项。这个操作将会启动一个Windows命令提示符并为Visual C++ 2017配置环境变量。不过遗憾的是,它还会将命令行程序停留在Program Files目录下的Visual Studio文件中。如果你希望进行程序开发工作,将会希望将命令行程序从该文件夹移动到其他文件夹中,以便在创建和删除文件时不会对上述目录下的文件造成不良影响。在执行此操作之前,请转到Visual C++目录下,并列出其中文件:

C:\Program Files\Microsoft Visual studio\2017\community>cd
%VCToolsInstallDir%
C:\Program Files\Microsoft Visual
Studio\2017\Community'yC\Tools\MSVC\14.0.10.2517>dir

因为安装程序将把C++文件放在一个包含当前版本编译器的文件夹中,所以为了确保系统采用了最新版本的程序(目前的版本号是14.0.10.2517) ,通过环境变量VCToolsInstal1oir 要比声明特定的版本安全得多。

有几件事是需要留意的。首先是C++项目文件中的 bin、 include和1ib 目录,关于这3个文件夹的用途如表1-1所列。

本章后续的内容还会涉及这些文件夹。

另外要指出的是位于文件夹VCAuxillary\Build下的vevarsall.bat文件。当我们在"开始"菜单上单击"Developer Command Prompt for VS2017"项时,这个批处理文件将被执行。如果希望在一个现有的命令提示符中编译C++代码,那么可以通过运行这个批处理文件进行设置。该批处理文件中3个最重要的操作是设置环境变量PATH ,以便其中包含bin文件的路径,然后将环境变量INCLUDE和LIB分别指向 include和1ib文件夹

现在导航到根目录下,新建一个名为 Beginning_c++ 的文件夹,并导航到该目录下。接下来为本章创建一个名为Chapter_01 的文件夹。现在你可以切换到Visual Studio。如果该程序还未启动,则可以从“开始"菜单中启动。

在Visual Studio中,单击"文件"菜单,然后单击"新建”按钮,之后弹出一个新的对话框,在左边的树形视图中单击Visual C++项目。在该面板中间你可以看到C++ File (.cpp)和Header File (.h)两个选项以及打开文件夹时的C++属性项,如图1-7所示。

前两种文件类型主要用于C++项目;第三种类型将创建一个JSON文件辅助Visual Studio实现代码自动补全功能(帮助我们输入代码) ,本书将不会使用这个选项。

单击这些选项中的第一项,然后单击"Open"按钮。该操作将创建一个名为Sourcel.cpp的空白文件,为了将它以simple.cpp的形式另存到本章项目文件夹下,可以通过单击"File"按钮,然后选择"Save Sourcel.cppAs"项,导航到上述新建的项目文件目录下,在单击"Save"按钮之前,在文件名输入框中将之重命名为simple.cpp。

现在我们可以在该空白文件中输入简单程序的代码,代码内容如下:

#include <iostream>
int main()
{
std::cout << "Hello, world!n";
}

当完成上述代码的输入后,可以通过单击"File"菜单,单击其中的"Save simple.cpp"项保存该文件。接下来我们就可以编译代码了。

3,编译代码

转到命令行提示符下,然后输入命令c1 /2 。因为环境变量PATH配置引用了 bin文件夹的路径,你将看到编译器的帮助页面。可以通过按下"回车"键对这些帮助信息进行滚动浏览,直到返回命令提示符。其中大多数选项的用途超出了本书的范围,但是我们将讨论表1-2中所列的编译器开关选项。

对于某些选项需要注意,在开关和选项之间需要包含空格,有些选项则不能有空格,而对于其他选项空格是可选的。一般来说,如果文件或者文件夹的名称中包含空格,那么最好使用双引号将它们引起来。在使用一个开关之前,我们最好查看相关的帮助文件,了解它们是如何处理空格的。

在命令行中,输入c1 siple.cpp 命令,你将发现编译器发出的警告信息C4530和4577 。这是因为C++标准库会使用异常机制,但是用户没有为编译器声明应该为异常机制提供必需的代码。可以通过开关/EHsc 解决这个问题。在命令行中,输入命令 c1 /EHsc simple.cpp 。如果输入正确无误,则将看到如下结果

C:Beginning C++iChanter Olsdl /EHsc simple.cpp
Microsoft (R) CIC+ Optimizing Compiler Version 19.00.25017 for x86
Copyright (C) Microsoft Corporation. All rights reserver
simple.cpp
Microsoft (R) Incremental Linker Version 14.10.25017.0
Copyright (C) Microsoft Corporation. All rights reserved.
/out:simple.ese
simple.obj

默认情况下,编译器会将源代码文件编译成一个对象文件,然后将该文件传递给链接器,并将之链接为一个与C++源文件同名的命令行可执行文件,不过其文件扩展名为.exe 。上述信息行指出

/out:simple.exe 是由链接鹃生成的, /out 是一个链接器选项。

列出文件夹中的内容,你将会发现3个文件:源码文件 simple.cpp ;编译器生成的对象文件

simple.obj;可执行文件simple.exe ,即链接器将对象文件和相应的运行时库链接之后生成的可执行文件。你可以通过在命令行中输入simple来运行这个可执行文件:

C:Beginning _C++/Chanter_ 01>simple
Hello. World!

4·在命令行和可执行文件之间传递参数

如前所述,读者发现main函数会返回一个值,默认情况下该值是0。当应用程序执行完毕后,可以向命令行返回一个错误代码。这使得你可以在批处理文件和脚本中使用可执行程序,并且可以在脚本中使用上述返回值控制程序流。一般来说,当运行一个可执行程序时,可以在命令行上传递相关参数,这将对可执行程序的行为产生影响。

通过在命令行上输入simple命令来运行这个简单的应用程序。在Windows中,错误代码是通过伪环境变量 ERRORLEVEL 获取的,因此可以通过ECHO命令获得这个值

C:Beginning_C++\Chapter_01>simple
Hello.World!
C:Beginning_C++\Chapter_01>ECHO %ERRORLEVEL%
o

为了演示上述值是通过该应用程序返回的,可以将main 函数的返回值修改为一个大于0的值(本示例中是99,并且予以加粗表示

int main()
{
std::cout << "Hello, worId!n";
return 99;
}

编译上述代码并运行它,然后打印输出与前文类似的错误代码。你将看到现在输出的错误代码是99这是一种非常基础的交流机制:它只允许传递整数值,脚本调用代码时必须知道每个整数值代码的具体含义。

我们更有可能将参数传递给应用程序,这些参数将通过main函数的形式参数进行传递。将main函教替换成如下形式:

int main(int argc, char *argv[])
{
std::cout << "there are "<< argc<< "parameters"<<
std::end1;
for (int i =0; i < argc; ++i)
{
std::cout << argv[i]<<std;:end1;
}
}

当我们编写可以从命令行接收参数值的main 函数时,按照约定它会包含两个参数。

第一个参数通常称为argc 。它是一个整数,并表明了传递给应用程序的参数格式。这个参数非常重要。因为我们将通过一个数组访问内存,该参数将对所访问的内存做一定限制。如果访问内存时超出了此限制,那么将会遇到麻烦。最好的情况是访问未初始化的内存,最糟糕的情况是出现访问冲突。非常重要的点是,每当访问内存时,都要了解可以访问的内存数量,并确保在其限制范围之内。

第二个参数通常称为 argy ,它是一个指向内存中C字符串的指针数组。第4章将详细介绍指针数组第9章将详细介绍字符串,因此我们在这里不对它们进行深入讨论。

方括号(1)表示参数是一个数组,并且数组中每个成员的类型是char * 。*表示数组的每个元素是指向内存的指针。一般来说,这将被解析为一个指向单个给定类型元素的指针,不过字符串比较特别:char 表示内存中的指针指向的是以NUL字符(结尾的0个或者多个字符。字符串的长度是根据字符数目到NL字符的总数得出的。

上述代码中的第三行表示在控制台上打印输出传递给应用程序字符的长度。在这个示例中,我们将使用流sta:iend1 替代转义换行符(n)来添加一个新行。有不少运算符可供选择,与之有关的详情将在第6章深入介绍。std::end1 运算符将把新行添加到输出流中,然后对流中的内容进行刷新。

该行表示C++允许将输出运算符<s链接到一起并添加到流中,该行也向用户表明 输出运算符被重载了,不同类型的参数对应的运算符版本也各不相同(有3种情况:一种是接收整数(用于argv参数) ,另一种是接收字符串参数,还有一种是接收运算符作为参数) ,不过这些运算符的语法调用几乎是一样的。

最后,用于打印输出argy数组中每个字符串的代码块如下:

for (int i =0; i < argc; ++i)
{
std::cout <<argy[i] << std::end1;
}

for语句表示该代码块在变量1的值小于argc的值之前会一直被调用,每次循环迭代成功后变量1的值自动加1 (在它前面使用自增运算符)。数组中的元素是通过方括号进行访问的( [] )。传递的值是数组中的索引。

需要注意的是,变量i的起始值是0,因此访问第一个元素是通过argv[0]进行的,因为for循环完成后,变量i中包含的是argc的值,这意味着访问数组中最后一个元素是通过argv[argc-1]实现的。数组的一种典型用法是:第一个索引是0,如果数组中包含n个元素,那么最后一个元素的索引就是n-1。

如前文所述,编译并运行这些代码,并且不提供任何参数:

C:Beginning C++Chapter_01>simple
there are 1 parameters
simple

注意,即使你没有提供任何参数,程序本身也会认为你提供了一个参数,即可执行程序的名称。事实上,它不仅是程序名称,而且是命令行中调用可执行程序的命令。在这种情况下,输入simple命令(没有扩展名) ,会返回文件simple的值并将其作为参数打印输出到控制台上。再试一次,不过这次使用文件全名simple.exe 。现在你将会发现第一个参数是simple.exe 。

尝试使用一些实际的参数调用该代码。在命令行上输入命令simple test parameters;

C:Beeinning_C++\Chapter_01>simple test parameters
there are 3 parameters
simple
test parameters

这次程序执行结果表明存在3个参数,并且使用空格对它们进行了分隔。如果你希望在单个参数中使用空格,那么可以将整个字符串放到双引号中:

C:Beginning_C++\Chapter_01>simple "test parameters"
there are 2 parameters
simple
test parameters

请记住, argy是一个字符串的指针数组,因此如果你希望在命令行中传递一个数字类型的参数,则必须通过argv对它的字符串进行类型转换。

1.4.4预处理器和标识符

C+编译器编译源文件需要经过几个步骤。顾名思义,编译器的预处理器位于这个过程的开始部分。预处理器会对头文件进行定位并将它们插入到源文件中。它还会替换宏和定义的常量。

1,定义常量

通过预处理器定义常量主要有两种方式:通过编译器开关和编写代码。为了了解它的运行机制,我们将修改main 函数以便打印输出常量的值,其中比较重要的两行代码予以加粗显示:

#include <iostream>
#define NUMBER 4
int main()
{
std::cout << NUMBER<< std:sendl:
}

以#define开头的代码行是一条预处理器指令,它表示代码文本中任意标记为MUMBER 的符号都应该被替换成4,它是一个文本搜索和替换,但是只会替换整个符号(因此如果文件中包含一个名为 NUMBER9的符号,则其中的MUMBER部分将被替换)。预处理器完成它的工作之后,编译器将看到如下内容:

int main()
{
std::cout<<4<<std::endl:
}

编译原始代码并运行它们,将发现该程序会把4打印输出到控制台。

预处理器的文本搜索和替换功能可能会导致一些奇怪的结果,比如修改main 函数,在其中声明一个名为MUMBER 的变量,如下列代码所示:

int main()
{
int NUMBER=99;
std::cout<<NUMBER << std::end1;
}

现在编译代码,你将发现编译器报告了一个错误

C: Beginning_C++\Chapter_01>cl /EHhe simple.cpp
Microsoft (R) C/C++ Optimizine Compiler Version 19.00,25017 for x86
Copyright (C) Microsoft Corporation. All rights reserved.
simple.cpp
simple.cpp(7): error C2143: syntax error; missing ';' before 'constant'
simple.cop(7): erorr C2106'=': left operand must be l-value

这表示第7行代码中存在一个错误,这是声明变量新增的代码行。不过,由于预处理器执行了搜索和替换工作,编译器看到的代码将如下列内容所示:

int 4 =99

这在C++程序中是错误的!

在所输入的代码中,很明显导致该问题的原因是你在相同文件中拥有一个该标识符的#define伪指令。在实际开发过程中,我们将引用若干头文件,这些文件可能会引用其自身,因此错误的#define伪指令可能会在多个文件中被重复定义。同样,常量标识符可能会与引用的头文件中的变量重名,并可能会被预处理器替换。

使用#define定义全局常量并不是一种好的解决方案, C++中有更好的方法,与之有关的详情将在第3章深入介绍。

如果你认为预处理器替换标识符过程中可能存在问题,那么可以通过检查经过预处理器处理后传递给编译器的源文件来确认自己的判断。为此,在编译时需要搭配开关/EP一起使用。这将中断实际的编译过程,并将预处理器的执行结果输出到命令行窗口中。需要注意的是,这将生成大量的文本,因此最好将输出结果另存为一个文件,然后使用Visual Studio编辑器查看该文件。

为预处理器提供所需的值的方式是通过编译器开关传递它们。编辑上述代码并将以#define开头的代码行删除。像往常一样对代码进行编译( c1 /EHsc simple.cpp )并运行它,然后确保打印输出到控制台上的值是99 ,即分配给变量的值。现在再次通过下列命令对代码进行编译:

cl/EHsc simple.cpp /DNUMBER=4

注意,开关/D与标识符之间没有空格。这会告知预处理器使用4替换每个NUMBER符号,并且这会导致与前文所述相同的错误,这表明预处理器正尝试使用你提供的值替换相关符号。

Visual C+这类工具和mmake 项目将提供一种机制通过C编译器定义符号。开关/0只能用来定义一个符号,如果你希望定义其他符号,将需要提供与之相关的/0开关。

你现在应该理解为什么C++会拥有这样一个看上去只会引起歧义的古怪特性。一旦明白了预处理器的工作机制,那么符号定义功能将非常有用。

2.宏

宏是预处理器符号非常有用的特性之一。一个宏可以包含参数,并且预处理器将确保使用宏的参数搜索和替换宏中的符号。

编辑main函数如下列代码所示:

#include <iostream>
#define MESSAGE (c, v)
for(int i =1; i<c; ++i) std::cout << v[i] << std::endl;
int main(int argc, char *argv[])
{
MESSAGE (argc, argv);
std::cout << "invoked with "<<argy[0] << std::endl;
}

注意,在宏定义中,反斜杠(\)表示行连接符,因此我们可以定义包含多行的宏。通过一个或者多个参数编译和运行这些代码,然后确保 MESSAGE能够打印输出命令行参数。

3.标识符

我们可以定义一个不包含值的标识符,并且预处理器可以被告知测试验证某个标识符是否被定义。最常见的应用场景是编译调试版本的不同代码,而不是发布版程序。

编辑上述代码并添加加粗显示的代码行:

#ifdefDEBUG
#define MESSAGE(c, v)
for(int i=1; i<c; ++i) std::cout << v[i] << std::endl;
#els
#define MESSAGE
#endit

第一行代码告知预处理器去查找 DEUG标识符。如果该标识符已经定义(不管其值是什么) ,则第一MESSAGE 宏定义将被使用。如果该标识符未定义(一个预览版构建) ,则MESSAGE标识符将被定义,不过它不执行任何操作,本质上来说,就是将代码中出现的包含两个参数的 MESSAGE 删除。

编译上述代码并通过一个或者多个参数运行该程序,比如下列内容:

C:\Beginnine C++\Chapter _01>simple test parameters
invoked with simple

这表示代码已经在不包含DEBUG定义的情况下被编译,因此MESSAGE的定义将不会执行任何操作。现在再次编译代码,不过这次使用 /ODEBUG开关来定义 DEUG标识符。再次运行该程序之后,用户将发现命令行参数被打印输出到控制台上:

C:\Beginning C++\Chapter _01>simple test parameters
test parameters
invoked with simple

上述代码使用了宏,不过我们可以通过条件编译在任意C+代码中使用标识符。这种标识符的使用方式允许编写灵活的代码,并且可以通过在编译器命令行中定义一个标识符来选择将要被编译的代码。此外,编译器自身也将定义一些标识符,比如DATE将包含当前日期、TIME将包含当前时间、FILE将包含当前文件名。

4. pragma指令

与标识符和条件编译有关的是编译器指令#pragma once 。 pragma是专属于编译器的指令,不同编译器支持的pragma也不尽相同。Visual C++定义的#pragma once指令是为了解决多个头文件重复引用相同头文件的问题。该问题可能导致相同元素被重复定义一次以上,并且编译器会将之标记为错误。有两种方法可以执行此操作,并且<iostream>头文件下采用了这两种技术。你可以在Visual C++的 include文件夹下找到该文件。在该文件顶部将看到如下代码:

//ostream standard header
#pragma once
#ifndef_ IOSTREAM_
#define_ IOSTREAM_

在该文件底部,将看到如下代码行:

#endif/*_IOSTREAM_*/

首先是条件编译。该头文件的名称首次被引用,标识符 IOSTREAM-还未被定义,所以该标识符会被定义,然后其余的文件将被引用直到#endif代码行。上述过程演示了条件编译时的最佳实践。对于每个#ifndef ,都有一个 tendif与之对应,并且它们之间包含数百行代码。当使用#ifdef或者

#ifundef时,为相应的#else 和#endif提供注释说明信息是比较推荐的做法,这样做的目的是声明标识符引用的目标。

如果文件被再次引用,则标识符 _IOSTREAML 将被定义,这样一来#ifndef和tendif之间的代码将被忽略。不过,非常重要的一点是,即使已经定义该标识符,头文件仍然将被载入和处理,因为相关的操作指令是被包含在文件中的。

#pragma once 标识符会对条件编译执行相同的操作,不过它解决了可能存在的标识符重复定义的问题。如果你将这行代码添加到了自己的头文件顶部,将指示预处理器载入和处理该文件一次。预处理器维护着一份已经处理过的文件列表,如果后续的某个头文件尝试载入一个已经处理过的文件,则该文件将不会被载入和处理。这种做法可以减少项目预处理过程所需的时间。

在关闭<iostream>文件之前,可以查看该文件的代码行数。对于版本是v6.50:0009的<iostream>它包含55行代码。这是一个小型文件,不过它引用的<istream>文件有( 1157行) ,引用的<ostream)文件有( 1036行) ,引用的<ios)文件有(374行) ,引用的文件有(1630行)。预处理的结果可能意味着你的源代码文件中将引用数万行代码,即使程序只包含一行代码!

1.4.5依赖项

一个C++项目将生成一个可执行文件或者库,它们是由链接器根据对象文件构建的。可执行文件或者库依赖于这些对象文件。一个对象文件是由一个C++源代码文件(可能包含一个或者多个头文件)编译而来的。对象文件依赖于这些C++源代码文件和头文件。理解这些依赖关系非常重要,它可以帮助我们了解项目代码的编译顺序,并且允许我们通过只编译已更改的文件来加快项目构建的速度。

1.库

当你在自己的源代码文件中引用一个文件时,头文件中的代码将能够访问代码。我们引用的文件可能包含整个函数或者类的定义(与之有关的详情将在后续章节介绍) ,不过这将导致出现前面提及的问题:某个函数或者类被重复定义。相反,你可以声明一个类或者函数原型,这可以指示代码如何调用函数而不进行实际定义。显然,代码将在其他地方定义,这可能是在一个源文件或者库中,不过这对编译器来说很有利,因为它只看到了一个定义。

库就是已经定义的代码,它经过完全的调试和测试,因此将不需要访问源代码。C++标准库主要是通过头文件的形式共享的,这有助于调试项目代码,但是你必须抵制住任何临时编辑这些代码的诱惑。其他库将以已编译程序库的形式提供。

编译程序库一般有两种:静态库和动态链接库。如果使用的是静态库,那么编译器将从静态库中拷贝我们所需的代码,并将它们集成到可执行程序中。如果你使用的是动态链接(共享)库,那么链接晶将在程序运行过程中(有可能是在可执行程序被加载后,或者可能被推迟到函数被调用时)添加一些信息,以便将共享库加载到内存中并访问其功能特性。

如果你所需的代码在某个静态库或者动态链接库中,编译器将需要精确地知道你调用函数的信息,以便确保函数调用时使用正确的参数个数和类型。这也是函数原型的主要用途:它在不提供实际函数体的情况下,为编译提供了调用函数所需的信息,即函数定义。

本书将不会涉及如何编写程序库的细节,因为它是特定于编译器的,也不会详细介绍调用程序库代码,因为不同操作系统共享代码的方式也各不相同。一般来说, C++标准库将以标准头文件的形式引入项目中。C运行时库(将为C++标准库提供一些代码)将以静态链接库的形式引入,不过如果编译器提供了动态链接版本程序,那么我们可以通过编译器选项来使用它。

2.预编译头文件

当我们将一个文件引入到源代码文件中时,预处理器将引入该文件的内容(在执行完所有条件编译指令之后),并且以递归的方式添加所有该文件引用的任意文件。如前所述,最终的结果可能涉及数千行代码。在程序开发过程中,我们将经常编译项目代码,以便对代码进行测试。每次编译代码时,在头文件中定义的代码也会被编译,即使头文件中的代码没有发生任何变化。对于大型项目,这使得编译过程需要耗费很长时间才能完成。

为了解决这个问题,编译器通常会提供一个选项,对没有发生变更的头文件进行预编译。预编译头文件的创建和使用是特定于编译器的。比如GNU C++编译器gcc ,如果编译的某个头文件是一个C++源代码文件(使用/x开关) ,该编译器会创建一个扩展名为gch的文件。当 gcc编译源代码文件需要用到该头文件时,它会去搜索该gch文件。如果它找到了该预编译头文件,将使用它;否则,它会使用对应的头文件。

在Visual C++中该过程稍微有点复杂,因为必须在编译器编译源代码文件时,告知编译器去查找某个预编译头文件。Visual C++项目的约定是提供一个名为stdafx.cpp 的源文件,其中包含一行引用stdafx.n文件的代码。你可以在stdafx.n文件中引用所有性能稳定的头文件。然后可以通过编译stdafx.cpp文件来创建一个预编译头文件,同时使用Yc编译器选项声明所有性能稳定并且需要被编译的头文件都包含在了 stdafx.h文件中。这将创建一个pch文件(一般来说, Visual C++将在项目名称之后附加相关的名称),其中包含经过编译的所有stdafx.h 头文件中引用的代码。其他源代码文件中必须将stdafx.h头文件作为第一个引用的文件进行引用,不过它们还可以引用其他文件。当编译源代码文件时,可以使用/Yu开关声明性能稳定的头文件( staafx.h ) ,编译器将使用预编译pch文件替代相关的头文件。

当在浏览大型项目文件时,经常会发现其中采用了不少预编译头文件。如你所见,它会改变项目的文件结构。本章后续的示例将向读者演示如何创建和使用预编译头文件。

3.项目结构

将项目代码进行模块化组织非常重要,这使得我们可以高效地对项目代码进行维护。第7章将介绍面向对象编程技术,它是一种组织和复用代码的方式。不过,即使你正在编写类似C语言的过程式代码(也就是说,代码是以线性的方式进行函数调用的) ,仍然可以将它们组织成模块化的形式,继而从中获益。比如,代码中的某些函数是与操作字符串有关的,其他函数是与文件访问有关的,那么我们可以将字符串函数的定义放在某个单独的源码文件中,即string.cpp ;与文件函数定义有关的内容放在其他文件中,即

file.cpp 。这样一来,就可以方便项目文件中的其他模块调用这些文件,你必须在某个头文件中声明这些函数的原型,并在调用这些函数的模块中引用上述头文件。

在头文件和源代码文件之间包含函数的定义在语言层面并没有绝对的规则。你可能在string.cpp的函数中引用了一个名为string.n 的头文件,或者在file.cpp 的函数中引用了一个名为file.h的头文件。又或者我们可能只有一个utilities.n文件,其中包含上述两个文件中所有函数的声明。我们必须遵守的唯一规则是,在编译时,编译器必须能够通过某个头文件或者函数定义本身,在当前的源代码文件中访问函数的定义。

编译器在源代码中将不会向前查找,因此如果函数A准备调用函数B,那么在同一源代码文件中函数B必须在函数A调用它之前就已经被定义,否则必须存在一个对应的原型声明。这导致了一个非常典型的约定,即每个包含头文件的源代码文件中包含函数的原型声明,并且该源文件引用上述头文件。当编写类时,这一约定变得更加重要。

4,管理依赖项

当通过构建工具构建项目时,将先检查构建的输出是否存在,如果不存在,则执行适当的构建操作。构建步骤的输出的通用术语一般称为目标,构建步骤的输入(比如源代码文件)是目标的依赖项。每个目标的依赖项是用于构建它们的文件。依赖项也可能自身就是某个构建动作的目标,并且拥有它们自己的依赖项

比如,图1-8展示了一个项目的依赖关系。

在这个项目中,有main.cpp 、file.cpp和file2.cpp 三个源代码文件。它们引用了相同的头文件utils.n ,它可以被预编译(因为有第四个源代码文件utils.cpp ,它只引用了utils.h 头文件)。所有源代码文件都依赖于utils.pch文件,而utils.pch文件又依赖于utils.h文件。源代码文件

main.cpp 包含main 函数,并调用了存在于file1.cpp和file2.cpp两个源代码文件的函数,而且是通过头文件file1.n和file2.n访问这些函数的。

在第一次编译时,构建工具将发现可执行程序依赖于4个对象文件,因此它将根据查找规则来构建每个对象文件。存在3个C++源代码文件的情况下,这意味着需要编译源代码文件,不过因为utils.obj是用于支持预编译头文件的,因此构建规则将与其他文件不同。当构建工具生成这些对象文件时,它将使用任意库代码将它们链接到一起(这里未显示)。

随后,如果你修改了file2.cpp文件,然后构建该项目,构建工具将发现只有file2.cpp文件被修改,并且因为只有file2.obj文件依赖于file2.cpp文件,需要构建工具做的所有工作就是编译

file2.cpp文件,然后使用现存的对象文件链接新的file2.obj文件,以便创建可执行程序。如果你修改了头文件file2.n ,构建工具将发现有两个文件依赖于该头文件,即file2.cpp 和main.cpp ,因此构建工具将编译这两个源代码文件,然后使用现有的对象文件链接新生成的对象文件file2.obj和

main.obj ,以便生成可执行文件。但是,如果预编译的头文件util.n发生了变化,这意味着所有源代码文件都必须重新编译。

对于小型项目来说,依赖关系的管理还比较容易。如你所见,对于单个源文件项目,我们甚至不需要为调用链接器操心,因为编译器会自动执行。但随着C++项目规模不断增大,依赖项的管理会变得越来越复杂,这时诸如Visual C++这样的开发环境就会变得至关重要。

5. makefile文件

如果你正在维护一个C++项目,则很有可能会遇到makefile文件。它是一个文本文件,其中包含用于构建目标文件的目标、依赖项以及项目构建规则。 makerile是通过make命令进行调用的,其中

Windows平台的工具是nmake 、类Unix平台的工具是make 。一个makefile文件就是一系列与下列内容类似的规则:

targets: dependents
commands

目标是一个文件还是多个文件取决于其依赖项(也可能是多个文件) ,因此如果有一个或者多个依赖项比目标文件中的版本更新(并且目标自上次构建之后已经发生变更) ,那么将需要再次构建目标文件,这些操作是通过运行相关命令完成的。可能有多个命令,每个命令都是以制表符为前缀处于单个行上。一个目标可能不包含任何依赖项,在这种情况下,这些命令仍然将被调用。

比如,使用上述示例时,可执行文件test.exe的构建规则如下:

test.exe: main.obj filel.obj file2.obj utils.obj
link /out:test. exe main.obj filel.obj file2.obj utils.obj

因为对象文件main.obj 依赖于源代码文件 main.cpp 、头文件File1.n和File2.h 、预编译头文件utils.pch ,所以该文件的构建规则如下:

main.obj: main.cpp filel,h file2,h utils.pch
C1 /c /Ehsc main. cpp /Yuutils,h

编译器被调用时使用了/c开关选项,这表明相关的代码会被编译成对象文件,但是编译器将不会调用链接器。 /Yu开关选项和头文件utils.h搭配使用,是告知编译器使用预编译头文件utils.pch 。其他两个源代码文件的构建规则与此类似。

生成预编译头文件的构建规则如下:

utils.pch: utils.cpp utils.h
C1 /c /EHsc utils.cpp /Ycutils.h

/YC开关是告知编译器使用头文件utils.n 创建一个预编译头文件。

实际开发中makefile通常比上述内容更复杂。它们将包含宏,即组目标、依赖项和命令行开关。它们还会包含目标类型的一般规则,而不是这里描述的具体规则,而且它们还将包含条件测试的内容。如果需要维护或者编写makefile ,那么你应该详细了解构建工具帮助手册中的所有选项。

本文节选自《C++编程自学宝典》

本书旨在通过全面细致的内容和代码示例,带领读者更加全方位地认识C++语言。全书内容共计10章,由浅入深地介绍了C++的各项特性,包括C++语法、数据类型、指针、函数、类、面向对象特性、标准库容器、字符串、诊断和调试等。本书涵盖了C++11规范及相关的C++标准库,是全面学习C++编程的合适之选。


Tags:

最近发表
标签列表