Evitar la comprobación de sentencias Nulas en Java

Descripción general

En general, las variables, referencias y colecciones nulas son difíciles de manejar en código Java. No solo son difíciles de identificar, sino que también son complejos de tratar.

De hecho, cualquier error al tratar con null no se puede identificar en tiempo de compilación y resulta en una excepción NullPointerException en tiempo de ejecución.

En este tutorial, echaremos un vistazo a la necesidad de verificar null en Java y varias alternativas que nos ayudan a evitar comprobaciones null en nuestro código.

Further reading:

Using NullAway to Avoid NullPointerExceptions

Learn how to avoid NullPointerExceptions using NullAway.
Read more →

Spring Null-Safety Annotations

A quick and practical guide to null-safety annotations in Spring.
Read more →

Introduction to the Null Object Pattern

Learn about the Null Object Pattern and how to implement it in Java
Read more →

What Is NullPointerException?

De acuerdo con la excepción Javadoc for NullPointerException, se lanza cuando una aplicación intenta usar null en un caso en el que se requiere un objeto, como:

  • Llamar a un método de instancia de un objeto nulo
  • Acceder o modificar un campo de un objeto nulo
  • Tomar la longitud de null como si fuera un array
  • Acceder o modificar las ranuras de null como si fuera un array
  • Lanzar null como si fuera un valor lanzable

Veamos rápidamente algunos ejemplos del código Java que causa esta excepción:

public void doSomething() { String result = doSomethingElse(); if (result.equalsIgnoreCase("Success")) // success }}private String doSomethingElse() { return null;}

Aquí, intentamos invocar una llamada a un método para una referencia nula. Esto resultaría en un NullPointerException.

Otro ejemplo común es si intentamos acceder a una matriz nula:

public static void main(String args) { findMax(null);}private static void findMax(int arr) { int max = arr; //check other elements in loop}

Esto causa una excepción de punto nulo en la línea 6.

Por lo tanto, acceder a cualquier campo, método o índice de un objeto nulo causa una excepción de punto nulo, como se puede ver en los ejemplos anteriores.

Una forma común de evitar la excepción NullPointerException es comprobar si hay null:

public void doSomething() { String result = doSomethingElse(); if (result != null && result.equalsIgnoreCase("Success")) { // success } else // failure}private String doSomethingElse() { return null;}

En el mundo real, a los programadores les resulta difícil identificar qué objetos pueden ser nulos. Una estrategia agresivamente segura podría ser comprobar null para cada objeto. Esto, sin embargo, causa muchas comprobaciones nulas redundantes y hace que nuestro código sea menos legible.

En las siguientes secciones, repasaremos algunas de las alternativas en Java que evitan tal redundancia.

Manejar null a través del Contrato de API

Como se explicó en la última sección, el acceso a métodos o variables de objetos null causa una excepción NullPointerException. También discutimos que poner una comprobación nula en un objeto antes de acceder a él elimina la posibilidad de la excepción NullPointerException.

Sin embargo, a menudo hay API que pueden manejar valores nulos. Por ejemplo:

public void print(Object param) { System.out.println("Printing " + param);}public Object process() throws Exception { Object result = doSomething(); if (result == null) { throw new Exception("Processing fail. Got a null response"); } else { return result; }}

El método print (), llamada simplemente imprimir «null» pero no una excepción. De manera similar, process() nunca devolvería null en su respuesta. Más bien lanza una excepción.

Por lo tanto, para un código de cliente que acceda a las API anteriores, no es necesario realizar una comprobación nula.

Sin embargo, dichas API deben hacerlo explícito en su contrato. Un lugar común para que las API publiquen un contrato de este tipo es el JavaDoc.

Esto, sin embargo, no da una indicación clara del contrato de API y, por lo tanto, depende de los desarrolladores de código del cliente para garantizar su cumplimiento.

En la siguiente sección, veremos cómo unos IDE y otras herramientas de desarrollo ayudan a los desarrolladores con esto.

Automatización de contratos de API

4.1. El uso de herramientas de análisis de código estático

ayuda a mejorar la calidad del código en gran medida. Y algunas de estas herramientas también permiten a los desarrolladores mantener el contrato nulo. Un ejemplo es FindBugs.

FindBugs ayuda a administrar el contrato nulo a través de las anotaciones @Nullable y @NonNull. Podemos usar estas anotaciones sobre cualquier método, campo, variable local o parámetro. Esto hace explícito al código del cliente si el tipo anotado puede ser nulo o no. Veamos un ejemplo:

public void accept(@Nonnull Object param) { System.out.println(param.toString());}

Aquí, @NonNull deja claro que el argumento no puede ser nulo. Si el código cliente llama a este método sin comprobar el argumento null, FindBugs generaría una advertencia en tiempo de compilación.

4.2. Usando soporte IDE

Los desarrolladores generalmente confían en IDE para escribir código Java. Y características como la finalización de código inteligente y advertencias útiles, como cuando una variable puede no haber sido asignada, sin duda ayudan en gran medida.

Algunos IDE también permiten a los desarrolladores administrar contratos de API y, por lo tanto, eliminar la necesidad de una herramienta de análisis de código estático. IntelliJ IDEA proporciona las anotaciones @NonNull y @Nullable. Para agregar el soporte para estas anotaciones en IntelliJ, debemos agregar la siguiente dependencia Maven:

<dependency> <groupId>org.jetbrains</groupId> <artifactId>annotations</artifactId> <version>16.0.2</version></dependency>

Ahora, IntelliJ generará una advertencia si falta la comprobación nula, como en nuestro último ejemplo.

IntelliJ también proporciona una anotación de contrato para gestionar contratos API complejos.

5. Aserciones

Hasta ahora, solo hemos hablado de eliminar la necesidad de comprobaciones nulas del código del cliente. Pero, eso rara vez se aplica en aplicaciones del mundo real.

Ahora, supongamos que estamos trabajando con una API que no puede aceptar parámetros nulos o puede devolver una respuesta nula que debe ser manejada por el cliente. Esto nos presenta la necesidad de comprobar los parámetros o la respuesta para un valor nulo.

Aquí, podemos usar Aserciones Java en lugar de la instrucción condicional de comprobación de nulos tradicional:

public void accept(Object param){ assert param != null; doSomething(param);}

En la línea 2, comprobamos si hay un parámetro nulo. Si las aserciones están habilitadas, esto daría lugar a un error de aserción.

Aunque es una buena manera de afirmar precondiciones como parámetros no nulos, este enfoque tiene dos problemas principales:Las aserciones

  1. generalmente se deshabilitan en una JVM
  2. Una aserción falsa resulta en un error sin marcar que es irrecuperable

Por lo tanto, no se recomienda que los programadores usen Aserciones para verificar condiciones. En las siguientes secciones, discutiremos otras formas de manejar validaciones nulas.

Evitar Comprobaciones Nulas Mediante Prácticas De Codificación

6.1. Precondiciones

Por lo general, es una buena práctica escribir código que falla temprano. Por lo tanto, si una API acepta varios parámetros que no pueden ser nulos, es mejor verificar todos los parámetros no nulos como condición previa de la API.

Por ejemplo, veamos dos métodos: uno que falla temprano y otro que no:

public void goodAccept(String one, String two, String three) { if (one == null || two == null || three == null) { throw new IllegalArgumentException(); } process(one); process(two); process(three);}public void badAccept(String one, String two, String three) { if (one == null) { throw new IllegalArgumentException(); } else { process(one); } if (two == null) { throw new IllegalArgumentException(); } else { process(two); } if (three == null) { throw new IllegalArgumentException(); } else { process(three); }}

Claramente, deberíamos preferir goodAccept() sobre badAccept().

Como alternativa, también podemos usar las precondiciones de Guayaba para validar los parámetros de la API.

6.2. Usando Primitivas En lugar de Clases de envoltura

Dado que null no es un valor aceptable para primitivas como int, deberíamos preferirlas a sus contrapartes de envoltura como Integer siempre que sea posible.

Considere dos implementaciones de un método que suma dos enteros:

public static int primitiveSum(int a, int b) { return a + b;}public static Integer wrapperSum(Integer a, Integer b) { return a + b;}

Ahora, llamemos a estas API en nuestro código de cliente:

int sum = primitiveSum(null, 2);

Esto resultaría en un error en tiempo de compilación ya que null no es un valor válido para una int.

Y al usar la API con clases de envoltura, obtenemos una excepción NullPointerException:

assertThrows(NullPointerException.class, () -> wrapperSum(null, 2));

También hay otros factores para usar primitivas sobre envoltorios, como cubrimos en otro tutorial, Primitivas de Java versus objetos.

6.3. Colecciones vacías

Ocasionalmente, necesitamos devolver una colección como respuesta de un método. Para estos métodos, siempre debemos intentar devolver una colección vacía en lugar de null:

public List<String> names() { if (userExists()) { return Stream.of(readName()).collect(Collectors.toList()); } else { return Collections.emptyList(); }}

Por lo tanto, hemos evitado la necesidad de que nuestro cliente realice una comprobación de null al llamar a este método.

Usando objetos

Java 7 introdujo la nueva API de objetos. Esta API tiene varios métodos de utilidad estáticos que eliminan una gran cantidad de código redundante. Veamos uno de estos métodos, requireNonNull():

public void accept(Object param) { Objects.requireNonNull(param); // doSomething()}

Ahora, probemos el método accept ():

assertThrows(NullPointerException.class, () -> accept(null));

Así que, si se pasa null como argumento, accept() lanza una excepción NullPointerException.

Esta clase también tiene métodos isNull() y NonNull() que se pueden usar como predicados para comprobar si un objeto es nulo.

Utilizando la opción

8.1. Usando orElseThrow

Java 8 introdujo una nueva API opcional en el lenguaje. Esto ofrece un mejor contrato para el manejo de valores opcionales en comparación con null. Veamos cómo Opcional elimina la necesidad de comprobaciones nulas:

public Optional<Object> process(boolean processed) { String response = doSomething(processed); if (response == null) { return Optional.empty(); } return Optional.of(response);}private String doSomething(boolean processed) { if (processed) { return "passed"; } else { return null; }}

Al devolver un método Opcional, como se muestra arriba, el método de proceso deja claro al llamante que la respuesta puede estar vacía y debe manejarse en tiempo de compilación.

Esto elimina notablemente la necesidad de cualquier comprobación nula en el código del cliente. Una respuesta vacía se puede manejar de manera diferente utilizando el estilo declarativo de la API opcional:

assertThrows(Exception.class, () -> process(false).orElseThrow(() -> new Exception()));

Además, también proporciona un mejor contrato a los desarrolladores de API para indicar a los clientes que una API puede devolver una respuesta vacía.

Aunque eliminamos la necesidad de una comprobación nula en el llamante de esta API, la usamos para devolver una respuesta vacía. Para evitar esto, Optional proporciona un método ofNullable que devuelve un Opcional con el valor especificado, o vacío, si el valor es nulo:

public Optional<Object> process(boolean processed) { String response = doSomething(processed); return Optional.ofNullable(response);}

8.2. Usar Opcional con colecciones

Al tratar con colecciones vacías, Opcional es útil:

public String findFirst() { return getList().stream() .findFirst() .orElse(DEFAULT_VALUE);}

Se supone que esta función devuelve el primer elemento de una lista. La función findFirst de la API de flujo devolverá una opción vacía cuando no haya datos. Aquí, hemos utilizado OrElse para proporcionar un valor predeterminado en su lugar.

Esto nos permite manejar listas vacías, o listas, que después de haber utilizado el método de filtro de la biblioteca de secuencias, no tienen elementos que suministrar.

Alternativamente, también podemos permitir que el cliente decida cómo manejar vacío devolviendo Opcional desde este método:

public Optional<String> findOptionalFirst() { return getList().stream() .findFirst();}

Por lo tanto, si el resultado de getList está vacío, este método devolverá una opción vacía al cliente.

El uso de Optional with collections nos permite diseñar API que seguramente devolverán valores no nulos, evitando así verificaciones nulas explícitas en el cliente.

Es importante tener en cuenta que esta implementación se basa en que getList no devuelve null. Sin embargo, como discutimos en la última sección, a menudo es mejor devolver una lista vacía en lugar de una nula.

8.3. Combinar opcionales

Cuando empezamos a hacer que nuestras funciones vuelvan opcionales, necesitamos una forma de combinar sus resultados en un solo valor. Tomemos nuestro ejemplo de lista de favoritos de antes. Lo que si es a devolver una lista Opcional, o se envuelve con un método que envuelve un nulo con Opcional con ofNullable?

Nuestro método findFirst quiere devolver un primer elemento Opcional de una lista Opcional:

public Optional<String> optionalListFirst() { return getOptionalList() .flatMap(list -> list.stream().findFirst());}

Mediante el uso de la función flatMap en el elemento Opcional devuelto desde getOptional podemos descomprimir el resultado de una expresión interna que devuelve Opcional. Sin flatMap, el resultado sería Opcional<Opcional<String>>. La operación Mapa plano solo se realiza cuando la opción no está vacía.

Bibliotecas

9.1. Usar Lombok

Lombok es una gran biblioteca que reduce la cantidad de código repetitivo en nuestros proyectos. Viene con un conjunto de anotaciones que toman el lugar de partes comunes de código que a menudo escribimos nosotros mismos en aplicaciones Java, como getters, setters y toString (), por nombrar algunos.

Otra de sus anotaciones es @distinto de null. Por lo tanto, si un proyecto ya usa Lombok para eliminar código repetitivo, @NonNull puede reemplazar la necesidad de comprobaciones nulas.

Antes de pasar a ver algunos ejemplos, agreguemos una dependencia Maven para Lombok:

<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.6</version></dependency>

Ahora, podemos usar @NonNull siempre que se necesite una comprobación nula:

public void accept(@NonNull Object param){ System.out.println(param);}

Por lo tanto, simplemente anotamos el objeto para el que se habría requerido la comprobación nula, y Lombok genera la clase compilada:

public void accept(@NonNull Object param) { if (param == null) { throw new NullPointerException("param"); } else { System.out.println(param); }}

Si param es null, este método lanza una excepción NullPointerException. El método debe hacer esto explícito en su contrato, y el código de cliente debe manejar la excepción.

9.2. Usando StringUtils

Generalmente, la validación de cadenas incluye una comprobación de un valor vacío además del valor nulo. Por lo tanto, una instrucción de validación común sería:

public void accept(String param){ if (null != param && !param.isEmpty()) System.out.println(param);}

Esto se vuelve rápidamente redundante si tenemos que lidiar con muchos tipos de cadenas. Aquí es donde StringUtils es útil. Antes de ver esto en acción, agreguemos una dependencia Maven para commons-lang3:

<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.11</version></dependency>

Ahora refactoricemos el código anterior con StringUtils:

public void accept(String param) { if (StringUtils.isNotEmpty(param)) System.out.println(param);}

Por lo tanto, reemplazamos nuestra comprobación nula o vacía con un método de utilidad estático isNotEmpty(). Esta API ofrece otros métodos de utilidad potentes para manejar funciones de cadena comunes.

Conclusión

En este artículo, analizamos las diversas razones para NullPointerException y por qué es difícil de identificar. Luego, vimos varias formas de evitar la redundancia en el código en torno a la comprobación de null con parámetros, tipos de retorno y otras variables.

Todos los ejemplos están disponibles en GitHub.

empezar con el Muelle 5 y el Resorte de Arranque 2, a través del Aprender de Primavera del curso:

>> COMPRUEBE EL CURSO



Deja una respuesta

Tu dirección de correo electrónico no será publicada.