0%

C++20 module模块详解

背景

c++一直以来使用c风格#include头文件方式引入依赖,而#include的方式存在诸多问题长期被程序猿吐槽,细数下来,这些问题包括不只限于:

include方式存在的问题

拖慢编译速度

这也是标准委员会引入module的主要原因,#include只是把头文件贴过来将代码段变成一个拥有完整上下文定义的整体,编译时就会出现引用的某个代码段在多个编译单元内重复编译然后链接时再合并的过程,重复编译自然会拖慢效率。

宏污染

引用的头文件越多,带来的可能的宏定义就越多,程序员编写宏时要考虑的就越多。以头文件防止重复被引用的宏惯例模式#ifndef XXX #define XXX为例(当然可使用#pragma once,只是部分团队代码规范要求不同),为防止宏名重复宏名总是越写越长。

依赖传递带来的混乱

头文件方式会将依赖关系上层的代码都传递至最下层的代码文件中,导致的问题则是最下层实际依赖项不清晰,上层的依赖关系如果管理不慎引入更多的头文件,则会进一步拖慢编译。

module 模块

为解决以上问题,c++20加入了模块用以代替#include机制,具体来说一个需要向外提供接口的代码块改用module后可以写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// sample.cppm  // cppm后缀是gcc推荐模块声明扩展名,但其实叫啥无所谓
export module sample; // 声明导出模块名

#define FACTOR 37 // 宏不会传递至当前模块以外

import <iostream>; // 导入stl的iostream
// 导入stl有坑,完整的stl的模块化要23标准后

using namespace std; // using声明不会影响到后续import该模块的代码

export void fo() // 导出单个声明
{
cout << "module sample" << endl;
}

export namespace Joshua { // 导出命名空间内多个声明
int foo() {
return 0;
}

struct SomeThing {
string name;
int value;
};

} // namespace Joshua

export { // 使用export{} 导出多个
int foo() {
return FACTOR;
}

void fooo(const Joshua::SomeThing& st) {
cout << "name:" << st.name << endl;
cout << "value:" << st.value << endl;
}
}
1
2
3
4
5
6
//main.cc
import sample;
int main() {
Joshua::SomeThing st{ .name = "sth", .value = 2 };
fooo(st);
}

在详细介绍module之前,需要介绍下当前(成文时间2025年4月)module的支持现状,以免你兴冲冲开始将手头代码全部模块化然后踩一堆坑。

module 当前支持现状

当前三大编译器(gcc、clang、msvc)只有msvc是完整的支持了20标准的模块特性(PS:少见的msvc走在了前头,巨硬牛啤)。当前最新版本gcc14仍不是完整的支持(部分语法不支持,大体可用,见支持情况,见gcc c++ module文档),在开启了--std=c++20之后,仍需通过选项-fmodules-ts这个“Technical Specification”选项来开启module支持,既然都是ts选项了那模块的全部特性就不都是支持的。gcc15版本发布在即,但其发布说明中也只是提到"C++ Modules have been greatly improved."。本文示例都基于gcc14进行。
clang相比gcc则支持的更少一点,如果你想知道支持情况看这里
STL的模块化在23标准,届时可以通过import std来使用STL模块,在20标准中想要导入标准库需要一定的措施(下文会提到)。
自动化构建工具如cmake当前对模块的支持也不是很好,本文所有用到的示例编译都经由自写编译脚本完成。
IDE对模块的支持也比较够呛,就目前我看到的vscode插件没有能正确识别module语法的,我当前主要使用的clangd也对module语法发出一堆错误告警(本来当前clang20也不是完全支持模块语法嘛)。
好了,在对现在的模块语法支持情况有一定了解后我们再来看看20标准中的module语法。

module 语法介绍

模块声明


也就是示例代码代码中的export module sample;,当出现这行时当前代码块被视为模块单元。除了以下提到全局模块片段外,export module必须是代码文件内首个声明,也就是一个文件中只能有一个export module声明。模块名中可以出现.号用以形式逻辑上表示子模块,.并不代表编译时会对子模块做任何处理,仍然是名称一部分,也就是说只要你愿意,你可以写任意一个export module A.B.C.D.E而实际上并不存在module A
虽然上面示例sample.cppm中将实现与接口放到了一起,但实际上和原先.h、.cpp文件一样,可以拆分接口声明和实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// sample.cppm
export module sample;

import <iostream>;

using namespace std;

export void fo();

export namespace Joshua {
int foo();

struct SomeThing {
string name;
int value;
};

} // namespace Joshua

export {
int foo();

void fooo(const Joshua::SomeThing& st);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// sample.cc
module sample; // 模块实现声明,表明当前是sample模块的实现

#define FACTOR 37

using namespace std;

void fo() {
cout << "module sample" << endl;
}

int Joshua::foo() {
return 0;
}

int foo() {
return FACTOR;
}

void fooo(const Joshua::SomeThing& st) {
cout << "name:" << st.name << endl;
cout << "value:" << st.value << endl;
}
1
2
3
4
5
6
7
8
9
10
# Makefile
G++:=/opt/rh/gcc-toolset-14/root/usr/bin/g++
GCC_MODULE_FLAGS:=-std=c++20 -fmodules-ts

all:
$(G++) $(GCC_MODULE_FLAGS) -x c++-system-header iostream
$(G++) -g $(GCC_MODULE_FLAGS) -x c++ -c ../src/modules/sample.cppm -o sample_int.o
$(G++) -g $(GCC_MODULE_FLAGS) -x c++ -c ../src/modules/sample.cc -o sample_impl.o
$(G++) -g $(GCC_MODULE_FLAGS) -x c++ -c ../src/test_main.cc -o test_main.o
$(G++) -static-libstdc++ sample_int.o sample_impl.o test_main.o -o test #使用静态链接是由于gcc-toolset没提供动态库

实现可以拆分到多个文件中,只要文件中第一个声明为module xxx;即可。
编译上需要注意cppm这类模块接口文件必须先编译,如果你对Makefile中的指令有疑问可以跳转这里

全局模块片段

模块声明提到的可以在模块声明之前的代码块,在引入模块之后,以往的头文件方式需要一个兼容的过程,这就是全局模块片段存在的意义。
使用模块之后头文件的#include只允许出现在全局模块片段里,语法为:

1
2
3
4
5
6
module;    // 固定形式'module;',表明全局模块片段开始

#include <string> // 只允许出现预处理指令

export module sample; // 模块声明,全局模块片段结束
// ...

需要注意全局模块片段内的头文件、宏同样会不会传递出模块外。

私有模块片段

模块声明中可以主动结束对外的模块声明,使用语法:

1
2
3
4
5
6
7
// ...
module : private; // 结束对外的模块声明
// 以下为私有模块片段,以下声明对外不可及
int f()
{
return 42;
}

注意当前gcc(gcc14)不支持私有模块片段。

导出声明和定义

示例代码中示例的几种导出声明方式,其语法为:

1
2
export 声明
export { 声明序列 (可选) }

导入模块和标头

导入的基础用法如示例代码main.cc这种非模块单元中的import sample;,实际开发中还会遇到模块文件中需要导入另一模块接口并导出的需求,标准规定通过export import 模块名语法解决:

1
2
3
4
// a_sample.cppm
export module a_sample;

export void foooo();
1
2
3
4
5
6
// sample.cppm
export module sample;

export import a_sample;

//...

需要注意在模块单元中,所有导入声明的位置必须在模块声明后以及其他声明前,同时在模块单元内不允许出现#include除非是全局模块片段。
头文件同样可以导入,比如示例中的import <iostream>,注意导入头文件与模块不同,头文件内所有声明和定义都可访问,包括宏也可以访问。

模块分区

不同于.子模块是面向程序员的模块拆分,模块分区是实际意义上的拆分模块实现,其语法为:

1
export module A:B;  // 单冒号划分,B是模块A的一个分区

模块分区是模块内部的实现拆分机制,外部不能直接导入某个模块分区,如果需要导出某个分区必须经由主模块单元导出。由于是实现拆分机制,分区内的实现无论是否导出都对同模块的代码可见:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//sample_a.cc
export module sample:a;

import <iostream>;

using namespace std;

export void foooo() { // 导出foooo,对于import sample;的单元均可见
cout << "in foooo" << endl;
}

void foo_impl() {
cout << "foo imple" << endl;
}
1
2
3
4
//sample.cppm 主模块接口
export module sample;
export import :a; // 导入并导出分区sample:a
//... 代码省略和前例一致
1
2
3
4
5
6
7
//sample.cc
module sample;
//...
int foo() {
return foo_impl(); // foo_impl虽未导出,但是对同sample模块可见
}
//...
1
2
3
4
5
6
7
8
//test_main.cc
import sample;
int main() {
//...
foooo(); // foooo可见

//...
}
1
2
3
4
5
6
7
8
9
G++:=/opt/rh/gcc-toolset-14/root/usr/bin/g++
GCC_MODULE_FLAGS:=-std=c++20 -fmodules-ts
all:
$(G++) $(GCC_MODULE_FLAGS) -x c++-system-header iostream
$(G++) -g $(GCC_MODULE_FLAGS) -x c++ -c ../src/modules/sample_a.cc -o sample_a_impl.o
$(G++) -g $(GCC_MODULE_FLAGS) -x c++ -c ../src/modules/sample.cppm -o sample_int.o
$(G++) -g $(GCC_MODULE_FLAGS) -x c++ -c ../src/modules/sample.cc -o sample_impl.o
$(G++) -g $(GCC_MODULE_FLAGS) -x c++ -c ../src/test_main.cc -o test_main.o
$(G++) -static-libstdc++ sample_a_impl.o sample_int.o sample_impl.o test_main.o -o test

测试时这里发现个问题,我无法将分区实现拆分成分区模块接口和模块实现(上面中的sample_a.cc拆分sample_a.cppm和sample_a.cc),编译时gcc会报重复定义等问题,感觉是gcc的bug。因为从代码结构来说,对于需要暴露接口的代码是需要拆分接口和实现的,即使是模块分区,同时gcc当前明确说明Partition definition visibility rules是有问题的。
测试时还发现即使没import :a在sample.cc中仍然可以使用:a分区的接口,应该同样是gcc的bug。

模块所有权

模块内声明通常都绑定于模块,其定义必须放在模块内。
20标准在内部链接与外部链接之外增加了模块链接,标准中把模块链接定义为在具名模块中声明并未导出的符号,同时定义模块链接的符号可被同模块下其他单元引用(不知道是否因为这个导致上述分区即使不被import也能被其他分区使用)。在实现上,gcc和clang使用了新的name mangling方式编译模块符号,比如上述示例中的export void fo()在我本机gcc14编译后为_ZW6sample2fovint foo()编译为_ZN6JoshuaW6sample3fooEv、未导出的void foo_impl() 编译为_ZW6sample8foo_implv(当前c++filt无法demangling该符号,较新版本gdb可以),可见所有符号都绑定在了module上。
但这种模块链接行为也可以打破,使用’语言链接’ extern "C" exter "C++":

1
2
3
4
5
6
//sample.cppm
extern "C" void my_foo() {}

namespace Joshua {
export extern "C++" void my_foo() {}
}

查看编译后的符号可以看到my_foo_ZN6Joshua6my_fooEv(为添加module的正常mangle,c++filt后为Joshua::my_foo())。

gcc14使用module的常见问题

系统头文件模块编译

上文也提到了,std的模块化在23标准里,在gcc 20标准里想要通过模块加速std编译需要额外编译命令:

1
/opt/rh/gcc-toolset-14/root/usr/bin/g++ -std=c++20 -fmodules-ts -x c++-system-header iostream

-fmodules-ts是gcc当前不完全支持module的实验选项,-x c++-system-header表明当前编译的是c++系统头文件(系统头文件没后缀,gcc认不出,gcc官方说法)。
执行以上命令后,会生成一个gcm.cache的目录以及一个iostream.gcm的“预”编译缓存文件,该文件即为后续import <iostream>;需要用到的编译缓存文件。
顺带解释下,当前gcc使用后缀名.gcm的Compiled Module Interfece(CMI)机制,见gcc 文档;clang使用后缀名.pcm的Built Module Interface(BMI)机制,见clang 文档;msvc使用后缀名.ifc的IFC机制,微软相关文档较少这里就不贴了。这里不详述具体的缓存文件机制,毕竟三个编译器实现也不太一样,对使用者来说,只需要知道这个缓存文件即是编译器用以加速编译过程的“预”编译机制即可。
使用gcc14导入std头文件时,实际测试遇到了各种问题,包含未定义、重复定义、使用定义ambiguous各种错误,可能由于现有头文件的依赖关系已经错综复杂,需要“预”编译各种依赖的头文件。

用户头文件模块编译

对于用户头文件可以使用如下指令生成gcm:

1
/opt/rh/gcc-toolset-14/root/usr/bin/g++ -std=c++20 -fmodules-ts -x c++-header -c xxx.h

总结

module的加入带来了很多显而易见的好处,更快的编译效率、更直观的依赖关系、避免了名称污染,但就目前编译器、build工具的支持情况来说,要在实际项目中真正用起来还是会碰到一定困难,不过现在也开始有一些大型开源项目开始用起module了(见参考链接)。
展望未来,包管理器是否也可以提上日程了?这个当今其他现代语言都有的机制,对于c++这个已经42岁(c++初版1983年发布)的老家伙来说想要变得更加‘modern’也是必经之路。(你可以在参考链接中看到connan基于module的打包尝试)

参考链接

How we adopted C++ modules for a large codebase
C++ Modules: The Packaging Story

infinity–AI原生数据库,使用clang编译,大范围使用了module加速编译。