Apuntes · Capítulo 14Diseño

Patrones de diseño

Patrones de diseño

La semana pasada estudiamos principios: alta cohesión, bajo acoplamiento y SOLID. Los principios dicen cómo se ve un buen diseño, pero no dicen qué escribir cuando uno tiene un problema concreto delante. Esta semana vemos el paso siguiente: soluciones con nombre a problemas de diseño que aparecen una y otra vez. Se llaman patrones de diseño (design patterns).

Estudiamos cuatro patrones, dos por sesión. En la sesión A, Strategy y Observer, los dos sobre cómo un objeto usa a otros sin conocer su clase concreta. En la sesión B, Factory, sobre dónde se decide qué objeto crear, y Composite, sobre cómo tratar igual a un objeto y a un grupo de objetos. De cada patrón veremos cuatro cosas: el problema que resuelve, su estructura, un ejemplo en Java y, tan importante como lo demás, cuándo no conviene usarlo.

Lectura. Eck no trata los patrones de diseño como tema. Las lecturas de la semana son el catálogo libre de Refactoring.Guru: Strategy, Observer, Factory Method y Composite (lean la intención, el problema, la estructura y la aplicabilidad; sus ejemplos son otros que los nuestros). De Eck, la subsección 9.4.3 Expression Trees muestra un árbol de nodos con una operación común, que es la estructura de Composite. El código del capítulo está en code/week-14 y se prueba con make test dentro del contenedor del curso.

Ruta de lectura.

  • Antes de clase (obligatorio, 15–20 min): Qué es un patrón y qué no y El problema: un switch que crece.
  • En clase: en la sesión A, el resto de Strategy y Observer: avisar sin conocer a quién, hasta Los detalles que importan; en la sesión B, Factory: decidir qué crear en un solo lugar y Composite: una parte y un todo que se tratan igual.
  • Después de clase: Probar un subject con un spy, las secciones Cuándo no usar… de cada patrón, Operaciones nuevas con un switch sobre el tipo sealed, Patrones juntos, el Resumen y los Ejercicios. Para ampliar, las lecturas de la nota Lectura.

Qué es un patrón y qué no

La idea viene de la arquitectura. En los años setenta, Christopher Alexander describió soluciones que se repiten en casas y ciudades (una ventana en cada lado de una habitación, un lugar para sentarse junto a la entrada) y les puso nombre, para que la gente pudiera hablar de ellas. En 1994, Erich Gamma, Richard Helm, Ralph Johnson y John Vlissides publicaron Design Patterns, con 23 patrones para programas orientados a objetos. Cuatro de ellos son los de esta semana.

Un patrón tiene tres partes:

  • Un problema que se repite. No un problema de un programa, sino uno que aparece en muchos: “tengo varias formas de calcular algo y quiero elegir una mientras el programa corre”; “varios objetos tienen que enterarse cuando otro cambia”.
  • Una solución con nombre. Una forma de repartir responsabilidades entre clases e interfaces, con un nombre corto que todos entienden. Decir “aquí usé Observer” ahorra un párrafo de explicación.
  • Consecuencias. Lo que se gana y lo que cuesta. Todo patrón agrega clases e indirección: para entender qué pasa hay que seguir una llamada más.

Idea clave. Un patrón es vocabulario compartido, no una receta para copiar. Se usa cuando el problema está presente, y se reconoce por el problema, no por los nombres de las clases. Un programa no es mejor por tener más patrones.

En las semanas anteriores ya usaron varios sin saber su nombre. Esta semana los nombramos, los escribimos de forma explícita y aprendemos a decidir.

Strategy: elegir un algoritmo mientras el programa corre

El problema: un switch que crece

Una tienda en línea cobra el envío de varias formas: una tarifa plana, una tarifa por peso, o gratis si la compra pasa de cierto monto. La primera versión, ShippingSwitch.java, pone todas las tarifas en un método y elige con un switch sobre un texto:

static Money cost(String kind, Parcel parcel) {
    return switch (kind) {
        case "flat" -> Money.of(1000, "PEN");
        case "weight" -> {
            long kilos = (parcel.grams() + 999) / 1000;
            yield Money.of(500 + 300 * kilos, "PEN");
        }
        case "free-above" -> parcel.subtotal().isLessThan(Money.of(15000, "PEN"))
                ? cost("weight", parcel)
                : Money.zero();
        default -> throw new IllegalArgumentException("tarifa desconocida: " + kind);
    };
}

(Money guarda un monto en céntimos, como la tarjeta de la semana 1; lo vemos completo en la sesión B. Parcel es un record con el peso en gramos y el subtotal de la compra.)

Funciona, pero tiene tres problemas que la semana pasada aprendimos a reconocer:

  • Cerrado a la extensión. Una tarifa nueva (por distrito, por volumen) obliga a editar este método, que ya funciona y ya tiene pruebas. Es justo lo que prohíbe el principio abierto/cerrado.
  • Los números están enterrados. Los S/ 5 de base y S/ 3 por kilo están escritos dentro del case. Dos tiendas con tarifas distintas necesitarían dos copias del método.
  • Un texto mal escrito ("wieght") se descubre recién al ejecutar.

La solución: una interfaz y una clase por algoritmo

Strategy encapsula cada algoritmo en su propia clase, detrás de una interfaz común. El objeto que lo usa, el context, guarda una referencia a la interfaz y no sabe cuál de las clases concretas tiene.

ShippingRate.java es la strategy: una interfaz con un solo método.

public interface ShippingRate {
    Money cost(Parcel parcel);
}

Cada tarifa es una clase que la implementa. Como no tienen más estado que sus parámetros, las escribimos como records (FlatRate, ByWeight, FreeAbove):

public record FlatRate(Money amount) implements ShippingRate {
    @Override
    public Money cost(Parcel parcel) {
        return amount;
    }
}

public record ByWeight(Money base, Money perKilo) implements ShippingRate {
    @Override
    public Money cost(Parcel parcel) {
        long kilos = (parcel.grams() + 999) / 1000;     // 1 g a 1000 g cuenta como 1 kilo
        return base.plus(perKilo.times(kilos));
    }
}

public record FreeAbove(Money threshold, ShippingRate otherwise) implements ShippingRate {
    @Override
    public Money cost(Parcel parcel) {
        if (parcel.subtotal().isLessThan(threshold)) {
            return otherwise.cost(parcel);
        }
        return Money.zero();
    }
}

Fíjense en FreeAbove: por debajo del monto mínimo no sabe cobrar, así que delega en otra strategy que recibe en el constructor. Se pueden combinar sin escribir clases nuevas: “gratis desde S/ 150, si no, por peso” es new FreeAbove(cientoCincuenta, new ByWeight(...)).

El context es Checkout.java. Recibe la strategy por el constructor (inversión de dependencias, semana 13) y la usa sin preguntar qué es:

public class Checkout {
    private ShippingRate rate;

    public Checkout(ShippingRate rate) {
        this.rate = rate;
    }

    public void setRate(ShippingRate rate) {
        this.rate = rate;
    }

    public Money total(Parcel parcel) {
        return parcel.subtotal().plus(rate.cost(parcel));
    }
}
Diagrama de clases. Checkout, el context, tiene un campo rate de tipo ShippingRate. ShippingRate es una interfaz con el método cost(Parcel). Tres records la implementan: FlatRate(amount), ByWeight(base, perKilo) y FreeAbove(threshold, otherwise); FreeAbove además tiene una referencia a otra ShippingRate. Una lambda parcel -> Money.zero() aparece como cuarta implementaciónDiagrama de clases. Checkout, el context, tiene un campo rate de tipo ShippingRate. ShippingRate es una interfaz con el método cost(Parcel). Tres records la implementan: FlatRate(amount), ByWeight(base, perKilo) y FreeAbove(threshold, otherwise); FreeAbove además tiene una referencia a otra ShippingRate. Una lambda parcel -> Money.zero() aparece como cuarta implementación
Strategy: el contexto conoce solo la interfaz. Cada algoritmo es una clase (o una lambda) que la implementa, y se cambia sin tocar Checkout.

Ejemplo resuelto: las tres tarifas y una lambda

ShippingDemo.java calcula el envío de dos paquetes con cada tarifa y luego cambia la strategy de un Checkout mientras el programa corre:

Parcel small = new Parcel(800, Money.of(4590, "PEN"));
Parcel heavy = new Parcel(3200, Money.of(18000, "PEN"));
for (ShippingRate rate : rates) {
    IO.println(rate);
    IO.println("  pequeño: " + rate.cost(small) + "   pesado: " + rate.cost(heavy));
}

ShippingRate pickup = parcel -> Money.zero();      // recoger en tienda
Checkout checkout = new Checkout(rates.get(1));
IO.println("total por peso: " + checkout.total(heavy));
checkout.setRate(pickup);
IO.println("total en tienda: " + checkout.total(heavy));
$ java -cp build ShippingDemo
FlatRate[amount=S/ 10.00]
  pequeño: S/ 10.00   pesado: S/ 10.00
ByWeight[base=S/ 5.00, perKilo=S/ 3.00]
  pequeño: S/ 8.00   pesado: S/ 17.00
FreeAbove[threshold=S/ 150.00, otherwise=ByWeight[base=S/ 5.00, perKilo=S/ 3.00]]
  pequeño: S/ 8.00   pesado: S/ 0.00
total por peso: S/ 197.00
total en tienda: S/ 180.00
switch, pesado por peso: S/ 17.00

Revisen una línea: el paquete pesado tiene 3200 g, que son cuatro kilos empezados, así que por peso cuesta 5 + 4 × 3 = S/ 17. El FreeAbove no le cobra porque su subtotal, S/ 180, pasa de S/ 150. La última línea es la versión con switch, que da lo mismo: la prueba strategiesAgreeWithTheOldSwitch de ShippingRateTest.java lo verifica, y es la prueba que permite reemplazar una versión por la otra con confianza.

La tarifa pickup es una lambda. ShippingRate tiene un solo método abstracto, así que es una interfaz funcional (semana 12) y una strategy sencilla no necesita clase propia. Cuando la strategy tiene parámetros que vale la pena ver (toString) o probar por separado, conviene una clase o un record.

Ya lo conocían: Comparator

En las semanas 11 y 12 ordenaron canciones con list.sort(comparador). Eso es Strategy: List.sort es el context, con un algoritmo de ordenamiento que no cambia; lo que cambia es cómo comparar dos elementos, y eso se lo pasan ustedes.

En Java. La biblioteca está llena de strategies: Comparator para sort y TreeMap, el Predicate de removeIf y filter, la Function de map. Cada interfaz funcional que reciben los métodos de las colecciones y de los streams es una strategy que se elige al llamar.

Las reglas de precio y descuento de P2 también son strategies, aunque quizás no las hayan llamado así: una interfaz de política de precio y una clase por promoción.

Strategy o herencia

¿No se podía resolver con polimorfismo, haciendo Checkout abstracta y una subclase por tarifa (FlatCheckout, WeightCheckout)? Se podía, pero:

  • Con herencia, la tarifa queda fija al crear el objeto: un FlatCheckout no puede pasar a cobrar por peso. Con Strategy basta con setRate.
  • Si Checkout tuviera dos cosas que varían (la tarifa de envío y la forma de pago), la herencia necesitaría una subclase por cada combinación. Con dos strategies, cada una varía por su lado.
  • La strategy se prueba sola, sin crear un Checkout.

Es la regla de la semana 7 aplicada: componer en lugar de heredar cuando lo que varía es un comportamiento, no el tipo de objeto.

Cuándo no usar Strategy

Error común. Crear una interfaz, una clase y una factory para un algoritmo que es uno solo y nadie ha pedido cambiar. Una tienda que siempre cobra S/ 10 de envío no necesita ShippingRate: necesita una constante. Strategy se justifica cuando ya hay dos o más algoritmos, o cuando hay un cambio concreto en camino. Si después aparece el segundo, el refactoring de la semana 13 (extraer interfaz, mover el código a una clase) toma diez minutos con las pruebas en su lugar.

Tampoco hace falta cuando las variantes son pocas y conocidas, y no se combinan: un switch sobre un enum de tres valores puede ser más claro que tres clases. La pregunta es la de abierto/cerrado: ¿de qué lado vendrán los cambios?

Observer: avisar sin conocer a quién

El problema: un subject que conoce a todos

Un termómetro vigila la refrigeradora de vacunas de una posta de salud. Las vacunas tienen que estar entre 2 y 8 °C (la “cadena de frío”). Cada lectura tiene que aparecer en una pantalla y, si sale del rango, disparar una alarma. La versión directa sería:

public void record(double celsius) {
    display.show(place, celsius);
    alarm.check(place, celsius);
    // mañana: log.write(...), sms.send(...), dashboard.update(...)
}

El termómetro conoce la clase concreta de cada interesado y su método. Cada interesado nuevo obliga a abrir el termómetro, agregarle un campo y una línea en record. Y no se puede probar el termómetro sin una pantalla y una alarma de verdad. Es acoplamiento alto en la dirección equivocada: el termómetro es lo estable, y depende de lo que cambia.

La solución: una lista de observers

Observer invierte la dependencia. El subject mantiene una lista de observers, que un cliente registra con addListener y, cuando algo cambia, los recorre y les avisa. Del observer, el subject conoce solo una interfaz de un método (TemperatureListener.java):

public interface TemperatureListener {
    void onReading(Reading reading);
}

Thermometer.java, sin los detalles que vemos después:

public class Thermometer implements TemperatureSource {
    private final String place;
    private final List<TemperatureListener> listeners = new ArrayList<>();

    public void addListener(TemperatureListener listener) {
        listeners.add(listener);
    }

    public void removeListener(TemperatureListener listener) {
        listeners.remove(listener);
    }

    public void record(double celsius) {
        Reading reading = new Reading(place, celsius);
        for (TemperatureListener listener : List.copyOf(listeners)) {
            listener.onReading(reading);
        }
    }
}

La pantalla y la alarma son observers (Display.java, ColdChainAlarm.java):

public class ColdChainAlarm implements TemperatureListener {
    private static final double MIN = 2.0;
    private static final double MAX = 8.0;
    private int alerts = 0;

    @Override
    public void onReading(Reading reading) {
        if (reading.celsius() < MIN || reading.celsius() > MAX) {
            alerts++;
            IO.println("ALARMA en " + reading.place() + ": " + reading.celsius() + " °C fuera de 2-8 °C");
        }
    }

    public int alerts() {
        return alerts;
    }
}

Thermometer no menciona Display ni ColdChainAlarm en ninguna parte. Se compila sin ellas, y un observer nuevo no lo toca.

Animación del patrón Observer. A la izquierda, el subject Thermometer con su lista listeners, que guarda tres referencias. A la derecha, tres observers que implementan TemperatureListener: Display, ColdChainAlarm y una lambda. Paso 1: llega record(9.1). Paso 2: el termómetro crea un Reading("refri 1", 9.1). Paso 3: recorre su lista y envía onReading(reading) a cada observer, uno por uno. Paso 4: la pantalla muestra la lectura, la alarma se enciende y la lambda escribe un avisoAnimación del patrón Observer. A la izquierda, el subject Thermometer con su lista listeners, que guarda tres referencias. A la derecha, tres observers que implementan TemperatureListener: Display, ColdChainAlarm y una lambda. Paso 1: llega record(9.1). Paso 2: el termómetro crea un Reading("refri 1", 9.1). Paso 3: recorre su lista y envía onReading(reading) a cada observer, uno por uno. Paso 4: la pantalla muestra la lectura, la alarma se enciende y la lambda escribe un aviso
Observer: el subject guarda referencias de tipo TemperatureListener y avisa a cada una con el mismo mensaje. No sabe qué hace cada observer con él.

Ejemplo resuelto: la refrigeradora de vacunas

ObserverDemo.java registra tres observers, uno de ellos una lambda, y registra tres lecturas:

Thermometer fridge = new Thermometer("refri 1");
ColdChainAlarm alarm = new ColdChainAlarm();
fridge.addListener(new Display());
fridge.addListener(alarm);
fridge.addListener(reading -> {             // un observer también puede ser una lambda
    if (reading.celsius() > 7.5) {
        IO.println("  (aviso: cerca del límite)");
    }
});

fridge.record(4.5);
fridge.record(7.8);
fridge.record(9.1);
IO.println("alarmas: " + alarm.alerts());
$ java -cp build ObserverDemo
[refri 1] 4.5 °C
[refri 1] 7.8 °C
  (aviso: cerca del límite)
[refri 1] 9.1 °C
ALARMA en refri 1: 9.1 °C fuera de 2-8 °C
  (aviso: cerca del límite)
alarmas: 1
...

El orden de las líneas es el orden en que se registraron los observers. A 7,8 °C la alarma no dice nada (está en rango), pero la lambda sí avisa.

Los detalles que importan

El esqueleto de Observer cabe en diez líneas. Lo difícil está en cuatro preguntas que todo subject tiene que responder.

¿Quién registra a quién, y quién se da de baja? Un observer que nadie da de baja sigue recibiendo avisos, y el subject lo mantiene vivo: mientras esté en la lista, el garbage collector no puede liberarlo. Con observers de vida corta (una ventana que se cierra, una venta que termina), olvidar el removeListener es un memory leak en Java.

¿Qué viaja en la notificación? Hay dos estilos. En el estilo push, el subject manda los datos: aquí, un record Reading con el lugar y los grados. En el estilo pull, manda solo “cambié” (o una referencia a sí mismo) y cada observer pregunta lo que necesita. Push es más simple y desacopla más; pull sirve cuando el estado es grande y cada observer mira una parte distinta. Usar un record para el evento permite agregar un campo después (la hora de la lectura) sin cambiar la interfaz.

¿Qué pasa si un observer lanza una excepción? Con el for simple, la excepción sale de record y los observers que faltaban no se enteran. Si la pantalla falla, la alarma no suena, y eso en una cadena de frío es grave. Nuestro termómetro avisa a todos y relanza al final la primera excepción, con las demás como suprimidas (addSuppressed, semana 9):

RuntimeException failure = null;
for (TemperatureListener listener : List.copyOf(listeners)) {
    try {
        listener.onReading(reading);
    } catch (RuntimeException e) {
        if (failure == null) {
            failure = e;
        } else {
            failure.addSuppressed(e);
        }
    }
}
if (failure != null) {
    throw failure;
}

Es una decisión de diseño, no la única correcta: otro subject podría registrar el error y seguir sin lanzar nada. Lo importante es decidirla y probarla (aFailingListenerDoesNotSilenceTheOthers en ThermometerTest.java).

¿Y si un observer se da de baja mientras se le notifica? FirstAlert.java avisa la primera vez que la temperatura pasa un límite y se retira de la lista:

@Override
public void onReading(Reading reading) {
    if (reading.celsius() > limit) {
        IO.println("primer aviso: se pasó de " + limit + " °C; ya no aviso más");
        subject.removeListener(this);
    }
}

Por eso record recorre List.copyOf(listeners) y no la lista misma: la copia no cambia aunque el observer modifique la original. NaiveThermometer.java recorre la lista original, y el final de la demostración muestra lo que pasa:

primer aviso: se pasó de 8.0 °C; ya no aviso más
[refri 2] 8.5 °C
[refri 2] 9.0 °C
primer aviso: se pasó de 8.0 °C; ya no aviso más
ingenuo: java.util.ConcurrentModificationException

Error común. Modificar una lista mientras se la recorre con for-each. El iterador de ArrayList detecta que la lista cambió y lanza ConcurrentModificationException (semana 11), aunque el programa tenga un solo hilo. En Observer pasa sin que se vea en el código del subject: la modificación la hace un observer, desde adentro de onReading. Recorrer una copia lo evita; copiar cuesta poco con listas de pocos observers.

En Java. La biblioteca trajo desde Java 1.0 una clase Observable y una interfaz Observer en java.util. Están marcadas como deprecated desde Java 9: el aviso no dice qué cambió y el orden de las notificaciones no está especificado. Hoy se escribe la interfaz del observer a la medida, como hicimos, o se usa PropertyChangeSupport (addPropertyChangeListener, firePropertyChange), que hace el trabajo del subject para cualquier clase. Las interfaces gráficas usan Observer en todas partes: un botón es un subject y quien escucha sus clics es un observer. Lo retomamos la semana 16.

Probar un subject con un spy

La semana 7 escribieron un spy: un test double que recuerda las llamadas que recibió. Para un subject es la prueba natural: se registra un spy y se mira qué le llegó.

static class RecordingListener implements TemperatureListener {
    final List<Reading> received = new ArrayList<>();

    @Override
    public void onReading(Reading reading) {
        received.add(reading);
    }
}

@Test
void removedListenerHearsNothingMore() {
    Thermometer t = new Thermometer("refri");
    RecordingListener spy = new RecordingListener();
    t.addListener(spy);
    t.record(4.0);
    t.removeListener(spy);
    t.record(9.0);
    assertEquals(1, spy.received.size());
}

La prueba no necesita pantalla ni alarma. Eso es lo que ganó el diseño: el subject se prueba solo porque depende de una interfaz.

Cuándo no usar Observer

  • Hay un solo interesado y siempre será el mismo. Una llamada directa se lee mejor que una lista de un elemento. Observer paga cuando el conjunto de interesados cambia, o cuando el subject no debe depender de ellos (porque están en otra capa del programa).
  • El orden importa. Si la alarma tiene que actuar antes que el registro, depender del orden de registro es frágil. Una secuencia fija de llamadas dice ese orden en voz alta.
  • Cadenas de avisos. Un observer que, al recibir un aviso, cambia otro subject, que avisa a otros observers… Con dos o tres niveles es muy difícil saber por qué pasó algo. El código ya no se lee de arriba abajo, y el stack trace de un error pasa por onReading varias veces.

