Большой гайд по 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. И у нас нет варианта, кроме как вернуть 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. Это головная боль новичков в программировании. Но неважно, новичок ли вы в Java или за плечами у вас десять лет опыта — всегда есть вероятность, что вы столкнетесь с NullPointerException.

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

А мы теперь имеем то, что имеем. Так что же делать в таком случае? Можно проверять все возвращаемые значения на null, вот так:

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

Теперь если пользователя нет, то мы просто не выполняем код? Допустим, что пользователь с таким идентификатором все же есть, выполняем код:

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)

И мы снова получили NullPointerException, только на этот раз проблема оказалась в поле firstName, теперь оно null, и уже вызов метода length() выбросил исключение. Не беда, добавим еще одну проверку:

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

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

Так приходилось жить до появления Optional в Java. Что же имеем теперь?

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

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

Java 17

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

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

Введение в Optional

Можно воспринимать Optional, как некую коробку, обертку, в которую кладется какой-либо объект. Optional всего лишь контейнер: он может содержать объект некоторого типа Т, а может быть пустым.

Перепишем наш пример с использованием 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));
    }

}
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() возвращает контейнер, в котором объекта может не быть. Больше вы не ожидаете внезапного null значения, если только разработчик этого метода не сумашедший, ведь ничто не мешает ему вернуть 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 в Optional другого типа. В данном случае мы получили фамилию пользователя, то есть преобразовали Optional<User> в 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())
);

А дальше мы вызвали метод ifPresent(), в котором мы вызвали вывод на консоль, можно еще чуть-чуть сократить код:

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

Методы Optional

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

Cоздание Optional

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

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

Optional.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)

Этот метод аналогичен Optional.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();
    }

}

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

Метод Optional.empty()

Но что делать, если пользователь с переданным логином не был найден? Метод все равно должен вернуть Optional.

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

Обратите внимание на последний пример, там мы как раз используем Optional.empty().

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

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

Метод isPresent()

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

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

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

Фактически это обычная проверка, как если бы мы сами написали if (value != null). И если зайти в реализацию метода isPresent(), то это мы там и увидим:

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

Метод isEmpty()

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

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

Метод get()

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

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

if(optPerson.isPresent()) {
	final Person person = optPerson.get();
    
	// остальной ваш код
}

Конечно, вы можете вызвать метод get() и без проверки. Но если объекта там не окажется, то вы получите NoSuchElementException.

Метод ifPresent()

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

Давайте с помощью этого метода выведем имя и фамилию пользователя на консоль.

personRepository.findById(id).ifPresent(
        person -> System.out.println(person.getFirstName() + " " + person.getLastName())
);

Метод ifPresentOrElse()

Метод ifPresent() ничего не сделает, если у вас нет объекта, но если вам и в этом случае необходимо выполнить какой-то код, то используйте метод ifPresentOrElse(), который принимает в качестве параметра еще и функциональный интерфейс Runnable.

Возьмем наш пример выше и выведем в консоль Иван Иванов, если пользователь не был найден.

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

Метод orElse()

Метод делает ровно то, что ожидается от его названия: возвращает значение в контейнере или значение по-умолчанию, которое вы указали.

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

public Person getPersonOrAnon(Long id) {
    return personRepository.findById(id)
            .orElse(new Person(-1L, "anon", "anon", "anon"));
}

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

💢
Некоторым приходит в голову довольно странная конструкция: objectOptional.orElse(null), которая лишает использование Optional всякого смысла. Никогда так не делайте, и бейте по рукам тем, кто так делает.

Метод orElseGet()

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

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

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

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

Метод orElseThrow()

Этот метод вернет объект если он есть, в противном случае выбросит стандартное исключение NoSuchElementException("No value present").

Метод 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);
    }

}

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

Optional также имеет ряд методов, которые могет быть знакомы вам по стримам: map, filter и flatMap. Они имеют такие же названия, и делают примерно то же самое.

Метод filter()

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

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

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

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

Метод map()

Если внутри контейнера есть значение, то к значению применяется переданная функция, результат помещается в новый Optional и возвращается, в случае отсутствия значения будет возвращен пустой контейнер. Для преобразования используется функциональный интерфейс Function.

Сделаем метод, который по идентификатору пользователя возвращает Optional<String>, содержащий имя и фамилию этого пользователя.

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

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

Метод flatMap()

Как уже было сказано, map() оборачивает возвращаемый результат лямбды Function. Но что, если эта лямбда у нас будет возвращать уже обернутый результат, у нас получится Optional<Optional<T>>. С таким дважды упакованным объектом будет сложно работать.

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

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

В таком случае используйте flatMap(), он позволит вам избавиться от лишнего контейнера.

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

Метод stream()

В Java 9 в Optional был добавлен новый метод stream(), который позволяет удобно работать со стримом от коллекции 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 передав новый объект, раньше так сделать было нельзя.

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

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

public Optional<Person> getPersonOrAnonOptional(Long id) {
    return personRepository.findById(id)
            .or(() -> Optional.of(new Person(-1L, "anon", "anon", "anon", 0L)));
}

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

Все перечисленные методы возвращают в ответ 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

Если открыть javadoc Optional, можно найти там прямой ответ на данный вопрос.

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

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

А теперь разберемся основные ошибки при использовании Optional.

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

Не стоит использовать Optional, в качестве параметра метода. Если пользователь метода с параметром Optional не знает об этом, он может передать методу null вместо Optional.empty(). И обработка null приведет к исключению NullPointerException.

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

Также не используйте Optional для объявления свойств класса.

Во-первых, у вас могут возникнуть проблемы с такими популярными фремворками, как Spring Data/Hibernate. Hibernate не может замапить значения из БД на Optional напрямую , без кастомных конвертеров.

Хотя некоторые фреймворки, такие как Jackson отлично интегрируют Optional в свою экосиситему. Как раз с Jackson можно подумать об использовании Optional в качестве свойства класса, если создание объекта этого класса вы никика не контролируете. Например, при использовании Webhook.

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

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

Решением в такой ситуации может быть использование Optional для геттеров класса. Однако у этого подхода есть один недостаток. Его нельзя полностью интегрировать с Lombok. Optional getters не подерживаются библиотекой и, судя по некоторым обсуждениям на Github, не будут.

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

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

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

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

Примитивы и Optional

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

Все эти классы похожи на Optional, но не имеют методов преобразования. В них доступны только: get, orElse, orElseGet, orElseThrow, ifPresent и isPresent.

Резюмирую

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

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

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

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

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