wandres.dev
ITERADORES · el protocolo del for

Iteradores sin estado: cuando la posición cabe en la tripleta

Un iterador sin estado es una función pura de estado y control que no recuerda nada entre llamadas porque no le hace falta: el bucle le devuelve la posición en cada vuelta. Por qué son los más baratos del lenguaje, qué condición debe cumplir la posición para caber en un solo valor, y cómo escribir uno para una estructura propia.

⏱ 17 min

Hay una categoría de iteradores que no asigna memoria, no crea closures, no toca el recolector y puede compartirse entre todos los bucles del programa a la vez, incluso desde varias corrutinas simultáneas. Se llaman iteradores sin estado y su definición es puramente negativa: no guardan nada entre llamadas. Lo consiguen porque no lo necesitan, y no lo necesitan porque el for genérico ya les devuelve la posición en cada vuelta, empaquetada en la variable de control. Entender esta clase es entender qué papel juegan de verdad los dos argumentos que recibe la función iteradora, y descubrir que la mayor parte de los recorridos que uno escribe a mano con closures podrían haberse escrito sin ninguno. Los dos iteradores más usados de la biblioteca básica, next y el contador interno de ipairs, pertenecen a esta categoría, y no por casualidad.

🎯 Al terminar esta lección sabrás
  • Definir un iterador sin estado como función pura de estado y control.
  • Justificar por qué no asignan memoria y qué implica eso para el recolector.
  • Determinar cuándo la posición cabe en un solo valor y cuándo hay que codificarla.
  • Escribir iteradores sin estado para una lista enlazada y para una matriz propia.

La condición: la posición cabe en un valor

La función iteradora recibe siempre dos argumentos, el estado invariante y la variable de control, y el bucle actualiza el segundo con el primer valor que ella devuelve. Un iterador es sin estado cuando esos dos argumentos bastan para calcular la vuelta siguiente, sin consultar ni modificar nada más.

Formalmente es una función pura: para el mismo par de argumentos devuelve siempre lo mismo. Y de esa pureza se sigue todo lo demás. Puede ser un valor único creado una sola vez al cargar el módulo, porque no hay nada que particularizar por bucle. Puede usarse desde dos bucles anidados sobre la misma estructura sin interferencias, porque cada bucle lleva su propia variable de control. Y no cuesta ni una asignación de memoria, porque construir la tripleta es devolver tres valores que ya existen.

local function siguiente(n, i)   -- estado: el limite; control: el ultimo entero
  i = i + 1
  if i <= n then return i end
end

local function hasta(n)
  return siguiente, n, 0         -- ni un solo objeto nuevo por bucle
end

for i in hasta(4) do print(i) end

La condición práctica que hay que comprobar antes de intentarlo es una sola: la posición del recorrido debe caber en un valor. Un índice cabe. Un nodo de una lista cabe. Un par de coordenadas no cabe tal cual, pero sí cabe codificado en un entero si conoces la anchura. Una posición dentro de un flujo de red que no puede rebobinarse no cabe de ninguna manera, y ahí es donde empieza la lección siguiente.

💡
La comprobación mental es preguntarse si puedes reanudar

Si alguien te entrega el estado y el control a medio recorrido, sin más contexto, ¿podrías continuar desde ahí? Si la respuesta es afirmativa, el iterador puede ser sin estado. Si necesitarías saber además algo que ocurrió antes —cuántos elementos llevas, qué pila de nodos pendientes tenías, si ya emitiste el separador—, entonces hace falta estado.

Coste cero por bucle

La diferencia de coste frente a un iterador con closure no está en la llamada, que es idéntica, sino en la preparación. Cada evaluación de la lista de expresiones de un for con closure crea al menos un objeto nuevo, con su asignación de memoria y su futura visita del recolector. Un iterador sin estado no crea ninguno.

flowchart TD
A[Entrar en el bucle] --> B[Evaluar la lista de expresiones]
B --> C[Iterador sin estado devuelve tres valores existentes]
B --> D[Iterador con estado crea un closure nuevo]
C --> E[Cero asignaciones y cero presion sobre el recolector]
D --> F[Una asignacion por bucle mas sus upvalues]
E --> G[Llamadas por vuelta identicas en ambos casos]
F --> G

Esa asimetría es irrelevante en un bucle que se ejecuta una vez y decisiva en un bucle interno que se entra millones de veces, que es la situación habitual en un motor de juego, un intérprete embebido o un manejador de eventos. La regla de oficio que se deduce es directa: si el recorrido puede escribirse sin estado, escríbelo sin estado, no por microoptimización sino porque suele salir además más simple de leer, ya que obliga a hacer explícito qué constituye una posición.

🔁

next es el ejemplo canónico

Recibe la tabla y una clave, devuelve la siguiente. No recuerda nada, y por eso puedes anidar dos recorridos sobre la misma tabla sin que se estorben.

🧮

El contador de ipairs también

Su estado es la tabla y su control es el último índice entregado. La biblioteca crea esa función una sola vez y la comparte con todos los bucles del programa.

🧵

Seguro entre corrutinas

Al no guardar nada, dos corrutinas pueden estar a media vuelta sobre la misma estructura sin corromperse. Un iterador con closure compartido no ofrece esa garantía.

🧊

Se deja precalcular

La tripleta puede guardarse en una constante del módulo y reutilizarse. Es el truco detrás de idiomas como escribir el recorrido con la función y la tabla directamente en la lista de expresiones.

Dos recorridos propios

El primer caso es una lista enlazada simple. La posición del recorrido es el nodo actual, y un nodo es un valor; así que el iterador puede ser sin estado y ni siquiera necesita el estado invariante.

local Lista = {}

local function siguienteNodo(_, nodo)      -- ignora el estado invariante
  nodo = nodo.sig
  if nodo then return nodo, nodo.valor end -- primero la posicion, luego el dato
end

function Lista.nodos(lista)
  return siguienteNodo, nil, lista         -- la cabecera hace de centinela
end

-- la cabecera lleva el mismo campo sig que los nodos, asi que no hay caso especial
local l = { sig = { valor = "a", sig = { valor = "b" } } }
for nodo, valor in Lista.nodos(l) do print(valor) end

Obsérvese la disciplina del orden de los valores devueltos: el nodo va primero porque es la posición, y el dato va después. Si se invirtiesen, un valor nulo almacenado en la lista cortaría el recorrido, y además el bucle alimentaría la variable de control con un dato en lugar de con una posición, que es la forma más rápida de escribir un iterador que no avanza.

El segundo caso es una matriz guardada en un vector plano, donde la posición son dos coordenadas y hay que codificarlas. La codificación evidente es el propio índice lineal, del que las coordenadas se recuperan con una división entera y un módulo.

local function siguienteCelda(m, k)
  k = k + 1
  if k > m.filas * m.cols then return end
  local f = (k - 1) // m.cols + 1
  local c = (k - 1) % m.cols + 1
  return k, f, c, m.datos[k]      -- posicion, fila, columna, valor
end

local function celdas(m)
  return siguienteCelda, m, 0
end

local m = { filas = 2, cols = 3, datos = { 1, 2, 3, 4, 5, 6 } }
for _, f, c, v in celdas(m) do print(f, c, v) end

Aquí el iterador devuelve cuatro valores y el bucle declara cuatro variables, descartando la primera con el nombre convencional de descarte. Es un patrón que merece reconocerse: cuando la posición no coincide con lo que el consumidor quiere ver, se emite igualmente en primer lugar, porque el protocolo la necesita, y se ignora en el cuerpo.

Las dos fábricas de esta lección, Lista.nodos y celdas, no crean nada: devuelven una función que ya existía desde que se cargó el módulo, un valor que el llamante ya tenía y una constante. Esa es la firma característica de la categoría, y es la comprobación que conviene hacer al terminar de escribir un iterador que se creía sin estado. Si la fábrica construye una tabla, un closure o cualquier objeto, algo se ha colado.

