C++20 Modules 杂谈:解释、效果、实践与 TODO
- 构建系统
- C++20 Modules 能减少多少编译时间?
- C++20 Modules 和 PCH 是等价的吗?区别是什么?
- C++20 Modules 能减少代码体积吗?为什么?
- 我们现在可以使用 C++20 Modules 编程吗?
- Modules Wrapper
- C++20 Modules 是怎么减少编译时间的?
- 修改接口文件导致的重编译问题
- Module Implementation Partition Unit 的其他用途
- 非传递式改变
- 尽量避免在不同 TU 中存放相同的声明
- Modules 对代码体积的影响
- Modules 改造过程中遇到的运行时问题
- Modules 中的前置声明问题
- TODO?
C++20 Modules 对于提升代码模块性、增强程序封装性、提示编译速度、降低库代码体积等方面都有帮助。因此 C++20 Modules 自诞生之初便受到很多人的期待。然而,在 2019 年定稿的特性直至 2025 后半年的今日依然没有得到广泛应用,这并不令人满意。本文从我们在 Modules 中的实战经验出发,分享下开发和应用 Modules 过程中的发现和想法。希望对 C++20 Modules 感兴趣的朋友能有帮助。
我对 Clang 的实现比较熟悉且只在 Linux 环境下工作,没有特殊说明时,本文中描述的环境应均为 Linux + Clang。对于 Windows 环境及 GCC 相关的信息,我基本没有验证过,可能由于记忆错误或信息过时导致不符合最新的事实。
关于 Modules 的基本知识,可以参考 background and terminology。
为方便阅读,本文会按照 high level 向 low level 的方向进行描述。
构建系统
我在工作中使用的构建系统是我们在下游修改的 Bazel。我们正尝试将该实现 贡献 给 bazel 社区。我用 CMake with C++20 Modules 写过一些小例子,但我并没有怎么严肃的用过 CMake with C++20 Modules。所以本文不会涉及 CMake。不过在我的记忆里,似乎 CMake 是不少人使用 C++20 Modules 的 Blocking issue。印象最深的是 Boost 的 Modules 实验,其中提到的 blocking issue 之一便是 CMake。此外 XMake 和 Build2 也提供了 C++20 Modules 支持且这两个项目都有 C++20 Modules 的重度用户。所以我对他们的印象也不错。另外还有 HMake 也声称对 C++20 Modules 做了非常好的实现,同时也野心勃勃的提了很多计划,不过我时间有限了解不足,感兴趣的朋友们可以看看,可能确实有新的机会。
C++20 Modules 能减少多少编译时间?
我实践中得到的数据为 25% ~ 45%,剔除了包括标准库在内的三方库的构建时间。
在网络上这个数字变化较大,印象中最夸张的数字是 Modules 化改造使项目编译速度提升了 26 倍。这应该是随着 Modules 化改造对项目进行了大规模重构的结果。此外如果项目中使用了较多模版元编程且使用 Modules 存储 constexpr 变量的值的话,项目编译速度可以简单提升上千倍,当然我们一般不讨论这种情况。除了这些比较夸张的说法之外,对于 C++20 Modules 编译提速的汇报大多在 10%~50% 之间。
也存在反馈项目在 Modules 改造后编译速度显著下降的汇报。这应该是由于 1. 编译并行度下降,以及 2. Module Units 间存在较多重复声明导致。(见下文)当然也有可能是编译器实现存在缺陷。
C++20 Modules 和 PCH 是等价的吗?区别是什么?
C++20 Modules 和 PCH 不是一回事。与 PCH 相比,C++20 Modules 存在自己的语义。C++20 Modules 的 Interface 文件是一个正常的 Translation Unit,可以产出 Object Files。这让 C++20 Modules 有更高的编译加速上限且可以生成更高效的代码。
C++20 Modules 能减少代码体积吗?为什么?
C++20 Modules 可以减少 object files
(.o),静态库 (.a) 与动态库(.so)产物的体积。但对于最终的可执行文件,我们没有观察到显著的体积差异。
在实践中,对构建目录下所有动态库产物 (.so) 的体积进行计算,我们发现进行 Modules 化之后所有动态库产物 (.so) 的体积之和下降了 12%。
原因为 C++20 Modules 的 Module Interfaces 文件本身也是一个正常的 Translation Unit,可以产出 object files
(.o),从而避免了相同的代码在不同的 Translation Unit 间重复生成的问题。
我们现在可以使用 C++20 Modules 编程吗?
可以的。在 Linux + Clang 环境下 C++20 Modules 是可用的。Windows 环境下 MSVC 也有例子说明 C++20 Modules 是可用的。我暂时还没听闻 GCC 的 C++20 Modules 在非玩具项目下的案例。
但代价是什么呢?
最重要的代价来自于对已有代码进行重构。所以如果你要开启一个新项目或者几乎全新的项目,这是最合适使用 C++20 Modules 的机会。需要注意当一个项目使用 C++20 Modules 后,大多数情况下,这个项目的下游也必须使用 C++20 Modules。这意味着对于绝大多数库而言,如果希望下游用户依然可以使用头文件,那么这些库基本只能提供 Modules 而非使用 Modules。后文会再讨论这一情形。
其次的代价来于编译器。编译器的崩溃总是令人沮丧。据我所知,Clang、GCC 和 MSVC 的 C++20 Modules 之前都因为持续的编译器内部崩溃问题而广受诟病。不过仅就 Clang 而言,我体感最近 Modules 的 Issue Report 数量已经低于 Coroutines 了!(虽然这还是令人羞愧)
再然后被提的最多的便是代码智能提示了。就我的场景而言,clangd 里现在的 experimental modules support 已经可以正常工作了。我之前本地遇到的问题几乎都是 compilation database 不正确。另外由于 clangd 的 C++20 Modules 支持其实比较简单,逻辑大多在 clang-tools-extra/clangd/ModulesBuilder.cpp
中,是很基础的文件管理。我很鼓励有需求的朋友们可以自己尝试做一做。
最后我印象里的就是编译器间/平台间的不一致行为了。例如上文提到的 Boost 的 Modules 实验的另一个 Blocking Issue 便是 MSVC 和 Clang 的一个行为差异。此外还有 clang-cl 的问题以及 CMake 在不同的环境下找不到 std module 的问题。
能使用 C++20 Modules 编程的场合
- 项目没有下游用户(或此项目的所有下游用户均已使用你提供的 Modules!)(或你只是不再关心他们了)
- 项目已更新到最新的编译器与语言标准(至少
-std=c++23
) - 项目对跨编译器与跨平台没有强需求 (需要调查目前的兼容性,我没有体感)
此时使用 C++20 Modules 的代价正比于项目的复杂度。作为参考,对一个有 4500 个 C++ 文件(包含源文件和头文件),共计一百万行代码(忽略注释和空行)的项目,我花了约两个月时间将其完全改造为使用 Modules 的版本。改造过程中我使用了我编写的改造工具进行辅助。
Modules Wrapper
如果一个项目的某些下游用户想要使用 C++20 Modules 而其他用户依然希望使用头文件。此时这个项目可以通过 Modules Wrapper 的形式向下游提供可选的 Modules 而不必要求对此不关心的下游用户做出任何更改。这也是目前大多数支持 Modules 的库所采取的方式。
我们可以在 arewemodulesyet 中看到很多这样的库。如果你知道某个库提供了 Modules 却不在这个网站中被记录,请你提交一个 PR 来更新它。
所谓的 Modules Wrapper 常见的有以下两种形式:
export-using style:
module;
#include "header_1.h"
#include "header_2.h"
...
#include "header_n.h"
export module your_library;
export namespace your_namespace {
using decl_1;
using decl_2;
...
using decl_n;
}
与 extern C++ style:
module;
#include "third_party/A/headers.h"
#include "third_party/B/headers.h"
... // Important: **ALL** the 3rd party library headers including standard headers
#include "third_party/Z/headers.h"
export module your_library;
#define IN_MODULE_INTERFACE
extern "C++" {
#include "header_1.h"
#include "header_2.h"
...
#include "header_n.h"
}
同时定义 Macro:
#ifdef IN_MODULE_INTERFACE
#define EXPORT export
#else
#define EXPORT
#endif
同时建议对 extern "C++"
中的所有文件,选择性的将三方库头文件剔除:
#ifndef IN_MODULE_INTERFACE
#include "third_party/A/headers.h"
#endif
#include "header_x.h"
...
这对于 debug 很有帮助。
export-using style 很简单而由于编译器内部实现机制的原因,extern C++ style 会有更好的编译时性能。
我们希望所有库都提供对应的 Modules。在理想情况下,C++ 世界的 Modules 化历程应为:
- 自顶向下地每个项目提供 Modules (从 std module 开始)
- 之后自底向上地每个项目使用 Modules。
需要注意,当你的三方库没有提供 Modules,你可以在自己的项目中自己 Mock 一个,哪怕对于 std module 也可以这么做。
C++20 Modules 是怎么减少编译时间的?
简单的说,编译过程可以分为前端和中端以及后端。在前端编译器会进行语言相关的预处理、语义分析以及中端代码生成。在中端,编译器会进行语言无关架构无关的优化。在后端,编译器会进行架构相关的优化与代码生成。
对于重模版元编程和编译期计算的项目而言,前端在模版展开以及编译期计算的开销会比较多。而对于其他没那么 “C++” 的 C++ 项目而言,特别是开启优化时,中后端的时间会占大头。
前端和编译期计算
对于编译期计算,C++20 Modules 可以起到非常好的 cache 作用。例如:
export module Fibonacci.Cache;
export namespace Fibonacci
{
...
template<unsigned long N>
constexpr unsigned long Cache = Fibonacci<N>();
template constexpr unsigned long Cache<30ul>;
}
显而易见地,这种情况下使用 Modules 可以得到非常巨大的编译速度提升。
而对于模版展开而言,Modules 也在编译器内部起到 Cache 的作用,例如对于这个例子:
// a.cpp
#include <vector>
#include <string>
#include <iostream>
int main() {
std::vector<std::string> vec = {"hello", "world"};
std::cout << vec[0] << " " << vec[1] <<"\n";
}
我们使用以下命令编译:
$ time clang++ -std=c++23 a.cpp -ftime-trace=a.json -c
real 0m0.516s
user 0m0.490s
sys 0m0.023s
然后我们 mock 一个 std module 并在其中实例化 std::vector<std::string>
:
// a.cppm
module;
#include <vector>
#include <string>
#include <iostream>
export module a;
export namespace std {
using std::vector;
using std::string;
using std::cout;
using std::operator<<;
}
std::vector<std::string> unused = {"hello", "world"};
然后我们用相同的逻辑:
// a.cc
import a;
int main() {
std::vector<std::string> vec = {"hello", "world"};
std::cout << vec[0] << " " << vec[1] <<"\n";
}
让我们编译:
$ clang++ -std=c++23 a.cppm --precompile -o a.pcm
$ time clang++ -std=c++23 a.cc -ftime-trace=a.imported.json -fmodule-file=a=a.pcm -c
real 0m0.077s
user 0m0.063s
sys 0m0.013s
编译速度提高了 7.7 倍。那模版实例化时间呢?
使用头文件的版本为
$jq '.traceEvents[] | select(.name | IN("Total InstantiateClass", "Total InstantiateFunction"))' a.json
{
"pid": 41756,
"tid": 41763,
"ph": "X",
"ts": 0,
"dur": 54859,
"name": "Total InstantiateClass",
"args": {
"count": 246,
"avg ms": 0
}
}
{
"pid": 41756,
"tid": 41764,
"ph": "X",
"ts": 0,
"dur": 50708,
"name": "Total InstantiateFunction",
"args": {
"count": 109,
"avg ms": 0
}
}
(没有 jq 时可以用 chrome://tracing/ 加载数据)
而使用 Modules 的版本为:
$jq '.traceEvents[] | select(.name | IN("Total InstantiateClass", "Total InstantiateFunction"))' a.imported.json
{
"pid": 41816,
"tid": 41827,
"ph": "X",
"ts": 0,
"dur": 2596,
"name": "Total InstantiateClass",
"args": {
"count": 6,
"avg ms": 0
}
}
{
"pid": 41816,
"tid": 41830,
"ph": "X",
"ts": 0,
"dur": 1510,
"name": "Total InstantiateFunction",
"args": {
"count": 3,
"avg ms": 0
}
}
我们可以看到 Modules 下的实例化时间相比头文件下降了 20 倍有余!
当然这里的 a.cppm
的实现略显做作。但在实际中,只需要 Modules 中自然的编程,类似的现象也自然会发生。
这里需要注意的是,由于Clang 目前并不会对 constexpr/consteval 函数做 Cache 同时 Clang 在 constexpr/consteval 混用时存在 defect,所以不要认为使用了 Modules 后就可以大幅减少此类时间。但同时若 Clang 修复了这些问题的话,想必 Modules 在编译期计算上的加速威力会更大。
中后端
对于非 inline linkage
的函数 (可简单认为是 inline functions 加隐式实例化的函数),Modules 可以避免其在不同 unit 间在中后端反复优化编译的开销。
例如:
// a.cppm
export module a;
export int a() { ... }
// a.cc
import a;
int aa() {
return a();
}
我们在编译 a.cc
时,函数 a()
的实现完全不会参与。这可以节约很多时间。
C++ 语言侧相关的一个改动是,在 module purview 内,在类里定义的函数不再是隐式 inline 的了。例如:
// a.cppm
export module a;
export class A {
public:
int a() { ... }
};
// a.cc
import a;
int aa() {
A a;
return a.a();
}
上述代码中的 A::a()
的实现依然不会参与到 a.cc
的编译中。
这个行为的一个影响是,与原先一对一映射回去的头文件模型相比,编译器失去了在 a.cc
中 inline
a()
函数进行优化的机会。
这一点在社区中存在争议。有多人反对这个行为,因为这影响了性能。在实践中,我们通过 thinLTO
来补足这一点,在开启 thinLTO
的情况下我们没有发现可观测的性能下降。(我们甚至发现了几次性能略微上升的 case,原因不明,可能和代码布局的变化有关)。在行为规范上我在 WG21 中讨论了 Should we import function bodies to get the better optimizations?,结论是为了更好的 ABI Boundary,WG21 建议目前的行为作为标准行为。
但考虑到确实有多人多次反对,后续应考虑在 clang 中增加一个选项以支持该行为。但需要注意,朴素地在前端代码生成时导入来自其他 module 的函数体并不是好实现。这将导致在中端的编译复杂度近似为 O(N^2)
。我们应考虑在 BMI 中嵌入优化后的 LLVM IR,在中端优化时避免反复优化这些优化后的 LLVM IR。这些 LLVM IR 只用于 IPO (Inter procedural optimization)。
Modules 对编译速度带来的负面影响
Modules 的引入在编译过程中引入了额外的序列化和反序列化开销。序列化开销和反序列化开销预期都是比较低的,特别是反序列化开销。如果你发现编译器的反序列化开销或序列化开销过高,这可能是一个编译器的 defect。
除开编译器内部实现细节外,在目前的设计上 Modules 可能会引入两个反面影响:
- Scanning 时间。
- 降低编译并发度。
Scanning 是目前构建系统(由 CMake 设计)与编译器交互的一种方式,指在编译开始之前对文件进行一遍预处理以分析该文件所提供与需要的 module units,开销约等于一次预处理的时间。必要性可参考 CMake 的文章与演讲。虽然我目前在社区没看到有人抱怨这种方式,但在我们内部,特别可能由于 bazel 的沙盒机制,我们能明显看到 Scanning 的开销,这不是我们期望的,为了缓解这个问题,我们内部实现了 fast scanning 机制。即不做预处理,直接对源文件进行简单字符串处理(类似 grep)来获取该文件所提供和需要的 module units 信息。这个操作的假设是没有任何 import declaration
和 module declaration
位于 #include
文件中。如果 import declaration
位于 #ifdef
中,fast scanning 会增加不必要的依赖但不会丢失依赖。这个机制目前运行得很好,如果社区感兴趣,我们后续会尝试将它贡献出来。
降低编译并发度的问题可以这么理解,假设我们的项目只有 32 个源文件和 100 个头文件,每个源文件都会 include 所有头文件,头文件之间线性链时依赖。那么如果我们将这个项目按照 header:module interface unit = 1:1 的方式进行 modules 化改造,可以预期在一台核数足够的机器上 modules 化后的编译速度应低于 modules 化之前。这个问题在文件数足够多,远大于核数的项目中不明显。
修改接口文件导致的重编译问题
我们一开始进行 Modules 化改造时,我们选择像名字暗示的那样,使用 Module Interface Unit 替换头文件,使用 Module Implementation Unit 替换源文件。然而随着改造过程的推进,我们发现为了解决前置声明, Partition 是必要的。而在启用 Partition 之后,Module Implementation Unit 变得非常尴尬。因为 Primary Module Interface Unit 会直接或间接 import 此 module 中的所有 module interface unit,而 module implementation unit 会隐式地 import primary module interface unit。这导致我们对于 module interface unit 的所有更改都会导致此 module 的所有 module implementation unit 重编译。这是不可接受的。
为了缓解此问题,我们发现可以通过 module implementation partition unit 解决此问题。Module implementation partition unit 的语法为:
module module.name:partition_impl.name;
同一 module 内的其他 module unit 可以 import Module implementation partition unit。但可以 import 不意味着必须 import。如果我们将 Module implementation partition unit 当作以前的源文件来实现这个 module 的 interfaces。那么我们就可以实现非常细粒度的依赖控制,避免了无谓的重编译问题。
不过略显可惜的是,在 CMake 中实现这个 pattern 依然需要将 Module implementation partition unit 放入 CXX_MODULES
中。这将使得用户付出额外的序列化代价。详见:[C++20 Modules] We should allow implementation partition unit to not be in CXX_MODULES FILES
Module Implementation Partition Unit 的其他用途
除了作为源文件之外,我们发现 Module Implementation Partition Unit 另一个用途是作为 只在 Module 内部 使用的接口,正如其名字所暗示的一样。例如像测试目录里的头文件,亦或者是划分 include
、srcs
的项目里 srcs
里的头文件,都应该使用 Module Implementation Partition Unit 来做替换。
在这种情况下,我们应该规定不可在 module interface unit 中 import Module Implementation Partition Unit。这样的话我们看代码的时候就可以对 interface 与实现细节有非常清晰的感知。
非传递式改变
Clang 中实现了 Non-Cascading Changes
。意在通过 Module 的封装性来打断更改在依赖链上的传递。例如对于:
export module a;
export int a() { return 43; }
若我们将 43
修改为 44
或其他值,module a
的任何用户理想情况上都可以安全地避免重编译。
同时类似地
export module b;
import a;
无论我们对 a
做任何改动,对于所有 b
的直接用户(但非 a
的直接用户),在理想情况下都可以安全地避免重编译。
为了达到这样的效果,Clang 实现了 Non-Cascading Changes
。即对于一个 BMI,它的哈希值可以表示它对外暴露的所有接口的签名。即对于构建系统而言,构建系统可以不需要考虑一个文件非间接 import unit。这个改动对构建系统是非常容易的。我们在下游的 bazel 里已经实现了并稳定运行了此功能很长一段时间了。其他构建系统可以考虑跟进这个改动。
尽量避免在不同 TU 中存放相同的声明
由于 Clang 的实现限制,虽然往往 Clang 会接受这种代码,但 Clang 在处理不同 TU 中的相同声明时效率相对会低非常多。例如:
module;
#include <vector>
export module a;
std::vector<int> va;
module;
#include <vector>
export module b;
std::vector<int> vb;
// c.cppm
module;
#include <vector>
export module c;
import a;
import b;
...
上面代码的编译速度应慢于
export module a;
import std;
std::vector<int> va;
export module b;
import std;
std::vector<int> vb;
// c.cppm
export module c;
import a;
import b;
import std;
...
因为之前的代码在 module a, module b 和 module c 中存在重复的代码,编译器处理这些重复的代码的效率很低,同时编译器生成的 BMI 与 object files 也可能因此包含冗余的内容。在 clang 中,我们提供了 -Wdecls-in-multiple-modules
选项来检查这种情况。这个 warning 哪怕在 -Wall
下也是默认关闭的,因为这些代码事实上并不违反标准。
这也是我们强烈推荐 C++20 modules 用户一定要使用 std module 的原因。哪怕因为各种各样原因无法使用标准的 std module,我们也建议用户自己 mock 一个。
Modules 对代码体积的影响
上面提到的 Modules 会避免重复生成非 inline linkage
的函数体是 modules 节约代码体积的主要原因之一。除此之外,C++20 Modules 的设计对于节约 Debug 体积也有帮助。例如上面相同的例子:
// a.cpp
#include <vector>
#include <string>
#include <iostream>
int main() {
std::vector<std::string> vec = {"hello", "world"};
std::cout << vec[0] << " " << vec[1] <<"\n";
}
我们使用以下命令编译:
$ clang++ -std=c++23 a.cpp -c -g -o a.o
$ du -sh a.o
164K a.o
$ readelf -S a.o | grep '.debug'
[278] .debug_abbrev PROGBITS 0000000000000000 000018b8
[279] .debug_info PROGBITS 0000000000000000 00002053
[280] .rela.debug_info RELA 0000000000000000 00018810
[281] .debug_rnglists PROGBITS 0000000000000000 00007944
[282] .debug_str_offset PROGBITS 0000000000000000 00007a82
[283] .rela.debug_str_o RELA 0000000000000000 00018888
[284] .debug_str PROGBITS 0000000000000000 00008a86
[285] .debug_addr PROGBITS 0000000000000000 000130c5
[286] .rela.debug_addr RELA 0000000000000000 0001e870
[294] .debug_line PROGBITS 0000000000000000 00014170
[295] .rela.debug_line RELA 0000000000000000 0001fce0
[296] .debug_line_str PROGBITS 0000000000000000 0001545a
然后对于使用 modules 版本:
// a.cppm
module;
#include <vector>
#include <string>
#include <iostream>
export module a;
export namespace std {
using std::vector;
using std::string;
using std::cout;
using std::operator<<;
}
std::vector<std::string> unused = {"hello", "world"};
// a.cc
import a;
int main() {
std::vector<std::string> vec = {"hello", "world"};
std::cout << vec[0] << " " << vec[1] <<"\n";
}
$ clang++ -std=c++23 a.cc -g -fmodule-file=a=a.pcm -c -o a.imported.o
$ du -sh a.imported.o
144K a.imported.o
$readelf -S a.imported.o | grep '.debug'
[278] .debug_abbrev PROGBITS 0000000000000000 000018f8
[279] .debug_info PROGBITS 0000000000000000 00001f5e
[280] .rela.debug_info RELA 0000000000000000 00015cb8
[281] .debug_rnglists PROGBITS 0000000000000000 00005f98
[282] .debug_str_offset PROGBITS 0000000000000000 000060d1
[283] .rela.debug_str_o RELA 0000000000000000 00015d30
[284] .debug_str PROGBITS 0000000000000000 00006c6d
[285] .debug_addr PROGBITS 0000000000000000 00010870
[286] .rela.debug_addr RELA 0000000000000000 0001a2a8
[294] .debug_line PROGBITS 0000000000000000 000118f0
[295] .rela.debug_line RELA 0000000000000000 0001b6e8
[296] .debug_line_str PROGBITS 0000000000000000 00012b29
可以看到相比 a.o
, a.imported.o
的体积下降了 12%,同时可以看到 debug 各个段的体积均有所下降。
Modules 改造过程中遇到的运行时问题
我在 Modules 改造过程中遇到的运行时问题可以分为 ODR Violation 问题以及原头文件中的 internal linkage 变量问题。
ODR 指 One Definition Rule,指 C++ 中一个声明只应该有一个定义的规则。这个规则在实践中往往会因为重名或者项目(很可能是间接地)对不同版本的同一个库进行了依赖。出现 ODR Violation 的程序被认为是 UB 的。我尝尝因为这个怀疑是否真的存在无 UB 的大规模 C++ 程序。很多违反 ODR Violation 的程序也能正常运行,但非常脆弱,往往在修改了链接顺序后就会暴露非常诡异的问题。从这个角度上来说,在这个过程中暴露的问题可能是给了我们一次自查的机会。
头文件中的 internal linkage 变量问题指,头文件中的 internal linkage 变量的初始化函数可能会被调用很多次,但换成 module 之后只会被调用一次。这种调用次数的变化就会反应到运行时上。
Modules 中的前置声明问题
Modules 为了避免 ODR Violation 问题,禁止在不同 Module 间声明和定义同一个实体。所以下列代码是 UB:
export module a;
class B;
class A {
public:
B b;
};
export module b;
class B {
public:
};
module a 中声明的 B
和 module b 中定义的 B
并不是同一个实体。为了缓解这个问题,我们要么将 module a 和 module b 放入同一个 module 中并使用 partition:
export module m:a;
class B;
class A {
public:
B b;
};
export module m:b;
class B {
public:
};
要么使用 extern "C++"
, extern "C++"
被认为位于 Global Module 中:
export module a;
extern "C++" class B;
class A {
public:
B b;
};
export module b;
extern "C++" class B {
public:
};
要么就只能重构代码。
TODO?
坦白说 Modules 带来的影响确实很大,导致从工具链层面到用户层面接纳 Modules 的成本都非常高,这也是目前 Modules 的进展非常缓慢的主要原因。但反过来说,有问题才有需求,现在 Modules 没那么差但也谈不上好的现状是很适合对工具链、社区生态感兴趣的朋友们参与的,有很多能做的事情也有很多 low hanging fruit。这里简单列举下,也欢迎大家交流。
首先在库层面,我们需要更多的基础库提供 Modules。目前 libc++、libstdc++ 和 MSSTL 都提供了 std module。那剩下最重要的便是 Boost 库,可以认为 Boost 提供 Modules 是一个重要的里程碑。此外为自己实际需要的库做封装也是很不错的。
然后在生态层面,如何对使用 C++20 Modules 的项目进行分发似乎也是一个黑盒。我的经验一方面在 closed world 里,另一个方面用的又是修改后的下游 bazel,所以对于 open ended world 里这方面没什么认知。我印象里常常看到有人问类似的问题,而好像没看到特别好的解决方案。可能还是需要更多的实践。
在智能提示层面,我觉得 clangd 里对 C++20 Modules 支持的难度不大,问题也很独立,很适合上手。
在工具链层面,比较明显的是跨平台问题。例如 [libc++] Fix C++23 standard modules when using with clang-cl on Windows。这类问题需要实战环境才能解决。在构建系统层面,前面也提到了一些特性,但觉得 blocker 主要在分发那一块。
然后是小工具层面,我自己编写过一个改造工具,我看很多其他人也做过类似的事。这方面可能还是有事可做。因为坦白说 Modules 在语言层面与 C++ 其他语言特性相比确实简单很多。但另一方面 Modules 对生态的冲击又远大于其他特性。这导致在做 Modules 改造时往往需要面对大量的、重复的工作。如果社区能有一个统一好用的工具的话,我觉得会有很大的帮助。我觉得 AI 在这方面可能很有潜力。
最后在编译器层面,由于其内部数据结构比较封闭,门槛确实高一些。Clang 中目前 C++20 Modules 的开发整体上是收敛的趋势,这是由于主要 feature 都实现之后剩下的问题主要 bug fix 为主以及性能优化为主。除了在序列化和反序列化中继续支持反射和 Contracts 这种新特性之外,Clang C++20 Modules 可能会有以下 TODO:
- 在 BMI 中增加优化后的 IR,在前端代码生成时使用优化后的 IR 并使 LLVM Passes 避免对此类 IR 进行过多操作。预期达到更好的运行时性能和编译时性能的平衡。
- 规范化的 BMI format。目前 clang 里的 BMI format 没有任何规范可言,以 commit 号为单位进行版本管理。如果我们可以对 BMI format 进行规范化,那么就可以大大增强 BMI 的兼容性,所谓的分发 BMI 也可能不再是痴人说梦。哪怕不同的编译器选项依然会造成不兼容,但不兼容和不兼容之间还是有非常大的区别的。微软在这方面做了 https://github.com/microsoft/ifc-spec 。Clang 可以 follow 也可以尝试从 clang 的角度进行 BMI format 的规范化。
- 一些扩展属性,例如
[[headers_wrapper]]
,标记这个 named module 其实是一系列头文件的封装,然后导出其他特性。例如若我们将 std module 标记为[[headers_wrapper]]
,那我们可以让 std module 导出其自身的头文件宏,从而我们不必再烦恼以下这种情况可能带来的多 TU 中的多个声明问题:
import std;
#include <vector> // Even if the compiler accepts it, it is not efficient.