En esta sección exploraremos qué son las excepciones, cómo manejarlas y por qué son importantes en la programación orientada a objetos.
Una excepción es un evento anormal que ocurre durante la ejecución de un programa y puede interrumpir el flujo normal de ejecución. Estos eventos pueden ser errores lógicos, como la división por cero, o situaciones imprevistas, como el error de lecura de un archivo que el programa intenta abrir.
En Java las excepciones se representan mediante objetos de clases que heredan de la clase Throwable. Estos objetos encapsulan información sobre el error, como el tipo de excepción, el mensaje de error y la pila de llamadas que muestra dónde ocurrió el error.
Las excepciones son clases, por ende tipos de datos, y también se relacionan a través de la herencia. Esto nos ofrece comportamiento polimórfico en los objetos de excepciones, algo muy útil al momento de definir handlers de excepciones. Veamos algunas excepciones predefinidas en Java:
En Java existen dos tipos de excepciones: las excepciones verificadas (checked exceptions) y las excepciones no verificadas (unchecked exceptions).
-
Checked Exceptions: Son excepciones que el compilador requiere que se manejen explícitamente en el código. Por ejemplo, IOException o SQLException. Deben ser capturadas o propagadas usando un bloque try-catch, o declarando que el método las lanza con la palabra clave throws al final de la firma.
Diseñaremos o usaremos checked exceptions (subtipos de Exception) cuando esperamos que el programa se recupere razonablemente ante ese tipo de evento anómalo.
-
Unchecked exceptions: Son excepciones que no se requieren manejar de manera explícita y pueden ocurrir en cualquier momento durante la ejecución del programa. No deberíamos capturarlas sino permitir que se propaguen hasta detener la ejecución para informar el problema.
- Error exceptions: Son excepciones que heredan de la clase Error que indican eventos excepcionales externos a la aplicación, por lo cual no pueden ser anticipados para recuperar así una normal ejecución. Suelen estar asociados a errores de hardware, por ejemplo, IOError. Por convención se reservan para uso de la JVM y no es recomendable definir excepciones personalizadas de este tipo.
- Runtime exceptions: Son excepciones que heredan de la clase RuntimeException que indican eventos excepcionales internos en la aplicación. Suelen indicar errores de programación (bugs), en el mal uso o consumo de una API, generalmente violando precondiciones o ignorando validaciones. Ejemplos comunes incluyen NullPointerException o ArrayIndexOutOfBoundsException.
Diseñaremos o usaremos unchecked exceptions (subtipos de RuntimeException) cuando esperamos que el programa no se recupere ante ese tipo de evento anómalo, por lo cual no deberían ser capturadas porque podrían dejar al programa en un estado inconsistente.
Dados los siguientes casos de error, determinar si corresponde modelar la excepción de tipo checked o unchecked.
- División de un número por 0.
- Formato de número de teléfono incorrecto.
- No se puede abrir el archivo solicitado.
- No existe el archivo a acceder.
- Operación aritmética no soportada en cierto tipo de dato.
- Se superó el límite de capacidad de la estructura.
- La configuración externa de la aplicación es incorrecta.
- Falló la conexión a la base de datos.
- Se convierte una referencia a un subtipo (downcasting) que no corresponde.
El mecanismo con el cual determinamos cuándo se produce un evento anormal se denomina lanzamiento de una exepción. Cuando se produce un error que deseamos modelar con este mecanismo de excepciones, se genera un objeto de tipo Throwable (exception object) que contiene información del error específico y se lo entregamos al entorno de ejecución para que sea capturado (o no) por algún método previo en la pila de ejecución.
Veamos este proceso en los siguientes pasos:
1. Ocurrencia de un Evento Anormal
Un evento anormal, como un acceso a un índice fuera de rango en un arreglo o la apertura de un archivo que no existe, ocurre durante la ejecución del programa en cierto método.
2. Creación de un Objeto de Excepción
Cuando ocurre el evento anormal se crea un objeto de excepción que encapsula información sobre el error. Como mencionamos, este objeto pertenece a una clase que hereda de la clase base Throwable, aunque seguramente sea de alguna especialización de ésta. Por ejemplo, si ocurre una división por cero, se crea un objeto ArithmeticException para describir lo mejor posible el evento ocurrido.
3. Lanzamiento de la Excepción
Una vez que se crea el objeto de excepción, se lanza al flujo de ejecución. Esto se hace utilizando la palabra clave throw:
throw algunObjetoThrowable;
Por ejemplo, instanciamos un objeto de ArithmeticException y lo lanzamos así:
throw new ArithmeticException("División por cero");
Una excepción lanzada puede ser capturada o no por los métodos que se encuentran en la pila de ejecución. Por ejemplo, si un método1 invoca a un método2 y este último lanza una excepción de tipo IndexOutOfBoundsException, el método1 podría intentar capturarla (catch) para manejarla y así evitar que se interrumpa la ejecución del programa. Si ningún método en la pila de ejecución captura la excepción, entonces el programa se detiene de forma imprevista y presenta el error asociado.
Continuando el proceso previo donde se lanzó una excepción, veamos los pasos que siguen:
4. Búsqueda de un Manejador de Excepción
El entorno de ejecución comienza a buscar un bloque try-catch adecuado para manejar (handle) la excepción. Comienza en el punto donde se lanzó la excepción y busca en la pila de ejecución en orden reverso algún manejador (handler) que pueda manejar el tipo de excepción lanzado. Esto se conoce como desapilamiento de la pila de ejecución (unwinding the call stack).
Si se encuentra un handler adecuado en la pila de ejecución, el flujo de control se desplaza al bloque catch correspondiente. Recordemos que ese bloque catch debe tratar el tipo de excepción lanzada, de lo contrario no puede capturarla.
5. Manejo de la Excepción o Propagación
Dentro del bloque catch que corresponde se puede manejar la excepción de manera adecuada. Esto puede incluir la impresión de un mensaje de error, la recuperación de datos o la toma de decisiones basadas en la excepción. Después de manejar la excepción, el programa puede continuar su ejecución normalmente a partir del punto donde se manejó la excepción.
Si no se maneja la excepción en el lugar donde se lanzó, la excepción se propaga hacia atrás en la pila en busca de un manejador adecuado. Si no se encuentra un manejador en ninguna parte de la pila, el programa se detendrá y mostrará un mensaje de error.
También es posible que en el bloque catch donde se maneja una excepción de cierto tipo, luego se lance otra excepción de mismo tipo u otro, lo que se denomina chained exceptions.
Java provee un mecanismo que permite validar en tiempo de compilación si nuestro manejo de excepciones es correcto. Para ello establece un acuerdo que denomina Catch or Specify Requirement. Si no cumplimos este acuerdo, no podremos compilar.
Todo código que pueda lanzar una checked exception debe estar abarcado por alguno de los siguientes:
- Un bloque try-catch que maneje ese tipo de excepción.
- Un método que especifique que puede lanzar ese tipo de excepción (throws en la firma).
Java proporciona bloques try-catch para manejar excepciones. El bloque try abarca el código que puede generar alguna excepción. Seguido del bloque try se definen uno o más bloques catch que actúan como manejadores o handlers de cierto tipo de excepción y tienen dentro el código a ejecutar en cada caso.
try {
// Código que puede generar una excepción
int resultado = 10 / 0; // Esto generará una ArithmeticException
} catch (ArithmeticException e) {
// Manejo de la excepción
System.out.println("Error: División por cero. " + e.getMessage());
}
En este ejemplo, el código dentro del bloque try puede generar una excepción. Si ocurre una excepción, el flujo de control se desplaza al primer bloque catch (en este caso hay uno solo que maneja el tipo de excepción ArithmeticException), donde se puede manejar la excepción de manera adecuada, como mostrar un mensaje de error.
El objeto de excepción (en el ejemplo, e) contiene métodos heredados que ofrecen información del error. Podríamos sobreescribirlos o crear nuevos con nuestras excepciones propias.
Si el tipo de excepción lanzada dentro del try no coincide con el tipo de excepción establecido en el catch, se avanza al próximo bloque catch. Si ningún catch puede manejar ese tipo de excepción, se propaga la excepción hacia atrás en la pila de ejecución.
try {
// Código que puede generar una excepción
} catch (NumberFormatException | IndexOutOfBoundsException e) {
// Manejo de la excepción NumberFormatException o IndexOutOfBoundsException
} catch (IllegalArgumentException e) {
// Manejo de la excepción IllegalArgumentException
} catch (RuntimeException e) {
// Manejo de la excepción RuntimeException
} catch (Exception e) {
// Manejo de la excepción Exception
}
En este caso definimos cuatro handlers que tratan diferentes excepciones. Notemos que el primero trata excepciones de tipo NumberFormatException o IndexOutOfBoundsException. El segundo trata excepciones de tipo IllegalArgumentException, la cual es superclase de NumberFormatException. Es importante tener esto presente porque si invertimos el orden de los catch no vamos a poder tratar la excepción NumberFormatException específicamente porque siempre la capturaría el handler más general de IllegalArgumentException. Por lo cual, siempre definimos primero las más especializadas y luego las más generales. Los dos handlers finales tratan excepciones bastante abstractas, lo cual no está recomendado, pero se muestra para reforzar el concepto de captura según orden de herencia.
Además del bloque try-catch, Java también proporciona el bloque finally. El código dentro del bloque finally se ejecuta siempre, independientemente de si se produce una excepción o no. Esto es útil para realizar tareas de limpieza, como cerrar archivos o conexiones de bases de datos, asegurando que se realicen incluso en caso de una excepción.
try {
// Código que puede generar una excepción
} catch (IOException e) {
// Manejo de la excepción
} finally {
// Código que se ejecutará siempre
}
En versiones previas de Java se utilizaba el bloque finally para cerrar recursos que se abrían dentro del bloque try. A partir de Java 7 aparece el bloque try-with-resources que encapsula este comportamiento y es recomendable para tratar estos casos. En este bloque declaramos uno o más recursos que serán utilizados (archivos, conexiones, etc).
Los recursos deben ser objetos de tipo AutoCloseable, separados por ; si es más de uno.
try (BufferedReader br = new BufferedReader(new FileReader("/tmp/miarchivo.txt"))) {
String linea;
while ((linea = br.readLine()) != null) {
System.out.println(linea);
}
}
catch (IOException e) {
// Manejo de la excepción ante error de lectura
}
En este ejemplo, tanto BufferedReader como FileReader son recursos que implementan AutoCloseable, por lo cual contienen un método close() que se invoca implícitamente siempre al finalizar el bloque try o si ocurre cualquier tipo de excepción. Esto nos permite evitar definir el bloque finally para cerrar estos recursos.
Si en el bloque try se lanza una excepción y también se lanza una en los recursos (por ejemplo al tratar de cerrarlos), la primera suprime a la segunda y es la que se propaga. Podemos acceder a las excepciones suprimidas con el método getSuppressed del objeto de la excepción lanzada.
En algunos casos es mejor no manejar una excepción en un método, sino propagarla hacia arriba en la jerarquía de invocaciones almacenada en la pila de ejecución. Para respetar el acuerdo Catch or Specify Requirement, si la excepción es checked, debemos agregar la palabra clave throws en la firma del método para indicar que el método puede arrojar ese tipo de excepción.
public void miMetodo() throws MiAppException {
// Código que puede generar MiAppException
}
Supongamos que mi MiAppException hereda directamente de Exception, entonces es de tipo checked y estamos obligados por el compilador en especificarla en la firma del método. Esto facilita documentar la API para que quien lo consume sabe que debe manejar o propagar ese tipo de excepción en código que abarque la invocación de miMetodo.
También podríamos especificar excepciones unchecked, aunque no es una buena práctica. Sí es buena práctica documentarlas, por ejemplo utilizando el tag @throws de Javadoc.
Si bien en Java tenemos una gran cantidad de excepciones predefinidas en las distintas librerías, también podemos construir nuestros propios paquetes de excepciones y aprovechar la jerarquía de herencia para facilitar el manejo de errores en nuestras aplicaciones.
Para diseñar una excepción personalizada simplemente lo hacemos al igual que una clase, pero contemplando lo siguiente al momento de definir su clase base:
- Si es unchecked, debe ser subtipo de RuntimeException.
- Si es checked, debe ser subtipo de Exception, pero no de RuntimeException.
public class MiAppException extends Exception {};
public class MiCheckedException extends MiAppException {};
public class MiAppUnCheckedException extends RuntimeException {};
En este caso, la primera excepción sería la base para todas las excepciones checked de la aplicación, que se extiende con la segunda excepción para algún error más particular. La tercera sirve de base para todas las excepciones unchecked de la aplicación. También podríamos incorporarlas todas a un paquete de excepciones para un mejor orden.
Definir nuestras propias excepciones es una opción interesante para modelar errores específicos de nuestra aplicación, pero recordemos que Java ya ofrece varias excepciones que seguramente sean aplicables a errores comunes. Es recomendable entonces utilizar las excepciones predefinidas para tratar estos tipos de problemas, usualmente los que se producen por errores de programación, porque facilitan la interpretación de quienes consuman nuestro código ya que son excepciones conocidas.
Algunas excepciones comunes que podemos utilizar son:
Excepción | Cuándo usarla |
---|---|
NullPointerException | Parámetro es null cuando no está permitido |
IllegalArgumentException | Parámetro no null inválido |
IllegalStateException | Estado del objeto inválido para invocar algún método |
IndexOutOfBoundsException | El valor del índice está fuera de rango permitido |
UnsupportedOperationException | El objeto no soporta ese método |
Siempre evitar lanzar excepciones directamente de Exception, RuntimeException o Error, porque son demasiado abstractas y si quisiéramos capturarlas sería difícil comprender el error específico.
Si analizamos la definición de la clase Exception veremos que tiene varios constructores. Uno que resulta de utilidad es el que recibe un String como argumento, lo cual es práctico para agregar información del error ocurrido. Es una buena práctica definir un mensaje con información relevante cuando construimos nuestra excepción.
public class MiAppException extends Exception {
public MiAppException() {
super("Error en MiApp");
}
public MiAppException(String mensaje) {
super(mensaje);
}
}
Ahora podemos generar objetos de MiAppException con dos constructores, uno incorporando un mensaje que puede definirse en el momento que se lanza (segundo constructor). En ambos casos, se construye mediante el constructor de la clase base Exception que recibe un String y lo guarda como mensaje de la excepción a través de la invocación también del constructor de Throwable.
Así como Throwable tiene un atributo donde almacena el mensaje de error de la excepción, nosotros podríamos también construir excepciones personalizadas otros atributos para guardar información relevante del error para eventualmente accederla de forma programática.
Veamos un ejemplo de lanzamiento y captura de una excepción personalizada:
public class UrlInvalidaException extends Exception {
public UrlInvalidaException() {
super("Formato de URL invalido");
}
}
public class ErrorConexionException extends Exception {
public ErrorConexionException() {
super("Error de conexion");
}
}
public class MiClienteHTTP {
public static HTTPReq crearHttpRequest(String url) throws UrlInvalidaException {
// En algún lado se valida la URL y se podría lanzar...
throw new UrlInvalidaException();
}
public void descargar(String url) throws ErrorConexionException {
try {
crearHttpRequest(url);
... // El resto del código no se ejecuta si ocurre UrlInvalidaException
} catch (UrlInvalidaException e) { // handler de UrlInvalidaException
// Guarda error de UrlInvalidaException
System.err.println(e.printStackTrace());
// Lanza otra excepcion
throw new ErrorConexionException();
}
}
public void descargarTodos(String[] urls) throws ErrorConexionException {
for (String url: urls) {
descargar(url);
}
}
}
Definimos dos excepciones propias UrlInvalidaException y ErrorConexionException que heredan de Exception, por lo cual son checked. Por esa razón, los métodos donde se puede lanzar una excepción de ese tipo y no se capturan deben declarar explícitamente en su firma el throws (Catch or Specify Requirement). En crearHttpRequest se lanza una excepción si la url pasada por parámetro es inválida. En descargar se invoca a ese método, por lo cual se captura la exepción con un handler apropiado. En la captura se guarda un log la información del error con printStackTrace y se lanza una nueva excepción más genérica ErrorConexionException (chained exception). Dado que el método descargarTodos invoca al descargar y en ningún momento intenta capturar ErrorConexionException, la declara en su firma con throws. Entonces, si alguien consume los métodos descargar o descargarTodos debemos intentar capturar esa excepción o declararla con throws en el método donde los consumimos para poder compilar.
Mencionamos que los objetos de excepción siempre heredan de Throwable. Esta clase tiene definidos campos y métodos que nos permiten acceder a información del evento ocurrido. Por ejemplo, el mensaje de la excepción o las excepciones suprimidas, accedidos mediante getMessage() y getSuppressed(), respecticamente. También almacena la causa de la excepción, que puede accederse mediante el método getCause() que devuelve otro objeto de tipo Throwable (o null si no fue causada por otra excepción).
Probablemente la información más importante que tiene una excepción es el volcado de la pila de ejecución, conocido como stack trace, donde se guarda la secuencia de métodos invocados desde el actual donde se lanzó la excepción hasta el main. Es muy importante saber interpretarla. Veamos un ejemplo.
package com.miorganizacion.excepciones;
public class MiExcepcion extends RuntimeException {
public MiExcepcion() {
super("Mensaje de mi excepción");
}
}
package com.miorganizacion.paquete1;
import com.miorganizacion.excepciones.MiExcepcion;
public class MiClase {
public static void miMetodo() {
// En algún lado se lanza la excepción
throw new MiExcepcion();
}
public static void main(String[] args) {
...
miMetodo();
...
}
}
Definimos una excepción unchecked llamada MiExcepcion dentro de un paquete de excepciones. Luego se lanza esta excepción en algún lugar de miMetodo manifestando algún error de programación. Cuando se lance esta excepción, como no la capturamos en ningún momento, se detendrá el programa y se mostrará el stack trace en la consola.
Exception in thread "main" com.miorganizacion.excepciones.MiExcepcion: Mensaje de mi excepción
at com.miorganizacion.paquete1.MiClase.miMetodo(MiClase.java:39)
at com.miorganizacion.paquete1.MiClase.main(MiClase.java:52)
Este ejemplo de stack trace indica que ocurrió una excepción de tipo MiExcepcion lanzada con el mensaje "Mensaje de mi excepción". El método donde se originó la excepción es la línea siguiente: com.miorganizacion.paquete1.MiClase.miMetodo. Inclusive nos indica el archivo fuente (MiClase.java) y la línea específica donde se lanzó (39). En la línea siguiente se muestra el otro elemento de la pila de ejecución, el que invocó a miMetodo, en este caso es el main. Este patrón es siempre el mismo y describe en cada línea a cada elemento que se encontraba en la pila de ejecución al momento de lanzarse la excepción.
Si necesitáramos acceder de forma programática a esta información, Throwable provee el método getStackTrace() que devuelve un arreglo de elementos del volcado de la pila (StackTraceElement[]), donde el primer elemento sería el método desde donde se lanzó la excepción y el último el main.
catch (RuntimeException e) {
StackTraceElement elementos[] = e.getStackTrace();
for (int i = 0; i < elementos.length; i++) {
System.err.println(
elementos[i].getMethodName() + "("
+ elementos[i].getFileName() + ":"
+ elementos[i].getLineNumber() + ")"
);
}
}
- Crear una clase llamada Estudiante con las siguientes propiedades: nombre (String), edad (int) y promedio (double).
- Crear una clase llamada RegistroEstudiantes que permita a un usuarix registrar estudiantes. La clase debe contener un arreglo (o una colección) para almacenar objetos de tipo Estudiante.
- Implementar un método en la clase RegistroEstudiantes que permita agregar un nuevo estudiante al registro. Podemos utilizar la librería Scanner para ingresar datos por consola.
- Verificar que manejemos las siguientes excepciones:
- Si el nombre del estudiante es nulo o una cadena vacía, lanza una excepción personalizada llamada NombreInvalidoException.
- Si la edad es menor de 0 o mayor de 120, lanza una excepción EdadInvalidaException.
- Si el promedio no está dentro del rango de 0.0 a 10.0, lanza una excepción PromedioInvalidoException.
- En el método main, crear un proceso interactivo que permita ingresar los datos de un estudiante (nombre, edad y promedio) y agregarlo al registro. Manejar cualquier excepción que pueda surgir durante este proceso y mostrar un mensaje amigable en caso de un error de ingreso.
Dados los ejercicios de la unidad previa Generics, incorporar el manejo de errores a través de excepciones a las implementaciones de listas (tanto la lista genérica como la no genérica). Por ejemplo, identificando cuando se intenta remover un elemento en una lista vacía.
El uso adecuado de excepciones mejora la robustez, la legibilidad y la mantenibilidad del código, además de permitir un manejo más eficiente de errores y situaciones excepcionales en una aplicación. Por lo tanto, es una práctica importante en la programación orientada a objetos. A continuación resumimos algunos beneficios:
-
Manejo de errores: Las excepciones permiten identificar y manejar errores de manera controlada en lugar de que el programa falle abruptamente. Esto mejora la robustez y la confiabilidad del software.
-
Claridad del código: El uso de excepciones puede hacer que el código sea más limpio y legible. En lugar de realizar verificaciones de errores en cada punto de un programa, podemos agrupar el manejo de errores en bloques específicos, lo que facilita la comprensión del flujo principal del programa.
-
Separación de intereses: Las excepciones permiten separar el código que realiza tareas normales del código que maneja situaciones excepcionales. Esto hace que el código sea más modular y más fácil de mantener, ya que los detalles del manejo de errores se aíslan en bloques específicos.
-
Mensajes de error informativos: Al proporcionar mensajes de error detallados cuando se lanza una excepción, facilitamos la identificación y corrección de problemas. Estos mensajes pueden ser útiles tanto para desarrolladorxs como para usuarixs finales.
-
Recuperación controlada: Las excepciones permiten tomar decisiones sobre cómo manejar un error. Podemos decidir si se debe intentar una acción alternativa, registrar el error para su posterior análisis o simplemente notificar de manera adecuada. Esto da lugar a una recuperación controlada y opciones de contingencia.
-
Extensibilidad: El manejo de excepciones facilita la extensibilidad de una aplicación. Podemos agregar nuevas excepciones y manejo de errores a medida que se identifiquen nuevos problemas sin tener que modificar todo el código existente.
-
Depuración efectiva: Las excepciones proporcionan información valiosa para la depuración de programas. Los mensajes de error y los stack trace pueden ayudar a desarrolladorxs a identificar la causa raíz de los problemas y corregirlos de manera más eficiente.
Lectura de interés:
- The Java Tutorials. Lesson: Exceptions
- Effective Java 3rd, de Joshua Bloch
- Item 9: Prefer try-with-resources to try-finally
- Item 69: Use exceptions only for exceptional conditions
- Item 70: Use checked exceptions for recoverable conditions and runtime exceptions for programming errors
- Item 71: Avoid unnecessary use of checked exceptions
- Item 72: Favor the use of standard exceptions
- Item 73: Throw exceptions appropriate to the abstraction
- Item 74: Document all exceptions thrown by each method
- Item 75: Include failure-capture information in detail messages
- Item 76: Strive for failure atomicity
- Item 77: Don’t ignore exceptions