使っていたらテスト不足が発見される

今回HtmlTableCreatorの実装にStringSplitterByString*1を利用しました。一文字の","をデリミタに利用したのですが、以前の実装では例外が発生してしまいました。文字列の終端まで行った時に何か失敗しているようです。
修正したものを記載しておきます。このような場合は単体テスト不足であるとみなせるので一文字のデリミタ用のテストも記述します。

/**
 * 
 */
package ms2310.oot.section5.sample0;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;

public class StringSplitterByString implements Splitter {
  private String delim;

  private String input;

  private SplitHandler handler;

  public StringSplitterByString(SplitHandler handler, String input, String delim) {
    if(handler == null) throw new IllegalArgumentException("handler == null");
    if(input == null) throw new IllegalArgumentException("input == null");
    if(delim == null) throw new IllegalArgumentException("delim == null");
    
    this.delim = delim;
    this.input = input;
    this.handler = handler;
  }

  public void split() throws IOException {
    if(input.length() == 0) return;
    if(delim.length() == 0) return;
    
    BufferedReader reader = new BufferedReader(new StringReader(input));
    int c;
    StringBuffer buf = new StringBuffer();

    char[] delimBuf = new char[delim.length()];
    for (;;) {
      reader.mark(delim.length());
      c = reader.read(delimBuf);

      if (c != delim.length()) {
        String part; 
        if(c == -1){
          part = buf.toString();
        }
        else{
          part = buf.append(new String(delimBuf, 0, c)).toString();
        }

        handler.handleSplit(part);
        break;
      } else if (delim.equals(new String(delimBuf))) {
        handler.handleSplit(buf.toString());
        buf = new StringBuffer();
      } else {
        reader.reset();
        c = reader.read();
        buf.append((char) c);
      }
    }
  }
}
package ms2310.oot.section5.sample0;

import java.util.ArrayList;
import java.util.List;

import junit.framework.TestCase;

public class StringSplitterByStringTest extends TestCase {

  private static class TestHandler implements SplitHandler {

    private List result = new ArrayList();

    public void handleSplit(String str) {
      result.add(str);
    }

    public List getResult() {
      return result;
    }
  }

  public void testSenario() throws Exception {

    TestHandler handler = new TestHandler();

    String input = "こんな::文字列を::::分解します";
    String delim = "::";

    Splitter outputter = new StringSplitterByString(handler,
        input, delim);

    // 文字列分解を行う
    outputter.split();

    // handlerは分解された文字を受け取る
    List result = handler.getResult();

    assertEquals(4, result.size());
    assertEquals("こんな", result.get(0));
    assertEquals("文字列を", result.get(1));
    assertEquals("", result.get(2));
    assertEquals("分解します", result.get(3));
  }

  public void testDelimChar() throws Exception {

    TestHandler handler = new TestHandler();

    String input = "こんな:文字列を::分解します";
    String delim = ":"; //デリミタが一文字

    Splitter outputter = new StringSplitterByString(handler,
        input, delim);

    // 文字列分解を行う
    outputter.split();

    // handlerは分解された文字を受け取る
    List result = handler.getResult();

    assertEquals(4, result.size());
    assertEquals("こんな", result.get(0));
    assertEquals("文字列を", result.get(1));
    assertEquals("", result.get(2));
    assertEquals("分解します", result.get(3));
  }

  public void testInputIsNull() throws Exception {

    TestHandler handler = new TestHandler();
    String input = null;
    String delim = "::";

    try {
      new StringSplitterByString(handler,
          input, delim);
      fail("inputがnullなら例外");
    } catch (IllegalArgumentException e) {
      // ここに来れば良い
    }
  }

  public void testDelimIsNull() throws Exception {

    TestHandler handler = new TestHandler();
    String input = "こんな::文字列を::::分解します";
    String delim = null;

    try {
      new StringSplitterByString(handler,
          input, delim);
      fail("delimがnullなら例外");
    } catch (IllegalArgumentException e) {
      // ここに来れば良い
    }
  }

  public void testHandlerIsNull() throws Exception {

    TestHandler handler = null;
    String input = "こんな::文字列を::::分解します";
    String delim = null;

    try {
      new StringSplitterByString(handler,
          input, delim);
      fail("handlerがnullなら例外");
    } catch (IllegalArgumentException e) {
      // ここに来れば良い
    }
  }

  public void testInputIsNothing() throws Exception {

    TestHandler handler = new TestHandler();
    String input = "";
    String delim = "::";

    Splitter outputter = new StringSplitterByString(handler,
          input, delim);

    // 文字列分解を行う
    outputter.split();

    // handlerは分解された文字を受け取る
    List result = handler.getResult();
    
    // handleSpritは一度も呼ばれていない
    assertEquals(0, result.size());
  }

