Read OSS

バインダー:シンボルテーブルと制御フローグラフの構築

上級

前提知識

  • 第1回:アーキテクチャとコードベースの全体像
  • 第2回:Scanner、Parser、そして AST
  • コンパイラにおけるシンボルテーブルとスコープチェーンの理解
  • 制御フロー解析の基本概念への習熟

バインダー:シンボルテーブルと制御フローグラフの構築

パーサーが SourceFile の AST を生成したら、次のフェーズでは各名前に意味を与えます。バインダーは AST を深さ優先でたどり、すべての宣言に対して Symbol オブジェクトを生成し、階層的なシンボルテーブルへと整理し、すべてのノードに親ポインターを設定します。そして最も重要な役割として、TypeScript の型絞り込みを実現する制御フローグラフを構築します。これらの処理はすべて src/compiler/binder.ts — 約 3,900 行 — に収まっています。

バインダーは構文と意味論をつなぐ橋です。チェッカーを理解する前にバインダーを把握しておくことが不可欠です。チェッカーは、バインダーが生成する親ポインター、シンボルテーブル、フローノードがすでに揃っていることを前提として動作するからです。

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 — このシンボルが表すエンティティの種類を分類する
  • escapedName: __String — シンボルの名前(組み込みプロパティとの衝突を避けるためエスケープ済み)
  • declarations: Declaration[] — このシンボルを宣言するすべての AST ノード(マージされた宣言では複数になる)
  • valueDeclaration: Declaration — 値を生成する最初の宣言
  • members: SymbolTable — インスタンスメンバー(クラス、インターフェース、オブジェクトリテラル向け)
  • exports: SymbolTable — エクスポートされたメンバー(モジュールや namespace 向け)

SymbolFlags:分類の仕組み

SymbolFlags はシンボルをカテゴリに分類するビットフラグの enum です:

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"]

1つのシンボルが値・型・namespace の「空間」に同時に存在することがあります。class Foo が値(コンストラクター)と型(インスタンス型)の両方を生成できるのはこのためです。ValueTypeNamespace といった複合フラグは、個別のフラグのユニオンとして定義されています:

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

シンボルテーブルとコンテナの階層

シンボルは SymbolTable マップ(実質的には Map<__String, Symbol>)に格納され、コンテナノードに紐付けられます。バインダーはコンテナのスタックを管理します:

  • グローバルコンテナ — どこからでも参照できるシンボル
  • モジュール / SourceFile — トップレベルの宣言。exports シンボルテーブルに格納される
  • クラス / インターフェース — インスタンスメンバーを members に格納
  • 関数 / ブロック — ローカル宣言を locals に格納

バインダーは宣言を発見すると、適切なコンテナを特定してそのシンボルテーブルにシンボルを追加します。var 宣言であれば直近の関数(関数スコープ)が、let / const であれば直近のブロック(ブロックスコープ)がコンテナになります。

宣言のマージ

同じコンテナ内で同名の宣言が複数ある場合、バインダーはそれらを1つのシンボルにマージします。これにより TypeScript は次のような機能を実現しています:

  • インターフェースのマージ:interface Foo { x: number } + interface Foo { y: string }
  • クラス / 関数 / enum と namespace のマージ
  • モジュール拡張(module augmentation)

マージの際は SymbolFlags の互換性を検証します。たとえば ClassInterface はマージ可能(クラス・インターフェース拡張)ですが、Class 同士はマージできません。マージされたシンボルの declarations 配列には、宣言に関与するすべてのノードが蓄積されます。

SymbolLinks:遅延評価される意味情報の拡張

バインダーは Symbol を生成しますが、型の解決はしません — それはチェッカーの仕事です。両者をつなぐのが 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 — シンボルの値に対して解決された型
  • declaredType — クラス、インターフェース、型エイリアスの宣言された型
  • typeParameters — ジェネリックの型パラメーター(型エイリアスやクラスで使用)
  • aliasTarget — import エイリアスの場合、解決された対象シンボル
  • resolvedExports / resolvedMembers — 早期バインドと遅延バインドのメンバーを統合したシンボルテーブル

チェッカーは getSymbolLinks(symbol) 関数を通じて SymbolLinks にアクセスします。この関数は初回アクセス時にリンクオブジェクトを生成してキャッシュします。この遅延初期化パターンはパフォーマンス上きわめて重要です。プログラム内のすべての宣言の型情報を先行して計算するのではなく、実際に参照されたシンボルの型のみを計算できるからです。

補足: チェッカーで型解決をデバッグしていて、あるシンボルの型がどこから来るのか調べたい場合は getTypeOfSymbol()getSymbolLinks(symbol).type を追ってみましょう。初回呼び出し時に解決が走り、以降はキャッシュから返ってきます。

制御フローグラフの構築

バインダーのもう一つの主要な役割がシンボルテーブルの構築に加えた制御フローグラフの構築です。このグラフが TypeScript の型絞り込みを可能にします。if (x !== null) { x.method() } と書いたとき、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()

FlowNodeantecedent ポインターを持ち、直前のフローノードとリンクしています。グラフは逆方向にたどります — 変数の参照箇所から出発し、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 を antecedent とする FlowAssignment ノードを作成し、currentFlow を新しいノードに更新する。
  • ifcurrentFlow を保存し、真偽それぞれのブランチ用の FlowCondition ノードを作成し、各ブランチを処理した後、両ブランチの終点をマージする FlowLabel ジャンクションを作成する。
  • ループLoopLabel フラグを持つ FlowLabel を作成し、ループ本体の処理中に後退辺(back-edge)を繋げる。
  • return / throw / breakcurrentFlowunreachableFlow に設定し、後続のコードをデッドコードとしてマークする。
  • 関数呼び出し:その関数がアサーション(asserts を返す型述語)である可能性がある場合、FlowCall ノードを作成する。

バインダーは複数のフローターゲットを同時に追跡します。currentBreakTargetcurrentContinueTargetcurrentReturnTargetcurrentTrueTargetcurrentFalseTargetcurrentExceptionTarget がそれにあたります。これらによりループ・ラベル付き文・try/catch ブロックを含む複雑な制御フローのエッジを正確に接続します。

型絞り込みとの接続

(第4回で解説する)チェッカーはこのグラフを getFlowTypeOfReference() で消費します。変数の参照箇所から出発してフローグラフを逆方向にたどり、各 FlowConditionFlowAssignment で絞り込みを適用します。各 FlowNodeid フィールドはキャッシュキーとして使用され、特定のフローノードでの絞り込み後の型が一度計算されると、分岐の多いグラフでの指数的な再計算を避けるためにキャッシュされます。

次回予告

バインダーはすべての宣言に Symbol を与え、シンボルをスコープ付きのテーブルへと整理し、AST 全体に制御フローグラフを張り巡らせました。第4回では、コンパイラ最大かつ最も複雑なモジュールである54,000行の型チェッカーに踏み込みます。シンボルから型を解決し、代入可能性を検査し、ジェネリクスを推論する仕組みを見ていきます。さらに制御フローのエッジで型を絞り込み、TypeScript が有用たる所以であるエラーを報告する仕組みも解説します。