Programación · Patrones de diseño
Patrón Composite explicado con ejemplos
Respuesta corta
Composite es un patrón de diseño estructural que permite componer objetos en estructuras de árbol y tratar después los objetos individuales y los grupos de objetos de la misma manera. Una hoja (como un archivo) y un compuesto (como una carpeta que contiene archivos y otras carpetas) implementan la misma interfaz, por ejemplo size(). El compuesto implementa la operación llamándola en sus hijos, de modo que el código cliente puede trabajar con un árbol completo sin comprobar qué tipo de nodo tiene.
Intención
Componer objetos en estructuras de árbol para representar jerarquías parte-todo. Composite permite que los clientes traten de forma uniforme los objetos individuales y las composiciones de objetos. Es uno de los siete patrones estructurales del libro de la Banda de los Cuatro (Gang of Four, GoF).
El problema que resuelve
Imagina una aplicación de almacenamiento que debe mostrar el tamaño total de cualquier elemento. Un archivo tiene un tamaño. El tamaño de una carpeta es la suma de todo lo que contiene, y una carpeta puede contener archivos y otras carpetas, anidadas a cualquier profundidad.
Sin una interfaz común, cada fragmento de código que recorre el árbol debe preguntar "¿esto es un archivo o una carpeta?" y tratar cada caso, normalmente con recursión escrita una y otra vez. Añadir un nuevo tipo de nodo, como un acceso directo o un archivo comprimido, obliga a buscar y actualizar todas esas comprobaciones. Composite coloca la recursión dentro de los propios nodos.
Estructura y participantes
| Participante | Responsabilidad | En el ejemplo |
|---|---|---|
| Componente | Declara la interfaz común para todos los objetos del árbol. | FsNode |
| Hoja | Un objeto sin hijos; implementa la operación directamente. | FileNode |
| Compuesto | Almacena componentes hijos e implementa la operación delegando en ellos. | Folder |
| Cliente | Trabaja con cualquier nodo a través de la interfaz del componente. | report |
Implementación en TypeScript
interface FsNode {
readonly name: string;
size(): number;
print(indent?: string): string[];
}
class FileNode implements FsNode {
constructor(readonly name: string, private bytes: number) {}
size(): number { return this.bytes; }
print(indent = ""): string[] { return [`${indent}${this.name} (${this.bytes})`]; }
}
class Folder implements FsNode {
private children: FsNode[] = [];
constructor(readonly name: string) {}
add(...nodes: FsNode[]): this { this.children.push(...nodes); return this; }
size(): number { return this.children.reduce((sum, c) => sum + c.size(), 0); }
print(indent = ""): string[] {
const lines = [`${indent}${this.name}/ (${this.size()})`];
for (const child of this.children) lines.push(...child.print(indent + " "));
return lines;
}
}
const root = new Folder("project").add(
new FileNode("README.md", 120),
new Folder("src").add(new FileNode("main.ts", 900), new FileNode("util.ts", 300)),
);
// The client treats a single file and a whole tree the same way.
const report = (node: FsNode) => console.log(node.print().join("\n"));
report(root);
// project/ (1320)
// README.md (120)
// src/ (1200)
// main.ts (900)
// util.ts (300)
report(new FileNode("notes.txt", 42)); // notes.txt (42)report nunca comprueba el tipo de nodo. Un nuevo tipo de nodo, como un Symlink que informa de un tamaño cero, solo necesita implementar FsNode.
Decisiones de diseño: transparencia frente a seguridad
¿Dónde deben vivir los métodos de gestión de hijos, como add() y remove()? El libro GoF describe el equilibrio:
| Opción | Qué significa | Contrapartida |
|---|---|---|
| Seguridad | Solo Composite tiene add() y remove(), como en el ejemplo anterior. | Seguro en tipos: no puedes añadir un hijo a un archivo. Pero los clientes deben saber cuándo tienen un compuesto. |
| Transparencia | Component declara add() y remove(), y las hojas lanzan un error o los ignoran. | Todos los nodos se ven idénticos para los clientes, pero los errores aparecen solo en tiempo de ejecución. |
En TypeScript, el enfoque de seguridad suele funcionar mejor, porque el verificador de tipos detecta el error en tiempo de compilación.
Cuándo usarlo y cuándo no
Usa Composite cuando:
- Tus datos son de forma natural un árbol: archivos y carpetas, widgets de interfaz dentro de contenedores, menús y submenús, organigramas, figuras y grupos de dibujo.
- Los clientes deberían poder ignorar la diferencia entre un objeto y un grupo de objetos.
Evítalo cuando:
- La estructura es plana o siempre tiene un solo nivel. Un arreglo simple es más sencillo.
- Las hojas y los grupos necesitan operaciones muy distintas. Forzarlos en una sola interfaz crea métodos que no tienen sentido para algunos nodos.
- Necesitas restringir qué hijos puede contener un compuesto, por ejemplo solo ciertos tipos de nodo. La interfaz uniforme hace más difícil aplicar esas reglas en el sistema de tipos.
Usos en el mundo real
- El DOM: cada elemento y cada nodo de texto es un
Node. Los elementos pueden contener otros nodos, y muchas operaciones, comotextContentocloneNode(true), funcionan igual en un solo nodo y en un subárbol completo. - Los frameworks de componentes de interfaz como React, Vue y Angular construyen interfaces como árboles donde un componente puede renderizar otros componentes.
- Java AWT y Swing:
Containeres una subclase deComponenty contiene otros componentes, así que un panel lleno de botones es a su vez un componente. - Los árboles de sintaxis abstracta en los compiladores, los grafos de escena en los motores de juegos y los menús anidados siguen la misma idea.
Patrones relacionados
- Iterator: recorre los nodos de un compuesto sin exponer cómo se almacenan los hijos.
- Visitor: añade nuevas operaciones sobre un árbol compuesto (exportación, validación, estadísticas) sin cambiar las clases de los nodos.
- Decorator: también tiene una interfaz de componente y envuelve objetos de forma recursiva, pero un decorador tiene exactamente un hijo y añade comportamiento, mientras que un compuesto tiene muchos hijos y combina sus resultados.
- Builder: se usa a menudo para construir paso a paso árboles compuestos complejos.
- Chain of Responsibility: suele usar los enlaces al padre de un compuesto, pasando una petición desde un hijo hacia sus padres, como los eventos del navegador que burbujean por el DOM.
Preguntas frecuentes
¿Cuál es la diferencia entre una hoja y un compuesto?
Una hoja no tiene hijos y hace el trabajo por sí misma. Un compuesto contiene componentes hijos y normalmente hace su trabajo llamando a la misma operación en cada hijo y combinando los resultados.
¿Composite sirve solo para árboles?
Está diseñado para jerarquías parte-todo con forma de árbol. Un grafo general con ciclos requiere cuidados adicionales, como registrar los nodos visitados, para evitar la recursión infinita.
¿Cuál es la diferencia entre Composite y Decorator?
Ambos usan una interfaz compartida y composición recursiva. Un decorador envuelve exactamente un objeto para añadir comportamiento, mientras que un compuesto agrupa muchos hijos para tratarlos como uno solo.
¿Debería add() estar también en la clase hoja?
Solo si quieres transparencia total y aceptas errores en tiempo de ejecución cuando alguien añade un hijo a una hoja. En lenguajes con tipos suele ser más seguro poner add() solo en el compuesto.