Patrones de diseño
En resumen: Un patrón de diseño es una solución probada para un problema que aparece en muchos programas, por ejemplo Strategy, Observer, Factory Method, Singleton y Adapter.
Problema
La función que crece con cada promoción
El precio en la tienda depende de la promoción:
function finalPrice(price, promo) { if (promo === "estudiante") { return price * 0.9; } else if (promo === "black friday") { return Math.max(price - 20, 0); } else if (promo === "cliente fiel") { return price * 0.95; } return price;}console.log(`${finalPrice(120, "estudiante").toFixed(2)} €`);console.log(`${finalPrice(120, "black friday").toFixed(2)} €`);console.log(`${finalPrice(120, "ninguno").toFixed(2)} €`);108.00 €100.00 €120.00 €else if en esta función, y a menudo en dos más: la de la factura y la del correo de confirmación. La función conoce todas las promociones a la vez, y cada cambio la toca. Este problema es tan común que su solución tiene nombre.La idea
Patrones de diseño
Los patrones se dividen en tres grupos:
- Creacionales: cómo se crean los objetos. Por ejemplo Singleton (un solo objeto de la clase) y Factory Method (otra cosa decide qué clase crear).
- Estructurales: cómo se juntan clases y objetos en otros más grandes. Por ejemplo Adapter (conecta interfaces incompatibles) y Composite (un árbol de partes con el que trabajas como con un todo).
- De comportamiento: cómo se reparten el trabajo los objetos y cómo se hablan. Por ejemplo Strategy (una regla reemplazable) y Observer (suscriptores a un evento).
Por qué vale la pena conocerlos:
- no reinventas una solución que otros ya probaron;
- el código cambia con menos riesgo, porque la parte que cambia está separada;
- los nombres son un idioma común. «Aquí pon un adaptador» le dice a un colega toda una estructura en una palabra.
Más precisamente, para curiosos
if hace el trabajo, no necesitas un patrón.Míralo
Strategy: una regla reemplazable
class Checkout { constructor(discount) { this.discount = discount; }
total(price) { return Math.max(this.discount(price), 0); }}const checkout = new Checkout((price) => price);console.log(`Sin descuento: ${checkout.total(120).toFixed(2)} €`);
checkout.discount = (price) => price * 0.9;console.log(`Estudiante: ${checkout.total(120).toFixed(2)} €`);
checkout.discount = (price) => price - 20;console.log(`Black Friday: ${checkout.total(120).toFixed(2)} €`);Sin descuento: 120.00 €Estudiante: 108.00 €Black Friday: 100.00 €Checkout no cambia. La regla también se puede cambiar mientras el programa se ejecuta: guardas una función nueva en checkout.discount.Esto es Strategy: una familia de reglas, cada una con los mismos parámetros y el mismo resultado, así que son intercambiables. La función de comparación que pasas a sort también es una estrategia: sort sabe cómo ordenar, y tú dices por qué ordenar.Más precisamente, para curiosos
En los libros, la clase que usa la estrategia se llama «contexto» (aquí Checkout). Si la estrategia tiene sus propios datos, como el porcentaje, es una función que crea una función. La función flecha devuelta recuerda percent incluso después de que percentDiscount terminó:
function percentDiscount(percent) { return (price) => price * (1 - percent / 100);}const checkout = new Checkout(percentDiscount(10));console.log(`Estudiante: ${checkout.total(120).toFixed(2)} €`);Estudiante: 108.00 €Míralo
Observer: suscriptores a un evento
Cuando cambia el stock de un producto, el sitio web tiene que mostrar la nueva cantidad, y el almacén tiene que reponer si quedan pocos. Mañana puede ser también un correo a los clientes. El producto no debería saber de ninguno de ellos.
class Product { constructor(name, quantity) { this.name = name; this.quantity = quantity; this.listeners = []; }
subscribe(listener) { this.listeners.push(listener); }
setQuantity(quantity) { if (quantity === this.quantity) { return; } this.quantity = quantity; for (const listener of this.listeners) { listener(this.name, quantity); } }}const phone = new Product("Teléfono", 0);phone.subscribe((name, qty) => console.log(`Sitio web: ${name}, ${qty} en stock`));phone.subscribe((name, qty) => { if (qty < 3) { console.log(`Almacén: reponer ${name}`); }});
phone.setQuantity(10);phone.setQuantity(2);Sitio web: Teléfono, 10 en stockSitio web: Teléfono, 2 en stockAlmacén: reponer Teléfono- Quien quiere enterarse se suscribe con
subscribey pasa una función. - Cuando hay un cambio, el producto recorre la lista y llama a cada función con el nombre y la cantidad. Quiénes son los suscriptores y qué hacen no es asunto suyo.
- Si la cantidad no cambió, no se molesta a nadie.
const phone = new Product("Teléfono", 0);phone.subscribe((name, qty) => console.log(`Sitio web: ${name}, ${qty} en stock`));phone.subscribe((name, qty) => { if (qty < 3) { console.log(`Almacén: reponer ${name}`); }});
phone.setQuantity(10);phone.setQuantity(2);Paso a paso, en palabras
- Teléfono con 0 en stock y una lista listeners vacía.
- El sitio web se suscribe: la función flecha entra en la lista.
- Lo mismo hace el almacén. El producto no sabe quiénes son, solo que puede llamarlos con un nombre y una cantidad.
- El stock pasa a ser 10. El producto recorre la lista y llama a cada suscriptor en el orden en que se suscribió. El sitio web muestra la nueva cantidad.
- El almacén comprueba 10 < 3: no, así que no hace nada.
- El stock pasa a ser 2. El producto recorre la lista y llama a cada suscriptor en el orden en que se suscribió. El sitio web muestra la nueva cantidad.
- El almacén comprueba 2 < 3: sí, así que repone.
Esto es Observer. El producto es el objeto observado (el sujeto), y los oyentes son los observadores. Los botones de los programas gráficos funcionan exactamente así: «cuando lo pulsen, llama a esto».
Más precisamente, para curiosos
- También hace falta un
unsubscribeque quite al oyente de la lista. Quitas la misma función que suscribiste, así que la guardas en una variable. Un suscriptor que nadie quita se queda en memoria mientras exista el producto.
const phone = new Product("Teléfono", 5);const site = (name, qty) => console.log(`Sitio web: ${qty} en stock`);phone.subscribe(site);phone.setQuantity(4);phone.listeners = phone.listeners.filter((l) => l !== site);phone.setQuantity(3);console.log("El sitio web ya no escucha");Sitio web: 4 en stockEl sitio web ya no escucha- En la lección antigua, la clase observada se llama
Subject, con los métodosattachynotifyAllObservers, y el observador es una clase abstracta con un métodoupdate(). La idea es la misma, y con funciones no necesitas una clase para cada observador. - Los oyentes se llaman uno tras otro, dentro del método que cambió la cantidad. Si uno es lento, todos esperan.
Pruébalo
¿Quién lo oirá?
Product es el de la tarjeta anterior.
¿Qué imprimirá el programa?
const coffee = new Product("Café", 5);coffee.subscribe((name, qty) => console.log(`A: ${qty}`));coffee.setQuantity(4);coffee.setQuantity(4);coffee.subscribe((name, qty) => console.log(`B: ${qty}`));coffee.setQuantity(0);setQuantity(4) avisa a A. El segundo no es un cambio, y nadie oye nada. B se suscribe después y no se entera de los cambios pasados. Con 0 lo oyen los dos, en el orden en que se suscribieron.Míralo
Factory Method: qué clase crear
new EmailNotification() en diez lugares, cada uno con su propio if. JavaScript no tiene interfaces: una notificación es cualquier objeto con un método send.// Una notificación es cualquier objeto con un método send(to, text).class EmailNotification { send(to, text) { console.log(`Correo a ${to}: ${text}`); }}
class SmsNotification { send(to, text) { console.log(`SMS a ${to}: ${text}`); }}function createNotification(channel) { switch (channel) { case "email": return new EmailNotification(); case "sms": return new SmsNotification(); default: throw new Error(`Canal desconocido: ${channel}`); }}const customers = [ ["email", "maria@example.com"], ["sms", "+34 612 345 678"], ["paloma", "Calle Mayor 5"],];for (const [channel, to] of customers) { try { const n = createNotification(channel); n.send(to, "Tu pedido ha sido enviado."); } catch (e) { console.log(e.message); }}Correo a maria@example.com: Tu pedido ha sido enviado.SMS a +34 612 345 678: Tu pedido ha sido enviado.Canal desconocido: palomasend. Solo la fábrica sabe exactamente qué clase hay detrás. Un canal nuevo, por ejemplo push, es una clase nueva y un case nuevo en el switch. El envío no se toca.Más precisamente, para curiosos
- Esta es la forma más común en la práctica. Su nombre más preciso es «fábrica simple». El Factory Method del libro GoF es otra cosa: la clase padre tiene un método que crea el objeto, y las subclases deciden de qué tipo. JavaScript no tiene
abstract, así que el método de la clase padre lanza un error: si la subclase no lo escribe, el error aparece en la primera llamada:
class Shop { createNotification() { throw new Error("createNotification() se tiene que escribir en la subclase"); }
confirmOrder(to) { this.createNotification().send(to, "Se recibió el pedido."); }}
class OnlineShop extends Shop { createNotification() { return new EmailNotification(); }}
class PhoneShop extends Shop { createNotification() { return new SmsNotification(); }}new OnlineShop().confirmOrder("maria@example.com");new PhoneShop().confirmOrder("+34 612 345 678");Correo a maria@example.com: Se recibió el pedido.SMS a +34 612 345 678: Se recibió el pedido.confirmOrder se escribe una sola vez, en Shop, y no sabe qué notificación envía.
- Si la fábrica no llega a un
return, devuelveundefined. Entonces el error llega recién ensend, lejos de la causa real:
function createNotification(channel) { if (channel === "email") { return new EmailNotification(); } if (channel === "sms") { return new SmsNotification(); }}
const n = createNotification("paloma");try { n.send("Calle Mayor 5", "Tu pedido ha sido enviado.");} catch (e) { console.log(`${e.name}: ${e.message}`);}TypeError: Cannot read properties of undefined (reading 'send')Es mejor una excepción con un mensaje claro en la fábrica.
Míralo
Singleton: un solo objeto
La configuración de la aplicación, como la moneda, se lee una vez. Si cada parte del programa crea su propio objeto de configuración, se lee muchas veces y las copias pueden dejar de coincidir.
class AppConfig { static instance = null;
constructor() { if (AppConfig.instance !== null) { return AppConfig.instance; } console.log("Leyendo la configuración..."); this.currency = "EUR"; AppConfig.instance = this; }}const a = new AppConfig();const b = new AppConfig();console.log(a === b);console.log(b.currency);Leyendo la configuración...trueEUR- JavaScript no tiene constructor
private: cualquiera puede escribirnew AppConfig(). Por eso la protección está en el propio constructor. - El único objeto está en el campo estático
instance. Si ya hay un objeto, el constructor lo devuelve conreturn. Cuando un constructor devuelve un objeto,newte da ese, no el nuevo. - «Leyendo la configuración...» se imprime una vez, y
a === bestrue: hay un solo objeto.
Esto es Singleton.
Más precisamente, para curiosos
- El Singleton más simple en JavaScript es un objeto normal. No hay clase de la que crear un segundo, y
Object.freezeprotege sus campos:
const settings = Object.freeze({ currency: "EUR",});console.log(settings.currency);console.log(Object.isFrozen(settings));EURtrue- En JavaScript es justo esto lo que se hace más a menudo: el objeto se crea una vez, en un módulo, y las demás partes lo traen con
import. - Un Singleton es una variable global para todo el programa con un nombre bonito. Cualquiera puede usarlo desde cualquier lugar, así que es difícil de probar y difícil de reemplazar. A menudo es mejor crear el objeto una vez al inicio del programa y pasarlo como parámetro a quienes lo usan.
Pruébalo
Encuentra el error
Los números de pedido tienen que ser únicos, así que el contador es un Singleton. Pero el programa dio dos veces el número 1001:
const numbers = new OrderNumbers();console.log(numbers.nextNumber());console.log(numbers.nextNumber());console.log(new OrderNumbers().nextNumber());100110021001¿En qué línea de la clase está el error?
Elige la línea con el error.
this.instance = this; guarda el objeto en un campo del propio objeto, no de la clase. OrderNumbers.instance sigue siendo null, la comprobación de la línea 5 nunca es verdadera, y cada new OrderNumbers() crea un contador nuevo desde 1001. La corrección es OrderNumbers.instance = this;.Míralo
Adapter: conecta interfaces incompatibles
La tienda paga a través de una interfaz con un importe en euros y una respuesta «sí o no». El banco te da una biblioteca antigua que quiere céntimos y devuelve un código de error. La biblioteca no es tuya, y no puedes cambiarla.
// La biblioteca antigua del banco. No puedes cambiarla.class LegacyBankClient { // El importe va en céntimos. Devuelve 0 si todo sale bien y un código de error si no. charge(cents, reference) { console.log(`Banco: ${cents} céntimos por ${reference}`); return cents <= 50000 ? 0 : 51; }}Escribes una clase que se parece a lo que espera la tienda, y por dentro llama al banco:
// Esto es lo que espera la tienda: un objeto con un método pay(orderId, amountEur) que devuelve true o false.class BankAdapter { constructor(bank) { this.bank = bank; }
pay(orderId, amountEur) { const cents = Math.round(amountEur * 100); return this.bank.charge(cents, `PEDIDO-${orderId}`) === 0; }}const gateway = new BankAdapter(new LegacyBankClient());console.log(gateway.pay("1001", 49.9));console.log(gateway.pay("1002", 1299));Banco: 4990 céntimos por PEDIDO-1001trueBanco: 129900 céntimos por PEDIDO-1002falsetrue a la vuelta. La tienda no sabe que detrás hay una biblioteca antigua: lo único que le importa es que el objeto tenga pay. Si mañana cambias de banco, escribes un adaptador nuevo, y la tienda no se toca.Esto es Adapter: como un adaptador de enchufe, que no cambia ni el enchufe ni la pared.
Más precisamente, para curiosos
El ejemplo clásico de los libros, y de la lección antigua, es un reproductor multimedia. AudioPlayer solo sabe reproducir mp3, y una clase existente AdvancedMediaPlayer tiene los métodos playVlc y playMp4. MediaAdapter implementa la interfaz del reproductor y reenvía al método correcto. La estructura es la misma que aquí.
Ejercicio
Patrones en la tienda
Resuelve las tareas en tu editor.
1. Descuentos como estrategias. Tres funciones que devuelven una función flecha: none(), percent(p) y fixed(amount) (nunca por debajo de 0 €). Una clase Cart con un array de precios y un descuento reemplazable en el campo discount. Agrega café en grano (18,50 €), miel (12 €) y té (4,20 €), e imprime el total sin descuento, con −10 %, con −5 € y con −50 €.
Sin descuento: 34.70 €-10%: 31.23 €-5 €: 29.70 €-50 €: 0.00 €2. «Avísame cuando haya stock». El producto guarda una lista de clientes que esperan. Cuando el stock pasa de 0 a más de 0, los avisa a todos y vacía la lista, porque el aviso es de una sola vez. La consola tiene 0 en stock, María y Jorge se suscriben, y después el stock pasa a 5, 0 y 3.
Correo a María: Consola está en stock (5 unidades)SMS a Jorge: Consola está en stockFin3. Una fábrica de notificaciones. Agrega PushNotification y una fábrica para los tres canales que no distinga mayúsculas de minúsculas. Lee un número de líneas y después líneas «canal;dirección». Envía «Tu reserva está confirmada.» a cada dirección, y para un canal desconocido imprime el mensaje de la excepción.
4Email;maria@example.comsms;+34 612 345 678push;el teléfono de Elenafax;91 123 45 67Correo a maria@example.com: Tu reserva está confirmada.SMS a +34 612 345 678: Tu reserva está confirmada.Push a el teléfono de Elena: Tu reserva está confirmada.Canal desconocido: faxOldThermometer devuelve la temperatura en Fahrenheit con readFahrenheit(). El sistema nuevo trabaja con sensores: objetos con un método celsius(). Escribe ThermometerAdapter (°C = (°F − 32) × 5 / 9). En un array de sensores, pon dos termómetros antiguos (71.6 °F y 39.2 °F) a través del adaptador, y un sensor nuevo como el objeto { celsius: () => 21.5 }.Oficina: 22.0 °CAlmacén: 4.0 °CSala de servidores: 21.5 °CMás tareas
- El envío como estrategia. Crea funciones para el precio de envío a una oficina, a un domicilio y desde la tienda, y pon la elegida en la caja junto con el descuento.
- Un registro como Singleton. Una clase
Loggercuyo constructor devuelve el único objeto, y un métodolog(text)que numera las líneas. Comprueba que los números continúan cuando lo llamas desde dos funciones distintas. - Un observador de precios. Agrega al producto suscriptores a los cambios de precio, y envía un aviso solo cuando el precio baje por lo menos un 10 %.
Test
Ponte a prueba
La idea
Lo siguiente: los patrones de cerca
Aquí viste cinco patrones de forma breve, con lambdas. El curso de Ingeniería de software tiene un módulo entero sobre patrones: cada uno empieza con código que duele, tiene una animación de qué objeto llama a cuál y dice cuándo no lo necesitas. También están Builder, Decorator, Composite, Command, State, Template Method, Iterator y Repository.
El módulo está aquí (por ahora, en inglés): Patrones de diseño.Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Arrays: push, splice y sort · Clases anidadas y datos · Funciones flecha · Datos, switch y desestructuración
- Continúa en: Por qué patrones · Strategy · Observer · Factory Method y Simple Factory
- Ejercicios sobre el tema: JavaScript moderno