Java 特性聚焦:局部變量的類型推斷

本文要點

  • Java SE 10(2018年3月)引入了局部變量的類型推斷,這是最近Java最常被要求的特性之一。
  • 類型推斷是靜態類型語言使用的一種技術,編譯器可以根據上下文推斷出變量的類型。
  • Java 中的類型推斷是局部的;收集和約束作用域被限制在程序的一個狹小部分,比如單個表達式或語句。
  • Java 庫團隊的 Stuart Marks 已經編輯了一份有用的樣式風格指南和 FAQ,以幫助理解局部類型推斷的利弊。
  • 如果使用正確,類型推斷可以使代碼更簡潔且更具可讀性。

在 QCon New York 的 Java Futures 演講中,Java 語言架構師 Brian Goetz 帶領我們快速瀏覽了 Java 語言近期及未來的一些特性。在本文中,他將深入研究局部變量的類型推斷。

Java SE 10(2018年3月)引入了局部變量的類型推斷。在此之前,聲明局部變量需要顯式聲明類型。現在,類型推斷使編譯器能夠根據變量初始化值的類型來選擇變量的靜態類型:

var names = new ArrayList<String>();

在這個簡單的例子中,變量 names 的類型是 ArrayList

儘管在語法上類似於 JavaScript 中的相似特性,但這並不是動態類型,Java 中的所有變量仍然都是靜態類型。局部變量的類型推斷僅僅允許我們要求編譯器爲我們找出這個類型,而不用強制我們顯式地提供該類型。

Java中的類型推斷

類型推斷是靜態類型語言使用的一種技術,編譯器可以根據上下文推斷出變量的類型。各語言在類型推斷的使用和解析上各不相同。類型推斷通常可以爲程序員提供一個選項,而不是一種義務;我們可以自由地在顯式類型和推斷類型之間進行選擇,並且我們應該負責任地做出這個選擇,在可以增強可讀性的地方使用類型推斷,在可能造成混淆的地方避免使用類型推斷。

Java 中的類型名可以很長,原因可能是類名本身就很長,也可能是有複雜的泛型類型參數,或者兩者都有。編程語言的一個普遍事實是:類型越有趣,編碼就越無趣,這就是爲什麼類型系統越複雜的語言往往更依賴於類型推斷的原因。

Java 從 Java 5 開始引入了有限形式的類型推斷,其範圍在過去幾年中穩步擴大。在 Java 5 中,當引入泛型方法時,我們還引入了在使用現場推斷泛型類型參數的能力;我們通常按照如下形式進行編碼:

List<String> list = Collection.emptyList();

而不是提供顯式類型聲明:

List<String> list = Collection.<String>emptyList();

事實上,推斷的形式非常常見,以至於一些 Java 開發人員甚至從未見過顯式形式!

在 Java 7 中,我們擴展了類型推斷的範圍,以推斷泛型構造函數調用的類型參數(被稱爲“鑽石”調用);我們可以編寫如下代碼:

List<String> list = new ArrayList<>();

它是如下更顯式形式的縮寫

List<String> list = new ArrayList<String>();

在 Java 8 中,當引入 Lambda 表達式時,我們還引入了推斷 Lambda 表達式形參類型的能力。所以,我們可以編寫如下代碼:

list.forEach(s -> System.out.println(s))

它是如下更顯式形式的縮寫

list.forEach((String s) -> System.out.println(s))

在 Java 10 中,我們將類型推斷更進一步地擴展到了局部變量的聲明上。

一些開發人員認爲日常使用推斷類型比較好,因爲這樣可以使程序更簡潔;另一些開發人員認爲它會更糟,因爲它從視圖中刪除了潛在可能有用的信息。但是,這兩種觀點都有些片面。有時,被推斷的信息可能僅僅是一堆雜亂無章的信息,它們只會造成混亂(沒有人抱怨我們經常對泛型類型參數使用類型推斷),在這些情況下,類型推斷使我們的代碼更具可讀性。在其他情況下,類型信息提供了關於正在發生的事情的重要線索,或者反映了開發人員的創造性選擇;在這些情況下,最好堅持使用顯式類型。

