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

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

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

Разработчики постоянно используют чужие библиотеки. Для доступа к базе данных часто применяют фреймворки Spring Data Jpa + Hibernate, которые организуют соединение с базой данных и базовый CRUD.

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

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

Запустим этот код мы можем получить исключение:

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "dev.struchkov.example.tamtam.User.getFirstName()" because "user" is null
	at dev.struchkov.example.tamtam.Test.test(Test.java:18)
	at dev.struchkov.example.tamtam.Test.main(Test.java:13)

Почему это могло произойти? Дело в том, что мы не учли того факта, что пользователя с идентификатором 1L может не быть в базе данных. И фреймворку не остается ничего, кроме как вернуть нам null, а во время вызова метода null-объекта вы получите NullPointerException.

NullPointerException это головная боль новичков в программировании. Но неважно, новичок ли вы в Java или за плечами у вас десять лет опыта — всегда есть вероятность, что вы столкнетесь с NullPointerException.

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

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

final User user = userRepository.findById(1L);
if (user != null) {
    final String firstName = user.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.tamtam.Test.test(Test.java:20)
	at dev.struchkov.example.tamtam.Test.main(Test.java:13)

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

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

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

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

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

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

Java 17

Введение в Optional

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

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

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

Вы скажете: это не выглядит проще, даже более того, добавилась лишняя строка?!! Но на самом деле случилось концептуально важное событие, вы точно знаете что метод findById() возвращает контейнер, в котором объекта может не быть. Больше вы не ожидаете внезапного null значения, если только разработчик этого метода не сумашедший, ведь ничто не мешает ему вернуть null вместо Optional 🤪

Здесь мы с вами увидели два основных метода: isPresent() возвращает true, если внутри есть объект, а метод get() возвращает этот объект. Да, проверку для firstName пришлось оставить, но об этом позже.

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

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

Мы воспользовались методом map(), который преобразует наш Optional в Optional другого типа. В данном случае мы получили фамилию пользователя, то есть преобразовали Optional<User> в Optional<String>.

Вот такой вариант показывает это нагляднее:

final Optional<User> optUser = userService.findById(1L);
final Optional<String> optFirstName = optUser.map(user -> user.getFirstName());

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

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

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

Методы Optional

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

Cоздание Optional

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

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

Optional.ofNullable принимает любой тип и создает контейнер. Если в параметры этого метода передать null, то будет создан пустой контейнер.

@Test
@DisplayName("Создание Optional через ofNullable()")
void createOptionalByOfNullable() {
    final String testString = "Test String";
    final Optional<String> testOptional = Optional.ofNullable(testString);

    ...
}

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

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

Этот метод аналогичен Optional.ofNullable, но если передать в параметр null значение, то получим старый добрый NullPointerException.

@Test
@DisplayName("Создание Optional через of()")
void createByOf() {
    final String testString = "Test String";
    final Optional<String> testOptional = Optional.of(testString);

    assertEquals(testString, testOptional.get());
}

@Test
@DisplayName("Создание пустого Optional через of")
void createNullOptionalByOf() {
    final String testString = null;

    assertThrows(NullPointerException.class, () -> {
        final Optional<String> testOptional = Optional.of(testString);
    });
}

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

Метод Optional.empty()

Иногда бывает нужно создать сразу пустой Optional. Для этого существует метод Optional.empty().

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

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

Метод isPresent()

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

Optional<User> optUser = userRepository.findById(1L);

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

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

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

Метод isEmpty()

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

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

Метод get()

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

Optional<User> optUser = userRepository.findById(1L);

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

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

@Test
@DisplayName("Вызываем get у пустого Optional")
void getForNullOptional() {
    final String testString = null;
    final Optional<String> testOptional = Optional.ofNullable(testString);

    assertFalse(testOptional.isPresent());
    assertThrows(NoSuchElementException.class, testOptional::get);
}

Метод ifPresent()

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

@Test
@DisplayName("Проверка метода ifPresent()")
void useIfPresentMethod() {
    final String testString = "test string";
    final Optional<String> testOptional = Optional.of(testString);

    final Consumer<String> ourConsumer = ourTestSpring -> {
        System.out.println(ourTestSpring);
        assertEquals(ourTestSpring, testString);
    };

    testOptional.ifPresent(ourConsumer);
}

Метод ifPresentOrElse()

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

@Test
@DisplayName("Проверка метода ifPresentOrElse()")
void useIfPresentOrElse() {
    final String testString = null;
    final Optional<String> testOptional = Optional.ofNullable(testString);

    testOptional.ifPresentOrElse(
            ourTestSpring -> System.out.println(ourTestSpring),
            () -> System.out.println("Null String in optional")
    );
}

Метод orElse()

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

@Test
@DisplayName("Используем метод orElse")
void useGetOrElse() {
    final String testString = null;
    final Optional<String> testOptional = Optional.ofNullable(testString);

    final String valueOfOptional = testOptional.orElse("Default String");

    assertEquals("Default String", valueOfOptional);
}

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

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

Метод orElseGet()

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

@Test
@DisplayName("Используем метод orElseGet")
void useGetOrElseGet() {
    final String testString = null;
    final Optional<String> testOptional = Optional.ofNullable(testString);

    final String valueOfOptional = testOptional.orElseGet(() -> {
        System.out.println("String is null");
        return "Default String";
    });

    assertEquals("Default String", valueOfOptional);
}

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

Метод orElseThrow()

Вернет объект если он есть, в противном случае выбросит указанное исключение.

@Test
@DisplayName("Проверем метод orElseThrow")
void useOrElseThrow() {
    final String testString = null;
    final Optional<String> testOptional = Optional.ofNullable(testString);

    assertThrows(
            RuntimeException.class,
            () -> testOptional.orElseThrow(() -> new RuntimeException("Null String in optional"))
    );
}

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

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

Метод filter()

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

@Test
@DisplayName("Проверка метода filter")
void useFilter() {
    final String testString = "test string";
    final Optional<String> testOptional = Optional.of(testString);

    final Optional<String> filteredOptional = testOptional
            .filter(ourTestSpring -> ourTestSpring.equals("test string"));
    final String valueOfOptional = filteredOptional.get();
    
    assertTrue(filteredOptional.isPresent());
    assertEquals("test string", valueOfOptional);
}

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

Метод map()

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

@Test
@DisplayName("Проверка метода map")
void useMap() {
    final String testString = "test string";
    final Optional<String> testOptional = Optional.of(testString);

    final Optional<Integer> mappedOptional = testOptional
            .map(ourTestSpring -> ourTestSpring.length());
    final Integer lengthString = mappedOptional.get();

    assertTrue(mappedOptional.isPresent());
    assertEquals(testString.length(), lengthString);
}

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

Метод flatMap()

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

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

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

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

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

Метод stream()

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

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

final List<Optional<User>> optUsers = new ArrayList<>();
final List<User> users = optUsers.stream()
        .flatMap(itemOfArr -> itemOfArr.stream())
        .map(user -> user.getLastName())
        .toList();

Метод or()

Начиная с Java 11 добавили новый метод or(). Он позволяет положить объект в пустой контейнер, раньше так сделать было нельзя.

@Test
@DisplayName("Проверка метода or")
void useOr() {
    final String testString = null;
    final Optional<String> testOptional = Optional.ofNullable(testString);

    final Optional<String> optDefaultString = testOptional.or(() -> Optional.of("Default String"));
    final String valueOfOptional = optDefaultString.get();

    assertEquals("Default String", valueOfOptional);
}

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

Все перечисленные методы возвращают в ответ 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. Вместо этого следует использовать 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 перед тем, как достать его содержимое;
  • Никогда так не делайте: objectOptional.orElse(null);

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

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