load: compilar una cadena en ejecución
El compilador de Lua expuesto como una función corriente de la biblioteca base: cómo `load` convierte texto en un chunk ejecutable, por qué el resultado es una función vararg con `_ENV` en su primer upvalue, qué gobierna el nombre del chunk en los mensajes de error, y por qué el modo de texto frente al binario es una decisión de seguridad y no de comodidad.
Casi todos los lenguajes dinámicos tienen una función que compila texto, y casi todos la rodean de advertencias porque hace tres cosas a la vez: compila, ejecuta y hereda el ámbito de quien la llama. load no es esa función. load compila y devuelve; no ejecuta nada, no ve las locales del que la invoca y no decide en qué mundo correrá lo que produce. Esas tres separaciones son la razón de que la metaprogramación en Lua sea una técnica ordinaria en lugar de un tabú, y son también la razón de que el compilador entero quepa expuesto como un valor más de la biblioteca base sin que el lenguaje pierda nada.
- Compilar una cadena con
loady distinguir con precisión el fallo de compilación del fallo de ejecución. - Explicar por qué el chunk resultante es una función vararg cuyo primer upvalue es
_ENV. - Controlar el nombre del chunk y leer los tres prefijos que gobiernan los mensajes de error.
- Elegir el modo de carga y justificar por qué el binario nunca es aceptable desde una fuente ajena.
De la cadena a la función
La firma completa es load(chunk, chunkname, mode, env) y solo el primer argumento es obligatorio. El valor devuelto es una función; si el texto no compila, se devuelven dos valores: nil y el mensaje del compilador.
local f, err = load("return 2 + 3")
print(f) --> function: 0x55a...
print(f()) --> 5
local g, err2 = load("return 2 +")
print(g) --> nil
print(err2) --> [string "return 2 +"]:1: unexpected symbol near '<eof>'
Obsérvese que un error de sintaxis no lanza un error: se devuelve. Esto no es un capricho de la interfaz, es lo que permite tratar la compilación como una operación total, que siempre termina y siempre entrega un resultado inspeccionable. Compilar código ajeno nunca necesita pcall; ejecutarlo, siempre.
El chunk compilado es una función vararg, exactamente como un archivo cargado con require. Eso da la vía honesta para pasarle datos:
local sumar = load("local a, b = ...; return a + b")
print(sumar(3, 4)) --> 7
Compara esa línea con la tentación de escribir load("return " .. a .. " + " .. b). La primera compila una vez y se llama un millón de veces con argumentos distintos; la segunda compila un millón de veces y, además, abre la puerta a la inyección de la lección 20.3. Los datos van por la pila de llamada; el texto es solo para la forma del código.
Falta la propiedad que más sorprende a quien viene de otros lenguajes: el chunk no ve el ámbito léxico de quien lo compiló.
local x = 10
local f = load("return x")
print(f()) --> nil
No hay ningún misterio si recuerdas la regla del nivel 9: x es un nombre libre, luego significa _ENV.x, y el _ENV de ese chunk recién nacido es el entorno global, donde no hay ninguna x. Las locales del llamante son ranuras de un marco de pila que el compilador ni siquiera tiene delante. La consecuencia práctica es enorme: el código cargado con load tiene una superficie de contacto explícita y finita con el resto del programa, formada por sus argumentos y por su entorno. Nada más.
El primer argumento admite además una función lectora, que Lua llama repetidamente hasta que devuelve nil o la cadena vacía. Cada trozo se concatena conceptualmente con el anterior y el analizador los consume según llegan, sin que el texto completo llegue a existir en memoria.
local trozos = { "local a, b = ...\n", "return a * b\n" }
local i = 0
local f = load(function()
i = i + 1
return trozos[i] -- nil al agotarse termina la lectura
end, "=producto", "t")
print(f(6, 7)) --> 42
Ese es el modo en que loadfile lee un archivo y el que se usa para compilar desde un socket o desde una entrada comprimida. La única regla es que los cortes entre trozos son arbitrarios para ti pero no para el analizador léxico: puedes partir el texto donde quieras, incluso en mitad de un identificador, porque el lector se comporta como un flujo continuo.
flowchart LR S[Cadena o funcion lectora] --> L[Analisis lexico] L --> P[Analisis sintactico en una pasada] P --> G[Generador de bytecode] G --> R[Prototipo con nombre de chunk y lineas] R --> C[Closure vararg con _ENV en el upvalue 1] P -.->|error de sintaxis| E[Devuelve nil y el mensaje]
El nombre del chunk manda en los errores
El segundo argumento es el nombre con el que ese código aparecerá en todo mensaje de error, en toda traza y en el campo source de debug.getinfo. Si lo omites y el primer argumento era una cadena, Lua usa la cadena misma, truncada, entre corchetes. Los tres prefijos posibles cambian por completo el resultado:
local a = load("error('roto')")
local b = load("error('roto')", "=configuracion del usuario")
local c = load("error('roto')", "@/etc/app/init.lua")
print(select(2, pcall(a))) --> [string "error('roto')"]:1: roto
print(select(2, pcall(b))) --> configuracion del usuario:1: roto
print(select(2, pcall(c))) --> /etc/app/init.lua:1: roto
La regla es mecánica. El prefijo @ declara que lo que sigue es un nombre de archivo y se muestra tal cual, que es lo que hace loadfile. El prefijo = dice que lo que sigue debe usarse literalmente, sin decoración de ninguna clase, y es la elección correcta para fuentes que no son archivos: una plantilla, una expresión de configuración, una línea de un intérprete interactivo. Sin prefijo, Lua asume que el nombre es el código y lo presenta como una cita entre corchetes, cortada a unos sesenta caracteres.
Ese corte es exactamente el motivo por el que hay que pasar siempre un nombre. Cuando generas cuatrocientas líneas de Lua y algo falla en la línea trescientos doce, el mensaje por omisión te dará los primeros sesenta caracteres del archivo generado seguidos de puntos suspensivos, y el número de línea será correcto pero apuntará a un archivo que no existe en ningún disco. Un nombre estable —por ejemplo =plantilla:informe.tpl— convierte esa traza en algo que se puede correlacionar con la entrada real.
El nombre no se guarda en la closure sino en el prototipo, junto con la tabla de líneas. Sobrevive a string.dump, aparece en debug.getinfo(f, "S").short_src y es lo que luaL_traceback imprime en cada marco. Elegirlo bien es una decisión de observabilidad, no de estética.
Texto o binario: el modo es una frontera
El tercer argumento acepta "t" (solo texto), "b" (solo binario) y "bt" (ambos), y el valor por omisión es "bt". Un chunk binario es la salida de string.dump o un archivo producido por luac: bytecode precompilado, reconocible porque empieza por un byte de escape seguido de la firma del lenguaje.
Aquí no hay matices que discutir. El manual es explícito en que Lua no verifica la consistencia de los chunks binarios y en que un binario manipulado con malicia puede hacer caer al intérprete. El cargador comprueba la cabecera —versión, formato, tamaños de los tipos numéricos— y poco más; no es un verificador de bytecode en el sentido en que lo es el de la JVM, y nunca pretendió serlo. Un binario deformado puede escribir fuera de los límites de la pila de registros, saltar a un índice de constante inexistente o construir un valor con una etiqueta de tipo que no le corresponde. El resultado no es una excepción de Lua: es corrupción de memoria en el proceso anfitrión.
De ahí la única regla que hay que recordar de esta lección:
-- Todo lo que venga de fuera del proceso se carga en modo texto. Sin excepciones.
local chunk, err = load(entrada, "=entrada remota", "t", entorno_restringido)
if not chunk then
return nil, "no compila: " .. err
end
Restringir a "t" no es una molestia: es la diferencia entre un fallo de compilación limpio y una primitiva de escritura arbitraria. El modo "b" tiene su lugar —arranques rápidos desde bytecode que tú mismo generaste y firmaste—, pero solo cuando la cadena de custodia del binario está enteramente bajo tu control.
Compilar no es ejecutar
Las dos operaciones fallan de maneras distintas y por eso un cargador robusto tiene siempre dos ramas: la compilación devuelve nil y un mensaje, la ejecución lanza y hay que atraparla.
local function ejecutar(fuente, nombre, entorno)
local chunk, err = load(fuente, nombre, "t", entorno)
if not chunk then
return nil, "compilacion: " .. err
end
local ok, resultado = pcall(chunk)
if not ok then
return nil, "ejecucion: " .. tostring(resultado)
end
return resultado
end
La segunda consecuencia de la separación es económica. Compilar cuesta, aproximadamente, en proporción a la longitud del texto, y ese coste es de varios órdenes de magnitud mayor que el de una llamada a función. El analizador de Lua es de una sola pasada y genera bytecode sin construir un árbol de sintaxis, lo que lo hace notablemente rápido, pero rápido no es gratis. El error clásico consiste en llamar a load dentro de un bucle con el mismo texto: hay que compilar una vez, guardar la función y llamarla tantas veces como haga falta.
La tercera es la más útil de todas. Como el chunk es un valor de primera clase, se puede guardar en una tabla, pasar a otra función, volcar a disco con string.dump, y —lo que abre la lección siguiente— darle un entorno distinto sin recompilarlo, sustituyendo su primer upvalue con debug.setupvalue. Compilar una plantilla una vez y ejecutarla mil veces contra mil entornos distintos es un patrón de una línea justamente porque las tres decisiones están desacopladas.
loadfile es load leyendo de un archivo y con el prefijo @ puesto automáticamente en el nombre del chunk; acepta los mismos argumentos de modo y entorno y devuelve igualmente nil más el mensaje si el archivo no compila o no se puede abrir. dofile es loadfile seguido de la llamada, y rompe el patrón a propósito: propaga los errores en lugar de devolverlos, porque está pensado para el arranque, donde un fallo debe detener el programa. Y require de la lección 14 está construido sobre loadfile más una búsqueda por package.path y una caché en package.loaded. Los cuatro son capas sobre la misma operación.
La diferencia entre load y el eval de la mayoría de los lenguajes dinámicos no es de potencia sino de descomposición, y es una de las decisiones de diseño más finas que tomó Lua. Compilar código en tiempo de ejecución implica responder a tres preguntas independientes: qué texto se compila, cuándo se ejecuta el resultado y en qué mundo de nombres se ejecuta. El eval clásico contesta las tres a la vez y siempre igual: compila esto, ejecútalo ahora, y hazlo en mi ámbito, con acceso a mis locales y a todo lo que yo alcanzo. De esa fusión salen todos sus problemas conocidos, desde la imposibilidad de razonar estáticamente sobre el código que lo rodea hasta el hecho de que cualquier dato que llegue a un eval sea automáticamente una escalada de privilegios. load responde a la primera pregunta y devuelve; la segunda la contestas tú llamando a la función, cuando quieras y cuantas veces quieras; y la tercera la contesta el cuarto argumento, o el upvalue que sustituyas después. Lo que quiero que veas es que la seguridad relativa de load no viene de comprobaciones ni de listas de prohibiciones —no hay ninguna—, sino de haber partido una operación monolítica en tres decisiones ortogonales que el programador toma por separado. Esa es la misma economía que vimos con _ENV en el nivel 9: cuando una abstracción se descompone bien, las propiedades deseables aparecen como consecuencias en lugar de tener que programarse. Y hay un corolario que casi nadie enuncia: como el compilador es un valor de biblioteca y no una fase privilegiada del proceso, en Lua no existe la distinción entre tiempo de compilación y tiempo de ejecución que estructura a los lenguajes compilados. No es que la frontera se cruce con facilidad; es que no hay frontera que cruzar. Todo intérprete de Lua embebido en un juego, en un router o en tu editor lleva dentro un compilador completo de unos pocos kilobytes, y esa es la razón última de que la configuración como código, la recarga en caliente y el REPL sean en Lua aplicaciones triviales de las reglas generales, mientras que en otros ecosistemas exigen un subsistema entero.
- Compila
return 2 +y comprueba queloaddevuelve dos valores en lugar de lanzar. Después compilaerror('x')y comprueba que hace faltapcallpara sobrevivir a su ejecución. - Escribe un chunk que sume sus dos primeros argumentos vararg y llámalo cien veces con pares distintos, compilando una sola vez. Mide con
os.clockla diferencia frente a recompilar en cada iteración. - Carga el mismo texto con los tres estilos de nombre —sin prefijo, con
=y con@— y transcribe los tres mensajes de error resultantes. - Declara una local
xy compilareturn x. Explica elnilque obtienes citando la regla del nombre libre del nivel 9. - Vuelca una función con
string.dump, intenta cargarla con el modo"t"y anota el mensaje exacto. Razona por qué ese rechazo es una defensa y no una limitación.