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

от автора

NullPointerException — одна из самых раздражающих вещей в Java мире, которую был призван решить Optional. Нельзя сказать, что проблема полностью ушла, но мы сделали большие шаги. Множество популярных библиотек и фреймворков внедрили Optional в свою экосистему. Например, JPA Specification возвращает 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. Я знаю, что это спорная тема. Если у вас есть какие-то вопросы или предложения, пожалуйста, оставляйте свои комментарии. Спасибо за чтение!

ссылка на оригинал статьи https://habr.com/ru/post/540080/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *