Java中避免NULL检查
1. 概述
通常,null变量、引用和集合在 Java 代码中难以处理。它们不仅难以识别,而且处理起来也很复杂。
事实上,处理null的任何失误在编译时都无法识别,并在运行时导致NullPointerException。
在本教程中,我们将了解在 Java 中检查null的必要性以及帮助我们避免代码中的null检查的各种替代方法。
2. 什么是NullPointerException?
根据NullPointerException 的Javadoc ,当应用程序在需要对象的情况下尝试使用null时会抛出它,例如:
- 调用null对象的实例方法
- 访问或修改null对象的字段
- 将null的长度视为数组
- 像数组一样访问或修改null的槽
- 将null视为Throwable值
让我们快速查看一些导致此异常的 Java 代码示例:
public void doSomething() {
String result = doSomethingElse();
if (result.equalsIgnoreCase("Success"))
// success
}
}
private String doSomethingElse() {
return null;
}
在这里,我们尝试为空引用调用方法调用。这将导致NullPointerException。
另一个常见的例子是如果我们尝试访问一个null数组:
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。
因此,访问null对象的任何字段、方法或索引都会导致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。但是,这会导致大量冗余的null检查,并使我们的代码可读性降低。
在接下来的几节中,我们将介绍 Java 中避免这种冗余的一些替代方案。
3. 通过API Contract处理null
如上一节所述,访问null对象的方法或变量会导致NullPointerException。我们还讨论了在访问对象之前对其进行null检查可以消除NullPointerException的可能性。
但是,通常有一些 API 可以处理null值:
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。它宁愿抛出一个Exception。
因此,对于访问上述 API 的客户端代码,不需要进行null检查。
但是,此类 API 需要在其合同中明确说明。API 发布此类合同的常见位置是 Javadoc。
但这并没有明确表明 API 合同,因此依赖于客户端代码开发人员来确保其合规性。
在下一节中,我们将了解一些 IDE 和其他开发工具如何帮助开发人员解决此问题。
4. 自动化 API 合约
4.1. 使用静态代码分析
静态代码分析 工具有助于极大地提高代码质量。并且一些这样的工具还允许开发人员维护空合约。一个例子是FindBugs 。
*FindBugs通过@Nullable和@NonNull注解帮助管理null合约。**我们可以在任何方法、字段、局部变量或参数上使用这些注解。这使得带注解的类型是否可以为null*对客户端代码来说是明确的。
让我们看一个例子:
public void accept(@NonNull Object param) {
System.out.println(param.toString());
}
在这里,@NonNull清楚地表明参数不能为null。如果客户端代码调用此方法而不检查参数是否为null,则FindBugs 将在编译时生成警告。
4.2. 使用 IDE 支持
开发人员通常依靠 IDE 来编写 Java 代码。诸如智能代码完成和有用的警告等功能,例如当一个变量可能没有被分配时,肯定会有很大帮助。
一些 IDE 还允许开发人员管理 API 合约,从而消除对静态代码分析工具的需求。IntelliJ IDEA 提供了@NonNull和@Nullable*注解。*
要在 IntelliJ 中添加对这些注解的支持,我们需要添加以下 Maven 依赖项:
<dependency>
<groupId>org.jetbrains</groupId>
<artifactId>annotations</artifactId>
<version>16.0.2</version>
</dependency>
现在IntelliJ 将在缺少null检查时生成警告,如上一个示例所示。
IntelliJ 还提供了一个*Contract *注解来处理复杂的 API 合约。
5. 断言
到目前为止,我们只讨论了从客户端代码中删除对null值检查的需要。但这在实际应用中很少适用。
现在让我们**假设我们正在使用一个不能接受null参数或可以返回必须由客户端处理的null响应的 API。**这表明我们需要检查参数或响应是否为null值。
在这里,我们可以使用Java 断言 来代替传统的空检查条件语句:
public void accept(Object param){
assert param != null;
doSomething(param);
}
在第 2 行,我们检查一个null参数。如果启用了断言,这将导致AssertionError。
虽然它是断言诸如非null参数之类的先决条件的好方法,但这种方法有两个主要问题:
- 断言通常在 JVM 中被禁用。
- 错误断言会导致无法恢复的未经检查的错误。
**因此,不建议程序员使用断言来检查条件。**在以下部分中,我们将讨论处理空验证的其他方法。
6. 通过编码实践避免null检查
6.1. 前提条件
编写早期失败的代码通常是一个好习惯。因此,如果 API 接受多个不允许为null的参数,最好检查每个非null参数作为 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;
}
现在让我们在客户端代码中调用这些 API:
int sum = primitiveSum(null, 2);
这将导致编译时错误,因为null不是int的有效值。
当将 API 与包装类一起使用时,我们会得到一个NullPointerException:
assertThrows(NullPointerException.class, () -> wrapperSum(null, 2));
正如我们在另一篇教程Java Primitives Versus Objects 中介绍的那样,在包装器上使用原语还有其他因素。
6.3. 空集合
有时,我们需要返回一个集合作为方法的响应。对于这样的方法,我们应该总是尝试返回一个空集合而不是null:
public List<String> names() {
if (userExists()) {
return Stream.of(readName()).collect(Collectors.toList());
} else {
return Collections.emptyList();
}
}
这样,我们就避免了客户端在调用此方法时执行空检查的需要。
7. 使用Objects
Java 7 引入了新的ObjectsAPI。这个 API 有几个static实用方法,可以去掉很多冗余代码。
让我们看一个这样的方法,requireNonNull() :
public void accept(Object param) {
Objects.requireNonNull(param);
// doSomething()
}
现在让我们测试*accept()*方法:
assertThrows(NullPointerException.class, () -> accept(null));
因此,如果null作为参数传递,accept()会抛出NullPointerException。
此类还具有isNull()和nonNull()方法,可用作检查对象是否为null的谓词。
8. 使用Optional
8.1. 使用orElseThrow
Java 8 在该语言中引入了一个新的*Optional * API。与null相比,这为处理可选值提供了更好的合同。
让我们看看Optional如何消除对null检查的需求:
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检查的需要。可以使用Optional API的声明式样式以不同方式处理空响应:
assertThrows(Exception.class, () -> process(false).orElseThrow(() -> new Exception()));
此外,它还为 API 开发人员提供了更好的合同,以向客户端表明 API 可以返回空响应。
尽管我们消除了对这个 API 的调用者进行null检查的需要,但我们使用它来返回一个空响应。
为了避免这种情况,Optional提供了一个ofNullable方法,该方法返回一个具有指定值的Optional,如果值为null,则返回empty:**
public Optional<Object> process(boolean processed) {
String response = doSomething(processed);
return Optional.ofNullable(response);
}
8.2. 对集合使用Optional
在处理空集合时,Optional派上用场:
public String findFirst() {
return getList().stream()
.findFirst()
.orElse(DEFAULT_VALUE);
}
该函数应该返回列表的第一项。Stream API 的findFirst函数将在没有数据时返回一个空的Optional。在这里,我们使用orElse来提供默认值。
这允许我们处理空列表或在使用Stream库的filter方法后没有可提供的项目的列表。
或者,我们也可以允许客户端通过从该方法返回Optional来决定如何处理null:
public Optional<String> findOptionalFirst() {
return getList().stream()
.findFirst();
}
因此,如果getList的结果为空,该方法将返回一个空的Optional给客户端。
将Optional与集合一起使用允许我们设计一定返回非空值的 API,从而避免在客户端上进行显式null检查。
需要注意的是,此实现依赖于getList不返回null。然而,正如我们在上一节中所讨论的,返回空列表通常比返回null更好。
8.3. 组合选项
当我们开始让我们的函数返回Optional时,我们需要一种方法将它们的结果组合成一个值。
让我们以前面的getList示例为例。如果要返回一个Optional列表,或者要使用使用ofNullable将null与Optional包装在一起的方法进行包装,该怎么办?
我们的findFirst方法想要返回Optional列表的Optional第一个元素:
public Optional<String> optionalListFirst() {
return getOptionalList()
.flatMap(list -> list.stream().findFirst());
}
通过对从getOptional返回的Optional使用flatMap函数,我们可以解包返回Optional的内部表达式的结果。如果没有flatMap,结果将是Optional<Optional<String»。flatMap操作仅在Optional不为空时执行。
9. 库
9.1. 使用Lombok
Lombok 是一个很棒的库,可以减少我们项目中的样板代码量。它带有一组注解来代替我们经常在 Java 应用程序中自己编写的代码的公共部分,例如 getter、setter 和*toString()*等等。
它的另一个注解是*@NonNull*。因此,如果一个项目已经使用 Lombok 来消除样板代码,@NonNull可以取代空检查的需要。**
在我们继续一些示例之前,让我们为 Lombok添加一个Maven依赖项:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
</dependency>
现在我们可以在需要null检查的地方使用*@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
通常,string验证除了空值之外还包括对null的检查。
因此,这将是一个常见的验证语句:
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.12.0</version>
</dependency>
现在让我们用StringUtils重构上面的代码:
public void accept(String param) {
if (StringUtils.isNotEmpty(param))
System.out.println(param);
}
因此,我们用static实用方法isNotEmpty()替换了我们的null或空检查。此 API 提供了其他强大的实用方法 来处理常见的string函数。