Javaで簡単な文法の独自言語を実装したかったので、構文解析器を生成してくれるパーサジェネレータ、つまりyaccのようなものがないか調べた記録。別に誰向けの記事ということはなく、単なるメモ。ぐぐってみたところ、

の3つが見つかった。最初、JavaCC を触ってみたのだが、どうも yacc っぽい定義方法ではなかったので辞めて、BYACC/J が YACC-compatible だと謳っていたのでそちらを使ってみることにした。コードが sourceforge においてあるので古いアプリケーションっぽくて不安があり後回しにしていたのだが、なんと驚くべきことに動くようです。

典型的な四則演算をするサンプルがホームページに書いてあって、以下のようなものだった

%{
import java.lang.Math;
import java.io.*;
import java.util.StringTokenizer;
%}

/* YACC Declarations */
%token NUM
%left '-' '+'
%left '*' '/'
%left NEG /* negation--unary minus */
%right '^' /* exponentiation */

/* Grammar follows */
%%
input: /* empty string */
 | input line
 ;

line: '\n'
 | exp '\n' { System.out.println(" " + $1.dval + " "); }
 ;

exp: NUM { $$ = $1; }
 | exp '+' exp { $$ = new ParserVal($1.dval + $3.dval); }
 | exp '-' exp { $$ = new ParserVal($1.dval - $3.dval); }
 | exp '*' exp { $$ = new ParserVal($1.dval * $3.dval); }
 | exp '/' exp { $$ = new ParserVal($1.dval / $3.dval); }
 | '-' exp %prec NEG { $$ = new ParserVal(-$2.dval); }
 | exp '^' exp { $$ = new ParserVal(Math.pow($1.dval, $3.dval)); }
 | '(' exp ')' { $$ = $2; }
 ;
%%

String ins;
StringTokenizer st;

void yyerror(String s)
{
    System.out.println("par:"+s);
}

boolean newline;
int yylex()
{
    String s;
    int tok;
    Double d;
    if (!st.hasMoreTokens()) {
        if (!newline) {
            newline=true;
            return '\n'; //So we look like classic YACC example
        } else {
            return 0;
        }
    }
    s = st.nextToken();
    System.out.println("tok:"+s);
    try {
        d = Double.valueOf(s);/*this may fail*/
        yylval = new ParserVal(d.doubleValue());
        tok = NUM;
    } catch (Exception e) {
        tok = s.charAt(0);/*if not float, return char*/
    }
    return tok;
}

void dotest()
{
    BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
    System.out.println("BYACC/J Calculator Demo");
    while (true) {
        System.out.print("expression:");
        try {
            ins = in.readLine();
        } catch (Exception e) {
        }
        st = new StringTokenizer(ins);
        newline=false;
        yyparse();
    }
}

public static void main(String args[])
{
    Parser par = new Parser(false);
    par.dotest();
}


使い方

このコードを

yacc -J calc.y

として喰わせると、Parser.javaParserVal.javaが生成されるので、javac でコンパイルすれば良い。

javac Parser.java ParserVal.java

サンプルコードには main コードも書いてあって、java コマンドで実行すると標準入力を受け付けて、四則演算を確認できた。

java Parser ParserVal
> 33 + 5
tok:33
tok:+
tok:5
38.0
> 33+5
par:syntax error
par:stack underflow. aborting...

スペースがないとダメなのは、yylex メソッドで StringTokenizer を使って字句解析を行っているため。 


.y の構成

  • YACCにおけるC宣言部に #include などを書く代わりに Java らしく import を記述する(以降、Java宣言部)
  • Yacc宣言部に yacc と同様にトークンや優先順位の記述を書く
  • 文法規則部に yacc と同様に BNF で文法を記述する
  • YACCにおける追加Cプログラム部に、 Java コードを記述する (以降、追加Javaプログラム部)

たしかにほとんど yacc と同じで compatible 感あった。


Yacc宣言部

