詳解Map.merge()

油膩的Java發表於2019-03-22

今天介紹Map的merge方法,讓我們來看看它的強大之處。

在JDK的API中,這樣的一個方法它是很特別的,它很新穎,它是值得我們花時間去了解的,同時也推薦你可以運用到實際的專案程式碼中,對你們應該幫助很大。Map.merge()。這可能是Map中最通用的操作。但它也相當模糊,幾乎很少人會去使用它。

背景介紹

merge()可以解釋如下:它將新的值賦值給到key中(如果不存在)或更新具有給定值的現有key(UPSERT)。讓我們從最基本的例子開始:計算唯一的單詞出現次數。在java8之前的時候,程式碼非常混亂,實際的實現其實已經失去了本質層面的設計意義。

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}
複製程式碼

改進V1

現在讓我們來重構它,主要去掉它的一些判斷邏輯;

words.forEach(word -> {
    map.putIfAbsent(word, 0);
    map.put(word, map.get(word) + 1);
});
複製程式碼

這樣的改進,是可以滿足我們的重構要求。putIfAbsent()的具體用法就不過多描述。putIfAbsent那一行程式碼是一定需要的,否則,後面的邏輯也就會報錯。而在下面程式碼中,又出現了putget這一點會很奇怪,讓我們再繼續的進行改進設計。

改進V2

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

複製程式碼

computeIfPresent是僅當 word中的的key存在的時候才呼叫給定的轉換。否則它什麼都不處理。我們通過將key初始化為零來確保key存在,因此增量始終有效。這樣的實現是不是已經足夠完美?未必,還有其他的思路可以減少額外的初始化。

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

compute ()就像是computeIfPresent(),但無論給定key的存在與否如何都會呼叫它。如果key的值不存在,則prev引數為null。將簡單移動if 到隱藏在lambda中的三元表示式也遠遠沒有達到最佳的表現。在我向你展示最終版本之前,讓我們看一下稍微簡化的預設實現Map.merge()原始碼分析。

改進V3

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() 適用於兩種情況。如果給定的key不存在,它就變成了put(key, value)。但是,如果key已經存在一些值,我們 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)
);
複製程式碼

你可以按照如下思路理解:如果沒有key,那麼初始化的value等於1;否則,將1新增到現有值。程式碼中的 one 是一個常量,因為我們的場景中,預設一直是加1,具體變化可以隨意切換。

場景

想象一下,merge()真的那麼好用嗎?它的場景可以有什麼?

舉一個例子。你有一個帳戶操作類

class Operation {
    private final String accNo;
    private final BigDecimal amount;
}
複製程式碼

以及針對不同帳戶的一系列操作:


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(),就變得非常麻煩了:


Map 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

當我們再延伸到ConcurrentHashMap來,當 Map.merge的出現,和ConcurrentHashMap的結合那是非常的完美的。這樣的搭配場景是對於那些自動執行插入或者更新操作的單執行緒安全的邏輯。

關注油膩的Java