1. 程式人生 > 程式設計 >Java SE基礎鞏固(十):泛型

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

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

泛型的定義主要有以下兩種:

  1. 在程式編碼中一些包含型別引數的型別,也就是說泛型的引數只可以代表類,不能代表個別物件。(這是當今較常見的定義)
  2. 在程式編碼中一些包含引數的。其引數可以代表類或物件等等。(現在人們大多把這稱作模板

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

一些強型別程式語言支援泛型,其主要目的是加強型別安全及減少類轉換的次數,但一些支援泛型的程式語言只能達到部分目的。

Java中的泛型適用於第一種定義,即:在程式編碼中一些包含型別引數的型別,也就是說泛型的引數只可以代表類,不能代表個別物件。

什麼是型別引數?假設你手上有兩個完全相同容器(自行想象,鍋碗瓢盆什麼的),現在倆都還是空的,但你也不想什麼亂七八糟的東西都往裡面扔,所以搞了兩個小紙條,上面寫的“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》第三版(英文版)