Mostrando las entradas con la etiqueta desarrollo de software. Mostrar todas las entradas
Mostrando las entradas con la etiqueta desarrollo de software. Mostrar todas las entradas

22 diciembre 2011

"Developability"

Existen numerosos atributos de calidad, e incluso clasificaciones estándar de ellos.

¿Para que sirven estas clasificaciones?

Por un lado sirven para darle a los diseñadores de sistemas un punto de partida. Algo que permita dar respuesta a la pregunta: "entre las características que requiere nuestro sistema ¿nos estaremos olvidando de algo?". Por otro sirven para que los investigadores de arquitectura de software puedan definir métricas y estilos de solución.

Y por otro, sirven para que yo tenga una excusa por donde empezar este post :)

Estaba pensando que en estas clasificaciones hay un atributo de calidad perdido, que voy a bautizar como "Developability", y voy a definir así: la capacidad un sistema para reflejar los cambios introducidos durante el desarrollo.

Después de todo si en Java existen productos como JRebel y frameworks como Play anuncian sus bondades con frases como: "No need to compile, deploy or restart the server"; creo que existe una necesidad de tener la característica de "developability" en mente a la hora de pensar en el diseño de un sistema.

¿No es lo mismo que Maintainability?
Es parte de la mantenibilidad, en el sentido que forma parte de la facilidad de cambio de un sistema.

Pero muchas veces cuando se habla de mantenibilidad se hace referencia a cuan fácil es introducir nueva funcionalidad sin tener que hacer grandes cambios en el diseño.

Podemos tener, por ejemplo, un sistema muy fácil de extender, donde cada vez que un desarrollador introduzca un cambio tenga que esperar 2hs a que termine la compilación para poder verlo.

¿No es lo mismo que Testablity?
Testabilty se refiere a que tan fácil es verificar el software, por ejemplo: si es posible realizar test automatizados con facilidad o no.

Y lo que planteo es en cierto sentido similar, pero con objetivos levemente distintos: un sistema puede ser fácil de testear en el sentido que es posible automatizar tests u obtener información de estado relevante para el testing y aún así ser un tremendo "dolor" para desarrollar.

Un ejemplo: Mejorando el "developability" de Microwiki
Mi pequeño proyecto: microwiki; del que les conte hace un par de posts atras. Utiliza templates de html para renderizar las paginas web. Estos templates son resources y se cargan del classpath.

Por default Jetty no hace un reload del classpath cada vez que algo cambia -principalmente por que estar viendo el filesystem por cambios es una operación costosa.

Al principio tener que reiniciar microwiki para ver los cambios en un template no me molestaba: el proyecto es chico y reiniciar es rápido.

Sin embargo cuando empece a mejorar un poco el diseño del HTML esos segundos de presionar restart en el IDE y volver a cargar la página se volvieron una molestia.

Por suerte para no meterme con cuestiones de classpath reload y configuración de Jetty, algunos astros de "mantenibilidad" y "testeabilidad" se alinearon.

Para permitir que microwiki sea configurable mediante archivos de configuración, y que la interpretación de estos me sea fácil de mantener utilice un DSL de Groovy.
Es decir los archivos de configuración son código fuente Groovy, y en ellos es posible definir un template de la siguiente manera:

templates {
    display = '/dir/disp.html'
    edit = '/other/edit.html'
}

Por otro lado para facilitar el testing los templates implementan la interfaz: ViewTemplate. Esto me facilita la creación de mocks en los tests.

Juntando estas dos cosas, el cambio para facilitar el "developability" fue muy sencillo:

Agregue un método al builder que interpreta el DSL del archivo de configuración (ConfigBuilder):

ViewTemplate debug(source, Map context = [:]) {
  { Map ctx -> template(source, context).applyWith(ctx) } as ViewTemplate
}

Con este método es posible definir la configuración de esta manera:

templates {
 File prjDir = new File('/Users/diegof/Projects/microwiki/src/main/resources')

 edit = debug(new File(prjDir, 'microwiki/templates/edit.html'))
 search = debug(new File(prjDir, 'microwiki/templates/search.html'))
}

Y ya no hay que preocuparse por el reload: microwiki va a leer el archivo cada vez, y los cambios se ven de forma instantánea (claro que esto no tiene en cuenta los cambios en el stylesheet, aunque es un avance).

De que sirve esto... cuando tengan uno de esos proyectos web que cargan millones de cosas al inicio, es bueno dedicar un poco de tiempo a pensar como mejorar el "developability": aunque los appserver pueden hacer reload de ciertas cosas, no pueden hacer magia, y a veces pensar un poco en esta característica como parte de la calidad de la solución les va a ahorrar horas de frustración.

09 abril 2011

Juego de la vida (2da parte: implementación)


Hecha la introducción sobre el juego de la vida de Conway en el post anterior, paso a contarles algunos detalles de implementación.
No voy a referirme al algoritmo: es el primero que me salió,  en cambio vamos a ver algunas cosas interesantes para aprender de JavaScript y HTML5.


Doctype 
La primer duda que me surgió a la hora de escribir el HTML5, es ¿Qué DOCTYPE uso?
Quienes recién comienzan con HTML quizás no entiendan esta duda, es una larga historia resumida de forma excelente en "Dive into Html 5": http://diveintohtml5.org/semantics.html#the-doctype .

En pocas palabras, el DOCTYPE que debe usarse en HTML5 es simplemente: <!DOCTYPE html >


Progressive Enhancement
Progressive enhancement es una técnica que consiste comenzar con el contenido, usando los tags de HTML para estructurarlo (títulos, párrafos, listas, etc) y luego ir agregando el diseño y "comportamiento" mediante CSS y JavaScript.

Pueden encontrar una buena introducción en este articulo.

Claro que en la pequeña aplicación JavaScript no hay contenido :), en fin el objetivo era aprender y practicar técnicas útiles para el desarrollo web.


CSS
Los stylesheets son sumamente engorrosos de mantener. Afortunadamente existen lenguajes de macros que permiten generar stylesheets usando constantes y funciones.
Uno de ellos es LESS, que lo elegí por dos razones: pueden usar JavaScript en el cliente para hacer pruebas y para aplicaciones Java cuentan con un servlet.
Otra alternativa para quienes usan Ruby es Sass.


Canvas y gráficos dinámicos
Para los gráficos comence usando un Canvas.
Lamentablemente el API de Canvas es en cierto sentido de "bajo" nivel: provee primitivas para dibujar directamente, pero carece de algunas abstracciones convenientes.
Luego de frustrarme (por alguna razón que no pude encontrar, los gradients en el stylesheet que uso modifican el estilo de dibujo del Canvas... algo muy engorrozo que me paso tanto Chrome como en Firefox) decidí buscar algun framework JavaScript que me facilite las cosas.
Entre los frameworks que estube mirando me encontré a gusto con raphaeljs.
No usa Canvas si no SVG (que al parecer no esta disponible en algunos browsers para smartphones).
Pero el API me pareció simple e ideal para hacer visualizaciones en HTML.


Local storage
Guardar y retornar información local en HTML5 es extremadamente fácil:

localStorage.setItem("key", "hello");
var value = localStorage.getItem("key");

Claro que este "map" es bastante rudimentario, hay otras opciones pero funcionan solo en ciertos browsers.
Aunque no lo use en este ejemplo, también pueden proveerle al browser información de que archivos mantener localmente para que la aplicación funcione offline.


Debugging y profilling
Con la ayuda de Diego Camera, conoci algunas herramientas de debugging y profiling de Google Chrome.
Tanto si usan Chrome o no, cuentan con la extensión FireBug que les agrega un API de consola extremadamente útil para hacer debugging y profiling (en Chrome no necesitan instalar una extensión para acceder a este API).

Para destacar: console.dir(object) les permite inspeccionar rápidamente el objeto pasado como parámetro.

Una cosa que me gusta de trabajar con estas herramientas es que me dio casi la misma sensación que en Smalltalk: uno puede inspeccionar y modificar en "entorno" en cualquier momento. Quizás en algún momento con proyectos como Ace (antes Mozilla Bespin/Skywriter) y  Eclipse Orion se llegue a lo que hacían los entornos de Smalltalk hace más de 20años ;-)

Para terminar....
Este post solo tiene algunas "puntas" a mirar para aprender más sobre JavaScript y HTML5.

Si están interesados en hacer algo para aprender, la implementación del juego de la vida que presente en el post anterior tiene muchas cosas pendientes, y con distintos niveles de complejidad:
  • Cada celda tiene su propio event handler, se podría usar un unico event handler para ahorrar recursos.
  • Las celdas se pintan haciendo click, lo cual es bastante incomodo si quieren pintar más de una celda. Se podrían cambiar los event handlers para permitirlo.
  • La función de "Save/Load" solo permite grabar una instancia, se podria modificar para soportar mas de una.
  • Seria útil poder insertar patrones de celdas predefinidos
  • Se puede cambiar la implementación para usar el tag canvas en lugar de raphaeljs
  • ... y si son unos "grosos" de los algoritmos, podrían simular que el "mundo" es infinito en lugar de hacer la simplificación que hice

23 febrero 2011

Reingeniería de software (2da parte)

En el post anterior presente los términos utilizados comúnmente en reingeniería de software. En este voy a centrarme en una herramienta para design recovery -desarrollada por el Software Composition Group de la universidad de Berna- llamada Moose.

Moose esta compuesto de varios frameworks, de los cuales voy a mencionar los que creo principales para la tarea de design recovery:


  • FAMIX*: es un modelo que representa entidades de código y sus relaciones (para su posterior análisis estático). * En la documentación y papers que lei sobre Moose se me hizo un tanto confusa la diferencia entre FAME y FAMIX. Por lo que pude entender FAMIX es el nombre metamodelo y FAME la implementación en Moose para el intercambio de estos meta modelos.
  • Moose Core: trabaja sobre el meta modelo de FAMIX permitiendo hacer consultas usando Smalltalk.
  • Mondrian: es un framework de visualización, que facilita la creación de vistas polimetricas (vistas polimetricas? más a continuación).


Hasta acá todo muy lindo con la lista de herramientas, pero... ¿Cómo me ayudan a entender un sistema?

Cuando vi Moose por primera vez me hice la misma pregunta. En realidad, como no estaba familiarizado con las herramientas de design recovery mi pregunta fue mucho más básica: ¿Para que sirve Moose?

Si bien hay un libro online (estilo wiki), no me quedaba claro como se usaba la herramienta hasta que leí la tesis de doctorado de Michele Lanza (es especial el capitulo 4).
Para quienes quieran buscar ejemplos de como usar las herramientas de Moose, les recomiendo leer esta tesis. Y para los que no, les cuento brevemente de que trata.

Como mencione en el post anterior el problema principal de la reingeniería no es hacer el rediseño (o refactoring), si no entender lo que hay. Y como los sistemas suelen ser enormes, es necesario generar y visualizar de alguna manera información de resumen que ayude a entender las cosas a alto nivel, o al menos guiarnos hacia los lugares de importancia.

Con este objetivo Lanza trabajo en un tipo de visualización llamado vistas polimetricas que mezcla entidades de software y sus relaciones, junto con métricas de software. (en este articulo o en el libro Object-Oriented Metrics in Practice pueden encontrar una explicación detallada de las vistas polimetricas).

La idea de mezclar métricas en la visualización, es la de poder identificar visualmente posibles candidatos a mirar. Acá van algunos ejemplos:

System Hotspots View
Da una idea general del tamaño del sistema. En este gráfico cada rectángulo representa una clase, y su tamaño esta dado por el número de métodos. (Los cuadrados grises son meta clases, esto se hizo sobre un sistema en Smalltalk, en general tener muchos métodos en la meta clase es un indicador que están faltando abstracciones adecuadas -es casi como tener muchos métodos static en Java):



System Complexity View
Muestra las relaciones de herencia entre clases y los tamaños de cada una de ellas (ancho: número de atributos, alto: número de métodos).


Inheritance Classification View
Las dos vistas anteriores fueron un ejemplo de lo que Lanza llama "first contact" views, es decir sirven para tener un pantallazo muy general, en cambio esta vista se aplica a una jerarquía de clases.
La forma de árbol muestra relaciones de herencia, el ancho de las cajas la cantidad de métodos agregados, el alto la cantidad de métodos sobre-escritos y el color (en escala de grises) la cantidad de métodos sobre-escritos que llaman a super.


Estos fueron algunos ejemplos, hay más vistas utilizadas para obtener información a distintos niveles de detalle. Cabe aclarar que la idea es que estas herramientas de visualización sean interactivas (ej. pasar el mouse sobre cada caja nos dice la clase), de nada sirve tener cajas de colores si no sabemos de que parte del sistema nos esta hablando.

¿Donde entra Moose en todo esto? Moose es un framework que permite crear estas vistas y explotar la información. "Out of the box" no es una herramienta automática para hacer una evaluación del sistema, si no más bien un framework, que se puede automatizar (usando Smalltalk) e integrar a sistemas de integración continúa (como Hudson o mejor dicho Jenkins).
Nota: Moose esta hecho en Smalltalk, pero puede analizar sistemas en Java usando un conversor de Java al formato FAMIX.
¿Existen otras herramientas de este estilo?
Mas o menos. Moose es un framework general, lo más parecido a un framework general de este tipo es una vieja herramienta llamada Rigi (que ya parece estar obsoleta - al menos no se actualiza desde hace mucho).

Si existen sistemas que proveen visualizaciones particulares, por ejemplo CodeCity es un software muy interesante que lee modelos de FAMIX y realiza vistas basadas en métricas pero utilizando una metáfora de ciudad en 3d, por ejemplo la siguiente es una representación del JDK (versión 1.5) realizada con CodeCity
Si mal no recuerdo: alto=número de métodos, ancho=número de atributos, los colores varían según lo que se quiera ver por ejemplo se pueden utilizar para resaltar "design disharmonies" -otro término para "code smells". Las "manzanas" de la ciudad representan paquetes.




08 enero 2011

Reingeniería de software (1era parte)

Diseñar un sistema desde cero es una actividad creativa y entretenida. Elegimos la tecnología más adecuada, pensamos un diseño que cumpla con las necesidades e intentamos hacer las cosas lo mejor posible.

Luego: nuevos requisitos, tiempos ajustados, parches en el diseño, nuevos desarrolladores, más funcionalidad,  interacción con otros sistemas y tecnologías; más parches, ese framework que era novedoso ya es obsoleto y problemático; cambios de presupuesto, rotación del equipo...

Y así, nuestro hermoso “bebe” devino en una bestia inmanejable.
Hombre lobo o bola de lodo, no importa, cambiamos de trabajo/proyecto y ahora empezamos de nuevo.
Ilusos -todo vuelve- ahora cae a nuestras manos una bestia.

Primer impulso: dinamitemos la cosa y empecemos de cero.
No es mala idea, hay quienes estiman que el costo de mantenimiento va del 50% al 75% del costo total de un sistema de software (Davis, Sommervielle).
El problema es que el costo de desarrollo no es la única variable a tener en cuenta: desde el punto de vista de los negocios comenzar de nuevo puede ser una maniobra de mucho riesgo.

¿Por donde empezar?
Bienvenidos a la reingeniería de software.
Para adentrarnos en este tema vamos a comenzar por ponerle nombre a algunas cosas que probablemente ya sabemos -una tarea muy aburrida pero útil a la hora de buscar y compartir información.

La mayoría de los investigadores en este área utilizan los términos descriptos en el articulo de Chikofsky y Cross y en el paper de Kazman, Woods y Carriere:


Reengineering. Reengineering, also know as both renovation and reclamation, is the examination and alteration of a subject system to reconstitute it in a new form and subsequent implementation of the new form.
Reengineering generaly includes some form of reverse engineering (to archive a more abstract description) followed by some form of forward engineering or re-structuring.

Es decir reingeniería es alterar un sistema existente y cambiarlo de forma para permitir su evolución. Esta tarea incluye entender lo que ya existe mediante ingeniería reversa:

Reverse engineering. Is the process of analyzing a subject system to
identify the system’s components and their interrelationships and
create representations of the system in another form or at higher level of abstraction.

La ingerniería reversa solo abarca la parte de examinar un sistema existente. Esta se puede dividir en dos sub-areas:

Redocumentation. Redocumentation is the creation or revision of a semantically equivalent representation within the same abstraction level.

Design Recovery. is a subset of reverse engineering in which domain knowledge, external information, and deduction or fuzzy reasoning are added to the observations of the subject system to identify meaningful higher level abstractions beyond those obtained directly by examining the system itself.

En ejemplos: Redocumentation es lo que hacen herramientas como Enterprise Architect que generan diagramas UML de clase a partir del código existente. El problema con este tipo de herramientas es que están al mismo nivel de abstracción que el código actual: recorrer un diagrama de clases de este tipo no es muy diferente a utilizar un “browser” de código en un IDE.

Sin embargo para poder comprender un sistema grande se necesita poder recrear otro tipo de información. Por ejemplo poder identificar módulos (más alla de la división de paquetes en el código)  y relacionarlos con la funcionalidad o poder entender la evolución del sistema y su relación con diferentes refactorings, funcionalidad y defectos.

Design Recovery se refiere a este segundo tipo de herramientas, y es donde se concentra el research en el área de reingeniería.

Para poder llegar a hacer herramientas de Design Recovery, primero es necesario tener herramientas que puedan analizar el código. Este tipo de herramientas se llaman herramientas de Program Analysis y las hay de tres tipos: estático (analizan la estructura del código), dinámico (analizan las interacciones en runtime) e histórico (analizan como el código fue cambiando historicamente). Hay varios desafios en el area de program analysis, para hacer un resumen de ellos:

  • análisis estático: lidiar con diferentes lenguajes de programación o scripting en un mismo sistema.
  • análisis dinámico: lidiar con enormes cantidades de información, un simple “trace” de una aplicación real puede ser enorme.
  • análisis histórico: lidiar con herramientas de SCM y el hecho que los “eventos” en el tiempo no son registrados de una forma uniforme.


Hacer Design Recovery significa buscar crear un modelo mental del sistema que permita comprenderlo, lo cual implica buscar formas de visualizar el software. Esto nos lleva a las herramientas de visualización.

Crear visualizaciones de un software no es fácil, por que hay muchas “dimensiones” e interrelaciones, asi que un área de estudio importante es como crear visualizaciones que permitan entender un sistema grande más facilmente.

En el próximo post voy a hablar un poco sobre herramientas de visualización.

Por ahora que ya saben los términos usados en este área de estudio les dejo algunos links a conferencias relacionadas con reingeniería de software:

13 abril 2010

Composición

Hace unos años atrás cuando dictaba cursos sobre POO y Java, di un ejercicio que consistía en diseñar el "checkout" de un Supermercado con soporte para ofertas del estilo "lleve 2 y pague 1".
La intención del ejercicio era que los alumnos piensen en como utilizar objetos para lidiar con la complejidad de aplicar las diferentes ofertas.
Esperaba también que se dieran cuenta que el precio de un producto depende de factores como: la fecha, el "mercado", ofertas u estrategias de negocio.
Imagine que comenzarían planteando la clase Producto con un "estilo base de datos":


Y que a lo largo del ejercicio iban a llegar a una especie de "lista de precios":


Sin embargo no fue como esperaba. Para uno de los alumnos la expresión "el producto tiene un precio" significaba que el un producto tenia que responder directamente su precio. Trate de justificar mi posición desde distintos puntos de vista, pero luego abandone la discusión.

Nota al margen:
Mi experiencia dando clases me enseño que es mejor no entrar en discusiones sin salida. Es preferible escuchar pacientemente y expresar cuales son los problemas del planteo desde distintos puntos de vista, con suerte la discusión evoluciona y se aprende mucho en el proceso. Pero si la discusión se estanca, es preferible abandonarla.

Escribo sobre este ejemplo después de tanto tiempo, por que veo que el problema de como expresar la relación de composición se repite en otros contextos.
Por ejemplo ¿Cómo modelarían que "un cliente que tiene contactos"? Una forma de hacerlo es:



Sin embargo, al hacer la traducción literal de este diagrama de clases, el modelo esta diciendo: "el cliente esta compuesto de contactos". La diferencia parece sutil, pero a medida que el sistema crece tiene consecuencias importantes:
  • Agregar nuevos contactos implica modificar la instancia de cliente y trae consecuencias en la forma de asegurar que se cumplan las reglas de negocio, por ejemplo: que un cliente cambie su numero CUIT es raro y puede afectar a los sistemas de facturación; pero es esperable que sus contactos cambien seguido sin mayores consecuencias para el resto del sistema, entonces hay que distinguir entre "modificaciones pesadas" y "modificaciones livianas".
  • Transmitir el cliente por "el cable" implica transmitir sus contactos, y como esta información puede ser pesada uno tiene que usar alguna estrategia como un objeto que represente una vista del cliente sin sus contactos (DTO), o bien hacer que la lista de contactos se cargue por demanda (lazy load).

Nota al margen: Creo que uno de los causantes de confusión es el uso del verbo tener. En nuestra manera de expresarnos es natural decir "el producto tiene un precio", sin embargo el "tiene" de nuestro lenguaje no expresa necesariamente una relación de composición. Erich Fromm, escribió un libro muy bueno llamado "Tener o Ser" que habla sobre la influencia del "tener" en la sociedad. Es curioso como el consumismo cambio nuestras formas de expresión, por ejemplo en lugar de decir "siento un dolor de cabeza" decimos "tengo un dolor de cabeza".

En cierto sentido el problema es similar al de Producto-Precio, una alternativa para expresar la relación del cliente con sus contactos es tener una "agenda de contactos" mediante la cual se pueden encontrar los contactos para un cliente:



¿Qué modelo expresa mejor la realidad?

Creo que pensar que un modelo es correcto por que expresa "la realidad" mejor que otro, sufre del problema de dar por sentado que nuestra percepción y traducción al sistema es absolutamente correcta: para uno de mis alumnos la realidad que "el producto tiene un precio", se traducía correctamente en "unProducto.getPrecio()".

Por eso es necesario tener en cuenta cuestiones que afectan a la percepción y traducción del problema:
  • El verbo "tener" tiene un uso extremadamente ambiguo en nuestra cultura, por ejemplo es más adecuado decir que a un producto se le asigna un precio y quizás de esa forma ya vemos el dominio con otra perspectiva.
  • Los "ritmos" de cambio pueden hacer que sea más conveniente pensar en objetos distintos. Por ejemplo el precio varia con el tiempo y el entorno, pero el producto no; algo similar ocurre con el cliente y sus contactos.
    Separar las cosas que cambian a distintas velocidades ayuda a evitar side effects innecesarios y simplifica la aplicación de reglas de negocio.
  • Ver si existe en el dominio un intermediario en la relación, como por ejemplo una "lista de precios". Es claro que el software es un medio distinto: la lista de precio existe físicamente por que es una herramienta para definir y recordar el precio de un producto, y ese rol puede cumplirse en software sin necesidad de tener una "lista de precios" propiamente dicha. Sin embargo explorar esas analogías puede servir para ver conceptos del dominio.

10 diciembre 2009

Test driven development... algunas lecciones aprendidas

En este post quería compartirles algunas cuestiones sobre Test Driven Development (TDD) que fui aprendiendo de los proyectos y personas con las que trabaje.

TDD != Testing en busca de bugs

Es muy común confundir TDD con testing: al menos comparten la palabra "test" en el nombre.

La diferencia esta en que uno generalmente usa la palabra "testing" para referirse a la búsqueda de bugs. Mientras que en TDD la intención es otra: uno busca agilizar el diseño, facilitando la incorporación de cambios.

A grandes rasgos TDD consiste en expresar con un programa las "expectativas" sobre lo que se va a desarrollar.


Supongamos, por ejemplo, que estamos aprendiendo C con libro de Kernighan y Ritchie y su ya famoso "Hello World", así que escribimos:

#include <stdio.h>
int main() {
    printf("Hello World\n")
    return 0;
}

Compilamos...

$ cc hello.c -o hello
hello.c: In function ‘main’:
hello.c:5: error: expected ‘;’ before ‘}’ token

Arreglamos código, compilamos y ejecutamos, hasta que vemos en la pantalla el resultado esperado:

Hello World

La diferencia usando TDD es que primero escribimos un programa para verificar el resultado esperado (algo que comúnmente hacemos de forma manual):

ejecutarPrograma();
verificar(salidaDePrograma, "Hello World");

Nuestro "test", no intenta buscar bugs probando diferentes casos, o casos "borde" que puedan ser problemáticos.
Si no que simplemente es una forma de establecer que es lo que queremos hacer.

Esta diferencia es sutil pero tiene muchas implicancias en la forma de trabajar:
  • No es necesario pensar casos de test complejos, solo basta con pensar que es lo que se quiere hacer.
  • Con TDD el ciclo de escribir código - compilar, se transforma en: escribir tests/código - compilar/ejecutar tests.
    Es decir, crear/ejecutar tests debe ser parte común del desarrollo. Si los tests tardan en ejecutarse se transforman en una carga.

En mi caso los "tests" usados de esta forma se convierten en una especie de "TODO list". En efecto muchas veces siento que si no tengo planteado un "test" (aunque sea en mi cabeza) no sé por donde empezar.

¿Vale la pena dedicar tiempo a escribir tests?


El ejemplo de "Hello World" muestra una cuestión evidente: programar el test puede ser mucho más difícil que hacer el programa a testear.
Cabe preguntarse si es siempre así, y si el trabajo adicional vale la pena.

En general no siempre es tan difícil escribir tests, y hay factores que influyen en la dificultad:
  • Componentes y frameworks "acoplados" a sistemas externos.
    Por ejemplo, si quisieran escribir el test del "Hello World" en C deberían capturar el STDOUT del programa. Suele ser fácil reemplazar la salida standard, aunque si el stream fuese parametrizable sería mucho más fácil.
  • Frameworks que no proveen interfaces separadas de a implementación.
    Siguiendo con el "Hello World", supongamos que podemos parametrizar el stream de salida, sería ideal poder capturar la salida en un String.
    Si el "Stream" que nos brinda el lenguaje no permite hacerlo, podemos hacer otra implementación que cumpla la misma interfaz y capture el String. Pero si "Stream" es una clase cerrada, estamos en problemas.
  • Diseños que no cumplen con el principio de Single Responsibility.
    Si la "responsabilidad" de lo que queremos testear es acotada, entonces el test también va a ser más corto y fácil de escribir.
  • Dependencias globales mutables (como Singletons que no son "singletons").
    En un post anterior ya hable sobre los problemas del estado global... así que voy a evitar la tentación de volver a comentarlos :)
  • Tests con grandes conjuntos de datos.
    Complican la mantención del test y por lo general también hacen que la ejecución sea más lenta.
  • El lenguaje/tecnología a usar influyen en la forma de trabajo.
    Por ejemplo en Smalltalk es muy común desarrollar mientras se ejecuta el test (completando lo que falta implementar en el debugger), ya que entorno permite modificar clases sin "parar el mundo". En Java este tipo de prácticas no es posible.
    En otro extremo el lenguaje C++ no cuenta con reflection out-of-the-box, y por lo tanto escribir tests es mucho más trabajoso (hay que indicarle al framework explicitamente que tests ejecutar).
    Es conveniente tener en cuenta la tecnología que se usa, por ejemplo en C++ intentería tener tests más grandes para evitar el "overhead" que implica crear tests nuevos. En Smalltalk, Java u otros lenguajes el entorno ayuda a que este problema no exista.

Desde un punto de vista de management, quizás la cuestión esta en cual es el "retorno de inversión" de lidiar con todo esto. Para mi el valor esta en que:

  • Al tener tests automatizados uno tiene más confianza a la hora de hacer refactorings o cambios en el diseño.
    Esto puedo afirmarlo con mi experiencia: trabaje en un sistema financiero donde teníamos una gran cantidad de tests (si no recuerdo mal, más de 10.000).
    Tuve que hacer un rediseño importante de una parte "core" del sistema, hice los cambios y emepecé a ver los tests que fallaban.
    Los tests que entendí, los arregle. Los que no, los consulte con sus autores y los arreglamos en conjunto. Todo este trabajo hubiese llevado muchísimo más tiempo sin tests automatizados.
  • Ayuda a tener un diseño más abierto a los cambios
    La razón es simple: cuanto más acoplado esta un objeto al uso de sistemas o frameworks externos, más difícil es testearlo. Por lo tanto con TDD uno tiende a diseñar de forma tal de disminuir este acoplamiento.
    Lo mismo ocurre cuando los objetos tienen dependencias globales mutables: se vuelven dificiles de testear por que un test cambia el estado global, haciendo que otro test falle. Por lo tanto uno tiende a evitar este tipo de globales.
    A la larga las consecuencias de usar TDD son beneficiosas para el diseño.

