Java | Java 標準ライブラリ:Stream の再利用不可性

Java Java
スポンサーリンク

Stream が「再利用できない」ってどういうことか

まず結論から言うと、Java の Stream

「一度“流して”終端操作を呼んだら、そのストリームは二度と使えない(使い捨て)」

という性質を持っています。

同じストリーム変数に対して、count() を呼んだあとに forEach() を呼ぶ、みたいなことはできません。
やろうとすると、実行時に IllegalStateException: stream has already been operated upon or closed が投げられます。

ここをしっかり理解しておくと、「あれ?さっきまで動いていたのに例外になった…」という事故を避けられます。


典型例:一度使った Stream をもう一度使おうとして怒られる

実際にエラーになるコード

まずは「やってはいけないパターン」をコードで見てみます。

import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;

public class StreamReuseError {
    public static void main(String[] args) {
        List<Integer> list = Arrays.asList(1, 2, 3);

        Stream<Integer> stream = list.stream();

        long count = stream.count();
        System.out.println("count = " + count);

        stream.forEach(System.out::println); // ここで例外
    }
}
Java

このコードを実行すると、最初の count は正常に動きますが、
そのあと forEach を呼んだところで、次のような例外が発生します。

java.lang.IllegalStateException: stream has already been operated upon or closed

メッセージの通り、「このストリームはすでに操作されたか閉じられている」ので、
もう一度使おうとすると怒られるわけです。

なぜこうなるのか(ざっくりした理由)

count() は「終端操作」です。
終端操作が呼ばれた時点で、そのストリームは「最後まで流し切られた」状態になります。

ストリーム内部のリソース(イテレータやファイルハンドルなど)は消費され、
パイプラインも「クローズ済み」として扱われます。

だから、そのストリーム変数に対して再度 forEach() などの終端操作を呼ぶことはできません。

ストリームは「一度きりの川」だとイメージしてください。
一度水を流し切った川を、そのままもう一度ゼロから流すことはできない、という感覚です。


再利用不可性の背景:ストリームは「遅延評価の一度きりパイプライン」

遅延評価と「その場で流して終わり」の関係

Stream は「遅延評価」でした。
filtermap をつなげただけでは実行されず、
forEachcollect などの終端操作を呼んだ瞬間に、初めて要素が流れ始めます。

実行されるときのイメージはこうです。

  1. ソース(List や配列、ファイルなど)から 1 要素取り出す
  2. その要素を filtermap のパイプラインに通して、終端操作まで届ける
  3. 次の要素を取り出して同じことを繰り返す
  4. ソースの要素が尽きたらストリームは終わり

この「ソースから要素を取り出す」処理は、たいてい一方向に一度だけ進める仕組みになっています。
例えば、List の内部イテレータや、ファイルの読み取り位置などです。

一度最後まで読み切ったイテレータを、戻さずにもう一度最初から使うことはできませんよね。
ストリームも同じで、「一度流し切ったら終わり」です。

ソース側のリソース解放という観点

特に、ファイル・ソケット・データベースなど外部リソースに紐づいたストリームは、
終端操作が呼ばれた時点で内部的にクローズされることが多いです。

例えば Files.lines(path) で作ったストリームは、
try-with-resources などで終端操作まで実行したら、自動的にクローズされます。

閉じたファイルストリームを再度読もうとしたらおかしい――
それと同じで、「クローズ済みの Stream は再利用不可」というルールになっています。


「Stream を再利用したくなる」よくあるパターンと、正しい書き方

よくある誤パターン:同じ Stream 変数を何度も使う

例えば、次のようなニーズがあります。

「この List の要素数を数えたい(count)」
「同じ対象を forEach で表示もしたい」

初心者がやりがちなのは、さきほどのように同じ Stream で両方やろうとする書き方です。

Stream<Integer> stream = list.stream();

long count = stream.count();
stream.forEach(...); // ダメ
Java

正しい発想は、「Stream そのものを再利用しようとしない」ことです。

解決策1:毎回新しい Stream を生成する

List や配列など、元データが再利用できるなら、
必要なタイミングで毎回 list.stream() を呼んで新しい Stream を作ればよいです。

List<Integer> list = Arrays.asList(1, 2, 3);

// 1回目のストリーム(要素数を数える)
long count = list.stream().count();
System.out.println("count = " + count);

// 2回目のストリーム(表示)
list.stream()
    .forEach(System.out::println);
