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 virtual decimal Cost() => 2.00m; public virtual string Description() => "Espresso";}
class EspressoWithMilk : Espresso{ public override decimal Cost() => base.Cost() + 0.40m; public override string Description() => base.Description() + ", leche";}
class EspressoWithMilkAndSyrup : EspressoWithMilk{ public override decimal Cost() => base.Cost() + 0.60m; public override string Description() => base.Description() + ", sirope";}Espresso drink = new Espresso();Console.WriteLine($"{drink.Description()}: {drink.Cost():F2} €");drink = new EspressoWithMilk();Console.WriteLine($"{drink.Description()}: {drink.Cost():F2} €");drink = new EspressoWithMilkAndSyrup();Console.WriteLine($"{drink.Description()}: {drink.Cost():F2} €");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 IDrink{ decimal Cost(); string Description();}El espresso y dos extras. Cada extra recibe la bebida interior en el constructor:
class Espresso : IDrink{ public decimal Cost() => 2.00m; public string Description() => "Espresso";}
class Milk : IDrink{ private readonly IDrink inner;
public Milk(IDrink inner) { this.inner = inner; }
public decimal Cost() => inner.Cost() + 0.40m; public string Description() => inner.Description() + ", leche";}
class Syrup : IDrink{ private readonly IDrink inner;
public Syrup(IDrink inner) { this.inner = inner; }
public decimal Cost() => inner.Cost() + 0.60m; public string Description() => 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.
IDrink drink = new Espresso();drink = new Milk(drink);drink = new Syrup(drink);drink = new Syrup(drink);Console.WriteLine($"{drink.Description()}: {drink.Cost():F2} €");Espresso, leche, sirope, sirope: 3.60 €IDrink drink = new Espresso();drink = new Milk(drink);drink = new Syrup(drink);drink = new Syrup(drink);Console.WriteLine($"{drink.Description()}: {drink.Cost():F2} €");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 IDrink.
- 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 : IDrink{ private readonly IDrink inner;
public ExtraShot(IDrink inner) { this.inner = inner; }
public decimal Cost() => inner.Cost() + 0.80m; public string Description() => inner.Description() + ", dosis extra";}
class Discount : IDrink{ private readonly IDrink inner;
public Discount(IDrink inner) { this.inner = inner; }
public decimal Cost() => inner.Cost() * 0.9m; public string Description() => inner.Description() + ", 10 % de descuento";}IDrink order = new Discount(new ExtraShot(new Milk(new Espresso())));Console.WriteLine($"{order.Description()}: {order.Cost():F2} €");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:
IDrink drink = new Milk("Espresso");error CS1503: Argument 1: cannot convert from 'string' to 'IDrink'El constructor quiere un IDrink, 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 IOrderService{ int Place(string drink, int quantity);}
class CafeOrderService : IOrderService{ private int next = 1001;
public int Place(string drink, int quantity) { Console.WriteLine($"Preparando: {quantity} × {drink}"); return next++; }}El envoltorio tiene la misma interfaz, registra y llama al servicio real:
class LoggingOrderService : IOrderService{ private readonly IOrderService inner;
public LoggingOrderService(IOrderService inner) { this.inner = inner; }
public int Place(string drink, int quantity) { Console.WriteLine($"[log] Place({drink}, {quantity})"); int number = inner.Place(drink, quantity); Console.WriteLine($"[log] Place devolvió {number}"); return number; }}IOrderService 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 IOrderService 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
.NET tiene decoradores en su propia biblioteca: GZipStream y BufferedStream envuelven otro Stream y lo usan desde dentro. En ASP.NET Core cada middleware recibe la petición, hace su parte y llama al siguiente, y un DelegatingHandler envuelve el manejador por el que HttpClient envía sus peticiones.
Pruébalo
¿Qué saldrá?
El descuento y el sirope son los mismos en ambos casos, solo que en otro orden. ¿Qué imprime el programa?
IDrink a = new Discount(new Syrup(new Espresso()));IDrink b = new Syrup(new Discount(new Espresso()));Console.WriteLine($"{a.Cost():F2} €");Console.WriteLine($"{b.Cost():F2} €");Pruébalo
Encuentra el error
Alguien reescribe el sirope:
class Syrup : IDrink{ private readonly IDrink inner;
public Syrup(IDrink inner) { this.inner = inner; }
public decimal Cost() => 0.60m; public string Description() => inner.Description() + ", sirope";}IDrink drink = new Syrup(new Milk(new Espresso()));Console.WriteLine($"{drink.Description()}: {drink.Cost():F2} €");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: => inner.Cost() + 0.60m;. 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 y delegados · Patrones de diseño
- Continúa en: La tienda: qué patrón dónde · Caché
- Ejercicios sobre el tema: Patrones de diseño