wandres.dev
MÓDULOS · require por dentro

Escribir un módulo bien

El idioma de la tabla local devuelta al final, la disciplina de no ensuciar el entorno global, las dependencias declaradas en la cabecera con su ganancia real, y el problema serio de los módulos que guardan estado en un caché compartido por todo el proceso.

⏱ 17 min

Un módulo de Lua se escribe en cuatro decisiones, y las cuatro se toman antes de escribir la primera función útil. Qué tabla devuelves y cuándo la creas. Qué nombres declaras como locales y cuáles se te escapan al entorno global. Qué dependencias fijas en la cabecera y qué ganas realmente con ello. Y, la más consecuente de todas, si tu módulo guarda estado, porque el caché de require convierte ese estado en compartido por todo el proceso sin que ningún consumidor lo sepa. Las tres primeras son higiene; la cuarta es arquitectura.

🎯 Al terminar esta lección sabrás
  • Aplicar el idioma de la tabla local devuelta al final y justificar sus variantes.
  • Impedir que un módulo cree variables globales, y detectarlo cuando ocurra.
  • Declarar dependencias en la cabecera y medir qué aporta localizar funciones de biblioteca.
  • Decidir con criterio entre un módulo con estado y un módulo que fabrica instancias.

La tabla local y el return final

El esqueleto canónico tiene tres partes: cabecera de dependencias, cuerpo colgado de una tabla local y devolución de esa tabla en la última línea.

-- cola.lua
local setmetatable = setmetatable
local M = {}

local Cola = {}          -- tipo privado: no se exporta
Cola.__index = Cola

function Cola:meter(v)
  self.ultimo = self.ultimo + 1
  self[self.ultimo] = v
end

function Cola:sacar()
  if self.primero > self.ultimo then return nil end
  local v = self[self.primero]
  self[self.primero], self.primero = nil, self.primero + 1
  return v
end

function M.nueva()
  return setmetatable({ primero = 1, ultimo = 0 }, Cola)
end

return M

La tabla se crea al principio y se devuelve al final porque así las funciones pueden referirse unas a otras a través de ella durante la definición. La alternativa de construir una tabla literal en el return final es igual de válida cuando el módulo es pequeño, pero obliga a que todos los nombres existan antes y a repetirlos en el constructor.

Obsérvese que Cola no se exporta. Es una tabla local que sirve de metatabla a las instancias y que nadie de fuera puede alcanzar ni modificar. Esa es la forma correcta de tener un tipo privado: no hay palabra reservada de visibilidad, solo el ámbito léxico del chunk.

Cuando dos funciones locales se llaman mutuamente hace falta declararlas antes de definirlas, o la segunda no existirá cuando la primera se compile.

local par, impar                                    -- declaracion adelantada
function par(n)   return n == 0 or impar(n - 1) end
function impar(n) return n ~= 0 and par(n - 1) end

Nada de globales

Toda asignación a un nombre no declarado escribe en el entorno global, y en un módulo eso es siempre un fallo, aunque el programa funcione. Contamina un espacio compartido por todo el proceso, crea colisiones que aparecen mucho después y en otro fichero, y hace que el módulo dependa de haberse cargado antes que su consumidor.

-- Fallo silencioso: ayuda queda como variable global
function ayuda(x)
  return x * 2
end

La versión correcta añade una palabra: local function ayuda(x). La forma disciplinada de garantizarlo, sin embargo, no es la revisión visual, sino hacer que el intérprete se queje. Una metatabla sobre el entorno global convierte cualquier escritura o lectura accidental en un error con número de línea.

setmetatable(_G, {
  __newindex = function(_, k) error("global no declarada: " .. tostring(k), 2) end,
  __index = function(_, k) error("global inexistente: " .. tostring(k), 2) end,
})
💡
Lua 5.5 lo integra en el lenguaje

La rama 5.5 incorpora declaraciones explícitas de globales, de modo que el propio compilador rechaza el uso de un nombre global no declarado sin necesidad de metatablas ni de bibliotecas externas. Es la misma disciplina de la lección 9.4, ahora con diagnóstico en tiempo de compilación en lugar de en tiempo de ejecución.

Las dependencias en la cabecera

Las llamadas a require van arriba, antes de cualquier definición, y se guardan en locales con nombre. La razón principal no es el rendimiento, sino que la cabecera se convierte en la declaración legible de lo que este fichero necesita.

local fmt      = string.format
local concat   = table.concat
local registro = require("proyecto.registro")
local config   = require("proyecto.config")

La ganancia de rendimiento existe y conviene entenderla sin exagerarla. Leer string.format en tiempo de ejecución cuesta dos accesos indexados: uno al entorno global para obtener string y otro a esa tabla para obtener el campo. Leer un local es leer un registro de la máquina virtual. En un bucle apretado la diferencia se mide; en el resto del programa es ruido, y localizarlo todo por sistema entorpece la lectura sin devolver nada.

Hay además un efecto que sí es cualitativo: al fijar la referencia en la cabecera, el módulo queda inmune a que alguien reemplace después string.format o el propio módulo importado. Eso es una ventaja cuando quieres estabilidad y un estorbo cuando querías precisamente sustituirlo en las pruebas.

La excepción a la regla de la cabecera es el ciclo. Si dos módulos se necesitan mutuamente, mover uno de los dos require al interior de la función que lo usa rompe la recursión de carga, porque en ese momento el otro módulo ya estará completo en el caché. Es un parche legítimo y conviene comentarlo como tal.

flowchart TB
A[cabecera con locales y require] --> B[tabla local del modulo]
B --> C[tipos y funciones privadas]
C --> D[funciones publicas colgadas de la tabla]
D --> E[return de la tabla]
B -.->|nunca se exportan| C
E -->|el cache guarda este valor| F[todos los consumidores comparten la misma tabla]

Módulos con estado

Aquí está la decisión de diseño de verdad. Como el caché entrega el mismo valor a todo el que lo pida, cualquier dato mutable que guardes en la tabla del módulo es estado global de proceso con un envoltorio más presentable.

-- contador.lua
local M = { n = 0 }

function M.incr()
  M.n = M.n + 1
end

return M

Dos partes distintas del programa que pidan contador compartirán la variable sin haberlo acordado. A veces eso es exactamente lo que quieres: una configuración leída una vez, un registro de sucesos, un contenedor de servicios, una conexión única. En esos casos el singleton es la intención y no hay nada que corregir.

El problema aparece cuando el estado era incidental. Un caché interno, un contador de identificadores, una lista de suscriptores: cosas que parecían detalles internos y que resultan ser compartidas por todo el proceso, imposibles de aislar en las pruebas e imposibles de tener por duplicado.

La alternativa es que el módulo no guarde nada y se limite a fabricar instancias.

-- registro.lua
local M = {}
local Registro = {}
Registro.__index = Registro

function Registro:anotar(msg)
  self.n = self.n + 1
  self.salida:write(self.prefijo, msg, "\n")
end

function M.nuevo(salida, prefijo)
  return setmetatable({ salida = salida, prefijo = prefijo or "", n = 0 }, Registro)
end

return M

Ahora el módulo es una fábrica sin memoria, cada consumidor tiene su instancia y las pruebas pueden crear una con una salida falsa. La regla práctica es simple de enunciar: estado en el módulo solo cuando la unicidad forme parte del contrato, y en ese caso escríbelo en la documentación, porque no hay nada en el código que lo anuncie.

Un módulo es una interfaz que nadie verifica

En Lua, la tabla que devuelves es a la vez tu implementación y tu interfaz publicada, y no existe ninguna distinción entre ambas cosas. No hay export, no hay modificadores de visibilidad, no hay fichero de cabecera, no hay firma que un compilador contraste. Lo que está en la tabla es público por el hecho de estar; lo que está en un local es privado por el hecho de no estar. Esa binariedad es una virtud y una trampa a partes iguales. Es virtud porque el criterio es visible de un vistazo: la superficie de tu módulo es literalmente la lista de campos de la tabla que devuelves, y mantenerla pequeña es una tarea mecánica que no requiere ceremonia. Es trampa porque un consumidor puede escribir en esa tabla, sustituir cualquiera de tus funciones, añadir campos propios o leer los que tú considerabas de implementación —y, gracias al caché, ese cambio será visto por todos los demás consumidores del proceso, incluida la parte de tu módulo que use la tabla internamente—. El monkey patching no es una técnica avanzada en Lua: es el comportamiento por defecto de un sistema donde el módulo es una tabla ordinaria alcanzable por todos. La consecuencia práctica para quien diseña con seriedad es doble. Primero, tratar la tabla devuelta como una frontera y no usarla como espacio de trabajo interno: las funciones deben llamarse entre sí a través de locales, de modo que un consumidor que reemplace un campo público no altere el funcionamiento interno sin querer. Segundo, aceptar que la única protección real que ofrece el lenguaje es el ámbito léxico del chunk, y que por tanto la decisión de qué es privado se toma en el instante de escribir local, no después. Todo lo demás —congelar la tabla con __newindex, devolver una vista de solo lectura, ocultar el estado en closures— son refinamientos que construyes tú sobre esa única base.

⚔️ Escribe un módulo defendible
  1. Convierte un script tuyo con globales en un módulo con tabla local, tipo privado y return final, sin cambiar su comportamiento.
  2. Instala la metatabla estricta sobre el entorno global al principio del programa y corrige todo lo que salte hasta que arranque limpio.
  3. Mide con os.clock un bucle de diez millones de iteraciones que use string.format global frente a la versión localizada en la cabecera. Anota la diferencia y decide si te importa.
  4. Escribe un módulo con un contador interno, pídelo desde dos ficheros distintos y demuestra que comparten la variable. Reescríbelo como fábrica de instancias.
  5. Desde un tercer fichero, sustituye una función pública de tu módulo y comprueba si el resto del módulo se ve afectado. Después reorganízalo con llamadas internas por local y repite la prueba.