讀古今文學網 > Java 8實戰 > 第10章 用Optional取代null >

第10章 用Optional取代null

本章內容

  • null引用引發的問題,以及為什麼要避免null引用

  • nullOptional:以null安全的方式重寫你的域模型

  • Optional發光發熱: 去除代碼中對null的檢查

  • 讀取Optional中可能值的幾種方法

  • 對可能缺失值的再思考

如果你作為Java程序員曾經遭遇過NullPointerException,請舉起手。如果這是你最常遭遇的異常,請繼續舉手。非常可惜,這個時刻,我們無法看到對方,但是我相信很多人的手這個時刻是舉著的。我們還猜想你可能也有這樣的想法:“毫無疑問,我承認,對任何一位Java程序員來說,無論是初出茅廬的新人,還是久經江湖的專家,NullPointerException都是他心中的痛,可是我們又無能為力,因為這就是我們為了使用方便甚至不可避免的像null引用這樣的構造所付出的代價。”這就是程序設計世界裡大家都持有的觀點,然而,這可能並非事實的全部真相,只是我們根深蒂固的一種偏見。

1965年,英國一位名為Tony Hoare的計算機科學家在設計ALGOL W語言時提出了null引用的想法。ALGOL W是第一批在堆上分配記錄的類型語言之一。Hoare選擇null引用這種方式,“只是因為這種方法實現起來非常容易”。雖然他的設計初衷就是要“通過編譯器的自動檢測機制,確保所有使用引用的地方都是絕對安全的”,他還是決定為null引用開個綠燈,因為他認為這是為“不存在的值”建模最容易的方式。很多年後,他開始為自己曾經做過這樣的決定而後悔不迭,把它稱為“我價值百萬的重大失誤”。我們已經看到它帶來的後果——程序員對對象的字段進行檢查,判斷它的值是否為期望的格式,最終卻發現我們查看的並不是一個對象,而是一個空指針,它會立即拋出一個讓人厭煩的NullPointerException異常。

實際上,Hoare的這段話低估了過去五十年來數百萬程序員為修復空引用所耗費的代價。近十年出現的大多數現代程序設計語言1,包括Java,都採用了同樣的設計方式,其原因是為了與更老的語言保持兼容,或者就像Hoare曾經陳述的那樣,“僅僅是因為這樣實現起來更加容易”。讓我們從一個簡單的例子入手,看看使用null都有什麼樣的問題。

1為數不多的幾個最著名的例外是典型的函數式語言,比如Haskell、ML;這些語言中引入了代數數據類型,允許顯式地聲明數據類型,明確地定義了特殊變量值(比如null)能否使用在定義類型的類型(type-by-type basis)中。

10.1 如何為缺失的值建模

假設你需要處理下面這樣的嵌套對象,這是一個擁有汽車及汽車保險的客戶。

代碼清單10-1 Person/Car/Insurance的數據模型

public class Person {
    private Car car;
    public Car getCar { return car; }
}

public class Car {
    private Insurance insurance;
    public Insurance getInsurance { return insurance; }
}

public class Insurance {
    private String name;
    public String getName { return name; }
}

  

那麼,下面這段代碼存在怎樣的問題呢?

public String getCarInsuranceName(Person person) {
    return person.getCar.getInsurance.getName;
}

  

這段代碼看起來相當正常,但是現實生活中很多人沒有車。所以調用getCar方法的結果會怎樣呢?在實踐中,一種比較常見的做法是返回一個null引用,表示該值的缺失,即用戶沒有車。而接下來,對getInsurance的調用會返回null引用的insurance,這會導致運行時出現一個NullPointerException,終止程序的運行。但這還不是全部。如果返回的person值為null會怎樣?如果getInsurance的返回值也是null,結果又會怎樣?

10.1.1 採用防禦式檢查減少NullPointerException

怎樣做才能避免這種不期而至的NullPointerException呢?通常,你可以在需要的地方添加null的檢查(過於激進的防禦式檢查甚至會在不太需要的地方添加檢測代碼),並且添加的方式往往各有不同。下面這個例子是我們試圖在方法中避免NullPointerException的第一次嘗試。

代碼清單10-2 null-安全的第一種嘗試:深層質疑

這個方法每次引用一個變量都會做一次null檢查,如果引用鏈上的任何一個遍歷的解變量值為null,它就返回一個值為“Unknown”的字符串。唯一的例外是保險公司的名字,你不需要對它進行檢查,原因很簡單,因為任何一家公司必定有個名字。注意到了嗎,由於你掌握業務領域的知識,避免了最後這個檢查,但這並不會直接反映在你建模數據的Java類之中。

我們將代碼清單10-2標記為“深層質疑”,原因是它不斷重複著一種模式:每次你不確定一個變量是否為null時,都需要添加一個進一步嵌套的if塊,也增加了代碼縮進的層數。很明顯,這種方式不具備擴展性,同時還犧牲了代碼的可讀性。面對這種窘境,你也許願意嘗試另一種方案。下面的代碼清單中,我們試圖通過一種不同的方式避免這種問題。

代碼清單10-3 null-安全的第二種嘗試:過多的退出語句

第二種嘗試中,你試圖避免深層遞歸的if語句塊,採用了一種不同的策略:每次你遭遇null變量,都返回一個字符串常量“Unknown”。然而,這種方案遠非理想,現在這個方法有了四個截然不同的退出點,使得代碼的維護異常艱難。更糟的是,發生null時返回的默認值,即字符串“Unknown”在三個不同的地方重複出現——出現拼寫錯誤的概率不小!當然,你可能會說,我們可以用把它們抽取到一個常量中的方式避免這種問題。

進一步而言,這種流程是極易出錯的;如果你忘記檢查了那個可能為null的屬性會怎樣?通過這一章的學習,你會瞭解使用null來表示變量值的缺失是大錯特錯的。你需要更優雅的方式來對缺失的變量值建模。

10.1.2 null帶來的種種問題

讓我們一起回顧一下到目前為止進行的討論,在Java程序開發中使用null會帶來理論和實際操作上的種種問題。

  • 它是錯誤之源。

    NullPointerException是目前Java程序開發中最典型的異常。

  • 它會使你的代碼膨脹。

    它讓你的代碼充斥著深度嵌套的null檢查,代碼的可讀性糟糕透頂。

  • 它自身是毫無意義的。

    null自身沒有任何的語義,尤其是,它代表的是在靜態類型語言中以一種錯誤的方式對缺失變量值的建模。

  • 它破壞了Java的哲學。

    Java一直試圖避免讓程序員意識到指針的存在,唯一的例外是:null指針。

  • 它在Java的類型系統上開了個口子。

    null並不屬於任何類型,這意味著它可以被賦值給任意引用類型的變量。這會導致問題,原因是當這個變量被傳遞到系統中的另一個部分後,你將無法獲知這個null變量最初的賦值到底是什麼類型。

為瞭解業界針對這個問題給出的解決方案,我們一起簡單看看其他語言提供了哪些功能。

10.1.3 其他語言中null的替代品

近年來出現的語言,比如Groovy,通過引入安全導航操作符(Safe Navigation Operator,標記為?)可以安全訪問可能為null的變量。為了理解它是如何工作的,讓我們看看下面這段Groovy代碼,它的功能是獲取某個用戶替他的車保險的保險公司的名稱:

def carInsuranceName = person?.car?.insurance?.name

  

這段代碼的表述相當清晰。person對象可能沒有car對象,你試圖通過賦一個nullPerson對象的car引用,對這種可能性建模。類似地,car也可能沒有insurance。Groovy的安全導航操作符能夠避免在訪問這些可能為null引用的變量時拋出NullPointerException,在調用鏈中的變量遭遇null時將null引用沿著調用鏈傳遞下去,返回一個null

關於Java 7的討論中曾經建議過一個類似的功能,不過後來又被捨棄了。不知道為什麼,我們在Java中似乎並不特別期待出現一種安全導航操作符,幾乎所有的Java程序員碰到NullPointerException時的第一衝動就是添加一個if語句,在調用方法使用該變量之前檢查它的值是否為null,快速地搞定問題。如果你按照這種方式解決問題,絲毫不考慮你的算法或者你的數據模型在這種狀況下是否應該返回一個null,那麼你其實並沒有真正解決這個問題,只是暫時地掩蓋了問題,使得下次該問題的調查和修復更加困難,而你很可能就是下個星期或下個月要面對這個問題的人。剛才的那種方式實際上是掩耳盜鈴,只是在清掃地毯下的灰塵。而Groovy的null安全解引用操作符也只是一個更強大的掃把,讓我們可以毫無顧忌地犯錯。你不會忘記做這樣的檢查,因為類型系統會強制你進行這樣的操作。

另一些函數式語言,比如Haskell、Scala,試圖從另一個角度處理這個問題。Haskell中包含了一個Maybe類型,它本質上是對optional值的封裝。Maybe類型的變量可以是指定類型的值,也可以什麼都不是。但是它並沒有null引用的概念。Scala有類似的數據結構,名字叫Option[T],它既可以包含類型為T的變量,也可以不包含該變量,我們在第15章會詳細討論這種類型。要使用這種類型,你必須顯式地調用Option類型的available操作,檢查該變量是否有值,而這其實也是一種變相的“null檢查”。

好了,我們似乎有些跑題了,剛才這些聽起來都十分抽像。你可能會疑惑:“那麼Java 8提供了什麼呢?”嗯,實際上Java 8從“optional值”的想法中吸取了靈感,引入了一個名為java.util.Optional<T>的新的類。這一章裡,我們會展示使用這種方式對可能缺失的值建模,而不是直接將null賦值給變量所帶來的好處。我們還會闡釋從nullOptional的遷移,你需要反思的是:如何在你的域模型中使用optional值。最後,我們會介紹新的Optional類提供的功能,並附幾個實際的例子,展示如何有效地使用這些特性。最終,你會學會如何設計更好的API——用戶只需要閱讀方法簽名就能知道它是否接受一個optional的值。

10.2 Optional類入門

汲取HaskellScala的靈感,Java 8中引入了一個新的類java.util.Optional<T>。這是一個封裝Optional值的類。舉例來說,使用新的類意味著,如果你知道一個人可能有也可能沒有車,那麼Person類內部的car變量就不應該聲明為Car,遭遇某人沒有車時把null引用賦值給它,而是應該像圖10-1那樣直接將其聲明為Optional<Car>類型。

