歡迎光臨
每天分享高質量文章

Java 函式式程式設計和 lambda 運算式

點選上方“芋道原始碼”,選擇“置頂公眾號”

技術文章第一時間送達!

原始碼精品專欄

 

來源:http://t.cn/EhV6LNh

為什麼要使用函式式程式設計

函式式程式設計更多時候是一種程式設計的思維方式,是種方法論。函式式與指令式程式設計的區別主要在於:函式式程式設計是告訴程式碼你要做什麼,而指令式程式設計則是告訴程式碼要怎麼做。說白了,函式式程式設計是基於某種語法或呼叫API去進行程式設計。例如,我們現在需要從一組數字中,找出最小的那個數字,若使用用指令式程式設計實現這個需求的話,那麼所編寫的程式碼如下:

public static void main(String[] args) {
    int[] nums = new int[]{12345678};

    int min = Integer.MAX_VALUE;
    for (int num : nums) {
        if (num             min = num;
        }
    }
    System.out.println(min);
}

而使用函式式程式設計進行實現的話,所編寫的程式碼如下:

public static void main(String[] args{
    int[] nums = new int[]{12345678};

    int min = IntStream.of(nums).min().getAsInt();
    System.out.println(min);
}

從以上的兩個例子中,可以看出,指令式程式設計需要自己去實現具體的邏輯細節。而函式式程式設計則是呼叫API完成需求的實現,將原本命令式的程式碼寫成一系列巢狀的函式呼叫,在函式式程式設計下顯得程式碼更簡潔、易懂,這就是為什麼要使用函式式程式設計的原因之一。所以才說函式式程式設計是告訴程式碼你要做什麼,而指令式程式設計則是告訴程式碼要怎麼做,是一種思維的轉變。

說到函式式程式設計就不得不提一下lambda運算式,它是函式式程式設計的基礎。在Java還不支援lambda運算式時,我們需要建立一個執行緒的話,需要編寫如下程式碼:

public static void main(String[] args{
    new Thread(new Runnable() {
        @Override
        public void run({
            System.out.println("running");
        }
    }).start();
}

而使用lambda運算式一句程式碼就能完成執行緒的建立,lambda強調了函式的輸入輸出,隱藏了過程的細節,並且可以接受函式當作輸入(引數)和輸出(傳回值):

public static void main(String[] args{
    new Thread(() -> System.out.println("running")).start();
}

註:箭頭的左邊是輸入,右邊則是輸出

該lambda運算式的作用其實就是傳回了Runnable介面的實現物件,這與我們呼叫某個方法獲取實體物件類似,只不過是將實現程式碼直接寫在了lambda運算式裡。我們可以做個簡單的對比:

public static void main(String[] args{
    Runnable runnable1 = () -> System.out.println("running");
    Runnable runnable2 = RunnableFactory.getInstance();
}

JDK8介面新特性

1.函式介面,介面只能有一個需要實現的方法,可以使用@FunctionalInterface 註解進行宣告。如下:

@FunctionalInterface
interface Interface1 {
    int doubleNum(int i);
}

使用lambda運算式獲取該介面的實現實體的幾種寫法:

public static void main(String[] args{
    // 最常見的寫法
    Interface1 i1 = (i) -> i * 2;
    Interface1 i2 = i -> i * 2;

    // 可以指定引數型別
    Interface1 i3 = (int i) -> i * 2;

    // 若有多行程式碼可以這麼寫
    Interface1 i4 = (int i) -> {
        System.out.println(i);
        return i * 2;
    };
}

2.比較重要的一個介面特性是介面的預設方法,用於提供預設實現。預設方法和普通實現類的方法一樣,可以使用this等關鍵字:

@FunctionalInterface
interface Interface1 {
    int doubleNum(int i);

    default int add(int x, int y) {
        return x + y;
    }
}

之所以說預設方法這個特性比較重要,是因為我們藉助這個特性可以在以前所編寫的一些介面上提供預設實現,並且不會影響任何的實現類以及既有的程式碼。例如我們最熟悉的List介面,在JDK1.2以來List介面就沒有改動過任何程式碼,到了1.8之後才使用這個新特性增加了一些預設實現。這是因為如果沒有預設方法的特性的話,修改介面程式碼帶來的影響是巨大的,而有了預設方法後,增加預設實現可以不影響任何的程式碼。

3.當介面多重繼承時,可能會發生預設方法改寫的問題,這時可以去指定使用哪一個介面的預設方法實現,如下示例:

@FunctionalInterface
interface Interface1 {
    int doubleNum(int i);

    default int add(int x, int y) {
        return x + y;
    }
}

@FunctionalInterface
interface Interface2 {
    int doubleNum(int i);

    default int add(int x, int y) {
        return x + y;
    }
}

@FunctionalInterface
interface Interface3 extends Interface1Interface2 {

    @Override
    default int add(int x, int y) {
        // 指定使用哪一個介面的預設方法實現
        return Interface1.super.add(x, y);
    }
}

函式介面

我們本小節來看看JDK8裡自帶了哪些重要的函式介面:

Java函式式程式設計和lambda運算式

可以看到上表中有好幾個介面,而其中最常用的是Function介面,它能為我們省去定義一些不必要的函式介面,減少介面的數量。我們使用一個簡單的例子演示一下 Function 介面的使用:

import java.text.DecimalFormat;
import java.util.function.Function;

class MyMoney {
    private final int money;

    public MyMoney(int money) {
        this.money = money;
    }

    public void printMoney(Function moneyFormat) {
        System.out.println("我的存款: " + moneyFormat.apply(this.money));
    }
}

public class MoneyDemo {
    public static void main(String[] args) {
        MyMoney me = new MyMoney(99999999);

        Function moneyFormat = i -> new DecimalFormat("#,###").format(i);
        // 函式介面支援鏈式操作,例如增加一個字串
        me.printMoney(moneyFormat.andThen(s -> "人民幣 " + s));
    }
}

執行以上例子,控制檯輸出如下:

我的存款: 人民幣 99,999,999

若在這個例子中不使用Function介面的話,則需要自行定義一個函式介面,並且不支援鏈式操作,如下示例:

import java.text.DecimalFormat;

// 自定義一個函式介面
@FunctionalInterface
interface IMoneyFormat {
    String format(int i);
}

class MyMoney {
    private final int money;

    public MyMoney(int money) {
        this.money = money;
    }

    public void printMoney(IMoneyFormat moneyFormat) {
        System.out.println("我的存款: " + moneyFormat.format(this.money));
    }
}

public class MoneyDemo {
    public static void main(String[] args) {
        MyMoney me = new MyMoney(99999999);

        IMoneyFormat moneyFormat = i -> new DecimalFormat("#,###").format(i);
        me.printMoney(moneyFormat);
    }
}

然後我們再來看看Predicate介面和Consumer介面的使用,如下示例:

public static void main(String[] args) {
    // 斷言函式介面
    Predicate predicate = i -> i > 0;
    System.out.println(predicate.test(-9));

    // 消費函式介面
    Consumer consumer = System.out::println;
    consumer.accept("這是輸入的資料");
}

執行以上例子,控制檯輸出如下:

false
這是輸入的資料

這些介面一般有對基本型別的封裝,使用特定型別的介面就不需要去指定泛型了,如下示例:

public static void main(String[] args{
    // 斷言函式介面
    IntPredicate intPredicate = i -> i > 0;
    System.out.println(intPredicate.test(-9));

    // 消費函式介面
    IntConsumer intConsumer = (value) -> System.out.println("輸入的資料是:" + value);
    intConsumer.accept(123);
}

執行以上程式碼,控制檯輸出如下:

false
輸入的資料是:123

有了以上介面示例的鋪墊,我們應該對函式介面的使用有了一個初步的瞭解,接下來我們演示剩下的函式介面使用方式:

public static void main(String[] args{
    // 提供資料介面
    Supplier supplier = () -> 10 + 1;
    System.out.println("提供的資料是:" + supplier.get());

    // 一元函式介面
    UnaryOperator unaryOperator = i -> i * 2;
    System.out.println("計算結果為:" + unaryOperator.apply(10));

    // 二元函式介面
    BinaryOperator binaryOperator = (a, b) -> a * b;
    System.out.println("計算結果為:" + binaryOperator.apply(1010));
}

執行以上程式碼,控制檯輸出如下:

提供的資料是:11
計算結果為:20
計算結果為:100

而BiFunction介面就是比Function介面多了一個輸入而已,如下示例:

class MyMoney {
    private final int money;
    private final String name;

    public MyMoney(int money, String name) {
        this.money = money;
        this.name = name;
    }

    public void printMoney(BiFunction moneyFormat) {
        System.out.println(moneyFormat.apply(this.money, this.name));
    }
}

public class MoneyDemo {
    public static void main(String[] args) {
        MyMoney me = new MyMoney(99999999"小明");

        BiFunction moneyFormat = (i, name) -> name + "的存款: " + new DecimalFormat("#,###").format(i);
        me.printMoney(moneyFormat);
    }
}

執行以上程式碼,控制檯輸出如下:

小明的存款: 99,999,999

方法取用

在學習了lambda運算式之後,我們通常會使用lambda運算式來建立匿名方法。但有的時候我們僅僅是需要呼叫一個已存在的方法。如下示例:

Arrays.sort(stringsArray, (s1, s2) -> s1.compareToIgnoreCase(s2));

在jdk8中,我們可以透過一個新特性來簡寫這段lambda運算式。如下示例:

Arrays.sort(stringsArrayString::compareToIgnoreCase);

這種特性就叫做方法取用(Method Reference)。方法取用的標準形式是:類名::方法名。(註意:只需要寫方法名,不需要寫括號)。

目前方法取用共有以下四種形式:

型別 示例 程式碼示例 對應的Lambda運算式
取用靜態方法 ContainingClass::staticMethodName String::valueOf (s) -> String.valueOf(s)
取用某個物件的實體方法 containingObject::instanceMethodName x::toString() () -> this.toString()
取用某個型別的任意物件的實體方法 ContainingType::methodName String::toString (s) -> s.toString
取用構造方法 ClassName::new String::new () -> new String()

下麵我們用一個簡單的例子來演示一下方法取用的幾種寫法。首先定義一個物體類:

public class Dog {
    private String name = "二哈";
    private int food = 10;

    public Dog({
    }

    public Dog(String name{
        this.name = name;
    }

    public static void bark(Dog dog{
        System.out.println(dog + "叫了");
    }

    public int eat(int num{
        System.out.println("吃了" + num + "斤");
        this.food -= num;
        return this.food;
    }

    @Override
    public String toString({
        return this.name;
    }
}

透過方法取用來呼叫該物體類中的方法,程式碼如下:

package org.zero01.example.demo;

import java.util.function.*;

/**
 * @ProjectName demo
 * @Author: zeroJun
 * @Date: 2018/9/21 13:09
 * @Description: 方法取用demo
 */

public class MethodRefrenceDemo {

    public static void main(String[] args) {
        // 方法取用,呼叫列印方法
        Consumer consumer = System.out::println;
        consumer.accept("接收的資料");

        // 靜態方法取用,透過類名即可呼叫
        Consumer consumer2 = Dog::bark;
        consumer2.accept(new Dog());

        // 實體方法取用,透過物件實體進行取用
        Dog dog = new Dog();
        IntUnaryOperator function = dog::eat;
        System.out.println("還剩下" + function.applyAsInt(2) + "斤");

        // 另一種透過實體方法取用的方式,之所以可以這麼乾是因為JDK預設會把當前實體傳入到非靜態方法,引數名為this,引數位置為第一個,所以我們在非靜態方法中才能訪問this,那麼就可以透過BiFunction傳入實體物件進行實體方法的取用
        Dog dog2 = new Dog();
        BiFunction biFunction = Dog::eat;
        System.out.println("還剩下" + biFunction.apply(dog2, 2) + "斤");

        // 無參建構式的方法取用,類似於靜態方法取用,只需要分析好輸入輸出即可
        Supplier supplier = Dog::new;
        System.out.println("建立了新物件:" + supplier.get());

        // 有參建構式的方法取用
        Function<StringDogfunction2 = Dog::new;
        System.out.println("建立了新物件:" + function2.apply("旺財"));
    }
}

型別推斷

透過以上的例子,我們知道之所以能夠使用Lambda運算式的依據是必須有相應的函式介面。這一點跟Java是強型別語言吻合,也就是說你並不能在程式碼的任何地方任性的寫Lambda運算式。實際上Lambda的型別就是對應函式介面的型別。Lambda運算式另一個依據是型別推斷機制,在背景關係資訊足夠的情況下,編譯器可以推斷出引數表的型別,而不需要顯式指名。

如果大家想學習以上路線內容,在此我向大家推薦一個架構學習交流群。交流學習群號874811168 裡面會分享一些資深架構師錄製的影片錄影:有Spring,MyBatis,Netty原始碼分析,高併發、高效能、分散式、微服務架構的原理,JVM效能最佳化、分散式架構等這些成為架構師必備的知識體系。還能領取免費的學習資源,目前受益良多

所以說 Lambda 運算式的型別是從 Lambda 的背景關係推斷出來的,背景關係中 Lambda 運算式需要的型別稱為標的型別,如下圖所示:

Java函式式程式設計和lambda運算式

接下來我們使用一個簡單的例子,演示一下 Lambda 運算式的幾種型別推斷,首先定義一個簡單的函式介面:

@FunctionalInterface
interface IMath {
    int add(int x, int y);
}

示例程式碼如下:

public class TypeDemo {

    public static void main(String[] args{
        // 1.透過變數型別定義
        IMath iMath = (x, y) -> x + y;

        // 2.陣列構建的方式
        IMath[] iMaths = {(x, y) -> x + y};

        // 3.強轉型別的方式
        Object object = (IMath) (x, y) -> x + y;

        // 4.透過方法傳回值確定型別
        IMath result = createIMathObj();

        // 5.透過方法引數確定型別
        test((x, y) -> x + y);

    }

    public static IMath createIMathObj({
        return (x, y) -> x + y;
    }

    public static void test(IMath iMath){
        return;
    }
}

變數取用

Lambda運算式類似於實現了指定介面的內部類或者說匿名類,所以在Lambda運算式中取用變數和我們在匿名類中取用變數的規則是一樣的。如下示例:

public static void main(String[] args{
    String str = "當前的系統時間戳是: ";
    Consumer consumer = s -> System.out.println(str + s);
    consumer.accept(System.currentTimeMillis());
}

值得一提的是,在JDK1.8之前我們一般會將匿名類裡訪問的外部變數設定為final,而在JDK1.8裡預設會將這個匿名類裡訪問的外部變數給設定為final。例如我現在改變str變數的值,ide就會提示錯誤:

Java函式式程式設計和lambda運算式

至於為什麼要將變數設定final,這是因為在Java裡沒有取用傳遞,變數都是值傳遞的。不將變數設定為final的話,如果外部變數的取用被改變了,那麼最終得出來的結果就會是錯誤的。

下麵用一組圖片簡單演示一下值傳遞與取用傳遞的區別。以串列為例,當只是值傳遞時,匿名類裡對外部變數的取用是一個值物件:

Java函式式程式設計和lambda運算式

若此時list變數指向了另一個物件,那麼匿名類裡取用的還是之前那個值物件,所以我們才需要將其設定為final防止外部變數取用改變:

Java函式式程式設計和lambda運算式

而如果是取用傳遞的話,匿名類裡對外部變數的取用就不是值物件了,而是指標指向這個外部變數:

Java函式式程式設計和lambda運算式

所以就算list變數指向了另一個物件,匿名類裡的取用也會隨著外部變數的取用改變而改變:

Java函式式程式設計和lambda運算式

級聯運算式和柯里化

在函式式程式設計中,函式既可以接收也可以傳回其他函式。函式不再像傳統的面向物件程式設計中一樣,只是一個物件的工廠或生成器,它也能夠建立和傳回另一個函式。傳回函式的函式可以變成級聯 lambda 運算式,特別值得註意的是程式碼非常簡短。儘管此語法初看起來可能非常陌生,但它有自己的用途。

級聯運算式就是多個lambda運算式的組合,這裡涉及到一個高階函式的概念,所謂高階函式就是一個可以傳回函式的函式,如下示例:

// 實現了 x + y 的級聯運算式
FunctionFunction> function1 = x -> y -> x + y;
System.out.println("計算結果為: " + function1.apply(2).apply(3));  // 計算結果為: 5

這裡的 y -> x + y 是作為一個函式傳回給上一級運算式,所以第一級運算式的輸出是 y -> x + y這個函式,如果使用括號括起來可能會好理解一些:

x -> (y -> x + y)

級聯運算式可以實現函式柯里化,簡單來說柯里化就是把本來多個引數的函式轉換為只有一個引數的函式,如下示例:

FunctionFunctionFunction>> function2 = x -> y -> z -> x + y + z;
System.out.println("計算結果為: " + function2.apply(1).apply(2).apply(3));  // 計算結果為: 6

如果大家想學習以上路線內容,在此我向大家推薦一個架構學習交流群。交流學習群號874811168 裡面會分享一些資深架構師錄製的影片錄影:有Spring,MyBatis,Netty原始碼分析,高併發、高效能、分散式、微服務架構的原理,JVM效能最佳化、分散式架構等這些成為架構師必備的知識體系。還能領取免費的學習資源,目前受益良多

函式柯里化的目的是將函式標準化,函式可靈活組合,方便統一處理等,例如我可以在迴圈裡只需要呼叫同一個方法,而不需要呼叫另外的方法就能實現一個陣列內元素的求和計算,程式碼如下:

public static void main(String[] args) {
    FunctionFunctionFunction>> f3 = x -> y -> z -> x + y + z;
    int[] nums = {123};
    for (int num : nums) {
        if (f3 instanceof Function) {
            Object obj = f3.apply(num);
            if (obj instanceof Function) {
                f3 = (Function) obj;
            } else {
                System.out.println("呼叫結束, 結果為: " + obj);  // 呼叫結束, 結果為: 6
            }
        }
    }
}

級聯運算式和柯里化一般在實際開發中並不是很常見,所以對其概念稍有理解即可,這裡只是簡單帶過,若對其感興趣的可以查閱相關資料。




如果你對 Dubbo / Netty 等等原始碼與原理感興趣,歡迎加入我的知識星球一起交流。長按下方二維碼噢

目前在知識星球更新了《Dubbo 原始碼解析》目錄如下:

01. 除錯環境搭建
02. 專案結構一覽
03. 配置 Configuration
04. 核心流程一覽

05. 拓展機制 SPI

06. 執行緒池

07. 服務暴露 Export

08. 服務取用 Refer

09. 註冊中心 Registry

10. 動態編譯 Compile

11. 動態代理 Proxy

12. 服務呼叫 Invoke

13. 呼叫特性 

14. 過濾器 Filter

15. NIO 伺服器

16. P2P 伺服器

17. HTTP 伺服器

18. 序列化 Serialization

19. 叢集容錯 Cluster

20. 優雅停機

21. 日誌適配

22. 狀態檢查

23. 監控中心 Monitor

24. 管理中心 Admin

25. 運維命令 QOS

26. 鏈路追蹤 Tracing

… 一共 69+ 篇

目前在知識星球更新了《Netty 原始碼解析》目錄如下:

01. 除錯環境搭建
02. NIO 基礎
03. Netty 簡介
04. 啟動 Bootstrap

05. 事件輪詢 EventLoop

06. 通道管道 ChannelPipeline

07. 通道 Channel

08. 位元組緩衝區 ByteBuf

09. 通道處理器 ChannelHandler

10. 編解碼 Codec

11. 工具類 Util

… 一共 61+ 篇

目前在知識星球更新了《資料庫物體設計》目錄如下:


01. 商品模組
02. 交易模組
03. 營銷模組
04. 公用模組

… 一共 17+ 篇

原始碼不易↓↓↓

點贊支援老艿艿↓↓

贊(0)

分享創造快樂