Manejo de Excepciones en Java: Una guía completa con las mejores y peores prácticas

M

Visión general

Manejar excepciones en Java es una de las cosas más básicas y fundamentales que un desarrollador debe saber de memoria. Lamentablemente, esto a menudo se pasa por alto y se subestima la importancia del manejo de excepciones; es tan importante como el resto del código.

En este artículo, repasemos todo lo que necesita saber sobre el manejo de excepciones en Java, así como las buenas y malas prácticas.

¿Qué es el manejo de excepciones?

Estamos rodeados de manejo de excepciones en la vida real a diario.

Al pedir un producto en una tienda en línea, es posible que el producto no esté disponible en stock o que se produzca una falla en la entrega. Estas condiciones excepcionales se pueden contrarrestar fabricando otro producto o enviando uno nuevo después de que la entrega fallara.

Al crear aplicaciones, pueden encontrarse con todo tipo de condiciones excepcionales. Afortunadamente, al ser competente en el manejo de excepciones, estas condiciones se pueden contrarrestar alterando el flujo del código.

¿Por qué utilizar el manejo de excepciones?

Al crear aplicaciones, normalmente trabajamos en un entorno ideal: el sistema de archivos puede proporcionarnos todos los archivos que solicitamos, nuestra conexión a Internet es estable y la JVM siempre puede proporcionar suficiente memoria para nuestras necesidades.

Lamentablemente, en realidad, el entorno está lejos de ser ideal: el archivo no se puede encontrar, la conexión a Internet se interrumpe de vez en cuando y la JVM no puede proporcionar suficiente memoria y nos quedamos con un problema desalentador StackOverflowError.

Si no manejamos tales condiciones, toda la aplicación terminará en ruinas y el resto del código quedará obsoleto. Por tanto, debemos ser capaces de escribir código que se adapte a tales situaciones.

Imagine que una empresa no puede resolver un problema simple que surgió después de ordenar un producto; no desea que su aplicación funcione de esa manera.

Jerarquía de excepciones

Todo esto simplemente plantea la pregunta: ¿cuáles son estas excepciones a los ojos de Java y la JVM?

Después de todo, las excepciones son simplemente objetos Java que amplían la Throwableinterfaz:

                                        ---> Throwable <--- 
                                        |    (checked)     |
                                        |                  |
                                        |                  |
                                ---> Exception           Error
                                |    (checked)        (unchecked)
                                |
                          RuntimeException
                            (unchecked)

Cuando hablamos de condiciones excepcionales, normalmente nos referimos a una de las tres:

  • Excepciones marcadas
  • Excepciones sin marcar / Excepciones en tiempo de ejecución
  • Errores

Nota : Los términos “Runtime” y “Unchecked” se utilizan a menudo indistintamente y se refieren al mismo tipo de excepciones.

Excepciones marcadas

Las excepciones marcadas son las excepciones que normalmente podemos prever y planificar con anticipación en nuestra aplicación. Estas también son excepciones que el compilador de Java requiere que manejemos o declaremos al escribir código.

La regla manejar o declarar se refiere a nuestra responsabilidad de declarar que un método arroja una excepción en la pila de llamadas, sin hacer mucho para evitarlo, o manejar la excepción con nuestro propio código, lo que generalmente conduce a la recuperación del programa de la condición excepcional.

Esta es la razón por la que se denominan excepciones marcadas. El compilador puede detectarlos antes del tiempo de ejecución y usted es consciente de su posible existencia mientras escribe código.

Excepciones sin marcar

Las excepciones no marcadas son las excepciones que suelen ocurrir debido a errores humanos, en lugar de ambientales. Estas excepciones no se comprueban durante la compilación, sino en el tiempo de ejecución, razón por la cual también se denominan Excepciones en tiempo de ejecución.

A menudo se pueden contrarrestar implementando comprobaciones simples antes de un segmento de código que podría potencialmente usarse de una manera que forme una excepción de tiempo de ejecución, pero más sobre eso más adelante.

Errores

Los errores son las condiciones excepcionales más graves con las que puede encontrarse. A menudo son irrecuperables y no hay una forma real de manejarlos. Lo único que nosotros, como desarrolladores, podemos hacer es optimizar el código con la esperanza de que los errores nunca ocurran.

Los errores pueden ocurrir debido a errores humanos y ambientales. La creación de un método infinitamente recurrente puede llevar a un StackOverflowError, o una pérdida de memoria puede llevar a un OutOfMemoryError.

