Java
lexer
java8
tokenizer

Java 8 で字句解析を作る(その1)


はじめに

自作のJavaコンソールプログラムで使用する為の、簡易字句解析器を作ってみました。

字句解析なら各種ライブラリを使う方が手間がかからないのですが、外部サイトからのダウンロードが禁止されている職場で使用する為、手持ちのJava8 の機能を使ってイチから作成しています。

作成にあたり、単純な字句解析をJavaで実装するを参考にしています。


注意点


要件

まずは、作成する字句解析器の要件です。


  • Java8の機能のみで実装する。


  • 本格的なプログラム言語の構文解析に使用するわけではないので、簡易的な字句解析(Windowsのコマンドラインと Unix シェルの中間くらい)のみを行う。


    • トークンになり得る1文字記号と文字列のみ実装。




プログラムスタイル

Javaの一般的な記述と乖離したオレオレスタイルなので注意して下さい。一応以下の理由があります。


  • 上位プログラムを簡潔にする為、可能な限り例外を投げない、nullを返さない。大量のエラーチェックの中に処理本体が埋没するのを避けたい。

  • スコープを最小限度にする。

  • 可能な限りイミュータブルなオブジェクトを使用する。

  • 一般的な getter、Setterは使用しない。(Getter/Setterは悪だ。以上。 原文 Getters/Setters. Evil. Period. 参照)

  • ソースをコンパクトにする為、アノテーションは付けない。

  • Javaの標準ライブラリに慣れるため、import文は原則使わない。記述が冗長になりがちな、Stream関連クラスのインポートくらい。


トークンクラス

まずは、基本となるトークンクラスの全文を示します。


Token.java

package console;

final class Token {
enum Kinds {
Unknown,
Empty,
Ampersand, // "&"
Assign, // "="
Plus, // "+"
Minus, // "-"
Asterisk, // "*"
Slash, // "/"
Separator, // "," or 空白文字
LeftParenthesis, // "("
RightParenthesis, // ")"
LeftCurlyBracket, // "{"
RightCurlyBracket, // "}"
LeftSquareBracket, // "["
RightSquareBracket, // "]"
Colon, // ":"
BackSlash, // "\"
DoubleQuote, // """
SingleQuote, // "'"
String,
}

static Token create(char c) {
final String s = Character.toString(c);
switch(c) {
case '&' : return new Token(s, Kinds.Ampersand );
case '=' : return new Token(s, Kinds.Assign );
case '+' : return new Token(s, Kinds.Plus );
case '-' : return new Token(s, Kinds.Minus );
case '*' : return new Token(s, Kinds.Asterisk );
case '/' : return new Token(s, Kinds.Slash );
case ',' : // down through
case ' ' : return new Token(s, Kinds.Separator );
case '(' : return new Token(s, Kinds.LeftParenthesis );
case ')' : return new Token(s, Kinds.RightParenthesis );
case '{' : return new Token(s, Kinds.LeftCurlyBracket );
case '}' : return new Token(s, Kinds.RightCurlyBracket );
case '[' : return new Token(s, Kinds.LeftSquareBracket );
case ']' : return new Token(s, Kinds.RightSquareBracket);
case ':' : return new Token(s, Kinds.Colon );
case '\\' : return new Token(s, Kinds.BackSlash );
case '\"' : return new Token(s, Kinds.DoubleQuote );
case '\'' : return new Token(s, Kinds.SingleQuote );
}
return unknown_;
}

static Token create(String s) {
if ( s == null || s.trim().isEmpty() ) { return empty_; }
// 間違えて、記号が文字列で渡された場合の対処
if ( s.length() == 1 ) {
Token t = Token.create(s.charAt(0));
if ( t.kind() != Kinds.Unknown ) { return t; }
}
return new Token(s.trim(), Kinds.String);
}

final String value() { return value_; }
final Kinds kind() { return kind_; }
final String kindName() { return kind_.toString(); }
public String toString() {
return String.format("[%-14s: \"%s\"]", kindName(), value());
}

private Token(String s, Kinds k) {
kind_ = k;
value_ = s;
}

private static Token empty_ = new Token("", Kinds.Empty); // empty token
private static Token unknown_ = new Token("**Unknown**", Kinds.Unknown); // unknown token

private final Kinds kind_;
private final String value_;
}



解説

短いソースですので、説明不要かと思いますが、自分が忘れそうなのでソースの各パートについてざっと解説します。


トークンの種類

