1.概述
通常,在Java代码中处理null变量、引用和集合很棘手。它们不仅难以识别,而且处理起来也很复杂。事实上,在编译时无法识别处理null的任何错误,会导致运行时NullPointerException。 在本教程中,我们将了解在Java中检查null的必要性以及帮助我们避免在代码中进行空检查的各种替代方法。
2.什么是NullPointerException?
根据 Javadoc for NullPointerException,当应用程序在需要对象的情况下尝试使用null时抛出它,例如:
- 调用null对象的实例方法
- 访问或修改空对象的字段
- 取null的长度,就好像它是一个数组一样
- 访问或修改null的插槽,就像它是一个数组一样
- 抛出null就好像它是一个Throwable值
让我们快速查看导致此异常的Java代码的几个示例:
public void doSomething() {
String result = doSomethingElse();
if (result.equalsIgnoreCase("Success"))
// success
}
}
private String doSomethingElse() {
return null;
}
在这里,我们尝试调用null引用的方法调用。这将导致NullPointerException。 另一个常见示例是,如果我们尝试访问空数组:
public static void main(String[] args) {
findMax(null);
}
private static void findMax(int[] arr) {
int max = arr[0];
//check other elements in loop
}
这会在第6行导致 NullPointerException。 因此,访问空 对象的任何字段,方法或索引会导致 NullPointerException,如上面的示例所示。避免 NullPointerException
的 常见方法是检查 null
:
public void doSomething() {
String result = doSomethingElse();
if (result != null && result.equalsIgnoreCase("Success")) {
// success
}
else
// failure
}
private String doSomethingElse() {
return null;
}
在现实世界中,程序员发现很难识别哪些对象可以为 null
。积极安全的策略可能是为每个对象检查 null
。但是,这会导致大量冗余空值检查,并使我们的代码可读性降低。在接下来的几节中,我们将介绍Java中的一些备选方案,以避免这种冗余。
3.通过API约定处理null
如上一节所述,访问null对象的方法或变量会导致NullPointerException。 我们还讨论了在访问对象之前对对象进行空 检查可以消除NullPointerException的可能性。 但是,通常有API可以处理空值。例如:
public void print(Object param) {
System.out.println("Printing " + param);
}
public Object process() throws Exception {
Object result = doSomething();
if (result == null) {
throw new Exception("Processing fail. Got a null response");
} else {
return result;
}
}
在 print()
方法调用将只打印 null
,但不会抛出异常。同样, process()
永远不会在其响应中返回 null
。它反而抛出异常。 因此对于访问上述API的客户端代码,不需要进行空检查。但是此类API必须在约定中明确说明。API发布此类约定的常见位置是JavaDoc。但是,这并未明确指出API约定,因此依赖于客户端代码开发人员来确保其合规性。 在下一节中,我们将看到一些IDE和其他开发工具如何帮助开发人员解决这个问题。
4.自动化API约定
4.1.使用静态代码分析
静态代码分析工具有助于提高代码质量。一些这样的工具也允许开发人员维护null约定(Null Contracts)。一个例子是 FindBugs
。 FindBugs
通过 @Nullable
和 @NonNull
注解帮助管理null约定。我们可以在任何方法,字段,局部变量或参数上使用这些注释。这使得对客户端代码明确指出注释类型是否为 null
。我们来看一个例子:
public void accept(@Nonnull Object param) {
System.out.println(param.toString());
}
在这里, @NonNull
清楚地表明参数不能为 null
。如果客户端代码在不检查 null
参数的情况下调用此方法 ,则 FindBugs
将在编译时生成警告。
4.2.使用静态代码分析
开发人员通常依靠IDE来编写Java代码。使用代码自动补全和有用警告等功能,例如可能没有声明变量,在很大程度上对编码有帮助。 一些IDE还允许开发人员管理API约定(API Contracts),从而消除对静态代码分析工具的需求。IntelliJ IDEA提供 @NonNull
和 @Nullable
注解。要在IntelliJ中添加对这些注释的支持,我们必须添加以下Maven依赖项:
<dependency>
<groupId>org.jetbrains</groupId>
<artifactId>annotations</artifactId>
<version>16.0.2</version>
</dependency>
现在,如果没有对 Null
进行检查,IntelliJ将生成警告,就像我们在上一个示例中一样。 IntelliJ还提供了用于处理复杂API约束的Contract注释。
5.断言
到目前为止,我们只讨论过从客户端代码中去除空检查的必要性。但是,这很少适用于实际应用。现在,假设我们正在使用一个不能接受空参数的API,或者可以返回必须由客户端处理的空响应。这表明我们需要检查参数或空值的响应。 这里,我们可以使用Java Assertions代替传统的 null
检查条件语句:
public void accept(Object param){
assert param != null;
doSomething(param);
}
在第2行中,我们检查null参数。如果启用了断言,则会导致 AssertionError
。 尽管这是断言非空参数等前置条件的好方法,但这种方法主要存在两个问题:
- 1.通常在JVM中禁用断言
- 2.一个虚假的声明将导致在未经检查的错误无法恢复
因此,建议程序员不要使用断言来检查条件。在以下部分中,我们将讨论处理null检查的其他方法
6.通过编码实践避免NULL检查
6.1.前提条件
编写早期失败的代码通常是一种很好的做法。因此,如果一个API不允许接受有多个参数为空,更好地方法是预先检查API中的每一个非空参数。
例如,让我们看看两个方法:一个早期失败,另一个不失败:
public void goodAccept(String one, String two, String three) {
if (one == null || two == null || three == null) {
throw new IllegalArgumentException();
}
process(one);
process(two);
process(three);
}
public void badAccept(String one, String two, String three) {
if (one == null) {
throw new IllegalArgumentException();
} else {
process(one);
}
if (two == null) {
throw new IllegalArgumentException();
} else {
process(two);
}
if (three == null) {
throw new IllegalArgumentException();
} else {
process(three);
}
}
显然,我们应该更喜欢 goodAccept()
而不是 badAccept()
。 作为替代方案,我们也可以使用Guava的前置条件来验证API参数。
6.2.使用原语而不是包装类
由于 null
对于像int这样的原语来说不是一个可接受的值,我们应该尽可能优先于它们的包装对象,如 Integer
。 考虑一个对两个整数求和的方法的两个实现:
public static int primitiveSum(int a, int b) {
return a + b;
}
public static Integer wrapperSum(Integer a, Integer b) {
return a + b;
}
6.3.空集合
有时,我们需要将一个集合作为方法的响应返回。对于这样的方法,我们应该总是尝试返回一个空集合而不是 null
public List<String> names() {
if (userExists()) {
return Stream.of(readName()).collect(Collectors.toList());
} else {
return Collections.emptyList();
}
}
因此,我们在调用此方法时避免了客户端执行空检查的需要。
7.使用 Objects
Java 7引入了新的Objects API。此API有几个静态 实用程序方法,可以消除大量冗余代码。让我们看看一个这样的方法, requireNonNull()
:
public void accept(Object param) {
Objects.requireNonNull(param);
// doSomething()
}
现在,让我们测试 accept
方法:
assertThrows(NullPointerException.class, () -> accept(null));
因此,如果将null 作为参数传递,则 accept()
会抛出 NullPointerException
。 此类还具有 isNull()
和 nonNull()
方法,可用作谓词来检查对象是否为null。
8.使用Optional
Java8在该语言中引入了一个新的 OptionalAPI
。与null相比,这为处理可选值提供了更好的约定。让我们看看 Optional
如何消除对空检查的需求:
public Optional<Object> process(boolean processed) {
String response = doSomething(processed);
if (response == null) {
return Optional.empty();
}
return Optional.of(response);
}
private String doSomething(boolean processed) {
if (processed) {
return "passed";
} else {
return null;
}
}
通过返回一个 Optional
,如上所示,该 process()
方法使得明确告诉调用者,响应可能是Null,并且必须在编译时处理。 这显然消除了客户端代码中对空检查的需求。可以使用 OptionalAPI
的声明性样式以不同方式处理空响应:
assertThrows(Exception.class, () -> process(false).orElseThrow(() -> new Exception()));
此外,它还为API开发人员提供了一个更好的约定,以向客户端表明API可以返回空响应。 虽然我们不需要对此API的调用者进行空检查,但我们使用它来返回空响应。为避免这种情况, Optional
提供了一个 ofNullable
方法,该方法返回具有指定值的 Optional
,如果值为 null
,则返回 empty
:
public Optional<Object> process(boolean processed) {
String response = doSomething(processed);
return Optional.ofNullable(response);
}
9.库
9.1.使用Lombok
Lombok是一个很棒的库,可以减少项目中样板代码的数量。它附带了一组注释,取代了我们经常在Java应用程序中编写的代码的常见部分,例如getter,setter和toString(),仅举几例。
另一个注释是 @NonNull
。 因此,如果项目已经使用Lombok来消除样板代码,则 @NonNull
可以代替作为空检查。
在继续查看一些示例之前,添加一个Maven依赖项引入Lombok:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.6</version>
</dependency>
现在,我们可以在需要进行空检查的地方 使用 @NonNull
:
public void accept(@NonNull Object param){
System.out.println(param);
}
因此,我们只是注解了需要进行null检查的对象,并且Lombok生成了已编译的类:
public void accept(@NonNull Object param) {
if (param == null) {
throw new NullPointerException("param");
} else {
System.out.println(param);
}
}
如果 param
为null,则此方法抛出 NullPointerException
。该方法必须在其约定中明确说明,并且客户端代码必须处理异常。
9.2.使用StringUtils
一般来说,字符串验证包括除空值检查空值。因此,常见的验证声明是:
public void accept(String param){
if (null != param && !param.isEmpty())
System.out.println(param);
}
如果我们必须处理很多 String
类型,这很快就会变得多余。这就是 StringUtils
派上用场的地方。在我们看到这个动作之前,让我们为commons-lang3添加一个Maven依赖项:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.8.1</version>
</dependency>
现在让我们用 StringUtils
重构上面的代码 :
public void accept(String param) {
if (StringUtils.isNotEmpty(param))
System.out.println(param);
}
因此,我们使用静态实用程序方法 isNotEmpty()
替换了 null
或空检查。此API提供了其它强大而实用方法来处理常见的String函数。
10.结论
在本文中,我们研究了发生 NullPointerException
的各种原因以及难以识别的原因。然后,我们使用了各种方法来避免代码中的冗余,以及对使用参数,返回类型和其他变量进行空检查。 所有示例都可以在GitHub上找到。