Skip to content

编译上下文

背景

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

源文件会因为不同的编译命令(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";       // 仅 Debug 构建中存在
    log("connection created");
#endif
    conn->start();
}

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

对于头文件,编译上下文分为两种情况。首先需要理解什么是"自包含":一个头文件如果不依赖包含者提供的 #define#include,自身就能独立编译,就是自包含的。绝大多数现代 C++ 头文件都是自包含的——自己 #include 所有依赖,不对包含者的状态做假设。

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

严格来说,"自包含"只是一种倾向而非绝对的属性。即使一个头文件自身能独立编译,包含者仍然可以通过 -D 或前置的 #define / #undef 改变它的语义。只是在实践中,我们通常不会这样做——一个被设计为自包含的头文件,其行为不应该依赖外部的预处理器状态。

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

cpp
// errors.def —— 不是自包含的,要求包含者先定义 X 宏
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 —— 生成枚举
#define X(name, code, msg) name = code,
enum ErrorCode {
#include "errors.def"
};
#undef X

// error_message.cpp —— 生成字符串映射
#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. Include 位置:该头文件在宿主源文件的 include 树中被包含的具体位置

这里 include 位置很重要,因为同一个头文件可能在同一个源文件中被包含多次,且每次的预处理器状态不同(例如 X macro 模式下,每次包含前定义了不同的宏)。可以把一个源文件的所有 include 位置展平为一个有序序列,每个位置对应一个唯一的编号。宿主源文件加上这个编号就唯一确定了一个头文件上下文。

如何确定编译上下文

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

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

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

  3. 依赖图查找。如果文件不在 CDB 中(头文件的常见情况),通过依赖图的 include 关系找到一个包含它的源文件,借用该源文件的编译命令。宿主候选按相关性排序:同名源文件(utils.h 对应 utils.cpp)优先,其次是同目录的源文件,再按路径接近程度,最后按字典序保证确定性。对于自包含头文件,借用编译命令即可;对于非自包含头文件,还需要合成前缀代码来还原预处理器状态——两种情况的区分是自动的,见下节。

  4. 命令转移启发式(预留,尚未实现)。对既没有 CDB 条目也没有宿主的文件,从 CDB 中推断一条最合适的命令。计划两种策略:其一,为编译命令中的头文件搜索目录(-I 等)建立目录到宿主命令的反查表,从文件所在目录逐级向上倒查——落在某条命令搜索路径之下的头文件借用这条命令,它自己的 include 也能正确解析(典型场景:新建的、还没被任何文件包含的头文件);其二,按相邻路径推断,从路径最接近的 CDB 条目借用。相当于把 clangd 的命令插值(interpolation)做成显式、可感知的层级,而不是隐藏的猜测。命令来源标注中已为它预留了 Inferred 档位,推断命令产生可疑错误时会附带引导性诊断。

  5. 默认命令兜底。以上全部落空的孤立文件,使用一条合成的默认命令编译,保证基本的语言服务可用。

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

clice/queryContext 列出一个文件所有可用的编译上下文。头文件返回可以做宿主的源文件列表(按相关性排序);源文件返回所有 CDB 条目,用编译命令中的关键标志(-D-std-O 等)做可读描述区分,并附带标识该条目的命令哈希。结果支持分页。对自包含头文件和源文件,上下文列表按规范化编译参数的哈希去重:参数规范化后相同的两个上下文会产生相同的编译结果,只保留排序最靠前的代表——否则像 <vector> 这种被大量源文件包含的头文件会列出几千个上下文。非自包含头文件不做此合并:不同宿主合成的前缀不同,每个宿主都是独立的上下文。没有 include 保护的头文件被同一宿主多次包含时,每个包含位置返回一个独立条目,用 occurrence 编号区分。

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

clice/switchContext 切换到用户选择的新上下文。切换时会校验选择的合法性:宿主必须确实(传递地)包含该头文件,occurrence 必须在范围内,命令哈希必须对应文件的真实 CDB 条目,否则返回失败。