Factory: decidir qué crear en un solo lugar

El código que crea objetos con new Clase(...) se ata a esa clase concreta. Casi siempre está bien. Los patrones Factory son para los casos en que esa decisión conviene sacarla del código que usa los objetos: porque necesita validar, porque la clase concreta depende de un dato, o porque se repite en varios lugares. Vemos tres formas, de la más simple a la que tiene nombre en el libro de 1994.

Static factory methods

Un static factory method es un método static que devuelve un objeto de su clase. Money no tiene constructor público; se crea con tres de estos métodos (Money.java):

public final class Money {
    private static final Money ZERO_PEN = new Money(0, "PEN");

    private final long cents;
    private final String currency;

    private Money(long cents, String currency) {
        this.cents = cents;
        this.currency = currency;
    }

    public static Money of(long cents, String currency) {
        if (cents < 0) {
            throw new IllegalArgumentException("monto negativo: " + cents);
        }
        String code = currency.strip().toUpperCase();
        if (code.length() != 3) {
            throw new IllegalArgumentException("moneda inválida: " + currency);
        }
        if (cents == 0 && code.equals("PEN")) {
            return ZERO_PEN;
        }
        return new Money(cents, code);
    }

    public static Money parse(String soles) { ... }    // "12.50" -> 1250 céntimos

    public static Money zero() {
        return ZERO_PEN;
    }
    // ...
}

¿Qué gana frente a new Money(cents, currency)?

  • Tiene nombre. Money.parse("12.50") y Money.zero() dicen qué hacen. Dos constructores con la misma lista de tipos de parámetros no pueden existir; dos métodos con nombres distintos, sí.
  • Puede devolver un objeto que ya existe. Money.of(0, "PEN") devuelve siempre el mismo objeto ZERO_PEN. Un constructor siempre crea uno nuevo.
  • Puede validar y normalizar antes de crear. " pen " se vuelve "PEN", y un monto negativo no llega a crear nada.
  • Puede devolver un subtipo, sin que quien llama lo sepa (lo vemos abajo).
$ java -cp build FactoryDemo
S/ 0.00  mismo objeto que zero(): true
S/ 12.50 + 2.50 USD
...

En Java. Integer.valueOf(n) guarda en caché los enteros de −128 a 127 y devuelve el mismo objeto para cada uno. Ese rango es lo mínimo que la API garantiza; una JVM puede guardar más, así que el false de 128 tampoco está garantizado. List.of(...) devuelve clases distintas según cuántos elementos recibe. Ninguna de las dos cosas se puede hacer con un constructor:

jshell> Integer.valueOf(127) == Integer.valueOf(127)
$1 ==> true

jshell> Integer.valueOf(128) == Integer.valueOf(128)
$2 ==> false

jshell> List.of(1, 2).getClass()
$3 ==> class java.util.ImmutableCollections$List12

jshell> List.of(1, 2, 3).getClass()
$4 ==> class java.util.ImmutableCollections$ListN

Por eso los objetos se comparan con equals y no con == (semana 5): lo que dé == con wrappers depende de la caché, no de la igualdad de los valores. Path.of(...), Optional.of(...) y Map.of(...) son otros static factory methods.

¿Y un record? Su constructor canónico tiene que ser tan visible como el record mismo, así que un record público no puede esconder su new. Puede agregar static factory methods, pero no obligar a usarlos. Por eso Money es una clase final con constructor privado.

Una factory: la decisión en un solo lugar

Los avisos de la semana 6 eran de varias clases: EmailNotification, SmsNotification. Supongan que llegan descritos como texto: un canal, un destinatario y un mensaje. Alguien tiene que convertir "sms" en un SmsNotification. Ese alguien es una factory: un método que recibe los datos y decide la clase concreta (Notification.java):

public interface Notification {
    String send();

    static Notification of(String channel, String to, String text) {
        return switch (channel) {
            case "email" -> new EmailNotification(to, text);
            case "sms" -> new SmsNotification(to, text);
            default -> throw new IllegalArgumentException("canal desconocido: " + channel);
        };
    }
}

Quien llama recibe un Notification y no sabe qué clase hay detrás. Es el cuarto punto de la lista anterior: un static factory method puede devolver un subtipo. Que esté en la interfaz misma (un método static de interfaz) hace que sea fácil de encontrar.

Error común. Llamar “factory” a un switch gigante que crea objetos y que aparece copiado en el código que lee archivos, en el menú y en las pruebas. El sentido de la factory es que la decisión esté en un solo lugar: cuando aparezca un canal nuevo, se agrega un case aquí y nada más. Si un switch sobre el mismo texto aparece dos veces, es el code smell de la semana 13, y el refactoring es moverlo a la factory.

Factory Method

El patrón que el libro de 1994 llama Factory Method es un poco distinto. No es un método estático: es un método al que las subclases le hacen override. Una superclase tiene un algoritmo que necesita crear objetos, y deja la creación a un método abstracto que cada subclase implementa a su manera.

Un ejemplo que nos va a servir la semana 15: leer objetos de líneas de texto. El recorrido es siempre el mismo (saltar líneas vacías y comentarios, partir la línea en campos, verificar cuántos campos hay, informar el número de línea si algo falla); lo único que cambia es qué objeto se crea con los campos (LineImporter.java):

public abstract class LineImporter<T> {

