Перейти к основному содержимому

Избегайте проверки на нулевой оператор в Java

· 12 мин. чтения

1. Обзор

Как правило, нулевые переменные, ссылки и коллекции сложно обрабатывать в коде Java. Их не только трудно идентифицировать, но и сложно с ними справляться.

На самом деле любой промах при работе с нулевым значением не может быть идентифицирован во время компиляции и приводит к исключению NullPointerException во время выполнения.

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

2. Что такое исключение NullPointerException ?

Согласно Javadoc для NullPointerException , он возникает, когда приложение пытается использовать null в случае, когда требуется объект, например:

  • Вызов метода экземпляра нулевого объекта
  • Доступ или изменение поля нулевого объекта
  • Принимая длину нуля , как если бы это был массив
  • Доступ или изменение слотов null , как если бы это был массив
  • Выбрасывание null , как если бы это было значение Throwable

Давайте быстро рассмотрим несколько примеров кода Java, которые вызывают это исключение:

public void doSomething() {
String result = doSomethingElse();
if (result.equalsIgnoreCase("Success"))
// success
}
}

private String doSomethingElse() {
return null;
}

Здесь мы попытались вызвать вызов метода для нулевой ссылки. Это приведет к NullPointerException .

Другой распространенный пример: если мы попытаемся получить доступ к нулевому массиву:

public static void main(String[] args) {
findMax(null);
}

private static void findMax(int[] arr) {
int max = arr[0];
//check other elements in loop
}

Это вызывает исключение NullPointerException в строке 6.

Таким образом, доступ к любому полю, методу или индексу нулевого объекта вызывает исключение NullPointerException , как видно из приведенных выше примеров.

Обычный способ избежать исключения NullPointerException — проверить значение null :

public void doSomething() {
String result = doSomethingElse();
if (result != null && result.equalsIgnoreCase("Success")) {
// success
}
else
// failure
}

private String doSomethingElse() {
return null;
}

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

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

3. Обработка null через контракт API

Как обсуждалось в предыдущем разделе, доступ к методам или переменным нулевых объектов вызывает исключение NullPointerException . Мы также обсудили, что проверка объекта на null перед доступом к нему устраняет возможность исключения NullPointerException .

Однако часто существуют API, которые могут обрабатывать нулевые значения:

public void print(Object param) {
System.out.println("Printing " + param);
}

public Object process() throws Exception {
Object result = doSomething();
if (result == null) {
throw new Exception("Processing fail. Got a null response");
} else {
return result;
}
}

Вызов метода print() просто напечатает «null», но не вызовет исключение. Точно так же process() никогда не вернет null в своем ответе. Скорее выдает Exception .

Таким образом, для клиентского кода, обращающегося к вышеуказанным API, нет необходимости в нулевой проверке.

Однако такие API должны явно указать это в своем контракте. Обычно API-интерфейсы публикуют такой контракт в Javadoc.

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

В следующем разделе мы увидим, как некоторые IDE и другие инструменты разработки помогают разработчикам в этом.

4. Автоматизация контрактов API

4.1. Использование статического анализа кода

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

FindBugs помогает управлять нулевым контрактом с помощью аннотаций @Nullable и @NonNull . Мы можем использовать эти аннотации для любого метода, поля, локальной переменной или параметра. Это делает явным для клиентского кода, может ли аннотированный тип быть нулевым или нет.

Давайте посмотрим пример:

public void accept(@NonNull Object param) {
System.out.println(param.toString());
}

Здесь @NonNull дает понять, что аргумент не может быть нулевым . Если клиентский код вызывает этот метод без проверки аргумента на значение null, FindBugs выдаст предупреждение во время компиляции.

4.2. Использование поддержки IDE

Разработчики обычно полагаются на IDE для написания кода Java. И такие функции, как интеллектуальное завершение кода и полезные предупреждения, например, когда переменная не была назначена, безусловно, очень помогают.

Некоторые IDE также позволяют разработчикам управлять контрактами API и, таким образом, устраняют необходимость в инструменте статического анализа кода. IntelliJ IDEA предоставляет аннотации @NonNull и @Nullable .

Чтобы добавить поддержку этих аннотаций в IntelliJ, нам нужно добавить следующую зависимость Maven:

<dependency>
<groupId>org.jetbrains</groupId>
<artifactId>annotations</artifactId>
<version>16.0.2</version>
</dependency>

