Contents

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参数之类的先决条件的好方法,但这种方法有两个主要问题

  1. 断言通常在 JVM 中被禁用。
  2. 错误断言会导致无法恢复的未经检查的错误。

**因此,不建议程序员使用断言来检查条件。**在以下部分中,我们将讨论处理验证的其他方法。

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列表,或者要使用使用ofNullablenullOptional包装在一起的方法进行包装,该怎么办?

我们的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 应用程序中自己编写的代码的公共部分,例如 gettersetter 和*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);
    }
}

如果paramnull,此方法将引发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函数。