泛型的基本原理


泛型是 JDK1.5 的一個新特性,其實就是一個『語法糖』,本質上就是編譯器為了提供更好的可讀性而提供的一種小「手段」,虛擬機層面是不存在所謂『泛型』的概念的。

在我看來,『泛型』的存在具有以下兩點意義,這也是它被設計出來的初衷。

一是,通過泛型的語法定義,編譯器可以在編譯期提供一定的類型安全檢查,過濾掉大部分因為類型不符而導致的運行時異常,例如:

ArrayList<Integer> list = new ArrayList<>();
list.add("ddddd"); //編譯失敗

由於我們的 ArrayList 是符合泛型語法定義的容器,所以你可以在實例化的時候指定一個類型,限定該容器只能容納 Integer 類型的元素。而如果你強行添加其他類型的元素進入,那么編譯器是不會通過的。

二是,泛型可以讓程序代碼的可讀性更高,並且由於本身只是一個語法糖,所以對於 JVM 運行時的性能是沒有任何影響的。

當然,『泛型』也有它與身俱來的一些缺點,雖然看起來好像只是提供了一種類型安全檢查的功能,但是實際上這種語法糖的實現卻沒有看起來的那樣輕松,理解好泛型的基本原理將有助於你理解各類容器集合框架。

類型擦除

『類型擦除』的概念放在最開始進行介紹是為了方便大家初步建立起對於『泛型』的一個基本認識,從而對於后續介紹的使用方式上會更容易理解。

泛型這種語法糖,編譯器會在編譯期間「擦除」泛型語法並相應的做出一些類型轉換動作。例如:

public class Caculate<T> {

    private T num;
}

我們定義了一個泛型類,具體定義泛型類的細節待會會進行詳細介紹,這里關注我們的類型擦除過程。定義了一個屬性成員,該成員的類型是一個泛型類型,這個 T 具體是什么類型,我們也不知道,它只是用於限定類型的。

當然,我們也可以反編譯一下這個 Caculate 類:

public class Caculate{

    public Caculate(){}

    private Object num;
}

會得到這樣一個結果,很明顯的是,編譯器擦除 Caculate 類后面的兩個尖括號,並且將 num 的類型定義為 Object 類型。

當然,有人可能就會問了,「是不是所有的泛型類型都以 Object 進行擦除呢?」

答案是:大部分情況下,泛型類型都會以 Object 進行替換,而有一種情況則不是。

public class Caculate<T extends String> {

    private T num;
}

這種情況的泛型類型,num 會被替換為 String 而不再是 Object。

這是一個類型限定的語法,它限定 T 是 String 或者 String 的子類,也就是你構建 Caculate 實例的時候只能限定 T 為 String 或者 String 的子類,所以無論你限定 T 為什么類型,String 都是父類,不會出現類型不匹配的問題,於是可以使用 String 進行類型擦除。

那么很多人也會有這樣的疑問,你類型擦除之后,所有泛型相關方法的返回值都是 Object,那我當初泛型限定的具體類型還有用嗎?例如這樣一個方法:

ArrayList<Integer> list = new ArrayList();
list.add(10);
Integer num = list.get(0);
//這是 ArrayList 內部的一個方法
public E get(int index) {
    .....
}

就是說,你類型擦除之后,方法 get 的返回值 E 會被擦除為 Object 類型,那么為什么我們看到的確實返回的 Integer 類型呢?

image

這是上述三行代碼的一個反編譯結果,可以看到,實際上編譯器會正常的將 ArrayList 編譯並進行類型擦除,然后返回實例。但是除此之外的是,如果構建 ArrayList 實例時使用了泛型語法,那么編譯器將標記該實例並關注該實例后續所有方法的調用,每次調用前都進行安全檢查,非指定類型的方法都不能調用成功。

其實還有一點可能大家都很少關注,大多數人只是知道編譯器會類型擦除一個泛型類並對創建出來的實例進行一定的安全檢查。但是實際上編譯器不僅關注一個泛型方法的調用,它還會為某些返回值為限定的泛型類型的方法進行強制類型轉換,由於類型擦除,返回值為泛型類型的方法都會擦除成 Object 類型,當這些方法被調用后,編譯器會額外插入一行 checkcast 指令用於強制類型轉換。

其實這一個過程,我們管它叫做『泛型翻譯』。不得不感嘆一下,編譯器為了蒙騙虛擬機對程序員提供泛型服務可是沒少費心思啊。

泛型的基本使用

泛型類與接口

定義一個泛型類或接口是容易的,我們看幾個 JDK 中的泛型類。

  • public class ArrayList
  • public interface List
  • public interface Queue

基本格式是這樣的:

訪問修飾符 class/interface 類名或接口名<限定類型變量名>

其中「限定類型變量名」可以是任意一個變量名稱,你叫它 T 也好,E 也好,只要符合 Java 變量命名規范就可以。在這里相當於聲明了一個泛型限定類型,該類中的成員屬性或者方法都可以直接拿來用。

泛型方法

這里大家需要明確一點的是,泛型方法並不一定依賴其外部的類或者接口,它可以獨立存在,也可以依賴外圍類存在。例如:

public E get(int index) {
    rangeCheck(index);
    return elementData(index);
}

ArrayList 的這個 get 方法就是一個泛型方法,它依賴外圍 ArrayList 聲明的 E 這個泛型類型,也就是它沒有自己聲明一個泛型類型而用的外圍類的。

當然,另一種方式就是自己申明一個泛型類型並使用:

public class Caculate {

    public <T> T add(T num){
        return num;
    }
}

這是泛型方法的另一種形態,其中 <T> 用於聲明一個名稱為 T 的泛型類型,第二個 T 是方法的返回值。

所以外部調用該方法都需要指定一個限定類型才能調用,像這樣:

Caculate caculate = new Caculate();
caculate.<Integer>add(12);
caculate.<String>add("fadf");

使用泛型的目的就是為了限定類型,本來不使用泛型語法,那么所有的參數都是 Object 類型的,現在泛型允許我們限定具體類型,這一點要明確。

當然,大家可能沒怎么見過這樣的調用語法,無論是日常寫代碼,或是看 JDK 源碼實現里,基本上都省略了類型限定部分,也就是上述代碼等效於:

Caculate caculate = new Caculate();
caculate.add(12);
caculate.add("fadf");

為什么呢?因為編譯會推斷你的參數類型,所以允許你省略,但前提是你這個方法是有參數的,如果你這個方法的邏輯是不需要傳參的,那么你依然需要顯式指定限定的具體類型。例如:

public class Caculate {

    public <T> T add(){
        T num = null;
        return num;
    }
}
Caculate caculate = new Caculate();
caculate.add();

這樣的 add 方法調用,就意味着你沒有限定 T 的類型,那么這個 T 實際上就是 Object 類型,並沒有被限定。

泛型的類型限定

這里的類型限定其實指的是這么個語法:

<T extends String>

它既可以應用於泛型類或者接口的定義上,也可以應用在泛型方法的定義上,它聲明了一個泛型的類型 T,並且 T 類型必須是 String 或者 String 的子類,也就是外部使用時所傳入的具體限定類型不能是非 String 體系的類型。

使用這種語法時,由於編譯器會確保外部使用時傳入的具體限定類型不會超過 String,所以在編譯期間將不再使用 Object 做類型擦除,可以使用 String 進行類型擦除。

通配符

通配符是用於解決泛型之間引用傳遞問題的特殊語法。看下面一段代碼:

public static void main(String[] args){
    Integer[] integerArr = new Integer[2];
    Number[] numberArr = new Number[2];
    numberArr = integerArr;

    ArrayList<Integer> integers = new ArrayList<>();
    ArrayList<Number> numbers = new ArrayList<>();
    numbers = integers;//編譯不通過
}

Java 中,數組是協變的,即 Integer extends Number,那么子類數組實例是可以賦值給父類數組實例的。那是由於 Java 中的數組類型本質上會由虛擬機運行時動態生成一個類型,這個類型除了記錄數組的必要屬性,如長度,元素類型等,會有一個指針指向內存某個位置,這個位置就是該數組元素的起始位置。

所以子類數組實例賦值父類數組實例,只不過意味着父類數組實例的引用指向堆中子類數組而已,並不會有所沖突,因此是 Java 允許這種操作的。

而泛型是不允許這么做的,為什么呢?

我們假設泛型允許這種協變,看看會有什么問題。

ArrayList<Integer> integers = new ArrayList<>();
ArrayList<Number> numbers = new ArrayList<>();
numbers = integers;//假設的前提下,編譯器是能通過的
numbers.add(23.5);

假設 Java 允許泛型協變,那么上述代碼在編譯器看來是沒問題的,但運行時就會出現問題。這個 add 方法實際上就將一個浮點數放入了整型容器中了,雖然由於類型擦除並不會對程序運行造成問題,但顯然違背了泛型的設計初衷,容易造成邏輯混亂,所以 Java 干脆禁止泛型協變。

所以雖然 ArrayList<Integer> 和 ArrayList<Number>編譯器類型擦除之后都是 ArrayList 的實例,但是起碼在編譯器看來,這兩者是兩種不同的類型。

那么,假如有某種需求,我們的方法既要支持子類泛型作為形參傳入,也要支持父類泛型作為形參傳入,又該怎么辦呢?

我們使用通配符處理這樣的需求,例如:

public void test2(ArrayList<? extends Number> list){
        
}

ArrayList<? extends Number> 表示泛型類型具體是什么不知道,但是具體類型必須是 Number 及其子類類型。例如:ArrayList<Number>,ArrayList<Integer>,ArrayList<Double> 等。

但是,通配符往往用於方法的形參中,而不允許用於定義和調用語法中。例如下面的語句是不被支持的:

ArrayList<?> list = new ArrayList<>();

當然了,除了 <? extends xxx> 這種通配符,還有另外兩種:

  • :通配任意一種類型
  • :必須是某個類型的父類

通配符相當於一個集合,符合通配符描述的類型都被框進集合中,方法調用時傳入的實參都必須是這個集合中的一員,否則將不能通過編譯。

細節與局限

通配符的只讀性

考慮這樣一段代碼:

ArrayList<Number> list = new ArrayList<>();
ArrayList<?> arrayList = list;
arrayList.add(32);
arrayList.add("fadsf");
arrayList.add(new Object());

上述的三條 add 語句都不能通過編譯,這就是通配符的一個局限點,通配符匹配出來的泛型類型只能讀取,不能寫。

原因也很簡單,? 代表不確定類型,即你不知道你這個容器里面放的是什么類型的數據,所以你只能讀取里面的數據,不能瞎往里面添加元素。

泛型不允許創建數組

我們剛開始介紹通配符的時候說過,數組具有協變性,即子類數組實例可以賦值給父類數組實例。我們也說過,泛型類型不具有協變性,即便兩個泛型類實例的具體類型是父子關系,他們之間也不能相互轉換。

具體原因是什么,我們也詳細介紹了,大致意思就是,父類容器可以放任意類型的元素,而子類容器只能放某種特殊類型的元素,如果父類代表了某一個子類容器,那么父類容器就有可能放入非當前子類實例所允許的元素進入容器,這會導致邏輯上的混亂,所以 Java 不允許這么做。

那么,如果允許泛型創建數組,由於數組的協變性,泛型數組必然也具有協變性,而泛型本身又不允許協變,自然沖突,所以泛型數組也是不允許創建的。


文章中的所有代碼、圖片、文件都雲存儲在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

歡迎關注微信公眾號:OneJavaCoder,所有文章都將同步在公眾號上。

image


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM