C++头文件

前向声明的局限性

之前我们讨论了程序如何被分割到多个文件中。我们还讨论了如何使用前向声明,以允许一个文件中的代码访问在另一个文件中定义的内容。 当程序只包含几个小文件时,手动在每个文件的顶部添加一些前向声明并不是太糟糕。然而,随着程序的增长(并且使用更多的文件和函数),手动在每个文件的顶部添加大量(可能不同的)前向声明变得极其繁琐。例如,如果你有一个5个文件的程序,每个文件都需要10个前向声明,你将不得不复制/粘贴50个前向声明。现在考虑你有100个文件,每个文件都需要100个前向声明的情况。这根本无法扩展!

为了解决这个问题,C++程序通常采取不同的方法。

头文件的基本概念

C++代码文件(带有.cpp扩展名)并不是C++程序中常见的唯一类型的文件。另一种类型的文件被称为头文件。头文件通常有.h扩展名,但有时你也会看到它们带有.hpp扩展名或根本没有扩展名。 按照惯例,头文件被用来将一组相关的前向声明传播到代码文件中。

关键洞见 头文件允许我们将声明放在一个地方,然后无论在哪里需要,都可以导入它们。这可以在多文件程序中节省大量的输入工作。

使用标准库头文件

考虑以下程序:

#include <iostream>

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

这个程序使用std::cout将"Hello, world!“打印到控制台。然而,这个程序从未提供std::cout的定义或声明,那么编译器怎么知道std::cout是什么? 答案是std::cout已经在"iostream"头文件中被前向声明。当我们#include 时,我们请求预处理器将名为"iostream"的文件中的所有内容(包括std::cout的前向声明)复制到执行#include的文件中。

关键洞见 当你#include一个文件时,被包含文件的内容会在包含点被插入。这提供了一种有用的方式,从另一个文件中拉取声明。

考虑一下,如果iostream头文件不存在会发生什么。无论你在哪里使用std::cout,你都需要手动输入或复制所有与std::cout相关的声明到每个使用std::cout的文件的顶部!这将需要大量关于如何声明std::cout的知识,并且将是大量的工作。更糟糕的是,如果函数原型被添加或更改,我们将不得不手动更新所有的前向声明。 直接#include 要容易得多!

使用头文件传播前向声明

现在让我们回到我们在上一课中讨论的例子。当我们结束时,我们有两个文件,add.cpp和main.cpp,它们看起来像这样: add.cpp:

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

main.cpp:

#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时知道add是什么标识符。如前所述,手动为每个想要使用的函数添加前向声明,这些函数位于另一个文件中,很快就会变得乏味。

创建自己的头文件

让我们编写一个头文件来减轻这个负担。编写一个头文件出奇地容易,因为头文件只包含两个部分:

一个头文件保护,我们将在下一课(2.12 — 头文件保护)中更详细地讨论。 头文件的实际内容,应该是我们希望其他文件能够看到的标识符的所有前向声明。

向项目中添加头文件的工作方式类似于添加源文件(在2.8课 —— 多文件程序中介绍)。 如果使用IDE,请按照相同的步骤进行,并在被要求时选择"Header"而不是"Source”。头文件应该作为你的项目的一部分出现。 如果使用命令行,只需在你喜欢的编辑器中在你源代码(.cpp)文件相同的目录下创建一个新文件。与源文件不同,头文件不应被添加到你的编译命令中(它们被#include语句隐式包含,并作为你的源文件的一部分被编译)。

最佳实践 在命名你的头文件时,优先使用.h后缀(除非你的项目已经遵循其他约定)。 这是C++头文件的长期惯例,大多数IDE仍然默认使用.h而不是其他选项。

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

最佳实践 如果头文件与代码文件配对(例如add.h与add.cpp),它们应该有相同的基本名称(add)。

头文件的内容与使用

这是我们完成的头文件: add.h:

// 我们这里真的应该有个头文件保护,但为了简单起见省略了(我们将在下一课中介绍头文件保护)

// 这是.h文件的内容,也就是声明所在的地方
int add(int x, int y); // add.h的函数原型 —— 不要忘记分号!

为了在main.cpp中使用这个头文件,我们必须#include它(使用引号,而不是尖括号)。 main.cpp:

#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:

#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顶部添加前向声明的程序相同的程序。 因此,我们的程序将正确编译和链接。

编译过程的可视化

注意:在上面的图形中,“Standard Runtime Library"应该被标记为"C++ Standard Library”。

避免在头文件中定义函数

在头文件中包含定义会导致违反单一定义规则 目前,你应该避免在头文件中放置函数或变量定义。这样做通常会在头文件被包含到多个源文件时违反单一定义规则(ODR)。

相关内容 我们在2.7课——前向声明和定义中介绍了单一定义规则(ODR)。

让我们来说明这是如何发生的: add.h:

// 我们这里真的应该有个头文件保护,但为了简单起见省略了(我们将在下一课中介绍头文件保护)

// 在头文件中定义add() —— 不要这样做!
int add(int x, int y)
{
    return x + y;
}

main.cpp:

  
#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:

#include "add.h" // add.h的内容在这里被复制

当main.cpp被编译时,#include “add.h"将被替换为add.h的内容,然后被编译。因此,编译器将编译看起来像这样的东西: main.cpp(预处理后):

// 来自add.h:
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(预处理后):

// 来自add.h:
int add(int x, int y)
{
    return x + y;
}

这也没问题。

但是,当链接器执行时,它会发现add()的两个定义:一个在main.o中,一个在add.o中。这违反了ODR,链接器会报错。

最佳实践 不要在头文件中放置函数和变量定义(目前)。 在头文件中定义这些将很可能导致违反单一定义规则(ODR),如果那个头文件随后被#include到多个源(.cpp)文件中。

作者注 在以后的课程中,我们将遇到可以安全地在头文件中定义的其他类型的声明(因为它们免于ODR)。这包括内联函数、内联变量、类型和模板的定义。我们将在介绍这些内容时进一步讨论。

源文件应该包含它们配对的头文件 在C++中,代码文件应该包含它们配对的头文件(如果存在)是一个最佳实践。这允许编译器在编译时而不是链接时捕获某些类型的错误。例如: add.h:

// 我们这里真的应该有个头文件保护,但为了简单起见省略了(我们将在下一课中介绍头文件保护)

int add(int x, int y);

main.cpp:

#include "add.h"
#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这里复制前向声明

double add(int x, int y) // 哎呀,返回类型是double而不是int
{
    return x + y;
}

当add.cpp被编译时,前向声明int add(int x, int y)将被复制到add.cpp的#include点。当编译器到达定义double add(int x, int y)时,它将注意到前向声明和定义的返回类型不匹配。因为函数不能仅通过返回类型不同而有所不同,编译器将报错并立即中止编译。在更大的项目中,这可以节省大量时间,并帮助确定问题所在。

顺便说一句… 不幸的是,如果是一个参数类型不同而不是返回类型不同,这不起作用。这是因为C++支持重载函数(具有相同名称但参数类型不同的函数),所以编译器会假设参数类型不匹配的函数是不同的重载。不能全赢。

如果缺少#include “add.h”,编译器不会捕获问题,因为它看不到不匹配。我们不得不等到链接时问题才会浮现。 我们还将看到许多例子,在这些例子中,源文件所需的内容在配对的头文件中定义。在这种情况下,包含头文件是必要的。

最佳实践 源文件应该包含它们配对的头文件(如果存在)。

不要#include .cpp文件 尽管预处理器会很高兴地这样做,但你通常不应该#include .cpp文件。这些应该被添加到你的项目中并被编译。 这样做有很多原因:

这样做可能会导致源文件之间的命名冲突。

在大型项目中,很难避免一个定义规则(ODR)问题。

任何对这样的.cpp文件的更改都会导致.cpp文件和任何其他包含它的.cpp文件重新编译,这可能需要很长时间。头文件比源文件变更得少。

这样做是非传统的。

最佳实践 避免#include .cpp文件。

提示 如果你的项目不编译,除非你#include .cpp文件,这意味着那些.cpp文件没有作为你的项目的一部分被编译。将它们添加到你的项目或命令行中,以便它们被编译。

故障排除 如果你收到一个编译器错误,指出add.h没有找到,请确保文件确实被命名为add.h。根据你创建和命名它的方式,文件可能被命名为add(没有扩展名)或add.h.txt或add.hpp。还要确保它位于与你的其他代码文件相同的目录中。 如果你收到一个链接器错误,关于函数add没有被定义,请确保你已经将add.cpp包含在你的项目中,以便函数add的定义可以链接到程序中。

尖括号与双引号

你可能会好奇为什么我们对iostream使用尖括号,而对add.h使用双引号。有可能一个具有相同文件名的头文件可能存在于多个目录中。我们使用尖括号与双引号有助于给预处理器一个线索,让它知道应该在哪里查找头文件。

当我们使用尖括号时,我们是在告诉预处理器这是一个我们自己没有编写的头文件。预处理器将只在include directories指定的目录中搜索头文件。include directories是作为你的项目/IDE设置/编译器设置的一部分配置的,通常默认为你的编译器和/或操作系统附带的头文件所在的目录。预处理器不会在你的项目源代码目录中搜索头文件。

当我们使用双引号时,我们是在告诉预处理器这是一个我们编写的头文件。预处理器将首先在当前目录中搜索头文件。如果它在那里找不到匹配的头文件,它将然后搜索include directories。

规则

使用双引号来包含你编写的头文件,或者预期在当前目录中找到的头文件。使用尖括号来包含随你的编译器、操作系统或安装在系统其他地方的第三方库一起提供的头文件。

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

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

当C++最初被创建时,标准库中的所有头文件都以.h后缀结尾。这些头文件包括:

头文件类型命名约定示例放置在命名空间中的标识符
C++特定<xxx.h>iostream.h全局命名空间
C兼容性<xxx.h>stddef.h全局命名空间

原始版本的cout和cin在全局命名空间中的iostream.h中被声明。生活是一致的,一切都很好。

当语言被ANSI委员会标准化时,他们决定将标准库中使用的所有名称移动到std命名空间,以帮助避免与用户声明的标识符的命名冲突。然而,这提出了一个问题:如果他们将所有名称都移动到std命名空间,那么没有任何旧程序(包括iostream.h)将能够工作!

为了解决这个问题,C++引入了新的没有.h后缀的头文件。这些新头文件在std命名空间中声明所有名称。这样,包括#include <iostream.h>的旧程序就不需要重写,而新程序可以#include 。 现代C++现在包含4组头文件:

头文件类型命名约定示例放置在命名空间中的标识符
C++特定(新)iostreamstd命名空间
C兼容性(新)cstddefstd命名空间(必需)全局命名空间(可选)
C++特定(旧)<xxx.h>iostream.h全局命名空间
C兼容性(旧)<xxx.h>stddef.h全局命名空间(必需)std命名空间(可选)

警告 新的C兼容性头文件可能选择性地在全局命名空间中声明名称,旧的C兼容性头文件<xxx.h>可能选择性地在std命名空间中声明名称。这些位置的名称应该避免,因为这些名称可能在其他实现中没有在这些位置声明。

最佳实践 使用没有.h扩展名的标准库头文件。用户定义的头文件仍然应该使用.h扩展名。

从其他目录包含头文件 另一个常见问题涉及如何从其他目录包含头文件。 一种(不好)的方式是在#include行中包含你想包含的头文件的相对路径。例如:

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

虽然这样做会编译(假设这些文件存在于那些相对目录中),但这种方法的缺点是它要求你在代码中反映你的目录结构。如果你曾经更新你的目录结构,你的代码将不再工作。

一个更好的方法是告诉你的编译器或IDE,你在其他位置有一堆头文件,这样当它在当前目录中找不到它们时,它会在那里查找。这通常可以通过在IDE项目设置中设置include路径或搜索目录来完成。

对于Visual Studio用户

在解决方案资源管理器中右键单击你的项目,选择属性,然后选择VC++目录选项卡。在这里,你将看到一个名为Include Directories的行。在此处添加你希望编译器搜索额外头文件的目录。

对于Code::Blocks用户

在Code::Blocks中,转到项目菜单并选择构建选项,然后选择搜索目录选项卡。在此处添加你希望编译器搜索额外头文件的目录。

对于gcc用户

使用g++,你可以使用-I选项指定一个替代的include目录:

g++ -o main -I./source/includes main.cpp

