CodeGym /コース /JAVA 25 SELF /groupingBy と partitioningBy のメソッド(Collectors)

groupingBy と partitioningBy のメソッド(Collectors)

JAVA 25 SELF
レベル 31, レッスン 2
使用可能

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 を招きます。

1
タスク
JAVA 25 SELF, レベル 31, レッスン 2
ロック未解除
「イノベーティブ・ソリューションズ」社におけるチーム編成 🏢
「イノベーティブ・ソリューションズ」社におけるチーム編成 🏢
1
タスク
JAVA 25 SELF, レベル 31, レッスン 2
ロック未解除
エキゾチックな果物の仕分け 🍎🍌
エキゾチックな果物の仕分け 🍎🍌
コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION