Generadores: la secuencia que nunca se construye
Envolver una corrutina para producir valores bajo demanda, recorrer un árbol sin materializar la lista y medir con honestidad qué cuesta un generador frente a una tabla ya construida.
Una corrutina que cede valores en lugar de devolverlos deja de ser una curiosidad del control y pasa a ser una estructura de datos que no existe. El generador es el primer edificio serio que se levanta sobre coroutine.yield: por fuera se comporta como una secuencia, por dentro es un proceso detenido a mitad de camino. La ganancia no es sintáctica. Un generador produce secuencias infinitas, recorre estructuras recursivas sin reificar su pila y puede abandonarse por la mitad sin haber pagado el resto. Lo que cobra es tiempo por elemento, y ese intercambio —memoria y trabajo diferidos a cambio de dos cambios de contexto por valor— es la única decisión de ingeniería que hay aquí.
- Convertir una corrutina en un iterador válido para el
forgenérico y explicar el papel de cada valor del protocolo. - Recorrer un árbol en profundidad con un generador y justificar por qué un closure necesitaría una pila explícita.
- Cerrar de forma determinista un generador abandonado usando la cuarta expresión del
forde Lua 5.4. - Medir el sobrecoste por elemento frente a una tabla materializada y decidir con datos, no por estética.
De corrutina a iterador
El for genérico no sabe nada de corrutinas. Pide una función a la que llamar en cada vuelta y termina cuando esa función devuelve nil como primer valor. coroutine.wrap fabrica precisamente eso: una función que en cada llamada reanuda una corrutina y entrega lo que la corrutina haya cedido.
local function cuadrados(n)
return coroutine.wrap(function()
for i = 1, n do
coroutine.yield(i, i * i)
end
end)
end
for i, c in cuadrados(4) do
print(i, c) --> 1 1 / 2 4 / 3 9 / 4 16
end
La expresión que sigue a in se evalúa una sola vez y produce tres valores: la función iteradora, el estado y el control inicial. Aquí solo hay el primero; los otros dos son nil y el bucle los pasa en cada llamada, de modo que la corrutina los recibe como resultado de su yield. Casi ningún generador los usa, pero conviene saber que están ahí: son el canal por el que el consumidor podría hablarle al productor, y en la lección siguiente se convertirá en el eje del asunto.
El final del bucle no lo decide una bandera sino la caída natural del cuerpo. Cuando la función interna retorna, la corrutina muere y wrap entrega sus valores de retorno, que al no existir se leen como nil, y el for se detiene. Llamar una vez más a ese mismo iterador ya no devuelve nil: lanza un error de corrutina muerta. El bucle nunca llega a hacerlo, pero cualquier código que guarde el iterador y lo reutilice sí.
Escribir el mismo iterador a mano deja el protocolo a la vista y muestra qué esconde wrap:
local function cuadrados_explicito(n)
local co = coroutine.create(function()
for i = 1, n do coroutine.yield(i, i * i) end
end)
return function()
local ok, a, b = coroutine.resume(co)
if not ok then error(a, 0) end
return a, b
end
end
Aquí se ve que el iterador es un closure sobre la corrutina, que el false de resume hay que traducirlo a un error de verdad y que los valores cedidos se reenvían sin tocarlos. coroutine.wrap es esa función, escrita en C y sin el coste de un marco Lua adicional por elemento.
Entre coroutine.wrap y la pareja coroutine.create con coroutine.resume hay una diferencia que no es de comodidad. wrap propaga los errores al llamante como si fueran suyos —y en Lua 5.4 además cierra la corrutina al hacerlo—, mientras que resume los devuelve como un false seguido del mensaje, obligándote a comprobar. wrap es la elección correcta para un iterador porque un fallo al producir debe interrumpir el bucle igual que interrumpiría a una función normal; resume es la elección correcta cuando el que reanuda es un planificador que no puede permitirse morir con su tarea.
El árbol que no se materializa
El caso donde el generador deja de ser una comodidad y pasa a ser insustituible es el recorrido de una estructura recursiva.
local function recorrer(nodo)
if nodo == nil then return end
recorrer(nodo.izq)
coroutine.yield(nodo.valor)
recorrer(nodo.der)
end
local function valores(raiz)
return coroutine.wrap(function() recorrer(raiz) end)
end
for v in valores(arbol) do
if v > 100 then break end
procesar(v)
end
La cesión ocurre dentro de una llamada anidada a varios niveles de profundidad, y funciona. La frontera que importa no es la de la función sino la de la corrutina: yield suspende toda la pila de la corrutina, con sus marcos de recorrer intactos, y resume la restaura tal cual. La recursión sigue viva mientras el consumidor hace otra cosa.
Compáralo con la alternativa sin corrutinas. Un iterador escrito como closure tiene un único estado plano —un índice, un puntero— y para recorrer un árbol en orden necesita convertir la pila de llamadas del recorrido en una tabla que tú mismo empujas y desapilas. Ese código existe, es correcto y es mucho más rápido, pero es una reimplementación manual de algo que el intérprete ya sabe hacer. El generador no ahorra trabajo al ordenador: ahorra la reificación del estado de control.
flowchart LR C[Consumidor en el for] -->|resume| G[Corrutina generadora] G -->|pila propia con marcos de recorrer| R[Nodo actual] G -->|yield del valor| C C -->|break| S[Corrutina suspendida para siempre]
Ese último nodo del diagrama es el precio del break. Una corrutina abandonada queda suspendida, viva y alcanzable hasta que el recolector la reclame, y con ella todo lo que su pila retenga: descriptores de fichero abiertos, cierres pendientes, bloqueos. En Lua 5.4 la solución es determinista y usa la cuarta expresión del for genérico, la variable de cierre:
local function valores_cerrables(raiz)
local co = coroutine.create(function() recorrer(raiz) end)
local guarda = setmetatable({}, {
__close = function() coroutine.close(co) end
})
local function siguiente()
local ok, v = coroutine.resume(co)
if not ok then error(v, 0) end
return v
end
return siguiente, nil, nil, guarda
end
Al salir del bucle, sea por agotamiento, por break, por return o por error, Lua cierra guarda, que a su vez cierra la corrutina, lo que ejecuta sus variables de cierre pendientes y libera su pila en el acto. Sin esa cuarta expresión el mismo programa es correcto pero no es predecible, y la diferencia solo se nota cuando el recurso retenido es escaso.
Lo que cuesta de verdad
Cada elemento de un generador cuesta dos transferencias de control: una para reanudar y otra para ceder. No hay hilos del sistema ni llamadas al núcleo, pero sí hay guardado y restauración de estado, ajuste de pilas y una llamada a función que el intérprete no puede convertir en un salto. Frente a recorrer una tabla ya construida con ipairs, el factor típico está entre tres y diez veces por elemento; mídelo en tu versión antes de citarlo, porque depende del intérprete y de cuánto trabajo real haya entre cesiones.
local t0 = os.clock()
local suma = 0
for v in valores(arbol) do suma = suma + v end
local con_generador = os.clock() - t0
local lista = {}
local function llenar(n)
if n == nil then return end
llenar(n.izq) ; lista[#lista + 1] = n.valor ; llenar(n.der)
end
t0 = os.clock()
llenar(arbol)
for i = 1, #lista do suma = suma + lista[i] end
local con_tabla = os.clock() - t0
Esa medida sola engaña, y por eso conviene enunciar el criterio con precisión. La tabla paga una vez O(n) en memoria y recorre entero antes de entregar nada; el generador paga O(profundidad) en memoria y solo produce lo que se le pide. Si vas a consumir la secuencia completa muchas veces, materializa una vez y recorre la tabla. Si consumes un prefijo, si la secuencia es infinita, si cada elemento cuesta caro de producir o si n no cabe cómodamente en memoria, el generador gana por un margen que ninguna constante compensa.
Queda un coste que las mediciones ingenuas omiten: la corrutina misma no es gratis. Crear una reserva un objeto de hilo con su pila, del orden de unos cientos de bytes antes de crecer, y ese objeto vive hasta que el recolector lo reclame. Un generador por elemento de una colección grande es un antipatrón caro; un generador por colección es despreciable.
El consumidor manda
El for decide cuándo se produce el siguiente valor y puede irse cuando quiera. Toda la pereza sale de ahí.
La pila es el estado
Un generador recursivo no necesita reificar su recorrido: la pila de la corrutina ya es la estructura que un closure tendría que copiar a mano.
Abandonar tiene precio
Una corrutina cortada con break queda suspendida y retiene lo que su pila sujete. La cuarta expresión del for lo cierra en el acto.
Mide antes de elegir
Dos cambios de contexto por elemento frente a un acceso indexado. La tabla gana si consumes todo muchas veces; el generador, en todo lo demás.
Estamos tan acostumbrados a que los datos y el control sean categorías separadas —esto es una lista, aquello es un bucle— que el generador se lee al principio como un truco de implementación. No lo es. Es la afirmación de que la diferencia entre una secuencia y el proceso que la produce es una convención de nuestra máquina, no una verdad sobre el problema. La lista de los números primos no está en ninguna parte; lo que existe es un procedimiento capaz de entregar el siguiente cuando alguien lo pide, y una tabla de los primeros mil primos no es esa lista sino una fotografía de una parte de ella. Lo que Lua te da con doce caracteres es la posibilidad de programar contra el procedimiento en vez de contra la fotografía, y de decidir después, con un perfilador delante, cuánta fotografía te conviene guardar. La consecuencia práctica es que la pregunta de diseño deja de ser cómo represento esta colección y pasa a ser quién decide cuándo se produce el siguiente elemento. Esa pregunta —quién decide— es la que gobierna las cuatro lecciones siguientes, y el generador es el caso más simple posible: decide el consumidor, siempre, y por eso puede irse cuando quiera.
- Escribe un generador de los números de Fibonacci sin límite superior y consúmelo con un
forque corte en el primero que supere un millón. Comprueba que el programa termina y explica qué queda de la corrutina. - Implementa el recorrido en orden del árbol de dos formas: con
coroutine.wrapy con un closure que mantenga una pila explícita en una tabla. Cuenta las líneas de cada una y mide ambas sobre un árbol de cien mil nodos. - Introduce un
erroren mitad del generador y observa la diferencia entre envolverlo concoroutine.wrapy reanudarlo a mano concoroutine.resume. Anota qué versión te deja el traceback útil. - Añade a
valores_cerrablesun fichero abierto dentro de la corrutina y una variable de cierre que lo cierre. Sal del bucle conbreakcon y sin la cuarta expresión, y verifica cuándo se cierra realmente el descriptor. - Encadena dos generadores, uno que produzca y otro que filtre lo producido, y mide el coste por elemento. Compara con el mismo filtrado hecho sobre una tabla ya materializada y decide en qué tamaño se cruzan las curvas.