Sobre checked exceptions y manejo de excepciones

Hace unos días me «retaba» Iván en Twitter a escribir un post sobre las checked exceptions de Java, y lo cierto es que me parece un tema entretenido para escribir algo más que un par de tweets, así que aquí va mi opinión.

Checked exceptions en Java

Desde la perspectiva de un sistema de tipos estático esto de las checked exceptions parece una buena idea: hace más expresiva la declaración del método y permite hacer más validaciones en tiempo de compilación.

Todo acorde con lo que uno esperaría de un sistema de tipos así. Si quieres flexibilidad vete a JavaScript y disfruta de su anarquía libertad, ¿no?

Sin embargo, para mi, que no soy ningún experto en Java, resultan incómodas. Quizá sea la sintaxis de Java, quizá sea el abuso de excepciones para situaciones que no son nada excepcionales, como que falle una conversión de string a int.

Si a eso le unes que existe un tipo mágico de excepciones que no se chequean (las que derivan de RuntimeException), pues te queda una mezcla un poco rara: por una parte me obligas a declarar y capturar excepciones con una sintaxis poco amigable, y por otra dejas una la puerta abierta a lanzar excepciones no chequeadas que hacen que nunca pueda estar seguro de si un método va a lanzar o no una excepción, por lo que esa presunta seguridad de tipos de queda en eso: presunta.

Excepciones como gestión de errores

En mi opinión, si una función puede no completar la operación que se supone que debe hacer es mejor codificarlo en el propio tipo devuelto por la función. Siguiendo con el ejemplo de convertir un string en un int, dado que es algo que puede fallar, me parece más razonable tener una función cuyo resultado indique si la conversión se pudo realizar o no y, en caso de que se realizase, el entero obtenido, en lugar de lanzar una excepción si el string no tiene un formato válido.

Si asumimos que la función puede fallar, podemos codificar el fallo en el tipo de retorno y convertir lo que sería una función parcial en una función total. En muchos lenguajes (como muchos funcionales) esto se representaría con un tipo Either o Maybe (si te da igual el motivo del fallo) y sería lo más normal del mundo.

Cuando unes ese diseño con un lenguaje que permite utilizar pattern matching para el análisis de resultados, o incluso algo como el do notation de Haskell, que suena muy exótico en pero en C# puedes simular con el select/from y seguro que en Java tienes alguna alternativa para hacer algo similar, la cosa se vuelve bastante cómoda y gestionar los errores de forma normal en lugar de como algo excepcional se convierte en rutinario.

Subiendo el nivel semántico de la excepción

Volviendo al hilo de Iván, él plantea:

Los argumentos que he escuchado en su contra son principalmente dos, el primero es que exponen detalles de implementación en la interfaz (la firma del método).

El segundo motivo en su contra es que si se modifica ese método y cambia su implementación de forma que pueda lanzar nuevas excepciones, es necesario actualizar todos sus usos añadiendo los correspondientes try.

Ciertamente son argumentos habituales en contra de las checked exceptions, y la respuesta de Iván a esos argumentos es más que razonable:

Tras darle vueltas y pensar en ello, para mi ninguno de los dos argumentos son válidos. Imagina una interfaz llamada Mailer que pueda tener diferentes implementaciones según como se quiera enviar un mensaje. Típico servicio registrado en un contenedor.

El método sería Future SendEmail(EmailAddress address, EmailContent content) throws EmailException

Bajo mi punto de vista, la interfaz debe de exponer esta excepción, y si ocurre algún tipo de excepción en alguna de sus implementaciones, relanzarla como EmailException.

De esta forma, me aseguro que la excepción siempre será EmailException y no otra. Imagina que estoy escribiendo una implementación que hace una llamada HTTP. Si HttpClient tiene HttpBadCodeResponseException como checked, me obligo a capturarla y relanzarla como EmailException de forma que no se me olvide hacer ningún catch y SendEmail lance un IOException o HttpBadResponseException, que SÍ que expondrían detalles de implementación.

A la hora de utilizar el servicio, sólo tendría que capturar EmailException en vez de Exception por si en alguna implementación se me ha olvidado capturar la excepción que produce y me encuentro con un FileNotFoundException o algo similar.

Básicamente la idea es aumentar el nivel semántico del error. Si tienes un método/función que envía un email, el resultado puede ser que lo ha enviado con éxito o que ha fallado, y si es así, el motivo real (fallo en la respuesta HTTP, fallo en el formato de la dirección del destinatario, etc.) puede considerarse algo secundario que quedará encapsulado en un EmailException.

Visto así tiene mucho sentido y es mucho más informativo encontrarte con un EmailException que con un HttpBadResponseException, pero lo cierto es que a efectos prácticos soluciona el problema a medias.

Es cierto que evita el carácter «vírico» de las checked excepctions, y si una nueva implementación del servicio puede encontrarse con otro tipo de problema los consumidores del servicio Mailer no tienen que preocuparse por ella puesto que quedará encapsulada en una EmailException. Esto funciona muy bien si desde el principio ya hemos decicido que el método SendEmail puede lanzar una excepción.

Pero, ¿qué ocurre cuando al diseñar nuestro interfaz decidimos no incluir ninguna excepción porque en las implementaciones iniciales no vemos ningún caso problemático? Básicamente, hemos vuelto al punto de partida. Cuando encontramos la primera implementación que puede lanzar excepciones, debemos encapsularlas en una excepción (con mayor valor semático, eso sí), y eso nos obliga a cambiar todas las implementaciones el interfaces Mailer y todos los consumidores del interfaces.

Una opción sería hacer que, especulativamente, todos y cada uno de los métodos de nuestros interfaces lanzasen una excepción con valor semántico asociado al interfaz; pero, sinceramente, empezar a añadir posible excepciones por si acaso en el futuro alguna implementación del interfaz la lanza no me parece muy práctico.

Y, ojo, esto no es algo que se solucione con la alternativa más «funcional» que comentaba antes de utilizar un tipo Either o similar para representar el resultado de la operación. Estás en las mismas, o bien introduces desde el principio el tipo Either como valor de retorno de todas tus funciones «por si acaso» en un futuro alguna puede fallar, o en el momento en que lo introduzcas te va a tocar modificar el código que ya existe.

Conclusión

Admito que aquí hay la cosa va por barrios.

En cierto modo, me recuerda a la típica discusión de lenguajes dinámicos y estáticos. Yo me siento más cómodo con lenguajes estáticos y nunca me ha supueto un freno tener que lidiar con tipos, más bien al contrario, pero es cierto que la excepciones chequeadas, que podrían verse como un paso más hacia la comprobación estática de errores, me han supuesto fricción adicional a la hora de desarrollar.

En realidad, ni siquiera estoy convencido de que las excepciones sean una buena idea como mecanismo de gestión de errores frente al uso de valores de retorno al estilo go, pero sin entrar a debatir eso, sí tengo claro que deberían representar cosas excepcionales y que, en general, se tienden a usar en situaciones que tienen poco de excepcional.

Habitualmente intento no gestionar excepciones en el lugar en que se producen a menos que esté tratando con una librería que las lance por casi cualquier motivo y pueda hacer algo para recuperarme del error o al menos convertirlas a algo con mayor valor semántico (cosa que, sinceramente, ocurre menos veces de las que me gustaría).

Desde mi punto de vista, una excepción debería representar algo excepcional y, por tanto, algo que de lo que la aplicación no tiene muchas formas de recuperarse. Por ello, tiendo a gestionar las excepciones en la «frontera» de las aplicaciones (controladores de un API Web, manejadores globales de excepciones), y muchas veces me limito a dejar que muera el proceso (ya sea la petición concreta a un API o incluso la aplicación entera) y logear la información para evitar en problemas futuros.

5 comentarios en “Sobre checked exceptions y manejo de excepciones

  1. Ivan Montilla dijo:

    Hola Juanma.

    Primero de todo, muchas gracias por escribir el post.

    Yo tampoco soy un experto en Java, y mi experiencia en Java no va más allá del ámbito educativo. Actualmente trabajo en entornos .NET Core y C#.

    Voy por partes:

    – El método parseInt de la clase String puede lanzar una RuntimeException si el string tiene un formato inválido (véase, que el string no es un número). En este caso, me parece correcto que sea una RuntimeException, pues con simplemente comprobar previamente que el string es válido, nos aseguramos que la excepción nunca se lanzará, y por ende, no es necesario capturarla. En este aspecto, para hacer dicha comprobación me gustan mucho los métodos TryParse de .NET

    – Yo entiendo que RuntimeException es un tipo de excepción que no debería de ocurrir nunca (y por ende, no se debe de capturar), ya que es responsabilidad del programador asegurarse de que no se produzcan. Cosas como llamar a un método de una referencia nula, querer convertir a entero un string que contiene letras, o aserciones del diseño por contrato (https://blog.koalite.com/2014/01/diseno-por-contrato/) podrían ser ejemplos de RuntimeExceptions.

    – También entiendo entonces, que una excepción normal (checked) son aquellas que escapan fuera del control del programador. Un fichero que no existe, un servidor que no responde o unas credenciales de acceso incorrectas, serían ejemplos de excepciones que siempre deberían de ser manejadas. Como usuario quiero que la aplicación me diga que mi contraseña está mal o que hay problemas de conexión con el servidor, en vez de que se muera sin darme un motivo.

    – Estoy deacuerdo contigo en que añadir excepciones «por si acaso», puede llegar a ser un coñazo, pero personalmente, no me parece tan grave. Las excepciones a añadir deben de tener un valor semántico que indique cual es el error, y esto me parece lógico que forme parte de la definición de la interfaz. Me explico, volviendo al caso de Mailer, los posibles errores que pueden ocurrir sería dirección de correo inválida, contenido de correo mal formado y otros errores relacionados con la implementación. Bajo este punto de vista, y antes de conocer la implementación, yo ya declaro InvalidEmailAddressException, InvalidEmailContentException y EmailException. Que la implementación en concreto lance todas ellas o sólo unas pocas, ya es otro tema. Una correcta implementación debería de lanzar cada uno de ellos en los casos que corresponda.

    – Para finalizar, al final del post dices que no sueles tratar las excepciones en el lugar que se producen, si no en la «frontera». Aquí estoy completamente deacuerdo contigo, pero en la frontera de la aplicación yo tengo una interfaz (e.g. he inyectado IMailer en el constructor del controlador) y desconozco por completo que implementación se está utilizando, es por ello que me gustaría que las excepciones fueran lo más semanticamente posibles con la interfaz. Quiero que IMailer me lance un InvalidEmailAddressException o un EmailException que podría capturar para mostrar al usuario un mensaje «Dirección de correo inválida» o «Ha fallado al enviar el correo». Si me lanza un HttpException o un IOException, no sabría que hacer con ellas. ¿Captura Exception por si acaso viene una de estas? ¿Y qué mensaje de error le muestro al usuario? En este sentido, contar con un mecanismo de comprobación de excepciones en tiempo de compilación, me aseguraría que sí o sí, en la frontera siempre me voy a topar con una excepción semánticamente correcta.

  2. Ivan Montilla dijo:

    Y hablando de excepciones, a modo de chiste y siguendo con la temática de excepciones, añado que en la primera línea del post se ha lanzado una OrtographicException.

    «Me retaba *a* escribir».

    Un saludo Juanma

  3. GreenEyed dijo:

    Anda en que temas te metes :).
    Empezando por las pegas que originaron la cuestión, lo de que «exponen detalles de implementación en la interfaz» no tiene sentido por que la declaración de las excepciones es parte de la firma del método, del contrato/interfaz así que no es un detalle de implementación. Sería como decir que el declarar el tipo de retorno es exponer detalles de implementación. Precisamente declarar ese tipo de cosas es el motivo de tener la firma del método.

    Respecto al segundo, pues igual que si cambias el metodo de retorno se modifica la firma del método y toca recompilar, pues las excepciones que se lanzan también son parte de la firma del método y pasa lo mismo. Tu puedes añadir otras excepciones que sean subclases de las declaradas, pero no añadir nuevas, igual que el tipo de retorno.

    A mi personalmente las excepciones me gustan como mecanismo de control para situaciones «excepcionales», y me gustaron ya en otros lenguajes que las tienen como Ada o PL/SQL, pero el problema principal que tienen es la poca consistencia y mal uso que han hecho de ellas en las clases core de Java, y con esos ejemplos pues la gente normalmente las usa igual de mal o peor.

    Las típicas quejas que suelo oir de que «te obligan a tratar…»: falso, declaras un throws y listo, la tratas al nivel que quieras. «No sirven para nada por que total no vas a poder hacer nada…»: falso. A veces puedes hacer algo, por que es un valor opcional y te lo han pasado mal y puedes avisar e ignorarlo, a veces no por que sin fichero de configuración es mejor no arrancar. Pero te da la opción de que TU escojas que hacer.
    Parte del problema es que a la gente le gusta programar para los mundos de Yupi donde nunca pasa nada y tener que tratar todos los casos de error que se podrían dar «es una plasta» y se vive más feliz si se programa solo para el caso perfecto donde todo esta en su sitio y los parametros son todos del tipo y formato correctos, y si pasa algo «que pete todo, total que más da si no se puede hacer nada».

    Eso sí, repitiendo que otra gran parte del problema, IMHO, es que el propio JDK no es consistente en cuanto a que declara como checked exception (Problemas «esperables» dentro de una ejecución) y
    runtime exception (cosas que no estaban previstas).

    A mi personalmente, lo de añadir el error dentro del tipo de retorno sigue sin gustarme ya que para mi, contamina el flujo del programa sin errores con comprobaciones continuas de si el retorno fue error… o eso o lo vas ignorando, que para el caso, peor.

    Es cuestión de gustos, pero a mi declarar un catch que espero que no llegue nunca a ejecutarse o añadir un throws en un metodo donde no quiero tratar un problema por que me falta contexto para hacerlo, no me ha supuesto nunca ningún trauma.

    Pero vamos, popular, popular, mi opinión no parece ser ;)

  4. Ivan Montilla dijo:

    Hola GreenEyed, muchas gracias por comentar.

    Estoy de acuerdo contigo en todo lo que expresas, salvo en que las excepciones se utilicen mal en JDK, pero lo cierto es que como tampoco tengo mucha experiencia con Java, pues no tengo opinión sobre ello. El argumento en su contra de que expone detalles de implementación lo saqué de este post https://phauer.com/2015/checked-exceptions-are-evil/

    Llegados a este punto, tal como expresé en Twitter, mi problema por lo que quise buscar opinión (gracias Juanma por escribir en tu blog sobre ello) fue cuando escribiendo en C# la implementación de un servicio, no supe si había encapsulado todas las posibles excepciones en el tipo de excepción más alto nivel relacionado con la interfaz. En ese momento recordé el mecanismo de Java para evitar eso.

    Un saludo.

  5. GreenEyed dijo:

    Hola Ivan,
    Lo de que expone detalles de implementación lo había leido en alguna otra parte, pero vamos, como ya comento es cuestión de considerarlo igual que el tipo de retorno. El ejemplo que comentan en el enlace tiene bastante poco sentido. A quien se le ocurriría en una interfaz declarar como excepcion a lanzar algo que solo lanza una sola de las posibles implementaciones… Si tenemos que extraer conclusiones del mal uso, mal vamos. Lo mismo podiamos decir que como en la interfaz podriamos declarar que getUsers devuelve CustomUserImplementation entonces declarar el tipo de retorno es malo…

    No puedo comentar sobre C# por que lo he usado muy poco, pero si puedo certificar que el decidir cuando es runtime y cuando es checked dentro del JDK, sobretodo en las clases antiguas, dependía un poco del equipo responsable y no habia un criterio claro. Admitido por los mismos de Sun (les pregunte a la cara cuando todavía era Sun :) )

Comentarios cerrados.