KSP:シンボルのテーブルの整理(仮) with AI
- 変数はグローバルスコープしか無いが、将来のローカルスコープも想定し実装している
- シンボルテーブルにネストした検索処理の責務も持たせている
- 現状はスコープのネスト(グローバル変数、ローカル変数)はしない
Q
現状シンボルテール自身が children (スコープを想定した)を持っていて、検索ロジックまで内包しています。 データ構造以上に責務を負っているとも見えます。そこで相談です。
1:ネストを含んだはシンボルテーブルとは独立した検索クラスを立てたほうが良いか? 例えば StackedSymbolTable といった名前で、内部ではスタックでテーブルを持ち、検索機能を提供する
2:いまのままのほうが良い?
A
結論としては、現状の Parent を辿る実装でも破綻はしていませんが、*「SymbolTable がデータ構造 + スコープ解決(名前解決)+ 一意 index 採番」* を抱えていて責務が重めです。将来の拡張(import/using、複数親、可視性、オーバーロード、曖昧性解決、診断情報など)を見込むなら、検索(解決)を分離するほうが保守性が上がります。
1:独立した検索クラスを立てたほうが良いか?
*良い選択*になりやすいです。理由は次の通りです。
- *SRP(単一責務)*:
SymbolTableは「あるスコープの登録簿」として扱い、解決戦略(親探索、優先順位、遮蔽、曖昧性)は別にできる - *解決戦略の差し替え*: 例えば「親を辿らない」「途中で止める」「別のテーブルも見る(using)」などを
Resolver側で変更できる - *テストが容易*: 検索ルールのユニットテストが
Resolverに集中する - *今の
TrySearchByIndexのような O(n) 探索*も、Resolver が index 用のマップを持つ等で最適化しやすい
StackedSymbolTable(厳密には「StackedSymbolResolver」的な役割)が妥当で、内部に IReadOnlyList<ISymbolTable<TSymbol>>(または Stack<ISymbolTable<TSymbol>>)を持って、*「先頭(現在スコープ)から順に探索」*するのが自然です。Parent 連鎖をやめたい場合にも移行しやすくなります。
2:いまのままのほうが良い?
*短期的には現状維持が低コスト*です。次の条件なら「今のまま」で十分なことが多いです。
- 参照関係が常に単一親(木構造)で、将来も変わらない
- 解決ルールが単純(遮蔽のみ)で、今後増えない
- 性能要件が厳しくない(親探索・index 逆引きの O(depth)/O(n) が許容)
SymbolTableを「スコープそのもの」として扱う設計を保ちたい
ただし現状でも「検索ロジックが SymbolTable の基底クラスに固定されている」ため、*言語仕様の都合で解決ルールが増える可能性*があるなら、早めに分離したほうが変更コストが下がります。
現実的な折衷案(推奨)
SymbolTable には *「単一スコープ内の操作」*だけを残し、*親探索を行うのは Resolver*にするのがバランスが良いです。Parent は残しても良いですが、検索は Resolver を正規ルートにすると責務が整理されます。
以下は最小の例です(検索を分離し、ネスト解決をスタックで提供)。
using System;
using System.Collections.Generic;
namespace KSPCompiler.Shared.Domain.Compilation.Symbols;
public interface ISymbolResolver<TSymbol> where TSymbol : SymbolBase
{
bool TryResolveByName(SymbolName name, out TSymbol result);
bool TryResolveByIndex(UniqueSymbolIndex index, out TSymbol result);
bool TryResolveIndexByName(SymbolName name, out UniqueSymbolIndex result);
}
/// <summary>
/// テーブル群(現在スコープが先頭)を順に探索して解決する。
/// </summary>
public sealed class StackedSymbolResolver<TSymbol> : ISymbolResolver<TSymbol>
where TSymbol : SymbolBase
{
private readonly IReadOnlyList<ISymbolTable<TSymbol>> _stack;
public StackedSymbolResolver(IReadOnlyList<ISymbolTable<TSymbol>> stack)
=> _stack = stack ?? throw new ArgumentNullException(nameof(stack));
public bool TryResolveByName(SymbolName name, out TSymbol result)
{
for (var i = 0; i < _stack.Count; i++)
{
if (_stack[i].TrySearchByName(name, out result, enableSearchParent: false))
return true;
}
result = default!;
return false;
}
public bool TryResolveByIndex(UniqueSymbolIndex index, out TSymbol result)
{
for (var i = 0; i < _stack.Count; i++)
{
if (_stack[i].TrySearchByIndex(index, out result, enableSearchParent: false))
return true;
}
result = default!;
return false;
}
public bool TryResolveIndexByName(SymbolName name, out UniqueSymbolIndex result)
{
for (var i = 0; i < _stack.Count; i++)
{
if (_stack[i].TrySearchIndexByName(name, out result, enableSearchParent: false))
return true;
}
result = UniqueSymbolIndex.Zero;
return false;
}
}この形にしておくと、将来 UsingSymbolResolver(別テーブル混在)や「曖昧性検出付き Resolver」などを追加しても、SymbolTable 自体はデータ構造として安定させられます。
Q
例えば意味解析フェーズで関数内の評価をする場合、関数を評価するクラスがシンボルうテーブル(関数ローカル)を新規に作成し既存のグローバルスコープのテーブルと組み合わせて評価クラス内で保持する、というイメージですが合っていますか?
A
概ね合っています。意味解析(名前解決/型解決)で「関数本体を解析する瞬間」に、その関数専用のローカルスコープ用 SymbolTable を新規作成し、既存の外側(グローバル、もしくはモジュール/クラス等)のスコープ列と組み合わせた「解決器(Resolver)」を評価(解析)クラス側で保持して参照する、という形が自然です。
ポイントは「`SymbolTable` は各スコープの入れ物」、「`Resolver` が複数スコープをどう探索するか(ローカル優先→外側へ)」を担当する、の分離です。関数解析では次のような関係になります。
- 関数解析開始時:
localTable = new FunctionLocalSymbolTable(parent: null)のように「ローカルの登録先」を作るresolver = new StackedSymbolResolver([localTable, globalTable, ...])のように「探索順」を作る(先頭ほど内側)
- 解析中:
- 変数宣言などは
localTable.Add(...)(登録はローカルへ) - 参照解決は
resolver.TryResolveByName(...)(探索はローカル→外側)
- 変数宣言などは
- 関数解析終了時:
localTableは破棄してよい(永続化が必要なら別途 AST/IR に紐付け)
以下の最小イメージです。
Shared/Shared/Domain/Compilation/Symbols/StackedSymbolResolver.cs
using System;
using System.Collections.Generic;
namespace KSPCompiler.Shared.Domain.Compilation.Symbols;
public interface ISymbolResolver<TSymbol> where TSymbol : SymbolBase
{
bool TryResolveByName(SymbolName name, out TSymbol result);
bool TryResolveByIndex(UniqueSymbolIndex index, out TSymbol result);
bool TryResolveIndexByName(SymbolName name, out UniqueSymbolIndex result);
}
/// <summary>
/// 複数スコープ(先頭ほど内側)を順に探索して解決する。
/// </summary>
public sealed class StackedSymbolResolver<TSymbol> : ISymbolResolver<TSymbol>
where TSymbol : SymbolBase
{
private readonly IReadOnlyList<ISymbolTable<TSymbol>> _scopes;
public StackedSymbolResolver(IReadOnlyList<ISymbolTable<TSymbol>> scopes)
=> _scopes = scopes ?? throw new ArgumentNullException(nameof(scopes));
public bool TryResolveByName(SymbolName name, out TSymbol result)
{
for (var i = 0; i < _scopes.Count; i++)
{
if (_scopes[i].TrySearchByName(name, out result, enableSearchParent: false))
return true;
}
result = default!;
return false;
}
public bool TryResolveByIndex(UniqueSymbolIndex index, out TSymbol result)
{
for (var i = 0; i < _scopes.Count; i++)
{
if (_scopes[i].TrySearchByIndex(index, out result, enableSearchParent: false))
return true;
}
result = default!;
return false;
}
public bool TryResolveIndexByName(SymbolName name, out UniqueSymbolIndex result)
{
for (var i = 0; i < _scopes.Count; i++)
{
if (_scopes[i].TrySearchIndexByName(name, out result, enableSearchParent: false))
return true;
}
result = UniqueSymbolIndex.Zero;
return false;
}
}Shared/Shared/Domain/Compilation/Analysis/FunctionBodyAnalyzer.cs
using System.Collections.Generic;
using KSPCompiler.Shared.Domain.Compilation.Symbols;
namespace KSPCompiler.Shared.Domain.Compilation.Analysis;
/// <summary>
/// 関数本体の意味解析(例)。
/// 登録はローカルテーブル、参照解決は resolver 経由で行う。
/// </summary>
public sealed class FunctionBodyAnalyzer
{
private readonly ISymbolTable<SymbolBase> _global;
private ISymbolTable<SymbolBase>? _local;
private ISymbolResolver<SymbolBase>? _resolver;
public FunctionBodyAnalyzer(ISymbolTable<SymbolBase> global)
=> _global = global;
public void BeginFunctionScope()
{
// ローカル(関数スコープ)用の表を新規作成
_local = new SimpleSymbolTable();
// 解決順: ローカル優先 → グローバル
_resolver = new StackedSymbolResolver<SymbolBase>(
new List<ISymbolTable<SymbolBase>> { _local, _global }
);
}
public bool DeclareLocal(SymbolBase symbol)
=> _local!.Add(symbol);
public bool TryResolve(SymbolName name, out SymbolBase symbol)
=> _resolver!.TryResolveByName(name, out symbol);
private sealed class SimpleSymbolTable : SymbolTable<SymbolBase> { }
}この構成だと、`SymbolTable` 側は「1 スコープの格納と操作」に寄せられ、関数解析側は「どのスコープ列で解決するか」を明示的に管理できます。
Q
言語サーバーとのデータのやり取りは各種シンボルのテーブルを用いて実装しています。 この場合、関数のローカル変数のテーブルを意味解析フェーズでの評価クラスで評価はできたとて、意味解析フェーズ終了とともに評価クラス内で生成したローカルな変数情報は消えていまいます。現状グローバルな変数テーブルが唯一フェーズ関係なく保持し続けているインスタンスとなります。
A
評価クラス(意味解析のワーカー)にローカル変数テーブルを閉じ込めたままだと、フェーズ終了で破棄されるのは自然です。言語サーバーが `SymbolTable` 群を直接参照する設計なら、**「解析結果としてのシンボル情報」を保持する長寿命オブジェクト**にローカルテーブル(またはそのスナップショット)を移す必要があります。
実装上は次のどれかになります。
- **Compilation\/SemanticModel**(解析結果コンテナ)に「関数 → 変数テーブル」を保持する(推奨)
- `FunctionSymbol` 自体が「自分のローカル変数テーブル」を保持する(関数宣言シンボルにぶら下げる)
- AST ノードに直接保持する(可変状態が混ざりやすく非推奨寄りだが、既に `SymbolTable` ベースで LSP を組んでいるなら “参照” を持たせるだけに留める、などで妥協は可能)
ポイントは、**シンボルテーブルを「作業用(解析中)」と「成果物(LSP 用)」で分ける**ことです。現状の `VariableSymbolTable` は可変で `uniqueIndexGenerator` も内包しているので、そのまま AST に埋め込むより、**Compilation に保持**するのが責務的に安全です。
以下は最小構成で、意味解析中に作った関数ローカル変数テーブルを `Compilation` に登録して残す形です(評価クラスは最後に `Register` して破棄されても問題なし)。
Shared/Shared/Domain/Compilation/CompilationResult.cs
using System;
using System.Collections.Generic;
using KSPCompiler.Shared.Domain.Compilation.Symbols;
namespace KSPCompiler.Shared.Domain.Compilation;
public sealed class CompilationResult
{
private readonly Dictionary<UniqueSymbolIndex, IVariableSymbolTable> _functionLocals
= new();
public IVariableSymbolTable GlobalVariables { get; }
public CompilationResult(IVariableSymbolTable globalVariables)
=> GlobalVariables = globalVariables ?? throw new ArgumentNullException(nameof(globalVariables));
public void RegisterFunctionLocals(UniqueSymbolIndex functionIndex, IVariableSymbolTable locals)
{
if (functionIndex == UniqueSymbolIndex.Zero || functionIndex == UniqueSymbolIndex.Null)
throw new ArgumentException("functionIndex is invalid.", nameof(functionIndex));
_functionLocals[functionIndex] = locals ?? throw new ArgumentNullException(nameof(locals));
}
public bool TryGetFunctionLocals(UniqueSymbolIndex functionIndex, out IVariableSymbolTable locals)
=> _functionLocals.TryGetValue(functionIndex, out locals!);
}Shared/Shared/Domain/Compilation/Analysis/FunctionBodyAnalyzer.cs
using KSPCompiler.Shared.Domain.Compilation.Symbols;
namespace KSPCompiler.Shared.Domain.Compilation.Analysis;
public sealed class FunctionBodyAnalyzer
{
private readonly CompilationResult _result;
private readonly UniqueSymbolIndex _functionIndex;
private VariableSymbolTable? _locals;
public FunctionBodyAnalyzer(CompilationResult result, UniqueSymbolIndex functionIndex)
{
_result = result;
_functionIndex = functionIndex;
}
public void Begin()
{
// 関数ローカルは新規作成(親に global を持たせるならここで渡す)
_locals = new VariableSymbolTable(parent: _result.GlobalVariables);
}
public void Declare(VariableSymbol symbol)
=> _locals!.Add(symbol);
public void End()
{
// LSP で必要な期間生存させるため、解析結果コンテナへ退避
_result.RegisterFunctionLocals(_functionIndex, _locals!);
_locals = null;
}
}この形にすると、
- 評価クラスは短命で良い(フェーズ終了で破棄 OK)
- グローバル以外の「関数ローカル」も `CompilationResult` が保持し続ける
- 言語サーバーは `CompilationResult` から `TryGetFunctionLocals()` で取得してアウトライン等に使える
という整理になります。