雖然多年來我們一直在擴展類型推斷的範圍,但我們遵循的一個設計原則是:僅將類型推斷用於實現細節,而不是用於聲明 API 元素;字段、方法參數和方法返回值的類型必須始終是顯式類型,因爲我們不希望 API 契約根據實現中的變更而發生微妙地變化。但是,在方法體的內部實現中, 提供更多地可以根據可讀性來進行選擇的自由是合理的。

類型推斷是如何工作的?

類型推斷經常被誤解成是魔術或讀心術;開發人員經常將編譯器擬人化,並問“爲什麼編譯器不能弄清楚我想要什麼”。實際上,類型推斷是要簡單得多:約束求解。

不同的語言使用類型推斷的方式不同,但是對於所有語言來說基本概念都是相同的:收集未知類型的約束,並在某個時刻求解它們。語言設計人員可以自由決策:可以在哪些地方使用類型推斷、收集哪些約束以及在什麼作用域內求解約束。

Java 中的類型推斷是針對局部變量的;我們收集約束的作用域以及求解約束的時間被限制在程序的一個狹小部分中,比如單個表達式或語句。例如,對於局部變量,我們收集約束並求解的作用域是局部變量本身的聲明,而不考慮對該局部變量的其他賦值。其他語言則採用更全局的方法來進行類型推斷,它們在嘗試求解變量的類型之前,會考慮變量的所有用法。雖然乍一看這似乎會更好,因爲它更精確,但它通常更難使用。如果每次的使用都會影響變量的類型,那麼當出現錯誤時(例如,由於編程錯誤而導致的類型被過度約束),錯誤消息通常會毫無用處,並且它可能是在遠離被推斷類型的變量的聲明位置或錯誤使用變量的地方彈出。這些選擇說明了語言設計者在使用類型推斷時所面臨的基本權衡之一:我們總是會用精確性和預測能力來換取複雜性和可預測性。我們可以對算法進行調整,以提高編譯器“正確求解”的普及率(例如,通過收集更多的約束或在更大作用域內求解),但是當它失敗時,結果幾乎總是令人更不快。

舉個簡單的例子,考慮一個鑽石調用:

List<String> list = new ArrayList<>();

我們知道 list 的類型是 List,因爲它是一個顯式類型。我們試圖推斷 ArrayList 的類型參數,我們將它設成 x 。所以,右側的類型是 ArrayList。因爲我們將右側的賦值給了左側的,所以右側的類型必須是左側類型的子類型,所以我們可以收集到如下約束:

ArrayList<x> <: List<String>

其中 <:  表示“…的子類型”。( x 是一個泛型類型變量,它的隱式邊界是 Object,我們還可以從上述事實中收集到一個細微的邊界約束 x <: Object。)我們還可以從 ArrayList 的聲明中知道  List 是 ArrayList 的超類型。由此可知,我們可以推出邊界約束 x <: String(JLS 18.2.3),因爲這是我們對 x 的唯一約束,所以我們可以得出 x=String 的結論 。

下面是一個更復雜的例子:

List<String> list = ...
Set<String> set = ...
var v = List.of(list, set);

此處,右側是一個泛型方法調用,因此我們可以從 List 如下的方法中推斷出泛型類型參數:

public static <X> List<X> of(X... values)

在此,與預覽示例相比,我們有更多的信息需要處理:參數類型,它們是 List 和Set。因此,我們可以收集到如下的約束:

List<String> <: x
Set<String> <: x

給定這組約束,我們通過計算最小上界(JLS 4.10.4)來求解 x ,即最精確的類型是二者的超類型,在本例中是 Collection 。所以,v 的類型是 List<Collection>。

我們收集哪些約束?

在設計類型推斷算法時,一個關鍵的選擇是如何從程序中收集約束。對於某些程序結構(如賦值),右側的類型必須與左側的類型兼容,因此我們肯定能從中收集到約束。類似地,對於泛型方法的參數,我們可以從它們的類型中收集約束。但在某些情況下,我們可能會選擇忽略其他的信息來源。

