Nasza strona używa cookies. Dowiedz się więcej o celu ich używania i zmianie ustawień w przeglądarce. Korzystając ze strony, wyrażasz zgodę na używanie cookies, zgodnie z aktualnymi ustawieniami przeglądarki. Rozumiem

Sprawdź, jak nie używać Optional w Javie

Mervyn McCreight Software Engineer / dreamIT GmbH
Poznaj korzyści płynące z korzystania z Optional w Javie, oraz naucz się, jak nie popełniać najczęstszych błędów związanych z tym typem.
Sprawdź, jak nie używać Optional w Javie

W tym artykule będziemy rozmawiać o doświadczeniach zebranych podczas pracy z typem Optional, które zostały wprowadzone w Java 8. Podczas naszej codziennej pracy napotkaliśmy kilka "antywzorców", którymi chcemy się podzielić. Z naszego doświadczenia wynika, że jeśli konsekwentnie unikniesz umieszczania tych wzorców w kodzie, to istnieje duże prawdopodobieństwo, że dojdziesz do czystszego rozwiązania.

Optional - sposób wyrażania możliwego braku wartości w Javie

Celem Optional jest wyrażenie potencjalnego braku wartości typem danych zamiast możliwości braku wartości tylko dlatego, że istnieje pusta referencja w Javie.

Jeśli spojrzeć na inne języki programowania, które nie mają wartości puste, opisują potencjalny brak wartości poprzez typy danych. W Haskell, na przykład, odbywa się to przy użyciu "Maybe", co moim zdaniem okazało się być skutecznym sposobem radzenia sobie z ewentualnym "brakiem wartości".

data Maybe a = Just a              | Nothing


Powyższy fragment kodu pokazuje definicję Maybe w Haskellu. Jak widzisz, Maybe jest sparametryzowany przez zmienny typ a, co oznacza, że możesz używać go z każdym typem, z którym chcesz. Deklarowanie możliwości braku wartości przy użyciu typu danych, np. w funkcji, zmusza użytkownika funkcji do zastanowienia się nad obydwoma możliwymi rezultatami wywołania funkcji - przypadkiem, w którym faktycznie obecne jest coś znaczącego i przypadkiem, w którym tak nie jest.

Zanim Optional został wprowadzony do Javy, "javowym sposobem" na opisanie niczego była pusta referencja, która może być przypisana do każdego typu. Ponieważ wszystko może być puste, zostaje zatajone, jeśli coś ma być puste (np. jeśli chcesz, aby coś reprezentowało wartość lub nic) lub nie. Np. jeśli coś może być puste - ponieważ wszystko może być puste w Javie - ale w przepływie aplikacji nie powinno być puste w jakimkolwiek momencie.

Jeśli chcesz sprecyzować, że coś może być wyraźnie niczym i podeprzeć to pewną semantyką, definicja wygląda tak samo, jak gdybyś oczekiwał, że coś będzie obecne przez cały czas. Wynalazca referencji pustej Sir Tony Hoare nawet przeprosił za jej wprowadzenie.

Nazywam to moim błędem wartym miliard dolarów.... W tym czasie projektowałem pierwszy kompleksowy system typów dla referencji w języku obiektowym. Moim celem było zapewnienie, aby korzystanie z referencji było całkowicie bezpieczne, a sprawdzanie odbywało się automatycznie przez kompilator. Ale nie mogłem się oprzeć pokusie, aby umieścić pustą referencję po prostu dlatego, że było tak łatwo ją wdrożyć. Doprowadziło to do niezliczonych błędów i awarii systemu, które prawdopodobnie spowodowały miliard dolarów bólu i szkód w ciągu ostatnich czterdziestu lat. (Tony Hoare, 2009 - QCon London).

Aby przezwyciężyć tę problematyczną sytuację, programiści wymyślili wiele metod, takich jak adnotacje (Nullable, NotNull), konwencje nazewnictwa (np. przedrostek metody z find zamiast get) lub po prostu wykorzystanie komentarzy w kodzie, aby zasugerować, że metoda może celowo zwrócić null, a osoba wywołująca powinna mieć to na uwadze. Dobrym tego przykładem jest funkcja get interfejsu map w Javie.

public V get(Object key);


Powyższa definicja wizualizuje problem. Zwyczajnie przez domyślną możliwość, że wszystko może być pustą referencją, nie można komunikować opcji, że wynik tej funkcji może być niczym przy użyciu sygnatury metody. Jeśli użytkownik tej funkcji przyjrzy się jej definicji, nie ma szansy, aby wiedzieć, że metoda ta może zwrócić pustą referencję z premedytacją - ponieważ może się zdarzyć, że w instancji mapy nie istnieje mapowanie do dostarczonego klucza. I to jest dokładnie to, o czym mówi dokumentacja tej metody:

Zwraca wartość, do której mapowany jest określony klucz, lub null, jeśli mapa nie zawiera mapowania dla klucza.

Jedyną szansą, by się o tym przekonać, jest zagłębienie się w dokumentację. I trzeba pamiętać - nie każdy kod jest dobrze udokumentowany w ten sposób. Wyobraź sobie, że w Twoim projekcie masz wewnętrzny kod platformy, który nie ma żadnych komentarzy, ale zaskakuje Cię zwróceniem null-reference gdzieś w głębi jego call-stacku. I tu właśnie sprawdza się wyrażenie potencjalnego braku wartości z typem danych.

public Optional<V> get(Object key);

Jeśli spojrzeć na powyższą sygnaturę typu, to wyraźnie widać, że metoda ta MOŻE nic nie zwrócić i nawet zmusza do zajęcia się tym przypadkiem, ponieważ jest on wyrażony za pomocą specjalnego typu danych.

Posiadanie Optional w Javie jest miłe, ale napotykamy pewne pułapki, jeśli używamy Optional w kodzie. W przeciwnym razie korzystanie z Optional może sprawić, że kod będzie jeszcze mniej czytelny i intuicyjny (innymi słowy - brudny). Poniższe części obejmą niektóre wzorce, które okazały się być pewnego rodzaju "antywzorcami" dla Optional Javy.

 

Optional w kolekcjach lub strumieniach

Wzorzec, który napotkaliśmy w kodzie, posiada puste Optionals przechowywane w kolekcji lub jako stan pośredni wewnątrz strumienia. Zazwyczaj po czymś takim należy filtrować puste Optional, a nawet wywoływać Optional::get, ponieważ tak naprawdę nie musisz mieć kolekcji opcji. Poniższy przykład kodu pokazuje bardzo uproszczony przypadek opisanej sytuacji.

private Optional<IdEnum> findValue(String id) {   return EnumSet.allOf(IdEnum.class).stream()      .filter(idEnum -> idEnum.name().equals(id)      .findFirst();};
(...)
List<String> identifiers = (...)
List<IdEnum> mapped = identifiers.stream()   .map(id -> findValue(id))   .filter(Optional::isPresent)   .map(Optional::get)   .collect(Collectors.toList());


Jak widać, nawet w tym uproszczonym przypadku trudno jest zrozumieć cel kodu. Musisz przyjrzeć się metodzie findValue, aby zrozumieć jaki jest cel kodu. A teraz wyobraźmy sobie, że metoda findValue jest bardziej złożona niż mapowanie reprezentacji łańcucha do jego wartości typu wyliczeniowego.

Jest też ciekawa lektura o tym, dlaczego należy unikać null w kolekcji [UsingAndAvoidingNullExplained]. Ogólnie rzecz biorąc, nie musisz mieć pustego Optional w kolekcji. Dzieje się tak dlatego, że pusty Optional jest reprezentacją "niczego". Wyobraź sobie, że masz listę z trzema elementami i wszystkie są pustymi Optionalami. W większości scenariuszy pusta lista byłaby semantycznie równoważna.

Co możemy zatem zrobić? W większości przypadków plan filtrowania przed mapowaniem prowadzi do bardziej czytelnego kodu, ponieważ bezpośrednio określa, co chcesz osiągnąć, zamiast ukrywać go za łańcuchem wywołań “potencjalnego mapowania, filtrowania i ponownego mapowania.

private boolean isIdEnum(String id) {   return Stream.of(IdEnum.values())      .map(IdEnum::name)      .anyMatch(name -> name.equals(id));};
(...)
List<String> identifiers = (...)
List<IdEnum> mapped = identifiers.stream()   .filter(this::isIdEnum)   .map(IdEnum::valueOf)   .collect(Collectors.toList());


Jeśli wyobrazisz sobie, że metoda isEnum jest własnością samego IdEnum, to stanie się jeszcze bardziej przejrzysta. Ale dla możliwości odczytania przykładu kodu, nie ma go w przykładzie. Jednak czytając powyższy przykład, można łatwo zrozumieć, co się dzieje, nawet bez konieczności wskakiwania do metody isIdEnum, do której było odwołanie.

Krótko mówiąc - jeśli nie potrzebujesz braku wartości wyrażonej w liście, nie potrzebujesz Optional - potrzebujesz jego zawartości, więc Optional jest wewnątrz kolekcji zbędny.

Optional w parametrach metody

Inny wzorzec, z którym się spotkaliśmy, zwłaszcza gdy kod jest migrowany ze "staromodnego" sposobu korzystania z null-reference do obsługi typu opcjonalnego, to posiadanie opcjonalnie wpisanych parametrów w definicjach funkcji. Dzieje się tak zazwyczaj wtedy, gdy znajdziesz funkcję, która sprawdza swoje parametry i stosuje inne zachowanie, co moim zdaniem jest złą praktyką.

void addAndUpdate(Something value) {    if (value != null) {      somethingStore.add(value);   }    updateSomething();}


Jeśli naiwnie zrefaktoryzujesz tę metodę, by wykorzystać typ opcjonalny, może się zdarzyć, że otrzymasz taki wynik, używając parametru typu opcjonalnego.

void addAndUpdate(Optional<Something> maybeValue) {   if (maybeValue.isPresent()) {      somethingStore.add(maybeValue.get());   }   updateSomething();}


Moim zdaniem, posiadanie parametru typu opcjonalnego w funkcji pokazuje w każdym przypadku wady projektowe. Tak czy inaczej masz jakąś decyzję do podjęcia, zrobisz coś z parametrem, jeśli tam jest, lub zrobisz coś innego, jeśli go tam nie ma - i ten przepływ jest ukryty wewnątrz funkcji. W przykładzie takim jak powyżej wyraźniej widać, że funkcja jest podzielona na dwie funkcje i warunkowo je wywołujemy (co również pasuje do zasady "jeden zamiar na funkcję").

private void addSomething(Something value) {   somethingStore.add(value);}
(...)
// somewhere, where the function would have been calledOptional.ofNullable(somethingOrNull).ifPresent(this::addSomething);updateSomething();


Z własnego doświadczenia powiem, że kiedykolwiek spotkałem się z przykładami jak powyżej w prawdziwym kodzie, zawsze warto było refaktoryzować "do końca", co oznacza, że nie posiadam funkcji lub metod z takimi parametrami. Skończyłem z dużo czystszym przepływem w kodzie, który był znacznie łatwiejszy do odczytania i utrzymania.

Tym niemniej - funkcja lub metoda z opcjonalnym parametrem moim zdaniem nie ma sensu. Mogę mieć jedną wersję z i jedną wersję bez parametru i decydować w punkcie wywołania, co robić, zamiast decydować o tym, że musi to być ukryte w jakiejś skomplikowanej funkcji. Dla mnie był to antywzorzec (posiadanie parametru, który może być celowo nullem, a jeśli jest nullem, to jest inaczej traktowany) i nadal nim pozostaje (posiadanie parametru typu opcjonalnego).

Optional::isPresent, po którym następuje Optional::get

Stary sposób tworzenia kodu zabezpieczonego przed nullami w Javie polega na dodaniu null-checków dla wartości, w których nie jesteś pewien, czy mają wartość, czy są pustymi referencjami.

if (value != null) {   doSomething(value);}


By wprost wyrazić możliwość, że wartość może być czymś, albo niczym, można chcieć przerobić ten kod, by użyć typu opcjonalnego.

Optional<Value> maybeValue = Optional.ofNullable(value);
if (maybeValue.isPresent()) {   doSomething(maybeValue.get());}


Powyższy przykład pokazuje "naiwną" wersję refaktoringu, z którą spotykałem się dosyć często w wielu przykładach kodu. Ten wzorzec isPresent, a następnie get, może być inspirowany przez kierunek wyznaczony przez null-checki starego typu. Napisawszy tak wiele null-checków, wyszkoliło nas to jakoś do automatycznego myślenia o tym wzorcu. Ale Optional jest zaprojektowany tak, aby mógł być używany w inny sposób, aby osiągnąć bardziej czytelny kod. Ta sama semantyka może być osiągnięta przy użyciu ifPresent w bardziej czytelny sposób.

Optional<Value> maybeValue = Optional.ofNullable(value);maybeValue.ifPresent(this::doSomething);


Pewnie myślisz sobie teraz "Ale co, gdy chcę zrobić coś innego, a wartość nie jest obecna". W Java-9 Optional oferuje rozwiązanie dla tego popularnego przypadku.

Optional.ofNullable(valueOrNull)    .ifPresentOrElse(        this::doSomethingWithPresentValue,        this::doSomethingElse    );


Biorąc pod uwagę powyższe możliwości, aby uzyskać typowe przypadki użycia null-checków bez używania isPresent, po którym nastapi get, ten wzorzec jest rodzajem antywzorca. Optional jest (z punktu widzenia API) zaprojektowane do wykorzystania w inny sposób, który moim zdaniem jest bardziej czytelny.

Złożone obliczenia, instancjonowanie obiektu lub modyfikacja stanu w orElse

API Optional Javy ma możliwość uzyskania gwarantowanej wartości z Optional. Odbywa się to za pomocą orElse, który daje Ci możliwość zdefiniowania domyślnej wartości, która zostanie zwrócona, jeśli Optional, który próbuje odpakować jest rzeczywiście pusty. Jest to przydatne za każdym razem, gdy chcesz określić domyślne zachowanie dla czegoś, co może się tam znajdować, ale wcale nie musi być wykonane.

// maybeNumber represents an Optional containing an int or not.int numberOr42 = maybeNumber.orElse(42);


Ten podstawowy przykład ilustruje użycie orElse. W tym momencie masz gwarancję, że albo dostaniesz numer, który umieściłeś w Optional, albo otrzymasz wartość domyślną 42. To bardzo proste.

Ale znacząca wartość domyślna nie zawsze musi być prostą wartością stałą. Czasami może zaistnieć potrzeba obliczenia odpowiedniej wartości domyślnej w sposób złożony i/lub czasochłonny. To prowadziłoby do wyodrębnienia tego złożonego obliczenia do funkcji i przekazania go do orElse jako parametr taki jak ten.

int numberOrDefault = maybeNumber.orElse(complexCalculation());


Teraz albo otrzymujesz numer, albo obliczoną wartość domyślną. Wygląda dobrze. Ale czy na pewno? Teraz musisz pamiętać, że Java przekazuje parametry do funkcji poprzez koncepcję wywołania przez wartość. Jedną z konsekwencji jest to, że w podanym przykładzie funkcja complexCalculation będzie zawsze obliczana, nawet jeśli ifElse nie zostanie wywołana.

Teraz wyobraź sobie, że ta complexCalculation jest naprawdę skomplikowana, a zatem czasochłonna. Zawsze będzie obliczana. Spowodowałoby to problemy z wydajnością. Inną kwestią jest to, że jeśli pracujesz z bardziej złożonymi obiektami jako liczbami całkowitymi, byłoby to również stratą pamięci, ponieważ zawsze tworzyłbyś instancję o wartości domyślnej. Czy jej potrzebujesz, czy nie.

Ale ponieważ znajdujemy się w kontekście Javy, to jeszcze nie koniec. Wyobraź sobie, że nie czasochłonnej funkcji, ale za to takiej, która zmienia stan i chciałbyś ją wywołać w przypadku, gdy Optional jest rzeczywiście puste.

int numberOrDefault = maybeNumber.orElse(stateChangingStuff());


Jest to jeszcze bardziej niebezpieczny przykład. Pamiętaj - w ten sposób funkcja będzie zawsze obliczana, czy jest potrzebna czy nie. Oznaczałoby to, że zawsze mutujesz stan, nawet jeśli nie chcesz tego robić. Moim zdaniem należy zawsze unikać stanów mutowalnych w takich funkcjach, za wszelką cenę.

Aby mieć możliwość radzenia sobie z takimi problemami, API Optional zapewnia alternatywny sposób definiowania sytuacji awaryjnej za pomocą orElseGet. Funkcja ta faktycznie pobiera dostawcę, który zostanie wywołany w celu wygenerowania wartości domyślnej.

// without method referenceint numberOrDefault = maybeNumber.orElseGet(() -> complex());
// with method referenceint numberOrDefault = maybeNumber.orElseGet(Something::complex);


W ten sposób dostawca, który faktycznie generuje wartość domyślną przez wywołanie complex, zostanie wykonany tylko wtedy, gdy lubElseGet zostanie faktycznie wywołany - czyli gdy Optional jest puste. Przez to complex nie jest wołany, gdy nie ma takiej potrzeby. Żadne skomplikowane obliczenia nie są wykonywane bez faktycznego wykorzystania ich wyników.

Ogólne zasady dotyczące tego, kiedy używać lubElse i kiedy używać lubElseGet to:

Jeśli spełniasz wszystkie trzy kryteria:

  1. prosta wartość domyślna, która nie jest trudna do obliczenia (jak stała)
  2. niezużywająca zbyt dużo pamięci wartość domyślna
  3. niemutowalna funkcja wartości domyślnej


To użyj orElse. W przeciwnym razie należy użyć orElseGet.

Wniosek (TL;DR)

  • Optional można użyć by komunikować ewentualny brak wartości (np. wartość zwracana przez funkcję).
  • Unikać umieszczania opcji w kolekcjach lub strumieniach. Wystarczy wypełnić je bezpośrednio aktualnymi wartościami.
  • Unikać posiadania Optional jako parametru funkcji.
  • Unikać Optional::isPresent, po których nastepuje Optional::get.
  • Unikać skomplikowanych lub zmieniających stan obliczeń w orElse. Do tego celu należy użyć orElseGet.


Autorzy: Mervyn McCreight oraz Mehmet Emin Tok
Oryginał artykułu w języku angielskim możesz przeczytać tutaj.

Masz coś do powiedzenia?

Podziel się tym z 120 tysiącami naszych czytelników

Dowiedz się więcej
Rocket dog