このページの目次
記号表とスコープ
コンパイラの「連絡先」:各識別子をその定義に関連付ける——字句スコープのスタック参照からクロージャキャプチャのマーキング、モジュールインポートからジェネリック単一化まで、すべての意味解析はこのテーブルから始まる。
概要
意味解析の最初のステップは型チェックではなく、名前解決(name resolution)——ソースコード内の各識別子(変数名、関数名、型名)をその定義に関連付けることである。記号表(symbol table)はこれを行うためのデータ構造である。それは単純に見える(名前→情報のマッピングを保存するだけ)が、スコープルールが加わると、記号表の設計はコンパイラの正しさや効率性に直接影響する——クロージャのキャプチャ、モジュールのインポート、ジェネリックの特殊化、オーバーロード解決など、それぞれが記号表に対して異なる操作要件を課す。
記号表の基本操作
あらゆる記号表は、以下の2つのコア操作を効率的にサポートしなければならない:
| 操作 | 意味 |
|---|---|
insert(name, info) | 現在のスコープに名前を登録する |
lookup(name) | 現在のスコープから外側へ向かってその名前の定義を検索する |
他にも以下がある:
enter_scope()/exit_scope():字句スコープへ入る/出るlookup_current(name):現在のスコープのみで検索する(重複定義の検出用)
計算量が極めて重要である。insert と lookup はコンパイラの名前解決フェーズで大量に呼び出されるため、O(1) またはそれに近いものである必要がある。
スタック型記号表:字句スコープのネイティブなマッピング
ソースコードのスコープは本質的にネストされている(波括弧が波括弧を囲む)ため、スタック型記号表で直接対応できる:
struct Scope {
symbols: HashMap<String, SymbolInfo>, ← 現在のスコープの名前
parent: u32, ← 外側のスコープのインデックス
}
symbol_table: Vec<Scope> ← スタック、現在スコープがスタックのトップ
enter_scope(): 新しい Scope {parent: 現在のスタックトップのインデックス} をプッシュ
exit_scope(): ポップ
lookup(name):
scope = スタックトップ
while scope != nil:
if name in scope.symbols:
return scope.symbols[name]
scope = scope.parent ← parent チェーンをたどって外側へ検索
return NOT_FOUND
insert:現在スコープ(スタックトップ)にのみ書き込む。lookup:スタックトップからparentチェーンをたどって外側へ検索し、最初にヒットしたものが最も近い定義となる(「内側が外側をシャドーイングする」という字句スコープのルールに合致する)。- 計算量:
insertは O(1);lookupは O(depth)、depth は通常定数(ネストは深くなりすぎない)。
これが大多数のコンパイラの実装方法である——Clang、Rust の一部のパス、C/C++ コンパイラはすべてこのパターンを使用している。
なぜ単一のグローバルハッシュテーブルを使わないのか
単一のハッシュテーブル HashMap<String, Vec<SymbolInfo>> でも可能だが、exit_scope() の際に、そのスコープで挿入されたすべてのエントリを走査して削除する必要がある——スタック型のように Scope をそのままポップするほど清潔ではない。
モジュールと名前空間:2次検索
言語にモジュール/名前空間がある場合(「別のファイルからの名前を参照する」)、記号表は「現在ファイル→現在モジュール→インポートされたモジュール」を超えて検索を行う必要がある:
lookup(name, current_module):
result = lookup_local(name) ← 1. 現在スコープチェーン
if result: return result
result = lookup_imported(name, current_module) ← 2. インポートされた他モジュールの公開記号
if result: return result
return lookup_global(name) ← 3. グローバル(組み込み型/関数など)
モジュールシステムは記号表に可視性(visibility)を導入する:
public:インポートする側からその名前が見えるprivate:現在モジュール内でのみ見える
記号表はモジュールインポートを処理する際、インポートされたモジュールの public 記号のみを現在スコープチェーンに挿入する(または「インポートされた記号」リストを別途管理する)。Rust コンパイラは resolve フェーズで use 文のインポート記号を展開し、記号表に格納する。
クロージャキャプチャ:スコープチェーンから「エスケープ」する名前へ
言語にクロージャ(closure)がある場合、クロージャ本体は外側スコープの変数を参照する可能性がある——これらの変数は外側スコープで exit_scope() された後もアクセスされ続ける必要がある:
fn outer():
x = 10
return fn inner(): ← inner は x をキャプチャする
return x + 1 ← x は outer のスコープ内にあるが、inner が戻り値としてエスケープする
記号表がクロージャキャプチャを処理するプロセス:
innerスコープに入ったとき、lookup("x")はスコープチェーンをたどってouter内のxを見つける。xを「クロージャによってキャプチャされた」とマーキングする——これは重要な意味マーキングであり、後続のコード生成時にxはスタック上に割り当てられない(outerが返った後にスタックが回収されるため)。代わりにヒープ割り当てまたは upvalue メカニズムを使用する必要がある。- クロージャの符号情報にキャプチャリストを記録する:
[(x, by_ref_or_by_value)]。
Rust コンパイラは記号解析フェーズで Fn/FnMut/FnOnce のキャプチャ方法を区別する(共有参照/ミュータブル参照/所有権の移動)——これらの決定はすべて記号表のキャプチャ分析に由来する。
オーバーロード解決:lookup が単一の値を返さなくなる
関数オーバーロードがある言語(C++、Java)では、同じ名前が1つのスコープ内に複数の定義(異なる引数型)を持つ可能性がある。この場合、lookup は単一の記号ではなく、オーバーロードセットを返す:
lookup("foo") → [
foo(int, int) → int,
foo(float, float) → float,
foo(string, string) → string,
]
どの関数を選ぶかは、呼び出し時の引数の型に依存する——これはすでに型チェックの範疇である(型システム で説明)。記号表の役割はオーバーロードセットを維持することであり、選択を行うことではない。
ジェネリックと単一化:名前の複数の「化身」
ジェネリック関数 fn identity<T>(x: T) -> T は、Rust/C++ では単一化(monomorphization)される——各型引数 T の実際の値(例:i32、String)に対して独立した機械語が生成される。これは記号表にとって以下を意味する:
- ジェネリック定義自体は1つの符号であり、型引数リストを伴う。
- 各インスタンス化(
identity::<i32>)は新しい記号表エントリを生成する——ただし、その参照情報(キャプチャ、依存関係)はジェネリックテンプレートからコピーされる。 - 記号表は「テンプレートのインスタンス化」をサポートする必要がある——ジェネリック定義の符号情報をコピーし、其中的な型引数を実際の型に置換する。
Rust コンパイラは MIR レベルで単一化を行う(AST レベルではない)ため、記号表は AST→HIR→MIR の異なる段階で異なる粒度を担う。
トレードオフと失敗パターン
- 単層グローバルテーブル:スコープを区別しない → 内側名が外側名をシャドーイングする無効化が機能せず、
lookupは常に最初に挿入された同名の符号を返す → スタック型でなければならない。 lookupが完全なチェーンを辿らない:パフォーマンスのためにparentチェーンをスキップし、グローバルキャッシュを直接参照する実装がある → スコープのシャドーイングが機能しなくなる。- クロージャキャプチャマーキングの漏れ:記号表がキャプチャを検出してもマーキングしない → コード生成がキャプチャ変数にスタック空間を割り当てる → クロージャ呼び出し時に use-after-free が発生する。
- 循環モジュールインポート:A が B をインポートし、B が A をインポートする → 記号表の解析中に無限ループが発生する → 解析フェーズでは「解析中のモジュール」の集合を維持し、循環を検出してエラーとする必要がある。
- 同名だが異なる型:関数と変数が一部の言語では同名になれる(異なる名前空間、例:C の
struct fooとfoo変数) → 記号表は名前空間ごとに分ける必要がある(tag namespace、object namespace、label namespace など)、各名前空間で独立したスタック検索を行う。
参考文献
- Dragon Book: 第2章(記号表)& 第6章(記号表を用いた型チェック)
- Cooper/Torczon: 「Engineering a Compiler」、第4章(記号表とスコーピング)
- Rust コンパイラソースコード:
compiler/rustc_resolve/src/— モジュール/クロージャキャプチャ/マクロ展開を含む、名前解決の産業レベルの実装
Keywords: symbol table, stack-based symbol table, scope, name resolution, lexical scope, shadowing, module, namespace, visibility, public/private, closure capture, upvalue, overload set, overload resolution, monomorphization, generic instantiation, cyclic import, name space