Saltar al contenido

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, la caja de herramientas de table, y el arsenal vim.tbl_ de Neovim con vim.iter como sucesor moderno.

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.

  • 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.
  • Manejar la caja de herramientas de table: insert, remove, sort y concat, con sus trampas.
  • 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.

LUA
local lista = { "uno", "dos", "tres" }          -- parte array: 1, 2, 3local dicc  = { nombre = "nvim", version = 12 } -- parte hashlocal mixta = { "uno", nombre = "nvim" }        -- las dos a la vezprint(lista[1])      --> uno       indice 1, no 0print(dicc.nombre)   --> nvim      azucar de dicc["nombre"]print(dicc["ver" .. "sion"])  --> 12   la clave se calcula

La notación con punto y la notación con corchetes son la misma operación: t.clave es azúcar de t["clave"], válido cuando la clave es un identificador. Los corchetes hacen falta cuando la clave se calcula, contiene caracteres raros o no es una cadena: t[1], t[otra_tabla], t["con espacios"].

Parte array

Claves enteras densas desde 1. Memoria contigua sin claves almacenadas: indexar es aritmética de punteros y recorrerla es amistoso con la caché del procesador.

Parte hash

Cadenas, booleanos, tablas, funciones, y también los enteros dispersos, negativos o el cero. Guarda pares clave-valor y resuelve colisiones encadenando dentro de la propia tabla.

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": la primera vive en el vector, la segunda paga un hash por acceso. Cuando la tabla crece, LuaJIT rehashea: 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 de 5.1 lo declara indefinido, y el resultado concreto depende de en qué zona interna cayó cada clave.

