Effective Java 第三版——52. 明智而審慎地使用重載

Tips
書中的源代碼地址:https://github.com/jbloch/effective-java-3e-source-code
注意,書中的有些代碼裏方法是基於Java 9 API中的,因此JDK 最好下載 JDK 9以上的版本。java

Effective Java, Third Edition

52. 明智而審慎地使用重載

下面的程序是一個善意的嘗試,根據Set、List或其餘類型的集合對它進行分類:git

// Broken! - What does this program print?

public class CollectionClassifier {

    public static String classify(Set<?> s) {
        return "Set";
    }

    public static String classify(List<?> lst) {
        return "List";
    }

    public static String classify(Collection<?> c) {
        return "Unknown Collection";
    }

    public static void main(String[] args) {
        Collection<?>[] collections = {
            new HashSet<String>(),
            new ArrayList<BigInteger>(),
            new HashMap<String, String>().values()
        };

        for (Collection<?> c : collections)
            System.out.println(classify(c));
    }

}

您可能但願此程序打印Set,而後是List和Unknown Collection字符串,實際上並無。 而是打印了三次Unknown Collection字符串。 爲何會這樣? 由於classify方法被重載了,在編譯時選擇要調用哪一個重載方法。 對於循環的全部三次迭代,參數的編譯時類型是相同的:Collection<?>。 運行時類型在每次迭代中都不一樣,但這不會影響對重載方法的選擇。 由於參數的編譯時類型是Collection<?>,,因此惟一適用的重載是第三個classify(Collection<?> c)方法,而且在循環的每次迭代中調用這個重載。程序員

此程序的行爲是違反直覺的,由於重載(overloaded)方法之間的選擇是靜態的,而重寫(overridden)方法之間的選擇是動態的。 根據調用方法的對象的運行時類型,在運行時選擇正確版本的重寫方法。 做爲提醒,當子類包含與父類中具備相同簽名的方法聲明時,會重寫此方法。 若是在子類中重寫實例方法而且在子類的實例上調用,則不管子類實例的編譯時類型如何,都會執行子類的重寫方法。 爲了具體說明,請考慮如下程序:github

class Wine {
    String name() { return "wine"; }
}

class SparklingWine extends Wine {
    @Override String name() { return "sparkling wine"; }
}

class Champagne extends SparklingWine {
    @Override String name() { return "champagne"; }
}

public class Overriding {
    public static void main(String[] args) {
        List<Wine> wineList = List.of(
            new Wine(), new SparklingWine(), new Champagne());

        for (Wine wine : wineList)
            System.out.println(wine.name());
    }

}

name方法在Wine類中聲明,並在子類SparklingWineChampagne中重寫。 正如你所料,此程序打印出wine,sparkling wine和champagne,即便實例的編譯時類型在循環的每次迭代中都是Wine。 當調用重寫方法時,對象的編譯時類型對執行哪一個方法沒有影響; 老是會執行「最具體(most specific)」的重寫方法。 將此與重載進行比較,其中對象的運行時類型對執行的重載沒有影響; 選擇是在編譯時完成的,徹底基於參數的編譯時類型。算法

CollectionClassifier示例中,程序的目的是經過基於參數的運行時類型自動調度到適當的方法重載來辨別參數的類型,就像Wine類中的name方法同樣。 方法重載根本不提供此功能。 假設須要一個靜態方法,修復CollectionClassifier程序的最佳方法是用一個執行顯式instanceof測試的方法替換classify的全部三個重載:數組

public static String classify(Collection<?> c) {
    return c instanceof Set  ? "Set" :
           c instanceof List ? "List" : "Unknown Collection";
}

由於重寫是規範,而重載是例外,因此重寫設置了人們對方法調用行爲的指望。 正如CollectionClassifier示例所示,重載很容易混淆這些指望。 編寫讓程序員感到困惑的代碼的行爲是很差的實踐。 對於API尤爲如此。 若是API的平常用戶不知道將爲給定的參數集調用多個方法重載中的哪個,則使用API可能會致使錯誤。 這些錯誤極可能表現爲運行時的不穩定行爲,許多程序員很難診斷它們。 所以,應該避免混淆使用重載安全

到底是什麼構成了重載的混亂用法還有待商榷。一個安全和保守的策略是永遠不要導出兩個具備相同參數數量的重載。若是一個方法使用了可變參數,除非如第53條目所述,保守策略是根本不重載它。若是遵照這些限制,程序員就不會懷疑哪些重載適用於任何一組實際參數。這些限制並不十分繁重,由於老是能夠爲方法賦予不一樣的名稱,而不是重載它們ide

