章节目录

头文件

本节阅读量:

头文件及其用途

随着程序越来越复杂(使用更多的文件),在不同cpp文件中要使用大量重复的前向声明。如果可以将所有的前向声明放在一个地方,在需要时导入,会方便一些。

C++代码文件(扩展名为.cpp)并不是C++程序中常见的唯一文件类型。另一种类型的文件称为头文件。头文件大多数扩展名为.h扩展名,偶尔会有.hpp扩展名或根本没有扩展名。头文件的主要目的是将声明传播到代码(.cpp)文件。


使用标准库头文件

考虑以下程序:

1
2
3
4
5
6
7
#include <iostream>

int main()
{
    std::cout << "Hello, world!";
    return 0;
}

该程序使用std::cout将“Hello,world!”打印到控制台。然而,该程序从未提供std::cout的定义或声明,那么编译器如何知道什么是std::cout?

原因是std::cout已在“iostream”头文件中向前声明。当#include时,预处理器将所有内容(包括std::cout的前向声明)从名为“iostream”的文件复制到#include 的位置。

考虑如果iostream头文件不存在会发生什么?无论哪里用std::cout,都必须将与std::cout相关的声明复制到使用std::cout的地方。这将需要大量关于std::cout的知识,且是一项繁重的工作。更糟糕的是,如果添加或更改了对应的函数或变量原型,就必须手动更新所有的前向声明。

只使用#include要容易得多!


使用头文件传播前向声明

现在,回到上一课中讨论的示例。有两个文件,add.cpp和main.cpp,如下所示:

add.cpp:

1
2
3
4
int add(int x, int y)
{
    return x + y;
}

main.cpp:

1
2
3
4
5
6
7
8
9
#include <iostream>

int add(int x, int y); // 前向声明函数原型

int main()
{
    std::cout << "The sum of 3 and 4 is " << add(3, 4) << '\n';
    return 0;
}

(如果从头开始重新创建此示例,请不要忘记将add.cpp添加到项目中,以便进行编译)。

在本例中,使用了前向声明,以便编译器在编译main.cpp时知道添加的标识符是什么。如前所述,为要使用的位于另一个文件中的函数添加前向声明是重复冗余的。

编写一个头文件来减轻负担。编写头文件非常容易,因为头文件仅由两部分组成:

  1. 头文件保护,下一节中讨论。
  2. 头文件的实际内容。包含其它文件要使用的前向声明。

将头文件添加到项目中的工作方式类似于添加源文件。

如果使用IDE,请执行相同的步骤,并选择“头文件”而不是“源文件”。头文件应作为项目一部分出现。

如果使用命令行,只需在编辑器中创建一个文件,该文件与源(.cpp)文件位于同一目录。与源文件不同,头文件不应添加到编译命令中(它们被#include语句隐式包含并编译为源文件的一部分)。

头文件通常与代码文件配对,头文件为相应的代码文件提供前向声明。由于头文件将包含add.cpp中定义的函数的前向声明,因此新建头文件add.h。

这是对应的头文件:

add.h:

1
2
3
4
// 1) 这里需要一个头文件保护,但暂时不影响程序编译,下一节讨论对应细节。

// 2) 这里是 .h 文件内容
int add(int x, int y); // add函数声明,不要忘记分号

为了在main.cpp中使用此头文件,需#include引用它(使用引号,而不是尖括号)。

main.cpp:

1
2
3
4
5
6
7
8
#include "add.h" // add.h 的内容会被插入这里。需要使用双引号
#include <iostream>

int main()
{
    std::cout << "The sum of 3 and 4 is " << add(3, 4) << '\n';
    return 0;
}

add.cpp:

1
2
3
4
5
6
#include "add.h" // add.h 的内容会被插入这里。需要使用双引号

int add(int x, int y)
{
    return x + y;
}

当预处理器处理#include “add.h” 时,会在该点将add.h的内容复制到当前文件中。因为add.h包含函数add() 的前向声明,所以该前向声明将被复制到main.cpp。最终结果是一个程序,其功能与main.cpp顶部添加前向声明的程序相同。

因此,程序将正确编译和链接。

引用关系示意图

在头文件中包含定义如何导致违反单定义规则

应避免将函数或变量定义放在头文件中。若头文件被多个源文件使用,这样违反单定义规则(ODR)。

说明这是如何发生的:

add.h:

1
2
3
4
5
6
7
// 这里需要头文件保护,下一节讨论对应细节。

// add() 函数定义 -- 不要这样做!
int add(int x, int y)
{
    return x + y;
}

main.cpp:

1
2
3
4
5
6
7
8
9
#include "add.h" // add.h 的内容会被插入这里
#include <iostream>

int main()
{
    std::cout << "The sum of 3 and 4 is " << add(3, 4) << '\n';

    return 0;
}

add.cpp:

1
#include "add.h" // add.h 的内容会被插入这里

编译main.cpp时,#include “add.h” 将替换为add.h的内容,然后进行编译。因此,编译器将编译如下所示的内容:

main.cpp(预处理后):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
int add(int x, int y)
{
    return x + y;
}
include <iostream>

int main()
{
    std::cout << "The sum of 3 and 4 is " << add(3, 4) << '\n';

    return 0;
}

这可以编译通过。

编译器编译add.cpp时,#include “add.h” 将替换为add.h的内容,然后进行编译。因此,编译器将编译如下:

add.cpp(预处理后):

1
2
3
4
int add(int x, int y)
{
    return x + y;
}

也可以很好地编译。

最后,链接器将运行。链接器将看到函数add() 有两处定义:一个在main.cpp中,另一个在add.cpp。这违反了ODR第2部分的规定,该部分指出,“在给定的程序中,变量或普通函数只能有一个定义。”


代码中.h与.cpp应该成对

在C++中,代码文件的最佳实践是#include成对的头文件(如果存在)。上面示例中,add.cpp引用add.h。

允许编译器在编译时而不是链接时捕获某些类型的错误。例如:

something.h:

1
int something(int); // 返回值是int的前向声明

something.cpp:

1
2
3
4
5
#include "something.h"

void something(int) // 编译失败,返回值类型不一致
{
}

由于something.cpp引用了something.h,编译器会注意到函数something() 的返回类型不匹配,并给出编译错误。如果something.cpp没有#include somethine.h,等待链接器发现问题,会浪费时间。

在未来的课程中,还将看到许多示例,其中源文件所需的内容在成对的头文件中定义。这种情况下,包括头文件是必要的。


不#include .cpp文件

尽管预处理器能处理,但通常不应该#include .cpp文件。应该添加到项目中并进行编译。

这样做有许多原因:

  1. 可能会导致源文件之间的命名冲突。
  2. 在大型项目中,很难避免违反单定义规则(ODR)的问题。
  3. 对这样的.cpp文件的更改都将导致.cpp文件和包含它的其他.cpp 文件重新编译,耗时长。与源文件相比,头文件的更改频率较低。
  4. 这样做是非常规的。

故障排除

如果出现编译器错误,指示找不到add.h,确保文件实际命名为add.h,而不是add(无扩展名)或add.h.txt或add.hpp。此外,确保它与其余代码文件位于同一目录中。

如果提示未定义函数add的链接错误,确保项目中已包含add.cpp,以便可将函数add定义链接到程序中。


尖括号与双引号

为什么对iostream使用尖括号,而对add.h使用双引号。多个目录中可能有相同文件名的头文件。对尖括号和双引号的使用有助于给预处理器一个线索,告诉它在哪里查找头文件。

当使用尖括号时,预处理器知道这不是程序员编写的头文件。预处理器将仅在系统目录指定的目录中搜索头文件。include系统目录配置为项目/IDE设置/编译器设置的一部分,通常默认为包含编译器和/或操作系统附带的头文件的目录。预处理器不会在项目的源代码目录中搜索对应的头文件。

当使用双引号时,预处理器知道这是编写的头文件。预处理器首先在当前目录中搜索头文件。如果找不到匹配的头文件,将搜索系统目录。


为什么iostream没有.h扩展名?

另一个常见的问题是“为什么iostream(或任何其他标准库头文件)没有.h扩展名?”。答案是iostream.h与iostream是不同的头文件!解释需要一堂简短的历史课。

首次创建C++时,标准库中的所有文件都以.h后缀结尾。如果生活始终如一,是美好的。cout和cin的原始版本在iostream.h中声明。ANSI委员会标准化C++语言时,将标准库中使用的标识符移到std命名空间中,以免与用户声明的标识符发生命名冲突。然而,会导致问题:如果将所有标识符移到std命名空间中,则旧程序(include iostream.h)都将无法再工作!

为了解决此问题,引入一组没有.h扩展名头文件,声明了std命名空间中的所有标识符。原来的iostream.h保持不变,旧程序不需要重写,而新程序也能用#include <iostream>。

此外,从C继承的在C++中仍有用的库都被赋予了C前缀(例如,stdlib.h变为cstdlib)。


包括其他目录的头文件

另一个常见的问题,如何包括其他目录的头文件。

一种错误方法是#include头文件的相对路径。例如:

1
2
#include "headers/myHeader.h"
#include "../moreHeaders/myOtherHeader.h"

虽然能够编译成功(相对目录中找到了文件),但缺点是会在代码中有目录结构。如果目录结构改变,代码不再工作。

更好的方法是告诉编译器或IDE,在其他位置有一组头文件,若在当前目录中找不到时,会在那里查找。在IDE项目设置中,设置头文件路径或搜索目录来完成。

这种方法好处是,如果更改了目录结构,只需更改单个编译器或IDE设置,而不是每个代码文件。


头文件可以include其他头文件

头文件也会使用其它头文件中声明或定义。因此,头文件通常#include其他头文件。

当#include头文件时,将获得此头文件#include的其他头文件(以及递归#include的所有头文件等)。它们是隐式包含的,而不是显式包含。

虽然递归包含的内容可在代码中使用。不过,不应依赖递归包含的头文件(除非参考文档指示需要递归#include)。头文件的实现可能会随着时间的推移而变化,或者在不同系统中有所不同。因此,代码可能只能在某些系统上编译,或者可以现在编译,但不能在将来编译。通过显式#include代码文件内容所需的头文件,就可避免这种情况。

不幸的是,当代码意外依赖于另一个头文件所包含的头文件时,并没有简单的方法来检测。


头文件的#include顺序

如果头文件编写正确,并且#include了需要的内容,那么包含的顺序应该无关紧要。

考虑以下场景:假设头文件A需要来自头文件B的声明,但没有include它。如果在头文件A之前包含头文件B,代码仍会编译!因为编译器在编译A时,已获得了B的全部信息。

然而,如果首先包含头文件A,那么编译器将报错,因为A的代码在编译器看到B的声明之前编译。因此错误出现,可以修复它。


头文件最佳实践

下面是创建使用头文件的建议。

  1. 始终使用头文件保护(将在下一课中介绍)。
  2. 不要在头文件中定义变量和函数(目前)。
  3. 为头文件提供与其关联的源文件相同的名称(例如,grades.h与grades.cpp成对出现)。
  4. 每个头文件都应该有一个特定的功能,并且尽可能独立。例如,将与功能A相关的声明放在A.h中,将与功能B相关的声明放在B.h中。如果只关心A,则可只包含A.h,而不获取与B相关的内容。
  5. 注意为代码文件中使用的功能显式包含对应的头文件。
  6. 头文件都应该能单独编译(应该#include需要的每个依赖项)。
  7. 仅#include 需要的内容(不要因为允许而include所有内容)。
  8. 不要#include .cpp文件。
  9. 在头文件中放置关于某段代码的作用或使用文档。它更可能在那里被看到。描述代码如何工作的文档应保留在源文件中。

2.9 预处理器简介

上一节

2.11 头文件保护

下一节