用好JAVA中的函數式介面,輕鬆從通用程式碼框架中剝離掉業務客製化邏輯

2022-08-11 21:00:24

大家好,又見面了。

今天我們一起聊一聊JAVA中的函數式介面。那我們首先要知道啥是函數式介面、它和JAVA中普通的介面有啥區別?其實函數式介面也是一個Interface類,是一種比較特殊的介面類,這個介面類有且僅有一個抽象方法(但是可以有其餘的方法,比如default方法)。

當然,我們看原始碼的時候,會發現JDK中提供的函數式介面,都會攜帶一個 @FunctionalFunction註解,這個註釋是用於標記此介面類是一個函數式介面,但是這個註解並非是實現函數式介面的必須項。說白了,加了這個註解,一方面可以方便程式碼的理解,告知這個程式碼是按照函數式介面來定義實現的,另一方面也是供編譯器協助檢查,如果此方法不符合函數式介面的要求,直接編譯失敗,方便程式設計師介入處理。

所以歸納下來,一個函數式介面應該具備如下特性:

  • 是一個JAVA interface類
  • 有且僅有1個公共抽象方法
  • @FunctionalFunction標註(可選)

比如我們在多執行緒場景中都很熟悉的Runnable介面,就是個典型的函數式介面,符合上面說的2個特性:

@FunctionalInterface
public interface Runnable {
    /**
     * When an object implementing interface <code>Runnable</code> is used
     * to create a thread, starting the thread causes the object's
     * <code>run</code> method to be called in that separately executing
     * thread.
     * <p>
     * The general contract of the method <code>run</code> is that it may
     * take any action whatsoever.
     *
     * @see     java.lang.Thread#run()
     */
    public abstract void run();
}

但是,我們在看JDK原始碼的時候,也會看到有些函數式介面裡面有多個抽象方法。比如JDK中的 Comparator介面的定義如下:

@FunctionalInterface
public interface Comparator<T> {
    int compare(T o1, T o2);
    boolean equals(Object obj);
    // 其他方法省略...
}

可以看到,Comparator介面裡面提供了 compareequals兩個抽象方法。這是啥原因呢?回答這個問題前,我們可以先來做個試驗。

我們自己定義一個函數式介面,裡面提供兩個抽象方法測試一下,會發現IDEA中直接就提示編譯失敗了:

同樣是這個自定義的函數式介面,我們修改下里面的抽象方法名稱,改為 equals方法,會發現這樣就不報錯了:

在IDEA中可能更容易看出端倪來,在上面的圖中,注意到12行程式碼前面那個 @符號了嗎?我們換種寫法,改為如下的方式,原因就更加清晰了:

原來,這個 equals方法,其實是繼承自父類別的方法,因為所有的類最終都是繼承自Object類,所以 equals方法只能算是對父類別介面的一個覆寫,而不算是此介面類自己的抽象方法,所以此方法裡面實際上還是隻有 1個抽象方法,並沒有違背函數式介面的約束條件。

函數式介面在JDK中的大放異彩

JDK原始碼 java.util.function包下面提供的一系列的預置的函數式介面定義:

部分使用場景比較多的函數式介面的功能描述歸納如下:

介面類 功能描述
Runnable 直接執行一段處理常式,無任何輸出引數,也沒有任何輸出結果。
Supplier<T> 執行一段處理常式,無任務輸入引數,返回一個T型別的結果。與Runnable的區別在於Supplier執行完之後有返回值。
Consumer<T> 執行一段處理常式,支援傳入一個T型別的引數,執行完沒有任何返回值。
BiConsumer<T, U> 與Consumer型別相似,區別點在於BiConsumer支援傳入兩個不同型別的引數,執行完成之後依舊沒有任何返回值。
Function<T, R> 執行一段處理常式,支援傳入一個T型別的引數,執行完成之後,返回一個R型別的結果。與Consumer的區別點就在於Function執行完成之後有輸出值。
BiFunction<T, U, R> 與Function相似,區別點在於BiFunction可以傳入兩個不同型別的引數,執行之後可以返回一個結果。與BiConsumer也很類似,區別點在於BiFunction可以有返回值。
UnaryOperator<T> 傳入一個引數物件T,允許對此引數進行處理,處理完成後返回同樣型別的結果物件T。繼承Function介面實現,輸入輸出物件的型別相同。
BinaryOperator<T> 允許傳入2個相同型別的引數,可以對引數進行處理,最後返回一個仍是相同型別的結果T。繼承BiFunction介面實現,兩個輸入引數以及最終輸出結果的物件型別都相同。
Predicate<T> 支援傳入一個T型別的引數,執行一段處理常式,最後返回一個布林型別的結果。
BiPredicate<T, U> 支援傳入2個相同型別T的引數,執行一段處理常式,最後返回一個布林型別的結果。

JDK中 java.util.function 包內預置了這麼多的函數式介面,很多場景下其實都是給JDK中其它的類或者方法中使用的,最典型的就是Stream了——可以說有一大半預置的函數式介面類,都是為適配Stream相關能力而提供的。也正是基於函數式介面的配合使用,才是使得Stream的靈活性與擴充套件性尤其的突出。

下面我們一起來看幾個Stream的方法實現原始碼,來感受下函數式介面使用的魅力。

比如,Stream中的 filter過濾操作,其實就是傳入一個元素物件,然後經過一系列的處理與判斷邏輯,最後需要給定一個boolean的結果,告知filter操作是應該保留還是丟棄此元素,所以filter方法傳入的引數就是一個 Predicate函數式介面的具體實現(因為Predicate介面的特點就是傳入一個T物件,輸出一個boolean結果):

/**
* Returns a stream consisting of the elements of this stream that match
* the given predicate.
*/
    Stream<T> filter(Predicate<? super T> predicate);

又比如,Stream中的 map操作,是通過遍歷的方式,將元素逐個傳入函數中進行處理,並支援輸出為一個新的型別物件結果,所以map方法要求傳入一個 Function函數式介面的具體實現:

/**
 * Returns a stream consisting of the results of applying the given
 * function to the elements of this stream.
 */
<R> Stream<R> map(Function<? super T, ? extends R> mapper);

再比如,Stream中的終止操作 forEach方法,其實就是通過迭代的方式去對元素進行逐個處理,最終其並沒有任何返回值生成,所以forEach方法定義的時候,要求傳入的是一個 Consumer函數式介面的具體實現:

/**
 * Performs an action for each element of this stream.
 */
void forEach(Consumer<? super T> action);

具體使用的時候,每個方法中都需要傳入具體函數式介面的實現邏輯,這個時候結合Lambda表示式,可以讓程式碼更加的簡潔幹練(不熟悉的話,也可能會覺得更加晦澀難懂~),比如:

public void testStreamUsage(@NotNull String sentence) {
    Arrays.stream(sentence.split(" "))
            .filter(word -> word.length() > 5)
            .sorted((o1, o2) -> o2.length() - o1.length())
            .forEach(System.out::println);
}

利用函數式介面提升框架靈活度

前面章節中我們提到,JDK中有預置提供了很多的函數式介面,比如SupplierConsumerPredicate等,可又分別應用於不同場景的使用。當然咯,根據業務的實際需要,我們也可以去自定義需要的函數式介面,來方便我們自己的使用。

舉個例子,有這麼一個業務場景:

一個運維資源申請平臺,需要根據資源規格不同計算各自資源的價格,最終彙總價格、並計算稅額、含稅總金額。
比如:

  1. 不同CPU核數、不同記憶體、不同磁碟大小的虛擬機器器,價格也是不一樣的
  2. 1M、2M、4M等不同規格的網路頻寬的費用也是不一樣的

在寫程式碼前,我們先分析下這個處理邏輯,並分析分類出其中的通用邏輯與客製化可變邏輯,如下所示:

因為我們要做的是一個通用框架邏輯,且申請的資源型別很多,所以我們顯然不可能直接在平臺框架程式碼裡面通過if else的方式來判斷型別並在框架邏輯裡面去寫每個不同資源的計算邏輯。

那按照常規的思路,我們要將客製化邏輯從公共邏輯中剝離,會定義一個介面型別,要求不同資源實體類都繼承此介面類,實現介面類中的calculatePirce方法,這樣在平臺通用計算邏輯的時候,就可以通過泛型介面呼叫的方式來實現我們的目的:

public PriceInfo  calculatePriceInfo(List<IResource> resources) {
    // 計算總價
    double price = resources.stream().collect(Collectors.summarizingDouble(IResource::calculatePrice)).getSum();
    // 執行後續處理策略
    PriceInfo priceInfo = new PriceInfo();
    priceInfo.setPrice(price);
    priceInfo.setTaxRate(0.15);
    priceInfo.setTax(price * 0.15);
    priceInfo.setTotalPay(priceInfo.getPrice() + priceInfo.getTax());
    return priceInfo;
}

考慮到我們構建的平臺程式碼的靈活性與可延伸性,能不能我們不要求所有資源都去實現指定介面類,也能將客製化邏輯從平臺邏輯中剝離呢?這裡,就可以藉助自定義函數式介面來實現啦。

再來回顧下函數式介面的要素是什麼:

  1. 一個普通的JAVA interface類
  2. 此Interface類中有且僅有1個public型別的介面方法;
  3. (可選)新增個 @FunctionalInterface註解標識。

所以,滿足上述3點的一個自定義函數式介面,我們可以很easy的就寫出來:

@FunctionalInterface
public interface PriceComputer<T> {
    double computePrice(List<T> objects);
}

然後我們在實現計算總價格的實現方法中,就可以將PriceComputer函數介面類作為一個引數傳入,並直接呼叫函數式介面方法,獲取到計算後的price資訊,然後進行一些後續的處理邏輯:

public <T> PriceInfo  calculatePriceInfo(List<T> resources, PriceComputer<T> priceComputer) {
    // 呼叫函數式介面獲取計算結果
    double price = priceComputer.computePrice(resources);
    // 執行後續處理策略
    PriceInfo priceInfo = new PriceInfo();
    priceInfo.setPrice(price);
    priceInfo.setTaxRate(0.15);
    priceInfo.setTax(price * 0.15);
    priceInfo.setTotalPay(priceInfo.getPrice() + priceInfo.getTax());
    return priceInfo;
}

具體呼叫的時候,對於不同資源的計算,具體各個資源單獨計費的邏輯可以自行傳入,無需耦合到上述的基礎方法裡面。例如需要計算一批不同規格的虛擬機器器的總價時,可以這樣:

// 計算虛擬機器器總金額
functionCodeTest.calculatePriceInfo(vmDetailList, objects -> {
    double result = 0d;
    for (VmDetail vmDetail : objects) {
        result += 100 * vmDetail.getCpuCores() + 10 * vmDetail.getDiskSizeG() + 50 * vmDetail.getMemSizeG();
    }
    return result;
});

同樣地,如果想要計算一批頻寬資源的費用資訊,我們可以這麼來實現:


// 計算磁碟總金額
functionCodeTest.calculatePriceInfo(networkDetailList, objects -> {
    double result = 0d;
    for (NetworkDetail networkDetail : objects) {
        result += 20 * networkDetail.getBandWidthM();
    }
    return result;
});

單看呼叫的邏輯,也許你會有個疑問,這也沒看出程式碼會有啥特別的優化改進啊,跟我直接封裝兩個私有方法似乎也沒啥差別?甚至還更復雜了?但是看calculatePriceInfo方法會發現其作為基礎框架的能力更加通用了,將可變部分的邏輯抽象出去由業務呼叫方自行傳入,而無需耦合到框架裡面了(很像回撥介面的感覺)。

函數式介面與Lambda的完美搭配

Lambda語法是JAVA8開始引入的一種全新的語法糖,可以進一步的簡化編碼的邏輯。在函數式介面的具體使用場景,如果結合Lambda表示式,可以使得編碼更加的簡潔、不拖沓。

我們都知道,在JAVA中的介面類是不能直接使用的,必須要有對應的實現類,然後使用具體的實現類。而有些時候如果沒有必要建立一個獨立的類時,則需要建立內部類或者匿名實現類來使用:

public void testNonLambdaUsage() {
    new Thread() {
        @Override
        public void run() {
            System.out.println("new thread executing...");
        }
    }.start();
}

這裡使用了匿名類的方式,先實現一個Runnable函數式介面的具體實現類,然後執行此實現類的 start()方法。而使用Lambda語法來實現,整個程式碼就會顯得很清晰了:

public void testLambdaUsage() {
    new Thread(() -> System.out.println("new thread executing...")).start();
}

所以說,Lambda不是使用函數語言程式設計的必需品,但是隻有結合Lambda使用,才能將函數式介面優勢發揮出來、才能將函數語言程式設計的思想詮釋出來。

程式設計正規化的演進思考

前面的章節中呢,我們一起探討了下函數式介面的一些內容,而函數式介面也是函數語言程式設計中的一部分。這裡說的函數語言程式設計,其實是常見程式設計正規化中的一種,也就是一種程式設計的思維方式或者實現方式。主流程式設計正規化有指令式程式設計與宣告式程式設計,而函數語言程式設計也即是宣告式程式設計思想的具體實踐。

那麼,該如何理解指令式程式設計與宣告式程式設計呢?先看個例子。

假如週末的中午,我突然想吃雞翅了,然後我自己動手,一番忙活之後,終於吃上雞翅了(不容易啊)!

為了實現「吃雞翅」這個目的,然後是具體的一步一步的去做對應的事情,最終實現了目的,吃上了雞翅。——這就是 指令式程式設計

中午吃完烤雞翅,我晚上還想再吃烤雞腿,但我不想像中午那樣去忙活了,於是我:

照樣如願的吃上雞腿了(比中午容易多了)。這裡的我,只需要宣告要吃雞腿就行了,至於這個雞腿是怎麼做出來的,完全不用關心。——這就是 宣告式程式設計

從上面的例子中,可以看出兩種不同程式設計風格的區別:

  1. 指令式程式設計的主要思想是關注計算機執行的步驟,即一步一步告訴計算機先做什麼再做什麼。各種主流程式語言如C、C++、JAVA等都可以遵循這種方式去寫程式碼。
  2. 宣告式程式設計的主要思想是告訴計算機應該做什麼,但不指定具體要怎麼做。典型的宣告式程式語言,比如:SQL語言、正規表示式等。

回到程式碼中,現在有個需求:

從給定的一個數位列表collection裡面,找到所有大於5的元素,用指令式程式設計的風格來實現,程式碼如下:

List<Integer> results = new ArrayList<>();
for (int num : collection) {
    if (num > 5) {
        results.add(num);
    }
}

而使用宣告式程式設計的時候,程式碼如下:

List<Integer> results = 
    collection.stream().filter(num -> num > 5).collect(Collectors.toList());

宣告式程式設計的優勢,在於其更關注於「要什麼」、而會忽略掉具體怎麼做。這樣整個程式碼閱讀起來會更加的接近於具體實際的訴求,比如我只需要告訴 filter要按照 num > 5這個條件來過濾,至於這個filter具體是怎麼去過濾的,無需關心。

總結

好啦,關於函數式介面相關的內容,就介紹到這裡啦。那麼看到這裡,相信您應該有所收穫吧?那麼你對函數語言程式設計如何看呢?評論區一起討論下吧、我會認真對待並探討每一個評論~~

此外

我是悟道,聊技術、又不僅僅聊技術~

如果覺得有用,請點贊 + 關注讓我感受到您的支援。也可以關注下我的公眾號【架構悟道】,獲取更及時的更新。

期待與你一起探討,一起成長為更好的自己。