Modules模块化设计
# 24.Modules模块化设计
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. 模块声明与导出
- 4. import 与模块图
- 5. 全局模块片段与 legacy
- 6. 二进制接口 BMI
- 7. 构建系统集成
- 8. 模块与模板的交互
- 9. 大规模迁移策略
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一次头文件改动的雪崩
某量化交易系统的 C++ 代码库拥有 2400 个翻译单元(.cpp 文件)。全量 Debug 构建需要 11 分 30 秒,Release 构建需要 18 分钟。一个周四下午,实习生接到任务:「把 constants.h 里的 MAX_ORDER_SIZE 从 1024 改成 2048」。
他改了一行:
// constants.h —— 被 2300 个 .cpp 文件直接或间接包含
constexpr int MAX_ORDER_SIZE = 1024; // → 改成 2048
2
全量构建重新跑了 18 分钟。这不是 constants.h 的问题——这是头文件模型(header model)的根本缺陷:
constants.h 改动
│
├─ 2300 个 .cpp 直接或间接 #include "constants.h"
│ └─ 每个 .cpp 都要重新做:预处理 → 编译 → 汇编
│
├─ 每个 .cpp 里 constants.h 的内容被复制 2300 遍
│ └─ 编译器把同一份 `constexpr int MAX_ORDER_SIZE` 解析了 2300 次
│
└─ 链接器接收 2300 个 .o,对每个符号做重复消除(COMDAT)
└─ 链接时间: 3 分 20 秒(占全流程 18%)
2
3
4
5
6
7
8
9
10
团队开始讨论换用 C++20 Modules。初步测算:同样的改动,用模块后只需重编译依赖该模块的文件——约 45 个翻译单元——从 18 分钟降到 2 分钟以内。
但他们动手后,碰到了三个意料之外的问题。
# 1.2 模块三击不中
第一击:BMI 版本地狱
团队用 GCC 13.2 构建了模块的 BMI(二进制模块接口),把它提交到共享的构建缓存里。另一个同事的机器运行 GCC 13.1——构建时报错:
fatal error: BMI file 'constants.gcm' is from a different compiler version
GCC 的 .gcm / Clang 的 .pcm / MSVC 的 .ifc——三家编译器的 BMI 格式互不兼容,同一编译器的不同大版本也不兼容。模块的缓存不是跨机器的——是跨编译选项的。
第二击:import 与 #include 的 ODR 陷阱
// order.ixx —— 模块接口单元
export module order;
#include "legacy_utils.h" // legacy_utils.h 里定义了 struct Config { int a; };
export struct Order {
Config cfg; // 用的 #include 进来的 Config
};
2
3
4
5
6
7
8
// trade.cpp —— 普通翻译单元(未模块化)
import order; // ← 拿到 order 模块里的 Config(从 legacy_utils.h 来)
#include "legacy_utils.h" // ← 又直接用 #include 拿了一份 Config(从同一个头文件来)
Config c; // ⚠️ ODR?——同一个 Config,从模块和头文件两个路径进入
2
3
4
5
6
GCC 14 编译报出:error: reference to 'Config' is ambiguous——不是 Config 有两个定义,而是 Config 从两个「归属」进来的,**模块归属(module attachment)**与「全局片段归属」被编译器视为不同来源。
第三击:CMake 的模块依赖排序 BUG
# CMakeLists.txt —— 声明模块依赖
target_sources(my_lib
PUBLIC FILE_SET CXX_MODULES
BASE_DIRS src
FILES
src/base.ixx
src/order.ixx # order 依赖 base
src/trade.ixx # trade 依赖 order
)
2
3
4
5
6
7
8
9
order.ixx 里写 import base;,理论上 CMake 应该自动发现这个依赖。但 CMake 3.28 的模块依赖扫描器在「import 语句出现在 #ifdef 里」时漏掉了它——导致 order.ixx 和 base.ixx 被并行编译,而 order.ixx 先编译完成时 base 的 BMI 还不存在,编译硬崩。
# 1.3 七个待解疑问
① export / import 的完整语法是什么? 能导出类、函数模板、using 别名吗? → 第 3 / 第 4 章
② 模块分区 (partition) 是干什么的? 怎么拆一个模块成多个文件? → 第 4.2 节
③ 模块怎么和老的头文件共存? #include 和 import 能混用吗? → 第 5 章
④ BMI 到底是什么? 为什么三个编译器的 BMI 不兼容? → 第 6 章
⑤ CMake 怎么支持模块? 怎么保证增量构建时 BMI 缓存的一致性? → 第 7 章
⑥ 模板/类模板在模块里导入导出有什么特殊规则? ODR 在模块时代怎么理解? → 第 8 章
⑦ 一个 2000+ 翻译单元的存量项目怎么渐进式迁移到模块? 分几步? → 第 9 / 第 10 章
2
3
4
5
6
7
# 2. 架构概览
# 2.1 Modules 四层模型
C++20 Modules 不是「把 #include 换成 import」的语法糖——它是编译模型的重新设计:
┌─────────────────────────────────────────────────────────────────┐
│ C++20 Modules 体系 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
│ │ ① 编译期 │ │ ② 二进制 │ │ ③ 构建 │ │ ④ 迁移 │ │
│ │ 模块语法 │ ──► │ 接口 BMI │ ──► │ 系统集成 │ ◄── │ 旧代码 │ │
│ │ export │ │ .gcm │ │ CMake │ │ #include│ │
│ │ import │ │ .pcm │ │ 依赖扫描 │ │ → import│ │
│ │ partition│ │ .ifc │ │ 并行编译 │ │ 四步法 │ │
│ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │
│ │ │ │ │ │
│ └────────────────┼─────────────────┼───────────────┘ │
│ ▼ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ 编译时间从 18 分钟 → 2 分钟 │ │
│ │ 头文件传染重编彻底消除 │ │
│ │ ODR 从「链接器口头保证」→ 编译器保证│ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
和头文件的本质区别:
| 维度 | #include 头文件 | import 模块 |
|---|---|---|
| 工作方式 | 文本复制:把头文件内容粘贴到每个 .cpp | 预编译:编译一次,产出 BMI,import 时读 BMI |
| 解析次数 | 每 #include 一次就完整解析一遍 | 只解析一次(模块编译时),后续 import 全走 BMI |
| 宏环境 | 取决于 #include 前面的宏定义 | 不受调用方宏影响 |
| 符号可见性 | 默认全部可见 | 默认全部隐藏,export 才可见 |
| 编译时间 | O(N×M) — N 个 .cpp × M 个头文件 | O(N+M) — N 个 .cpp + M 个模块各编译一次 |
| ODR | 靠 COMDAT 在链接时硬消除 | 在模块编译时就保证唯一 |
# 2.2 为何这么切
疑惑:为什么 Modules 花了 C++11 到 C++20 整整 10 年才进标准?不就是把 #include 换成 import 吗?
论证:
- 「宏问题」是关键障碍——
#include的语义里,宏在前面的定义会影响头文件内容。import必须切断这条依赖——但存量 C++ 代码里随处可见#include <windows.h>前面必须定义WIN32_LEAN_AND_MEAN的宏用法。模块必须在「保留宏能力」和「切断宏传染」之间选一条路——C++20 选了后者(import不带宏),同时用「全局模块片段」给旧头文件一个逃生通道。 - 模板的「可达性」比函数复杂一个维度——普通函数的符号归属很明确(定义在哪个翻译单元)。但模板没有明确的「编译单元归属」——每个用到的翻译单元都可以实例化。模块必须重新定义「模板实体从哪个模块来」,这直接催生了**模块链接(module linkage)和可达性(reachability)**两套新概念(第 8 章)。
- BMI 兼容性是编译器的噩梦——BMI 本质上是编译器内部的 AST/符号表的「序列化快照」。不同编译器内部表示不同(GCC 用 tree、Clang 用 ASTContext、MSVC 用 IFCLibrary),序列化成同一格式是不可能的。委员会放弃了对 BMI 格式的标准化,让编译器各自负责自己的 BMI——这是务实的选择,也是「BMI 不能跨编译器」的根源。
- 构建系统的依赖发现必须重塑——头文件的依赖很容易:
#include "a.h"→ a.h 存在就行。模块的依赖是:import foo;→ 需要先找到「哪个文件定义了模块 foo」→ 再编译它生成 BMI → 再读 BMI。这意味着构建系统必须有预扫描阶段——在编译之前,先扫描所有源文件里的import语句,建立模块依赖图。make/ninja 本身不知道 C++ 模块——必须由 CMake/MSBuild 等上层系统补这个能力。 - 反向验证:C++0x 时代曾经有过一份更激进的 Modules 提案——要求 BMI 格式标准化、要求编译器做跨翻译单元的模板可达性分析。但那时根本没有编译器实现能证明可行性,被一致否决。10 年后 C++20 版本是所有编译器(GCC/Clang/MSVC)都已经在各自代码库里有了可工作的原型后的最大公约数。
结论:Modules 不是「#include 的简单替换」——它是一场编译模型重构:宏隔离、模板可达性重定义、BMI 缓存一致性、构建系统依赖发现——这四个维度每一个都足够独立成章。理解这四层,就理解了为什么 10 年才落地——不是没能力,是所有编译器都得先自己证明这条路走得通。
# 3. 模块声明与导出
# 3.1 主模块接口单元三行起手
一个最小的模块只有三行:
// ====== math.ixx (模块接口单元)======
export module math; // ① 声明本文件定义一个名为 math 的模块
export int add(int a, int b) // ② export 关键字对外暴露
{ return a + b; }
// =========================================
2
3
4
5
6
编译成模块:
# GCC:产生 math.gcm(BMI)+ math.o(目标文件)
g++ -std=c++20 -fmodules-ts -c math.ixx -o math.o
# Clang:
clang++ -std=c++20 -stdlib=libc++ --precompile math.cppm -o math.pcm
clang++ -std=c++20 -c math.pcm -o math.o
# MSVC:
cl /std:c++20 /interface /TP math.ixx /reference math=math.ifc
2
3
4
5
6
7
8
9
文件扩展名约定(不是强制,但业界趋向统一):
| 编译器 | 接口单元 | 实现单元 | BMI 扩展名 |
|---|---|---|---|
| GCC | .ixx / .cppm | .cpp | .gcm |
| Clang | .cppm | .cpp | .pcm |
| MSVC | .ixx | .cpp | .ifc |
# 3.2 export 三段式语法
export 有三种写法:
export module math; // 模块声明行——全文件只有一个
// ① 单个声明导出(一行一个 export)
export int add(int a, int b);
// ② 导出块(一段 export { ... })
export {
int mul(int a, int b) { return a * b; }
int div(int a, int b) { return a / b; }
constexpr auto version = "1.0";
}
// ③ 导出 using 声明
namespace detail { int helper(); }
export using detail::helper; // 导出一个别的命名空间的符号
2
3
4
5
6
7
8
9
10
11
12
13
14
15
什么可以 export:
// ✅ 可以导出:
export int x; // 变量
export void f(); // 函数声明/定义
export class C { /* ... */ }; // 类
export template <typename T> T max(T,T); // 函数模板
export template <typename T> class Vec; // 类模板
export using IntVec = std::vector<int>; // using 别名
export namespace N { int x; } // 命名空间(导出内部所有公开符号)
export enum class Color { R, G, B }; // 枚举
// ❌ 不能导出:
export using namespace std; // using namespace 不能导出
export static int s = 0; // 内部链接符号不能导出
export #define E 2.718 // 宏不能导出(模块没有宏的概念)
2
3
4
5
6
7
8
9
10
11
12
13
14
# 3.3 导出块与命名空间导出
直接用 export namespace 导出整个命名空间:
export module geometry;
export namespace geo {
struct Point { double x, y; };
double distance(Point a, Point b) {
double dx = a.x - b.x, dy = a.y - b.y;
return std::sqrt(dx*dx + dy*dy);
}
}
// 使用方:import geometry; → geo::Point, geo::distance 全都可用
2
3
4
5
6
7
8
9
10
11
导出块 vs 逐个导出的选择:
| 方式 | 优点 | 缺点 |
|---|---|---|
逐个 export | 精确控制 API 面 | 代码啰唆 |
export { ... } | 一块导出 | 大括号内所有人可见 |
export namespace N { ... } | 保持命名空间组织 | 命名空间内所有符号一并导出 |
生产建议:对外 API 用逐个 export(显式控制 API 面),内部模块用 export namespace。
# 3.4 模块实现单元的职责
模块实现单元是 .cpp 文件——不含 export 声明行,只有函数体:
// ====== math.ixx(接口单元) ======
export module math;
export int factorial(int n); // 仅声明,不定义
// ====== math_impl.cpp(实现单元) ======
module math; // 注意:不是 export module——是实现单元
int factorial(int n) { // 不写 export——接口已经导出了
return n <= 1 ? 1 : n * factorial(n - 1);
}
2
3
4
5
6
7
8
9
10
实现单元的规则:
- 必须用
module math;(不带export)开头 - 所有符号的实现不需要再写
export——接口单元的export声明已经足够 - 实现单元可以包含任意多的函数定义
- 关键好处:修改实现单元不会导致接口的 BMI 失效——使用方只需重编译实现单元本身
对案例 1.1 的启示:如果有模块实现单元,改一行常数根本不会触发任何使用方的重编译——只有实现单元自己重新编译。
# 4. import 与模块图
# 4.1 import 的传递性规则
核心规则:import 默认不传递——你 import 了 A,不代表能用 A 所 import 的 B。
// ====== base.ixx ======
export module base;
export int base_fn();
// ====== middle.ixx ======
export module middle;
import base; // middle 依赖 base
export int middle_fn();
// ====== user.cpp ======
import middle;
base_fn(); // ❌ 错误:base_fn 不可见!
// 虽然 middle 依赖 base,但 user 没有直接 import base
2
3
4
5
6
7
8
9
10
11
12
13
解决办法:export import——显式传递:
// ====== middle.ixx(改进版) ======
export module middle;
export import base; // ← 加 export
export int middle_fn();
// user.cpp 里:
import middle;
base_fn(); // ✅ middle 显式传递了 base
2
3
4
5
6
7
8
export import 是模块设计的刻意选择——不默认传递,迫使每个模块显式声明「我要把哪个依赖暴露给调用方」。这和头文件模型(所有被 #include 的东西对所有包含者可见)形成鲜明对比。
# 4.2 模块分区的设计意图
一个大型模块可以拆成多个分区(partition)——每个分区是独立的文件,但共享主模块的可见性空间:
// ====== geo-point.ixx(分区接口单元) ======
export module geo:point; // 冒号分隔——geo 模块的 point 分区
export struct Point { double x, y; };
// ====== geo-distance.ixx(分区接口单元) ======
export module geo:distance;
import :point; // 同一模块的分区之间,直接用 :point 引用
export double distance(Point a, Point b);
// ====== geo.ixx(主模块接口单元) ======
export module geo;
export import :point; // 把分区的内容暴露给模块使用方
export import :distance;
// ====== user.cpp ======
import geo; // 拿到了 Point 和 distance——主模块什么都给你了
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
分区的核心约束:
geo:point ← 分区接口单元(可以 export)
geo:distance
geo:impl ← 分区实现单元(只能 module geo:impl; 不能 export)
主模块 geo.ixx 是唯一的门面——使用方只能 import geo,
不能 import geo:point 直接跳到某个分区。
2
3
4
5
6
分区 vs 子模块:C++20 选了分区(module A:B)而不是子模块(module A.B)——因为分区之间的符号共享是「同一模块内部」的,比子模块之间的隔离更高效(没有跨模块的 BMI 检查)。
# 4.3 模块的依赖 DAG
模块依赖关系构成一个有向无环图(DAG)——这是构建系统做增量和并行编译的基础:
┌─────────┐
│ math │ ← 基础模块
└────┬────┘
│ (export import)
┌───────┼───────┐
▼ ▼
┌─────────┐ ┌─────────┐
│ geo │ │ stats │ ← 中间模块
└────┬────┘ └────┬────┘
│ │
└───────┬───────┘
▼
┌─────────┐
│ engine │ ← 最终模块
└─────────┘
增量构建逻辑:
修改 geo:distance → 只重编 geo 模块(其 BMI 变了)
→ engine 依赖 geo → engine 也要重编
→ stats 不依赖 geo → stats 不用重编!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
这和头文件模型对比:
- 头文件:改一个
.h→ 所有#include的.cpp全重编 - 模块:改一个分区 → 只有这个模块和直接依赖它的模块重编
# 4.4 import 与 #include 的本质差异
| 维度 | #include | import |
|---|---|---|
| 本质 | 文本复制 + 本地解析 | 读预编译的 BMI |
| 宏传递 | ✅ 向调用方传递宏 | ❌ 宏不传递 |
| 重复包含 | 需要 #pragma once 保护 | 编译器保证唯一 |
| 声明顺序 | 依赖 #include 先后 | 不依赖顺序 |
| ODR | 链接期 COMDAT 消重 | 编译期检查 |
| 编译时间 | O(N×M) | O(N+M) |
汇编等价验证(GCC 13.2 -O2):
// 头文件方案
#include "math.h"
int x = add(3, 4);
// 编译时:math.h 被复制到当前文件,add 原型被解析和声明。
// 链接时:链接器从 math.o 找 add 的定义。
// 模块方案
import math;
int x = add(3, 4);
// 编译时:读 math.gcm,提取 add 的声明(类型系统)。
// 链接时:链接器从 math.o 找 add 的定义(与头文件方案完全相同)。
2
3
4
5
6
7
8
9
10
11
两种方案的最终二进制完全相同——模块只改变了编译过程,不改链接过程。
# 5. 全局模块片段与 legacy
# 5.1 module; 前置声明区
要在模块里使用老式头文件(<iostream>、<vector> 等),必须在全局模块片段里 #include:
// ====== 全局模块片段 ======
module; // 特殊的模块声明——全局片段开始
#include <vector>
#include <string>
#include "legacy_config.h"
// ====== 模块导出区 ======
export module my_module;
export std::string greet(const std::vector<int>& nums);
2
3
4
5
6
7
8
9
10
11
规则:
module;必须在文件最顶部(之前只能有空行和注释)- 在
module;和export module之间#include的内容,是对旧世界的「桥接」 - 这些
#include进来的符号不会自动导出——只有被export显式引用的符号才会暴露
关键细节——module; 里声明的符号不归模块所有:
module;
#include "types.h" // 定义 struct Config { int x; };
export module order;
import "types.h"; // ← 错误:头文件已经在 module; 里引入过,不能再次 import
2
3
4
5
# 5.2 头文件单元的定位
C++23 引入头文件单元(header unit)——把老式头文件当模块来 import:
// 不需要把 iostream 改写成模块——直接 import 它
import <iostream>; // C++23:标准库头文件可以作为 importable header
import "legacy_config.h"; // 你自己的头文件也可以
2
3
头文件单元是编译器自动为头文件生成的模块包装:
import <vector>;
│
└─ 编译器把 <vector>当成一个模块处理
├─ 为它生成一个临时的 BMI
├─ 对 vector 的所有声明做封装
└─ 后续的 import <vector> 共享同一份 BMI
效果:和 #include <vector> 等价,但更快(只编译一次)
2
3
4
5
6
7
8
头文件单元的编译命令:
# GCC:把 iostream 预编译成头文件单元
g++ -std=c++20 -fmodules-ts -xc++-system-header iostream
# Clang:
clang++ -std=c++20 -xc++-system-header --precompile iostream -o iostream.pcm
2
3
4
5
# 5.3 importable header 的三种策略
一个旧头文件要能被 import,你有三种策略:
| 策略 | 做法 | 适用场景 |
|---|---|---|
| 包装器模块 | 写一个 .ixx 文件,module; 里 #include "legacy.h",然后 export using 需要的东西 | 需要控制暴露面 |
| 头文件单元 | import "legacy.h"; 直接使用 | 不做改造,先用起来 |
| 完全重写 | 把 legacy.h 重写成 export module legacy; 格式 | 长期目标 |
生产建议:先头文件单元(零成本先用起来),再包装器(控制 API 面),最后完全重写(长期维护)。
# 5.4 新旧混编的 ODR 防线
回到案例 1.2 的第二击——同一个头文件既被 #include 又被 import:
// 场景:legacy_utils.h 同时被老式 #include 和新式 import 引用
// file_a.cpp(老式)
#include "legacy_utils.h" // Config 归属:全局片段
// file_b.cpp(模块)
module;
#include "legacy_utils.h" // Config 归属:全局模块片段
export module my_mod;
export void f(Config c); // 对 Config 的引用
// file_c.cpp(新式)
import my_mod; // Config 从模块进来——归属:模块 my_mod
#include "legacy_utils.h" // Config 又从全局片段进来——归属:全局片段
Config c; // ❌ 冲突:同一个名字从两个归属进入
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
解决方向(C++23 强化):
- 统一入口——只通过
import或只通过#include,不要混用 - 用
extern "C++"的归属标记——C++23 引入#include的归属标记语法(编译器中实验性) - 构建系统检查——CMake 3.29+ 能检测「同一头文件同时被 import 和 #include」的情况
# 6. 二进制接口 BMI
# 6.1 BMI 的内部结构
BMI(Binary Module Interface)本质上是编译器内部 AST 和符号表的序列化快照:
一个 .gcm / .pcm / .ifc 文件内部:
┌────────────────────────────────────────────────┐
│ Magic Number: "GCM\0" / "CPCH" / "IFC " │
├────────────────────────────────────────────────┤
│ 字符串表 │
│ "math" → index 0 │
│ "add" → index 1 │
│ "int" → index 2 │
├────────────────────────────────────────────────┤
│ 模块名 + 版本哈希 │
│ module: math hash: 0x3A2F... │
├────────────────────────────────────────────────┤
│ 导出符号表 │
│ add(int,int) → int │
│ 签名:(int, int) → int │
│ mangled name: _Z3addii │
│ 定位:math.o 的符号表偏移 0x1040 │
├────────────────────────────────────────────────┤
│ 类型定义 (AST 序列化) │
│ struct Point { double x; double y; } │
│ 字段偏移:x=+0, y=+8 │
│ alignof: 8 │
├────────────────────────────────────────────────┤
│ 依赖模块指纹 │
│ 依赖 base.gcm: hash=0x7B1C... │
│ 依赖 geo.gcm: hash=0x9E4D... │
└────────────────────────────────────────────────┘
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
关键点:BMI 不是「另一种目标文件」——它不和 .o / .obj 竞争。BMI 只存类型信息和声明,不存函数体。函数体仍然存在 .o,链接时链接器去找。
# 6.2 三家编译器的 BMI 差异
GCC (.gcm) | Clang (.pcm) | MSVC (.ifc) | |
|---|---|---|---|
| 格式 | 自定二进制 | 自定二进制(基于 serialized AST) | IFC(唯一有公开规范的) |
| 是否跨编译器 | ❌ | ❌ | ❌ |
| 是否跨版本 | ❌ (major 版本不兼容) | ❌ | ⚠️ (MSVC 承诺向后兼容) |
是否依赖 -O2/-g | ✅ 会变 | ✅ 会变 | ✅ 会变(调试信息影响 IFC) |
| 公开文档 | 无 | 无 | IFC Specification (opens new window) |
生产结论:BMI 是不可移植的缓存——只应在本地或同构 CI 节点之间做缓存。不要把 BMI 当成「模块的 .lib 文件」分发给第三方。
# 6.3 BMI 兼容性的三重维度
一个模块的 BMI 何时需要重编译,取决于三个维度的「指纹」:
触发 BMI 重编译的变化:
├─ 维度 ①:模块源码变化
│ ├─ 接口文件(.ixx)改动 → BMI 必须重编
│ ├─ 实现文件(.cpp)改动 → BMI 不需要重编 ✅
│ └─ 分区接口改动 → BMI 需要重编
│
├─ 维度 ②:依赖模块 BMI 变化
│ ├─ 上游模块改动 → 下游模块的 BMI 可能需要重编
│ └─ 指纹匹配:如果上游模块导出的符号签名没变 → 不需要重编(C++23 精化)
│
└─ 维度 ③:编译标志变化
├─ `-O2` vs `-O0` → BMI 不同(优化级别影响内联决策的元信息)
├─ `-g` → BMI 不同
└─ `-DNDEBUG` → BMI 不同(条件编译影响导出符号集)
2
3
4
5
6
7
8
9
10
11
12
13
14
最省心的实践:CI 脚本统一 -O0 -g 产生 BMI 用于快速增量编译;发布构建用独立缓存目录存 -O2 的 BMI。
# 6.4 版本漂移与缓存策略
分布式构建系统(如 Bazel / BuildCache)处理 BMI 缓存的策略:
BMI 缓存 key 设计(推荐):
key = hash(
模块文件名 +
模块源码指纹 +
依赖模块的 BMI 指纹列表 +
编译选项指纹
)
缓存命中 = 同一个模块 → 同源码 → 同依赖 → 同编译选项 → 同 BMI
缓存未命中 = 任何一个变了 → 重编模块 + 重算反向依赖链
2
3
4
5
6
7
8
9
10
Hack 技巧:部分编译器允许「导出宏」嵌入进 BMI(GCC 的 -fmacro-prefix-map),但默认关——模块不传递宏,也不从外部接受宏。
# 7. 构建系统集成
# 7.1 静态依赖扫描
CMake 3.28+ 对 C++20 模块的支持分两个阶段:
CMake 处理 module 的两阶段:
阶段 ①:依赖扫描 (dependency scanning)
┌──────────────────────────────────────────────┐
│ CMake 调用编译器的「依赖扫描」模式 │
│ g++ -E -fdeps-file=out.dd source.cpp │
│ → 输出:这个 .cpp 使用了哪些模块 │
│ → 找到模块声明文件的路径 │
│ → 确定 .ixx 文件之间的编译顺序 │
└──────────────────────────────────────────────┘
│
▼
阶段 ②:编译 (compilation)
┌──────────────────────────────────────────────┐
│ 按依赖 DAG 的拓扑序编译模块 │
│ 先编译所有叶子模块(无依赖) │
│ 再编译中间模块(依赖已编译好) │
│ 最后编译使用方翻译单元 │
└──────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 7.2 CMake 的 C++20 模块支持
最小可用的 CMake 模块项目:
cmake_minimum_required(VERSION 3.28)
project(MyProject LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 定义一个库(带模块)
add_library(math_lib)
target_sources(math_lib
PUBLIC FILE_SET CXX_MODULES
BASE_DIRS src
FILES
src/math.ixx # export module math;
)
target_sources(math_lib
PRIVATE
src/math_impl.cpp # module math; 实现单元
)
# 使用这个库的可执行文件
add_executable(my_app src/main.cpp)
target_link_libraries(my_app PRIVATE math_lib)
# CMake 自动发现 main.cpp 里 import math; 的依赖
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
FILE_SET CXX_MODULES 是 CMake 3.28 的核心机制——CMake 自动做模块依赖扫描、自动决定编译顺序、自动管理 BMI 缓存。
# 7.3 并行编译的模块图
模块依赖的 DAG 决定了并行编译的上限:
假设依赖图:
A (无依赖)
├── B (依赖 A)
│ ├── D (依赖 B)
│ └── E (依赖 B)
└── C (依赖 A)
└── F (依赖 C)
时间线:
t=0: 编译 A [1 核]
t=1: A 完成 → 编译 B 和 C 并行 [2 核]
t=2: B,C 完成 → 编译 D、E、F 并行 [3 核]
最大并行度 = 依赖图的宽度
总编译时间 = 依赖图的关键路径长度
对比头文件方案:
t=0~t=100: 2300 个 .cpp 全部并行 [N 核]
→ 头文件方案看起来更「并行」——但每个 .cpp 内部消费了重复的头文件解析时间
→ 模块方案的「墙钟时间」显著更短(因为少了重复劳动)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 7.4 增量构建的 BMI 缓存一致性
增量构建中,CMake 检查模块是否需要重编译的逻辑:
增量构建决策流程:
对齐 math.ixx 的修改时间 vs math.gcm 的生成时间
├─ .ixx 更新 → 重新扫描 → 重新编译 math → 重新生成 .gcm
├─ math_impl.cpp 更新 → 只重新编译 math_impl.cpp → 不影响 .gcm ✅
├─ 上游模块 A 的 .gcm 变了 → math 依赖 A → math 也要重编
└─ .ixx 和上游都没变 → 读取已有 .gcm,跳过编译 ✅
增量构建的实质收益:
改造:0 个源文件变动 → 0 次编译 → 0.0s
头文件改一行 → 2300 个 TU 全编 → 18 分钟
模块改一个分区 → 4 个模块重编 → 45 秒
2
3
4
5
6
7
8
9
10
11
# 8. 模块与模板的交互
# 8.1 模板实体的可达性规则
模板在模块里有第二套规则——不是简单的「export 可见,不 export 不可见」:
// ====== vec.ixx ======
export module vec;
template <typename T>
class VecImpl { // 没有 export——内部的实现辅助模板
public:
void push_back(const T& v) { /* ... */ }
};
export template <typename T>
class Vec { // 导出的接口模板
VecImpl<T> impl; // 内部用了不导出的模板——OK
public:
void push_back(const T& v) {
impl.push_back(v); // VecImpl 从这里被「可达」
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
关键规则:Vec<int> 的实例化需要 VecImpl<int> 的定义。虽然 VecImpl 没有导出,但因为 Vec 是导出的,并且 Vec 的实例化需要用到 VecImpl,所以 VecImpl 对 Vec 的使用方是「可到达的」——不需要显式导出。
可到达性(reachability)vs 可见性(visibility):
| 可见性 | 可到达性 | |
|---|---|---|
| 是否可通过名称引用 | ✅ | ❌(不能直接用名字) |
| 是否参与实例化 | ✅ | ✅ |
| 是否需要 export | ✅ | ❌ |
# 8.2 模块链接 vs 内部链接
模块引入了第三种链接类型——模块链接(module linkage):
// 在模块内部,所有未 export 的符号默认拥有「模块链接」
export module my_mod;
static int hidden = 0; // 内部链接——此文件之外不可见
namespace { int also_hidden; } // 同上
int internal_func() { return 1; } // 模块链接——同一模块的所有单元可见
// 但模块外部不可见(没 export)
export int public_func() { // 外部链接——模块外面也能调
return internal_func(); // ← internal_func 在同一模块内,可达
}
2
3
4
5
6
7
8
9
10
11
三种链接类型的对比:
| 链接类型 | 作用范围 | 相同模块的其它单元 | 不同模块 | export 需要? |
|---|---|---|---|---|
内部链接 (static) | 单个翻译单元 | ❌ | ❌ | ❌ |
| 模块链接(默认) | 同一模块 | ✅ | ❌ | ❌ |
外部链接 (export) | 所有位置 | ✅ | ✅ | ✅ |
# 8.3 显式实例化在模块中的表达
第 17 篇的 extern template 在模块里换个写法:
// ====== vec.ixx ======
export module vec;
export template <typename T> class Vec { /* ... */ };
// 显式实例化声明:告诉使用方「这个特化我已经在别处提供了」
extern template class Vec<int>; // ← 和新式一致
// ====== vec_impl.cpp ======
module vec;
template class Vec<int>; // 显式实例化定义——在这个实现单元里
2
3
4
5
6
7
8
9
10
关键:显式实例化定义仍然在实现单元里——修改它不影响 BMI。
# 8.4 模块中的 ODR 新面貌
在头文件时代,ODR 是链接期「COMDAT 折叠」保证的——同一模板在每个翻译单元都实例化一份,链接时随机挑一个保留。
在模块时代,ODR 被前移到编译期:
头文件 ODR:
每个 .cpp 自己实例化 Vec<int>
→ 每个 .o 里都有一份 Vec<int>::push_back(int)
→ 链接器合并所有 COMDAT group → 只留一份
→ 编译时编译器从不检查「有没有别人也实例化了 Vec<int>」
模块 ODR:
模块导出 Vec<int>
→ 每个 import 这个模块的 .cpp 看到的是同一个 BMI 里的 Vec<int>
→ 编译器不需要实例化 Vec<int>——直接从 BMI 拿定义
→ 不存在重复实例化 → 不存在链接期的 COMDAT 折叠
2
3
4
5
6
7
8
9
10
11
收益:链接时间减少(不需要合并 COMDAT)、编译时间减少(不需要每个 TU 都实例化一遍)。
# 9. 大规模迁移策略
# 9.1 四步渐进迁移法
回答案例 1.1 的核心疑问——2000+ 翻译单元的项目怎么迁移:
第一步:标准库头文件 → 头文件单元(零代码改动)
import <vector>; import <string>; import <iostream>;
→ 编译器的系统头文件已支持 import
→ 这一步直接省掉每个 .cpp 里 <vector> 的重复解析
→ 编译时间:-30%
第二步:内部稳定的底层库 → 包装器模块
写一个 math.ixx:
module;
#include "math_core.h"
export module math;
export using math::add;
→ 不改造原有代码,加一层模块封装
→ 编译时间:-50%
第三步:中层模块 → 原生模块
把 order / trade / risk 等业务模块重写成 export module xxx;
头文件中的模板 → 保持模块内部可见性
→ 编译时间:-70%
第四步:顶层和对外接口模块化
把所有暴露给外部的 API 汇聚到统一的模块里
→ 编译时间:-85%+
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
每步之间可以独立发布、独立回滚——不需要一次性迁移整个代码库。
# 9.2 编译时间对比证据
对一个 2400 TU 的量化交易系统(GCC 13.2 -O2,AMD 7950X 16C/32T):
| 阶段 | 改动常数后的重编译时间 | 全量构建时间 |
|---|---|---|
| 全部头文件(基线) | 18 分 20 秒 | 18 分 20 秒 |
| 第 1 步:STL 头文件单元 | 13 分 10 秒 | 11 分 30 秒 |
| 第 2 步:底层库包装器 | 8 分 40 秒 | 7 分 10 秒 |
| 第 3 步:业务模块原生 | 3 分 20 秒 | 4 分 50 秒 |
| 第 4 步:全面模块化 | 45 秒 | 3 分 10 秒 |
增量构建的加速比远比全量构建显著——因为模块把「传染性重编译」砍断了。
# 9.3 为什么不一步到位
疑惑:为什么不直接一次性把所有 .h 重写成模块?
论证:
- 改动量巨大——一个 2000 TU 的项目通常有 400+ 个头文件。每个头文件都可能包含:宏定义(模块不支持)、复杂的
#ifdef条件编译(模块的 BMI 会在编译选项变化时失效)、依赖链交织(头文件 A 包含头文件 B,头文件 B 又包含头文件 A 的部分宏)。直接重写意味着同时解决所有这些问题——极高风险。 - 宏是不可替代的遗留系统——很多老项目有巨量的
#ifdef PLATFORM_LINUX或配置宏。模块不能用宏传参——这意味着所有宏控制的编译路径必须改写为if constexpr或模板,这是工作量黑洞。 - 部分第三方库的头文件不能改——你没有控制权。不能要求 boost、protobuf 等改写成模块。必须通过「包装器模块」或「头文件单元」来做桥接。
- 团队学习曲线——「同一个符号从两个归属进来」的 ODR 问题是新概念,团队全员的认知需要时间同步。
结论:模块迁移必须是增量的、可回滚的、每步有收益的。四步法每一步独立产生可度量的编译时间收益,是工程上的唯一可行路径。
# 9.4 C++23/26 的模块增强
正在标准化的模块增强:
| 特性 | 版本 | 说明 |
|---|---|---|
import std; 标准库模块 | C++23 | import std; 一行替代所有标准库头文件 |
| 头文件单元的 import | C++23 | import <vector>; 正式标准化 |
模块的 extern "C++" 归属标记 | C++23 | 解决新旧混编的归属冲突 |
| 标准库模块化规范 | C++23 | 所有标准库有一个统一的 .std 模块 |
| 反射 + 模块 | C++26 (预计) | 编译期反射信息与模块边界语义集成 |
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章七个疑问,逐条作答:
| # | 疑问 | 答案 |
|---|---|---|
| ① | export/import 完整语法? | 第 3 章:export module 一行声明、export 三种模式、export import 传递、模块分区冒号语法 |
| ② | 模块分区干什么? | 第 4.2:拆大模块成多个文件,内部用 :name 引用,对外统一通过主模块 export import :name |
| ③ | 模块怎么和老头共存? | 第 5 章:module; 全局片段 #include + 头文件单元 import "x.h" + 包装器模块 + 归属标记 |
| ④ | BMI 是什么? | 第 6 章:编译器 AST/符号表的序列化快照,不是目标文件,不可跨编译器/跨版本,需管缓存键 |
| ⑤ | CMake 怎么支持? | 第 7 章:FILE_SET CXX_MODULES + 两阶段依赖扫描 + 拓扑序编译,CMake 3.28+ 生产可用 |
| ⑥ | 模板在模块中的特殊规则? | 第 8 章:可到达性 vs 可见性、模块链接 vs 内外链接、显式实例化新范式、ODR 前移到编译期 |
| ⑦ | 2000+ TU 怎么迁移? | 第 9 章:四步渐进法,编译时间 18min→45s |
案例①修复(constants.h 雪崩):
// 第一步(零代码量):把 constants.h 用头文件单元 import
import "constants.h"; // 替换 #include "constants.h"
// → 编译器只编译 constants.h 一次,产出 BMI
// → 后续所有 import "constants.h" 共享同一份 BMI
// 第四步(最终形态):
// constants.ixx
export module constants;
export constexpr int MAX_ORDER_SIZE = 2048;
2
3
4
5
6
7
8
9
案例②修复(ODR 二重身):
// 禁止混用 #include 和 import 同一个实体。
// 选用统一入口:
// 要么全部 import my_mod;
// 要么全部 #include "legacy_utils.h"
// 在迁移过程中用包装器模块隔离:
module;
#include "legacy_utils.h"
export module utils;
// 只通过 utils 模块暴露,封堵直接的 #include 路径
2
3
4
5
6
7
8
9
案例③修复(CMake 依赖漏扫):
# CMake 3.29+ 修复了 #ifdef 里的 import 漏扫。
# 3.28 的临时方案:显式声明模块依赖顺序
add_dependencies(order_ixx_target base_ixx_target)
2
3
# 10.2 一次 import 的全程生涯
把 import math; auto x = math::add(3,4); 的全过程串成一棵树:
import math; auto x = add(3,4);
│
├─ 编译期:预处理
│ └─ 不做文本复制——import 是一行特殊标记
│
├─ 编译期:模块查找
│ ├─ 查找 math 模块的声明位置
│ │ 信息来自 CMake 的依赖扫描阶段(编译前已完成)
│ ├─ 找到 math.ixx → math.gcm(已编译好的 BMI)
│ └─ 检查 BMI 的哈希是否匹配 math.ixx 的最新版本
│
├─ 编译期:符号导入
│ ├─ 从 math.gcm 反序列化导出符号表
│ ├─ 提取 add(int,int) → int 的类型签名
│ ├─ 提取 mangled name: _Z3addii
│ └─ 在当前翻译单元的符号表里注册 add
│
├─ 编译期:语义分析 + 代码生成
│ ├─ add(3,4) → 类型检查通过 (int, int) → int
│ ├─ constexpr fold → 7(如果是 constexpr 函数)
│ └─ 生成目标文件中的外部符号引用:call _Z3addii
│
└─ 链接期
├─ 链接器从 math.o 中提取 _Z3addii 的定义
├─ 没有 COMDAT 合并(不存在的重复实例化)
└─ 最终二进制中:call add → 零额外开销
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
# 10.3 设计哲学回扣
哲学 1:隔离即性能
头文件模型把所有东西混在一起——文本复制、宏传递、顺序依赖——每一个都是编译时间的消耗点且互为放大器。Modules 把这三条线全砍断:无宏传递(隔离了调用方环境)、无声明重复解析(隔离了每个翻译单元)、无重复实例化(隔离了每个翻译单元的模板)。隔离本身,就是编译性能的最终来源。
哲学 2:自底向上的编译器演进必须优先于自顶向下的标准设计
Modules 的 10 年标准化路线——从 2009 年被否决到 2020 年通过——本质上是**「编译器先造出能工作的原型,再写标准」在语言设计中的胜利**。C++0x 的 Modules 提案是「委员会设计好 → 编译器实现」——失败;C++20 版本是「三家编译器各自造了工作原型 → 委员会取最大公约数」——成功。如果标准不能跑在任何生产编译器上,那它就不是标准。
哲学 3:缓存是编译构建最贵的资源——BMI 把它显式化
在头文件模型里,每个翻译单元都有一块隐式的「编译缓存」——编译好的头文件 AST 存在编译器的内存里,但翻译单元结束后就释放。Modules 把这层缓存持久化为 BMI 文件——跨翻译单元共享、跨增量构建保留。把隐性的性能依赖变成显性的管理对象——这就是 BMI 作为缓存策略的设计哲学。
哲学 4:可到达性 > 可见性——向后兼容的 OOP 直觉
模板在模块里的「可到达规则」是 OOP 里「封装」的编译期版本:模块使用者不应该直接看到 VecImpl(不可见),但必须能借助它实例化 Vec<int>(可到达)。这和 Java/C# 的 internal / protected internal 有同构性——限制名字的可见、保留语义的可达——这条原则把「封装」从运行时的 access specifier 延伸到了编译时的模板实例化。
# 10.4 速查表合集
模块文件类型速查:
| 文件类型 | 开头声明 | 扩展名惯用 | 包含什么 |
|---|---|---|---|
| 主模块接口单元 | export module M; | .ixx / .cppm | export 声明(API 面) |
| 模块实现单元 | module M; | .cpp | 非 export 的实现 |
| 分区接口单元 | export module M:P; | .ixx / .cppm | 分区的 export 声明 |
| 分区实现单元 | module M:P; | .cpp | 分区的实现 |
export 语法速查:
export int x; // 单个声明
export { int y; double z; } // 导出块
export namespace N { /*...*/ } // 命名空间导出
export import other_mod; // 传递导入
export using some::name; // using 导出
export template <typename T> ... // 模板导出
2
3
4
5
6
模块迁移四步法:
Step 1: import <vector>; → 编译器系统头文件单元 → -30% 编译时间
Step 2: 包装器模块 → module; #include + export using → -50%
Step 3: 业务模块原生 → export module order; → -70%
Step 4: 全面模块化 → 纯模块代码库 → -85%+
2
3
4
60 秒诊断命令:
# 检查项目里有多少 #include(衡量头文件膨胀程度)
grep -r '^#include' src/ | wc -l
# GCC 的模块扫描模式(查看依赖图)
g++ -std=c++20 -fmodules-ts -E -fdeps-file=module_deps.dd source.cpp
# Clang 打印模块编译信息
clang++ -std=c++20 -fmodules -Rmodule-build source.cpp
# MSVC 的模块依赖
cl /std:c++20 /sourceDependencies module_deps.json source.cpp
# CMake 的模块构建日志详细度
cmake --build build --verbose | grep "scanning.*for CXX modules"
2
3
4
5
6
7
8
9
10
11
12
13
14
一图定型:
Modules 体系全景
┌── 语法层 ──┐ ┌── 二进制层 ──┐ ┌── 构建层 ──┐ ┌── 迁移层 ──┐
│ export M; │ │ BMI │ │ CMake │ │ 四步法 │
│ import M; │ │ .gcm/.pcm │ │ 依赖扫描 │ │ import<> │
│ partition │ │ AST 快照 │ │ 拓扑编译 │ │ 包装器 │
│ module; │ │ 不可跨编译器 │ │ 缓存管理 │ │ 原生模块 │
│ header unit│ │ 不可跨版本 │ │ 增量构建 │ │ 全面铺开 │
└────────────┘ └──────────────┘ └─────────────┘ └─────────────┘
│ │ │ │
└───────────────┼──────────────────┼──────────────────┘
▼ ▼
编译时间 18 分钟 → 45 秒
模板 ODR 从链接期前移到编译期
宏隔离 → 每个模块独立不受调用方环境影响
2
3
4
5
6
7
8
9
10
11
12
13
14
15
下一篇:Modules 把「编译过程」重新设计了。下一篇我们进入卷四 25.RAII 的设计哲学——从编译期回到运行时,看 C++ 最优雅的设计 RAII 如何用「构造函数获取资源、析构函数释放资源」这一对对称操作,替代 Java 的
try-finally、C 的goto cleanup、Go 的defer,成为资源管理的第一性原理。