  public void testDelimIsNothing() throws Exception {

    TestHandler handler = new TestHandler();
    String input = "こんな::文字列を::::分解します";
    String delim = "";

    Splitter outputter = new StringSplitterByString(handler,
          input, delim);

    // 文字列分解を行う
    outputter.split();

    // handlerは分解された文字を受け取る
    List result = handler.getResult();
    
    // handleSpritは一度も呼ばれていない
    assertEquals(0, result.size());
  }

}

*1:本来はByCharの方を使うべきかもしれません

テストしにくいクラス

http://d.hatena.ne.jp/ms2310/20070407#p1
ではとりあえずxUnitの紹介をしてみました。単純なクラスはこれでテストをやってゆけると思います。では

  • クラスの中で特殊なライブラリを呼び出していて、それが実行環境*1に依存している
  • 通信して戻ってきた結果を利用して処理を行う
  • システムの日付等に結果が影響されてしまい、一定の結果が返ってこない

このようなテストのしにくいクラスをどうにかテストできる形にしようというのが今回の目的です。

サンプルとして以下のような大雑把な仕様のクラスをテストすることを考えます。

クラス名
HtmlTableCreator
メソッド
String create()
仕様
createメソッドで実行環境からCSVファイルのパスを取得し、そのファイルの内容HTMLのテーブルタグの形式で出力する。

実行のイメージは以下のような形でしょう。

    HtmlTableCreator creator = new HtmlTableCreator();
    String table = creator.create();

環境に依存しない部分だけ別のクラスとして切り出す方法もありますが、今回はその手段を取らずにテストする方法を考えます。

package ms2310.xunit.section1.sample0;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;

import ms2310.oot.section5.sample0.SplitHandler;
import ms2310.oot.section5.sample0.StringSplitterByString;

/**
 * テストがしにくい実装の入ったクラス
 */
public class HtmlTableCreator {

  private class CSV2TableHandler implements SplitHandler{
    private StringBuffer buffer;

    CSV2TableHandler(StringBuffer buffer){
      this.buffer = buffer;
    }
    
    public void handleSplit(String str) {
      buffer.append("<td>").append(str).append("</td>");
    }
    
  }
  
  public String create() throws IOException {
    BufferedReader reader = new BufferedReader(createCSV());
    String delim = ",";
    StringBuffer buffer = new StringBuffer();
    buffer.append("<table>\n");
    CSV2TableHandler handler = new CSV2TableHandler(buffer);

    String line;
    while((line = reader.readLine()) != null){
      buffer.append("<tr>");
      StringSplitterByString ss = new StringSplitterByString(handler, line, delim);
      ss.split();
      buffer.append("</tr>\n");
    }
    buffer.append("</table>");
    
    return buffer.toString();
  }

  Reader createCSV(){
    //ここはシステムや環境に依存していて
    //切り離すのが難しい処理が入ります。
    //例えばサーブレットAPIを利用してファイル内容を取得する等

    //この戻り値はテストには無関係
    return null;
  }
}
package ms2310.xunit.section1.sample0;

import java.io.BufferedReader;
import java.io.Reader;
import java.io.StringReader;

import junit.framework.TestCase;

public class HtmlTableCreatorTest extends TestCase {

  private static class TestHtmlTableCreator extends HtmlTableCreator{
    Reader createCSV() {
      //実装クラスの関数をオーバーライドして、常にテスト用の戻り値が取得できるようにする。
      String ret = "1,3,5\n" +
            "2,,6\n" + 
            "3,3,11";
      return new StringReader(ret);
    }
  }
  
  
  public void testSenario() throws Exception{
    
    //本来HtmlTableCreatorはテストが難しいクラスだったが、
    //原因となる部分をオーバーライドしたTestHtmlTableCreatorはテストがしやすいクラス。
    HtmlTableCreator creator = new TestHtmlTableCreator();
    String table = creator.create();
    
    //テーブルタグが出力される
    BufferedReader reader = new BufferedReader(new StringReader(table));
    assertEquals("<table>", reader.readLine());
    assertEquals("<tr><td>1</td><td>3</td><td>5</td></tr>", (Object)reader.readLine());
    assertEquals("<tr><td>2</td><td></td><td>6</td></tr>", (Object)reader.readLine());
    assertEquals("<tr><td>3</td><td>3</td><td>11</td></tr>", (Object)reader.readLine());
    assertEquals("</table>", reader.readLine());
  }
  
}

テストに邪魔な関数をオーバーライドして、テスト用の実装で上書きしてしまうのです。これでテストしにくい状況を作り出している部分を消してしまいます。
本来はこのような「実装の上書き」の意味でのオーバーライドは嫌われるものだと思いますが、テストする際には重要なテクニックでしょう。また、「Readerを生成する」という概念をクラス内の生成関数として抜き出す形は、FactoryMethodというデザインパターンで知られる形式*2です。

Javaの場合、テストの為だけにオーバーライドさせる関数はパッケージプライベート(デフォルト)のアクセス権限を持たせることでテストケースからの上書きを可能にすると良いでしょう。テストケースはテスト対象クラスと同じパッケージに置き、別のディレクトリをルートにすることで管理上も分けるのが扱いやすいと思います。

また、今回のようなCSVはファイルを読み込む実装になることが多いので、ファイルから読み込ませるケースを書くことも可能だとは思いますが、テストケースとテストデータが離れてしまうとかえってわかりにくいものになってしまいます。そのため自分はなるべくテストケース内に書いてしまうようにしています。*3

さらに余談となりますが

 assertEquals("<tr><td>3</td><td>3</td><td>11</td></tr>", (Object)reader.readLine());

のように右辺をあえてObjectにキャストしているのはStringの比較の場合、長すぎる文字列が省略されてしまうことを嫌っています。このキャストを入れることで全ての文字列が出力されます。文字列比較の時に役に立つ方法かもしれません。

*1:例えばサーブレット

*2:デザインパターンも時間があれば少し触れたいところです。

*3:PerlPHPのようにヒアドキュメントがあると便利だと思ったりします。

名前付けは適切に

今までSplitStringOutputterというインターフェースを実装したStringSplitStringOutputterやCharSplitStringOutputterを扱ってきました。しかし、よく見るとStringSplitStringOutputterはSplitHandlerに文字列を渡しているだけで、Output(出力)は何もしていません。これはコードを見る人(自分自身も含めて)に誤解を与えます。

SplitStringOutputterは「何か」を分割し、分割した結果を文字列としてSplitHandlerへ渡すだけです。Splitterとしましょう。というわけで以下のように

  • SplitStringOutputterはSplitter
  • StringSplitStringOutputterはStringSplitterByString
  • CharSplitStringOutputterはStringSplitterByChar

と名前を変更しました。

名前は第一印象にもなりますし、会話する際にも必要な道具です。良い名前を付けることはスキルの一つであると思います。自分はこれが相当苦手ですが。

単体テストの自動化 xUnit

http://d.hatena.ne.jp/ms2310/20070121#p1
までは結果を確かめる為にmain関数を動かした時の出力をコメントで書いていました。
プログラムを書いている時の作業は以下のようになります。

  1. コードを書く
  2. main関数を起動させる。
  3. 望み通りの出力になっているか確認する。

これの繰り返しです。

昔のコードに手を加えた場合など、上記の手順で確認していると知らぬ間に望み通りの結果になっていない場合があります。デグレードと呼ばれるものです。これに気付かずに納品してしまうと「今まで動いていた製品がちゃんと動かないです」とサポートに電話がかかるようなことになります。

これを防ぐには以前動いていたものが手を加えた後も望み通り動いているか(目で見るというようなミス発生の可能性の高い方法を取らずに)正確に確認する手段が必要です。そのような手段の一つにxUnitと呼ばれるものがあります。

xUnitとは総称で、各言語ごとにJUnitCppUnitPHPUnit等の実装が存在します。今回はJavaの実装であるJUnitを利用します。尚、ここではJUnitの具体的な利用方法については触れません。


StringSplitStringOutputterのテストクラス(テストケースと呼ばれます)は例えば以下のようなものです。

package ms2310.oot.section3.sample0;

import java.util.ArrayList;
import java.util.List;

import junit.framework.TestCase;

public class StringSplitStringOutputterTest extends TestCase {

  private static class TestHandler implements SplitHandler{

    private List result = new ArrayList();
    
    public void handleSplit(String str) {
      result.add(str);
    }
    
    public List getResult(){
      return result;
    }
  }
  
  public void testSenario() throws Exception{
    
    TestHandler handler = new TestHandler();
    
    String input = "こんな::文字列を::::分解します";
    String delim = "::";

    SplitStringOutputter outputter = new StringSplitStringOutputter(
        handler, input, delim);
    
    //文字列分解を行う
    outputter.output();
    
    //handlerは分解された文字を受け取る
    List result = handler.getResult();
    
    assertEquals(4, result.size());
    assertEquals("こんな", result.get(0));
    assertEquals("文字列を", result.get(1));
    assertEquals("", result.get(2));
    assertEquals("分解します", result.get(3));
  }
  
}

TestHandlerというテスト用のSplitHandlerを作成しました。このようなことができるのも、ポリモーフィズムのお陰です。また、CharSplitStringOutputterTestも同様のコードになるでしょう。

さて、テストにはそのクラスが何の目的で作成されていて、どんな動作をするのかということを示す必要があります。testSenarioは俗に言う正常系のテストであり、サンプルでもあります。異常系として考えられることは何でしょう。例えば以下のようなものでしょうか。

  1. inputがnullだった場合
  2. delimがnullだった場合
  3. inputが空文字だった場合
  4. delimが空文字だった場合
  5. handlerがnullだった場合

1、2、5についてはコンストラクタでIllegalArgumentExceptionを投げることにしましょう。3、4は一回もSplitHandler.handleSplitが呼ばれずに処理が終わるものとします。

