Tratamiento de excepciones en Java

El tratamiento de excepciones en Java es un mecanismo del lenguaje que permite gestionar errores y situaciones excepcionales. Debido a que el tratamiento de excepciones es uno de los pilares fundamentales del lenguaje, todo programador sabe como lanzarlas y capturarlas, pero (como cualquier aspecto fundamental en programación) es necesario conocer con cierta profundidad el propósito por el cual tenemos disponible dicho mecanismo, además de hacer un uso correcto de esta funcionalidad. En este artículo vamos a ver ambos aspectos: el qué y el cómo.

1. Concepto de excepción

Una excepción en Java (así como en otros muchos lenguajes de programación) es un error o situación excepcional que se produce durante la ejecución de un programa. Algunos ejemplos de errores y situaciones excepcionales son:

  • Leer un fichero que no existe
  • Acceder al valor N de una colección que contiene menos de N elementos
  • Enviar/recibir información por red mientras se produce una perdida de conectividad

Todas las excepciones en Java se representan, como vamos a ver en la siguiente sección, a través de objetos que heredan, en última instancia, de la clase java.lang.Throwable.

2. Tipos de excepciones

El lenguaje Java diferencia claramente entre tres tipos de excepciones: errores, comprobadas (en adelante checked) y no comprobadas (en adelante unchecked). El gráfico que se muestra a continuación muestra el árbol de herencia de las excepciones en Java (se omite el paquete de todas las que aparecen, que es java.lang):

			Throwable 
			    | 
		___________________________ 
		|                         | 
	      Error                   Exception 
		|                         |                             
	       ···              ______________________ 
			        |                    | 
			       ···            RuntimeException 
						     | 
						    ···
	

La clase principal de la cual heredan todas las excepciones Java es Throwable. De ella nacen dos ramas: Error y Exception. La primera representa errores de una magnitud tal que una aplicación nunca debería intentar realizar nada con ellos (como errores de la JVM, desbordamientos de buffer, etc) y que por tanto no tienen cabida en este artículo. La segunda rama, encabezada por Exception, representa aquellos errores que normalmente si solemos gestionar, y a los que comunmente solemos llamar excepciones.

De Exception nacen múltiples ramas: ClassNotFoundException, IOException, ParseException, SQLException y otras muchas, todas ellas de tipo checked. La única excepción (valga la redundancia) es RuntimeException que es de tipo unchecked y encabeza todas las de este tipo.

A pesar de que la diferencia entre las excepciones de tipo checked y unchecked es muy importante, es también a menudo uno de los aspectos menos entendidos dentro del tratamiento de excepciones. Veamos cada una de ellas con un poco más de detalle.

3. Excepciones checked

Una excepción de tipo checked representa un error del cual técnicamente podemos recuperarnos. Por ejemplo, una operación de lectura/escritura en disco puede fallar porque el fichero no exista, porque este se encuentre bloqueado por otra aplicación, etc. Todos estas situaciones, además de ser inherentes al propósito del código que las lanza (lectura/escritura en disco) son totalmente ajenas al propio código, y deben ser (y de hecho son) declaradas y manejadas mediante excepciones de tipo checked y sus mecanismos de control.

En ciertos momentos, a pesar de la promesa de recuperabilidad, nuestro código no estará preparado para gestionar la situación de error, o simplemente no será su responsabilidad. En estos casos lo más razonable es relanzar la excepción y confiar en que un método superior en la cadena de llamadas sepa gestionarla.

Por tanto, todas las excepciones de tipo checked deben ser capturadas o relanzadas. En el primer caso, utilizamos el más que conocido bloque try-catch:

import java.io.FileWriter; 
import java.io.IOException; 

public class Main { 
    public static void main(String[] args) {         
        FileWriter fichero; 
        try { 
            // Las siguientes dos líneas pueden lanzar una excepción de tipo IOException 
            fichero = new FileWriter("ruta"); 
            fichero.write("Esto se escribirá en el fichero"); 
        } catch (IOException ioex) { 
            // Aquí capturamos cualquier excepción IOException que se lance (incluidas sus subclases) 
            ioex.printStackTrace(); 
        } 
         
    } 
}
	

En caso de querer relanzar la excepción, debemos declarar dicha intención en la firma del método que contiene las sentencias que lanzan la excepción, y lo hacemos mediante la claúsula throws:

import java.io.FileWriter; 
import java.io.IOException; 

public class Main { 
    // En lugar de capturar una posible excepción, la relanzamos 
    public static void main(String[] args) throws IOException {         
        FileWriter fichero = new FileWriter("ruta"); 
        fichero.write("Esto se escribirá en el fichero");     
    } 
}
	

Hay que tener presente que cuando se relanza una excepción estamos forzando al código cliente de nuestro método a capturarla o relanzarla. Una excepción que sea relanzada una y otra vez hacia arriba terminará llegando al método primigenio y, en caso de no ser capturada por éste, producirá la finalización de su hilo de ejecución (thread).

La dos preguntas que debemos hacernos en este momento es: ¿Cuándo capturar una excepción? ¿Cuándo relanzarla? La respuesta es muy simple. Capturamos una excepción cuando:

  • Podemos recuperarnos del error y continuar con la ejecución
  • Queremos registrar el error
  • Queremos relanzar el error con un tipo de excepción distinto

En definitiva, cuando tenemos que realizar algún tratamiento del propio error. Por contra, relanzamos una excepción cuando:

  • No es competencia nuestra ningún tratamiento de ningún tipo sobre el error que se ha producido

4. Excepciones unchecked

Una excepción de tipo unchecked representa un error de programación. Uno de los ejemplos más tipicos es el de intentar leer en un array de N elementos un elemento que se encuentra en una posición mayor que N:

int[] numerosPrimos = {1, 3, 5, 7, 9, 11, 13, 17, 19, 23};    // Array de diez elementos 
int undecimoPrimo = numerosPrimos[10];    // Accedemos al undécimo elemento mediante el literal numérico 10 
	

El código anterior accede a una posición inexistente dentro del array, y su ejecución lanzará la excepción unchecked ArrayIndexOutOfBoundsException (excepción de índice de array fuera de límite). Esto es claramente un error de programación, ya que el código debería haber comprobado el tamaño del array antes de intentar acceder a una posición concreta:

int[] numerosPrimos = {1, 3, 5, 7, 9, 11, 13, 17, 19, 23}; 
int indiceUndecimoPrimo = 10; 

if(indiceUndecimoPrimo > numerosPrimos.length) { 
    System.out.println("El índice proporcionado (" + indiceUndecimoPrimo + ") es mayor que el tamaño del array (" + numerosPrimos.length + ")"); 
} else { 
    int undecimoPrimo = numerosPrimos[indiceUndecimoPrimo]; 
    // ... 
} 
	

El código anterior no sólo valida el tamaño de la colección antes de acceder a una posición concreta (el proposito fundamental del ejemplo), sino que evita el uso de literales numéricos asignando el índice del array a una variable bien nombrada.

El aspecto más destacado de las excepciones de tipo unchecked es que no deben ser forzosamente declaradas ni capturadas (en otras palabras, no son comprobadas). Por ello no son necesarios bloques try-catch ni declarar formalmente en la firma del método el lanzamiento de excepciones de este tipo. Ésto, por supuesto, también afecta a métodos y/o clases más hacia arriba en la cadena invocante.

5. Creando nuestras propias excepciones

Aprovechando dos de las características más importantes de Java, la herencia y el polimorfismo, podemos crear nuestras propias excepciones de forma muy simple:

class CreditoInsuficienteException extends Exception { 
    // ... 
}
	

La clase del código anterior extiende a Exception y por tanto representa una excepción de tipo checked. Tal como su nombre indica, sería lanzada cuando durante una operación comercial no exista suficiente crédito. Esta situación excepcional es inherente a nuestra transacción comercial y no se genera por un defecto de código (no es un error de programación), y por tanto debe gestionarse con anterioridad:

class CarritoDeLaCompra { 
    // ... 
     
