wandres.dev
CLOSURES Y UPVALUES · el mecanismo real

La trampa del bucle: capturar la variable o una copia

El error clásico de capturar la variable de control de un bucle y por qué Lua no lo padece: cada iteración abre y cierra su propio bloque, de modo que el cuerpo tiene variables nuevas cada vuelta. Dónde sí tropieza Lua de verdad, cómo se arregla con una local interna, y el cambio de Lua 5.5 que vuelve inmutables las variables de control.

⏱ 16 min

Hay un error que casi todos los lenguajes con closures han cometido en algún momento de su historia y que ha llenado foros durante veinte años: crear funciones dentro de un bucle, guardarlas, llamarlas después y descubrir que todas dan el mismo resultado, el último. Es el bug pedagógico perfecto, porque no se arregla con una regla que memorizar sino entendiendo qué significa capturar. Lua ocupa aquí una posición peculiar y muy instructiva: por el diseño de sus ámbitos hace lo correcto en el caso que a otros lenguajes les costó una versión mayor arreglar, y sin embargo tiene su propia versión del problema, más silenciosa, en los bucles construidos a mano. Esta lección disecciona las dos caras y termina en el presente, porque Lua 5.5 ha vuelto inmutables las variables de control del for y eso reescribe un puñado de idiomas clásicos que llevaban décadas circulando.

🎯 Al terminar esta lección sabrás
  • Explicar la trampa clásica como captura de una única variable compartida.
  • Justificar por qué el for de Lua crea una variable nueva en cada iteración.
  • Identificar los casos en los que Lua sí comparte y arreglarlos con una local interna.
  • Incorporar la inmutabilidad de las variables de control introducida en Lua 5.5.

La trampa clásica y de dónde sale

El escenario es siempre el mismo. Un bucle que crea funciones y las guarda para llamarlas más tarde:

local fs = {}
for i = 1, 3 do
  fs[i] = function() return i end
end
print(fs[1](), fs[2](), fs[3]())

En un lenguaje donde la variable del bucle es una sola para todo el bucle —el caso histórico del var de JavaScript, o el de las variables de rango de algunas versiones de Go— la salida sería 3 3 3. Y la explicación no es misteriosa una vez que dominas la lección 2: las tres funciones capturan la misma variable, esa variable termina el bucle valiendo el último valor, y como el closure guarda una indirección a la variable y no una foto de su contenido, las tres leen ese último valor. No hay ningún error de captura; hay un error de expectativa. Se capturó exactamente lo que se pidió capturar.

Por eso la formulación correcta del problema no es cómo capturo el valor sino cuántas variables hay. Si el bucle declara una sola variable, un solo upvalue compartido y un solo resultado. Si declara una por vuelta, tres upvalues distintos y tres resultados.

El arreglo histórico en los lenguajes afectados fue siempre el mismo, y merece verse escrito porque enseña la mecánica: interponer una llamada a función, ya que llamar es la única forma segura de crear un ámbito nuevo con parámetros propios.

local fs = {}
local i = 1
while i <= 3 do
  fs[i] = (function(copia)              -- el parametro es una variable nueva
    return function() return copia end
  end)(i)                               -- se invoca de inmediato con el valor
  i = i + 1
end
print(fs[1](), fs[2](), fs[3]())        --> 1  2  3

Cada invocación de la fábrica anónima crea su propio parámetro copia, con su propio hueco de pila y su propio upvalue. Funciona, pero paga un closure extra y una llamada por vuelta, y se lee mal. En Lua tienes una alternativa más barata y más clara —declarar una local dentro del cuerpo— porque el bloque del bucle ya es un ámbito de verdad. Que en otros lenguajes hiciera falta el rodeo de la llamada es precisamente el síntoma de que allí el cuerpo del bucle no lo era.

Lua declara una variable por iteración

En Lua el programa anterior imprime 1 2 3, y no por una excepción especial para closures sino por la regla general de ámbito: el cuerpo del bucle es un bloque y la variable de control es local a ese bloque, de modo que cada iteración tiene su propia instancia. Cuando la vuelta acaba, el bloque termina, y ya sabes de la lección 2 lo que ocurre al terminar un bloque con locales capturadas: se cierran sus upvalues. Al empezar la vuelta siguiente hay un hueco nuevo, una variable nueva y, si se captura, un objeto upvalue nuevo.

flowchart TD
B[Bucle for de tres vueltas] --> V1[Vuelta 1 abre bloque con i propia]
B --> V2[Vuelta 2 abre bloque con i propia]
B --> V3[Vuelta 3 abre bloque con i propia]
V1 --> C1[Closure 1 con su upvalue cerrado en 1]
V2 --> C2[Closure 2 con su upvalue cerrado en 2]
V3 --> C3[Closure 3 con su upvalue cerrado en 3]

Conviene precisar que la variable visible en el cuerpo no es el contador interno del bucle. La máquina virtual mantiene su propio índice en registros que tu código no puede nombrar, y en cada iteración copia el valor en la variable de control del cuerpo. Esa separación es la que permite que la variable del cuerpo sea desechable sin descarrilar el bucle, y es también la razón histórica de una confusión célebre: en versiones anteriores a 5.5 podías asignar a la variable de control, pero la asignación solo afectaba a la copia de esa vuelta y el bucle continuaba imperturbable, lo que producía código que parecía funcionar y no funcionaba.

Lo mismo vale para el for genérico: las variables que recibes de la función iteradora son locales de cada iteración, así que capturarlas en un manejador es seguro.

Dónde sí tropieza Lua

Que el for esté bien resuelto no vacuna al lenguaje. La trampa reaparece intacta en cuanto la variable capturada se declara fuera del bucle, que es lo que ocurre de forma natural en un while o cuando alguien iza una declaración por costumbre de otro lenguaje.

local fs = {}
local i = 1                       -- una sola variable para todo el bucle
while i <= 3 do
  fs[i] = function() return i end -- las tres capturan la MISMA
  i = i + 1
end
print(fs[1](), fs[2](), fs[3]())  --> 4  4  4

Aquí Lua se comporta como los lenguajes acusados al principio, y con toda la razón: hay una sola variable i, viva durante todo el bucle y también después, así que hay un solo upvalue y las tres funciones leen el valor con el que el bucle terminó, que además es el que hizo fallar la condición. El arreglo es el de siempre y consiste en crear una variable nueva dentro del bloque, para que cada vuelta tenga la suya:

local fs = {}
local i = 1
while i <= 3 do
  local copia = i                    -- local del cuerpo: una por vuelta
  fs[i] = function() return copia end
  i = i + 1
end
print(fs[1](), fs[2](), fs[3]())     --> 1  2  3

El for numerico y el generico

Variable por iteración, captura segura. Es el caso habitual y por eso el bug aparece poco en código Lua idiomático.

⚠️

El while y el repeat

El cursor lo declaras tú, normalmente fuera. Si lo capturas, compartes. La solución es una local dentro del cuerpo, nunca una copia fuera.

🧊

Acumuladores y banderas

Cualquier local declarada antes del bucle y capturada dentro se comparte entre todas las vueltas. A veces es justo lo que quieres; el problema es cuando no lo has decidido.

🧵

Manejadores diferidos

El síntoma casi siempre aparece con retraso: temporizadores, callbacks, corrutinas, suscripciones. Si el closure se llamase de inmediato, el bug sería invisible.

⚠️
Copiar fuera del bucle no arregla nada

El error más común al intentar el arreglo es declarar la copia antes del bucle y asignarla dentro. Eso no crea variables nuevas: sigue habiendo una sola, ahora con otro nombre, y las capturas se siguen compartiendo. Lo que fabrica una variable por vuelta es la declaración dentro del cuerpo, porque la declaración es lo que abre un hueco nuevo cada vez que se ejecuta.

Lua 5.5: la variable de control es inmutable

Y aquí el cambio del presente. En Lua 5.5 las variables de control de un bucle for, tanto numérico como genérico, son inmutables: dentro del cuerpo solo se pueden leer, y cualquier intento de asignarles algo es un error detectado al compilar.

