學習如何在 lambda 表示式中使用型別推斷,掌握改進引數命名的技巧。
概覽
Java8 是一個支援型別推斷的 Java 版本,而且它僅對 lambda 表示式支援此功能。在 lambda 表示式中使用型別推斷具有強大的作用,它將幫助您做好準備來應對未來的 Java 版本,在以後的版本中還會將型別推斷用於變數等更多可能。這裡的訣竅在於恰當地命名引數,相信 Java 編譯器會推斷出剩餘的資訊。大多數時候編譯器能夠完全推斷出型別。在無法推斷出來的時候,就會報錯。瞭解 lambda 表示式的推斷工作原理,至少要檢視一個無法推斷型別的示例。
顯示型別和冗餘
假設我們詢問一個人叫什麼名字,它會回答:“我叫xxx”。這樣的例子在生活中經常會發生,但是簡單地說 “xxx” 會更高效。您需要的只是一個名稱,所以該句子的剩餘部分都是多餘的。
我們在程式碼中也經常會遇到這類多餘的事情。Java 開發人員可以使用 forEach 迭代輸出某個範圍內的每個值的雙倍。看下面的例子:
public static void main(String[] args) {
// TODO Auto-generated method stub
IntStream.rangeClosed(1, 5)
.forEach((int number) -> System.out.println(number * 2));
}
複製程式碼
測試結果:
2
4
6
8
10
複製程式碼
rangeClosed 方法生成一個從 1 到 5 的 int 值流。lambda 表示式的唯一職責就是接收一個名為 number 的 int 引數,使用 PrintStream 的 println 方法輸出該值的雙倍值。從語法上講,該 lambda 表示式沒有錯,但型別細節有些冗餘。
java8 中的型別推斷
當您在某個數字範圍內提取一個值時,編譯器知道該值的型別為 int。不需要在程式碼中顯示的宣告。
在 Java8 中我們可以丟棄 lambda 表示式中的型別:
IntStream.rangeClosed(1, 5)
.forEach((number) -> System.out.println(number * 2));
複製程式碼
由於 Java 是靜態型別語言,它需要在編譯時知道所有的物件和變數的型別。在 lambda 表示式的引數列表中省略型別並不會讓 Java 更接近動態型別語言。但是,新增適當的型別推斷功能會讓 Java 更接近其他靜態型別語言,比如 Haskel 等。
信任編譯器
如果您在 lambda 表示式的一個引數中省略型別,Java 需要通過上下文細節來推斷該型別。返回上一個示例,當我們在 IntStream 上呼叫 forEach 時,編譯器會查詢該方法來確定它採用的引數。IntStream 的 forEach 方法期望使用函式介面 IntConsumer,該介面的抽象方法 accept 採用了一個 int 型別的引數並返回 void。
如果在引數列表中指定了該型別,編譯器將會確認該型別符合預期。如果省略了該型別,編譯器會推斷出預期的型別。
無論您是提供型別還是編譯器推斷出該型別,Java 都會在編譯時知道 lambda 表示式引數的型別。要測試這種情況,可以在 lambda 表示式中引入一個錯誤,同時省略引數的型別:
編譯器直接就會報錯。編譯器知道名為 number 的引數的型別。它報錯是因為它無法使用點運算子解除對某個 int 型別的變數的引用。不能對 int 變數執行這個操作。
型別推斷的好處
在 lambda 表示式中省略型別有兩個主要好處:
- 鍵入的內容更少。無需輸入型別資訊,因為編譯器自己能輕鬆確定該型別。
- 程式碼雜質更少,更簡單。
此外,一般來講,如果我們僅有一個引數,省略型別意味著也可以省略 (),如下所示:
IntStream.rangeClosed(1, 5)
.forEach(number -> System.out.println(number * 2));
複製程式碼
請注意,您將需要為採用多個引數的 lambda 表示式新增括號。
型別推斷和可讀性
lambda 表示式中的型別推斷違背了 Java 中的常規做法,在常規做法中,會指定每個變數和引數的型別。
看下面這個示例:
List<String> result =
cars.stream()
.map((Car c) -> c.getRegistration())
.map((String s) -> DMVRecords.getOwner(s))
.map((Person o) -> o.getName())
.map((String s) -> s.toUpperCase())
.collect(toList());
複製程式碼
這段程式碼中的每個 lambda 表示式都為其引數指定了一個型別,但我們為引數使用了單字母變數名。這在 Java 中很常見。但這種做法不合適,因為它丟棄了特定於域的上下文。
我們可以做得比這更好。讓我們看看使用更強大的引數名重寫程式碼後發生的情況:
List<String> result =
cars.stream()
.map((Car car) -> car.getRegistration())
.map((String registration) -> DMVRecords.getOwner(registration))
.map((Person owner) -> owner.getName())
.map((String name) -> name.toUpperCase())
.collect(toList());
複製程式碼
這些引數名包含了特定於域的資訊。我們沒有使用 s 來表示 String,而是指定了特定於域的細節,比如 registration 和 name。類似地,我們沒有使用 p 或 o,而是使用 owner 表明 Person 不只是一個人,還是這輛車的車主。
命名引數
Scala 和 TypeScript 等一些語言更加重視引數名而不是型別。在 Scala 中,我們在定義型別之前定義引數,例如通過編寫:
def getOwner(registration: String)
複製程式碼
而不是:
def getOwner(String registration)
複製程式碼
型別和引數名都很有用,但在 Scala 中,引數名更重要一些。我們用 Java 編寫 lambda 表示式時,也可以考慮這一想法。請注意我們在 Java 中的車輛註冊示例中丟棄型別細節和括號時發生的情況:
List<String> result =
cars.stream()
.map(car -> car.getRegistration())
.map(registration -> DMVRecords.getOwner(registration))
.map(owner -> owner.getName())
.map(name -> name.toUpperCase())
.collect(toList());
複製程式碼
因為我們新增了描述性的引數名,所以我們沒有丟失太多上下文,而且顯式型別(現在是冗餘內容)已悄然消失。結果是我們獲得了更乾淨、更樸實的程式碼。
型別推斷的侷限性
儘管使用型別推斷可以提高效率和可讀性,但這種技術並不適合與所有的場所。在某些情況下,完全無法使用型別推斷。幸運的是,您可以依靠 Java 編譯器來獲取何時出現這種情況。
我們首先看一個編譯器成功的例子,然後看一個測試失敗的例子。
擴充套件型別推斷
在我們的第一個示例中,假設我們想建立一個 Comparator 來比較 Car 例項。我們首先需要一個 Car 類:
class Car {
public String getRegistration() { return null; }
}
複製程式碼
接下來,我們將建立一個 Comparator,以便基於 Car 例項的註冊資訊對它們進行比較:
public static Comparator<Car> createComparator() {
return comparing((Car car) -> car.getRegistration());
}
複製程式碼
用作 comparing 方法的引數的 lambda 表示式在其引數列表中包含了型別資訊。我們知道 Java 編譯器非常擅長型別推斷,那麼讓我們看看在省略引數型別的情況下會發生什麼,如下所示:
public static Comparator<Car> createComparator() {
return comparing(car -> car.getRegistration());
}
複製程式碼
comparing 方法採用了 1 個引數。它期望使用 Function<? super T, ? extends U>
並返回 Comparator<T>
。因為 comparing 是 Comparator<T>
上的一個靜態方法,所以編譯器目前沒有關於 T 或 U 可能是什麼的線索。
為了解決此問題,編譯器稍微擴充套件了推斷範圍,將範圍擴大到傳遞給 comparing 方法的引數之外。它觀察我們是如何處理呼叫 comparing 的結果的。根據此資訊,編譯器確定我們僅返回該結果。接下來,它看到由 comparing 返回的 Comparator<T>
又作為 Comparator<Car>
由 createComparator 返回 。
注意了!編譯器現在已明白我們的意圖:它推斷應該將 T 繫結到 Car。根據此資訊,它知道 lambda 表示式中的 car 引數的型別應該為 Car。
在這個例子中,編譯器必須執行一些額外的工作來推斷型別,但它成功了。接下來,讓我們看看在提高挑戰難度,讓編譯器達到其能力極限時,會發生什麼。
推斷的侷限性
首先,我們在前一個 comparing 呼叫後面新增了一個新呼叫:
public static Comparator<Car> createComparator() {
return comparing((Car car) -> car.getRegistration()).reversed();
}
複製程式碼
藉助顯式型別,此程式碼沒有編譯問題,但現在讓我們丟棄型別資訊,看看會發生什麼:
public static Comparator<Car> createComparator() {
return comparing(car -> car.getRegistration()).reversed();
}
複製程式碼
Java 編譯器丟擲了錯誤:
Sample.java:21: error: cannot find symbol
return comparing(car -> car.getRegistration()).reversed();
^
symbol: method getRegistration()
location: variable car of type Object
Sample.java:21: error: incompatible types: Comparator<Object> cannot be converted to Comparator<Car>
return comparing(car -> car.getRegistration()).reversed();
^
2 errors
複製程式碼
像上一個場景一樣,在包含 .reversed() 之前,編譯器會詢問我們將如何處理呼叫 comparing(car -> car.getRegistration())
的結果。在上一個示例中,我們以 Comparable<Car>
形式返回結果,所以編譯器能推斷出 T 的型別為 Car。
但在修改過後的版本中,我們將傳遞 comparable 的結果作為呼叫 reversed() 的目標。comparable 返回 Comparable<T>
,reversed() 沒有展示任何有關 T 的可能含義的額外資訊。根據此資訊,編譯器推斷 T 的型別肯定是 Object。遺憾的是,此資訊對於該程式碼而言並不足夠,因為 Object 缺少我們在 lambda 表示式中呼叫的 getRegistration() 方法。
型別推斷在這一刻失敗了。在這種情況下,編譯器實際上需要一些資訊。型別推斷會分析引數、返回元素或賦值元素來確定型別,但在上下文提供的細節不足時,編譯器就會達到其能力極限。
能否採用方法引用作為補救措施?
在我們放棄這種特殊情況之前,讓我們嘗試另一種方法:不使用 lambda 表示式,而是嘗試使用方法引用:
public static Comparator<Car> createComparator() {
return comparing(Car::getRegistration).reversed();
}
複製程式碼
由於直接說明了 Car 型別,編譯器對此非常滿意。
總結
Java 8 為 lambda 表示式的引數引入了有限的型別推斷能力,在未來的 Java 版本中,會將型別推斷擴充套件到區域性變數。現在應該學會省略型別細節並信任編譯器,這有助於您輕鬆步入未來的 Java 環境。
依靠型別推斷和適當命名的引數,編寫簡明、更富於表達且更少雜質的程式碼。只要您相信編譯器能自行推斷出型別,就可以使用型別推斷。僅在您確定編譯器確實需要您的幫助的情況下提供型別細節。
文章學習地址:
感謝 Venkat Subramaniam 博士
Venkat Subramaniam 博士站點:http://agiledeveloper.com/
知識改變命運,努力改變生活