泛型概述-萬用字元

阿禎發表於2018-02-28

泛型概述-基本概念當中,我們介紹了有關型別引數限定的概念,使用 extends 關鍵字,給型別引數加以限定,例如:<T extends Fruit>,它表示 Fruit 或者 Fruit 的子型別。

public class Plate<T extends Fruit> {
    //......省略部分程式碼
    public void addFruits(Plate<T> plate) {
        for (T fruit : plate.getFruitList()) {
            fruitList.add(fruit);
        }
    }
}
複製程式碼

Plate 類中,我們新增了一個方法 addFruits(Plate<T> plate),此方法的作用是將引數 plate,新增到當前的 Plate 物件當中。

List<Apple> appleList = new ArrayList<>();
Apple apple1 = new Apple("apple1");
Apple apple2 = new Apple("apple2");
appleList.add(apple1);
appleList.add(apple2);

Plate<Apple> applePlate = new Plate<>(appleList);
Plate<Fruit> fruitPlate = new Plate<>();

fruitPlate.addFruits(applePlate);
複製程式碼

我們構造了一個蘋果盤子 applePlate,並將它作為引數傳遞給 fruitPlateaddFruits() 方法,從實際生活的角度中來看,這樣的需求是沒有任何問題的,但是這麼寫的話,編譯器會報錯。因為 Plate<Fruit> 中的 addFruits(Plate<Fruit> plate) 的方法引數接收的是一個 Plate<Fruit> 型別的物件,而我們傳遞的確是一個 Plate<Apple> 型別的物件。

這個時候我們就需要對型別引數加以限定,來對 addFruits 改造一下:

public <E extends T> void addFruits(Plate<E> plate) {
    for (T fruit : plate.getFruitList()) {
        fruitList.add(fruit);
    }
}
複製程式碼

我們對 addFruits 方法能夠接收的引數型別進行限定,它能夠接受的型別必須是 T 或者是 T 的子型別,這個時候我們上面的程式碼就可以正常執行了。

萬用字元

我們可以用一種更簡單的,帶子型別限定的萬用字元型別來替換上面的泛型方法。也就是將 public <E extends T> void addFruits(Plate<E> plate) 替換成 public void addFruits(Plate<? extends T> plate)

子型別限定

<? extends T>,稱為子型別限定的萬用字元型別,它表示 T 以及 T 的任意子型別。採用萬用字元形式的寫法,無疑看上去更簡單明瞭。

這裡需要注意的是,萬用字元是用來例項化定義好的型別引數的。如果一個類或者一個方法並不是泛型類或者泛型方法,那我們是沒有辦法使用萬用字元的。

public class GenericType<?> {
    ? type;
    
    public ? getType() {
        return type;
    }
    public void setType(? type) {
        this.type=type;
    }
}
複製程式碼

GenericType 這種寫法是不支援的,可以看到,沒有辦法使用萬用字元型別來定義類,宣告屬性,也沒有辦法作為方法的返回型別。

子型別限定的萬用字元也有它的侷限性

List<Apple> appleList = new ArrayList<>();
Apple apple1 = new Apple("apple1");
Apple apple2 = new Apple("apple2");
appleList.add(apple1);
appleList.add(apple2);
        
List<? extends Fruit> fruitList = appleList;
    for (Fruit fruit : fruitList) {
        System.out.println(fruit.getName());
}
複製程式碼

使用了子型別限定萬用字元 <? extends Fruit> ,它表示 Fruit 以及 Fruit 的任意子型別。所以我們可以將 List<Apple> 型別的物件 appleList 賦值給它。還記得在泛型概述-基本概念當中有講過,泛型是不支援協變的,但是使用了這種子型別限定萬用字元型別之後,它就符合了協變的規則。

List<Apple> appleList = new ArrayList<>();
Apple apple1 = new Apple("apple1");
Apple apple2 = new Apple("apple2");
appleList.add(apple1);
appleList.add(apple2);
        
List<? extends Fruit> fruitList = appleList;
fruitList.add(new Fruit("fruit"));//compile error
fruitList.add(new Apple("apple"));//compile error
fruitList.add(new Banana("banana"));//compile error
複製程式碼

我們可以看一下 ArrayList 類中的 add()get() 兩個方法

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

public E get(int index) {
    rangeCheck(index);
    return elementData(index);
}
複製程式碼

我們宣告瞭一個 List<? extends Fruit> 型別的物件 fruitList,那麼 add 方法get 方法中的型別引數就變成了 ?extends Fruit 型別

public boolean add(? extends Fruit e) {
    // 可以認為變成了這種形式,實際上沒有辦法這麼編寫
}

public ? extends Fruit get(int index) {
    // 可以認為變成了這種形式,實際上沒有辦法這麼編寫
}
複製程式碼

當我們列印水果名稱的時候,也就是從 List<? extends Fruit> 型別的 fruitList 物件當中讀取資料的時候是沒有問題的,get() 方法返回的是 ? extends Fruit 型別,不論它是什麼型別,一定可以向上轉型成 Fruit 型別;但是當我們需要呼叫 add() 方法的時候,編譯器就會提示錯誤,這是因為 add() 方法的引數是 <? extends Fruit> 型別,它代表的是 Fruit 以及 Fruit 的任意子型別,並不能夠知道具體是什麼型別,所以就不能夠呼叫 add() 方法。add(null) 除外?

假設我們允許呼叫 add() 方法的話,就會出現問題

List<? extends Fruit> fruitList = appleList;
fruitList.add(new Fruit("fruit"));//假設沒有問題
fruitList.add(new Apple("apple"));//假設沒有問題
fruitList.add(new Banana("banana"));//假設沒有問題
複製程式碼

這就相當於是向 List<Apple> 型別的 appleList 物件中,新增了 FruitAppleBanana 三種型別的物件,先不說它違背了泛型的型別安全的原則,這等於是埋下了一顆定時炸彈,在我們從 appleList 物件當中取資料的時候,就有可能發生型別轉換異常。

fruitList.add(new Apple("apple"));
複製程式碼

細心的朋友可能發現了,即使是向 fruitList 當中新增 Apple 型別的物件也是不可以的,上面說了 add 方法 的引數型別是 ? extends Fruit 對於編譯器來說它會將 ? extends Fruit 識別為 CAP#1 extends Fruit 型別,它沒有辦法匹配到 CAP#1的具體型別是什麼,所以沒有辦法進行賦值。

無限定萬用字元

<?> 稱為無限定萬用字元,當一些操作與具體的型別無關的時候,或者說我們不需要知道型別資訊的時候,就可以使用無限定的萬用字元型別,來例項化我們定義的型別引數,例如交換陣列中的元素,比較元素的大小,獲取元素的個數等等。

例如我們 Collections 工具類中提供的 swap 方法,用於交換列表中指定位置的元素,這個時候不需要知道具體的型別,可以使用萬用字元型別。

public static void swap(List<?> list, int i, int j) {
    // instead of using a raw type here, it's possible to capture
    // the wildcard but it will require a call to a supplementary
    // private method
    final List l = list;
    l.set(i, l.set(j, l.get(i)));
}
複製程式碼

除了使用原生型別之外,我們還可以通過一個輔助的私有方法來匹配萬用字元的型別

public static void swap(List<?> list, int i, int j) {
        swapHelp(list, i, j);
}

private static <T> void swapHelp(List<T> list, int i, int j) {
    T temp = list.get(i);
    list.set(i, list.get(j));
    list.set(j, temp);
}
複製程式碼

對於編譯器來說它會將 ? 型別識別為 CAP#1 型別,在內部會呼叫 swapHelp 方法,編譯器會將推斷出 T 的型別就是 CAP#1,之後就像是普通的容器類一樣進行讀寫操作。

上面出現的關於萬用字元的例子,都可以使用泛型方法替代:

public static void swap(List<?> list, int i, int j)
複製程式碼

它的泛型方法形式是

public static <T> void swap(List<T> list, int i, int j)
複製程式碼

看到這裡你可能有些疑惑,因為 swap 方法的泛型方法形式和 swapHelp 方法的形式是一模一樣的,你可能會覺得這麼寫有一些多此一舉,採用萬用字元的形式簡潔明瞭,減少了引數型別的個數,所以應該儘可能的採用萬用字元型別。

並不是所有的泛型方法都可以改寫成萬用字元的形式,在這裡我直接引用老馬說程式設計中的兩個例子來說明:

引數型別間具有依賴關係

public static <D,S extends D> void copy(List<D> dest,
        List<S> src){
    for(int i=0; i<src.size(); i++){
        dest.add(src.get(i));
    }
}
複製程式碼

我們只能將其簡化成具有一個引數型別的形式

public static <D> void copy(List<D> dest,
        List<? extends D> src){
    for(int i=0; i<src.size(); i++){
        dest.add(src.get(i));
    }
}
複製程式碼

返回值依賴引數型別

public static <T extends Comparable<T>> T max(List<T> arr){
    T max = arr.get(0);
    for(int i=1; i<arr.size(); i++){
        if(arr.get(i).compareTo(max)>0){
            max = arr.get(i);
        }
    }
    return max;
}
複製程式碼

max 方法的返回值是依賴於引數型別 T 的,也沒有辦法將其改寫為萬用字元形式。

超型別限定萬用字元

上面我們說到子類限定的萬用字元無限定的萬用字元,兩種型別的萬用字元,這兩種萬用字元型別都有一個缺點,就是沒有辦法進行寫操作。

對於 List<?> 來說,我們獲取的元素只能賦值給 Object 型別的引用,並且沒有辦法進行 add 操作,除了 null 值。

對於 List<? extends T> 來說,我們獲取的元素只能賦值給它的上限 T 型別,同樣沒有辦法進行 add 操作,除了 null 值。

現在我們有這樣一個需求,需要將蘋果盤子中的水果,全部放到另一個水果盤子中區,也就是將 Plate<Apple> 中的所有水果,複製一份到 Plate<Fruit> 當中,這個需求沒有什麼問題,我們在 Plate<T extends Fruit> 類中加入一個方法:

public void copyTo(Plate<T> dest) {
    List<T> destList = dest.getFruitList();
    for (T t : fruitList) {
        destList.add(t);
    }
}

public static void main(String[] args) {
    List<Apple> appleList = new ArrayList<>();
    Apple apple1 = new Apple("apple1");
    Apple apple2 = new Apple("apple2");

    appleList.add(apple1);
    appleList.add(apple2);

    Plate<Apple> applePlate = new Plate<>(appleList);
    Plate<Fruit> fruitPlate = new Plate<>();

    applePlate.copyTo(fruitPlate);

    for (Fruit fruit : fruitPlate.getFruitList()) {
        System.out.println(fruit.getName());
    }
}
複製程式碼

編譯器會在這一行 applePlate.copyTo(fruitPlate); 提示錯誤,copyTo 方法需要 Plate<Apple> 型別,但是我們傳遞的是 Plate<Fruit> 型別,這個時候就需要用到超型別限定的萬用字元:<? super T>

 public void copyTo(Plate<? super T> dest) {
    List<? super T> destList = dest.getFruitList();
    for (T t : fruitList) {
    destList.add(t);
}
複製程式碼

copyTo 方法改成這個樣子之後,就可以正常編譯執行了,對於 Plate<Apple> 的型別來說,它的 copyTo 方法接收的引數型別是 Plate<? super Apple> 型別,意思是 Apple 或者 Apple 的任意父型別,這個時候我們就可以將 Plate<Fruit> 型別的物件傳遞給該方法了。

另外超型別限定萬用字元允許寫入,詳細看一下 copyTo 方法中的程式碼,可以發現我們呼叫 destList.add(t) 方法將 T 型別的物件寫入到了 List<? super T> 的列表中,對於 <? super T> 而言,僅僅能夠寫入 T 或者 T 型別的子型別。

除了可以靈活的寫入之外,超型別限定的萬用字元型別還可以應用於實現 Comparable 介面

public class Fruit implements Comparable<Fruit> {
    private int weight;
    //... 省略部分程式碼
    @Override
    public int compareTo(Fruit fruit) {
        if (weight == fruit.getWeight()) {
            return 0;
        } else if (weight > fruit.getWeight()) {
            return 1;
        } else {
            return -1;
        }
    }
}
複製程式碼

Fruit 實現 Comparable<Fruit> 介面,根據水果的重量 weight 的大小來作為比較規則

現在我們需要取出 Plate<T extends Fruit> 物件中重量最大的水果,程式碼如下:

public class PlateUtils {
    public static <T extends Comparable<T>> T max(List<T> fruitList) {
        Iterator<T> i = fruitList.iterator();
        T candidate = i.next();

        while (i.hasNext()) {
            T next = i.next();
            if (next.compareTo(candidate) > 0)
                candidate = next;
        }
        return candidate;
    }
}

List<Fruit> fruitList = new ArrayList<>();
Fruit fruit1 = new Fruit("fruit1");
fruit1.setWeight(2);
Fruit fruit2 = new Fruit("fruit2");
fruit2.setWeight(10);
fruitList.add(fruit1);
fruitList.add(fruit2);

Plate<Fruit> fruitPlate = new Plate<>(fruitList);
Fruit maxFruit = PlateUtils.max(fruitPlate.getFruitList());
System.out.println(maxFruit.getName());

//print:fruit2
複製程式碼

似乎沒有問題,但是如果我們將 List<Apple> 傳遞進去的話,編譯器就會報錯

public class Apple extends Fruit {
    public Apple(String name) {
        super(name);
    }
}
複製程式碼

這是由於我們的 Apple 雖然繼承了 Fruit 但是並沒有實現 Comparable<Apple> 介面,對於 max 方法來說,它會根據引數推斷出 T 的實際型別為 Apple 型別,Apple 實現的是 Comparable<Fruit> 介面,然而需要的是 Comparable<Apple> 型別,型別不匹配,所以編譯器報錯。

對於 Apple 來說,它並不需要重新實現 Comparable<Apple> 介面,Fruit 類中實現的 compareTo 規則已經適用於它了,這個時候超型別限定萬用字元就派上用場了:

public static <T extends Comparable<? super T>> T max(List<T> fruitList) {
    Iterator<T> i = fruitList.iterator();
    T candidate = i.next();

    while (i.hasNext()) {
        T next = i.next();
        if (next.compareTo(candidate) > 0)
        candidate = next;
    }
    return candidate;
}
複製程式碼

我們修改了 T 型別的限定,限制它實現的 Comparable 介面型別必須是 T 或者 T 的超型別,對於 Apple 來說,它實現的是 Comparable<Fruit> 型別,符合要求。

相關文章