    public void pagarCompraConTarjeta() { 
        try { 
            TarjetaDeCredito tarjetaPreferida = cliente.getTarjetaDeCreditoPreferida(); 
            tarjetaPreferida.realizarPago(getImporteCompra()); 
            cliente.enviarEmailConfirmación(); 
        } catch(CreditoInsuficienteException ciex) { 
            // Informamos al usuario de crédito insuficiente 
        } 
    } 
}
	

Si por el contrario deseamos crear una excepción de tipo unchecked, debemos hacer que nuestra clase extienda (como no) de RuntimeException. Volviendo al último ejemplo, podríamos pensar que CreditoInsuficienteException podría ser declarada como una excepción de tipo unchecked, ya que siempre es posible validar el saldo de la tarjeta de credito con anterioridad al pago (como haciamos con los índices del array). Sin embargo esto no siempre es posible ni razonable ya que:

  • No disponemos del código fuente, sólo somos clientes de una librería escrita por terceros
  • Aunque dispusieramos del código fuente, no deberíamos estar autorizados a conocer el credito disponible de ningún cliente (esto es información muy sensible y por tanto, confidencial)

Antes de escribir tus propias excepciones, piensa detenidamente en que grupo encaja mejor su responsabilidad (checked vs unchecked). Programa siempre de forma inteligente.

6. Malas prácticas de uso

Para convertirnos en maestros de las excepciones, debemos evitar el uso de aquellas malas practicas que se han generalizado a los largo de los años (y por supuesto no inventar las nuestras...). La primera que vamos a ver es la más peligrosa y, a pesar de ello, también la más común:

try { 
    // Código que declara lanzar excepciónes 
} catch(Exception ex) {}
	

El código anterior ignorará cualquier excepción que se lance dentro del bloque try, o mejor dicho, capturará toda excepción lanzada dentro del bloque try pero la silenciará no haciendo nada (frustrando así el principal propósito de la gestión de excepciones checked: gestiónala o relánzala). Cualquier error de diseño, de programación o de funcionamiento en esas lineas de código pasará inadvertido tanto para el programador como para el usuario. Lo mínimamente aceptable dentro de un bloque catch es un mensaje de log informando del error:

try { 
    // Código que declara lanzar excepciónes 
} catch(Exception ex) { 
    logging.log("Se ha producido el siguiente error: " + ex.getMessage()); 
    logging.log("Se continua la ejecución"); 
}
	

Algo más razonable sería pintar una traza completa del error mediante uno de los métodos informativos de Throwable:

try { 
    // Código que declara lanzar excepciónes 
} catch(Excepcion ex) { 
    ex.printStackTrace();    // Podemos añadir cualquier tratamiento adicional antes y/o después de esta línea 
}
	

Otro abuso del mecanismo de tratamiento de excepciones es cuando se está intentando escribir código que mejore el rendimiento de la aplicación:

try { 
    int i = 0; 
    while(true) { 
        System.out.println(numerosPrimos[i++]); 
    } 
} catch(ArrayIndexOutOfBoundsException aioobex) {}
	

El ejemplo anterior itera nuestro fabuloso array de números primos sin preocuparse de los límites del array (tal como haría de manera formal un bucle for) hasta sobrepasar el índice máximo, momento en el cual se lanzará una excepción de tipo ArrayIndexOutOfBoundsException que será capturada y silenciada. Esto es un error porque:

  • El tratamiento de excepciones está diseñado para gestionar excepciones y no para realizar optimizaciones
  • El código dentro de bloques try-catch no dispone de ciertas optimizaciones de las JVM más modernas (por ejemplo, y aplicable a nuestro caso, iteración de colecciones)
  • El resultado es estéticamente horrible

Otro error común se produce cuando estamos creando nuestra propia librería de excepciones y nos excedemos declarando excepciones checked. Las excepciones checked son fabulosas ya que, al contrario que los códigos return de lenguajes como C, fuerzan al programador a manejar condiciones excepcionales, mejorando así la legibilidad del código. Sin embargo, esta obligación puede llegar a cargar el código cliente:

try { 
    // Código que declara lanzar muchas excepciónes 
} catch(UnTipoDeException ex1) { 
    // Gestionar... 
} catch(OtroTipoDeException ex2) { 
    // Gestionar... 
} catch(OtroTipoMasDeException ex3) { 
    // Gestionar... 
} catch(OtroTipoTodaviaMasDeException ex3) { 
    // Gestionar... 
}
	

El código anterior suele abrumar, y el cliente acabará tentado por la siguiente alternativa:

try { 
    // Código que declara lanzar muchas excepciónes 
} catch(Exception ex) { 
    // Gestionar cualquier excepcion, pues todas heredan de Exception 
    // Perdemos la ventaja de gestionar condiciones excepcionales concretas
} 
	

Por ello, debes pensar detenidamente si tu excepción es de tipo checked o unchecked. Recuerda, cualquier situación excepcional que deje la aplicación en un estado irrecuperable y/o no sea inherente al proposito del código que la produce debe ser declarada como una excepción de tipo unchecked.

La siguiente mala práctica que vamos a ver está intimamente relacionada con la anterior, y es la de lanzar excepciones de forma generica:

public void miMetodo() throws Exception { 
    // Código que declara lanzar muchas excepciones. 
    // Sin embargo, en la firma del método declaramos lanzar una única super-clase de todas éllas 
}
	

Nunca hagas lo anterior. Sé que lo has hecho. Yo reconozco haberlo hecho también en el pasado. Y es un absoluto ERROR. Los clientes de tu método no sabrán jamás con que condiciones especiales se pueden encontrar, y por tanto no podrán gestionarlas; no tendrán más remedio que informar del error y detener la ejecución.

Por último existe una técnica para convertir toda excepción checked en unchecked:

public void noLanzoExcepcionesChecked() { 
    try { 
        // Código que lanza una o más excepciones de tipo checked 
    } catch(Exception ex) { 
        throw new RuntimeException("Se ha producido una excepción con el mensaje: " + ex.getMessage(), ex); 
    } 
}
	

El método del código anterior convierte cualquier excepción de tipo checked en una excepción de tipo unchecked, de manera que ningún cliente suyo esté forzado a declarar/gestionar ninguna de ellas. En los últimos años ha crecido una comunidad de usuarios Java que abogan por eliminar el sistema de excepciones checked, que es justo lo que hace el código anterior. Si alguno de estos usuarios me lee, probablemente discrepe con mi idea de incluir dicho código (o cualquiera de sus variantes con menos ámbito de alcance y por tanto menos agresiva) dentro de lo que yo considero malas prácticas. Donde, y me incluyo en este grupo, unos vemos ventajas sobre el tratamiento de excepciones de tipo checked (porque nos da control sobre los errores que se producen a través de estructuras basadas en código legible y con un proposito claro), ellos ven desventajas (sobrecarga del lenguaje y en última instancia del código con una funcionalidad que, y en esto estoy de acuerdo, no es absolutamente perfecta). Existe actualmente un debate abierto sobre la verdadera utilidad de las excepciones checked. Tal vez en próximas versiones de Java (o en el próximo gran lenguaje orientado a objetos) se elimine cualquier concepto actual de error comprobado, pero en el momento actual no es así.

Personalmente considero el ejemplo anterior no un abuso del lenguaje, si no un verdadero mal uso. La razón es todavía peor a la del penúltimo ejemplo (¡aquel que te dije no hacer nunca!): no sólo estamos aniquilando toda posibilidad de un tratamiendo de excepciones que sea razonablemente útil, sino que lo más probable es que nuestra nueva y flamante excepción uncheked vaya subiendo hacia arriba en la cadena de llamadas y termine deteniendo el hilo de ejecución actual (puesto que, como ya sabes, éstas no tienen porqué ser gestionadas de forma obligatoria). A efectos prácticos, la única verdadera posibilidad de que sea capturada es que alguien haya declarado un bloque catch que declare capturar Exception (lo cual ya hemos dicho que es otro mal uso):

