Patrones Estructurales (VI): Patrón Composite


Objetivo:

«Componer objetos en árboles para representar jerarquías todo-parte. Composite permite a los clientes tratar objetos individuales y objetos compuestos de una manera uniforme».

Design Patterns: Elements of Reusable Object-Oriented Software

El patrón Composite se aleja un poco de la línea tradicional de los patrones vistos hasta ahora, ya que rompe uno de los principios de la programación orientada a objetos: una clase, una responsabilidad. En realidad, los más puristas pueden decidir no hacerlo, pero el precio a pagar es demasiado alto para los ingenieros mortales: la simplicidad del modelo.

Cuando diseñamos debemos tener claro que la idea principal es alcanzar un equilibrio entre muchos factores como por ejemplo presupuesto, usabilidad y facilidad para que nuestro código sea reutilizable y pueda ser fácilmente mantenible en un futuro. Si el objetivo del anterior patrón, Flyweight, era el rendimiento, el sine qua non de este patrón es la facilidad de uso.

A grandes rasgos, el patrón Composite permite crear una jerarquía de elementos anidados unos dentro de otros. Cada elemento permitirá alojar una colección de elementos del mismo tipo, hasta llegar a los elementos «reales» que se corresponderán con los nodos «Hoja» del árbol. Un ejemplo del concepto de la jerarquía que se pretende modelar sería el de los menús de una aplicación:

En este ejemplo tenemos un menú (Archivo) que contiene varios elementos, que pueden ser «hojas» que ejecutan una operación (Abrir, CreatePDF en línea.., Compartir archivos usando SendNow Online…, Adjuntar a correo electrónico…) o bien otro menú (Guardar como) que a su vez contiene más elementos «hoja» (PDF…, Texto…, Word o Excel Online…).

Creo que el ejemplo es lo suficientemente ilustrativo como para entender el concepto: poder anidar menús que puedan contener o bien otros menús, o bien directamente nodos hoja que ejecuten operaciones. Este ejemplo se aproxima bastante al concepto de Composite, pero no se ajusta exactamente a su filosofía, ya que le falta una funcionalidad: el submenú debería ser capaz de ejecutar una operación que se encargaría de iterar sobre todos los subelementos que contiene, ejecutando la operación de cada uno de ellos. Sin embargo, deja bastante claro el esquema lógico que seguirá este patrón.

Llevando el coche al taller

Para ilustrar el patrón, acudiremos a un taller, que tuvo a bien contratar a un ingeniero de software para que le diseñara una aplicación para realizar el inventariado de las piezas de recambio. El dueño del taller le expuso al ingeniero la dificultad que tenía para establecer los precios de los recambios, ya que dependiendo de la avería, a veces era necesario cambiar piezas enteras mientras que en otras ocasiones bastaba con sustituir un pequeño componente. Nuestro cliente había almacenado en una base de datos cada uno de los componentes con su respectivo precio, pero cuando el proveedor le aumentaba el precio de una pieza que formaba parte de otra, tenía que preocuparse de actualizar, una a una, todas las piezas de grano más grueso en las cuales estaba contenida esta pieza. Por poner un ejemplo, si el precio de un tornillo para una rueda aumentaba, nuestro mecánico tenía que acceder a su catálogo y modificar el precio total en:

  • Tornillo para rueda
  • Llantas (en todos y cada uno de los modelos que usaran los tornillos anteriores)
  • Ruedas (en todos y cada uno de los modelos que usaran las llantas anteriores)

Como podemos imaginar, esta situación era muy laboriosa. Así pues, nuestro ingeniero desarrolló un sistema que realizara el cálculo del precio de forma automática a la vez que modelaba el almacén de recambios con una estructura arborescente. En lugar de disponer del siguiente modelo:

Le proporcionamos este:

Aplicando Composite

Es hora de ensuciarnos un poco las manos y plantar cara a nuestro código. Comenzaremos creando nuestro elemento abstracto o interfaz correspondiente al «Componente». Esta clase o interfaz debe exponer los métodos comunes tanto a los elementos compuestos como a los elementos «hoja». Lo verdaderamente importante es que este elemento sea una abstracción (depender de abstracciones, no de concreciones).

  • Si optamos por la interfaz, simplemente definiremos sus operaciones.
  • Si optamos por la clase abstracta, además de definir sus operaciones, añadiremos un comportamiento por defecto en el que lanzaremos una excepción que indique que la operación no está soportada. De este modo, las clases derivadas deberán encargarse de proporcionar la funcionalidad. En caso de no proporcionarla, lanzarán una excepción (por ejemplo, el método add en un elemento Hoja no tiene sentido, por lo que deberá tener este comportamiento por defecto).

Nuestra clase Componente será, por tanto, el siguiente:

    public abstract class ComponenteRecambio
    {
        #region Métodos comunes a objetos compuestos y hojas

        public virtual string getNombre()
        {
            throw new NotSupportedException(this.GetType().Name + "getNombre()");
        }

        public virtual void setNombre(string nombre)
        {
            throw new NotSupportedException(this.GetType().Name + "setNombre()");
        }

        public virtual string getDescripcion()
        {
            throw new NotSupportedException(this.GetType().Name + "getDescripcion()");
        }

        public virtual void setDescripcion(string descripcion)
        {
            throw new NotSupportedException(this.GetType().Name + "setDescripcion()");
        }

        public virtual double getPrecio()
        {
            throw new NotSupportedException(this.GetType().Name + "getPrecio()");
        }

        public virtual void setPrecio(double precio)
        {
            throw new NotSupportedException(this.GetType().Name + "setPrecio()");
        }

        #endregion

        #region Métodos exclusivos de los objetos compuestos

        public virtual void add(ComponenteRecambio componente)
        {
            throw new NotSupportedException(this.GetType().Name + "add()");
        }

        public virtual void remove(ComponenteRecambio componente)
        {
            throw new NotSupportedException(this.GetType().Name + "remove()");
        }

        public virtual ComponenteRecambio getElemento(int indice)
        {
            throw new NotSupportedException(this.GetType().Name + "getElemento()");
        }

        #endregion
    }

Sobre esta clase trabajaremos creando otras dos clases que simbolizarán los dos tipos de elemento que puede contener nuestro patrón Composite: elementos hoja (ElementoRecambio) y elementos compuestos (Recambio). Así, nuestros elementos «hoja» serán los que incorporen la verdadera funcionalidad, que será invocada por los elementos compuestos en caso de que fuera necesario.

Hemos utilizado «nomenclatura Java» (getters y setters en lugar de una propiedad para ambas operaciones) porque a la hora de sobrecargar los métodos será más intuitivo si las operaciones se encuentran separadas, como veremos más adelante. Por supuesto, para quien lo desee, también es posible usar propiedades virtuales y sobrecargarlas del mismo modo que hacemos con estos dos métodos.

Volviendo a nuestro código, nuestra clase ElementoRecambio almacenará información como el nombre, la descripción y el precio:


    public class ElementoRecambio : ComponenteRecambio
    {
        // Atributos propios del nodo hoja.
        private string nombre;
        private string descripcion;
        private double precio;

        // Constructor
        public ElementoRecambio(string nombre, string descripcion, double precio)
        {
            this.nombre = nombre;
            this.descripcion = descripcion;
            this.precio = precio;
        }

        // Sobrecargamos únicamente los métodos propios de los nodos hoja, destinados
        // a devolver la información y a asignarla

        // NOMBRE
        public override string getNombre()
        {
            return nombre;
        }

        public override void setNombre(string nombre)
        {
            this.nombre = nombre;
        }

        // DESCRIPCION
        public override string getDescripcion()
        {
            return descripcion;
        }

        public override void setDescripcion(string descripcion)
        {
            this.descripcion = descripcion;
        }

        // PRECIO
        public override double getPrecio()
        {
            return precio;
        }

        public override void setPrecio(double precio)
        {
            this.precio = precio;
        }

        // Los métodos add, remove y getElemento no se sobrecargarán, ya que
        // el nodo hoja no estará compuesto por más elementos que él mismo.
        // Por tanto, si se invocan estos métodos, se llamará el método padre
        // que lanzará una excepción de tipo NotSupportedException
    }

Seguramente, a estas alturas ya nos habremos dado cuenta de lo que comentábamos al principio del artículo respecto a la ruptura del principio de responsabilidad única. La clase Elemento implementa un conjunto de métodos, pero deja otro conjunto sin implementar (los métodos add, remove y getElemento), que lanzarán una excepción si te utilizan. Esos métodos serán implementados por la siguiente clase: Recambio.

    public class Recambio : ComponenteRecambio
    {
        // Arraylist que contendrá los elementos hijo
        private ArrayList listaRecambios;

        // Atributos
        private string nombre;
        private string descripcion;

        // Constructor que recibirá el nombre, el precio y la descripción.
        public Recambio(string nombre, string descripcion, double precio)
        {
            // Instanciamos el ArrayList
            listaRecambios = new ArrayList();

            // Asignamos el nombre, la descripción y el precio
            this.nombre = nombre;
            this.descripcion = descripcion;
            this.precio = precio;

        }

        #region Métodos relacionados con el árbol

        // Añade un nuevo elemento al ArrayList
        public override void add(ComponenteRecambio componente)
        {
            listaRecambios.Add(componente);
        }

        // Elimina un elemento del ArrayList
        public override void remove(ComponenteRecambio componente)
        {
            listaRecambios.Remove(componente);
        }

        // Recupera un elemento del ArrayList
        public override ComponenteRecambio getElemento(int indice)
        {
            return (ComponenteRecambio)listaRecambios[indice];
        }

        #endregion

    } 

