解析
残念ながら、Interpreterパターンの解説において、このような複合構造をそもそもどうやって作成するかという点が語られることはほとんどありません。今回は私の趣味で、文字列を受け取って適切なインタープリタ階層を生成する、簡単なパーサーを試作してみることにします。手始めに、1個の終端式を解析できることを確認するための簡単なテストを作成します(リスト8)。
import static org.junit.Assert.*; import org.junit.*; public class ParserTest { @Test public void singleExpression() { Parser parser = new Parser(); Expression expression = parser.parse("contains text"); assertEquals(Contains.class, expression.getClass()); Contains contains = (Contains)expression; String[] keywords = contains.getKeywords(); assertEquals("text", keywords[0]); } }
このテストに合格するのは簡単です(リスト9)。
public class Parser { public Expression parse(String expression) { String[] tokens = expression.split(" "); return new Contains(tokens[1]); } }
テストをもう少しだけ複雑にしてみましょう(リスト10)。「contains」キーワードに複数のキーワードを指定できることを確認します。
import static org.junit.Assert.*; import org.junit.*; public class ParserTest { private Parser parser; @Before public void createParser() { parser = new Parser(); } @Test public void singleContainsExpression() { Expression expression = parser.parse("contains text"); assertKeywords((Contains)expression, "text"); } @Test public void containsMultipleKeywords() { Expression expression = parser.parse("contains text bozo"); assertKeywords((Contains)expression, "text", "bozo"); } private void assertKeywords(Contains contains, String... expected) { assertArrayEquals(expected, contains.getKeywords()); } }
リスト11に、複数のキーワードをサポートするための実装を示します。注意してほしいのは、Contains
クラスのコンストラクタを、Stringの配列を受け取るように変更する必要がある点です。
import java.util.*; public class Parser { private List<String> arguments = new ArrayList<String>(); public Expression parse(String expression) { String[] tokens = expression.split(" "); for (String token: tokens) { if (!token.equals("contains")) pushArgument(token); } return new Contains ((String[])arguments.toArray(new String[0])); } private void pushArgument(String token) { arguments.add(token); } }
次に実装が簡単なのは、もう1つの終端式である「olderThan」でしょう。リスト12と13に、olderThanを追加したテストと、修正後のParser
クラスを示します。
import static org.junit.Assert.*; import java.util.*; import org.junit.*; public class ParserTest { private Parser parser; @Before public void createParser() { parser = new Parser(); } @Test public void singleContainsExpression() { Expression expression = parser.parse("contains text"); assertKeywords((Contains)expression, "text"); } @Test public void containsMultipleKeywords() { Expression expression = parser.parse("contains text bozo"); assertKeywords((Contains)expression, "text", "bozo"); } @Test public void olderThan() { Expression expression = parser.parse("olderThan 03/31/2008"); OlderThan olderThan = (OlderThan)expression; assertDate(2008, Calendar.MARCH, 31, olderThan.getDate()); } private void assertDate(int year, int month, int dayOfMonth, Date date) { Calendar calendar = GregorianCalendar.getInstance(); calendar.setTime(date); assertEquals(year, calendar.get(Calendar.YEAR)); assertEquals(month, calendar.get(Calendar.MONTH)); assertEquals(dayOfMonth, calendar.get(Calendar.DAY_OF_MONTH)); } private void assertKeywords(Contains contains, String... expected) { assertArrayEquals(expected, contains.getKeywords()); } }
import java.text.*; import java.util.*; public class Parser { private List<String> arguments = new ArrayList<String>(); public Expression parse(String expression) { String command = null; String[] tokens = expression.split(" "); for (String token: tokens) if (isKeyword(token)) command = token; else pushArgument(token); return createExpression(command); } private Expression createExpression(String command) { if (command.equals("contains")) return new Contains ((String[])arguments.toArray(new String[0])); if (command.equals("olderThan")) return new OlderThan(parseDate(arguments.get(0))); return null; } private Date parseDate(String textDate) { try { return new SimpleDateFormat("MM/dd/yyyy").parse(textDate); } catch (ParseException unexpected) { return null; } } private boolean isKeyword(String token) { return token.equals("contains") || token.equals("olderThan"); } private void pushArgument(String token) { arguments.add(token); } }
なんだか、ごちゃごちゃしたパーサーになってきてしまいました。既にお気づきの人もいるでしょうが、このParser
クラスは責任の切り分けがうまくできていません。というのも、関連する式オブジェクトごとに異なる引数の解析方法を、このクラス自身が把握していなければならないからです。また、コマンドを表す文字列を保持しておき、後で引数をアタッチできる段階になったときにコマンドをインスタンス化するという今のやり方も好ましくありません。おそらく、JavaBeansの規約に従う方が適切でしょう。そこで、個々のExpression実装クラスを修正し、引数のないコンストラクタと引数のセッターをサポートするようにしたいと思います。
しばらく実装とテストを繰り返してリファクタリングを行った後、前よりすっきりした例ができあがりました。リスト14をご覧ください。
import java.util.*; public class Parser { private List<String> arguments = new ArrayList<String>(); public Expression parse(String expressionText) { String command = null; String[] tokens = expressionText.split(" "); for (String token: tokens) if (isKeyword(token)) command = token; else arguments.add(token); Expression expression = createExpression(command); expression.setArgs(arguments); return expression; } private Expression createExpression(String command) { if (command.equals("contains")) return new Contains(); if (command.equals("olderThan")) return new OlderThan(); return null; } private boolean isKeyword(String token) { return token.equals("contains") || token.equals("olderThan"); } }
それぞれのExpression実装クラスに少し手を加え、引数のないコンストラクタのサポートと、setArgs
メソッドを追加します(リスト15)。
import java.util.*; public class Contains implements Expression { private List<String> keywords = new ArrayList<String>(); @Override public boolean evaluate(Document document) { return document.contains ((String[])keywords.toArray(new String[0])); } String[] getKeywords() { return (String[])keywords.toArray(new String[0]); } @Override public void setArgs(List<String> args) { this.keywords = args; } }
これでよくなりました。ただ、まだ少し冗長な部分が残っているので、これをどうにかしなくてはなりません。Expression型のクラスを新しく追加したときに、createExpression
とisKeyword
の両方にコードを追加する必要があるからです。これは面倒なばかりでなく、リスクも増やします。そこで、少しだけリフレクションを使用し、両方のメソッドのコードを分離することにします。この方法ではマップを1つ利用しますが、最初にマップの初期化が必要です(リスト16)。これにより、新しい式クラスを追加するときも、コードの1か所を変更するだけで済みます。
import java.util.*; public class Parser { private List<String> arguments = new ArrayList<String>(); private Map<String,Class<? extends Expression>> expressionTypes = new HashMap<String,Class<? extends Expression>>(); { expressionTypes.put("contains", Contains.class); expressionTypes.put("olderThan", OlderThan.class); } public Expression parse(String expressionText) { String command = null; String[] tokens = expressionText.split(" "); for (String token: tokens) if (isKeyword(token)) command = token; else arguments.add(token); Expression expression = createExpression(command); expression.setArgs(arguments); return expression; } private Expression createExpression(String command) { try { return expressionTypes.get(command).newInstance(); } catch (Exception e) { throw new RuntimeException(e); } } private boolean isKeyword(String token) { return expressionTypes.containsKey(token); } }
悪くないです。生成部分のコードを別のファクトリクラスに分割するというやり方もありますが、それは別の機会に譲ることにします。