C++20模块用户视角下的最佳实践
作者:许传奇
在2025年的现在,C++20模块的文章,演讲和介绍比比皆是.只是他们大多是工具链的介绍或抱怨,在用户层面的探讨似乎较少.
一方面工具链确实非常重要,是大范围使用C++20模块的基础.另一方面在语言特性角度C++20模块和协程,概念,反射及合约等特性相比,说一句非常简单并不过分.
但即便C++20模块在语言层面已非常简单了,共享一些使用经验应该依然是有价值的.
标题中的"用户视角",指的是不关心选择什么编译器,什么构建系统,编译器怎么实现模块,编译器如何与构建系统交互,不同编译器的不同行为等等事项,而是作为一个C++库的维护者或一个C++项目的最佳实践管理者的角色,在语言层面该如何使用C++20模块.
这里强调"用户视角"的原因是我想总结一些很有价值但还没说的东西,并不是指现在工具链一切都就绪了.(虽然我依然觉得linux+clang下C++20模块已可用了).
这里的讲可分为两部分:如何为当前使用头文件的项目提供C++20模块包装器(但依然使用头文件开发),及怎么在模块中本地的组织代码.
模块本地指像模块从第一天就在C++里一样地去编写代码.
我试让这篇博客的各个节保持独立,所以兴趣不同读者可跳过不感兴趣的内容.如如果你想开始在全新项目中使用C++20模块,你可只看模块本地相关节,这其实是最简单的部分.
或你不关心ABI,也可跳过ABI``相关部分.
C++20模块的好处
在介绍实践方式前,先介绍下C++20模块的好处有哪些,为之后介绍不同实践方式的原因做铺垫.C++20模块的设计目的主要有:
1,更快的编译速度
2,避免ODR违规
3,控制API可见性
4,避免宏污染
其中更快的编译速度和避免ODR违反两个目的都是用C++20模块可为每一个声明,提供唯一一个归属的TU(翻译单元)来达到的.
更快的编译速度(和更小的代码体积)
之前有人认为C++20模块不过是标准化的PCH或标准化的Clang头模块.这都不对.PCH或Clang头模块避免不同TU重复的预处理/语法分析以减少编译时间.
而C++20模块在此之上,还可避免相同声明在编译器中后端的重复优化与编译.而对很多项目而言,编译器中后端的优化和编译才是耗时的主要来源.
如
cpp
//`A.h`
inline void func_a() {
...
}
该写法会让每一个包含a.h且引用到了func_a()的TU都对func_a()做优化及生成代码.
而使用模块的写法:
cpp
export module a;
export int func_a() {
...
}
无论有多少TU引用了func_a(),编译时,这些TU都不会再对func_a()做重复的优化和生成代码.
这是C++20模块相比于PCH或Clang头模块能提升更多编译速度的一个点.
比起全局函数,更常见的是类内内联函数,即:
cpp
class A {
public:
void a() { ... }
};
C++20标准规定,在命名模块中的类内内联函数不再是隐式内联.
即当A::a()在命名模块中时,只应该在命名模块对应的目标文件中放置A::a()的定义,而不会被不同客户重复优化/编译.
而除了这样显式的函数定义外,如虚表和调试信息等信息,都应该遵守相同原则,即此类信息应该只在相关定义对应的命名模块中生成,避免在各个客户中都生成一遍,即浪费时间还浪费空间.
是,实际上发现,应用C++20模块不但可减少编译时间,对减少构建产物的体积也有显著帮助.
避免ODR违反
ODR(一个定义原则)指的是一个程序中每个实例都应该只有一个相同定义.当一个实例有多个不同定义时,该程序是就违反了ODR,叫ODR违反,此时程序是有问题.
实践中,若一个实例的多个定义是强符号,则会在链接时报错并提示``多个定义.而如果一个实例的多个定义全是弱符号,则会在链接时任意挑选一个定义,实践上链接器一般会选择遇见的第一个定义.
忽略一个强符号多个弱符号的情况,它一般是特意设计的.两种情况相比,在链接时报错比在运行时报错要强很多,安全很多.
头文件机制因为其自身不是TU却要被多个TU共享的特征,天然地会将头文件内的几乎所有符号都按弱符号设计,为ODR安全埋下了很大的隐患.
当一个大项目因为各种原因引入了同一个三方库的不同版本时,可能就进入了ODR违反的潜在危机中.
而C++20模块基于每一个实例都有唯一的物主TU的原则,会为每一个实例都提供强符号,天然地可避免这类ODR违反.
此外C++20模块还引入了独特的混杂机制,为命名模块中的每个实例添加和模块名强相关后缀,可避免不同库之间不经意的重名冲突.如
cpp
export module M;
namespace NS {
export int foo();
}
NS::foo()的链接名在解混杂后按NS::foo@M()显示.进一步降低和其他模块中的foo()函数重名的概率.
至于模块的重名,C++20模块要求每个模块单元都生成一个模块初化器来初化其内部状态(哪怕该模块内部实际上不需要初化任何东西),该模块初化器是一个强符号.
从该角度可避免一个程序中出现重名的模块单元.
模块接口后缀名
Clang推荐模块接口文件以.cppm为后缀名.MSVC推荐.ixx.GCC则没有特殊偏好.
大多数用户一般通过构建系统和编译器交互,而构建系统实际上清楚哪些文件是模块接口而哪些不是.所以不少用户觉得它并不重要,随意就行.
但我还是推荐大家使用.cppm或.ccm,此后缀作为模块接口文件的后缀.原因一个是工具友好,另一个是可读性也更好.
对工具,如代码行统计此类工具,用后缀名统计可给出更直观的结果.
哪怕是对clangd这样更复杂的工具,如果clangd可假设所有模块接口都以.cppm结尾,就可提升clangd的处理速度.如Clion就假设了只有.cppm结尾的文件才是模块接口文件.
另如我现在维护AreWeModulesYet,对我能假设所有模块接口文件都以.cppm结尾会简单很多.
同时在代码可读性方面,.cppm此特殊后缀对可读性也有帮助,如:
cpp
.
├── network.cpp
└── network.cppm
当看到此文件结构时,可很简单的认识到network.cppm声明了接口而network.cpp声明相关实现.
此外对clangd此工具,还有源头切换(应该按Impl/Interface修改该名字)此功能可让在network.cppm和network.cpp间快速切换.
而对.ixx,他读上去像预处理后的文件,没有.cppm优雅.
所以我推荐大家使用.cppm或.ccm作为模块接口文件的后缀.
为基于头文件的项目提供C++20模块Interface
更简洁的版本在https://clang.llvm.org/docs/StandardCPlusPlusModules.html#transitioning-to-modules%EF%BC%89,这里
使用简单项目如:
cpp
//`header.h`
#pragma once
#include <cstdint>
namespace example {
class C {
public:
std::size_t inline_get() { return 42; }
std::size_t get();
};
}
//`src.cpp`
#include "header.h"
std::size_t example::C::get() {
return 43 + inline_get();
}
为了ABI兼容,假设其会分发一个导出的符号为如下的libexample.so:
cpp
$nm ACD libexample.so
libexample.so: w __cxa_finalize
libexample.so: w __gmon_start__
libexample.so: w _ITM_deregisterTMCloneTable
libexample.so: w _ITM_registerTMCloneTable
libexample.so:0000000000001130 W example::C::inline_get()
libexample.so:0000000000001110 T example::C::get()
W表示Weak,指example::C::inline_get()为弱符号.T表示example::C::get()为强符号.
对仅头库作者和只分发源码不分发二进制的库作者而言,该例可能依然复杂了些,但只要理解了该简单情况,相信为其他更简单的示例封装模块包装器也不成问题.
导出用风格
导出用风格是为头文件提供C++20模块接口``最简单的办法,包括libc++,libstdc++和MSSTL使用的都是该办法.
当前看到的大部分支持C++20模块的库使用的也是该办法.
类似:
cpp
//`example.cppm`
module;
#include "header.h"
export module example;
namespace example {
export using example::C;
}
即在全局模块碎片中插入此项目的所有头,然后在模块预览中导出 用语句导出对外可见的声明.
最大的优点是简单及对其他代码没有侵入性.
因为没有侵入性,在应用中发现其他三方库不支持模块但又希望导入这些三方库时,可在自己的项目中为这些三方库添加包装器.
但该方式的缺点主要是模块包装器与原先头文件中的实现在不同文件中,维护者可能在维护头文件时新增/删除/修改了原先导出的接口,但忘记更新example.cppm导致中断.
它可靠脚本或测试来解决/缓解.如:https://github.com/llvm/llvm-project/blob/main/libcxx/utils/generate_libcxx_cppm_in.py,这里
对支持C++20模块的三方库使用导入
若项目使用的第三方库使用了C++20模块,应该在模块接口中应使用导入引入该三方库而非#include.
这对提升用户的编译速度有帮助.因为Clang``编译器当前的实现限制,在存在导入时避免使用#include可带来更大的编译加速.
而可将基础库看作最普遍的第三方库,所以对上述示例,可重写header.h为:
cpp
//`header.h`
#pragma once
#ifdef USE_STD_MODULE_IN_HEADER
import std;
#else
#include <cstdint>
#endif
namespace example {
class C {
public:
std::size_t inline_get() { return 42; }
std::size_t get();
};
}
然后将example.cppm改为:
cpp
//`example.cppm`
module;
#define USE_STD_MODULE_IN_HEADER
#include "header.h"
export module example;
namespace example {
export using example::C;
}
这样可让用户取得更大的编译性能提升.
导出用风格的ABI
注意,如果你的项目会分发二进制,你需要将example.cppm编译到你分发二进制中,此时libexample.so中导出的符号应为:
cpp
$llvm-nm -ACD libexample.so
libexample.so: w _ITM_deregisterTMCloneTable
libexample.so: w _ITM_registerTMCloneTable
libexample.so: 0000000000001050 T initializer for module example
libexample.so: 0000000000001140 W example::C::inline_get()
libexample.so: 0000000000001120 T example::C::get()
libexample.so: w __cxa_finalize@GLIBC_2.2.5
libexample.so: w __gmon_start__
使用llvm-nm而非nm因为低版本nm不能解混杂C++20模块相关混杂规则
与之前的版本相比,这里导出的符号多了模块示例的初化器.
类似,哪怕项目不分发二进制,但如果你的项目中存在源文件,你的构建脚本中,应该在同一个库文件中编译example.cppm此模块接口和你的源文件.
因为这些文件逻辑上都属于你项目的头文件.
对仅头的库而言,在一个库(哪怕你不真分发二进制)中添加example.cppm此非模块接口,对用户来说是最方便的.
但如果如前单纯地只分发example.cppm的源码,那用户需要自己处理example.cppm对应的目标文件.
如果此用户是终端用户,没有更下游的代码用户,那问题还算简单,只需要编译example.cppm到目标文件然后链接在一起即可.
而如果此用户依然是库用户,其有更下游的代码用户,那可能最好是不要将example.cppm编译到目标文件,将该任务延迟到最后的二进制用户.
这里的关键在于,如果example.cppm在一开始的库中没有被指派到库中,那该源文件在二进制层面缺乏了真正的物主,只能希望最终的可执行文件的用户去处理它.
导出extern"C++"风格
对所有头文件中的#include,用一个宏控制,如:
cpp
//`header.h`
#pragma once
#ifndef IN_MODULE_WRAPPER
#include <cstdint>
#endif
#ifdef IN_MODULE_WRAPPER
#define EXPORT export
#else
#define EXPORT
#endif
namespace example {
EXPORT class C {
public:
std::size_t inline_get() { return 42; }
std::size_t get();
};
}
然后用以下形式在example.cppm对其封装模块包装器.
cpp
//`example.cppm`
module;
#include <cstdint>
//一般,即在`全局模块碎片`中编写所有三方库`头文件`
export module example;
#define IN_MODULE_WRAPPER
extern "C++" {
#include "header.h"
}
而如果的所有第三方库都提供了模块的话,可进一步:
cpp
//`example.cppm`
export module example;
import std;
//(如果有的话)及其他三方库的`Module`
#define IN_MODULE_WRAPPER
extern "C++" {
#include "header.h"
}
该形式下,在完成前期的安装后,后续在example.cppm中只需要在文件级维护即可,用导出宏在声明处控制不同声明的可见性,相比导出用风格,大大提高了可维护性.
example.cppm中的extern"C++"很关键,它用来保护库的ABI一致.将extern"C++"去掉则变成更近一步的ABI的破坏风格.
所以导出外"C++"风格也可当作为后续更激进的改造做准备.
此外相比于导出用风格,导出外"C++"风格在头文件存在内联全局函数和内联全局变量,特别是该变量存在动态初化时,可选择性``内联,令其应用有更高的编译性能.
如假设之前示例中的头文件为:
cpp
//`header.h`
#pragma once
#include <cstdint>
namespace example {
inline int func() { return 43; }
inline int init() { return 43; }
inline int var = init();
}
可把它改造为:
cpp
//`header.h`
#pragma once
#ifndef IN_MODULE_WRAPPER
#include <cstdint>
#endif
#ifdef IN_MODULE_WRAPPER
#define EXPORT export
#else
#define EXPORT
#endif
#ifndef IN_MODULE_WRAPPER
#define INLINE inline
#else
#define INLINE
#endif
namespace example {
INLINE int func() { return 43; }
INLINE int init() { return 43; }
INLINE int var = init();
}
example.cppm实现不变,这可算按导出外"C++"风格更具维护性的一个侧面示例.
此时,example.cppm的客户不会重新编译函数.
注意该改造其实更改了ABI,让原先的弱符号变成了现在的强符号.
这在良好定义的项目,即不存在ODR违反的项目中没有关系.
但如果本来就有这几个符号的ODR违反,那此改造可能会改变现有行为.如果担心它,可修改header.h的实现为:
cpp
//`header.h`
#pragma once
#ifndef IN_MODULE_WRAPPER
#include <cstdint>
#endif
#ifdef IN_MODULE_WRAPPER
#define EXPORT export
#else
#define EXPORT
#endif
#ifndef IN_MODULE_WRAPPER
#define INLINE inline
#else
#define INLINE __attribute__((weak))
#endif
namespace example {
INLINE int func() { return 43; }
INLINE int init() { return 43; }
INLINE int var = init();
}
此时哪怕在模块中,example::func,example::init和example::var依然会是一个弱符号.
这降低了触发本来ODR违反的可能性,但再次强调,若项目在这几个符号上本来就ODR违反状态,这样修改也只是降低了触发的可能性,依然可能会改变程序现有行为.
编译器忽略模块单元中的所有内联链接
这里,还想谈一下编译器中的处理.在实现过程中,有多人向我建议,编译器应该忽略模块单元的内联标识,直接生成强符号或对应的弱符号,而不是现在的内联链接.
这样可在模块中避免C++早期的一些设计问题(按我理解,现在很多ODR问题的原因之一便是当年头文件的设计).
但我还是觉得兼容非常重要,应该尽量把选择权留给用户.
导出外"C++"风格的ABI
在ABI上,如果不重写上述内联,导出外"C++"风格的ABI应与导出用风格的ABI``完全一致.