Template Method e Iterator
En resumen: Template Method guarda el esqueleto común de un algoritmo en una clase base mientras las subclases rellenan los pasos; Iterator entrega los elementos uno por uno sin mostrar cómo están guardados.
Problema
Dos informes con el mismo esqueleto
Contabilidad quiere los pedidos en CSV, y el sitio web quiere los mismos en una tabla HTML. Los pedidos cancelados no entran en el informe. Alguien escribió el de CSV y luego copió la método para HTML:
static String exportCsv(List<Order> orders) { List<String> lines = new ArrayList<>(); lines.add("pedido,suma"); double sum = 0; for (Order order : orders) { if (order.cancelled()) { continue; } lines.add(order.id() + "," + String.format("%.2f", order.total())); sum += order.total(); } lines.add("total," + String.format("%.2f", sum)); return String.join("\n", lines);}
static String exportHtml(List<Order> orders) { List<String> lines = new ArrayList<>(); lines.add("<table>"); lines.add("<tr><th>Pedido</th><th>Suma</th></tr>"); double sum = 0; for (Order order : orders) { lines.add("<tr><td>" + order.id() + "</td><td>" + String.format("%.2f", order.total()) + "</td></tr>"); sum += order.total(); } lines.add("<tr><td>Total</td><td>" + String.format("%.2f", sum) + "</td></tr>"); lines.add("</table>"); return String.join("\n", lines);}List<Order> orders = List.of( new Order(1001, 80.00, false), new Order(1002, 25.50, false), new Order(1003, 40.00, true));System.out.println(exportCsv(orders));System.out.println(exportHtml(orders));pedido,suma1001,80.001002,25.50total,105.50<table><tr><th>Pedido</th><th>Suma</th></tr><tr><td>1001</td><td>80.00</td></tr><tr><td>1002</td><td>25.50</td></tr><tr><td>1003</td><td>40.00</td></tr><tr><td>Total</td><td>145.50</td></tr></table>Los dos informes muestran una suma distinta: el CSV dice 105.50 €, y el HTML dice 145.50 €. La regla «los cancelados no cuentan» existe solo en la primera copia. Las dos copias hacen lo mismo: abren, escriben un encabezado, recorren los pedidos, escriben el total y cierran. Solo cambian las líneas que escriben. Un tercer formato será una tercera copia, y habrá que recordar la regla en un lugar más.
La idea
Template Method: un esqueleto, pasos distintos
Lo que no cambia es el orden de los pasos y las reglas entre ellos: abrir, encabezado, una fila por cada pedido salvo los cancelados, total, cerrar. Lo que cambia es cómo se ve cada paso.
El esqueleto va en la clase base Report, en el método export. En cada lugar donde los formatos se diferencian, llama a un paso: header, row, footer. La subclase escribe solo los pasos. Un paso que es opcional (open y close, que CSV no usa) tiene un cuerpo vacío en la clase base y se llama gancho (hook).
Este es el patrón Template Method. Tiene dos roles:
- la clase base con el esqueleto: decide el orden y llama a los pasos;
- las subclases:
CsvReportyHtmlReportescriben los pasos y no llaman a nada por sí mismas.
Míralo
El esqueleto y los dos informes
final: una subclase no puede cambiarlo. Los pasos son protected y abstract, salvo los dos ganchos con cuerpo vacío:abstract class Report { public final String export(List<Order> orders) { List<String> lines = new ArrayList<>(); open(lines); header(lines); double sum = 0; for (Order order : orders) { if (order.cancelled()) { continue; } row(lines, order); sum += order.total(); } footer(lines, sum); close(lines); return String.join("\n", lines); }
protected void open(List<String> lines) { }
protected abstract void header(List<String> lines);
protected abstract void row(List<String> lines, Order order);
protected abstract void footer(List<String> lines, double sum);
protected void close(List<String> lines) { }}Los dos informes escriben solo sus líneas. CsvReport no escribe los ganchos, así que usa los vacíos de la clase base:
class CsvReport extends Report { @Override protected void header(List<String> lines) { lines.add("pedido,suma"); }
@Override protected void row(List<String> lines, Order order) { lines.add(order.id() + "," + String.format("%.2f", order.total())); }
@Override protected void footer(List<String> lines, double sum) { lines.add("total," + String.format("%.2f", sum)); }}
class HtmlReport extends Report { @Override protected void open(List<String> lines) { lines.add("<table>"); }
@Override protected void header(List<String> lines) { lines.add("<tr><th>Pedido</th><th>Suma</th></tr>"); }
@Override protected void row(List<String> lines, Order order) { lines.add("<tr><td>" + order.id() + "</td><td>" + String.format("%.2f", order.total()) + "</td></tr>"); }
@Override protected void footer(List<String> lines, double sum) { lines.add("<tr><td>Total</td><td>" + String.format("%.2f", sum) + "</td></tr>"); }
@Override protected void close(List<String> lines) { lines.add("</table>"); }}List<Order> orders = List.of( new Order(1001, 80.00, false), new Order(1002, 25.50, false), new Order(1003, 40.00, true));System.out.println(new CsvReport().export(orders));System.out.println(new HtmlReport().export(orders));pedido,suma1001,80.001002,25.50total,105.50<table><tr><th>Pedido</th><th>Suma</th></tr><tr><td>1001</td><td>80.00</td></tr><tr><td>1002</td><td>25.50</td></tr><tr><td>Total</td><td>105.50</td></tr></table>abstract class Report { public final String export(List<Order> orders) { List<String> lines = new ArrayList<>(); open(lines); header(lines); double sum = 0; for (Order order : orders) { if (order.cancelled()) { continue; } row(lines, order); sum += order.total(); } footer(lines, sum); close(lines); return String.join("\n", lines); }
protected void open(List<String> lines) { }
protected abstract void header(List<String> lines);
protected abstract void row(List<String> lines, Order order);
protected abstract void footer(List<String> lines, double sum);
protected void close(List<String> lines) { }}Paso a paso, en palabras
- new CsvReport().export(orders): el esqueleto está en Report. Él decide el orden de los pasos.
- open: CsvReport no escribe este paso, así que se ejecuta el cuerpo vacío de Report. No se añade nada.
- header: el esqueleto llama al paso, y CsvReport escribe su línea.
- row para el pedido 1001: el esqueleto llama al paso, y CsvReport escribe su línea.
- row para el pedido 1002: el esqueleto llama al paso, y CsvReport escribe su línea.
- El pedido 1003 está cancelado: el propio esqueleto lo salta con continue. Ningún informe tiene que recordar esta regla.
- footer: el esqueleto llama al paso, y CsvReport escribe su línea.
- close: CsvReport no escribe este paso, así que se ejecuta el cuerpo vacío de Report. No se añade nada.
- Ahora HtmlReport: el mismo esqueleto, otros pasos.
- open: el esqueleto llama al paso, y HtmlReport escribe su línea.
- header: el esqueleto llama al paso, y HtmlReport escribe su línea.
- row para el pedido 1001: el esqueleto llama al paso, y HtmlReport escribe su línea.
- row para el pedido 1002: el esqueleto llama al paso, y HtmlReport escribe su línea.
- El pedido 1003 está cancelado: el propio esqueleto lo salta con continue. Ningún informe tiene que recordar esta regla.
- footer: el esqueleto llama al paso, y HtmlReport escribe su línea.
- close: el esqueleto llama al paso, y HtmlReport escribe su línea.
- Dos informes, un esqueleto. Las dos sumas son 105.50 €, porque la regla de los cancelados está en un solo lugar.
El total es 105.50 € en los dos informes: la regla de los cancelados está en el esqueleto, en un solo lugar.
Míralo
El esqueleto no se toca
Una subclase que intenta escribir su propio export no compila:
class QuickCsvReport extends CsvReport { @Override public String export(List<Order> orders) { return "pedido,suma"; }}error: export(List<Order>) in QuickCsvReport cannot override export(List<Order>) in Report public String export(List<Order> orders) { ^ overridden method is finalfinal. Sin él, la subclase cambia el esqueleto en silencio, y la regla de los cancelados desaparece solo para ese informe.Míralo
Sin herencia: los pasos como funciones
Los pasos no tienen que ser métodos de una subclase. El esqueleto puede recibirlos como parámetros: el encabezado es texto, y la fila y el total son funciones.
static String export(List<Order> orders, String header, Function<Order, String> row, Function<Double, String> footer) { List<String> lines = new ArrayList<>(); lines.add(header); double sum = 0; for (Order order : orders) { if (order.cancelled()) { continue; } lines.add(row.apply(order)); sum += order.total(); } lines.add(footer.apply(sum)); return String.join("\n", lines);}System.out.println(export(orders, "pedido,suma", order -> order.id() + "," + String.format("%.2f", order.total()), sum -> "total," + String.format("%.2f", sum)));System.out.println(export(orders, "<table>\n<tr><th>Pedido</th><th>Suma</th></tr>", order -> "<tr><td>" + order.id() + "</td><td>" + String.format("%.2f", order.total()) + "</td></tr>", sum -> "<tr><td>Total</td><td>" + String.format("%.2f", sum) + "</td></tr>\n</table>"));pedido,suma1001,80.001002,25.50total,105.50<table><tr><th>Pedido</th><th>Suma</th></tr><tr><td>1001</td><td>80.00</td></tr><tr><td>1002</td><td>25.50</td></tr><tr><td>Total</td><td>105.50</td></tr></table>El esqueleto sigue estando en un solo lugar, y los pedidos cancelados siguen sin contar.
Esto es Strategy para cada paso:Function<Order, String> en lugar de un método abstracto. Basta para dos o tres pasos cortos. Una subclase vale la pena cuando hay muchos pasos, comparten campos o algunos son opcionales, como los ganchos open y close.Problema
Todo el que quiere los pedidos pasa las páginas
Los pedidos están en un sistema que los entrega por páginas: dos por petición, porque una petición es lenta. Aquí la fuente es una lista en memoria, y cada carga imprime una línea, para que veas cuándo ocurre:
class OrderSource { private final List<Order> orders;
OrderSource(List<Order> orders) { this.orders = orders; }
List<Order> fetchPage(int number) { System.out.println("cargando la página " + number); int from = Math.min((number - 1) * 2, orders.size()); int to = Math.min(from + 2, orders.size()); return orders.subList(from, to); }}El informe, el correo a los clientes y la exportación quieren todos los pedidos, y cada uno pasa las páginas por su cuenta:
int number = 1;List<Order> page = source.fetchPage(number);while (!page.isEmpty()) { for (Order order : page) { System.out.println(order.id()); } number++; page = source.fetchPage(number);}cargando la página 110011002cargando la página 210031004cargando la página 31005cargando la página 4El código que solo quiere «cada pedido, uno tras otro» sabe que hay páginas, cómo se cuentan y que el final es una página vacía. Las mismas líneas están en tres archivos. Cuando el sistema pase de números de página a «una clave para la página siguiente», cambias los tres.
La idea
Iterator: recorrer algo detrás de un cursor
Quien llama quiere una sola cosa: el siguiente pedido, mientras haya uno. Cómo llegar a él (una lista, páginas, un archivo, la red) es asunto de otro.
Ese otro es el cursor: un objeto que recuerda hasta dónde llegó y responde solo a la pregunta «¿hay más?», cargando una página nueva solo cuando la anterior se acabó. Quien llama escribe un for-each común y no sabe nada de las páginas.
Este es el patrón Iterator. Tiene dos roles:
- la colección: lo que se recorre. Entrega un cursor;
- el cursor: recuerda el lugar y entrega el siguiente elemento.
Este patrón está integrado en cada uno de los cuatro lenguajes: for-each funciona con todo objeto que entregue un cursor. En Java la colección es un Iterable, y el cursor es un Iterator con hasNext() y next().
Míralo
El cursor que carga las páginas
yield, así que el cursor se escribe a mano. hasNext carga la página siguiente solo cuando la actual se agotó:class PageIterator implements Iterator<Order> { private final OrderSource source; private List<Order> page = List.of(); private int number = 0; private int index = 0; private boolean done = false;
PageIterator(OrderSource source) { this.source = source; }
@Override public boolean hasNext() { if (index == page.size() && !done) { number++; page = source.fetchPage(number); index = 0; done = page.isEmpty(); } return index < page.size(); }
@Override public Order next() { if (!hasNext()) { throw new NoSuchElementException(); } return page.get(index++); }}Iterable es la colección: cada for-each le pide un cursor nuevo.
class AllOrders implements Iterable<Order> { private final OrderSource source;
AllOrders(OrderSource source) { this.source = source; }
@Override public Iterator<Order> iterator() { return new PageIterator(source); }}Quien llama no sabe nada de las páginas:
for (Order order : new AllOrders(source)) { System.out.println(order.id());}cargando la página 110011002cargando la página 210031004cargando la página 31005cargando la página 4class PageIterator implements Iterator<Order> { private final OrderSource source; private List<Order> page = List.of(); private int number = 0; private int index = 0; private boolean done = false;
PageIterator(OrderSource source) { this.source = source; }
@Override public boolean hasNext() { if (index == page.size() && !done) { number++; page = source.fetchPage(number); index = 0; done = page.isEmpty(); } return index < page.size(); }
@Override public Order next() { if (!hasNext()) { throw new NoSuchElementException(); } return page.get(index++); }}
for (Order order : new AllOrders(source)) { System.out.println(order.id());}Paso a paso, en palabras
- El bucle pide el primer pedido. Todavía no se ha cargado nada.
- El cursor (PageIterator) todavía no tiene página, así que carga la página 1.
- 1001: el primer pedido de la página va al bucle, y el bucle lo imprime.
- 1002: de la misma página. No se carga nada.
- La página se acabó. Solo ahora el cursor carga la página 2.
- 1003 y 1004 de la página 2.
- Otra vez el límite: página 3.
- 1005: la página 3 tiene un solo pedido.
- La página 4 está vacía: no hay más pedidos.
- El cursor dice «no hay más», y el bucle termina. El bucle nunca vio una página.
La salida es la misma, y contar las páginas está en un solo lugar. Cuando el sistema cambia la forma de entregar páginas, cambias el cursor, y los tres lugares que recorren los pedidos no se tocan.
Míralo
Qué hace for-each por dentro
for-each es una forma corta de escribir este bucle con el cursor:
Iterator<Order> it = new AllOrders(source).iterator();while (it.hasNext()) { Order order = it.next(); System.out.println(order.id());}cargando la página 110011002cargando la página 210031004cargando la página 31005cargando la página 4for (Order order : orders) llama una vez a orders.iterator(), y luego a hasNext() y next() en cada paso. Por eso funciona con una lista, con AllOrders y con todo lo que sea Iterable.Pruébalo
Predice la salida
El bucle se detiene después del tercer pedido. ¿Qué imprime el programa?
int shown = 0;for (Order order : new AllOrders(source)) { System.out.println(order.id()); shown++; if (shown == 3) { break; }}Pruébalo
Encuentra el error
El correo a los clientes primero dice cuántos pedidos hay, y luego los lista:
AllOrders orders = new AllOrders(new OrderSource(all));int count = 0;for (Order order : orders) { count++;}System.out.println("Pedidos: " + count);for (Order order : orders) { System.out.println(order.id());}cargando la página 1cargando la página 2cargando la página 3cargando la página 4Pedidos: 5cargando la página 110011002cargando la página 210031004cargando la página 31005cargando la página 4Elige la línea con el error.
AllOrders es un Iterable: cada for-each toma un cursor nuevo y empieza desde el principio, cargando todas las páginas. Si necesitas los pedidos dos veces, reúnelos una vez en una lista: List<Order> orders = new ArrayList<>(); new AllOrders(...).forEach(orders::add);. Entonces las páginas se cargan una sola vez.La idea
Cuándo no los necesitas
Template Method tiene un costo: una clase base y herencia, y la herencia es un lazo difícil de deshacer. No vale la pena cuando:
- hay un solo formato. Una sola método sin pasos es más clara;
- hay uno o dos pasos. Pásalos como lambdas, como en la tarjeta «Sin herencia»;
- las subclases quieren cambiar también el orden de los pasos. Entonces el esqueleto no es común, y si lo fuerzas te quedan pasos con un
ifdentro.
No necesitas un Iterator propio cuando los datos ya están en una lista: ya se puede recorrer. Escribes uno propio cuando detrás hay algo que no es una colección en memoria: páginas, un archivo leído línea por línea, un árbol o una secuencia infinita (números de factura).
Ejercicio
Un informe en Markdown
Resuelve la tarea en tu editor.
Markdown. El sitio de documentación quiere el informe como una tabla Markdown. Escribe MarkdownReport, que escribe solo los pasos. El encabezado son dos líneas. Report y los demás informes no se tocan. Con los pedidos de la lección:
| Pedido | Suma ||---|---|| 1001 | 80.00 || 1002 | 25.50 || **Total** | 105.50 |Más tareas
- Números de fila. El informe HTML numera sus filas (
<td>1</td>,<td>2</td>), y los pedidos cancelados no cuentan. ¿Dónde guardas el contador: en el esqueleto o enHtmlReport? - Los primeros N. Escribe un cursor que entregue solo los primeros N pedidos de otro cursor. ¿Cuántas páginas se cargan para los tres primeros?
- Solo los grandes. Recorre los pedidos de la fuente y entrega solo los de más de 30 €, de nuevo sin que quien llama sepa nada de las páginas.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Lambdas · record, switch y patrones · Patrones de diseño · Strategy
- Ejercicios sobre el tema: Patrones de diseño