Skip to content

编译上下文

背景

C++ 的 #include 是一种很原始的代码复用机制——本质上就是文本替换。与现代语言普遍采用的模块系统不同,它没有隔离性:一个头文件被包含时看到的内容,完全取决于包含它之前的预处理器状态。这意味着同一个文件在不同条件下可能产生完全不同的编译结果,也使编译器的缓存优化变得更加困难。

源文件会因编译命令不同(Debug/Release、不同平台)而产生不同的结果;头文件则会根据由哪个源文件包含,以及包含位置之前有哪些 #define#include 指令,展开出完全不同的内容。

当用户在编辑器中打开文件时,语言服务器必须为其选择合适的编译上下文——诊断、代码补全和跳转到定义都依赖这个上下文。现有的语言服务器(不只是 C++ 语言服务器)都没有正式的编译上下文概念——它们会隐式选择上下文,却不允许用户感知或控制这一选择。上下文错误时,编译会产生错误,用户也会看到大量误报诊断。clice 是第一个正式引入编译上下文概念的语言服务器。

以 clangd 为例,缺乏编译上下文概念导致社区中长期存在以下问题:

  • 不支持非自包含头文件。这是 clangd 社区最早报告且至今仍未解决的问题之一(clangd #45)。由于 clangd 没有头文件上下文的概念,无法为非自包含头文件还原正确的预处理器状态,用户打开这类文件时会看到大量无意义的错误。

  • 猜测头文件的编译命令。clangd 为头文件选择编译命令时,主要依赖路径启发式方法——根据文件名和目录结构,从编译数据库(CDB)中猜测“最相关”的源文件,再借用该源文件的编译命令。这种猜测并不可靠,经常选错文件,从而产生误报诊断(clangd #1505)。

  • 多配置项目中无法选择上下文。当一个文件在 compile_commands.json 中出现多次时(Debug/Release、不同平台),clangd 只会选择其中一条记录,用户无法控制具体选择哪一条(clangd #681)。

clice 的设计从一开始就纳入了编译上下文框架——这一概念自然地融入整个系统。用户不仅能看到当前生效的编译上下文,还能查询可用的上下文并在它们之间切换。

设计

编译上下文是完整还原文件编译状态所需的全部条件。其含义因文件类型而异。

对于源文件,编译上下文就是它的编译命令。一个源文件在 CDB 中可能对应多条编译命令——这在多配置项目中很常见。每条命令代表不同的编译上下文,选择哪一条决定了该文件在语言服务器中的行为。例如,同一个文件在 Debug 和 Release 下可能生成不同的代码:

cpp
void initialize() {
    auto* conn = create_connection();
#ifndef NDEBUG
    conn->debug_label = "main";       // Only present in Debug builds
    log("connection created");
#endif
    conn->start();
}

在 Debug 构建(-O0,未定义 NDEBUG)和 Release 构建(-O2 -DNDEBUG)下,可见符号、诊断和代码补全建议都可能不同。

对于头文件,编译上下文分为两种情况。首先需要理解“自包含”的含义:如果一个头文件不依赖包含者提供的 #define#include 指令,自身就能独立编译,那么它就是自包含的。绝大多数现代 C++ 头文件都是自包含的——它们会自行 #include 所有依赖,不对包含者的状态作任何假设。

自包含头文件可以像源文件一样处理。但头文件通常不在 CDB 中(CDB 只记录源文件的编译命令),所以即使自包含头文件也需要通过依赖图找到一个包含它的源文件,借用该源文件的编译命令(搜索路径、语言标准等)。关键区别在于:自包含头文件不需要合成前缀代码——借用编译命令即可。

严格来说,“自包含”与其说是一种绝对属性,不如说是一种倾向。即使头文件能够独立编译,包含它的文件仍可通过 -D 选项或此前的 #define / #undef 指令改变其语义。但实际中很少这样做——设计为自包含的头文件不应依赖外部预处理器状态。

非自包含头文件的编译结果取决于包含它的源文件。经典例子是 X 宏:

cpp
// errors.def — not self-contained, requires the includer to define the X macro
X(Ok,           0, "success")
X(NotFound,     1, "not found")
X(InvalidArg,   2, "invalid argument")
X(Timeout,      3, "operation timed out")

不同的源文件包含同一个 errors.def,各自定义不同的 X 宏,展开为完全不同的代码:

cpp
// error_enum.cpp — generates an enum
#define X(name, code, msg) name = code,
enum ErrorCode {
#include "errors.def"
};
#undef X

// error_message.cpp — generates a string mapping
#define X(name, code, msg) case code: return msg;
const char* error_message(int code) {
    switch(code) {
#include "errors.def"
    }
}
#undef X

errors.def 在两个源文件中展开为完全不同的 AST——一个是枚举定义,另一个是 switch 语句。对于这类非自包含头文件,编译上下文称为头文件上下文(Header Context),由两个要素确定:

  1. 宿主源文件:一个直接或间接包含该头文件的源文件,提供基础编译命令
  2. 包含位置:该头文件在宿主源文件的包含树中被包含的具体位置

包含位置很重要,因为同一个头文件可能在同一个源文件中被多次包含,而每次包含时的预处理器状态都不同(例如在 X 宏模式中,每次包含前都会定义不同的宏)。一个源文件中的所有包含位置可以展平成一个有序序列,并为每个位置分配唯一编号。宿主源文件与此编号共同唯一确定一个头文件上下文。

如何确定编译上下文

需要编译一个文件时,按以下优先级自动确定编译上下文:

  1. 用户选择优先。如果用户通过 clice/switchContext 主动选择了编译上下文,则使用该选择。选择会持久化到工作区缓存,并在后续会话中打开该文件时自动恢复。

  2. 直接查询 CDB。如果编译数据库中有该文件的条目,则使用该条目的编译命令。在多配置项目中,同一文件有多条命令时,默认使用第一条;用户可以通过命令哈希值切换到其他条目。

  3. 依赖图查找。如果文件不在 CDB 中(头文件的常见情况),则通过依赖图中的包含关系找到包含它的源文件,并借用该源文件的编译命令。候选宿主按相关性排序:与头文件的文件名主干相同的源文件(utils.h -> utils.cpp)优先,其次是同目录的源文件,然后按路径接近程度排序,最后以字典序打破平局,确保结果具有确定性。对于自包含头文件,借用编译命令即可;对于非自包含头文件,还必须合成前缀代码来还原预处理器状态——系统会自动判断属于哪种情况,见下一节。

  4. 规则的默认命令。既没有条目也没有宿主的文件,采用第一条声明了 default_command 的匹配规则所给出的命令——要描述所有文件共用同一套标志的项目,用的就是这种方式。

  5. 借来的命令。对于既没有 CDB 条目也没有宿主、也没有匹配的规则声明 default_command 的文件——新建的源文件,或还没有被任何文件包含的头文件——采用附近一个同语言的翻译单元的命令:优先取该文件所在目录中的翻译单元(文件名主干相同者优先),其次取头文件搜索目录(-I 等)包含该头文件的翻译单元,最后取路径最接近的翻译单元(参见推断)。命令来源标记为 Inferred,决策日志会写明出借方,关于文件缺失的诊断也会附带一条引导说明。借来的命令只服务于编辑器:该文件不是构建的成员,也不会进入后台索引。

  6. 内置回退。如果上述方式均未命中,孤立文件会使用合成的内置命令编译,从而让基本语言服务继续工作。

除了自动解析外,clice 还通过三个 LSP 扩展请求让用户可以显式地查询和切换编译上下文:

clice/queryContext 列出文件的所有可用编译上下文。对于头文件,它返回可作为宿主的源文件,并按相关性排序;对于源文件,它返回所有 CDB 条目,以编译命令中的关键标志(-D-std-O 等)作为易读描述,每个条目还携带用于标识它的命令哈希。结果支持分页。对于自包含头文件和源文件,上下文列表会按条目的**身份哈希(identity hash)**去重;该哈希根据最终生效命令中与前端相关的部分,以及输入的槽位位置和工作目录计算得出。身份哈希相同的两个上下文会产生相同的编译结果,因此只保留排名最高的代表项——否则,像 <vector> 这样被广泛包含的头文件可能会列出数千个上下文。非自包含头文件不会以这种方式合并:不同宿主会合成不同的前缀,因此每个宿主都是独立的上下文。如果同一宿主多次包含未设包含防护的头文件,则每个包含位置都会作为独立条目返回,并以出现序号(occurrence number)区分。

clice/currentContext 返回当前生效的用户选择(宿主 + 出现序号,或命令哈希)。如果用户没有主动切换过(即正在使用自动选择的默认上下文),则返回空。

clice/switchContext 切换到用户选择的新上下文。切换时会校验选择的合法性:宿主必须确实直接或间接包含该头文件,出现序号必须在有效范围内,命令哈希必须对应文件的真实 CDB 条目,否则请求失败。

除这些请求外,文件中预处理器非活跃的区域(未选中的 #if 分支体)还会通过语义 Token 呈现:在当前编译上下文下,其中的每个 Token 都带有 inactive 修饰符,编辑器会将这些区域变暗。切换上下文会触发重新编译,服务器随后请求刷新语义 Token,客户端重新拉取后,区域的明暗状态会随之切换——这是切换上下文最直观的视觉反馈。

切换构建配置

上下文按文件选择。与之对应、作用于整个工作区的是构建配置clice.toml 中的规则可以带上 configuration 标签(参见命令解析),不同的标签构成一份菜单,一个服务器进程中恰好有一个标签生效——带该标签的规则适用,不带标签的规则始终适用,文件编译时用到的一切都由此决定:查阅哪些数据库、采用哪些默认命令、施加哪些编辑。生效的配置在启动时确定一次,依次取自三层:

  1. 命令行上的 --configuration <tag>,它为本次会话固定配置;clice serveclice indexclice lintclice inspect 都接受该选项。
  2. 持久化的选择:缓存目录下与存储并列的 state.json。缓存布局升级会丢弃存储,但不会丢弃这个文件。
  3. 配置中的 default_configuration,没有则取第一个声明的标签。

前两层中若出现没有任何规则声明过的名称,会以引导性提示报告出来并跳过,选择文件保持原样——被改名的标签会回落到默认配置,直到用户重新选择。这是服务器的规则,因为编辑器必须总能启动;clice indexclice lintclice inspect 以脚本方式运行,遇到未声明的 --configuration 会直接失败,而不是替另一个配置报告成功。

菜单由两个扩展请求驱动。clice/listConfigurations 返回已声明的标签,以及生效的、已选择的和默认的名称。clice/switchConfiguration 校验名称并将其持久化为选择;运行中的服务器仍保持原有配置,该请求会告知客户端重启后选择才会生效。切换要求重启是刻意的设计:一个服务器进程持有一个配置的整个世界——它加载的数据库、依赖图、上下文、产物和索引——重新启动进入那个世界,正是每次会话本来就要做的冷启动,而且能用上该配置自己的缓存。在 VS Code 中,只要规则声明了标签,状态栏就会显示生效的配置;选择另一个配置会将其持久化并重启服务器,之后每个打开的文档都会走一遍常规的打开流程,其诊断、非活跃区域和语义 Token 随之切换。在 socket 模式下,重启会断开所有已连接的客户端。

每个配置在缓存存储下都拥有自己的索引库 index/<tag>~<hash>(没有标签的配置为 index/default):持久化的符号索引、构建时所依据的编译命令快照、上下文选择和产物记录。因此,切换绝不会把两个配置的数据混在一起,切回去时也不会重新索引:离开时的索引库会原样重新打开,随后照常做一次核对,只处理这期间磁盘上发生变化的部分。只有生效的配置会被索引。PCH 与 PCM 产物仍留在共享的内容寻址命名空间中,但其键包含配置名,因为为产物背书的依赖戳记保存在该配置的索引库里;头文件上下文文件只取决于内容,仍然共享。不再使用的配置所占的索引库不会被自动回收。

自包含性自动检测

绝大多数头文件都是自包含的,为它们合成前缀纯属浪费——这需要计算包含链、读取链上的每个文件,并引入大量前置依赖。因此,对于没有 CDB 条目的头文件,clice 采用先尝试、再回退的策略:

  1. 试编译:将头文件视为自包含,并借用宿主的编译命令直接编译它(不使用前缀)。
  2. 诊断评分:如果试编译的诊断命中一组经过严格筛选的“缺失上下文”信号——未知类型名、未声明的标识符、未终止的条件编译指令——则判定该头文件不是自包含的。这组判据刻意限定得很窄:误报只会导致一次无用的前缀合成,而把正常编辑过程中尚未完成的输入错误误判为缺失上下文,则会造成毫无意义的重新编译。
  3. 回退重编:得出判定后,使用合成的前缀重新编译头文件。试编译产生的错误诊断绝不会发布到编辑器;用户只会看到最终结果。

扩展名为 .def.inc 的文件(X macro 惯例)会跳过试编译,直接按非自包含处理。只有“需要上下文”的判定会持久化到工作区缓存——这是成本较高的一种情况,重启后不应再次试编译;“自包含”则仅是会话内的临时判断,重新验证无需额外成本(试编译就是常规编译)。重新评估的时机经过刻意区分:依赖变化(包含链上的文件被保存、该头文件所包含的文件发生变化、磁盘上的文件被外部修改)会重新触发试编译,而用户输入绝不会触发——否则,输入到一半的未知类型名会不断引发无意义的前缀合成。保存该头文件本身也会重置持久化的判定。

实践中的头文件上下文

头文件上下文在概念上很清楚(宿主源文件 + include 位置),但确定头文件上下文后,如何让 Clang 在正确的预处理器状态下编译该头文件,是一个需要选择实现方案的工程问题。

这里讨论的前缀合成只适用于用户打开的头文件。磁盘上未打开的头文件不需要特殊处理——编译各个源文件时,它们会被正常包含和处理。索引系统在每个源文件的索引过程中收集头文件的符号信息,而头文件的逐文件索引分片会合并在不同源文件下产生的变体(合并机制见 索引设计)。

clice 采用前缀合成 + -include 注入方案:根据头文件上下文中的宿主源文件和 include 位置,沿 include 链提取目标头文件之前的所有内容,将其合成为前缀代码并写入磁盘上的前缀文件,然后通过 Clang 的 -include 标志将该文件注入编译命令。该方案的核心优势是天然兼容 PCH 优化——通过 -include 包含的文件会先经由 Clang 的 predefines 缓冲区处理,然后才处理主文件。因此,构建 Preamble PCH 时,前缀内容会与头文件自身的 Preamble 区域一起写入同一个 PCH;即使头文件自身没有任何预处理指令(如 X macro 风格的 .def 文件),也会为其前缀构建 PCH。复用 PCH 时,Clang 会验证匹配的 -include 并将其视为已包含,因此前缀绝不会被处理两次。PCH 按内容键(Preamble 文本 + 规范化标志)缓存,前缀相同的文件会自动共享同一份 PCH。用户之后编辑头文件主体时,无需每次都重新处理前缀中包含的大量头文件。选择该设计的详细理由见下方 FAQ 章节。PCH 的构建、缓存和失效机制见 增量编译设计

合成过程分为四个阶段。下面通过一个示例说明这一过程。假设项目中有以下文件:

cpp
// main.cpp
#include <vector>
#define DEBUG 1
#include "utils.h"
int main() { ... }
cpp
// utils.h
#pragma once
#include <string>
#include "math.h"
void util_func();

用户打开了 math.h,但它不在 CDB 中,因此必须为它合成前缀代码。

第一步:查找宿主源文件。 利用 DependencyGraph 的反向 include 图,从 math.h 开始向上执行 BFS,查找传递包含它的源文件。在本例中,math.hutils.h 包含,utils.h 又被 main.cpp 包含,而 main.cpp 在 CDB 中有条目,因此它被选为宿主。如果用户通过 clice/switchContext 指定了首选宿主,则优先采用该宿主。

第二步:计算 include 链。 从宿主 main.cpp 到目标 math.h,在正向 include 图上执行 BFS,找到最短路径:[main.cpp, utils.h, math.h]

第三步:合成前缀代码。 沿 include 链依次处理每个文件(最后的 math.h 除外):读取其内容,找到包含下一个文件的 #include 行,并提取该行之前的所有内容。每个片段前都会添加一条 #line 指令,以确保诊断指向正确的原始位置。本例合成的前缀代码如下:

cpp
#line 1 "main.cpp"
#include <vector>
#define DEBUG 1
#line 1 "utils.h"
#pragma once
#include <string>

main.cpp#include "utils.h" 之后的内容(int main() { ... })会被截去,utils.h#include "math.h" 之后的内容(void util_func();)也会被截去。这段前缀代码还原了 math.h 在原始编译中会看到的预处理器状态:<vector><string> 已被包含,DEBUG 被定义为 1。

前缀代码会写入磁盘缓存,并以其内容的 xxh3 哈希值命名,因此相同的内容只会存储一份。

第四步:注入编译命令。 使用宿主 main.cpp 在 CDB 中的编译命令,将源文件路径替换为 math.h,并通过 -include 标志注入前缀文件:

bash
clang -std=c++17 -Iinclude -include cache/header_context/a1b2c3d4.h math.h

Clang 会先处理 -include 指定的前缀文件,然后编译 math.h。这样一来,math.h 就如同在 main.cpp#include "math.h" 所在的位置进行编译,并具有正确的预处理器状态。

这里有几个工程实现细节很重要。包含指令会根据宿主编译命令的实际搜索路径进行解析,并按绝对路径匹配,因此不会混淆不同目录下的同名头文件;由于前缀文件位于缓存目录中,其中使用引号指定相对路径的包含指令会被改写为解析后的绝对路径,否则查找时会采用错误的基准目录。匹配时优先选择 #if 块外的包含位置(未选中分支中的位置不得遮蔽实际生效的位置);当截断点位于 #if 块内时(最常见的情况是中间头文件中的头文件保护(include guard)),会追加用于配平的 #endif,使前缀保持良构——编译器仍会对保护条件求值,从而保持原有语义。

前缀之外还有后缀:包含位置之后的内容会沿包含链以镜像顺序拼接(直接包含者的剩余内容最先,宿主文件的剩余内容最后),合成为后缀文件;编译时,通过在头文件缓冲区末尾追加一行 #include 将其注入。因此,嵌入枚举或函数体中的 X macro 片段也能看到外围大括号闭合——Token 流连续穿过前缀、主文件和后缀。当截断点位于 #if 块内时,前缀会用 #endif 将其闭合,后缀则用相同层数的 #if 1 重新打开,使两侧保持平衡。包含链中对该头文件自身的其他包含位置会被重定向到它的磁盘快照——该头文件自身的路径已重映射到末尾附有后缀包含指令的缓冲区,因此若保持这些包含不变,就会无限递归。追加的这一行位于编辑器可见内容之外,后缀文件中的诊断绝不会归到该头文件本身。

合成结果(宿主、前缀文件路径、后缀文件路径、内容哈希,以及附带内容快照的包含链)由上下文解析器持有,其生命周期超过编辑会话——关闭并重新打开头文件时会复用该结果。链上各文件的内容已嵌入前缀,编译器不会打开这些文件,因此常规依赖跟踪无法感知它们——包含链快照通过 mtime 与内容哈希两层检测来处理过期问题:链上任一文件发生变化,都会重新合成前缀。即使 mtime 未发生变化,保存链上文件也会强制重新验证其内容。

索引中的多上下文

编译上下文不仅影响实时编译,也影响索引构建。同一个头文件在不同编译上下文下可能产生不同的符号。例如:

cpp
// config.h
#ifdef USE_V2
    using Handler = AsyncHandler;    // Visible in context A
#else
    using Handler = SyncHandler;     // Visible in context B
#endif

如果索引只记录某一个上下文中的结果,转到定义或查找引用就会遗漏另一个上下文中的信息。因此,每个文件的索引分片会将不同编译上下文下生成的行分别存为独立变体,项目索引则记录这些变体由哪些翻译单元贡献;查询会返回所有活跃变体的并集——对于 config.h,查找引用可以找到 AsyncHandlerSyncHandler 的引用。具体的合并与去重机制见索引设计

常见问题

  • 同一个源文件能否为同一个头文件提供多个上下文?

    可以。如果头文件有头文件保护或 #pragma once,对它的多个 #include 指令只有第一次有效,因此一个源文件最多只能提供一个上下文。但是,如果头文件没有包含保护机制(例如 X macro 风格的 .def 文件),每个 #include 都会引入一个新上下文。如上所述,源文件中所有有效的包含位置都会按顺序编号,因此,即使同一个头文件被多次包含,也可以通过编号唯一区分每次包含。

  • 通过 -include 注入前缀文件,与原始编译的效果完全一致吗?

    对于 C++ 标准定义的机制来说,是的。用户代码中观测文件名和行号的方式只有 __FILE____LINE__std::source_location。根据 C++ 标准,#line 指令 会改变 __LINE____FILE__ 的值,std::source_location 的行为也由此决定。因此前缀文件中的 #line 指令能保证这三者都正确反映目标头文件的位置信息。

    但有两个 GCC/Clang 编译器扩展的行为不同:__INCLUDE_LEVEL__-include 模式下为 0(目标文件是主文件),而在原始编译中反映嵌套的 include 深度;__BASE_FILE__-include 模式下返回目标头文件路径,而在原始编译中返回宿主源文件路径。这两个扩展在实际代码中极少使用,对语言服务器的功能没有影响。

  • 为什么将前缀文件写入磁盘,而不是使用虚拟文件?

    Clang 支持虚拟文件系统,因此理论上合成的前缀代码可以只存在于内存中,无须写入磁盘。选择写入磁盘主要是为了便于调试:用户遇到问题时,可以直接查看磁盘上的前缀文件内容,从而更容易诊断问题和提交缺陷报告。除此之外,没有什么深层的技术原因。

  • 为什么不采用“将宿主源文件作为主编译单元,并在目标位置停止”的方案?

    还考虑过另一种方案:不合成前缀文件,而是直接将宿主源文件作为主编译单元进行编译。Clang 提供了声明级回调机制,可以在每个顶层声明解析完成后检查当前源码位置。利用该机制,可以根据头文件上下文中的 include 位置,在解析到目标头文件末尾时停止编译。此时,编译器已经自然积累了正确的预处理器状态,可以直接在该状态下为目标头文件提供语言服务。

    该方案有明显的优点:无须合成文件,无须追踪 include 链,只需指定宿主源文件即可解决问题。此外,对于 X macro 这类嵌入函数体内的非自包含头文件,由于编译对象是完整的源文件,include 位置前后的上下文(花括号、语句等)均保持完整,因此不会出现语法不匹配的问题。

    但该方案有一个关键缺陷:无法有效利用 PCH 优化。PCH 的工作原理是预编译并缓存文件开头连续的 Preamble 段(通常是一系列 #include 指令)。在前缀合成方案中,目标头文件是编译对象,合成的前缀代码是它的 Preamble,可以完整地由 PCH 缓存。但在宿主编译方案中,编译对象是宿主源文件,目标头文件嵌入在 include 树的某个位置——通常不在宿主源文件的 Preamble 区域,而是在文件中间。Clang 的 PCH 机制无法在这种位置切分,因此目标头文件之前的内容都无法由 PCH 缓存。

    对于用户正在编辑的头文件,文件主体是更新最频繁的部分,每次编辑都需要重新编译。如果无法使用 PCH 缓存前缀内容,每次编译都必须重新处理宿主源文件中此前包含的大量头文件——这一开销在大型项目中不可接受。前缀合成方案将目标头文件作为编译对象,并将前缀代码作为可单独缓存的 Preamble,从而完整保留了 PCH 的生成和使用能力。

已知局限

  • occurrence 编号只覆盖直接包含方。按设计,头文件上下文由宿主源文件及其包含树中的位置编号唯一确定。当前实现中的 occurrence 只区分目标头文件在其直接包含方中的多次包含(涵盖 X 宏的典型用法),而不是对宿主的整个包含树进行展平编号——当同一头文件由同一宿主经不同中间路径传递包含时,只呈现最短链所对应的上下文。

  • 最短链未必对应实际的首次包含。从宿主到目标头文件的包含链采用包含图中的最短路径。实际编译中可能先经由另一条更长的路径到达该头文件,而在此之前积累的预处理器状态可能有所不同。实践中,这很少造成可观察到的差异,但严格来说,合成的状态可能无法与任何一次实际编译完全对应。

  • 同一 TU 内跨配置的条件包含。依赖扫描会按编译配置分别记录包含边,但宿主查找使用所有配置的并集。极端情况下,只在配置 A 下成立的包含边可能导致某个头文件采用配置 B 的编译命令。

  • 切换构建配置需要重启服务器。选择会被持久化,并由下一次服务器启动生效,而不是在进程内直接换掉;之后由客户端重新打开这些文档。如果没有声明任何数据库,发现流程会加载工作区根目录及其一级子目录下的每一个 compile_commands.json,以及打开某个文件时它上层目录中的那些;在此之前不会去扫描嵌套项目的数据库。