Java中的泛型 - 細節篇

語言: CN / TW / HK

前言

大家好啊,我是湯圓,今天給大家帶來的是《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指向一個父類的泛型物件,然後又在父類的泛型物件中,插入其他型別,那可就亂了(又回到<T>的問題了,要自己多注意)

比如:

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>:超型別的萬用字元限定,以新增為主,比如生產者集合場景

後記

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

分享到: