通过


命名模块教程 (C++)

本教程介绍如何创建 C++20 模块。 模块替换头文件。 你将了解模块是如何改进头文件的。

在本教程中,您将学习如何:

  • 创建和导入模块
  • 创建主模块接口单元
  • 创建模块分区文件
  • 创建模块单元实现文件

先决条件

本教程需要 Visual Studio 2022 17.1.0 或更高版本。

当你生成本教程中的代码示例时,可能会遇到 IntelliSense 错误。 IntelliSense 引擎的工作进度即将赶上编译器的进度。 IntelliSense 错误可被忽略,不会阻止生成代码示例。 若要跟踪 IntelliSense 工作的进度,请查看此问题

什么是 C++ 模块

头文件是 C++ 中源文件之间共享声明和定义的方式。 头文件很脆弱,难以撰写。 它们的编译方式可能不同,这取决于你包含它们的顺序,或者取决于是否有定义的宏。 它们可能会减缓编译进度,因为它们要对包含它们的每个源文件进行重新处理。

C++20 引入了用于组件化 C++ 程序的一种新式方法:模块

与头文件一样,模块允许跨源文件共享声明和定义。 但与头文件不同的是,模块不会泄露宏定义或专用实现详细信息。

模块更易于撰写,因为它们的语义不会因为宏定义或其他已导入的内容、导入顺序等而改变。 它们还可以更轻松地控制使用者可见的内容。

模块提供了头文件所不具备的额外安全保证。 编译器和链接器协同工作,以防止可能出现的名称冲突问题,并提供更强大的单一定义规则 (ODR) 保证。

强所有权模型可避免在链接时名称之间发生冲突,因为链接器会将导出的名称附加到导出它们的模块。 这种模式使 Microsoft Visual C++ 编译器能够阻止通过链接报告同一程序中类似名称的不同模块而导致的未定义行为。 有关详细信息,请参阅强所有权

模块由编译为二进制文件的一个或多个源代码文件组成。 二进制文件描述了模块中的所有导出类型、函数和模板。 当源文件导入模块时,编译器会读入包含模块内容的二进制文件。 读取二进制文件比处理头文件快得多。 此外,每次导入模块时,编译器都会重复使用二进制文件,从而节省更多时间。 由于一个模块只生成一次,而不是每次导入时都会生成,因此生成时间可以减少,有时会大幅减少。

更重要的是,模块没有头文件存在的脆弱问题。 导入模块不会更改模块的语义,也不会更改任何其他导入的模块的语义。 在模块中声明的宏、预处理器指令和非导出名称对导入它的源文件是不可见的。 可以按任意顺序导入模块,并且不会更改模块的含义。

模块可以与头文件并行使用。 如果要迁移代码库以使用模块,此功能十分方便,因为可以分阶段执行此操作。

在某些情况下,可以将头文件作为标头单元,而不是 #include 文件导入。 标头单元是预编译头文件 (PCH) 的推荐替代方法。 与共享 PCH 文件相比,它们更易于设置和使用,但它们提供类似的性能优势。 有关详细信息,请参阅演练:在 Microsoft Visual C++ 中生成和导入标头单元

代码可以使用同一项目中的模块或任何引用的项目,自动使用对静态库项目的项目到项目引用。

创建项目

构建简单的项目时,我们将了解模块的各个方面。 项目将使用模块而不是头文件实现 API。

在 Visual Studio 2022 或更高版本中,选择“创建新项目”,然后选择“控制台应用”(适用于 C++)项目类型。 如果此项目类型不可用,你可能在安装 Visual Studio 时没有选择“使用 C++ 的桌面开发”工作负载。 可以使用 Visual Studio 安装程序来添加 C++ 工作负载。

为新项目指定名称 ModulesTutorial 并创建项目。

由于模块是一项 C++20 功能,因此请使用 /std:c++20/std:c++latest 编译器选项。 在解决方案资源管理器中,右键单击项目名称 ModulesTutorial,然后选择“属性”。 在项目的“属性页”对话框中,将“配置”更改为“所有配置”,将“平台”更改为“所有平台”。 在左侧的树状视图窗格中,选择“配置属性”>“常规”。 选择“C++ 语言标准”属性。 使用下拉菜单将属性值更改为“ISO C++20 标准(/std:c++20)”。 选择“确定”以接受更改。

A screenshot of the ModulesTutorial property page with the left pane open to Configuration Properties > General, and the C++ Language Standard dropdown open with ISO C++20 Standard (/std:c++20) selected

创建主模块接口单元

