por Mervyn McCreight y Mehmet Emin Tok

Visión general
En este artículo, vamos a hablar sobre las experiencias que recopilamos mientras trabajamos con el tipo de datos Opcional de Java, que se ha introducido con Java 8. Durante nuestro trabajo diario, encontramos algunos "anti-patrones" que queríamos compartir. Nuestra experiencia fue que si evita estrictamente tener esos patrones en su código, es muy probable que llegue a una solución más limpia.
Opcional: la forma de Java de expresar explícitamente la posible ausencia de un valor
El propósito de Opcional es expresar la posible ausencia de un valor con un tipo de datos en lugar de tener la posibilidad implícita de tener un valor ausente solo porque existe una referencia nula en Java.
Si echa un vistazo a otros lenguajes de programación, que no tienen un valor nulo , describen la posible ausencia de un valor a través de tipos de datos. En Haskell, por ejemplo, se hace usando Maybe, que en mi opinión ha demostrado ser una forma eficaz de manejar un posible “sin valor”.
data Maybe a = Just a | Nothing
El fragmento de código anterior muestra la definición de Maybe en Haskell. Como puede ver, Maybe a está parametrizado por la variable de tipo a , lo que significa que puede usarlo con cualquier tipo que desee. Declarar la posibilidad de un valor ausente usando un tipo de datos, por ejemplo, en una función, lo obliga a usted como usuario de la función a pensar en los dos posibles resultados de una invocación de la función: el caso en el que realmente hay algo significativo presente y el caso donde no lo es.
Antes de que Optional se introdujera en Java, la "forma java" a seguir si deseaba describir la nada era la referencia nula , que se puede asignar a cualquier tipo. Debido a que todo puede ser nulo, se ofusca si algo está destinado a ser nulo (por ejemplo, si desea que algo represente un valor o nada) o no (por ejemplo, si algo puede ser nulo, porque todo puede ser nulo en Java, pero en el flujo de la aplicación, no debe ser nulo en ningún momento).
Si desea especificar que algo puede ser explícitamente nada con una determinada semántica detrás, la definición se ve igual, como si esperara que algo esté presente todo el tiempo. El inventor del sir Tony Hoare de referencia nulaincluso se disculpó por la introducción de la referencia nula.
Yo lo llamo mi error de mil millones de dólares ... En ese momento, estaba diseñando el primer sistema de tipos completo para referencias en un lenguaje orientado a objetos. Mi objetivo era garantizar que todo uso de referencias fuera absolutamente seguro, y que el compilador las verificara automáticamente. Pero no pude resistir la tentación de poner una referencia nula, simplemente porque era muy fácil de implementar. Esto ha llevado a innumerables errores, vulnerabilidades y caídas del sistema, que probablemente han causado mil millones de dólares en dolor y daño en los últimos cuarenta años. (Tony Hoare, 2009 - QCon Londres)Para superar esta situación problemática, los desarrolladores inventaron muchos métodos como anotaciones (Nullable, NotNull), convenciones de nomenclatura (por ejemplo, prefijar un método con find en lugar de get ) o simplemente usar comentarios de código para insinuar que un método puede devolver intencionalmente null y el invocador debería preocuparse por este caso. Un buen ejemplo de esto es la función get de la interfaz de mapa de Java.
public V get(Object key);
La definición anterior visualiza el problema. Solo por la posibilidad implícita de que todo pueda ser una referencia nula , no se puede comunicar la opción de que el resultado de esta función puede ser nada usando la firma del método. Si un usuario de esta función mira su definición, no tiene la oportunidad de saber que este método podría devolver una referencia nula por intención, porque podría darse el caso de que no exista una asignación a la clave proporcionada en la instancia de mapa. Y esto es exactamente lo que te dice la documentación de este método:
Devuelve el valor al que se asigna la clave especificada, onull
si este mapa no contiene ninguna asignación para la clave.
La única posibilidad de saberlo es profundizando en la documentación. Y debe recordar que no todo el código está bien documentado de esta manera. Imagine que tiene un código interno de la plataforma en su proyecto, que no tiene comentarios, pero lo sorprende al devolver una referencia nula en algún lugar profundo de su pila de llamadas. Y aquí es donde brilla la expresión de la posible ausencia de un valor con un tipo de datos.
public Optional get(Object key);
Si echa un vistazo a la firma de tipo anterior, se comunica claramente que este método PUEDE no devolver nada, incluso lo obliga a lidiar con este caso, porque se expresa con un tipo de datos especial.
Entonces, tener Opcional en Java es bueno, pero encontramos algunos errores si usa Opcional en su código. De lo contrario, el uso de Opcional podría hacer que su código sea aún menos legible e intuitivo (en pocas palabras, menos limpio). Las siguientes partes cubrirán algunos patrones que descubrimos que son una especie de "anti-patrones" para el Opcional de Java .
Opcional en colecciones o transmisiones
Un patrón que encontramos en el código con el que trabajamos es tener opcionales vacíos almacenados en una colección o como un estado intermedio dentro de una secuencia. Normalmente, esto fue seguido por el filtrado de los opcionales vacíos e incluso seguido por la invocación de Optional :: get , porque realmente no necesita tener una colección de opcionales. El siguiente ejemplo de código muestra un caso muy simplificado de la situación descrita.
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());
Como puede ver, incluso en este caso simplificado, resulta difícil entender cuál es la intención de este código. Tienes que echar un vistazo al método findValue para obtener la intención de todo. Y ahora imagine que el método findValue es más complejo que mapear una representación de cadena a su valor con tipo de enumeración.
También hay una lectura interesante sobre por qué debería evitar tener nulos en una colección [UsingAndAvoidingNullExplained]. En general, no es necesario tener un opcional vacío en una colección. Esto se debe a que un opcional vacío es la representación de "nada". Imagínese tener una lista con tres elementos y todos son opcionales vacíos. En la mayoría de los escenarios, una lista vacía sería semánticamente equivalente.
Entonces, ¿qué podemos hacer al respecto? En la mayoría de los casos, el plan de filtrar primero antes del mapeo conduce a un código más legible, ya que indica directamente lo que desea lograr, en lugar de esconderlo detrás de una cadena de tal vez mapeo , filtrado y luego mapeo .
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());
Si imagina que el método isEnum es propiedad del propio IdEnum, se volvería aún más claro. Pero en aras de tener un ejemplo de código legible, no está en el ejemplo. Pero con solo leer el ejemplo anterior, puede comprender fácilmente lo que está sucediendo, incluso sin tener que saltar al método isIdEnum al que se hace referencia .
Entonces, para resumir, si no necesita la ausencia de un valor expresado en una lista, no necesita Opcional, solo necesita su contenido, por lo que lo opcional es obsoleto dentro de las colecciones.
Opcional en los parámetros del método
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
- a simple default value that is not hard to calculate (like a constant)
- a not too memory consuming default value
- 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.