Теперь IntelliJ будет генерировать предупреждение, если проверка на null отсутствует, как в нашем последнем примере.

IntelliJ также предоставляет аннотацию Contract для обработки сложных контрактов API.

5. Утверждения

До сих пор мы говорили только об устранении необходимости нулевых проверок из клиентского кода. Но это редко применимо в реальных приложениях.

Теперь давайте предположим, что мы работаем с API, который не может принимать пустые параметры или может возвращать нулевой ответ, который должен обрабатываться клиентом. Это требует от нас проверки параметров или ответа на нулевое значение.

Здесь мы можем использовать Java Assertions вместо традиционного условного оператора null check:

public void accept(Object param){
assert param != null;
doSomething(param);
}

В строке 2 мы проверяем нулевой параметр. Если утверждения включены, это приведет к AssertionError .

Хотя это хороший способ утверждения предварительных условий, таких как ненулевые параметры, этот подход имеет две основные проблемы :

  1. Утверждения обычно отключены в JVM.
  2. Ложное утверждение приводит к непроверенной ошибке, которая не может быть устранена.

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

6. Избегайте нулевых проверок с помощью практики кодирования

6.1. Предварительные условия

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

Давайте рассмотрим два метода — один, который дает сбой раньше, а другой — нет:

public void goodAccept(String one, String two, String three) {
if (one == null || two == null || three == null) {
throw new IllegalArgumentException();
}

process(one);
process(two);
process(three);
}

public void badAccept(String one, String two, String three) {
if (one == null) {
throw new IllegalArgumentException();
} else {
process(one);
}

if (two == null) {
throw new IllegalArgumentException();
} else {
process(two);
}

if (three == null) {
throw new IllegalArgumentException();
} else {
process(three);
}
}

Ясно, что мы должны предпочесть goodAccept() , а не badAccept () .

В качестве альтернативы мы также можем использовать предварительные условия Guava для проверки параметров API.

6.2. Использование примитивов вместо классов-оболочек

Поскольку null не является приемлемым значением для таких примитивов, как int , мы должны по возможности предпочесть их аналогам-оболочкам, таким как Integer .

Рассмотрим две реализации метода, суммирующего два целых числа:

public static int primitiveSum(int a, int b) {
return a + b;
}

public static Integer wrapperSum(Integer a, Integer b) {
return a + b;
}

Теперь давайте вызовем эти API в нашем клиентском коде:

int sum = primitiveSum(null, 2);

Это приведет к ошибке времени компиляции, поскольку null не является допустимым значением для int .

А при использовании API с классами-обертками мы получаем NullPointerException :

assertThrows(NullPointerException.class, () -> wrapperSum(null, 2));

Существуют и другие факторы использования примитивов вместо оболочек, как мы рассмотрели в другом руководстве Java Primitives Versus Objects .

6.3. Пустые коллекции

Иногда нам нужно вернуть коллекцию в качестве ответа от метода. Для таких методов мы всегда должны пытаться вернуть пустую коллекцию вместо null :

public List<String> names() {
if (userExists()) {
return Stream.of(readName()).collect(Collectors.toList());
} else {
return Collections.emptyList();
}
}

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

7. Использование объектов

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

Давайте рассмотрим один из таких методов, requireNonNull() :

public void accept(Object param) {
Objects.requireNonNull(param);
// doSomething()
}

Теперь давайте протестируем метод accept() :

assertThrows(NullPointerException.class, () -> accept(null));

Таким образом, если в качестве аргумента передается null , accept() выдает исключение NullPointerException .

Этот класс также имеет методы isNull() и nonNull() , которые можно использовать в качестве предикатов для проверки объекта на значение null .

8. Использование опционального

8.1. Использование orElseThrow

Java 8 представила новый необязательный API в языке. Это предлагает лучший контракт для обработки необязательных значений по сравнению с null .

Давайте посмотрим, как Optional избавляет от необходимости проверки null :

public Optional<Object> process(boolean processed) {
String response = doSomething(processed);

if (response == null) {
return Optional.empty();
}

return Optional.of(response);
}

private String doSomething(boolean processed) {
if (processed) {
return "passed";
} else {
return null;
}
}

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

Это заметно устраняет необходимость в каких-либо проверках на null в клиентском коде. Пустой ответ можно обработать по-разному, используя декларативный стиль Дополнительного API:

assertThrows(Exception.class, () -> process(false).orElseThrow(() -> new Exception()));

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

Хотя мы устранили необходимость нулевой проверки вызывающей стороны этого API, мы использовали ее для возврата пустого ответа.

Чтобы избежать этого, Optional предоставляет метод ofNullable , который возвращает Optional с указанным значением или empty , если значение равно null :

public Optional<Object> process(boolean processed) {
String response = doSomething(processed);
return Optional.ofNullable(response);
}

8.2. Использование необязательного с коллекциями

При работе с пустыми коллекциями опционал пригодится:

public String findFirst() {
return getList().stream()
.findFirst()
.orElse(DEFAULT_VALUE);
}

Эта функция должна возвращать первый элемент списка. Функция findFirst Stream API вернет пустой необязательный параметр , если данных нет. Здесь мы использовали orElse вместо значения по умолчанию. ``

Это позволяет нам обрабатывать как пустые списки, так и списки, которые после использования метода фильтрации библиотеки Stream не содержат элементов для предоставления. ``

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

public Optional<String> findOptionalFirst() {
return getList().stream()
.findFirst();
}

Следовательно, если результат getList пуст, этот метод вернет клиенту пустой необязательный элемент.

Использование необязательного с коллекциями позволяет нам разрабатывать API, которые обязательно возвращают ненулевые значения, что позволяет избежать явных нулевых проверок на клиенте.

Здесь важно отметить, что эта реализация основана на том, что getList не возвращает null. Однако, как мы обсуждали в предыдущем разделе, часто лучше возвращать пустой список, а не нуль .

8.3. Объединение опций

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

Давайте возьмем наш пример с getList из предыдущего. Что, если бы он возвращал необязательный список или должен был быть обернут методом, который оборачивает ноль с помощью опционального с помощью ofNullable ?

Наш метод findFirst хочет вернуть необязательный первый элемент необязательного списка:

public Optional<String> optionalListFirst() {
return getOptionalList()
.flatMap(list -> list.stream().findFirst());
}

С помощью функции flatMap для option , возвращаемого из getOptional , мы можем распаковать результат внутреннего выражения, которое возвращает option . Без flatMap результатом будет Optional<Optional<String>> . Операция flatMap выполняется только в том случае, если необязательный не пуст.

9. Библиотеки

9.1. Использование Ломбока

Lombok — отличная библиотека, которая уменьшает количество шаблонного кода в наших проектах. Он поставляется с набором аннотаций, которые заменяют общие части кода, которые мы часто пишем сами в приложениях Java, таких как геттеры, сеттеры и toString() , и это лишь некоторые из них.

Еще одна его аннотация — @NonNull . Таким образом, если проект уже использует Lombok для устранения стандартного кода, @NonNull может заменить необходимость проверки нулей .

Прежде чем мы перейдем к некоторым примерам, давайте добавим зависимость Maven для Lombok:

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
</dependency>

Теперь мы можем использовать @NonNull везде, где нужна проверка на null :

public void accept(@NonNull Object param){
System.out.println(param);
}

Итак, мы просто аннотировали объект, для которого потребовалась бы проверка на null , и Lombok генерирует скомпилированный класс:

public void accept(@NonNull Object param) {
if (param == null) {
throw new NullPointerException("param");
} else {
System.out.println(param);
}
}

Если param имеет значение null , этот метод генерирует исключение NullPointerException . Метод должен сделать это явным в своем контракте, а клиентский код должен обработать исключение.

9.2. Использование StringUtils

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

Следовательно, это будет общий оператор проверки:

public void accept(String param){
if (null != param && !param.isEmpty())
System.out.println(param);
}

Это быстро становится излишним, если нам приходится иметь дело с большим количеством типов String . Вот где StringUtils пригодится.

Прежде чем мы увидим это в действии, давайте добавим зависимость Maven для commons-lang3 :

<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version>
</dependency>

Теперь давайте рефакторим приведенный выше код с помощью StringUtils :

public void accept(String param) {
if (StringUtils.isNotEmpty(param))
System.out.println(param);
}

Итак, мы заменили нашу нулевую или пустую проверку статическим служебным методом isNotEmpty() . Этот API предлагает другие мощные служебные методы для обработки общих функций String .

10. Заключение

В этой статье мы рассмотрели различные причины NullPointerException и почему их трудно идентифицировать.

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

Все примеры доступны на GitHub .