Collectors.toMap() は第3引数を意識してください


リストからマップを作る時に使う以下のコード

Map<String, String> phoneBook = people.stream()
        .collect(Collectors.toMap(Person::getName, Person::getAddress);

なつかしい以下のコードとは挙動が異なります。

Map<String, String> phoneBook = new HashMap<>();
for (Person p : people) {
    phoneBook.put(p.getName(), p.getAddress());
}

Collectors.toMap() の場合は、キー つまり Person.getName() に重複がある場合は IllegalStateException になります。

昔ながらの Map の操作の延長で考えると、新しいもので上書きされると考えがちなので注意しなければなりません。


Collectors.toMap() には第3引数に BinaryOperator<U> mergeFunction を指定することで、キー重複時の動作を指定できます。

新しいもので上書する場合は以下のような具合です。

Map<String, String> phoneBook = people.stream()
        .collect(Collectors.toMap(Person::getName, Person::getAddress, 
                (s, a) -> a);

値を文字列連結して格納するような場合は以下の具合です。

Map<String, String> phoneBook = people.stream()
        .collect(Collectors.toMap(Person::getName, Person::getAddress, 
                (s, a) -> s + ", " + a);

実装は

Collectors.toMap() の中では stream の値を map.merge() で処理しています。

(map, element) -> map.merge(
                    keyMapper.apply(element),
                    valueMapper.apply(element), 
                    mergeFunction);

merge() は 1.8 で Map インターフェースに追加された default メソッドです。

public interface Map<K,V> {

    default V merge(K key, V value,
            BiFunction<? super V, ? super V, ? extends V> remappingFunction) {

        V oldValue = get(key);
        V newValue = (oldValue == null) 
                                ? value 
                                : remappingFunction.apply(oldValue, value);
        if (newValue == null) {
            remove(key);
        } else {
            put(key, newValue);
        }
        return newValue;
    }
}

key に該当する値があれば remappingFunction で oldValue と value を評価して、得られた値を使っていますね。

remappingFunction の戻りが null の場合は remove(key) されます。


引数が2つの Collectors.toMap() の場合は以下の BinaryOperator<T> が設定され、IllegalStateException となります。

private static <T> BinaryOperator<T> throwingMerger() {
    return (u,v) -> { throw new IllegalStateException(String.format("Duplicate key %s", u)); };
}


ちなみに、Collectors.toSet() の場合は、値が重複しても問題ありません。

Stream.of("A", "B", "C", "B").collect(Collectors.toSet());

以下のようにそのまま add() しているだけなので。

public static <T> Collector<T, ?, Set<T>> toSet() {
    return new CollectorImpl<>((Supplier<Set<T>>) HashSet::new, Set::add,
                               (left, right) -> { left.addAll(right); return left; },
                               CH_UNORDERED_ID);
}

まとめ

Collectors.toMap() はデフォルトではキー重複を許容せず、IllegalStateException になります。

意識せずに使って、思わぬところで例外とならないように注意が必要です。

常に第3引数まで、以下のように指定する

public viod foo() {
    Map<String, String> phoneBook = people.stream()
          .collect(Collectors.toMap(Person::getName, Person::getAddress, 
                duplicateKeyThrowing());
}

public static <T> BinaryOperator<T> duplicateKeyThrowing() {
    return (u, v) -> { throw new IllegalStateException(String.format("Duplicate key %s", u)); };
}

と言ったら行き過ぎでしょうか。