Skip to content

Optional en Java (1)

Java 8 introdujo el tipo java.util.Optional con el objeto de mejorar la expresividad de algunas interfaces y simplificar ciertos patrones de código. Amada y odiada por igual, la mayoría de los desarrolladores sólo se acuerdan de ella como la clase que prometía evitar el famoso NullPointerException.

Optional es un contenedor de valores. Pudiendo el valor estar presente o no. Así de simple. La clase está declarada de forma genérica como Optional<T>, siendo T el tipo del valor contenido, y se incluyen además de forma nativa clases especializadas como OptionalInt, OptionalLong y OptionalDouble.

La principal utilidad que se espera obtener de esta clase es su uso como tipo retornado por un método. Un método declarado como Optional<T> findFirst() tiene un contrato más estricto que otro declarado simplemente como T findFirst(). No hace falta leer la documentación del primer método para saber que el valor retornado es opcional, y que nunca retornará una referencia nula, es decir, un null.

Normalmente muchos desarrolladores dejan de leer a partir del párrafo anterior. ¿El motivo? Tienen que escribir ocho caracteres más (O-p-t-i-o-n-a-l). Pero sobre todo porque nadie, ni Java, puede garantizar que ninguna de todas las posibles implementaciones de dicho método no retornará nunca un null, independientemente de lo que estipule su contrato.

Una de las claves de todo el asunto es entender que una referencia a una instancia de tipo Optional siempre debe tener valor, es decir, nunca debe ser nula. Nunca debe escribirse código que inicialice una variable de tipo Optional a null. NUNCA debe escribirse Optional variable = null;.

La clase Optional ofrece distintos métodos estáticos a modo de factorías para construir instancias de ella misma:

El método empty retorna una instancia vacía. El método ofNullable retorna una instancia que contiene el valor pasado como parámetro, o la instancia vacía si el valor es nulo. Y el método of retorna una instancia que contiene el valor pasado como parámetro, pero eleva  NullPointerException si el valor es nulo. ¿ NullPointerException? Aquí dejan de leer los desarrolladores que no lo hicieron hace un par de párrafos.

La clase Optional se introdujo para suplir una carencia de las referencias en Java. Una referencia no es más que un puntero a una dirección de memoria. Un puntero siempre tiene valor. No existe el concepto de puntero no inicializado. Se utiliza null a forma de número mágico. Java eleva una excepción si se intenta operar con una referencia/puntero que sea igual a dicho número mágico.

Los desarrolladores son los responsables de comprobar que su código no opera sobre referencias nulas:

Por su parte, la clase Optional tiene el método isPresent, que permite comprobar si contiene algún valor o no:

Otras de las claves de todo el asunto es entender que Optional no se añadió a Java para cambiar la comparación con null por la llamada al método isPresent. Si este hubiera sido el único motivo entonces el cambio hubiera sido totalmente contraproductivo, ya que usar Optional implica gastar un poco más de memoria al tener que crear un objeto contenedor, y es un poco más lento al tener que invocar un método para comprobar si el objeto contenedor está vacío o no.

Para recuperar el valor contenido en un Optional se debe utilizar el método get:

Si la variable de tipo Optional está vacía entonces el método get eleva NoSuchElementException. Wat!? En este punto sí que ya no queda ningún desarrollador leyendo. Pero es que otra clave más de todo el asunto es entender que Optional no se añadió para sustituir la excepción NullPointerException por otra. No se debe intentar obtener el valor de un Optional si no se sabe si está presente o no. Eso sería lo mismo que intentar utilizar una referencia a un objeto sin comprobar antes que no es null.

No entender los puntos claves de todo el asunto vistos hasta ahora hace que se escriba código INCORRECTO como el siguiente:

Ni un variable de tipo Optional debe ser null. Ni la forma correcta de comprobar si contiene algún valor es recuperarlo y compararlo con null. Una variable de tipo Optional nunca debe ser  null. Y debe utilizarse el método isPresent para comprobar si contiene algún valor.

Java introdujo Optional para propiciar el uso de determinadas prácticas de diseño. Para la implementación de determinados patrones, sobre todo del ámbito de la programación funcional. No se trata de sustituir todos los tipos de parámetros, variables y valores retornados por Optional. De hecho no se recomienda para métodos que retornen colecciones, prefiriéndose retornar colecciones vacías inmutables en dicho caso. Optional sólo debe utilizarse como tipo retornado por un método cuando aporte alguna ventaja significativa.

Colecciones inmutables en Java

Hubo un tiempo en que parecía que los objetos inmutables iban a salvar el mundo. En particular cuando los gurús los pusieron de moda introduciendo la programación funcional a las masas. Hoy en día sigue siendo un patrón totalmente válido y que debería utilizarse más a menudo de lo que normalmente se hace.

En algunos lenguajes todo son objetos inmutables. La idea es que una vez que se crea un objeto éste no se puede modificar. De esta manera el objeto se puede pasar de forma segura entre distintos componentes de un sistema. Un objeto que no expone ningún mecanismo para cambiar su estado interno no se puede modificar por ningún componente que consuma dicho objeto. Los componentes que actúan como clientes pueden llamar a componentes que actúan como servidores sin preocuparse de que los objetos pasados se modifiquen.

De forma muy simplista puede visualizarse como una clase POJO (Plain Old Java Object) en la que todos los atributos son final, inicializados en el constructor, y que por tanto carece de mutators (setters). En la práctica se podría incluso prescindir de los accesors (getters) y hacer los atributos public, pero en algunos entornos esto es motivo de excomunión.

Los compiladores y máquinas virtuales pueden aplicar optimizaciones cuando se usan objetos inmutables. Un objeto en memoria no es más que una secuencia de bytes. Si se garantiza que dicha secuencia no puede cambiar, entonces es seguro pasar una referencia (puntero) a dicha secuencia a lo largo y ancho de todo un sistema. No hay que preocuparse de hacer copias o sincronizar el acceso en entornos multhilos. Y es más, si se crean varios objetos iguales entonces es seguro utilizar la misma referencia para todos ellos.

En Java los objetos inmutables han existido desde el principio, por ejemplo las instancias de las clase String son objetos inmutables. Cuando se modifica una instancia lo que se hace en realidad es crear una nueva instancia. La instancia no cambia su estado, sino que se crea una nueva instancia con un nuevo estado. Y de esta forma se eliminan los efectos laterales debidos a los cambios de estado de los objetos.

Un caso de uso habitual en el desarrollo de casi cualquier sistema es procesar una colección de objetos. Se pasa una colección a un método, y se presupone que la colección original pasada no es modificada por el método llamado. Sin embargo esta suposición es difícil de garantizar en la práctica. Se puede establecer en el contrato de la interface, pero debe ser finalmente la implementación la que asegure el cumplimiento de dicho contrato.

En Java las colecciones inmutables se han creado tradicionalmente a través de los métodos estáticos de la clase Collections, como  emptyList, para crear listas vacías, y  unmodifiableList, que admite una lista como parámetro y retorna una copia inmutable de dicha lista:

En el segundo caso, si la lista original pasada como parámetro se modifica, la lista inmutable no se ve afectada. Si se intenta añadir, modificar o borrar sobre la lista inmutable se eleva una excepción en tiempo de ejecución. Si la lista es de objetos inmutables entonces toda la lista es inmutable. Si se modifica un objeto de la lista original  parecerá que la lista inmutable se modifica también, pero en realidad lo que se modifica es el objeto. La lista inmutable sólo almacena referencias (punteros) a los objetos que contenía la lista original en el momento de su creación.

La clase Collections contiene métodos adicionales para la creación de otros tipos de colecciones inmutables, como Map y Set. Pero Java en si mismo carece de una sintaxis que permita crear colecciones inline de manera sencilla.

Crear arrays es sencillo:

Pero crear listas es sencillo sólo si se conoce el API, ¡el método está en la clase Arrays!:

Java 9 añadió un nuevo método estático of sobrecargado dentro de las interfaces List, Set y Map para facilitar la creación de colecciones inmutables:

Teniendo en cuenta que estas factorías no admiten nulos, ni duplicados en el caso concreto de Set y Map, ni claves nulas en el Map, y que dos llamadas distintas pueden retornar la misma referencia si las colecciones creadas son iguales.

Por su parte Java 10 añadió un nuevo método estático copyOf dentro de las interfaces List, Set y Map para facilitar la creación de colecciones inmutables a partir de una colección dada:

Y métodos en Collectors para facilitar la creación de colecciones inmutables a partir de un stream:

Be inmutable, my friend.

Multi-Release JAR Files

Si se está desarrollando una librería en la versión 8 de Java, no se puede empezar a utilizar las novedades introducidas en las versiones 10 de la noche a la mañana, ya que ello obligaría a los usuarios de la librería a migrar también a las nuevas versiones de Java si quieren seguir utilizando la librería.

Para intentar mitigar en parte este problema, Java permite compilar el código de forma que funcione en una versión inferior. Por ejemplo, con Java 10 se puede generar código para Java 8, pero evidentemente no se puede utilizar ninguna característica específica de Java 10.

A partir de Java 9 se pueden utilizar ficheros Multi-Release JAR (MRJAR). Un nuevo formato de fichero JAR que permite empaquetar distintas versiones de una misma clase compiladas para ser ejecutadas en distintas versiones de Java.

