1 Modules是什么?
C++20 引入了一个重要的新特性:模块(Modules),这是对 C++ 编译模型的一次重大革新,旨在解决传统头文件(header files)机制带来的诸多问题,如编译速度慢、宏污染、重复包含、依赖管理混乱等。
这么说可能有点抽象, 那么我们说人话就是:
- 有了模块之后, C++就不需要不需要将源文件和头文件分离了
- 你甚至可以不需要头文件
- 你的C++的文件结构变得类似
Java和Python,import时导入你想要的代码就行了, 不需要考虑头文件这个东西
以上只是引入Modules后代码直观结果, 当然Modules的功能不仅仅如此
2 头文件存在的问题
2.1 传统头文件机制的代码案例
在 C++20 之前,我们通常采用“声明-定义分离”模式:
1 | // math.h |
这个例子中, 也就是传统的 C++ 编译模型中,头文件实际上是一种文本替换机制。当预处理器遇到 #include 指令时,它会将指定的头文件内容直接复制到 #include 所在的位置。这种机制也被称为”包含模型”(include model)。
例如,在上面的例子中,预处理器处理后的 math.cpp 文件实际上变成了:
1 | // 预处理器处理后的 math.cpp |
这个过程中, 头文件的作用是: 生命一个编译时期可见的符号, 在这个例子中, 头文件math.h声明了函数add, 使得math.cpp可以调用这个函数, 为了理解这句话, 我们还需要知道C++ 程序从源代码到可执行文件需要经历的步骤:
编译阶段
- 预处理阶段(Preprocessing):
- 处理 #include、#define 等预处理指令
- 展开头文件内容,替换宏定义
- 条件编译处理
- 编译阶段(Compilation):
- 将预处理后的文件转换为汇编代码
- 每个源文件(.cpp)独立编译成目标文件(.obj/.o)
- 进行词法分析、语法分析、语义分析
- 生成符号表
- 汇编阶段(Assembly):
- 将汇编代码转换为机器码
- 生成目标文件(.obj/.o)
链接阶段
- 合并多个目标文件
- 解析外部引用(如函数调用)
- 重定位地址
- 生成最终的可执行文件
这里还需要知道一个重要的知识点, C++编译的基础单元就是一个.cpp文件(严格说是翻译单元 translation unit), 而.cpp文件会生成一个.obj文件, 一个或多个.obj文件经处理会生成一个.exe文件
在这个过程中, 一个编译单元引用其他编译单元的符号如何确保其正确和有效呢? 在传统方法中, 就是通过头文件告知编译器。 例如这里main.cpp需要调用add函数, 那么它就需要包含math.h, 这样编译器在编译main.cpp时就能够从头文件知道add函数的存在, 先将其地址标记为可重定位。最后在链接阶段, 链接器会找到add函数的实现并将之前的可重定位符号替换为真实的地址。
2.2 传统头文件机制的问题
基于上述编译过程,我们可以发现传统头文件机制好像没啥问题?
但如果是下面几个场景呢?
- 一个头文件在大型项目中被多个源文件包含
- 每次编译这些源文件时, 头文件内容都会被重复处理
- 即使头文件发生微小变化,所有包含它的源文件都需要重新编译
- 不同代码由不同的团队编写, 可能存在冲突的符号
- 虽然可以使用
include guards(如#ifndef)或#pragma once来防止重复包含,但这增加了复杂性,并且仍需要预处理器进行检查
- 虽然可以使用
- 模板支持不佳
- 模板实现必须放在头文件中才能被正确实例化,这进一步增加了头文件的大小和编译负担。
3 Modules 所解决的问题
C++20引入的Modules特性旨在从根本上解决上述问题:
3.1 编译速度提升
Modules通过引入模块接口单元(module interface unit)来替代头文件。模块只编译一次,生成中间表示形式,其他编译单元可以直接导入这个预编译的结果:
1 | // math.cpp (模块接口) |
这里给出的名词和代码案例先不进行详细介绍, 后面的章节中会进行详细介绍
3.2 符号污染问题
Modules具有明确的导出控制,只有明确标记为export的声明才会对外可见,定义的符号默认不会泄露到导入模块的编译单元中。例如
1 | export module math; |
这里的inner_add函数没有使用export关键字,不会被导出,因此不会被其他模块访问。
4 Modules 语法详解
4.1 模块声明
默认 → 普通单元
- 不使用
module关键字的翻译单元是普通单元(即传统.cpp文件) - 可以包含头文件,使用
#include
- 不使用
加
module关键字 → 模块单元- 使用
module声明的单元称为模块单元 - 分为两种子类型:
- 默认 → 模块实现单元
- 仅定义内部逻辑,不对外暴露接口
- 通常用于实现细节
- 加
export关键字 → 模块接口单元- 使用
export module ModuleName;定义模块接口 - 是模块的入口点,供其他模块导入
- 使用
- 默认 → 模块实现单元
- 使用
示例:
1
2
3 export module mymath;
export int add(int a, int b) { return a + b; }
4.2 导入导出的方法
这里的导入导出的含义就是, 如何让外部模块访问模块中的符号
4.2.1 导出(Export)
- 默认 → 不可见
- 模块单元中定义的符号默认对其他模块不可见
- 加
export关键字 → 可见- 显式导出函数、类、变量等,使其可以被其他模块使用
示例:
1
2 export int multiply(int a, int b) { return a * b; } // 可被导入
int helper() { return 42; } // 不可见
4.2.2 导入(Import)
加
import关键字 → 导入只在本编译单元可见- 使用
import ModuleName;引入整个模块 - 导入后,该模块的导出内容可在当前单元中使用
- 使用
普通/模块单元区别:导入头文件
- 模块单元无法使用
#include引入头文件- 必须使用
import "header.h"或import <header>方式导入
- 必须使用
- 问题:预处理宏访问限制
- 在模块单元中,通过
import导入的头文件中定义的宏,不能被其他import的头文件访问 - 因为模块机制绕过了传统的预处理器行为
- 在模块单元中,通过
- 模块单元无法使用
示例:
1
2 import math; // 导入模块
import "myconfig.h"; // 导入自定义头文件(作为模块)补充说明:
import math;用于导入名为math的模块,这与传统的#include方式不同,它不会进行文本替换,而是直接导入模块的接口import "myconfig.h";用于将以传统头文件形式存在的代码作为模块导入,这为逐步迁移现有代码提供了便利- 与
#include不同,import导入的头文件中的宏定义不会泄露到全局命名空间,避免了宏污染问题- 模块导入具有原子性,要么全部导入成功,要么全部失败,不会出现部分导入的情况
4.3 全局和私有模块片段
- 模块支持定义全局模块片段(Global Module Fragment)
- 用
module;表示 - 用于在模块中声明不依赖于模块名的全局内容
- 用
- 支持私有模块片段(Private Module Fragment)
- 用于定义仅在模块内部使用的符号
- 无需
export,自动隐藏
示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 export module mylib;
module; // 全局模块片段开始
int global_counter = 0; // 全局变量,模块内可见
export int get_counter() {
return global_counter;
}
module : private; // 私有模块片段开始
void internal_helper() {
// 仅在当前模块内可用的辅助函数
global_counter++;
}
export void increment_counter() {
internal_helper(); // 可以调用私有函数
}在这个例子中:
module;开始全局模块片段,可以包含头文件和全局声明module : private;开始私有模块片段,其中定义的符号仅在当前模块内可见global_counter在全局模块片段中定义,对整个模块可见但不导出internal_helper函数在私有模块片段中定义,只能在当前模块内使用
4.4 子模块划分
- 模块支持子模块(Submodules)或模块分区(Module Partitions)
- 用于将大型模块拆分为多个部分,便于组织和维护
- 语法:
export module mylib:part1;—— 子模块接口module mylib:impl;—— 实现部分(非导出)
示例:
1
2
3
4
5
6
7
8
9 // mylib.cppm
export module mylib;
export int main_func(); // 接口
// mylib:impl.cppm
module mylib:impl;
int main_func() { return 42; } // 实现
注意:主模块必须先声明,子模块才能引用。
4.5 所有权问题
- 模块的所有权属于其定义者
- 不能随意修改已发布的模块
- 导入模块时,不能重定义其符号
- 模块间的依赖关系明确且强绑定
- 避免循环依赖:模块间应保持清晰的依赖链
⚠️ 重要提示:
- 模块一旦发布,其接口应尽量稳定
- 修改模块接口可能破坏所有依赖它的代码
- 模块是“编译期实体”,不像头文件那样容易“偷偷”引入副作用
5 实战 Demo
下面是一个 使用 xmake 构建 C++20 Modules 的完整真实案例,涵盖了模块的大部分常用功能:
- 主模块接口(Primary Module Interface)
- 模块实现单元(Module Implementation Unit)
- 模块分区(Module Partition)
- 导入标准库模块(如
) - 与传统 .cpp 文件混合编译
完整代码请参考: https://github.com/Vanilla-Beauty/cpp_notes_code/tree/master/modules_demo
这里说明为什么使用
xmake而不是cmake, 原因当然是xmake配置最简单了…同时我个人非常不喜欢cmake的语法…
📁 0 项目结构
1 | bash |
🔧 1. xmake.lua 配置文件
1 | -- xmake.lua |
📄 2. 模块分区:src/math/utils.ixx\
1 | // src/math/utils.ixx |
📄 3. 主模块接口:src/math/math.ixx
1 | // src//math/math.ixx |
📄 4. 模块实现单元:src/math/math_impl.cpp
1 | // src/math/math_impl.cpp |
⚠️ 实现单元使用 module math;(无 export),表示这是 math 模块的一部分,但不导出新符号。
这里模块接口定义单元和模块实现单元类似传统的头文件与源文件
📄 5. 主程序:src/main.cpp
1 | // src/main.cpp |
✅ 注意:import
如果你的编译器不支持 import
1 |
|
▶️ 6. 构建与运行
1 | # 进入项目目录 |
预期输出:
1 | 2 + 3 = 5 |
如果输出符合预期, 那么恭喜, 你已经成功使用 C++20 Modules 构建了一个完整的项目。