Java坑人面試題系列: 包裝類(中級難度)

鐵錨發表於2020-02-09

Java Magazine上面有一個專門坑人的面試題系列: https://blogs.oracle.com/javamagazine/quiz-2

這些問題的設計宗旨,主要是測試面試者對Java語言的瞭解程度,而不是為了用彎彎繞繞的手段把面試者搞蒙。

如果你看過往期的問題,就會發現每一個都不簡單。

這些試題模擬了認證考試中的一些難題。 而 “中級(intermediate)” 和 “高階(advanced)” 指的是試題難度,而不是說這些知識本身很深。 一般來說,“高階”問題會稍微難一點。

先思考一個簡單的問題: 兩個 Integer 包裝類物件。 怎樣比較它們的值是否相等,有哪些方法?

問題(中級難度)

在開發中我們經常會使用包裝類(例如 Boolean, Double, 以及 Integer 等等)。

請看下面的程式碼片段:

String one = "1";
Boolean b1 = Boolean.valueOf(one);  // line n1
Integer i1 = new Integer(one);
Integer i2 = 1;
if (b1) {
    System.out.print(i1 == i2);
}

執行結果是什麼, 請選擇:

  • A、 丟擲執行時異常
  • B、 true
  • C、 false
  • D、 無任何輸出

答案和解析

這個問題考察原生資料的包裝類(primitive wrapper),主要是 Boolean 類比較生僻的 valueOf 工廠方法。
在認證考試和麵試中,這個問題可能不太容易碰到,因為主要還是靠死記硬背, 大部分考試都會避免此類問題。
但是,這個問題從多個方面綜合考察了面試者對Java語言的理解和認識水平, 有一點小坑,但關鍵在於解答的過程。

包裝類主要提供了三種獲取物件例項的方法:

    1. 每個包裝類都有名為 valueOf 的靜態工廠方法。
    1. 如果語義很清晰, 在程式碼中將原生資料型別賦值給包裝類的變數,則會發生自動裝箱 (autoboxing)。 自動裝箱只是語法上的簡寫,它允許編譯器 (javac) 自動呼叫valueOf方法, 目的是為了編碼更簡潔。
    1. 第三種方法是使用構造器, 也就是通過 new 關鍵字來呼叫建構函式。 實際上,在 Java 9 中已經不推薦使用第三種方法, 而本文的一個目標是解釋為什麼不贊成使用它。

在Java中,只要使用 new 關鍵字呼叫建構函式,只會發生兩種情況: 要麼成功建立指定型別的新物件並返回,要麼就拋異常。
這實際上是一個限制,如今一般是推薦使用工廠方法, 因為工廠方法除了達成建構函式的效果之外, 還會有一些優化。

工廠方法的有些功能是用建構函式實現不了的: 比如返回與請求引數相匹配的已快取的例項物件。
因為 Integer 包裝器是不可變的, 表示相同數值的兩個Integer物件一般是可以互換的。
因此,建立多個表示相同值的物件例項會浪費記憶體。
很多情況下,工廠方法返回的兩個物件允許使用 == 來比較, 而不必每次都寫成 equals(Object o) 這種方式。
對於 Integer 類來說,一般只快取了 -128 到 +127 範圍內的值。

這種行為類似於在編碼中直接使用 "XXX" 這種字面量表示方式, 而不是 new String("XXX")

工廠方法更加靈活:

  • 如果有多個工廠方法,則每個方法都可以使用不同的名稱,因為名稱不同,也就可以使用相同的入參宣告。
  • 對於建構函式而言,因為必須引數型別不同才能形成過載,也就不可能根據同樣的引數構造不同的物件。

第三個優點是, Java中用 new 呼叫建構函式只能返回固定型別的物件。
而用工廠方法則可以返回相容的各種型別物件例項(例如介面的實現類,而且這是一種隱藏實現細節的絕佳方法)。

回到這個問題,最關鍵的地方在於, 我們使用 Boolean.valueOf(...) 方法時, 只會得到兩個常量物件: Boolean.TRUEBoolean.FALSE
這兩個物件可以被重複利用,不會浪費多餘的記憶體。 如果使用 new 呼叫顯然是不可能的。

大部分包裝類的工廠方法, 如果傳入了 null 引數, 或者字串引數不符合目標值的表現形式就會丟擲異常,例如,Integer.valueOf("six") 就會拋異常。

java.lang.Boolean 類的工廠方法是個特例, 內部實現判斷的是非空(null)並且等於 “true”(忽略大小寫)。

內部實現如下所示:

public static boolean parseBoolean(String s) {
    return ((s != null) && s.equalsIgnoreCase("true"));
}

如果滿足這兩個條件則返回 Boolean.TRUE
否則直接返回 Boolean.FALSE
這意味著: 如果傳入 null 或者無意義的字串, 則會返回 Boolean.FALSE,並不會丟擲異常。

基於這點,我們可以確定 n1 行那裡不會丟擲異常,而是返回 Boolean.FALSE, 被賦值給變數 b1
因此,可以確定 選項A不正確

然後我們看一下 if 語句和裡面的比較程式碼。

一般來說 if 語句小括號中的表示式必須是 boolean 型別。
顯然,這裡會自動將 Boolean 物件進行拆箱操作, 變為 boolean 型別。
這算是Java的基礎知識,當然,如果在 Java 5 之前的版本這樣寫, 程式碼確實會無法編譯。
即使有這樣的擔憂,但因為沒有【編譯錯誤】的選項,所以我們不關注這個問題。

在這種情況下,我們已經確定 b1 所引用的物件值相當於 false。 因此,if 判斷不通過,裡面的程式碼不會被執行。
所以我們可以確定 選項D是正確的

雖然我們已經確定 if 語句內部的程式碼沒有執行,但是面試過程中可能會問到: 如果執行了呢,又是什麼結果。

Java語言中有兩種形式的相等比較。

  • 第一種是 == 運算子,是Java語法的一部分。
  • 第二種是 equals(Object o) 方法,本質上是一個API。

每個物件都可以使用 equals(Object o) 方法,因為這個方法是在 java.lang.Object 類中定義的。
除非某個類覆寫了equals方法,否則這個方法一般不定返回 true
下面我們主要討論 == 運算子,如果對 equals 方法的實現感興趣, 請參考: Java中hashCode與equals方法的約定及重寫原則

== 運算子比較兩個表示式的值。
聽起來很簡單,但是表示式的值可能有兩種不同的型別。這兩種型別使用 == 的結果可能會不同。
順便說一下,這裡故意使用術語“表示式”, 而變數是一種簡單的表示式。

表示式主要有兩種型別:

  • 原生資料型別/基本資料型別 (primitive, 共8種: boolean, byte, short, char, int, long, float, double)
  • 引用型別(reference)。 引用類似於指標, 表示記憶體中某個物件的地址值(可以認為是一個偏移量數值)。

如果表示式是原生資料型別,則表示式的值很直觀。 例如,如果 int 表示式的值為 32,則該表示式的值就是32的二進位制表示形式。

但問題是,如果變數是引用型別呢(例如,Integer 型別), 它所引用物件內部的值為32,那麼這個引用的值 並不是32
而是一個神祕的數字(引用地址),通過這個引用地址,JVM可以找到對應的 Integer 物件。

也就是說,對於引用型別(即除了8種原生資料型別之外的所有型別), == 表示式判斷的是這兩個引用的記憶體地址值是否相等,即判斷它們是否引用了同一個物件。
最重要的是,即使兩個 Integer 物件裡面的值都是 32,但如果它們是不同的物件, 那麼它們的引用地址也就不同,使用==比較會返回 false

這一點應該很好理解,再看下面這樣的程式碼:

Integer v1 = new Integer("1");
Integer v2 = new Integer("1");
System.out.print(v1 == v2);

這裡的輸出肯定是 false
前面提到過,new 關鍵字的任何呼叫,要麼產生一個新物件, 要麼拋異常。
這意味著 v2v1 引用了不同的物件,== 操作的結果為 false

換一種方式,如果有以下程式碼:

Integer v1 = new Integer("1");
Integer v2 = 1;
System.out.print(v1 == v2);

這與面試題中的程式碼很像,一個使用建構函式, 一個使用自動裝箱,可以肯定這也會輸出 false
建構函式建立的物件必定是唯一的新物件,因此,不可能 == 自動裝箱為工廠方法返回的物件。

不可變物件的工廠方法一般都會有特殊處理,只要在一個範圍內,並且引數相等,就返回同一個(快取的)物件。

Integer 類的API文件中,對 valueOf(int) 方法有如下說明:

“此方法將始終快取 [-128 ~ 127] 範圍內的值, 可能還會快取這個範圍之外的其他值。”

Integer v1 = Integer.valueOf(1);
Integer v2 = Integer.valueOf(1);
System.out.print(v1 == v2);

也就是說,上面這段程式碼肯定會輸出 true

雖然只在 valueOf(int)valueOf(String) 方法的文件說明中提到了這個快取保證。
但在實際的實現中, 其他包裝類也表現出相同的快取行為。

當然,這裡討論了兩個 Integer 物件: 一個是使用建構函式建立,另一個是使用自動裝箱建立(Integer.valueOf(int) 方法)。
假如我們稍微改變一下面試題中 if 語句,則輸出內容將為 false

總結: 本文開始提到的面試題, 選項D是正確答案。 這裡只是附帶的討論。

相關連結

原文連結: https://blogs.oracle.com/javamagazine/quiz-intermediate-wrapper-classes

相關文章