Ir al contenido
wannacode

Decorator

En resumen: Decorator envuelve un objeto en otro con la misma forma y añade comportamiento sin tocar la clase; en Python un @decorator envuelve una función de la misma manera.

Problema

La carta que crece con cada extra

La cafetería vende espresso por 2 €. El cliente puede añadir leche (+0,40 €), sirope (+0,60 €) y una dosis extra (+0,80 €). Alguien escribe una clase para cada combinación:

class Espresso:
def cost(self):
return 2.00
def description(self):
return "Espresso"
class EspressoWithMilk(Espresso):
def cost(self):
return super().cost() + 0.40
def description(self):
return super().description() + ", leche"
class EspressoWithMilkAndSyrup(EspressoWithMilk):
def cost(self):
return super().cost() + 0.60
def description(self):
return super().description() + ", sirope"
drink = Espresso()
print(f"{drink.description()}: {drink.cost():.2f} €")
drink = EspressoWithMilk()
print(f"{drink.description()}: {drink.cost():.2f} €")
drink = EspressoWithMilkAndSyrup()
print(f"{drink.description()}: {drink.cost():.2f} €")
Salida
Espresso: 2.00 €
Espresso, leche: 2.40 €
Espresso, leche, sirope: 3.00 €

Funciona para las tres bebidas de la carta. Pero tres extras dan ocho combinaciones, y con capuchino y té son 24 clases. Un extra más duplica la cantidad.

Python permite una clase con varios padres, pero eso no ayuda: sigues necesitando una clase para cada combinación, y el código del sirope vive en varias clases.

Y el cliente quiere sirope dos veces. Esa clase no existe. Una clase para uno, dos y tres siropes no es una solución.

La idea

Decorator: un envoltorio con la misma forma

Un extra no es un tipo de bebida. Es algo que envuelve una bebida: toma la bebida y añade su propia parte.

Por eso todos comparten una forma: los métodos cost() y description(). El espresso los tiene y conoce su propio precio. La leche también los tiene, pero guarda en un campo otra bebida. Cuando le preguntan el precio, se lo pregunta a la bebida interior y suma 0,40 €. No hace falta una interfaz: cualquier objeto con estos dos métodos se puede envolver.

Este es el patrón Decorator. Tiene tres roles:

  • la forma común: los métodos cost() y description();
  • la base: el espresso, que no envuelve nada;
  • los decoradores: leche, sirope, la dosis extra. Cada uno guarda una bebida interior, llama al mismo método sobre ella y añade su propia parte.

Como el envoltorio tiene la misma forma que la bebida, un envoltorio puede envolver otro envoltorio. El sirope doble son dos capas de sirope. El código que pregunta el precio no sabe cuántas capas hay.

Míralo

Capa por capa

El espresso y dos extras. Cada extra recibe la bebida interior en el constructor:

class Espresso:
def cost(self):
return 2.00
def description(self):
return "Espresso"
class Milk:
def __init__(self, inner):
self.inner = inner
def cost(self):
return self.inner.cost() + 0.40
def description(self):
return self.inner.description() + ", leche"
class Syrup:
def __init__(self, inner):
self.inner = inner
def cost(self):
return self.inner.cost() + 0.60
def description(self):
return self.inner.description() + ", sirope"

El pedido se arma de dentro hacia fuera: primero el espresso, y luego cada capa envuelve lo que hay hasta ese momento.

drink = Espresso()
drink = Milk(drink)
drink = Syrup(drink)
drink = Syrup(drink)
print(f"{drink.description()}: {drink.cost():.2f} €")
Salida
Espresso, leche, sirope, sirope: 3.60 €

