StackPractices
intermediate Por Mathias Paulenko

Patrón Iterator para Colecciones Personalizadas

Accede a los elementos de un objeto agregado secuencialmente sin exponer su representación interna — árboles, grafos y streams asíncronos — con el patrón Iterator.

Temas: design

Descripción General

El patrón Iterator proporciona una forma de acceder a los elementos de un objeto agregado secuencialmente sin exponer su representación interna. Separa el algoritmo de recorrido de la estructura de la colección, de modo que puedes iterar sobre arrays, árboles, grafos o streams a través de la misma interfaz.

Este recurso se centra en la parte que el patrón genérico deja abierta: las colecciones personalizadas. Un árbol que necesita recorrido en profundidad y en anchura, una lista enlazada que debería funcionar con for...of, y una consulta paginada que devuelve filas de forma perezosa son el mismo problema — desacoplar “cómo recorro esto” de “qué es esto”.

Mermaid flowchart TD diagram

Cuándo Usarlo

  • Necesitas recorrer una colección sin exponer su estructura interna
  • Se requieren varios algoritmos de recorrido (pre-order, post-order, level-order) sobre la misma colección
  • Quieres iteración uniforme entre distintos tipos de colección — o soporte nativo de for...of
  • Los elementos llegan de forma perezosa: APIs paginadas, cursores de base de datos, streams de archivos

Evítalo cuando la colección es un simple array — [Symbol.iterator] ya viene incluido. El patrón compensa cuando la lógica de recorrido no es trivial o necesita variar.

Problema

Una estructura de árbol requiere distintos órdenes de recorrido según el caso de uso — un volcado de sistema de archivos quiere profundidad, una búsqueda de “nodo más cercano primero” quiere anchura. Codificar cada recorrido dentro del árbol acopla el algoritmo a la implementación del nodo; externalizarlo significa exponer internos que el cliente no debería ver.

Solución

Dos recorridos, una interfaz, cero conocimiento de los internos del árbol por parte del cliente:

// iterator/Iterator.ts
interface Iterator<T> {
  next(): T | null;
  hasNext(): boolean;
  reset(): void;
}

interface IterableCollection<T> {
  createIterator(): Iterator<T>;
}

// Nodo del árbol
class TreeNode<T> {
  children: TreeNode<T>[] = [];

  constructor(public value: T) {}

  addChild(child: TreeNode<T>): void {
    this.children.push(child);
  }
}

// Iterador en profundidad (pre-order)
class PreOrderIterator<T> implements Iterator<T> {
  private stack: TreeNode<T>[] = [];

  constructor(root: TreeNode<T>) {
    this.stack.push(root);
  }

  next(): T | null {
    if (!this.hasNext()) return null;

    const node = this.stack.pop()!;
    // Apilar hijos en orden inverso para recorrido izquierda-derecha
    for (let i = node.children.length - 1; i >= 0; i--) {
      this.stack.push(node.children[i]);
    }

    return node.value;
  }

  hasNext(): boolean {
    return this.stack.length > 0;
  }

  reset(): void {
    this.stack = [];
  }
}

// Iterador en anchura — cola con índice, O(1) por next()
class LevelOrderIterator<T> implements Iterator<T> {
  private queue: TreeNode<T>[] = [];
  private head = 0;

  constructor(root: TreeNode<T>) {
    this.queue.push(root);
  }

  next(): T | null {
    if (!this.hasNext()) return null;

    const node = this.queue[this.head++];
    this.queue.push(...node.children);

    return node.value;
  }

  hasNext(): boolean {
    return this.head < this.queue.length;
  }

  reset(): void {
    this.queue = [];
    this.head = 0;
  }
}

// Un árbol real: los nodos se direccionan por un mapa de rutas, no un stub
class FileSystem implements IterableCollection<string> {
  private root = new TreeNode<string>('root');
  private nodes = new Map<string, TreeNode<string>>();

  constructor() {
    this.nodes.set('root', this.root);
  }

  addNode(parentPath: string, name: string): void {
    const parent = this.nodes.get(parentPath);
    if (!parent) throw new Error(`Ruta desconocida: ${parentPath}`);
    const child = new TreeNode<string>(name);
    parent.addChild(child);
    this.nodes.set(name, child);
  }

  createIterator(type: 'pre-order' | 'level-order' = 'pre-order'): Iterator<string> {
    if (type === 'level-order') {
      return new LevelOrderIterator(this.root);
    }
    return new PreOrderIterator(this.root);
  }
}

// Uso
const fs = new FileSystem();
fs.addNode('root', 'src');
fs.addNode('root', 'dist');
fs.addNode('src', 'index.ts');

