本文收录于 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方法需要关注以下几个点:
- m2的入参被加上了可为null的注解,如下所示:
@Lorg/jetbrains/annotations/Nullable;() // invisible, parameter 0
- 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方法来说也只需要关注以下几个点:
- m3方法的入参同样被标注为了可为null,如下所示:
@Lorg/jetbrains/annotations/Nullable;() // invisible, parameter 0
- 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
上面三种场景都分析完了,现在我们来总结下:
- 对于入参不可能为空的类型,kotlin编译器会加上 @Lorg/jetbrains/annotations/NotNull;注解,反之会加上@Lorg/jetbrains/annotations/Nullable;注解。
- 对于不可能为null的入参,则会直接执行对应的代码逻辑。而对于可能为null的入参,则会根据调用方式的不同而不同,参见下面第3点。
- 对于使用 ?. 操作符的语句,kotlin会进行调用变量是否为null的判断,如果不为null,就执行对应的代码逻辑,否则什么都不做;而对于使用 !! 操作符的语句,kotlin同样也会进行是否为null的判断,只不过当调用变量为null的时候,会直接抛出空指针异常。
至此,kotlin空安全的场景及其背后的原理分析完毕。
网友评论