Saltar al contenido

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 select en vez de con el operador de longitud.
  • Blindar código frágil con pcall, xpcall y assert, distinguiendo error de valor de retorno.
  • Leer una metatabla y reconocer los mecanismos que hay bajo vim.opt, vim.fn y vim.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.

LUA
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 independientes

Cada 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.

LUA
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())                      --> 2

El 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.

LUA
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 })end

La 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—.

LUA
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 uno

El 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.

LUA
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  endend

Fí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.

LUA
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.

LUA
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  EARG

assert(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:

LUA
local f = assert(io.open(ruta, "r"))   -- si falla: error con el motivo del sistema

Cuando 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.

LUA
local ok, err = xpcall(arriesgado, debug.traceback, arg1, arg2)if not ok then vim.schedule(function() vim.notify(err, vim.log.levels.ERROR) end) end

pcall 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:

LUA
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 demanda

Esto 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:

LUA
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 self

Lé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

  1. Implementa memo(fn) que cachee resultados por argumento usando una tabla capturada como upvalue; comprueba con dos llamadas idénticas y un print dentro de fn que la segunda no ejecuta el cuerpo.
  2. Escribe la envoltura cronometrar de esta lección y úsala sobre vim.fn.getcwd; comprueba que el valor devuelto sigue llegando y que la notificación aparece en :messages.
  3. Convierte un require de tu configuración al idioma pcall y verifica ambas ramas: con el nombre correcto y con un nombre inventado.
  4. Lanza con :lua un error con tabla y nivel 2 dentro de una función de validación, captúralo con pcall e imprime e.code; repite lanzando una cadena y observa la posición antepuesta.
  5. Construye un proxy con __index y __newindex que imprima cada lectura y escritura sobre una tabla interna (usa rawget y rawset dentro de los ganchos), y explica qué hace vim.g de forma análoga con la API.
  6. Ejecuta :lua =getmetatable(vim.opt.clipboard) y relaciona lo que ves con la llamada vim.opt.clipboard:append(...).