miércoles, 22 de junio de 2011

Un caso excepcional

No, no es que haya encontrado código Java bueno. Lo único que conozco más difícil que encontrar código Java de calidad es encontrar aparcamiento en Getafe. Hablo del uso de las excepciones en Java. Con esto reanudo este blog, que he tenido abandonado desde el principio por causas de fuerza mayor (es que la vagancia hace mucha fuerza).

Como todos sabemos, hay muchas posibilidades de hacer las cosas mal cuando se trabaja con las excepciones. Además de los típicos antipatrones, se puede meter la pata al empezar a pensar en ellas.

Está de moda por ahí la discusión acerca del tipo de excepciones que un sistema cualquiera debe declarar. ¿Deben ser Exception (checked) o deben ser RuntimeException?. La verdad es que no le veo mucho sentido a la discusión, si tenemos en cuenta para qué es cada cosa (y, recordad, siempre digo que cada cosa es para lo que es). Pero bueno, ya que últimamente me ha salido hasta en el trabajo, voy a ver si pongo esto un poco claro.

Para los quisquillosos: en adelante, cuando hable de Exception quiero decir "clases que hereden de Exception y no de RuntimeException".

Los partidarios de RuntimeException suelen exponer una cierta cantidad de argumentos (luego expondré yo los míos). Veamos los Top-Ten:

1) Las Exception lo que hacen es complicar el código porque obligan a capturarlas incluso cuando no se puede hacer nada con ellas. Mentira. Si no sabes qué hacer con ellas, colocas un throws en el método y que se encargue el que sepa.

2) No hay nada que se pueda hacer con Exception que no se pueda hacer igual con RuntimeException. Ciertamente, si ignoramos el hecho de que el compilador te obliga a hacer algo con una Exception, mientras que de la RuntimeException puedes pasar sin que el compilador te chille.

3) Cuando andas con prisa, las Exception favorecen que uses algún antipatrón de manejo de excepciones, como el catch&ignore y otros. Pues sí, si el programador es malo, mientras que las RuntimeException favorecen el ignore sin catch ni nada.

4) Aunque un método lance Exception, también puede lanzar RuntimeException, de modo que habrá parte del comportamiento que no estará documentado. Aquí volvemos a lo de que cada cosa es para lo que es. ¿O preferimos que todo el comportamiento no esté documentado?

5) Mezclar cosas queda feo: o todo Exception o todo RuntimeException. Me gustaría ver como uno de estos hace las ensaladas.

6) Si las llamadas a métodos se van acumulando, los métodos invocadores van acumulando también una lista tremenda de excepciones en el throws, de manera que es complicado gestionar el método. Sí, si lo haces mal. Existe aquello de la traducción de excepciones y, además, si te llega la posibilidad de que un método declare más de tres o cuatro, es muy posible que alguien haya hecho algo mal: algún diseño incorrecto, excepciones sinónimas, excepciones inventadas, no hacer algo cuando se debía... Hay muchas posibilidades.

7) La mayoría de las veces no se puede hacer con ellas más que mostrar un mensaje, escribir un log, deshacer una transacción... y esto con algún elemento central que se encargue; no merece la pena llenarlo todo de catch y throws. No estaba enterado de que la lógica de procesamiento se basara en una democracia. Si no es el caso, entonces la mayoría no tiene que ver y las circunstancias excepcionales (palabra elegida aposta) pueden mandar. De todos modos, volveré a esto luego.

8) Si un método declara Exception, esto se vuelve parte del contrato, volviéndose muy complicado hacer alteraciones posteriores, porque romperías tu propio contrato. Ya, depende de cómo lo hagas. Además, ¿es mejor que cuando sabes que un método puede encontrar un fallo no se lo digas a nadie? Porque si lo pones en la documentación, eso también es parte del contrato, lo sepas. Supongo que este argumento puede usarse para hacer cambios que signifiquen que código anterior deje de funcionar y poder decir: "Ah!, la culpa no es mía...".

9) El mismo Josh Bloch lo dice en "Effective Java": Avoid unnecessary use of checked exceptions. Pues sí, es cierto. Pero obsérvese la existencia de la palabra "unnecessary". Si alguien quiere darme este argumento, le recomendaré leer el contenido del ítem, además del título, así como los otros ítems del apartado "Exceptions". Ya puestos, todo el libro. Es muy interesante.

10) Otros lenguajes de alto nivel no tienen excepciones de tipo checked y van muy bien. Esta me gusta. Es como si me dices que el inglés se expresa muy bien sin tantas conjugaciones como tiene el castellano. Pues habla inglés. Y no te extrañes de que alguien no te entienda. Estamos hablando de Java, no de otros lenguajes. Java tiene su estilo, sus maneras de hacer las cosas (bien), y así ha sido diseñado.

Veamos entonces cómo hacer las cosas bajo mi punto de vista que, en lo que a mí concierne, es el bueno.

¿Pues para qué es cada cosa? Los señores de Sun (aún me resisto a lo de Oracle) dicen claramente que si un cliente puede razonablemente recuperarse de un fallo, se haga una Exception; si no, una RuntimeException. Bueno, esto no es decir mucho. Además, ellos mismos se lo saltan a veces (vease CloneNotSupportedException). A ver si lo digo mejor:

Si un fallo está causado por circunstancias ajenas a la programación, o sea, si por muy bien que un tío programe no puede prevenirlo, entonces debe ser una Exception. Por ejemplo, a ver como uno se asegura por programa, y de forma razonable, que una conexión no se corte, que un fichero no desaparezca o que un usuario manazas no escriba algo mal. Además, en estos casos muchas veces se podría hacer algo (reconectar, reintentar, y otros res, por ejemplo).

Ahora bien, si la culpa del fallo se le puede echar a la programación, por ejemplo, por pasarse el contrato de un método por algún sitio no computable, entonces será una RuntimeException. Vale, también si no hay nada lógico que se pueda hacer, como con CloneNotSupportedException.

Hay una pista: cuando nos vale una RuntimeException existente, probablemente sea eso lo que hay que usar (no vale decir que para cuando el usuario ha escrito en una fecha el mes 38 es una NullPointerException porque no encontramos ese mes, o algo del estilo). Si no, lo más probable es que debamos crear una Exception.

¿Y qué pasa con algunos de los argumentos a favor de RuntimeException que aún podrían valer? Pesaditos. A ver.

Pongamos los métodos de I/O. ¿Y si FileNotFoundException fuera una RuntimeException? Pues que ni el gato pensaría en capturar la excepción. Total, seguramente habremos comprobado que el fichero existe antes de abrirlo. Lo malo sería que el fichero desapareciera entre la comprobación y la apertura. Entonces el thread cascaría con una bonita traza de un fallo que, examinando el código, no tienes idea de por qué ocurre. Y que, además, se la encontraría un cliente, con la posible bronca subsiguiente.

No. El contrato del método de apertura considera una circunstancia excepcional que el fichero no exista (¿para qué ibas a pretender abrir un fichero que no exista?) y, por tanto, lanza una Exception.

¿Tanto rollo por si ocurre algo que raramente (excepcionalmente) ocurre? ¿Dónde está aquello que siempre digo de que lo más simple suele ser lo mejor? Aquí: Es mucho, muchísimo, más simple detectar un posible fallo al compilar que al ejecutar, y esto tiene un peso enorme frente a otras cosas.

Pero claro, tenemos un método que hace unas cuantas cosas de I/O, y sería un jaleo tratar todas las posibles excepciones en catch y throws. No pasa nada, para eso todas esas excepciones heredan de IOException. Eso te permite hacer cosas como:
public void miMetodo(String nombreDeFichero) throws IOException {

    if(nombreDeFichero==null)
        throw new NullPointerException("nombreDeFichero es null");

    ...
    try {
        ...
        FileReader fis=new FileReader(nombreDeFichero);
        ...
    } catch(FileNotFoundException conEstaSiSeQueHacer) {
        ...
    } // Con las demas no
    ...
}
Obsérvese que primero compruebo lo que me pasan. Esto lo haré porque en el contrato del método especificaré que el parámetro nombreDeFichero no puede ser null, y que lanzaré una NPE al listo que se salte eso. O sea, al programador le responsabilizo de que el parámetro no sea null, de modo que, si lo es, culpa suya (fallo de programación), porque la circunstancia está enteramente bajo su control, no como el hecho de que el fichero exista justo cuando voy a abrirlo. Alguien podría decir que para eso también puedo usar el assert. Sí, claro, y elimino el error desactivando las aserciones. Eso me vale para cosas que no estén en el API público (ya daré una definición adecuada de esto) y cuyos usos están controlados, pero cuando algo está en API y no sé quién ni como lo va a usar, pues ya no. También podría decir que si no hago la comprobación, la NPE saltará en el new, así que no hace falta. Primero, la mía tiene más información. Segundo, me evito todas las cosas que haga o deshaga entre lo uno y lo otro. Tercero, si el método lo que hiciera es serializar el parámetro y mandarlo a otro país para que otro día alguien haga algo, a ver cómo rastreas el fallo. A hacer las cosas como en el ejemplo es lo que se llama fail-fast, técnica muy recomendable.

Que el método declare IOException no es obstáculo para luego usar excepciones concretas:
public void miOtroMetodo() throws IOException {
    ...
    try {
        ...
        miMetodo(unNombre);
        ...
    } catch (UnsupportedEncodingException aquiYaSeQueHacerConEsto) {
        ...
    }
    ...
}
Lo malo es que tenemos por ahí un método básico, que llama a todos los demás, donde se centraliza el tratamiento de errores (de una manera bastante genérica, claro). Aún agrupados por herencia, tendríamos que tener una ristra de catch tal que aquello parecería un muestrario de una fábrica de embutidos. Pues no. Seguro que, en algún punto de la cadena de llamadas a método, hay alguien a quien le importa un rábano que los métodos a que llaman resuelvan su tarea con streams, bases de datos o XMLs. Por lo tanto, también le importa un rábano que la excepción sea IOException, SQLException o SAXException. Eso quiere decir que a quien le llame también le importará un rábano. Y seguro que estos detalles tampoco le importan al que trata los fallos. Es el momento de la traducción de la excepción (exception wrapping, les gusta decir a algunos).
public void metodoQueNoQuiereSaberNadaDeLoQueHacenLosOtros throws MiBaseException {
    ...
    try {
        ...
        miOtroMetodo();
        ...
        algunOtroMetodoMas();
        ...
    } catch (IOException e) {
        throw new MiBaseException("Un mensaje explicativo aquí", e); // Que no se me olvide colocar e como causa
    } catch (SQLException e) {
        throw new MiBaseException("Otro mensaje explicativo aquí", e);
    }
    ...
}
Algunos dicen que el código como este no aporta nada nuevo, que no hace nada. Pues que lo miren de nuevo. Yo diría que algo hace.

También podrían decir que todo esto también se puede hacer con RuntimeException. Sí. La diferencia es que con unos se puede y con otros se debe. Y ya veremos donde queda el "se puede" cuando uno tiene que entregar a las siete. Y repito lo de los contratos: si la excepción está en throws, pasa a formar parte del contrato. Si está en la documentación, también, con la diferencia de que es probable que alguien se acuerde de tratar el fallo, como pronto, en las pruebas. La alternativa para que el contrato quede libre es no decir ni mú. Y en ese caso, si la excepción se lanza, la culpa no será de quien use el método que la lance, si no del que lo ha hecho. Para eso están los contratos.

Y así nos podríamos tirar todo el día. Alguien me puede preguntar qué opino de heramientas como Spring, Hibernate, JPA, y otros para los que toda excepción es un RuntimeException. Pues si me centro solo en el tema, para no llamarlos (como hay quién) "muestrario de antipatrones" o "enorme antipatrón", diré que tienen graves errores de diseño elemental. Je!, que comedido he estado aquí.

Como resumen, si tenemos clases y métodos con contratos (que siempre los tenemos, otra cosa es que los documentemos), entonces en las clases puede haber invariantes y en los métodos precondiciones y poscondiciones. Chequearemos siempre las precondiciones, y lanzaremos RuntimeException adecuadas si no se cumplen. En cuanto a las poscondiciones, ya nos vale que el método se encargue de cumplirlas (estaría bueno). Chequearemos las invariantes cuando se entre en un método de API y lanzaremos RuntimeException (IllegalStateException, para más señas) si no se cumplen. Aseguraremos las invariantes antes de salir de un método de API, o lanzaremos RuntimeException si no podemos garantizarlas (por cierto, todo esto de las invariantes también nos sirve si nuestra clase debe ser thread safe, cosa que diremos en la documentación). En cualquier otro caso, casi seguro que tenemos que usar una Exception.

Bueno, creo que ya está bien por hoy. Que os aproveche y hasta otra.

2 comentarios:

  1. Al hilo de lo que comentas acerca del trío lalalá (Spring, Hibernate y JPA), sería muy interesante un post hablando de alternativas mejor diseñadas (si existen) y más "mejores".

    Lo que es Hibernate (tener que aprender un lenguaje nuevo para no usar SQL y regresar al poco tiempo a SQL cuando eso no tira ni para atrás) me despista bastante. ¿Existe una forma efectiva de utilizarlo? ¿Todo depende de disponer de un buen modelo de datos (que ésa es otra de las buenas), de mapearlo correctamente para no traer chorrocientas mil instancias a memoria y gastar la memoria del servidor o de todo un poco :-)?

    Un saludo

    ResponderEliminar
  2. Hola, Jerónimo.

    Alternativas siempre hay, pero la calidad del diseño (puede que por desgracia) no es la única cosa a tener en cuenta ni, probablemente, la más importante. Si hay que usar algo de funcionalidad, por ejemplo, similar a la de Hibernate, mejor usar Hibernate, por flexibilidad y, sobre todo, por lo extendido que está (o sea, por las posibilidades de encontrar a gente que lo conozca.

    Lo que sí es importante a la hora de incorporar uno de estos frameworks a tu sistema, y que no he visto que se tenga en cuenta hasta ahora, es que son soluciones que no están diseñadas para solucionar tu problema. Es decir, un framework incorpora una gran cantidad de artilugios que necesita para servir a todo el mundo, que requieren recursos y muchos de los cuales no te van a servir para nada. Evidentemente, a la hora de elegir uno es necesario valorar las ventajas de hacerlo, pero también las desventajas. Esto, la próxima vez que lo vea en un estudio será la primera. Creo que hay (muchos) “ingenieros” y “arquitectos” que, movidos por modas e inercias, ni siquiera saben que hay desventajas. Esto y los desarrollos chapuceros deben ser las principales causas de que los clientes se quejen de lo lentos que son sus sistemas.

    Acerca de hacer que Hibernate, o cualquier ORM, sea verdaderamente eficaz, mi opinión es que el truco consiste en olvidarse de él hasta que haga falta. La persistencia, que se toma muchas veces como base, en realidad es un “accidente” Cuando haces el diseño de tus objetos de negocio debes hacerlo según criterios orientados a objetos (¡sorpresa!, esto no es COBOL). Diseñas entidades, relaciones, asociaciones, etc., y clases de apoyo, factorías, especializaciones…, determinas la accesibilidad de clases y propiedades… Cuando tengamos todo esto, podremos extrapolar el diseño de datos. Muchas veces tendremos un diseño de datos dado, bien porque un analista lumbrera ha empezado por el final o porque se hereda de un sistema anterior (no sé qué es peor). En este caso hacemos lo mismo, y tocará crear una capa de adaptación (de persistencia) que traduzca los objetos persistentes de tu sistema a los que necesita la base de datos. Si lo haces bien, probablemente mejorarás algo el uso de memoria, aunque no creo que la velocidad, y obtendrás un sistema con mejor mantenibilidad. Si la velocidad es necesaria, se suele empezar a usar truquitos (el 90% inútiles o mal aplicados), pero el único bueno es sustituir Hibernate por JDBC.

    Espero que esto te sirva. Saludos.

    ResponderEliminar