面試題系列:用了這麼多年的 Java 泛型,我竟然只知道它的皮毛

面試題:說說你對泛型的理解?

面試考察點

考察目的:了解求職者對於Java基礎知識的掌握程度。

考察範圍:工作1-3年的Java程式設計師。

背景知識

Java中的泛型,是JDK5引入的一個新特性。

它主要提供的是編譯時期類型的安全檢測機制。這個機制允許程式在編譯時檢測到非法的類型,從而進行錯誤提示。

這樣做的好處,一方面是告訴開發者當前方法接收或返回的參數類型,另一方面是避免程式運行時的類型轉換錯誤。

泛型的設計推演

舉一個比較簡單的例子,首先我們來看一下ArrayList這個集合,部分程式碼定義如下。

public class ArrayList{
   transient Object[] elementData; // non-private to simplify nested class access
}

在ArrayList中,存儲元素所使用的結構是一個Object[]對象數組。意味著可以存儲任何類型的數據。

當我們使用這個ArrayList來做下面這個操作時。

public class ArrayExample {

    public static void main(String[] args) {
        ArrayList al=new ArrayList();
        al.add("Hello World");
        al.add(1001);
        String str=(String)al.get(1);
        System.out.println(str);
    }
}

運行程式後,會得到如下的執行結果

Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String
	at org.example.cl06.ArrayExample.main(ArrayExample.java:11)

這種類型轉換錯誤,相信大家在開發中有遇到過,總的來說,在沒有泛型的情況下,會有兩個比較嚴重的問題

  1. 需要對類型進行強制轉換
  2. 使用不方便,容易出錯

怎麼解決上面這個問題呢?要解決這個問題,就得思考這個問題背後的需求是什麼?

我簡單總結兩點:

  1. 要能支援不同類型的數據存儲
  2. 還需要保證存儲數據類型的統一性

基於這兩個點不難發現,對於一個數據容器中要存儲什麼類型的數據,其實是由開發者自己決定的。因此,為了解決這個問題,在JDK5中就引入了泛型的機制。

其定義形式是:ArrayList<E>,它相當於給ArrayList提供了一個類型輸入的模板EE可以是任意類型的對象,它的定義方式如下。

public class ArrayList<E>{
   transient E[] elementData; // non-private to simplify nested class access
}

在ArrayList這個類的定義中,使用<>語法,並傳入一個用來表示任意類型的對象E,這個E可以隨便定義,你可以定義成ABC都可以。

接著,把用來存儲元素的數組elementData的類型,設置為E類型。

有了這個配置之後,ArrayList這個容器中,你想存儲什麼類型的數據,是由使用者自己決定,比如我希望ArrayList只存儲String類型,那麼它可以這麼實現

public class ArrayExample {

    public static void main(String[] args) {
        ArrayList<String> al=new ArrayList();
        al.add("Hello World");
        al.add(1001);
        String str=(String)al.get(1);
        System.out.println(str);
    }
}

在定義ArrayList時,傳入一個String類型,這樣寫意味著後續往ArrayList這個實例對象al中添加元素,必須是String類型,否則會提示如下的語法錯誤。

image-20211104235945672

同理,如果需要保存其他類型的數據,可以這麼寫:

  1. ArrayList
  2. ArrayList

總結:所謂泛型定義,其實本質上就是一種類型模板,在實際開發中,我們把一個容器或者一個對象中需要保存的屬性的類型,通過模板定義的方式,給到調用者來決定,從而保證了類型的安全性。

泛型的定義

泛型定義可以從兩個維度來說明:

  1. 泛型類
  2. 泛型方法

泛型類

泛型類指的是在類名後面添加一個或多個類型參數,一個泛型參數,也被稱為一個類型變數,是用於指定一個泛型類型名稱的標識符。因為他們接受一個或多個參數,這些類被稱為參數化的類或參數化的類型。

類型變數的表示標記,常用的是:E(element)T(type)K(key)V(value)N(number)等,這只是一個表示符號,可以是任何字元,沒有強制要求。

下面的程式碼是關於泛型類的定義。

該類接收一個T標記符的類型參數,該類中有一個成員變數,使用T類型。

public class Response <T>{
    
    private T data;

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }
}

使用方式如下:

public static void main(String[] args) {
  Response<String> res=new Response<>();
  res.setData("Hello World");
}

泛型方法

泛型方法是指指定方法級別的類型參數,這個方法在調用時可以接收不同的參數類型,根據傳遞給泛型方法的參數類型,編譯器適當地處理每一個方法調用。

下面的程式碼表示泛型方法的定義,用到了JDK提供的反射機制,來生成動態代理類。

public interface IHelloWorld {

    String say();
}

定義getProxy方法,它用來生成動態代理對象,但是傳遞的參數類型是T,也就是說,這個方法可以完成任意介面的動態代理實例的構建。

在這裡,我們針對IHelloWorld這個介面,構建了動態代理實例,程式碼如下。

public class ArrayExample implements InvocationHandler {

    public <T> T getProxy(Class<T> clazz){
        // clazz 不是介面不能使用JDK動態代理
        return (T) Proxy.newProxyInstance(clazz.getClassLoader(), new Class<?>[]{ clazz }, ArrayExample.this);
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        return "Hello World";
    }

    public static void main(String[] args) {
        IHelloWorld hw=new ArrayExample().getProxy(IHelloWorld.class);
        System.out.println(hw.say());
    }
}

運行結果:

Hello World

關於泛型方法的定義規則,簡單總結如下:

  1. 所有泛型方法的定義,都有一個用<>表示的類型參數聲明,這個類型參數聲明部分在方法返回類型之前。
  2. 每一個類型參數聲明部分包含一個或多個類型參數,參數間用逗號隔開。一個泛型參數,也被稱為一個類型變數,是用於指定一個泛型類型名稱的標識符。
  3. 類型參數能被用來聲明返回值類型,並且能作為泛型方法得到的實際參數類型的佔位符
  4. 泛型方法體的聲明和其他方法一樣。注意類型參數只能代表引用型類型,不能是原始類型(像 int、double、char 等)。##

多類型變數定義

上在我們只定義了一個泛型變數T,那如果我們需要傳進去多個泛型要怎麼辦呢?

我們可以這麼寫:

public class Response <T,K,V>{
}

每一個參數聲明符號代表一種類型。

注意,在多變數類型定義中,泛型變數最好是定義成能夠簡單理解具有含義的字元,否則類型太多,調用者比較容易搞混。

有界類型參數

在有些場景中,我們希望傳遞的參數類型屬於某種類型範圍,比如,一個操作數字的方法可能只希望接受Number或者Number子類的實例,怎麼實現呢?

泛型通配符上邊界

上邊界,代表類型變數的範圍有限,只能傳入某種類型,或者它的子類。

我們可以在泛型參數上,增加一個extends關鍵字,表示該泛型參數類型,必須是派生自某個實現類,示例程式碼如下。

public class TypeExample<T extends Number> {
    private T t;

    public T getT() {
        return t;
    }

    public void setT(T t) {
        this.t = t;
    }

    public static void main(String[] args) {
        TypeExample<String> t=new TypeExample<>();
    }
}

上述程式碼,聲明了一個泛型參數T,該泛型參數必須是繼承Number這個類,表示後續實例化TypeExample時,傳入的泛型類型應該是Number的子類。

所以,有了這個規則後,上面這個測試程式碼,會提示java: 類型參數java.lang.String不在類型變數T的範圍內錯誤。

泛型通配符下邊界

下邊界,代表類型變數的範圍有限,只能傳入某種類型,或者它的父類。

我們可以在泛型參數上,增加一個super關鍵字,可以設定泛型通配符的上邊界。實例程式碼如下。

public class TypeExample<T> {
    private T t;

    public T getT() {
        return t;
    }
    public void setT(T t) {
        this.t = t;
    }
    public static void say(TypeExample<? super Number> te){
        System.out.println("say: "+te.getT());
    }
    public static void main(String[] args) {
        TypeExample<Number> te=new TypeExample<>();
        TypeExample<Integer> te2=new TypeExample<>();
        say(te);
        say(te2);
    }
}

say方法上聲明TypeExample<? super Number> te,表示傳入的TypeExample的泛型類型,必須是Number以及Number的父類類型。

在上述程式碼中,運行時會得到如下錯誤:

java: 不兼容的類型: org.example.cl06.TypeExample<java.lang.Integer>無法轉換為org.example.cl06.TypeExample<? super java.lang.Number>

如下圖所示,表示Number這個類的類關係圖,通過super關鍵字限定後,只能傳遞Number以及父類Serializable

image-20211105101105344

類型通配符?

類型通配符一般是使用 ? 代替具體的類型參數。例如 List<?> 在邏輯上是 List,List 等所有 List<具體類型實參> 的父類。

來看下面這段程式碼的定義,在say方法中,接受一個TypeExample類型的參數,並且泛型類型是<?>,代表接收任何類型的泛型類型參數。

public class TypeExample<T> {
    private T t;

    public T getT() {
        return t;
    }

    public void setT(T t) {
        this.t = t;
    }
    public static void say(TypeExample<?> te){
        System.out.println("say: "+te.getT());
    }
    public static void main(String[] args) {
        TypeExample<Integer> te1=new TypeExample<>();
        te1.setT(1111);
        TypeExample<String> te2=new TypeExample<>();
        te2.setT("Hello World");
        say(te1);
        say(te2);
    }
}

運行結果如下

say: 1111
say: Hello World

同樣,類型通配符的參數,也可以通過extends來做限定,比如:

public class TypeExample<T> {
    private T t;

    public T getT() {
        return t;
    }

    public void setT(T t) {
        this.t = t;
    }
    public static void say(TypeExample<? extends Number> te){ //修改,增加extends
        System.out.println("say: "+te.getT());
    }
    public static void main(String[] args) {
        TypeExample<Integer> te1=new TypeExample<>();
        te1.setT(1111);
        TypeExample<String> te2=new TypeExample<>();
        te2.setT("Hello World");
        say(te1);
        say(te2);
    }
}

由於say方法中的參數TypeExample,在泛型類型定義中使用了<? extends Number>,所以後續在傳遞參數時,泛型類型必須是Number的子類型。

因此上述程式碼運行時,會提示如下錯誤:

java: 不兼容的類型: org.example.cl06.TypeExample<java.lang.String>無法轉換為org.example.cl06.TypeExample<? extends java.lang.Number>

注意: 構建泛型實例時,如果省略了泛型類型,則默認是通配符類型,意味著可以接受任意類型的參數。

泛型的繼承

泛型類型參數的定義,是允許被繼承的,比如下面這種寫法。

表示子類SayResponse和父類Response使用同一種泛型類型。

public class SayResponse<T> extends Response<T>{
    private T ox;
}

JVM是如何實現泛型的?

在JVM中,採用了類型擦除Type erasure generics)的方式來實現泛型,簡單來說,就是泛型只存在.java源碼文件中,一旦編譯後就會把泛型擦除.

我們來看ArrayExample這個類,編譯之後的位元組指令。

public class ArrayExample implements InvocationHandler {

    public <T> T getProxy(Class<T> clazz){
        // clazz 不是介面不能使用JDK動態代理
        return (T) Proxy.newProxyInstance(clazz.getClassLoader(), new Class<?>[]{ clazz }, ArrayExample.this);
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        return "Hello World";
    }

    public static void main(String[] args) {
        IHelloWorld hw=new ArrayExample().getProxy(IHelloWorld.class);
        System.out.println(hw.say());
    }
}

通過javap -v ArrayExample.class查看位元組指令如下。

 public <T extends java.lang.Object> T getProxy(java.lang.Class<T>);
    descriptor: (Ljava/lang/Class;)Ljava/lang/Object;
    flags: ACC_PUBLIC
    Code:
      stack=5, locals=2, args_size=2
         0: aload_1
         1: invokevirtual #2                  // Method java/lang/Class.getClassLoader:()Ljava/lang/ClassLoader;

可以看到,getProxy在編譯之後,泛型T已經被擦除了,參數類型替換成了java.lang.Object.

並不是所有類型都會轉換為java.lang.Object,比如如果是,則參數類型是java.lang.String。

同時,為了保證IHelloWorld hw=new ArrayExample().getProxy(IHelloWorld.class);這段程式碼的準確性,編譯器還會在這裡插入一個類型轉換的機制。

下面這個程式碼是ArrayExample.class反編譯之後的呈現。

IHelloWorld hw = (IHelloWorld)(new ArrayExample()).getProxy(IHelloWorld.class);
System.out.println(hw.say());

泛型類型擦除實現帶來的缺陷

擦除方式實現泛型,還是會存在一些缺陷的,簡單舉幾個案例說明。

不支援基本類型

由於泛型類型擦除後,變成了java.lang.Object類型,這種方式對於基本類型如int/long/float等八種基本類型來說,就比較麻煩,因為Java無法實現基本類型到Object類型的強制轉換。

 ArrayList<int> list=new ArrayList<int>();

如果這麼寫,會得到如下錯誤

java: 意外的類型
  需要: 引用
  找到:    int

所以,在泛型定義中,只能使用引用類型。

但是作為引用類型,如果保存基本類型的數據時,又會涉及到裝箱和拆箱的過程。比如

List<Integer> list = new ArrayList<Integer>();
list.add(10); // 1
int num = list.get(0); // 2

在上述程式碼中,聲明了一個List<Integer>泛型類型的集合,

在標記1的位置,添加了一個int類型的數字10,這個過程中,會涉及到裝箱操作,也就是把基本類型int轉換為Integer.

在標記2的位置,編譯器首先要把Object轉換為Integer類型,接著再進行拆箱,把Integer轉換為int。因此上述程式碼等同於

List list = new ArrayList();
list.add(Integer.valueOf(10));
int num = ((Integer) list.get(0)).intValue();

增加了一些執行步驟,對於執行效率來說還是會有一些影響。

運行期間無法獲取泛型實際類型

由於編譯之後,泛型就被擦除,所以在程式碼運行期間,Java 虛擬機無法獲取泛型的實際類型。

下面這段程式碼,從源碼上兩個 List 看起來是不同類型的集合,但是經過泛型擦除之後,集合都變為 ArrayList。所以 if語句中程式碼將會被執行。

public static void main(String[] args) {
  ArrayList<Integer> li = new ArrayList<>();
  ArrayList<Float> lf = new ArrayList<>();
  if (li.getClass() == lf.getClass()) { // 泛型擦除,兩個 List 類型是一樣的
    System.out.println("類型相同");
  }
}

運行結果:

類型相同

這就使得,我們在做方法重載時,無法根據泛型類型來定義重寫方法。

也就是說下面這種方式無法實現重寫。

public void say(List<Integer> a){}
public void say(List<String> b){}

另外還會給我們在實際使用中帶來一些限制,比如說我們沒辦法直接實現以下程式碼

public <T> void say(T a){
  if(a instanceof T){

  }
  T t=new T();
}

上述程式碼會存在編譯錯誤。

既然通過擦除的方式實現泛型有這麼多缺陷,那為什麼要這麼設計呢?

要回答這個問題,需要知道泛型的歷史,Java的泛型是在Jdk 1.5 引入的,在此之前Jdk中的容器類等都是用Object來保證框架的靈活性,然後在讀取時強轉。但是這樣做有個很大的問題,那就是類型不安全,編譯器不能幫我們提前發現類型轉換錯誤,會將這個風險帶到運行時。 引入泛型,也就是為解決類型不安全的問題,但是由於當時java已經被廣泛使用,保證版本的向前兼容是必須的,所以為了兼容老版本jdk,泛型的設計者選擇了基於擦除的實現。

問題解答

面試題:說說你對泛型的理解?

回答: 泛型是JDK5提供的一個新特性。它主要提供的是編譯時期類型的安全檢測機制。這個機制允許程式在編譯時檢測到非法的類型,從而進行錯誤提示。

問題總結

深入理解Java泛型是程式設計師最基礎的必備技能,雖然面試很卷,但是實力仍然很重要。

關注[跟著Mic學架構]公眾號,獲取更多精品原創