14. Composite (Compuesto) - Patrón Estructural

El patrón Composite permite tratar de forma uniforme objetos individuales y colecciones jerárquicas como si fueran del mismo tipo. Se emplea cuando los dominios del problema se prestan a estructuras tipo árbol, por ejemplo archivos y carpetas, widgets visuales, nodos de expresiones o elementos de catálogos.

Su fuerza radica en eliminar condicionales que distinguen entre elementos simples y compuestos. En su lugar se define una interfaz común que abstrae las operaciones relevantes, delegando a cada participante la responsabilidad de ejecutarlas de la forma adecuada.

14.1 Contexto y problema

En dominios jerárquicos, el código cliente suele verse obligado a preguntar si cada nodo es una hoja o un contenedor para decidir cómo proceder. Esto genera estructuras condicionales complejas, dificulta la extensión y acopla al cliente con detalles internos de las colecciones.

Composite introduce una jerarquía de objetos donde todos comparten la misma interfaz de alto nivel. De esta manera, el cliente delega la operación en el nodo sin preocuparse por su naturaleza. Cada tipo decide si ejecuta la acción de manera directa (hojas) o la propaga a sus hijos (compuestos).

14.2 Intención y motivación

La intención es construir objetos complejos a partir de composiciones de objetos similares de forma que todos se manipulen a través de la misma interfaz. Se aplica cuando:

  • El modelo del dominio puede describirse como una estructura jerárquica de profundidad variable.
  • El cliente necesita ejecutar operaciones recursivas (dibujar, calcular precios, validar, exportar).
  • Se espera que nuevos tipos de componentes se agreguen sin cambiar el código consumidor.

La motivación principal es evitar condicionales dispersos que comprueban tipos concretos, logrando polimorfismo real y delegación recursiva.

14.3 Participantes y estructura

Los participantes esenciales son:

  • Component: interfaz o clase abstracta que declara las operaciones comunes (por ejemplo render(), obtenerPrecio() o calcularTamaño()).
  • Leaf: implementaciones concretas que representan nodos sin hijos. Ejecutan la operación directamente.
  • Composite: nodos que almacenan y administran otros componentes. Implementan las mismas operaciones delegando en su colección interna.
  • Cliente: consume la jerarquía mediante referencias al tipo Component, ignorando si apunta a una hoja o a un compuesto.

El patrón suele incorporar operaciones adicionales para agregar, eliminar u obtener hijos. Dependiendo de la variante elegida (transparente o segura) esas operaciones se exponen en la interfaz Component o solo en Composite.

14.4 Implementación básica en C#

Consideremos un sistema de gestión de proyectos donde tareas simples y grupos de tareas se combinan y comparten operaciones como cálculo de horas restantes o marcación de estado. Una implementación base podría verse así:

using System;
using System.Collections.Generic;

public abstract class Tarea
{
    protected Tarea(string nombre)
    {
        Nombre = nombre;
    }

    public string Nombre { get; }
    public abstract int HorasEstimadas();
    public abstract void MarcarComoCompleta();
}

public class TareaSimple : Tarea
{
    private int _horasRestantes;
    private bool _completa;

    public TareaSimple(string nombre, int horasRestantes) : base(nombre)
    {
        _horasRestantes = horasRestantes;
    }

    public override int HorasEstimadas()
    {
        return _completa ? 0 : _horasRestantes;
    }

    public override void MarcarComoCompleta()
    {
        _completa = true;
        _horasRestantes = 0;
        Console.WriteLine($"Tarea simple completada: {Nombre}");
    }
}

public class TareaCompuesta : Tarea
{
    private readonly List<Tarea> _subtareas = new List<Tarea>();

    public TareaCompuesta(string nombre) : base(nombre)
    {
    }

    public void Agregar(Tarea tarea)
    {
        _subtareas.Add(tarea);
    }

    public void Eliminar(Tarea tarea)
    {
        _subtareas.Remove(tarea);
    }

    public override int HorasEstimadas()
    {
        var total = 0;
        foreach (var tarea in _subtareas)
        {
            total += tarea.HorasEstimadas();
        }

        return total;
    }

    public override void MarcarComoCompleta()
    {
        Console.WriteLine($"Marcando grupo como completo: {Nombre}");
        foreach (var tarea in _subtareas)
        {
            tarea.MarcarComoCompleta();
        }
    }
}

public static class GestorProyecto
{
    public static void Main()
    {
        var investigar = new TareaSimple("Investigar APIs", 6);
        var prototipo = new TareaSimple("Programar prototipo", 12);
        var entregable = new TareaCompuesta("Preparar entregable");
        entregable.Agregar(investigar);
        entregable.Agregar(prototipo);

        Console.WriteLine($"Horas estimadas: {entregable.HorasEstimadas()}");
        entregable.MarcarComoCompleta();
        Console.WriteLine($"Horas pendientes: {entregable.HorasEstimadas()}");
    }
}

