JavaのStream API は非常に便利ですが、例外処理が面倒です。
ラムダ内で実行時例外に置き換えるとかOptionalを利用する方法もありますが、もう少しきちんとハンドリングしたい。せっかくなのでモナド的にEitherとか使いたい。と思い調べたところ、今のところ標準ではEitherは実装されていないが、vavrライブラリを利用することで行けるようです。
以前はjavaslangと言われていたライブラリが名前が変わったみたいですね。
EitherはTupleのようにRight/Leftに2つの要素を保持する入れ物で、Rightに正常な値を、Leftにエラーを保持することでOptionalのように連鎖的に処理をつないでいけます。
Java8 Streamから学ぶOptionalモナドとEitherモナド。
というわけで、StreamAPIでEitherを活用していく方法を整理したいと思います。
Streamでの個々の例外処理パターン
streamの要素1つ1つに何らかの処理を行い、その処理内で例外が発生する場合、その例外をどう扱いたいでしょう。
とりあえず思いつくのは、以下の3パターンくらいでしょうか。
- 例外が発生した要素をすべて無視して成功した要素だけを取り出したい
- 最初に発生した例外を捕捉したい
- 発生した例外をすべて捕捉したい
これらそれぞれについて実装方法を考えていきます。
0. 事前準備
サンプルコード
今回の問題を考えるためのサンプルコードです。
文字列リストを引数に取り、Integer#parseIntを挟んでIntegerリストを生成します。
public void test() {
List<String> list = Arrays.asList("123", "abc", "45", "def", "789");
List<Integer> result = list.stream() // Streamにする
.map(this::parseInt) // 個々の要素を変換する
.collect(Collectors.toList()); // 終端処理としてListに戻す
}
private Integer parseInt(String data) {
try {
return Integer.parseInt(value);
} catch (Exception ex) {
return null; //エラーを握りつぶしてnullにしている
}
}
このサンプルではエラーを握りつぶしてnullを返しているので、
[123, null, 45, null, 789]
という感じのリストが得られます。
vavrをインポート
vavrを使えるようにしておきます。
dependencies {
...
implementation 'io.vavr:vavr:0.9.3'
}
1. 成功した要素だけを取り出す
この場合はエラーをハンドリングしないので、Eitherは必要ありません。
public void testFilterSuccess() {
List<String> data = Arrays.asList("123", "abc", "45", "def", "789");
// List<Integer>
var result = data.stream()
.map(this::parseIntOpt)
.filter(Optional::isPresent).map(Optional::get)
.collect(Collectors.toList());
}
private Optional<Integer> parseIntOpt(String value) {
try {
return Optional.of(Integer.parseInt(value));
} catch (Exception ex) {
return Optional.empty();
}
}
filterで値を持つ要素のみをフィルタリングし、mapで取り出せば終了です。
2. 最初に発生した例外を取り出す
ここからが本題。
まずは要素の処理でEitherを返すように修正します。
private Either<Exception, Integer> parseIntEither(String value) {
try {
return Either.right(Integer.parseInt(value));
} catch (Exception ex) {
return Either.left(ex);
}
}
この関数を利用するようにサンプルを修正
public void testFirstException() {
List<String> data = Arrays.asList("123", "abc", "45", "def", "789");
// List<Either<Exception, Integer>>
var result = data.stream()
.map(this::parseIntEither)
.collect(Collectors.toList()));
}
この段階ではまだEitherのリストです。
これを、エラーがある場合は最初のエラーを取り出し、正常な場合にはList<Integer>
を取得したいので、さらに変換をかけていきます。
ここでは、vavrに実装されたstaticメソッドのEither#sequenceRightを利用します。
// Either<Exception, Seq<Integer>>
var result = Either.sequenceRight(data.stream()
.map(this::parseIntEither)
.collect(Collectors.toList()));
このメソッドがEitherのListを順に適用し、Either.left()
があればその値を保持、全てEither.right()
であればそれを集約したEither.right(Seq<Integer>)
を返してくれます。
あとはEitherから値を取り出すだけです。
try {
List<Integer> intList = result.getOrElseThrow(ex -> ex).toJavaList();
// handle intList
} catch (Exception ex) {
// handle exception
}
Seq
はvavrで定義されたSequenceクラスなのでJavaのListに変換していますが、問題なければSeqのまま後続処理してもOKです。
という訳で、まとめると以下のようなコードになります。
public void testFirstException() {
List<String> data = Arrays.asList("123", "abc", "45", "def", "789");
try {
List<Integer> result = Either.sequenceRight(data.stream()
.map(this::parseIntEither)
.collect(Collectors.toList()))
.getOrElseThrow(ex -> ex).toJavaList();
} catch (Exception ex) {
// 下のエラーがthrowされる
// For input string: "abc"
}
}
かなりスッキリと書ける感じがします。
3. 発生した例外をすべて取り出す
途中までは2.と一緒です。
Either#sequenceRightに代わりEither#sequenceを利用するとすべてのExceptionを集めてSeqを返してくれます。
// Either<Seq<Exception>, Seq<Integer>>
var result = Either.sequence(data.stream()
.map(this::parseIntEither)
.collect(Collectors.toList()));
これを利用して、集めたExceptionリストをfoldでまとめていくと以下のような記述が可能です。
public void testAllException() {
List<String> data = Arrays.asList("123", "abc", "45", "def", "789");
try {
List<Integer> result = Either.sequence(data.stream()
.map(this::parseIntEither)
.collect(Collectors.toList()))
.getOrElseThrow(exSeq -> exSeq.fold(new Exception("Multiple Exception"),
(root, value) -> {
root.addSuppressed(value);
return root;
}))
.toJavaList();
} catch (Exception ex) {
// ex.getSuppressed()に"abc,"def"の2つのExceptionを保持したExceptionがthrowされる
}
}
これで各要素のExceptionを集約することもできました。
番外編: ExceptionのListとIntegerのListをそれぞれ取り出す (追記あり)
EitherはLeftかRightか必ずどちらかの状態を取るクラスなので、ExceptionがあればisLeft()で、無ければisRight()です。
そのため、Exceptionがある場合にExceptionも取り出しつつ正常時の値も集めることは出来ません。
そんな用途があるかどうかは不明ですが、勉強も兼ねてList<Either<Exception, Integer>>
をList<Exception>
とList<Integer>
に分離する方法も検討してみます。
アプローチとしてはStreamの終端処理の
collect(Supplier<R> supplier,
BiConsumer<R, ? super T> accumulator,
BiConsumer<R, R> combiner)
を利用し、vavrのTuple2<List<Exception>, List<Integer>>
にCollectしていきます。
//Tuple2<List<Exception>, List<Integer>>
var result = data.stream()
.map(this::parseIntEither)
.collect(() -> Tuple.of(new ArrayList<Exception>(), new ArrayList<Integer>()),
(accumulator, value) -> {
if (value.isLeft()) {
accumulator._1.add(value.getLeft());
} else {
accumulator._2.add(value.get());
}
},
(left, right) -> {
left._1.addAll(right._1);
left._2.addAll(right._2);
});
ここまで書いてしまうとあまり綺麗ではないですね。。
どうしても実装したい場合はCollectorインターフェースを実装したクラスを別途作ったほうがよいかも知れません。
[追記]
コメントでとてもシンプルな書き方をご教示いただきました。
collect(Collectors.collectingAndThen(
Collectors.groupingBy(Either::isLeft),
map -> Tuple.of(map.get(true),map.get(false))
);
Collectors.groupingBy
は、Streamの要素を指定した処理でグループ化し、(グループ化キー -> 要素リスト)のMapを返してくれます。
今回の場合、Either::isLeftがtrueかfalseかでグループ化して Map<Boolean, Seq<Either<Exception, Integer>>>
が得られます。
さらに、Collectors.collectingAndThen
は、1つ目の引数にcollect処理を、2つ目の引数にその後処理を記述できます。
そこで2つ目の引数でmapから値を取り出してTupleに変換します。
他の例と同様に整理すると以下のようなコードになります。
var result = data.stream()
.map(this::parseIntEither)
.collect(Collectors.collectingAndThen(
Collectors.groupingBy(Either::isLeft),
map -> Tuple.of(
Either.sequence(map.get(true)).getLeft().toJavaList(),
Either.sequence(map.get(false)).get().toJavaList())
));
明らかにスッキリしたコードになりました。
Collectorsのメソッドは便利ですね。
一通りちゃんと抑えておきたいです。
[追記ここまで]
まとめ
悩ましかったStreamの例外処理がかなりスッキリ書けそうな感触が得られました。
Eitherを綺麗にまとめ上げる方法にしばらく悩みましたが当然のようにvavrに便利なメソッドが実装されていたオチ。vavr便利。