我一直在关注C++标准化,最近接触到了C++模块的概念。但是我找不到一篇好的文章来介绍它。它到底是什么?
我一直在关注C++标准化,最近接触到了C++模块的概念。但是我找不到一篇好的文章来介绍它。它到底是什么?
简单来说,C++模块就像是既是头文件又是翻译单元的东西。它像一个头文件,因为你可以使用它(通过import
,这是一个新的上下文关键字)来访问库中的声明。因为它是一个翻译单元(或对于复杂模块而言是多个翻译单元),它会被单独地编译一次。(记住,#include
会将文件的内容拷贝到包含该指令的翻译单元中。)这种组合带来了许多优点:
因为在模块中声明的名称必须在客户端中找到,所以需要一种新的重要的名称查找方法,可以跨越翻译单元工作;获得正确的参数相关查找和模板实例化规则是这个提案花费十多年时间进行标准化的重要部分。简单的规则是(除了与内部链接不兼容的明显原因之外),export
仅影响名称查找;通过(例如)decltype
或模板参数可用的任何实体都具有完全相同的行为,无论它是否被导出。
因为模块必须能够以允许其客户端使用其内容的方式提供类型、内联函数和模板,通常编译器在处理模块时生成一个构件(有时称为已编译模块接口),其中包含客户端所需的详细信息。CMI类似于预编译头文件,但没有相同的限制,即必须在每个相关的翻译单元中按相同的顺序包括相同的头文件。它也类似于Fortran模块的行为,尽管没有类似于从模块导入特定名称的功能。
因为编译器必须能够基于import foo;
(并基于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
guards。)
一个模块有一个单独的主接口单元,其中包含export module A;
:这是编译器处理以生成客户端所需数据的翻译单元。它可以招募额外的接口分区,其中包含export module A:sub1;
;这些是单独的翻译单元,但包含在模块的一个CMI中。还可以有实现分区(module A:impl1;
),可以由接口导入而不向整个模块的客户提供其内容。(出于技术原因,一些实现可能会泄漏这些内容给客户端,但这永远不会影响名称查找。)
最后,(非分区)模块实现单元(仅使用module A;
)不向客户端提供任何内容,但可以定义在模块接口中声明的实体(它们隐式导入)。只要它没有内部链接(换句话说,忽略export
),模块的所有翻译单元都可以使用在同一模块的另一部分中声明的任何内容。
作为特殊情况,单文件模块可以包含一个module :private;
声明,该声明有效地将实现单元与接口打包;这称为私有模块片段。特别是,它可用于定义类,同时在客户端中保留其不完整(这提供了二进制兼容性,但不会防止使用典型构建工具进行重新编译)。
static_assert
声明或推导指南)。通常,namespace detail {}
可以转换为namespace {}
,也可以简单地保持未导出状态;在后一种情况下,它的内容通常可以移动到包含命名空间中。如果需要从其他翻译单元内联调用类成员,则需要显式标记其inline
。
当然,并非所有库都可以立即升级;向后兼容性始终是C++的重点之一,因此有两个单独的机制允许基于模块的库依赖于基于头文件的库(基于最初的实验性实现提供)。 (在另一个方向上,即使被模块使用,头文件也可以像任何其他东西一样使用import
。)
如同模块技术规范中所述,全局模块片段可能出现在模块单元的开头(由裸的module;
引入),其中仅包含预处理器指令:特别是,用于模块依赖的头文件的#include
。在大多数情况下,可以实例化定义在模块中使用其包含的头文件声明的模板,因为这些声明已合并到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有一个功能齐全但不完整的标准化版本实现。
#include <cmeow>
不是有效的C++代码行,因此它不能被翻译成中文。它看起来像是一个玩笑或者调侃,因为"C meow"(“C 喵”)听起来像是“kitty”,也就是一只小猫的名字。如果您有其他需要翻译的内容,请告诉我。 - Lightness Races in OrbitA:B:C
和A:X:Y
之间存在某种隔离,而该模型并未包括在内。 - Davis HerringC++模块是一个提案,它允许编译器使用“语义导入”而不是旧的文本包含模型。当找到#include预处理指令时,它们不再执行复制和粘贴,而是读取一个包含表示代码的抽象语法树序列化的二进制文件。
这些语义导入避免了头文件中包含的代码的多次重新编译,加快了编译速度。例如,如果您的项目包含100个不同的.cpp文件中的100个#include
<iostream>
,则头文件将仅在每种语言配置下被解析一次,而不是在使用该模块的每个翻译单元中解析一次。
微软的提案超越了这一点,并引入了internal
关键字。具有internal
可见性的类成员不会在模块之外看到,从而允许类实现者隐藏类的实现细节。
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4465.pdf
我在我的博客中使用<iostream>
编写了一个小例子,使用LLVM的模块缓存:
https://cppisland.wordpress.com/2015/09/13/6/
https://learn.microsoft.com/en-us/cpp/cpp/modules-cpp?view=msvc-170
每个模块的接口文件是模块向消费者公开功能的唯一文件。例如,要公开在模块的.cpp文件中的类和函数等功能,它们必须从模块的接口文件中“导出”。接口文件还可以包含功能及其导出。不需要预编译的概念,因为接口文件和其他模块源文件总是被编译成二进制形式。
模块通过接口文件公开(导出)功能。
接口文件(例如 .ixx)和 .cpp 文件使用 import
语句按模块名称导入其他模块。例如,import ModuleA;
将在幕后使用接口文件 ModuleA.ixx 的二进制形式,例如 ModuleA.ixx.obj。我们不需要关心这些细节,只需简单地使用 import ModuleA;
模块比头文件更加自包含,因为:
import
暴露给导入它的文件使用的内容,对其他导入的模块不可见。例如,即使模块 A 包含传统的头文件,其中的宏等对使用 import module A;
的模块 XYZ 不可见。模块 XYZ 只能看到模块 A 明确导出的内容。如果模块 XYZ 在导入模块 A 之后再导入模块 B,那么模块 A 的任何内容都不会暴露给模块 B(反之亦然)。请注意,这与头文件不同,头文件会将所有内容暴露给随后包含的其他头文件以及包含它的文件。
与之相比,"include ModuleA.h"
(例如)将暴露ModuleA.h中的所有内容以及ModuleA.h包含的所有内容!
import
模块的方式包含在模块中,以便使用这些头文件。例如,STL的大部分甚至全部可以在一个单独的.ixx文件中#include
并进行编译。生成的模块是原始头文件的单一二进制形式,可以被其他模块import
。通过在SomeSetOfHeaders.ixx(例如)中包含头文件所生成的接口二进制形式,在import
时更加隔离且编译效率更高,而不是直接包含原始头文件。以下示例用于说明模块的隔离特性:
ModuleA.ixx
export module ModuleA;
import ThingThatModuleA_Uses;
export void ExampleExportedFunction
{
std::cout << "\nModule Test\n";
}
ModuleB.ixx
export module ModuleB;
import ModuleA;
// ThingThatModuleA_Uses IS NOT visible here.
...
...
ExampleA.h
#include ThingThatExampleA_Uses.h
...
...
ExampleB.cpp
#include ExampleA.h
// Contents of ThingThatExampleA_Uses.h IS visible here.
...
...
https://learn.microsoft.com/en-us/cpp/cpp/tutorial-named-modules-cpp?view=msvc-170