2-11 头文件

第2.8课——多代码文件程序中,我们讨论了如何将程序拆分为多个文件。还探讨了如何使用前向声明,使一个文件中的代码能够访问另一个文件中定义的内容。

当程序仅包含少量小型文件时,手动在每个文件开头添加几个前向声明尚可接受。但随着程序规模扩大(涉及更多文件和函数),手动在每个文件开头添加大量(且可能各不相同)的前向声明将变得极其繁琐。例如,若你有一个由5个文件组成的程序,每个文件需要10个前向声明,你将不得不复制粘贴50个前向声明。现在考虑这样一种情况:你有100个文件,每个文件需要100个前向声明。这显然无法扩展!

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


头文件

在C++程序中常见的文件不仅限于C++代码文件(扩展名为.cpp)。另一类文件称为头文件header file。头文件通常使用.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包含文件时,被包含文件的内容会被插入到包含点的位置。这为从其他文件引入声明提供了便捷方式。

试想如果iostream头文件不存在会怎样?每当使用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); // forward declaration using function prototype

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

(若您是从零开始重现此示例,请记得将 add.cpp 加入项目以便编译。)

在此示例中,我们使用了前向声明,以便编译器在编译 main.cpp 时能识别 add 标识符。如前所述,为每个位于其他文件中的函数手动添加前向声明会很快变得繁琐。

让我们编写一个头文件来减轻这种负担。头文件的编写其实非常简单,它仅包含两部分:

  1. 头文件保护机制(将在下一课2.12节详细讨论)。
  2. 头文件的实际内容,即需要让其他文件可见的所有标识符的前向声明。

向项目添加头文件的操作与添加源文件类似(详见第2.8课——多代码文件程序)。

若使用IDE,请按相同步骤操作,在选择类型时将“源文件”改为“头文件”。头文件将自动显示在项目结构中。

若使用命令行操作,只需在源文件(.cpp)所在目录中,用常用编辑器新建文件。与源文件不同,头文件无需添加到编译命令中(它们将通过#include语句隐式包含,并作为源文件的一部分进行编译)。

最佳实践
命名头文件时建议使用 .h 后缀(除非项目已有其他约定)。
这是C++头文件的长期惯例,多数集成开发环境仍默认采用 .h 后缀而非其他选项。

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

最佳实践
若头文件与代码文件配对使用(例如 add.h 与 add.cpp),两者应采用相同的基名(add)。

以下是我们完成的头文件:

add.h:

// We really should have a header guard here, but will omit it for simplicity (we'll cover header guards in the next lesson)

// This is the content of the .h file, which is where the declarations go
int add(int x, int y); // function prototype for add.h -- don't forget the semicolon!

要在 main.cpp 中使用此头文件,必须使用 #include 语法包含它(使用引号而非尖括号)。

main.cpp:

#include "add.h" // Insert contents of add.h at this point.  Note use of double quotes here.
#include <iostream>

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

add.cpp:

#include "add.h" // Insert contents of add.h at this point.  Note use of double quotes here.

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

image

当前处理器处理#include “add.h”行时,会将add.h的内容复制到当前文件的该位置。由于add.h包含函数add()的前向声明,该声明将被复制到main.cpp中。最终生成的程序功能上等同于我们在main.cpp开头手动添加前向声明的情况。

因此,我们的程序将能正确编译和链接。

image

注:在上图中,“标准运行时库”应标注为“C++标准库”。


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

目前,应避免在头文件中放置函数或变量定义。当头文件被多个源文件包含时,此类操作通常会导致违反单定义规则(ODR)。

相关内容
我们在第2.7课——前向声明与定义中探讨过单定义规则(ODR)。

让我们通过实例说明其发生机制:

add.h:

// We really should have a header guard here, but will omit it for simplicity (we'll cover header guards in the next lesson)

// definition for add() in header file -- don't do this!
int add(int x, int y)
{
    return x + y;
}

main.cpp:

#include "add.h" // Contents of add.h copied here
#include <iostream>

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

    return 0;
}

add.cpp:

#include "add.h" // Contents of add.h copied here

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

main.cpp(预处理后):

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

// contents of iostream header here

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(预处理后):

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

这段代码同样能顺利编译。

最后,链接器将开始运行。它会发现函数 add() 现存在两个定义:一个在 main.cpp 中,另一个在 add.cpp 中。这违反了 ODR 第二部分的规定,该条款指出:“在给定程序中,变量或普通函数只能有一个定义。”

最佳实践

(目前)请勿在头文件中定义函数和变量。

若在头文件中定义函数或变量,当该头文件被多个源文件(.cpp)包含时,很可能违反单一定义规则(ODR)。

作者注
后续课程中,我们将遇到可在头文件中安全定义的其他类型定义(因其不受ODR限制)。这包括内联函数、内联变量、类型及模板的定义。具体细节将在介绍相应概念时展开讨论。


源文件应包含其对应的头文件