En esta primera etapa, hemos codificado la parte que se encarga de añadir, eliminar y consultar otros elementos. El método es sencillo: a través de un ArrayList interno, los métodos add, remove y getElemento realizarán operaciones sobre él.

A continuación codificaremos los métodos get que recuperarán el contenido de los atributos de cada ElementoRecambio: nombre, descripción y precio. Para ello iteraremos sobre los elementos contenidos dentro de cada Recambio.

El precio, sin ir más lejos, se calculará a partir de los precios de los elementos contenidos en cada ElementoRecambio, que se sumará al precio del propio componente.

        #region Métodos relacionados con el elemento

        public override string getNombre()
        {
            string nombreCompleto = this.nombre + "\n";

            foreach(ComponenteRecambio c in listaRecambios)
                nombreCompleto += c.getNombre();

            return nombreCompleto;
        }

        public override string getDescripcion()
        {
            string descripcionCompleta = this.descripcion + "\n";

            foreach (ComponenteRecambio c in listaRecambios)
                descripcionCompleta += c.getDescripcion();

            return descripcionCompleta;
        }

        public override double getPrecio()
        {
            double precioTotal = this.precio;

            foreach (ComponenteRecambio c in listaRecambios)
                precioTotal += c.getPrecio();

            return precioTotal;
        }

        #endregion

También sobrecargaremos los métodos setNombre, setDescripcion y setPrecio.

        public override void setNombre(string nombre)
        {
            this.nombre = nombre;
        }

        public override void setDescripcion(string descripcion)
        {
            this.descripcion = descripcion;
        }
        public override void setPrecio(double precio)
        {
            this.precio = precio;
        }

Recorriendo todos los elementos contenidos dentro de cada recambio podremos obtener la información almacenada tanto en un objeto nodo (Recambio) como en un objeto hoja (ElementoRecambio). Ambos se tratarán de la misma manera, aunque la implementación de los métodos sea distinta. Y dado que ambos objetos son intercambiables, estamos consiguiendo lo que declaramos en primera instancia: componer objetos de forma arborescente respetando la jerarquía todo-parte permitiendo que ambos elementos se traten de forma uniforme.

            // Declaramos los tornillos incluidos en la llanta, que serán nodos hoja
            ComponenteRecambio tornillo1 = new ElementoRecambio("Tornillo llanta", "Tornillo llanta marca ACME", 0.21);
            ComponenteRecambio tornillo2 = new ElementoRecambio("Tornillo llanta", "Tornillo llanta marca ACME", 0.21);
            ComponenteRecambio tornillo3 = new ElementoRecambio("Tornillo llanta", "Tornillo llanta marca ACME", 0.21);
            ComponenteRecambio tornillo4 = new ElementoRecambio("Tornillo llanta", "Tornillo llanta marca ACME", 0.21);

            // Declaramos la llanta, que poseerá cuatro tornillos. Por tanto, se tratará de un elemento Composite (compuesto
            // por otros elementos, que pueden ser compuestos u hojas)
            ComponenteRecambio llanta = new Recambio("Llanta ACME 15'", "Llanta ACME de 15'", 42.22);

            // Añadimos los tornillos a la llanta
            llanta.add(tornillo1);
            llanta.add(tornillo2);
            llanta.add(tornillo3);
            llanta.add(tornillo4);

            // Declaramos ahora otro elemento: la válvula de la rueda
            ComponenteRecambio valvula = new ElementoRecambio("Válvula", "Válvula de neumático genérica", 0.49);

            // Realizamos lo mismo con el neumático
            ComponenteRecambio neumatico = new ElementoRecambio("Neumático 15'", "Neumático Michelin de 15'", 13.42);

            // Declaramos un nuevo objeto compuesto: la rueda.
            // Este objeto estará compuesto por la llanta, la válvula y el neumático.
            // A su vez, la llanta incluirá los tornillos.
            // Establecemos el precio de la rueda a '0', ya que dependerá en exclusiva del contenido de sus elementos.
            ComponenteRecambio rueda = new Recambio("Rueda 15'", "Rueda de 15' con llanta ACME y neumático Michelin", 0);

            // Añadimos a la rueda los elementos hoja que instanciamos previamente
            rueda.add(llanta);
            rueda.add(neumatico);
            rueda.add(valvula);

Si consultamos el método getNombre() de nuestra rueda, se mostrará el contenido del atributo privado nombre para, a continuación, iterar sobre cada elemento del ArrayList invocando a su vez el método getNombre() de cada elemento de forma recursiva. Así, obtendríamos lo siguiente:

            Console.WriteLine(rueda.getNombre());

Lo cual nos daría la siguiente salida:

Si hacemos lo propio con el precio, obtendríamos la suma de todos los precios de sus elementos hoja. Si después de esto realizamos una modificación en el precio de uno de los elementos contenidos dentro del objeto compuesto, el cambio se propagará de forma automática sin necesidad de modificar el precio total de cada rueda.

            double precioTotal = rueda.getPrecio();

            Console.WriteLine("El precio total de la rueda es " + precioTotal);

            // Modificamos el precio de los tornillos para ver cómo afecta al precio total
            tornillo1.setPrecio(0.34);
            tornillo2.setPrecio(0.34);
            tornillo3.setPrecio(0.34);
            tornillo4.setPrecio(0.34);

            precioTotal = rueda.getPrecio();
            Console.WriteLine("El precio total de la rueda tras la subida de precio es " + precioTotal);

Esto producirá la siguiente salida:

Purismo vs. Transparencia

Nos acabamos de encontrar con el primer patrón que viola deliberadamente uno de los principios de la programación orientada a objetos. El motivo ha sido que el incremento en la transparencia (y usabilidad) es tan importante que merece la pena el sacrificio de permitir que una clase adquiera más de una responsabilidad. Recordemos que los patrones de diseño son soluciones genéricas a problemas concretos, por lo que su objetivo es la de facilitar el desarrollo de software. Hay ocasiones, por tanto, en las que las que las ventajas de romper las normas sobrepasan de largo seguirlas a rajatabla.

¿Cuándo utilizar este patrón? Ejemplos reales

Los escenarios en los que este patrón suele utilizarse son, principalmente:

  • Como su propia definición indica, cuando se requiere representar jerarquías todo-parte que superen cierto tamaño.
  • Cuando se desea que los clientes puedan ignorar la diferencia entre colecciones de objetos y objetos individuales, haciendo que ambos se traten de la misma manera.

El ejemplo más sencillo de visualizar de este patrón son los controles de un formulario, por ejemplo de WinForms. Los objetos (botones, literales, paneles…) heredan de la clase Control, que, de base, puede contener a su vez una colección de otros objetos que hereden de la clase Control. Cada uno de estos controles pueden tratarse bien como un control individual en sí o como una colección de controles (un panel puede contener, por poner un ejemplo, un Label, un Button y un RadioButton).

El sistema del pipeline de petición/respuesta de ASP.NET también sigue este esquema.

Finalmente, la estructura de ficheros y directorios de un sistema de archivos también actúa en consonancia con este patrón.

Fuentes:

4 comentarios

  1. Hola Daniel,

    Las explicaciones que das sobre este patrón hace se entienda perfectamente. Y creo que el hecho de que siempre expliques con los mismos conceptos: coche, motor, … hace que todo sea más fácil.

    Has llegado a debatir sobre el uso de un patrón u otro porque el escenario a representar admite varios puntos de vista?. Al fin y al cabo a Roma se llega por muchos caminos y no siempre el camino más corto es el mejor a tomar.

    Gracias.

    1. Hola, José Miguel,

      Es cierto que existen ejemplos más sencillos para entender los patrones (por ejemplo, ‘Head First’ hace una aproximación a este patrón a partir de menús de comida con submenús), pero coincido contigo en que usar el mismo escenario para definir todos los patrones simplifica bastante su entendimiento. De todos modos, como todo, dependerá de cada uno 🙂

      Tal y como dices, los propios patrones de diseño se desaconsejan en ciertos escenarios. A fin de cuentas, se trata de herramientas lógicas cuyo objetivo es hacernos más fácil el trabajo. Si en cierta situación consiguen lo contrario, ¿de qué servirían?

      Nuevamente, mil gracias por tus palabras. Un cordial saludo.

      Dani.

    1. un poco tarde, pero no tiene ninguna traduccion.Que existan ORM no quiere decir que tenga que existir una mapeo exacto a una base de datos relacional.

Deja un comentario