Recargar y aislar módulos
Por qué require nunca recarga, qué se rompe exactamente al vaciar el caché a mano, cómo cargar código en un entorno propio con loadfile y su cuarto argumento, y qué hace y qué no hace LuaRocks como gestor de paquetes.
Las dos preguntas que cierran este nivel parecen prácticas y son en realidad la misma pregunta teórica formulada dos veces. Recargar un módulo consiste en pedir que un nombre deje de significar lo que significaba; aislarlo consiste en pedir que ese nombre signifique cosas distintas en sitios distintos. Ambas chocan con la propiedad central del sistema —un nombre, un valor, un proceso— y ambas se resuelven con las mismas dos herramientas: manipular el caché o no usarlo en absoluto. Ver por qué la primera vía es peligrosa y la segunda es limpia es el resumen honesto de todo el nivel 13.
- Explicar por qué
requireno recarga y qué habría que cambiar para que lo hiciera. - Enumerar los cuatro tipos de referencia obsoleta que sobreviven a un vaciado del caché.
- Cargar un chunk en un entorno propio con
loadfiley controlar qué puede ver. - Situar a LuaRocks respecto del mecanismo del lenguaje: qué automatiza y qué no toca.
Por qué require no recarga
Ya conoces la respuesta desde la lección 13.2: la primera acción de require es consultar package.loaded, y si el nombre tiene un valor verdadero devuelve ese valor sin mirar nada más. El fichero puede haber cambiado en disco, haberse borrado o haberse sustituido entero; require no lo sabrá nunca porque no vuelve a mirar.
Esto no es una limitación que se olvidaron de quitar, sino la condición que sostiene la identidad de los módulos. Si require releyera el fichero en cada llamada, dos partes del programa que pidiesen el mismo nombre obtendrían tablas distintas, las metatablas dejarían de servir como marca de tipo y el estado de un módulo se multiplicaría por el número de consumidores.
La única forma de forzar una nueva carga es borrar la entrada.
local function recargar(nombre)
package.loaded[nombre] = nil
return require(nombre)
end
Tres líneas que funcionan y que casi nunca hacen lo que uno cree.
Vaciar el caché y lo que se rompe
Borrar la entrada no destruye la versión antigua: la desconecta del nombre. El resultado no es un módulo actualizado sino dos módulos coexistiendo, el viejo alcanzable por todas las referencias que ya se habían tomado y el nuevo por las que se tomen a partir de ahora.
flowchart TB V[tabla del modulo version 1] -->|referencia guardada| C1[consumidor cargado antes] V -->|metatabla de objetos ya creados| O[instancias antiguas vivas] V -->|callback registrado| R[registro de sucesos] K[cache vaciado y vuelto a llenar] --> N[tabla del modulo version 2] N -->|referencia nueva| C2[consumidor cargado despues] O -.->|la comparacion de identidad falla| N
Los daños tienen cuatro formas distintas y conviene poder nombrarlas.
La primera son las referencias directas. Todo módulo que hiciera local otro = require("otro") en su cabecera guarda la tabla vieja en un upvalue y seguirá usándola para siempre. Como esa captura es léxica y ya está cerrada, ninguna manipulación del caché la alcanza.
La segunda son las metatablas. Si el módulo define un tipo y las instancias creadas antes de la recarga llevan la metatabla vieja, la comprobación de tipo por identidad —comparar getmetatable(x) con la tabla del módulo— empezará a fallar para objetos perfectamente válidos. Es el fallo más desconcertante porque el objeto se comporta bien y solo la comparación miente.
La tercera son los registros externos. Toda función del módulo que se hubiera entregado a un sistema de suscripción, a una tabla de despacho o a una lista de temporizadores seguirá siendo la función antigua, con sus upvalues antiguos apuntando al estado antiguo.
La cuarta es el estado perdido. La tabla nueva nace vacía: contadores, cachés internos y conexiones abiertas desaparecen del nombre aunque sigan vivos en la versión anterior.
Existe un patrón intermedio que mitiga la primera forma de daño conservando la identidad de la tabla: cargar la versión nueva y volcar su contenido dentro de la tabla vieja.
local function recargar_en_sitio(nombre)
local viejo = package.loaded[nombre]
package.loaded[nombre] = nil
local nuevo = require(nombre)
if type(viejo) ~= "table" or type(nuevo) ~= "table" then
return nuevo
end
for k in pairs(viejo) do viejo[k] = nil end
for k, v in pairs(nuevo) do viejo[k] = v end
package.loaded[nombre] = viejo
return viejo
end
Quien tenía la tabla vieja ve ahora las funciones nuevas, porque la tabla es la misma y solo ha cambiado su contenido. Sigue sin resolver las metatablas ya adheridas ni los callbacks ya registrados, y añade un riesgo propio: si la versión nueva tiene un error de sintaxis, el require falla después de haber borrado la entrada y te quedas sin ninguna de las dos.
Todos los patrones de esta sección son aceptables mientras editas código y observas el efecto, y ninguno lo es en producción. La razón no es la elegancia: es que el conjunto de referencias obsoletas que sobreviven depende del orden concreto en que se cargaron los módulos, y por tanto los fallos que produce no son reproducibles. Si necesitas sustituir código en un servicio vivo, la respuesta es crear un estado de Lua nuevo y descartar el viejo, no reescribir el caché del que ya tienes.
Cargar un módulo en un entorno propio
La vía limpia consiste en no pasar por require. Desde 5.2, tanto load como loadfile aceptan un cuarto argumento que será el valor del _ENV del chunk resultante, y con él un fichero se ejecuta viendo exactamente el entorno que tú decidas.
local function cargar_aislado(ruta, entorno)
local chunk, err = loadfile(ruta, "t", entorno)
if not chunk then return nil, err end
return chunk()
end
El tercer argumento, la cadena que indica el modo, merece atención en cualquier contexto donde el código no sea enteramente tuyo. Restringirlo al texto hace que el cargador rechace el bytecode precompilado, y ese rechazo no es una formalidad: el verificador de bytecode de Lua no es un mecanismo de seguridad, y un volcado manipulado puede provocar comportamiento indefinido en el intérprete.
El entorno se construye por enumeración, nunca por copia del global.
local entorno = {
pairs = pairs, ipairs = ipairs, type = type, tostring = tostring,
math = math, string = string, table = table,
}
entorno._G = entorno
local plugin = assert(cargar_aislado("plugins/saludo.lua", entorno))
Lo que no está en la tabla no existe para ese chunk: no hay io, no hay os, no hay load y, en particular, no hay require. Si quieres que el código aislado pueda importar, tendrás que darle una función de importación propia con su propio caché, porque la que usa el require real es la del proceso entero y compartirla anularía el aislamiento.
local function require_privado(cache, entorno)
return function(nombre)
local v = cache[nombre]
if v ~= nil then return v end
local ruta = assert(package.searchpath(nombre, package.path))
local chunk = assert(loadfile(ruta, "t", entorno))
v = chunk(nombre, ruta)
if v == nil then v = true end
cache[nombre] = v
return v
end
end
Son doce líneas que reproducen el algoritmo del nivel entero: consultar, buscar, ejecutar, anotar. Que quepan aquí es la mejor prueba de la tesis de la lección 13.1, porque el sistema de módulos de Lua es exactamente esto y nada más.
Conviene ser exacto sobre el alcance de esta técnica, porque se sobrevende con facilidad. Controlar el entorno controla qué nombres ve el código; no limita cuánta memoria consume, ni cuánto tiempo se ejecuta, ni impide un bucle infinito. El aislamiento fuerte —límites de asignación, cuentas de instrucciones, estados independientes— pertenece a la API C y a la lección de empotrado del nivel 27.
LuaRocks: el gestor
LuaRocks es el gestor de paquetes del ecosistema, y lo primero que hay que entender es lo poco que cambia. No introduce un formato de módulo nuevo, no altera require y no añade ningún buscador. Se limita a descargar código, compilar la parte en C si la hay, dejar los ficheros en un árbol de directorios y decirte qué plantillas debes añadir a package.path y package.cpath para que el mecanismo de siempre los encuentre.
luarocks install --local penlight
eval "$(luarocks path --bin)" # anade las plantillas al entorno
luarocks show penlight
Un paquete se describe en un rockspec, un fichero que es a su vez una tabla de Lua: nombre, versión, dependencias con restricciones, origen del código y reglas de construcción. Que la descripción del paquete sea código del propio lenguaje es coherente con todo lo que has visto en este nivel.
La distinción que más problemas ahorra es la de los árboles. La instalación global escribe en directorios del sistema; con --local se escribe en el árbol del usuario; y luarocks init prepara un árbol dentro del propio proyecto, junto con un script que ajusta las rutas antes de arrancar. La tercera opción es la que hace reproducible un proyecto, y es la equivalente funcional de un entorno virtual, con la diferencia de que aquí no hay magia: son dos cadenas de plantillas apuntando a un directorio del repositorio.
Merece la pena mirar de frente lo que este sistema no tiene, porque explica la forma del ecosistema entero. El nombre de un módulo no lleva versión. package.loaded está indexado por una cadena desnuda, y por tanto dos versiones distintas del mismo módulo no pueden coexistir en un estado de Lua: la primera que se cargue ocupa el nombre y todas las demás peticiones reciben esa. No existe nada parecido al árbol anidado de Node, donde cada dependencia puede llevar su propia copia de una dependencia común; no existe la unificación semántica de Cargo; no existe siquiera un lugar donde declarar qué versión necesitas, porque require recibe una cadena y no una restricción. LuaRocks resuelve versiones en el momento de la instalación, decidiendo qué ficheros acaban en el árbol, y a partir de ahí desaparece: en tiempo de ejecución solo quedan plantillas de ruta y la regla de primera coincidencia. La consecuencia sociológica es visible en el ecosistema y suele malinterpretarse como inmadurez: las bibliotecas de Lua son pequeñas, tienen pocas dependencias y rompen la compatibilidad con enorme reticencia, no por virtud sino porque el infierno de dependencias no puede resolverse en tiempo de ejecución y todo el mundo lo sabe. La lectura profunda es la misma que cierra el nivel 1. Lua no proporciona un gestor de paquetes por la misma razón que no proporciona hilos, ni sockets, ni un sistema de objetos: porque su unidad de despliegue no es la aplicación sino el intérprete empotrado dentro de otro programa, y ese programa ya tiene su propio gestor de paquetes, sus propias reglas de versionado y su propia idea de dónde vive el código. Un gestor en el núcleo del lenguaje habría sido una imposición sobre el anfitrión, y el anfitrión es quien manda.
- Escribe un módulo con un contador, guárdalo en un local desde dos ficheros, recárgalo borrando el caché y demuestra que uno de los dos sigue viendo la versión antigua.
- Haz que ese módulo defina un tipo con metatabla. Crea una instancia, recarga y comprueba que la verificación de tipo por identidad falla.
- Implementa la recarga en sitio y explica qué caso de los cuatro arregla y cuáles no. Añade protección para que un error de sintaxis no deje el caché vacío.
- Carga un fichero con
loadfiley un entorno mínimo. Verifica que no puede tocarosniio, y después dale una función de importación con caché privado. - Prepara un proyecto con
luarocks init, instala una dependencia en el árbol local e imprimepackage.pathdesde dentro del programa para ver exactamente qué plantillas se añadieron.