Java中的函數式編程(五)Java集合框架中的高階函數

寫在前面

隨著Java 8引入了函數式介面和lambda表達式,Java 8中的集合框架(Java Collections Framework, JCF)也增加相應的介面以適應函數式編程。
 
本文的目標是帶領大家熟悉Java 8中集合框架新增的常用介面,讓我們的程式碼更簡潔、更高級。
 
本文的示例程式碼可從gitee上獲取://gitee.com/cnmemset/javafp
 
 

Java 8中的集合框架

首先,和大家從整體上了解Java集合框架。
 
Java集合框架,主要是由兩個根介面派生出來的:一個是 Collecton介面,用於存放單個元素;另一個是 Map 介面,主要用於存放鍵值對。
 
Java集合框架的介面繼承結構如下圖:
 

 

 
Java 8中,主要是Collection、List和Map三個介面新增了一些方法,部分常用的新增函數如下表所示:
 
介面名
Java8新加入的方法
Collection
forEach(), removeIf(), stream(), parallelStream()
List
replaceAll(), sort()
Map
forEach(), replaceAll(), compute(), computeIfAbsent(), computeIfPresent(), merge()
 
上述這些函數,它們都包含一個函數式介面類型的參數。這類函數有一個專門的術語:高階函數(high order function)。
 
高階函數(定義):
一個函數,如果它有一個或多個參數是函數類型的,或者它的返回值是函數類型的,那麼我們稱這個函數為高階函數。
 
題外話:
可以發現,這些新增的方法基本都有默認實現(default implementation),這大大減少了具體子類的負擔。介面中的默認方法是Java 8的新特性,一個重要的作用就是「介面演化(interface evolution)」。舉例說明,假設在Java 8之前,我們有一個類實現了Collection介面:
public class Container implements Collection { ... }

升級到Java 8後,因為Collection中新增了方法 forEach,假設沒有默認方法,之前的類 Container 將編譯失敗。

Collection中的高階函數

1. forEach

嚴格來說,forEach方法是定義在介面 Iterable 中的,它的方法簽名和默認實現是:
default void forEach(Consumer<? super T> action) {
    Objects.requireNonNull(action);
    for (T t : this) {
        action.accept(t);
    }
}

它的作用是遍歷集合中的每一個元素,然後依次對每個元素執行參數 action 指定的動作。參數 action 是一個 Consumer 類型的函數式介面,我們可以通過lambda表達式或方法引用來實現一個 Consumer 。因此,在Java 8或以後的版本中,不要用 for 語句來迭代 Collection 了,不妨使用 forEach 方法。

示例程式碼如下:

public static void collectionForEach() {
    Collection<String> list = Arrays.asList("Guangdong", "Zhejiang", "Jiangsu");
 
    // for 語句
    for (String s : list) {
        System.out.println(s);
    }
 
    // forEach 方法 + lambda表達式
    list.forEach(s -> System.out.println(s));
 
    // forEach 方法 + 方法引用
    list.forEach(System.out::println);
}

 

2. removeIf

removeIf的方法簽名為:
default boolean removeIf(Predicate<? super E> filter) { ... }

removeIf的作用是遍歷集合中的每一個元素,然後依次對每個元素進行指定的過濾操作。filter 參數是一個 Predicate 類型的函數式介面。

示例程式碼如下,假定我們要過濾掉以字母 G 開頭的省份:

public static void collectionRemoveIf() {
    List<String> provinces = new ArrayList<>(Arrays.asList("Guangdong", "Jiangsu", "Guangxi", "Jiangxi", "Shandong"));
 
    boolean removed = provinces.removeIf(s -> {
        return s.startsWith("G");
    });
 
    System.out.println(removed);
    System.out.println(provinces);
}

上述程式碼輸出為:

true
[Jiangsu, Jiangxi, Shandong]
 

3. replaceAll

replaceAll的方法簽名是:
default void replaceAll(UnaryOperator<E> operator) { ... }

replaceAll方法的作用是對集合中的每個元素執行 operator 指定的計算,並用計算結果替換原來的元素。參數 operator 是類型為 UnaryOperator 的函數式介面,它的參數和返回值類型是相同的。

示例程式碼如下:將所有的省份的拼音轉換為大寫字母
public static void listReplaceAll() {
    List<String> provinces = Arrays.asList("Guangdong", "Jiangsu", "Guangxi", "Jiangxi", "Shandong");
    provinces.replaceAll(s -> s.toUpperCase());
    System.out.println(provinces);
}

上述程式碼的輸出為:

[GUANGDONG, JIANGSU, GUANGXI, JIANGXI, SHANDONG]
 

4. sort

sort的方法簽名如下:
default void sort(Comparator<? super E> c) { ... }

sort方法是根據比較器 c 指定的排序規則,對 List 中的元素進行排序。參數 c 的類型是Comparator,同樣是一個函數式介面。

示例程式碼:
public static void listSort() {
    List<String> list = Arrays.asList("Guangdong", "Zhejiang", "Jiangsu", "Xizang", "Fujian", "Hunan", "Guangxi");
 
    // 對省份進行排序,首先按照長度排序,如果長度一樣,則按照字母順序排序
    list.sort((first, second) -> {
        int lenDiff = first.length() - second.length();
        return lenDiff == 0 ? first.compareTo(second) : lenDiff;
    });
 
    list.forEach(s -> System.out.println(s));
}

上述程式碼的輸出為:

Hunan
Fujian
Xizang
Guangxi
Jiangsu
Zhejiang
Guangdong
 

5. stream 和 parallelStream

Stream是Java中函數式編程的重要組成部分,我們會在隨後的文章中加以詳述。
 
 

Map中的高階函數

1. forEach

和Collection類似,Map中也有forEach方法,它的方法簽名和默認實現如下:
default void forEach(BiConsumer<? super K, ? super V> action) { ... }

可以看到,Map的forEach方法的作用是遍歷Map中所有的鍵值對,並執行參數 action 指定的操作。參數 action 的類型是函數式介面 BiConsumer,要求有2個參數,分別代表鍵值對的key和value。

示例程式碼:
public static void mapForEach() {
    Map<String, String> cityMap = new HashMap<>();
    cityMap.put("Guangdong", "Guangzhou");
    cityMap.put("Zhejiang", "Hangzhou");
    cityMap.put("Jiangsu", "Nanjing");
 
    cityMap.forEach((key, value) -> {
        System.out.println(String.format("%s 的省會是 %s", key, value));
    });
}

上述程式碼的輸出為:

Guangdong 的省會是 Guangzhou
Zhejiang 的省會是 Hangzhou
Jiangsu 的省會是 Nanjing
 

2. replaceAll

Map的replaceAll的方法簽名和默認實現如下:
  
default void replaceAll(BiFunction<? super K, ? super V, ? extends V> function) { ... }

 

和Collection的replaceAll類似,Map的replaceAll方法對Map中的每個鍵值對執行 operator 指定的計算,並用計算結果替換原來的value值。注意到參數 function 是一個 BiFunction,意味著需要提供這樣的一個函數實現:它需要有2個參數,參數類型分別和鍵類型(K)和值類型(V)一一對應,並且它還需要返回一個類型為值類型(V)的返回值。
示例程式碼:
public static void mapReplaceAll() {
    Map<String, String> cityMap = new HashMap<>();
    cityMap.put("Guangdong", "Guangzhou");
    cityMap.put("Zhejiang", "Hangzhou");
    cityMap.put("Jiangsu", "Nanjing");

    // 將省府的拼音轉換為大寫
    cityMap.replaceAll((key, value) -> {
        return value.toUpperCase();
    });

    cityMap.forEach((key, value) -> {
        System.out.println(String.format("%s 的省會大寫是 %s", key, value));
    });
}

 

上述程式碼輸出為:
Guangdong 的省會大寫是 GUANGZHOU
Zhejiang 的省會大寫是 HANGZHOU
Jiangsu 的省會大寫是 NANJING
 

3. compute

compute的方法簽名:
default V compute(K key,
        BiFunction<? super K, ? super V, ? extends V> remappingFunction) { ... }

 

compute方法的作用是將參數 remappingFunction 的計算結果關聯到參數 key 上,但如果計算結果為null,則在Map中刪除key的映射。

示例程式碼如下:

public static void mapCompute() {
    Map<String, String> cityMap = new HashMap<>();
    cityMap.put("Guangdong", "null");
    cityMap.put("Zhejiang", "Hangzhou");
    cityMap.put("Jiangsu", "null");
 
    // 稍顯複雜的語句,先調用 forEach 遍歷 cityMap 中的鍵,然後根據原有的鍵值對計算新的值
    Set keys = new HashSet<>(cityMap.keySet());
    keys.forEach(key -> {
        cityMap.compute(key, (k, v) -> {
            // 如果是 Guangdong,則返回 Guangzhou
            if ("Guangdong".equals(k)) {
                return "Guangzhou";
            }
 
            // 如果舊的鍵值對中,值是字元串 "null" ,則返回 null。
            // 這意味著 cityMap 會刪除對應的key
            if ("null".equals(v)) {
                return null;
            }
 
            // 否則,返回原來的 value 值
            return v;
        });
    });
 
    cityMap.forEach((key, value) -> {
        System.out.println(String.format("%s 的省會是 %s", key, value));
    });
}

上述程式碼的輸出為:

Guangdong 的省會是 Guangzhou
Zhejiang 的省會是 Hangzhou
 
注意到,Jiangsu 已經從cityMap中被刪除了。
 

4. computeIfPresent

computeIfPresent的方法簽名和默認實現是:
default V computeIfPresent(K key,
        BiFunction<? super K, ? super V, ? extends V> remappingFunction) {
    Objects.requireNonNull(remappingFunction);
    V oldValue;
    if ((oldValue = get(key)) != null) {
        V newValue = remappingFunction.apply(key, oldValue);
        if (newValue != null) {
            put(key, newValue);
            return newValue;
        } else {
            remove(key);
            return null;
        }
    } else {
        return null;
    }
}

 

