专栏首页墨鬓《Java 开发手册》解读:三目运算符为何会导致 NPE?
原创

《Java 开发手册》解读:三目运算符为何会导致 NPE?

阿里妹导读:在三目运算符中,表达式 1 和 2 在涉及算术计算或数据类型转换时,会触发自动拆箱。当其中的操作数为 null 值时,会导致 NPE 。本文将详细剖析 NPE 出现的原因,重新梳理相关知识点,并进一步扩展,帮助大家彻底理解这个问题。

文末福利:下载《Java 开发手册》解读电子书。

最近,《Java 开发手册》发布了最新版——泰山版,这个名字起的不错,一览众山小。

新版新增了 30+ 规约,其中有一条规约引起了作者的关注,那就是手册中提到在三目运算符使用过程中,需要注意自动拆箱导致的 NullPointerException(后文简称: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。

参考资料: 【1】《Java 开发手册(泰山版)》 【2】http://docs.oracle.com/javase/specs/jls/se7/html/jls-15.html#jls-15.25  【3】http://docs.oracle.com/javase/specs/jls/se8/html/jls-15.html#jls-15.25  【4】https://docs.oracle.com/javase/specs/jls/se8/html/jls-15.html#jls-15.2  【5】https://docs.oracle.com/javase/specs/jls/se7/html/jls-15.html#jls-15.12.2.7  【6】https://docs.oracle.com/javase/specs/jls/se8/html/jls-18.htm

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 我去,这么简单的条件表达式竟然也有这么多坑

    最近,小黑哥在一个业务改造中,使用三目运算符重构了业务代码,没想到测试的时候竟然发生 NPE 的问题。

    andyxh
  • 解读《Java开发手册(泰山版)》- 会当凌绝顶,一览众山小 (附下载地址)

    阿里的《Java开发手册》被Java开发者所拜读,基本人手一册,就在前几天(2020.04.22)发布了泰山版 - 会当凌绝顶,一览众山小,而这次发布新增了很多...

    xcbeyond
  • 在项目中随手把haseMap改成了currenHaseMap差点被公司给开除了。

    所以涉及金钱的计算一定不要使用float和double。使用BigDecimal并且一定要用String来构造。 上面的列子我们可以这样来初始化 new Big...

    java金融
  • Java编码手册之华山版小精华

    最近又复习了一下阿里出品的<Java开发手册–华山版>,对于开发过程中绝大多数开发者都遵循开发手册,相信开发团队的代码可维护性能够绝对性提高。为什么要整理出这篇...

    沁溪源
  • 万恶的NPE差点让我半个月工资没了

    最近看到《阿里巴巴Java开发手册》(公众号回复[开发手册]免费获取)第11条规范写到:

    java金融
  • 「Java开发者的福音」泰山版《Java开发手册》速览!附下载地址。

    会当凌绝顶,一览众山小。经过一年的修炼,《Java 开发手册》泰山版于 4.22 正式发布。泰山版发布之后,我便立马去下载简单阅读了一下新增加了内容。

    Guide哥
  • Java 开发中如何正确的踩坑

    我们的做法是,要用最好的人。我一直都认为研发本身是很有创造性的,如果人不放松,或不够聪明,都很难做得好。你要找到最好的人,一个好的工程师不是顶10个,是顶100...

    好好学java
  • 万恶的NPE差点让我半个月工资没了

    NPE(Null Pointer Exception)一直是开发中最头疼的问题,也是最容易忽视的地方。记得刚开始工作的时候所在的项目组线上出现最多的bug不是逻...

    java金融
  • Java开发中遇到的那些坑!

    之前在这个手册刚发布的时候看过一遍,当时感觉真是每个开发者都应该必读的一本手册,最近由于在总结一些我们日常开发中容易忽略的问题,可能是最低级的编码常见问题,往往...

    互扯程序
  • Java开发中如何正确踩坑

    之前在这个手册刚发布的时候看过一遍,当时感觉真是每个开发者都应该必读的一本手册,期间还写过一篇关于日志规约的文章:《下一个项目为什么要用 SLF4J》,最近由于...

    Java团长
  • 懵逼...

    Java语言层面:null值自身是不会引起任何问题的。它安安静静的待在某个地方(局部变量、成员字段、静态字段)不会有任何问题;它从一个地方被搬运到另一个地方也不...

    why技术
  • java采坑之路

    为什么n3== n4 是false呢?由于包装类的缓存机制。包装类的比较用equals去判断。最推荐的还是用工具类去判断。例如上面的列子如果n3=null的话n...

    java金融
  • 速来,围观阿里出品的泰山版Java开发手册

    说起华山,我就想起岳不群,不,令狐冲;说起泰山,我就想起司马迁,他的那句名言“人总有一死,或重于泰山,或轻于鸿毛”,真的发人深省啊。这就意味着,阿里出品的泰山版...

    用户1564362
  • Java异常处理最佳实践

    在Java中处理异常并不是一个简单的事情。不仅仅初学者很难理解,即使一些有经验的开发者也需要花...

    Java架构师必看
  • Listener、Filter、Interceptor的那些事

    用户2032165
  • 一文读懂 JAVA 异常处理

    Throwable 是所有异常类型的基类,Throwable 下一层分为两个分支,Error 和 Exception.

    java架构师
  • 小谈 Kotlin 的空处理

    近来关于 Kotlin 的文章着实不少,Google 官方的支持让越来越多的开发者开始关注 Kotlin。不久前加入的项目用的是 Kotlin 与 Java 混...

    程序亦非猿
  • 在高通芯片的手机上怎么玩人工智能?

    人工智能一直比较火,作为移动互联网时代的行业人员,感觉离我们比较近,但实践起来离我们又比较远,当今智能手机正成为最普遍的人工智能平台,那么今天我们来说一说高通在...

    安智客
  • 推荐一款IDEA神器!一键查看Java字节码以及其他类信息

    由于后面要分享的一篇文章中用到了这篇文章要推荐的一个插件,所以这里分享一下。非常实用!你会爱上它的!

    Guide哥

扫码关注云+社区

领取腾讯云代金券