Java函式式開發——優雅的Optional空指標處理

隨風溜達的向日葵發表於2016-09-03

摘要

空閒時會抽空學習同在jvm上執行的Groovy和Scala,發現他們對null的處理比早期版本Java慎重很多。在Java8中,Optional為函數語言程式設計的null處理給出了非常優雅的解決方案。本文將說明長久以來Java中對null的蹩腳處理,然後介紹使用Optional來實現Java函數語言程式設計。

那些年困擾著我們的null

在Java江湖流傳著這樣一個傳說:直到真正瞭解了空指標異常,才能算一名合格的Java開發人員。在我們逼格閃閃的java碼字元生涯中,每天都會遇到各種null的處理,像下面這樣的程式碼可能我們每天都在反覆編寫:

if(null != obj1){
  if(null != obje2){
     // do something
  }
}

稍微有點眼界javaer就去幹一些稍有逼格的事,弄一個判斷null的方法:

boolean checkNotNull(Object obj){
  return null == obj ? false : true; 
}

void do(){
  if(checkNotNull(obj1)){
     if(checkNotNull(obj2)){
        //do something
     }
  }
}

然後,問題又來了:如果一個null表示一個空字串,那”"表示什麼?

然後慣性思維告訴我們,”"和null不都是空字串碼?索性就把判斷空值升級了一下:

boolean checkNotBlank(Object obj){
  return null != obj && !"".equals(obj) ? true : false; 
}
void do(){
  if(checkNotBlank(obj1)){
     if(checkNotNull(obj2)){
        //do something
     }
  }
}

有空的話各位可以看看目前專案中或者自己過往的程式碼,到底寫了多少和上面類似的程式碼。

不知道你是否認真思考過一個問題:一個null到底意味著什麼?

  1. 淺顯的認識——null當然表示“值不存在”。
  2. 對記憶體管理有點經驗的理解——null表示記憶體沒有被分配,指標指向了一個空地址。
  3. 稍微透徹點的認識——null可能表示某個地方處理有問題了,也可能表示某個值不存在。
  4. 被虐千萬次的認識——哎喲,又一個NullPointerException異常,看來我得加一個if(null != value)了。

回憶一下,在我們們前面碼字生涯中到底遇到過多少次java.lang.NullPointerException異常?NullPointerException作為一個RuntimeException級別的異常不用顯示捕獲,若不小心處理我們經常會在生產日誌中看到各種由NullPointerException引起的異常堆疊輸出。而且根據這個異常堆疊資訊我們根本無法定位到導致問題的原因,因為並不是丟擲NullPointerException的地方引發了這個問題。我們得更深處去查詢什麼地方產生了這個null,而這個時候日誌往往無法跟蹤。

有時更悲劇的是,產生null值的地方往往不在我們自己的專案程式碼中。這就存在一個更尷尬的事實——在我們呼叫各種良莠不齊第三方介面時,說不清某個介面在某種機緣巧合的情況下就會返回一個null……

回到前面對null的認知問題。很多javaer認為null就是表示“什麼都沒有”或者“值不存在”。按照這個慣性思維我們的程式碼邏輯就是:你呼叫我的介面,按照你給我的引數返回對應的“值”,如果這條件沒法找到對應的“值”,那我當然返回一個null給你表示沒有“任何東西”了。我們看看下面這個程式碼,用很傳統很標準的Java編碼風格編寫:

class MyEntity{
   int id;
   String name;
   String getName(){
      return name;
   }
}

// main
public class Test{
   public static void main(String[] args) 
       final MyEntity myEntity = getMyEntity(false);
       System.out.println(myEntity.getName());
   }

   private getMyEntity(boolean isSuc){
       if(isSuc){
           return new MyEntity();
       }else{
           return null;
       }
   }
}

這一段程式碼很簡單,日常的業務程式碼肯定比這個複雜的多,但是實際上我們大量的Java編碼都是按這種套路編寫的,懂貨的人一眼就可以看出最終肯定會丟擲NullPointerException。但是在我們編寫業務程式碼時,很少會想到要處理這個可能會出現的null(也許API文件已經寫得很清楚在某些情況下會返回null,但是你確保你會認真看完API文件後才開始寫程式碼麼?),直到我們到了某個測試階段,突然蹦出一個NullPointerException異常,我們才意識到原來我們得像下面這樣加一個判斷來搞定這個可能會返回的null值。

