Java 理論和實踐: 瞭解泛型
識別和避免學習使用泛型過程中的陷阱
表面上看起來,無論語法還是應用的環境(比如容器類),泛型類型(或者泛型)都類似於 C++ 中的模板。但是這種相似性僅限於表面,Java 語言中的泛型基本上完全在編譯器中實現,由編譯器執行類型檢查和類型推斷,然後生成普通的非泛型的字節碼。這種實現技術稱爲 擦除(erasure) (編譯器使用泛型類型信息保證類型安全,然後在生成字節碼之前將其清除),這項技術有一些奇怪,並且有時會帶來一些令人迷惑的後果。雖然範型是 Java 類走向類型安全的一大步,但是在學習使用泛型的過程中幾乎肯定會遇到頭痛(有時候讓人無法忍受)的問題。
注意: 本文假設您對 JDK 5.0 中的範型有基本的瞭解。
雖然將集合看作是數組的抽象會有所幫助,但是數組還有一些集合不具備的特殊性質。Java 語言中的數組是協變的(covariant),也就是說,如果 Integer
擴展了 Number
(事實也是如此),那麼不僅 Integer
是 Number
,而且 Integer[]
也是 Number[]
,在要求Number[]
的地方完全可以傳遞或者賦予 Integer[]
。(更正式地說,如果 Number
是 Integer
的超類型,那麼 Number[]
也是Integer[]
的超類型)。您也許認爲這一原理同樣適用於泛型類型 —— List<Number>
是 List<Integer>
的超類型,那麼可以在需要List<Number>
的地方傳遞 List<Integer>
。不幸的是,情況並非如此。
不允許這樣做有一個很充分的理由:這樣做將破壞要提供的類型安全泛型。如果能夠將 List<Integer>
賦給 List<Number>
。那麼下面的代碼就允許將非 Integer
的內容放入 List<Integer>
:
List<Integer> li = new ArrayList<Integer>(); List<Number> ln = li; // illegal ln.add(new Float(3.1415)); |
因爲 ln
是 List<Number>
,所以向其添加 Float
似乎是完全合法的。但是如果 ln
是 li
的別名,那麼這就破壞了蘊含在 li
定義中的類型安全承諾 —— 它是一個整數列表,這就是泛型類型不能協變的原因。
數組能夠協變而泛型不能協變的另一個後果是,不能實例化泛型類型的數組(new List<String>[3]
是不合法的),除非類型參數是一個未綁定的通配符(new List<?>[3]
是合法的)。讓我們看看如果允許聲明泛型類型數組會造成什麼後果:
List<String>[] lsa = new List<String>[10]; // illegal Object[] oa = lsa; // OK because List<String> is a subtype of Object List<Integer> li = new ArrayList<Integer>(); li.add(new Integer(3)); oa[0] = li; String s = lsa[0].get(0); |
最後一行將拋出 ClassCastException
,因爲這樣將把 List<Integer>
填入本應是 List<String>
的位置。因爲數組協變會破壞泛型的類型安全,所以不允許實例化泛型類型的數組(除非類型參數是未綁定的通配符,比如 List<?>
)。
因爲可以擦除功能,所以 List<Integer>
和 List<String>
是同一個類,編譯器在編譯 List<V>
時只生成一個類(和 C++ 不同)。因此,在編譯 List<V>
類時,編譯器不知道 V
所表示的類型,所以它就不能像知道類所表示的具體類型那樣處理 List<V>
類定義中的類型參數(List<V>
中的 V
)。
因爲運行時不能區分 List<String>
和 List<Integer>
(運行時都是 List
),用泛型類型參數標識類型的變量的構造就成了問題。運行時缺乏類型信息,這給泛型容器類和希望創建保護性副本的泛型類提出了難題。
比如泛型類 Foo
:
class Foo<T> { public void doSomething(T param) { ... } } |
假設 doSomething()
方法希望複製輸入的 param
參數,會怎麼樣呢?沒有多少選擇。您可能希望按以下方式實現 doSomething()
:
public void doSomething(T param) { T copy = new T(param); // illegal } |
但是您不能使用類型參數訪問構造函數,因爲在編譯的時候還不知道要構造什麼類,因此也就不知道使用什麼構造函數。使用泛型不能表達“T
必須擁有一個拷貝構造函數(copy constructor)”(甚至一個無參數的構造函數)這類約束,因此不能使用泛型類型參數所表示的類的構造函數。
clone()
怎麼樣呢?假設在 Foo
的定義中,T
擴展了 Cloneable
:
class Foo<T extends Cloneable> { public void doSomething(T param) { T copy = (T) param.clone(); // illegal } } |
不幸的是,仍然不能調用 param.clone()
。爲什麼呢?因爲 clone()
在 Object
中是保護訪問的,調用 clone()
必須通過將 clone()
改寫公共訪問的類引用來完成。但是重新聲明 clone()
爲 public 並不知道 T
,因此克隆也無濟於事。
因此,不能複製在編譯時根本不知道是什麼類的類型引用。那麼使用通配符類型怎麼樣?假設要創建類型爲 Set<?>
的參數的保護性副本。您知道 Set
有一個拷貝構造函數。而且別人可能曾經告訴過您,如果不知道要設置的內容的類型,最好使用 Set<?>
代替原始類型的 Set
,因爲這種方法引起的未檢查類型轉換警告更少。於是,可以試着這樣寫:
class Foo { public void doSomething(Set<?> set) { Set<?> copy = new HashSet<?>(set); // illegal } } |
不幸的是,您不能用通配符類型的參數調用泛型構造函數,即使知道存在這樣的構造函數也不行。不過您可以這樣做:
class Foo { public void doSomething(Set<?> set) { Set<?> copy = new HashSet<Object>(set); } } |
這種構造不那麼直觀,但它是類型安全的,而且可以像 new HashSet<?>(set)
那樣工作。
如何實現 ArrayList<V>
?假設類 ArrayList
管理一個 V
數組,您可能希望用 ArrayList<V>
的構造函數創建一個 V
數組:
class ArrayList<V> { private V[] backingArray; public ArrayList() { backingArray = new V[DEFAULT_SIZE]; // illegal } } |
但是這段代碼不能工作 —— 不能實例化用類型參數表示的類型數組。編譯器不知道 V
到底表示什麼類型,因此不能實例化 V
數組。
Collections 類通過一種彆扭的方法繞過了這個問題,在 Collections 類編譯時會產生類型未檢查轉換的警告。ArrayList
具體實現的構造函數如下:
class ArrayList<V> { private V[] backingArray; public ArrayList() { backingArray = (V[]) new Object[DEFAULT_SIZE]; } } |
爲何這些代碼在訪問 backingArray
時沒有產生 ArrayStoreException
呢?無論如何,都不能將 Object
數組賦給 String
數組。因爲泛型是通過擦除實現的,backingArray
的類型實際上就是 Object[]
,因爲 Object
代替了 V
。這意味着:實際上這個類期望backingArray
是一個 Object
數組,但是編譯器要進行額外的類型檢查,以確保它包含 V
類型的對象。所以這種方法很奏效,但是非常彆扭,因此不值得效仿(甚至連泛型 Collections 框架的作者都這麼說,請參閱 參考資料
)。
還有一種方法就是聲明 backingArray
爲 Object
數組,並在使用它的各個地方強制將它轉化爲 V[]
。仍然會看到類型未檢查轉換警告(與上一種方法一樣),但是它使一些未明確的假設更清楚了(比如 backingArray
不應逃避 ArrayList
的實現)。
最好的辦法是向構造函數傳遞類文字(Foo.class
),這樣,該實現就能在運行時知道 T
的值。不採用這種方法的原因在於向後兼容性 —— 新的泛型集合類不能與 Collections 框架以前的版本兼容。
下面的代碼中 ArrayList
採用了以下方法:
public class ArrayList<V> implements List<V> { private V[] backingArray; private Class<V> elementType; public ArrayList(Class<V> elementType) { this.elementType = elementType; backingArray = (V[]) Array.newInstance(elementType, DEFAULT_LENGTH); } } |
但是等一等!仍然有不妥的地方,調用 Array.newInstance()
時會引起未經檢查的類型轉換。爲什麼呢?同樣是由於向後兼容性。Array.newInstance()
的簽名是:
public static Object newInstance(Class<?> componentType, int length) |
而不是類型安全的:
public static<T> T[] newInstance(Class<T> componentType, int length) |
爲何 Array
用這種方式進行泛化呢?同樣是爲了保持向後兼容。要創建基本類型的數組,如 int[]
,可以使用適當的包裝器類中的TYPE
字段調用 Array.newInstance()
(對於 int
,可以傳遞 Integer.TYPE
作爲類文字)。用 Class<T>
參數而不是 Class<?>
泛化Array.newInstance()
,對於引用類型有更好的類型安全,但是就不能使用 Array.newInstance()
創建基本類型數組的實例了。也許將來會爲引用類型提供新的 newInstance()
版本,這樣就兩者兼顧了。
在這裏可以看到一種模式 —— 與泛型有關的很多問題或者折衷並非來自泛型本身,而是保持和已有代碼兼容的要求帶來的副作用。
在轉化現有的庫類來使用泛型方面沒有多少技巧,但與平常的情況相同,向後兼容性不會憑空而來。我已經討論了兩個例子,其中向後兼容性限制了類庫的泛化。
另一種不同的泛化方法可能不存在向後兼容問題,這就是 Collections.toArray(Object[])
。傳入 toArray()
的數組有兩個目的 —— 如果集合足夠小,那麼可以將其內容直接放在提供的數組中。否則,利用反射(reflection)創建相同類型的新數組來接受結果。如果從頭開始重寫 Collections 框架,那麼很可能傳遞給 Collections.toArray()
的參數不是一個數組,而是一個類文字:
interface Collection<E> { public T[] toArray(Class<T super E> elementClass); } |
因爲 Collections 框架作爲良好類設計的例子被廣泛效仿,但是它的設計受到向後兼容性約束,所以這些地方值得您注意,不要盲目效仿。
首先,常常被混淆的泛型 Collections API 的一個重要方面是 containsAll()
、removeAll()
和 retainAll()
的簽名。您可能認爲remove()
和 removeAll()
的簽名應該是:
interface Collection<E> { public boolean remove(E e); // not really public void removeAll(Collection<? extends E> c); // not really } |
但實際上卻是:
interface Collection<E> { public boolean remove(Object o); public void removeAll(Collection<?> c); } |
爲什麼呢?答案同樣是因爲向後兼容性。x.remove(o)
的接口表明“如果 o
包含在 x
中,則刪除它,否則什麼也不做。”如果 x
是一個泛型集合,那麼 o
不一定與 x
的類型參數兼容。如果 removeAll()
被泛化爲只有類型兼容時才能調用(Collection<? extends E>
),那麼在泛化之前,合法的代碼序列就會變得不合法,比如:
// a collection of Integers Collection c = new HashSet(); // a collection of Objects Collection r = new HashSet(); c.removeAll(r); |
如果上述片段用直觀的方法泛化(將 c
設爲 Collection<Integer>
,r
設爲 Collection<Object>
),如果 removeAll()
的簽名要求其參數爲 Collection<? extends E>
而不是 no-op,那麼就無法編譯上面的代碼。泛型類庫的一個主要目標就是不打破或者改變已有代碼的語義,因此,必須用比從頭重新設計泛型所使用類型約束更弱的類型約束來定義 remove()
、removeAll()
、retainAll()
和containsAll()
。
在泛型之前設計的類可能阻礙了“顯然的”泛型化方法。這種情況下就要像上例這樣進行折衷,但是如果從頭設計新的泛型類,理解 Java 類庫中的哪些東西是向後兼容的結果很有意義,這樣可以避免不適當的模仿。
因爲泛型基本上都是在 Java 編譯器中而不是運行庫中實現的,所以在生成字節碼的時候,差不多所有關於泛型類型的類型信息都被“擦掉”了。換句話說,編譯器生成的代碼與您手工編寫的不用泛型、檢查程序的類型安全後進行強制類型轉換所得到的代碼基本相同。與 C++ 不同,List<Integer>
和 List<String>
是同一個類(雖然是不同的類型但都是 List<?>
的子類型,與以前的版本相比,在 JDK 5.0 中這是一個更重要的區別)。
擦除意味着一個類不能同時實現 Comparable<String>
和 Comparable<Number>
,因爲事實上兩者都在同一個接口中,指定同一個compareTo()
方法。聲明 DecimalString
類以便與 String
與 Number
比較似乎是明智的,但對於 Java 編譯器來說,這相當於對同一個方法進行了兩次聲明:
public class DecimalString implements Comparable<Number>, Comparable<String> { ... } // nope |
擦除的另一個後果是,對泛型類型參數是用強制類型轉換或者 instanceof
毫無意義。下面的代碼完全不會改善代碼的類型安全性:
public <T> T naiveCast(T t, Object o) { return (T) o; } |
編譯器僅僅發出一個類型未檢查轉換警告,因爲它不知道這種轉換是否安全。naiveCast()
方法實際上根本不作任何轉換,T
直接被替換爲 Object
,與期望的相反,傳入的對象被強制轉換爲 Object
。
擦除也是造成上述構造問題的原因,即不能創建泛型類型的對象,因爲編譯器不知道要調用什麼構造函數。如果泛型類需要構造用泛型類型參數來指定類型的對象,那麼構造函數應該接受類文字(Foo.class
)並將它們保存起來,以便通過反射創建實例。
泛型是 Java 語言走向類型安全的一大步,但是泛型設施的設計和類庫的泛化並非未經過妥協。擴展虛擬機指令集來支持泛型被認爲是無法接受的,因爲這會爲 Java 廠商升級其 JVM 造成難以逾越的障礙。因此採用了可以完全在編譯器中實現的擦除方法。類似地,在泛型 Java 類庫時,保持向後兼容也爲類庫的泛化方式設置了很多限制,產生了一些混亂的、令人沮喪的結構(如Array.newInstance()
)。這並非泛型本身的問題,而是與語言的演化與兼容有關。但這些也使得泛型學習和應用起來更讓人迷惑,更加困難。
-
參與論壇討論
。
- 您可以參閱本文在 developerWorks 全球站點上的 英文原文
。
- 請參閱 Brian Goetz 撰寫的 Java 理論和實踐
系列的所有文章。
- Brian Goetz 撰寫的“Introduction to generic types in JDK 5.0
”(developerWorks,2004 年 12 月)更完整地介紹了泛型類型。
- Eric Allen 的系列文章“診斷 Java 代碼:輕鬆掌握 Java 泛型
” 描述了 Java 語言中對泛型函數支持的演化過程(developerWorks,2003 年 2 月到 5 月)。
- 泛型的規範(包括 Java 語言規範的修改)是由 Java Community Process 按照 JSR 14
開發的。
- Angelika Langer 撰寫了關於泛型的 FAQ
。
- Gilad Bracha 是 Java 語言中對泛型類型支持的首席架構師,他曾撰寫過一篇 泛型教程
(PDF)。
- 在 developerWorks Java 技術專區
可以找到數百篇 Java 技術參考資料。
-
Developer Bookstore
提供了完整的技術圖書列表,其中包括數百本 有關 Java 的書籍
。
Brian Goetz 有超過 17 年的專業軟件開發經驗。他是 Quiotix 的首席顧問,這是一家位於加利福尼亞 Los Altos 的軟件開發和諮詢公司,他還參加了多個 JCP Expert Groups。請參閱 Brian 在主流專業出版物上已經發表或即將發表的文章 。