帶你徹底掌握 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 寫法。
本小節最開始時我們也提到了此註解。從上圖也能看出,@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
依然符合函數式介面的定義。
另外,我們仔細看下注解的描述片段:

上面截圖中的資訊量較大,分為兩塊內容。
第一塊內容是使用 @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 等函數式介面怎麼用