Открыть список
Как стать автором
Обновить

Java Optional не такой уж очевидный

ПрограммированиеJavaООПФункциональное программирование
Перевод
Автор оригинала: Semyon Kirekov

NullPointerException - одна из самых раздражающих вещей в Java мире, которую был призван решить Optional. Нельзя сказать, что проблема полностью ушла, но мы сделали большие шаги. Множество популярных библиотек и фреймворков внедрили Optional в свою экосистему. Например, репозитории в Spring Data возвращают Optional вместо null.

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

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

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

Знайте API

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

public String getPersonName() {
    Optional<String> name = getName();
    if (name.isPresent()) {
        return name.get();
    }
    return "DefaultName";
}

Идея проста: если имя отсутствует, вернуть значение по умолчанию. Можно сделать это лучше.

public String getPersonName() {
    Optional<String> name = getName();
    return name.orElse("DefautName");
}

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

public Optional<String> getPersonName() {
    Person person = getPerson();
    if (ALLOWED_NAMES.contains(person.getName())) {
        return Optional.ofNullable(person.getName());
    }
    return Optional.empty();
}

Optional.filter упрощает код.

public Optional<String> getPersonName() {
    Person person = getPerson();
    return Optional.ofNullable(person.getName())
                   .filter(ALLOWED_NAMES::contains);
}

Этот подход стоит применять не только в контексте Optional, но ко всему процессу разработки.

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

Отдавайте предпочтение контейнерам "на примитивах"

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

Не пренебрегайте ленивыми вычислениями

Optional.orElse — это удобной инструмент для получения значения по умолчанию. Но если его вычисление является дорогой операцией, это может повлечь за собой проблемы с быстродействием.

public Optional<Table> retrieveTable() {
    return Optional.ofNullable(constructTableFromCache())
                   .orElse(fetchTableFromRemote());
}

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

public Optional<Table> retrieveTable() {
    return Optional.ofNullable(constructTableFromCache())
                   .orElseGet(this::fetchTableFromRemote);
}

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

Не оборачивайте коллекции в Optional

Хотя я и видел такое не часто, иногда это происходит.

public Optional<List<String>> getNames() {
    if (isDevMode()) {
        return Optional.of(getPredefinedNames());
    }
    try {
        List<String> names = getNamesFromRemote();
        return Optional.of(names);
    }
    catch (Exception e) {
        log.error("Cannot retrieve names from the remote server", e);
        return Optional.empty();
    }
}

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

public List<String> getNames() {
    if (isDevMode()) {
        return getPredefinedNames();
    }
    try {
        return getNamesFromRemote();
    }
    catch (Exception e) {
        log.error("Cannot retrieve names from the remote server", e);
        return emptyList();
    }
}

Чрезмерное использование Optional усложняет работу с API.

Не передавайте Optional в качестве параметра

А сейчас мы начинаем обсуждать наиболее спорные моменты.

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

public void doAction() {
    OptionalInt age = getAge();
    Optional<Role> role = getRole();
    applySettings(name, age, role);
}

Во-первых, API имеет ненужные границы. При каждом вызове applySettings пользователь вынужден оборачивать значения в Optional. Даже предустановленные константы.

Во-вторых, applySettings обладает четырьмя потенциальными поведениями в зависимости от того, являются ли переданные Optional пустыми, или нет.

В-третьих, мы понятия не имеем, как реализация интерпретирует Optional. Возможно, в случае пустого контейнера происходит простая замена на значение по умолчанию. Может быть, выбрасывается NoSuchElementException. Но также вероятно, что наличие или отсутствие данных в монаде может полностью поменять бизнес-логику.

Если взглянуть на javadoc к Optional, можно найти там интересную заметку.

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

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

Что же, как мы можем улучшить этот код? Если age и role должны всегда присутствовать, мы можем легко избавиться от Optional и решать проблему отсутствующих значений на верхнем уровне.

public void doAction() {
    OptionalInt age = getAge();
    Optional<Role> role = getRole();
    applySettings(name, age.orElse(defaultAge), role.orElse(defaultRole));
}

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

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

void applySettings(String name) { ... }
void applySettings(String name, int age) { ... }
void applySettings(String name, Role role) { ... }
void applySettings(String name, int age, Role role) { ... }

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

Не используйте Optional в качестве полей класса

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

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

Отсутствие сериализуемости

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

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

Хранение лишних ссылок

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

Плохая интеграция со Spring Data/Hibernate

Предположим, что мы хотим построить простое Spring Boot приложение. Нам нужно получить данные из таблицы в БД. Сделать это очень просто, объявив Hibernate сущность и соответствующий репозиторий.

@Entity
@Table(name = "person")
public class Person {
    @Id
    private long id;

    @Column(name = "firstname")
    private String firstName;

    @Column(name = "lastname")
    private String lastName;
    
    // constructors, getters, toString, and etc.
}

public interface PersonRepository extends JpaRepository<Person, Long> {
}

Вот возможный результат для personRepository.findAll().

Person(id=1, firstName=John, lastName=Brown)
Person(id=2, firstName=Helen, lastName=Green)
Person(id=3, firstName=Michael, lastName=Blue)

Пусть поля firstName и lastName могут быть null. Мы не хотим иметь дело с NullPointerException, так что просто заменим обычный тип поля на Optional.

@Entity
@Table(name = "person")
public class Person {
    @Id
    private long id;

