Боротьба з null в Java-проєктах. Новий стандарт JSpecify
Всім привіт. Я Сергій Моренець, розробник, тренер, викладач, спікер та технічний письменник, хочу розповісти вам про те, як у сучасних Java-проєктах борються із проблемою NullPointerException (NPE). У цій статті я опишу різні способи боротьби з NPE, які з’явилися в Java-індустрії (і багато з яких досі популярні), і також розповім про новий перспективний проєкт/стандарт JSpecify. Сподіваюся, що ця стаття буде корисною для всіх, хто хоче більше дізнатися про сучасні тенденції роботи з null-значеннями та про те, як уникнути пов’язаних із цим складнощів.
Передісторія
Боротьба з помилками, пов’язаними з null-значеннями, має дуже довгу історію, усіяну тушками провалених проєктів, звільнених менеджерів і розорених замовників. На жаль, розробники JDK дуже довгий час ігнорували цю проблему, що і призвело до таких сумних наслідків (а також до появи Kotlin). Давайте розглянемо засоби, які зараз існують для запобігання NPE.
Отже, припустимо, що у нас є ось такий код:
public interface TokenGenerator { String generate(int length); }
Це API загального характеру, що дозволяє згенерувати якийсь токен. Що буде, якщо ми передамо неправильний аргумент length? Чи буде викинуто виключення, чи повернеться null? А може, повернеться порожній рядок? JavaDocs немає, як часто буває, а реалізації цього інтерфейсу вільні самі вирішувати, як обробляти цей випадок (причому кожна по-різному). Такий код є нічним кошмаром для будь-якого розробника, але якщо він існує, то необхідно знайти найбільш оптимальне рішення для того, щоб уникнути null там, де воно можливе.
Найперший і найпростіший спосіб — перевірити на null значення, що повертається:
public class UserRequst { public UserRequst(TokenGenerator generator) { String token = generator.generate(20); if(token == null) {
Такий підхід дуже популярний, але призводить до появи численних if перевірок, які тільки заплутують розробника. Більш компактно — використовувати клас Objects, який з’явився в Java 7:
public UserRequst(TokenGenerator generator) { String token = Objects.requireNonNull(generator.generate(20));
Такий код просто викидає NullPointerException, якщо йому передати null. Він оберігає від некоректного стану ваших об’єктів, але цей підхід теж є імперативним і не розв’язує проблему в цілому. Тому боротьба з NullPointerException у Java-проєктах пішла двома принципово різними шляхами. Перший шлях полягав у використанні спеціальних типів для API.
Клас-обгортка Optional з’явився в Java 8 в 2014 році і дозволяв явно вказати споживачам вашого API, що вони можуть отримати як валідний результат операції, так і його відсутність:
Optional<String> generate(int length);
Такий тип був відомий усім, хто використовував Google Guava, а з’явився він у Guava 10.0 у 2011 році. Загалом Optional був значним кроком уперед, але він мав кілька недоліків. При його інтенсивному використанні JVM засмічувалося величезною кількістю дрібних об’єктів. Optional об’єкти не можна серіалізувати. Крім того, навіть якщо ви використовували Optional, ви все одно могли б повернути null з методу generate().
Тому найбільш істотна робота пішла другим шляхом, пов’язаним з декларативною перевіркою на null. Ще в
@Nonnull String generate(int length);
Фактично саме по собі це було схоже на JavaDocs, оскільки жодної перевірки на null (статичної або run-time) воно не включало. Тому цей набір анотацій зазвичай використовувався разом із засобами статичного аналізу коду, спочатку FindBugs, а після заморозки цього проєкту — Spotbugs. На жаль, JSR 305 припинив свій розвиток ще на початку
Ще один цікавий підхід запропонували автори бібліотеки Lombok. Вони ввели свою анотацію @NonNull. І якщо ви додаєте її на аргумент конструктора чи методу:
public class AccountService { private final @NonNull TokenGenerator tokenGenerator;
То Lombok автоматично генерує захист від null:
public class AccountService { private final @NonNull TokenGenerator tokenGenerator; @Generated public AccountService(@NonNull TokenGenerator tokenGenerator) { if (tokenGenerator == null) { throw new NullPointerException("tokenGenerator is marked non-null but is null"); } else { this.tokenGenerator = tokenGenerator; } }
Недолік такого підходу в тому, що якщо ви додасте таку анотацію до повертаного значення:
@NonNull String generate(int length);
То й помилки не буде, але й жодної перевірки на null зроблено не буде.
Такий підхід принаймні полегшував роботу з null, але автори Lombok при реалізації цієї фічі виявили, що в Java-проєктах вже існують і активно використовуються кілька десятків аналогічних анотацій:
Їм довелося підтримувати кожну з них. Виходило, що відсутність єдиного стандарту в цьому питанні ускладнювала роботу і IDE, і статичним аналізаторам коду, та й самим Java-розробникам, які ламали голову над тим, які ж анотації вибрати.
Найбільша технічна конфа ТУТ!🤌
А що ж розробники JDK? Кому, як не їм, пропонувати стандарти. Але на жаль, тільки в 2023 році з’явився перший тикет на створення nullable-типів. Його автор пропонував підхід, схожий на Kotlin:
- String! item — змінна item може бути null (null-restricted)
- String? item — змінна item може бути nullable
На жаль, якоїсь активності в цьому тикеті немає, тому навряд він незабаром буде реалізований. Можливо, в Enterprise Java є відповідне рішення? На жаль, але в Jakarta Annotations, який прийшов на зміну Java EE, є ті самі анотації @Nullable і @Nonnull, як і в JDK 15 років тому. Щоправда, у
Там, де безсилі Java Core і Java EE, зазвичай у фарватері прогресу йдуть розробники Spring Framework. У
- Nullable
- NonNull
- NonNullApi
- NonNullFields
Цікаво, що вони є мета-анотаціями, тобто включають існуючі анотації з JSR-305 (@CheckForNull, @Nonnull):
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD}) @Retention(RetentionPolicy.RUNTIME) @Documented @CheckForNull @TypeQualifierNickname public @interface Nullable { }
Однак уже в 2022 році з’явився новий тикет, який передбачав відмовитися від даних анотацій в Spring і перейти на інші, з проєкту JSpecify. Що це за новий проєкт?
JSpecify — це спроба створити стандарт і задекларувати набір анотацій, які потім використовуватимуться статичними аналізаторами коду. Цей проєкт стартував у 2018 році й був випущений версією 1.0 у липні 2024 року. Очолює цю ініціативу Google, а до складу команди розробників входять представники JetBrains, Meta, Uber, Oracle та Microsoft.
Що нам, розробникам, дає JSpecify? Крім стандарту, він містить:
- Специфікацію.
- Реалізацію (jspecify-reference-checker).
Крім того, є і сторонні проєкти, які підтримують JSpecify:
- Бібліотека NullAway
- Intellij IDEA
- Checker Framework
Цікаво, що JSpecify як специфікація містить лише 4 анотації:
- NonNull
- Nullable
- NullMarked
- NullUnmarked
@NonNull — анотація, яка дозволяє вказати, що певне значення може бути null. У нашому прикладі:
public interface TokenGenerator { @NonNull String generate(int length);
На відміну від аналогічної анотації Lombok, ви можете її застосовувати до будь-якого елемента вашого коду, наприклад:
class Container<@NonNull E> {
Nullable — анотація, яка дозволяє вказати, що певне значення може бути null:
public interface TokenGenerator { @Nullable String generate(int length);
При використанні масивів потрібно бути особливо уважним, тому що тут не кожен може сказати, що означає цей код:
@NonNull String @Nullable[] items = new String[] {};
А тут ми говоримо, що змінна масив items може бути null, а ось її елементи — ні.
Виходить, якщо ви хочете перейти на JSpecify, то потрібно кожен аргумент, поле і локальну змінну позначати як @Nullable і @NonNull? Зовсім ні, тому що у вас в запасі є дві магічні анотації — @NullMarked і @NullUnmarked.
Якщо ви їх поставите на декларацію класу чи інтерфейсу:
@NullMarked public interface TokenGenerator { String generate(int length);
Це означає, що скрізь всередині цього елемента null значення не допускається. Якщо вам потрібно дозволити використання null в одному конкретному місці, то ви явно вказуєте @Nullable:
@NullMarked public interface TokenGenerator { @Nullable String generate(int length);
У сучасних проєктах можуть бути тисячі і десятки тисяч класів, і ставити анотації на кожен з них вимагає великих витрат часу. Тому є простіший варіант — файл package-info.java:
package demo; import org.jspecify.annotations.NullMarked;
Тепер вважається, що анотація @NullMarked поширюється на всі елементи/типи в цьому пакеті (підпакети при цьому не торкаються).
Таким чином, після переходу на JSpecify ваші змінні можна поділити на три групи:
- Можуть бути null з @Nullable.
- Не можуть бути null з @NonNull/@NullMarked.
- Їхня можливість зберігати null невідома (це називається unspecified nullness) — як було б до використання JSpecify.
Проєкт JSpecify настільки сподобався розробникам Spring Framework, що вони ще в 2022 році захотіли на нього перейти і використовувати його за допомогою мета-анотацій. Але коли в
При цьому потрібно усвідомлювати, що в сучасних проєктах
Як перевірити, що це справді працює? Спробуємо перейти на JSpecify і застосувати для одного з невеликих проєктів.
Переходимо на JSpecify
У нашому випадку перехід на JSpecify буде максимально безболісним, оскільки ми до цього жодних анотацій та бібліотек із цієї категорії не використовували.
Додамо нову залежність для Gradle-конфігурації:
implementation("org.jspecify:jspecify:1.0.0″)
Тепер ми можемо використати його у нашому проєкті. Зрозуміло, що перевести весь проєкт на JSpecify — дуже затратне завдання, тому спробуємо перевірити, як це працює на невеликому наборі типів.
У нас є інтерфейс NumberGenerator:
public sealed interface NumberGenerator permits RandomNumberGenerator, SecureRandomNumberGenerator, SequentialNumberGenerator { int generate(); }
Метод generate() повертає тип int, тому немає сенсу вказувати там @NonNull, адже це і так очевидно. Але спробуємо там поставити анотацію @Nullable і перевірити, чи визначить це статичний аналізатор як помилку:
@Nullable int generate();
Далі у нас є клас ReflectionUtil з методом createInstance:
public static <T> T createInstance(Class<T> clz) throws ConfigurationException { try { return clz.getDeclaredConstructor().newInstance(); } catch (Exception e) { throw new ConfigurationException(e); } }
Після аналізу цього методу стає зрозуміло, що і аргумент, і значення, що повертається, не може бути null, тому ми можемо вказати в сигнатурі @NonNull:
public static @NonNull <T> T createInstance(@NonNull Class<T> clz) throws ConfigurationException {
Далі візьмемо метод transform в інтерфейсі Transformer, який викликає цей метод:
default <T, P> P transform(T entity, Class<P> clz) { checkParams(entity, clz); P dest = ReflectionUtil.createInstance(clz); return transform(entity, dest); }
Тут також додамо дві помилкові анотації:
- @Nullable для аргумента clz
- @Nullable для локальної змінної dest
default <T, P> P transform(T entity, @Nullable Class<P> clz) { checkParams(entity, clz); @Nullable P dest = ReflectionUtil.createInstance(clz); return transform(entity, dest); }
Крім того, в тому ж інтерфейсі додамо main() метод і спробуємо передати null у createInstance:
public static void main(String[] args) { ReflectionUtil.createInstance(null); }
Зараз ми просто помітили анотації, але ніяких перевірок робитися не буде, тому що не підключений статичний аналізатор коду. У JSpecify є своя реалізація Reference Checker, але вона ще на стадії розробки і не готова для повноцінного використання.
Але, на щастя, вона має альтернативу — проєкт NullAway. Він стартував у 2017 році під крилом компанії Uber. Фактично це плагін до бібліотеки Google ErrorProne, про яку я вже писав. ErrorProne — статичний аналізатор коду, який перевіряє ваш проєкт на помилки під час складання.
NullAway має свої власні анотації, такі як @RequiresNonNull і @EnsuresNonNull, але в базовому варіанті він використовує анотації з JSpecify.
ErrorProne у нашому проєкті вже є, тому додамо цей плагін для Gradle-конфігурації:
errorprone("com.uber.nullaway:nullaway:0.12.4″)
І запустимо збірку:
gradle build
Відразу отримуємо помилку під час збирання:
Caused by: java.lang.IllegalStateException: DO NOT report an issue to Error Prone for this crash! NullAway configuration is incorrect. Must either specify annotated packages, using the -XepOpt:NullAway:AnnotatedPackages=[...] flag, or pass -XepOpt:NullAway:OnlyNullMarked (but not both). See github.com/...llAway/wiki/Configuration for details. If you feel you have gotten this message in error report an issue at github.com/uber/NullAway/issues.
at com.uber.nullaway.ErrorProneCLIFlagsConfig.<init>(ErrorProneCLIFlagsConfig.java:259)
at com.uber.nullaway.NullAway.<init>(NullAway.java:295)
Ця помилка свідчить про те, що нам потрібно додати той пакет, який NullAway перевірятиме під час статичного аналізу:
tasks.withType<JavaCompile>(){ options.errorprone { option("NullAway:AnnotatedPackages«, «demo») } }
По дефолту всі помилки при використанні JSpecify трактуватимуться як попередження (тобто не зупинятимуть збирання), що нас цілком влаштовує. Знову запускаємо збірку і отримуємо цілу купу попереджень від NullAway. Розбиратимемо їх окремо.
warning: [NullAway] passing @Nullable parameter ’null’ where @NonNull is required
ReflectionUtil.createInstance(null);
Тут знайдено помилку з передачею null в метод, де аргумент @NonNull:
warning: [NullAway] passing @Nullable parameter ’clz’ where @NonNull is required
@Nullable P dest = ReflectionUtil.createInstance(clz);
Тут знайдено помилку, що ми передаємо @Nullable значення в метод, де аргумент @NonNull:
warning: [NullAway] passing @Nullable parameter ’clz’ where @NonNull is required
checkParams(entity, clz);
А ось тут цікавіше. Якщо ще раз поглянути на метод transform:
default <T, P> P transform(T entity, @Nullable Class<P> clz) { checkParams(entity, clz); @Nullable P dest = ReflectionUtil.createInstance(clz);
То NullAway лається на те, що ми передаємо @Nullable значення метод checkParams. Але checkParams не помічений анотаціями. Чому ж попередження? Єдине можливе пояснення — те, що NullAway помітив, що clz має бути @NonNull у createInstance і свариться скрізь, де ми намагаємося передати clz.
Тепер спробуємо виправити ці помилки:
default <T, P> P transform(T entity, @NonNull Class<P> clz) {
Цікаво, що наша спроба ввести в оману ErrorProne була припинена:
: warning: [NullablePrimitive] Nullness annotations should not be used for primitive types since they cannot be null
@Nullable int generate();
^
(see errorprone.info/...pattern/NullablePrimitive)
Did you mean ’int generate();’?
Якщо метод повертає примітивне значення, ми не можемо додати анотацію @Nullable. Але цікаве інше. Це попередження не від NullAway, а від ErrorProne. Тобто він теж підтримує JSpecify? Насправді в ньому просто є патерн на те, як мають виглядати анотації, які не допускають або допускають null:
private static final Predicate<String> ANNOTATION_RELEVANT_TO_NULLNESS = Pattern.compile( «(Recently)?NonNull(Decl|Type)?|NotNull|Nonnull|» + «(Recently)?Nullable(Decl|Type)?|CheckForNull|PolyNull|MonotonicNonNull(Decl)?|» + «ProtoMethodMayReturnNull|ProtoMethodAcceptsNullParameter|» + «ProtoPassThroughNullness») .asMatchPredicate(); private static final Predicate<String> NULLABLE_ANNOTATION = Pattern.compile( «(Recently)?Nullable(Decl|Type)?|CheckForNull|PolyNull|MonotonicNonNull(Decl)?|» + «ProtoMethodMayReturnNull|ProtoMethodAcceptsNullParameter|» + «ProtoPassThroughNullness») .asMatchPredicate();
Єдину помилку, яку NullAway не знайшов, це:
@Nullable P dest = ReflectionUtil.createInstance(clz);
Я створив тикет на цей приклад. І розробники досить швидко відповіли та пояснили, що головне завдання JSpecify/NullAway — уникнути NPE. І хоча конвертація @NonNull в @Nullable виглядає логічно некоректною, але помилки це не викличе, так як NullAway вимагатиме перевірки на null.
Однак NullAway видав цілу купу інших попереджень, які вимагають аналізу.
warning: [NullAway] initializer method does not guarantee @NonNull field client (line 46) is initialized along all control-flow paths (remember to check for exceptions or early returns).
public ConsulConfigSource() {
^
З одного боку, NullAway правильно визначив, що в конструкторі цього класу не ініціалізується поле client. Але чому це помилка, якщо це поле lazy й ініціалізується при першому зверненні?
У цьому випадку можна підказати NullAway назву методу, де ми ініціалізуємо це поле. Для цього потрібно створити анотацію @Initializer:
@Documented @Target(TYPE_USE) @Retention(RUNTIME) public @interface Initializer { }
І додати її на метод getClient у класі ConfigConsulSource:
@Initializer private ConsulClient getClient() { if (client == null) { String host = getConfig().getOptionalValue(PROPERTY_CONSUL_HOST, String.class) .orElse(ConsulRawClient.DEFAULT_HOST); int port = getConfig().getOptionalValue(PROPERTY_CONSUL_PORT, Integer.class) .orElse(ConsulRawClient.DEFAULT_PORT); client = new ConsulClient(host, port); } return client; }
Тепер це попередження більше не відображається.
Ще одне дивне попередження:
warning: [NullAway] passing @Nullable parameter ’e.getMessage()’ where @NonNull is required
throw new CommunicationException(e.getMessage(), e);
У конструкторі класу CommunicationException немає жодного натяку на @NonNull:
public CommunicationException(String message, Throwable cause) {
Але чомусь NullAway вважає інакше. Після детального аналізу документації з’ясовується, що NullAway розглядає всі оголошення як @NonNull, навіть якщо такої анотації там немає. Це особливість NullAway, не JSpecify, тому якщо ви будете використовувати іншу reference implementation, там можуть бути інші правила.
Поточне попередження можна усунути, додавши анотацію @NullUmarked до всього класу CommunicationException (або в package-info.java):
@NullUnmarked public class CommunicationException extends AppException {
Ця анотація свідчить, що це елементи класу, не помічені анотаціями, можуть приймати і повертати null (unspecified nullness).
Що буде, якщо ви використовуєте анотації, але не з JSpecify, а з інших бібліотек? NullAway підтримує будь-які анотації під назвою @NotNull або @NonNull. Але навіть якщо ці анотації називаються інакше, то їх можна передати в конфігураційний блок файлу збірки. Єдиний виняток із цього правила — анотація @NotNull з Java/Jakarta Validation. Ця анотація не забороняє надавати null, але виявляє null при подальшій перевірці (якого може і не бути). Тому таку анотацію слід виключити зі списку у конфігураційному файлі збірки.
Висновки
Загалом можна сказати, що JSpecify — це досить перспективний проєкт і, по суті, перша спроба створити стандарт у Java-світі для боротьби з NPE. Поки що його можна використовувати лише за допомогою NullAway (а його — через ErrorProne), так що подивимося, можливо, з’являться інші реалізації, які також дозволять боротися з цією проблемою.
До переваг NullAway можна віднести гнучкість конфігурації та підтримку не тільки JSpecfy-анотацій, але й аналогічних анотацій з інших бібліотек.
32 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів