ねらいと前提
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 は省略
}
Java3引数 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}
JavaMap の結合は 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; }
}
Java3引数 reduce の基本形
A result = stream.parallel().reduce(
A.identity(), // 単位元
(acc, t) -> acc.add(t),// A,T -> A
(a, b) -> a.combine(b) // A,A -> A
);
JavaMap を抱える集約(頻度)
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 で結合的に、順序依存の処理は別経路へ。不変で安全に、可変で軽く、どちらの設計も「並列でも壊れない」ことを最優先に。テンプレートを土台に、ドメインの指標へフィールドを足すだけで、現場の集計が読みやすく、速く、正確に書けます。