圖 10-1 使用Optional定義的Car

變量存在時,Optional類只是對類簡單封裝。變量不存在時,缺失的值會被建模成一個“空”的Optional對象,由方法Optional.empty返回。Optional.empty方法是一個靜態工廠方法,它返回Optional類的特定單一實例。你可能還有疑惑,null引用和Optional.empty有什麼本質的區別嗎?從語義上,你可以把它們當作一回事兒,但是實際中它們之間的差別非常大:如果你嘗試解引用一個null,一定會觸發NullPointerException,不過使用Optional.empty就完全沒事兒,它是Optional類的一個有效對象,多種場景都能調用,非常有用。關於這一點,接下來的部分會詳細介紹。

使用Optional而不是null的一個非常重要而又實際的語義區別是,第一個例子中,我們在聲明變量時使用的是Optional<Car>類型,而不是Car類型,這句聲明非常清楚地表明了這裡發生變量缺失是允許的。與此相反,使用Car這樣的類型,可能將變量賦值為null,這意味著你需要獨立面對這些,你只能依賴你對業務模型的理解,判斷一個null是否屬於該變量的有效範疇。

牢記上面這些原則,你現在可以使用Optional類對代碼清單10-1中最初的代碼進行重構,結果如下。

代碼清單10-4 使用Optional重新定義Person/Car/Insurance的數據模型

public class Person {
    private Optional<Car> car;                        ←─人可能有車,也可能沒有車,因此將這個字段聲明為Optional
    public Optional<Car> getCar { return car; }
}

public class Car {
    private Optional<Insurance> insurance;              ←─車可能進行了保險,也可能沒有保險,所以將這個字段聲明為Optional
    public Optional<Insurance> getInsurance { return insurance; }
}

public class Insurance {
    private String name;                         ←─保險公司必須有名字
    public String getName { return name; }
}

  

發現Optional是如何豐富你模型的語義了吧。代碼中person引用的是Optional<Car>,而car引用的是Optional<Insurance>,這種方式非常清晰地表達了你的模型中一個person可能擁有也可能沒有car的情形,同樣,car可能進行了保險,也可能沒有保險。

與此同時,我們看到insurance公司的名稱被聲明成String類型,而不是Optional<String>,這非常清楚地表明聲明為insurance公司的類型必須提供公司名稱。使用這種方式,一旦解引用insurance公司名稱時發生NullPointerException,你就能非常確定地知道出錯的原因,不再需要為其添加null的檢查,因為null的檢查只會掩蓋問題,並未真正地修復問題。insurance公司必須有個名字,所以,如果你遇到一個公司沒有名稱,你需要調查你的數據出了什麼問題,而不應該再添加一段代碼,將這個問題隱藏。

在你的代碼中始終如一地使用Optional,能非常清晰地界定出變量值的缺失是結構上的問題,還是你算法上的缺陷,抑或是你數據中的問題。另外,我們還想特別強調,引入Optional類的意圖並非要消除每一個null引用。與此相反,它的目標是幫助你更好地設計出普適的API,讓程序員看到方法簽名,就能瞭解它是否接受一個Optional的值。這種強制會讓你更積極地將變量從Optional中解包出來,直面缺失的變量值。

10.3 應用Optional的幾種模式

到目前為止,一切都很順利;你已經知道了如何使用Optional類型來聲明你的域模型,也瞭解了這種方式與直接使用null引用表示變量值的缺失的優劣。但是,我們該如何使用呢?用這種方式能做什麼,或者怎樣使用Optional封裝的值呢?

10.3.1 創建Optional對像

使用Optional之前,你首先需要學習的是如何創建Optional對象。完成這一任務有多種方法。

1. 聲明一個空的Optional

正如前文已經提到,你可以通過靜態工廠方法Optional.empty,創建一個空的Optional對像:

Optional<Car> optCar = Optional.empty;

  

2. 依據一個非空值創建Optional

你還可以使用靜態工廠方法Optional.of,依據一個非空值創建一個Optional對像:

Optional<Car> optCar = Optional.of(car);

  

如果car是一個null,這段代碼會立即拋出一個NullPointerException,而不是等到你試圖訪問car的屬性值時才返回一個錯誤。

3. 可接受nullOptional

最後,使用靜態工廠方法Optional.ofNullable,你可以創建一個允許null值的Optional對像:

Optional<Car> optCar = Optional.ofNullable(car);

  

如果carnull,那麼得到的Optional對象就是個空對象。

你可能已經猜到,我們還需要繼續研究“如何獲取Optional變量中的值”。尤其是,Optional提供了一個get方法,它能非常精準地完成這項工作,我們在後面會詳細介紹這部分內容。不過get方法在遭遇到空的Optional對像時也會拋出異常,所以不按照約定的方式使用它,又會讓我們再度陷入由null引起的代碼維護的夢魘。因此,我們首先從無需顯式檢查的Optional值的使用入手,這些方法與Stream中的某些操作極其相似。

10.3.2 使用mapOptional對像中提取和轉換值

從對像中提取信息是一種比較常見的模式。比如,你可能想要從insurance公司對像中提取公司的名稱。提取名稱之前,你需要檢查insurance對象是否為null,代碼如下所示:

String name = null;
if(insurance != null){
    name = insurance.getName;
}

  

為了支持這種模式,Optional提供了一個map方法。它的工作方式如下(這裡,我們繼續借用了代碼清單10-4的模式):

Optional<Insurance> optInsurance = Optional.ofNullable(insurance);
Optional<String> name = optInsurance.map(Insurance::getName);

  

從概念上,這與我們在第4章和第5章中看到的流的map方法相差無幾。map操作會將提供的函數應用於流的每個元素。你可以把Optional對像看成一種特殊的集合數據,它至多包含一個元素。如果Optional包含一個值,那函數就將該值作為參數傳遞給map,對該值進行轉換。如果Optional為空,就什麼也不做。圖10-2對這種相似性進行了說明,展示了把一個將正方形轉換為三角形的函數,分別傳遞給正方形和Optional正方形流的map方法之後的結果。

圖 10-2 StreamOptionalmap方法對比

這看起來挺有用,但是你怎樣才能應用起來,重構之前的代碼呢?前文的代碼裡用安全的方式鏈接了多個方法。

public String getCarInsuranceName(Person person) {
    return person.getCar.getInsurance.getName;
}

  

為了達到這個目的,我們需要求助Optional提供的另一個方法flatMap

10.3.3 使用flatMap鏈接Optional對像

由於我們剛剛學習了如何使用map,你的第一反應可能是我們可以利用map重寫之前的代碼,如下所示:

Optional<Person> optPerson = Optional.of(person);
Optional<String> name =
    optPerson.map(Person::getCar)
             .map(Car::getInsurance)
             .map(Insurance::getName);

  

不幸的是,這段代碼無法通過編譯。為什麼呢?optPersonOptional<Person>類型的變量, 調用map方法應該沒有問題。但getCar返回的是一個Optional<Car>類型的對象(如代碼清單10-4所示),這意味著map操作的結果是一個Optional<Optional<Car>>類型的對象。因此,它對getInsurance的調用是非法的,因為最外層的optional對像包含了另一個optional對象的值,而它當然不會支持getInsurance方法。圖10-3說明了你會遭遇的嵌套式optional結構。

圖 10-3 兩層的optional對像

所以,我們該如何解決這個問題呢?讓我們再回顧一下你剛剛在流上使用過的模式: flatMap方法。使用流時,flatMap方法接受一個函數作為參數,這個函數的返回值是另一個流。這個方法會應用到流中的每一個元素,最終形成一個新的流的流。但是flagMap會用流的內容替換每個新生成的流。換句話說,由方法生成的各個流會被合併或者扁平化為一個單一的流。這裡你希望的結果其實也是類似的,但是你想要的是將兩層的optional合併為一個。

跟圖10-2類似,我們借助圖10-4來說明flatMap方法在StreamOptional類之間的相似性。

圖 10-4 StreamOptionalflagMap方法對比

這個例子中,傳遞給流的flatMap方法會將每個正方形轉換為另一個流中的兩個三角形。那麼,map操作的結果就包含有三個新的流,每一個流包含兩個三角形,但flatMap方法會將這種兩層的流合併為一個包含六個三角形的單一流。類似地,傳遞給optionalflatMap方法的函數會將原始包含正方形的optional對像轉換為包含三角形的optional對象。如果將該方法傳遞給map方法,結果會是一個Optional對象,而這個Optional對像中包含了三角形;但flatMap方法會將這種兩層的Optional對像轉換為包含三角形的單一Optional對象。

1. 使用Optional獲取car的保險公司名稱

相信現在你已經對OptionalmapflatMap方法有了一定的瞭解,讓我們看看如何應用。代碼清單10-2和代碼清單10-3的示例用基於Optional的數據模式重寫之後,如代碼清單10-5所示。

代碼清單10-5 使用Optional獲取carInsurance名稱

public String getCarInsuranceName(Optional<Person> person) {
    return person.flatMap(Person::getCar)
                 .flatMap(Car::getInsurance)
                 .map(Insurance::getName)
                 .orElse("Unknown");    ←─如果Optional的結果值為空,設置默認值
}

  

通過比較代碼清單10-5和之前的兩個代碼清單,我們可以看到,處理潛在可能缺失的值時,使用Optional具有明顯的優勢。這一次,你可以用非常容易卻又普適的方法實現之前你期望的效果——不再需要使用那麼多的條件分支,也不會增加代碼的複雜性。

從具體的代碼實現來看,首先我們注意到你修改了代碼清單10-2和代碼清單10-3中的getCarInsuranceName方法的簽名,因為我們很明確地知道存在這樣的用例,即一個不存在的Person被傳遞給了方法,比如,Person是使用某個標識符從數據庫中查詢出來的,你想要對數據庫中不存在指定標識符對應的用戶數據的情況進行建模。你可以將方法的參數類型由Person改為Optional<Person>,對這種特殊情況進行建模。

我們再一次看到這種方式的優點,它通過類型系統讓你的域模型中隱藏的知識顯式地體現在你的代碼中,換句話說,你永遠都不應該忘記語言的首要功能就是溝通,即使對程序設計語言而言也沒有什麼不同。聲明方法接受一個Optional參數,或者將結果作為Optional類型返回,讓你的同事或者未來你方法的使用者,很清楚地知道它可以接受空值,或者它可能返回一個空值。

