深入理解Java泛型

dreamGong發表於2018-08-03

泛型是什麼

一說到泛型,大夥肯定不會陌生,我們程式碼裡面有很多類似這樣的語句:

List<String> list=new ArrayList<>();
複製程式碼

ArrayList就是個泛型類,我們通過設定不同的型別,可以往集合裡面儲存不同型別的資料型別(而且只能儲存設定的資料型別,這是泛型的優勢之一)。“泛型”簡單的意思就是泛指的型別(引數化型別)。想象下這樣的場景:如果我們現在要寫一個容器類(支援資料增刪查詢的),我們寫了支援String型別的,後面還需要寫支援Integer型別的。然後呢?Doubel、Float、各種自定義型別?這樣重複程式碼太多了,而且這些容器的演算法都是一致的。我們可以通過泛指一種型別T,來代替我們之前需要的所有型別,把我們需要的型別作為引數傳遞到容器裡面,這樣我們演算法只需要寫一套就可以適應所有的型別。最典型的的例子就是ArrayList了,這個集合我們無論傳遞什麼資料型別,它都能很好的工作。
聰明的同學看完上面的描述,靈機一動,寫出了下面的程式碼:

class MyList{
    private Object[] elements=new Object[10];
    private int size;
    
    public void add(Object item) {
    	elements[size++]=item;
    }
    
    public Object get(int index) {
    	return elements[index];
    }
}
複製程式碼

這個程式碼靈活性很高,所有的型別都可以向上轉型為Object類,這樣我們就可以往裡面儲存各種型別的資料了。的確Java在泛型出現之前,也是這麼做的。但是這樣的有一個問題:如果集合裡面資料很多,某一個資料轉型出現錯誤,在編譯期是無法發現的。但是在執行期會發生java.lang.ClassCastException。例如:

MyList myList=new MyList();
myList.add("A");
myList.add(1);
System.out.println(myList.get(0));
System.out.println((String)myList.get(1));
複製程式碼

我們在這個集合裡面儲存了多個型別(某些情況下容器可能會儲存多種型別的資料),如果資料量較多,轉型的時候難免會出現異常,而這些都是無法在編譯期得知的。而泛型一方面讓我們只能往集合中新增一種型別的資料,同時可以讓我們在編譯期就發現這些錯誤,避免執行時異常的發生,提升程式碼的健壯性。

Java泛型介紹

下面我們來介紹Java泛型的相關內容,下面會介紹以下幾個方面:

  • Java泛型類
  • Java泛型方法
  • Java泛型介面
  • Java泛型擦除及其相關內容
  • Java泛型萬用字元

Java泛型類

類結構是物件導向中最基本的元素,如果我們的類需要有很好的擴充套件性,那麼我們可以將其設定成泛型的。假設我們需要一個資料的包裝類,通過傳入不同型別的資料,可以儲存相應型別的資料。我們看看這個簡單的泛型類的設計:

class DataHolder<T>{
    T item;
    
    public void setData(T t) {
    	this.item=t;
    }
    
    public T getData() {
    	return this.item;
    }
}
複製程式碼

泛型類定義時只需要在類名後面加上型別引數即可,當然你也可以新增多個引數,類似於<K,V>,<T,E,K>等。這樣我們就可以在類裡面使用定義的型別引數。
泛型類最常用的使用場景就是“元組”的使用。我們知道方法return返回值只能返回單個物件。如果我們定義一個泛型類,定義2個甚至3個型別引數,這樣我們return物件的時候,構建這樣一個“元組”資料,通過泛型傳入多個物件,這樣我們就可以一次性方法多個資料了。

Java泛型方法

前面我們介紹的泛型是作用於整個類的,現在我們來介紹泛型方法。泛型方法既可以存在於泛型類中,也可以存在於普通的類中。如果使用泛型方法可以解決問題,那麼應該儘量使用泛型方法。下面我們通過例子來看一下泛型方法的使用:

class DataHolder<T>{
    T item;
    
    public void setData(T t) {
    	this.item=t;
    }
    
    public T getData() {
    	return this.item;
    }
    
    /**
     * 泛型方法
     * @param e
     */
    public <E> void PrinterInfo(E e) {
    	System.out.println(e);
    }
}
複製程式碼

我們來看執行結果:

1
AAAAA
8.88
複製程式碼

從上面的例子中,我們看到我們是在一個泛型類裡面定義了一個泛型方法printInfo。通過傳入不同的資料型別,我們都可以列印出來。在這個方法裡面,我們定義了型別引數E。這個E和泛型類裡面的T兩者之間是沒有關係的。哪怕我們將泛型方法設定成這樣:

//注意這個T是一種全新的型別,可以與泛型類中宣告的T不是同一種型別。
public <T> void PrinterInfo(T e) {
    System.out.println(e);
}
//呼叫方法
DataHolder<String> dataHolder=new DataHolder<>();
dataHolder.PrinterInfo(1);
dataHolder.PrinterInfo("AAAAA");
dataHolder.PrinterInfo(8.88f);
複製程式碼

這個泛型方法依然可以傳入Double、Float等型別的資料。泛型方法裡面的型別引數T和泛型類裡面的型別引數是不一樣的型別,從上面的呼叫方式,我們也可以看出,泛型方法printInfo不受我們DataHolder中泛型型別引數是String的影響。 我們來總結下泛型方法的幾個基本特徵:

  • public與返回值中間非常重要,可以理解為宣告此方法為泛型方法。
  • 只有宣告瞭的方法才是泛型方法,泛型類中的使用了泛型的成員方法並不是泛型方法。
  • 表明該方法將使用泛型型別T,此時才可以在方法中使用泛型型別T。
  • 與泛型類的定義一樣,此處T可以隨便寫為任意標識,常見的如T、E、K、V等形式的引數常用於表示泛型。

Java泛型介面

Java泛型介面的定義和Java泛型類基本相同,下面是一個例子:

//定義一個泛型介面
public interface Generator<T> {
    public T next();
}
複製程式碼

此處有兩點需要注意:

  • 泛型介面未傳入泛型實參時,與泛型類的定義相同,在宣告類的時候,需將泛型的宣告也一起加到類中。例子如下:
/* 即:class DataHolder implements Generator<T>{
 * 如果不宣告泛型,如:class DataHolder implements Generator<T>,編譯器會報錯:"Unknown class"
 */
class FruitGenerator<T> implements Generator<T>{
    @Override
    public T next() {
        return null;
    }
}
複製程式碼
  • 如果泛型介面傳入型別引數時,實現該泛型介面的實現類,則所有使用泛型的地方都要替換成傳入的實參型別。例子如下:
class DataHolder implements Generator<String>{
    @Override
    public String next() {
    	return null;
    }
}
複製程式碼

從這個例子我們看到,實現類裡面的所有T的地方都需要實現為String。

Java泛型擦除及其相關內容

我們下面看一個例子:

Class<?> class1=new ArrayList<String>().getClass();
Class<?> class2=new ArrayList<Integer>().getClass();
System.out.println(class1);		//class java.util.ArrayList
System.out.println(class2);		//class java.util.ArrayList
System.out.println(class1.equals(class2));	//true
複製程式碼

我們看輸出發現,class1和class2居然是同一個型別ArrayList,在執行時我們傳入的型別變數String和Integer都被丟掉了。Java語言泛型在設計的時候為了相容原來的舊程式碼,Java的泛型機制使用了“擦除”機制。我們來看一個更徹底的例子:

class Table {}
class Room {}
class House<Q> {}
class Particle<POSITION, MOMENTUM> {}
//呼叫程式碼及輸出
List<Table> tableList = new ArrayList<Table>();
Map<Room, Table> maps = new HashMap<Room, Table>();
House<Room> house = new House<Room>();
Particle<Long, Double> particle = new Particle<Long, Double>();
System.out.println(Arrays.toString(tableList.getClass().getTypeParameters()));
System.out.println(Arrays.toString(maps.getClass().getTypeParameters()));
System.out.println(Arrays.toString(house.getClass().getTypeParameters()));
System.out.println(Arrays.toString(particle.getClass().getTypeParameters()));
/** 
[E]
[K, V]
[Q]
[POSITION, MOMENTUM]
 */
複製程式碼

上面的程式碼裡,我們想在執行時獲取類的型別引數,但是我們看到返回的都是“形參”。在執行期我們是獲取不到任何已經宣告的型別資訊的。
注意:
編譯器雖然會在編譯過程中移除引數的型別資訊,但是會保證類或方法內部引數型別的一致性。
泛型引數將會被擦除到它的第一個邊界(邊界可以有多個,重用 extends 關鍵字,通過它能給與引數型別新增一個邊界)。編譯器事實上會把型別引數替換為它的第一個邊界的型別。如果沒有指明邊界,那麼型別引數將被擦除到Object。下面的例子中,可以把泛型引數T當作HasF型別來使用。

public interface HasF {
    void f();
}

public class Manipulator<T extends HasF> {
    T obj;
    public T getObj() {
        return obj;
    }
    public void setObj(T obj) {
        this.obj = obj;
    }
}
複製程式碼

extend關鍵字後後面的型別資訊決定了泛型引數能保留的資訊。Java型別擦除只會擦除到HasF型別。

Java泛型擦除的原理

我們通過例子來看一下,先看一個非泛型的版本:

// SimpleHolder.java
public class SimpleHolder {
    private Object obj;
    public Object getObj() {
        return obj;
    }
    public void setObj(Object obj) {
        this.obj = obj;
    }
    public static void main(String[] args) {
        SimpleHolder holder = new SimpleHolder();
        holder.setObj("Item");
        String s = (String) holder.getObj();
    }
}
// SimpleHolder.class
public class SimpleHolder {
  public SimpleHolder();
    Code:
       0: aload_0       
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return        

  public java.lang.Object getObj();
    Code:
       0: aload_0       
       1: getfield      #2                  // Field obj:Ljava/lang/Object;
       4: areturn       

  public void setObj(java.lang.Object);
    Code:
       0: aload_0       
       1: aload_1       
       2: putfield      #2                  // Field obj:Ljava/lang/Object;
       5: return        

  public static void main(java.lang.String[]);
    Code:
       0: new           #3                  // class SimpleHolder
       3: dup           
       4: invokespecial #4                  // Method "<init>":()V
       7: astore_1      
       8: aload_1       
       9: ldc           #5                  // String Item
      11: invokevirtual #6                  // Method setObj:(Ljava/lang/Object;)V
      14: aload_1       
      15: invokevirtual #7                  // Method getObj:()Ljava/lang/Object;
      18: checkcast     #8                  // class java/lang/String
      21: astore_2      
      22: return        
}
複製程式碼

下面我們給出一個泛型的版本,從位元組碼的角度來看看:

//GenericHolder.java
public class GenericHolder<T> {
    T obj;
    public T getObj() {
        return obj;
    }
    public void setObj(T obj) {
        this.obj = obj;
    }
    public static void main(String[] args) {
        GenericHolder<String> holder = new GenericHolder<>();
        holder.setObj("Item");
        String s = holder.getObj();
    }
}

//GenericHolder.class
public class GenericHolder<T> {
  T obj;

  public GenericHolder();
    Code:
       0: aload_0       
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return        

  public T getObj();
    Code:
       0: aload_0       
       1: getfield      #2                  // Field obj:Ljava/lang/Object;
       4: areturn       

  public void setObj(T);
    Code:
       0: aload_0       
       1: aload_1       
       2: putfield      #2                  // Field obj:Ljava/lang/Object;
       5: return        

  public static void main(java.lang.String[]);
    Code:
       0: new           #3                  // class GenericHolder
       3: dup           
       4: invokespecial #4                  // Method "<init>":()V
       7: astore_1      
       8: aload_1       
       9: ldc           #5                  // String Item
      11: invokevirtual #6                  // Method setObj:(Ljava/lang/Object;)V
      14: aload_1       
      15: invokevirtual #7                  // Method getObj:()Ljava/lang/Object;
      18: checkcast     #8                  // class java/lang/String
      21: astore_2      
      22: return        
}
複製程式碼

在編譯過程中,型別變數的資訊是能拿到的。所以,set方法在編譯器可以做型別檢查,非法型別不能通過編譯。但是對於get方法,由於擦除機制,執行時的實際引用型別為Object型別。為了“還原”返回結果的型別,編譯器在get之後新增了型別轉換。所以,在GenericHolder.class檔案main方法主體第18行有一處型別轉換的邏輯。它是編譯器自動幫我們加進去的。
所以在泛型類物件讀取和寫入的位置為我們做了處理,為程式碼新增約束。

Java泛型擦除的缺陷及補救措施

泛型型別不能顯式地運用在執行時型別的操作當中,例如:轉型、instanceof 和 new。因為在執行時,所有引數的型別資訊都丟失了。類似下面的程式碼都是無法通過編譯的:

public class Erased<T> {
    private final int SIZE = 100;
    public static void f(Object arg) {
        //編譯不通過
        if (arg instanceof T) {
        }
        //編譯不通過
        T var = new T();
        //編譯不通過
        T[] array = new T[SIZE];
        //編譯不通過
        T[] array = (T) new Object[SIZE];
    }
}
複製程式碼

那我們有什麼辦法來補救呢?下面介紹幾種方法來一一解決上面出現的問題。

型別判斷問題

我們可以通過下面的程式碼來解決泛型的型別資訊由於擦除無法進行型別判斷的問題:

/**
 * 泛型型別判斷封裝類
 * @param <T>
 */
class GenericType<T>{
    Class<?> classType;
    
    public GenericType(Class<?> type) {
        classType=type;
    }
    
    public boolean isInstance(Object object) {
        return classType.isInstance(object);
    }
}
複製程式碼

在main方法我們可以這樣呼叫:

GenericType<A> genericType=new GenericType<>(A.class);
System.out.println("------------");
System.out.println(genericType.isInstance(new A()));
System.out.println(genericType.isInstance(new B()));
複製程式碼

我們通過記錄型別引數的Class物件,然後通過這個Class物件進行型別判斷。

建立型別例項

泛型程式碼中不能new T()的原因有兩個,一是因為擦除,不能確定型別;而是無法確定T是否包含無參建構函式。
為了避免這兩個問題,我們使用顯式的工廠模式:

/**
 * 使用工廠方法來建立例項
 *
 * @param <T>
 */
interface Factory<T>{
    T create();
}

class Creater<T>{
    T instance;
    public <F extends Factory<T>> T newInstance(F f) {
    	instance=f.create();
    	return instance;
    }
}

class IntegerFactory implements Factory<Integer>{
    @Override
    public Integer create() {
    	Integer integer=new Integer(9);
    	return integer;
    }
}
複製程式碼

我們通過工廠模式+泛型方法來建立例項物件,上面程式碼中我們建立了一個IntegerFactory工廠,用來建立Integer例項,以後程式碼有變動的話,我們可以新增新的工廠型別即可。
呼叫程式碼如下:

Creater<Integer> creater=new Creater<>();
System.out.println(creater.newInstance(new IntegerFactory()));
複製程式碼
建立泛型陣列

一般不建議建立泛型陣列。儘量使用ArrayList來代替泛型陣列。但是在這裡還是給出一種建立泛型陣列的方法。

public class GenericArrayWithTypeToken<T> {
    private T[] array;

    @SuppressWarnings("unchecked")
    public GenericArrayWithTypeToken(Class<T> type, int sz) {
        array = (T[]) Array.newInstance(type, sz);
    }

    public void put(int index, T item) {
        array[index] = item;
    }

    public T[] rep() {
        return array;
    }

    public static void main(String[] args) {
        
    }
}
複製程式碼

這裡我們使用的還是傳引數型別,利用型別的newInstance方法建立例項的方式。

Java泛型的萬用字元

上界萬用字元<? extends T>

我們先來看一個例子:

class Fruit {}
class Apple extends Fruit {}
複製程式碼

現在我們定義一個盤子類:

class Plate<T>{
    T item;
    public Plate(T t){
        item=t;
    }
    
    public void set(T t) {
        item=t;
    }
    
    public T get() {
        return item;
    }
}
複製程式碼

下面,我們定義一個水果盤子,理論上水果盤子裡,當然可以存在蘋果

Plate<Fruit> p=new Plate<Apple>(new Apple());
複製程式碼

你會發現這段程式碼無法進行編譯。裝蘋果的盤子”無法轉換成“裝水果的盤子:

cannot convert from Plate<Apple> to Plate<Fruit>
複製程式碼

從上面程式碼我們知道,就算容器中的型別之間存在繼承關係,但是Plate和Plate兩個容器之間是不存在繼承關係的。 在這種情況下,Java就設計成Plate<? extend Fruit>來讓兩個容器之間存在繼承關係。我們上面的程式碼就可以進行賦值了

Plate<? extends Fruit> p=new Plate<Apple>(new Apple());
複製程式碼

Plate<? extend Fruit>是Plate< Fruit >和Plate< Apple >的基類。
我們通過一個更加詳細的例子來看一下上界的界限:

class Food{}

class Fruit extends Food {}
class Meat extends Food {}

class Apple extends Fruit {}
class Banana extends Fruit {}
class Pork extends Meat{}
class Beef extends Meat{}

class RedApple extends Apple {}
class GreenApple extends Apple {}
複製程式碼

在上面這個類層次中,Plate<? extend Fruit>,覆蓋下面的藍色部分:

深入理解Java泛型

如果我們往盤子裡面新增資料,例如:

p.set(new Fruit());
p.set(new Apple());
複製程式碼

你會發現無法往裡面設定資料,按道理說我們將泛型型別設定為? extend Fruit。按理說我們往裡面新增Fruit的子類應該是可以的。但是Java編譯器不允許這樣操作。<? extends Fruit>會使往盤子裡放東西的set()方法失效。但取東西get()方法還有效
原因是:
Java編譯期只知道容器裡面存放的是Fruit和它的派生類,具體是什麼型別不知道,可能是Fruit?可能是Apple?也可能是Banana,RedApple,GreenApple?編譯器在後面看到Plate< Apple >賦值以後,盤子裡面沒有標記為“蘋果”。只是標記了一個佔位符“CAP#1”,來表示捕獲一個Fruit或者Fruit的派生類,具體是什麼型別不知道。所有呼叫程式碼無論往容器裡面插入Apple或者Meat或者Fruit編譯器都不知道能不能和這個“CAP#1”匹配,所以這些操作都不允許。
但是上界萬用字元是允許讀取操作的。例如程式碼:

Fruit fruit=p.get();
Object object=p.get();
複製程式碼

這個我們很好理解,由於上界萬用字元設定容器中只能存放Fruit及其派生類,那麼獲取出來的我們都可以隱式的轉為其基類(或者Object基類)。所以上界描述符Extends適合頻繁讀取的場景。

下界萬用字元<? super T>

下界萬用字元的意思是容器中只能存放T及其T的基類型別的資料。我們還是以上面類層次的來看,<? super Fruit>覆蓋下面的紅色部分:

深入理解Java泛型
下界萬用字元<? super T>不影響往裡面儲存,但是讀取出來的資料只能是Object型別。
原因是:
下界萬用字元規定了元素最小的粒度,必須是T及其基類,那麼我往裡面儲存T及其派生類都是可以的,因為它都可以隱式的轉化為T型別。但是往外讀就不好控制了,裡面儲存的都是T及其基類,無法轉型為任何一種型別,只有Object基類才能裝下。

PECS原則

最後簡單介紹下Effective Java這本書裡面介紹的PECS原則。

  • 上界<? extends T>不能往裡存,只能往外取,適合頻繁往外面讀取內容的場景。
  • 下界<? super T>不影響往裡存,但往外取只能放在Object物件裡,適合經常往裡面插入資料的場景。

<?>無限萬用字元

無界萬用字元 意味著可以使用任何物件,因此使用它類似於使用原生型別。但它是有作用的,原生型別可以持有任何型別,而無界萬用字元修飾的容器持有的是某種具體的型別。舉個例子,在List型別的引用中,不能向其中新增Object, 而List型別的引用就可以新增Object型別的變數。
最後提醒一下的就是,List與List並不等同,List是List的子類。還有不能往List<?> list裡新增任意物件,除了null。

相關文章