深入理解JAVA泛型

1. Why ——引入泛型機制的原因

假如我們想要實現一個String數組,並且要求它可以動態改變大小,這時我們都會想到用ArrayList來聚合String對象。然而,過了一陣,我們想要實現一個大小可以改變的Date對象數組,這時我們當然希望能夠重用之前寫過的那個針對String對象的ArrayList實現。

在Java 5之前,ArrayList的實現大致如下:

1
2
3
4
5
6
public class ArrayList {
    public Object get(int i) { ... }
    public void add(Object o) { ... }
    ...
    private Object[] elementData;
}

從以上代碼我們可以看到,用於向ArrayList中添加元素的add函數接收一個Object型的參數,從ArrayList獲取指定元素的get方法也返回一個Object類型的對象,Object對象數組elementData存放這ArrayList中的對象, 也就是說,無論你向ArrayList中放入什麼類型的類型,到了它的內部,都是一個Object對象。

基於繼承的泛型實現會帶來兩個問題:第一個問題是有關get方法的,我們每次調用get方法都會返回一個Object對象,每一次都要強制類型轉換爲我們需要的類型,這樣會顯得很麻煩;第二個問題是有關add方法的,假如我們往聚合了String對象的ArrayList中加入一個File對象,編譯器不會產生任何錯誤提示,而這不是我們想要的。

所以,從Java 5開始,ArrayList在使用時可以加上一個類型參數(type parameter),這個類型參數用來指明ArrayList中的元素類型。類型參數的引入解決了以上提到的兩個問題,如以下代碼所示:

1
2
3
4
5
ArrayList<String> s = new ArrayList<String>();
s.add("abc");
String s = s.get(0); //無需進行強制轉換
s.add(123);  //編譯錯誤,只能向其中添加String對象
...

在以上代碼中,編譯器“獲知”ArrayList的類型參數String後,便會替我們完成強制類型轉換以及類型檢查的工作。

2. 泛型類

    所謂泛型類(generic class)就是具有一個或多個類型參數的類。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Pair<T, U> {
    private T first;
    private U second;
 
    public Pair(T first, U second) {
        this.first = first;
        this.second = second;
    }
 
    public T getFirst() {
        return first;
    }
 
    public U getSecond() {
        return second;
    }
 
    public void setFirst(T newValue) {
        first = newValue;
    }
 
    public void setSecond(U newValue) {
        second = newValue;
    }
}

上面的代碼中我們可以看到,泛型類Pair的類型參數爲T、U,放在類名後的尖括號中。這裏的T即Type的首字母,代表類型的意思,常用的還有E(element)、K(key)、V(value)等。當然不用這些字母指代類型參數也完全可以。

實例化泛型類的時候,我們只需要把類型參數換成具體的類型即可,比如實例化一個Pair<T, U>類我們可以這樣:

1
Pair<String, Integer> pair = new Pair<String, Integer>();

3. 泛型方法

所謂泛型方法,就是帶有類型參數的方法,它既可以定義在泛型類中,也可以定義在普通類中。例如:

1
2
3
4
5
public class ArrayAlg {
    public static <T> T getMiddle(T[] a) {
        return a[a.length / 2];
    }
}

以上代碼中的getMiddle方法即爲一個泛型方法,定義的格式是類型變量放在修飾符的後面、返回類型的前面。我們可以看到,以上泛型方法可以針對各種類型的數組調用,在這些數組的類型已知切有限時,雖然也可以用過重載實現,不過編碼效率要低得多。調用以上泛型方法的示例代碼如下:

1
2
String[] strings = {"aa", "bb", "cc"};
String middle = ArrayAlg.getMiddle(names);

4. 類型變量的限定

在有些情況下,泛型類或者泛型方法想要對自己的類型參數進一步加一些限制。比如,我們想要限定類型參數只能爲某個類的子類或者只能爲實現了某個接口的類。相關的語法如下:

<T extends BoundingType>(BoundingType是一個類或者接口)。其中的BoundingType可以多於1個,用“&”連接即可。

5. 深入理解泛型的實現

實際上,從虛擬機的角度看,不存在“泛型”概念。比如上面我們定義的泛型類Pair,在虛擬機看來(即編譯爲字節碼後),它長的是這樣的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Pair {
    private Object first;
    private Object second;
 
    public Pair(Object first, Object second) {
        this.first = first;
        this.second = second;
    }
 
    public Object getFirst() {
        return first;
    }
 
    public Object getSecond() {
        return second;
    }
 
    public void setFirst(Object newValue) {
        first = newValue;
    }
 
    public void setSecond(Object newValue) {
        second = newValue;
    }
}

上面的類是通過類型擦除得到的,是Pair泛型類對應的原始類型(raw type)。類型擦除就是把所有類型參數替換爲BoundingType(若未加限定就替換爲Object)。

我們可以簡單地驗證下,編譯Pair.java後,鍵入“javap -c -s Pair”可得到:

上圖中帶“descriptor”的行即爲相應方法的簽名,比如從第四行我們可以看到Pair構造方法的兩個形參經過類型擦除後均已變爲了Object。

由於在虛擬機中泛型類Pair變爲它的raw type,因而getFirst方法返回的是一個Object對象,而從編譯器的角度看,這個方法返回的是我們實例化類時指定的類型參數的對象。實際上, 是編譯器幫我們完成了強制類型轉換的工作。也就是說編譯器會把對Pair泛型類中getFirst方法的調用轉化爲兩條虛擬機指令:

第一條是對raw type方法getFirst的調用,這個方法返回一個Object對象;第二條指令把返回的Object對象強制類型轉換爲當初我們指定的類型參數類型。

我們通過以下的代碼來直觀的感受下:

1
2
3
4
5
6
7
8
9
10
public class Pair<T, U> {
    //請見上面貼出的代碼
 
    public static void main(String[] args) {
        String first = "first", second = "second";
        Pair<String, String> p = new Pair<String, String>(first, second);
        String result = p.getFirst();
    }
 
}

編譯後我們通過javap查看下生成的字節碼:

我們重點關注下上面標着”17:”的那行,根據後面的註釋,我們知道這是對getFirst方法的調用,可以看到他的返回類型的確是Object。

我們再看下標着“20:”的那行,是一個checkcast指令,字面上我們就可以知道這條指令的含義是檢查類型轉換是否成功,再看後面的註釋,我們這裏確實存在一個到String的強制類型轉換。

類型擦除也會發生於泛型方法中,如以下泛型方法:

1
public static <T extends Comparable> T min(T[] a)

編譯後經過類型擦除會變成下面這樣:

1
public static Comparable min(Comparable[] a)

方法的類型擦除會帶來一些問題,考慮以下的代碼:

1
2
3
4
5
6
7
8
9
10
11
12
public class DateInterval extends Pair<Date, Date> {
    public DateInterval(Date first, Date second) {
        super(first, second);
    }
 
    public void setSecond(Date second) {
        if (second.compareTo(getFirst()) >= 0) {
            super.setSecond(second);
        }
    }
 
}

以上代碼經過類型擦除後,變爲:

1
2
3
4
5
6
7
8
9
10
public class DateInterval extends Pair {
 
    ...
    public void setSecond(Date second) {
        if (second.compareTo(getFirst()) >= 0) {
            super.setSecond(second);
        }
    }
 
}

而在DateInterval類還存在一個從Pair類繼承而來的setSecond的方法(經過類型擦除後)如下:

1
public void setSecond(Object second)

現在我們可以看到,這個方法與DateInterval重寫的setSecond方法具有不同的方法簽名(形參不同),所以是兩個不同的方法,然而這兩個方法之前卻是override的關係。考慮以下的代碼:

1
2
3
4
DateInterval interval = new DateInterval(...);
Pair<Date, Date> pair = interval;
Date aDate = new Date(...);
pair.setSecond(aDate);

由以上代碼可知,pair實際引用的是DateInterval對象,因此應該調用DateInterval的setSecond方法,這裏的問題是類型擦除與多態發生了衝突。

我們來梳理下爲什麼會發生這個問題:pair在之前被聲明爲類型Pair<Date, Date>,該類在虛擬機看來只有一個“setSecond(Object)”方法。因此在運行時,虛擬機發現pair實際引用的是DateInterval對象後,會去調用DateInterval的“setSecond(Object)”,然而DateInterval類中卻只有”setSecond(Date)”方法。

解決這個問題的方法是由編譯器在DateInterval中生成一個橋方法

1
2
3
public void setSecond(Object second) {
    setSecond((Date) second);
}

我們再來通過javap來感受下:

我們可以看到,在DateInterval類中存在兩個setSecond方法,第一個setSecond方法(即我們定義的setSecond方法)的形參爲Date,第二個setSecond方法的形參是Object,第二個方法就是編譯器爲我們生成的橋方法。我們可以看到第二個方法中存在到Date的強制類型轉換,而且調用了第一個setSecond方法。

綜合以上,我們知道了泛型機制的實現實際上是編譯器幫我們分擔了一些麻煩的工作。一方面通過使用類型參數,可以告訴編譯器在編譯時進行類型檢查;另一方面,原本需要我們做的強制類型轉換的工作也由編譯器爲我們代勞了。

6. 注意事項

(1)不能用基本類型實例化類型參數

也就是說,以下語句是非法的:

1
Pair<int, int> pair = new Pair<int, int>();

不過我們可以用相應的包裝類型來代替。

(2)不能拋出也不能捕獲泛型類實例

泛型類擴展Throwable即爲不合法,因此無法拋出或捕獲泛型類實例。但在異常聲明中使用類型參數是合法的:

1
2
3
4
5
6
7
8
public static <T extends Throwable> void doWork(T t) throws T {
    try {
        ...
    } catch (Throwable realCause) {
        t.initCause(realCause);
        throw t;
    }
}

(3)參數化類型的數組不合法

在Java中,Object[]數組可以是任何數組的父類(因爲任何一個數組都可以向上轉型爲它在定義時指定元素類型的父類的數組)。考慮以下代碼:

1
2
3
String[] strs = new String[10];
Object[] objs = strs;
obj[0] = new Date(...);

在上述代碼中,我們將數組元素賦值爲滿足父類(Object)類型,但不同於原始類型(Pair)的對象,在編譯時能夠通過,而在運行時會拋出ArrayStoreException異常。

基於以上原因,假設Java允許我們通過以下語句聲明並初始化一個泛型數組:

1
Pair<String, String>[] pairs = new Pair<String, String>[10];

那麼在虛擬機進行類型擦除後,實際上pairs成爲了Pair[]數組,我們可以將它向上轉型爲Object[]數組。這時我們若往其中添加Pair<Date, Date>對象,便能通過編譯時檢查和運行時檢查,而我們的本意是隻想讓這個數組存儲Pair<String, String>對象,這會產生難以定位的錯誤。因此,Java不允許我們通過以上的語句形式聲明並初始化一個泛型數組。

可用如下語句聲明並初始化一個泛型數組:

1
Pair<String, String>[] pairs = (Pair<String, String>[]) new Pair[10];

(4)不能實例化類型變量

不能以諸如“new T(…)”, “new T[...]“, “T.class”的形式使用類型變量。Java禁止我們這樣做的原因很簡單,因爲存在類型擦除,所以類似於”new T(…)”這樣的語句就會變爲”new Object(…)”, 而這通常不是我們的本意。我們可以用如下語句代替對“new T[...]“的調用:

1
arrays = (T[]) new Object[N];

(5)泛型類的靜態上下文中不能使用類型變量

注意,這裏我們強調了泛型類。因爲普通類中可以定義靜態泛型方法,如上面我們提到的ArrayAlg類中的getMiddle方法。關於爲什麼有這樣的規定,請考慮下面的代碼:

1
2
3
4
5
6
public class People<T> {
    public static T name;
    public static T getName() {
        ...
    }
}

我們知道,在同一時刻,內存中可能存在不只一個People<T>類實例。假設現在內存中存在着一個People<String>對象和People<Integer>對象,而類的靜態變量與靜態方法是所有類實例共享的。那麼問題來了,name究竟是String類型還是Integer類型呢?基於這個原因,Java中不允許在泛型類的靜態上下文中使用類型變量。

7. 類型通配符

介紹類型通配符前,首先介紹兩點:

(1)假設Student是People的子類,Pair<Student, Student>卻不是Pair<People, People>的子類,它們之間不存在”is-a”關係。

(2)Pair<T, T>與它的原始類型Pair之間存在”is-a”關係,Pair<T, T>在任何情況下都可以轉換爲Pair類型。

現在考慮這樣一個方法:

1
2
3
4
public static void printName(Pair<People, People> p) {
    People p1 = p.getFirst();
    System.out.println(p1.getName()); //假設People類定義了getName實例方法
}

在以上的方法中,我們想要同時能夠傳入Pair<Student, Student>和Pair<People, People>類型的參數,然而二者之間並不存在”is-a”關係。在這種情況下,Java提供給我們這樣一種解決方案:使用Pair<? extends People>作爲形參的類型。也就是說,Pair<Student, Student>和Pair<People, People>都可以看作是Pair<? extends People>的子類。

形如”<? extends BoundingType>”的代碼叫做通配符的子類型限定。與之對應的還有通配符的超類型限定,格式是這樣的:<? super BoundingType>。

現在我們考慮下面這段代碼:

1
2
3
Pair<Student> students = new Pair<Student>(student1, student2);
Pair<? extends People> wildchards = students;
wildchards.setFirst(people1);

以上代碼的第三行會報錯,因爲wildchards是一個Pair<? extends People>對象,它的setFirst方法和getFirst方法是這樣的:

1
2
void setFirst(? extends People)
? extends People getFirst()

對於setFirst方法來說,會使得編譯器不知道形參究竟是什麼類型(只知道是People的子類),而我們試圖傳入一個People對象,編譯器無法判定People和形參類型是否是”is-a”的關係,所以調用setFirst方法會報錯。而調用wildchards的getFirst方法是合法的,因爲我們知道它會返回一個People的子類,而People的子類“always is a People”。(總是可以把子類對象轉換爲父類對象)

而對於通配符的超類型限定的情況下,調用getter方法是非法的,而調用setter方法是合法的。

除了子類型限定和超類型限定,還有一種通配符叫做無限定的通配符,它是這樣的:<?>。這個東西我們什麼時候會用到呢?考慮一下這個場景,我們調用一個會返回一個getPairs方法,這個方法會返回一組Pair<T, T>對象。其中既有Pair<Student, Student>,  還有Pair<Teacher, Teacher>對象。(Student類和Teacher類不存在繼承關係)顯然,這種情況下,子類型限定和超類型限定都不能用。這時我們可以用這樣一條語句搞定它:

1
Pair<?>[] pairs = getPairs(...);

對於無限定的通配符,調用getter方法和setter方法都是非法的。

8. 參考資料

    JAVA核心技術(卷1) (豆瓣)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章