1. 程式人生 > >Buggy Java Code:Java程式設計師最容易犯的10個錯(2)

Buggy Java Code:Java程式設計師最容易犯的10個錯(2)

本文翻譯:吳嘉俊,叩丁狼高階講師。 

Java語言最開始是為了互動電視機而開發的,隨著時間的推移,他已經廣泛應用各種軟體開發領域。基於面向物件的設計,遮蔽了諸如C,C++等語言的一些複雜性,提供了垃圾回收機制,平臺無關的虛擬機器技術,Java創造了一種前所未有的開發方式。另一方面,得益於Java提出的“一次編碼,到處執行”的口號,讓Java更加出名。但是Java中的異常也是處處發生,下面我就列出了我認為的Java開發最容易出現的10個錯誤。

這是第二篇,剩下的5個常見錯誤。

#6、NPE

避免出現空指標引用的物件是一個很好的習慣。比如,一個方法最好返回一個空陣列或者空集合,而不是返回一個null,這些都可以避免出現NPE。

下面是一段程式碼演示在一個方法中遍歷另一個方法返回的集合:

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

如果使用者沒有賬戶資訊,則getAccountIds()方法返回一個null,隨後而來的就是NPE的出現。為了解決這個問題,我們需要新增一個null-check。如果返回值用一個空的集合來代替,那麼我們就可以直接避免出現多餘的判斷程式碼。

為了避免出現NPE,還有一些不同的方法。其中一個就是使用Optional型別來包裝可能為空的物件值:

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

JAVA8在Optional上提供了更優雅的做法:

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

從Java8開始,Optional就是Java中很有用的一個功能,但是就我的瞭解來看,在日常開發中使用Optional的程式設計師並不多。如果使用的是Java8之前的版本,Google的guava是一個不錯的選擇。

#7、忽略異常

很多開發一般會留著異常不處理。最好的做法還是建議開發人員及時的處理異常。異常的丟擲,往往都有特定的含義,作為開發,我們需要定位這些異常,並關注異常出現的原因。如果需要,我們應該重新丟擲異常,給使用者以提示,或者記錄到日誌中。再不濟,也應該解釋為什麼我們不去處理這個異常,而不僅僅只是忽略它。

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

一個更好的做法,通過給異常一個合適的名稱來告知其他開發,為什麼我們忽略該異常:

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

#8、同步修改異常

這個異常出現的原因在於我們使用iterator物件遍歷一個集合的同時,嘗試用集合的修改方法去修改集合本身。比如,我們想在一個帽子集合中刪除所有帶有耳套的帽子:

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異常。如果有兩個執行緒同時訪問一個集合,一個執行緒在遍歷集合,另一個執行緒嘗試修改集合,也會丟擲這個異常。在開發中,多執行緒併發修改一個集合是非常常見的事情,要正確完成這個工作,需要使用併發程式設計相關的工具,比如同步鎖,支援併發修改的集合等。在單執行緒和多執行緒下解決這個問題,也有一些區別。下面是簡單的驗證在單執行緒情況下怎麼解決這個問題:

蒐集到一個集合並在另一個迴圈中刪除

我們可以把帶耳廓的帽子在第一遍迴圈的時候查詢到另一個集合中,然後再遍歷這個集合,再從原始的集合中刪除。

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介面,使用ListIterator是一個恰當的方法。實現了ListIterator介面的遍歷器,不僅允許刪除元素,還提供了add操作和set操作。ListIterator繼承了Iterator介面,所以下面這個例子和遍歷器刪除的例子幾乎一樣,唯一的區別就是獲得的遍歷器的型別,我們使用的是listIterator()方法獲取遍歷器。下面的方法我們除了展示remove方法,我們還會展示ListIterator.add方法:

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,刪除和新增操作可以合併成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
    }
}

使用Stream API

使用Java8提供的stream方法,允許開發者將集合轉化成stream,然後通過filter進行過濾。下面是一個使用streamAPI來過濾帽子的方法,也可以避免出現ConcurrentModificationException異常。

hats = hats.stream().filter((hat -> !hat.hasEarFlaps()))
        .collect(Collectors.toCollection(ArrayList::new));

Collectors.toCollection方法會使用過濾出來的物件建立一個新的ArrayList。這可能會出現一些問題,比如假如過濾出來的元素非常多,那麼創建出來的ArrayList會非常大,所以使用的時候需要注意一下。使用Java8提供的List.removeIf方法也是另一種解決方案,並且更加清晰:

hats.removeIf(IHat::hasEarFlaps);

在底層,其實也是使用iterator.remove方法完成的。

使用特殊的集合

如果在最開始,我們使用CopyOnWriteArrayList代替ArrayList,那麼最初的操作根本就不會出錯,因為CopyOnWriteArrayList提供了修改的方法(比如set,add,remove)而不會導致集合背後的陣列發生變化,但是會建立一個新的修改版本。所以遍歷方法一直遍歷的是原始版本的集合資料,修改是發生在新版本集合之上的,這樣就避免出現了ConcurrentModificationException異常。所以,背後的原理其實就是每次修改的時候,都建立一個新的集合。

當然,還有類似的其他集合型別,比如CopyOnWriteSet和ConcurrentHashMap。

另一個在集合併發修的時候,可能產生的問題就是,當為集合建立一個stream,在遍歷這個stream的時候,在後臺修改原始集合。stream有一個基本的使用原則,就是在使用stream查詢的時候,不要修改原始的集合。下面展示了一個錯誤的stream使用案例:

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

peek方法針對所有的元素,施加了對應的操作,但是這個操作是把匹配的元素從原始的集合中刪除,這會導致異常的發生。(注:peek針對每一個元素實施操作,但是peek是惰性操作,返回的仍然是stream,在遇到toCollection方法的時候,才會真正執行,但這個時候,把元素刪除了)

#9、破壞契約

通常情況下,標準庫或者第三方提供的程式碼,都需要準守一些既定的規則。比如,必須要遵循正確的hashCode和equals邏輯,才能匹配Java集合框架提供的功能,或者其他使用hashCode和equals方法的場景。如果違反這些約定,不會導致直接的編譯異常或者執行異常,而是在貌似正常的執行過程中,隱藏著巨大的危險。類似這樣的錯誤程式碼,常常會繞過測試,進入生產環境,並且產生一系列意料之外的影響,比如錯誤的UI行為,錯誤的資料報告,極低的應用效能,資料丟失等等。

幸運的是,這種約定的情況很少。我上面已經提到了hashCode和equals約定,這個約定在具有hash和比較物件的集合中會用到,比如HashMap和HashSet。這個約定包含兩條規則:

  • 如果兩個物件相等,則他們的hash值必須相等。
  • 如果兩個物件的hash值相等,這兩個物件可能相等,也可能不等。

如果違反了第一條規則,會導致hashmap中存取資料出現錯誤。下面是一個違反了第一條規則的示例程式碼:

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類覆寫了equals和hashCode方法。但是,他違反了約定,因為hashCode方法返回了一個隨機值。

下面的程式碼展示了問題,我們先想hashset中添加了一個名為Enterprise的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' : %bn", boats.contains(new Boat("Enterprise")));
}

另一個約定的例子是finalize方法。

我們可以選擇在finalize方法中釋放類似開啟檔案的資源,但是這是一個錯誤的想法。因為約定中說明,finalize方法只能在GC的時候執行,但你怎麼知道什麼時候執行GC呢?

#10、使用泛型但並不指定泛型型別

我們來看看下面這段程式碼:

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

我們定義了一個未指定泛型型別的ArrayList(raw ArrayList),因為沒有具體引數化泛型的型別,所以我們能往這個list中新增任何物件。但是在最後一行程式碼中,我們強行把元素轉化成int,乘以2並列印。這段程式碼不會出現編譯錯誤,但是在執行的時候會丟擲異常,因為我們嘗試把一個字串轉型成整型。顯然,因為我們隱藏了型別,所以型別系統也無法正確幫我們編寫安全的程式碼。

要避免這個錯誤,只需要在例項化集合的時候指明具體的泛型型別即可:

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

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

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

唯一的區別在第一句程式碼。

我們修改之後的程式碼在編譯的時候就會報錯,因為我們嘗試把一個字串放進只能存放整形的集合中。記住,在使用泛型型別的時候,一定要指定泛型的型別,是一個非常重要的編碼習慣。

小結

Java平臺依賴JVM和語言本身的特性,為我們簡化了開發中的很多複雜性。但是,他提供的這些功能,比如記憶體管理,OOP工具等,並不能讓開發者一勞永逸。所以,熟悉Java庫,閱讀Java原始碼,閱讀JVM相關文件是非常有必要的。最後,在開發中配合使用幾個錯誤分析工具,能降低我們的錯誤發生概率。

原文:https://www.voxxed.com/2017/03/buggy-java-code-part-ii/