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()` で取得してアウトライン等に使える

という整理になります。