Kotlin 中的 null safety

从历史上看,在编程语言中空引用(Null Reference)一直是一个不太好的概念。空引用的最早在1964年由Tony Hoare 博士发明,随后的主流语言中都延续了空引用的使用,包括 C, C++, Java, C# 等。空引用在编程中带来了一系列的麻烦,在2016年的QCon中Tony Hoare 博士将空引用称作十亿美元的错误(The Billion Dollar Mistake)。

Null 带来的问题

破坏类型系统

静态检查的编程语言可以由编译器确保类型正确,不需要等到运行时实际执行代码。 例如在 java 中当我们写下 x.toUpperCase() , 编译器会检查 x 的类型。如果 x 得类型是 String , 那么检查通过;如果是其他类型,比如 Servlet , 那么检查失败。 静态类型检查在编写大型的复杂软件时会提供强大的帮助。但是在 Java 中,由于任何引用都可以是 Null,编译器得静态检查变得非常痛苦。例如

  • toUppercase() 可以由任意 String 类型的对象安全调用,除非是 Null

  • read() 可以由任意 InputStream 及其子类对象安全的调用,除非对象是 Null

  • toString() 可以由任意 Object 调用,除非 Object 是 Null

代码变得繁琐

由于静态编译检查的失败,我们不得不做大量的运行时检查来防止 NullPointerException 得出现。例如

// 字符串判断
if (str == null || str.equals("")) {
}

// 集合判断
if (list == null || list.isEmpty()) {
}

在 Google Guava 库中提供了 String.isNullOrEmpty 的方法来统一对 String 做检查。

使 API 得设计变得困难

由于 null 的存在,在设计 API 的时候很容易产生歧义,比如对于一个类似 getByName() 的方法,由于可能返回值可能为 null ,那么方法命名为 getByNameOrNullIfNotFound() 更合适。

以 Java Collection 中的 HashMap 为例,假设我们要从数据库中获取用户的电话号码,我们使用 HashMap 来缓存以避免重复请求。

Map<String, String> phoneNumberStore = new HashMap<>();
phoneNumberStore.put("Li Lei", "138-1100-0011"); 
phoneNumberStore.get("Li Lei"); // 返回 Li Lei 的号码 "138-1100-0011"
phoneNumberStore.get("Han Meimei"); // 返回 null,表示 Han Meimei 不存在

如果某个用户的号码不存在,我们仍然可以缓存,这样就不需要重新请求。

Map<String, String> phoneNumberStore = new HashMap<>();
phoneNumberStore.put("Lucy", null); // Lucy 没有电话,我们缓存 null 代替
phoneNumberStore.get("Lucy");

这里对于返回值就产生了歧义:

  1. 这个用户不在缓存中(Han Meimei)

  2. 这个用户在缓存中和,但是没有电话号码(Lucy)

语言设计变得困难

Java 可以自动的转换包装类型和原生类型,由于 null 的存在,使得这一行为变得诡异并且难以调试。

int x = null; // 编译错误

// 编译通过,但是运行时抛出NullPointerException
Integer i = null;
int x = i; // 运行时错误

如果将 Integer i = null 作为参数值传递到 int 类型的方法参数里,那更是一个灾难,甚至很难定位到 null 的对象。

一些常见的处理方式

鉴于 null 的种种问题,也诞生了一系列针对 null 处理的方案。

空对象模式

空对象模式(Null object pattern)是一种设计模式,核心是使用单独定义的空对象来代替 null 的返回值。空对象可以在数据不可用时提供默认的行为。 在空对象模式中,我们需要先创建一个指定各种要执行的操作的抽象类或接口、扩展该类的实体类,还创建一个未对该类做任何实现的空对象类,该空对象类将无缝地使用在需要检查空值的地方。

public interface Animal {
    void makeSound() ;
}

public class Dog implements Animal {
    public void makeSound() {
        System.out.println("woof!");
    }
}

public class NullAnimal implements Animal {
    public void makeSound() {
        // 静音的
    }
}

空对象模式可以比较好的简化调用端的处理逻辑,并且可以定制空对象的行为。但是空对象模式也有几个问题:

  1. 代码更加繁琐,需要定义一个抽象类或者接口,并且无法处理原生对象,比如 String, Integer

  2. 函数仍然可以返回 null

使用标签联合

标签联合(Tagged Union)是一种代数数据类型,也被称为 Sum Type, variants 等等。 对 Tagged Union 的抽象一般写为 Optional.T = Some(T) | None 。 在guava的早期版本中就提供了 com.google.common.base.Optional<T> 类来专门处理 null 相关的场景,Optional 类提供了三个静态方法来实例化:

  1. Optional.of(T) :构造一个 Optional 对象,其内部包含了一个非 null 的T数据类型实例

  2. Optional.absent() :构造一个代表空值的 Optional 对象

  3. Optional.fromNullable(T) :将一个T的实例转换为 Optional 对象,T可以为空

    同时 Optional 类也提供了几个实例方法来处理具体的值:

    1. boolean isPresent() : 判断当前包含的实例是否为 null

    2. T get() : 获取实例,如果为 null 则抛出 IllegalStateException

    3. T or(T) : 获取实例,如果为 null 则以参数中的值代替

      可以看到 Tagged Union 可以很好的处理 null,这一方法也被众多编程语言所采用,在 rust、Haskell、swfit 等语言中甚至完全去掉了 null 。从 Java 8 开始,Jdk 也内置了 java.util.Optional<T> 类来处理 null, 使用方法和 guava 类似。 但是由于 null 的存在,实际项目中我们还是很容易犯错,比如 Optional 类实例本身为 null,参数也可能为 null。

使用断言检查参数

guava 和 RxJava 等库中大量使用类似断言的方式检查输入参数, ObjectHelper.requireNonNull , Preconditions.checkNotNull 以及从 Java 7 开始加入的 java.util.Objects.requireNonNull 等,如果输入为 null 则立即抛出 IllegalArgumentException 。 这种方式可以在第一时间检查输入,防止 null 变量继续进入后续的逻辑,另外重新抛出异常的方式能帮助我们更好的定位到有问题的变量。但是这种方式还是在运行时做的检查,出现异常调用要等到运行时才能发现。

使用JSR 305 Annotation

JSR 305 定义了几个 Annotation 来标记变量或者方法返回值是否可以为 null。

  1. Nullness annotations: @Nonnull , @CheckForNull

  2. Nullable annotations: @Nullable

    Android, Guava, JetBrains等都提供了类似的实现。使用Annotation标记之后,如果出现参数传递错误的情况,IDE中会给出相应的提示,一些静态检查工具如 FindBugs 等也会检查到问题,通过配置编译器能给出 Warning 。

    比如在 Guava 中,使用 @Nullable 来标记可空参数(在 Guava 中所有参数默认都是不可以为 null 的)。

    public static boolean isNullOrEmpty(@Nullable String string) {
        return string == null || string.length() == 0; // string.isEmpty() in Java 6
    }
    

Kotlin 的方案

Kotlin 中的类型

Kotlin 中对 null 的处理采用的是 UnTagged Union 的方案,和 Taggged Unio 的区别可以表示为 Optional.T = T | None 。这也是 Kotlin 和 Swift 的不同,语法上来说两者非常接近,但是从语义上又不完全相同。 Kotlin 中所有变量默认都是不可以为空的,变量是否允许为空,必须在定义时明确,例如:

var a: String = "abc"
a = null // 编译错误

var b: String? = "abc" // 变量声明时在类型的后面加上 ? 表示可空
b = null // ok
print(b)

Kotlin 可以保证调用 a 的方法不会出现 NullPointerException ,我们可以安全的调用 a 的任何实例方法:

val upper = a.toUpperCase()

但是,当我们要调用 b 的方法时,由于 b 是可空类型,编译器会报告错误:

val upper = b.toUpperCase() // error: variable 'b' can be null

那么我们怎么来处理 Kotlin 中的可空类型呢?

处理可空类型

Kotlin 没有针对可空类型进行隐式转换,但是提供了基于控制流的类型推断(Flow-sensitive typing),它可以很好的和 untagged union 结合。当编译器可以确定 Optional.T 类型的变量不为 null(None) 时,将自动转为 T 类型。 还是以上边的 b 为例:

if (b != null) {
    val upper = b.toUpperCase() // 编译通过
}

需要注意的是,只有在当前作用域内确定不会重现变化的属性才可以进行自动转换,比如线程不安全的可变变量是无法自动转换的,例如:

class SmartCast {
    var nonSafe: String? = null

    fun cannotCast() {
        if (nonSafe != null) {
            print(nonSafe.length) // 编译错误, nonSafe 无法进行自动转换
        }
    }
}

但是,对于一些自定义的场景 Kotlin 无法帮我们确定一个可空变量是否为 null ,也就无法完成类型的自动转换,例如:

fun String?.isNotNull(): Boolean = this != null

fun foo(s: String?) {
    if (s.isNotNull()) s.length // 编译错误,s 无法转换为 String
}

从 Kotlin 1.3 版本开始,引入了实验性的 contract 特性,借助 contract 我们可以更好的处理自动类型转换,例如:

@kotlin.internal.InlineOnly
public inline fun CharSequence?.isNullOrEmpty(): Boolean {
    contract {
        returns(false) implies (this@isNullOrEmpty != null)
    }

    return this == null || this.length == 0
}


fun bar(x: String?) {
    if (!x.isNullOrEmpty()) {
        println("length of '$x' is ${x.length}") // s已经自动转换为非空类型
    }
}

实例

fun main(args: Array<String>) {
    var s: String? = "Hello world"
    // print(s.length) ---- 编译错误
    if (s != null) {
        print(s.length)
    }
}

运行输出:

$ java kotlinc example.kt -include-runtime -d example.jar
$ java java -jar example.jar 
11

和 JSR 305 结合使用

Kotlin 和 Java 有非常好的互操作性,对于 JSR 305 也提供了很好的支持。在调用 Java 编写的 API 的时候,Koltin 默认认为所有的参数和返回值都是可空的,比如在继承类或者实现接口的时候,通过 IDE 生成的模板代码中,参数默认是都可空类型。

class CustomSerializer: org.codehaus.jackson.map.JsonSerializer<UserFollow>() {
    override fun serialize(value: UserFollow?, jgen: JsonGenerator?, provider: SerializerProvider?) {
        // TODO
    }
}

上面的例子中,我们实现一个自定义的 Jaskson JsonSerializer,IDE 默认生成的代码会将参数设定为可空类型。当然我们仍然可以手动将参数类型转换为非可空类型。

interface WithJSR305 {
    @Nullable  String foo(String x);

    @Nonnull
    String bar(String x, @Nullable String y);

    String baz(@Nonnull String x);
}

我们定义个一个接口,给相应的参数和方法标注 JSR 305 的 annotation,IDE 会自动将对应的类型标注正确,更重要的是,如果类型不匹配,IDE 和 编译器都会报错。

class CustomImpl: WithJSR305 {
    override fun foo(x: String?): String? {
        TODO()
    }

    override fun bar(x: String?, y: String?): String {
        TODO()
    }

    override fun baz(x: String): String? {
        TODO()
    }
}

Reactor 库中使用 @NonNull , @Nullable , @NonNullApi 为 Kotlin 的 null safety 提供了全面的支持。

实例

首先定义个一个使用JSR305标记的 Java 类

import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

public class JSR305Test {
    @NotNull
    String nonNullApi(@NotNull String x) {
        return "NON_NULL: " + x;
    }

    @Nullable String nullableApi(@Nullable String x) {
        if (x == null) {
            return null;
        }
        return "nullable: " + x;
    }
}

在 kotlin 调用的时候如果使用了错误的类型,编译器会报错。

fun main(args: Array<String>) {
    val jsr305 = JSR305Test()

    println(jsr305.nonNullApi(null)) // 编译错误: 参数不可以是 null

    println(jsr305.nullableApi("hello world").length) // 编译错误: 返回值可能为 null ,不能直接使用 length 属性
}

使用相关操作符简化操作

很多时候我们需要可空类型的变量参数等等,但是每次使用之前都要进行空值判断比较繁琐,Kotlin 提供了一些操作符来帮忙我们简化操作。

使用 `?.` 来进行安全调用

使用 ?. 可以安全的调用可空类型的方法和属性,如果为空那么返回null,否则调用对应的方法或者属性。

val b: String? = null
println(b?.toUpperCase())

这个例子中, b?.toUpperCase() 如果 b 不为 null,那么返回 b.toUpperCase() , 否则返回 nullb?.toUpperCase 的型是 String?

对于嵌套比较深的复杂对象,使用 ?. 能够方便的进行链式调用。

user?.name?.firstName?.toUpperCase()

中间任意一个属性或者方法为 null 那么整个表达式就返回 null

实例

fun main(args: Array<String>) {
    var userInput: String?

    userInput = null

    userInput?.let { // 只有当 userInput 不为 null 时执行 let 方法
        println("$userInput is not null")
    }
}
$ java kotlinc example.kt -include-runtime -dexample.jar
$ java java -jar example.jar
$ 

三元操作符 `?:`

对于一个可空变量,很多时候我们希望可以在不为空时直接使用对应的值,空时使用默认值, ?: 操作符可以实现对应的效果。

val l = b?.length ?: -1

?: 左边的部分不为 null 时 ?: 返回对应的值,否则返回右边的值另外 ?: 也遵循短路原则,如果左边的值不为 null 右边的表达式是会执行的。

在函数中, ?: 可以用来提前从函数中退出。

fun foo(node: Node): String? {
    val parent = node.getParent() ?: return null
    val name = node.getName() ?: throw IllegalArgumentException("name expected")
    // ...
}

实例

class Person(val name: String, val age: Int)

fun main(args: Array<String>) {
    val john : Person? = Person("John", 32)

    val age = john?.age ?: 25
    val offsetAge = age + 1 //编译通过

    println("offsetAge: $offsetAge")

    val ageWithThrow = john?.age ?: throw IllegalArgumentException("John is null")
    val offsetThrowAge = ageWithThrow + 2 //编译通过

    println("offsetThrowAge: $offsetThrowAge")
}

运行结果:

$ java kotlinc example.kt -include-runtime -dexample.jar
$ java java -jar example.jar
offsetAge: 33
offsetThrowAge: 34

`lateinit`

通常 kotlin 类里的非空属性必须在构造期间初始化,但是很多时候我们会过其他方式来完成赋值操作,比如依赖注入( @Inject , @Autowired ),单元测试的 setup 方法, @PostConstruct 等。这时如果我们属性声明为可空类型就会带来很多的麻烦,使用时都要做可空判断或者使用 !! ,非常的不方便。

这种场景下,我们可以使用 lateinit 关键词来修饰属性,来避免 null 相关检查。

class UserController() {
    @Resource
    private lateinit var userService: UserService

    @GetMapping("/users/{uid}")
    fun getUserFavPostList(@PathVariable("uid") uid: uid): User {
        return userService.getUserInfo(uid) // 不需要空判断
    }
}

lateinit 使用有一定的条件限制,只能修饰 var 声明的属性,并且能有自定义的 getter/setter ,只能用在 class body 中的属性在 primary constructor 中的不可用。如果在属性初始化之前使用变量然会抛出 NPE ,从1.2开始,可以使用 isInitialized 来检查 lateinit var 是否被初始化。

if (this::userService.isInitialized) {
    println(userService.getUserInfo(uid))
}

lateinit 的实现是基于 kotlin 的属性委托,使用 notNull 也以达到类似的效果。

实例

以 junit 的 testcase 为例:

import org.junit.Assert
import org.junit.Before
import org.junit.Test

class LateInitTest {
    private lateinit var lateVar: String

    @Before
    fun setup() {
        this.lateVar = "Hello world!"
    }

    @Test
    fun call() {
        Assert.assertEquals(12, lateVar.length)
    }
}

执行结果:

$ kotlinc example.kt -include-runtime -d example.jar-cp ./lib/junit-4.12.jar
$ java -cp .lib/hamcrest-core-1.3.jar:./libjunit-4.12.jar:./example.jar org.junit.runner.JUnitCore LateInitTest
JUnit version 4.12
.
Time: 0.008

OK (1 test)

非安全操作符 `!!`

!! 会忽略变量的类型,强制转换为非空类型,但是这个操作符是不安的,如果变量为 null 那么会抛出 NPE

val upper = b!!.toUpperCase()
我来评几句
登录后评论

已发表评论数()

相关站点

热门文章