Java

Предыдущая часть: Часть 1

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

Возвращайте Optional вместо null

Хоть в предыдущей статье я и убеждал вас в уместности null для некоторых сценариев, в этой я буду напротив говорить, что по возможности его все же лучше избегать. При каждом добавлении в базу кода нового метода, способного вернуть пустое значение, вам следует стараться использовать вместо null тип Optional. Этот тип был введен, чтобы разработчики могли указывать на возможность возвращения методом пустого значения, что ранее было недоступно (либо требовало аннотаций, о которых я расскажу ниже). Существует два вида объектов Optional: содержащие значение и без него. Первые изначально создаются со значением, вторые же просто являются статическими одиночками. Структурно типичный случай их использования выглядит так:

if (checkSomeCondition(someValue)){
    return Optional.of(someValue);
} else {
    return Optional.empty();
}

Чтобы увидеть, почему сигнатура с Optional предпочтительней, представьте следующий метод:

public Admission getAdmission(Patient patient)

Из этой сигнатуры понятно лишь, что она возвращает объект типа Admission. При этом, как уже говорилось, null соответствует любому типу. У нас нет иной возможности узнать, возвращает ли данный метод null, кроме как проанализировать его реализацию. В противоположность приведенному примеру, метод, возвращающий Optional, будет выглядеть так:

public Optional<Admission> getAdmission(Patient patient)

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

Возвращаемое значение Optional обрабатывается аналогично null: вы проверяете, передано ли значение, и, если да — выполняете с ним определенные действия. Вызов метода, возвращающего null, как правило, выглядит так:

Admission admission = getAdmission(Patient patient);
if (admission != null) {
    // действия с данными о госпитализации
}

Со значением Optional соответствующий код будет выглядеть так:

Optional<Admission> admissionOptional = getAdmission(Patient patient);
if (admissionOptional.isPresent()) {
    Admission admission = admissionOptional.get();
    // действия с данными о госпитализации
}

В то время как можно легко забыть выполнить проверку на null в первом примере, тип Optional безопаснее и делает очевидным возможное отсутствие возвращаемого значения. Если вы вызовите get() для пустого значения Optional, то получите исключение, как и в случае вызова любого метода для значения null. В самых простых случаях перед вызовом get() всегда нужно вызывать isPresent(), однако API предлагает и другие альтернативы, где при отсутствии значения может возвращаться его предустановленный вариант.

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

Optional<Admission> admissionOptional = Optional.empty()for (Admission admission : admissions) {
    if (admission.isActive()) {
      admissionOptional= admission;
      break;
    }
}if (admissionOptional.isPresent()) {
    // Выполнение действий
} else {
    // Выполнение других действий
}

Однако если бы вместо этого у нас был метод, возвращающий активную госпитализацию, то предпочтительнее было бы использовать Optional:

for (Admission admission : admissions) {
    if (admission.isActive()) {
      return Optional.of(admission);
    }
}
return Optional.empty();

Можно ли использовать Optional для параметров?

SonarLint отвечает “Нет”, поскольку лучше предоставить действительное значение, чем Optional, которое может его иметь или нет. Ведь иначе нам придется проверять в методе оба случая, а также убеждаться, что никто не передал вместо него null. Смешивать же null и Optional вообще не стоит, иначе смысл в использовании последнего полностью исчезнет. 

Никогда не делайте следующее, равно как не предоставляйте null в качестве аргумента параметра с Optional значением:

Optional<Object> optional = null; // Плохо!

Если вы сделаете нечто подобное, то также получите NPE (исключение нулевого указателя).

Optional<Object> optional = Optional.of(null);

Если у вас есть некое значение, которое, вероятно, содержит null, и вы хотите преобразовать его в Optional, то вам нужно вызвать вместо этого Optional.ofNullable(), который вернет обернутое в Optional значение, если аргумент не является null. В противном случае он вернет пустой Optional. Если же вам нужно выполнить обратную операцию, т.е. преобразовать, вероятно, пустое значение Optional в null или значение, которое оно содержит, используйте orElse(null), который при отсутствии значения возвращает предоставленный аргумент.

Содействуйте IDE с помощью аннотаций Nullable/Nonnull

