バインダー:シンボルテーブルと制御フローグラフの構築
前提知識
- ›第1回:アーキテクチャとコードベースの全体像
- ›第2回:Scanner、Parser、そして AST
- ›コンパイラにおけるシンボルテーブルとスコープチェーンの理解
- ›制御フロー解析の基本概念への習熟
バインダー:シンボルテーブルと制御フローグラフの構築
パーサーが SourceFile の AST を生成したら、次のフェーズでは各名前に意味を与えます。バインダーは AST を深さ優先でたどり、すべての宣言に対して Symbol オブジェクトを生成し、階層的なシンボルテーブルへと整理し、すべてのノードに親ポインターを設定します。そして最も重要な役割として、TypeScript の型絞り込みを実現する制御フローグラフを構築します。これらの処理はすべて src/compiler/binder.ts — 約 3,900 行 — に収まっています。
バインダーは構文と意味論をつなぐ橋です。チェッカーを理解する前にバインダーを把握しておくことが不可欠です。チェッカーは、バインダーが生成する親ポインター、シンボルテーブル、フローノードがすでに揃っていることを前提として動作するからです。
bindSourceFile とクロージャベースのバインダー
Scanner や Parser と同様に、バインダーもクロージャベースのモジュールパターンを採用しています。createBinder() は SourceFile と CompilerOptions を受け取る関数を返します。内部では数十の 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 が値(コンストラクター)と型(インスタンス型)の両方を生成できるのはこのためです。Value や Type、Namespace といった複合フラグは、個別のフラグのユニオンとして定義されています:
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 の互換性を検証します。たとえば Class と Interface はマージ可能(クラス・インターフェース拡張)ですが、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() |
各 FlowNode は antecedent ポインターを持ち、直前のフローノードとリンクしています。グラフは逆方向にたどります — 変数の参照箇所から出発し、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を新しいノードに更新する。 if文:currentFlowを保存し、真偽それぞれのブランチ用のFlowConditionノードを作成し、各ブランチを処理した後、両ブランチの終点をマージするFlowLabelジャンクションを作成する。- ループ:
LoopLabelフラグを持つFlowLabelを作成し、ループ本体の処理中に後退辺(back-edge)を繋げる。 return/throw/break:currentFlowをunreachableFlowに設定し、後続のコードをデッドコードとしてマークする。- 関数呼び出し:その関数がアサーション(
assertsを返す型述語)である可能性がある場合、FlowCallノードを作成する。
バインダーは複数のフローターゲットを同時に追跡します。currentBreakTarget・currentContinueTarget・currentReturnTarget・currentTrueTarget・currentFalseTarget・currentExceptionTarget がそれにあたります。これらによりループ・ラベル付き文・try/catch ブロックを含む複雑な制御フローのエッジを正確に接続します。
型絞り込みとの接続
(第4回で解説する)チェッカーはこのグラフを getFlowTypeOfReference() で消費します。変数の参照箇所から出発してフローグラフを逆方向にたどり、各 FlowCondition と FlowAssignment で絞り込みを適用します。各 FlowNode の id フィールドはキャッシュキーとして使用され、特定のフローノードでの絞り込み後の型が一度計算されると、分岐の多いグラフでの指数的な再計算を避けるためにキャッシュされます。
次回予告
バインダーはすべての宣言に Symbol を与え、シンボルをスコープ付きのテーブルへと整理し、AST 全体に制御フローグラフを張り巡らせました。第4回では、コンパイラ最大かつ最も複雑なモジュールである54,000行の型チェッカーに踏み込みます。シンボルから型を解決し、代入可能性を検査し、ジェネリクスを推論する仕組みを見ていきます。さらに制御フローのエッジで型を絞り込み、TypeScript が有用たる所以であるエラーを報告する仕組みも解説します。