JSpecify 空值用户指南

本文翻译自 Nullness User Guide,版权归原作者所有。

在 Java 代码中,表达式是否可能求值为 null 通常只在自然语言中记录,甚至根本没有记录。JSpecify 的空值注解让程序员能够以一致且定义良好的方式表达 Java 代码的空值性。

JSpecify 定义了描述 Java 类型是否包含 null 值的注解。这些注解对以下方面很有用(例如):

  • 阅读代码的程序员,
  • 帮助开发人员避免 NullPointerException 的工具,
  • 执行运行时检查和测试生成的工具,以及
  • 文档系统。

Java 变量是引用

在 Java 中,所有非基本类型变量要么是 null,要么是对对象的引用。我们经常认为像 String x 这样的声明意味着 x 是一个 String,但它实际上意味着 x 要么null 要么是对实际 String 对象的引用。JSpecify 为您提供了一种明确表达您真正含义的方法,即您是真的这样认为,还是您真的认为 x 绝对是对 String 对象的引用而不是 null

类型和空值性

JSpecify 为您提供了规则,用于确定每种类型用法具有四种空值性中的哪一种:

  • 它可以包含 null(它是"可空的")。
  • 它不会包含 null(它是"非空的")。
  • 仅对类型变量:如果替换它的类型参数包含 null,则它包含 null(它具有"参数化空值性")。
  • 我们不知道它是否可以包含 null(它具有"未指定的空值性")。这相当于没有 JSpecify 注解的世界状态。

对于给定的变量 x,如果 x 可以是 null,那么 x.getClass() 是不安全的,因为它可能产生 NullPointerException。如果 x 不能是 nullx.getClass() 永远不会产生 NullPointerException。如果我们不知道 x 是否可以是 null,我们就不知道 x.getClass() 是否安全(至少就 JSpecify 而言)。

“不能是 null” 的概念实际上应该附带一个脚注,即"如果所讨论的代码都不涉及未指定的空值性"。例如,如果您有一些代码将具有未指定空值性的类型传递给仅接受 @NonNull 参数的方法,那么工具可能允许它将可能为 null 的值传递给期望"不能是 null" 参数的方法。

有四个 JSpecify 注解一起用于指示所有类型用法的空值性:

  • 两个类型使用注解,指示特定类型用法是否包含 null@Nullable@NonNull
  • 一个作用域注解,让您避免大多数时候都要输入 @NonNull@NullMarked
  • 另一个作用域注解,撤消 @NullMarked 的效果,以便您可以增量采用注解:@NullUnmarked

@Nullable 和 @NonNull

当类型用 @Nullable 注解时,表示该类型的值可以是 null@Nullable String x 表示 x 可能是 null。使用这些值的代码必须能够处理 null 情况,并且可以将 null 分配给此类变量或将 null 传递给这些参数。

当类型用 @NonNull 注解时,表示该类型的值不应该是 null@NonNull String x 表示 x 永远不应该是 null。使用这些值的代码可以假定它们不是 null,将 null 分配给这些值或将 null 传递给这些参数是一个坏主意。(有关如何避免大多数时候都要拼写 @NonNull,请参见下文。)

static @Nullable String emptyToNull(@NonNull String x) {
  return x.isEmpty() ? null : x;
}

static @NonNull String nullToEmpty(@Nullable String x) {
  return x == null ? "" : x;
}

在此示例中,emptyToNull 的参数用 @NonNull 注解,因此它不能是 nullemptyToNull(null) 不是有效的方法调用。emptyToNull 方法的主体依赖于该假设并立即调用 x.isEmpty(),如果 x 实际上是 null,则会抛出 NullPointerException。相反,emptyToNull 可能返回 null,因此其返回类型用 @Nullable 注解。

另一方面,nullToEmpty 承诺处理 null 参数,因此其参数用 @Nullable 注解以指示 nullToEmpty(null) 是有效的方法调用。它的主体考虑了参数为 null 的情况,并且不会抛出 NullPointerException。它不能返回 null,因此其返回类型用 @NonNull 注解。

void doSomething() {
  // OK: nullToEmpty 接受 null 但不会返回它
  int length1 = nullToEmpty(null).length();

  // 不 OK: emptyToNull 不接受 null;而且,它可能返回 null!
  int length2 = emptyToNull(null).length();
}

工具可以使用 @Nullable@NonNull 注解来警告用户有关不安全的调用。

就 JSpecify 而言,@NonNull String@Nullable String不同的类型@NonNull String 类型的变量可以引用任何 String 对象。@Nullable String 类型的变量也可以,但它也可以是 null。这意味着 @NonNull String@Nullable String子类型,就像 IntegerNumber 的子类型一样。看待这一点的一种方式是,子类型缩小了可能值的范围。Number 变量可以从 Integer 分配,但也可以从 Long 分配。同时,Integer 变量不能从 Number 分配(因为该 Number 可能是 Long 或其他子类型)。同样,@Nullable String 可以从 @NonNull String 分配,但 @NonNull String 不能从 @Nullable String 分配(因为那可能是 null)。

class Example {
  void useNullable(@Nullable String x) { ... }
  void useNonNull(@NonNull String x) { ... }

  void example(@Nullable String nullable, @NonNull String nonNull) {
    useNullable(nonNull); // JSpecify 允许这样做
    useNonNull(nullable); // JSpecify 不允许这样做
  }
}

未注解的类型呢?

String 这样没有用 @Nullable@NonNull 注解的类型意味着它一直以来的含义:它的值可能打算包含 null,也可能不包含,这取决于您能找到的任何文档(但请参见下文寻求帮助!)。JSpecify 称之为"未指定的空值性"。

class Unannotated {
  void whoKnows(String x) { ... }

  void example(@Nullable String nullable) {
    whoKnows(nullable); // ¯\_(ツ)_/¯
  }
}

@NullMarked

如果必须用 @Nullable@NonNull 注解 Java 代码中的每一个类型用法以避免未指定的空值性(尤其是一旦添加泛型!),那将很烦人。

因此 JSpecify 为您提供了 @NullMarked 注解。当您将 @NullMarked 应用于模块、包、类或方法时,意味着该作用域中未注解的类型被视为好像它们用 @NonNull 注解一样。(下面我们将看到,对于局部变量类型变量有一些例外。)在 @NullMarked 覆盖的代码中,String x 的含义与 @NonNull String x 相同。

如果应用于模块,则其作用域是模块中的所有代码。如果应用于包,则其作用域是包中的所有代码。(请注意,包不是分层的;将 @NullMarked 应用于包 com.foo 不会使包 com.foo.bar 成为 @NullMarked。)如果应用于类、接口或方法,则其作用域是该类、接口或方法中的所有代码。

@NullMarked
class Strings {
  static @Nullable String emptyToNull(String x) {
    return x.isEmpty() ? null : x;
  }

  static String nullToEmpty(@Nullable String x) {
    return x == null ? "" : x;
  }
}

这是上面的示例,其中包含方法的类用 @NullMarked 注解。类型的空值性与以前相同:emptyToNull 不接受 null 参数,但它可能返回 nullnullToEmpty 接受 null 参数,但它不会返回 null。但我们能够用更少的注解做到这一点。通常,使用 @NullMarked 将为您提供正确的空值语义和更少的注解。在 @NullMarked 代码中,您会习惯于将像 String 这样的普通、未注解的类型视为对 String 对象的真实引用,而永远不是 null

如上所述,对于局部变量类型变量,此解释有一些例外。

@NullUnmarked

如果您正在将 JSpecify 注解应用于您的代码,您可能无法一次性注解所有代码。如果您现在可以将 @NullMarked 应用于部分代码,稍后再做其余部分,那比等到有时间注解所有内容要好。但这意味着您可能必须对模块、包或类进行空标记,但某些类或方法除外。为此,请将 @NullUnmarked 应用于已经位于 @NullMarked 上下文中的包、类或方法。@NullUnmarked 只是撤消了周围 @NullMarked 的效果,因此未注解的类型具有未指定的空值性,除非它们用 @Nullable@NonNull 注解,就像根本没有封闭的 @NullMarked 一样。@NullUnmarked 作用域可能反过来包含嵌套的 @NullMarked 元素,以使该较窄作用域内的大多数未注解类型用法为非空。

局部变量

@Nullable@NonNull 不应用于局部变量——至少不应用于它们的根类型。(它们应该应用于类型参数和数组组件。)原因是可以根据分配给变量的值来推断变量是否可以是 null。例如:

@NullMarked
class MyClass {
  void myMethod(@Nullable String one, String two) {
    String anotherOne = one;
    String anotherTwo = two;
    String oneOrTwo = random() ? one : two;
    String twoOrNull = Strings.emptyToNull(two);
    ...
  }
}

分析器可以告诉所有这些变量除了 anotherTwo 都可以是 nullanotherTwo 不能是 null,因为 two 不能是 null:它不是 @Nullable 并且在 @NullMarked 的作用域内。anotherOne 可以是 null,因为它是从 @Nullable 参数分配的。oneOrTwo 可以是 null,因为它可能从 @Nullable 参数分配。并且 twoOrNull 可以是 null,因为它的值来自返回 @Nullable String 的方法。

泛型

当您使用泛型类型时,有关 @Nullable@NonNull@NullMarked 的规则与我们所看到的一样。例如,在 @NullMarked 上下文中,List<@Nullable String> 表示对 List 的引用(不是 null),其中每个元素都是对 String 对象的引用或 null;但 List<String> 表示一个列表(不是 null),其中每个元素都是对 String 对象的引用,不能null

声明泛型类型

当您声明泛型类型时,事情会变得更复杂一些。考虑这个:

@NullMarked
public class NumberList<E extends Number> implements List<E> { ... }

extends Number 为类型变量 E 定义了一个边界。这意味着您可以编写 NumberList<Integer>,因为 Integer 可以分配给 Number,但您不能编写 NumberList<String>,因为 String 不能分配给 Number。这是标准的 Java 行为。

但现在让我们考虑一下该边界与 @NullMarked 的关系。我们可以编写 NumberList<@Nullable Integer> 吗?

@NullMarked 内,请记住,未注解的类型与用 @NonNull 注解的类型相同。由于 E 的边界与 @NonNull Number 相同,而不是 @Nullable Number,这意味着 E 的类型参数不能是包含 null 的类型。那么 @Nullable Integer 不能是类型参数,因为它可以包含 null。(换句话说:@Nullable Integer 不是 Number 的子类型。)

@NullMarked 内部,如果您希望能够为类型参数替换可空类型参数,则必须在类型变量上显式提供 @Nullable 边界:

@NullMarked
public class NumberList<E extends @Nullable Number> implements List<E> { ... }

现在编写 NumberList<@Nullable Integer> 合法的,因为 @Nullable Integer 可分配给边界 @Nullable Number。编写 NumberList<Integer> 是合法的,因为普通的 Integer 可分配给 @Nullable Number。在 @NullMarked 内部,普通的 Integer@NonNull Integer 含义相同:对实际 Integer 值的引用,永远不会是 null。这只是意味着由 E 表示的值可以在 NumberList 的其他一些参数化上是 null,但在 NumberList<Integer> 的实例中不是。

当然,这假定 List 本身以允许可空类型参数的方式编写:

@NullMarked
public interface List<E extends @Nullable Object> { ... }

如果是 interface List<E> 而不是 interface List<E extends @Nullable Object>,那么 NumberList<E extends @Nullable Number> implements List<E> 将不合法。这是因为 interface List<E>interface List<E extends Object> 的简写。在 @NullMarked 内部,普通的 Object 表示"不能是 nullObject 引用"。来自 NumberList<E extends @Nullable Number> 将与 <E extends Object> 不兼容。

