Collectors#toMap の注意点
はじめに
本記事では Stream
から Map
への変換で使う Collectors#toMap
の注意点について記す。
Stream#toList()
の罠 に続いて Stream
の使い方の話になる。
Stream
は便利な反面、Java の柵に囚われた悲しきモンスターな気がしてならない。他の言語で同様の処理を実現するためのライブラリと比べると API が全体的に歪ではないだろうか。
この記事も書き始めた後に既存のブログ記事を調べたところ、例に漏れず言及されている事柄であった。
TL;DR
- 引数が 2 つの
Collectors#toMap
は重複するキーがある場合に例外をスローする - 重複するキーがある場合に例外をスローしないように第三引数でマージ関数を指定すべし
Collectors#toMap
Collectors#toMap
は Stream
の要素をキーと値のペアに変換する Collector
である。
Collectors#toMap
は 3 つのメソッドをオーバーロードしている。それぞれ引数の数が異なり、用途によって使い分けなければならない。
引数が 2 つの Collectors#toMap
引数が 2 つの Collectors#toMap
のシグネチャは次の通りである。
1public static <T, K, U> Collector<T, ?, Map<K,U>> toMap(2Function<? super T, ? extends K> keyMapper,3Function<? super T, ? extends U> valueMapper4)
このメソッドでは、keyMapper
でキーを、valueMapper
で値を取得する関数を指定する。
典型的な使い方は次の通りである。
1jshell> Stream.of(2...> Map.entry("Alice", 1995),3...> Map.entry("Bob", 1972),4...> Map.entry("Charile", 1991)5...> ).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))6$1 ==> {Bob=1972, Alice=1995, Charile=1991}
例を簡単にするため、Stream
の要素に Map.Entry
を使っているが、現実のコードでは何かしらのクラスからあるフィールドの値をキーにしたマップを作る場面が多いだろう。
さて、この例は上手く動いているように思える、が引数が 2 つの Collectors#toMap
はキーが重複すると例外を投げる 🙄
二人の Alice
がいると次のような例外が発生する。
1jshell> Stream.of(2...> Map.entry("Alice", 1995),3...> Map.entry("Bob", 1972),の4...> Map.entry("Charile", 1991),5...> Map.entry("Alice", 1992)6...> ).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))7...>8| 例外java.lang.IllegalStateException: Duplicate key Alice (attempted merging values 1995 and 1992)9| at Collectors.duplicateKeyException (Collectors.java:135)10| at Collectors.lambda$uniqKeysMapAccumulator$1 (Collectors.java:182)11| at ReduceOps$3ReducingSink.accept (ReduceOps.java:169)12| at Spliterators$ArraySpliterator.forEachRemaining (Spliterators.java:992)13| at AbstractPipeline.copyInto (AbstractPipeline.java:509)14| at AbstractPipeline.wrapAndCopyInto (AbstractPipeline.java:499)15| at ReduceOps$ReduceOp.evaluateSequential (ReduceOps.java:921)16| at AbstractPipeline.evaluate (AbstractPipeline.java:234)17| at ReferencePipeline.collect (ReferencePipeline.java:682)18| at (#3:6)
Stream
を使ったプログラミング経験が少ないなかったり、マップというデータ構造を扱うのであればキーの重複時の動作に思いを巡らす習慣がないとこのようなパターンのデータが渡された場合のことをケアできずバグの原因となってしまう。
ある日まではデータの重複がなかったため上手く動いていたが、何かの拍子にキーが重複するようなデータが扱われてしまい実行時エラーが発生するのは目も当てられない。
Java は実行時例外を安易に投げる API を提供しないで欲しい。これならば 3 つ引数を受け取る Collection#toMap
だけを提供してくれた方が初学者に優しいのではないだろうか。
引数が 3 つの Collectors#toMap
引数が 3 つの Collectors#toMap
のシグネチャは次の通りである。
1public static <T, K, U> Collector<T,?,Map<K,U>> toMap(2Function<? super T,? extends K> keyMapper,3Function<? super T,? extends U> valueMapper,4BinaryOperator<U> mergeFunction5)
引数が 2 つの場合と比較すると第 3 引数に BinaryOperator<U> mergeFunction
が追加されている。
mergeFunction
はキーが重複したとき既に登録されている値とキーが重複したときに登録しようとした値の二つを受け取り、新しく登録するキーをを返す関数を受け取るための仮引数となっている。先ほどの Aclie
が 2 つある例について、後に現れる Alice
をキーに持つデータを優先したい場合は次のように書ける。
1jshell> Stream.of(2...> Map.entry("Alice", 1995),3...> Map.entry("Bob", 1972),4...> Map.entry("Charile", 1991),5...> Map.entry("Alice", 1992)6...> ).collect(Collectors.toMap(7...> Map.Entry::getKey,8...> Map.Entry::getValue,9...> (existing, replacement) -> replacement10...> ))11$2 ==> {Bob=1972, Alice=1992, Charile=1991}
Alice
の値が 1992
になっていることが確認できる。
mergeFunction
は単なる BinaryFunction
のため次のような処理も書ける。
1jshell> Stream.of(2...> Map.entry("Alice", 1995),3...> Map.entry("Bob", 1972),4...> Map.entry("Charile", 1991),5...> Map.entry("Alice", 1992)6...> ).collect(Collectors.toMap(7...> Map.Entry::getKey,8...> (entry) -> entry.getValue().toString(),9...> (existing, replacement) -> existing + "," + replacement10...> ))11$3 ==> {Bob=1972, Alice=1995,1992, Charile=1991}
この例では、Map.entry
の値を Integer
から String
に変換して、
Map<String, String>
型のマップを生成している。
mergeFunction
では、キーが重複したときは既存の値と新しい値をカンマ区切りで繋ぐようにした。
このように引数が 3 つある Collections#toMap
を使えば、キーが重複した場合を考慮したマップを作れる。
引数が 4 つの Collectors#toMap
最後に引数が 4 つある Collectors#toMap
のシグネチャを確認しよう。この関数は使う場面は多くないだろう。
1public static <T, K, U, M extends Map<K, U>> Collector<T,?,M> toMap(2Function<? super T,? extends K> keyMapper,3Function<? super T,? extends U> valueMapper,4BinaryOperator<U> mergeFunction,5Supplier<M> mapFactory6)
追加された 4 つ目の仮引数 mapFactory
は結果を入れる Map
型のインスタンスを生成する関数になっている。典型的には HashMap::new
のようなミュータブルなマップを生成する関数が渡される。
1jshell> Stream.of(2...> Map.entry("Alice", 1995),3...> Map.entry("Bob", 1972),4...> Map.entry("Charile", 1991),5...> Map.entry("Alice", 1992)6...> ).collect(Collectors.toMap(7...> Map.Entry::getKey,8...> (entry) -> entry.getValue().toString(),9...> (existing, replacement) -> existing + "," + replacement,10...> HashMap::new11...> ))12$4 ==> {Bob=1972, Alice=1995,1992, Charile=1991}
ドキュメントでは空のマップのサプライヤと書かかれているが空である必要はないだろう。
1jshell> Stream.of(2...> Map.entry("Alice", 1995),3...> Map.entry("Bob", 1972),4...> Map.entry("Charile", 1991),5...> Map.entry("Alice", 1992)6...> ).collect(Collectors.toMap(7...> Map.Entry::getKey,8...> (entry) -> entry.getValue().toString(),9...> (existing, replacement) -> existing + "," + replacement,10...> () -> {11...> var map = new HashMap<String, String>();12...> map.put("Dave", "2000");13...> return map;14...> }15...> ))16$7 ==> {Bob=1972, Alice=1995,1992, Charile=1991, Dave=2000}
このような使い方をすることはないだろうが、初期データを登録されたマップを作ってからデータを追加していくこともできる。
重複したときは List
にしたいのですが…
Collectors#groupingBy を使いましょう。
しかし、この記事を読んで Collectors#toMap
の動作を理解した人であれば Collectors#toMap
でも実装できることに気がつくでしょう。例えば valueMapper
、mergeFunction
に次のような処理を書けば同じような結果が得られる。
1jshell> Stream.of(2...> Map.entry("Alice", 1995),3...> Map.entry("Bob", 1972),4...> Map.entry("Charile", 1991),5...> Map.entry("Alice", 1992)6...> ).collect(Collectors.toMap(7...> Map.Entry::getKey,8...> (entry) -> {9...> var list = new ArrayList<Integer>();10...> list.add(entry.getValue());11...>12...> return list;13...> },14...> (existing, replacement) -> {15...> existing.addAll(replacement);16...>17...> return existing;18...> }19...> ))20$8 ==> {Bob=[1972], Alice=[1995, 1992], Charile=[1991]}
しかし、毎回これを書くのは億劫なので素直に重複した要素をすべて別のコレクションに集計したい場合は Collectors#groupingBy
を使いましょう。
まとめ
Collectors#toMap
を使う場合の注意点と、このメソッドが利用される典型的な場面について触れた。ここまで読んだ方であればわかってもらえると思うが、Collectors#toMap
を使う場合は常に第 3 引数まで指定することを前提に実引数を渡していくべきなのだ。
Stream
から Map
を作るときの思考の流れは、
- マップのキーは
___
だからStream
の要素から___
のように加工 - マップの値は
___
だからStream
の要素から___
のように加工 - マップのキーが重複する場合は
___
といった処理の結果を採用
をベースとし、「マップのキーは絶対に重複しない」という結論に至ったら第 3 引数を消す、にしておくのがよい習慣ではないだろうか。こんなことをプログラマに意識させるのであれば、引数が 2 つのメソッドなんて定義しないで
1public class ToMap {2public static BinaryOperator<T> neverDuplicateKey(T existing, T replacement) {3throw new IllegalStateException(...);4}5}
のようなメソッドを提供して、
1Stream.of(2Map.entry("Alice", 1995),3Map.entry("Bob", 1972),4Map.entry("Charile", 1991)5).collect(Collectors.toMap(6Map.Entry::getKey,7Map.Entry::getValue,8ToMap::neverDuplicateKey9))
みたいな API にした方がよかったのではないだろうか。 Java のコレクション回りはわざと実行時エラーが発生する可能性のあるコードを書きやすようにしているのではないかというふしがある。
Java に対する愚痴はこの辺にして、読者の方がこの記事を読むことで Collectors#toMap
を使ったコードで実行時例外を発生させないことを祈りつつ筆を置くこととする。