wandres.dev
LUA · el lenguaje del editor

Tablas: array y diccionario a la vez

La única estructura compuesta de Lua por dentro: la parte array y la parte hash de LuaJIT, el operador de longitud y su borde indefinido, pairs frente a ipairs, y el arsenal vim.tbl_ de Neovim con vim.iter como sucesor moderno.

⏱ 19 min

Lua tiene exactamente una estructura de datos compuesta, y con ella construye todo lo demás: listas, diccionarios, objetos, módulos, espacios de nombres y hasta el propio entorno global. Entender cómo está hecha una tabla por dentro —y dónde deja de comportarse como esperas— es la diferencia entre una configuración que funciona y una que funciona siempre.

🎯 Al terminar esta lección sabrás
  • Describir la parte array y la parte hash de una tabla en LuaJIT y su efecto en el rendimiento.
  • Explicar por qué el operador de longitud es indefinido cuando hay huecos.
  • Elegir con criterio entre pairs, ipairs y vim.iter para recorrer datos.
  • Usar vim.tbl_deep_extend y compañía sabiendo exactamente qué hacen con las listas.

Una tabla, dos almacenes

Una tabla es un mapa de claves a valores donde la clave puede ser cualquier valor salvo nil y NaN. Pero por dentro, LuaJIT la parte en dos zonas: un vector contiguo para las claves enteras que van de 1 hacia arriba, y una tabla hash para todo lo demás. El constructor decide en cuál cae cada elemento.

local lista = { "uno", "dos", "tres" }        -- parte array: 1, 2, 3
local dicc  = { nombre = "nvim", version = 12 } -- parte hash
local mixta = { "uno", nombre = "nvim" }      -- las dos a la vez

print(lista[1])      --> uno       indice 1, no 0
print(dicc.nombre)   --> nvim      azucar de dicc["nombre"]
print(dicc["ver" .. "sion"])  --> 12   la clave se calcula

La parte array es memoria contigua sin claves almacenadas: indexar es aritmética de punteros y recorrerla es amistoso con la caché. La parte hash guarda pares clave-valor y resuelve colisiones con encadenamiento interno. Por eso una lista de mil elementos con claves 1..1000 es órdenes de magnitud más rápida que la misma información con claves "1".."1000".

flowchart LR
T[Una tabla de Lua] --> A[Parte array]
T --> H[Parte hash]
A --> A1[Claves enteras densas desde 1]
A --> A2[Memoria contigua y sin claves]
H --> H1[Cadenas booleanos tablas funciones]
H --> H2[Enteros dispersos o negativos]
style A fill:#a6e3a1,color:#11111b
style H fill:#cba6f7,color:#11111b

Cuando la tabla crece, LuaJIT rehashea y redimensiona ambas zonas eligiendo el tamaño de array que deje al menos la mitad de las ranuras ocupadas. Insertar mil elementos uno a uno provoca varios rehasheos; construir la tabla de golpe con un constructor, ninguno. En bucles calientes, esa diferencia es medible.

El operador de longitud y su borde

#t no cuenta elementos: devuelve un borde, es decir, un índice n tal que t[n] no es nil y t[n+1] sí lo es. Si la secuencia tiene huecos, hay varios bordes válidos y el lenguaje puede devolver cualquiera de ellos. No es un fallo de implementación: la especificación lo declara indefinido.

