0%

null漫谈

null起源

如果你使用 Java 或类似的语言编程,那么应该经常遇到名为“空指针”的异常,其原因通常在于被访问属性或方法的对象是 null 值,这往往是因为该对象作为参数传入时就是 null

而作为参数,null 值本身有两个意思:

  • 参数不存在
  • 参数没有值

很显然 null 值具有二义性,而在计算机科学里这是能避免就尽量避免的。所以,null 值的二义性是一个 Java 中的设计缺陷。不过,也不光是在 Java 语言中,null 的二义性在编程语言里是广泛存在的一个问题。这个问题被称为“Null 引用问题”。

Null 引用是计算机科学中一个历史悠久又臭名昭著的问题。在 1964 年,由快排算法的创造者东尼·霍尔发明。他自称这是个“十亿美元的错误”。

有些计算机语言,运算可能会返回空值(null),这是一个设计错误,因为会中断类型系统,你将无法依靠编译器来检查代码的有效性。任何可能为 null 的值都是等待爆炸的炸弹。我们必须依靠运行时手动检查来确保所处理的值不为 null。即使是静态类型语言,null 也剥夺了类型系统的许多好处。
——《可变状态是新的 Goto》

“判空地狱”与自动检查

Null 引用问题会在意想不到的地方引起空指针异常,这可能使得程序意外中断。

一个常见的不触发空指针异常的编程模式相信大家耳熟能详,就是在使用某个对象前判断其是否为空。但是,在极限情况下,我们可能会发现一段核心代码中,“判空”语句可能占了很高的比例,代码中充斥着 if(xxx != null) { doSth(); } 语句显然是不恰当的。

而类似这样检查代码其实都是“模板化”的,因此,很容易想到用一套检查框架来处理它。最为常见的即是使用注解标识,常见的注解有 @NotNull@NonNull。前者是 JSR 303(Bean 校验框架),在运行时检查;后者是 JSR 305(缺陷检查框架),进行编译器检查,目前仅 IDEA 支持。

使用这些检查框架,可以在很大程度上规避空指针异常,但并不能杜绝。

空对象模式

另一种规避空指针的方法是,空对象(NullObject)模式。

空对象模式的基本思想是,所有传递的对象都不会是 null 值,如果确实是“空”值,则传递一个“空对象”,该对象具有普通对象的所有属性以及方法,通常两者分别是默认值和默认实现。

这样,就不会导致空指针了——But,传空对象不传 null 的约束不是强制的,So……

链式编程中的空指针定位

另一方面,空指针在链式编程模式下会相当难于定位。

如果你经常使用链式编程模式,那么,你应该遇到过这样的场景,一条很长的链式调用语句,抛出空指针异常。这时,要定位具体是哪个调用引起的空指针,简直就是一个恶梦。

原因在于,JVM 在这种场景下抛出的异常信息是不充足的。通常,异常信息仅仅告知某行代码有问题,但由于是链式调用,所以该行代码包括了多个调用,而我们不能明确知道是哪个调用结果为 null。

因此,如果要想获知哪个调用结果为 null,通常需要使用调用器 debug,在运行时逐一查看调用链——痛苦一言难尽……

好在,既然这是一个众所周知的痛点,这个世界必然会有人出手解决它。

SAP 早在 2006 年就为其商业 JVM 实现了增强型的 NullPointerException。而在 2019 年 2 月,它被提议作为 OpenJDK 社区的一个增强,很快,便成为了一个 JEP。继而,该功能在 2019 年 10 月完成并在 JDK 14 版本推出。

本质上,JEP 358 旨在通过描述某个变量是 “null” 来提高 JVM 生成的 NullPointerException 的可读性。JEP 358 通过在方法、文件名和行号旁边描述为 null 的变量,带来了一个详细的 NullPointerException 消息。它通过分析程序的字节码指令来工作。因此,它能够精确地确定哪个变量或表达式是 null。最重要的是,JDK 14 中默认关闭详细的异常消息。其命令行启用选项为:

1
-XX:+ShowCodeDetailsInExceptionMessages

首先,只有当 JVM 本身抛出一个 NullPointerException 时,才会进行详细的消息计算,如果我们在 Java 代码中显式抛出异常,则不会执行计算。原因是因为:在这些情况下,很可能已经在异常构造函数中传递了一条有意义的消息。
其次,JEP 358 懒汉式地计算消息,这意味着只有当我们打印异常消息时才调用增强的 NullPointerException,而不是当异常发生时就调用。因此,对于通常的 JVM 流程不应该有任何性能影响,在那里我们可以捕获并重新抛出异常,因为我们并不会只想打印异常消息。
最后,详细的异常消息可能包含源代码中的局部变量名。因此,我们可以认为这是一个潜在的安全风险。但是,只有在运行使用激活的 -g 标记编译的代码时,才会发生这种情况,该标记会生成调试信息并将其添加到类文件中。

消除二义性

从上文可以看到,无论是主动判空,还是注解检查,或是空对象模式,亦或是增强型 NPE,手段无非是规避 null 值。但是,都没有消除其二义性问题。

而 Java 8 新增的 Optional 类较为优雅地消除了二义性问题。

Optional 类对象是一个容器,可能包含一个非空值也可能不包含值。它提供了相应方法进行值的存在性判断,以便决定下一步处理。不仅如此,Optional 类还提供了丰富的方法,对值进行加工处理,使用这些方法则不需要关注值是否为 null,只需要关注处理逻辑即可,这提供了确定性。

我们可以选择在参数不存在时抛出想要的异常,或在参数为“空”时指定一个默认值……

关于 Optional 更多请参考我的另一篇相关专题《Java知识点:Optional类的使用》

Optional 除了可以有效规避空指针问题,另一个优点是:它天生“优雅地”支持链式调用。但反过来说,使用 Optional 进行复杂的链式调用时,代码可读性并不是很理想。

小结

为了规避空指针,我们应该优先考虑使用 Optional 类,毕竟它是 Java 原生的,拿来即可用。