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

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"));
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 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

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 interfaces 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. Aquí están los cinco con los que más te vas a encontrar en código Java. 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 interfaz. La caja no sabe qué promoción es, solo llama a 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);
}
}
La interfaz es funcional, así que cada descuento es una lambda:
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));
Salida
Sin descuento: 120.00 €
Estudiante: 108.00 €
Black Friday: 100.00 €
Una promoción nueva es una lambda nueva. 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));
Salida
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);
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.
  • 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
  1. Teléfono con 0 en stock y una lista listeners vacía.
  2. El sitio web se suscribe: la lambda entra en la lista.
  3. Lo mismo hace el almacén. El producto no sabe quiénes son, solo que pueden oír stockChanged.
  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. 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étodos attach y notifyAllObservers, y el observador es una clase abstracta con un método update(). 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);
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.
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);
}
}
La elección está en un solo lugar, en una fábrica:
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());
}
}
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 una Notification. 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");
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.

  • En los ejemplos antiguos, la fábrica devuelve null para un tipo desconocido. Entonces quien llama revienta con una NullPointerException lejos 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());
Salida
Leyendo la configuración...
true
EUR
  • El constructor es private. Nadie fuera de la clase puede escribir new 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 == b es true: 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 enum con un solo valor. Un enum es un tipo con objetos enumerados de antemano. Java crea cada uno exactamente una vez y no permite new:
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);
Salida
EUR
true
  • 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 main y 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());
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.
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));
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. 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 €.

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 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.
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 una ShippingStrategy con 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 Logger con getInstance() y un método log(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

1. ¿Qué es un patrón de diseño?
2. Cada nueva promoción de la tienda agrega un nuevo else if al método 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. ¿Qué impide Singleton?
5. Tienes una clase antigua que devuelve la temperatura en Fahrenheit, y el programa espera una interfaz con grados 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 ·