Cómo manejar las excepciones

tirar y lanzar

La forma más sencilla de solucionar un error del compilador cuando se trata de una excepción marcada es simplemente lanzarla.

public File getFile(String url) throws FileNotFoundException {
    // some code
    throw new FileNotFoundException();
}

Estamos obligados a marcar la firma de nuestro método con una throwscláusula. Un método puede agregar tantas excepciones como sea necesario en su throwscláusula y puede lanzarlas más adelante en el código, pero no es necesario. Este método no requiere una returndeclaración, aunque define un tipo de retorno. Esto se debe a que genera una excepción de forma predeterminada, que finaliza el flujo del método de forma abrupta. Por lo returntanto, la declaración sería inalcanzable y provocaría un error de compilación.

Tenga en cuenta que cualquiera que llame a este método también debe seguir la regla de manejar o declarar.

Al lanzar una excepción, podemos lanzar una nueva excepción, como en el ejemplo anterior, o una excepción detectada.

Try-catch Blocks

Un enfoque más común sería el uso de un trycatchbloque para capturar y manejar la excepción que se genera:

public String readFirstLine(String url) throws FileNotFoundException {
    try {
        Scanner scanner = new Scanner(new File(url));
        return scanner.nextLine();
    } catch(FileNotFoundException ex) {
        throw ex; 
    }
}

En este ejemplo, “marcamos” un segmento de código de riesgo encerrándolo dentro de un trybloque. Esto le dice al compilador que estamos al tanto de una posible excepción y que tenemos la intención de manejarla si surge.

Este código intenta leer el contenido del archivo y, si no se encuentra, FileNotFoundExceptionse captura y se vuelve a lanzar. Más sobre este tema más adelante.

Ejecutar este fragmento de código sin una URL válida resultará en una excepción lanzada:

Exception in thread "main" java.io.FileNotFoundException: some_file (The system cannot find the file specified) <-- some_file doesn't exist
    at java.io.FileInputStream.open0(Native Method)
    at java.io.FileInputStream.open(FileInputStream.java:195)
    at java.io.FileInputStream.<init>(FileInputStream.java:138)
    at java.util.Scanner.<init>(Scanner.java:611)
    at Exceptions.ExceptionHandling.readFirstLine(ExceptionHandling.java:15) <-- Exception arises on the the     readFirstLine() method, on line 15
    at Exceptions.ExceptionHandling.main(ExceptionHandling.java:10) <-- readFirstLine() is called by main() on  line 10
...

Alternativamente, podemos intentar recuperarnos de esta condición en lugar de volver a lanzar:

public static String readFirstLine(String url) {
    try {
        Scanner scanner = new Scanner(new File(url));
        return scanner.nextLine();
    } catch(FileNotFoundException ex) {
        System.out.println("File not found.");
        return null;
    }
}

Ejecutar este fragmento de código sin una URL válida resultará en:

File not found.

finalmente bloquea

Al introducir un nuevo tipo de bloque, el finallybloque se ejecuta independientemente de lo que suceda en el bloque try. Incluso si termina abruptamente lanzando una excepción, el finallybloque se ejecutará.

Esto se usó a menudo para cerrar los recursos que se abrieron en el trybloque, ya que una excepción que surgiera omitiría el código cerrándolos:

public String readFirstLine(String path) throws IOException {
    BufferedReader br = new BufferedReader(new FileReader(path));   
    try {
        return br.readLine();
    } finally {
        if(br != null) br.close();
    }
}

Sin embargo, este enfoque ha sido mal visto después del lanzamiento de Java 7, que introdujo una forma mejor y más limpia de cerrar recursos, y actualmente se considera una mala práctica.

Declaración de prueba con recursos

El bloque anteriormente complejo y detallado se puede sustituir por:

static String readFirstLineFromFile(String path) throws IOException {
    try(BufferedReader br = new BufferedReader(new FileReader(path))) {
        return br.readLine();
    }
}

Es mucho más limpio y obviamente se simplifica al incluir la declaración entre paréntesis del trybloque.

Además, puede incluir varios recursos en este bloque, uno tras otro:

static String multipleResources(String path) throws IOException {
    try(BufferedReader br = new BufferedReader(new FileReader(path));
        BufferedWriter writer = new BufferedWriter(path, charset)) {
        // some code
    }
}

De esta manera, no tiene que preocuparse por cerrar los recursos usted mismo, ya que el bloque try-with-resources asegura que los recursos se cerrarán al final de la declaración.

Múltiples bloques de captura

Cuando el código que estamos escribiendo puede lanzar más de una excepción, podemos emplear varios bloques de captura para manejarlos individualmente:

public void parseFile(String filePath) {
    try {
        // some code 
    } catch (IOException ex) {
        // handle
    } catch (NumberFormatException ex) {
        // handle
    }
}

Cuando el trybloque incurre en una excepción, la JVM verifica si la primera excepción detectada es apropiada y, de no ser así, continúa hasta que encuentra una.

Nota : La captura de una excepción genérica capturará todas sus subclases, por lo que no es necesario capturarlas por separado.

FileNotFoundNo es necesario IOExceptioncapturar una excepción en este ejemplo, porque se extiende desde , pero si surge la necesidad, podemos detectarla antes de IOException:

public void parseFile(String filePath) {
    try {
        // some code 
    } catch(FileNotFoundException ex) {
        // handle
    } catch (IOException ex) {
        // handle
    } catch (NumberFormatException ex) {
        // handle
    }
}

De esta manera, podemos manejar la excepción más específica de una manera diferente a una más genérica.

Nota : Cuando se detectan múltiples excepciones, el compilador de Java requiere que coloquemos las más específicas antes que las más generales, de lo contrario, serían inalcanzables y generarían un error del compilador.

Bloques de captura de unión

Para reducir el código repetitivo, Java 7 también introdujo bloques de captura de unión. Nos permiten tratar múltiples excepciones de la misma manera y manejar sus excepciones en un solo bloque:

public void parseFile(String filePath) {
    try {
        // some code 
    } catch (IOException | NumberFormatException ex) {
        // handle
    } 
}

Cómo lanzar excepciones

A veces, no queremos manejar excepciones. En tales casos, solo debemos preocuparnos por generarlos cuando sea necesario y permitir que otra persona, llamando a nuestro método, los maneje adecuadamente.

Lanzar una excepción marcada

Cuando algo sale mal, como la cantidad de usuarios que se conectan actualmente a nuestro servicio y excede la cantidad máxima que el servidor puede manejar sin problemas, queremos throwuna excepción para indicar una situación excepcional:

    public void countUsers() throws TooManyUsersException {
       int numberOfUsers = 0;
           while(numberOfUsers < 500) {
               // some code
               numberOfUsers++;
        }
        throw new TooManyUsersException("The number of users exceeds our maximum 
            recommended amount.");
    }
}

Este código aumentará numberOfUsershasta que supere la cantidad máxima recomendada, después de lo cual lanzará una excepción. Dado que esta es una excepción marcada, tenemos que agregar la throwscláusula en la firma del método.

Definir una excepción como esta es tan fácil como escribir lo siguiente:

public class TooManyUsersException extends Exception {
    public TooManyUsersException(String message) {
        super(message);
    }
}

Lanzar una excepción no marcada

Lanzar excepciones de tiempo de ejecución por lo general se reduce a la validación de la entrada, ya que más a menudo se producen debido a la entrada defectuoso – ya sea en la forma de un IllegalArgumentException, NumberFormatException, ArrayIndexOutOfBoundsException, o una NullPointerException:

public void authenticateUser(String username) throws UserNotAuthenticatedException {
    if(!isAuthenticated(username)) {
        throw new UserNotAuthenticatedException("User is not authenticated!");
    }
}

Dado que estamos lanzando una excepción de tiempo de ejecución, no es necesario incluirla en la firma del método, como en el ejemplo anterior, pero a menudo se considera una buena práctica hacerlo, al menos por el bien de la documentación.

Nuevamente, definir una excepción de tiempo de ejecución personalizada como esta es tan fácil como:

public class UserNotAuthenticatedException extends RuntimeException {
    public UserNotAuthenticatedException(String message) {
        super(message);
    }
}

Relanzar

Antes se mencionó volver a lanzar una excepción, así que aquí hay una breve sección para aclarar:

public String readFirstLine(String url) throws FileNotFoundException {
    try {
        Scanner scanner = new Scanner(new File(url));
        return scanner.nextLine();
    } catch(FileNotFoundException ex) {
        throw ex; 
    }
}

Relanzar se refiere al proceso de lanzar una excepción ya detectada, en lugar de lanzar una nueva.

Envase

Envolver, por otro lado, se refiere al proceso de envolver una excepción ya detectada, dentro de otra excepción:

public String readFirstLine(String url) throws FileNotFoundException {
    try {
        Scanner scanner = new Scanner(new File(url));
        return scanner.nextLine();
    } catch(FileNotFoundException ex) {
        throw new SomeOtherException(ex); 
    }
}

¿Relanzar Throwable o _Exception *?

Estas clases de nivel superior se pueden capturar y volver a lanzar, pero la forma de hacerlo puede variar:

public void parseFile(String filePath) {
    try {
        throw new NumberFormatException();
    } catch (Throwable t) {
        throw t;
    }
}

En este caso, el método lanza una NumberFormatExceptionexcepción de tiempo de ejecución. Debido a esto, no tenemos que marcar la firma del método con NumberFormatExceptiono Throwable.

Sin embargo, si lanzamos una excepción marcada dentro del método:

public void parseFile(String filePath) throws Throwable {
    try {
        throw new IOException();
    } catch (Throwable t) {
        throw t;
    }
}

Ahora tenemos que declarar que el método arroja un Throwable. Por qué esto puede ser útil es un tema amplio que está fuera del alcance de este blog, pero hay usos para este caso específico.

Herencia de excepción

Las subclases que heredan un método solo pueden generar menos excepciones marcadas que su superclase:

public class SomeClass {
   public void doSomething() throws SomeException {
        // some code
    }
}

Con esta definición, el siguiente método provocará un error del compilador:

public class OtherClass extends SomeClass {
    @Override
    public void doSomething() throws OtherException {
        // some code
    }
}

Mejores y peores prácticas de manejo de excepciones

Con todo eso cubierto, debería estar bastante familiarizado con cómo funcionan las excepciones y cómo usarlas. Ahora, cubramos las mejores y peores prácticas cuando se trata de manejar excepciones que esperamos que comprendamos completamente ahora.

Mejores prácticas de manejo de excepciones

Evite condiciones excepcionales

A veces, al usar comprobaciones simples, podemos evitar que se forme una excepción por completo:

public Employee getEmployee(int i) {
    Employee[] employeeArray = {new Employee("David"), new Employee("Rhett"), new 
        Employee("Scott")};
    
    if(i >= employeeArray.length) {
        System.out.println("Index is too high!");
        return null;
    } else {
        System.out.println("Employee found: " + employeeArray[i].name);
        return employeeArray[i];
    }
  }
}

Llamar a este método con un índice válido daría como resultado:

Employee found: Scott

Pero llamar a este método con un índice que está fuera de los límites daría como resultado:

Index is too high!

En cualquier caso, aunque el índice sea demasiado alto, la línea de código infractora no se ejecutará y no surgirá ninguna excepción.

Usa recursos de prueba

Como ya se mencionó anteriormente, siempre es mejor utilizar el enfoque más nuevo, más conciso y más limpio cuando se trabaja con recursos.

Cerrar recursos en try-catch-finalmente

Si no está utilizando los consejos anteriores por algún motivo, al menos asegúrese de cerrar los recursos manualmente en el bloque finalmente.

No incluiré un ejemplo de código para esto ya que ambos ya se han proporcionado, por brevedad.

Peores prácticas de manejo de excepciones

Excepciones al tragar

Si su intención es simplemente satisfacer al compilador, puede hacerlo fácilmente tragándose la excepción :

public void parseFile(String filePath) {
    try {
        // some code that forms an exception
    } catch (Exception ex) {}
}

Tragar una excepción se refiere al acto de detectar una excepción y no solucionar el problema.

De esta forma, el compilador queda satisfecho ya que se detecta la excepción, pero se pierde toda la información útil relevante que pudimos extraer de la excepción para la depuración, y no hicimos nada para recuperarnos de esta condición excepcional.

Otra práctica muy común es simplemente imprimir el seguimiento de la pila de la excepción:

public void parseFile(String filePath) {
    try {
        // some code that forms an exception
    } catch(Exception ex) {
        ex.printStackTrace();
    }
}

Este enfoque forma una ilusión de manejo. Sí, aunque es mejor que simplemente ignorar la excepción, al imprimir la información relevante, esto no maneja la condición excepcional más que ignorarla.

Regresa en un bloque finalmente

Según la JLS ( especificación del lenguaje Java ):

Si la ejecución del bloque try se completa abruptamente por cualquier otro motivo R, entonces el finallybloque se ejecuta y luego hay una opción.

Entonces, en la terminología de la documentación, si el finallybloque se completa normalmente, entonces la trydeclaración se completa abruptamente por la razón R.

Si el finallybloque se completa abruptamente por la razón S, entonces la trydeclaración se completa abruptamente por la razón S (y la razón R se descarta).

En esencia, al regresar abruptamente de un finallybloque, la JVM eliminará la excepción del trybloque y se perderán todos los datos valiosos del mismo:

public String doSomething() {
    String name = "David";
    try {
        throw new IOException();
    } finally {
        return name;
    }
}

En este caso, aunque el trybloque arroje un nuevo IOException, usamos returnen el finallybloque, finalizándolo abruptamente. Esto hace que el trybloque finalice abruptamente debido a la declaración de retorno, y no al IOException, esencialmente eliminando la excepción en el proceso.

Lanzando un bloque finalmente

Muy similar al ejemplo anterior, usar throwen un finallybloque eliminará la excepción del bloque try-catch:

public static String doSomething() {
    try {
        // some code that forms an exception
    } catch(IOException io) {
        throw io;
    } finally {
        throw new MyException();
    }
}

En este ejemplo, lo MyExceptionarrojado dentro del finallybloque eclipsará la excepción lanzada por el catchbloque y se eliminará toda la información valiosa.

Simular una declaración goto

El pensamiento crítico y las formas creativas de encontrar una solución a un problema es un buen rasgo, pero algunas soluciones, por creativas que sean, son ineficaces y redundantes.

Java no tiene una declaración goto como otros lenguajes, sino que usa etiquetas para saltar el código:

public void jumpForward() {
    label: {
        someMethod();
        if (condition) break label;
        otherMethod();
    }
}

Sin embargo, algunas personas usan excepciones para simularlas:

public void jumpForward() {
    try {
      // some code 1
      throw new MyException();
      // some code 2
    } catch(MyException ex) {
      // some code 3
    }
}

Usar excepciones para este propósito es ineficaz y lento. Las excepciones están diseñadas para código excepcional y deben usarse para código excepcional.

Tala y lanzamiento

Cuando intente depurar un fragmento de código y descubra lo que está sucediendo, no registre y lance la excepción:

public static String readFirstLine(String url) throws FileNotFoundException {
    try {
        Scanner scanner = new Scanner(new File(url));
        return scanner.nextLine();
    } catch(FileNotFoundException ex) {
        LOGGER.error("FileNotFoundException: ", ex);
        throw ex;
    }
}

Hacer esto es redundante y simplemente resultará en un montón de mensajes de registro que no son realmente necesarios. La cantidad de texto reducirá la visibilidad de los registros.

Atrapar la excepción o lanzar

¿Por qué no simplemente capturamos Exception o Throwable, si captura todas las subclases?

A menos que haya una buena razón específica para detectar cualquiera de estos dos, generalmente no se recomienda hacerlo.

La captura Exceptiondetectará las excepciones marcadas y en tiempo de ejecución. Las excepciones en tiempo de ejecución representan problemas que son un resultado directo de un problema de programación y, como tales, no deben detectarse, ya que no se puede esperar razonablemente que se recuperen o manejen.

La captura Throwableatrapará todo . Esto incluye todos los errores, que en realidad no deben detectarse de ninguna manera.

Conclusión

En este artículo, hemos cubierto las excepciones y el manejo de excepciones desde cero. Luego, cubrimos las mejores y peores prácticas de manejo de excepciones en Java.

Espero que hayas encontrado este blog informativo y educativo, ¡feliz codificación!

 

About the author

Ramiro de la Vega

Bienvenido a Pharos.sh

Soy Ramiro de la Vega, Estadounidense con raíces Españolas. Empecé a programar hace casi 20 años cuando era muy jovencito.

Espero que en mi web encuentres la inspiración y ayuda que necesitas para adentrarte en el fantástico mundo de la programación y conseguir tus objetivos por difíciles que sean.

Add comment

Sobre mi

Últimos Post

Etiquetas

Esta web utiliza cookies propias para su correcto funcionamiento. Al hacer clic en el botón Aceptar, aceptas el uso de estas tecnologías y el procesamiento de tus datos para estos propósitos. Más información
Privacidad