[譯] Java 中最多見的 5 個錯誤

在編程時,開發者常常會遭遇各式各樣莫名錯誤。近日,Sushil Das 在 Geek On Java上列舉了 Java 開發中常見的 5 個錯誤,與君共「免」。html

原文連接:Top 5 Common Mistake in Javajava

Java 中最多見的 5 個錯誤

如下爲譯文:程序員

1. Null 的過分使用

避免過分使用 null 值是一個最佳實踐。例如,更好的作法是讓方法返回空的 array 或者 collection 而不是 null 值,由於這樣能夠防止程序拋出 NullPointerException。下面代碼片斷會從另外一個方法得到一個集合:編程

</>
List<String> accountIds = person.getAccountIds();
for (String accountId : accountIds) {
    processAccount(accountId);
}

當一個 person 沒有 account 的時候,getAccountIds() 將返回 null 值,程序就會拋出 NullPointerException 異常。所以須要加入空檢查來解決這個問題。若是將返回的 null 值替換成一個空的 list,那麼 NullPointerException 也不會出現。並且,由於咱們再也不須要對變量 accountId 作空檢查,代碼將變得更加簡潔。後端

當你想避免 null 值的時候,不一樣場景可能採起不一樣作法。其中一個方法就是使用 Optional 類型,它既能夠是一個空對象,也能夠是一些值的封裝。api

</>
Optional<String> optionalString = Optional.ofNullable(nullableString);
if(optionalString.isPresent()) {
    System.out.println(optionalString.get());
}

事實上,Java8 提供了一個更簡潔的方法:數組

</>
Optional<String> optionalString = Optional.ofNullable(nullableString);
optionalString.ifPresent(System.out::println);

Java 是從 Java8 版本開始支持 Optional 類型,可是它在函數式編程世界早已廣爲人知。在此以前,它已經在 Google Guava 中針對 Java 的早期版本被使用。安全

2. 忽視異常

咱們常常對異常置之不理。然而,針對初學者和有經驗的 Java 程序員,最佳實踐還是處理它們。異常拋出一般是帶有目的性的,所以在大多數狀況下須要記錄引發異常的事件。別小看這件事,若是必要的話,你能夠從新拋出它,在一個對話框中將錯誤信息展現給用戶或者將錯誤信息記錄在日誌中。至少,爲了讓其它開發者知曉來龍去脈,你應該解釋爲何沒有處理這個異常。多線程

</>
selfie = person.shootASelfie();
try {
    selfie.show();
} catch (NullPointerException e) {
    // Maybe, invisible man. Who cares, anyway?
}

強調某個異常不重要的一個簡便途徑就是將此信息做爲異常的變量名,像這樣:併發

</>
try { selfie.delete(); } catch (NullPointerException unimportant) {  }

3. 併發修改異常

這種異常發生在集合對象被修改,同時又沒有使用 iterator 對象提供的方法去更新集合中的內容。例如,這裏有一個 hats 列表,並想刪除其中全部含 ear flaps 的值:

</>
List<IHat> hats = new ArrayList<>();
hats.add(new Ushanka()); // that one has ear flaps
hats.add(new Fedora());
hats.add(new Sombrero());
for (IHat hat : hats) {
    if (hat.hasEarFlaps()) {
        hats.remove(hat);
    }
}

若是運行此代碼,ConcurrentModificationException 將會被拋出,由於代碼在遍歷這個集合的同時對其進行修改。當多個進程做用於同一列表,在其中一個進程遍歷列表時,另外一個進程試圖修改列表內容,一樣的異常也可能會出現。

在多線程中併發修改集合內容是很是常見的,所以須要使用併發編程中經常使用的方法進行處理,例如同步鎖、對於併發修改採用特殊的集合等等。Java 在單線程和多線程狀況下解決這個問題有微小的差異。

收集對象並在另外一個循環中刪除它們

直接的解決方案是將帶有 ear flaps 的 hats 放進一個 list,以後用另外一個循環刪除它。不過這須要一個額外的集合來存放將要被刪除的 hats。

</>
List<IHat> hatsToRemove = new LinkedList<>();
for (IHat hat : hats) {
    if (hat.hasEarFlaps()) {
        hatsToRemove.add(hat);
    }
}
for (IHat hat : hatsToRemove) {
    hats.remove(hat);
}

使用 Iterator.remove 方法

這個方法更簡單,同時並不須要建立額外的集合:

</>
Iterator<IHat> hatIterator = hats.iterator();
while (hatIterator.hasNext()) {
    IHat hat = hatIterator.next();
    if (hat.hasEarFlaps()) {
        hatIterator.remove();
    }
}

使用 ListIterator 的方法

當須要修改的集合實現了 List 接口時,list iterator 是很是合適的選擇。實現 ListIterator 接口的 iterator 不只支持刪除操做,還支持 addset 操做。ListIterator 接口實現了 Iterator 接口,所以這個例子看起來和 Iteratorremove 方法很像。惟一的區別是 hat iterator 的類型和咱們得到 iterator 的方式——使用 listIterator() 方法。下面的片斷展現瞭如何使用 ListIterator.removeListIterator.add 方法將帶有 ear flaps 的 hat 替換成帶有sombreros 的。

</>
IHat sombrero = new Sombrero();
ListIterator<IHat> hatIterator = hats.listIterator();
while (hatIterator.hasNext()) {
    IHat hat = hatIterator.next();
    if (hat.hasEarFlaps()) {
        hatIterator.remove();
        hatIterator.add(sombrero);
    }
}

使用 ListIterator,調用 removeadd 方法可替換爲只調用一個 set 方法:

</>
IHat sombrero = new Sombrero();
ListIterator<IHat> hatIterator = hats.listIterator();
while (hatIterator.hasNext()) {
    IHat hat = hatIterator.next();
    if (hat.hasEarFlaps()) {
        hatIterator.set(sombrero); // set instead of remove and add
    }
}

使用Java 8中的 stream 方法

在 Java8 中,開發人員能夠將一個 collection 轉換爲 stream,而且根據一些條件過濾 stream。這個例子講述了 stream api 是如何過濾 hats 和避免 ConcurrentModificationException
hats = hats.stream().filter((hat -> !hat.hasEarFlaps()))

</>
 .collect(Collectors.toCollection(ArrayList::new));

Collectors.toCollection 方法將會建立一個新的 ArrayList,它負責存放被過濾掉的 hats 值。若是過濾條件過濾掉了大量條目,這裏將會產生一個很大的 ArrayList。所以,須要謹慎使用。

使用 Java 8 中的 List.removeIf 方法

可使用 Java 8 中另外一個更簡潔明瞭的方法—— removeIf 方法:

</>
hats.removeIf(IHat::hasEarFlaps);

在底層,它使用 Iterator.remove 來完成這個操做。

使用特殊的集合

若是在一開始就決定使用 CopyOnWriteArrayList 而不是 ArrayList,那就不會出現問題。由於 CopyOnWriteArrayList 提供了修改的方法(例如 set,add,remove),它不會去改變原始集合數組,而是建立了一個新的修改版本。這就容許遍歷原來版本集合的同時進行修改,從而不會拋出 ConcurrentModificationException 異常。這種集合的缺點也很是明顯——針對每次修改都產生一個新的集合。

還有其餘適用於不一樣場景的集合,好比 CopyOnWriteSetConcurrentHashMap

關於另外一個可能可能在併發修改集合時產生的錯誤是,從一個 collection 建立了一個 stream,在遍歷 stream 的時候,同時修改後端的 collection。針對 stream 的通常準則是,在查詢 stream 的時候,避免修改後端的 collection。接下來的例子將展現如何正確地處理 stream:

</>
List<IHat> filteredHats = hats.stream().peek(hat -> {
    if (hat.hasEarFlaps()) {
        hats.remove(hat);
    }
}).collect(Collectors.toCollection(ArrayList::new));

peek 方法收集全部的元素,並對每個元素執行既定動做。在這裏,動做即爲嘗試從一個基礎列表中刪除數據,這顯然是錯誤的。爲避免這樣的操做,能夠嘗試一些上面講解的方法。

4. 違約

有時候,爲了更好地協做,由標準庫或者第三方提供的代碼必須遵照共同的依賴準則。例如,必須遵照 hashCodeequals 的共同約定,從而保證 Java 集合框架中的一系列集合類和其它使用 hashCodeequals 方法的類可以正常工做。不遵照約定並不會產生 exception 或者破壞代碼編譯之類的錯誤;它很陰險,由於它隨時可能在毫無危險提示的狀況下更改應用程序行爲。