local t = { 1, 2, 3 }
t[5] = 5
print(#t)     --> 3 o 5, ambos son respuestas legitimas

De ahí la regla operativa: una lista no debe tener huecos. Para borrar del centro usa table.remove, que desplaza el resto; nunca asignes nil a mitad de una lista que vayas a recorrer con ipairs o medir con #.

table.insert(lista, "cuatro")        -- al final
table.insert(lista, 1, "cero")       -- en posicion, desplaza
table.remove(lista, 2)               -- borra y compacta
print(table.concat(lista, ", "))     -- une con separador
table.sort(lista, function(a, b) return #a < #b end)

Los dos iteradores no son intercambiables. ipairs recorre 1, 2, 3, ... y se detiene en el primer nil; ignora por completo la parte hash. pairs recorre todas las claves, pero sin orden garantizado: el orden depende del hash interno y puede cambiar entre ejecuciones.

⚠️
Nunca dependas del orden de `pairs`

Si iteras con pairs para construir una cadena de configuración o una lista de plugins, el resultado puede variar entre arranques. Cuando el orden importe, recoge las claves con vim.tbl_keys, ordénalas con table.sort y recorre esa lista.

El arsenal vim.tbl_ y el relevo de vim.iter

Neovim añade una capa de utilidades porque la biblioteca estándar de 5.1 es deliberadamente escueta. Estas son las que aparecen a diario en cualquier configuración seria:

🔎

Consultar

vim.tbl_isempty, vim.tbl_count, vim.tbl_contains, vim.tbl_keys, vim.tbl_values y vim.tbl_get para leer rutas anidadas sin encadenar comprobaciones.

🧬

Transformar

vim.tbl_map y vim.tbl_filter sobre listas, vim.deepcopy para clonar de verdad, y vim.list_extend o vim.list_slice para trabajar por rangos.

🌙

Fusionar

vim.tbl_extend y vim.tbl_deep_extend, el mecanismo con el que toda distribución mezcla tus opciones con las suyas.

local cfg = { ui = { border = "rounded" }, hosts = { "a", "b" } }

print(vim.tbl_get(cfg, "ui", "border"))   --> rounded, y nil si falta un tramo
print(vim.tbl_count(cfg))                 --> 2, cuenta claves de cualquier tipo
print(vim.tbl_contains(cfg.hosts, "b"))   --> true

local largos = vim.tbl_map(function(h) return h .. ".local" end, cfg.hosts)
local copia  = vim.deepcopy(cfg)          -- copia recursiva, no comparte tablas

La distinción entre lista y diccionario, tan borrosa en Lua, se consulta con vim.islist —comprueba claves consecutivas desde 1— y con vim.isarray, que tolera huecos. Ambas sustituyeron a las antiguas vim.tbl_islist y vim.tbl_isarray, igual que vim.tbl_flatten cedió el puesto a vim.iter.

vim.iter es la apuesta moderna: envuelve una tabla o una función iteradora y expone un objeto encadenable y perezoso, donde nada se materializa hasta que llamas a totable o fold.

local nombres = vim.iter(vim.api.nvim_list_bufs())
  :filter(function(b) return vim.bo[b].buflisted end)
  :map(function(b) return vim.api.nvim_buf_get_name(b) end)
  :filter(function(n) return n ~= "" end)
  :totable()

local total = vim.iter({ { 1, 2 }, { 3 } }):flatten():fold(0, function(acc, n)
  return acc + n
end)

Fusionar configuraciones sin sorpresas

vim.tbl_extend(comportamiento, ...) mezcla tablas a un solo nivel; vim.tbl_deep_extend desciende recursivamente. El primer argumento decide qué pasa ante una clave repetida: "force" deja ganar a la última tabla, "keep" a la primera y "error" aborta.

local defecto = { ui = { border = "single", ancho = 80 }, srv = { "lua_ls" } }
local usuario = { ui = { border = "rounded" },            srv = { "clangd" } }

local plano = vim.tbl_extend("force", defecto, usuario)
-- plano.ui = { border = "rounded" }   se perdio ancho: solo un nivel

local hondo = vim.tbl_deep_extend("force", defecto, usuario)
-- hondo.ui = { border = "rounded", ancho = 80 }   se fusiono
-- hondo.srv = { "clangd" }                        la LISTA se reemplaza entera

Esa última línea es el detalle que arruina tardes enteras: la fusión profunda recorre diccionarios, pero al encontrar dos listas no las concatena, las sustituye. Si quieres añadir a una lista existente, vim.list_extend es la herramienta correcta, y ese es exactamente el motivo por el que los gestores de plugins ofrecen la forma de función opts = function(_, opts) ... end en lugar de una tabla plana.

Una sola estructura, y por eso la conoces entera

Detente en la magnitud de la decisión: Lua no tiene listas, ni tuplas, ni diccionarios, ni conjuntos, ni registros, ni clases. Tiene tablas. Un módulo es una tabla que devuelves; un objeto es una tabla con metatabla; un espacio de nombres es una tabla anidada; el entorno global es la tabla _G; vim.opt, vim.bo y vim.g son tablas con comportamiento inyectado. El coste de esa unificación lo has visto en esta lección: el operador de longitud tiene un borde indefinido, pairs no garantiza orden, y distinguir una lista de un diccionario exige una heurística en vez de un tipo. Pero mira lo que compras a cambio. Una única implementación bien optimizada —el par array más hash— cubre todos los casos de uso, así que el motor entero es minúsculo y su rendimiento, uniforme y predecible. Una única API que aprendes una vez y aplicas en todas partes: next, la indexación y las metatablas bastan para razonar sobre cualquier dato del sistema. Y una única representación al cruzar la frontera hacia la API de Neovim, donde una tabla se convierte en lista o en diccionario de MessagePack según su forma. Cuando escribas require("plugin").setup({ ... }) recuerda que esa llave abre el mismo objeto que sostiene el lenguaje entero: si dominas la tabla, dominas Lua, porque en Lua no hay nada más que dominar.

⚔️ Pon a prueba la única estructura
  1. Construye una tabla con un hueco deliberado y explica por qué #t puede devolver dos valores distintos igual de correctos.
  2. Escribe una función que reciba un diccionario y devuelva sus claves ordenadas alfabéticamente, sin depender del orden de pairs.
  3. Reescribe un bucle for de tu configuración con una cadena de vim.iter y razona qué gana en claridad y qué en trabajo diferido.
  4. Demuestra con un ejemplo mínimo que vim.tbl_deep_extend reemplaza listas en vez de concatenarlas, y arréglalo con vim.list_extend.
  5. Justifica por qué vim.deepcopy es imprescindible antes de mutar una tabla de opciones que te ha entregado un plugin.