Ir al contenido
wannacode

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} €");
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.

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 interfaz Drink (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} €");
Salida
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
  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, que también es un IDrink.
  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 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 : 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} €");
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 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
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);
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 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} €");
Respuesta

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} €");
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;
  • 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.

Salida
Capuchino, nata montada, nata montada: 3.80 €

Más tareas

  • Cuánto tarda. Escribe un TimingOrderService que mida el tiempo de cada place e 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

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. LoggingOrderService envuelve a CafeOrderService. ¿Qué cambia en CafeOrderService 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 ·