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 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());
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 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());
Salida
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
  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 Drink.
  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 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());
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:

Drink drink = new Milk("Espresso");
Error
error: incompatible types: String cannot be converted to Drink

El 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);
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 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());
Respuesta

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());
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 ·