1. 项目概述:为什么我们需要深入理解C++26模块系统与MSVC
如果你是一名长期在Windows平台上使用Visual Studio进行C++开发的工程师,那么“模块”这个词对你来说,可能已经从C++20时代的一个遥远概念,变成了一个近在眼前、却又充满困惑的现实。从Visual Studio 2019开始,MSVC就逐步提供了对C++模块的实验性支持,而到了VS 2022 17.5及之后的版本,标准库模块的支持已经趋于完善。现在,随着C++26标准草案的推进,模块系统正在变得更加成熟和强大。但问题来了:我们真的理解它吗?还是仅仅在项目属性里勾选了“/std:c++latest”和“/experimental:module”,然后对着那一堆.ixx文件感到迷茫?
我经历过这个阶段。最初,我只是把模块看作一种“更快的头文件”,试图用import std;替换掉#include <iostream>,然后期待编译速度的飞跃。但现实往往更骨感:链接错误、奇怪的IntelliSense提示、以及面对大型遗留项目时无从下手的重构压力。直到我花了大量时间,结合MSVC编译器的具体行为进行深度调试和剖析,才真正理解了模块系统背后的设计哲学、编译模型以及MSVC独特的实现细节。这篇文章,就是我这些经验的总结。它不是一份简单的语法手册,而是一份针对MSVC编译器的“实战剖析指南”。我们将一起拆解模块的编译过程、解析.ifc接口文件、理解模块分区与私有片段的奥秘,并最终掌握如何在一个真实的、混合了传统头文件和现代模块的项目中游刃有余。
2. 核心需求解析:从“头文件地狱”到模块化编译
在深入技术细节之前,我们必须先搞清楚模块系统究竟要解决什么问题。只有这样,后续的所有选择和实践才有意义。
2.1 传统头文件机制的固有缺陷
我们早已习惯了#include。它简单、直接,但带来的问题在大型项目中几乎是灾难性的。
文本替换的代价:#include本质上是一个文本复制粘贴操作。当一个vector头文件被成百上千个.cpp文件包含时,编译器需要反复地解析、预处理、编译同一份代码。这不仅仅是I/O和CPU时间的浪费。更严重的是,头文件中的宏定义、using指令等会污染每一个包含它的翻译单元。我曾在一个项目中,因为一个底层头文件无意中定义了一个叫count的宏,导致所有使用了std::count算法的文件编译报错,排查过程苦不堪言。
编译防火墙的缺失:在传统的Pimpl(Pointer to Implementation)惯用法中,我们不得不将实现细节放在一个单独的.cpp文件里,只为在头文件中提供一个不透明的指针。这是为了向用户隐藏实现细节,避免头文件变动导致大规模的重新编译。模块系统从语言层面提供了更强大、更自然的编译防火墙。
顺序依赖与循环引用:头文件的包含顺序至关重要,不正确的顺序可能导致符号找不到或者宏定义冲突。虽然可以通过前向声明和精心设计来缓解,但这增加了架构的复杂性。模块的导入顺序则灵活得多,因为模块接口是独立编译并生成二进制描述的。
2.2 C++模块系统的核心承诺
C++模块旨在从根本上改变代码的组织和编译方式。它的核心承诺可以概括为以下几点:
- 逻辑与物理分离:模块声明了哪些接口是对外可见的(通过
export),哪些是内部实现细节。用户只需import模块名,无需关心其内部由多少个文件组成。 - 一次编译,多次使用:模块接口单元(
.ixx)被编译器编译一次,生成一个二进制接口文件(MSVC中为.ifc)。所有导入该模块的翻译单元都直接读取这个.ifc文件,而不是重新解析源代码。这是编译速度提升的理论基础。 - 强封装性:未导出的名称在模块外部完全不可见。这比C++的
private访问权限更强,因为它作用于整个翻译单元级别,彻底杜绝了通过友元或前置声明“偷窥”内部实现的可能性。 - 消除宏污染:模块内部的宏定义不会泄漏到导入方。这终于让我们可以安全地在模块内部使用宏进行条件编译或代码生成,而不必担心影响下游用户。
2.3 MSVC环境下的特殊考量
在MSVC中实践模块,你还需要关注一些平台和工具链特有的点:
- 编译选项:
/std:c++20或更高是基础。对于模块,你通常需要启用/experimental:module(在较新版本中可能已默认集成或更名)。更重要的是/interface选项,它告诉编译器当前源文件是一个模块接口单元。 - 文件扩展名:MSVC约定使用
.ixx作为模块接口文件的扩展名。虽然你可以通过编译选项指定其他扩展名,但遵循约定能让项目结构更清晰,也能被IDE更好地识别。 - 构建系统集成:无论是使用MSBuild(
.vcxproj)还是CMake,都需要正确配置模块间的依赖关系。MSBuild需要知道哪些文件生成.ifc,哪些文件消费.ifc,并据此构建正确的编译图。这是新手最容易踩坑的地方。 - 标准库模块
std与std.compat:从VS2022 17.5开始,你可以使用import std;来导入整个C++标准库。std.compat模块则额外包含了C标准库的全局命名空间符号(如printf),为从C代码迁移提供便利。理解这两者的区别和适用场景很重要。
3. 模块系统核心概念与MSVC实现深度剖析
理解了“为什么”,我们开始深入“是什么”。这一部分,我们将结合MSVC的具体行为,拆解模块系统的核心组件。
3.1 模块单元类型及其在MSVC中的表现
一个模块可以由多种类型的“单元”组成。MSVC对每种单元的处理方式略有不同。
模块接口单元:这是模块的“门面”。它必须使用export module ModuleName;声明。在MSVC中,编译此类文件(如MyModule.ixx)时,你需要使用/interface编译选项(或在项目属性中设置“编译为C++模块代码”)。编译器会做两件事:1) 像普通.cpp一样编译其中的函数体(如果有的话);2) 生成一个.ifc文件(如MyModule.ifc),该文件是模块接口的二进制序列化表示,包含了所有导出实体的声明信息。
模块实现单元:它提供模块接口中导出实体的具体实现。它以module ModuleName;开头(没有export)。关键点在于,它自动“看到”了对应模块接口单元中的所有声明(包括未导出的)。这意味着你不需要在实现单元中#include或import自己的接口头文件。在MSVC中,你只需正常编译这些.cpp文件,编译器会自动查找并关联同名的.ifc文件来获取声明信息。
模块分区:这是管理大型模块的利器。一个模块可以被划分为多个分区,每个分区有独立的接口和实现单元。
- 分区接口单元:
export module ModuleName:PartitionName;。它导出该分区内部的接口。编译后也会生成一个.ifc文件,但文件名会包含分区名(如ModuleName-PartitionName.ifc)。 - 分区实现单元:
module ModuleName:PartitionName;。为该分区提供实现。 - 主接口单元聚合:主接口单元(
export module ModuleName;)必须通过export import :PartitionName;来导出分区的接口。这是模块对外呈现统一视图的关键。
在MSVC中,分区极大地影响了.ifc文件的生成和依赖关系。构建系统必须能正确处理这种“模块主接口依赖分区接口”的图状结构。
3.2 揭秘.ifc文件:模块的二进制契约
.ifc文件是MSVC模块实现的核心。你可以把它想象成一个高度压缩、序列化的“超级头文件”。使用/d1reportAllClassLayout或/d1reportSingleClassLayout等诊断选项时,编译器输出的信息就部分来源于.ifc。
虽然.ifc是二进制格式,但我们可以通过一些方式窥其一二:
- 使用
/sourceDependencies选项:运行cl /c /std:c++latest /interface MyModule.ixx /sourceDependencies,编译器会输出一个.module.json文件,其中详细列出了该模块的依赖图。 - 观察文件变化:尝试修改模块接口(比如增加一个导出的函数),然后重新编译接口单元。你会发现,即使实现单元(
.cpp)没有任何改动,只要.ifc文件内容变了(通过时间戳或MD5校验),所有导入该模块的消费单元都需要重新编译。这是因为消费单元依赖的是.ifc这个二进制接口,而非模块的源代码。
注意:
.ifc文件是MSVC特定的。GCC和Clang使用不同的机制(如CMI,Compiled Module Interface)。这意味着模块的二进制接口在不同编译器间不兼容。你不能用MSVC编译一个模块,然后让GCC来导入它。这是当前模块生态系统的一个主要限制。
3.3 全局模块片段与私有模块片段
这两个概念是模块系统与旧世界(头文件)沟通的桥梁,也是封装内部细节的关键。
全局模块片段:位于模块声明之前,以module;开始。这个区域用于放置必须使用#include引入的内容,通常是那些尚未模块化的第三方库头文件或系统头文件。
// MyModule.ixx module; // 全局模块片段开始 #include <windows.h> #include <legacy_lib.h> // 全局模块片段结束 export module MyModule; // 现在可以安全地使用来自windows.h和legacy_lib.h的类型 export void doSomethingWithWindow(HWND hWnd);在全局模块片段中#include的内容,对于模块内部(接口和实现单元)是可见的,但不会被导出到.ifc文件中。这意味着HWND类型对模块使用者是可见的(因为它来自windows.h,而windows.h可能在用户代码中也被包含了),但legacy_lib.h内部的私有宏或类型如果未在模块接口中使用,则对使用者完全隐藏。
私有模块片段:位于模块接口单元的末尾,以module :private;开始。它用于放置模块接口需要但不想暴露给使用者的辅助函数、类型定义等。
// MyModule.ixx export module MyModule; export class PublicClass { public: void publicMethod(); private: struct ImplDetail; // 前向声明可以放在导出部分 ImplDetail* pImpl; }; // 私有模块片段开始 module :private; #include <internal_helpers.h> struct PublicClass::ImplDetail { // 详细的实现细节,外部不可见 int hiddenData; }; void PublicClass::publicMethod() { /* 实现,可以访问ImplDetail */ } // 私有模块片段结束私有模块片段中的内容不会被导出到.ifc。它是将接口与实现分离在同一个文件内的优雅方式,特别适合小型模块或工具类,避免了为简单的类单独创建.cpp实现文件。
在MSVC中,正确使用这两个片段,能有效管理依赖和封装,是编写高质量模块的关键技巧。
4. MSVC编译器实战:从零构建一个模块化项目
理论说再多,不如动手做一遍。让我们在Visual Studio 2022中,从头创建一个使用模块的中小型项目。
4.1 环境准备与项目配置
- 安装与版本:确保你安装的是Visual Studio 2022版本17.5或更高。在安装时,勾选“使用C++的桌面开发”工作负载,并确保包含最新的MSVC工具集。
- 创建新项目:创建一个新的“控制台应用”项目,命名为
ModuleDemo。 - 项目属性配置:
- 常规 > C++语言标准:设置为“ISO C++20 标准”或“预览 - ...最新标准”。
- C/C++ > 常规 > 扫描源以查找模块依赖项:设置为“是”。这是最关键的一步。它启用MSBuild的模块依赖关系扫描器,自动推断
.ifc文件的生成和消费关系。 - C/C++ > 高级 > 编译为:对于模块接口文件(
.ixx),需要在此处选择“编译为C++模块代码 (/interface)”。我们更推荐在文件级别设置。
4.2 创建你的第一个模块
- 添加模块接口文件:在解决方案资源管理器中,右键点击项目 -> 添加 -> 新建项。选择“C++ 文件(.cpp)”,但将名称改为
Math.ixx。点击“添加”。 - 编写模块接口:在
Math.ixx中写入以下代码:// Math.ixx - 模块接口单元 export module Math; export namespace math { // 导出函数 export int add(int a, int b); export double multiply(double a, double b); // 导出类 export class Calculator { public: Calculator(double initValue); double getValue() const; void add(double x); void multiply(double x); private: double value_; }; // 导出常量 export constexpr double pi = 3.141592653589793; } - 设置文件属性:右键点击
Math.ixx-> 属性。在“常规 > 项类型”中,确保它是“C/C++ 编译器”。在“C/C++ > 高级 > 编译为”中,选择“编译为C++模块代码 (/interface)”。这一步告诉MSVC将此文件作为模块接口编译并生成Math.ifc。 - 添加模块实现文件:添加一个新的C++文件,命名为
MathImpl.cpp。
注意,实现文件的开头是// MathImpl.cpp - 模块实现单元 module Math; // 注意:没有 export 关键字 namespace math { int add(int a, int b) { return a + b; } double multiply(double a, double b) { return a * b; } Calculator::Calculator(double initValue) : value_(initValue) {} double Calculator::getValue() const { return value_; } void Calculator::add(double x) { value_ += x; } void Calculator::multiply(double x) { value_ *= x; } }module Math;,它自动“继承”了Math模块接口中的所有声明,因此我们可以直接实现math::add等函数,而无需任何#include或前向声明。
4.3 在主程序中消费模块
修改自动生成的ModuleDemo.cpp(或main.cpp):
// ModuleDemo.cpp - 主程序,消费模块 import std; // 导入C++标准库模块(VS2022 17.5+) import Math; // 导入我们自定义的Math模块 int main() { using namespace std; cout << "Math Demo\n"; cout << "math::add(10, 20) = " << math::add(10, 20) << endl; cout << "math::pi = " << math::pi << endl; math::Calculator calc(5.0); calc.add(3.0); calc.multiply(2.0); cout << "Calculator value: " << calc.getValue() << endl; // 应输出 16 return 0; }4.4 编译与构建过程解析
现在点击“生成解决方案”。MSBuild会执行以下关键步骤:
- 依赖扫描:由于我们开启了“扫描源以查找模块依赖项”,MSBuild会分析所有源文件。它发现
Math.ixx是一个模块接口(通过/interface设置),而ModuleDemo.cpp中包含import Math;。 - 构建顺序确定:MSBuild自动推断出必须先编译
Math.ixx以生成Math.ifc,然后才能编译依赖它的ModuleDemo.cpp和MathImpl.cpp。你无需手动管理这个顺序。 - 编译接口单元:编译器
cl.exe以/interface模式编译Math.ixx,生成Math.ifc(通常位于中间输出目录如x64\Debug\)和对象文件Math.obj。 - 编译消费单元:编译器编译
ModuleDemo.cpp和MathImpl.cpp时,会通过/reference选项(由MSBuild自动管理)指向Math.ifc文件,从而解析import Math;语句。 - 链接:将所有生成的
.obj文件链接成最终的可执行文件。
如果一切顺利,你将成功运行程序。这个过程中最值得称道的是依赖管理的自动化。在传统的头文件项目中,我们通过包含目录(/I)来管理#include路径。在模块世界中,MSBuild通过扫描import语句和.ifc文件,自动建立了模块间的编译依赖图。
5. 高级主题:模块分区、与头文件混用及性能调优
掌握了基础之后,我们来探讨一些更复杂的场景,这些是你在实际项目中很可能遇到的。
5.1 使用模块分区管理复杂模块
当单个模块变得庞大时,我们可以将其拆分为多个分区。假设我们的Math模块扩展了统计功能,我们将其放入一个独立的分区。
- 创建统计分区接口:添加
Math-Statistics.ixx。
同样,需要设置此文件的“编译为”属性为“C++模块代码”。// Math-Statistics.ixx - 分区接口单元 export module Math:Statistics; // 注意语法 `ModuleName:PartitionName` export namespace math { export double mean(const double* data, size_t count); export double variance(const double* data, size_t count); } - 创建统计分区实现:添加
Math-StatisticsImpl.cpp。// Math-StatisticsImpl.cpp - 分区实现单元 module Math:Statistics; // 实现单元声明分区 #include <numeric> #include <cmath> namespace math { double mean(const double* data, size_t count) { if (count == 0) return 0.0; double sum = std::accumulate(data, data + count, 0.0); return sum / count; } double variance(const double* data, size_t count) { if (count <= 1) return 0.0; double m = mean(data, count); double sq_sum = 0.0; for (size_t i = 0; i < count; ++i) { double diff = data[i] - m; sq_sum += diff * diff; } return sq_sum / (count - 1); // 样本方差 } } - 在主接口中聚合分区:修改最初的
Math.ixx。
关键语句是// Math.ixx - 主接口单元(更新后) export module Math; // 导出主分区自己的内容 export namespace math { export int add(int a, int b); export double multiply(double a, double b); export class Calculator { ... }; // 省略细节 export constexpr double pi = 3.141592653589793; } // 导入并重新导出统计分区 export import :Statistics;export import :Statistics;。这意味着:1) 主接口单元本身依赖于:Statistics分区;2) 将:Statistics分区导出的所有内容,作为Math模块的一部分再次导出。 - 消费分区:现在,用户只需
import Math;,就可以同时使用基础数学函数和统计函数,因为主接口已经聚合了它们。import Math; int main() { double data[] = {1.0, 2.0, 3.0, 4.0, 5.0}; double m = math::mean(data, 5); double v = math::variance(data, 5); // ... }
分区的好处:
- 逻辑分离:将不同功能的代码组织到不同文件中,提高可维护性。
- 编译并行:不同的分区接口单元可以并行编译。
- 增量构建:修改一个分区的实现,只需要重新编译该分区的实现单元和主接口单元,而不影响其他分区的消费单元。
5.2 模块与头文件的混合模式
在迁移现有项目或使用第三方库时,混合使用模块和头文件是必然的。规则如下:
- 模块中可以包含头文件:在全局模块片段或私有模块片段中使用
#include。这是引入非模块化代码的主要方式。 - 头文件中可以导入模块:这是将模块引入到头文件世界的桥梁。但需要小心处理。
这种条件编译虽然可行,但会让代码变得复杂。更好的策略是,让头文件仅包含声明,并将模块导入的责任交给包含该头文件的// LegacyHeader.h #pragma once #ifdef USE_MODULES import MyModernModule; #else // 传统的头文件包含/前向声明 class MyModernModule_SimulatedType; #endif void legacyFunction(#ifdef USE_MODULES MyModernModule::SomeType #else MyModernModule_SimulatedType #endif param);.cpp文件或模块。 .cpp文件可以同时#include和import:这是最常见的混合场景。编译器会先处理所有的#include(在全局模块片段或模块单元开头),然后再处理import。
实操心得:在混合项目中,一个有效的策略是“由外向内”迁移。先为最底层、依赖最少的库创建模块接口。然后让依赖它的上层代码(无论是模块还是传统
.cpp)通过import来使用。逐步将头文件替换为模块导入。避免在一个大型项目的中心头文件中尝试条件导入模块,那会带来巨大的重构风险。
5.3 性能分析与编译缓存
模块号称能提升编译速度,但前提是使用得当。在MSVC中,你可以通过以下方式验证和优化:
- 使用
/time编译选项:在项目属性 -> C/C++ -> 命令行 -> 附加选项中添加/time。编译器会在输出中显示每个翻译单元的编译时间。对比使用模块前后,特别是增量编译的时间。 - 理解编译缓存:MSVC的模块编译缓存与预编译头(PCH)有些类似,但更精确。
.ifc文件就是缓存的产物。确保你的构建系统(如CMake)能正确地将.ifc文件视为一等公民的构建产物,并在清洁构建后能正确重新生成。 - 增量构建场景:
- 修改模块接口(
.ixx):会导致.ifc文件改变,所有直接或间接导入该模块的翻译单元都需要重新编译。这是影响范围最大的改动。 - 修改模块实现(
.cpp):只会重新编译该实现单元和链接步骤,所有导入该模块的消费单元不需要重新编译。这是模块带来的最大优势之一,实现了真正的接口与实现分离。 - 修改仅被模块实现使用的头文件(在全局模块片段中
#include):如果该头文件的内容没有影响模块接口(即未出现在export声明中),则通常只需要重新编译该模块的实现单元。编译器需要能正确分析这种依赖。
- 修改模块接口(
- 并行编译:模块接口单元之间如果没有依赖,可以并行编译。MSBuild和CMake(Ninja生成器)都能利用这一点。确保你的项目结构允许这种并行性。
6. 常见问题排查与调试技巧实录
在实际使用MSVC模块时,你肯定会遇到各种错误和警告。以下是我踩过的一些坑及其解决方案。
6.1 编译错误与链接错误
| 错误信息/现象 | 可能原因 | 解决方案 |
|---|---|---|
| C7612: 无法找到模块“X”的模块接口 | 1. 模块接口文件(.ixx)未被编译,或编译失败未生成.ifc。2. 消费单元( import X;)的编译顺序早于模块接口单元。3. .ifc文件路径未被编译器找到。 | 1. 检查.ixx文件的“编译为”属性是否设置为“C++模块代码”。确保它能成功编译并生成.ifc。2. 确保项目启用了“扫描源以查找模块依赖项”,MSBuild会自动排序。 3. 检查中间输出目录,确认 .ifc文件存在。清理解决方案并重新生成。 |
| LNKxxxx: 无法解析的外部符号 | 模块接口中export了函数或类成员函数的声明,但在任何模块实现单元中没有提供定义。 | 1. 检查是否遗漏了对应的模块实现文件(.cpp)。2. 检查实现文件的开头是否是 module X;(没有export)。3. 确保函数签名(包括命名空间、参数类型、常量性)在接口和实现中完全一致。 |
| 模块分区相关错误 | 1. 分区接口单元未在主接口中用export import :Partition;导出。2. 分区实现单元错误地使用了 export。3. 试图从模块外部直接 import Module:Partition;。 | 1. 分区必须通过主接口聚合后才能被外部使用。 2. 分区实现单元( .cpp)应以module Module:Partition;开头,绝不能有export。3. 外部代码只能 import主模块名(如Math),不能直接导入分区。 |
| IntelliSense报错但编译通过 | Visual Studio的IntelliSense引擎对C++模块的支持可能滞后于编译器,或者.ifc文件索引未及时更新。 | 1. 尝试“编辑 -> IntelliSense -> 重新扫描解决方案”。 2. 关闭解决方案,删除 .vs隐藏文件夹和所有.ifc文件,重新打开。3. 这是一个已知问题,通常不影响实际编译,等待IDE更新。 |
6.2 构建系统集成问题(CMake特别篇)
如果你使用CMake,配置模块需要更多手动干预。CMake 3.28及更高版本对C++模块有更好的原生支持,但以下步骤在早期版本中也是一种可行方案。
cmake_minimum_required(VERSION 3.26) # 需要足够高的版本以支持部分模块特性 project(ModuleDemo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 关键:告知MSVC扫描模块依赖 if(MSVC) add_compile_options(/experimental:module) # 较新版本可能不需要 # 更推荐使用源文件属性,但全局设置有时是必要的 endif() # 添加模块接口单元 add_library(Math) target_sources(Math PUBLIC FILE_SET CXX_MODULES BASE_DIRS ${CMAKE_CURRENT_SOURCE_DIR} FILES Math.ixx ) # 对于MSVC,需要为.ixx文件设置特殊属性 set_source_files_properties(Math.ixx PROPERTIES CXX_SCAN_FOR_MODULES ON # 让CMake知道这是模块接口 # MSVC特定:设置编译为模块 COMPILE_OPTIONS "/interface" ) # 添加模块实现单元和分区文件 target_sources(Math PRIVATE MathImpl.cpp Math-Statistics.ixx Math-StatisticsImpl.cpp) # 注意:分区接口文件也需要设置属性 set_source_files_properties(Math-Statistics.ixx PROPERTIES CXX_SCAN_FOR_MODULES ON COMPILE_OPTIONS "/interface" ) # 创建主程序 add_executable(ModuleDemo ModuleDemo.cpp) target_link_libraries(ModuleDemo PRIVATE Math)CMake常见坑点:
- 依赖扫描:确保为模块接口文件设置了
CXX_SCAN_FOR_MODULES ON。这能帮助CMake生成正确的构建依赖(Ninja生成器效果较好)。 .ifc文件输出目录:MSVC默认将.ifc生成在中间目录(如CMakeFiles/...)。有时链接器或消费单元编译器可能找不到它。确保target_include_directories不干扰模块搜索路径。在复杂项目中,可能需要手动指定/reference选项,但这很繁琐。- 清洁构建:CMake的清洁构建(删除build目录)有时无法彻底清理模块状态。如果遇到奇怪的错误,尝试完全删除build目录并重新配置。
6.3 调试与 introspection
- 查看生成的
.ifc文件依赖:使用cl /sourceDependencies选项,可以生成一个JSON文件,清晰展示每个源文件依赖哪些模块(消费哪些.ifc),以及每个模块接口单元生成哪个.ifc。这对于调试复杂的模块间依赖至关重要。 - 使用
/d1reportTime:这个编译器选项可以输出每个编译阶段的耗时,帮助你分析模块编译是否真的带来了加速,或者瓶颈在哪里。 - 预编译头(PCH)与模块:模块和预编译头是互斥的。一个翻译单元不能同时使用PCH和
import模块。对于大型项目,你需要做出选择:是继续使用PCH来加速大量传统头文件的编译,还是逐步迁移到模块以获得更精细的增量编译收益。通常,新项目或新模块建议直接使用模块,而大型遗留项目可以采取部分模块化的策略,在模块边界外继续使用PCH。