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.
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”.
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
- Iterator declara la interfaz de recorrido con
next(),hasNext()yreset() - Iterador concreto implementa la lógica de recorrido para una estructura de colección concreta
- Agregado declara el método fábrica para crear iteradores
- 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.iteratorhace que tu clase funcione confor...of, spread ([...lista]), destructuring,Promise.ally 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 queSymbol.iteratorno 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:
- 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.
- 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. - Semántica de reinicio. ¿Puede reutilizarse un iterador consumido? La respuesta de Java es no (
Iteratores de un solo paso); la interfaz propia aquí ofrecereset().Symbol.iteratorlo resuelve distinto — cadafor...ofinvoca el método de nuevo, así que el iterable es reutilizable aunque cada iterador sea de un solo uso. - Valor de fin de iteración.
nullal agotarse (este ejemplo) frente a{ done: true }(el protocolo) frente a lanzar una excepción. Los llamadores escriben bucleswhilecontra 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.iteratorpara soporte nativo defor...ofen 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.iteratorhace cualquier iterable compatible confor...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,ArrayyNodeListson 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...ofsolo 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 índiceheado 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 quehasMorese calculó comopage.length > 0en lugar depage.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
whilesobre 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.
Recursos Relacionados
Patrón Iterator
Proporciona una forma de acceder a los elementos de una colección secuencialmente sin exponer su representación subyacente. Un patrón de comportamiento para recorrido.
PatternComposite Pattern para Arboles de Componentes UI en React
Usa el Composite pattern para componer objetos en estructuras de arbol, permitiendo que clientes traten objetos individuales y composiciones uniformemente en jerarquias de componentes
PatternAbstract Factory para Familias de Componentes UI
Crea familias de objetos relacionados sin especificar clases concretas, permitiendo implementaciones específicas de plataforma que comparten una interfaz común.
PatternInterpreter Pattern para Lenguajes de Expresion
Construye un interprete de lenguaje que evalua expresiones y reglas representando la gramatica como objetos componibles, util para formulas, queries y reglas de negocio
PatternPatrón Visitor: Operaciones Extensibles en TypeScript
Separa algoritmos de los objetos sobre los que operan, permitiendo añadir nuevas operaciones sin modificar las clases de elementos existentes
PatternPatrón Memento para Snapshots y Restauración de Estado
Captura y externaliza el estado interno de un objeto sin violar la encapsulación, habilitando undo, serialización y rollback de estado en aplicaciones