В компании “Свой Банк” мы активно развиваем лучшие практики и стандарты в 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-значений. О них мы подробнее поговорим в следующей статье.

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


  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. WelcomeToRussia Автор
      15.10.2024 07:50

      Все зависит от реализуемой логики. Явные проверки необходимы, если объект nullable и это ожидаемое поведение. Вы привели только лишь один случай, когда null - это ошибка:

      1. Если name не должен быть null и это ошибка, то несомнено нужно бросить исключение, но читаемое и понятное.

      if (name != null) {
          return name.length();
      } else {
          throw new ViolationException('Expected non-null value on field [name].');
      }

      А вот несколько примеров, когда null - это ожидаемое значение

      private static int DEFAULT_LENGTH = 10;
      ..
      if (name != null) {
          return name.length();
      } else {
          return DEFAULT_LENGTH;
      }
      if (name != null) { 
        return Optional.of(name.length()); 
      } else { 
        return Optional.empty(); 
      }


  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, её бы уже все использовали и не задавали вопросов.


      1. m_chrom
        15.10.2024 07:50

        На мой вкус Optional  ужасно раздувает и делает плохочитаемым код. А уж если у вас больше одной nullable переменной, то замена строки if (a != null && b != null) {} на альтернативу с Optional будет выглядеть как небольшая программа на Лиспе

        Мой вопрос не к тому, что нужно искать какую-то другую "серебряную пулю". Я скорее к тому, что не стоит так уж демонизировать null. Вполне нормальная практика его возвращать, когда это уместно, как по мне. Аннотации @Nullable/@NotNull и ворнинги в IDE решают почти все потенциальные проблемы с использованием null.

        Optional спасает и от NPE в цеплочке вида country.getCity().getDistrict().getStreet().getHouse().getApartment()

        В такого рода вызовах Optional - то, что надо, да.


      1. WelcomeToRussia Автор
        15.10.2024 07:50

        Согласен.

        Есть еще способ - это default или null object.

        Пример реализации null object можно подглядеть в jackson: https://fasterxml.github.io/jackson-databind/javadoc/2.9/com/fasterxml/jackson/databind/node/NullNode.html


    1. WelcomeToRussia Автор
      15.10.2024 07:50

      Поле в объекте может быть null и это может быть валидное значение.

      Но при этом getter поля может возвратить Optional или Null\Default object.

      Например,

      class .. {
        private String name;
      
        public Optional<String> getName() {
          return Optional.ofNullable(name);
        }
      }

      или

      class .. {
        private static final String DEFAULT_NAME = 'UNKNOWN';
        private String name;
      
        public String getName() {
          return name == null ? DEFAULT_NAME : name;
        }
      }

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


      1. m_chrom
        15.10.2024 07:50

        А чем Null-Object удобнее в обработке, чем простой null? Если каждый раз писать проверку вида

        if (value != NULL_VALUE) ...

        то разницы вроде и никакой. Только этот NULL_VALUE надо еще для каждого класса задефайнить и где-то в глобальной переменной держать. А null из коробки есть. И инспекции в IDE опять же с null подскажут, что я проверку упустил, а с NULL_VALUE - уже нет.

        А ежели проверки не писать, то это будет источником багов, которые отлавливаются куда хуже, чем NPE. NPE хотя бы fail fast.

        Optional да, лучше. Но выше уже отвечал, что он уж больно код раздувает. Хотя это вкусовщина, возможно


        1. WelcomeToRussia Автор
          15.10.2024 07:50

          Null-object не нужно проверять на то, что он "пустой". С ним работают так же как с обычным классом, но у него поведение "пустоты".

          Только этот NULL_VALUE надо еще для каждого класса задефайнить и где-то в глобальной переменной держать.

          Конечно для каждого класса нужно описать Null-object, но это часть логики. Не во всех задачах подойдет такое решение.

          А null из коробки есть.

          null - есть и коробки, но его появление, как правило становиться неожиданностью, если не использовать аннотации или Optional.

          А чем Null-Object удобнее в обработке, чем простой null? Если каждый раз писать проверку вида

          Далее приведу простые примеры реализации null-object, которые дадут понять, что не нужно писать проверки:

          строка содержащая что то:

          String someString = "I'm not empty";

          И пустая строка, которая по сути null-object:

          String nullObjectString = "";

          И с someString и nullObjectString работать можно не думая о проверке на null.

          Более сложный пример с пользовательским классом:

          interface Friend {
              void sendEmail();
              String getName();
          }
          
          class BestFriend extends Friend {
              private final String name = "m_chrom";
              public void sendEmail() {
                   // send email
             }
          
              public String getName() {
                   return name;
              }
          }
          
          class NullFriend extends Friend {
              private static final String name = 'Unknown';
              public void sendEmail() {
                  //do nothing
              }
          
              String getName() {
                  return name;
              }
          }


          1. m_chrom
            15.10.2024 07:50

            "более сложный" пример как раз хорошо объясняет то, что я имел в виду. Если не использовать проверки, то в каком-то интерфейсе вашего продукта у вас всплывет фейковый друг с именем "Unknown" и кнопкой "Отправить письмо", которая ничего не делает. Хотя логичнее было бы увидеть сообщение "Друг не задан, выберите друга".

            Я не говорю, что Null-object антипаттерн. Он полезен в специфических случаях. Но как универсальную замену null-ов я бы его не советовал. Правило "всегда использовать использовать аннотации @Nullableтам, где может прилететь null" выглядит проще и безопаснее


            1. WelcomeToRussia Автор
              15.10.2024 07:50

              то в каком-то интерфейсе вашего продукта у вас всплывет фейковый друг с именем "Unknown" и кнопкой "Отправить письмо", которая ничего не делает.

              Это вопрос требований и реализации логики. Где то уместно вывести имя "Unknown" и ничего не делать при "Отправить письмо" , а где то - нет.

              Правило "всегда использовать использовать аннотации @Nullableтам, где может прилететь null"

              Есть разные инструменты работы с nullable объектами, каждый инструмент подходит под свою задачу. О них подробнее напишу в следующей статье.

              Nullable подойдет, чтобы пометить поле в объекте.

              Optional и null-object подойдут для того, чтобы возвратить значение из метода.

              Nullable работает только в compile time.

              Optional и null-object работают в runtime.


              1. m_chrom
                15.10.2024 07:50

                Это вопрос требований и реализации логики. Где то уместно вывести имя "Unknown" и ничего не делать при "Отправить письмо" , а где то - нет.

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

                Nullable подойдет, чтобы пометить поле в объекте.

                А чем плохо помечать Nullable метод?

                Nullable работает только в compile time.

                Так если мы говорим о способе избегать NPE, то есть по сути о борьбе с человеческими ошибками, то compile time вполне достаточно. Ну Optional будет более строгим, да. Но ценой читабельности кода.

                Еще раз уточню с чем я спорю. Я согласен с посылом, что можно использовать Optional для возвращения потенциально null значений (если не пугает объем кода). Я также не вижу проблемы в использовании Null-object для определенных кейсов. Мне показалось излишне строгим предложение никогда не возвращать null из методов. Потому что я не вижу в этом ничего плохого, если при этом не забывать помечать метод соответствующей аннотацией.


                1. WelcomeToRussia Автор
                  15.10.2024 07:50

                  Мне показалось излишне строгим предложение никогда не возвращать null из методов.

                  Допустим нам необходимо получить книгу getBook(String id) и получить количество страниц book.getTotalPages().

                  Рассмотрим 3 случая

                  Nullable

                  //Реализация метода:
                  @Nullable
                  Book getBook(String id) {
                      Book book = // ищем где то книгу, если не найдем вернется null
                      return book;  
                  }
                  
                  //Реализация получения количества страниц,
                  //которая может привести к NullPointerException:
                  totalPages = getBook("Clean code").getTotalPages()  
                  
                  //Если обратить внимание на подсветку от среды разработки
                  // или на алерты от статического анализа
                  // или заглянуть в описание метода,
                  // то пишем безопасный код:
                  int totalPages = 0;
                  Book book = getBook("Clean code");
                  if (book != null) {
                    totalPages = book.getTotalPages()
                  }

                  Optional

                  //Реализация метода:
                  Optional<Book> getBook(String id) {
                      Book book = // ищем где то книгу, если не найдем вернется null
                      return Optional.ofNullable(book);  
                  }
                  
                  //Нативная реализация получения количества страниц,
                  //которая вынуждает работать безопасно:
                  totalPages = getBook("Clean code")
                    .map(book -> book.getTotalPages())
                    .orElse(0)
                  

                  Null-object

                  class Book {
                    public static Book emptyBook() {
                      return //создаем пустую книгу, без страниц
                        // getTotalPages() будет возвращать 0
                    }
                  }
                  
                  //Реализация метода:
                  @Nonnull
                  Book getBook(String id) {
                      Book book = // ищем где то книгу, если не найдем вернется null
                      if (book == null) {
                        return Book.emptyBook();
                      } else {
                        return book;
                      }  
                  }
                  
                  //Реализация получения количества страниц,
                  //которая работает безопасно:
                  totalPages = getBook("Clean code");

                  Итого при реализации getBook

                  с Nullable мы даем возможность ошибиться разработчику при его использовании

                  с Optional мы обязываем обработать отсутствие значение

                  с Null-object мы снимаем с разработчика дополнительную логику и реализовываем сами

                  Не нужно вам возвращать null из метода, уж поверьте. Не мне так популярным фреймворкам, где это применяют.


  1. m_chrom
    15.10.2024 07:50

    del