// main
public class Test{
   public static void main(String[] args) 
       final MyEntity myEntity = getMyEntity(false);
       if(null != myEntity){
           System.out.println(myEntity.getName());
       }else{
           System.out.println("ERROR");
       }
   }
}

仔細想想過去這麼些年,我們們是不是都這樣幹過來的?如果直到測試階段才能發現某些null導致的問題,那麼現在問題就來了——在那些雍容繁雜、層次分明的業務程式碼中到底還有多少null沒有被正確處理呢?

對於null的處理態度,往往可以看出一個專案的成熟和嚴謹程度。比如Guava早在JDK1.6之前就給出了優雅的null處理方式,可見功底之深。

鬼魅一般的null阻礙我們進步

如果你是一位聚焦於傳統物件導向開發的Javaer,或許你已經習慣了null帶來的種種問題。但是早在許多年前,大神就說了null這玩意就是個坑。

託尼.霍爾(你不知道這貨是誰嗎?自己去查查吧)曾經說過:“I call it my billion-dollar mistake. It was the invention of the null reference in 1965. I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement.”(大意是:“哥將發明null這事稱為價值連城的錯誤。因為在1965那個計算機的蠻荒時代,空引用太容易實現,讓哥根本經不住誘惑發明了空指標這玩意。”)。

然後,我們再看看null還會引入什麼問題。

看看下面這個程式碼:

String address = person.getCountry().getProvince().getCity();

如果你玩過一些函式式語言(Haskell、Erlang、Clojure、Scala等等),上面這樣是一種很自然的寫法。用Java當然也可以實現上面這樣的編寫方式。

但是為了完滿的處理所有可能出現的null異常,我們不得不把這種優雅的函式程式設計正規化改為這樣:

if (person != null) {
	Country country = person.getCountry();
	if (country != null) {
		Province province = country.getProvince();
		if (province != null) {
			address = province.getCity();
		}
	}
}

瞬間,高逼格的函數語言程式設計Java8又回到了10年前。這樣一層一層的巢狀判斷,增加程式碼量和不優雅還是小事。更可能出現的情況是:在大部分時間裡,人們會忘記去判斷這可能會出現的null,即使是寫了多年程式碼的老人家也不例外。

上面這一段層層巢狀的 null 處理,也是傳統Java長期被詬病的地方。如果以Java早期版本作為你的啟蒙語言,這種get->if null->return 的臭毛病會影響你很長的時間(記得在某國外社群,這被稱為:面向entity開發)。

利用Optional實現Java函數語言程式設計

好了,說了各種各樣的毛病,然後我們可以進入新時代了。

早在推出Java SE 8版本之前,其他類似的函式式開發語言早就有自己的各種解決方案。下面是Groovy的程式碼:

String version = computer?.getSoundcard()?.getUSB()?.getVersion():"unkonwn";

Haskell用一個 Maybe 型別類標識處理null值。而號稱多正規化開發語言的Scala則提供了一個和Maybe差不多意思的Option[T],用來包裹處理null。

Java8引入了 java.util.Optional<T>來處理函數語言程式設計的null問題,Optional<T>的處理思路和Haskell、Scala類似,但又有些許區別。先看看下面這個Java程式碼的例子:

public class Test {
	public static void main(String[] args) {
		final String text = "Hallo world!";
		Optional.ofNullable(text)//顯示建立一個Optional殼
		    .map(Test::print)
			.map(Test::print)
			.ifPresent(System.out::println);

		Optional.ofNullable(text)
			.map(s ->{ 
				System.out.println(s);
				return s.substring(6);
			})
			.map(s -> null)//返回 null
			.ifPresent(System.out::println);
	}
	// 列印並擷取str[5]之後的字串
	private static String print(String str) {
		System.out.println(str);
		return str.substring(6);
	}
}
//Consol 輸出
//num1:Hallo world!
//num2:world!
//num3:
//num4:Hallo world!

 (可以把上面的程式碼copy到你的IDE中執行,前提是必須安裝了JDK8。)

上面的程式碼中建立了2個Optional,實現的功能基本相同,都是使用Optional作為String的外殼對String進行截斷處理。當在處理過程中遇到null值時,就不再繼續處理。我們可以發現第二個Optional中出現s->null之後,後續的ifPresent不再執行。

注意觀察輸出的 //num3:,這表示輸出了一個”"字元,而不是一個null。

Optional提供了豐富的介面來處理各種情況,比如可以將程式碼修改為:

public class Test {
	public static void main(String[] args) {
		final String text = "Hallo World!";
		System.out.println(lowerCase(text));//方法一
		lowerCase(null, System.out::println);//方法二
	}