所有这些的含义是,每次定义像 E 这样的类型变量时,您都需要决定它是否可以用 @Nullable 类型替换。如果可以,那么它必须具有 @Nullable 边界。通常这将是 <E extends @Nullable Object>。另一方面,如果它不能表示 @Nullable 类型,则通过在其边界中不具有 @Nullable 来表示(包括根本没有显式边界的情况)。这是另一个例子:

@NullMarked
public class ImmutableList<E> implements List<E> { ... }

在这里,因为它是 ImmutableList<E> 而不是 ImmutableList<E extends @Nullable Object>,所以编写 ImmutableList<@Nullable String> 是不合法的。您只能编写 ImmutableList<String>,这是一个非空 String 引用的列表。

在泛型类型中使用类型变量

让我们看看 List 接口中的方法可能是什么样子:

@NullMarked
public interface List<E extends @Nullable Object> {
  boolean add(E element);
  E get(int index);
  @Nullable E getFirst();
  Optional<@NonNull E> maybeFirst();
  ...
}

add 的参数类型 E 表示与 List 元素的实际类型兼容的引用。就像您不能将 Integer 添加到 List<String> 一样,您也不能将 @Nullable String 添加到 List<String>,但您可以将它添加到 List<@Nullable String>

同样,get 的返回类型 E 表示它返回具有列表元素的实际类型的引用。如果列表是 List<@Nullable String>,那么该引用是 @Nullable String。如果列表是 List<String>,那么引用是 String

另一方面,(虚构的)getFirst 方法的返回类型 @Nullable E 始终是 @Nullable。无论是在 List<@Nullable String> 还是 List<String> 上调用,它都将是 @Nullable String。这个想法是该方法返回列表的第一个元素,如果列表为空,则返回 null。同样,Map 中的真实方法 @Nullable V get(Object key)Queue 中的 @Nullable E peek() 即使在 VE 不能是 null 时也可以返回 null

这里的区别是一个重要的区别,值得重复。像 E 这样的类型变量的使用只有在表示即使 E 本身不能是 null 也可以是 null 的引用时,才应该是 @Nullable E。否则,普通的 E 表示只有当 E@Nullable 类型(如本例中的 @Nullable String)时才能是 null 的引用。(正如我们所看到的,只有当 E 的定义具有像 <E extends @Nullable Object> 这样的 @Nullable 边界时,E 才能是 @Nullable 类型。)

同样,您可以使用 @NonNull E 来指示即使 E 可空也是非空的类型。虚构的 maybeFirst() 方法返回非空的 OptionalOptional 对象只能保存非空值,因此将其定义为 class Optional<T> 是合理的;也就是说,它的类型参数不能是可空的。因此,即使对于 List<@Nullable String>maybeFirst() 也必须返回 Optional<@NonNull String>。声明它的方法是将 maybeFirst() 的返回类型声明为 Optional<@NonNull E>

我们之前看到 @NullMarked 通常意味着"引用不能是 null,除非它们被标记为 @Nullable",并且这也不适用于局部变量。在这里我们看到它也不适用于未注解的类型变量使用,因为边界为 @Nullable 的未注解类型变量用法可能会替换为 @Nullable 类型参数。

在泛型方法中使用类型变量

我们刚刚看到的泛型类型的相同考虑基本上也适用于泛型方法。这是一个例子:

@NullMarked
public class Methods {
  public static <T> @Nullable T
    firstOrNull(List<T> list) {
    return list.isEmpty() ? null : list.get(0);
  }

  public static <T> T
    firstOrNonNullDefault(List<T> list, T defaultValue) {
    return list.isEmpty() ? defaultValue : list.get(0);
  }

  public static <T extends @Nullable Object> T
    firstOrDefault(List<T> list, T defaultValue) {
    return list.isEmpty() ? defaultValue : list.get(0);
  }

  public static <T extends @Nullable Object> @Nullable T
    firstOrNullableDefault(List<T> list, @Nullable T defaultValue) {
    return list.isEmpty() ? defaultValue : list.get(0);
  }
}

