楼层: 首页/ 软件技术/ 现代 C++/ C++20 Modules:让编译快起来,让宏别乱跑
14

C++20 Modules:让编译快起来,让宏别乱跑

Modules · export module / import / CMake FILE_SET CXX_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 前缀。

一个完整的模块:接口单元 + 实现单元 + 使用它的主程序

// ---------- math.cppm(模块接口单元,约等于"新版头文件")---------- export module math; // 声明"我是名为 math 的模块的接口" import <cstdint>; // 模块内部也可以 import 标准库头(这是 header unit) // 有 export 前缀:外部可见 export int add(int a, int b); // 没有 export:这是模块的"私有"内容,外部完全看不到,也不会污染名字 int internal_helper(int x) { return x * 2; } // 导出一个常量 export constexpr int MAX_LEVEL = 99; // 导出一个类:类的名字可见,但未导出的成员函数依然是私有的 export class Calculator { public: int compute(int x) const; private: int cache_ = 0; // 外部看不到,也不需要知道 }; // ---------- math_impl.cpp(模块实现单元,写实现的地方)---------- module math; // 注意:没有 export 前缀,这是实现单元 // 定义 add:因为接口里已经 export 过声明,这里不需要再写 export int add(int a, int b) { return a + b + internal_helper(0); // 实现里可以随便用私有函数 } int Calculator::compute(int x) const { return x * 2; } // ---------- main.cpp(消费者)---------- import math; // 就这一行,替代了原来的一堆 #include import <iostream>; // 标准库也能用 import(header unit) // #include <vector> ← 老写法仍然能和 import 混用 int main() { std::cout << add(1, 2) << "\n"; // 3 std::cout << MAX_LEVEL << "\n"; // 99 // internal_helper(1); ← 编译错误!模块私有,外部不可见 return 0; }

论接口单元 vs 实现单元的判据

① 接口单元(interface unit):第一行是 export module 名字;。它只放"别人需要知道的东西"——函数声明、类定义、常量。它负责生成 BMI,别人 import 时读的就是它。

② 实现单元(implementation unit):第一行是 module 名字;(没有 export)。它放具体实现,可以随便 #include 第三方头文件、用宏,都不会泄漏出去。

③ 为什么建议拆开:如果实现细节(比如某个第三方库的头)写在接口单元里,每次改实现都要重新生成 BMI,所有依赖这个模块的翻译单元都要重编——那编译加速就白做了。拆开之后,改实现只影响实现单元本身。记住这条判据:接口单元要尽量稳定,实现单元随便改。

模块分区:大模块怎么拆文件

真实的模块不会只有一个接口单元。比如 math 模块下有向量、矩阵、统计三块,你想分开写文件,但对外还是"一个 math 模块"。这就是模块分区(module partition)。

用分区把大模块拆成多个文件,对外仍是单一模块

// ---------- math.cppm(主接口单元:只做"汇总导出")---------- export module math; // export import:把子分区的导出内容"转发"出去,让消费者只 import math 就能用全部 export import :vector; // 分区语法:冒号 + 分区名 export import :matrix; // ---------- math_vector.cppm(分区接口)---------- export module math:vector; // 注意:模块名后面跟 :分区名 import <vector>; export std::vector<double> scale(const std::vector<double>& v, double k); // ---------- math_matrix.cppm(分区接口)---------- export module math:matrix; export struct Matrix2x2 { double m[2][2]; double det() const; }; // ---------- 消费者:一个 import 拿到全部 ---------- import math; import <iostream>; int main() { auto v = scale({1.0, 2.0, 3.0}, 2.0); Matrix2x2 m{{{1, 2}, {3, 4}}}; std::cout << m.det() << "\n"; }
分区别乱用,它不是"新版命名空间"

分区不是用来做代码组织的装饰,它有一个硬性约束:所有分区必须属于同一个模块(同一个构建目标),它们共享模块的所有权,且不能被分别安装/分发。如果你想把一块功能独立发布给别人用,应该做成独立模块而不是分区。另外 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 模块)。这是理想终态,但目前不建议作为唯一依赖。

迁移期的三种混用形态,从保守到激进

// ---------- 形态一:最保守,零风险,先让新代码用模块 ---------- // 老代码继续 #include,新模块内部用 import,两者可以共存 export module myapp.report; #include <thirdparty/legacy.h> // 模块里也能 include,宏不会泄漏出去 import <string>; import myapp.core; // 新模块 import 另一个新模块 export std::string makeReport(int id); // ---------- 形态二:用 header unit 减少重复解析 ---------- // 注意:<cstdio> 这种 C 头在 header unit 里用 <...> 形式导入 import <vector>; import <algorithm>; import <memory>; export void sortAndDedup(std::vector<int>& data) { std::sort(data.begin(), data.end()); data.erase(std::unique(data.begin(), data.end()), data.end()); } // ---------- 形态三:理想终态,C++23 的 import std ---------- import std; // 一行拿到整个标准库 export void demo() { std::vector<std::string> names; std::println("共 {} 个名字", names.size()); // C++23 std::print }
import std; 不能和 #include 混着来

同一次编译里既 import std; 又 #include <vector>,在多数实现上会导致重复定义或链接错误——因为标准库的类型从两条路径各进来了一份。规则:一个翻译单元内,标准库要么全走 import,要么全走 include,不要混。这条在团队协作里非常容易犯规:你 import std 写了新文件,同事复制旧文件的 include 过来,一编译就炸。建议在项目里明确约定并用 lint 工具检查。

模块与 CMake 的配合

这是 Modules 落地最现实的门槛。编译器需要知道"哪些文件是模块接口、扫描顺序是什么",而这个信息必须由构建系统提供。CMake 从 3.28 起提供了 FILE_SET CXX_MODULES,这是目前最标准的做法(更早的 CMake 需要手写依赖扫描,非常痛苦)。

CMakeLists.txt:用 FILE_SET 声明模块源文件

cmake_minimum_required(VERSION 3.28) # FILE_SET CXX_MODULES 要求 3.28+ project(mathmod LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_SCAN_FOR_MODULES ON) # 打开模块扫描 add_library(math) # 关键:用 FILE_SET CXX_MODULES 声明模块接口文件 target_sources(math PUBLIC FILE_SET CXX_MODULES BASE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/src FILES src/math.cppm # 接口单元 src/math_vector.cppm # 分区接口 ) # 实现单元当普通源文件加进去即可(它没有 export 前缀,CMake 会识别) target_sources(math PRIVATE src/math_impl.cpp) add_executable(demo src/main.cpp) target_link_libraries(demo PRIVATE math) # 不同编译器的"额外要求"要显式打开 if(MSVC) target_compile_options(math PUBLIC /std:c++20 /utf-8) elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") target_compile_options(math PUBLIC -fmodules-ts) # GCC 需显式开启 elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang") target_compile_options(math PUBLIC -stdlib=libc++) endif()

命令行手工编译(理解构建系统在背后做了什么)

# GCC(g++ 14/15):用 -fmodules-ts,接口单元用 -x c++-module 指定语言 g++ -std=c++20 -fmodules-ts -x c++-module -c math.cppm -o math.o g++ -std=c++20 -fmodules-ts -c math_impl.cpp -o math_impl.o g++ -std=c++20 -fmodules-ts -c main.cpp -o main.o g++ math.o math_impl.o main.o -o demo # Clang:需要在构建目录里生成模块缓存目录,显式给出接口映射 clang++ -std=c++20 -stdlib=libc++ \ --precompile math.cppm -o math.pcm clang++ -std=c++20 -stdlib=libc++ -fmodule-file=math=math.pcm \ -c math_impl.cpp -o math_impl.o clang++ -std=c++20 -stdlib=libc++ -fmodule-file=math=math.pcm \ -c main.cpp -o main.o # 结论:手工编译又长又容易错,这就是为什么必须把这件事交给 CMake/Meson/Bazel

论构建系统要为模块做三件事

① 识别:扫描每个源文件的开头,判断它是模块接口、模块实现,还是普通翻译单元。所以 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 { ... }导出整个命名空间内的名字。注意:这会把命名空间里所有名字都导出,慎用。

三种可见性一次看清

export module demo; // 1) 完全私有:外部看不到,也不污染名字空间 namespace detail { struct Buffer { char data[256]; }; } // 2) 导出块:一次性导出多个声明,可读性更好 export { int parse(const std::string& s); void reset(); extern const int VERSION; } // 3) 导出类:只有类名和 public 接口可见 export class Parser { public: bool feed(char c); private: detail::Buffer buf_; // 私有成员类型不必导出,外部完全不受影响 }; // 4) export import:转发依赖,让使用者"顺带"也拿到 export import demo:json; // 使用者 import demo 时也能用 json 分区的东西 import <cstring>; // 只自己用,使用者拿不到(本来标准库也不用你给)
两个关于可见性的误解

误解一:以为 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 秒,那这件事的优先级应该排在很后面。

Modules 的四个高发坑

坑一:模块与宏。宏不会被模块导出,这通常是好事,但如果你依赖某个宏做条件编译(比如 #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,别做一次性大重写。

自测

小练习 · C++20 Modules

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,改动的传播范围和头文件一样大,没有解耦;③ 构建系统没有开启增量模块缓存,每次都重新扫描生成;④ 模块的粒度太细碎,每个小功能一个模块,构建系统的调度开销反而上来了。核心思路:模块的收益来自"接口稳定 + 编译一次复用",粒度设计比语法更重要。