例如,考慮ObjectOutputStream類。對於每一個基本類型和幾個引用類型,它都有其write方法的變體。這些變體都有不一樣的名稱,例如writeBoolean(boolean)writeInt(int)writeLong(long),而不是重載write方法。與重載相比,這種命名模式的另外一個好處是,能夠爲read方法提供相應的名稱,例如readBoolean()readInt()readLong()ObjectInputStream類實際上提供了這樣的讀取方法。函數

對於構造方法,沒法使用不一樣的名稱:類的多個構造函數老是被重載。 在許多狀況下,能夠選擇導出靜態工廠而不是構造方法(條目1)。 此外,使用構造方法,沒必要擔憂重載和重寫之間的影響,由於構造方法不能被重寫。 你可能有機會導出具備相同數量參數的多個構造函數,所以知道如何安全地執行它是值得的。測試

若是老是清楚哪一個重載將應用於任何給定的實際參數集,那麼用相同數量的參數導出多個重載不太可能讓程序員感到困惑。在這種狀況下,每對重載中至少有一個對應的形式參數在這兩個重載中具備「徹底不一樣的」類型。若是顯然不可能將任何非空表達式強制轉換爲這兩種類型,那麼這兩種類型是徹底不一樣的。在這些狀況下,應用於給定實際參數集的重載徹底由參數的運行時類型決定,且不受其編譯時類型的影響,所以消除了一個主要的混淆。例如,ArrayList有一個接受int的構造方法和第二個接受Collection的構造方法。很難想象在任何狀況下,這兩個構造方法在調用時哪一個會產生混淆。

在Java 5以前,全部基本類型都與引用類型徹底不一樣,但在自動裝箱存在的狀況下,則並不是如此,而且它已經形成了真正的麻煩。 考慮如下程序:

public class SetList {
    public static void main(String[] args) {
        Set<Integer> set = new TreeSet<>();
        List<Integer> list = new ArrayList<>();

        for (int i = -3; i < 3; i++) {
            set.add(i);
            list.add(i);
        }

        for (int i = 0; i < 3; i++) {
            set.remove(i);
            list.remove(i);
        }

        System.out.println(set + " " + list);
    }
}

首先,程序將從-3到2的整數添加到有序集合和列表中。 而後,它在集合和列表上進行三次相同的remove方法調用。 若是你和大多數人同樣,但願程序從集合和列表中刪除非負值(0,1和2)並打印[-3,-2,-1] [ - 3,-2,-1]。 實際上,程序從集合中刪除非負值,從列表中刪除奇數值,並打印[-3,-2,-1] [-2,0,2]。 稱這種混亂的行爲是一種保守的說法。

實際狀況是:調用set.remove(i)選擇重載remove(E)方法,其中Eset (Integer)的元素類型,將基本類型i由int自動裝箱爲Integer中。這是你所指望的行爲,所以程序最終會從集合中刪除正值。另外一方面,對list.remove(i)的調用選擇重載remove(int i)方法,它將刪除列表中指定位置的元素。若是從列表[-3,-2,-1,0,1,2]開始,移除第0個元素,而後是第1個,而後是第二個,就只剩下[-2,0,2],謎底就解開了。若要修復此問題,請強制轉換list.remove的參數爲Integer類型,迫使選擇正確的重載。或者,也能夠調用Integer.valueOf(i),而後將結果傳遞給list.remove方法。不管哪一種方式,程序都會按預期打印[-3,-2,-1][-3,-2,-1]:

for (int i = 0; i < 3; i++) {
    set.remove(i);
    list.remove((Integer) i);  // or remove(Integer.valueOf(i))
}

前一個示例所演示的使人混亂的行爲是因爲List<E>接口對remove方法有兩個重載:remove(E)remove(int)。在Java 5以前,當List接口被「泛型化」時,它有一個remove(Object)方法代替remove(E),而相應的參數類型Object和int則徹底不一樣。可是,在泛型和自動裝箱的存在下,這兩種參數類型再也不徹底不一樣了。換句話說,在語言中添加泛型和自動裝箱破壞了List接口。幸運的是,Java類庫中的其餘API幾乎沒有受到相似的破壞,可是這個故事清楚地代表,自動裝箱和泛型在重載時增長了謹慎的重要性。

