きっかけ
Stream APIを使い始めてから、もう何年も経つ。
filterしてmapしてcollectする、という一連の流れはほぼ手が覚えているのに、「で、なんでこの順番なの?」と聞かれると、うまく説明できないことがあった。先日、チームの後輩に説明しようとして言葉に詰まったのが正直なところで、それをきっかけに改めて自分なりに整理してみることにした。
Stream APIの骨格:生成 → 中間操作 → 終端操作
Stream APIの使い方は、大きく3つのフェーズに分かれている。
- Streamを生成する
- 中間操作で加工する(複数つなげられる)
- 終端操作で結果を取り出す
この流れ自体は知っていたが、「なぜこの3層構造なのか」という視点が抜けていた。整理してみると、Streamは処理のパイプラインを定義するものであって、実際に処理が走るのは終端操作のタイミングだということが腹落ちした。
生成:コレクションと配列では書き方が微妙に違う
Streamの生成は、Listなどのコレクションなら.stream()、配列ならArrays.stream()を使う。
コレクションからの生成:
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
List<String> list = Arrays.asList("A", "B", "C");
Stream<String> stream = list.stream();
stream.forEach(System.out::println);
配列からの生成:
import java.util.Arrays;
import java.util.stream.IntStream;
int[] array = {1, 2, 3, 4, 5};
IntStream stream = Arrays.stream(array);
stream.forEach(System.out::println);
プリミティブ型の配列を扱うときはIntStreamになる、というのを意識していなかった時期がある。Stream<Integer>とIntStreamは別物で、sum()やaverage()などの数値集計メソッドはIntStream側にしかない。ここを混同すると、コンパイルエラーが出て「なんで?」となる。
中間操作:遅延実行、という概念を軽視していた
中間操作(filter、mapなど)は、呼んだ瞬間には処理が走らない。
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
List<String> list = Arrays.asList("apple", "banana", "cherry", "avocado");
Stream<String> stream = list.stream()
.filter(s -> s.startsWith("a"))
.map(String::toUpperCase);
// この時点ではまだ処理されていない
stream.forEach(System.out::println); // ここで初めて実行される
「遅延実行」という言葉は知っていたが、実際に問題に直面するまであまり意識していなかった。ログを仕込んで動作確認しようとして、中間操作の途中にデバッグ用のpeekを入れても終端操作が呼ばれるまで何も出力されなかったとき、ようやく体感として理解できた。
余談だが、この遅延実行の性質があるからこそ、巨大なコレクションに対しても「必要な要素だけを必要なタイミングで処理する」という効率化が成り立っている。最初は「なんで遅延させるんだろう」と思っていたが、設計の理由がわかると納得感がある。
終端操作:forEachとcollectの使い分け
終端操作の中でよく使うのはforEachとcollectの2つだが、目的が違う。
-
forEach:結果を返さず、副作用のある処理に使う(ログ出力、DBへの書き込みなど) -
collect:処理結果を新しいコレクションとして受け取りたいときに使う
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
List<String> list = Arrays.asList("apple", "banana", "cherry", "avocado");
List<String> result = list.stream()
.filter(s -> s.startsWith("a"))
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println(result); // [APPLE, AVOCADO]
以前はcollectの引数にCollectors.toList()を渡すことを「おまじない」感覚で書いていたが、CollectorsクラスにはtoSet()やgroupingBy()など他にも多くのコレクターが用意されている。このあたりはまだ使いこなせていない部分が多いので、別途整理したいと思っている。
理解が甘かった部分:Streamは使い捨て
Stream<String> stream = list.stream();
stream.forEach(System.out::println);
stream.forEach(System.out::println); // IllegalStateException が発生する
Streamは終端操作を呼んだ時点で「消費済み」になる。同じStreamインスタンスを再利用しようとするとIllegalStateExceptionが発生する。最初にこれに遭遇したとき、エラーの意味がすぐに飲み込めなかった。再度使いたい場合は、.stream()から再生成するしかない。
一度使ったらおしまい、という設計は、パイプラインとしての一方向の流れを保証するためのものだと今は理解している。
Streamを変数に代入して複数箇所で使い回すのは危険。終端操作のたびに再生成するか、処理をメソッドにまとめる構造にするのが安全。
振り返って
今回整理できたのは以下の3点。
- 生成・中間操作・終端操作という3層構造の意味と役割
- 中間操作が遅延実行される理由と、それによって生じる注意点
- Streamが一度しか使えない設計上の理由
一方で、Collectorsの各種メソッドや、並列ストリーム(parallelStream)の使いどころについては、まだ体系的に理解できていない。特に並列処理については、スレッドセーフな操作かどうかの判断が曖昧なまま使うのは怖いと感じているので、次に機会があれば手を動かして確認してみたいと思っている。
この記事を書いた人について
株式会社Flexibilityでエンジニアをしています。
DX推進・システム開発を軸に、エンジニアが自律的に動ける環境を大事にしている会社です。
技術的に面白いことをやっていきたい方や、働き方に柔軟さを求めている方は、
よかったら一度のぞいてみてください。
- 会社サイト: https://www.flexi-inc.com/
- Qiita Organization: https://qiita.com/organizations/flexi-inc