Java 逆引き集 | ストリーム処理の設計ガイドライン(副作用禁止等) — 保守性向上

Java Java
スポンサーリンク

ねらいと基本原則

ストリーム処理の保守性は「副作用を禁じる」「遅延評価を味方にする」「結合可能な集約を選ぶ」の三本柱で決まります。中間操作は純粋関数(入力だけに依存し、外部状態を変更しない)に徹し、副作用は終端でのみ行うのが鉄則です。順序や並列に依存しない設計を重視し、複数の終端が必要なら再生成や材質化の分岐で扱います。


副作用禁止の実践(終端へ集約)

中間操作は純粋関数にする

中間操作に外部リスト追加やログ出力などを入れると、短絡や並列で壊れます。純粋に「入力→出力」の変換だけに限定します。

// NG(副作用あり)
List<String> out = new ArrayList<>();
data.stream().map(s -> { out.add(s); return s.toUpperCase(); }).toList();

// OK(純粋):終端で外へ流す
List<String> up = data.stream().map(String::toUpperCase).toList();
up.forEach(out::add);
Java

終端でのみ外部I/Oや更新を行う

書き込み・送信・メトリクスは forEach(or forEachOrdered)で行い、並列時は順序保証が必要な箇所だけ forEachOrdered を使います。

try (var w = java.nio.file.Files.newBufferedWriter(java.nio.file.Paths.get("out.txt"))) {
    lines.stream()
        .map(String::trim)
        .filter(s -> !s.isEmpty())
        .forEachOrdered(s -> {
            try { w.write(s); w.newLine(); }
            catch (java.io.IOException e) { throw new java.io.UncheckedIOException(e); }
        });
}
Java

遅延評価を活かす順序設計

先に絞る、短絡で止める、重い処理は後段

安い filter と limit を前段に。map・sorted のような重い処理は後段へ移動し、必要最小限の要素だけ通します。

List<String> top = users.stream()
    .filter(u -> u.score() >= 80)                           // 上流で絞る
    .sorted(java.util.Comparator.comparingInt(User::score).reversed())
    .limit(100)                                             // 短絡で止める
    .map(User::name)                                        // 後段で整形
    .toList();
Java

複数終端は再生成か小さな材質化

ストリームは一回限り。同じ上流を使って件数とリスト両方が欲しいときは、再生成(Supplier)か、先に小さく材質化して分岐します。

java.util.function.Supplier<java.util.stream.Stream<User>> src =
    () -> users.stream().filter(User::isActive);

long n = src.get().count();
java.util.List<String> names = src.get().limit(50).map(User::name).toList();
Java

並列・集約の正しさ(結合可能な演算)

reduce/collect は結合律と単位元を満たす

並列でも正しくなるよう、和・件数・min/max のような結合可能な演算を用います。平均は途中で割らず、最後に sum/count で計算します。

record S(double sum, long n) {}
S s = nums.parallelStream()
    .reduce(new S(0,0),
            (a, x) -> new S(a.sum() + x, a.n() + 1),
            (l, r) -> new S(l.sum() + r.sum(), l.n() + r.n()));
double avg = s.n() == 0 ? Double.NaN : s.sum() / s.n();
Java

グループ化は並列適性のある Collector を選ぶ

件数・合計・最大などは groupingByConcurrent と counting/summing を組み合わせます。順序に依存せず、スレッド安全に集約できます。

java.util.Map<String, Long> byCity = users.parallelStream()
    .collect(java.util.stream.Collectors.groupingByConcurrent(
        User::city, java.util.stream.Collectors.counting()));
Java

エラーハンドリングと Optional 設計

失敗しうる変換は Optional で落とす

想定内の不正は Optional で自然に除外し、例外で全体を止めないようにします。合流は flatMap(Optional::stream)。

static java.util.Optional<Integer> safeInt(String s) {
    try { return java.util.Optional.of(Integer.parseInt(s)); }
    catch (NumberFormatException e) { return java.util.Optional.empty(); }
}

java.util.List<Integer> out = strings.stream()
    .map(My::safeInt)
    .flatMap(java.util.Optional::stream)
    .toList();
Java

境界で例外化する

ドメイン的に必須値へ昇格する境界(コントローラ、保存前)で orElseThrow を用い、途中段階では Optional のまま柔軟に流します。

int id = safeInt(rawId).orElseThrow(() -> new IllegalArgumentException("id required"));
Java

イミュータブル指向と可読性の作法

イミュータブルな中間データを保つ

中間ステップでは再代入・破壊的変更を避け、不変オブジェクトを返す設計にします。デバッグ・テストが容易になり、並列でも安全です。

record UserView(long id, String name, int score) {}
java.util.List<UserView> views = users.stream()
    .map(u -> new UserView(u.id(), u.name().trim(), u.score()))
    .toList();
Java

意味のある関数名で段階を語彙化

Predicate/Function/Comparator を名前付きで切り出し、パイプラインの意図を明確化します。

java.util.function.Predicate<String> nonBlank = s -> !s.isBlank();
java.util.function.Function<String, String> normalize = String::trim;

java.util.List<String> cleaned = lines.stream()
    .map(normalize).filter(nonBlank).toList();
Java

例題で手触りを掴む

クレンジング→検証→逐次出力(副作用は終端のみ)

try (var w = java.nio.file.Files.newBufferedWriter(java.nio.file.Paths.get("clean.txt"))) {
    java.nio.file.Files.lines(java.nio.file.Paths.get("raw.txt"))
        .map(String::trim)
        .filter(s -> !s.isEmpty())
        .map(s -> s.replaceAll("\\s+", " "))      // 純粋変換
        .filter(s -> s.length() >= 3)             // 検証
        .forEachOrdered(s -> {
            try { w.write(s); w.newLine(); }      // 終端で副作用
            catch (java.io.IOException e) { throw new java.io.UncheckedIOException(e); }
        });
}
Java

並列安全な頻度集計(順序非依存)

java.util.Map<String, Long> freq =
    words.parallelStream()
         .collect(java.util.stream.Collectors.groupingByConcurrent(
             w -> w.toLowerCase(),
             java.util.stream.Collectors.counting()));
Java

テンプレート(そのまま使える骨子)

副作用ゼロの基本形

java.util.stream.Stream<T> pipeline(java.util.stream.Stream<S> in,
                                    java.util.function.Function<S, T> map,
                                    java.util.function.Predicate<T> filter) {
    return in.map(map).filter(filter);
}
Java

終端で外部へ流す

static <T> void sink(java.util.stream.Stream<T> s,
                     java.util.function.Consumer<T> out) {
    s.forEach(out); // 副作用はここだけ
}
Java

複数終端の再生成

java.util.function.Supplier<java.util.stream.Stream<T>> src =
    () -> list.stream().filter(this::valid);

long count = src.get().count();
java.util.List<T> top = src.get().limit(100).toList();
Java

並列安全な集計

java.util.Map<K, Integer> sums = data.parallelStream()
    .collect(java.util.stream.Collectors.groupingByConcurrent(
        keyFn, java.util.stream.Collectors.summingInt(valFn)));
Java

まとめ

保守性の高いストリーム設計は「中間は純粋、副作用は終端」「上流で絞る・短絡で止める」「結合可能な集約で正しさを担保」「Optional で想定内の失敗を自然に落とす」「イミュータブルと語彙化で読みやすく」の徹底から生まれます。複数終端は再生成で安全に、並列は結合可能な Collector と順序非依存の設計で。これらを守れば、コードは短く、壊れにくく、将来の変更にも強いパイプラインに育ちます。

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