Collectors.groupingByとpartitioningByの実践例
1. 概要
Streamで要素を特定の基準によってまとめる必要があることがよくあります。例えば、注文を都市ごとにまとめたり、顧客ごとに合計金額を求めたり、大きな注文と小さな注文を分ける場合です。Collectors.groupingByとCollectors.partitioningByはまさにこのために設計されています。
この記事では、よく使うパターンをそれぞれ、具体的な出力とともに紹介します。downstream collectorの組み合わせ方、戻り値のMap型の選択方法、そしてよくある落とし穴を回避する方法を学びます。
2. サンプルデータ
この記事の全ての例では同じ注文セットを使用します:
import java.util.List;
enum OrderStatus { PENDING, PAID, CANCELLED }
record Order(String id, String customer, String city, OrderStatus status, long amount) {}
class OrderFixtures {
static List<Order> sample() {
return List.of(
new Order("O1", "An", "Hà Nội", OrderStatus.PAID, 500_000),
new Order("O2", "Bình", "Đà Nẵng", OrderStatus.PAID, 1_200_000),
new Order("O3", "An", "Hà Nội", OrderStatus.CANCELLED, 300_000),
new Order("O4", "Chi", "Hồ Chí Minh", OrderStatus.PAID, 2_000_000),
new Order("O5", "Bình", "Đà Nẵng", OrderStatus.PENDING, 150_000),
new Order("O6", "Chi", "Hồ Chí Minh", OrderStatus.PAID, 800_000)
);
}
}
amountはVNDで指定され、シンプルさのためにlong型を使用しています。実際のシステムでは、金銭に対してBigDecimalを使用すべきです。
以下の例では、既にimport static java.util.stream.Collectors.*;とList<Order> orders = OrderFixtures.sample();がされていると仮定しています。
3. groupingByの基本
最も単純な形式は1つの分類関数(classifier)のみを受け取ります:
Map<String, List<Order>> byCity = orders.stream()
.collect(groupingBy(Order::city));
byCity.forEach((city, list) ->
System.out.println(city + " -> " + list.stream().map(Order::id).toList()));
Hà Nội -> [O1, O3]
Đà Nẵng -> [O2, O5]
Hồ Chí Minh -> [O4, O6]
Order::cityがキーを決定します。各キーは、その値を持つ注文を含むListにマップされます。内部的には、このバージョンはgroupingBy(Order::city, HashMap::new, toList())と等価です。
注意: 戻り値の
MapはHashMapなので、キーの順序は保証されません。あなたのマシンでの出力は異なる可能性があります。順序が必要な場合は、セクション5を参照してください。
4. Downstream Collector
groupingByの2番目のパラメータは別のCollectorで、downstreamと呼ばれます。これは同じグループ内の要素をどうまとめるかを決定します。これがdownstream collectorを使用することでgroupingByが本当に強力になる理由です。
4.1. カウントと合計
Map<OrderStatus, Long> countByStatus = orders.stream()
.collect(groupingBy(Order::status, counting()));
Map<String, Long> totalByCustomer = orders.stream()
.collect(groupingBy(Order::customer, summingLong(Order::amount)));
System.out.println(countByStatus);
System.out.println(totalByCustomer);
{PAID=4, PENDING=1, CANCELLED=1}
{An=800000, Bình=1350000, Chi=2800000}
counting()は常にLongを返し、Integerではありません。変数の型を宣言するときはこれを覚えておいてください。summingLongの他に、summingInt、summingDouble、averagingLong、summarizingLongがあります。最後のcollectorはLongSummaryStatisticsを返し、カウント、合計、最小値、最大値、平均を同時に含みます。
4.2. mappingで要素を変換
オブジェクト全体ではなく単一の属性が必要な場合は、mappingを使用します:
Map<String, Set<String>> customersByCity = orders.stream()
.collect(groupingBy(Order::city, mapping(Order::customer, toSet())));
System.out.println(customersByCity);
{Hà Nội=[An], Đà Nẵng=[Bình], Hồ Chí Minh=[Chi]}
mapping(Order::customer, toSet())は各注文の顧客名を取り出し、重複を排除したSetにまとめます。Anはハノイに2つの注文を持っていますが、1回だけ表示されます。
4.3. グループ内の最大要素を取得
maxByはOptional<Order>を返します。理論的には、グループは空の場合があるからです。groupingByでは、グループが少なくとも1つの要素を持つ場合のみ存在するため、collectingAndThenを使用してOptionalを“開く”ことができます:
Map<String, Order> biggestByCustomer = orders.stream()
.collect(groupingBy(
Order::customer,
collectingAndThen(maxBy(Comparator.comparingLong(Order::amount)), Optional::get)
));
biggestByCustomer.forEach((customer, order) ->
System.out.println(customer + " -> " + order.id() + " (" + order.amount() + ")"));
An -> O1 (500000)
Bình -> O2 (1200000)
Chi -> O4 (2000000)
collectingAndThenは内側のcollectorを最初に実行し、その結果に仕上げ関数(Optional::get)を適用します。
別の方法も一般的に使用されます:toMap(Order::customer, Function.identity(), BinaryOperator.maxBy(...))。この方法は同様の結果を得られ、Optionalを経由する必要がありません。
4.4. filteringでグループをフィルタリング
Collectors.filteringはJava 9から利用可能です。グループ化の前に.filter()を呼び出すのとは異なります:
Map<String, Long> cancelledPerCustomer = orders.stream()
.collect(groupingBy(Order::customer,
filtering(o -> o.status() == OrderStatus.CANCELLED, counting())));
Map<String, Long> filteredFirst = orders.stream()
.filter(o -> o.status() == OrderStatus.CANCELLED)
.collect(groupingBy(Order::customer, counting()));
System.out.println(cancelledPerCustomer);
System.out.println(filteredFirst);
{An=1, Bình=0, Chi=0}
{An=1}
filteringを使用すると、すべての顧客が結果に表示されます。カウントが0でも表示されます。.filter()を先に使用すると、BìnhとChiは完全に消えます。必要に応じて選択してください。レポートに完全な顧客リストが必要な場合は、filteringを使用します。
同様に、flatMapping(Java 9)は各グループ内のコレクションをフラット化するのに役立ちます。
5. 戻り値のMap型を選択
3パラメータ版はMapを作成するSupplierをサポートします:
Map<String, Long> sortedTotals = orders.stream()
.collect(groupingBy(Order::customer, TreeMap::new, summingLong(Order::amount)));
Map<OrderStatus, Long> statusCounts = orders.stream()
.collect(groupingBy(Order::status,
() -> new EnumMap<>(OrderStatus.class), counting()));
System.out.println(sortedTotals);
System.out.println(statusCounts);
{An=800000, Bình=1350000, Chi=2800000}
{PENDING=1, PAID=4, CANCELLED=1}
TreeMapはキーを自然順序でソートします。EnumMapはenumの宣言順でキーを保持し、キーがenumの場合HashMapより高速です。ストリームの最初の出現順を保持する必要がある場合は、LinkedHashMap::newを使用します。
注意:
TreeMapはStringをUnicodeコード順にソートするため、ベトナム語の規則に従ってはソートされません。例えば「Đà Nẵng」は「Hồ Chí Minh」の後に来ます。ベトナム語で正しくソートするには、() -> new TreeMap<>(Collator.getInstance(Locale.of("vi")))を渡してください。
6. ネストされたグループ化
downstreamは任意のcollectorなので、別のgroupingByをその中に配置できます:
Map<String, Map<OrderStatus, Long>> cityThenStatus = orders.stream()
.collect(groupingBy(Order::city, TreeMap::new,
groupingBy(Order::status, counting())));
System.out.println(cityThenStatus);
{Hà Nội={PAID=1, CANCELLED=1}, Hồ Chí Minh={PAID=2}, Đà Nẵng={PAID=1, PENDING=1}}
結果は2層のMapです。この方法はピボットテーブル型のレポートに非常に便利です。しかし、3層以上になるとデータ型が読みにくくなります。その場合は、複合キーの使用を検討してください。例えばrecord CityStatus(String city, OrderStatus status)、その後groupingBy(o -> new CityStatus(o.city(), o.status()))。
7. partitioningBy
partitioningByはグループ化の特殊なケースです。分類子はPredicateで、キーは常にBooleanです:
Map<Boolean, List<String>> bigOrders = orders.stream()
.collect(partitioningBy(o -> o.amount() >= 1_000_000,
mapping(Order::id, toList())));
Map<Boolean, Long> bigOrderCount = orders.stream()
.collect(partitioningBy(o -> o.amount() >= 1_000_000, counting()));
System.out.println(bigOrders);
System.out.println(bigOrderCount);
System.out.println("大きな注文: " + bigOrders.get(true));
{false=[O1, O3, O5, O6], true=[O2, O4]}
{false=4, true=2}
大きな注文: [O2, O4]
partitioningByもgroupingByと同じdownstream collectorを受け入れます。しかし、MapのSupplierを受け取るバージョンはありません。戻り値は内部実装で、正確に2つのキーだけを持つためです。
7.1. 重要な違い:キーは常に存在
これは多くの人が2つのcollectorの間で誤選択する理由です:
List<Order> smallOnly = orders.stream().filter(o -> o.amount() < 1_000_000).toList();
Map<Boolean, List<Order>> partitioned = smallOnly.stream()
.collect(partitioningBy(o -> o.amount() >= 1_000_000));
Map<Boolean, List<Order>> grouped = smallOnly.stream()
.collect(groupingBy(o -> o.amount() >= 1_000_000));
System.out.println(partitioned.get(true));
System.out.println(grouped.get(true));
[]
null
partitioningByは片側が空でも、常にtrueとfalseの両方を含みます。groupingByは少なくとも1つの要素がある場合のみキーを作成します。ここでgrouped.get(true).size()を呼び出すとNullPointerExceptionがスローされます。
したがって、真偽値の基準の場合はpartitioningByを使用してください。コードの意図がより明確で、キーが不足する心配がありません。
8. グループ化ロジックのテストを書く
pom.xmlにJUnit 5とAssertJを追加します。プロジェクトがSpring Boot 3.xを使用している場合、spring-boot-starter-testには既に両方が含まれています:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
レポートロジックを個別のサービスに配置して、テストしやすくします:
class OrderReportService {
Map<String, Long> paidTotalByCustomer(List<Order> orders) {
return orders.stream()
.collect(groupingBy(Order::customer, TreeMap::new,
filtering(o -> o.status() == OrderStatus.PAID,
summingLong(Order::amount))));
}
Map<Boolean, Long> countBigOrders(List<Order> orders, long threshold) {
return orders.stream()
.collect(partitioningBy(o -> o.amount() >= threshold, counting()));
}
}
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class OrderReportServiceTest {
private final OrderReportService service = new OrderReportService();
@Test
void shouldSumOnlyPaidOrdersPerCustomer() {
var result = service.paidTotalByCustomer(OrderFixtures.sample());
assertThat(result)
.containsExactly(
entry("An", 500_000L),
entry("Bình", 1_200_000L),
entry("Chi", 2_800_000L));
}
@Test
void shouldAlwaysContainBothPartitions() {
var result = service.countBigOrders(OrderFixtures.sample(), 10_000_000);
assertThat(result)
.containsEntry(true, 0L)
.containsEntry(false, 6L);
}
}
最初のテストは結果がTreeMapなのでcontainsExactlyを使用します。キーの順序は固定です。Anは500,000のみカウントされます。注文O3はキャンセルされているからです。Bìnhは1,200,000のみカウントされます。O5はまだ支払い待ちだからです。
2番目のテストは、閾値を超える注文がない場合でも、キーtrueが値0で存在することを確認します。
9. よくある間違い
Nullキー。 groupingByは分類子がnullを返すことを受け入れません:
var orders = List.of(new Order("O9", "Dung", null, OrderStatus.PAID, 100_000));
orders.stream().collect(groupingBy(Order::city));
java.lang.NullPointerException: element cannot be mapped to a null key
対処方法は2つあります。1つ目はnullをデフォルト値に置き換える:groupingBy(o -> Objects.requireNonNullElse(o.city(), "不明"))。2つ目はグループ化の前に都市を持たない注文をフィルタリングしておきます。
戻り値の型の混同。 counting()はLongを返します。averagingLongはDoubleを返します。maxByはOptionalを返します。型を誤ると、読みにくいコンパイルエラーが発生します。迷った場合はvarを使用するか、IDEに型を推論させてください。
各キーが単一の値のみを持つ場合のgroupingByの使用。 IDが一意の場合、groupingBy(Order::id)は1要素のリストを持つMap<String, List<Order>>を作成します。この場合はtoMap(Order::id, Function.identity())を使用してください。
結果のMapを変更。 Javadocは、デフォルトバージョンから返されたMapの型、変更可能性、またはスレッドセーフティを保証していません。その後Mapを変更する予定の場合は、Supplierを明確に渡してください。例えばHashMap::new。
注意: 並列ストリームを使用する場合、
groupingByは複数のMap部分をマージする必要があるため、遅い可能性があります。groupingByConcurrentはConcurrentHashMapに直接書き込み、より高速です。その代わり、各グループ内の要素の順序を保持しません。通常のストリームの場合は、単にgroupingByを使用してください。
10. 結論
groupingByは任意のキーで要素をグループ化します。partitioningByはPredicateに基づいて2つのサイドに分割し、常にtrueとfalseの両方を返します。真の力はdownstream collectorにあります:counting、summingLong、mapping、filtering、collectingAndThen、またはネストされたgroupingBy。
これら2つのcollectorを使用する際は、3つのポイントを覚えておくべきです:
- 順序が必要な場合は、適切な
Map型(TreeMap、EnumMap、LinkedHashMap)を選択します。 - グループ化の前にnullキーを処理します。
- 真偽値の基準には
partitioningByを優先します。
これらのポイントをマスターすれば、レポートロジックの大部分はわずか数行の読みやすく、テスト可能なStreamコードになります。