演练:在 Microsoft Visual C++ 中生成和导入标头单元

本文介绍如何使用 Visual Studio 2022 生成和导入标头单元。 若要了解如何将 C++ 标准库标头作为标头单元导入,请参阅演练:将 STL 库作为标头单元导入。 如需更快、更可靠地导入标准库的方法,请参阅教程:使用模块导入 C++ 标准库

标头单元是预编译头文件 (PCH) 的推荐替代方法。 标头单元更易于设置和使用,在磁盘上明显较小,具有类似的性能优势,并且比共享 PCH 更灵活。

若要将标头单元与在程序中包含功能的其他方法进行比较,请参阅比较标头单元、模块和预编译标头

先决条件

若要使用标头单元,需要使用 Visual Studio 2019 16.10 或更高版本。

什么是标头单元

标头单元是头文件的二进制表示形式。 标头单元以 .ifc 扩展名结尾。 相同的格式也用于命名模块。

标头单元和头文件之间的一个重要区别是,标头单元不受标头单元之外的宏定义的影响。 也就是说,不能定义使标头单元的行为有所不同的预处理器符号。 当你导入标头单元时,就已经编译了标头单元。 这与处理 #include 文件的方式不同。 包含的文件可能会受到头文件外部宏定义的影响,因为当你编译包含它的源文件时,头文件会经过预处理器。

标头单元可以按任何顺序导入,但头文件则不然。 头文件顺序很重要,因为一个头文件中定义的宏定义可能会影响后续头文件。 一个标头单元中的宏定义不会影响另一个标头单元。

头文件中可见的所有内容在标头单元中也都可见,其中包括标头单元内定义的宏。

必须将头文件转换为标头单元,才能导入该头文件。 标头单元相对于预编译标头文件 (PCH) 的一个优点是它可以在分布式生成中使用。 只要你使用相同的编译器来编译 .ifc 和导入它的程序,并以相同的平台和体系结构为目标,那么在一台计算机上生成的标头单元可用于另一台计算机。 与 PCH 不同,当标头单元发生更改时,只会重新生成它本身及其依赖项。 标头单元的大小最多可以比 .pch 小一个数量级。

与 PCH 相比,标头单元对用于创建标头单元和编译使用它的代码的编译器开关组合所需的相似性施加的约束更少。 但是,某些开关组合和宏定义可能会违反各种翻译单元之间的单一定义规则 (ODR)。

最后,标头单元比 PCH 更灵活。 使用 PCH 时,不能选择只引入 PCH 中的一个标头,编译器会处理所有这些标头。 使用标头单元时,即使将它们一起编译到一个静态库中,也只会引入你导入到应用程序中的标头单元的内容。

标头单元是头文件和 C++20 模块之间的一个步骤。 它们提供了模块的一些优势。 它们更可靠,因为外部宏定义不会影响它们,因此你可以按任意顺序导入它们。 编译器可以比头文件更快地处理它们。 但是标头单元并不具有模块的所有优点,因为标头单元公开了其中定义的宏(模块则没有)。 与模块不同,无法在标头单元中隐藏专用实现。 为了表示头文件的专用实现,我们采用了不同的技术,例如向名称添加前导下划线,或将内容放入实现命名空间中。 模块不会以任何形式公开专用实现,因此你无需这样做。

可以考虑将预编译标头替换为标头单元。 你可获得相同的速度优势,并且还有其他代码卫生和灵活性优势。

编译标头单元的方法

可以通过多种方法将文件编译为标头单元:

  • 生成共享标头单元项目。 推荐使用此方法,因为它可以更好地控制导入的标头单元的组织和重复使用。 创建一个静态库项目,其中包含所需的标头单元,然后引用该项目以导入标头单元。 有关此方法的演练,请参阅为标头单元生成标头单元静态库项目

  • 选择要转换为标头单元的各个文件。 使用此方法可以逐个文件控制将哪些文件视为标头单元。 如果必须将文件编译为标头单元,这也很有用,因为它没有默认扩展名(.ixx.cppm.h.hpp),通常不会被编译为标头单元。 本演练中介绍了这种方法。 要开始使用,请参阅方法 1:将特定文件转换为标头单元

  • 自动扫描和生成标头单元。 此方法很方便,但最适用于较小的项目,因为它不能保证最佳的生成吞吐量。 有关此方法的详细信息,请参阅方法 2:自动扫描标头单元

  • 如简介中所述,可以将 STL 头文件作为标头单元生成并导入,并自动将 STL 库标头的 #include 视为 import,而无需重新编写代码。 若要了解如何操作,请访问演练:将 STL 库作为标头单元导入

方法 1:将特定文件转换为标头单元

本部分介绍如何选择要转换为标头单元的具体文件。 在 Visual Studio 中使用以下步骤将头文件编译为标头单元:

  1. 新建 C++ 控制台应用项目。

  2. 按如下所示替换源文件内容:

    #include "Pythagorean.h"
    
    int main()
    {
        PrintPythagoreanTriple(2,3);
        return 0;
    }
    
  3. 添加一个名为 Pythagorean.h 的头文件,然后将其内容替换为以下代码:

    #ifndef PYTHAGOREAN
    #define PYTHAGOREAN
    
    #include <iostream>
    
    inline void PrintPythagoreanTriple(int a, int b)
    {
        std::cout << "Pythagorean triple a:" << a << " b:" << b << " c:" << a*a + b*b << std::endl;
    }
    #endif
    

设置项目属性

若要启用标头单元,请执行以下步骤,首先将“C++ 语言标准”设置为 /std:c++20 或更高版本:

  1. 在“解决方案资源管理器”中,右键单击项目名称,然后选择“属性”
  2. 在项目属性页窗口的左侧窗格中,选择“配置属性”>“常规”
  3. 在“C++ 语言标准”下拉列表中,选择“ISO C++20 Standard (/std:c++20)”或更高级别。 选择“确定”以关闭对话框。

将头文件编译为标头单元:

  1. 在“解决方案资源管理器”中,选择要编译为标头单元的文件(在本例中为 Pythagorean.h)。 右键单击该文件,然后选择“属性”

  2. 在“配置属性”>“常规”>“项类型”下拉列表中设置“C/C++ 编译器”,然后选择“确定”

    显示将“项目类型”更改为“C/C++ 编译器”的屏幕截图。

稍后在本演练中生成此项目时,Pythagorean.h 将被转换为标头单元。 它被转换为标头单元,这是因为此头文件的项类型已设置为“C/C++ 编译器”,并且按这种方式设置的 .h.hpp 文件的默认操作是将文件转换为标头单元。

注意

这不是本演练所必需的,但可以供你参考。 若要将文件编译为没有默认标头单元文件扩展名的标头单元(例如 .cpp),请在“配置属性”>“C/C++”>“高级”>“编译为”中设置“编译为 C++ 标头单元(/exportHeader)”显示更改配置属性 > C/C++ > 高级 > 编译为编译为C++标头单元(/exportHeader)的屏幕截图。

更改代码以导入标头单元

  1. 在示例项目的源文件中,将 #include "Pythagorean.h" 更改为 import "Pythagorean.h";(不要忘记结尾分号)。 它是 import 语句所必需的。 由于它是项目本地目录中的头文件,因此我们在 import 语句中使用引号:import "file";。 在你自己的项目中,如果要从系统标头编译标头单元,请使用尖括号:import <file>;

  2. 通过在主菜单上选择“生成”>“生成解决方案”来生成解决方案。 运行它后,可以看到它生成了预期的输出:Pythagorean triple a:2 b:3 c:13

在你自己的项目中,重复此过程以编译要作为标头单元导入的头文件。

如果你只想将一些头文件转换为标头单元,这是一种很好的方法。 但是,如果要编译的头文件很多,并且构建性能的潜在损失超过了构建系统自动处理它们的便利性,请参阅以下部分。

如果你有兴趣专门将 STL 库标头作为标头单元导入,请参阅演练:将 STL 库作为标头单元导入

方法 2:自动扫描和生成标头单元

由于扫描所有源文件中的标头单元以及构建它们需要时间,因此以下方法最适合较小的项目。 它不能保证最佳的构建吞吐量。

