天天看點

深入探讨 Lambda 表達式(上)序言

說明:

由于 Lambda 表達式涉及的周邊知識點實在太多,是以拆分為上、下兩篇文章講解,本篇為上篇,下篇随後放出。

目錄介紹:

深入探讨 Lambda 表達式(上)序言

上篇,主要講 1~4 章節,下篇,主要介紹 5~8 章節。

序言

JDK8 日漸成為項目開發中的主流。

但平時的開發中,可能很多小夥伴都還沒有适應 JDK8 的某些文法,還在沿用之前版本的習慣寫業務代碼,若是習慣 JDK8 的新特性,可能會讓我們的業務開發更高效。

而 Lambda 表達式,更是 JDK8 新特性中的重中之重,它能夠簡化我們的傳統操作模式。

本文會幫你詳細梳理 Lambda 表達式的前世今生,有原理講解,有示例實戰,有問題大家可以一起留言讨論,拉個釘釘群也可以~

文章略長,但一定是幹貨滿滿的,對于技術文章而言,短小精悍的特點并不是好事,我情願将文章寫的系統化一點,偏長也沒關系,注重幹貨,注重前因後果,做到知其然更要知其是以然。如果沒時間看,可收藏留以備用~

在具體描述 Lambda 表達式之前,我們需要補充一些基礎知識:什麼是函數式接口。

1. 函數式接口的定義

提到函數式接口( functional interface ),就牽扯到一個注解:

@FunctionInterface

所謂函數式接口,是指的一類添加了  

@FunctionInterface

注解的接口。換言之,隻要一個接口有

@FunctionInterface

注解,那這個接口就是函數式接口。

舉個例子就明白了。

當你在對任務 taskA 處理時,如果想異步處理,不影響主幹流程的繼續進行,你會怎麼做?

a) 初級版:新增一個類,實作 Runnable 接口

你會說很簡單呐,另起一個線程去執行任務 taskA 就可以了呀,喏,如下:

/**
 * @author: sss
 */
public class TaskAThread implements Runnable {
    @Override
    public void run() {
        // process taskA
        ...
    }
}

public class Main {
    public static void main(String[] args) {
        // new 一個新線程,執行任務A
        Runnable taskA = new TaskAThread();
        new Thread(taskA).start();

        // 主線程繼續做其他事情
        System.out.println("do other things...");
    }
}
           

這種方式是可以實作,但有沒有其他方式呢?

b) 進階版:使用匿名内部類

有些小夥伴明顯的發現了上面代碼中的問題:繁瑣!!隻是為了建立一個線程并使用它的

run()

方法,還要新增一個類,沒有必要,直接使用匿名類就解決啦:

public class Main {
    public static void main(String[] args) {
        // 通過匿名類來建立一個新線程,執行任務A
        Runnable taskA = new Runnable() {
            @Override
            public void run() {
                // process taskA
                ...
            }
        };
        new Thread(taskA).start();

        // 主線程繼續做其他事情
        System.out.println("do other things...");
    }
}
           

通過匿名類的方式,省去了新增一個類的操作,大大簡化。但若使用 Lambda 的方式,會更加簡潔。

c) 進階版:使用 Lambda 表達式

public static void main(String[] args) {
    // 通過匿名類來建立一個新線程,執行任務A
    new Thread(() -> {
        System.out.println("正在異步處理 taskA 中...");
        // do things
        ...
    }).start();

    // 主線程繼續做其他事情
    System.out.println("do other things...");
}
           

有沒有發現很神奇,類似

() -> {...}

的這種箭頭式寫法竟然能通過編譯!而且還能運作(不信的小夥伴可以試試)!這種就是 Lambda 表達式的其中一種寫法,不了解的小夥伴也沒關系,我們後面會詳細解釋。

也許這種 Lambda 寫法很多小夥伴見過,并習以為常,但為什麼可以運作,你知道根本原因嗎?

這裡就展現出函數式接口的作用了。我們去看一下 JDK7 和 JDK8 中關于  

Runnable

接口的定義,如下。大家有發現什麼不同點了嗎?

深入探讨 Lambda 表達式(上)序言