2. 使用Optional解引用串接的Person/Car/Insurance對像

Optional<Person>對象,我們可以結合使用之前介紹的mapflatMap方法,從Person中解引用出Car,從Car中解引用出Insurance,從Insurance對像中解引用出包含insurance公司名稱的字符串。圖10-5對這種流水線式的操作進行了說明。

圖 10-5 使用Optional解引用串接的Person/Car/Insurance

這裡,我們從以Optional封裝的Person入手,對其調用flatMap(Person::getCar)。如前所述,這種調用邏輯上可以劃分為兩步。第一步,某個Function作為參數,被傳遞給由Optional封裝的Person對象,對其進行轉換。這個場景中,Function的具體表現是一個方法引用,即對Person對象的getCar方法進行調用。由於該方法返回一個Optional<Car>類型的對象,Optional內的Person也被轉換成了這種對象的實例,結果就是一個兩層的Optional對象,最終它們會被flagMap操作合併。從純理論的角度而言,你可以將這種合併操作簡單地看成把兩個Optional對像結合在一起,如果其中有一個對像為空,就構成一個空的Optional對象。如果你對一個空的Optional對像調用flatMap,實際情況又會如何呢?結果不會發生任何改變,返回值也是個空的Optional對象。與此相反,如果Optional封裝了一個Person對象,傳遞給flapMapFunction,就會應用到Person上對其進行處理。這個例子中,由於Function的返回值已經是一個Optional對象,flapMap方法就直接將其返回。

第二步與第一步大同小異,它會將Optional<Car>轉換為Optional<Insurance> 。第三步則會將Optional<Insurance>轉化為Optional<String>對象,由於Insurance.getName方法的返回類型為String,這裡就不再需要進行flapMap操作了。

截至目前為止,返回的Optional可能是兩種情況:如果調用鏈上的任何一個方法返回一個空的Optional,那麼結果就為空,否則返回的值就是你期望的保險公司的名稱。那麼,你如何讀出這個值呢?畢竟你最後得到的這個對象還是個Optional<String>,它可能包含保險公司的名稱,也可能為空。代碼清單10-5中,我們使用了一個名為orElse的方法,當Optional的值為空時,它會為其設定一個默認值。除此之外,還有很多其他的方法可以為Optional設定默認值,或者解析出Optional代表的值。接下來我們會對此做進一步的探討。

在域模型中使用Optional,以及為什麼它們無法序列化

在代碼清單10-4中,我們展示了如何在你的域模型中使用Optional,將允許缺失或者暫無定義的變量值用特殊的形式標記出來。然而,Optional類設計者的初衷並非如此,他們構思時懷揣的是另一個用例。這一點,Java語言的架構師Brian Goetz曾經非常明確地陳述過,Optional的設計初衷僅僅是要支持能返回Optional對象的語法。

由於Optional類設計時就沒特別考慮將其作為類的字段使用,所以它也並未實現Serializable接口。由於這個原因,如果你的應用使用了某些要求序列化的庫或者框架,在域模型中使用Optional,有可能引發應用程序故障。然而,我們相信,通過前面的介紹,你已經看到用Optional聲明域模型中的某些類型是個不錯的主意,尤其是你需要遍歷有可能全部或部分為空,或者可能不存在的對象時。如果你一定要實現序列化的域模型,作為替代方案,我們建議你像下面這個例子那樣,提供一個能訪問聲明為Optional、變量值可能缺失的接口,代碼清單如下:

public class Person {
    private Car car;
    public Optional<Car> getCarAsOptional {
        return Optional.ofNullable(car);
    }
}

  

10.3.4 默認行為及解引用Optional對像

我們決定採用orElse方法讀取這個變量的值,使用這種方式你還可以定義一個默認值,遭遇空的Optional變量時,默認值會作為該方法的調用返回值。Optional類提供了多種方法讀取Optional實例中的變量值。

  • get是這些方法中最簡單但又最不安全的方法。如果變量存在,它直接返回封裝的變量值,否則就拋出一個NoSuchElementException異常。所以,除非你非常確定Optional變量一定包含值,否則使用這個方法是個相當糟糕的主意。此外,這種方式即便相對於嵌套式的null檢查,也並未體現出多大的改進。

  • orElse(T other)是我們在代碼清單10-5中使用的方法,正如之前提到的,它允許你在Optional對像不包含值時提供一個默認值。

  • orElseGet(Supplier<? extends T> other)orElse方法的延遲調用版,Supplier方法只有在Optional對像不含值時才執行調用。如果創建默認值是件耗時費力的工作,你應該考慮採用這種方式(借此提升程序的性能),或者你需要非常確定某個方法僅在Optional為空時才進行調用,也可以考慮該方式(這種情況有嚴格的限制條件)。

  • orElseThrow(Supplier<? extends X> exceptionSupplier)get方法非常類似,它們遭遇Optional對像為空時都會拋出一個異常,但是使用orElseThrow你可以定制希望拋出的異常類型。

  • ifPresent(Consumer<? super T>)讓你能在變量值存在時執行一個作為參數傳入的方法,否則就不進行任何操作。

Optional類和Stream接口的相似之處,遠不止mapflatMap這兩個方法。還有第三個方法filter,它的行為在兩種類型之間也極其相似,我們會在10.3.6節做進一步的介紹。

10.3.5 兩個Optional對象的組合

現在,我們假設你有這樣一個方法,它接受一個Person和一個Car對象,並以此為條件對外部提供的服務進行查詢,通過一些複雜的業務邏輯,試圖找到滿足該組合的最便宜的保險公司:

public Insurance findCheapestInsurance(Person person, Car car) {
    // 不同的保險公司提供的查詢服務
    // 對比所有數據
    return cheapestCompany;
}

  

我們還假設你想要該方法的一個null-安全的版本,它接受兩個Optional對像作為參數,返回值是一個Optional<Insurance>對象,如果傳入的任何一個參數值為空,它的返回值亦為空。Optional類還提供了一個isPresent方法,如果Optional對像包含值,該方法就返回true,所以你的第一想法可能是通過下面這種方式實現該方法:

public Optional<Insurance> nullSafeFindCheapestInsurance(
                              Optional<Person> person, Optional<Car> car) {
    if (person.isPresent && car.isPresent) {
        return Optional.of(findCheapestInsurance(person.get, car.get));
    } else {
        return Optional.empty;
    }
}

  

這個方法具有明顯的優勢,我們從它的簽名就能非常清楚地知道無論是person還是car,它的值都有可能為空,出現這種情況時,方法的返回值也不會包含任何值。不幸的是,該方法的具體實現和你之前曾經實現的null檢查太相似了:方法接受一個Person和一個Car對像作為參數,而二者都有可能為null。利用Optional類提供的特性,有沒有更好或更地道的方式來實現這個方法呢? 花幾分鐘時間思考一下測驗10.1,試試能不能找到更優雅的解決方案。

測驗10.1:以不解包的方式組合兩個Optional對像

結合本節中介紹的mapflatMap方法,用一行語句重新實現之前出現的nullSafeFindCheapestInsurance方法。

答案:你可以像使用三元操作符那樣,無需任何條件判斷的結構,以一行語句實現該方法,代碼如下。

public Optional<Insurance> nullSafeFindCheapestInsurance(
                              Optional<Person> person, Optional<Car> car) {
    return person.flatMap(p -> car.map(c -> findCheapestInsurance(p, c)));
}

  

這段代碼中,你對第一個Optional對像調用flatMap方法,如果它是個空值,傳遞給它的Lambda表達式不會執行,這次調用會直接返回一個空的Optional對象。反之,如果person對像存在,這次調用就會將其作為函數Function的輸入,並按照與flatMap方法的約定返回一個Optional<Insurance>對象。這個函數的函數體會對第二個Optional對像執行map操作,如果第二個對象不包含car,函數Function就返回一個空的Optional對象,整個nullSafeFindCheapestInsuranc方法的返回值也是一個空的Optional對象。最後,如果personcar對象都存在,作為參數傳遞給map方法的Lambda表達式能夠使用這兩個值安全地調用原始的findCheapestInsurance方法,完成期望的操作。

Optional類和Stream接口的相似之處遠不止mapflatMap這兩個方法。還有第三個方法filter,它的行為在兩種類型之間也極其相似,我們在接下來的一節會進行介紹。

10.3.6 使用filter剔除特定的值

你經常需要調用某個對象的方法,查看它的某些屬性。比如,你可能需要檢查保險公司的名稱是否為“Cambridge-Insurance”。為了以一種安全的方式進行這些操作,你首先需要確定引用指向的Insurance對象是否為null,之後再調用它的getName方法,如下所示:

Insurance insurance = ...;
if(insurance != null && "CambridgeInsurance".equals(insurance.getName)){
   System.out.println("ok");
}

  

使用Optional對象的filter方法,這段代碼可以重構如下:

Optional<Insurance> optInsurance = ...;
optInsurance.filter(insurance ->
                        "CambridgeInsurance".equals(insurance.getName))
            .ifPresent(x -> System.out.println("ok"));

  

filter方法接受一個謂詞作為參數。如果Optional對象的值存在,並且它符合謂詞的條件,filter方法就返回其值;否則它就返回一個空的Optional對象。如果你還記得我們可以將Optional看成最多包含一個元素的Stream對象,這個方法的行為就非常清晰了。如果Optional對像為空,它不做任何操作,反之,它就對Optional對像中包含的值施加謂詞操作。如果該操作的結果為true,它不做任何改變,直接返回該Optional對象,否則就將該值過濾掉,將Optional的值置空。通過測驗10.2,可以測試你對filter方法工作方式的理解。

測驗10.2:對Optional對像進行過濾

假設在我們的Person/Car/Insurance 模型中,Person還提供了一個方法可以取得Person對象的年齡,請使用下面的簽名改寫代碼清單10-5中的getCarInsuranceName方法:

public String getCarInsuranceName(Optional<Person> person, int minAge)

  

找出年齡大於或者等於minAge參數的Person所對應的保險公司列表。

答案:你可以對Optional封裝的Person對像進行filter操作,設置相應的條件謂詞,即如果person的年齡大於minAge參數的設定值,就返回該值,並將謂詞傳遞給filter方法,代碼如下所示。

public String getCarInsuranceName(Optional<Person> person, int minAge) {
    return person.filter(p -> p.getAge >= minAge)
                 .flatMap(Person::getCar)
                 .flatMap(Car::getInsurance)
                 .map(Insurance::getName)
                 .orElse("Unknown");
}

  

下一節中,我們會探討Optional類剩下的一些特性,並提供更實際的例子,展示多種你能夠應用於代碼中更好地管理缺失值的技巧。

表10-1對Optional類中的方法進行了分類和概括。

表10-1 Optional類的方法

方法

描述

empty

返回一個空的Optional實例

filter

如果值存在並且滿足提供的謂詞,就返回包含該值的Optional對像;否則返回一個空的Optional對像

flatMap

如果值存在,就對該值執行提供的mapping函數調用,返回一個Optional類型的值,否則就返回一個空的Optional對像

get

如果該值存在,將該值用Optional封裝返回,否則拋出一個NoSuchElementException異常

ifPresent

如果值存在,就執行使用該值的方法調用,否則什麼也不做

isPresent

如果值存在就返回true,否則返回false

map

如果值存在,就對該值執行提供的mapping函數調用

of

將指定值用Optional封裝之後返回,如果該值為null,則拋出一個NullPointerException異常

ofNullable

將指定值用Optional封裝之後返回,如果該值為null,則返回一個空的Optional對像

orElse

如果有值則將其返回,否則返回一個默認值

orElseGet

如果有值則將其返回,否則返回一個由指定的Supplier接口生成的值

orElseThrow

如果有值則將其返回,否則拋出一個由指定的Supplier接口生成的異常

10.4 使用Optional的實戰示例

相信你已經瞭解,有效地使用Optional類意味著你需要對如何處理潛在缺失值進行全面的反思。這種反思不僅僅限於你曾經寫過的代碼,更重要的可能是,你如何與原生Java API實現共存共贏。

實際上,我們相信如果Optional類能夠在這些API創建之初就存在的話,很多API的設計編寫可能會大有不同。為了保持後向兼容性,我們很難對老的Java API進行改動,讓它們也使用Optional,但這並不表示我們什麼也做不了。你可以在自己的代碼中添加一些工具方法,修復或者繞過這些問題,讓你的代碼能享受Optional帶來的威力。我們會通過幾個實際的例子講解如何達到這樣的目的。

10.4.1 用Optional封裝可能為null的值

現存Java API幾乎都是通過返回一個null的方式來表示需要值的缺失,或者由於某些原因計算無法得到該值。比如,如果Map中不含指定的鍵對應的值,它的get方法會返回一個null。但是,正如我們之前介紹的,大多數情況下,你可能希望這些方法能返回一個Optional對象。你無法修改這些方法的簽名,但是你很容易用Optional對這些方法的返回值進行封裝。我們接著用Map做例子,假設你有一個Map<String, Object>方法,訪問由key索引的值時,如果map中沒有與key關聯的值,該次調用就會返回一個null

Object value = map.get("key");

  

使用Optional封裝map的返回值,你可以對這段代碼進行優化。要達到這個目的有兩種方式:你可以使用笨拙的if-then-else判斷語句,毫無疑問這種方式會增加代碼的複雜度;或者你可以採用我們前文介紹的Optional.ofNullable方法:

Optional<Object> value = Optional.ofNullable(map.get("key"));

  

每次你希望安全地對潛在為null的對象進行轉換,將其替換為Optional對像時,都可以考慮使用這種方法。

10.4.2 異常與Optional的對比

由於某種原因,函數無法返回某個值,這時除了返回null,Java API比較常見的替代做法是拋出一個異常。這種情況比較典型的例子是使用靜態方法Integer.parseInt(String),將String轉換為int。在這個例子中,如果String無法解析到對應的整型,該方法就拋出一個NumberFormatException。最後的效果是,發生String無法轉換為int時,代碼發出一個遭遇非法參數的信號,唯一的不同是,這次你需要使用try/catch 語句,而不是使用if條件判斷來控制一個變量的值是否非空。

你也可以用空的Optional對象,對遭遇無法轉換的String時返回的非法值進行建模,這時你期望parseInt的返回值是一個optional。我們無法修改最初的Java方法,但是這無礙我們進行需要的改進,你可以實現一個工具方法,將這部分邏輯封裝於其中,最終返回一個我們希望的Optional對象,代碼如下所示。

代碼清單10-6 將String轉換為Integer,並返回一個Optional對像

public static Optional<Integer> stringToInt(String s) {
    try {
        return Optional.of(Integer.parseInt(s));    ←─如果String能轉換為對應的Integer,將其封裝在Optioal對像中返回
    } catch (NumberFormatException e) {
        return Optional.empty;    ←─否則返回一個空的Optional對像
    }
}

  

我們的建議是,你可以將多個類似的方法封裝到一個工具類中,讓我們稱之為OptionalUtility。通過這種方式,你以後就能直接調用OptionalUtility.stringToInt方法,將String轉換為一個Optional<Integer>對象,而不再需要記得你在其中封裝了笨拙的try/catch的邏輯了。

基礎類型的Optional對象,以及為什麼應該避免使用它們

不知道你注意到了沒有,與Stream對像一樣,Optional也提供了類似的基礎類 型——OptionalIntOptionalLong以及OptionalDouble——所以代碼清單10-6中的方法可以不返回Optional<Integer>,而是直接返回一個OptionalInt類型的對象。第5章中,我們討論過使用基礎類型Stream的場景,尤其是如果Stream對像包含了大量元素,出於性能的考量,使用基礎類型是不錯的選擇,但對Optional對像而言,這個理由就不成立了,因為Optional對像最多只包含一個值。

我們不推薦大家使用基礎類型的Optional,因為基礎類型的Optional不支持mapflatMap以及filter方法,而這些卻是Optional類最有用的方法(正如我們在10.2節所看到的那樣)。此外,與Stream一樣,Optional對像無法由基礎類型的Optional組合構成,所以,舉例而言,如果代碼清單10-6中返回的是OptionalInt類型的對象,你就不能將其作為方法引用傳遞給另一個Optional對象的flatMap方法。

10.4.3 把所有內容整合起來

為了展示之前介紹過的Optional類的各種方法整合在一起的威力,我們假設你需要向你的程序傳遞一些屬性。為了舉例以及測試你開發的代碼,你創建了一些示例屬性,如下所示:

Properties props = new Properties;
props.setProperty("a", "5");
props.setProperty("b", "true");
props.setProperty("c", "-3");

  

現在,我們假設你的程序需要從這些屬性中讀取一個值,該值是以秒為單位計量的一段時間。由於一段時間必須是正數,你想要該方法符合下面的簽名:

public int readDuration(Properties props, String name)

  

即,如果給定屬性對應的值是一個代表正整數的字符串,就返回該整數值,任何其他的情況都返回0。為了明確這些需求,你可以採用JUnit的斷言,將它們形式化:

assertEquals(5, readDuration(param, "a"));
assertEquals(0, readDuration(param, "b"));
assertEquals(0, readDuration(param, "c"));
assertEquals(0, readDuration(param, "d"));

  

這些斷言反映了初始的需求:如果屬性是areadDuration方法返回5,因為該屬性對應的字符串能映射到一個正數;對於屬性b,方法的返回值是0,因為它對應的值不是一個數字;對於c,方法的返回值是0,因為雖然它對應的值是個數字,不過它是個負數;對於d,方法的返回值是0,因為並不存在該名稱對應的屬性。讓我們以命令式編程的方式實現滿足這些需求的方法,代碼清單如下所示。

代碼清單10-7 以命令式編程的方式從屬性中讀取duration

public int readDuration(Properties props, String name) {
    String value = props.getProperty(name);
    if (value != null) {                 ←─確保名稱對應的屬性存在
        try {
            int i = Integer.parseInt(value);    ←─將String屬性轉換為數字類型
            if (i > 0) {           ←─檢查返回的數字是否為正數
                return i;
            }
        } catch (NumberFormatException nfe) { }
    }
    return 0;    ←─如果前述的條件都不滿足,返回0
}

  

你可能已經預見,最終的實現既複雜又不具備可讀性,呈現為多個由if語句及try/catch塊兒構成的嵌套條件。花幾分鐘時間思考一下測驗10.3, 想想怎樣使用本章內容實現同樣的效果。

測驗10.3:使用Optional從屬性中讀取duration

請嘗試使用Optional類提供的特性及代碼清單10-6中提供的工具方法,通過一條精煉的語句重構代碼清單10-7中的方法。

答案:如果需要訪問的屬性值不存在,Properties.getProperty(String)方法的返回值就是一個null,使用ofNullable工廠方法非常輕易地就能把該值轉換為Optional對象。接著,你可以向它的flatMap方法傳遞代碼清單10-6中實現的OptionalUtility.stringToInt方法的引用,將Optional<String>轉換為Optional<Integer>。最後,你非常輕易地就可以過濾掉負數。這種方式下,如果任何一個操作返回一個空的Optional對象,該方法都會返回orElse方法設置的默認值0;否則就返回封裝在Optional對像中的正整數。下面就是這段簡化的實現:

public int readDuration(Properties props, String name) {
    return Optional.ofNullable(props.getProperty(name))
                   .flatMap(OptionalUtility::stringToInt)
                   .filter(i -> i > 0)
                   .orElse(0);
}

  

注意到使用OptionalStream時的那些通用模式了嗎?它們都是對數據庫查詢過程的反思,查詢時,多種操作會被串接在一起執行。

10.5 小結

這一章中,你學到了以下的內容。

  • null引用在歷史上被引入到程序設計語言中,目的是為了表示變量值的缺失。

  • Java 8中引入了一個新的類java.util.Optional<T>,對存在或缺失的變量值進行建模。

  • 你可以使用靜態工廠方法Optional.emptyOptional.of以及Optional.ofNullable創建Optional對象。

  • Optional類支持多種方法,比如mapflatMapfilter,它們在概念上與Stream類中對應的方法十分相似。

  • 使用Optional會迫使你更積極地解引用Optional對象,以應對變量值缺失的問題,最終,你能更有效地防止代碼中出現不期而至的空指針異常。

  • 使用Optional能幫助你設計更好的API,用戶只需要閱讀方法簽名,就能瞭解該方法是否接受一個Optional類型的值。