起初,這聽起來很奇怪;收集更多的約束不是更好嗎?因爲這會導致更精確的答案。同樣地,精確性並非始終是最重要的目標;收集更多的約束也可能增加過度約束解決方案的可能性(在這種情況下,推斷將失敗,或是選擇一個諸如 Object 的備選答案),並且還會導致程序更加不穩定(實現中的微小變更可能會導致程序其他地方在輸入或重載解析方面的驚人變化)。正如要解決的問題一樣,我們要在精確性和預測能力與複雜性和可預測性之間進行權衡,這是一項主觀任務。

請考慮這樣一個相關的示例,來解釋有時應該忽略一些約束: 參數爲 Lambda 的方法,在重載時的類型解析。我們可以使用 Lambda 主體拋出的異常來縮小適用方法的範圍(更高的精度),但這也可能會使 Lambda 主體實現中的細微變更可以改變重載選擇的結果,這樣的結果會讓人感到奇怪(降低了可預測性),在這種情況下,精確性的提高並不能彌補可預測性的降低,因此在制定重載解析決策時不會考慮這一約束。

優雅的代碼

現在,我們已經在整體上了解了類型推斷的工作原理,接下來讓我們深入瞭解一下它是如何應用於局部變量聲明的。對於用 var 聲明的局部變量,我們首先會計算初始化值的獨立類型。(獨立類型是我們通過計算“自下而上”表達式的類型而得出的類型,它忽略了賦值目標。某些表達式是沒有獨立類型,比如 Lambda 和方法引用,因此它們不能作爲使用類型推斷的局部變量的初始化值。)

對於大多數表達式,我們只需使用初始化值的獨立類型作爲局部變量的類型。但是,在某些情況下,特別是當獨立類型不可表示時,我們可以改進或拒絕類型推斷。

不可表示類型是我們不能用對應語言的語法寫下來的類型。Java 中的不可表示類型包括交集類型( Runnable & Serializable )、捕獲類型(那些從通配符捕獲轉換派生出來的類型)、匿名類類型(匿名類創建表達式的類型)和空類型(null 文本的類型)。首先,我們考慮拒絕對所有不可表示類型的推斷,根據這個理論,var 應該只是顯式類型的簡寫。但是,事實證明,不可表示類型在實際程序中非常普遍,這樣的限制會使該特性的用處變少,這會更加令人沮喪。這意味着使用 var 的程序不一定僅僅是使用顯式類型程序的簡寫(有些程序可以用 var 表示,但又不能直接用它表示)。

作爲上述程序的一個示例,可以考慮匿名類聲明:

var v = new Runnable() {
    void run() { … }
    void runTwice() { run(); run(); }
};

v.runTwice();

如果我們要提供一個顯式類型(一個顯而易見的選擇是 Runnable ),runTwice() 方法將無法通過變量 v 來訪問,因爲它不是 Runnable 的成員。但是使用推斷類型,我們能夠推斷出匿名類創建表達式的更敏銳的類型,因此能夠訪問該方法。

每種不可表示類型都有它們自己的故事。對於空類型(此處是我們從 var x = null 推斷出的類型),我們簡單地拒絕了此類聲明。這是因爲駐留在空類型中的唯一值是 null ,而且它不太可能是一個只包含 null 的變量。因爲我們不想通過推斷 Object 或其他類型來“猜測”它的意圖,所以我們拒絕這種情況,以便開發人員能夠提供正確的類型。

對於匿名類類型和交集類型,我們只能使用推斷類型;這些類型怪異且新穎,但基本上是無害的。這意味着,我們現在可以更廣泛地接觸到一些“怪異”的類型了,這些類型之前一直在處於水平線之下。例如,假設我們有如下代碼:

var list = List.of(1, 3.14d);

這和上面的例子很像,所以我們知道結果是怎麼的(我們要獲取 Integer 和 Double 的最小上界)。結果發現它是一個比較難看的類型 Number & Comparable extends Number & Comparable>>。所以,list 的類型是 List<Number & Comparable extends Number & Comparable>>>。

