Evitar los NullPointerException en Java con JSpecify

Escrito por picodotdev el .
java
Enlace permanente Comentarios

Si llevas tiempo programando en Java, sabrás de sobra que el NullPointerException es uno de esos errores que aparece cuando menos lo esperas y que no en vano se le conoce como el error del billón de dólares: omnipresente, frustrante y, en teoría, completamente evitable. A diferencia de lenguajes más modernos como Kotlin, Java nunca fue diseñado para garantizar la seguridad frente a nulos a nivel del sistema de tipos, aunque el ecosistema ha evolucionado si no para resolver el problema para mitigarlo. Desde la llegada de Optional en Java 8 hasta la reciente adopción de JSpecify por parte de Spring, existen hoy herramientas y estándares para detectar y mitigar los NPE en tiempo de compilación, que vale la pena conocer.

Java

Si hay un error que causa numerosos problemas en el lenguaje de programación es el de NullPointerException o NPE está en las primeras posiciones. Esto es debido a que el lenguaje de programación Java y su compilador hasta el momento no están diseñados para evitarlo.

El SDK de Java si ha añadido algunas clases y métodos para mitigar el problema de los NPE.

El siguiente artículo de Java Code Geeks explica muy bien muchos aspectos del problema, artículo que recomiendo leer. En este artículo explicaré un poco los detalles y proporciono un ejemplo de código ejecutable con la aplicación de la solución.

Qué son los punteros o referencias a objetos

Lo que en el lenguaje de programación C se llaman punteros en Java se conoce como referencias a objetos. C trabaja a más bajo nivel, Java oculta en gran parte la complejidad para el programador de referencia de objetos y desreferenciarlos. En cualquier caso es útil como es el funcionamiento a bajo nivel de C para comprender el problema de los NullPointerExceptions en Java.

Un puntero o una referencia a un objeto es una variable que contiene la dirección de memoria a una estructura de datos u objeto.

El problema de los NullPointerException

Lo que en C provoca un error fatal en el programa lo que puede provocar su finalización abrupta, en Java provoca un NullPointerException o NPE tratable de alguna forma, por ejemplo emitiendo una línea de log.

Un NullPointerException es un error en tiempo de ejecución y un error en la implementación del programa. En un programa perfectamente implementado nunca debería darse un NullPointerException. Sin embargo, los desarrolladores cometen errores y este es uno de los errores más numerosos en los programas. Pero no todo es culpa de los desarrolladores al igual que el compilador valida los tipos en el código fuente del programa al compilarlo, el mismo compilador puede ayudar a evitarlos. Al mismo tiempo, el lenguaje Java podría haberse diseñado y con la ayuda del compilador para que los programas Java estuvieran libres de NullPointerException. Como han hecho lenguajes posteriores como Kotlin. Introducir un cambio a nivel del lenguaje provocaría romper la compatibilidad hacia atrás cosa que Java se caracteriza en evitarlo.

El problema de los NullPointerException en Java surge cuando se intenta acceder a un miembro de una variable cuya referencia es null, no se puede invocar el método del objeto.

1
2
Car car = null;
car.startEngine();
Car.java

A los NPE se le conoce también como el problema del billón de dólares por su frecuencia en la mayoría de programas.

Soluciones

Hasta que los diseñadores del lenguaje Java decidan incorporar alguna sintaxis para evitar los NullPointerException en este momento hay varias opciones para mitigar el problema.

Optional

La clase Optional introducida en Java 8 es una clase que referencia un objeto o no referencia ningún objeto. Tiene métodos para conocer si referencia o no un objeto y métodos orientados a la programación funcional. Además es una clase que usa los tipos genéricos de Java introducidos en Java 5.

Sustituyendo el tipo de las variables que puedan ser null se mitigan los los NullPointerException.

1
2
Optional<Car> car = Optional.ofNullable(otherCar);
car.ifPresent(Car::startEngine);
CarOptional.java

El problema de los Optional es usar sustituir en todas las variables el tipo a Optional. En código existente y legacy esto significa mucho trabajo y propenso a introducir errores. Otra desventaja de los Optional es que hace más incómodo trabajar con objetos requiriendo el uso de sus métodos funcionales, y es innecesario cuando la referencia no hay posibilidad de que sea nula.

Entre sus ventajas está que el tipo Optional comunica de forma clara al leer el código que la referencia puede ser nula.

JSpecify

Dada la frecuencia de los NullPointerException varias organizaciones han acordado poner en común en el estándar de JSpecify una solución que use una combinación de anotaciones y la ayuda del compilador.

A fecha de hoy solo incluye anotaciones para tratar los null aunque en el futuro podría incluir alguna anotación más para el análisis estático de código.

JSpecify no evita los NPE en tiempo de ejecución simplemente hace que el compilador muestre una advertencia o error cuando se detecte un caso potencial de NPE, las anotaciones es información para el compilador. Se trata de detectar los casos en los que se usa una variable que puede ser nula

