Et kig på den valgfri datatype i Java og nogle antimønstre, når du bruger den

af Mervyn McCreight og Mehmet Emin Tok

Oversigt

I denne artikel skal vi tale om oplevelser, vi har samlet, mens vi arbejder med Java's Optional -datatype, som er blevet introduceret med Java 8. Under vores daglige forretning stødte vi på nogle "anti-mønstre", som vi ønskede at dele. Vores erfaring var, at hvis du strengt undgår at have disse mønstre i din kode, er chancerne store for, at du kommer til en renere løsning.

Valgfrit - Java-metoden til eksplicit at udtrykke det mulige fravær af en værdi

Formålet med Optional er at udtrykke det potentielle fravær af en værdi med en datatype i stedet for at have den implicitte mulighed for at have en fraværende værdi, bare fordi der findes null-reference i Java.

Hvis du kigger på andre programmeringssprog, der ikke har en nulværdi , beskriver de det potentielle fravær af en værdi gennem datatyper. I Haskell gøres det for eksempel ved hjælp af Maybe, som efter min mening har vist sig at være en effektiv måde at håndtere en mulig "no-value" på.

data Maybe a = Just a | Nothing

Kodestykket ovenfor viser definitionen af ​​Måske i Haskell. Som du kan se, er a måske parametreret af typevariablen a , hvilket betyder at du kan bruge den med hvilken som helst type, du vil. At erklære muligheden for en fraværende værdi ved hjælp af en datatype, fx i en funktion, tvinger dig som bruger af funktionen til at tænke over både mulige resultater af en påkaldelse af funktionen - tilfældet hvor der faktisk er noget meningsfuldt til stede og tilfælde hvor det ikke er.

Før Optional blev introduceret i Java, var "java-way", hvis du ville beskrive intet, null-referencen , som kan tildeles til enhver type. Fordi alt kan være nul, bliver det tilsløret, hvis noget er beregnet til at være nul (f.eks. Hvis du vil have, at noget enten skal repræsentere en værdi eller intet) eller ikke (f.eks. Hvis noget kan være nul, fordi alt kan være nul i Java, men i strømmen af ​​applikationen, den skal ikke være nul på noget tidspunkt).

Hvis du vil specificere, at noget eksplicit kan være intet med en bestemt semantik bag sig, ser definitionen den samme ud, som hvis du forventer, at der altid skal være noget til stede. Opfinderen af ​​null-reference Sir Tony Hoareselv undskyldte for indførelsen af ​​null-reference.

Jeg kalder det min milliarddollarfejl ... På det tidspunkt designede jeg det første omfattende typesystem til referencer på et objektorienteret sprog. Mit mål var at sikre, at al brug af referencer skulle være helt sikker, med kontrol udført automatisk af compileren. Men jeg kunne ikke modstå fristelsen til at indsætte en null reference, simpelthen fordi det var så let at implementere. Dette har ført til utallige fejl, sårbarheder og systemnedbrud, som sandsynligvis har forårsaget en milliard dollars smerte og skade i de sidste fyrre år. (Tony Hoare, 2009 - QCon London)

For at overvinde denne problematiske situation opfandt udviklerne mange metoder som annoteringer (Nullable, NotNull), navngivningskonventioner (f.eks. Forud for en metode med find i stedet for get ) eller bare ved hjælp af kodekommentarer for at antyde, at en metode med vilje kan returnere null og invoker skal bekymre sig om denne sag. Et godt eksempel på dette er get-funktionen af Java-kortgrænsefladen.

public V get(Object key);

Definitionen ovenfor visualiserer problemet. Bare ved den implicitte mulighed for, at alt kan være en null-reference , kan du ikke kommunikere muligheden for, at resultatet af denne funktion ikke kan være noget ved hjælp af signaturen til metoden. Hvis en bruger af denne funktion ser på dens definition, har de ikke en chance for at vide, at denne metode kan returnere en null-reference ved hensigt - fordi det kan være tilfældet, at der ikke findes nogen tilknytning til den angivne nøgle i kortinstansen. Og dette er præcis, hvad dokumentationen til denne metode fortæller dig:

Returnerer den værdi, som den angivne nøgle er kortlagt til, eller nullhvis dette kort ikke indeholder nogen kortlægning for nøglen.

Den eneste chance for at vide dette er ved at se dybere ind i dokumentationen. Og du skal huske - ikke al kode er veldokumenteret sådan. Forestil dig, at du har platform-intern kode i dit projekt, som ikke har nogen kommentarer, men overrasker dig med at returnere en null-reference et eller andet sted dybt nede i sin call-stack. Og det er her, der udtrykker det potentielle fravær af en værdi med en datatype.

public Optional get(Object key);

Hvis du ser på typesignaturen ovenfor, meddeles det tydeligt, at denne metode KAN ikke returnere noget - det tvinger dig endda til at behandle denne sag, fordi den udtrykkes med en speciel datatype.

Så det er dejligt at have Valgfri i Java, men vi støder på nogle faldgruber, hvis du bruger Valgfri i din kode. Ellers kan brugen af Optional gøre din kode endnu mindre læsbar og intuitiv (lang historie kort - mindre ren). De følgende dele vil dække nogle mønstre, som vi fandt ud af at være en slags “anti-mønstre” for Java's Optional .

Valgfri i samlinger eller streams

Et mønster, vi stødte på i kode, vi arbejdede med, er at have tomme valgfrie lagret i en samling eller som en mellemstatus i en stream. Dette blev typisk efterfulgt af filtrering af de tomme optionals og endda efterfulgt af påkaldelse af Optional :: get , fordi du ikke rigtig behøver at have en samling optionals. Følgende kodeeksempel viser et meget forenklet tilfælde af den beskrevne situation.

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

Som du kan se, bliver det selv i dette forenklede tilfælde svært at forstå, hvad formålet med denne kode er. Du er nødt til at se på findValue-metoden for at få intentionen om det hele. Og forestil dig nu, at findValue-metoden er mere kompleks end at kortlægge en strengrepræsentation til dens optællingstypede værdi.

Der er også en interessant læsning om, hvorfor du bør undgå at have null i en samling [BrugAndAvoidingNullExplained]. Generelt behøver du ikke rigtig have en tom valgfri i en samling. Dette skyldes, at en tom valgfri er repræsentation for "ingenting". Forestil dig at have en liste med tre elementer i, og de er alle tomme valgfri. I de fleste scenarier ville en tom liste være semantisk ækvivalent.

Så hvad kan vi gøre ved det? I de fleste tilfælde fører planen om at filtrere først før kortlægning til en mere læselig kode, da den direkte angav, hvad du vil opnå, i stedet for at skjule det bag en kæde af måske kortlægning , filtrering og derefter kortlægning .

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

Hvis du forestiller dig, at isEnum-metoden skal ejes af IdEnum selv, ville det blive endnu tydeligere. Men af ​​hensyn til at have et læsbart kodeeksempel er det ikke i eksemplet. Men bare ved at læse ovenstående eksempel kan du let forstå, hvad der foregår, selv uden virkelig at skulle springe ind i den refererede isIdEnum-metode .

Så lang historie kort - hvis du ikke har brug for fraværet af en værdi udtrykt på en liste, har du ikke brug for Valgfri - du har bare brug for dens indhold, så valgfri er forældet i samlinger.

Valgfri i metodeparametre

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.

Konklusion (TL; DR)

  • Brug Valgfri til at kommunikere et tilsigtet muligt fravær af en værdi (f.eks. En funktions returværdi).
  • Undgå at have optioner i samlinger eller streams. Bare udfyld dem med de nuværende værdier direkte.
  • Undgå at have ekstraudstyr som parametre for funktioner.
  • Undgå Valgfri :: isPresent efterfulgt af Valgfri :: get.
  • Undgå komplekse eller tilstandsændrede beregninger i orElse. Brug orElseGet til det.

Feedback og spørgsmål

Hvad er din erfaring hidtil med at bruge Java Optional? Du er velkommen til at dele dine oplevelser og diskutere de punkter, vi kom med i kommentarfeltet.