22 評価プロセス(エラー処理) [オリジナル言語インタプリタを作る]

22 評価プロセス(エラー処理) [オリジナル言語インタプリタを作る]

インタプリタのエラー処理

これまでの評価の実装で NullObject を返していたところがいくつかあります。未定義の動作で全て Null を返しているところは本来エラーとしてプログラムで処理できるべきです。そうしないと利用したりあるいはデバッグするのが大変です。

ここで実装しようとしているのはユーザー定義のエラーではなく、内部のエラー処理のことです。実行中に発生しうる誤った演算子の利用、未定義の演算、その他実行時の例外(ゼロ除算など)です。

エラー処理の作りはreturn文とよく似ています。なぜならエラー処理もreturn文と同じく、エラー発生時には以降の処理を中断し、エラー内容を上の呼び出し階層に伝播させる必要があるためです。

エラーオブジェクト

return文で ReturnValue を定義したように、まずはエラーオブジェクトを定義する必要があります。

Objects/Error.cs

namespace Gorilla.Objects
{
    public class Error : IObject
    {
        public string Message { get; set; }
        public Error(string message) => this.Message = message;
        public string Inspect() => this.Message;
        public ObjectType Type() => ObjectType.ERROR_OBJ;
    }
}

Objects/IObject.cs

namespace Gorilla.Objects
{
    // ..
    public enum ObjectType
    {
        // ..
        ERROR_OBJ,
    }
}

Error クラスの実装は非常にシンプルです。エラーメッセージを保持できるプロパティを持ちます。Inspect() ではそのエラーメッセージを返します。

ObjectType は ERROR_OBJ という種類を新しく定義しこれを適応します。

もし字句解析器が行数や列数をトークンに付与する場合、もっと詳細な情報をエラーオブジェクトに保持できるかもしれませんが、ここではシンプルな実装で行きます。

これまでとりあえずで NullObject を返していた箇所全てにこのエラーオブジェクトを返すようにしていきます。

テストの作成

実装前にテストを作成します。

[TestMethod]
public void TestErrorHandling()
{
    var tests = new(string, string)[]
    {
        ("5 + true;", "型のミスマッチ: INTEGER + BOOLEAN"),
        ("5 + true; 5;", "型のミスマッチ: INTEGER + BOOLEAN"),
        ("-true", "未知の演算子: -BOOLEAN"),
        ("true + false", "未知の演算子: BOOLEAN + BOOLEAN"),
        ("if (true) { true * false; }", "未知の演算子: BOOLEAN * BOOLEAN"),
        (@"if (true) {
                if (true) {
                    return false / false;
                }
                0;
            }", "未知の演算子: BOOLEAN / BOOLEAN"),
        ("-true + 100", "未知の演算子: -BOOLEAN"),
    };

    foreach (var (input, expected) in tests)
    {
        var evaluated = this._TestEval(input);
        var error = evaluated as Error;
        if (error == null)
        {
            Assert.Fail($"エラーオブジェクトではありません。({evaluated.GetType()})");
        }

        Assert.AreEqual(error.Message, expected);
    }
}

未定義の演算子による演算がエラーとなることをテストしています。現在はエラーとすべき箇所も Null で返しているので実行結果は以下のようになります。

テスト名:    TestErrorHandling
テストの完全名:    UnitTestProject.EvaluatorTest.TestErrorHandling
テスト ソース:    E:\work\cs\Gorilla\UnitTestProject\EvaluatorTest.cs : 行 184
テスト成果:    失敗
テスト継続時間:    0:00:00.0339496

結果  のスタック トレース:    at UnitTestProject.EvaluatorTest.TestErrorHandling() in E:\work\cs\Gorilla\UnitTestProject\EvaluatorTest.cs:line 207
結果  のメッセージ:    Assert.Fail failed. エラーオブジェクトではありません。(Gorilla.Objects.NullObject)

期待通りのテスト結果です。未定義の演算でもNullで評価されて処理は停止しないのがこのエラーの原因です。

これを通るようにエラー処理を追加します。

エラー処理の実装

まず考えられるのは演算式の評価です。未定義の演算子はすべて実行時エラーとします。

Evaluating/Evaluator.cs

public class Evaluator
{
    // ..
    public IObject EvalPrefixExpression(string op, IObject right)
    {
        switch (op)
        {
            // ..
        }
        return new Error($"未知の演算子: {op}{right.Type()}");
    }
    // ..
    public IObject EvalMinusPrefixOperatorExpression(IObject right)
    {
        if (right.Type() != ObjectType.INTEGER)
            return new Error($"未知の演算子: -{right.Type()}");

        // ..
    }
    // ..
    public IObject EvalInfixExpression(string op, IObject left, IObject right)
    {
        // ..
        switch (op)
        {
            // ..
        }

        if (left.Type() != right.Type())
            return new Error($"型のミスマッチ: {left.Type()} {op} {right.Type()}");

        return new Error($"未知の演算子: {left.Type()} {op} {right.Type()}");
    }
}