    @Column(name = "firstname")
    private Optional<String> firstName;

    @Column(name = "lastname")
    private Optional<String> lastName;
    
    // constructors, getters, toString, and etc.
}

Теперь все сломано.

org.hibernate.MappingException: 
Could not determine type for: java.util.Optional, at table: person, 
      for columns: [org.hibernate.mapping.Column(firstname)]

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

Но некоторые вещи работают правильно

Должен признать, что в конечном итоге не все так плохо. Некоторые фреймворки корректно интегрируют Optional в свою экосистему.

Jackson

Давайте объявим простой эндпойнт и DTO.

public class PersonDTO {
    private long id;
    private String firstName;
    private String lastName;
    // getters, constructors, and etc.
}
@GetMapping("/person/{id}")
public PersonDTO getPersonDTO(@PathVariable long id) {
    return personRepository.findById(id)
            .map(person -> new PersonDTO(
                    person.getId(),
                    person.getFirstName(),
                    person.getLastName())
            )
            .orElseThrow();
}

Результат для GET /person/1.

{
  "id": 1,
  "firstName": "John",
  "lastName": "Brown"
}

Как вы можете заметить, нет никакой дополнительной конфигурации. Все работает из коробки. Давайте попробуем заменить String на Optional<String>.

public class PersonDTO {
    private long id;
    private Optional<String> firstName;
    private Optional<String> lastName;
    // getters, constructors, and etc.
}

Для того чтобы проверить разные варианты работы, я заменил один параметр на Optional.empty().

@GetMapping("/person/{id}")
public PersonDTO getPersonDTO(@PathVariable long id) {
    return personRepository.findById(id)
            .map(person -> new PersonDTO(
                    person.getId(),
                    Optional.ofNullable(person.getFirstName()),
                    Optional.empty()
            ))
            .orElseThrow();
}

Как ни странно, все по-прежнему работает так, как и ожидается.

{
  "id": 1,
  "firstName": "John",
  "lastName": null
}

Это значит, что мы можем использовать Optional в качестве полей DTO и безопасно интегрироваться со Spring Web? Ну, вроде того. Однако есть потенциальные проблемы.

SpringDoc

SpringDoc — это библиотека для Spring Boot приложений, которая позволяет автоматически сгенерировать Open Api спецификацию.

Вот пример того, что мы получим для эндпойнта GET /person/{id}.

"PersonDTO": {
  "type": "object",
  "properties": {
    "id": {
      "type": "integer",
      "format": "int64"
    },
    "firstName": {
      "type": "string"
    },
    "lastName": {
      "type": "string"
    }
  }
}

Выглядит довольно убедительно. Но нам нужно сделать поле id обязательным. Это можно осуществить с помощью аннотации @NotNull или @Schema(required = true). Давайте добавим кое-какие детали. Что если мы поставим аннотацию @NotNull над полем типа Optional?

public class PersonDTO {
    @NotNull
    private long id;
    @NotNull
    private Optional<String> firstName;
    private Optional<String> lastName;
    // getters, constructors, and etc.
}

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

"PersonDTO": {
  "required": [
    "firstName",
    "id"
  ],
  "type": "object",
  "properties": {
    "id": {
      "type": "integer",
      "format": "int64"
    },
    "firstName": {
      "type": "string"
    },
    "lastName": {
      "type": "string"
    }
  }
}

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

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

Решение

Что же нам делать со всем этим? Ответ прост. Используйте Optional только для геттеров.

public class PersonDTO {
    private long id;
    private String firstName;
    private String lastName;
    
    public PersonDTO(long id, String firstName, String lastName) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
    }
    
    public long getId() {
        return id;
    }
    
    public Optional<String> getFirstName() {
        return Optional.ofNullable(firstName);
    }
    
    public Optional<String> getLastName() {
        return Optional.ofNullable(lastName);
    }
}

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

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

Я писал статью по Lombok и я думаю, что это прекрасный инструмент. Тот факт, что он не интегрируются с Optional getters, довольно печален.

На текущий момент единственным выходом является ручное объявление необходимых геттеров.

Заключение

Это все, что я хотел сказать по поводу java.util.Optional. Я знаю, что это спорная тема. Если у вас есть какие-то вопросы или предложения, пожалуйста, оставляйте свои комментарии. Спасибо за чтение!

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Используете Optional?
12.03% Передаю в параметрах 16
13.53% Храню в полях класса 18
75.94% Использую как возвращаемое значение 101
24.81% Не применяю 33
Проголосовали 133 пользователя. Воздержались 33 пользователя.
Теги:javaoptionalразработка поbackendпереводлучшие практики
Хабы: Программирование Java ООП Функциональное программирование
Всего голосов 15: ↑13 и ↓2 +11
Просмотры6.5K

Похожие публикации

Java/Kotlin Backend Developer
от 140 000 до 250 000 ₽EVEN LabМоскваМожно удаленно
Senior Backend developer / Java
от 2 200 до 3 000 $Uventex LabsМожно удаленно
Java разработчик
от 150 000 до 190 000 ₽KotelovСанкт-Петербург
Java developer
от 150 000 до 300 000 ₽EmphasoftСанкт-Петербург
Java Developer
от 160 000 до 190 000 ₽SCHNEIDER GROUPСанкт-Петербург

Лучшие публикации за сутки