Egy pillantás a Java opcionális adattípusára és néhány anti-mintára annak használatakor

által Mervyn McCreight és Mehmet Emin Tok

Áttekintés

Ebben a cikkben azokról a tapasztalatokról fogunk beszélni , amelyeket a Java 8-as Java-val bevezetett Opcionális- adattípusával dolgozva gyűjtöttünk össze . Napi üzleti tevékenységünk során találkoztunk néhány „anti-mintával”, amelyet meg akartunk osztani. Tapasztalatunk az volt, hogy ha szigorúan kerüli ezeket a mintákat a kódban, nagy az esély arra, hogy tisztább megoldást talál.

Opcionális - a Java módja az érték esetleges hiányának kifejezett kifejezésére

Az Opcionális célja egy érték adattípussal való lehetséges hiányának kifejezése ahelyett, hogy implicit lehetősége lenne hiányzó értékre, csak azért, mert null-referencia létezik a Java-ban.

Ha megnézel más programozási nyelveket, amelyeknek nincs nullértéke , akkor ezek adattípusokon keresztül írják le az érték hiányát. A Haskellben például a Varb használatával történik, amely véleményem szerint hatékony módszernek bizonyult az esetleges „nem érték” kezelésére.

data Maybe a = Just a | Nothing

A fenti kódrészlet a Varb definícióját mutatja be Haskellben. Mint láthatja, a Varb a paraméterét az a típusváltozó adja meg , ami azt jelenti, hogy bármilyen tetszőleges típussal használható. A hiányzó érték lehetőségének deklarálása adattípus használatával, például egy függvényben arra kényszeríti Önt, mint a függvény felhasználóját, hogy gondolkodjon a függvény meghívásának mindkét lehetséges eredményén - azon eseten, amikor valójában van valami értelmes jelen, és a eset, amikor nem.

Mielőtt az Opcionálist bevezették volna a Java-ba, a semmit leíró „java-út” a null-referencia volt , amely bármilyen típushoz rendelhető. Mivel minden null lehet, elmosódik, ha valamit nullának szánnak (pl. Ha azt akarja, hogy valami értéket képviseljen, vagy semmit sem), vagy sem (pl. Ha valami lehet null, mert minden null lehet Java-ban, de az alkalmazás folyamata, semmiképpen sem lehet semmilyen).

Ha meg akarja határozni, hogy valami kifejezetten semmi lehet, egy bizonyos szemantikával a háta mögött, akkor a meghatározás ugyanúgy néz ki, mint például ha azt várja, hogy valami mindig jelen legyen. A null-referencia feltalálója, Sir Tony Hoaresőt elnézést kért a null-referencia bevezetése miatt.

Milliárdos hibámnak nevezem ... Akkoriban az első átfogó típusú rendszert terveztem objektum-orientált nyelvű hivatkozásokra. Célom az volt, hogy biztosítsam, hogy a referenciák minden használata teljesen biztonságos legyen, az ellenőrzést a fordító automatikusan végezze. De nem tudtam ellenállni a kísértésnek, hogy semmilyen referenciát ne tegyek bele, egyszerűen azért, mert ezt nagyon könnyű megvalósítani. Ez számtalan hibához, sebezhetőséghez és rendszerösszeomláshoz vezetett, amelyek valószínűleg egymilliárd dollár fájdalmat és kárt okoztak az elmúlt negyven évben. (Tony Hoare, 2009 - QCon London)

Ennek legyőzésére problematikus helyzet, a fejlesztők feltalált sok módszer például a jelölések (lehet üres, NotNull) elnevezési-egyezmények (pl elé egy módszert találni helyett get ), vagy csak a code-megjegyzések arra utalnak, hogy a módszer szándékosan visszaút null és a Invoker érdekelnie kell ezt az esetet. Erre jó példa a Java térkép-felületének get-függvénye .

public V get(Object key);