/* YACC Declarations */
%token NUM
%left '-' '+'
%left '*' '/'
%left NEG /* negation--unary minus */
%right '^' /* exponentiation */

NUMというトークンを定義して、これによって文法規則部および、追加Javaプログラム部で NUM 定数を利用できるようになる。 %left で優先順位を記述していて、- と + は同列だが、* と / がそれより優先され、さらに NEG が優先されるようになる。


文法規則部

exp: NUM { $$ = $1; }
 | exp '+' exp { $$ = new ParserVal($1.dval + $3.dval); }
 | exp '-' exp { $$ = new ParserVal($1.dval - $3.dval); }
 | exp '*' exp { $$ = new ParserVal($1.dval * $3.dval); }
 | exp '/' exp { $$ = new ParserVal($1.dval / $3.dval); }
 | '-' exp %prec NEG { $$ = new ParserVal(-$2.dval); }
 | exp '^' exp { $$ = new ParserVal(Math.pow($1.dval, $3.dval)); }
 | '(' exp ')' { $$ = $2; }
 ;

BNF で文法が定義されていて、{} に Java コードを記述して、どのように処理するのかを記述する。ParserVal はParserVal.java に出力されているクラスで、$1 なども ParserVal クラスのインスタンスになるようだ。dval メソッドを呼ぶと double な value が返ってくるように ParserVal クラスは実装されていた。


追加Javaプログラム部

自動生成される Parser クラスは int yylex() メソッドを読んで、次のトークンを取得して、分岐処理を行う。 返り値は1つのトークンを返す必要があって、33 のような2文字の数字の場合は、返り値としてはYacc宣言部で定義した NUM トークンを返して(実体は整数で、Parser.java に埋め込まれる)、33 という値自体は ParserVal インスタンスに直して、yylval に保存するように実装する。 yylval に値を保存すると、文法規則部で $1 や $3 のように$記号でアクセスできるようになる。

ylval = new ParserVal(d.doubleValue());
return NUM;

+ や - の場合は、1文字なのでそれそのものをトークンとして扱うことができて、String#charAt でASCIIコードを返せば良い。

return s.charAt(0)

字句解析は自前で実装してあって、java の StringTokenizer を利用している。そのため、トークンの区切りにスペースがないとちゃんと動かない。33 + 5 は動くが、33+5 は動かない。

    if (!st.hasMoreTokens())
      return 0;
    s = st.nextToken();


おわりに

字句解析をより難しいルールで行いたい場合は、自分で頑張ってもよいし、lex などの字句解析器を使ってみても良い。java の場合は JFlex というものがあるようだ。ただ、今回は触っていない。

サンプルでは、字句解析のあと構文解析と同時に「実行」していたが、実世界では、構文解析の段階では AST (Abstract Syntax Tree)の構築だけをしておき、あとでASTを実行する方式を取ることが多い。そうすれば再実行の際は、構文解析をスキップして、ASTの再実行だけでよくなる。

字句解析 (lexer) - token -> 構文解析 (parser) - AST -> 実行

AST の構築、および実行にはいわゆる Interpreter パターンでコードを書けばよいだろう。まさしくそのためのデザインパターンだし。

後記:  JFlex やってみた => Javaで構文解析器ジェネレータ

追記

JFlex とはてブコメントで教えてもらったが、ANTLR というものが人気らしい。たしかに、サイトをみるととても良くメンテナンスされているように見える。IntelliJ プラグインまである。ただし、実行時に runtime が必要になり、その runtime が遅いという欠点があるとのこと。

ためしてみると Lexer.java というファイルも生成されたので、JFlex の役割も同時に担うのかもしれない。あとは、やはり yacc / flex とは書き方が違うので、ちょっと学習コストがありそうといったところかな。=> と思ったが Java だけでなく C, C++, Python, Ruby などでも ANTLR 文法(?)のツールがあるようなので、学んでも損にはならないのかもしれない。