← Volver al listado de tecnologías

Capítulo 18: Atributos Dinámicos

Por: Artiko
luadynamic-attributesproxylazy-loading

Capítulo 18: Atributos Dinámicos

Introducción

Los atributos dinámicos representan uno de los aspectos más poderosos de Lua: la capacidad de generar propiedades bajo demanda, interceptar accesos, y crear abstracciones que responden inteligentemente a las necesidades del código. En este capítulo exploraremos técnicas avanzadas de metaprogramación que van más allá del simple uso de metatables.

__index como Función: El Motor de la Dinamicidad

Cuando __index es una función en lugar de una tabla, cada acceso a una clave inexistente se convierte en una oportunidad para computar, validar o generar valores dinámicamente.

Ejemplo REPL: Propiedades Computadas Básicas

-- Crear un objeto con propiedades computadas
local Rectangle = {}
Rectangle.__index = function(self, key)
    if key == "area" then
        return self.width * self.height
    elseif key == "perimeter" then
        return 2 * (self.width + self.height)
    elseif key == "diagonal" then
        return math.sqrt(self.width^2 + self.height^2)
    end
    -- Si no es una propiedad computada, retornar nil
    return nil
end

function Rectangle.new(width, height)
    local rect = {width = width, height = height}
    setmetatable(rect, Rectangle)
    return rect
end

-- Crear y usar rectángulo
local r = Rectangle.new(5, 3)

print(r.width)      --> 5
print(r.height)     --> 3
print(r.area)       --> 15 (computado dinámicamente)
print(r.perimeter)  --> 16 (computado dinámicamente)
print(r.diagonal)   --> 5.830951894845301

-- Las propiedades NO se almacenan
print(rawget(r, "area"))  --> nil

-- Se recomputan en cada acceso
r.width = 10
print(r.area)  --> 30 (actualizado automáticamente)

Ejemplo REPL: Validación de Accesos

-- Tabla que solo permite acceso a claves permitidas
local function createRestrictedTable(allowedKeys)
    local data = {}
    local allowed = {}
    for _, key in ipairs(allowedKeys) do
        allowed[key] = true
    end

    local mt = {
        __index = function(t, key)
            if not allowed[key] then
                error("Acceso a clave no permitida: " .. tostring(key))
            end
            return data[key]
        end,
        __newindex = function(t, key, value)
            if not allowed[key] then
                error("Asignación a clave no permitida: " .. tostring(key))
            end
            data[key] = value
        end
    }

    return setmetatable({}, mt)
end

-- Crear tabla restringida
local config = createRestrictedTable({"host", "port", "timeout"})

config.host = "localhost"
config.port = 8080
print(config.host, config.port)  --> localhost  8080

-- Esto generará error:
-- config.database = "mydb"  --> Error: Asignación a clave no permitida: database
-- print(config.user)        --> Error: Acceso a clave no permitida: user

Lazy Loading: Carga Diferida de Propiedades

El lazy loading permite cargar recursos costosos solo cuando se necesitan, mejorando significativamente el rendimiento de inicialización.

Ejemplo REPL: Sistema de Módulos con Lazy Loading

-- Sistema de carga diferida de módulos
local ModuleLoader = {}

function ModuleLoader.new()
    local modules = {}
    local loaded = {}

    local mt = {
        __index = function(t, moduleName)
            -- Si ya está cargado, retornarlo
            if loaded[moduleName] then
                return loaded[moduleName]
            end

            -- Si está registrado, cargarlo
            if modules[moduleName] then
                print("Cargando módulo: " .. moduleName)
                local module = modules[moduleName]()
                loaded[moduleName] = module
                return module
            end

            error("Módulo no encontrado: " .. moduleName)
        end
    }

    local loader = setmetatable({}, mt)

    -- Método para registrar módulos
    function loader.register(name, factory)
        modules[name] = factory
    end

    return loader
end

-- Crear loader
local loader = ModuleLoader.new()

-- Registrar módulos (solo funciones, no se ejecutan aún)
loader.register("math_utils", function()
    print("  Inicializando math_utils...")
    return {
        sum = function(a, b) return a + b end,
        multiply = function(a, b) return a * b end
    }
end)

loader.register("string_utils", function()
    print("  Inicializando string_utils...")
    return {
        uppercase = function(s) return s:upper() end,
        reverse = function(s) return s:reverse() end
    }
end)

loader.register("file_utils", function()
    print("  Inicializando file_utils (operación costosa)...")
    -- Simular carga costosa
    local start = os.clock()
    while os.clock() - start < 0.1 do end
    return {
        read = function(path) return "contenido de " .. path end
    }
end)

-- Los módulos no se cargan hasta que se acceden
print("=== Inicio del programa ===")

-- Primer acceso: se carga
print(loader.math_utils.sum(5, 3))
--> Cargando módulo: math_utils
-->   Inicializando math_utils...
--> 8

-- Segundo acceso: ya está cargado
print(loader.math_utils.multiply(4, 7))
--> 28 (sin mensaje de carga)

-- Otro módulo: se carga solo cuando se necesita
print(loader.string_utils.uppercase("hello"))
--> Cargando módulo: string_utils
-->   Inicializando string_utils...
--> HELLO

-- file_utils nunca se carga si no se usa
print("=== Fin del programa ===")

Ejemplo REPL: Lazy Loading de Propiedades Individuales

-- Objeto con propiedades que se calculan y cachean
local function createLazyObject(initializers)
    local cache = {}

    local mt = {
        __index = function(t, key)
            -- Si está en cache, retornar
            if cache[key] ~= nil then
                return cache[key]
            end

            -- Si hay inicializador, ejecutar y cachear
            if initializers[key] then
                print("Calculando " .. key .. "...")
                local value = initializers[key]()
                cache[key] = value
                return value
            end

            return nil
        end
    }

    return setmetatable({}, mt)
end

-- Crear objeto con cálculos costosos
local data = createLazyObject({
    fibonacci_1000 = function()
        -- Simular cálculo costoso
        local function fib(n)
            if n <= 2 then return 1 end
            return fib(n-1) + fib(n-2)
        end
        return fib(20)  -- Reducido para ejemplo
    end,

    large_table = function()
        local t = {}
        for i = 1, 1000000 do
            t[i] = i * i
        end
        return t
    end,

    timestamp = function()
        return os.time()
    end
})

-- Primera llamada: se calcula
print(data.fibonacci_1000)  --> Calculando fibonacci_1000...
                            --> 6765

-- Segunda llamada: usa cache
print(data.fibonacci_1000)  --> 6765 (sin mensaje)

-- Solo se calcula lo que se usa
print(data.timestamp)  --> Calculando timestamp...
                       --> [número de timestamp]

Proxies: Interceptación Completa

Los proxies son objetos que interceptan todas las operaciones sobre otro objeto, permitiendo validación, logging, transformación y control de acceso.

Ejemplo REPL: Proxy Completo con Logging

-- Crear proxy que registra todos los accesos
local function createLoggingProxy(target, name)
    local proxy = {}

    local mt = {
        __index = function(t, key)
            print(string.format("[GET] %s.%s", name, key))
            return target[key]
        end,

        __newindex = function(t, key, value)
            print(string.format("[SET] %s.%s = %s", name, key, tostring(value)))
            target[key] = value
        end,

        __pairs = function(t)
            print(string.format("[ITERATE] %s", name))
            return pairs(target)
        end,

        __tostring = function(t)
            return string.format("Proxy<%s>", name)
        end
    }

    return setmetatable(proxy, mt)
end

-- Crear objeto original
local user = {
    name = "Alice",
    age = 30
}

-- Crear proxy
local userProxy = createLoggingProxy(user, "user")

-- Todas las operaciones se registran
print(userProxy.name)
--> [GET] user.name
--> Alice

userProxy.email = "[email protected]"
--> [SET] user.email = [email protected]

userProxy.age = 31
--> [SET] user.age = 31

-- Iterar también se registra
for k, v in pairs(userProxy) do
    print(k, v)
end
--> [ITERATE] user
--> name    Alice
--> age     31
--> email   [email protected]

print(userProxy)
--> Proxy<user>

