kotlin入门潜修之特性及其原理篇—空安全

本文收录于 kotlin入门潜修专题系列,欢迎学习交流。

创作不易,如有转载,还请备注。

写在前面

少说些漂亮话,多做些日常平凡的事情。——与君共勉。

空安全

本篇文章将对kotlin中的空安全相关的知识进行阐述,并分析其背后的原理。
kotlin最为人熟知的便是解决了空指针问题,那么kotlin是怎么解决空指针问题的?是否能够完全避免空指针问题?这就是本节要阐述的话题。

什么是空指针想必大家都很清楚,这里就不再展开。直接奔入主题:在kotlin中如何实现空指针安全的?主要体现以下几个方面:

第一,kotlin在定义变量的时候就可以限制该变量是否能为null。
来看个例子,如下所示:

fun main(args: Array<String>) {
    var str: String = "test"//定义了类型为String的变量str
    str = null//!!!错误,str不能为null

    var str2: String? = "test"//定义了类型为String?的str2
    str2 = null//正确,str2可以为null
}

由代码来看,kotlin只有在用具体类型加上?(上面代码中的String?)来修饰变量的时候,才允许该变量为null。因此,如果我们不期望某个变量或者某个入参为null的时候,直接使用具体类型修饰即可,此时无法接收可能为null的入参;而当我们可以接受某个变量或者入参为null时,就可以使用 具体类型加 ? 来修饰,如下所示:

//方法m1,不能接受为null的字符串类型入参
fun m1(p1: String) {}
//方法m2,可以接受为null的字符串类型入参
fun m2(p2: String?) {}

fun main(args: Array<String>) {
    var str2: String? = "test"
    m1(str2)//!!!错误,无法接受可能为null的入参
    m2(str2)//正确,可以接受为null的入参
}    

第二,kotlin了提供了安全调用操作符 (?.) ,示例如下:

fun main(args: Array<String>) {
    var str: String? = null
    println(str.length)//!!!编译错误,对于可能为null的类型,不能直接这么使用
    println(str?.length)//正确,打印null
    str = "test"
    println(str?.length)//正确,打印 4
}

这就是kotlin的安全调用方式,即对于可能为null的类型,必须要使用安全的调用方式。咋一看,这种方式像是变量名后面跟了个问号,但是实际上却不是这样的,这个?不是和变量绑定的,而是和点(.)绑定的,即 ?. 是一个操作符,可以实现安全调用。这种调用方式在变量为null的时候不会crash而是打印null,在变量不为null的时候则正常执行代码。

第三,kotlin提供了 !! 操作符,当对象为null时,会强制抛出异常。示例如下:

fun main(args: Array<String>) {
    var str: String? = null
    println(str?.length)//正确,可以通过安全调用操作符调用
    println(str!!.length)//编译正确,但是因为str此时为null,故会抛出空指针异常。
    str = "test"
    println(str!!.length)//正确,打印 4,因为str不为null
}

上面代码在变量不为null的时候会正确执行,但是当变量为null的时候则会抛出kotlin.KotlinNullPointerException空指针异常。

其实,从效果上来看 !! 操作符并不是为了解决空安全问题的,因为其会抛出空指针异常,这个只是kotlin提供的另一种关于空处理的方式而已。

第四,一定条件下,kotlin拥有智能推断变量是否为null的能力,如下所示:

fun main(args: Array<String>) {
    var str: String? = null
    println(str.length)//!!!错误,str可能为null,无法直接使用,可以通过str?.length来调用
    var result = if (str != null) str.length else 0//正确!这里竟然又可以通过str.length来完成调用了??
    println(result)
}

重点关注 if (str != null) str.length else 0这一句,按照常理,对于可能为null的变量,必须通过?.操作符或者!!操作符调用,才能编译通过。然而,此处我们竟然没有通过这两种操作符,同样完成了调用!这是为什么?

这是因为,我们已经在前面通过if else语句进行了判断,所以kotlin可以据此智能推断出,在执行str.length的时候,str已不可能为null,所以允许这么写。

上面几条就是kotlin在空安全方面所做的工作,这将大大减轻我们写程序的压力,尤其是安全调用操作符,不仅能够有效减免空指针问题,还能大大减少代码量。

kotlin空安全中的"不安全性"

看这个标题实在有点难以明白是什么意思,其实这个小节想要表达的意思就是,即使kotlin在空安全方面做了很多的工作,但是依然无法完全避免空指针的产生,来看个例子。

//注意,这个是个java代码
public class Test {
//定义了一个静态方法getStr,这里直接返回null
    public static String getStr(){
        return null;
    }
}

上面代码是java代码,我们定义了一个getStr的静态方法,该方法直接返回了null,下面我们通过kotlin代码来使用getStr方法,如下所示:

import test.Test
fun main(args: Array<String>) {
    println(Test.getStr().length)
}

上面代码执行完成之后会发生什么?显然会产生空指针异常:java.lang.NullPointerException。这就是为什么说kotlin并不能完全避免空指针异常的问题。

如果说上面的例子是因为调用了java代码才产生了空指针异常,那么现在来看一个单纯使用kotlin也会产生空指针异常的场景,示例如下:

//定义了一个抽象类Test
abstract class Test {
//定义 了一个抽象属性str,子类复写了该属性
    abstract var str: String
    constructor() {
//然后我们在父类中的构造方法中打印str 的长度
        println(str.length)
    }
}
//这里定义了子类SubTest,复写了Test的str属性,并完成了赋值
class SubTest : Test() {
    override var str: String = ""
}
//测试方法main
fun main(args: Array<String>) {
    SubTest()//仅仅生成了一个子类对象
}

上述代码执行完后会产生什么问题?答案是会产生空指针异常!这是为什么?我们来分析下:

在执行SubTest()语句的时候,kotlin会首先执行父类的构造方法,然后再去完成子类属性的初始化,也就是说父类构造方法的初始化时机要高于子类属性的初始化时机。所以,在父类构造方法中打印str的长度的时候,实际上子类属性还没有完成初始化,进而产生了空指针异常。实际上如果我们在生成对象之后在打印str的长度,就不会产生空指针异常,因为此时str已经完成了初始化,如下所示:

abstract class Test {
    abstract open var str: String
    constructor() {
    }
    fun m1(){
        str.length
    }
}
class SubTest : Test() {
    override var str: String = ""
}
fun main(args: Array<String>) {
    SubTest().m1()//这里会正常执行,打印0,因为str为"",所以其长度为0
}

安全类型转换

在java编程的时候,我们一定会遇到过ClassCastException这个异常,即类型转换异常,比如我们将String转换为Integer,这个就会引起类型转换异常,那么在kotlin中,我们可以避免这种情况的发生,那就是通过使用安全类型转换as?来完成,使用as?进行类型转换的时候,如果转换失败则会将目标赋值为null,如下所示:

fun main(args: Array<String>) {
    var str: String? = null
    var value: Int = str as Int//抛出kotlin.TypeCastException异常
    var value2: Int? = str as? Int//能正确运行,只不过value2为null
    System.out.println(value2)//打印null
}

Elvis 操作符

Elvis 操作符能够大大简化if else表达式,可以省去繁琐的null判断,如下所示:

fun main(args: Array<String>) {
    var str: String? = "test"//定义了一个可能为null的字符串变量str
//我们可以通过if表达式来获取str的长度,但是比较麻烦
    val value: Int = if (str != null) str.length else 0
//这里我们通过Elvis操作符来获取str的长度,显得非常简洁
    val value2: Int = str?.length ?: 0
}

上面代码中的 ?: 操作符就是Elvis操作符,可以大大简化代码量。

空安全背后的原理

前面阐述了kotlin中关于空安全的几种场景,下面我们看下其背后的原理,照例先上我们要分析的代码:

//场景1,m1方法接收一个不可能为null的字符串
//在其方法体中我们获取了传入字符串的长度
fun m1(str: String) {
    str.length
}
//场景2,m2方法接收一个可能为null的字符串
//在其方法体中我们采用了安全调用操作符 ?. 来获取传入字符串的长度
fun m2(str: String?) {
    str?.length
}
//场景3,m3方法接收一个可能为null的字符串
//在其方法体中我们采用了 !!  来获取传入字符串的长度
fun m3(str: String?) {
    str!!.length
}

那么上面三种场景,kotlin都是怎么处理的呢?这里一个一个的来分析下。

首先看下场景1背后的字节码,如下所示:

  public final static m1(Ljava/lang/String;)V
    @Lorg/jetbrains/annotations/NotNull;() // invisible, parameter 0
   L0
    ALOAD 0
    LDC "str"
    INVOKESTATIC kotlin/jvm/internal/Intrinsics.checkParameterIsNotNull (Ljava/lang/Object;Ljava/lang/String;)V
   L1
    LINENUMBER 6 L1
    ALOAD 0
    INVOKEVIRTUAL java/lang/String.length ()I
    POP
   L2
    LINENUMBER 7 L2
    RETURN
   L3
    LOCALVARIABLE str Ljava/lang/String; L0 L3 0
    MAXSTACK = 2
    MAXLOCALS = 1

由字节码可知,该方法的入参会被加上非空注解,如下所示:

    @Lorg/jetbrains/annotations/NotNull;() // invisible, parameter 0

之后,kotlin编译器内部调用了是否为null的检查,这就是为什么我们传入null的时候会编译报错,如下所示:

    INVOKESTATIC kotlin/jvm/internal/Intrinsics.checkParameterIsNotNull (Ljava/lang/Object;Ljava/lang/String;)V

接着会直接调用str的length方法,返回str长度,如下所示:

    INVOKEVIRTUAL java/lang/String.length ()I

上面就是m1方法背后的原理,下面来看下m2方法背后的原理,如下所示:

// access flags 0x19
  public final static m2(Ljava/lang/String;)V
    @Lorg/jetbrains/annotations/Nullable;() // invisible, parameter 0
   L0
    LINENUMBER 10 L0
    ALOAD 0
    DUP
    IFNULL L1
    INVOKEVIRTUAL java/lang/String.length ()I
    POP
    GOTO L2
   L1
    POP
   L2
   L3
    LINENUMBER 11 L3
    RETURN
   L4
    LOCALVARIABLE str Ljava/lang/String; L0 L4 0
    MAXSTACK = 2
    MAXLOCALS = 1

m2方法需要关注以下几个点:

  1. m2的入参被加上了可为null的注解,如下所示:
    @Lorg/jetbrains/annotations/Nullable;() // invisible, parameter 0
  1. kotlin编译器对该场景做了如下处理:如果为null则什么都不做,否则直接调用str的length方法,如下所示:
    IFNULL L1//如果为null,则执行L1,即直接出栈
    INVOKEVIRTUAL java/lang/String.length ()I//否则调用str的length方法
    POP
    GOTO L2
   L1
    POP

最后,再来看下m3方法对应的字节码,如下所示:

 public final static m3(Ljava/lang/String;)V
    @Lorg/jetbrains/annotations/Nullable;() // invisible, parameter 0
   L0
    LINENUMBER 15 L0
    ALOAD 0
    DUP
    IFNONNULL L1
    INVOKESTATIC kotlin/jvm/internal/Intrinsics.throwNpe ()V
   L1
    INVOKEVIRTUAL java/lang/String.length ()I
    POP
   L2
    LINENUMBER 16 L2
    RETURN
   L3
    LOCALVARIABLE str Ljava/lang/String; L0 L3 0
    MAXSTACK = 3
    MAXLOCALS = 1

对于m3方法来说也只需要关注以下几个点:

  1. m3方法的入参同样被标注为了可为null,如下所示:
    @Lorg/jetbrains/annotations/Nullable;() // invisible, parameter 0
  1. m3方法对于传入为null的字符串直接抛出空指针异常,否则调用其length方法,如下所示:
//如果入参不为null,则执行L1,即调用str的length方法
    IFNONNULL L1
//否则,kotlin会直接抛出空指针异常,即调用Intrinsics.throwNpe ()
    INVOKESTATIC kotlin/jvm/internal/Intrinsics.throwNpe ()V
   L1
    INVOKEVIRTUAL java/lang/String.length ()I
    POP

上面三种场景都分析完了,现在我们来总结下:

  1. 对于入参不可能为空的类型,kotlin编译器会加上 @Lorg/jetbrains/annotations/NotNull;注解,反之会加上@Lorg/jetbrains/annotations/Nullable;注解。
  2. 对于不可能为null的入参,则会直接执行对应的代码逻辑。而对于可能为null的入参,则会根据调用方式的不同而不同,参见下面第3点。
  3. 对于使用 ?. 操作符的语句,kotlin会进行调用变量是否为null的判断,如果不为null,就执行对应的代码逻辑,否则什么都不做;而对于使用 !! 操作符的语句,kotlin同样也会进行是否为null的判断,只不过当调用变量为null的时候,会直接抛出空指针异常。

至此,kotlin空安全的场景及其背后的原理分析完毕。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值