Большой гайд по Optional в Java

Разбираемся, как уменьшить шанс получить NullPointerException, используя класс Optional.

· 11 минуты на чтение
Большой гайд по Optional в Java

Прежде чем разбираться с Optional, давайте выясним, какую проблему он решает. Представим, что у нас есть класс UserRepository, который работает с хранилищем пользователей, в данном случае — с обычной HashMap.

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

final Person person = personRepository.findById(1L);
final String firstName = person.getFirstName();
System.out.println("Длина твоего имени: " + firstName.length());

Однако выполнение этого кода может привести к следующей ошибке:

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "dev.struchkov.example.optional.Person.getFirstName()" because "person" is null
	at dev.struchkov.example.optional.Main.test(Main.java:18)
	at dev.struchkov.example.optional.Main.main(Main.java:13)

Почему это происходит? Дело в том, что мы не учли, что пользователя с идентификатором 1L может не существовать в HashMap, и метод findById вернёт null. Вот реализация нашего репозитория:

public class PersonRepository {

    private final Map<Long, Person> persons;

    public PersonRepository(Map<Long, Person> persons) {
        this.persons = persons;
    }

    public Person findById(Long id) {
        return persons.get(id);
    }
    
}

Реализация PersonRepository

Если метод вернёт null, то попытка вызова метода у этого объекта приведёт к NullPointerException. Эта проблема встречается не только у новичков — даже опытные разработчики могут сталкиваться с подобной ситуацией.

Изобретение null в 1965 году было моей ошибкой на миллиард долларов. Я не мог устоять перед искушением ввести нулевую ссылку, потому что её было так легко реализовать.

- Создатель концепции null, Тони Хоар

Что же нам делать, чтобы избежать этой проблемы? Мы можем проверять все возвращаемые значения на null:

final Person person = personRepository.findById(1L);
if (person != null) {
    final String firstName = person.getFirstName();
    System.out.println("Длина твоего имени: " + firstName.length());
}

Теперь, если пользователя нет, мы просто не будем выполнять код дальше. Но что, если пользователь есть, а его поле firstNamenull? Тогда снова получим NullPointerException:

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "firstName" is null
	at dev.struchkov.example.optional.Main.test(Main.java:20)
	at dev.struchkov.example.optional.Main.main(Main.java:13)

Чтобы избежать этого, мы добавляем ещё одну проверку:

final User user = userRepository.findById(1L);
if (user != null) {
	final String firstName = user.getFirstName();
	if (firstName != null) {
		System.out.println("Длина твоего имени: " + firstName.length());
	}
}

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

Спонсор поста

Используемые версии

Java 17

История обновлений статьи

17.06.2022: Исправил некоторые неточности и грамматические ошибки. Добавил информацию о методе orElseThrow(). Сделал примеры использования более наглядными.

💡
Прежде чем изучать данную статью, рекомендую ознакомиться со статьей: Функциональные интерфейсы и лямбды в Java.

Введение в Optional

Optional можно воспринимать как контейнер или “коробку”, в которую помещается объект. Он может содержать объект типа T, а может быть пустым. Это всего лишь обёртка, которая помогает избежать работы с null напрямую.

Перепишем наш пример с использованием Optional и посмотрим, что изменилось:

package dev.struchkov.example.optional;

import java.util.Map;
import java.util.Optional;

public class PersonRepository {

    private final Map<Long, Person> persons;

    public PersonRepository(Map<Long, Person> persons) {
        this.persons = persons;
    }

    public Optional<Person> findById(Long id) {
        return Optional.ofNullable(persons.get(id));
    }

}

Теперь метод findById() возвращает не сам объект Person, а Optional<Person>. Посмотрим, как изменится основной код:

final Optional<Person> optPerson = personRepository.findById(1L);
if (optPerson.isPresent()) {
    final Person person = optPerson.get();
    final String firstName = person.getFirstName();
    if (firstName != null) {
    	System.out.println("Длина твоего имени: " + firstName.length());
    }
}

Вы можете сказать: “Это не стало проще, даже добавилось больше кода!” Однако, здесь произошло концептуально важное изменение: теперь точно известно, что метод findById() возвращает контейнер, в котором объект может отсутствовать. Таким образом, больше нет необходимости беспокоиться о внезапных NullPointerException, если только разработчик не решит вернуть null вместо Optional 😅.

В данном примере мы использовали два ключевых метода:

  • isPresent() — возвращает true, если внутри контейнера есть объект.
  • get() — возвращает сам объект, если он присутствует.

Теперь давайте посмотрим, как можно упростить этот код с помощью других методов Optional:

final Optional<Person> optPerson = personRepository.findById(1L);
optUser.map(person -> person.getFirstName())
        .ifPresent(
                firstName -> System.out.println("Длина твоего имени: " + firstName.length())
        );

Мы воспользовались методом map(), который позволяет преобразовать Optional<Person> в Optional<String>. В данном случае — это имя пользователя. Вот ещё более наглядный пример:

final Optional<Person> optPerson = personRepository.findById(1L);
final Optional<String> optFirstName = optPerson.map(user -> user.getFirstName());

optFirstName.ifPresent(
	firstName -> System.out.println("Длина твоего имени: " + firstName.length())
);

Мы преобразовали объект типа Optional<Person> в Optional<String> и затем, используя метод ifPresent(), вывели на консоль длину имени. Код можно сократить ещё сильнее:

personRepository.findById(1L)
        .map(User::getFirstName)
        .ifPresent(
                firstName -> System.out.println("Длина твоего имени: " + firstName.length())
        );

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

Методы Optional

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

Cоздание Optional

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

Метод Optional.ofNullable(T t)

Метод ofNullable() принимает любой объект и создаёт контейнер. Если передать null, будет создан пустой контейнер. Этот метод следует использовать, когда есть вероятность, что объект может быть null.

public class PersonRepository {

    private final Map<Long, Person> persons;

    public PersonRepository(Map<Long, Person> persons) {
        this.persons = persons;
    }

    public Optional<Person> findById(Long id) {
        return Optional.ofNullable(persons.get(id));
    }

}

Здесь мы используем ofNullable(), потому что метод Map.get() может вернуть null.

Метод Optional.of(T t)

Метод of() похож на ofNullable(), но если передать null, он выбросит NullPointerException. Этот метод нужно использовать, когда точно известно, что объект не должен быть null.

public class PersonRepository {

    private final Map<Long, Person> persons;

    public PersonRepository(Map<Long, Person> persons) {
        this.persons = persons;
    }

    public Optional<Person> findByLogin(String login) {
        for (Person person : persons.values()) {
            if (person.getLogin().equals(login)) {
                return Optional.of(person);
            }
        }
        return Optional.empty();
    }

}

В этом примере метод ищет пользователя по логину. Как только мы находим совпадение, вызывается Optional.of(), так как мы уверены, что объект существует.

Метод Optional.empty()

Если объект не найден, нужно вернуть пустой Optional. В таком случае лучше использовать метод empty(), который создаёт пустой контейнер.

return Optional.empty();

Этот метод используется в последнем примере, когда пользователь с переданным логином не найден. Он возвращает пустой Optional, что является предпочтительным решением, нежели вызов Optional.ofNullable(null).

Получение содержимого

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

Метод isPresent()

Прежде чем достать объект из Optional, нужно убедиться, что он там действительно есть. Для этого служит метод isPresent(), который возвращает true, если объект присутствует, и false — если нет.

Optional<Person> optPerson = personRepository.findById(1L);

if(optPerson.isPresent()) {
	// если пользователь найден, то выполняем этот блок кода
}

Этот метод фактически работает как проверка на null. Внутри его реализации это выглядит так:

public boolean isPresent() {
	return value != null;
}

Метод isEmpty()

isEmpty() — это противоположность методу isPresent(). Он возвращает true, если объект отсутствует, и false, если объект есть. Реализация аналогична:

public boolean isEmpty() {
	return value == null;
}

Метод get()

После проверки с помощью методов isPresent() или isEmpty(), можно извлечь объект из контейнера с помощью метода get(). Пример:

Optional<Person> optPerson = personRepository.findById(1L);

if(optPerson.isPresent()) {
	final Person person = optPerson.get();
    
	// остальной ваш код
}
🚨
Стоит отметить, что вызов get() без проверки приведёт к исключению NoSuchElementException, если объект внутри отсутствует.

Метод ifPresent()

Метод ifPresent() позволяет выполнить действия над объектом, если он присутствует, не используя явную проверку. Он принимает функциональный интерфейс Consumer, который описывает логику, выполняемую с объектом.

personRepository.findById(id).ifPresent(
        person -> System.out.println(person.getFirstName() + " " + person.getLastName())
);
Функциональные интерфейсы и лямбды в Java
Эволюция Java-кода: от анонимных классов к лямбда-выражениям.

Метод ifPresentOrElse()

Метод ifPresentOrElse() позволяет выполнить одну операцию, если объект присутствует, и другую — если его нет. Это удобно, когда требуется обработать оба случая. В отличие от ifPresent(), который ничего не делает при отсутствии объекта, этот метод принимает два аргумента: функциональный интерфейс Consumer для случая, когда объект присутствует, и Runnable для случая, когда объект отсутствует.

personRepository.findById(id).ifPresentOrElse(
        person -> System.out.println(person.getFirstName() + " " + person.getLastName()),
        () -> System.out.println("Иван Иванов")
);

Здесь, если пользователя нет, на консоль будет выведено “Иван Иванов”.

Метод orElse()

Метод orElse() возвращает значение из контейнера, если оно есть, или значение по умолчанию, если объект отсутствует. Этот метод полезен, когда необходимо иметь заранее определённое значение для случая отсутствия данных.

public Person getPersonOrAnon(Long id) {
    return personRepository.findById(id)
            .orElse(new Person(-1L, "anon", "anon", "anon"));
}
💢
Некоторым приходит в голову довольно странная конструкция: objectOptional.orElse(null), которая лишает использование Optional всякого смысла. Никогда так не делайте, и бейте по рукам тем, кто так делает.

Метод orElseGet()

Метод orElseGet() похож на orElse(), но вместо того, чтобы сразу вернуть значение по умолчанию, он вызывает функцию (интерфейс Supplier), которая его генерирует. Это удобно, если нужно выполнить дополнительную логику при отсутствии значения.

public Person getPersonOrAnonWithLog(Long id) {
    return personRepository.findById(id)
            .orElseGet(() -> {
                System.out.println("Пользователь не был найден, отправляем анонимного");
                return new Person(-1L, "anon", "anon", "anon");
            });
}

Используйте этот метод, если необходимо не просто вернуть дефолтное значение, но и выполнить дополнительную логику.

Метод orElseThrow()

Метод orElseThrow() выбросит исключение, если объект отсутствует, вместо того чтобы возвращать значение по умолчанию. По умолчанию метод бросает NoSuchElementException, если объект не найден.

Метод orElseThrow(Supplier s)

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

public Person getPersonOrThrow(Long id) {
    return personRepository.findById(id)
            .orElseThrow(() -> new NotFoundException("Пользователь не найден"));
}

Элегантный orElseThrow()

Рассмотрим более эстетичный вариант использования метода orElseThrow():

public Person getPersonOrElegantThrow(Long id) {
    return personRepository.findById(id)
            .orElseThrow(notFoundException("Пользователь {0} не найден", id));
}

На мой взгляд, это один из самых элегантных способов обработки исключений. Для его реализации необходимо, чтобы в классе исключения был метод, возвращающий Supplier. Это позволяет значительно упростить код и повысить его читаемость.

Пример класса NotFoundException, где мы добавили два метода notFoundException(): один принимает простую строку, другой использует MessageFormat.format() для форматирования сообщения с параметрами.

import java.text.MessageFormat;
import java.util.function.Supplier;

public class NotFoundException extends RuntimeException {

    public NotFoundException(String message) {
        super(message);
    }

    public NotFoundException(String message, Object... args) {
        super(MessageFormat.format(message, args));
    }

    public static Supplier<NotFoundException> notFoundException(String message, Object... args) {
        return () -> new NotFoundException(message, args);
    }

    public static Supplier<NotFoundException> notFoundException(String message) {
        return () -> new NotFoundException(message);
    }

}

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

Преобразование

Класс Optional предлагает методы для преобразования данных, такие как map, filter и flatMap. Они имеют аналогичное поведение с методами в стримах, и их использование делает код более элегантным и функциональным.

Метод filter()

Метод filter() проверяет, удовлетворяет ли значение внутри контейнера заданному условию (переданному через функциональный интерфейс Predicate). Если условие выполнено, возвращается новый Optional с этим значением, в противном случае — пустой Optional.

Пример: метод, который возвращает только взрослых пользователей:

public Optional<Person> getAdultById(Long id) {
    return personRepository.findById(id)
            .filter(person -> person.getAge() > 18);
}

Используйте filter(), если необходимо получить объект, который удовлетворяет какому-то условию.

Метод map()

Метод map() применяется, когда нужно преобразовать объект внутри контейнера. Если объект присутствует, переданная функция преобразует его, а результат оборачивается в новый Optional. Если объект отсутствует, возвращается пустой контейнер.

Пример: метод, который возвращает имя и фамилию пользователя:

public Optional<String> getFirstAndLastNames(Long id) {
    return personRepository.findById(id)
            .map(person -> person.getFirstName() + " " + person.getLastName());
}

Используйте map(), когда нужно преобразовать объект внутри контейнера в другой объект.

Метод flatMap()

Метод map() вернет Optional<Optional<T>>, если функция, переданная в map(), возвращает Optional. Чтобы избежать двойного обёртывания, используется flatMap(), который “распаковывает” вложенные контейнеры.

Пример: получение телефонного номера пользователя из другого сервиса:

Optional<Optional<String>> optUserPhoneNumber = personRepository.findById(1L)
        .map(person -> {
            Optional<String> optPhoneNumber = phoneNumberRepository.findByPersonId(person.getId());
            return optPhoneNumber;
        });

Используйте flatMap(), чтобы избежать вложенных Optional и работать с объектом напрямую.

Optional<String> optUserPhoneNumber = personRepository.findById(1L)
        .flatMap(person -> {
            Optional<String> optPhoneNumber = phoneNumberRepository.findByLogin(user.getLogin());
            return optPhoneNumber;
        });

Метод stream()

С Java 9 был добавлен метод stream(), который позволяет преобразовать Optional в стрим. Это особенно полезно при работе с коллекциями Optional, так как все пустые контейнеры автоматически исключаются.

Допустим, вы запрашивали множество пользователей по различным идентификаторам, а в ответ вам возвращался Optional<Person>. Вы сложили полученные объекты в коллекцию, и теперь с ними нужно как-то работать. Можно создать стрим от этой коллекции, и используя метод flatMap() стрима и метод stream() у Optional, чтобы получить стрим существующих пользователей. При этом все пустые контейнеры будут отброшены.

final List<Optional<Person>> optPeople = new ArrayList<>();
final List<Person> people = optPeople.stream()
        .flatMap(optItem -> optItem.stream())
        .toList();

Метод or()

С Java 11 появился метод or(), который возвращает новый Optional, если текущий контейнер пуст. Он принимает Supplier и создаёт новый объект, если внутри контейнера ничего нет.

Пример: возвращаем анонимного пользователя, если по идентификатору ничего не найдено:

public Optional<Person> getPersonOrAnonOptional(Long id) {
    return personRepository.findById(id)
            .or(() -> Optional.of(new Person(-1L, "anon", "anon", "anon", 0L)));
}
⚠️
Метод or() не изменяет исходный объект Optional, а возвращает новый.

Комбинирование методов

Все методы, которые возвращают Optional, можно комбинировать в цепочки, подобно методам работы со стримами.

Пример, иллюстрирующий цепочку методов (хотя и немного абстрактный):

final LocalDateTime now = LocalDateTime.now();
final DayOfWeek dayWeek = Optional.of(now)
        .map(LocalDateTime::getDayOfWeek)
        .filter(dayOfWeek -> DayOfWeek.SUNDAY.equals(dayOfWeek))
        .orElse(DayOfWeek.MONDAY);

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

Прочие нюансы

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

Когда стоит использовать Optional

Если открыть документацию по Optional, можно найти ответ на этот вопрос. Основное правило:

💁
Optional в первую очередь предназначен для использования в качестве типа возвращаемого значения метода, когда существует явная необходимость представлять «отсутствие результата» и где использование null может вызвать ошибки.

Как НЕ стоит использовать Optional

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

Как параметр метода

Не используйте Optional в качестве параметра метода. Это создаёт путаницу для пользователей метода. Если они не знают, что должны передать Optional, они могут случайно передать null, что приведёт к NullPointerException. Вместо этого лучше использовать стандартные проверки на null внутри метода.

Как свойство класса

Не стоит использовать Optional для объявления полей класса по нескольким причинам:

  • Совместимость с фреймворками. Например, фреймворки вроде Spring Data или Hibernate не могут напрямую мапить поля из базы данных на Optional, если не использовать кастомные конвертеры. Однако фреймворки вроде Jackson хорошо интегрируют Optional, что может быть полезно в редких случаях, таких как обработка входящих данных через Webhook, когда вы не контролируете создание объекта.
  • Производительность и память. Optional создаёт дополнительный объект, который обычно нужен лишь на короткое время. Если использовать его в качестве поля класса, он может оставаться в памяти дольше, что не критично для маленьких приложений, но в больших системах может повлиять на производительность.
  • Сериализация. Optional не реализует интерфейс Serializable, что делает его неудобным для полей, которые должны быть сериализованы. В результате, классы с такими полями нельзя сериализовать напрямую, что может вызвать проблемы при работе с распределёнными системами или передачей данных.

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

Как обертка коллекции

Не оборачивайте коллекции в Optional. Коллекции сами по себе являются контейнерами. Вместо возврата null, используйте методы для возврата пустых коллекций, такие как Collections.emptyList(), Collections.emptySet() и другие.

Optional не должен равняться null

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

Лучшее решение — используйте Optional.empty() вместо null:

Примитивы и Optional

Для работы с примитивами существуют специализированные классы: OptionalDouble, OptionalInt и OptionalLong. Эти классы помогают избежать ненужных автоупаковок и распаковок примитивных значений в объекты (например, intInteger).

Однако, на практике они используются довольно редко. Основная причина в том, что они предлагают ограниченное количество методов по сравнению с обычным Optional. В этих классах доступны только основные методы: get(), orElse(), orElseGet(), orElseThrow(), ifPresent() и isPresent(). Методы преобразования, такие как map() или flatMap(), в них отсутствуют.

Для работы с обертками примитивов есть java.util.OptionalDouble, java.util.OptionalInt и java.util.OptionalLong, которые позволяют избегать лишних автоупаковок и распаковок. Однако не смотря на это, на практике используются они крайне редко.

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

Резюмирую

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

Основные моменты, которые стоит запомнить

  • Если метод должен вернуть null, лучше верните Optional.
  • Не используйте Optional в качестве параметра метода или свойства класса.
  • Всегда проверяйте наличие значения в Optional перед тем, как извлечь его содержимое.
  • Избегайте конструкций типа: optional.orElse(null) — это противоречит самой идее использования Optional.

Дополнительные ссылки по теме

Struchkov Mark
Struchkov Mark
Задавайте вопросы, если что-то осталось не понятным👇