You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

22 KiB

加餐四 | 什么是“空安全思维”?

你好,我是朱涛。这节加餐,我们来聊聊空安全思维。

null是很多编程语言中都有的设计不同的语言中名字也都不太一样比如Java和Kotlin里叫null在Swift里叫做nil而Objective-C当中根据情况的不同还细分了NULL、nil、Nil等等。

如果你有Java的经验那你一定不会对NullPointerExceptionNPE代码中常见的逻辑错误感到陌生。null会引起NPE但是在很多场景下你却不得不使用它。因为null用起来实在是太方便了。比如说前面第4讲我提到的计算器程序当中的calculate()方法它的返回值就是可为空的当我们的输入不合法的时候calculate()就会返回null。

一般来说我们会习惯性地用null来解决以下这些场景的问题

  • 当变量还没初始化的时候用null赋值
  • 当变量的值不合法的时候用null赋值
  • 当变量的值计算错误的时候用null赋值。

虽然这些场景我们不借助null也可以漂亮地解决但null其实才是最方便的解决方案。因为总的来说null代表了一切不正常的值。如果没有了null我们编程的时候将会面临很多困难。

所以null对于我们开发者来说是一把双刃剑我们既需要借助它提供的便利还需要避开它引出的问题。这是一种取舍我们要在null的利与弊当中找到一个平衡点。而且这里的平衡点在不同的场景中是不一样的。

那么怎么才能把握好null的平衡点呢这就体现出空安全思维的重要性了。

Java的空安全思维

在正式研究Kotlin的空安全思维之前我们先来看看Java是否能给我们带来一些灵感。

在Java当中其实也有一些手段来规避NPE最常见的手段当然就是判空,这是防御式编程的一种体现,应用范围也很广泛。

另外一种手段是@Nullable、@NotNull之类的注解开发者可以使用这样的注解来告诉IDE哪些变量是可能为空的哪些是不可能为空的IDE会借助这些注解信息来帮我们规避NPE。

不过注解这样的方式实际效果并不好主要有两个原因一方面是注解很难在代码中大面积使用这全依赖于开发者的习惯很难在大型团队中推行即使推行了也会影响开发效率另一方面即使在工程当中大面积推行可空注解也无法完全解决NPE的问题。

想象一下,虽然我们可以在函数的参数以及返回值上面都标注可空性,但无法为每一个变量、每一种类型,都标注这些可空信息。因此,可空注解这样的方式,必然是会留下很多死角的。

还有一种手段是Java 1.8当中引入的Optional这种手段的核心思路就是封装数据不再直接使用null。举个例子从前我们直接使用String类使用Optional以后我们就得改成 Optional<String>。如果我们要判断值是否为空就用optional.isPresent()。

但Optional有几个缺点

  • 第一点,增加了代码的复杂度;
  • 第二点降低了代码的执行效率Optional的效率肯定比String更低
  • 最后业界普及度不高。这其实也是由前面两者而决定的即使我们在自己的工程中用了Optional而第三方SDK当中没有的话它也很难与其交互。

由此可见Java解决NPE的三种思路都无法令人满意。

那么现在假设你自己就是Kotlin语言的设计者在Java的三种思路的基础上你能想到什么更好的思路吗

前面我们曾提到了,使用@NotNull之类的注解是存在死角的因为我们无法为每一个变量、每一个类型都加上可空注解。一方面是注解的Target存在限制另一方面是开发效率也会急剧下降。

所以,我们需要的其实是一种**简洁,且能为每一种类型都标明可空性的方式。**这样一来,我们自然而然就能想到一个更好的方案,那就是:从类型系统下手

Kotlin的空安全思维

Kotlin虽然是与Java兼容的但是它的类型系统与Java却有很大的不同。在Java当中我们用String代表字符串类型。而在Kotlin当中同样是字符串类型它却有三种表示方法。

  • String不可为空的字符串
  • String?,可能为空的字符串;
  • String!,不知道是不是可能为空。

这有点像是电影里的分身术Java当中的一个概念在Kotlin当中分化出了三种概念。

Kotlin的可空String?、不可空String我们在第1讲就已经介绍过了。Kotlin这样的类型系统让开发者必须明确规定每一个变量类型是否可能为空通过这样的方式Kotlin编译器就能帮我们规避NPE了。

可以说在不与Kotlin以外的环境进行交互的情况下仅仅只是纯Kotlin开发当中Kotlin编译器已经可以帮我们消灭NPE了。不过现代商业化的软件当中全栈使用Kotlin是不现实的这也就意味着我们将不得不与其他语言环境打交道其中最常见的就是Java。

Kotlin、Java混合编程的空安全

在Java当中是不存在可空类型这个概念的。因此在Kotlin当中我们把Java未知可空性的类型叫做平台类型比如String!。所有Java当中的不确定可空性的类型在Kotlin看来都是平台类型用“!”来表示。

让我们来看一个实际的例子:

// Java 代码

public class NullJava {
    public static String getMsg(String s) {
        return s + "Kotlin";
    }

    @Nullable
    public static String getNullableString(@Nullable String s) {
        return s + "Kotlin";
    }

    @NotNull
    public static String getNotNullString(@NotNull String s) {
        return "Hello World.";
    }
}

上面的代码中我们一共定义了三个Java方法第一个getMsg()我们直接返回了一个字符串但是没有用可空注解标注第二个方法getNullableString()则使用了@Nullable修饰了代表它的参数和返回值是可能为空的第三个方法getNotNullString()则是用的@NotNull修饰的代表它的参数和返回值是不可能为空的。

而以上三个Java方法在Kotlin调用的时候就出现以下几种情况

// Kotlin代码

fun testPlatformType() {
    val nullableMsg: String? = NullJava.getNullableString(null)
    val notNullMsg: String = NullJava.getNotNullString("Hey,")

    val platformMsg1: String? = NullJava.getMsg(null)
    val platformMsg2: String = NullJava.getMsg("Hello")
}

也就是由于Java当中的getNullableString()是由@Nullable修饰的因此Kotlin编译器会自动将其识别为可空类型“String?”而getNotNullString()是由@NotNull修饰的因此Kotlin编译器会自动将其识别为不可空类型“String”。

最后是getMsg()由于这个Java方法没有任何可空注解因此它在Kotlin代码中会被认为是平台类型“String!”。

图片

对于平台类型Kotlin会认为它既能被当作可空类型“String?”也可以被当作不可空类型“String”。Kotlin没有强制开发者而是将选择权交给了开发者。所以我们开发者就需要在这中间寻找平衡点。

我们不能将平台类型一概认为是不可空的因为这会引发NPE我们也不能一概认为平台类型是可空的因为这会导致我们的代码出现过多的空安全检查。

在这里结合我个人的一些实践经验来看对于Kotlin与Java混合编程的情况这几个建议你可以参考一下

  • 对于工程中的Java源代码当它与Kotlin交互的时候我们应该尽量为它的参数与返回值加上可空的注解。注意这里并不是说要一次性为所有Java代码都加上注解而是当它与Kotlin交互的时候。这个需求其实可以通过一些静态代码检测方案来实现。
  • 对于工程当中的Java SDK当它需要与Kotlin交互的时候如果SDK没有完善的可空注解我们可以在SDK与业务代码之间建立一个抽象层对Java SDK进行封装。

至此我们就能总结出Kotlin空安全的第一条准则警惕Kotlin以外的数据类型。

语言角度上看Kotlin不仅只是和Java交互它还可以与其他语言交互而如果其他语言没有可空的类型系统那么我们就一定要警惕起来。

另外,从环境角度上看Kotlin还可以与其他外界环境交互比如发起网络请求、解析网络请求、读取数据库等等。这些外界的环境当中的数据往往也是没有可空类型系统的这时候我们更要警惕。

举个例子很多Kotlin开发者会在JSON解析的时候遇到NPE的问题这也是一个相当棘手的问题这个问题我们会在后面的实战项目中进一步分析。

聊完了Kotlin与外界交互的空安全准则后我们再来看看纯Kotlin开发的准则。

纯Kotlin的空安全

正常情况下我们使用纯Kotlin开发编译器已经可以帮助解决大部分的空安全问题了。不过纯Kotlin开发仍然有三大准则需要严格遵守。接下来,我们就通过两个实际场景,来具体学习下这三个准则。

  • 非空断言

在前面我们曾经学习过Kotlin的空安全调用语法“?.”其实Kotlin还提供了一个非空安全的调用语法“!!.”。这样的语法,我们也叫做非空断言。让我们来看个具体的例子:

fun testNPE(msg: String?) {
//            非空断言
//              ↓
    val i = msg!!.length
}

fun main() {
    NullExample.testNPE(null)
}

正常情况下如果我们要调用“String?”类型的成员,需要使用空安全调用,而这里我们使用非空断言,强行调用了它的成员。毫无疑问,上面的代码就会产生空指针异常。

看到这里,相信你马上就能总结出第二条空安全准则了:绝不使用非空断言“!!.”

看到这条准则也许很多人都会觉得这不是废话吗谁会喜欢用非空断言呢但是啊在我过去的几年经验当中确实见到过不少Kotlin断言相关的代码它们大致有两个原因。

第一个原因是当我们借助IDE的“Convert Java File To Kotlin File”的时候这个工具会自动帮我们生成带有非空断言的代码。而我们在转换完代码以后却没有review进而将非空断言带到了生产环境当中。

就以下面这段代码为例:

// Java 代码

public class JavaConvertExample {
    private String name = null;

    void init() {
        name = "";
    }

    void test(){
        if (name != null) {
            int count = name.length();
        }
    }
}

这段代码是非常典型的Java代码其中我们也已经使用了if来判断name是否为空然后再调用它的length。

那么如果我们借助IDE的转换工具它会变成什么样呢

class JavaConvertExample {
    private var name: String? = null
    fun init() {
        name = ""
    }

    fun foo() {
        name = null;
    }

    fun test() {
        if (name != null) {
//                        非空断言
//                           ↓
            val count = name!!.length
        }
    }
}

可以看到转成Kotlin代码以后我们test()方法当中出现了非空断言

你也许会好奇Kotlin不是支持Smart Cast吗既然我们已经在if当中判断了name不等于空那么它不是会被Smart Cast成为一个非空类型吗毕竟我们经常能写出这样的代码

val name: String? = getLocalName()
if (name != null) {
//   判断非空后,被转换成非空类型了
//      ↓
    name.length
}

那么,回到我们上面的例子当中,如果我们将转换出来的非空断言语法删除掉,会发生什么?

图片

可见当我们删除掉非空断言以后IDE报错了大致意思是在这种场景下Smart Cast是不可能发生的。为什么呢我们判空以后的代码明明就很安全了啊为什么不能自动转换成非空类型

这就是很多Kotlin开发者使用非空断言的第二个原因在某些场景下Smart Cast失效了即使我们判空了,也免不了还是要继续使用非空断言

注意了这种情况下并不是IDE出现了Bug。它在这种情况下不支持Smart Cast是有原因的。我给你举个例子

class JavaConvertExample {
    private var name: String? = null
    fun init() {
        name = ""
    }

    fun foo() {
        name = null;
    }

    fun test() {
        if (name != null) {
            // 几百行代码
            foo()
            //几百行代码
            val count = name!!.length
        }
    }
}

当我们的程序逻辑变得复杂的时候在判空后我们可能又会不小心改变name的值比如上面的foo()函数这时候我们的非空断言就会产生NPE。这也是Kotlin编译器无法帮我们做Smart Cast的原因。

那么,在这种情况下,我们到底该如何避免使用非空断言呢?主要有这么几种方法。

第一种,避免直接访问成员变量或者全局变量,将其改为传参的形式:

//    改为函数参数
//        ↓
fun test(name: String?) {
    if (name != null) {
//             函数参数支持Smart Cast
//                      ↓
        val count = name.length
    }
}

在Kotlin当中函数的参数是不可变的因此当我们将外部的成员变量或者全局变量以函数参数的形式传进来以后它可以用于Smart Cast了。

第二种避免使用可变变量var改为val不可变变量

class JavaConvertExample {
//       不可变变量
//           ↓
    private val name: String? = null

    fun test() {
        if (name != null) {
//               不可变变量支持Smart Cast
//                          ↓
            val count = name.length
        }
    }
}

这种方式很好理解,既然引发问题的根本原因是可变性导致的,我们直接将其改为不可变的即可。从这里,我们也可以看到“空安全”与“不可变性”之间的关联。

第三种,借助临时的不可变变量:

class JavaConvertExample {
    private var name: String? = null

    fun test() {
//        不可变变量
//            ↓
        val _name = name
        if (_name != null) {
            // 在if当中只使用_name这个临时变量
            val count = _name.length
        }
    }
}

以上代码本质上还是借助了不可变性。这种方式看起来有点丑陋但如果稍微封装一下也是有用的比如接下来要用到的let。

第四种是借助Kotlin提供的标准函数let

class JavaConvertExample {
    private var name: String? = null

    fun test() {
//                      标准函数
//                         ↓
        val count = name?.let { it.length }
    }
}

这种方式和第三种方式从本质上来讲是相似的但是我们通过let可以更加优雅地来实现同样的需求。

第五种是借助Kotlin提供的lateinit关键字

class JavaConvertExample {
//         稍后初始化             不可空
//            ↓                   ↓
    private lateinit var name: String

    fun init() {
        name = "Tom"
    }

    fun test() {
        if (this::name.isInitialized) {
            val count = name.length
        } else {
            println("Please call init() first!")
        }
    }
}

fun main() {
    val example = JavaConvertExample()
    example.init()
    example.test()
}

如果你足够细心,你会发现这种思路其实是完全抛弃可空性的。我们直接用lateinit var定义了不可能为空的String类型然后当我们要用到这个变量的时候再去判断这个变量是否已经完成了初始化。

由于它的类型是不可能为空的因此我们初始化的时候必须传入一个非空的值这就能保证只要name初始化了它的值就一定不为空。在这种情况下我们就将判空问题变成了一个判断是否初始化的问题。

第六种使用by lazy委托

class JavaConvertExample {
//         不可变        非空   懒加载委托
//           ↓           ↓        ↓
    private val name: String by lazy { init() }
    
    fun init() = "Tom"
    
    fun test() {
        val count = name.length
    }
}

可以看到我们将name这个变量改为了不可变的非空属性并且借助Kotlin的懒加载委托来完成初始化。借助这种方式我们可以尽可能地延迟初始化同时也消灭了可变性、可空性。

到这里,相信你就可以总结出第三条准则了:尽可能使用非空类型

下面我们再来看看另一个场景。

  • 泛型可空性

在学习泛型的时候我曾经介绍过我们可以用字母比如T来代表某种未知的类型以此来提升程序的灵活性。比如我们很容易就能写出下面这样的代码

// 泛型定义处              泛型使用处
//   ↓                      ↓
fun <T> saveSomething(data: T) {
    val set = sortedSetOf<T>() // Java TreeSet
    set.add(data)
}

fun main() {
//                 泛型实参自动推导为String
//                        ↓
    saveSomething("Hello world!")
}

这段代码没有实际应用价值它代表的是一种代码模式。我们重点来看看saveSomething()这个方法,请问你能找出它的问题在哪吗?说实话,如果不是实际踩过坑,我们是很难意识到这段代码的问题在何处的。

让我们来看看这段代码是怎么出问题的。

// 泛型定义处              泛型使用处
//   ↓                      ↓
fun <T> saveSomething(data: T) {
    val set = sortedSetOf<T>()
//    空指针异常              
//       ↓
    set.add(data)
}

fun main() {
//               编译通过
//                  ↓
    saveSomething(null)
}

在上面的代码中虽然我们定义的泛型参数是“T”函数的参数是“data: T”看起来data好像是不可为空的但实际上我们是可以将null作为参数传进去的。这时候编译器也不会报错。紧接着由于TreeSet内部无法存储null所以我们的代码在“set.add(data)”这里,会产生运行时的空指针异常。

出现这样的问题的原因其实是因为泛型的参数T给了我们一种错觉让我们觉得T是非空的而“T?”才是可空的。实际上我们的T是等价于 <T: Any?>因为Any?才是Kotlin的根类型。这也就意味着泛型的T是可以接收null作为实参的。


fun <T> saveSomething(data: T) {}
//   ↑ 
//  等价              
//   ↓                      
fun <T: Any?> saveSomething(data: T) {}


那么saveSomething()这个方法,正确的写法应该是怎样的呢?答案其实也很简单:

// 增加泛型的边界限制              
//       ↓                      
fun <T: Any> saveSomething(data: T) {
    val set = sortedSetOf<T>()
    set.add(data)
}

fun main() {
//              编译无法通过
//                  ↓
    saveSomething(null)
}

以上代码中我们为泛型T 增加了上界“Any”由于Any是所有非空类型的“根类型”这样就能保证我们的data一定是非空的。这样一来当我们尝试往saveSomething()这个方法里传入null的时候编译器就会报错让这个问题在编译期就能暴露出来。

看到这里相信你也可以总结出Kotlin空安全的第四条准则了明确泛型可空性

小结

学完了这节课相信现在你对Kotlin的空安全思维就已经有了一个全面的认识。最后我也再给你总结一下你需要重点关注Kotlin的空安全思维主要有四大准则

  • 第一个准则:警惕Kotlin与外界的交互。这里主要分为两大类第一种是Kotlin与其他语言的交互比如和Java交互第二种是Kotlin与外界环境的交互比如JSON解析。
  • 第二个准则:绝不使用非空断言“!!.”。这里主要是两个场景需要注意一个是IDE的“Convert Java File To Kotlin File”功能转换的Kotlin代码一定要review消灭其中的非空断言另一个是当Smart Cast失效的时候我们要用其他办法来解决而不是使用非空断言。
  • 第三个准则:尽可能使用非空类型。借助lateinit、懒加载我们可以做到灵活初始化的同时还能消灭可空类型。
  • 第四个准则:明确泛型的可空性。我们不能被泛型T的外表所迷惑当我们定义 <T> 的时候,一定要记住,它是可空的。在非空的场景下,我们一定要明确它的可空性,这一点,通过增加泛型的边界就能做到 <T: Any>

其实Kotlin的空安全思维也并不是四个准则就可以完全概括的不过这四个准则可以为我们指明方向为我们后面的学习打下基础。Kotlin的空安全其实和Kotlin的每一个特性都息息相关。比如我们在前面课程里就应用了不变性、lateinit、懒加载委托、泛型边界等特性来解决空安全问题。

思考题

这节课我介绍了空安全的四大准则,请问你还能想到其他的准则吗?欢迎在评论区分享你的思路。