经过多年的标准化进程,在 C++ 开发者社区中呼声极高的 Modules 特性终于被收入 C++20 标准中。本文将对这一特性进行介绍和探讨。
编译器支持
在正式介绍前,我们首先看一看现有的编译器对 Modules 特性的支持情况(截止 2021 年 5 月)。从 cppreference 上的 C++20 编译器支持矩阵可获悉,目前只有 MSVC 提供了对 Modules 的完整支持;GCC 11、Clang 8 以及 Apple Clang 10.0.1 仅包含对 Modules 特性的部分支持。
动机
翻译单元和头文件
在我们熟悉的 C++ 世界中,一个包含多个源代码文件的 C++ 程序的编译过程是这样的:
.o.obj.cpp.cc
ABABBB#includeBB
问题
通过公共的头文件在多个翻译单元之间传递接口信息的做法是上古时期的编程方式,以现代的角度来看有诸多弊端。
首先,该机制会肉眼可见地造成编译速度的下降。我们不妨回顾一下经典的 C++ hello world 程序:
该程序仅包含 6 行(包含空行)。使用如下命令查看如上程序经预处理后的结果:
hello.imainiostreamiostreamiostream
#include#include
已有缓解措施
#includestdafx.h
但是,PCH 机制仍有其局限性。前文已经提到,头文件以源代码方式嵌入到翻译单元中并参与预处理这一过程可能会造成头文件在多个翻译单元中的展开内容的不一致。PCH 并不能很好地解决这个问题,因此许多现代编译器在实现 PCH 时,要么将保证头文件内容一致性的任务交给程序员(例如 MSVC),要么设立一系列的限制条件,限制 PCH 的使用场景(例如 GCC)。
Modules
C++20 Modules (模块)特性为 C++ 程序带来了一种新的程序划分和管理的方式。在以往的 C++ 程序中,程序被划分为若干翻译单元;在 C++20 以后,程序将被划分为若干模块。每个模块负责实现一定的功能,并将访问这些功能的接口 导出(Export) 来允许其他模块使用这些接口。当某个模块需要使用另一个模块导出的接口时,它需要首先 导入(Import) 这个模块,然后便能使用导入的模块提供的接口。
简单的例子
Adderadd
adder.hppadder.cppmain.cppAddermain.cpp
定义模块的基本方式
Adder
module XXXexportexport module XXX
adder.hppAdderadder.cppAdderAdder
exportadder.hppexportAdderaddexport
adder.cppAdderadd
.hpp.cpp
使用模块的基本方式
importmain.cppimportAdder
main.cppAdderAdderaddAdder.addAdder::add
全局模块
addAddermain.cppmain.cppmainiostream
答案是这些内容从属于一个隐式定义的 全局模块(Global Module)。全局模块是一个由编译器隐式提供的模块,它没有名字且包含所有的那些不从属于任何一个程序中定义的模块的内容。全局模块是两种允许没有模块接口单元的模块之一(我们实际上也没有办法为全局模块提供模块接口单元),另一种允许没有模块接口单元的模块将在稍后介绍。
模块的编译方式
由于各大编译器还没有提供统一的、有关如何编译 C++20 模块的标准,因此我将会以 Clang 10.0.0 为例,介绍 Clang 编译模块的方式。其他的编译器对于编译模块所需的具体参数和实现细节可能有所不同。
import
adder.hpp
这里需要注意几个点:
-fmodules-Xclang -emit-module-interface.pcm
adder.cppmain.cpp
-fmodule-file=adder.pcmimportadder.pcm
最后,我们可以使用常规的链接操作生成可执行文件并执行:
Module Partitions
div_intdiv_float
NumericNumericIntegralNumeraldiv_intIntegraldiv_floatNumeral
我们可以借助 Module Partition 特性实现这一点。具体的实现代码如下:
NumericIntegralNumeralABA:BIntegraldiv_intNumeraldiv_floatNumericexport importNumeralNumericdiv_intdiv_floatNumeric
Numericimport Numeric:IntegralIntegralNumericimportnumeric.hppimport
内部子模块
Module Partition 机制除了可以对模块提供的接口进行进一步的逻辑划分以外,还可以用于提供模块的内部实现。为模块提供内部实现的子模块在标准中没有特定的名称,在本文中我称其为“内部子模块”。这一类子模块不会被模块的模块接口单元所导出,它存在的唯一作用是提供模块实现的内部细节。
DataData
DatabaseConnectionDataDataData
Datadata.cppDataDatabaseConnection
DatabaseConnectionDataDataDatabaseConnectionData
Data:InternalNumeric:NumeralData:Internalimport
模块导出规则
导出的根本目的与头文件机制的根本目的一致,是让模块实现单元(翻译单元)中定义的功能能够被模块的用户(其他翻译单元)所看到。
export
- 被导出的项应至少具有一个符号;
- 被导出的项应该具有 external linkage。
第一条意味着如下的导出是非法的:
第二条意味着如下的导出也是非法的:
exportexport
export
头文件导入
C++20 Modules 特别规定允许“导入”一个头文件。例如:
importiostreammainiostream
需要注意到,C++20 标准并没有规定导入头文件的行为。因此,导入头文件的行为是 implementation defined 的。另外,C++20 标准并没有规定所有的头文件都能够被导入;相反地,C++20 标准规定了一个 implementation defined 的 可导入头文件 列表,只有在这个列表中的头文件才能够被导入。
总结
C++20 引入了 Modules 机制来弥补传统的基于头文件在翻译单元之间传递接口信息的方式的诸多弊端。本文介绍了 Modules 机制的基本使用方法和已有实现现状,但还没有完全覆盖 Modules 机制的具体设计细节。有关 Modules 机制进一步的使用方式和设计细节请参考 C++20 标准。