downstream collector ってそもそも何?
downstream collector は、日本語にすると「下流の Collector」です。
でも名前よりもイメージが大事で、ざっくり言うとこうです。
「groupingBy や partitioningBy で“グループに分けたあと”、その“各グループの中身をどう集計するか”を担当する 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) は、イメージとしてはこうです。
- まずキーごとに要素をグループ分けする
- 各グループに対して「そのグループの Stream」を作る
- そのグループの 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()
));
JavasummingInt / 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 がさらに「mapping → toList」という二段構成になっています。
「グループごとに 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 は、groupingBy や partitioningBy で作られた“各グループの中身”に対して、さらに collect をかけるための Collector。
キーでグループ分けするのが外側、グループごとに何をするかを決めるのが downstream。counting、summingInt、toList、mapping などを組み合わせることで、“グループごとの件数・合計・リスト・変換後のリスト”などを柔軟に表現できる。」