    public final List<T> importAll(List<String> lines) {
        List<T> result = new ArrayList<>();
        int number = 0;
        for (String line : lines) {
            number++;
            if (line.isBlank() || line.startsWith("#")) {
                continue;
            }
            String[] fields = line.split(";");
            if (fields.length != fieldCount()) {
                throw new IllegalArgumentException("línea " + number + ": se esperaban "
                        + fieldCount() + " campos y hay " + fields.length);
            }
            result.add(create(fields));
        }
        return result;
    }

    protected abstract int fieldCount();

    /** El factory method: crea el objeto de una línea. */
    protected abstract T create(String[] fields);
}

importAll es final: el algoritmo no se cambia. create es el factory method. Hay dos subclases; NotificationImporter usa la factory de antes, y SongImporter crea canciones:

public class NotificationImporter extends LineImporter<Notification> {
    @Override
    protected int fieldCount() {
        return 3;
    }

    @Override
    protected Notification create(String[] fields) {
        return Notification.of(fields[0].strip(), fields[1].strip(), fields[2].strip());
    }
}
Diagrama de clases de Factory Method. La clase abstracta LineImporter<T> tiene el método final importAll(lines), que recorre las líneas y llama a create(fields), y los métodos abstractos fieldCount() y create(fields). Dos subclases le hacen override: NotificationImporter crea un Notification llamando a Notification.of, que decide entre EmailNotification y SmsNotification; SongImporter crea un SongDiagrama de clases de Factory Method. La clase abstracta LineImporter<T> tiene el método final importAll(lines), que recorre las líneas y llama a create(fields), y los métodos abstractos fieldCount() y create(fields). Dos subclases le hacen override: NotificationImporter crea un Notification llamando a Notification.of, que decide entre EmailNotification y SmsNotification; SongImporter crea un Song
Factory Method: el algoritmo vive en la superclase y llama a create; qué objeto se crea lo decide cada subclase. NotificationImporter delega a su vez en la factory Notification.of.
List<String> lines = List.of(
        "# canal;destinatario;texto",
        "email;alice@uni.pe;Su pedido salió hoy",
        "sms;987654321;Su pedido llega mañana entre 9 y 12",
        "",
        "email;bob@uni.pe;Gracias por su compra");
for (Notification n : new NotificationImporter().importAll(lines)) {
    IO.println(n.send());
}
$ java -cp build FactoryDemo
...
email a alice@uni.pe: Su pedido salió hoy
SMS a 987654321: Su pedido llega maña...
email a bob@uni.pe: Gracias por su compra
error: canal desconocido: fax

(El SMS se recorta a 20 caracteres: es el send() de SmsNotification.)

Idea clave. Tres cosas parecidas, con nombres distintos. Un static factory method (Money.of) reemplaza a un constructor. Una factory (Notification.of) decide en un solo lugar qué clase crear según un dato. Factory Method (LineImporter.create) es un método abstracto que una superclase llama dentro de su algoritmo para que cada subclase decida qué crear. Cuando alguien dice “factory”, pregunten cuál de las tres.

Cuándo no usar una factory

  • Si la clase es una sola y su constructor no necesita validar nada, new es lo más claro. new ArrayList<>() no necesita factory.
  • Una jerarquía de “factories de factories” para que el código “no dependa de new” en un programa pequeño es indirección sin cliente. Factory Method paga cuando hay un algoritmo común de verdad, como el de importAll, y al menos dos subclases.
  • El static factory method tiene un costo: no se distingue a simple vista de otros métodos static, y una clase con constructor privado no se puede extender. Para Money es justo lo que queremos; para una clase pensada para heredar, no.

Composite: una parte y un todo que se tratan igual

El problema: listas que contienen listas

Una aplicación de música tiene canciones y listas de reproducción. Una lista puede contener canciones y también otras listas: la lista “Fiesta” contiene la lista “Criollas”, dos canciones sueltas y la lista “Cumbias”. Queremos saber la duración de cualquier cosa: de una canción, de una lista, de una lista de listas.

Sin el patrón, el código que calcula duraciones tiene que preguntar a cada paso qué tiene en la mano: si es una canción, sumar su duración; si es una lista, recorrerla y, para cada elemento, volver a preguntar. Ese código se repite en cada operación (duración, contar canciones, imprimir) y cada una tiene que conocer los dos casos.

La solución: una interfaz común para la leaf y el composite

Composite define una interfaz común, el component, que implementan tanto los objetos simples, las leaves, como los objetos que contienen a otros, los composites. Un composite guarda una lista de components, sin saber cuáles son leaves y cuáles composites, y responde a cada operación pidiéndosela a sus hijos.

Con lo que vimos en la semana 8, el component es una interfaz sealed (Playable.java) con dos records (Song.java, Playlist.java):

public sealed interface Playable permits Song, Playlist {
    String title();

    int seconds();

    default String length() {
        return String.format("%d:%02d", seconds() / 60, seconds() % 60);
    }
}

public record Song(String title, String artist, int seconds) implements Playable {
    // el constructor compacto rechaza una duración que no sea positiva
}

public record Playlist(String title, List<Playable> entries) implements Playable {
    public Playlist {
        entries = List.copyOf(entries);
    }

    @Override
    public int seconds() {
        int total = 0;
        for (Playable entry : entries) {
            total += entry.seconds();
        }
        return total;
    }
}

En Song, los métodos title() y seconds() que genera el record ya implementan la interfaz. En Playlist, seconds() suma lo que responde cada elemento, y cada elemento, si es otra lista, hace lo mismo con los suyos. Ahí está la recursión: la llamada entry.seconds() puede ser la de una canción (el caso base) o la de otra lista (el caso recursivo), y el dynamic dispatch de la semana 6 elige cuál. length() es un método default escrito una sola vez para los dos casos.

