帶你徹底掌握 Lambda 表達式(上)

  • 2020 年 2 月 11 日
  • 筆記

說明:

由於 Lambda 表達式涉及的周邊知識點實在太多,因此拆分為上、下兩篇文章講解,本篇為上篇,下篇隨後放出,大家可在我公眾號中查找。

整體目錄介紹:

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

序言

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

但平時在和很多小夥伴的交流和面試中,發現很多人仍停留在 JDK7 及以前的認知層面,Lambda 表達式、方法引用、Stream 流、default 關鍵字,很少使用,甚至還有不少小夥伴不知道怎麼用!!

不客氣的說,不掌握 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 介面的定義,如下。大家有發現什麼不同點了嗎?

眼尖的小夥伴一定發現了,JDK8 中多了個註解 @FunctionalInterface。這就是為何能在 JDK8 中可以使用這種箭頭式的 Lambda 寫法。

本小節最開始時我們也提到了此註解。從上圖也能看出,@FunctionalInterfaceJDK8 中新引入的一個註解,它定義了一類新的介面(即函數式介面),該類介面有且只能有一個抽象方法

它主要用於編譯期的錯誤檢查,如果一個介面不包含抽象方法(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 依然符合函數式介面的定義。

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

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

第一塊內容是使用 @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,它裡面內置了一些我們常用的函數式介面,如 PredicateSupplierConsumer 等介面。

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 等函數式介面怎麼用