模块由一个或多个文件组成。 其中一个文件必须是“主模块接口单元”。 它定义模块导出的内容;也就是说,模块的导入程序将看到什么。 每个模块只能有一个主模块接口单元。

若要添加主模块接口单元,请在“解决方案资源管理器”中,右键单击“源文件”,然后选择“添加”>“模块”。

Add item dialog in solution explorer with Add > Module... highlighted to illustrate where to click to add a module.

在显示的“添加新项”对话框中,为新模块指定名称 BasicPlane.Figures.ixx,然后选择“添加”

创建的模块文件的默认内容有两行:

export module BasicPlane;

export void MyFunc();

第一行中的 export module 关键字声明此文件是模块接口单元。 此处有一个微妙的点:对于每个命名模块,必须正好有一个未指定模块分区的模块接口单元。 该模块单元称为“主模块接口单元”

主模块接口单元用于声明在源文件导入模块时要公开的函数、类型、模板、其他模块和模块分区。 模块可以包含多个文件,但只有主模块接口文件标识要公开的内容。

BasicPlane.Figures.ixx 文件的内容替换为:

export module BasicPlane.Figures; // the export module keywords mark this file as a primary module interface unit

此行将此文件标识为主模块接口,并为模块指定一个名称:BasicPlane.Figures。 模块名称中的句点对编译器没有特殊含义。 句点可用于传达模块的组织方式。 如果你有多个模块文件一起工作,可以使用句点来指示分离关注点。 在本教程中,我们将使用句点来指示 API 的不同功能区域。

此名称也是“命名模块”中的“命名”的由来。 属于此模块一部分的文件使用此名称将自己标识为命名模块的一部分。 命名模块是具有相同模块名称的模块单元的集合。

在进一步讨论之前,我们应该先谈谈我们要实现的 API。 它会影响我们接下来的选择。 API 表示不同的形状。 在本示例中,我们只打算提供几个形状:PointRectanglePoint 用作更复杂的形状的一部分,例如 Rectangle

为了说明模块的一些功能,我们将此 API 分成几个部分。 一个部分是 Point API。 另一个部分是 Rectangle。 假设此 API 将变得更为复杂。 这种划分对于分离关注点或简化代码维护很有用。

到目前为止,我们创建了将公开此 API 的主模块接口。 现在,我们来生成 Point API。 我们希望它成为此模块的一部分。 出于逻辑组织和潜在的生成效率的原因,我们希望使这一部分的 API 容易被理解。 为此,我们将创建“模块分区”文件。

模块分区文件是模块的一个部分,或者说是组件。 它的独特之处在于,它可以被视为模块的一个单独的部分,但只能在模块内。 模块分区不能在模块之外使用。 模块分区可用于将模块实现划分为可管理部分。

当你将分区导入到主模块中时,无论其所有声明是否导出,都会对主模块可见。 可以将分区导入到属于命名模块的任何分区接口、主模块接口或模块单元中。

创建模块分区文件

Point 模块分区

要创建模块分区文件,请在“解决方案资源管理器”中右键单击“源文件”,然后选择“添加”>“模块”。 将文件命名为 BasicPlane.Figures-Point.ixx,然后选择“添加”

由于它是一个模块分区文件,因此我们已将一个连字符和分区的名称添加到模块名称中。 此约定有助于编译器在命令行中的使用,因为编译器使用基于模块名称的名称查找规则来查找分区的已编译 .ifc 文件。 这样,你不需要提供显式 /reference 命令行参数即可查找属于模块的分区。 它还有助于按名称组织属于模块的文件,因为可以轻松查看哪些文件属于哪些模块。

BasicPlane.Figures-Point.ixx 的内容替换为:

export module BasicPlane.Figures:Point; // defines a module partition, Point, that's part of the module BasicPlane.Figures

export struct Point
{
    int x, y;
};

文件以 export module 开头。 这些关键字也是主模块接口的开始方式。 使此文件有所不同的是模块名称后面的冒号 (:),后跟分区名称。 此命名约定将文件标识为“模块分区”。 它定义了分区的模块接口,因此它不被视为主模块接口。

名称 BasicPlane.Figures:Point 将此分区标识为模块 BasicPlane.Figures 的一部分。 (请记住,名称中的句点对编译器没有特殊意义)。 冒号指示此文件包含名为 Point 的模块分区,该分区属于模块 BasicPlane.Figures。 我们可以将此分区导入到属于此命名模块的其他文件中。

在此文件中,export 关键字使 struct Point 对使用者可见。

Rectangle 模块分区

我们将定义的下一个分区是 Rectangle。 使用与之前相同的步骤创建另一个模块文件:在“解决方案资源管理器”中,右键单击“源文件”,然后选择“添加”>“模块”。 将文件命名为“BasicPlane.Figures-Rectangle.ixx”,并选择“添加”。

BasicPlane.Figures-Rectangle.ixx 的内容替换为:

export module BasicPlane.Figures:Rectangle; // defines the module partition Rectangle

import :Point;

export struct Rectangle // make this struct visible to importers
{
    Point ul, lr;
};

// These functions are declared, but will
// be defined in a module implementation file
export int area(const Rectangle& r);
export int height(const Rectangle& r);
export int width(const Rectangle& r);

该文件以 export module BasicPlane.Figures:Rectangle; 开头,其声明了属于模块 BasicPlane.Figures 的一个模块分区。 添加到模块名称的 :Rectangle 将其定义为模块 BasicPlane.Figures 的分区。 它可以被单独导入到属于此命名模块的任何模块文件中。

接下来,import :Point; 演示了如何导入模块分区。 import 语句使模块分区中的所有导出类型、函数和模板对模块可见。 你不需要指定模块名称。 编译器知道此文件属于 BasicPlane.Figures 模块,因为 export module BasicPlane.Figures:Rectangle; 位于文件的顶部。

接下来,代码导出 struct Rectangle 的定义以及一些返回矩形的各种属性的函数的声明。 export 关键字指示是否使它前面的内容对模块的使用者可见。 它用于使函数 areaheightwidth 在模块之外可见。

模块分区中的所有定义和声明都对导入模块单元可见,无论它们是否具有 export 关键字。 export 关键字控制在主模块接口中导出分区时,定义、声明或 typedef 是否在模块之外可见。

名称通过几种方式对模块使用者可见:

  • 将关键字 export 放在要导出的每个类型、函数等的前面。
  • 如果将 export 放在命名空间的前面(例如 export namespace N { ... }),则会导出大括号内定义的所有内容。 但是,如果在模块中的其他位置定义 namespace N { struct S {...};},则 struct S 不可用于模块的使用者。 它之所以不可用,是因为命名空间声明不在 export 的前面,尽管有另一个同名的命名空间在前面。
  • 如果不应导出类型、函数等,则省略 export 关键字。 它对属于模块的其他文件可见,但对模块的导入程序不可见。
  • 使用 module :private; 标记专用模块分区的开头。 专用模块分区是模块的一部分,其中声明仅对该文件可见。 它们对导入此模块的文件或属于此模块的其他文件不可见。 可以将它视为文件本地的静态部分。 此部分仅在文件中可见。
  • 若要使导入的模块或模块分区可见,请使用 export import。 下一部分提供了一个示例。

撰写模块分区

现在我们定义了 API 的两个部分,让我们将它们组合在一起,以便导入此模块的文件可以作为一个整体访问它们。

所有模块分区都必须作为它们所属的模块定义的一部分公开。 分区是在主模块接口中公开的。 打开 BasicPlane.Figures.ixx 文件,该文件定义了主模块接口。 将其内容替换为:

export module BasicPlane.Figures; // keywords export module marks this as a primary module interface unit

export import :Point; // bring in the Point partition, and export it to consumers of this module
export import :Rectangle; // bring in the Rectangle partition, and export it to consumers of this module

export import 开头的两行在这里是新的。 合并后,这两个关键字指示编译器导入指定的模块,并使它对本模块的使用者可见。 在本例中,模块名称中的冒号 (:) 指示我们正在导入模块分区。

导入的名称不包括完整的模块名称。 例如,:Point 分区被声明为 export module BasicPlane.Figures:Point。 然而,我们在此处导入的是 :Point。 由于我们在模块 BasicPlane.Figures 的主模块接口文件中,模块名称是隐含的,并且只指定了分区名称。

到目前为止,我们定义了主模块接口,它公开了我们想要提供的 API 图面。 但我们仅声明了 area()height()width(),没有定义它们。 接下来,我们将通过创建模块实现文件来执行此操作。

创建模块单元实现文件

模块单元实现文件不会以 .ixx 扩展名结尾,它们是正常的 .cpp 文件。 通过在“解决方案资源管理器”中右键单击“源文件”,选择“添加”>“新建项”,然后选择“C++ 文件(.cpp)”来创建源文件,从而添加一个模块单元实现文件。 为新文件指定名称 BasicPlane.Figures-Rectangle.cpp,然后选择“添加”

模块分区实现文件的命名约定遵循分区的命名约定。 但它具有 .cpp 扩展名,因为它是一个实现文件。

BasicPlane.Figures-Rectangle.cpp 文件的内容替换为:

module;

// global module fragment area. Put #include directives here 

module BasicPlane.Figures:Rectangle;

int area(const Rectangle& r) { return width(r) * height(r); }
int height(const Rectangle& r) { return r.ul.y - r.lr.y; }
int width(const Rectangle& r) { return r.lr.x - r.ul.x; }

此文件以 module; 开头,它引入了称为“全局模块片段”的模块特殊区域。 它位于命名模块的代码的前面,你可以在其中使用预处理器指令,例如 #include。 全局模块片段中的代码不是模块接口拥有或导出的。

当你包含一个头文件时,通常不希望将其视为模块的一个导出部分。 你通常将头文件作为一个不应属于模块接口的实现详细信息包含在内。 可能有一些高级的案例需要这样做,但一般来说你不会这样做。 不会为全局模块片段中的 .ifc 指令生成单独的元数据(#include 文件)。 全局模块片段提供了一个很好的位置来包含头文件,如 windows.h 或在 Linux 上为 unistd.h

我们生成的模块实现文件不包含任何库,因为它不需要它们作为其实现的一部分。 但是,如果这样做,此区域就是将执行 #include 指令的位置。

module BasicPlane.Figures:Rectangle; 指示此文件是命名模块 BasicPlane.Figures 的一部分。 编译器会自动将主模块接口公开的类型和函数引入此文件中。 模块实现单元在其模块声明中的 module 关键字之前没有 export 关键字。

接下来是函数 area()height()width() 的定义。 它们已在 BasicPlane.Figures-Rectangle.ixxRectangle 分区中声明。 由于此模块的主模块接口导入了 PointRectangle 模块分区,因此这些类型在模块单元实现文件中可见。 模块实现单元有一个有趣的功能:编译器会自动使相应模块主接口中的所有内容对文件可见。 不需要 imports <module-name>

你在实现单元中声明的任何内容都只对它所属的模块可见。

导入模块

现在,我们将使用已定义的模块。 打开 ModulesTutorial.cpp 文件。 它是作为项目的一部分自动创建的。 它目前包含函数 main()。 将其内容替换为:

#include <iostream>

import BasicPlane.Figures;

int main()
{
    Rectangle r{ {1,8}, {11,3} };

    std::cout << "area: " << area(r) << '\n';
    std::cout << "width: " << width(r) << '\n';

    return 0;
}

语句 import BasicPlane.Figures; 使 BasicPlane.Figures 模块的所有导出函数和类型都对此文件可见。 它可以在任何 #include 指令之前或之后出现。

然后,应用使用模块中的类型和函数输出定义的矩形的区域和宽度:

area: 50
width: 10

模块的解析

现在,让我们更详细地了解各种模块文件。

主模块接口

模块由一个或多个文件组成。 其中一个文件定义了导入程序将看到的接口。 此文件包含“主模块接口”。 每个模块只能有一个主模块接口。 如前所述,导出的模块接口单元未指定模块分区。

默认情况下,它具有 .ixx 扩展名。 但是,你可以将具有任何扩展名的源文件视为模块接口文件。 为此,请将源文件的属性页的“高级”选项卡中的“编译为”属性设置为“编译为模块(/interface)”:

Screenshot of a hypothetical source file's Configuration properties under Configuration properties > C/C++ > Advanced > Compile As, with Compile as C++ Module Code (/interface) highlighted

模块接口定义文件的基本大纲如下:

module; // optional. Defines the beginning of the global module fragment

// #include directives go here but only apply to this file and
// aren't shared with other module implementation files.
// Macro definitions aren't visible outside this file, or to importers.
// import statements aren't allowed here. They go in the module preamble, below.

export module [module-name]; // Required. Marks the beginning of the module preamble

// import statements go here. They're available to all files that belong to the named module
// Put #includes in the global module fragment, above

// After any import statements, the module purview begins here
// Put exported functions, types, and templates here

module :private; // optional. The start of the private module partition.

// Everything after this point is visible only within this file, and isn't 
// visible to any of the other files that belong to the named module.

此文件必须以 module; 开头来指示全局模块片段的开头,或以 export module [module-name]; 开头来指示“模块 purview”的开头。

模块 purview 是你希望从模块公开函数、类型、模板等的位置。

它也是可以通过 export import 关键字公开其他模块或模块分区的位置,如 BasicPlane.Figures.ixx 文件中所示。

主接口文件必须导出为模块定义的所有接口分区(直接或间接),否则程序就不规范。

在专用模块分区中,可以放置你希望仅在此文件中可见的内容。

模块接口单元在关键字 module 前面加上关键字 export

有关模块语法的更深入研究,请参阅模块

模块实现单元

模块实现单元属于命名模块。 它们所属的命名模块由文件中的 module [module-name] 语句指示。 模块实现单元提供了实现详细信息,出于代码安全或其他原因,你不需要放入主模块接口或模块分区文件中。

模块实现单元可用于将大型模块分解为较小的部分,这可能会导致生成时间更快。 最佳做法部分简要介绍了此方法。

模块实现单元文件具有 .cpp 扩展名。 模块实现单元文件的基本大纲如下:

// optional #include or import statements. These only apply to this file
// imports in the associated module's interface are automatically available to this file

module [module-name]; // required. Identifies which named module this implementation unit belongs to

// implementation

模块分区文件

模块分区提供了将模块组件化为不同部分或分区的方法。 模块分区只应在属于命名模块的文件中导入。 无法在命名模块之外导入它们。

分区具有一个接口文件,以及零个或多个实现文件。 模块分区共享整个模块中所有声明的所有权。

分区接口文件导出的所有名称都必须由主接口文件导入并重新导出 (export import)。 分区的名称必须以模块名称开头,后跟一个冒号,然后是分区的名称。

分区接口文件的基本大纲如下:

module; // optional. Defines the beginning of the global module fragment

// This is where #include directives go. They only apply to this file and aren't shared
// with other module implementation files.
// Macro definitions aren't visible outside of this file or to importers
// import statements aren't allowed here. They go in the module preamble, below

export module [Module-name]:[Partition name]; // Required. Marks the beginning of the module preamble

// import statements go here. 
// To access declarations in another partition, import the partition. Only use the partition name, not the module name.
// For example, import :Point;
// #include directives don't go here. The recommended place is in the global module fragment, above

// export imports statements go here

// after import, export import statements, the module purview begins
// put exported functions, types, and templates for the partition here

module :private; // optional. Everything after this point is visible only within this file, and isn't 
                         // visible to any of the other files that belong to the named module.
...

模块最佳做法

必须使用相同的编译器选项编译导入它的模块和代码。

模块命名

  • 可以在模块名称中使用句点(“.”),但它们对编译器没有特殊意义。 使用它们向模块的用户传达意义。 例如,以库或项目的顶级命名空间开始。 以描述模块功能的名称结束。 BasicPlane.Figures 旨在传达一个关于几何平面的 API,特别是可在平面上表示的图形。
  • 包含模块主接口的文件的名称通常是模块的名称。 例如,如果模块名称为 BasicPlane.Figures,则包含主接口的文件的名称将为 BasicPlane.Figures.ixx
  • 模块分区文件的名称通常是 <primary-module-name>-<module-partition-name>,其中,模块的名称后跟一个连字符(“-”),然后是分区的名称。 例如: BasicPlane.Figures-Rectangle.ixx

如果要从命令行生成,并且对模块分区使用此命名约定,则无需为每个模块分区文件显式添加 /reference。 编译器将根据模块的名称自动查找它们。 编译的分区文件的名称(以 .ifc 扩展名结尾)是从模块名称生成的。 请考虑模块名称 BasicPlane.Figures:Rectangle:编译器将预计 Rectangle 的相应编译分区文件被命名为 BasicPlane.Figures-Rectangle.ifc。 编译器使用此命名方案,通过自动查找分区的接口单元文件来更轻松使用模块分区。

可以使用自己的约定来为它们命名。 但是,需要为命令行编译器指定相应的 /reference 参数。

因子模块

使用模块实现文件和分区来分解模块,以便更轻松地维护代码,并可能更快地进行编译。

例如,将模块的实现从模块接口定义文件移出,并移入模块实现文件,这意味着对实现的更改不一定会导致导入模块的每个文件重新编译(除非有 inline 实现)。

通过模块分区,可以更轻松地在逻辑上对大型模块进行分解。 它们可用于缩短编译时间,以便对实现的一部分所做的更改不会导致重新编译所有模块的文件。

总结

本教程介绍了 C++20 模块的基础知识。 你已经创建了主模块接口,定义了模块分区,并生成了模块实现文件。

另请参阅

C++ 中的模块概述
moduleimportexport 关键字
Visual Studio 中的 C++ 模块简介
实用的 C++20 模块和围绕 C++ 模块的工具的未来
将项目移动到 C++ 命名模块
演练:在 Microsoft Visual C++ 中生成和导入标头单元