ではまず1から始めます。上記テストクラスに以下のようなメソッドを追加しました。

  public void testInputIsNull() throws Exception {

    TestHandler handler = new TestHandler();
    String input = null;
    String delim = "::";

    try {
      new StringSplitStringOutputter(handler,
          input, delim);
      fail("inputがnullなら例外");
    } catch (IllegalArgumentException e) {
      // ここに来れば良い
    }
  }

これで一度テストを実行します。テストは失敗しますね。"inputがnullなら例外"というメッセージが出力されるはずです。StringSplitStringOutputterではまだコンストラクタでの引数チェックを行っていないからです。引数チェックを実装してテストを成功させましょう。同様に他の仕様に関しても実装します。

テストファーストという言葉があります。「まずテストケースを作成し、現在の実装クラスにおいてテストが失敗してから修正し、テストを通すことで完成させる。」というものです。これによりテストが完全に通っていることが保証されるという概念です。より確実なテストを目指すならばこれを意識すると良いでしょう。ただし、設計がコロコロ変わるような段階で行うと設計を変更する度にテストも修正しなければならないので、ある程度固まってから作成するのが良さそうだと筆者は考えています。

また、テストケースの中ではあまり処理を細かい関数に分けない方が良いようです。多少冗長な表現であった方が見る人にわかりやすくなることが多いです。

結果、以下のようなコードになりました。全てを載せるのは大変なので中心になったStringSplitStringOutputterとそのテストケースのみ掲載します。

/**
 * 
 */
package ms2310.oot.section3.sample0;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;

class StringSplitStringOutputter implements SplitStringOutputter {
  private String delim;

  private String input;

  private SplitHandler handler;

  StringSplitStringOutputter(SplitHandler handler, String input, String delim) {
    if(handler == null) throw new IllegalArgumentException("handler == null");
    if(input == null) throw new IllegalArgumentException("input == null");
    if(delim == null) throw new IllegalArgumentException("delim == null");
    
    this.delim = delim;
    this.input = input;
    this.handler = handler;
  }

  public void output() throws IOException {
    if(input.length() == 0) return;
    if(delim.length() == 0) return;
    
    BufferedReader reader = new BufferedReader(new StringReader(input));
    int c;
    StringBuffer buf = new StringBuffer();

    char[] delimBuf = new char[delim.length()];
    for (;;) {
      reader.mark(delim.length());
      c = reader.read(delimBuf);

      if (c != delim.length()) {
        handler
            .handleSplit(buf.append(new String(delimBuf, 0, c)).toString());
        break;
      } else if (delim.equals(new String(delimBuf))) {
        handler.handleSplit(buf.toString());
        buf = new StringBuffer();
      } else {
        reader.reset();
        c = reader.read();
        buf.append((char) c);
      }
    }
  }
}
package ms2310.oot.section3.sample0;

import java.util.ArrayList;
import java.util.List;

import junit.framework.TestCase;

public class StringSplitStringOutputterTest extends TestCase {

  private static class TestHandler implements SplitHandler {

    private List result = new ArrayList();

    public void handleSplit(String str) {
      result.add(str);
    }

    public List getResult() {
      return result;
    }
  }

  public void testSenario() throws Exception {

    TestHandler handler = new TestHandler();

    String input = "こんな::文字列を::::分解します";
    String delim = "::";

    SplitStringOutputter outputter = new StringSplitStringOutputter(handler,
        input, delim);

    // 文字列分解を行う
    outputter.output();

    // handlerは分解された文字を受け取る
    List result = handler.getResult();

    assertEquals(4, result.size());
    assertEquals("こんな", result.get(0));
    assertEquals("文字列を", result.get(1));
    assertEquals("", result.get(2));
    assertEquals("分解します", result.get(3));
  }

  public void testInputIsNull() throws Exception {

    TestHandler handler = new TestHandler();
    String input = null;
    String delim = "::";

    try {
      new StringSplitStringOutputter(handler,
          input, delim);
      fail("inputがnullなら例外");
    } catch (IllegalArgumentException e) {
      // ここに来れば良い
    }
  }

  public void testDelimIsNull() throws Exception {

    TestHandler handler = new TestHandler();
    String input = "こんな::文字列を::::分解します";
    String delim = null;

    try {
      new StringSplitStringOutputter(handler,
          input, delim);
      fail("delimがnullなら例外");
    } catch (IllegalArgumentException e) {
      // ここに来れば良い
    }
  }

  public void testHandlerIsNull() throws Exception {

    TestHandler handler = null;
    String input = "こんな::文字列を::::分解します";
    String delim = null;

    try {
      new StringSplitStringOutputter(handler,
          input, delim);
      fail("handlerがnullなら例外");
    } catch (IllegalArgumentException e) {
      // ここに来れば良い
    }
  }

