1. 程式人生 > >Java 泛型總結(三):萬用字元的使用

Java 泛型總結(三):萬用字元的使用

簡介

前兩篇文章介紹了泛型的基本用法、型別擦除以及泛型陣列。在泛型的使用中,還有個重要的東西叫萬用字元,本文介紹萬用字元的使用。

這個系列的另外兩篇文章:

陣列的協變

在瞭解萬用字元之前,先來了解一下陣列。Java 中的陣列是協變的,什麼意思?看下面的例子:

class Fruit {}
class Apple extends Fruit {}
class Jonathan extends Apple {}
class Orange extends Fruit {}

public class CovariantArrays {
    public static void main(String[] args) {       
        Fruit[] fruit = new Apple[10];
        fruit[0] = new Apple(); // OK
        fruit[1] = new Jonathan(); // OK
        // Runtime type is Apple[], not Fruit[] or Orange[]:
        try {
            // Compiler allows you to add Fruit:
            fruit[0] = new Fruit(); // ArrayStoreException
        } catch(Exception e) { System.out.println(e); }
        try {
            // Compiler allows you to add Oranges:
            fruit[0] = new Orange(); // ArrayStoreException
        } catch(Exception e) { System.out.println(e); }
        }
} /* Output:
java.lang.ArrayStoreException: Fruit
java.lang.ArrayStoreException: Orange
*///:~

main 方法中的第一行,建立了一個 Apple 陣列並把它賦給 Fruit 陣列的引用。這是有意義的,Apple 是 Fruit 的子類,一個 Apple 物件也是一種 Fruit 物件,所以一個 Apple 陣列也是一種 Fruit 的陣列。這稱作陣列的協變,Java 把陣列設計為協變的,對此是有爭議的,有人認為這是一種缺陷。

儘管 Apple[] 可以 “向上轉型” 為 Fruit[],但陣列元素的實際型別還是 Apple

,我們只能向陣列中放入 Apple或者 Apple 的子類。在上面的程式碼中,向陣列中放入了 Fruit 物件和 Orange 物件。對於編譯器來說,這是可以通過編譯的,但是在執行時期,JVM 能夠知道陣列的實際型別是 Apple[],所以當其它物件加入陣列的時候就會丟擲異常。

泛型設計的目的之一是要使這種執行時期的錯誤在編譯期就能發現,看看用泛型容器類來代替陣列會發生什麼:

// Compile Error: incompatible types:
ArrayList<Fruit> flist = new ArrayList<Apple>();

上面的程式碼根本就無法編譯。當涉及到泛型時, 儘管 Apple 是 Fruit 的子型別,但是 ArrayList<Apple> 不是 ArrayList<Fruit> 的子型別,泛型不支援協變。

使用萬用字元

從上面我們知道,List<Number> list = ArrayList<Integer> 這樣的語句是無法通過編譯的,儘管 Integer 是 Number的子型別。那麼如果我們確實需要建立這種 “向上轉型” 的關係怎麼辦呢?這就需要萬用字元來發揮作用了。

上邊界限定萬用字元

利用 <? extends Fruit> 形式的萬用字元,可以實現泛型的向上轉型:

public class GenericsAndCovariance {
    public static void main(String[] args) {
        // Wildcards allow covariance:
        List<? extends Fruit> flist = new ArrayList<Apple>();
        // Compile Error: can’t add any type of object:
        // flist.add(new Apple());
        // flist.add(new Fruit());
        // flist.add(new Object());
        flist.add(null); // Legal but uninteresting
        // We know that it returns at least Fruit:
        Fruit f = flist.get(0);
    }
}

上面的例子中, flist 的型別是 List<? extends Fruit>,我們可以把它讀作:一個型別的 List, 這個型別可以是繼承了 Fruit 的某種型別。注意,這並不是說這個 List 可以持有 Fruit 的任意型別。萬用字元代表了一種特定的型別,它表示 “某種特定的型別,但是 flist 沒有指定”。這樣不太好理解,具體針對這個例子解釋就是,flist 引用可以指向某個型別的 List,只要這個型別繼承自 Fruit,可以是 Fruit 或者 Apple,比如例子中的 new ArrayList<Apple>,但是為了向上轉型給 flistflist 並不關心這個具體型別是什麼。

