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.

1 comentario:

  1. Structure and Interpretation of Computer Programs.
    http://www.youtube.com/watch?v=2Op3QLzMgSY

    ResponderEliminar