Diagrama de clases de Composite. La interfaz sealed Playable, el component, declara title() y seconds(). La implementan el record Song, la leaf, con title, artist y seconds, y el record Playlist, el composite, con title y una lista entries. De Playlist sale una flecha de composición hacia Playable marcada con 0..*: una lista contiene cualquier cantidad de Playable, que pueden ser canciones u otras listasDiagrama de clases de Composite. La interfaz sealed Playable, el component, declara title() y seconds(). La implementan el record Song, la leaf, con title, artist y seconds, y el record Playlist, el composite, con title y una lista entries. De Playlist sale una flecha de composición hacia Playable marcada con 0..*: una lista contiene cualquier cantidad de Playable, que pueden ser canciones u otras listas
Composite: la flecha de Playlist hacia Playable cierra el ciclo. Una lista contiene componentes, y un componente puede ser otra lista.

List.copyOf en el constructor compacto hace que el árbol sea inmutable: nadie puede agregar una canción a una lista por detrás (prueba playlistCannotBeChangedFromOutside). Con un árbol mutable habría que cuidar otra cosa: que una lista no termine conteniéndose a sí misma, porque entonces seconds() no terminaría nunca.

Ejemplo resuelto: la lista “Fiesta”

CompositeDemo.java arma el árbol con Playable.playlist, un static factory method que recibe los elementos con varargs:

Playlist criollas = Playable.playlist("Criollas", contigoPeru);
Playlist cumbias = Playable.playlist("Cumbias", carinito);
Playlist party = Playable.playlist("Fiesta", criollas, condorPasa, cumbias, valicha);

IO.print(Playables.tree(party));
IO.println("canciones: " + Playables.countSongs(party) + ", duración: " + party.length());
$ java -cp build CompositeDemo
Fiesta  13:51
  Criollas  4:14
    Contigo Perú  4:14
  El cóndor pasa  3:18
  Cumbias  3:07
    Cariñito  3:07
  Valicha  3:12
canciones: 4, duración: 13:51
...
Animación: el árbol de la lista Fiesta, con Criollas, El cóndor pasa, Cumbias y Valicha como hijos, y Contigo Perú dentro de Criollas y Cariñito dentro de Cumbias. La llamada party.seconds() baja por el árbol. Cada leaf responde su duración (254, 198, 187 y 192 segundos); Criollas responde 254 y Cumbias 187; al final Fiesta suma 254 + 198 + 187 + 192 = 831 segundos, es decir 13:51Animación: el árbol de la lista Fiesta, con Criollas, El cóndor pasa, Cumbias y Valicha como hijos, y Contigo Perú dentro de Criollas y Cariñito dentro de Cumbias. La llamada party.seconds() baja por el árbol. Cada leaf responde su duración (254, 198, 187 y 192 segundos); Criollas responde 254 y Cumbias 187; al final Fiesta suma 254 + 198 + 187 + 192 = 831 segundos, es decir 13:51
La duración sube por el árbol: cada leaf responde la suya y cada lista suma lo que respondieron sus hijos. Nadie pregunta de qué tipo es cada elemento.

Operaciones nuevas con un switch sobre el tipo sealed

seconds() es un método en cada caso: el estilo de la semana 6. Como Playable es sealed, una operación nueva se puede escribir también afuera, con un switch exhaustivo (semana 8). Así están countSongs y songs en Playables.java:

public static int countSongs(Playable p) {
    return switch (p) {
        case Song s -> 1;
        case Playlist list -> {
            int n = 0;
            for (Playable entry : list.entries()) {
                n += countSongs(entry);
            }
            yield n;
        }
    };
}

Las dos formas tienen la misma recursión. Lo que cambia es hacia dónde es fácil crecer:

Un método en cada clase Un switch sobre el tipo sealed
Agregar un tipo nuevo (un podcast) Una clase nueva, nada más Cambiar cada switch (el compilador avisa cuáles)
Agregar una operación nueva Cambiar la interfaz y cada clase Un método estático nuevo, nada más

Es la misma tensión que vimos con abierto/cerrado en la semana 13. Para lo que todo component debe saber (su duración), un método en la interfaz; para operaciones que se agregan después (imprimir el árbol, exportarlo), un switch.

Cuándo no usar Composite

  • Si los datos no forman un árbol. Una lista de canciones sin listas adentro es un List<Song>; una interfaz común para un solo tipo de elemento no agrega nada.
  • Si la leaf y el composite no comparten operaciones con sentido. El patrón obliga a que la interfaz común tenga solo lo que vale para los dos. Poner add(Playable) en Playable para “tratar todo igual” obligaría a que una canción tenga un add que lanza una excepción: es la violación de Liskov de la semana 7. Nuestra versión deja entries solo en Playlist.
  • Si cada operación tiene que distinguir los casos de todos modos, la interfaz común es solo una formalidad, y un switch sobre el tipo sealed lo dice más claro.

Patrones juntos

Los patrones rara vez aparecen solos. En CompositeDemo hay tres trabajando juntos: las canciones salen de un SongImporter (Factory Method), forman un árbol de listas (Composite) y, al final, las leaves se ordenan con un Comparator (Strategy):

List<Song> imported = new SongImporter().importAll(List.of(
        "Contigo Perú;Arturo Cavero;254",
        "El cóndor pasa;Daniel Alomía Robles;198",
        "Cariñito;Los Hijos del Sol;187",
        "Valicha;Miguel Ángel Hurtado;192"));
