SOLID y Visitor: Un ejemplo explicado (1era Parte)

  • strict warning: Non-static method view::load() should not be called statically in /home/codecomp/public_html/codecompiling.net/mai.www/sites/all/modules/views/views.module on line 906.
  • strict warning: Declaration of views_handler_filter::options_validate() should be compatible with views_handler::options_validate($form, &$form_state) in /home/codecomp/public_html/codecompiling.net/mai.www/sites/all/modules/views/handlers/views_handler_filter.inc on line 607.
  • strict warning: Declaration of views_handler_filter::options_submit() should be compatible with views_handler::options_submit($form, &$form_state) in /home/codecomp/public_html/codecompiling.net/mai.www/sites/all/modules/views/handlers/views_handler_filter.inc on line 607.
  • strict warning: Declaration of views_handler_filter_boolean_operator::value_validate() should be compatible with views_handler_filter::value_validate($form, &$form_state) in /home/codecomp/public_html/codecompiling.net/mai.www/sites/all/modules/views/handlers/views_handler_filter_boolean_operator.inc on line 159.
  • strict warning: Declaration of views_plugin_style_default::options() should be compatible with views_object::options() in /home/codecomp/public_html/codecompiling.net/mai.www/sites/all/modules/views/plugins/views_plugin_style_default.inc on line 24.
  • strict warning: Declaration of views_plugin_row::options_validate() should be compatible with views_plugin::options_validate(&$form, &$form_state) in /home/codecomp/public_html/codecompiling.net/mai.www/sites/all/modules/views/plugins/views_plugin_row.inc on line 134.
  • strict warning: Declaration of views_plugin_row::options_submit() should be compatible with views_plugin::options_submit(&$form, &$form_state) in /home/codecomp/public_html/codecompiling.net/mai.www/sites/all/modules/views/plugins/views_plugin_row.inc on line 134.

DONE

No es fácil encontrar ejemplos de los principios SOLID que sean lo suficientemente "realistas" y "didácticos" al mismo tiempo.

El siguiente ejemplo surge de un fragmento de código de un trabajo de tesis que actualmente estoy dirigiendo, y sirve para hablar de SRP (Single Responsibility Principle), OCP (Open-Closed Principle), ISP (Interface Segregation Principle) y DIP (Dependency Inversion Principle). El ejemplo resulta interesante porque abarca casi todos los principios SOLID y el único que queda por fuera es el LSP (Liskov Substitution Principle). Adicionalmente se muestra el uso del patrón Visitor, que es un patrón de diseño útil en algunos casos.

Problema

El problema a resolver es el siguiente: Dado un directorio (un File en Java), se debe recorrer el directorio y todos sus subdirectorios, y para cada archivo encontrado, es necesario realizar cierto proceso. En algunos casos, dependiendo de ciertas reglas, algunos subdirectorios se deben ignorar, por lo que no es necesario recorrerlos recursivamente. También existen casos en los que según ciertas reglas algunos archivos se deben ignorar y no deben ser procesados.

Este artículo tiene su origen en el código de una herramienta de internacionalización (I18N) para Java, de modo que lo que se recorre es un árbol de directorios que contiene código fuente. Entre los directorios que se ignoran están los "CVS", ".svn" y ".hg", entre otros, y los archivos que se deben procesar son todos aquellos con extensión ".properties" que comiencen con cualquiera de los prefijos "I18N", "Numb" y "Date".

Para no entrar en los detalles técnicos de la herramienta de internacionalización, en algunos casos a lo largo de este artículo se usarán como ejemplo dos posibles aplicaciones de esta idea: La primera es un anti-virus y su misión es recorrer un conjunto de directorios y procesar los archivos ".com", ".exe" y ".dll", buscando posibles virus y eliminándolos si los encuentra. La segunda es todo lo contrario, es decir es un virus, su misión es recorrer un conjunto de directorios, también buscando archivos ".com", ".exe" y ".dll" para infectarlos.

En algunos casos se hablará de la funcionalidad general, es decir, de un componente que debe recorrer un conjunto de directorios filtrando según ciertas reglas cuáles recorre recursivamente y cuales ignora, y procesar o no también según ciertas reglas algunos archivos. En otros casos nos referiremos al ejemplo del antivirus - virus para poder resaltar algunos aspectos asociados a la reusabilidad.

Primera Aproximación

La primera aproximación es un enfoque monolítico donde la única clase (FileScanner) tiene un único punto de entrada (scan) y hace absolutamente todo el trabajo.

Veamos que sucede si analizamos esta clase desde tres puntos de vista: Responsabilidades, Reusabilidad y Facilidad de Prueba.

Las responsabilidades de la clase son:

  1. Recorrer recursivamente los directorios/subdirectorios a partir del directorio raíz pasado en el método scan.
  2. Filtrar (decidir) cuales subdirectorios deben recorrerse recursivamente y cuales deben ignorarse.
  3. Filtrar (decidir) cuales archivos deben procesarse y cuales deben ignorarse.
  4. Procesar cada uno de los archivos encontrados.

En total podemos enumerar al menos cuatro responsabilidades diferentes en una sola clase (Baja Cohesión).

Sobre la reusabilidad del componente: Es reutilizable sólo en situaciones en las que las reglas de filtrado para directorios y archivos y el algoritmo de procesamiento de los archivos sean exactamente las mismos que en la implementación original, es decir, no es muy reutilizable (de hecho podríamos decir que no es nada reutilizable).

Sobre la facilidad de pruebas: Sólo podemos hacer pruebas de integración/sistema, es decir, pruebas fin a fin. No es posible hacer pruebas para los filtros individuales (de archivos o subdirectorios) o para el algoritmo de procesamiento de los archivos, porque todas las responsabilidades están encapsuladas en una misma clase y tenemos sólo un punto de entrada. Evidentemente esto es malo y hace que escribir pruebas para esta clase sea difícil.

En general aquí tenemos varios "smells" (olores de código/arquitectura, síntomas que nos dicen que hay algún problema de diseño). (1) La clase tiene demasiadas responsabilidades y hace muchas cosas, (2) la clase es larga (dado el punto anterior seguro que será larga), (3) la clase no es muy reutilizable en contextos distintos (no es flexible), y finalmente, (4) la clase, o más bien sus responsabilidades, son difíciles (o imposibles) de probar.

Para resolver el problema deberíamos primero revisar el SRP (Single Responsibility Principle / Principio de Responsabilidad Única) y tratar de romper la clase en varias partes de manera que podamos distribuir las responsabilidades.

En este caso particular, se puede utilizar el patrón visitante, o al menos una versión inicial del patrón visitante, porque en teoría no se estaremos usándolo sino hasta el siguiente refactor en el que incluyamos la interfaz Visitor. La idea general del patrón visitante es que dada una estructura de datos o un conjunto de objetos, se escribe un algoritmo que recorre dicha estructura y que para cada elemento en ella invoca a un método particular en una interfaz dada (el visitante), permitiendo procesar o hacer algo con el elemento actual. Lo interesante de este patrón, es que de hecho separa el recorrido de una estructura de datos del procesamiento de cada uno de los datos de la estructura de datos.

Segunda Aproximación

En este escenario, FileScanner hace el recorrido de los directorios y el filtrado de los subdirectorios/archivos y cuando consigue un archivo para procesar invoca a visit en DoSomethingVisitor.

Hagamos el mismo análisis basado en responsabilidades, reusabilidad y facilidad de pruebas:

Responsabilidades de FileScanner:

  1. Recorrer recursivamente los directorios/subdirectorios a partir del directorio raíz pasado en el método scan.
  2. Filtrar (decidir) cuales subdirectorios deben recorrerse recursivamente y cuales deben ignorarse.
  3. Filtrar (decidir) cuales archivos deben procesarse y cuales deben ignorarse.

Responsabilidades de DoSomethingVisitor:

  1. Procesar cada uno de los archivos encontrados.

Es claro que este aspecto ha mejorado porque ahora las responsabilidades se han distribuido entre dos clases.