  public void testInputIsNothing() throws Exception {

    TestHandler handler = new TestHandler();
    String input = "";
    String delim = "::";

    SplitStringOutputter outputter = new StringSplitStringOutputter(handler,
          input, delim);

    // 文字列分解を行う
    outputter.output();

    // handlerは分解された文字を受け取る
    List result = handler.getResult();
    
    // handleSpritは一度も呼ばれていない
    assertEquals(0, result.size());
  }

  public void testDelimIsNothing() throws Exception {

    TestHandler handler = new TestHandler();
    String input = "こんな::文字列を::::分解します";
    String delim = "";

    SplitStringOutputter outputter = new StringSplitStringOutputter(handler,
          input, delim);

    // 文字列分解を行う
    outputter.output();

    // handlerは分解された文字を受け取る
    List result = handler.getResult();
    
    // handleSpritは一度も呼ばれていない
    assertEquals(0, result.size());
  }

}

テストケースの利点

今回は単なる実装よりもコードを書く量が増えてしまいました。これが面倒で嫌なものだと感じる人もいるかと思います。しかし、この面倒さを補うほどの効果があります。

  1. デグレードチェックが一発でできる
  2. 他人にクラスの仕様を理解してもらう手段になる
  3. どの程度実装してあるかの証明になる

1は冒頭でも書いたデグレードに関することです。デグレードは精神的にも辛いものであるので、これを自分の作業の中で食い止めることが出来るのは大きな恩恵でしょう。
2は正常系のテストケースはサンプルコードであり、異常系の動作も示しているのでそれ自体がクラスの仕様を示すものであるという考えです。口頭で人に説明するよりも見ればわかるものになっているのは大きいでしょう。こういった仕様はjavadoc等に書かれる事もありますが、実際にjavadoc通りの実装であるのかを示すことが出来ます。
3は組織でアプリケーションを作成する場合に自分の実装レベルを明確にすることができるということです。他人にクラスを提供する際に「この程度はテストしているよ」と証拠を示すことが出来ます。人によっては自分を守る為の手段にもなるでしょう。

一区切り

ここまで、SplitSampleというクラス内にインナークラスを作成してサンプルを示してきましたが、そろそろ分割してしまいます。WEBに貼りやすいという安易な理由でこの方法を取ったのですが、そろそろ限界のようです。今後はそれぞれのクラスがパッケージ直下のクラスであるという扱いをします。

もっとポリモーフィズム

前回までのプログラムは分割した文字列をコンソールに一行ずつ表示していました。
しかし、実際に役に立つアプリケーションを作成する時にコンソールに出せば終わりというケースはごくまれです。というわけでまたクライアントから仕様追加がなされます。

  • 分割した結果をWriterに出力できるようにする。形式は一行ずつbタグで囲むこと。

この仕様に対応するにはどうすれば良いでしょうか。また、今回の仕様に対応するだけだといつ次の変更を受けるかわかりません。元のプログラムをその場だけに対応して書くのは得策では無いでしょう。

差し替え可能に

SplitStringOutputterの派生クラスであるCharSplitStringOutputterとStringSplitStringOutputterとにそれぞれ出力形式を示すものを外から渡せるようにするのはどうでしょうか。下記のようになります。

package ms2310.oot.section2.sample0;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
import java.io.Writer;

public class SplitSample {

  public static void main(String[] args) {

    try {
      {
        //コンソールに出力するサンプル
        SplitHandler handler = new ConsoleLineOutputter();
        {
          //一文字のデリミタ
          String input = "こんな,文字列を,,分解します";
          char delim = ',';

          SplitStringOutputter outputter = new CharSplitStringOutputter(
              handler, input, delim);
          outputSplitString(outputter);
        }

        {
          //複数文字のデリミタ
          String input = "こんな::文字列を::::分解します";
          String delim = "::";

          SplitStringOutputter outputter = new StringSplitStringOutputter(
              handler, input, delim);
          outputSplitString(outputter);
        }
      }

      {
        //Writerに出力するサンプル
        StringWriter writer = new StringWriter();
        SplitHandler handler = new TaggedWriterOutputter(writer);
        {
          String input = "こんな::文字列を::::分解します";
          String delim = "::";

          SplitStringOutputter outputter = new StringSplitStringOutputter(
              handler, input, delim);
          outputSplitString(outputter);
        }

        System.out.println("# Writerの内容です");
        System.out.print(writer.toString());
      }
    } catch (Exception e) {
      // エラー処理を省略しています
      e.printStackTrace();
    }

/* 出力
# 分解開始
こんな
文字列を

分解します
# 分解終了
# 分解開始
こんな
文字列を

分解します
# 分解終了
# 分解開始
# 分解終了
# Writerの内容です
<b>こんな</b>
<b>文字列を</b>
<b></b>
<b>分解します</b>
 */
  }

  static interface SplitStringOutputter {
    void output() throws IOException;
  }

  static interface SplitHandler {
    void handleSplit(String str);
  }

  static class ConsoleLineOutputter implements SplitHandler {

    public void handleSplit(String str) {
      System.out.println(str);
    }
  }

  static class TaggedWriterOutputter implements SplitHandler {

    private Writer writer;

    public TaggedWriterOutputter(Writer writer) {
      this.writer = writer;
    }

    public void handleSplit(String str) {
      try {
        writer.write("<b>");
        writer.write(str);
        writer.write("</b>");
        writer.write('\n');
      } catch (IOException e) {
        // エラー処理を省略しています。
      }
    }
  }

  static class CharSplitStringOutputter implements SplitStringOutputter {
    private char delim;

    private String input;

    private SplitHandler handler;

    CharSplitStringOutputter(SplitHandler handler, String input, char delim) {
      this.delim = delim;
      this.input = input;
      this.handler = handler;
    }

    public void output() throws IOException {
      StringReader reader = new StringReader(input);
      int c;
      StringBuffer buf = new StringBuffer();
      for (;;) {
        c = reader.read();
        if (c == delim) {
          handler.handleSplit(buf.toString());
          buf = new StringBuffer();
        } else if (c == -1) {
          handler.handleSplit(buf.toString());
          break;
        } else {
          buf.append((char) c);
        }
      }
    }
  }

  static class StringSplitStringOutputter implements SplitStringOutputter {
    private String delim;

    private String input;

    private SplitHandler handler;

    StringSplitStringOutputter(SplitHandler handler, String input, String delim) {
      this.delim = delim;
      this.input = input;
      this.handler = handler;
    }

    public void output() throws IOException {
      BufferedReader reader = new BufferedReader(new StringReader(input));
      int c;
      StringBuffer buf = new StringBuffer();

      char[] delimBuf = new char[delim.length()];
      for (;;) {
        reader.mark(delim.length());
        c = reader.read(delimBuf);

        if (c != delim.length()) {
          handler
              .handleSplit(buf.append(new String(delimBuf, 0, c)).toString());
          break;
        } else if (delim.equals(new String(delimBuf))) {
          handler.handleSplit(buf.toString());
          buf = new StringBuffer();
        } else {
          reader.reset();
          c = reader.read();
          buf.append((char) c);
        }
      }
    }
  }

  private static void outputSplitString(SplitStringOutputter outputter)
      throws IOException {

    System.out.println("# 分解開始");
    outputter.output();
    System.out.println("# 分解終了");
  }
}

元々の仕様はFileへの出力等を目指したものです。『Writerに書ける⇒FileWriterに書ける』というポリモーフィズムの性質が適用されます。今回の例ではStringWriterに書くことにしました。
新たに登場したSplitHandlerは文字列を分解する度に呼ばれるハンドラメソッドhandleSplitを持っています。文字を分解した後それをコンソールに出すのか、それともWriterに書くのか、装飾の文字列(bタグ等)を付けるのか等、分解後の様々な処理を担当します。これを実装したのがConsoleLineOutputterとTaggedWriterOutputterです。

差し替え可能の利点

新たなインターフェースを作成した理由がわからなければ、自分で同様の問題を解決することはできません。「教科書の例題は読めるけれど、自分では解けない」子ではつまらないですね。
では、今回のケースでの利点は何でしょうか。

  • 出力形式の新たな変更が起きた場合(例えばbタグではなくdivタグに)SplitHandlerの派生クラス以外変更する必要が無い→バグを埋め込む可能性が減る。
  • 出力形式以外の汎用的な部分を使いまわすことが出来る。

時間の節約ができるというのが根源的なものでしょうか。バグが減るということは結局デバッグする時間が短縮できることになります。これからはたとえ「分割した文字列をメールで送る」というような仕様変更があってもSplitHandlerの派生クラスでメールを送信する機能を作れば良いことになります。


ここでは示せなかったのですが、下記のようなこともあります。

  • テスト用の実装と、本番用の実装を準備するようなことが可能になる*1

*1:ユニットテストの自動化の話を書く機会があればこの件も書きたいです

文字列を分割しよう

課題として文字列を分割するロジックを考えることにします。まずこんな仕様です。

  • 与えられた文字列を分割する。
  • 分割した文字列は一行ごとコンソールに出力する。
  • 分割するための文字列(デリミタ)は一文字の文字とする。

JavaAPIをよくご存知の人はこんな風にしてこれを実現するかもしれません。新人さんでこれをサラッと書いてくれるなら教える側としても非常に楽でしょうね。

package ms2310.oot.section1.sample0;

import java.util.StringTokenizer;

public class SplitSample {

  public static void main(String[] args) {
    String input = "こんな,文字列を,分解します";
    String delim = ",";

    StringTokenizer tokenizer = new StringTokenizer(input, delim);
    while (tokenizer.hasMoreTokens()) {
      System.out.println(tokenizer.nextToken());
    }
    
    /* 出力
こんな
文字列を
分解します
    */
  }
}

では、仕様追加です。

  • デリミタが連続で続く場合は、改行のみを出力する。

上記のプログラムをそのまま利用すると以下のようになります。

package ms2310.oot.section1.sample1;

import java.util.StringTokenizer;

public class SplitSample {

  public static void main(String[] args) {
    String input = "こんな,文字列を,,分解します";
    String delim = ",";

    StringTokenizer tokenizer = new StringTokenizer(input, delim);
    while (tokenizer.hasMoreTokens()) {
      System.out.println(tokenizer.nextToken());
    }
    
    /* 出力
こんな
文字列を
分解します
    */
  }
}

追加された仕様を満たすことができません。恐らく2007年1月現在のStringTokenizerではこれを実現することが出来ないのでしょう。無いものは自分で書かなければなりませんね。例えば以下のようになるでしょう。

package ms2310.oot.section1.sample2;

import java.io.IOException;
import java.io.StringReader;

public class SplitSample {

  public static void main(String[] args) {

    try {
      String input = "こんな,文字列を,,分解します";
      char delim = ',';

      outputSplitString(input, delim);
    } catch (Exception e) {
      //エラー処理を省略しています
      e.printStackTrace();
    }

    /* 出力
こんな
文字列を

分解します 
     */
  }

  private static void outputSplitString(String input, char delim)
      throws IOException {
    StringReader reader = new StringReader(input);
    int c;
    StringBuffer buf = new StringBuffer();
    for(;;) {
      c = reader.read();
      if(c == delim){
        System.out.println(buf.toString());
        buf = new StringBuffer();
      }
      else if(c == -1){
        System.out.println(buf.toString());
        break;
      }
      else{
        buf.append((char)c);
      }
    }
  }
}

仕様を満たすことができました。一安心ですね。
では、鬼のようなクライアント(?)がさらに仕様を変更します。

  • デリミタは一文字でも、一文字以上の文字列でも良いこととする。
  • 文字列分解の開始時に"# 分解開始"とコンソールに出力する。
  • 文字列分解の終了時に"# 分解終了"とコンソールに出力する。

とりあえず、以下のようにすれば実現できるでしょう。

package ms2310.oot.section1.sample3;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;

public class SplitSample {

  public static void main(String[] args) {

    try {

      {
        String input = "こんな,文字列を,,分解します";
        char delim = ',';
        
        outputSplitString(input, delim);
      }
     
      {
        String input = "こんな::文字列を::::分解します";
        String delim = "::";
  
        outputSplitString(input, delim);
      }
      

    } catch (Exception e) {
      // エラー処理を省略しています
      e.printStackTrace();
    }

    /*
     * 出力
# 分解開始
こんな
文字列を

分解します
# 分解終了
# 分解開始
こんな
文字列を

分解します
# 分解終了
     */
  }

  private static void outputSplitString(String input, char delim)
      throws IOException {
    System.out.println("# 分解開始");

    StringReader reader = new StringReader(input);
    int c;
    StringBuffer buf = new StringBuffer();
    for (;;) {
      c = reader.read();
      if (c == delim) {
        System.out.println(buf.toString());
        buf = new StringBuffer();
      } else if (c == -1) {
        System.out.println(buf.toString());
        break;
      } else {
        buf.append((char) c);
      }
    }

    System.out.println("# 分解終了");
  }

  private static void outputSplitString(String input, String delim)
      throws IOException {
    
    System.out.println("# 分解開始");
   
    BufferedReader reader = new BufferedReader(new StringReader(input));
    int c;
    StringBuffer buf = new StringBuffer();
    
    char[] delimBuf = new char[delim.length()]; 
    for (;;) {
      reader.mark(delim.length());
      c = reader.read(delimBuf);
      
      if (c != delim.length()) {
        System.out.println(buf.append(new String(delimBuf, 0, c)));
        break;
      }
      else if (delim.equals(new String(delimBuf))) {
        System.out.println(buf.toString());
        buf = new StringBuffer();
      } else {
        reader.reset();
        c = reader.read();
        buf.append((char) c);
      }
    }

    System.out.println("# 分解終了");        

  }
}

一文字(char)で分割するロジックは効率が良さそうなのでそのまま残しています。
さて、このコードには少し嫌な部分があります。"# 分解開始"、"# 分解終了"と出力するロジックが重複しているのです。*1では、この重複を解消したいと思います。

ロジックを外から渡すには

リファクタリング
プログラムの動作を変えることなく、コードの形式を変更すること。関数名を変更したり、処理を抽出して新たに関数を作ったりすること等がこれに該当します。今からやることはリファクタリングの一種です。

今、outputSplitString関数はオーバーロードで同じ名前の関数が二つできています。そのため分解開始と分解終了時に来る部分がそれぞれ二つずつ存在してしまうのです。できれば、「分解するロジック」というまとまりは一つにしておきたいですね。outputSplitStringの中身は以下のような流れで構成されます。

  1. 分解前ロジック("# 分解開始"と出力)
  2. 実際の分解ロジック
  3. 分解後ロジック("# 分解終了"と出力)