LUA
local t = { 1, 2, 3 }t[5] = 5print(#t)     --> 3 o 5: ambos son bordes legitimos

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 #. Y si necesitas marcar «aquí no hay valor» sin romper la secuencia, guarda un centinela explícito —false, o la propia vim.NIL que conocerás en la 7.5— en vez de un agujero.

La caja de herramientas de table

La biblioteca table de 5.1 es corta y la usarás entera:

LUA
table.insert(lista, "cuatro")        -- al final: lista[#lista+1] = v es equivalentetable.insert(lista, 1, "cero")       -- en posicion, desplaza lo demastable.remove(lista)                  -- quita y devuelve el ultimotable.remove(lista, 2)               -- borra en posicion y compactatable.sort(lista)                    -- en el sitio, con el orden de <table.sort(lista, function(a, b) return #a < #b end)  -- o con comparadorprint(table.concat(lista, ", "))     -- une una lista de cadenas o numeros

Dos avisos que ahorran tardes. table.sort ordena en el sitio —muta la tabla, no devuelve una copia— y su comparador debe ser un orden estricto: significa «a va antes que b», y con elementos iguales debe devolver false. Un comparador con <= puede reventar con invalid order function for sorting en cuanto la lista crezca. Y table.concat es la otra mitad del consejo de la lección 7.1: como cada .. crea una cadena nueva, un bucle que concatena acumula copias cuadráticas; acumular los trozos en una lista y unirlos al final con concat es lineal.

LUA
local partes = {}for i = 1, 1000 do  partes[#partes + 1] = "linea " .. i    -- barato: mil cadenas cortasendlocal doc = table.concat(partes, "\n")   -- una sola union al final

pairs e ipairs 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 —array y hash—, pero sin orden garantizado: el orden depende del estado interno del hash y puede cambiar entre ejecuciones. Con la lista agujereada de antes, los tres mecanismos responden cosas distintas:

LUA
local h = { "a", "b", nil, "d" }-- ipairs: recorre 2 elementos (para en el hueco)-- pairs:  visita 3 claves (1, 2 y 4, en algun orden)-- #h:     puede decir 2 o 4

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. Es el idioma estándar para «diccionario en orden alfabético», y lo usarás en cualquier cosa que imprima configuración.

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.

LUA
local cfg = { ui = { border = "rounded" }, hosts = { "a", "b" } }print(vim.tbl_get(cfg, "ui", "border"))   --> rounded, y nil si falta un tramoprint(vim.tbl_count(cfg))                 --> 2, cuenta claves de cualquier tipoprint(vim.tbl_contains(cfg.hosts, "b"))   --> truelocal largos = vim.tbl_map(function(h) return h .. ".local" end, cfg.hosts)local copia  = vim.deepcopy(cfg)          -- copia recursiva, no comparte tablas

vim.deepcopy merece una frase más, porque su necesidad viene directa de la lección 7.1: asignar una tabla copia la referencia, así que «guardar» una tabla de opciones antes de mutarla no guarda nada. La copia profunda crea tablas nuevas en cada nivel, y es lo que quieres antes de tocar una tabla que te entregó un plugin —porque él conserva su referencia y verá tus mutaciones—.

La distinción entre lista y diccionario, tan borrosa en Lua, se consulta con vim.islist —claves consecutivas desde 1— y con vim.isarray, que tolera enteros no contiguos. Ambas sustituyeron a las antiguas vim.tbl_islist y vim.tbl_isarray, igual que vim.tbl_flatten cedió el puesto a vim.iter. Verás las viejas en configuraciones antiguas: no las copies.

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.

LUA
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 + nend)

Sobre una lista, la cadena itera como ipairs; sobre un diccionario, como pairs —con su orden no garantizado—, y en ese caso los callbacks reciben clave y valor. La familia sigue creciendo: 0.12 añadió Iter:unique() para deduplicar y dejó que take y skip acepten predicados, además del módulo vim.list con utilidades como vim.list.unique. No hace falta memorizarla entera; hace falta saber que, cuando encadenes dos transformaciones sobre una colección, este es el idioma que la documentación moderna espera.

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. Desde 0.12 también acepta una función (clave, valor_previo, valor_nuevo) que decide caso a caso, para cuando ninguna de las tres políticas encaja.

LUA
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 nivellocal 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, y no es un descuido sino una decisión documentada: la fusión profunda recorre diccionarios, pero trata las listas como valores opacos y las sustituye enteras. Tiene sentido —si el usuario da su lista de servidores, casi nunca quiere los suyos más los de defecto—, pero significa que «añadir un elemento» no se hace fusionando. Para eso está vim.list_extend, que concatena en el sitio; y esa asimetría es exactamente el motivo por el que los gestores de plugins ofrecen una forma de función para modificar opciones, en vez de dejarte solo una tabla que se fusionará.

LUA
-- Quiero los servidores por defecto Y el mio:local cfg = vim.deepcopy(defecto)vim.list_extend(cfg.srv, { "clangd" })   -- cfg.srv = { "lua_ls", "clangd" }

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, como verás en la 7.5. 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.

Drills

Anota lo que devuelve tu Neovim. Lo importante no es el número concreto: es que la especificación permite el otro, y que ningún código tuyo debe depender de cuál sale.

true false true. islist exige claves consecutivas desde 1; isarray acepta enteros con huecos. La forma de la tabla es lo único que hay: no existe un tipo lista.

alfa, media, zeta, siempre. tbl_keys recoge, table.sort ordena, y recorres la lista ordenada en vez del diccionario. El idioma completo contra el orden aleatorio de pairs. Ojo: no funcionaría sobre vim.g, que no es una tabla de verdad sino un proxy con metatabla —lo verás en la 7.3—.

Una lista de la API, un map y la materialización final con totable. La versión con bucle son cinco líneas; la cadena cuenta la misma historia en una.

Pon a prueba la única estructura

  1. Construye con :lua una tabla con un hueco deliberado, imprime #t, y explica por qué el valor que ves y el otro son igual de correctos.
  2. Escribe una función que reciba un diccionario y devuelva sus claves ordenadas alfabéticamente, y pruébala desde :lua sobre package.loaded, que es un diccionario de verdad.
  3. Demuestra en una sola línea de :lua que ipairs se detiene en un hueco donde pairs continúa, contando iteraciones de cada uno.
  4. Reproduce el ejemplo de defecto y usuario de esta lección, imprime hondo.srv con vim.print y comprueba que la lista fue reemplazada; arréglalo después con vim.deepcopy más vim.list_extend y verifica que ahora hay dos servidores.
  5. Demuestra por qué vim.deepcopy importa: guarda una tabla anidada en dos variables con = y muta una; luego repite con deepcopy y compara con vim.deep_equal qué cambió en cada caso.