C++ 中的模块概述

C++20 引入了模块模块是一组源代码文件,这些文件独立于源文件(或者更确切地说,导入它们的转换单元 进行编译。

模块可消除或减少与使用头文件相关的许多问题。 它们通常会减少编译时间,有时甚至会显著减少。 在模块中声明的宏、预处理器指令和非导出名称在模块外部是不可见的。 它们不会影响导入模块的转换单元的编译。 你可以按任何顺序导入模块,而无需考虑宏重新定义。 导入转换单元中的声明不参与导入模块中的重载解析或名称查找。 编译一次模块后,结果将存储在描述所有导出的类型、函数和模板的二进制文件中。 编译器可以比头文件更快地处理该文件。 而且,编译器可以在项目中导入模块的每个位置重复使用该文件。

可以将模块与头文件并行使用。 C++ 源文件可以 import 模块,并同时 #include 头文件。 在一些情况下,可以将头文件导入为模块,这比通过预处理器使用#include处理它更快。 建议在新项目中尽可能多地使用模块,而不是头文件。 对于正在开发中的大型现有项目,请尝试将旧标头转换为模块。 根据能否显著缩短编译时间来确定是否采用。

要将模块与其他导入标准库的方法进行比较,请参阅比较头单元、模块和预编译标头

在 Microsoft C++ 编译器中启用模块

从 Visual Studio 2022 版本 17.1 起,C++20 标准模块已在 Microsoft C++ 编译器中完全实现。

在 C++20 标准指定之前,Microsoft 对模块具有实验性支持。 编译器还支持导入预生成的标准库模块,如下所述。

从 Visual Studio 2022 版本 17.5 开始,将标准库作为模块导入在 Microsoft C++ 编译器中是标准化且完全实现的操作。 本部分介绍了仍受支持的较旧的实验方法。 有关使用模块导入标准库的新标准化方法的信息,请参阅使用模块导入 C++ 标准库

可以使用模块功能创建单分区模块并导入 Microsoft 提供的标准库模块。 若要启用对标准库模块的支持,请使用 /experimental:module/std:c++latest。 在 Visual Studio 项目中,右键单击“解决方案资源管理器”中的项目节点,然后选择“属性”。 将“配置”下拉列表设置为“所有配置”,然后选择“配置属性”>“C/C++”>“语言”>“启用 C++ 模块(实验性)”。

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

使用 C++ 标准库作为模块(实验性)

本部分介绍了仍受支持的实验性实现。 使用 C++ 标准库的新标准化方式如使用模块导入 C++ 标准库中所述。

通过将 C++ 标准库作为模块导入(而不是通过头文件包含它),可以根据项目的规模加快编译时间。 实验性库拆分为以下命名模块:

  • std.regex 提供标头 <regex> 的内容
  • std.filesystem 提供标头 <filesystem> 的内容
  • std.memory 提供标头 <memory> 的内容
  • std.threading 提供标头 <atomic><condition_variable><future><mutex><shared_mutex><thread> 的内容
  • std.core 提供 C++ 标准库中的任何其他内容

若要使用这些模块,请将导入声明添加到源代码文件的顶部。 例如:

import std.core;
import std.regex;

若要使用 Microsoft 标准库模块,请使用 /EHsc/MD 选项编译程序。

示例

以下示例显示了名为 Example.ixx 的源文件中的简单模块定义。 Visual Studio 中的模块接口文件需要 .ixx 扩展。 在此示例中,接口文件同时包含函数定义和声明。 但是,还可以将定义放置在一个或多个单独的模块实现文件中,如后面的示例所示。

export module Example; 语句指示此文件是名为 Example 的模块的主接口。 f() 上的 export 修饰符指示此函数在另一个程序或模块导入 Example 时可见。

// Example.ixx
export module Example;

#define ANSWER 42

namespace Example_NS
{
   int f_internal() {
        return ANSWER;
      }

   export int f() {
      return f_internal();
   }
}

该文件 MyProgram.cpp 使用 import 访问由 Example 导出的名称。 命名空间名称 Example_NS 在此处可见,但不是所有成员均可见,因为它们未导出。 此外,宏 ANSWER 不可见,因为宏未导出。

// MyProgram.cpp
import Example;
import std.core;

using namespace std;

int main()
{
   cout << "The result of f() is " << Example_NS::f() << endl; // 42
   // int i = Example_NS::f_internal(); // C2039
   // int j = ANSWER; //C2065
}

import 声明仅在全局范围内显示。

模块语法

module-name
module-name-qualifier-seqopt identifier

module-name-qualifier-seq
identifier .
module-name-qualifier-seq identifier .

module-partition
: module-name

module-declaration
exportopt module module-name module-partitionopt attribute-specifier-seqopt ;

module-import-declaration
exportopt import module-name attribute-specifier-seqopt ;
exportopt import module-partition attribute-specifier-seqopt ;
exportopt import header-name attribute-specifier-seqopt ;

实现模块

模块接口导出模块名称以及构成模块的公共接口的所有命名空间、类型和函数等。
模块实现定义模块导出的内容。
模块的最简单形式可以是一个结合了模块接口和实现的文件。 还可以将实现放入一个或多个单独的模块实现文件中,类似于 .h.cpp 文件的操作方式。

对于较大的模块,可以将模块的各个部分拆分为称为“分区”子模块。 每个分区由导出模块分区名称的模块接口文件组成。 分区可能还具有一个或多个分区实现文件。 整个模块有一个主模块接口,它是模块的公共接口。 如果需要,它可以导出分区接口。

模块由一个或多个模块单元组成。 模块单元是一个包含模块声明的转换单元(源文件)。 有多种类型的模块单元:

  • 模块接口单元会导出模块名称或模块分区名称。 模块接口单元在其模块声明中具有 export module
  • 模块实现单元不会导出模块名称或模块分区名称。 顾名思义,它可实现模块。
  • 主模块接口单元会导出模块名称。 一个模块中必须且只能有一个主模块接口单元。
  • 模块分区接口单元会导出模块分区名称。
  • 模块分区实现单元在其模块声明中具有模块分区名称,但没有 export 关键字。

export 关键字仅用于接口文件。 实现文件可以 import 另一个模块,但不能 export 任何名称。 实现文件可以具有任何扩展。

模块、命名空间和依赖于自变量的查找

模块中命名空间的规则与任何其他代码中的规则相同。 如果导出命名空间中的声明,则也会隐式导出封闭命名空间(不包括未在该命名空间中显式导出的成员)。 如果显式导出命名空间,则会导出该命名空间定义中的所有声明。

当编译器在导入转换单元中为重载解析执行依赖于自变量的查找时,它会考虑使用在定义函数参数类型的同一转换单元(包括模块接口)中声明的函数。

模块分区

模块分区类似于模块,但以下情况除外:

  • 它共享整个模块中所有声明的所有权。
  • 分区接口文件导出的所有名称都必须由主接口文件导入并导出。
  • 分区的名称必须以模块名称开头,后跟冒号 (:)。
  • 任何分区中的声明在整个模块中都可见。\
  • 无需采取特殊预防措施来避免单定义规则 (ODR) 错误。 可以在一个分区中声明名称(函数、类等),并在另一个分区中定义它。

分区实现文件从 C++ 标准的角度开始如下,是内部分区:

module Example:part1;

分区接口文件的开头如下所示:

export module Example:part1;

若要访问另一个分区中的声明,分区必须导入它。 但它只能使用分区名称,而不能使用模块名称:

module Example:part2;
import :part1;

主接口单元必须导入并重新导出模块的所有接口分区文件,如下所示:

export import :part1;
export import :part2;

主接口单元可以导入分区实现文件,但无法导出它们。 不允许这些文件导出任何名称。 这一限制使模块能够在模块内部保留实现详细信息。

模块和头文件

可以通过在模块声明前放置 #include 指令,将头文件包含在模块源文件中。 这些文件被视为位于全局模块片段中。 模块只能查看其显式包含的标头中的全局模块片段中的名称。 全局模块片段仅包含使用的符号。

// MyModuleA.cpp

#include "customlib.h"
#include "anotherlib.h"

import std.core;
import MyModuleB;

//... rest of file

可以使用传统的头文件来控制导入的模块:

// MyProgram.h
import std.core;
#ifdef DEBUG_LOGGING
import std.filesystem;
#endif

导入的头文件

某些标头已足够自包含,可以使用 import 关键字将其引入。 导入的标头与导入的模块之间的主要区别在于,标头中的任何预处理器定义在 import 语句后立即在导入程序中可见。

import <vector>;
import "myheader.h";

另请参阅

.- .
命名模块教程
比较标头单元、模块和预编译标头