文章首發於【博客園-陳樹義】,點擊跳轉到原文《大白話說Java泛型:入門、使用、原理》
遠在 JDK 1.4 版本的時候,那時候是沒有泛型的概念的。當時 Java 程序員們寫集合類的代碼都是類似於下面這樣:
List list = new ArrayList();
list.add("www.cnblogs.com");
list.add(23);
String name = (String)list.get(0);
Integer number = (Integer)list.get(1);
在代碼中聲明一個集合,我們可以往集合中放入各種各樣的數據,而在取出來的時候就進行強制類型轉換。但其實這樣的代碼存在一定隱患,因為可能過了不久我們就會忘記到底我們存放的 list 里面到底第幾個是 String,第幾個是 Integer 了。這樣就會出現下面這樣的情況:
List list = new ArrayList();
list.add("www.cnblogs.com");
list.add(23);
String name = (String)list.get(0);
String number = (String)list.get(1); //ClassCastException
上面的代碼在運行時會發生強制類型轉換異常。這是因為我們在存入的時候,第二個是一個 Integer 類型,但是取出來的時候卻將其強制轉換為 String 類型了。Sun 公司為了使 Java 語言更加安全,減少運行時異常的發生。於是在 JDK 1.5 之后推出了泛型的概念。
於是在 JDK 1.5 之后,我們如果使用集合來書寫代碼,可以使用下面這種形式:
List<String> list = new ArrayList();
list.add("www.cnblogs.com");
list.add("www.cnblogs.com/chanshuyi");
String cnBlogs = list.get(0);
String myWebSite = list.get(1);
泛型就是將類型參數化,其在編譯時才確定具體的參數。在上面這個例子中,這個具體的類型就是 String。可以看到我們在創建 List 集合的時候指定了 String 類型,這就意味着我們只能往 List 集合中存放 String 類型的數據。而當我們指定泛型之后,我們去取出數據后就不再需要進行強制類型轉換了,這樣就減少了發生強制類型轉換的風險。
泛型的原理
上面我們通過兩個很簡單的例子知道了為什么要有泛型,以及泛型最簡單的使用。下面我們通過一個面試中常見的例子來看一下泛型的本質是什么。
ArrayList<String> a = new ArrayList<String>();
ArrayList<Integer> b = new ArrayList<Integer>();
Class c1 = a.getClass();
Class c2 = b.getClass();
System.out.println(c1 == c2);
在繼續往下看之前,先想一想,這道題輸出的結果是什么?
是 true 還是 false ?
這道題輸出的結果是 true。因為無論對於 ArrayList
那它們聲明時指定的 String 和 Integer 到底體現在哪里呢?
答案是體現在類編譯的時候。當 JVM 進行類編譯時,會進行泛型檢查,如果一個集合被聲明為 String 類型,那么它往該集合存取數據的時候就會對數據進行判斷,從而避免存入或取出錯誤的數據。
也就是說:泛型只存在於編譯階段,而不存在於運行階段。在編譯后的 class 文件中,是沒有泛型這個概念的。
上面我們只是說了泛型在集合中的使用方式,但其實泛型的應用范圍不僅僅只是集合,還包括類、方法、Map 接口等等。
泛型的使用情景
泛型的應用還廣泛存在於下面幾種情形:泛型類、泛型方法、泛型集合。
泛型類
泛型類一般使用字母 T 作為泛型的標志。
public class GenericClass<T> {
private T object;
public T getObject() {
return object;
}
public void setObject(T object) {
this.object = object;
}
}
使用:
public static void main(String[] args) {
GenericClass<Integer> integerGenericClass = new GenericClass<>(100);
integerGenericClass.showType();
GenericClass<String> stringGenericClass = new GenericClass<>("www.cnblogs.com/chanshuyi");
stringGenericClass.showType();
}
除了使用 T 作為泛型類的標志之外,在需要使用 Map 的類中,通常使用 K V 兩個字母表示 Key Value 對應的類型。
public class GenericMap<K, V> {
private K key;
private V value;
public void put(K key, V value) {
this.key = key;
this.value = value;
}
}
使用:
public static void main(String[] args) {
GenericMap<Integer, String> team = new GenericMap<>();
team.put(1, "YaoMin");
team.put(2, "Me");
GenericMap<String, Integer> score = new GenericMap<>();
score.put("YaoMin", 88);
score.put("Me", 80);
}
泛型方法
泛型方法一般使用字母 T 作為泛型的標志。
public class GenericMethod {
public static <T> T getObject(Class<T> clz) throws InstantiationException, IllegalAccessException{
T t = clz.newInstance();
return t;
}
}
使用:
public static void main(String[] args) throws Exception{
GenericMethod genericMethod = getObject(GenericMethod.class);
System.out.println("Class:" + genericMethod.getClass().getName());
}
泛型通配符
除了泛型類、泛型方法之外,泛型還有更加復雜的應用,如:
List<? extends Number> list = new ArrayList();
List<? super Number> list = new ArrayList();
上面的 extends 和 super 關鍵字其實就是泛型的高級應用:泛型通配符。
但在講泛型通配符之前,我們必須對編譯時類型和運行時類型有一個基本的了解,才能更好地理解通配符的使用。
編譯時類型和運行時類型
我們先來看看一個簡單的例子。
Class Fruit{}
Class Apple extends Fruit{}
上面聲明一個 Fruit 類,Apple 類是 Fruit 類的子類。
接着下面我們聲明一個蘋果對象:
Apple apple = new Apple();
這樣的聲明,我相信大家都沒有什么異議,聲明一個 Apple 類型的變量指向一個 Apple 對象。在上面這段代碼中,apple 屬性指向的對象,其編譯時類型和運行時類型都是 Apple 類型。
但其實很多時候我們也使用下面這種寫法:
Fruit apple = new Apple();
我們使用 Fruit 類型的變量指向了一個 Apple 對象,這在 Java 的語法體系中也是沒有問題的。因為 Java 允許把一個子類對象(Apple對象)直接賦值給一個父類引用變量(Fruit類變量),一般我們稱之為「向上轉型」。
那問題來了,此時 apple 屬性所指向的對象,其編譯時類型和運行時類型是什么呢?
很多人會說:apple 屬性指向的對象,其編譯時類型和運行時類型不都是 Apple 類型嗎?
正確答案是:apple 屬性所指向的對象,其在編譯時的類型就是 Fruit 類型,而在運行時的類型就是 Apple 類型。
這是為什么呢?
因為在編譯的時候,JVM 只知道 Fruit 類變量指向了一個對象,並且這個對象是 Fruit 的子類對象或自身對象,其具體的類型並不確定,有可能是 Apple 類型,也有可能是 Orange 類型。而為了安全方面的考慮,JVM 此時將 apple 屬性指向的對象定義為 Fruit 類型。因為無論其是 Apple 類型還是 Orange 類型,它們都可以安全轉為 Fruit 類型。
而在運行時階段,JVM 通過初始化知道了它指向了一個 Apple 對象,所以其在運行時的類型就是 Apple 類型。
泛型中的向上轉型
當我們明白了編譯時類型和運行時類型之后,我們再來理解通配符的誕生就相對容易一些了。
還是上面的場景,我們有一個 Fruit 類,Apple 類是 Fruit 的子類。這時候,我們增加一個簡單的容器:Plate 類。Plate 類定義了盤子一些最基本的動作:
public class Plate<T> {
private List<T> list;
public Plate(){}
public void add(T item){list.add(item);}
public T get(){return list.get(0);}
}
按我們之前對泛型的學習,我們可以知道上面的代碼定義了一個 Plate 類。Plate 類定義了一個 T 泛型類型,可以接收任何類型。說人話就是:我們定義了一個盤子類,這個盤子可以裝任何類型的東西,比如裝水果、裝蔬菜。
如果我們想要一個裝水果的盤子,那定義的代碼就是這樣的:
Plate<Fruit> plate = new Plate<Fruit>();
我們直接定義了一個 Plate 對象,並且指定其泛型類型為 Fruit 類。這樣我們就可以往里面加水果了:
plate.add(new Fruit());
plate.add(new Apple());
按照 Java 向上轉型的原則,Java 泛型可以向上轉型,即我們上面關於水果盤子的定義可以變為這樣:
Plate<Fruit> plate = new Plate<Apple>(); //Error
但事實上,上面的代碼在編譯的時候會出現編譯錯誤。
按理說,這種寫法應該是沒有問題的,因為 Java 支持向上轉型嘛。
錯誤的原因就是:Java並不支持支持泛型的向上轉型,所以不能夠使用上面的寫法,這樣的寫法在Java中是不被支持的。
那有沒有解決的辦法呢?
肯定是有的,這個解決方案就是:泛型通配符。
上面這行代碼如果要正常編譯,只需要修改一下 Plate 類的聲明即可:
Plate<? extends Fruit> plate = new Plate<Apple>();
上面的這行代碼表示:plate 可以指向任何 Fruit 類對象,或者任何 Fruit 的子類對象。
Apple 是 Fruit 的子類,自然就可以正常編譯了。
extends 通配符的缺陷
雖然通過這種方式,Java 支持了 Java 泛型的向上轉型,但是這種方式是有缺陷的,那就是:其無法向 Plate 中添加任何對象,只能從中讀取對象。
Plate<? extends Fruit> plate = new Plate<Apple>();
plate.add(new Apple()); //Compile Error
plate.get(); // Compile Success
可以看到,當我們嘗試往盤子中加入一個蘋果時,會發現編譯錯誤。但是我們可以從中取出東西。那為什么我們會無法往盤子中加東西呢?
這還得從我們對盤子的定義說起。
Plate<? extends Fruit> plate = new Plate<XXX>();
上面我們對盤子的定義中,plate 可以指向任何 Fruit 類對象,或者任何 Fruit 的子類對象。也就是說,plate 屬性指向的對象其在運行時可以是 Apple 類型,也可以是 Orange 類型,也可以是 Banana 類型,只要它是 Fruit 類,或任何 Fruit 的子類即可。即我們下面幾種定義都是正確的:
Plate<? extends Fruit> plate = new Plate<Apple>();
Plate<? extends Fruit> plate = new Plate<Orange>();
Plate<? extends Fruit> plate = new Plate<Banana>();
這樣子的話,在我們還未具體運行時,JVM 並不知道我們要往盤子里放的是什么水果,到底是蘋果,還是橙子,還是香蕉,完全不知道。既然我們不能確定要往里面放的類型,那 JVM 就干脆什么都不給放,避免出錯。
正是出於這種原因,所以當使用 extends 通配符時,我們無法向其中添加任何東西。
那為什么又可以取出數據呢?因為無論是取出蘋果,還是橙子,還是香蕉,我們都可以通過向上轉型用 Fruit 類型的變量指向它,這在 Java 中都是允許的。
Fruit apple = plate.get();
Apple apple = plate.get(); //Error
可以從上面的代碼看到,當你嘗試用一個 Apple 類型的變量指向一個從盤子里取出的水果時,是會提示錯誤的。
所以當使用 extends 通配符時,我們可以取出所有東西。
總結一下,我們通過 extends 關鍵字可以實現向上轉型。但是我們卻失去了部分的靈活性,即我們不能往其中添加任何東西,只能取出東西。
super 通配符的缺陷
與 extends 通配符相似的另一個通配符是 super 通配符,其特性與 extends 完全相反。super通配符可以存入對象,但是取出對象的時候受到限制。
Plate<? super Apple> plate = new Plate<Fruit>();
上面這行代碼表示 plate 屬性可以指向一個特定類型的 Plate 對象,只要這個特定類型是 Apple 或 Apple 的父類。上面的 Fruit 類就是 Apple 類的父級,所以上面的語法是對的。
也就是說,如果 EatThing 類是 Fruit 的父級,那么下面的聲明也是正確的:
Plate<? super Apple> plate = new Plate<EatThing>();
當然了,下面的聲明肯定也是對的,因為 Object 是任何一個類的父級。
Plate<? super Apple> plate = new Plate<Object>();
既然這樣,也就是說 plate 指向的具體類型可以是任何 Apple 的父級,JVM 在編譯的時候肯定無法判斷具體是哪個類型。但 JVM 能確定的是,任何 Apple 的子類都可以轉為 Apple 類型,但任何 Apple 的父類都無法轉為 Apple 類型。
所以對於使用了 super 通配符的情況,我們只能存入 T 類型及 T 類型的子類對象。
Plate<? super Apple> plate = new Plate<Fruit>();
plate.add(new Apple());
plate.add(new Fruit()); //Error
當我們向 plate 存入 Apple 對象時,編譯正常。但是存入 Fruit 對象,就會報編譯錯誤。
而當我們取出數據的時候,也是類似的道理。JVM 在編譯的時候知道,我們具體的運行時類型可以是任何 Apple 的父級,那么為了安全起見,我們就用一個最頂層的父級來指向取出的數據,這樣就可以避免發生強制類型轉換異常了。
Object object = plate.get();
Apple apple = plate.get(); //Error
Fruit fruit = plate.get(); //Error
從上面的代碼可以知道,當使用 Apple 類型或 Fruit 類型的變量指向 plate 取出的對象,會出現編譯錯誤。而使用 Object 類型的額變量指向 plate 取出的對象,則可以正常通過。
也就是說對於使用了 super 通配符的情況,我們取出的時候只能用 Object 類型的屬性指向取出的對象。
PECS 原則
說到這里,我相信大家已經明白了 extends 和 super 通配符的使用和限制了。我們知道:
- 對於 extends 通配符,我們無法向其中加入任何對象,但是我們可以進行正常的取出。
- 對於 super 通配符,我們可以存入 T 類型對象或 T 類型的子類對象,但是我們取出的時候只能用 Object 類變量指向取出的對象。
從上面的總結可以看出,extends 通配符偏向於內容的獲取,而 super 通配符更偏向於內容的存入。我們有一個 PECS 原則(Producer Extends Consumer Super)很好的解釋了這兩個通配符的使用場景。
Producer Extends 說的是當你的情景是生產者類型,需要獲取資源以供生產時,我們建議使用 extends 通配符,因為使用了 extends 通配符的類型更適合獲取資源。
Consumer Super 說的是當你的場景是消費者類型,需要存入資源以供消費時,我們建議使用 super 通配符,因為使用 super 通配符的類型更適合存入資源。
但如果你既想存入,又想取出,那么你最好還是不要使用 extends 或 super 通配符。
總結
Java 泛型通配符的出現是為了使 Java 泛型也支持向上轉型,從而保持 Java 語言向上轉型概念的統一。但與此同時,也導致 Java 通配符出現了一些缺陷,使得其有特定的使用場景。