Decorator
En resumen: Decorator envuelve un objeto en otro con la misma forma y añade comportamiento sin tocar la clase; en JavaScript una función envuelve otra 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 { cost() { return 2; }
description() { return "Espresso"; }}
class EspressoWithMilk extends Espresso { cost() { return super.cost() + 0.4; }
description() { return `${super.description()}, leche`; }}
class EspressoWithMilkAndSyrup extends EspressoWithMilk { cost() { return super.cost() + 0.6; }
description() { return `${super.description()}, sirope`; }}let drink = new Espresso();console.log(`${drink.description()}: ${drink.cost().toFixed(2)} €`);drink = new EspressoWithMilk();console.log(`${drink.description()}: ${drink.cost().toFixed(2)} €`);drink = new EspressoWithMilkAndSyrup();console.log(`${drink.description()}: ${drink.cost().toFixed(2)} €`);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: los métodoscost() 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()ydescription(); - 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 { cost() { return 2; }
description() { return "Espresso"; }}
class Milk { constructor(inner) { this.inner = inner; }
cost() { return this.inner.cost() + 0.4; }
description() { return `${this.inner.description()}, leche`; }}
class Syrup { constructor(inner) { this.inner = inner; }
cost() { return this.inner.cost() + 0.6; }
description() { return `${this.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.
let drink = new Espresso();drink = new Milk(drink);drink = new Syrup(drink);drink = new Syrup(drink);console.log(`${drink.description()}: ${drink.cost().toFixed(2)} €`);Espresso, leche, sirope, sirope: 3.60 €let drink = new Espresso();drink = new Milk(drink);drink = new Syrup(drink);drink = new Syrup(drink);console.log(`${drink.description()}: ${drink.cost().toFixed(2)} €`);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.
- 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 this.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 { constructor(inner) { this.inner = inner; }
cost() { return this.inner.cost() + 0.8; }
description() { return `${this.inner.description()}, dosis extra`; }}
class Discount { constructor(inner) { this.inner = inner; }
cost() { return this.inner.cost() * 0.9; }
description() { return `${this.inner.description()}, 10 % de descuento`; }}const order = new Discount(new ExtraShot(new Milk(new Espresso())));console.log(`${order.description()}: ${order.cost().toFixed(2)} €`);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.
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:
const drink = new Milk("Espresso");console.log(drink.cost());TypeError: this.inner.cost is not a function 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 JavaScript una función es un valor, así que el envoltorio es una función que toma una función y devuelve una función nueva. logged(fn) devuelve una función flecha que registra, llama a fn con los mismos argumentos (...args) y devuelve su resultado:
function logged(fn) { return (...args) => { console.log(`[log] ${fn.name}(${args.join(", ")})`); const result = fn(...args); console.log(`[log] ${fn.name} devolvió ${result}`); return result; };}
let next = 1001;
const place = logged(function place(drink, quantity) { console.log(`Preparando: ${quantity} × ${drink}`); return next++;});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);[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
Para las clases hay una propuesta de sintaxis @decorator, que TypeScript ya admite. Node 22 todavía no la tiene, así que aquí el envoltorio es una simple llamada a una función. El middleware de Express también es un envoltorio: cada uno recibe la petición, hace su parte y llama al siguiente.
Pruébalo
¿Qué saldrá?
El descuento y el sirope son los mismos en ambos casos, solo que en otro orden. ¿Qué imprime el programa?
const a = new Discount(new Syrup(new Espresso()));const b = new Syrup(new Discount(new Espresso()));console.log(`${a.cost().toFixed(2)} €`);console.log(`${b.cost().toFixed(2)} €`);Pruébalo
Encuentra el error
Alguien reescribe el sirope:
class Syrup { constructor(inner) { this.inner = inner; }
cost() { return 0.6; }
description() { return `${this.inner.description()}, sirope`; }}const drink = new Syrup(new Milk(new Espresso()));console.log(`${drink.description()}: ${drink.cost().toFixed(2)} €`);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 this.inner.cost() + 0.6;. 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;
- 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.
Capuchino, nata montada, nata montada: 3.80 €Más tareas
- Cuánto tarda. Escribe
timed(fn), que mida el tiempo conperformance.now()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
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Interfaces sin interface · Excepciones · Funciones flecha · Patrones de diseño
- Continúa en: La tienda: qué patrón dónde · Caché
- Ejercicios sobre el tema: Patrones de diseño