Ir al contenido
wannacode

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
@dataclass
class Product:
name: str
price: float
@dataclass
class Box:
name: str
packaging: float
items: list

El 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 result
order = [
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} €")
Salida
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étodo total(). 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):

@dataclass
class 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} €")
Salida
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
  1. El árbol se arma: cada artículo está en su propia línea, y cada caja contiene a sus hijos.
  2. 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.
  3. Vela es un producto: responde con su precio, 5.00, y la respuesta vuelve hacia arriba.
  4. Caja navideña es una caja: hace exactamente lo mismo, primero el embalaje y luego un hijo tras otro.
  5. Cajita está en el tercer nivel. Aquí no desaparece nada: les pregunta a sus propios hijos.
  6. Cajita: 1.00 + 3.50 + 2.50 = 7.00. Su total sube a su padre.
  7. Caja navideña: 2.00 + 4.50 + 6.00 + 7.00 = 19.50.
  8. 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:

@dataclass
class WeighedProduct:
name: str
per_kg: float
kg: float
def total(self):
return self.per_kg * self.kg
coffee = 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} €")
Salida
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())
Error
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.

Si quieres que la forma se compruebe sin herencia, 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} €")
Respuesta

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} €")
Salida
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 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»:

Salida
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

1. La tienda empieza a vender café por kilo. ¿Qué escribes con Composite?
2. ¿Cuántos niveles puede tener el árbol para que el total sea correcto?
3. Una caja suma su embalaje y los precios de solo los productos que están directamente dentro. ¿Qué pasa con una caja dentro de la caja?
4. ¿Cuándo no necesitas Composite?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·