Java 逆引き集 | reduce で複合オブジェクト集約 — ドメイン集計

Java Java
スポンサーリンク

ねらいと前提

reduce は「ストリームの全要素をひとつの結果へ畳み込む」終端操作です。ドメイン集計では単なる数値の和ではなく、複数フィールドを持つ複合オブジェクト(件数・合計・最小・最大・補助メモなど)にまとめることが多いです。正しく設計するコツは、結合律(結合可能性)を満たす演算、適切な単位元(identity)、そして accumulator と combiner の整合性です。これが揃えば、順次でも並列でも正確な集計が書けます。


基本設計(結合律・単位元・整合性)

結合律を満たす「集計オブジェクト」を定義する

複合オブジェクトは「部分結果を結合しても結果が変わらない」ように設計します。和は和に、件数は件数に、最小は最小に、最大は最大に結合できるよう、加算用のメソッドを用意します。

// 「売上集計」の複合オブジェクト
public final class SalesStats {
    private final long count;
    private final long sum;
    private final int min;
    private final int max;

    public SalesStats(long count, long sum, int min, int max) {
        this.count = count; this.sum = sum; this.min = min; this.max = max;
    }

    // 単位元(何もしない初期状態)
    public static SalesStats identity() {
        return new SalesStats(0, 0, Integer.MAX_VALUE, Integer.MIN_VALUE);
    }

    // 要素を1つ取り込んだ新しい集計(累積)
    public SalesStats add(int amount) {
        int newMin = Math.min(this.min, amount);
        int newMax = Math.max(this.max, amount);
        return new SalesStats(this.count + 1, this.sum + amount, newMin, newMax);
    }

    // 部分結果どうしの結合(結合律)
    public SalesStats combine(SalesStats other) {
        int newMin = Math.min(this.min, other.min);
        int newMax = Math.max(this.max, other.max);
        return new SalesStats(this.count + other.count, this.sum + other.sum, newMin, newMax);
    }

    public double average() { return count == 0 ? Double.NaN : (double) sum / count; }

    // getter は省略
}
Java

3引数 reduce の整合性ルール

accumulator は「A に T を取り込む」、combiner は「A と A を結ぶ」。両者が同じ集計ロジックの拡張であることが重要です。identity は「空ストリームの結果」を表す値でなければなりません。

List<Integer> amounts = List.of(1200, 800, 1600);

SalesStats stats = amounts.parallelStream().reduce(
    SalesStats.identity(),      // identity
    (acc, x) -> acc.add(x),     // accumulator: A,T -> A
    SalesStats::combine         // combiner:    A,A -> A
);

System.out.println(stats.average());
Java

例題で身につける複合集約

例題1:注文データから「件数・合計・最小・最大・平均」

record Order(long id, int amount) {}

List<Order> orders = List.of(new Order(1, 1200), new Order(2, 800), new Order(3, 1600));

SalesStats stats = orders.stream()
    .map(Order::amount)
    .reduce(SalesStats.identity(),
            (acc, amt) -> acc.add(amt),
            SalesStats::combine);

System.out.printf("count=%d sum=%d min=%d max=%d avg=%.2f%n",
    stats.getCount(), stats.getSum(), stats.getMin(), stats.getMax(), stats.average());
Java

平均は「途中で割らない」。sum と count を最後に割ることで並列でも正しくなります。

例題2:タグ別の頻度(Map を持つ複合オブジェクト)

record Item(String name, List<String> tags) {}

public final class TagFreq {
    private final Map<String, Long> freq = new java.util.HashMap<>();

    public static TagFreq identity() { return new TagFreq(); }

    public TagFreq add(List<String> tags) {
        for (String t : tags) freq.merge(t, 1L, Long::sum);
        return this;
    }

    public TagFreq combine(TagFreq other) {
        other.freq.forEach((k, v) -> freq.merge(k, v, Long::sum));
        return this;
    }

    public Map<String, Long> asMap() { return freq; }
}

List<Item> items = List.of(
    new Item("A", List.of("red","new")),
    new Item("B", List.of("blue","sale")),
    new Item("C", List.of("red","sale"))
);

TagFreq tf = items.parallelStream()
    .reduce(TagFreq.identity(),
            (acc, it) -> acc.add(it.tags()),
            TagFreq::combine);

System.out.println(tf.asMap()); // {red=2, new=1, blue=1, sale=2}
Java

Map の結合は merge で「結合的」に。並列時でも combiner が正しくマージします。

例題3:品質検査のスコア集計+閾値超過件数

record Sample(int score, boolean defect) {}

public final class QAStats {
    long count, sum, defects;

    public static QAStats identity() { return new QAStats(); }

    public QAStats add(Sample s) {
        count++; sum += s.score(); if (s.defect()) defects++;
        return this;
    }

    public QAStats combine(QAStats o) {
        count += o.count; sum += o.sum; defects += o.defects;
        return this;
    }

    public double avg() { return count == 0 ? Double.NaN : (double) sum / count; }
}

List<Sample> samples = List.of(new Sample(80,false), new Sample(60,true), new Sample(90,false));

QAStats qa = samples.parallelStream()
    .reduce(QAStats.identity(), QAStats::add, QAStats::combine);

System.out.printf("avg=%.1f defects=%d/%d%n", qa.avg(), qa.defects, qa.count);
Java

副作用をオブジェクトの内部状態に限定し、外部には出さないのが安全です。


深掘り:正しさ・性能・保守性の勘所

不変 vs 可変どちらを選ぶか

不変オブジェクト(毎回新インスタンス)ならスレッド安全でバグが少なくなりますが、割り当てが増えます。可変オブジェクト(内部で加算)は割り当てを減らせますが、accumulator と combiner が「同じオブジェクトを共有しない」ことを前提にしないと危険です。並列時は Collector を使うほうが自然な場合もあります。

並列に強いパターン

3引数 reduce は「モノイド(結合律+単位元)」を満たす集計型に向きます。Map など可変容器の結合は reduce より Collectors を優先すると、安全性と読みやすさが両立します。大量データなら ArrayList・配列・IntStream.range のような分割に強いソースを使い、parallel の恩恵を受けやすくします。

identity の厳密性が結果を左右する

min の初期値は Integer.MAX_VALUE、max は Integer.MIN_VALUE のように「単位元として意味が正しい値」を選びます。間違った初期化(例:min=0)は結果を歪めます。空ストリーム時の挙動も identity が決めるため、API の境界仕様として明示しましょう。

順序・短絡に依存しない設計

reduce は要素順に依存しない集約に向いています。順序が重要な処理(中央値、ソート依存)は材質化+ソート+インデックスに切り替えるか、専用アルゴリズムを使います。短絡(limit/findFirst)は上流の行数を減らせますが、集計の意味が変わらないことを確認してください。


実戦レシピ(ドメイン集計のよくある形)

期間別の売上集計(合計・件数)

record Sale(java.time.LocalDate date, int amount) {}

final class DayStats {
    long count; long sum;
    static DayStats identity() { return new DayStats(); }
    DayStats add(Sale s) { count++; sum += s.amount(); return this; }
    DayStats combine(DayStats o) { count += o.count; sum += o.sum; return this; }
    double avg() { return count == 0 ? Double.NaN : (double) sum / count; }
}

List<Sale> sales = /* load */;
DayStats ds = sales.parallelStream()
    .reduce(DayStats.identity(), DayStats::add, DayStats::combine);
Java

ヒストグラム(範囲別件数)

record Score(int val) {}

final class Histogram {
    final int bucketSize;
    final java.util.Map<Integer, Long> freq = new java.util.HashMap<>();
    Histogram(int bucketSize) { this.bucketSize = bucketSize; }
    static Histogram identity(int b) { return new Histogram(b); }
    Histogram add(Score s) {
        int b = s.val() / bucketSize;
        freq.merge(b, 1L, Long::sum);
        return this;
    }
    Histogram combine(Histogram o) {
        o.freq.forEach((k,v) -> freq.merge(k, v, Long::sum));
        return this;
    }
}

List<Score> scores = /* load */;
Histogram h = scores.parallelStream()
    .reduce(Histogram.identity(10), Histogram::add, Histogram::combine);
Java

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

複合オブジェクトの平均・最小・最大・合計

public final class Stats {
    long count; long sum; int min = Integer.MAX_VALUE; int max = Integer.MIN_VALUE;
    static Stats identity() { return new Stats(); }
    Stats add(int x) { count++; sum += x; min = Math.min(min, x); max = Math.max(max, x); return this; }
    Stats combine(Stats o) { count += o.count; sum += o.sum; min = Math.min(min, o.min); max = Math.max(max, o.max); return this; }
    double avg() { return count == 0 ? Double.NaN : (double) sum / count; }
}
Java

3引数 reduce の基本形

A result = stream.parallel().reduce(
    A.identity(),          // 単位元
    (acc, t) -> acc.add(t),// A,T -> A
    (a, b) -> a.combine(b) // A,A -> A
);
Java

Map を抱える集約(頻度)

final class Freq {
    final java.util.Map<K, Long> m = new java.util.HashMap<>();
    static Freq identity() { return new Freq(); }
    Freq add(K k) { m.merge(k, 1L, Long::sum); return this; }
    Freq combine(Freq o) { o.m.forEach((k,v) -> m.merge(k, v, Long::sum)); return this; }
}
Java

まとめ

複合オブジェクトの reduce は「結合律」「単位元」「accumulator/combiner の整合性」を満たす集計型を用意するところから始まります。平均は sum+count を最後に割る、Map の結合は merge で結合的に、順序依存の処理は別経路へ。不変で安全に、可変で軽く、どちらの設計も「並列でも壊れない」ことを最優先に。テンプレートを土台に、ドメインの指標へフィールドを足すだけで、現場の集計が読みやすく、速く、正確に書けます。

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