使用空注释

NullPointerException 是导致 Java 程序故障的其中一个最常见的原因。 在最简单的方案中,出现类似以下代码时,编译器会直接向您发出警告:

    Object o = null;
    String s = o.toString();

因为出现分支/循环,并抛出异常,有必要退出复杂的流量分析,以便查出整个程序的某些或所有路径上是否已为取消引用的变量分配空/非空值。

由于固有的复杂性,所以可在小数据块中更好地执行流量分析。使用良好的工具性能一次可分析一个方法,而整个系统分析会超出 Eclipse Java 编译器的范围。优点是:分析速度较快,并可以增量的方式完成操作,这样在您输入内容时编译器可直接向你发出警告。缺点是:该分析无法“看到”哪些值(空或非空)正在方法之间流动(作为参数和返回值)。

过程间的空值分析

此时空注释就开始起作用了。通过将方法参数指定为 @NonNull,可以告诉编译器在此位置您不用空值。

    String capitalize(@NonNull String in) {
        return in.toUpperCase();                // no null check required
    }
    void caller(String s) {
        if (s != null)
            System.out.println(capitalize(s));  // preceding null check is required
    }

约定式设计状态中,存在两个方面:

  1. 调用者有责任从不传递空值,这一点要得到保证,具体可通过显式空检查实现。
  2. 方法 capitalize实现者享有自变量 in 不应为空的保证,因此,此处可以是不为空检查的解除引用。

对于方法返回值,该情况是相对的:

    @NonNull String getString(String maybeString) {
        if (maybeString != null)
            return maybeString;                         // the above null check is required
    else
            return "<n/a>";
    }
    void caller(String s) {
        System.out.println(getString(s).toUpperCase()); // no null check required
    }
  1. 现在实现者必须确保从不返回空。
  2. 相反,调用者现在享有解除方法结果引用(可能没有检查)的保证。

可用注释

配置 Eclipse Java 编译器以将三种不同的注释类型用于其增强的空值分析(缺省情况下为禁用状态):

在以下位置支持注释 @NonNull@Nullable

@NonNullByDefault 受以下内容支持:

注意:即使这些注释的实际限定名为可配置,但在缺省情况下,仍会(从程序包 org.eclipse.jdt.annotation)使用上面给出的限定名。使用第三方空注释时,请确保它们是使用至少一个 @Target 元注释正确定义的,(因为)否则编译器无法区分声明注释 (Java 5) 与类型注释 (Java 8)。

设置构建路径

随 Eclipse 提供了一个具有缺省空注释的 JAR,它位于 eclipse/plugins/org.eclipse.jdt.annotation_*.jar。在编译时,此 JAR 需要位于构建路径上,但在运行时这并非必需(因此您无需将此 JAR 提供给您的已编译代码的用户)。

从 Eclipse Luna 开始,此 JAR 有两个版本,一个带有在 Java 7 或更低版本 (V1.1.x) 中使用的声明注释,一个带有在 Java 8 (V2.0.x) 中使用的空类型注释

对于纯 Java 项目,还有一个针对 @NonNull@Nullable@NonNullByDefault 的未解析引用的快速修订,它会将该 JAR 的适当版本添加至构建路径:

将具有缺省空注释的库复制到构建路径

对于 OSGi 捆绑软件/插件,请将下列其中一个条目添加至 MANIFEST.MF:

在 Java 7 项目或更低版本项目中使用空注释时:
Require-Bundle: ...,
 org.eclipse.jdt.annotation;bundle-version="[1.1.0,2.0.0)";resolution:=optional
对于 Java 8 项目中的空类型注释,请使用:
Require-Bundle: ...,
 org.eclipse.jdt.annotation;bundle-version="[2.0.0,3.0.0)";resolution:=optional

另请参阅对应章节中有关兼容性的讨论。

空注释的解释

现在应该清楚,空注释已将更多信息添加到您的 Java 程序(然后编译器可使用该程序提供更好的警告)。但是,我们真正想让这些注释说明什么?从实用的角度出发,我们至少要使用空注释表达三个级别:

  1. 对读者的零星提示(普通人和编辑者)
  2. 约定式设计:某些或所有方法的 API 规范
  3. 使用扩展类型系统的完整规范

对于 (1),您可使用空注释立即启动,且无需更进一步阅读,但不要期望时常获得更多提示。其他级别会获得更多说明。

约定式设计:API 规范

看上去在“约定式设计”状态下使用 API 规范的空注释仅意味着应该全面注释所有 API 方法的特征符(诸如 int 的专用类型除外),实际每个参数和每个方法返回类型应标记为 @NonNull@Nullable。因为这将意味着插入许多空注释,所以,应该很好地了解在设计良好的代码(尤其是 API 方法)中,使用 @NonNull 的频率远远高于 @Nullable。这样,在程序包级别通过声明 @NonNull缺省值,使用 @NonNullByDefault 注释可减少注释的数量。

注意,@Nullable 与忽略空注释之间的巨大差别:此注释明确声明可以为空且必须为期望的注释。通过比较,没有任何注释仅仅意味着我们不知道其目的。这是以前的情况,其中有时两端(调用者和被调用者)会随机检查是否为空,有时两端会错误地假定另一端将执行检查。这是 NullPointerExceptions 的来源。如果没有注释,那么编译器将不会提供特定的建议;但是,如果有 @Nullable 注释,那么将标志每个没有检查的解除引用。

有了这些基本知识,我们可以直接将所有参数注释影射到先决条件中,并将返回注释解释为该方法的后置条件

子分类和覆盖

在面向对象程序设计中,概念“约定式设计”需要解决另一个维:子分类和覆盖(在续集中,术语“覆盖”将以 Java 6 中 @Override 注释的意义使用:从超类型覆盖或执行另一方法的方法)。调用方法的客户机如下所示:

    @NonNull String checkedString(@Nullable String in)

应允许假定此方法的所有实现都符合该约定。因此,当在接口 I1 中找到该方法声明时,我们必须排除任何实现 I1 的类 Cn 都提供一个不兼容的实现。具体地说,如果任何 Cn 尝试使用声明参数为 @NonNull 的实现覆盖此方法,那么它将非法。如果我们允许这一点,那么针对 I1 进行编程的客户机模块会将空作为自己变量进行传递,但该实现将假定允许方法实现中未校验的取消引用非空值,但该值在运行时会放大。因此,@Nullable 参数规范会强制所有覆盖以承认空为预期的合法值。

相反,@NonNull 返回规范会强制所有覆盖以确保将从不返回空值。

因此,编译器必须检查任何覆盖都不会添加超类型不存在的 @NonNull 参数注释(或 @Nullable 返回注释)。

有趣的是,反向重定义也是合法的:添加 @Nullable 参数注释或 @NonNull 返回注释(您可以将这些视为该方法的“改善”,它接受更多的值,并产生更特定的返回值)。

通过强制子类在任何覆盖方法中重复使用空注释,可以理解每个方法的空合同,而不用搜索继承层次结构。 但是,在继承层次结构混合了不同起源的代码的情况下,可能无法一次性对所有类添加空注释。 在这些情况下,可以告知编译器将缺少空注释的方法视为继承了被覆盖方法中的注释。 使用编译器选项继承空注释启用了这种情况。 一个方法可以覆盖具有不同空合同的两个方法。 此外,可空性缺省值可能适用于方法,这与继承的空注释相冲突。 这些情况都将标记为错误,覆盖方法必须使用显式空注释来解决该冲突。

是否将 @NonNull 参数放宽为未指定参数?

如果启用继承空注释,那么一种特殊情况从类型论的视角来说是安全的,但是仍然可能表明存在问题:假定有一个超级方法将参数声明为 @NonNull,还有一个覆盖方法未约束相应的参数(既不由显式空注释约束,也不由适当的 @NonNullByDefault 约束)。

这是安全的,这是因为客户机知道将强制使用超级声明以避免使用 null,而覆盖实现无法利用此保证,这是由于在此特定方法中缺少规范。

这还可能导致误解,因为可能预期超类型中的声明应该也适用于所有覆盖。

正是由于这些原因,编译器提供了在覆盖方法中未注释“@NonNull”参数选项:

遗留超类型

当以“遗留”(即,未注释的)类型(它可能来自第三方库,因此无法更改)的子类型写入注释的代码时,以前的考虑事项会添加差异性。如果您认真阅读最后一部分,那么可能会注意到我们无法承认“遗留”方法由带有 @NonNull 参数的方法覆盖(因为使用超类型的客户机“看不到” @NonNull 责任)。

在这种情况下,将强制您忽略空注释(存在的计划支持事后将注释添加到库中,但当这样的功能可用时,不会做任何承诺)。

取消可空性缺省值

如果“遗留”类型的子类型驻留在已为其指定 @NonNullByDefault 的程序包中,那么情况会变得很复杂。现在具有未注释的超类型的类型需要将覆盖方法中的所有参数标记为 @Nullable:即使不允许忽略的参数注释也是如此,因为这会将此参数解析为类似 @NonNull 参数(该位置禁止此参数)。这就是 Eclipse Java 编译器为什么支持取消可空性缺省值的原因:通过使用 @NonNullByDefault(false) 注释某个方法或类型,将取消此元素的适用缺省值,并再将未注释的参数解释为未指定。现在,在没有添加不想要的 @Nullable 注释的情况下,子分类又会合法:

class LegacyClass {
    String enhance (String in) { // clients are not forced to pass nonnull.
        return in.toUpperCase();
    }
}
 
@NonNullByDefault
class MyClass extends LegacyClass {
	
    // ... methods with @NonNull default ...
 
    @Override
    @NonNullByDefault(false)
    String enhance(String in) { // would not be valid if @NonNullByDefault were effective here
        return super.enhance(in);
    }
}

字段情况

空注释最适合应用于方法特征符(通常,局部变量甚至不需要这些空注释,但是还可以利用空注释将已注释的代码与“旧”代码联系起来)。 这样使用空注释时,空注释将根据有关全局数据流的实现语句来连接大量的内部过程分析。 从 Eclipse Kepler 开始,还可以将空注释应用于字段,但是情况稍有不同。

考虑一个使用 @NonNull 来标记的字段:这明显要求对于该字段的任何赋值提供一个不为空的值。 此外,编译器必须能够验证非空字段在其处于未初始化状态时(在这种情况下,它的值仍为 null)从不可访问。 如果可以验证每个构造函数都符合此规则(类似于静态字段必须具有初始化程序),那么程序将受益于安全,解除对字段的引用从来不会导致 NullPointerException

当考虑一个标记为 @Nullable 的字段时,情况更微妙。 始终应将这样一个字段视为是危险的,使用可空字段的建议方法是:始终在使用局部变量之前为其指定值。 通过使用局部变量,流分析可以准确地指出解除引用是否受到空值检查的足够保护。 遵循此一般规则时,使用可空字段不会产生任何问题。

当代码直接解除引用可空字段的值时,可能会涉及到更多事项。 问题在于,下列任何一种情况都很容易使代码在解除引用之前可以执行的任何空值检查失效:

用户很容易认识到,如果不对线程同步也进行分析(这超出了编译器的能力),那么对于可空字段的空值检查不会 100% 安全以便后续解除引用。 因此,如果可以并发访问可空字段,那么决不应该直接解除引用该字段的值,而是始终应该使用局部变量。 即使未涉及到并行性,其余问题也会对完整分析提出挑战,完整分析比编译器通常可以处理的分析更难。

流分析与语法分析

假定编译器无法完全分析建立别名的影响、副作用和并行性,Eclipse 编译器不会对字段执行任何流分析(而不是与它们的初始化有关)。 由于许多开发者将认为此局限性的限制太多 - 要求使用局部变量,而开发者感觉他们的代码实际上应该是安全的 - 因此,已经引入了一个新的选项,作为试探性的折衷办法:

可以配置编译器以执行一些语法分析。 这将检测最明显的模式,如下所示:

    @Nullable Object f;
    void printChecked() {
        if (this.f != null)
            System.out.println(this.f.toString());
    }

启用了所给定选项的情况下,编译器将不会标记以上代码。 了解不会以任何方式“智能”进行此语法分析至关重要。 如果在进行检查与解除引用之间出现任何代码,编译器将“忘记”前一次空值检查的信息,甚至不会尝试按照某一条件来了解中间代码是否也许无害。 因此,请注意:每当编译器将一个可空字段的解除引用标记为不安全时,尽管凭眼睛观察应该不会产生空值,请重新编写代码以严格遵循以上所显示的可识别模式,甚至采用更好的办法:使用局部变量来利用流分析的所有复杂性,语法分析决不会实现的复杂性。

约定式设计的好处

使用上面列出的约定式设计风格的空注释有助于用几个方法提高 Java 代码的质量:在方法之间的接口上这是明确的,即:哪些参数/返回 容忍一个空值,哪些不容忍。这会捕获设计决策,这与开发者高度相关,从另一方面说,也可由编译器检查。

另外,根据此接口规范,内部程序流分析可选择可用的信息,并提供更加准确的错误/警告。如果没有注释,那么流进或流出方法的任何值都具有未知可空性,因此空值分析仍就其使用情况保持静默。对于 API 级别的空注释,大部分值的可空性实际已知,极少 NPE 会被编译器忽视。但是,您应该知道仍存在某些漏洞,其中,未指定的值会注入该分析,从而阻止完整的声明,这与在运行时是否发生 NPE 无关。

使用扩展类型系统完成规范

已按应与未来扩展兼容的方式设计对空注释的支持。此扩展作为类型注释 (JSR 308) 包含在 Java 语言中,它是在 Java 8 中引入的。JDT 支持对空类型注释使用此新概念。

说明的编译器消息

通过说明编译器检查的规则及根据规则违例发出的消息,此处显示了基于注释的空值分析的语义详细信息。

在对应首选项页面上,编译器检查的各个规则按以下标题分组:

违反空规范

对于规范违例,我们处理空注释做出实际实现违例的声明的任何情况。典型情况会从指定值(本地、自变量和方法返回)为 @NonNull 得出结果,因此,该实现实际提供可为空的值。如果静态已知将此处的表达式估算为空值或将其声明为 @Nullable 注释,那么将其视为可为空的值。

其次,本组还包含方法覆盖的规则(如所述)。此处有个超级方法,建立了一个声明(即:空为合法自变量),而覆盖将尝试避免此声明(通过假定空不是合法自变量)。如前所述,甚至将自变量从未注释具体化到 @NonNull 是一种规范违例,因为,它会引入约定,该约定应绑定客户机(不传递空值)但是使用超类型的客户机甚至看不到此约定,所以,甚至不知道他的期望。

此处提供了视为规范违例的情况的完全列表。了解绝不应该忽视此组中的错非常重要,否则整个空值分析将基于错误的假设执行操作。具体地说,当编译器看到带有 @NonNull 注释的值时,会理所当然地认为在运行时不会产生空值。这是规范违例的规则,可确保此违例的合理性。因此,强烈建议保留将此类型的问题配置为错误

空注释与空推断之间存在冲突

另外,此组规则会监视对空规范的忠诚度。但是,在此我们将处理不将其声明@Nullable 的值(其本身也不是空值),但内部程序流分析推断空还可能在某些执行路径上发生的值除外。

这种情况源于一个针对未注释的局部变量的事实,编译器将推断空是否可以使用其流分析。假定此分析是正确的,如果发现问题,那么此问题与空规范的直接违例具有相同的严重性。因此,再次强烈建议将此问题配置为错误,且不要忽略这些消息。

为这些问题服务器创建单独的组有两个目的:记录提出给定的问题,以帮助流分析,及负责造成流分析错误这一事实(由实施中的错误导致)。对于知识性实施错误,这是一种良好的异常情况,可抑制发生此类错误消息。