Ejemplo REPL: Proxy de Validación

-- Proxy que valida tipos de datos
local function createTypedProxy(schema)
    local data = {}

    local mt = {
        __index = function(t, key)
            return data[key]
        end,

        __newindex = function(t, key, value)
            -- Verificar si la clave está en el schema
            if not schema[key] then
                error("Campo desconocido: " .. key)
            end

            -- Validar tipo
            local expectedType = schema[key]
            local actualType = type(value)

            if expectedType == "integer" then
                if actualType ~= "number" or math.floor(value) ~= value then
                    error(string.format(
                        "Campo '%s' debe ser integer, recibido: %s",
                        key, actualType
                    ))
                end
            elseif actualType ~= expectedType then
                error(string.format(
                    "Campo '%s' debe ser %s, recibido: %s",
                    key, expectedType, actualType
                ))
            end

            data[key] = value
        end
    }

    return setmetatable({}, mt)
end

-- Definir schema
local personSchema = {
    name = "string",
    age = "integer",
    email = "string",
    active = "boolean"
}

-- Crear objeto tipado
local person = createTypedProxy(personSchema)

-- Asignaciones válidas
person.name = "Bob"
person.age = 25
person.email = "[email protected]"
person.active = true

print(person.name, person.age, person.active)
--> Bob  25  true

-- Asignaciones inválidas (generan errores):
-- person.age = "veinticinco"  --> Error: debe ser integer
-- person.age = 25.5           --> Error: debe ser integer
-- person.active = "yes"       --> Error: debe ser boolean
-- person.country = "USA"      --> Error: Campo desconocido

Wrappers: Extensión de Funcionalidad

Los wrappers envuelven objetos existentes agregando comportamiento sin modificar el original.

Ejemplo REPL: Wrapper de Conversión Automática

-- Wrapper que convierte strings a números automáticamente
local function createAutoConvertWrapper(target)
    local wrapper = {}

    local mt = {
        __index = function(t, key)
            local value = target[key]

            -- Si es string numérico, convertir
            if type(value) == "string" and tonumber(value) then
                return tonumber(value)
            end

            return value
        end,

        __newindex = function(t, key, value)
            target[key] = value
        end
    }

    return setmetatable(wrapper, mt)
end

-- Datos originales con strings
local config = {
    port = "8080",
    timeout = "30",
    host = "localhost",
    debug = "true"
}

-- Wrapper con conversión automática
local configWrapper = createAutoConvertWrapper(config)

print(configWrapper.port)     --> 8080 (número)
print(configWrapper.timeout)  --> 30 (número)
print(configWrapper.host)     --> localhost (string)

print(type(configWrapper.port))  --> number
print(type(config.port))         --> string (original no cambia)

-- Operaciones matemáticas funcionan directamente
print(configWrapper.port + 1)  --> 8081

Ejemplo REPL: Wrapper de Normalización de Claves

-- Wrapper que normaliza claves (case-insensitive)
local function createCaseInsensitiveWrapper(target)
    local keyMap = {}

    -- Construir mapa de claves normalizadas
    for key in pairs(target) do
        keyMap[key:lower()] = key
    end

    local mt = {
        __index = function(t, key)
            local normalizedKey = key:lower()
            local originalKey = keyMap[normalizedKey]
            return originalKey and target[originalKey]
        end,

        __newindex = function(t, key, value)
            local normalizedKey = key:lower()
            local originalKey = keyMap[normalizedKey]

            if originalKey then
                target[originalKey] = value
            else
                -- Nueva clave: guardar en formato original
                target[key] = value
                keyMap[normalizedKey] = key
            end
        end
    }

    return setmetatable({}, mt)
end

-- Datos con claves en diferentes casos
local headers = {
    ContentType = "application/json",
    Authorization = "Bearer token123",
    UserAgent = "MyApp/1.0"
}

local headersCI = createCaseInsensitiveWrapper(headers)

-- Acceso case-insensitive
print(headersCI.contenttype)    --> application/json
print(headersCI.AUTHORIZATION)  --> Bearer token123
print(headersCI.useragent)      --> MyApp/1.0

