1. 事情的起因:Modules 代码在编辑器里全面失灵
大概一年前,我开始用 C++20 Modules 重写一个内部的算法库。当时被各种宣传打动:模块化编译可以显著缩短构建时间、告别头文件顺序依赖、把接口和实现之间的逻辑边界变成真正语法层面的边界。最初几个星期确实很爽,export module calc;一写,import calc;一引,编译速度肉眼可见地提升,构建系统也比以前干净了许多。
但很快我遇到了一个让整个开发体验倒退十年的问题:代码编辑器彻底"不认识"我的项目了。我用的是 VS Code + clangd 组合,对传统 C++ 项目来说这套组合基本是无敌的,跳转、补全、引用查找都非常准。可一旦源文件顶部出现export module或import xxx;,编辑器就开始胡言乱语。定义跳转直接失效,补全基本靠猜,semantic highlight消失了,甚至偶尔还会崩掉几个语言服务器插件。
那段日子我几乎是被迫回到了"看系统日志找错字"的状态。更麻烦的是,这跟写不写注释、项目组织得好不好完全无关,是工具链在语义层面已经跟不上了。于是我做了一个比较冲动的决定:自己写一个支持 C++ Modules 的 language server。这个项目断断续续做了大半年,期间踩了无数坑,也把 Modules 在编译语义层面对编辑器的要求摸了个遍。这篇文章想把整个设计思路和踩坑过程完整记录下来,对于想做类似工具、或者正在用 Modules 但嫌弃编辑器不给力的朋友,应该会有不小帮助。
先说清楚一件事:我做的不是一个"能跑就行"的玩具,目标很明确——在编辑器中实现这四种能力:跨模块的符号跳转、模块范围内准确的补全、支持模块可见性语义的引用查找、以及基于模块依赖图的增量式索引更新。后面所有的设计都是围绕这四点展开的。
2. 模块语义为什么能让现有语言工具链集体翻车
2.1 从"文本与预处理"到"语义与编译产物"的范式迁移
传统 C++ 的编辑体验建立在一条很古老的地基上:#include本质上是文本拼接。一个.h文件被 include 多少次,它的内容就会以文本形式被复制多少份。语言服务器之所以能对传统代码做到近乎完美的补全和跳转,正是因为它可以模拟这条文本拼接链路——把 include 展开、把宏替换掉、把预处理后的 token 流塞给解析器,拿到的是一个"完整翻译单元"的 AST。对这个 AST 做分析,就能回答编辑器的大部分问题。
C++ Modules 把这条链路彻底改掉了。import foo;不是文本导入,而是语义导入。编译器不会把foo的源码内容粘贴到当前位置,而是要求预先存在一个已经编译好的模块语义缓存——Clang 里叫.pcm,GCC 的模块缓存体系叫 GCM,MSVC 则生成.ifc。语言服务器如果还想像以前那样靠"解析文本"来理解代码,它就必须自己把整个项目的模块依赖图找出来、给每个模块接口文件生成对应的语义缓存,然后在这个缓存上做查询。这一步直接就把大多数语言服务器挡在了门外。
现在很多人提到 C++20 Modules 还是停留在"新语法"的层面,实际上它真正颠覆的是工具链的架构分工:**编译器不再是"读进文本、吐出目标文件"的简单管道,语义在编译过程中会先被物化成一个中间产物,之后的翻译单元直接消费这个产物。**语言服务器想高效工作,就必须成为这个语义产物的生产者和消费者,而不再只是文本解析器。
2.2 Visible 和 Reachable:语言服务器必须理解的两套可见性
模块引入了一堆传统#include完全没有的概念,其中对编辑器影响最大的是 visible(可见)和 reachable(可达)的区分。
简单说,当你import a;时,模块a中用export修饰的实体对你是可见(visible)的,可以直接用名字调用;但模块a中没有导出的实体,只是"可达"(reachable)而非可见。所谓可达,指的是它们在模板实例化等编译底层机制中仍然参与名字查找,但你如果直接在代码里写这些未导出名字,编译器会报错。这个设计对实现者有个很微妙的好处:内部实现细节不会污染外部接口,但又不会破坏模板特化和重载决议需要的"副作用"。
这个区分对语言服务器来说简直是灾难。做"查找所有引用"功能时,如果一个符号属于某个模块的未导出部分,它在模块外部只能以"可达"的方式影响代码,而不能被显式引用。到底该不该把那些模板特化中的使用算作"引用"?如果算,跳过去的代码读者根本看不懂;如果不算,又可能漏掉真正优化模板特化逻辑时需要的依据。最终我选择的是:对可见实体,统计所有真实引用;对不可见实体,只统计模块内部引用以及模板实例化上下文中的隐式使用,并在 UI 上做明确标识。
2.3 模块分区和重新导出带来的"寻名"复杂度
模块的另一个核心语法是 partition(分区)和 re-export(重新导出)。一个模块可以用export module mylib:math;声明数学分区,然后在主模块接口里写export import :math;把分区重新导出。此外还有export import other_module;这样把别人引入再导出的写法。这套机制导致一个符号的真实所属模块和用户看到的模块名可以完全不一致:用户写的是import mylib;,但符号定义在mylib:math分区里,甚至可能在other_module里被mylib间接带出来。
任何想支持 Modules 的语言服务器都必须维护一张"模块名到物理文件"以及"模块分区名到模块接口文件"的映射表,并在每一个编译单元里跟踪整个导入闭包。做跳转时,要从当前符号往模块图里回溯,找到真正定义它的那个模块接口文件;做补全时,要把当前模块的所有导出实体、被 re-export 的实体、以及可达模块中的可发现实体都合并进候选集。细节数量级比之前 include 时代膨胀了至少一个维度。
3. 具体实现:怎么让 Language Server 真正"理解"模块
3.1 不自己造编译器前端,而是做一个"编译器前端的客户端"
我最早犯过的错是试图自己解析模块声明、索引模块结构。只要稍微深入一点就会发现,Modules 的语义远比 AST 长得复杂——判断一个名字是否可见,依赖模块的导出声明集合;判断一个模板实例化是否会依赖某个不可见实体,需要模拟编译器的查找规则。这些逻辑如果自己实现,等于重新写一个 C++ 编译器前端。
所以项目从第二周开始就被我重构为:**直接复用真实编译器前端来生成语义缓存,语言服务器自己只负责管理依赖图、缓存生命周期、以及把语义缓存里的数据翻译成 LSP 响应。**我选择了 Clang 作为前端,因为 Clang 的 libclang 和 Clangd 基础设施相对更容易嵌入到自定义工具里,而且.pcm模块缓存可以直接通过编译命令生成。具体流程如下:
- 读取项目的
compile_commands.json,找到所有编译单元和它们的编译参数。 - 扫描其中用于模块接口编译的命令(
-x c++-module或等价参数),提取模块名。 - 为每个模块接口文件执行一次"预编译"操作,生成
.pcm语义缓存,同时记录缓存与源文件、编译选项的对应关系。 - 用户在编辑器中打开或修改文件时,根据 LSP 请求决定是直接用当前文件近似的 AST 快速响应,还是先确保相关
.pcm缓存是最新版本,再返回语义级别的结果。
这套架构最大的好处是,语义正确性完全由 Clang 保证,我不需要去猜某个符号到底可见不可见。坏处也有,就是每次模块接口变动后,缓存重建可能需要数百毫秒甚至更久,必须仔细设计后台任务优先级和缓存失效策略。
# 典型的模块接口编译命令(Clang 前端) clang++ -std=c++20 -x c++-module --precompile calc.cppm -o calc.pcm # 使用该模块的普通翻译单元 clang++ -std=c++20 -fmodule-file=calc.pcm main.cpp -c -o main.o3.2 模块依赖图是语言服务器的主干数据结构
我实现里最重要的数据结构不是 AST,也不是符号表,而是一张模块依赖图(Module Dependency Graph)。图中每个节点对应一个模块接口文件或模块分区,每条边表示一个import依赖关系。整个项目的所有语义缓存都挂在这张图上,节点版本号(每个.pcm内容哈希)一旦变化,依赖它的下游节点版本全部失效。
为什么必须靠这个图而不是传统的"文件时间戳"?因为一个模块接口文件即使在文本层面没变,但如果它依赖的另一个模块变了,它的语义缓存其实也已经过期了。举个例子:calc.cppm写了import helper;,helper.cppm里改了一个函数模板的实现并重新编译生成新的helper.pcm。此时calc.cppm虽然内容一个字没改,但它的.pcm语义缓存里已经蕴含了旧helper.pcm的信息,必须重建。这种传递失效如果不靠依赖图,只靠 mtime 判断,一定会漏掉大量更新,然后编辑器里就会出现各种"改了代码但补全还是旧的"诡异现象。
我在实际实现里给每个缓存记录两个内容:源文件的SHA256哈希,以及所有直接依赖模块的缓存版本号。计算出新哈希后,先跟源文件比较,再沿依赖图逐层比较。只要有任何一层的版本号不一致,就从该节点往下重算,并同时更新所有下游节点。这套机制跟构建系统里的增量编译逻辑很像,区别在于它专门服务于编辑器的语义查询。
3.3 LSP 层面对模块语义的"翻译"
标准 LSP 本身并不认识模块,它只有textDocument/definition、textDocument/completion、textDocument/references这些通用能力。所以我需要在标准协议的基础上做一层"语义翻译"。
比如补全:传统 C++ 的补全可以基于当前文件作用域 + include 头文件的类型声明来暴力收集候选符号。但模块化代码的候选符号必须遵循可见性规则。我的实现里,补全请求会先定位当前文件所属的模块节点,然后在节点对应的模块语义缓存里展开完整的导出实体集合,再叠加 import 进来的其他模块的可见实体。对于每个候选,还会额外返回一个"到达路径"——比如mylib:math分区里的sin,到达路径是mylib -> mylib:math的 re-export 链。
再比如跳转定义:当用户对一个从模块导入的符号请求定义时,我会先查这个符号属于哪个模块分区,再找到那个分区的模块接口文件,定位到其源码位置。如果符号是export import二次引入的,这个定位过程还要跨层追溯。听起来不复杂,但真正写到实现里,每一步都要处理大量边界情况:符号是模板特化吗?是依赖模板参数的隐式实例化吗?模块文件有增量编译导致的源码区间偏移吗?这些细节我用一个专门的ModuleLocator组件统一处理,避免把逻辑散落在各种 handler 里。
4. 踩坑记录:四个几乎劝退我的难题
4.1 BMI 缓存的间接失效:文字没变,语义已旧
前面提到了缓存失效策略,但在现实的编辑器场景里这个问题比理论上更恶毒。我记得有一次我在constants.cppm里改了一个常量值,这个模块被十几个下游模块直接或间接引用。改动后,VS Code 里的大多数补全都更新了,唯独某个深层的函数模板补全结果还是旧的。原因是那个函数模板所在的模块并不直接 importconstants,而是通过另一个中间模块间接依赖的。我的早期版本只检查了直接的导入关系,导致中间模块的缓存版本没有传递刷新,深层模块的语义索引就会继续引用旧缓存。
解法其实也简单:每次缓存重建完成后,除了更新节点自身,还要立刻把所有下游可达节点的版本号标记为 stale,让编辑器在下一个请求到来前优先重建。这一步说起来轻松,但它要求语言服务器必须真正理解整张模块依赖图的拓扑排序,而不能像 clangd 处理传统 include 那样"按文件粒度"去碰运气。
另外一个隐藏点:.pcm文件跟编译器参数强关联。如果某一天用户改了编译标准(比如从-std=c++20改成-std=c++23),前面生成的.pcm全部作废,但编译命令文件的 mtime 可能没有变化。我把编译器主要选项也纳入缓存 key 的哈希计算,才彻底解决这个问题。
4.2 标准模块的适配:import std;在不同编译器的"同词不同义"
C++23 开始有import std;这种标准库模块的一键导入语法,但在 2024 年的现实世界,不同编译器对它的支持程度和底层映射完全不同。Clang 需要额外的标准库模块映射文件,GCC 需要-fmodules-ts加上实验性的头文件单元扫描,MSVC 则是把.ifc打包在安装目录里。同一个import std;在不同工具链下面是完全不同的产物,这给语言服务器的通用性带来了巨大麻烦。
我给项目做了一层"标准模块适配层":针对每种编译器前端,单独配置一份标准模块清单,核心逻辑判断当前compile_commands.json里的编译器类型,再决定去哪个路径下查找标准模块的语义缓存。如果找得到,就把这些缓存登记进模块依赖图的根部;如果找不到,我会向 LSP 客户端返回自定义的诊断消息,提示用户先配置标准模块位置,而不是让编辑器静默崩溃。
这层适配让我意识到一个问题:C++ Modules 的标准化还远未完成,工具链层面的割裂比标准文档里写得更痛。语言服务器如果想真正面向普通用户,这个适配层就不能是临时补丁,而必须是一等公民。
4.3 编辑器交互的延迟:构建语义缓存不能阻塞 UI
最初几版我把textDocument/completion请求直接挂在模块缓存生成后面,结果就是每次补全都要等一两秒。在小型测试项目里还能接受,但到了几百个模块的真实项目里,这个延迟完全没法用。后来我改成异步流水线:
- 用户输入时,先立刻用一个快速的词法级别的近似索引返回一部分候选符号,保证编辑器不会空白。
- 同时,把当前文件关联的模块节点标记为 pending,触发后台线程重建它以及所有下游依赖的语义缓存。
- 重建完成后,再主动向 LSP 客户端推送一次"semantic tokens 刷新",让补全结果自动更新。
这套机制的难点在于管理后台任务的优先级。我设了一个简单的调度策略:当前编辑文件所在模块节点的重建优先级最高,直接依赖它的节点第二,其余按依赖图深度依次降低。由于模块图天然是 DAG,这个调度可以用拓扑排序轻松实现。实测下来,编辑器首屏补全响应可以压到 100 毫秒以内,完整语义补全则能在 0.5 到 1 秒内"追"上来。
4.4 和构建系统的集成:CMake 的 P1689 扫描是个老坑
这个语言服务器的最后一块拼图是让用户不需要手动配置模块映射。因为目标群体基本都用 CMake,我最初直接读取compile_commands.json来推断模块关系。但 CMake 在开启CXX_MODULE_UNITS特性的项目里,编译命令本身并不直接包含模块到.pcm的映射关系,而是依赖 CMake 生成的 P1689 扫描文件(.p1689.json)来告知构建系统哪个文件是哪个模块。构建时编译器会看着这份扫描文件来决定模块编译顺序。
我在集成这个文件格式时被一个细节折磨了很久:早期某些 CMake 版本生成的 P1689 文件格式跟后来的 4.x 版本略有差异,字段从粗分类改成更细的类型。特别是当我试图把一个老项目的compile_commands.json跟新生成的 P1689 合并时,经常触发类似网上经常看到的/usr/share/cmake-4.2/modules/CMakeDetermineCompilerID.cmake:9那一串报错——本质上就是"编译器探测阶段没找到可用的 C++ 编译器路径,而后续的模块扫描又依赖这个探测结果"。排查到最后,发现不是我的语言服务器逻辑有问题,而是 CMake 版本升级后在模块编译器探测顺序上发生了变化。解决办法是在构建系统集成代码里加了一层兼容逻辑:P1689 文件缺失时回退到直接扫描源文件一级的export module声明,同时把模块名映射结果输出成一份简易映射文件,给下游语义索引使用。这样即便构建系统配置不对,语言服务器也不会完全停摆。
5. 实测表现:除补全和跳转之外,真正受益的场景
5.1 测试项目的性能数字
我拿这个语言服务器跑了两个测试项目。第一个是内部算法库,包含 46 个模块接口文件、120 多个普通翻译单元,总代码量约 8 万行;第二个是一个用import std;写的小型演示项目,只有 10 个模块,但依赖了标准库模块和两个第三方模块。在本地这台 Linux 工作站上,冷启动(清空全部语义缓存后首次打开项目)完成全量模块图构建大约 5.4 秒,之后每次打开文件的语义索引命中时间小于 150 毫秒,textDocument/definition的平均响应在 40 毫秒左右。把编辑器从 clangd 切到我的 server 前,相同项目里 clangd 在传统 include 场景下这些指标大约是 20 到 60 毫秒,差距已经缩小到了模块化代码可以接受的程度。
但更有意义的指标是崩溃率。之前用 clangd 打开模块项目,几乎每半小时就会触发一次客户端里的断言错误,要么是索引过期,要么是补全结果列表里混入非法符号。换了新 server 的测试周期里,我只遇到过一次崩溃,还被我复现出来定位到是 P1689 文件解析的越界 bug,属于我自身代码的问题,而不是模块语义导致的系统性错误。
5.2 跨模块"安全重构"终于变得现实
传统重命名在 include 时代其实很脆弱:你执行一个符号的全局重命名,工具会把所有出现该符号的文本位置全改掉——包括刚刚好同名的局部变量、模板参数甚至注释。模块化代码反而给了工具一个救命优势:每个符号都有明确的模块归属和可见性边界。
我的 server 在重命名时会把"候选引用集合"限制在模块语义的合法可见范围内。比如某个符号只在模块mylib:internal里可见,那么重命名时绝不会去改动模块外部那些碰巧同名的局部符号。这个能力在传统 C++ 工具里几乎不可用,但在模块化代码里可以做到非常精确。我用测试项目做了一次ExternalAPI类名的跨模块重命名,总共 23 处引用,工具指示的结果全部命中,无一误伤。
5.3 对用户排查模块依赖循环的实际帮助
Modules 对依赖循环的约束比 include 严格得多,但因为模块图可以很复杂,用户经常在 import 链的某个暗处绕了一圈自己都没发现。我的语言服务器在生成依赖图时顺便做了环检测,一旦发现依赖环路,就会通过自定义 LSP diagnostics 给用户返回一条"环路链"提示,比如mylib -> mylib:math -> mylib:utils -> mylib。这个功能后来被很多测试用户反馈为"最意外的实用功能",因为编译器通常会在一堆底层报错之后才暴露循环,而语言服务器能在编辑阶段就把这个信息干净地呈现出来。
做一个支持 C++ Modules 的 language server,最核心的认知转变是:**它本质上不是"文本编辑器插件",而是编译器语义缓存的管理系统。**那些漂亮的补全和跳转,只是把编译过程中早已生成的语义信息拿出来做可视化而已。这个项目让我重新理解了 C++ 工具链的演变方向——当语言本身开始把语义物化成编译产物,编辑器和编译器之间的边界就注定要被重塑。希望这篇记录能给你一些启发,如果你也在设计类似的工具,至少知道那些"看起来简单、做起来崩溃"的坑都在哪里。