2

C++20 四大特性之一:Module 特性详解

 2 years ago
source link: https://segmentfault.com/a/1190000040479687
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

C++20 四大特性之一:Module 特性详解

C++20 最大的特性是什么?

最大的特性是迄今为止没有哪一款编译器完全实现了所有特性。

有人认为 C++20 是 C++11 以来最大的一次改动,甚至比 C++11 还要大。本文仅介绍 C++20 四大特性当中的 Module 部分,分为三部分:

  • 探究 C++ 编译链接模型的由来以及利弊
  • 介绍 C++20 Module 机制的使用姿势
  • 总结 Module 背后的机制、利弊、以及各大编译器的支持情况

C++ 是兼容 C 的,不但兼容了 C 的语法,也兼容了 C 的编译链接模型。1973年初,C 语言基本定型:有了预处理、支持结构体;编译模型也基本定型为:预处理、编译、汇编、链接四个步骤并沿用至今;1973年,K&R 二人使用 C 语言重写了 Unix 内核。

为何要有预处理?为何要有头文件?在 C 诞生的年代,用来跑 C 编译器的计算机 PDP-11 的硬件配置是这样的:内存:64 KiB 硬盘:512 KiB。编译器无法把较大的源码文件放入狭小的内存,故当时 C 编译器的设计目标是能够支持模块化编译,即将源码分成多个源码文件、挨个编译,以生成多个目标文件,最后整合(链接)成一个可执行文件。

C 编译器分别编译多个源码文件的过程,实际上是一个 One pass compile 的过程,即:从头到尾扫描一遍源码、边扫描边生成目标文件、过眼即忘(以源码文件为单位)、后面的代码不会影响编译器前面的决策,该特性导致了 C 语言的以下特征:

  • 结构体必须先定义再使用,否则无法知道成员的类型以及偏移,就无法生成目标代码。
  • 局部变量先定义再使用,否则无法知道变量的类型以及在栈中的位置,且为了方便编译器管理栈空间,局部变量必须定义在语句块的开始处。
  • 外部变量只需要知道类型、名字(二者合起来便是声明)即可使用(生成目标代码),外部变量的实际地址由连接器填写。
  • 外部函数只需知道函数名、返回值、参数类型列表(函数声明)即可生成调用函数的目标代码,函数的实际地址由连接器填写。

头文件和预处理恰好满足了上述要求,头文件只需用少量的代码,声明好函数原型、结构体等信息,编译时将头文件展开到实现文件中,编译器即可完美执行 One pass comlile 过程了。

至此,我们看到的都是头文件的必要性和益处,当然,头文件也有很多负面影响:

  • 低效:头文件的本职工作是提供前置声明,而提供前置声明的方式采用了文本拷贝,文本拷贝过程不带有语法分析,会一股脑将需要的、不需要的声明全部拷贝到源文件中。
  • 传递性:最底层的头文件中宏、变量等实体的可见性,可以通过中间头文件“透传”给最上层的头文件,这种透传会带来很多麻烦。
  • 降低编译速度:加入 a.h 被三个模块包含,则 a 会被展开三次、编译三次。
  • 顺序相关:程序的行为受头文件的包含顺影响,也受是否包含某一个头文件影响,在 C++ 中尤为严重(重载)。
  • 不确定性:同一个头文件在不同的源文件中可能表现出不同的行为,导致这些不同的原因,可能源自源文件(比如该源文件包含的其他头文件、该源文件中定义的宏等),也可能源自编译选项。

C++20 中加入了 Module,我们先看 Module 的基本使用姿势,最后再总结 Module 比 头文件的优势。

Module(即模块)避免了传统头文件机制的诸多缺点,一个 Module 是一个独立的翻译单元,包含一个到多个 module interface file(即模块接口文件),包含 0 个到多个 module implementation file(即模块实现文件),使用 Import 关键字即可导入一个模块、使用这个模块暴露的方法。

实现一个最简单的 Module

module_hello.cppm:定义一个完整的hello模块,并导出一个 say_hello_to 方法给外部使用。当前各编译器并未规定模块接口文件的后缀,本文统一使用 ".cppm" 后缀名。".cppm" 文件有一个专用名称"模块接口文件",值得注意的是,该文件不光可以声明实体,也可定义实体。

图片

main 函数中可以直接使用 hello 模块:

图片

编译脚本如下,需要先编译 module_hello.cppm 生成一个 pcm 文件(Module 缓存文件),该文件包含了 hello 模块导出的符号。

图片

以上代码有以下细节需要注意:

  • module hello:声明了一个模块,前面加一个 export,则意味着当前文件是一个模块接口文件(module interface file),只有在模块接口文件中可以导出实体(变量、函数、类、namespace等)。一个模块至少有一个模块接口文件、模块接口文件可以只放实体声明,也可以放实体定义。
  • import hello:不需加尖括号,且不同于 include,import 后跟的不是文件名,而是模块名(文件名为 module_hello.cpp),编译器并未强制模块名必须与文件名一致。
  • 想要导出一个函数,在函数定义/声明前加一个 export 关键字即可。
  • Import 的模块不具有传递性。hello 模块包含了 string_view,但是 main 函数在使用 hello 模块前,依然需要再 import <string_view>; 。
  • 模块中的 Import 声明需要放在模块声明之后、模块内部其他实体声明之前,即:import <iostream>; 必须放在 export module hello; 之后,void internal_helper() 之前。
  • 编译时需要先编译基础的模块,再编译上层模块,buildfile.sh 中先将 module_hello 编译生成 pcm,再编译 main。

接口与实现分离

上个示例中,接口的声明与实现都在同一个文件中(.cppm中,准确地说,该文件中只有函数的实现,声明是由编译器自动生成、放到缓存文件pcm中),当模块的规模变大、接口变多之后,将所有的实体定义都放在模块接口文件中会非常不利于代码的维护,C++20 的模块机制还支持接口与实现分离。下面我们将接口的声明与实现分别放到 .cppm 和 .cpp 文件中。

module_hello.cppm:我们假设 say_hello_to、func_a、func_b 等接口十分复杂,.cppm 文件中只包含接口的声明(square 方法是个例外,它是函数模板,只能定义在 .cppm 中,不能分离式编译)。

图片

module_hello.cpp:给出 hello 模块的各个接口声明对应的实现。

图片

代码有几个细节需要注意:

  • 整个 hello 模块分成了 module_hello.cppm 和 module_hello.cpp 两个文件,前者是模块接口文件(module 声明前有 export 关键字),后者是模块实现文件(module implementation file)。当前各大编译器并未规定模块接口文件的后缀必须是 cppm。
  • 模块实现文件中不能 export 任何实体。
  • 函数模板,比如代码中的 square 函数,定义必须放在模块接口文件中,使用 auto 返回值的函数,定义也必须放在模块接口文件。

可见性控制

在模块最开始的例子中,我们就提到了模块的 Import 不具有传递性:main 函数使用 hello 模块的时候必须 import <string_view>,如果想让 hello 模块中的 string_view 模块暴露给使用者,需使用 export import 显式声明:

图片

hello 模块显式导出 string_view 后,main 文件中便无需再包含 string_view 了。

图片

子模块(Submodule)

当模块变得再大一些,仅仅是将模块的接口与实现拆分到两个文件也有点力不从心,模块实现文件会变得非常大,不便于代码的维护。C++20 的模块机制支持子模块。

这次 module_hello.cppm 文件不再定义、声明任何函数,而是仅仅显式导出 hello.sub_a、hello.sub_b 两个子模块,外部需要的方法都由上述两个子模块定义,module_hello.cppm 充当一个“汇总”的角色。

图片

子模块 module hello.sub_a 采用了接口与实现分离的定义方式:“.cppm” 中给出定义,“.cpp” 中给出实现。

图片

图片

module hello.sub_b 同上,不再赘述。

图片

图片

这样,hello 模块的接口和实现文件被拆分到了两个子模块中,每个子模块又有自己的接口文件、实现文件。

值得注意的是,C++20 的子模块是一种“模拟机制”,模块 hello.sub_b 是一个完整的模块,中间的点并不代表语法上的从属关系,不同于函数名、变量名等标识符的命名规则,模块的命名规则中允许点存在于模块名字当中,点只是从逻辑语义上帮助程序员理解模块间的逻辑关系。

Module Partition

除了子模块之外,处理复杂模块的机制还有 Module Partition。Module Partition 一直没想到一个贴切的中文翻译,或者可以翻译为模块分区,下文直接使用 Module Partition。Module Partition 分为两种:

  • module implementation partition
  • module interface partition

module implementation partition 可以通俗的理解为:将模块的实现文件拆分成多个。module_hello.cppm 文件:给出模块的声明、导出函数的声明。

图片

模块的一部分实现代码拆分到 module_hello_partition_internal.cpp 文件,该文件实现了一个内部方法 internal_helper。

图片

模块的另一部分实现拆分到 module_hello.cpp 文件,该文件实现了 func_a、func_b,同时引用了内部方法 internal_helper(func_a、func_b 当然也可以拆分到两个 cpp 文件中)。

图片

值得注意的是, 模块内部 Import 一个 module partition 时,不能 import hello:internal;而是直接import :internal; 。

module interface partition 可以理解为模块声明拆分到多个文件中。module implementation partition 的例子中,函数声明只集中在一个文件中,module interface partition 可以将这些声明拆分到多个接口文件。

首先定义一个内部 helper:internal_helper:

图片

hello 模块的 a 部分采用声明+定义合一的方式,定义在 module_hello_partition_a.cppm 中:

图片

hello 模块的 b 部分采用声明+定义分离的方式,module_hello_partition_b.cppm 只做声明:

图片

module_hello_partition_b.cpp 给出 hello 模块的 b 部分对应的实现:

图片

module_hello.cppm 再次充当了”汇总“的角色,将模块的 a 部分+ b 部分导出给外部使用:

图片

