Closures en Lua
Los closures son una de las características más poderosas y elegantes de Lua, permitiendo crear funciones que "recuerdan" el entorno en el que fueron creadas. Esta capacidad abre un amplio abanico de posibilidades para encapsular datos, crear funciones especializadas y gestionar el estado de manera limpia y eficiente.
En este artículo exploraremos qué son los closures, cómo funcionan internamente, y cómo aprovecharlos para escribir código más expresivo y mantenible. Los closures son fundamentales para patrones avanzados de programación en Lua, incluyendo la programación funcional, la creación de módulos con estado privado, y la implementación de callbacks y sistemas de eventos.
Si has trabajado con funciones en Lua y comprendes los ámbitos de las variables locales y globales, estás preparado para dominar este concepto. Los closures son simplemente funciones que aprovechan el ámbito léxico de Lua para acceder a variables que, en teoría, ya deberían haber desaparecido.
¿Qué es un closure?
Un closure es una función que ha sido definida dentro de otra función y que tiene acceso a las variables locales de la función que la contiene. En términos técnicos, un closure "captura" o "cierra sobre" las variables del ámbito léxico en el que fue creado, de ahí su nombre en inglés (closure significa "cierre").
La característica distintiva de un closure es que puede acceder y modificar estas variables capturadas incluso después de que la función externa haya terminado su ejecución. Esto es posible porque Lua mantiene vivas las variables mientras existan referencias a ellas a través del closure.
Ámbito léxico y captura de variables
Para entender completamente los closures, primero debemos comprender el ámbito léxico. En Lua, cuando defines una función, ésta puede acceder a:
- Sus propias variables locales
- Sus parámetros
- Las variables locales de las funciones que la contienen
- Las variables globales
Este acceso se determina en tiempo de definición, no de ejecución, de ahí el término "léxico" (basado en la estructura del código, no en el flujo de ejecución).
Cuando una función interna hace referencia a una variable de su función contenedora, Lua "captura" esa variable. La variable no se destruye cuando la función externa termina; en su lugar, permanece en memoria mientras el closure exista, creando un vínculo permanente entre la función interna y las variables capturadas.
Ejemplo básico de closure
Veamos un ejemplo simple para ilustrar el concepto fundamental:
function crearSumador(valorBase)
-- valorBase es una variable local de crearSumador
local b = 10
local c = 5
-- Esta función interna es un closure
return function(numero)
-- Accede a valorBase, b y c de la función externa
return numero + valorBase + b + c
end
end
-- Creamos un closure con valorBase = 1
local sumador = crearSumador(1)
-- Usamos el closure
print(sumador(5)) --> 21 (5 + 1 + 10 + 5)
print(sumador(10)) --> 26 (10 + 1 + 10 + 5)
Análisis del ejemplo:
- La función
crearSumadoracepta un parámetrovalorBasey define dos variables localesbyc - Devuelve una función anónima que acepta un parámetro
numero - La función anónima es un closure porque accede a
valorBase,bycde su función contenedora - Cuando llamamos a
crearSumador(1), se crea un nuevo closure convalorBase = 1 - Este closure "recuerda" los valores de
valorBase,bycincluso después de quecrearSumadorhaya terminado - Cada llamada a
sumador()usa los valores capturados para realizar el cálculo
Lo importante aquí es que las variables valorBase, b y c no desaparecen cuando crearSumador termina su ejecución. El closure mantiene una referencia a ellas, permitiendo que sigan siendo accesibles.
Closures con estado mutable
Una de las aplicaciones más útiles de los closures es la creación de funciones que mantienen su propio estado privado. Veamos un ejemplo clásico: un contador.
function crearContador()
-- Variable local que guardará el estado
local i = 0
-- Retornamos un closure que modifica y accede a 'i'
return function()
i = i + 1
return i
end
end
-- Creamos dos contadores independientes
local contador1 = crearContador()
local contador2 = crearContador()
-- Cada contador mantiene su propio estado
print(contador1()) --> 1
print(contador1()) --> 2
print(contador2()) --> 1
print(contador1()) --> 3
print(contador2()) --> 2
print(contador2()) --> 3
¿Qué está sucediendo aquí?
Cada vez que llamamos a crearContador(), se crea un nuevo ámbito con su propia variable i inicializada en 0. El closure retornado captura esa instancia específica de i. Por lo tanto:
contador1tiene acceso a su propia variableicontador2tiene acceso a una variableicompletamente diferente- Cada closure mantiene su estado de forma independiente
Veamos una representación del estado después de varias llamadas:
| Operación | Estado contador1 (i) | Estado contador2 (i) | Salida |
|---|---|---|---|
contador1() |
1 | 0 | 1 |
contador1() |
2 | 0 | 2 |
contador2() |
2 | 1 | 1 |
contador1() |
3 | 1 | 3 |
contador2() |
3 | 2 | 2 |
contador2() |
3 | 3 | 3 |
Este patrón es extremadamente útil porque nos permite crear múltiples "instancias" de una función, cada una con su propio estado privado, sin necesidad de usar tablas o estructuras de datos explícitas.
Closures vs. objetos con estado
Los closures ofrecen una alternativa elegante a los objetos tradicionales para encapsular estado. Veamos una comparación:
Usando una tabla (enfoque orientado a objetos):
-- Definición usando tabla
local Contador = {}
Contador.__index = Contador
function Contador.nuevo()
local self = setmetatable({}, Contador)
self.valor = 0
return self
end
function Contador:incrementar()
self.valor = self.valor + 1
return self.valor
end
-- Uso
local c = Contador.nuevo()
print(c:incrementar()) --> 1
Usando un closure:
-- Definición usando closure
function crearContador()
local valor = 0
return function()
valor = valor + 1
return valor
end
end
-- Uso
local c = crearContador()
print(c()) --> 1
Comparación:
| Aspecto | Closures | Objetos con tablas |
|---|---|---|
| Sintaxis | Más concisa | Más verbosa |
| Privacidad | Verdadera (imposible acceder a variables internas) | Convencional (se pueden acceder con conocimiento de la estructura) |
| Memoria | Ligeramente más eficiente | Ligeramente mayor overhead |
| Múltiples métodos | Requiere retornar tabla de funciones | Natural y directo |
| Herencia | Más complicado | Más directo con metatablas |
¿Cuándo usar cada uno?
- Closures: Ideal para funciones simples con estado, callbacks, configuradores, y cuando necesitas verdadera privacidad
- Objetos con tablas: Mejor para estructuras complejas con múltiples métodos, cuando necesitas herencia, o cuando la claridad de la sintaxis orientada a objetos es importante
Casos de uso prácticos
1. Funciones factory (generadores de funciones especializadas)
Los closures son perfectos para crear funciones especializadas a partir de una función genérica:
-- Factory para crear multiplicadores
function crearMultiplicador(factor)
return function(numero)
return numero * factor
end
end
-- Creamos multiplicadores especializados
local duplicar = crearMultiplicador(2)
local triplicar = crearMultiplicador(3)
local multiplicarPor10 = crearMultiplicador(10)
print(duplicar(5)) --> 10
print(triplicar(5)) --> 15
print(multiplicarPor10(5)) --> 50
Este patrón es muy útil cuando necesitas muchas variaciones de una función similar.
2. Sistema de callbacks con contexto
Los closures permiten crear callbacks que "recuerdan" información adicional:
function crearManejadorBoton(nombreBoton, accionPredeterminada)
return function(evento)
print("Boton presionado:", nombreBoton)
print("Tipo de evento:", evento)
accionPredeterminada()
end
end
-- Definir acción predeterminada
local function guardarDocumento()
print("Guardando documento...")
end
-- Crear callback que recuerda el nombre del botón y la acción
local botonGuardar = crearManejadorBoton("Guardar", guardarDocumento)
-- Simular clic en el botón
botonGuardar("click")
--> Boton presionado: Guardar
--> Tipo de evento: click
--> Guardando documento...
3. Encapsulación y privacidad de datos
Los closures proporcionan verdadera privacidad de datos, algo que las tablas simples no pueden ofrecer:
function crearCuenta(saldoInicial)
-- Variable privada, inaccesible desde fuera
local saldo = saldoInicial or 0
-- Retornamos una tabla con métodos públicos
return {
depositar = function(cantidad)
if cantidad > 0 then
saldo = saldo + cantidad
return true
end
return false
end,
retirar = function(cantidad)
if cantidad > 0 and cantidad <= saldo then
saldo = saldo - cantidad
return true
end
return false
end,
consultarSaldo = function()
return saldo
end
}
end
-- Uso
local miCuenta = crearCuenta(100)
print(miCuenta.consultarSaldo()) --> 100
miCuenta.depositar(50)
print(miCuenta.consultarSaldo()) --> 150
miCuenta.retirar(30)
print(miCuenta.consultarSaldo()) --> 120
-- Imposible acceder directamente a 'saldo'
print(miCuenta.saldo) --> nil
En este ejemplo, la variable saldo es completamente privada. No hay forma de acceder o modificar directamente el saldo excepto a través de los métodos proporcionados.
4. Decoradores de funciones
Los closures permiten "envolver" funciones existentes para añadir funcionalidad:
-- Decorador que mide el tiempo de ejecución
function cronometrar(funcion)
return function(...)
local inicio = os.clock()
local resultado = funcion(...)
local fin = os.clock()
print(string.format("Tiempo de ejecucion: %.6f segundos", fin - inicio))
return resultado
end
end
-- Función original
local function calcularFactorial(n)
if n <= 1 then return 1 end
return n * calcularFactorial(n - 1)
end
-- Versión cronometrada
local factorialCronometrado = cronometrar(calcularFactorial)
-- Uso
local resultado = factorialCronometrado(10)
--> Tiempo de ejecucion: 0.000023 segundos
print("Resultado:", resultado)
--> Resultado: 3628800
5. Implementación de una caché con closure
function crearCacheFuncion(funcion)
local cache = {}
return function(argumento)
-- Si el resultado ya está en caché, devolverlo
if cache[argumento] ~= nil then
print("Resultado desde cache para:", argumento)
return cache[argumento]
end
-- Si no, calcularlo y guardarlo
print("Calculando resultado para:", argumento)
local resultado = funcion(argumento)
cache[argumento] = resultado
return resultado
end
end
-- Función costosa
local function cuadrado(x)
-- Simulamos operación costosa
local suma = 0
for i = 1, 1000000 do
suma = suma + 1
end
return x * x
end
-- Versión con caché
local cuadradoConCache = crearCacheFuncion(cuadrado)
print(cuadradoConCache(5)) --> Calculando... 25
print(cuadradoConCache(5)) --> Desde cache... 25
print(cuadradoConCache(7)) --> Calculando... 49
print(cuadradoConCache(5)) --> Desde cache... 25
Errores comunes con closures
1. Variables compartidas en bucles
Un error muy común es crear closures dentro de un bucle y esperar que cada uno capture el valor actual de la variable de iteración:
-- INCORRECTO
function crearFunciones()
local funciones = {}
for i = 1, 3 do
funciones[i] = function()
print(i)
end
end
return funciones
end
local fns = crearFunciones()
fns[1]() --> 4 (¡no 1!)
fns[2]() --> 4 (¡no 2!)
fns[3]() --> 4 (¡no 3!)
¿Por qué sucede esto? Todos los closures capturan la misma variable i. Cuando el bucle termina, i vale 4, y todos los closures ven ese valor.
Solución: Crear una nueva variable local en cada iteración:
-- CORRECTO
function crearFunciones()
local funciones = {}
for i = 1, 3 do
local valor = i -- Nueva variable local en cada iteración
funciones[i] = function()
print(valor)
end
end
return funciones
end
local fns = crearFunciones()
fns[1]() --> 1
fns[2]() --> 2
fns[3]() --> 3
2. Referencias cíclicas y memoria
Los closures pueden crear referencias cíclicas que dificultan la recolección de basura:
-- Posible problema de memoria
function crearNodo(valor)
local nodo = {
valor = valor,
obtenerValor = function()
return nodo.valor -- Referencia cíclica
end
}
return nodo
end
En este caso, el closure obtenerValor mantiene una referencia a nodo, y nodo contiene el closure, creando un ciclo. Aunque el recolector de basura de Lua puede manejar esto, es bueno ser consciente del problema.
3. Closures anidados excesivamente
Anidar closures profundamente puede hacer el código difícil de entender y depurar:
-- Difícil de entender
function a()
local x = 1
return function()
local y = 2
return function()
local z = 3
return function()
return x + y + z
end
end
end
end
-- Uso confuso
local fn = a()()()()
print(fn()) --> 6
Recomendación: Mantén los closures en niveles razonables de anidamiento (generalmente no más de 2-3 niveles).
4. Captura inesperada de variables
A veces capturamos variables sin darnos cuenta:
local contadorGlobal = 0
function crearIncrementador()
-- CUIDADO: captura contadorGlobal de manera inesperada
return function()
contadorGlobal = contadorGlobal + 1
return contadorGlobal
end
end
local inc1 = crearIncrementador()
local inc2 = crearIncrementador()
print(inc1()) --> 1
print(inc2()) --> 2 (¡comparten el mismo contador!)
Buenas prácticas con closures
1. Usa nombres descriptivos
-- Mal: nombres genéricos
function crear(x)
return function(y)
return x + y
end
end
-- Bien: nombres que describen el propósito
function crearSumadorConBase(valorBase)
return function(numeroASumar)
return valorBase + numeroASumar
end
end
2. Documenta qué variables se capturan
function crearManejador(configuracion)
-- Variables capturadas: configuracion.timeout, configuracion.reintentos
local timeout = configuracion.timeout
local reintentos = configuracion.reintentos
return function(datos)
-- Usa timeout y reintentos
end
end
3. Considera el impacto en la memoria
Los closures mantienen vivas las variables que capturan. Si capturas una tabla grande que solo necesitas parcialmente, considera extraer solo lo necesario:
-- Menos eficiente: captura toda la tabla
function crearProcesador(configuracionCompleta)
return function(datos)
return datos * configuracionCompleta.multiplicador
end
end
-- Más eficiente: solo captura lo necesario
function crearProcesador(configuracionCompleta)
local multiplicador = configuracionCompleta.multiplicador
return function(datos)
return datos * multiplicador
end
end
4. Usa closures cuando realmente aporten valor
No uses closures solo porque puedes. Evalúa si realmente necesitas capturar estado:
-- Innecesariamente complejo
function sumar()
return function(a, b)
return a + b
end
end
-- Más simple y directo
function sumar(a, b)
return a + b
end
5. Combina closures con tablas para interfaces ricas
Cuando necesites múltiples operaciones relacionadas:
function crearCalculadora(valorInicial)
local valor = valorInicial or 0
return {
sumar = function(x)
valor = valor + x
return valor
end,
restar = function(x)
valor = valor - x
return valor
end,
multiplicar = function(x)
valor = valor * x
return valor
end,
obtenerValor = function()
return valor
end,
reiniciar = function()
valor = valorInicial
return valor
end
}
end
-- Uso fluido
local calc = crearCalculadora(10)
calc.sumar(5) --> 15
calc.multiplicar(2) --> 30
calc.restar(10) --> 20
print(calc.obtenerValor()) --> 20
calc.reiniciar() --> 10
Ejercicios prácticos
Ejercicio 1: Generador de saludos personalizados
Crea una función crearSaludador(saludo) que retorne un closure que salude a personas de forma personalizada.
local saludadorFormal = crearSaludador("Buenos días")
local saludadorInformal = crearSaludador("¡Hola")
print(saludadorFormal("Sr. García")) --> "Buenos días, Sr. García"
print(saludadorInformal("amigo")) --> "¡Hola, amigo!"
Ejercicio 2: Contador con límite
Implementa un contador que tenga un valor máximo. Una vez alcanzado el máximo, debe detenerse y devolver nil.
local contador = crearContadorLimitado(3)
print(contador()) --> 1
print(contador()) --> 2
print(contador()) --> 3
print(contador()) --> nil
print(contador()) --> nil
Ejercicio 3: Sistema de notificaciones
Crea un sistema donde puedas suscribir funciones a un evento y todas se ejecuten cuando el evento ocurra:
local evento = crearEvento()
evento.suscribir(function(datos) print("Suscriptor 1:", datos) end)
evento.suscribir(function(datos) print("Suscriptor 2:", datos) end)
evento.disparar("¡Hola a todos!")
--> Suscriptor 1: ¡Hola a todos!
--> Suscriptor 2: ¡Hola a todos!
Ejercicio 4: Generador de secuencias
Implementa un generador que produzca números de una secuencia (Fibonacci, cuadrados, etc.):
local fibonacci = crearGeneradorFibonacci()
print(fibonacci()) --> 1
print(fibonacci()) --> 1
print(fibonacci()) --> 2
print(fibonacci()) --> 3
print(fibonacci()) --> 5
Soluciones a los ejercicios
Solución Ejercicio 1:
function crearSaludador(saludo)
return function(nombre)
return saludo .. ", " .. nombre
end
end
Solución Ejercicio 2:
function crearContadorLimitado(limite)
local contador = 0
return function()
if contador < limite then
contador = contador + 1
return contador
else
return nil
end
end
end
Solución Ejercicio 3:
function crearEvento()
local suscriptores = {}
return {
suscribir = function(funcion)
table.insert(suscriptores, funcion)
end,
disparar = function(datos)
for _, suscriptor in ipairs(suscriptores) do
suscriptor(datos)
end
end
}
end
Solución Ejercicio 4:
function crearGeneradorFibonacci()
local a, b = 0, 1
return function()
a, b = b, a + b
return a
end
end
Resumen
Los closures son funciones que capturan y mantienen acceso a las variables de su ámbito léxico, incluso después de que la función contenedora haya terminado su ejecución. Esta característica los convierte en una herramienta versátil para:
- Encapsular estado privado sin necesidad de estructuras complejas
- Crear funciones especializadas mediante funciones factory
- Implementar callbacks que recuerdan su contexto
- Decorar funciones añadiendo funcionalidad adicional
- Optimizar mediante caché manteniendo resultados previos
Los closures son particularmente útiles cuando necesitas:
- Verdadera privacidad de datos
- Múltiples instancias de funciones con estados independientes
- Configuración de funciones en tiempo de ejecución
- Patrones de programación funcional
Sin embargo, debes tener cuidado con:
- Variables compartidas en bucles
- Referencias cíclicas que pueden afectar la memoria
- Anidamiento excesivo que dificulta la legibilidad
- Captura innecesaria de variables grandes
En el próximo artículo, exploraremos los módulos en Lua, donde veremos cómo los closures juegan un papel fundamental en la creación de módulos con estado privado y en la implementación de diferentes patrones de organización de código. Los conceptos de closures que has aprendido aquí serán la base para entender módulos más avanzados, incluyendo los patrones singleton y la encapsulación de bibliotecas completas.
Los closures son una pieza clave para escribir código Lua elegante, eficiente y mantenible. Dominar este concepto te abrirá las puertas a técnicas de programación más avanzadas y te permitirá crear APIs más limpias y expresivas.