Map.merge()

Map.merge() 在键值范围中可能是功能最丰富的操作。而且还比较晦涩,很少使用。 merge() 可以解释如下:如果键值不存在(if absent),将新值放在给定键下;否则使用给定值更新现有键(UPSERT)。让我们从最基本的示例开始:计算唯一单词的出现次数。Java 8 之前的实现比较麻烦:

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.put() 中发现 map.get(word) 有点别扭。让我们也摆脱它!

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

仅当存在相关单词时,computeIfPresent() 才调用给定的转换,否则什么都不做。我们通过将键初始化为零来确保键存在,因此后面的增加转换始终有效。我们可以做得更好吗?通过减少额外的初始化,但是我不建议这样做:

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

compute()computeIfPresent() 类似,但是无论给定键是否存在都被调用。如果键的值不存在,则 prev 参数为 null。将 if 判断隐藏在 lambda 中的三元表达式远非最佳。这是 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 添加到现有值。我将参数之一命名为 “one”,因为在我们的示例中,它始终是……1。遗憾的是 remappingFunction 需要两个参数,其中第二个是我们将要更新(新增或修改)的值。从技术上讲,我们已经知道此值,因此 (word, 1, prev -> prev + 1) 将更容易理解。但是没有这样的 API。

好的,但是 merge() 真的有用吗?假设您有一个帐户操作(省略了构造函数,getter 和其他有用的属性):

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)
);

我觉得这很可读。对于每个操作,将给定数量添加到给定编号。结果符合预期:

{123=9.5, 456=-100}

ConcurrentHashMap

当您意识到 ConcurrentHashMap 中正确实现了 Map.merge() 时,它会更加有用。这意味着我们可以原子地执行插入或更新操作。而且线程安全。


原文链接