C++中的模块概述

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

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

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

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

从 Visual Studio 2022 版本 17.5 开始,将标准库导入为模块在 Microsoft C++ 编译器中都是标准化和完全实现的。 若要了解如何使用模块导入标准库,请参阅 使用模块导入C++标准库

单分区模块

单分区模块是包含单个源文件的模块。 模块接口和实现位于同一文件中。

以下单分区模块示例演示了名为 Example.ixx 的源文件中的简单模块定义。 该 .ixx 扩展是 Visual Studio 中模块接口文件的默认扩展。 如果要使用不同的扩展,请使用 /interface 开关将其编译为模块接口。 在此示例中,接口文件包含函数定义和声明。 还可以将定义放置在一个或多个单独的模块实现文件中,如后面的示例所示,但这是单分区模块的示例。

export module Example; 语句指示此文件是调用 Example的模块的主接口。 export 修饰符位于 int f() 之前,指示当另一个程序或模块导入 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-seq选择identifier

module-name-qualifier-seq:
identifier.
module-name-qualifier-seqidentifier.

module-partition:
:module-name

module-declaration:
export选择modulemodule-namemodule-partition选择attribute-specifier-seq选择;

module-import-declaration:
export选择importmodule-nameattribute-specifier-seq选择;
export选择importmodule-partitionattribute-specifier-seq选择;
export选择importheader-nameattribute-specifier-seq选择;

实现模块

模块接口导出模块名称和构成模块的公共接口的所有命名空间、类型、函数等。
模块实现定义模块导出的内容。
以最简单的形式,模块可以是合并模块接口和实现的单个文件。 还可以将实现放在一个或多个单独的模块实现文件中,类似于.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";

另请参阅

module, import, export
命名模块教程
比较标头单元、模块和预编译标头