錯誤代碼可能潛入生產環境,從而形成一大堆不良影響。這包括較差的 UI 體驗、錯誤的數據報告、較差的應用性能、數據丟失或者更多。慶幸的是,這些災難性的錯誤不會常常發生。在以前已經說起了 hashCode 和equals 約定,它出現的場景多是:集合依賴於將對象進行哈希或者比較,就像 HashMap 和 HashSet。簡單來講,這個約定有兩個準則:

  • 若是兩個對象相等,那麼 hash code 必須相等。
  • 若是兩個對象有相同的 hash code,那麼它們可能相等也可能不相等。

破壞約定的第一條準則,當你試圖從一個 hashmap 中檢索數據的時候將會致使錯誤。第二個準則意味着擁有相同 hash code 的對象不必定相等。

下面看一下破壞第一條準則的後果:

</>
public static class Boat {
    private String name;

    Boat(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Boat boat = (Boat) o;

        return !(name != null ? !name.equals(boat.name) : boat.name != null);
    }

    @Override
    public int hashCode() {
        return (int) (Math.random() * 5000);
    }
}

正如你所見,Boat 類重寫了 equalshashCode 方法。然而,它破壞了約定,由於 hashCode 針對每次調用的相同對象返回了隨機值。下面的代碼極可能在 hashset 中找不到一個名爲 Enterprise 的boat,儘管事實上咱們提早加入了這種類型的 boat:

</>
public static void main(String[] args) {
    Set<Boat> boats = new HashSet<>();
    boats.add(new Boat("Enterprise"));

    System.out.printf("We have a boat named 'Enterprise' : %b\n", boats.contains(new Boat("Enterprise")));
}

另外一個約定的例子是 finalize 方法。這裏是官方 Java 文檔關於它功能描述的引用:

finalize 的常規約定是:當 JavaTM 虛擬機肯定任何線程都沒法再經過任何方式訪問指定對象時,這個方法會被調用,此後這個對象只能在某個其餘(準備終止的)對象或類終結時被做爲某個行爲的結果。finalize 方法有多個功能,其中包括再次使此對象對其餘線程可用;不過 finalize 的主要目的是在不可撤消地丟棄對象以前執行清除操做。例如,表示輸入/輸出鏈接對象的 finalize 方法可執行顯式 I/O 事務,以便在永久丟棄對象以前中斷鏈接。

你能夠決定在諸如文件處理器中使用 finalize 方法來釋放資源,可是這種用法是很糟糕的。因爲它是在垃圾回收期間被調用的,而 GC 的時間並不肯定,所以 finalize 被調用的時間將沒法保證。

5. 使用原始類型而不是參數化的

根據 Java 文檔描述:原始類型要麼是非參數化的,要麼是類 R 的(同時也是非繼承 R 父類或者父接口的)非靜態成員。在 Java 泛型被引入以前,並無原始類型的替代類型。Java 從1.5版本開始支持泛型編程,毫無疑問這是一個重要的功能提高。然而,因爲向後兼容的緣由,這裏存在一個陷阱可能會破壞整個類型系統。着眼下例:

</>
List listOfNumbers = new ArrayList();
listOfNumbers.add(10);
listOfNumbers.add("Twenty");
listOfNumbers.forEach(n -> System.out.println((int) n * 2));

這是一個由數字組成的列表被定義爲原始的 ArrayList。因爲它並無指定類型參數,所以能夠給它添加任何對象。可是最後一行將其包含的元素映射爲 int 類型並乘以 2,打印出翻倍以後的數據到標準輸出。

此代碼編譯時不會出錯,可是一旦運行就會拋出運行時錯誤,由於這裏試圖將字符類型映射爲整形。很顯然,若是隱藏了必要信息,類型系統將不能幫助寫出安全代碼。

爲了解決這個問題,須要爲存入集合中的對象指定具體類型:

</>
List<Integer> listOfNumbers = new ArrayList<>();

listOfNumbers.add(10);
listOfNumbers.add("Twenty");

listOfNumbers.forEach(n -> System.out.println((int) n * 2));

與以前代碼的惟一差異便是定義集合的那一行:

</>
List<Integer> listOfNumbers = new ArrayList<>();

修改以後的代碼編譯不可能被經過,由於這裏試圖向只指望存儲整形的集合中添加字符串。編譯器將會顯示錯誤信息,並指向試圖向列表中添加 Twenty 字符的那一行。參數化泛型類型是個不錯的主意。這樣的話,編譯器就可以檢查全部可能的類型,從而因爲類型不一致而致使的運行時異常概率將大大下降。

本文系 OneAPM 工程師編譯整理。想閱讀更多技術文章,請訪問 OneAPM 官方博客

相關文章
相關標籤/搜索