Sobre la reusabilidad: Sigue siendo poco reutilizable, porque sucede lo mismo que el escenario anterior. En general, la lógica del filtrado sigue codificada directamente en FileScanner y si bien la lógica del procesamiento está ahora codificada en DoSomethingVisitor, existe una dependencia directa entre las dos clases y aparentemente FileScanner crea su dependencia, es decir, crea una instancia de DoSomethingVisitor, lo que acopla fuertemente ambas clases entre si (o al menos acopla FileScanner a DoSomethingVisitor). Por esta razón no es posible cambiar la lógica de procesamiento según sea necesario en otro contexto, es decir, no es posible usar la implementación de FileScanner con otra lógica de procesamiento, al menos no sin cambiar el código de FileScanner.

Pensando en el ejemplo del antivirus - virus, no es posible compartir la misma implementación de FileScanner entre ambas aplicaciones porque simplemente esta clase está atada (acoplada) a DoSomethingVisitor, y para cambiar esto no queda más remedio que modificar el código de FileScanner. Vale decir en este punto que cortar y pegar no es una buena definición de reutlización, y que estar haciendo mucho copy/paste puede llegar a considerarse un "smell" de código también.

Sobre la facilidad de pruebas: Debido a que está implementado en una clase aparte, el algoritmo de procesamiento se puede probar de forma independiente de FileScanner, lo que en principio mejora un poco la situación con respecto al escenario anterior, pero FileScanner no se puede probar de forma independiente del algoritmo de procesamiento. Esto se debe a que existe una dependencia de una clase concreta y a que (probablemente) FileScanner crea esta dependencia, es decir, hace el new DoSomethingVisitor() en algún lugar de su código.

El siguiente paso podría consistir en tratar de mejorar la facilidad de prueba de FileScanner y en romper el acoplamiento/dependencia de FileScanner a un algoritmo de procesamiento concreto.

En relación con los principios SOLID, en este caso si se analiza FileScanner se puede ver que para modificar su comportamiento (lo que se hace con un archivo visitado) es necesario cambiar la implementación de DoSomethingVisitor, o peor aún, cambiar FileScanner para que apunte a una clase visitante que haga algo distinto. Este hecho, el tener que cambiar la clase (tocar el código) para cambiar su comportamiento, es una violación al OCP (Open-Close Principle / Principio Abierto-Cerrado). El OCP dice que las clases deberían estar abiertas para ser extendidas (no en el sentido de la herencia) pero cerradas para su modificación. Es decir, debería ser posible cambiar el comportamiento de un componente o clase sin que sea necesario cambiar el código de la clase. Esto puede sonar un poco confuso, pero en la próxima aproximación se verá un poco más claro.

Tercera Aproximación

En este caso hemos introducido la interfaz Visitor y hemos hecho que FileScanner dependa de la interfaz en lugar de depender de un algoritmo o clase concreta. Además, y esto es extremadamente importante, estamos pasando la instancia usada de la interfaz en el constructor de FileScanner, de manera que ésta última clase ya no construye su dependencia concreta sino que se le "inyecta" en el constructor. Desde el punto de vista de FileScanner podría estar utilizando cualquier algoritmo de procesamiento, es decir, a FileScanner no le importa que algoritmo le pasen en el constructor, siempre y cuando se apegue al contrato definido por la interfaz Visitor.

Sobe SOLID, en este punto vale la pena mencionar el DIP o Dependency Inversion Principle / Principio de Inversión de Dependencias. Como hemos comentado, al FileScanner se le inyecta una instancia de Visitor en el constructor, pero ¿qué pasaría sin no le pasaramos la instancia en el constructor, sino que hicieramos algo como esto:

private Visitor visitor;
 
public FileScanner() {
  //...
  visitor = new DoSomethingVisitor();
  //...

En efecto, estaríamos atando la funcionalidad del visitante a una interfaz (lo que es correcto) pero también estaríamos creando nuestra propia instancia (dependencia) concreta asociada a esa interfaz en el constructor. El punto es que de querer cambiar el comportamiento de FileScanner tendríamos que alterar el constructor, en especial cambiar el new para poder crear otra instancia concreta. El constructor anterior es una violación del principio DIP, y para resolver el problema se debería escribir de esta forma:

private Visitor visitor;
 
public FileScanner(Visitor visitor) {
  //...
  this.visitor = visitor;
  //...

Es decir, la clase FileScanner no debería construir sus propias dependencias sino que estas deberían ser inyectadas en el constructor (o por setters) de forma que sea posible pasar diferentes implementaciones y generar comportamientos distintos (lo que es coherente con el OCP).

Hagamos el mismo análisis basado en responsabilidades, reusabilidad y facilidad de pruebas y veamos cómo ha valido la pena hacer este cambio:

Responsabilidades de FileScanner:

  1. Recorrer recursivamente los directorios/subdirectorios a partir del directorio raíz pasado en el método scan, invocando al visitante pasado (Visitor) por cada archivo a procesar.
  2. Filtrar (decidir) cuales subdirectorios deben recorrerse recursivamente y cuales deben ignorarse.
  3. Filtrar (decidir) cuales archivos deben procesarse y cuales deben ignorarse.

Responsabilidades de Visitor:

  1. Servir de interfaz "abstracta/genérica" para que FileScanner pueda invocar al algoritmo de procesamiento de turno.

Responsabilidades de DoSomethingVisitor:

  1. Implementar Visitor y procesar cada uno de los archivos encontrados con un algoritmo particular.

En este aspecto no se ven muchos cambios, es decir, todas las clases tienen más o menos la misma cantidad de responsabilidades que tenían en el escenario anterior.

Desde el punto de vista de la reusabilidad: Hemos ganado mucho, porque ahora podemos usar FileScanner con cualquier algoritmo de procesamiento que sea necesario. Por ejemplo, podríamos tener una implementación de Visitor que saca una copia de los archivos procesados, otra que realiza cambios particulares y otra que borra los archivos, y todas las podríamos utilizar con el mismo FileScanner en diferentes contextos según sea necesario.

Pensando en el ejemplo del antivirus - virus, podríamos compartir/reusar la clase FileScanner (bueno, como veremos más adelante, no todavía, pero casi) y la interfaz Visitor tanto en el antivirus como en el virus, y lo único que cambiaría sería la implementación de la interfaz Visitor que le pasaríamos al FileScanner, es decir, en el caso del antivirus la implementación de Visitor removería los virus, en el caso del virus, la implementación infectaría los archivos procesados.

Lo importante, para cambiar el comportamiento es implementar un Visitor que haga lo que deseamos y parametrizar/configurar FileScanner con el Visitor correcto por medio del constructor antes de invocar el método scan.

Por otro lado, aún estamos atados al filtro de directorios/subdirectorios y archivos que está implementado en FileScanner. Este último punto es crítico, porque es lo único que aún no nos permite compartir el mismo FileScanner entre el antivirus y el virus, porque hay que recordar que el antivirus procesa los ".exe", ".com" y ".dll", mientras que el virus procesa sólo los ".exe" y los ".com", es decir, los filtros entra ambas aplicaciones son distintos, pero como los filtros aún están codificados directamente en FileScanner, entonces eso genera un problema. Más adelante, al igual que como hicimos con el algoritmo de procesamiento, veremos que este no es un problema difícil de resolver.

Desde el punto de vista de la facilidad de pruebas también hemos ganado algo. El (los) algoritmos de procesamiento siguen siendo fáciles de probar de forma individual sin depender de FileScanner

Sin embargo ahora FileScanner es más fácil de probar, porque ahora no existe una dependencia directa entre esta clase y un algoritmo de procesamiento en particular, sino que ahora depende de una interfaz (Visitor) de la cual recibe una instancia en el constructor. De esta forma podemos probar FileScanner de manera independiente de cualquier algoritmo de procesamiento, esencialmente pasando en el constructor un Mock Object de Visitor, es decir, una implementación "falsa" de Visitor especialmente hecha para pruebas, que nos permite validar que sea invocada correctamente por FileScanner.

Más Adelante...

En una segunda parte de este artículo, seguiremos mejorando y generalizando la estructura de clases diseñada y continuaremos discutiendo los principios SOLID que aún faltan por mencionar.

Citas...

De Guiche, refiriendose al Quijote al atacar molinos de viento, intentando amenazar a Cyrano:

De Guiche: Pues, cuando se arremete contra ellos [los molinos], sucede a menudo...
Cyrano: ¿Ataco, entonces a gentes que giran según sopla el viento?
De Guiche: ...que las aspas del molino os lanzan al lodo.
Cyrano: ¡O bien, a las estrellas!

Edmond Rostand - Cyrano de Bergerac

Cursos