Java 中 Null 的设计并不是一个错误

文章目录

我使用 Java 开发项目很多年了,了解如何用它来开发大型的项目。在工业界,我看到大家做了很多努力来规避 NullPointerException(NPE),对其胆战心惊。NPE 的发明人 Tony Hoare 在 2009年承认:「null 引用的设计是一个十亿美元的错误」。

我把 null 引用称为自己的十亿美元错误。它的发明是在1965 年,那时我用一个面向对象语言( ALGOL W )设计了第一个全面的引用类型系统。我的目的是确保所有引用的使用都是绝对安全的,编译器会自动进行检查。但是我未能抵御住诱惑,加入了null引用,仅仅是因为实现起来非常容易。它导致了数不清的错误、漏洞和系统崩溃,可能在之后 40 年中造成了十亿美元的损失。近年来,大家开始使用各种程序分析程序,比如微软的 PREfix 和 PREfast 来检查引用,如果存在为非 null 的风险时就提出警告。更新的程序设计语言比如 Spec# 已经引入了非 null 引用的声明。这正是我在1965年拒绝的解决方案。” —— 《Null References: The Billion Dollar Mistake》托尼·霍尔(Tony Hoare),图灵奖得主。

在1996年,Java 1.0 出现的时候,这种对 NPE 的排斥还部署很明显。下面让我们看一个比较常见的例子:Java API 中获取文件列表 File.list() 这个方法,该方法可以如下使用,列出一个文件夹下的所有文件名称:

1
2
3
for (String name : new File("directory").list()) {
System.out.println(name);
}

上面这段代码,这有在文件夹 “directory” 存在的时候才能正常运行,否则会抛出一个 NPE,因为 list() 不能接受一个 NPE。相信我们是不会写出这样的代码的,首先 list() 方法已经清楚明白地表面了,如果 “directory” 为空,则会抛出 NPE(通过 throw 关键字声明),其次,现在的一些 IDE 其实也会提示我们,这段代码这有使用会出现 NPE 问题。

但是,为啥我们在使用 Java 编程时i,还经常犯 NPE 的错误呢,这里就不展开了,有很多资料能给出原因。事实上,在绝大多数情况下,我们的 API 在被调用时,是不希望返回 null 的。当然,在极端情况下,比如某些属性缺失,我们可能会返回一个「空对象」(比如空的集合,未定义的领域模型等),而不是直接返回 null 或者抛出异常,这样做能给我们减少些许罪恶感…这也就是 File.list 的「现代」版本:Files.newDirectoryStream 的设计哲学:没有 null了。

注:Files.newDirectoryStream 的方法签名如下:

1
2
3
4
5
public static DirectoryStream<Path> newDirectoryStream(Path dir,
DirectoryStream.Filter<? super Path> filter)
throws IOException{
return provider(dir).newDirectoryStream(dir, filter);
}

因此,当我们看到 null 在某些情况(比如性能优化,没有初始化的引用等)下作为方法的返回结果时,会让正常的代码流变得糟糕,因为没有合适的方法去处理。其实,很少需要去处理 null,但是为了不让程序出错,又不得不去写一些又臭又长的代码去 处理 null

1
2
3
4
5
6
String[] list = new File("directory").list();
if (list != null) {
for (String name : list) {
System.out.println(name);
}
}

这种繁琐且不优雅的代码,我们当然是拒绝的~除非,你的客户告诉你线上出故障了,这个时候你只能屈服了。

null 的恐惧,会出现一些极端的编码实践。比如,有一些 Java 的编码规范就完全禁止 null 的使用,迫使研发使用一些非常规的手段去规避这个问题。不知道你有没有看过一个代码库,里面的每一类领域模型,都需要去集成一个 null 的接口,而且需要去手动编写所谓的「空对象」实例。如果你没有见过,那好,我相信你肯定见过类似 Optional<T> 这种包装器类型了吧,而这仅仅是为了避免 null 的使用,就污染了一片 Java 代码。

有一些和集合相关的 API,已经禁止 null 这种元素了,使用这类 API 少了不少风险。有很多 Java 团队的核心成员认为,在 Java 的 Collection SDK 中支持 null 是一种错误的做法,真是一个悲伤的故事~

而事实上,null 这个概念不是一个错误,出错的是 Java 的类型系统——后者将 null 视为所有类型的成员。比如,“abc” 是一个有效的 String 类型,但是 null 不是一个有效的 String 类型。对于前者而言,你能使用所有 String 下的方法,而对于后者,任何调用都会在运行时出错。这真的是所谓的类型安全么?并不是。如果说,运行时下,某几个操作在特定的一些值上会出错(比如除数为0),我们可以视为是正常的,那么如果有有那么一个值,它会导致所有的操作,我们是不是首先应该考虑,这个值它是否应该属于这个类型呢?在 Java 语言中,所有的 NPE 错误都表明,Java 的类型系统是存在缺陷的。

更像「类型安全」的一些语言,比如说 Kotlin,通过将 null 和类型系统的结合来修复上面提到的缺陷。检查机制或增加告警,也能有一些作用,但还不够。很显然,在类型安全的系统里,只会允许合理的值存在,比如 String 类型,那么 String 类型的值是可以支持所有 String 的操作的。在 Kotlin 里,把 null 放到一个 String 类型的变量时,不仅是警告,还会直接报错,这就类似于把 42 这种数字赋值给 String 类型变量一样。

在类型系统里,合理地使用 null 可以为我们的 API 设计工作引入一些变化。没有任何理由去恐惧 null,返回为 null 的方法(本应返回 String 类型)和返回非 null 类型的 String 应该视为一体,就如返回 String 类型的方法和返回为 Integer 类型的方法一样。这些方法的差异只是返回值不同,我们只要有安全的编码应对这些返回值即可。

类型安全的 null 当然是更好的选择,因为更高效,更简洁,避免各种形式化地处理 “空返回结果”。让我们看一下 Kotlin 的标准方法库,或许能获取一些灵感。例如,String.toIntOrNull() 这个方法可以将一个字符串解析成一个整型,或者返回一个 null,使用起来很方便。我们可以写一个命令行的应用,这个应用接受一个整型参数,当参数异常时,会给出合理的反馈。

1
2
3
4
5
fun main(args: Array<String>) {
val id = args.getOrNull(0)?.toIntOrNull()
?: error("id expected")
// ...
}

大胆地在你的 API 中使用 null 吧,在 Kotlin 里,null 会是你的朋友。我们没有任何理由去害怕 null,也没必要使用空对象、包装类、抛异常等方式去消除 null。在 API 中合理地使用 null,可以使得你的代码的易读性、健壮性更上层楼,也不用受一些开发模式的限制。