《新版Java開發手冊》提到的三目運算符的空指針問題究竟是個怎麼回事?

最近,阿里巴巴Java開發手冊發佈了最新版——泰山版,這個名字起的不錯,一覽衆山小。html

新版新增了30+規約,其中有一條規約引發了做者的關注,那就是手冊中提到在三目運算符使用過程當中,須要注意自動拆箱致使的NullPointerException(後文簡稱:NPE)問題:java

由於這個問題我好久以前(2015年)遇到過,曾經在博客中也記錄過,恰好最新的開發手冊再次提到了這個知識點,因而把以前的文章內容翻出來並從新整理了一下,帶你們一塊兒回顧下這個知識點。程序員

可能有些人看過我以前那篇文章,本文並非單純的"舊瓶裝新酒",在從新梳理這個知識點的時候,做者從新翻閱了《The Java Language Specification》,而且對比了Java SE 7 和 Java SE 8以後的相關變化,但願能夠幫助你們更加全面的理解這個問題。express

基礎回顧

在詳細展看介紹以前,先簡單介紹下本文要涉及到的幾個重要概念,分別是"三目運算符"、"自動拆裝箱"等,若是你們對於這些歷史知識有所掌握的話,能夠先跳過本段內容,直接看問題重現部分便可。oracle

三目運算符

在《The Java Language Specification》中,三目運算符的官方名稱是 Conditional Operator ? : ,我通常稱呼他爲條件表達式,詳細介紹在JLS 15.25中,這裏簡單介紹下其基本形式和用法:app

三目運算符是Java語言中的重要組成部分,它也是惟一有3個操做數的運算符。形式爲:編輯器

<表達式1> ? <表達式2> : <表達式3>
複製代碼

以上,經過組合的形式獲得一個條件表達式。其中運算符的含義是:先求表達式1的值,若是爲真,則執行並返回表達式2的結果;若是表達式1的值爲假,則執行並返回表達式3的結果。工具

值得注意的是,一個條件表達式從不會既計算<表達式2>,又計算<表達式3>。條件運算符是右結合的,也就是說,從右向左分組計算。例如,a?b:c?d:e將按a?b:(c?d:e)執行。單元測試

自動裝箱與自動拆箱

介紹過了三目運算符(條件表達式)以後,咱們再來簡單介紹下Java中的自動拆裝箱相關知識點。學習

每個Java開發者必定都對Java中的基本數據類型不陌生,Java中共有8種基本數據類型,這些基礎數據類型帶來一個好處就是他們直接在棧內存中存儲,不會在堆上分配內存,使用起來更加高效。

可是,Java語言是一個面向對象的語言,而基本數據類型不是對象,致使在實際使用過程當中有諸多不便,如集合類要求其內部元素必須是Object類型,基本數據類型就沒法使用。

因此,相對應的,Java提供了8種包裝類型,更加方便在須要對象的地方使用。

有了基本數據類型和包裝類,帶來了一個麻煩就是須要在他們之間進行轉換。在Java SE5中,爲了減小開發人員的工做,Java提供了自動拆箱與自動裝箱功能。

自動裝箱: 就是將基本數據類型自動轉換成對應的包裝類。

自動拆箱:就是將包裝類自動轉換成對應的基本數據類型。

Integer i =10;  //自動裝箱
int b= i;     //自動拆箱
複製代碼

咱們能夠簡單理解爲,當咱們本身寫的代碼符合裝(拆)箱規範的時候,編譯器就會自動幫咱們拆(裝)箱。

自動裝箱都是經過包裝類的valueOf()方法來實現的.自動拆箱都是經過包裝類對象的xxxValue()來實現的(如booleanValue()、longValue()等)。

問題重現

在最新版的開發手冊中給出了一個例子,提示咱們在使用三目運算符的過程當中,可能會進行自動拆箱而致使NPE問題。

原文中的例子相對複雜一些,由於他還涉及到多個Integer相乘的結果是int的問題,咱們舉一個相對簡單的一點的例子先來重現下這個問題:

boolean flag = true; //設置成true,保證條件表達式的表達式二必定能夠執行
boolean simpleBoolean = false; //定義一個基本數據類型的boolean變量
Boolean nullBoolean = null;//定義一個包裝類對象類型的Boolean變量,值爲null

boolean x = flag ? nullBoolean : simpleBoolean; //使用三目運算符並給x變量賦值
複製代碼

以上代碼,在運行過程當中,會拋出NPE:

Exception in thread "main" java.lang.NullPointerException
複製代碼

並且,這個和你使用的JDK版本是無關的,做者分別在JDK 六、JDK 8和JDK 14上作了測試,均會拋出NPE。

爲了一探究竟,咱們嘗試對以上代碼進行反編譯,使用jad工具進行反編譯後,獲得如下代碼:

boolean flag = true;
boolean simpleBoolean = false;
Boolean nullBoolean = null;
boolean x = flag ? nullBoolean.booleanValue() : simpleBoolean;
複製代碼

能夠看到,反編譯後的代碼的最後一行,編譯器幫咱們作了一次自動拆箱,而就是由於此次自動拆箱,致使代碼出現對於一個null對象(nullBoolean.booleanValue())的調用,致使了NPE。

那麼,爲何編譯器會進行自動拆箱呢?什麼狀況下須要進行自動拆箱呢?

原理分析

關於爲何編輯器會在代碼編譯階段對於三目運算符中的表達式進行自動拆箱,其實在《The Java Language Specification》(後文簡稱JLS)的第15.25章節中是有相關介紹的。

在不一樣版本的JLS中,關於這部分描述雖然不盡相同,尤爲在Java 8中有了大幅度的更新,可是其核心內容和原理是不變的。咱們直接看Java SE 1.7 JLS中關於這部分的描述(由於1.7的表述更加簡潔一些):

The type of a conditional expression is determined as follows: • If the second and third operands have the same type (which may be the null type),then that is the type of the conditional expression. • If one of the second and third operands is of primitive type T, and the type of the other is the result of applying boxing conversion (§5.1.7) to T, then the type of the conditional expression is T.

簡單的來講就是:當第二位和第三位操做數的類型相同時,則三目運算符表達式的結果和這兩位操做數的類型相同。當第二,第三位操做數分別爲基本類型和該基本類型對應的包裝類型時,那麼該表達式的結果的類型要求是基本類型。

爲了知足以上規定,又避免程序員過分感知這個規則,因此在編譯過程當中編譯器若是發現三目操做符的第二位和第三位操做數的類型分別是基本數據類型(如boolean)以及該基本類型對應的包裝類型(如Boolean)時,而且須要返回表達式爲包裝類型,那麼就須要對該包裝類進行自動拆箱。

在Java SE 1.8 JLS中,關於這部分描述又作了一些細分,再次把表達式區分紅布爾型條件表達式(Boolean Conditional Expressions)、數值型條件表達式(Numeric Conditional Expressions)和引用類型條件表達式(Reference Conditional Expressions)。

而且經過表格的形式明確的列舉了第二位和第三位分別是不一樣類型時獲得的表達式結果值應該是什麼,感興趣的你們能夠去翻閱一下。

其實簡單總結下,就是:當第二位和第三位表達式都是包裝類型的時候,該表達式的結果纔是該包裝類型,不然,只要有一個表達式的類型是基本數據類型,則表達式獲得的結果都是基本數據類型。若是結果不符合預期,那麼編譯器就會進行自動拆箱。(即Java開發手冊中總結的:只要表達式1和表達式2的類型有一個是基本類型,就會作觸發類型對齊的拆箱操做,只不過若是都是基本類型也就不須要拆箱了。)

以下3種狀況是咱們熟知該規則,在聲明表達式的結果的類型時刻意和規則保持一致的狀況(爲了幫助你們理解,我備註了註釋和反編譯後的代碼):

boolean flag = true;
boolean simpleBoolean = false;
Boolean objectBoolean = Boolean.FALSE;

//當第二位和第三位表達式都是對象時,表達式返回值也爲對象;
Boolean x1 = flag ? objectBoolean : objectBoolean; 
//反編譯後代碼爲:Boolean x1 = flag ? objectBoolean : objectBoolean; 
//由於x1的類型是對象,因此不須要作任何特殊操做。