module implementation partition 的使用方式较为直观,相当于我们平时编程中“一个头文件声明多个 cpp 实现”这种情况。module interface partition 有点类似于 submodule 机制,但语法上有较多差异:

  • module_hello_partition_b.cpp 第一行不能使用 import hello:partition_b;虽然这样看上去更符合直觉,但是不允许。
  • 每个 module partition interface 最终必须被 primary module interface file 导出,不能遗漏。
  • primary module interface file 不能导出 module implementation file,只能导出 module interface file,故在 module_hello.cppm 中 export :internal; 是错误的。

同样作为处理大模块的机制,Module Partition 与子模块最本质的区别在于:子模块可以独立的被外部使用者 Import,而 Module Partition 只在模块内部可见。

全局模块片段

(Global module fragments)

C++20 之前有大量的不支持模块的代码、头文件,这些代码实际被隐式的当作全局模块片段处理,模块代码与这些片段交互方式如下:

图片

事实上,由于标准库的大多数头文件尚未模块化(VS 模块化了部分头文件),整个第二章的代码在当前编译器环境下(Clang12)是不能直接编译通过的——当前尚不能直接 import < iostream > 等模块,通全局模块段则可以进行方便的过渡(在全局模块片段直接 #include <iostream>),另一个过渡方案便是下一节所介绍的 Module Map——该机制可以使我们能够将旧的 iostream编译成一个 Module。

Module Map

Module Map 机制可以将普通的头文件映射成 Module,进而可以使旧的代码吃到 Module 机制的红利。下面便以 Clang13 中的 Module Map 机制为例:

假设有一个 a.h 头文件,该头文件历史较久,不支持 Module:

图片

通过给 Clang 编译器定义一个 module.modulemap 文件,在该文件中可以将头文件映射成模块:

图片

图片

编译脚本需要依次编译 A、ctype、iostream 三个模块,然后再编译 main 文件:

图片

首先使用 -fmodule-map-file 参数,指定一个 module map file,然后通过 -fmodule 指定 map file 中定义的 module,就可以将头文件编译成 pcm。main 文件使用 A、iostream 等模块时,同样需要使用 fmodule-map-file 参数指定 mdule map 文件,同时使用 -fmodule 指定依赖的模块名称。

注:关于 Module Map 机制能够查到的资料较少,有些细节笔者也未能一一查明,例如:

  • 通过 Module Map 将一个头文件模块化之后,头文件中暴露的宏会如何处理?
  • 假如头文件声明的实体的实现分散在多个 cpp 中,该如何组织编译?

Module 与 Namespace

Module 与 Namespace 是两个维度的概念,在 Module 中同样可以导出 Namespace:

图片

图片

总结

最后,对比最开始提到的头文件的缺点,模块机制有以下几点优势:

  • 无需重复编译:一个模块的所有接口文件、实现文件,作为一个翻译单元,一次编译后生成 pcm,之后遇到 Import 该模块的代码,编译器会从 pcm 中寻找函数声明等信息,该特性会极大加快 C++ 代码的编译速度。
  • 隔离性更好:模块内 Import 的内容,不会泄漏到模块外部,除非显式使用 export Import 声明。
  • 顺序无关:Import 多个模块,无需关心这些模块间的顺序。
  • 减少冗余与不一致:小的模块可以直接在单个 cppm 文件中完成实体的导出、定义,但大的模块依然会把声明、实现拆分到不同文件。
  • 子模块、Module Partition 等机制让大模块、超大模块的组织方式更加灵活。
  • 全局模块段、Module Map 制使得 Module 与老旧的头文件交互成为可能。

缺点也有:

  • 编译器支持不稳定:尚未有编译器完全支持 Module 的所有特性、Clang13 支持的 Module Map 特性不一定保留到主干版本。
  • 编译时需要分析依赖关系、先编译最基础的模块。
  • 现有的 C++ 工程需要重新组织 pipline,且尚未出现自动化的构建系统,需要人工根据依赖关系组构建脚本,实施难度巨大。

Module 不能做什么?

  • Module 不能实现代码的二进制分发,依然需要通过源码分发 Module。
  • pcm 文件不能通用,不同编译器的 pcm 文件不能通用,同一编译器不同参数的 pcm 不能通用。
  • 无法自动构建,现阶段需要人工组织构建脚本。

编译器如何实现对外隐藏 Module 内部符号的?

  • 在 Module 机制出现之前,符号的链接性分为外部连接性(external linkage,符号可在文件之间共享)、内部链接性(internal linkage,符号只能在文件内部使用),可以通过 extern、static 等关键字控制一个符号的链接性。
  • Module 机制引入了模块链接性(module linkage),符号可在整个模块内部共享(一个模块可能存在多个 partition 文件)。
  • 对于模块 export 的符号,编译器根据现有规则(外部连接性)对符号进行名称修饰(name mangling)。
  • 对于 Module 内部的符号,统一在符号名称前面添加 “_Zw” 名称修饰,这样链接器链接时便不会链接到内部符号。

截至2020.7,三大编译器对 Module 机制的支持情况:

图片

以上就是本文的全部内容,关于 C++20 的四大特性我们介绍了其一,在后续的文章中,我们也会陆续安排另外三大(concept、range、coroutine)的解读,也欢迎继续关注我们。文中内容难免会有疏漏与不足,欢迎留言与我们交流。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK