このページの目次
シンボルテーブルとスコープ
コンパイラの「連絡先」: 各識別子をその定義に関連付ける。レキシカルスコープのスタック参照からクロージャのキャプチャマーキング、モジュールインポートからジェネリクスの単一化まで、すべての意味解析はこのテーブルから始まります。
概要
意味解析の最初のステップは型チェックではなく、名前解決 (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 をそのままポップするよりもクリーンではありません。
モジュールと名前空間: 二段階の検索
言語にモジュールや名前空間がある場合(「別のファイルの名前を参照する」場合)、シンボルテーブルは「現在のファイル → 現在のモジュール → インポートされたモジュール」という跨界検索を行う必要があります。
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: シンボルテーブル, スタック型シンボルテーブル, スコープ, 名前解決, レキシカルスコープ, シャドーイング, モジュール, 名前空間, 可視性, public/private, クロージャのキャプチャ, upvalue, オーバーロードセット, オーバーロード解決, 単一化, ジェネリックインスタンス化, 循環インポート, 名前空間