class MiClaseCliente() { 
    public void miMetodoCliente() { 
        try { 
            obj.noLanzoExcepcionesChecked(); 
        } catch(Exception ex) { 
            // Bloque catch extremadamente genérico (mal uso) 
            // Capturamos RuntimeException porque hereda de Exception (¿casualidad?) 
            // El bloque try debe lanzar al menos una excepción checked (o este bloque es ilegal) 
            // ¿Cuantos niveles estoy por encima del origen de la excepción convertida? 
            // ¿Y si no existiera ningún bloque como este? Detención del thread (podría haberse evitado)             
        } 
    } 
}
	

Existen ciertas situaciones en las que la conversión de una o más excepciones de tipo checked en unchecked es útil o práctica, pero son situaciones tan concretas y a menudo tan complejas y potencialmente peligrosas que no voy entrar en ningún tipo de detalle sobre éllas. Mi consejo final es: ¡NO CONVIERTAS EXCEPCIONES CHECKED EN UNCHECKED! (salvo que realmente sepas lo que haces).

7. Recomendaciones de uso

Hay algunos principios de uso que debemos ver desde la perspectiva del haz esto, en lugar del no hagas esto. Esto, que además de ser un pensamiento más positivo, me permite a mi añadir una sección más al artículo (:P) y a ti presumir de conocer tanto las malas como las buenas prácticas del uso de excepciones. Ahí es nada.

Un buen uso del tratamiento de excepciones es usar excepciones que ya existen, en lugar de crear las tuyas propias, siempre que ambas fueran a cumplir el mismo cometido (que es básicamente informar y, en caso de las checked, obligar a gestionar). Se suelen usar excepciones que ya existen cuando se dispone de un profundo conociento del API que se está usando (en otras palabras, experiencia). Si un argumento pasado a uno de tus métodos no es del tipo esperado, o no tiene el formato correcto, lanza una excepción IllegarArgumentException en lugar de crear tu propia excepción. Esto es bueno porque:

  • Uno de los pilares de Java es la reutilización de código (no reinventes la rueda)
  • Tu código es más universal (FormatoInvalidoException puede no significar nada para un germanoparlante)

Otra recomendación que no suele llevarse a cabo nunca o casi nunca es la de lanzar excepciónes acordes al nivel de abstracción en el que nos encontramos. Imaginemos una seríe de clases que actuan como capas, una encima de otra (cuanto más arriba más abstracta, cuanto más abajo más concreta). Cuando se produce un error en las capas más bajas y éste se propaga hacia arriba, llega un momento en que dicho error representando una condición excepcional muy concreta se encuentra en un contexto muy abstracto. Esto tiene básicamente tres problemas: el primero, que puede ser importante, es que estamos contaminando el API de las capas superiores con suciedad de las inferiores. El segundo, que es importante, es que estamos desvelando detalles de nuestra implementación muchos niveles por encima de lo deseable. El último problema, que es importante y puede ser critico, es que si en el futuro deseamos intercambiar una de las capas más concretas y ésta ha cambiado su implementación, todas las capas por encima se romperán. Por tanto, debemos lanzar excepciones apropiadas a la abstracción en la que nos encontramos:

try { 
    // Código que declara lanzar excepciones de bajo nivel 
} catch(BajoNivelException bnex) { 
    throw new AltoNivelException("Mensaje"); 
}
	

Por último, y para terminar con esta sección y (casi) con este artículo, debes documentar adecuadamente las excepciones que lanza tu código. Para ello, detalla en tus Javadoc todas las excepciones que lanzan tus métodos, informando que condiciones van a provocar el lanzamiento de cada una de ellas:

/** 
* @author David Marco 
* @throws MiExcepcion se lanza en caso de producirse [...] condición especial. 
*/ 
public class MiClase throws MiExcepcion { 
    // ... 
}
	

Resumen

Como con otros temas tratados con anterioridad, el tratamiento de excepciones en Java podría merecer perfectamente un volumen entero de una enciclopedia sobre programación. En este artículo he intentado exponer los aspectos que considero más importantes, y algunos que considero tan útiles como (por desgracia) desconocidos.

Como siempre os invito a poneros en contacto conmigo con cualquier tema relacionado con mis artículos, como sugerencias, correcciones y por supuesto críticas. También como siempre os doy las gracias por el apoyo y agradecimiento que me brindáis constantemente a través del correo electrónico.

¡Felices try-catch!