數組和泛型容器有什么區別
要區分數組和泛型容器的功能,這里先要理解三個概念:協變性(covariance)、逆變性(contravariance)和無關性(invariant)。
若類A是類B的子類,則記作A ≦ B。設有變換f(),若:
當A ≦ B時,有f(A)≦ f(B),則稱變換f()具有協變性;
當A ≦ B時,有f(B)≦ f(A),則稱變換f()具有逆變性;
如果以上兩者皆不成立,則稱變換f()具有無關性。
在Java中,數組具有協變性,而泛型具有無關性,示例代碼如下:
Object[] array = new String[10];
//編譯錯誤 ArrayList<Object> list=new ArrayList<String>();
這兩句代碼,數組正常編譯通過,而泛型拋出了編譯期錯誤,應用之前提出的概念對代碼進行分析,可知:
1、String ≦ Object 2、數組的變換可以表達為f(A)=A[],通過之前的示例,可以得出下推論: f(String) = String[] 以及 f(Object) = Object[]; 4、通過代碼驗證,String[] ≦ Object[] 是成立的,由此可見,數組具有協變性。
又可知:
5、ArrayList泛型的變換可以表達為 f(A)= ArrayList<A>,得出推論: f(String) = ArrayList<String> 以及 f(Object) = ArrayList<Object>; 6、通過代碼驗證,ArrayList<String> ≦ ArrayList<Object>不成立,由此可見,泛型具備無關性
最終得出結論,數組具備協變性,而泛型具備無關性。
所以,為了讓泛型具備協變性和逆變性,Java引入了有界泛型(參見3.1.2小節內容)概念。
除了協變性的不同,數組還是具象化的,而泛型不是。
什么是具象化(reified,也可以稱之為具體化,物化)?
| 完全在運行時可用的類型被稱為具象化類型(refiable type),會做這種區分是因為有些類型會在編譯過程中被擦除,並不是所有的類型都在運行時可用。 它包括: 1、非泛型類聲明,接口類型聲明; 2、所有泛型參數類型為無界通配符(僅用‘?’修飾)的泛型參數類; 3、原始類型; 4、基本數據類型; 5、其元素類型為具象化類型的數組; 6、嵌套類(內部類,匿名內部類等,比如java.util.HashMap.Entry),並且嵌套過程中的每一個類都是具象化的。 |
不論是在編譯時還是運行時,數組都能確切的知道自己的所屬的類型。但是泛型在編譯時會丟失部分類型信息,在運行時,它又會被當作Object處理。
這里要涉及到類型擦除的相關知識,會在后面詳細解釋。在當前,只需要知道,Java的泛型最后都被當作上界(此概念會在后面說明)處理了。
引申:數組具備協變性,是Java的一個缺陷,因為極少有地方需要用到數組的協變性,甚至,使用數組的協變會引起不易檢查的運行時異常,參見下面代碼:
Object[] array = new String[10]; array[0] = 1;
很明顯,這會在運行期拋出異常:java.lang.ArrayStoreException。
鑒於有如此多的不同,在Java里,數組和泛型是不能混合使用的。參見下面代碼:
List<String>[] genericListArray = new ArrayList<String>[10]; T[] genericArray = new T[];
它們都會在編譯期拋出Cannot create a generic array錯誤。這是因為,數組要求類型是具象化(refied)的,而泛型恰好不是。
換言之,數組必須清楚的知道自己內部元素的類型,並且會一直保存這個類型信息,在添加的時候元素的時候,該信息會用於做類型檢查,而泛型的類型不確定。所以,在編譯器層面就杜絕了這個問題。這在《Java語言規范》里有明確的說明:
| If the element type of an array were not reifiable,the virtual machine could not perform the store check described in the preceding paragraph. This is why creation of arrays of non-reifiable types is forbidden. One may declare variables of array types whose element type is not reifiable, but any attempt to assign them a value will give rise to an unchecked warning . 如果數組的元素類型不是具象化的,虛擬機將無法應用在前面章節里描述過的存儲檢查。這就是為什么禁止創建(實例化)非具象化的數組。你可以定義(聲明)一個元素類型是非具象化的數組類型,但任何師徒給它分配一個值的操作,都會產生一個unchecked warning。 存儲檢查:這里涉及到Array的基本原理,可以自行參閱《Java語言規范》或者參考5.1.1ArrayList相關章節 |
這不得不說,又是Java在泛型設計上的一點缺陷,為什么Java的泛型設計會有這么多缺陷呢?難道真的是Java語言不夠好嗎?這些內容將在3.3節泛型歷史中解答。
泛型使用建議
泛型在Java開發和設計中占據了重要的地位,如果正確高效的使用泛型尤為重要。下面通過介紹兩條使用泛型時的建議,來加深對泛型的理解:
1、泛型類型只能是類類型,不能是基本數據類型,如果要使用基本數據類型作為泛型,應當使用其對應的包裝類。比如,如果期望在List中存放整形變量,因為int是基本類型,所以不能使用List<int>,應該使用int的包裝類Integer,所以正確的使用方法為List<Integer>。
當然,泛型不支持基本數據類型,試圖使用基本數據類型作為泛型的時候必須轉化為包裝類這點,是Java泛型設計之初的缺陷。
2、使用到集合的時候,盡量的使用泛型集合來替代非泛型集合。一般來說,軟件的開發期和維護期時間占比,也是符合二八定律的,維護期的時長能超出開發期數倍。使用了泛型的集合至少,在IDE工具上,是類型確定的,可以提高代碼的可讀性,並在編譯期就避免一些嚴重的BUG。
3、不要使用常見類名(尤其是String這種屬於java.lang的)作為泛型名,會造成編譯器無法區分開類和泛型,並且不會拋出異常。
泛型擦除
在學習泛型擦除之前,明確一個概念:Java的泛型不存在於運行時。這也是為什么有人說Java沒有真正的泛型。
泛型擦除(類型擦除),它是指在編譯器處理帶泛型定義的類\接口\方法時,會在字節碼指令集里抹去全部泛型類型信息,被擦除后泛型,在字節碼里只保留泛型的原始類型(raw type)。
原始類型,是指抹去泛型信息后的類型,在Java中,它必須是一個引用類型(非基本數據類型),一般而言,它對應的是泛型的定義上界。
舉例:<T>中的T對應的原始泛型是Object,<T extends String>對應的原始類型就是String。
泛型信息會在編譯時擦除
如何證明泛型會被擦除呢?這里提供了一段測試代碼:
class TypeErasureSample<T> { public T v1; public T v2; public String v3; } /** * 泛型擦除示例 */
public class Generic3_2 { public static void main(String[] args) throws Exception { TypeErasureSample<String> type = new TypeErasureSample<String>(); type.v1 = "String value"; // 反射設置v2的值為整型數
Field v2 = TypeErasureSample.class.getDeclaredField("v2"); v2.set(type, 1); for (Field f : TypeErasureSample.class.getDeclaredFields()) { System.out.println(f.getName() + ":" + f.getType()); } /* * 此處會拋出java.lang.ClassCastException: java.lang.Integer cannot be cast * to java.lang.String */ System.out.println(type.v2); } }
程序運行結果為:
| v1:class java.lang.Object v2:class java.lang.Object v3:class java.lang.String Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String at capter3.generic.Generic3_2.main(Generic3_2.java:29) |
v1和v2的類型被指定為泛型T,但是通過反射發現,它們實質上還是Object,而v3原本定義的就是String,和前兩項一比對,證明反射本身並無錯誤。
代碼在輸出type.v2的過程中拋出了類型轉換異常,這說明了兩件事:
1、為v2設置整型數已經成功(可以自行寫一段反射來驗證);
2、編譯器在構建字節碼的時候,一定做了類似於(String)type.v2的強行轉換,關於這一點,可以通過反編譯驗證(反編譯工具為jd-gui),結果如下所示:
public class Generic3_2 { public static void main(String[] args) throws Exception { TypeErasureSample type = new TypeErasureSample(); type.v1 = "String value"; Field v2 = TypeErasureSample.class.getDeclaredField("v2"); v2.set(type, Integer.valueOf(1)); for (Field f : TypeErasureSample.class.getDeclaredFields()) { System.out.println(f.getName() + ":" + f.getType()); } System.out.println((String)type.v2); } }
可以看到,如果編譯器認為type.v2有被申明為String的必要的時候,都會加上(String)強行轉換。可以進行測試:
Object o = type.v2;
String s = type .v2;
后者會拋出類型轉換異常,而前者是正常執行的。由此,可以得出結論,編譯器會在構建字節碼的時候,抹去一些泛型信息。
編譯器保留的泛型信息有哪些?
上一節中介紹了編譯器會擦除全部泛型信息,那么是不是所有的泛型信息都會在編譯的過程中消失呢,答案是否定的,字節碼里指令集之外的地方,會保留部分泛型信息。下面的泛型在編譯階段是會被保留的:
1、泛型接口、類、方法定義上的所有泛型;
2、成員變量聲明處的泛型。
參考下面的代碼:
/** * 定義了泛型參數的接口 */
interface GI<T> { } /** * 定義了泛型參數並實現了泛型接口的類 */
class GC<T> implements GI<T> { // 兩種使用了泛型的成員變量
T m1; ArrayList<T> m2 = new ArrayList<T>(); /** * 定義了泛型參數的方法,並在返回值、參數和異常拋出位置使用了該泛型 */
<K extends Exception> ArrayList<K> method(K p) throws K { // 在方法體中使用了泛型
K k = p; ArrayList<K> list = new ArrayList<K>(); list.add(k); return list; } }
代碼涵蓋了泛型的各種聲明和使用情況。接下來使用反編譯工具看看結果,可以注意到,接口、類、方法定義的位置,大部分泛型信息依然存在,字段中使用到泛型作為聲明的位置,泛型同樣存在,而在所有在局部代碼快對泛型做引用的位置,泛型內容消失了:
|
abstract interface GI<T>{ }
class GC<T> implements GI<T>{ T m1; ArrayList<T> m2 = new ArrayList();
<K extends Exception> ArrayList<K> method(K p) throws Exception{ Exception k = p; ArrayList list = new ArrayList(); list.add(k); return list; } } |
可以注意到,在之前沒有提及的位置,比如GC.m2成員變量的實例化位置,method方法體里的泛型信息全部被擦除。
為什么Java會這么設計?這也很好理解:
1、如果不保留泛型定義,那么除非擁有源碼,不然無法使用泛型。
2、即使保留了泛型定義,定義位置的泛型信息並未初始化,也就是說,泛型參數沒有綁定為特定的某個類,對使用者不具備意義。而且,泛型信息在運行時也會被處理為上界,對使用並不會有影響。
相信注意細節的讀者已經發現了,之前提及的“會被保留泛型信息的位置”里,“異常拋出位置”的K被替換為了Exception,這不正說明它被擦除了?
事實上,如果通過反射來獲取泛型信息的時候(方法將在下一小節詳細講解),會發現,依然可以得到異常的泛型信息。得出結論,作為拋出異常的泛型參數,沒有消失。
這是為什么呢?
既然反編譯工具沒有記錄下泛型信息,只能說明某些反編譯工具沒有解析二進制文件里的某些信息。這些信息是什么呢?這里要引入的一個概念,方法簽名(Method Signatrue)。
下面列出的是上一個例子的部分字節碼內容(也就是class文件反編譯的原始內容):
| // Method descriptor #31 (Ljava/lang/Exception;)Ljava/util/ArrayList; // Signature: <K:Ljava/lang/Exception;>(TK;)Ljava/util/ArrayList<TK;>;^TK; // Stack: 1, Locals: 2 java.util.ArrayList method(java.lang.Exception p) throws java.lang.Exception; 0 aconst_null 1 areturn Line numbers: [pc: 0, line: 40] Local variable table: [pc: 0, pc: 2] local: this index: 0 type: capter3.generic.GC [pc: 0, pc: 2] local: p index: 1 type: java.lang.Exception Local variable type table: [pc: 0, pc: 2] local: this index: 0 type: capter3.generic.GC<T> [pc: 0, pc: 2] local: p index: 1 type: K |
這段內容不長,也無需細看,如果稍微觀察下,可以注意到第四行開始就是方法的定義部分,包括返回值ArrayList,參數Exception,拋出的異常Exception,注意到沒有?它們,統統不帶泛型信息,而在更早之前的位置(1-3行)可以看到三段注釋,這就是之前所說的方法簽名了。
方法簽名是方法定義的一部分,它規定了方法的參數列表和返回值等信息。下面來詳細解釋下各個部分的概念。
第一行:
// Method descriptor #31 (Ljava/lang/Exception;)Ljava/util/ArrayList;
Method descriptor是標志方法簽名的開始。
#ID是該方法的id號,在同一個方法體內不會重復。
(參數列表)表示方法有一個Exception類型的形參,類名前的L是引用類型的標記;基礎數據類型的標記是對應類型的首字母大寫,比如int對應I。數組的標記是在原始標記前加上符號[,比如double[]對應[D,String[]對應[Ljava/lang/String。
最后的位置是返回值,比如Ljava/util/ArrayList;表示方法的返回值是ArrayList。
第二行:
// Signature: <K:Ljava/lang/Exception;>(TK;)Ljava/util/ArrayList<TK;>;^TK;
Signature是簽名的意思,標識開始的關鍵字,這一行對應的就是泛型了。
<泛型參數名:上界>這部分對應的是方法的泛型描述。
(參數列表)和第一行的大體意思一致,但是多了泛型的定義,在字節碼中,泛型會用其上界來替代(擦除),如果沒有定義上界,則默認為Object,真正的泛型的定義就出現在本行的這個位置。用T前綴來表示泛型,比如泛型K就對應TK;。
緊跟着參數列表的是返回值。該返回值描述和第一行的返回值描述一致,不過,同樣多了泛型的描述,也是用T前綴來表達,比如返回值是java.util.ArrayList,這里就變為Ljava/util/ArrayList<TK;>;。
^泛型異常,用於描述用泛型表達的異常,如果異常不是泛型,則該部分描述不會生成。比如throws K就會被描述為^TK;。
第三行:
// Stack: 1, Locals: 2
Stack,表達的是調用棧(call stack),用於描述在調用棧上最多有多少個對象。為什么會有個這個棧呢?是因為“局部變量”這個概念對於虛擬機來說,是不存在的,所以在某個方法被調用前,需要把該方法要用到的變量都加載到一個全局調用棧內。方法被虛擬機喚起的時候,只需要按順序傳入變量類型,然后自動從調用棧里按需取得變量。
每次操作執行完成后,棧被清空,所以,棧深等同為變量最多的操作的變量數。
Locals,用於描述使用到的本地變量,讀者可能會疑惑,該方法里明明只用到了一個形參K,為什么會有兩個變量呢?這是因為java默認給方法注冊了一個this,作為本地變量。
懂得了字節碼的真相,也就懂得了Java泛型的實現原理。
Java的方法泛型沒有記錄在方法體內部,而是在方法簽名內做了實現。同樣,可以在字節碼里找到類\接口簽名,類字段(成員變量)簽名等等。
換言之,Java的泛型是由編譯時擦除和簽名來實現的。
Java這樣的設計,是為了兼容性的考慮,低版本的字節碼和高版本基本上只有簽名上的不一樣,不影響功能本體,所以,可以不做任何改動就在高版本的虛擬機里運行。
反射獲取泛型信息
上一節中提到了如下的一些泛型信息不會被擦除:
1、泛型接口、類、方法定義上處的所有泛型
2、成員變量聲明處的泛型
可以得出推論,這些泛型信息應當能夠被反射獲取。
對這些能被反射獲取的內容,按照泛型的分類來進行討論:
1、泛型接口和泛型類。它們對應的反射對象都是java.reflect.Class,該類提供了三個方法:
public Type getGenericSuperclass(){...}
public Type[] getGenericInterfaces() {...}
public TypeVariable<Class<T>>[] getTypeParameters() {...}
分別對應:獲取超類的完整類型,獲取接口的完整類型,以及獲取自身的類型變量。
java.lang.reflect.Type是一個空接口,在使用標准JDK的情況下,一般來說,泛型的實現類是:sun.reflect.generics.reflectiveObjects.ParameterizedTypeImpl。
它提供了獲取原始類型和泛型類型的方法。
java.lang.reflect.TypeVariable是Type的子接口,它提供的方法就比Type要詳細一些,這些多出來的方法包括:
Type[] getBound(),獲取上界; D getGenericDeclaration(),獲取泛型定義; String getName(),獲取泛型參數名,也就是<T>中的T。
2、聲明為泛型的字段。它對應的反射對象是java.reflect.Field,提供了一個方法:
public Type getGenericType() {...}
該方法的使用方式和上文一致。
3、泛型方法。對應的反射對象是java.reflect.Method,提供了三個方法:
public Type getGenericReturnType() {...}
public Type getGenericParameterTypes() {...}
public Type getGenericExceptionTypes() {...}
分別對應返回值泛型,參數泛型和異常泛型。
注意!雖然這里可以獲取到泛型的定義,但不論是哪一種方式,其獲取到的泛型,都不會是具體的某一個類。給定一個泛型的定義<T>,能獲取到的只有T這個關鍵字。
這是因為,Java目前的泛型實現已經在原理上(泛型擦除)堵死了“反射獲取泛型的確定類型”的可能性。
泛型的原理和基本概念到這里已經講解得差不多了,后面會介紹一下Java泛型的歷史,以說明為什么Java的泛型為什么有這么多的“缺陷”。
