Map.merge () - метод для управления всеми остальными

Я не часто объясняю один метод в JDK, но когда я это делаю, речь идет о Map.merge.

Я не часто объясняю отдельные методы JDK, но когда это делаю — речь идёт о Map.merge(). Пожалуй, это самая универсальная операция во вселенной «ключ — значение», при этом довольно малоизвестная и редко используемая.

Метод merge() можно описать так: он либо помещает новое значение под заданным ключом, если тот отсутствует, либо обновляет существующее значение по этому ключу (UPSERT).

Начнём с классического примера — подсчёта уникальных вхождений слов. Код, написанный до Java 8 (то есть до 2014 года!), был довольно запутанным: суть терялась в деталях реализации:

var map = new HashMap<String, Integer>();
words.forEach(word -> {
    var prev = map.get(word);
    if (prev == null) {
        map.put(word, 1);
    } else {
        map.put(word, prev + 1);
    }
});

Тем не менее код работает и для данного набора слов даёт ожидаемый результат:

var words = List.of("Foo", "Bar", "Foo", "Buzz", "Foo", "Buzz", "Fizz", "Fizz");
// ...
// {Bar=1, Fizz=2, Foo=3, Buzz=2}

Попробуем избавиться от условной логики:

words.forEach(word -> {
    map.putIfAbsent(word, 0);
    map.put(word, map.get(word) + 1);
});

Уже лучше. putIfAbsent() здесь необходимое зло: без него код сломается при первом появлении ранее неизвестного слова. Вызов map.get(word) внутри map.put() всё равно выглядит немного громоздко. Упростим и это:

words.forEach(word -> {
    map.putIfAbsent(word, 0);
    map.computeIfPresent(word, (w, prev) -> prev + 1);
});

computeIfPresent() применяет указанное преобразование только в том случае, если указанный ключ уже существует в map; в противном случае ничего не происходит.

Мы гарантируем наличие ключа, инициализируя его нулём, — поэтому инкремент всегда отрабатывает корректно. Можно ли сократить код ещё больше? Технически — да, убрав инициализацию, но я бы не советовал:

words.forEach(word ->
    map.compute(word, (w, prev) -> prev != null ? prev + 1 : 1)
);

compute() похож на computeIfPresent(), но вызывается независимо от того, существует ли ключ. Если значения для ключа нет, параметр prev будет null. Заменять простой if на тернарное выражение, спрятанное в лямбде, — не лучшее решение. Именно здесь в игру вступает merge().

Прежде чем показать финальный вариант, рассмотрим упрощённую реализацию Map.merge() по умолчанию:

default V merge(K key, V value, BiFunction<V, V, 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;
}

Этот фрагмент стоит тысячи слов. merge() работает в двух сценариях. Если ключа нет — выполняется обычный put(key, value). Если же ключ уже существует — remappingFunction позволяет объединить старое и новое значение. С её помощью можно:

  • перезаписать старое значение: (old, new) -> new
  • сохранить старое значение: (old, new) -> old
  • объединить оба значения, например: (old, new) -> old + new
  • удалить старое значение: (old, new) -> null

Как видите, merge() действительно универсален. Как же выглядит наш пример с его использованием? Весьма элегантно:

words.forEach(word ->
    map.merge(word, 1, (prev, one) -> prev + one)
);

Читается это примерно так: под ключом-словом ставится значение 1, если его там нет; в противном случае к существующему значению прибавляется 1.

Один из параметров я назвал one, потому что в нашем примере это всегда... 1. К сожалению, remappingFunction принимает два параметра, второй из которых — то самое значение, которое мы собираемся вставить или обновить. Технически мы его и так знаем, поэтому запись вида (word, 1, prev -> prev + 1) была бы значительно нагляднее, но такого API не существует.


Но действительно ли merge() полезен за пределами учебных примеров? Представьте, что у вас есть класс банковской операции (конструктор, геттеры и прочее опущены):

class Operation {
    private final String accNo;
    private final BigDecimal amount;
}

И множество операций по разным счетам:

var operations = List.of(
    new Operation("123", new BigDecimal("10")),
    new Operation("456", new BigDecimal("1200")),
    new Operation("123", new BigDecimal("-4")),
    new Operation("123", new BigDecimal("8")),
    new Operation("456", new BigDecimal("800")),
    new Operation("456", new BigDecimal("-1500")),
    new Operation("123", new BigDecimal("2")),
    new Operation("123", new BigDecimal("-6.5")),
    new Operation("456", new BigDecimal("-600"))
);

Нам нужно рассчитать итоговый баланс для каждого счёта. Без merge() код получается громоздким:

var balances = new HashMap<String, BigDecimal>();

operations.forEach(op -> {
    var key = op.getAccNo();
    balances.putIfAbsent(key, BigDecimal.ZERO);
    balances.computeIfPresent(key, (accNo, prev) -> prev.add(op.getAmount()));
});

С merge() всё лаконичнее:

operations.forEach(op ->
    balances.merge(op.getAccNo(), op.getAmount(),
            (soFar, amount) -> soFar.add(amount))
);

А теперь заметили, что здесь можно использовать method reference?

operations.forEach(op ->
    balances.merge(op.getAccNo(), op.getAmount(), BigDecimal::add)
);

Читается поразительно легко: для каждой операции к значению по ключу accNo прибавляется соответствующая сумма. Результат будет таким:

// {123=9.5, 456=-100}

ConcurrentHashMap

Map.merge() сияет ещё ярче, когда узнаёшь, что в ConcurrentHashMap этот метод реализован атомарно. Это значит, что операции вставки и обновления выполняются в одну строку и потокобезопасно. ConcurrentHashMap в целом потокобезопасен, однако составные операции — например, последовательный вызов get() и put() — таковыми не являются. merge() же гарантирует, что ни одно обновление не будет потеряно при конкурентном доступе.

MyGpsTools publishes practical guides about GPS apps, maps, navigation tools, satellite imagery, Android Auto, Apple CarPlay, ZIP code maps, and location-based technologies.