	private static String lowerCase(String str) {
		return Optional.ofNullable(str).map(s -> s.toLowerCase()).map(s->s.replace("world", "java")).orElse("NaN");
	}

	private static void lowerCase(String str, Consumer<String> consumer) {
		consumer.accept(lowerCase(str));
	}
}
//輸出
//hallo java!
//NaN

這樣,我們可以動態的處理一個字串,如果在任何時候發現值為null,則使用orElse返回預設預設的“NaN”

總的來說,我們可以將任何資料結構用Optional包裹起來,然後使用函式式的方式對他進行處理,而不必關心隨時可能會出現的null

我們看看前面提到的Person.getCountry().getProvince().getCity()怎麼不用一堆if來處理。

第一種方法是不改變以前的entity:

import java.util.Optional;
public class Test {
	public static void main(String[] args) {
		System.out.println(Optional.ofNullable(new Person())
			.map(x->x.country)
			.map(x->x.provinec)
			.map(x->x.city)
			.map(x->x.name)
			.orElse("unkonwn"));
	}
}
class Person {
	Country country;
}
class Country {
	Province provinec;
}
class Province {
	City city;
}
class City {
	String name;
}

這裡用Optional作為每一次返回的外殼,如果有某個位置返回了null,則會直接得到”unkonwn”。

第二種辦法是將所有的值都用Optional來定義:

import java.util.Optional;
public class Test {
	public static void main(String[] args) {
		System.out.println(new Person()
				.country.flatMap(x -> x.provinec)
				.flatMap(Province::getCity)
				.flatMap(x -> x.name)
				.orElse("unkonwn"));
	}
}
class Person {
	Optional<Country> country = Optional.empty();
}
class Country {
	Optional<Province> provinec;
}
class Province {
	Optional<City> city;
	Optional<City> getCity(){//用於::
		return city;
	}
}
class City {
	Optional<String> name;
}

第一種方法可以平滑的和已有的JavaBean、EntityPOJA整合,而無需改動什麼,也能更輕鬆的整合到第三方介面中(例如springbean)。建議目前還是以第一種Optional的使用方法為主,畢竟不是團隊中每一個人都能理解每個get/set帶著一個Optional的用意。

Optional還提供了一個filter方法用於過濾資料(實際上Java8stream風格的介面都提供了filter方法)。例如過去我們判斷值存在並作出相應的處理:

if(Province!= null){
  City city = Province.getCity();
  if(null != city && "guangzhou".equals(city.getName()){
    System.out.println(city.getName());
  }else{
    System.out.println("unkonwn");
  }
}

現在我們可以修改為

Optional.ofNullable(province)
   .map(x->x.city)
   .filter(x->"guangzhou".equals(x.getName()))
   .map(x->x.name)
   .orElse("unkonw");

到此,利用Optional來進行函數語言程式設計介紹完畢。Optional除了上面提到的方法,還有orElseGetorElseThrow等根據更多需要提供的方法。orElseGet會因為出現null值丟擲空指標異常,而orElseThrow會在出現null時,丟擲一個使用者自定義的異常。可以檢視API文件來了解所有方法的細節。

寫在最後的

Optional只是Java函數語言程式設計的冰山一角,需要結合lambdastreamFuncationinterface等特性才能真正的瞭解Java8函數語言程式設計的效用。本來還想介紹一些Optional的原始碼和執行原理的,但是Optional本身的程式碼就很少、API介面也不多,仔細想想也沒什麼好說的就省略了。

Optional雖然優雅,但是個人感覺有一些效率問題,不過還沒去驗證。如果有誰有確實的資料,請告訴我。

本人也不是“函數語言程式設計支持者”。從團隊管理者的角度來說,每提升一點學習難度,人員的使用成本和團隊互動成本就會更高一些。就像在傳說中Lisp可以比C++的程式碼量少三十倍、開發更高效,但是若一個國內的常規IT公司真用Lisp來做專案,請問去哪、得花多少錢弄到這些用Lisp的哥們啊?

但是我非常鼓勵大家都學習和了解函數語言程式設計的思路。尤其是過去只侵淫在Java這一門語言、到現在還不清楚Java8會帶來什麼改變的開發人員,Java8是一個良好的契機。更鼓勵把新的Java8特性引入到目前的專案中,一個長期配合的團隊以及一門古老的程式語言都需要不斷的注入新活力,否則不進則退。

相關文章