El cliente gestiona referencias al tipo abstracto Tarea, por lo que puede operar sobre tareas simples o grupos indistintamente. Observa que la recursividad surge de forma natural cuando los compuestos delegan en su colección.

14.5 Ejemplo extendido: menú navegable

Un menú de navegación compuesto por opciones y submenús es otro caso clásico. Podemos enriquecer el diseño agregando iteración y lógica de habilitación:

using System;
using System.Collections.Generic;

public abstract class EntradaMenu
{
    protected EntradaMenu(string etiqueta)
    {
        Etiqueta = etiqueta;
    }

    public string Etiqueta { get; }
    public abstract void Ejecutar();

    public virtual void Imprimir(int nivel)
    {
        Console.WriteLine(new string(' ', nivel * 2) + "- " + Etiqueta);
    }
}

public class AccionMenu : EntradaMenu
{
    private readonly Action _accion;

    public AccionMenu(string etiqueta, Action accion) : base(etiqueta)
    {
        _accion = accion;
    }

    public override void Ejecutar()
    {
        Console.WriteLine($"Ejecutando accion: {Etiqueta}");
        _accion();
    }
}

public class MenuCompuesto : EntradaMenu
{
    private readonly List<EntradaMenu> _hijos = new List<EntradaMenu>();

    public MenuCompuesto(string etiqueta) : base(etiqueta)
    {
    }

    public MenuCompuesto Agregar(EntradaMenu entrada)
    {
        _hijos.Add(entrada);
        return this;
    }

    public override void Ejecutar()
    {
        Console.WriteLine($"Abriendo menu: {Etiqueta}");
        foreach (var hijo in _hijos)
        {
            hijo.Ejecutar();
        }
    }

    public override void Imprimir(int nivel)
    {
        base.Imprimir(nivel);
        foreach (var hijo in _hijos)
        {
            hijo.Imprimir(nivel + 1);
        }
    }
}

La clase MenuCompuesto devuelve la referencia a sí misma en el método Agregar para facilitar la definición fluida de jerarquías. El cliente imprime y ejecuta el menú sin preguntar qué tipo de entrada está recorriendo.

14.6 Variantes del patrón

Existen dos variantes reconocidas:

  • Composite transparente: los métodos para administrar hijos se declaran en Component. Los clientes pueden agregar hijos a cualquier nodo, aunque solo los compuestos los utilicen. Simplifica el código cliente, pero expone operaciones que no tienen sentido en hojas.
  • Composite seguro: la gestión de hijos se declara solo en la clase Composite. Protege a las hojas de operaciones no válidas a costa de que el cliente deba conocer el tipo concreto cuando necesita modificar la jerarquía.

Otra decisión de diseño se relaciona con el almacenamiento de referencias al padre. Algunas implementaciones lo requieren para facilitar eliminaciones o navegación inversa, pero esto puede aumentar el acoplamiento.

14.7 Beneficios

  • El cliente opera con polimorfismo puro: no necesita condicionales para distinguir entre nodos simples y compuestos.
  • La recursividad queda encapsulada en los objetos compuestos, reduciendo duplicación de lógica.
  • Permite construir jerarquías de profundidad arbitraria y expandirlas en tiempo de ejecución.
  • Facilita aplicar otros patrones sobre la jerarquía, como Iterator o Visitor, sin reescribir el código principal.

14.8 Riesgos y malas prácticas

El principal riesgo es violar el principio de responsabilidad única al incluir lógica compleja en los compuestos. Se recomienda que el Composite coordine, pero delegue el trabajo especializado en objetos colaborativos.

Otro problema frecuente es tratar de imponer una colección fija de tipos. Si se experimenta con lógica condicional para cada nuevo hijo, el patrón pierde su ventaja. Además, conviene evitar dependencias cíclicas: los nodos no deberían tener referencias cruzadas que formen grafos generales, porque el patrón asume una estructura arborescente.

14.9 Buenas prácticas de implementación

  • Exponga operaciones de lectura en la interfaz común y limite las de escritura según la variante elegida.
  • Proporcione un iterador consistente cuando el cliente necesite recorrer hijos sin conocer la estructura interna.
  • Combine Composite con Builder para crear jerarquías complejas en pasos declarativos.
  • Centralice las validaciones de negocio en un punto estable (por ejemplo, servicios de dominio) para que los componentes se enfoquen en la composición.

14.10 Elección frente a otros patrones

Composite se complementa con Decorator cuando se desea agregar responsabilidades a nodos individuales manteniendo la jerarquía. Si el objetivo principal es traducir interfaces incompatibles, Adapter puede resultar más apropiado. Para algoritmos que recorren estructuras complejas sin cambiar clases existentes, Visitor ofrece una abstracción más potente, aunque incrementa el acoplamiento.

Antes de aplicarlo, verifica que la jerarquía sea estable y que los clientes realmente necesiten manipular colecciones y elementos individuales por igual. De lo contrario, podría bastar con una colección convencional o con objetos de alto nivel sin recursividad.