Supongamos que partimos de una sencilla aplicación escrita originalmente en Java 8, que se compone de las dos clases listadas a continuación, y que se limita a obtener la versión de la máquina virtual de Java que se está ejecutando leyéndola de una variable del sistema.

  • src/Main.java:

  • src/Util.java:

Aplicación que puede compilarse, empaquetarse y ejecutarse de la forma habitual con los siguientes comandos:

El resultado de la aplicación dependerá de la versión de Java utilizada para ejecutar el programa. Si se ejecuta con la máquina virtual de Java 8 (161) se obtendrá 1.8.0_161. Pero si se ejecuta con la máquina virtual de Java 10 se obtendrá 10.

Si la aplicación se compilase con Java 10 utilizando los mismos argumentos de compilación por defecto entonces no podría ejecutarse con Java 8, daría un error en tiempo de ejecución al arrancar la aplicación.

Para generar código que pueda ejecutarse en una versión anterior de Java se debe utilizar el parámetro --release seguido del número de la versión de Java para la que se quiere compilar. Por ejemplo, para compilar y empaquetar con Java 10 la aplicación indicando que se genere código compatible con la versión 8 se debe usar el siguiente comando:

El código generado de esta manera se ejecutará correctamente con la versión 8 y superiores de Java.

El parámetro --release se introdujo nuevo con la versión 9 de Java, es similar a los parámetros -source y -target ya existentes, pero además detecta si el código utiliza alguna API pública conocida de Java que no se encuentre en la versión de Java destino de la compilación.

Veamos ahora como mejorar la aplicación de ejemplo anterior, utilizando el método estático java.lang.Runtime.version, que permite a las aplicaciones obtener la versión de Java que se está ejecutando, en vez de leerla de una variable del sistema. Es un método que sólo está disponible a partir de Java 9, por lo que no se puede simplemente reemplazar el código de la clase Util original para que lo utilice si se quiere que la aplicación siga siendo compatible con Java 8.

La solución es crear una nueva clase como la listada a continuación, y empaquetarla junto con las dos anteriores en un MRJAR.

  • src-9/Util.java:

Clase que puede compilarse con el siguiente comando:

Y que puede empaquetarse junto con el resto de clases con el siguiente comando:

El parámetro --release indica la versión de Java que se debe utilizar para una lista de clases dada, hace que se añada la entrada Multi-Release: true al fichero MANIFEST.MF, y fuerza a que se cree un MRJAR con la siguiente estructura:

Cuando se ejecute la aplicación con Java 8 se ejecutará la clase Util de la raíz, y cuando se ejecute con Java 9 o superior se ejecutará la clase Util de META-INF/versions/9.

Inferencia de tipos en variables locales en Java

Una de las novedades más relevantes de la versión 10 de Java, desde el punto de vista del desarrollador, es la inferencia de tipos de las variables locales. Lo que quiere decir que ya no es necesario indicar el tipo de las variables al declararlas. El compilador calculará (inferirá) los tipos en función de cómo se inicializan las variables.

En la práctica quiere decir que el siguiente código que declara un par de variables:

ahora puede escribirse sin indicar los tipos de manera explícita con el nuevo nombre de tipo reservado  var:

var puede utilizarse sobre variables inicializadas con literales de tipo String, char, long, float, double y boolean:

var interpreta todos los valores numéricos enteros sin sufijo como de tipo int por defecto, por lo que las variables de tipo byte y short deben seguir siendo declaradas con su tipo de manera explícita o mediante un cast:

var interpreta los valores numéricos con decimales sin sufijo como double por defecto:

La inicialización de variables con tipos numéricos nulables como Integer o Double puede realizarse con el método valueOf:

El uso de var simplifica la declaración de variables en los bucles de tipo for, evitando tener que repetir el tipo de la colección recorrida, y minimizando el impacto de posibles refactorizaciones:

El uso de var evita el clásico patrón de Java de tener que importar interfaces en una clase con objeto de declarar variables inicializadas con una implementación de dicha interface:

var interpreta como Object el tipo de las clases genéricas cuando se utiliza el operador diamante en la inicialización, por lo que el tipo debe indicarse de forma explícita:

En todo caso, var no se puede utilizar sobre variables que no se inicialicen en el momento de declararse:

Teniendo en cuenta que Java es un lenguaje fuertemente tipado, al que siempre se ha acusado de ser extremadamente repetitivo, es un cambio importante y que continúa el esfuerzo realizado en la versión 8 con la introducción del operador diamante o las funciones lambda.

No se trata tanto de escribir menos código, el ahorro de caracteres netos tecleados, sino de hacerlo más conciso, legible, a la par que favorecer buenas prácticas, como la elección de nombres coherentes, o la minimización de la localidad de las variables, y simplificar algunos patrones de Java, como la declaración de variables en bucles, o la declaración de variables con una interface y su inicialización con una clase que implementa dicha interface.

Y aunque parezca algo novedoso, hay que tener en cuenta que es una característica que se encuentra presente en muchos lenguajes veteranos como C++ o recientes como Go, por citar algunos. Pero que supone un paso en la dirección correcta en todo caso.

Máquinas virtuales de Java personalizadas con jlink

Tener un runtime o máquina virtual de tamaño reducido ha sido tradicionalmente una característica bastante apreciada, ya que normalmente se traduce en un menor almacenamiento, memoria y tiempo de carga. Las tendencias tecnológicas actuales de microservicios, contenedores e Internet de las Cosas (IOT) no han variado ni un ápice esta apreciación.

Java se ha caracterizado siempre por tener una máquina virtual de un tamaño considerable y pocas opciones de personalización. Afortunadamente, con la versión 9 de Java se cambió a un diseño más modular y se publicó la herramienta jlink. El diseño modular introdujo el concepto de módulo, tanto para el código propio de la máquina virtual como para el de las aplicaciones, y la herramienta jlink dotó a los desarrolladores de una forma de crear distribuciones de Java personalizadas, en las que incluyesen tan solo los módulos que fueran necesarios para ejecutar sus aplicaciones.

Dentro del subdirectorio jmods donde se encuentre instalado Java están los distintos módulos en los que se estructura la máquina virtual. Siendo el módulo java.base el que contiene las clases básicas fundamentales necesarias para ejecutar la máquina virtual, y del que todos los demás módulos dependen, ya sea de forma directa o indirecta.

Para crear una imagen con una distribución personalizada de Java que contenga tan sólo el módulo base se debe ejecutar el siguiente comando:

Con el parámetro --module-path se indica donde se encuentran los módulos, con --add-modules la lista de módulos que se quieren incluir en la imagen, y con --output el nombre del directorio donde crear la imagen personalizada.

Como resultado de la ejecución del comando anterior se creará un directorio de nombre imagen con la misma estructura de subdirectorios que en la distribución original de Java, pero que tan sólo contendrá los archivos del módulo java.base, lo que se traduce en una ocupación de unos 36,5 MB frente a los 474 MB del JDK original.

Es posible reducir el tamaño de la imagen resultante a unos 20 MB con diversos parámetros como --compress, --no-man-pages--no-header-files y otros. Las opciones disponibles se muestran en la ayuda que se lista con el clásico parámetro --help.

La máquina virtual personalizada se ejecuta de igual forma que la original:

En todos los aspectos es una máquina virtual de Java, y se puede aplicar sobre ella por ejemplo la técnica de Class Data Sharing para generar un fichero de clases precompiladas con .\imagen\bin\java --Xshare:dump, aunque a costa de doblar el tamaño de la distribución.

De forma general, para comprobar qué módulos se encuentran en una distribución de Java se tiene que ejecutar el siguiente comando:

Ejecutado sobre la distribución original de Java lista todos los módulos, ejecutado sobre nuestra distribución personalizada lista sólo el módulo básico incluido en ella:

    java.base@10

Una distribución personalizada de Java cobra mayor sentido si se crea para ejecutar una aplicación concreta, de forma que la imagen contenga sólo los módulos que utiliza dicha aplicación. Algo sencillo de ejemplificar con una aplicación muy simple que dependa de un módulo cualquiera, como la compuesta por los siguientes dos ficheros:

  • modulo/paquete/Clase.java:

  • modulo/module-info.java:

La clase paquete.Clase simplemente invoca un método estático cualquiera de una clase de un paquete del módulo java.prefs. El fichero module-info define el módulo modulo, indica la dependencia explícita con el módulo java.prefs, y exporta el paquete paquete que contiene la clase principal de la aplicación.

La aplicación se puede compilar, empaquetar en un jar, y probar de la forma acostumbrada con los siguientes comandos:

La imagen a partir del módulo de dicha aplicación se puede crear con el siguiente comando:

Y se puede comprobar qué módulos se han incluido dentro la imagen con el siguiente comando:

El resultado mostrará cuatro módulos:

    java.prefs@10
    java.xml@10
    java.base@10
    modulo

El primer módulo es java.pref, requerido por la aplicación de forma explícita. Los dos siguientes son dependencias directas y transitivas, ya que java.prefs depende de java.xml, y java.xml depende a su vez de java.base. El último módulo es el propio de la aplicación.

El último paso es verificar que la aplicación se ejecuta correctamente con la imagen:

Como se observa en el último comando, para ejecutar la aplicación no es necesario pasar como parámetro ni el módulo ni el jar, la clase paquete.Clase es parte de la imagen.