Java SE基礎鞏固(十):泛型

Java泛型是Java5推出的一個強大的特性,那什麼是泛型?下面是從維基百科上摘下來的定義:java

泛型的定義主要有如下兩種:程序員

  1. 在程序編碼中一些包含類型參數的類型,也就是說泛型的參數只能夠表明類,不能表明個別對象。(這是當今較常見的定義)
  2. 在程序編碼中一些包含參數的。其參數能夠表明類或對象等等。(如今人們大多把這稱做模板

不論使用哪一個定義,泛型的參數在真正使用泛型時都必須做出指明。數組

一些強類型程序語言支持泛型,其主要目的是增強類型安全及減小類轉換的次數,但一些支持泛型的程序語言只能達到部分目的。安全

Java中的泛型適用於第一種定義,即:在程序編碼中一些包含類型參數的類型,也就是說泛型的參數只能夠表明類,不能表明個別對象。bash

什麼是類型參數?假設你手上有兩個徹底相同容器(自行想象,鍋碗瓢盆什麼的),如今倆都仍是空的,但你也不想什麼亂七八糟的東西都往裏面扔,因此搞了兩個小紙條,上面寫的「T恤」,一個寫的「鞋子」,分別貼到兩個容器上,之後貼有T恤的容器就只裝T恤,貼有鞋子的容器就只裝鞋子。在這個小例子中,小紙條上的內容就是所謂的「類型參數」。併發

上面的例子可能不太合適(實在是不太好舉例),但不用擔憂,到下面看到Java泛型的「樣子」時,再回想這個例子,就會明白了。工具

1 Java中的泛型的使用

Java泛型有三種使用方式,分別是:泛型類、泛型接口、泛型方法,下面將就這三種方式逐一介紹。學習

1.1 泛型類

當泛型做用在類定義的時候,該類就是泛型類,JDK裏(1.5以後)有不少泛型類,例如ArrayList,HashMap,ThreadLocal等,以下所示:測試

public class MyList<T> {
   //.....
}
複製代碼

中的T是泛型標識,能夠是任意字符,不過通常會採用一些通用的單字符或者雙字符,例如T、K、V、E等。在編寫類定義的時候可使用T來代替類型,例如:編碼

//用在方法參數上和返回值上
//合法的
public T method1(T val) {
    //do something
    return (一個T類型的對象);
}

//不合法,不能用在靜態方法
public static T method1(T val) {
    //do something
    return (一個T類型的對象);
}


//用在字段聲明
private T val; //ok
private static T staticVal; //不合法,不能用在靜態字段上
複製代碼

至於爲何不能用在靜態字段或者方法上,後面講到泛型的實現時會講到,這裏先把這個問題放着。

1.2 泛型接口

JDK裏也有不少泛型接口,例如List,Map,Set等,當泛型做用在接口定義的時候,這個接口就是一個泛型接口,例如:

public interface MyGenericInterface<T> {
	//用在抽象方法上
    T method1(T t);

    //或者默認方法也是能夠的
    default T method2(T val) {
        
    }
    //但仍然不能做用在靜態方法和靜態字段上
    //不合法
    static T method3() {
        
    }
    
    //字段就很好理解了,怎麼寫都不像合法的
    T message = "MESSAGE"; //語法規定了接口裏的字段默認是static final的,因此必需要有初始化值,但T不表明某個具體的類型,因此泛型字段根本不合理。
}
複製代碼

代碼註釋寫的比較清楚了,很少作說明了,接下來看看泛型方法。

1.3 泛型方法

當泛型做用在方法上時,該方法就是一個泛型方法。注意,這裏和以前在泛型類或者泛型接口中的方法裏使用泛型是不一樣的,咱們既能夠在一個泛型類或者泛型接口中定義泛型方法,也能夠在普通類或者接口中定義泛型方法。泛型方法較泛型類和泛型接口的定義稍微複雜一些,以下所示:

public <E>  E method1(E val) {
    return val;
}

//靜態方法也是合法的
public static  <E>  E method2(E val) {
    return val;
}
複製代碼

這裏的泛型標識要在修飾符以後,返回值以前的位置,不能放錯,這裏的泛型標識E的做用範圍僅限於方法內部,便可以簡單的將該泛型標識是一個局部變量(實際上不是)。但爲何這時候泛型能夠做用在靜態方法上了呢?仍是和以前同樣,留到後面解釋。

2 泛型的做用

上面三個小結介紹了泛型類,泛型接口和泛型方法,但僅僅是介紹瞭如何定義,沒有介紹到如何使用泛型,在實踐的過程當中,會接觸到文章最開始說到的「類型參數」的概念,但願能對讀者有幫助。

public class Main {

    public static void main(String[] args) {
        List<Integer> integers = new ArrayList<>();
        integers.add(1);
        integers.add(2);
        for (Integer integer : integers) {
            System.out.println(integer);
        }
    }
}
複製代碼

代碼很是很是簡單,使用了List接口和ArrayList實現類,注意這一行:

List<Integer> integers = new ArrayList<>();
複製代碼

Integer即所謂的「類型參數」,表示這個List容器只能存放Integer類以其子類對象實例,類型參數只能是引用類型,不能是基本類型(例如int,double,char等),JVM會在編譯期會經過類型檢查來保證這一點。賦值號後面的<>稱做「菱形操做符」,是Java7提供的一個語法糖,用於簡化泛型的使用,編譯器會自動推斷出類型參數,例如在這裏,編譯器會自動推斷出類型參數是Integer,而不用在顯式指明ArrayList的類型參數,在Java7以前,上面那一行語句不得不這樣寫:

List<Integer> integers = new ArrayList<Integer>();
複製代碼

在聲明並賦值完成以後,咱們往容器裏「扔」了兩個元素1和2,由於自動裝箱的緣由,1和2會被包裝成Integre類的實例,因此並不會發生類型安全問題,假設如今加入以下語句:

integers.add("yeonon");
複製代碼

會發生什麼狀況?編譯會報錯,錯誤提示的意思大概是類型不匹配。爲何呢?其實在剛剛已經說了,這個容器有一個類型參數Integer,這就代表了該容器只能存放Integer類以其子類對象實例,若是強行放入其餘類型的實例,由於類型檢查機制的存在,因此會發生類型匹配異常,這個就是泛型最重要的一個特性:保證類型安全。在沒有泛型機制以前,咱們會這樣使用容器類:

List integers = new ArrayList();
integers.add(1);
integers.add(2);
integers.add("yeonon");
複製代碼

編譯一下,發生編譯經過,只不過有一些警告而已。這是有類型安全問題的,爲何?例如如今我要從容器中提取元素,就不得不進行強制類型轉換,以下所示:

Integer i1 = (Integer) integers.get(0);
Integer i2 = (Integer) integers.get(1);
String s1 = (String) integers.get(2);
複製代碼

固然,徹底能夠不作類型轉換,直接使用Object類來接收元素,但那有什麼意義呢?光有一個Object引用,幾乎沒什麼操做空間,最終仍是要作類型轉換的。

幸虧這裏只有三個元素,並且都明確知道元素的順序,第1,2個是Integer類型的,第3個是String類型的,因此能夠準確的作出類型轉換。那若是是下面這種狀況呢?

public processList(List list) {
    //如何處理元素?
}
複製代碼

在processList方法中,List是從外部傳進來的,徹底不知道這個List裏是些什麼東西,若是魯莽的將元素強轉成某種類型,就很是有可能出現強轉異常,並且該異常仍是運行時異常,即不肯定何時會發生異常!可能你會說,那給方法寫個文檔說明,說明List裏存的元素是Integer類型,而後要求客戶端也必須傳入元素全是Integer的List,這不就完事兒了?確實,這是一個解決方案,但這其實只是在制定「協議」,並且這個協議屬於「君子協議」,客戶端徹底可能會出於各類各樣的緣由違反這個協議(例如客戶端被入侵了,或者調用者沒有注意到這個「協議」),因此,仍是有可能發生類型安全問題。

經過這個例子,我想讀者已經能感覺到泛型帶來的好處了,泛型能夠在編譯期發現類型錯誤,併發出錯誤報告,提示程序員!這使得類型安全問題不會出如今不可控的運行時,而是出如今可控的編譯期,這個特性使Java語言的安全性大大提升。

那Java中的泛型是如何實現的呢?答案是經過「擦除」來實現的。

3 泛型擦除

常常在論壇、社區裏聽到Java的泛型實現是僞泛型,而C#、C++的泛型實現纔是真正的泛型。這麼說是有緣由的,由於Java源碼編譯後的字節碼裏不存在什麼類型參數。舉個例子,現有以下代碼:

public class Main {

    public static void main(String[] args) {
        List<Integer> integers = new ArrayList<>();
        integers.add(1);
        integers.add(2);
        for (Integer integer : integers) {
            System.out.println(integer);
        }
    }
}
複製代碼

使用Javac編譯,編譯後的.class文件內容以下(我使用的是IDEA來打開的,若是使用其餘工具,可能會略有差異):

public class Main {
    public Main() {
    }

    public static void main(String[] var0) {
        ArrayList var1 = new ArrayList();
        var1.add(1);
        var1.add(2);
        Iterator var2 = var1.iterator();

        while(var2.hasNext()) {
            Integer var3 = (Integer)var2.next();
            System.out.println(var3);
        }

    }
}
複製代碼

發現,確實沒有相似的字符出現了,換句話說,類型參數被「擦除」了。取而代之的是,當有須要進行類型轉換的時候,編譯器幫咱們加上了強制類型轉換的語法,例如這句:

Integer var3 = (Integer)var2.next();
複製代碼

從這裏能夠看出,JVM是不知道類型參數的信息的(JVM只認字節碼),知道了這一點以後就能夠回答上面留下的兩個問題了。

爲何在泛型類和泛型接口中,泛型不能做用在靜態方法或者靜態字段上?

靜態方法或者靜態字段是屬於類信息的一部分,存儲在方法區且只有一份,可被類的多個不一樣實例共享,所以即便編譯器知道類型信息,能夠作特殊處理,也沒法爲靜態量肯定某一種類型。假設容許靜態方法或者靜態字段,以下代碼所示:

public A<T> {
    public static T val;
}

public static void main(String[] args) {
    A<Integer> a1 = new A<>();
    A<String> a2 = new A<>();
    System.out.println(a1.val);
    System.out.println(a2.val);
}
複製代碼

這裏的val到底應該是什麼類型呢?若是該程序能正常運行,那麼只有一種可能,就是有兩份不一樣類型的靜態量,但虛擬機的知識告訴咱們,這顯然是不符合規範的,因此這種使用方法是不被容許的。

反過來看一下普通實例方法和字段,由於普通實例方法和字段是能夠有多份的(每一個對象一份),因此編譯器徹底能夠根據類型參數來肯定對象實例裏的實例方法和字段的類型。須要注意的是,這裏的類型信息是編譯器知道的,虛擬機是不知道的,編譯器能夠爲每一個不一樣參數類型的實例對象作類型檢查、類型轉換等操做。例如上面的a1和a2對象,編譯器知道他們的類型參數分別是Integre和String,因此在編譯的時候能夠對他們作類型檢查、類型轉換等。

爲何泛型方法就可使得泛型做用在靜態量上呢?

其實這仍是編譯器的「把戲」。來看個例子:

public class Main {

    public static void main(String[] args) {
        MyList<Integer> list1 = new MyList<>();
        MyList<String> list2 = new MyList<>();

        MyList.method2(1);
        MyList.method2("String");
    }
}
複製代碼

用javac編譯後,用javap來查看字節碼信息,大體內容以下(省略了無關部分):

#21 = NameAndType #28:#29 // method2:(Ljava/lang/Object;)Ljava/lang/Object;
   
 
 		20: invokestatic  #5 // Method top/yeonon/generic/MyList.method2:(Ljava/lang/Object;)Ljava/lang/Object;
        23: pop
        24: ldc           #6 // String String
        26: invokestatic  #5 // Method top/yeonon/generic/MyList.method2:(Ljava/lang/Object;)Ljava/lang/Object;
複製代碼

發如今序號20和26調用了method2方法,從常量池#21號能夠看到,method2的參數是Object類型,說明在虛擬機中,泛型參數的類型實際只是Object類型,沒有違背虛擬機規範。什麼類型檢查啊、自動類型推斷、類型轉換啊都是編譯器本身加上去的。

更多關於泛型擦除的知識,建議多多參考資料,並結合javac、javap等工具進行研究。

4 泛型通配符

在泛型系統中,大體有如下幾種聲明泛型的方式:

  • 。最簡單的聲明,T能夠表明任何類型,可是當類型肯定下來以後就只能表明某個類型,例如List中,T就表明了String,不能再表明其餘類型。
  • 。無界通配符形式,這種狀況下,類型參數能夠是任意類型,例如Class,可是這種形式是隻讀的,即不能改變值,通常用在方法返回值或者方法參數中。
  • 。有界通配符形式,其類型參數能夠是T類型及其子類型。例若有List的聲明,那麼這個list能插入int類型,也能插入long類型,在這裏T就是Number,其子類型例如Interger,Long等都是Number的子類型。以下代碼所示: ```java //E是類聲明時候的泛型。咱們在該方法中使用有界通配符,使得能夠接受多種類型的值 public void pushAll(Iterable iterable) { for (E e : iterable) { push(e); } } //測試類,建立了類型參數爲Integer和Double的List,使用pushAll方法,均可以正常運行,若是pushAll方法沒有使用泛型通配符,那麼就只能插入一種類型的元素。 public static void main(String[] args) { MyStack myStack = new MyStack<>(); List integers = new ArrayList<>(); integers.add(1); integers.add(2); List doubles = new ArrayList<>(); doubles.add(1.0); doubles.add(2.0); myStack.pushAll(integers); myStack.pushAll(doubles); while (!myStack.isEmpty()) { System.out.println(myStack.pop()); } } ```
  • 。和上面那種差很少,只是適配的類型只能是T或者T的父類。

使用有界通配符能提高泛型的靈活性,使得泛型能夠同時爲多種類型而工做,從而使得咱們不須要爲多種類型編寫類似的代碼,從另外一方面提供了代碼的複用性。可是正確使用有界通配符會比較困難,其中最麻煩的是如何肯定使用有上界的通配符仍是有下界的通配符?《Effective Java》一書中給出了一個原則:PECS(producer-extends,consumer-super)。即對於生產者,使用有上界的通配符(extends,上界是T),對於消費者,使用有下界的通配符(super,下界是T)。

如今又有了新的問題,如何區分消費者和生產者。簡單來講,對於集合,消費者就是使用容器裏的元素,例如List.sort(Comparator<? super E> c),sort須要使用到list內部的元素,因此這個方法是消費者,根據PECS原則,方法聲明的參數應該是有下界的通配符。又例如List.addAll(Collection<? extends E> c)方法,addAll是將元素插入到容器中,屬於生產者,根據PECS原則,方法參數應該使用有上界的通配符。

雖然有界通配符能提升API的靈活性,可是若是該方法不是消費者或是是生產者,那麼就不要使用有界通配符了,直接使用便可,儘可能保持API的簡單也是咱們的設計原則。

總之,使用有界通配符能夠大大提供API的靈活性,不過在設計API時,應該儘可能保持簡單,並且遵循PECS原則。

5 泛型數組

數組和泛型容器類是有很大區別的,JVM把A[]數組和B[]數組當作兩種不一樣的類型,而將List和List當作同一個類型List,假設能建立泛型數組,以下代碼所示:

public class Main {

    public static void main(String[] args) {
        List<String>[] stringLists = new List<String>[1]; //1
        List<Integer> integerList = new ArrayList<>();  //2
        integerList.add(0);  //3
        Object[] objects = stringLists; //4
        objects[0] = integerList; //5
        String s = stringLists[0].get(0); //6
    }
}
複製代碼

代碼有些繞,咱們一行一行分析:

  1. 第1行,建立了一個泛型數組stringLists,數組元素的類型是List,合法的(咱們的假設前提)。
  2. 第2行,建立了一個List容器對象integerList。
  3. 第3行,往integerList裏插入一個元素。
  4. 第4行,將stringLists賦值給Object[]類型的數組。這裏的賦值是容許的,屬於向上類型轉換。
  5. 第5行,設置obejcts數組的第一個元素爲integerList,這也是合法的,由於List的最頂層父類是Object,注意這裏的integerList是List類型。
  6. 第6行,問題來了,獲取stringLists的第一個List元素(實際上是integerList),並獲取該List的第一個元素(該元素的類型實際上是Integer),但編譯器認爲既然從stringLists裏獲取,裏面的List存儲的應該是String類型的元素,因此這裏賦值給String引用就沒有必要進行類型轉換。但實際上,這裏應該是Integer類型,但要在運行時纔會拋出類型轉換異常。

這就是泛型數組帶來的問題,最根本的緣由仍是由於泛型擦除的機制,虛擬機沒法區分List和List,因此爲了不這種難以發覺的問題,就乾脆禁止建立泛型數組了。

雖然有一些辦法能夠繞開建立泛型數組的限制,但最好不要這樣幹,由於這樣就失去了泛型帶來的在編譯期發現類型安全問題的好處,得不償失。

6 小結

本文簡單介紹了泛型,也講了一下泛型的實現方式:擦除。說實話,泛型是比較複雜難懂的知識點,想理解透徹,須要有必定的泛型使用經驗,或者說是真真切切被坑過,不然會總以爲泛型這玩意有點「虛無縹緲」。至於如何學習,個人經驗是閱讀JDK的源碼,注意JDK是如何使用泛型的。

7 參考資料

《Effective Java》第三版(英文版)

相關文章
相關標籤/搜索