Streams API をこれまでまともに使ったことがなかった。
まともに使おうとしたら例外処理の扱いが非常に難しいことを実感したので、一例を挙げてその難しさを解説する。
##やりたい処理
複数のテキストファイルのそれぞれを1行ずつ読みこみ、空白文字で各行を単語に分割し、単語に対して何か処理を行なう、というよくある処理を実装してみよう。処理の手順を手続き的に書くと以下のようになる。
- あるディレクトリの直下に存在する複数のテキストファイル(拡張子 .txt)を1つずつ読む
- 各テキストファイルを1行ずつ読む
- 各行(文字列)を、空白文字を区切り文字として複数の文字列(単語と呼ぶことにする)に分割する
- 各単語に対して何か処理をする。以下、処理の名前を analyze とする
##クラスの雛形
以下の TextFileProcessor を拡張したクラスを実装する。
各単語に関する処理 analyze は実装済みとして、process() メソッドだけを実装すれば良い。
import java.io.File;
import java.io.IOException;
public abstract class TextFileProcessor {
@SuppressWarnings("serial")
public static class MyException extends Exception {
}
public void analyze(String word) throws MyException {
// do something for the given word
}
// implement this!
public abstract void process(File directory) throws IOException, MyException;
}
Streams API を使わない実装
Streams API を使わない場合、例えば以下のように書ける。この書き方は自分にとって馴染みのあるものである。
import java.io.BufferedReader;
import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.nio.file.Files;
public class NormalTextFileProcessor extends TextFileProcessor {
public NormalTextFileProcessor() {
}
@Override
public void process(File directory) throws IOException, MyException {
for (File file : directory.listFiles(new FileFilter() {
public boolean accept(File path) {
return path.getName().endsWith(".txt") && path.isFile();
}
})) {
try (BufferedReader r = Files.newBufferedReader(file.toPath())) {
String line = null;
while ((line = r.readLine()) != null) {
for (String word : line.split("\\s")) {
this.analyze(word);
}
}
}
}
}
}
Streams API を使った実装
本題はここからである。Streams API を使えば、上の NormalTextFileProcessor に見られるような匿名クラスの生成(FileFilter)や BufferedReader から読み込みにわざわざローカル変数(line)を持ち出す煩わしさが無い。そう期待して書いたコードが以下である。
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.util.stream.Stream;
public class StreamsTextFileProcessor extends TextFileProcessor {
public StreamsTextFileProcessor() {
}
@Override
public void process(File directory) throws IOException, MyException {
Files.list(directory.toPath())
.filter(p -> (p.toString().endsWith(".txt") && p.toFile().isFile()))
.forEach(f -> {
try (Stream<String> lines = Files.lines(f)) {
lines
.forEach(
line -> {
Stream.of(line.split("\\s"))
.forEach(word -> {
try {
this.analyze(word);
} catch (MyException e) {
throw new RuntimeException(e);
}
});
}
);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
}
綺麗になるどころか、try-catch のネストのせいでむしろ読みにくくなってしまった。
この理由は、Streams API で用いられる Function, Consumer を始めとする functional interfaces のメソッドたちが、検査例外をスローする定義になっていないからである。
java.nio.file.Files#lines メソッドは IOException をスローするが、これを forEach から外にスローできないので、forEach 内で try-catch して非検査例外でラップして再スローせざるを得ない。ちなみに UncheckedIOException は Java 8 から新たに導入されている。この目的のためであろうか。
また、analyze メソッドはユーザー定義の例外 MyException をスローする。 MyException も検査例外なので、やはり forEach 内で非検査例外でラップするなどの対応をする必要がある。今回の例のようにユーザー定義例外の仕組みが非検査例外をサポートしていなかった場合、RuntimeException などの既存の例外クラスを使うしかなく、process メソッドの呼び出し側にとっては使い勝手が悪い。
関数型言語に詳しくないので推測の域を出ないが、アトミックな処理は副作用を発生しない、という前提のもとで言語仕様が定められた結果、このように検査例外を許さない定義になったのだろうか。例外をスローする事態というのは副作用が現れたことに他ならないからである。
この問題は良く知られているらしく、stackexchange を漁ると関連する記事がいくつも見つかる。以下はその中で特に目に留まったものである。
http://stackoverflow.com/questions/27644361/how-can-i-throw-checked-exceptions-from-inside-java-8-streams/
この問題に対処するため独自にライブラリ(ラッパー)を書いている人もいるくらいである。
https://github.com/JeffreyFalgout/ThrowingStream
ファイルや外部サービスとの入出力が発生する処理を含む処理を、少なくとも現状の Streams API で書かないほうが良いと改めて感じた。もし、上記の StreamsTextFileProcessor をもっとエレガントに書く方法を思いついた方は、ご教示頂けると幸いである。