除请求外,每次编译完成后服务器会推送 clice/inactiveRegions 通知,携带该文件在当前编译上下文下的非活跃预处理区域(未选中的 #if 分支体)。编辑器将它们变暗渲染;切换上下文触发重编译后区域随之翻转,这也是上下文切换最直观的视觉反馈。

自包含的自动判断

绝大多数头文件是自包含的,为它们合成前缀纯属浪费——需要计算 include 链、读取链上所有文件、引入大量前置依赖。因此 clice 对不在 CDB 中的头文件采用先试后退的策略:

  1. 试编译:默认当作自包含处理,借用宿主的编译命令直接编译(不合成前缀)。
  2. 诊断打分:如果试编译的诊断命中一组严格筛选的"缺失上下文"特征——未知类型名、未声明标识符、未闭合的条件编译指令——则判定为非自包含。判据刻意收窄:误判为非自包含只是多合成一次前缀,而把用户正在输入产生的普通语法错误误判进去会导致无意义的重编译。
  3. 回退重编:判定非自包含后,合成前缀重新编译。试编译产生的错误诊断不会发布给编辑器,用户只会看到最终结果。

.def.inc 扩展名的文件(X macro 惯例)跳过试编译,直接按非自包含处理。只有"非自包含"的判定会持久化到工作区缓存——它是昂贵的那一边,重启后不必重复试编译;"自包含"只是会话内的印象,重新验证的成本为零(试编译就是常规编译本身)。判定的重评时机经过刻意区分:依赖变化(保存了链上文件、头文件自己包含的文件变化、磁盘上的外部修改)触发重评,用户打字不触发——否则输入到一半的未知类型名会不断引发无意义的前缀合成。头文件自身被保存后,持久化的判定也会被重置。

实践中的头文件上下文

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

这里讨论的前缀合成只针对用户打开的头文件。磁盘上未被打开的头文件不需要单独处理——它们在各个源文件编译时会被正常包含和处理。索引系统在索引每个源文件时会顺带收集其中头文件的符号信息,再由 MergedIndex 将同一个头文件在不同源文件中产生的索引数据合并起来(合并机制见 索引设计)。

clice 采用的方案是前缀合成 + -include 注入:根据头文件上下文中的宿主源文件和 include 位置,沿 include 链提取目标头文件之前的所有内容,合成为前缀代码并写入磁盘上的前缀文件,然后通过 Clang 的 -include 标志将该文件注入到编译命令中。这个方案的核心优势是与 PCH 优化的天然配合——-include 的文件经由 Clang 的 predefines 缓冲区在主文件之前被处理,构建 preamble PCH 时前缀内容与头文件自身的 preamble 区一起被烘进同一个 PCH;即使头文件自身没有任何 include 指令(X macro 风格的 .def 文件),也会为前缀单独构建 PCH。消费 PCH 时 Clang 会校验并跳过两侧一致的 -include,前缀不会被处理两次。PCH 按内容键(preamble 文本 + 规范化参数)缓存,前缀相同的文件自动共享一份。用户后续编辑头文件主体时不必每次都重新处理前缀中大量的头文件包含。方案选择的详细论证见下方 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.hmain.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" 位置被编译一样,拥有正确的预处理器状态。

合成有几个关键的工程细节。include 指令用宿主命令的真实搜索路径解析后按绝对路径匹配,同名不同目录的头文件不会混淆;前缀文件位于缓存目录,其中相对路径的引号包含会被改写为解析后的绝对路径,否则会以错误的目录为基准查找。匹配优先选择不在 #if 块内的 include(未选中分支里的包含不应遮蔽真实的那个);当截断点落在 #if 块内部时(最常见的是中间头文件的 include guard),按深度补齐 #endif 保证前缀良构——guard 条件仍由编译器求值,语义不变。

前缀之外还有后缀:include 位置之后的内容(沿链镜像拼接——直接包含者的剩余部分在前,宿主的在后)被合成为后缀文件,编译时在头文件缓冲区末尾追加一行 #include 注入。这使得嵌在 enum 或函数体内部的 X macro 片段能看到包围它的闭合括号——token 流跨越前缀、主文件、后缀连续展开,语法完整。截断点在 #if 块内时,前缀补 #endif 闭合、后缀以对应数量的 #if 1 重开,两侧各自平衡。链上对头文件自身的其他包含(同一文件的另一个 occurrence)会改写为指向它的磁盘快照——头文件自身的路径在编译时被重映射为带后缀追加行的缓冲区,原样保留会造成无限递归。追加的那一行位于编辑器可见内容之外,后缀文件里的诊断也不会归属到头文件本身。

合成结果(宿主、前缀文件路径、后缀文件路径、内容哈希、include 链及其内容快照)缓存在 Session 中。链上文件的内容被"拓印"进了前缀,编译器不会打开它们本身,因此常规的依赖追踪对它们失明——失效由链快照(mtime + 内容哈希两层检测)负责:链上任何文件变化,前缀都会重新合成。保存链上文件时会强制按内容重新校验,即使 mtime 未变。

索引中的多重上下文

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

cpp
// config.h
#ifdef USE_V2
    using Handler = AsyncHandler;    // 上下文 A 中可见
#else
    using Handler = SyncHandler;     // 上下文 B 中可见
#endif

如果索引只记录一个上下文的结果,用户做 go-to-definition 或 find-references 时就会丢失另一个上下文中的信息。MergedIndex 会将同一个文件在不同编译上下文下产生的索引数据合并存储,查询时返回所有上下文的并集——对 config.h 来说,find-references 能同时找到 AsyncHandlerSyncHandler 的引用。合并和去重的具体机制见 索引设计

FAQ

  • 同一个源文件可以为同一个头文件提供多种上下文吗?

    可以。如果头文件有 header guard 或 #pragma once,那么多次 #include 只有第一次有效,因此一个源文件最多为它提供一个上下文。但如果头文件没有 include 保护(如 X macro 风格的 .def 文件),每一次 #include 都会引入一个新的上下文。如前文所述,一个源文件中所有有效的 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 支持虚拟文件系统,理论上合成的前缀代码可以只存在于内存中,不需要写到磁盘。选择写磁盘文件主要是出于可调试性的考虑:用户遇到问题时可以直接查看磁盘上的前缀文件内容,方便定位问题和提交 issue。没有特别深层的技术原因。

  • 为什么不使用"以宿主源文件为主体编译,在目标位置停止"的方案?

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

    这个方案有明显的优点:不需要合成任何文件,不需要追踪 include 链,只需要指定宿主源文件就能解决问题。而且对于 X macro 这类嵌入在函数体内部的非自包含头文件,由于编译的主体是完整的源文件,包含位置前后的括号、语句等上下文都是完整的,不会出现语法不配对的问题。

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

    对于用户正在编辑的头文件来说,文件主体是最频繁更新的部分,每次编辑都需要重新编译。如果无法利用 PCH 缓存前缀内容,每次编译都要重新处理宿主源文件中大量的前置头文件包含,大型项目中这个开销是不可接受的。前缀合成方案让目标头文件成为编译主体、前缀代码成为可独立缓存的 preamble,完全不影响 PCH 的生成和使用。

已知局限

  • occurrence 编号只覆盖直接包含者。设计上,头文件上下文由宿主源文件加 include 树中的位置编号唯一确定。当前实现的 occurrence 只区分直接包含者中对目标头文件的多次包含(这覆盖了 X macro 的典型用法),而不是对宿主整个 include 树的展平编号——同一头文件经由不同中间路径被同一宿主传递包含的场景,目前只呈现最短链对应的那一个上下文。

  • 最短链未必对应真实的首次包含。从宿主到目标头文件的 include 链取的是 include 图上的最短路径。真实编译中头文件首次被包含时走的可能是另一条更长的路径,两者前面积累的预处理器状态可能不同。实践中这种差异很少造成可观察的影响,但严格来说合成出的状态可能不与任何一次真实编译完全一致。

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