在Java 8中添加lambda表達式和方法引用之後,進一步增長了重載混淆的可能性。 例如,考慮如下兩個代碼片斷:

new Thread(System.out::println).start();

ExecutorService exec = Executors.newCachedThreadPool();

exec.submit(System.out::println);

雖然Thread構造方法調用和submit方法調用看起來很類似,可是前者編譯然後者不編譯。參數是相同的(System.out::println),二者都有一個帶有Runnable的重載。這裏發生了什麼?使人驚訝的答案是,submit方法有一個帶有Callable <T>參數的重載,而Thread構造方法卻沒有。你可能認爲這不會有什麼區別,由於println方法的全部重載都會返回void,所以方法引用不多是Callable
。這頗有道理,但重載解析算法不是這樣工做的。也許一樣使人驚訝的是,若是println方法沒有被重載,那麼submit方法調用是合法的。正是被引用的方法(println)的重載和被調用的方法(submit)相結合,阻止了重載解析算法按照你所指望的方式運行。

從技術上講,問題是System.out :: println是一個不精確的方法引用[JLS,15.13.1],而且『包含隱式類型的lambda表達式或不精確的方法引用的某些參數表達式被適用性測試忽略,由於在選擇目標類型以前沒法肯定它們的含義[JLS,15.12.2]。』若是你不理解這段話也不要擔憂; 它針對的是編譯器編寫者。 關鍵是在同一參數位置中具備不一樣功能接口的重載方法或構造方法會致使混淆。 所以,不要在相同參數位置重載採用不一樣函數式接口的方法。 在此條目的說法中,不一樣的函數式接口並無根本不一樣。 若是傳遞命令行開關-Xlint:overloads,Java編譯器將警告這種有問題的重載。

數組類型和Object之外的類是徹底不一樣的。此外,除了SerializableCloneable以外,數組類型和其餘接口類型也徹底不一樣。若是兩個不一樣的類都不是另外一個類的後代[JLS, 5.5],則稱它們是不相關的。例如,StringThrowable是不相關的。任何對象都不多是兩個不相關類的實例,因此不相關的類也是徹底不一樣的。

還有其餘『類型對(pairs of types)』不能在任何方向轉換[JLS, 5.1.12],可是一旦超出上面描述的簡單狀況,大多數程序員就很難辨別哪些重載(若是有的話)適用於一組實際參數。決定選擇哪一個重載的規則很是複雜,而且隨着每一個版本的發佈而變得愈來愈複雜。不多有程序員能理解它們全部的微妙之處。

有時候,可能以爲有必要違反這一條目中的指導原則,特別是在演化現有類時。例如,考慮String,它從Java 4開始就有一個contenttequals (StringBuffer)方法。在Java 5中,添加了CharSequence接口,來爲StringBufferStringBuilderStringCharBuffer和其餘相似類型提供公共接口。在添加CharSequence的同時,String還配備了一個重載的contenttequals方法,該方法接受CharSequence參數。

雖然上面的重載明顯違反了此條目中的指導原則,但它不會形成任何危害,由於當在同一個對象引用上調用這兩個重載方法時,它們作的是徹底相同的事情。程序員可能不知道將調用哪一個重載,但只要它們的行爲相同,就沒有什麼後果。確保這種行爲的標準方法是,將更具體的重載方法調用轉發給更通常的重載方法:

// Ensuring that 2 methods have identical behavior by forwarding

public boolean contentEquals(StringBuffer sb) {
    return contentEquals((CharSequence) sb);
}

雖然Java類庫在很大程度上遵循了這一條目中的建議,可是有一些類違反了它。例如,String導出兩個重載的靜態工廠方法valueOf(char[])valueOf(Object),它們在傳遞相同的對象引用時執行徹底不一樣的操做。對此沒有任何正當的理由理由,它應該被視爲一種異常現象,有可能形成真正的混亂。

總而言之,僅僅能夠重載方法並不意味着應該這樣作。一般,最好避免重載具備相同數量參數的多個簽名的方法。在某些狀況下,特別是涉及構造方法的狀況下,可能沒法遵循此建議。在這些狀況下,至少應該避免經過添增強制轉換將相同的參數集傳遞給不一樣的重載。若是這是沒法避免的,例如,由於要對現有類進行改造以實現新接口,那麼應該確保在傳遞相同的參數時,全部重載的行爲都是相同的。若是作不到這一點,程序員將很難有效地使用重載方法或構造方法,也沒法理解爲何它不能工做。

相關文章
相關標籤/搜索