10個精妙的Java編碼最佳實踐

importnew發表於2015-06-23

這是一個比Josh Bloch的Effective Java規則更精妙的10條Java編碼實踐的列表。和Josh Bloch的列表容易學習並且關注日常情況相比,這個列表將包含涉及API/SPI設計中不常見的情況,可能有很大影響。

我在編寫和維護jOOQ(Java中內部DSL建模的SQL)時遇到過這些。作為一個內部DSL,jOOQ最大限度的挑戰了Java的編譯器和泛型,把泛型,可變引數和過載結合在一起,Josh Bloch可能不會推薦的這種太寬泛的API。

讓我與你分享10個微妙的Java編碼最佳實踐:

1. 牢記C++的解構函式

記得C++的解構函式?不記得了?那麼你真的很幸運,因為你不必去除錯那些由於物件刪除後分配的記憶體沒有被釋放而導致記憶體洩露的程式碼。感謝Sun/Oracle實現的垃圾回收機制吧!

儘管如此,解構函式仍提供了一個有趣的特徵。它理解逆分配順序釋放記憶體。記住在Java中也是這樣的,當你操作類解構函式語法:

  • 使用JUnit的@Before和@After註釋
  • 分配,釋放JDBC資源
  • 呼叫super方法

還有其他各種用例。這裡有一個具體的例子,說明如何實現一些事件偵聽器的SPI:

@Override
public void beforeEvent(EventContext e) {
    super.beforeEvent(e);
    // Super code before my code
}

@Override
public void afterEvent(EventContext e) {
    // Super code after my code
    super.afterEvent(e);
}

臭名昭著的哲學家就餐問題是另一個說明它為什麼重要的好例子。 關於哲學家用餐的問題,請檢視連結:

http://adit.io/posts/2013-05-11-The-Dining-Philosophers-Problem-With-Ron-Swanson.html

規則:無論何時使用before/after, allocate/free, take/return語義實現邏輯時,考慮是否逆序執行after/free/return操作。

2. 不要相信你早期的SPI演進判斷

向客戶提供SPI可以使他們輕鬆的向你的庫/程式碼中注入自定義行為的方法。當心你的SPI演進判斷可能會迷惑你,使你認為你 (不)打算需要附加引數。 當然,不應當過早增加功能。但一旦你釋出了你的SPI,一旦你決定遵循語義版本控制,當你意識到在某種情況下你可能需要另外一個引數時,你會真的後悔在SPI中增加一個愚蠢的單引數的方法:

interface EventListener {
    // Bad
    void message(String message);
}

如果你也需要訊息ID和訊息源,怎麼辦?API演進將會阻止你向上面的型別新增引數。當然,有了Java8,你可以新增一個defender方法,“防禦”你早期糟糕的設計決策:

interface EventListener {
    // Bad
    default void message(String message) {
        message(message, null, null);
    }
    // Better?
    void message(
        String message,
        Integer id,
        MessageSource source
    );
}

注意,不幸的是,defender方法不能使用final修飾符。

但是比起使用許多方法汙染你的SPI,使用上下文物件(或者引數物件)會好很多。

interface MessageContext {
    String message();
    Integer id();
    MessageSource source();
}

interface EventListener {
    // Awesome!
    void message(MessageContext context);
}

比起EventListner SPI你可以更容易演進MessageContext API,因為很少使用者會實現它。

規則: 無論何時指定SPI時,考慮使用上下文/引數物件,而不是寫帶有固定引數的方法。

備註: 通過專用的MessageResult型別交換結果也是一個好主意,該型別可以使用建設者API構造它。這樣將大大增加SPI進化的靈活性。

3. 避免返回匿名,本地或者內部類

Swing程式設計師通常只要按幾下快捷鍵即可生成成百上千的匿名類。在多數情況下,只要遵循介面、不違反SPI子型別的生命週期(SPI subtype lifecycle),這樣做也無妨。 但是不要因為一個簡單的原因——它們會儲存對外部類的引用,就頻繁的使用匿名、區域性或者內部類。因為無論它們走到哪,外部類就得跟到哪。例如,在區域性類的域外操作不當的話,那麼整個物件圖就會發生微妙的變化從而可能引起記憶體洩露。

規則:在編寫匿名、區域性或內部類前請三思能否將它轉化為靜態的或普通的頂級類,從而避免方法將它們的物件返回到更外層的域中。

注意:使用雙層花括號來初始化簡單物件:

new HashMap<String, String>() {{
  put("1", "a");
  put("2", "b");
}}

這個方法利用了 JLS §8.6規範裡描述的例項初始化方法(initializer)。表面上看起來不錯,但實際上不提倡這種做法。因為要是使用完全獨立的HashMap物件,那麼例項就不會一直儲存著外部物件的引用。此外,這也會讓類載入器管理更多的類。

4. 現在就開始編寫SAM!

Java8的腳步近了。伴隨著Java8帶來了lambda表示式,無論你是否喜歡。儘管你的API使用者可能會喜歡,但是你最好確保他們可以儘可能經常的使用。因此除非你的API接收簡單的“標量”型別,比如int、long、String 、Date,否則讓你的API儘可能經常的接收SAM。

什麼是SAM?SAM是單一抽象方法[型別]。也稱為函式介面,不久會被註釋為@FunctionalInterface。這與規則2很配,EventListener實際上就是一個SAM。最好的SAM只有一個引數,因為這將會進一步簡化lambda表示式的編寫。設想編寫

listeners.add(c -> System.out.println(c.message()));

來替代

listeners.add(new EventListener() {
  @Override
  public void message(MessageContext c) {
    System.out.println(c.message()));
  }
});

設想以JOOX的方式來處理XML。JOOX就包含很多的SAM:

$(document)
  // Find elements with an ID
  .find(c -> $(c).id() != null)
  // Find their child elements
  .children(c -> $(c).tag().equals("order"))
  // Print all matches
  .each(c -> System.out.println($(c)))

規則:對你的API使用者好一點兒,從現在開始編寫SAM/函式介面。

備註:有許多關於Java8 lambda表示式和改善的Collections API的有趣的部落格:

5.避免讓方法返回null

我曾寫過1、2篇關於java NULLs的文章,也講解過Java8中引入新的Optional類。從學術或實用的角度來看,這些話題還是比較有趣的。

儘管現階段Null和NullPointerException依然是Java的硬傷,但是你仍可以設計出不會出現任何問題的API。在設計API時,應當儘可能的避免讓方法返回null,因為你的使用者可能會鏈式呼叫方法:

initialise(someArgument).calculate(data).dispatch();

從上面程式碼中可看出,任何一個方法都不應返回null。實際上,在通常情況下使用null會被認為相當的異類。像 jQueryjOOX這樣的庫在可迭代的物件上已完全的摒棄了null。

Null通常用在延遲初始化中。在許多情況下,在不嚴重影響效能的條件下,延遲初始化也應該被避免。實際上,如果涉及的資料結構過於龐大,那麼就要慎用延遲初始化。

規則:無論何時方法都應避免返回null。null僅用來表示“未初始化”或“不存在”的語義。

6.設計API時永遠不要返回空(null)陣列或List

儘管在一些情況下方法返回值為null是可以的,但是絕不要返回空陣列或空集合!請看 java.io.File.list()方法,它是這樣設計的:

此方法會返回一個指定目錄下所有檔案或目錄的字串陣列。如果目錄為空(empty)那麼返回的陣列也為空(empty)。如果指定的路徑不存在或發生I/O錯誤,則返回null。

因此,這個方法通常要這樣使用:

File directory = // ...

if (directory.isDirectory()) {
  String[] list = directory.list();

  if (list != null) {
    for (String file : list) {
      // ...
    }
  }
}
大家覺得null檢查有必要嗎?大多數I/O操作會產生IOExceptions,但這個方法卻只返回了null。Null是無法存放I/O錯誤資訊的。因此這樣的設計,有以下3方面的不足:
  • Null無助於發現錯誤
  • Null無法表明I/O錯誤是由File例項所對應的路徑不正確引起的
  • 每個人都可能會忘記判斷null情況

以集合的思維來看待問題的話,那麼空的(empty)的陣列或集合就是對“不存在”的最佳實現。返回空(null)陣列或集合幾乎是無任何實際意義的,除非用於延遲初始化。

規則:返回的陣列或集合不應為null。

7. 避免狀態,使用函式

HTTP的好處是無狀態。所有相關的狀態在每次請求和響應中轉移。這是REST命名的本質:含狀態傳輸(Representational state transfer)。在Java中這樣做也很贊。當方法接收狀態引數物件的時候從規則2的角度想想這件事。如果狀態通過這種物件傳輸,而不是從外邊操作狀態,那麼事情將會更簡單。以JDBC為例。下述例子從一個儲存的程式中讀取一個游標。

CallableStatement s =
  connection.prepareCall("{ ? = ... }");

// Verbose manipulation of statement state:
s.registerOutParameter(1, cursor);
s.setString(2, "abc");
s.execute();
ResultSet rs = s.getObject(1);

// Verbose manipulation of result set state:
rs.next();
rs.next();

這使得JDBC API如此的古怪。每個物件都是有狀態的,難以操作。具體的說,有兩個主要的問題:

  • 在多執行緒環境很難正確的處理有狀態的API
  • 很難讓有狀態的資源全域性可用,因為狀態沒有被描述

規則:更多的以函式風格實現。通過方法引數轉移狀態。極少操作物件狀態。

8. 短路式 equals()

這是一個比較容易操作的方法。在比較複雜的物件系統中,你可以獲得顯著的效能提升,只要你在所有物件的equals()方法中首先進行相等判斷:

@Override
public boolean equals(Object other) {
  if (this == other) return true;
  // 其它相等判斷邏輯...
}

注意,其它短路式檢查可能涉及到null值檢查,所以也應當加進去:

@Override
public boolean equals(Object other) {
  if (this == other) return true;
  if (other == null) return false;
  // Rest of equality logic...
}

規則: 在你所有的equals()方法中使用短路來提升效能。

9. 儘量使方法預設為final

有些人可能不同意這一條,因為使方法預設為final與Java開發者的習慣相違背。但是如果你對程式碼有完全的掌控,那麼使方法預設為final是肯定沒錯的:

  • 如果你確實需要覆蓋(override)一個方法(你真的需要?),你仍然可以移除final關鍵字
  • 你將永遠不會意外地覆蓋(override)任何方法

這特別適用於靜態方法,在這種情況下“覆蓋”(實際上是遮蔽)幾乎不起作用。我最近在Apache Tika中遇到了一個很糟糕的遮蔽靜態方法的例子。看一下:

TikaInputStream擴充套件了TaggedInputStream,以一種相對不同的實現遮蔽了它的靜態get()方法。

與常規方法不同,靜態方法不能互相覆蓋,因為呼叫的地方在編譯時就繫結了靜態方法呼叫。如果你不走運,你可能會意外獲得錯誤的方法。

規則:如果你完全掌控你的API,那麼使盡可能多的方法預設為final。

10. 避免方法(T…)簽名

在特殊場合下使用“accept-all”變數引數方法接收一個Object…引數就沒有錯的:

void acceptAll(Object... all);

編寫這樣的方法為Java生態系統帶來一點兒JavaScript的感覺。當然你可能想要根據真實的情形限制實際的型別,比如String…。因為你不想要限制太多,你可能會認為用泛型T取代Object是一個好想法:

void acceptAll(T... all);

但是不是。T總是會被推斷為Object。實際上你可能僅僅認為上述方法中不能使用泛型。更重要的是你可能認為你可以過載上述方法,但是你不能:

void acceptAll(T... all);
void acceptAll(String message, T... all);

這看起來好像你可以可選地傳遞一個String訊息到方法。但是這個呼叫會發生什麼呢?

acceptAll("Message", 123, "abc");

編譯器將T推斷為<? extends Serializable & Comparable<?>>,這將會使呼叫不明確!

所以無論何時你有一個“accept-all”簽名(即使是泛型),你將永遠不能型別安全地過載它。API使用者可能僅僅在走運的時候才會讓編譯器“偶然地”選擇“正確的”方法。但是也可能使用accept-all方法或者無法呼叫任何方法。

規則: 如果可能,避免“accept-all”簽名。如果不能,不要過載這樣的方法。

結論

Java是一個野獸。不像其它更理想主義的語言,它慢慢地演進為今天的樣子。這可能是一件好事,因為以Java的開發速度就已經有成百上千個警告,而且這些警告只能通過多年的經驗去把握。

敬請期待更多關於這個主題的前十名列表!

相關文章