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
. Я знаю, что это спорная тема. Если у вас есть какие-то вопросы или предложения, пожалуйста, оставляйте свои комментарии. Спасибо за чтение!
Mishiko