眼尖的小夥伴一定發現了,JDK8 中多了個注解  

@FunctionalInterface

。這就是為何能在 JDK8 中可以使用這種箭頭式的 Lambda 寫法。

本小節最開始時我們也提到了此注解。從上圖也能看出,

@FunctionalInterface

  是 JDK8 中新引入的一個注解,它定義了一類新的接口(即函數式接口),該類接口有且隻能有一個抽象方法。

它主要用于編譯期的錯誤檢查,如果一個接口不包含抽象方法(eg:

Serializable、Cloneable

等标記接口),或者包含多個抽象方法,都不符合  

@FunctionalInterface

  注解的定義,加了就會出錯,如下這種:

// 錯誤示例 1
@FunctionalInterface
interface InvalidInterfaceA {
}

// 錯誤示例 2
@FunctionalInterface
interface InvalidInterfaceB {
    void testA();
    void testB();
}
           

正确示範:

@FunctionalInterface
interface InvalidInterfaceC {
    void testC();
}

@FunctionalInterface
interface InvalidInterfaceD {
    void testD();
    default void testE() {
        System.out.println("this is a default method.");
    }
}
           

@FunctionalInterface

修飾的接口,隻能有一個抽象方法,但并代表隻能有一個方法聲明,像上面的  

InvalidInterfaceD

接口,還有

default

關鍵字修飾的

testE()

方法,但這是一個有預設實作的方法,并不是抽象方法,是以接口  

InvalidInterfaceD

  依然符合函數式接口的定義。

另外,我們仔細看下注解的描述片段:

深入探讨 Lambda 表達式(上)序言

上面截圖中的資訊量較大,分為兩塊内容。

第一塊内容是使用

@FunctionalInterface

注解需滿足的 2 個條件:

  • 必須是接口,不能是注解、枚舉或類,限定了使用的類型範圍
  • 被注解的接口,必須滿足函數式接口的定義,即隻能有一個抽象函數

第二塊内容是

@FunctionalInterface

注解的功能已内置于編譯器的處理邏輯中:不管一個接口是否添加了

@FunctionalInterface

注解,隻要該接口滿足函數式接口的定義,編譯器都會把它當做函數式接口。

看下面的例子:

interface MathOperation {
    int operation(int a, int b);
}

public static void main(String args[]) {
    MathOperation addition = (int a, int b) -> a + b;
}
           

上面的  

MathOperation

接口,并沒有添加  

@FunctionalInterface

注解,但依然可以使用 Lambda 表達式,就是因為它符合函數式接口的定義,JDK8 的編譯器預設将其當做函數式接口(上面代碼中的箭頭表達式不懂沒關系,我們下面會詳細講解)。

在 JDK8 中,推出了一個新的包:

java.util.function

,它裡面内置了一些我們常用的函數式接口,如

Predicate

Supplier

Consumer

等接口。

2. 什麼是 Lambda 表達式

總結了很久,發現還是很難用語言來定義什麼是 Lambda 表達式,它更适合結合示例來說明。

2.1 示例 1

還是以上面的異步線程執行任務 A 為例。在 Lambda 表達式之前,我們最精簡的寫法就是使用匿名類,但若用 Labmda 表達式,則可直接簡化成一行代碼。看下面代碼示例的對比:

public static void main(String[] args) {
    // 使用匿名内部類
    new Thread(new Runnable() {
        @Override
        public void run() {
            System.out.println("正在異步處理 taskA 中...");
        }
    }).start();

    // 使用 Lambda 表達式
    new Thread(() -> System.out.println("正在異步處理 taskA 中...")).start();
}
           

上面的示例中,使用 Lambda 表達式,進一步簡化了匿名類,這也是 Lambda 表達式最常用的功能。

2.2 示例 2

為進一步強化大家對 Lambda 表達式的了解,再舉一個最常用的示例,集合類的周遊操作。在 JDK8 以前,List 的周遊操作,要麼用 for 循環,要麼用疊代器(Iterator):

public static void main(String[] args) {
    List<String> strList = Arrays.asList("a", "b", "c", "d");
    // 方式1
    for (int i = 0; i < strList.size(); i++) {
        System.out.println(strList.get(i));
    }
    // 方式2,文法糖,本質還是下面的方式3
    for (String str : strList) {
        System.out.println(str);
    }
    // 方法3
    Iterator<String> iterator = strList.iterator();
    while (iterator.hasNext()) {
        System.out.println(iterator.next());
    }
}
           

上面的代碼中,方式 2 是一種文法糖,本質上還是方法 3,大家可通過編譯之後的

.class

檔案來檢視。 但在 JDK8 中,我們可使用

forEach()

方式來實作 Lambda 表達式下的周遊操作。

strList.forEach(str -> System.out.println(str));
           

進一步探究,

forEach()

是怎麼做到的,看下其源碼:

default void forEach(Consumer<? super T> action) {
    Objects.requireNonNull(action);
    for (T t : this) {
        action.accept(t);
    }
}

/**
 * Represents an operation that accepts a single input argument and returns no
 * result. Unlike most other functional interfaces, {@code Consumer} is expected
 * to operate via side-effects.
 *
 * <p>This is a <a href="package-summary.html">functional interface</a>
 * whose functional method is {@link #accept(Object)}.
 *
 * @param <T> the type of the input to the operation
 *
 * @since 1.8
 */
@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);

    default Consumer<T> andThen(Consumer<? super T> after) {
        Objects.requireNonNull(after);
        return (T t) -> { accept(t); after.accept(t); };
    }
}
           

forEach()

  的形參是一個

Consumer

對象,而  

Comsumer

  接口又是一個有

@FunctionalInterface

注解的函數式接口,其抽象方法是  

accept(T t)

此時,如果我們撇開 Lambda 表達式,使用匿名類,依然可以做到,如下:

strList.forEach(new Consumer<String>() {
    @Override
    public void accept(String str) {
        System.out.println(str);
    }
});
           

既然  

Consumer

  是一個函數式接口,就可以使用更簡潔的 Lambda 表達式:

strList.forEach(str -> System.out.println(str));
           

2.3 小結

有了前面兩個示例,你應該對 Lambda 表達式有個大體的印象了。

若一個方法的形參是一個接口類型,且該接口是一個函數式接口(即隻有一個抽象方法),那麼就可以使用 Lambda 表達式來替代其對應的匿名類,達到易讀、簡化的目的。

通常,Lambda 表達式的格式如下:

() -> {...}
或
(xxx) -> {...}
           

從前面的示例也可以看到,Lambda 表達式其實就代表了一個接口的執行個體對象,并且這個接口還得是一個函數式接口,即隻能有一個抽象方法,這個抽象方法的具體實作,就是 Lambda 表達式中箭頭的右側 body 部分。

3 Lambda 表達式特性及示例

前面我們初識了 Lambda 表達式,那麼,它又有哪些特性呢?

  • 特性 1:由箭頭将表達式分為左、右兩個部分

必須是形如

() -> {...}

的形式。

  • 特性 2:入參可為零個、一個、多個

當為零個時,箭頭左側的括号不可省略:

() -> {System.out.println("test expression!");};
() -> 123;
           

當入參為 1 個時,箭頭左側的圓括号可省略:

(x) -> {System.out.println(x);};
x => x + 2;
           

當入參為多個時,左側括号不能省略:

(x, y, z) -> {
    System.out.println(x);
    System.out.println(y);
    System.out.println(z);
};
           

以上都是合法表達式。但是,這并不意味着他們可以獨立存在。若不給這些表達式賦左值,則編譯器會報錯:

Not s statement

前面我們也有提到,Lambda 表達式其實是一個執行個體對象,是以,賦左值,自然是指派給某個特定類型的執行個體。它是如何指派的呢?可手動指定,也可根據 IDE 自動生成(此時編譯器會自動推斷左值類型)。在正常使用過程中,我們往往都會有目的的手動賦左值。

  • 特性 3:入參類型聲明可省略,編譯器會做自動類型推斷
List<String> strList = Arrays.asList("a", "b", "c", "d");
strList.forEach(str -> {
	System.out.println(str);
});
           

上方代碼中,Lambda 表達式中的

