使用Java 8 Optional避免空指標異常

liuyatao發表於2018-01-11

Optional可以讓你的程式碼具有可讀性,且會避免出現空指標異常。

都說沒有遇到過空指標異常的程式設計師不是Java程式設計師,null確實引發過很多問題。Java 8中引入了一個叫做java.util.Optional的新類可以避免null引起的諸多問題。

我們看看一個null引用能導致哪些危害。首先建立一個類Computer,結構如下圖所示:

輸入圖片說明
Computer類模型

當我們呼叫如下程式碼會怎樣?

String version = computer.getSoundcard().getUSB().getVersion();
複製程式碼

上述程式碼看似是沒有問題的,但是很多計算機(比如,樹莓派)其實是沒有音效卡的,那麼呼叫getSoundcard()方法可定會丟擲空指標異常了。

一個常規的但是不好的的方法是返回一個null引用來表示計算機沒有音效卡,但是這就意味著會對一個空引呼叫getUSB()方法,顯然會在程式執行過程中丟擲控制異常,從而導致程式停止執行。想想一下,當你的程式在客戶端電腦上執行時,突然出現這種錯是多尷尬的一件事?

偉大電腦科學Tony Hoare曾經寫到:"我認為null引用從1965年被創造出來導致了十億美元的損失。當初使用null引用對我最大的誘惑就是它實現起來方便。"

那麼該怎麼避免在程式執行時會出現空指標異常呢?你需要保持警惕,並且不斷檢查可能出現空指標的情況,就像下面這樣:

String version = "UNKNOWN";
if(computer != null)
    {
        Soundcard soundcard = computer.getSoundcard();
        if(soundcard != null){
             USB usb = soundcard.getUSB();
             if(usb != null){
                 version = usb.getVersion();
                }
            }
    }
複製程式碼

然而,你可以看到上述程式碼有太多的null檢查,整個程式碼結構變得非常醜陋。但是我們又不得不通過這樣的判斷來確保系統執行時不會出現空指標。如果在我們的業務程式碼中出現大量的這種空引用判斷簡直讓人惱火,也導致我們程式碼的可讀性會很差。

如果你忘記檢查要給值是否為空,null引用也是存在很大的潛在問題。這篇文章我將證明使用null引用作為值不存在的表示是不好的方法。我們需要一個更好的表示值不存在的模型,而不是再使用null引用。

Java 8引入了一個新類叫做java.util.Optional<T>,這個類的設計的靈感來源於Haskell語言和Scala語言。這個類可以包含了一個任意值,像下面圖和程式碼表示的那樣。你可以把Optional看做是一個有可能包含了值的值,如果Optional不包含值那麼它就是空的,下圖那樣。

輸入圖片說明
Optional模型

public class Computer {
  private Optional<Soundcard> soundcard;
  public Optional<Soundcard> getSoundcard() { ... }
  ...
}

public class Soundcard {
  private Optional<USB> usb;
  public Optional<USB> getUSB() { ... }

}

public class USB{
  public String getVersion(){ ... }
}
複製程式碼

上述程式碼展現了一臺計算機有可能包換一個音效卡(音效卡是有可能存在也有可能不存在)。音效卡也是有可能包含一個USB埠的。這是一種改善方法,該模型可以更加清晰的反映一個被給定的值是可以不存在的。

但是該怎麼處理Optional<Soundcard>這個物件呢?畢竟,你想要獲取的是USB的埠號。很簡單,Optional類包含了一些方法來處理值是否存在的狀況。和null引用相比Optional類迫使你在你要做值是否相關處理,從而避免了空指標異常。

需要說明的是Optional類並不是要取代null引用。相反地,是為了讓設計的API更容易被理解,當你看到一個函式的簽名時,你就可以判斷要傳遞給這個函式的值是不是有可能不存在。這就促使你要開啟Optional類來處理確實值的狀況了。

採用Optional模式