Для большинства баз кода полное отсутствие null в передаче данных является утопией. Еще одним способом прояснить ваши намерения и одновременно помочь системе типов помочь вам — это использовать для параметров и возвращаемых значений аннотации, которые указывают, где стоит ожидать null, а где нет, а также где его использование предполагается. Такую возможность предлагают рзаные фреймворки, но основная идея одна: параметр можно снабдить либо тегом Nonnull, сообщая, что значение null для него ожидать не нужно, либо Nullable, указывая обратное. Добавление тегов допустимо также для полей и возвращаемых значений. 

Вот один из примеров подобной сигнатуры метода:

@Nullable
public Admission getAdmission(@Nonnull Patient patient) {
  ...
}

Сейчас эта сигнатура показывает, что метод не предполагает вызов с параметром Patient, содержащим null, и если такой вызов произойдет, IDE выдаст предупреждение. При этом мы также проинформированы, что данный метод может вернуть null, и вы аналогичным образом получите предупреждение при использовании этого возвращаемого значения метода.

Если новым методам в возвращаемых значениях следует использовать Optional вместо null, то выполнение обширного рефакторинга существующих методов обычно в ходе разработки функционала не рассматривается. Тем не менее, когда вы пишите код, то простое добавление тега после каждой проверки, допускает ли значение null, окажет помощь всем, кто будет использовать этот код в будущем, и поможет избежать NPE.

Используйте Objects.requireNonNull()

Аннотации имеют значение только во время компиляции и не влияют на поведение программы при выполнении. Если вам нужно, чтобы при выполнении метод не вызывался с null-аргументом, то следует использовать метод Objects.requireNonNull(T obj, строка сообщения). Объекты  —  это статический служебный класс для выполнения основных операций вроде equals, hashCode и toString, защищенным от null способом. 

Чтобы оградить метод от нулевых аргументов, передайте в метод requireNonNull() соответствующие параметры вместе с описательным текстом ошибки. Если параметр окажется null, это вызовет NPE с указанным вами сообщением об ошибке. В противном случае данный метод просто вернет параметр. Обычно его используют в конструкторах, как показано ниже:

public Foo(Bar bar, Baz baz, Qux qux) {
  this.bar = Objects.requireNonNull(bar, “bar must not be null”);
  this.baz = Objects.requireNonNull(baz, “baz must not be null”);
  this.qux = qux; // qux is allowed to be null
}

Здесь у вас может возникнуть вопрос, заключается ли причина избегания нулевого параметра в том, что он может ненамеренно привести к NPE. Не будет ли лучше выбросить NPE намеренно до того, как это произойдет?

Скорее всего, это одно и то же, и с позиции пользователя выглядеть это будет тоже одинаково  —  в виде появления ошибки. А вот при диагностировании причины сбоя в дальнейшем, последний вариант окажется гораздо предпочтительнее. Проблема с NPE в том, что они не сообщают, какая переменная оказалась null, а просто предоставляют номер строки. В строке, где для объектов вызывается несколько методов, может быть сложно определить, в каком из них причина. 

Предположим, вы получаете NPE в следующей строке кода:

computeStuff(bar.getValue(), baz.getAnotherValue(), qux.getSomeObject().getStuff());

Какая из переменных была null? И bar, и baz, и qux могли оказаться нулевыми. Если qux не была null, то объект, который она возвращает, мог. Потребуется потратить какое-то время на выяснение, какой из этих сценариев оказался виновником. 

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

Работа со строками

Я часто вижу баги там, где логике при генерации строк не удается учесть нулевые значения, в результате чего среди строк пользовательского интерфейса появляется null. Причина такого коварного поведения в том, что в Java при конкатенации строк неожиданное нулевое значение не ведет к NPE до тех пор, пока для него явно не вызываются методы. Если мы заглянем внутрь JRE, то выясним, что null-значения часто явно обрабатываются и преобразуются в null— значения. Каждый раз, когда мы используем сокращение “+”, генерируется StringBuilder и используется для создания объединенного значения. Это означает, что правосторонние значения присваиваний s3 и s4 по факту эквивалентны:

String s1 = “hello “;
String s2 = null;
String s3 = s1 + s2;
String s4 = new StringBuilder(String.valueOf(s1)).append(s2).toString();