//當第二位和第三位表達式都爲基本類型時,表達式返回值也爲基本類型;
boolean x2 = flag ? simpleBoolean : simpleBoolean; 
//反編譯後代碼爲:boolean x2 = flag ? simpleBoolean : simpleBoolean;
//由於x2的類型也是基本類型,因此不須要作任何特殊操做。

//當第二位和第三位表達式中有一個爲基本類型時,表達式返回值也爲基本類型;
boolean x3 = flag ? objectBoolean : simpleBoolean; 
//反編譯後代碼爲:boolean x3 = flag ? objectBoolean.booleanValue() : simpleBoolean;
//由於x3的類型是基本類型,因此須要對其中的包裝類進行拆箱。
複製代碼

由於咱們熟知三目運算符的規則,因此咱們就會按照以上方式去定義x一、x2和x3的類型。

可是,並非全部人都熟知這個規則,因此在實際應用中,還會出現如下三種定義方式:

//當第二位和第三位表達式都是對象時,表達式返回值也爲對象;
boolean x4 = flag ? objectBoolean : objectBoolean; 
//反編譯後代碼爲:boolean x4 = (flag ? objectBoolean : objectBoolean).booleanValue();
//由於x4的類型是基本類型,因此須要對錶達式結果進行自動拆箱。

//當第二位和第三位表達式都爲基本類型時,表達式返回值也爲基本類型;
Boolean x5 = flag ? simpleBoolean : simpleBoolean; 
//反編譯後代碼爲:Boolean x5 = Boolean.valueOf(flag ? simpleBoolean : simpleBoolean);
//由於x5的類型是對象類型,因此須要對錶達式結果進行自動裝箱。

//當第二位和第三位表達式中有一個爲基本類型時,表達式返回值也爲基本類型;
Boolean x6 = flag ? objectBoolean : simpleBoolean;  
//反編譯後代碼爲:Boolean x6 = Boolean.valueOf(flag ? objectBoolean.booleanValue() : simpleBoolean);
//由於x6的類型是對象類型,因此須要對錶達式結果進行自動裝箱。
複製代碼

因此,平常開發中就有可能出現以上6種狀況。聰明的讀者們讀到這裏也必定想到了,在以上6種狀況中,若是是涉及到自動拆箱的,一旦對象的值爲null,就必然會發生NPE。

舉例驗證,咱們把以上的x三、x4以及x6中的的對象類型設置成null,分別執行下代碼:

Boolean nullBoolean = null;
boolean x3 = flag ? nullBoolean : simpleBoolean;
boolean x4 = flag ? nullBoolean : objectBoolean;
Boolean x6 = flag ? nullBoolean : simpleBoolean;
複製代碼

以上三種狀況,都會在執行時發生NPE。

其中x3和x6是三目運算符運算過程當中,根據JLS的規則肯定類型的過程當中要作自動拆箱而致使的NPE。因爲使用了三目運算符,而且第2、第三位操做數分別是基本類型和對象。就須要對對象進行拆箱操做,因爲該對象爲null,因此在拆箱過程當中調用null.booleanValue()的時候就報了NPE。

而x4是由於三目運算符運算結束後根據規則他獲得的是一個對象類型,可是在給變量賦值過程當中進行自動拆箱所致使的NPE。

小結

如前文介紹,在開發過程當中,若是涉及到三目運算符,那麼就要高度注意其中的自動拆裝箱問題。

最好的作法就是保持三目運算符的第二位和第三位表達式的類型一致,而且若是要把三目運算符表達式給變量賦值的時候,也儘可能保持變量的類型和他們保持一致。而且,作好單元測試!!!

因此,Java開發手冊中提到要高度注意第二位和第三位表達式的類型對齊過程當中因爲自動拆箱發生的NPE問題,其實還須要注意使用三目運算符表達式給變量賦值的時候因爲自動拆箱致使的NPE問題。

至此,咱們已經介紹完了Java開發手冊中關於三目運算符使用過程當中可能會致使NPE的問題。

