1. toMap()の基本構造と典型的な落とし穴

JavaのCollectors.toMap()は、StreamからMap構造へと変換するCollectorの中でも非常に頻出のAPIです。
しかしこのCollectorには引数の数と意味が直感に反する罠があります。

1.1 基本構文(2引数)

Map<String, User> userMap = users.stream()
  .collect(Collectors.toMap(User::getId, Function.identity()));

これは id をキーとし、要素全体を値にしたマッピング。
一見シンプルですが、キー重複時は例外(IllegalStateException)になります

2引数の toMap() はキー重複に対処できない。
運用環境で1件重複が出た瞬間に、全体が例外で停止する構造を含んでいる。

レビュー観点

  • 入力Streamに キーが一意である保証はあるか?
  • Map化した後の用途において、 衝突が無視されると問題になるケースはないか?

2. キー衝突時の合成関数と“どちらを残すか”の判断軸

3引数版のtoMap()では、キーが重複した場合の 合成関数(mergeFunction) を指定できます。

Map<String, User> userMap = users.stream()
  .collect(Collectors.toMap(
    User::getId,
    Function.identity(),
    (u1, u2) -> u1
  ));

この (u1, u2) -> u1 は「同じキーが複数ある場合、最初の値を採用する」構造です。

mergeFunction では「どちらを優先するか?」という設計意図を明確に持つ。
単純な (a, b) -> a でも、“なぜ後者を捨てられるか?”が重要な設計判断になる。

判断の観点

  • データの鮮度:最新レコードが必要なら (a, b) -> b を優先
  • 品質やスコア:スコアが高い方を採用 (a, b) -> a.getScore() > b.getScore() ? a : b
  • ログ・追跡:衝突自体をログに記録し、後から検証する設計もあり

レビュー観点

  • 衝突する可能性を“例外”ではなく“構造”として処理しているか?
  • mergeFunction が暗黙的すぎないか?(例: (a, b) -> a の意図が曖昧)
  • 衝突した要素のどちらかに 統計・重み・優先度 がある場合、それを無視していないか?

3. toConcurrentMap()使用時のスレッド設計と安全性

toConcurrentMap() は、ConcurrentMap で返されることを除けば構文は toMap() と同様です。
しかしその内部では ConcurrentHashMap の非同期的なputIfAbsent()や合成が行われるため、並列Streamでの使用が想定されます

ConcurrentMap<String, Integer> concurrentMap = users.parallelStream()
  .collect(Collectors.toConcurrentMap(
    User::getDepartment,
    u -> 1,
    Integer::sum
  ));

この例では部署ごとの人数をカウントしています。

toConcurrentMap() を使用する意図は、並列StreamでのスレッドセーフなMap集約を行いたいときに限る。
通常StreamではtoMap()で十分。

誤用の例

  • 並列Streamを使っていないのに toConcurrentMap() を使っている
  • mergeFunctionで副作用(外部変更など)を含めてしまう

レビュー観点

  • parallelStream()との組み合わせで初めてtoConcurrentMap()を使用しているか?
  • mergeFunction内が副作用なしで安全に結合できる構造になっているか?
  • Mapとしての戻り値に対して同時書き込みや順序性を求めていないか?

4. toMapで作られるMapの責務:DTOマップか構造的集約か

StreamからMapを構築するとき、しばしば「何をキーにするか?」ばかりが焦点になります。
しかしレビュー観点では 「そのMapが何を担っているか?」 という責務設計の視点が重要です。

4.1 DTOマップ vs 集約構造マップ

種類 構造の例 意図
DTOマップ Map<Id, User> 特定要素のランダムアクセス・高速参照
構造的集約 Map<Dept, List<User>> 論理的なグルーピング・集計対象構造

DTOマップはあくまで“一覧”をMapにすり替えたもの。
一方で構造的集約は、処理の流れそのものをMapに表現した構造となる。

レビュー観点

  • 値の型(V)がDTOなのか、リスト・統計・合成構造なのかを読み取れているか?
  • Mapの役割は単なる“検索性”か、それとも“集約的意味”を持つ構造か?
  • 後続処理にとって、Mapである必要は本当にあるか?(例:リストで十分では?)

5. nullキー・null値・Optionalとの設計的な整合性

JavaのMap構造では、nullキーとnull値の扱いに制限があります。特にConcurrentMapでは nullキーもnull値も非許容 です。

5.1 toMap()のnull挙動

  • nullキー → NullPointerException
  • null値 → 許容されるが、意図の明示が必要
users.stream().collect(Collectors.toMap(
  User::getId, 
  user -> user.getNickname() // nickname が null の可能性あり
));

このようなケースでは、「nullのまま入れる」「Optional.ofNullable()に変える」「filter で除外する」などの判断が分かれます。

toMap()でnullを渡すと、エラーになるか、Mapの意図が崩壊するリスクがある。
nullの存在が“例外”なのか“仕様”なのかをレビューで見極める。

レビュー観点

  • キーや値にnullが含まれる可能性があるとき、それが 許容されている前提かどうか?
  • Optionalなどで構造的に明示されているか?
  • filter(x -> x.getX() != null) などでnullを回避している場合、その除外の妥当性は?

6. CollectorでMapを扱う際の再利用設計とレビュー支援

toMaptoConcurrentMapはロジックとしてはシンプルですが、レビューしやすく・再利用しやすいCollector構造にする設計力が求められます。

6.1 共通化パターン

ユーティリティ関数化

public static <T> Collector<T, ?, Map<String, T>> indexById(Function<T, String> idExtractor) {
  return Collectors.toMap(idExtractor, Function.identity());
}

ラッパ化して意味づけ

Map<String, User> userMap = users.stream().collect(indexById(User::getId));

このように構造の意図をCollector名に込めることで、レビュー時の理解が飛躍的に向上します。

Collectorを直接埋め込むのではなく、「意味を持つ命名関数」として包む。
レビューアーは“意図を読む”ことに集中できるようになる。

レビュー観点

  • toMap()の構造が、再利用しやすく名前で説明可能な関数に分離されているか?
  • そのCollectorが「どんな構造のために存在するか」が命名に現れているか?
  • 複数箇所で使われるtoMapが定義ごとに微妙に異なっていないか?
UML Diagram

総まとめ:Mapは“検索構造”か、“集約構造”かを見極める

toMap()は単なる便利APIではなく、そのMapがアプリケーションの構造においてどう振る舞うかを決める設計部品です。

レビューアーは以下を常に問い直すべきです:

  • 「このMapはどこで何のために使われるのか?」
  • 「衝突・null・責務の視点で矛盾や落とし穴はないか?」
  • 「そのCollectorは、命名されるに値する“構造の意味”を持っているか?」

Streamの出口がMapであるとき、そこには設計判断の重心があります。
レビューは「動けばよい」ではなく、「意味が保たれるか」を照らす行為であることを、改めて意識しておきましょう。