wandres.dev
TABLAS I · el único tipo compuesto

Idiomas fundamentales con tablas

Los cuatro patrones que aparecen en todo programa Lua serio: la tabla como conjunto con pertenencia en tiempo constante, la caché con valor por defecto, la cola con dos índices que no desplaza nada, y el registro de funciones que sustituye a la cadena de condicionales.

⏱ 15 min

Un lenguaje con un único tipo compuesto no tiene bibliotecas de estructuras de datos: tiene idiomas. Un idioma es un patrón de uso de la tabla lo bastante estable como para haberse convertido en vocabulario compartido de la comunidad, reconocible a simple vista y con un coste conocido. Los cuatro que siguen —conjunto, caché, cola y registro de funciones— cubren la inmensa mayoría de las necesidades estructurales de un programa Lua, se escriben en menos de diez líneas cada uno y son la mejor demostración de que la pequeñez del lenguaje no limita lo que puedes construir, solo traslada quién lo construye.

🎯 Al terminar esta lección sabrás
  • Implementar un conjunto con pertenencia y operaciones básicas en tiempo constante.
  • Construir una caché que fabrique su valor por defecto en la primera consulta.
  • Escribir una cola con dos índices que no desplace elementos al desencolar.
  • Sustituir cadenas de condicionales por un registro de funciones indexado.

El conjunto: las claves son los elementos

La observación de partida es que una tabla ya es un conjunto de claves. Basta ignorar los valores y usarlos como testigo de presencia para obtener pertenencia en tiempo constante, sin recorrer nada:

local function conjunto(lista)
  local s = {}
  for _, v in ipairs(lista) do s[v] = true end
  return s
end

local reservadas = conjunto{ "if", "then", "else", "end", "local" }

if reservadas["local"] then print("es palabra reservada") end
reservadas["goto"] = true    -- anadir
reservadas["if"] = nil       -- quitar: borrar la clave es quitar del conjunto

Las operaciones clásicas caben en una línea cada una. La unión recorre los dos conjuntos y marca; la intersección recorre el menor y comprueba en el otro; la diferencia recorre el primero y descarta lo que esté en el segundo. Todas son lineales en el tamaño del conjunto recorrido y ninguna necesita ordenar.

💡
Guarda algo útil en el valor en lugar de un testigo

La convención de poner un valor verdadero como testigo funciona, pero desaprovecha la mitad de la estructura. Si en lugar de un testigo guardas el índice original, el contador de apariciones o la posición en el archivo fuente, obtienes gratis un multiconjunto o un índice invertido sin cambiar ni una línea de la lógica de pertenencia. Lo único que debes respetar es que el valor nunca sea ausente, porque eso equivaldría a borrar el elemento.

La caché con valor por defecto

El segundo idioma resuelve un problema recurrente: consultar una clave que quizá no exista todavía y querer que se fabrique sola. La versión explícita se escribe con una comprobación; la versión elegante delega en la metatabla, cuyo campo de búsqueda fallida recibe la tabla y la clave ausente y puede crear el valor en ese momento.

-- Version explicita: valida en cualquier version de Lua
local function obtener(cache, k)
  local v = cache[k]
  if v == nil then v = calcular(k); cache[k] = v end
  return v
end

-- Version con metatabla: la tabla se rellena sola al consultarla
local memo = setmetatable({}, {
  __index = function(t, k)
    local v = calcular(k)
    t[k] = v          -- se guarda: la proxima consulta ya no entra aqui
    return v
  end,
})

print(memo[10])   -- calcula y almacena
print(memo[10])   -- lectura directa: la busqueda ya no falla

Ese guardado dentro de la propia función es lo que convierte el patrón en una caché real y no en un simple valor por defecto: la búsqueda fallida ocurre una sola vez por clave. La misma idea, con una tabla vacía como valor fabricado, produce el idioma del diccionario anidado automático, donde puedes escribir en un nivel profundo sin crear a mano cada nivel intermedio.

flowchart TD
Q[Consulta de una clave] --> H[Busqueda directa en la tabla]
H --> R[Existe y se devuelve el valor]
H --> M[No existe y hay metatabla con index]
M --> F[La funcion fabricante calcula el valor]
F --> G[Lo guarda en la tabla]
G --> R

La cola con dos índices

Desencolar con table.remove desde la primera posición cuesta lineal, porque compacta el resto. Para una cola con mucho tráfico eso es inaceptable. El idioma canónico mantiene dos índices y no mueve jamás un elemento: encolar avanza la cabeza, desencolar avanza la cola y borra la entrada consumida para que el recolector pueda liberarla.

local Cola = {}

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

function Cola.encolar(q, v)
  q.ultimo = q.ultimo + 1
  q[q.ultimo] = v
end

function Cola.desencolar(q)
  if q.primero > q.ultimo then return nil end
  local v = q[q.primero]
  q[q.primero] = nil          -- libera la referencia para el recolector
  q.primero = q.primero + 1
  return v
end

Ambas operaciones son de coste constante y la tabla nunca se compacta. Nótese que la estructura resultante no es una secuencia: sus claves enteras empiezan en un índice que va creciendo, de modo que el operador de longitud y ipairs no significan nada útil sobre ella. El número de elementos es la diferencia entre los dos índices, y eso es deliberado.

🗃️

Conjunto

Pertenencia en tiempo constante con una sola indexación. Añadir es asignar un valor, quitar es borrar la clave, y no hace falta ninguna estructura auxiliar.

🧠

Caché

La búsqueda fallida se convierte en un punto de extensión. Memorización, valores por defecto y diccionarios anidados automáticos son la misma línea con distinta función fabricante.

🚚

Cola

Dos índices y ningún desplazamiento. El precio es renunciar a que la estructura sea una secuencia; a cambio, encolar y desencolar cuestan lo mismo siempre.

🎛️

Registro

Una tabla que asocia nombres con funciones sustituye a la cadena de condicionales y convierte el conjunto de casos en un dato que se puede ampliar en caliente.

El registro de funciones

Como las funciones son valores de primera clase, una tabla puede asociarlas a nombres y sustituir por completo a una cadena de comparaciones. El patrón aparece en despachadores de eventos, en intérpretes de comandos, en gestores de estados y en la práctica totalidad de los complementos de editores escritos en Lua:

local acciones = {
  abrir  = function(arg) return "abriendo " .. arg end,
  cerrar = function(arg) return "cerrando " .. arg end,
  listar = function()    return "listado completo" end,
}

local function despachar(nombre, arg)
  local f = acciones[nombre]
  if not f then return nil, "accion desconocida: " .. nombre end
  return f(arg)
end

La ventaja decisiva no es la brevedad sino que el conjunto de casos deja de ser código y pasa a ser un dato: se puede recorrer para generar ayuda, comprobar antes de llamar, ampliar desde un complemento externo o filtrar por permisos, todo sin tocar el despachador. Es el mismo mecanismo con el que los módulos de la biblioteca estándar se exponen, y la razón por la que en Lua ampliar un programa rara vez exige modificarlo.

Los idiomas son la biblioteca estándar que Lua decidió no escribir

Estos cuatro patrones no aparecen en ningún manual como funciones que puedas importar, y sin embargo están en todos los programas Lua de tamaño apreciable, escritos casi con las mismas letras por gente que jamás se puso de acuerdo. Eso merece una lectura atenta, porque explica el sitio que ocupa Lua entre los lenguajes. Cuando un lenguaje incluye una clase para el conjunto, otra para la cola y un decorador para la memorización, está tomando por ti un conjunto de decisiones que casi siempre son razonables y ocasionalmente son un estorbo: el conjunto trae un método de igualdad que no puedes cambiar, la cola trae un bloqueo que no necesitas, la caché trae una política de expiración que no encaja con tu carga. Lua no toma ninguna de esas decisiones, y el resultado es que cada uno de estos idiomas es un fragmento de diez líneas que puedes leer entero, entender del todo y modificar en el sitio: tu conjunto pasa a contar apariciones cambiando un valor, tu caché pasa a expirar por tiempo añadiendo una marca, tu cola pasa a ser doblemente terminada añadiendo un índice. La contrapartida es igual de real y hay que nombrarla: no hay un conjunto canónico, así que en un proyecto grande conviven tres implementaciones ligeramente distintas escritas por tres personas, ninguna está probada por miles de usuarios y el nombre que le pongas al patrón no le dice nada a quien lea tu código si no comparte el vocabulario. Por eso los idiomas importan tanto en este lenguaje y por eso merecen aprenderse con su forma tradicional en lugar de reinventarlos: en Lua, la convención hace el trabajo que en otros lenguajes hace el sistema de tipos, y el vocabulario compartido es literalmente la biblioteca estándar que el diseño decidió no escribir.

⚔️ Construir el vocabulario
  1. Implementa unión, intersección y diferencia de conjuntos, y comprueba que ninguna necesita ordenar ni recorrer dos veces la misma tabla.
  2. Escribe una caché memorizadora para una función recursiva costosa y mide la diferencia de tiempo con y sin ella.
  3. Amplía la cola con una operación que devuelva el número de elementos y otra que consulte el primero sin extraerlo.
  4. Convierte una cadena de cinco condicionales en un registro de funciones y añade después un caso nuevo sin tocar el despachador.