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под ключ word, если отсутствует, в противном случае добавить 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():

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

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

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

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

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

ConcurrentHashMap

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

Ilia is a professional writer. He has expert knowledge in GPS and cartography with 15 years of experience. Additionally, Ilia has extensive experience in data recovery on PC and mobile. He started his career as a journalist by reviewing PC and mobile apps. His current responsibilities are to keep track of users' questions on MGT and answer them.