目录
Kotlin 空安全思维
Java 的空安全思维
Java 中规避 NPE 的几个方案:
- 判空:这是
防御式编程
的一种体现,应用范围也很广泛 - 注解:借助
@Nullable
、@NotNull
之类的注解,IDE 可以帮我们规避 NPE - 封装数据:例如 1.8 中引入的
Optional
,这种手段的核心思路就是封装数据,不再直接使用 null
Java 解决 NPE 的三种思路都无法令人满意。
其实,我们需要的是一种简洁
,且能为每一种类型
都标明可空性
的方式。这样一来,我们自然而然就能想到一个更好的方案,那就是:从类型系统
下手。
Kotlin 的空安全思维
Kotlin 的类型系统与 Java 有很大的不同。在 Kotlin 当中,同样是字符串类型,却有三种表示方法。
String
:不可为空的字符串String?
:可能为空的字符串String!
:不知道是不是可能为空的字符串 -- 平台类型
Kotlin 这样的类型系统
,让开发者必须明确规定每一个变量类型是否可能为空
,通过这样的方式,Kotlin 编译器就能帮我们规避 NPE 了。
在不与 Kotlin 以外的环境进行交互的情况下,仅仅只是纯 Kotlin 开发当中,Kotlin 编译器已经可以帮我们消灭 NPE 了。不过,与 Java 等其他语言环境打交道时,问题就比较复杂了。
Kotlin 中的平台类型
Java 中不存在可空类型
这个概念,因此,在 Kotlin 中,我们把 Java 中的所有未知可空性的类型
,都看做是平台类型。平台类型用 !
来表示,比如String!
。
class Test {
@Nullable
public static String getNullableString(@Nullable String s) {
return s + "Nullable"; // 使用 @Nullable 修饰,代表参数和返回值可能为空
}
@NotNull
public static String getNotNullString(@NotNull String s) {
return s + "NotNull"; // 使用 @NotNull 修饰,代表参数和返回值不可能为空
}
public static String getMsg(String s) {
return s + "Kotlin"; // 直接返回了一个字符串,但是没有用可空注解标注
}
}
由于 Java 中的 getMsg() 方法没有任何可空注解,因此,它在 Kotlin 中会被认为是平台类型 String!
。
对于平台类型,Kotlin 会认为,它 既能被当作可空类型(例如 String?
),也可以被当作不可空类型(例如 String
)。
以上方法在 Kotlin 调用的时候,就出现以下几种情况:
fun main() {
val s1: String? = Test.getNullableString(null) // 可传 null,返回值为可空类型
val s2: String = Test.getNotNullString("Hey,") // 不可传 null,返回值为不可空类型
val s3: String? = Test.getMsg(null) // 返回值可以为【可空类型】,也可以为【不可空类型】
val s4: String = Test.getMsg("bqt") // 返回值可以为【可空类型】,也可以为【不可空类型】
}
Kotlin 空安全的第一条准则:警惕 Kotlin 以外的数据类型。
- 从语言角度上看:Kotlin 不仅会和 Java 交互,还可以与其他语言交互,如果其他语言没有可空的类型系统,那么我们就一定要警惕起来
- 从环境角度上看:Kotlin 可以与其他外界环境交互,这些外界的环境中的数据,往往也是没有可空类型系统的,这时候我们也要警惕
Kotlin 中的非空断言
Kotlin 空安全的第二条准则:绝不使用非空断言。
- Kotlin 空安全调用语法:
?.
- Kotlin 非空安全的调用语法:
!!.
,这样的语法也叫做非空断言
下面代码中,如果使用非空断言
,强行调用可为空的 String?
类型的成员,就会产生空指针异常。
fun testNPE(msg: String?) {
println(msg?.length) // 空安全调用语法,打印 null
println(msg!!.length) // 非空断言,非空安全的调用语法,报 NPE
}
fun main() {
testNPE(null)
}
在 Kotlin 代码中,我们应坚持 绝不使用非空断言,非空断言
代码主要在两种情况下会被引入:
- 使用IDE 的
Convert Java File To Kotlin File
功能时,工具会自动生成带有非空断言
的代码 - 某些场景下,
Smart Cast
会失效,导致我们即使判空了,也免不了是要继续使用非空断言
的代码
IDE 的代码转换功能
当我们借助 IDE 的 Convert Java File To Kotlin File
时,这个工具可能会自动帮我们生成带有非空断言
的代码。
public class JavaConvertExample {
private String name = null;
void init() {
name = "";
}
void test() {
if (name != null) {
System.out.println(name.length());
}
}
}
上述 Java 代码通过 IDE 的转换成 Kotlin 代码后为:
class JavaConvertExample {
private var name: String? = null
fun init() {
name = ""
}
fun test() {
if (name != null) {
println(name!!.length) // 非空断言
}
}
}
可以看到,转成 Kotlin 代码以后,test() 方法中出现了非空断言
。如果我们在转换完代码以后,没有 review,尽可能将非空断言
带到生产环境中。
Smart Cast 失效原因
Kotlin 是支持 Smart Cast
的,如果我们已经在 if 中判断了 name 不等于空,那么,就可以被转换成非空类型了,例如:
import kotlin.random.Random
fun getName() = if (Random.nextBoolean()) null else "bqt"
fun main() {
val name: String? = getName() // name 是局部变量,至于是 var 还是 val 都可以
if (name != null) {
val tem: String = name // 判断非空后,就可以被转换成【非空类型】了
println(name.length)
}
}
但是,上面 IDE 自动转换的 Kotlin 代码中,如果我们将转换出来的非空断言
语法删除掉,IDE 就报错了:
Smart cast to 'String' is impossible 不可能发生的, because 'name' is a
mutable property
that could have been changed by this time
我们将代码简化一下:
import kotlin.random.Random
var city: String? = if (Random.nextBoolean()) null else "sz" // city 是全局变量(或成员属性)
fun main() {
if (city != null) {
println(city.length) // 编译报错
}
}
可以发现,两者的核心区别是,上面的 name 是局部变量
,而下面的 city 是可变的全局变量
。这就导致,即使前一行代码中 city 已经判空了,后一行代码运行时,city 也可能已经被改变了
(比如多线程)。所以此时没办法使用 Smart Cast
。
如何避免使用非空断言
Kotlin 空安全的第三条准则:尽可能使用非空类型。
借助 lateinit、懒加载,我们可以做到灵活初始化的同时,还能消灭可空类型。
① 改为函数传参的形式
fun test(name: String?) { // 改为函数参数
if (name != null) {
println(name.length) // 函数参数支持 Smart Cast
}
}
函数的参数
是不可变的,因此,当我们将外部的成员变量
或者全局变量
,以函数参数
的形式传进来后,就可以用于 Smart Cast
了。
② 改为使用不可变变量
class JavaConvertExample {
private val name: String? = null // 将可变变量 var 改为不可变变量 val
fun test() {
if (name != null) {
println(name.length) // 不可变变量支持 Smart Cast
}
}
}
③ 借助临时的不可变变量
class JavaConvertExample {
private var name: String? = null
fun test() {
val _name = name // 定义一个临时的【不可变变量】
if (_name != null) {
println(_name.length) // 使用临时变量的【不可变变量】
}
}
}
④ 借助标准函数 let
class JavaConvertExample {
private var name: String? = null
fun test() {
name?.let { println(it.length) } // 使用标准函数 let
}
}
这种方式和第三种方式,从本质上来讲是相似的,但是使用 let
的实现更加优雅。
⑤ 借助 lateinit 关键字
class JavaConvertExample {
private lateinit var name: String // 【稍后初始化】【不可空类型】的变量
fun init() {
name = "Tom"
}
fun test() {
if (::name.isInitialized) println(name.length) else println("not init")
}
}
fun main() {
val example = JavaConvertExample()
example.test() // not init
example.init()
example.test() // 3
}
这种思路其实是完全抛弃可空性
的。由于它的类型是不可为空
的,因此我们初始化的时候,必须传入一个非空的值,这就能保证:只要 name 初始化了,它的值就一定不为空。在这种情况下,我们就将判空
问题变成了一个判断是否初始化
的问题。
⑥ 使用 by lazy 委托
class JavaConvertExample {
private val name: String by lazy { init() } // 【不可变】的【非空】属性
private fun init() = "Tom"
fun test() {
println(name.length)
}
}
我们将 name 这个变量声明为了不可变的非空属性
,并且,借助 Kotlin 的懒加载委托
来完成初始化。借助这种方式,我们可以尽可能地延迟初始化,同时,也消灭了可变性、可空性。
明确泛型的可空性
Kotlin 空安全的第四条准则:明确泛型可空性。
fun <T> saveSomething(data: T) { // 注意,泛型 T 是可为空的类型
val set = sortedSetOf<T>() // 对应 Java TreeSet
set.add(data) // TreeSet 内部无法存储 null
}
fun main() {
saveSomething("bqt") // 泛型实参自动推导为 String
saveSomething(null) // 编译通过,运行时报 NPE
}
上面的代码中,我们定义的泛型参数 T
,是可为空的类型。所以,我们可以将 null
作为参数传进去的,并且编译器也不会报错。紧接着,由于 TreeSet 内部无法存储 null,所以我们的代码在 set.add(data)
这里,会产生空指针异常。
实际上,我们的 T
是等价于 <T: Any?>
的,这也就意味着,泛型的 T
是可以接收 null
作为实参的。
fun <T> saveSomething(data: T) {}
// 等价于
fun <T: Any?> saveSomething(data: T) {}
所以,正确的写法是,为泛型 T
增加上界 Any
,这样当我们尝试传入 null
的时候,编译器就会报错,让这个问题在编译期就能暴露出来。
fun <T: Any> saveSomething(data: T) { // 增加泛型的边界限制【Any】,不增加时,默认为【Any?】
val set = sortedSetOf<T>()
set.add(data)
}
小结
Kotlin 的空安全思维,主要有四大准则:
- 警惕 Kotlin 与外界的交互
- 绝不使用非空断言
!!.
- 尽可能使用非空类型
- 明确泛型的可空性
2016-08-29