В компании “Свой Банк” мы активно развиваем лучшие практики и стандарты в Backend-разработке. Но, прежде чем выработать хотя бы одну практику, необходимо изучить материалы, разобраться в теме и выработать подходящий вариант. Поэтому в данной статье затронем основные понятия и концепции работы null-безопасности в объектно-ориентированном языке программирования Java.

Что такое Null-безопасность и Nullability

Nullability — это концепция, которая описывает, может ли переменная или выражение содержать значение null. Несмотря на свою кажущуюся простоту, работа с null часто становится причиной сложных ошибок, таких как NullPointerException (NPE). Из-за этого возникла концепция null-безопасности, целью которой является защита кода от ошибок, связанных с null.

Null-безопасность означает, что в коде либо отсутствуют null-значения, либо они обрабатываются явно и безопасно. В Java существуют разные подходы к обеспечению null-безопасности, которые позволяют управлять возможностью появления null в коде и минимизировать риски, связанные с их использованием.

Итого Nullability - это способность объекта быть null.

Null-безопасность - это свойство языка программирования безопасно работать с null.

Null-безопасность в Java

Java изначально не имела встроенных механизмов для безопасной работы с null-значениями, что привело к распространению ошибок, вызванных его использованием. 

Для предотвращения NPE требовалась проверка объектов перед их использованием, либо приходилось обрабатывать NullPointerException в try catch конструкции. 

Начиная с версии Java 5, были добавлены аннотации, которые позволили добавлять метаданные о намерении использовать null-значения. Они помогают разработчикам и инструментам статического анализа понять, какие переменные могут быть null, а какие нет.

В версии Java 8 был добавлен Null wrapper Optional, который предоставляет способ работы с потенциально null значениями без необходимости явных проверок.

Пример Optional:

Optional<String> optionalName = Optional.ofNullable(user.getName());
optionalName.ifPresent(name -> System.out.println(name))

Однако даже с этими инструментами null-безопасность в Java требует внимания и дисциплины, так как многие старые библиотеки и API активно используют null.

Что такое null?

В Java null означает отсутствие объекта. Он используется для обозначения неинициализированных объектов, отсутствующих данных, а также для обозначения ситуаций, когда переменной присваивается неизвестное или неопределенное значение.

В отличие от других объектов, null не содержит никаких данных и не поддерживает методы. Попытка вызвать метод на null приводит к NullPointerException.

Пример использования null:

String name = null

Здесь переменная name не ссылается на какой-либо объект. Если попытаться вызвать метод этой переменной, программа выбросит исключение.

Когда возникает NullPointerException? 

NullPointerException (NPE) — это одна из самых распространенных ошибок в Java. Она возникает, когда программа пытается использовать объект, который равен null. Это может привести к неожиданным сбоям и сложностям в отладке. Проблемы начинаются, когда код пытается работать с null так же, как с обычным объектом. 

Пример возникновения NullPointerException:  

User user = null;
String name = user.getName(); // Возникает NullPointerException (NPE)

Значение переменной user равно null, и при попытке вызвать метод getName() происходит сбой.

Как используется null?

В зависимости от написанного кода каждому объекту можно дать характеристику на время его жизни:

  1. Всегда null.

