C 模块到底是什么?

新手上路,请多包涵

我一直在关注 C++ 标准化并遇到了 C++ 模块的想法。我找不到关于它的好文章。它到底是关于什么的?

原文由 Amani 发布,翻译遵循 CC BY-SA 4.0 许可协议

阅读 617
2 个回答

动机

简单的答案是 C++ 模块就像一个 头文件,也是一个 翻译单元。它就像一个标题,您可以使用它(使用 import ,这是一个新的上下文关键字)来访问库中的声明。因为它是一个翻译单元(或者对于一个复杂的模块来说是几个),所以它是 单独 编译的,并且只编译一次。 (回想一下 #include 从字面上将文件 的内容复制 到包含该指令的翻译单元中。)这种组合产生了许多优点:

  1. 隔离:因为一个模块单元是一个单独的翻译单元,它有自己的一组宏和 using 声明/指令既不影响也不受导入翻译单元或任何其他模块中的那些影响。这可以防止一个标头中的标识符 #define d 与另一个标头中的标识符发生冲突。虽然使用 using 仍然应该是明智的,但即使在模块接口的命名空间范围内写入 using namespace 也没有本质上的危害。
  2. 接口控制:因为模块单元可以声明具有内部链接的实体(使用 staticnamespace {} ),使用 export ++98),或者两者都没有,它可以限制客户端可以使用的内容量。这替换了 namespace detail 可能在标头之间发生冲突的成语(在同一包含名称空间中使用它)。
  3. 重复数据删除:因为在许多情况下不再需要在头文件中提供声明并在单独的源文件中提供定义,因此减少了冗余和相关的分歧机会。
  4. 避免违反定义规则:ODR 的存在仅仅是因为需要在使用它们的每个翻译单元中 定义 某些实体(类型、内联函数/变量和模板)。一个模块可以只定义一个实体一次,然后将该 定义 提供给客户端。此外,已经通过内部链接声明违反 ODR 的现有标头在转换为模块时不再是格式错误的,不需要诊断。
  5. 非局部变量初始化顺序:因为 import 在包含(唯一)变量 定义 的翻译单元之间建立了依赖顺序,所以 使用静态存储持续时间初始化非局部变量 有一个明显的顺序。 C++17 提供 inline 具有可控初始化顺序的变量;模块将其扩展到普通变量(并且根本不需要 inline 变量)。
  6. 模块私有声明:模块中声明的既不导出也不具有内部链接的实体可由模块中的任何翻译单元(按名称)使用,在 static 或不是。虽然具体实现将如何处理这些还有待观察,但它们与动态对象中“隐藏”(或“未导出”)符号的概念密切相关,为这种实际的动态链接优化提供了潜在的语言识别。
  7. ABI 稳定性inline (其 ODR 兼容性目的与模块无关)的规则已调整为支持(但不是必需!)非内联函数可以用作 ABI 的实施策略共享库升级的边界。
  8. 编译速度:因为模块的内容不需要作为每个使用它们的翻译单元的一部分重新解析,在许多情况下编译进行得更快。值得注意的是,编译的关键路径(控制无限并行构建的延迟)实际上可以更长,因为模块必须按依赖顺序单独处理,但总 CPU 时间显着减少,并且仅重建部分模块/客户要快得多。
  9. 工具:涉及 importmodule 的“结构声明”对它们的使用有限制,以便需要了解项目依赖图的工具可以轻松有效地检测到它们。这些限制还允许大多数(如果不是全部)这些常用词作为标识符的现有使用。

方法

因为必须在客户端中找到在模块中声明的名称,所以需要一种重要的新型 名称查找,它可以跨翻译单元工作;为依赖于参数的查找和模板实例化获取正确的规则是使该提案需要十多年才能标准化的重要部分。简单的规则是(除了由于明显的原因与内部链接不兼容) export 影响名称查找;通过( _例如_) decltype 或模板参数可用的任何实体都具有完全相同的行为,无论它是否被导出。

因为模块必须能够以允许使用其 内容 的方式向其客户端提供类型、内联函数和模板,所以通常 编译 器在处理包含客户需要的详细信息。 CMI 类似于 预编译的标头,但没有限制必须以相同的顺序在每个相关的翻译单元中包含相同的标头。它也类似于 Fortran 模块的行为,尽管没有类似于它们从模块中仅导入特定名称的特性。

因为编译器必须能够根据 import foo; 找到CMI(并根据 import :partition; 找到源文件),它必须知道从“foo”到(CMI)文件的一些映射姓名。 Clang 为这个概念建立了术语“模块映射”;一般来说,如何处理隐式目录结构或模块(或分区)名称与源文件名不匹配的情况还有待观察。

非特征

与其他“二进制标头”技术一样,模块不应被视为一种 分发机制(就像那些秘密倾向的人可能希望避免提供标头和任何包含模板的所有定义一样)。它们也不是传统意义上的“仅头文件”,尽管编译器可以使用模块为每个项目重新生成 CMI。

虽然在许多其他语言( 例如 Python)中,模块不仅是编译单元,而且是命名单元,但 C++ 模块 不是名称空间。 C++ 已经有了命名空间,模块的使用和行为没有任何改变(部分是为了向后兼容)。然而,可以预料的是,模块名称通常会与命名空间名称保持一致,特别是对于具有众所周知的命名空间名称的库,这些名称会与任何其他模块的名称混淆。 (A nested::name may be rendered as a module name nested.name , since . and not :: is allowed there; a . 除了作为约定外,在 C++20 中没有任何意义。)

模块也不会废弃 pImpl 习惯用法 或防止 脆弱的基类问题。如果一个类对于客户端来说是完整的,那么更改该类通常仍然需要重新编译客户端。

最后,模块没有提供一种机制来提供 ,这些宏是某些库接口的重要组成部分;可以提供一个看起来像

// 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 {} 或者干脆不导出;在后一种情况下,它的内容可能经常被移动到包含名称空间。类成员需要明确标记为 inline 如果希望即使是 ABI-conservative 实现也需要从其他翻译单元对它们进行内联调用。

当然,并不是所有的库都可以瞬间升级;向后兼容性一直是 C++ 的重点之一,并且有两种独立的机制允许基于模块的库 依赖 于基于标头的库(基于初始实验实现提供的库)。 (在另一个方向上,标头可以简单地使用 import 就像其他任何东西一样,即使它被模块以任何一种方式使用。)

正如在模块技术规范中一样, 全局模块片段 可能出现在仅包含预处理器指令的模块单元(由裸 module; 引入)的开头:特别是 #include 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++ 编程,但这些改进是渐进式的并且(实际上)是渐进式的。委员会强烈反对使模块成为一种 “新语言” 的想法( _例如_,改变有符号和无符号整数之间的比较规则),因为这会使转换现有代码变得更加困难,并且会使代码之间移动变得危险。模块化和非模块化文件。

一段时间以来,MSVC 已经实现了模块(紧跟 TS)。 Clang 也已经实施了几年的可导入标头。 GCC 具有 标准化 版本的功能但不完整的实现。

原文由 Davis Herring 发布,翻译遵循 CC BY-SA 4.0 许可协议

C++ 模块是允许编译器使用“语义导入”而不是旧的文本包含模型的提议。当找到#include 预处理器指令时,它们不会执行复制和粘贴,而是读取包含表示代码的抽象语法树的序列化的二进制文件。

这些语义导入避免了头文件中包含的代码的多次重新编译,从而加快了编译速度。例如,如果您的项目包含 100 #include s of <iostream> ,在不同的 .cpp 文件中,每个语言配置的标头只会被解析一次,而不是每个使用模块的翻译单元解析一次.

微软的提议不止于此,还引入了 internal 关键字。具有 internal 可见性的类的成员将不会在模块外部看到,因此允许类实现者对类隐藏实现细节。 http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4465.pdf

我使用 LLVM 的模块缓存在我的博客中使用 <iostream> 写了一个小例子: https ://cppisland.wordpress.com/2015/09/13/6/

原文由 Gerardo Hernandez 发布,翻译遵循 CC BY-SA 3.0 许可协议

撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进
推荐问题