В обоих случаях получится строка hello null. Конкатенацию также можно выполнить при помощи s1.concat(s2), и это, что интересно, будет вызывать NPE всякий раз, когда s2 будет null. Тем не менее метод concat не является предпочтительным несмотря на то, что справляется лучше StringBuilder при конкатенации всего нескольких строк. В связи с этим мы, как правило, будем обнаруживать null-строки лишь в UI. 

Используйте класс Objects и библиотеку StringUtils 

Ошибки нередко прокрадываются в базу кода, когда у нас нет ясного понимания того, что код делает или почему он это делает. Здесь важно удобство в обслуживании. Я часто сталкиваюсь с запутанным кодом создания строки. По какой-то причине люди склонны увлекаться тернарными выражениями, помещая их в другие выражения или вкладывая друг в друга. Это очень эффективно усложняет чтение и понимание простой логики. 

Тернарный оператор часто используется для возвращения предустановленного значения, когда переменная содержит null. Ниже приведен пример этого, взятый прямо из нашей базы кода Columna:

String s = (form != null ? form : "-") + " " + (nameAndStrength != null ? nameAndStrength: "");

Примечательно, что это не самый сложный для анализа код, но я думаю, что его можно написать гораздо лучше. В C# существует так называемый оператор объединения с неопределенным значением, относящийся к тернарному оператору, явно проверяющему значение на null. Вот пример:

String s = form ?? "-" + " " + nameAndStrength ?? "-";

Здесь левое значение оператора присваивается, если оно не null, в противном случае используется правая сторона. Несмотря на то, что в Java недостает подобного оператора, с той же целью можно использовать встроенный класс Objects, описанный выше. Метод toString(Object o, String s) класса Objects возвращает значение toString() первого объекта, если этот объект не null, в противном случае он возвращает указанное строковое значение. 

String hyphen = "";
String s = Objects.toString(form, hyphen) + " " + Objects.toString(nameAndStrength, hyphen);

Не так складно, как в примере с C#, но, на мой взгляд, намного надежнее, чем исходный пример, а значит и шанс появления ошибок при работе с кодом уже существенно меньше. 

Используйте StringUtils для защиты String-методов от null

Библиотека StringUtils упрощает обработку null и пустых строк. В API это описывается как: “Защищенные от null операции со String.” Эта библиотека содержит много стандартных строковых операций со встроенными проверками на null. При ее использовании база кода станет менее громоздкой, так как вам не потребуется реализовывать весь рутинный код проверок на null, и читать условные выражения станет легче. При этом база кода также станет более единообразной, и что более важно, код станет безопаснее, так как вы уже ненароком не забудете реализовать проверки нулевых значений. В частности, такая агрессивная защита выражений конкатенации с условными инструкциями на основе метода isBlank(s) гарантирует, что в строки уже не проскользнут нежелательные “null”-значения.

Заключение

Вкратце содержание рассмотренной серии статей можно выразить так:

  • В ряде случаев применение null допустимо. Не бойтесь его использовать там, где он имеет смысл как часть моделирования заданной области.
  • Минимизируйте использование null и не применяйте там, где другие его не ждут  —  следите за интерфейсом между методами. 
  • Не задействуйте null для неявного указания ошибок  —  лучше выбрасывайте явное исключение. 
  • Используйте перегрузку метода и шаблон Builder для избежания передачи null-значений в вызовы метода. 
  • Никогда не присваивайте переменной типа коллекции значение null  —  используйте Collections.emptyList(), emptySet(), emptyMap() для создания пустых структур данных. 
  • При возврате пустых значений используйте Optional вместо null, чтобы вызывающие ожидали пустое значение и обеспечивали для него соответствующую обработку. 
  • При невозможности применить Optional  —  явно выразите ваши намерения обработки/не обработки null-параметров и возвращения/не возвращения null в методах при помощи аннотаций NonNull/Nullable.
  • Используйте Objects.requireNonNull() для намеренного провала при нежелательных null-параметрах.
  • Высматривайте коварные null-строки и используйте класс Objects и библиотеку StringUtils для изящной обработки null.

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Jens Christian B. Madsen: Part 2: Avoiding Null-Pointer Exceptions in a Modern Java Application