Published on
Updated on 

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

Authors

Автор статьи - 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() гарантирует, что обновления не будут потеряны.

Was this helpful?

Result: 0, total votes: 0

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.