Spring Framework 6.x anotó su API pública con las anotaciones @Nullable y @NonNull de JSpecify, lo que permite que IntelliJ y NullAway propaguen el análisis de nulabilidad a través de las dependencias de Spring sin configuración extra.

Las anotaciones de JSpecify

La especificación se compone de las siguientes 4 anotaciones. Dos para anotar un tipo de datos y dos para anotar ámbitos completos.

Las 4 anotaciones core:

Anotación Ámbito Descripción
@Nullable Type El tipo puede ser null
@NonNull Type El tipo nunca será null
@NullMarked Package, Class, Method Todo en el scope es non-null por defecto, (útil en migraciones)
@NullUnmarked Nested scope Revierte el NullMarked

Una limitación de JSpecify es que las anotaciones solo tienen efecto si todo el código que interactúa con esa API también está anotado. En código legacy no anotado, el checker no puede hacer las comprobaciones y el comportamiento es como si no se usa JSpecify.

El checker

Las anotaciones por sí mismas simplemente añaden información adicional a los tipos, es necesario hacer uso de un checker para que en tiempo de compilación el compilador emita mensajes de error cuando se haga uso de una variable que potencialmente genere un NPE.

IntelliJ soporta las anotaciones de JSpecify, proporciona indicaciones en caso de detectar un potencial NullPointerException. Con la combinación de Error Prone y su plugin de checker NullAway se añade al compilador la capacidad de hacer honor a las anotaciones de JSpecify generando errores de compilación.

Este es el error que da el compilador cuando detecta un uso de valor null en una variable que está anotada como no nula.

1
2
3
4
5
6
7
8
$ ./gradlew run
...
> Task :compileJava FAILED
.../blog-ejemplos/HolaMundoSpotless/src/main/java/io/github/picodotdev/blogbitix/holamundospotless/Main.java:10: error: [NullAway] passing @Nullable parameter 'null' where @NonNull is required
        System.out.println(greeter.greet(null));
                                         ^
    (see http://t.uber.com/nullaway )
1 error
System.out

En caso de querer ignorar el error se puede hacer con la anotacinón @SuppressWarnings.

1
2
@SuppressWarnings({ "NullAway" }

supresswarnings-nullaway.java

Cómo lo soluciona Kotlin

El lenguaje basado en la JVM Kotlin más moderno que Java ha sido diseñado desde el principio para resolver el problema de los NullPointerExceptions. Para ello Kotlin tiene dos tipos distintos en el sistema de tipos para una misma clase.

Un tipo nullable y otro no nullable. El tipo nullable es potencialmente causante de un NPE, un tipo no nullable no puede causar NPE ya que su referencia siempre apuntará a una instancia del objeto. El tipo nullable se especifica con el sufijo ?.

1
2
var name: String = null   // Error de compilación
var name: String? = null  // Tipo nullable explícito
Name-1.kt

El compilador se encarga de verificar y forzar al programador a añadir comprobaciones para evitar NPE en los tipos nullables.

1
2
3
4
5
val name: String? = getName()
name.length        // No compila, name puede ser ser null
name?.length       // Llamada segura, devuelve null si name es null
name!!.length      // Fuerza el unwrap, NPE si es null el error es responsabilidad del programador
name?.length ?: 0  // Uso de elvis operator con fallback si es null
Name-2.kt

El ejemplo

El ejemplo de Spotless, Error Prone y JSpecify se puede probar con el siguiente código.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package io.github.picodotdev.blogbitix.holamundospotless;

public class Main {

    @SuppressWarnings("SelfAssignment")
    public static void main(final String[] args) {
        ...
        System.out.println(greeter.greet(null));
    }
}
Main.java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import net.ltgt.gradle.errorprone.CheckSeverity
import net.ltgt.gradle.errorprone.errorprone

plugins {
    application
    id("com.diffplug.spotless") version "7.0.2"
    id("net.ltgt.errorprone") version "5.1.0"
}

...

dependencies {
    compileOnly("org.jspecify:jspecify:1.0.0")

    errorprone("com.google.errorprone:error_prone_core:2.49.0")
    errorprone("com.uber.nullaway:nullaway:0.13.6")
}

...

tasks.withType<JavaCompile>().configureEach {
    options.encoding = "UTF-8"
    options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror"))

    options.errorprone {
        ...

        check("NullAway", CheckSeverity.ERROR)
        option("NullAway:AnnotatedPackages", "io.github.picodotdev.blogbitix")
    }
}

...
build.gradle.kts
Terminal

El código fuente completo del ejemplo puedes descargarlo del repositorio de ejemplos de Blog Bitix alojado en GitHub y probarlo en tu equipo ejecutando siguiente comando:
./gradlew run


Comparte el artículo: