C++新特性梳理-Modules

1 Modules是什么?

C++20 引入了一个重要的新特性:模块(Modules),这是对 C++ 编译模型的一次重大革新,旨在解决传统头文件(header files)机制带来的诸多问题,如编译速度慢、宏污染、重复包含、依赖管理混乱等。

这么说可能有点抽象, 那么我们说人话就是:

  • 有了模块之后, C++就不需要不需要将源文件和头文件分离了
  • 你甚至可以不需要头文件
  • 你的C++的文件结构变得类似JavaPython, import时导入你想要的代码就行了, 不需要考虑头文件这个东西

以上只是引入Modules后代码直观结果, 当然Modules的功能不仅仅如此

2 头文件存在的问题

2.1 传统头文件机制的代码案例

在 C++20 之前,我们通常采用“声明-定义分离”模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
// math.h
#ifndef MATH_H
#define MATH_H
int add(int a, int b);
#endif

// main.cpp
#include "math.h"
int add(int a, int b) { return a + b; }

// math.cpp
#include "math.h"
int add(int a, int b) { return a + b; }

这个例子中, 也就是传统的 C++ 编译模型中,头文件实际上是一种文本替换机制。当预处理器遇到 #include 指令时,它会将指定的头文件内容直接复制到 #include 所在的位置。这种机制也被称为”包含模型”(include model)。
例如,在上面的例子中,预处理器处理后的 math.cpp 文件实际上变成了:

1
2
3
4
5
6
7
// 预处理器处理后的 math.cpp
#ifndef MATH_H // 来自 math.h
#define MATH_H // 来自 math.h
int add(int a, int b); // 来自 math.h
#endif // 来自 math.h

int add(int a, int b) { return a + b; }

这个过程中, 头文件的作用是: 生命一个编译时期可见的符号, 在这个例子中, 头文件math.h声明了函数add, 使得math.cpp可以调用这个函数, 为了理解这句话, 我们还需要知道C++ 程序从源代码到可执行文件需要经历的步骤:

编译阶段

  1. 预处理阶段(Preprocessing):
    1. 处理 #include、#define 等预处理指令
    2. 展开头文件内容,替换宏定义
    3. 条件编译处理
  2. 编译阶段(Compilation):
    1. 将预处理后的文件转换为汇编代码
    2. 每个源文件(.cpp)独立编译成目标文件(.obj/.o)
    3. 进行词法分析、语法分析、语义分析
    4. 生成符号表
  3. 汇编阶段(Assembly):
    1. 将汇编代码转换为机器码
    2. 生成目标文件(.obj/.o)

链接阶段

  1. 合并多个目标文件
  2. 解析外部引用(如函数调用)
  3. 重定位地址
  4. 生成最终的可执行文件

这里还需要知道一个重要的知识点, C++编译的基础单元就是一个.cpp文件(严格说是翻译单元 translation unit), 而.cpp文件会生成一个.obj文件, 一个或多个.obj文件经处理会生成一个.exe文件

在这个过程中, 一个编译单元引用其他编译单元的符号如何确保其正确和有效呢? 在传统方法中, 就是通过头文件告知编译器。 例如这里main.cpp需要调用add函数, 那么它就需要包含math.h, 这样编译器在编译main.cpp时就能够从头文件知道add函数的存在, 先将其地址标记为可重定位。最后在链接阶段, 链接器会找到add函数的实现并将之前的可重定位符号替换为真实的地址。

2.2 传统头文件机制的问题

基于上述编译过程,我们可以发现传统头文件机制好像没啥问题?

但如果是下面几个场景呢?

  1. 一个头文件在大型项目中被多个源文件包含
    1. 每次编译这些源文件时, 头文件内容都会被重复处理
    2. 即使头文件发生微小变化,所有包含它的源文件都需要重新编译
  2. 不同代码由不同的团队编写, 可能存在冲突的符号
    1. 虽然可以使用include guards(如#ifndef)或#pragma once来防止重复包含,但这增加了复杂性,并且仍需要预处理器进行检查
  3. 模板支持不佳
    1. 模板实现必须放在头文件中才能被正确实例化,这进一步增加了头文件的大小和编译负担。

3 Modules 所解决的问题

C++20引入的Modules特性旨在从根本上解决上述问题:

3.1 编译速度提升

Modules通过引入模块接口单元(module interface unit)来替代头文件。模块只编译一次,生成中间表示形式,其他编译单元可以直接导入这个预编译的结果:

1
2
3
4
5
6
7
8
9
10
11
12
// math.cpp (模块接口)
export module math;

export int add(int a, int b) {
return a + b;
}

// main.cpp
import math; // 直接导入,无需文本替换
int main() {
return add(1, 2);
}

这里给出的名词和代码案例先不进行详细介绍, 后面的章节中会进行详细介绍

3.2 符号污染问题

Modules具有明确的导出控制,只有明确标记为export的声明才会对外可见,定义的符号默认不会泄露到导入模块的编译单元中。例如

1
2
3
4
5
6
7
export module math;
export int add(int a, int b) {
return a + b;
}
int inner_add(int a, int b) {
return a + b;
}

这里的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; // 全局模块片段开始
#include <iostream> // 在全局模块片段中包含头文件
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
2
3
4
5
6
7
8
9
bash
modules_demo/
├── src
│   ├── main.cpp #
│   └── math
│   ├── math.ixx
│   ├── math_impl.cpp
│   └── utils.ixx
└── xmake.lua

🔧 1. xmake.lua 配置文件

1
2
3
4
5
6
7
8
9
10
11
12
-- xmake.lua
set_languages("c++20")

-- 启用模块支持(关键!)
set_policy("build.c++.modules", true)

target("modules_demo")
set_kind("binary")
add_files("src/main.cpp")
add_files("src/math/math.ixx") -- 主模块接口
add_files("src/math/math_impl.cpp") -- 模块实现单元
add_files("src/math/utils.ixx") -- 模块分区

📄 2. 模块分区:src/math/utils.ixx\

1
2
3
4
5
6
7
8
// src/math/utils.ixx
export module math:utils;

export namespace math::utils {
inline int square(int x) {
return x * x;
}
}

📄 3. 主模块接口:src/math/math.ixx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src//math/math.ixx
export module math;

// 导入模块分区
export import :utils; // 等价于 import math:utils;

export namespace math {
// 导出函数声明(定义在实现单元)
int add(int a, int b);
int multiply(int a, int b);

// 内联函数可直接定义并导出
inline int power2(int x) {
return utils::square(x);
}
}

📄 4. 模块实现单元:src/math/math_impl.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/math/math_impl.cpp
module math; // 注意:这里不加 export!

// 实现主模块中声明的函数
namespace math {
int add(int a, int b) {
return a + b;
}

int multiply(int a, int b) {
return a * b;
}
}

⚠️ 实现单元使用 module math;(无 export),表示这是 math 模块的一部分,但不导出新符号。

这里模块接口定义单元和模块实现单元类似传统的头文件与源文件

📄 5. 主程序:src/main.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/main.cpp
import math; // 导入我们自己的模块
import <iostream>; // C++20 标准库模块(需编译器支持)

int main() {
using namespace std;
using namespace math;

cout << "2 + 3 = " << add(2, 3) << endl;
cout << "4 5 = " << multiply(4, 5) << endl;
cout << "7^2 = " << power2(7) << endl; // 来自内联定义
cout << "9^2 (via utils) = " << utils::square(9) << endl;

return 0;
}

✅ 注意:import ; 要求编译器支持标准库模块(MSVC 支持较好,Clang/GCC 需额外配置或暂时回退到 #include)。

如果你的编译器不支持 import ,可临时改为:

1
2
#include <iostream>
// 并移除 import <iostream>;

▶️ 6. 构建与运行

1
2
3
4
5
6
7
8
# 进入项目目录
cd modules_demo
# 配置(自动检测编译器,推荐 clang++ 或 cl.exe)
xmake f -c
# 构建
xmake
# 运行
xmake run

预期输出:

1
2
3
4
2 + 3 = 5
4 5 = 20
7^2 = 49
9^2 (via utils) = 81

如果输出符合预期, 那么恭喜, 你已经成功使用 C++20 Modules 构建了一个完整的项目。