🔄

Se reinician gratis

Para volver a empezar basta con llamar otra vez a la fábrica, o incluso con reutilizar la misma tripleta, porque no hay nada consumido que reponer.

🎯

Permiten arrancar por la mitad

Como la posición es un valor ordinario, puedes pasar la que quieras como control inicial y el recorrido empezará ahí. Es imposible con un closure que fija su historia al construirse.

📤

Se exportan como constantes

La función iteradora puede publicarse en el módulo y usarse directamente en la lista de expresiones, igual que se hace con next, sin pasar por ninguna fábrica.

🧪

Se prueban sin bucle

Al ser puras, se comprueban llamándolas con pares concretos de estado y control y comparando los resultados, sin montar un recorrido completo.

📝
Lua 5.5 y la variable de control

En Lua 5.5 las variables que el bucle asigna en cada vuelta son de solo lectura dentro del cuerpo, y eso incluye la que alimenta la posición. Nunca hubo forma de saltar elementos escribiendo en ella —el bucle la reasigna al comienzo de la vuelta siguiente con el valor que devuelva el iterador—, así que la regla nueva solo convierte en error de compilación algo que antes era un malentendido silencioso. Quien necesite saltar posiciones debe hacerlo dentro del propio iterador, que es donde vive la aritmética del recorrido.

Un iterador sin estado es una relación de recurrencia disfrazada

Merece la pena mirar esta categoría con las gafas correctas, porque entonces deja de ser un truco de rendimiento y se convierte en una forma de pensar. Un iterador sin estado es literalmente la función de transición de una recurrencia: dado el elemento actual y un contexto fijo, produce el siguiente. Es la misma criatura matemática que aparece en la definición de una sucesión por recurrencia, en la función de transición de un autómata determinista y en el paso de inducción de una demostración. Y esa identidad no es una analogía bonita: es la razón de todas sus propiedades operativas. Es reentrante porque una función pura lo es. Es segura entre corrutinas porque una función pura lo es. Se puede reanudar desde cualquier punto porque una recurrencia solo depende del punto anterior. Y no necesita memoria porque el bucle actúa como la cinta que guarda el término actual, exactamente igual que en el cálculo a mano de una sucesión donde el papel guarda el último valor y tú solo aplicas la regla. Vista así, la pregunta ¿puedo escribir este recorrido sin estado? se traduce en una pregunta mucho más precisa y mucho más útil: ¿es mi recorrido una recurrencia de primer orden?. Si el siguiente elemento depende solo del actual, la respuesta es afirmativa y el iterador saldrá sin closures. Si depende de una historia —de cuántos elementos llevas, de una pila de ramas pendientes, de un buffer parcialmente consumido—, entonces el recorrido tiene un orden mayor y esa historia hay que guardarla en alguna parte. Ahí es donde aparecen las dos únicas salidas honestas: codificarla en la variable de control, que es lo que hace el índice lineal de la matriz, o aceptar un iterador con estado. Elegir a ciegas entre las dos es lo que produce código enrevesado; elegir sabiendo el orden de la recurrencia es diseño.

⚔️ Encuentra la recurrencia
  1. Escribe un iterador sin estado que recorra una cadena carácter a carácter, con el índice como posición.
  2. Convierte el recorrido de la matriz para que avance por columnas en lugar de por filas, cambiando solo la aritmética de decodificación.
  3. Anida dos recorridos con next sobre la misma tabla y comprueba que no interfieren. Explica por qué.
  4. Escribe un iterador sin estado que entregue los enteros de un rango con un paso arbitrario, y razona por qué el paso debe ir en el estado y no en el control.
  5. Toma un recorrido en profundidad de un árbol binario e intenta escribirlo sin estado. Documenta exactamente dónde falla la condición y qué historia te falta.