Java 逆引き集 | ストリームチェーンの分割と再利用 — 可読性/保守性

Java Java
スポンサーリンク

ねらいと基本原則

ストリームは「1回流して終わり」の使い捨てモデルです。可読性と保守性を高めたいなら、再利用すべきはストリームそのものではなく「処理の定義(Predicate/Function/Collector)」です。重要な考え方は、共通のフィルタや変換を関数として切り出し、必要な場所に合成して使い回すこと。複数の終端操作が必要なときは、ストリームを再生成するか、一度だけ流して中間結果を材質化(toList などで材料化)します。


分割パターンの基本(「処理」を再利用する)

名前付き中間結果で意図を明確化

長いチェーンは「ステップごとに名前を付ける」だけで読みやすくなります。これ自体は再利用ではありませんが、意図を示す強い手段です。

List<User> users = // ...
Stream<User> s = users.stream();

Stream<User> active = s.filter(User::isActive);
Stream<User> adults = active.filter(u -> u.age() >= 20);
Stream<String> names = adults.map(User::name);

List<String> result = names.sorted().toList();
Java

ステップに意味のある名前を与えることで、後から見ても「何をしているか」が一目で分かります。

再利用可能な Predicate/Function/Comparator を作る

共通処理は関数化しておくと再利用が容易です。ポイントは「ドメイン語彙化」。ロジックを名前に閉じ込めます。

import java.util.function.*;

Predicate<User> isActive = User::isActive;
Predicate<User> isAdult = u -> u.age() >= 20;
Function<User, String> toDisplayName = u -> u.lastName() + " " + u.firstName();
Comparator<User> byScoreDesc = Comparator.comparingInt(User::score).reversed();

// 使い回し
List<String> names = users.stream()
    .filter(isActive.and(isAdult))
    .sorted(byScoreDesc)
    .map(toDisplayName)
    .toList();
Java

関数に名前を付けるだけで、チェーンの意味が劇的に明確になります。

パイプライン関数として抽象化する

「ストリームを受け取ってストリームを返す」関数(UnaryOperator)に分割すると、処理を部品のように繋げられます。

import java.util.function.UnaryOperator;
import java.util.stream.Stream;

UnaryOperator<Stream<User>> filterActiveAdults =
    s -> s.filter(User::isActive).filter(u -> u.age() >= 20);

UnaryOperator<Stream<User>> sortByScore =
    s -> s.sorted(Comparator.comparingInt(User::score).reversed());

UnaryOperator<Stream<User>> uniqueById =
    s -> s.collect(java.util.stream.Collectors.toMap(
            User::id, u -> u, (a,b) -> a)) // 重複IDは先勝ち
          .values().stream();

// 合成して使う
List<String> names = sortByScore
    .andThen(filterActiveAdults)
    .andThen(uniqueById)
    .apply(users.stream())
    .map(User::name)
    .toList();
Java

ストリームは一回性ですが「処理の部品」は何度でも合成できます。

Supplier<Stream<T>> で複数終端操作に備える

同じデータで複数の終端(例:件数とリスト)を行うなら、ストリーム再生成の仕組みが必要です。元データから新しいストリームを提供する Supplier を用意します。

Supplier<Stream<User>> source = () -> users.stream()
    .filter(User::isActive)
    .filter(u -> u.age() >= 20);

long count = source.get().count();             // 1つめの終端
List<User> top10 = source.get()                // 2つめの終端(再生成)
    .sorted(Comparator.comparingInt(User::score).reversed())
    .limit(10)
    .toList();
Java

「1ストリーム=1終端」の原則を守りつつ、同じ処理定義を再利用できます。

一度だけ流して材質化(コストと可読性の見極め)

データが中〜小規模なら、チェーン途中で toList() して材質化するのも有効です。後続の処理を複数回実行したい場合に読みやすさを優先できます。

List<User> activeAdults = users.stream()
    .filter(User::isActive)
    .filter(u -> u.age() >= 20)
    .toList(); // ここで材料化

List<String> names = activeAdults.stream().map(User::name).toList();
double avgScore = activeAdults.stream().mapToInt(User::score).average().orElse(0.0);
Java

材質化は「もう一度同じ上流を流したくないとき」の選択肢です。ただし巨大データではメモリに注意。


実戦例題で分割と再利用を体感する

ログ解析の再利用可能パイプライン

ログ行から「ERROR かつ重要度 HIGH」の行を抽出し、整形とレポートを共有する例です。

record Log(String level, String severity, String msg) {}

UnaryOperator<Stream<Log>> errorHighFilter =
    s -> s.filter(l -> "ERROR".equals(l.level()))
          .filter(l -> "HIGH".equals(l.severity()));

Function<Log, String> toReportLine =
    l -> "[ERROR/HIGH] " + l.msg();

Supplier<Stream<Log>> source = () -> readLogs(); // 読み込みは都度生成

// レポート本文
String report = errorHighFilter.apply(source.get())
    .map(toReportLine)
    .collect(java.util.stream.Collectors.joining("\n"));

// 件数統計
long count = errorHighFilter.apply(source.get()).count();
Java

フィルタと整形を部品化したことで、複数の用途に自然に再利用できています。

ユーザ集計の「材質化 vs 再生成」判断

