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í:
from dataclasses import dataclass
@dataclassclass Product: name: str price: float
@dataclassclass Box: name: str packaging: float items: listEl total se calcula comprobando qué es cada pieza: un producto o una caja. Para una caja hay un segundo bucle:
def total(order): result = 0 for item in order: if isinstance(item, Product): result += item.price elif isinstance(item, Box): result += item.packaging for inner in item.items: if isinstance(inner, Product): result += inner.price return resultorder = [ Product("Vela", 5.00), Box("Caja navideña", 2.00, [ Product("Té", 4.50), Product("Miel", 6.00), Box("Cajita", 1.00, [ Product("Caramelos", 3.50), Product("Chocolate", 2.50)])])]print(f"Total: {total(order):.2f} €")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 el mismo métodototal(). La caja guarda una lista de piezas y no pregunta qué son: llama a total() en cada una. No hace falta una interfaz, mientras cada pieza tenga este método.Este es el patrón Composite. Tiene tres papeles:
- la forma común: el método
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
El producto y la caja. El producto es una dataclass, y la caja recibe cualquier cantidad de piezas (*items):
@dataclassclass Product: name: str price: float
def total(self): return self.price
class Box: def __init__(self, name, packaging, *items): self.name = name self.packaging = packaging self.items = list(items)
def total(self): return self.packaging + sum(item.total() for item in self.items)El árbol se escribe como constructores anidados, una pieza por línea:
order = Box("Pedido", 0, Product("Vela", 5.00), Box("Caja navideña", 2.00, Product("Té", 4.50), Product("Miel", 6.00), Box("Cajita", 1.00, Product("Caramelos", 3.50), Product("Chocolate", 2.50))))print(f"Total: {order.total():.2f} €")Total: 24.50 €order = Box("Pedido", 0, Product("Vela", 5.00), Box("Caja navideña", 2.00, Product("Té", 4.50), Product("Miel", 6.00), Box("Cajita", 1.00, Product("Caramelos", 3.50), Product("Chocolate", 2.50))))print(f"Total: {order.total():.2f} €")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:
@dataclassclass WeighedProduct: name: str per_kg: float kg: float
def total(self): return self.per_kg * self.kgcoffee = Box("Caja de café", 1.00, WeighedProduct("Café en grano", 24.00, 0.25), Product("Taza", 8.00))print(f"Caja de café: {coffee.total():.2f} €")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.
Aquí la caja acepta cualquier cosa. Un texto en lugar de un producto entra sin error, y el programa se detiene solo al calcular el total:
box = Box("Caja", 1.00, "Té")print(box.total())AttributeError: 'str' object has no attribute 'total'Eso sigue siendo mejor que el problema, donde una pieza así se saltaba en silencio: aquí el error aparece en cuanto alguien pide el total.
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.
typing.Protocol describe «algo con un método total()», y herramientas como mypy comprueban la caja antes de que el programa arranque. Python mismo no lo comprueba al ejecutar.Pruébalo
Predice la salida
La caja contiene una caja vacía y una vela. ¿Qué imprime el programa?
box = Box("Caja", 1.00, Box("Vacía", 0.50), Product("Vela", 5.00))print(f"Caja: {box.total():.2f} €")Pruébalo
Encuentra el error
Alguien reescribe la caja para hacerla «más segura»:
class Box: def __init__(self, name, packaging, *items): self.name = name self.packaging = packaging self.items = list(items)
def total(self): return self.packaging + sum( item.total() for item in self.items if isinstance(item, Product) )gift = Box("Caja navideña", 2.00, Product("Té", 4.50), Product("Miel", 6.00), Box("Cajita", 1.00, Product("Caramelos", 3.50), Product("Chocolate", 2.50)))print(f"Caja navideña: {gift.total():.2f} €")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(self, indent="") al producto y a la caja. 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: append, remove y sort · dataclass, match y patrones · Patrones de diseño
- Continúa en: Command
- Ejercicios sobre el tema: Patrones de diseño