深刻理解Java泛型

泛型是什麼?

在咱們寫代碼的時候,常常都會看到相似於ArrayList<T>的代碼,而這裏的T既是泛型,泛型就是泛指一種類型的意思,也就是沒有固定的類型,只有到使用的時候根據用戶的需求才會最終肯定下類型。html

實際Java的泛型並非真泛型,而是一種僞泛型,由於Java在編譯時會進行類型擦除,要理解Java泛型,那麼泛型擦除就必須掌握。而在運行時,JVM是不認識泛型這種東西的,因此在運行時,並無泛型一說,泛型只有在編譯時纔有意義。java

也就是說ArrayList<Integer>ArrayList<String>在JVM中都是ArrayList類型,而ArrayList也稱爲原始類型。數組

該代碼返回的結果爲true,由結果可知,他們返回的類型的相同的,都是ArrayList類型。安全

可是在C#中的泛型是真泛型,即ArrayList<Integer>ArrayList<String>是兩種類型。oracle

類型擦除

那麼什麼是類型擦除呢?app

類型擦除就是編譯器在編譯Java代碼的時候,會將泛型給擦除掉,若是泛型是無界的,那麼將泛型替換爲Object類型,若是泛型是有界的,那麼則將泛型替換爲第一個有界的類型。ide

泛型類

最多見的就是定義的類中存在泛型,咱們看看類型擦除在類中是如何表現的。this

  • 無界泛型:
class Node<T> {
    T element;
    
    public T getNode(){
        return element;
    }
    
    public void set(T t){
        this.element = t;
    }
}
複製代碼

對於這種無界的泛型,在編譯器編譯以後會變成什麼樣呢,根據咱們上面的解釋,它會將泛型T替換爲Objectspa

class Node {
    Object element;
    
    public Object getNode(){
        return element;
    }
    
    public void set(Object t){
        this.element = t;
    }
}
複製代碼
  • 有界泛型1:

對於有界泛型來講,就不是將泛型T直接替換爲Object類型了,看以下代碼:3d

class Node<T extends Comparable> {
    T element;
    
    public T getNode(){
        return element;
    }
    
    public void set(T t){
        this.element = t;
    }
}
複製代碼

這段代碼是一個有界的泛型,即泛型T的類型必須是Comparable類型或者是Comparable的子類類型,那麼編譯後的泛型將會被替換爲Comparable

class Node<T extends Comparable<T>> {
    Comparable element;
    
    public Comparable getNode(){
        return element;
    }
    
    public void set(Comparable t){
        this.element = t;
    }
}
複製代碼
  • 有界泛型2:

若是在有界泛型的類型參數中,既有類,又有接口,好比A是類,B、C是接口

class Node<T extends A & B & C> {
    T element;
    
    public T getNode(){
        return element;
    }
    
    public void set(T t){
        this.element = t;
    }
}
複製代碼

那麼A就必須寫在最左邊,不然將會編譯錯誤,而且泛型T也會被替換爲A類型。

  • 有界泛型3:

若是在有界泛型中存在多個類型參數的話,在類型擦除中,只會使用最左邊的類型去替換泛型。

class Node<T extends Comparable<T> & Serializable> {
    T element;
    
    public T getNode(){
        return element;
    }
    
    public void set(T t){
        this.element = t;
    }
}
複製代碼

該寫法,泛型T會被替換爲Comparable類型。

class Node<T extends Serializable & Comparable<T>> {
    T element;
    
    public T getNode(){
        return element;
    }
    
    public void set(T t){
        this.element = t;
    }
}
複製代碼

可是,若是咱們將兩個類型調換一下,即將Serializable類型放在最左邊,那麼泛型T就會被替換爲Serializable類型。

根據有界泛型2和有界泛型3的例子咱們能夠知道,對於有界的泛型來講,泛型擦除會使用第一個參數類型來替換泛型,而對於既有類,又有接口的參數類型,類必須寫在第一個參數類型中,也就是類必須在接口以前。也就是會優先使用類的類型來進行替換,其次纔會使用接口類型來進行替換。

泛型方法

泛型並不僅能引用於類中,還能夠運用於方法中。

public T getNode(){
    return element;
}
複製代碼

對於非靜態方法而言,類型參數能夠是類中定義的,也能夠是自定義的。

public <U> U get(U u){
    return u;
}
複製代碼

與類的泛型使用相似,能夠由一組類型參數組成,類型參數須要使用尖括號封閉,而且要放置於方法的返回值以前。該方法的做用是:接收一個U類型的參數,而且返回一個U類型的值。

而對於靜態方法而言,類型參數只能使用自定義的,而不能使用類中定義的,類型參數必須放置於方法的返回值前面。

public static <T> int print(T t){
    System.out.println(t);
}
複製代碼

至於爲何不能使用類中定義的,由於類中定義的泛型都是在建立對象的時候使用的,而靜態方法是屬於類的,而不屬於任何一個類。好比:

class Node<T> {
    T element;
    
    public T getNode(){
        return element;
    }
    
    public void set(T t){
        this.element = t;
    }
    
    // 靜態方法A,錯誤的寫法
    public static T get(){
        return element;
    }
    
    //靜態方法B,正確的寫法
    public static <T> T get(T t){
        return t;
    }
}
複製代碼

咱們寫代碼時,能夠Node<Integer>Node<String>,那麼靜態方法中的T是Integer類型仍是String類型呢?JVM是沒法推斷出來的,由於選擇任何一種都是不正確的。

靜態方法B中的T與類中的T並非同一個泛型T,他們是互相獨立的。

咱們在使用泛型靜態方法時,通常不須要直接寫出泛型,編譯器會根據傳入的參數自動進行推斷。

Node.<String>get("aaaa");

好比這段代碼,咱們能夠省略尖括號中的類型參數,由於編譯器會自行推斷出來,等價於下面這句:

Node.get("aaaa");

多態與泛型

咱們考慮這樣一個狀況:

class Node<T> {
    T element;
    
    public T getNode(){
        return element;
    }
    
    public void set(T t){
        this.element = t;
    }
}

class MyNode extends Node<Integer>{
    @Override
    public void set(Integer t){
        super.set(t);
    }
}
複製代碼

考慮如下代碼:

MyNode myNode = new MyNode();
myNode.setEle(5);
Node n = myNode;
n.setEle("abc");
Integer x = myNode.getEle();
複製代碼

該代碼在編譯期是能夠經過的,可是在運行期將會拋出類型轉換異常。致使整個異常發生是在第四行代碼執行時將會發生一個類型轉換,而整個類型轉換將String轉換爲Integer,因此拋出異常。

由於咱們知道Node類型在編譯時,會進行類型擦除,因此當咱們使用一個靜態類型爲Node的變量去接受MyNode類型時,咱們看到方法簽名爲set(Object t)的方法。

而在實際執行時,當咱們傳遞一個字符串參數時,是執行的MyNode中的set(Object t)方法(與方法的分派有關,具體請查閱《深刻理解Java虛擬機 第三版》8.3.2章節),可是 set(Integer t)不是已經重寫了Node類中的set(T t)方法嗎,可是其實是沒有重寫的,由於Node類型中並無簽名爲set(Integer t)的方法,即便編譯以後,也只有一個set(Object t)方法,那麼java開發團隊是如何解決這個問題的呢?

實際上當出現此種狀況的時候,編譯器會在MyNode類中生成一個橋方法,該橋方法的簽名就是set(Object t),而該橋方法纔是真正重寫了Node中的set(Object t)

而橋方法內部是如何實現的呢,其實很簡單:

public void set(Object t){
    set((Integer) t);
}
複製代碼

因此MyNode類中的代碼將是以下所示:

class MyNode extends Node<Integer>{

    // 橋方法,由編譯器生成
    public void set(Object t){
        set((Integer) t);
    }
	
    @Override
    public void set(Integer t){
        super.set(t);
    }
}
複製代碼

因此當咱們調用n.set("abc"),實際就是在調用set(Object t),而且對String類型的值進行了類型轉換,轉換爲Integer,因此纔會在運行時拋出類型轉換異常。

沒法使用泛型的場景

不能使用基本類型做爲類型參數

class Pair<K, V> {

    private K key;
    private V value;

    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    // ...
}
複製代碼

當建立該類型的對象時,不能使用基本類型做爲類型參數K,V的值。

Pair<int, char> p = new pair<>(1, 'a'); 編譯時就會拋出錯誤

而只能使用非基本類型做爲類型參數K,V的值。

Pair<Integer, Character> p = new Pair(1, 'a'); 正確的用法

不能建立類型參數的實例

public static <E> void append(List<E> list) {
    E elem = new E();  // 編譯時拋出錯誤
    list.add(elem);
}
複製代碼

咱們不能爲類型參數建立實例,不然將會拋出錯誤。

咱們可使用反射來實現這種需求:

public static <E> void append(List<E> list, Class<E> c) {
    E elem = cls.newInstance();
    list.add(elem);
}
複製代碼

不能將靜態類型字段的類型設置爲類型參數

public class MobileDevice<T> {
    private static T os; // 編譯時拋出錯誤

    // ...
}
複製代碼

由於靜態字段是屬於類的,而不是屬於對象的,因此沒法肯定參數類型T的具體類型是什麼。

好比有以下代碼:

MobileDevice<Integer> md1 = new MobileDevice<>();

MobileDevice<String> md2 = new MobileDevice<>();

MobileDevice<Double> md3 = new MobileDevice<>();

由於靜態字段os是被對象md一、md二、md3共享的,那麼os字段的類型到底是哪一個呢?這是沒法推斷或者肯定的,因此不能將靜態類型字段的類型設置爲類型參數。

不能將類型轉換或者instanceof與參數化類型一塊兒使用

public static <E> void rtti(List<E> list) {
    if (list instanceof ArrayList<Integer>) {  // 編譯時拋出錯誤
        // ...
    }
}
複製代碼

其實理解這個也很簡單,由於泛型在編譯時將會被擦除,因此在運行時,並不知道類型參數是什麼,因此也就沒法判斷ArrayList<Integer>ArrayList<String>之間的區別,所以運行時只能識別原始類型ArrayList

而能作的只有使用一個通配符(通配符?表示任意類型)去驗證類型是否爲ArrayList

public static <E> void rtti(List<E> list) {
    if (list instanceof ArrayList<?>) {  // 正確
        // ...
    }
}
複製代碼

一般,咱們也不能將類型轉換爲參數化類型,除非是使用參數化類型是通配符進行修飾

List<Integer> li = new ArrayList<>();
List<Number>  ln = (List<Number>) li;  // 編譯時錯誤
List<?> n = (List<?>)li; // 正確,能夠省略(List<?>)
複製代碼

可是,在某種狀況下,編譯器知道類型參數始終有效,並容許強制類型轉換

List<String> l1 = ...;
ArrayList<String> l2 = (ArrayList<String>)l1;  // 正確
複製代碼

不能建立參數化類型的數組

List<String>[] arrays = new List<String>[2]; 編譯時拋出錯誤

其實要理解這個約束也很簡單,咱們先舉個簡單的例子:

Object[] arr = new String[10];
arr[0] = "abc"; // 正確
arr[1] = 10; // 拋出ArrayStoreException,由於該數組只能接受String類型
複製代碼

有了上面那個例子,咱們如今來看下面這個例子:

Object[] arr = new List<String>[10]; // 假設咱們能夠這麼作,實際會拋出編譯時錯誤
arr[1] = new ArrayList<String>(); // 正常執行
arr[0] = new ArrayList<Integer>(); // 根據上面那個列子,這裏應該拋出ArrayStoreException
複製代碼

假設咱們可使用參數化類型的數組,那麼根據第二個例子,在執行第三行代碼時,就應該拋出異常,由於ArrayList<Integer> 類型並不符合List<String>類型,可是不容許這樣作的緣由是JVM沒法識別,由於編譯時會進行類型擦除。類型擦除以後,JVM只認識ArrayList這個類型。

不能建立、捕獲參數化類型的對象

一個泛型類不能間接或者直接的繼承Throwable類。

class MathException<T> extends Exception { /* ... */ } // 間接繼承,編譯時拋出錯誤
複製代碼
class QueueFullException<T> extends Throwable { /* ... */ // 直接繼承,編譯時拋出錯誤
複製代碼

在方法中不能捕獲類型參數的實例。

public static <T extends Exception, J> void execute(List<J> jobs) {
    try {
        for (J job : jobs)
            // ...
    } catch (T e) {   // 編譯時拋出錯誤
        // ...
    }
}
複製代碼

可是能夠在方法中拋出類型參數

class Parser<T extends Exception> {
    public void parse(File file) throws T {     // 正確
        // ...
    }
}
複製代碼

不能重載類型擦除以後擁有相同簽名的方法

public class Example {
    public void print(Set<String> strSet) { }
    public void print(Set<Integer> intSet) { }
}
複製代碼

這兩個方法在類型擦除以後的代碼:

public class Example {
    public void print(Set strSet) { }
    public void print(Set intSet) { }
}
複製代碼

這兩個方法的簽名就如出一轍了,這在Java語言規範中是不合法的。

不可驗證的類型

若是一個類型的類型信息在運行時是徹底可用的,那麼這個類型就是可驗證的類型,其中包括基本類型、非泛型類型、原始類型、綁定無界通配符的泛型。

不可驗證類型的類型信息在編譯時已經被類型擦除機制移除了。不可驗證類型在運行時沒有所有可用的信息,好比ArrayList<Integer>ArrayList<String>,JVM在運行時沒法識別這兩種類型的不一樣之處,JVM只認識ArrayList這種類型。因此Java的泛型是僞泛型,在編譯時纔有用。

堆污染

堆污染髮生的狀況是將一個具備類型參數的變量指向一個不具備類型參數的對象。

public class Main {
    public static <T> void addToList (List<T> listArg, T... elements) {
        for (T x : elements) {
            listArg.add(x);
        }
    }

    public static void faultyMethod(List<String>... l) {
        Object[] objectArray = l;     // 有效
        objectArray[0] = Arrays.asList(42);
        String s = l[0].get(0);       // 拋出ClassCastException
    }
}
複製代碼

當編譯器遇到可變參數的方法時,編譯器將會把可變形式參數轉換爲一個數組。可是,在Java語言中,沒法建立帶有參數化類型的數組(在沒法使用泛型場景中的第五個場景有描述)。咱們拿addToList方法來描述,編譯器會將T...elements轉換爲T[] elements,可是,因爲存在類型擦除,最終,編譯器會將T...elements轉換爲Object[] elements,所以,這裏就可能產生堆污染。

咱們看到faultyMethod方法,這裏的可變參數l賦值給類型爲Object[]的變量是有效的,由於變量l通過編譯器編譯後就是轉換爲List[]類型,所以咱們能夠往裏面放置任何該類型或者該類型的子類類型的對象,由於類型已經被擦除了,因此咱們能夠放置任何List類型的值進去,這裏就出現一個數組對象中,既能夠放入List<String>的對象,也能夠放入List<Integer>,或者其餘類型。這裏就出現了堆污染。

禁止不可驗證形參的可變參數發出警告

若是你能保證你的可變參數不會出現轉換錯誤,那麼就能夠添加@SafeVarags註解來取消警告的出現。

也能夠添加@SuppressWarnings({"unchecked", "varargs"})註解來取消警告。可是這必須創建在你能確保本身的代碼安全的狀況下才能添加。

思考

類型擦除實驗

咱們如今來下面這段代碼:

這段代碼是經過反射來獲取Node類型的參數類型,以前不是說在編譯時不是會進行類型擦除嗎,那麼JVM是怎麼在運行時還能獲取到它的參數類型的。

咱們能夠經過反編譯來看看,反編譯以後的class文件是怎麼樣的。咱們先反編譯Node文件:

咱們能夠看到,這裏並無將T擦除,並替換爲 Object類型。因此JVM才能經過該類型信息獲取到參數類型。

那麼類型擦除是發生在哪裏呢?

咱們再來看另一段代碼就能明白了:

咱們這裏獲取了Node中的element字段的類型:

這裏打印出來的結果就是Object類型。咱們能夠給Node類型的參數類型添加一個下界,讓它繼承Comparable接口,而後再打印一下類型:

經過這兩個例子能夠說明,類型的擦除並不會發生在泛型聲明上,而是發生在泛型的使用上。

參考文獻:

  1. Oracle文檔
相關文章
相關標籤/搜索