Java

ポイントは、「stream() を呼ぶのは安い操作」だということです。
stream() は大抵、「内部のイテレータをくれるだけ」のような軽い処理です。

「Stream を変数に保存して再利用しよう」と考えるのではなく、
「元コレクションから必要なときに何度でもストリームを作る」と考えると安全です。

解決策2:一度 List に collect してから、そこから何度も Stream にする

もしソースが「一度しか読めない」タイプ(例えば Files.lines(path) やネットワークストリーム)なら、
最初に全部 List に読み込んでおくという手もあります。

List<String> lines;
try (Stream<String> lineStream = Files.lines(path)) {
    lines = lineStream.toList(); // ここでファイルを全部読み込んで閉じる
}

// 以降は lines から何度でも stream() できる
long count = lines.stream().count();
lines.stream().forEach(System.out::println);
Java

一度 List にしてしまえば、lines.stream() は何度呼んでもかまいません。

ストリームを無理に再利用しようとするのではなく、
「再利用したいなら、再利用できる形(List や配列)に変換しておく」という発想になります。


「再利用不可」を意識して設計するときのコツ

Stream をフィールドに持たない、保持しない

クラスのフィールドに Stream を持たせてしまうと、再利用不可性で簡単にハマります。

例えば、こんな設計は危険です。

class UserService {
    private Stream<User> userStream;

    UserService(List<User> users) {
        this.userStream = users.stream();
    }

    long countActive() {
        return userStream.filter(User::isActive).count();
    }

    void printAll() {
        userStream.forEach(System.out::println); // ここで例外になる可能性大
    }
}
Java

countActive で一度ストリームを流してしまったら、
printAll を呼んだときには userStream はすでに「使い切り済み」です。

こういったバグは、最初は静かに潜んでいて、
特定のメソッド呼び出し順でだけ爆発するので、非常にやっかいです。

設計としては、

Stream をフィールドや長生きする変数に保持しない
必要なメソッドの中で、その都度 collection.stream() を呼ぶ

とするのが安全です。

「パイプライン全体」を一気に書いて、その場で終わらせる

よい書き方の基本は、

stream() から終端操作までを、一つの式で完結させる」

ことです。

例えば、

long count =
    users.stream()
         .filter(User::isActive)
         .count();
Java

あるいは、

users.stream()
     .filter(User::isActive)
     .forEach(System.out::println);
Java

このように、「このストリームはここで終わり」と明確にしてあげるのがベストです。

Stream 変数を中途半端に外に出しておくと、
「このストリームはもう使っていいのか、ダメなのか」が一気に曖昧になります。


Supplier を使って「必要なときに新しい Stream を供給する」テクニック(応用)

少し応用になりますが、
「同じ“パイプライン”を複数回使いたい」というニーズもあります。

例えば、

「同じフィルタ条件で、count も取りたいし、List も作りたい」

という場合です。

ダメな例:

Stream<User> filtered =
    users.stream()
         .filter(User::isActive);

long count = filtered.count();
List<User> list = filtered.toList(); // ここで例外
Java

これをきれいに書く方法の一つとして、Supplier<Stream<T>> を使うやり方があります。

import java.util.function.Supplier;

Supplier<Stream<User>> filteredStreamSupplier =
        () -> users.stream().filter(User::isActive);

long count = filteredStreamSupplier.get().count();
List<User> list = filteredStreamSupplier.get().toList();
Java

get() を呼ぶたびに「新しい Stream」が生成されるので、
count 用と toList 用で、それぞれ別のストリームになります。

この方法は少し上級寄りですが、

「Stream を保持するのではなく、“Stream を作るレシピ”を保持する」

という設計のヒントとして覚えておくと役に立ちます。


まとめ:Stream の再利用不可性を自分の中に落とし込む

Stream の再利用不可性を、初心者向けにまとめるとこうです。

  • Stream は「一度きりの流れ」。終端操作を呼んだら、そのストリームは二度と使えない。
  • count と forEach を同じ Stream で両方やろうとすると、IllegalStateException になる。
  • 複数回処理したいときは、毎回 collection.stream() から新しい Stream を作る。
  • ソースが一度しか読めない場合は、まず List などに collect してから、そこから何度でも stream() する。
  • Stream をフィールドなどで長期間保持せず、「メソッドの中で作って、その中で終わらせる」書き方を基本にする。

タイトルとURLをコピーしました