Ir al contenido
wannacode

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 función para HTML:

function exportCsv(orders) {
const lines = ["pedido,suma"];
let total = 0;
for (const order of orders) {
if (order.cancelled) {
continue;
}
lines.push(`${order.id},${order.total.toFixed(2)}`);
total += order.total;
}
lines.push(`total,${total.toFixed(2)}`);
return lines.join("\n");
}
function exportHtml(orders) {
const lines = ["<table>", "<tr><th>Pedido</th><th>Suma</th></tr>"];
let total = 0;
for (const order of orders) {
lines.push(`<tr><td>${order.id}</td><td>${order.total.toFixed(2)}</td></tr>`);
total += order.total;
}
lines.push(`<tr><td>Total</td><td>${total.toFixed(2)}</td></tr>`);
lines.push("</table>");
return lines.join("\n");
}
const orders = [
{ id: 1001, total: 80, cancelled: false },
{ id: 1002, total: 25.5, cancelled: false },
{ id: 1003, total: 40, cancelled: true },
];
console.log(exportCsv(orders));
console.log(exportHtml(orders));
Salida
pedido,suma
1001,80.00
1002,25.50
total,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: CsvReport y HtmlReport escriben los pasos y no llaman a nada por sí mismas.

Míralo

El esqueleto y los dos informes

