C++20 把模块(Modules)正式带进标准之后,“头文件地狱”终于有了官方解法,但真到了落地那一步你会发现:把.h改成.cppm只是最表层的工作,真正拉开差距的是模块接口怎么设计。
这篇文章就是聊这件事——C++模块接口设计。我会从接口单元怎么组织、export怎么用才不翻车、分区(partition)什么时候拆、构建系统要配什么,到实测下来容易踩的编译顺序和隐藏符号的坑,尽量讲成可以直接照着用的一套经验。适合正在把老项目往模块迁移、或者新项目想直接用模块但不想被坑的 C++ 开发者读,也适合给团队做技术选型时用来评估模块的成熟度。
1. 模块接口为什么值得单独设计
1.1 传统头文件模式的三个硬伤
老 C++ 项目的接口本质上是“头文件里的声明”,这是从 C 时代传下来的遗产。它最大的问题不是不好看,而是它把物理组织方式(一个.h文件)和语义接口(这个类对外提供什么能力)强行绑在了一起。于是产生三个绕不开的硬伤:
第一,预处理机制导致接口边界极度脆弱。头文件里的内容是被文本复制到每个.cpp里的,谁 include 谁就把全部内容原样粘贴一遍。一个头文件里多了一个#include <string>,所有 include 它的翻译单元全都跟着负担这份解析成本。更麻烦的是,宏定义能穿透 include 边界,你在某个头文件里#define min(a,b),下游每个文件都可能被影响,接口的实际内容取决于“include 之前发生了哪些宏替换”,这就让接口变成了一个时间相关的功能,而不是一个稳定的契约。
第二,隐私无法真正隐藏。头文件要实现private成员时,最常见的做法是 Pimpl(指针指向实现类),因为不这样做,改一个私有成员变量的类型,所有 include 这个头文件的源文件都要重新编译。Pimpl 能解决问题,但它本身就是一种笨重的妥协:你要手写构造函数、析构函数、移动语义,还要承担一次堆分配和一个指针间接访问的性能损耗。模块的私有实现放在实现单元里,接口单元只留公有声明,编译依赖和访问控制是“物理级别”割开的,这才是真正的接口隔离。
第三,include 依赖是全局有向无环的噩梦。头文件之间的关系靠#include串联,而 include 的顺序、重复 include 的防护、前向声明的缺失,都会让编译期忽然崩在一个“明明很简单”的错误上。大型项目为了压编译时间,最后通常要靠 PCH、Unity Build、强制 include 顺序之类的“偏方”,但这些都是对缺失模块机制的事后弥补,不是在根上解决问题。
模块把“接口”和“实现细节”从标准层面分离:接口单元的export列表是显式的,没有被导出的东西外部根本看不见,连私有声明都不需要出现在接口单元里。这就让接口回归了它本来的含义——一个明确、稳定、可审查的边界。
1.2 模块接口是编译边界,不只是文件拆分
很多刚上手模块的朋友有个误区:把模块理解成“语文上更好的头文件”——反正都是把声明放一起,区别就是换了个扩展名。这种理解会踩大坑。
模块真正改变的是编译边界。每一个模块单元都是独立编译的,主接口单元编译后会产生一份二进制模块接口(Binary Module Interface,BCL),后续所有import这个模块的源文件,不再去读取文本形式的源码,而是直接消费这份已编译的接口。
这意味着三件事变好了:
- 重复 import 的成本几乎归零。头文件被 100 个
.cppinclude,就要被预处理 100 次;模块被 100 个文件 import,BCL 只做一次语义分析,后续文件直接引用。 - 接口的抽象是强制的。头文件模式下你可以声明一个私有类型放在公头里“假装隐藏”;模块模式下私有类型放在实现单元,外部物理上无法引用。
- 实现改动不引发下游连锁重编译。如果接口单元的内容不变,实现单元的内容变了,依赖它的下游文件根本不需要重编译,增量构建的时间被大幅缩短。
从这个角度看,模块接口设计的本质,不是“换个文件组织方式”,而是重新定义“什么是一个公开的 API 契约”。接口单元就是契约的物理化身,export关键字就是契约的盖章处,任何没有盖章的能力都无法被外部看到。
2. 接口单元与导出语法:先搞懂这几块基石
2.1 文件扩展名和单元类型
C++ 标准其实没有固定规定模块文件的后缀名,但社区里形成了比较统一的使用习惯。我用过较多也推荐的是:
- 主模块接口单元:
模块名.cppm(Clang/CMake 默认风格),也叫模块名.ixx(MSVC 风格),或者模块名.hpp(部分项目为了让 IDE 高亮友好)。 - 子分区接口单元:
模块名-分区名.cppm。 - 实现单元:
模块名.cpp或模块名-impl.cpp。
无论文件名怎么取,真正决定单元类型的是文件内容的第一行语法:
// 这是主接口单元 export module my_module; // 这是实现单元(属于 my_module 内部,不导出任何新东西) module my_module; // 这是分区接口单元 export module my_module:detail; // 这是分区实现单元 module my_module:detail;一句简单的区分经验:看到export module就是接口单元,看到裸module就是实现单元。接口单元决定“这个模块对外是什么”,实现单元只是实现,外部 import 这个模块时根本不会看到实现单元的存在。
2.2 export 的四种姿势,别只会用最笨的一种
export是模块接口设计的核心关键字,但很多新手把它理解成“在函数前面加 export”就完事了。实际用下来,至少有这么四种导出方式,各有各的适合场景:
// 方式一:逐个导出 export int add(int a, int b); export int sub(int a, int b); export class Point { /* ... */ }; // 方式二:批量导出 export { int mul(int a, int b); int div(int a, int b); float to_float(int v); } // 方式三:命名空间级导出 export namespace mylib { int square(int x); int cube(int x); } // 方式四:重新导出(re-export) export import :detail; // 导出分区内容 export import <vector>; // 导出标准库头文件的内容 export import my_other_mod; // 导出另一个模块的接口我的经验是,具体导出优先于批量导出,因为批量导出会模糊接口边界——你只是把一批东西“扔出去”,阅读接口单元的人要花精力才能看出哪些是真正对外承诺的 API,哪些只是顺手带的内部工具。命名空间级导出很适合做“库门面”:开发者import模块后天然是mylib::square的形式,既有命名空间的隔离,又不用为每个函数单独写 export。
export import特别容易被人忽略,但它在接口设计里很有用。比如你的 API 大量使用std::vector,如果不export import <vector>,这个模块的使用者还得自己去import <vector>,否则代码编译不过。这本质上是在“替使用者把依赖声明清楚”,属于接口设计的一部分,而不是语法细节。
2.3 全局模块片段:和 #include 的过渡区
模块不是要求你把所有#include立刻扔掉。在模块的前面可以有一块所谓的“全局模块片段”(Global Module Fragment),写法是在module;之后、export module之前,放你想保留的宏定义和#include:
module; #include <string> #include <vector> #define LOG_INLINE_VAR 1 export module my_mod;这个片段里的头文件内容属于“全局模块”,不会污染其他 import 者的命名空间。但有一个天大的坑:全局模块片段里包含的头文件,默认不会被下游 import 者自动看到。如果接口单元的export声明里用了std::string,但你只是#include <string>而不是export import <string>,下游文件虽然能 import 这个模块,却会报std::string未定义。
这种事情我在迁移老代码时踩过好几次,后来形成了固定习惯:接口单元里用到的所有 STL 类型,一律在接口单元里export import <对应头文件>,不要把依赖偷偷藏在#include里。全局模块片段只放两种东西——实现单元需要的宏配置,以及那些还没有模块化包装的第三方 C 头文件。
3. 接口分区:什么时候拆,怎么拆最合理
3.1 分区是模块内部的组织工具,不是给外部用的
很多人把分区误当成“子模块”,以为外部可以单独import my_module:detail,这是错的。分区(Module Partition)是模块内部的组织单位,它只能被同一个模块内的其他单元 import,外部使用者拿到的是一个整体my_module。
如果你把接口做成两个分区 A 和 B,外部import my_module时,看到的是合并后的完整接口。在使用者眼里,分区结构完全透明。这个设计价值非常大:大模块可以分成多个接口分区来维护,但公开的 API 面始终是一个统一整体。
常见分区策略有两种。
第一种是“按功能分区”。比如一个图形引擎模块:
// geometry.cppm export module engine:geometry; export struct Vec2 { float x, y; }; export float dot(const Vec2& a, const Vec2& b); // render.cppm export module engine:render; export import :geometry; // render 的接口要用 Vec2 export void draw_line(const Vec2& a, const Vec2& b);这种情况下,render分区如果想在接口里暴露Vec2类型,就必须export import :geometry,把geometry分区的内容重新导出出去。否则使用者明明通过import engine拿到了draw_line的声明,却看不到Vec2的完整类型。这个“分区之间要显式传递接口依赖”的规则,其实是在强制你把模块内的依赖关系画清楚。
第二种是“接口 + 实现分区分离”。主接口只负责“门面”,具体实现放在:impl分区里。这保留了 Pimpl 的隐藏效果,但没有堆分配成本:
// bus.cppm 主接口 export module messagebus; export import <string>; import :impl; // 实现分区,不导出 export class Bus { public: Bus(); ~Bus(); void publish(const std::string& topic, const std::string& payload); size_t pending() const noexcept; private: Impl* impl_; };这里impl_指向一个只在:impl分区里定义的类型,主接口里只有前向声明和指针。外部使用Bus时完全看不到Impl的任何信息,但实现却能直接内嵌在:分区里,不需要额外堆分配。从接口设计角度,这是“物理封装”最极致的一种做法——比 Pimpl 更透明,改动内部实现时下游零重编译。
3.2 分区拆分的两个度:别拆太碎,也别一股脑堆一个文件里
分区不是拆得越多越好。我见过有团队把一个模块拆成十多个分区,每个分区只有两三个函数,结果构建图变成一张复杂网络,编译顺序调度、缓存命中率都跟着倒霉。实事求是的标准是:
- 单个分区源码规模达到 1000 行以上、且内部有明确的功能内聚时,才考虑拆。
- 接口分区和实现分区一比一或者一对多,不要让实现单元散落在一个“裸模块”里没有归属。
- 分区之间的关系必须“向下流动”——
render依赖geometry,geometry绝不反向依赖render,否则就是循环依赖,模块体系里没法用 include guard 那种“谁先包含谁”的方式逃过循环。
这一点是模块和头文件思路最大的不同:头文件可以靠 include guard 假装没有循环依赖,但模块要求你在设计阶段就把依赖画成 DAG。接口分区实际上是把你项目里已有的“抽象依赖图”强制物理化了,拆得是否清晰,直接决定你后续能不能顺利构建。
3.3 分区超能力:给测试开一扇私密后门
分区有一个头文件没有的用法——给测试留内部通道。真实项目里经常遇到一个问题:我想单测某个模块的内部函数,但它没有导出,外部 import 不到。老办法是用#define private public或者 friend class 之类的 hack。模块体系下次,可以在接口里做一个专门的测试钩子:
// stats.cppm export module stats; export import <vector>; export import <optional>; import :detail; // 不想让外部看到 detail // stats-internal-test.cpp —— 这也是 stats 模块的实现单元 module stats; import :detail; export void run_internal_tests() { // 可以直接调用 detail 里的所有类型和函数 }实现单元天然属于模块内部,能访问模块内的任何私有分区。把测试入口做成一个实现单元,然后让测试主程序链接进来调用即可。这样既不给外部暴露内部实现,又能拿到直接的测试访问权。比 friend 技巧干净得多,也不用额外维护一套“测试可见”的导出符号。
4. 实战:一个统计工具模块从零到完整落地
4.1 接口设计先行:先回答“外部需要什么”
假设我要写一个轻量统计模块stats,先不急着写代码,先列出外部调用方真正需要的能力:
- 均值
mean、中位数median、方差variance - 模板函数
clamp,对输入向量做裁剪归一化 - 可能需要把中间计算细节全部藏起来
接口层我打算这样组织:一个主接口文件stats.cppm,一个实现分区文件stats-impl.cppm,一个模板实现内联在接口单元里(因为模板必须在实例化点可见)。
// stats.cppm export module stats; export import <vector>; export import <optional>; export namespace stats { // 非模板函数:只需要声明,实现在实现单元中完成 double mean(const std::vector<double>& data); std::optional<double> median(std::vector<double> data); double variance(const std::vector<double>& data, bool is_sample = false); // 模板函数:定义必须在这里 template<typename T> T clamp_value(T value, T low, T high) { return value < low ? low : (value > high ? high : value); } }你可能注意到我用了export namespace stats { ... }。这里的语义是整个命名空间里的公开声明全部导出,但外部访问仍是stats::mean。这样写比逐个函数export更省心,接口单元的“公开面”也更一目了然。
4.2 实现单元的写法与边界
实现单元里,有两点是关键。第一是开头直接module stats;,表示它是这个模块的一部分;第二是它可以自由 import 本模块的私有分区,但不需要 export 任何东西:
// stats-impl.cpp module stats; import :detail; namespace stats { double mean(const std::vector<double>& data) { if (data.empty()) return 0.0; double sum = 0.0; for (double v : data) sum += v; return sum / static_cast<double>(data.size()); } std::optional<double> median(std::vector<double> data) { if (data.empty()) return std::nullopt; std::sort(data.begin(), data.end()); size_t n = data.size(); if (n % 2 == 1) return data[n / 2]; return (data[n / 2 - 1] + data[n / 2]) / 2.0; } double variance(const std::vector<double>& data, bool is_sample) { if (data.empty()) return 0.0; double m = mean(data); double sq_sum = 0.0; for (double v : data) { double diff = v - m; sq_sum += diff * diff; } size_t denom = is_sample ? data.size() - 1 : data.size(); if (denom == 0) return 0.0; return sq_sum / static_cast<double>(denom); } }这里有个值得解释的细节:median为什么用传值而不是常引用?因为函数内部要std::sort,用传值就是把“副本归谁所有”这个事说清楚了——调用者传入的数据不会被改动,代价是一次拷贝。如果数据量大、不想拷贝,也可以传引用,然后把“排序会修改原数据”写进接口文档。我在设计接口时宁可多做一次拷贝,也不让调用者惊讶于自己的数据被改了。
实现单元还可以只把一部分函数放这里,另一部分放进分区。它们的区别是:普通实现单元只能“做已声明的事”,分区可以额外定义新的内部类型和函数。所以在stats-impl.cpp里,我只需要实现三个已有声明;真正复杂的分摊计算,会放在:detail分区里,让测试钩子也能触达。
4.3 用 C++20 模块替代 Pimpl:边界体验对比
我把“旧方案 Pimpl 头文件”和“新方案模块接口分区”放一起做了个对比,这应该能直观表示出模块接口设计的收益:
| 维度 | 传统头文件 + Pimpl | 模块接口 + 实现分区 |
|---|---|---|
| 接口文件里有没有实现细节 | 有 Pimpl 指针和前置声明 | 只有公开声明,私有类型完全不可见 |
| 下游能否误用内部类型 | 可能,Impl类型名出现在头文件里 | 不能,内部类型不在接口中 |
| 修改私有成员是否引发下游重编译 | 否(Pimpl 隔离) | 否(接口不变,实现分区改动不影响下游) |
| 每次访问私有数据 | 一次指针解引用 + 堆分配 | 一次指针解引用,无堆分配 |
| 初始化复杂度 | 手写默认构造、析构、移动 | 默认成员初始化即可,或自定义一次 |
| 模板成员函数实现 | Pimpl 里处理模板很别扭 | 模板可以直接放在接口单元或分区内,语义干净 |
我评估下来,模块方案在绝大多数场景下都优于 Pimpl,唯一要注意的额外成本是工具链的模块支持和构建系统的配合。如果你的项目还在老编译器上跑,Pimpl 还可能是过渡期方案,但如果已经上了 C++20 工具链,没必要再让新代码背负 Pimpl 那套手写移动语义的旧账。
4.4 模板与模块接口:几个容易忽略的规则
模板在模块接口里要特别注意几点,这是我趟过的泥坑。
第一,模板定义必须放在接口单元、或者接口单元能见到的分区里。因为模板的实例化发生在使用方,编译器必须在实例化点看到模板完整定义。如果把模板声明放在接口单元、定义放在实现单元,外部 import 后想实例化时会直接卡死,因为实现单元的内容对外部不可见。
第二,模板在模块里的“所有权”与 ODR 处理和头文件不同。在头文件模式下,同一个模板被多个翻译单元各自实例化,靠链接器做合并;模块模式下模板与模块绑定,一个模板定义只有一份,实例化共享,错误信息也更集中。
第三,不要在模块接口单元里用extern template做性能优化。传统做法是头文件声明extern template、某个实现里显式实例化,模块模式下这会把实例化点挪走,反而破坏模块的清晰边界。如果确实要控制模板实例化,应该把实例化放在一个分区里,再显式导出。
5. 构建与工具链集成:接口设计能落地的前提
5.1 模块带来的构建顺序问题
模块模式引入了一个头文件时代不存在的问题:编译顺序强依赖。头文件是文本模式,编译器读到的永远是当前内容;模块是二进制接口模式,编译 A 之前必须先把 A 的接口单元编完,否则下游 import 时找不到 BCL。
这意味着构建系统必须“认识”模块的依赖图,而不能像旧时代那样并行编译所有.cpp再靠链接兜底。我用的方案是让构建系统自己去解析模块声明。如果用的 CMake,需要 3.28 以上版本,搭配 Ninja 生成器。
cmake_minimum_required(VERSION 3.28) project(StatsDemo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(stats STATIC) target_sources(stats PUBLIC FILE_SET CXX_MODULES FILES src/stats.cppm PRIVATE src/stats-impl.cpp )注意FILE_SET CXX_MODULES这个语法,它专门声明“这个文件是模块接口单元”。CMake 会像处理头文件安装一样处理这个文件集,并且在构建时优先编译接口单元、给下游生成依赖信息。如果用的是 MSVC 的生成器,CMake 3.28 对 C++20 模块的支持还有不少边角问题,我自己实践下来最稳的还是 Ninja。
Clang 下如果不想用 CMake,手写编译顺序是这样的:
# 第一步:编译接口单元,生成 pcm clang++ -std=c++20 -fmodules --precompile stats.cppm -o stats.pcm # 第二步:编译实现单元 clang++ -std=c++20 -fmodule-file=stats=stats.pcm -c stats-impl.cpp -o stats-impl.o # 第三步:编译主程序 clang++ -std=c++20 -fmodule-file=stats=stats.pcm -c main.cpp -o main.o # 第四步:链接 clang++ stats-impl.o main.o -o demo这里的-fmodule-file=stats=stats.pcm就是告诉编译器“stats 模块的 BCL 是哪个文件”。一旦模块数量变多,手写就没法维护了,构建系统的作用就完全是“自动解析模块依赖 + 拓扑排序编译”。
5.2 BCL 与缓存:增量构建的真相
模块接口单元的 BC L缓存是性能大头。Clang 生成的.pcm、MSVC 的.ifc,本质都是“已经做完语义分析的接口快照”。当实现单元改动时,BCL 没有变,下游所有文件都不需要重新做 parse,所以增量构建速度可以好到头文件模式不敢想的地步。
但我必须泼一盆冷水:BCL 缓存的高度敏感性意味着微小的接口改动都会引发全量重编译。传统头文件里你加一个private成员变量,所有包含它的文件都要重编;模块上也类似,只不过触发条件是“接口单元内容变了”。所以在模块接口设计里,有一条不成文的规则:接口稳定高于一切。前期接口没定稳就开工,后期每天都因为接口调整全量重编,会比老模式更痛苦。它倒逼设计前置,这不是坏事,但团队如果习惯了“先在头文件里堆,编译不过再改”的节奏,需要一点适应期。
5.3 编译器支持与平台差异速查
目前主流 C++20 模块实现的成熟度仍有差异,我按自己实际使用的感受整理成了一张表:
| 编译器 | 模块支持状态 | 注意事项 |
|---|---|---|
| MSVC | 最成熟,VS 2022 17.x 一直在迭代 | 接口单元扩展名惯用.ixx;模板导出支持良好 |
| Clang | 相当可用,CMake + Ninja 配合很顺 | 对标准库头文件 import 的支持需要-fimplicit-modules;新旧版本间行为有差异 |
| GCC | 逐步跟进,但历史版本支持不稳定 | 常用-fmodules-ts标志;社区使用量少于前两者 |
标准库本身能否import <vector>这类头文件,取决于标准库是否提供了模块映射(header unit)。MSVC 和 Clang 自家标准库都推进得不错,但如果你还在用旧版标准库,就得退回“全局模块片段 +#include过渡 +export import”的方案。我的经验是,如果项目工具链能以 Clang 或 MSVC 为主力,模块化收益会明显高于继续留守头文件路径。
6. 常见问题与排查技巧实录
6.1 编译与链接错误速查表
模块的报错信息和传统 C++ 差异很大,我把高频问题和排查方向整理成表,省得大家遇到时报头不知措:
| 报错表现 | 常见根因 | 解决方向 |
|---|---|---|
module 'stats' not found | 构建系统没有先编译接口单元,或-fmodule-file路径不对 | 确认接口单元已在CXX_MODULES文件集;检查 pcm 路径名和模块名是否完全一致 |
cannot export non-inline external function | 在接口单元里尝试定义外部链接的普通函数 | 接口单元只放声明,定义放实现单元;如果要放定义,必须是模板或内联函数 |
'Impl' is not a member of 'messagebus' | 主接口想访问私有分区里的类型,却没有import :impl | 主接口需要import :impl(非 export)来使用私有类型 |
use of undeclared identifier 'std::vector' | 接口单元用了 STL 类型但只#include没有export import | 在接口单元显式export import <vector> |
| 链接时大量重复符号 | 一个非内联函数的定义被写进了接口单元 | 实现移到实现单元,接口只保留声明 |
| 下游文件改了,但 build 不重新编译模块 | 构建系统没有跟踪 BCL 的依赖变更 | 检查 CMake/Ninja 的 module dependency 扫描是否开启 |
链接错误尤其容易让人迷路。因为模块里的非导出符号默认不会出现在模块的对外接口里,你在实现单元里写了一个static辅助函数,外部链接器根本看不到它;一旦确认无误却报未定义,优先检查你是不是把实现放进了“接口单元”而不是“实现单元”。
6.2 循环依赖在模块体系下的死路
头文件模式下,循环依赖有时能靠 include guard 和“反正都被打平了”的文本机制糊弄过去。模块模式下循环依赖是物理不可行的——A 的接口要 import B,B 的接口又要 import A,任何一个模块的 BCL 都无法先生成。
打破循环的方式通常有三种:
第一,把脆弱依赖降级为前向声明。模块接口单元支持export class X;这种前向声明,如果 A 只需要“知道 X 存在”,完全不需要 import B 的完整接口。使用方真正需要 X 的完整类型时,再自己 import B 即可。
第二,让依赖走分区通道。A 的对外接口不依赖 B,但内部实现依赖 B,可以把“A 依赖 B”的部分放进A:impl分区;接口不 export 这个分区,外部接触不到依赖,构建系统也能正常拓扑排序。
第三,重新审视抽象层次。“A 和 B 互相依赖但谁也不离不开谁”,本身就是领域建模问题的信号,通常意味着中间少了一层抽象。最好的解法是把两者共同依赖的类型抽到第三个模块或分区里,让依赖变成单向的。
这个“物理强制无环”的约束一开始会让人觉得束手束脚,但它其实是强迫你的项目把架构画成一张有序的依赖图,从长期维护看是高收益的。
6.3 老项目迁移的渐进路径:鸟笼式过渡
如果你现在接的是一个有几十个模块、几千个头文件的老项目,不要试图一个月内全部转模块。我用下来的可行路径是“四步走”:
第一步,把全项目切到支持模块的编译器,但暂时不改代码。先保证老代码在新工具链下能编译、能测试,消除变量。
第二步,从依赖链最底层的库开始模块化。比如某个工具库只有头文件和少量实现,被其他十几个模块依赖,先把这一小块改成模块接口,上层先不要动。此时上层仍然可以import 这个新模块,或者临时通过一个“胶水模块”把模块重新导回头的格式:
// glue.cppm —— 把模块重新导出成传统头文件形态 export module old_glue; export import new_stats; #define STATS_INLINE 1这个胶水模块能让旧文件继续用#include 新模块对应的头文件的方式工作,等于给迁移过程留了一条后路。等所有调用方都切到import,再把胶水删掉。
第三步,处理高依赖的公共头文件。把所有#include集中到全局模块片段里,逐步替换成export import <对应头文件>。这个阶段通常能看见编译时间第一次明显下降。
第四步,优化接口细节。把实现单元没必要的导出收干净、能加constexpr的加上、接口里能隐藏的类型全部隐藏。
我见过不少团队第一步就卡住了,因为内部第三方库不支持模块边界。这种时候不用硬闯,保留混合模式完全可行:一部分模块、一部分头文件,两者通过胶水模块和实现单元衔接,过渡期会长一点,但比一次性推倒重来稳得多。
7. 我自己上手后的一些体会
真用了大半年模块之后,最大的感受不是编译变快了多少——虽然增量构建确实会变快,而是代码评审的方式变了。头文件模式下,review 一个人改接口,我要看他改了哪些声明、有没有破坏 ABI;模块模式下,接口单元的export列表就是一份自带的“公共 API 文档”,任何公开能力的变化都摆在明面上。就连新人接手模块,也不会出现“看到头文件里某个类以为能用、实际没被导出”这种事,边界本身就是文档。
最后分享一个小技巧:模块接口单元里我会刻意维护一份“已导出实体清单”的注释块,按功能分组列出来。不光是给维护的人看,也是给自己一个心理锚点——凡是清单之外的,都不应该被公众误用。这个习惯帮我在接口设计阶段少犯很多“顺手就 export”的错误,也让我后来的重构成本明显低于项目里还在用头文件的老代码。如果你也准备开始用 C++20 模块,我建议一开始就认真对待接口设计,把它当成新工程的核心约定来定,别只把它当成一个编译加速器。