Joshua Bloch:effective Java@2001 (第1版)
以下是根據您提供的資料所整理及解釋的主要論點:
Effective Java: Programming Language Guide 主要論點詳盡解釋
這本書提供關於 Java 程式語言及其核心函式庫(java.lang, java.util, java.io)的有效使用指南。書中的主要論點以「項目」(Item)的形式呈現,每個項目傳達一個特定的規則或建議,旨在幫助開發者撰寫更清晰、正確、強健且可重用的程式碼。以下將針對書中介紹的各項目進行詳盡解釋:
第 2 章:物件的建立與銷毀 (Creating and Destroying Objects)
-
項目 1:考慮使用靜態工廠方法(static factory method)而非建構子(constructor)
這是一個替代公共建構子的取得類別實例的方法。與建構子不同,靜態工廠方法有名稱,這能讓程式碼更容易閱讀和理解,尤其是在有多個建構子參數類型相同,但意義不同的情況下。靜態工廠方法也不像建構子那樣每次呼叫都必須建立新物件,這使得它們可以用於不可變類別(Item 13)中重複使用預先建立的實例或快取實例,從而提高效能並嚴格控制實例的存在(例如單例模式,Item 2)。此外,靜態工廠方法可以返回其聲明返回類型的 任何 子類型的物件,這提供了極大的靈活性,可以隱藏實作類別,使 API 更加緊湊,並允許返回的物件類別在不同呼叫或不同版本間變化,這在服務提供者框架(service provider framework)中特別有用。然而,靜態工廠方法的缺點是,沒有公共或受保護建構子的類別不能被子類別化,且靜態工廠方法不如建構子那樣在 API 文件中突出,可能較難發現,這可以透過遵循標準命名約定(如valueOf或getInstance)來緩解。 -
項目 2:使用私有建構子加強單例屬性(singleton property)
單例模式確保一個類別只被實例化一次。實現單例的兩種常見方法都涉及私有化建構子並透過公共靜態成員提供實例訪問。第一種方法是使用公共靜態 final 欄位,實例在類別初始化時建立。第二種方法是提供公共靜態工廠方法getInstance,在首次呼叫時建立並返回單一實例。第一種方法的優點是意圖明確,程式碼更簡潔;第二種方法的優點是提供了修改為非單例(例如每個執行緒一個實例)的彈性,且靜態工廠方法(Item 1)可以隱藏實作類別。為了使單例類別可序列化(Item 54),必須提供一個readResolve方法(Item 57),否則每次反序列化都會建立一個新的實例,破壞單例屬性。 -
項目 3:使用私有建構子加強不可實例化(noninstantiability)
有些類別(如工具類別java.lang.Math或java.util.Collections)設計為只包含靜態方法和欄位,不應被實例化。然而,如果沒有顯式建構子,編譯器會提供一個公共的無參數預設建構子,這使得類別可以被實例化。將類別宣告為abstract也無法阻止實例化,因為其子類別可以被實例化,且誤導使用者以為類別設計用於繼承(Item 15)。正確的做法是包含一個私有的顯式建構子。由於私有建構子在類別外部不可訪問,因此保證了類別不會被實例化。為清楚起見,應添加註解說明此建構子的用途是為了防止實例化。此方法還有一個副作用:它阻止了類別被子類別化,因為所有子類別建構子都需要呼叫一個可訪問的父類別建構子。 -
項目 4:避免建立不必要的重複物件
在許多情況下,重用單一物件比每次需要時都建立一個功能等效的新物件更有效且更具風格。不可變物件(Item 13)總是可重用的。例如,避免使用new String("silly"),而是使用字面量"No longer silly"。對於不可變類別,優先使用靜態工廠方法(Item 1)而非建構子通常可以避免建立重複物件,例如Boolean.valueOf(boolean)。即使是可變物件,如果在其實例化後不會被修改,也可以重用。例如,在isBabyBoomer方法中使用靜態初始化器建立Calendar、TimeZone和Date常量,避免在每次方法呼叫時重新建立。這種重用可以顯著提高效能並增加程式碼的清晰度。然而,這並不意味著物件建立本身就很昂貴;對於輕量級物件,建立和回收通常很快。應避免為了重用輕量級物件而維護自己的物件池,因為現代 JVM 的垃圾收集器在這方面表現優異。此項目應與項目 24 的防禦性複製(defensive copying)對照:避免在應重用時建立新物件,避免在應建立新物件時重用現有物件。未能進行防禦性複製的後果比不必要地建立重複物件嚴重得多。 -
項目 5:消除過時的物件引用(obsolete object reference)
即使在使用垃圾收集語言時,也可能存在「記憶體洩漏」(更準確地說是無意識的物件保留),這會導致效能下降,甚至OutOfMemoryError。這通常發生在類別自己管理記憶體的情況下,例如維護一個物件陣列,但當陣列中的物件不再被使用時,其引用並未被清除。Stack 類的pop方法例子就展示了這種問題:彈出的物件雖然不再被程式使用,但其在內部陣列中的引用仍然存在,阻止了垃圾收集。解決方法是將過時的引用設為null。在 Stack 的例子中,可以在pop方法中將彈出元素在陣列中的位置設為null。將引用設為null的額外好處是,如果隨後意外地對其進行解引用,程式會立即失敗並拋出NullPointerException。然而,不應過度地將所有物件引用設為null;最好是讓變數超出作用域。另一種常見的記憶體洩漏源是快取(cache)。將物件放入快取後容易忘記,導致它們長期存在。對於快取,可以使用WeakHashMap或定期清理機制(如LinkedHashMap的removeEldestEntry方法)。 -
項目 6:避免使用終止方法(finalizer)
終止方法(finalize)是不可預測、危險且通常不必要的。它們的執行時機沒有保證,可能任意延遲,甚至根本不執行。這使得它們不適合用於釋放時間敏感的資源(如檔案描述符),否則可能導致資源耗盡。終止方法執行時拋出的未捕獲異常會被忽略,可能導致物件處於損壞狀態。應避免依賴終止方法更新關鍵持久狀態。作為替代,應提供顯式的終止方法(如close,dispose),並要求客戶端在物件不再需要時呼叫它,通常與try-finally區塊結合使用以確保執行。終止方法有兩個合法用途:作為顯式終止方法的「安全網」(safety net),在客戶端忘記呼叫時提供備用釋放;以及用於終止非關鍵的本地對等(native peer)資源。如果一個類別覆寫了finalize方法,其子類別也必須手動呼叫super.finalize()。對於公共的非 final 類別,可以考慮使用「終止監護人」(Finalizer Guardian)慣用法,將終止邏輯放在一個私有匿名類別的終止方法中,該類別的實例作為外部物件的成員存在,以確保終止方法被執行,即使子類別未能正確呼叫super.finalize()。
第 3 章:所有物件共有的方法 (Methods Common to All Objects)
-
項目 7:在覆寫
equals方法時遵守通用契約
覆寫equals看似簡單,但有很多錯誤的方式,後果可能很嚴重。最安全的做法是避免覆寫,除非類別需要邏輯相等性的判斷且父類別沒有提供。通常用於值類別(如Integer,Date)。覆寫equals必須遵守五個特性構成的契約:自反性(reflexive)、對稱性(symmetric)、傳遞性(transitive)、一致性(consistent)、非空性(對於非 null 物件x,x.equals(null)返回 false)。違反這些契約可能導致程式行為異常,特別是在集合類別中使用時。對稱性容易被違反,例如CaseInsensitiveString與String比較。傳遞性容易被違反,例如子類別繼承並添加新屬性(ColorPoint繼承Point)。解決方法通常是優先使用組合(Item 14)而非繼承。寫出高品質equals方法的步驟:使用==優化(可選),使用instanceof檢查類型,轉換參數,比較所有「顯著」(significant)欄位(對原始類型用==,對物件引用遞迴呼叫equals,特殊處理float/double/陣列/null),檢查自反性、對稱性、傳遞性(一致性和非空性通常會自動滿足)。 -
項目 8:在覆寫
equals方法時務必覆寫hashCode方法
如果覆寫了equals但沒有覆寫hashCode,將違反Object.hashCode的通用契約,導致在所有基於雜湊的集合(HashMap,HashSet,Hashtable)中無法正常工作。契約規定:同一物件多次呼叫hashCode應一致返回相同整數(不改變用於 equals 比較的信息);如果兩個物件根據equals相等,它們的hashCode必須相等;如果兩個物件根據equals不相等,不要求hashCode不等,但不同結果有助於雜湊表效能。未能覆寫hashCode會導致相等的物件有不同的雜湊碼。一個好的hashCode函數應盡可能為不相等的物件產生不同的雜湊碼。書中提供了一個簡單的實現hashCode的食譜:初始化result為非零常數(如 17),對於每個顯著欄位f,計算其雜湊碼c(對不同類型有不同規則,如原始類型轉換,物件引用遞迴呼叫hashCode,陣列處理),將c組合到result中 (result = 37*result + c;),最後返回result。可以排除冗餘欄位,但 必須 排除不參與equals比較的欄位。對於不可變類別,可以快取計算出的雜湊碼。不要為了效能排除顯著欄位,否則可能導致雜湊表效能急劇下降。 -
項目 9:務必覆寫
toString方法
Object.toString的預設實現(如PhoneNumber@163b91)通常對使用者沒有幫助。覆寫toString可以提供一個「簡潔但信息豐富、易於閱讀的表示」,使類別更易於使用。好的toString實現有助於除錯、記錄以及包含該物件的其他物件(如集合)的表示。toString方法應返回物件包含的所有有趣信息,或大型物件的摘要。應考慮是否在文件中指定返回值的格式:指定格式可以作為標準可讀表示用於輸入輸出,但一旦指定就難以更改;不指定格式則保留了未來修改的靈活性。無論是否指定格式,都應清楚記錄意圖。無論是否指定格式,應提供程式化的訪問方法(accessor)來獲取toString返回值中的所有信息,否則使用者將被迫解析字串,導致脆弱且效能低下的程式碼。 -
項目 10:謹慎覆寫
clone方法
Cloneable介面設計用於指示類別允許複製,但其本身沒有clone方法,且Object.clone是 protected,這使得Cloneable的作用非常規。它的主要作用是決定Object.clone的行為:實現Cloneable的類別,Object.clone返回欄位複製;否則拋出CloneNotSupportedException。clone機制創建物件不呼叫建構子。clone的通用契約很弱。在非 final 類別中覆寫clone時,應返回透過super.clone()獲取的物件以保留子類別類型。對於包含可變物件引用的物件,僅呼叫super.clone()是不夠的,需要對可變成員進行深層複製(deep copy),這與使用 final 欄位引用可變物件的正常做法不兼容。複製複雜物件結構(如鏈結列表)可能需要迭代而非遞迴複製以避免堆疊溢位。clone方法不應呼叫非 final 方法。Object.clone宣告拋出CloneNotSupportedException,覆寫方法可以省略此宣告(對於 final 類別)或保留它(對於可繼承類別,允許子類別選擇不複製)。鑑於Cloneable的種種問題,更好的物件複製方法是提供複製建構子(copy constructor)或靜態工廠方法(Item 1),它們更安全、靈活、不依賴於風險機制,且允許介面基礎的複製。 -
項目 11:考慮實現
Comparable介面
Comparable介面只有一個方法compareTo,它允許物件根據其自然排序進行比較。實現Comparable使得類別的實例可以使用所有依賴此介面的通用演算法和集合實現(如Arrays.sort,Collections.sort,TreeSet,TreeMap,Collections.binarySearch),這為程式碼帶來了巨大的力量。應強烈考慮為具有明顯自然順序的值類別(如字母順序、數字順序、時間順序)實現此介面。compareTo的契約類似於equals:它必須定義一個全序關係(total order),滿足自反性、對稱性(sgn(x.compareTo(y)) == -sgn(y.compareTo(x)))、傳遞性(x > y && y > z implies x > z)和一致性(相等物件與任何其他物件的比較結果符號相同)。雖然不嚴格要求,但強烈建議(x.compareTo(y) == 0) == (x.equals(y))(排序與equals一致)。寫compareTo類似於寫equals,但不需對參數進行類型檢查(不匹配時拋出ClassCastException),對 null 參數應拋出NullPointerException。欄位比較是順序比較,對物件引用遞迴呼叫compareTo,對原始類型使用關係運算符,對陣列元素遞迴應用規則。比較順序從最重要欄位開始。避免對原始類型使用差值技巧(如field1 - field2),因為可能導致整數溢位而違反契約。
第 4 章:類別與介面 (Classes and Interfaces)
-
項目 12:最小化類別與成員的可訪問性(accessibility)
資訊隱藏(information hiding)或封裝(encapsulation)是軟體設計的基本原則,它能有效地解耦模組,提高可開發性、可測試性、可維護性和可重用性。實現資訊隱藏的關鍵是使用訪問控制機制,使每個類別或成員盡可能不可訪問。對於頂層類別和介面,只有 package-private 和 public 兩個級別,應盡可能使用 package-private。對於成員,有 private, package-private, protected, public 四個級別,應優先使用 private。protected 和 public 成員是導出 API 的一部分,一旦發布就必須長期支援。覆寫父類別方法時,不能降低可訪問性。公共類別應極少有公共欄位,除非是公共靜態 final 常量,且這些常量應是原始類型或不可變物件(Item 13)。特別注意公共靜態 final 陣列,它們是可變的,存在安全隱患,應替換為私有陣列加公共不可變列表或防禦性複製方法。 -
項目 13:優先考慮不可變性(immutability)
不可變類別的實例在建立後不能被修改。它們更容易設計、實現和使用,錯誤更少,更安全。創建不可變類別需遵循五個規則:不提供修改物件的方法(mutator),防止方法被覆寫(通常通過 final 類別或 private 建構子加靜態工廠實現),所有欄位宣告為 final,所有欄位宣告為 private,確保對任何可變組件的專有訪問(在建構子、訪問子和readObject方法中進行防禦性複製)。不可變物件的優點包括:簡單性、內在的執行緒安全性(可自由共享,無需同步)、可被用作優秀的構建塊、適合作為 Map 的鍵和 Set 的元素。缺點是每個不同值都需要一個單獨的物件,對於多步驟操作可能效率較低。可以通過提供原始操作或可變伴隨類別來緩解此問題。優先使用不可變類別,尤其對於小值物件;對於大值物件(如 String, BigInteger)也應認真考慮。如果類別無法完全不可變,應盡可能限制其可變性。 -
項目 14:優先考慮組合(composition)而非繼承(inheritance)
繼承是一種強大的程式碼重用方式,但使用不當會導致程式碼脆弱,特別是在跨 package 繼承普通具體類別時,因為繼承打破了封裝,子類別依賴於父類別的實作細節。父類別實作的改變可能在子類別毫無修改的情況下破壞其功能。例如,InstrumentedHashSet繼承HashSet的例子展示了因父類別內部呼叫覆寫方法(self-use)導致的問題。父類別在未來版本新增方法也可能破壞子類別。解決這些問題的方法是使用組合:在新類別中包含一個現有類別的私有欄位,並透過轉發(forwarding)方法將呼叫委派給內部實例。這種模式稱為包裝器類別(wrapper class)或裝飾器模式(Decorator pattern)。包裝器類別健壯性強,不依賴實作細節,且更靈活(可包裝任何介面實現)。缺點是增加了轉發方法的程式碼量,且不適用於回呼框架中的 self-reference 問題。只有在子類別與父類別之間存在真正的子類型(is-a)關係時才應使用繼承。否則,應優先使用組合。 -
項目 15:設計類別以供繼承,否則禁止繼承
設計類別以供繼承需要仔細考慮。必須精確地記錄覆寫任何方法的影響,即記錄類別對可覆寫方法的「自用」(self-use)模式(哪些公共/protected 方法會呼叫哪些可覆寫方法,呼叫順序等),這違反了好的 API 文件應只描述「做什麼」而非「怎麼做」的原則。為了讓子類別能高效實現,可能需要提供精心選擇的 protected 方法或欄位作為「掛鉤」(hook),但這些細節一旦發布就難以更改。建構子中不能直接或間接呼叫可覆寫方法,因為子類別的狀態在父類別建構子執行時可能還未初始化(Stack Overflow 例子)。Cloneable和Serializable介面對可繼承類別設計提出了特殊挑戰,通常應避免實現它們,除非提供特殊機制讓子類別選擇實現(Item 10, 54)。clone 和readObject方法也不能呼叫可覆寫方法。設計供繼承的類別會對其本身施加重大限制,這不是輕率的決定。對於普通具體類別,不經設計和文件就允許繼承是危險的。最佳解決方案是禁止繼承(將類別設為 final 或使用私有建構子加靜態工廠)。如果必須允許繼承但不是為此設計,應確保類別不呼叫其任何可覆寫方法(完全消除自用)。
第 5 章:C 語言結構的替代方案 (Substitutes for C Constructs)
-
項目 19:用類別取代結構(struct)
Java 中沒有struct,因為類別包含了struct的所有功能並增加了封裝。struct只是一組數據欄位,而類別可以將操作與數據綁定並隱藏數據實現。對於公共類別,應使用私有欄位和公共訪問方法以保持實作彈性。對於 package-private 或私有嵌套類別,如果數據欄位確實代表了抽象概念,直接暴露數據欄位是可以接受的,程式碼更簡潔。Java 函式庫中的Point和Dimension類別直接暴露公共欄位,但這被視為應避免的範例,因為限制了未來的優化能力。 -
項目 20:用類別層次結構取代聯合體(union)
C 語言中的 discriminated union(包含一個 tag 欄位和一個 union)通常用於表示可能包含不同數據類型的結構。在 Java 中,更好的替代方案是類別層次結構(class hierarchy)。可以定義一個抽象根類別,包含共有的數據和 tag-dependent 操作的抽象方法。然後為聯合體中每種可能的類型定義具體子類別,包含該類型特有的數據和抽象方法的具體實現。類別層次結構提供了類型安全、程式碼清晰度高、易於擴展(新增子類別)以及可以反映自然的層次關係等優點。應避免在 Java 中使用顯式的 tag 欄位來模擬 discriminated union。C 語言 union 的另一種用途是查看數據的內部表示(例如將 float 的位元模式視為 int),這在 Java 中沒有可移植的對應方式,且與 Java 的類型安全理念相悖。Java 提供了如Float.floatToIntBits這樣基於精確規範的位元表示方法來確保可移植性。 -
項目 21:用類別取代列舉結構(enum construct)
C 語言的enum只是定義了一組命名整數常量,缺乏類型安全、命名空間、擴展性差且不提供方便的 toString 和遍歷方法。Java 中常見的 int 常量模式(public static final int)也有類似缺點。String 常量模式更差。Java 提供了類型安全的列舉模式(typesafe enum pattern):定義一個類別,將所有實例作為公共靜態 final 欄位導出,建構子設為私有。這樣就嚴格控制了實例的數量和種類。類型安全列舉提供了編譯時類型安全、命名空間安全、向前兼容性、可覆寫toString、可添加方法和實現介面。對於序列化,需要readResolve方法來確保反序列化時返回的是單例常量。可以通過私有類別或匿名內部類為每個常量添加特定的行為。類型安全列舉通常與 int 常量模式效能相當。對於集合操作,不如 int 位元旗標方便,但可以通過專用 Set 實現緩解。總之,類型安全列舉優勢巨大,應優先使用。 -
項目 22:用類別和介面取代函數指標(function pointer)
C 語言的函數指標用於傳遞呼叫特定函數的能力(例如用於回呼和策略模式)。在 Java 中,可以使用物件引用來實現相同功能,這稱為函數物件(function object)。函數物件是實現單一方法(通常稱為策略介面,如Comparator)的類別實例。它們通常是無狀態的,可以實現為單例或匿名內部類別(用於一次性使用)。對於需要導出的策略,可以將其實現為宿主類別的私有靜態成員類別,並通過公共靜態 final 欄位(類型為策略介面)導出。
第 6 章:方法 (Methods)
-
項目 23:檢查參數的有效性
大多數方法和建構子對其參數值有約束。應清楚記錄這些約束(使用@param,@throws)並在方法開始時通過檢查來強制執行,以實現「快速失敗」(fail-fast)原則。這有助於儘早發現錯誤並簡化除錯。對於公共方法,使用標準異常(如IllegalArgumentException,IndexOutOfBoundsException,NullPointerException)。對於非公共方法,可以使用斷言(assertion)。檢查參數有效性尤其重要,當參數值會被存儲起來供以後使用時(如建構子)。例外情況是檢查成本高昂或檢查本身在計算過程中隱式執行,但這可能導致失去原子性(Item 46)。設計方法時應使其盡可能通用,減少不必要的限制。 -
項目 24:必要時進行防禦性複製(defensive copying)
即使在類型安全的語言中,也必須對客戶端提供的可變物件進行防禦性程式設計,以保護類別的內部不變性。例如,對於包含可變私有欄位的不可變類別(如Period中的Date),必須在建構子中對可變參數進行防禦性複製,並在訪問方法中返回內部可變欄位的防禦性複製。建構子中的防禦性複製應在有效性檢查 之前 進行,並檢查複製後的物件。對於客戶端可能子類別化的可變類型,避免使用clone進行防禦性複製,而應使用建構子(如new Date(date.getTime()))。此原則也適用於將客戶端提供的可變物件存儲到內部數據結構或將內部可變組件返回給客戶端的情況。非零長度陣列總是可變的,返回內部陣列時應進行防禦性複製或返回不可變視圖(Item 12)。首選方式是使用不可變物件作為組件(Item 13)。明確記錄需要進行物件控制轉移(handoff)的方法。 -
項目 25:精心設計方法簽名(method signature)
精心選擇方法名稱(遵循命名約定,Item 38)、避免過多的便捷方法、避免過長的參數列表(通常最多三個)以及使用接口而非類別作為參數類型(Item 34)都有助於提高 API 的可學習性和易用性。過長的參數列表可以使用分解方法或創建參數集合的輔助類別來縮短。謹慎使用函數物件(Item 22),雖然它們在某些設計模式中有用,但在 Java 中過度使用可能會降低程式碼的可讀性和效能。 -
項目 26:謹慎使用重載(overloading)
重載方法的選擇是在編譯時根據參數的 編譯時類型 確定的,而覆寫方法的選擇是在運行時根據物件的 運行時類型 確定的。重載可能導致混淆和錯誤,特別是在參數個數相同但類型不同時。一個安全的策略是避免導出參數個數相同的多個重載方法,可以改用不同的方法名稱。對於建構子,重載是必需的,當參數類型明顯不同時(如原始類型 vs 引用類型,無關聯類別)重載是安全的。 retrofitting 現有類別實現新接口(如compareTo(String)和 `compareTo(Object))時,應確保所有重載方法對相同參數執行相同操作,更通用的方法可以轉發給更具體的方法。 -
項目 27:返回零長度陣列,而不是 null
返回 null 表示沒有結果的陣列值方法會迫使客戶端進行額外的 null 檢查,容易出錯且使程式碼複雜。返回一個零長度陣列更清晰、更安全。避免 null 可以簡化客戶端程式碼。儘管返回零長度陣列需要分配一個物件,但成本很低(Item 37),並且可以通過重複使用一個零長度陣列常數(因為零長度陣列是不可變的)來避免重複分配,特別是在使用Collection.toArray(T[])慣用法時。 -
項目 28:為所有導出的 API 元素編寫文檔註解(doc comment)
使用 Javadoc 工具生成的文檔註解是記錄 API 的最佳且最有效的方式。應為每個導出的類別、介面、建構子、方法和欄位(除了簡單的預設建構子)編寫文檔註解。文檔註解應清晰描述契約:方法做了什麼(而非怎麼做),前提條件(precondition),後置條件(postcondition),副作用,以及執行緒安全性(Item 52)。使用@param,@return,@throws標籤記錄參數、返回值和拋出的異常(包括非檢查異常,Item 44)。註解中可以使用 HTML 標籤,但需對元字符進行轉義。文檔註解的第一句話是摘要描述,應簡潔並能獨立地描述元素功能。方法摘要通常是動詞片語,類別/介面/欄位摘要通常是名詞片語。避免在第一句話中包含句號,除非使用 .。可以運行 HTML 檢查器來驗證文檔註解的正確性。Javadoc 1.2.2+ 支持文檔註解繼承,減少冗餘。對於複雜 API,應輔以外部文檔。
第 8 章:異常處理 (Exceptions)
-
項目 39:只在異常情況下使用異常
異常應只用於表示異常的、意外的情況,而不是用於普通的控制流程。使用異常進行控制流程會使程式碼難以理解、效能低下且容易隱藏錯誤。例如,在陣列遍歷中使用try-catch(ArrayIndexOutOfBoundsException)來結束迴圈是錯誤的。如果方法在特定(可預測的)條件下可能失敗,應該提供一個狀態測試方法(如Iterator.hasNext()),或者返回一個特殊值(如 null 或空集合),而不是依賴拋出異常。只有當方法在正常使用下無法預防的條件發生,且呼叫者能夠從該條件中合理地恢復時,才應使用異常。 -
項目 40:對可恢復情況使用受檢查異常(checked exception),對程式錯誤使用運行時異常(run-time exception)
異常分為受檢查異常、運行時異常和錯誤。應對呼叫者可以合理地期望恢復的條件使用受檢查異常,這會強制呼叫者處理異常。對程式錯誤(通常是違反 API 契約的前提條件)使用運行時異常,這表示程式存在錯誤,通常不可恢復。錯誤(Error)通常由 JVM 使用來表示資源耗盡或內部不變性失敗等嚴峻問題,不應自己實現新的Error子類別,所有自定義的非檢查異常都應繼承RuntimeException。應提供方法在異常物件中攜帶額外信息,幫助呼叫者或除錯者理解和恢復(對於受檢查異常尤其重要),避免解析異常的字串表示。 -
項目 41:避免不必要地使用受檢查異常
受檢查異常雖然提高了可靠性,但也增加了程式設計師的負擔。如果一個異常條件在正常使用 API 時無法預防,且程式設計師能夠採取有用的恢復措施,則使用受檢查異常是合理的。否則,非檢查異常更合適。如果一個方法只拋出一個受檢查異常,且程式設計師通常無法做比捕獲後拋出Error或中止程式更好的處理,應考慮將其替換為非檢查異常。一種技術是將拋出異常的方法分解為兩個方法:一個返回 boolean 指示操作是否允許,另一個執行操作(在不允許時拋出非檢查異常)。但此技術不適用於並發訪問或狀態可能在兩次呼叫之間變化的情況。 -
項目 42:偏好使用標準異常
重用標準異常(如IllegalArgumentException,IllegalStateException,NullPointerException,IndexOutOfBoundsException,ConcurrentModificationException,UnsupportedOperationException)有許多好處:使 API 更易學、更易用、程式碼更清晰、記憶體佔用更少。應根據語義選擇合適的標準異常,而非僅僅根據名稱。如果需要包含額外信息,可以子類別化標準異常(Item 45)。當多種標準異常都適用時,選擇哪種異常可能需要權衡,但通常會有更合適的選擇。 -
項目 43:拋出與抽象層次相對應的異常
不應讓底層抽象拋出的異常直接從高層抽象中傳播出去,因為這會將底層實作細節暴露在高層 API 中,可能導致 API 脆弱且難以理解。高層應捕獲底層異常,並拋出可以根據高層抽象進行解釋的新異常(異常轉譯,exception translation)。例如,底層的NoSuchElementException在 List 的get方法中應轉譯為IndexOutOfBoundsException。如果底層異常對於除錯很有幫助,可以使用異常鏈接(exception chaining),將底層異常作為原因(cause)存儲在高層異常中,並通過getCause方法暴露(Java 1.4+ 支持Throwable(Throwable)建構子)。然而,最好的處理方式是預防底層異常(在呼叫前檢查參數),或在高層默默處理掉(work around)底層異常。 -
項目 44:記錄方法可能拋出的所有異常
精確記錄方法可能拋出的所有異常是正確使用 API 的重要前提。應單獨宣告受檢查異常(不要使用throws Exception),並使用 Javadoc@throws標籤精確記錄拋出的條件。雖然語言不強制要求宣告非檢查異常,但應同樣仔細地記錄它們(使用@throws但不使用throws關鍵字),因為它們描述了方法的成功執行前提條件。在類別級別文檔註解中可以記錄該類別中許多方法都可能拋出的通用非檢查異常(如NullPointerException)。 -
項目 45:在詳細消息中包含失敗捕獲信息
當程式因未捕獲異常失敗時,系統會印出包含異常詳細消息的堆疊追蹤(stack trace)。詳細消息應盡可能包含關於失敗原因的信息,以協助除錯。應包含所有「對異常有貢獻」的參數和欄位的值。例如,IndexOutOfBoundsException的詳細消息應包含下界、上界和實際索引。不需包含冗長的文字描述,重點在於數據。為確保異常包含足夠的失敗捕獲信息,可以在建構子中要求這些信息作為參數,並自動生成詳細消息。雖然 Java 函式庫不常用此慣用法,但它有助於集中生成高品質異常字符串表示。對於受檢查異常,提供訪問方法(accessor)以程式化方式獲取失敗捕獲信息尤其重要,有助於恢復。 -
項目 46:爭取實現失敗原子性(failure atomicity)
方法拋出異常後,物件應盡可能保持在一個明確、可用的狀態,通常是異常發生前的狀態。這稱為失敗原子性。實現失敗原子性的方法:1. 設計不可變物件(Item 13),這可以免費獲得失敗原子性。2. 在操作前檢查參數有效性(Item 23),確保在修改物件前拋出異常。3. 調整計算順序,讓可能失敗的部分在修改物件的部分之前執行。4. 編寫恢復程式碼,在失敗後將物件回滾到操作開始前的狀態(常用於持久化數據結構)。5. 在物件的臨時複製上執行操作,成功後替換物件內容(如Collections.sort)。失敗原子性並非總是可行或必要的,特別是在並發修改或處理Error時。違反失敗原子性時應在 API 文檔中明確說明物件狀態。 -
項目 47:不要忽略異常
忽略異常(如使用空的catch區塊)違背了異常的初衷:強迫處理異常情況。忽略異常會導致程式在錯誤發生時靜默繼續運行,並可能在未來任意時間、與問題源頭無關的程式碼點失敗,使除錯極其困難。應至少在 catch 區塊中記錄或適當地處理異常,或者(對於非檢查異常)讓其傳播以使程式快速失敗,保留除錯信息。只有在極少數情況下(如動畫渲染中的瞬時故障)才可能適當地忽略異常,且應添加註解說明。
第 9 章:執行緒 (Threads)
-
項目 48:同步存取共享的可變數據
synchronized關鍵字不僅用於互斥訪問以防止物件處於不一致狀態,更重要的是,它確保了執行緒之間的可靠通信。當多個執行緒共享可變數據時,所有讀寫該數據的執行緒都必須進行同步。即使單個變數的讀寫是原子的(除了long和double),缺乏同步也無法保證一個執行緒寫入的值對另一個執行緒可見(Java 記憶體模型問題)。generateSerialNumber的例子展示了缺乏同步導致序列號重複。StoppableThread的例子展示了缺乏同步導致停止請求可能無法被執行緒看到。正確的解決方法是對所有對共享可變數據的訪問都使用synchronized。對於某些情況,使用volatile修飾符可以替代普通的同步,以確保可見性,但這是一種進階技術,且適用範圍受 Java 記憶體模型演進的影響。雙重檢查鎖定(double-check locking)慣用法(在getFoo中)通常是錯誤的,因為缺乏同步讀取物件引用無法保證看到物件初始化完成後的最新數據。最佳解決方案通常是避免延遲初始化(Item 37)。如果需要延遲初始化,應使用同步方法或「請求時初始化持有者類別」(initialize-on-demand holder class)慣用法(適用於靜態字段),後者通過類別初始化保證安全且效能接近無同步。 -
項目 49:避免過度同步
過度同步可能導致效能下降、死鎖(deadlock)或非確定性行為。為避免死鎖,絕不在同步方法或區塊內將控制權交給客戶端(不呼叫可覆寫的公共或受保護方法,即執行緒持有鎖時進行「開放呼叫」,open call)。在同步區塊內應盡可能少做事,迅速完成數據的檢查和修改並釋放鎖。將耗時的操作移出同步區塊可以提高並發性。在同步區塊內呼叫可覆寫的外部方法可能導致更嚴重的錯誤,因為該方法可能在鎖保護的不變性暫時無效時被呼叫,破壞不變性。過度同步也可能導致不必要的效能開銷,例如StringBuffer。對於低層抽象,如果主要由單個執行緒使用或作為大型同步物件的組件,應考慮避免內部同步。對於廣泛使用且需要同步的類別,可以提供同步和非同步兩種變體(如 Collections Framework 的包裝器類別)。如果類別或靜態方法依賴可變靜態欄位,必須內部同步。 -
項目 50:絕不在迴圈外部呼叫
wait方法
Object.wait方法用於使執行緒等待某個條件。它必須在鎖定物件的同步區域內被呼叫。呼叫wait的標準慣用法是在while迴圈內部:while (<condition does not hold>) obj.wait();。迴圈用於在等待之前檢查條件(確保活躍性,避免因過早的 notify 而永遠等待),並在等待之後再次檢查條件(確保安全性,應對過早、意外、惡意通知或虛假喚醒)。notifyvsnotifyAll:通常應使用notifyAll,它能喚醒所有等待的執行緒,確保正確性(儘管可能喚醒一些不需喚醒的執行緒,它們會在迴圈中再次等待)。只有當所有等待執行緒等待相同條件且每次只有一個執行緒能從條件滿足中受益時,才可能考慮使用notify作為優化,但這可能犧牲活躍性(如果存在惡意等待的執行緒)。對於僅部分等待執行緒符合條件的數據結構,需要更複雜的通知策略。 -
項目 51:不要依賴執行緒排程器(thread scheduler)
Java 執行緒排程器的具體策略在不同 JVM 實現之間差異很大。健壯、響應快、可移植的多執行緒程式不應依賴這些細節。最好的做法是確保在任何給定時間只有少量執行緒是可運行的,讓執行緒排程器幾乎沒有選擇。這通常通過讓執行緒完成少量工作後使用Object.wait或Thread.sleep等待某個條件或時間來實現。避免忙等待(busy-waiting),這會消耗大量 CPU 資源並降低可移植性。不要試圖通過插入Thread.yield呼叫或調整執行緒優先級來「修復」因為執行緒排程導致的程式問題;這些手段是排程器的提示,效果不可移植,應從根本上重構程式以減少並發可運行的執行緒數量。Thread.yield的主要作用是人工增加程式在測試時的並發性,以暴露潛在的並發錯誤。 -
項目 52:記錄執行緒安全性
類別的執行緒安全性是其契約的重要部分,必須明確記錄。僅僅查看 Javadoc 中是否包含synchronized修飾符是不夠的,因為這是一個實作細節。執行緒安全性有多個級別,應以文字描述類別支持的執行緒安全級別:不可變(immutable,無需同步)、執行緒安全(thread-safe,內部同步)、有條件的執行緒安全(conditionally thread-safe,某些序列操作需外部同步並指定鎖)、執行緒兼容(thread-compatible,呼叫者需外部同步)、執行緒不安全(thread-hostile,即使外部同步也不安全,極少)。記錄有條件的執行緒安全類別時,必須說明需要外部同步的操作序列以及需要獲取的鎖(通常是實例鎖)。為了避免客戶端通過鎖進行拒絕服務攻擊,可以使用私有鎖物件來同步操作。對於設計供繼承的類別(Item 15),使用私有鎖物件尤為重要,避免子類別無意間干擾父類別的同步。 -
項目 53:避免使用執行緒組(thread group)
執行緒組最初設計用於隔離 Applet 出於安全目的,但並未實現該承諾。它們提供的功能(批量應用Thread原語)有限,且部分功能已過時或存在缺陷(如enumerate方法的執行緒安全性問題)。執行緒組被視為一個不成功的實驗,最好忽略其存在。如果需要管理邏輯上的執行緒組,可以使用陣列或集合存儲Thread引用。唯一的例外是ThreadGroup.uncaughtException方法,它在執行緒拋出未捕獲異常時自動呼叫,可用於自定義異常處理邏輯。
第 10 章:序列化 (Serialization)
-
項目 54:謹慎地實現
Serializable介面
實現Serializable看似簡單,但長期成本高昂。一旦實現,物件的序列化形式就成為導出 API 的一部分,未來版本必須兼容。如果接受預設序列化形式,私有和 package-private 欄位也成為事實上的 API,限制了實作彈性。更改內部表示可能導致序列化形式不兼容。實現Serializable還增加了錯誤和安全漏洞的可能性(Item 56),反序列化是一個「隱藏的建構子」,需要確保不變性。增加了測試負擔,需要測試新舊版本之間的兼容性。是否實現Serializable需權衡成本與效益。值類別和集合類別通常適合,活躍實體類別通常不適合。設計供繼承的類別和介面應極少實現或擴展Serializable,否則會加重子類別/實現者的負擔。對於不可序列化的可繼承類別,考慮提供一個可訪問的無參數建構子,以便子類別可以實現序列化。內部類別應避免實現Serializable,但靜態成員類別可以。 -
項目 55:考慮使用自定義序列化形式
不應未經考慮就接受預設序列化形式。預設形式反映了物件的物理表示,而理想的序列化形式應只包含物件的邏輯數據。當物件的物理表示與邏輯內容顯著不同時,使用預設序列化形式有四個缺點:將導出 API 永久綁定到內部表示,可能消耗過多空間和時間,可能導致堆疊溢位。例如,StringList的連結列表結構不應成為序列化形式的一部分。應設計一個反映物件邏輯狀態的自定義序列化形式(如StringList例子中只序列化大小和字串元素),通過實現writeObject和readObject方法來實現。即使使用自定義序列化形式,也應在writeObject和readObject中呼叫defaultWriteObject和defaultReadObject以增強未來兼容性。將不屬於邏輯狀態的欄位標記為transient。對於使用預設序列化形式且有transient欄位的類別,需在readObject中處理這些欄位的初始化。應在所有可序列化類別中宣告一個顯式的serialVersionUID。 -
項目 56:防禦性地編寫
readObject方法
readObject方法實際上是一個公共建構子,必須像真正的建構子一樣檢查參數的有效性(Item 23)並進行防禦性複製(Item 24),以保護類別的不變性和安全性。例如,對於包含可變私有欄位(如Date)的不可變類別(如Period),即使使用預設序列化形式,也必須提供readObject方法,在呼叫defaultReadObject後對可變欄位進行防禦性複製,並檢查不變性。未能進行防禦性複製可能導致客戶端通過獲取內部可變物件的引用來破壞物件的不變性。防禦性複製必須在有效性檢查之前進行,且應避免使用clone進行對客戶端可能子類別化的類型。如果一個類別允許繼承,readObject方法不能直接或間接呼叫可覆寫方法(Item 15)。判斷是否需要顯式readObject的測試:是否能接受一個公共建構子,直接將所有非transient欄位的值賦給欄位而不做任何驗證?如果不能,則需要顯式readObject。 -
項目 57:必要時提供
readResolve方法
readResolve方法用於在反序列化過程中替換新創建的物件。如果一個類別嚴格控制實例創建以保持不變性(如單例或類型安全列舉),則必須提供readResolve方法,返回規範的實例,以保證反序列化不會創建新的、破壞單例/實例控制的實例。readResolve方法也可以作為 Item 56 中防禦性readObject的替代方案。在這個慣用法中,反序列化後的物件被用於獲取數據,然後將數據傳遞給正常的建構子或靜態工廠來創建最終物件,從而確保了不變性。這基本上消除了序列化的「超語言」成分,使其更像通過公共 API 創建物件。這種方法對於禁止在自身 package 外繼承的類別特別有效。readResolve方法的可訪問性很重要:final 類別應使用 private,非 final 類別需仔細考慮以避免子類別問題。
comments
comments for this post are closed