Pohľad na voliteľný údajový typ v Jave a niektoré anti-vzory pri jeho použití

od Mervyn McCreight a Mehmet Emin Tok

Prehľad

V tomto článku si povieme niečo o skúsenostiach, ktoré sme získali pri práci s voliteľným dátovým typom Java , ktorý bol predstavený v prostredí Java 8. Počas nášho každodenného podnikania sme sa stretli s nejakými „anti-vzormi“, o ktoré sme sa chceli podeliť. Naša skúsenosť bola taká, že ak sa striktne vyhnete tomu, aby ste tieto vzory mali v kóde, je veľká šanca, že prídete k čistejšiemu riešeniu.

Voliteľné - spôsob Java, ktorý výslovne vyjadruje možnú absenciu hodnoty

Účelom voliteľného je vyjadrenie potenciálnej absencie hodnoty s dátovým typom namiesto implicitnej možnosti mať neprítomnú hodnotu len preto , že v Jave existuje nulová referencia .

Ak sa pozriete na ďalšie programovacie jazyky, ktoré nemajú nulovú hodnotu , popisujú potenciálnu absenciu hodnoty prostredníctvom dátových typov. Napríklad v Haskell sa to robí pomocou Možná, čo sa podľa môjho názoru ukázalo ako efektívny spôsob riešenia možnej „nehodnoty“.

data Maybe a = Just a | Nothing

Fragment kódu vyššie zobrazuje definíciu možná v Haskelli. Ako vidíte, Možná je a parametrizovaná typovou premennou a , čo znamená, že ju môžete použiť s akýmkoľvek typom, ktorý chcete. Deklarovanie možnosti absencie hodnoty pomocou dátového typu, napr. Vo funkcii, vás núti ako používateľa funkcie premýšľať o oboch možných výsledkoch vyvolania funkcie - prípade, keď v skutočnosti existuje niečo zmysluplné a prípade, že nie je.

Pred zavedením voliteľného jazyka do jazyka Java bol „java-way“, ak chcete popísať nič, nulový odkaz , ktorý je možné priradiť k ľubovoľnému typu. Pretože všetko môže byť nulové, bude zahmlievané, ak má byť niečo neplatné (napr. Ak chcete, aby niečo predstavovalo hodnotu alebo nič) alebo nie (napr. Ak niečo môže byť nulové, pretože všetko môže byť nulové v Jave, ale v tok aplikácie, nemal by byť kedykoľvek neplatný).

Ak chcete určiť, že niečo nemôže byť výslovne nič, čo má za sebou určitú sémantiku, definícia vyzerá rovnako, akoby ste čakali, že niečo bude neustále. Vynálezca nulového odkazu Sir Tony Hoaredokonca sa ospravedlnil za zavedenie nulového odkazu.

Ja to nazývam moja miliardová chyba ... V tom čase som navrhoval prvý komplexný typový systém pre referencie v objektovo orientovanom jazyku. Mojím cieľom bolo zabezpečiť, aby každé použitie odkazov malo byť absolútne bezpečné, pričom kontrola sa bude vykonávať automaticky kompilátorom. Ale nemohol som odolať pokušeniu uviesť nulový odkaz, jednoducho preto, že sa dal tak ľahko implementovať. To viedlo k nespočetným chybám, zraniteľnostiam a zlyhaniam systému, ktoré za posledných štyridsať rokov pravdepodobne spôsobili bolesť a škody za miliardu dolárov. (Tony Hoare, 2009 - QCon London)

Na prekonanie tejto problematickej situácie vyvinuli vývojári mnoho metód, ako sú anotácie (Nullable, NotNull), konvencie pomenovania (napr. Predpona metódy namiesto find namiesto get ) alebo len použitie komentárov k kódu, ktoré naznačujú, že metóda môže zámerne vrátiť hodnotu null a vyvolávač by malo na tomto prípade záležať. Dobrým príkladom je funkcia get mapového rozhrania Java.

public V get(Object key);

Vyššie uvedená definícia vizualizuje problém. Iba vďaka implicitnej možnosti, že všetko môže byť nulový odkaz , nemôžete pomocou podpisu metódy oznámiť možnosť, že výsledkom tejto funkcie nemôže byť nič . Ak sa používateľ tejto funkcie pozrie na svoju definíciu, nemá šancu vedieť, že táto metóda môže vrátiť nulový odkaz zámerne - pretože by sa mohlo stať, že v inštancii mapy neexistuje žiadne mapovanie na poskytnutý kľúč. A presne to vám hovorí dokumentácia k tejto metóde:

Vráti hodnotu, na ktorú je namapovaný zadaný kľúč, alebo nullak táto mapa neobsahuje žiadne mapovanie pre kľúč.

