16 構文解析器の拡張(関数呼び出し式) [オリジナル言語インタプリタを作る]

16 構文解析器の拡張(関数呼び出し式) [オリジナル言語インタプリタを作る]

関数の呼び出し式

関数の呼び出し式を解析できるようにします。以下のような使い方ができます。

let result = add(1, 2);

式なので値を返せます。

識別子の後ろに引数指定の () がある、というのが構文規則でしょうか。実はそうではありません。あらゆる式に対して関数呼び出しを行えます。

どういうことでしょうか。たとえば関数リテラルを用いた即時呼び出しも有効です。

fn (a, b) { a + b }(1, 2);

他にもある関数が関数リテラルを戻り値として返す場合、それを呼び出すこともできます。

myfn()();

したがって、関数の呼び出し式の構文規則は以下のようになります。

<expression>(<expression>, .. , <expression>)

新しく出てくるトークンはなく、式と()とカンマのみで構成されます。呼び出す関数については、識別子であろうとリテラルであろうと、あるいは関数の呼び出し式であろうと、関数として評価される式であれば何でもよいということです。

ASTの定義

Ast/Expressions/CallExpression.cs

using Gorilla.Lexing;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace Gorilla.Ast.Expressions
{
    public class CallExpression : IExpression
    {
        public Token Token { get; set; }
        public IExpression Function { get; set; }
        public List<IExpression> Arguments { get; set; }

        public string ToCode()
        {
            var args = this.Arguments.Select(a => a.ToCode());
            var builder = new StringBuilder();
            builder.Append(this.Function.ToCode());
            builder.Append("(");
            builder.Append(string.Join(", ", args));
            builder.Append(")");
            return builder.ToString();
        }

        public string TokenLiteral() => this.Token.Literal;
    }
}

関数の呼び出し式 CallExpression を定義しています。呼び出される関数として評価される式(Function)と引数のリストを保持します。引数も当然式となります。

これだけです。難しい点はありません。

テストの作成

次にテストを作成しましょう。

[TestMethod]
public void TestCallExpression()
{
    var input = "add(1, 2 * 3, 4 + 5);";
    var lexer = new Lexer(input);
    var parser = new Parser(lexer);
    var root = parser.ParseProgram();
    this._CheckParserErrors(parser);

    Assert.AreEqual(
        root.Statements.Count, 1,
        "Root.Statementsの数が間違っています。"
    );

    var statement = root.Statements[0] as ExpressionStatement;
    if (statement == null)
    {
        Assert.Fail("statement が ExpressionStatement ではありません。");
    }

    var expression = statement.Expression as CallExpression;
    if (expression == null)
    {
        Assert.Fail("expression が CallExpression ではありません。");
    }

    this._TestIdentifier(expression.Function, "add");

    Assert.AreEqual(
        expression.Arguments.Count, 3,
        "関数リテラルの引数の数が間違っています。"
    );

    this._TestLiteralExpression(expression.Arguments[0], 1);
    this._TestInfixExpression(expression.Arguments[1], 2, "*", 3);
    this._TestInfixExpression(expression.Arguments[2], 4, "+", 5);
}

テストもこれまでに作成したテスト内容とほとんど同じです。

テストは当然失敗します。

テスト名:    TestCallExpression
テストの完全名:    UnitTestProject.ParserTest.TestCallExpression
テスト ソース:    C:\work\cs\Gorilla\UnitTestProject\ParserTest.cs : 行 571
テスト成果:    失敗
テスト継続期間:    0:00:00.0053344

結果  のスタック トレース:    
at UnitTestProject.ParserTest._CheckParserErrors(Parser parser) in C:\work\cs\Gorilla\UnitTestProject\ParserTest.cs:line 100
   at UnitTestProject.ParserTest.TestCallExpression() in C:\work\cs\Gorilla\UnitTestProject\ParserTest.cs:line 577
結果  のメッセージ:    Assert.Fail failed. 
COMMA ではなく RPAREN が来なければなりません。
COMMA に関連付けられた Prefix Parse Function が存在しません。
COMMA に関連付けられた Prefix Parse Function が存在しません。
RPAREN に関連付けられた Prefix Parse Function が存在しません。

解析関数の実装の前に

さっそく実装 .. と行きたいところですが、どこに何を実装すればよいでしょう? if式や関数リテラルの解析と同じように PrefixParseFns に用意した解析関数を追加してとはいきません。

なぜなら今回は新しく登場するトークンが存在しないからです。たとえば add(1, 2); という式について、登場するのは識別子と括弧、カンマ、引数の式くらいです。

どうすればよいでしょうか。まずはこのテストが失敗する理由を考えてみます。

式の解析関数の内容をもう一度確認して動作を追ってみましょう。

public IExpression ParseExpression(Precedence precedence)
{
    // 現在のトークンは識別子add
    this.PrefixParseFns.TryGetValue(this.CurrentToken.Type, out var prefix);
    if (prefix == null)
    {
        this.AddPrefixParseFnError(this.CurrentToken.Type);
        return null;
    }

    // 識別子の解析関数が呼ばれる
    var leftExpression = prefix();

    // 次のトークンは ( で優先順位は未定義の為LOWEST
    // precedence=LOWEST, this.NextPrecedence=LOWEST
    // したがってこのループ内の処理は実行されない
    while (this.NextToken.Type != TokenType.SEMICOLON
        && precedence < this.NextPrecedence)
    {
        this.InfixParseFns.TryGetValue(this.NextToken.Type, out var infix);
        if (infix == null)
        {
            return leftExpression;
        }

        this.ReadToken();
        leftExpression = infix(leftExpression);
    }

    // したがって識別子のみを返すことになる
    return leftExpression;
}

初回に呼び出される ParseExpression() の中身はこのような流れになります
。add識別子を解析し、以降の処理は行われず終わります。つまり add(1, 2); を1つの式ではなくadd識別子単体の式として解析してしまっているわけです。

