algoritmos1

Excepciones

En esta sección exploraremos qué son las excepciones, cómo manejarlas y por qué son importantes en la programación orientada a objetos.

¿Qué son las excepciones?

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.

Clasificación de excepciones

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:

Jerarquía de excepciones en Java

En Java existen dos tipos de excepciones: las excepciones verificadas (checked exceptions) y las excepciones no verificadas (unchecked exceptions).

Ejercicio: Clasificando excepciones

Dados los siguientes casos de error, determinar si corresponde modelar la excepción de tipo checked o unchecked.

Lanzamiento de excepciones

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");

Manejo de excepciones

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.

Propagación de excepción en la pila

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.

El requerimiento Capturar o Especificar

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:

El bloque try-catch

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.

Múltiples manejadores

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.

El bloque finally

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
}

El bloque try-with-resources

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.

Especificación de excepciones

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.

Definiendo excepciones personalizadas

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:

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.

¿Necesitamos excepciones personalizadas?

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.

El mensaje

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.

Ejemplo con excepciones encadenadas

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.

El objeto de excepción

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).

El stack trace

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() + ")"
        );
    }
}

Ejercicio: Registro de Estudiantes

  1. Crear una clase llamada Estudiante con las siguientes propiedades: nombre (String), edad (int) y promedio (double).
  2. 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.
  3. 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.
  4. 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.
  5. 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.

Ejercicio: Manejo de error en listas

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.

Beneficios de usar excepciones

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:

Lectura de interés: