12 minutos

Manejo de Excepciones en Java

Manejo de Excepciones en Java

1. Introducción

En este tutorial, abordaremos los fundamentos del manejo de excepciones en Java, así como algunas de sus particularidades.

2. Principios Básicos

2.1. ¿Qué Es?

Para comprender mejor las excepciones y el manejo de ellas, hagamos una comparación con la vida real.

Imagina que ordenamos un producto en línea, pero en el camino hay un problema en la entrega. Una buena empresa puede manejar este problema y redirigir elegantemente nuestro paquete para que aún llegue a tiempo.

De manera similar, en Java, el código puede experimentar errores mientras ejecuta nuestras instrucciones. Un buen manejo de excepciones puede manejar los errores y redirigir el programa de manera que el usuario aún tenga una experiencia positiva.

2.2. ¿Por Qué Usarlo?

Normalmente, escribimos código en un entorno idealizado: el sistema de archivos siempre contiene nuestros archivos, la red está saludable y la JVM siempre tiene suficiente memoria. A veces llamamos a esto el "camino feliz".

Sin embargo, en producción, los sistemas de archivos pueden corromperse, las redes pueden fallar y las JVM pueden quedarse sin memoria. El bienestar de nuestro código depende de cómo maneje los "caminos infelices".

Debemos manejar estas condiciones porque afectan negativamente al flujo de la aplicación y generan excepciones:

public static List<Jugador> obtenerJugadores() throws IOException {
    Path ruta = Paths.get("jugadores.dat");
    List<String> jugadores = Files.readAllLines(ruta);

    return jugadores.stream()
      .map(Jugador::new)
      .collect(Collectors.toList());
}

Este código elige no manejar la IOException, sino pasarla hacia arriba en la pila de llamadas. En un entorno idealizado, el código funciona bien.

Pero, ¿qué podría pasar en producción si falta jugadores.dat?

Exception in thread "main" java.nio.file.NoSuchFileException: players.dat <-- players.dat file doesn't exist
    at sun.nio.fs.WindowsException.translateToIOException(Unknown Source)
    at sun.nio.fs.WindowsException.rethrowAsIOException(Unknown Source)
    // ... más traza de la pila
    at java.nio.file.Files.readAllLines(Unknown Source)
    at java.nio.file.Files.readAllLines(Unknown Source)
    at Excepciones.obtenerJugadores(Excepciones.java:12) <-- La excepción ocurre en el método obtenerJugadores(), en la línea 12
    at Excepciones.main(Excepciones.java:19) <-- obtenerJugadores() es llamado por main(), en la línea 19

Sin manejar esta excepción, ¡un programa por lo demás saludable podría dejar de ejecutarse por completo! Necesitamos asegurarnos de que nuestro código tenga un plan para cuando las cosas salgan mal.

También cabe destacar otro beneficio aquí, y es la propia traza de la pila. Gracias a esta traza, a menudo podemos identificar el código ofensor sin necesidad de utilizar un depurador.

3. Jerarquía de Excepciones

En última instancia, las excepciones son simplemente objetos de Java, y todas ellas extienden de Throwable:

exceptions

Hay tres categorías principales de condiciones excepcionales:

  • Excepciones verificadas
  • Excepciones no verificadas / Excepciones en tiempo de ejecución
  • Errores

Las excepciones en tiempo de ejecución y las excepciones no verificadas se refieren a lo mismo y a menudo se pueden usar de manera intercambiable.

3.1. Excepciones Verificadas

Las excepciones verificadas son excepciones que el compilador de Java nos exige manejar. Debemos o bien declarar explícitamente que lanzaremos la excepción hacia arriba en la pila de llamadas, o debemos manejarla nosotros mismos. Hablaremos más sobre ambos en un momento.

La documentación de Oracle nos dice que debemos usar excepciones verificadas cuando razonablemente esperamos que el llamante de nuestro método pueda recuperarse.

Algunos ejemplos de excepciones verificadas son IOException y ServletException.

3.2. Excepciones No Verificadas

Las excepciones no verificadas son excepciones que el compilador de Java no nos exige manejar.

En pocas palabras, si creamos una excepción que extiende de RuntimeException, será no verificada; de lo contrario, será verificada.

Y aunque esto suena conveniente, la documentación de Oracle nos dice que hay buenas razones para ambos conceptos, como diferenciar entre un error situacional (verificado) y un error de uso (no verificado).

Algunos ejemplos de excepciones no verificadas son NullPointerException, IllegalArgumentException y SecurityException.

3.3. Errores

Los errores representan condiciones graves y generalmente irreparables, como una incompatibilidad de biblioteca, una recursión infinita o fugas de memoria.

Y aunque no extienden RuntimeException, también son no verificados.

En la mayoría de los casos, sería extraño que manejáramos, instanciáramos o extendiéramos Error(s). Por lo general, queremos que estos se propaguen hasta arriba.

Algunos ejemplos de errores son StackOverflowError y OutOfMemoryError.

4. Manejo de Excepciones

En la API de Java, hay muchos lugares donde las cosas pueden salir mal, y algunos de estos lugares están marcados con excepciones, ya sea en la firma o en el Javadoc:

/**
 * @exception FileNotFoundException ...
 */
public Scanner(String fileName) throws FileNotFoundException {
   // ...
}

Como mencionamos un poco antes, cuando llamamos a estos métodos "arriesgados", debemos manejar las excepciones verificadas y podemos manejar las no verificadas. Java nos ofrece varias formas de hacer esto:

4.1. throws

La forma más simple de "manejar" una excepción es relanzarla:

public int obtenerPuntuacionJugador(String archivoJugador)
  throws FileNotFoundException {
 
    Scanner contenido = new Scanner(new File(archivoJugador));
    return Integer.parseInt(contenido.nextLine());
}

Dado que FileNotFoundException es una excepción verificada, esta es la forma más sencilla de satisfacer al compilador, pero significa que cualquiera que llame a nuestro método ahora también debe manejarla.

parseInt puede lanzar una NumberFormatException, pero como es una excepción no verificada, no estamos obligados a manejarla.

4.2. try-catch

Si queremos intentar manejar la excepción nosotros mismos, podemos usar un bloque try-catch. Podemos manejarlo volviendo a lanzar nuestra excepción:

public int obtenerPuntuacionJugador(String archivoJugador) {
    try {
        Scanner contenido = new Scanner(new File(archivoJugador));
        return Integer.parseInt(contenido.nextLine());
    } catch (FileNotFoundException noArchivo) {
        throw new IllegalArgumentException("Archivo no encontrado");
    }
}

O realizando pasos de recuperación:

public int obtenerPuntuacionJugador(String archivoJugador) {
    try {
        Scanner contenido = new Scanner(new File(archivoJugador));
        return Integer.parseInt(contenido.nextLine());
    } catch ( FileNotFoundException noArchivo ) {
        logger.warn("Archivo no encontrado, restableciendo puntuación.");
        return 0;
    }
}

4.3. finally

Hay momentos en los que tenemos código que debe ejecutarse independientemente de si se produce una excepción, y aquí es donde entra la palabra clave finally.

En nuestros ejemplos hasta ahora, ha habido un error molesto acechando en las sombras, que es que Java, por defecto, no devolverá los identificadores de archivos al sistema operativo.

Ciertamente, ya sea que podamos leer el archivo o no, queremos asegurarnos de hacer la limpieza adecuada.

Probemos esto de la manera "perezosa" primero:

public int obtenerPuntuacionJugador(String archivoJugador)
  throws FileNotFoundException {
    Scanner contenido = null;
    try {
        contenido = new Scanner(new File(archivoJugador));
        return Integer.parseInt(contenido.nextLine());
    } finally {
        if (contenido != null) {
            contenido.close();
        }
    }
}

