Qué es un closure: la función y su entorno
La definición operativa de closure en Lua: el par formado por un prototipo compilado y el vector de variables externas que captura. Por qué el manual afirma que toda función de Lua es técnicamente un closure, cómo se separan prototipo e instancia, y qué consecuencias tiene eso sobre la identidad, la igualdad y el coste de crear funciones.
En casi todos los lenguajes, closure es una palabra del capítulo avanzado que describe una excepción: la función que, contra la disciplina de pila, sobrevive a su ámbito y se lleva algo consigo. En Lua la relación es exactamente la inversa. El manual de referencia no dice que algunas funciones sean closures; dice que las funciones de Lua son closures, sin excepción, y define closure como el par formado por un prototipo compilado y el vector de variables externas que ese prototipo necesita. Lo que en otros lenguajes es un mecanismo opcional, aquí es la única representación posible de lo que llamas función. Interiorizar esa identidad —función igual a código más entorno capturado— no es una precisión terminológica de manual: es la clave que explica por qué dos funciones creadas en la misma línea del fichero no son la misma función, por qué el estado privado en Lua no necesita tablas, y por qué los módulos, los iteradores con estado y el sandboxing descansan todos sobre este único mecanismo.
- Definir closure como prototipo más entorno capturado, no como una forma sintáctica.
- Entender por qué el ámbito léxico con funciones de primera clase obliga a esa representación.
- Distinguir el prototipo, que es único y estático, de la instancia, que se crea en ejecución.
- Medir las consecuencias en identidad, igualdad y coste de creación de funciones.
La definición operativa
Empieza por el caso que lo contiene todo. Una función fábrica que devuelve otra función:
local function hacer_saludador(saludo)
return function(nombre)
return saludo .. ", " .. nombre
end
end
local hola = hacer_saludador("Hola")
local adios = hacer_saludador("Adios")
print(hola("Ada")) --> Hola, Ada
print(adios("Ada")) --> Adios, Ada
La variable saludo es un parámetro de hacer_saludador, es decir, una variable local a esa invocación. Cuando hacer_saludador retorna, su registro de activación se descarta y, bajo una disciplina de pila estricta, saludo debería desaparecer con él. Pero la función interna la nombra, y en Lua el nombre se resuelve por anidamiento textual, no por quién llame a quién. Así que el valor tiene que seguir vivo: no como una copia perdida en algún sitio, sino como una variable con identidad propia a la que la función interna conserva acceso. Esa variable capturada, vista desde dentro de la función que la usa, se llama upvalue.
De ahí sale la definición que vas a usar durante todo el nivel: un closure es una función más el entorno de las variables locales externas que captura. Ni la función sola —eso es el prototipo, que es código muerto— ni el entorno solo. El par.
Conviene desactivar de entrada el malentendido más caro, porque contamina todo lo que viene después. Lo capturado es la variable, no el valor que la variable tuviera en el instante de crear la función:
local function observador()
local n = 0
local ver = function() return n end
n = 99 -- se asigna DESPUES de crear el closure
return ver
end
print(observador()) --> 99, no 0
Si la creación del closure hubiera fotografiado el contenido de n, la salida sería 0. Es 99 porque lo que quedó guardado es una vía de acceso a la variable, y la variable siguió su vida normal después. Esta propiedad no es un detalle: es el requisito imprescindible para que dos funciones puedan compartir estado, que es el asunto de la lección 3, y es al mismo tiempo la causa del error clásico de los bucles, que es el de la lección 4. La misma característica sostiene el patrón útil y el bug célebre.
El ámbito léxico exige el closure
No es una elección estética del diseño de Lua. Es una consecuencia forzada de combinar dos propiedades que Lua sí eligió por separado.
La primera es el ámbito léxico: un nombre libre dentro de una función se resuelve mirando hacia afuera en el texto del programa, hacia los bloques que la envuelven en el fichero fuente, y esa resolución queda fijada en tiempo de compilación. La segunda es que las funciones son valores de primera clase: se guardan en variables, se meten en tablas, se pasan como argumentos y —lo decisivo— se devuelven, con lo que pueden escapar del bloque donde nacieron.
Junta las dos y el resultado es inevitable. Si una función puede sobrevivir al bloque que la definió, y si los nombres que usa apuntan a variables de ese bloque, entonces la vida de esas variables no puede estar atada a la vida del registro de activación. Alguien tiene que mantenerlas. Un lenguaje con ámbito léxico y funciones de primera clase implementa closures o está roto; no hay tercera opción. Lo que distingue a Lua no es tenerlos, sino haber hecho de ellos la representación universal en lugar de un caso especial.
La captura, además, es transitiva y atraviesa tantos niveles de anidamiento como haga falta:
local function nivel1()
local a = "A"
return function() -- nivel 2
return function() -- nivel 3, usa una local de nivel 1
return a
end
end
end
print(nivel1()()()) --> A
La función más interna nombra una variable que está dos bloques por encima. El compilador resuelve eso encadenando: la del nivel 3 declara un upvalue que no viene de una local del padre sino de un upvalue del padre, y la del nivel 2 declara el upvalue que sí sale de la local de nivel1. Es decir, el nivel intermedio captura a aunque su cuerpo no la mencione, solo para poder cedérsela a su hija. Esta cadena de reenvío es la razón de que en el prototipo cada upvalue lleve, además del nombre, una marca que indica si su origen es una local del padre directo o un upvalue heredado.
Conviene separar tres cosas que se confunden a diario. Función anónima es una cuestión de sintaxis: una función escrita sin nombre. Lambda es el término del cálculo lambda para la abstracción funcional. Closure es una propiedad de la representación en ejecución: código más entorno. Una función anónima que no captura nada sigue siendo un closure en Lua, con un vector de upvalues de longitud cero o con solo _ENV; y una función con nombre declarada con local function es igual de closure que la anónima que devuelve. La sintaxis no decide nada aquí.
Un prototipo, muchas instancias
Aquí está la distinción que casi todo el mundo pasa por alto. Cuando el compilador lee tu fichero, cada expresión function del fuente produce un prototipo: una estructura con el bytecode, el número de parámetros, las constantes y la descripción de qué upvalues necesita y de dónde vienen. Eso ocurre una vez y no vuelve a ocurrir.
En ejecución, cada vez que el flujo de control evalúa esa expresión function, la máquina virtual crea un objeto nuevo: un closure que apunta al mismo prototipo compartido y lleva su propio vector de upvalues, recién enlazado con las variables de esa invocación concreta. Dos llamadas a hacer_saludador no duplican bytecode; duplican el enlace al entorno.
flowchart TD F[Fuente con una expresion function] --> P[Prototipo unico compilado] P --> C1[Closure creado en la llamada 1] P --> C2[Closure creado en la llamada 2] C1 --> U1[Upvalue saludo con valor Hola] C2 --> U2[Upvalue saludo con valor Adios]
El prototipo es estático
Nace en la compilación, es inmutable y se comparte. Contiene el bytecode, las constantes, las líneas para los mensajes de error y la tabla que dice de dónde sale cada upvalue: del padre directo o de un upvalue del padre.
El closure es dinámico
Nace cada vez que se ejecuta la expresión que lo define. Es un objeto recolectable, con identidad propia, cuyo único contenido nuevo respecto al prototipo es el vector de referencias a variables externas.
Las globales también son un upvalue
Desde Lua 5.2 no existe una tabla de globales privilegiada: x sin declarar se compila como un acceso a un campo de la variable _ENV, que es una variable léxica normal. Toda función que toca globales captura _ENV como upvalue.
Las funciones C también son closures
La API C distingue entre función C ligera, sin estado, y closure C, creada con lua_pushcclosure, que lleva valores asociados. La simetría no es casual: es el mismo concepto llevado al otro lado de la frontera.
Identidad, igualdad y coste
Que el closure sea un objeto creado en ejecución tiene consecuencias medibles que conviene tener claras antes de seguir.
La primera es la identidad. En el ejemplo inicial, hola y adios comparten hasta la última instrucción de bytecode y aun así son valores distintos: comparar el uno con el otro da falso, y guardarlos como claves de una tabla ocupa dos entradas.
local function crea() return function() end end
local f, g = crea(), crea()
print(f == g) --> false, dos objetos distintos
print(type(f)) --> function, no hay un tipo closure aparte
local registro = {}
registro[f], registro[g] = true, true -- dos entradas, no una
Nota de paso que type devuelve function y no distingue closures de nada, porque no hay nada de lo que distinguirlos: en Lua no existe la categoría de función sin entorno. La igualdad de funciones es siempre igualdad de identidad, nunca comparación de código. Esto tiene una consecuencia práctica frecuente: si registras un manejador de eventos con una función anónima escrita en la propia llamada, no tienes forma de darla de baja después, porque no conservas el valor exacto que se registró. Guarda el closure en una variable si vas a necesitar su identidad más tarde.
La segunda es el coste. Crear un closure implica asignar memoria en el montón y trabajo posterior para el recolector. Lua 5.2 y 5.3 intentaron amortiguarlo con una caché en el prototipo, que reutilizaba un closure previo si los upvalues coincidían exactamente; en Lua 5.4 esa caché se eliminó porque interactuaba mal con el recolector generacional, de modo que en 5.4 y 5.5 puedes asumir que cada evaluación de una expresión function produce un objeto nuevo. La lectura de ingeniería es directa: crear closures dentro de un bucle caliente es asignar memoria dentro de un bucle caliente, y el remedio habitual es izar la creación fuera del bucle o pasar el estado por argumento.
Cuando veas una expresión function en el código, no leas declaración: lee constructor. Esa línea no describe una función, fabrica una cada vez que el flujo pasa por ella. Con esa lente, media docena de comportamientos que parecían caprichosos del lenguaje se vuelven predicciones triviales.
Aquí está el giro que separa a quien usa Lua de quien lo entiende. En la mayoría de lenguajes puedes programar durante años tratando las funciones como bloques de código con nombre y quedarte con la palabra closure archivada bajo la etiqueta de truco funcional para casos raros. En Lua esa estrategia te deja ciego ante la mitad del lenguaje, porque aquí no existe la categoría función simple frente a la categoría función que captura: solo existe el par código más entorno, y todo lo que Lua no te da como característica del lenguaje está construido con él. El estado privado, que otros lenguajes ofrecen como campos privados de una clase, aquí es sencillamente una variable local capturada por unas cuantas funciones y por nadie más: no hay una palabra clave que la proteja, la protege el hecho de que ningún otro código tenga un nombre para alcanzarla. Los iteradores con estado, que otros resuelven con un objeto y un protocolo, aquí son un closure que recuerda por dónde iba. Los módulos son closures que se cierran sobre sus dependencias. El sandbox que ejecuta código no confiable es un closure al que le has cambiado el upvalue _ENV por una tabla que tú controlas, y por eso el código de dentro no puede ni nombrar lo que no le has puesto delante. Incluso el aislamiento entre corrutinas se apoya en que cada una arrastra su propio entorno capturado. Ninguna de esas cosas es una biblioteca ni una extensión: son cuatro maneras de mirar el mismo mecanismo. Por eso este nivel no es un capítulo opcional sobre programación funcional, sino el punto donde el lenguaje deja de parecer un conjunto de características sueltas y se revela como una sola idea aplicada con obstinación. Quien entiende que función es igual a código más entorno deja de memorizar idiomas de Lua y empieza a deducirlos.
- Escribe la fábrica
hacer_saludadordel principio, crea dos saludadores distintos y comprueba con una comparación de igualdad que no son el mismo valor pese a compartir el código. - Mete los dos en una tabla como claves y cuenta cuántas entradas hay. Explica el resultado en términos de identidad de objeto.
- Escribe un bucle que ejecute cien mil veces una expresión
functionvacía y mide el tiempo conos.clock. Repite izando la creación fuera del bucle y compara. - Declara una función que use una global cualquiera y otra que no use ninguna. Razona cuál de las dos tiene un upvalue llamado
_ENVy cuál no. En la lección 5 lo verificarás con la bibliotecadebug.