23 評価プロセス(環境とlet文) [オリジナル言語インタプリタを作る]

23 評価プロセス(環境とlet文) [オリジナル言語インタプリタを作る]

環境を使ったlet文による束縛

ここではインタプリタにlet文の対応を追加し変数束縛の機能を実装します。let文に対応するということは、合わせて識別子にも対応しなければなりません。

let a = 1;

この文を評価した後、x という識別子を評価すると 1 となるようにしなければならない。つまり、”a” という識別子と 1 という値の紐づけを何かしらの方法で管理する必要があります。

まずは期待する動作をテストで表現してみましょう。

テストの作成

let文は識別子に対して指定した値を保存します。そのあとに指定された識別子を評価するときは、その名前がすでに値に束縛されているかを確認し、そうであればその値を、そうでなければエラーを返します。

テストにすると以下のようになります。

[TestMethod]
public void TestEvalLetStatement()
{
    var tests = new(string, int)[]
    {
        ("let a = 1; a;", 1),
        ("let a = 1 + 2 * 3; a;", 7),
        ("let a = 1; let b = a; b;", 1),
        ("let a = 1; let b = 2; let c = a + b; c;", 3),
    };

    foreach (var (input, expected) in tests)
    {
        var evaluated = this._TestEval(input);
        this._TestIntegerObject(evaluated, expected);
    }
}

さらに未定義の識別子を評価した場合にはエラーを返すべきなのでそれもテストケースに追加します。

[TestMethod]
public void TestErrorHandling()
{
    var tests = new(string, string)[]
    {
        // ..
        ("foo", "識別子が見つかりません。: foo"),
    };
    // ..
}

このテストを通すためには定義した識別子と値の組み合わせを保存できるようにする必要がある。そのためには「環境」と呼ばれるものが必要になります。

環境(変数テーブル)の定義

環境 とは名前に関連付けられた値を記録しておくためのものです。変数テーブルと呼ばれることもあります。

環境は名前(文字列)と値の組み合わせを管理します。文字列に対応する値は常に1つしかありません。したがってハッシュテーブルのようなデータ構造が環境の本質です。

C# で定義するのであれば Dictionaly を使うことになるでしょう。ただし直接使うのではなく薄いラッパーとして定義します。

Objects/Enviroment.cs

using System.Collections.Generic;

namespace Gorilla.Objects
{
    public class Enviroment
    {
        public Dictionary<string, IObject> Store { get; set; } 
            = new Dictionary<string, IObject>();

        public (IObject, bool) Get(string name)
        {
            var ok = this.Store.TryGetValue(name, out var value);
            return (value, ok);
        }

        public IObject Set(string name, IObject value)
        {
            this.Store.Add(name, value);
            return value;
        }
    }
}

Get() Set() で環境への参照処理と登録処理を用意しています。このようなラッパーとして定義する理由は関数呼び出しの実装でよくわかります。

実装

環境の準備が完了しました。ではどのようにして Eval() から参照するのでしょうか。今現在のコンテキストに対応する環境を手に入れられるように、Eval() の引数で引き回します。

Evaluating/Evaluator.cs

public class Evaluator
{
    // ..
    public IObject Eval(INode node, Enviroment enviroment)
    {
        // ..
    }
    // ..
}

これは非常に大胆な変更です。この変更を行うとシグネチャが変わっているため当然コンパイルが通らなくなります。そして同様にすべての評価用関数 EvalXxx() に対して同様の変更を施します。

それぞれが呼び出されるときには、引数で受けとった環境を引き回すようにします。この変更は対応箇所が多いですが単純で地味な作業です。それぞれ変更して、どこからでも環境を参照できるようにしてください。

最後に一番最初に呼び出される Eval() に対して渡す環境を準備すれば再びコンパイルが通るようになります。Repl.Start() と _TestEval() を以下のように変更します。

private IObject _TestEval(string input)
{
    // ..
    var enviroment = new Enviroment();
    return evaluator.Eval(root, enviroment);
}

Repl.cs

using Gorilla.Objects;
// ..
public class Repl
{
    // ..
    public void Start()
    {
        var enviroment = new Enviroment();
        while (true)
        {
            // ..
            var evaluated = evaluator.Eval(root, enviroment);
            // ..
        }
    }
}

ループの中で環境を初期化すると、1文実行するたびに新しい環境になってしまい、先に定義した識別子が次の文で参照できなくなるので最初に環境を定義して使いまわすようにします。

これでコンパイルも、今まで通っていたテストも再び動きます。しかしまだこの環境を使ったlet文の評価がありません。早速実装しましょう。

まずは Eval() にlet文の条件分岐を追加します。

public class Evaluator
{
    // ..
    public IObject Eval(INode node, Enviroment enviroment)
    {
        switch (node)
        {
            // ..
            case LetStatement letStatement:
                var letValue = this.Eval(letStatement.Value, enviroment);
                if (this.IsError(letValue)) return letValue;
                enviroment.Set(letStatement.Name.Value, letValue);
                break;
            // ..
        }
        // ..
    }
    // ..
}

let文の評価時に値を評価し、エラー出なければ名前と値を環境に登録して管理しています。

環境に登録した値を参照するには識別子を評価します。

public class Evaluator
{
    // ..
    public IObject Eval(INode node, Enviroment enviroment)
    {
        switch (node)
        {
            // ..
            case Identifier identifier:
                return this.EvalIdentifier(identifier, enviroment);
            // ..
        }
        // ..
    }
    // ..
    public IObject EvalIdentifier(Identifier identifier, Enviroment enviroment)
    {
        var (value, ok) = enviroment.Get(identifier.Value);
        if (ok) return value;
        return new Error($"識別子が見つかりません。: {identifier.Value}");
    }
}

識別子の評価も簡単です。環境を参照し、名前に紐づく値があればそれを返します。存在しない場合は未定義の識別子なのでエラーとして扱います。

これでどうでしょう。すべてのテストが通るようになりました。

まとめ

ここまでのソース

mntm0/Gorilla at 055b1929f21d7c66d86e419f101da143c2e91629

今回の実装で環境を管理することで名前に紐づく値を参照したり、定義したりできるようになりました。これはいわばグローバル変数の専用テーブルです。まだ完全ではありません。次回の関数呼び出し式の実装でさらに拡張することになります。

しかしそれでもいっぱしのプログラミング言語として動かせるようになっています。

Hello Gorilla Script!
>> let a = 10;
>> let b = 5;
>> let c = a * b;
>> if (c < 100) { c } else { 0 }
50

素晴らしい…。

さて次回は関数の呼び出し式を評価できるようにします。これがひとまずは最後の機能です。この関数呼び出し式が評価できるようになると完成です。

以上。

開発いろいろカテゴリの最新記事