Java程式設計細節之十個最佳實踐

importnew發表於2014-01-23

  本文講述的十個最佳實踐,這十個最佳實踐要比通常Josh Bloch Effective Java規範更加細緻。Josh Bloch的清單很容易學習,考慮的多是日常的情形,而本文則包括了不常見的情形例如API或SPI設計,儘管不常見,他們卻可能有著大的影響。

  譯註:Java SPI (Service Provider Interface)是針對廠商或者外掛提供的介面,提供類似“Callback”的功能,實現對API的定製。關於SPI的詳細資訊可以參見java.util.ServiceLoader文件

  我在開發和維護JOOQ的過程中遇見過這類事情,JOOQ是一個用Java模擬SQL的 internal DSL(Domain Specific Language). 作為一個internal DSL, JOOQ挑戰了Java編譯器和泛型的極限,它結合泛型,可變引數和過載的方式恐怕是Josh Bloch在“average API”中不做推薦的。

  讓我來告訴你這10個Java程式設計細節的最佳實踐。

  1、記住C++的解構函式

  要記住C++的解構函式(C++ destructors)?不想這樣做?那你得很幸運以至於從來不需要除錯那些由於分配記憶體而沒有在物件移除後釋放記憶體而導致記憶體洩露的程式碼。感謝Sun/Oracle實現了垃圾回收機制。

  不過儘管如此,解構函式有一個有趣的特徵。通常按照分配的逆序來釋放記憶體是有道理的。在Java中也記著這一點,在你操作類似於解構函式的語義時

  • JUnit 註釋中使用@Before和@After時
  • 在分配和釋放JDBC資源時
  • 在呼叫父類方法時

  有多種例項。下面是一個具體的例子,它展示瞭如何實現事件監聽器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);
}

  這裡如果你還需要一個message ID和一個message source怎麼辦?API演化可以阻止你輕易的給上面這個類新增引數。誠然,在java8中,你可以新增一個defender方法,來’defend’你早期的壞的設計決定。

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

  請注意,不幸的是defender方法不能定義為final

  不過這跟用一堆方法汙染你的api相比,已經很不錯了,在這裡可以使用一個上下文物件或者引數物件

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

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

  相對於EventListener SPI而言,你可以更容易地擴充套件MessageContext API,很少有人會實現它(指EventListener)。

  規則:無論何時當你要指定一個SPI,考慮使用上下文物件或者引數物件,而不要寫有固定數量引數的方法。

  備註:同時,通過特定的MessageRsult類來傳遞結果通常是一個好主意,這種類可以使用builder API來建立。這會給你的SPI增添更多的可擴充套件性。

  3、避免返回匿名類,區域性類或者內部類

  Swing開發者大概有很多快捷鍵來為他們成百上千個匿名類建立程式碼。在很多情況下,建立這些程式碼並不難因為你可以將他們依附於介面,而不用自找麻煩來思考整個SPI子類的生命週期。

  但是你不應該太頻繁的使用匿名類,區域性類或內部類,原因很簡單,他們給每一個外部例項保留一個引用。他們無論走到哪裡都會帶著這個外部例項,例如如果你不注意,他們會帶到區域性類以外的範圍。這可能會成為一個主要的記憶體洩露點,因為你的整個物件圖譜將會突然以一種不被發覺的方式變得混亂起來。

  規則:當你需要寫匿名類,區域性類,或者內部類時,看看能不能把他設為靜態甚至是常規的頂層類。避免從方法中向外層返回匿名類,區域性類和內部類。

  備註:有很多明智的關於雙花括號給簡單物件例項化的實踐:

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

  這裡利用了JLS8.6中提到的Java例項初始化器。看起來不錯(或許有點怪),但實際上是非常差的主意。不然怎麼會有這個完全獨立的HashMap例項,這個為外層例項儲存一個引用的例項,只是碰巧也無所謂。而且,你還得建立額外的類給類的裝載器來管理。

  4、開始寫SAMs吧(single abstract method)單個抽象方法

  Java8已經在敲門了。無論你喜歡與否,Java8會帶來lambdas表示式。但你的API使用者可能會喜歡他們,你最好是確認他們可以儘量多的使用這些。因此,如果你的API不接收簡單的“標量”型別類如int, long, String, Date, 那麼就讓你的API儘量多地接收SAMs吧。

  什麼是SAM? SAM是Single Abstract Method[Type], 即單一抽象方法,也稱作函式式介面(Functional Interface)。它即將使用@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,這裡會有一些SAMs特色:

$(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)))

  規則:好好對待你的使用者,現在就開始寫SAMs/Functional 介面吧。

  備註:這裡有一些關於Java8 Lambda和改善的Collection API的文章:

  5、避免在API方法中返回null

  我已經寫過幾次關於Java NULL的部落格了。我也寫過關於Java8中引入Optional的文章。這些有趣的話題都是從學術和實踐兩方面而來的。

  雖然Nulls和NullPointerException在一段時期內還會是Java的一大頭痛難題,但你仍可以設計一種API使得你的使用者不會遇到任何問題。儘量試著避免在API方法中返回null。你的API使用者應該無論合適都可以串聯方法:

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

  在上面這段程式碼中,任何方法都不應該返回null。實際上,總的來講,使用null這種語法應當是例外情況。在像JQuery(或者JOOX,Java中的一個埠),null是被完全避免的。因為你總是在操作可迭代的物件。無論你有匹配的內容與否,都與下一個方法的呼叫無關。

  Null通常在延遲初始化時出現。很多情況下,延遲初始化是可以避免的,並且對效能的影響不大。事實上,延遲初始化只應該被很小心的使用,特別是在有大型資料結構參與的時候。

  規則:儘可能地避免方法返回nulls。只在非初始化或者預設的情況下使用null。

  6、API方法永遠不要返回null陣列或null連結串列

  其實在一些情形下方法返回null值是可以的,但是任何情況想都絕不要返回null陣列或者null collection!我們來看一個可怕的java.io.File.list()方法。它返回:

字串陣列,這些字串制定此抽象路徑名錶示的目錄中的檔案和目錄。如果目錄為空,則陣列為空。如果這個抽象路徑並不表示一個目錄,或者發生I/O錯誤,則返回null。

  因此,對待這個方法正確方式是:

File directory = // ...

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

  if (list != null) {
    for (String file : list) {
      // ...
    }
  }
}
  第六行檢查null值有必要嗎?大多數I/<wbr>O操作產生的是IOException,<wbr>但是這個返回了null,<wbr>Null不能儲存任何錯誤資訊來說明為什麼會發生這個I/<wbr>O錯誤。所以這裡錯在三方面:
  • Null無助於找到錯誤。
  • Null並不能區分是I/<wbr>O錯誤還是檔案的例項不能表示一個目錄。
  • 在這裡,任何人都記不住這個null。
  在collection contexts(集合上下文)中,“absence”(缺席)<wbr>的概念最好使用空的陣列或集合來實現。除非是延遲初始化,否則“<wbr>缺席”陣列或集合幾乎從不使用。

  規則:陣列或集合永遠不應該是null。

  7、避免使用狀態(state),使用函數語言程式設計(functional)

  HTTP的優點就是它是非狀態性的。<wbr>所有有關的狀態都被轉化到每一個請求和回覆當中。<wbr>這對於REST的命名很重要:Representational State Transfer(表徵狀態轉移)。<wbr>這個在Java中的實現是一件非常棒的事。想一想規則2,<wbr>在方法接收到有狀態的引數物件。如果狀態都在這些物件中傳遞,<wbr>而不是總在外面操作,事情會變得簡單很多。比如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。<wbr>每一個API都難以置信地具有狀態性,難以操作。具體來說,<wbr>主要是兩個問題:

  • 在多執行緒環境下很難正確處理有狀態的API。
  • 很難使得有狀態的資源全域性可用,因為狀態沒有被備份。

  人們相信上面的使用滿足公平使用原則。

  規則:實現更多功能風格的東西。通過方法引數傳遞狀態。<wbr>少操作物件的狀態。

  8、equals()的捷徑

  這是一個簡單易用的東西。在大型物件圖表中,<wbr>如果你所有的物件的equals()方法都先使用”dirt cheaply” 比較一下他們的身份型別,那麼你可以獲得很大的效能提升。

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

  注意,其它捷徑檢查包括檢查null值,他也應該在那裡:

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

  規則:為所有的equals()尋找捷徑,從而提高效能。

  9、預設把方法設為final

  有些人並不同意這一點,<wbr>因為預設把東西設為final跟java程式設計師們通常所作的格格<wbr>不入。但是如果你對於原始碼有完全的控制,<wbr>預設把方法設為final絕對不會有任何錯,因為:

  • 如果你需要重寫一個方法(真的需要麼?),<wbr>你仍可以刪掉final關鍵詞。
  • 你永遠不會意外地重寫某個方法。
   這個特別適用於靜態方法,在那裡重寫沒有任何意義。<wbr>我最近在Apache Tika看過一個非常差的關於遮蓋靜態方法的例子, 考慮一下:

  TikaInputStream 繼承TaggedInputStream並且以一種不同的實現方<wbr>法遮蓋了他的靜態get()方法。

  與正常的方法不同,靜態方法不能相互重寫,<wbr>因為呼叫方在編譯時繫結了一個靜態方法的呼叫。如果你不幸運,<wbr>你可能會偶爾得到錯誤的方法呼叫。

  規則:如果你對你的API有完全的控制,<wbr>試著將盡可能多的方法預設設為final。

  10、避免 method(T…)

  偶爾使用”accept-all”可變引數方法並沒什麼錯,<wbr>這個方法的引數是Object:

void acceptAll(Object... all);

  寫這樣的方法給Java生態系統帶來了一絲JavaScript<wbr>的感覺。當然,在實際應用中,你可能會想把實際的型別加以限制,<wbr>比如String… 又因為你不像限制太多,<wbr>你就會認為用泛型T代替Object是一個好主意:

void acceptAll(T... all);

  但這並不是一個好主意。T可能總會被認為是一個Object。<wbr>事實上,在上面的方法中你可以不用泛型。更重要的是,<wbr>你認為你能過載上面的方法,但實際上你並不能:

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

  雖然這個看起來像是你也可以傳遞一個String給這個方法。但是如果執行這行會發生什麼?

  編譯器會把<?extends Serializable & Comparable<?>>當做T,這樣以來這個呼叫就有了歧義。

  因此,無論何時你使用“accept-all”(即使它是泛型),你永遠不能在保證類安全的前提下過載它。API使用者可能會很幸運的讓編譯器恰恰偶然選擇了那個正確的方法。但是他們也可能被騙而去使用”accept-all”方法,或者他們壓根不能呼叫任何方法。

  規則:如果可以,避免“accept-all”特性。如果不可以,永遠不要過載這樣的方法。

  總結

  Java是一個野獸,與其他花哨的語言不同,<wbr>它是慢慢演化成今天的樣子的。而那其實是一件好事,因為以Java開發的速度,有很多的警告,<wbr>只有在多年的經驗基礎上才能被搞定。

  原文連結: dzone 翻譯: ImportNew.com - 湯米貓

相關文章