Read OSS

绑定器:构建符号表与控制流图

高级

前置知识

  • 第 1 篇:架构与代码库全览
  • 第 2 篇:Scanner、Parser 与 AST
  • 了解编译器中符号表与作用域链的基本概念
  • 熟悉控制流分析的相关知识

绑定器:构建符号表与控制流图

Parser 输出 SourceFile AST 之后,下一个阶段负责赋予名称以语义。绑定器对 AST 进行深度优先遍历,为每个声明创建 Symbol 对象,将它们组织到层级化的符号表中,为每个节点设置父指针,并且——最关键的——构建控制流图,从而支撑 TypeScript 的类型收窄能力。这一切都发生在 src/compiler/binder.ts 中,整个文件大约有 3,900 行。

绑定器是语法与语义之间的桥梁。在深入研究 checker 之前,理解绑定器至关重要——因为 checker 默认绑定器所产出的一切都已就绪:父指针、符号表以及 flow 节点。

bindSourceFile 与基于闭包的绑定器

和 scanner、parser 一样,绑定器采用基于闭包的模块模式。createBinder() 返回一个函数,该函数接受 SourceFileCompilerOptions。在其内部,闭包捕获了数十个 var 局部变量:

function createBinder(): (file: SourceFile, options: CompilerOptions) => void {
    // Why var? It avoids TDZ checks in the runtime which can be costly.
    // See: https://github.com/microsoft/TypeScript/issues/52924
    var file: SourceFile;
    var options: CompilerOptions;
    var parent: Node;
    var container: IsContainer | EntityNameExpression;
    var blockScopeContainer: IsBlockScopedContainer;
    // ... control flow state ...
    var currentFlow: FlowNode;
    var currentBreakTarget: FlowLabel | undefined;
    var currentContinueTarget: FlowLabel | undefined;
    // ...

绑定器以模块级单例的方式实例化一次——const binder = createBinder()——并在处理每个文件时复用。每次调用 bindSourceFile() 都会重新初始化闭包状态,然后遍历新文件的 AST。

flowchart TD
    BSF["bindSourceFile(file, options)"] --> Init["Initialize closure state<br/>Reset containers, flow nodes"]
    Init --> Walk["Depth-first AST walk"]
    Walk --> Bind["bind(node)"]
    Bind --> SetParent["Set node.parent"]
    Bind --> CheckDecl{"Is declaration?"}
    CheckDecl -->|Yes| CreateSym["Create/merge Symbol"]
    CheckDecl -->|No| Skip["Continue walk"]
    CreateSym --> AddToTable["Add to container's symbol table"]
    Bind --> FlowCheck{"Affects control flow?"}
    FlowCheck -->|Yes| UpdateFlow["Create FlowNode, update currentFlow"]
    FlowCheck -->|No| Continue["Continue"]
    Bind --> Children["Visit child nodes"]

bindSourceFile() 本身只是一个轻量的包装器,主要用于在绑定调用前后插入性能打点。

提示: 绑定器的闭包状态决定了你无法在单个程序中对多个文件并发执行绑定。这些闭包局部变量相当于隐式的线程本地状态——在单线程执行模式下性能极佳,但无法并行化。

Symbol、SymbolFlags 与符号表

Symbol 接口

Symbol 代表一个命名实体的语义标识。其核心字段包括:

  • flags: SymbolFlags — 标识该 symbol 所代表的实体类型
  • escapedName: __String — symbol 的名称(经过转义,以避免与内置属性冲突)
  • declarations: Declaration[] — 所有声明该 symbol 的 AST 节点(合并声明时可能有多个)
  • valueDeclaration: Declaration — 第一个产生值的声明
  • members: SymbolTable — 实例成员(用于 class、interface、对象字面量)
  • exports: SymbolTable — 导出成员(用于 module 和 namespace)

SymbolFlags:分类体系

SymbolFlags 是一个位标志枚举,将 symbol 划分为不同的类别:

flowchart TD
    SF["SymbolFlags"]
    SF --> Value["Value Space<br/>FunctionScopedVariable | BlockScopedVariable<br/>Property | EnumMember | Function<br/>Class | Method | Accessor"]
    SF --> TypeSpace["Type Space<br/>Class | Interface | Enum<br/>EnumMember | TypeLiteral<br/>TypeParameter | TypeAlias"]
    SF --> Namespace["Namespace Space<br/>ValueModule | NamespaceModule | Enum"]
    SF --> Special["Special Flags<br/>Alias | Transient | Assignment<br/>Optional | ExportStar | Prototype"]

一个 symbol 可以同时存在于值、类型和 namespace 三个"空间"中——这正是 class Foo 既能作为值(构造函数)又能作为类型(实例类型)使用的原因。ValueTypeNamespace 等复合标志是各个单独标志的联合:

Value = Variable | Property | EnumMember | ObjectLiteral |
        Function | Class | Enum | ValueModule | Method |
        GetAccessor | SetAccessor,
Type  = Class | Interface | Enum | EnumMember |
        TypeLiteral | TypeParameter | TypeAlias,

符号表与容器层级

Symbol 存储在附加于容器节点的 SymbolTable 映射中(本质上是 Map<__String, Symbol>)。绑定器维护着一个容器栈:

  • 全局容器 — 全局可访问的 symbol
  • Module/SourceFile — 顶层声明,填充 exports 符号表
  • Class/Interface — 填充实例成员的 members
  • Function/Block — 填充局部声明的 locals

绑定器遇到声明时,会确定对应的容器,并将 symbol 加入该容器的符号表。var 声明的容器是最近的函数作用域,let/const 声明的容器则是最近的块作用域。

声明合并

当同一容器中的两个声明共用同一个名称时,绑定器会将它们合并为一个 symbol。TypeScript 的以下特性均依赖于此机制:

  • Interface 合并:interface Foo { x: number } + interface Foo { y: string }
  • Namespace 与 class/function/enum 的合并
  • Module 扩充(module augmentation)

合并逻辑会检查 SymbolFlags 的兼容性——例如,ClassInterface 可以合并(class-interface 扩充),但两个 Class 声明不能合并。合并后的 symbol 的 declarations 数组会累积所有参与声明的节点。

SymbolLinks:惰性语义扩展

绑定器负责创建 Symbol,但不负责解析类型——那是 checker 的职责。连接两者的桥梁是 SymbolLinks,这是一个独立的接口,在运行时以惰性计算的方式为 Symbol 附加类型信息。

classDiagram
    class Symbol {
        +flags: SymbolFlags
        +escapedName: __String
        +declarations: Declaration[]
        +members: SymbolTable
        +exports: SymbolTable
    }
    class SymbolLinks {
        +type: Type
        +writeType: Type
        +declaredType: Type
        +typeParameters: TypeParameter[]
        +target: Symbol
        +aliasTarget: Symbol
        +mapper: TypeMapper
        +resolvedExports: SymbolTable
        +resolvedMembers: SymbolTable
    }
    Symbol ..> SymbolLinks : "checker.getSymbolLinks(symbol)"

SymbolLinks 的核心字段包括:

  • type — symbol 值的已解析类型
  • declaredType — 对于 class、interface 和类型别名,表示其声明类型
  • typeParameters — 泛型类型参数(用于类型别名和 class)
  • aliasTarget — 对于 import 别名,表示解析后的目标 symbol
  • resolvedExports / resolvedMembers — 合并了早绑定和晚绑定成员的符号表

Checker 通过 getSymbolLinks(symbol) 函数访问 SymbolLinks,该函数在首次访问时创建 links 对象并缓存。这种惰性初始化模式对性能至关重要——checker 只会为实际被查询的 symbol 计算类型信息,而不是急于为程序中所有声明计算类型。

提示: 如果你在调试 checker 中的类型解析,想知道某个 symbol 的类型从何而来,可以顺着 getTypeOfSymbol()getSymbolLinks(symbol).type 这条链路追踪。第一次调用会触发解析,后续调用则直接命中缓存。

控制流图的构建

绑定器的第二大职责——除符号表之外——是构建控制流图。这张图是 TypeScript 类型收窄的基础:当你写下 if (x !== null) { x.method() } 时,checker 之所以知道 if 块内的 x 不为 null,正是因为控制流图对分支进行了建模。

FlowNode 的类型

控制流图由 FlowNode 对象构成的链表表示,通过 FlowFlags 进行分类:

FlowNode 类型 FlowFlags 用途
FlowStart Start 函数控制流图的起始节点
FlowAssignment Assignment 变量赋值(收窄类型)
FlowCondition TrueCondition / FalseCondition 条件语句(if/三元运算符)产生的分支
FlowSwitchClause SwitchClause switch case 产生的分支
FlowLabel BranchLabel / LoopLabel 合并多条控制流的汇聚节点
FlowCall Call 潜在的断言调用(如 assert(x)
FlowArrayMutation ArrayMutation 可能收窄元组类型的数组 .push() 操作

每个 FlowNode 都有一个 antecedent 指针,指向前一个 flow 节点。图的遍历方向是反向的——从变量的使用点出发,checker 沿着 antecedent 链向前追溯,直到找到决定收窄类型的赋值或分支节点。

绑定器如何构建控制流图

flowchart TB
    Start["FlowStart"] --> A1["x = getSomething()<br/>FlowAssignment"]
    A1 --> Cond{"if (x !== null)"}
    Cond -->|"True"| TrueFlow["FlowCondition<br/>(TrueCondition)"]
    Cond -->|"False"| FalseFlow["FlowCondition<br/>(FalseCondition)"]
    TrueFlow --> Body["x.method()<br/>(use of x)"]
    Body --> Merge["FlowLabel<br/>(BranchLabel)"]
    FalseFlow --> Merge
    Merge --> After["code after if"]

绑定器在遍历 AST 的过程中,始终维护着 currentFlow——即控制流图中的当前位置。当它遇到以下情况时:

  • 赋值语句x = expr):以 currentFlow 为前驱节点创建一个 FlowAssignment 节点,然后将 currentFlow 更新为新节点。
  • if 语句:保存当前的 currentFlow,为 true 和 false 分支分别创建 FlowCondition 节点,处理完各分支后,创建一个 FlowLabel 汇聚节点合并两个分支的终点。
  • 循环:创建带有 LoopLabel 标志的 FlowLabel,在处理循环体时添加回边。
  • return/throw/break:将 currentFlow 设置为 unreachableFlow,将后续代码标记为死代码。
  • 函数调用:如果该函数可能是断言函数(返回 asserts 的类型谓词),则创建一个 FlowCall 节点。

绑定器同时追踪多个 flow 目标——currentBreakTargetcurrentContinueTargetcurrentReturnTargetcurrentTrueTargetcurrentFalseTarget 以及 currentExceptionTarget——以正确连接涉及循环、带标签语句和 try/catch 块等复杂控制流的 flow 边。

与类型收窄的关联

Checker(将在第 4 篇中介绍)在 getFlowTypeOfReference() 中使用这张图。从某个变量引用出发,checker 沿控制流图反向遍历,在每个 FlowConditionFlowAssignment 节点处应用收窄逻辑。每个 FlowNode 上的 id 字段用作缓存键——一旦某个 flow 节点处的收窄类型被计算出来,就会被缓存,以避免在具有大量分支的图中出现指数级的重复计算。

下一步

绑定器已经为每个声明创建了 Symbol,将 symbol 组织到了作用域化的表中,并在 AST 中织入了控制流图。在第 4 篇中,我们将进入编译器中规模最大、最为复杂的模块——这个拥有 54,000 行代码的类型检查器——看它如何从 symbol 解析类型、检查可赋值性、推断泛型、沿控制流边收窄类型,并输出那些让 TypeScript 真正有价值的错误信息。