在Java中使用JSpecify实现空值的安全检查

Java开发人员的一个常见挫折来源是NullPointerException。无论是在大型代码库中工作还是进行API调用,Java开发人员总是不得不问自己,“如果返回null怎么办?”尽管Java是一种静态类型的语言,但它对空值的处理总是有歧义。

最近,Java社区已经采取措施来解决这个问题。这一领域的一个有希望的发展是JSpecify。

在本文中,我们讨论JSpecify工具,它可以帮助开发人员在很大程度上消除与null相关的错误。它确实使Java代码库更加健壮和有弹性,在生产过程中减少了意外。虽然工具支持仍在不断成熟,但JSpecify背后的势头表明,它将很快成为Java中表示空值的默认方法。

什么是JSpecify?
Jspecify提供了一组标准的注释来显式地声明Java代码的空值期望。Jspecify是与工具无关的,这意味着它不依赖于任何特定的框架或IDE。它适用于整个Java生态系统。

它允许开发人员注释方法、字段或参数(包括泛型参数)是否可以保存空值。这有助于IDE、静态分析工具和编译器在开发过程中捕获潜在的空值相关问题。

虽然过去有空检查的注释,但问题是不同的项目和工具经常使用不同的注释,其含义略有不同。然而,JSpecify试图将这些努力统一在一个精确、一致和可互操作的标准下。

为什么要关注安全性?
从历史上看,Java依赖于隐式空性,即基于默认行为或上下文假设变量是可空的或不可空的,而无需开发人员每次都显式指定。

如果这些工具知道空期望,它们可以在我们违反它们时警告我们。这有助于我们在开发过程的早期发现bug。

此外,当我们显式地指定空性时,我们的API的消费者立即了解方法是否可以返回null或参数是否可以接受null。他们的IDE将空性信息显示为提示或警告。

如何使用JSpecify
JSpecify允许我们指定各种注释来表示空值。

要开始使用Jspecify,我们需要添加以下依赖项:

<dependency>
    <groupId>org.jspecify</groupId>
    <artifactId>jspecify</artifactId>
    <version>1.0.0</version>
</dependency>

JSpecify提供各种注释来表示空值。

@Nullable注释意味着注释的元素可以合法地为null。@Nonnull意味着带注释的元素永远不能为null。

我们也可以在包或类级别指定空值。例如,@NullMarked注释应用于包、类或模块,以指示默认情况下所有未注释的类型都被视为非空。

类似地,我们有@NullUnmarked annotation,它取消了@NullMarked的效果,并允许未注释的类型具有未指定的空值。

这些注释提供了灵活性。例如,我们可以通过使用@NullMarked使所有内容默认为非null,并且只显式地注释可以使用@Nullable接受null的地方。这样,我们就可以减少所需的注释数量。

一旦我们添加了依赖项,我们就可以开始在代码中使用注释,如下所示:

@Nullable
private String findNicknameOrNull(String userId) {
    if ("user123".equals(userId)) {
        return
"CoolUser";
    } else {
        return null;
    }
}
@Test
void givenUnknownUserId_whenFindNicknameOrNull_thenReturnsNull() {
    String nickname = findNicknameOrNull(
"unknownUser");
    assertNull(nickname);
}
@Test
void givenNullableMethodResult_whenWrappedInOptional_thenHandledSafely() {
    String nickname = findNicknameOrNull(
"unknownUser");
    Optional<String> safeNickname = Optional.ofNullable(nickname);
    assertTrue(safeNickname.isEmpty());
}

在上面的代码中,方法findNicknameOrange(StringuserId)使用@Nullable进行注释,这向开发人员发出了它可能返回null的信号。这有助于在编译时捕获潜在的空值相关问题。然而,由于JSpecify在运行时没有任何作用,这里的测试验证了预期的运行时行为,即当找不到用户时,该方法确实返回null。

在第二个测试中,我们使用Optional来安全地封装null值,从而消除了NullPointerException的风险。

与其他空值检查方法的比较
在JSpecify之前,我们有其他方法来检查空性。在本节中,我们将探索编写空安全代码的各种方法。

使用Optional
Optional是一个容器对象,它包装了包含非空值或表示不存在的返回值。它提供了一种类型安全和显式的方式来处理可能缺少的值,从而降低了NullPointerException的风险。Optional强制方法的调用方有意识地处理这两种情况。

例如,让我们看看下面的代码:

private Optional<String> findNickname(String userId) {
    if ("user123".equals(userId)) {
        return Optional.of(
"CoolUser");
    } else {
        return Optional.empty();
    }
}

该方法从不返回null。它始终返回可选的。可选.of(value)当有值时。可选的.empty()当没有值时。

现在,让我们来看看如何从调用方法中处理Optional:

@Test
void givenKnownUserId_whenFindNickname_thenReturnsOptionalWithValue() {
    Optional<String> nickname = findNickname("user123");
    assertTrue(nickname.isPresent());
    assertEquals(
"CoolUser", nickname.get());
}
@Test
void givenUnknownUserId_whenFindNickname_thenReturnsEmptyOptional() {
    Optional<String> nickname = findNickname(
"unknownUser");
    assertTrue(nickname.isEmpty());
}

我们可以从上面的代码中看到,调用者要么接收带值的Optional,要么接收空Optional,并且必须显式处理存在或不存在,这降低了获得NullPointerException的风险。

但是,Optional主要用于方法返回类型,而不是字段或方法参数。对字段或方法参数使用Optional会引入额外的复杂性。

此外,由于对象创建和包装,Optional引入了一个小的性能成本。

使用Objects.requireNonparallel()
另一种处理可能的空值相关问题的常见做法是使用运行时断言,使用Objects.requireNonvalues。如果提供的参数为null,此方法将立即引发NullPointerException。

例如,让我们看看这段代码:

@Test
void givenNonNullArgument_whenValidate_thenDoesNotThrowException() {
    String result = processNickname("CoolUser");
    assertEquals(
"Processed: CoolUser", result);
}
@Test
void givenNullArgument_whenValidate_thenThrowsNullPointerException() {
    assertThrows(NullPointerException.class, () -> processNickname(null));
}
private String processNickname(String nickname) {
    Objects.requireNonNull(nickname,
"Nickname must not be null");
    return
"Processed: " + nickname;
}

正如我们从上面的代码中看到的,如果参数为null,则立即抛出NullPointerException。它使bug在测试过程中更加明显,因为它们在早期失败,而不是静静地流入更深层次的逻辑。

通过在方法开始时验证输入,我们可以在开发或测试过程中捕获违规行为;这将防止生产过程中的错误。

然而,requireNonparameter()的一个关键限制是它只在运行时检测问题。它们不像JSpecify那样提供编译时或IDE提示。

JSpecify采用策略
一次性注释整个代码库是不切实际的。幸运的是,JSpecify允许逐步采用。我们可以在不破坏代码的情况下逐步实现空安全注释。

一个典型的采用策略是开始注释小的,自包含的包或类,然后使用@NullMarked强制非空默认值,减少注释噪音。然后,我们可以在必要的地方显式地添加@Nullable注释。

我们还可以运行静态分析工具来捕获任何不匹配并改进我们的注释,然后逐渐将覆盖范围扩展到代码库的更广泛部分。

工具和生态系统支持
许多流行的工具和IDE现在都在不同程度上支持JSpecify注释。

例如,JSpecify Framework是一个流行的静态分析工具,它有自己的空安全注释;但是,它在最近的版本中已经开始支持JSpecify的核心注释。

类似地,NullAway是另一个专注于检测空性问题的静态分析工具。它现在支持JSpecify注释。

从IDE的前端来看,IntelliJ IDEA长期以来一直支持空值注释,包括它自己的。IntelliJ现在提供了JSpecify注释的基本识别,突出显示不匹配和潜在的空性问题。