在C++中,代码文件应包含其对应的头文件(若存在)是最佳实践。这能让编译器在编译时而非链接时捕获某些类型的错误。例如:

add.h:

// We really should have a header guard here, but will omit it for simplicity (we'll cover header guards in the next lesson)

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"         // copies forward declaration from add.h here

double add(int x, int y) // oops, return type is double instead of int
{
    return x + y;
}

(add.cpp 加入#include "add.h", 单独编译add.cpp文件时,发现了add函数的声明和定义返回类型不匹配的问题)
image

当编译 add.cpp 时,前向声明 int add(int x, int y) 会被复制到 #include 指令所在位置的 add.cpp 中。当编译器遇到定义 double add(int x, int y) 时,会发现前向声明与定义的返回类型不匹配。由于函数不能仅在返回类型上存在差异,编译器将立即报错并终止编译。在大型项目中,这种机制能节省大量时间并精准定位问题所在。

顺带一提……
遗憾的是,若形参类型与返回类型不同,此法便失效。这是因为C++支持函数重载(即名称相同但参数类型不同的函数),编译器会将参数类型不匹配的函数视为不同的重载版本。真是两难啊。

如果缺少 #include “add.h” 这行代码,编译器将无法发现问题,因为它无法检测到定义不匹配的情况。我们必须等到链接阶段问题才会显现。

在后续课程中,我们还将看到许多类似示例:源文件所需的内容定义在配对的头文件中。在这种情况下,包含该头文件是必需的。

最佳实践
源文件应包含其对应的头文件(若存在)。


请勿包含 .cpp 文件

尽管预处理器会欣然执行此操作,但通常不应包含 .cpp 文件。这些文件应添加到项目中并进行编译。

原因如下:

  • 此操作可能导致源文件间发生命名冲突。
  • 在大型项目中,难以避免单次定义规则(ODR)问题。
  • 此类.cpp文件的任何修改都会导致该文件及其所有包含它的.cpp文件重新编译,耗时较长。头文件的变更频率通常低于源文件。
  • 此做法不符合惯例规范。

最佳实践
避免包含 .cpp 文件。

提示
如果您的项目必须包含 .cpp 文件才能编译成功,这意味着这些 .cpp 文件并未作为项目的一部分进行编译。请将它们添加到项目或命令行中,确保它们被编译。


故障排除

若收到编译器错误提示未找到add.h文件,请确认文件名是否确实为add.h。根据创建和命名方式的不同,该文件可能被命名为add(无扩展名)、add.h.txt或add.hpp等形式。同时请确保该文件与其他代码文件位于同一目录下。

若出现关于函数add未定义的链接器错误,请确认项目中已包含add.cpp文件,以便函数add的定义能被正确链接到程序中。


尖括号与双引号的区别

你可能好奇为何我们用尖括号标记iostream,却用双引号标记add.h。这是因为同名头文件可能存在于多个目录中。通过使用尖括号与双引号的区别,我们能为预处理器提供线索,告知其应在何处查找头文件。

使用尖括号时,我们向预处理器表明这是非自编写的头文件。预处理器仅会在包含目录中指定的目录搜索头文件。包含目录配置属于项目/IDE设置/编译器设置的一部分,通常默认指向编译器和/或操作系统自带头文件所在的目录。预处理器不会在项目源代码目录中搜索头文件。

当使用双引号时,我们告知预处理器这是自编写的头文件。预处理器将首先在当前目录搜索头文件,若未找到匹配项,才会转而搜索包含目录。

规则
使用双引号包含您编写的头文件或预期位于当前目录中的头文件。使用尖括号包含随编译器、操作系统或您在系统其他位置安装的第三方库提供的头文件。


为什么iostream没有.h后缀?

另一个常见问题是:“为什么iostream(或其他标准库头文件)没有.h后缀?”答案在于iostream.h与iostream是不同的头文件!要解释这一点需要简述一段历史。

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

Header type Naming convention Example Identifiers placed in namespace
C++ specific <xxx.h> iostream.h Global namespace
C compatibility <xxx.h> stddef.h Global namespace

原始版本的 cout 和 cin 在 iostream.h 中被声明于全局命名空间。那时一切井然有序,美好而和谐。

当ANSI委员会对语言进行标准化时,他们决定将标准库中所有名称移入std命名空间,以避免与用户声明的标识符发生命名冲突。然而这带来了新问题:若将所有名称移入std命名空间,所有包含iostream.h的旧程序都将无法运行!

为解决此问题,C++引入了不带.h扩展名的新头文件。这些新头文件将所有名称声明在std命名空间内。如此一来,包含#include <iostream.h>的旧程序无需重写,而新程序可直接使用#include

现代C++现包含四组头文件:

Header type Naming convention Example Identifiers placed in namespace
C++ specific (new) <xxx> iostream std namespace
C compatibility (new) <cxxx> cstddef std namespace (required)
global namespace (optional)
C++ specific (old) <xxx.h> iostream.h Global namespace
C compatibility (old) <xxx.h> stddef.h Global namespace (required)
std namespace (optional)

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

最佳实践
使用标准库头文件时,请勿添加 .h 扩展名。用户自定义头文件仍应使用 .h 扩展名。


包含其他目录中的头文件

另一个常见问题涉及如何包含其他目录中的头文件。

一种(不推荐的)做法是在#include语句中使用相对路径包含目标头文件。例如:

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

虽然这种方法可以编译通过(假设文件存在于相应相对路径中),但其弊端在于需要在代码中反映目录结构。一旦更新目录结构,代码便会失效。

更优的方法是告知编译器或IDE:您在其他位置存放了一批头文件,当当前目录中找不到时,编译器会自动转至该位置搜索。通常可通过在IDE项目设置中配置包含路径或搜索目录来实现。

对于 Visual Studio 用户
在解决方案资源管理器中右键单击项目,选择“属性”,然后切换到“VC++ 目录”选项卡。在此处您将看到名为“包含目录”的行。在此处添加您希望编译器搜索额外头文件的目录。

对于Code::Blocks 用户
在 Code::Blocks 中,前往项目菜单选择构建选项,切换至搜索目录选项卡。在此处添加您希望编译器搜索的额外头文件目录。

对于 gcc/clang 用户
使用 g++ 时,可通过 -I 选项指定替代包含目录:
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> // required to use std::string_view

std::string_view getApplicationName(); // std::string_view used here

传递性包含

当你的源文件(.cpp)包含某个头文件时,该头文件所包含的其他头文件(以及这些头文件包含的头文件,以此类推)也会被自动包含进来。这些额外的头文件有时被称为传递性包含transitive includes,因为它们是隐式而非显式包含的。

这些传递性包含的内容可在你的代码文件中使用。但通常不应依赖传递包含的头文件内容(除非参考文档明确要求使用这些传递包含)。头文件的实现可能随时间变更,或在不同系统间存在差异。若发生此类情况,您的代码可能仅在特定系统上编译通过,或当前可编译但未来无法通过。通过显式包含代码文件所需的所有头文件,可轻松避免此问题。

最佳实践
每个文件都应显式包含其编译所需的所有头文件。切勿依赖其他头文件间接包含的内容。

遗憾的是,目前尚无简便方法检测代码文件是否意外依赖了由其他头文件包含的内容。

问:我未包含,程序却仍能运行!为什么?

这是本站最常见的问题之一。答案是:程序能运行很可能是因为你包含了其他头文件(如),而该文件本身包含了。尽管程序能编译通过,但根据上述最佳实践,你不应依赖这种情况。能在你机器上编译成功的代码,在他人机器上可能无法通过编译。


头文件包含顺序

若头文件编写规范且已包含所有必需内容,包含顺序通常无关紧要。

但请考虑以下情况:假设头文件A需要头文件B的声明,却忘记包含它。在代码文件中,若先包含头文件B再包含头文件A,代码仍能编译通过!这是因为编译器会先编译B文件中的所有声明,再处理依赖这些声明的A文件代码。

但若先包含A文件,编译器就会报错——因为A文件的代码将在编译器看到B文件声明前就被编译。这种情况反而更理想,因为错误已显现,我们可以及时修正。

最佳实践
为最大程度提高编译器识别遗漏包含文件的概率,请按以下顺序排列#include语句(跳过无关项):

  • 与当前代码文件配对的头文件(例如add.cpp应包含add.h)
  • 同一项目中的其他头文件(例如包含mymath.h)
  • 第三方库头文件(例如 #include <boost/tuple/tuple.hpp>)
  • 标准库头文件(例如 #include

各组头文件应按字母顺序排序(除非第三方库文档另有说明)。

这样一来,如果某个用户自定义头文件缺少第三方库或标准库头文件的#include声明,就更容易引发编译错误,从而便于您及时修复。


头文件最佳实践

以下是创建和使用头文件的若干建议:

  • 始终包含头文件保护机制(下节课将详细讲解)。
  • (目前)避免在头文件中定义变量和函数。
  • 头文件名称应与关联的源文件保持一致(例如 grades.h 对应 grades.cpp)。
  • 每个头文件应承担特定职责,并尽可能保持独立性。例如,可将功能A相关的声明集中在A.h中,功能B相关的声明集中在B.h中。这样后续仅需关注功能A时,只需包含A.h即可避免引入B的相关内容。
  • 请注意在代码文件中使用功能时需明确包含哪些头文件,避免意外的传递包含。
  • 头文件应包含其所需功能的其他头文件。当单独包含于.cpp文件时,该头文件应能成功编译。
  • 仅包含必需文件(切勿因技术可行而全盘包含)。
  • 禁止包含.cpp文件。
  • 建议在头文件中说明功能作用及使用方式,此处更易被开发者关注。实现原理说明应保留在源文件中。
posted @ 2026-02-10 04:20  游翔  阅读(1)  评论(0)    收藏  举报