Sposoby na uniknięcie problemów z Java Optional
NullPointerException
to jedna z najbardziej irytujących rzeczy, jakie można spotkać w Javie. Na ratunek przybywa nam na szczęście Optional
. Nie można jednak jednoznacznie stwierdzić, czy NullPointerException
zniknęło na dobre, ale myślę, że wiele tutaj dokonaliśmy.
Wiele popularnych bibliotek i frameworków ma Optional
w swoim ekosystemie. Na przykład, repozytoria Spring Data zwracają Optional<Entity>
zamiast null
.
Myślę, że niektórzy programiści są już tak podekscytowani Optional
, że zaczęli go trochę za dużo używać i korzystać z niego we wzorcach, w których nie powinni tego robić. Wymienię tutaj niektóre takie błędne przypadki użycia i pokażę mój sposób na poradzenie sobie z tymi problemami.
Optional nigdy nie może być nullem
Wydaje mi się, że nie trzeba tutaj wiele tłumaczyć. Przypisywanie Optional do null
sprawi, że klasa ta nie będzie mieć sensu.
Żaden klient Twojego API nie sprawdzi, czy Optional jest równe null
. Zamiast tego powinieneś użyć Optional.empty()
.
Poznaj API
Ponieważ wydano nowe wersje Javy, Optional
rozszerzono o nowe funkcje, które pozwalają pisać mniej rozwlekły kod.
public String getPersonName() {
Optional<String> name = getName();
if (name.isPresent()) {
return name.get();
}
return "DefaultName";
}
Optional.isPresent
Pomysł jest całkiem prosty - jeśli nazwa jest pusta, zwróć domyślną wartość. Można to trochę ładniej napisać.
public String getPersonName() {
Optional<String> name = getName();
return name.orElse("DefautName");
}
Optional.orElse
Stwórzmy coś bardziej skomplikowanego. Załóżmy, że musimy zwrócić Optional
imienia jakiejś osoby. Jeśli dane imię zawiera się w zestawie dozwolonych imion, to wartość powinna być obecna, w przeciwnym razie, nie będzie.
Oto trochę rozwlekły przykład:
public Optional<String> getPersonName() {
Person person = getPerson();
if (ALLOWED_NAMES.contains(person.getName())) {
return Optional.ofNullable(person.getName());
}
return Optional.empty();
}
Optional name
Optional.filter
sprawi, że powyższy kod będzie wyglądał o wiele lepiej.
public Optional<String> getPersonName() {
Person person = getPerson();
return Optional.ofNullable(person.getName())
.filter(ALLOWED_NAMES::contains);
}
Optional name - lepsze podejście
Właściwie to powyższa rada ma zastosowanie w całym procesie developmentu.
Jeśli w danym języku istnieje już rozwiązanie danego problemu, to postaraj się z niego skorzystać, zamiast tworzyć swoje własne.
Alternatywy oparte na wartościach są lepsze, niż te ogólne
Mamy również specjalne klasy Optional
, które nie są ogólne. OptionalInt
, OptionalLong
oraz OptionalDouble
.
Jeśli musisz działać z prymitywnymi typami danych, lepiej jest użyć kontenerów opartych na wartościach. W takim przypadku nie mamy żadnych dodatkowych procedur związanych z opakowywaniem, które mogłyby wpłynąć na wydajność.
Nie lekceważ lenistwa
Optional.orElse
to wygodne podejście do zwracania wartości domyślnych. Jeśli obliczenie wartości domyślnej jest kosztowne, to może to spowodować problemy z wydajnością.
public Optional<Table> retrieveTable() {
return Optional.ofNullable(constructTableFromCache())
.orElse(fetchTableFromRemote());
}
Gorsza wydajność przy użyciu Optional
Nawet jeśli mamy daną tabelę w cache, to będzie ona za każdym razem pobierana z zewnętrznego serwera. Ten problem można jednak prosto rozwiązać za pomocą Optional.orElseGet
.
public Optional<Table> retrieveTable() {
return Optional.ofNullable(constructTableFromCache())
.orElseGet(this::fetchTableFromRemote);
}
Wydajnie działające Optional
Optional.orElseGet
akceptuje lambdę, która będzie obliczana tylko, jeśli zastosuje się ją w pustym kontenerze.
Nie twórz Optional z kolekcji
Nie widziałem czegoś takiego zbyt często - no ale zdarza się.
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();
}
}
Optional listy
Jakakolwiek kolekcja jest kontenerem, którego pustkę można określić bez dodatkowych klas.
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();
}
}
Pusta lista - lepsze podejście
Niepotrzebne klasy Optional
sprawiają, że z danym API za ciężko się pracuje.
Nie przekazuj Optional jako parametru
Tutaj zaczniemy mówić o najbardziej dyskusyjnych kwestiach, jakie poruszę w tym artykule.
Dlaczego nie powinniśmy przekazywać Optional jako parametru? Na pierwszy rzut oka wydaje się to logiczne - pomaga w uniknięciu niespodziewanych wystąpień NullPointerException
. Być może tak jest, ale problem jednak sięga głębiej.
public void doAction() {
OptionalInt age = getAge();
Optional<Role> role = getRole();
applySettings(name, age, role);
}
Parametry Optional
Po pierwsze, API posiada niepotrzebne ograniczenia. Użytkownik musi przy każdym wywołaniu opakowywać wartości w Optional, nawet jeśli mówimy tu o znanych, stałych wartościach.
Po drugie, applySettings
ma 4 potencjalne zachowania, oparte na tym, że Optional jest obecny. Nie jest to niestety zgodne z zasadą pojedynczej odpowiedzialności.
No i finalnie nie wiemy, jak nasza metoda interpretuje Optional. Czy zamienia nieobecną wartość wartością domyślną? A może całkowicie zmienia logikę biznesową? A może po prostu wyrzuci NoSuchElementException
?
Jeśli spojrzymy na Optional w dokumentacji Javy, to znajdziemy coś takiego:
Optional można używać jako metody na zwracanie typu, kiedy zachodzi wyraźna potrzeba reprezentacji “no result” oraz tam, gdzie korzystanie z null może spowodować wiele błędów.
Optional jest dosłowną definicją obiektu possible no result. Przekazywanie possible no result w danej metodzie nie brzmi jak dobry pomysł. Oznacza to, że API wie za dużo o kontekście i podejmuje decyzje, których nie powinno być w stanie podjąć ze względu na brak odpowiedniej wiedzy.
Jak możemy to poprawić? Jeśli age
oraz role
zawsze muszą być obecne, to możemy wyrzucić Optional z parametrów i obsłużyć jego nieobecność wysokopoziomowo.
public void doAction() {
OptionalInt age = getAge();
Optional<Role> role = getRole();
applySettings(name, age.orElse(defaultAge), role.orElse(defaultRole));
}
Wyeliminowane parametry Optional
Kod, którego używamy do wywoływania, kontroluje wartości argumentów - jest to jeszcze ważniejsze, gdy rozwijamy framework lub jakąś bibliotekę.
Albo przeciwnie, jeśli age
i role
będzie można pominąć, to takie podejście nie zadziała. W takim przypadku najlepszym sposobem będzie deklaracja oddzielnych metod dla różnych potrzeb użytkowników.
void applySettings(String name) { ... }
void applySettings(String name, int age) { ... }
void applySettings(String name, Role role) { ... }
void applySettings(String name, int age, Role role) { ... }
Podzielona metoda ‘applySettings’
Może to wyglądać trochę rozwlekle, ale użytkownik ma teraz możliwość robienia dokładnie tego, co musi zrobić, unikając niespodziewanych błędów.
Nie używaj Optional jako pól klas
Słyszałem wiele opinii na ten temat. Niektórzy myślą, że przechowywanie Optional w klasach pozwala na bezpośrednią redukcję NullPointerException
i to w znacznym stopniu.
Mam kolegę, który pracuje w znanym startupie. Mówi on, że takie podejście jest w jego firmie uznawane za wzorzec. Inni jednak sądzą, że psuje to całą koncepcję monady.
Pomimo że przechowywanie Optional bezpośrednio w klasie brzmi dobrze, to wydaje mi się jednak, że przyniesie więcej problemów, niż korzyści.
Brak serializacji
Optional nie implementuje interfejsu Serializable. Nie jest to żaden błąd - zostało to zrobione ręcznie, ponieważ Optional zaprojektowano jako typ zwracany. A zatem klasa, która ma jakiekolwiek pole Optional nie może być serializowana.
Wydaje mi się, że to najmniej przekonujący element tej dyskusji. W nowoczesnym świecie, w którym istnieją systemy rozproszone i serializacja oparta na platformach mikroserwisów, nie ma to aż takiego znaczenia, jak kiedyś.
Zatrzymywanie niepotrzebnych referencji
Optional to obiekt, którego użytkownik potrzebuje przez kilka milisekund. Potem można go usunąć za pomocą garbage collectora.
Jeśli jednak zatrzymamy Optional jako pole klasy, to można je tam przechować do momentu zatrzymania działania programu. W małym projekcie możliwe problemy z działaniem będą jednak niezauważalne.
Jeśli mówimy o dużej aplikacji z dużą liczbą beanów, to może to doprowadzić do różnych problemów.
Słaba integracja ze Spring Data/Hibernate
Załóżmy, że budujemy prostą aplikację Spring Boot - musimy wyciągnąć wartości z tablicy. Można to prosto uzyskać za pomocą deklarowania danej encji i odpowiadającego mu repozytorium.
@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> {
}
Prosta encja z repozytorium
Poniżej możliwy wynik personRepository.findAll()
.
Person(id=1, firstName=John, lastName=Brown)
Person(id=2, firstName=Helen, lastName=Green)
Person(id=3, firstName=Michael, lastName=Blue)
Nie chcemy dać ciała z NullPointerException
, więc zamieniamy proste pole na deklaracje używające 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.
}
Encja z polami Optional
Teraz to już wszystko się popsuło.
org.hibernate.MappingException: Could not determine type for: java.util.Optional, at table: person, for columns: [org.hibernate.mapping.Column(firstname)]
Hibernate nie może mapować wartości z bazy danych na Optional.
Jest też trochę rzeczy, które jednak działają
Muszę przyznać, że nie wszystko jest takie złe - niektóre frameworki dobrze zintegrowały się z Optional.
Jackson
Zadeklarujemy prosty punkt końcowy oraz DTO.
public class PersonDTO {
private long id;
private String firstName;
private String lastName;
// getters, constructors, and etc.
}
PersonDTO
@GetMapping("/person/{id}")
public PersonDTO getPersonDTO(@PathVariable long id) {
return personRepository.findById(id)
.map(person -> new PersonDTO(
person.getId(),
person.getFirstName(),
person.getLastName())
)
.orElseThrow();
}
Prosty punkt końcowy
Oto wynik GET /person/1
.
{
"id": 1,
"firstName": "John",
"lastName": "Brown"
}
Jak widać, nie mamy tutaj żadnej konfiguracji. Wszystko jest od razu gotowe do działania. Spróbujmy zastąpić String
przy pomocy Optional<String>
.
public class PersonDTO {
private long id;
private Optional<String> firstName;
private Optional<String> lastName;
// getters, constructors, and etc.
}
PersonDTO razem z Optionals
Aby przetestować kilka przypadków, zastąpiłem jedno zadanie przy pomocy 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();
}
Punkt końcowy z wieloma Optional
Wszystko cały czas dobrze działa, co mnie zaskakuje.
{
"id": 1,
"firstName": "John",
"lastName": null
}
W Spring Web możemy więc bezpiecznie użyć Optional jako wartości pola, prawda? I tak i nie - jest kilka szczególnych przypadków.
SpringDoc
SpringDoc to biblioteka aplikacji SpringBoot, która generuje Open Api Schema w sposób automatyczny.
Oto, co mamy w związku z punktem końcowym GET /person/{id}
.
"PersonDTO": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
}
}
}
Musimy tutaj jednak uczynić właściwość id
obowiązkową. Można to zrobić używając @NotNull
albo @Schema(required = true)
.
Dodajmy jeszcze kilka ciekawych szczegółów. Co, jeśli umieścimy @NotNull
w polu Optional?
public class PersonDTO {
@NotNull
private long id;
@NotNull
private Optional<String> firstName;
private Optional<String> lastName;
// getters, constructors, and etc.
}
PersonDTO z obowiązkowym id
Może to dać ciekawe rezultaty.
"PersonDTO": {
"required": [
"firstName",
"id"
],
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
}
}
}
Jak widać, id
jest polem wymaganym. Tak jak firstName
. Zaczyna się robić ciekawie. Optional nie może być wymagane, ponieważ cały sens Optional polega na tym, że daną wartość można pominąć. Tak czy siak, trochę popsuliśmy framework, używając zaledwie jednej adnotacji.
W czym więc problem? Na przykład, jeśli część frontendowa korzysta z jakiegokolwiek generatora typów opartego na Open Api Schema, to popsułoby to format danych i doprowadziło do nieprzyjemnych konsekwencji.
Rozwiązanie
Co możemy na to poradzić? Odpowiedź jest prosta - używaj Optional tylko dla getterów.
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);
}
}
PersonDTO z getterami Optional
Teraz klasy tej można spokojnie użyć jako encji DTO albo Hibernate. Optional nie ma żadnego wpływu na dane - opakowuje wartości null, aby obsłużyły nieobecne dane.
Jest jednak jeden szkopuł - tego podejścia nie da się w pełni zintegrować z Lombok. Gettery Optional nie są obsługiwane przez tę bibliotekę. Nie ma też co liczyć na to, żeby się to zmieniło. A przynajmniej może się tak wydawać, patrząc na niektóre dyskusje na GitHubie.
Napisałem kiedyś artykuł o Lombok i uważam, że to wspaniałe narzędzie. Fakt, że nie jest zintegrowane z wzorcem Optional-Getter jest nieprzyjemny.
Jedynym sposobem na obejście tej przeszkody jest definiowanie getterów manualnie.
Podsumowanie
To wszystko, co chciałem powiedzieć o java.util.Optional
. Wiem, że jest to kwestia mocno sporna. Daj znać, co myślisz, w komentarzach!