Ir al contenido
wannacode

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)} €`);
Salida
108.00 €
100.00 €
120.00 €
Funciona. Pero marketing inventa una promoción nueva cada mes. Cada una es un nuevo 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

Un patrón de diseño es una solución probada para un problema que aparece en muchos programas. No es una biblioteca ni código para copiar. Es una forma de organizar las clases y las funciones que ya pasó por miles de proyectos.

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
El catálogo clásico es el libro «Design Patterns» de 1994, de cuatro autores conocidos como la «Banda de los Cuatro» (Gang of Four, GoF). Tiene 23 patrones, escritos para lenguajes como C++ y Java. Aquí están los cinco con los que más te vas a encontrar. En JavaScript algunos se acortan, porque una función se puede pasar como valor: donde Java necesita una interfaz y una clase, aquí basta una función. Un patrón no es un objetivo: si un if hace el trabajo, no necesitas un patrón.

Míralo

Strategy: una regla reemplazable

El descuento pasa a ser una función que la caja guarda en un campo. La caja no sabe qué promoción es, solo llama a la función:
class Checkout {
constructor(discount) {
this.discount = discount;
}
total(price) {
return Math.max(this.discount(price), 0);
}
}
Cada descuento es una función flecha:
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)} €`);
Salida
Sin descuento: 120.00 €
Estudiante: 108.00 €
Black Friday: 100.00 €
Una promoción nueva es una función flecha nueva. 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)} €`);
Salida
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);
Salida
Sitio web: Teléfono, 10 en stock
Sitio web: Teléfono, 2 en stock
Almacén: reponer Teléfono
  • Quien quiere enterarse se suscribe con subscribe y 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
  1. Teléfono con 0 en stock y una lista listeners vacía.
  2. El sitio web se suscribe: la función flecha entra en la lista.
  3. Lo mismo hace el almacén. El producto no sabe quiénes son, solo que puede llamarlos con un nombre y una cantidad.
  4. 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.
  5. El almacén comprueba 10 < 3: no, así que no hace nada.
  6. 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.
  7. 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 unsubscribe que 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");
Salida
Sitio web: 4 en stock
El sitio web ya no escucha
  • En la lección antigua, la clase observada se llama Subject, con los métodos attach y notifyAllObservers, y el observador es una clase abstracta con un método update(). 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);
Respuesta

Míralo

Factory Method: qué clase crear

El cliente eligió cómo lo avisamos: por correo o por SMS. El código que envía no debería escribir 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}`);
}
}
La elección está en un solo lugar, en una fábrica. Es una función normal: en Java es un método estático en una clase solo porque allí todo está en una clase.
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);
}
}
Salida
Correo a maria@example.com: Tu pedido ha sido enviado.
SMS a +34 612 345 678: Tu pedido ha sido enviado.
Canal desconocido: paloma
Quien llama dice qué quiere («email») y recibe un objeto que sabe hacer send. 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");
Salida
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, devuelve undefined. Entonces el error llega recién en send, 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}`);
}
Salida
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);
Salida
Leyendo la configuración...
true
EUR
  • JavaScript no tiene constructor private: cualquiera puede escribir new 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 con return. Cuando un constructor devuelve un objeto, new te da ese, no el nuevo.
  • «Leyendo la configuración...» se imprime una vez, y a === b es true: 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.freeze protege sus campos:
const settings = Object.freeze({
currency: "EUR",
});
console.log(settings.currency);
console.log(Object.isFrozen(settings));
Salida
EUR
true
  • 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());
Salida
1001
1002
1001

¿En qué línea de la clase está el error?

Elige la línea con el error.

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));
Salida
Banco: 4990 céntimos por PEDIDO-1001
true
Banco: 129900 céntimos por PEDIDO-1002
false
El adaptador traduce en los dos sentidos: euros a céntimos y un número a texto a la ida, el código 0 a true 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 €.

Salida
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.

Salida
Correo a María: Consola está en stock (5 unidades)
SMS a Jorge: Consola está en stock
Fin

3. 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.

Entrada
4
Email;maria@example.com
sms;+34 612 345 678
push;el teléfono de Elena
fax;91 123 45 67
Salida
Correo 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: fax
4. Un termómetro antiguo. Una clase OldThermometer 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 }.
Salida
Oficina: 22.0 °C
Almacén: 4.0 °C
Sala de servidores: 21.5 °C

Má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 Logger cuyo constructor devuelve el único objeto, y un método log(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

1. ¿Qué es un patrón de diseño?
2. Cada nueva promoción de la tienda agrega un nuevo else if a la función del precio. ¿Qué patrón ayuda?
3. El producto avisa al sitio web, al almacén y al sistema de correo cuando cambia el stock, sin saber quiénes son. ¿Qué patrón es?
4. El constructor del Singleton tiene if (OrderNumbers.instance !== null) return OrderNumbers.instance;, y al final this.instance = this;. ¿Qué pasa en el segundo new OrderNumbers()?
5. Tienes una clase antigua que devuelve la temperatura en Fahrenheit, y el programa espera un sensor con un método celsius(). ¿Qué haces?

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.

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·