計算機程式的思維邏輯 (37) - 泛型 (下) - 細節和侷限性

swiftma發表於2016-11-02

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (37) - 泛型 (下) - 細節和侷限性

35節介紹了泛型的基本概念和原理,上節介紹了泛型中的萬用字元,本節來介紹泛型中的一些細節和侷限性。

這些侷限性主要與Java的實現機制有關,Java中,泛型是通過型別擦除來實現的,型別引數在編譯時會被替換為Object,執行時Java虛擬機器不知道泛型這回事,這帶來了很多侷限性,其中有的部分是比較容易理解的,有的則是非常違反直覺的。

一項技術,往往只有理解了其侷限性,我們才算是真正理解了它,才能更好的應用它。

下面,我們將從以下幾個方面來介紹這些細節和侷限性:

  • 使用泛型類、方法和介面
  • 定義泛型類、方法和介面
  • 泛型與陣列

使用泛型類、方法和介面

在使用泛型類、方法和介面時,有一些值得注意的地方,比如:

  • 基本型別不能用於例項化型別引數
  • 執行時型別資訊不適用於泛型
  • 型別擦除可能會引發一些衝突

我們逐個來看下。

基本型別不能用於例項化型別引數

Java中,因為型別引數會被替換為Object,所以Java泛型中不能使用基本資料型別,也就是說,類似下面寫法是不合法的:

Pair<int> minmax = new Pair<int>(1,100);
複製程式碼

解決方法就是使用基本型別對應的包裝類。

執行時型別資訊不適用於泛型

在介紹繼承的實現原理時,我們提到,在記憶體中,每個類都有一份型別資訊,而每個物件也都儲存著其對應型別資訊的引用。關於執行時資訊,後續文章我們會進一步詳細介紹,這裡簡要說明一下。

在Java中,這個型別資訊也是一個物件,它的型別為Class,Class本身也是一個泛型類,每個類的型別物件可以通過<類名>.class的方式引用,比如String.classInteger.class

這個型別物件也可以通過物件的getClass()方法獲得,比如:

Class<?> cls = "hello".getClass();
複製程式碼

這個型別物件只有一份,與泛型無關,所以Java不支援類似如下寫法:

Pair<Integer>.class
複製程式碼

一個泛型物件的getClass方法的返回值與原始型別物件也是相同的,比如說,下面程式碼的輸出都是true:

Pair<Integer> p1 = new Pair<Integer>(1,100);
Pair<String> p2 = new Pair<String>("hello","world");
System.out.println(Pair.class==p1.getClass());
System.out.println(Pair.class==p2.getClass());
複製程式碼

第16節,我們介紹過instanceof關鍵字,instanceof後面是介面或類名,instanceof是執行時判斷,也與泛型無關,所以,Java也不支援類似如下寫法:

if(p1 instanceof Pair<Integer>)
複製程式碼

不過,Java支援這麼寫:

if(p1 instanceof Pair<?>)
複製程式碼

型別擦除可能會引發一些衝突

由於型別擦除,可能會引發一些編譯衝突,這些衝突初看上去並不容易理解,我們通過一些例子看一下。

上節我們介紹過一個例子,有兩個類Base和Child,Base的宣告為:

class Base implements Comparable<Base>
複製程式碼

Child的宣告為:

class Child extends Base
複製程式碼

Child沒有專門實現Comparable介面,上節我們說Base類已經有了比較所需的全部資訊,所以Child沒有必要實現,可是如果Child希望自定義這個比較方法呢?直覺上,可以這樣修改Child類:

class Child extends Base implements Comparable<Child>{
    @Override
    public int compareTo(Child o) {
        
    }
    //...
}
複製程式碼

遺憾的是,Java編譯器會提示錯誤,Comparable介面不能被實現兩次,且兩次實現的型別引數還不同,一次是Comparable<Base>,一次是Comparable<Child>。為什麼不允許呢?因為型別擦除後,實際上只能有一個。

那Child有什麼辦法修改比較方法呢?只能是重寫Base類的實現,如下所示:

class Child extends Base {
    @Override
    public int compareTo(Base o) {
        if(!(o instanceof Child)){
            throw new IllegalArgumentException();
        }
        Child c = (Child)o;
        //...
        return 0;
    }
    //...
}
複製程式碼

還有,你可能認為可以這麼定義過載方法:

public static void test(DynamicArray<Integer> intArr)
public static void test(DynamicArray<String> strArr)
複製程式碼

雖然引數都是DynamicArray,但例項化型別不同,一個是DynamicArray<Integer>,另一個是DynamicArray<String>,同樣,遺憾的是,Java不允許這種寫法,理由同樣是,型別擦除後,它們的宣告是一樣的。

定義泛型類、方法和介面

在定義泛型類、方法和介面時,也有一些需要注意的地方,比如:

  • 不能通過型別引數建立物件
  • 泛型類型別引數不能用於靜態變數和方法
  • 瞭解多個型別限定的語法

我們逐個來看下。

不能通過型別引數建立物件

不能通過型別引數建立物件,比如,T是型別引數,下面寫法都是非法的:

T elm = new T();
T[] arr = new T[10];
複製程式碼

為什麼非法呢?因為如果允許,那你以為建立的就是對應型別的物件,但由於型別擦除,Java只能建立Object型別的物件,而無法建立T型別的物件,容易引起誤解,所以Java乾脆禁止這麼做。

那如果確實希望根據型別建立物件呢?需要設計API接受型別物件,即Class物件,並使用Java中的反射機制,後續文章我們再詳細介紹反射,這裡簡要說明一下,如果型別有預設構造方法,可以呼叫Class的newInstance方法構建物件,類似這樣:

public static <T> T create(Class<T> type){
    try {
        return type.newInstance();
    } catch (Exception e) {
        return null;
    }
}
複製程式碼

比如:

Date date = create(Date.class);
StringBuilder sb = create(StringBuilder.class);
複製程式碼

泛型類型別引數不能用於靜態變數和方法

對於泛型類宣告的型別引數,可以在例項變數和方法中使用,但在靜態變數和靜態方法中是不能使用的。類似下面這種寫法是非法的:

public class Singleton<T> {

    private static T instance;
    
    public synchronized static T getInstance(){
        if(instance==null){
             // 建立例項
        }
        return instance;
    }
}    
複製程式碼

如果合法的話,那麼對於每種例項化型別,都需要有一個對應的靜態變數和方法。但由於型別擦除,Singleton型別只有一份,靜態變數和方法都是型別的屬性,且與型別引數無關,所以不能使用泛型類型別引數。

不過,對於靜態方法,它可以是泛型方法,可以宣告自己的型別引數,這個引數與泛型類的型別引數是沒有關係的。

瞭解多個型別限定的語法

之前介紹型別引數限定的時候,我們介紹,上界可以為某個類、某個介面或者其他型別引數,但上界都是隻有一個,Java中還支援多個上界,多個上界之間以&分隔,類似這樣:

T extends Base & Comparable & Serializable
複製程式碼

Base為上界類,Comparable和Serializable為上界介面,如果有上界類,類應該放在第一個,型別擦除時,會用第一個上界替換。

泛型與陣列

泛型與陣列的關係稍微複雜一些,我們單獨討論一下。

為什麼不能建立泛型陣列?

引入泛型後,一個令人驚訝的事實是,你不能建立泛型陣列。比如說,我們可能想這樣建立一個Pair的泛型陣列,以表示隨機一節中介紹的獎勵面額和權重。

Pair<Object,Integer>[] options = new Pair<Object,Integer>[]{
        new Pair("1元",7),
        new Pair("2元", 2),
        new Pair("10元", 1)
};
複製程式碼

Java會提示編譯錯誤,不能建立泛型陣列。這是為什麼呢?我們先來進一步理解一下陣列。

前面我們解釋過,型別引數之間有繼承關係的容器之間是沒有關係的,比如,一個DynamicArray<Integer>物件不能賦值給一個DynamicArray<Number>變數。不過,陣列是可以的,看程式碼:

Integer[] ints = new Integer[10];
Number[] numbers = ints;
Object[] objs = ints;
複製程式碼

後面兩種賦值都是允許的。陣列為什麼可以呢?陣列是Java直接支援的概念,它知道陣列元素的實際型別,它知道Object和Number都是Integer的父型別,所以這個操作是允許的。

雖然Java允許這種轉換,但如果使用不當,可能會引起執行時異常,比如:

Integer[] ints = new Integer[10];
Object[] objs = ints;
objs[0] = "hello";
複製程式碼

編譯是沒有問題的,執行時會丟擲ArrayStoreException,因為Java知道實際的型別是Integer,所以寫入String會丟擲異常。

理解了陣列的這個行為,我們再來看泛型陣列。如果Java允許建立泛型陣列,則會發生非常嚴重的問題,我們看看具體會發生什麼:

Pair<Object,Integer>[] options = new Pair<Object,Integer>[3];
Object[] objs = options;
objs[0] = new Pair<Double,String>(12.34,"hello");
複製程式碼

如果可以建立泛型陣列options,那它就可以賦值給其他型別的陣列objs,而最後一行明顯錯誤的賦值操作,則既不會引起編譯錯誤,也不會觸發執行時異常,因為Pair<Double,String>的執行時型別是Pair,和objs的執行時型別Pair[]是匹配的。但我們知道,它的實際型別是不匹配的,在程式的其他地方,當把objs[0]當做Pair<Object,Integer>進行處理的時候,一定會觸發異常。

也就是說,如果允許建立泛型陣列,那就可能會有上面這種錯誤操作,它既不會引起編譯錯誤,也不會立即觸發執行時異常,卻相當於埋下了一顆炸彈,不定什麼時候爆發,為避免這種情況,Java乾脆就禁止建立泛型陣列。

如何存放泛型物件?

但,現實需要能夠存放泛型物件的容器啊,怎麼辦呢?可以使用原始型別的陣列,比如:

Pair[] options = new Pair[]{
      new Pair<String,Integer>("1元",7),
      new Pair<String,Integer>("2元", 2),
      new Pair<String,Integer>("10元", 1)};
複製程式碼

更好的選擇是,使用後續章節介紹的泛型容器。目前,可以使用我們自己實現的DynamicArray,比如:

DynamicArray<Pair<String,Integer>> options = new DynamicArray<>();
options.add(new Pair<String,Integer>("1元",7));
options.add(new Pair<String,Integer>("2元",2));
options.add(new Pair<String,Integer>("10元",1));
複製程式碼

DynamicArray內部的陣列為Object型別,一些操作插入了強制型別轉換,外部介面是型別安全的,對陣列的訪問都是內部程式碼,可以避免誤用和型別異常。

如何轉換容器為陣列?

有時,我們希望轉換泛型容器為一個陣列,比如說,對於DynamicArray,我們可能希望它有這麼一個方法:

public E[] toArray()
複製程式碼

而我們希望可以這麼用:

DynamicArray<Integer> ints = new DynamicArray<Integer>();
ints.add(100);
ints.add(34);
Integer[] arr = ints.toArray();
複製程式碼

先使用動態容器收集一些資料,然後轉換為一個固定陣列,這也是一個常見合理的需求,怎麼來實現這個toArray方法呢?

可能想先這樣:

E[] arr = new E[size];
複製程式碼

遺憾的是,如之前所述,這是不合法的。Java執行時根本不知道E是什麼,也就無法做到建立E型別的陣列。

另一種想法是這樣:

public E[] toArray(){
    Object[] copy = new Object[size];
    System.arraycopy(elementData, 0, copy, 0, size);
    return (E[])copy;
}
複製程式碼

或者使用之前介紹的Arrays方法:

public E[] toArray(){
    return (E[])Arrays.copyOf(elementData, size);
}
複製程式碼

結果都是一樣的,沒有編譯錯誤了,但執行時,會丟擲ClassCastException異常,原因是,Object型別的陣列不能轉換為Integer型別的陣列。

那怎麼辦呢?可以利用Java中的執行時型別資訊和反射機制,這些概念我們後續章節再介紹。這裡,我們簡要介紹下。

Java必須在執行時知道你要轉換成的陣列型別,型別可以作為引數傳遞給toArray方法,比如:

public E[] toArray(Class<E> type){
    Object copy = Array.newInstance(type, size);
    System.arraycopy(elementData, 0, copy, 0, size);
    return (E[])copy;
}
複製程式碼

Class<E>表示要轉換成的陣列型別資訊,有了這個型別資訊,Array類的newInstance方法就可以建立出真正型別的陣列物件。

呼叫toArray方法時,需要傳遞需要的型別,比如,可以這樣:

Integer[] arr = ints.toArray(Integer.class);
複製程式碼

泛型與陣列小結

我們來稍微總結下泛型與陣列的關係:

  • Java不支援建立泛型陣列。
  • 如果要存放泛型物件,可以使用原始型別的陣列,或者使用泛型容器。
  • 泛型容器內部使用Object陣列,如果要轉換泛型容器為對應型別的陣列,需要使用反射。

小結

本節介紹了泛型的一些細節和侷限性,這些侷限性主要是由於Java泛型的實現機制引起的,這些侷限性包括,不能使用基本型別,沒有執行時型別資訊,型別擦除會引發一些衝突,不能通過型別引數建立物件,不能用於靜態變數等,我們還單獨討論了泛型與陣列的關係。

我們需要理解這些侷限性,但,幸運的是,一般並不需要特別去記憶,因為用錯的時候,Java開發環境和編譯器會提示你,當被提示時,你需要能夠理解,並可以從容應對。

至此,關於泛型的介紹就結束了,泛型是Java容器類的基礎,理解了泛型,接下來,就讓我們開始探索Java中的容器類。


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (37) - 泛型 (下) - 細節和侷限性

相關文章