目的と前提
ストリームは「一度流したら終わり」の一回性を持ちます。同じパイプラインで複数の終端操作(count と toList など)をしたい、あるいは同じフィルタや変換を繰り返し適用したい場面では、その都度「新しくストリームを生成する」仕組みが必要です。そこで役に立つのが Supplier を用いた「再生成可能なストリーム供給」のパターンです。再利用するのはストリーム本体ではなく、「ストリームを作る方法(供給元)」であることを前提にします。
サプライヤーパターンの核心
なぜ Supplier が効くのか
Stream は終端操作で消費されると再使用できません。しかし Supplier<Stream<T>> は「呼ぶたびに新しい Stream を返す約束」なので、同じパイプライン定義を保ちながら何度でも実行できます。重要なのは、Supplier の中に「上流データの取得」「中間操作の定義」を閉じ込めて、終端だけ呼び分けられる形にすることです。これにより、重複コードを排除し、保守性が大幅に向上します。
基本形と最小コード
Supplier<Stream<User>> activeAdults = () -> users.stream()
.filter(User::isActive)
.filter(u -> u.age() >= 20);
// 終端を使い分ける
long count = activeAdults.get().count();
List<User> top10 = activeAdults.get()
.sorted(Comparator.comparingInt(User::score).reversed())
.limit(10)
.toList();
JavaSupplier 内に「共通のフィルタ」を寄せ、呼び出し側で終端と最終段の中間操作(ソートや limit など)を自由に組み合わせます。これが最小にして実用的な形です。
実戦での再生成と材質化の判断
複数終端操作を安全に呼び分ける
「同じ抽出条件で件数と一覧の両方を欲しい」など、複数終端が必要な場面では Supplier を使うのが定石です。ストリームの再利用禁止を根本から回避し、終端ごとに新規ストリームを生成できます。
Supplier<Stream<Order>> premium = () -> orders.stream()
.filter(o -> o.total() > 30_000)
.filter(o -> o.tags().contains("VIP"));
long premiumCount = premium.get().count();
List<Order> recentPremium = premium.get()
.filter(o -> o.date().isAfter(LocalDate.now().minusDays(7)))
.toList();
Java同じ条件を二重に書かずに済み、テストや差分変更も一箇所で完結します。
上流が高コストなら一度だけ材質化
上流が重い(I/O やリモート取得)場合、毎回再生成すると「取り直し」が発生します。データ量がメモリに載る範囲なら、一度だけ材質化(toList など)して、その後の処理を複数回に分ける方が総コストが下がります。重要なのは「I/O は一度」「後段は何度でも」の分離です。
List<Log> cached = logSource.stream()
.filter(l -> "ERROR".equals(l.level()))
.toList();
Supplier<Stream<Log>> errorLogs = () -> cached.stream();
long count = errorLogs.get().count();
String report = errorLogs.get()
.map(l -> "[ERROR] " + l.msg())
.collect(Collectors.joining("\n"));
JavaI/O を一度にまとめ、以降はメモリ上のデータから再生成することで高コストを局所化します。
例題で身につける再生成パターン
例題1:共通フィルタを Supplier 化して指標と一覧を同時に取る
record User(String name, int age, boolean active, int score) {}
List<User> users = List.of(
new User("Tanaka", 22, true, 91),
new User("Sato", 19, true, 84),
new User("Ito", 25, false, 70)
);
Supplier<Stream<User>> base = () -> users.stream()
.filter(User::active)
.filter(u -> u.age() >= 20);
long activeAdultCount = base.get().count();
List<String> topNames = base.get()
.sorted(Comparator.comparingInt(User::score).reversed())
.limit(2)
.map(User::name)
.toList();
Java同じ「アクティブ成人」条件を一箇所に閉じ込め、指標(count)と一覧(上位)を安全に取得します。
例題2:I/O ソースを都度再生する Supplier
Supplier<Stream<String>> lines = () -> {
try {
return Files.lines(Paths.get("server.log")); // 新規に開いて返す
} catch (IOException e) {
throw new UncheckedIOException(e);
}
};
long errors = lines.get()
.filter(l -> l.contains("ERROR"))
.count();
String top10 = lines.get()
.filter(l -> l.contains("ERROR"))
.limit(10)
.collect(Collectors.joining("\n"));
JavaFiles.lines はクローズ必須の I/O リソースなので、Supplier 内で新しく開き直します。終端ごとに新しいストリームを返す設計が安全です。
例題3:部品を合成してパイプラインを再利用
UnaryOperator<Stream<Order>> premiumOnly =
s -> s.filter(o -> o.total() > 30_000).filter(o -> o.tags().contains("VIP"));
UnaryOperator<Stream<Order>> recentOnly =
s -> s.filter(o -> o.date().isAfter(LocalDate.now().minusDays(7)));
Supplier<Stream<Order>> source = () -> readOrders(); // 読み込みは毎回新規
List<Order> premiumRecent = premiumOnly.andThen(recentOnly).apply(source.get()).toList();
long premiumTotal = premiumOnly.apply(source.get()).count();
Java「供給(source)」と「部品(UnaryOperator)」を分離することで、同じ部品を用途ごとに合成して使えます。
深掘り:設計・性能・安全性のバランス
一回性の制約を設計で消す
Stream の再利用禁止は仕様です。これに抗わず、Supplier で「いつでも同じストリーム定義を新鮮に用意できる」形にするのが王道です。特に、同じ上流を複数の終端で評価したい時、Supplier に閉じ込めるだけでバグの温床(終端後の再使用)を撲滅できます。
I/O とメモリのトレードオフ
都度再生成は I/O を繰り返すため遅くなりがちです。材質化(toList)すれば I/O は一度ですがメモリを使います。判断基準は「データサイズ」と「上流のコスト」。メモリに載るなら材質化で可読性と総コストを下げ、載らないなら Supplier で都度流すか、チャンク化して分割処理に切り替えます。
例外とリソースの寿命管理
I/O を含む Supplier は、例外のラップと寿命管理が重要です。UncheckedIOException を使えばストリーム内の例外取り回しがシンプルになります。Files.lines のように Closeable が絡む場合、try-with-resources を終端側に置く設計も有効です。供給と消費の責務分離を徹底しましょう。
Supplier<Path> logPath = () -> Paths.get("server.log");
try (Stream<String> s = Files.lines(logPath.get())) {
long errors = s.filter(l -> l.contains("ERROR")).count();
}
try (Stream<String> s = Files.lines(logPath.get())) {
String first = s.filter(l -> l.contains("ERROR")).findFirst().orElse("none");
}
Javaテンプレート集
共通フィルタの Supplier 化
Supplier<Stream<T>> base = () -> source.stream().filter(this::commonCond);
Java複数終端を安全に呼ぶ
R1 r1 = base.get().map(this::transform).collect(...);
R2 r2 = base.get().map(this::transform).reduce(...);
JavaI/O 供給の再生成
Supplier<Stream<String>> lines = () -> {
try { return Files.lines(path); }
catch (IOException e) { throw new UncheckedIOException(e); }
};
Java部品合成(UnaryOperator でパイプライン再利用)
UnaryOperator<Stream<T>> stepA = s -> s.filter(...);
UnaryOperator<Stream<T>> stepB = s -> s.map(...);
List<R> out = stepA.andThen(stepB).apply(base.get()).toList();
Java材質化で後段を分岐
List<T> cached = base.get().toList();
R1 a = cached.stream().map(...).collect(...);
R2 b = cached.stream().map(...).reduce(...).orElse(defaultVal);
Javaよくある落とし穴と回避策
終端後の再利用エラー
終端後の同一 Stream 使用は IllegalStateException の原因です。必ず Supplier.get() で新しい Stream を取得してから終端に渡してください。これだけで事故はほぼ防げます。
I/O の二重取得での性能劣化
都度 get() が重い場合、材質化で一度だけ取得し、後段を再利用する設計に切り替えます。メモリ制約がきついなら、チャンク単位で Supplier を回すか、処理を一度のパイプラインでまとめて出力を複数作る構成にします。
副作用が混じる Supplier
Supplier の中で外部状態を更新すると、並列や複数呼び出し時に破綻します。供給は「新しいストリームを返すだけ」に徹し、集約は純粋関数と標準 Collector で行いましょう。
まとめ
Supplier を使うと「ストリームを何度でも新鮮に生成できる」ため、複数終端や共通パイプラインの再利用が安全・簡潔になります。重い上流は材質化で一度に取り、軽い上流は Supplier で都度生成と使い分ける。I/O とメモリ、保守性と性能のバランスを見極め、供給(Supplier)と処理(UnaryOperator/Predicate/Function)を綺麗に分離することが、プロの設計の基本です。
