11.1 如何为缺失的值建模

假设你需要处理下面这样的嵌套对象,这是一个拥有汽车及汽车保险的客户。

代码清单 11-1 Person/Car/Insurance的数据模型

  1. public class Person {
  2. private Car car;
  3. public Car getCar() { return car; }
  4. }
  5. public class Car {
  6. private Insurance insurance;
  7. public Insurance getInsurance() { return insurance; }
  8. }
  9. public class Insurance {
  10. private String name;
  11. public String getName() { return name; }
  12. }

那么,下面这段代码存在怎样的问题呢?

  1. public String getCarInsuranceName(Person person) {
  2. return person.getCar().getInsurance().getName();
  3. }

这段代码看起来相当正常,但是现实生活中很多人没有汽车。所以调用getCar方法的结果会怎样呢?在实践中,一种比较常见的做法是返回一个null引用,表示该值的缺失,即用户没有汽车。而接下来,对getInsurance的调用会返回null引用的insurance,这会导致运行时出现一个NullPointerException,终止程序的运行。但这还不是全部。如果返回的person值为null会怎样?如果getInsurance的返回值也是null,结果又会怎样?

11.1.1 采用防御式检查减少NullPointerException

怎样做才能避免这种不期而至的NullPointerException呢?通常,你可以在需要的地方添加null的检查(过于激进的防御式检查甚至会在不太需要的地方添加检测代码),并且添加的方式往往各有不同。下面这个例子是我们试图在方法中避免NullPointerException的第一次尝试。

代码清单 11-2 null-安全的第一种尝试:深层质疑

  1. public String getCarInsuranceName(Person person) {
  2. if (person != null) { (以下5行)每个null检查都会增加调用链上剩余代码的嵌套层数
  3. Car car = person.getCar();
  4. if (car != null) {
  5. Insurance insurance = car.getInsurance();
  6. if (insurance != null) {
  7. return insurance.getName();
  8. }
  9. }
  10. }
  11. return "Unknown";
  12. }

这个方法每次引用一个变量都会做一次null检查,如果引用链上的任何一个遍历的解变量值为null,它就返回一个值为“Unknown”的字符串。唯一的例外是保险公司的名字,你不需要对它进行检查,原因很简单,因为任何一家公司必定有个名字。注意到了吗,由于你掌握业务领域的知识,因此避免了最后这个检查,但这并不会直接反映在你建模数据的Java类之中。

我们将代码清单11-2标记为“深层质疑”,原因是它不断重复着一种模式:每次你不确定一个变量是否为null时,都需要添加一个进一步嵌套的if块,这也增加了代码缩进的层数。很明显,这种方式不具备扩展性,同时还牺牲了代码的可读性。面对这种窘境,你也许愿意尝试另一种方案。下面的代码清单中试图通过一种不同的方式避免这种问题。

代码清单 11-3 null-安全的第二种尝试:过多的退出语句

  1. public String getCarInsuranceName(Person person) {
  2. if (person == null) { (以下9行)每个null检查都会添加新的退出点
  3. return "Unknown";
  4. }
  5. Car car = person.getCar();
  6. if (car == null) {
  7. return "Unknown";
  8. }
  9. Insurance insurance = car.getInsurance();
  10. if (insurance == null) {
  11. return "Unknown";
  12. }
  13. return insurance.getName();
  14. }

在第二种尝试中,你试图避免深层递归的if语句块,采用了一种不同的策略:每次遭遇null变量,都返回一个字符串常量“Unknown”。然而,这种方案远非理想,现在这个方法有了四个截然不同的退出点,使得代码的维护异常艰难。更糟的是,发生null时返回的默认值,即字符串“Unknown”在三个不同的地方重复出现——出现拼写错误的概率不小!当然,你可能会说,我们可以用把它们抽取到一个常量中的方式避免这种问题。

进一步而言,这种流程是极易出错的。如果你忘记检查那个可能为null的属性会怎样?通过这一章的学习,你会了解使用null来表示变量值的缺失是大错特错的。你需要更优雅的方式来对缺失的变量值建模。