NullObjectを返していた箇所について、Errorを返すようにしています。

EvalInfixExpression() では、左右両辺の型が不一致の場合は型ミスマッチのエラーを、それ以外の場合は未知の演算子としてエラー処理します。

この実装でいくつかのテストケースは通るようになりますが、まだブロック文から脱出して評価を停止することはできません。

エラー時の評価停止

エラー時に評価を停止する処理はどこに実装すればよいか、return文の実装を思い出せば分かります。

public IObject EvalRootProgram(List<IStatement> statements)
{
    IObject result = null;
    foreach (var statement in statements)
    {
        result = this.Eval(statement);

        switch (result)
        {
            case ReturnValue returnValue:
                return returnValue.Value;
            case Error _:
                return result;
            default:
                break;
        }
    }
    return result;
}

public IObject EvalBlockStatement(BlockStatement blockStatement)
{
    IObject result = null;
    foreach (var statement in blockStatement.Statements)
    {
        result = this.Eval(statement);

        if (result.Type() == ObjectType.RETURN_VALUE
            || result.Type() == ObjectType.ERROR_OBJ) return result;
    }
    return result;
}

EvalRootProgram()EvalBlockStatement() にそれぞれエラー時の処理を追加しています。評価結果がエラーの場合はすぐにそのエラー値を返して処理を中断します。

これで最後の1つのテストケースを除いてテストは通るようになりました。

最後のテストケースは以下のような内容で失敗します。

テスト名:    TestErrorHandling
テストの完全名:    UnitTestProject.EvaluatorTest.TestErrorHandling
テスト ソース:    E:\work\cs\Gorilla\UnitTestProject\EvaluatorTest.cs : 行 184
テスト成果:    失敗
テスト継続時間:    0:00:00.0378163

結果  のスタック トレース:    at UnitTestProject.EvaluatorTest.TestErrorHandling() in E:\work\cs\Gorilla\UnitTestProject\EvaluatorTest.cs:line 211
結果  のメッセージ:    Assert.AreEqual failed. Expected:<型のミスマッチ: ERROR_OBJ + INTEGER>. Actual:<未知の演算子: -BOOLEAN>.

なぜか ERROR_OBJ + INTEGER を計算しようとしてエラーになっています。

エラーオブジェクトを引き回さない

例えば -true + 100 のような演算式の場合、左辺の -true の評価でエラーオブジェクトが返り、そのあと右辺を評価し、ERROR_OBJ + BOOLEAN の演算でエラーになります。これはエラーオブジェクトを引き回している分余計な処理をしているということです。

エラーオブジェクトが生成された時点で処理を切り上げることで、エラーオブジェクトをコード内で引き回さないようにしてやる必要があります。

演算式以外でも、ifの条件文などいくつかの箇所で同様の処理を実装する必要があります。

public class Evaluator
{
    // ..
    public bool IsError(IObject obj)
    {
        if (obj != null) return obj.Type() == ObjectType.ERROR_OBJ;
        return false;
    }
    // ..
    public IObject Eval(INode node)
    {
        switch (node)
        {
            // ..
            case ReturnStatement returnStatement:
                var value = this.Eval(returnStatement.ReturnValue);
                if (this.IsError(value)) return value;
                return new ReturnValue(value);
            case PrefixExpression prefixExpression:
                var right = this.Eval(prefixExpression.Right);
                return this.EvalPrefixExpression(prefixExpression.Operator, right);
            case InfixExpression infixExpression:
                var ifLeft = this.Eval(infixExpression.Left);
                if (this.IsError(ifLeft)) return ifLeft;
                var ifRight = this.Eval(infixExpression.Left);
                if (this.IsError(ifRight)) return ifRight;
                return this.EvalInfixExpression(
                    infixExpression.Operator,
                    ifLeft,
                    ifRight
                );
            // ..
        }
        return null;
    }
    // ..
    public IObject EvalIfExpression(IfExpression ifExpression)
    {
        var condition = this.Eval(ifExpression.Condition);
        if (this.IsError(condition)) return condition;
        // ..
    }
}

これですべての実装は完了です。テストもすべてクリアします。

まとめ

ここまでのソース

mntm0/Gorilla at 0cb2769714b6a153c24134bf05b0ed4077d1b524

次は変数束縛、let文の対応を行います。

以上。

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