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

El manejo de 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 la importancia...

Visión general

El manejo de 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? {#qué es el manejo de excepciones}

Estamos rodeados de manejo de excepciones en la vida real todos los días.

Al solicitar 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. Tales condiciones excepcionales pueden contrarrestarse fabricando otro producto o enviando uno nuevo después de que la entrega falle.

Al crear aplicaciones, es posible que se encuentren con todo tipo de condiciones excepcionales. Afortunadamente, al ser competente en el manejo de excepciones, tales condiciones pueden contrarrestarse alterando el flujo de código.

¿Por qué usar el manejo de excepciones? {#por qué utilizar el control de excepciones}

Cuando creamos aplicaciones, generalmente 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: no se puede encontrar el archivo, la conexión a Internet se interrumpe de vez en cuando y la JVM no puede proporcionar suficiente memoria y nos queda un desalentador StackOverflowError.

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

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

Jerarquía de excepciones

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

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

1
2
3
4
5
6
7
8
9
                                        ---> Throwable <--- 
                                        |    (checked)     |
                                        |                  |
                                        |                  |
                                ---> Exception           Error
                                |    (checked)        (unchecked)
                                |
                          RuntimeException
                            (unchecked)

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

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

Nota: Los términos "Runtime" y "Unchecked" a menudo se usan 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 de manejar o declarar se refiere a nuestra responsabilidad de declarar que un método genera 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 llaman excepciones comprobadas. El compilador puede detectarlos antes del tiempo de ejecución y usted es consciente de su posible existencia mientras escribe el código.

Excepciones no verificadas {#excepciones no verificadas}

Las excepciones no verificadas son las excepciones que generalmente ocurren debido a un error humano, en lugar de un error ambiental. Estas excepciones no se verifican durante el tiempo de compilación, sino en tiempo de ejecución, razón por la cual también se denominan Excepciones de tiempo de ejecución.

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

Errores

Los errores son las condiciones excepcionales más graves con las que te puedes encontrar. 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 generar un StackOverflowError, o una fuga de memoria puede generar un OutOfMemoryError.

Cómo manejar las excepciones

lanzar y lanzar

La forma más fácil de solucionar un error del compilador cuando se trata de una excepción verificada es simplemente lanzarlo.

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

Estamos obligados a marcar la firma de nuestro método con una cláusula throws. Un método puede agregar tantas excepciones como sea necesario en su cláusula throws, y puede lanzarlas más adelante en el código, pero no es necesario. Este método no requiere una declaración return, aunque define un tipo de devolución. Esto se debe a que lanza una excepción de forma predeterminada, lo que finaliza el flujo del método abruptamente. La instrucción return, por lo tanto, 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 atrapada.

Bloques intentar atrapar {#intentarbloques de captura}

Un enfoque más común sería usar un bloque try-catch para capturar y manejar la excepción que surja:

1
2
3
4
5
6
7
8
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 riesgoso encerrándolo dentro de un bloque try. Esto le dice al compilador que somos conscientes 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 el archivo, la FileNotFoundException es capturada y reproducida. Más sobre este tema más adelante.

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

1
2
3
4
5
6
7
8
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:

1
2
3
4
5
6
7
8
9
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 dará como resultado:

1
File not found.

finalmente Bloquea

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

Esto se usó a menudo para cerrar los recursos que se abrieron en el bloque try, ya que una excepción que surgiera omitiría el código que los cierra:

1
2
3
4
5
6
7
8
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 pruebe-con-recursos

El bloque anteriormente complejo y detallado se puede sustituir por:

1
2
3
4
5
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 bloque try.

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

1
2
3
4
5
6
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 probar-con-recursos asegura que los recursos se cerrarán al final de la instrucción.

Múltiples catch Bloques

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

1
2
3
4
5
6
7
8
9
public void parseFile(String filePath) {
    try {
        // some code 
    } catch (IOException ex) {
        // handle
    } catch (NumberFormatException ex) {
        // handle
    }
}

Cuando el bloque try incurre en una excepción, la JVM comprueba si la primera excepción detectada es adecuada 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.

No es necesario capturar una excepción FileNotFound en este ejemplo, porque se extiende desde IOException, pero si surge la necesidad, podemos capturarla antes de IOException:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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 la más genérica.

Nota: Al detectar varias excepciones, el compilador de Java requiere que coloquemos las más específicas antes de las más generales; de lo contrario, serían inalcanzables y darían como resultado un error del compilador.

Unión atrapar Bloques

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

1
2
3
4
5
6
7
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.

Lanzamiento de una excepción marcada

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    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á numberOfUsers hasta que exceda la cantidad máxima recomendada, después de lo cual generará una excepción. Dado que esta es una excepción verificada, debemos agregar la cláusula throws en la firma del método.

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

1
2
3
4
5
public class TooManyUsersException extends Exception {
    public TooManyUsersException(String message) {
        super(message);
    }
}

Lanzar una excepción no verificada {#lanzar una excepción no verificada}

Lanzar excepciones de tiempo de ejecución generalmente se reduce a la validación de la entrada, ya que ocurren con mayor frecuencia debido a una entrada defectuosa, ya sea en forma de IllegalArgumentException, NumberFormatException, ArrayIndexOutOfBoundsException o NullPointerException:

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

Dado que lanzamos 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:

1
2
3
4
5
public class UserNotAuthenticatedException extends RuntimeException {
    public UserNotAuthenticatedException(String message) {
        super(message);
    }
}

Relanzamiento

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

1
2
3
4
5
6
7
8
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 capturada, en lugar de lanzar una nueva.

Envoltura

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

1
2
3
4
5
6
7
8
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 Lanzable o _Excepción*?

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

1
2
3
4
5
6
7
public void parseFile(String filePath) {
    try {
        throw new NumberFormatException();
    } catch (Throwable t) {
        throw t;
    }
}

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

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

1
2
3
4
5
6
7
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.

Excepción de herencia

Las subclases que heredan un método solo pueden arrojar menos excepciones verificadas que su superclase:

1
2
3
4
5
public class SomeClass {
   public void doSomething() throws SomeException {
        // some code
    }
}

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

1
2
3
4
5
6
public class OtherClass extends SomeClass {
    @Override
    public void doSomething() throws OtherException {
        // some code
    }
}

Mejores y peores prácticas de manejo de excepciones {#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, con suerte, entendemos completamente ahora.

Mejores prácticas de manejo de excepciones

Evitar condiciones excepcionales

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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:

1
Employee found: Scott

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

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

Use pruebe-con-recursos

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

Cerrar recursos en try-catch-finally

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.

Las peores prácticas de manejo de excepciones

Excepciones de deglución

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

1
2
3
4
5
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 manera, el compilador está 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:

1
2
3
4
5
6
7
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 de lo que lo hace ignorarla.

Volver en un bloque finally

Según el JLS (Especificación del lenguaje Java):

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

Entonces, en la terminología de la documentación, si el bloque finally se completa normalmente, entonces la declaración try se completa abruptamente por la razón R.

Si el bloque finally se completa abruptamente por la razón S, entonces la instrucción try se completa abruptamente por la razón S (y la razón R se descarta).

En esencia, al regresar abruptamente de un bloque ‘finally’, la JVM eliminará la excepción del bloque ’try’ y todos los datos valiosos se perderán:

1
2
3
4
5
6
7
8
public String doSomething() {
    String name = "David";
    try {
        throw new IOException();
    } finally {
        return name;
    }
}

En este caso, aunque el bloque try lanza una nueva IOException, usamos return en el bloque finally, finalizándolo abruptamente. Esto hace que el bloque try finalice abruptamente debido a la declaración de devolución, y no a la IOException, lo que esencialmente descarta la excepción en el proceso.

Lanzar un bloque finally

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

1
2
3
4
5
6
7
8
9
public static String doSomething() {
    try {
        // some code that forms an exception
    } catch(IOException io) {
        throw io;
    } finally {
        throw new MyException();
    }
}

En este ejemplo, la MyException lanzada dentro del bloque finally eclipsará la excepción lanzada por el bloque catch y toda la información valiosa será descartada.

Simulación de una declaración goto

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

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

1
2
3
4
5
6
7
public void jumpForward() {
    label: {
        someMethod();
        if (condition) break label;
        otherMethod();
    }
}

Sin embargo, todavía algunas personas usan excepciones para simularlas:

1
2
3
4
5
6
7
8
9
public void jumpForward() {
    try {
      // some code 1
      throw new MyException();
      // some code 2
    } catch(MyException ex) {
      // some code 3
    }
}

El uso de 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.

Registro y lanzamiento {#registro y lanzamiento}

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

1
2
3
4
5
6
7
8
9
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 dará como resultado un montón de mensajes de registro que no son realmente necesarios. La cantidad de texto reducirá la visibilidad de los registros.

Atrapar excepción o arrojar

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

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

Capturar Exception capturará tanto las excepciones verificadas como las de tiempo de ejecución. Las excepciones de tiempo de ejecución representan problemas que son el resultado directo de un problema de programación y, como tales, no deben detectarse, ya que no se puede esperar razonablemente que se recupere de ellos o los maneje.

Atrapar Throwable atrapará todo. Esto incluye todos los errores, que en realidad no están destinados a detectarse de ninguna manera.

Conclusión

En este artículo, hemos cubierto las excepciones y el manejo de excepciones desde cero. Posteriormente, 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!

Licensed under CC BY-NC-SA 4.0