wandres.dev
TABLAS I · el único tipo compuesto

Una estructura para todo

La tabla como único tipo compuesto de Lua: array, diccionario, objeto, módulo, conjunto y espacio de nombres a la vez. Su anatomía interna de parte secuencia y parte hash, y la razón de ingeniería por la que Lua renunció a tener más de una estructura de datos.

⏱ 13 min

Casi todos los lenguajes reparten un catálogo de estructuras: listas, tuplas, diccionarios, conjuntos, registros, clases, espacios de nombres. Lua reparte una. La tabla es el único tipo compuesto del lenguaje, y no por carencia: es una decisión de diseño que atraviesa el intérprete entero, desde la representación en memoria hasta la semántica de los módulos y del propio entorno global. Entender que array, objeto, módulo y conjunto no son cuatro cosas distintas sino cuatro maneras de usar la misma cosa es el salto conceptual que separa a quien escribe Lua de quien lo traduce desde otro lenguaje.

🎯 Al terminar esta lección sabrás
  • Reconocer los seis papeles que una sola tabla desempeña en un programa Lua.
  • Entender la anatomía interna: parte secuencia y parte hash en un mismo objeto.
  • Situar la tabla como base de los módulos, del entorno global y de los objetos.
  • Justificar por qué un lenguaje pensado para ser embebido elige un único tipo compuesto.

Seis papeles, un solo tipo

Lo que sigue son seis usos que en otros lenguajes exigirían seis tipos distintos. En Lua son el mismo valor, creado con el mismo constructor y consultado con el mismo operador de indexación:

-- 1. Array (o secuencia): claves enteras consecutivas desde 1
local dias = { "lunes", "martes", "miercoles" }

-- 2. Diccionario: claves de cadena, valores arbitrarios
local config = { ancho = 80, alto = 24, tema = "oscuro" }

-- 3. Objeto: datos y funciones que operan sobre esos datos
local contador = { n = 0 }
function contador:incr() self.n = self.n + 1 end

-- 4. Módulo: una tabla que se devuelve al final del archivo
local M = {}
function M.saluda(quien) return "hola " .. quien end
return M

-- 5. Conjunto: las claves son los elementos, el valor es solo un testigo
local vocales = { a = true, e = true, i = true, o = true, u = true }

-- 6. Espacio de nombres: el propio entorno global es una tabla
_G.print == print  --> true

La consulta a un campo inexistente no es un error: devuelve el valor ausente. Eso hace que preguntar por algo que no está sea barato y silencioso, y explica por qué en Lua los registros no necesitan declararse ni los objetos anunciar sus campos.

local t = { a = 1 }
print(t.a, t.b, t[1])   --> 1   nil   nil
t.b = 2                 -- crear un campo es asignarlo, no declararlo

No hay conversión entre estos usos porque no hay nada que convertir. Una misma tabla puede ser simultáneamente array y diccionario: t[1] y t.nombre conviven sin fricción porque, para Lua, t.nombre es puro azúcar de t["nombre"]. La distinción array frente a diccionario no existe en el tipo; existe únicamente en tu cabeza y en la disciplina con la que uses las claves.

ℹ️
La indexación es un solo operador

En Lua solo hay una manera de acceder a un campo: t[k], donde k es cualquier valor. La sintaxis con punto, t.k, es azúcar sintáctico para t["k"], y la sintaxis con dos puntos, obj:m(x), es azúcar para obj.m(obj, x). Toda la aparente riqueza de acceso a estructuras, campos, métodos y módulos se reduce a la misma operación primitiva.

Por dentro: dos mitades en un mismo objeto

La implementación de referencia no guarda todas las claves de la misma forma. Cada tabla contiene dos almacenes cooperando bajo una única fachada:

flowchart TD
T[Una tabla de Lua] --> S[Parte secuencia]
T --> H[Parte hash]
T --> M[Metatabla opcional]
S --> S1[Claves enteras densas desde 1]
S --> S2[Acceso por indice directo]
H --> H1[Cadenas booleanos flotantes funciones tablas]
H --> H2[Acceso por dispersion con encadenamiento]
M --> M1[Comportamiento ante busquedas fallidas]

La parte secuencia es un vector de C indexado por posición: si las claves enteras son densas y empiezan en 1, el acceso es aritmética de punteros, sin dispersión ni comparación de claves. La parte hash es una tabla de dispersión con encadenamiento interno para todo lo demás. Lua decide en tiempo de ejecución, al redimensionar, cuántas claves enteras merecen migrar al vector; el criterio es que más de la mitad de las posiciones del vector queden ocupadas.

Esta arquitectura tiene dos consecuencias que se notan en programas reales. La primera es que rellenar una tabla en orden creciente desde el índice 1 es sensiblemente más rápido que rellenarla en orden aleatorio o desde índices altos, porque en el primer caso el redimensionado migra las claves al vector y en el segundo se quedan en la parte dispersa pagando una función de dispersión por acceso. La segunda es que el orden en que pairs visita las claves no es el orden de inserción ni ningún otro orden estable: es el orden físico de los dos almacenes, y puede cambiar entre ejecuciones o entre versiones del intérprete. Si tu programa necesita un orden, debes imponerlo tú con una secuencia auxiliar de claves ordenadas.

🗃️

Un tipo, cero conversiones

No existe una operación para convertir una lista en un diccionario porque no existen dos tipos que convertir. Añadir una clave de cadena a un array es simplemente una asignación más.

Coste amortizado

El vector interno da a las secuencias un rendimiento comparable al de un array nativo, sin que el programador elija nunca la representación. La heurística vive en el intérprete.

🧩

Composición en lugar de jerarquía

Clases, herencia, mixins, módulos y espacios de nombres se construyen encima de la tabla con unas pocas líneas. El lenguaje no fija la política, solo el mecanismo.

🌍

El entorno también es tabla

Las variables globales viven en una tabla accesible desde el propio programa. Leer una global es indexar una tabla, y eso hace posible el sandboxing sin soporte especial.

La tabla debajo de todo lo demás

Los tres mecanismos que en otros lenguajes forman parte del núcleo sintáctico son, en Lua, meros usos de la tabla. No hay palabra reservada para ninguno de ellos:

-- Objetos: los metodos viven en una tabla que hace de clase
local Punto = {}
Punto.__index = Punto

function Punto.nuevo(x, y)
  return setmetatable({ x = x, y = y }, Punto)
end

function Punto:norma()
  return math.sqrt(self.x ^ 2 + self.y ^ 2)
end

-- Modulos: require devuelve la tabla que el archivo retorno, y la cachea
local geo = require("geometria")
print(package.loaded["geometria"] == geo)   --> true

-- Entorno: las globales viven en una tabla que el programa puede sustituir
local seguro = { print = print, pairs = pairs, tostring = tostring }
-- ejecutar codigo no confiable con "seguro" como entorno lo aisla del resto

Merece la pena detenerse en la tercera. En casi todos los lenguajes, aislar código ajeno exige un intérprete separado, un proceso, un contenedor o un mecanismo de permisos empotrado en el runtime. En Lua basta con darle al fragmento sospechoso una tabla distinta como entorno global: si esa tabla no contiene os ni io, el código simplemente no tiene manera de nombrar el sistema de archivos. El sandbox no es una característica del lenguaje; es un corolario de que las globales viven en una tabla ordinaria.

💡
El operador de longitud no cuenta, delimita

Sobre una secuencia completa, #t devuelve el número de elementos. Sobre una tabla con claves enteras discontinuas devuelve alguna frontera válida, es decir, algún índice cuyo sucesor esté ausente, y la que devuelva depende de la disposición interna. Y sobre una tabla usada como diccionario devuelve cero, porque no hay parte secuencia que delimitar. Es la primera consecuencia observable de que el tipo no distinga array de registro.

La economía de una sola decisión

Un solo tipo compuesto no es simplicidad estética: es presupuesto de implementación

La tabla única es la consecuencia más visible de una restricción que Lua se autoimpuso desde su primera versión: el intérprete debe caber, entero, dentro del programa que lo hospeda —un motor de juego, un router, un servidor web, tu editor— sin imponerle un runtime pesado ni un modelo de objetos ajeno. Cada tipo compuesto adicional cuesta código de C, cuesta reglas nuevas en el recolector de basura, cuesta metamétodos, cuesta entradas en la API C y, sobre todo, cuesta manual: el lector tiene que aprender cuándo usar una lista y cuándo una tupla, y luego cómo convertir entre ambas. Lua eligió pagar ese coste una sola vez y hacerlo bien: una estructura híbrida cuya mitad vectorial le da a las secuencias el rendimiento de un array de C y cuya mitad dispersa le da a los registros el de una tabla hash, con una heurística de rebalanceo que el programador nunca ve. A cambio, todo lo demás se vuelve composición. La herencia es una tabla que apunta a otra mediante un campo del metaprotocolo. Un módulo es una tabla que un archivo devuelve. Un conjunto es una tabla donde los valores no importan. Un sandbox es una tabla que sustituye al entorno global. Una caché es una tabla cuya metatabla fabrica lo que falta. El resultado es un lenguaje que cabe en la cabeza y en unos cientos de kilobytes, pero también un lenguaje que traslada al programador una responsabilidad real: como el tipo no distingue array de diccionario, tampoco te protege de mezclarlos por accidente, y la coherencia de tus estructuras deja de ser una garantía del sistema para pasar a ser una convención tuya. Ese es el intercambio exacto de Lua: máxima expresividad por unidad de complejidad del intérprete, a cambio de disciplina en quien escribe.

Hay una consecuencia práctica de todo esto que conviene aceptar pronto: en Lua el diseño de datos es diseño de convenciones. Ningún tipo te va a impedir mezclar una secuencia con campos con nombre, ni te va a avisar de que un módulo acabó recibiendo una clave numérica, ni va a distinguir un conjunto de un diccionario. Lo que en otros lenguajes sostiene el compilador, aquí lo sostienen la disciplina del equipo y la claridad de los nombres.

Conviene fijar el vocabulario que usaremos en el resto del nivel. Una secuencia es una tabla cuyas claves son exactamente los enteros de 1 a n sin ausencias. Un registro es una tabla usada con claves de cadena fijas y conocidas. Un agujero es un nil intercalado entre claves enteras, y basta uno para que la tabla deje de ser secuencia y para que buena parte de la biblioteca estándar pase a tener comportamiento indefinido sobre ella.

💡
Una tabla vacía siempre es verdadera

Un error clásico al llegar desde otros lenguajes: if t then no comprueba si la tabla tiene contenido, sino si la variable contiene una tabla. En Lua solo nil y false son falsos, de modo que una tabla vacía entra en la rama verdadera. Para preguntar por el contenido de una secuencia, usa #t == 0; para una tabla usada como diccionario, next(t) == nil.

⚔️ Ver los seis papeles en una sola sesión
  1. Crea una tabla que sea a la vez array y diccionario, con tres elementos numerados y dos campos con nombre, y comprueba qué devuelve el operador # sobre ella.
  2. Escribe un archivo que construya una tabla, le añada dos funciones y la devuelva; cárgalo con require y observa que el módulo no es más que esa tabla.
  3. Construye un conjunto con claves de cadena y valor true, y comprueba la pertenencia con una sola indexación en lugar de recorrer nada.
  4. Imprime el tipo de _G y localiza dentro de esa tabla una función que uses a diario: acabas de confirmar que el espacio global es una tabla ordinaria.