Funciones y closures: valores, varargs y pcall
Funciones como valores de primera clase, upvalues compartidos entre closures, el manejo correcto de varargs y los nil intermedios, pcall, xpcall y assert para que un error no tumbe el editor, y las metatablas que sostienen vim.opt, vim.fn y la orientación a objetos de Lua.
Una configuración de Neovim es, en el fondo, un montón de funciones esperando a que pase algo: un autocmd que reacciona a un evento, un keymap con callback, un handler del LSP, un temporizador de libuv. Todas se ejecutan dentro del proceso del editor, así que una función mal escrita no lanza una excepción bonita: te deja el editor a medio configurar. Esta lección va de escribir funciones que capturan lo que deben y fallan como es debido.
- Explicar qué es un upvalue y cómo dos closures comparten estado.
- Manejar varargs contando argumentos con
selecten vez de con el operador de longitud. - Blindar código frágil con
pcall,xpcallyassert, distinguiendo error de valor de retorno. - Leer una metatabla y reconocer los mecanismos que hay bajo
vim.opt,vim.fnyvim.g.
Closures: funciones que recuerdan
Una función que referencia una variable local del ámbito exterior no copia su valor: captura la variable misma. Esa variable capturada se llama upvalue, y sobrevive al bloque que la creó porque el recolector de basura la mantiene viva mientras la función exista. Es la continuación natural del alcance léxico de la lección 7.1: la variable vive donde se declaró, pero su vida se alarga hasta que nadie la necesite.
local function crear_contador() local n = 0 -- upvalue capturado abajo return function() n = n + 1 return n endendlocal a, b = crear_contador(), crear_contador()print(a(), a(), b()) --> 1 2 1 estados independientesCada llamada a la fábrica crea un n nuevo, así que a y b no se ven. Pero dos closures creadas en el mismo ámbito comparten el mismo upvalue: si una fábrica devuelve dos funciones que leen y escriben la misma local, ambas operan sobre el mismo estado. Con esas dos reglas ya tienes encapsulación sin objetos ni clases: el estado es invisible desde fuera —no hay tabla que inspeccionar, no hay clave que pisar— y solo las funciones que nacieron dentro pueden tocarlo.
local function crear_par() local n = 0 local function incrementar() n = n + 1 end local function leer() return n end return incrementar, leer -- comparten el MISMO nendlocal inc, leer = crear_par()inc(); inc()print(leer()) --> 2El caso que aparece en toda configuración real es el de la fábrica de callbacks: en lugar de escribir treinta funciones casi idénticas, escribes una que las genera.
local function mover_ventana(dir) return function() vim.cmd.wincmd(dir) endendfor _, dir in ipairs({ "h", "j", "k", "l" }) do vim.keymap.set("n", "<C-" .. dir .. ">", mover_ventana(dir), { desc = "Ir a " .. dir })endLa trampa del bucle no existe aquí
En Lua cada iteración de un for crea una variable nueva para el control del bucle, así que capturarla en un closure es seguro: las cuatro funciones de arriba recuerdan cada una su dir. Es el comportamiento que JavaScript solo consiguió al pasar de var a let. Ojo: eso vale para la variable del for, no para una local declarada fuera del bucle — esa sí es una sola y todas las closures la compartirían.
Varargs: contar bien los argumentos
Los tres puntos declaran un número variable de argumentos. El error clásico es empaquetarlos con un constructor y medirlos con #: acabas de ver en la 7.2 que un nil intermedio crea un hueco, y con huecos el operador de longitud responde lo que quiere. La forma correcta es select("#", ...), que devuelve el número real de argumentos recibidos, nil incluidos; y select(i, ...), que devuelve del i-ésimo en adelante —de ahí los paréntesis extra del ejemplo, que truncan al primero según la regla de ajuste de la 7.1—.
local function registrar(nivel, ...) local partes = {} for i = 1, select("#", ...) do -- cuenta de verdad partes[i] = vim.inspect((select(i, ...))) -- i-esimo argumento end vim.notify(table.concat(partes, " "), nivel)endregistrar(vim.log.levels.WARN, "ruta", nil, 42) -- tres argumentos, no unoEl otro uso constante es el reenvío transparente: una envoltura que recibe cualquier firma y la pasa intacta. Aquí ... en posición de argumento se expande entera —ajuste otra vez— y los retornos se capturan en una tabla para poder devolverlos después.
local unpack = table.unpack or unpack -- en el LuaJIT de Neovim es la global unpacklocal function pack(...) -- captura retornos SIN perder nil return { n = select("#", ...), ... }endlocal function cronometrar(fn, nombre) return function(...) local t0 = vim.uv.hrtime() local res = pack(fn(...)) -- res.n = cuantos retornos hubo de verdad vim.notify(("%s: %.2f ms"):format(nombre, (vim.uv.hrtime() - t0) / 1e6)) return unpack(res, 1, res.n) -- reexpande respetando nil intermedios endendFíjate en el truco de pack: recibe los retornos de fn como sus varargs, así que select("#", ...) los cuenta bien aunque haya nil en medio, y ese conteo viaja en la clave n. El unpack(res, 1, res.n) con límites explícitos cierra el círculo: sin ellos, unpack usaría #res y los nil intermedios volverían a perderse. Para el día a día basta { fn(...) } y unpack(res), pero cuando envuelvas funciones ajenas —cuyos retornos no controlas— la pareja pack/límites marca la diferencia entre una envoltura transparente y una que se come valores en silencio.
pcall: que un error no tumbe el editor
Lua no tiene excepciones con tipos ni bloques try. Tiene tres funciones. error aborta la ejecución lanzando un valor. pcall ejecuta una función en modo protegido y traduce cualquier error a un valor de retorno. Y assert hace el viaje contrario: convierte el convenio nil, mensaje de la lección 7.1 en un error de verdad.
local ok, mod = pcall(require, "telescope")if not ok then vim.notify("telescope no disponible: " .. mod, vim.log.levels.WARN) returnendmod.setup({})Ese idioma es el más repetido de todo el ecosistema, y con razón: si un plugin falta o su setup revienta, el resto de tu configuración sigue cargándose. Sin el pcall, el primer error corta el archivo entero y arrancas con medio editor configurado y sin saber por qué. Fíjate en el doble papel del segundo retorno: si ok es verdadero es el módulo; si es falso, es el mensaje. Es el mismo canal reutilizado, y por eso las variables se suelen llamar ok y algo neutro.
El valor que se lanza no tiene por qué ser una cadena: error acepta cualquier valor, incluida una tabla, lo que permite errores estructurados con código y contexto. Hay una diferencia observable: a una cadena, error le antepone la posición (archivo:línea:); a cualquier otro valor lo entrega intacto. El segundo parámetro de error es el nivel, que decide a qué posición se culpa: 1 a la función que llama a error, 2 a quien la llamó —lo correcto al validar argumentos, porque el error del usuario está en su llamada, no en tu validación—, 0 para no añadir posición.
local function abrir(ruta) if type(ruta) ~= "string" then error({ code = "EARG", msg = "ruta debe ser string" }, 2) -- culpa al llamante endendlocal ok, e = pcall(abrir, 42)print(ok, type(e), e.code) --> false table EARGassert(v, mensaje) completa el trío: devuelve sus argumentos si v es verdadero y lanza mensaje como error si no. Como tantas funciones de la API devuelven valor o nil, motivo, envolver la llamada en assert convierte el fallo blando en fallo ruidoso exactamente donde tú decidas:
local f = assert(io.open(ruta, "r")) -- si falla: error con el motivo del sistemaCuando además quieres la traza de la pila, pcall no basta: para cuando te devuelve el error, la pila ya se desenrolló. xpcall acepta un manejador que se ejecuta antes de desenrollar, que es el único momento en que la traza aún existe; debug.traceback es el manejador estándar. Y en el LuaJIT de Neovim, xpcall acepta argumentos para la función, una extensión de 5.2 que el 5.1 de referencia no tenía.
local ok, err = xpcall(arriesgado, debug.traceback, arg1, arg2)if not ok then vim.schedule(function() vim.notify(err, vim.log.levels.ERROR) end) endpcall no es un cajón de sastre
Envolver todo en pcall y descartar el mensaje convierte un fallo ruidoso en uno silencioso, que es mucho peor de depurar: la configuración «carga» pero algo no funciona y no hay ni una pista. Protege lo que puede fallar de forma esperable —cargar un módulo opcional, tocar un archivo, hablar con un proceso externo— y notifica siempre el error que capturas. Un pcall sin if not ok delante es casi siempre un bug futuro.
Metatablas: el gancho bajo vim.opt
Una metatabla es una tabla asociada a otra que define qué ocurre en operaciones que, de otro modo, fallarían o serían triviales. Se instala con setmetatable(t, mt) y se consulta con getmetatable(t). Los tres ganchos que necesitas conocer: __index, que se consulta al leer una clave ausente; __newindex, que intercepta la escritura de una clave nueva; y __call, que hace que una tabla se pueda invocar como función. Hay más —__eq para redefinir la igualdad, __tostring para la impresión— pero con estos tres se lee el noventa por ciento del ecosistema.
__index tiene dos formas. Si es una tabla, la búsqueda continúa en ella —así se hace herencia—. Si es una función, se invoca con la tabla y la clave, y su retorno es el resultado —así se hacen proxies y cargas perezosas—. Las funciones rawget y rawset saltan los ganchos, imprescindible para no dispararlos desde dentro de sí mismos:
local perezoso = setmetatable({}, { __index = function(t, k) -- solo se llama si k NO existe local v = cargar_caro(k) rawset(t, k, v) -- cachea sin redisparar el gancho return v end,}) -- perezoso.lsp carga bajo demandaEsto no es teoría: es literalmente cómo funciona la superficie de Neovim que ya usas. vim.fn no contiene los cientos de funciones de Vimscript: contiene un __index que fabrica cada una la primera vez que la pides —por eso la documentación avisa de que pairs(vim.fn) solo enumera las ya llamadas—. vim.g, vim.b y vim.o traducen cada lectura y cada escritura a llamadas de la API mediante __index y __newindex; por eso no puedes recorrerlos con pairs como si fueran tablas normales. Y vim.opt devuelve un objeto con metatabla cuyos métodos append, prepend, remove y get operan sobre opciones de lista. Cuando escribes vim.opt.clipboard:append("unnamedplus"), estás invocando un método sobre una tabla que se fabricó al instante.
La otra cara es la orientación a objetos idiomática, que no es más que __index apuntando a una tabla de métodos y el azúcar de los dos puntos, que inserta self como primer argumento:
local Cola = {}Cola.__index = Cola -- los metodos se buscan aquifunction Cola.new() return setmetatable({ n = 0 }, Cola) endfunction Cola:push(v) self.n = self.n + 1; self[self.n] = v; return self endlocal q = Cola.new():push("a"):push("b") -- encadenable por el return selfLéelo despacio una vez: q es una tabla con n = 2 y dos elementos; no tiene ningún método propio. Al escribir q:push, la clave push no existe en q, el gancho __index redirige la búsqueda a Cola, y los dos puntos pasan q como self. Ni clases, ni herencia, ni new mágico: una tabla de métodos, un gancho y azúcar sintáctico. Cuando un plugin te devuelva «objetos», serán exactamente esto.
Todo lo que parece sintaxis es en realidad una función
Junta las tres piezas de esta lección y verás el mismo principio en cada una. Una closure no es una construcción del compilador: es una función más una lista de upvalues, y con eso solo ya tienes estado privado, memoización, inyección de dependencias y fábricas de callbacks sin que el lenguaje añada una sola palabra clave. El manejo de errores no es un sistema de excepciones con jerarquías: es error lanzando un valor cualquiera y pcall devolviendo un booleano, de modo que un fallo es un dato que decides propagar o absorber, y no un canal paralelo de control que se escapa de tu vista. Y las metatablas no son un sistema de objetos: son ganchos sobre operaciones primitivas que te dejan redefinir el significado de indexar, escribir y llamar, y sobre esa base mínima se levantan la herencia, los proxies perezosos y la API entera de vim. El patrón es siempre el mismo: en vez de darte una función del lenguaje, Lua te da el mecanismo con el que esa función se construye. Por eso al leer el código de un plugin no encuentras magia, sino tablas, funciones y setmetatable; y por eso, cuando algo se comporte de forma inesperada, siempre podrás abrir la tabla, mirar su metatabla y ver el gancho exacto que lo explica. Aprende el mecanismo y dejarás de memorizar API.
Drills
Define antes la fábrica con :lua function crear_contador() local n = 0; return function() n = n + 1; return n end end. Cada :lua =C() imprime 1, 2, 3… El upvalue n vive dentro de la closure guardada en la global C, aunque cada comando :lua sea un chunk distinto.
false seguido del mensaje con todas las rutas que se probaron. El mismo mensaje que, sin pcall, habría abortado tu init.lua entero. Ese texto de rutas lo diseccionarás en la 7.4.
select responde 3, el argumento real; el constructor con # puede responder 1. La diferencia entre contar argumentos y buscar un borde en una tabla con huecos.
Primero nil: la clave no existe en la tabla. Tras usarla, rawget la encuentra: el __index la fabricó y la cacheó. Acabas de observar una metatabla trabajando en la API real.
Escribe código que no rompa nada
- Implementa
memo(fn)que cachee resultados por argumento usando una tabla capturada como upvalue; comprueba con dos llamadas idénticas y unprintdentro defnque la segunda no ejecuta el cuerpo. - Escribe la envoltura
cronometrarde esta lección y úsala sobrevim.fn.getcwd; comprueba que el valor devuelto sigue llegando y que la notificación aparece en:messages. - Convierte un
requirede tu configuración al idiomapcally verifica ambas ramas: con el nombre correcto y con un nombre inventado. - Lanza con
:luaunerrorcon tabla y nivel 2 dentro de una función de validación, captúralo conpcalle imprimee.code; repite lanzando una cadena y observa la posición antepuesta. - Construye un proxy con
__indexy__newindexque imprima cada lectura y escritura sobre una tabla interna (usarawgetyrawsetdentro de los ganchos), y explica qué hacevim.gde forma análoga con la API. - Ejecuta
:lua =getmetatable(vim.opt.clipboard)y relaciona lo que ves con la llamadavim.opt.clipboard:append(...).