泛型的基本原理

YangAM發表於2018-06-19

泛型是 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 型別,當這些方法被呼叫後,編譯器會限定這個結果只能賦值給 Integer 或者 Object。

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

泛型的基本使用

泛型類與介面

定義一個泛型類或介面是容易的,我們看幾個 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

相關文章