Java : ストリームの基本 (Stream)
ストリームを使うと、List や 配列などの要素に対して、絞り込みや並び替えなどを便利に実行できます。
本記事では、そんなストリームの基本的な使い方を解説していきます。
対象読者:ラムダ式をある程度理解しているかた
もし不安のあるかたは「ラムダ式の基本」も参考にしていただけたら幸いです。
概要
ストリームAPI を使うと、List や Set、配列 などの各要素に対して、
- 変換
- 絞り込み(フィルタ)
- 重複の除外
- 並び替え
などの操作を便利に実行することができます。
ストリームの簡単なデータ遷移図です。
もととなる1つのソースからストリームを生成して、中間操作(いくらでも可能)して、最後に終端操作で結果を得ます。
- ストリーム生成
- 中間操作 (複数OK)
- 終端操作
これが基本です。
コード例も見てみましょう。
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())
.toList();
System.out.println(result); // [A, B, C]
ストリームの操作は…
- values.stream() : List からストリームを生成して (ストリーム生成)
- distinct() : 重複を除外して (中間操作)
- map(s -> s.toUpperCase()) : 要素の文字列を大文字に変換して (中間操作)
- toList() : 最後に、ストリームから結果を List で取得 (終端操作)
となります。
要素に対する操作を、順番につなげるように記述できます。
パイプライン処理をご存じのかたは、そのイメージが近いかもしれません。
同じことをストリームなしで記述してみましょう。
final var values = List.of("a", "b", "b", "c", "c", "c");
System.out.println(values); // [a, b, b, c, c, c]
final var result = new ArrayList<String>();
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 | Stream<E> stream () List や Set などのコレクションから Stream を生成します。 |
|
Arrays | 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 にはいろいろと便利なメソッドがあるので、気になったかたは 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 var mapper = new Function<String, String>() {
@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 ret = stream.map(mapper).toList();
System.out.println(ret); // [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;
}
};
final var stream = Stream.of("a", "b", "c");
final var ret = stream.map(mapper).toList();
この例では、2つのルール違反をしています。
- apply メソッドの 外 にある outer 変数にアクセス
- apply メソッドの 外 にある count 変数にアクセス
フィールドの count 変数も NG なのでご注意ください。
ルールを守らないと、ストリームの結果は予期しないものとなる可能性があります。
実際にどのような問題が起こるのか見てみましょう。
// ※問題のある例です。
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, D, B, A] や [C, D, A] など
今度は、ストリームを parallelメソッド で並列に実行しています。
順番がばらばらになるのはもちろん、[C, D, A] とサイズが小さくなってしまうこともあります。
これは ArrayList の result 変数に複数スレッドから同時にアクセスされて競合が発生しているためです。
副作用を起こさないように結果を得るには、関数の外にある result 変数は使わずに、ストリームの終端操作を使います。
final var stream = Stream.of("a", "b", "c", "d");
final var ret = stream
.parallel()
.map(s -> s.toUpperCase())
.toList();
System.out.println(ret); // [A, B, C, D]
パラレルで実行していますが、副作用を起こさずに問題なく結果が取得できました。
ストリームを使うときは、副作用を起こさない ように気をつけましょう。
もし、どうしても関数の外の変数にアクセスしたい場合、それはストリームを使うべきではないかもしれません。
よく検討してみましょう。
補足
System.out は、厳密に言えば、ストリームAPIに指定する関数の 外 にある変数です。
しかし、API仕様に System.out.println は通常無害との記述があるので、本記事のコード例でも使っています。
中間操作と終端操作
ストリームAPIの操作は、大きく分けて中間操作と終端操作があります。
中間操作はいくらでもつなげることができます。
終端操作は1度のみです。
終端操作を行ったストリームは使用済みとなり、もう1度操作しようとすると例外が発生します。
final var stream = Stream.of("a", "b", "c");
stream.map(s -> s.toUpperCase())
.forEach(s -> System.out.println("forEach : " + s));
// 結果
// ↓
//forEach : A
//forEach : B
//forEach : C
// forEachは終端操作なので、さらに操作しようとすると例外が発生します。
try {
final var ret = stream.toList();
} catch (IllegalStateException e) {
System.out.println("IllegalStateException! : " + e.getMessage());
}
// 結果
// ↓
//IllegalStateException! : stream has already been operated upon or closed
遅延処理
ストリームの中間操作は、終端操作が実行されるまで処理されません。
これを遅延処理といいます。(遅延評価とも)
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 ret = stream.toList();
System.out.println("ret : " + ret);
// 結果
// ↓
//-- 中間操作 --
//-- 終端操作 --
//map : a
//map : b
//map : c
//ret : [A, B, C]
このように、中間処理の map はすぐには実行されません。
終端処理の toList を実行したときに処理されます。
並列性
parallel メソッドを使うと、各操作が並列に実行されます。
前述した 副作用 を起こさないように操作を記述すれば、並列処理に対して安全です。
大量の要素があるストリームでは、並列処理することでパフォーマンス向上が見込めます。
面倒なスレッドの管理も不要なので、お手軽ですね。
System.out.println("main : thread id = "
+ Thread.currentThread().threadId());
final var stream = Stream.of("a", "b", "c");
final var ret = stream
.parallel()
.map(s -> {
System.out.println("map : thread id = "
+ Thread.currentThread().threadId());
return s.toUpperCase();
})
.toList();
System.out.println("-- ret --");
System.out.println(ret);
// 結果
// ↓
//main : thread id = 1
//map : thread id = 1
//map : thread id = 1
//map : thread id = 32
//-- ret --
//[A, B, C]
IOチャネルのストリームは close が必要
Stream インタフェースは AutoCloseable を実装します。
ただし、公式API仕様にもあるように、IOチャネル以外は close する必要はありません。
IOチャネルのストリームは 必ずclose しましょう。
close には try-with-resources文 を使うのがおすすめです。
final var path = Path.of("R:", "java-work", "aaa.txt");
Files.writeString(path, """
XXX
YYY
ZZZ
""");
try (final var stream = Files.lines(path)) {
final var ret = stream.toList();
System.out.println(ret); // [XXX, YYY, ZZZ]
}
公式ドキュメントのリンク
java.util.stream パッケージの説明ページです。
ストリームAPI についてかなり詳しく解説されています。
本記事では解説しきれていないこともあるので、一読することをおすすめします。
まとめ
ストリームAPI を使うと、List や Set、配列などの各要素に対して、
- 変換
- 絞り込み(フィルタ)
- 重複の除外
- 並び替え
などの操作を便利に実行することができます。
ソースコードの可読性の向上にもつながるので、有効に活用していきたいですね。