文字列を分割しよう

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

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

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でやろうとか、いろいろありそうです。今は簡単なところから入りたいのでこんな例になります。