Compartir estado: closures que ven la misma variable
La consecuencia más productiva del mecanismo de upvalues: varios closures creados en el mismo ámbito comparten el objeto que sostiene la variable capturada. De ahí salen el contador, el generador y el objeto con estado verdaderamente privado sin usar tablas, con su coste, sus límites y su comparación honesta con la metatabla.
Si un upvalue es una indirección única por variable, y si dos closures creados en el mismo ámbito reutilizan ese mismo objeto, entonces la compartición de estado no es un patrón que haya que construir: es lo que ocurre por defecto cuando dos funciones nombran la misma local. Esta lección explota esa propiedad hasta el final. Verás que el contador, el generador perezoso, el objeto con campos privados y el sistema de eventos con desuscripción son todos el mismo montaje visto desde ángulos distintos: una variable local, varias funciones que la nombran, y nadie más en el programa con un nombre para alcanzarla. Y verás también el precio, porque en Lua el encapsulamiento por closure y el encapsulamiento por tabla con metatabla no son intercambiables: tienen costes de memoria, de creación y de introspección muy distintos, y elegir bien exige conocer los dos.
- Demostrar que dos closures del mismo ámbito comparten el objeto upvalue.
- Construir el contador y el generador como aplicaciones directas de esa compartición.
- Implementar estado estrictamente privado sin tablas y entender por qué es inalcanzable.
- Comparar coste y límites frente al objeto clásico con tabla y metatabla.
Dos funciones, una sola variable
El experimento mínimo cabe en diez líneas y contiene toda la lección:
local function hacer_par()
local n = 0
local function leer() return n end
local function subir() n = n + 1 end
return leer, subir
end
local leer, subir = hacer_par()
print(leer()) --> 0
subir(); subir()
print(leer()) --> 2
Las dos funciones internas se crearon mientras n estaba viva en la pila, así que la máquina virtual encontró un upvalue abierto ya existente para ese hueco y lo reutilizó en lugar de fabricar uno nuevo. Cuando hacer_par retorna, ese upvalue se cierra: el valor migra al montón. Pero el objeto que lo sostiene sigue siendo el mismo, y los dos closures siguen apuntándolo. Por eso subir escribe donde leer lee.
Fíjate en lo que no ha pasado. Nadie copió n a ninguna parte al crear los closures. No hay dos copias que haya que sincronizar. No hay una tabla intermedia. Y sobre todo: cada llamada a hacer_par produce una pareja completamente independiente de la anterior, porque cada invocación tiene su propia local n y por tanto su propio objeto upvalue.
flowchart LR subgraph Instancia A LA[Closure leer] --> UA[Upvalue n de A] SA[Closure subir] --> UA end subgraph Instancia B LB[Closure leer] --> UB[Upvalue n de B] SB[Closure subir] --> UB end P[Prototipos compartidos] -.-> LA P -.-> SA P -.-> LB P -.-> SB
El contador, el generador y el acumulador
Con la compartición entendida, los tres idiomas clásicos se escriben solos y conviene verlos juntos para notar que son el mismo esqueleto.
-- Contador: un unico closure que muta su upvalue
local function contador(inicio, paso)
local n = inicio - paso
return function()
n = n + paso
return n
end
end
-- Generador perezoso: el estado del recorrido vive en upvalues
local function rango(desde, hasta)
local i = desde - 1
return function()
if i < hasta then
i = i + 1
return i
end
end
end
for v in rango(1, 3) do print(v) end --> 1, 2, 3
El caso del generador merece un comentario. El protocolo del for genérico de Lua espera una función iteradora a la que llama repetidamente hasta que devuelve nada. Puedes suministrarle el estado por los parámetros que el propio protocolo pasa —el estado invariable y la variable de control— o puedes esconderlo en upvalues y devolver un iterador de cero argumentos, como hace rango. La segunda forma es más legible y la primera es más barata, porque no crea un closure por recorrido y permite que el estado sea inspeccionable. Que Lua ofrezca las dos vías no es indecisión: son dos puntos distintos en la curva entre encapsulamiento y coste.
Añade aquí un dato del presente. En Lua 5.5 las variables de control de un bucle for son inmutables: dentro del cuerpo solo se pueden leer. Esto no afecta al generador, que guarda su propio contador en un upvalue, pero sí invalida el viejo truco de manipular la variable del bucle desde dentro para saltarse iteraciones. Si necesitas un cursor mutable, declara tu propia local, que es exactamente lo que hace rango.
Estado privado sin tablas
Aquí está el uso más interesante, y el que suele sorprender a quien llega de un lenguaje con clases. Devolviendo una tabla de closures obtienes un objeto cuyos campos internos no son privados por convención ni por un modificador de acceso, sino porque no existe ninguna expresión del lenguaje capaz de nombrarlos.
local function cuenta(saldo_inicial)
local saldo = saldo_inicial -- no vive en ninguna tabla
local self = {}
function self.ingresar(cantidad)
assert(cantidad > 0, "cantidad invalida")
saldo = saldo + cantidad
return saldo
end
function self.consultar()
return saldo
end
return self
end
local c = cuenta(100)
c.ingresar(50)
print(c.consultar()) --> 150
print(c.saldo) --> nil, no hay ningun campo saldo
Compáralo con el objeto clásico: allí saldo sería un campo de la tabla, y cualquiera con una referencia al objeto puede leerlo, escribirlo, recorrerlo con pairs o serializarlo sin pedir permiso. El convenio de anteponer un guion bajo al nombre es documentación, no protección. Con el closure, la variable no está en ninguna tabla: está en un objeto upvalue al que solo llegan los closures que la capturaron. Ni pairs, ni next, ni una metatabla, ni el serializador más agresivo la alcanzan. La única puerta trasera es la biblioteca debug, y esa es precisamente la puerta que estudiarás en la lección 5.
Privacidad real, no convenida
El estado no aparece en la tabla, así que no lo ven ni la iteración, ni la serialización, ni el código que recibe el objeto. La única vía de acceso es la biblioteca debug, que en un sandbox bien montado no está disponible.
Cierre sobre dependencias
El mismo patrón sirve para inyectar dependencias: captura el registro de trazas o la conexión en el ámbito de la fábrica y las funciones devueltas las usan sin recibirlas por parámetro ni buscarlas en una global.
Coste por instancia
Cada objeto creado así paga un closure por método, con su asignación en el montón. Cien mil instancias con cinco métodos son medio millón de objetos recolectables. El modelo con metatabla comparte los métodos entre todas.
Sin herencia ni introspección
No hay cadena __index que extender, ni forma de enumerar métodos, ni de parchearlos desde fuera. Lo que ganas en aislamiento lo pierdes en extensibilidad y en depurabilidad.
Cuándo sí y cuándo no
La regla práctica se deja resumir en dos preguntas. ¿Cuántas instancias vas a crear? ¿Y necesitas que el objeto sea extensible desde fuera?
Si vas a crear pocas instancias de larga vida —una conexión, un planificador, un módulo, un manejador de configuración— y el aislamiento importa, el closure gana con claridad: menos indirecciones en la llamada, privacidad real y ninguna metatabla que razonar. Si vas a crear muchas instancias pequeñas y homogéneas —partículas, nodos, entidades de un juego— el modelo con tabla y metatabla gana sin discusión, porque los métodos se comparten y el coste por instancia se reduce a la tabla de campos.
No es un dilema binario. Un patrón muy usado en código de producción combina ambos: los datos abundantes y no sensibles viven como campos de la tabla, con metatabla y métodos compartidos, mientras que uno o dos secretos concretos —una clave, un descriptor de fichero, un estado que jamás debe ser tocado desde fuera— se capturan como upvalues en los dos o tres métodos que los necesitan. Pagas closures solo por lo que de verdad hay que esconder.
Aquí conviene cambiar de categoría mental y no volver atrás. En los lenguajes que crecieron alrededor de la clase, la privacidad es un sistema de permisos: el campo existe, tiene nombre, está en el objeto, cualquiera podría alcanzarlo, y lo que impide hacerlo es un guardián —el compilador, el verificador, la máquina virtual— que comprueba quién pregunta y decide si le deja. Por eso en esos lenguajes la privacidad siempre acaba teniendo una puerta de servicio: la reflexión, la deserialización, el depurador, el compilador de otro lenguaje que comparte runtime. El campo sigue estando ahí, y quien logre saltarse al guardián lo tiene. En Lua el planteamiento es de otra naturaleza. Cuando escondes el saldo en un upvalue no has puesto un guardián delante de un dato accesible: has hecho que no exista ninguna expresión del lenguaje que se refiera a ese dato. Nadie está prohibiendo nada porque no hay nada que prohibir; simplemente el conjunto de nombres visibles desde fuera de la fábrica no contiene ese. La privacidad deja de ser una política de acceso y pasa a ser una consecuencia geométrica de dónde declaraste la variable. Y eso cambia la conversación entera. Ya no discutes si el modificador es suficientemente estricto, discutes qué funciones tienen razones legítimas para estar dentro del ámbito. Ya no auditas quién llama, auditas quién captura. La superficie de ataque no se mide en comprobaciones de permisos sino en léxico: cuántas funciones nombran la variable y qué se hace con lo que devuelven. De ahí sale el idioma de sandboxing de Lua, en el que a un trozo de código no confiable no se le prohíbe usar la biblioteca de ficheros, se le entrega un _ENV en el que esa biblioteca sencillamente no aparece, y por tanto la palabra no significa nada allí dentro. Es la diferencia entre cerrar una puerta con llave y no construir la puerta. La segunda no tiene ganzúa, y la única forma de burlarla es que alguien te entregue desde fuera una capacidad que no habías dado —el caso de la biblioteca debug, que por eso es lo primero que se retira de cualquier sandbox serio.
- Implementa
cuentacon una operación de retirada que rechace dejar el saldo en negativo. Comprueba desde fuera que no hay manera de forzar un saldo inválido. - Recorre el objeto devuelto con
pairsy confirma que el saldo no aparece. Serialízalo a texto y confirma lo mismo. - Escribe la versión equivalente con tabla y metatabla. Mide con
collectgarbageen modo de conteo la memoria de diez mil instancias de cada variante y compara. - Crea dos cuentas distintas y verifica que sus estados son independientes. Explica el resultado en términos de un objeto upvalue por invocación de la fábrica.