泛型是我們需要的程序設計手段。使用泛型機制編寫的程序代碼要比那些雜亂地使用 Object 變量,然后再進行強制類型轉換的代碼具有更好的安全性和可讀性。
至少在表面上看來,泛型很像 C++ 中的模板。與 Java —樣,在 C++ 中,模板也是最先被添加到語言中支持強類型集合的。但是,多年之后人們發現模板還有其他的用武之地。學習完本章的內容可以發現 Java 中的泛型在程序中也有新的用途。
為什么要使用泛型程序設計
泛型程序設計(Generic programming)意味著編寫的代碼可以被很多不同類型的對象所重用。例如,我們并不希望為聚集 String 和 File 對象分別設計不同的類。實際上,也不需要這樣做,因為一個 ArrayList 類可以聚集任何類型的對象。這是一個泛型程序設計的實例。
實際上,在 Java 增加泛型類之前已經有一個 ArrayList 類。下面來研究泛型程序設計的機制是如何演變的,另外還會講解這對于用戶和實現者來說意味著什么。
類型參數的好處
在 Java 中增加范型類之前,泛型程序設計是用繼承實現的。實現時使用通用類型(如 Object 或 Comparable 接口),在使用時進行強制類型轉換。
泛型對于集合類尤其有用,ArrayList 就是一個無處不在的集合類。ArrayList 類維護一個 Object 類型的數組(Object 類是所有類的父類):
// before generic classes
public class ArrayList {
private Object[] elementData;
// ...
public Object get(int i) { ... }
public void add(Object o) { ... }
}
這種方法有兩個問題。
- 當獲取一個值時,必須進行強制類型轉換。
- 此外,這里沒有錯誤檢査??梢韵驍到M列表中添加任何類的對象。對于 files.add(new File("..."); 這個調用,編譯和運行都不會出錯。然而在其他地方,如果將 get() 的結果強制類型轉換為 String 類型, 就會產生一個錯誤。
ArrayList files = new ArrayList();
String filename = (String) files.get(0);
泛型提供了一個更好的解決方案:類型參數(type parameters)。ArrayList 類有一個類型參數用來指示元素的類型:ArrayList<String> files = new ArrayList<String>();
這使得代碼具有更好的可讀性。人們一看就知道這個數組列表中包含的是 String 對象。
在 Java7 及以后的版本中,構造函數中可以省略泛型類型:ArrayList<String> files = new ArrayList<>();
省略的類型可以從變量的類型推斷得出。
編譯器也可以很好地利用這個信息。當調用 get() 方法的時候,不需要進行強制類型轉換,編譯器就知道返回值類型為 String,而不是 Object:String filename = files.get(0);
編譯器還知道 ArrayList
files,add(new File("...")); // can only add String objects to an ArrayList<String>
類型參數的魅力在于:使得程序具有更好的可讀性和安全性。
誰想成為泛型程序員
使用像 ArrayList 這樣的泛型類很容易。大多數 Java 程序員都使用 ArrayList
但是,實現一個泛型類并沒有那么容易。對于類型參數,使用這段代碼的程序員可能想要內置(plugin)所有的類。他們希望在沒有過多的限制以及混亂的錯誤消息的狀態下,做所有的事情。因此,一個泛型程序員的任務就是預測出所用類的未來可能有的所有用途。
這一任務難到什么程度呢?下面是標準類庫的設計者們肯定產生爭議的一個典型問題。ArrayList 類的 addAll() 方法用來添加另一個集合的全部元素。程序員可能想要將 ArrayList
泛型程序設計劃分為 3 個能力級別?;炯墑e是,僅僅使用泛型類:典型的是像 ArrayList 這樣的集合,不必考慮它們的工作方式與原因。大多數應用程序員將會停留在這一級別上,直到出現了什么問題。當把不同的泛型類混合在一起時,或是在與對類型參數一無所知的遺留的代碼進行銜接時,可能會看到含混不清的錯誤消息。如果這樣的話,就需要系統地學習 Java 泛型來解決這些問題,而不要胡亂地猜測。當然,最終可能想要實現自己的泛型類與泛型方法。
應用程序員很可能不喜歡編寫太多的泛型代碼。JDK 開發人員已經做出了很大的努力,為所有的集合類提供了類型參數。憑經驗來說,那些原本涉及許多來自通用類型(如 Object 或 Comparable 接口)的強制類型轉換的代碼一定會因使用類型參數而受益。
本章介紹實現自己的泛型代碼需要了解的各種知識。希望大多數讀者可以利用這些知識解決一些疑難問題,并滿足對于參數化集合類的內部工作方式的好奇心。
泛型類
泛型類(generic class)就是具有一個或多個類型參數的類。
本章使用一個簡單的 Pair 類作為例子。
public class Pair<T> {
private T first;
private T second;
public Pair() { first = null ; second = null ; }
public Pair(T first, T second) { this.first = first; this.second = second; }
public T getFirst() { return first; }
public T getSecond() { return second; }
public void setFirst(T newValue) { first = newValue; }
public void setSecond(T newValue) { second = newValue; }
}
Pair 類引入了一個類型參數 T,用尖括號(< >)括起來,并放在類名的后面。
泛型類可以有多個類型參數。如果有多個類型變量,多個類型變量之間用 “,”逗號分隔。例如,可以定義 Pair 類,其中第一個域和第二個域使用不同的類型:public class Pair<T, U> { ... }
類定義中的類型參數指定方法的返回類型以及域和局部變量的類型。例如,private T first;
用具體的類型替換類型參數就可以實例化泛型類型,例如:Pair
- Pair
() - Pair
(String, String)
和方法:
- String getFirst()、String getSecond()
- void setFirst(String)、void setSecond(String)
換句話說,泛型類可看作普通類的工廠。
泛型方法
前面已經介紹了如何定義一個泛型類。實際上,還可以定義一個帶有類型參數的簡單方法。
class ArrayAlg {
public static <T> T getMiddle(T... a) {
return a[a.length / 2];
}
}
這個方法是在普通類中定義的,而不是在泛型類中定義的。然而,這是一個泛型方法,可以從尖括號和類型參數看出這一點。注意,類型參數放在修飾符(這里是 public static)的后面,返回類型的前面。
泛型方法可以定義在普通類中,也可以定義在泛型類中。
當調用一個泛型方法時,在方法名前的尖括號中放入具體的類型:
String middle = ArrayAlg.<String>getMiddle("]ohn", "Q.", "Public");
在這種情況(實際也是大多數情況)下,方法調用中可以省略
String middle = ArrayAlg.getMiddle("]ohn", "Q.", "Public");
類型參數的限定
有時,類或方法需要對類型參數加以約束。下面是一個典型的例子。我們要計算數組中的最小元素:
class ArrayAIg {
// almost correct
public static <T> T min(T[] a) {
if (a == null || a.length == 0) {
return null;
}
T smallest = a[0];
for (int i = 1; i < a.length; i++) {
if (smallest.compareTo(a[i]) > 0) smallest = a[i];
}
return smallest;
}
}
但是,這里有一個問題。請看一下 min() 方法的代碼內部。變量 smallest 類型為 T,這意味著它可以是任何一個類的對象。怎么才能確信 T 所屬的類有 compareTo() 方法呢?
解決這個問題的方案是將 T 限制為實現了 Comparable 接口(只含一個 compareTo() 方法的標準接口)的類??梢酝ㄟ^對類型參數 T 設置限定(bound)實現這一點:
public static <T extends Comparable> T min(T[] a) {}
現在,泛型的 min() 方法只能被實現了 Comparable 接口的類(如 String、LocalDate 等)的數組調用。
T extends 綁定類型
表示 T 應該是綁定類型的子類型(subtype)。T 和綁定類型可以是類,也可以是接口。
一個類型參數或通配符可以有多個限定,多個限定之間用 “ &” 分隔,例如:T extends Comparable & Serializable。在Java的限定中,可以根據需要擁有多個接口限定,但至多有一個類限定。如果用一個類作為限定,它必須放在限定列表中的第一個位置。
// ok
T extends Object & Comparable & Serializable
// error
T extends Comparable & Serializable & Object
泛型代碼和虛擬機
類型擦除
類型擦除是Java泛型實現的一種方式。
類型擦除指的是:在編譯時,將泛型類型擦除成其原始類型。
虛擬機沒有泛型類型對象,所有對象都屬于普通類。無論何時定義一個泛型類型,都自動提供了一個相應的原始類型(raw type)。類型參數用第一個限定的類型來替換,如果沒有給定限定就用 Object 替換。例如:
- 類 Pair
中的類型參數沒有顯式的限定,因此,原始類型用 Object 替換 T。 - 類 Interval<T extends Comparable & Serializable> 中第一個限定的類型為 Comparable,因此,原始類型用 Comparable 替換 T。
Pair
public class Pair {
private Object first;
private Object second;
public Pair(Object first, Object second) {
this.first = first;
this.second = second;
}
public Object getFirst() { return first; }
public Object getSecond() { return second; }
public void setFirst(Object newValue) { first = newValue; }
public void setSecond(Object newValue) { second = newValue; }
}
翻譯泛型表達式
當程序調用泛型方法時,如果擦除返回類型,編譯器插入強制類型轉換。例如,下面這個語句序列:
Pair<Employee> buddies = ...;
Employee buddy = buddies.getFirst();
擦除 getFirst() 方法的返回類型后,getFirst() 方法將返回 Object 類型。編譯器自動插入 Employee 的強制類型轉換。也就是說,編譯器把這個方法調用翻譯為兩條虛擬機指令:
- 對 Pair#getFirst() 原始方法的調用。
- 將返回的 Object 類型強制轉換為 Employee 類型。
當存取一個泛型域時也要插入強制類型轉換。假設 Pair 類的 first 域和 second 域都是公有的,表達式:Employee buddy = buddies.first;
也會在結果字節碼中插入強制類型轉換。
翻譯泛型方法
類型擦除也會出現在泛型方法中。程序員通常認為下述的泛型方法 public static <T extends Comparable> T min(T[] a)
是一個完整的方法族,而擦除類型之后,只剩下一個方法:public static Comparable min(Comparable[] a)
注意,類型參數 T 已經被擦除了,只留下了限定類型 Comparable。
類型擦除帶來了兩個復雜問題。看一看下面這個示例:
class DateInterval extends Pair<LocalDate> {
public void setSecond(LocalDate second) {
if (second.compareTo(getFirst()) >= 0) {
super.setSecond(second);
}
}
...
}
一個日期區間是一對 LocalDate 對象,并且需要覆蓋這個方法來確保第二個值永遠不小于第一個值。這個類擦除后變成
// after erasure
class DateInterval extends Pair {
public void setSecond(LocalDate second) { ... }
...
}
令人感到奇怪的是,存在另一個從 Pair 繼承的 setSecond() 方法,即 public void setSecond(Object second)
這顯然是一個不同的方法,因為它有一個不同類型的參數 Object,而不是 LocalDate。然而,不應該不一樣。考慮下面的語句序列:
DateInterval interval = new DateInterval(...);
Pair<LocalDate> pair = interval; // OK assignment to superclass
pair.setSecond(aDate);
這里,希望對 setSecond() 的調用具有多態性,并調用最合適的那個方法。由于 pair 引用 Datelnterval 對象,所以應該調用 Datelnterval.setSecond()。問題在于類型擦除與多態發生了沖突。要解決這個問題,就需要編譯器在 Datelnterval 類中生成一個橋方法(bridge method):
public void setSecond(Object second) { setSecond((Date) second); }
有關泛型的事實
需要記住有關 Java 泛型轉換的事實:
- 虛擬機中沒有泛型,只有普通的類和方法。
- 所有的類型參數都用它們的限定類型替換。
- 橋方法被合成來保持多態。
- 為保持類型安全性,必要時插入強制類型轉換。
類 A 是類 B 的子類,但是 G 和 G 不具有繼承關系,二者是并列關系。
public static void printBuddies(Pair<Employee> p) { ... }
// Manager 類是 Employee 類的子類
Pair<Manager> pair = new Pair<>();
// error(固定的泛型類型系統的局限,通配符類型解決了這個問題)
printBuddies(pair);
泛型一般有三種使用方式:泛型類、泛型方法、泛型接口。
// 泛型類
public class Pair<T>
// 實例化泛型類
Pair<String> pair = new Pair<>();
// 繼承泛型類,指定類型
class DateInterval extends Pair<LocalDate>
// 泛型方法
public static <T> T getMiddle(T... a)
// 泛型接口
public interface Generator<T>
// 實現泛型接口,指定類型
class GeneratorImpl implements Generator<String>
通配符類型
固定的泛型類型系統使用起來并沒有那么令人愉快,類型系統的研究人員知道這一點已經有一段時間了。Java 的設計者發明了一種巧妙的(仍然是安全的)“解決方案”:通配符類型。下面幾小節會介紹如何處理通配符。
通配符概念
通配符類型中,允許類型參數變化。例如,通配符類型 Pair<? extends Employee> 表示任何泛型 Pair 類型,它的類型參數是 Employee 的子類,如 Pair
假設要編寫一個打印雇員對的方法,像這樣:
public static void printBuddies(Pair<Employee> p) {
Employee first = p.getFirst();
Employee second = p.getSecond();
System.out.println(first.getName() + " and " + second.getName() + " are buddies.");
}
正如前面講到的,不能將 Pair
public static void printBuddies(Pair<? extends Employee> p)
類型 Pair
使用通配符會通過 Pair<? extends Employee> 的引用破壞 Pair
Pair<Manager> managerBuddies = new Pair<>(ceo, cfo);
Pair<? extends Employee> wildcardBuddies = managerBuddies; // OK
wildcardBuddies.setFirst(lowlyEmployee); // compile-time error
這可能不會引起破壞。對 setFirst() 的調用有一個類型錯誤。要了解其中的緣由,請仔細看一看類型 Pair<? extends Employee>。其方法似乎是這樣的:
? extends Employee getFirst()
void setFirst(? extends Employee)
這樣將不可能調用 setFirst() 方法。編譯器只知道需要某個 Employee 的子類型,但不知道具體是什么類型。它拒絕傳遞任何特定的類型。畢竟 ? 不能用來匹配。
使用 getFirst() 就不存在這個問題:將 getFirst() 的返回值賦給一個 Employee 的引用完全合法。這就是引入有限定的通配符的關鍵之處?,F在已經有辦法區分安全的訪問器方法和不安全的更改器方法了。
通配符的超類型限定
通配符限定與類型參數限定十分類似,但是,通配符限定還有一個附加的能力,即可以指定一個超類型限定(supertype bound),如下所示:? super Manager。
這個通配符限制為 Manager 的所有超類型。(已有的 super 關鍵字十分準確地描述了這種聯系)
參考資料
《Java核心技術卷一:基礎知識》(第10版)第 8 章:泛型程序設計