C++20 Modules:告别 #include
C++20 Modules:告别 #include
C++ 的 #include 模型已经用了四十多年。它的工作方式简单粗暴:预处理器把被包含文件的内容文本复制到 #include 的位置,然后编译器对着拼接后的翻译单元(translation unit)开始解析。
这个模型有三个根本性的缺陷:
- 重复编译:每份头文件被每个
.cpp独立编译一次(N个翻译单元 =N次完全相同的前端处理) - 宏污染:
#define影响它之后的所有#include,不按作用域隔离 - 物理耦合:包含了什么头文件,就是暴露了它们全部的符号——没有导出控制
C++20 Modules 正是为了解决这三个问题而诞生的。它把"编译中间结果"提升为一等公民,让编译器的前端工作从 O(N × headers) 降到 O(headers + N)。
1. 核心概念:模块声明与接口
一个最简单的模块:
// math.cppm —— .cppm 是约定俗成的模块接口文件扩展名
export module math; // 声明"这是一个模块,名为 math"
export int add(int a, int b) { return a + b; } // export = 公开
int internal_helper(int x) { return x * 2; } // 不 export = 模块内部使用使用方:
// main.cpp
import math; // 导入模块 math
int main() {
auto result = add(1, 2); // OK,add 是导出的
// auto x = internal_helper(3); // 编译错误!未导出,不可见
}就这么简单。import 不是文本包含——它是语义导入。编译器在读 main.cpp 之前已经编译过 math 模块并将其编译产物(BMI, Binary Module Interface)持久化,import math 时直接加载 BMI。
2. 模块分区(Module Partition)
当一个模块太大时,可以用分区来拆分:
// math-core.cppm
export module math:core; // :core 是 math 模块的一个分区
export int add(int a, int b) { return a + b; }
export int sub(int a, int b) { return a - b; }
// math-advanced.cppm
export module math:advanced;
export int mul(int a, int b) { return a * b; }
export int div(int a, int b) { return a / b; }
// math.cppm —— 主导入声明
export module math;
export import :core; // 再导出分区的所有内容
export import :advanced;使用者只看到 import math;,不需要关心内部分区。
分区语法
module math:core; 中的冒号语法表示这是分区,不是嵌套模块。: 后面是分区名,只在模块内部可引用。外部代码只能 import math;,不能 import math:core;(除非 math 模块显式 export import :core; 来再导出)。
3. 模块与头文件的互操作
实际项目不可能一夜之间全部模块化。一个实用的迁移路线是全局模块片段和头文件单元:
3.1 全局模块片段
module; // ← 全局模块片段开始
#include <vector>
#include <string>
#include "legacy_lib.h"
export module my_component;
// 这里开始写模块内容,可以使用 #include 引入的符号
export std::vector<int> make_vector() { return {1, 2, 3}; }全局模块片段中的 #include 不会被模块的隔离保护——它们仍然受宏的影响。但至少你的模块外部使用者不会看到这些头文件的符号。
3.2 头文件单元(Header Unit, C++23 完善)
import <vector>; // 把 <vector> 当作模块导入(头文件单元)
import "legacy_lib.h"; // 把 legacy_lib.h 当作模块导入
// 和 #include <vector> 的区别:
// - import <vector> 确保 vector 的内容被当作独立的模块编译一次
// - 不受当前的宏环境影响3.3 import std; (C++23)
import std; // 一行导入整个 C++ 标准库(如果编译器和标准库支持)import std; 对编译时间的改善最为显著——所有标准库类型只编译一次。
4. 编译模型对比
传统 #include 模型(每个 .cpp 独立处理所有头文件):
main.cpp → [预处理: 展开 50000 行头文件] → [编译: 100000 行] → main.o
utils.cpp → [预处理: 展开 50000 行头文件] → [编译: 100000 行] → utils.o
parser.cpp → [预处理: 展开 50000 行头文件] → [编译: 100000 行] → parser.o
↑ 每份头文件被重复解析 N 次
Modules 模型(模块编译一次,BMI 被各消费方复用):
math.cppm → [编译] → math.bmi (Binary Module Interface)
main.cpp → [import math: 加载 BMI] → [编译 main.cpp 自身] → main.o
utils.cpp → [import math: 加载 BMI] → [编译 utils.cpp 自身] → utils.o
↑ 模块内容只编译一次编译加速的程度取决于头文件体量。对重度依赖模板库(Boost、Eigen)的项目,模块化可以将增量编译时间缩短 50%-90%。
5. 符号可见性:一个被低估的好处
#include 把被包含文件的所有符号都倒了进来——宏、using namespace、内部辅助函数——全部可见:
// legacy.h
#define MAX_SIZE 256
namespace detail { void helper(); }
using namespace detail;
// user.cpp
#include "legacy.h"
MAX_SIZE // 宏泄漏
helper() // 本应是 detail::helper() 的缩写,现在直接可见对应地:
// legacy.cppm
export module legacy;
namespace detail { void helper(); } // 未 export → 模块外不可见
export int process(); // export → 模块外可见
// user.cpp
import legacy;
// MAX_SIZE → 不存在
// helper() → 编译错误
process() // 唯一可见的符号模块默认是封闭的——除非显式 export,否则一切不泄漏。这比头文件的"默认全裸"模型更安全。
6. 当前工具链的支持现状
截至 2026 年,主流编译器对 C++20 Modules 的支持:
| 编译器 | 状态 | 备注 |
|---|---|---|
| MSVC 2022 | 生产可用 | Windows 上最成熟的模块支持,VS 的 IntelliSense 支持模块 |
| Clang 18+ | 基本可用 | 需要 -std=c++20 -fmodules,和 CMake 3.28+ 配合经验最好 |
| GCC 14+ | 基本可用 | 需要 -fmodules-ts,import std; 需 libstdc++ 构建时开启模块 |
CMake 3.28 引入了对 CXX_MODULES 的一等支持:
add_library(math)
target_sources(math
PUBLIC FILE_SET CXX_MODULES FILES math.cppm
)7. 迁移策略
在一个现存项目中渐进式引入模块:
- 从标准库开始:把
#include <vector>等替换为import <vector>;(头文件单元,风险最低) - 将新写的功能做成模块:新的
.cppm文件用export module,让新代码享受隔离 - 挑语义独立的目录做模块化:选一个已有的子组件,用全局模块片段包装它
- 最后再碰跨模块基础设施(
import std;、大型头文件→模块的重构)
不要试图一次性全重构——头文件和模块可以长期共存。
总结
Modules 不是语法糖——它改变了 C++ 编译的基本模型。它的核心收益有三:
- 更快的编译(模块编译一次,BMI 被复用)
- 更好的隔离(默认隐藏,显式导出;宏不泄漏)
- 更清晰的物理架构(
import表达的是语义依赖,而非文本拼接)
#include 有它的历史位置,但在未来十年中,模块将成为 C++ 项目物理设计的默认选择。