Java中的泛型 – 細節篇

前言

大家好啊,我是湯圓,今天給大家帶來的是《Java中的泛型 – 細節篇》,希望對大家有幫助,謝謝

細心的觀眾朋友們可能發現了,現在的標題不再是入門篇,而是各種詳細篇細節篇

是因為之前的幾篇比較簡單,所以叫做入門篇會合適點;

現在往後的都慢慢的開始複雜化了,所以叫入門就有點標題黨了,所以改叫詳細篇或者細節篇或者進階篇等等

文章純屬原創,個人總結難免有差錯,如果有,麻煩在評論區回復或後台私信,謝啦

簡介

泛型的作用就是把類型參數化,也就是我們常說的類型參數

平時我們接觸的普通方法的參數,比如public void fun(String s);參數的類型是String,是固定的

現在泛型的作用就是再將String定義為可變的參數,即定義一個類型參數T,比如public static <T> void fun(T t);這時參數的類型就是T的類型,是不固定的

從上面的String和T來看,泛型有着濃濃的多態的味道,但實際上泛型跟多態還是有區別的

從本質上來講,多態是Java中的一個特性,一個概念,泛型是真實存在的一種類型;

目錄

下面我們詳細說下Java中的泛型相關的知識點,目錄如下:

  • 什麼是類型參數

  • 為啥要有泛型

  • 泛型的演變史

  • 類型擦除

  • 泛型的應用場景

  • 通配符限定

  • 動態類型安全

  • 等等

正文中大部分示例都是以集合中的泛型為例來做介紹,因為用的比較多,大家都熟悉

正文

什麼是類型參數

類型參數就是參數的類型,它接受類作為實際的值

白話一點來說,就是你可以把類型參數看作形參,把實際傳入的類看作實參

比如:ArrayList<E>中的類型參數E看做形參, ArrayList<String>中的類String看做實參

如果你學過工廠設計模式,那麼就可以把這裡的ArrayList<E>看做一個工廠類,然後你需要什麼類型的ArrayList,就傳入對應的類型參數即可

  • 比如,傳入Integer則為ArrayList<Integer>
  • 比如,傳入String則為ArrayList<String>

為啥要有泛型

主要是為了提高代碼可讀性和安全性

具體的要從泛型的演變史說起

泛型的演變史

從廣義上來說,泛型很早就有了,只是隱式存在的;

比如List list = new ArrayList(); //等價於List<Object> list = new ArrayList<>();

但是這個時候的泛型是很脆弱的,可讀性和安全性都很差(這個時期的集合相對於數組來說,優勢還不是很大)

首先,填充數據時,沒有類型檢查,那就有可能把Cat放到Dog集合中

其次,取出時,需要類型轉換,如果你很幸運的把對象放錯了集合(有可能是故意的),那麼運行時就會報錯轉換異常(但是編譯卻可以通過)

不過到了JDK1.5,出現了真正意義上的泛型(類型參數,用尖括號<>表示);

比如List<E>集合類,其中的E就是泛型的類型參數,因為集合中都是存的元素Element,所以用E字母替代(類似還有T,S,K-key,V-value);