Aquí, el bloque finally indica qué código queremos que Java ejecute independientemente de lo que suceda al intentar leer el archivo.

Incluso si se lanza una FileNotFoundException hacia arriba en la pila de llamadas, Java llamará al contenido de finally antes de hacerlo.

También podemos manejar la excepción y asegurarnos de que nuestros recursos se cierren:

public int obtenerPuntuacionJugador(String archivoJugador) {
    Scanner contenido;
    try {
        contenido = new Scanner(new File(archivoJugador));
        return Integer.parseInt(contenido.nextLine());
    } catch (FileNotFoundException noArchivo ) {
        logger.warn("Archivo no encontrado, restableciendo puntuación.");
        return 0; 
    } finally {
        try {
            if (contenido != null) {
                contenido.close();
            }
        } catch (IOException io) {
            logger.error("No se pudo cerrar el lector!", io);
        }
    }
}

Debido a que close también es un método "arriesgado", también necesitamos capturar su excepción.

Esto puede parecer bastante complicado, pero necesitamos cada pieza para manejar correctamente cada problema potencial que pueda surgir.

4.4. try-with-resources

Afortunadamente, a partir de Java 7, podemos simplificar la sintaxis anterior cuando trabajamos con elementos que extienden AutoCloseable:

public int obtenerPuntuacionJugador(String archivoJugador) {
    try (Scanner contenido = new Scanner(new File(archivoJugador))) {
      return Integer.parseInt(contenido.nextLine());
    } catch (FileNotFoundException e ) {
      logger.warn("Archivo no encontrado, restableciendo puntuación.");
      return 0;
    }
}

Cuando colocamos referencias que son AutoCloseable en la declaración try, entonces no necesitamos cerrar el recurso nosotros mismos.

Aún podemos usar un bloque finally para realizar cualquier otro tipo de limpieza que deseemos.

En próximas publicaciones, veremos un artículo dedicado a try-with-resources para explorarlo en mayor profundidad.

4.5. Múltiples Bloques catch

A veces, el código puede generar más de una excepción, y podemos tener más de un bloque catch para manejar cada una individualmente:

public int obtenerPuntuacionJugador(String archivoJugador) {
    try (Scanner contenido = new Scanner(new File(archivoJugador))) {
        return Integer.parseInt(contenido.nextLine());
    } catch (IOException e) {
        logger.warn("¡No se pudo cargar el archivo del jugador!", e);
        return 0;
    } catch (NumberFormatException e) {
        logger.warn("¡El archivo del jugador está corrupto!", e);
        return 0;
    }
}

Los bloques catch múltiples nos brindan la oportunidad de manejar cada excepción de manera diferente, si es necesario.

También nota aquí que no capturamos FileNotFoundException, y eso se debe a que extiende IOException. Dado que estamos capturando IOException, Java considerará que cualquiera de sus subclases también está manejada.

Sin embargo, supongamos que necesitamos tratar FileNotFoundException de manera diferente a la IOException más general:

public int obtenerPuntuacionJugador(String archivoJugador) {
    try (Scanner contenido = new Scanner(new File(archivoJugador)) ) {
        return Integer.parseInt(contenido.nextLine());
    } catch (FileNotFoundException e) {
        logger.warn("¡Archivo del jugador no encontrado!", e);
        return 0;
    } catch (IOException e) {
        logger.warn("¡No se pudo cargar el archivo del jugador!", e);
        return 0;
    } catch (NumberFormatException e) {
        logger.warn("¡El archivo del jugador está corrupto!", e);
        return 0;
    }
}

Java nos permite manejar excepciones de subclases por separado, recuerda colocarlas más arriba en la lista de catch.

4.6. Bloques catch de Unión

Cuando sabemos que la forma en que manejamos los errores será la misma, Java 7 introdujo la capacidad de capturar múltiples excepciones en el mismo bloque:

public int obtenerPuntuacionJugador(String archivoJugador) {
    try (Scanner contenido = new Scanner(new File(archivoJugador))) {
        return Integer.parseInt(contenido.nextLine());
    } catch (IOException | NumberFormatException e) {
        logger.warn("Error al cargar la puntuación.", e);
        return 0;
    }
}

5. Lanzamiento de Excepciones

Si no queremos manejar la excepción nosotros mismos o queremos generar nuestras propias excepciones para que otros las manejen, entonces necesitamos familiarizarnos con la palabra clave throw.

Supongamos que tenemos la siguiente excepción verificada que hemos creado nosotros mismos:

public class ExcepcionTiempoAgotado extends Exception {
    public ExcepcionTiempoAgotado(String mensaje) {
        super(mensaje);
    }
}

y tenemos un método que podría tomar mucho tiempo en completarse:

public List<Jugador> cargarTodosLosJugadores(String archivoJugadores) {
    // ... operación potencialmente larga
}

5.1. Lanzamiento de una Excepción Verificada

Al igual que con la devolución de un método, podemos hacer throw en cualquier punto.

Por supuesto, deberíamos lanzar cuando intentamos indicar que algo ha salido mal:

public List<Jugador> cargarTodosLosJugadores(String archivoJugadores) throws ExcepcionTiempoAgotado {
    while ( !demasiadoTiempo ) {
        // ... operación potencialmente larga
    }
    throw new ExcepcionTiempoAgotado("Esta operación tardó demasiado");
}

Dado que ExcepcionTiempoAgotado es verificada, también debemos usar la palabra clave throws en la firma para que los llamadores de nuestro método sepan que deben manejarla.

5.2. Lanzamiento de una Excepción No Verificada

Si queremos hacer algo como validar la entrada, podemos usar una excepción no verificada:

public List<Jugador> cargarTodosLosJugadores(String archivoJugadores) throws ExcepcionTiempoAgotado {
    if (!esNombreDeArchivoValido(archivoJugadores)) {
        throw new IllegalArgumentException("¡El nombre de archivo no es válido!");
    }
   
    // ...
}

Dado que IllegalArgumentException no es verificada, no tenemos que marcar el método, aunque podemos hacerlo si lo deseamos.

Algunos marcan el método de todos modos como una forma de documentación.

5.3. Envoltura y Relanzamiento

También podemos elegir relanzar una excepción que hemos capturado:

public List<Jugador> cargarTodosLosJugadores(String archivoJugadores) 
  throws IOException {
    try { 
        // ...
    } catch (IOException io) { 		
        throw io;
    }
}

O hacer una envoltura y relanzamiento:

public List<Jugador> cargarTodosLosJugadores(String archivoJugadores) 
  throws ExcepcionCargaJugador {
    try { 
        // ...
    } catch (IOException io) { 		
        throw new ExcepcionCargaJugador(io);
    }
}

Esto puede ser útil para consolidar muchas excepciones diferentes en una.

5.4. Relanzamiento de Throwable o Exception

Ahora, un caso especial.

Si las únicas excepciones posibles que un bloque de código podría generar son excepciones no verificadas, entonces podemos capturar y relanzar Throwable o Exception sin agregarlas a la firma de nuestro método:

public List<Jugador> cargarTodosLosJugadores(String archivoJugadores) {
    try {
        throw new NullPointerException();
    } catch (Throwable t) {
        throw t;
    }
}

Aunque simple, el código anterior no puede lanzar una excepción verificada y, debido a eso, incluso si estamos relanzando una excepción verificada, no tenemos que marcar la firma con una cláusula throws.

Esto es útil con clases y métodos de proxy. Puedes encontrar más información al respecto aquí.

5.5. Herencia

Cuando marcamos métodos con una palabra clave throws, afecta cómo las subclases pueden anular nuestro método.

En la circunstancia en la que nuestro método lanza una excepción verificada:

public class Excepciones {
    public List<Jugador> cargarTodosLosJugadores(String archivoJugadores) 
      throws ExcepcionTiempoAgotado {
        // ...
    }
}

Una subclase puede tener una firma "menos arriesgada":

public class MenosExcepciones extends Excepciones {	
    @Override
    public List<Jugador> cargarTodosLosJugadores(String archivoJugadores) {
        // anulado
    }
}

Pero no una firma "más riesgosa":

public class MasExcepciones extends Excepciones {		
    @Override
    public List<Jugador> cargarTodosLosJugadores(String archivoJugadores) throws MiExcepcionVerificada {
        // anulado
    }
}

Esto se debe a que los contratos se determinan en tiempo de compilación por el tipo de referencia. Si creo una instancia de MasExcepciones y la guardo en Excepciones:

Excepciones excepciones = new MasExcepciones();
excepciones.cargarTodosLosJugadores("archivo");

Entonces la JVM solo me indicará que capture ExcepcionTiempoAgotado, lo cual es incorrecto ya que he dicho que MasExcepciones#cargarTodosLosJugadores lanza una excepción diferente.

En resumen, las subclases pueden lanzar menos excepciones verificadas que su superclase, pero no más.

6. Anti-Patrones

6.1. Tragar Excepciones

Ahora, hay otra manera en la que podríamos haber satisfecho al compilador:

public int obtenerPuntuacionJugador(String archivoJugador) {
    try {
        // ...
    } catch (Exception e) {} // <== captura y traga
    return 0;
}

Lo anterior se llama tragar (swallowing) una excepción. La mayoría de las veces, sería un poco malo hacer esto porque no aborda el problema y evita que otro código también pueda abordarlo.

Hay momentos en los que hay una excepción verificada que estamos seguros de que simplemente nunca ocurrirá. En esos casos, al menos deberíamos agregar un comentario indicando que hemos comido intencionalmente la excepción:

public int obtenerPuntuacionJugador(String archivoJugador) {
    try {
        // ...
    } catch (IOException e) {
        // esto nunca sucederá
    }
}

Otra forma de "tragar" una excepción es imprimir la excepción en el flujo de errores:

public int obtenerPuntuacionJugador(String archivoJugador) {
    try {
        // ...
    } catch (Exception e) {
        e.printStackTrace();
    }
    return 0;
}

Hemos mejorado un poco nuestra situación al menos escribiendo el error en algún lugar para un diagnóstico posterior.

Sería mejor, sin embargo, usar un registrador:

public int obtenerPuntuacionJugador(String archivoJugador) {
    try {
        // ...
    } catch (IOException e) {
        logger.error("No se pudo cargar la puntuación", e);
        return 0;
    }
}

Aunque es muy conveniente manejar las excepciones de esta manera, debemos asegurarnos de no tragar información importante que los llamadores de nuestro código podrían usar para solucionar el problema.

Finalmente, podemos tragar inadvertidamente una excepción al no incluirla como una causa cuando estamos lanzando una nueva excepción:

public int obtenerPuntuacionJugador(String archivoJugador) {
    try {
        // ...
    } catch (IOException e) {
        throw new ExcepcionPuntuacionJugador();
    }
}

Aquí, nos felicitamos por alertar a nuestro llamador sobre un error, pero no incluimos IOException como la causa. Debido a esto, hemos perdido información importante que los llamadores u operadores podrían usar para diagnosticar el problema.

Sería mejor hacer:

public int obtenerPuntuacionJugador(String archivoJugador) {
    try {
        // ...
    } catch (IOException e) {
        throw new ExcepcionPuntuacionJugador(e);
    }
}

Observa la sutil diferencia de incluir IOException como la causa de ExcepcionPuntuacionJugador.

6.2. Uso de return en un Bloque finally

