Java | Java 詳細・モダン文法:Stream API 深掘り – groupingBy 複数キー

Java Java
スポンサーリンク

「groupingBy 複数キー」でやりたいことのイメージ

Collectors.groupingBy の「複数キー」は、ざっくり言うと
「1 つの条件じゃなくて、2 個以上の条件の組み合わせでグループ分けしたい」
という話です。

例えば「部署 × 性別ごとに社員をグループ分けしたい」「年 × 言語ごとに本をグループ分けしたい」みたいなやつですね。引用元: Java 8 Streams – Collectors.groupingBy() を使用した複数フィールドによるグループ化

やり方は大きく 2 パターンあります。

ひとつは「groupingBy をネストする」パターン。
もうひとつは「複数のフィールドを 1 つの“複合キー”にまとめる」パターンです。

順番にかみ砕いていきます。


パターン1:groupingBy をネストして「Map の中に Map」を作る

部署 × 性別でグループ分けする例

まずは一番ストレートなやり方から。

社員クラスをざっくりこう決めます。

class Employee {
    private final String name;
    private final String department; // 部署
    private final String gender;     // "M" / "F" など

    Employee(String name, String department, String gender) {
        this.name = name;
        this.department = department;
        this.gender = gender;
    }

    String getDepartment() { return department; }
    String getGender() { return gender; }
    String getName() { return name; }

    @Override
    public String toString() {
        return name;
    }
}
Java

これを「部署ごと」かつ「その中で性別ごと」にグループ分けしたいとします。

コードはこう書けます。

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class GroupingByNested {
    public static void main(String[] args) {
        List<Employee> employees = List.of(
                new Employee("Alice",   "Sales", "F"),
                new Employee("Bob",     "Sales", "M"),
                new Employee("Carol",   "Dev",   "F"),
                new Employee("Dave",    "Dev",   "M"),
                new Employee("Ellen",   "Dev",   "F")
        );

        Map<String, Map<String, List<Employee>>> grouped =
                employees.stream()
                         .collect(Collectors.groupingBy(
                                 Employee::getDepartment,                 // 第1キー:部署
                                 Collectors.groupingBy(Employee::getGender) // 第2キー:性別
                         ));

        System.out.println(grouped);
    }
}
Java

型に注目すると、

Map<部署, Map<性別, List<Employee>>>

という形になっています。

つまり、外側の Map が「部署ごと」、内側の Map が「性別ごと」、一番内側が「そのグループに属する社員のリスト」です。引用元: Javaのラムダ式(Stream API)で複数キーでCollectors.groupingByする

ネストが増えるとどうなるか

同じノリで「部署 × 性別 × 雇用形態」みたいに増やしていくと、

Collectors.groupingBy(
    Employee::getDepartment,
    Collectors.groupingBy(
        Employee::getGender,
        Collectors.groupingBy(Employee::getType)
    )
)
Java

のように、groupingBy を入れ子にしていくことになります。

型は

Map<部署, Map<性別, Map<雇用形態, List<Employee>>>>

という、なかなかパンチのある形になります。

「階層構造として扱いたい」「部署 → 性別 → … と段階的に見たい」なら、このネスト方式は素直で分かりやすいです。
一方で、「ただ“組み合わせ”でグループ分けしたいだけ」のときは、もう少しフラットに扱いたくなることもあります。


パターン2:複数フィールドを 1 つの「複合キー」にまとめる

「部署+性別」を 1 つのキーとして扱う

もうひとつの考え方は、
「部署」と「性別」を別々のキーにするのではなく、
「部署+性別」という 1 つのキーにしてしまう方法です。

そのために、小さなキー用クラス(または record)を作ります。

import java.util.Objects;

class DeptGenderKey {
    private final String department;
    private final String gender;

    DeptGenderKey(String department, String gender) {
        this.department = department;
        this.gender = gender;
    }

    String getDepartment() { return department; }
    String getGender() { return gender; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof DeptGenderKey)) return false;
        DeptGenderKey other = (DeptGenderKey) o;
        return Objects.equals(department, other.department)
                && Objects.equals(gender, other.gender);
    }

    @Override
    public int hashCode() {
        return Objects.hash(department, gender);
    }

    @Override
    public String toString() {
        return department + "-" + gender;
    }
}
Java

これをキーにして groupingBy します。

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class GroupingByCompositeKey {
    public static void main(String[] args) {
        List<Employee> employees = List.of(
                new Employee("Alice",   "Sales", "F"),
                new Employee("Bob",     "Sales", "M"),
                new Employee("Carol",   "Dev",   "F"),
                new Employee("Dave",    "Dev",   "M"),
                new Employee("Ellen",   "Dev",   "F")
        );

        Map<DeptGenderKey, List<Employee>> grouped =
                employees.stream()
                         .collect(Collectors.groupingBy(
                                 e -> new DeptGenderKey(e.getDepartment(), e.getGender())
                         ));

        System.out.println(grouped);
    }
}
Java