若是必定要給出一個方法論去避免這個問題的話,那麼在使用的過程當中,不管是三目運算符中的三個表達式,仍是三目運算符表達式要賦值的變量,最好都使用包裝類型,能夠減小發生錯誤的機率。

正文內容已完,若是你們對這個問題還有更深的興趣的話,接下來部份內容是擴展內容,也歡迎學習,不過這部分涉及到不少JLS的規範,若是實在看不懂也不要緊~

擴展思考

爲了方便你們理解,我使用了簡單的布爾類型的例子說明了NPE的問題。可是實際在代碼開發中,遇到的場景可能並無那麼簡單,好比說如下代碼,你們猜一下可否正常執行:

Map<String,Boolean> map =  new HashMap<String, Boolean>();
Boolean b = (map!=null ? map.get("Hollis") : false);
複製代碼

若是你的答案是"不能,這裏會拋NPE"那麼說明你看懂了本文的內容,可是,我只能說你只是答對了一半。

由於以上代碼,在小於JDK 1.8的版本中執行的結果是NPE,在JDK 1.8 及之後的版本中執行結果是null。

之因此會出現這樣的不一樣,這個就說來話長了,我挑其中的重點內容簡單介紹下吧,如下內容主要內容仍是圍繞Java 8 的JLS 。

JLS 15中對條件表達式(三目運算符)作了細分以後分爲三種,區分方式:

若是表達式的第二個和第三個操做數都是布爾表達式,那麼該條件表達式就是布爾表達式

若是表達式的第二個和第三個操做數都是數字型表達式,那麼該條件表達式就是數字型表達式

除了以上兩種之外的表達式就是引用表達式

由於Boolean b = (map!=null ? map.get("Hollis") : false);表達式中,第二位操做數爲map.get("test"),雖然Map在定義的時候規定了其值類型爲Boolean,可是在編譯過程當中泛型是會被擦除的(泛型的類型擦除),因此,其結果就是Object。那麼根據以上規則判斷,這個表達式就是引用表達式。

又跟據JLS15.25.3中規定:

若是引用條件表達式出如今賦值上下文或調用上下文中,那麼條件表達式就是合成表達式

由於,Boolean b = (map!=null ? map.get("Hollis") : false);其實就是一個賦值上下文(關於賦值上下文相見JLS 5.2),因此map!=null ? map.get("Hollis") : false;就是合成表達式。

那麼JLS15.25.3中對合成表達式的操做數類型作了約束:

合成的引用條件表達式的類型與其目標類型相同

因此,由於有了這個約束,編譯器就能夠推斷(Java 8 中類型推斷,詳見JLS 18)出該表達式的第二個操做數和第三個操做數的結果應該都是Boolean類型。

因此,在編譯過程當中,就能夠分別把他們都轉成Boolean便可,那麼以上代碼在Java 8中反編譯後內容以下:

Boolean b = maps == null ? Boolean.valueOf(false) : (Boolean)maps.get("Hollis");
複製代碼

可是在Java 7中可沒有這些規定(Java 8以前的類型推斷功能還很弱),編譯器只知道表達式的第二位和第三位分別是基本類型和包裝類型,而沒法推斷最終表達式類型。

那麼他就會先根據JLS 15.25的規定,把返回值結果轉換成基本類型。而後在進行變量賦值的時候,再轉換成包裝類型:

Boolean b = Boolean.valueOf(maps == null ? false : ((Boolean)maps.get("Hollis")).booleanValue());
複製代碼

因此,相比Java 8中多了一步自動拆箱,因此會致使NPE。

《解讀Java開發手冊》電子書來了,靈魂13問,深刻剖析Java規約背後的原理,從"問題重現"到"原理分析"再到"問題解決",深刻挖掘阿里巴巴開發思惟!《Java開發手冊》必備伴讀書目。

關注公衆號,後臺回覆『Java手冊』便可下載。

參考資料:

《Java開發手冊——泰山版》

docs.oracle.com/javase/spec… docs.oracle.com/javase/spec… docs.oracle.com/javase/spec… docs.oracle.com/javase/spec… docs.oracle.com/javase/spec…

相關文章
相關標籤/搜索