Ir al contenido principal

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:

  1. Sus propias variables locales
  2. Sus parámetros
  3. Las variables locales de las funciones que la contienen
  4. 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:

  1. La función crearSumador acepta un parámetro valorBase y define dos variables locales b y c
  2. Devuelve una función anónima que acepta un parámetro numero
  3. La función anónima es un closure porque accede a valorBase, b y c de su función contenedora
  4. Cuando llamamos a crearSumador(1), se crea un nuevo closure con valorBase = 1
  5. Este closure "recuerda" los valores de valorBase, b y c incluso después de que crearSumador haya terminado
  6. 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:

  • contador1 tiene acceso a su propia variable i
  • contador2 tiene acceso a una variable i completamente 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.