《Effective Java (3rd Edition)》中的最佳實務
摘自 Joshua Bloch 所著的《Effective Java (3rd Edition) 》
1. 回傳空集合或空陣列,而不是 null
如果方法回傳一個 Set,在結果為空時不要 return null;,而應該 return Set.of();。
2. 考慮以靜態工廠方法取代建構子
public static Boolean valueOf(boolean b) {
return b ? Boolean.TRUE : Boolean.FALSE;
}
不一定總是比較好,但在某些情境下,它們有許多優點:
- 靜態工廠方法的一大優點是:它們和建構子不同,是有名稱的。
- 它們不必在每次被呼叫時都建立新物件。
- 它們可以回傳其回傳型別的任何子型別物件。
- 回傳物件的類別可以隨著輸入參數的不同而改變。
- 在撰寫包含該方法的類別時,回傳物件的類別甚至可以還不存在。
缺點:
- 只提供靜態工廠方法的主要限制在於:沒有 public 或 protected 建構子的類別無法被繼承。
- 靜態工廠方法的第二個缺點是,程式設計師很難找到它們。
3. 遇到許多建構子參數時,考慮使用建造者(Builder)
靜態工廠和建構子有一個共同的限制:當選用參數很多時,它們都無法妥善擴充。我參與過的一些專案就深受其害。
4. 以私有建構子或列舉型別強制單例特性
凡是不需要多個實例的東西,都應該只建立一次,無論是透過 @ApplicationScoped 還是其他單例模式。
5. 以私有建構子強制不可實例化
有時你會想寫一個只包含一組靜態方法和靜態欄位的類別。這種類別名聲不太好,因為有些人濫用它們來逃避以物件的方式思考,但它們確實有正當的用途。
private MyStaticClass() {
// private constructor to prevent instantiation
}
6. 避免建立不必要的物件
String s = new String("bikini"); // DON'T DO THIS!
或者:
// Hideously slow! Can you spot the object creation?
private static long sum() {
Long sum = 0L;
for (long i = 0; i <= Integer.MAX_VALUE; i++)
sum += i;
return sum;
}
答案:這個程式算出的結果是對的,但因為一個字元的打字錯誤,它比應有的速度慢得多。變數 sum 被宣告成 Long 而不是 long,這表示程式建立了大約 2^31 個不必要的 Long 實例(大致上每次把 long i 加到 Long sum 時就會建立一個)。
7. 避免使用 finalizer 與 cleaner
Finalizer 無法預測、往往很危險,而且通常沒有必要。使用它們可能導致行為異常、效能低落以及可攜性問題。
8. 優先使用 try-with-resources,而非 try-finally
// try-with-resources - the best way to close resources!
try (BufferedReader br = new BufferedReader(new FileReader(path))) {
return br.readLine();
}
9. 考慮實作 Comparable
實作 Comparable 之後,你的類別就能與仰賴這個介面的眾多泛型演算法和集合實作互相搭配。
10. 將類別與成員的存取範圍降到最低
區分一個元件設計得好壞,最重要的一個因素就是:它在多大程度上對其他元件隱藏了自己的內部資料和其他實作細節。設計良好的元件會隱藏所有實作細節,把 API 與實作清楚地區隔開來。元件之間只透過 API 溝通,彼此對對方的內部運作一無所知。這個概念稱為資訊隱藏或封裝,是軟體設計的一項基本原則。
11. 在公開類別中使用存取方法,而不是公開欄位
有時你可能會忍不住寫出一些退化的類別,它們除了把實例欄位歸在一起之外,沒有任何用途:
// Degenerate classes like this should not be public!
class Point {
public double x;
public double y;
}
由於這類類別的資料欄位是被直接存取的,它們無法提供封裝的好處。不修改 API 就無法改變資料的表示方式,無法強制維持不變條件,也無法在存取欄位時執行額外的動作。立場強硬的物件導向程式設計師認為這種類別是大忌,應該一律改用擁有私有欄位和公開存取方法(getter)的類別,對於可變類別,還要提供修改方法(setter):
// Encapsulation of data by accessor methods and mutators
class Point {
private double x;
private double y;
public Point(double x, double y) {
this.x = x;
this.y = y;
}
public double getX() { return x; }
public double getY() { return y; }
public void setX(double x) { this.x = x; }
public void setY(double y) { this.y = y; }
}
當然,就公開類別而言,強硬派是對的:如果一個類別可以在其套件之外被存取,就應該提供存取方法,以保留日後修改類別內部表示方式的彈性。如果一個公開類別暴露了自己的資料欄位,那麼修改其表示方式的希望就徹底破滅了,因為用戶端程式碼可能早已散布到各個角落。
不過,如果一個類別是套件私有的,或是私有的巢狀類別,那麼直接暴露它的資料欄位本身並沒有什麼不對,前提是這些欄位足以描述該類別所提供的抽象概念。
12. 將可變性降到最低
不可變類別就是其實例無法被修改的類別。每個實例所包含的所有資訊在物件的整個生命週期內都是固定的,因此永遠不會觀察到任何變化。 Java 平台函式庫中有許多不可變類別,例如 String、基本型別的包裝類別,以及 BigInteger 和 BigDecimal。這麼做有很多充分的理由:不可變類別比可變類別更容易設計、實作和使用。它們更不容易出錯,也更安全。要讓一個類別成為不可變類別,請遵循以下五條規則:
- 不要提供任何會修改物件狀態的方法(也就是所謂的修改方法,mutator)。
-
確保類別無法被擴充。這能防止粗心或惡意的子類別假裝物件的狀態已經改變,進而破壞該類別的不可變行為。防止子類別化的常見做法,是把類別宣告為
final。 - 將所有欄位宣告為 final。這能以系統強制的方式清楚表達你的意圖。此外,正如記憶體模型中所闡明的,如果一個指向新建立實例的參考在沒有同步的情況下從一條執行緒傳遞到另一條執行緒,就必須這樣做才能確保行為正確。
- 將所有欄位宣告為私有。這能防止用戶端取得欄位所參考的可變物件,並直接修改這些物件。雖然在技術上,不可變類別可以擁有包含基本型別值或不可變物件參考的 public final 欄位,但並不建議這麼做,因為這會讓你在日後的版本中無法改變內部表示方式。
-
確保對任何可變元件的獨占存取。如果類別中有欄位參考了可變物件,就要確保該類別的用戶端無法取得這些物件的參考。永遠不要用用戶端提供的物件參考來初始化這種欄位,也不要從存取方法中回傳該欄位。在建構子、存取方法和
readObject方法中使用防禦性複製。
13. 組合優於繼承
繼承是達成程式碼重用的強大手段,但它並不總是最適合這項工作的工具。使用不當會讓軟體變得脆弱。在同一個套件內使用繼承是安全的,因為子類別和父類別的實作都由同一批程式設計師掌控。對於專門為擴充而設計、並有完善文件的類別,使用繼承也是安全的。然而,跨越套件邊界去繼承一般的具體類別,則是危險的。
與方法呼叫不同,繼承會破壞封裝。你也許會認為,如果只是新增方法、而不覆寫現有方法,擴充一個類別就是安全的。雖然這種擴充安全得多,但也並非毫無風險。如果父類別在後續版本中新增了一個方法,而你不巧已經在子類別中寫了一個簽章相同、回傳型別不同的方法,那麼你的子類別將無法再通過編譯。如果你在子類別中寫的方法與新的父類別方法簽章和回傳型別都相同,那你現在就是在覆寫它,於是又會碰上前面提到的那些問題。此外,你的方法能否滿足新父類別方法的契約也很令人懷疑,因為在你撰寫子類別方法時,這份契約根本還沒寫出來。
幸好,有個辦法可以避開上述所有問題。不要擴充現有的類別,而是在新類別中加入一個私有欄位,讓它參考現有類別的一個實例。這種設計稱為組合,因為現有的類別成了新類別的一個元件。新類別中的每個實例方法都會呼叫所包含的現有類別實例上對應的方法,並回傳其結果。這稱為轉發,新類別中的這些方法則稱為轉發方法。這樣得到的類別將堅若磐石,不依賴現有類別的任何實作細節。即使現有的類別新增了方法,也不會對新類別造成任何影響。
14. 介面優於抽象類別
Java 有兩種機制可以定義允許多種實作的型別:介面和抽象類別。自從 Java 8 為介面引入預設方法以來,這兩種機制都允許你為某些實例方法提供實作。
一個主要差異在於:要實作由抽象類別定義的型別,類別必須成為該抽象類別的子類別。由於 Java 只允許單一繼承,抽象類別的這項限制嚴重局限了它們作為型別定義的用途。而任何定義了所有必要方法並遵守一般契約的類別,都可以實作一個介面,無論這個類別位於類別階層中的哪個位置。現有的類別可以很輕鬆地加以改造,以實作新的介面。
- 介面是定義 mixin(混入型別)的理想選擇。
- 介面可以用來建構非階層式的型別框架。
- 介面能夠安全而有力地強化功能,方法是透過包裝類別慣用法。如果你用抽象類別來定義型別,那麼想要新增功能的程式設計師除了繼承之外別無選擇。
15. 泛型:不要使用原始型別
直到執行期你才會發現這個錯誤,那時它早已發生很久,而且出錯的地方可能離含有錯誤的程式碼很遠。一旦看到 ClassCastException,你就得在整個程式碼庫裡搜尋,找出是哪一次方法呼叫把硬幣放進了郵票收藏裡。編譯器幫不了你。
16. 泛型:消除未檢查警告
用泛型寫程式時,你會看到許多編譯器警告:
- 未檢查的轉型(unchecked cast)警告
- 未檢查的方法呼叫警告
- 未檢查的參數化可變長度參數型別警告
- 未檢查的型別轉換(unchecked conversion)警告。
你對泛型累積的經驗越多,遇到的警告就越少,但別指望新寫的程式碼能乾乾淨淨地通過編譯。許多未檢查警告都很容易消除。例如,假設你不小心寫了這樣的宣告:
Set<Lark> exaltation = new HashSet();
把它改成:
Set<Lark> exaltation = new HashSet<>();
有些警告則難消除得多。遇到需要動腦的警告時,請堅持下去!盡可能消除每一個未檢查警告。
如果消除了所有警告,你就能確信程式碼是型別安全的,這是件非常好的事。這表示你在執行期不會遇到 ClassCastException,也讓你更有信心,相信程式會照你的意圖運作。如果無法消除某個警告,但你能證明引發警告的程式碼是型別安全的,那麼(而且只有在這種情況下)才可以用 @SuppressWarnings("unchecked") 標註來抑制該警告。如果沒有先證明程式碼是型別安全的就抑制警告,那只是給了自己一種虛假的安全感。程式碼也許在編譯時不會發出任何警告,但在執行期仍然可能拋出 ClassCastException。
每次使用 @SuppressWarnings("unchecked") 標註時,都要加上一則註解,說明為什麼這樣做是安全的。
17. 泛型:在合適的地方使用
哎唷,一個亟需泛型的類別:
這種程式碼最晚也該在合併請求(merge request)階段被擋下來!
18. 泛型:錯誤示範
抱歉,不必多說。請千萬不要合併這種程式碼:
public abstract class JpaLearningCardBaseContentService<LC extends LearningCardBaseContent<L, LC, AC>, J extends JpaLearningCardBaseContent<L, LC, AC>, L extends
LearningCardBase<LC, A>, A extends AnswerOptionBase<L, AC>, AC extends AnswerOptionBaseContent<LC, AC, A>, AS extends AnswerOptionBaseService<A, AC, L, LC>, ACS extends
AnswerOptionBaseContentService<AC, A, L, LC>, LS extends LearningCardBaseService<L>>
extends JpaLightPersistenceService<LC, J, Long> implements LearningCardBaseContentService<LC, L, A, AC> {
// ...
}
光是一個類別宣告,絕不該花 5 分鐘才看得懂。
19. 清單優於陣列
陣列和泛型型別有兩個重要的差異。第一,陣列是共變的。這個聽起來很嚇人的詞其實只是表示:如果 Sub 是 Super 的子型別,那麼陣列型別 Sub[] 就是陣列型別 Super[] 的子型別。相反地,泛型是不變的:對於任意兩個不同的型別 Type1 和 Type2,List<Type1> 既不是 List<Type2> 的子型別,也不是它的父型別。
20. Lambda 優於匿名類別
// Anonymous class instance as a function object - obsolete!
Collections.sort(words, new Comparator<String>() {
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});
應該這樣寫:
// Lambda expression as function object (replaces anonymous class)
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
21. 將區域變數的作用範圍降到最低
要將區域變數的作用範圍降到最低,最有效的方法就是在第一次使用它的地方才宣告……
22. for-each 迴圈優於傳統的 for 迴圈
// The preferred idiom for iterating over collections and arrays
for (Element e : elements) {
... // Do something with e
}
23. 了解並善用函式庫
假設你想產生介於 0 和某個上限之間的隨機整數。面對這個常見的任務,很多程式設計師會寫一個類似這樣的小方法:
// Common but deeply flawed!
static Random rnd = new Random();
static int random(int n) {
return Math.abs(rnd.nextInt()) % n;
}
這個方法看起來不錯,但它有三個缺陷。第一,如果 n 是一個比較小的 2 的次方,亂數序列會在相當短的週期後開始重複。第二,如果 n 不是 2 的次方,那麼平均而言,某些數字會比其他數字更常被回傳。如果 n 很大,這種效應會相當明顯。
random 方法的第三個缺陷是:在極少數情況下,它會發生災難性的失敗,回傳一個超出指定範圍的數字。這是因為該方法試圖透過呼叫 Math.abs,把 rnd.nextInt() 回傳的值對應到一個非負的 int。如果 nextInt() 回傳Integer.MIN_VALUE,那麼 Math.abs 也會回傳 Integer.MIN_VALUE,此時假設n 不是 2 的次方,餘數運算子(%)就會回傳一個負數。這幾乎肯定會讓你的程式失敗,而且這種失敗可能很難重現。
24. 需要精確答案時,請避免使用 float 和 double
處理金額時請使用 BigDecimal。float 和 double 會產生捨入誤差。
例如,假設你口袋裡有 1.03 美元,花掉了 42 美分。你還剩多少錢?下面這段天真的程式片段試圖回答這個問題:
System.out.println(1.03 - 0.42);
可惜的是,它印出的是 0.6100000000000001。這並不是特例。假設你口袋裡有 1 美元,買了九個單價 10 美分的墊圈。你能找回多少零錢?
System.out.println(1.00 - 9 * 0.10);
根據這段程式片段,你會拿到 $0.09999999999999998。
你可能會認為,只要在印出之前先對結果做捨入就能解決問題,但可惜這麼做並不總是有效。例如,假設你口袋裡有 1 美元,看到架上擺著一排美味的糖果,標價分別是 10 美分、20 美分、30 美分,依此類推,一直到 1 美元。你從 10 美分的那顆開始,每種糖果各買一顆,直到買不起架上的下一顆為止。
25. 基本型別優於包裝後的基本型別
// Broken comparator - can you spot the flaw?
Comparator<Integer> naturalOrder = (i, j) -> (i < j) ? -1 : (i == j ? 0 : 1);
26. 透過介面參考物件
好的範例:
Set<Son> sonSet = new LinkedHashSet<>();
不好的範例:
LinkedHashSet<Son> sonSet = new LinkedHashSet<>();
27. 介面優於反射
否則:
- 你會失去編譯期型別檢查的所有好處,包括例外檢查。如果程式試圖透過反射呼叫一個不存在或無法存取的方法,除非你事先採取了特別的防範措施,否則它會在執行期失敗。
- 執行反射存取所需的程式碼既笨拙又冗長。寫起來乏味,讀起來也吃力。
- 效能會受影響。透過反射呼叫方法比一般的方法呼叫慢得多。
28. 遵守普遍公認的命名慣例
Java 平台有一套完善的命名慣例,其中許多都收錄在《Java 語言規格》(The Java Language Specification)[JLS, 6.1] 中。
29. 只在例外狀況下使用例外
// Horrible abuse of exceptions. Don't ever do this!
try {
int i = 0;
while (true) {
range[i++].climb();
}
} catch (ArrayIndexOutOfBoundsException e) {
// catch it
}
這段程式碼在做什麼?光看完全看不出來,單憑這一點就足以不用它了。原來,這是一種構思極其拙劣的走訪陣列元素的慣用寫法。當它試圖存取陣列邊界外的第一個元素時,會拋出、捕捉並忽略一個 ArrayIndexOutOfBoundsException,藉此結束這個無窮迴圈。它原本應該等同於走訪陣列的標準慣用寫法,而任何 Java 程式設計師都能一眼認出後者:
for (Mountain m : range) {
m.climb();
}
顧名思義,例外只應該用於例外狀況;它們永遠不該被用於一般的控制流程。
30. 可恢復的狀況使用受檢例外,程式錯誤使用執行期例外
如果可以合理期待呼叫端能從某種狀況中恢復,就使用受檢例外。透過向使用者拋出受檢例外,API 設計者等於下達了一道必須從該狀況中恢復的指令。使用者可以捕捉例外後置之不理,藉此無視這道指令,但這通常不是個好主意。
用執行期例外來表示程式錯誤。絕大多數執行期例外都代表違反了前置條件。違反前置條件,就是 API 的用戶端沒有遵守 API 規格所訂立的契約。例如,陣列存取的契約規定,陣列索引必須介於 0 和陣列長度減 1 之間(含兩端)。ArrayIndexOutOfBoundsException 就表示違反了這項前置條件。
31. 避免不必要地使用受檢例外
總而言之,審慎地使用受檢例外可以提高程式的可靠性;而過度使用則會讓 API 用起來十分痛苦。
Pair<Boolean, LearningCardGroup> answerLearningCard(@NotNull LearningCardGroup learningCardGroup,
LearningCardAssignment learningCardAssignment,
@NotNull Collection<AnswerOption> answers)
throws InvalidTrainingStateException, PersistenceException, LearningCardGroupAlreadyCompletedException,
ConsecutiveCorrectCountExceededException,
PreviousLearningCardGroupUncompletedException, LearningCardGroupCooldownException,
LearningCardGroupHasUncompletedLearningCardsException, InvalidParticipantException, TrainingNotSignedException; // but why ???
// this is a mistake seen a lot with junior devs
// exceptions are exceptionally slow and should be avoided for mere validation
// the performance gets even worse if a stacktrace is printed, since logging and printing is one of the slowest possible actions
32. 優先使用標準例外
專家級程式設計師與經驗不足的程式設計師的一個差別在於:專家們會追求、而且通常也能做到高度的程式碼重用。
程式碼重用是件好事,例外也不例外。Java 函式庫提供了一組例外,涵蓋了大多數 API 在拋出例外方面的絕大部分需求。重用標準例外有幾個好處。其中最主要的是,它讓你的 API 更容易學習和使用,因為它符合程式設計師早已熟悉的既定慣例。緊接在後的好處是,使用你的 API 的程式更容易閱讀,因為其中不會充斥著陌生的例外。最後(也是最不重要的),例外類別越少,記憶體用量就越小,載入類別所花的時間也越少。
最常被重用的例外型別是 IllegalArgumentException。當呼叫端傳入的參數值不恰當時,通常就應該拋出這個例外。例如,如果某個參數代表某項動作的重複次數,而呼叫端傳入了一個負數,就應該拋出這個例外。另一個經常被重用的例外是IllegalStateException。如果因為接收物件的狀態而使呼叫不合法,通常就應該拋出這個例外。例如,如果呼叫端試圖在某個物件被正確初始化之前就使用它,就應該拋出這個例外。
可以說,所有錯誤的方法呼叫歸根究柢都是不合法的參數或狀態,但對於某些特定類型的不合法參數和狀態,還有其他約定俗成的例外。如果呼叫端在某個禁止傳入 null 的參數中傳入了 null,依照慣例應該拋出 NullPointerException,而不是 IllegalArgumentException。同樣地,如果呼叫端在代表序列索引的參數中傳入了超出範圍的值,就應該拋出 IndexOutOfBoundsException,而不是 IllegalArgumentException。
不要直接重用 Exception、RuntimeException、Throwable 或 Error。請把這些類別當成抽象類別來看待。不過,如果你想把受檢例外轉換成執行期例外,而且確實清楚自己在做什麼,也可以使用 RuntimeException。
33. 不要忽略例外
// Empty catch block ignores exception - Highly suspect!
try {
...
} catch (SomeException e) {
}
空的 catch 區塊會讓例外失去存在的意義,而例外的意義就在於強迫你處理例外狀況。忽略例外就好比忽略火災警報,而且還把它關掉,讓其他人再也沒有機會確認是否真的失火了。