Collector設計の深化──mutable構造とスレッド安全性、teeing・filtering・flatMappingの構造的使い分け
1. mutableなCollectorの危険性とレビュー観点
JavaのCollector
は、内部的に可変構造(mutable container)を使用して状態を蓄積するため、設計次第ではスレッドセーフでない構造になります。
これはとくにparallelStream()
時に意図しない動作や競合を引き起こす原因になります。
1.1 典型的な危険構造
Collector<String, List<String>, List<String>> unsafeCollector = Collector.of(
ArrayList::new,
List::add,
(l1, l2) -> { l1.addAll(l2); return l1; }
);
この構造は並列Streamで使うと、l1.addAll(l2)
のタイミングで競合・破壊的合成が発生しうる構造です。
mutable構造でのcombiner
は、“返すオブジェクトを破壊的に変更する”構造を避けるのが鉄則。
レビュー観点
- combinerが破壊的変更(in-place変更)になっていないか?
List.addAll()
やMap.putAll()
で戻り値をそのまま再利用していないか?- 並列Streamを想定した設計かどうか?
2. カスタムCollectorを並列対応させる設計ポイント
スレッドセーフなCollectorを自作する場合は、特にcombiner
の実装が重要になります。
並列処理時に蓄積用の構造を共有せず、安全に結合できる設計が求められます。
2.1 安全な構造例(Immutable構造を再生成)
Collector<String, List<String>, List<String>> safeCollector = Collector.of(
ArrayList::new,
List::add,
(left, right) -> {
List<String> merged = new ArrayList<>(left);
merged.addAll(right);
return merged;
}
);
このように、結合用の構造は必ず新しく生成することで、既存インスタンスの破壊を防ぎます。
mutableな蓄積構造を使う場合でも、“combinerでは必ず新しいインスタンスに結果を移す”ことが原則。
レビュー観点
combiner
で副作用が発生していないか?finisher
で不変構造に変換すべきかどうか?- 並列性を担保できる構造(immutable化 or thread-local構造)になっているか?
3. teeing()
の複合集約構造と可読性のトレードオフ
Java 12以降で導入されたCollectors.teeing()
は、2つのCollectorを並行実行し、それぞれの結果を1つの出力に合成できる構造です。
これは柔軟性の高い構造ですが、読解難易度が高くなりがちなため、レビューでは「意図が明確に読み取れるかどうか」が焦点になります。
3.1 使用例:合計と平均の同時取得
Collector<Integer, ?, Summary> summaryCollector = Collectors.teeing(
Collectors.summingInt(x -> x),
Collectors.averagingInt(x -> x),
Summary::new
);
ここでSummary
は (sum, avg)
を受け取るDTOです。
teeingは「データの2分割集約」ではなく、「同じstreamの2方向集約」を可能にする構造。
利点
- streamの評価を1回で済ませつつ、複数の異なる集約を実現できる
留意点
- 構造が入れ子になりやすく、レビューで意図が読みづらい
- 合成後の構造(DTOやPair)の定義が曖昧だと、「何が返るのか」が不明瞭
レビュー観点
teeing
で作成された合成構造は名前付きのDTOなどで明示されているか?- “合成結果の意味”が型や変数名から読み取れる構造になっているか?
- 必要に応じて、合成先構造(Tuple/Pairsなど)を明示的に型定義しているか?
4. filtering()
の設計的使いどころと責務分離
Java 9 で導入された Collectors.filtering()
は、groupingBy
などの downstream collector の前段階で条件を適用できる構造です。
従来は filter().collect(...)
で対応していたが、groupingBy
などと併用する場合に責務分離として明確になります。
4.1 通常の構造との比較
従来の例:
users.stream()
.filter(User::isActive)
.collect(Collectors.groupingBy(User::getDepartment));
filtering()
を使った構造:
users.stream().collect(Collectors.groupingBy(
User::getDepartment,
Collectors.filtering(User::isActive, Collectors.toList())
));
filtering は「特定のグループにおける条件付き収集」を構造の中で表現できるため、
stream 全体の filter に比べて 局所的な目的が明確に見える。
レビュー観点
filtering
は必要な粒度(group内)に対して使われているか?- 単純なstream filterとの責務の違いが意識されているか?
groupingBy
との組み合わせで意図が過剰に複雑化していないか?
5. flatMapping()
によるStreamネストの解消と再構築
Java 9 で導入された flatMapping()
は、ネストされた構造(List<Set
5.1 基本形
Map<String, Set<String>> deptToTags =
users.stream().collect(Collectors.groupingBy(
User::getDepartment,
Collectors.flatMapping(
user -> user.getTags().stream(),
Collectors.toSet()
)
));
これは、「部署ごとの全タグ集合」を求める典型例です。
flatMapping によって、ネスト構造の flatten と mapping が一度に実現できる。
複雑な map(...).flatMap(...).collect(...)
を避けられる。
レビュー観点
flatMapping
の使用が「List の List」や「Set の Set」などのネスト構造に対して正当化されているか?- mapping 関数の戻り値が null を返す可能性がないか?(→ 空streamで防御されているか?)
- 返却される構造が
Set
/List
/Optional
など、明示的な意図を持って選定されているか?
6. Collector設計における柔軟性と安全性のバランス戦略
これまでの章で見たように、Collectorは非常に表現力が高い一方で、過剰な合成・副作用の混入・スレッド安全性の欠如といった罠も併せ持ちます。
6.1 柔軟な構造設計の要点
- downstream collector は目的ごとに明確に分ける
- filtering/flatMapping のような“構造変換の粒度”を使い分ける
- 合成された Collector は必ず
命名された関数
やDTO
で意味づけを補う
6.2 安全性を担保するポイント
- mutable構造は
combiner
で破壊的操作を行わない collectingAndThen
で外部サービスを呼ばない(副作用禁止)teeing
などの複合集約では意味づけ構造を明示(record
やPair<T1,T2>
)
Collectorは「コードを短くする道具」ではなく、「構造を組み立てる設計部品」。
レビューアーは、そのCollectorが「何を表していて、どう合成されているか」を読み解く力が問われる。
総まとめ:Collectorを“コード”としてではなく“構造”として読む
Stream API における Collector は、設計における「出口のかたち」を決定づける構成要素です。
以下の観点を踏まえることで、Collector は「動く処理」から「意味を持つ構造」へと進化します。
✔ Collector設計のチェックポイント
観点 | チェック内容 |
---|---|
スレッド安全性 | combinerは破壊的でないか? |
複合集約 | teeingの出力構造に名前や意味があるか? |
ネスト解消 | flatMappingは適切な場所で使われているか? |
条件付き集約 | filteringでロジックをstream外に追い出せるか? |
構造可読性 | collect文の意図が関数名・DTOで補足されているか? |