Поглед на опциони тип података у Јави и неке анти-обрасце када га користите

аутор Мервин МцЦреигхт и Мехмет Емин Ток

Преглед

У овом чланку, ми ћемо говорити о искуствима смо се окупили у раду са Јаве Опционом -дататипе, који је уведен са Јава 8. Током нашег свакодневног пословања, наишли смо на неке "анти-шеме" смо желели да учешћем. Наше искуство је било да ако строго избегавате да те обрасце имате у коду, велике су шансе да ћете доћи до чистијег решења.

Опционално - Јава начин да експлицитно изрази могуће одсуство вредности

Сврха Опционалног је изразити потенцијално одсуство вредности са типом података, уместо да има имплицитну могућност да има одсутну вредност само зато што нулл-референца постоји у Јави.

Ако погледате друге програмске језике који немају нулл-вредност , они описују потенцијално одсуство вредности кроз типове података. На пример, у Хаскеллу се то ради помоћу Можда, што се по мом мишљењу показало као ефикасан начин за решавање могуће „не-вредности“.

data Maybe a = Just a | Nothing

Исјечак кода горе приказује дефиницију Можда у Хаскелл-у. Као што видите, можда је а параметризовано променљивом типа а , што значи да га можете користити са било којим типом који желите. Изјава о могућности одсутне вредности помоћу типа података, нпр. У функцији, приморава вас као корисника функције да размислите о оба могућа резултата позивања функције - случају када је у ствари присутно нешто значајно и случај где није.

Пре него што је Оптионал уведен у Јаву, „јава-пут“ који је требало да иде ако желите да опишете ништа била је нулл-референца која се може доделити било ком типу. Будући да све може бити нуло, замућује се ако је нешто намењено нули (нпр. Ако желите да нешто представља вредност или ништа) или не (нпр. Ако нешто може бити нулл, јер у Јави све може бити нулл, али у ток апликације, ни у једном тренутку не би требало да буде ништавно).

Ако желите да наведете да нешто изричито не може бити ништа са иза себе има одређену семантику, дефиниција изгледа исто, као да очекујете да је нешто стално присутно. Изумитељ ништавне референце Сир Тони Хоаречак се извинио због увођења нул-референце.

Ја то називам својом грешком од милијарду долара ... У то време сам дизајнирао први свеобухватни систем типа за референце у објектно оријентисаном језику. Циљ ми је био да осигурам да свака употреба референци треба да буде апсолутно сигурна, а проверу аутоматски врши компајлер. Али нисам могао да одолим искушењу да дам нулу референцу, једноставно зато што је то било тако лако применити. То је довело до небројених грешака, рањивости и падова система, што је вероватно проузроковало милијарду долара бола и штете у последњих четрдесет година. (Тони Хоаре, 2009 - КЦон Лондон)

Да би се превазишао овај проблематичну ситуацију, програмери измислио многе методе као што су напоменама (нуллабле, НотНулл), наминг-конвенције (нпр префикса метод са налаза уместо Гет ) или само помоћу цоде-коментаре на наговештај да поступак може намерно врати нулл и Инвокер треба да брине о овом случају. Добар пример за то је гет-функција мап-интерфејса Јаве.

public V get(Object key);

Горња дефиниција визуализује проблем. Само имплицитном могућношћу да све може бити нул-референца , не можете да саопштите опцију да резултат ове функције не може бити ништа користећи потпис методе. Ако корисник ове функције погледа њену дефиницију, нема шансе да зна да би овај метод могао да врати нулу референцу намерно - јер може бити случај да у мапи-инстанци не постоји мапирање на дати кључ. А ово је управо оно што вам говори документација ове методе:

Враћа вредност на коју је мапиран наведени кључ или nullако ова мапа не садржи мапирање за кључ.

Једина шанса да се то сазна је дубљи увид у документацију. И морате запамтити - није сав код добро документован на овај начин. Замислите да у свом пројекту имате интерни код платформе, који нема коментара, али вас изненађује враћањем нулл-референце негде дубоко у њен низ позива. И ту блиста изражавање потенцијалног одсуства вредности типом података.

public Optional get(Object key);

Ако погледате горњи потпис типа, јасно се саопштава да ова метода МОЖДА не враћа ништа - чак вас приморава да се позабавите овим случајем, јер је изражен посебним типом података.

Дакле, имати опционално на Јави је лепо, али наилазимо на неке замке ако у свом коду користите опционално . У супротном, употреба Оптиона може ваш код учинити још мање читљивим и интуитивним (укратко речено - мање чистим). Следећи делови покриваће неке обрасце за које смо установили да су нека врста „анти-образаца“ за Јавину опцију .

Опционално у колекцијама или стримовима

Узорак на који смо наишли у коду са којим смо радили има празне опционе датотеке смештене у колекцији или као средње стање унутар тока. Уобичајено је то праћено филтрирањем празних опција, па чак и позивање Оптионал :: гет , јер заправо не морате имати колекцију опција. Следећи пример кода приказује врло поједностављен случај описане ситуације.

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

Као што видите, чак и у овом поједностављеном случају постаје тешко разумети шта је намера овог кода. Морате да погледате методу финдВалуе да бисте добили намеру свега тога. А сада замислите да је метода финдВалуе сложенија од мапирања представљања низа у његову вредност откуцану набрајањем.

Такође је занимљиво штиво о томе зашто би требало да избегнете нулл у колекцији [УсингАндАвоидингНуллЕкплаинед]. Генерално, у збирци заправо не треба да имате празну опцију. То је зато што је празна опција избор за „ништа“. Замислите да имате листу са три ставке и све су празне опције. У већини сценарија празна листа би била семантички еквивалентна.

Па шта можемо да учинимо поводом тога? У већини случајева план за прво филтрирање пре мапирања доводи до читљивијег кода, јер је директно наводио оно што желите да постигнете, уместо да га сакрије иза ланца можда мапирања , филтрирања и затим мапирања .

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

Ако замислите да је метода исЕнум у власништву самог ИдЕнум-а, то би постало још јасније. Али ради примера читљивог кода то није у примеру. Али само читајући горњи пример, можете лако разумети шта се догађа, чак и без стварног ускакања у референцирану методу исИдЕнум .

Дакле, најкраће - ако вам није потребно одсуство вредности изражене на листи, не треба вам Необвезно - потребан вам је само његов садржај, па је опционално застарело унутар колекција.

Опционално у параметрима методе

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.

Закључак (ТЛ; ДР)

  • Користите опционално за саопштавање могућег одсуства вредности (нпр. Повратне вредности функције).
  • Избегавајте да имате Опционале у колекцијама или стримовима. Само их директно напуните садашњим вредностима.
  • Избегавајте да имате Опције као параметре функција.
  • Избегавајте Оптионал :: исПресент, а затим Необавезна :: гет.
  • Избегавајте сложене прорачуне или промене стања у или другачије. За то користите орЕлсеГет.

Повратне информације и питања

Какво је ваше досадашње искуство са употребом Јава Оптионал? Слободно делите своја искуства и разговарајте о тачкама које смо изнели у одељку за коментаре.