长期以来,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)
在这一点上,你不知道在调用链中哪个部分是null 。foo,还是由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();
}
}
}
它解决了问题,但远不是最好的开发者体验--至少可以说是。
- 开发人员需要谨慎对待他们的编码实践
- 这种模式使代码更难阅读。
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);
如果调用链中的任何一个值是null ,option 是空的。否则,它就包含了计算的值。在任何情况下,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)
| 1 | nullable 可以是null |
| 2 | nonNullable 不能 |
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()
| 1 | Java API会返回一个平台类型;你需要设置正确的类型,这个类型是可忽略的 |
| 2 | Arrow正确地推断出了可归零的Foo? 类型 |
除了推断出正确的类型,Arrow的Option 提供。
- 上面看到的
map()函数 - 其他传统上与单体相关的标准函数,例如,
flatMap()和fold() - 额外的函数
例如,fold() 允许提供两个lambdas,一个在Option 是Some 时运行,另一个在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程序员的需要。