Java Tutorials -- Generics
Java Generics伴隨JDK 5.0發布到現在已經超過2年半了,但目前還沒有被"非常廣泛"地應用,我也一直沒有進行過系統的學習。最近使用Thinking in Java(4th)和Java Tutorials對泛型進行了專門的學習。本文是對Java Tutorials中Generics一章的翻譯。其實關于Java Generics的文章已是汗牛充棟,之所以將這篇譯文放在此處,也算是對自己學習的一種鼓勵吧。該文的讀者應該只有我一人,但仍然希望對其他朋友有所助益。(2007.07.10最后更新)
1 介紹
JDK 5.0引進了幾種Java程序設計語言的新擴展。其中之一,就是對泛型的引入。
本次體驗只是對泛型的介紹。你可能通過其它的語言,特別是C++ Template,已經對泛型的結構有些熟悉了。如果是這樣的話,你將看到它們的相似點和重要的不同點。如果你對從別處看到的這種似曾相識的結構不熟悉的話,那就更好了,你可以從頭開始,以避免不得不忘卻一些誤解。
泛型允許你抽象出類型。最普通的例子就是容器類型,如集合框架(Collection)中的那些類。
下面是一個此類特性的典型使用:
List myIntList = new LinkedList(); // 1
myIntList.add(new Integer(0)); // 2
Integer x = (Integer) myIntList.iterator().next(); // 3
第三行的強制類型轉換有點煩人。基本上,程序員知道到底是什么類型的數據被放到這個特定的List中了。然而,這個強制類型轉換是必需的。編譯器只能保證迭代器將返回的是一個對象。為了確保一個類型為Integer的變量x是類型安全的,這個強制類型轉換是需要的。
當然,這個強制類型轉換并不會造成混亂。它仍然可能會造成一個運行時錯誤,可能是由程序員的失誤而產生的。
那么程序員如何才能準確地表達他們的本意,使得一個List被限制為只能包含某個特定類型的數據呢?這正是泛型背后的核心思想。下面的程序片斷是前述例子的泛型版:
List<Integer> myIntList = new LinkedList<Integer>(); // 1'
myIntList.add(new Integer(0)); // 2'
Integer x = myIntList.iterator().next(); // 3'
注意變量myIntList的類型聲明。它不是指定了一個任意的List,而是指定了一個Integer對象的List,寫作List<Integer>。我們說,List是一個擁有類型參數,在此處就是Integer,的泛型接口。當創建這個List對象時,我們也指定了一個類型參數。
再次注意,原來行3的的強制類型轉換已經不需要了。
現在你可能會想我們所已經完成的就是移除了那個混亂(強制類型轉換)。我們在行1處就使Integer成為一個類型參數,而不是在行3處進行強制類型轉換。這兒就有一個很大的不同。在編譯時,編譯器就能夠檢查程序中的類型是否正確。當我們說myIntList在聲明時使用了類型List<Integer>,那么就是告訴我們myIntList變量在任何時間和任何地點所包含的類型必須是Integer,并且編譯器會確保這一點。相反地,強制類型轉換只是告訴我們在代碼中的某個獨立的地方程序員所期望的情況而以。
在實際情況下,特別是在大型應用中,泛型可以提高程序的可讀性和魯棒性。
2 定義簡單的泛型
下面是java.util包中List和Iterator接口定義的簡短摘要:
public interface List <E>{
void add(E x);
Iterator<E> iterator();
}
public interface Iterator<E>{
E next();
boolean hasNext();
}
除了角括號中的內容,我們對這段代碼應該比較熟悉了。這些是List和Iterator接口的形式類型參數的聲明。
類型參數的使用可以貫穿于整個泛型聲明,用在那些你以后想使用普通類型的地方(但有一些重要的約束,詳見"良好的打印"一節)。
在"介紹"一節中,我們知道了使用了泛型類型聲明的List接口的調用方法,如List<Integer>。在這個調用(一般就是調用一個參數化的類型)中,所有形式類型參數(即此處的E)出現的地方都被實際的類型參數(即此處的Integer)替換了。
你可能會想像List<Integer>表示一種由Integer統一地代替E之后的新的List版本:
public interface IntegerList {
void add(Integer x);
Iterator<Integer> iterator();
}
這種直覺是有助益的,但那也是誤解。
說它是有助益的,是因為參數類型List<Integer>實際上所使用的方法看起來就是像那種擴展。
說它是誤解,是因為泛型的聲明確實沒有用那種方式進行擴展。并不存在那些代碼的多個復本,在源文件中、二進制文件中、硬盤中、內存中都沒有這些復本。如果你是C++程序員,你將會發現這與C++ Template非常的不同。
泛型類型的聲明絕對只會被編譯一次,然后進入一個class文件中,就像一個普通的類或接口聲明一樣。
類型參數類似于方法或構造器中的普通參數。它非常像一個方法擁有一個形式值參數,這個參數描述了可以出現在該處的值的類型,泛型聲明也有一個形式類型參數。當一個方法被調用時,一個真實的的參數會替換形式參數,然后這個方法會進行評估。當一個泛型聲明被調用時,一個真實的類型參數也會替代形式類型參數。
需要注重一個命名規范。我們推薦你使用叫起來盡量精簡的(如果可能的話,最好是單個字母)的名字作為形式類型參數。最好避免使用小寫字母,這樣就可以很容易地從普通的類和接口中區分出形式類型參數。如上述例子中,很多容器類型使用E代表容器中的元素(element)。
3 泛型與子類
讓我們測試一下你對泛型的理解。下面的代碼片斷是合法的嗎?
List<String> ls = new ArrayList<String>(); // 1
List<Object> lo = ls; // 2
第一行肯定是合法的。這個問題狡猾的部分是在第二行。這個問題可歸結為:一個String對象的List也是Object對象的List嗎?大部分人都會本能的回答到,是的!
那好,來看看下面幾行:
lo.add(new Object()); // 3
String s = ls.get(0); // 4: 試圖將一個Object對象賦值給一個String變量!
此處我們已經別名化了ls和lo。通過別名lo訪問ls,一個String對象的List,我們可以向其中插入任意對象。但ls不能包含除String對象外的其它對象,則當我們試圖從中獲得些什么(Object對象)時,我們會感到非常的驚訝。
譯者:上面這段話的意思是說,如果上述4行代碼都成立的話,那么就會使我們感到很驚訝、很困惑。lo的類型是List<Object>,那么可以放入任意的Object到這個List中;而ls的類型是List<String>,即只能放入String對象。但lo引用的對象實際上是ArrayList<String>的對象,即只能存放String對象,所以上面的例子會使人感到很困惑。
當然,Java編譯器會阻止這一切的發生--第二行將會導致一個編譯時錯誤。
一般地,如果Foo是Bar的子類型(子類或子接口),且G是某個泛型類型聲明,那么G<Foo>并不是G<Bar>的子類型。這可能是當你學習泛型時所遇到的最困難的問題,因為這違反了我們根深蒂固的直覺。
我們不能假設集成對象們不會改變。我們的直覺可能會導致我們靜態地思考這些問題。
例如,如果機動車管理部(Department of Motor Vehicles, DMV)向人口調查局(Census Bureau)提交了一組司機的名單,這會被看成是合理的,因為我們認為List<Driver>是List<Person>的子類型(假設Driver是Person的子類型)。實際上被提交的只是司機注冊表的副本。否則,人口調查局也可以把那些不是司機的人也加入到這個名單 (List)中,這就會破壞DMV的記錄。
為了應對這種情況,有必要考慮更為彈性的泛型類型。我們到目前為止所看到的規則實在是太具限制性了。
4 通配符
考慮這樣一個問題,寫一個程序打印出一個集合對象中的所有元素。下面的程序可能是你用老版Java語言所寫的:
void printCollection(Collection c) {
Iterator i = c.iterator();
for (k = 0; k < c.size(); k++) {
System.out.println(i.next());
}
}
這兒有一個不成熟的對泛型應用的嘗試(并且使用了新的foreach循環語法):
void printCollection(Collection<Object> c) {
for (Object e : c) {
System.out.println(e);
}
}
這個問題就是新版的程序并不比舊版的程序更有用。反之,舊版的程序能夠作為參數被任何類型的集合對象調用,新版的程序只能用于Collection<Object>,而這種情況已經被我們證明了,它并不是所有集合類型的超類。
那么什么才是所有集合對象的超類呢?它應該寫作Collection<?>(叫作"collection of unknow,未知的集合"),這種集合類型的元素才可能配置任何類型。很明顯,它被稱作通配符類型。我們可以這樣寫:
void printCollection(Collection<?> c) {
for (Object e : c) {
System.out.println(e);
}
}
然后我們就可以用任何集合類型來調用這個方法了。注意printCollection方法的內部,我們仍然可以從c中讀取它的元素,并可將這些元素賦值給Object類型的變量。
Collection<?> c = new ArrayList<String>();
c.add(new Object()); // Compile time error
由于不知道c中元素的類型是什么,我們不能向它里面添加元素。add方法接受類型E的參數,即這個集合對象元素的類型。當然實際的類型參數是"?"時,它表示某個未知的類型。任何我們要添加入的參數都將不得不是未知類型的子類型。由于我們不知道這個類型是什么,所以我們不能傳入任何類型。唯一的例外是 "null",null是每個類型的成員(譯者:null是每種類型的子類型。)。
另一方面,給出一個List<?>,我們就能調用get方法并使用得到的結果。所得結果的類型是未知的,但我們總可以知道它是一個 Object對象。因此將由get方法得到的結果賦予一個Object類型的變量,或是將它作為一個參數傳入一個期望獲得Object類型對象的地方,都是完全的。
有邊界的通配符
考慮這樣的一個簡單的繪圖程序,它可以繪制諸如矩形和環形之類的形狀。為了使用程序來描述這些形狀,你可能是會下面那樣定義一組類:
public abstract class Shape {
public abstract void draw(Canvas c);
}
public class Circle extends Shape {
private int x, y, radius;
public void draw(Canvas c) {
...
}
}
public class Rectangle extends Shape {
private int x, y, width, height;
public void draw(Canvas c) {
...
}
}
這些類可以被繪在一個畫布(canvas)上:
public class Canvas {
public void draw(Shape s) {
s.draw(this);
}
}
任何繪制動作通常都會包含一組形狀。假設使用List來表示它們,那么為方便起見,Canvas需要有一個方法去繪制所有的形狀:
public void drawAll(List<Shape> shapes) {
for (Shape s: shapes) {
s.draw(this);
}
}
現在,規則要求drawAll方法只能用于僅包含Shape對象的List,例如它不能用于List<Circle>。但不幸的是,由于所有的方法所做的只是從List中讀取Shape對象,所以它也需要能用于List<Circle>。我們所想要的就是這個方法能夠接受所有的 Shape類型。
public void drawAll(List<? extends Shape> shapes) {
...
}
這兒是一個很小但很重要的區別:我們已經用List<? extends Shape>代替了List<Shape>。現在,drawAll方法就可以接受Shape的任何子類對象的List了。
List<? extends Shape>就是有邊界的通配符的一個例子。問號(?)代表未知類型,就如我們之前所看到的這個通配符一樣。然而,在這個例子中,我們這個未知類型實際上是Shape類的子類。(注:它可以是Shape類型本身;無需按字面上的意義一定說是Shape子類)。
一般地,在使用通配符時要付出一些彈性方面的代價。這個代價就是,馬上向該方法體中寫入Shape類型的對象是非法的。例如,下面的代碼是不被允許的:
public void addRectangle(List<? extends Shape> shapes) {
shapes.add(0, new Rectangle()); // Compile-time error!
}
你應該會指出為什么上面的代碼是不能被接受的。shaps.add的第二個參數是"? extends Shape"--一個未知的Shape子類,由于我們不知道它會是哪個Shape類型,不知道它的超類是否就是Rectangle;它可能是,也可能不是 Rectangle的超類,所以當傳遞一個Rectangle對象,并不安全。
有邊界的通配符正是上一節中DMV向人口調查局提交數據的例子所需要的。我們的例子假設那些數據是由姓名(用字符串表示)到人(用Person或其子類,如 Driver,的引用類型表示)的映射表示。Map<K, V>是包含兩個類型參數的例子,這兩個類型參數分別表示映射中的鍵與值。
再次注意形式類型參數的命名規范--K代表鍵,V代表值。
public class Census {
public static void addRegistry(Map<String, ? extends Person> registry) {
}
...
Map<String, Driver> allDrivers = ... ;
Census.addRegistry(allDrivers);
5 泛型方法
考慮寫一個方法,它包含一個Object數據和一個集合對象,它的作用是將數組中的對象全部插入到集合對象中。下面是第一次嘗試:
static void fromArrayToCollection(Object[] a, Collection<?> c) {
for (Object o : a) {
c.add(o); // Compile time error
}
}
到現在為此,你要學會避免新手所犯的錯誤--嘗試將Collection<Object>作為這個集合的類型參數。你可能認識或沒認識到使用 Collection<?>也不能完成工作。回憶一下,你不能將對象擠入一個未知類型的集合對象中。
處理這些問題的方法是使用泛型方法。就像類型的聲明一樣,方法的聲明也可以泛型化--即,用一個或多個參數去參數化這個方法。
static <T> void fromArrayToCollection(T[] a, Collection<T> c) {
for (T o : a) {
c.add(o); // Correct
}
}
我們能夠調用任意類型的集合對象中的方法,只要這個集合對象中的元素是數組類型中元素的超類型。
Object[] oa = new Object[100];
Collection<Object> co = new ArrayList<Object>();
fromArrayToCollection(oa, co); // T inferred to be Object
String[] sa = new String[100];
Collection<String> cs = new ArrayList<String>();
fromArrayToCollection(sa, cs); // T inferred to be String
fromArrayToCollection(sa, co); // T inferred to be Object
Integer[] ia = new Integer[100];
Float[] fa = new Float[100];
Number[] na = new Number[100];
Collection<Number> cn = new ArrayList<Number>();
fromArrayToCollection(ia, cn); // T inferred to be Number
fromArrayToCollection(fa, cn); // T inferred to be Number
fromArrayToCollection(na, cn); // T inferred to be Number
fromArrayToCollection(na, co); // T inferred to be Object
fromArrayToCollection(na, cs); // compile-time error
注意我們并不需要傳遞一個確切的類型給泛型方法。編譯器會根據準確的參數的類型幫我們推斷出實際類型參數。編譯器通常會推斷出大部分的特定類型參數,這就使得對方法的調用是類型正確的。
產生了一個問題:什么時候我應該使用泛型方法,什么時候我應用使用通配符類型?為了理解答案,讓我們測試一些集合框架類庫中的方法:
interface Collection<E> {
public boolean containsAll(Collection<?> c);
public boolean addAll(Collection<? extends E> c);
}
我們可能使用下面的泛型方法替換上面的程序:
interface Collection<E> {
public <T> boolean containsAll(Collection<T> c);
public <T extends E> boolean addAll(Collection<T> c);
// Hey, type variables can have bounds too!
}
然而,在兩個containAll和addAll方法中,類型參數T只被使用了一次。返回類型既不依賴類型參數,也不需要傳遞其它的參數給這個方法(在本例中,只不過是一個實參罷了)。這就告訴我們該實參將用于多態;它的僅有的作用就是允許該方法的多種不同的實參能夠應用于不同的調用點。
泛型方法允許類型參數用于描述一個或多個實參的類型對于該方法和/或它的返回值之間依賴關系。如果沒有這種依賴關系,那么就不應該使用泛型方法。
一前一后的使用泛型方法和通配符是可能的,下面的方法Collections.copy()就表現了這一點: class Collections {
public static <T> void copy(List<T> dest, List<? extends T> src) {
...
}
注意這兩個參數的類型之間的依賴關系。任何復制于源表scr的對象對于目標表dest中元素的類型T都必須是可賦值的。所以src元素的類型肯定是T的任何子類型--我們不用關心這些。復制方法的簽名使用一個類型參數描述了這種依賴關系,但將通配符用于第二個參數中元素的類型。
我們也可以使用另一種方法來書寫這個方法的簽名,這種方法完全不需要使用通配符:
class Collections {
public static <T, S extends T>
void copy(List<T> dest, List<S> src) {
...
}
這很好,但當第一個類型參數在類型dest和第二個類型的限度中都使用了時,S它那本身只被使用了一次,就是在src的類型中--沒任何其它的東西再依賴于它了。這就是一個我們要以使用通配符替換S的一個信號。使用通配符比顯示的聲明類型變量更加清晰、更加精確,所以在任何可能的時候通配符是首選。
通配符也有它的優點,它可以被用于方法簽名的外面,以作為字段的類型,局部變量或數組。下面就是這樣的一個例子。
回到我們繪制形狀的那個例子,假設我們想維護一個繪制形狀請求的歷史記錄。我們可以將這個歷史記錄維護在類Shape內部的一個靜態變量,讓drawAll方法將它自己獲得的實參(即要求繪制的形狀)加入歷史字段中。
static List<List<? extends Shape>> history =
new ArrayList<List<? extends Shape>>();
public void drawAll(List<? extends Shape> shapes) {
history.addLast(shapes);
for (Shape s: shapes) {
s.draw(this);
}
}
最后,仍然讓我們再次注意類型變量的命名規范。我們一般使用T表示類型,只要無需再區別任何其它的特定類型。這種情況經常用于泛型方法中。如果有多個類型參數,我可以使字母表中鄰近T的其它字母,例如S。如果在一個泛型類中有一個泛型方法,那么為了避免混淆,一個好的習慣是不要使泛型類和泛型方法有相同名字的類型參數。這也適用于嵌套泛型類。
6 與遺留代碼交互
到現在為止,我們的例子是假設處于一種理想的狀況,即每個人都在使用Java程序設計語言的支持泛型的最新版。
唉,但現實并非如此。數以百萬行計的代碼是用Java語言的早期版本寫的,而且也不可能在一夜之間就將它們轉換到新版中。
稍后,在"使用泛型轉化遺留代碼"這一節中,我們將解決將你的舊代碼轉換到使用泛型這個問題。在本節,我們將關注一個簡單的問題:遺留代碼與泛型代碼之間如何交互?這個問題含有兩個部分:在泛型代碼內部使用遺留代碼;在遺留代碼內部使用泛型代碼。
作為一個例子,假設你想使用包com.Fooblibar.widgets。分支Fooblibar.com*商用在一個資產管理系統中,這個系統的精華如下所示:
package com.Fooblibar.widgets;
public interface Part { ...}
public class Inventory {
/**
* Adds a new Assembly to the inventory database.
* The assembly is given the name name, and consists of a set
* parts specified by parts. All elements of the collection parts
* must support the Part interface.
**/
public static void addAssembly(String name, Collection parts) {...}
public static Assembly getAssembly(String name) {...}
}
public interface Assembly {
Collection getParts(); // Returns a collection of Parts
}
現在,你要添加一些新的代碼并使用上述API。比較好的是,要確保你一直能夠使用適當的實參去調用addAssembly方法--即,你傳入的集合對象必須是裝有Part對象的集合對象。當然,泛型最適合做這些了:
package com.mycompany.inventory;
import com.Fooblibar.widgets.*;
public class Blade implements Part {
...
}
public class Guillotine implements Part {
}
public class Main {
public static void main(String[] args) {
Collection<Part> c = new ArrayList<Part>();
c.add(new Guillotine()) ;
c.add(new Blade());
Inventory.addAssembly("thingee", c);
Collection<Part> k = Inventory.getAssembly("thingee").getParts();
}
}
當我們調用addAssembly方法時,該方法希望第二個參數的類型是Collection。該參數的實際類型是Collection< Part>。這是正確的,但是什么呢?畢竟,大部分的Collection是不能包含Part對象的,因為一般來說,編譯器無法知道該 Collection所表示的是哪種對象的集合對象。
在合適的泛型代碼中,Collection將一直跟隨著一個類型參數。當一個像Collection這樣的泛型類型在被使用時沒有提供類型參數,就被稱之為原生類型(Raw Type)。
大多數人的每一直覺認為Collection就是Collection<Object>。然而,按我們之前所說的,在需要Collection<Object>的地方使用Collection<Part>并不是安全的。
但請等等,那也不對!想想對getParts對象的調用,它要返回一個Collection對象(實際上是一個引用變量)。然后這個對象被賦于變量k,k是 Collection<Part>類型。如果調用該方法而返回的結果是一個Collection<?>對象,該賦值操作也將產生錯誤。
事實上,該賦值操作是合法的,它會生產一個未檢查的警告。這個警告是必要的,因為事實上編譯器并不能保證它的正確性。我們沒辦法檢查 getAssembly方法中的遺留代碼以保證返回的集合對象Part對象的集合。被用于該代碼的類型是Collection,能夠合法的向這種 Collection中插入任何類型的對象。
那么這還應該是一個錯誤嗎?就理論上而言,是的;但就實際上而言,如果泛型代碼是為了調用遺留代碼,那么就不得不允許了。對于你,一個程序員,會對這種情況感到滿意的,賦值是安全的,因為getAssermbly方法的規則告訴我們它返回返回的是 Part對象的Collection,即使該方法的簽名并沒有表明這一點。
所以原生類型非常像通配符類型,但它們不會被做嚴格的類型檢查。這是經過深思熟慮之后的結果,是為了允許泛型代碼能夠與之前已存在的代碼交互使用。
用泛型代碼調用遺留代碼是天生危險的;一旦你在泛型代碼中混合了非泛型的遺留代碼,那么泛型類型系統通常都無法提供完全的保證。然而,這仍然比你不使用泛型要好些。至少你知道最終這些代碼是一致的。
碰到那兒已經有了很多的非泛型代碼,然后又有了泛型代碼的時候,那么無法避免的情況就是不得不混合它們。
如果你發現你必須混合使用遺留代碼和泛型代碼,請密切注意未檢查的警告。要謹慎地思考你如何再才能證明那些被給出了危險警告的代碼是安全的。
當你繼續犯錯誤,且代碼造成的警告確實不是類型安全的,什么事情將發生呢?讓我們看看這樣的一種情況。在這個處理過程中,我們將觀察編譯器所做的事情。
擦除和翻譯
public String loophole(Integer x) {
List<String> ys = new LinkedList<String>();
List xs = ys;
xs.add(x); // Compile-time unchecked warning
return ys.iterator().next();
}
此處,我們已經別名化了String的List和一個普通的老版的List。我們向這個List xs插入一個Integer對象,并試圖抽取一個String對象。這顯然是錯的。如果我們忽略警告并嘗試執行這段代碼,它將在我們試圖使用錯誤類型的地方上失敗。
public String loophole(Integer x) {
List ys = new LinkedList;
List xs = ys;
xs.add(x);
return(String) ys.iterator().next(); // run time error
}
當我們從這個List中抽取一個元素,并試圖將它當作String對象而把它轉換成String時,我們將得到一個ClassCastException的異常。完全相同的情況也發生在了loophole方法的泛型版中。
這種情況的原因就是泛型是由Java編譯器作為一種叫做"擦除(Erasure)"的最前到后的機制實現的。你(幾乎)可以把它想像為一種"源代碼對源代碼"(source-to-source)的翻譯,這就是為何loophole的泛型版被轉換成了非泛型版了。
結果,Java虛擬機的類型安全和完整性再也不處于危險中了,甚至在遇到到未檢查的警告時也一樣。
基本地,Erasure去除(或者說"擦除")了所有的泛型信息。所有的在角括號中的類型信息都被拋棄了,所以,如像List<String> 這樣的參數化類型被轉化成了List。所有保持對類型變量使用的地方都被類型變量的高層限度類型(一般就是Object)替換了。并且,無論何時產生的結果都不是類型正確的,一個向適當的類型的強制類型轉換被插入了其中。
對Erasure的全部細節的描述超出了本教程的范疇,但我們給出的簡單描述離真實情況并不太遠。了解一些這方面的知識是有益的,特別是如果你想做一些更加老練的泛型應用,如把已有的API轉換到使用泛型時(詳見"使用泛型轉化遺留代碼"),或者只是想理解為什么它們會是這種情況。
在遺留代碼中使用泛型代碼
現在讓我們思考一個顛倒的例子。想像Foolibar.com選擇泛型去轉化了它們的API,但他們的一些客戶端程序還沒有轉化。所以這些代碼看起來像:
package com.Fooblibar.widgets;
public interface Part {
...
}
public class Inventory {
/**
* Adds a new Assembly to the inventory database.
* The assembly is given the name name, and consists of a set
* parts specified by parts. All elements of the collection parts
* must support the Part interface.
**/
public static void addAssembly(String name, Collection<Part> parts) {...}
public static Assembly getAssembly(String name) {...}
}
public interface Assembly {
Collection<Part> getParts(); // Returns a collection of Parts
}
客戶端程序看起來像:
package com.mycompany.inventory;
import com.Fooblibar.widgets.*;
public class Blade implements Part {
...
}
public class Guillotine implements Part {
}
public class Main {
public static void main(String[] args) {
Collection c = new ArrayList();
c.add(new Guillotine()) ;
c.add(new Blade());
Inventory.addAssembly("thingee", c); // 1: unchecked warning}
Collection k = Inventory.getAssembly("thingee").getParts();
}
}
這些客戶端代碼是在泛型產生之前寫成的,但它使用了包com.Fooblibar.widgets和集合框架類庫,這兩者都在使用泛型。客戶端中對泛型類型的使用使得它們成為了原生(Raw Type)類型。
代碼行1產生了一個未檢查的警告,因為一個原生Collection被傳入了一個期望是Collection<Part>出現的地方,而且編譯器無法保證這個原生Collection真的就是Part對象的Collection。
作為一種可選的方法,你可以將這些代碼作為Java 1.4的源代碼進行編譯,這就能保證不會出現警告。但這樣的話,你將不能使用到JDK 5.0中任何新的語言特性。
--------------------------------------------------------------------------
注意,"Fooblibar.com"是一個純屬虛構的公司,目的僅僅只是為了本文中的例子。任何公司或機構、任何健在或已故的個人與此有關的話,純屬巧合。
譯者:看來老外做事情十分謹慎,對于這種"小問題"我們又怎么會如此鄭重其事的發表一個聲明呢。
7 良好的打印
一個泛型類被它的所有應用共享
下面的代碼片斷是打印出什么呢?
List <String> l1 = new ArrayList<String>();
List<Integer> l2 = new ArrayList<Integer>();
System.out.println(l1.getClass() == l2.getClass());
你可能會被引誘得說是false,但你錯了。打印的是true,因為一個泛型類的所有實際擁有相同的運行時類,而不管它們具體的類型參數。
確實,對一個類的泛型所做的事實就是這個泛型類對它所有可能的類型參數都有相同的行為;相同的這個類可以被視為它有很多不同的類型。
同樣的結果,泛型類中的靜態變量和方法也被該類的所有實例共享。這就是為什么在一個靜態方法或初始化器中、在一個靜態變量的聲明或初始化器中引用類型變量是非法的。
Cast和Instanceof
一個泛型類被它的所有實例共享的另一個隱含意義就是,如果某個實例是這個泛型類的一種特定類型的實例,那么通常情況下請求這個類的實例是無意義的:
Collection cs = new ArrayList<String>();
if (cs instanceof Collection<String>) { ...} // Illegal.
類似地,如下面這個強制類型轉換
Collection<String> cstr = (Collection<String>) cs; // Unchecked warning,
會報一個未檢查的警告,因為這不應該是運行時系統將要為你檢查的事情。
對類型變量也是如此
<T> T badCast(T t, Object o) {return (T) o; // Unchecked warning.
}
類型變量在運行時并不存在。這就意味著在時間和空間上,它們都不可能避免地無法產生作用。不幸的是,這也意味著你不能可靠地在強制類型轉換中使用它們。
數組
一個數組對象中元素的類型不會是一個類型變量或參數化的類型,除非它是一個(非受限的)通配符類型。你可以聲明數組類型的元素類型是一個類型變量或參數化的類型,但數組對象本身不行。
這很煩人,但卻是真的。該約束對避免如下例子中的情況是有必要的:
List<String>[] lsa = new List<String>[10]; // Not really allowed.
Object o = lsa;
Object[] oa = (Object[]) o;
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
oa[1] = li; // Unsound, but passes run time store check
String s = lsa[1].get(0); // Run-time error: ClassCastException.
如果允許有參數化類型的數組,上面的例子將會通過編譯且不報任何未檢查的警告,然而會在運行時失敗。我們已經知道設計泛型的主要目的就是為了類型安全。特別地說,Java語言被設計為,如果你的整個程序使用javac -source 1.5進行編譯時沒有報任何未檢查的警告,那么這個程序就是類型安全的。
然而,你仍然可以使用通配符數組。這兒有上面代碼的兩個變種。第一個變種放棄使用參數化類型的數組對象和參數化類型元素。這樣我們為了在數組外得到String對象不得不在顯示地使用強制類型轉換。
List<?>[] lsa = new List<?>[10]; // OK, array of unbounded wildcard type.
Object o = lsa;
Object[] oa = (Object[]) o;
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
oa[1] = li; // Correct.
String s = (String) lsa[1].get(0); // Run time error, but cast is explicit.
在第二個變種中,我們限制了數組對象的創建,這個數組的元素的類型被參數化了,但仍然要將一個參數化的元素類型用于這個數組。這是合法的,但產生一個未檢查的警告。確實,這段代碼是不安全的,甚至會導致一個錯誤。
List<String>[] lsa = new List<?>[10]; // Unchecked warning. This is unsafe!
Object o = lsa;
Object[] oa = (Object[]) o;
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
oa[1] = li; // Correct.
String s = lsa[1].get(0); // Run time error, but we were warned.
譯者:根據我的測試(JDK 1.5.0_11),"List<String>[] lsa = new List<?>[10]"這一句無法通過編譯,理由也很直觀"類型不匹配,不能將List<?>[]轉化為List< String>[]"。
類似地,試圖創建一個元素類型是類型變量的數組對象會導致一個運行時錯誤:
<T> T[] makeArray(T t) {
return new T[100]; // Error.
}
因為類型變量在運行時并不存在,這就沒有辦法確定數組的實際類型。
圍繞著這些限制的工作方法是使用了將類字面量當作運行時類型標記的機制,該機制將在下一節"類字面量作為運行時標記"中進行敘述。
8 類字面量作為運行時標記
JDK 5.0的變量之一就是java.lang.Class也被泛型化了。這是一個不容器類而在其它地方使用泛型機制的有趣例子。
既然Class類有一個類型參數T,你可能會問,這個T代表什么?它代表這個Class對象表示的類型。
例如,String.class的類型是Class<String>,而Serializable.class的類型就是Class<Serializable>。這種機制用于提高在你的反射程序中的類型安全性。
特別地,由于Class類中的方法netInstance現在是返回一個T,這樣當你在使用反射機制創建對象時能夠得到更加精確的類型。
例如,假設你需要一個執行數據庫查詢的工具方法,給入的是SQL字符串,返回的是數據庫中匹配該查詢語言的對象的集合。
一種方法就是顯示地傳入一個工廠對象中,所寫的代碼就像:
interface Factory<T> { T make();}
public <T> Collection<T> select(Factory<T> factory, String statement) {
Collection<T> result = new ArrayList<T>();
/* Run sql query using jdbc */
for (/* Iterate over jdbc results. */) {
T item = factory.make();
/* Use reflection and set all of item's fields from sql results. */
result.add(item);
}
return result;
}
你可以像下面那么樣去調用
select(new Factory<EmpInfo>(){ public EmpInfo make() {
return new EmpInfo();
}}
, "selection string");
你也可以聲明一個EmpInfoFactory類去支持Factory接口
class EmpInfoFactory implements Factory<EmpInfo> {
...
public EmpInfo make() { return new EmpInfo();}
}
然后像下面那樣去調用它
select(getMyEmpInfoFactory(), "selection string");
這個解決方案最終還需要:
* 在調用點使用冗長的匿名工廠類,
* 或者,為每個被使用的類型聲明一個工廠類,并將這個工廠類的實例傳遞到調用點,但這種方法有點不自然。
可以很自然地將類字面量用作工廠對象,這個工廠稍后可被反射機制使用。現在這個程序(不用泛型)可以寫為:
Collection emps = sqlUtility.select(EmpInfo.class, "select * from emps");
...
public static Collection select(Class c, String sqlStatement) {
Collection result = new ArrayList();
/* Run sql query using jdbc. */
for (/* Iterate over jdbc results. */ ) {
Object item = c.newInstance();
/* Use reflection and set all of item's fields from sql results. */
result.add(item);
}
return result;
}
可是,這不能給我們一個所期望的精確類型的集合。既然Class是泛型的,我們可以使用下面的代替寫法:
Collection<EmpInfo> emps =
sqlUtility.select(EmpInfo.class, "select * from emps");
...
public static <T> Collection<T> select(Class<T> c, String sqlStatement) {
Collection<T> result = new ArrayList<T>();
/* Run sql query using jdbc. */
for (/* Iterate over jdbc results. */ ) {
T item = c.newInstance();
/* Use reflection and set all of item's fields from sql results. */
result.add(item);
}
return result;
}
上面的程序以一種類型安全的方法給了我們精確類型的集合。
將類字面量作為運行時標記的技術被認為十分狡猾。例如,為了操作Annotation,這種技術在新API中被擴展使用了。
9 通配符的更多趣味
在本節,我們將考慮一些更高級的通配符用法。我們已經看了幾個受限的通配符用于讀取數據結構時例子。現在反過來想想一個只可寫的數據結構。接口Sink是這種類型的一個簡單的例子:
interface Sink<T> {
flush(T t);
}
我們可以想像將它作為一個范例用于下面的代碼。方法writeAll被設計為刷新集合coll中的所有元素到Sink的實例snk中,并返回最后一個被刷新的元素。
public static <T> T writeAll(Collection<T> coll, Sink<T> snk) {
T last;
for (T t : coll) {
last = t;
snk.flush(last);
}
return last;
}
...
Sink<Object> s;
Collection<String> cs;
String str = writeAll(cs, s); // Illegal call.
就已經寫出來的,對writeAll方法的調用是非法的,由于無法推斷出有效的類型實參;String或Object都不是T的合適類型,因為Collection的元素和Sink必須是相同的類型。
我們可以通過修改writeAll的方法簽名來修正這個錯誤,如下所示,使用了通配符:
public static <T> T writeAll(Collection<? extends T>, Sink<T>) {...}
...
String str = writeAll(cs, s); // Call is OK, but wrong return type.
該調用是合法的,但賦值是錯的,是由于返回類型被推斷成了Object,因為T匹配s的類型,但s的類型是Object。
該解決方案使用了一種我們尚未見過的受限通配符形式:有一個較低限度的通配符。語法"? super T"表示未知類型是T的超類型(或者是T本身;記住,超類型關系是彈性的)。
public static <T> T writeAll(Collection<T> coll, Sink<? super T> snk) {
...
}
String str = writeAll(cs, s); // Yes!
使用了這種語法,方法的調用就是合法的,并且被推斷的類型正如所愿是String。
現在讓我們轉向更為實際的例子。java.util.TreeSet<E>表示了一個排序了的以類型為E的對象作為元素的樹。構造一個 TreeSet對象的方法之一是傳遞一個Comparator對象給這個構造器。該Comparator對象將被用于根據期望的規則對TreeSet中的元素進行排序。
TreeSet(Comparator<E> c)
Comparator接口是必須的:
interface Comparator<T> {
int compare(T fst, T snd);
}
假設我們想創建一個TreeSet<String>對象,并傳入一個合適的比較器對象。我們就需要一個能比較String的 Comparator對象,一個Comparator<String>就可以做到,但一個Comparator<Object> 對象也能做到。然而,我們不能調用上面Comparator<Object>所提供的構造器。
TreeSet(Comparator<? super E> c)
上述代碼允許適用的比較器被使用。
作為最后一個低位受限通配符的例子,讓我們看看Collections.max方法,該方法返回一個集合中的極大元素。為了讓max文件能夠工作,集合中所有的傳入該集合的元素都必須實現了Comparable接口。此外,它們相互之間必須是可被比較的。
在第一次嘗試創建這個方法后有如下結果:
public static <T extends Comparable<T>>
T max(Collection<T> coll)
即,這個方法有一個某類型T的集合對象,T的實例之間可以進行比較,該方法并返回一個該類型的元素。然而,這個程序實現起來太受限制了。看看是為什么,考慮一個對象,它能與任意對象進行比較:
class Foo implements Comparable<Object> {
...
}
Collection<Foo> cf = ... ;
Collections.max(cf); // Should work.
Collection cf中的每個元素都能與該集合中的其它元素進行比較,因為每個這樣的元素都是一個Foo的實例,而Foo的實例能夠與任意對象進行比較,則與另一個Foo 對象比較那就更沒問題了。然而,使用前面的方法簽名,我們可以發現上面對方法max的調用會被拒絕。被推斷出的類型必須是Foo,但Foo并沒有實現 Comparable<Foo>。
沒有必要精確地要求T與它自己的實例進行比較。所有被要求的是T的實例能夠與它的某個超類型的實例進行比較。這就讓我們有了如下代碼:
public static <T extends Comparable<? super T>>
T max(Collection<T> coll)
注意到Collections.max真實的方法簽名更難以理解。我們將在下一節"將遺留代碼轉化到使用泛型"中再講述它。這個適用于幾乎任何一個 Comprarable應用的理論是打算能用于任意的類型:你總是想使用Comprarable<? super T>。
一般地,如果你的API只是將類型參數T作為類型變量使用,那就應該利于低位受限通配符(? super T)。相反地,如果這個API只需返回T,你就要使用高位受限通配符(? extends T)以給這個API的客戶端程序更大的靈活性。
通配符捕獲
到目前為此,下面的程序應該更清晰些:
Set<?> unknownSet = new HashSet<String>();
...
/** Add an element t to a Set s. */
public static <T> void addToSet(Set<T> s, T t) {
...
}
但下面的調用是非法的。
addToSet(unknownSet, "abc"); // Illegal.
傳入該方法的一個精確的Set是一個String的Set這沒有影響;問題在于作為實參傳入表達式的是一個未知類型的Set,這并不能保證它一定就是String或其它任何特定類型的Set。
現在考慮下面的代碼:
class Collections {
...
<T> public static Set<T> unmodifiableSet(Set<T> set) {
...
}
}
...
Set<?> s = Collections.unmodifiableSet(unknownSet); // This works! Why?
看起來它應該不被允許;然而,看看這個特殊的調用,它確實是安全的而可以允許這么做。畢竟,unmodifiableSet方法可用于任何類型的Set,而不管這個Set中的元素的類型。
因為這種情況發生地相對比較頻繁,所以有一個特殊的規則允許這些在一個非常特殊的環境中的代碼是合法的,在這個環境中這些代碼被證明是安全的。這個名為"通配符捕獲"的規則允許編譯器將通配符的未知類型作為類型實參推斷到泛型方法中。
10 將遺留代碼轉化為使用泛型
早先,我們展示了新、老代碼之間如何交互。現在是時候看看"泛型化"老代碼這個困難的問題了。
如果你決定將老代碼轉換成使用泛型,你需要仔細考慮如何去修改你的API。
你需要確定泛型化的API不會造成過度的限制;它必須能繼續地支持API原先的功能。再次考慮一些來自于java.util.Collection中的例子。沒有使用泛型的API看起來像:
interface Collection {
public boolean containsAll(Collection c);
public boolean addAll(Collection c);
}
一種自然的泛型化嘗試可能像下面那樣:
interface Collection<E> {
public boolean containsAll(Collection<E> c);
public boolean addAll(Collection<E> c);
}
肯定是類型安全的了,但它并沒有實現該API之前的功能。containsAll方法用于任何引入的集合對象,如果引入的集合真地僅包含E的實例時,該方法才會成功。但是:
* 引入集合的靜態類型可能有所不同,或許是因為調用者不知道傳入的集合對象的準確類型,或者可能是因為它是一個Collection<S>,而S是E的子類型。
* 能夠合法地使用一個不同的類型的集合調用containsAll方法則最為理想了。這種方法應該能工作,并將返回false。
在這個例子中的addAll方法,我們應該能夠加入由任何由E的子類型的實例組成的集合對象。我們在"泛型方法"這一節中已經看過了如何正確地處理此類情況。
你也需要保證修改后的API要保持與老的客戶端程序的二進制兼容性。這就暗示著"擦除"后的API必須與以前的非泛型化API相同。在大部分例子中,這自然會引用爭吵,但也有一些精妙的例子。我們將測試我們已經遇到過的最精妙例子中的一個,即Collections.max()方法。根據我們在"通配符的更多樂趣"一節所看到的,一個模糊的max方法簽名是:
public static <T extends Comparable<? super T>>
T max(Collection<T> coll)
除了擦除后的簽名之外,這些都很好:
public static Comparable max(Collection coll)
這與max之前的方法簽名不同:
public static Object max(Collection coll)
當然可以這樣指定max方法的簽名,但這沒有什么用。所有老的調用Collections.max方法的二進制class文件都依賴于返回類型為Object的方法的簽名。
通過顯示地在限度中為形式類型參數T指定一個超類,我們能夠強制這個擦除產生不同的結果。
public static <T extends Object & Comparable<? super T>>
T max(Collection<T> coll)
這是一個單個類型參數有多個限度的例子,使用語法"T1 & T2 ... & Tn"。有多個限度的類型變量是被認為是限度中所有類型的一個子類型。當使用多限度時,限度中第一個被提及的類型將作為該類型變量被擦除后的類型。
最后,我們應該回想到max方法只需從輸入的Collection中進行讀取操作,所以這適合于T的任何子類型的集合。
這就把我們帶入到JDK中該方法的真實簽名中:
public static <T extends Object & Comparable<? super T>>
T max(Collection<? extends T> coll)
在實踐中產生如此晦澀的應用是十分罕見的,但是當轉換現有API時,專家型的類庫設計者們應該要準備著去進行非常細致地地思考。
另一個問題需要密切關注的就是"協變返回",即在一個子類型中精煉了返回類型。你不需要在老的API中使用這個特性。為了找到原因,讓我們看一個例子。
假設你原先的API是如下形式:
public class Foo {
public Foo create() {
...
} // Factory. Should create an instance of whatever class it is declared in.
}
public class Bar extends Foo {
public Foo create() {
...
} // Actually creates a Bar.
}
為了利用"協變返回",你將它修改為:
public class Foo {
public Foo create() {
...
} // Factory. Should create an instance of whatever class it is declared in.
}
public class Bar extends Foo {
public Bar create() {
...
} // Actually creates a Bar.
}
現在假設有一個像下面那樣寫的你代碼的第三方客戶端程序:
public class Baz extends Bar {
public Foo create() {
...
} // Actually creates a Baz.
}
Java 虛擬機不直接支持有著不同返回類型的方法的覆蓋,該特性由編譯器支持。因此,除非Baz類被重新編譯,否則它不能正常地覆蓋Bar的create方法。另外,Baz將不得不被修改,因為這些代碼將如前面所寫的那樣被拒絕--Baz中的create方法返回類型并不是Bar中create方法返回類型的子類型。
譯者:根據我的測試(JDK 1.5.0_11),Baz類中的create方法無法通過編譯,理由就是Baz.create方法與Bar.create方法的返回不兼容,返回類型須是Bar,而不是Foo。
致謝
Erik Ernst, Christian Plesner Hansen, Jeff Norton, Mads Torgersen, Peter von der Ahe和Philip Wadler為該教程提供了材料。
感謝David Biesack, Bruce Chapman, David Flanagan, Neal Gafter, Orjan Petersson, Scott Seligman, Yoshiki Shibata和Kresten Krab Thorup為該教程的早期版本所提出的富有價值的反饋。向我忘記列出來的每個人道歉。