如上所述,萬用字元 List<? extends Fruit> 表示某種特定型別 ( Fruit 或者其子類 ) 的 List,但是並不關心這個實際的型別到底是什麼,反正是 Fruit 的子型別,Fruit 是它的上邊界。那麼對這樣的一個 List 我們能做什麼呢?其實如果我們不知道這個 List 到底持有什麼型別,怎麼可能安全的新增一個物件呢?在上面的程式碼中,向 flist 中新增任何物件,無論是 Apple 還是 Orange 甚至是 Fruit 物件,編譯器都不允許,唯一可以新增的是 null。所以如果做了泛型的向上轉型 (List<? extends Fruit> flist = new ArrayList<Apple>()),那麼我們也就失去了向這個 List 新增任何物件的能力,即使是 Object 也不行。

另一方面,如果呼叫某個返回 Fruit 的方法,這是安全的。因為我們知道,在這個 List 中,不管它實際的型別到底是什麼,但肯定能轉型為 Fruit,所以編譯器允許返回 Fruit

瞭解了萬用字元的作用和限制後,好像任何接受引數的方法我們都不能呼叫了。其實倒也不是,看下面的例子:

public class CompilerIntelligence {
    public static void main(String[] args) {
        List<? extends Fruit> flist =
        Arrays.asList(new Apple());
        Apple a = (Apple)flist.get(0); // No warning
        flist.contains(new Apple()); // Argument is ‘Object’
        flist.indexOf(new Apple()); // Argument is ‘Object’
        
        //flist.add(new Apple());   無法編譯

    }
}

在上面的例子中,flist 的型別是 List<? extends Fruit>,泛型引數使用了受限制的萬用字元,所以我們失去了向其中加入任何型別物件的例子,最後一行程式碼無法編譯。

但是 flist 卻可以呼叫 contains 和 indexOf 方法,它們都接受了一個 Apple 物件做引數。如果檢視 ArrayList 的原始碼,可以發現 add() 接受一個泛型型別作為引數,但是 contains 和 indexOf 接受一個 Object 型別的引數,下面是它們的方法簽名:

public boolean add(E e)
public boolean contains(Object o)
public int indexOf(Object o)

所以如果我們指定泛型引數為 <? extends Fruit> 時,add() 方法的引數變為 ? extends Fruit,編譯器無法判斷這個引數接受的到底是 Fruit 的哪種型別,所以它不會接受任何型別。

然而,contains 和 indexOf 的型別是 Object,並沒有涉及到萬用字元,所以編譯器允許呼叫這兩個方法。這意味著一切取決於泛型類的編寫者來決定那些呼叫是 “安全” 的,並且用 Object 作為這些安全方法的引數。如果某些方法不允許型別引數是萬用字元時的呼叫,這些方法的引數應該用型別引數,比如 add(E e)

當我們自己編寫泛型類時,上面介紹的就有用了。下面編寫一個 Holder 類:

public class Holder<T> {
    private T value;
    public Holder() {}
    public Holder(T val) { value = val; }
    public void set(T val) { value = val; }
    public T get() { return value; }
    public boolean equals(Object obj) {
    return value.equals(obj);
    }
    public static void main(String[] args) {
        Holder<Apple> Apple = new Holder<Apple>(new Apple());
        Apple d = Apple.get();
        Apple.set(d);
        // Holder<Fruit> Fruit = Apple; // Cannot upcast
        Holder<? extends Fruit> fruit = Apple; // OK
        Fruit p = fruit.get();
        d = (Apple)fruit.get(); // Returns ‘Object’
        try {
            Orange c = (Orange)fruit.get(); // No warning
        } catch(Exception e) { System.out.println(e); }
        // fruit.set(new Apple()); // Cannot call set()
        // fruit.set(new Fruit()); // Cannot call set()
        System.out.println(fruit.equals(d)); // OK
    }
} /* Output: (Sample)
java.lang.ClassCastException: Apple cannot be cast to Orange
true
*///:~

在 Holer 類中,set() 方法接受型別引數 T 的物件作為引數,get() 返回一個 T 型別,而 equals() 接受一個 Object作為引數。fruit 的型別是 Holder<? extends Fruit>,所以set()方法不會接受任何物件的新增,但是 equals() 可以正常工作。

下邊界限定萬用字元

萬用字元的另一個方向是 “超型別的萬用字元“: ? super TT 是型別引數的下界。使用這種形式的萬用字元,我們就可以 ”傳遞物件” 了。還是用例子解釋:

public class SuperTypeWildcards {
    static void writeTo(List<? super Apple> apples) {
        apples.add(new Apple());
        apples.add(new Jonathan());
        // apples.add(new Fruit()); // Error
    }
}

writeTo 方法的引數 apples 的型別是 List<? super Apple>,它表示某種型別的 List,這個型別是 Apple 的基型別。也就是說,我們不知道實際型別是什麼,但是這個型別肯定是 Apple 的父型別。因此,我們可以知道向這個 List 新增一個 Apple 或者其子型別的物件是安全的,這些物件都可以向上轉型為 Apple。但是我們不知道加入 Fruit 物件是否安全,因為那樣會使得這個 List 新增跟 Apple 無關的型別。

在瞭解了子型別邊界和超型別邊界之後,我們就可以知道如何向泛型型別中 “寫入” ( 傳遞物件給方法引數) 以及如何從泛型型別中 “讀取” ( 從方法中返回物件 )。下面是一個例子:

public class Collections { 
  public static <T> void copy(List<? super T> dest, List<? extends T> src) 
  {
      for (int i=0; i<src.size(); i++) 
        dest.set(i,src.get(i)); 
  } 
}

src 是原始資料的 List,因為要從這裡面讀取資料,所以用了上邊界限定萬用字元:<? extends T>,取出的元素轉型為 Tdest 是要寫入的目標 List,所以用了下邊界限定萬用字元:<? super T>,可以寫入的元素型別是 T 及其子型別。

無邊界萬用字元

還有一種萬用字元是無邊界萬用字元,它的使用形式是一個單獨的問號:List<?>,也就是沒有任何限定。不做任何限制,跟不用型別引數的 List 有什麼區別呢?

List<?> list 表示 list 是持有某種特定型別的 List,但是不知道具體是哪種型別。那麼我們可以向其中新增物件嗎?當然不可以,因為並不知道實際是哪種型別,所以不能新增任何型別,這是不安全的。而單獨的 List list ,也就是沒有傳入泛型引數,表示這個 list 持有的元素的型別是 Object,因此可以新增任何型別的物件,只不過編譯器會有警告資訊。

總結

萬用字元的使用可以對泛型引數做出某些限制,使程式碼更安全,對於上邊界和下邊界限定的萬用字元總結如下:

  • 使用 List<? extends C> list 這種形式,表示 list 可以引用一個 ArrayList ( 或者其它 List 的 子類 ) 的物件,這個物件包含的元素型別是 C 的子型別 ( 包含 C 本身)的一種。
  • 使用 List<? super C> list 這種形式,表示 list 可以引用一個 ArrayList ( 或者其它 List 的 子類 ) 的物件,這個物件包含的元素就型別是 C 的超型別 ( 包含 C 本身 ) 的一種。

大多數情況下泛型的使用比較簡單,但是如果自己編寫支援泛型的程式碼需要對泛型有深入的瞭解。這幾篇文章介紹了泛型的基本用法、型別擦除、泛型陣列以及萬用字元的使用,涵蓋了最常用的要點,泛型的總結就寫到這裡。

參考

  • Java 程式設計思想

from: https://segmentfault.com/a/1190000005337789