21 評価プロセス(if式, return文) [オリジナル言語インタプリタを作る]

21 評価プロセス(if式, return文) [オリジナル言語インタプリタを作る]

条件分岐 if式

これから条件分岐の評価、if式の評価を実装します。if式の例を示します。

if (x > y) {
    x
} else {
    y
}

このようなif式を考えるとき、どのような時にifのブロック文が評価されるでしょうか。条件式(x > y)が true の時なのか、それともtruthlyな値(trueもしくは非Null値)であればよいのか、これは設計上の判断です。この判断は広範囲に影響を与えます。

多くのスクリプト言語に倣い、プログラミング言語 Gorilla ではtruethlyな値である場合にifのブロック分を実行します。

truethly とは null ではなく、かつ false ではない値のことをさします。必ずしも true である必要はありません。0も truethly です。

テストの作成

[TestMethod]
public void TestEvalIfExpression()
{
    var tests = new (string, int?)[]
    {
        ("if (true) { 1 }", 1),
        ("if (false) { 1 }", null),
        ("if (true) { 1 } else { 2 }", 1),
        ("if (false) { 1 } else { 2 }", 2),
        ("if (5) { 1 } else { 2 }", 1),
        ("if (!5) { 1 } else { 2 }", 2),
        ("if (1 < 2) { 1 } else { 2 }", 1),
        ("if (1 > 2) { 1 } else { 2 }", 2),
        ("if (1 > 2) { 1 }", null),
    };

    foreach (var (input, expected) in tests)
    {
        var evaluated = this._TestEval(input);
        if (expected.HasValue)
        {
            this._TestIntegerObject(evaluated, expected.Value);
        }
        else
        {
            this._TestNullObject(evaluated);
        }
    }
}

private void _TestNullObject(object obj)
{
    if (obj) == Evaluator.Null)
    {
        Assert.Fail($"Object が Null ではありません。{obj.GetType()}");
    }
}

このテストはif式の評価結果でNullが評価される可能性があることを示しています。

if (false) { 1 }

このif式は条件式が常にfalseにもかかわらずelse句がありません。このような場合、if式はNullを評価結果として返すことになります。

NullObjectの判定は、型変換をして確認しています。評価器のクラス全体をstaticにしておけばよかったかもしれません。別の参照のNullObjectだったらどうするんだとも思いますが、まあ気が向いたらstaticに直します。ここではこれで良しとします。

実装

では評価の方を実装していきます。if式のASTを思い出してみると、ifの条件式とブロック文からなることがわかります。これを評価できるようにしましょう。

実装だと以下のような感じです。

public class Evaluator
{
    // ..
    public IObject Eval(INode node)
    {
        switch (node)
        {
            // ..
            case BlockStatement blockStatement:
                return this.EvalStatements(blockStatement.Statements);
            case IfExpression ifExpression:
                return this.EvalIfExpression(ifExpression);
        }
        return null;
    }
    // ..
    public IObject EvalIfExpression(IfExpression ifExpression)
    {
        var condition = this.Eval(ifExpression.Condition);

        if (this.IsTruthly(condition))
        {
            return this.EvalStatements(ifExpression.Consequence.Statements);
        }
        else if (ifExpression.Alternative != null)
        {
            return this.EvalStatements(ifExpression.Alternative.Statements);
        }
        return this.Null;
    }

    public bool IsTruthly(IObject obj)
    {
        if (obj == this.True) return true;
        if (obj == this.False) return false;
        if (obj == this.Null) return false;
        return true;
    }
}

これでテストが通るようになります。何をしているのでしょうか。

まず IfExpression について Eval() が呼ばれます。この時 EvalIfExpression() に処理をしてもらうようにします。

truthly 判定

EvalIfExpression() では、条件式を評価して結果を受け取ります。まず注意すべきなのはこの条件式の評価結果は真偽値とは限らないということです。

したがってまずはこの結果が Truthly な値かどうかを判定してやる必要があります。その判定用関数を作っておきましょう。前述のとおり非Null値全てがtruthly判定となります。

ブロック文の評価

条件式の結果が truthly である場合は Consequence を評価します。falsely である場合は、else句 Alternative を評価します。

Alternative については存在しない場合がある点に注意しましょう。その場合は先ほど決めたとおり Null を返せばよいです。

ブロック文 BlockStatemen の評価は既に複数文をまとめて評価する用の処理 EvalStatements() を最初に定義しています。Root直下の文を評価するときの処理です。これを呼び出せば完成です。

これで条件付き電卓になりました。ただの数値を表示するだけのところから大分進化しました。

Hello Gorilla Script!
>> if (5*5+10>35) { 99 } else { 100 }
100
>> if (1+2+3+4+5 > 100) { 1 } else { 0 }
0

return文の評価

続けてreturn文の評価を実装しましょう。retrun文はトップレベルでも関数呼び出しの中でも、if式の中でも使われます。一連の文の評価を中断し、その式部分を評価した値を結果として返します。

以下の例を見てみましょう。

1 + 2;
return 100;
1 + 2 + 3;

このプログラムが評価されるとき、1 + 2 の文が評価され、その次にreturn文が評価され100がこのプログラムの結果として返されます。

続く 1 + 2 + 3 については評価されません。つまりこの部分まで処理がたどりつく前に全体の評価が完了するということです。

これは少し難しいです。ホスト言語によっては例外を投げたり、goto文を使うことで実装することもできます。今回は評価結果をラップするオブジェクトを定義し、これを返すようにします。

こうすることでreturn文で返された値を追跡し、文の評価を中断することができるようになります。

ReturnValue の定義

Objects/ReturnValue.cs

namespace Gorilla.Objects
{
    public class ReturnValue : IObject
    {
        public IObject Value { get; set; }

        public ReturnValue(IObject value) => this.Value = value;
        public string Inspect() => this.Value.Inspect();
        public ObjectType Type() => ObjectType.RETURN_VALUE;
    }
}

Objects/IObject.cs

public enum ObjectType
{
    INTEGER,
    BOOLEAN,
    NULL,
    RETURN_VALUE,
}

見ての通り単に値をラップしたクラスが ReturnValue です。

ObjectType は新しく RETURN_VALUE を定義してこれを設定します。

テストの作成

[TestMethod]
public void TestEvalReturnStatement()
{
    var tests = new (string, int)[]
    {
        ("return 10;", 10),
        ("return 100/10", 10),
        ("return 10; 1234;", 10),
        ("2*3; return 10; 1234;", 10),
    };

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

このテストはreturn文が正しく値を返せるかをテストします。複数文からreturn文で評価した式の値が最終的な評価となっているかを確認しています。

実装

では実装に移ります。

public class Evaluator
{
    // ..
    public IObject Eval(INode node)
    {
        switch (node)
        {
            // ..
            case ReturnStatement returnStatement:
                var value = this.Eval(returnStatement.ReturnValue);
                return new ReturnValue(value);
        }
        return null;
    }

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

            if (result is ReturnValue returnValue)
            {
                return returnValue.Value;
            }
        }
        return result;
    }
    // ..
}

Return文評価のための分岐を追加し、そこで式の評価結果を ReturnValue でラップして返すようにします。

それから、連続した文をループして評価する際に、ReturnValue が返ってくると、ラップされた値を取り出してそれを最終結果として返すようにします。ReturnValue そのものではなくラップされた本来の値の方を返します。これがほしい値でした。

ラップされた値を返した時点でループから抜けるようにすることで、以降の文が評価自体行われなくなります。

これで完了です。新しい評価関数を実装することなく、条件分岐を追加するだけで対応できました。

テストを動かして確認してみましょう。

入れ子になったブロック文

これですべてではありません。この実装だと入れ子になったブロック文において ReturnValue はより長く保持されなければならないことがあります。

以下の例を見てみます。

if (true) {
    if (true) {
        return 10;
    }
    0;
}

このプログラムの評価結果はどうなるでしょうか。10が返ってほしいところですが、今の実装だとすぐに値がアンラップされてしまい、結果として0が最終的に評価されることになります。

このようにブロック文が入れ子になっている場合、一番外側までReturnValueを保持し、外側まで帰ってきたところでようやくアンラップしてその値を返すようにしなければなりません。

したがって何かしらの方法で追跡できるようにしなければなりません。

実装の前に先ほどの例をテストケースとして追加しておきましょう。

[TestMethod]
public void TestEvalReturnStatement()
{
    var tests = new (string, int)[]
    {
        // ..
        (@"if (true) {
                if (true) {
                    return 10;
                }
                0;
            }", 10),
    };
    // ..
}

テストを動かしてみるとやはり、10と期待すべきところが0となってしまっています。

テスト名:    TestEvalReturnStatement
テストの完全名:    UnitTestProject.EvaluatorTest.TestEvalReturnStatement
テスト ソース:    C:\work\cs\Gorilla\UnitTestProject\EvaluatorTest.cs : 行 160
テスト成果:    失敗
テスト継続期間:    0:00:00.0375643

結果  のスタック トレース:    
at UnitTestProject.EvaluatorTest._TestIntegerObject(IObject obj, Int32 expected) in C:\work\cs\Gorilla\UnitTestProject\EvaluatorTest.cs:line 54
   at UnitTestProject.EvaluatorTest.TestEvalReturnStatement() in C:\work\cs\Gorilla\UnitTestProject\EvaluatorTest.cs:line 179
結果  のメッセージ:    Assert.AreEqual failed. Expected:<10>. Actual:<0>.

どのようにこのテストを通すようにするのがよいでしょうか。現在ブロック文の評価とトップレベルの文の評価を、EvalStatement() で共通化していますが、これをそれぞれに特化した関数に切り分けてしまいましょう。

なぜこのようにするかというとルート以下で遭遇する ReturnValue は必ずアンラップする必要があるのに対し、ブロック文では一番外側まで保持するという違いがあるためです。フラグで管理もできるかもしれませんが、このあと関数呼び出しの評価を実装したりすることを考えると切り分けるほうがいいでしょう。

public class Evaluator
{
    // ..
    public IObject Eval(INode node)
    {
        switch (node)
        {
            case Root root:
                return this.EvalRootProgram(root.Statements);
            // ..
    }

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

            if (result is ReturnValue returnValue)
            {
                return returnValue.Value;
            }
        }
        return result;
    }
    // ..
}

もともとあった EvalStatements() を EvalRootProgram() という名前に変えて、トップレベルの文を評価する専用の関数にしてしまします。呼び出していた箇所のそれに合わせて修正しています。

続いてブロック文用の評価関数 EvalBlockStatement() を作成します。

public class Evaluator
{
    // ..
    public IObject Eval(INode node)
    {
        switch (node)
        {
            // ..
            case BlockStatement blockStatement:
                return this.EvalBlockStatement(blockStatement);
            // ..
    }
    // ..
    public IObject EvalBlockStatement(BlockStatement blockStatement)
    {
        IObject result = null;
        foreach (var statement in blockStatement.Statements)
        {
            result = this.Eval(statement);

            if (result.Type() == ObjectType.RETURN_VALUE) return result;
        }
        return result;
    }
    // ..
    public IObject EvalIfExpression(IfExpression ifExpression)
    {
        var condition = this.Eval(ifExpression.Condition);

        if (this.IsTruthly(condition))
        {
            // List<Statement> ではなく BlockStatement を渡す
            return this.EvalBlockStatement(ifExpression.Consequence);
        }
        else if (ifExpression.Alternative != null)
        {
            // List<Statement> ではなく BlockStatement を渡す
            return this.EvalBlockStatement(ifExpression.Alternative);
        }
        return this.Null;
    }
    // ..
}

こちらもブロック文の評価専用の関数 EvalBlockStatement() を定義しました。中身は EvalRootProgram() と似ていますが文の評価結果の値について ObjectType が RETURN_VALUE であればすぐにその値を返して以降の文の評価を打ち切ります。あとはより上の階層で値がアンラップされることでしょう。

また、ブロック文では値のアンラップは行わないため型の変換は不要です。Type() はすべてのオブジェクトが実装しているのでこれを確認するだけで判定は可能です。余計な処理コストを払わずに済みます。

後は EvalBlockStatement() を呼び出して評価できるように、Eval() のブロック文評価時の分岐で呼び出すようにし、EvalIfExpressin() で EvalBlockStatement() を呼び出していた箇所を EvalBlockStatement() の呼び出しに変更して完了です。

これでテストが通るようになりました。ブロック文では値をアンラップしないため,どんどん外側に ReturnValue が受け渡され、最終的に EvalRootProgram() でその値がアンラップされることになります。

まとめ

ここまでのソース

mntm0/Gorilla at 6cde2a4d12de6bafa0a5fdde5cf317a38a67e125

今回はif式とreturn文の実装を行いました。かなりプログラミング言語っぽく動作するようになってきたのではないでしょうか。あと残っているのはlet文、関数呼び出しと、後は何かあったでしょうか。これらの実装に移る前に、エラー処理を次回は追加していきます。

以上。

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