Μια ματιά στον προαιρετικό τύπο δεδομένων στην Java και σε ορισμένα αντί-μοτίβα κατά τη χρήση του

των Mervyn McCreight και Mehmet Emin Tok

ΣΦΑΙΡΙΚΗ ΕΙΚΟΝΑ

Σε αυτό το άρθρο, πρόκειται να μιλήσουμε για τις εμπειρίες που συγκεντρώσαμε ενώ εργαζόμαστε με το Java Optional -datatype, το οποίο παρουσιάστηκε με την Java 8. Κατά τη διάρκεια της καθημερινής μας επιχείρησης, συναντήσαμε κάποια «αντι-μοτίβα» που θέλαμε να μοιραστούμε. Η εμπειρία μας ήταν ότι εάν αποφύγετε αυστηρά να έχετε αυτά τα μοτίβα στον κώδικά σας, οι πιθανότητες είναι υψηλές ότι θα βρείτε μια καθαρότερη λύση.

Προαιρετικό - ο τρόπος Java για ρητή έκφραση της πιθανής απουσίας μιας τιμής

Ο σκοπός του Προαιρετικού είναι να εκφράσει την πιθανή απουσία μιας τιμής με έναν τύπο δεδομένων αντί να έχει τη σιωπηρή δυνατότητα να έχει μια απούσα τιμή μόνο και μόνο επειδή υπάρχει μηδενική αναφορά στην Java.

Εάν ρίξετε μια ματιά σε άλλες γλώσσες προγραμματισμού, οι οποίες δεν έχουν μηδενική τιμή , περιγράφουν την πιθανή απουσία μιας τιμής μέσω τύπων δεδομένων. Στο Haskell, για παράδειγμα, γίνεται χρησιμοποιώντας το Ίσως, το οποίο κατά τη γνώμη μου έχει αποδειχθεί αποτελεσματικός τρόπος χειρισμού μιας πιθανής «μη αξίας».

data Maybe a = Just a | Nothing

Το παραπάνω απόσπασμα κώδικα δείχνει τον ορισμό του Ίσως στο Haskell. Όπως μπορείτε να δείτε, Ίσως ένα παραμετροποιείται από τη μεταβλητή τύπου a , που σημαίνει ότι μπορείτε να τη χρησιμοποιήσετε με οποιονδήποτε τύπο θέλετε. Η δήλωση της πιθανότητας απουσίας τιμής χρησιμοποιώντας έναν τύπο δεδομένων, π.χ. σε μια συνάρτηση, σας αναγκάζει ως χρήστη της συνάρτησης να σκεφτείτε και τα δύο πιθανά αποτελέσματα μιας επίκλησης της συνάρτησης - την περίπτωση όπου υπάρχει πράγματι κάτι σημαντικό και το περίπτωση που δεν είναι.

Πριν το Opional εισαχθεί στην Java, ο «java-way» για να πάει αν θέλετε να περιγράψετε το τίποτα δεν ήταν η μηδενική αναφορά , η οποία μπορεί να αντιστοιχιστεί σε οποιονδήποτε τύπο. Επειδή όλα μπορεί να είναι μηδενικά, αποκρύπτεται εάν κάτι προορίζεται να είναι μηδενικό (π.χ. εάν θέλετε κάτι να αντιπροσωπεύει μια τιμή ή τίποτα) ή όχι (π.χ. εάν κάτι μπορεί να είναι μηδενικό, επειδή όλα μπορούν να είναι μηδενικά στην Java, αλλά σε τη ροή της εφαρμογής, δεν πρέπει να είναι άκυρη ανά πάσα στιγμή).

Εάν θέλετε να προσδιορίσετε ότι κάτι μπορεί να είναι σαφώς τίποτα με ένα συγκεκριμένο σημασιολογικό πίσω από αυτό, ο ορισμός μοιάζει ο ίδιος, σαν να περιμένετε να υπάρχει κάτι συνεχώς. Ο εφευρέτης του μηδενικής αναφοράς Sir Tony Hoareσυγγνώμη ακόμη και για την εισαγωγή της μηδενικής αναφοράς.

