¿Cansado de las excepciones de puntero nulo?
Evalúe la posibilidad de usar Optional de Java SE 8.

Por Raoul-Gabriel Urma
Publicado en Agosto 2014

Un hombre muy sabio dijo alguna vez que no se es un verdadero programador de Java hasta que no se ha enfrentado una excepción de puntero nulo. Bromas aparte, la referencia nula da origen a muchos problemas porque a menudo se emplea para denotar la ausencia de un valor. Java SE 8 presenta una nueva clase denominada java.util.Optional que puede paliar algunos de esos problemas.

Empecemos con un ejemplo para ver los peligros de usar null. Pensemos, por ejemplo, en una estructura de objetos anidados para representar Computer, como se ilustra en la Figura 1.

Java8 -optional- fig.1
Figura 1: Estructura anidada para representar Computer

¿Qué problemas puede presentar el siguiente código?

String version = computer.getSoundcard().getUSB().getVersion();

El código parece bastante lógico. Sin embargo, muchas computadoras (por ejemplo, la Raspberry Pi) se distribuyen sin tarjeta de sonido. Entonces, ¿cuál es el resultado de getSoundcard()?
Una (mala) práctica habitual es devolver la referencia nula para indicar la ausencia de tarjeta de sonido. Lamentablemente, eso significa que la llamada a getUSB() tratará de devolver el puerto USB de una referencia nula; en consecuencia, se arrojará una excepción NullPointerException en tiempo de ejecución y el programa dejará de ejecutarse. Imagine que su programa se estuviera ejecutando en el equipo de un cliente: ¿qué diría ese cliente si el programa, de pronto, fallara?
Para proporcionar un poco de contexto histórico, Tony Hoare –uno de los gigantes de las ciencias de la computación– escribió: "Lo llamo mi error de los mil millones de dólares: el invento de la referencia nula en 1965. No pude resistir la tentación de insertar una referencia nula. Era tan fácil implementarla...".
¿Qué puede hacerse para evitar las excepciones de puntero nulo no intencionales? Se puede adoptar una actitud defensiva y añadir comprobaciones para evitar las referencias nulas, como se muestra en el Listado 1:

String version = "UNKNOWN";
if(computer != null){
  Soundcard soundcard = computer.getSoundcard();
  if(soundcard != null){
    USB usb = soundcard.getUSB();
    if(usb != null){
      version = usb.getVersion();
    }
  }
}

Listado 1
Sin embargo, es fácil ver que enseguida el código del Listado 1 empieza a perder elegancia debido a las comprobaciones anidadas. Por desgracia, necesitamos mucho código repetitivo para asegurarnos de no obtener un error NullPointerException. Además, resulta molesto que esas comprobaciones interfieran con la lógica de negocios. De hecho, reducen la legibilidad general del programa.
Es más, se trata de un proceso propenso a errores: ¿qué ocurriría si se olvidara de comprobar que una propiedad puede resultar nula? En el presente artículo, argumentaré que usar null para representar la ausencia de un valor constituye un enfoque erróneo. Lo que necesitamos, es una mejor manera de modelar la ausencia y la presencia de un valor.
Para proporcionar cierto contexto al análisis, examinemos qué ofrecen otros lenguajes de programación.

¿Cuáles son las alternativas al uso de null?

