《新版阿⾥巴巴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);nullpointerexception: null
/
/因为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<String,Boolean> map =  new HashMap<String, Boolean>();
Boolean b = (map!=null ? ("Hollis") : false);
如果你的答案是"不能,这⾥会抛NPE"那么说明你看懂了本⽂的内容,但是,我只能说你只是答对了⼀半。
因为以上代码,在⼩于JDK 1.8的版本中执⾏的结果是NPE,在JDK 1.8 及以后的版本中执⾏结果是null。
之所以会出现这样的不同,这个就说来话长了,我挑其中的重点内容简单介绍下吧,以下内容主要内容还是围绕Java 8 的JLS 。
JLS 15中对条件表达式(三⽬运算符)做了细分之后分为三种,区分⽅式:
如果表达式的第⼆个和第三个操作数都是布尔表达式,那么该条件表达式就是布尔表达式
如果表达式的第⼆个和第三个操作数都是数字型表达式,那么该条件表达式就是数字型表达式
除了以上两种以外的表达式就是引⽤表达式
因为Boolean b = (map!=null ? ("Hollis") : false);表达式中,第⼆位操作数为("test"),虽然Map在定义的时候规定了其值类型为Boolean,但是在编译过程中泛型是会被擦除的(泛型的类型擦除),所以,其结果就是Object。那么根据以上规则判断,这个表达式就是引⽤表达式。
⼜跟据JLS15.25.3中规定:
如果引⽤条件表达式出现在赋值上下⽂或调⽤上下⽂中,那么条件表达式就是合成表达式
因为,Boolean b = (map!=null ? ("Hollis") : false);其实就是⼀个赋值上下⽂(关于赋值上下⽂相见JLS 5.2),所以map!=null ? ("Hollis") : false;就是合成表达式。
那么JLS15.25.3中对合成表达式的操作数类型做了约束:
合成的引⽤条件表达式的类型与其⽬标类型相同
所以,因为有了这个约束,编译器就可以推断(Java 8 中类型推断,详见JLS 18)出该表达式的第⼆个操作数和第三个操作数的结果应该都是Boolean类型。
所以,在编译过程中,就可以分别把他们都转成Boolean即可,那么以上代码在Java 8中反编译后内容如下:
Boolean b = maps == null ? Boolean.valueOf(false) : (("Hollis");
但是在Java 7中可没有这些规定(Java 8之前的类型推断功能还很弱),编译器只知道表达式的第⼆位和第三位分别是基本类型和包装类型,⽽⽆法推断最终表达式类型。
那么他就会先根据JLS 15.25的规定,把返回值结果转换成基本类型。然后在进⾏变量赋值的时候,再转换成包装类型:
Boolean b = Boolean.valueOf(maps == null ? false : ((("Hollis")).booleanValue());
所以,相⽐Java 8中多了⼀步⾃动拆箱,所以会导致NPE。
《解读Java开发⼿册》电⼦书来了,灵魂13问,深⼊剖析Java规约背后的原理,从"问题重现"到"原理分析"再到"问题解决",深⼊挖掘阿⾥巴巴开发思维!《Java开发⼿册》必备伴读书⽬。
关注,后台回复『Java⼿册』即可下载。
参考资料:
《Java开发⼿册——泰⼭版》

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。