Jedinou šancou to vedieť je nahliadnuť hlbšie do dokumentácie. A musíte si pamätať - nie všetky kódy sú takto dobre zdokumentované. Predstavte si, že máte vo svojom projekte interný kód platformy, ktorý nemá žiadne komentáre, ale prekvapí vás návratom nulovej referencie niekde hlboko do jej zásobníka hovorov. A tu svieti vyjadrenie potenciálnej absencie hodnoty s dátovým typom.

public Optional get(Object key);

Ak sa pozriete na typový podpis uvedený vyššie, je zrejmé, že táto metóda NEMUSÍ nič vrátiť - dokonca vás núti zaoberať sa týmto prípadom, pretože je vyjadrená špeciálnym dátovým typom.

Mať voliteľné v Jave je teda fajn, ale ak vo svojom kóde použijete voliteľné, narazíme na určité úskalia . Inak by použitie voliteľného doplnku mohlo spôsobiť, že váš kód bude ešte menej čitateľný a intuitívny (dlhá poviedka - menej čistá). Nasledujúce časti sa budú týkať niektorých vzorov, o ktorých sme zistili, že sú akýmsi „anti-vzorom“ pre Java voliteľné .

Voliteľné v zbierkach alebo streamoch

Vzor, s ktorým sme sa stretli v kóde, s ktorým sme pracovali, má prázdne voliteľné položky uložené v kolekcii alebo ako prechodný stav vo vnútri streamu. Typicky nasledovalo odfiltrovanie prázdnych voliteľných doplnkov a dokonca nasledovalo vyvolanie Optional :: get , pretože v skutočnosti nemusíte mať zbierku voliteľných doplnkov. Nasledujúci príklad kódu ukazuje veľmi zjednodušený prípad opísanej situácie.

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());

Ako vidíte, aj v tomto zjednodušenom prípade je ťažké pochopiť, čo je zámerom tohto kódexu. Musíte sa pozrieť na metódu findValue, aby ste dosiahli úmysel toho všetkého. A teraz si predstavte, že metóda findValue je zložitejšia ako mapovanie reťazcovej reprezentácie na jej hodnotu napísanú pomocou výčtu.

Je tu tiež zaujímavé čítanie o tom, prečo by ste sa mali vyhnúť kolekcii null [UsingAndAvoidingNullExplained]. Všeobecne nemusíte v zbierke skutočne mať prázdny údaj. Je to tak preto, lebo prázdny nepovinný údaj predstavuje „nič“. Predstavte si, že máte zoznam, ktorý obsahuje tri položky a všetky sú prázdne. Vo väčšine scenárov by prázdny zoznam bol sémanticky ekvivalentný.

Čo s tým teda môžeme urobiť? Plán filtrovať najskôr pred mapovaním vedie vo väčšine prípadov k čitateľnejšiemu kódu, pretože priamo hovorí o tom, čo chcete dosiahnuť, a nie ho skrývať za reťazec mapovania , filtrovania a potom mapovania .

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());

Ak si predstavíte, že metódu isEnum bude vlastniť samotné IdEnum, bolo by to ešte jasnejšie. Ale kvôli príkladu čitateľného kódu to nie je v tomto príklade. Ale iba prečítaním vyššie uvedeného príkladu môžete ľahko pochopiť, o čo ide, a to aj bez toho, aby ste museli skutočne skočiť do odkazovanej metódy isIdEnum .

Stručný príbeh - ak nepotrebujete absenciu hodnoty vyjadrenej v zozname, nepotrebujete voliteľné - potrebujete iba jeho obsah, takže voliteľné je v zbierkach zastarané.

Nepovinné v parametroch metódy

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

  1. a simple default value that is not hard to calculate (like a constant)
  2. a not too memory consuming default value
  3. a non-state-mutating default value function

then use orElse.

Otherwise use orElseGet.

Záver (TL; DR)

  • Použite voliteľné na komunikáciu zamýšľanej možnej absencie hodnoty (napr. Návratovej hodnoty funkcie).
  • Nepoužívajte voliteľné položky v zbierkach alebo streamoch. Stačí ich priamo naplniť súčasnými hodnotami.
  • Nepoužívajte voliteľné položky ako parametre funkcií.
  • Vyhnite sa Optional :: isPresent, za ktorým nasleduje Optional :: get.
  • Vyvarujte sa zložitých výpočtov alebo zmien stavu v systéme orElse. Použite na to orElseGet.

Spätná väzba a otázky

Aké sú vaše doterajšie skúsenosti s používaním Java Optional? Neváhajte sa podeliť o svoje skúsenosti a diskutovať o bodoch, ktoré sme priniesli v sekcii komentárov.