const preOrder = fs.createIterator('pre-order');
console.log('Pre-order:');
while (preOrder.hasNext()) {
  console.log(preOrder.next());  // root, src, index.ts, dist
}

const levelOrder = fs.createIterator('level-order');
console.log('Level-order:');
while (levelOrder.hasNext()) {
  console.log(levelOrder.next());  // root, src, dist, index.ts
}

Dos correcciones que merecen atención frente a implementaciones ingenuas. El iterador de anchura usa un índice head en lugar de queue.shift() — shift() reindexa todo el array en cada llamada, convirtiendo el recorrido en O(n²). Y FileSystem mantiene un Map real de rutas a nodos, así que addNode('src', 'index.ts') realmente anida bajo src en lugar de colgar silenciosamente de root.

Variación: Iterador Asíncrono para Streams

Una consulta paginada es el mismo patrón con Promise — el consumidor pide una fila y el iterador trae páginas entre bastidores:

// iterator/AsyncStreamIterator.ts
interface AsyncIterator<T> {
  next(): Promise<T | null>;
  hasNext(): boolean;
}

class DatabaseQueryIterator implements AsyncIterator<Record<string, unknown>> {
  private currentPage: Record<string, unknown>[] = [];
  private pageIndex = 0;
  private offset = 0;
  private hasMore = true;

  constructor(
    private query: string,
    private pageSize: number = 100,
    private db: { query: (sql: string, params: unknown[]) => Promise<Record<string, unknown>[]> }
  ) {}

  async next(): Promise<Record<string, unknown> | null> {
    if (this.pageIndex >= this.currentPage.length) {
      if (!this.hasMore) return null;
      await this.loadNextPage();
    }

    if (this.pageIndex >= this.currentPage.length) return null;
    return this.currentPage[this.pageIndex++];
  }

  hasNext(): boolean {
    return this.hasMore || this.pageIndex < this.currentPage.length;
  }

  private async loadNextPage(): Promise<void> {
    this.currentPage = await this.db.query(
      `${this.query} LIMIT ${this.pageSize} OFFSET ${this.offset}`,
      []
    );
    this.offset += this.pageSize;
    this.pageIndex = 0;
    this.hasMore = this.currentPage.length === this.pageSize;
  }
}

El llamador ve un contrato uniforme next()/hasNext() tanto si la colección es un árbol en memoria como una tabla de un millón de filas tras paginación. Ese es el punto: la estrategia de recorrido vive en el iterador, no en el bucle que lo consume.

Cómo Funciona

  1. Iterator declara la interfaz de recorrido con next(), hasNext() y reset()
  2. Iterador concreto implementa la lógica de recorrido para una estructura de colección concreta
  3. Agregado declara el método fábrica para crear iteradores
  4. Agregado concreto devuelve una instancia de iterador configurada para su estructura

El bucle del cliente (while (it.hasNext())) nunca descubre si los elementos vinieron de una pila, una cola, un cursor o una página HTTP — y la colección nunca descubre qué recorrido se ejecutó.

Symbol.iterator frente a una Interfaz Propia

En TypeScript/JavaScript tienes dos puertas a este patrón, y sirven a llamadores distintos:

  • Symbol.iterator hace que tu clase funcione con for...of, spread ([...lista]), destructuring, Promise.all y cualquier librería que acepte iterables. Es la opción idiomática — consulta los protocolos de iteración en MDN.
  • Una interfaz con nombre (next()/hasNext()/reset()) te da lo que Symbol.iterator no puede: rebobinar a mitad de recorrido (reset()), elegir el algoritmo por llamada (createIterator('level-order')) y exponer el estado del recorrido explícitamente. Es la forma correcta cuando el recorrido es en sí mismo la funcionalidad.

El patrón pragmático: implementa Symbol.iterator para el recorrido por defecto, de modo que for...of simplemente funcione, y conserva createIterator(tipo) para recorridos alternativos con nombre.

Generators: La Implementación Concisa

Un par next()/hasNext() manual controla el estado a mano — una pila, una cola, un índice. Un generator (function*) invierte eso: escribes el recorrido como código normal con yield, y el runtime gestiona la máquina de estados. Tres líneas de generator reemplazan una clase iteradora entera:

class TreeNode<T> {
  children: TreeNode<T>[] = [];
  constructor(public value: T) {}
}

function* preOrder<T>(node: TreeNode<T>): Generator<T> {
  yield node.value;
  for (const child of node.children) {
    yield* preOrder(child);  // delegar en el generator recursivo
  }
}

yield* reenvía cada valor producido por la llamada recursiva, así que la recursión se lee exactamente como el orden del recorrido. Los generators además son perezosos por construcción — los valores se computan solo al consumirlos, lo que importa en secuencias grandes o infinitas.

Especificar el Contrato del Iterador

La interfaz son tres métodos; el contrato son las preguntas que harán los llamadores y que el sistema de tipos no puede responder. Decide y documenta cuatro cosas:

  1. Garantía de orden. DFS pre-order y BFS producen secuencias distintas del mismo árbol — di cuál devuelve el iterador por defecto, o los llamadores asumirán “orden natural” y se equivocarán.
  2. Pereza. ¿next() computa bajo demanda o materializa todo por adelantado? Lo perezoso es correcto para streams y grafos grandes; los snapshots ansiosos lo son cuando la colección puede mutar a mitad de recorrido.
  3. Semántica de reinicio. ¿Puede reutilizarse un iterador consumido? La respuesta de Java es no (Iterator es de un solo paso); la interfaz propia aquí ofrece reset(). Symbol.iterator lo resuelve distinto — cada for...of invoca el método de nuevo, así que el iterable es reutilizable aunque cada iterador sea de un solo uso.
  4. Valor de fin de iteración. null al agotarse (este ejemplo) frente a { done: true } (el protocolo) frente a lanzar una excepción. Los llamadores escriben bucles while contra lo que elijas — elige uno y mantenlo uniforme en todos tus iteradores.

Los contratos mal especificados explican por qué los bugs de “iterador” se agrupan en producción: los consumidores adivinan el orden, reutilizan iteradores muertos, o nunca observan el agotamiento porque hasNext() miente.

Probar tu Iterador

Los iteradores se ganan la vida en los casos límite, y son baratos de probar porque son máquinas de estados puras:

function collect<T>(it: { next(): T | null; hasNext(): boolean }): T[] {
  const out: T[] = [];
  while (it.hasNext()) out.push(it.next()!);
  return out;
}

// Agotamiento: un árbol de un nodo produce exactamente un valor y para
const single = new FileSystem();
expect(collect(single.createIterator())).toEqual(['root']);
expect(single.createIterator().next()).toBe('root');

// Orden: los dos recorridos producen secuencias distintas y documentadas
const fs = new FileSystem();
fs.addNode('root', 'a'); fs.addNode('root', 'b'); fs.addNode('a', 'c');
expect(collect(fs.createIterator('pre-order'))).toEqual(['root', 'a', 'c', 'b']);
expect(collect(fs.createIterator('level-order'))).toEqual(['root', 'a', 'b', 'c']);

// hasNext() es idempotente — llamarlo dos veces no consume un elemento
const it = fs.createIterator();
expect(it.hasNext()).toBe(true);
expect(it.hasNext()).toBe(true);
expect(it.next()).toBe('root');

El test de idempotencia de hasNext() es el que detecta bugs reales: muchos iteradores hechos a mano consumen estado dentro de hasNext() y se saltan silenciosamente un elemento de cada dos.

Consideraciones de Producción

  • Implementa Symbol.iterator para soporte nativo de for...of en TypeScript
  • Usa generators (function*) para una implementación concisa del iterador
  • Considera iteradores asíncronos (for await...of) para APIs paginadas y datos en streaming
  • Decide la semántica de modificación concurrente desde el principio: fail-fast estilo Java (lanzar al mutar durante la iteración) frente a semántica de snapshot (iterar sobre una copia estable)
  • Mantén los iteradores de un solo uso salvo que reset() forme parte del contrato — reutilizar un iterador consumido es una fuente habitual de bugs

Errores Comunes

  • Exponer el índice interno de la colección, permitiendo a los clientes modificarlo a mitad de recorrido
  • No gestionar la modificación concurrente durante la iteración — elementos saltados o repetidos en silencio
  • Implementar un solo recorrido cuando se necesitan varios
  • Usar queue.shift() en código de anchura — O(n) por llamada; usa un índice o una deque real
  • Cargar todo el conjunto de resultados cuando un iterador perezoso/paginado lo transmitiría

Iterar una LinkedList con for…of

La recompensa completa de Symbol.iterator: una LinkedList personalizada que se comporta como una colección nativa — for...of, spread, destructuring — más iteradores alternativos para recorrido inverso y filtrado:

// Iterador para LinkedList personalizada
class LinkedListNode<T> {
  constructor(public value: T, public next: LinkedListNode<T> | null = null) {}
}

class LinkedList<T> implements Iterable<T> {
  private head: LinkedListNode<T> | null = null;
  private tail: LinkedListNode<T> | null = null;
  private size = 0;

  append(value: T) {
    const node = new LinkedListNode(value);
    if (!this.head) { this.head = node; this.tail = node; }
    else { this.tail!.next = node; this.tail = node; }
    this.size++;
  }

  // Implementar Symbol.iterator para soportar for...of
  [Symbol.iterator](): Iterator<T> {
    let current = this.head;
    return {
      next(): IteratorResult<T> {
        if (!current) return { done: true, value: undefined };
        const value = current.value;
        current = current.next;
        return { done: false, value };
      }
    };
  }

  // Iterador inverso usando generator
  *reverseIterator(): Generator<T> {
    const values: T[] = [];
    let current = this.head;
    while (current) { values.unshift(current.value); current = current.next; }
    for (const v of values) yield v;
  }

  // Iterador con filtro
  *filterIterator(pred: (v: T) => boolean): Generator<T> {
    let current = this.head;
    while (current) {
      if (pred(current.value)) yield current.value;
      current = current.next;
    }
  }
}

// Uso
const list = new LinkedList<number>();
list.append(1); list.append(2); list.append(3); list.append(4); list.append(5);

// for...of funciona gracias a Symbol.iterator
for (const v of list) console.log(v); // 1, 2, 3, 4, 5

// Iterador inverso
for (const v of list.reverseIterator()) console.log(v); // 5, 4, 3, 2, 1

// Iterador con filtro
for (const v of list.filterIterator(x => x % 2 === 0)) console.log(v); // 2, 4

// El spread operator también funciona
const arr = [...list]; // [1, 2, 3, 4, 5]

Lecciones:

  • Symbol.iterator hace cualquier iterable compatible con for...of, spread y destructuring
  • Los generators (function*) son la forma más concisa de implementar iteradores
  • Los iteradores personalizados — filter, reverse, map, slice — evitan arrays intermedios
  • Evaluación perezosa: los generators solo computan cuando se consumen
  • En JS, Map, Set, String, Array y NodeList son iterables por defecto

Solución de Problemas

  • for…of no funciona en mi clase: implementaste una interfaz con nombre (next()/hasNext()) pero no [Symbol.iterator] — for...of solo habla el protocolo del símbolo. Añádelo, aunque delegue en tu iterador propio.
  • El recorrido en anchura es lento en árboles grandes: busca queue.shift() — reindexa el array en cada llamada. Sustitúyelo por un índice head o una deque y el recorrido vuelve de O(n²) a O(n).
  • El iterador salta o repite elementos tras una mutación: la colección cambió a mitad de recorrido. Documenta comportamiento fail-fast (lanzar un error de modificación concurrente) o itera sobre un snapshot.
  • El iterador asíncrono se cuelga al final: hasNext() devolviendo true tras la última página suele significar que hasMore se calculó como page.length > 0 en lugar de page.length === pageSize — una página final corta dispara una petición extra que no devuelve nada.
  • Cada consumidor reimplementa el recorrido: si tres call sites contienen el mismo bucle while sobre internos, el recorrido pertenece a un iterador dentro de la colección, no al código cliente.

Lecturas Recomendadas

Preguntas frecuentes

¿En qué se diferencia de un simple bucle for?

Iterator separa el recorrido de la colección, permitiendo varios algoritmos y ocultando la estructura interna. Un for expone índices y detalles del array — bien para arrays, mal para árboles, grafos y streams perezosos.

¿Puedo usar esto con los iteradores nativos de JavaScript?

Sí. Implementa [Symbol.iterator] y usa generators para integrarte con for...of, spread y destructuring — los protocolos de iteración están diseñados exactamente para eso.

¿Cuándo debería usar iteradores asíncronos?

Para consultas paginadas a base de datos, lectura de archivos en streaming, o cualquier colección donde los elementos lleguen de forma asíncrona. for await...of da a los consumidores la misma ergonomía que la iteración síncrona sin materializar todo el conjunto.

¿Cómo hago mi clase iterable con for...of?

Implementa [Symbol.iterator]() devolviendo un objeto con next() que produzca pares { done, value } — cuando done es true, la iteración termina. La versión con generator (*[Symbol.iterator]() { ... yield ... }) gestiona el estado automáticamente y suele ser más corta.

¿Qué diferencia hay con el patrón Iterator genérico?

El patrón genérico describe los cuatro roles y el problema clásico. Este recurso lo aplica a colecciones personalizadas reales: recorrido multiorden de árboles, integración con Symbol.iterator/for...of, generators y streams paginados asíncronos en TypeScript.

¿Deberían los iteradores ser fail-fast como en Java?

Elige una semántica y documéntala. Fail-fast (lanzar cuando la colección muta a mitad de iteración) detecta bugs pronto; la semántica de snapshot (iterar una copia) cambia memoria por estabilidad. Saltar o repetir elementos en silencio — la tercera opción — es la única elección incorrecta.