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-seq
identifier
.
module-partition
:
:
module-name
module-declaration
:
export
选择module
module-name
module-partition
选择attribute-specifier-seq
选择;
module-import-declaration
:
export
选择import
module-name
attribute-specifier-seq
选择;
export
选择import
module-partition
attribute-specifier-seq
选择;
export
选择import
header-name
attribute-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";