处理null:可选和可空类型

182 阅读5分钟

长期以来,Java因其NullPointerException 而臭名昭著。导致NPE的原因是调用一个方法或访问一个未被初始化的对象的属性。

var value = foo.getBar().getBaz().toLowerCase();

运行这个片段可能会出现如下的结果。

Exception in thread "main" java.lang.NullPointerException
  at ch.frankel.blog.NpeSample.main(NpeSample.java:10)

在这一点上,你不知道在调用链中哪个部分是nullfoo,还是由getBar()getBaz() 返回的值?

在最新版本的JVM中,语言设计者改善了这种情况。在JVM 14上,你可以用-XX:+ShowCodeDetailsInExceptionMessages 标志激活 "有用的 "NPEs。运行相同的代码段可以看到哪一部分是null

Exception in thread "main" java.lang.NullPointerException:
  Cannot invoke "String.toLowerCase()" because the return value of
"ch.frankel.blog.Bar.getBaz()" is null
  at  ch.frankel.blog.NpeSample.main(NpeSample.java:10)

在JVM 15上,它成为默认行为:你不需要一个特定的标志。

处理NullPointerException

在上面的片段中,开发者假设每个部分都已被初始化。显示null 部分有助于调试和驳斥错误的假设。然而,它并没有解决根本原因:我们需要以某种方式处理null 的值。

为此,我们需要求助于防御性编程。

String value = null;
if (foo != null) {
    var bar = foo.getBar();
    if (bar != null) {
        baz = bar.getBaz()
        if (baz != null) {
            value = baz.toLowerCase();
        }
    }
}

它解决了问题,但远不是最好的开发者体验--至少可以说是。

  1. 开发人员需要谨慎对待他们的编码实践
  2. 这种模式使代码更难阅读。

Option封装类型

在JVM上,Scala的Option ,这是我所知道的第一个理智的null 处理方法的尝试,即使这个概念被烘托在函数式编程的基础上。Option 背后的概念确实很简单:它是对一个有可能是null 的值的包装。

你可以在包装器内的对象上调用依赖类型的方法,而包装器将作为一个过滤器。因为Option 有它的方法,所以我们需要一个对被包装的类型起作用的传递函数:这个函数在Scala中被称为map() (也有一些其他语言)。它在代码中翻译为。

def map[B](f: A => B): Option[B] = if (isEmpty) None else Some(f(this.get))

如果包装器是空的,包含一个null 的值,则返回一个空的包装器;如果不是,则在底层值上调用传递的函数,并返回一个包装结果的包装器。

从Java 8开始,JDK提供了一个名为Optional 的包装器类型。有了它,我们可以将上面的空值检查代码改写为。

var option = Optional.ofNullable(foo)
    .map(Foo::getBar)
    .map(Bar::getBaz)
    .map(String::toLowerCase);

如果调用链中的任何一个值是nulloption 是空的。否则,它就包含了计算的值。在任何情况下,NPE都不复存在。

可归零类型

不管是哪种语言,Option类型的主要问题是其鸡生蛋蛋生鸡的特性。要使用一个Option,你首先要确定它不是null 。考虑一下下面这个方法。

void print(Optional<String> optional) {
    optional.ifPresent(str -> System.out.println(str));
}

如果我们执行这段代码会发生什么?

Optional<String> optional = null;
print(optional);                       (1)
1哎呀,又回到了我们熟悉的NPE

在这一点上,迷恋Option类型的开发者会告诉你,这不应该发生,你不应该写这样的代码,等等。这可能是准确的,但不幸的是,它并没有解决这个问题。为了100%避免NPE,我们需要回到防御性编程。

void print(Optional<String> optional) {
    if (optional != null) {
        optional.ifPresent(str -> System.out.println(str));
    }
}

Kotlin选择了另一条路线,即使用可归零类型和其对应的非归零类型。在Kotlin中,每个类型T ,有两种味道,一个尾部? ,暗示它可以null

val nullable: String?          (1)
val nonNullable: String        (2)
1nullable 可以是null
2nonNullable 不能

Kotlin编译器知道这一点,并阻止你在一个可能是null 的引用上直接调用一个函数。

val nullable: String? = "FooBar"
nullable.toLowerCase()

上面的片段在编译时抛出一个异常,因为编译器不能断言nullable 不是null

Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?

空安全操作符, ?. ,与map 的作用非常相似:如果对象是null ,则停止并保留null ;如果不是,则继续进行函数调用。让我们把代码迁移到Kotlin中,用一个null safe调用代替Optional

val value = foo?.bar?.baz?.lowercase()

选项还是可空类型?

如果你使用的语言的编译器不执行null safety,你就没有选择。这个问题只在那些执行安全的语言范围内提出,例如Kotlin。Kotlin的标准库不提供Option类型。然而,Arrow库提供。另外,你仍然可以使用Java的Optional

但是问题仍然存在:在有选择的情况下,你应该使用一个可选类型还是一个可空类型?第一个选择是比较啰嗦的。

val optional: Foo? = Optional.ofNullable(foo)   (1)
                             .map(Foo::bar)
                             .map(Bar::baz)
                             .map(String::lowercase)
                             .orElse(null)

val option = Some(foo).map(Foo::bar)            (2)
                      .map(Bar::baz)
                      .map(String::lowercase)
                      .orNull()
1Java API会返回一个平台类型;你需要设置正确的类型,这个类型是可忽略的
2Arrow正确地推断出了可归零的Foo? 类型

除了推断出正确的类型,Arrow的Option 提供。

  • 上面看到的map() 函数
  • 其他传统上与单体相关的标准函数,例如flatMap()fold()
  • 额外的函数

Simplified Arrow Option class diagram

例如,fold() 允许提供两个lambdas,一个在OptionSome 时运行,另一个在None

val option = Some(foo).map(Foo::bar)
                      .map(Bar::baz)
                      .map(String::lowercase)
                      .fold(
                        { println("Nothing to print") },
                        { println("Result is $it") }
                      )

结论

如果说null 是一个百万美元的错误,现代工程实践和语言可以应对它。在Kotlin中发现的编译器强化的空值安全是一个很好的开始。然而,为了充分利用函数式编程的力量,我们需要一个符合FP规范的Option的实现。在这种情况下,问题是要确保传递的Option对象永远不会是null

Kotlin的编译器可以做到这一点,而Arrow库提供了一个Option的实现,可以满足FP程序员的需要。

更进一步。

关注@nicolas_frankel