专栏首页sickworm3. 类型声明与空安全(Void Safety)

3. 类型声明与空安全(Void Safety)

上一篇文章介绍了 Koltin 的声明类型语法,但我有意避开了 Kotlin 类型系统里最重要的部分:空安全(Void Safety/Null Safety)。在 Kotlin 中,不可能为空的变量和可能为空的变量被强行分开了(Java 有 @Nullable 和 @NonNull 注释,但只会提供警告)。那 Kotlin 为什么要这样设计呢?我们来看一下今天的代码场景:(只想看使用办法的可以跳过这一节)

0. 场景分析

某一天你正在优雅的编写新业务代码,老板突然告诉你,有一个线上的空指针 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。

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

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

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

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

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

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

这确实是我们 Java Boy 的最通用解决办法。那么 Kotlin Boy 可以如何优雅的解决这个问题呢?

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

最开始时我们提到:在 Kotlin 中,不可能为空的变量和可能为空的变量被强行分开了。具体是怎么分开的呢?很简单,默认的类型声明不能为空,类型后面跟问号”?”则可以为空。我们来看下面这段代码:

Nullable.kt:

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?。至于 Nothing 类型我们暂且不管,实际也很少用到,后面再分析。

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

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

Kotlin 可空变量的调用方法是:调用的”.”号前加”?”或”!!”。前者的行为是,如果非空则调用,否则不调用;后者行为是,如果非空则调用,否则抛出 Illegalstateexception。来看看例子:

Nullable2.kt:

/** 很普通的一个类,有一个“成员变量”,一个返回该变量的方法 **/
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 的空安全特性。

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

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

/** 很普通的一个类,有一个“成员变量”,一个返回该变量的方法 **/
class A {
    var code = 0

    fun getMyCode(): Int { // 返回 Int 类型,就像是 Java 的 Integer 那样
        return code
    }
}

var a4: A? = null

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()

看起来比较 ugly。。但不用担心,Kotlin 有其他的特性来协助你处理可空变量,不用写出像这样的嘲讽代码(疯狂打问号 ???)。请继续期待后面的文章吧!

4. 回到场景

如果用 Kotlin 来实现场景中的代码,只需要将 mAudioPlayer 声明为可空类型就可以了:

PlayerController.kt:

/**
  * 用户音频 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
    }
}

写起来方便很多,而且少了一层嵌套,人也舒服了。

空安全特性首次出现在 F#(2005) 上,此外 Swift 和 TypeScript 等也是空安全语言。 空指针首次出现在 Algol W(1965) 上,用作者的原话说,就是:后悔,非常的后悔。。(I call it my billion-dollar mistake) 参考:https://en.wikipedia.org/wiki/Void_safety 版权所有,转载请注明出处: https://sickworm.com/?p=1779

共享此文章:

相关

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 《计算机网络:自顶向下方法》笔记(4):网络层

    网络层的功能是:分组从一台发送主机移动到另一台接收主机。细分为两个子功能:转发(forwarding)和路由选择(routing)。涉及的协议是:IP,NAT,...

    sickworm
  • FIDO UAF Authenticator Commands v1.0

    本版本 https://fidoalliance.org/specs/fido-uaf-v1.0-ps-20141208/fido-uaf-authnr-cm...

    sickworm
  • 一次活见鬼的调试经历

    昨天在调试FIDO项目,运行时发现某个变量的值和我设置的不对。调试进去,几番折腾,吓呆了我:

    sickworm
  • g++中宏NULL究竟是什么?

    NULL是个指针,还是个整数?0?或(void*)0?答案是和g++版本有关。g++ 4.6支持C++11,引入了nullptr,也许会发生变化。

    一见
  • php处理数组相关

    Text-to-speech function is limited to 200 characters

    botkenni
  • PHP301跳转代码

    Huramkin
  • Hadoop 2.0中作业日志收集原理以及配置方法

    Hadoop 2.0提供了跟1.0类似的作业日志收集组件,从一定程度上可认为直接重用了1.0的代码模块,考虑到YARN已经变为通用资源管理平台,因此,提供一个通...

    小小科
  • Windows下获取网络连线实际名称,加强IP类设置脚本的兼容性

    在之前的工作中,由于分区域管理,TCP 设置有差异,所以编写过很多关于 IP 切换的脚本,作为大批量电脑维护的脚本,最重要的就是兼容性,可不能就测试的那几台电脑...

    张戈
  • 动态 | 云从科技与上海交大 AAAI 入选论文解读:语义角色标注新思路 get

    AI 科技评论按:AAAI 2019 已于月初落幕,国内企业也在陆续公布自家被录用论文名单。本届大会共收到 7700 余篇有效投稿,其中 7095 篇论文进入评...

    AI科技评论
  • NVIDIA显卡虚拟化vGPU终于支持KVM了

    2018年5月14日,NVIDIA发布NVIDIA virtual GPU software 6.1 (390.57/391.58),正式增加了对RedHat ...

    虚拟化云计算

扫码关注云+社区

领取腾讯云代金券