← Volver al listado de tecnologías

Capítulo 8: Decoradores al Estilo Lua

Por: Artiko
luadecoratorswrappersmetaprogrammingaop

Capítulo 8: Decoradores al Estilo Lua

“El código es como el humor. Cuando tienes que explicarlo, es malo.” — Cory House

En Python, los decoradores son una característica del lenguaje con sintaxis especial (@decorator). En Lua, no existe esa sintaxis, pero el concepto es aún más poderoso porque las funciones son valores de primera clase.

Un decorador es una función que toma otra función y retorna una versión “mejorada” de ella, típicamente agregando comportamiento antes o después de la ejecución original.

El Patrón Básico de Decorador

local function my_decorator(fn)
    return function(...)
        -- Hacer algo ANTES
        local result = {fn(...)}  -- Llamar función original
        -- Hacer algo DESPUÉS
        return table.unpack(result)
    end
end

Este patrón preserva:

Decorador de Logging

Loggear automáticamente cada llamada a función:

local function with_logging(fn, name)
    name = name or "function"
    return function(...)
        local args = {...}
        local args_str = table.concat(vim.tbl_map(tostring, args), ", ")

        print(string.format("[LOG] Calling %s(%s)", name, args_str))

        local results = {fn(...)}

        if #results > 0 then
            local results_str = table.concat(vim.tbl_map(tostring, results), ", ")
            print(string.format("[LOG] %s returned: %s", name, results_str))
        end

        return table.unpack(results)
    end
end

-- Función original
local function add(a, b)
    return a + b
end

-- Decorar
add = with_logging(add, "add")

-- >>> print(add(5, 3))
-- [LOG] Calling add(5, 3)
-- [LOG] add returned: 8
-- 8

Versión Mejorada con Niveles

local LOG_LEVEL = {
    DEBUG = 1,
    INFO = 2,
    WARN = 3,
    ERROR = 4
}

local current_level = LOG_LEVEL.INFO

local function with_logging_level(fn, name, level)
    level = level or LOG_LEVEL.DEBUG
    name = name or "function"

    return function(...)
        if level < current_level then
            return fn(...)  -- No loggear
        end

        local args = {...}
        local args_str = {}
        for _, v in ipairs(args) do
            table.insert(args_str, tostring(v))
        end

        print(string.format("[%s] %s(%s)",
            level >= LOG_LEVEL.ERROR and "ERROR" or
            level >= LOG_LEVEL.WARN and "WARN" or
            level >= LOG_LEVEL.INFO and "INFO" or "DEBUG",
            name,
            table.concat(args_str, ", ")))

        return fn(...)
    end
end

Decorador de Timing

Medir cuánto tiempo tarda una función:

local function with_timing(fn, name)
    name = name or "function"
    return function(...)
        local start = os.clock()
        local results = {fn(...)}
        local elapsed = os.clock() - start

        print(string.format("[TIMING] %s took %.6f seconds", name, elapsed))

        return table.unpack(results)
    end
end

-- >>> local function slow_operation()
-- >>>     local sum = 0
-- >>>     for i = 1, 1000000 do
-- >>>         sum = sum + i
-- >>>     end
-- >>>     return sum
-- >>> end

-- >>> slow_operation = with_timing(slow_operation, "slow_operation")
-- >>> slow_operation()
-- [TIMING] slow_operation took 0.042315 seconds

Timing con Estadísticas

local function with_stats(fn, name)
    name = name or "function"
    local stats = {
        calls = 0,
        total_time = 0,
        min_time = math.huge,
        max_time = 0
    }

    local decorated = function(...)
        local start = os.clock()
        local results = {fn(...)}
        local elapsed = os.clock() - start

        stats.calls = stats.calls + 1
        stats.total_time = stats.total_time + elapsed
        stats.min_time = math.min(stats.min_time, elapsed)
        stats.max_time = math.max(stats.max_time, elapsed)

        return table.unpack(results)
    end

    decorated.get_stats = function()
        return {
            calls = stats.calls,
            total_time = stats.total_time,
            avg_time = stats.calls > 0 and (stats.total_time / stats.calls) or 0,
            min_time = stats.min_time,
            max_time = stats.max_time
        }
    end

    decorated.print_stats = function()
        local s = decorated.get_stats()
        print(string.format([[
%s Statistics:
  Calls:       %d
  Total time:  %.6fs
  Avg time:    %.6fs
  Min time:    %.6fs
  Max time:    %.6fs
]], name, s.calls, s.total_time, s.avg_time, s.min_time, s.max_time))
    end

    return decorated
end

Decorador de Memoización

Cachear resultados automáticamente:

local function memoize(fn)
    local cache = {}

    return function(...)
        -- Crear key del cache
        local key = table.concat({...}, "\0")

        if cache[key] == nil then
            cache[key] = {fn(...)}
        end

        return table.unpack(cache[key])
    end
end

-- Fibonacci sin memoización: O(2^n)
local function fib(n)
    if n <= 1 then return n end
    return fib(n - 1) + fib(n - 2)
end

-- Decorar
fib = memoize(fib)

-- >>> print(fib(100))  -- Instantáneo gracias al cache

Memoización con Límite de Tamaño (LRU Cache)

local function memoize_lru(fn, max_size)
    max_size = max_size or 100
    local cache = {}
    local keys = {}  -- Orden de inserción

    return function(...)
        local key = table.concat({...}, "\0")

        -- Si está en cache, mover al final (más reciente)
        if cache[key] then
            for i, k in ipairs(keys) do
                if k == key then
                    table.remove(keys, i)
                    break
                end
            end
            table.insert(keys, key)
            return table.unpack(cache[key])
        end

        -- Calcular resultado
        local results = {fn(...)}

        -- Agregar al cache
        cache[key] = results
        table.insert(keys, key)

        -- Si excede tamaño, eliminar el más antiguo
        if #keys > max_size then
            local oldest = table.remove(keys, 1)
            cache[oldest] = nil
        end

        return table.unpack(results)
    end
end

Decorador de Retry

Reintentar automáticamente en caso de fallo:

local function with_retry(fn, max_attempts, delay)
    max_attempts = max_attempts or 3
    delay = delay or 1

    return function(...)
        local attempts = 0
        local last_error

        while attempts < max_attempts do
            attempts = attempts + 1

            local ok, result = pcall(fn, ...)

            if ok then
                return result
            else
                last_error = result
                print(string.format("[RETRY] Attempt %d/%d failed: %s",
                    attempts, max_attempts, result))

                if attempts < max_attempts then
                    os.execute("sleep " .. delay)
                end
            end
        end

        error(string.format("Failed after %d attempts: %s",
            max_attempts, last_error))
    end
end

-- >>> local function flaky_api()
-- >>>     if math.random() > 0.7 then
-- >>>         return "Success!"
-- >>>     else
-- >>>         error("Network timeout")
-- >>>     end
-- >>> end

-- >>> flaky_api = with_retry(flaky_api, 5, 0.5)
-- >>> print(flaky_api())
-- [RETRY] Attempt 1/5 failed: Network timeout
-- [RETRY] Attempt 2/5 failed: Network timeout
-- Success!

Decorador de Validación

Validar argumentos automáticamente:

local function with_validation(fn, ...)
    local validators = {...}

    return function(...)
        local args = {...}

        -- Validar cada argumento
        for i, validator in ipairs(validators) do
            if args[i] ~= nil then
                local ok, err = validator(args[i])
                if not ok then
                    error(string.format("Argument %d: %s", i, err))
                end
            end
        end

        return fn(...)
    end
end

-- Validadores
local function is_number(x)
    if type(x) ~= "number" then
        return false, "must be a number"
    end
    return true
end

local function is_positive(x)
    if type(x) == "number" and x <= 0 then
        return false, "must be positive"
    end
    return true
end

local function is_string(x)
    if type(x) ~= "string" then
        return false, "must be a string"
    end
    return true
end

-- >>> local function greet(name, age)
-- >>>     return string.format("Hello %s, you are %d years old", name, age)
-- >>> end

-- >>> greet = with_validation(greet, is_string, is_number)
-- >>> print(greet("Alice", 30))
-- Hello Alice, you are 30 years old

-- >>> greet("Alice", "thirty")
-- Error: Argument 2: must be a number

Decorador de Rate Limiting

local function with_rate_limit(fn, max_calls, window_seconds)
    local calls = {}

    return function(...)
        local now = os.time()

        -- Limpiar llamadas antiguas
        local new_calls = {}
        for _, timestamp in ipairs(calls) do
            if now - timestamp < window_seconds then
                table.insert(new_calls, timestamp)
            end
        end
        calls = new_calls

        -- Verificar límite
        if #calls >= max_calls then
            error(string.format("Rate limit exceeded: max %d calls per %d seconds",
                max_calls, window_seconds))
        end

        -- Registrar llamada
        table.insert(calls, now)

        return fn(...)
    end
end

-- >>> local function send_email(to, subject)
-- >>>     print("Sending email to " .. to)
-- >>> end

-- >>> send_email = with_rate_limit(send_email, 5, 60)  -- 5 emails por minuto

Composición de Decoradores

Puedes aplicar múltiples decoradores:

local function compose_decorators(...)
    local decorators = {...}

    return function(fn)
        local result = fn
        -- Aplicar decoradores de derecha a izquierda
        for i = #decorators, 1, -1 do
            result = decorators[i](result)
        end
        return result
    end
end

-- >>> local function calculate(a, b)
-- >>>     return a + b
-- >>> end

-- >>> calculate = compose_decorators(
-- >>>     with_logging,
-- >>>     with_timing,
-- >>>     memoize
-- >>> )(calculate)

Helper para Aplicar Decoradores

local function decorate(fn, ...)
    local result = fn
    for _, decorator in ipairs({...}) do
        result = decorator(result)
    end
    return result
end

-- >>> calculate = decorate(calculate,
-- >>>     memoize,
-- >>>     with_timing,
-- >>>     with_logging
-- >>> )

Caso Práctico: Sistema de Permisos

local function require_permission(permission)
    return function(fn)
        return function(user, ...)
            if not user.permissions[permission] then
                error("Permission denied: " .. permission)
            end
            return fn(user, ...)
        end
    end
end

local function require_role(role)
    return function(fn)
        return function(user, ...)
            if user.role ~= role then
                error("Requires role: " .. role)
            end
            return fn(user, ...)
        end
    end
end

-- >>> local function delete_user(user, user_id)
-- >>>     print("Deleting user " .. user_id)
-- >>> end

-- >>> delete_user = require_permission("users.delete")(delete_user)
-- >>> delete_user = require_role("admin")(delete_user)

-- >>> local admin = {role = "admin", permissions = {["users.delete"] = true}}
-- >>> delete_user(admin, 123)
-- Deleting user 123

-- >>> local regular_user = {role = "user", permissions = {}}
-- >>> delete_user(regular_user, 123)
-- Error: Requires role: admin

Caso Práctico: Lazy Evaluation

Ejecutar función solo cuando se necesita el resultado:

local function lazy(fn)
    local cached = nil
    local computed = false

    return function(...)
        if not computed then
            cached = {fn(...)}
            computed = true
        end
        return table.unpack(cached)
    end
end

-- >>> local function expensive_calculation()
-- >>>     print("Computing...")
-- >>>     local sum = 0
-- >>>     for i = 1, 1000000 do
-- >>>         sum = sum + i
-- >>>     end
-- >>>     return sum
-- >>> end

-- >>> expensive_calculation = lazy(expensive_calculation)
-- >>> -- No imprime "Computing..." todavía
-- >>> print("Result:", expensive_calculation())
-- Computing...
-- Result:    500000500000
-- >>> print("Result:", expensive_calculation())
-- Result:    500000500000
-- (no vuelve a calcular)

DEEP DIVE: Preservar Metadata

Cuando decoras una función, pierdes metadata como su nombre. Podemos preservarla:

local function preserve_metadata(original, decorated)
    -- En Lua no hay mucha metadata nativa, pero podemos agregar nuestra propia
    if debug and debug.getinfo then
        local info = debug.getinfo(original, "S")
        decorated._source = info.source
        decorated._linedefined = info.linedefined
    end

    decorated._original = original
    return decorated
end

local function smart_decorator(fn)
    local decorated = function(...)
        -- ... lógica del decorador
        return fn(...)
    end

    return preserve_metadata(fn, decorated)
end

Decoradores para Clases

Aunque Lua no tiene clases nativas, podemos decorar métodos:

local function decorate_methods(class, decorator)
    local decorated_class = {}

    for key, value in pairs(class) do
        if type(value) == "function" then
            decorated_class[key] = decorator(value, key)
        else
            decorated_class[key] = value
        end
    end

    return setmetatable(decorated_class, getmetatable(class))
end

-- >>> local MyClass = {
-- >>>     add = function(self, a, b) return a + b end,
-- >>>     sub = function(self, a, b) return a - b end
-- >>> }

-- >>> MyClass = decorate_methods(MyClass, with_logging)

SOAPBOX: Decoradores vs Aspectos

Los decoradores son una forma de Aspect-Oriented Programming (AOP). En lenguajes como Java, necesitas frameworks como AspectJ. En Lua, es built-in gracias a funciones de primera clase.


Resumen del Capítulo

Decoradores en Lua:

Próximo: Capítulo 9: Introspección y Atributos


Ejercicios

  1. Implementar once: Decorador que permite ejecutar función solo una vez.
function once(fn)
    -- Tu código aquí
end

-- >>> local initialize = once(function() print("Initialized!") end)
-- >>> initialize()
-- Initialized!
-- >>> initialize()
-- (no imprime nada)
  1. Deprecation Decorator: Avisar que una función está deprecada.
function deprecated(fn, message)
    -- Tu código aquí
end

-- >>> old_function = deprecated(old_function, "Use new_function instead")
-- >>> old_function()
-- [WARNING] This function is deprecated: Use new_function instead
  1. Timeout Decorator: Abortar función si tarda más de N segundos.
function with_timeout(fn, timeout_seconds)
    -- Tu código aquí (pista: usa coroutines)
end

Lecturas Adicionales