上位 100 名のプロフィールと統計を作る例。大規模なら再生成、小規模なら材質化で可読性優先。

UnaryOperator<Stream<User>> top100 =
    s -> s.sorted(Comparator.comparingInt(User::score).reversed()).limit(100);

// 再生成で二重走査(大規模向け、メモリ節約)
List<Profile> profiles = top100.apply(users.stream())
    .map(Profile::fromUser).toList();
double avgAge = top100.apply(users.stream())
    .mapToInt(User::age).average().orElse(0.0);

// 材質化で読みやすさ優先(小〜中規模)
List<User> topUsers = top100.apply(users.stream()).toList();
List<Profile> profiles2 = topUsers.stream().map(Profile::fromUser).toList();
double avgAge2 = topUsers.stream().mapToInt(User::age).average().orElse(0.0);
Java

重要ポイントは「データサイズと総コスト」で選ぶこと。材質化は後続の再利用が多いなら有利です。

高コストフィルタの言語化と再利用

何度も出現する重い条件を関数に切り出すと、性能と可読性が両立します。

Predicate<Order> isPremium =
    o -> o.total() > 30_000 && o.tags().contains("VIP") && RiskEngine.approves(o);

List<Order> premiumRecent = orders.stream()
    .filter(isPremium)
    .filter(o -> o.date().isAfter(java.time.LocalDate.now().minusDays(7)))
    .toList();

long premiumCount = orders.stream().filter(isPremium).count();
Java

重い条件が一箇所に集約されるため、キャッシュや最適化の差し込みも容易になります。


深掘り:パフォーマンスと保守性のトレードオフ

1ストリーム1終端の原則とコストモデル

ストリームは終端操作を呼んだ瞬間に「一度だけ」上流を走査します。複数の終端が必要な場合に、毎回上流から作り直すと走査コストは「回数 × 要素数」。材質化は走査 1 回で済みますが、メモリ(と GC)を消費します。指針は以下です。

  • データが巨大・I/O バックエンドなら再生成(Supplier)で都度読み出す。
  • データがメモリ常駐・中〜小規模なら材質化で可読性重視。
  • 同じ高コスト上流を繰り返すなら材質化が有利。

関数合成で「意味」を先に決める

ストリームの可読性は「何をするか」が一目で分かるかに尽きます。分割のゴールは、チェーンを「読める日本語」に変えること。Predicate/Function/UnaryOperator による「語彙化」で、手続きではなく意図を記述しましょう。テスト容易性も上がります。

並列化と副作用の境界線

部品を合成すると並列化を入れやすくなりますが、副作用を混ぜると壊れます。並列を想定するなら、部品は純粋関数(外部状態不変更)に徹し、集約は標準 Collector(sum、toList、groupingByConcurrent など)を使うのが安全です。


テンプレート集(そのまま使える雛形)

再利用可能な関数群の定義

// Predicates
Predicate<User> active = User::isActive;
Predicate<User> adult = u -> u.age() >= 20;
Predicate<User> highScore = u -> u.score() >= 80;

// Functions
Function<User, String> toName = User::name;
Function<User, Profile> toProfile = Profile::fromUser;

// Comparators
Comparator<User> byScoreDesc = Comparator.comparingInt(User::score).reversed();
Java

パイプライン部品の合成

UnaryOperator<Stream<User>> filterActiveAdults =
    s -> s.filter(active).filter(adult);

UnaryOperator<Stream<User>> sortTopN =
    s -> s.sorted(byScoreDesc).limit(100);

List<Profile> profiles = sortTopN
    .andThen(filterActiveAdults)
    .apply(users.stream())
    .map(toProfile)
    .toList();
Java

Supplier で複数終端を安全に

Supplier<Stream<User>> base = () -> users.stream().filter(active).filter(adult);

long count = base.get().count();
List<String> names = base.get().map(toName).toList();
Java

材質化して後段を分岐

List<User> filtered = users.stream().filter(active).filter(adult).toList();

List<String> names = filtered.stream().map(toName).toList();
double avg = filtered.stream().mapToInt(User::score).average().orElse(0.0);
Java

よくある落とし穴と回避策

ストリームの再利用禁止を忘れる

終端後に同じストリームをもう一度使うと IllegalStateException。必ず再生成(Supplier)か材質化してから再利用します。

分割しすぎて意味が散逸する

「1行にすべて書く」も「過剰分割」も読みづらさの原因。関数に名前を付けて語彙を整え、ステップ数は「意図が伝わる」最小限に。

副作用入りの部品で並列が壊れる

forEach 内で外部リストへ add するなどの副作用は破滅の元。collect/reduce を使い、部品は純粋関数に徹します。

高コスト上流の二重走査

同じ重いフィルタや I/O を複数回流すと性能が落ちます。材質化やキャッシュを適用し、「一度だけ重い処理」を守りましょう。


まとめ

ストリームの保守性は「処理の意味を関数として分割・再利用する」ことで劇的に向上します。再利用するのはストリームではなく、Predicate・Function・Comparator・UnaryOperator の部品。複数終端が必要なら Supplier で再生成するか、材質化で分岐。データサイズと処理の重さを見極めて、再生成(低メモリ)か材質化(高可読性)を選び、純粋関数+標準 Collector で安全に合成するのがプロの作法です。

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