そして当該エラーについては、次の式の解析で ( があるためグループ式として解析しようとし、その中でカンマが出てきてさあ大変といったエラーです。

ではどのようにするべきか。ParseExpression() が呼び出し式としての解析結果 CallExpression を返さなければなりません。しかし特別なトークンが登場しないですし、識別子以外にもすべての式が呼び出し式に含まれます。つまり、PrefixParseFns に対してごにょごにょしてもうまくいきません。むしろ、PrefixParseFn の処理はこのままでなければなりません。

つまり、PrefixParseFns で解析された式と ( の後ろの内容を1つの式として解析しなければなりません。前の式と後ろの引数の にあるのは “(“です。

したがって “(” LPAREN に関連する中置解析関数を定義するとうまくいきます。演算子でもない ( について中置解析関数というのは一見わかりづらいですが、コードを追えば分ります。

もう一度式の解析関数を見てみます。

public IExpression ParseExpression(Precedence precedence)
{
    this.PrefixParseFns.TryGetValue(this.CurrentToken.Type, out var prefix);
    if (prefix == null)
    {
        this.AddPrefixParseFnError(this.CurrentToken.Type);
        return null;
    }

    // 識別子の解析関数が呼ばれる
    var leftExpression = prefix();

    // この中でうまくCallExpresionを生成する
    while (this.NextToken.Type != TokenType.SEMICOLON
        && precedence < this.NextPrecedence)
    {
        // ( に対応する解析関数を呼び出せるようにする
        this.InfixParseFns.TryGetValue(this.NextToken.Type, out var infix);
        if (infix == null)
        {
            return leftExpression;
        }
        this.ReadToken();

        // 引数部分を解析して CallExpression を返す
        leftExpression = infix(leftExpression);
    }

    // ここで CallExpression を返す
    return leftExpression;
}

この処理では式の呼び出しについて、CallExpression を返さなければなりません。

また呼び出される関数については、識別子の場合以外にもすべての式が有効です。なので PrefixParseFns で解析される式はこのまま利用します。

そしてwhile文の中で ( に関連する中置解析関数を呼び出すことで CallExpression を生成します。

ParseCallExpression()

では実装を見てみます。

public class Parser
{
    // ..
    public IExpression ParseCallExpression(IExpression fn)
    {
        var expression = new CallExpression()
        {
            Token = this.CurrentToken,
            Function = fn,
            Arguments = this.ParseCallArguments(),
        };

        return expression;
    }

    public List<IExpression> ParseCallArguments()
    {
        var args = new List<IExpression>();

        // ( を読み飛ばす
        this.ReadToken();

        // 引数なしの場合
        if (this.CurrentToken.Type == TokenType.RPAREN) return args;

        // 引数ありの場合は1つ目の引数を解析
        args.Add(this.ParseExpression(Precedence.LOWEST));

        // 2つ目以降の引数があればそれを解析
        while (this.NextToken.Type == TokenType.COMMA)
        {
            // カンマ直前のトークンとカンマトークンを読み飛ばす
            this.ReadToken();
            this.ReadToken();
            args.Add(this.ParseExpression(Precedence.LOWEST));
        }

        // 閉じかっこがなければエラー
        if (!this.ExpectPeek(TokenType.RPAREN)) return null;

        return args;
    }
}

ParseCallExpression() は引数で呼び出される関数を式で受け取ります。これは、PrefixParseFn で解析された式です。

引数部分の解析は別の処理 ParseCallArguments() として切り出しています。この処理は関数リテラルの引数解析と同じような流れです。やることはカンマ区切りで続く限り、式として解析し続けるというものです。

ではこの処理を中置解析関数として関連付けて完了です。

public class Parser
{
    // ..
    public Dictionary<TokenType, Precedence> Precedences { get; set; } =
        new Dictionary<TokenType, Precedence>()
        {
            // ..
            { TokenType.LPAREN, Precedence.CALL },
        };
    // ..
    private void RegisterInfixParseFns()
    {
        // ..
        this.InfixParseFns.Add(TokenType.LPAREN, this.ParseCallExpression);
    }
}

こうすることで、ParseExpression() で ParseCallExpression() がいい感じに呼ばれます。

“(” を中置演算子のように扱うので、優先順位も定義しておきます。これは最上位の優先順位です。つまりに呼び出し式が他の演算子に影響をうけません。

1 + add(2, 3) * 4 という式は 1 + ((add(2, 3)) * 4) というイメージです。呼び出し式はこれ1つで単体の中置演算式です。

優先順位のテストを拡張

さて、一応これで実装は完了したので先ほど作成たテストは通ります。しかし優先順位を設定したのでこれがうまく動くかのテストもしておきます。

TestOperatorPrecedenceParsing() にテストケースをいくつか追加して確認します。

[TestMethod]
public void TestOperatorPrecedenceParsing()
{
    var tests = new[]
    {
        // ..
        ("add(1, 2) + 3 > 4", "((add(1, 2) + 3) > 4)"),
        ("add(x, y, 1, 2*3, 4+5, add(z) )", "add(x, y, 1, (2 * 3), (4 + 5), add(z))"),
        ("add(1 + 2 - 3 * 4 / 5 + 6)", "add((((1 + 2) - ((3 * 4) / 5)) + 6))"),
    };

    // ..
}

テストが通れば、これで完成です。

まとめ

ここまでのソース

https://github.com/mntm0/Gorilla/tree/67c259794860f8fbaa9385812a18223bd2f9c4c0

実はこれで構文解析で実装すべき内容はすべてです。作成中のプログラミング言語 Gorilla の構文としてはこれですべてに対応しています。

本当に最小限の機能のみしかありません。ループすらありません。ほしい機能があれば後から追加する方針です。

構文解析のフェーズが終了となると、あとは作成した抽象構文僕を評価するフェーズに移るべきなのですが、まだ一部の構文解析機能に中途半端な実装で放置している部分があります。次回はそれを先に直します。

それから REPL も字句解析するだけなので、構文解析に対応させます。

以上。

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