列挙型に、トークンの種類を登録します。これは、後の構文解析で使用するのマーカーなので、必ずしも文字と、要素が一対一で対応している必要はありません。

    enum Kinds {

Unknown,
Empty,
Ampersand, // "&"
Assign, // "="
Plus, // "+"
// 途中省略
String,
}


ファクトリメソッド

ファクトリメソッドを使用して、記号のトークンを作成します。

字句解析器からは、読み取った文字型(char型)を直接渡すようにして、文字列トークンのファクトリと分けています。

もし、switch文で捕まえられない文字が渡された場合は、予め作成しておいた unknown トークンを返します。

Tokenはイミュータブルなオブジェクトの為、中身が変えられることありません。その為、安心して事前作成したオブジェクトを使い回せます。

上で述べた、列挙型と文字が一対一に対応していない例として、カンマとスペースがあります。(どちらも Kinds.Separator として作成されます)

    static Token create(char c) {

final String s = Character.toString(c);
switch(c) {
case '&' : return new Token(s, Kinds.Ampersand );
case '=' : return new Token(s, Kinds.Assign );
case '+' : return new Token(s, Kinds.Plus );
case '-' : return new Token(s, Kinds.Minus );
case '*' : return new Token(s, Kinds.Asterisk );
case '/' : return new Token(s, Kinds.Slash );
case ',' : // down through
case ' ' : return new Token(s, Kinds.Separator );
case '(' : return new Token(s, Kinds.LeftParenthesis );
case ')' : return new Token(s, Kinds.RightParenthesis );
case '{' : return new Token(s, Kinds.LeftCurlyBracket );
case '}' : return new Token(s, Kinds.RightCurlyBracket );
case '[' : return new Token(s, Kinds.LeftSquareBracket );
case ']' : return new Token(s, Kinds.RightSquareBracket);
case ':' : return new Token(s, Kinds.Colon );
case '\\' : return new Token(s, Kinds.BackSlash );
case '\"' : return new Token(s, Kinds.DoubleQuote );
case '\'' : return new Token(s, Kinds.SingleQuote );
}
return unknown_;
}

private static Token unknown_ = new Token("**Unknown**", Kinds.Unknown); // unknown token

次は、文字列トークンのファクトリメソッドです。

これは、字句解析器で、文字列の終わりまで読み取ってからファクトリに渡されて来る為、記号トークンと異なり、文字列(String)型の引数を取るようにオーバーロードしてあります。

もし、空文字列(スペースのみの文字列を含む)や、nullが渡された場合は、予め作成しておいた空トークンを返します。これで、呼び出し側の余分なエラーチェックを回避できます。

一文字記号は、char型で渡されることを想定していますが、文字列型で渡されたときの保険として、渡された文字列が1文字だけの場合は、記号トークンのファクトリを呼び出しています。記号トークンファクトリで、対応できない文字列の場合は、不明トークンが返されるので、その時は改めて文字列トークンとして作成します。

尚、説明が前後しますが、ファクトリメソッドを介してのみオブジェクトを生成させるよう、コンストラクタはprivateで宣言しています。

    static Token create(String s) {

if ( s == null || s.trim().isEmpty() ) { return empty_; }
// 間違えて、記号が文字列で渡された場合の対処
if ( s.length() == 1 ) {
Token t = Token.create(s.charAt(0));
if ( t.kind() != Kinds.Unknown ) { return t; }
}
return new Token(s.trim(), Kinds.String);
}

private Token(String s, Kinds k) {
kind_ = k;
value_ = s;
}

private static Token empty_ = new Token("", Kinds.Empty); // empty token


問い合わせ

トークンの種類は、列挙型の要素型と、要素の文字列表現(kinds.Plus なら "Plus"が返る)の2つを用意しています。

    final String value()     { return value_; }

final Kinds kind() { return kind_; }
final String kindName() { return kind_.toString(); }

public String toString() {
return String.format("[%-14s: \"%s\"]", kindName(), value());
}


まとめ

以上で、トークンクラスの説明は終わりです。

実は、実際に作成したプログラムから、記号トークンを増やしたのですが、この程度でも、列挙型とファクトリの switch文を同期させるのが、面倒臭いことに気付きました。

この部分を汎用的に改造しようかとも考えましたが、クラスを増やすのが面倒なのと、時間の関係で、今回は見送ることにします。

次回は、字句解析器本体になります。

Java 8 で字句解析を作る(その2) 投稿しました。