firstOrNull 方法将接受 List<String> 但不接受 List<@Nullable String>。当给定类型为 List<String> 的参数时,TString,因此返回类型 @Nullable T@Nullable String。输入列表不能包含 null 元素,但返回值可以是 null

firstOrNonNullDefault 方法再次不允许 T@Nullable 类型,因此不允许使用 List<@Nullable String>。现在返回值也不是 @Nullable,这意味着它永远不会是 null

firstOrDefault 方法将接受 List<String>List<@Nullable String>。在第一种情况下,TString,因此 defaultValue 参数和返回值的类型是 String,这意味着两者都不能是 null。在第二种情况下,T@Nullable String,因此 defaultValue 和返回值的类型是 @Nullable String,这意味着两者都可以是 null

firstOrNullableDefault 方法再次接受 List<String>List<@Nullable String>,但现在 defaultValue 参数被标记为 @Nullable,因此即使在 List<String> 情况下它也可以是 null。同样,返回值是 @Nullable T,因此即使 T 不能是 null,它也可以是 null

这是另一个例子:

@NullMarked
public static <T> List<@Nullable T> nullOutMatches(List<T> list, T toRemove) {
  List<@Nullable T> copy = new ArrayList<>(list);
  for (int i = 0; i < copy.size(); i++) {
    if (copy.get(i).equals(toRemove)) {
      copy.set(i, null);
    }
  }
  return copy;
}

这需要一个 List<T>,根据定义不包含 null 元素,并生成一个 List<@Nullable T>,其中每个匹配 toRemove 的元素都用 null 替换。输出是 List<@Nullable T>,因为它可以包含 null 元素,即使 T 本身不能是 null

一些更微妙的细节

前面的部分涵盖了您需要了解的 99% 的内容,以便能够有效地使用 JSpecify 注解。在这里,我们将介绍一些您可能不需要知道的细节。

类型使用注解语法

在一些地方,像 @Nullable@NonNull 这样的类型使用注解的语法可能令人惊讶。

  • 对于像 Map.Entry 这样的嵌套静态类型,如果您想说该值可以是 null,那么语法是 Map.@Nullable Entry。您通常可以通过直接导入嵌套类型来避免处理这个问题,但在这种情况下 import java.util.Map.Entry 可能不可取,因为 Entry 是一个非常常见的类型名称。

  • 对于数组类型,如果您想说数组的元素可以是 null,那么语法是 @Nullable String[]。如果您想说数组本身可以是 null,那么语法是 String @Nullable []。如果元素和数组本身都可以是 null,语法是 @Nullable String @Nullable []

记住这一点的好方法是,@Nullable 后面的东西可以是 null。在 Map.@Nullable Entry 中可以是 null 的是 Entry,而不是 Map。在 @Nullable String[] 中可以是 null 的是 String,而在 String @Nullable [] 中可以是 null 的是 [],即数组。

通配符边界

@NullMarked 内部,通配符边界的工作方式与类型变量边界几乎完全相同。我们看到 <E extends @Nullable Number> 意味着 E 可以是 @Nullable 类型,而 <E extends Number> 意味着不能。同样,List<? extends @Nullable Number> 表示元素可以是 null 的列表,而 List<? extends Number> 意味着不能。

但是,当没有显式边界时,会有所不同。我们看到像 <E> 这样的类型变量定义意味着 <E extends Object>,这意味着它不是 @Nullable。但是 <?> 实际上意味着 <? extends B>,其中 B 是相应类型变量的边界。因此,如果我们有

interface List<E extends @Nullable Object> { ... }

那么 List<?> 的含义与 List<? extends @Nullable Object> 相同。如果我们有

class ImmutableList<E> implements List<E> { ... }

那么我们看到这意味着与

class ImmutableList<E extends Object> implements List<E>

相同,因此 ImmutableList<?> 的含义与 ImmutableList<? extends Object> 相同。在这里,@NullMarked 意味着 Object 排除 nullList<?>get(int) 方法可以返回 null,但 ImmutableList<?> 的相同方法不能。

comments powered by Disqus