Java | Java 詳細・モダン文法:Stream API 深掘り – downstream collector

Java Java
スポンサーリンク

downstream collector ってそもそも何?

downstream collector は、日本語にすると「下流の Collector」です。
でも名前よりもイメージが大事で、ざっくり言うとこうです。

groupingBypartitioningBy で“グループに分けたあと”、その“各グループの中身をどう集計するか”を担当する Collector」

つまり、
「どのグループに入るか」を決めるのが groupingBy / partitioningBy の役割で、
「グループごとに何をするか」を決めるのが downstream collector の役割です。

counting()summingInt()mapping()toList() など、Collectors にいるやつらが、よく downstream として使われます。


一番シンプルな形:groupingBy + counting の組み合わせ

「グループ分け」だけの groupingBy

まず、downstream を使わない groupingBy から見てみます。

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

public class GroupingBasic {
    public static void main(String[] args) {
        List<String> words = List.of("apple", "banana", "apricot", "blueberry");

        Map<Character, List<String>> byInitial =
                words.stream()
                     .collect(Collectors.groupingBy(s -> s.charAt(0)));

        System.out.println(byInitial);
        // {a=[apple, apricot], b=[banana, blueberry]}
    }
}
Java

ここでは、キーが Character、値が List<String>Map になっています。
つまり「グループ分けだけして、グループの中身はそのまま全部 List に入れる」という動きです。

downstream を足して「グループごとの件数」にする

今度は、「頭文字ごとの件数」が欲しいとします。
このときに出てくるのが downstream collector です。

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

public class GroupingWithCounting {
    public static void main(String[] args) {
        List<String> words = List.of("apple", "banana", "apricot", "blueberry");

        Map<Character, Long> counts =
                words.stream()
                     .collect(Collectors.groupingBy(
                             s -> s.charAt(0),          // キー:頭文字
                             Collectors.counting()      // 下流:件数を数える
                     ));

        System.out.println(counts);
        // {a=2, b=2}
    }
}
Java

ここでの Collectors.counting() が downstream collector です。
groupingBy が「どのグループに入るか」を決め、counting() が「そのグループの中で何をするか(件数を数える)」を決めています。

戻り値の型が Map<Character, Long> になっていることに注目してください。
downstream を変えると、「グループごとの結果の型」も変わります。


もう少し踏み込む:downstream は「グループの中身専用の collect」

「グループごとに別の collect をしている」と考える

groupingBy(keyExtractor, downstream) は、イメージとしてはこうです。

  1. まずキーごとに要素をグループ分けする
  2. 各グループに対して「そのグループの Stream」を作る
  3. そのグループの Stream に対して collect(downstream) を実行する

つまり、

「全体の Stream に対して 1 回 collect する」のではなく、
「グループごとに小さな collect をしている」

と考えると、downstream の役割がスッと入ってきます。

counting() なら「グループごとの件数」、
toList() なら「グループごとのリスト」、
summingInt() なら「グループごとの合計」
という具合です。


よく使う downstream collector のパターン

toList / toSet:グループごとのコレクション

groupingBy の第二引数に toList() を渡すと、実は「デフォルトと同じ」動きになります。

Map<Character, List<String>> byInitial =
        words.stream()
             .collect(Collectors.groupingBy(
                     s -> s.charAt(0),
                     Collectors.toList()
             ));
Java

これは「グループごとに collect(toList()) している」と読めます。

toSet() を使えば、「グループごとに重複を除いた Set にする」こともできます。

Map<Character, Set<String>> byInitialSet =
        words.stream()
             .collect(Collectors.groupingBy(
                     s -> s.charAt(0),
                     Collectors.toSet()
             ));
Java

summingInt / averagingInt:グループごとの数値集計

例えば「年齢ごとの合計給与」を出したいとします。

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

class Employee {
    private final String name;
    private final int age;
    private final int salary;

    Employee(String name, int age, int salary) {
        this.name = name;
        this.age = age;
        this.salary = salary;
    }

    int getAge() { return age; }
    int getSalary() { return salary; }
}

public class GroupingSum {
    public static void main(String[] args) {
        List<Employee> employees = List.of(
                new Employee("Alice", 30, 300),
                new Employee("Bob",   30, 400),
                new Employee("Carol", 40, 500)
        );

        Map<Integer, Integer> totalSalaryByAge =
                employees.stream()
                         .collect(Collectors.groupingBy(
                                 Employee::getAge,
                                 Collectors.summingInt(Employee::getSalary)
                         ));

        System.out.println(totalSalaryByAge);
        // {30=700, 40=500}
    }
}
Java

ここでは、

キー:年齢
downstream:その年齢グループの給与を合計する summingInt

という構造になっています。

同じように averagingInt を使えば、「グループごとの平均値」も簡単に書けます。


mapping と組み合わせる:グループごとに「別の型」に変換して集める

例:カテゴリごとに「商品名だけのリスト」を作る

mapping という downstream 用の Collector を使うと、
「グループごとに、要素を別の型に変換してから集める」ことができます。

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

class Item {
    private final String name;
    private final String category;

    Item(String name, String category) {
        this.name = name;
        this.category = category;
    }

    String getName() { return name; }
    String getCategory() { return category; }
}

public class GroupingMapping {
    public static void main(String[] args) {
        List<Item> items = List.of(
                new Item("Apple",  "Fruit"),
                new Item("Banana", "Fruit"),
                new Item("Carrot", "Vegetable")
        );

        Map<String, List<String>> namesByCategory =
                items.stream()
                     .collect(Collectors.groupingBy(
                             Item::getCategory,
                             Collectors.mapping(
                                     Item::getName,          // まず名前に変換して
                                     Collectors.toList()     // それをリストに集める
                             )
                     ));

        System.out.println(namesByCategory);
        // {Fruit=[Apple, Banana], Vegetable=[Carrot]}
    }
}
Java

ここでは、downstream がさらに「mappingtoList」という二段構成になっています。
「グループごとに collect する」だけでなく、「その collect の中身も組み合わせられる」というのが、downstream の強さです。


partitioningBy でも同じように downstream が使える

partitioningBy も、第二引数に downstream を取るオーバーロードがあります。

Map<Boolean, Long> counts =
        students.stream()
                .collect(Collectors.partitioningBy(
                        s -> s.getScore() >= 60,
                        Collectors.counting()
                ));
Java

これは「合格 / 不合格に二分」+「それぞれの人数を数える」という処理です。
ここでも、「二分する」のが partitioningBy、「各グループで何をするか」が downstream です。


設計の視点:downstream をどう選ぶか

downstream collector を設計するときに考えることは、実はシンプルです。

まず、「何でグループ分けしたいか」を決める。
次に、「グループごとに何が欲しいか」を決める。

そして、その「グループごとに欲しいもの」を素直に表現してくれる Collector を downstream に置きます。

グループごとのリストが欲しいなら toList
グループごとの件数が欲しいなら counting
グループごとの合計・平均が欲しいなら summingXxx / averagingXxx
グループごとの別の型のリストが欲しいなら mapping(..., toList())

この「キー」と「downstream」をセットで考える癖がつくと、
groupingBy / partitioningBy の設計が一気に気持ちよくなります。


まとめ:downstream collector を自分の言葉で定義する

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

「downstream collector は、groupingBypartitioningBy で作られた“各グループの中身”に対して、さらに collect をかけるための Collector。
キーでグループ分けするのが外側、グループごとに何をするかを決めるのが downstream。
countingsummingInttoListmapping などを組み合わせることで、“グループごとの件数・合計・リスト・変換後のリスト”などを柔軟に表現できる。」

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