Iteradores con estado: closures que recuerdan la posición
Cuando la posición del recorrido no cabe en la variable de control hay que guardarla en alguna parte, y esa parte suele ser un closure creado una vez por bucle. Qué cuesta exactamente esa fábrica, qué se gana a cambio, por qué un iterador con estado no es reentrante ni reiniciable, y la alternativa de la tabla de estado explícita.
Hay recorridos cuya posición no es un valor sino una historia. Recorrer un árbol en profundidad exige recordar las ramas pendientes; emitir los elementos de dos colecciones encadenadas exige saber en cuál estás; entregar las coincidencias de un patrón dentro de una cadena exige recordar dónde terminó la anterior. En todos esos casos la tripleta del for genérico se queda corta, porque un solo valor de control no puede representar la posición, y la historia hay que guardarla en alguna parte. La respuesta idiomática de Lua es el closure: una función creada al entrar en el bucle, con la historia en sus upvalues, que el bucle llama sin pasarle nada útil. Es el patrón más común en la biblioteca estándar y también el más caro, y saber cuándo compensa exige medir el coste real en lugar de repetir la consigna de que las asignaciones son malas.
- Reconocer los recorridos cuya posición no cabe en la variable de control.
- Escribir la fábrica que devuelve un closure iterador y entender qué valores sobran.
- Cuantificar el coste de crear un iterador por bucle y sus consecuencias.
- Elegir entre closure, tabla de estado explícita y versión sin estado con criterio.
La fábrica y su closure
El patrón tiene siempre la misma forma. Una función externa recibe la estructura, prepara la historia en locales y devuelve una función interna que la captura. El bucle recibe esa función como primer valor de la tripleta y nulo en los otros dos, porque el iterador no los usa.
local function elementos(t)
local i = 0 -- la historia vive aqui
return function() -- el bucle llamara a esta con dos nil
i = i + 1
if t[i] ~= nil then return i, t[i] end
end
end
for i, v in elementos({ "a", "b", "c" }) do print(i, v) end
Compárese con el iterador sin estado equivalente de la lección anterior y se verá que la diferencia no está en el trabajo por vuelta, que es el mismo, sino en quién guarda el índice: allí lo guardaba el bucle en su variable de control, aquí lo guarda un upvalue. El bucle sigue llamando a la función con el estado y el control, pero ambos son nulos y la función los ignora.
El caso donde la técnica deja de ser opcional aparece en cuanto la historia crece. Un recorrido en profundidad de un árbol necesita una pila de nodos pendientes, y una pila no cabe en un valor de control:
local function enProfundidad(raiz)
local pila = { raiz } -- historia: las ramas por visitar
return function()
local nodo = table.remove(pila)
if nodo == nil then return nil end
for k = #nodo.hijos, 1, -1 do
pila[#pila + 1] = nodo.hijos[k]
end
return nodo
end
end
for nodo in enProfundidad(arbol) do print(nodo.nombre) end
Aquí no hay codificación aritmética que salve la situación: la pila es información genuinamente acumulada y su tamaño depende de la forma del árbol. Intentar empaquetarla en un entero sería reinventar una estructura de datos dentro de un número, que es el tipo de heroísmo que produce código incomprensible.
Las coincidencias de un patrón, las líneas de un fichero y cualquier función que devuelva un iterador sin pedirte que le pases nada en cada vuelta son iteradores con estado. Reconocerlos importa porque comparten todas sus limitaciones: no se reinician, no se anidan sobre sí mismos y se consumen una sola vez.
Qué cuesta y qué se pierde
El coste tiene tres componentes y conviene separarlos porque solo uno de ellos importa de verdad.
La asignación del closure
Cada entrada al bucle crea un objeto función nuevo. En un bucle exterior es ruido; en un bucle interno ejecutado un millón de veces es un millón de objetos que el recolector tendrá que visitar.
Los upvalues que se cierran
Las locales capturadas dejan de vivir en la pila y pasan a celdas propias. Cada acceso desde dentro del closure es una indirección más que un acceso a una local.
La presión sobre el recolector
Los closures son basura de vida cortísima, justo el perfil que el recolector generacional maneja mejor. El coste real suele ser menor de lo que la intuición sugiere, pero no es cero.
El cierre no es gratis tampoco
Si el iterador abre un recurso, la tripleta debe llevar un cuarto valor para soltarlo, y ese cuarto valor es otro objeto con su metatabla. Vale la pena, pero hay que contarlo.
Lo que se pierde es más interesante que lo que se paga, porque no se compra de vuelta con hardware. Un iterador con estado no es reentrante: si guardas el closure y lo usas en dos bucles a la vez, ambos comparten la historia y el resultado es un desastre silencioso. No es reiniciable: una vez agotado, devuelve nulo para siempre, y para recorrer otra vez hay que volver a llamar a la fábrica. Y no es inspeccionable: la posición está escondida en upvalues a los que solo se llega con la biblioteca de depuración.
flowchart TD A[Necesito recorrer una estructura] --> B[La posicion cabe en un solo valor] B --> C[Si cabe escribir un iterador sin estado y compartirlo] B --> D[Si no cabe hace falta guardar historia] D --> E[El bucle se entra pocas veces y prefiero claridad] D --> F[El bucle se entra muchisimas veces y mido asignaciones] E --> G[Closure creado por la fabrica] F --> H[Tabla de estado explicita con funcion compartida]
La alternativa: tabla de estado explícita
Entre el iterador sin estado y el closure hay un punto intermedio que se usa poco y resuelve bien varios casos: una función iteradora compartida que recibe como estado invariante una tabla mutable donde guarda la historia.
local function pasoProfundidad(est) -- funcion unica, compartida
local nodo = table.remove(est.pila)
if nodo == nil then return nil end
for k = #nodo.hijos, 1, -1 do
est.pila[#est.pila + 1] = nodo.hijos[k]
end
est.visitados = est.visitados + 1
return nodo
end
local function enProfundidad(raiz)
return pasoProfundidad, { pila = { raiz }, visitados = 0 }, nil
end
La cuenta de objetos es parecida —una tabla y su vector interno frente a un closure y sus celdas—, pero las propiedades cambian. La función es única y se crea al cargar el módulo, no por bucle. El estado es un valor ordinario que puedes leer desde fuera, guardar, serializar o reiniciar. Y el recorrido queda documentado en la estructura de la tabla en lugar de en unos upvalues invisibles. A cambio, cada acceso a la historia es una consulta a tabla en lugar de un acceso a upvalue, que es algo más caro por vuelta.
La conclusión operativa no es que una forma gane siempre, sino que la elección tiene un criterio claro: si el bucle se entra pocas veces y ejecuta muchas vueltas, el closure es preferible porque su coste se amortiza en la primera vuelta y su código se lee mejor; si el bucle se entra muchísimas veces y ejecuta pocas vueltas —el perfil típico de un recorrido dentro de otro recorrido—, la asignación por entrada domina y conviene la versión sin estado o la tabla reutilizable.
El error más difícil de diagnosticar de esta lección consiste en guardar el resultado de la fábrica en una variable y usarlo en dos sitios. El segundo bucle empieza donde terminó el primero, o no empieza en absoluto, y no hay error ni aviso. Si tu API entrega iteradores, entrega fábricas y documenta que cada llamada produce un recorrido nuevo.
La comparación completa de las tres formas cabe en una tabla, y conviene tenerla presente porque las diferencias que importan no son las de velocidad.
| Aspecto | Sin estado | Closure | Tabla de estado |
|---|---|---|---|
| Objetos creados por bucle | Ninguno | Uno, más sus celdas | Uno, la tabla |
| Dónde vive la posición | En la variable de control | En upvalues | En campos de la tabla |
| Reentrante | Sí | No | No, pero el estado es visible |
| Reiniciable | Sí, con otra tripleta | No | Sí, reponiendo los campos |
| Inspeccionable desde fuera | Trivialmente | Solo con la biblioteca de depuración | Sí |
| Acceso a la historia | Argumento | Upvalue | Consulta a tabla |
| Puede guardar historia arbitraria | No | Sí | Sí |
La fila decisiva es la última. Todo lo demás son matices de coste y comodidad; la capacidad de guardar una historia de tamaño arbitrario es lo único que un iterador sin estado no puede ofrecer bajo ninguna circunstancia, y por tanto es la única razón legítima para pagar una fábrica por bucle. Cuando alguien escribe un closure para recorrer un vector, no está eligiendo entre alternativas: está pagando sin comprar.
Sobre Lua 5.5 conviene añadir una precisión que aquí es fácil de pasar por alto. Las variables que el bucle asigna en cada vuelta son inmutables dentro del cuerpo, y eso vale igual para un iterador con estado, aunque su variable de control real sea nulo: lo que el cuerpo recibe son los valores devueltos, y esos son de solo lectura. La historia interna del closure, en cambio, sigue siendo perfectamente mutable, porque vive en upvalues y no en variables del bucle. La regla nueva delimita con nitidez las dos zonas: lo que el iterador te entrega no se toca, lo que el iterador guarda es asunto suyo.
Vale la pena nombrar con precisión lo que ocurre cuando una fábrica devuelve un closure, porque el nombre correcto explica de golpe todas sus limitaciones. Lo que la fábrica devuelve no es una función en el sentido corriente: es una continuación reificada, un objeto que encapsula dónde se había quedado el cómputo y que, al invocarse, avanza ese cómputo un paso y vuelve a guardarse. Por eso un iterador con estado se comporta como un proceso y no como una fórmula, y por eso ninguna de sus limitaciones es un defecto de implementación que una versión futura pudiera arreglar. No es reentrante porque un proceso tiene un solo punto de ejecución y dos consumidores simultáneos lo empujarían en direcciones incompatibles. No es reiniciable porque una continuación describe un futuro, no un recorrido completo, y el futuro de un cómputo agotado está vacío. Y no es inspeccionable porque el estado de una continuación es su marco de activación, que el lenguaje deliberadamente no expone. Esta lectura también aclara la relación con las corrutinas, que es el tema de la lección siguiente: una corrutina es una continuación reificada con pila propia, y por eso puede suspenderse en mitad de una llamada anidada, cosa que un closure no puede hacer. El closure iterador es la versión pobre y barata de esa idea, capaz de recordar solo lo que quepa en sus upvalues y obligada a aplanar a mano cualquier recursión, que es exactamente lo que hace la pila explícita del recorrido en profundidad de más arriba. Ahí está el criterio de diseño que ordena todo el nivel: la elección entre iterador sin estado, closure y corrutina no es una escala de comodidad ni de rendimiento, es una escala de cuánta continuación necesitas materializar. Nada, un puñado de upvalues, o una pila entera. Cada escalón cuesta más y compra más, y elegir el más barato que resuelva el problema es la única regla que no envejece.
- Escribe la versión con closure y la versión sin estado del mismo recorrido y compáralas dentro de un bucle exterior de un millón de vueltas.
- Guarda un iterador con estado en una variable, úsalo en dos bucles seguidos y explica la salida del segundo.
- Convierte el recorrido en profundidad a la forma de tabla de estado explícita y añade un contador de nodos visitados legible desde fuera.
- Escribe una fábrica que encadene dos tablas y entregue sus elementos como una sola secuencia. Razona por qué la posición no cabe en un valor.
- Añade un cuarto valor de cierre a un iterador con estado que abra un recurso y comprueba que se libera al salir con
break.