All You Need Is

Collectors#toMap の注意点

2024/08/29
Javaプログラミング

はじめに

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

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

TL;DR

Collectors#toMap

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

引数が 2 つの Collectors#toMap

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

1
public static <T, K, U> Collector<T, ?, Map<K,U>> toMap(
2
Function<? super T, ? extends K> keyMapper,
3
Function<? super T, ? extends U> valueMapper
4
)

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

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

1
jshell> 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 がいると次のような例外が発生する。

1
jshell> 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 のシグネチャは次の通りである。

1
public static <T, K, U> Collector<T,?,Map<K,U>> toMap(
2
Function<? super T,? extends K> keyMapper,
3
Function<? super T,? extends U> valueMapper,
4
BinaryOperator<U> mergeFunction
5
)

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

1
jshell> 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) -> replacement
10
...> ))
11
$2 ==> {Bob=1972, Alice=1992, Charile=1991}

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

1
jshell> 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
$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 のシグネチャを確認しよう。この関数は使う場面は多くないだろう。

1
public static <T, K, U, M extends Map<K, U>> Collector<T,?,M> toMap(
2
Function<? super T,? extends K> keyMapper,
3
Function<? super T,? extends U> valueMapper,
4
BinaryOperator<U> mergeFunction,
5
Supplier<M> mapFactory
6
)

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

1
jshell> 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::new
11
...> ))
12
$4 ==> {Bob=1972, Alice=1995,1992, Charile=1991}

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

1
jshell> 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 でも実装できることに気がつくでしょう。例えば valueMappermergeFunction に次のような処理を書けば同じような結果が得られる。

1
jshell> 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 を作るときの思考の流れは、

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

1
public class ToMap {
2
public static BinaryOperator<T> neverDuplicateKey(T existing, T replacement) {
3
throw new IllegalStateException(...);
4
}
5
}

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

1
Stream.of(
2
Map.entry("Alice", 1995),
3
Map.entry("Bob", 1972),
4
Map.entry("Charile", 1991)
5
).collect(Collectors.toMap(
6
Map.Entry::getKey,
7
Map.Entry::getValue,
8
ToMap::neverDuplicateKey
9
))

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

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

類似の記事


Buy Me A Coffee