Otra manera de tragar excepciones es retornar desde el bloque finally. Esto es perjudicial porque, al retornar abruptamente, la JVM descartará la excepción, incluso si fue lanzada por nuestro código:

public int obtenerPuntuacionJugador(String archivoJugador) {
    int puntuacion = 0;
    try {
        throw new IOException();
    } finally {
        return puntuacion; // <== la IOException se descarta
    }
}

De acuerdo con la Especificación del Lenguaje Java:

  • Si la ejecución del bloque try se completa abruptamente por cualquier otro motivo X, entonces se ejecuta el bloque finally, y luego hay una elección.

  • Si el bloque finally se completa normalmente, entonces la declaración try se completa abruptamente por la razón X.

  • Si el bloque finally se completa abruptamente por la razón Y, entonces la declaración try se completa abruptamente por la razón Y (y la razón X se descarta).

6.3. Uso de throw en un Bloque finally

Similar al uso de return en un bloque finally, la excepción lanzada en un bloque finally tomará precedencia sobre la excepción que surge en el bloque catch.

Esto "borrará" la excepción original del bloque try, y perdemos toda esa información valiosa:

public int obtenerPuntuacionJugador(String archivoJugador) {
    try {
        // ...
    } catch ( IOException io ) {
        throw new IllegalStateException(io); // <== devorada por el bloque finally
    } finally {
        throw new OtraExcepcion();
    }
}

6.4. Uso de throw como un goto

Algunas personas también sucumben a la tentación de usar throw como una declaración goto:

public void hacerAlgo() {
    try {
        // montón de código
        throw new MiExcepcion();
        // segundo montón de código
    } catch (MiExcepcion e) {
        // tercer montón de código
    }		
}

Esto es extraño porque el código está intentando usar excepciones para el control de flujo en lugar de para el manejo de errores.

7. Excepciones y Errores Comunes

Aquí hay algunas excepciones y errores comunes con los que todos nos encontramos de vez en cuando:

7.1. Excepciones Verificadas

  • IOException: Esta excepción suele indicar que algo en la red, el sistema de archivos o la base de datos falló.

7.2. Excepciones de Tiempo de Ejecución

  • ArrayIndexOutOfBoundsException: Esta excepción significa que intentamos acceder a un índice de array que no existe, como al intentar obtener el índice 5 de un array de longitud 3.

  • ClassCastException: Esta excepción significa que intentamos realizar una conversión ilegal, como intentar convertir una String en una List. Usualmente podemos evitarlo realizando comprobaciones defensivas con instanceof antes de hacer la conversión.

  • IllegalArgumentException: Esta excepción es una forma genérica de decir que uno de los parámetros proporcionados al método o constructor es inválido.

  • IllegalStateException: Esta excepción es una forma genérica de decir que nuestro estado interno, como el estado de nuestro objeto, es inválido.

  • NullPointerException: Esta excepción significa que intentamos referenciar un objeto null. Usualmente podemos evitarlo realizando comprobaciones defensivas contra null o utilizando Optional.

  • NumberFormatException: Esta excepción significa que intentamos convertir una String en un número, pero la String contenía caracteres no válidos, como intentar convertir 13f37 en un número.

7.3. Errores

  • StackOverflowError: Este error significa que la traza de la pila es demasiado grande. Esto a veces puede ocurrir en aplicaciones masivas; sin embargo, generalmente significa que hay una recursión infinita en nuestro código.

  • NoClassDefFoundError: Este error significa que una clase no se pudo cargar ya sea porque no está en el classpath o debido a un fallo en la inicialización estática.

  • OutOfMemoryError: Este error significa que la JVM no tiene más memoria disponible para asignar más objetos. A veces, esto se debe a una fuga de memoria.

8. Conclusión

En este artículo, hemos repasado los conceptos básicos del manejo de excepciones, así como algunos ejemplos de prácticas buenas y malas.