wandres.dev
ÁMBITO Y GLOBALES · _ENV lo explica todo

_ENV: la revelación de 5.2

Toda variable global es en realidad un campo de un upvalue llamado _ENV que el compilador inyecta en cada chunk. Qué implica que sea un nombre corriente, por qué _G no es más que su valor inicial, y por qué sustituir setfenv por una regla léxica fue una de las mejores decisiones del lenguaje.

⏱ 16 min

En 2011, Lua 5.2 eliminó las variables globales del lenguaje. No las prohibió: las explicó. Descubrió que nunca habían sido una categoría aparte, sino azúcar sintáctica para un acceso a tabla, y lo hizo explícito con una sola regla de una línea que sustituyó a dos funciones de la biblioteca, a un campo del objeto función y a un concepto entero del manual. Esa regla es la clave que abre todo lo que queda de este nivel, todo el sandboxing de la lección siguiente y buena parte de los idiomas avanzados de Lua. Se enuncia así: el nombre libre x significa _ENV.x. Nada más. Y nada menos.

🎯 Al terminar esta lección sabrás
  • Enunciar y aplicar la traducción de todo nombre libre a un campo de _ENV.
  • Comprobar en el bytecode y con debug.getupvalue que _ENV es un upvalue corriente.
  • Situar _G en su lugar real: un nombre más, cuyo valor inicial es el entorno global.
  • Explicar por qué la regla léxica es superior a setfenv y getfenv de 5.1.

No existen las variables globales

La regla del manual es literal: toda referencia a una variable global var se traduce sintácticamente a _ENV.var, y todo chunk se compila en el ámbito de una variable local externa llamada _ENV. Las dos frases juntas eliminan el concepto. Este programa:

print("hola", x)
x = 42

es exactamente, para el compilador, este otro:

_ENV.print("hola", _ENV.x)
_ENV.x = 42

De aquí se deduce, sin necesidad de memorizar nada más, todo el comportamiento clásico de los globales. ¿Por qué leer una global no declarada devuelve nil en lugar de dar error? Porque leer una clave ausente de una tabla devuelve nil. ¿Por qué una global escrita mal se crea en silencio? Porque asignar a una clave nueva de una tabla la crea. ¿Por qué se pueden interceptar con una metatabla? Porque el entorno es una tabla y las tablas tienen metatablas. ¿Por qué son lentas comparadas con las locales? Por lo que vimos en 9.2: son un acceso a tabla, y las locales no.

💡
La regla del nombre libre

Un nombre es libre si en el punto donde aparece no hay ninguna declaración local, parámetro ni upvalue que lo capture. Solo entonces se traduce a un campo de _ENV. La resolución la hace el compilador con la información del texto, sin ejecutar nada: es la misma búsqueda léxica de 9.1, con _ENV como último recurso en lugar de un error.

_ENV es un upvalue como cualquier otro

Lo verdaderamente elegante es que _ENV no es una palabra clave ni recibe trato especial en la máquina virtual. Es un nombre corriente, sujeto a las reglas de ámbito corrientes, que resulta estar declarado como upvalue del chunk principal. Se comprueba desde el propio programa:

local function f () return x end       -- 'x' es libre

print(debug.getupvalue(f, 1))          --> _ENV    table: 0x...
print(_ENV == _G)                      --> true en el interprete estandar
print(rawequal(_ENV, debug.getupvalue(f, 1)))  -- comparalo tu mismo

Que sea un nombre corriente implica que puedes declararlo como local, recibirlo como parámetro y sombrearlo igual que cualquier otro:

local function aislada (_ENV)          -- parametro llamado _ENV
  return saludo .. " " .. nombre       -- se leen del parametro, no del global
end

print(aislada({ saludo = "hola", nombre = "mundo" }))  --> hola mundo

En esa función no hay ninguna magia: saludo es libre, luego significa _ENV.saludo, y el _ENV visible ahí es el parámetro. Toda la lección 9.4 es la explotación sistemática de este hecho.

Hay una única precaución, y es lógica: _ENV no debe ser a su vez una variable global. Si lo fuera, _ENV.var se traduciría a _ENV._ENV.var, y ese _ENV a _ENV._ENV._ENV.var, en una regresión infinita. Lua 5.5 ya no confía en tu disciplina y lo convierte en un error de compilación explícito: _ENV is global when accessing variable 'x'. Es una de esas comprobaciones que solo tienen sentido cuando entiendes que la traducción es realmente sintáctica.

_G es solo un nombre más

Aquí se derrumba el malentendido más extendido del lenguaje. _G no es el mecanismo de los globales: es una variable global corriente que el intérprete inicializa apuntando al entorno global, para que puedas manipular esa tabla por su nombre. El manual es tajante: _G nunca se usa internamente, así que cambiar su valor solo afecta a tu propio código.

_G = "ya no es una tabla"     -- perfectamente legal
print("sigo funcionando")     --> sigo funcionando

El programa sigue vivo porque print se resolvió vía _ENV, no vía _G. El verdadero entorno global es un valor guardado en el registro de C, en el índice LUA_RIDX_GLOBALS; _G y el _ENV inicial de cada chunk son dos nombres que empiezan apuntando a él. Manipular _G.foo funciona sencillamente porque, mientras nadie los separe, son la misma tabla.

Que sean la misma tabla por defecto no significa que tengan que serlo. En cuanto separas los dos nombres, la diferencia salta a la vista:

local viejo_G = _G
local _ENV = { print = viejo_G.print, _G = viejo_G }   -- entorno propio

x = 1                        -- va a la tabla nueva
print(x)                     --> 1
print(_G.x)                  --> nil   el entorno global no se ha tocado
_G.y = 2                     -- escribe en el global de verdad
print(y)                     --> nil   'y' se busca en la tabla nueva

Esta distinción es la que hace posible el sandboxing. Si _G fuera el mecanismo, no habría forma de darle a un chunk un conjunto distinto de globales. Como el mecanismo es _ENV y _ENV es una variable léxica, basta con darle otro valor.

📝
Tres consecuencias que ya no hay que memorizar

De la única regla se deducen, sin excepciones ni casos especiales: que las globales no declaradas valen nil, porque una clave ausente de una tabla vale nil; que se crean al asignarlas, porque las tablas crecen al asignar claves nuevas; y que pairs(_ENV) enumera todas las globales vivas, porque el entorno es una tabla corriente y se recorre como cualquier otra.

flowchart TD
A[El compilador encuentra el nombre saludo] --> B[Hay local o upvalue con ese nombre]
B -- si --> C[Acceso lexico directo]
B -- no --> D[Se reescribe como _ENV.saludo]
D --> E[Que _ENV es visible aqui]
E --> F[El upvalue implicito del chunk]
E --> G[Un parametro o una local llamada _ENV]
F --> H[Entorno global del registro de C]
G --> I[Entorno propio de esa funcion o bloque]

De setfenv a _ENV: por qué el cambio

Lua 5.1 resolvía lo mismo con setfenv(f, tabla) y getfenv(f): cada función llevaba un campo con su entorno, y podías cambiarlo en tiempo de ejecución, incluso indicando un nivel de la pila de llamadas. Funcionaba, y aun así se eliminó por completo en 5.2. Las razones son un caso de estudio de diseño de lenguajes.

  • Era dinámico, no léxico. Leyendo una función no podías saber a qué entorno accedían sus nombres libres: dependía de quién hubiera llamado a setfenv antes, quizá desde otro módulo. Ningún análisis estático era posible.
  • Manipulaba la pila de llamadas. setfenv(1, t) afectaba a la función actual según el nivel; con corrutinas y llamadas de cola la semántica se volvía sutil hasta la fragilidad.
  • Exigía un campo por función. Cada objeto función cargaba con un puntero de entorno, y el intérprete con un caso especial en cada acceso a global.
  • No componía. Anidar entornos, heredar de uno externo o dárselo a un bloque en lugar de a una función eran problemas sin solución limpia.

Lo que se ganó a cambio se resume en cuatro capacidades que en 5.1 no existían o eran acrobacias:

🧭

Análisis estático

Un lector —o una herramienta— sabe leyendo el texto a qué entorno accede cada nombre libre. luacheck y los servidores de lenguaje viven de esta propiedad.

🧱

Entornos por bloque

setfenv operaba sobre funciones enteras. Con una local llamada _ENV puedes cambiar el entorno de un do ... end de tres líneas y recuperar el anterior al salir.

🪆

Composición y anidamiento

Un entorno puede heredar de otro con una metatabla, y una closure captura el _ENV vigente donde se definió. Los entornos se anidan como cualquier otra variable léxica.

⚙️

Una máquina virtual más simple

Desapareció el campo de entorno por función y el caso especial en cada acceso a global. Solo quedó GETTABUP, que no sabe nada de globales: indexa un upvalue.

La sustitución es una lección de economía: _ENV no añadió nada al lenguaje. Reutiliza el mecanismo de upvalues que ya existía, reutiliza las reglas de ámbito que ya existían y reutiliza el acceso a tabla que ya existía. La máquina virtual solo necesita una instrucción, GETTABUP, que ni siquiera es específica de los globales: significa “indexa un upvalue”. Se borraron dos funciones de la biblioteca, un campo de la estructura de función y un concepto del manual, y a cambio el lenguaje ganó expresividad.

La mejor característica es la que resulta no existir

La historia de _ENV es el mejor ejemplo que conozco de una forma de progreso que casi nunca se enseña: avanzar eliminando un concepto en lugar de añadir otro. Durante quince años, el manual de Lua describió las variables globales como una categoría propia, con su tabla de entorno por función, sus dos funciones de biblioteca para manipularla y su párrafo de advertencias sobre los niveles de pila. En 5.2 los autores hicieron la pregunta correcta —no “cómo mejoramos los globales”, sino “qué son realmente los globales”— y la respuesta resultó ser: no son nada, son un campo de una tabla a la que hay una referencia léxica. A partir de ahí, la implementación se convirtió en una sola línea de traducción en el analizador sintáctico y todo lo demás desapareció. Y aquí está lo que de verdad importa: al desaparecer el caso especial, aparecieron capacidades que nadie había programado. El sandboxing dejó de ser una función de biblioteca y pasó a ser una consecuencia trivial de la regla, porque si _ENV es una variable léxica, entonces se puede sombrear en un bloque, pasar como parámetro, capturar en una closure, dar distinto a cada chunk cargado o incluso hacer que no sea una tabla sino un proxy con metatabla. Nada de eso se diseñó: se dedujo. Esa es la firma de una simplificación verdadera, frente a una mera reorganización de la complejidad. Cuando una abstracción es correcta, no solo hace más corto el manual: hace que los casos de uso que antes exigían código especializado se conviertan en aplicaciones directas de las reglas generales. Compáralo con la evolución de los lenguajes que crecen añadiendo una característica por cada problema y quedan con manuales de dos mil páginas donde toda característica interactúa con todas las demás. Lua eligió el camino contrario, y _ENV es su demostración más pura: la mejor característica del lenguaje es una que, mirada de cerca, resulta que no existe.

⚔️ Comprueba la traducción con tus manos
  1. Escribe local function f () return x end y saca su primer upvalue con debug.getupvalue; anota el nombre y el tipo.
  2. Compila x = 1 con luac -l y localiza el SETTABUP con el comentario _ENV "x".
  3. Escribe una función que reciba un parámetro llamado _ENV y comprueba que sus nombres libres se leen de la tabla que le pases.
  4. Asigna _G = 5 y verifica que el programa sigue funcionando; explica exactamente por qué.
  5. Declara _ENV como global en Lua 5.5 y transcribe el error de compilación; razona qué regresión infinita evita.