考虑到任何静态分析的性质,流分析可能无法看到执行路径和值的特定组合是不可能组合。例如,考虑变量相关性

   String flatten(String[] inputs1, String[] inputs2) { 
        StringBuffer sb1 = null, sb2 = null;
        int len = Math.min(inputs1.length, inputs2.length);
        for (int i=0; i<len; i++) {
            if (sb1 == null) {
                sb1 = new StringBuffer();
                sb2 = new StringBuffer();
            }
            sb1.append(inputs1[i]);
            sb2.append(inputs2[i]); // warning here
        }
        if (sb1 != null) return sb1.append(sb2).toString();
        return "";
    }

编译器将在调用 sb2.append(..) 时报告潜在的空指针访问。. 人工阅读器可以看到,不存在实际的危险,因为无论变量同时为空或同时为非空,sb1sb2 都具有实际的关联性。在有问题的行中,我们知道 sb1 不为空,因此 sb2 也不为空。没有深入了解为什么这样的相关性分析超出了 Eclipse Java 编译器的能力,请记住此分析不具有完整定理证明程序的功能,因此,悲观地报告了一些问题,从而可能将使更具能力的分析标识为错误警报。

如果您想从流分析中获得好处,建议您对编译器提供一点帮助,以便它能够“看到”您的结论。此帮助可很容易地将 if (sb1 == null) 分成两个单独的 if,可分别将其用于每个局部变量,它价格便宜,少量付费即可获得,从而使编译器现在可以完全看到发生的操作并可相应地检查代码。有关本主题的更多讨论,将遵循以下内容

从未注释类型到 @NonNull 类型的未校验转换

此组问题基于以下类比:在使用 Java 5 泛型的程序中,对 pre-Java-5 库的任何调用都可能暴露原始类型,即:通用类型的应用程序可能无法指定具体类型参数。要使这样的值适合某个程序,那么借助泛型,通过假定以代码的客户机部件预期的方式指定类型自变量,从而使编译器可添加一个隐式转换。编译器将发出一个关于使用这样的转换的警告,并假定该库“执行了正确的操作”来继续其类型检查。用完全相同的方法,库方法的未注释的返回类型可以被认为是一个“原始”类型或“遗留”类型。另外,隐式转换可以乐观地假定预期的规范。另外,又发出一个警告,且分析会继续假定该库“执行正确的操作”。

从理论上说,还需要这样的隐式转换指定某个规范违例。但是,在这种情况下,它可能是第三方代码,这违反了我们的代码预期的规范。或者(我们已深信)某些第三方代码可能确实符合该约定,但只是未能做此声明(因为它没有使用空注释)。在这种情况下,我们可能因组织原因而无法正确修复该问题。

    @SuppressWarnings("null")
    @NonNull Foo foo = Library.getFoo(); // implicit conversion
    foo.bar(); 

以上代码片段假定 Library.getFoo() 返回 Foo,但不指定空注释。我们可通过将返回值分配到 @NonNull 局部变量从而将其集成到我们的注释的程序中,这会触发关于未校验转换的警告。通过将相应的 SuppressWarnings("null") 添加到此声明,我们了解到此内在危险,并接受验证该库是否按实际所需的方式工作的责任。

用于使代码更易于分析的提示

如果流分析无法看到某个值确实不是空值,那么最简单的策略始终是添加使用 @NonNull 注释的新作用域局部变量。那么,如果您认为赋予此局部变量的值在运行时决不会是空值,那么可以使用 helper 方法,如下所示:

    static @NonNull <T> T assertNonNull(@Nullable T value, @Nullable String msg) {
        if (value == null) throw new AssertionError(msg);
        return value;
    }
    @NonNull MyType foo() {
        if (isInitialized()) {
            MyType couldBeNull = getObjectOrNull();
            @NonNull MyType theValue = assertNonNull(couldBeNull, 
                    "value should not be null because application " +
                    "is fully initialized at this point.");
            return theValue;
        }
        return new MyTypeImpl();
    }

注意,通过使用以上 assertNonNull() 方法,您将接受在运行时将始终保留此声明的职责。如果这不是你想要的,那么,对于流入某个位置的空值,注释的局部变量将仍有助于确定会在何处以及为何分析过程需要查看潜在变量。

用于采用空注释的提示

在发布 JDT V3.8.0 时,收集采用空注释的建议仍在进行中。因此,当前可在 Eclipse wiki 中维护该信息。