小谈 Kotlin 的空处理

2019-08-16 16:30:52 浏览数 (1)

近来关于 Kotlin 的文章着实不少,Google 官方的支持让越来越多的开发者开始关注 Kotlin。不久前加入的项目用的是 Kotlin 与 Java 混合开发的模式,纸上得来终觉浅,终于可以实践一把新语言。本文就来小谈一下 Kotlin 中的空处理。

一、上手的确容易

先扯一扯 Kotlin 学习本身。

之前各种听人说上手容易,但真要切换到另一门语言,难免还是会踌躇是否有这个必要。现在因为工作关系直接上手 Kotlin,感受是 真香(上手的确容易)

首先在代码阅读层面,对于有 Java 基础的程序员来说阅读 Kotlin 代码基本无障碍,除去一些操作符、一些顺序上的变化,整体上可以直接阅读。

其次在代码编写层面,仅需要改变一些编码习惯。主要是:语句不要写分号、变量需要用 var 或 val 声明、类型写在变量之后、实例化一个对象时不用 "new" …… 习惯层面的改变只需要多写代码,自然而然就适应了。

最后在学习方式层面,由于 Kotlin 最终都会被编译成字节码跑在 JVM 上,所以初入手时完全可以用 Java 作为对比。比如你可能不知道 Kotlin 里 companion object 是什么意思,但你知道既然 Kotlin 最终会转成 jvm 可以跑的字节码,那 Java 里必然可以找到与之对应的东西。

Android Studio 也提供了很方便的工具。选择菜单 Tools -> Kotlin -> Show Kotlin Bytecode 即可看到 Kotlin 编译成的字节码,点击窗口上方的 "Decompile" 即可看到这份字节码对应的 Java 代码。 —— 这个工具特别重要,假如一段 Kotlin 代码让你看得云里雾里,看一下它对应的 Java 代码你就能知道它的含义。

当然这里仅仅是说上手或入门(仅入门的话可以忽略诸如协程等高级特性),真正熟练应用乃至完全掌握肯定需要一定时间。

二、针对 NPE 的强规则

有些文章说 Kotlin 帮开发者解决了 NPE(NullPointerException),这个说法是不对的。在我看来,Kotlin 没有帮开发者解决了 NPE (Kotlin: 臣妾真的做不到啊),而是通过在语言层面增加各种强规则,强制开发者去自己处理可能的空指针问题,达到尽量减少(只能减少而无法完全避免)出现 NPE 的目的。

那么 Kotlin 具体是怎么做的呢?别着急,我们可以先回顾一下在 Java 中我们是怎么处理空指针问题的。

Java 中对于空指针的处理总体来说可以分为“防御式编程”和“契约式编程”两种方案。

“防御式编程”大家应该不陌生,核心思想是不信任任何“外部”输入 —— 不管是真实的用户输入还是其他模块传入的实参,具体点就是各种判空。创建一个方法需要判空,创建一个逻辑块需要判空,甚至自己的代码内部也需要判空(防止对象的回收之类的)。示例如下:

代码语言:javascript复制
    public void showToast(Activity activity) {
        if (activity == null) {
            return;
        }
        
        ......
    }

另一种是“契约式编程”,各个模块之间约定好一种规则,大家按照规则来办事,出了问题找没有遵守规则的人负责,这样可以避免大量的判空逻辑。Android 提供了相关的注解以及最基础的检查来协助开发者,示例如下:

代码语言:javascript复制
    public void showToast(@NonNull Activity activity) {
        ......
    }

在示例中我们给 Activity 增加了 @NonNull 的注解,就是向所有调用这个方法的人声明了一个约定,调用方应该保证传入的 activity 非空。当然聪明的你应该知道,这是一个很弱的限制,调用方没注意或者不理会这个注解的话,程序就依然还有 NPE 导致的 crash 的风险。

回过头来,对于 Kotlin,我觉得就是一种把契约式编程和防御式编程相结合且提升到语言层面的处理方式。(听起来似乎比 Java 中各种判空或注解更麻烦?继续看下去,你会发现的确是更麻烦……)

在 Kotlin 中,有以下几方面约束:

  1. 在声明阶段,变量需要决定自己是否可为空,比如 var time: Long? 可接受 null,而 var time: Long 则不能接受 null。
  2. 在变量传递阶段,必须保持“可空性”一致,比如形参声明是不为空的,那么实参必须本身是非空或者转为非空才能正常传递。示例如下: fun main() { ...... // test(isOpen) 直接这样调用,编译不通过 // 可以是在空检查之内传递,证明自己非空 isOpen?.apply { test(this) } // 也可以是强制转成非空类型 test(isOpen!!) } private fun test(open: Boolean) { ...... }
  3. 在使用阶段,需要严格判空: var time: Long? = 1000 //尽管你才赋值了非空的值,但在使用过程中,你无法这样: //time.toInt() //必须判空 time?.toInt()

总的来说 Kotlin 为了解决 NPE 做了大量语言层级的强限制,的确可以做到减少 NPE 的发生。但这种既“契约式”(判空)又“防御式”(声明空与非空)的方案会让开发者做更多的工作,会更“麻烦”一点。

当然,Kotlin 为了减少麻烦,用 "?" 简化了判空逻辑 —— "?" 的实质还是判空,我们可以通过工具查看 time?.toInt() 的 Java 等价代码是:

代码语言:javascript复制
      if (time != null) {
         int var10000 = (int)time;
      }

这种简化在数据层级很深需要写大量判空语句时会特别方便,这也是为什么虽然逻辑上 Kotlin 让开发者做了更多工作,但写代码过程中却并没有感觉到更麻烦。

三、强规则之下的 NPE 问题

在 Kotlin 这么严密的防御之下,NPE 问题是否已经被终结了呢?答案当然是否定的。 在实践过程中我们发现主要有以下几种容易导致 NPE 的场景:

1. data class(含义对应 Java 中的 model)声明了非空

例如从后端拿 json 数据的场景,后端的哪个字段可能会传空是客户端无法控制的,这种情况下我们的预期必须是每个字段都可能为空,这样转成 json object 时才不会有问题:

代码语言:javascript复制
data class User(
        var id: Long?,
        var gender: Long?,
        var avatar: String?)

假如有一个字段忘了加上"?",后端没传该值就会抛出空指针异常。

2. 过分依赖 Kotlin 的空值检查
代码语言:javascript复制
private lateinit var mUser: User

...

private fun initView() {
    mUser = intent.getParcelableExtra<User>("key_user")
}

在 Kotlin 的体系中久了会过分依赖于 Android Studio 的空值检查,在代码提示中 Intent 的 getParcelableExtra 方法返回的是非空,因此这里你直接用方法结果赋值不会有任何警告。但点击进 getParcelableExtra 方法内部你会发现它的实现是这样的:

代码语言:javascript复制
    public <T extends Parcelable> T getParcelableExtra(String name) {
        return mExtras == null ? null : mExtras.<T>getParcelable(name);
    }

内部的其他代码不展开了,总之它是可能会返回 null 的,直接赋值显然会有问题。

我理解这是 Kotlin 编译工具对 Java 代码检查的不足之处,它无法准确判断 Java 方法是否会返回空就选择无条件信任,即便方法本身可能还声明了 @Nullable

3. 变量或形参声明为非空

这点与第一、第二点都很类似,主要是使用过程中一定要进一步思考传递过来的值是否真的非空。

有人可能会说,那我全部都声明为可空类型不就得了么 —— 这样做会让你在使用该变量的所有地方都需要判空,Kotlin 本身的便利性就荡然无存了。

我的观点是不要因噎废食,使用时多注意点就可以避免大部分问题。

4. !! 强行转为非空

当将可空类型赋值给非空类型时,需要有对空类型的判断,确保非空才能赋值(Kotlin 的约束)。

我们使用!! 可以很方便得将“可空”转为“非空”,但可空变量值为 null,则会 crash

因此使用上建议在确保非空时才用 !!:

代码语言:javascript复制
    param!!

否则还是尽量放在判空代码块里:

代码语言:javascript复制
    param?.let {
        doSomething(it)
    }

四、实践中碰到的问题

从 Java 的空处理转到 Kotlin 的空处理,我们可能会下意识去寻找对标 Java 的判空写法:

代码语言:javascript复制
    if (n != null) {
        //非空如何  
    } else {
        //为空又如何
    }

在 Kotlin 中类似的写法的确有,那就是结合高阶函数 let、apply、run …… 来处理判空,比如上述 Java 代码就可以写成:

代码语言:javascript复制
    n?.let {
        //非空如何
    } ?: let {
        //为空又如何
    }

但这里有几个小坑。

1. 两个代码块不是互斥关系

假如是 Java 的写法,那么不管 n 的值怎样,两个代码块都是互斥的,也就是“非黑即白”。但 Kotlin 的这种写法不是(不确定这种写法是否是最佳实践,假如有更好的方案可以留言指出)。

?: 这个操作符可以理解为 if (a != null) a else b,也就是它之前的值非空返回之前的值,否则返回之后的值。

而上面代码中这些高阶函数都是有返回值的,详见下表:

函数

返回值

let

返回指定 return 或函数里最后一行

apply

返回该对象本身

run

返回指定 return 或函数里最后一行

with

返回指定 return 或函数里最后一行

also

返回该对象本身

takeIf

条件成立返回对象本身,不成立返回 null

takeUnless

条件成立返回 null,不成立返回该对象本身

假如用的是 let, 注意看它的返回值是“指定 return 或函数里最后一行”,那么碰到以下情况:

代码语言:javascript复制
    val n = 1
    var a = 0
    n?.let {
        a  
        ...
        null  //最后一行为 null
    } ?: let {
        a  
    }

你会很神奇地发现 a 的值是 2,也就是既执行了前一个代码块,也执行了后一个代码块

上面这种写法你可能不以为然,因为很明显地提醒了诸位需要注意最后一行,但假如是之前没注意这个细节或者是下面这种写法呢?

代码语言:javascript复制
    n?.let {
        ...
        anMap.put(key, value) // anMap 是一个 HashMap
    } ?: let {
        ...
    }

应该很少人会注意到 Map 的 put 方法是有返回值的,且可能会返回 null。那么这种情况下很容易踩坑。

2. 两个代码块的对象不同

以 let 为例,在 let 代码块里可以用 it 指代该对象(其他高阶函数可能用 this,类似的),那么我们在写如下代码时可能会顺手这样写:

代码语言:javascript复制
    activity {
        n?.let {
            it.hashCode() // it 为 n
        } ?: let {
            it.hashCode() // it 为 activity
        }   
    }

结果自然会发现值不一样。前一个代码块 it 指代的是 n,而后一个代码块里 it 指代的是整个代码块指向的 this。

原因是 ?: 与 let 之间是没有 . 的,也就是说后一个代码块调用 let 的对象并不是被判空的对象,而是 this。(不过这种场景会出错的概率不大,因为在后一个代码块里很多对象 n 的方法用不了,就会注意到问题了)

后记

总的来说切换到 Kotlin 还是比预期顺利和舒服,写惯了 Kotlin 后再回去写 Java 反倒有点不习惯。今天先写这点,后面有其他需要总结的再分享。

0 人点赞