JavaScript no tiene abstract. Un paso obligatorio en la clase base lanza un error si la subclase no lo escribió. Los dos ganchos están vacíos:
class Report {
export(orders) {
const lines = [];
this.open(lines);
this.header(lines);
let total = 0;
for (const order of orders) {
if (order.cancelled) {
continue;
}
this.row(lines, order);
total += order.total;
}
this.footer(lines, total);
this.close(lines);
return lines.join("\n");
}
open(lines) {}
header(lines) {
throw new Error("header() debe escribirse");
}
row(lines, order) {
throw new Error("row() debe escribirse");
}
footer(lines, total) {
throw new Error("footer() debe escribirse");
}
close(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 {
header(lines) {
lines.push("pedido,suma");
}
row(lines, order) {
lines.push(`${order.id},${order.total.toFixed(2)}`);
}
footer(lines, total) {
lines.push(`total,${total.toFixed(2)}`);
}
}
class HtmlReport extends Report {
open(lines) {
lines.push("<table>");
}
header(lines) {
lines.push("<tr><th>Pedido</th><th>Suma</th></tr>");
}
row(lines, order) {
lines.push(`<tr><td>${order.id}</td><td>${order.total.toFixed(2)}</td></tr>`);
}
footer(lines, total) {
lines.push(`<tr><td>Total</td><td>${total.toFixed(2)}</td></tr>`);
}
close(lines) {
lines.push("</table>");
}
}
const orders = [
{ id: 1001, total: 80, cancelled: false },
{ id: 1002, total: 25.5, cancelled: false },
{ id: 1003, total: 40, cancelled: true },
];
console.log(new CsvReport().export(orders));
console.log(new HtmlReport().export(orders));
Salida
pedido,suma
1001,80.00
1002,25.50
total,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>

class Report {
export(orders) {
const lines = [];
this.open(lines);
this.header(lines);
let total = 0;
for (const order of orders) {
if (order.cancelled) {
continue;
}
this.row(lines, order);
total += order.total;
}
this.footer(lines, total);
this.close(lines);
return lines.join("\n");
}
open(lines) {}
header(lines) {
throw new Error("header() debe escribirse");
}
row(lines, order) {
throw new Error("row() debe escribirse");
}
footer(lines, total) {
throw new Error("footer() debe escribirse");
}
close(lines) {}
}
Paso a paso, en palabras
  1. new CsvReport().export(orders): el esqueleto está en Report. Él decide el orden de los pasos.
  2. open: CsvReport no escribe este paso, así que se ejecuta el cuerpo vacío de Report. No se añade nada.
  3. header: el esqueleto llama al paso, y CsvReport escribe su línea.
  4. row para el pedido 1001: el esqueleto llama al paso, y CsvReport escribe su línea.
  5. row para el pedido 1002: el esqueleto llama al paso, y CsvReport escribe su línea.
  6. El pedido 1003 está cancelado: el propio esqueleto lo salta con continue. Ningún informe tiene que recordar esta regla.
  7. footer: el esqueleto llama al paso, y CsvReport escribe su línea.
  8. close: CsvReport no escribe este paso, así que se ejecuta el cuerpo vacío de Report. No se añade nada.
  9. Ahora HtmlReport: el mismo esqueleto, otros pasos.
  10. open: el esqueleto llama al paso, y HtmlReport escribe su línea.
  11. header: el esqueleto llama al paso, y HtmlReport escribe su línea.
  12. row para el pedido 1001: el esqueleto llama al paso, y HtmlReport escribe su línea.
  13. row para el pedido 1002: el esqueleto llama al paso, y HtmlReport escribe su línea.
  14. El pedido 1003 está cancelado: el propio esqueleto lo salta con continue. Ningún informe tiene que recordar esta regla.
  15. footer: el esqueleto llama al paso, y HtmlReport escribe su línea.
  16. close: el esqueleto llama al paso, y HtmlReport escribe su línea.
  17. 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

JavaScript no tiene final. Una subclase puede escribir su propio export, y nada la detiene. Un paso que falta solo se nota cuando el esqueleto llega a él:

class TextReport extends Report {
header(lines) {
lines.push("pedido suma");
}
row(lines, order) {
lines.push(`${order.id} ${order.total.toFixed(2)}`);
}
}
console.log(new TextReport().export(orders));
Error
Error: footer() debe escribirse

Por eso cada informe nuevo se ejecuta al menos una vez antes de llegar a contabilidad.

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.

function exportOrders(orders, header, row, footer) {
const lines = [header];
let total = 0;
for (const order of orders) {
if (order.cancelled) {
continue;
}
lines.push(row(order));
total += order.total;
}
lines.push(footer(total));
return lines.join("\n");
}
console.log(exportOrders(
orders,
"pedido,suma",
(order) => `${order.id},${order.total.toFixed(2)}`,
(total) => `total,${total.toFixed(2)}`,
));
console.log(exportOrders(
orders,
"<table>\n<tr><th>Pedido</th><th>Suma</th></tr>",
(order) => `<tr><td>${order.id}</td><td>${order.total.toFixed(2)}</td></tr>`,
(total) => `<tr><td>Total</td><td>${total.toFixed(2)}</td></tr>\n</table>`,
));
Salida
pedido,suma
1001,80.00
1002,25.50
total,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.

En JavaScript esta suele ser la forma más natural: una función es un valor, y no hace falta herencia. Una subclase vale la pena cuando hay muchos pasos o comparten datos (por ejemplo el número de la fila actual), que caben mejor en los campos de un solo objeto.

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 {
constructor(orders) {
this.orders = orders;
}
fetchPage(number) {
console.log(`cargando la página ${number}`);
const start = (number - 1) * 2;
return this.orders.slice(start, start + 2);
}
}

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:

let number = 1;
let page = source.fetchPage(number);
while (page.length > 0) {
for (const order of page) {
console.log(order.id);
}
number++;
page = source.fetchPage(number);
}
Salida
cargando la página 1
1001
1002
cargando la página 2
1003
1004
cargando la página 3
1005
cargando la página 4

El 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...of 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...of funciona con todo objeto que entregue un cursor. En JavaScript el cursor es un objeto con next(), y la forma más fácil de hacer uno es un generador: una función con function* y yield.

Míralo

El cursor que carga las páginas

function* hace un generador: llamarlo todavía no hace nada, devuelve un cursor. Cada vez que for...of pide el siguiente pedido, la función avanza hasta el siguiente yield, entrega el pedido y se pausa justo ahí. Por eso la página siguiente se carga solo cuando la actual se acabó:
function* allOrders(source) {
for (let number = 1; ; number++) {
const page = source.fetchPage(number);
if (page.length === 0) {
return;
}
for (const order of page) {
yield order;
}
}
}

Quien llama no sabe nada de las páginas:

for (const order of allOrders(source)) {
console.log(order.id);
}
Salida
cargando la página 1
1001
1002
cargando la página 2
1003
1004
cargando la página 3
1005
cargando la página 4

function* allOrders(source) {
for (let number = 1; ; number++) {
const page = source.fetchPage(number);
if (page.length === 0) {
return;
}
for (const order of page) {
yield order;
}
}
}
for (const order of allOrders(source)) {
console.log(order.id);
}
Paso a paso, en palabras
  1. El bucle pide el primer pedido. Todavía no se ha cargado nada.
  2. El generador todavía no tiene página, así que carga la página 1.
  3. 1001: el primer pedido de la página va al bucle, y el bucle lo imprime.
  4. 1002: de la misma página. No se carga nada.
  5. La página se acabó. Solo ahora el cursor carga la página 2.
  6. 1003 y 1004 de la página 2.
  7. Otra vez el límite: página 3.
  8. 1005: la página 3 tiene un solo pedido.
  9. La página 4 está vacía: no hay más pedidos.
  10. 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 generador, y los tres lugares que recorren los pedidos no se tocan.

Míralo

Qué hace for-each por dentro

for...of es una forma corta de escribir este bucle con el cursor:

const it = allOrders(source)[Symbol.iterator]();
for (let step = it.next(); !step.done; step = it.next()) {
const order = step.value;
console.log(order.id);
}
Salida
cargando la página 1
1001
1002
cargando la página 2
1003
1004
cargando la página 3
1005
cargando la página 4
for...of llama una vez a [Symbol.iterator](), y luego a next() en cada paso, hasta que done pasa a true. Un generador es su propio cursor: [Symbol.iterator]() devuelve el generador mismo.

Si quieres algo que se pueda recorrer de nuevo cada vez, como una lista, haz una clase que entregue un generador nuevo en cada pasada:

class AllOrders {
constructor(source) {
this.source = source;
}
[Symbol.iterator]() {
return allOrders(this.source);
}
}

Ahora for (const order of new AllOrders(source)) empieza desde el principio en cada pasada, igual que un Iterable en Java.

Pruébalo

Predice la salida

El bucle se detiene después del tercer pedido. ¿Qué imprime el programa?

let shown = 0;
for (const order of allOrders(source)) {
console.log(order.id);
shown++;
if (shown === 3) {
break;
}
}
Respuesta

Pruébalo

Encuentra el error

El correo a los clientes primero dice cuántos pedidos hay, y luego los lista:

const orders = allOrders(new OrderSource(all));
let count = 0;
for (const order of orders) {
count++;
}
console.log(`Pedidos: ${count}`);
for (const order of orders) {
console.log(order.id);
}
Salida
cargando la página 1
cargando la página 2
cargando la página 3
cargando la página 4
Pedidos: 5
La cuenta es 5, pero los números no salen. ¿Qué línea tienes que cambiar?

Elige la línea con el error.

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 función sin pasos es más clara;
  • hay uno o dos pasos. Pásalos como funciones, 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 if dentro.

No necesitas un Iterator propio cuando los datos ya están en una array: 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:

Salida
| 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 en HtmlReport?
  • Los primeros N. Escribe un generador 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

1. La tienda quiere el informe también en JSON. ¿Qué escribes con Template Method?
2. ¿Por qué el paso open tiene un cuerpo vacío en la clase base, y header no?
3. Un bucle recorre el generador de pedidos y se detiene con break en el segundo. ¿Cuántas páginas se cargan?
4. Un generador ya se recorrió una vez hasta el final. ¿Qué hace un segundo for con el mismo generador?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·