天天看點

《Java 開發手冊》解讀:三目運算符為何會導緻 NPE?

《Java 開發手冊》解讀:三目運算符為何會導緻 NPE?

最近,《Java 開發手冊》釋出了最新版——泰山版,這個名字起的不錯,一覽衆山小。

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

《Java 開發手冊》解讀:三目運算符為何會導緻 NPE?

因為這個問題我很久之前(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 的問題。

如果一定要給出一個方法論去避免這個問題的話,那麼在使用的過程中,無論是三目運算符中的三個表達式,還是三目運算符表達式要指派的變量,最好都使用包裝類型,可以減少發生錯誤的機率。

擴充思考

為了友善大家了解,我使用了簡單的布爾類型的例子說明了 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。那麼根據以上規則判斷,這個表達式就是引用表達式。

又跟據 JLS 15.25.3 中規定:

如果引用條件表達式出現在指派上下文或調用上下文中,那麼條件表達式就是合成表達式

因為,Boolean b = (map!=null ? map.get("Hollis") : false); 其實就是一個指派上下文(關于指派上下文相見 JLS 5.2),是以 map!=null ? map.get("Hollis") : false; 就是合成表達式。

那麼 JLS 15.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問》