C++20 Modules:让编译快起来,让宏别乱跑
你一定有过这种体验:改了一个头文件里的一行注释,结果整个项目重新编译了十分钟。这不是编译器笨,而是 #include 这套机制从 1972 年就没变过——它只是"把文件内容原样粘过来"。C++20 的 Modules 是四十多年来第一次真正改变"代码怎么组织"这件事。这一章把它的动机、语法、构建系统配合、以及"现在到底能不能用"讲清楚。
#include 到底慢在哪、脏在哪
先别急着学语法,把问题根因看清楚,你才知道 Modules 哪些设计是在解决什么。#include 由预处理器执行,它在编译器真正开始分析代码之前做纯文本替换。
论预处理器带来的三个结构性问题
① 重复解析,编译时间爆炸:你在 100 个源文件里都写了 #include <vector>,标准库的 vector 头文件就被完整解析了 100 遍。每个翻译单元都是一座孤岛,编译器无法在文件之间复用解析结果(预编译头 PCH 是缓解手段,但很脆)。
② 宏污染,名字说变就变:某个头文件里写了 #define max(a,b) ...,你后面所有用到 max 这个名字(包括 std::max)都会被替换掉。这种错很难定位——报错位置和真正的原因可能隔着几千行。
③ 顺序敏感,包含图不可控:头文件的语义依赖"谁先被包含"。A 头能编过、B 头单独编不过,只有 #include A 之后才行。这三点加在一起,就是"改一行、编十分钟"和"编译错误看不懂"的根源。
| 维度 | #include(文本包含) | Modules(语义导入) |
|---|---|---|
| 本质 | 预处理器做文本替换,把文件内容粘进来 | 编译器把模块编译成二进制接口(BMI),导入时直接读取 |
| 编译速度 | 同一头文件在每个翻译单元里重复解析 | 模块接口只编译一次,此后导入是读取二进制,通常显著更快 |
| 宏 | 会泄漏到所有包含它的文件,污染命名空间 | 模块内部的宏默认不导出,除非显式 export |
| 顺序 | 结果依赖包含顺序,脆弱 | 导入顺序无关,语义稳定 |
| 可见性 | 头文件里所有声明都可见,无法细粒度控制 | 只有 export 的内容对外可见,其余是模块私有 |
| ODR 风险 | 同一声明在不同 TU 里可能不一致,链接期才炸 | 接口唯一,ODR 问题从机制上大幅减少 |
最小可用语法:export module / import / export
Modules 的核心只有三个关键字,别被各种变体吓到。声明模块用 export module 名字;,使用模块用 import 名字;,把某个声明开放给外部用 export 前缀。
一个完整的模块:接口单元 + 实现单元 + 使用它的主程序
论接口单元 vs 实现单元的判据
① 接口单元(interface unit):第一行是 export module 名字;。它只放"别人需要知道的东西"——函数声明、类定义、常量。它负责生成 BMI,别人 import 时读的就是它。
② 实现单元(implementation unit):第一行是 module 名字;(没有 export)。它放具体实现,可以随便 #include 第三方头文件、用宏,都不会泄漏出去。
③ 为什么建议拆开:如果实现细节(比如某个第三方库的头)写在接口单元里,每次改实现都要重新生成 BMI,所有依赖这个模块的翻译单元都要重编——那编译加速就白做了。拆开之后,改实现只影响实现单元本身。记住这条判据:接口单元要尽量稳定,实现单元随便改。
模块分区:大模块怎么拆文件
真实的模块不会只有一个接口单元。比如 math 模块下有向量、矩阵、统计三块,你想分开写文件,但对外还是"一个 math 模块"。这就是模块分区(module partition)。
用分区把大模块拆成多个文件,对外仍是单一模块
分区不是用来做代码组织的装饰,它有一个硬性约束:所有分区必须属于同一个模块(同一个构建目标),它们共享模块的所有权,且不能被分别安装/分发。如果你想把一块功能独立发布给别人用,应该做成独立模块而不是分区。另外 export import :part 这个"转发"写法很容易写错成 import :part——后者不会导出,消费者 import 主模块时拿不到分区里的东西,报的错还是"找不到符号",排查起来很绕。
过渡方案:import std; 与 header unit
完全重写所有头文件成模块是不可能的,所以标准给了两条台阶:header unit(把现有头文件当模块导入)和 import std;(C++23 引入的标准库模块)。这是从"全是 include"迁移到"全是 import"的现实路径。
| 写法 | 含义 | 现状与建议 |
|---|---|---|
#include <vector> | 老写法,文本包含 | 永远支持。存量代码继续用,别为了迁移而迁移。 |
import <vector>; | header unit:把系统/第三方头文件当模块导入 | 语义上等价于 include,但支持 PCH 一样的复用,宏不会泄漏。需要构建系统显式支持头单元扫描。 |
import "my_header.h"; | 把项目内的头文件当 header unit 导入 | 可用于自己项目里暂时不想改写的头文件。工具链支持差异较大,谨慎使用。 |
import std; | C++23 起标准库的模块形式,一条顶几十条 include | 编译器厂商逐步落地(GCC 15 起支持较好、Clang 需配并生成 std 模块)。这是理想终态,但目前不建议作为唯一依赖。 |
迁移期的三种混用形态,从保守到激进
同一次编译里既 import std; 又 #include <vector>,在多数实现上会导致重复定义或链接错误——因为标准库的类型从两条路径各进来了一份。规则:一个翻译单元内,标准库要么全走 import,要么全走 include,不要混。这条在团队协作里非常容易犯规:你 import std 写了新文件,同事复制旧文件的 include 过来,一编译就炸。建议在项目里明确约定并用 lint 工具检查。
模块与 CMake 的配合
这是 Modules 落地最现实的门槛。编译器需要知道"哪些文件是模块接口、扫描顺序是什么",而这个信息必须由构建系统提供。CMake 从 3.28 起提供了 FILE_SET CXX_MODULES,这是目前最标准的做法(更早的 CMake 需要手写依赖扫描,非常痛苦)。
CMakeLists.txt:用 FILE_SET 声明模块源文件
命令行手工编译(理解构建系统在背后做了什么)
论构建系统要为模块做三件事
① 识别:扫描每个源文件的开头,判断它是模块接口、模块实现,还是普通翻译单元。所以 FILE_SET CXX_MODULES 必须把接口文件列进去——列漏了会变成"找不到模块"。
② 排序:模块 A 导入了模块 B,必须先编译 B 生成 BMI,再编译 A。这个依赖图由构建系统生成,是模块构建的核心难点(也是为什么低版本 CMake 很难用)。
③ 传递路径:消费方编译时需要知道 BMI 在哪,对应 Clang 的 -fmodule-file=、MSVC 的 /reference、GCC 的隐式查找。CMake 会通过 target 的 usage requirements 自动传下去。所以你只要把模块写成 target_sources,剩下的依赖顺序与传递都会自动处理好。
export 与 export import 的区别,以及"可见性"的真正含义
这两个写法差一个词,语义差很远,是面试和 review 里的高频失分点。
| 写法 | 语义 |
|---|---|
export int f(); | 把 f 这个声明开放给导入本模块的人。这是最基本的导出。 |
export import math:vector; | 转发导入:不仅我自己导入了分区,我导入的东西也一起对使用者可见。使用者 import 主模块就等于也拿到了 vector 分区。这是"聚合"模块的标准用法。 |
import math:vector; | 只是我自己用了这个分区,使用者看不到。适合内部实现依赖。 |
export { ... }(导出块) | 把一整组声明批量导出,比逐个加 export 更清爽。 |
export namespace ns { ... } | 导出整个命名空间内的名字。注意:这会把命名空间里所有名字都导出,慎用。 |
三种可见性一次看清
误解一:以为 import 了模块就能用它的私有东西。模块的私有声明在编译期就不可见,报错是明确的"未声明标识符",不会像头文件那样出现"链接时才炸"的诡异问题。这其实是好事——错误暴露得更早。
误解二:以为 export 一个类就需要 export 它的私有成员类型。不需要。只要私有成员类型不去出现在 public 接口的签名里,外部就不需要知道它。这和头文件的体验完全不同——头文件里你必须把私有成员类型所在的头也包含进来。这正是模块减少编译依赖的关键收益:接口的"传递性依赖"显著变少了。
工具链成熟度与迁移策略
最后回答那个最实际的问题:2026 年了,Modules 能上生产了吗?答案是"视情况"——它已经可用,但三家编译器的实现细节和支持程度并不完全一致,跨平台项目要留出验证成本。
| 编译器 | 状态 | 注意点 |
|---|---|---|
| MSVC | 支持最完整,Visual Studio 里开箱可用 | 需要 /std:c++20 /utf-8;对模块的诊断信息最友好。缺点是只能在 Windows/VS 生态用。 |
| Clang | 实现完整,但需要显式配置 | 要生成并管理 PCM 文件、传 -fmodule-file=;import std; 需要单独构建标准库模块,步骤较多。 |
| GCC | 可用,需显式开启 | 要加 -fmodules-ts,且模块缓存目录管理比较敏感;版本之间的行为有差异,建议锁定版本。 |
论一条务实的迁移路线
第一步:只在新代码里用模块。新增的功能模块写成 .cppm,存量代码原样不动。新旧共存是完全合法的,这样风险可控,还能立刻吃到新模块的编译加速。
第二步:把"编译最慢的头文件"改成模块。挑那些被几百个源文件包含的重头戏(工具类库、基础数据结构),改成一个模块,收益最明显。
第三步:第三方库继续用 header unit 或 include。第三方代码你改不了,也保证不了它内部没有奇怪的宏。import <lib.h>; 是比 include 更好的选择(宏不泄漏),但前提是构建系统支持头单元扫描。
第四步:等 import std; 三家都稳了,再统一标准库入口。不要为了迁移而迁移——模块的收益是编译速度与封装性,如果你的项目编译只要 10 秒,那这件事的优先级应该排在很后面。
坑一:模块与宏。宏不会被模块导出,这通常是好事,但如果你依赖某个宏做条件编译(比如 #ifdef DEBUG),在接口单元里它只在编译这个模块时生效,消费方编译时不会重新求值。所以别在模块接口里写依赖宏的条件导出——不同编译选项下生成的 BMI 语义会不一致,非常难查。
坑二:和预编译头(PCH)冲突。同一个构建里既用 PCH 又用模块,很多工具链会出问题甚至报错。如果你在迁移到模块,就不要再维护 PCH 了,模块本身就是更好的替代品。
坑三:三家编译器对 import std; 的落地方式不一样。有的需要你跑一个额外的脚本生成标准库模块。CI 上要先验证目标编译器的具体步骤,别在本地 MSVC 上跑通了就以为 Linux CI 也能过。
坑四:把模块当成"新的头文件"来用。最常见的形式是:类定义写在 .cppm 里,所有实现也塞在同一个 .cppm 里,只是把 #include 换成 import。这样能编过,但你完全没拿到模块的核心收益(接口稳定、实现解耦、增量编译),而且每次改实现都会让依赖方重编。接口单元和实现单元要分开写。
① #include 是文本替换,带来重复解析、宏污染、顺序敏感三个结构性缺陷,Modules 就是来治这三件事的。
② 核心语法只有三个:export module 名字; 声明模块,import 名字; 使用模块,export 前缀控制可见性。
③ 接口单元(export module)放声明、要稳定;实现单元(module)放实现、随便改。分开写才有收益。
④ 分区用 module 名字:分区名,主单元用 export import :分区 转发;分区不能独立分发。
⑤ 过渡方案是 header unit(import <vector>;)和 import std;,但同一个翻译单元里不要 import 和 include 混用标准库。
⑥ CMake 3.28+ 用 FILE_SET CXX_MODULES 声明接口文件,依赖顺序由构建系统自动处理。
⑦ 迁移要渐进:新代码先用,改掉最慢的头文件,第三方继续 include,别做一次性大重写。
自测
1.(概念题)export module math; 和 module math; 有什么区别?为什么建议把它们写在不同的文件里?
查看答案
答案:前者是模块接口单元(导出声明、生成 BMI),后者是模块实现单元(写实现)。建议分文件的原因:接口单元一旦改动,所有导入它的翻译单元都要重新编译;把实现放到实现单元,改实现只影响那一个文件。如果全写在接口单元里,编译加速的收益会大打折扣。
2.(语法题)export import :util; 和 import :util; 在"对外可见性"上有什么差别?
查看答案
答案:export import :util; 是转发导出——使用者 import 我的主模块时,也自动拿到了 util 分区里的内容。import :util; 只是我自己内部使用,使用者看不到。聚合型模块(对外提供统一入口)应该用前者,内部实现依赖用后者。写错成后者时,使用者会报"找不到符号",很像链接错误,容易误判。
3.(工程题)你的项目从 #include 迁移到 import,编译时间反而变长了。可能的原因有哪些?
查看答案
答案:① 所有实现都写在接口单元里——改一行实现就要重新生成 BMI 并让所有依赖方重编,比原来还慢;② 模块依赖关系设计成了链式——A 导入 B、B 导入 C、C 导入 D,改动的传播范围和头文件一样大,没有解耦;③ 构建系统没有开启增量模块缓存,每次都重新扫描生成;④ 模块的粒度太细碎,每个小功能一个模块,构建系统的调度开销反而上来了。核心思路:模块的收益来自"接口稳定 + 编译一次复用",粒度设计比语法更重要。