argius note

プログラミング関連

JavaでJVM言語を作ってみる(5) - JavaCCとJJTreeの導入

今回は解析器ジェネレータについて見ていきます。


第1回でも触れましたが、Unix-like環境というかC言語では少し昔からスタンダードなものはyacc+lexというものがあります。他にも字句解析器ジェネレータ+構文解析器ジェネレータで使われる例は多いようです。
と言ったものの、解析器ジェネレータについてはこれ以上詳しく知らないので、早速JavaCCの話に移ります。

JavaCC

JavaCCは、字句解析器ジェネレータです。拡張子.jjのファイルにEBNF(拡張BNF)を使って文法を記述しておき、これを入力するとJava言語で記述された字句解析器のソースコードが生成されます。
解析器生成の仕様を記述するには、BNF(バッカス・ナウア記法)というメタ言語を用いるのが一般的です。バッカス・ナウア記法は、プログラミング言語に限らず、文脈自由文法を定義することができます。最近ではBNF正規表現を導入したEBNF(Extended -)が使われています。(Wikipedia参照)


下記ページからダウンロードします。今回使用しているのは、バージョン5.0です。

アーカイブを適当なディレクトリに展開します。ここを${javacc.home}として、"${javacc.home}/bin"にパスを通します。

使い方は、.jjファイルを記述しておき、

$ javacc spec.jj

とします。自動生成するコード以外も.jjファイルに書くので、.jjファイルをを修正→ビルド、をするだけで再構築できます。
JavaCCには、構文解析のためのツリーを構築するための解析器を生成するJJTreeというツールが付属しています。
こちらは、.jjtファイルを入力します。

$ jjtree spec.jjt

.jjファイルの時と同様です。構文解析器を生成します。

"${javacc.home}/examples"にサンプルが多数用意されていますので、入門用に使えそうです。


ここでは、その中のサンプルをいくつか使ってみます。

構文解析

examplesの"JavaGrammars/1.5"には、Java1.5の文法を解析する.jjファイルがあります。

ここに以下の内容のファイルを作っておきます。

  • Dump.java: トークンをダンプする
import java.io.*;

class Dump {
    static void printToken(Token token) {
        System.out.printf("(%02d) %-24s %s%n", token.kind, JavaParserConstants.tokenImage[token.kind], token.image);
    }
    public static void main(String[] args) throws IOException {
        for (final String arg : args) {
            JavaParser parser = new JavaParser(arg);
            while (true) {
                Token token = parser.getNextToken();
                printToken(token);
                if (token.kind == JavaParserConstants.EOF) break;
            }
        }
    }
}

あとはAntでビルドします。
※Windows(7限定?)ではビルドがエラーになります。下記のようにターゲット"generated-files"のjavaccに.batをつけ、オプションを消せば上手くいくようになります。なお、javacc.homeがjavacc.rootとなっていて、設定値は相対パスになっていますのでご注意ください。

  <target name="generated-files" depends="parser-files-init" if="parser.gen">
     <exec failonerror="true" executable="${javacc.root}/bin/javacc.bat" >
           <arg value="Java1.5.jj"/>
     </exec>
  </target>

入力用に、HelloWorld的なJavaファイルを作ります。これはビルド不要です。

class HelloWorld {
    public static void main(String[] args) {
        final int i = 123;
        System.out.println("hello, " + i + 99.9f);
    }
}

これをDumpプログラムに渡せば、トークンに分割して出力されます。

