広告

Java : ストリームの基本 (Stream)

ストリームを使うと、List や 配列などの要素に対して、絞り込みや並び替えなどを便利に実行できます。
本記事では、そんなストリームの基本的な使い方を解説していきます。

対象読者:ラムダ式をある程度理解しているかた

もし不安のあるかたは「ラムダ式の基本」も参考にしていただけたら幸いです。


概要

順次および並列の集約操作をサポートする要素のシーケンスです。

ストリームAPI を使うと、ListSet配列 などの各要素に対して、

  • 変換
  • 絞り込み(フィルタ)
  • 重複の除外
  • 並び替え

などの操作を便利に実行することができます。

データ遷移図

ストリームの簡単なデータ遷移図です。
もととなる1つのソースからストリームを生成して、中間操作(いくらでも可能)して、最後に終端操作で結果を得ます。

  1. ストリーム生成
  2. 中間操作 (複数OK)
  3. 終端操作

これが基本です。

コード例も見てみましょう。

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]

ストリームの操作は…

  1. values.stream() : List からストリームを生成して (ストリーム生成)
  2. distinct() : 重複を除外して (中間操作)
  3. map(s -> s.toUpperCase()) : 要素の文字列を大文字に変換して (中間操作)
  4. 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 を生成します。
final var list = List.of("a", "b", "c");
final var stream = list.stream();

final var ret = stream.toList();
System.out.println(ret); // [a, b, c]
Arrays Stream<T> stream​ (T[] array)

配列から Stream を生成します。
final String[] array = {"a", "b", "c"};
final var stream = Arrays.stream(array);

final var ret = stream.toList();
System.out.println(ret); // [a, b, c]
IntStream IntStream range ​(int startInclusive, int endExclusive)

指定した範囲の数値からなる IntStream を生成します。
// 0 ~ 9 の IntStream を生成します。
final var stream = IntStream.range(0, 10);
final int[] ret = stream.toArray();

// [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
System.out.println(Arrays.toString(ret));
Stream Stream<T> of ​(T t)

直接、Stream を生成します。
final var stream = Stream.of("a", "b", "c");

final var ret = stream.toList();
System.out.println(ret); // [a, b, c]

中間操作

API メソッド コード例
Stream Stream<R> map ​(Function<? super T,​? extends R> mapper)

ストリームの要素を変換します。
文字列→文字列や、文字列→数値といった他の型への変換も可能です。
// 各要素の文字列を大文字に変換(map)します。
{
    final var stream = Stream.of("a", "b", "c");

    final var ret = stream
            .map(s -> s.toUpperCase())
            .toList();
    System.out.println(ret); // [A, B, C]
}

// 16進数表記の文字列を Integer に変換します。
{
    final var stream = Stream.of("0x01", "0x10", "0xff");

    final var ret = stream
            .map(s -> Integer.decode(s))
            .toList();
    System.out.println(ret); // [1, 16, 255]
}
Stream<T> filter ​(Predicate<? super T> predicate)

条件に一致する要素を、後続の操作の対象とします。
// 大文字のみを取り出します(filter)。
final var stream = Stream.of("a", "B", "c", "D");

final var ret = stream
        .filter(s -> s.matches("[A-Z]"))
        .toList();
System.out.println(ret); // [B, D]
Stream<T> distinct ()

重複した要素を除外します。
// 重複を除外(distinct)します。
final var stream = Stream.of("a", "b", "b", "c", "c", "c");

final var ret = stream
        .distinct()
        .toList();
System.out.println(ret); // [a, b, c]
Stream<T> sorted ()

要素を昇順で並べ替えます。
Comparator を指定することによって、任意のルールで並べ替えもできます。
// 昇順で並び替え(sorted)します。
final var stream = Stream.of("c", "d", "a", "b");

final var ret = stream
        .sorted()
        .toList();
System.out.println(ret); // [a, b, c, d]
Stream<T> limit ​(long maxSize)

先頭から指定した数の要素のみを、後続の操作の対象とします。
// 0 ~ 99 の先頭 5件を操作します。
final var stream = IntStream.range(0, 100);

final int[] ret = stream
        .limit(5)
        .toArray();
System.out.println(Arrays.toString(ret)); // [0, 1, 2, 3, 4]
Stream<T> peek ​(Consumer<? super T> action)

各要素に対して、指定した action を実行します。
forEach と違い peek は中間操作です。
// 各要素をコンソールへ出力します。
final var stream = Stream.of("a", "b", "c");

final var ret = stream
        .peek(s -> System.out.println("peek : " + s))
        .toList();

System.out.println("-- ret --");
System.out.println(ret);

// 結果
// ↓
//peek : a
//peek : b
//peek : c
//-- ret --
//[a, b, c]
BaseStream S parallel ()

Stream の操作を並列に実行するよう指示します。
// 中間操作を並列に実行します。
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]

終端操作

API メソッド コード例
Stream R collect ​(Collector<? super T,​A,​R> collector)

ストリームの操作を終了して結果を得ます。
Collector を指定することで、結果の形式を List や Set、要素を連結した文字列などにできます。
List については、より簡易的な toList メソッドもあります。(後述)

他にも Collectors にはいろいろと便利なメソッドがあるので、気になったかたは API仕様をご確認ください。
// ストリームの結果として List を得ます。
{
    final var stream = Stream.of("a", "b", "c");

    final List<String> ret = stream.collect(Collectors.toList());
    System.out.println(ret); // [a, b, c]
}

// ストリームの結果として Set を得ます。
{
    final var stream = Stream.of("a", "b", "c");

    final Set<String> ret = stream.collect(Collectors.toSet());
    System.out.println(ret); // [a, b, c]

}

// ストリームの結果として連結した文字列を得ます。
{
    final var stream = Stream.of("a", "b", "c");

    final String ret = stream.collect(Collectors.joining("-"));
    System.out.println(ret); // a-b-c
}
List<T> toList ()

ストリームの操作を終了して、結果として変更不能な List を得ます。
final var stream = Stream.of("a", "b", "c");

final var ret = stream.toList();
System.out.println(ret); // [a, b, c]
void forEach ​(Consumer<? super T> action)

各要素に対して、指定した action を実行します。
並列で実行している場合は、呼び出し順序の保証はありません
// 各要素をコンソールへ出力します。
final var stream = Stream.of("a", "b", "c");

stream.forEach(s -> System.out.println("forEach : " + s));

// 結果
// ↓
//forEach : a
//forEach : b
//forEach : c
void forEachOrdered ​(Consumer<? super T> action)

各要素に対して、指定した action を実行します。
並列で実行している場合でも、呼び出し順序が保証されます。
// 各要素をコンソールへ出力します。
final var stream = Stream.of("a", "b", "c");

stream.parallel().forEachOrdered(
        s -> System.out.println("forEachOrdered : " + s));

// 結果
// ↓
//forEachOrdered : a
//forEachOrdered : b
//forEachOrdered : c

守るべきルール (副作用を起こさない)

ストリームを使う上で、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]

パラレルで実行していますが、副作用を起こさずに問題なく結果が取得できました。

ストリームを使うときは、副作用を起こさない ように気をつけましょう。

もし、どうしても関数の外の変数にアクセスしたい場合、それはストリームを使うべきではないかもしれません。
よく検討してみましょう。


補足

副作用
...
ただし、println()をデバッグ目的で使用するなどの副作用は、通常無害です。副作用を介さないと動作できないストリーム操作は、forEach()やpeek()など、ごく少数です。

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 が必要

ほとんどのストリーム・インスタンスは、特別なリソース管理を必要としないコレクション、配列、または生成関数によってサポートされているため、実際には使用後に閉じる必要はありません。一般的に、Files.lines(Path)によって返されるものなど、IOチャネルであるストリームを持つストリームのみがクローズする必要があります。

クラス図

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、配列などの各要素に対して、

  • 変換
  • 絞り込み(フィルタ)
  • 重複の除外
  • 並び替え

などの操作を便利に実行することができます。

ソースコードの可読性の向上にもつながるので、有効に活用していきたいですね。


関連記事

ページの先頭へ