Чтобы соответствовать принципу подстановки Барбары Лисков (SOLID) с точки зрения заменяемости класса-родителя классом-наследником, нужны следующие проверки аргументов метода и возвращаемых значений:
Если возвращаемый тип метода предка является
Nonnull
, то переопределенный метод наследника тоже должен бытьNonnull
. Остальное допустимо.Если аргумент метода предка является
Nullable
, то переопределенный метод наследника тоже должен иметьNullable
аннотацию. Остальное допустимо.
Но это не все проверки, которые выполнит за вас Idea после соответствующей настройки. Полный перечень проверок приведен ниже в таблице. Из "коробки" Idea выполняет только две проверки (не те, что выше). Если вы пишете null free код, то статья для вас окажется все равно полезной по причине: 1) наличия стороннего кода; 2) легаси кода; и 3) по причине, что null
может быть использован в критических участках кода.
Зависимости
Для обеспечения таких проверок каждый метод и аргумент метода должны быть обозначены аннотациями @Nullable
и @Nonnull
. Чтобы не утонуть в этих аннотациях можно прийти к соглашению, что аннотацию @Nonnull
не нужно указывать, т.е. что она неявная.
Чтобы научить Idea определять отсутствие аннотации как @Nonnull
, нужно выполнить некоторые манипуляции с кодом. Рассматривалось три подхода, которые умеет обрабатывать Idea. Вариант с аннотацией org.eclipse.jdt.annotation.NonNullByDefault
не рассматриваю.
Подход на основе JSR-305. Требуется создать мета-аннотацию, которая настраивается на классы одного пакета. Действие аннотации не распространяется на классы подпакетов. Не поддерживаемая сообществом технология.
Подход на основе Checker Framework. Мета-аннотация не требуется. Действие применяется на пакеты класса и на все классы подпакетов.Поддерживается Lombok.
Подход с использованием JSR-305
Для реализации подхода, добавляется зависимость
<dependency>
<groupId>com.google.code.findbugs</groupId>
<artifactId>jsr305</artifactId>
<version>3.0.2</version>
<scope>provided</scope>
</dependency>
Scope зависимости можно указать "provided", возможно и вам в Runtime эти аннотации не нужны, JVM никак не импортит классы аннотаций при загрузке классов, если только аннотация не используется в Runtime через вызов Class.getAnnotations()
и обработку класса аннотации. Подход с provided использует и Spring Framework, убедиться в этом можно, если открыть класс org.springframework.lang.NonNull
(если не подключена транзитивная зависимость, то import javax.annotation.Nonnull
будет подсвечен красным, но это не будет мешать работе приложения). Размер jar библиотеки ~20 кБ.
Далее создается класс аннотации @NonnullByDefault
import любимая.реализация.Nonnull
/**
* This annotation can be applied to a package, class or method to indicate that the class fields,
* method return types and parameters in that element are not null by default unless there is:
* The method overrides a method in a superclass (in which
* case the annotation of the corresponding parameter in the superclass applies) there is a
* default parameter annotation applied to a more tightly nested element.
*
* @see <a href="https://youtrack.jetbrains.com/issue/IDEA-125281">Impl</a>
*/
@Documented
@Nonnull
@TypeQualifierDefault({
ElementType.ANNOTATION_TYPE,
ElementType.CONSTRUCTOR,
ElementType.FIELD,
ElementType.LOCAL_VARIABLE,
ElementType.METHOD,
ElementType.PACKAGE,
ElementType.PARAMETER,
ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface NonnullByDefault {
}
Реализацию аннотации Nonnull
можно использовать любую. Рекомендуется либо org.springframework.lang.NonNull
(если проект на Spring), либо javax.annotation.Nonnull
, чтобы не повышать зацепление кода.
Далее в каждом пакете, в классах которого требуется анализ NPE (скорее всего это все пакеты проекта), создается файл package-info.java
со следующим содержанием
@NonnullByDefault
package ru.my.package;
Idea будет отображать сообщения вида: "Method annotated with @Nullable must not override @NonnullByDefault method"
Подход с использованием Checker Framework
Добавляется зависимость (размер jar библиотеки ~200 кБ)
<dependency>
<groupId>org.checkerframework</groupId>
<artifactId>checker-qual</artifactId>
<version>3.25.0</version>
<scope>provided</scope>
</dependency>
Далее в корневом пакете проекта создается файл package-info.java
со следующим содержанием
@DefaultQualifier(Nonnull.class)
package ru.my.package;
import любимая.реализация.Nonnull
Из минусов библиотеки - это, что в сообщение об ошибке идет ссылка не на NonNull
, а DefaultQualifier
: "Method annotated with @Nullable must not override @DefaultQualifier method"
Реализацию Nonnull
можно использовать любую. Также рекомендуется либо org.springframework.lang.NonNull
, либо org.checkerframework.checker.nullness.qual.NonNull
.
Настройка Idea
Считаем, что одним из двух предыдущих способов настроена неявная аннотация @Nonnull
. Можно быть спокойным насчет размера class-файлов, размер не увеличивается, аннотаций @Nonnull
в class-файле не будет).
Проверки в Idea настраиваются в меню Editor → Inspections → Java → Probable bugs , в группах параметров @NotNull/@Nullable problems
, Return of 'null'
и Constant conditions & exceptions
(для версий старше IDEA 2022.3 разделена на 2 пункта в списке: Constant Values
и Nullability and data flow problems
). Сheckbox-ы расписывать не буду, удобнее настройки вычитывать из файла, а файл сохранить в CVS для всех участников команды. Для этого в директории .idea/inspectionProfiles
нужно создать два файла:
Inspections.xml
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Inspections" />
<inspection_tool class="ConstantConditions" enabled="true" level="WARNING" enabled_by_default="true">
<option name="SUGGEST_NULLABLE_ANNOTATIONS" value="true" />
<option name="DONT_REPORT_TRUE_ASSERT_STATEMENTS" value="false" />
</inspection_tool>
<inspection_tool class="NullableProblems" enabled="true" level="WARNING" enabled_by_default="true">
<option name="REPORT_NULLABLE_METHOD_OVERRIDES_NOTNULL" value="true" />
<option name="REPORT_NOT_ANNOTATED_METHOD_OVERRIDES_NOTNULL" value="false" />
<option name="REPORT_NOTNULL_PARAMETER_OVERRIDES_NULLABLE" value="true" />
<option name="REPORT_NOT_ANNOTATED_PARAMETER_OVERRIDES_NOTNULL" value="true" />
<option name="REPORT_NOT_ANNOTATED_GETTER" value="true" />
<option name="REPORT_NOT_ANNOTATED_SETTER_PARAMETER" value="true" />
<option name="REPORT_ANNOTATION_NOT_PROPAGATED_TO_OVERRIDERS" value="false" />
<option name="REPORT_NULLS_PASSED_TO_NON_ANNOTATED_METHOD" value="true" />
</inspection_tool>
<inspection_tool class="ReturnNull" enabled="true" level="WARNING" enabled_by_default="true">
<option name="m_reportObjectMethods" value="true" />
<option name="m_reportArrayMethods" value="true" />
<option name="m_reportCollectionMethods" value="true" />
<option name="m_ignorePrivateMethods" value="false" />
</inspection_tool>
<inspection_tool class="VariableTypeCanBeExplicit" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="WARNING_ATTRIBUTES" />
</profile>
</component>
profiles_settings.xml
<component name="InspectionProjectProfileManager">
<settings>
<option name="PROJECT_PROFILE" value="Inspections" />
<version value="1.0" />
</settings>
</component>
Если работаете с GIT, то не забудьте добавить файлы в .gitignore
.idea
!.idea/inspectionProfiles
Далее нужно в разделе Return of 'null'
(в Idea 2022.2.4 Constant conditions & exceptions
) нужно настроить аннотации для автодополнений, кликнув по кнопке Configure Annotations...
Настройка сохраняется в .idea/misc.xml
. Если файл не добавить в CVS, каждый в группе должен ее настроить так, как настроили остальные участники, чтобы аннотации проставлялись одинаково всеми.
Результат
В таблице указаны проверки (там, где далее упоминается Nonnull
, по соглашению считать, что аннотация на элементе должна отсутствовать; теоретически Nonnull
можно и указывать, на проверки это не повлияет).
Проверка | По умолчанию | После конфигурации | |
1 | Если возвращаемый тип метода предка является | ❌ | ✅
|
2 | Если аргумент метода предка является | ❌ | ✅ |
3 | Проверяется, что аннотации на setter и getter методах соответствуют аннотациям полей класса | ✅ | ✅ |
4 | Проверяется передача | ❌ | ✅ |
5 | Проверяется наличие аннотации | ❌ | ✅ |
6 | Проверяется наличие аннотации | ❌ | ✅ |
7 | Проверяется, что | ❌ | ✅ |
8 | Проверяется наличие аннотации | ❌ | ✅ |
9 | Проверяется отсутствие аннотации | ❌ | ✅ |
10 | Проверяется возможность получения NPE при работе с объектом, например при вызове метода на объекте, который может принимать значение | ✅ | ✅ |
Так выглядит инспекция в Idea Ultimate 2022.2.4 без настройки.
Так будет выглядеть инспекция после настройки.
Код для воспроизведения
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.checkerframework.framework.qual.DefaultQualifier;
@DefaultQualifier(NonNull.class)
@SuppressWarnings({"unused", "FieldMayBeFinal", "ResultOfMethodCallIgnored", "FieldCanBeLocal"})
class Sub extends Base {
private Integer nonNull = 1;
@Nullable private Integer nullable;
@Nullable
@Override
Integer nonNull(Integer i) { return null; } // 1, 2
Integer getNullable() { return nullable; } // 3, 8
void setNullable(Integer nullable) { this.nullable = nullable; }
@Nullable
Integer getNonNull() { return nonNull; }
void setNonNull(@Nullable Integer nonNull) { this.nonNull = nonNull; } // 7
void test() { nonNullArg(1); nonNullArg(null); } // 4
void nonNullArg(Integer i) {} // 5 (works for Checker Framework only; JSR 305 requires @NonNull on arg explicitly)
void test2() { Integer i = null; } // 6
// (configured by "Constraint conditions & exceptions" -> "Report nullable method always return non-null value")
@Nullable // 9? (no warn, idea bug)
Object nonNullResult(Integer i) { return new Object(); }
void testNpe(@Nullable Integer i) { i.longValue(); } // 10
void noTestNpe(Integer i) { i.longValue(); } // this is nonNull by default
}
@DefaultQualifier(NonNull.class)
class Base {
Integer nonNull(@Nullable Integer i) { return 1; }
}
Запуск инспекций из maven
SpotBugs (JSR-305) plugin
Позволяет обнаружить кейсы 4, 7, 10 в схеме с аннотациями JSR-305, в схеме с аннотациями Checker Framework обнаруживает только 10 вариант NPE (есть ряд открытых запросов на SpotBugs). Настраивается maven следующим образом
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>4.7.2.1</version>
<executions>
<execution>
<goals>
<goal>check</goal>
</goals>
<phase>compile</phase>
</execution>
</executions>
</plugin>
Checker Framework plugin
Позволяет обнаружить 9 NPE из 10 (кроме 5-ой, но она покрывается 4-ой проверкой). Для Java 11+ настраивается следующим образом
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.10.1</version>
<configuration>
<fork>true</fork> <!-- Must fork or else JVM arguments are ignored. -->
<showDeprecation>true</showDeprecation>
<showWarnings>true</showWarnings>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.checkerframework</groupId>
<artifactId>checker</artifactId>
<version>${checkerframework.version}</version>
</path>
</annotationProcessorPaths>
<annotationProcessors>
<annotationProcessor>
lombok.launch.AnnotationProcessorHider$AnnotationProcessor
</annotationProcessor>
<annotationProcessor>
org.checkerframework.checker.nullness.NullnessChecker
</annotationProcessor>
</annotationProcessors>
<compilerArgs combine.children="append">
<!--arg>-Awarns</arg--> <!-- CI: падать при наличии проблем -->
<arg>-Astubs=jdk.astub</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
</compilerArgs>
</configuration>
</plugin>
Анализатор корректно работает с неаннотированным API JDK, но есть особенность относительно работы Objects.requireNonNull(@NonNull arg)
, аргумент считается @NonNull
, чтобы подсвечивать возможный NPE в точке вызова метода. Для обхода можно использовать механизм astub. В корне CVS репозитария нужно создать файл jdk.astub
import org.checkerframework.checker.nullness.qual.Nullable;
package java.util;
public class Objects {
@EnsuresNonNull("#1")
public static <T> T requireNonNull(@Nullable T obj);
@EnsuresNonNull("#1")
public static <T> T requireNonNull(@Nullable T obj, String message);
}
В файл можно вносить сигнатуры методов из других пакетов, начало классов пакета определяется ключевым словом package
.
Второй вариант - @SuppressWarnings({"nullness", "ConstantConditions"})
- на мой взгляд более правильный. Во-первых, обязывает программиста реагировать на возможный NPE, во-вторых позволяет убрать warning не только для компилятора ("nullness"), но и для инспекций Idea ("ConstantConditions").
Nullaway plugin
По наводке @Asapin проверены анализаторы Errorprone
+ NullAway
.
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<javac.version>9+181-r4173-1</javac.version>
</properties>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.0</version>
<configuration>
<showWarnings>true</showWarnings>
<compilerArgs>
<arg>-XDcompilePolicy=simple</arg>
<arg>-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=org</arg>
</compilerArgs>
<annotationProcessorPaths>
<path>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_core</artifactId>
<version>2.4.0</version>
</path>
<path>
<groupId>com.uber.nullaway</groupId>
<artifactId>nullaway</artifactId>
<version>0.8.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
Nullaway находит 5 NPE из 10 (проверки 1, 4, 7, 8, 10 из таблицы выше). Подробности в комментарии.
Настройка CI
Можно настроить CI, чтобы он не пропускал коммиты с потенциальными NPE, например так это можно сделать на GitHub с maven-плагином Checker Framework
on.pull_request.branches: ['master', 'develop']
jobs:
npecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-java@v3
with:
java-version: '18'
distribution: 'liberica'
cache: maven
- run: mvn --batch-mode clean compile
В заблокированный NPE инспекцией Merge Request легко внести правки, не обращаясь к логу CI, т.к. Idea подсвечивает зафейленные проверки Checker Framework, - это несомненно очень удобно. Idea даже еще на момент коммита покажет все warning (checkbox в окне коммита Analyze code, Choose profile
-> выбрать ранее настроенный "Inspections"), поэтому Merge Request может быть заблокирован только в случае игнорирования предупреждений.
Итоги
Рассмотрен статический анализатор NPE в Idea с использованием аннотаций JSR-305 и Checker Framework. Оба подхода позволяют настроить 10 проверок (из коробки Idea выполняет только 2 проверки). JSR-305 имеет меньшего размера библиотеку (не уходит в runtime), однако не развивается, поэтому рекомендуется только для тех проектов, где уже внедрен. Существует проект-преемник JSR-305 - SpotBugs, однако его maven-плагин покрывает лишь от 1 до 3 проверок из 10 (в зависимости от используемой аннотации @Nullable
). Плагин Nullaway покрывает 5 NPE проверок. Checker Framework покрывает все 10 проверок NPE, причем набор проверки и в Idea, и из maven-плагина получается одинаковый, это позволяет настроить согласованное с Idea поведение проверок при CI.