C++模块到底是什么?

bjg7j2ky  于 2023-06-25  发布在  其他
关注(0)|答案(5)|浏览(165)

我一直在跟踪C标准化,并偶然发现了C模块的想法。我找不到一篇关于它的好文章。到底是关于什么的

3hvapo4f

3hvapo4f1#

动机

简单的答案是,C模块就像一个header,它也是一个翻译单元。它就像一个头文件,你可以使用它(与import,这是一个新的上下文关键字)来访问库中的声明。因为它是一个翻译单元(对于复杂的模块来说是几个),所以它被单独编译**并且只编译一次。(回想一下,#include实际上是将文件的内容复制到包含指令的翻译单元中。)这种组合产生了许多优点:
1.隔离:因为模块单元是单独的翻译单元,所以它具有其自己的宏和X1 M2 N1 X声明/指令集,这些宏和X1 M2 N1 X声明/指令集既不影响导入翻译单元或任何其他模块中的宏和X1 M2 N1 X声明/指令,也不受导入翻译单元或任何其他模块中的宏和X1 M2 N1 X声明/指令集的影响。这防止了在一个报头中的标识符#define d与在另一报头中使用的标识符之间的冲突。虽然使用using仍然是明智的,但在模块接口的名称空间范围内编写using namespace本质上并没有什么害处。
1.接口控制:因为模块单元可以声明具有内部链接(使用staticnamespace {})、export(从C
98开始为这些目的保留的关键字)或两者都不使用的实体,所以它可以限制客户端可以使用多少内容。这取代了namespace detail习惯用法,它可能会在标头之间发生冲突(在同一个包含名称空间中使用它)。
1.重复数据消除:因为在许多情况下,不再需要在头文件中提供声明并在单独的源文件中提供定义,所以减少了冗余和相关联的发散机会。
1.避免一定义规则违规:ODR的存在仅仅是因为需要在使用它们的每个翻译单元中 * 定义 * 某些实体(类型、内联函数/变量和模板)。一个模块只能定义一个实体一次,但仍然可以向客户端提供该定义。此外,已经通过内部链接声明违反ODR的现有头文件在转换为模块时不再是格式错误的,不需要诊断。
1.非局部变量初始化顺序:因为import在包含(唯一的)变量 * 定义 * 的翻译单元之间建立了依赖性顺序,所以initialize non-local variables with static storage duration存在明显的顺序。C++17为inline变量提供了一个可控的初始化顺序;模块将其扩展到普通变量(并且根本不需要inline变量)。
1.模块私有声明:在模块中声明的既没有导出也没有内部链接的实体可由模块中的任何转换单元使用(按名称),从而在static的预先存在的选择之间提供有用的中间地带。虽然还有待观察具体实现将如何处理这些,但它们密切对应于动态对象中的“隐藏”(或“未导出”)符号的概念,提供了这种实际动态链接优化的潜在语言识别。
1.ABI稳定性inline的规则(其ODR兼容性目的与模块无关)已调整为支持(但不是必需的!)一种实现策略,其中非内联函数可以用作共享库升级的ABI边界。
1.编译速度:因为模块的内容不需要作为使用它们的每个翻译单元的一部分被重新解析,所以在许多情况下编译进行得快得多。值得注意的是,编译的关键路径(控制无限并行构建的延迟)实际上可能更长,因为模块必须按依赖顺序单独处理,但总的CPU时间显著减少,并且只有一些模块/客户端的重建速度要快得多。
1.工装:涉及importmodule的“结构化声明”在其使用上具有限制,以使需要理解项目的依赖图的工具能够容易且有效地检测它们。这些限制还允许大多数(如果不是全部的话)现有使用这些常用词作为标识符。

方法

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

由于模块必须能够以允许使用其内容的方式向其客户端提供类型,内联函数和模板,因此通常编译器在处理包含客户端所需详细信息的模块(有时称为编译模块接口)时生成工件。CMI类似于预编译头,但没有必须以相同顺序在每个相关翻译单元中包含相同头的限制。它也类似于Fortran模块的行为,尽管没有类似于它们仅从模块导入特定名称的功能。
因为编译器必须能够找到基于import foo;的CMI(并找到基于import :partition;的源文件),所以它必须知道从“foo”到(CMI)文件名的Map。Clang为这个概念建立了术语“模块Map”;一般来说,如何处理隐式目录结构或模块(或分区)名称与源文件名不匹配的情况还有待观察。

非功能

像其他“二进制头”技术一样,模块不应该被视为一种分发机制(就像那些秘密的弯曲可能希望避免提供任何包含模板的头和所有定义一样)。它们也不是传统意义上的“仅头文件”,尽管编译器可以使用模块为每个项目重新生成CMI。
虽然在许多其他语言(例如Python)中,模块不仅是编译单元,而且是命名单元,但C模块不是命名空间。C已经有了命名空间,模块不会改变它们的用法和行为(部分是为了向后兼容)。但是,模块名称通常与命名空间名称一致,这是可以预料的,特别是对于具有众所周知的命名空间名称的库,这些名称可能会与任何其他模块的名称混淆。(一个nested::name可以被渲染为一个模块名nested.name,因为在那里允许.而不是::; a .在C++20中除了作为约定外没有任何意义。
模块也不会淘汰pImpl idiom或阻止fragile base class problem。如果客户端的类是完整的,那么更改该类通常仍需要重新编译客户端。
最后,模块没有提供一种机制来提供,而这些宏是某些库接口的重要组成部分;可以提供看起来像

// wants_macros.hpp
import wants.macros;
#define INTERFACE_MACRO(x) (wants::f(x),wants::g(x))

(You甚至不需要#include保护,除非同一个宏可能有其他定义。)

多文件模块

模块有一个 * 主接口单元 *,其中包含export module A;:这是由编译器处理以产生客户端所需的数据的转换单元。它可以招募包含export module A:sub1;的额外的 * 接口分区 *;这些是单独的翻译单元,但是被包括在模块的一个CMI中。也可以有 * 实现分区 module A:impl1;),它可以通过接口导入,而无需将其内容提供给整个模块的客户端。(由于技术原因,一些实现可能会将这些内容泄漏给客户端,但这永远不会影响名称查找。
最后,(非分区)
模块实现单元 *(简单地使用module A;)根本不向客户端提供任何东西,但可以定义在模块接口中声明的实体(它们隐式地导入)。一个模块的所有翻译单元都可以使用它们导入的同一模块的另一部分中声明的任何内容,只要它没有内部链接(换句话说,它们忽略export)。
作为一种特殊情况,单文件模块可以包含一个module :private;声明,该声明有效地将实现单元与接口打包在一起;这被称为“私有模块片段”。特别是,它可以用来定义一个类,同时在客户端中让它不完整(这提供了二进制兼容性,但不会阻止使用典型的构建工具进行重新编译)。

升级

将基于头的库转换为模块既不是一件小事,也不是一项艰巨的任务。所需的样板文件非常小(在许多情况下只有两行),并且可以将export {}放在文件的相对较大的部分(尽管有一些不幸的限制:不得附上static_assert声明或扣减指南)。通常,namespace detail {}可以转换为namespace {},也可以不导出;在后一种情况下,其内容通常可以被移动到包含名称空间。如果希望即使是ABI保守的实现也能从其他翻译单元内联调用类成员,则需要显式地将类成员标记为inline

当然,并不是所有的库都能瞬间升级;向后兼容性一直是C的重点之一,有两种独立的机制允许基于模块的库依赖于基于头的库(基于最初的实验实现提供的库)。(在另一个方向,头可以像其他任何东西一样简单地使用import,即使它被模块以任何一种方式使用。
在模块技术规范中,* 全局模块片段 * 可能出现在模块单元(由裸module;引入)的开头,该单元仅包含预处理器指令:特别地,#include s用于模块所依赖的报头。在大多数情况下,可以示例化在模块中定义的模板,该模块使用包含的标头中的声明,因为这些声明被合并到CMI中。
还有一个选项可以导入一个“modular”(或 importable)header(import "foo.hpp";):导入的是一个合成的 * 头单元 *,它的作用就像一个模块,除了它导出它声明的所有东西,甚至是带有内部链接的东西(这可能(仍然!)如果在标头之外使用,则会产生ODR冲突。(使用由不同导入的头单元给定不同值的宏是错误的;不考虑命令行宏(-D)。)非正式地说,如果包含一次头,没有定义特殊的宏,就足以使用它,那么它是模块化的(而不是说,它是带有标记粘贴的模板的C实现)。如果实现知道一个header是可导入的,它可以自动将其中的#include替换为import
在C
20中,标准库仍然以头文件的形式表示;所有C头文件(但不是C头文件或<cmeow> Package 器)都被指定为可导入的。C23可能会额外提供命名模块(尽管可能不是每个头一个)。

示例

一个非常简单的模块可能是

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”的想法(* 例如 *,它改变了有符号和无符号整数之间的比较规则),因为这会使转换现有代码变得更加困难,并且会使在模块化和非模块化文件之间移动代码变得危险。
MSVC已经有了一段时间的模块实现(紧跟TS)。Clang已经实现了可导入头文件好几年了。GCC对标准化版本有功能性但不完整的实现。

cwtwac6a

cwtwac6a2#

C++模块是允许编译器使用“语义导入”而不是旧的文本包含模型的建议。当找到#include预处理器指令时,它们将读取包含表示代码的抽象语法树的序列化的二进制文件,而不是执行复制和粘贴。
这些语义导入避免了包含在头文件中的代码的多次重新编译,从而加快了编译速度。例如,如果您的项目包含100个#include<iostream>,在不同的.cpp文件中,每个语言配置只解析一次头文件,而不是每个使用该模块的翻译单元解析一次。
微软的提议超越了这一点,并引入了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/

zdwk9cvp

zdwk9cvp3#

请看这个我喜欢的简单例子。这里的模块解释得很好。作者使用简单的术语和伟大的例子来检查问题的每一个方面,在文章中陈述。
https://www.modernescpp.com/index.php/c-20-modules

eagi6jfj

eagi6jfj5#

模块是一组源文件,它们被编译为一个单元,形成一个二进制组件,可以由其他模块(或支持模块导入的翻译单元)导入。包括模块的源文件集合包括一个接口文件(例如,接口文件)。.ixx文件),以及0个或更多个源文件,例如.cpp文件。
https://learn.microsoft.com/en-us/cpp/cpp/modules-cpp?view=msvc-170
每个模块的接口文件是模块可以向模块的使用者公开功能的唯一文件。例如,为了公开模块的.cpp文件中的类和函数等功能,它们必须从模块的接口文件中export '艾德。接口文件还可以包含功能以及导出。
关于模块的关键概念:

  • 不需要预编译的概念,因为接口文件和其他模块源文件总是被编译成二进制形式。
  • 模块通过接口文件公开(导出)功能。
  • 接口(例如.ixx)和.cpp文件使用其他模块,方法是使用import语句按模块名称导入其他模块。例如,import ModuleA;将使用接口文件ModuleA.ixx的二进制形式,例如:ModuleA.ixx.obj,幕后。您不必关心这些,只需使用import ModuleA;
  • 模块比头文件更自包含,因为只有模块显式导出的内容在模块外部才是可见的。例如,即使模块A包括传统的头文件,#defines等等。对于使用import module A;的模块B是不可见的。模块B唯一能看到的是模块A显式导出的内容。将其与"include ModuleA.h"(例如)进行比较,"include ModuleA.h"将暴露ModuleA. h中的所有内容以及ModuleA. h中包含的所有内容!
  • 通常头文件(包括模板库头文件)通常可以放在模块中,并由import 'ing编译后的模块使用。例如,STL的大部分(如果不是全部)可以在单个.ixx文件中进行#include艾德并编译。生成的模块是原始报头的单个二进制形式,可以由其他模块进行x1m8 n1艾德。与包含原始头文件相比,在SomeSetOfHeaders.ixx(例如)中包含头文件所产生的接口二进制文件在import 'ed时明显更隔离,编译效率更高。

以下示例说明了模块的隔离特性:
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.

...
...

在上面的例子中:

  • 导入到ModuleA中的模块ThingThatModuleA_Uses***在ModuleB中不可见***。
  • 但是,ExampleA. h***包含的ThingThatExampleA_Uses. h对ExampleB. cpp可见***

https://learn.microsoft.com/en-us/cpp/cpp/tutorial-named-modules-cpp?view=msvc-170

相关问题