1. 程式人生 > >java基礎(28)--泛型與型別擦除、泛型與繼承

java基礎(28)--泛型與型別擦除、泛型與繼承

【泛型與型別擦除】

泛型是JDK 1.5的一項新特性,它的本質是引數化型別(Parameterized Type)的應用,也就是說所操作的資料型別被指定為一個引數。這種引數型別可以用在類、介面和方法的建立中,分別稱為泛型類、泛型介面和泛型方法。

泛型思想早在C++語言的模板(Templates)中就開始生根發芽,在Java語言處於還沒有出現泛型的版本時,只能通過Object是所有型別的父類和型別強制轉換兩個特點的配合來實現型別泛化。例如在雜湊表的存取中,JDK 1.5之前使用HashMap的get()方法,返回值就是一個Object物件,由於Java語言裡面所有的型別都繼承於 java.lang.Object,那Object轉型成任何物件都是有可能的。但是也因為有無限的可能性,就只有程式設計師和執行期的虛擬機器才知道這個 Object到底是個什麼型別的物件。在編譯期間,編譯器無法檢查這個Object的強制轉型是否成功,如果僅僅依賴程式設計師去保障這項操作的正確性,許多 ClassCastException的風險就會被轉嫁到程式執行期之中。

泛型技術在C#和Java之中的使用方式看似相同,但實現上卻有 著根本性的分歧,C#裡面泛型無論在程式原始碼中、編譯後的IL中(Intermediate Language,中間語言,這時候泛型是一個佔位符)或是執行期的CLR中都是切實存在的,List與 List就是兩個不同的型別,它們在系統執行期生成,有自己的虛方法表和型別資料,這種實現稱為型別膨脹,基於這種方法實現 的泛型被稱為真實泛型。

Java語言中的泛型則不一樣,它只在程式原始碼中存在,在編譯後的位元組碼檔案中,就已經被替換為原來的原生型別 (Raw Type,也稱為裸型別,也即沒有了後面的引數)了,並且在相應的地方插入了強制轉型程式碼,因此對於執行期的Java語言來說,ArrayList與 ArrayList就是同一個類。所以說泛型技術實際上是Java語言的一顆語法糖,Java語言中的泛型實現方法稱為型別 擦除,基於這種方法實現的泛型被稱為偽泛型。

也就是說Java中的泛型是個假泛型,僅僅只是在編譯器那邊做了語法檢查而已,和C#裡的泛型不一樣的。
基本上,不管你在List<>裡面寫什麼型別,編譯通過了以後執行時全部都是Object。

程式碼清單10-2是一段簡單的Java泛型例子,我們可以看一下它編譯後的結果是怎樣的?

程式碼清單 10-2 泛型擦除前的例子

public static void main(String[] args) {  
    Map<String, String> map = new HashMap<String, String>();  
    map.put("hello"
, "你好"); map.put("how are you?", "吃了沒?"); System.out.println(map.get("hello")); System.out.println(map.get("how are you?")); }

把這段Java程式碼編譯成Class檔案,然後再用位元組碼反編譯工具進行反編譯後,將會發現泛型都不見了,程式又變回了Java泛型出現之前的寫法,泛型型別都變回了原生型別,如程式碼清單10-3所示。

程式碼清單 10-3 泛型擦除後的例子

public static void main(String[] args) {  
    Map map = new HashMap();  
    map.put("hello", "你好");  
    map.put("how are you?", "吃了沒?");  
    System.out.println((String) map.get("hello"));  
    System.out.println((String) map.get("how are you?"));  
}  

當初JDK設計團隊為什麼選擇型別擦除的方式來實現Java語言的泛型支援呢?是因為實現簡單、相容性考慮還是別的原因?我們已不得而知,但確實有不少 人對Java語言提供的偽泛型頗有微詞,當時甚至連《Thinking In Java》一書的作者Bruce Eckel也發表了一篇文章《這不是泛型!》 來批評JDK 1.5中的泛型實現。
(注1:原文:http://www.anyang-window.com.cn/quotthis-is-not-a-genericquot-bruce-eckel-eyes-of-the-generic-java/

當時眾多的批評之中,有一些是比較表面的,還有一些從效能上說泛型會由於強制轉型操作和執行期缺少針對型別的優化等從而導致比C#的泛型慢一些,則是完 全偏離了方向,姑且不論Java泛型是不是真的會比C#泛型慢,選擇從效能的角度上評價用於提升語義準確性的泛型思想,就猶如在討論劉翔打斯諾克的水平與 丁俊暉有多大的差距一般。但筆者也並非在為Java的泛型辯護,它在某些場景下確實存在不足,筆者認為通過擦除法來實現泛型喪失了一些泛型思想應有的優 雅,例如下面程式碼清單10-4的例子:

程式碼清單 10-4 當泛型遇見過載 1

public class GenericTypes {  

    public static void method(List<String> list) {  
        System.out.println("invoke method(List<String> list)");  
    }  

    public static void method(List<Integer> list) {  
        System.out.println("invoke method(List<Integer> list)");  
    }  
}  

想一想,上面這段程式碼是否正確,能否編譯執行?也許您已經有了答案,這段程式碼是不能被編譯的,是因為引數List和 List編譯之後都被擦除了,變成了一樣的原生型別List,擦除動作導致這兩個方法的特徵簽名變得一模 一樣。初步看來,無法過載的原因已經找到了,但是真的就是如此嗎?只能說,泛型擦除成相同的原生型別只是無法過載的其中一部分原因,請再接著看一看程式碼清 單10-5中的內容。

程式碼清單 10-5 當泛型遇見過載 2

public class GenericTypes {  

    public static String method(List<String> list) {  
        System.out.println("invoke method(List<String> list)");  
        return "";  
    }  

    public static int method(List<Integer> list) {  
        System.out.println("invoke method(List<Integer> list)");  
        return 1;  
    }  

    public static void main(String[] args) {  
        method(new ArrayList<String>());  
        method(new ArrayList<Integer>());  
    }  
}

}
執行結果:

invoke method(List<String> list)  
invoke method(List<Integer> list)  

程式碼清單10-5與程式碼清單10-4的差別,是兩個method方法添加了不同的返回值,由於這兩個返回值的加入,方法過載居然成功了,即這段程式碼可以被編譯和執行 了。這是我們對Java語言中返回值不參與過載選擇的基本認知的挑戰嗎?

(注 2:測試的時候請使用Sun JDK的Javac編譯器進行編譯,其他編譯器,如Eclipse JDT的ECJ編譯器,仍然可能會拒絕編譯這段程式碼,ECJ編譯時會提示“Method method(List) has the same erasure method(List) as another method in type GenericTypes”。)
  程式碼清單10-5中的過載當然不是根據返回值來確定的,之所以這次能編譯和執行成功,是因為兩個 mehtod()方法加入了不同的返回值後才能共存在一個Class檔案之中。第6章介紹Class檔案方法表(method_info)的資料結構時曾 經提到過,方法過載要求方法具備不同的特徵簽名,返回值並不包含在方法的特徵簽名之中,所以返回值不參與過載選擇,但是在Class檔案格式之中,只要描述符不是完全一致的兩個方法就可以共存。也就是說兩個方法如果有相同的名稱和特徵簽名,但返回值不同,那它們也是可以合法地共存於一個Class檔案中的。

由於Java泛型的引入,各種場景(虛擬機器解析、反射等)下的方法呼叫都可能對原有的基礎產生影響和新的需求,如在泛型類中如何獲取傳 入的引數化型別等。所以JCP組織對虛擬機器規範做出了相應的修改,引入了諸如Signature、LocalVariableTypeTable等新的屬性用於解決伴隨泛型而來的引數型別的識別問題,Signature是其中最重要的一項屬性,它的作用就是儲存一個方法在位元組碼層面的特徵簽名,這個屬性中儲存的引數型別並不是原生型別,而是包括了引數化型別的資訊。修改後的虛擬機器規範 要求所有能識別49.0以上版本的Class檔案的虛擬機器都要能正確地識別Signature引數。

(注3:在《Java虛擬機器規範第二版》 (JDK 1.5修改後的版本)的“§4.4.4 Signatures”章節及《Java語言規範第三版》的“§8.4.2 Method Signature”章節中分別都定義了位元組碼層面的方法特徵簽名,以及Java程式碼層面的方法特徵簽名,特徵簽名最重要的任務就是作為方法獨一無二不可 重複的ID,在Java程式碼中的方法特徵簽名只包括了方法名稱、引數順序及引數型別,而在位元組碼中的特徵簽名還包括方法返回值及受查異常表,本書中如果指的是位元組碼層面的方法簽名,筆者會加入限定語進行說明,也請讀者根據上下文語境注意區分。)

從上面的例子可以看到擦除法對實際編碼帶來的影響,由於List和List擦除後是同一個型別,我們只能新增兩個並不需要實際使用到的返回值才能完成過載,這是一種毫無優雅和美感可言的解決方案。同時,從Signature屬性的出現我們還可以得出結論,擦除法所謂的擦除,僅僅是對方法的 Code屬性中的位元組碼進行擦除,實際上元資料中還是保留了泛型資訊,這也是我們能通過反射手段取得引數化型別的根本依據。

同時值得注意的是:泛型與繼承的關係

例如:

public void inspect(List<Object> list) 
{ 
     for(Object obj : list) 
     { 
         System.out.println(obj); 
     } 
     list.add(1); //這個操作在當前方法的上下文是合法的。
}
public void test()
{ 
     List<String> strs = new ArrayList<String>();
     inspect(strs); //編譯錯誤 
} 

這段程式碼中,inspect方法接受List作為引數,當在test方法中試圖傳入List的 時候,會出現編譯錯誤。假設這樣的做法是允許的,那麼在inspect方法就可以通過list.add(1)來向集合中新增一個數字。這樣在test方法 看來,其宣告為List的集合中卻被添加了一個Integer型別的物件。這顯然是違反型別安全的原則的,在某個時候肯定會 丟擲ClassCastException。因此,編譯器禁止這樣的行為。編譯器會盡可能的檢查可能存在的型別安全問題。對於確定是違反相關原則的地方,會給出編譯錯誤。當編譯器無法判斷型別的使用是否正確的時候,會給出警告資訊。

【繼承和泛型】

在使用子類一般型別引數時,必須在子類級別重複在基類級別規定的任何約束。例如,派生約束:
這裡寫圖片描述
基類可以定義其簽名使用一般型別引數的虛擬方法。在重寫它們時,子類必須在方法簽名中提供相應的型別:
這裡寫圖片描述
您可以定義一般介面、一般抽象類,甚至一般抽象方法。這些型別的行為像其他任何一般基型別一樣:
這裡寫圖片描述