06.19 深入淺出JDK泛型的基本原理

深入淺出JDK泛型的基本原理

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

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

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

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

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

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

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

類型擦除

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

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

public class Caculate {
private T num;
}

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

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

public class Caculate{
public Caculate(){}
private Object num;
}

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

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

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

public class Caculate {
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) {
.....
}
/<integer>

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

深入淺出JDK泛型的基本原理

這是上述三行代碼的一個反編譯結果,可以看到,實際上編譯器會正常的將 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 add(T num){
return num;
}
}

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

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

Caculate caculate = new Caculate();
caculate.<integer>add(12);
caculate.<string>add("fadf");
/<string>/<integer>

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

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

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

caculate.add("fadf");

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

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

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

泛型的類型限定

這裡的類型限定其實指的是這麼個語法:


它既可以應用於泛型類或者接口的定義上,也可以應用在泛型方法的定義上,它聲明瞭一個泛型的類型 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;//編譯不通過
}
/<number>/<integer>

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

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

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

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

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

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

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

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

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

public void test2(ArrayList extends Number> list){

}

ArrayList extends Number> 表示泛型類型具體是什麼不知道,但是具體類型必須是 Number 及其子類類型。例如:ArrayList<number>,ArrayList<integer>,ArrayList<double> 等。/<double>/<integer>/<number>

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

ArrayList> list = new ArrayList<>();

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

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

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

細節與侷限

通配符的只讀性

考慮這樣一段代碼:

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

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

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

泛型不允許創建數組

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

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

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

如果對java微服務、分佈式、高併發、高可用、大型互聯網架構技術、面試經驗交流。感興趣可以關注我的頭條號,我會在微頭條不定期的發放免費的資料鏈接,這些資料都是從各個技術網站蒐集、整理出來的,如果你有好的學習資料可以私聊發我,我會註明出處之後分享給大家。歡迎分享,歡迎評論,歡迎轉發

另外:本月的第二波福利將在本月23日微頭條公佈,感謝大家一直以來的支持,小編會繼續努力的。


分享到:


相關文章: