面試官:說說什麼是泛型的型別擦除?

碼農參上發表於2021-08-24

先看一道常見的面試題,下面的程式碼的執行結果是什麼?

public static void main(String[] args) {
    List<String> list1=new ArrayList<String>();
    List<Integer> list2=new ArrayList<Integer>();
    System.out.println(list1.getClass()==list2.getClass());
}

首先,我們知道getClas方法獲取的是物件執行時的類(Class),那麼這個問題也就可以轉化為ArrayList<String>ArrayList<Integer>的物件在執行時對應的Class是否相同?

我們直接揭曉答案,執行上面的程式碼,程式會列印true,說明雖然在程式碼中宣告瞭具體的泛型,但是兩個List物件對應的Class是一樣的,對它們的型別進行列印,結果都是:

class java.util.ArrayList

也就是說,雖然ArrayList<String>ArrayList<Integer>在編譯時是不同的型別,但是在編譯完成後都被編譯器簡化成了ArrayList,這一現象,被稱為泛型的型別擦除(Type Erasure)。泛型的本質是引數化型別,而型別擦除使得型別引數只存在於編譯期,在執行時,jvm是並不知道泛型的存在的。

那麼為什麼要進行泛型的型別擦除呢?查閱的一些資料中,解釋說型別擦除的主要目的是避免過多的建立類而造成的執行時的過度消耗。試想一下,如果用List<A>表示一個型別,再用List<B>表示另一個型別,以此類推,無疑會引起型別的數量爆炸。

在對型別擦除有了一個大致的瞭解後,我們再看看下面的幾個問題。

型別擦除做了什麼?

上面我們說了,編譯完成後會對泛型進行型別擦除,如果想要眼見為實,實際看一下的話應該怎麼辦呢?那麼就需要對編譯後的位元組碼檔案進行反編譯了,這裡使用一個輕量級的小工具Jad來進行反編譯(可以從這個地址進行下載:https://varaneckas.com/jad/

Jad的使用也很簡單,下載解壓後,把需要反編譯的位元組碼檔案放在目錄下,然後在命令列裡執行下面的命令就可以在同目錄下生成反編譯後的.java檔案了:

jad -sjava Test.class 

好了,工具準備好了,下面我們就看一下不同情況下的型別擦除。

1、無限制型別擦除

當類定義中的型別引數沒有任何限制時,在型別擦除後,會被直接替換為Object。在下面的例子中,<T>中的型別引數T就全被替換為了Object(左側為編譯前的程式碼,右側為通過位元組碼檔案反編譯得到的程式碼):

2、有限制型別擦除

當類定義中的型別引數存在限制時,在型別擦除中替換為型別引數的上界或者下界。下面的程式碼中,經過擦除後T被替換成了Integer

3、擦除方法中的型別引數

比較下面兩邊的程式碼,可以看到在擦除方法中的型別引數時,和擦除類定義中的型別引數一致,無限制時直接擦除為Object,有限制時則會被擦除為上界或下界:

反射能獲取泛型的型別嗎?

估計對Java反射比較熟悉小夥伴要有疑問了,反射中的getTypeParameters方法可以獲得類、陣列、介面等實體的型別引數,如果型別被擦除了,那麼能獲取到什麼呢?我們來嘗試一下使用反射來獲取型別引數:

System.out.println(Arrays.asList(list1.getClass().getTypeParameters()));

執行結果如下:

[E]

同樣,如果列印Map物件的引數型別:

Map<String,Integer> map=new HashMap<>();
System.out.println(Arrays.asList(map.getClass().getTypeParameters()));

最終也只能夠獲取到:

[K, V]

可以看到通過getTypeParameters方法只能獲取到泛型的引數佔位符,而不能獲得程式碼中真正的泛型型別。

能在指定型別的List中放入其他型別的物件嗎?

使用泛型的好處之一,就是在編譯的時候能夠檢查型別安全,但是通過上面的例子,我們知道執行時是沒有泛型約束的,那麼是不是就意味著,在執行時可以把一個型別的物件能放進另一型別的List呢?我們先看看正常情況下,直接呼叫add方法會有什麼報錯:

當我們嘗試將User型別的物件放入String型別的陣列時,泛型的約束會在編譯期間就進行報錯,提示提供的User型別物件不適用於String型別陣列。那麼既然編譯時不行,那麼我們就在執行時寫入,藉助真正執行的class是沒有泛型約束這一特性,使用反射在執行時寫入:

public class ReflectTest {
    static List<String> list = new ArrayList<>();

    public static void main(String[] args) {
        list.add("1");
        ReflectTest reflectTest =new ReflectTest();
        try {
            Field field = ReflectTest.class.getDeclaredField("list");
            field.setAccessible(true);
            List list=(List) field.get(reflectTest);
            list.add(new User());
        } catch (Exception e) {
            e.printStackTrace();
        }        
    }
}

執行上面的程式碼,不僅在編譯期間可以通過語法檢查,並且也可以正常地執行,我們使用debug來看一下陣列中的內容:

可以看到雖然陣列中宣告的泛型型別是String,但是仍然成功的放入了User型別的物件。那麼,如果我們在程式碼中嘗試取出這個User物件,程式還能正常執行嗎,我們在上面程式碼的最後再加上一句:

System.out.println(list.get(1));

再次執行程式碼,程式執行到最後的列印語句時,報錯如下:

異常提示User型別的物件無法被轉換成String型別,這是否也就意味著,在取出物件時存在強制型別轉換呢?我們來看一下ArrayListget方法的原始碼:

public E get(int index) {
    rangeCheck(index);
    return elementData(index);
}

E elementData(int index) {
    return (E) elementData[index];
}

可以看到,在取出元素時,會將這個元素強制型別轉換成泛型中的型別,也就是說在上面的程式碼中,最後會嘗試強制把User物件轉換成String型別,在這一階段程式會報錯。通過這一過程,也再次證明了泛型可以對型別安全進行檢測。

型別擦除會引起什麼問題?

下面我們看一個稍微有點複雜的例子,首先宣告一個介面,然後建立一個實現該介面的類:

public interface Fruit<T> {
    T get(T param);
}

public class Apple implements Fruit<Integer> {
    @Override
    public Integer get(Integer param) {
        return param;
    }
}

按照之前我們的理解,在進行型別擦除後,應該是這樣的:

public interface Fruit {
    Object get(Object param);
}

public class Apple implements Fruit {
    @Override
    public Integer get(Integer param) {
        return param;
    }
}

但是,如果真是這樣的話那麼程式碼是無法執行的,因為雖然Apple類中也有一個get方法,但是與介面中的方法引數不一致,也就是說沒有覆蓋介面中的方法。針對這種情況,編譯器會通過新增一個橋接方法來滿足語法上的要求,同時保證了基於泛型的多型能夠有效。我們反編譯上面程式碼生成的位元組碼檔案:

可以看到,編譯後的程式碼中生成了兩個get方法。引數為Objectget方法負責實現Fruit介面中的同名方法,然後在實現類中又額外新增了一個引數為Integerget方法,這個方法也就是理論上應該生成的帶引數型別的方法。最終用介面方法呼叫額外新增的方法,通過這種方式構建了介面和實現類的關係,類似於起到了橋接的作用,因此也被稱為橋接方法,最終,通過這種機制保證了泛型情況下的Java多型性。

總結

本文由面試中常見的一道面試題入手,介紹了java中泛型的型別擦除相關知識,通過這一過程,也便於大家理解為什麼平常總是說java中的泛型是一個偽泛型,同時也有助於大家認識到java中泛型的一些缺陷。瞭解型別擦除的原因以及原理,相信能夠方便大家在日常的工作中更好的使用泛型。

如果文章對您有所幫助,歡迎關注公眾號 碼農參上

相關文章