Skip to main content

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

Автор статьи - 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():

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