El patrón Compuesto (Composite) en C#
Recientemente me encargaron desarrollar un módulo para calcular los costes de productos formados por conjuntos de otros productos. Lo primero que me vino a la cabeza cuando me explicaban los requisitos fue el patrón Compuesto (Composite).
El patrón me resultó útil con el planteamiento inicial, pero iba a tener que trabajar más allá de él para entregar una solución completa.
Este post forma parte de una serie de tres:
- El patrón Compuesto (Composite) en C#
- Aplicación práctica del patrón compuesto en un ejemplo real
- Persistencia del patrón compuesto en una base de datos
Un árbol con ramas y hojas
El patrón tiene dos responsabilidades:
- Poder crear una estructura jerárquica de tipo árbol.
- Poder aplicar las mismas operaciones sobre cada uno de los nodos de la estructura, independientemente de que sea una rama o una hoja.
Si estamos hablando de este patrón hay que tener muy claro que ambos aspectos van ligados: crear una estructura jerárquica y operar sobre cualquier punto de la estructura.
Ejemplo:
class Program { static void Main(string[] args) { var troncoArbol = new Rama("Tronco"); var ramaPrincipal = new Rama("Rama principal"); ramaPrincipal.Brotar(new Hoja("Hoja1")); var ramaSecundaria = new Rama("Rama secundaria"); ramaSecundaria.Brotar(new Hoja("Hoja1")); ramaSecundaria.Brotar(new Hoja("Hoja2")); var ramaConMuchasHojas = new Rama("Rama con muchas hojas"); ramaConMuchasHojas.Brotar(new Hoja("Hoja1")); ramaConMuchasHojas.Brotar(new Hoja("Hoja2")); ramaConMuchasHojas.Brotar(new Hoja("Hoja3")); troncoArbol.Brotar(ramaPrincipal); troncoArbol.Brotar(ramaSecundaria); troncoArbol.Brotar(ramaConMuchasHojas); troncoArbol.Pintar(1); Console.ReadLine(); } } public abstract class ComponenteArbol { protected ComponenteArbol(string nombreComponente) { Nombre = nombreComponente; } protected string Nombre { get; } public abstract void Brotar(ComponenteArbol componente); public abstract void Cortar(ComponenteArbol componente); public abstract void Pintar(int nivel); } public class Rama : ComponenteArbol { private readonly List<ComponenteArbol> _componentes; public Rama(string nombre) : base(nombre) { _componentes = new List<ComponenteArbol>(); } public override void Brotar(ComponenteArbol componente) { _componentes.Add(componente); } public override void Cortar(ComponenteArbol componente) { _componentes.Remove(componente); } public override void Pintar(int nivel) { Console.WriteLine(new String('-', nivel) + " " + Nombre); foreach (ComponenteArbol hojaORama in _componentes) { hojaORama.Pintar(nivel + 1); } } } public class Hoja : ComponenteArbol { public Hoja(string nombre) : base(nombre) { } public override void Brotar(ComponenteArbol componente) { throw new NotImplementedException(); } public override void Cortar(ComponenteArbol componente) { throw new NotImplementedException(); } public override void Pintar(int nivel) { Console.WriteLine(new String('-', nivel) + " " + Nombre); } }
Resultado:
Los patrones de diseño tienen dos grandes ventajas:
- El vocabulario. El hecho de poner nombre a las cosas te permite hablar con otros programadores sin tener que dar largas explicaciones.
- Te proporcionan ideas para implementar soluciones sencillas que sin conocer el patrón quizá no se te hubieran ocurrido.
Justamente este es uno de los patrones en el que estos dos puntos son destacables. No es recomendable forzar el patrón hasta sus últimas consecuencias, es mejor utilizarlo como punto de partida y luego ir adaptándolo a las necesidades según convenga. Pero para eso es necesario conocer antes sus participantes y sus objetivos.
Así que aquí viene lo más importante de este post: el vocabulario.
El vocabulario
Componente (ComponenteArbol)
En este patrón todo es un componente: el todo y las partes.
- Declara la interfaz de los objetos de la composición (int Pintar())
- Declara la interfaz para añadir o quitar los componentes hijos (Brotar() y Cortar())
- Es la clase común de la que deben heredar los elementos Composite (ramas) y los elementos Leaf (Hoja)
- Opcionalmente puede definir una interfaz para acceder al padre dentro de la estructura recursiva y si es necesario lo implementa.
Hoja
Una hoja es un componente que no tiene hijos.
- Representa los objetos finales dentro de la estructura.
- Define un comportamiento de los objetos primitivos de la composición (Pintar()).
Compuesto (Rama)
Un compuesto es un componente que tiene hijos.
- Define el comportamiento de los objetos que tienen hijos (Brotar(), Cortar(), Pintar()).
- Encapsula los componentes hijos (private List<ComponenteArborl _componentes;).
- Implementa las operaciones de la interfaz Componente relacionadas con los hijos (Brotar(), Cortar()).
Cliente
El cliente es la clase que va a utilizar el patrón para crear el árbol y aplicar operaciones sobre él.
- Manipula los objetos de la composición a través de la interfaz Componente.
Hasta aquí tenemos las bases del patrón.
Tres cosas que debes saber sobre el patrón
1. No cumple con el principio de responsabilidad única (SRP)
El patrón define una clase que va a tener dos responsabilidades, por tanto no cumple con el principio de responsabilidad única (SRP). Es bueno saber esto para no querer llevar el patrón hasta sus últimas consecuencias. Si el código va aumentando en complejidad es mejor que separes la clase en dos, una para la gestión de la jerarquía y otra para las operaciones sobre los nodos.
Responsabilidad 1. Crear una estructura de tipo árbol:
public abstract class ComponenteArbol { protected ComponenteArbol(string nombreComponente) { Nombre = nombreComponente; } protected string Nombre { get; } public abstract void Brotar(ComponenteArbol componente); public abstract void Cortar(ComponenteArbol componente); public abstract void Pintar(int nivel); }
Responsabilidad 2. Poder aplicar las mismas operaciones sobre cada uno de los nodos de la estructura.
public abstract class ComponenteArbol { protected ComponenteArbol(string nombreComponente) { Nombre = nombreComponente; } protected string Nombre { get; } public abstract void Brotar(ComponenteArbol componente); public abstract void Cortar(ComponenteArbol componente); public abstract void Pintar(int nivel); }
Todos los componentes tienen un nombre y todos pueden ser pintados.
2. El patrón aconseja que la clase Componente sea abstracta
El patrón propone implementar el máximo número de operaciones en la clase Componente, de esta manera, tanto las Ramas como las Hojas que heredan de Componente ya tienen un comportamiento determinado. Esto en la práctica significa que la clase Componente tiene que ser abstracta y que además tendría que implementar el máximo número de operaciones comunes de todos los tipos de componentes.
En nuestro caso, el comportamiento común implementado en la clase componente es la asignación del Nombre, pero también podríamos definir un comportamiento común para Brotar y Cortar:
public abstract class ComponenteArbol { protected ComponenteArbol(string nombreComponente) { Nombre = nombreComponente; } protected string Nombre { get; } public virtual void Brotar(ComponenteArbol componente) { throw new NotImplementedException(); } public virtual void Cortar(ComponenteArbol componente)) { throw new NotImplementedException(); } public abstract void Pintar(int nivel); }
Así, la clase Hoja ya no tendría que implementar estos métodos y quedaría simplificada así:
public class Hoja : ComponenteArbol { public Hoja(string nombre) : base(nombre) { } public override void Pintar(int nivel) { Console.WriteLine(new String('-', nivel) + " " + Nombre); } }
La clase Rama no sufriría ningún cambio.
Este patrón propone que la clase Componente sea abstracta, pero también sería válido utilizar una interfaz para definir Componente:
public interface IComponenteArbol { void Brotar(ComponenteArbol componente); void Cortar(ComponenteArbol componente); void Pintar(int nivel); }
Si definimos el Componente como una interfaz acabaremos duplicando las partes comunes entre los componentes. En nuestro caso, la asignación del nombre en el constructor quedaría duplicada para cada subclase de Componente.
Teniendo dos subclases de componente (Rama y Hoja) esta duplicación del código no es un problema, pero si estamos hablando de 20 o 30 tipos de componentes lo más conveniente sería definir una clase abstracta.
3. Deberás escoger entre transparencia vs. seguridad.
¿Para qué necesita una Hoja tener una operación como Brotar() si de una Hoja no pueden brotar otras hojas? Este hecho entra en conflicto con el principio de diseño de jerarquías de clases que dice que una clase base sólo debería definir operaciones comunes que tienen sentido en todas sus subclases.
Si se definen las operaciones de Brotar y Cortar como abstractas en la clase Componente entonces hay que implementarlas en la clase Hoja. Dicha implementación no tiene sentido así que la función simplemente lanza una excepción. Si actuamos de esta manera estaríamos vialonado el Principio de Sustitución de Liskov (LSP) porque una hoja no se podría comportar igual que su clase base.
Si la implementación de Brotar y Cortar se encuentra en la clase Componente lo habitual es que también lancen una excepción o que simplemente no haga nada, y eso sería un claro signo de que alguna cosa no está bien.
La solución a este problema sería implementar estas funciones solo en la clase Compuesto (Composite), pero entonces las clases Hojas y las clases Ramas no tendrían la misma interfaz y antes de aplicar cualquier operación sobre ellos, el cliente debería saber si está tratando con una Hoja o con una Rama.
Ejemplo de código seguro:
public abstract class ComponenteArbol { protected ComponenteArbol(string nombreComponente) { Nombre = nombreComponente; } protected string Nombre { get; } public abstract void Pintar(int nivel); } public class Rama : ComponenteArbol { private readonly List<ComponenteArbol> _componentes; public Rama(string nombre) : base(nombre) { _componentes = new List<ComponenteArbol>(); } public void Brotar(ComponenteArbol componente) { _componentes.Add(componente); } public void Cortar(ComponenteArbol componente) { _componentes.Remove(componente); } public override void Pintar(int nivel) { Console.WriteLine(new String('-', nivel) + " " + Nombre); foreach (ComponenteArbol hojaORama in _componentes) { hojaORama.Pintar(nivel + 1); } } } public class Hoja : ComponenteArbol { public Hoja(string nombre) : base(nombre) { } public override void Pintar(int nivel) { Console.WriteLine(new String('-', nivel) + " " + Nombre); } }
En este ejemplo, las operaciones Brotar y Cortar ya no son comunes, solo las puede utilizar la clase Compuesto (Rama) y por tanto las Ramas y las Hojas ya no son transparentes para el cliente.
Cuando utilices este patrón selecciona cuidadosamente qué te interesa más: las transparencia (código sencillo) o la seguridad (código que no falla).
En el próximo post voy a escribir cómo utilicé este patrón en un ejemplo real. :)