1. 程式人生 > >《新版阿里巴巴Java開發手冊》提到的三目運算子的空指標問題到底是個怎麼回事?

《新版阿里巴巴Java開發手冊》提到的三目運算子的空指標問題到底是個怎麼回事?

最近,阿里巴巴Java開發手冊釋出了最新版——泰山版,這個名字起的不錯,一覽眾山小。 新版新增了30+規約,其中有一條規約引起了作者的關注,那就是手冊中提到在三目運算子使用過程中,需要注意自動拆箱導致的NullPointerException(後文簡稱:NPE)問題: ![][1] 因為這個問題我很久之前(2015年)遇到過,曾經在部落格中也記錄過,剛好最新的開發手冊再次提到了這個知識點,於是把之前的文章內容翻出來並重新整理了一下,帶大家一起回顧下這個知識點。 可能有些人看過我之前那篇文章,本文並不是單純的"舊瓶裝新酒",在重新梳理這個知識點的時候,作者重新翻閱了《The Java Language Specification》,並且對比了Java SE 7 和 Java SE 8之後的相關變化,希望可以幫助大家更加全面的理解這個問題。 ### 基礎回顧 在詳細展看介紹之前,先簡單介紹下本文要涉及到的幾個重要概念,分別是"三目運算子"、"自動拆裝箱"等,如果大家對於這些歷史知識有所掌握的話,可以先跳過本段內容,直接看問題重現部分即可。 #### 三目運算子 在《The Java Language Specification》中,三目運算子的官方名稱是 `Conditional Operator ? :` ,我一般稱呼他為條件表示式,詳細介紹在JLS 15.25中,這裡簡單介紹下其基本形式和用法: 三目運算子是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 6、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的型別是基本型別,所以需要對其中的包裝類進行拆箱。 因為我們熟知三目運算子的規則,所以我們就會按照以上方式去定義x1、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。 舉例驗證,我們把以上的x3、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。由於使用了三目運算子,並且第二、第三位運算元分別是基本型別和物件。就需要對物件進行拆箱操作,由於該物件為null,所以在拆箱過程中呼叫null.booleanValue()的時候就報了NPE。 而x4是因為三目運算子運算結束後根據規則他得到的是一個物件型別,但是在給變數賦值過程中進行自動拆箱所導致的NPE。 ### 小結 如前文介紹,在開發過程中,如果涉及到三目運算子,那麼就要高度注意其中的自動拆裝箱問題。 最好的做法就是保持三目運算子的第二位和第三位表示式的型別一致,並且如果要把三目運算子表示式給變數賦值的時候,也儘量保持變數的型別和他們保持一致。並且,做好單元測試!!! **所以,Java開發手冊中提到要高度注意第二位和第三位表示式的型別對齊過程中由於自動拆箱發生的NPE問題,其實還需要注意使用三目運算子表示式給變數賦值的時候由於自動拆箱導致的NPE問題。** 至此,我們已經介紹完了Java開發手冊中關於三目運算子使用過程中可能會導致NPE的問題。 如果一定要給出一個方法論去避免這個問題的話,那麼在使用的過程中,無論是三目運算子中的三個表示式,還是三目運算子表示式要賦值的變數,最好都使用包裝型別,可以減少發生錯誤的概率。 正文內容已完,如果大家對這個問題還有更深的興趣的話,接下來部分內容是擴充套件內容,也歡迎學習,不過這部分涉及到很多JLS的規範,如果實在看不懂也沒關係~ ### 擴充套件思考 為了方便大家理解,我使用了簡單的布林型別的例子說明了NPE的問題。但是實際在程式碼開發中,遇到的場景可能並沒有那麼簡單,比如說以下程式碼,大家猜一下能否正常執行: Map