Kotlin 空安全是否能变得更好?

2 阅读8分钟

概述

“null” 是软件开发领域很常见的一个特殊值,一个经典的说法是:Null References: The Billion Dollar Mistake,但它的威力不止在于各种可能的 NPE,现代语言普遍已经对可空和非空类型有了很明确的区分,在这种情况下还能出现的 NPE 其实就是程序员个人粗心(当然大概率是被需求排期逼的)导致的 unsafe 操作(如 Kotlin 中的 !! 或 lateinit 等)。

但本文我们想讨论的是现代语言(Kotlin 为例)中 “null” 设计的不足以及我认为更好一些的设计,同时也表达一些对 Kotlin 2.0 中 error type 特性的思考。

思考

滥用的可空传递

现代语言普遍会采取适当的“默认值”或“空传递”的策略来方便对可空元素的处理,在这点中,不止 Kotlin 会这么做,包括 C#、JS、Dart 等语言都会采用类似于 ?., ??, ?:, ??= 等形式来进行相应的处理。

但大多数语言的 null 都是无差别 null,即对于任何可空的类型来说,null 都是他们的有效值。这似乎不会有什么问题,但它隐藏掉了一个重要的事实,null 是如何传播的,下面我来举一个具体的例子:

fun process(bar: Bar?) = doSomething(bar)

fun main() {
    val bar: Bar? = x?.foo()?.bar()
    process(bar)
}

在上面的例子中,我们企图从外部一个可空的 x 变量获取可空的 bar,并且我们规定 foo 和 bar 的返回都是可空的,这是我们日常编码过程中非常容易写出来的代码。但这个代码有很严重的问题,传给 process 方法的 bar 为 null 时,究竟是 x 为 null,还是 foo 返回了 null,还是 bar 返回了 null 呢?

或许很多人看来这并不是问题,这不都是 null 吗,还用计较吗?但我为数不多的 5 年 Kotlin 开发经验告诉我,这是个非常严重的问题,可空传递的滥用导致我们排查问题极为困难。研发极为保守的防御性编程盛行,啥东西的返回都做成可空的。一个页面不显示问题,一个对象获取不到问题,通常需要我们仔细分析整个流程到底是因为哪个中间环节可空,造成了全局出现的空现象。

造成这一切的本质正是因为 null 传递时,不同的可空类型之间进行了隐式转换。

我并不认为这比 Java 时代,NPE 直接崩在了某个地方显得高明。Java 修复这种问题崩了马上就能找到源头,在 Java 中,由于没有如此便利的 null 传递机制,因此研发在写代码的时候通常是需要自己手写 null 判断和传递的代码的,至少会让研发具有一些体感,而 Kotlin 则让这个行为变得没有负担,我并不认为这是高明的选择。

在 Java 标准库中,通常会有不少 asset 代码,事实上我认为这比可空要好的很多,以 Instant 类为例,其 now 方法是这样的,需要传入一个 Clock 对象来确定用哪个 Clock 来获取现在的时间:

public static Instant now(Clock clock) {
    Objects.requireNonNull(clock, "clock");
    return clock.instant();
}

我承认对于极端保守防御主义,即使你让他们写,他们也会写出下面这样的代码,将出错排查成本传递给未来的使用者,这也是大多数企业内研发降低 crash 率的“银弹”

public static Instant now(Clock clock) {
    if (clock == null) return null;
    return clock.instant();
}

但提高成本或许会让研发有体感,让他们意识到如何写好一个函数。

当然滥用的可空传递,与滥用 try catch 所有的 exception 然后返回个 null 有的一拼。

Result 的引入

抛出错误事实上也不是较好的实践,尽管 Java 的具有 try catch 能力,但不少人在看到需要强制捕获的时候要么 print 一下然后返回空,要么就直接将异常继续向上传播,于是在 Kotlin 中也取消掉了强制的异常捕获。

Kotlin 取消异常捕获其实也让我们可能遇到更多可能存在的 bug,官方则在标准库中引入了 Result,通常被用在 runCatching { } 方法的返回,通常存在几种用法:

  • 有相当大一批人会直接 getOrNull 取值,然后和上面一样继续滥用可空传递。
  • 其次有一些人会选择 getOrThrow 直接抛出异常给上层处理,讲道理这和直接不套 Result 的行为也一样
  • 还有些人会 getOrDefault 给一个默认值

Kotlin 的 Result 只有一个泛型参数,因此当 Result 需要产生传递时,exception 的处理往往是被忽略的,会直接传递给新的 Result:

fun Result<String>.parse() : Result<Int> {
    return mapCatching { Integer.parseInt(it) }
}

和上文提到的可空传递一样,这里 Exception 的传递也是忽略的具体类型的,而仅仅是传递 Exception。相比之下我们可以看看 Rust 的设计,不同的 Error 默认是不允许直接转换的(除非实现了对应的自动类型转换):

fn convert(s: Result<String, StringFormatError>) -> Result<i32, ParseIntError> {
    s?.parse()
}

在上面的代码中 parse 是 rust std 自带的方法,能够将 String 转成 i32 并返回 Result<i32, ParseIntError>,但上面的代码编译是会在 ? 处报错的,因为 StringFormatError 并不能自动转换成 ParseIntError,日志如下:

fn convert(s: Result<String, StringFormatError>) -> Result<i32, ParseIntError> {
                                                    -------------------------- expected `ParseIntError` because of this
    s?.parse()
     -^ the trait `From<StringFormatError>` is not implemented for `ParseIntError`, which is required by `Result<i32, ParseIntError>: FromResidual<Result<Infallible, StringFormatError>>`
     |
     this can't be annotated with `?` because it has type `Result<_, StringFormatError>`

note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait

但事实上这并不是 Kotlin 的设计不好,Kotlin Exception 默认实现是提供了 message 和 cause 的,并且在各个平台上抛出时也通常能够拿到 stacktrace,因此在大多数情况下足以确定问题的发生地。

但在 Rust 这样的语言中,比较强的区分了可恢复 Err 和不可恢复 panic,且 Result 的 Err 是可以随便实现的,并不要求一定要包含 stacktrace 或其他溯源信息,因此想要找到错误具体发生的原因是很困难的,此时强制约束类型能够方便使用者在恢复时具备更多的一些信息(我个人认为强制类型可以认为是一种默认的 message)

Kotlin 2.0 带来的思考

具体可见:Kotlin 2.0 更新速览与 2.1、2.2+ 展望

Kotlin 2.0 开始,官方也提出了一种新的想法(目前还没实现):error object

error object NotFound // new category of types

// getOrThrow -> get
fun <T> get(): T | Error

// maxOrNull -> max
fun IntArray.max(): Int | NoSuchElement

// awaitSingleOrNull -> awaitSingle
fun <T> awaitSingle(): T | NoSuchElement

可以自己定义一种新的 category 的类型,而且并不是一定要继承 Throwable 什么的,更像是 Rust Result 那种可以写任意类型的 Error。

通过这种方式,我们能够更清晰的去感知返回的错误,也能够进一步区分可恢复异常和不可恢复 panic(更像 Rust 了),这应当是一件好事。

其实这一定程度上也是因为 Kotlin 没有引入联合类型之类的特性所导致的,其实 Kotlin 不引入联合类型的原因有不少,可以是避免增加语言理解成本,也可以是官方所说的解析联合类型是个 NP 问题等等,都是取舍。

总结与其他

  • 现代语言的可空处理似乎做的很好,但潜在的 null 转换也可能隐藏问题,难以排查问题的真实原因。
  • 异常捕获也容易导致类似的问题,过于宽松的异常捕获也是在隐藏问题,或许区分可恢复 Result不可恢复 panic 会是一种更好的语言设计。

附:如何写好一个函数?

当我们需要写一个函数的时候,我们应当按顺序思考如下的问题:

  1. 函数职责,想清楚它是用来做什么的,并确保单一职责
  2. 函数的输入输出,输入应当满足哪些条件,输出会产生怎样的结果
  3. 函数副作用,函数的执行是否需要特定外部环境,是否改变外部环境

在问过自己上面的几个问题之后,我会有强烈的写注释的冲动,我很激动的希望别人知道我设计这个函数的一切。

此时我已经自然而然的知道自己应该开始写单测了,单测也是写好函数重要的一点,他让别人更为明确的知道你对这三个问题的思考(即通过真实的代码 Sample)。因此从某种程度上来说,单测事实上也属于更为具体的一种注释,在 Rust 语言中的 Documentation tests 就是这样的设计,我们可以直接在 code doc 中写 unit test。