1. はじめに
現実の開発では、ほとんど常にデータをグループ化する必要があります。例えば、学生を学年で分ける、商品をカテゴリで分ける、成人と未成年を分ける、など。
Stream API がない場合、こうした作業は手作業で行っていました。コレクションを走査し、特徴を判定し、Map 内の適切なリストに追加します。これは典型的な「昔ながらの」コードです:
Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee employee : employees) {
String dept = employee.getDepartment();
byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(employee);
}
動きはしますが、まるでパスタを瓶に手作業で仕分けしているような感じです。複数の特徴でグループ化したくなったら?さらに各グループの合計を出したくなったら?コードは膨らみ、読みにくくなります。
Stream API と専用コレクタ(groupingBy、partitioningBy)なら、これが 1〜2 行で書け、しかも本物の Java ウィザードのように見えます。
2. コレクタ groupingBy: 特徴によるグルーピング
基本アイデア
groupingBy は、要素のストリームを Map に変換するコレクタです。キーは特徴関数の結果、値はその特徴に該当する要素のリストになります。
シグネチャ:
Collectors.groupingBy(Function<T, K>)
- T — ストリーム中の要素の型
- K — 特徴関数が返すキー(グループ)の型
簡単な例: 部署ごとに従業員をグループ化
次のクラスがあるとします:
public class Employee {
private final String name;
private final String department;
// ... コンストラクタとゲッター
public Employee(String name, String department) {
this.name = name;
this.department = department;
}
public String getName() { return name; }
public String getDepartment() { return department; }
}
従業員のコレクション:
List<Employee> employees = List.of(
new Employee("Alisa", "IT"),
new Employee("Bob", "HR"),
new Employee("Klara", "IT"),
new Employee("Denis", "Finance"),
new Employee("Eva", "HR")
);
部署でグループ化:
Map<String, List<Employee>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
何が得られるか?
- キー: 部署名(String)。
- 値: その部署の従業員のリスト(List<Employee>)。
結果を出力:
byDepartment.forEach((dept, emps) -> {
System.out.println(dept + ": " +
emps.stream().map(Employee::getName).toList());
});
出力:
IT: [Alisa, Klara]
HR: [Bob, Eva]
Finance: [Denis]
内部でどう動くか?
最初に、ストリームの各要素に対してキー(たとえば getDepartment())が計算されます。すでにそのキーが Map に存在する場合は対応するリストに要素が追加され、存在しない場合は新しいリストが作られます。
たとえ話
手紙をフォルダに仕分けるイメージです。各手紙について、目的のフォルダがあるか確認し、なければ新しいフォルダを作ってそこに入れます。
3. コレクタ partitioningBy: 二つのグループへの分割
「値でグループ化」するというより、論理条件(true/false)でコレクションを二つに分けたいことがあります。例えば、給与が閾値より上/下の従業員、学生を「合格/不合格」に分ける、といった場合です。
そのための特別なコレクタが partitioningBy です。
シグネチャ:
Collectors.partitioningBy(Predicate<T>)
Predicate は真偽値を返す関数です。
例: 給与水準で従業員を二分割
次のようなクラスがあるとします:
public class Employee {
private final String name;
private final int salary;
// ... コンストラクタとゲッター
public Employee(String name, int salary) {
this.name = name;
this.salary = salary;
}
public String getName() { return name; }
public int getSalary() { return salary; }
}
そしてリスト:
List<Employee> employees = List.of(
new Employee("Alisa", 120_000),
new Employee("Bob", 80_000),
new Employee("Klara", 150_000),
new Employee("Denis", 95_000)
);
「高給」と「控えめ」に分割:
Map<Boolean, List<Employee>> partitioned = employees.stream()
.collect(Collectors.partitioningBy(e -> e.getSalary() > 100_000));
- true — 給与が 100_000 を超える従業員。
- false — それ以外。
出力:
System.out.println("高給: " +
partitioned.get(true).stream().map(Employee::getName).toList());
System.out.println("控えめ: " +
partitioned.get(false).stream().map(Employee::getName).toList());
結果:
高給: [Alisa, Klara]
控えめ: [Bob, Denis]
partitioningBy と groupingBy の使い分けは?
- グループが二つより多い場合は groupingBy。
- ブール条件で二分するだけなら partitioningBy。その方が高速で分かりやすい。
4. 入れ子のグルーピング: 複数の特徴でグループ化
一つの特徴だけでなく、グルーピングを「入れ子」にしたいこともあります。例えば、まず部署で従業員をまとめ、部署の内部でさらに役職で分ける、といった具合です。
例:
クラス Employee に position フィールドがあるとします:
public class Employee {
private final String name;
private final String department;
private final String position;
// ... コンストラクタとゲッター
}
入れ子のグルーピング:
Map<String, Map<String, List<Employee>>> byDeptAndPosition = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment,
Collectors.groupingBy(Employee::getPosition)));
- 外側のキー — 部署。
- 内側のキー — 役職。
- 値 — 従業員のリスト。
IT 部門で役職が「Developer」の従業員にどうアクセスする?
List<Employee> itDevs = byDeptAndPosition
.getOrDefault("IT", Map.of())
.getOrDefault("Developer", List.of());
ビジュアル図
Map<Department, Map<Position, List<Employee>>>
└─ "IT"
├─ "Developer" -> [Alisa, Klara]
└─ "QA" -> [Boris]
└─ "HR"
└─ "Recruiter" -> [Denis]
5. 実用的なグルーピング例
例 1: 文字列を長さでグループ化
List<String> words = List.of("cat", "dog", "elephant", "bee", "ant", "dolphin");
Map<Integer, List<String>> byLength = words.stream()
.collect(Collectors.groupingBy(String::length));
byLength.forEach((len, ws) -> System.out.println(len + ": " + ws));
出力:
3: [cat, dog, bee, ant]
8: [elephant, dolphin]
例 2: 偶奇で数をグループ化
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
Map<String, List<Integer>> byParity = numbers.stream()
.collect(Collectors.groupingBy(n -> n % 2 == 0 ? "even" : "odd"));
System.out.println(byParity);
// {odd=[1, 3, 5], even=[2, 4, 6]}
例 3: 文字列での partitioningBy の使用
先頭が "A" のものとそれ以外に分割:
List<String> names = List.of("Alice", "Bob", "Anna", "Charlie");
Map<Boolean, List<String>> byA = names.stream()
.collect(Collectors.partitioningBy(s -> s.startsWith("A")));
System.out.println("A-names: " + byA.get(true)); // [Alice, Anna]
System.out.println("Other: " + byA.get(false)); // [Bob, Charlie]
5. 役に立つ注意点
グルーピング結果の使い方
グルーピングのあと、単に Map を得るだけでなく、何らかの処理をしたくなることが多いです:
- 全グループを走査して情報を出力する。
- 要素数が最大のグループを見つける。
- 各グループで合計や平均を計算する(詳しくは次回の講義)。
例: 各部署の従業員数を出力
byDepartment.forEach((dept, emps) ->
System.out.println(dept + ": " + emps.size() + " 人"));
手作業実装との比較
復習として: 部署でのグループ化を「昔ながらのやり方」で書くとこうなります:
Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee e : employees) {
String dept = e.getDepartment();
byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(e);
}
そして Stream API を使うとこうなります:
Map<String, List<Employee>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
結論: コード量もエラーも減り、読みやすさが向上します。
小技
List ではなく例えば Set に集めたい場合は、第 2 引数に Collectors.toSet() を指定します:
Collectors.groupingBy(Employee::getDepartment, Collectors.toSet())
集約を同時に行うこともできます。例えば、各部署の従業員数を得るには:
Collectors.groupingBy(Employee::getDepartment, Collectors.counting())
ただし詳細は次回の講義で!
partitioningBy の結果には常に二つのキー、true と false が存在します。どちらかのグループが空でも同様です。
入れ子のグルーピングを行うと、構造は「木」になります。すなわち Map の中に Map が入っていきます。
6. グルーピング/partitioning でよくあるミス
誤り No. 1: 戻り値の型の思い違い。 初心者は、groupingBy の結果が単なる List<T> だと期待しがちですが、実際は Map<K, List<T>> です。その結果、List のメソッドを呼ぼうとしてコンパイルエラーになります。覚えておきましょう: グルーピングの結果は常に Map です。
誤り No. 2: 存在しないグループにアクセスして NullPointerException。 存在しないキーでリストを取得しようとすると、null が返ります。getOrDefault(key, List.of()) を使うか、containsKey でキーの有無を確認しましょう。
誤り No. 3: 複数グループの問題に partitioningBy を使ってしまう。 partitioningBy は二つのグループ(true/false)専用です。グループがそれ以上あるなら groupingBy を使いましょう。
誤り No. 4: ストリーム内でコレクションを変更する。 ストリーム内で元のコレクションや Map を変更しないでください。予期しない不具合につながります。処理は Stream API とコレクタで完結させましょう。
誤り No. 5: 入れ子のグルーピング構造を見落とす。 入れ子の groupingBy の結果は、Map の中に Map が入る構造です。適切にデータを取り出す(例えば getOrDefault を使う)ことを忘れると、ClassCastException を招きます。
GO TO FULL VERSION