str

  局部變量,不需要再次聲明類型,因為編譯器會從

strList

變量中推斷出

str

  變量的類型為

String

  • 特性 4:表達式右側的 body 中,隻有一條語句,則可省略大括号,否則不可省略

上面的

strList

變量的

forEach()

  方式的周遊,可簡化為如下形式:

strList.forEach(str -> System.out.println(str));
           
  • 特性 5:表達式的傳回值是可選的

forEach()

方式,就是沒有傳回值的,也可認為是 void。

4.  為何引入 Lambda 表達式

我們先來簡述下幾種常見的程式設計範式。

4.1 幾種常見的程式設計範式

程式設計範式代表了計算機程式設計語言的典型風格和程式設計方式,通俗來說,程式設計範式就是對各種程式設計語言的分類,分類的依據,就是對各類程式設計語言的行為和處理方式進行抽象拔高,再看是否都是一類。

這麼說比較抽象,舉幾種常見的程式設計範式:指令式程式設計、聲明式程式設計和函數式程式設計。

我們看一個具體示例:

你眼前有一個水果籃,裡面放了一堆的蘋果和桔子。這時候,你老闆跟你說:“小張,交給你一個事兒,你從水果籃中一個個拿出水果,如果是桔子,則放回,繼續從水果籃中拿下一個水果,如果是蘋果,再看是否有 M 标簽,如果沒有,則放回,如果有 M 标簽,再看這個蘋果是否已壞掉,如果壞掉,則傳回,如果沒壞掉,則把該蘋果挑出來”,然後你很快就按老闆的訓示圓滿完成了任務。

這時,如果你老闆是程式員,你是計算機,那麼你老闆就在使用指令式程式設計。他會把每一步該怎麼做都告訴你,然後你隻需要嚴格按照他要求的去做就可以完成任務。

但是,我們考慮另外一種情況:

你老闆跟你說:“小張,交給你一件事,把水果籃裡的貼了 M 标簽的沒有壞掉的蘋果都撿出來”。然後你按照老闆的要求,一個個把符合條件的蘋果撿出來。

此時,老闆并沒有告訴你該怎麼一步步的把符合條件的蘋果撿出來,它隻是告訴了你他想要的是什麼(what),但并沒有告訴你該怎麼做(how),這種就是聲明式程式設計。

一般來說,絕大多數的程式員都是使用的指令式程式設計的風格,像 Java、C、C++ 等,都屬于指令式程式設計語言,它們都需要由程式員來嚴格指定每一步該怎麼做,語言本身是不會做任何特殊邏輯處理。這和馮諾依曼體系的計算機一緻,指令存儲在記憶體中,由 CPU 一條條執行指令做運算,并将資料再放回記憶體。

從程式設計範式的角度來看,像 Java、C++ 等這些進階程式設計語言,本質上和更接近機器語言的彙編語言沒有差別,都是基于馮諾依曼體系計算機模式的思想,都是指令式程式設計。相比彙編語言,進階語言隻是更符合我們人類認知的習慣和便于了解、編寫,但編譯後,還是變成了天書般的機器語言。

我們經常接觸的 SQL 語句,其實就是聲明式程式設計。如下面的語句:

## 找出所有學生的數學成績
select name,
       age,
       course,
       score
  from student
 where course= "math";
           

上面的 SQL 語句,隻是聲明了需要什麼(找出所有學生的數學成績),但至于怎麼找,語言層面不需要關心,交給資料庫系統來處理。

函數式程式設計,是近幾年火起來的一種程式設計範式,但其早就存在于我們周圍,想 JavaScript 就是一種函數式程式設計語言。函數式語言最鮮明的特點,是允許将函數作為入參傳遞給另一個函數,且也可以傳回一個函數。像我們常用的 Java 語言,其函數是無法獨立存在的,必須聲明在某個類的内部,換句話說,Java 中的函數是依附于某個特定類的,且服務于該類的域變量。是以若要按等級來劃分,對象或變量的級别是高于函數的。但在函數式程式設計語言中,函數可當做參數傳遞,也可作為傳回值,我們稱之為高階函數。看下面的示例:

def sum(x):
    def add(y):
        return x + y;
    return add;

sum2 = sum(2);
elementB = sum(7);
a = sum2(3); # 2 + 3 = 5
b = elementB(1); # 7 + 1 = 8
print a; # 輸出5
print b; # 輸出8
           

示例中,

sum()

函數内部定義了

add()

函數,兩者各自有一個入參,且

sum()

函數的傳回值是

add()

函數。那麼這裡的

sum()

就是一個高階函數。它做了件什麼事情呢?很簡單,求兩個數值的和。在 Java 中,它是怎麼實作的呢?

public int sum(int x, int y) {
	return x + y;
}
           

這是 Java 中的寫法,但函數式程式設計的計算思想和我們正常了解的不同,它使用了兩個函數來實作。比如前面的示例中,要計算 2+3,首先通過函數

sum(2)

  得到一個變量

sum2

,它同時也是一個函數,即

add()

函數,我們再次把數字 3 作為參數傳進去:

sum2(3)

,就得到了求和的值 6。

通過以上的示例對比,就能發現函數式程式設計的核心思想:通過函數來操作資料,複雜邏輯的實作是通過多個函數的組合來實作的。相比聲明式程式設計和指令式程式設計,它是一種更進階别的抽象:彙編語言要求我們如何用機器能了解的語言來寫代碼(指令);進階語言如 Java、C++ 則是使用易于人了解的方式,但如何做,還需要我們來一步步設定,仍未逃脫指令式的思維模式;函數式程式設計,通過函數來操作資料,至于函數内部做了什麼,交給其他函數來組合實作。

4.2 為何引入 Lambda

因為 Lambda 表達式是屬于函數式程式設計的範圍(将函數視作變量或對象),且後面要講到的 Stream 流,都屬于函數式程式設計的範圍,是以,這個問題的問法是可以再擴大化,即:

為何會引入函數式程式設計的用法?

a) 原因 1:使得代碼更簡潔,可讀性強

如果你有仔細閱讀前面的介紹,你會發現,Lambda 表達式本質上就是一個函數,就是其對應的函數式接口的那個唯一抽象方法的具體實作!再來回顧一下代碼:

new Thread(() -> System.out.println("this is a Lambda expression!")).start();
           

Thread 類的有參構造函數

Thread(Runnable runnable)

,本來參數是一個 Runnable 對象,

但 Java 作為一枚面向對象的程式設計語言,除了像 int、double、char 等 8 種基本資料類型,其他的一切都是對象,包括類(class)、接口(interface)、枚舉(Enum)、數組(Array)。但函數并不是對象,它隻能依附于對象而存在,按層級劃分的話,函數是低于對象的,它是無法作為一個方法的入參或者傳回值的。

在這種限制下,Java 的部分功能代碼就難免出現臃腫的現象。比如:難看又無法避免的匿名内部類、集合類的過濾、求和、轉換等操作。而 Lambda 表達式的出現,就避免了這種臃腫。

而函數式程式設計的優點就是使用簡潔、可讀性高(隻看函數名就知道要做什麼操作),如下的 Stream 流操作:

List<String> nameList = Arrays.asList("tom", "kate", "jim", "david");
List<String> newNameList = nameList
    .stream()
    .filter(name -> name.length() > 3)
    .map(name -> name.toUpperCase())
    .sorted()
    .collect(Collectors.toList());
           

上面代碼要實作的功能一目了然,沒有大量的匿名内部類,沒有多餘的中間變量,沒有複雜的邏輯計算。若摒棄 JDK8 的寫法,則需要使用又臭又長的代碼,耗費兩倍不止的時間才能實作。

是以,從可讀性、易用性角度講,函數式程式設計的寫法完勝 JDK7 以前的 Java 式寫法。

b) 原因 2:傳遞行為,而不止是傳遞值,更便于功能複用

因為函數是代表了一連串行為的集合,代表的是一組動作,而不止是一個資料,舉個例子就明白了,看下面的示例:

// 給定一個整數集合
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);

// 求所有元素的和
private Integer sumAll(List<Integer> list) {
    int sum = 0;
    for (Integer ele : list) {
        sum += ele;
    }
    return sum;
}