囉嗦了這麼多,來看一些程式碼吧!我們先看一下怎麼使用Optional改寫傳統的null引用檢測後是什麼樣子。在這邊文章的末尾你將會明白怎麼使用Optional。

String name = computer.flatMap(Computer::getSoundcard)
                          .flatMap(Soundcard::getUSB)
                          .map(USB::getVersion)
                          .orElse("UNKNOWN");
複製程式碼

建立Optional物件

可以建立一個空的Optional物件:

Optional<Soundcard> sc = Optional.empty();
複製程式碼

接下來是建立一個包含非null值的Optional:

SoundCard soundcard = new Soundcard();
Optional<Soundcard> sc = Optional.of(soundcard);
複製程式碼

如果音效卡null,空指標異常會立即被丟擲(這比在獲取音效卡屬性時才丟擲要好)。

通過使用ofNullable,你可以建立一個可能包含null引用的Optional物件:

Optional<Soundcard> sc = Optional.ofNullable(soundcard);
複製程式碼

如果音效卡是null 引用,Optional物件就是一個空的。

對Optional中的值的處理

既然現在已經有了Optional物件,你可以呼叫相應的方法來處理Optional物件中的值是否存在。和進行null檢測相比,我們可以使用ifPresent()方法,像下面這樣:

Optional<Soundcard> soundcard = ...;
soundcard.ifPresent(System.out::println);
複製程式碼

這樣就不必再做null檢測,如果Optional物件是空的,那麼什麼資訊將不會列印出來。

你也可以使用isPresent()方法檢視Optional物件是否真的存在。另外,還有一個get()方法可以返回Optional物件中的包含的值,如果存在的話。否則會丟擲一個NoSuchElementException異常。這兩個方式可以像下面這樣搭配起來使用,從而避免異常:

if(soundcard.isPresent()){
  System.out.println(soundcard.get());
}

複製程式碼

但是這種方式不推薦使用(它和null檢測相比沒有什麼改進),下面我們將會探討一下工作慣用的方式。

返回預設值和相關操作

當遇到null時一個常規的操作就是返回一個預設值,你可以使用三元表示式來實現:

Soundcard soundcard = maybeSoundcard != null ? maybeSoundcard : new Soundcard("basic_sound_card");

複製程式碼

使用Optional物件的話,你可以orElse()使用重寫,當Optional是空的時候orElse()可以返回一個預設值:

Soundcard soundcard = maybeSoundcard.orElse(new Soundcard("defaut"));
複製程式碼

類似地,當Optional為空的時候也可以使用orElseThrow()丟擲異常:

Soundcard soundcard = 
  maybeSoundCard.orElseThrow(IllegalStateException::new);

複製程式碼

使用filter過濾特定的值

我們常常會呼叫一個物件的方法來判斷它的一下屬性。比如,你可能需要檢測USB埠號是否是某個特定值。為了安全起見,你需要檢查指向USB的醫用是否是null,然後再呼叫getVersion()方法,像下面這樣:

USB usb = ...;
if(usb != null && "3.0".equals(usb.getVersion())){
  System.out.println("ok");
}
複製程式碼

如果使用Optional的話可以使用filter函式重寫:

Optional<USB> maybeUSB = ...;
maybeUSB.filter(usb -> "3.0".equals(usb.getVersion())
                    .ifPresent(() -> System.out.println("ok"));

複製程式碼

filter方法需要一個predicate對向作為引數。如果Optional中的值存在並且滿足predicate,那麼filter函式將會返回滿足條件的值;否則,會返回一個空的Optional物件。

使用map方法進行資料的提取和轉化

一個常見的模式是提取一個物件的一些屬性。比如,對於一個Soundcard物件,你可能需要獲取它的USB物件,然後判斷它的的版本號。通常我們的實現方式是這樣的:

if(soundcard != null){
  USB usb = soundcard.getUSB();
  if(usb != null && "3.0".equals(usb.getVersion()){
    System.out.println("ok");
  }
}
複製程式碼

我們可以使用map方法重寫這種檢測null,然後再提取物件型別的物件。

Optional<USB> usb = maybeSoundcard.map(Soundcard::getUSB);
複製程式碼

這個和使用stream的map函式式一樣的。使用stream需要給map函式傳遞一個函式作為引數,這個傳遞進來的函式將會應用於stream中的每個元素。當stream時空的時候,什麼也不會發生。

Optional中包含的值將會被傳遞進來的函式轉化(這裡是一個從音效卡中獲取USB的函式)。如果Optional物件時空的,那麼什麼也不會發生。

然後,我們結合map方法和filter方法過濾掉USB的版本號不是3.0的音效卡。

maybeSoundcard.map(Soundcard::getUSB)
      .filter(usb -> "3.0".equals(usb.getVersion())
      .ifPresent(() -> System.out.println("ok"));
複製程式碼

這樣我們的程式碼開始變得像有點像開始我們給出的樣子,沒有了null檢測。

使用flatMap函式傳遞Optional物件

現在已經介紹了一個可以使用Optional重構程式碼的例子,那麼我們應該如何使用安全的方式實現下面程式碼呢?

String version = computer.getSoundcard().getUSB().getVersion();
複製程式碼

注意上面的程式碼都是從一個物件中提取另一個物件,使用map函式可以實現。在前面的文章中我們設定了Computer中包含的是一個Optional物件,Soundcard包含的是一個Optional物件,因此我們可以這麼重構程式碼

String version = computer.map(Computer::getSoundcard)
                  .map(Soundcard::getUSB)
                  .map(USB::getVersion)
                  .orElse("UNKNOWN");
複製程式碼

不幸的是,上面的程式碼會編譯錯誤,那麼為什麼呢?computer變數是Optional型別的,所以它呼叫map函式是沒有問題的。但是getSoundcard()方法返回的是一個Optional<Soundcard>的物件,返回的是Optional<Optional<Soundcard>>型別的物件,進行了第二次map函式的呼叫,結果呼叫getUSB()函式就變成非法的了。下面的圖描述了這種場景:

輸入圖片說明
Optional>

map函式的原始碼實現是這樣的:

 public<U> Optional<U> map(Function<? super T, ? extends U> mapper) {
        Objects.requireNonNull(mapper);
        if (!isPresent())
            return empty();
        else {
            return Optional.ofNullable(mapper.apply(value));
        }
    }
複製程式碼

可以看出map函式還會再呼叫一次Optional.ofNullable(),從而導致返回Optional<Optional<Soundcard>>

Optional提供了flatMap這個函式,它的設計意圖是當對Optional物件的值進行轉化(就像map操作)然後一個兩級Optional壓縮成一個。下面的圖展示了Optional物件通過呼叫map和flatMap進行型別轉化的不同:

輸入圖片說明
map和flatMap比較

因此我們可以這樣寫:

String version = computer.flatMap(Computer::getSoundcard)
                   .flatMap(Soundcard::getUSB)
                   .map(USB::getVersion)
                   .orElse("UNKNOWN");

複製程式碼

第一個flatMap保證了返回的是Optional而不是Optional<Optional>,第二個flatMap實現了同樣的功能從而返回的是 Optional。注意第三次呼叫了map(),因為getVersion()返回的是一個String物件而不是一個Optional物件。

我們終於把剛開始使用的巢狀null檢查的醜陋程式碼改寫了可讀性高的程式碼,也避免了空指標異常的出現的程式碼。

總結

在這片文章中我們採用了Java 8提供的新類java.util.Optional<T>。這個類的初衷不是要取代null引用,而是幫助設計者設計出更好的API,只要讀到函式的簽名就可知道該函式是否接受一個可能存在也可能不存在的值。另外,Optional迫使你去開啟Optional,然後處理值是否存在,這就使得你的程式碼避免了潛在的空指標異常。

最後

感謝閱讀,有興趣可以關注微信公眾賬號獲取最新推送文章。

歡迎關注微信公眾賬號
歡迎關注微信公眾賬號

相關文章