こんにちは、かつコーチです。Collectors.toList()やtoMap()は使えるようになったものの、groupingByやpartitioningByとなると急に難しく感じませんか。
今回はStream・Collectorsの基本を理解している前提で、実務のグルーピング・集計処理をCollectorsだけでどこまで簡潔に書けるかを掘り下げます。
groupingByで集計する
基本のグルーピング
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
record Order(String category, int amount) {}
List<Order> orders = List.of(
new Order("書籍", 1500),
new Order("家電", 8000),
new Order("書籍", 2200),
new Order("食品", 900),
new Order("家電", 12000)
);
Map<String, List<Order>> byCategory = orders.stream()
.collect(Collectors.groupingBy(Order::category));
System.out.println(byCategory.keySet()); // [書籍, 家電, 食品]
groupingByはgroupByのようなSQL的な集計をStreamだけで実現するCollectorです。
キー抽出関数を渡すだけで、そのキーごとにListへ振り分けられたMapが得られます。
ダウンストリームCollectorで集計を1段深くする
groupingByは第2引数にダウンストリームCollector(グループ化した後の要素にさらに適用するCollector)を渡せます。
Map<String, Long> countByCategory = orders.stream()
.collect(Collectors.groupingBy(Order::category, Collectors.counting()));
Map<String, Integer> totalByCategory = orders.stream()
.collect(Collectors.groupingBy(Order::category, Collectors.summingInt(Order::amount)));
System.out.println(countByCategory); // {書籍=2, 家電=2, 食品=1}
System.out.println(totalByCategory); // {書籍=3700, 家電=20000, 食品=900}
groupingBy(キー抽出, counting())で件数、groupingBy(キー抽出, summingInt(...))で合計というように、単純なListへの振り分けでは足りない場面をワンライナーで解決できます。
partitioningByで二分割する
partitioningByはgroupingByの特殊系で、Predicateの結果(true/false)でちょうど2つのグループに分けます。
Map<Boolean, List<Order>> partitioned = orders.stream()
.collect(Collectors.partitioningBy(o -> o.amount() >= 5000));
System.out.println(partitioned.get(true).size()); // 2(家電2件)
System.out.println(partitioned.get(false).size()); // 3
groupingBy(Predicate)でも似たことはできますが、partitioningByは戻り値がMap<Boolean, List<T>>で確定しており、true・false両方のキーが必ず存在することが保証されます。groupingByだと該当がないキーはMapに含まれないため、「該当なしの場合も空リストとして扱いたい」場面ではpartitioningByの方が安全です。
つまずきポイント:多段groupingByとcollectingAndThen
実際に私が集計バッチを組んだ際、「カテゴリごとに合計金額が一番高い注文を取りたい」という要件でハマりました。
// ❌Before:groupingByの結果をさらにStream処理しようとする
Map<String, Order> maxByCategory = orders.stream()
.collect(Collectors.groupingBy(Order::category))
.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
e -> e.getValue().stream()
.max(java.util.Comparator.comparingInt(Order::amount))
.get()
));
動きはしますが、groupingByの結果をさらにStreamに戻してtoMapし直すという、二度手間な書き方になっています。groupingByのダウンストリームCollectorにmaxByを渡せば1段で書けます。
// ✅After:ダウンストリームCollectorにmaxByを渡す
Map<String, java.util.Optional<Order>> maxByCategory = orders.stream()
.collect(Collectors.groupingBy(
Order::category,
Collectors.maxBy(java.util.Comparator.comparingInt(Order::amount))
));
maxByCategory.forEach((category, order) ->
System.out.println(category + ": " + order.get().amount()));
maxByは結果がOptionalで返るため、扱いにくいと感じる場合はCollectors.collectingAndThenで後処理を挟めます。
Map<String, Order> maxByCategoryUnwrapped = orders.stream()
.collect(Collectors.groupingBy(
Order::category,
Collectors.collectingAndThen(
Collectors.maxBy(java.util.Comparator.comparingInt(Order::amount)),
java.util.Optional::get
)
));
collectingAndThen(Collector, 後処理関数)は、Collectorの結果に対してさらに変換を1つ挟みたいときの定番パターンです。
「groupingByの結果をさらにStreamに戻す」よりも、ダウンストリームCollectorを組み合わせる方が1回のStream走査で完結し、可読性・パフォーマンスの両面で有利です。
実務での判断軸
- 単純な振り分けだけなら
groupingBy(キー抽出) - グループごとに件数・合計・平均が欲しいなら
groupingBy(キー抽出, counting()/summingInt()/averagingInt()) - true/falseのちょうど2分割なら
partitioningBy - グループごとの最大・最小や、Collectorの結果をさらに加工したいなら
maxBy・minByとcollectingAndThenの組み合わせ
groupingByをネストさせる(第2引数にさらにgroupingByを渡す)ことで多段の集計も可能ですが、ネストが深くなるほど可読性が落ちるため、複雑な集計はStream1本にこだわらず一部を通常のループやSQL側の集計に任せる判断も必要です。
まとめ
この記事のポイント
groupingByはキーごとの振り分け、ダウンストリームCollectorを渡すと件数・合計などの集計まで1段で書けるpartitioningByはtrue/falseの二分割専用で、該当なしのキーも空リストとして保証される- グループごとの最大・最小は
maxBy・minBy、結果をさらに加工したいときはcollectingAndThenを使う - ネストしたgroupingByは可読性が落ちやすいため、複雑な集計は分割して書く判断も必要
次に読むべき記事
- 終端操作(collect・reduce)
- 並列Stream
タグ: Java, 上級者向け, 関数型プログラミング