$ java Dump HelloWorld.java 
  20 "class"                  class
  76 <IDENTIFIER>             HelloWorld
  81 "{"                      {
  48 "public"                 public
  51 "static"                 static
  62 "void"                   void
  76 <IDENTIFIER>             main
  79 "("                      (
  76 <IDENTIFIER>             String
  83 "["                      [
  84 "]"                      ]
  76 <IDENTIFIER>             args
  80 ")"                      )
  81 "{"                      {
  30 "final"                  final
  39 "int"                    int
  76 <IDENTIFIER>             i
  89 "="                      =
  65 <INTEGER_LITERAL>        123
  85 ";"                      ;
  76 <IDENTIFIER>             System
  87 "."                      .
  76 <IDENTIFIER>             out
  87 "."                      .
  76 <IDENTIFIER>             println
  79 "("                      (
  75 <STRING_LITERAL>         "hello, "
 103 "+"                      +
  76 <IDENTIFIER>             i
 103 "+"                      +
  69 <FLOATING_POINT_LITERAL> 99.9f
  80 ")"                      )
  85 ";"                      ;
  82 "}"                      }
  82 "}"                      }
   0 <EOF>                    
$

先頭の数値は、トークン種別のIDとして使われるint定数の値です。次が種別の文字列表現、最後がトークン自体の文字列です。予約語リテラル、識別子、記号、に分類されていることが分かります。
同じソースコードをインデントや改行規則を崩して書いても、同様の結果に沿ってないコードでも同様の結果が得られます。つまり、「フリーフォーマット」として解析できているということになります。

JJTreeで構文木を作る構文解析器を作る

今度は構文解析器をJJTreeを使って生成します。
examplesのJJTreeExamplesを使ってみます。
eg1〜4の例がありますが、eg4はVisitorパターンにより構文木(syntax tree)をたどりながら処理ができるようになっていて、VisitorとしてEg4DumpVisitor.javaがjjtではなく直接Javaファイルとして用意されています。
これを使えば手間が省けそうです。

まずはそのまま動かしてみます。

※Windowsの場合、Antの"javacc.home"がUNIX系のパスになっていますので、書き換える必要があります。

$ ant eg4
(中略)
     [echo] *******
     [echo] ******* Now cd into the eg4 directory and run 'java Eg4' ******
     [echo] *******

BUILD SUCCESSFUL
Total time: 2 seconds
$ cd eg4
$ java Eg4
Reading from standard input...
(3 + 2 + a) * (2 + b) + c;
Start
 Add
  Mult
   Add
    Integer
    Integer
    Identifier: a
   Add
    Integer
    Identifier: b
  Identifier: c
Thank you.
$

式は自分で入力する箇所です。";"で終了です。
Eg4DumpVisitor.javaを見てみると、各visitメソッドで"System.out.println(indentString() + node)"を実行しています。これをnode#childrenAcceptより前に呼び出しているので、ツリーを「降りる」前にprintlnしているということになります。"--indent"の行の後にも"System.out.println(indentString() + node)"を実行して、降りる前と後が分かるようにしてみます。

  • Eg4DumpVisitor.java 改造部分抜粋
  private String indentString() {
    return indentString(false);
  }

  private String indentString(boolean end) {
    StringBuffer sb = new StringBuffer();
    for (int i = 0; i < indent; ++i) {
      sb.append(' ');
    }
    sb.append("[" + (end ? "E" : "S") + "]");
    return sb.toString();
  }

  public Object visit(ASTInteger node, Object data) {
    System.out.println(indentString() + node);
    ++indent;
    data = node.childrenAccept(this, data);
    --indent;
    System.out.println(indentString(true) + node);
    return data;
  }
Reading from standard input...
(3 + 2 ) * 4;
[S]Start
 [S]Mult
  [S]Add
   [S]Integer
   [E]Integer
   [S]Integer
   [E]Integer
  [E]Add
  [S]Integer
  [E]Integer
 [E]Mult
Thank you.

ENDのタイミングで命令コードを出力するようにしておけば、そのままCalcGenとして使えそうです。


ところが、このままだと上手く行かないケースがあります。同じ優先順位の演算子の場合だと、ひとつのノードに3以上の子がぶらさがってしまいます。

Reading from standard input...
1 + 2 - 3 + 4;
[S]Start
 [S]Add
  [S]Integer
  [E]Integer
               # -->iconst_Integer
  [S]Integer
  [E]Integer
               # -->iconst_Integer
  [S]Integer
  [E]Integer
               # -->iconst_Integer
  [S]Integer
  [E]Integer
               # -->iconst_Integer
 [E]Add
               # -->iadd
[E]Start
               # -->invoke static
Thank you.

これは、算術演算のノードが「繰り返し全体」「子要素=1以上」と定義されているためです。
繰り返し1つにつき1ノードにし、子要素を常に2つ持つようにします。

  • eg4.jjtの抜粋

( MultiplicativeExpression() ( ( "+" | "-" ) MultiplicativeExpression() )* ) #Add(>1)

( MultiplicativeExpression() ( ( "+" | "-" ) MultiplicativeExpression() #Add(2) )* )

Reading from standard input...
1 + 2 + 3;
[S]Start
 [S]Add
  [S]Add
   [S]Integer
   [E]Integer
               # -->iconst_Integer
   [S]Integer
   [E]Integer
               # -->iconst_Integer
  [E]Add
               # -->iadd
  [S]Integer
  [E]Integer
               # -->iconst_Integer
 [E]Add
               # -->iadd
[E]Start
               # -->invoke static
Thank you.

iaddが2回出力されるようになりました。


JJTree版CalcGen

まとめとして、前回作成したCalcGenをJJTreeで再構築してみました。
前回と比べて、

  • スペース区切りでなくてもOK
  • カッコ優先が使える

となっています。

  • calcgen.jjt
options {
  STATIC=false;
  MULTI=true;
  VISITOR=true;
  NODE_DEFAULT_VOID=true;
  NODE_PREFIX="Calc";
}

PARSER_BEGIN(CalcGen)

import java.io.*;

/** Calc Generator. */
public final class CalcGen {

    public static void main(String[] args) throws Exception {
        if (args.length > 0) {
            StringBuilder buffer = new StringBuilder();
            for (final String arg : args) {
                buffer.append(' ');
                buffer.append(arg);
            }
            final String exp = buffer.toString().replace("x", "*");
            CalcGen t = new CalcGen(new StringReader(exp + ";"));
            t.Start().jjtAccept(new CalcGenVisitorImpl(exp), null);
        }
    }

}

PARSER_END(CalcGen)


SKIP : { " " | "\t" | "\n" | "\r" }

TOKEN : { < INTEGER_LITERAL: ( ["1"-"9"] (["0"-"9"])* ) | "0" > }

TOKEN : { < IDENTIFIER: ["a"-"e"] > }

TOKEN : { < ADD: ( "+" | "-" ) > }

TOKEN : { < MUL: ( "*" | "/" | "%" ) > }

CalcStart Start() #Start : {} { Expression() ";" { return jjtThis; } }

void Expression() : {} { AdditiveExpression() }

void AdditiveExpression() : { Token t; }
{ MultiplicativeExpression() ( t=<ADD> MultiplicativeExpression() #Add(2) { jjtn001.jjtSetValue(t.image); } )* } 

void MultiplicativeExpression() : { Token t; }
{ UnaryExpression() ( t=<MUL> UnaryExpression() #Mult(2) { jjtn001.jjtSetValue(t.image); } )* }

void UnaryExpression() : {}
{ "(" Expression() ")" | Identifier() | Integer() }

void Identifier() #Identifier : { Token t; }
{ t=<IDENTIFIER> { jjtThis.jjtSetValue(t.image); } }

void Integer() #Integer : { Token t; }
{ t=<INTEGER_LITERAL> { jjtThis.jjtSetValue(t.image); } }
  • CalcGenVisitorImpl.java
import static org.apache.bcel.Constants.INVOKEVIRTUAL;
import static org.apache.bcel.generic.InstructionConstants.*;

import java.io.*;

import org.apache.bcel.*;
import org.apache.bcel.classfile.*;
import org.apache.bcel.generic.*;

public class CalcGenVisitorImpl implements CalcGenVisitor {

    static final String CLASS_NAME = "Calc";

    final String exp;

    ClassGen cg;
    Method mainMethod;
    MethodGen mg;
    InstructionList il;
    InstructionFactory factory;

    CalcGenVisitorImpl(String exp) {
        this.exp = exp;
    }

    void onBegin() {
        System.out.println("*** CalcGen START ***");
        cg = new ClassGen(getBaseClass());
        cg.setClassName(CLASS_NAME);
        mainMethod = cg.containsMethod("main", "([Ljava/lang/String;)V");
        factory = new InstructionFactory(cg);
        // method: public static void main(String[] args)
        mg = new MethodGen(mainMethod, CLASS_NAME, cg.getConstantPool());
        il = mg.getInstructionList();
        // parse
        try {
            il.delete(il.getEnd());
        } catch (TargetLostException ex) {
            // ignore
        }
        il.append(factory.createGetStatic(System.class.getName(),
                                          "out",
                                          Type.getType(PrintStream.class)));
        il.append(DUP);
        il.append(factory.createConstant("  " + exp + " = "));
        il.append(factory.createInvoke(java.io.PrintStream.class.getName(),
                                       "print",
                                       Type.VOID,
                                       new Type[]{Type.STRING},
                                       INVOKEVIRTUAL));
    }

    void onEnd() {
        il.append(factory.createInvoke(java.io.PrintStream.class.getName(),
                                       "println",
                                       Type.VOID,
                                       new Type[]{Type.INT},
                                       INVOKEVIRTUAL));
        il.append(RETURN);
        // end of instructions
        mg.setMaxStack();
        mg.setMaxLocals();
        cg.replaceMethod(mainMethod, mg.getMethod());
        // generate
        JavaClass c = cg.getJavaClass();
        c.setSourceFileName(CLASS_NAME + ".java");
        try {
            c.dump(CLASS_NAME + ".class");
        } catch (IOException ex) {
            throw new RuntimeException("compile error", ex);
        }
        System.out.println("*** CalcGen END ***");
    }

    @Override
    public Object visit(SimpleNode node, Object data) {
        throw new AssertionError("compile error: unexpected token " + node);
    }

    @Override
    public Object visit(CalcStart node, Object data) {
        onBegin();
        data = node.childrenAccept(this, data);
        onEnd();
        return data;
    }

    @Override
    public Object visit(CalcInteger node, Object data) {
        data = node.childrenAccept(this, data);
        final String value = (String)node.jjtGetValue();
        System.out.printf("  %s --> (constant %s)%n", p(node), value);
        il.append(factory.createConstant(Integer.valueOf(value)));
        return data;
    }

    @Override
    public Object visit(CalcIdentifier node, Object data) {
        data = node.childrenAccept(this, data);
        final int index = " abcde".indexOf((String)node.jjtGetValue());
        System.out.printf("  %s --> iload_%s%n", p(node), index);
        il.append(InstructionFactory.createLoad(Type.INT, index));
        return data;
    }

    @Override
    public Object visit(CalcAdd node, Object data) {
        final String op = String.valueOf(node.jjtGetValue());
        return createBinaryOperation(op, op + "(iadd/isub)", node, data);
    }

    @Override
    public Object visit(CalcMult node, Object data) {
        final String op = String.valueOf(node.jjtGetValue());
        return createBinaryOperation(op, op + "(imul/idiv/irem)", node, data);
    }

    Object createBinaryOperation(String op, String code, SimpleNode node, Object data) {
        data = node.childrenAccept(this, data);
        System.out.printf("  %s --> %s%n", node, code);
        il.append(InstructionFactory.createBinaryOperation(op, Type.INT));
        return data;
    }

    static String p(SimpleNode node) {
        return String.format("%s %s", node, node.value);
    }

    static JavaClass getBaseClass() {
        try {
            return Repository.lookupClass(CalcBase.class);
        } catch (ClassNotFoundException ex) {
            throw new RuntimeException(ex);
        }
    }

}

final class CalcBase {

    @SuppressWarnings("unused")
    public static void main(String[] args) {
        System.out.println("*** Calc ***");
        final int a;
        final int b;
        final int c;
        final int d;
        final int e;
        int[] iargs = new int[5];
        {
            int i = 0;
            for (String arg : args) {
                if (arg.matches("\\d+")) {
                    iargs[i++] = Integer.parseInt(arg);
                }
            }
            i = 0;
            for (final int v : iargs) {
                if (i != 0)
                    System.out.print(',');
                System.out.printf("  %s=%d", (char)('a' + i), v);
                ++i;
            }
            System.out.println();
        }
        a = iargs[0];
        b = iargs[1];
        c = iargs[2];
        d = iargs[3];
        e = iargs[4];
    }

}


動かしてみます。

$ ls
calcgen.jjt  CalcGenVisitorImpl.java
$ jjtree calcgen.jjt 
Java Compiler Compiler Version 5.0 (Tree Builder)
(type "jjtree" with no arguments for help)
Reading from file calcgen.jjt . . .
File "Node.java" does not exist.  Will create one.
File "SimpleNode.java" does not exist.  Will create one.
File "CalcStart.java" does not exist.  Will create one.
File "CalcAdd.java" does not exist.  Will create one.
File "CalcSub.java" does not exist.  Will create one.
File "CalcMult.java" does not exist.  Will create one.
File "CalcDiv.java" does not exist.  Will create one.
File "CalcIdentifier.java" does not exist.  Will create one.
File "CalcInteger.java" does not exist.  Will create one.
File "CalcGenTreeConstants.java" does not exist.  Will create one.
File "CalcGenVisitor.java" does not exist.  Will create one.
File "JJTCalcGenState.java" does not exist.  Will create one.
Annotated grammar generated successfully in .\calcgen.jj
$ javacc calcgen.jj
Java Compiler Compiler Version 5.0 (Parser Generator)
(type "javacc" with no arguments for help)
Reading from file calcgen.jj . . .
File "TokenMgrError.java" does not exist.  Will create one.
File "ParseException.java" does not exist.  Will create one.
File "Token.java" does not exist.  Will create one.
File "SimpleCharStream.java" does not exist.  Will create one.
Parser generated successfully.
$ printenv CLASSPATH  # ※Cygwinです
bin;lib/bcel-5.2.jar;./
$ javac *.java
$ java CalcGen "(1 +2)x3 + a / b"
*** CalcGen START ***
  Integer 1 --> (constant 1)
  Integer 2 --> (constant 2)
  Add --> +(iadd/isub)
  Integer 3 --> (constant 3)
  Mult --> *(imul/idiv/irem)
  Identifier a --> iload_1
  Identifier b --> iload_2
  Mult --> /(imul/idiv/irem)
  Add --> +(iadd/isub)
*** CalcGen END ***
$ java Calc 6 2 0 0 0
*** Calc ***
  a=6,  b=2,  c=0,  d=0,  e=0
   (1 +2)*3 + a / b = 12
$ java CalcGen "a + b + c x d"
*** CalcGen START ***
  Identifier a --> iload_1
  Identifier b --> iload_2
  Add --> +(iadd/isub)
  Identifier c --> iload_3
  Identifier d --> iload_4
  Mult --> *(imul/idiv/irem)
  Add --> +(iadd/isub)
*** CalcGen END ***
$ java Calc 1 2 3 4 5
*** Calc ***
  a=1,  b=2,  c=3,  d=4,  e=5
   a + b + c * d = 15
$ java Calc 4 3 2 1 0
*** Calc ***
  a=4,  b=3,  c=2,  d=1,  e=0
   a + b + c * d = 9
$

ちゃんと計算できていますね。


これでプログラム(と言ってもまだ四則演算機能しかありませんが)からJavaCC(JJTree)+BCELを使ってクラスファイルを生成する仕組みが準備できました。


次回は、もう少しプログラミング言語っぽいものを作っていきます。