编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程地址空间布局
      • 对象内存布局原理
      • 引用与指针本质
      • this指针与成员函数
      • 虚函数表深度剖析
      • 多重继承内存模型
      • 内存对齐与缓存行
      • 内存分配器演进史
      • 五大值类别详解
      • 右值引用与移动语义
      • 完美转发与引用折叠
      • 类型推导三大规则
      • 类型转换与隐式构造
      • const与volatile真相
      • RTTI与dynamic_cast
      • 类型擦除技术原理
      • 模板实例化机制
      • 模板特化与偏特化
      • SFINAE与enable_if
      • 可变参数模板原理
      • constexpr编译期计算
      • Concepts深度剖析
      • 元编程模板技巧
      • Modules模块化设计
        • 1. 案例引入
          • 1.1 一次头文件改动的雪崩
          • 1.2 模块三击不中
          • 1.3 七个待解疑问
        • 2. 架构概览
          • 2.1 Modules 四层模型
          • 2.2 为何这么切
        • 3. 模块声明与导出
          • 3.1 主模块接口单元三行起手
          • 3.2 export 三段式语法
          • 3.3 导出块与命名空间导出
          • 3.4 模块实现单元的职责
        • 4. import 与模块图
          • 4.1 import 的传递性规则
          • 4.2 模块分区的设计意图
          • 4.3 模块的依赖 DAG
          • 4.4 import 与 #include 的本质差异
        • 5. 全局模块片段与 legacy
          • 5.1 module; 前置声明区
          • 5.2 头文件单元的定位
          • 5.3 importable header 的三种策略
          • 5.4 新旧混编的 ODR 防线
        • 6. 二进制接口 BMI
          • 6.1 BMI 的内部结构
          • 6.2 三家编译器的 BMI 差异
          • 6.3 BMI 兼容性的三重维度
          • 6.4 版本漂移与缓存策略
        • 7. 构建系统集成
          • 7.1 静态依赖扫描
          • 7.2 CMake 的 C++20 模块支持
          • 7.3 并行编译的模块图
          • 7.4 增量构建的 BMI 缓存一致性
        • 8. 模块与模板的交互
          • 8.1 模板实体的可达性规则
          • 8.2 模块链接 vs 内部链接
          • 8.3 显式实例化在模块中的表达
          • 8.4 模块中的 ODR 新面貌
        • 9. 大规模迁移策略
          • 9.1 四步渐进迁移法
          • 9.2 编译时间对比证据
          • 9.3 为什么不一步到位
          • 9.4 C++23/26 的模块增强
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一次 import 的全程生涯
          • 10.3 设计哲学回扣
          • 10.4 速查表合集
      • RAII的设计哲学
      • 对象构造与析构
      • 拷贝与移动控制
      • unique_ptr原理剖析
      • shared_ptr底层剖析
      • weak_ptr与this增强
      • 五种存储期管理
      • vector扩容真相
      • deque分段连续设计
      • list与forward_list
      • 关联容器红黑树
      • 哈希容器深度剖析
      • 迭代器五大类别
      • STL算法设计哲学
      • Allocator分配器机制
      • C++内存模型基石
      • 六大内存序详解
      • atomic原子操作原理
      • mutex与条件变量
      • thread与jthread机制
      • 异步编程future家族
      • 无锁数据结构设计
      • 协程coroutine原理
      • 翻译单元与预处理
      • 编译期符号生成
      • 链接器工作原理
      • ODR规则与陷阱
      • 动态库与符号可见性
      • C++ ABI兼容性
      • LTO与PGO优化
      • 异常机制底层原理
      • Ranges革命与管道
      • format与print体系
      • UB未定义行为图鉴
      • C++设计哲学回望
      • 写作模板
    • 开发技巧

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Cpp入门到精通
  • 专栏博客
杨充
2026-06-05
目录

Modules模块化设计

# 24.Modules模块化设计

# 目录介绍

  • 1. 案例引入
    • 1.1 一次头文件改动的雪崩
    • 1.2 模块三击不中
    • 1.3 七个待解疑问
  • 2. 架构概览
    • 2.1 Modules 四层模型
    • 2.2 为何这么切
  • 3. 模块声明与导出
    • 3.1 主模块接口单元三行起手
    • 3.2 export 三段式语法
    • 3.3 导出块与命名空间导出
    • 3.4 模块实现单元的职责
  • 4. import 与模块图
    • 4.1 import 的传递性规则
    • 4.2 模块分区的设计意图
    • 4.3 模块的依赖 DAG
    • 4.4 import 与 #include 的本质差异
  • 5. 全局模块片段与 legacy
    • 5.1 module; 前置声明区
    • 5.2 头文件单元的定位
    • 5.3 importable header 的三种策略
    • 5.4 新旧混编的 ODR 防线
  • 6. 二进制接口 BMI
    • 6.1 BMI 的内部结构
    • 6.2 三家编译器的 BMI 差异
    • 6.3 BMI 兼容性的三重维度
    • 6.4 版本漂移与缓存策略
  • 7. 构建系统集成
    • 7.1 静态依赖扫描
    • 7.2 CMake 的 C++20 模块支持
    • 7.3 并行编译的模块图
    • 7.4 增量构建的 BMI 缓存一致性
  • 8. 模块与模板的交互
    • 8.1 模板实体的可达性规则
    • 8.2 模块链接 vs 内部链接
    • 8.3 显式实例化在模块中的表达
    • 8.4 模块中的 ODR 新面貌
  • 9. 大规模迁移策略
    • 9.1 四步渐进迁移法
    • 9.2 编译时间对比证据
    • 9.3 为什么不一步到位
    • 9.4 C++23/26 的模块增强
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一次 import 的全程生涯
    • 10.3 设计哲学回扣
    • 10.4 速查表合集