drink = Espresso()
drink = Milk(drink)
drink = Syrup(drink)
drink = Syrup(drink)
print(f"{drink.description()}: {drink.cost():.2f} €")
Paso a paso, en palabras
  1. El espresso es la base. No envuelve nada y conoce su precio: 2.00.
  2. La leche envuelve el espresso y lo guarda en el campo inner. drink ahora apunta a la leche.
  3. El sirope envuelve la leche. La leche y el espresso de dentro no se tocan.
  4. Sirope otra vez: una segunda capa de la misma clase. Eso es el sirope doble.
  5. cost() llega a la capa exterior. Todavía no suma nada: primero llama a self.inner.cost(), y así hace cada capa hacia dentro.
  6. El espresso responde 2.00. Al salir, cada capa suma su parte a lo que viene de dentro.
  7. De la capa exterior sale 3.60. description() sigue el mismo camino y reúne las palabras.

La llamada a cost() entra capa por capa hasta el espresso, y la suma crece al salir. Ninguna de las tres clases sabe de las otras.

Míralo

Una capa nueva sin tocar las viejas

La dosis extra y el descuento de la hora feliz son dos clases nuevas. El descuento no suma, multiplica por 0,9 todo lo que hay dentro:

class ExtraShot:
def __init__(self, inner):
self.inner = inner
def cost(self):
return self.inner.cost() + 0.80
def description(self):
return self.inner.description() + ", dosis extra"
class Discount:
def __init__(self, inner):
self.inner = inner
def cost(self):
return self.inner.cost() * 0.9
def description(self):
return self.inner.description() + ", 10 % de descuento"
order = Discount(ExtraShot(Milk(Espresso())))
print(f"{order.description()}: {order.cost():.2f} €")
Salida
Espresso, leche, dosis extra, 10 % de descuento: 2.88 €

Espresso, Milk y Syrup no cambiaron. La capa nueva va alrededor de cualquier bebida, incluso de una que ya está envuelta.

Lees Discount(ExtraShot(Milk(Espresso()))) de dentro hacia fuera: un espresso, envuelto en leche, envuelto en una dosis, envuelto en un descuento. El orden importa: el descuento se aplica solo a lo que tiene dentro.

Aquí nadie comprueba qué se envuelve. Un envoltorio alrededor de un trozo de texto se crea sin error, y el programa se detiene solo cuando alguien pregunta el precio:

drink = Milk("Espresso")
print(drink.cost())
Error
AttributeError: 'str' object has no attribute 'cost'. Did you mean: 'count'?

Python incluso sugiere count, porque el texto tiene un método con ese nombre. El error señala la línea de Milk, no la línea donde se armó el pedido equivocado. Por eso cada envoltorio nuevo se ejecuta al menos una vez con una bebida real.

Míralo

Un envoltorio alrededor de un método

La misma idea funciona también fuera del café. El servicio de pedidos funciona, y ahora cada llamada tiene que ser visible: qué se pidió y qué volvió. El código del servicio no debe tocarse: está probado, y los logs son temporales.

En Python esto hace falta tan a menudo que el lenguaje tiene una sintaxis para ello: @decorator. Un decorador de funciones es una función que toma una función y devuelve una función nueva, un envoltorio a su alrededor. @logged encima de def place significa place = logged(place):

import functools
import itertools
numbers = itertools.count(1001)
def logged(func):
@functools.wraps(func)
def wrapper(*args):
print(f"[log] {func.__name__}({', '.join(map(str, args))})")
result = func(*args)
print(f"[log] {func.__name__} devolvió {result}")
return result
return wrapper
@logged
def place(drink, quantity):
print(f"Preparando: {quantity} × {drink}")
return next(numbers)

wrapper recibe los argumentos que lleguen (*args), registra, llama a la función real y devuelve su resultado. Es el mismo patrón que la leche alrededor del espresso, solo que la capa va alrededor de una función.

place("Capuchino", 2)
place("Espresso", 1)
Salida
[log] place(Capuchino, 2)
Preparando: 2 × Capuchino
[log] place devolvió 1001
[log] place(Espresso, 1)
Preparando: 1 × Espresso
[log] place devolvió 1002

El código que llama a place no sabe que está envuelto. Quitas los logs quitando el envoltorio. Lo mismo vale para medir el tiempo, para una caché o para un reintento tras un error: cada uno es una capa aparte.

Más precisamente, para curiosos

@functools.wraps(func) copia el nombre y la descripción de la función original al envoltorio:

print("El nombre de la función: " + place.__name__)
Salida
El nombre de la función: place

Sin él, place.__name__ es wrapper, y los errores y los logs muestran el nombre del envoltorio en lugar del de la función real. Hay muchos decoradores integrados: @functools.cache guarda los resultados que ya se calcularon, @property permite leer un método como si fuera un campo.

Pruébalo

¿Qué saldrá?

El descuento y el sirope son los mismos en ambos casos, solo que en otro orden. ¿Qué imprime el programa?

a = Discount(Syrup(Espresso()))
b = Syrup(Discount(Espresso()))
print(f"{a.cost():.2f} €")
print(f"{b.cost():.2f} €")
Respuesta

Pruébalo

Encuentra el error

Alguien reescribe el sirope:

class Syrup:
def __init__(self, inner):
self.inner = inner
def cost(self):
return 0.60
def description(self):
return self.inner.description() + ", sirope"
drink = Syrup(Milk(Espresso()))
print(f"{drink.description()}: {drink.cost():.2f} €")
Salida
Espresso, leche, sirope: 0.60 €

La descripción es correcta, y la suma es 0,60 €. ¿En qué línea está el error?

Elige la línea con el error.

La idea

Cuándo no lo necesitas

Decorator tiene un coste: una clase por cada capa, y el orden es un objeto dentro de un objeto dentro de un objeto. No compensa cuando:

  • cada extra solo añade un precio y un nombre. Entonces los extras son datos: una lista de pares «nombre, precio», y la suma es el precio de la base más el total de la lista. Un sirope nuevo es una fila nueva en la carta, no una clase nueva;
  • hay una sola capa y nunca se quita. Su código puede vivir en la propia clase;
  • envuelves un objeto con muchos métodos y cambias uno. Cada envoltorio tiene que reenviar también los demás, o faltan.

Cuidado también con las pilas profundas: seis capas son seis líneas en un stack trace, y su orden solo se ve donde se arman.

Compensa cuando las capas hacen cosas distintas (un descuento, registro, una caché, un reintento), se arman de otra manera según el caso y no quieres tocar lo que envuelves.

Ejercicio

Capuchino con nata

Resuelve la tarea en tu editor.

Capuchino con nata. La cafetería añade un capuchino por 2,80 € y nata montada por 0,50 €. Escribe las clases Cappuccino y WhippedCream y pide un capuchino con nata dos veces. Las clases viejas no se tocan.

Salida
Capuchino, nata montada, nata montada: 3.80 €

Más tareas

  • Cuánto tarda. Escribe un decorador @timed que mida el tiempo con time.perf_counter() e imprima cuántos milisegundos tardó la llamada. Úsalo junto con el registro. ¿Qué capa va por fuera, y por qué importa?
  • Una taza grande. Una taza grande cuesta un 30 % más. Añade una capa Large. Ponla una vez alrededor del espresso y otra alrededor de todo el pedido con leche y sirope. ¿Cuál es justa con el cliente?
  • Un contador. Un envoltorio alrededor del servicio cuenta cuántos pedidos pasaron e imprime la cuenta al final. Piensa dónde vive el contador: en el envoltorio o en el servicio.

Test

Ponte a prueba

1. Un cliente quiere un espresso con leche y sirope dos veces. ¿Qué escribes con Decorator?
2. ¿Qué hace Milk cuando le piden cost()?
3. place se envuelve con @logged (en JavaScript: logged(function place…)). ¿Qué cambia en el cuerpo de place para tener logs?
4. ¿Cuándo no necesitas Decorator?

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