ITEM 52: USE OVERLOADING JUDICIOUSLY
??下面的程序是一個善意的嘗試,根據(jù)集合,列表,或其他類型的集合來分類:
// 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" 三次。為什么會這樣?因?yàn)?classification 方法是重載的,并且在編譯時選擇要調(diào)用哪個重載。對于循環(huán)的所有三個迭代,參數(shù)的編譯時類型是相同的: Collection。運(yùn)行時類型在每個迭代中是不同的,但這并不影響重載的選擇。因?yàn)閰?shù)的編譯時類型是Collection 唯一適用的重載是第三個,classify(Collection<?>),這個重載在循環(huán)的每次迭代中調(diào)用。
??這個程序的行為是違反直覺的,因?yàn)?overloaded 的選擇是靜態(tài)的,而 override 的選擇是動態(tài)的。重寫方法的正確版本是在運(yùn)行時根據(jù)調(diào)用該方法的對象的運(yùn)行時類型選擇的。提醒一下,當(dāng)子類包含與祖先中的方法聲明具有相同簽名的方法聲明時,方法將被重寫。如果在子類中重寫了實(shí)例方法,并且在子類的實(shí)例上調(diào)用了此方法,則執(zhí)行子類的重寫方法,而不管子類實(shí)例的編譯時類型如何。具體來說,考慮以下程序:
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”,即使實(shí)例的編譯時類型是循環(huán)每次迭代中的 Wine。調(diào)用被覆蓋的方法時,對象的編譯時類型對執(zhí)行哪個方法沒有影響,“最特定的”重寫方法總是被執(zhí)行。與重載進(jìn)行比較,對象的運(yùn)行時類型對執(zhí)行重載沒有影響;選擇是在編譯時進(jìn)行的,完全基于參數(shù)的編譯時類型。
??在 CollectionClassifier 示例中,程序的目的是通過根據(jù)參數(shù)的運(yùn)行時類型自動分派到適當(dāng)?shù)姆椒ㄖ剌d來識別參數(shù)的類型,就像 Wine 示例中的 name 方法所做的那樣。方法重載根本不提供此功能。假設(shè)需要一個靜態(tài)方法,修復(fù) CollectionClassifier 程序的最佳方法是用一個執(zhí)行顯式instanceof 測試的方法替換這三個 classification 重載:
public static String classify(Collection<?> c) {
return c instanceof Set ?
"Set"
: c instanceof List ?
"List"
: "Unknown Collection";
}
??因?yàn)橹貙懯且?guī)范,而重載是例外,所以重寫設(shè)置了人們對方法調(diào)用行為的期望。正如CollectionClassifier 示例所演示的,重載很容易混淆這些期望。編寫行為可能使程序員感到困惑的代碼是不好的實(shí)踐。對于 API 尤其如此。如果 API 的典型用戶不知道給定的一組參數(shù)將調(diào)用幾種方法重載中的哪一種,那么使用 API 可能會導(dǎo)致錯誤。這些錯誤很可能在運(yùn)行時表現(xiàn)為不穩(wěn)定的行為,許多程序員很難診斷它們。因此,您應(yīng)該避免混淆重載的用法。
??究竟是什么構(gòu)成了重載的一種令人困惑的用法還存在一些爭議。一個安全、保守的策略是永遠(yuǎn)不導(dǎo)出具有相同數(shù)量參數(shù)的兩個重載。如果一個方法使用了 varargs,保守策略是根本不重載它,除非如 item 53 所述。如果您堅持這些限制,程序員將永遠(yuǎn)不會懷疑哪個重載適用于任何一組實(shí)際參數(shù)。這些限制不是很麻煩,因?yàn)槟偸强梢詾榉椒ㄌ峁┎煌拿Q,而不是重載它們。
??例如,考慮 ObjectOutputStream 類。對于每個基本類型和幾個引用類型,它都有一個不同的寫方法。這些變量都有不同的名稱,如 writeBoolean(boolean)、writeInt(int) 和 writeLong(long),而不是重載 write 方法。與重載相比,這種命名模式的另一個好處是可以提供具有相應(yīng)名稱的read 方法,例如 readBoolean()、readInt() 和 readLong()。實(shí)際上,ObjectInputStream 類確實(shí)提供了這樣的讀取方法。
??對于構(gòu)造函數(shù),您沒有使用不同名稱的選項(xiàng):一個類的多個構(gòu)造函數(shù)總是重載的。在很多情況下,你可以選擇導(dǎo)出靜態(tài)的工廠而不是構(gòu)造函數(shù)(item 1)。您可能會有機(jī)會導(dǎo)出具有相同數(shù)量參數(shù)的多個構(gòu)造函數(shù),因此知道如何安全地進(jìn)行導(dǎo)出是值得的。
??如果總是清楚哪個重載將應(yīng)用于給定的實(shí)際參數(shù)集,那么使用相同數(shù)量的參數(shù)導(dǎo)出多個重載不太可能使程序員感到困惑。當(dāng)每一對重載中至少有一個對應(yīng)的形式參數(shù)在兩個重載中具有“完全不同的”類型時,就是這種情況。如果顯然不可能將任何非空表達(dá)式強(qiáng)制轉(zhuǎn)換為這兩種類型,則這兩種類型完全不同。在這種情況下,對給定的實(shí)際參數(shù)集應(yīng)用哪種重載完全由參數(shù)的運(yùn)行時類型決定,并且不受它們的編譯時類型的影響,因此消除了主要的混淆。例如,ArrayList有一個接受int的構(gòu)造函數(shù)和第二個接受集合的構(gòu)造函數(shù)。很難想象在任何情況下這兩個構(gòu)造函數(shù)中哪個會被調(diào)用。
??在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);
}
}
??首先,程序?qū)⒄麛?shù)從 -3到2,加到一個排序集和一個列表中。然后,它執(zhí)行三個相同的調(diào)用來刪除集合和列表。如果您與大多數(shù)人一樣,您可能希望程序從集合和列表中刪除非負(fù)的值(0、1和2),并打印[-3、-2、-1][-3、-2、-1]。實(shí)際上,程序從集合中刪除非負(fù)的值,從列表中刪除奇數(shù)值,并輸出[- 3, -2, -1] [- 2,0,2]。把這種行為稱為混亂是保守的說法。
??實(shí)際情況是這樣的:調(diào)用 set.remove(i) 選擇重載 remove(E),其中 E 是集合(Integer) 的元素類型,而 自動裝箱 i 從 int 到 Integer。這是您所期望的行為,因此程序最終會從集合中刪除正值。另一方面,對 list.remove(i) 的調(diào)用會選擇重載remove(int i),它會刪除列表中指定位置的元素。如果從列表 [-3,-2,-1,0,1,2] 開始,刪除第 0 個元素,然后第一個,然后第二個,就只剩下 [-2,0,2],謎底就解開了。若要修復(fù)此問題,請強(qiáng)制轉(zhuǎn)換列表。移除整型參數(shù),強(qiáng)制選擇正確的重載。或者,您可以調(diào)用 list.remove(Integer)。不管采用哪種方式,程序都會按預(yù)期打印[-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))
}
??前一個示例所演示的混亂行為是由于列表接口對 remove 方法有兩個重載: remove(E) 和 remove(int)。在Java 5之前,當(dāng)列表接口被“一般化”時,它用一個remove(Object) 方法代替 remove(E),而相應(yīng)的參數(shù)類型 Object 和 int 則完全不同。但是,在泛型和自動裝箱的情況下,這兩種參數(shù)類型不再是完全不同的。換句話說,向語言中添加泛型和自動裝箱破壞了列表接口。幸運(yùn)的是,Java 庫中的其他 api 幾乎沒有受到類似的破壞,但是這個故事清楚地表明,自動裝箱和泛型增加了重載時謹(jǐn)慎的重要性。
??Java 8 中增加的 lambdas 和方法引用進(jìn)一步增加了重載中混淆的可能性。例如,考慮以下兩個片段:
new Thread(System.out::println).start();
ExecutorService exec = Executors.newCachedThreadPool(); exec.submit(System.out::println);
??雖然線程構(gòu)造函數(shù)調(diào)用和提交方法調(diào)用看起來很相似,但是前者編譯而后者不編譯。參數(shù)是相同的(System.out::println),構(gòu)造函數(shù)和方法都有一個重載,該重載接受一個Runnable。這是怎么回事?令人驚訝的答案是,submit 方法有一個重載,它接受一個 Callable<T> ,而線程構(gòu)造函數(shù)沒有。您可能認(rèn)為這不會有任何區(qū)別,因?yàn)?println的所有重載都會返回 void,所以方法引用不可能是可調(diào)用的。這很有道理,但這不是重載解析算法的工作方式。也許同樣令人驚訝的是,如果 println 方法沒有被重載,那么 submit 方法調(diào)用將是合法的。正是被引用的方法(println)和被調(diào)用的方法(submit)的重載阻止了重載解析算法的正常工作。
??從技術(shù)上講,問題出在這個系統(tǒng)上。println 是一個不精確的方法引用[JLS, 15.13.1],并且“某些包含隱式類型化 lambda 表達(dá)式或不精確的方法引用的參數(shù)表達(dá)式將被適用性測試忽略,因?yàn)樗鼈兊暮x在選擇目標(biāo)類型[JLS, 15.12.2]之前無法確定?!?br>
如果你不明白這段話,不要擔(dān)心;它的目標(biāo)是編譯器作者。關(guān)鍵是在相同的參數(shù)位置上重載具有不同功能接口的方法或構(gòu)造函數(shù)會導(dǎo)致混淆。因此,不要重載方法來將不同的功能接口放在相同的參數(shù)位置。用這個術(shù)語來說,不同的功能接口并不是完全不同的。如果您通過命令行開關(guān)(-Xlint:overloads),Java編譯器將警告您此類有問題的重載。
??數(shù)組類型和對象以外的類類型是完全不同的。此外,除了 Serializable 和 Cloneable之外,數(shù)組類型和接口類型也有根本的不同。如果兩個不同的類都不是另一個類的后代[JLS, 5.5],則稱它們是不相關(guān)的。例如,String 和 Throwable 是不相關(guān)的。任何對象都不可能是兩個不相關(guān)的類的實(shí)例,所以不相關(guān)的類也完全不同。
??還有其他類型對不能在任何方向上轉(zhuǎn)換[JLS, 5.1.12],但是一旦您超越了上面描述的簡單情況,大多數(shù)程序員就很難辨別哪些重載(如果有的話)適用于一組實(shí)際參數(shù)。決定選擇哪個重載的規(guī)則非常復(fù)雜,并且隨著每個版本的發(fā)布而變得越來越復(fù)雜。很少有程序員了解它們所有的微妙之處。
??有時您可能覺得需要違反此項(xiàng)中的準(zhǔn)則,特別是在演化現(xiàn)有類時。例如,考慮String,它從 Java 4 開始就有一個內(nèi)容 quals(StringBuffer) 方法。在 Java 5 中,添加了 CharSequence 來為 StringBuffer、StringBuilder、String、CharBuffer和其他類似類型提供公共接口。在添加 CharSequence的同時,String 還配備了一個獲取CharSequence 的 contentEquals 方法的重載。
??雖然結(jié)果重載明顯違反了此項(xiàng)中的準(zhǔn)則,但它不會造成任何損害,因?yàn)閮蓚€重載方法在調(diào)用相同的對象引用時執(zhí)行的是完全相同的操作。程序員可能不知道將調(diào)用哪個重載,但只要它們的行為相同,就沒有任何后果。確保這種行為的標(biāo)準(zhǔn)方法是將更具體的重載轉(zhuǎn)發(fā)給更一般的:
// Ensuring that 2 methods have identical behavior by forwarding
public boolean contentEquals(StringBuffer sb) {
return contentEquals((CharSequence) sb);
}
??雖然 Java 庫在很大程度上遵循了這項(xiàng)建議的精神,但是有許多類違反了它。例如,String 導(dǎo)出兩個重載的靜態(tài)工廠方法 valueOf(char[])和 valueOf(Object),它們在傳遞相同的對象引用時執(zhí)行完全不同的操作。這樣做沒有真正的理由,它應(yīng)該被視為一種可能引起真正混亂的反?,F(xiàn)象。
??總之,您可以重載方法并不意味著您應(yīng)該這樣做。通常最好避免使用具有相同數(shù)量參數(shù)的多個簽名的方法重載。在某些情況下,特別是涉及到構(gòu)造函數(shù)時,可能不可能遵循這個建議。在這些情況下,至少應(yīng)該避免通過添加強(qiáng)制類型轉(zhuǎn)換將相同的參數(shù)集傳遞給不同的重載。如果這是無法避免的,例如,因?yàn)槟趯ΜF(xiàn)有類進(jìn)行改造以實(shí)現(xiàn)新的接口,那么您應(yīng)該確保在傳遞相同的參數(shù)時,所有重載的行為都是相同的。如果不這樣做,程序員將很難有效地使用重載的方法或構(gòu)造函數(shù),他們將無法理解為什么它不能工作。