1. はじめに
Stream API でコレクションを扱うとき、単に要素をグループ化するだけでなく、同時に何らかの処理(変換・フィルタリング・別の種類のコレクションに収集)を行いたい場面がよくあります。これを支えるのが Java の ダウンストリーム・コレクター — 各グループやデータの部分集合に対して適用される入れ子のコレクターです。
ダウンストリーム・コレクターとは?
グループ化や分割の結果に対して適用されるコレクターのことです。たとえば groupingBy で学生をコースごとにグループ化し、各グループの中で名前だけを集めたり、優秀者だけを集めたり(例: GPA の閾値を 4.5 とする)できます。
例
mapping
収集する前にグループ内の要素を変換できます。
Map<Integer, List<String>> namesByCourse = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.mapping(Student::getName, Collectors.toList())
));
- 学生をコースごとにグループ化します.
- 各グループでは(オブジェクト全体ではなく)名前だけを収集します。
filtering
グループ内の要素をフィルタリングできます(例: GPA >= 4.5 のみ残す)。
Map<Integer, List<Student>> honorsByCourse = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.filtering(s -> s.getGpa() >= 4.5, Collectors.toList())
));
各グループで優秀者だけを残します。
flatMapping
グループ内の入れ子になったコレクションを「展開」できます。
Map<String, Set<String>> tagsByAuthor = books.stream()
.collect(Collectors.groupingBy(
Book::getAuthor,
Collectors.flatMapping(
book -> book.getTags().stream(),
Collectors.toSet()
)
));
各著者について、その著者の全書籍のタグを一意に収集します。
partitioningBy と downstream
groupingBy に似ていますが、真偽条件で 2 つのグループに分割します。
Map<Boolean, List<String>> namesByPassed = students.stream()
.collect(Collectors.partitioningBy(
s -> s.getGpa() >= 3.0,
Collectors.mapping(Student::getName, Collectors.toList())
));
合格/不合格で学生を分け、各グループでは名前のみを収集します。
2. teeing: 2 つのコレクターで同時に集約する
ストリームに対して複数の集計値(例: 合計と平均、最小値と最大値)を同時に計算したいことがあります。これを可能にするコレクターが Java 12+ で導入された teeing です。
teeing はどのように動作するか?
2 つのコレクターと、それらの結果を結合する関数を渡します。
構文:
Collectors.teeing(collector1, collector2, (result1, result2) -> ...)
例
最小値 + 最大値
Optional<MinMax> minMax = numbers.stream()
.collect(Collectors.teeing(
Collectors.minBy(Integer::compareTo),
Collectors.maxBy(Integer::compareTo),
(min, max) -> min.isPresent() && max.isPresent() ? new MinMax(min.get(), max.get()) : null
));
最小値と最大値を同時に求めます。
合計 + 平均
Result result = numbers.stream()
.collect(Collectors.teeing(
Collectors.summingInt(Integer::intValue),
Collectors.averagingInt(Integer::intValue),
(sum, avg) -> new Result(sum, avg)
));
合計と平均を持つオブジェクトを得ます。
例: 給与レポート
SalaryStats stats = employees.stream()
.collect(Collectors.teeing(
Collectors.summingInt(Employee::getSalary),
Collectors.averagingInt(Employee::getSalary),
SalaryStats::new
));
SalaryStats に合計と平均の両方を保持します。
3. toUnmodifiableList/Set/Map と collectingAndThen による「凍結」
近年の Java には、作成後に変更できないコレクション — 不変(unmodifiable)コレクションがあります。結果をうっかり変更できないことが重要な API では便利です。
toUnmodifiableList/Set/Map
- 不変コレクションを返します。
- 要素の追加/削除を試みると UnsupportedOperationException が発生します。
例:
List<String> names = students.stream()
.map(Student::getName)
.collect(Collectors.toUnmodifiableList());
Map<Integer, Student> byId = students.stream()
.collect(Collectors.toUnmodifiableMap(Student::getId, Function.identity()));
collectingAndThen
コレクターの結果に関数を適用できます。たとえばコレクションを「凍結」します。
List<String> names = students.stream()
.map(Student::getName)
.collect(Collectors.collectingAndThen(
Collectors.toList(),
Collections::unmodifiableList
));
まず通常のリストに収集し、その後に不変化します。
Set の例:
Set<String> tags = books.stream()
.flatMap(book -> book.getTags().stream())
.collect(Collectors.collectingAndThen(
Collectors.toSet(),
Set::copyOf // Java 10+
));
4. パイプラインの実例
レポートとスライス別統計
高度なコレクターを使うと、複雑なレポートや統計を「ワンライナー」で構築できます。
例: 部門別の平均給与
Map<String, Double> avgSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.averagingInt(Employee::getSalary)
));
例: カテゴリごとの高価な商品トップ3
Map<String, List<Product>> top3ByCategory = products.stream()
.collect(Collectors.groupingBy(
Product::getCategory,
Collectors.collectingAndThen(
Collectors.toList(),
list -> list.stream()
.sorted(Comparator.comparing(Product::getPrice).reversed())
.limit(3)
.toList()
)
));
API 契約としての不変な結果
メソッドが変更不可能なコレクションを返すなら、偶発的なミスを防ぎ、API をより堅牢にできます。
public List<String> getTags() {
return tags.stream()
.collect(Collectors.toUnmodifiableList());
}
利用者は getTags().add("新しいタグ") のような操作はできず、例外が発生します。
例: 複数の集計を含むレポート(teeing)
public SalaryReport getSalaryReport(List<Employee> employees) {
return employees.stream()
.collect(Collectors.teeing(
Collectors.averagingInt(Employee::getSalary),
Collectors.summingInt(Employee::getSalary),
SalaryReport::new
));
}
SalaryReport に平均給与と総給与の両方を保持します。
5. 典型的な落とし穴と注意点
エラー1: 不変性を忘れる。 通常のリストを返すと、後から変更される可能性があります。結果を「凍結」するには toUnmodifiableList/Set/Map または collectingAndThen を使用します。
エラー2: 不適切なダウンストリーム・コレクター。 グループ内の要素を変換したい場合は mapping、フィルタリングしたい場合は filtering、入れ子のコレクションを「展開」したい場合は flatMapping を使います。
エラー3: UnsupportedOperationException。 toUnmodifiableList/Set/Map で収集したコレクション、または「凍結」した collectingAndThen のコレクションを変更しようとすると発生します。
エラー4: 一意性の欠落/キー衝突。 toUnmodifiableSet では要素が一意である必要があり、toUnmodifiableMap ではキーが一意である必要があります。満たされない場合は収集中に例外が発生します。
GO TO FULL VERSION