此方法合并了两个 Visual Studio 项目设置:

  • “扫描源以查找模块依赖项”会导致生成系统调用编译器,以确保在编译依赖它们的文件之前生成所有导入的模块和标头单元。 在与“将包含转换为导入”结合使用时,源中包含的任何头文件(也在与头文件位于相同目录中的 header-units.json 文件中指定)被编译为标头单元。
  • 如果 #include 引用可编译为标头单元的头文件(如 header-units.json 文件中所指定的),并且已编译的标头单元可用于头文件,“将包含转换为导入”会将头文件视为 import。 否则,头文件被视为普通 #includeheader-units.json 文件用于自动为每个 #include 生成符号不重复的标头单元。

可以在项目的属性中启用这些设置。 为此,请在“解决方案资源管理器”中右键单击项目,然后选择“属性”。 然后选择“配置属性”>“C/C++”>“常规”

显示项目属性屏幕的屏幕截图,其中突出显示了“配置”并选择了“所有配置”。在 C/C++ > “常规”下,突出显示模块依赖项的扫描源并设置为“是”,突出显示“转换为导入”,并设置为“是”(/translateInclude)

可以为“项目属性”的项目中的所有文件设置“扫描源以查找模块依赖项”,如图所示,也可以为“文件属性”中的个别文件设置。 始终会扫描模块和标头单元。 当你有一个 .cpp 文件导入了你希望自动生成但可能还没有生成的标头单元时,可以设置此选项。

这些设置共同发挥作用,在以下条件下自动生成和导入标头单元:

  • “扫描源以查找模块依赖项”,这会扫描源以查找可被视为标头单元的文件及其依赖项。 无论此设置如何,都会始终扫描扩展名为 .ixx 的文件,以及将“文件属性”>“C/C++”>“编译为”属性设置为“编译为 C++ 标头单元(/export)”的文件。 编译器还会查找 import 语句以标识标头单元依赖项。 如果已指定 /translateInclude,编译器还会扫描同时在 header-units.json 文件中指定的 #include 指令,以将其视为标头单元。 依赖项图由项目中的所有模块和标头单元生成。
  • “将包含转换为导入”,当编译器遇到 #include 语句,并且指定的头文件存在匹配的标头单元文件 (.ifc) 时,编译器会导入标头单元,而不是将头文件视为 #include。 与“扫描依赖项”结合使用时,编译器会查找可编译为标头单元的所有头文件。 编译器会参考允许列表,以确定哪些头文件可以被编译为标头单元。 此列表存储在 header-units.json 文件中,此文件必须与包含的文件位于同一目录中。 可以在 Visual Studio 的安装目录下看到 header-units.json 文件的示例。 例如,编译器使用 %ProgramFiles%\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.30.30705\include\header-units.json 来确定是否可以将标准模板库标头编译为标头单元。 此功能作为与旧代码之间的桥梁,以获得标头单元的一些优势。

header-units.json 文件有两种用途。 除了指定哪些头文件可以被编译为标头单元之外,还可以最大程度地减少重复的符号以提高生成吞吐量。 有关符号重复的详细信息,请参阅 C++ header-units.json 参考

这些开关和 header-unit.json 提供了标头单元的一些优势。 获得便利的代价是产生了吞吐量。 对于较大的项目,此方法可能不是最佳的方法,因为它不能保证最佳的生成时间。 此外,相同的头文件可能会重复重新处理,这会增加生成时间。 但是,根据项目的情况,这种便利可能是值得的。

这些功能专为旧代码而设计。 对于新代码,请移至模块,而不是标头单元或 #include 文件。 有关使用模块的教程,请参阅名称模块教程 (C++)

有关如何使用此方法将 STL 头文件作为标头单元导入的示例,请参阅演练:将 STL 库作为标头单元导入

预处理器的影响

创建和使用标头单元需要符合标准 C99/C++11 的预处理器。 编译器在编译标头单元时,无论使用哪种形式的 /exportHeader,都会在命令行中隐式添加 /Zc:preprocessor,从而启用新的符合 C99/C++11 的预处理器。 尝试将其禁用将导致编译错误。

启用新的预处理器会影响可变参数宏的处理。 有关详细信息,请参阅可变参数宏的备注部分。

另请参阅

/translateInclude
/exportHeader
/headerUnit
header-units.json
比较标头单元、模块和预编译标头
C++ 中的模块概述
教程:使用模块导入 C++ 标准库
演练:导入 STL 库作为标头单位