用var宣告變數,在java中居然有這麼多細節,你都瞭解麼?

程式零世界發表於2020-08-04

簡介

Java SE 10引入了區域性變數的型別推斷。早先,所有的區域性變數宣告都要在左側宣告明確型別。 使用型別推斷,一些顯式型別可以替換為具有初始化值的區域性變數保留型別var,這種作為區域性變數型別 的var型別,是從初始化值的型別中推斷出來的。

關於此功能存在一定的爭議。有些人對它的簡潔性表示歡迎,其他人則擔心它剝奪了閱讀者看重的型別資訊 ,從而損害了程式碼的可讀性。這兩邊觀點都是對的。它可以通過消除冗餘資訊使程式碼更具有可讀性,也可以 通過刪除有用的資訊來降低程式碼的可讀性。另外一個觀點是擔心它會被濫用,從而導致編寫更糟糕的Java程式碼。 這也是事實,但它也可能會導致編寫更好的程式碼。像所有功能一樣,使用它必須要判斷。何時該使用, 何時不該使用,沒有一系列規則。

區域性變數宣告不是孤立存在的;周邊的程式碼可以影響甚至壓倒使用var的影響。本文件的目的是檢查周邊程式碼 對var宣告的影響,解釋一些權衡,並提供有效使用var的指南。

使用原則

P1. 閱讀程式碼比編寫程式碼更重要

程式碼的讀取頻率遠高於編寫程式碼。此外,在編寫程式碼時,我們通常要有整體思路,並且要花費時間; 在閱讀程式碼的時候,我們經常是上下文瀏覽,而且可能更加匆忙。是否以及如何使用特定語言功能應該取決 於它對未來讀者的影響,而不是它的作者。較短的程式可能比較長的程式更可取,但是過多地簡寫程式會 省略對於理解程式有用的資訊。這裡的核心問題是為程式找到合適的大小,以便最大化程式的可讀性。

我們在這裡特別關注在輸入或編寫程式時所需要的碼字量。雖然簡潔性可能會是對作者的一個很好的鼓勵,但 專注於它會忽略主要的目標,即提高程式的可讀性。

P2. 在經過區域性變數型別推斷後,程式碼應該變得清晰

讀者應該能夠檢視var宣告,並且使用區域性變數宣告的時候可以立刻了解程式碼里正在發生了什麼事情。理想 情況下,只通過程式碼片段或上下文就可以輕鬆理解程式碼。如果讀懂一個var宣告需要讀者去檢視程式碼周邊的 若干位置程式碼,此時使用var可能不是一個好的情況。而且,它還表明程式碼本身可能存在問題。

P3. 程式碼的可讀性不應該依賴於IDE

程式碼通常在IDE中編寫和讀取,所以很容易依賴IDE的程式碼分析功能。對於型別宣告,在任何地方使用var,都可以通過IDE指向一個 變數來確定它的型別,但是為什麼不這麼做呢?

這裡有兩個原因。程式碼經常在IDE外部讀取。程式碼出現在IDE設施不可用的許多地方,例如文件中的片段,瀏覽網際網路上的倉庫或補丁檔案 。為了理解這些程式碼的作用,你必須將程式碼匯入IDE。這樣做是適得其反的。

第二個原因是即使是在IDE中讀取程式碼時也是如此,通常需要明確的操作來查詢IDE以獲取有關變數的更多資訊。例如,查詢使用var宣告 的變數型別,可能必須將滑鼠懸停在變數上並等待彈出資訊,這可能只需要片刻時間,但是它會擾亂閱讀流程。

程式碼應該是自動呈現的,它的表面應該是可以理解的,並且無需工具的幫助。

P4. 顯式型別是一種權衡

Java歷來要求區域性變數宣告裡要明確包含顯式型別,顯然顯式型別可能非常有用,但它們有時候不是很重要,有時候還可以忽略。要求一個 明確的型別可能還會混亂一些有用的資訊。

省略顯式型別可以減少這種混亂,但只有在這種混亂不會損害其可理解性的情況下。這種型別不是向讀者傳達資訊的唯一方式。其他方法 包括變數的名稱和初始化表示式也可以傳達資訊。在確定是否可以將其中一個頻道靜音時,我們應該考慮所有可用的頻道。

指南
G1. 選擇能夠提供有用資訊的變數名稱
通常這是一個好習慣,不過在var的背景下它更為重要。在一個var的宣告中,可以使用變數的名稱來傳達有關變數含義和用法的資訊。 使用var替換顯式型別的同時也要改進變數的名稱。例如:

//原始寫法
List<Customer> x = dbconn.executeQuery(query);

//改進寫法
var custList = dbconn.executeQuery(query);

在這種情況下,無用的變數名x已被替換為一個能夠喚起變數型別的名稱custList,該名稱現在隱含在var的宣告中。 根據方法的邏輯結果對變數的型別進行編碼,得出了”匈牙利表示法”形式的變數名custList。 就像顯式型別一樣,這樣有時是有幫助的,有時候只是雜亂無章。在此示例中,名稱custList表示正在返回List。這也可能不重要。 和顯式型別不同,變數的名稱有時可以更好地表達變數的角色或性質,比如”customers”:

//原始寫法    
try (Stream<Customer> result = dbconn.executeQuery(query)) {
     return result.map(...)
                  .filter(...)
                  .findAny();
 }
//改進寫法
try (var customers = dbconn.executeQuery(query)) {
    return customers.map(...)
                    .filter(...)
                    .findAny();
}

G2.最小化區域性變數的使用範圍
最小化區域性變數的範圍通常也是一個好的習慣。這種做法在 Effective Java (第三版),第57項 中有所描述。 如果你要使用var,它會是一個額外的助力。

在下面的例子中,add方法正確地將特殊項新增到list集合的最後一個元素,所以它按照預期最後處理。

var items = new ArrayList<Item>(...);
items.add(MUST_BE_PROCESSED_LAST);
for (var item : items) ...

現在假設為了刪除重複的專案,程式設計師要修改此程式碼以使用HashSet而不是ArrayList:

var items = new HashSet<Item>(...);
items.add(MUST_BE_PROCESSED_LAST);
for (var item : items) ...

這段程式碼現在有個bug,因為Set沒有定義迭代順序。不過,程式設計師可能會立即修復這個bug,因為items變數的使用與其宣告相鄰。

現在假設這段程式碼是一個大方法的一部分,相應地items變數具有很大的使用範圍:

var items = new HashSet<Item>(...);

// ... 100 lines of code ...

items.add(MUST_BE_PROCESSED_LAST);
for (var item : items) ...

將ArrayList更改為HashSet的影響不再明顯,因為使用items的程式碼與宣告items的程式碼離得很遠。這意味著上面所說的bug似乎可以存活 更長時間。

如果items已經明確宣告為List,還需要更改初始化程式將型別更改為Set。這可能會提示程式設計師檢查方法的其餘部分 是否存在受此類更改影響的程式碼。(問題來了,他也可能不會檢查)。如果使用var會消除這類影響,不過也可能會增加在此類程式碼中 引入錯誤的風險。

這似乎是反對使用var的論據,但實際上並非如此。使用var的初始化程式非常精簡。僅當變數的使用範圍很大時才會出現此問題。 你應該更改程式碼來減少區域性變數的使用範圍,然後用var宣告它們,而不是簡單地避免在這些情況下使用var。

G3. 當初始化程式為讀者提供足夠的資訊時,請考慮使用var
區域性變數通常在建構函式中進行初始化。正在建立的建構函式名稱通常與左側顯式宣告的型別重複。 如果型別名稱很長,就可以使用var提升簡潔性而不會丟失資訊:

// 原始寫法:
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
// 改進寫法
var outputStream = new ByteArrayOutputStream();

在初始化程式是方法呼叫的情況下,使用var也是合理的,例如靜態工廠方法,並且其名稱包含足夠的型別資訊:

//原始寫法
BufferedReader reader = Files.newBufferedReader(...);
List<String> stringList = List.of("a", "b", "c");
// 改進寫法
var reader = Files.newBufferedReader(...);
var stringList = List.of("a", "b", "c"); 

在這些情況下,方法的名稱強烈暗示其特定的返回型別,然後用於推斷變數的型別。

G4. 使用var區域性變數分解鏈式或巢狀表示式
考慮使用字串集合並查詢最常出現的字串的程式碼,可能如下所示:

return strings.stream()
               .collect(groupingBy(s -> s, counting()))
               .entrySet()
               .stream()
               .max(Map.Entry.comparingByValue())
               .map(Map.Entry::getKey);

這段程式碼是正確的,但它可能令人困惑,因為它看起來像是一個單一的流管道。事實上,它先是一個短暫的流,接著是第一個流的結果生成 的第二個流,然後是第二個流的可選結果對映後的流。表達此程式碼的最易讀的方式是兩個或三個語句; 第一組實體放入一個Map,然後第二組過濾這個Map,第三組從Map結果中提取出Key,如下所示:

Map<String, Long> freqMap = strings.stream()
                                   .collect(groupingBy(s -> s, counting()));
Optional<Map.Entry<String, Long>> maxEntryOpt = freqMap.entrySet()
                                                       .stream()
                                                       .max(Map.Entry.comparingByValue());
return maxEntryOpt.map(Map.Entry::getKey);

但編寫者可能會拒絕這樣做,因為編寫中間變數的型別似乎太過於繁瑣,相反他們篡改了控制流程。使用var允許我們更自然地表達程式碼 ,而無需付出顯式宣告中間變數型別的高代價:

var freqMap = strings.stream()
                     .collect(groupingBy(s -> s, counting()));
var maxEntryOpt = freqMap.entrySet()
                         .stream()
                         .max(Map.Entry.comparingByValue());
return maxEntryOpt.map(Map.Entry::getKey);

有些人可能更傾向於第一段程式碼中單個長的鏈式呼叫。但是,在某些條件下,最好分解長的方法鏈。對這些情況使用var是一種可行的 方法,而在第二個段中使用中間變數的完整宣告會是一個不好的選擇。 和許多其他情況一樣,正確使用var可能會涉及到扔掉一些東西 (顯示型別)和加入一些東西(更好的變數名稱,更好的程式碼結構)。

G5. 不用過分擔心”使用介面程式設計” 中區域性變數的使用問題

Java程式設計中常見的習慣用法是構造具體型別的例項,但要將其分配給介面型別的變數。這將程式碼繫結到抽象上而不是具體實現上,為 程式碼以後的維護保留了靈活性。

//原始寫法, list型別為介面List型別
List<String> list = new ArrayList<>()

如果使用var,可以推斷出list具體的實現型別ArrayList而不是介面型別List

// 推斷出list的型別是 ArrayList<String>.
var list = new ArrayList<String>(); 

這裡要再次重申一次,var只能用於區域性變數。它不能用於推斷屬性型別,方法引數型別和方法返回型別。”使用介面程式設計”的原則在這些 情況下仍然和以往一樣重要。

主要問題是使用該變數的程式碼可以形成對具體實現的依賴性。如果變數的初始化程式以後要改變,這可能導致其推斷型別發生變化,在 使用該變數的後續程式碼中產生異常或bug。

如果,如指南G2中所建議的那樣,區域性變數的範圍很小,可能影響後續程式碼的具體實現的”漏洞”是有限的。如果變數僅用於幾行之外的 程式碼,應該很容易避免這些問題或者緩解這些出現的問題。

在這種特殊情況下,ArrayList只包含一些不在List上的方法,如ensureCapacity()和trimToSize()。這些方法不會影響到List,所以 呼叫他們不會影響程式的正確性。這進一步降低了推斷型別作為具體實現型別而不是介面型別的影響。

G6. 使用帶有<>和泛型方法的var時候要小心

var和<>功能允許您在可以從已存在的資訊派生時,省略具體的型別資訊。你能在同一個變數宣告中使用它們嗎?

考慮一下以下程式碼:

PriorityQueue<Item> itemQueue = new PriorityQueue<Item>();

這段程式碼可以使用var或<>重寫,並且不會丟失型別資訊:

// 正確:兩個變數都可以宣告為PriorityQueue<Item>型別
PriorityQueue<Item> itemQueue = new PriorityQueue<>();
var itemQueue = new PriorityQueue<Item>();   

同時使用var和<>是合法的,但推斷型別將會改變:

// 危險: 推斷型別變成了 PriorityQueue<Object>
var itemQueue = new PriorityQueue<>();

從上面的推斷結果來看,<>可以使用目標型別(通常在宣告的左側)或建構函式作為<>裡的引數型別。如果兩者都不存在,它會追溯到 最寬泛的適用型別,通常是Object。這通常不是我們預期的結果。

泛型方法早已經提供了型別推斷,使用泛型方法很少需要提供顯式型別引數,如果是沒有提供足夠型別資訊的實際方法引數,泛型方法 的推斷就會依賴於目標型別。在var宣告中沒有目標型別,所以也會出現類似的問題。例如:

// 危險: list推斷為 List<Object>
var list = List.of();

使用<>和泛型方法,可以通過建構函式或方法的實際引數提供其他型別資訊,允許推斷出預期的型別,從而有:

// 正確: itemQueue 推斷為 PriorityQueue<String>
Comparator<String> comp = ... ;
var itemQueue = new PriorityQueue<>(comp);

// 正確: infers 推斷為 List<BigInteger>
var list = List.of(BigInteger.ZERO);

如果你想要將var與<>或泛型方法一起使用,你應該確保方法或建構函式引數能夠提供足夠的型別資訊,以便推斷的型別與你想要的型別匹配。 否則,請避免在同一宣告中同時使用var和<>或泛型方法。

G7.使用基本型別的var要小心

基本型別可以使用var宣告進行初始化。在這些情況下使用var不太可能提供很多優勢,因為型別名稱通常很短。不過,var有時候也很有用 ,例如,可以使變數的名稱對齊。

boolean,character,long,string的基本型別使用var沒有問題,這些型別的推斷是精確的,因為var的含義是明確的

// 原始做法
boolean ready = true;
char ch = '\ufffd';
long sum = 0L;
String label = "wombat";

// 改進做法
var ready = true;
var ch    = '\ufffd';
var sum   = 0L;
var label = "wombat";

當初始值是數字時,應該特別小心,特別是int型別。如果左側有顯示型別,那麼右側會通過向上或向下轉型將int數值默默轉為 左邊對應的型別。如果左邊使用var,那麼右邊的值會被推斷為int型別。這可能是無意的。

// 原始做法
byte flags = 0;
short mask = 0x7fff;
long base = 17;

// 危險: 所有的變數型別都是int
var flags = 0;
var mask = 0x7fff;
var base = 17;  

如果初始值是浮點型,推斷的型別大多是明確的:

// 原始做法
float f = 1.0f;
double d = 2.0;

// 改進做法
var f = 1.0f;
var d = 2.0;

注意,float型別可以默默向上轉型為double型別。使用顯式的float變數(如3.0f)為double變數做初始化會有點遲鈍。不過, 如果是使用var對double變數用float變數做初始化,要注意:

// 原始做法
static final float INITIAL = 3.0f;
...
double temp = INITIAL;

// 危險: temp被推斷為float型別了
var temp = INITIAL;  

(實際上,這個例子違反了G3準則,因為初始化程式裡沒有提供足夠的型別資訊能讓讀者明白其推斷型別。)

示例

本節包含一些示例,這些例子中使用var可以收到良好的效果。

下面這段程式碼表示的是根據最多匹配數max從一個Map中移除匹配的實體。萬用字元(?)型別邊界可以提高方法的靈活性,但是長度會很長 。不幸的是,這裡Iterator的型別還被要求是一個巢狀的萬用字元型別,這樣使它的宣告更加的冗長,以至於for迴圈標題長度在一行裡 都放不下。也使程式碼更加的難懂。

// 原始做法
void removeMatches(Map<? extends String, ? extends Number> map, int max) {
    for (Iterator<? extends Map.Entry<? extends String, ? extends Number>> iterator =
             map.entrySet().iterator(); iterator.hasNext();) {
        Map.Entry<? extends String, ? extends Number> entry = iterator.next();
        if (max > 0 &amp;&amp; matches(entry)) {
            iterator.remove();
            max--;
        }
    }
}  

在這裡使用var就可以刪除掉區域性變數一些干擾的型別宣告。在這種迴圈中顯式的型別Iterator,Map.Entry在很大程度上是沒有必要的,
使用var宣告就能夠讓for迴圈標題放在同一行。程式碼也更易懂。

// 改進做法
void removeMatches(Map<? extends String, ? extends Number> map, int max) {
    for (var iterator = map.entrySet().iterator(); iterator.hasNext();) {
        var entry = iterator.next();
        if (max > 0 &amp;&amp; matches(entry)) {
            iterator.remove();
            max--;
        }
    }
}

考慮使用try-with-resources語句從Socket讀取單行文字的程式碼。網路和IO類一般都是裝飾類。在使用時,必須將每個中間物件宣告為 資源變數,以便在開啟後續的裝飾類的時候能夠正確關閉這個資源。常規編寫程式碼要求在變數左右宣告重複的類名,這樣會導致很多混亂:

// 原始做法
try (InputStream is = socket.getInputStream();
     InputStreamReader isr = new InputStreamReader(is, charsetName);
     BufferedReader buf = new BufferedReader(isr)) {
    return buf.readLine();
}  

使用var宣告會減少很多這種混亂:

// 改進做法
try (var inputStream = socket.getInputStream();
     var reader = new InputStreamReader(inputStream, charsetName);
     var bufReader = new BufferedReader(reader)) {
    return bufReader.readLine();
}

結語

使用var宣告可以通過減少混亂來改善程式碼,從而讓更重要的資訊脫穎而出。另一方面,不加選擇地使用var也會讓事情變得更糟。使用 得當,var有助於改善良好的程式碼,使其更短更清晰,同時又不影響可理解性。

私信回覆 “資料” 領取一線大廠Java面試題總結+阿里巴巴泰山手冊+各知識點學習思維導+一份300頁pdf文件的Java核心知識點總結!

這些資料的內容都是面試時面試官必問的知識點,篇章包括了很多知識點,其中包括了有基礎知識、Java集合、JVM、多執行緒併發、spring原理、微服務、Netty 與RPC 、Kafka、日記、設計模式、Java演算法、資料庫、Zookeeper、分散式快取、資料結構等等。

file

相關文章