Generar Lua desde Lua: plantillas y despacho
Construir código como cadena y compilarlo: el patrón de la plantilla compilada a función, el criterio honesto para decidir cuándo la generación compensa frente a una tabla de despacho o una closure especializada, y las reglas que evitan que una cadena de datos se convierta en código ejecutable.
Con load en la mano y el entorno bajo control, queda la parte que de verdad distingue a la metaprogramación de un truco: producir el texto. Generar Lua desde Lua es fácil de escribir y difícil de justificar, porque la generación siempre gana en velocidad bruta y casi siempre pierde en todo lo demás. Esta lección trata el mecanismo —cómo se construye la cadena sin errores y cómo se convierte una plantilla en una función—, pero sobre todo trata el criterio: qué problema tiene la forma correcta para que compilar en tiempo de ejecución sea la respuesta y no una manera cara de escribir un if.
- Construir código fuente correcto usando
string.formatcon la conversión%qy un búfer de piezas. - Compilar una plantilla de texto a una función parametrizada por su propio
_ENV. - Comparar la generación con una tabla de despacho y con una closure especializada, y decidir con un criterio enunciable.
- Identificar y cerrar las tres vías por las que un dato se convierte en código ejecutable.
Construir el texto sin equivocarse
Dos herramientas resuelven el noventa por ciento del problema. La primera es un búfer de piezas cerrado con table.concat, porque concatenar con el operador dentro de un bucle construye una cadena nueva en cada vuelta y convierte un trabajo lineal en cuadrático. La segunda es la conversión %q de string.format, cuyo contrato es exactamente el que necesitamos: produce una constante válida en el código fuente de Lua.
print(string.format("%q", 'dice "hola"\ny salta'))
--> "dice \"hola\"\
--> y salta"
print(string.format("%q", 0.1)) --> 0x1.999999999999ap-4
print(string.format("%q", 1/0)) --> 1e9999
print(string.format("%q", 0/0)) --> (0/0)
Los tres últimos casos son la razón por la que %q también se usa con números y no solo con cadenas. Desde Lua 5.3, un flotante se emite en notación hexadecimal, que es la única forma de que el valor recuperado sea bit a bit el mismo; el infinito y el no-número se emiten como expresiones que se evalúan a ellos mismos. Escribir tostring(x) en su lugar redondea a decimal y pierde información en silencio. La conversión acepta cadenas, números, booleanos y nil, y rechaza cualquier otra cosa con un error, lo cual es una comprobación gratuita de que no estás intentando meter una tabla en una posición de literal.
La plantilla compilada a función
El caso de estudio canónico es un motor de plantillas. La idea es no interpretar la plantilla en cada uso, sino traducirla una vez a una función de Lua que ya solo concatena. Usamos $(expresion) como marca, y aprovechamos el nivel 9 para que la tabla de datos sea directamente el _ENV de la función generada.
local function compilar_plantilla(texto, nombre)
local p = {
"local _tostring, _concat = tostring, table.concat\n",
"return function (_ENV)\n",
"local _s = {}\n",
}
local pos = 1
while true do
local i, j = string.find(texto, "%$%b()", pos)
if not i then break end
p[#p + 1] = string.format("_s[#_s+1] = %q\n", string.sub(texto, pos, i - 1))
p[#p + 1] = "_s[#_s+1] = _tostring(" .. string.sub(texto, i + 2, j - 1) .. ")\n"
pos = j + 1
end
p[#p + 1] = string.format("_s[#_s+1] = %q\n", string.sub(texto, pos))
p[#p + 1] = "return _concat(_s)\nend\n"
local chunk, err = load(table.concat(p), "=plantilla:" .. nombre, "t")
if not chunk then return nil, err end
return chunk()
end
local render = compilar_plantilla("Hola $(nombre), tienes $(n) mensajes.", "saludo")
print(render({ nombre = "Ada", n = 3 })) --> Hola Ada, tienes 3 mensajes.
Merece la pena leer despacio el código generado, porque cada línea es una decisión. Las dos funciones auxiliares se capturan como locales del chunk exterior, de modo que dentro de la función generada son upvalues, tan baratos como una local, y no accesos al entorno. El parámetro se llama _ENV, así que todo nombre libre de la plantilla se resuelve en la tabla de datos y en ninguna otra parte: la plantilla no puede llamar a os.exit porque os no existe en ese mundo. El texto literal viaja por %q, de modo que unas comillas o un salto de línea en la plantilla no rompen nada. Y el nombre del chunk lleva el prefijo = con el nombre de la plantilla, para que un error de ejecución señale a algo reconocible.
flowchart LR T[Texto de la plantilla] --> A[Analisis de las marcas] A --> B[Piezas de codigo en una tabla] B --> C[table.concat produce la fuente] C --> D[load en modo texto con nombre propio] D --> E[Funcion que recibe su _ENV] E --> F[Se llama miles de veces sin recompilar]
Frente a la tabla de despacho
La pregunta correcta no es si la generación es rápida, que lo es, sino qué alternativa se está descartando. Hay tres soluciones al mismo problema y forman una escala clara.
La tabla de despacho guarda funciones en una tabla y elige por clave. Cuesta una búsqueda en la parte hash más una llamada, es trivial de leer, de depurar y de recargar, y es la respuesta por omisión. La closure especializada captura los datos fijos como upvalues y devuelve una función que ya no consulta nada: elimina la búsqueda y deja el cuerpo escrito por un humano. La generación elimina además la llamada y cualquier estructura intermedia, porque el cuerpo se escribe con los valores concretos ya incorporados.
-- Despacho: una busqueda por iteracion
local ops = { suma = function(a, b) return a + b end }
local function aplicar(nombre, a, b) return ops[nombre](a, b) end
-- Closure: la decision se toma una vez, fuera del bucle
local function hacer(nombre) return ops[nombre] end
-- Generacion: no queda ni llamada ni tabla
local sumar = load("local a, b = ...; return a + b", "=op:suma", "t")()
Tabla de despacho
Una búsqueda en la parte hash más una llamada por iteración. Legible, depurable, recargable en caliente. Es la respuesta por omisión y acierta casi siempre.
Closure especializada
La decisión se toma una vez y el resultado queda en un upvalue, tan barato como una local. Suele capturar la mayor parte de la mejora sin renunciar a nada.
Código generado
Sin búsqueda, sin llamada y sin estructura intermedia. Gana un orden de magnitud en el caso correcto y pierde la traza, el analizador estático y el depurador.
El criterio que sí se puede enunciar es este: la generación compensa cuando estás escribiendo un intérprete de una estructura de datos que se ejecuta en un bucle caliente. Si tu código recorre en cada iteración un esquema, un plan de consulta, una gramática o una lista de campos para decidir qué hacer, estás interpretando; generar convierte esa interpretación repetida en una compilación única. Ese es literalmente el contenido de la primera proyección de Futamura: especializar un intérprete respecto de un programa concreto produce un compilador para ese programa. Serializadores de un esquema fijo, mapeadores de filas de una consulta, máquinas de estados derivadas de una gramática y compiladores de cadenas de formato son los casos donde la técnica está justificada, y no por casualidad son todos intérpretes disfrazados.
Fuera de ese patrón, la generación pierde. Pierde cuando la forma cambia a menudo, porque el coste de compilar domina. Pierde cuando el cuerpo generado es tan corto que una closure con upvalues consigue el noventa por ciento de la mejora sin ninguno de los inconvenientes. Y pierde siempre en observabilidad: la traza de error apunta a un archivo que no existe, ningún analizador estático ve ese código, ningún depurador puede poner un punto de ruptura en él a menos que tú lo emitas a disco, y ninguna herramienta de cobertura lo cuenta.
La comparación válida no es entre la versión generada y la versión ingenua, sino entre la generada y la mejor versión escrita a mano, que casi siempre es una closure que saca la decisión fuera del bucle. Muchos informes de una mejora espectacular por generación miden en realidad la mejora de haber dejado de buscar en una tabla dentro del bucle, y eso se consigue sin compilar nada.
Inyección: el dato que se vuelve código
Todo generador tiene tres puntos por donde un dato puede convertirse en programa, y los tres tienen una regla.
El primero y peor es interpolar un dato en posición de expresión. Aquí no hay grados: es ejecución de código arbitrario.
-- NUNCA. La entrada es codigo.
local f = load("return " .. entrada_del_usuario)
El segundo es interpolar un dato en posición de literal sin pasar por %q. Aunque no haya malicia, una comilla o un salto de línea rompen la sintaxis; con malicia, la comilla cierra la cadena y lo que sigue es código:
local clave = 'x"] = os.exit() --'
local malo = 'local t = {}\nt["' .. clave .. '"] = 1\nreturn t'
local bueno = 'local t = {}\nt[' .. string.format("%q", clave) .. '] = 1\nreturn t'
El tercero es el más sutil: interpolar un dato en posición de identificador. Emitir t. seguido de un nombre que viene de datos falla si el nombre lleva un guion, y ejecuta cosas raras si el nombre es una palabra reservada o contiene un paréntesis. La regla es no emitir nunca identificadores procedentes de datos: se emite siempre indexación por corchetes con el nombre pasado por %q, que es válido para cualquier cadena.
De aquí sale la disciplina completa, y son tres frases. Los datos viajan como argumentos del chunk o dentro de su _ENV, nunca como texto. Si algo tiene que aparecer en el fuente, aparece como constante a través de %q y jamás como expresión. Y el resultado se carga siempre en modo "t" y con un entorno restringido, porque la defensa en profundidad es barata cuando cuesta dos argumentos más.
Falta una cuarta práctica que no es de seguridad sino de supervivencia. Todo generador serio escribe su salida a disco cuando una variable de entorno o una opción de diagnóstico lo pide, y carga entonces ese archivo con loadfile en lugar de la cadena:
local fuente = table.concat(piezas)
if os.getenv("VOLCAR_GENERADO") then
local ruta = "/tmp/generado-" .. nombre .. ".lua"
local h = assert(io.open(ruta, "w")); h:write(fuente); h:close()
return loadfile(ruta, "t", entorno) -- lineas y puntos de ruptura reales
end
return load(fuente, "=generado:" .. nombre, "t", entorno)
Son seis líneas y devuelven de golpe todo lo que la generación te había quitado: una traza que apunta a un archivo que existe, un depurador que puede parar dentro y un texto que un humano puede leer para descubrir que el bug no estaba en el generador sino en su idea de la sintaxis.
Cuando decides generar código estás firmando un contrato que casi nadie lee del todo, y su cláusula principal es esta: acabas de convertirte en el autor de un compilador, con todas las obligaciones que eso conlleva y ninguna de las herramientas. El texto que emites tiene una sintaxis que hay que respetar, una semántica que hay que preservar y una relación con la entrada que debería ser una función total —para toda estructura de datos válida, un programa válido—, pero no existe ninguna especificación escrita de esa relación salvo el propio generador, ni ninguna prueba de que se cumpla salvo los casos que se te hayan ocurrido. Por eso la única forma sostenible de la técnica es la que mantiene el código generado en el papel de artefacto derivado y desechable: se produce a partir de datos que son la fuente de verdad, se puede regenerar entero en cualquier momento, y nadie lo edita jamás. En el instante en que alguien retoca la salida a mano, o en que el generador acumula casos especiales que ya no se deducen de la entrada, lo que tienes no es metaprogramación sino un compilador sin especificación mantenido por accidente, y su comportamiento vive en la intersección de dos programas que nadie lee a la vez. Hay además una asimetría que conviene tener presente al decidir. Cuando escribes código a mano, todo el ecosistema trabaja para ti: el analizador sintáctico te corrige, el servidor de lenguaje te completa, el depurador te para en la línea, la cobertura te dice qué no probaste, el control de versiones te enseña qué cambió. El código generado en tiempo de ejecución sale fuera de todo eso de golpe; existe solo en memoria, entre la llamada a load y la última referencia a la función. Emitirlo a un archivo cuando se activa un modo de diagnóstico —para poder leerlo, cargarlo con loadfile y ponerle un punto de ruptura— no es un lujo, es la mínima restitución de lo que has renunciado. La generación es una herramienta excelente y su lugar es estrecho: cuando la ganancia es un orden de magnitud, cuando la forma del código es una proyección mecánica de datos que ya tienes, y cuando el resultado se puede tirar y volver a construir sin que nadie lo eche de menos.
- Escribe el compilador de plantillas de la lección y verifica que un texto con comillas dobles y saltos de línea sobrevive intacto gracias a
%q. - Comprueba desde una plantilla que no puedes alcanzar
osniprint, y explica por qué en términos del parámetro_ENV. - Implementa la misma operación de tres formas —tabla de despacho, closure especializada y función generada— y mide un millón de iteraciones de cada una. Anota cuánta de la mejora total aporta ya la closure.
- Construye un serializador de un esquema fijo de seis campos: primero interpretando el esquema en cada llamada, después generando una función específica. Compara y decide si lo mantendrías.
- Escribe deliberadamente un generador vulnerable que interpole una clave sin
%qy consigue que ejecute una llamada que tú no escribiste. Arréglalo después con corchetes y%q.