Java : ストリームの基本 (Stream)
ストリームは、ListやSet、配列などの各要素に対して、
- 変換
- 絞り込み(フィルタ)
- 重複の除外
- 並び替え
といった操作を便利に行うことができるAPIです。
そんなストリームの基本的な使い方を解説していきます。
対象読者:ラムダ式をある程度理解しているかた
もし不安のあるかたは「ラムダ式の基本」も参考にしていただけたら幸いです。
概要
ストリームの簡単なデータ遷移図です。
もととなる1つのソースからストリームを生成して、中間操作(いくらでも可能)して、最後に終端操作を行って結果を得ます。
コード例も見てみましょう。
final var values = List.of("a", "b", "b", "c", "c", "c");
System.out.println(values); // [a, b, b, c, c, c]
final List<String> result = values.stream()
.distinct()
.map(s -> s.toUpperCase())
.collect(Collectors.toList());
System.out.println(result); // [A, B, C]
- values.stream() : Listからストリームを生成して
- distinct() : 重複を除外して
- map(s -> s.toUpperCase()) : 要素の文字列を大文字に変換して
- collect(Collectors.toList()) : ストリームからListを得ます。
要素に対する操作を順番につなげるように記述できます。
それぞれの操作は独立しています。
パイプライン処理をご存じのかたは、そのイメージが近いと思います。
同じことをストリームなしで記述してみましょう。
final var values = List.of("a", "b", "b", "c", "c", "c");
System.out.println(values); // [a, b, b, c, c, c]
final List<String> result = new ArrayList<>();
for (final var value : values) {
final var upperCase = value.toUpperCase();
if (!result.contains(upperCase)) {
result.add(upperCase);
}
}
System.out.println(result); // [A, B, C]
ループ処理と重複除外と文字列変換が、密接に関連してしまった印象です。(つまり可読性が少し悪い)
ストリームの代表的な操作一覧
ストリームでできる代表的な操作を簡単にご紹介します。
生成
API | 簡単な説明 | コード例 | |
---|---|---|---|
Collection (Java SE 16 & JDK 16) | Stream<E> stream() | ListやSetなどのコレクションからStreamを生成します。 |
|
Arrays (Java SE 16 & JDK 16) | Stream<T> stream(T[] array) | 配列からStreamを生成します。 |
|
IntStream | IntStream range(int startInclusive, int endExclusive) | 指定した範囲の数値からなるIntStreamを生成します。 |
|
Stream | Stream<T> of(T t) | 直接、Streamを生成します。 |
|
中間操作
API | 簡単な説明 | コード例 | |
---|---|---|---|
Stream | Stream<R> map(Function<? super T,? extends R> mapper) | ストリームの要素を変換します。 文字列→文字列や、文字列→数値といった他の型への変換も可能です。 |
|
Stream<T> filter(Predicate<? super T> predicate) | 条件に一致する要素を、後続の操作の対象とします。 |
|
|
Stream<T> distinct() | 重複した要素を除外します。 |
|
|
Stream<T> sorted() | 要素を昇順で並べ替えます。 Comparatorを指定することによって、任意のルールで並べ替えもできます。 |
|
|
Stream<T> limit(long maxSize) | 先頭から指定した数の要素のみを、後続の操作の対象とします。 |
|
|
Stream<T> peek(Consumer<? super T> action) | 各要素に対して、指定したactionを実行します。 forEachと違いpeekは中間操作です。 |
|
|
BaseStream | S parallel() | Streamの操作を並列に実行するよう指示します。 |
|
終端操作
API | 簡単な説明 | コード例 | |
---|---|---|---|
Stream | R collect(Collector<? super T,A,R> collector) | ストリームの操作を終了して結果を得ます。 Collectorを指定することで、結果の形式をListやSet、要素を連結した文字列などにできます。 Listについては、より簡易的なtoListメソッドもあります。(後述) 他にもCollectors (Java SE 16 & JDK 16)にはいろいろと便利なメソッドがあるので、気になったかたはAPI仕様をご確認ください。 |
|
List<T> toList() | ストリームの操作を終了して、結果として変更不能なListを得ます。 |
|
|
void forEach(Consumer<? super T> action) | 各要素に対して、指定したactionを実行します。 並列で実行している場合は、呼び出し順序の保証はありません。 |
|
|
void forEachOrdered(Consumer<? super T> action) | 各要素に対して、指定したactionを実行します。 並列で実行している場合でも、呼び出し順序が保証されます。 |
|
守るべきルール (副作用を起こさない)
ストリームを使う上で1つルールを守る必要があります。
ストリームAPIで指定する関数型インタフェースは、オブジェクトとしてではなく、本当に単純な関数として使うようにしましょう。
具体的には、関数(メソッド)のパラメータ(引数)とローカル変数のみを使うようにします。
関数の外にある変数にアクセスしてはいけません。
(あと、変数ではありませんが、競合する可能性のあるファイルへの入出力もよくありません)
公式API仕様では、関数の外に影響を及ぼすことを 副作用(side effect) と表現しています。
関数型インタフェースのFunctionを例にしてみます。
Functionの関数(メソッド)はapplyです。
まずは問題のない例です。
final Function<String, String> mapper = new Function<>() {
@Override
public String apply(String s) {
final var local = s.repeat(3);
return local.toUpperCase();
}
};
final var stream = Stream.of("a", "b", "c");
final var result = stream.map(mapper).collect(Collectors.toList());
System.out.println(result); // [AAA, BBB, CCC]
このようにapplyメソッドのスコープに閉じた実装をします。
使っている変数は、
- applyメソッドのパラメータである s
- applyメソッドのローカル変数である local
のみです。
次に問題のある例です。
final var outer = new ArrayList<String>();
final var mapper = new Function<String, String>() {
int count;
@Override
public String apply(String s) {
count++;
outer.add(s);
return s + " : count = " + count;
}
};
2つのルール違反をしています。
- applayメソッドの外にあるouter変数にアクセス
- applayメソッドの外にあるcount変数にアクセス
フィールドのcountもNGなのでご注意ください。
ラムダ式を使えばフィールドは作れないので少し安全になります。
ただ、outerのようなfinalな変数にはアクセスできてしまいますが…
このルールを守らないと、ストリームの結果は予期しないものとなる可能性があります。
実際にどのような問題が起こるのか見てみましょう。
// ※問題のある例です。
final var result = new ArrayList<String>();
final var stream = Stream.of("a", "b", "c", "d");
stream.map(s -> s.toUpperCase()).forEach(s -> result.add(s));
System.out.println(result); // [A, B, C, D]
これは、一見問題なく動いているように見えます。
しかし、ストリームを並列実行すると問題が発生します。
// ※問題のある例です。
final var result = new ArrayList<String>();
final var stream = Stream.of("a", "b", "c", "d");
stream.parallel().map(s -> s.toUpperCase()).forEach(s -> result.add(s));
System.out.println(result); // [C, B, A, D] や [C, A, D] など
parallelメソッドで並列に実行します。
順番がばらばらになるのはもちろん、[C, A, D]とサイズが小さくなってしまうこともあります。
これはArrayListのresult変数に複数スレッドから同時にアクセスされて競合が発生しているためです。
副作用を起こさないように結果を得るにはcollectメソッドを使います。
final var stream = Stream.of("a", "b", "c", "d");
final List<String> result = stream.parallel().map(s -> s.toUpperCase()).collect(Collectors.toList());
System.out.println(result); // [A, B, C, D]
ストリームを使うときは、副作用を起こさないように気をつけましょう。
もし、どうしても関数の外の変数にアクセスしたい場合、それはストリームを使うべきではないかもしれません。
よく検討してみましょう。
補足
副作用
...
ただし、println()をデバッグ目的で使用するなどの副作用は、通常無害です。副作用を介さないと動作できないストリーム操作は、forEach()やpeek()など、ごく少数です。これらは注意して使用すべきです。
公式API仕様にSystem.out.printlnは通常無害との記述があるので、本記事でもいくつかの例として使用しています。
中間操作と終端操作
ストリームAPIの操作は、大きく分けて中間操作と終端操作があります。
中間操作はいくらでもつなげることができます。
終端操作は1度のみです。
終端操作を行ったストリームは使用済みとなり、もう1度操作しようとすると例外が発生します。
final var stream = Stream.of("a", "b", "c");
//forEach : A
//forEach : B
//forEach : C
stream.map(s -> s.toUpperCase()).forEach(s -> System.out.println("forEach : " + s));
// forEachは終端操作なので、さらに操作しようとすると例外が発生します。
// IllegalStateException: stream has already been operated upon or closed
final var result = stream.collect(Collectors.toList());
遅延処理
中間操作は新しいストリームを返します。 これらの操作は常に遅延されます。
...
パイプラインの終端操作が実行されるまで、パイプライン・ソースのトラバーサルは開始されません。
...
ストリームの遅延処理は効率性を大幅に向上させます。
ストリームの中間操作は、終端操作が実行されるまで実行されません。
これを遅延処理といいます。(遅延評価とも)
var stream = Stream.of("a", "b", "c");
System.out.println("-- 中間操作 --");
stream = stream.map(s -> {
System.out.println("map : " + s);
return s.toUpperCase();
});
System.out.println("-- 終端操作 --");
final var result = stream.collect(Collectors.toList());
System.out.println("result : " + result);
// 結果
// ↓
//-- 中間操作 --
//-- 終端操作 --
//map : a
//map : b
//map : c
//result : [A, B, C]
このように、中間処理のmapはすぐには実行されません。
終端処理のcollectを実行したときに処理されます。
並列性
parallelメソッドを使うと、各操作が並列に実行されます。
前述した副作用を起こさないように操作を記述すれば、並列処理に対して安全です。
大量の要素があるストリームでは、並列処理することでパフォーマンス向上が見込めます。
面倒なスレッドの管理も不要なので、お手軽ですね。
System.out.println("main thread id : " + Thread.currentThread().getId());
final Stream<String> stream = Stream.of("a", "b", "c");
final List<String> result = stream.parallel().map(s -> {
System.out.println("map thread id : " + Thread.currentThread().getId());
return s.toUpperCase();
}).collect(Collectors.toList());
System.out.println(result);
// 結果
// ↓
//main thread id : 16
//map thread id : 16
//map thread id : 20
//map thread id : 19
//[A, B, C]
IOチャネルから生成したストリームはcloseが必要
ストリームはBaseStream.close()メソッドを持ち、AutoCloseableを実装します。
...
ほとんどのストリーム・インスタンスは、特別なリソース管理を必要としないコレクション、配列、または生成関数によってサポートされているため、実際には使用後に閉じる必要はありません。
一般的に、Files.lines(Path)によって返されるものなど、IOチャネルであるストリームを持つストリームのみがクローズする必要があります。
StreamインタフェースはAutoCloseableを実装します。
ただし、公式API仕様にもあるように、IOチャネル以外はcloseする必要はありません。
IOチャネルから生成したストリームは必ずcloseしましょう。
closeにはtry-with-resources文を使うのがおすすめです。
// --- PowerShell ---
//PS R:\java-work> cat .\aaa.txt
//XXX
//YYY
//ZZZ
final var path = Path.of("R:", "java-work", "aaa.txt");
try (final var stream = Files.lines(path)) {
final var result = stream.collect(Collectors.toList());
System.out.println(result); // [XXX, YYY, ZZZ]
}
公式ドキュメントのリンク
コレクションに対するマップ-リデュース変換など、要素のストリームに対する関数型の操作をサポートするクラスです。
java.util.streamパッケージの説明ページです。
ストリームAPIについてかなり詳しく解説されています。
本記事では解説しきれていないこともあるので、一読することをおすすめします。
関連記事
- API 使用例