-I.后面没有空格。对于完整路径(而不是相对路径),请在-I后去掉.。

对于VS Code用户 在你的tasks.json配置文件中,在“Args”部分添加一个新行:

"-I./source/includes",

-I后面没有空格。对于完整路径(而不是相对路径),请在-I后去掉.。

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

头文件可以包含其他头文件

头文件的内容可能会使用另一个头文件中声明(或定义)的内容。当这种情况发生时,头文件应该#include它需要的声明(或定义)所在的其他头文件。

Foo.h:

#include <string_view> // 需要使用std::string_view

std::string_view getApplicationName(); // 这里使用了std::string_view

传递性包含

当你的源(.cpp)文件#include一个头文件时,你也会得到任何其他被该头文件包含的头文件(以及任何被那些头文件包含的头文件,等等)。这些额外的头文件有时被称为传递性包含,因为它们是隐式而不是显式包含的。

这些传递性包含的内容可以在你的代码文件中使用。然而,你通常不应该依赖于从其他头文件隐式包含的头文件的内容(除非参考文档指示那些传递性包含是必需的)。头文件的实现可能会随着时间变化,或者在不同系统之间有所不同。如果发生这种情况,你的代码可能只能在某些系统上编译,或者可能现在可以编译但将来不行。这很容易避免,通过显式包含你的代码文件内容所需的所有头文件。

最佳实践 每个文件应该显式#include它需要编译的所有头文件。不要依赖于从其他头文件传递性包含的头文件。

不幸的是,没有简单的方法来检测你的代码文件是否不小心依赖于另一个头文件包含的内容。

Q:我没有包含,但我的程序还是工作了!为什么?

这是这个网站上最常问的问题之一。答案是:它可能工作,因为你包括了另一个头文件(例如),它本身包括了。尽管你的程序会编译,但根据上述最佳实践,你不应该依赖于此。对你编译的东西可能在朋友的机器上不会编译。

头文件包含的顺序

如果你的头文件写得正确,并且#include它们需要的一切,包含的顺序不应该重要。

现在考虑以下情况:假设头文件A需要头文件B的声明,但却忘记了包含它。在我们的代码文件中,如果我们在头文件A之前包含头文件B,我们的代码仍然会编译!这是因为编译器将在编译依赖于B中声明的A中的代码之前,编译B中的所有声明。

然而,如果我们首先包含头文件A,然后编译器会抱怨,因为A中的代码将在编译器看到B中的声明之前被编译。这实际上是可取的,因为错误已经被暴露出来,然后我们可以修复它。

最佳实践 为了最大化错过包含项被编译器标记的机会,请按以下顺序排列你的#include(跳过任何不相关的):

这个代码文件的配对头文件(例如add.cpp应该#include “add.h”)

同一项目的其他头文件(例如#include “mymath.h”)

第三方库头文件(例如#include <boost/tuple/tuple.hpp>)

标准库头文件(例如#include

每个分组的头文件应该按字母顺序排序(除非第三方库的文档指示你否则)。

那样的话,如果你的用户定义的头文件缺少对第三方库或标准库头文件的#include,它更有可能引起编译错误,以便你可以修复它。 头文件最佳实践

以下是创建和使用头文件的一些额外建议。

总是包含头文件保护(我们将在下一课中介绍这些)。

目前不要在头文件中定义变量和函数。

给你的头文件一个与它关联的源文件相同的名称(例如grades.h与grades.cpp配对)。

每个头文件应该有特定的工作,并且尽可能独立。例如,你可能把所有与功能A相关的声明放在A.h中,把所有与功能B相关的声明放在B.h中。那样的话,如果你以后只关心A,你可以只包含A.h,而不需要任何与B相关的内容。

注意你需要为代码文件中使用的功能显式包含哪些头文件,以避免不经意的传递性包含。

头文件应该#include任何包含它需要的功能的其他头文件。这样的头文件应该能够在被包含到.cpp文件中时单独编译成功。

只#include你需要的东西(不要只是因为你可以就包含一切)。

不要#include .cpp文件。

优先在头文件中放置文档,说明某物的作用或如何使用它。它更有可能在那里被看到。描述某物如何工作的文档应该保留在源文件中。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"