No todo es color verde...

Como mencione antes: la intención de TDD es facilitar cambios y ayudar en el diseño. No encontrar bugs.

Por lo tanto aunque la aplicación pase los tests de unidad, puede contener errores funcionales (casos de test mal planteados), bugs por casos "especiales" y problemas de UI.

Volviendo al ejemplo de este sistema en el que trabaje con más de 10.000 tests de unidad:
Al principio no teniamos un equipo de QA, los tests automáticos pasaban y algunos de nosotros probamos la aplicación durante el desarrollo.

Llego el día "D": empaquetamos todo para producción y llevamos el software al cliente.

A las horas recibimos un llamado: un botón de la UI permanecía deshabilitado y el cliente no podía acceder a una funcionalidad del programa (funcionalidad que estaba desarrollada, pero inaccesible). Asi que tuvimos que corregir el problema y hacer un nuevo deploy.

Los tests de unidad no reemplazan a un equipo de QA. No es la intención de TDD reemplazar las prácticas comunes de testing y verificación de calidad.

La diferencia esta en los detalles

Supongamos los convencí de las "bondades" de TDD. Asi que empiezan a practicarlo en un proyecto.

Al tiempo hay una alta probabilidad de que empiece a pasar lo siguiente. Algunos tests fallan pero arreglarlos es un problema: los tests se volvieron enormes e inmantenibles. Solución rápida: los tests que fallan se ignoran. Un par de meses despúes los tests que fallan se siguen ignorando, y se pierde la ventaja de TDD: si necesitamos hacer un cambio de diseño no tenemos forma de saber que rompimos.

¿Por que se llega a este punto?
Principalmente es una cuestión de la costumbre del equipo en hacer y mantener los tests.
Pero además hay un montón de detalles que con el tiempo se acumulan cual bola de nieve e influyen en que los tests se vuelvan inmantenibles. Algunos "detalles" a tener en cuenta:

No depender de bases de datos externas

Hacer que un test de unidad requiera de una base de datos es problemático por que:
  • El entorno de desarrollo es dificil de configurar: cada desarrollador tiene que configurar los drivers y conexiones para poder empezar a trabajar con los tests.
  • Si se usa una base compartida: un cambio hace que los tests fallen para el resto de los desarrolladores. Para hacer TDD los tests se deben correr todo el tiempo, asi que tener tests que fallen por cambios de otros es inadminsible.
  • Es más trabajoso para usar un servidor de integración continua: hay que asegurarse de que el servidor de integración use correctamente la base de datos de prueba.
  • La ejecución de los tests suele ser más lenta, asi que correr todos los tests no es algo que se haga muy seguido.

En el caso de que un test necesite una base de datos es necesario distinguir:
  • Que es lo que se esta testeando: ¿Quiero realmente testear el acceso a la base de datos?
  • ¿Puedo reemplazar el acceso a la base de datos, por una implementación que "simule" la respuesta y no acceda a una base de datos real?

Muchas veces uno quiere testear la implementación a la base de datos, por ejemplo se usa Hibernate para responder algunas consultas y cambiar la implementación por una que simule la base no aporta nada.
En esos casos lo mejor es usar una base en memoria (como HSQLDB) y levantarla durante el test.

HSQLDB es liviano, soporta Hibernate y toda la sintaxis de SQL. El unico punto en contra es que si uno quiere "ver" como se guardan las cosas en la base hay que frenar el test para no bajar el servidor.

El siguiente es un pequeño ejemplo de como usar HSQLDB en un test:

public class TestDatabase {
    public static final String DEFAULT_DATABASE_NAME = "test";
    public static final int DEFAULT_SERVER_PORT = 9001;

    private Server hsqldbServer;
    private HsqlProperties hsqldbProperties;
    private String jdbcConnectionUrl;
    
    public TestDatabase(String databaseName, int serverPort) {
        hsqldbProperties = new HsqlProperties();
        jdbcConnectionUrl = "jdbc:hsqldb:hsql://localhost:" + serverPort + "/" + databaseName;
        hsqldbProperties.setProperty("server.port", serverPort);
        hsqldbProperties.setProperty("server.database.0", databaseName);
        hsqldbProperties.setProperty("server.dbname.0", databaseName);
    }

    public static TestDatabase startWithDefaultConfiguration() {
        return new TestDatabase(DEFAULT_DATABASE_NAME, DEFAULT_SERVER_PORT).start();
    }

    private TestDatabase start() {
        hsqldbServer = new Server();
        hsqldbServer.setProperties(hsqldbProperties);
        hsqldbServer.start();
        return this;
    }

    public void shutdown() {
        if (hsqldbServer == null) return;
        hsqldbServer.shutdown();
    }
}

public class AccesoALaBaseTest {
    private static TestDatabase database;

    @BeforeClass
    public static void startTestDatabase() {
        database = TestDatabase.startWithDefaultConfiguration();
    }
    
    @AfterClass
    public static void shutdownHsqldbServer() {
        database.shutdown();
    }
}

No usar herencia para compartir instancias de prueba

Usar herencia para compartir instancias entre tests es un error muy común.

Supongamos que estamos testeando la implementación de CajaDeAhorro. Para crear una instancia de la caja de ahorros, necesitamos una instancia de Cliente, que a su vez necesita una instancia de Contrato, que a su vez necesita una instancia de... bueno se hacen a la idea: crear todas estas instancias es trabajoso, nos tomamos el trabajo :(

Despúes queremos testear CuentaCorriente, y queremos reusar las instancias que creamos para el test de CajaDeAhorro.
Muchas veces se suele crear una superclase para el test, supongamos AbstractTest, donde se colocan estas instancias que necesitamos compartir.
Los desarrolladores empiezan a heredar todos los tests de AbstractTest, y van "subiendo" instancias que quieren compartir entre tests.

¿Se ve el problema?
AbstractTest termina siendo una gran bolsa de gatos. Cuando se quiere refactorizar AbstractTest hay otro problema: es engorroso buscar las referencias a cada variable usada en las subclases. Por lo general este refactoring implica mucho trabajo y nadie lo hace. AbstractTest sobrevive a varias versiones transformandose en una inmensa bola de lodo.

La solución:
No usar herencia para compartir instancias entre los tests (usar herencia para re usar código es mala idea, en este caso es malisima).
Es preferible crear una clase, por ejemplo ClienteTestResource (suelo usar el sufijo "TestResource" para esas clases) que brinda instancias de Cliente para los tests.

Nada evita que ClienteTestResource no se convierta tambien en una bolsa de gatos, pero la cuestión es más controlada: uno puede crear clases distintas para agrupar recursos necesarios en los tests. Y a diferencia de AbstractTest, los desarrolladores solo usan ClienteTestResource cuando lo necesitan.

Evitar código redundante

Otro problema común es la actitud de: "hago copy & paste, total es un test!".

Esto es perjudicial: los tests tambien hay que mantenerlos. Si hay que hacer un "copy & paste" para crear instancias que se necesitan en el test... usar un "TestResource".

Si hay que implementar un método en común para facilitar el testing: ¿Entonces por que no implementarlo en el modelo? Por ejemplo, si estoy testeando el balance de una cuenta y el codigo del test es algo asi:

cuenta.balanceAl(crearFecha("01/01/2009"));
...

private function Date crearFecha(String s) {
      SimpleDateFormat sdf .....
      return sdf.parse(s);
}

Y empiezo a copiar "crearFecha" en varios tests, la alternativa es dar una interfaz amigable en Cuenta (la otra alternativa es tener una interfaz más amigable para construir instancias de Date en general, un buen ejemplo de esto en Smalltalk es el framework Chaltén, que pemite expresar fechas como: "Jaunary first, 2009" donde Jaunary es un objeto first y , son mensajes... asi que la expresión es basicamente da una fecha) que permita escribir:

cuenta.balanceAl("01/01/2009");

Eso no quita que el metodo balanceAl(Date) no tenga que existir. El formateo de fechas depende del Locale y no debería usarse con un Locale implicito en la aplicación. Sin embargo es conveniente pensar en la facilidad de uso de las interfaces que uno provee: las interfaces de los objetos son como las interfaces graficas.

Tener interfaces que permitan expresar las cosas de forma natural, usando defaults apropiados y simplificando ciertas cuestiones, ayuda muchisimo a la hora de escribir tests.

La clave es pensar que cuando uno programa esta construyendo un lenguaje, y esto abarca tambien a los tests. No es lo mismo escribir:

assertThat(coleccion, hasItem("hola"));

Que escribir:

boolean found = false;
for (String s : coleccion) {
    if (s.equals("hola")) found = true;
}
assertTrue(found);

(Nota: sé que existe coleccion.contains("hola") pero quería explicitar la fealdad de esta alternativa ;) )

Un test por "observación"

Supongamos que para una CajaDeAhorro queremos testear que:
  • Los depositos incrementan el balance.
  • Las extracciones decrementan el balance.
  • No se puede hacer una extracción si no hay fondos.

Uno puede estar tentado a testear todo esto junto:

@Test public void depositoYExtraccion() {
assertThat(cuenta.balance(), is(monto));
cuenta.depositar(monto);
assertThat(cuenta.balance(), is(monto * 2));
cuenta.extraer(cuenta.balance());
assertThat(cuenta.balance(), is(0));
try {
    cuenta.extraer(monto);
    fail();
} catch (NoHayFondosException e) {}
}

Pero tiene algunos problemas:
  • Si el test falla es necesario debuggear para saber si el problema esta en depositar, extraer o balance.
  • El ejemplo es chico, pero en tests más grandes los errores tienden ser más dificiles de ver.

En comparación esta solución es mejor:

@Test public void losDepositosIncrementanElBalance() {
    assertThat(cuenta.balance(), is(0));
    cuenta.depositar(monto);
    assertThat(cuenta.balance(), is(monto));
}
@Test public void lasExtraccionesDecrementanElBalance() {
    assertThat(cuenta.balance(), is(not(0)));
    cuenta.extraer(cuenta.balance());
    assertThat(cuenta.balance(), is(0));
}
@Test(expected=NoHayFondosException.class)
public void noSePuedeHacerUnaExtraccionSiNoHayFondos() {
    assertThat(cuenta.balance(), is(not(0)));
    cuenta.extraer(cuenta.balance() * 2);
}

Parece más largo pero tiene varias ventajas:
  • Es más facil ver que esta fallando.
  • Si un test falla es más facil de corregir.
  • Tiene (para mi) una implicación psicologica: ir haciendo pequeños tests que van pasando, en general me ayuda a tener un mejor ritmo de trabajo. Incluso entre pequeños test a veces se me ocurren rediseños que no habia pensado originalmente.

Evitar tests no deterministicos

Cuando el resultado esperado depende de un valor del "entorno", por ejemplo: la hora actual o un número random. Es común terminar con tests no deterministicos: a veces pasan y otras veces no.

Por ejemplo supongamos que estamos testeando una aplicación que genera registros de auditoria con la hora actual:

registroEsperado = new RegistroAuditoria(nuevoTimestamp, etc, etc, etc);
sistema.ejecutarMetodoQueGeneraAuditoria();
assertThat(sistema.logDeAuditoria(), hasItem(registroEsperado));

El problema es que si la comparación de RegistroAuditoria involucra comparar el "time stamp", entonces el test pasa o falla según la resolución de la hora y si hubo pausas en el medio de la ejecución (por ejemplo se ejecutó el GC).

Una forma de evitar este tipo de cosas es "fijar" estos valores. Por ejemplo podemos tener una interfaz Clock que provee la hora:

Clock fixedClock = new FixedClock(nuevoTimestamp);
sistema = new Sistema(fixedClock);
registroEsperado = new RegistroAuditoria(nuevoTimestamp, etc, etc, etc);
sistema.ejecutarMetodoQueGeneraAuditoria();
assertThat(sistema.logDeAuditoria(), hasItem(registroEsperado));

Ya no hay más problemas de indeterminismo :)

Evitar delays

Muchas veces por razones de concurrencia, se termina agregando al test un "delay" (por ejemplo se ejecuta un thread y quiero esperar a que el thread actualize un valor que despues voy a verificar).

Este tipo de test tiene dos problemas:
  • El delay hace que los tests corran más lento.
  • Por lo general resultan en tests no-deterministicos por que dependen de como la plataforma maneja la ejecución de los threads.

La alternativa es investigar un poco más lo que se esta testeando. Si es necesario tener el thread separado en el test conviene usar un semanforo (o monitor en Java) para hacer un wait hasta que se modifique el valor.

Lamentablemente no tengo ningun ejemplo a mano para mostrar en el blog.
Lo he hecho tanto en Smalltalk y en Java, y quizas lo unico que puedo agregar es que las cosas concurrentes son extremandamente dificiles de testear.
Algunas veces una solución intermedia es usar "yield" para que la VM ejecute otros procesos, esto salva el delay pero puede generar tambien tests no-deterministicos... la mejor opción sigue siendo usar algun tipo de semaforo.

De todas formas vale la pena hacer la "investigación" para evitar el delay: uno termina aprendiendo muchas cosas de concurrencia :), y tener un delay de un 1seg en varios tests es muy molesto.

Usar mock objects con cuidado

Un mock object es simplemente una implementación "de mentira" que se usa para reemplazar en un test a una implementación real (esta es al menos mi definición de mock object).

En el ejemplo anterior FixedClock es una implementación de mentira de Clock, que nos facilita el testing.

Existen frameworks de mock objects que por lo general:
  • Facilitan la implementación de mock objects usando meta-programación.
  • Permiten hacer tests de caja blanca, pudiendo validar si se ejecuto o no un método del mock object.

En mi experiecia estos frameworks terminan derivando en tests dificiles de mantener:
  • Los tests de "caja blanca" suelen ser una mala idea, al primer refactoring fallan y se convierten en un "dolor" de mantener. En esos casos es mejor re-plantearse el caso de test (y recordar cual es la intención de los tests en TDD).
  • En general es mucho más facil tener una interfaz y hacer la implementación de mentira para el test que usar un framework de mock objects.
  • Cuando no se generan dependencias adicionales (explicación a continuación) y no hay problemas como en el ejemplo de FixedClock, es más facil usar objetos reales que mock objects (usar un "TestResource" puede ayudar a escribir menos codigo en estos casos).
  • En lenguajes dinamicos como Smalltalk es muchisimo mas facil crear mock objects, pero tambien tienen problemas: si se usan muchos mock objects y se hace un refactoring, es probable que los test compilen y pasen... aunque deberían haber fallado.

Un pequeño hint: usar mock objects no es necesariamente malo, pero necesitar de mucha logica en un mock object es signo de que algo esta mal. A veces no se necesita un mock object si no una implementación más sencilla de la "interfaz" que queremos simular.

Ser cuidadoso con las dependencias de los tests

Ya estoy llegando al final de este enorme post.

El tema de las dependencias de tests requiere un poco más de explicación, asi que voy a ser breve:

Muchas veces uno expresa los tests a más alto nivel por ejemplo:

"El usuario agrega items al carrito de compras. Procede al checkout, donde obtiene una factura de los items que compró"

Este test abarca toda una historia de usuario y a veces es bueno automatizarlo.

Sin embargo hay que tener en cuenta que este tipo de test no solo es más largo, si no que posiblemente dependa de otras implementaciones: por ejemplo el carrito de compras se implementa en un proyecto, la generación de factura en otro y quien usa ambas implementaciones (llamemosle Cajero) usa interfaces asi que el proyecto donde esta el Cajero no depende de una implementacion particular de carrito de compra o el generador de factura.

¿Donde colocamos el test?
Si lo hacemos en el proyecto donde esta Cajero, y no usamos mock objects, generamos dependencias adicionales que no son necesarias (o quizas dependencias circulares que son problematicas). Y si usamos mock objects para todo, el test se vuelve más dificil de mantener.

Lo mejor en estos casos es ver el test a otro nivel. Si quieren pueden llamarle "test de integración" (siguiendo una convención que usabamos en Mercap, prefiero llamarles "user story test" por que abarcan una historia de usuario, y reservar el nombre "test de integración" para tests que verifican la integración con sistemas externos).

Es conveniente que este tipo de test este en un proyecto separado. De esta forma se evitan dependencias circulares, y es más facil compartir información entre dintintos "user story tests" (usando de "TestResources").

Además este tipo de test suele ser más lento, y por lo general no se ejecuta con la misma frecuencia que los tests de unidad.

El problema de este tipo de test es que son más grandes, tienen más dependencias y por lo tanto más dificiles de mantener.

Pero cuando las fechas de entrega apremian, a veces no hay tiempo para estar haciendo muchos tests a nivel unitario. En esos casos con tests de este estilo se puede testear la implementación manteniendo un estilo TDD (uno escribe el "user story test" antes de empezar), sin necesidad de crear un test por cada una de las clases que se usan "internamente". En este caso las herramientas de covertura pueden ayudar a ver que se cubre con el "user story test" y que no.

Si bien esta es una alternativa que complementa a los tests de unidad, no los reemplaza: los tests de unidad son una buena forma de trabajar a "nivel micro", pueden ejecutarse todo el tiempo durante el desarrollo y son más faciles de mantener.

Espero no haberlos dormido con este post enorme, trate de resumir un poco las "lecciones aprendidas" con TDD.
Hasta la proxima :)

12 agosto 2009

Side effects

El post anterior estuvo dedicado a la representación de "estados" en el dominio, donde mencione al pasar que el código del estilo: "objeto.getEstado()/objeto.setEstado(X)" tiene además otras complejidades, producto de algo que los programadores de lenguajes funcionales odian bastante: side effects.

Para los que se desayunan con el término voy a contarles de que se trata. Supongamos el siguiente programa:

a := 10
b := a + 10
if (b = a + 10) then retornar "OK" else retornar "FALLA"

Sabemos de ante mano que el programa retorna "OK", asi que podemos simplificar el código y ahorrarnos muchas vueltas (incluso el compilador podría realizar estas simplificaciones por nosotros). Ahora añadimos un procedimiento "inofensivo":

a := 10
b := a + 10
procedimientoRaro()
if (b = a + 10) then retornar "OK" else retornar "FALLA"

¿Seguimos sabiendo que el programa retorna siempre "OK"?
Antes de decir si, voy a poner una condición tramposa:
Dado que este es un programa en pseudo-código y no sabemos como se evalua nuestro supuesto lenguaje, supongamos que la variable "a" se puede acceder y modificar desde "procedimientoRaro()".

Ahora si volvamos a la pregunta: ¿El programa retorna siempre "OK"?
No lo sabemos :-(.
Tendríamos que examinar el "procedimientoRaro()" (si es que tenemos el código) un lindo procedimiento con más de 200 lineas de código y millones de ifs/fors anidados.

Sobre la "modularidad" del ejemplo podríamos decir que la variable "a" es una global y "procedimientoRaro" no debería usar globales, etc, etc. Asi que vamos a cambiarlo:

a := 10
b := a + 10
procedimientoRaro(a)
if (b = a + 10) then retornar "OK" else retornar "FALLA"

¿Seguimos sabiendo que el programa retorna siempre "OK"?
Quizás no, el problema es si "procedimientoRaro" puede modificar o no el valor de "a", y si nuestro lenguaje copia el argumento o lo pasa por referencia.

Podemos seguir con innumerables ejemplos, la cuestión es que en nuestro lenguaje imperativo "procedimientoRaro" puede tener side effects: afecta estados compartidos con el resto del programa.
La raíz de los problemas reside en la asignación. Al incorporar la capacidad de asignar valores a la variable "a", incorporamos implícitamente la noción de estado de ejecución.
Es decir la verificación "b = a + 10" ya no depende de la solo de la expresión, si no que depende además del estado. (hay una explicación muy buena sobre asignación y estado en el capitulo 3 del libro Structure and Interpretation of Programs).

“Todo esto parece muy teórico. En el trabajo diario ¿De qué me sirve?”

Supongamos que estamos trabajando en un sistema que se encarga de registrar la reserva de salas para una empresa de capacitación:
  • CalendarioDeSalas: Lleva un control de la reserva de salas. Una sala no puede reservarse en un intervalo de tiempo que ya este en uso.
  • IntervaloDeTiempo: Representa un rango de fecha-hora.



Con estas clases podríamos escribir un código así:

// reserva la sala del día 16 al 17
// el calendario verifica la disponibilidad
intervaloDeTiempo = new IntervaloDeTiempo();
intervaloDeTiempo.setDesde("16/07/2009");
intervaloDeTiempo.setHasta("17/07/2009");
calendario.reservar(unaSala, intervaloDeTiempo);

// reserva la sala del día 20 al 21
otroIntervaloDeTiempo = new IntervaloDeTiempo();
otroIntervaloDeTiempo.setDesde("20/07/2009");
otroIntervaloDeTiempo.setHasta("21/07/2009");
calendario.reservar(unaSala, otroIntervaloDeTiempo);

// cambia la fecha de la primer reserva..
// OOPS! el calendario ni se entera!
// este cambio rompe con la especificación del calendario
intervaloDeTiempo.setDesde("20/07/2009");
intervaloDeTiempo.setHasta("21/07/2009");

“Pero si esto es un problema: ¿Por qué no me encuentro con estos errores en mi mega sistema J2EE/Spring/Hibernate/etc/etc…?”

El problema del ejemplo puede aliviarse superficialmente si el calendario se encarga de copiar el intervalo antes de registrarlo.
Esto es básicamente lo que sucede con todos los mapeos Objeto-Relacional: lo valores se copian a la base de datos y luego las instancias son regeneradas.
Pero el problema aunque oculto sigue estando. Basta con agregar un “cache”, o usar la instancia de IntervaloDeTiempo de forma compartida sin darse cuenta (por ejemplo en un widget de UI), para que empiecen a ocurrir bugs inesperados.

También surgen un montón de complicaciones innecesarias en el código debido a las “validaciones”.

Supongamos que quiero que en un IntervaloDeTiempo la fecha inicial sea siempre menor a la final. Empiezo a agregar una guarda en “setDesde” otra en “setHasta”, y genero una excepción.

Pero ahora tengo que tener en cuenta en qué orden establezco los valores.
Bueno podemos hacer que cuando uno de los valores es null no se haga el chequeo… ¡HORRIBLE! Complicaciones y más complicaciones.

Simplifiquemos un poco, hacemos un método setIntervalo(desde, hasta), nos ahorramos problemas de validaciones. Pero seguimos teniendo problemas de otro tipo: para que el calendario funcione correctamente hay que asegurarse que el intervalo de la reserva no se cambie sin conocimiento el calendario.

Como mencione antes podemos aliviar el problema haciendo que calendario se encargue de copiar la instancia de intervalo que recibe. Pero esta es solo una solución superficial, supongamos que tras varias iteraciones decidimos hacer un refactoring, en lugar de:

calendario.reservar(unaSala, intervalo);

Ahora tenemos un objeto que representa la reserva:



unaReserva = new Reserva();
unaReserva.setSala(unaSala);
unaReserva.setIntervalo(intervalo);
calendario.registrar(unaReserva);

¿Cómo nos aseguramos que nadie nos cambie el intervalo de la reserva? Tenemos que:
  • Copiar la reserva al registrar en el calendario.
  • Copiar el intervalo a establecerlo en la reserva.
  • Asegurarnos de hacer una nueva copia del intervalo al retornar getIntervalo() en Reserva.
  • Asegurarnos de hacer una copia de la reserva al retornarla en el calendario, o decorarla de alguna manera para que nadie cambie la reserva sin que se entere el calendario.
  • Inicializar correctamente la sala y el intervalo, y chequear en “registrar” que tengan valores validos.
  • ¡HORRIBLE! Seguimos teniendo mucha complejidad.

“¿Cómo puedo aliviar/evitar estos problemas?”

Evitando side effects, es decir eliminar la asignación (setters) usando objetos inmutables.
En el ejemplo es preferible que el valor de las variables de instancia de IntervaloDeTiempo no se puedan cambiar. Es decir que no exista un “setDesde” y “setHasta”.

Para eso solo hay que definir un constructor adecuado:

new IntervaloDeTiempo(desde, hasta);

Lo mismo sucede con Reserva:

new Reserva(unaSala, intervalo);

Remando contra la corriente

"¿Hasta que punto se puede diseñar pensando en objetos inmutables?"

Es evidente que hay casos donde conviene que los objetos sean mutables, por ejemplo en entornos donde la configuración puede cambiar dinamicamte (sin ir más lejos un ejemplo de este tipo de cambios dinamicos es el entorno de Smalltalk).
En otros casos la necesidad de objetos mutables tiene que ver con cuestiones de performance e interacción con otros frameworks (donde por framework incluyo también a las librerías standard del lenguaje, por ejemplo las colecciones).

Este ultimo punto es para mi el más molesto para los programadores Java: la gran mayoría de los frameworks Java adoptaron la convención de JavaBeans (con la que ya me ensañe bastante). El problema es que muchas veces adoptan esa convención de manera totalmente innecesaria, fomentando malas prácticas.

Por ejemplo, Spring puede hacer inyección de dependencias usando constructores. Sin embargo el manual de referencia menciona que la forma preferida es usar "setters", eso significa que la mayoría de los tutoriales usan inyección por setters y por lo tanto la mayoría de los "usuarios" del framework también.

Pero si uno le presta más atención al por qué, el manual dice: "Setter methods also make objects of that class amenable to reconfiguration or re-injection later", lo que me genera la duda ¿Cuantas veces uno diseña una aplicación con este grado de re-configuración en runtime? Mi punto es: existen tecnologías como JMX que facilitan los cambios en runtime, sin embargo no es el requerimiento común, y si fuese un requerimiento de la aplicación uno tendría que diseñar pensando en la complejidad que pueden generar estos cambios dinámicos.

La otra justificación para usar setters en Spring es cuando existen dependencias circulares, sin embargo este tipo de dependencias a veces pueden evitarse incorporando en el diseño un tercer objeto que haga de mediador.

Otros frameworks son peores: al no permitir otro uso que no sea mediante la convención de JavaBeans fuerzan este tipo de prácticas.

Aún así hay algunas prácticas de diseño que ayudan evitar side effects:
  • Mientras sea posible usar objetos inmutables.
  • Hacer las validaciones en el constructor, de esta manera si uno tiene una instancia ya se sabe que la misma fue construida correctamente.
  • Si el constructor queda enorme (cosa que es muy molesta para manejar), pensar de "dividir" el objeto en conceptos más pequeños. Por ejemplo en el ejemplo anterior la clase Reserva podría recibir el valor desde y hasta del intervalo: Reserva(desde,hasta,sala). Sin embargo separando el concepto de intervalo es mucho más simple (incluso para las validaciones): Reserva(intervalo,sala).
  • Si la construcción es complicada utilizar un builder. Lo bueno es que si la interfaz del builder se diseña con cuidado se pueden hacer DSL internos, logrando un código muy comunicativo.
  • Si en el dominio hay distintos "estados", pensar como se representan esos estados y utilizar algunas de las técnicas que mencione en el post previo. Por ejemplo, si en la UI tenemos que modificar una Reserva, podríamos tener un objeto ReservaBorrador que sea mutable y que actúe como builder de una Reserva inmutable.
  • No implementar equals en objetos mutables: es una muy mala práctica. Si no se dan cuenta por que: creen un objeto mutable, calculen equals y hashCode en base a variables de instancia que pueden cambiar, agreguen el objeto a un Set, modifiquen la instancia y vuelvan a probar si la misma esta en el Set.
  • Tener cuidado al retornar colecciones en un setter. Para que la colección no sea modificada pueden hacer una copia, o en Java decorarla para evitar modificaciones.

Estas cuestiones de side effects suelen ser importantes, no solo para el diseño, si no también para la escalabilidad de un sistema. Por eso cada vez más se empiezan a incorporar a los lenguajes orientados a objetos conceptos que vienen de lenguajes funcionales, el lenguaje Scala es un buen ejemplo que les recomiendo ver.