A fenti meghatározás vizualizálja a problémát. Csak annak implicit lehetőségével, hogy minden null-referencia lehet, nem közölheti azt az opciót, hogy ennek a függvénynek az eredménye semmi sem lehet a metódus aláírásával. Ha ennek a függvénynek a felhasználója megnézi a definícióját, akkor esélye sincs tudni, hogy ez a módszer szándékosan null-referenciát adhat vissza - mert előfordulhat, hogy a térképpéldányban nincs leképezés a megadott kulcsra. És pontosan ezt mondja el a módszer dokumentációja:

Visszaadja azt az értéket, amelyhez a megadott kulcs hozzárendelve van, vagy nullha ez a térkép nem tartalmazza a kulcs leképezését.

Ennek megismerésére egyetlen esély a dokumentáció mélyebb áttekintése. És emlékeznie kell - nem minden kód van így jól dokumentálva. Képzelje el, hogy a projektben van platform-belső kód, amelyhez nincsenek megjegyzések, de meglep, hogy egy null-referenciát ad vissza valahol a hívásverem mélyén. És itt ragyog az érték esetleges hiányának adattípussal való kifejezése.

public Optional get(Object key);

Ha megnézi a fenti típusaláírást, egyértelműen közlik, hogy ez a módszer semmit sem adhat vissza - sőt arra is kényszerít, hogy foglalkozzon ezzel az esettel, mert egy speciális adattípussal fejezik ki.

Tehát az Opcionális használata a Java-ban jó, de néhány buktatóval találkozunk, ha az Opcionálisat használja a kódban. Ellenkező esetben az Opcionális használata még kevésbé olvashatóvá és intuitívabbá teheti a kódot (rövid történet - kevésbé tiszta). A következő részek olyan mintákat fognak bemutatni, amelyekről kiderült, hogy valamiféle „anti-minták” a Java Opcionális számára .

Választható a gyűjteményekben vagy az adatfolyamokban

Egy minta, amellyel olyan kódban találkoztunk, amellyel dolgoztunk, az üres opcionális elemek tárolása egy gyűjteményben vagy köztes állapotként egy adatfolyamban. Általában ezt követte az üres opciók kiszűrése, sőt az Optional :: get meghívása , mert valójában nem kell opcionális gyűjtemény. Az alábbi kódpélda a leírt helyzet nagyon leegyszerűsített esetét mutatja be.

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

Mint látható, még ebben az egyszerűsített esetben is nehéz megérteni, mi a célja ennek a kódnak. Meg kell néznie a findValue-metódust, hogy megkapja mindennek a szándékát. Most képzelje el, hogy a findValue-módszer bonyolultabb, mint egy karakterlánc-reprezentáció leképezése a felsorolás tipizált értékéhez.

Van egy érdekes olvasmány arról is, hogy miért kell kerülni a null értéket egy gyűjteményben [UsingAndAvoidingNullExplained]. Általában nem szükséges, hogy üres legyen egy választható gyűjtemény. Ugyanis egy üres opcionális a „semmi” ábrázolása. Képzelje el, hogy van egy lista, amelyben három elem van, és ezek mind üres opciók. A legtöbb esetben egy üres lista szemantikailag egyenértékű lenne.

Tehát mit tehetünk ellene? A legtöbb esetben az első szűrés a feltérképezés előtt olvashatóbb kódhoz vezet, mivel közvetlenül meghatározta, mit szeretne elérni, ahelyett, hogy esetleg leképezés , szűrés, majd leképezés lánca mögé rejtette volna .

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

Ha úgy gondolja, hogy az isEnum-módszer maga az IdEnum tulajdonosa, akkor az még világosabbá válna. De az olvasható kódpélda kedvéért ez nem szerepel a példában. De csak a fenti példa elolvasásával könnyen megértheti, mi történik, anélkül, hogy valóban be kellene ugrania a hivatkozott isIdEnum-módszerbe .

Tehát, rövid történet - ha nincs szükséged a listában kifejezett érték hiányára, akkor nincs szükség Opcionálisra - csak a tartalmára van szükséged, így az opcionális elavult a gyűjteményekben.

Választható a módszer paramétereiben

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.

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.