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

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)

分享創造快樂