這個時候,程序的健壯性就提高了,可讀性和安全性也都很高,看一眼就知道放進去的是個啥東西(這個時期的集合相對於數組來說,優勢就很明顯了

現在拿List<Dog> list = new ArrayList<>();來舉例說明

首先,填充數據時,編譯器自己會進行類型檢查,防止將Cat放入Dog中

其次,取出數據時,不需要我們手動進行類型轉換,編譯器自己會進行類型轉換

細心的你可能發現了,既然有了泛型,那我放進去的是Dog,取出的不應該也是Dog嗎?為啥編譯器還要類型轉換呢?

這裡就引出類型擦除的概念

類型擦除

什麼是類型擦除?

類型擦除指的是,你在給類型參數<T>賦值時,編譯器會將實參類型擦除為Object(這裡假設沒有限定符,限定符下面會講到)

所以這裡我們要明白一個東西:虛擬機中沒有泛型類型對象的概念,在它眼裡所有對象都是普通對象

比如下面的代碼

擦除前

public class EraseDemo<T> {
    private T t;
    public static void main(String[] args) {
        
    }
    public T getT(){
        return t;
    }
    public void setT(T t){
        this.t = t;
    }
}

擦除後

public class EraseDemo {
    private Object t;
    public static void main(String[] args) {
        
    }
    public Object getT(){
        return t;
    }
    public void setT(Object t){
        this.t = t;
    }
}

可以看到,T都變成了Object

泛型類被擦除後的類型,我們一般叫它原始類型(raw type),比如EraseDemo<T>擦除後的原始類型就是EraseDemo

相應的,如果你有兩個數組列表,ArrayList<String>ArrayList<Integer> ,編譯器也會把兩者都擦除為ArrayList

你可以通過代碼來測試一下

ArrayList<String> list1 = new ArrayList<>();
ArrayList<Integer> list2 = new ArrayList<>();
System.out.println(list1.getClass() == list2.getClass());// 這裡會打印true

上面提到的限定符是幹嘛的?

限定符就是用來限定邊界的,如果泛型有設置邊界,比如<T extends Animal>,那麼擦除時,會擦除到第一個邊界Animal類,而不是Object

下面還是以上面的代碼為例,展示下擦除前後的對比

擦除前:

public class EraseDemo<T extends Animal> {
    private T t;
    public static void main(String[] args) {
        
    }
    public T getT(){
        return t;
    }
    public void setT(T t){
        this.t = t;
    }
}

擦除後:

public class EraseDemo {
    private Animal t;
    public static void main(String[] args) {
        
    }
    public Animal getT(){
        return t;
    }
    public void setT(Animal t){
        this.t = t;
    }
}

這裡的extends符號是表示繼承的意思嗎?

不是的,這裡的extends只是表示前者是後者的一個子類,可以繼承也可以實現

之所以用extends只是因為這個關鍵詞已經內置在Java中了,且比較符合情景

如果自己再造一個關鍵詞,比如sub,可能會使得某些舊代碼產生問題(比如使用sub作為變量的代碼)

為什麼要擦除呢?

這其實不是想不想擦除的問題,而是不得不擦除的問題

因為舊代碼是沒有泛型概念的,這裡的擦除主要是為了兼容舊代碼,使得舊代碼和新代碼可以互相調用

泛型的應用場景

  • 從大的方向來說:
    • 用在類中:叫做泛型類,類名後面緊跟<類型參數>,比如ArrayList<E>
    • 用在方法中:叫做泛型方法,方法的返回值前面添加<類型參數>,比如:public <T> void fun(T obj)

是不是想到了抽象類和抽象方法?

​ 還是有區別的,抽象類和抽象方法是相互關聯的,但是泛型類和泛型方法之間沒有聯繫

  • 集中到類的方向來說:泛型多用在集合類中,比如ArrayList<E>

如果是自定義泛型的話,推薦用泛型方法,原因有二:

  1. 脫離泛型類單獨使用,使代碼更加清晰(不用為了某個小功能而泛化整個類)

  2. 泛型類中,靜態方法無法使用類型參數;但是靜態的泛型方法可以

通配符限定

這裡主要介紹<T>, <? extends T>, <? super T>的區別

  • <T>:這個是最常用的,就是普通的類型參數,在調用時傳入實際的類來替換T即可,這個實際的類可以是T,也可以是T的子類

比如List<String> list = new ArrayList<>();,這裡的String就是實際傳入的類,用來替換類型參數T

  • <? extends T>:這個屬於通配符限定中的子類型限定,即傳入實際的類必須是T或者T子類

乍一看,這個有點像<T>類型參數,都是往裡放T或者T的子類;

但是區別還是挺多的,後面會列出

  • <? super T>:這個屬於通配符限定中的超類型限定,即傳入實際的類必須是T或者T的父類

  • <?>:這個屬於無限定通配符,即它也不知道裏面該放啥類型,所以乾脆就不讓你往裡添加,只能獲取(這一點類似<? extends T>

下面用表格列出<T><? extends T>, <? super T>的幾個比較明細的區別

<T> <? extends T> <? super T>
類型擦除 傳入實參時,實參被擦為Object,但是在get時編譯器會自動轉為T 擦到T 擦到Object
引用對象 不能將引用指向子類型或者父類型的對象,比如:List<Animal> list = new ArrayList<Cat>();//報錯 能將引用指向子類型的對象,比如:List<? extends Animal> list = new ArrayList<Cat>(); 能將引用指向父類型的對象,比如:List<? super Cat> list = new ArrayList<Animal>();
添加數據 可以添加數據,T或者T的子類 不能 能,T或者T的子類

下面我們用代碼來演示下

類型擦除:

// <T>類型,傳入實參時,擦除為Object,但是get時還是實參的類型
List<Animal> list1 = new ArrayList<>();// 合法
list1.add(new Dog());// 合法
Animal animal = list1.get(0); // 這裡不需要強轉,雖然前面傳入實參時被擦除為Object,但是get時編譯器內部已經做了強制類型轉換

// <? extends T> 子類型的通配符限定,擦除到T(整個過程不再變)
List<? extends Animal> list2 = list1;// 合法
Animal animal2 = list2.get(0); // 這裡不需要強轉,因為只擦除到T(即Animal)

// <? super T> 超類型的通配符限定,擦除到Object
List<? super Animal> list3 = list1; // 合法
Animal animal3 = (Animal)list3.get(0); // 需要手動強制,因為被擦除到Object

將引用指向子類型或父類型的對象:

// <T>類型,不能指向子類型或父類型
List<Animal> list = new ArrayList<Dog>();// 報錯:需要的是List<Animal>,提供的是ArrayList<Dog>

// <? extends T> 子類型的通配符限定,指向子類型
List<? extends Animal> list2 = new ArrayList<Dog>();// 合法

// <? super T> 超類型的通配符限定,指向父類型
List<? super Dog> list3 = new ArrayList<Animal>(); // 合法

添加數據

// <T>類型,可以添加T或者T的子類型
List<Animal> list1 = new ArrayList<>();
list.add(new Dog());// 合法

// <? extends T> 子類型的通配符限定,不能添加元素
List<? extends Animal> list2 = new ArrayList<Dog>();// 正確
list2.add(new Dog()); // 報錯:不能往裡添加元素

// <? super T> 超類型的通配符限定,可以添加T或者T的子類型
List<? super Dog> list3 = new ArrayList<Animal>();
list3.add(new Dog()); // 合法,可以添加T類型的元素
list3.add(new Animal());//報錯,不能添加父類型的元素

下面針對上面的測試結果進行解惑

先從<T>的報錯開始吧

為啥<T>類型的引用不能指向子類型,比如 List<Animal> list = new ArrayList<Dog>();

首先說明一點,Animal和Dog雖然是父子關係(Dog繼承Animal),但是List<Animal> List<Dog>之間是沒有任何關係的(有點像Java和Javascript)

他們之間的關係如下圖

T引用

之所以這樣設計,主要是為了類型安全的考慮

下面用代碼演示,假設可以將List<Animal>指向子類List<Dog>

List<Animal> list = new ArrayList<Dog>();// 假設這裡不報錯
list.add(new Cat()); //這裡把貓放到狗裏面了

第二行可以看到,很明顯,把貓放到狗裏面是不對的,這就又回到了泛型真正出現之前的時期了(沒有泛型,集合存取數據時不安全)

那為啥<? extends T>就能指向子類型呢?比如List<? extends Animal> list = new ArrayList<Dog>();

說的淺一點,原因是:這個通配符限定出現的目的就是為了解決上面的不能指向子類的問題

當然,這個原因說了跟沒說一樣。下面開始正經點解釋吧

因為這個通配符限定不允許插入任何數據,所以當你指向子類型時,這個list就只能存放指向的那個集合里的數據了,而不能再往裡添加;

自然的也就類型安全了,只能訪問,不能添加

為什麼<? extends T>不允許插入數據呢?

其實這個的原因跟上面的修改引用對象是相輔相成的,合起來就是為了保證泛型的類型安全性

考慮下面的代碼

List<Animal> list = new ArrayList<>();
list.add(new Cat());
list.add(new Dog());
Dog d = (Dog) list.get(0); // 報錯,轉換異常

可以看到,插入的子類很混亂,導致提取時轉型容易出錯(這是泛型<T>的一個弊端,當然我們寫的時候多用點心可能就不會這個問題)

但是有了<? extends T>之後,就不一樣了

首先你可以通過修改引用的對象來使得list指向不同的Animal子類

其次你添加數據,不能直接添加,但是可以通過指向的Animal子類對象來添加

這樣就保證了類型的安全性

代碼如下:

// 定義一個Dog集合
List<Dog> listDog = new ArrayList<>();
listDog.add(new Dog());

// 讓<? extends Animal>通配符限定的泛型 指向上面的Dog集合
List<? extends Animal> list2 = listDog;
// 這時如果想往裡添加數據,只需要操作listDog即可,它可以保證類型安全
listDog.add(new Dog());
// 如果自己去添加,就會報錯
list2.add(new Dog());// 報錯

<? extends T>一般用在形參,這樣我們需要哪個子類型,只需要傳入對應子類的泛型對象就可以了,從而實現泛型中的多態

<? super T>為啥可以插入呢?

兩個原因

  1. 它只能插入T或者T的子類
  2. 它的下限是T

也就是說你隨便插入,我已經限制了你插入的類型為T或者T的子類

那麼我在查詢時,就可以放心的轉為T或者T的父類

代碼如下:

List<? super Dog> listDog = new ArrayList<>();
listDog.add(new Dog());
listDog.add(new Cat()); // 報錯
listDog.add(new Animal()); // 報錯
Dog dog = (Dog) listDog.get(0); // 內部被擦除為Object,所以要手動強轉

為啥<T>獲取時,編譯器會自動強轉轉換,到了這裡<? super T>,就要手動轉換了呢?

這個可能是因為編譯器也不確定你的要返回的T的父類是什麼類型,所以乾脆留給你自己來處理了

但是如果你把這個listDog指向一個父類的泛型對象,然後又在父類的泛型對象中,插入其他類型,那可就亂了(又回到的問題了,要自己多注意)

比如:

List<Animal> list = new ArrayList<>();
list.add(new Cat()); // 加了Cat
// 指向Animal
List<? super Dog> listDog = list;
listDog.add(new Dog());
list.add(new Cat()); // 報錯
list.add(new Animal()); // 報錯

Dog dog = (Dog) listDog.get(0); //報錯:轉換異常Cat-》Dog

所以建議<? super T>在添加數據的時候,盡量集中在一個地方,不要多個地方添加,像上面的,要麼都在<? super Dog>里添加數據,要麼都在<Animal>中添加

動態類型安全檢查

這個主要是為了跟舊代碼兼容,對舊代碼進行的一種類型安全檢查,防止將Cat插入Dog集合中這種錯誤

這種檢查是發生在編譯階段,這樣就可以提早發現問題

對應的類為Collections工具類,方法如下圖

類型安全檢查

代碼如下

// 動態類型安全檢查,在與舊代碼兼容時,防止將Dog放到Cat集合中類似的問題

// === 檢查之前 ===
List list = new ArrayList<Integer>();
// 添加不報錯
list.add("a");
list.add(1);
// 只有用的時候,才會報錯
Integer i = (Integer) list.get(0); // 這裡運行時報錯

// === 檢查之後 ===
List list2 = Collections.checkedList(new ArrayList<>(), Integer.class);
// 插入時就會報錯
list2.add("a"); // 這裡編譯時就報錯,提前發現錯誤
list2.add(1);

總結

泛型的作用:

  1. 提高類型安全性:預防各種類型轉換問題
  2. 增加程序可讀性:所見即所得,看得到放進去的是啥,也知道會取出啥
  3. 提高代碼重用性:多種同類型的數據(比如Animal下的Dog,Cat)可以集合到一處來處理,從而調高代碼重用性

類型擦除:

​ 泛型T在傳入實參時,實參的類型會被擦除為限定類型(即<? extends T>中的T),如果沒有限定類型,則默認為Object

通配符限定:

  1. <? extends T>:子類型的通配符限定,以查詢為主,比如消費者集合場景
  2. <? super T>:超類型的通配符限定,以添加為主,比如生產者集合場景

後記

最後,感謝大家的觀看,謝謝