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/
Добавить комментарий