Effective Java 第三版——31.使用限定通配符來增長API的靈活性

Tips
《Effective Java, Third Edition》一書英文版已經出版,這本書的第二版想必不少人都讀過,號稱Java四大名著之一,不過第二版2009年出版,到如今已經將近8年的時間,但隨着Java 6,7,8,甚至9的發佈,Java語言發生了深入的變化。
在這裏第一時間翻譯成中文版。供你們學習分享之用。java

Effective Java, Third Edition

31. 使用限定通配符來增長API的靈活性

如條目 28所述,參數化類型是不變的。換句話說,對於任何兩個不一樣類型的Type1TypeList <Type1>既不是List <Type2>子類型也不是其父類型。儘管List <String>不是List <Object>的子類型是違反直覺的,但它確實是有道理的。 能夠將任何對象放入List <Object>中,可是隻能將字符串放入List <String>中。 因爲List <String>不能作List <Object>所能作的全部事情,因此它不是一個子類型(條目 10 中的里氏替代原則)。api

相對於提供的不可變的類型,有時你須要比此更多的靈活性。 考慮條目 29中的Stack類。下面是它的公共API:安全

public class Stack<E> {

    public Stack();

    public void push(E e);

    public E pop();

    public boolean isEmpty();

}

假設咱們想要添加一個方法來獲取一系列元素,並將它們所有推送到棧上。 如下是第一種嘗試:學習

// pushAll method without wildcard type - deficient!

public void pushAll(Iterable<E> src) {
    for (E e : src)
        push(e);
}

這種方法能夠乾淨地編譯,但不徹底使人滿意。 若是可遍歷的src元素類型與棧的元素類型徹底匹配,那麼它工做正常。 可是,假設有一個Stack <Number>,並調用push(intVal),其中intVal的類型是Integer。 這是由於IntegerNumber的子類型。 從邏輯上看,這彷佛也應該起做用:ui

Stack<Number> numberStack = new Stack<>();

Iterable<Integer> integers = ... ;

numberStack.pushAll(integers);

可是,若是你嘗試了,會獲得這個錯誤消息,由於參數化類型是不變的:翻譯

StackTest.java:7: error: incompatible types: Iterable<Integer>

cannot be converted to Iterable<Number>

        numberStack.pushAll(integers);

                            ^

幸運的是,有對應的解決方法。 該語言提供了一種特殊的參數化類型來調用一個限定通配符類型來處理這種狀況。 pushAll的輸入參數的類型不該該是「E的Iterable接口」,而應該是「E的某個子類型的Iterable接口」,而且有一個通配符類型,這意味着:Iterable <? extends E>。 (關鍵字extends的使用有點誤導:回憶條目 29中,子類型被定義爲每一個類型都是它本身的子類型,即便它自己沒有繼承。)讓咱們修改pushAll來使用這個類型:code

// Wildcard type for a parameter that serves as an E producer

public void pushAll(Iterable<? extends E> src) {
    for (E e : src)
        push(e);
}

有了這個改變,Stack類不只能夠乾淨地編譯,並且客戶端代碼也不會用原始的pushAll聲明編譯。 由於Stack和它的客戶端乾淨地編譯,你知道一切都是類型安全的。對象

如今假設你想寫一個popAll方法,與pushAll方法相對應。 popAll方法從棧中彈出每一個元素並將元素添加到給定的集合中。 如下是第一次嘗試編寫popAll方法的過程:blog

// popAll method without wildcard type - deficient!

public void popAll(Collection<E> dst) {

    while (!isEmpty())

        dst.add(pop());

}

一樣,若是目標集合的元素類型與棧的元素類型徹底匹配,則乾淨編譯而且工做正常。 可是,這又不徹底使人滿意。 假設你有一個Stack <Number>Object類型的變量。 若是從棧中彈出一個元素並將其存儲在該變量中,它將編譯並運行而不會出錯。 因此你也不能這樣作嗎?繼承

Stack<Number> numberStack = new Stack<Number>();

Collection<Object> objects = ... ;

numberStack.popAll(objects);

若是嘗試將此客戶端代碼與以前顯示的popAll版本進行編譯,則會獲得與咱們的初版pushAll很是相似的錯誤:Collection <Object>不是Collection <Number>的子類型。 通配符類型再一次提供了一條出路。 popAll的輸入參數的類型不該該是「E的集合」,而應該是「E的某個父類型的集合」(其中父類型被定義爲E是它本身的父類型[JLS,4.10])。 再次,有一個通配符類型,正是這個意思:Collection <? super E>。 讓咱們修改popAll來使用它:

// Wildcard type for parameter that serves as an E consumer

public void popAll(Collection<? super E> dst) {

    while (!isEmpty())

        dst.add(pop());

}

經過這個改動,Stack類和客戶端代碼均可以乾淨地編譯。

這個結論很清楚。 爲了得到最大的靈活性,對錶明生產者或消費者的輸入參數使用通配符類型。 若是一個輸入參數既是一個生產者又是一個消費者,那麼通配符類型對你沒有好處:你須要一個精確的類型匹配,這就是沒有任何通配符的狀況。

這裏有一個助記符來幫助你記住使用哪一種通配符類型:
PECS表明: producer-extends,consumer-super。

換句話說,若是一個參數化類型表明一個T生產者,使用<? extends T>;若是它表明T消費者,則使用<? super T>。 在咱們的Stack示例中,pushAll方法的src參數生成棧使用的E實例,所以src的合適類型爲Iterable<? extends E>popAll方法的dst參數消費Stack中的E實例,所以dst的合適類型是Collection <? super E>。 PECS助記符抓住了使用通配符類型的基本原則。 Naftalin和Wadler稱之爲獲取和放置原則( Get and Put Principle )[Naftalin07,2.4]。

記住這個助記符以後,讓咱們來看看本章中之前項目的一些方法和構造方法聲明。 條目 28中的Chooser類構造方法有這樣的聲明:

public Chooser(Collection<T> choices)

這個構造方法只使用集合選擇來生產類型T的值(並將它們存儲起來以備後用),因此它的聲明應該使用一個extends T的通配符類型。下面是獲得的構造方法聲明:

// Wildcard type for parameter that serves as an T producer

public Chooser(Collection<? extends T> choices)

這種改變在實踐中會有什麼不一樣嗎? 是的,會有不一樣。 假你有一個List <Integer>,而且想把它傳遞給Chooser<Number>的構造方法。 這不會與原始聲明一塊兒編譯,可是它只會將限定通配符類型添加到聲明中。

如今看看條目 30中的union方法。下是聲明:

public static <E> Set<E> union(Set<E> s1, Set<E> s2)

兩個參數s1s2都是E的生產者,因此PECS助記符告訴咱們該聲明應該以下:

public static <E> Set<E> union(Set<? extends E> s1,  Set<? extends E> s2)

請注意,返回類型仍然是Set <E>。 不要使用限定通配符類型做爲返回類型。除了會爲用戶提供額外的靈活性,還強制他們在客戶端代碼中使用通配符類型。 經過修改後的聲明,此代碼將清晰地編譯:

Set<Integer>  integers =  Set.of(1, 3, 5);

Set<Double>   doubles  =  Set.of(2.0, 4.0, 6.0);

Set<Number>   numbers  =  union(integers, doubles);

若是使用得當,類的用戶幾乎不會看到通配符類型。 他們使方法接受他們應該接受的參數,拒絕他們應該拒絕的參數。 若是一個類的用戶必須考慮通配符類型,那麼它的API可能有問題。

在Java 8以前,類型推斷規則不夠聰明,沒法處理先前的代碼片斷,這要求編譯器使用上下文指定的返回類型(或目標類型)來推斷E的類型。union方法調用的目標類型如前所示是Set <Number>。 若是嘗試在早期版本的Java中編譯片斷(以及適合的Set.of工廠替代版本),將會看到如此長的錯綜複雜的錯誤消息:

Union.java:14: error: incompatible types

        Set<Number> numbers = union(integers, doubles);

                                   ^

  required: Set<Number>

  found:    Set<INT#1>

  where INT#1,INT#2 are intersection types:

    INT#1 extends Number,Comparable<? extends INT#2>

    INT#2 extends Number,Comparable<?>

幸運的是有辦法來處理這種錯誤。 若是編譯器不能推斷出正確的類型,你能夠隨時告訴它使用什麼類型的顯式類型參數[JLS,15.12]。 甚至在Java 8中引入目標類型以前,這不是你必須常常作的事情,這很好,由於顯式類型參數不是很漂亮。 經過添加顯式類型參數,以下所示,代碼片斷在Java 8以前的版本中進行了乾淨編譯:

// Explicit type parameter - required prior to Java 8

Set<Number> numbers = Union.<Number>union(integers, doubles);

