Effective Java 第三版——52. 明智而審慎地使用過載
Tips
書中的原始碼地址: https://github.com/jbloch/effective-java-3e-source-code
注意,書中的有些程式碼裡方法是基於Java 9 API中的,所以JDK 最好下載 JDK 9以上的版本。

Effective Java, Third Edition
52. 明智而審慎地使用過載
下面的程式是一個善意的嘗試,根據Set、List或其他型別的集合對它進行分類:
// 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)
方法,並且在迴圈的每次迭代中呼叫這個過載。
此程式的行為是違反直覺的,因為 過載(overridden)方法之間的選擇是靜態的,而重寫(overridden)方法之間的選擇是動態的 。 根據呼叫方法的物件的執行時型別,在執行時選擇正確版本的重寫方法。 作為提醒,當子類包含與父類中具有相同簽名的方法宣告時,會重寫此方法。 如果在子類中重寫例項方法並且在子類的例項上呼叫,則無論子類例項的編譯時型別如何,都會執行子類的重寫方法。 為了具體說明,請考慮以下程式:
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
類中宣告,並在子類 SparklingWine
和 Champagne
中重寫。 正如你所料,此程式打印出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條目所述,保守策略是根本不過載它。如果遵守這些限制,程式設計師就不會懷疑哪些過載適用於任何一組實際引數。這些限制並不十分繁重,因為 總是可以為方法賦予不同的名稱,而不是過載它們 。
例如,考慮 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)
方法,其中 E
是 set (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以外的類是完全不同的。此外,除了 Serializable
和 Cloneable
之外,陣列型別和其他介面型別也完全不同。如果兩個不同的類都不是另一個類的後代[JLS, 5.5],則稱它們是不相關的。例如, String
和 Throwable
是不相關的。任何物件都不可能是兩個不相關類的例項,所以不相關的類也是完全不同的。
還有其他『型別對(pairs of types)』不能在任何方向轉換[JLS, 5.1.12],但是一旦超出上面描述的簡單情況,大多數程式設計師就很難辨別哪些過載(如果有的話)適用於一組實際引數。決定選擇哪個過載的規則非常複雜,並且隨著每個版本的釋出而變得越來越複雜。很少有程式設計師能理解它們所有的微妙之處。
有時候,可能覺得有必要違反這一條目中的指導原則,特別是在演化現有類時。例如,考慮String,它從Java 4開始就有一個 contenttequals (StringBuffer)
方法。在Java 5中,添加了 CharSequence
介面,來為 StringBuffer
、 StringBuilder
、 String
、 CharBuffer
和其他類似型別提供公共介面。在新增 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)
,它們在傳遞相同的物件引用時執行完全不同的操作。對此沒有任何正當的理由理由,它應該被視為一種異常現象,有可能造成真正的混亂。
總而言之,僅僅可以過載方法並不意味著應該這樣做。通常,最好避免過載具有相同數量引數的多個簽名的方法。在某些情況下,特別是涉及構造方法的情況下,可能無法遵循此建議。在這些情況下,至少應該避免通過新增強制轉換將相同的引數集傳遞給不同的過載。如果這是無法避免的,例如,因為要對現有類進行改造以實現新介面,那麼應該確保在傳遞相同的引數時,所有過載的行為都是相同的。如果做不到這一點,程式設計師將很難有效地使用過載方法或構造方法,也無法理解為什麼它不能工作。