このドキュメントは http://edu.net.c.dendai.ac.jp/ 上で公開されています。
前回はバッカスナウア記法により文脈自由文法を記述し、さらに、その文法を 処理するパーサをプログラムで作成する方法に関して議論しました。 今回はまず、よく使われるバッカスナウア記法の拡張表記を説明します。 その後、 Java で文脈自由文法を処理するコンパイラコンパイラである JavaCC を紹介します。
基本的なバッカスナウア記法では右辺に終端記号、非終端記号の連接の表現し かありませんでした。 拡張バッカスナウア記法は、連接以外に正規文法の記法である、選択と繰り 返しを指定できるようにしたものです。 選択は |(縦棒) で右辺の表現を列挙します。 一方、繰り返しに関しては多くの表現方法があります。 本質的には次に示す一部が可能であれば、他の表現を実現できますが、よく使 われる表現を紹介します。
なお、このような表記を使うため、今後、文法表記で終端記号をルールに含め るときは "" (ダブルクォーテーションマーク)で囲むことにします。
足し算の文法 G1 は以下の通りでした。
これを拡張バッカスナウア記法で記述すると次のようになります。
G1は左再帰性がありました。 これを除去するには、繰り返し構造を見抜いて、繰り返し構文で記述します。
さらに、式の最後に =(イコール)記号をつけた文法を示します。 これを G3 とします。
バッカス・ナウア記法を与えて、それを処理するプログラムを出力する処理系 をコンパイラコンパイラと言います。 java 用のコンパイラコンパイラに JavaCCというクラスラ イブラリがあります。 JavaCC は構文解析も字句解析もできます。また、構文解析木を出力する JJTree というツールも付属してきます。
JavaCC の基本的な利用法は、「数」や「単語」など、文法の基礎となる要素を トークン(token) として登録し、次にそのトークン間のルールを 文法として記述します。
なお、JavaCC は Java1.4 のコードを出力しますので、 Java 6 コンパイラで コンパイルすると警告が出ます。 また、JavaCC は標準では Unicode などの日本語を処理することはできませ ん。 対応させるためのツールなどを使用する必要があります。
JavaCC のソースファイルのファイル名には .jj という接尾語を付けます。
なお、 jj ファイルの正確な記述法は JavaCC [tm]: Grammar Files https://javacc.dev.java.net/doc/javaccgrm.html を参照してください。 また、 JavaCC FAQ http://www.engr.mun.ca/~theo/JavaCC-FAQ/という文献も参考になります。
以下は入力した文字数だけ Hello World を出力するプログラムです。
PARSER_BEGIN(Rei)
import java.io.*;
public class Rei {
public static void main(String[] arg) throws ParseException {
final Rei parser = new Rei(new InputStreamReader(System.in));
parser.start();
}
}
PARSER_END(Rei)
SKIP: {
" "
| "\n"
| "\r"
}
TOKEN: { <MOJI : ~[] >}
private void start() :
{}
{
(<MOJI> { System.out.println("Hello World.");})*
}
これを a.jj というファイル名で保存したら、
jj ファイルの基本書式は次のとおりです。
PARSER_BEGIN(パーサクラス名)
宣言したパーサクラスの定義
PARSER_END(パーサクラス名)
生成ルール
例えば、パーサのクラス名が P であれば、次のようになります。
PARSER_BEGIN(P)
class P {
}
PARSER_END(P)
生成ルール
生成ルールのうち、文字そのものからトークンを取り出すには正規文法を使用 します。 TOKEN というキーワードを使用して、トークンの名前と正規文法を対にして定 義します。 このトークンの名前は後で説明するバッカス・ナウア記法の記述で使用します。
トークンの登録の記法は次のようにします。
TOKEN: {
<トークン名1 : 正規表現1 >
| <トークン名2 : 正規表現2 >
| ...
}
なお、正規表現は通常の Java のものと若干異なります。
例えば、自然数を NUM、名前を NAME という名称でトークンを取り出したい時は 次のように指定します。
TOKEN: {
<NUM : ["1"-"9"] (["0"-"9"])* >
|<NAME : ["A"-"Z","a"-"z"] (["0"-"9","A"-"Z","a"-"z"])* >
}
なお、空白など文法では特に処理しない、読み飛ばすための表現のために、 TOKEN キーワードの他に SKIP というキーワードが用意されています。 SKIP でトークン名称は指定しないので、次のようになります。
SKIP: {
" "
| "\n"
| "\r"
}
次にバッカスナウア記法により文法を指定します。 自力でパーサを作成したのと同様に、各非終端記号ごとにメソッドを作ります。 しかし、定義の書式は下記のように異なります。
メソッドのシグネチャ :
{
メソッド内のアクションで共通で使用する変数などの宣言
}
{
<トークン名1>あるいは非終端記号に対応するメソッドの呼び出しの列
{ Java 構文によるアクション1 }
<トークン名2>あるいは非終端記号に対応するメソッドの呼び出しの列
{ Java 構文によるアクション2 }
...
}
なお、あらかじめ用意されている暗黙のトークンとして、ファイルの終わりを 意味する <EOF> があります。 また、拡張バッカスナウア記法をサポートしますので、選択を表す |(縦棒) や繰り返しの表現などもサポートします。 なお、空列は | {} で指定できますが、通常は [] で括るなど、別の表現で 代替可能です。
空白で区切られた名前の集まりを認識するパーサを作ります。 この文法は次のようになります。
始→(名前)*
これを JavaCC で記述すると次のようになります。
// jj ファイル(Parser.java を生成)
options
{
JDK_VERSION = "1.6";
static = false;
}
PARSER_BEGIN(Parser)
class Parser {
}
PARSER_END(Parser)
TOKEN: {
<NAME : ["A"-"Z","a"-"z"] (["A"-"Z","a"-"z","0"-"9"])*>
}
SKIP:{
" "
| "\n"
| "\r"
}
public void start() :
{}
{
(<NAME>)*
}
import java.io.*;
class Rei {
public static void main(String[] arg) throws ParseException {
final Parser parser = new Parser(new InputStreamReader(System.in));
try{
parser.start();
System.out.println("Accept");
}catch(Exception e){
System.out.println("Reject");
}
}
}
これを実行すると、名前と空白と改行だけからなる文字列を入力すると、 Accept を表示します。 なお、それ以外の入力の場合、 Exception ではなく Lexical error が発行さ れるため、例外処理ではなくエラーでプログラムが止まります。
次に、同じ文法において、名前が与えられる度に「x」を表示するようなプ ログラムを与えます。
// jj ファイル(Parser.java を生成)
options
{
JDK_VERSION = "1.5";
static = false;
}
PARSER_BEGIN(Parser)
class Parser {
}
PARSER_END(Parser)
TOKEN: {
<NAME : ["A"-"Z","a"-"z"] (["A"-"Z","a"-"z","0"-"9"])*>
}
SKIP:{
" "
| "\n"
| "\r"
}
public void start() :
{}
{
(<NAME> {System.out.println("x");})*
}
このようにパターンの直後に {}(中括弧)を付け、中に Java のプログラムを 書くことで、マッチしたときそのプログラムが実行できるようになります。 また、この中括弧はパターンの一部になるので、この例のように繰り返しの表 現などをさらに付加することができます。
次に、同じ文法において、名前が与えられる度にその得られた名前を角括弧 で[○○]のように表示するようなプログラムを与えます。
// jj ファイル(Parser.java を生成)
PARSER_BEGIN(Parser)
class Parser {
}
PARSER_END(Parser)
TOKEN: {
<NAME : ["A"-"Z","a"-"z"] (["A"-"Z","a"-"z","0"-"9"])*>
}
SKIP:{
" "
| "\n"
| "\r"
}
public void start() :
{
Token name;
}
{
(name=<NAME> {System.out.println("["+name.image+"]");})*
}
トークンを得るには Token 型の変数を用意します。 メソッドのシグネチャの後の最初の {} (中括弧)の中には、そのメソッドが使 用する変数などを宣言する場所になります。 トークンは「変数名 = パターン」で得られます。 Token が含むフィールドは javacc を動かすと生成される Token.java を見れば厳 密に分かります(javadoc 対応)が、下記のものがあります。
他に、マッチした位置に関する情報が取り出せます。
名前と数、それぞれの出現回数を数え上げるプログラムを示します。
options {
STATIC = false;
}
PARSER_BEGIN(Parser)
import java.io.*;
class Parser {
private int name;
private int num;
public void init(){
name=0;
num=0;
}
public int getNumberOfNames(){
return name;
}
public int getNumberOfNumbers(){
return num;
}
}
PARSER_END(Parser)
TOKEN : {
<NAME : ["A"-"Z","a"-"z"](["A"-"Z","a"-"z","0"-"9"])*>
| <NUM : ["1"-"9"](["0"-"9"])*>
| <OTHER : (~[" ","\n","\r"])+>
}
SKIP : {
" " | "\n" | "\r"
}
public void start() :
{}
{
(<NAME> {name++;} | <NUM> {num++;} | <OTHER>)*
}
import java.io.*;
class Rei {
public static void main(String[] arg) throws ParseException {
final Parser parser = new Parser(new InputStreamReader(System.in));
parser.init();
parser.start();
System.out.println(parser.getNumberOfNames());
System.out.println(parser.getNumberOfNumbers());
}
}
まず、 PARSER_BEGIN の前にオプションを与えることができます。
今回は Parser クラスにデータを持たせて取り出したいために、
STATIC = false;
というオプションを指定しています。
通常 JavaCC では高速化のためにデフォルトではメソッドは static で作成す
るようです。
それでは static メソッドからメンバ変数にアクセスできなくなるので、指定
しました。
また、クラスの初期化を行う場合、コンストラクタは JavaCC が生成するため、変 数の初期化などは別の初期化のメソッドを用意した方が簡単です。
TOKEN で定義している正規表現において、 OTHER は NAME も NUM も含んでい ます。 このような場合先に書いた方が優先となります。 下記の定義では期待した動作にはなりません。
TOKEN : {
<OTHER : (~[" ","\n","\r"])+>
| <NAME : ["A"-"Z","a"-"z"](["A"-"Z","a"-"z","0"-"9"])*>
| <NUM : ["1"-"9"](["0"-"9"])*>
}
本章では JavaCC による数式の処理を考えます。 出力は木構造を返すこととします。
足し算の文法として下記の G3 を JavaCC に与えます。
まず、木の関係のクラスは前回と同じ物を使います。 但し、数を与える部分は一文字ではなく自然数としますので、その部分だ け変更します。 以下に示します。
abstract class Node {
abstract public void show();
abstract public void rpn();
abstract public void setOp(char c);
abstract public void addLeft(Node n);
abstract public void addRight(Node n);
}
class Num extends Node {
public Num(int n){
value = n;
}
private int value;
@Override public void setOp(char c){}
@Override public void addLeft(Node n){}
@Override public void addRight(Node n){}
@Override public void show(){
System.out.print(value);
}
@Override public void rpn(){
System.out.print(value);
}
}
class Op extends Node{
public static Node connectToLeft(Node n){
final Op result = new Op();
result.left = n;
return result;
}
public Op(){}
private char op;
private Node left;
private Node right;
@Override public void setOp(char c){
op = c;
}
@Override public void addLeft(Node n){
left = n;
}
@Override public void addRight(Node n){
right = n;
}
@Override public void show(){
System.out.print("(");
if(left!=null){
left.show();
}
System.out.print(op);
if(right!=null){
right.show();
}
System.out.print(")");
}
@Override public void rpn(){
if(left!=null){
left.rpn();
}
System.out.print(" ");
if(right!=null){
right.rpn();
}
System.out.print(" ");
System.out.print(op);
System.out.print(" ");
}
}
またテスト用の起動プログラムも Exception を替えただけで同様です。
import java.io.*;
class Rei {
public static void main(String[] arg) throws ParseException {
final Reader r = new InputStreamReader(System.in);
final Parser parser = new Parser(r);
final Node tree = parser.start();
if(tree !=null){
tree.show();
System.out.println();
tree.rpn();
System.out.println();
}
}
}
さて、次にこれを利用して JavaCC のプログラムを作ります。 まずトークンは数と '+' 演算子と '=' 演算子になります。 その定義は次のようにします。
TOKEN : {
<NUM : ["1"-"9"](["0"-"9"])*>
| <PLUSOP : "+">
| <EQOP : "=">
}
SKIP : {
" " | "\n" | "\r"
}
構文解釈では、最初の NUM で一つノードを作り、次の PLUSOP と NUM の繰 り返しで、Op.connectToLeft 静的メソッドで木をつないでいきます。 最終的に EQOP を認識したら木構造を返します。 そのため、 Node 型の変数 root を用意します。 以上をまとめるとつぎのようになります。
PARSER_BEGIN(Parser)
import java.io.*;
class Parser {
}
PARSER_END(Parser)
TOKEN : {
<NUM : ["1"-"9"](["0"-"9"])*>
| <PLUSOP : "+" >
| <EQOP : "=" >
}
SKIP : {
" " | "\n" | "\r"
}
public Node start() :
{
Token token;
Node root;
}
{
token=<NUM> {root = new Num(Integer.parseInt(token.image));}
( <PLUSOP> token=<NUM> {
root = Op.connectToLeft(root);
root.setOp('+');
root.addRight(new Num(Integer.parseInt(token.image)));
}
)*
<EQOP> { return root; }
}
次に足し算と掛け算の混ざった式を考えましょう。 計算は積項の和をとることで行いますので、 G3 を書き換えた次 の文法を G4 とします。
なお、 jj ファイル以外は全て同じですので、ここでは jj ファイルのみを示 します。
さて、ここで非終端記号が二つ現れます。 そして、このプログラムの目標は式の構文解析木を得ることです。 非終端記号の「積」を表すメソッドを prod とすると、この prod は構文解析 木を返すことになります。 そのため、 prod のシグネチャはつぎのようになります。
private Node prod() :
すると start 内の構文ルールにおいて、 prod から Node 型のオブジェクト を受けとることになります。 そこで、前節では NUM というトークンから Token 型のオブジェクトを受け取っ てましたが、これを全て Node に統一させた方が見通しが良くなると思います。 そのため、 NUM というトークンから Node 型のオブジェクトを生成するよう な非終端記号を num として別に用意することにします。 つまり、 num は次のようになります。
private Node num() :
{
Token token;
}
{
<NUM> { return new Node(Integer.parseInt(token.image));}
}
これを利用して作成したものを以下に示します。
PARSER_BEGIN(Parser)
import java.io.*;
class Parser {
}
PARSER_END(Parser)
TOKEN : {
<NUM : ["1"-"9"](["0"-"9"])*>
| <PLUSOP : "+" >
| <MULOP : "*" >
| <EQOP : "=" >
}
SKIP : {
" " | "\n" | "\r"
}
public Node start() :
{
Node root, node;
}
{
root=prod()
( <PLUSOP> node=prod() {
root = Op.connectToLeft(root);
root.setOp('+');
root.addRight(node);
}
)*
<EQOP> { return root; }
}
private Node prod() :
{
Node node, root;
}
{
root=num()
( <MULOP> node=num() {
root = Op.connectToLeft(root);
root.setOp('*');
root.addRight(node);
}
)* { return root; }
}
private Node num() :
{
Token token;
}
{
token=<NUM> { return new Num(Integer.parseInt(token.image));}
}
さらに括弧の処理を考えます。 括弧の中にはどんな式も入ります。一方括弧はその中の値を計算したら、計算 の対象となる値として数と同じように扱われます。 今まで「数」という終端記号を使ってきましたが、ここで「値」 という非終端記号を導入します。 すると、値に関するルールは次のようになります。
和に = がついているとうまく行かないことに注意して作成したルールを G5 とすると以下のようになります。
これの JavaCC ファイルは次のようになります。
PARSER_BEGIN(Parser)
import java.io.*;
class Parser {
}
PARSER_END(Parser)
TOKEN : {
<NUM : ["1"-"9"](["0"-"9"])*>
| <PLUSOP : "+" >
| <MULOP : "*" >
| <OPEN : "(" >
| <CLOSE : ")" >
| <EQOP : "=" >
}
SKIP : {
" " | "\n" | "\r"
}
public Node start() :
{
Node root;
}
{
root=sum()
<EQOP> { return root; }
}
public Node sum() :
{
Node root, node;
}
{
root=prod()
( <PLUSOP> node=prod() {
root = Op.connectToLeft(root);
root.setOp('+');
root.addRight(node);
}
)* { return root; }
}
private Node prod() :
{
Node node, root;
}
{
root=atai()
( <MULOP> node=atai() {
root = Op.connectToLeft(root);
root.setOp('*');
root.addRight(node);
}
)* { return root; }
}
private Node atai() :
{
Node node;
}
{
node=num() { return node; }
| <OPEN> node=sum() <CLOSE> {return node;}
}
private Node num() :
{
Token token;
}
{
token=<NUM> { return new Num(Integer.parseInt(token.image));}
}
構文解析木を受け取り、計算を行うプログラムを作成しなさい。
入力に自然数だけでなく、整数を許す数式計算プログラムを実現しなさい。