1. 入れ子の groupingBy: 構文と仕組み
現実のデータでは、1 つの条件だけでグループ化できることはまれです。たとえば従業員リストがある場合、各部門の人数だけでなく、各部門の各職位ごとの人数も知りたくなるでしょう。学生のデータベースなら、各課程の各専攻ごとの学生数です。
入れ子のグルーピングを使うと、「Map の中に Map」という構造、すなわち「部門 → 職位 → 従業員のリスト」や「課程 → 専攻 → 学生のリスト」を構築できます。
Stream API がない場合、こうした処理は複数の入れ子ループと手作業での Map 構築で行うことになります。ストリームと Collector を使えば、1〜2 行で書けます。
入れ子のグルーピングとは、Collectors.groupingBy の第 2 引数に別の Collector(たとえばもう一つの groupingBy)を渡すことです。結果は、各キーの値としてさらに別のマップを持つマップになります。
基本パターン
Map<Klyuch1, Map<Klyuch2, List<T>>> result =
stream.collect(Collectors.groupingBy(
obekt -> klyuch1,
Collectors.groupingBy(obekt -> klyuch2)
));
例: 部門と職位で従業員をグループ化
次のようなクラスがあるとします:
class Employee {
private String name;
private String department;
private String position;
private int salary;
// ... コンストラクター、getter、toString
}
従業員のリスト:
List<Employee> employees = List.of(
new Employee("Ivan", "IT", "開発者", 120_000),
new Employee("Mariya", "IT", "テスター", 90_000),
new Employee("Pyotr", "HR", "マネージャー", 80_000),
new Employee("Ol’ga", "IT", "開発者", 130_000),
new Employee("Svetlana", "HR", "リクルーター", 70_000)
);
部門と職位でグループ化します:
Map<String, Map<String, List<Employee>>> grouped = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(Employee::getPosition)
));
何が得られたか?
キーが部門、値がマップ(キーが職位、値が従業員のリスト)です。
構造の可視化
IT:
開発者: [Ivan, Ol’ga]
テスター: [Mariya]
HR:
マネージャー: [Pyotr]
リクルーター: [Svetlana]
2. 集約付きグルーピング: groupingBy と集約子の組み合わせ
入れ子のグルーピングはリスト収集に限りません。各サブグループ内でそのまま集約もできます。
例: 部門ごとの最大給与
Map<String, Optional<Employee>> maxSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
));
ここでは各部門について、給与が最大の従業員を取得します(部門が空の可能性があるため、結果は Optional でラップされます)。
入れ子のグルーピング + 集約
各部門の各職位ごとの最大給与を知りたいとします:
Map<String, Map<String, Optional<Employee>>> maxSalaryByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))
)
));
これは何を意味するか?
- 各部門に対して職位のマップ。
- 各職位に対して最大給与の従業員(該当なしなら空の Optional)。
3. 変換付きグルーピング: mapping を groupingBy の中で使う
オブジェクトをそのまま集めるのではなく、各グループ内で特定のフィールドだけを取り出したい場合があります。
例: 部門ごとの従業員名
Map<String, List<String>> namesByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.mapping(Employee::getName, Collectors.toList())
));
結果:
IT: [Ivan, Mariya, Ol’ga]
HR: [Pyotr, Svetlana]
入れ子の mapping
mapping は入れ子の groupingBy とも組み合わせられます:
Map<String, Map<String, List<String>>> namesByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.mapping(Employee::getName, Collectors.toList())
)
));
結果:
IT:
開発者: [Ivan, Ol’ga]
テスター: [Mariya]
HR:
マネージャー: [Pyotr]
リクルーター: [Svetlana]
4. 数値データのグルーピング + 集約
単にグルーピングするだけでなく、グループごとの合計・平均・件数を計算したいことがよくあります。
例: 部門ごとの平均給与
Map<String, Double> avgSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.averagingInt(Employee::getSalary)
));
入れ子の集約
各部門における職位ごとの平均給与:
Map<String, Map<String, Double>> avgSalaryByDeptAndPos = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.groupingBy(
Employee::getPosition,
Collectors.averagingInt(Employee::getSalary)
)
));
5. partitioningBy + 集約
ブール条件でコレクションを 2 つに分け、その内側でさらに集約するのが便利なこともあります。
例: 各部門で給与が 100_000 を超える従業員の人数
Map<String, Map<Boolean, Long>> countByDeptAndSalary = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.partitioningBy(
e -> e.getSalary() > 100_000,
Collectors.counting()
)
));
結果:
各部門に対して、true/false → 件数のマップが得られます。
6. 実践課題: 入れ子のグルーピングを使う
課題 1. 課程と専攻で学生をグループ化
class Student {
private String name;
private int course;
private String speciality;
private double grade;
// ... getter、コンストラクター
}
List<Student> students = ... // すでにあるとする
Map<Integer, Map<String, List<Student>>> byCourseAndSpec = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.groupingBy(Student::getSpeciality)
));
課題 2. 課程ごとの平均成績
Map<Integer, Double> avgGradeByCourse = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.averagingDouble(Student::getGrade)
));
課題 3. グループごとに名前だけ取り出す
Map<Integer, Map<String, List<String>>> namesByCourseAndSpec = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.groupingBy(
Student::getSpeciality,
Collectors.mapping(Student::getName, Collectors.toList())
)
));
7. 役に立つポイント
入れ子の Map からの読み出し方
入れ子のマップは最初は扱いづらく感じるかもしれません。基本的な例を示します:
for (var deptEntry : grouped.entrySet()) {
String dept = deptEntry.getKey();
Map<String, List<Employee>> byPosition = deptEntry.getValue();
System.out.println("部門: " + dept);
for (var posEntry : byPosition.entrySet()) {
String pos = posEntry.getKey();
List<Employee> emps = posEntry.getValue();
System.out.println(" 職位: " + pos + " -> " + emps);
}
}
入れ子グルーピングの模式図
Map<部門, Map<職位, List<Employee>>>
│
├── "IT"
│ ├── "開発者" → [Ivan, Ol’ga]
│ └── "テスター" → [Mariya]
└── "HR"
├── "マネージャー" → [Pyotr]
└── "リクルーター" → [Svetlana]
表: 組み合わせごとの結果
| コレクター | 結果 |
|---|---|
|
|
|
|
|
|
|
|
|
|
8. 入れ子のグルーピングでよくあるミス
エラー No.1: 入れ子の Map 構造を誤解している。
入れ子のグルーピング後に、各マップの値が何かを取り違えやすいです。常に結果のシグネチャ(型)を確認しましょう — IDE が型を教えてくれます。自信がないときは System.out.println(grouped) で出力するか、デバッガーを使いましょう。
エラー No.2: データ取得時の NullPointerException。
キーが存在しない場合(例: その部門に特定の職位の従業員がいない)、Map.get(key) は null を返します。containsKey でキーの有無を確認するか、Java 8 以降なら Map.getOrDefault、Java 9 以降なら Map.ofNullable や Optional のメソッドを活用しましょう。
エラー No.3: 入れ子が複雑すぎる。
グルーピングがあまりに深く(3〜4 階層)なる場合は、データ構造の見直しや処理の段階分割を検討しましょう。
エラー No.4: 集約の適用レベルを間違える。
集約用の Collector(averagingInt、counting など)を本来入れるべき groupingBy の内側ではなく外側に置いてしまい、意図しない結果になることがあります。かっこの位置に注意しましょう!
エラー No.5: collect の最中に要素を変更しようとする。
グルーピングの処理中に元のコレクションやオブジェクトを変更しないでください。思わぬバグの原因になります。
GO TO FULL VERSION