小谈Kotlin的空处理的使用
近来关于Kotlin的文章着实不少,Google官方的支持让越来越多的开发者开始关注Kotlin。不久前加入的项目用的是Kotlin与Java混合开发的模式,纸上得来终觉浅,终于可以实践一把新语言。本文就来小谈一下Kotlin中的空处理。
一、上手的确容易
先扯一扯Kotlin学习本身。
之前各种听人说上手容易,但真要切换到另一门语言,难免还是会踌躇是否有这个必要。现在因为工作关系直接上手Kotlin,感受是真香(上手的确容易)。
首先在代码阅读层面,对于有Java基础的程序员来说阅读Kotlin代码基本无障碍,除去一些操作符、一些顺序上的变化,整体上可以直接阅读。
其次在代码编写层面,仅需要改变一些编码习惯。主要是:语句不要写分号、变量需要用var或val声明、类型写在变量之后、实例化一个对象时不用“new”……习惯层面的改变只需要多写代码,自然而然就适应了。
最后在学习方式层面,由于Kotlin最终都会被编译成字节码跑在JVM上,所以初入手时完全可以用Java作为对比。比如你可能不知道Kotlin里companionobject是什么意思,但你知道既然Kotlin最终会转成jvm可以跑的字节码,那Java里必然可以找到与之对应的东西。
AndroidStudio也提供了很方便的工具。选择菜单Tools->Kotlin->ShowKotlinBytecode即可看到Kotlin编译成的字节码,点击窗口上方的“Decompile”即可看到这份字节码对应的Java代码。——这个工具特别重要,假如一段Kotlin代码让你看得云里雾里,看一下它对应的Java代码你就能知道它的含义。
当然这里仅仅是说上手或入门(仅入门的话可以忽略诸如协程等高级特性),真正熟练应用乃至完全掌握肯定需要一定时间。
二、针对NPE的强规则
有些文章说Kotlin帮开发者解决了NPE(NullPointerException),这个说法是不对的。在我看来,Kotlin没有帮开发者解决了NPE(Kotlin:臣妾真的做不到啊),而是通过在语言层面增加各种强规则,强制开发者去自己处理可能的空指针问题,达到尽量减少(只能减少而无法完全避免)出现NPE的目的。
那么Kotlin具体是怎么做的呢?别着急,我们可以先回顾一下在Java中我们是怎么处理空指针问题的。
Java中对于空指针的处理总体来说可以分为“防御式编程”和“契约式编程”两种方案。
“防御式编程”大家应该不陌生,核心思想是不信任任何“外部”输入——不管是真实的用户输入还是其他模块传入的实参,具体点就是各种判空。创建一个方法需要判空,创建一个逻辑块需要判空,甚至自己的代码内部也需要判空(防止对象的回收之类的)。示例如下:
publicvoidshowToast(Activityactivity){ if(activity==null){ return; } ...... }
另一种是“契约式编程”,各个模块之间约定好一种规则,大家按照规则来办事,出了问题找没有遵守规则的人负责,这样可以避免大量的判空逻辑。Android提供了相关的注解以及最基础的检查来协助开发者,示例如下:
publicvoidshowToast(@NonNullActivityactivity){ ...... }
在示例中我们给Activity增加了@NonNull的注解,就是向所有调用这个方法的人声明了一个约定,调用方应该保证传入的activity非空。当然聪明的你应该知道,这是一个很弱的限制,调用方没注意或者不理会这个注解的话,程序就依然还有NPE导致的crash的风险。
回过头来,对于Kotlin,我觉得就是一种把契约式编程和防御式编程相结合且提升到语言层面的处理方式。(听起来似乎比Java中各种判空或注解更麻烦?继续看下去,你会发现的确是更麻烦……)
在Kotlin中,有以下几方面约束:
在声明阶段,变量需要决定自己是否可为空,比如vartime:Long?可接受null,而vartime:Long则不能接受null。
在变量传递阶段,必须保持“可空性”一致,比如形参声明是不为空的,那么实参必须本身是非空或者转为非空才能正常传递。示例如下:
funmain(){ ...... //test(isOpen)直接这样调用,编译不通过 //可以是在空检查之内传递,证明自己非空 isOpen?.apply{ test(this) } //也可以是强制转成非空类型 test(isOpen!!) } privatefuntest(open:Boolean){ ...... }
在使用阶段,需要严格判空:
vartime:Long?=1000 //尽管你才赋值了非空的值,但在使用过程中,你无法这样: //time.toInt() //必须判空 time?.toInt()
总的来说Kotlin为了解决NPE做了大量语言层级的强限制,的确可以做到减少NPE的发生。但这种既“契约式”(判空)又“防御式”(声明空与非空)的方案会让开发者做更多的工作,会更“麻烦”一点。
当然,Kotlin为了减少麻烦,用“?”简化了判空逻辑——“?”的实质还是判空,我们可以通过工具查看time?.toInt()的Java等价代码是:
if(time!=null){ intvar10000=(int)time; }
这种简化在数据层级很深需要写大量判空语句时会特别方便,这也是为什么虽然逻辑上Kotlin让开发者做了更多工作,但写代码过程中却并没有感觉到更麻烦。
三、强规则之下的NPE问题
在Kotlin这么严密的防御之下,NPE问题是否已经被终结了呢?答案当然是否定的。在实践过程中我们发现主要有以下几种容易导致NPE的场景:
1.dataclass(含义对应Java中的model)声明了非空
例如从后端拿json数据的场景,后端的哪个字段可能会传空是客户端无法控制的,这种情况下我们的预期必须是每个字段都可能为空,这样转成jsonobject时才不会有问题:
dataclassUser( varid:Long?, vargender:Long?, varavatar:String?)
假如有一个字段忘了加上”?”,后端没传该值就会抛出空指针异常。
2.过分依赖Kotlin的空值检查
privatelateinitvarmUser:User ... privatefuninitView(){ mUser=intent.getParcelableExtra("key_user") }
在Kotlin的体系中久了会过分依赖于AndroidStudio的空值检查,在代码提示中Intent的getParcelableExtra方法返回的是非空,因此这里你直接用方法结果赋值不会有任何警告。但点击进getParcelableExtra方法内部你会发现它的实现是这样的:
publicTgetParcelableExtra(Stringname){ returnmExtras==null?null:mExtras. getParcelable(name); }
内部的其他代码不展开了,总之它是可能会返回null的,直接赋值显然会有问题。
我理解这是Kotlin编译工具对Java代码检查的不足之处,它无法准确判断Java方法是否会返回空就选择无条件信任,即便方法本身可能还声明了@Nullable。
3.变量或形参声明为非空
这点与第一、第二点都很类似,主要是使用过程中一定要进一步思考传递过来的值是否真的非空。
有人可能会说,那我全部都声明为可空类型不就得了么——这样做会让你在使用该变量的所有地方都需要判空,Kotlin本身的便利性就荡然无存了。
我的观点是不要因噎废食,使用时多注意点就可以避免大部分问题。
4.!!强行转为非空
当将可空类型赋值给非空类型时,需要有对空类型的判断,确保非空才能赋值(Kotlin的约束)。
我们使用!!可以很方便得将“可空”转为“非空”,但可空变量值为null,则会crash。
因此使用上建议在确保非空时才用!!:
param!!
否则还是尽量放在判空代码块里:
param?.let{ doSomething(it) }
四、实践中碰到的问题
从Java的空处理转到Kotlin的空处理,我们可能会下意识去寻找对标Java的判空写法:
if(n!=null){ //非空如何 }else{ //为空又如何 }
在Kotlin中类似的写法的确有,那就是结合高阶函数let、apply、run……来处理判空,比如上述Java代码就可以写成:
n?.let{ //非空如何 }?:let{ //为空又如何 }
但这里有几个小坑。
1.两个代码块不是互斥关系
假如是Java的写法,那么不管n的值怎样,两个代码块都是互斥的,也就是“非黑即白”。但Kotlin的这种写法不是(不确定这种写法是否是最佳实践,假如有更好的方案可以留言指出)。
?:这个操作符可以理解为if(a!=null)aelseb,也就是它之前的值非空返回之前的值,否则返回之后的值。
而上面代码中这些高阶函数都是有返回值的,详见下表:
函数 | 返回值 |
---|---|
let | 返回指定return或函数里最后一行 |
apply | 返回该对象本身 |
run | 返回指定return或函数里最后一行 |
with | 返回指定return或函数里最后一行 |
also | 返回该对象本身 |
takeIf | 条件成立返回对象本身,不成立返回null |
takeUnless | 条件成立返回null,不成立返回该对象本身 |
假如用的是let,注意看它的返回值是“指定return或函数里最后一行”,那么碰到以下情况:
valn=1 vara=0 n?.let{ a++ ... null//最后一行为null }?:let{ a++ }
你会很神奇地发现a的值是2,也就是既执行了前一个代码块,也执行了后一个代码块。
上面这种写法你可能不以为然,因为很明显地提醒了诸位需要注意最后一行,但假如是之前没注意这个细节或者是下面这种写法呢?
n?.let{ ... anMap.put(key,value)//anMap是一个HashMap }?:let{ ... }
应该很少人会注意到Map的put方法是有返回值的,且可能会返回null。那么这种情况下很容易踩坑。
2.两个代码块的对象不同
以let为例,在let代码块里可以用it指代该对象(其他高阶函数可能用this,类似的),那么我们在写如下代码时可能会顺手这样写:
activity{ n?.let{ it.hashCode()//it为n }?:let{ it.hashCode()//it为activity } }
结果自然会发现值不一样。前一个代码块it指代的是n,而后一个代码块里it指代的是整个代码块指向的this。
原因是?:与let之间是没有.的,也就是说后一个代码块调用let的对象并不是被判空的对象,而是this。(不过这种场景会出错的概率不大,因为在后一个代码块里很多对象n的方法用不了,就会注意到问题了)
后记
总的来说切换到Kotlin还是比预期顺利和舒服,写惯了Kotlin后再回去写Java反倒有点不习惯。今天先写这点,后面有其他需要总结的再分享。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持毛票票。