Το λέω λάθος δισεκατομμυρίων δολαρίων μου… Εκείνη την εποχή, σχεδίαζα το πρώτο ολοκληρωμένο σύστημα τύπου για αναφορές σε αντικειμενοστρεφή γλώσσα. Ο στόχος μου ήταν να διασφαλίσω ότι όλη η χρήση των αναφορών θα πρέπει να είναι απολύτως ασφαλής, με τον έλεγχο να γίνεται αυτόματα από τον μεταγλωττιστή. Αλλά δεν μπορούσα να αντισταθώ στον πειρασμό να κάνω μια μηδενική αναφορά, απλώς και μόνο επειδή ήταν τόσο εύκολο να εφαρμοστεί. Αυτό έχει οδηγήσει σε αναρίθμητα σφάλματα, ευπάθειες και σφάλματα συστήματος, τα οποία πιθανώς προκάλεσαν ένα δισεκατομμύριο δολάρια πόνο και ζημιά τα τελευταία σαράντα χρόνια. (Tony Hoare, 2009 - QCon Λονδίνο)

Για να ξεπεραστεί αυτή η προβληματική κατάσταση, οι προγραμματιστές επινόησε πολλές μεθόδους, όπως οι σχολιασμοί (nullable, NotNull), ονομαστικές συμβάσεις (π.χ. πρόθεμα μια μέθοδο με την ανακάλυψη αντί πάρει ) ή απλά χρησιμοποιώντας την κωδική σχόλια για να υπαινίσσονται ότι η μέθοδος μπορεί σκόπιμα να επιστρέψει null και το επικλητής πρέπει να νοιάζεται για αυτήν την υπόθεση. Ένα καλό παράδειγμα για αυτό είναι η λειτουργία get της διεπαφής χάρτη της Java.

public V get(Object key);

Ο παραπάνω ορισμός απεικονίζει το πρόβλημα. Ακριβώς λόγω της σιωπηρής πιθανότητας ότι όλα μπορούν να είναι μηδενική αναφορά , δεν μπορείτε να κοινοποιήσετε την επιλογή ότι το αποτέλεσμα αυτής της λειτουργίας δεν μπορεί να είναι τίποτα χρησιμοποιώντας την υπογραφή της μεθόδου. Εάν ένας χρήστης αυτής της συνάρτησης εξετάσει τον ορισμό της, δεν έχουν την ευκαιρία να γνωρίζουν ότι αυτή η μέθοδος θα μπορούσε να επιστρέψει μια μηδενική αναφορά από πρόθεση - επειδή θα μπορούσε να συμβαίνει ότι δεν υπάρχει αντιστοίχιση στο παρεχόμενο κλειδί στην παρουσία του χάρτη. Και αυτό ακριβώς σας λέει η τεκμηρίωση αυτής της μεθόδου:

Επιστρέφει την τιμή στην οποία αντιστοιχίζεται το καθορισμένο κλειδί ή nullεάν αυτός ο χάρτης δεν περιέχει αντιστοίχιση για το κλειδί.

Η μόνη ευκαιρία να το γνωρίζουμε είναι να κοιτάξουμε βαθύτερα την τεκμηρίωση. Και πρέπει να θυμάστε - δεν είναι όλα τα έγγραφα καλά τεκμηριωμένα έτσι. Φανταστείτε ότι διαθέτετε εσωτερικό πλατφόρμα κώδικα στο έργο σας, ο οποίος δεν έχει σχόλια, αλλά σας εκπλήσσει με την επιστροφή μιας μηδενικής αναφοράς κάπου βαθιά στη στοίβα κλήσεων. Και εδώ είναι που εκφράζει την πιθανή απουσία μιας τιμής με έναν τύπο δεδομένων που λάμπει.

public Optional get(Object key);

Εάν ρίξετε μια ματιά στην παραπάνω υπογραφή τύπου, κοινοποιείται σαφώς ότι αυτή η μέθοδος ΜΠΟΡΕΙ να μην επιστρέψει τίποτα - σας αναγκάζει ακόμη και να ασχοληθείτε με αυτήν την περίπτωση, επειδή εκφράζεται με έναν ειδικό τύπο δεδομένων.

Επομένως, το να έχετε προαιρετικά στην Java είναι ωραίο, αλλά συναντάμε κάποιες παγίδες εάν χρησιμοποιείτε τον προαιρετικό στον κωδικό σας. Διαφορετικά, η χρήση του Προαιρετικού μπορεί να κάνει τον κώδικά σας ακόμη λιγότερο ευανάγνωστο και διαισθητικό (μακρόχρονο - λιγότερο καθαρό). Τα ακόλουθα μέρη θα καλύψουν ορισμένα μοτίβα που ανακαλύψαμε ότι είναι κάποιο είδος «αντι-μοτίβων» για το Java's Optional .

