Personalizar la iteración: `__pairs`, corrutinas y composición
Cómo hacer que una tabla se recorra a tu manera con `__pairs`, por qué ese metamétodo hace mentir a `pairs` y cuándo conviene evitarlo. Iteradores construidos con corrutinas, que escriben solos los recorridos recursivos y traen su propio coste. Y la composición de iteradores como filtros y transformaciones perezosas sin estructuras intermedias.
Las cuatro lecciones anteriores han desmontado el protocolo y han mostrado las dos formas de cumplirlo. Queda la parte que convierte todo eso en una herramienta de diseño: intervenir el recorrido ajeno, generar recorridos que serían insoportables de escribir a mano y encadenar iteradores para construir tuberías que no materializan nada. Son tres técnicas de naturaleza muy distinta y con reputaciones opuestas. El metamétodo __pairs es la más tentadora y la más peligrosa, porque hace mentir a una función que todo el mundo cree conocer. Las corrutinas son la más expresiva y la más cara, porque compran una pila entera para poder suspenderse dentro de una recursión. Y la composición es la más humilde y la más rentable, porque no necesita nada que no hayas visto ya y sin embargo reemplaza a bibliotecas enteras de otros lenguajes.
- Instalar
__pairsen una metatabla y enumerar sus límites y sus riesgos. - Construir iteradores con corrutinas y cerrarlos correctamente al abandonar el bucle.
- Componer filtros y transformaciones sobre una tripleta existente sin materializar datos.
- Elegir entre las tres técnicas según el coste y la honestidad del resultado.
__pairs y el precio de mentir
Cuando pairs recibe un valor cuya metatabla tiene la clave __pairs, no devuelve next y la tabla: llama a ese metamétodo con el valor como único argumento y devuelve los tres primeros resultados de la llamada. El metamétodo se convierte así en la fábrica de la tripleta, y puede devolver la que quiera.
local datos = { alfa = 1, beta = 2, gamma = 3 }
local vista = setmetatable({}, {
__index = datos,
__pairs = function()
local claves = {}
for k in next, datos do claves[#claves + 1] = k end
table.sort(claves)
local i = 0
return function()
i = i + 1
local k = claves[i]
if k ~= nil then return k, datos[k] end
end
end,
})
for k, v in pairs(vista) do print(k, v) end -- alfa, beta, gamma, siempre
El ejemplo enseña de paso dos detalles del contrato. El primero es que __pairs devuelve tres valores, no cuatro: no hay manera de suministrar un valor de cierre por esta vía, así que un recorrido personalizado que abra recursos debe exponerse como función propia y no colgarse de pairs. El segundo es que next no consulta el metamétodo: escribir for k in next, vista do esquiva la personalización por completo y ve la tabla real, que aquí está vacía.
Ese segundo detalle es también la objeción de fondo. Instalar __pairs significa que dos formas de recorrer el mismo valor dan resultados distintos, y que cualquier herramienta escrita por otros —un serializador, un comparador estructural, un inspector de depuración— verá una cosa o la otra según qué haya usado su autor. La tabla pasa a tener dos verdades.
El patrón habitual consiste en una tabla vacía con __index apuntando a los datos reales y un __pairs que los recorre. Funciona para el consumidor disciplinado y falla para todo lo demás: el operador de longitud, next, ipairs sin __index, y cualquier código en C que use la interfaz de bajo nivel ven la tabla vacía. Si necesitas una vista, casi siempre sale mejor exponer un método explícito que sustituir el significado de pairs.
Iteradores con corrutinas
Un recorrido recursivo escrito como iterador con estado obliga a aplanar la recursión en una pila explícita, y el resultado se aleja tanto del algoritmo original que a menudo deja de ser legible. La corrutina elimina ese trabajo: se escribe el recorrido de la forma natural, se emite cada elemento con una suspensión y alguien fuera lo consume.
local function enOrden(nodo)
if nodo == nil then return end
enOrden(nodo.izq)
coroutine.yield(nodo.valor) -- suspende en mitad de la recursion
enOrden(nodo.der)
end
local function valores(raiz)
return coroutine.wrap(function() enOrden(raiz) end)
end
for v in valores(arbol) do print(v) end
La función que devuelve coroutine.wrap cumple el protocolo sin adaptador: se llama sin argumentos útiles, devuelve lo que la corrutina emita y devuelve nulo cuando el cuerpo termina. Es un iterador con estado llevado al extremo, porque su historia no son unos upvalues sino una pila de llamadas completa, y por eso puede suspenderse a cinco niveles de profundidad, cosa que ningún closure puede hacer.
El precio tiene dos partes. La primera es la memoria: cada corrutina reserva su propia pila, mucho más que un closure. La segunda es el cambio de contexto, dos por vuelta, que aunque en Lua es notablemente barato no es comparable a una llamada ordinaria. Y hay un tercer coste que no se ve: una corrutina abandonada a medias queda suspendida, viva y reteniendo todo lo que su pila referencia hasta que el recolector la alcance.
local function valoresSeguros(raiz)
local co = coroutine.create(function() enOrden(raiz) end)
local guardia = setmetatable({}, {
__close = function() coroutine.close(co) end,
})
return function()
local ok, v = coroutine.resume(co)
if not ok then error(v, 0) end
return v
end, nil, nil, guardia -- el cuarto valor cierra la corrutina
end
Esta versión es la profesional. Usa la corrutina explícita en lugar de la envoltura, propaga los errores conservando el mensaje original y aprovecha el cuarto valor del protocolo para cerrar la corrutina al salir del bucle, incluso con break o con un error, liberando sus variables de cierre pendientes de inmediato.
Componer iteradores
La composición es la técnica que más rendimiento da por línea escrita, y no necesita nada nuevo. Un filtro es una función que recibe una tripleta y devuelve otra que entrega solo los elementos que pasan una condición.
local function filtrar(pred, f, s, ctrl)
return function()
while true do
local k, v = f(s, ctrl)
if k == nil then return nil end
ctrl = k -- el filtro mantiene su propio control
if pred(k, v) then return k, v end
end
end
end
local function transformar(fn, f, s, ctrl)
return function()
local k, v = f(s, ctrl)
if k == nil then return nil end
ctrl = k
return k, fn(k, v)
end
end
El detalle elegante es cómo se invocan. Como pairs devuelve tres valores y una llamada en última posición se expande, la tripleta entra sola:
local caros = filtrar(function(_, v) return v.precio > 100 end, pairs(inventario))
for k, v in transformar(function(_, v) return v.precio end, caros) do
print(k, v)
end
flowchart LR A[Fuente pairs sobre el inventario] --> B[Filtro deja pasar los caros] B --> C[Transformacion emite solo el precio] C --> D[Bucle for consume una vuelta cada vez] D --> E[Nada intermedio se materializa en memoria]
Dos propiedades merecen subrayarse. La primera es que la tubería es perezosa: no se construye ninguna tabla intermedia, cada elemento atraviesa las tres etapas antes de que el siguiente empiece, y el consumo de memoria no depende del tamaño de la fuente. La segunda es que un break en el cuerpo detiene la tubería entera al instante, porque nadie sigue tirando de la fuente. Ese modelo de tirón es lo que hace que componer sea seguro incluso sobre fuentes infinitas.
El filtro necesita su propio control
Al saltarse elementos, la variable de control del bucle exterior dejaría de coincidir con la de la fuente. Por eso el filtro la mantiene en un upvalue y devuelve una función, no una tripleta.
Las fuentes infinitas son legítimas
Un iterador que nunca devuelve nulo es perfectamente utilizable si alguien más arriba corta. Componer una fuente infinita con un filtro y un límite es un idioma habitual.
Cada etapa cuesta una llamada
La pereza no es gratis: tres etapas son tres llamadas por elemento. En bucles críticos suele salir mejor una función que haga las tres cosas de una vez.
No hace falta biblioteca
Lo que en otros lenguajes es un módulo de flujos con su jerarquía de clases aquí son diez líneas, porque el protocolo ya era el punto de extensión.
Una nota final del presente. Bajo Lua 5.5 las variables que cada etapa entrega al cuerpo son inmutables, de modo que la costumbre de normalizar la clave o el valor reasignándolos dentro del bucle deja de compilar; el sitio correcto para esa normalización es precisamente una etapa de transformación como la de arriba, que además la hace reutilizable. La regla nueva empuja, sin proponérselo, hacia el estilo compositivo.
Conviene cerrar el nivel mirando lo que acaba de ocurrir en las últimas veinte líneas, porque es una demostración de diseño y no un ejercicio. Otros lenguajes resuelven el problema de recorrer, filtrar y transformar con una jerarquía: una interfaz de iterable, una de iterador, una familia de adaptadores, un tipo de flujo perezoso, y a menudo una segunda familia para la variante asíncrona. Es una cantidad enorme de superficie, y existe porque en esos lenguajes el recorrido es un tipo y los tipos hay que declararlos, relacionarlos y mantenerlos. Lua no tiene nada de eso y sin embargo puede hacer lo mismo, porque decidió que el recorrido fuese un contrato de llamada en lugar de un tipo. La diferencia es profunda: un contrato de llamada no necesita que nadie lo implemente formalmente, no impone herencia, no obliga a envolver los valores ajenos y, sobre todo, se compone por la única operación que un lenguaje funcional siempre tiene a mano, que es escribir una función que devuelve otra. Por eso el filtro y la transformación de esta lección caben en cinco líneas cada uno y funcionan sobre absolutamente cualquier fuente, incluidas las tres de este nivel: la tripleta sin estado, el closure y la corrutina. Ninguna de las tres sabe que existen las otras, ninguna implementa una interfaz común, y aun así encajan. Ese es el rendimiento real de haber elegido bien el punto de extensión, y explica una decisión que a mucha gente le parece una carencia: Lua no incorpora un módulo de iteradores en su biblioteca estándar, del mismo modo que no incorpora un sistema de clases, porque en ambos casos el mecanismo que permite construirlos en diez líneas ya está en el núcleo y añadir la biblioteca solo serviría para imponer una de las muchas variantes posibles. La lección que hay que llevarse al diseñar código propio es exactamente esa: antes de escribir una jerarquía para representar recorridos, pregúntate si te basta con devolver una función que, llamada repetidamente, entregue lo siguiente y termine devolviendo nulo. Casi siempre basta.
- Instala
__pairsen una tabla para que se recorra en orden alfabético y comprueba quenextsigue viendo el orden real. - Escribe el recorrido en orden de un árbol binario con una corrutina y compáralo, línea a línea, con la versión de pila explícita.
- Añade el cuarto valor de cierre a tu iterador de corrutina, sal con
breaka la mitad y verifica el estado de la corrutina. - Implementa una etapa que limite el número de elementos y compónla con un filtro sobre una fuente infinita.
- Mide una tubería de tres etapas frente a una única función que haga las tres cosas, sobre un millón de elementos, y decide con datos cuál usarías en un bucle crítico.