模板解析
背景
C++ 模板的一个核心设计原则是延迟实例化——在使用具体类型实参实例化模板代码之前,编译器不会(也无法)解析依赖于模板参数的名称。这些名称称为依赖名称,其类型在模板定义时是未知的。
对语言服务器来说,这意味着模板代码内部是一个“盲区”:
template <typename T>
void foo(std::vector<T> vec) {
vec. // cursor here -- vec's type is known to be vector<T>, completions can be provided
}在简单情况下,使用主模板的定义就足以提供基本的代码补全。但请看一个更复杂的场景:
template <typename T>
void foo(std::vector<std::vector<T>> vec2) {
vec2[0]. // What is the type of vec2[0]?
}vec2[0] 的类型是 std::vector<std::vector<T>>::reference——一个依赖名称。在标准库实现中,解析它需要追踪几十层嵌套的模板 typedef 链:reference -> allocator_traits<Alloc>::value_type -> __alloc_traits<Alloc>::reference -> ...。每一层都涉及偏特化匹配、默认模板实参、typedef 展开等复杂操作。
在 clangd 社区中,模板代码中的代码补全和悬停问题长期存在。用户反复报告无法在模板函数体内获得代码补全建议——光标所在位置显示 <dependent type>,所有 LSP 功能均会失效。根本原因是 clangd 的解析策略存在几个根本性限制:
不处理偏特化:clangd 假设成员查找总是使用主模板的定义。但标准库大量使用偏特化(例如为不同分配器提供 allocator_traits 偏特化),而主模板甚至可能没有待查找的成员——该成员只存在于某个特定的偏特化中。
缺少实参映射:即使名称查找找到了成员的类型,该类型仍然以所查找模板的参数(例如 allocator_traits<Alloc>::value_type 中的 Alloc)表示,而不是调用方的参数(例如 T)。由于 clangd 不执行实例化,因此无法建立参数之间的映射。
忽略默认模板实参:std::vector<T> 实际上是 std::vector<T, std::allocator<T>>——第二个实参使用默认值。clangd 不展开默认实参,导致依赖于默认实参的名称无法解析。
设计
核心思路
clice 实现了一个 PseudoInstantiator——它通过启发式方法解析依赖名称,无需具体类型实参。核心思路是:不需要知道 T 是 int 还是 string;只需要追踪 T 如何在模板 typedef 链中传播,并用 T 表示最终结果。
例如:std::vector<std::vector<T>>::reference 经过伪实例化后简化为 std::vector<T>&——得到的类型已足够具体,可用于提供代码补全,同时保留了模板参数 T 的符号含义。
一个重写器,两种策略
解析器是一个手写的类型重写器。它有意不使用 Sema 或 Clang 的 TreeTransform——两者都假设存在带有具体实参的真实实例化上下文,而这里恰恰不存在这样的上下文。同一个重写器会采用以下两种策略之一运行:
解析策略(启发式解析)
这是主引擎,负责解析各种依赖名称:
- DependentNameType(例如
typename Container::value_type):在模板成员中查找value_type、匹配偏特化、展开 typedef,并用调用方的参数重新表示结果 - DependentTemplateSpecializationType(例如
Alloc::rebind<U>):解析依赖模板特化,并优先尝试针对标准库模式的快速路径 - TemplateTypeParmType(例如
T):通过实例化栈查找参数的绑定值或默认实参 - DecltypeType(例如
decltype(var)):将简单的变量引用解析为其声明类型
替换策略(打破循环)
当启发式解析展开一个 typedef 时,可能会遇到另一个需要启发式查找的依赖名称,从而形成循环:typedef A 的底层类型引用了依赖名称 B,而查找 B 又发现其类型再次涉及 typedef A。
替换策略会打破这种循环——它只执行参数替换和 typedef 展开,不做启发式查找。解析需要展开 typedef 时,会委托给替换策略,确保不会触发递归启发式查找。
实例化栈
嵌套模板中的模板参数可能处于不同深度。实例化栈(InstantiationStack)维护一个参数映射栈,每个栈帧记录一个层级的模板参数绑定。
遇到 TemplateTypeParmType(通过深度和索引标识的模板参数)时,解析器从栈顶(最内层)向栈底(最外层)线性搜索,在对应深度匹配参数绑定。找到匹配项时,将该参数替换为绑定的类型;未找到时,则尝试使用参数的默认值。当栈为空(独立的依赖类型)时,解析器会向上遍历外层模板声明,将其中注入的模板参数压栈作为上下文。
这使解析器可以处理多层嵌套模板:
template<typename X>
struct Outer {
template<typename Y>
struct Inner {
typename std::pair<X, Y>::first_type member;
// X is at depth 0, Y is at depth 1
// Both need to be tracked in the stack for correct resolution
};
};依赖名称解析流程
以 typename A<T>::type 为例:
- 缓存检查:如果此节点之前已解析过,直接返回缓存结果
- 循环检测:如果此节点当前正在解析,则中止解析以防止无限递归
- 限定符变换:变换
A<T>部分——替换参数并匹配偏特化 - 成员查找:在变换后的类型中查找
type。先尝试每个偏特化及其基类,再尝试主模板及其基类 - 参数推导:利用 Clang 的模板参数推导机制建立形参与实参之间的映射
- 替换:通过 SubstituteOnly 展开所找到成员类型中的 typedef,并使用调用方的参数进行替换
- 递归:如果结果仍包含依赖名称,则递归解析
整个过程有 16 层的递归深度限制,以防止极端情况下的无限递归。
解析器还维护两层循环检测:一层防止对同一 DependentNameType 节点进行重入解析,另一层防止对同一 ClassTemplateDecl 进行递归查找(用于处理 CRTP 和其他自引用模式)。
偏特化匹配
偏特化匹配是伪实例化器相较于 clangd 的一个关键优势。标准库大量使用偏特化,例如:
// Primary template -- does not define value_type
template<typename Alloc> struct allocator_traits;
// Partial specialization -- defines value_type
template<typename T> struct allocator_traits<allocator<T>> {
using value_type = T;
};解析 allocator_traits<allocator<int>>::value_type 时,clangd 因为在主模板中查找 value_type 而失败。伪实例化器尝试将实际参数与每个偏特化的模式进行匹配,找到正确的偏特化后,再在其中查找该成员。
标准库特殊处理
标准库的分配器重绑定链是一条特别深的 typedef 链:
vector<T> -> __alloc_traits<allocator<T>> -> allocator_traits<allocator<T>>
-> allocator_traits<Alloc>::rebind_alloc<U>这条链可能超过深度限制。解析器对 allocator_traits::rebind_alloc 模式做了短路处理:检测到该模式后,解析器会直接尝试 Alloc::rebind<T>::other(标准分配器协议);若失败,则回退到替换第一个模板参数。
恢复类型语法糖(Type Resugaring)
Clang 内部将模板参数表示为规范的 TemplateTypeParmType 值(深度 + 索引)。用户应该看到参数名称(例如 T),而不是 parameter 0 of depth 0。ResugarOnly 变换将规范的参数类型映射回其原始声明,从而生成便于用户理解的类型信息。
优雅降级
如果解析过程中的任何步骤失败(查找失败、触发循环检测、超过深度限制),解析器会返回原始依赖类型,而不报告错误。这意味着 LSP 功能不会因解析失败而彻底不可用——它们只会降级到“无法解析”状态,并不比完全不执行伪实例化更差。
设计决策与权衡
为什么选择伪实例化而不是真正的模板实例化? 真正的实例化需要具体的类型实参(例如 T = int),但模板定义时这些信息不可用。而且,语言服务器中的代码经常是不完整的,无法进行完整实例化。伪实例化通过保留符号参数(T 本身)来避免依赖具体类型。
为什么要用两阶段设计? 单阶段设计中,typedef 展开会触发新的启发式查找,而查找结果又可能需要 typedef 展开——形成循环。两阶段设计通过职责分离打破这个循环:启发式查找只在第一阶段执行,typedef 展开交给第二阶段,第二阶段不执行查找。
为什么要对标准库模式做特殊处理? 标准库的 allocator rebinding 是实际代码中最常见的深层 typedef 链。通用递归解析在这里会超过深度限制。虽然不够优雅,但特殊处理覆盖了用户最常遇到的场景。未来计划用通用机制替换这些特殊处理。
为什么选择优雅降级而不是报错? 模板解析本质上是一种启发式方法——不可能覆盖 C++ 模板的所有边界情况。优雅降级确保解析失败的最坏情况不会比“完全不解析”更糟,同时在解析成功的情况下带来明显的体验改进。
将实例化视为实现
伪实例化从模板的书写形式入手:它仅根据模式本身推断依赖名称 可能 表示什么。翻译单元通常还保存着一项互补的事实依据——实际 实例化,记录了对于每个具体实参列表,每个依赖名称 确实 表示什么。
clice 有意将模板视为鸭子类型接口,将每个实例化视为该接口的实现,类似于虚函数与其重写函数之间的关系。这是一个长期发展方向,并且已经影响了当前的行为:
- 语义分类不会跳过实例化代码。实例化会复用模式中的源码位置,因此实例化体内依赖名称的解析结果会落到用户在模板中写下的那个 Token 上。只有一个实例化时,该依赖名称会按其实际解析结果分类;当多个实例化的类别一致时,仅保留它们共有的修饰符;当它们不一致时(一个将名称解析为函数,另一个解析为变量),该 Token 会被分类为冲突——实现之间的分歧是信息,而不是噪音。目前,翻译单元只记录显式实例化定义产生的实例化;在使用处记录隐式实例化,是沿此方向规划的一项扩展。
- 计划:对依赖名称执行转到实现时,将列出它在每个实例化中的解析结果,就像对虚函数执行转到实现时会列出其重写函数一样。目前,这些实现关系在符号索引中的建模方式有意暂不确定。
基于遍历的功能(内联提示、折叠范围、文档符号)仍然会跳过实例化子树:这些功能生成以位置为键的条目,而实例化只会重复模式已经生成的内容,或与之冲突。这一区分是有意为之:合并多个解析结果对于分类和代码导航有意义,但对于按位置渲染没有意义。
已知局限
解析器重写中有意未包含表达式级推导——这方面有无穷无尽的边界情况,而解析器的约定是优雅降级(返回时保持名称未解析),而不是进行猜测。以下已知缺口均属此类:
- 非类型模板参数表达式:复合 NTTP 表达式不会被替换或推导(例如,选择以
N + 1为键的偏特化);只有直接参数值会传递。 - 约束求值:可以检测到受约束的偏特化,但不会对其约束求值——如果某个特化只能通过
requires子句确定,解析结果会降级为未解析,而不会选中该特化。 - 复杂
decltype:只处理decltype(var)这种简单情况;不支持成员访问、函数调用和其他复杂表达式,而用作作用域限定符的依赖型decltype会使整个链条无法解析。 - 深层依赖成员链:链式成员访问(
box.inner.leaf)只解析一跳;中间成员的类型不会反馈到下一跳的查找中。x.template foo<T>()成员表达式同样尚未实现。 - 运算符查找:解析器自身不会解析依赖上下文中的运算符(例如
a + b,其中a的类型依赖于模板参数)。与解析器无关的是,存在实例化时,模板体中的相应位置仍可通过实例化信息获得着色并支持导航(参见“实例化作为实现”一节)。
