autor Mervyn McCreight i Mehmet Emin Tok

Pregled
U ovom ćemo članku govoriti o iskustvima koja smo stekli radeći s Java-ovim opcijskim -datatipom, koji je uveden s Javom 8. Tijekom našeg svakodnevnog poslovanja susreli smo se s nekim "anti-uzorcima" koje smo željeli podijeliti. Naše je iskustvo bilo da ako strogo izbjegavate imati te uzorke u kodu, velike su šanse da ćete doći do čišćeg rješenja.
Izborno - Java način na koji eksplicitno izražava moguće odsustvo vrijednosti
Svrha Opcije je izraziti potencijalno odsutnost vrijednosti s tipom podataka, umjesto da implicitno ima mogućnost odsutne vrijednosti samo zato što null-reference postoji u Javi.
Ako pogledate druge programske jezike koji nemaju null-vrijednost , oni opisuju potencijalno odsustvo vrijednosti kroz tipove podataka. Na primjer, u Haskellu se to radi pomoću Možda, što se po mom mišljenju pokazalo kao učinkovit način za rješavanje moguće "ne-vrijednosti".
data Maybe a = Just a | Nothing
Isječak koda gore prikazuje definiciju Možda u Haskellu. Kao što vidite, možda je a parametrizirana varijablom tipa a , što znači da je možete koristiti s bilo kojom vrstom koju želite. Izjava o mogućnosti odsutne vrijednosti pomoću tipa podataka, npr. U funkciji, prisiljava vas kao korisnika funkcije da razmislite o oba moguća rezultata pozivanja funkcije - slučaju kada zapravo postoji nešto značajno i slučaj gdje nije.
Prije nego što je Neobvezno uvedeno u Javu, "java-put" koji je trebao ići ako ste htjeli opisati ništa bila je null-referenca koja se može dodijeliti bilo kojoj vrsti. Budući da sve može biti nulo, zamućuje se ako je nešto namijenjeno nuli (npr. Ako želite da nešto predstavlja vrijednost ili ništa) ili ne (npr. Ako nešto može biti null, jer u Javi sve može biti null, ali u tijek aplikacije, ne smije biti ni u kojem trenutku).
Ako želite odrediti da nešto izričito ne može biti ništa s određenom semantikom iza, definicija izgleda isto, kao ako očekujete da je nešto stalno prisutno. Izumitelj ništavne reference Sir Tony Hoarečak se ispričao zbog uvođenja null-reference.
To nazivam svojom pogreškom u milijardama dolara ... U to sam vrijeme dizajnirao prvi sveobuhvatni sustav tipa za reference u objektno orijentiranom jeziku. Cilj mi je bio osigurati da sva upotreba referenci bude apsolutno sigurna, a provjeru vrši automatski prevodilac. Ali nisam mogao odoljeti iskušenju da ubacim nultu referencu, jednostavno zato što je to bilo tako jednostavno implementirati. To je dovelo do nebrojenih pogrešaka, ranjivosti i padova sustava, što je vjerojatno uzrokovalo milijardu dolara boli i štete u posljednjih četrdeset godina. (Tony Hoare, 2009. - QCon London)Da bi prevladali ovu problematičnu situaciju, programeri su izumili mnoge metode poput napomena (Nullable, NotNull), konvencija imenovanja (npr. Dodavanje metode prefiksa find umjesto get ) ili jednostavno korištenje komentara-koda da bi nagovijestili da metoda može namjerno vratiti nulu i pozivatelja trebao brinuti o ovom slučaju. Dobar primjer za to je get-funkcija sučelja map Java.
public V get(Object key);
Gornja definicija vizualizira problem. Samo implicitnom mogućnošću da sve može biti nul-referenca , ne možete priopćiti mogućnost da rezultat ove funkcije ne može biti ništa koristeći potpis metode. Ako korisnik ove funkcije pogleda njezinu definiciju, nema šanse znati da bi ova metoda mogla namjerno vratiti null-referencu - jer bi mogao biti slučaj da u mapi-instanci ne postoji mapiranje na navedeni ključ. I to je upravo ono što vam govori dokumentacija ove metode:
Vraća vrijednost na koju je mapiran navedeni ključ ilinull
ako ova karta ne sadrži mapiranje za ključ.
Jedina šansa da se to sazna je dublji uvid u dokumentaciju. I morate zapamtiti - nije sav kôd dobro dokumentiran na ovaj način. Zamislite da u projektu imate interni kod platforme, koji nema komentara, ali iznenađuje vas vraćanjem null-reference negdje duboko u svoj niz poziva. I tu sjaji izražavanje potencijalnog odsustva vrijednosti tipom podataka.
public Optional get(Object key);
Ako pogledate gornji potpis tipa, jasno je priopćeno da ova metoda MOŽDA ne vraća ništa - čak vas prisiljava da se pozabavite ovim slučajem, jer je izražen posebnom vrstom podataka.
Dakle, imati Neobvezno na Javi je lijepo, ali nailazimo na neke zamke ako u svom kodu koristite Neobavezno . U suprotnom, upotreba Opcijskog može vaš kôd učiniti još manje čitljivim i intuitivnim (ukratko - manje čistim). Sljedeći dijelovi pokrivat će neke uzorke za koje smo ustanovili da su neka vrsta "anti-obrazaca" za Javinu opciju .
Nije obavezno u zbirkama ili streamovima
Uzorak s kojim smo se susreli u kodu s kojim smo radili ima prazne opcijske uređaje pohranjene u zbirci ili kao srednje stanje unutar toka. Uobičajeno je to praćeno filtriranjem praznih opcija, a čak je slijedilo i pozivanje Optional :: get , jer zapravo ne trebate imati kolekciju opcija. Sljedeći primjer koda prikazuje vrlo pojednostavljeni slučaj opisane situacije.
private Optional findValue(String id) { return EnumSet.allOf(IdEnum.class).stream() .filter(idEnum -> idEnum.name().equals(id) .findFirst();};
(...)
List identifiers = (...)
List mapped = identifiers.stream() .map(id -> findValue(id)) .filter(Optional::isPresent) .map(Optional::get) .collect(Collectors.toList());
Kao što vidite, čak i u ovom pojednostavljenom slučaju postaje teško razumjeti koja je namjera ovog koda. Morate pogledati findValue-metodu da biste shvatili namjeru svega. A sada zamislite da je metoda findValue složenija od mapiranja predstavljanja niza na njezinu vrijednost koja je upisana u nabrajanje.
Tu je i zanimljivo štivo o tome zašto biste trebali izbjegavati nulu u zbirci [UsingAndAvoidingNullExplained]. Općenito u zbirci zapravo ne morate imati praznu opciju. To je zato što je prazna opcija izbor za "ništa". Zamislite da imate Popis s tri stavke i sve su prazne opcije. U većini scenarija prazan popis bio bi semantički ekvivalentan.
Pa što možemo učiniti po tom pitanju? U većini slučajeva plan za prvo filtriranje prije mapiranja dovodi do čitljivijeg koda, jer je izravno navodio ono što želite postići, umjesto da ga sakrije iza lanca možda mapiranja , filtriranja i zatim mapiranja .
private boolean isIdEnum(String id) { return Stream.of(IdEnum.values()) .map(IdEnum::name) .anyMatch(name -> name.equals(id));};
(...)
List identifiers = (...)
List mapped = identifiers.stream() .filter(this::isIdEnum) .map(IdEnum::valueOf) .collect(Collectors.toList());
Ako zamislite da je metoda isEnum u vlasništvu samog IdEnuma, to bi postalo još jasnije. Ali radi čitljivog primjera koda to nije u primjeru. Ali samo čitajući gornji primjer, lako možete shvatiti što se događa, čak i bez potrebe za uskakanjem u referenciranu metodu isIdEnum .
Dakle, ukratko - ako vam nije potrebno odsustvo vrijednosti izražene na popisu, ne treba vam Neobvezno - potreban vam je samo njegov sadržaj, pa je neobavezno zastarjelo unutar zbirki.
Neobvezno u parametrima metode
Another pattern we encountered, especially when code is getting migrated from the “old-fashioned” way of using a null-reference to using the optional-type, is having optional-typed parameters in function-definitions. This typically happens if you find a function that does null-checks on its parameters and applies different behaviour then — which, in my opinion, was bad-practice before anyways.
void addAndUpdate(Something value) { if (value != null) { somethingStore.add(value); } updateSomething();}
If you “naively” refactor this method to make use of the optional-type, you might end up with a result like this, using an optional-typed parameter.
void addAndUpdate(Optional maybeValue) { if (maybeValue.isPresent()) { somethingStore.add(maybeValue.get()); } updateSomething();}
In my opinion, having an optional-typed parameter in a function shows a design-flaw in every case. You either way have some decision to make if you do something with the parameter if it is there, or you do something else if it is not — and this flow is hidden inside the function. In an example like above, it is clearer to split the function into two functions and conditionally call them (which would also happen to fit to the “one intention per function”-principle).
private void addSomething(Something value) { somethingStore.add(value);}
(...)
// somewhere, where the function would have been calledOptional.ofNullable(somethingOrNull).ifPresent(this::addSomething);updateSomething();
In my experience, if I ever encountered examples like above in real code, it always was worth refactoring “‘till the end”, which means that I do not have functions or methods with optional-typed parameters. I ended up with a much cleaner code-flow, which was much easier to read and maintain.
Speaking of which — in my opinion a function or method with an optional parameter does not even make sense. I can have one version with and one version without the parameter, and decide in the point of invocation what to do, instead of deciding it hidden in some complex function. So to me, this was an anti-pattern before (having a parameter that can intentionally be null, and is handled differently if it is) and stays an anti-pattern now (having an optional-typed parameter).
Optional::isPresent followed by Optional::get
The old way of thinking in Java to do null-safe programming is to apply null-checks on values where you are not sure if they actually hold a value or are referencing to a null-reference.
if (value != null) { doSomething(value);}
To have an explicit expression of the possibility that value can actually be either something or nothing, one might want to refactor this code so you have an optional-typed version of value.
Optional maybeValue = Optional.ofNullable(value);
if (maybeValue.isPresent()) { doSomething(maybeValue.get());}
The example above shows the “naive” version of the refactoring, which I encountered quite often in several code examples. This pattern of isPresent followed by a get might be caused by the old null-check pattern leading one in that direction. Having written so many null-checks has somehow trained us to automatically think in this pattern. But Optional is designed to be used in another way to reach more readable code. The same semantics can simply be achieved using ifPresent in a more readable way.
Optional maybeValue = Optional.ofNullable(value);maybeValue.ifPresent(this::doSomething);
“But what if I want to do something else instead, if the value is not present” might be something you think right now. Since Java-9 Optional comes with a solution for this popular case.
Optional.ofNullable(valueOrNull) .ifPresentOrElse( this::doSomethingWithPresentValue, this::doSomethingElse );
Given the above possibilities, to achieve the typical use-cases of a null-check without using isPresent followed by a get makes this pattern sort of a anti-pattern. Optional is per API designed to be used in another way which in my opinion is more readable.
Complex calculations, object-instantiation or state-mutation in orElse
The Optional-API of Java comes with the ability to get a guaranteed value out of an optional. This is done with orElse which gives you the opportunity to define a default value to fall back to, if the optional you are trying to unpack is actually empty. This is useful every time you want to specify a default behaviour for something that can be there, but does not have to be done.
// maybeNumber represents an Optional containing an int or not.int numberOr42 = maybeNumber.orElse(42);
This basic example illustrates the usage of orElse. At this point you are guaranteed to either get the number you have put into the optional or you get the default value of 42. Simple as that.
But a meaningful default value does not always have to be a simple constant value. Sometimes a meaningful default value may need to be computed in a complex and/or time-consuming way. This would lead you to extract this complex calculation into a function and pass it to orElse as a parameter like this.
int numberOrDefault = maybeNumber.orElse(complexCalculation());
Now you either get the number or the calculated default value. Looks good. Or does it? Now you have to remember that Java is passing parameters to a function by the concept of call by value. One consequence of this is that in the given example the function complexCalculation will always be evaluated, even if orElse will not be called.
Now imagine this complexCalculation is really complex and therefore time-consuming. It would always get evaluated. This would cause performance issues. Another point is, if you are handling more complex objects as integer values here, this would also be a waste of memory here, because you would always create an instance of the default value. Needed or not.
But because we are in the context of Java, this does not end here. Imagine you do not have a time-consuming but a state-changing function and would want to invoke it in the case where the Optional is actually empty.
int numberOrDefault = maybeNumber.orElse(stateChangingStuff());
This is actually an even more dangerous example. Remember — like this the function will always be evaluated, needed or not. This would mean you are always mutating the state, even if you actually would not want to do this. My personal opinion about this is to avoid having state mutation in functions like this at all cost.
To have the ability to deal with issues like described, the Optional-API provides an alternative way of defining a fallback using orElseGet. This function actually takes a supplier that will be invoked to generate the default value.
// without method referenceint numberOrDefault = maybeNumber.orElseGet(() -> complex());
// with method referenceint numberOrDefault = maybeNumber.orElseGet(Something::complex);
Like this the supplier, which actually generates the default value by invoking complex will only be executed when orElseGet actually gets called — which is if the optional is empty. Like this complex is not getting invoked when it is not needed. No complex calculation is done without actually using its result.
A general rule for when to use orElse and when to use orElseGet can be:
If you fulfill all three criteria
- a simple default value that is not hard to calculate (like a constant)
- a not too memory consuming default value
- a non-state-mutating default value function
then use orElse.
Otherwise use orElseGet.
Conclusion (TL;DR)
- Use Optional to communicate an intended possible absence of a value (e.g. the return value of a function).
- Avoid having Optionals in collections or streams. Just fill them with the present values directly.
- Avoid having Optionals as parameters of functions.
- Avoid Optional::isPresent followed by Optional::get.
- Avoid complex or state changing calculations in orElse. Use orElseGet for that.
Feedback & Questions
What is your experience so far with using Java Optional? Feel free to share your experiences and discuss the points we brought up in the comment section.