-- Modificación case-insensitive
headersCI.CONTENTTYPE = "text/html"
print(headers.ContentType)  --> text/html (clave original se mantiene)

Deep Dive: Cadenas de __index

Lua permite crear cadenas de búsqueda complejas combinando funciones y tablas en __index.

Ejemplo REPL: Herencia Multinivel con Mixins

-- Sistema de herencia con mixins dinámicos
local function createClass(name)
    local class = {__name = name}
    local mixins = {}

    class.__index = function(t, key)
        -- 1. Buscar en la clase misma
        local value = class[key]
        if value ~= nil then return value end

        -- 2. Buscar en mixins (en orden inverso de inclusión)
        for i = #mixins, 1, -1 do
            value = mixins[i][key]
            if value ~= nil then return value end
        end

        -- 3. Buscar en clase padre si existe
        if class.__parent then
            return class.__parent[key]
        end

        return nil
    end

    function class.include(mixin)
        table.insert(mixins, mixin)
    end

    function class.extend(parentClass)
        class.__parent = parentClass
    end

    function class.new(...)
        local instance = {}
        setmetatable(instance, class)
        if instance.init then
            instance:init(...)
        end
        return instance
    end

    return class
end

-- Definir mixins
local Serializable = {
    toJSON = function(self)
        local result = {}
        for k, v in pairs(self) do
            if type(v) ~= "function" then
                table.insert(result, string.format('"%s": %s', k, tostring(v)))
            end
        end
        return "{" .. table.concat(result, ", ") .. "}"
    end
}

local Timestamped = {
    touch = function(self)
        self.updatedAt = os.time()
    end
}

-- Crear clase base
local Entity = createClass("Entity")

function Entity:init(id)
    self.id = id
    self.createdAt = os.time()
end

-- Crear clase derivada con mixins
local User = createClass("User")
User.extend(Entity)
User.include(Serializable)
User.include(Timestamped)

function User:init(id, name)
    Entity.init(self, id)
    self.name = name
end

function User:greet()
    return "Hola, soy " .. self.name
end

-- Crear instancia
local user = User.new(1, "Carlos")

-- Métodos de la clase
print(user:greet())  --> Hola, soy Carlos

-- Métodos del mixin Timestamped
user:touch()
print(user.updatedAt)  --> [timestamp]

-- Métodos del mixin Serializable
print(user:toJSON())
--> {"id": 1, "name": Carlos, "createdAt": [time], "updatedAt": [time]}

-- Propiedades de la clase padre
print(user.id, user.createdAt)  --> 1  [timestamp]

Soapbox: Cuándo NO Usar Atributos Dinámicos

Advertencia: Los atributos dinámicos son poderosos pero tienen costos.

Evita usarlos cuando:

  1. Performance es crítica: Cada acceso ejecuta código adicional
  2. Debugging es importante: Los valores computados son más difíciles de inspeccionar
  3. La lógica es simple: No uses un martillo para clavar un tornillo
  4. Necesitas serialización: Los valores computados no se serializan automáticamente

Úsalos cuando:

  1. Necesitas abstracción: Ocultar complejidad interna
  2. Validación es esencial: Garantizar invariantes
  3. Lazy loading beneficia: Recursos costosos que no siempre se usan
  4. API uniforme: Propiedades y métodos deben verse igual
-- MAL: Sobre-ingeniería para algo simple
local point = {}
setmetatable(point, {
    __index = function(t, key)
        if key == "x" then return rawget(t, "_x") end
        if key == "y" then return rawget(t, "_y") end
    end,
    __newindex = function(t, key, value)
        if key == "x" then rawset(t, "_x", value) end
        if key == "y" then rawset(t, "_y", value) end
    end
})

-- BIEN: Simplicidad cuando no necesitas control
local point = {x = 0, y = 0}

Caso Práctico: Sistema de Configuración con Validación y Defaults

Implementaremos un sistema de configuración que combina defaults, validación, y transformación automática.

-- Sistema completo de configuración
local ConfigSystem = {}

function ConfigSystem.new(schema)
    local config = {}
    local values = {}

    -- Validador de tipos
    local function validateType(key, value, expectedType)
        if expectedType == "integer" then
            return type(value) == "number" and math.floor(value) == value
        elseif expectedType == "positive_number" then
            return type(value) == "number" and value > 0
        elseif expectedType == "string_nonempty" then
            return type(value) == "string" and #value > 0
        else
            return type(value) == expectedType
        end
    end

    local mt = {
        __index = function(t, key)
            -- Si hay valor, retornarlo
            if values[key] ~= nil then
                return values[key]
            end

            -- Si hay default en schema, retornarlo
            if schema[key] and schema[key].default ~= nil then
                return schema[key].default
            end

            return nil
        end,

        __newindex = function(t, key, value)
            -- Verificar que la clave existe en schema
            if not schema[key] then
                error("Configuración desconocida: " .. key)
            end

            local spec = schema[key]

            -- Validar tipo
            if not validateType(key, value, spec.type) then
                error(string.format(
                    "Tipo inválido para '%s': esperado %s, recibido %s",
                    key, spec.type, type(value)
                ))
            end

            -- Aplicar transformación si existe
            if spec.transform then
                value = spec.transform(value)
            end

            -- Validación personalizada si existe
            if spec.validate then
                local ok, err = spec.validate(value)
                if not ok then
                    error(string.format("Validación fallida para '%s': %s", key, err))
                end
            end

            values[key] = value
        end,

        __pairs = function(t)
            -- Combinar valores y defaults
            local combined = {}

            -- Agregar valores establecidos
            for k, v in pairs(values) do
                combined[k] = v
            end

            -- Agregar defaults no sobreescritos
            for k, spec in pairs(schema) do
                if combined[k] == nil and spec.default ~= nil then
                    combined[k] = spec.default
                end
            end

            return pairs(combined)
        end
    }

    config = setmetatable({}, mt)

    -- Método para validar configuración completa
    function config.validate()
        local errors = {}

        for key, spec in pairs(schema) do
            if spec.required and values[key] == nil and spec.default == nil then
                table.insert(errors, "Falta configuración requerida: " .. key)
            end
        end

        if #errors > 0 then
            return false, table.concat(errors, "\n")
        end

        return true
    end

    -- Método para exportar configuración
    function config.toTable()
        local result = {}
        for k, v in pairs(config) do
            result[k] = v
        end
        return result
    end

    return config
end

-- Definir schema de configuración
local appSchema = {
    host = {
        type = "string_nonempty",
        default = "localhost",
        required = false
    },

    port = {
        type = "integer",
        default = 8080,
        required = false,
        validate = function(value)
            if value < 1 or value > 65535 then
                return false, "Puerto debe estar entre 1 y 65535"
            end
            return true
        end
    },

    timeout = {
        type = "positive_number",
        default = 30,
        required = false
    },

    apiKey = {
        type = "string",
        required = true,
        transform = function(value)
            -- Remover espacios y normalizar
            return value:gsub("%s+", ""):upper()
        end,
        validate = function(value)
            if #value < 32 then
                return false, "API key debe tener al menos 32 caracteres"
            end
            return true
        end
    },

    debug = {
        type = "boolean",
        default = false,
        required = false
    },

    maxConnections = {
        type = "integer",
        default = 100,
        required = false,
        validate = function(value)
            if value < 1 then
                return false, "Debe haber al menos 1 conexión"
            end
            return true
        end
    }
}

-- Crear configuración
local config = ConfigSystem.new(appSchema)

-- Usar defaults
print("Host:", config.host)        --> localhost
print("Port:", config.port)        --> 8080
print("Debug:", config.debug)      --> false

-- Establecer valores
config.apiKey = "  abc123def456ghi789jkl012mno345pqr  "
print("API Key:", config.apiKey)   --> ABC123DEF456GHI789JKL012MNO345PQR (transformado)

config.port = 3000
print("Port:", config.port)        --> 3000

config.maxConnections = 50
print("Max Connections:", config.maxConnections)  --> 50

-- Validaciones que fallarían:
-- config.port = 70000        --> Error: Puerto debe estar entre 1 y 65535
-- config.timeout = -5        --> Error: Tipo inválido (debe ser positive_number)
-- config.apiKey = "corta"    --> Error: API key debe tener al menos 32 caracteres
-- config.unknown = "value"   --> Error: Configuración desconocida

-- Iterar sobre toda la configuración (valores + defaults)
print("\n=== Configuración completa ===")
for key, value in pairs(config) do
    print(string.format("%s: %s", key, tostring(value)))
end

-- Exportar como tabla
local configTable = config.toTable()
for k, v in pairs(configTable) do
    print(k, v)
end

-- Validar configuración completa
local ok, err = config.validate()
print("Configuración válida:", ok)  --> true

Ejercicios

Ejercicio 1: Cache con Tiempo de Expiración

Implementa un sistema de cache donde los valores expiran después de cierto tiempo.

Requisitos:

-- Tu implementación aquí
local function createExpiringCache(ttl)
    -- Implementar cache con expiración
end

-- Ejemplo de uso:
local cache = createExpiringCache(2) -- 2 segundos de TTL

local counter = 0
cache.register("value", function()
    counter = counter + 1
    return "Valor " .. counter
end)

print(cache.value)  --> Valor 1
print(cache.value)  --> Valor 1 (mismo, en cache)

-- Esperar 3 segundos
os.execute("sleep 3")

print(cache.value)  --> Valor 2 (recomputado, expiró)

Solución:

local function createExpiringCache(ttl)
    local factories = {}
    local cache = {}
    local timestamps = {}

    local proxy = {}

    local mt = {
        __index = function(t, key)
            local now = os.time()

            -- Verificar si está en cache y no ha expirado
            if cache[key] ~= nil and timestamps[key] then
                if now - timestamps[key] < ttl then
                    return cache[key]
                end
            end

            -- Recomputar
            if factories[key] then
                local value = factories[key]()
                cache[key] = value
                timestamps[key] = now
                return value
            end

            return nil
        end
    }

    function proxy.register(key, factory)
        factories[key] = factory
    end

    function proxy.invalidate(key)
        cache[key] = nil
        timestamps[key] = nil
    end

    function proxy.invalidateAll()
        cache = {}
        timestamps = {}
    end

    return setmetatable(proxy, mt)
end

-- Test
local cache = createExpiringCache(2)

local counter = 0
cache.register("expensive", function()
    counter = counter + 1
    print("Calculando valor costoso...")
    return counter * 100
end)

print(cache.expensive)  --> Calculando valor costoso... 100
print(cache.expensive)  --> 100 (cache)

os.execute("sleep 3")

print(cache.expensive)  --> Calculando valor costoso... 200 (expiró)

cache.invalidate("expensive")
print(cache.expensive)  --> Calculando valor costoso... 300 (invalidado manualmente)

Ejercicio 2: Proxy de Auditoría

Crea un proxy que registre en un log todas las operaciones (lectura, escritura, eliminación) con timestamp.

Requisitos:

-- Tu implementación aquí
local function createAuditProxy(target, auditName)
    -- Implementar proxy de auditoría
end

-- Ejemplo de uso:
local data = {count = 0}
local auditData = createAuditProxy(data, "UserData")

auditData.count = auditData.count + 1
auditData.name = "Test"
local x = auditData.missing

local log = auditData.getAuditLog()
for _, entry in ipairs(log) do
    print(entry)
end

Solución:

local function createAuditProxy(target, auditName)
    local auditLog = {}

    local function log(operation, key, value)
        local entry = {
            timestamp = os.date("%Y-%m-%d %H:%M:%S"),
            operation = operation,
            target = auditName,
            key = key,
            value = value
        }
        table.insert(auditLog, entry)
    end

    local proxy = {}

    local mt = {
        __index = function(t, key)
            if key == "getAuditLog" then
                return function()
                    local formatted = {}
                    for _, entry in ipairs(auditLog) do
                        table.insert(formatted, string.format(
                            "[%s] %s.%s - %s: %s",
                            entry.timestamp,
                            entry.target,
                            entry.key,
                            entry.operation,
                            tostring(entry.value)
                        ))
                    end
                    return formatted
                end
            end

            local value = target[key]

            if value == nil then
                log("READ_NIL", key, nil)
            else
                log("READ", key, value)
            end

            return value
        end,

        __newindex = function(t, key, value)
            log("WRITE", key, value)
            target[key] = value
        end
    }

    return setmetatable(proxy, mt)
end

-- Test
local data = {count = 0, status = "active"}
local auditData = createAuditProxy(data, "UserData")

-- Operaciones auditadas
auditData.count = auditData.count + 1
auditData.name = "Alice"
local x = auditData.missing
auditData.status = "inactive"

-- Ver log de auditoría
print("=== Audit Log ===")
for _, entry in ipairs(auditData.getAuditLog()) do
    print(entry)
end

--> [2025-01-20 10:30:45] UserData.count - READ: 0
--> [2025-01-20 10:30:45] UserData.count - WRITE: 1
--> [2025-01-20 10:30:45] UserData.name - WRITE: Alice
--> [2025-01-20 10:30:45] UserData.missing - READ_NIL: nil
--> [2025-01-20 10:30:45] UserData.status - WRITE: inactive

Ejercicio 3: Builder Pattern con Validación

Implementa un builder pattern usando atributos dinámicos que valide la construcción de objetos complejos.

Requisitos:

-- Tu implementación aquí
local function createBuilder(schema)
    -- Implementar builder pattern
end

-- Ejemplo de uso:
local userSchema = {
    name = {required = true, type = "string"},
    email = {required = true, type = "string"},
    age = {required = false, type = "number", default = 18},
    active = {required = false, type = "boolean", default = true}
}

local user = createBuilder(userSchema)
    :name("Bob")
    :email("[email protected]")
    :age(25)
    :build()

print(user.name, user.email, user.age, user.active)

Solución:

local function createBuilder(schema)
    local values = {}
    local builder = {}

    -- Crear métodos chainable para cada campo
    for key, spec in pairs(schema) do
        builder[key] = function(self, value)
            -- Validar tipo
            if type(value) ~= spec.type then
                error(string.format(
                    "Tipo inválido para '%s': esperado %s, recibido %s",
                    key, spec.type, type(value)
                ))
            end

            values[key] = value
            return self
        end
    end

    -- Método build
    function builder:build()
        local result = {}

        -- Validar campos requeridos
        for key, spec in pairs(schema) do
            if spec.required and values[key] == nil then
                error("Campo requerido faltante: " .. key)
            end

            -- Usar valor o default
            result[key] = values[key] or spec.default
        end

        return result
    end

    -- Método reset
    function builder:reset()
        values = {}
        return self
    end

    return builder
end

-- Test
local userSchema = {
    name = {required = true, type = "string"},
    email = {required = true, type = "string"},
    age = {required = false, type = "number", default = 18},
    active = {required = false, type = "boolean", default = true}
}

-- Construcción exitosa
local builder = createBuilder(userSchema)

local user1 = builder
    :name("Alice")
    :email("[email protected]")
    :age(30)
    :build()

print(user1.name, user1.email, user1.age, user1.active)
--> Alice  [email protected]  30  true

-- Usando defaults
local user2 = builder:reset()
    :name("Bob")
    :email("[email protected]")
    :build()

print(user2.name, user2.email, user2.age, user2.active)
--> Bob  [email protected]  18  true (age y active usan defaults)

-- Error por campo faltante:
-- local user3 = builder:reset():name("Charlie"):build()
--> Error: Campo requerido faltante: email

Resumen

En este capítulo exploramos:

  1. __index como función: Computación dinámica de propiedades
  2. Lazy Loading: Carga diferida de recursos costosos con caching
  3. Proxies: Interceptación completa de operaciones con logging y validación
  4. Wrappers: Extensión de funcionalidad sin modificar originales
  5. Cadenas de búsqueda: Herencia multinivel con mixins
  6. Caso práctico: Sistema de configuración con validación, defaults y transformación

Los atributos dinámicos son una herramienta poderosa para crear abstracciones elegantes, pero deben usarse con criterio. En el próximo capítulo exploraremos ambientes y sandboxing para ejecutar código no confiable de forma segura.