泛型概述-基本概念

阿禎發表於2018-02-07

泛型程式設計 (Generic programming) 意味著編寫的程式碼可以被很多不同的型別的物件所重用。

原始型別(Raw Type)

下面我們會用一些例子來說明,為什麼使用泛型編寫的程式碼可以被不同型別的物件所重用。在沒有泛型之前 ArrayList 的程式碼是這樣的:

public class RawArrayList {

    private Object[] element;

    public Object getElement(int position) {
        //...
    }
    
    public void addElement(Object element) {
        //...
    }
}
複製程式碼
public class RawArrayListTest {
    public static void main(String[] args) {
        RawArrayList rawArrayList = new RawArrayList();
        rawArrayList.addElement("string");
        rawArrayList.addElement(new File("file"));

        String string = (String) rawArrayList.getElement(0);
        
        //此處會出現型別轉換異常
        String file = (String) rawArrayList.getElement(1);
    }
}
複製程式碼

上面的程式碼有兩個問題:

  1. 編譯器沒有錯誤檢查,我們可以呼叫 setElement("string") 方法向 RawArrayList 中放入一個 String 型別的字串,之後我仍然可以向其中放入一個其他型別的物件,例如 setElement(new File("file"))。編譯器並不會有任何警告。
  2. 因為 RawArrayList 內部使用 Object 陣列 來儲存物件,這樣我們在獲取物件的時候就必須使用強制型別轉換,String string = (String) rawArrayList.getElement(0);,由於 RawArrayList 沒有對放入的型別做限制,所以就有可能出現型別轉換異常 java.lang.ClassCastException

型別引數

泛型提供了型別引數 (type parameters) 來幫助我們改善上述的程式碼。 可以認為是給 RawArrayList 中宣告一個引數,這個引數就代表著列表中元素的型別,我們會在宣告 RawArrayList 的時候指明引數的具體型別。

型別引數用尖括號加任意字母表示 : <T>,字母一般為單個大寫字母,並有一定含義,例如 T(type),E(element),K(key),V(value) 等等

下面來看看用使用型別引數之後的 ArrayList:

public class ArrayListTest {
    public static void main(String[] args) {
        ArrayList<String> stringList = new ArrayList<String>();
        stringList.add("string");
//        下面一行程式碼,編譯器會報錯,無法將 File 物件應用於 String 型別的 ArrayList
//        stringList.add(new File("file"));

        String string=stringList.get(0);
        System.out.println(string);
    }
    
    // print > string
}
複製程式碼

ArrayList<String> stringList = new ArrayList<String>(); 這一行程式碼中,可以省略建立 ArrayList 物件的時候傳遞的引數型別如 ArrayList<String> stringList = new ArrayList<>();,編譯器可以從宣告中推斷出省略的型別。

注意尖括號不能省略,不然可能造成型別不安全的隱患( 這相當於將原始型別的物件傳遞給泛型型別的引用 )。例如我們將之前的內部具有 String 型別和 File 物件的 RawArrayList 傳遞給 ArrayList 型別的引用,會造成什麼影響呢?

 RawArrayList list=new RawArrayList();
        list.add(1);
        list.add("string");

        ArrayList<String> stringArrayList=list;
        for (String s : stringArrayList) {
            System.out.println(s);//boom
        }
複製程式碼

很不幸會發生型別轉換異常,我們最好不要將原生型別和泛型型別這樣使用,除非你能保證型別安全。

泛型的一個目的就是儘早的發現可能出現的異常,在使用了泛型提供的型別引數之後,有兩個顯著的好處是

  1. 我們不需要自己進行型別轉換了,編譯器能推斷出返回型別,可讀性提高
  2. 編譯器會對插入資料做型別檢查,避免插入了錯誤的型別,安全性提高

好像 RawArrayList 程式碼的例子沒有明顯體現出我們在開頭所說的,泛型程式碼可以被很多不同的型別的物件所重用。

接下來在讓我們看看水果和果盤的例子:

Apple

public class Apple {
    private String name;

    public Apple(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
複製程式碼

Orange

public class Orange {
    private String name;

    public Orange(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
複製程式碼

現在我們再來定義一個果盤,用來存放水果。我們需要定義一個蘋果果盤,用來存放蘋果,再定義一個橘子果盤,用來存放橘子。

ApplePlate

public class ApplePlate {
    private List<Apple> appleList;

    public ApplePlate(List<Apple> appleList) {
        this.appleList = appleList;
    }

    public void setAppleList(List<Apple> appleList) {
        this.appleList = appleList;
    }

    public List<Apple> getAppleList() {
        return appleList;
    }
    
}
複製程式碼

OrangePlate

public class OrangePlate {
    private List<Orange> orangePlate;

    public OrangePlate(List<Orange> orangePlate) {
        this.orangePlate = orangePlate;
    }

    public List<Orange> getOrangePlate() {
        return orangePlate;
    }

    public void setOrangePlate(List<Orange> orangePlate) {
        this.orangePlate = orangePlate;
    }
}
複製程式碼

將蘋果放進蘋果果盤

private static void createApple() {
    //生成蘋果
    List<Apple> apples = new ArrayList<>();
    Apple apple1 = new Apple("蘋果1");
    Apple apple2 = new Apple("蘋果2");
    Apple apple3 = new Apple("蘋果3");
    apples.add(apple1);
    apples.add(apple2);
    apples.add(apple3);
    //將蘋果放入蘋果果盤
    ApplePlate applePlate = new ApplePlate(apples);

    //取出剛放入的蘋果們
    for (Apple apple : applePlate.getAppleList()) {
        System.out.println(apple.getName());
    }
}
複製程式碼

現在將橘子放進橘子果盤的話,只需要按照 createApple() 方法在編寫一個 createOrange() 就可以了。

那如果現在我要新增一個水果型別怎麼辦,我還需要對應的再增加一個該水果型別的果盤。而且可以看到,我們水果的屬性,方法,果盤的方法,除了型別不同之外,沒什麼不同。這時候就可以使用泛型來解決這個問題。

泛型類/介面

先讓我們看看泛型類的概念:

具有一個或者多個型別引數的類/介面,就是泛型類/泛型介面

在定義類的時候,我們在類名的後面加上一個形如 <T> 的型別引數。類中屬性的宣告,方法的引數型別,包括返回型別等,都可以用型別 T 替代。泛型介面與泛型類的定義相同,我們就不展開敘述了。

現在我們將果盤(XXXPlate)改寫為泛型類是什麼樣子

public class Plate<T> {
    private List<T> fruitList;

    public Plate(List<T> fruitList) {
        this.fruitList = fruitList;
    }

    public List<T> getFruitList() {
        return fruitList;
    }

    public void setFruitList(List<T> fruitList) {
        this.fruitList = fruitList;
    }
}
複製程式碼

現在我們先抽象出一個水果類 Fruit

public class Fruit {
    private String name;

    public Fruit(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
複製程式碼

為了對比,我們新建立一種水果型別 Cherry 使他繼承 Fruit

public class Cherry extends Fruit {
    public Cherry(String name) {
        super(name);
    }
}
複製程式碼

首先通過抽象將共有的屬性和方法抽象到父類中去,這樣子類只需要實現一個建構函式即可。

接下來我們看看,使用了泛型之後,是如何將 Cherry 裝進果盤中去的。

public static void createCherry() {
    //生成車釐子
    List<Cherry> cherryList = new ArrayList<>();
    Cherry cherry1 = new Cherry("車釐子1");
    Cherry cherry2 = new Cherry("車釐子2");
    Cherry cherry3 = new Cherry("車釐子3");
    cherryList.add(cherry1);
    cherryList.add(cherry2);
    cherryList.add(cherry3);

    //將剛買的車釐子放入車釐子果盤
    Plate<Cherry> cherryPlate = new Plate<>(cherryList);
    for (Cherry cherry : cherryPlate.getFruitList()) {
        System.out.println(cherry.getName());
    }
}
複製程式碼

假如我們現在又增加了一種水果 Pear ,這個時候我們只需要將 Plate<T> 中的型別引數指定為 Pear 這個樣子 Plate<Pear> 即可。

這就體現了我們上面所說的,泛型程式碼可以被不同型別的物件所重用。可以這麼認為:我們封裝了一套資料結構和演算法,用來處理一類操作,他與具體的型別無關,或者與限定的型別有關,這個時候,我們就可以使用泛型,只關注具體的操作,不用關心具體的型別。

泛型方法

我們類比泛型類可以知道,泛型方法就是具有一個或者多個型別引數的方法

將型別引數 <T> 放在修飾符的後面返回型別的前面,這樣我們的返回值,方法中的區域性變數,引數型別都可以指定為我們宣告的 T 型別。

我們這樣定義一個泛型方法:

 public static <T> T getMiddleFruit(Plate<T> plate) {
    int middle = plate.getFruitList().size();
    return plate.getFruitList().get(middle);
}
複製程式碼

這段程式碼的意思是,獲取 Plate<T> 中間的元素,也就是獲取果盤最中間的水果。

我們可以這樣來呼叫它:

public static void createCherry() {
    /...省略之前建立水果,將水果放進果盤的操作
    Cherry middleCherry=PlateUtils.getMiddleFruit(cherryPlate);
    System.out.println(middleCherry.getName());
}
複製程式碼

泛型限定

在回頭看我們上面定義的泛型類 Plate<T>,我們的型別引數是沒有做任何限定的,型別引數 T 可以在宣告的時候被指定為任何型別。

雖然我將 Plate,定義為果盤,可以傳進來任何型別的水果,但其實由於我沒有對 T 做任何的限定,那就意味著我們在宣告的時候可以傳遞任意型別。

如下我們定義一個動物型別 Animal

public class Animal {
    private String name;

    public Animal(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
複製程式碼

然後我們來嘗試將一群動物放進果盤中:

public static void createAnimal() {
    List<Animal> animalList = new ArrayList<>();
    Animal dog = new Animal("Dog");
    Animal cat = new Animal("Cat");
    animalList.add(dog);
    animalList.add(cat);

    Plate<Animal> animalPlate = new Plate<>(animalList);
    for (Animal animal : animalPlate.getFruitList()) {
        System.out.println(animal.getName());
    }
}
複製程式碼

尷尬?,我們從果盤中取出一群動物,現在我們要修正上面的程式碼,不然就亂套了,這個時候我就可以使用型別限定,對型別引數加以約束。

我們只需要將程式碼改成這樣:

public class Plate<T extends Fruit> {...}
複製程式碼

將型別限定為 Fruit 型別或者是 Fruit 的子類,這樣我們在嘗試編譯上面的createAnimal()程式碼的時候,編譯器就會報錯:

  1. Error:(172, 15) java: 型別引數model.Animal不在型別變數T的範圍內
  2. Error:(172, 37) java: 無法推斷model.plate.Plate<>的型別引數 原因: 推論變數 T 具有不相容的限制範圍 等式約束條件: model.Animal 上限: model.base.Fruit

由於我們對 Plate 能夠接收的型別做了限制,所以現在我們無法將一群動物 List<Animal> 放進果盤了。

泛型擦除

Java 核心技術:無論何時定義一個泛型型別,都會自動提供一個原始型別(Raw Type),原始型別的名字就是刪去型別引數後的泛型型別名。擦除型別變數,並切換為限定型別,沒有限定型別則替換為 Object

請看下面的程式碼:

    List<Banana> bananas = new ArrayList<>();
    Plate<Banana> bananaPlate = new Plate<>(bananas);

    List<Pear> pears = new ArrayList<>();
    Plate<Pear> pearPlate = new Plate<>(pears);
    
    System.out.println(bananaPlate.getClass());
    System.out.println(pearPlate.getClass());
    System.out.println(bananaPlate.getClass()==pearPlate.getClass());
    //print>class model.plate.Plate
    //print>class model.plate.Plate
    //print>true
複製程式碼

在執行時,Plate<Banana>Plate<Pear> 的型別是一樣的,都是 Plate 型別,我們在程式碼中指定的具體型別 BananaPear 不見了。

造成這個問題的原因就是型別擦除,型別擦除後的 Plate<T extends Fruit> 程式碼是這個樣子的,為了展示型別變數被替換成為限定型別,我特意在原有的 Plate 的程式碼中加入一句 T t;,宣告一個 T 型別的變數 t,然後我們來看擦除後的程式碼:

public class Plate {
    private Fruit t;
    private List fruitList;

    public Plate(List fruitList) {
        this.fruitList = fruitList;
    }

    public List getFruitList() {
        return fruitList;
    }

    public void setFruitList(List fruitList) {
        this.fruitList = fruitList;
    }
}
複製程式碼

可以看到在執行時所有有關於泛型的資訊全部不見了,使用泛型宣告的屬性,全都替換成了限定型別 Fruit(大家可以嘗試用 javap -c -s -p Plate 指令來反編譯位元組碼,就可以看到更具體的資訊)

在文章的最開始我們舉了一個有關於 ArrayList 的例子,宣告一個 String 型別的 ArrayList,我們先 add 一個字串進去,在將該字串取出,來看看編譯後的檔案

...
 ArrayList<String> stringList = new ArrayList<>();
        stringList.add("string");
        String string=stringList.get(0);
...
複製程式碼

image_1c55onosh1hoslgnvl81eidffe26.png-166.6kB

  1. 建立了一個原生的 ArrayList 沒有泛型相關的資訊
  2. 呼叫 add 方法,接收的引數型別是 Object 型別
  3. 呼叫 get 方法,方法的返回型別是 Object 型別
  4. 將 Object 型別轉化為 String 型別

Java 中的泛型之所以設計成編譯時泛型,就是為了相容老程式碼,能夠和之前的程式碼相互操作

拿我們的 Plate 來說說這件事,來看看原生的 Plate 型別和泛型類 Plate<T> 之間是怎麼相互操作並且會帶來什麼影響。

public class RawAndGenericOperation {
    public static void main(String[] args) {
        List<Banana> bananas = new ArrayList<>();
        Banana banana = new Banana("banana");
        bananas.add(banana);
        Plate<Banana> bananaPlate = new Plate<>(bananas);

        Plate rawPlate = bananaPlate;

        List<Pear> pears = new ArrayList<>();
        Pear pear = new Pear("pear");
        pears.add(pear);
        rawPlate.setFruitList(pears);//

        for (Object o : rawPlate.getFruitList()) {
            System.out.println(((Banana) o).getName());
            //Exception in thread "main" java.lang.ClassCastException: model.pear.Pear cannot be cast to model.banana.Banana
        }
    }
}
複製程式碼

在 For 迴圈取資料的時候?了,型別轉換異常,程式碼清晰可見,是由於我們自己將 List<Pear> 傳遞給了 Plate,原生型別沒有型別檢查,造成了型別不安全的隱患,我們在將泛型型別物件傳遞給原生型別的引用的時候,這個隱患就存在了,誰知道它們會對原生型別做些什麼呢。

在來看看將原生型別的物件,傳遞給泛型型別的引用:

public class RawAndGenericOperation {
    public static void main(String[] args) {

        List bananaList = new ArrayList();
        bananaList.add(new Banana("banana"));
        bananaList.add(new Pear("pear"));


        List<Banana> bananas = new ArrayList<>();
        bananas = bananaList;

        for (Banana banana : bananas) {
            System.out.println(banana.getName());
            //Exception in thread "main" java.lang.ClassCastException: model.pear.Pear cannot be cast to model.banana.Banana
        }
    }
}
複製程式碼

依然?,由於原生型別的 List 沒有對儲存的元素做限制,我們在 BananaList 中混入了 Pear,然後將它賦值給 List<Banana>。在迴圈的時候出現了型別轉換異常,除非你能保證原生 List 中的元素型別和泛型型別保持一致,不然就不要這麼做。

但是在與遺留的程式碼進行銜接的時候,難免會出現上述的情況,但是沒有關係,這裡只是失去了泛型程式設計提供的附加安全性,不會變的更壞了。

泛型的繼承關係

在之前我們定義了 Banana 型別,它是 Fruit 的子類。那麼 List<Banana>List<Fruit> 的子類嗎?那麼 Banana[]Fruit[] 型別的子類嗎?

在回答這兩個問題之前,我們先來看看一個概念 Java中的逆變與協變。然後我們來寫兩段程式碼試試看:

public class Covariant {
    public static void main(String[] args) {
        List<Fruit> fruitList = new ArrayList<>();
        List<Banana> bananaList = new ArrayList<>();

        //編譯器提示型別不相容
        fruitList = bananaList;

        Fruit[] fruits = new Fruit[10];
        Banana[] bananas = new Banana[10];

        //不會有任何問題
        fruits = bananas;
    }
}
複製程式碼

由上可知 List<Banana> 沒有辦法轉化成 List<Fruit> 型別。但是 Banana[] 可以轉化為 Fruit[] 型別,劃重點在 Java 中陣列是支援協變的,但是泛型是不支援協變的。假如讓泛型支援協變會怎麼樣,假設 List<Banana> 可以傳遞給 List<Fruit> 型別的引用會發什麼呢?

List<Fruit> fruitList = new ArrayList<>();
List<Banana> bananaList = new ArrayList<>();

//假設這行程式碼允許執行
fruitList = bananaList;
        
fruitList.add(new Pear("pear"));
fruitList.add(new Fruit("fruit"));

for (Banana banana : bananaList) {
    //型別轉換異常
}
複製程式碼

和上面的問題一樣,我們丟失了型別的安全性。那為什麼陣列是協變的但卻一點事情沒有呢? 首先來看看知乎中胖胖的回答Java 中陣列為什麼要設計為協變

public class CovariantArray {
    public static void main(String[] args) {
        Fruit[] fruits = new Fruit[3];
        Banana[] bananas = new Banana[4];

        bananas[0] = new Banana("banana1");
        bananas[1] = new Banana("banana2");
        bananas[2] = new Banana("banana3");

        fruits = bananas;
        fruits[3] = new Pear("pear");
        for (Fruit fruit : fruits) {
            System.out.println(fruit.getName());
            //Exception in thread "main" java.lang.ArrayStoreException: model.pear.Pear
        }
    }
}
複製程式碼

陣列中帶有特別的保護,陣列會在建立的時候記住元素型別,如果後續的插入與之前的型別不匹配,虛擬機器將會丟擲 ArrayStoreException 異常。

陣列在插入的時候就暴露出問題,如果是泛型協變的話,你就不知道什麼時候會發現問題了。

泛型陣列

在最開始你可能會編寫這樣一段程式碼:

public class GenericArray {
    public static void main(String[] args) {
        Plate<Banana>[] bananaArray = new Plate<Banana>[10];
    }
}
複製程式碼

編譯器會直接報 Error:建立泛型陣列,我們是沒有辦法通過 new 的方式來建立泛型陣列的,如果編譯器允許我們建立泛型陣列會怎麼樣?

public class GenericArray {
    public static void main(String[] args) {
    // 假設編譯器沒有報錯
        Plate<Banana>[] bananaArray = new Plate<Banana>[2];

        List<Banana> bananas = new ArrayList<>();
        Plate<Banana> bananaPlate = new Plate<>(bananas);
        bananaArray[0] = bananaPlate;

        List<Pear> pears = new ArrayList<>();
        Plate<Pear> pearPlate = new Plate<>(pears);

        Object[] objectArray = bananaArray;
        objectArray[1] = pearPlate;
    }
}
複製程式碼

可以看到我們將 Plate<Banana>[] 向上轉型為 Object[],然後向其中追加一個 Plate<Pear>物件,這個時候編譯器沒有報錯,原本我們期望在執行時,陣列會判斷出加入的資料型別不對從而報出 ArrayStoreException,但是被忘了型別擦除這回事,我們的 Plate<T> 全部被擦除成 Plate 型別,對於陣列來說,無論你插入 Plate<Banana> 或者 Plate<Pear> 由於型別擦除,它都認為是同一種型別。這個時候泛型陣列就變得型別不安全了。

編譯器只是不允許通過 new Plate<T>[] 這種方式建立陣列,我們依然可以通過其他方式來得到一個泛型陣列,這裡就不再介紹了

你總是可以將一個泛型型別轉化為原生型別。

具體的示例在上面我們講述與遺留程式碼相互呼叫的時候已經展示過了,相信你已經能夠分析出為什麼轉化為原生型別是不安全的。

泛型類是可以擴充套件或者實現其他的泛型類的。

就像我們上面一直寫的那樣,ArrayList<Banana> 是可以賦值給 List<Banana> 型別的引用的。

參考文章

  1. Java 核心技術卷1
  2. 胖胖在知乎的回答
  3. Java 協變與逆變

相關文章