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
El método que crece con cada promoción
El precio en la tienda depende de la promoción:
static double finalPrice(double price, String promo) { if (promo.equals("estudiante")) { return price * 0.9; } else if (promo.equals("black friday")) { return Math.max(price - 20, 0); } else if (promo.equals("cliente fiel")) { return price * 0.95; } return price;}System.out.printf("%.2f €%n", finalPrice(120, "estudiante"));System.out.printf("%.2f €%n", finalPrice(120, "black friday"));System.out.printf("%.2f €%n", finalPrice(120, "ninguno"));108.00 €100.00 €120.00 €else if en este método, y a menudo en dos más: el de la factura y el del correo de confirmación. El método conoce todas las promociones a la vez, y cada cambio lo 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
apply:interface DiscountStrategy { double apply(double price);}
class Checkout { private DiscountStrategy discount;
public Checkout(DiscountStrategy discount) { this.discount = discount; }
public void setDiscount(DiscountStrategy discount) { this.discount = discount; }
public double total(double price) { return Math.max(discount.apply(price), 0); }}Checkout checkout = new Checkout(price -> price);System.out.printf("Sin descuento: %.2f €%n", checkout.total(120));
checkout.setDiscount(price -> price * 0.9);System.out.printf("Estudiante: %.2f €%n", checkout.total(120));
checkout.setDiscount(price -> price - 20);System.out.printf("Black Friday: %.2f €%n", checkout.total(120));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, con setDiscount.Esto es Strategy: una familia de reglas, cada una detrás de la misma interfaz, así que son intercambiables. El Comparator 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
Antes de las lambdas, cada estrategia era una clase aparte, y la clase que la usa se llama en los libros «contexto» (aquí Checkout):
class StudentDiscount implements DiscountStrategy { @Override public double apply(double price) { return price * 0.9; }}Checkout checkout = new Checkout(new StudentDiscount());System.out.printf("Estudiante: %.2f €%n", checkout.total(120));Estudiante: 108.00 €Una clase es mejor opción cuando la estrategia tiene sus propios campos o es larga. Una lambda, cuando es una línea.
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.
interface StockListener { void stockChanged(String product, int quantity);}
class Product { private String name; private int quantity; private List<StockListener> listeners = new ArrayList<>();
public Product(String name, int quantity) { this.name = name; this.quantity = quantity; }
public void subscribe(StockListener listener) { listeners.add(listener); }
public void setQuantity(int quantity) { if (quantity == this.quantity) { return; } this.quantity = quantity; for (StockListener listener : listeners) { listener.stockChanged(name, quantity); } }}Product phone = new Product("Teléfono", 0);phone.subscribe((name, qty) -> System.out.println("Sitio web: " + name + ", " + qty + " en stock"));phone.subscribe((name, qty) -> { if (qty < 3) { System.out.println("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
subscribe. - Cuando hay un cambio, el producto recorre la lista y avisa a cada suscriptor. Quiénes son y qué hacen no es asunto suyo.
- Si la cantidad no cambió, no se molesta a nadie.
Product phone = new Product("Teléfono", 0);phone.subscribe((name, qty) -> System.out.println("Sitio web: " + name + ", " + qty + " en stock"));phone.subscribe((name, qty) -> { if (qty < 3) { System.out.println("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 lambda entra en la lista.
- Lo mismo hace el almacén. El producto no sabe quiénes son, solo que pueden oír stockChanged.
- 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. Un suscriptor que nadie quita se queda en memoria mientras exista el producto. - 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 la interfaz permite lambdas. - Los oyentes se llaman uno tras otro en el hilo 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?
Product coffee = new Product("Café", 5);coffee.subscribe((name, qty) -> System.out.println("A: " + qty));coffee.setQuantity(4);coffee.setQuantity(4);coffee.subscribe((name, qty) -> System.out.println("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.interface Notification { void send(String to, String text);}
class EmailNotification implements Notification { @Override public void send(String to, String text) { System.out.println("Correo a " + to + ": " + text); }}
class SmsNotification implements Notification { @Override public void send(String to, String text) { System.out.println("SMS a " + to + ": " + text); }}class NotificationFactory { public static Notification create(String channel) { return switch (channel) { case "email" -> new EmailNotification(); case "sms" -> new SmsNotification(); default -> throw new IllegalArgumentException("Canal desconocido: " + channel); }; }}String[][] customers = { { "email", "maria@example.com" }, { "sms", "+34 612 345 678" }, { "paloma", "Calle Mayor 5" },};for (String[] c : customers) { try { Notification n = NotificationFactory.create(c[0]); n.send(c[1], "Tu pedido ha sido enviado."); } catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }}Correo a maria@example.com: Tu pedido ha sido enviado.SMS a +34 612 345 678: Tu pedido ha sido enviado.Canal desconocido: palomaNotification. Solo la fábrica sabe exactamente qué clase hay detrás. Un canal nuevo, por ejemplo push, es una clase nueva y una línea nueva 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 abstracto que crea el objeto, y las subclases deciden de qué tipo:
abstract class Shop { protected abstract Notification createNotification();
public void confirmOrder(String to) { createNotification().send(to, "Se recibió el pedido."); }}
class OnlineShop extends Shop { @Override protected Notification createNotification() { return new EmailNotification(); }}
class PhoneShop extends Shop { @Override protected Notification 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.
- En los ejemplos antiguos, la fábrica devuelve
nullpara un tipo desconocido. Entonces quien llama revienta con unaNullPointerExceptionlejos de la causa real. Es mejor una excepción con un mensaje claro.
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 { private static AppConfig instance; private String currency = "EUR";
private AppConfig() { System.out.println("Leyendo la configuración..."); }
public static AppConfig getInstance() { if (instance == null) { instance = new AppConfig(); } return instance; }
public String getCurrency() { return currency; }}AppConfig a = AppConfig.getInstance();AppConfig b = AppConfig.getInstance();System.out.println(a == b);System.out.println(b.getCurrency());Leyendo la configuración...trueEUR- El constructor es
private. Nadie fuera de la clase puede escribirnew AppConfig(). - El único objeto está en el campo estático
instance.getInstance()lo crea en la primera llamada y después siempre devuelve ese. - «Leyendo la configuración...» se imprime una vez, y
a == bestrue: hay un solo objeto.
Esto es Singleton.
Más precisamente, para curiosos
- Si dos hilos llaman a
getInstance()en el mismo momento, se pueden crear dos objetos. Los hilos están en el módulo «Programas en el mundo real». - El Singleton más seguro en Java es un
enumcon un solo valor. Unenumes un tipo con objetos enumerados de antemano. Java crea cada uno exactamente una vez y no permitenew:
enum Settings { INSTANCE;
private String currency = "EUR";
public String getCurrency() { return currency; }}System.out.println(Settings.INSTANCE.getCurrency());System.out.println(Settings.INSTANCE == Settings.INSTANCE);EURtrue- 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 en
mainy pasarlo por el constructor 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:
System.out.println(OrderNumbers.getInstance().nextNumber());System.out.println(OrderNumbers.getInstance().nextNumber());System.out.println(new OrderNumbers().nextNumber());100110021001¿En qué línea de la clase está el error?
Elige la línea con el error.
public. Entonces cualquiera puede escribir new OrderNumbers() y obtener un contador nuevo que vuelve a empezar en 1001. Un Singleton se protege con una sola línea: private OrderNumbers(). Con ella, la tercera línea de main ni siquiera compila.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. public int charge(long cents, String reference) { System.out.println("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.interface PaymentGateway { boolean pay(String orderId, double amountEur);}
class BankAdapter implements PaymentGateway { private LegacyBankClient bank;
public BankAdapter(LegacyBankClient bank) { this.bank = bank; }
@Override public boolean pay(String orderId, double amountEur) { long cents = Math.round(amountEur * 100); return bank.charge(cents, "PEDIDO-" + orderId) == 0; }}PaymentGateway gateway = new BankAdapter(new LegacyBankClient());System.out.println(gateway.pay("1001", 49.90));System.out.println(gateway.pay("1002", 1299.00));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. 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. Una interfaz DiscountStrategy con métodos estáticos que devuelven una lambda (una interfaz puede tener métodos static con cuerpo, igual que los default): none(), percent(double p) y fixed(double amount) (nunca por debajo de 0 €). Una clase Cart con una lista de precios y un descuento reemplazable. 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 interface TemperatureSensor { double celsius(); }. Escribe ThermometerAdapter (°C = (°F − 32) × 5 / 9). En una lista de sensores, pon dos termómetros antiguos (71.6 °F y 39.2 °F) a través del adaptador, y un sensor nuevo como una lambda que devuelve 21.5 °C.Oficina: 22.0 °CAlmacén: 4.0 °CSala de servidores: 21.5 °CMás tareas
- El envío como estrategia. Crea una
ShippingStrategycon un precio para envío a una oficina, a un domicilio y para recoger en la tienda, y ponla en la caja junto con el descuento. - Un registro como Singleton. Una clase
LoggercongetInstance()y un métodolog(String)que numera las líneas. Comprueba que los números continúan cuando lo llamas desde dos métodos distintos. - 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: Listas · Clases anidadas · Lambdas · record, switch y patrones
- Continúa en: Por qué patrones · Strategy · Observer · Factory Method y Simple Factory
- Ejercicios sobre el tema: Java moderno