computeIfPresent 的方法簽名和compute一樣,作用也和compute類似,但與compute不同的是,只有在Map中存在key並且對應的value非空時,才會調用參數 remappingFunction 指定的計算函數(函數編程特性之惰性求值:只有觸發或滿足某種條件後,才會執行函數)。如果計算結果為null,則刪除key的映射,否則使用該結果替換key原來的映射。

 

5. computeIfAbsent

computeIfAbsent的方法簽名和默認實現是:
default V computeIfAbsent(K key,
        Function<? super K, ? extends V> mappingFunction) {
    Objects.requireNonNull(mappingFunction);
    V v;
    if ((v = get(key)) == null) {
        V newValue;
        if ((newValue = mappingFunction.apply(key)) != null) {
            put(key, newValue);
            return newValue;
        }
    }

    return v;
}

 

computeIfAbsent的作用和computeIfPresent 相反,只有在Map中不存在key或對應的value為null時,才調用參數 mappingFunction 指定的計算函數(函數編程特性之惰性求值:只有觸發或滿足某種條件後,才會執行函數),並且當計算結果非null時,才將計算結果跟key關聯。如果計算結果為null,則Map不做任何修改,不會增加新的映射關係。
computeIfAbsent總是會返回操作之後Map中key對應的value,這個value可能是之前已存在的值(如果之前存在的話),也有可能是計算出來的新值。
 
computeIfAbsent特別適合用來初始化Map。假設這麼一個實踐場景公司里的員工已經有一個「姓名->年齡」的Map了,我們需要根據它來構建一個「年齡->姓名列表」的新Map,此時,使用 computeIfAbsent 會使得程式碼簡潔而有效:
public static void mapComputeIfAbsent() {
    Map<String, Integer> staffMap = new HashMap<>();
    staffMap.put("Lilei", 24);
    staffMap.put("Hanmeimei", 22);
    staffMap.put("Liming", 24);
    staffMap.put("Jim", 22);
    staffMap.put("David", 24);

    Map<Integer, List<String>> staffInvertMap = new HashMap<>();
    staffMap.forEach((key, value) -> {
        // 以年齡為鍵,構建一個新的Map
        // 以 22 歲為例:
        // 如果 staffInvertMap 之前不存在 22 歲對應的映射關係,
        // 則新建一個 "年齡 -> ArrayList<String>" 的映射,並且把新建的 ArrayList 返回。
        // 如果 staffInvertMap 已經存在 22 歲對應的映射關係了,則將已存在的 ArrayList 返回。
        List<String> nameList = staffInvertMap.computeIfAbsent(value, age -> {
            // 對於同一個 age,這句程式碼只會執行一次
            return new ArrayList<>();
        });

        nameList.add(key);
    });

    System.out.println(staffInvertMap);
}

 

上述程式碼的輸出為:
{22=[Hanmeimei, Jim], 24=[Lilei, David, Liming]}
 
題外話:
作者特別喜歡computeIfPresent和computeIfAbsent這兩個函數,除了它們可以簡化我們的程式碼外,它倆還有一個特性:在ConcurrentHashMap 中,computeIfPresent和computeIfAbsent中的系列操作具備原子性。原子性的含義,歡迎添加公眾號,員說,一起討論。
 

6. merge

merge的方法簽名和默認實現為:
default V merge(K key, V value,
        BiFunction<? super V, ? super V, ? extends V> remappingFunction) {
    Objects.requireNonNull(remappingFunction);
    Objects.requireNonNull(value);
    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對應的舊值和新值:當Map中不存在key對應的映射或者映射值為null時,則將參數 value 關聯到 key 上;否則,將舊值 oldValue 和 參數 value 作為函數 remappingFunction 的兩個參數,計算得出一個新值,如果新值不為null,則將新值關聯到 key 上,如果新值為null,則刪除 key 對應的映射關係。
一個典型的場景是針對某個用戶進行備註,如果之前有過備註,則將新的備註資訊加到後面,示例程式碼如下:
public static void mapMerge() {
    Map<String, String> staffMap = new HashMap<>();
    staffMap.put("Lilei", "性別男 ");

    // 等價於 staffMap.merge("Lilei", "年齡 24", String::concat)
    // oldValue 即之前添加的值 「性別男 」,value 即merge函數的第二個參數 「年齡24」
    staffMap.merge("Lilei", " 年齡24", (oldValue, value) -> oldValue.concat(value));
    staffMap.merge("Hanmeimei", "年齡22", String::concat);

    System.out.println(staffMap);
}

 

上述程式碼的輸出為:
{Lilei=性別男 年齡24, Hanmeimei=年齡22}
 
 

結語

Java 8引入了函數式編程,也因此為Java中的集合框架帶來了許多新的功能,使得我們能更好的進行函數式編程。
 
作為一個優秀的Java程式設計師,要有意識的在實際編碼中運用這些新方法,使得我們的程式碼更簡潔更清晰。