1. 程式人生 > 實用技巧 >Java萬用字元詳解

Java萬用字元詳解

轉載自:https://www.cnblogs.com/chanshuyi/p/deep_insight_java_generic.html

遠在 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還是 ArrayList,它們的 Class 型別都是一直的,都是 ArrayList.class。

那它們宣告時指定的 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 萬用字元出現了一些缺陷,使得其有特定的使用場景。