Serialización: una tabla convertida en Lua
Convertir una estructura de datos en código fuente ejecutable: la conversión `%q` como base, el paso de dos fases que resuelve los ciclos y preserva la identidad compartida, por qué las funciones no sobreviven al viaje, y qué garantiza exactamente la cabecera de un chunk binario producido por `string.dump`.
La serialización cierra el nivel porque es el problema donde convergen todas sus piezas. Convertir una tabla en texto es un ejercicio de veinte líneas mientras la tabla sea un árbol de valores simples; deja de serlo en cuanto aparece un ciclo, en cuanto dos ramas apuntan al mismo objeto, en cuanto un campo contiene una función y en cuanto alguien propone mandar el resultado por la red. Cada una de esas complicaciones enseña algo distinto sobre qué es realmente un valor en Lua, y la respuesta —que un valor es su contenido más su identidad más el mundo al que se refiere— explica por qué unas cosas cruzan la frontera intactas y otras no pueden cruzarla en absoluto.
- Emitir literales exactos con
%q, incluidos flotantes, infinitos y no-números. - Escribir un serializador de dos fases que preserve ciclos e identidad compartida entre referencias.
- Explicar por qué
string.dumpno salva los upvalues y qué haceloadcon el primero. - Enumerar lo que la cabecera de un chunk binario garantiza y lo que deliberadamente no garantiza.
La tabla como código fuente
La versión directa aprovecha que la sintaxis del constructor de tablas ya es un formato de datos y que %q produce constantes exactas. Solo hacen falta dos decisiones: cuándo una clave puede escribirse como nombre y cuándo hay que indexar por corchetes, y cómo separar la parte secuencial del resto.
local reservadas = {
["and"] = true, ["break"] = true, ["do"] = true, ["else"] = true,
["end"] = true, ["for"] = true, ["function"] = true, ["if"] = true,
["local"] = true, ["nil"] = true, ["not"] = true, ["or"] = true,
["return"] = true, ["then"] = true, ["while"] = true,
}
local function es_nombre(k)
return type(k) == "string"
and string.match(k, "^[%a_][%w_]*$") ~= nil
and not reservadas[k]
end
local function serializar(v)
local t = type(v)
if t ~= "table" then return string.format("%q", v) end
local piezas, n = { "{" }, #v
for i = 1, n do
piezas[#piezas + 1] = serializar(v[i]) .. ","
end
for k, x in pairs(v) do
if not (math.type(k) == "integer" and k >= 1 and k <= n) then
local clave = es_nombre(k) and (k .. "=") or ("[" .. serializar(k) .. "]=")
piezas[#piezas + 1] = clave .. serializar(x) .. ","
end
end
piezas[#piezas + 1] = "}"
return table.concat(piezas)
end
La lectura de vuelta es la lección 20.2 aplicada con la máxima restricción posible:
local datos = load("return " .. texto, "=datos", "t", {})()
Ese entorno vacío no es un adorno. Un texto compuesto exclusivamente de literales no nombra nada, así que no necesita ningún global para evaluarse; si el texto intentara llamar a una función, fallaría con un error de indexación en lugar de ejecutarla. Un serializador cuyo formato de salida es código convierte así la carga de datos ajenos en una operación con una superficie de ataque nula por construcción, siempre que el modo sea "t" y el entorno esté vacío. Es una propiedad notable, y se pierde entera si alguien decide que sería cómodo permitir una función auxiliar dentro del formato.
tostring(0.1) da 0.1, que al releerse produce un flotante distinto del original en el último bit. string.format("%q", 0.1) da una constante hexadecimal que recupera exactamente los mismos bits. La misma conversión escribe el infinito como una constante que desborda a infinito y el no-número como una división de cero entre cero, que son las únicas formas de que esos valores sobrevivan a un viaje por texto.
Ciclos e identidad compartida
El serializador anterior entra en recursión infinita en cuanto una tabla se contiene a sí misma, aunque sea a través de tres saltos. Y tiene un defecto más silencioso y a menudo peor: si dos campos apuntan a la misma tabla, la escribe dos veces, y al releer aparecen dos tablas distintas que ya no son iguales por identidad. Un grafo acíclico con estructura compartida en profundidad crece exponencialmente al aplanarlo en un árbol.
La solución a ambos problemas es la misma y consiste en dejar de emitir una expresión para emitir un programa: primero se declara una variable local por cada tabla del grafo, después se rellenan sus campos, y al final se devuelve la raíz.
local function literal(v, id)
if type(v) == "table" then return id[v] end
return string.format("%q", v)
end
local function serializar_grafo(raiz)
local id, orden, decl, asig = {}, {}, {}, {}
local function registrar(t)
if id[t] then return end
id[t] = "_" .. (#orden + 1)
orden[#orden + 1] = t
decl[#decl + 1] = "local " .. id[t] .. " = {}\n"
for k, v in pairs(t) do
if type(k) == "table" then registrar(k) end
if type(v) == "table" then registrar(v) end
end
end
registrar(raiz)
for _, t in ipairs(orden) do -- orden estable, no pairs(id)
for k, v in pairs(t) do
asig[#asig + 1] = string.format("%s[%s] = %s\n",
id[t], literal(k, id), literal(v, id))
end
end
return table.concat(decl) .. table.concat(asig) .. "return " .. id[raiz]
end
Tres detalles justifican cada línea. El registro previo de todas las tablas antes de rellenar ninguna es lo que rompe el ciclo: cuando se asigna un campo que apunta a una tabla aún no rellenada, la variable ya existe. La tabla id mapea cada tabla a su nombre y, al consultarse en literal, hace que dos referencias al mismo objeto emitan el mismo identificador, de modo que la identidad se conserva al releer. Y la segunda fase recorre orden y no id, porque iterar una tabla con claves de tipo tabla produce un orden que cambia entre ejecuciones: hacerlo sobre la lista ordenada es lo que convierte la salida en determinista, y sin determinismo no hay diferencias legibles ni sumas de verificación estables.
Lo que este esquema sigue sin salvar hay que decirlo: las metatablas. Una tabla con __index o con __eq se serializa como sus campos crudos y renace sin comportamiento. Salvarla exige nombrarla en un registro que exista en los dos extremos, que es exactamente la solución que veremos para las funciones.
flowchart TD V[Valor a serializar] --> Q[Numero cadena booleano o nil] Q --> OK[Sobrevive exacto con la conversion q] V --> T[Tabla] T --> D[Se registra y recibe un nombre local] D --> C[Los ciclos y el reparto se conservan] T --> M[Su metatabla no viaja] V --> F[Funcion] F --> B[string.dump salva el bytecode] B --> U[Los upvalues no viajan] V --> X[Userdata hilo o funcion de C] X --> N[No hay representacion posible]
string.dump y lo que el bytecode no lleva
string.dump(f, strip) devuelve un chunk binario con el prototipo compilado de una función de Lua. Falla con las funciones de C, que no tienen bytecode que volcar. El resultado se recarga con load en modo "b", y ahí empieza lo interesante.
local function contador()
local n = 0
return function() n = n + 1; return n end
end
local c = contador()
c(); c() -- n vale 2
local copia = load(string.dump(c), "=copia", "b")
print(copia()) -- error, no devuelve 3
El manual lo dice con una frase que conviene leer dos veces: de las funciones con upvalues solo se guarda el número de upvalues, y al recargarlas reciben valores nuevos. La razón es estructural y ya la conoces del nivel 8: un upvalue no es parte del prototipo sino de la closure, y su valor puede ser cualquier cosa del grafo vivo del programa, incluida otra closure o un userdata. Volcar una closure completa exigiría volcar el mundo entero al que alcanza.
El fallo concreto del ejemplo es más instructivo todavía. Cuando load termina, si la función resultante tiene al menos un upvalue, instala el entorno en el primero, sea cual sea su nombre. Lua no comprueba que se llame _ENV: en un chunk principal ese primer upvalue siempre es el entorno, pero en una closure cualquiera el primero es el que el compilador colocó ahí, que aquí es n. La copia arranca, por tanto, con n valiendo la tabla global, y la suma falla con un error de aritmética sobre un valor de tipo tabla. Es un caso perfecto de una regla razonable aplicada fuera del contexto para el que se escribió.
Reconstruir el estado a mano es posible con debug.setupvalue para cada upvalue, y con debug.upvaluejoin cuando dos closures compartían el mismo, porque asignar el mismo valor a las dos por separado produce dos celdas independientes y el reparto se pierde. En la práctica esto significa que las funciones no se serializan: se serializa su identidad. El patrón que funciona es un registro de funciones con nombre presente en los dos extremos; se emite el nombre como cadena y se resuelve al cargar. Los datos viajan, el comportamiento se acuerda.
El segundo argumento, strip, elimina la información de depuración: números de línea, nombres de locales y de upvalues, nombre del chunk. Reduce el tamaño de forma apreciable y a cambio convierte cualquier error futuro en un mensaje sin archivo ni línea. Es una opción para distribuir, nunca para diagnosticar.
Qué garantiza la cabecera
Un chunk binario empieza con una cabecera cuyo contenido es una lista de comprobaciones, y entenderla evita tanto el optimismo como el miedo. Lleva la firma del formato, el byte de versión del lenguaje, un byte de formato, una secuencia de bytes de control elegida para detectar que el transporte ha alterado saltos de línea o ha truncado el octavo bit, los tamaños en bytes de la instrucción, del entero y del flotante, y por último un entero y un flotante de prueba con valores conocidos.
Ese último par es el más elegante: si el número recuperado no coincide, la máquina que graba y la que lee difieren en el orden de los bytes o en la representación de los flotantes, y la carga se rechaza. La cabecera cubre, por tanto, versión del lenguaje, integridad del transporte, anchura de los tipos numéricos, orden de bytes y formato de coma flotante.
Y no cubre nada más. No hay verificación del cuerpo: un binario cuyo cuerpo haya sido manipulado pasa la cabecera y entra en la máquina virtual con índices de registro, saltos y constantes que el compilador nunca habría generado. Eso no produce un error de Lua, produce accesos fuera de límites en el proceso anfitrión. De ahí la regla que ya enunciamos en 20.1 y que ahora se entiende del todo: el bytecode es una caché local, útil para acelerar el arranque en la misma máquina y con el mismo binario del intérprete, y jamás un formato de intercambio. Entre versiones de Lua el formato cambia sin compromiso de compatibilidad, y el de LuaJIT es otro distinto por completo.
Serializar obliga a contestar una pregunta que el resto de la programación permite esquivar: qué parte de un valor es el valor. Mientras un dato vive dentro de un proceso, su significado se sostiene sobre un andamiaje invisible que nadie tiene que nombrar: su dirección lo distingue de cualquier otro dato igual a él, su metatabla le da comportamiento, los upvalues de sus funciones le dan memoria, el entorno le da un vocabulario y la máquina virtual concreta le da una interpretación a cada byte de su bytecode. Serializar es arrancar el dato de ese andamiaje y preguntarle si sigue significando algo. La respuesta se ordena en capas nítidas. El contenido plano —números, cadenas, booleanos— sobrevive intacto, porque su significado es enteramente interno y %q lo transporta bit a bit. La estructura sobrevive si te tomas la molestia de dos fases, porque el reparto y los ciclos son relaciones entre los objetos que estás mandando y por tanto expresables en el mismo mensaje. La identidad no sobrevive: al otro lado hay objetos nuevos, y si tu programa usaba la dirección de una tabla como clave o si comparaba con igualdad de referencia, esa información se ha perdido y no había forma de mandarla. Y el comportamiento no sobrevive en absoluto, porque una función es su bytecode más sus upvalues más su entorno más una máquina virtual de la misma construcción, y de esos cuatro solo el primero cabe en una cadena. Este escalonamiento no es una limitación de Lua sino la forma general del problema, y explica por qué toda solución madura acaba en el mismo sitio: se manda descripción, no ejecución. Un nombre que las dos partes saben resolver contra un registro compartido pesa doce bytes, es legible, es verificable, no ejecuta nada al cargarse y no depende de la versión del intérprete; el bytecode de esa misma función pesa cientos de bytes, no es legible, no es verificable, ejecuta al cargarse y solo funciona en el gemelo exacto de la máquina que lo produjo. Cada vez que sientas la tentación de mandar código porque es más cómodo que acordar un vocabulario, recuerda que el acuerdo es el trabajo y que el código es solo la manera de saltárselo durante un rato.
- Serializa una tabla con claves de identificador, claves con espacios, enteros fuera de la secuencia y el valor
0.1. Recárgala con un entorno vacío y comprueba con==que el flotante es idéntico. - Construye una tabla que se contenga a sí misma y comprueba que el serializador ingenuo se queda sin pila. Repite con la versión de dos fases.
- Crea un grafo donde dos campos apunten a la misma tabla. Verifica con
rawequalque la propiedad se conserva tras el viaje, y que con el serializador ingenuo no. - Sustituye el recorrido de la segunda fase por
pairssobre la tabla de identificadores y serializa el mismo grafo diez veces. Anota cuántas salidas distintas obtienes. - Vuelca la closure contadora de la lección, recárgala y transcribe el error exacto. Explícalo citando qué hace
loadcon el primer upvalue. - Vuelca la misma función con y sin
strip, compara los tamaños, provoca un error en cada versión y compara los mensajes.