型は

Map<DeptGenderKey, List<Employee>>

と、かなりスッキリします。

DeptGenderKey の中身を見れば、「どの部署のどの性別か」が一目で分かりますし、
equals / hashCode をちゃんと実装しておけば、Map のキーとしても安全に使えます。引用元: Java 8 Streams – Collectors.groupingBy() を使用した複数フィールドによるグループ化

record を使うともっとシンプルになる

Java 16 以降なら、複合キーは record で書くとかなり楽です。

record DeptGenderKey(String department, String gender) {}
Java

これだけで、equals / hashCode / toString まで自動生成されます。

あとは先ほどと同じように、

Map<DeptGenderKey, List<Employee>> grouped =
        employees.stream()
                 .collect(Collectors.groupingBy(
                         e -> new DeptGenderKey(e.getDepartment(), e.getGender())
                 ));
Java

と書くだけです。


どっちのパターンを選ぶべきかの設計感覚

ネスト Map 方式が向いている場面

「部署 → 性別 → …」のように、階層構造として扱いたいとき。
例えば、「まず部署ごとにループして、その中で性別ごとに処理したい」ようなケースです。

この場合、

Map<String, Map<String, List<Employee>>> grouped = ...;

for (var entryDept : grouped.entrySet()) {
    String dept = entryDept.getKey();
    Map<String, List<Employee>> byGender = entryDept.getValue();

    for (var entryGender : byGender.entrySet()) {
        String gender = entryGender.getKey();
        List<Employee> list = entryGender.getValue();
        // 部署×性別ごとの処理
    }
}
Java

のように、段階的に降りていくコードが自然に書けます。

複合キー方式が向いている場面

「単に“組み合わせ”でグループ分けしたいだけ」で、
階層構造として扱う必要はあまりないとき。

例えば、「部署×性別ごとの人数を集計して一覧にしたい」ようなケースでは、

Map<DeptGenderKey, Long> counts =
        employees.stream()
                 .collect(Collectors.groupingBy(
                         e -> new DeptGenderKey(e.getDepartment(), e.getGender()),
                         Collectors.counting()
                 ));
Java

のように書けて、とてもフラットで扱いやすくなります。

Map.Entry<DeptGenderKey, Long> をそのまま List に変換して画面表示、みたいな流れも作りやすいです。


さらに一歩:複数キー+下流 Collector の組み合わせ

「部署×性別ごとの人数」を出す

さっき少し触れたように、groupingBy の第 2 引数に「下流 Collector」を渡すと、
「グループごとにさらに集計する」ことができます。引用元: 【java8】streamのgroupingbyの使い方

複合キー方式で人数を数える例をもう少し丁寧に書くと、こうです。

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class GroupingCountDemo {
    public static void main(String[] args) {
        List<Employee> employees = List.of(
                new Employee("Alice",   "Sales", "F"),
                new Employee("Bob",     "Sales", "M"),
                new Employee("Carol",   "Dev",   "F"),
                new Employee("Dave",    "Dev",   "M"),
                new Employee("Ellen",   "Dev",   "F")
        );

        Map<DeptGenderKey, Long> counts =
                employees.stream()
                         .collect(Collectors.groupingBy(
                                 e -> new DeptGenderKey(e.getDepartment(), e.getGender()),
                                 Collectors.counting()
                         ));

        counts.forEach((key, count) ->
                System.out.println(key + " : " + count));
    }
}
Java

出力イメージはこんな感じです。

Sales-F : 1
Sales-M : 1
Dev-F   : 2
Dev-M   : 1

「複数キーでグループ分け」+「グループごとの集計」というのは、
実務でかなり頻出のパターンなので、
groupingBy(複合キー, counting()) の形はぜひ手に馴染ませておくといいです。


まとめ:groupingBy 複数キーを自分の言葉で整理する

あなたの言葉でまとめるなら、こうなります。

groupingBy の複数キーは、
groupingBy をネストして Map<キー1, Map<キー2, List<T>>> のような階層構造にするか、
複数フィールドを 1 つの“複合キー”クラス(record)にまとめて Map<複合キー, List<T>> にするか、
どちらかのパターンで実現する。」

そして設計のポイントは、

階層として扱いたいならネスト方式
フラットに扱いたい・集計したいなら複合キー方式
複合キーを作るときは equals / hashCode を正しく実装する(record だと楽)
groupingBy(キー, counting() / summingInt() / mapping() ...) のように、下流 Collector と組み合わせると一気に表現力が上がる

あたりです。

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