
Compartir:
Sina es promotora de desarrollo Java en Vonage. Procede del mundo académico y, en general, siente curiosidad por todo lo relacionado con los coches, los ordenadores, la programación, la tecnología y la naturaleza humana. En su tiempo libre, se le puede encontrar paseando o jugando a videojuegos de competición.
Por qué debe evitar el uso de excepciones comprobadas en Java
Tiempo de lectura: 11 minutos
Di una breve charla sobre esto en Devoxx UK 2023. Échale un vistazo en YouTube.
Introducción
La mayoría de los lenguajes de programación convencionales disponen de construcciones específicas para tratar excepciones. excepciones. ¿Qué son las excepciones? Son casos de comportamiento anormal como resultado de una entrada o un estado del sistema poco comunes. Los mecanismos de gestión de excepciones rompen el flujo de ejecución típico de un programa, como se indica en Wikipedia. Probablemente esté familiarizado con try-catch-finally si utiliza un lenguaje de programación orientado a objetos como C# o Java. Sin embargo, puede que no esté familiarizado con la noción de excepciones comprobadas. Hace poco me enteré de que esta "característica" es básicamente exclusiva de Java - al menos cuando se trata de lenguajes de programación convencionales. En este artículo, espero convencerte de por qué las excepciones controladas son malas y, si no eres un desarrollador Java, por qué deberías estar agradecido de que tu lenguaje no las tenga.
Diferencia entre excepciones comprobadas y no comprobadas
La idea de las excepciones comprobadas es imponer el manejo explícito de excepciones en tiempo de compilación en un intento de garantizar la "integridad" del código. Un método que produce una excepción comprobada puede lanzar esa excepción, que pasa a formar parte de la firma del método. Cualquier persona que llame a ese método debe propagar la excepción incluyéndola también en la firma de su método o tratarla mediante un bloque catch bloque. Para demostrarlo, considere el siguiente código:
public class ExceptionsDemo {
public static void main(String[] args) throws Throwable {
code();
}
static class BaseChecked extends Exception {}
static class CheckedA extends BaseChecked {}
static class CheckedB extends BaseChecked {}
static class BaseUnchecked extends RuntimeException {}
static class UncheckedA extends BaseUnchecked {}
static class UncheckedB extends BaseUnchecked {}
interface MyApi {
void checked() throws CheckedA, CheckedB;
void unchecked();
}
static class MyApiImpl implements MyApi {
@Override
public void checked() throws CheckedA {
throw new CheckedA();
}
@Override
public void unchecked() {
throw (Math.random() > 0.5) ? new UncheckedA() : new UncheckedB();
}
}
static void code() {
MyApi api = new MyApiImpl();
api.unchecked();
}
}Aquí tenemos dos jerarquías de excepciones BaseChecked y BaseUncheckedcada una de las cuales tiene dos subclases. Observe cómo BaseChecked extiende de Exceptionmientras que BaseUnchecked extiende RuntimeException. Para ilustrar la diferencia, considere la interfaz MyApi que declara dos métodos. El método checked() declara en su firma que un método que lo implemente puede lanzar un método CheckedA o CheckedB mientras que unchecked() no declara nada. Al implementar la interfaz, es posible lanzar cualquier excepción no controlada del método unchecked() método. Eso es esencialmente lo que es una excepción no controlada: una excepción que es una subclase de java.lang.RuntimeException (directa o indirectamente).
Por el contrario, las excepciones verificadas son subclases de java.lang.Exception. El compilador requiere que las excepciones comprobadas se declaren en la firma del método para que puedan lanzarse. Sin embargo, observe que aunque el método de interfaz MyApi::checked declara CheckedA y CheckedB como posibles excepciones, la implementación (MyApiImpl::checked) sólo lanza CheckedA y no necesita declarar CheckedB. Así, los métodos sobreescritos no necesitan declarar las excepciones de sus padres en su propia firma a menos que sean lanzadas desde el método. Sin embargo, las nuevas excepciones comprobadas que no son declaradas por el supermétodo no pueden ser lanzadas ya que esto violaría los principios polimórficos en los que se basa el lenguaje.
Semántica estrafalaria
La semántica se hace más fácil de entender con la ayuda de un compilador, así que si no estás familiarizado con las excepciones en Java, te animo a que juegues con el código en tu IDE. Por ejemplo, observe cómo el compilador le obliga a manejar tanto CheckedA y CheckedB si cambias la llamada de api.unchecked() a api.checked() en el siguiente ejemplo, esto se debe a que estás llamando al método de la interfaz, no al de la implementación.
static void code() {
MyApi api = new MyApiImpl();
try {
api.checked();
}
catch (CheckedA | CheckedB ex) {
ex.printStackTrace();
}
}Por supuesto, puedes tirarlo en su lugar:
static void code() throws CheckedA, CheckedB {
MyApi api = new MyApiImpl();
api.checked();
}o manejar una excepción y lanzar la otra:
static void code() throws CheckedB {
MyApi api = new MyApiImpl();
try {
api.checked();
}
catch (CheckedA ax) {
// TODO handle here
}
}También puede "preparar el futuro" de su código para la máxima compatibilidad declarando BaseChecked en la firma del método:
static void code() throws BaseChecked {
MyApi api = new MyApiImpl();
api.checked();
}Si no quieres contaminar la firma de tu método, puedes envolverlo en un método RuntimeException:
static void code() {
MyApi api = new MyApiImpl();
try {
api.checked();
}
catch (BaseChecked ex) {
throw new RuntimeException(ex);
}
}Tenga en cuenta que aún puede capturarla en el código de llamada, pero tendrá que llamar a getCause() para obtener la excepción original que fue lanzada, así:
public static void main(String[] args) throws Throwable {
try {
code();
}
catch (Exception ex) {
assert ex.getCause() instanceof BaseChecked;
}
} Lanzamiento furtivo
También hay un truco oculto que descubrí investigando sobre este tema y que me sorprendió la primera vez que lo vi. ¿Sabías que Java te permite eludir eficazmente las excepciones comprobadas? Antes de entrar en materia, primero tienes que saber que RuntimeException extiende Exceptiona pesar de que la primera no está controlada y la segunda sí. El compilador comprueba la jerarquía de clases y hace una excepción explícita (perdón por el juego de palabras) para RuntimeException y sus subclases. Aquí está el truco
public static <E extends Throwable> void sneakyThrow(Exception ex) throws E {
throw (E) ex;
}
static void code() {
MyApi api = new MyApiImpl();
try {
api.checked();
}
catch (BaseChecked ex) {
sneakyThrows(ex);
}
}Observa cómo podemos llamar al método api.checked() y lanzar efectivamente la excepción comprobada sin declararla en la firma del método de code()¡! Y no, esto no es azúcar sintáctico para envolver una excepción. Si la capturamos en la llamada, nos daremos cuenta de que es la misma excepción que fue lanzada, no un método RuntimeException que la envuelve.
public static void main(String[] args) throws Throwable {
try {
code();
}
catch (Exception ex) {
System.out.println(ex.getClass().getName());
}
} Subtipos de Excepción de Sombreado
Si la sección anterior te ha dejado boquiabierto, entonces he logrado en parte mi objetivo: ¡las excepciones controladas son complicadas! Especialmente en un lenguaje que soporta tanto excepciones controladas como no controladas, la semántica y la interacción entre ellas puede ser bastante compleja y extraña a veces. Esto se agrava aún más por el hecho de que las excepciones controladas son las "predeterminadas" en Java. Esto crea un problema con la "sombra" de las excepciones no verificadas. Consideremos, por ejemplo, el siguiente código:
static void code() throws Exception {
MyApi api = new MyApiImpl();
if (Math.random() > 0.67) {
api.checked();
}
else {
api.unchecked();
}
}Obsérvese cómo la firma del método declara ahora el general java.lang.Exception en su cláusula throws cláusula. Si llamamos al método, ahora debemos manejarlo. Pero ¿qué ocurre cuando en lugar de lanzar BaseChecked, a BaseUnchecked se lanza una excepción? ¿Qué pasa si no queremos atrapar RuntimeException? Bueno, entonces tenemos que volver a lanzarla. La forma idiomática es atrapar RuntimeException primero, así:
public static void main(String[] args) throws Throwable {
try {
code();
}
catch (RuntimeException ex) {
throw ex;
}
catch (Exception ex) {
// Handle checked
}
}Del mismo modo, si se quisiera manejar CheckedA, CheckedB y BaseUnchecked explícitamente pero no RuntimeExceptionpuedes hacerlo así:
public static void main(String[] args) throws Throwable {
try {
code();
}
catch (BaseUnchecked ex) {
// Handle BaseUnchecked
}
catch (RuntimeException ex) {
throw ex;
}
catch (Exception ex) {
if (ex instanceof CheckedA) {
// Handle CheckedA
}
else if (ex instanceof CheckedB) {
// Handle CheckedB
}
}
}Así, se capturan las excepciones desde las más específicas a las más genéricas, casi como una switch declaración. ¿Ves el problema? Cuanto más genérica es una excepción, más información esconde. Estás forzado a tratar con el tipo más genérico de excepción, y depende de ti descifrar qué subtipos específicos pueden ser lanzados por un método. Esto no se puede comunicar a través de las firmas de los métodos - el compilador no puede ayudarte aquí. Por lo tanto, debes confiar en la documentación o incluso en el conocimiento del código fuente para descifrar qué posibles excepciones puede lanzar un método. Podría decirse que esto anula el principal propósito de las excepciones comprobadas. Por supuesto, el uso inadecuado de excepciones en las firmas de métodos es un problema de diseño más que de lenguaje, pero sólo hace falta una manzana podrida para corromper las firmas de métodos.
En la práctica, esto es especialmente común con java.io.IOExceptiondonde hay muchas subclases que describen problemas específicos, pero si estás usando un método de biblioteca que lanza IOExceptionentonces ha renunciado efectivamente a la capacidad de lanzar subtipos más específicos a menos que esté dispuesto a capturar y manejar explícitamente todas las demás posibles excepciones IO, ¡lo cual no recomiendo!
Evolución de las API y abstracción con fugas
Por definición, las excepciones verificadas deben ser declaradas en la firma de un método para ser lanzadas. Esto significa que si tu implementación cambia - digamos, llamas a un método de la biblioteca que lanza una IOException u otra excepción controlada, te ves obligado a manejarla dentro de tu implementación o envolverla en un método RuntimeException. La primera opción añade volumen al código y hace que el método sea más difícil de comprender. Sin embargo, no es necesariamente un "cambio de última hora", porque su método no necesita declarar todas las posibles excepciones no comprobadas. La mejor forma de comunicar un cambio de este tipo es utilizar la función @throws Javadoc en la documentación de la firma del método, por ejemplo
/**
* Method that does X.
*
* @throws UncheckedA If A goes wrong.
* @throws UncheckedB If B goes wrong.
*/
static void code() {
new MyApiImpl().unchecked();
}De esta forma, los usuarios de su método no tienen que hacer una expedición para descubrir cuándo puede lanzarse una excepción potencial. Sin embargo, supone una carga adicional para los mantenedores, que deben documentar explícitamente las excepciones que pueden lanzarse, y para los usuarios, que deben leer la documentación. Usted es explícito sobre las excepciones que conoce en este método y cuándo pueden ser lanzadas. Podría decirse que esto es más comunicativo que simplemente lanzar una excepción comprobada y confiar en que el compilador obligue a tus usuarios a manejarla. Además, de esta forma puedes ser selectivo sobre qué información expones a tus usuarios. No tienes que declarar detalles de bajo nivel de excepciones que PUEDEN ser lanzadas pero que son altamente improbables o incluso imposibles. Esto me lleva al siguiente punto.
Algunas excepciones no pueden ocurrir NUNCA
En algunos casos, las excepciones verificadas deben ser capturadas incluso cuando se puede probar que nunca serán lanzadas. He aquí un caso trivial:
static void code() throws URISyntaxException {
URI url = new URI("https://example.com");
}La mayoría de las cadenas son URI válidas, ya que java.net.URI admite URI parciales y puede determinar qué segmentos se especifican. Reconociendo esto, los mantenedores del JDK tienen una forma alternativa de crear un URI. La [documentación] de este método(https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/net/URI.html#create(java.lang.String)) debería darte una indicación de por qué existe.
static void code() {
URI url = URI.create("example");
}El uso excesivo de excepciones verificadas puede hacer que sea desagradable trabajar con una librería y aún peor para sus métricas. Un ejemplo particularmente molesto por experiencia personal es Jackson ObjectMapper. En el Vonage Java SDKla mayoría de nuestros objetos de dominio necesitan ser serializados a JSON, pero hacerlo usando Jackson requiere que manejemos el comando JsonProcessingException. La única manera en que esta excepción puede ser lanzada es si la clase tiene un uso incorrecto de las anotaciones. Existe incluso una pregunta popular en StackOverflow sobre esto porque cuando se persigue el 100% de cobertura de código se requiere cubrir el bloque catch aunque en la práctica nunca se lance la excepción.
Propagación por defecto
La mayoría de las veces, probablemente querrá propagar ("burbujear") una excepción de todos modos. Lo bueno de las excepciones no comprobadas es que su manejo es opcional. Por defecto, son lanzadas hasta que son capturadas, o manejadas por el hilo de ejecución UncaughtExceptionHandler (el comportamiento por defecto es imprimir el stack trace del hilo). Con las excepciones verificadas, tienes que propagarlas explícitamente, lo cual, como se ha discutido, contamina las firmas de los métodos y las convierte en un problema del llamante, incluso cuando en la práctica la excepción puede que nunca ocurra.
Caer en desgracia
Hay una buena razón por la que Java es uno de los únicos lenguajes de programación ampliamente utilizados que tiene excepciones comprobadas (consulte esta pregunta de StackOverflow). Otros lenguajes JVM como Kotlin, Scala, Groovy, Clojure etc. no tienen excepciones comprobadas - al menos, no te obligan a manejarlas. La documentación del lenguaje Kotlin resume el razonamiento bastante bien, y cita la entrevista de 2003 con el principal diseñador de lenguaje de C# sobre los motivos para saltarse las excepciones comprobadas.
Incluso dentro del propio lenguaje Java, basta con echar un vistazo a las nuevas API de la biblioteca estándar para darse cuenta de que las excepciones comprobadas deben utilizarse con moderación. Las API introducidas en Java 8, como java.time, java.util.stream y java.util.function evitan las excepciones comprobadas. La ya bien establecida API Streams es activamente hostil a las excepciones comprobadas. Esto ha causado un montón de dolor para los desarrolladores que quieren utilizar la API de flujos, ya que requiere hacky workarounds para propagar las excepciones comprobadas desde dentro de las interfaces funcionales incorporadas de Java. Más sobre esto más adelante.
Otros paradigmas de gestión de excepciones
Mi curiosidad por las excepciones comprobadas empezó con una discusión con mi colega, Guillaume. Ha dado una excelente charla sobre este tema, en la que defiende las excepciones comprobadas. Argumenta que las excepciones no siempre son la herramienta adecuada para tratar los errores y aboga por el uso de mónadas en su lugar. Un ejemplo de esto es la forma en que Java Streams API trata las excepciones. Tomemos por ejemplo el siguiente código:
static void code() {
OptionalInt resultWrapped = IntStream.range(1, 20)
.filter(i -> i % 9 == 0 && i % 2 == 0)
.findAny();
int guaranteedResult = resultWrapped.orElseThrow(IllegalStateException::new);
int resultWithAlternative = resultWrapped.orElse(18);
}
Observe que al hacer un filter en un flujo significa que cuando se llama a una operación terminal como finayAny()se le presenta una envoltura que puede o no contener un resultado. Para obtener el resultado, tiene que llamar a orElseThrow()que también puede tomar un Supplier para personalizar la excepción si el valor está ausente. Esto es casi como una excepción comprobada disfrazada, porque te ves obligado a reconocer explícitamente la posible ausencia de un valor, aunque en la práctica esté garantizada su presencia. Por supuesto, podría decirse que es más elegante y explícito, ya que también tiene la opción de proporcionar un valor alternativo con orElse.
Sin embargo, la charla Devoxx UK 2023 de Venkat Subramaniam sobre "Manejo de Excepciones en Programación Funcional y Reactiva" me hizo darme cuenta de que los dos puntos de vista no son contradictorios. Dos citas directas de su charla para contextualizar son:
"El manejo de excepciones es puramente un estilo imperativo de programación". "La programación funcional y el manejo de excepciones son mutuamente excluyentes".
Yo recomendaría encarecidamente la charla, que también toca el tema de la utilización de excepciones comprobadas dentro de código funcional, como Java Streams como se discutió anteriormente. La conclusión es que en los estilos de programación funcional y reactiva, el manejo de excepciones se realiza a lo largo de la transformación de datos. En lugar de utilizar catch y finally los marcos de trabajo reactivos utilizan funciones explícitas de gestión de errores aplicadas a la cadena para gestionar las excepciones. Y esto nos lleva a la intención original de comprobar las excepciones en primer lugar: garantizar la integridad y el reconocimiento explícito de errores y fallos en el código. Tal vez el debate en torno al tratamiento de las excepciones se refiera realmente a cuándo y en qué parte del código debemos tratar los errores que puedan surgir, y no tanto a los mecanismos utilizados para lograrlo.
Despedida
Eso es todo por ahora. Si tienes algún comentario o sugerencia, no dudes en ponerte en contacto con nosotros en X, antes conocido como Twitter o pásate por nuestro Slack de la comunidad. Espero que este artículo haya sido útil y agradezco cualquier opinión. Si te ha gustado, echa un vistazo a mis otros artículos sobre artículos sobre Java.
Compartir:
Sina es promotora de desarrollo Java en Vonage. Procede del mundo académico y, en general, siente curiosidad por todo lo relacionado con los coches, los ordenadores, la programación, la tecnología y la naturaleza humana. En su tiempo libre, se le puede encontrar paseando o jugando a videojuegos de competición.