接下來讓咱們把注意力轉向條目 30中的max方法。這裏是原始聲明:

public static <T extends Comparable<T>> T max(List<T> list)

如下是使用通配符類型的修改後的聲明:

public static <T extends Comparable<? super T>> T max(List<? extends T> list)

爲了從原來到修改後的聲明,咱們兩次應用了PECS。首先直接的應用是參數列表。 它生成T實例,因此將類型從List <T>更改成List<? extends T>。 棘手的應用是類型參數T。這是咱們第一次看到通配符應用於類型參數。 最初,T被指定爲繼承Comparable <T>,但ComparableT消費T實例(並生成指示順序關係的整數)。 所以,參數化類型Comparable <T>被替換爲限定通配符類型Comparable<? super T>Comparable實例老是消費者,因此一般應該使用Comparable<? super T>優於Comparable <T>Comparator也是如此。所以,一般應該使用Comparator<? super T>優於Comparator<T>

修改後的max聲明多是本書中最複雜的方法聲明。 增長的複雜性是否真的起做用了嗎? 一樣,它的確如此。 這是一個列表的簡單例子,它被原始聲明排除,但在被修改後的版本里是容許的:

List<ScheduledFuture<?>> scheduledFutures = ... ;

沒法將原始方法聲明應用於此列表的緣由是ScheduledFuture不實現Comparable <ScheduledFuture>。 相反,它是Delayed的子接口,它繼承了Comparable <Delayed>。 換句話說,一個ScheduledFuture實例不只僅和其餘的ScheduledFuture實例相比較: 它能夠與任何Delayed實例比較,而且足以致使原始的聲明拒絕它。 更廣泛地說,通配符要求來支持沒有直接實現Comparable(或Comparator)的類型,但繼承了一個類型。

還有一個關於通配符相關的話題。 類型參數和通配符之間具備雙重性,許多方法能夠用一個或另外一個聲明。 例如,下面是兩個可能的聲明,用於交換列表中兩個索引項目的靜態方法。 第一個使用無限制類型參數(條目 30),第二個使用無限制通配符:

// Two possible declarations for the swap method

public static <E> void swap(List<E> list, int i, int j);

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

這兩個聲明中的哪個更可取,爲何? 在公共API中,第二個更好,由於它更簡單。 你傳入一個列表(任何列表),該方法交換索引的元素。 沒有類型參數須要擔憂。 一般,若是類型參數在方法聲明中只出現一次,請將其替換爲通配符。 若是它是一個無限制的類型參數,請將其替換爲無限制的通配符; 若是它是一個限定類型參數,則用限定通配符替換它。

第二個swap方法聲明有一個問題。 這個簡單的實現不會編譯:

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

試圖編譯它會產生這個不太有用的錯誤信息:

Swap.java:5: error: incompatible types: Object cannot be

converted to CAP#1

        list.set(i, list.set(j, list.get(i)));

                                        ^

  where CAP#1 is a fresh type-variable:

    CAP#1 extends Object from capture of ?

看起來咱們不能把一個元素放回到咱們剛剛拿出來的列表中。 問題是列表的類型是List <?>,而且不能將除null外的任何值放入List <?>中。 幸運的是,有一種方法能夠在不使用不安全的轉換或原始類型的狀況下實現此方法。 這個想法是寫一個私有輔助方法來捕捉通配符類型。 輔助方法必須是泛型方法才能捕獲類型。 如下是它的定義:

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

    swapHelper(list, i, j);

}

// Private helper method for wildcard capture

private static <E> void swapHelper(List<E> list, int i, int j) {

    list.set(i, list.set(j, list.get(i)));

}

swapHelper方法知道該列表是一個List <E>。 所以,它知道從這個列表中得到的任何值都是E類型,而且能夠安全地將任何類型的E值放入列表中。 這個稍微複雜的swap的實現能夠乾淨地編譯。 它容許咱們導出基於通配符的漂亮聲明,同時利用內部更復雜的泛型方法。 swap方法的客戶端不須要面對更復雜的swapHelper聲明,但他們從中受益。 輔助方法具備咱們認爲對公共方法來講過於複雜的簽名。

總之,在你的API中使用通配符類型,雖然棘手,但使得API更加靈活。 若是編寫一個將被普遍使用的類庫,正確使用通配符類型應該被認爲是強制性的。 記住基本規則: producer-extends, consumer-super(PECS)。 還要記住,全部ComparableComparator都是消費者。

相關文章
相關標籤/搜索