La motivación es la que ya has visto. La asignación a la variable de control nunca alteró el curso del bucle, porque el estado real vive en registros internos, así que el lenguaje ofrecía una operación cuyo efecto observable era casi siempre distinto del esperado. Convertirla en error elimina una clase entera de confusiones sin quitar potencia expresiva, porque quien necesita un cursor mutable siempre pudo —y debió— declarar su propia local.

-- Idioma antiguo que deja de compilar en Lua 5.5
for i = 1, 10 do
  if i % 2 == 0 then i = i + 1 end   -- error: i es de solo lectura
  print(i)
end

-- Reescritura correcta con una local propia
for i = 1, 10 do
  local v = i
  if v % 2 == 0 then v = v + 1 end
  print(v)
end

Las consecuencias prácticas son tres. La primera es que un puñado de idiomas antiguos deja de compilar: saltar iteraciones asignando a la variable, reutilizarla como acumulador temporal, o normalizar dentro del cuerpo el valor que entrega el iterador. Todos se arreglan con una declaración local dentro del bloque, que es además más legible. La segunda afecta directamente a este nivel: capturar la variable de control es ahora, por construcción, capturar algo que solo se lee. Sigue siendo una variable por iteración, con su upvalue por vuelta, pero desaparece el escenario turbio en el que un closure creado dentro del bucle escribía en la variable de control y dejaba a la vista una modificación que el bucle ignoraba. La tercera es de implementación: una variable de control de solo lectura da al compilador información que puede aprovechar, y ordena la semántica en la vecindad de los atributos <const> y <close> que 5.4 había introducido.

Capturar es un acto sobre la variable, no sobre el valor

Si te llevas una sola frase de este nivel, que sea esta: capturar no es fotografiar, es apuntar. Y de esa distinción se sigue todo lo demás, porque reordena la pregunta que hay que hacerse ante cualquier código que cree funciones dentro de un bucle. La pregunta ingenua es qué valor tenía la variable cuando se creó el closure, y es una pregunta sin respuesta, porque el closure no guardó ningún valor: guardó el camino hacia una celda que otros pueden seguir escribiendo. La pregunta correcta es cuántas celdas hay, y esa sí tiene una respuesta mecánica que no depende de intuiciones sobre el lenguaje: hay tantas celdas como veces se ejecute la declaración de la variable. Una declaración dentro del cuerpo del bucle se ejecuta una vez por vuelta y produce una celda por vuelta; una declaración antes del bucle se ejecuta una sola vez y produce una celda para todas. No hay más teoría. Con esa regla en la mano dejas de necesitar tablas comparativas de qué lenguaje hace qué, porque puedes derivar el comportamiento de cualquiera de ellos sin más que averiguar dónde declara su variable de bucle: el JavaScript de var la declaraba una vez por función, y por eso fallaba; el de let la declara una vez por iteración, y por eso dejó de fallar; el for de Lua siempre la ha declarado por iteración, y por eso nunca falló; y el while de Lua no declara nada, la declaras tú, y por eso falla exactamente cuando tú la declaras fuera. La misma regla explica el patrón del contador de la lección anterior, donde la compartición era deseada, y el bug de este, donde no lo era: en los dos casos hay una celda compartida por varias funciones, y lo único que cambia es si lo decidiste tú. Ese es el nivel de comprensión al que hay que llegar. No memorizar qué construcciones son seguras, sino leer cualquier código y ver el número de celdas.

⚔️ Cuenta las celdas
  1. Ejecuta las tres versiones del bucle —el for, el while roto y el while arreglado— y predice cada salida antes de verla. Justifica cada una contando declaraciones ejecutadas.
  2. Escribe la versión con for genérico sobre una tabla usando ipairs, captura las dos variables y comprueba que también es segura.
  3. Registra tres temporizadores simulados que impriman su índice y llámalos al final del bucle, no dentro. Observa que el bug solo se manifiesta con la llamada diferida.
  4. Toma un fragmento antiguo que asigne a la variable de control dentro del cuerpo, explica por qué nunca hizo lo que aparentaba y reescríbelo para que compile bajo las reglas de Lua 5.5.