11.1.2 null带来的种种问题

让我们一起回顾一下到目前为止进行的讨论,在Java程序开发中使用null会带来理论和实际操作上的种种问题。

  • 它是错误之源。

NullPointerException是目前Java程序开发中最典型的异常。

  • 它会使你的代码膨胀。

它让你的代码充斥着深度嵌套的null检查,代码的可读性糟糕透顶。

  • 它自身是毫无意义的。

null自身没有任何的语义,尤其是,它代表的是在静态类型语言中以一种错误的方式对缺失变量值的建模。

  • 它破坏了Java的哲学。

Java一直试图避免让程序员意识到指针的存在,唯一的例外是:null指针。

  • 它在Java的类型系统上开了个口子。

null并不属于任何类型,这意味着它可以被赋值给任意引用类型的变量。这会导致问题,原因是当这个变量被传递到系统中的另一个部分后,你将无法获知这个null变量最初的赋值到底是什么类型。

为了解业界针对这个问题给出的解决方案,我们一起简单看看其他语言提供了哪些功能。

11.1.3 其他语言中null的替代品

近年来出现的语言,比如Groovy,通过引入安全导航操作符(safe navigation operator,标记为?)可以安全访问可能为null的变量。为了理解它是如何工作的,让我们看看下面这段Groovy代码,它的功能是获取某个用户替他的汽车保险的保险公司的名称:

  1. def carInsuranceName = person?.car?.insurance?.name

这段代码的表述相当清晰。person对象可能没有car对象,你试图通过赋一个nullPerson对象的car引用,对这种可能性建模。类似地,car也可能没有insurance。Groovy的安全导航操作符能够避免在访问这些可能为null引用的变量时抛出NullPointerException,在调用链中的变量遭遇null时将null引用沿着调用链传递下去,返回一个null

关于Java 7的讨论中曾经建议过一个类似的功能,不过后来又被舍弃了。不知道为什么,我们在Java中似乎并不特别期待出现一种安全导航操作符。几乎所有的Java程序员碰到NullPointerException时的第一冲动就是添加一个if语句,在调用方法使用该变量之前检查它的值是否为null,快速地搞定问题。如果你按照这种方式解决问题,丝毫不考虑你的算法或者数据模型在这种状况下是否应该返回一个null,那么其实并没有真正解决这个问题,只是暂时地掩盖了它,使得下次该问题的调查和修复更加困难,而你很可能就是下个星期或下个月要面对这个问题的人。刚才的那种方式实际上是掩耳盗铃,只是在清扫地毯下的灰尘。而Groovy的null安全解引用操作符也只是一个更强大的扫把,让我们可以毫无顾忌地犯错。你不会忘记做这样的检查,因为类型系统会强制你进行这样的操作。

另一些函数式语言,比如Haskell和Scala,试图从另一个角度处理这个问题。Haskell中包含了一个Maybe类型,它本质上是对Optional值的封装。Maybe类型的变量可以是指定类型的值,也可以什么都不是。但是它并没有null引用的概念。Scala有类似的数据结构,名字叫Option[T],它既可以包含类型为T的变量,也可以不包含该变量,第20章会详细讨论这种类型。要使用这种类型,你必须显式地调用Option类型的available操作,检查该变量是否有值,而这其实也是一种变相的“null检查”。有了这些机制之后,你再也不用担心忘记检查变量是否为空了——因为类型系统默认会强制进行检查。

好了,似乎有些跑题了,刚才这些听起来都十分抽象。你可能会疑惑:“那么Java 8提供了什么呢?”嗯,实际上Java 8从“Optional值”的想法中汲取了灵感,引入了一个名为java.util.Optional的新的类。本章会展示使用这种方式对可能缺失的值建模,而不是直接将null赋值给变量所带来的好处。我们还会阐释从nullOptional的迁移,你需要反思的是:如何在你的域模型中使用Optional值。最后,我们会介绍新的Optional类提供的功能,并附几个实际的例子,展示如何有效地使用这些特性。最终,你将学会如何设计更好的API——用户只需要阅读方法签名就能知道它是否接受一个Optional的值。