空指针(Null Pointer Exception,NPE)是Java中最常见不过的异常了。其原因虽然显而易见,但是开发人员往往会忽略,或未能及时采取措施。本文将和您详细讨论空指针问题的根源,以及对应的解决方法。
空引用破坏了Java类型安全性
Java通过提供编译类型的安全性(Compile Type Safety),来保证开发人员不会错配不同的变量类型。在下面的示例中,我们试图将整形(Integer)值分配给某个字符串(String)变量,而Java会及时提醒您。
Java虽然会在编译过程中,去验证变量和赋值的类型,但是由于空值(NULL)代表了所有未初始化的对象,因此空值可以被分配为任何类型(如下图所示),且Java不会报错。
例如,Java允许如下赋值情况的出现:
这些对象在未被初始化的情况下,就指向了空引用,往往会产生Java类型的安全性漏洞。如下代码段所示,就Java而言,Null和真实对象可能并没有什么区别,但是它会导致一些不可实现的操作:
同时,由于Null属于String类型,因此在编译如下代码段时,Java甚至都不会有任何警告。
但是,我们一旦运行该程序代码,就会出现失败,并且会被提示如下的空指针异常:
空指针异常的定义
空指针异常属于运行时的异常。当Java尝试去调用真实对象上的任何方法时,如果在运行时中,该对象调用的是空引用(Null Reference),那么就会抛出异常。您可以通过链接--https://dzone.com/articles/java-exceptions-1,找到有关异常、及其根源的更多详细信息。
由于种种原因,开发人员时常会忘记初始化对象和验证对象。这往往是导致空指针异常的根源。下面让我们根据上述例子,讨论如何修复NPE。
解释空指针异常
下面是一个带有Address字段的User对象。它们都可能为空。
使用Simple != Null Check避免空指针异常
下面是通过简单的检查(并非Null Check),来防止该问题的发生:
作为改进方案,我们可以使用Optional,并通过map函数,编写出如下类似于前例的等效语句:
与简单的Null Check相比,Optional能够再次确保我们在ifPresent lambda中使用的数据不为空。这里的再次是指:如果User或Address的确为空的话,而且ifPresent被忽略了的话,即使我们忘记了使用Optional的相关功能,它也会以突出显示.get()的方式,并提醒我们为设计提供Null Check。
其实,早在2014年,Optional就作为可选特性,在Java 1.8中被发布了。不过由于如下原因,导致其至今未能被广泛地使用:
由于Java本身非常冗长,因此Optional也跟着变得冗长起来,因此容易影响到代码的整体质量。
对于各种map/flatmap/ifpresent逻辑,开发人员更倾向使用简单明了的Null Check。
Optional本身可能会导致开发人员创建更多NPE,例如使用到Optional.of(nullable)。
因此,鉴于上述原因,一些开发团队会更喜欢使用Null Check,并且会用一堆逻辑性的测试覆盖率,来避免潜在的NPE。
Null Check和Optional真的能够解决问题吗?
虽然上面讨论到的Null Check与Optional的使用目的都是针对空值数据进行验证。其中,Optional还可以提醒开发者返回值为空。但是,它们无法解决隐藏在开发者头脑中的关键问题——在编译步骤中出现的疏忽与遗漏。
@NotNull@Nullable注释处理器有助于识别潜在的空值
因此,我们需要一个解决方案,可以在编译步骤中读取代码,并通知开发人员他们可能疏漏的潜在NPE场景。对此,我们可以使用具有丰富功能的Java注释处理器(Annotation Processors)。您可以通过链接--https://www.javacodegeeks.com/2015/09/java-annotation-processors.html,了解如何使用注释处理器,来检查可变性的示例。
目前,业界有几种与NPE问题相关的注释处理器。它们并非遵循完全相同的方法。下面我们将重点讨论@NotNull和@Nullable两种注释提供工具。
Lombok的@NotNull注释
Lombok(译者注:一种Java库,提供了一组非常实用的注释)的@NotNull注释可用于生成那些仅在运行时(Runtime)阻断执行的非Null Check。下面的代码段展示了该注释、及其等效语句。
检查器框架的@NonNull和@Nullable注释处理器
检查器框架(Checker Framework)提供了@NonNull和@Nullable注释,以及可以识别潜在Null Check的编译处理器的步骤。该框架可以通过强制开发人员指定的Nullability,来发现潜在的空值。因此,您的代码必须明确声明可返回的结果为Nullable或NotNullable。下面让我们来看一个可能返回Null,而非String的简单方法:
现在,让我们使用检查器框架,来检验是否可以完成编译。
如您所见,它报出了错误,并且返回了一个未使用@Nullable注释标记的疑似空字符串。那么,让我们将其标记为@Nullable试试:
如果我们再次运行编译检查,则会得到如下错误信息:
可见,检查器框架在第19行发现了一个潜在问题,即:我们在Nullable字符串上调用了.length()。下面,让我们使用Null Check和Optional的ifPresent来予以修复:
在编译之后,我们将得到如下成功的构建信息:
检查器框架的限制
至此,检查器框架向我们展示了良好的检查结果,并且突出了潜在的NPE。不过,其代价是我们必须通过@Nullable方法,标记所有可能为空的方法。为了突破该强制性的限制,我们可以创建一个带有两个字段的简单类,并且将其中一个字段标记为@NonNull:
下面是经由检查器框架的检查结果:
显然,检查器框架会强制要求我们构造一个初始化id值的构造函数,例如:
可见,检查器框架不仅能够识别潜在的NPE,而且还会迫使我们遵循特定的设计要求。这在某种程度上牺牲了框架开发的灵活性。如果您对该问题有兴趣的话,可以通过如下命令克隆我为您准备的示例:
git clone https://github.com/isicju/checker_framework_example1.
若要运行检查器框架的话,请使用如下命令:
Mvn clean compile1.
检查器框架的替代方案:Intellij Idea @NotNull注释
当然,检查器框架并非唯一的解决方案,Intellij Idea也提供了自己的注释--@NotNull和@Nullable,并嵌入到了IDE的插件中。目前,我尚未找到在maven编译步骤中添加它的方法。如果您对此有经验的话,欢迎您追加评论。
小结
通过上述讨论,我们可以看到,避免空指针异常的方法可以总结为:
首选使用Optional,而不是传递Null
使用检查器框架
当然,检查器框架会给您的开发带来一些限制。在实践中,如果您必须避免使用Lombok、甚至是Builder Pattern(建造者模式)的话,我建议您基于生产环境的稳定性考虑,去使用检查器框架。
【原标题】Null Pointer Exception in Java: Causes and Ways to Avoid It(作者:Dmitry Egorov)