Published on
Updated on 

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

Authors
Author: Vladimir Brown

Автор статьи - TOMASZ NURKIEWICZ, ссылка на оригинал: Map.merge() - One method to rule them all. Перевод опубликован с разрешения автора.

Я не часто объясняю один метод в 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() вызывает указанное преобразование только в том случае, если существует ключ в вопросе (word). В противном случае ничего не происходит.

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

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() универсален. Так как же выглядит наша академическая проблема с merge()? Это довольно-таки приятно:

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

Вы можете прочитать это следующим образом: под словом-ключом ставится 1, если оно отсутствует. В противном случае, добавляется 1 к существующему значению.

Я назвал один из параметров "один", потому что в нашем примере это всегда ... 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():

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

Вы видите здесь ссылку на метод?

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

Это поразительно читабельно. Для каждой операции add задана сумма к заданному значению accNo. Результаты будут такими, как ожидалось:

{
  ;(123 = 9.5), (456 = -100)
}

ConcurrentHashMap

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

I'm Vladimir, your guide in the expansive world of technology journalism, with a special focus on GPS technologies and mapping. My journey in this field extends over twenty fruitful years, fueled by a profound passion for technology and an insatiable curiosity to explore its frontiers.