# 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
1
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%)
1
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
1

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
};
1
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,从模块和头文件两个路径进入
1
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
)
1
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 章
1
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 从「链接器口头保证」→ 编译器保证│               │
│              └──────────────────────────────────┘                 │
└─────────────────────────────────────────────────────────────────┘
1
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 吗?

论证:

  1. 「宏问题」是关键障碍——#include 的语义里,宏在前面的定义会影响头文件内容。import 必须切断这条依赖——但存量 C++ 代码里随处可见 #include <windows.h> 前面必须定义 WIN32_LEAN_AND_MEAN 的宏用法。模块必须在「保留宏能力」和「切断宏传染」之间选一条路——C++20 选了后者(import 不带宏),同时用「全局模块片段」给旧头文件一个逃生通道。
  2. 模板的「可达性」比函数复杂一个维度——普通函数的符号归属很明确(定义在哪个翻译单元)。但模板没有明确的「编译单元归属」——每个用到的翻译单元都可以实例化。模块必须重新定义「模板实体从哪个模块来」,这直接催生了**模块链接(module linkage)和可达性(reachability)**两套新概念(第 8 章)。
  3. BMI 兼容性是编译器的噩梦——BMI 本质上是编译器内部的 AST/符号表的「序列化快照」。不同编译器内部表示不同(GCC 用 tree、Clang 用 ASTContext、MSVC 用 IFCLibrary),序列化成同一格式是不可能的。委员会放弃了对 BMI 格式的标准化,让编译器各自负责自己的 BMI——这是务实的选择,也是「BMI 不能跨编译器」的根源。
  4. 构建系统的依赖发现必须重塑——头文件的依赖很容易:#include "a.h" → a.h 存在就行。模块的依赖是:import foo; → 需要先找到「哪个文件定义了模块 foo」→ 再编译它生成 BMI → 再读 BMI。这意味着构建系统必须有预扫描阶段——在编译之前,先扫描所有源文件里的 import 语句,建立模块依赖图。make/ninja 本身不知道 C++ 模块——必须由 CMake/MSBuild 等上层系统补这个能力。
  5. 反向验证: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; }
// =========================================
1
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
1
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;   // 导出一个别的命名空间的符号
1
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                     // 宏不能导出(模块没有宏的概念)
1
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 全都可用
1
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);
}
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
1
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
1
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——主模块什么都给你了
1
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 直接跳到某个分区。
1
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 不用重编!
1
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 的定义(与头文件方案完全相同)。
1
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);
1
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
1
2
3
4
5

# 5.2 头文件单元的定位

C++23 引入头文件单元(header unit)——把老式头文件当模块来 import:

// 不需要把 iostream 改写成模块——直接 import 它
import <iostream>;           // C++23:标准库头文件可以作为 importable header
import "legacy_config.h";    // 你自己的头文件也可以
1
2
3

头文件单元是编译器自动为头文件生成的模块包装:

import &lt;vector>;
  │
  └─ 编译器把 &lt;vector>当成一个模块处理
     ├─ 为它生成一个临时的 BMI
     ├─ 对 vector 的所有声明做封装
     └─ 后续的 import &lt;vector> 共享同一份 BMI

效果:和 #include &lt;vector> 等价,但更快(只编译一次)
1
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
1
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;                       // ❌ 冲突:同一个名字从两个归属进入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

解决方向(C++23 强化):

  1. 统一入口——只通过 import 或只通过 #include,不要混用
  2. 用 extern "C++" 的归属标记——C++23 引入 #include 的归属标记语法(编译器中实验性)
  3. 构建系统检查——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...               │
└────────────────────────────────────────────────┘
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

关键点: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 不同(条件编译影响导出符号集)
1
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
缓存未命中 = 任何一个变了 → 重编模块 + 重算反向依赖链
1
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 的拓扑序编译模块                  │
  │   先编译所有叶子模块(无依赖)                │
  │   再编译中间模块(依赖已编译好)              │
  │   最后编译使用方翻译单元                      │
  └──────────────────────────────────────────────┘
