C++20 Modules 杂谈:解释、效果、实践与 TODO
25-08-22 更新 Modules Wrapper,[One big thirdparty module], [推荐的文件名后缀],[include 与 import] 混用等内容。
- 构建系统
- C++20 Modules 能减少多少编译时间?
- C++20 Modules 和 PCH 是等价的吗?区别是什么?
- C++20 Modules 能减少代码体积吗?为什么?
- 我们现在可以使用 C++20 Modules 编程吗?
- Modules Wrapper
- 为你封装的三方库选择特定后缀以避免冲突
- One big thirdparty module
- 使用 .cppm 等特殊后缀作为 importable module unit 的文件名后缀
- 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,尝试与构建系统的开发者进行接触,说明需求可能对开发者也是有帮助的。
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 很有帮助。
虽然更显复杂,extern C++ style 相比 export using style 会有更好的编译时性能。
同时当你的库的所有依赖都提供了 modules 时,extern C++ style 可以很方便的转换为以下形式:
export module your_library;
import third_party1;
import third_party2;
import third_party3;
...
#define IN_MODULE_INTERFACE
extern "C++" {
#include "header_1.h"
#include "header_2.h"
...
#include "header_n.h"
}
之后若你希望,之后应该也可以较为简单的在你的项目中使用 Modules。
我们希望所有库都提供对应的 Modules。在理想情况下,C++ 世界的 Modules 化历程应为:
- 自顶向下地每个项目提供 Modules (从 std module 开始)
- 之后自底向上地每个项目使用 Modules。
需要注意,当你需要使用 Modules 而你的三方库没有提供 Modules 时,你可以也应该在自己的项目中自己 Mock 一个,哪怕对于 std module 也可以这么做。
为你封装的三方库选择特定后缀以避免冲突
如果你的项目存在下游源码依赖,那么封装三方库时最后带上特定的后缀,以避免下游用户使用时造成冲突。例如若你的库名为 aaa
,那么你封装 boost
库时最后将其 module 命名为 boost.aaa.mock
而不是 boost
。
One big thirdparty module
若你的项目存在多个三方库,且他们中的大部分都没有提供 Modules。此时可以选择将所有三方库封装为一个大的 thirdparty
modules。例如:
module;
#include "third_party/header_1.h"
#include "third_party/header_2.h"
...
#include "third_party/header_n.h"
export module third_party;
export namespace third_party_namespace {
using third_party_decl_1;
using third_party_decl_2;
...
using third_party_decl_n;
}
此时你项目中所有对三方库的引用都可以通过 import third_party;
引入。这种方式虽然粗暴,但简单好用。
使用 .cppm 等特殊后缀作为 importable module unit 的文件名后缀
clang 推荐使用 .cppm
、.ccm
、.cxxm
等后缀作为 importable module unit 的文件名后缀。MSVC 推荐 .ixx
。GCC 没有特殊要求。
虽然事实上用户可以使用任意文件名,但我还是推荐大家使用 .cppm 作为 importable module unit 的文件名后缀。因为一方面对于小工具等更友好,例如统计代码行数的小工具等。另一方面这种约定对于阅读代码也有帮助。例如当我们看到 SourceManager.cppm
和 SourceManager.cpp
时,我们会自然联想到 SourceManager.cppm
是接口, SourceManager.cpp
是实现。
至于 .cppm
和 .ixx
之间的选择则是个人喜好。.ixx
读着像 “更好的头文件” 或 “C++ 版本的头文件”。我觉得不是这么回事。而 .cppm
更符合我心中 “可导入的 C++ 文件” 的定义。
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 一个。
import 与 #include 混用
实践中,对于依赖比较复杂的项目,期待所有依赖都提供 modules 是不现实的(或极为漫长的)。此时我们必须要面对 import 与 #include 混用的情况。import 和 #include 混用的主要问题是在不同 TU 间引入相同的声明而造成编译速度下降。include 只包含宏的头文件不会造成任何问题。
我总结的 practice 为:
- 在非 wrapper 的 module units 中,避免 include 非只包含宏的头文件。
- 在 wrapper 中,在编译器不报错的情况下,import before #include 相比只 #include 或 #include before import 有更好的性能。
例如:
module;
#include <vector>
export module my_module;
export std::vector<int> vi = {1, 2, 3, 4};
不如
export module my_module;
import std;
export std::vector<int> vi = {1, 2, 3, 4};
这个例子过于简单,但却很有用。无论因为任何原因 std module 在你的环境中不可用,你都应该 mock 一个 std.mock module。类似的,若你的项目需要 boost 而 boost 目前没有为你所需要的组件提供 modules,你应该在你的仓库中 mock 一个 boost.mock module。
同时在 wrapper 中,import before #include 给了编译器更多的优化机会。例如对于:
//--- a.h
namespace a {
class A {
public:
int aaaa;
int get() {
return aaaa;
}
};
template <class T>
class B {
public:
B(T t): t(t) {}
T t;
};
using BI = B<int>;
inline int get(A a, BI b) {
return a.get() + b.t;
}
}
//--- a.cppm
module;
#include "a.h"
export module a;
namespace a {
export using ::a::A;
export using ::a::get;
export using ::a::BI;
}
//--- a.cpp
import a;
#include "a.h"
int test() {
a::A aa;
a::BI bb(43);
return get(aa, bb);
}
然后执行:
$ clang++ -std=c++20 a.cppm --precompile -o a.pcm
$ clang++ -std=c++20 a.cc -fmodule-file=a=a.pcm -Xclang -ast-dump -fsyntax-only
|-NamespaceDecl 0x8ebd718 prev 0x8ebd5f8 <./a.h:1:1, line:25:1> line:1:11 a
| |-original Namespace 0x8ebd688 'a'
| |-CXXRecordDecl 0x8ebda28 prev 0x8ebd790 <line:2:1, col:7> col:7 referenced class A
| |-ClassTemplateDecl 0x8ebe1c0 prev 0x8ebde18 <line:12:1, line:13:7> col:7 B
| | |-TemplateTypeParmDecl 0x8ebdd38 <line:12:11, col:17> col:17 class depth 0 index 0 T
| | |-CXXRecordDecl 0x8ebe128 prev 0x8ebdff8 <line:13:1, col:7> col:7 class B
| | `-ClassTemplateSpecialization 0x8eed998 'B'
| |-TypeAliasDecl 0x8eede48 prev 0x8eedbc0 <line:19:1, col:17> col:7 referenced BI 'B<int>':'a::B<int>'
| | `-TemplateSpecializationType 0x8eedb10 'B<int>' sugar
| | |-name: 'B':'a::B' qualified
| | | `-ClassTemplateDecl 0x8ebe1c0 prev 0x8ebde18 <line:12:1, line:13:7> col:7 B
| | |-TemplateArgument type 'int'
| | | `-BuiltinType 0x8e692f0 'int'
| | `-RecordType 0x8eedaf0 'a::B<int>' imported canonical
| | `-ClassTemplateSpecialization 0x8eed998 'B'
| `-FunctionDecl 0x8eee530 prev 0x8eee100 <line:21:1, col:25> col:12 used get 'int (A, BI)' inline
| |-ParmVarDecl 0x8eedef0 <col:16, col:18> col:18 a 'A'
| `-ParmVarDecl 0x8eedfa0 <col:21, col:24> col:24 b 'BI':'a::B<int>'
`-FunctionDecl
我们可以看到这里的 CXXRecordDecl ... class A
、ClassTemplateDecl ... B
以及 FunctionDecl ... get
都是没有实现的。因为在 a.cc
中编译器先 import a 了,后续编译器 parse 到 class A
时,编译器会查询是否已存在 class A
的定义,在查询满足后编译器则认为已经解析过 class A
了,从而在 a.cc
中跳过了对 class A
的 parsing!与之相对的,若我们修改的 a.cc
实现至先 #include 再 import:
#include "a.h"
import a;
int test() {
a::A aa;
a::BI bb(43);
return get(aa, bb);
}
|-NamespaceDecl 0x9a105b0 <./a.h:1:1, line:25:1> line:1:11 a
| |-CXXRecordDecl 0x9a10640 <line:2:1, line:9:1> line:2:7 referenced class A definition
| | |-DefinitionData pass_in_registers aggregate standard_layout trivially_copyable pod trivial literal has_constexpr_non_copy_move_ctor
| | | |-DefaultConstructor exists trivial constexpr defaulted_is_constexpr
| | | |-CopyConstructor simple trivial has_const_param implicit_has_const_param
| | | |-MoveConstructor exists simple trivial
| | | |-CopyAssignment simple trivial has_const_param needs_implicit implicit_has_const_param
| | | |-MoveAssignment exists simple trivial needs_implicit
| | | `-Destructor simple irrelevant trivial constexpr
| | |-CXXRecordDecl 0x9a10758 <col:1, col:7> col:7 implicit class A
| | |-AccessSpecDecl 0x9a10808 <line:3:1, col:7> col:1 public
| | |-FieldDecl 0x9a10850 <line:4:5, col:9> col:9 referenced aaaa 'int'
| | |-CXXMethodDecl 0x9a10950 <line:6:5, line:8:5> line:6:9 used get 'int ()' implicit-inline
| | | `-CompoundStmt 0x9a10a90 <col:15, line:8:5>
| | | `-ReturnStmt 0x9a10a80 <line:7:9, col:16>
| | | `-ImplicitCastExpr 0x9a10a68 <col:16> 'int' <LValueToRValue>
| | | `-MemberExpr 0x9a10a38 <col:16> 'int' lvalue ->aaaa 0x9a10850
| | | `-CXXThisExpr 0x9a10a28 <col:16> 'a::A *' implicit this
| | |-CXXConstructorDecl 0x9a45c40 <line:2:7> col:7 implicit used constexpr A 'void () noexcept' inline default trivial
| | | `-CompoundStmt 0x9a46200 <col:7>
| | |-CXXConstructorDecl 0x9a45dc8 <col:7> col:7 implicit used constexpr A 'void (const A &) noexcept' inline default trivial
| | | |-ParmVarDecl 0x9a45f08 <col:7> col:7 used 'const A &'
| | | |-CXXCtorInitializer Field 0x9a10850 'aaaa' 'int'
| | | | `-ImplicitCastExpr 0x9a4b130 <col:7> 'int' <LValueToRValue>
| | | | `-MemberExpr 0x9a4b100 <col:7> 'const int' lvalue .aaaa 0x9a10850
| | | | `-DeclRefExpr 0x9a4b0c8 <col:7> 'const A' lvalue ParmVar 0x9a45f08 depth 0 index 0 'const A &'
| | | `-CompoundStmt 0x9a4b170 <col:7>
| | |-CXXConstructorDecl 0x9a45ff8 <col:7> col:7 implicit constexpr A 'void (A &&)' inline default trivial noexcept-unevaluated 0x9a45ff8
| | | `-ParmVarDecl 0x9a46138 <col:7> col:7 'A &&'
| | `-CXXDestructorDecl 0x9a4b1b8 <col:7> col:7 implicit referenced constexpr ~A 'void () noexcept' inline default trivial
| |-ClassTemplateDecl 0x9a10c38 <line:12:1, line:17:1> line:13:7 B
| | |-TemplateTypeParmDecl 0x9a10ab0 <line:12:11, col:17> col:17 referenced class depth 0 index 0 T
| | |-CXXRecordDecl 0x9a10b88 <line:13:1, line:17:1> line:13:7 class B definition
| | | |-DefinitionData standard_layout trivially_copyable has_user_declared_ctor can_const_default_init
| | | | |-DefaultConstructor
| | | | |-CopyConstructor simple trivial has_const_param needs_implicit implicit_has_const_param
| | | | |-MoveConstructor exists simple trivial needs_implicit
| | | | |-CopyAssignment simple trivial has_const_param needs_implicit implicit_has_const_param
| | | | |-MoveAssignment exists simple trivial needs_implicit
| | | | `-Destructor simple irrelevant trivial constexpr needs_implicit
| | | |-CXXRecordDecl 0x9a10d18 <col:1, col:7> col:7 implicit class B
| | | |-AccessSpecDecl 0x9a10dc8 <line:14:1, col:7> col:1 public
| | | |-CXXConstructorDecl 0x9a10fc8 <line:15:5, col:19> col:5 a::B<T> 'void (T)' implicit-inline
| | | | |-ParmVarDecl 0x9a10e58 <col:7, col:9> col:9 referenced t 'T'
| | | | |-CXXCtorInitializer Field 0x9a110b0 't' 'T'
| | | | | `-ParenListExpr 0x9a11138 <col:14, col:16> 'NULL TYPE'
| | | | | `-DeclRefExpr 0x9a11118 <col:15> 'T' lvalue ParmVar 0x9a10e58 't' 'T'
| | | | `-CompoundStmt 0x9a11180 <col:18, col:19>
| | | `-FieldDecl 0x9a110b0 <line:16:5, col:7> col:7 t 'T'
| | |-ClassTemplateSpecializationDecl 0x9a46618 prev 0x9a11298 <line:12:1, line:17:1> line:13:7 imported in a.<global> hidden <undeserialized declarations> class B implicit_instantiation
| | | `-TemplateArgument type 'int'
| | | `-BuiltinType 0x99bc2f0 'int'
| | `-ClassTemplateSpecializationDecl 0x9a11298 <line:12:1, line:17:1> line:13:7 class B definition implicit_instantiation
| | |-DefinitionData pass_in_registers standard_layout trivially_copyable has_user_declared_ctor can_const_default_init
| | | |-DefaultConstructor defaulted_is_constexpr
| | | |-CopyConstructor simple trivial has_const_param implicit_has_const_param
| | | |-MoveConstructor exists simple trivial
| | | |-CopyAssignment simple trivial has_const_param needs_implicit implicit_has_const_param
| | | |-MoveAssignment exists simple trivial needs_implicit
| | | `-Destructor simple irrelevant trivial constexpr
| | |-TemplateArgument type 'int'
| | | `-BuiltinType 0x99bc2f0 'int'
| | |-CXXRecordDecl 0x9a34d20 <col:1, col:7> col:7 implicit class B
| | |-AccessSpecDecl 0x9a34dd0 <line:14:1, col:7> col:1 public
| | |-CXXConstructorDecl 0x9a34fd0 <line:15:5, col:19> col:5 used B 'void (int)' implicit_instantiation implicit-inline instantiated_from 0x9a10fc8
| | | |-ParmVarDecl 0x9a34e78 <col:7, col:9> col:9 used t 'int'
| | | |-CXXCtorInitializer Field 0x9a350b0 't' 'int'
| | | | `-ImplicitCastExpr 0x9a4b6b8 <col:15> 'int' <LValueToRValue>
| | | | `-DeclRefExpr 0x9a4b5d8 <col:15> 'int' lvalue ParmVar 0x9a34e78 't' 'int'
| | | `-CompoundStmt 0x9a11180 <col:18, col:19>
| | |-FieldDecl 0x9a350b0 <line:16:5, col:7> col:7 referenced t 'int'
| | |-CXXConstructorDecl 0x9a490c8 <line:13:7> col:7 implicit used constexpr B 'void (const B<int> &) noexcept' inline default trivial
| | | |-ParmVarDecl 0x9a49208 <col:7> col:7 used 'const B<int> &'
| | | |-CXXCtorInitializer Field 0x9a350b0 't' 'int'
| | | | `-ImplicitCastExpr 0x9a4b410 <col:7> 'int' <LValueToRValue>
| | | | `-MemberExpr 0x9a4b3e0 <col:7> 'const int' lvalue .t 0x9a350b0
| | | | `-DeclRefExpr 0x9a4b3a8 <col:7> 'const B<int>' lvalue ParmVar 0x9a49208 depth 0 index 0 'const B<int> &'
| | | `-CompoundStmt 0x9a4b450 <col:7>
| | |-CXXConstructorDecl 0x9a496e8 <col:7> col:7 implicit constexpr B 'void (B<int> &&)' inline default trivial noexcept-unevaluated 0x9a496e8
| | | `-ParmVarDecl 0x9a49828 <col:7> col:7 'B<int> &&'
| | `-CXXDestructorDecl 0x9a4b498 <col:7> col:7 implicit referenced constexpr ~B 'void () noexcept' inline default trivial
| |-TypeAliasDecl 0x9a34930 <line:19:1, col:17> col:7 referenced BI 'B<int>':'a::B<int>'
| | `-TemplateSpecializationType 0x9a34880 'B<int>' sugar
| | |-name: 'B':'a::B' qualified
| | | `-ClassTemplateDecl 0x9a10c38 <line:12:1, line:17:1> line:13:7 B
| | |-TemplateArgument type 'int'
| | | `-BuiltinType 0x99bc2f0 'int'
| | `-RecordType 0x9a11390 'a::B<int>' imported canonical
| | `-ClassTemplateSpecialization 0x9a11298 'B'
| `-FunctionDecl 0x9a34be0 <line:21:1, line:23:1> line:21:12 used get 'int (A, BI)' inline
| |-ParmVarDecl 0x9a349d0 <col:16, col:18> col:18 used a 'A'
| |-ParmVarDecl 0x9a34a80 <col:21, col:24> col:24 used b 'BI':'a::B<int>'
| `-CompoundStmt 0x9a35458 <col:27, line:23:1>
| `-ReturnStmt 0x9a35448 <line:22:5, col:24>
| `-BinaryOperator 0x9a35350 <col:12, col:24> 'int' '+'
| |-CXXMemberCallExpr 0x9a351b0 <col:12, col:18> 'int'
| | `-MemberExpr 0x9a35150 <col:12, col:14> '<bound member function type>' .get 0x9a10950
| | `-DeclRefExpr 0x9a35130 <col:12> 'A' lvalue ParmVar 0x9a349d0 'a' 'A'
| `-ImplicitCastExpr 0x9a35338 <col:22, col:24> 'int' <LValueToRValue>
| `-MemberExpr 0x9a35308 <col:22, col:24> 'int' lvalue .t 0x9a350b0
| `-DeclRefExpr 0x9a352e8 <col:22> 'BI':'a::B<int>' lvalue ParmVar 0x9a34a80 'b' 'BI':'a::B<int>'
|-ImportDecl 0x9a35478 <a.cc:2:1, col:8> col:1 a
`-FunctionDecl 0x9a354e8 <line:4:1, line:8:1> line:4:5 test
我们能很明显地看到从 a.cc
中生成的 AST 大量增加。
然而,由于编译器开发的历史原因,import before #include 会给编译器(据我所知,包括 Clang、MSVC 与 GCC)造成比较大的挑战,暴露出各种问题。不过编译器也日渐在改进这类问题。在目标编译器不报错的前提下,使用 import before #include 方法对 三方库 进行封装是比较好的。例如我们的库中需要 boost 而我们不能修改 boost 代码,我们可以这样封装:
module;
import std;
#include "boost/..."
export module boost.mock;
export using boost::...
若我们需要导出我们 import 的 module,我们可以在 module purview 中对其 export import:
module;
import std;
#include "boost/..."
export module boost.mock;
export import std;
export using boost::...
在 extern C++ style 中,若并非全部依赖都提供了 modules,我们也可以做类似的事:
module;
import std;
#include "third_party_not_providing_modules/..."
export module your_library;
#define IN_MODULE_INTERFACE
extern "C++" {
#include "header_1.h"
#include "header_2.h"
...
#include "header_n.h"
}
当依赖的一部分提供了 modules,我们可以对这部分依赖进行 import,然后避免 include 其对应的头文件。
module;
#include "third_party_not_providing_modules/..."
export module your_library;
import third_party_providing_modules;
#define IN_MODULE_INTERFACE
extern "C++" {
#include "header_1.h"
#include "header_2.h"
...
#include "header_n.h"
}
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.
需要注意的是,虽然编译器可能接受这种混合使用方式,但它确实是低效的。当我们 import std;
时,已经导入了标准库的所有声明。再使用 #include <vector>
会导致以下问题:
-
重复声明问题:
std::vector
在 std module 中已经声明,在<vector>
头文件中也会声明,这会导致同一实体在不同 Translation Unit 中存在重复声明。正如前文所述,Clang 在处理不同 TU 中的相同声明时效率相对会低非常多。 -
编译时间增加:预处理器需要处理
#include <vector>
,这会增加预处理时间。同时,编译器需要处理重复的声明,进一步增加编译时间。 -
代码体积增加:重复的声明可能会导致生成的目标文件包含冗余内容,增加最终的代码体积。
正确的做法是完全使用 Modules 方式:
import std; // 导入标准库模块
import <vector>; // 如果你的编译器支持 import header units
// 或者使用 export import std.core; 等更细粒度的导入方式(取决于具体实现)
这样可以完全避免传统头文件带来的性能问题,充分发挥 C++20 Modules 的优势。
-
最佳实践和使用建议:随着更多项目开始采用 C++20 Modules,社区需要总结和分享最佳实践。这包括如何正确地组织模块结构、如何处理模块间的依赖关系、如何平衡模块的粒度等。特别是对于混合使用 Modules 和传统头文件的项目,需要明确的指导原则来避免性能陷阱。
-
工具支持:需要更多的工具来帮助开发者迁移到 C++20 Modules,包括自动转换工具、静态分析工具、性能分析工具等。这些工具可以帮助开发者识别和解决在迁移过程中遇到的问题。