Например, поле в объекте, которое никогда не инициализируется и не заполняется значением. В “Своем Банк” мы стремимся не создавать такие поля, даже если оно планируется использоваться когда-то в будущем, следуем принципу YAGNI («You aren't gonna need it»).

  1. Всегда не null.

Например, поле в объекте, которое всегда инициализируется значением.

  1. Nullable до определенного этапа жизни.

Например, поле в объекте, которое инициализируется как null, но на определенном этапе всегда заполняется не null значением.

  1. Nullable в любой момент времени.

Например, поле в объекте, которое в любой момент времени заполняется либо null-значением, либо не null-значением.

Nullable объекты является самой частой причиной  NullPointerException, т.к. не дают явного понимания, когда с объектом работать безопасно.


Рассмотрим варианты, когда используются
null:

1. Неинициализированное значение     

Объект объявлен, но не инициализирован. По умолчанию для объектов переменным присваивается null.

Пример:

Object obj = null;

2. Отсутствующее значение или возвращение null из методов     

При вызове метода, который возвращает значение, иногда можно получить null, если результат отсутствует, как в случае с методом Map.get()

Пример:

Map<String, String> map = new HashMap<>();   
String value = map.get("key");  // Может вернуть null, если ключ не найден

3. Незаполненное значение из внешних систем: 

Пустые поля из внешних систем могут быть представлены как null.

Пример:

String json = "{"name": "Ivan", "age": null}";   
User user = objectMapper.readValue(json, User.class);
user.getAge(); // вернет null

4. Неизвестное значение    

Например, неизвестное или неопределённое значение из перечисления (enum). 

Итого null — это контракт и намерение.

Избавиться от NullPointerException - это не использовать null.

В Java полностью избежать использования null невозможно из-за обратной совместимости и распространенного использования этого значения в стандартных библиотеках и фреймворках.

Разработчики в “Своем Банке” стремятся не возвращать null "by design". Проверки на null все же необходимы там, где потенциально может возникнуть ситуация с его использованием.

Многие языки имеют null-значения, но главное отличие между ними – это возможность избегать использование null.

Мы уже поняли, что в Java невозможно избегать null. Другие языки имеют иные подходы для работы с null, например, в Kotlin встроена возможность избегать использование null, но даже это не исключает ошибок разработке и возникновения NullPointerException при использовании Java библиотек или интеграции с другими системами.

Несмотря на то что null является неотъемлемой частью Java, существуют инструменты и библиотеки, которые помогают минимизировать его использование.

Решения

1. Non-null по умолчанию — объекты не могут быть null, если это явно не указано.

В Java невозможно получить non-null по умолчанию, но можно добиться гарантии non-null:

  • Проверками на null во время создания объекта;

  • Использованием аннотаций @Nonnull, @ParametersAreNonnullByDefault и статических анализаторов  

2. Null wrapper — обертка для объектов, которые могут быть null.

Вместо возвращения null можно возвращать объект класса Optional, который помогает избежать прямой работы с null.

Это заставляет явно обрабатывать случай отсутствия значения.

Пример Null wrapper:

Optional<String> optionalName = Optional.ofNullable(..);
optionalName.ifPresent(name -> System.out.println(name.length()));

 3. Явные проверки

Самый простой и распространенный способ избежать NullPointerException — это проверка значения на null перед его использованием. 

Пример:

if (name != null) {
   	int length = name.length();
}

Заключение

Null-безопасность — это подход, который помогает сделать работу с null более явной, понятной и безопасной. Чтобы добиться null-безопасности, можно использовать аннотации, Optional и другие подходы, предотвращающие передачу и возврат null-значений. О них мы подробнее поговорим в следующей статье.

Комментарии (4)


  1. AdrianoVisoccini
    15.10.2024 07:50

    "В “Своем Банк” мы стремимся не создавать такие поля, даже если оно планируется использоваться когда-то в будущем, следуем принципу YAGNI («You aren't gonna need it»)"

    На этой фразе у Годзиллы случился сердечный приступ и он умер


  1. rsashka
    15.10.2024 07:50

    Вы боретесь не причиной, а со следствием (NullPointerException), тогда как причина возникновения этого исключения - нарушение логики работы приложения, но с ней вы так ничего и не делаете при явных проверках. Ведь в последнем случае правильнее был бы примерно вот такой код:

    if (name != null) {
        int length = name.length();
    } else {
        throw new NameEmptyException();
    }
    

    То есть от исключения вы все равно никуда деваетесь.

    Но если вы обрабатываете ошибки логики с помощью кодов возврата, вот тогда любые исключения будут мешаться. Но в этом случае проблема опять же не в самих NullPointerException, а в том, что у вас перемешиваются способы обработки ошибок (с помощью исключений и кодов возврата). И тогда да, первые два способа Non-null по умолчанию и Null wrapper частично закрывают данную проблему, но к сожалению не избавляют от исключений полностью. Поэтому даже в этих случаях все равно приходится ловить исключения, хоть и в меньшем объеме, чем с nullable объектами.


  1. m_chrom
    15.10.2024 07:50

    Разработчики в “Своем Банке” стремятся не возвращать null "by design"

    А что делать если null - валидное значение. Так-то null вполне естественный способ вернуть ответ "того, что ты ищешь, у меня нет"


    1. borman712
      15.10.2024 07:50

      Optional же. Запросил, получил Optional, дальше - по обстоятельствам. В целом Optional спасает и от NPE в цеплочке вида country.getCity().getDistrict().getStreet().getHouse().getApartment() благодаря методу map.

      Но всё равно не идеально... Думаю, если бы была идеальная серебряная пуля от NPE, её бы уже все использовали и не задавали вопросов.