これをコードに置き換えようとすると、少し頭を使わなければならない事態になります。デリミタは文字列(String)であったり、文字(char)であったりするからです。そしてデリミタの型はロジックの内容に依存しています。考えられるのは例えば以下のような方法でしょうか。

  1. outputSplitString(String input, String sdelim, char cdelim)のように両方のデリミタを引数にして、中でif文で分岐する。
  2. String限定にして、一文字かどうかをif文で分岐する。
  3. それ以外の方法

1は不要な引数を作ってしまいます。避けたい形ですね。
2はそれなりに有力です。場合によっては採用する手でしょう。
ここでは3を考えます。

今やりたいことは

  • ロジックを関数の外から渡したい。
  • ロジックは二種類あって、どちらも同じ関数に渡せるようにしたい。

これを実現するためにはインターフェースを利用するのが近道でしょう*2。例えば以下のようになります。

package ms2310.oot.section1.sample4;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;

public class SplitSample {

  public static void main(String[] args) {

    try {

      {
        String input = "こんな,文字列を,,分解します";
        char delim = ',';
        
        SplitStringOutputter outputter = new CharSplitStringOutputter(input, delim);
        outputSplitString(outputter);
      }
     
      {
        String input = "こんな::文字列を::::分解します";
        String delim = "::";
  
        SplitStringOutputter outputter = new StringSplitStringOutputter(input, delim);
        outputSplitString(outputter);
      }
      

    } catch (Exception e) {
      // エラー処理を省略しています
      e.printStackTrace();
    }

    /*
     * 出力 
# 分解開始
こんな
文字列を

分解します
# 分解終了
# 分解開始
こんな
文字列を

分解します
# 分解終了
     */
  }

  static interface SplitStringOutputter{
    void output() throws IOException;
  }
  
  static class CharSplitStringOutputter implements SplitStringOutputter{
    private char delim;
    private String input;
    
    CharSplitStringOutputter(String input, char delim){
      this.delim = delim;
      this.input = input;
    }
    
    public void output() throws IOException{
      StringReader reader = new StringReader(input);
      int c;
      StringBuffer buf = new StringBuffer();
      for (;;) {
        c = reader.read();
        if (c == delim) {
          System.out.println(buf.toString());
          buf = new StringBuffer();
        } else if (c == -1) {
          System.out.println(buf.toString());
          break;
        } else {
          buf.append((char) c);
        }
      }      
    }   
  }
  
  static class StringSplitStringOutputter implements SplitStringOutputter{
    private String delim;
    private String input;
    
    StringSplitStringOutputter(String input, String delim){
      this.delim = delim;
      this.input = input;
    }
    
    public void output() throws IOException{
      BufferedReader reader = new BufferedReader(new StringReader(input));
      int c;
      StringBuffer buf = new StringBuffer();
      
      char[] delimBuf = new char[delim.length()]; 
      for (;;) {
        reader.mark(delim.length());
        c = reader.read(delimBuf);
        
        if (c != delim.length()) {
          System.out.println(buf.append(new String(delimBuf, 0, c)));
          break;
        }
        else if (delim.equals(new String(delimBuf))) {
          System.out.println(buf.toString());
          buf = new StringBuffer();
        } else {
          reader.reset();
          c = reader.read();
          buf.append((char) c);
        }
      }      
    }
  }
  
  private static void outputSplitString(SplitStringOutputter outputter)
      throws IOException {

    System.out.println("# 分解開始");
    outputter.output();
    System.out.println("# 分解終了");
  }
}

outputSplitString(SplitStringOutputter outputter)という関数ができました。ここで定型的な出力処理を行っています。実際のロジックはoutputter.output()で呼ばれます。

SplitStringOutputterは「outputというメソッドを持っている何かの型」を示したインターフェースです。実際にoutputが呼ばれた時に何が起こるのか、という部分はoutputSplitStringを呼び出している箇所を見てはじめてわかります。mainの中でその記述がありますが

  1. CharSplitStringOutputter
  2. StringSplitStringOutputter

の二つになっています。これらのクラスにはそれぞれoutputというメソッドが存在して、ここでoutputter.output()が呼ばれた時にどんな動きをするのかが記されているのです。ちなみに、上記二つのクラスのoutputメソッドは今まで関数で書かれていたロジックをそのまま使っています。

これで分解処理の前後での出力が一つにまとまりました。こういった感覚を持つことによって、手続き型の考え方よりも処理の共通化がやりやすくなります。

このような型を利用して動作を変更する方法をポリモーフィズムと呼びます。

*1:重複コードが嫌だと思えることは、プログラマが仕事をしていく上で大切な感覚だと思います。特に保守することを考えると、出来る限り重複が少ないことが望ましいです。

*2:他の言語ではクロージャでやろうとかdelegateでやろうとか、いろいろありそうです。今は簡単なところから入りたいのでこんな例になります。