【Java】JavaのCollectors活用術:groupingBy・partitioningByで集計処理を簡潔に書く

Java

こんにちは、かつコーチです。
Collectors.toList()toMap()は使えるようになったものの、groupingBypartitioningByとなると急に難しく感じませんか。
今回はStreamCollectorsの基本を理解している前提で、実務のグルーピング・集計処理を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()); // [書籍, 家電, 食品]

groupingBygroupByのような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で二分割する

partitioningBygroupingByの特殊系で、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>>で確定しており、truefalse両方のキーが必ず存在することが保証されます。
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の結果をさらに加工したいならmaxByminBycollectingAndThenの組み合わせ

groupingByをネストさせる(第2引数にさらにgroupingByを渡す)ことで多段の集計も可能ですが、ネストが深くなるほど可読性が落ちるため、複雑な集計はStream1本にこだわらず一部を通常のループやSQL側の集計に任せる判断も必要です。

まとめ

この記事のポイント

  • groupingByはキーごとの振り分け、ダウンストリームCollectorを渡すと件数・合計などの集計まで1段で書ける
  • partitioningByはtrue/falseの二分割専用で、該当なしのキーも空リストとして保証される
  • グループごとの最大・最小はmaxByminBy、結果をさらに加工したいときはcollectingAndThenを使う
  • ネストしたgroupingByは可読性が落ちやすいため、複雑な集計は分割して書く判断も必要

次に読むべき記事

  • 終端操作(collect・reduce)
  • 並列Stream

タグ: Java, 上級者向け, 関数型プログラミング

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