Java編程細節之十個最佳實踐

jopen 10年前發布 | 22K 次閱讀 Java Java開發

本文講述的十個最佳實踐,這十個最佳實踐要比通常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/O操作產生的是IOException,但是這個返回了null,Null不能保存任何錯誤信息來說明為什么會發生這個I/O錯誤。所以這里錯在三方面:
  • Null無助于找到錯誤。
  • Null并不能區分是I/O錯誤還是文件的實例不能表示一個目錄。
  • 在這里,任何人都記不住這個null。
在collection contexts(集合上下文)中,“absence”(缺席)的概念最好使用空的數組或集合來實現。除非是延遲初始化,否則“缺席”數組或集合幾乎從不使用。

規則:數組或集合永遠不應該是null。

7、避免使用狀態(state),使用函數式編程(functional)

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都難以置信地具有狀態性,難以操作。具體來說,主要是兩個問題:

  • 在多線程環境下很難正確處理有狀態的API。
  • 很難使得有狀態的資源全局可用,因為狀態沒有被備份。
 Java編程細節之十個最佳實踐

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

規則:實現更多功能風格的東西。通過方法參數傳遞狀態。少操作對象的狀態。

8、equals()的捷徑

這是一個簡單易用的東西。在大型對象圖表中,如果你所有的對象的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

有些人并不同意這一點,因為默認把東西設為final跟java程序員們通常所作的格格不入。但是如果你對于源代碼有完全的控制,默認把方法設為final絕對不會有任何錯,因為:

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

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

與正常的方法不同,靜態方法不能相互重寫,因為調用方在編譯時綁定了一個靜態方法的調用。如果你不幸運,你可能會偶爾得到錯誤的方法調用。

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

10、避免 method(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給這個方法。但是如果執行這行會發生什么?

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

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

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

總結

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

原文鏈接: dzone 翻譯: ImportNew.com - 湯米貓
譯文鏈接: http://www.importnew.com/8815.html

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!