Προαιρετικό σε Συλλογές ή Ροές

Ένα μοτίβο που συναντήσαμε στον κώδικα με το οποίο εργαζόμαστε είναι να αποθηκεύονται κενά προαιρετικά σε μια συλλογή ή ως ενδιάμεση κατάσταση μέσα σε μια ροή. Συνήθως αυτό ακολουθήθηκε από το φιλτράρισμα των κενών προαιρετικών και ακόμη και με την επίκληση του Opional :: get , επειδή δεν χρειάζεται πραγματικά να έχετε μια συλλογή από προαιρετικά. Το ακόλουθο παράδειγμα κώδικα δείχνει μια πολύ απλοποιημένη περίπτωση της περιγραφόμενης κατάστασης.

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

Όπως μπορείτε να δείτε, ακόμη και σε αυτήν την απλοποιημένη περίπτωση γίνεται δύσκολο να καταλάβετε ποια είναι η πρόθεση αυτού του κώδικα. Πρέπει να ρίξετε μια ματιά στη μέθοδο findValue για να πάρετε την πρόθεση όλων. Και τώρα φανταστείτε τη μέθοδο findValue να είναι πιο περίπλοκη από τη χαρτογράφηση μιας παράστασης συμβολοσειράς στην τιμή που έχει πληκτρολογηθεί.

Υπάρχει επίσης μια ενδιαφέρουσα ανάγνωση σχετικά με το γιατί πρέπει να αποφύγετε το null σε μια συλλογή [UsingAndAvoidingNullExplained]. Σε γενικές γραμμές δεν χρειάζεται πραγματικά να έχετε ένα κενό προαιρετικό σε μια συλλογή. Αυτό συμβαίνει επειδή ένα κενό προαιρετικό είναι η αναπαράσταση για «τίποτα». Φανταστείτε να έχετε μια λίστα με τρία αντικείμενα σε αυτήν και όλα είναι κενά προαιρετικά. Στα περισσότερα σενάρια μια κενή λίστα θα ήταν σημασιολογικά ισοδύναμη.

Τι μπορούμε λοιπόν να κάνουμε γι 'αυτό; Στις περισσότερες περιπτώσεις, το σχέδιο φιλτραρίσματος πρώτα πριν από τη χαρτογράφηση οδηγεί σε πιο ευανάγνωστο κώδικα, καθώς δηλώνει άμεσα τι θέλετε να επιτύχετε, αντί να το κρύψετε πίσω από μια αλυσίδα ίσως χαρτογράφησης , φιλτραρίσματος και στη συνέχεια χαρτογράφησης .

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

If you imagine the isEnum-method to be owned by the IdEnum itself, it would become even clearer. But for the sake of having a readable code example it is not in the example. But just by reading the above example, you can easily understand what is going on, even without really having to jump into the referenced isIdEnum-method.

So, long story short — if you do not need the absence of a value expressed in a list, you do not need Optional — you just need its content, so optional is obsolete inside collections.

Optional in method parameters

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.

Συμπέρασμα (TL; DR)

  • Χρησιμοποιήστε το Προαιρετικό για να κοινοποιήσετε μια προβλεπόμενη πιθανή απουσία μιας τιμής (π.χ. την τιμή επιστροφής μιας συνάρτησης).
  • Αποφύγετε να έχετε προαιρετικά σε συλλογές ή ροές. Απλώς συμπληρώστε τις με τις παρούσες τιμές απευθείας.
  • Αποφύγετε να έχετε Optionals ως παραμέτρους των συναρτήσεων.
  • Αποφύγετε το Προαιρετικό :: isPresent ακολουθούμενο από το Προαιρετικό :: get.
  • Αποφύγετε τους πολύπλοκους ή μεταβαλλόμενους υπολογισμούς κατάστασης στο Χρησιμοποιήστε το orElseGet για αυτό.

Σχόλια και ερωτήσεις

Ποια είναι η εμπειρία σας μέχρι τώρα με τη χρήση του Java Optional; Μη διστάσετε να μοιραστείτε τις εμπειρίες σας και να συζητήσετε τα σημεία που αναφέραμε στην ενότητα σχολίων.