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-14y se prueba conmake testdentro 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
switchque 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
switchsobre 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));
}
}
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:
ComparatorparasortyTreeMap, elPredicatederemoveIfyfilter, laFunctiondemap. 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
FlatCheckoutno puede pasar a cobrar por peso. Con Strategy basta consetRate. - Si
Checkouttuviera 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.


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
ArrayListdetecta que la lista cambió y lanzaConcurrentModificationException(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 deonReading. Recorrer una copia lo evita; copiar cuesta poco con listas de pocos observers.
En Java. La biblioteca trajo desde Java 1.0 una clase
Observabley una interfazObserverenjava.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 usaPropertyChangeSupport(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
onReadingvarias 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")yMoney.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 objetoZERO_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 elfalsede 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$ListNPor eso los objetos se comparan con
equalsy no con==(semana 5): lo que dé==con wrappers depende de la caché, no de la igualdad de los valores.Path.of(...),Optional.of(...)yMap.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
switchgigante 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 uncaseaquí y nada más. Si unswitchsobre 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());
}
}
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,
newes 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 deimportAll, 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. ParaMoneyes 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.
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
...


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)enPlayablepara “tratar todo igual” obligaría a que una canción tenga unaddque lanza una excepción: es la violación de Liskov de la semana 7. Nuestra versión dejaentriessolo enPlaylist. - Si cada operación tiene que distinguir los casos de todos modos, la
interfaz común es solo una formalidad, y un
switchsobre 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.
Comparatores 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
- 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. - Agreguen una tarifa
ByDistrictque cobre según el distrito de destino (agreguen el distrito aParcel), con unMap<String, Money>de tarifas y una tarifa por defecto. ¿Qué archivos tuvieron que cambiar? ¿CambióCheckout? Escriban sus pruebas. - Escriban como lambda una tarifa que cobre S/ 2 por cada producto del
paquete. ¿Qué le falta a
Parcelpara poder escribirla? ¿En qué momento dejarían la lambda y escribirían un record? - 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? - Agreguen a
Thermometerun observerMinMaxTrackerque recuerde la temperatura mínima y la máxima recibidas (como elThermometerde la semana 2, pero ahora como observer). Pruébenlo con lecturas registradas en el termómetro, sin llamar aonReadingdirectamente. - En
NaiveThermometer, registren solo dos observers: unFirstAlerty unDisplay, 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 deArrayListdecide si hay un elemento siguiente. ¿Por qué este caso es todavía peor que la excepción? - Cambien el estilo de
Thermometerde push a pull: la interfaz recibe elThermometery el termómetro ofrecelastReading(). Comparen las dos versiones: ¿cuál acopla más al observer con el subject? - 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. - Agreguen un canal
"whatsapp"a los avisos. ¿Cuántos lugares del código tuvieron que cambiar? Busquen en su proyecto unswitchsobre el tipo de producto que aparezca en más de un lugar y digan cómo lo llevarían a una factory. - Escriban una subclase de
LineImporterque lea líneas"lugar;grados"y cree objetosReading. ¿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. - Escriban
Playables.longest(Playable), que devuelva la canción más larga del árbol, con unswitchsobre 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? - 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.