写点什么

Kotlin 变量的空安全 (Null Safety)

作者:子不语Any
  • 2022-11-15
    湖南
  • 本文字数:4053 字

    阅读完需:约 13 分钟

Kotlin变量的空安全(Null Safety)

前言

这篇文章巩固 Kotlin 变量的空安全特性。

本文大纲


前面介绍过 Koltin 声明类型的语法,这里将介绍 Kotlin 类型系统里最重要的特性:空安全(Void Safety/Null Safety)。在 Kotlin 中,不可为空的变量和可为空的变量被强行分开了(Java@Nullable @NonNull 注解) Kotlin 为什么要这样设计呢?带着这个疑问往下看:

1. 场景分析

某天你正在优雅的编写新业务代码,leader 突然来告诉你,有一个线上的空指针 crash,赶紧处理一下。赶紧 git stash 了自己的代码,切换到出问题的那个类。


这是一个管理音频播放的类,叫 PlayerController,用来播放用户上传的 ugc 音频内容。播放是一个很基础通用的功能,所以这个类依赖了一个播放库 AudioPlayer,PlayerController 主要是实现业务功能


之前的维护者刚离职,你现在临时接任,对里面的结构是不太熟悉的。这个类年代久远,在某个初期版本就上线了,承载了无数的业务变更。里面代码逻辑混乱,业务和通用代码耦合在了一起。你想过重构,但功能实在太多了,需要很长的时间,且现在功能也比较稳定了,重构的收益对业务增长没有明显帮助 那还是先打个补丁吧。


来看看代码:


PlayerController.java:


/**  * 用户音频 ugc 播放器。  * 如果看到奇怪的逻辑,请不要随便删除,那都是为了规避  * AudioPlayer 库一些奇怪的 bug,或者是为了兼容业务做的处理。  */public class PlayerController {    private AudioPlayer mAudioPlayer;
public PlayerController() {
}
/** 初始化,只会初始化一次 */ public void init () { // 构造播放组件 if (mAudioPlayer != null) { mAudioPlayer = AudioPlayer(); } }
/** 播放前需要先初始化数据 **/ public void prepare(String audioPath) { // 设置音频文件路径 if (mAudioPlayer != null) { mAudioPlayer.prepare(audioPath); } }
/** 开始播放 **/ public void play() { if (mAudioPlayer != null) { // 前置条件判断 // ... mAudioPlayer.play(); } }
/** 暂停 **/ public void pause() { if (mAudioPlayer != null) { mAudioPlayer.pause(); } }
/** 跳转到指定时间 **/ public void seekTo(long time) { if (mAudioPlayer != null) { mAudioPlayer.seekTo(time); } }
public void stop() { if (mAudioPlayer != null) { // 数据处理 // ... mAudioPlayer.stop(); // 该行空指针错误了 } }
public void release() { if (mAudioPlayer != null) { mAudioPlayer.release(); mAudioPlayer = null; } }}
复制代码


这是个很典型的依赖了底层组件的封装类。初始化,释放,播放,暂停这些是外部接口。里面还包含着很多空判断和 proxy 的代码。这样写代码量就迅速起来了。


这个类在后面讲解很多 Kotlin 特性的场景都会用它,可以先熟悉一下


开始 crash 分析。通过上报的报错信息,发现是 mAudioPlayer.stop()这行空指针错误。mAudioPlayer 在init()时被赋值,release()时被释放,且为了防止内存泄漏被设置为 null。并且考虑到并发操作,即 mAudioPlayer 这个变量在任何使用的时候都可能为 null。


但外部已经有空条件判断了,且这是最新的版本才暴露的问题,为什么会这样呢?


通过 代码提交记录排查后了解到,是mAudioPlayer.stop()之前新增了一些业务代码,而新增代码有耗时操作。这导致在空判断时非空,但进入 if 代码块之后,线程被切换了,上层调用了release(),等线程再切回来的时候 mAudioPlayer 已经变成 null ,再执行就出现了空指针错误。


最简单的解决办法就是mAudioPlayer.stop()单独再包一层*。虽然很丑,但很管用,大伙也很喜欢用,特别是灰度测试时不允许大幅改动代码。


或者是给所有 mAudioPlayer 操作都加上锁 synchronized。不过考虑到里面 API 有耗时操作,这样有可能会造成 UI 卡顿。


不加锁的话也有多次调用,即破坏幂等性的风险。


总之事情就这样暂时解决了。代码随着时间的迁移,越来越多变量可能为空的地方加上了if (xxx != null)的保护代码,甚至可能一个类 10% 的行都是空指针保护!涉及到逻辑冗长的地方,空保护的嵌套甚至到达了 5 层以上! 我直接好家伙。。


但这确实是 Java 的最通用解决办法。那么 Kotlin 怎么更优雅的解决这个问题呢?

2. Kotlin 非空类型/可空类型(NonNull/Nullable)声明

之前提到:在 Kotlin 中,不可为空的变量和可为空的变量被强行分开了。那么是如何分开的呢?很简单,默认的类型声明不能为空,类型后面跟问号"?"即表示可为空


看下面这段代码:


fun main() {    var string1: String = "123" // ok    string1 = "456" // ok
var string2: String = null // 编译器报错
var string3: String? = null // ok string3 = "456" // ok string3 = null // ok
var string4 = "123" // ok,类型推断为 String string4 = null // 编译器报错
var string5 = null // ok,类型推断为 Nothing? string5 = "123" // 编译器报错}
复制代码


观察 string1,string2 可以得出:


当像 Java 那样声明一个 String 对象时,在之后的赋值也是不能被赋值为空的。意味着如果一个变量的类型为 String,则在任何时候都不可能为空。


观察 string3 可以得出:


声明对象为 String? 类型,可以将其设置为空。典型场景:初始化这个变量的时候,还暂时无法得到其值,就必须用可空类型的声明方法了。


观察 string4,string5 可以得出:


类型推断是完全根据初始化时的赋值来确定的。不会根据后面的赋值作为依据来推断这个变量的类型。所以需要像 string3 那样显式声明为 String?

3. Kotlin 可空(Nullable)类型的调用

声明一个非空变量,意味着你可以随意的调用他的方法而不用担心空指针错误,相对应的,可空变量则无法保证。Kotlin 通过不允许可空变量直接调用方法来保证不会出现空指针错误。可空变量应该怎么调用呢?


Kotlin 中可空变量的调用方式是:调用的"."号前加"?"或"!!"。前者表示是,如果非空则调用,否则不调用;后者表示是,如果非空则调用,否则抛出 Exception。来看例子:


/** 很普通的一个类,有一个“成员变量”,一个返回该变量的方法 **/class A {    var code = 0
fun getMyCode(): Int { // 返回 Int 类型,就像是 Java 的 Integer 那样 return code }}
fun main() { var a1 = A() a1.code = 3 a1.getMyCode() // ok
var a2: A? = A() a2.code = 3 // 编译错误 a2.getMyCode() // 编译错误
var a3: A? = A() a3?.getMyCode() // ok a3!!.getMyCode() // ok}
复制代码


生产环境不建议使用双叹号!!,一般只用于测试环境。使用双叹号!!可以理解为放弃 Kotlin 的空安全特性。

4. Kotlin 可空(Nullable)的传递性

如果可空对象调用了方法,因为方法有可能不被执行,那么如果接收它的返回值,那么返回值的类型应该是什么呢?继续使用上面A这个类,来看看这个例子:


/** 很普通的一个类,有一个“成员变量”,一个返回该变量的方法 **/class A {    var code = 0
fun getMyCode(): Int { // 返回 Int 类型,就像是 Java 的 Integer 那样 return code }}
var a4: A? = null // 声明一个A类型且可为空的变量
fun main() { var myCode: Int = a4?.getMyCode() // 编译错误 var myCode2: Int? = a4?.getMyCode() // ok
myCode2.toFloat() // 编译错误 myCode2?.toFloat() // ok
var myCode3: Int? = a4!!.getMyCode() // ok myCode3.toFloat() // ok}
复制代码


可以看到,本来getMyCode()方法返回的是 Int 类型,但由于 a4 为可空类型,所以 myCode 被编译器认为是 Int? 类型。所以,可空是具有传递性的。


!! 由于在变量为空时会抛出异常,所以返回值就还是为 Int,若抛异常的话,后面的代码就不会被执行了。


a4 要写在外面的原因是,若声明为局部变量,即使 a4 被声明为 A?,但由于局部变量的关系,编译器会把 myCode 纠正为 Int,而不是 Int?


用链式调用的话,就会变成如下这样:


myCode2?.toFloat()?.toLong()?.toByte()myCode2!!.toFloat().toLong().toByte()
复制代码


看起来比较不好看懂。但不用担心,Kotlin 有其他的特性来协助你处理可空变量,不用写出像这样不好理解的代码。

5. 回到之前场景

用 Kotlin 来实现代码,只需要将 mAudioPlayer 声明为可空类型就可以:


/**  * 用户音频 ugc 播放器。  * 如果看到奇怪的逻辑,请不要随便删除,那都是为了规避  * AudioPlayer 库一些奇怪的 bug,或者是为了兼容业务做的处理。  */
class PlayerController { private var mAudioPlayer: AudioPlayer? = null
/** 初始化,只会初始化一次 */ fun init() { // 构造播放组件 if (mAudioPlayer != null) { mAudioPlayer = AudioPlayer() } }
/** 播放前需要先初始化数据 */ fun prepare(audioPath: String) { // 设置音频文件路径 mAudioPlayer?.prepare(audioPath) }
/** 开始播放 */ fun play() { // 前置条件判断 // ... mAudioPlayer?.play() }
/** 暂停 */ fun pause() { mAudioPlayer?.pause() }
/** 跳转到指定时间 */ fun seekTo(time: Long) { mAudioPlayer?.seekTo(time) }
fun stop() { // 数据处理 // ... mAudioPlayer?.stop() // 不会空指针错误了 }
fun release() { mAudioPlayer?.release() mAudioPlayer = null }}
复制代码


Kotlin 写起来简洁很多,而且减少了嵌套层数。

发布于: 刚刚阅读数: 5
用户头像

子不语Any

关注

If not now,when? 2022-09-17 加入

安卓攻城狮

评论

发布
暂无评论
Kotlin变量的空安全(Null Safety)_android_子不语Any_InfoQ写作社区