正如我們所看到的那樣,即使是一個簡單的示例也會產生一些令人驚訝的複雜類型(它包含了一些不能顯式寫下來的類型)。

最棘手的問題是我們如何處理通配符捕獲類型。捕獲類型來自於泛型的黑暗角落;它們源於這樣一個事實:在程序中每次使用的 ? 都對應於一個不同的類型。考慮如下的方法聲明:

void m(List<?> a, List<?> b)

儘管 a 和 b 的類型在文本上是相同的,但它們實際上並不是同一類型,因爲我們沒有理由相信這兩個列表具有相同的元素類型。(如果我們想讓這兩個列表具有相同的類型,我們可以在 T 中構造一個 m() 泛型方法,並對這兩個列表使用 List 。)因此,每次在程序中使用 ? 時,編譯器都會創建一個佔位符,也叫做“捕獲”,所以我們可以將通配符的不同用法分開。到目前爲止,捕獲類型仍停留它們所述領域的黑暗之中,但如果我們允許它們逃離黑暗,它們可能會造成混亂。

例如,假設我們在 MyClass 類中編寫如下代碼:

var c = getClass();

我們可能會認爲 c 的類型是 Class>,但右側表達式的類型實際上是 Class> 。在程序中設置這樣的類型對任何人都沒有幫助。

一開始就禁止捕獲類型的推斷似乎是很有吸引力的,但是,在太多的情況下,這些類型都是突然出現。因此,我們選擇使用一種稱爲向上投影(JLS 4.10.5)的轉換來對它們進行清洗,該向上投影轉換接受一個可能包含捕獲類型的類型,並生成一個該類型的超類型,該超類型是不包含捕獲類型的。在上面的例子中,向上投影將 c 的類型清洗爲 Class<?> ,這是一種表現更好的類型。

清洗類型是一種實用的解決方案,但它並非沒有妥協。通過推斷一個不同於自然類型的表達式,這意味着如果我們使用“提取變量”重構將一個複雜表達式 f(e) 重構爲 var x = e; f(x) ,這可能會改變下游類型推斷或重載選擇決策。在大多數情況下,這都不是問題,但當我們修改“自然”類型表達式時,這就會成爲一個風險。在捕獲類型的情況下,治療的副作用要高於疾病本身。

意見分歧

與 Lambda 或泛型相比,局部變量的類型推斷是一個非常小的特性(不過,正如我們所看到的那樣,它的細節比大多數人認爲的都要複雜得多),但是,圍繞這個特性的爭論卻很激烈。

多年來,這都是 Java 最常被要求的特性之一;開發人員已經習慣了 C# 、Scala 或更高版本的Kotlin 中的這一特性,但當他們回到 Java 時卻非常懷念這一特性,所以他們對此頗有微詞。我們決定根據它的流行程度繼續向前推進,因爲它已經被證明可以在其他類似 Java 的語言中很好地運行,並且它與其他語言特性的交互範圍相對較小。

或許令人驚訝的是,當我們宣佈我們將繼續推進這一特性時,另一個聲音出現了,那些人顯然認爲這是他們所見過的最愚蠢的想法。它被描述爲“屈服於時尚”或“鼓勵懶惰”(甚至更糟),並且人們對無法閱讀的代碼的反烏托邦式的未來做出了可怕的預測。支持者和反對者都通過呼籲相同的核心價值來證明他們的立場:可讀性。

在發佈了這個特性之後,實際情況並沒有那麼可怕;雖然有一個初始的學習曲線,開發人員必須找到正確的方法來使用新特性(就像使用其他特性一樣),但是在大多數情況下,開發人員可以很容易地內化一些合理的指導原則,比如瞭解該特性何時可以增值,何時不可以,並據此使用它。

風格建議

Oracle Java 庫團隊的 Stuart Marks 編寫了一個很有用的風格指南,以幫助理解局部變量類型推斷的利弊。

與大多數嚴謹的風格指南一樣,本指南的重點是明確所涉及的權衡。顯式性是一種權衡;一方面,顯式類型提供了變量類型明確且精確的聲明,但另一方面,有時類型是明顯的或不重要的,顯式類型可能會與更重要的信息競爭,來吸引讀者的注意。

風格指南中概述的一般原則包括:

  • 選擇好的變量名。如果我們爲局部變量選擇表達性強的名稱,那麼類型聲明很可能就是不必要的了,甚至都不需要類型聲明。另一方面,如果我們選擇像 x 和 a3 這樣的變量名,那麼去掉類型信息很可能會使代碼更加難以理解。
  • 最小化局部變量的作用域。一個變量聲明與其使用之間的距離越大,我們就越有可能對其進行不精確的推理。使用 var 聲明作用域跨越多行的局部變量比那些具有較小作用域或具有顯式類型的局部變量更容易導致疏忽。
  • 當初始化值可以向讀者提供足夠的信息時可以考慮 var 。對於許多局部變量聲明,初始化表達式可以使它很清楚表達發生了什麼(例如  var names = new ArrayList() ),因此顯式類型的需求就減少了。
  • 無需太擔心“接口編程”。開發人員的一個共同擔憂是,長期以來我們都被鼓勵對變量使用抽象類型(如 List ),而不是更具體的實現類型(如 ArrayList),但是如果我們允許編譯器推斷類型,它將會推斷出更具體的類型。但是,我們也不必太擔心這個問題,因爲這個建議對於 API(比如方法返回類型)比實現中的局部變量更重要,特別是當我們遵循了前面關於保持較小作用域的建議時。
  • 注意 var 和鑽石推斷之間的交互作用。var 和鑽石符號都要求編譯器爲我們推斷類型,如果已經存在了足夠的類型信息來推斷所需的類型(比如構造函數參數的類型),那麼將它們放在一起使用是完全可以的。
  • 注意 var 與數值的結合。數值是多形表達式,這意味着它們的類型可以依賴於它們被賦值的類型。(例如,我們可以編寫簡短的 x = 0 ,而數值 0 與 int、long、short 和 byte 都是兼容的。)但是,如果沒有目標類型,數值的獨立類型是 int ,因此將簡短的 s = 0 變更爲 var s = 0 將會導致 s 的類型變更。
  • 使用 var 來分解鏈式或嵌套表達式。當爲子表達式聲明一個新的局部變量是一種負擔時,它增加了使用鏈式和/或嵌套創建複雜表達式的誘惑,有時會犧牲可讀性。通過降低聲明子表達式的成本,局部變量類型推斷可以降低錯誤操作的可能性,從而提高了可讀性。

最後一項說明了在關於編程語言特性的辯論中經常會忽略的一個重要的問題。在評估一個新特徵的後果時,我們通常只會考慮它可能被使用的最表層的方式;在局部變量類型推斷的情況下,它將用 var 替換現有程序中的顯式類型。但是,我們採用的編程風格受到許多因素的影響,包括各種構造結構的相對成本。如果我們降低了聲明局部變量的成本,那麼我們很有可能會在一個使用更多局部變量的地方重新實現平衡,這還有可能會使程序更具有可讀性。但是,人們在爭論一個特性是有益的還是有害的時,很少會考慮這種二階效應。

不管怎樣,這些指導原則中的很多都是好的風格建議,畢竟不管有沒有推斷,選擇好的變量名是使代碼更具可讀的最有效的方法之一。

總結

正如我們所看到的那樣,局部變量的類型推斷並不像它的語法所建議的那樣簡單;雖然有些人可能希望它讓我們忽略類型,但它實際上要求我們更好地理解 Java 的類型系統。但是,如果我們理解了它是如何工作的,並且遵循一些合理的風格指導原則,那麼它將有助於使我們的代碼更加簡潔且更具有可讀性。

作者介紹

Brian Goetz 是 Oracle 的 Java 語言架構師,同時也是 JSR-335(Java 編程語言的 Lambda 表達式)規範的負責人,他還是暢銷書《Java 併發編程實戰》的作者,自從 Jimmy Carter 擔任總統開始,他就已經對編程很着迷了。

原文鏈接:

https://www.infoq.com/articles/java-local-variable-type-inference/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章