Los lenguajes como Groovy cuentan con un operador de navegación segura representado por "?." para sortear sin riesgos posibles referencias nulas. (Nótese que C# pronto contará también con este operador, y que se había propuesto incluirlo en Java SE 7, aunque no fue posible llegar a hacerlo en esa versión.) Funciona de la siguiente manera:

String version = computer?.getSoundcard()?.getUSB()?.getVersion();

En este caso, a la variable version se le asignará un valor null si computer es null o getSoundcard() devuelve null o getUSB() devuelve null. No es necesario introducir condiciones anidadas complejas para comprobar la presencia de null.
Además, Groovy también cuenta con el operador Elvis "?:" (si lo mira de costado, reconocerá el famoso peinado de Elvis), que puede utilizarse para casos sencillos cuando se requiere un valor predeterminado. En el siguiente ejemplo, si la expresión que utiliza el operador de navegación segura devuelve null, se devuelve el valor predeterminado "UNKNOWN"; en el caso contrario, se devuelve la etiqueta con la versión disponible.

String version = computer?.getSoundcard()?.getUSB()?.getVersion() ?: "UNKNOWN";

Otros lenguajes funcionales, como Haskell y Scala, adoptan una visión diferente. Haskell incluye un tipo Maybe, que, básicamente, encapsula un valor opcional. Un valor del tipo Maybe puede contener un valor de un tipo dado o nada. No existe el concepto de referencia nula. Scala cuenta con un constructo similar denominado Option[T] para encapsular la presencia o ausencia de un valor del tipo T. Luego es necesario comprobar de manera explícita si un valor está presente o ausente usando operaciones disponibles en el tipo Option, lo que vuelve obligatoria la "comprobación de null". Ya no es posible "olvidarse de comprobar" porque el sistema de tipos no lo permite.
Bien, nos desviamos un poco del tema y todo esto suena bastante abstracto. Tal vez se estén preguntando: "Entonces, ¿qué ofrece Java SE 8?".

Optional en pocas palabras

Java SE 8 incluye una clase nueva denominada java.util.Optional<T>, inspirada en Haskell y Scala. Se trata de una clase que encapsula un valor opcional, como se muestra en el Listado 2, incluido a continuación, y en la Figura 2. Optional puede considerarse un contenedor de valor único que o bien contiene un valor o no lo contiene (en ese caso, se dice que está "vacío"), como se muestra en la Figura 2.
Java8 -optional- fig.2
Figura 2: Tarjeta de sonido opcional

Podemos modificar nuestro modelo y usar Optional, como se observa en el Listado 2:

public class Computer {
  private Optional<Soundcard> soundcard;  
  public Optional<Soundcard> getSoundcard() { ... }
  ...
}
 
public class Soundcard {
  private Optional<USB> usb;
  public Optional<USB> getUSB() { ... }
 
}
 
public class USB{
  public String getVersion(){ ... }
}

Listado 2
En el Listado 2, es evidente de inmediato que una computadora puede o no tener tarjeta de sonido (la tarjeta de sonido es opcional). Además, la tarjeta de sonido puede contar opcionalmente con puerto USB. Se trata de una mejora respecto del modelo anterior, pues este nuevo modelo refleja con claridad si se permite que un valor determinado esté ausente. Nótese que en bibliotecas como Guava se encuentran disponibles posibilidades similares.
Pero ¿qué se puede hacer en realidad con un objeto Optional<Soundcard> ? Después de todo, usted quiere obtener el número de versión del puerto USB. En pocas palabras, la clase Optional incluye métodos que permiten ocuparse explícitamente de los casos en que un valor está presente o ausente. No obstante, la ventaja en comparación con las referencias nulas radica en que la clase Optional obliga a pensar en el caso en que el valor no esté presente. Como consecuencia, es posible prevenir las excepciones de puntero nulo no intencionales.
Es importante señalar que el objetivo de la clase Optional no es reemplazar todas las referencias nulas, sino que su propósito consiste en ayudar a diseñar rutinas API más comprensibles, tales que con solo leer la signatura de un método sea posible saber si puede esperarse la devolución de un valor opcional. Si ese es el caso, nos vemos obligados a "desencapsular" la clase Optional para actuar ante la ausencia de un valor.

Patrones para la adopción de Optional

Suficiente explicación: pasemos al código. En primer lugar, veremos cómo reescribir patrones típicos de comprobación de null utilizando Optional. Al concluir el presente artículo, usted sabrá cómo usar Optional para reescribir el código que muestra el Listado 1, en el que se realizaban varias comprobaciones de null anidadas (ver abajo):

String name = computer.flatMap(Computer::getSoundcard)
                          .flatMap(Soundcard::getUSB)
                          .map(USB::getVersion)
                          .orElse("UNKNOWN");
 

Nota: Asegúrese de repasar antes la sintaxis de las referencias a métodos y expresiones lambda de Java SE 8 (ver "Java 8: Lambdas") así como los conceptos de canalización (pipelining) de streams (ver "Processing Data with Java SE 8 Streams" [Procesamiento de datos con streams de Java SE 8]).

Creación de objetos Optional

En primer lugar, ¿cómo se crean objetos Optional? Existen diversos modos:
Este es un Optional vacío:

Optional<Soundcard> sc = Optional.empty(); 

Y este es un Optional con un valor no nulo:

SoundCard soundcard = new Soundcard();
Optional<Soundcard> sc = Optional.of(soundcard); 

Si soundcard fuera nulo, se arrojaría de inmediato una excepción NullPointerException (en lugar de obtener un error latente cuando se intente acceder a las propiedades de soundcard).
Asimismo, utilizando ofNullable, es posible crear un objeto Optional que puede contener un valor nulo:

Optional<Soundcard> sc = Optional.ofNullable(soundcard); 

Si soundcard fuera nulo, el objeto Optional resultante estaría vacío.

Hacer algo ante la presencia de un valor

Ahora que contamos con un objeto Optional, podemos recurrir a los métodos disponibles para ocuparnos de manera explícita de la presencia o ausencia de valores. En lugar de vernos obligados a recordar hacer una comprobación de null, de la manera siguiente:

SoundCard soundcard = ...;
if(soundcard != null){
  System.out.println(soundcard);
}

podemos usar el método ifPresent() como se ve a continuación:

Optional<Soundcard> soundcard = ...;
soundcard.ifPresent(System.out::println);

Ya no necesitamos efectuar una comprobación de null explícita: el sistema de tipos mismo se ocupa de ejecutarla. Si el objeto Optional estuviera vacío, no se imprimiría nada.
También podemos utilizar el método isPresent() para averiguar si hay un valor en un objeto Optional. Además, existe un método get() que devuelve el valor contenido en el objeto Optional, si estuviera presente. De lo contrario, arroja una excepción NoSuchElementException. Es posible combinar ambos métodos para prevenir excepciones, como se muestra a continuación:

if(soundcard.isPresent()){
  System.out.println(soundcard.get());
}

Sin embargo, no es este el uso recomendado de Optional (no representa una mejora significativa respecto de las comprobaciones de null anidadas); además, existen alternativas más idiomáticas, que exploraremos más adelante.

Valores predeterminados y acciones

Un patrón típico consiste en devolver un valor predeterminado si se determina que el resultado de una operación es nulo. En general, para lograr ese objetivo puede emplearse el operador ternario:

Soundcard soundcard = 
  maybeSoundcard != null ? maybeSoundcard 
            : new Soundcard("basic_sound_card");

Si se usa un objeto Optional, es posible reescribir el código anterior empleando el método orElse(), que proporciona un valor predeterminado si Optional está vacío:

Soundcard soundcard = maybeSoundcard.orElse(new Soundcard("defaut"));

De modo similar, puede utilizarse el método orElseThrow(), que, en lugar de proporcionar un valor predeterminado en caso de que Optional estuviera vacío, arroja una excepción:

Soundcard soundcard = 
  maybeSoundCard.orElseThrow(IllegalStateException::new);

Rechazo de ciertos valores con el uso del método filter

A menudo, es necesario llamar un método de un objeto y comprobar alguna propiedad. Por ejemplo, puede ser necesario verificar si el puerto USB es de una versión determinada. Para hacerlo sin riesgos, es necesario comprobar primero si la referencia que apunta a un objeto USB es nula y llamar, luego, el método getVersion(), de la siguiente manera:

USB usb = ...;
if(usb != null && "3.0".equals(usb.getVersion())){
  System.out.println("ok");
}

Es posible reescribir este patrón utilizando el método filter para un objeto Optional, como se muestra a continuación:

Optional<USB> maybeUSB = ...;
maybeUSB.filter(usb -> "3.0".equals(usb.getVersion())
                    .ifPresent(() -> System.out.println("ok"));

El método filter toma un predicado como argumento. Si hay un valor en el objeto Optional y ese valor cumple con el predicado, el método filter devuelve ese valor; de lo contrario, devuelve un objeto Optional vacío. Es posible que haya encontrado un patrón similar si ha utilizado el método filter con la interfaz Stream.

Extracción y transformación de valores con el método map

Otro patrón frecuente consiste en extraer información de un objeto. Por ejemplo, puede ocurrir que se desee extraer el objeto USB de un objeto Soundcard y comprobar, a continuación, si es de la versión correcta. El código típico sería:

if(soundcard != null){
  USB usb = soundcard.getUSB();
  if(usb != null && "3.0".equals(usb.getVersion()){
    System.out.println("ok");
  }
}

Es posible reescribir este patrón de "comprobar null y extraer" (en este caso, el objeto Soundcard) usando el método map.

Optional<USB> usb = maybeSoundcard.map(Soundcard::getUSB);

Existe un paralelo directo con el método map usado con streams. Allí, se pasa una función al método map, que aplica esa función a cada elemento de un stream. Sin embargo, si el stream está vacío no ocurre nada.
El método map de la clase Optional hace lo mismo: la función que se pasa como argumento (en este caso, una referencia a un método para extraer el puerto USB) "transforma" el valor contenido en Optional, mientras que nada ocurre si Optional está vacío.
Por último, podemos combinar el método map con el método filter para rechazar un puerto USB cuya versión no sea 3.0:

maybeSoundcard.map(Soundcard::getUSB)
      .filter(usb -> "3.0".equals(usb.getVersion())
      .ifPresent(() -> System.out.println("ok"));

Genial: nuestro código empieza a acercarse al objetivo buscado, sin comprobaciones de null explícitas que se interpongan en el camino.

Cascada de objetos Optional con el método flatMap

Ya hemos visto algunos patrones que pueden refactorizarse para usar Optional. Ahora, ¿cómo podemos escribir el siguiente código de modo seguro?

String version = computer.getSoundcard().getUSB().getVersion();

Nótese que la única función de este código es extraer un objeto de otro, exactamente el propósito del método map. En líneas anteriores, modificamos nuestro modelo de modo tal que Computer tuviera Optional<Soundcard> y Soundcard tuviera Optional<USB>, de modo que debería ser posible escribir lo siguiente:

String version = computer.map(Computer::getSoundcard)
                  .map(Soundcard::getUSB)
                  .map(USB::getVersion)
                  .orElse("UNKNOWN");

Lamentablemente, no es posible compilar este código. ¿Por qué? La variable Computer es de tipo Optional<Computer>, de modo que es correcto llamar el método map. Sin embargo, getSoundcard() devuelve un objeto de tipo Optional<Soundcard>, lo que significa que el resultado de la operación map es un objeto de tipo Optional<Optional<Soundcard>>. Como consecuencia, el llamado a getUSB() no es válido porque el Optional exterior contiene como valor otro Optional que, por supuesto, no admite el método getUSB(). La Figura 3 ilustra la estructura anidada de Optional que se obtendría.

Java8 -optional- fig.3
Figura 3: Un Optional de dos niveles

¿Cómo puede resolverse el problema? Una vez más, podemos recurrir a un patrón que tal vez usted haya usado antes con streams: el método flatMap. Con los streams, el método flatMap toma una función como argumento, lo que devuelve un nuevo stream. Esa función se aplica a cada elemento del stream, lo cual tendría como resultado un stream de streams. Sin embargo, el efecto de flatMap consiste en reemplazar cada stream que se genera por el contenido del stream de que se trate. En otras palabras, todos los streams que genera la función se amalgaman o "aplanan" en un único stream. Lo que necesitamos en este caso es algo similar, pero buscamos "aplanar" un Optional de dos niveles y obtener, en cambio, un solo nivel.
Bien, tenemos una buena noticia: Optional también admite un método flatMap. Su propósito es aplicar la función de transformación al valor de un Optional (tal como ocurre con la operación map) y, a continuación, "aplanar" el Optional de dos niveles para obtener un único nivel. La Figura 4 ilustra la diferencia entre map y flatMap cuando la función de transformación devuelve un objeto Optional.

Java8 -optional- fig.4
Figura 4: Comparación del uso de map y flatMap con Optional

Entonces, para que el código sea correcto, debemos reescribirlo de la siguiente manera usando flatMap:

String version = computer.flatMap(Computer::getSoundcard)
                   .flatMap(Soundcard::getUSB)
                   .map(USB::getVersion)
                   .orElse("UNKNOWN");

El primer flatMap garantiza que se devuelva Optional<Soundcard> en lugar de Optional<Optional<Soundcard>>, y el segundo flatMap logra el mismo objetivo con la devolución de Optional<USB>. Nótese que en el caso del tercer llamado, solo se necesita map() porque getVersion() devuelve una String en lugar de un objeto Optional.

¡Genial! Hemos avanzado muchísimo: pasamos de escribir molestas comprobaciones de null anidadas a escribir un código declarativo que es legible, admite composición y se encuentra mejor protegido de las excepciones de puntero nulo.

Conclusión

En el presente artículo, hemos abordado la adopción de la nueva clase java.util.Optional<T> de Java SE 8. El propósito de Optional no radica en reemplazar todas las referencias nulas del código, sino en ayudar a diseñar mejores rutinas API en las que, mediante la lectura de la signatura de un método, los usuarios sepan si deben o no esperar un valor opcional. Además, Optional obliga a "desencapsular" un Optional con el fin de actuar ante la ausencia de un valor; como resultado, se previene la presencia de excepciones de puntero nulo no intencionales en el código.

Información adicional

Agradecimientos

Agradezco a Alan Mycroft y Mario Fusco por emprender la aventura de escribir Java 8 in Action: Lambdas, Streams, and Functional-style Programming junto conmigo.



Raoul-Gabriel Urma (@raoulUK) está terminando su doctorado en Ciencias de la Computación en la Universidad de Cambridge, donde desarrolla su investigación en lenguajes de programación. Es coautor de Java 8 in Action: Lambdas, Streams, and Functional-style Programming, que será publicado próximamente por Manning. Además, participa habitualmente como expositor en conferencias sobre Java de primera línea (por ejemplo Devoxx y Fosdem) y se desempeña como instructor. Asimismo, ha trabajado en varias empresas prestigiosas, entre ellas el equipo Python de Google, el grupo Java Platform de Oracle, eBay y Goldman Sachs, así como en varios proyectos de nuevos emprendimientos.