Decorator
En resumen: Decorator envuelve un objeto en otro objeto con la misma interfaz y añade comportamiento sin cambiar la clase y sin una clase para cada combinación.
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 { public double cost() { return 2.00; }
public String description() { return "Espresso"; }}
class EspressoWithMilk extends Espresso { @Override public double cost() { return super.cost() + 0.40; }
@Override public String description() { return super.description() + ", leche"; }}
class EspressoWithMilkAndSyrup extends EspressoWithMilk { @Override public double cost() { return super.cost() + 0.60; }
@Override public String description() { return super.description() + ", sirope"; }}Espresso drink = new Espresso();System.out.printf("%s: %.2f €%n", drink.description(), drink.cost());drink = new EspressoWithMilk();System.out.printf("%s: %.2f €%n", drink.description(), drink.cost());drink = new EspressoWithMilkAndSyrup();System.out.printf("%s: %.2f €%n", drink.description(), drink.cost());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.
EspressoWithSyrup (sin leche) no puede heredar el sirope de EspressoWithMilkAndSyrup, porque una clase tiene un solo padre. El código del sirope se copia, y cuando el sirope sube de precio, cambia en varios sitios.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: la interfazDrink (IDrink en C#) con un precio y una descripción. El espresso la implementa y conoce su propio precio. La leche también la implementa, pero guarda en un campo otra bebida, que es un Drink. Cuando le preguntan el precio, se lo pregunta a la bebida interior y suma 0,40 €.Este es el patrón Decorator. Tiene tres roles:
- la forma común: la interfaz que tienen tanto las bebidas como los envoltorios;
- 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
La forma común:
interface Drink { double cost(); String description();}El espresso y dos extras. Cada extra recibe la bebida interior en el constructor:
class Espresso implements Drink { @Override public double cost() { return 2.00; }
@Override public String description() { return "Espresso"; }}
class Milk implements Drink { private final Drink inner;
public Milk(Drink inner) { this.inner = inner; }
@Override public double cost() { return inner.cost() + 0.40; }
@Override public String description() { return inner.description() + ", leche"; }}
class Syrup implements Drink { private final Drink inner;
public Syrup(Drink inner) { this.inner = inner; }
@Override public double cost() { return inner.cost() + 0.60; }
@Override public String description() { return 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 drink = new Espresso();drink = new Milk(drink);drink = new Syrup(drink);drink = new Syrup(drink);System.out.printf("%s: %.2f €%n", drink.description(), drink.cost());Espresso, leche, sirope, sirope: 3.60 €Drink drink = new Espresso();drink = new Milk(drink);drink = new Syrup(drink);drink = new Syrup(drink);System.out.printf("%s: %.2f €%n", drink.description(), drink.cost());Paso a paso, en palabras
- El espresso es la base. No envuelve nada y conoce su precio: 2.00.
- La leche envuelve el espresso y lo guarda en el campo inner. drink ahora apunta a la leche, que también es un Drink.
- El sirope envuelve la leche. La leche y el espresso de dentro no se tocan.
- Sirope otra vez: una segunda capa de la misma clase. Eso es el sirope doble.
- cost() llega a la capa exterior. Todavía no suma nada: primero llama a inner.cost(), y así hace cada capa hacia dentro.
- El espresso responde 2.00. Al salir, cada capa suma su parte a lo que viene de dentro.
- 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 implements Drink { private final Drink inner;
public ExtraShot(Drink inner) { this.inner = inner; }
@Override public double cost() { return inner.cost() + 0.80; }
@Override public String description() { return inner.description() + ", dosis extra"; }}
class Discount implements Drink { private final Drink inner;
public Discount(Drink inner) { this.inner = inner; }
@Override public double cost() { return inner.cost() * 0.9; }
@Override public String description() { return inner.description() + ", 10 % de descuento"; }}Drink order = new Discount(new ExtraShot(new Milk(new Espresso())));System.out.printf("%s: %.2f €%n", order.description(), order.cost());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 new Discount(new ExtraShot(new Milk(new 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.
Un envoltorio alrededor de algo que no es una bebida no compila:
Drink drink = new Milk("Espresso");error: incompatible types: String cannot be converted to DrinkEl constructor quiere un Drink, y el compilador se detiene antes del primer pedido.
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.
El servicio está detrás de una interfaz:
interface OrderService { int place(String drink, int quantity);}
class CafeOrderService implements OrderService { private int next = 1001;
@Override public int place(String drink, int quantity) { System.out.println("Preparando: " + quantity + " × " + drink); return next++; }}El envoltorio tiene la misma interfaz, registra y llama al servicio real:
class LoggingOrderService implements OrderService { private final OrderService inner;
public LoggingOrderService(OrderService inner) { this.inner = inner; }
@Override public int place(String drink, int quantity) { System.out.println("[log] place(" + drink + ", " + quantity + ")"); int number = inner.place(drink, quantity); System.out.println("[log] place devolvió " + number); return number; }}OrderService orders = new LoggingOrderService(new CafeOrderService());orders.place("Capuchino", 2);orders.place("Espresso", 1);[log] place(Capuchino, 2)Preparando: 2 × Capuchino[log] place devolvió 1001[log] place(Espresso, 1)Preparando: 1 × Espresso[log] place devolvió 1002El código que toma los pedidos recibe un OrderService y no sabe que está envuelto. Quitas los logs pasando el servicio sin 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
Java tiene decoradores en su biblioteca estándar: new BufferedReader(new InputStreamReader(System.in)) es un flujo de entrada envuelto en un flujo que lee línea por línea. Spring pone envoltorios alrededor de tus clases para añadir transacciones o una caché (@Transactional, @Cacheable).
Pruébalo
¿Qué saldrá?
El descuento y el sirope son los mismos en ambos casos, solo que en otro orden. ¿Qué imprime el programa?
Drink a = new Discount(new Syrup(new Espresso()));Drink b = new Syrup(new Discount(new Espresso()));System.out.printf("%.2f €%n", a.cost());System.out.printf("%.2f €%n", b.cost());Pruébalo
Encuentra el error
Alguien reescribe el sirope:
class Syrup implements Drink { private final Drink inner;
public Syrup(Drink inner) { this.inner = inner; }
@Override public double cost() { return 0.60; }
@Override public String description() { return inner.description() + ", sirope"; }}Drink drink = new Syrup(new Milk(new Espresso()));System.out.printf("%s: %.2f €%n", drink.description(), drink.cost());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.
El sirope devuelve solo su propio precio y no le pregunta a la bebida interior. Todo lo que hay debajo (el espresso y la leche) desaparece de la suma, sin ningún error. La descripción es correcta, porque description() sí le pregunta a la bebida interior, y justo por eso el error pasa fácilmente inadvertido. Un decorador tiene que llamar al mismo método del objeto que envuelve: return inner.cost() + 0.60;. Entonces la suma es 3 €.
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;
- la interfaz envuelta tiene veinte métodos. Cada envoltorio tiene que reenviar los veinte, aunque cambie solo uno.
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.
Capuchino, nata montada, nata montada: 3.80 €Más tareas
- Cuánto tarda. Escribe un
TimingOrderServiceque mida el tiempo de cadaplacee imprima cuántos milisegundos tardó. Ú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
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Interfaces · Excepciones · Lambdas · Patrones de diseño
- Continúa en: La tienda: qué patrón dónde · Caché
- Ejercicios sobre el tema: Patrones de diseño