Java 10帶來了全新的語言特性:局部變量類型推導(local variable type inference)。它的主要目標就是減少樣板代碼(boilerplate),增強代碼可讀性。可以使用關鍵詞var來替代局部變量的類型聲明——編譯器會根據變量初始化語句來自己填充正確的類型的。比如說:
Map<User, List<String>> userChannels = new HashMap<>();
在Java 10中可以這麼寫:
var userChannels = new HashMap<User, List<String>>();
除了代碼的簡潔性,類型推導還有不少優點,本文我們將會一一看下。先來看一個稍微複雜點的例子:
Path path = Paths.get(「src/web.log」);
try (Streamlines = Files.lines(path)){
long warningCount =
lines.filter(line -> line.contains(「WARNING」))
.count();
System.out.println(
「Found 「 + warningCount + 「 warnings in the log file」);
} catch (IOException e) {
e.printStackTrace();
}
在Java 10中可以重構為:
var path = Paths.get(「src/web.log」);
try (var lines = Files.lines(path)){
var warningCount =
lines.filter(line -> line.contains(「WARNING」))
.count();
System.out.println(
「Found 「 + warningCount + 「 warnings in the log file」);
} catch (IOException e) {
e.printStackTrace();
}
代碼中的表達式都仍是靜態類型的(也就是值所聲明的類型),對應如下:
- 局部變量path類型為Path。
- 變量lines的類型是Stream
- 。
- warningCount類型是long
也就說其它類型的值賦值過去是會報錯的。比方說下面重新賦值的代碼在編譯時就會報錯:
var warningCount = 5;
warningCount = "6";
| Error:
| incompatible types: java.lang.String cannot be converted to int
| warningCount = "6"
關於類型推導還有一些地方是要注意的。比如說,如果Car和Bike是Vehicle的子類,那麼var v = new Car();聲明的類型是Car還是Vehicle?
簡單來說,省略的類型和初始化的類型是一致的(在這裡是Car類型),這點很清楚了,換句話說如果沒有初始化語句是不能使用var的。
如果後面再進行v = new Bike();賦值是不行的。換句話說,var是不支持多態的。
什麼情況用不了類型推導
哪些情況用不了類型推導?首先,只有局部變量可以使用。欄位或者方法簽名都不支持的。比如說下面這個就是錯誤的:
public long process(var list) { }
如果沒有顯式的初始化語句的局部變量聲明也是不行的。也就是說不能只用var聲明變量卻沒有賦值。
下面的:
var x;
會返回編譯報錯:
| Error:
| cannot infer type for local variable x
| (cannot use 'var' on variable without initializer)
| var x;
| ^----^
也不能將var變量初始化為null。因為它可能要在後邊才進行初始化,這樣就無法確定具體類型。
| Error:
| cannot infer type for local variable x
| (variable initializer is 'null')
| var x = null;
| ^-----------^
var也不能用於lambda表達式,因為它需要顯式的目標類型。下面的賦值語句會報錯:
var x = () -> {}
報這個錯誤:
| Error:
| cannot infer type for local variable x
| (lambda expression needs an explicit target-type)
| var x = () -> {};
| ^---------------^
不過不一樣的是下面的賦值是能通過的,因為右邊有顯式的初始化語句:
var list = new ArrayList<>();
但list的靜態類型是什麼?推導出來的變量類型是ArrayList,這可能並不是太有意義,因為泛型信息丟失了。所以你可能並不希望這樣來賦值。
匿名類型(Nondenotable Types)的推導
Java中存在幾種匿名類型——這個類型確實存在於程序中,但無法寫出它的類型名。匿名類就是一個很好的例子——你可以往它裡面添加欄位和方法,但無法在代碼中引用這個類的名字。<>操作符也無法用於匿名類。var的限制相對少一點,可以支持部分匿名類型——具體來說就是匿名類和交集類型(intersection type)。
var關鍵詞可以更高效地使用匿名類,或用來引用「無法描述清楚」的類型。正常來說,如果你創建了一個匿名類,可以往裡面添加新的欄位,但卻沒辦法引用這些欄位,因為這個對象總會要賦值給一個已知的類型的。
比如說,下面這段代碼就沒法通過編譯,因為productInfo的類型是Object,不可能去訪問一個Object對象的name和total欄位:
Object productInfo = new Object() {
String name = 「Apple」;
int total = 30;
};
System.out.println(
「name = 「 + productInfo.name + 「, total = 「 + productInfo.total);
而var能解決這一問題。當你把一個匿名對象賦值給var類型的局部變量時,編譯器能推導出匿名類的真正類型,而不只是父類類型。這樣你就可以引用匿名類內部所聲明的欄位了,如下所示:
var productInfo = new Object() {
String name = 「Apple」;
int total = 30;
};
System.out.println(
「name = 「 + productInfo.name + 「, total = 「 + productInfo.total);
這一功能看起來貌似只是一個很有意思的小的語言特性而已,沒什麼大用,但在某些場景下還是很有幫助的。比方說,在某個方法裡你希望返回幾個值作為中間變量。正常來說,只為了在這一個方法裡面使用,你就得創建和維護一個新的類型。比如在Collectors.averagingDouble()的實現中,一個小的double數組就是用作這個目的的。
var有一個更好的解決方案:使用匿名類來存儲中間值。
再來看一個例子,你有一些產品,產品會有自己的名字,數量,單價。你需要計算每個物品的總價值——也就是數量乘以單價。如果只需要這個信息的話將產品map到它的價值就可以了,但結果信息中可能還需要加上產品名才更有意義。
我們來看下Java 10中通過var可以怎麼做:
var products = List.of(
new Product(10, 3, 「Apple」),
new Product( 5, 2, 「Banana」),
new Product(17, 5, 「Pear」));
var productInfos = products
.stream()
.map(product -> new Object() {
String name = product.getName();
int total = product.getStock() * product.getValue();
}).collect(toList());
productInfos.forEach(prod -> System.out.println(
「name = 「 + prod.name + 「, total = 「 + prod.total));
會輸出如下信息:
name = Apple, total = 30
name = Banana, total = 10
name = Pear, total = 85
並不是所有的匿名類型都能使用var。它只支持匿名類和交集類型。而通配符類型則不支持推導,這是為了避免給Java開發人員展示太多與通配符相關的複雜報錯信息。支持匿名類型是為了能儘可能多地保留推導類型的信息,以便能用局部變量來重構更多代碼。這項特性最初的設計目標並不是為了編寫類似上面這樣的代碼的,而是希望解決var在處理匿名類型時會碰到的問題。var在匿名類型上的使用是否會流行起來還不好說。
推薦用法
類型推導當然可以減少Java代碼的編寫時間,不過它對可讀性有沒有提升?開發人員通常花在讀代碼上的時間比寫代碼要多得多,因此肯定希望閱讀代碼時能更輕鬆一點。而var對可讀性的提升是因人而異的:有人喜歡它,有人討厭它。你應該時刻關注如何能讓團隊成員能更好地閱讀你的代碼。如果大家都喜歡var,就用它;否則就不要使用。
有的時候顯式類型聲明會妨礙可讀性。比如說,當遍歷Map的entrySet時,需要在Map.Entry中再重複參數類型。下面這段代碼會遍歷國家下面的各個城市:
Map<string, list
// …
for (Map.Entry<string, list
countryToCity.entrySet()) {
Listcities = citiesInCountry.getValue();
// …
}
可以用var來重寫這段代碼,減少重複和樣板代碼,如下:
var countryToCity = new HashMap<string, list
// …
for (var citiesInCountry : countryToCity.entrySet()) {
var cities = citiesInCountry.getValue();
// …
}
這不光提升了可讀性,對代碼的可維護性也有提高。
比如說,如果還是同樣的代碼,如果要將城市從String類型替換為City,增加些額外的城市信息,正常你需要重寫代碼,因為它依賴了特定的類型。
Map<string, list
// …
for (Map.Entry<string, list
countryToCity.entrySet()) {
Listcities = citiesInCountry.getValue();
// …
}
而如果使用了var及類型推導,只需要改下首行代碼便可以了,其它代碼無需改動:
var countryToCity = new HashMap<string, list
// …
for (var citiesInCountry : countryToCity.entrySet()) {
var cities = citiesInCountry.getValue();
// …
}
這個例子說明了var使用的一個關鍵原則:不要為了讀寫代碼方便而進行優化,而應該優化可維護性。如果奔著可維護性去優化代碼,隨著程序的不斷疊代,自然會在可讀性和代碼量上能找到平衡點。
很難說使用了類型推導就一定能夠對代碼有所提升——有的時候顯式類型會讓代碼的可讀性更強一些。尤其是當表達式中不容易看出具體類型時。下面的代碼最好還是使用顯式類型,因為光看getCities()你是不知道它返回了什麼的:
Map<String, List<City>> countryToCity = getCities();
var countryToCity = getCities();
關於var和可讀性間的權衡,終極建議是:使用好變量名!由於var省略了變量的類型,讀代碼的人只能去猜測代碼的真實意圖,因此作為開發人員更有義務要為局部變量取一個好的名字。理論上來說這也是Java開發人員應該做的。不過在實踐中,其實Java代碼的很多可讀性問題並不是語言特性引起的,更多還是目前大家的做法導致的,比如說變量命名。
類型推導及IDE
許多IDE都提供了局部變量提取(extract local variable)的功能,它們能推導出正確的類型並且生成代碼。這個特性和Java 10中的var關鍵字的功能有些重疊。IDE和var都能節省手動輸入類型的工作量,不過它們有著不同的取捨。
提取功能會在代碼中生成一個擁有完整類型信息的局部變量,而var則徹底消除了在代碼中聲明類型的必要。儘管它們在簡少代碼編寫上的作用是相似的,但var或多或少改變了可讀性而提取功能則沒有。正如前面所說的,大多數情況下var還是能提升可讀性的,不過有的時候又變成了阻礙。
和其它語言相比
Java不是首個也不是唯一一個進行類型推導的語言。事實上Java 10所引入的類弄推導功能仍非常有限。目前var的推導算法只檢查了變量的賦值表達式,保證了實現的簡單以及編譯器報錯能直接到關聯到具體的var語句上。
結論
從提高生產效率和可讀性方面來看,var的類型推導是Java語言的一個有效的補充,不過這只是一個開始。未來的Java版本仍會繼續穩步推進語言的革新及現代化。比如在Java 10發布後僅過了6個月便發布的長期支持版的Java 11中,var關鍵字便可用於lambda表達式的參數中。這樣便可以推導出參數的類型,並且仍然可以使用註解,比如這樣:
(@Nonnull var x, var y) -> x.process(y)
而函數式程式語言中已經成為主流的一些做法也會陸續加入到未來的Java版本中來——比如說模式匹配和值類型。加入這些優化並非代表著Java不再是那個開發人員所熟知和喜愛的語言了。相反,Java會變為一門更靈活,可讀性更強,更簡潔的語言。