Collectors#toMap の注意点

2024/08/29

はじめに

本記事では Stream から Map への変換で使う Collectors#toMap の注意点について記す。 Stream#toList() の罠 に続いて Stream の使い方の話になる。 Stream は便利な反面、Java の柵に囚われた悲しきモンスターな気がしてならない。他の言語で同様の処理を実現するためのライブラリと比べると API が全体的に歪ではないだろうか。

この記事も書き始めた後に既存のブログ記事を調べたところ、例に漏れず言及されている事柄であった。

TL;DR

  • 引数が 2 つの Collectors#toMap は重複するキーがある場合に例外をスローする
  • 重複するキーがある場合に例外をスローしないように第三引数でマージ関数を指定すべし

Collectors#toMap

Collectors#toMapStream の要素をキーと値のペアに変換する Collector である。 Collectors#toMap は 3 つのメソッドをオーバーロードしている。それぞれ引数の数が異なり、用途によって使い分けなければならない。

引数が 2 つの Collectors#toMap

引数が 2 つの Collectors#toMap のシグネチャは次の通りである。

Loading code...

このメソッドでは、keyMapper でキーを、valueMapper で値を取得する関数を指定する。

典型的な使い方は次の通りである。

Loading code...

例を簡単にするため、Stream の要素に Map.Entry を使っているが、現実のコードでは何かしらのクラスからあるフィールドの値をキーにしたマップを作る場面が多いだろう。

さて、この例は上手く動いているように思える、が引数が 2 つの Collectors#toMap はキーが重複すると例外を投げる 🙄 二人の Alice がいると次のような例外が発生する。

Loading code...

Stream を使ったプログラミング経験が少ないなかったり、マップというデータ構造を扱うのであればキーの重複時の動作に思いを巡らす習慣がないとこのようなパターンのデータが渡された場合のことをケアできずバグの原因となってしまう。

ある日まではデータの重複がなかったため上手く動いていたが、何かの拍子にキーが重複するようなデータが扱われてしまい実行時エラーが発生するのは目も当てられない。

Java は実行時例外を安易に投げる API を提供しないで欲しい。これならば 3 つ引数を受け取る Collection#toMap だけを提供してくれた方が初学者に優しいのではないだろうか。

例外メッセージによる情報漏洩

Java の例外メッセージは親切にも Alice というキーで 19951992 のマージを試みたことを例外のメッセージで表示してくれている。しかし、この手のメッセージを出力する API は慎重に取り扱わなければならない。もし、生成しようとしいるマップのキーや値に個人情報が含まれているとどうなるだろうか。ログにキーと値が出力されてしまい、個人情報の漏洩に繋る可能性がある。例外メッセージの出力を抑制して対策することは可能だが、バグを埋め込んでしまった場合の原因調査の難易度が上がってしまうという別の問題が発生する。今回の例であれば、スタックトレースさえあれば原因の特定は容易だが、外部依存しているライブラリで例外が発生したときが難しい。

ERR01-J. センシティブな情報を例外によって外部に漏えいしないで紹介されているように検査例外であれば実装時にクレデンシャルが露呈するリスクに気がつけるが、標準 API が投げる非検査例外が例外のメッセージにオブジェクトの値を入れ込んでくるのが非常に厄介だ。

引数が 3 つの Collectors#toMap

引数が 3 つの Collectors#toMap のシグネチャは次の通りである。

Loading code...

引数が 2 つの場合と比較すると第 3 引数に BinaryOperator<U> mergeFunction が追加されている。 mergeFunction はキーが重複したとき既に登録されている値とキーが重複したときに登録しようとした値の二つを受け取り、新しく登録するキーをを返す関数を受け取るための仮引数となっている。先ほどの Aclie が 2 つある例について、後に現れる Alice をキーに持つデータを優先したい場合は次のように書ける。

Loading code...

Alice の値が 1992 になっていることが確認できる。 mergeFunction は単なる BinaryFunction のため次のような処理も書ける。

Loading code...

この例では、Map.entry の値を Integer から String に変換して、 Map<String, String> 型のマップを生成している。 mergeFunction では、キーが重複したときは既存の値と新しい値をカンマ区切りで繋ぐようにした。

このように引数が 3 つある Collections#toMap を使えば、キーが重複した場合を考慮したマップを作れる。

引数が 4 つの Collectors#toMap

最後に引数が 4 つある Collectors#toMap のシグネチャを確認しよう。この関数は使う場面は多くないだろう。

Loading code...

追加された 4 つ目の仮引数 mapFactory は結果を入れる Map 型のインスタンスを生成する関数になっている。典型的には HashMap::new のようなミュータブルなマップを生成する関数が渡される。

Loading code...

ドキュメントでは空のマップのサプライヤと書かかれているが空である必要はないだろう。

Loading code...

このような使い方をすることはないだろうが、初期データを登録されたマップを作ってからデータを追加していくこともできる。

重複したときは List にしたいのですが…

Collectors#groupingBy を使いましょう。

しかし、この記事を読んで Collectors#toMap の動作を理解した人であれば Collectors#toMap でも実装できることに気がつくでしょう。例えば valueMappermergeFunction に次のような処理を書けば同じような結果が得られる。

Loading code...

しかし、毎回これを書くのは億劫なので素直に重複した要素をすべて別のコレクションに集計したい場合は Collectors#groupingBy を使いましょう。

まとめ

Collectors#toMap を使う場合の注意点と、このメソッドが利用される典型的な場面について触れた。ここまで読んだ方であればわかってもらえると思うが、Collectors#toMap を使う場合は常に第 3 引数まで指定することを前提に実引数を渡していくべきなのだ。

Stream から Map を作るときの思考の流れは、

  • マップのキーは ___ だから Stream の要素から ___ のように加工
  • マップの値は ___ だから Stream の要素から ___ のように加工
  • マップのキーが重複する場合は ___ といった処理の結果を採用

をベースとし、「マップのキーは絶対に重複しない」という結論に至ったら第 3 引数を消す、にしておくのがよい習慣ではないだろうか。こんなことをプログラマに意識させるのであれば、引数が 2 つのメソッドなんて定義しないで

Loading code...

のようなメソッドを提供して、

Loading code...

みたいな API にした方がよかったのではないだろうか。 Java のコレクション回りはわざと実行時エラーが発生する可能性のあるコードを書きやすようにしているのではないかというふしがある。

Java に対する愚痴はこの辺にして、読者の方がこの記事を読むことで Collectors#toMap を使ったコードで実行時例外を発生させないことを祈りつつ筆を置くこととする。

類似の記事

SuzumiyaAobaのプロフィール画像

SuzumiyaAoba

プログラミング、技術、その他の話題について共有するブログを書いてます。 主にScala、Java、TypeScriptなどの技術について興味あり。

ScalaJavaTypeScriptReact

Buy Me A Coffee