动机
简单的答案是,C++ 模块就像一个标题,也是一个翻译单元。它就像一个标题,您可以使用它(使用import,这是一个新的上下文关键字)来访问库中的声明。因为它是一个翻译单元(或者对于一个复杂的模块来说是多个),所以它被单独编译一次并且只编译一次。 (回想一下#include 字面意思是将文件的内容复制到包含该指令的翻译单元中。)这种组合产生了许多优点:
-
隔离:因为模块单元是一个单独的翻译单元,它有自己的一组宏和
using 声明/指令,既不影响也不受导入翻译单元或任何其他模块中的那些影响.这可以防止一个标头中的标识符#defined 与另一个标头中的标识符发生冲突。虽然使用 using 仍然应该是明智的,但在模块接口的命名空间范围内写入 using namespace 本质上也无害。
-
接口控制:因为模块单元可以声明具有内部链接的实体(使用
static 或namespace {}),使用export(自C++98 起为此类用途保留的关键字),或者两者都没有,它可以限制有多少内容可供客户使用。这取代了 namespace detail 成语,它可能在标头之间发生冲突(在同一包含命名空间中使用它)。
-
重复数据删除:因为在许多情况下不再需要在头文件中提供声明并在单独的源文件中提供定义,因此减少了冗余和相关的分歧机会。
-
避免违反定义规则:ODR 的存在仅仅是因为需要在每个使用它们的翻译单元中定义某些实体(类型、内联函数/变量和模板) .一个模块可以只定义一个实体一次,然后将该定义提供给客户端。此外,已经通过内部链接声明违反 ODR 的现有标头在转换为模块时不再是格式错误的,无需诊断。
-
非局部变量初始化顺序:因为
import在包含(唯一)变量定义的翻译单元之间建立了依赖顺序,所以@987654321有一个明显的顺序@。 C++17 提供了具有可控初始化顺序的inline 变量;模块将其扩展到普通变量(并且根本不需要 inline 变量)。
-
模块私有声明:在模块中声明的既不导出也不具有内部链接的实体可由模块中的任何翻译单元(按名称)使用,在@的预先存在的选择之间提供有用的中间立场987654338@ 与否。虽然具体实现将如何处理这些还有待观察,但它们与动态对象中“隐藏”(或“未导出”)符号的概念密切相关,为这种实际的动态链接优化提供了潜在的语言识别。
-
ABI 稳定性:
inline 的规则(其 ODR 兼容性目的与模块无关)已调整为支持(但不是必需!)非内联函数可以服务的实现策略作为共享库升级的 ABI 边界。
-
编译速度:因为不需要将模块的内容作为每个使用它们的翻译单元的一部分重新解析,所以在许多情况下编译进行得更快。值得注意的是,编译的关键路径(控制无限并行构建的延迟)实际上可以更长,因为模块必须按依赖顺序单独处理,但总 CPU 时间显着减少,并且仅重建部分模块/客户端的速度要快得多。
-
工具:涉及
import 和module 的“结构声明”对其使用有限制,以便需要了解项目依赖图的工具可以轻松有效地检测到它们。这些限制还允许大多数(如果不是全部)使用这些常用词作为标识符。
方法
因为必须在客户端中找到在模块中声明的名称,所以需要一种重要的新类型的名称查找,它可以跨翻译单元工作;为依赖于参数的查找和模板实例化获取正确的规则是使该提案需要十多年才能标准化的重要部分。简单的规则是(除了由于明显的原因与内部链接不兼容)export 影响仅名称查找;通过(例如)decltype 或模板参数可用的任何实体都具有完全相同的行为,无论它是否被导出。
因为模块必须能够以允许使用其内容的方式向其客户端提供类型、内联函数和模板,通常编译器在处理模块时会生成工件(有时称为编译模块接口),其中包含客户端所需的详细信息。 CMI 类似于预编译的标头,但没有限制必须以相同的顺序在每个相关的翻译单元中包含相同的标头。它也类似于 Fortran 模块的行为,尽管没有类似的特性是从模块中仅导入特定名称。
因为编译器必须能够根据import foo; 找到CMI(并根据import :partition; 找到源文件),所以它必须知道从“foo”到(CMI)文件名的一些映射。 Clang 为这个概念建立了术语“模块映射”;一般来说,如何处理隐式目录结构或模块(或分区)名称与源文件名不匹配的情况还有待观察。
非特征
与其他“二进制标头”技术一样,模块不应被视为一种分发机制(就像那些秘密倾向的人可能希望避免提供标头和任何包含的所有定义一样)模板)。它们也不是传统意义上的“仅头文件”,尽管编译器可以使用模块为每个项目重新生成 CMI。
在许多其他语言(例如、Python)中,模块不仅是编译单元,也是命名单元,而 C++ 模块不是命名空间。 C++ 已经有了命名空间,模块的使用和行为没有任何改变(部分是为了向后兼容)。然而,可以预料的是,模块名称通常会与命名空间名称保持一致,特别是对于具有众所周知的命名空间名称的库,这些名称会与任何其他模块的名称混淆。 (nested::name 可以呈现为模块名称 nested.name,因为 . 而不是 :: 在那里被允许;. 在 C++20 中没有任何意义,除非作为约定。)
模块也不会废弃pImpl idiom 或阻止fragile base class problem。如果一个类对于客户端来说是完整的,那么更改该类通常仍然需要重新编译客户端。
最后,模块没有提供机制来提供宏,这些宏是某些库接口的重要组成部分;可以提供一个看起来像
的包装头
// wants_macros.hpp
import wants.macros;
#define INTERFACE_MACRO(x) (wants::f(x),wants::g(x))
(你甚至不需要#include 守卫,除非同一宏可能有其他定义。)
多文件模块
一个模块有一个主接口单元,其中包含export module A;:这是编译器处理的翻译单元,用于生成客户端所需的数据。它可能会招募包含export module A:sub1; 的额外接口分区;这些是单独的翻译单元,但包含在模块的一个 CMI 中。也可以有 实现分区 (module A:impl1;),这些分区可以由接口导入,而无需将其内容提供给整个模块的客户端。 (某些实现可能出于技术原因将这些内容泄露给客户端,但这不会影响名称查找。)
最后,(非分区)模块实现单元(简单地module A;)向客户端提供任何东西,但可以定义在模块接口中声明的实体(它们隐式导入)。一个模块的所有翻译单元都可以使用在它们导入的同一模块的另一部分中声明的任何内容,只要它没有内部链接(换句话说,它们会忽略export)。
作为一种特殊情况,单文件模块可以包含module :private; 声明,该声明有效地将实现单元与接口打包在一起;这称为私有模块片段。特别是,它可用于定义一个类,同时将其不完整留在客户端中(提供二进制兼容性,但不会阻止使用典型的构建工具重新编译)。
升级
将基于标头的库转换为模块既不是微不足道的任务,也不是一项艰巨的任务。所需的样板文件非常少(在许多情况下为两行),并且可以将export {} 放在文件的相对较大的部分周围(尽管有一些不幸的限制:没有static_assert 声明或推导指南可能被附上)。通常,namespace detail {} 可以转换为namespace {} 或直接不导出;在后一种情况下,它的内容可能经常被移动到包含名称空间。如果希望即使是 ABI-conservative 实现也需要从其他翻译单元对它们进行内联调用,则需要将类成员显式标记为 inline。
当然,并不是所有的库都可以瞬间升级;向后兼容性一直是 C++ 的重点之一,并且有两种独立的机制允许基于模块的库依赖基于标头的库(基于初始实验实现提供的库)。 (在另一个方向上,标题可以像其他任何东西一样简单地使用import,即使它被模块以任何一种方式使用。)
与模块技术规范一样,全局模块片段可能会出现在仅包含预处理器指令的模块单元(由 module; 引入)的开头:特别是 @987654366 @s 用于模块所依赖的标头。在大多数情况下,可以实例化在模块中定义的模板,该模板使用来自它包含的标头中的声明,因为这些声明已合并到 CMI 中。
还有导入“模块化”(或可导入)标头(import "foo.hpp";)的选项:导入的是合成的标头单元,其作用类似于一个模块,除了它导出它声明的所有内容——即使是具有内部链接的东西(如果在标头之外使用,可能(仍然!)产生 ODR 违规)和宏。 (使用由不同导入的标头单元给定不同值的宏是错误的;不考虑命令行宏(-D)。)非正式地,如果标头包含一次,则标头是模块化的,没有特殊的宏定义,就足以使用它(而不是,比如说,带有标记粘贴的模板的 C 实现)。如果实现知道标头是可导入的,它可以自动将其中的#include 替换为import。
在 C++20 中,标准库仍然以头文件的形式呈现;所有 C++ 头文件(但不是 C 头文件或 <cmeow> 包装器)都被指定为可导入的。 C++23 可能会另外提供命名模块(尽管每个标头可能不是一个)。
示例
一个非常简单的模块可能是
export module simple;
import <string_view>;
import <memory>;
using std::unique_ptr; // not exported
int *parse(std::string_view s) {/*…*/} // cannot collide with other modules
export namespace simple {
auto get_ints(const char *text)
{return unique_ptr<int[]>(parse(text));}
}
可以用作
import simple;
int main() {
return simple::get_ints("1 1 2 3 5 8")[0]-1;
}
结论
预计模块会以多种方式改进 C++ 编程,但这些改进是渐进式的并且(在实践中)是渐进式的。委员会强烈反对将模块设为“new language”(eg,这会改变有符号和无符号整数之间的比较规则)的想法,因为这会使转换现有代码变得更加困难,并且会使在模块化和非模块化文件之间移动代码很危险。
MSVC 已经有一段时间的模块实现(紧跟 TS)。 Clang 也已经实施了几年的可导入标头。 GCC 对标准化版本有一个功能性但不完整的实现。