說明:
由于 Lambda 表達式涉及的周邊知識點實在太多,是以拆分為上、下兩篇文章講解。
本篇為下篇,上篇請點選:
深入探讨 Lambda 表達式(上)
目錄介紹:

在上篇 “
” 中,主要講述了 1~4 章節,本篇,主要介紹 5~8 章節。
5. 與匿名類的差別
在一定程度上,Lambda 表達式是對匿名内部類的一種替代,避免了備援醜陋的代碼風格,但又不能完全取而代之。
我們知道,Lambda 表達式簡化的是符合函數式接口定義的匿名内部類,如果一個接口有多個抽象方法,那這種接口不是函數式接口,也無法使用 Lambda 表達式來替換。
舉個示例:
public interface DataOperate {
public boolean accept(Integer value);
public Integer convertValue(Integer value);
}
public static List<Integer> process(List<Integer> valueList, DataOperate operate) {
return valueList.stream()
.filter(value -> operate.accept(value))
.map(value -> operate.convertValue(value))
.collect(Collectors.toList());
}
public static void main(String[] args) {
List<Integer> valueList = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
// 示例場景1: 将大于3的值翻倍,否則丢棄,得到新數組
List<Integer> newValueList1 = process(valueList, new DataOperate() {
@Override
public boolean accept(Integer value) {
return value > 3 ? true : false;
}
@Override
public Integer convertValue(Integer value) {
return value * 2;
}
});
// 示例場景2:将為偶數的值除以2,否則丢棄,得到新數組
List<Integer> newValueList2 = process(valueList, new DataOperate() {
@Override
public boolean accept(Integer value) {
return value % 2 == 0 ? true : false;
}
@Override
public Integer convertValue(Integer value) {
return value / 2;
}
});
}
上面示例中的
DataOperate
接口,因存在兩個接口,是無法使用 Lambda 表達式的,隻能在調用的地方通過匿名内部類來實作。
若
DataOperate
接口多種不同的應用場景,要麼使用匿名内部類來實作,要麼就優雅一些,使用設計模式中的政策模式來封裝一下,Lambda 在這裡是不适用的。
6. 變量作用域
不少人在使用 Lambda 表達式的嘗鮮階段,可能都遇到過一個錯誤提示:
Variable used in lambda expression should be final or effectively final
以上報錯,就涉及到外部變量在 Labmda 表達式中的作用域,且有以下幾個文法規則。
6.1 變量作用域的規則
- 規則 1:局部變量不可變,域變量或靜态變量是可變的
何為局部變量?局部變量是指在我們普通的方法内部,且在 Lambda 表達式外部聲明的變量。
在 Lambda 表達式内使用局部變量時,該局部變量必須是不可變的。
如下的代碼展示中,變量
a
就是一個局部變量,因在 Lambda 表達式中調用且改變了值,在編譯期就會報錯:
public class AClass {
private Integer num1 = 1;
private static Integer num2 = 10;
public void testA() {
int a = 1;
int b = 2;
int c = 3;
a++;
new Thread(() -> {
System.out.println("a=" + a); // 在 Lambda 表達式使用前有改動,編譯報錯
b++; // 在 Lambda 表達式中更改,報錯
System.out.println("c=" + c); // 在 Lambda 表達式使用之後有改動,編譯報錯
System.out.println("num1=" + this.num1++); // 對象變量,或叫域變量,編譯通過
AClass.num2 = AClass.num2 + 1;
System.out.println("num2=" + AClass.num2); // 靜态變量,編譯通過
}).start();
c++;
}
}
上面的代碼中,變量
a
,
b
c
都是局部變量,無論在 Lambda 表達式前、表達式中或表達式後修改,都是不允許的,直接編譯報錯。而對于域變量
num1
,以及靜态變量
num2
,不受此規則限制。
- 規則 2:表達式内的變量名不能與局部變量重名,域變量和靜态變量不受限制
不解釋,看代碼示例:
public class AClass {
private Integer num1 = 1;
private static Integer num2 = 10;
public void testA() {
int a = 1;
new Thread(() -> {
int a = 3; // 與外部的局部變量重名,編譯報錯
Integer num1 = 232; // 雖與域變量重名,允許,編譯通過
Integer num2 = 11; // 雖與靜态變量重名,允許,編譯通過
}).start();
}
}
友情提醒:雖然域變量和靜态變量可以重名,從可讀性的角度考慮,最好也不用重複,養成良好的編碼習慣。
- 規則 3:可使用
、this
關鍵字,等同于在普通方法中使用super
public class AClass extends ParentClass {
@Override
public void printHello() {
System.out.println("subClass: hello budy!");
}
@Override
public void printName(String name) {
System.out.println("subClass: name=" + name);
}
public void testA() {
this.printHello(); // 輸出:subClass: hello budy!
super.printName("susu"); // 輸出:ParentClass: name=susu
new Thread(() -> {
this.printHello(); // 輸出:subClass: hello budy!
super.printName("susu"); // 輸出:ParentClass: name=susu
}).start();
}
}
class ParentClass {
public void printHello() {
System.out.println("ParentClass: hello budy!");
}
public void printName(String name) {
System.out.println("ParentClass: name=" + name);
}
}
對于
this
super
關鍵字,大家記住一點就行啦:在 Lambda 表達式中使用,跟在普通方法中使用沒有差別!
- 規則 4:不能使用接口中的預設方法(default 方法)
public class AClass implements testInterface {
public void testA() {
new Thread(() -> {
String name = super.getName(); // 編譯報錯:cannot resolve method 'getName()'
}).start();
}
}
interface testInterface {
// 預設方法
default public String getName() {
return "susu";
}
}
6.2 為何要 final?
不管是 Lambda 表達式,還是匿名内部類,編譯器都要求了變量必須是 final 類型的,即使不顯式聲明,也要確定沒有修改。那大家有沒有想過,為何編譯器要強制設定變量為 final 或 effectively final 呢?
- 原因 1:引入的局部變量是副本,改變不了原本的值
看以下代碼:
public static void main(String args[]) {
int a = 3;
String str = "susu";
Susu123 susu123 = (x) -> System.out.println(x * 2 + str);
susu123.print(a);
}
interface Susu123 {
void print(int x);
}
在編譯器看來,main 方法所在類的方法是如下幾個:
public class Java8Tester {
public Java8Tester(){
}
public static void main(java.lang.String[]){
...
}
private static void lambda$main$0(java.lang.String, int);
...
}
}
可以看到,編譯後的檔案中,多了一個方法
lambda$main$0(java.lang.String, int)
,這個方法就對應了 Lambda 表達式。它有兩個參數,第一個是 String 類型的參數,對應了引入的 局部變量
str
,第二個參數是 int 類型,對應了傳入的變量
a
。
若在 Lambda 表達式中修改變量 str 的值,依然不會影響到外部的值,這對很多使用者來說,會造成誤解,甚至不了解。
既然在表達式内部改變不了,那就索性直接從編譯器層面做限制,把有在表達式内部使用到的局部變量強制為 final 的,直接告訴使用者:這個局部變量在表達式内部不能改動,在外部也不要改啦!
- 原因 2:局部變量存于棧中,多線程中使用有問題
大家都知道,局部變量是存于 JVM 的棧中的,也就是線程私有的,若 Lambda 表達式中可直接修改這邊變量,會不會引起什麼問題?
很多小夥伴想到了,如果這個 Lambda 表達式是在另一個線程中執行的,是拿不到局部變量的,是以表達式中擁有的隻能是局部變量的副本。
如下的代碼:
public static void main(String args[]) {
int b = 1;
new Thread(() -> System.out.println(b++));
}
假設在 Lambda 表達式中是可以修改局部變量的,那在上面的代碼中,就出現沖突了。變量
b
是一個局部變量,是目前線程私有的,而 Lambda 表達式是在另外一個線程中執行的,它又怎麼能改變這個局部變量
b
的值呢?這是沖突的。
- 原因 3:線程安全問題
舉一個經常被列舉的一個例子:
public void test() {
boolean flag = true;
new Thread(() -> {
while(flag) {
...
flag = false;
}
});
flag = false;
}
先假設 Lambda 表達式中的 flag 與外部的有關聯。那麼在多線程環境中,線程 A、線程 B 都在執行 Lambda 表達式,那麼線程之間如何彼此知道 flag 的值呢?且外部的 flag 變量是在主線程的棧(stack)中,其他線程也無法得到其值,是以,這是自相沖突的。
小結:
前面我們列舉了多個局部變量必須為 final 或 effectively final 的原因,而 Lambda 表達式并沒有對執行個體變量或靜态變量做任何限制。
雖然沒做限制,大家也應該明白,允許使用,并不代表就是線程安全的,看下面的例子:
// 執行個體變量
private int a = 1;
public static void main(String args[]) {
Java8Tester java8Tester = new Java8Tester();
java8Tester.test();
System.out.println(java8Tester.a);
}
public void test() {
for (int i = 0; i < 10; i++) {
new Thread(() -> this.a++).start();
}
}
以上的代碼,并不是每次執行的結果都是 11,是以也存線上程安全問題。
7. Java 中的閉包
前面已經把 Lmabda 表達式講的差不多了,是時候該講一下閉包了。
閉包是函數式程式設計中的一個概念。在介紹 Java 中的閉包前,我們先看下 JavaScript 語言中的閉包。
function func1() {
var s1 = 32;
incre = function() {
s1 + 1;
};
return function func2(y) {
return s1 + y;
};
}
tmp = func1();
console.log(tmp(1)); // 33
incre();
console.log(tmp(1)); // 34
上面的 JavaScript 示例代碼中,函數
func2(y)
就是一個閉包,特征如下:
- 第一點,它本身是一個函數,且是一個在其他函數内部定義的函數;
- 第二點,它還攜帶了它作用域外的變量
,即外部變量。s1
正常來說,語句
tmp = func1();
在執行完之後,
func1()
函數的聲明周期就結束啦,并且變量
s1
還使用了
var
修飾符,即它是一個方法内的局部變量,是存在于方法棧中的,在該語句執行完後,是要随
func1()
函數一起被回收的。
但在執行第二條語句
console.log(tmp(1));
時,它竟然沒有報錯,還仍然保有變量
s1
的值!
繼續往下看。
在執行完第三條語句
incre();
後,再次執行語句
console.log(tmp(1));
,會發現輸出值是 34。這說明在整個執行的過程中,函數
func2(y)
是持有了變量
s1
的引用,而不單純是數值 32!
通過以上的代碼示例,我們可以用依據通俗的話來總結閉包:
閉包是由函數和其外部的引用環境組成的一個實體,并且這個外部引用必須是在堆上的(在棧中就直接回收掉了,無法共享)。
在上面的 JavaScript 示例中,變量
s1
就是外部引用環境,而且是 capture by Reference。
說完 JavaScript 中的閉包,我們再來看下 Java 中的閉包是什麼樣子的。Java 中的内部類就是一個很好的闡述閉包概念的例子。
public class OuterClass {
private String name = "susu";
private class InnerClass {
private String firstName = "Shan";
public String getFullName() {
return new StringBuilder(firstName).append(" ").append(name).toString();
}
public OuterClass getOuterObj() {
// 通過 外部類.this 得到對外部環境的引用
return OuterClass.this;
}
}
public static void main(String[] args) {
OuterClass outerClass = new OuterClass();
InnerClass innerClass = outerClass.new InnerClass();
System.out.println(innerClass.getFullName());
outerClass.name = "susu1";
System.out.println(innerClass.getFullName());
System.out.println(Objects.equals(outerClass, innerClass.getOuterObj()));
}
}
#### 輸出 ####
Shan susu
Shan susu1
true
上面的例子中,函數
getFullName()
就是一個閉包函數,其持有一個外部引用的變量
name
,從輸出結果可以看到,引用的外部變量變化,輸出值也會跟随變化的,也是 capture by reference。
内部類可以通過
外部類.this
來得到對外部環境的引用,上面示例的輸出結果為 true 說明了這點。在内部類的
getFullName()
方法中,可直接引用外部變量
name
,其實也是通過内部類持有的外部引用來調用的,比如,該方法也可以寫成如下形式:
public String getFullName() {
return new StringBuilder(firstName).append(" ").append(OutClass.this.name).toString();
}
OutClass.this
就是内部類持有的外部引用。
内部類可以有多種形式,比如匿名内部類,局部内部類,成員内部類(上面的示例中
InnerClass
類就是),靜态内部類(可用于實作單例模式),這裡不再一一列舉。
對于 Lambda 表達式,在一定條件下可替換匿名内部類,但都是要求引入的外部變量必須是 final 的,前面也解釋了為何變量必須是 final 的。
寬泛了解,Lambda 表達式也是一種閉包,也是在函數内部引入了外部環境的變量,但不同于 JavaScript 語言中的閉包,函數内一直持有外部變量,即使對應的外部函數已經銷毀,外部變量依然可以存在并可以修改,Java 中 Lambda 表達式中對外部變量的持有,是一種值拷貝,Lambda 表達式内并不持有外部變量的引用,實際上是一種 capture by value,是以 Java 中的 Lambda 表達式所呈現的閉包是一種僞閉包。
8. Consumer、Supplier 等函數式接口
說實話,在第一次看到這類函數式接口的定時時,我是一臉懵逼的,這類接口有什麼用?看不懂有什麼含義,這類接口定義的莫名其妙。
就像 Consumer 接口的定義:
@FunctionalInterface
public interface Consumer<T> {
/**
* Performs this operation on the given argument.
*
* @param t the input argument
*/
void accept(T t);
default Consumer<T> andThen(Consumer<? super T> after) {
Objects.requireNonNull(after);
return (T t) -> { accept(t); after.accept(t); };
}
}
單看
accept(T t)
抽象方法,需傳入一個入參,沒有傳回值。這個方法做了啥?有什麼語義上的功能嗎?木有!
衆所周知,Java 是一門面向對象的語言,一切皆對象。我們自定義的類(比如:
HashMap
ArrayList
)或方法(如:
getName()
execute()
),都是有一定的語義(semantic)資訊的,是暗含了它的使用範圍和場景的,通俗點說,我們明顯的可以知道它們可以幹啥。
但回過頭看
accept(T t)
這個抽象方法,你卻不知道它是幹啥的。其實,對于函數式接口中的抽象方法,它們是從另外一個次元去定義的,即結構化(structure)的定義。它們就是一種結構化意義的存在,本身就不能從語義角度去了解。
這裡介紹幾種常見的函數式接口的用法。
- Consumer 接口:消費型函數式接口
從其抽象方法
void accept(T t)
來了解,就是一個參數傳入了進去,整個方法的具體實作都與目前這個參數有關聯。這與清單元素的循環擷取很像,比如集合類的
Foreach()
方法:
default void forEach(Consumer<? super T> action) {
Objects.requireNonNull(action);
for (T t : this) {
action.accept(t);
}
}
再舉一個例子。在日常開發中,可能會遇到連接配接,如資料庫的連接配接,網絡的連接配接等,假設有這麼一個連接配接類:
public class Connection {
public Connection() {
}
public void operate() {
System.out.println("do something.");
}
public void close() {
}
每次使用時,都需要建立連接配接、使用連接配接和關閉連接配接三個步驟,比如:
public void executeTask() {
Connection conn = new Connection();
try {
conn.operate();
} catch (Exception e) {
e.printStackTrace();
} finally {
conn.close();
}
}
當有多處代碼都需要用到此類用法時,就需要在多處去建立連接配接、使用和關閉連接配接等操作。
這樣有沒有什麼問題呢?萬一某處代碼忘記關閉其建立的連接配接對象,就可能會導緻記憶體洩漏!
有沒有比較好的方式呢?
可以将這部分常用代碼做抽象,且不允許外部随意建立連接配接對象,隻能自己建立自己的對象,如下:
public class Connection {
private Connection() {
}
public void operate() {
System.out.println("do something.");
}
public void close() {
}
public static void useConnection(Consumer<Connection> consumer) {
Connection conn = new Connection();
try {
consumer.accept(conn);
} catch (Exception e) {
} finally {
conn.close();
}
}
}
注意,上面的構造函數是私有的,進而避免了由外部建立
Connection
對象,同時在其内部提供了一個靜态方法
useConnection()
,入參就是一個
Consumer
對象。當我們外部想使用時,使用如下調用語句即可:
Connection.useConnection(conn -> conn.operate());
- Supplier 接口:供給型函數式接口
接口定義如下:
public interface Supplier<T> {
/**
* Gets a result.
*
* @return a result
*/
T get();
}
抽象方法
T get()
沒有入參,傳回一個對象,和前面的
Consumer
接口的
void accept(T t)
抽象方法正好相反。
看下基本用法:
// 示例 1
Supplier<Integer> supplier1 = () -> Integer.valueOf(32);
System.out.println(supplier1.get()); // 32
// 示例 2
Supplier<Runnable> supplier2 = () -> () -> System.out.println("abc");
supplier2.get().run(); // abc
第 2 個示例,你有沒有看糊塗?其等價代碼如下:
Supplier<Runnable> supplier2 = () -> {
Runnable runnable = () -> System.out.println("abc");
return runnable;
};
supplier2.get().run();
像 Predicate、BiConsumer 等其他函數式接口,這裡不再一一列舉,感興趣的小夥伴可自行查閱學習。
小結
關于 Lambda 表達式的知識點,上篇文章
和本篇就已經全部介紹完畢。各位小夥伴,你都掌握了嗎?