Composite
En resumen: Composite da a una pieza suelta y a un grupo la misma forma, de modo que un árbol de productos y cajas se maneja con una sola llamada, sin importar cuántos niveles tenga.
Problema
Una caja dentro de una caja dentro de una caja
La tienda vende cajas de regalo. La caja navideña contiene té, miel y una caja más pequeña con caramelos y chocolate. Cada caja tiene además un precio por su embalaje. El pedido lleva una vela y la caja navideña. Los datos se ven así:
record Product(string Name, decimal Price);
record Box(string Name, decimal Packaging, List<object> Items);El total se calcula comprobando qué es cada pieza: un producto o una caja. Para una caja hay un segundo bucle:
static decimal Total(List<object> order){ decimal sum = 0; foreach (object item in order) { if (item is Product p) { sum += p.Price; } else if (item is Box box) { sum += box.Packaging; foreach (object inner in box.Items) { if (inner is Product q) { sum += q.Price; } } } } return sum;}List<object> order = [ new Product("Vela", 5.00m), new Box("Caja navideña", 2.00m, [ new Product("Té", 4.50m), new Product("Miel", 6.00m), new Box("Cajita", 1.00m, [ new Product("Caramelos", 3.50m), new Product("Chocolate", 2.50m)])])];Console.WriteLine($"Total: {Total(order):F2} €");Total: 17.50 €El pedido cuesta 24,50 €, y el programa dice 17,50 €. La cajita está en el tercer nivel. El segundo bucle la ve, pero no es un producto, y desaparece del total junto con los caramelos y el chocolate. No hay ningún error.
Un tercer bucle anidado arregla este pedido, pero no una caja en el cuarto nivel. Y la misma cadena de comprobaciones está también al contar los productos y en el peso para el mensajero.
La idea
Composite: la parte y el todo con una misma forma
Al producto y a la caja se les pregunta lo mismo: ¿cuánto cuestas? El producto responde con su precio. La caja responde con su embalaje más las respuestas de todo lo que hay dentro.
Así que los dos tienen una forma: la interfazItem (IItem en C#) con un método Total(). La caja guarda una lista de Item y no sabe cuál de ellos es un producto y cuál una caja.Este es el patrón Composite. Tiene tres papeles:
- la forma común: la interfaz con
Total(); - la hoja: el producto, que calcula por sí mismo;
- el contenedor: la caja, que guarda hijos con la misma forma y le pregunta a cada uno.
Una caja dentro de la caja es solo un hijo que también les pregunta a sus hijos. Así las llamadas bajan hasta los productos, sin importar cuántos niveles haya: es recursión a través de objetos. El código que pide el total no sabe si tiene en la mano un producto, una caja o un árbol entero.
Una carpeta con archivos tiene la misma forma: el tamaño de un archivo es el suyo, y el tamaño de una carpeta es el total de todo lo que contiene, incluidas las carpetas de dentro.
Míralo
El total baja hasta el fondo y vuelve
La forma común:
interface IItem{ decimal Total();}El producto y la caja. El constructor de la caja recibe cualquier cantidad de piezas (params IItem[] items):
record Product(string Name, decimal Price) : IItem{ public decimal Total() => Price;}
class Box : IItem{ private readonly List<IItem> items;
public string Name { get; } public decimal Packaging { get; }
public Box(string name, decimal packaging, params IItem[] items) { Name = name; Packaging = packaging; this.items = new List<IItem>(items); }
public decimal Total() { decimal sum = Packaging; foreach (IItem item in items) { sum += item.Total(); } return sum; }}El árbol se escribe como constructores anidados, una pieza por línea:
IItem order = new Box("Pedido", 0, new Product("Vela", 5.00m), new Box("Caja navideña", 2.00m, new Product("Té", 4.50m), new Product("Miel", 6.00m), new Box("Cajita", 1.00m, new Product("Caramelos", 3.50m), new Product("Chocolate", 2.50m))));Console.WriteLine($"Total: {order.Total():F2} €");Total: 24.50 €IItem order = new Box("Pedido", 0, new Product("Vela", 5.00m), new Box("Caja navideña", 2.00m, new Product("Té", 4.50m), new Product("Miel", 6.00m), new Box("Cajita", 1.00m, new Product("Caramelos", 3.50m), new Product("Chocolate", 2.50m))));Console.WriteLine($"Total: {order.Total():F2} €");Paso a paso, en palabras
- El árbol se arma: cada artículo está en su propia línea, y cada caja contiene a sus hijos.
- Total() le pregunta a la raíz. El pedido no sabe qué hay dentro: empieza por su embalaje y llama a Total() en cada hijo.
- Vela es un producto: responde con su precio, 5.00, y la respuesta vuelve hacia arriba.
- Caja navideña es una caja: hace exactamente lo mismo, primero el embalaje y luego un hijo tras otro.
- Cajita está en el tercer nivel. Aquí no desaparece nada: les pregunta a sus propios hijos.
- Cajita: 1.00 + 3.50 + 2.50 = 7.00. Su total sube a su padre.
- Caja navideña: 2.00 + 4.50 + 6.00 + 7.00 = 19.50.
- Pedido: 0.00 + 5.00 + 19.50 = 24.50. Eso es lo que imprime el programa.
No hay una comprobación de tipo en ningún sitio ni un número de niveles en ningún sitio. La caja hace lo mismo en cada nivel: el embalaje más el total de los hijos.
Míralo
Un tipo nuevo de pieza sin tocar la caja
La tienda empieza a vender café en grano a 24 € el kilo. Su precio es el precio por kilo por los kilos. Es una clase nueva con la misma forma:
record WeighedProduct(string Name, decimal PerKg, decimal Kg) : IItem{ public decimal Total() => PerKg * Kg;}IItem coffee = new Box("Caja de café", 1.00m, new WeighedProduct("Café en grano", 24.00m, 0.25m), new Product("Taza", 8.00m));Console.WriteLine($"Caja de café: {coffee.Total():F2} €");Caja de café: 15.00 €No cambió ni una sola línea de Box. La caja llama a Total() en el café, igual que en cualquier otra pieza.
En el problema la lista era una List<object> y aceptaba cualquier cosa. Ahora la caja solo acepta un IItem, y un texto en lugar de un producto no compila:
IItem box = new Box("Caja", 1.00m, "Té");error CS1503: Argument 3: cannot convert from 'string' to 'IItem'Más precisamente, para curiosos
Un nuevo tipo de pieza es fácil: una clase. Una nueva operación cuesta más: si además del total necesitas un peso para el mensajero, el método Weight() se añade a cada clase. Composite es fuerte donde crecen los tipos de piezas y las operaciones son pocas.
Pruébalo
Predice la salida
La caja contiene una caja vacía y una vela. ¿Qué imprime el programa?
IItem box = new Box("Caja", 1.00m, new Box("Vacía", 0.50m), new Product("Vela", 5.00m));Console.WriteLine($"Caja: {box.Total():F2} €");Pruébalo
Encuentra el error
Alguien reescribe la caja para hacerla «más segura»:
class Box : IItem{ private readonly List<IItem> items;
public string Name { get; } public decimal Packaging { get; }
public Box(string name, decimal packaging, params IItem[] items) { Name = name; Packaging = packaging; this.items = new List<IItem>(items); }
public decimal Total() { decimal sum = Packaging; foreach (IItem item in items) { if (item is Product) { sum += item.Total(); } } return sum; }}IItem gift = new Box("Caja navideña", 2.00m, new Product("Té", 4.50m), new Product("Miel", 6.00m), new Box("Cajita", 1.00m, new Product("Caramelos", 3.50m), new Product("Chocolate", 2.50m)));Console.WriteLine($"Caja navideña: {gift.Total():F2} €");Caja navideña: 12.50 €La caja navideña cuesta 19,50 €, y el programa dice 12,50 €. ¿En qué línea está el error?
Elige la línea con el error.
La comprobación deja pasar solo productos. La cajita de dentro no es un producto, así que ella, los caramelos y el chocolate (7 €) desaparecen del total. Es el mismo error que en el problema, solo que escondido dentro de la caja. El contenedor en Composite no pregunta qué es el hijo: llama a Total() en cada uno. Quita la comprobación, y el total es 19,50 €.
La idea
Cuándo no lo necesitas
Composite tiene un costo: una forma común y una recursión que hay que leer para entender un total. No compensa cuando:
- las cosas están en una lista plana. Un carrito sin cajas es una lista, y el total es un bucle;
- hay exactamente dos niveles y se queda así: un pedido con líneas. Una línea de pedido no contiene un pedido;
- las operaciones se vuelven muchas y hay solo dos tipos de piezas. Cada operación nueva se escribe en cada clase. Entonces a veces es más claro que una función recorra el árbol con una comprobación del tipo, pero en un solo lugar.
Compensa cuando las partes y los todos están anidados unos en otros en tantos niveles como vengan: cajas, carpetas, categorías de un catálogo, menús con submenús, elementos de una página.
Ejercicio
El recibo
Resuelve la tarea en tu editor.
El recibo como árbol. Añade un método Print(string indent) al producto y a la caja, y también a la interfaz. El producto imprime su nombre y su precio. La caja imprime su nombre y su total, y luego llama a Print en cada hijo con dos espacios más. Imprime el pedido de la tarjeta «El total baja hasta el fondo y vuelve»:
Pedido: 24.50 € Vela: 5.00 € Caja navideña: 19.50 € Té: 4.50 € Miel: 6.00 € Cajita: 7.00 € Caramelos: 3.50 € Chocolate: 2.50 €Más tareas
- Cuántos productos. Añade
Count(): un producto vale 1, y una caja es el total de sus hijos. El embalaje no es un producto. El pedido tiene 5. - Carpetas y archivos. Haz un archivo con nombre y tamaño en KB, y una carpeta con archivos y carpetas. Imprime el tamaño de cada carpeta. ¿Qué tiene en común con las cajas?
- El producto más caro. Encuentra el precio del producto más caro de todo el árbol. Piensa qué devuelve una caja vacía.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Excepciones · Listas · record, switch y patrones · Patrones de diseño
- Continúa en: Command
- Ejercicios sobre el tema: Patrones de diseño