Song contigoPeru = imported.get(0);
// ... condorPasa, carinito y valicha; se arma el árbol party como antes ...
List<Song> shortestFirst = Playables.songs(party);
shortestFirst.sort(Comparator.comparingInt(Song::seconds));
shortestFirst.forEach(s -> IO.println("  " + s.length() + "  " + s.title()));
  3:07  Cariñito
  3:12  Valicha
  3:18  El cóndor pasa
  4:14  Contigo Perú

Cada patrón resuelve un problema distinto: dónde se decide qué crear, cómo tratar igual a la parte y al todo, cómo elegir el orden. Ninguno está ahí por estar.

El código de la semana y make test

$ cd code/week-14
$ make test
...
== ShippingDemo
...
== CompositeDemo
...
├─ JUnit Jupiter ✔
│  ├─ PlayableTest ✔
│  ├─ FactoryTest ✔
│  ├─ ThermometerTest ✔
│  └─ ShippingRateTest ✔
...
[        22 tests successful      ]
[         0 tests failed          ]

Las pruebas de cada patrón están en su archivo: ShippingRateTest, ThermometerTest (con el spy), FactoryTest y PlayableTest. Las líneas ALARMA ... que aparecen antes del árbol de JUnit las imprimen las alarmas que usan las pruebas.

Resumen

  • Un patrón de diseño es un problema que se repite, una solución con nombre y sus consecuencias. Es vocabulario, no receta, y todo patrón cuesta clases e indirección.
  • Strategy: cada algoritmo en una clase detrás de una interfaz; el context la recibe por el constructor y se puede cambiar mientras el programa corre. Con una interfaz funcional, una strategy puede ser una lambda. Comparator es una strategy.
  • Observer: el subject guarda una lista de observers que conoce solo por su interfaz y les avisa de cada cambio. Hay que decidir quién se da de baja, qué viaja en el aviso, qué pasa si un observer falla y qué pasa si se da de baja durante el aviso (recorrer una copia). Se prueba con un spy.
  • Factories: un static factory method tiene nombre, puede devolver un objeto existente o un subtipo y puede validar; una factory decide en un solo lugar qué clase crear; Factory Method deja la creación a un método del que cada subclase hace override.
  • Composite: la leaf y el composite implementan la misma interfaz; el composite responde pidiéndoselo a sus hijos, y ahí está la recursión. Con un tipo sealed, las operaciones nuevas se escriben como un switch.
  • Cuándo no: un solo algoritmo, un solo interesado, una sola clase o datos que no forman un árbol no necesitan patrón.

Ejercicios

  1. Expliquen con sus palabras qué diferencia hay entre resolver las tarifas de envío con Strategy y con una jerarquía Checkout → FlatCheckout, WeightCheckout. Den un cambio de requisitos que sea fácil con una solución y difícil con la otra.
  2. Agreguen una tarifa ByDistrict que cobre según el distrito de destino (agreguen el distrito a Parcel), con un Map<String, Money> de tarifas y una tarifa por defecto. ¿Qué archivos tuvieron que cambiar? ¿Cambió Checkout? Escriban sus pruebas.
  3. Escriban como lambda una tarifa que cobre S/ 2 por cada producto del paquete. ¿Qué le falta a Parcel para poder escribirla? ¿En qué momento dejarían la lambda y escribirían un record?
  4. En ObserverDemo, ¿qué imprime el programa si la lambda se registra antes que la pantalla? Predíganlo, cámbienlo y compruébenlo. ¿Debería un observer depender del orden en que se registraron los demás?
  5. Agreguen a Thermometer un observer MinMaxTracker que recuerde la temperatura mínima y la máxima recibidas (como el Thermometer de la semana 2, pero ahora como observer). Pruébenlo con lecturas registradas en el termómetro, sin llamar a onReading directamente.
  6. En NaiveThermometer, registren solo dos observers: un FirstAlert y un Display, en ese orden, y registren 8,5 °C. ¿Se lanza la excepción? ¿Se imprime la lectura? Expliquen lo que pasa mirando cómo el iterador de ArrayList decide si hay un elemento siguiente. ¿Por qué este caso es todavía peor que la excepción?
  7. Cambien el estilo de Thermometer de push a pull: la interfaz recibe el Thermometer y el termómetro ofrece lastReading(). Comparen las dos versiones: ¿cuál acopla más al observer con el subject?
  8. Den dos razones por las que Money.of(cents, currency) es preferible a un constructor público en esta clase, y una situación en que preferirían un constructor.
  9. Agreguen un canal "whatsapp" a los avisos. ¿Cuántos lugares del código tuvieron que cambiar? Busquen en su proyecto un switch sobre el tipo de producto que aparezca en más de un lugar y digan cómo lo llevarían a una factory.
  10. Escriban una subclase de LineImporter que lea líneas "lugar;grados" y cree objetos Reading. ¿Qué parte del trabajo no tuvieron que escribir? ¿Qué pasa con una línea "refri 1;frío"? Decidan cómo debería informarse ese error y pruébenlo.
  11. Escriban Playables.longest(Playable), que devuelva la canción más larga del árbol, con un switch sobre el tipo sealed. Después agreguen a la jerarquía un tercer tipo, Podcast(String title, int seconds). ¿Qué métodos dejan de compilar? ¿Por qué eso es una ayuda?
  12. Dibujen el diagrama de clases de un Composite para los paquetes de productos de su proyecto (un paquete contiene productos u otros paquetes y tiene un descuento propio). ¿Dónde está la recursión? ¿Qué operación pondrían en la interfaz y cuál escribirían con un switch? Nombren además un lugar de su proyecto donde agregar un patrón sería un exceso.