// 求所有偶數元素的和
private Integer sumEven(List<Integer> list) {
    int sum = 0;
    for (Integer ele : list) {
        if (ele % 2 == 0) {
            sum += ele;
        }
    }
    return sum;
}

// 求所有奇數元素的和
private Integer sumOdd(List<Integer> list) {
    int sum = 0;
    for (Integer ele : list) {
        if (ele % 2 == 1) {
            sum += ele;
        }
    }
    return sum;
}
// 求所有大于3的元素的和
private Integer sumLargerThan3(List<Integer> list) {
    int sum = 0;
    for (Integer ele : list) {
        if (ele > 3) {
            sum += ele;
        }
    }
    return sum;
}
           

作為一個有追求的程式員,對上面的這種代碼是不能忍的,重複度太高了有木有!除了元素的判斷條件不同,其他處理方式都相同。

那,對于上面的代碼,我們能怎麼優化呢?大家也許會想到政策模式,每一種處理,都對應一個不同的計算政策,設計模式用起來:

public interface sumStrategy {
	Integer sum(List<Integer> list);
}

public class SumAllStrategy implements sumStrategy {
	@Override
    public Integer sum(List<Integer> list) {
        int sum;
        for (Integer ele : list) {
            sum += ele;
        }
        return sum;
    }
}

public class SumEvenStrategy implements sumStrategy {
	@Override
    public Integer sum(List<Integer> list) {
    	...
    }
}

// 實際調用
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
// 示例1:當想求所有元素的和時,使用 SumAllStrategy 類
Strategy strategy1 = new SumAllStrategy();
strategy1.sum(list);

// 示例2:當想求所有偶數元素的和時,使用 SumEvenStrategy 類
Strategy strategy2 = new SumEvenStrategy();
strategy2.sum(list);
           

雖然設計模式用起來了,逼格也高起來了,然并卵,代碼量依然沒有減少,代碼并沒有做到複用的目的。

有了 Lambda  表達式,以上的一切都變得簡單起來,我們可以依賴一個函數式接口:

Predicate

接口。

// @since 1.8
@FunctionalInterface
public interface Predicate<T> {
    /**
     * Evaluates this predicate on the given argument.
     *
     * @param t the input argument
     * @return {@code true} if the input argument matches the predicate,
     * otherwise {@code false}
     */
    boolean test(T t);

    ...
 }
           

裡面唯一的抽象方法

test(T t)

,一個入參,然後傳回一個布爾值,很符合這裡的元素判斷。

Lambda 的使用如下:

private Integer sum(List<Integer> list, Predicate condition) {
    int sum = 0;
    for (Integer ele : list) {
        if (condition.test(ele)) {
            sum += ele;
        }
    }
    return sum;
}

// 實際使用
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
// 示例1:求所有元素的和
int sum = sum(list, x -> true);
// 示例2:求所有偶數元素的和
sum = tester.sum(list, x -> (int)x % 2 == 0);
// 示例3:求所有大于5的元素的和
sum = tester.sum(list, x -> (int)x > 5);
           

通過 Lambda 表達式,使用一個函數就搞定一切。

在上面的示例中,多個重複代碼片段的唯一異同點,就是對元素的判斷行為不同。而 Lambda 表達式,就可以把不同的判斷行為當做參數傳入

sum()

方法中,達到複用的目的。

c) 原因 3:流的并行化操作

新引入的

Stream

流操作,可以串行,也可以并行:

List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
// 串行
Integer reduce = list.stream().reduce((a, b) -> a + b).get();
// 并行,相比串行多了 parallel() 函數
Integer reduce = list.stream().parallel().reduce((a, b) -> a + b).get();
           

小結

關于 Lambda 表達式的基本使用,本篇就先介紹到這裡。但僅僅掌握這些是不夠的!

在下篇中,我們将會圍繞以下幾點内容展示:

  • Lambda 表達式和匿名内部類的差別?
  • 變量作用域
  • Java 中的閉包是什麼?
  • 常用的 Consumer、Supplier 等函數式接口怎麼用?