引言上一章我们聊到开发者为了业务逻辑划分和代码复用需要模块化代码。但随着现代 C 编程语言的演进现代 C 项目的规模越来越大即便是最佳实践方法在不牺牲编译性能的情况下也没有完全解决符号可见性和符号名称隔离的问题。如果从技术的本质来探究“模块”这个概念其实模块主要解决的就是符号的可见性问题。而控制符号可见性的灵活程度和粒度决定了一门编程语言能否很好地支持现代化、标准化和模块化的程序开发。一般模块技术需要实现以下几个必要特性。每个模块使用模块名称进行标识。模块可以不断划分为更多的子模块便于大规模代码组织。模块内部符号仅对模块内部可见对模块外部不可见。模块可以定义外部接口外部接口中的符号对模块外部可见。模块可以相互引用并调用被引用模块的外部接口也就是符号。我们在上一章中仔细讲解了 include 头文件机制虽然它在一定程度上解决了同一组件内相同符号定义冲突的符号可见性问题但头文件代码这种技术方式实现非常“低级”仍无法避免两个编译单元的重复符号的符号名称隔离问题。我们迫切需要一种更加现代的、为未来编程场景提供完备支持的解决方案。而今天的主角——C Modules在满足上述特性的基础上针对 C 的特性将提供一种解决符号隔离问题的全新思路。我们一起进入今天的学习看看如何使用 C Modules 解决旧世界的问题课程源代码https://github.com/samblg/cpp20-plus-indepth基本用法首先我们了解一下什么是 C Modules。作为一种共享符号声明与定义的技术C Modules 目的是替代头文件的一些使用场景也就是现代编程的模块化场景。前面说过模块解决的是模块之间符号的可见性控制问题不解决模块之间的符号名称隔离问题因此 C Modules 与 C 标准中的命名空间namespace在设计上是正交的不会产生冲突。与其他现代化编程语言不同C 包含一个预处理阶段来处理预处理指令然后生成每个编译单元的最终代码。因此 C Modules 的设计必须考虑如何处理预处理指令并在预处理阶段支持 C Modules。目前C Modules 支持通过 import 导入 C 的头文件并使用头文件中定义的预处理指令。总之在现代化的编程模式与编程习惯下如果我们采用了 C Modules基本可以完全抛弃 #include而且大部分场景下在不对遗留代码进行更改的情况下仍可以使用过去的头文件。了解了基本概念我们接着来看 C Modules 的具体细节包括模块声明、导入导出的方法、全局和私有模块的划分、模块分区以及所有权问题。这些细节是掌握 C Modules 的关键当然了也不是什么难题毕竟对于核心语言特性的变更和设计哲学来说“易用”是首要目标也是重中之重。从每一个设定的引入中你将看到如何通过 C Modules 提供的新特性方便地声明模块、引用模块、使用模块提供的接口并更好地组织模块代码学会使用新的特性替代传统的模块管理方式编写更易于维护的代码。模块声明在引入 C Modules 后编译单元会被分为“模块单元”和“普通单元”两种类型。普通单元除了可以有限引用“模块”以外和传统的 C 编译单元没有任何区别这也实现了对历史代码的向下兼容。只有“模块单元”才能用于定义模块并实现模块中的符号。如果想要将编译单元设置成“模块单元”需要在编译单元的源代码头部除了包含全局模块片段的情况下采用 module 关键字比如我们现在如果要声明一个“模块单元”属于模块 helloworld需要采用如下方式声明module helloworld;“模块单元”会被分为“模块接口单元Module Interface Unit”和“模块实现单元Module Implementation Unit”。模块接口单元用于定义模块的对外接口也就是控制哪些符号对外可见作用类似于传统方案中的头文件。模块实现单元用于实现模块接口模块中的符号作用类似于传统方案中与头文件配套的编译单元。模块单元默认是模块实现单元如果想要将模块单元定义成模块接口单元需要在 module 前添加 export 关键字export module helloworld;在构建过程中多个编译单元声明为同名的模块单元只要同名这些编译单元的符号也就内部相互可见也就是模块声明相同的编译单元都属于同一个模块。这里有个例外需要注意整个项目中每个模块只能有一个模块接口单元换言之模块接口单元的模块名称是不能重复的否则就会出错。导出声明与传统的编译单元不同一个模块单元中定义的符号对模块外部默认是不可见的。比如下面这段代码中我们定义的 private_hello 函数就是对模块外部不可见的。export module helloworld; void private_hello() { std::cout Hello world! std::endl; }如果想要定义对模块外部可见的函数我们需要使用 export 关键字比如下面这段代码中我们定义了一个对模块外部可见的 hello 函数。export module helloworld; void private_hello() { std::cout Hello world! std::endl; } export void hello() { std::cout Hello world! std::endl; }前面说过C Modules 与传统的命名空间namespace是保持正交设计的。因此我们可以在模块单元中导出命名空间。export module helloworld; export namespace hname { int32_t getNumber() { return 10; } }这里定义了一个对外可见的命名空间 hname包含一个 getNumber 函数。我们就可以在其他模块通过 hname::getNumber 调用这个函数也就是 hname::getNumber 这个符号对外部可见。不过你需要知道的是这样其实让该 namespace 中包含的所有符号对外可见了因此也可以这样编码。export module helloworld; namespace hname { export int32_t getNumber() { return 10; } }这和前面的代码是等价的只是如果 namespace 不标注 export我们可以在 namespace 内部通过 export 关键字更细粒度地标记符号的对外可见性因此在编码实践上一般不建议直接在 namespace 上使用 export当然特定场景除外比如定义一个 namespace 作为对外接口。导入模块我们可以在其他编译单元中通过 import 关键字导入模块而且无论是模块单元还是普通单元都可以导入模块比如编写 main.cpp使用了前面 helloworld 模块中定义的外部符号。#include iostream import helloworld; int main() { hello(); std::cout Hello hname::getNumber() std::endl; return 0; }关键字 import 导入模块实际其实是让被引用的模块中的符号对本编译单元可见也就是将被导入模块中的符号直接暴露在本编译单元中这就类似于传统 C 技术中的 using namespace。我们说过 C Modules 并不解决符号名称隔离问题也就是如果通过 import 导入了一个模块并且被导入模块中有符号与本编译单元可见符号的名称冲突了还是会产生命名空间污染。如果想避免污染就需要结合使用 namespace 进行编码。需要注意的是通过 import 导入的模块符号只在本编译单元可见其他的编译单元是无法使用被导入的模块符号的。同时如果模块 A 通过 import 导入了模块 B 的符号然后模块 B 通过 import 导入了模块 C 的符号模块 A 中是无法直接使用模块 C 的符号的。毕竟模块系统就是为了严格规范符号可见性。如果想要把通过 import 导入的符号对外导出就需要在 import 前加上 export 来将导入的模块中的符号全部对外导出。比如export import bye;就可以将 bye 模块的所有符号再对外导出。接下来我们看看怎样直接在 main.cpp 中使用函数 goodbye()。我们首先定义一个模块 bye编写 bye.cpp。export module bye; import iostream; export void goodbye() { std::cout Goodbye std::endl; }然后修改 helloworld.cpp 的定义。export module helloworld; export import bye; void private_hello() { std::cout Hello world! std::endl; } export void hello() { std::cout Hello world! std::endl; }最后编写 main.cpp。import helloworld; int main() { hello(); goodbye(); return 0; }由于模块 helloworld 导出了 bye 模块的符号我们可以在 main.cpp 中直接使用 bye 模块中的函数 goodbye()。导入头文件既然普通单元和模块单元都可以通过 import 导入模块那么普通单元和模块单元的 import 的区别是什么呢事实上最大的区别就是模块单元无法使用 #include 引入头文件必须要使用 import 导入头文件。比如说我们定义一个头文件 h1.h。#pragma once #define H1 (1)然后在 helloworld.cpp 中通过 import 引入这个头文件。export module helloworld; import iostream; import h1.h; export void hello() { std::cout Hello world! std::endl; std::cout Hello2 H1 std::endl; }我们就可以在 helloworld.cpp 中使用 h1.h 中定义的 H1 这个符号。发现了吗通过 import 导入头文件的兼容性是经过精心设计的从设计上来说你依然可以认为 import 头文件是简单的文本操作也就是将头文件的文本复制到编译单元中。所以我们可以利用头文件的这种特性。比如编写一个 h2.h。#pragma once #define H2 (H1 1)然后修改一下 helloworld.cpp通过 import 导入这个新的头文件。export module helloworld; import iostream; import h1.h; import h2.h; export void hello() { std::cout Hello world! std::endl; std::cout Hello2 H1 std::endl; std::cout Hello2 H2 std::endl; }可以看到这里引用 h2.h 中的 H2而 h2.h 中也使用了 h1.h 中的 H1。以此得知通过 import 导入头文件依然可以实现原本预处理指令的效果这是因为 C Modules 也规定了在预处理阶段对 import 的处理要求所以 import 在预处理和编译阶段都会有对应的效果。虽然我们可以通过 import 来导入头文件但是 import 和以前的 #include 还是存在区别的。区别就是通过 import 导入头文件的编译单元定义的预处理宏是无法被 import 导入的文件访问的比如这样的代码就会出现编译错误。export module helloworld; import iostream; #define H1 (1) import h2.h; export void hello() { std::cout Hello world! std::endl; std::cout Hello2 H1 std::endl; std::cout Hello2 H2 std::endl; }这是因为 H1 是在编译单元中定义的而编译单元本身是一个模块单元因此 h2.h 中无法访问到这个编译单元中定义的 H1。但是在传统的 C/C 代码中很多头文件经常会要求用户通过定义预定义宏进行配置比如这段代码就会影响头文件的行为。#define _POSIX_C_SOURCE 200809L #include stdlib.h那么在新的模块单元中我们要如何实现这种特性呢这就需要“模块片段”来帮忙。模块片段模块片段又可以分为全局模块片段和私有模块片段。对于前面的问题我们需要的是全局模块片段。全局模块片段全局模块片段global module fragment是实现向下兼容性的关键特性当无法通过 import 导入传统的头文件实现 #include 指令的效果时就要使用全局模块片段来导入头文件。全局模块片段是一个模块单元的一部分需要定义在模块单元的模块声明之前声明语法如下。module; 预处理指令 模块声明如果需要在模块单元中定义全局模块片段文件必须以 modules; 声明开头表示这是一个模块单元的全局模块片段接着就是全局模块片段的定义内容只能包含预处理指令编写完模块片段定义之后需要加上模块单元的模块声明也就是 export module 或 module 声明。比如我们可以修改一下前文中有问题的 helloworld.cpp解决无法通过 import 导入头文件的问题。module; #define H1 (1) #include h2.h export module helloworld; import iostream; export void hello() { std::cout Hello world! std::endl; std::cout Hello2 H1 std::endl; std::cout Hello2 H2 std::endl; }在全局模块片段中先定义了宏 H1然后再通过 #include 而非 import 包含头文件 h2.h这样 h2.h 就会以传统的预处理模式被包含在本模块单元内这样我们就可以在模块单元中使用 h2.h 中的宏 H2 了。私有模块片段除了可以在模块单元的模块定义前添加全局模块片段在接口模块单元的模块单元接口定义后我们还可以定义私有模块片段作为模块的内部实现。如果我们想要编写一个单文件模块就可以采用这个特性。在模块接口单元中定义“接口”部分和“实现”部分也就是在模块单元定义中编写接口在私有模块片段内编写实现。我们修改一下之前的 helloworld.cpp在代码尾部添加私有模块片段如下所示export module helloworld; import iostream; export void hello() module : private; void hiddenHello(); void hello() { std::cout Hello world! std::endl; std::cout Hello2 H1 std::endl; std::cout Hello2 H2 std::endl; hiddenHello(); } void hiddenHello() { std::cout Hidden Hello! std::endl; }私有代码片段需要使用 module : private 标识然后定义我们需要实现的代码。在私有代码片段中定义了函数 hello() 和 hiddenHello()并在模块单元代码中通过 export 导出这个符号。这里由于函数 hiddenHello() 定义在了函数 hello() 之后因此需要在 hello 之前前置声明。所以 module : private 就是提供了一种在单文件模块中标记接口部分和实现部分的手段由于我们可能更倾向于使用模块接口单元和模块实现单元来组织模块因此这种方式可能是使用较少的。模块分区模块的一个关键特性是可以划分为更多的子模块。在 C Modules 中子模块主要有两种实现方式通过模块名称进行区分、利用模块分区特性。先看第一个方式通过模块名称进行区分。C Modules 的模块名称除了可以使用 C 标识符字符以外还可以使用“.”这个符号比如有一个名为 utils 的模块如果需要定义一个 utils 中的图像处理子模块 image可以声明一个名为 utils.image 的模块将其作为 utils 的子模块。这种子模块的模块名组织方式和其他现代编程语言更类似所以使用起来也很简单易懂。但这种方式存在一个问题C 中并没有提供标注两个模块隶属关系的方法所以子模块和父模块之间其实没有什么隶属关系本质上通过这种方法进行模块分层只是一种基于名称的约定父模块使用子模块和其他模块使用子模块没区别。因此有了第二种方式C Modules 提供“模块分区”作为一种划分子模块的方法。模块分区的声明方法是将一个模块单元的名称命名为“模块名: 分区名”如果我们需要定义一个 helloworld 的分区 B可以创建一个名为 helloworld_b.cpp 的文件并在文件开头使用如下方式声明模块。module helloworld:B;然后就可以像其他的模块单元一样定义模块的内容比如定义一个函数 helloworldB完整代码如下所示。export module helloworld:B; import iostream; void helloworldB() { std::cout HelloworldB std::endl; }模块分区单元也可以分为“模块分区接口单元”和“模块分区实现单元”模块分区接口单元也就是在模块声明前追加 export 关键词。比如我们定义 helloworld 的分区 A文件名是 helloworld_a.cpp。export module helloworld:A; export void helloworldA(); import iostream; void helloworldA() { std::cout HelloworldA std::endl; }接下来就可以在 helloworld 模块中通过 import 导入这两个分区并调用这两个函数。export module helloworld; import iostream; export import :A; import :B; void hello() { std::cout Hello world! std::endl; helloworldA(); helloworldB(); }在模块中通过 import 导入分区的时候需要直接指定分区名称不需要指定模块名称这样就可以导入本模块的不同分区。分区导入到本模块后分区内部的符号也就对整个模块可见了因此分区内部是否将符号标识为 export并不影响分区内部符号对模块内部的可见性。那么模块分区内部的 export 有什么作用呢作用是允许“模块接口单元”通过 export来控制是否将一个分区内导入的符号导出给其他模块有两种方法。在主模块的模块接口单元通过 export import 导入分区分区内标识为 export 的符号就对其他模块可见。在主模块中通过 import 导入分区并在主模块的模块接口单元中通过 export 声明需要导出的符号。第一种方式比较简单方便第二种方式的控制粒度比较细各有优劣需要我们在实际应用中根据实际情况选择处理方案。使用模块分区后有一个很重要的特点模块分区单元中的符号必须通过主模块的接口单元控制对外可见性因为一个模块无法通过 import 导入一个模块的分区。这就为模块的开发者提供了控制子模块符号可见性的有效工具。模块所有权我们在使用模块的时候需要注意符号声明的所有权问题这个会影响两个方面一个是符号的实现位置另一个是符号的“链接性linkage”。在模块单元的模块声明中出现的符号声明属于attached这个模块。所有属于一个模块的符号声明必须在这个模块的编译单元内实现我们不能在模块之外的编译单元中实现这些符号。模块所有权也会引发符号的链接性发生变化。在传统的 C 中链接性分为无链接性no linkage、内部链接性internal linkage、外部链接性external linkage。无链接性的符号只能在其声明作用域中使用。内部链接性的符号可以在声明的编译单元内使用。外部链接性的符号可以在其他的编译单元使用。在 C 支持 Modules 之后新增了一种链接性叫做模块链接性module linkage。所有从属于模块而且没有通过 export 标记导出的符号就具备这种链接性。具备模块链接性的符号可以在属于这个模块的编译单元中使用。模块中的符号如果满足下面两种情况就不属于声明所在模块。具备外部链接性的 namespace。使用“链接性指示符”修改符号的链接性。我们用一段非常简单的代码来展示一下。export module lib; namespace hello { extern C int32_t f(); extern C int32_t g(); int32_t x(); export int32_t z(); }定义模块 lib包含了 5 个符号分别是命名空间 hello、函数 hello::f、hello::g、hello::x 和 hello::z我们逐一分析一下这些符号的链接性与所有权。hello 是命名空间所以不从属于模块 lib。函数 hello::f 使用了 extern “C”指示符说明这个符号是外部链接性并采用 C 的方式生成符号所以不从属于模块 lib。函数 hello::g 使用了 extern “C”指示符说明这个符号是外部链接性但采用了 C 的方式生成符号所以不从属于模块 lib。函数 hello::x 是属于模块 lib 的符号只不过符号本身是模块链接性只能被相同模块的编译单元引用。函数 hello::z 是属于模块 lib 的符号并且添加了 export因此符号是外部链接性可以被其他模块的编译单元引用。其中 hello、f、g 都不从属于模块 lib因此这些符号都可以在其他模块中实现而 x 和 z 只能在模块 lib 中实现。总结使用 C Modules我们可以切实有效地提升构建性能从语言层面这不仅是为我们开发者提供了规范的模块化工具更是解决了一个鱼与熊掌不可兼得的关键问题即传统头文件编译范式在编译性能和符号隔离之间二选一的难题。这里我们对 Modules 的基础概念简单总结一下使用 module 声明可以将编译单元设置为模块单元如果声明前包含 export 则为模块接口单元否则就是模块实现单元。一个模块只能包含一个模块接口单元。在 module 中声明的符号默认具有模块链接性只能在模块内部使用可以通过 export 将符号设置为对其他模块可见。使用 import可以将其他模块的符号引入到一个模块中被引用模块的符号对本编译单元可见。也可以使用 import 导入传统头文件相对于 #include 会有一些限制。模块支持定义分区。模块分区只能被本模块导入不能被其他模块导入。模块分区内符号对其他模块的可见性需要通过主模块的接口模块控制。在模块单元中通过 modules; 定义全局模块片段通过 module : private; 定义私有模块片段可以在特定场景中使用这些特性解决问题。模块中声明的符号归属权一般是模块本身只能在相同模块实现。但是具备外部链接性的 namespace 和采用“链接性指示符”修改了链接性的符号是例外。下一讲我们将学习如何使用 C Modules 来组织实际的项目代码敬请期待