C++模块化编程:告别#include的新时代
我看网上的一些文档,里面有很多人讲import,对此来讲一下个人见解。
事先声明:本文仅作技术讨论与交流,部分术语可能为非标准表述(如“接口污染”等概念),旨在通俗化描述技术问题。文中观点或示例可能存在不严谨之处,欢迎专业人士指正。
第一章 差异变化
从C++98到C++17,C++一直沿用基于 #include的“库导入”方式来组织代码。这种方式本质上是文本替换:预编译器将头文件的内容原封不动地复制到源文件中,形成一个巨大的编译单元。如:
//C++98及以后的标准 #include<iostream>//导入库 int main()//程序入口 { std::cout<<"Hello,World!"; //隐式加入return 0; }这里是将代码变为:
// iostream standard header // Copyright (c) Microsoft Corporation. // SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception #ifndef _IOSTREAM_ #define _IOSTREAM_ #include <yvals_core.h> #if _STL_COMPILER_PREPROCESSOR #include <istream> #include <ostream> //等等,以上仅作演示,可能不对 int main() { std::cout<<"Hello,World!"; }这种机制带来了几个显著问题:
- 编译速度慢:每个编译单元(.cpp文件)都要独立、重复地解析这些庞大的头文件内容,工程量指数级增长。
- 封装性被破坏:头文件中的所有内容(包括实现细节、私有宏)都会暴露给包含它的源文件,没有真正的信息隐藏。
- 依赖管理复杂:头文件的层层包含容易导致循环依赖、宏污染等问题。
不过有了import后,代码可以变为这样
import std; int main() { std::print("Hello,World"); //隐式添加return 0; }注:这里有人会问:“这不是用了C++23的内容吗?”确实,std::print是 C++23 的新特性。但更重要的是,import std;这种导入整个标准库作为模块的方式,也是 C++23 才正式标准化的。C++20 虽然引入了模块语法,但并未为标准库提供模块化支持。具体可参考微软文档 教程:使用命令行中的模块导入 C++ 标准库。
这里本质是寻找std::print这个接口,并调用实现,能极大限度的保证封装性。
同时在编译的角度里,他仅仅只是调用接口,使用,所以编译时间完虐#include
依赖管理这个更明显了,非常清晰的表示“我需要std这个模块里的接口”。
所以,模块化可能是C++工程中最革命的一环
第二章 怎么实现模块
在传统C++中,我们用 .hpp声明接口,用 .cpp实现功能。但这只是逻辑上的分离——只要 #include了头文件,里面的 private成员、内部类型,全都暴露无遗。这就像你家装修,虽然卧室门上挂了“私有”的牌子,但墙却是玻璃的,路过的谁都能瞅一眼。
模块的出现,就是为了砌上这堵实心的墙。
至于怎么实现……见下
2.1 基于现有的代码进行实现
传统C++工程通常采用.hpp声明与.cpp实现分离的方式,前者定义接口规范,后者完成具体实现。
模板化改造后演变为.ixx接口文件与.cpp实现文件的组合。虽然形式上相似,但设计思维存在本质差异:.ixx通过抽象接口描述组件契约,.cpp则负责接口的具体外部实现,形成更松散的模块化耦合。
以下为例子
转换前:
// CharacterBase.hpp #pragma once #include<string> namespace GameSet { class NameID { /* ... */ }; class Performance { /* ... */ }; class MoveSpeed { /* ... */ }; };// CharacterBase.cpp #include"CharacterBase.hpp" namespace GameSet { // ... 实现代码 };转换后:
// CharacterBase.ixx export module CharacterBase; // 确定模块名,为后续导入 // import std; // 导入std,但不知为何这个会出错,故先用#include #include <string> export namespace GameSet // export声明这个命名空间是公有的 { class NameID { /* ... */ }; class Performance { /* ... */ }; class MoveSpeed { /* ... */ }; };// CharacterBase.cpp module CharacterBase; // 声明这个.cpp是CharacterBase模块里的 namespace GameSet { // ... 实现代码(与转换前几乎相同) };几个关键变化:
#pragma once→export module 模块名;
从“防止本文件被重复包含”的防御性指令,升级为“主动声明一个模块身份”的构建性指令。#include <string>→import std;
从“复制粘贴文本”变为“导入预编译的接口”。这是编译速度产生质变的核心。- 在需要公开的代码前添加
export
这是模块的“守门人”。只有被export导出的符号(类、函数、命名空间)才会成为模块的公有接口。未导出的部分(包括整个类内部的私有成员)对外部代码完全隐形,实现了真正的物理封装。 - 实现文件首行写
module 模块名;
这行代码告诉编译器:“本文件是某模块的实现部分,我拥有访问其全部内部细节的权限。”因此,实现代码可以无缝使用模块接口中定义的私有成员。
(顺便说一句,我的代码写得有点shit)
不过这仅仅只是有的情况下,万一团队从头开始呢?
2.2 从头开始编写.ixx文件
如果你正在关注C++26版本,建议在看完本教程后查阅std模块是否已被封装。本教程基于C++23标准编写。
C++提供了丰富的声明方式,例如变量声明支持超过20种不同的语法形式,模块导入机制也包含12种具体方法,详细内容可参考微软文档中关于module、import和export的说明。
这边展示最基础的
//mymodule.ixx export module mymodule;//声明模块名字 /*namespace mymodule*///可选,但建议加上,原因见后面 int fier();//私有接口 void slow();//私有接口 export void set();//公有接口//mymodule.cpp module mymodule;//声明接口 set() { //dosomething }当其他文件import mymodule;时:
- 能看到:
api()函数 - 完全看不到:
helper()和internal()函数
为什么建议加上命名空间?
虽然模块名本身提供了一层隔离,但在模块内部使用命名空间(如namespace mymodule { ... })仍然是好习惯。这能避免模块内符号污染全局空间,并与现有大量使用命名空间的代码风格保持一致。
第三章 当前import机制存在的痛点
本章为个人观点
尽管模块功能强大,但仍存在几个明显的缺陷需要注意
3.1 官方库的缺点
3.1.1 编译器并未完全普及
如标题所示,当前编译器对C++17的支持最为完善。虽然C++20和C++23已在实验性支持(MSVC)或部分支持(Clang)阶段,但尚未达到普及程度。预计在下一版本C++中,这些新特性将获得全面支持。
注意:2.1代码中import std;在某些编译器或项目配置下可能无法通过编译。这是因为标准库模块 (std) 是 C++23 才正式引入的特性,且需要在编译器中明确开启支持(如在 MSVC 中需使用/std:c++23并配合特定编译开关)。如果你遇到错误,一种临时的兼容方式是回退到在.ixx文件中使用#include <string>,但这会牺牲部分模块化优势。这正说明了新特性的普及仍需时日。
3.1.2 导入功能过多
当前std模块同时包含IO交互和STL库功能,甚至包含一些使用频率较低的特性,这违背了DRY和YAGNI原则。因此在2.2节特别说明:"如果你关注C++26版本,建议学完本教程后查阅std模块的最新封装情况。本教程内容基于C++23标准编写。"
3.2 接口/命名空间污染
随着接口数量持续增长,std模块功能过于庞杂的问题日益突出,容易引发同名接口冲突。为此,必须对std模块进行重构和细化封装。同时,编译器需要提供相应警告机制(或通过制定编码规范)来预防此类问题的发生。
为避免全局命名空间污染的风险,谨慎设计模块的接口边界至关重要。
3.3 工程中会同时出现#include<>与import
混合使用难免降低可读性,仅用单一方式又容易产生问题,这确实是个两难困境。这是一个典型的“过渡期阵痛”。
第四章 工程中比较推荐的写法。
注:本部分为个人推荐,作者并未发现任何文档能支持
4.1 传统导入库+现代导入模块
将常用代码模块化虽然会增加一些复杂性,能显著提升整体代码的可读性。如:
export module CharacterBase; import std; export namespace GameSet { class NameID { private: std::string m_name; std::string m_id; public: NameID(std::string const&, std::string const&); std::string GetName()const; std::string GetID()const; void SetName(std::string const&); };//存储玩家社交信息。 class Performance { private: float m_current_health; float m_max_health; public: Performance(float); float GetMaxHealth()const; float GetCurrentHealth()const; void SetCurrentHealth(float); void SetMaxHealth(float); };//之后基础属性往这里塞 class MoveSpeed { float m_speed; public: MoveSpeed(float); float GetSpeed()const; void SetSpeed(float); };//之后进阶属性往这里塞 };将std::string封装为一个专门的模块
// StringModule.ixx export module StringModule; #include <string> export class StringWrapper { public: std::string data; // 这里仅仅只是想用string而已,未必需要复杂封装。 };重新导入(记得修改一下)
// CharacterBase.ixx (修改后) export module CharacterBase; import StringModule; // 导入我们自制的模块 export namespace GameSet { class NameID { private: StringWrapper m_name; // 使用自制的字符串包装类 StringWrapper m_id; public: // ... 接口声明 }; // ... 其他类 };这样做看似多此一举,但能精确控制依赖,减少不必要的接口暴露。
4.2 自创模块+实现导入库
这个思路就非常简单:将所有公开接口抽象到.ixx文件中,所有具体实现留在.cpp文件里。这正是本文2.1节所展示的模式,也是模块化最核心、最推荐的做法。
4.3 避免反复导入:保持依赖清晰
为什么不推荐在模块中反复使用#include?
正如在3.3节提到的,在工程中混合使用#include<>与import是常见的过渡期现象,但这会带来可读性和维护性的问题。具体到模块内部,反复导入库的弊端更加明显:
- 破坏封装一致性:一个模块的理想状态是对外提供明确的接口,对内隐藏所有实现细节。如果在模块接口文件(
.ixx)中大量使用#include,相当于又把外部库的实现细节“泄露”到了模块的接口边界上,削弱了模块的封装价值。 - 编译优势被抵消:模块的核心优势之一是避免重复编译。但如果你在每个模块里都
#include <vector>,那么vector的代码又会在每个模块的编译过程中被重复处理,部分抵消了模块带来的编译加速收益。 - 依赖关系复杂化:假设模块A和模块B都
#include <some_library.h>,而some_library.h发生了改变,那么A和B都需要重新编译。如果它们通过一个共同的、稳定的模块接口来间接使用该功能,则可能只需要重新编译那个接口模块。
工程建议:
- 优先
import:对于已模块化的标准库组件(C++23的std/std.core),坚持使用import。(虽然4.1代码块并没有这么做,更多是收敛) - 收敛
#include:对于尚未模块化的第三方库或平台头文件,尽量将其集中到少数几个“适配模块”中。其他业务模块只import这些适配模块,而不是直接#include原始头文件。 - 保持接口纯洁:模块的
.ixx文件应尽可能只包含import语句和export接口声明,将具体的实现细节(包括必要的#include)推到.cpp实现文件中。
简单的说:把模块当成一个“边界清晰的盒子”。盒子里怎么折腾(.cpp里#include)是内部事务,但盒子对外展示的接口(.ixx)应该尽可能干净、稳定、不暴露内部所用的具体工具。
第五章 对未来的猜测
注:本章为个人观点,无任何文档证实。
5.1 Unity的实验性兼容
考虑到Unity引擎底层大量使用C++,且对性能、编译速度和跨平台有极高要求,模块几乎是Unity未来无法回避的技术选项。
我猜测Unity团队很可能已经在内部进行模块的评估和实验,原因有三:
- 编译加速需求:Unity项目的编译时间一直是开发者痛点。模块带来的编译期改进,对动辄数小时的完整项目构建是巨大的诱惑。
- 更好的封装:Unity的代码库极其庞大,模块提供的物理级别封装,能更好地隔离引擎核心代码与用户脚本、第三方插件,减少意外耦合。
- C++生态演进:作为主流游戏引擎,Unity需要跟上C++标准的步伐,确保长期的技术竞争力。
不过,这种兼容很可能是渐进式的:先在内部工具链、部分子系统试用,再逐步向用户开放的插件系统或脚本后端渗透。我们可能会在未来的Unity版本中看到类似#pragma enable_cpp_modules的编译选项,或是对import语句的实验性支持。
5.2 std模板拆分
当前import std;这种“大一统”的导入方式,几乎肯定会在未来版本中被拆分。原因正是我们在3.1.1节(导入功能过多)和3.2节(接口污染) 所分析的:
- 违背“按需索取”原则:现代C++强调“零开销抽象”,而强迫用户导入整个标准库来使用一个
std::vector,本身就是一种抽象开销。 - 工程可维护性差:巨型模块会拖慢IDE的智能提示、增加符号解析负担,并让依赖关系变得模糊。
- 生态系统压力:第三方库作者会需要更细粒度的依赖控制,而不是绑定整个
std。
我猜想到C++26或C++29,我们会看到更精细的模块划分,比如:
// 未来的理想导入方式(猜测) import std.core; // 最基础的运行时支持 import std.containers; // vector, map, set等 import std.threading; // thread, mutex, atomic import std.io; // iostream, format // 而不是现在的 import std; (一股脑全进来)这种拆分不仅能解决接口污染问题,还能让编译器的增量编译、并行编译更加高效。当然,为了向后兼容,import std;这个“快捷方式”很可能还会保留,但它会逐渐被编码规范标记为“不推荐”。
第六章 总结:模块,C++工程的静默革命
注:本部分由ai生成
回顾这场从#include到import的迁移,我们看到的不仅是一次语法更新,更是C++工程哲学的一次深刻转变。
模块带来的核心价值可以用三个词概括:
- 真正的封装——不再是“约定俗成”的
private,而是编译器强制的物理边界 - 编译革命——从每个文件重复解析的“体力劳动”,到一次编译多次复用的“智能缓存”
- 依赖清晰——从隐晦的
#include链到显式的import声明
但正如我们所讨论的,这场革命仍在进行中。C++23的std模块更像一个“概念验证”,编译器支持参差不齐,工程实践也还在摸索。我们既要拥抱模块带来的新可能,也要清醒地认识到它的过渡期特性——混合使用#include与import、谨慎设计模块边界、关注编译器支持度,这些都是现阶段必须考虑的务实问题。
模块不会一夜之间取代所有#include,就像nullptr没有立即淘汰NULL一样。C++的演进总是渐进的、兼容的。但趋势已经明确:模块代表了C++对大规模工程、快速编译、清晰架构的追求。
作为开发者,我们现在要做的不是立即重写所有代码,而是开始理解模块的思想,在合适的场景尝试,并关注标准的演进。当C++26带来更精细的std.vector、std.thread模块时,当更多编译器完善支持时,我们已经准备好了。
毕竟,最好的代码不是用最新语法写成的,而是用最合适的工具解决实际问题的。模块就是C++给我们的一把新工具——锋利、高效,但需要时间去掌握它的正确握法。