1
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; 的依赖
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

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 内部消费了重复的头文件解析时间
  → 模块方案的「墙钟时间」显著更短(因为少了重复劳动)
1
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 秒
1
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 从这里被「可达」
    }
};
1
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 在同一模块内,可达
}
1
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>;            // 显式实例化定义——在这个实现单元里
1
2
3
4
5
6
7
8
9
10

关键:显式实例化定义仍然在实现单元里——修改它不影响 BMI。

# 8.4 模块中的 ODR 新面貌

在头文件时代,ODR 是链接期「COMDAT 折叠」保证的——同一模板在每个翻译单元都实例化一份,链接时随机挑一个保留。

在模块时代,ODR 被前移到编译期:

头文件 ODR:
  每个 .cpp 自己实例化 Vec&lt;int>
  → 每个 .o 里都有一份 Vec&lt;int>::push_back(int)
  → 链接器合并所有 COMDAT group → 只留一份
  → 编译时编译器从不检查「有没有别人也实例化了 Vec&lt;int>」

模块 ODR:
  模块导出 Vec&lt;int>
  → 每个 import 这个模块的 .cpp 看到的是同一个 BMI 里的 Vec&lt;int>
  → 编译器不需要实例化 Vec&lt;int>——直接从 BMI 拿定义
  → 不存在重复实例化 → 不存在链接期的 COMDAT 折叠
1
2
3
4
5
6
7
8
9
10
11

收益:链接时间减少(不需要合并 COMDAT)、编译时间减少(不需要每个 TU 都实例化一遍)。


# 9. 大规模迁移策略

# 9.1 四步渐进迁移法

回答案例 1.1 的核心疑问——2000+ 翻译单元的项目怎么迁移:

第一步:标准库头文件 → 头文件单元(零代码改动)
  import &lt;vector>;  import &lt;string>;  import &lt;iostream>;
  → 编译器的系统头文件已支持 import
  → 这一步直接省掉每个 .cpp 里 &lt;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%+
1
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 重写成模块?

论证:

  1. 改动量巨大——一个 2000 TU 的项目通常有 400+ 个头文件。每个头文件都可能包含:宏定义(模块不支持)、复杂的 #ifdef 条件编译(模块的 BMI 会在编译选项变化时失效)、依赖链交织(头文件 A 包含头文件 B,头文件 B 又包含头文件 A 的部分宏)。直接重写意味着同时解决所有这些问题——极高风险。
  2. 宏是不可替代的遗留系统——很多老项目有巨量的 #ifdef PLATFORM_LINUX 或配置宏。模块不能用宏传参——这意味着所有宏控制的编译路径必须改写为 if constexpr 或模板,这是工作量黑洞。
  3. 部分第三方库的头文件不能改——你没有控制权。不能要求 boost、protobuf 等改写成模块。必须通过「包装器模块」或「头文件单元」来做桥接。
  4. 团队学习曲线——「同一个符号从两个归属进来」的 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;
1
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 路径
1
2
3
4
5
6
7
8
9

案例③修复(CMake 依赖漏扫):

# CMake 3.29+ 修复了 #ifdef 里的 import 漏扫。
# 3.28 的临时方案:显式声明模块依赖顺序
add_dependencies(order_ixx_target base_ixx_target)
1
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 → 零额外开销
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

# 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> ...      // 模板导出
1
2
3
4
5
6

模块迁移四步法:

Step 1: import &lt;vector>;     → 编译器系统头文件单元  → -30% 编译时间
Step 2: 包装器模块           → module; #include + export using  → -50%
Step 3: 业务模块原生         → export module order;  → -70%
Step 4: 全面模块化           → 纯模块代码库           → -85%+
1
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"
1
2
3
4
5
6
7
8
9
10
11
12
13
14

一图定型:

Modules 体系全景

  ┌── 语法层 ──┐   ┌── 二进制层 ──┐   ┌── 构建层 ──┐   ┌── 迁移层 ──┐
  │ export M;  │   │  BMI         │   │  CMake      │   │  四步法     │
  │ import M;  │   │  .gcm/.pcm   │   │  依赖扫描    │   │  import&lt;>   │
  │ partition  │   │  AST 快照     │   │  拓扑编译    │   │  包装器     │
  │ module;    │   │  不可跨编译器  │   │  缓存管理    │   │  原生模块   │
  │ header unit│   │  不可跨版本   │   │  增量构建    │   │  全面铺开   │
  └────────────┘   └──────────────┘   └─────────────┘   └─────────────┘
          │               │                  │                  │
          └───────────────┼──────────────────┼──────────────────┘
                          ▼                  ▼
              编译时间 18 分钟 → 45 秒
              模板 ODR 从链接期前移到编译期
              宏隔离 → 每个模块独立不受调用方环境影响
1
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,成为资源管理的第一性原理。

上次更新: 2026/06/10, 11:13:41
元编程模板技巧
RAII的设计哲学

← 元编程模板技巧 RAII的设计哲学→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式