# Kotlin 空安全是否能变得更好？


# Kotlin 空安全是否能变得更好？

> 之后文章会全部转到飞书文档发布，此网页可能存在更新不及时的问题，飞书文档首页 [zsqw123 Homepage](https://eqyrx3fg3l.feishu.cn/docx/TkWidN8RtoLK4ix1NRRcWpdmnQf)

## 概述

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

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

  


## 思考

### 滥用的可空传递

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

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

```kotlin
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 来获取现在的时间：

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

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

```java
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：

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

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

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

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

```rust
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+ 展望](https://eqyrx3fg3l.feishu.cn/docx/I9BUdZ39zoyYZ5xXJaWcb8Shn4f)

Kotlin 2.0 开始，官方也提出了一种新的想法（目前还没实现）：error object

```kotlin
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.  函数职责，想清楚它是用来做什么的，并确保单一职责
1.  函数的输入输出，输入应当满足哪些条件，输出会产生怎样的结果
1.  函数副作用，函数的执行是否需要特定外部环境，是否改变外部环境

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

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

-   飞书文档原文：https://eqyrx3fg3l.feishu.cn/docx/EIkMdD95Kob2nqxemOocQBiknme
-   个人飞书主页：https://eqyrx3fg3l.feishu.cn/docx/TkWidN8RtoLK4ix1NRRcWpdmnQf
