Metatables en Lua: implementar orientación a objetos y operadores personalizados

Lua no tiene clases. No tiene operadores sobrecargados en el sentido de C++ ni una jerarquía de tipos integrada. Lo que tiene son metatables: un mecanismo que permite asociar a cualquier tabla (o userdata) otra tabla con funciones especiales que controlan cómo se comporta la primera cuando se opera sobre ella. Es un sistema de metaprogramación minimalista que, manejado bien, da acceso a orientación a objetos, operadores personalizados y proxies transparentes.

Cómo funciona una metatable

Cada tabla puede tener asociada una metatable. Cuando Lua no sabe cómo ejecutar una operación sobre una tabla (sumarla, acceder a un campo que no existe, llamarla como función…), mira si la metatable tiene un metamétodo para esa operación. Si lo hay, lo llama; si no, lanza un error.

local t = {}
local mt = {}
setmetatable(t, mt)
print(getmetatable(t) == mt)   -- true

Metamétodos de acceso: __index y __newindex

__index es el metamétodo más importante. Se activa cuando se accede a una clave que no existe en la tabla:

local defaults = {color = "rojo", tamanyo = 10, activo = true}
local objeto = setmetatable({tamanyo = 20}, {__index = defaults})

print(objeto.tamanyo)   -- 20 (existe en objeto)
print(objeto.color)     -- rojo (no existe en objeto, cae a defaults.__index)
print(objeto.activo)    -- true

-- __index también puede ser una función:
local proxy = setmetatable({}, {
    __index = function(t, clave)
        print("Accediendo a clave inexistente: " .. tostring(clave))
        return nil
    end
})
local x = proxy.cualquiercosa
-- Imprime: "Accediendo a clave inexistente: cualquiercosa"

__newindex se activa cuando se asigna a una clave que no existe en la tabla. Sirve para hacer tablas de solo lectura o para interceptar asignaciones:

local soloLectura = setmetatable({x = 1, y = 2}, {
    __newindex = function(t, k, v)
        error("Esta tabla es de solo lectura: intento de asignar '" .. k .. "'")
    end
})

print(soloLectura.x)   -- 1 (lectura OK)
soloLectura.z = 3      -- ERROR: Esta tabla es de solo lectura: intento de asignar 'z'

Operadores aritméticos y de comparación

local Vector = {}
Vector.__index = Vector

function Vector.new(x, y)
    return setmetatable({x = x, y = y}, Vector)
end

-- Suma de vectores
function Vector.__add(a, b)
    return Vector.new(a.x + b.x, a.y + b.y)
end

-- Producto por escalar
function Vector.__mul(v, escalar)
    if type(escalar) == "number" then
        return Vector.new(v.x * escalar, v.y * escalar)
    elseif type(v) == "number" then
        return Vector.new(escalar.x * v, escalar.y * v)
    end
end

-- Longitud con #
function Vector.__len(v)
    return math.sqrt(v.x * v.x + v.y * v.y)
end

-- Representación como string
function Vector.__tostring(v)
    return string.format("(%g, %g)", v.x, v.y)
end

-- Igualdad
function Vector.__eq(a, b)
    return a.x == b.x and a.y == b.y
end

local v1 = Vector.new(3, 4)
local v2 = Vector.new(1, 2)
local v3 = v1 + v2
print(tostring(v3))   -- (4, 6)
print(#v1)            -- 5.0  (3-4-5)
print(v1 * 2)         -- (6, 8) gracias a __tostring en print
print(v1 == Vector.new(3, 4))   -- true

Orientación a objetos: el patrón estándar

El patrón más extendido para OOP en Lua usa __index = ClaseBase para que las instancias hereden los métodos de la clase:

-- Clase base: Animal
local Animal = {}
Animal.__index = Animal

function Animal.new(nombre, sonido)
    local self = setmetatable({}, Animal)
    self.nombre = nombre
    self.sonido = sonido
    self.energia = 100
    return self
end

function Animal:hablar()
    print(self.nombre .. " dice: " .. self.sonido)
end

function Animal:comer(cantidad)
    self.energia = self.energia + cantidad
    print(self.nombre .. " come. Energía: " .. self.energia)
end

function Animal:__tostring()
    return "Animal(" .. self.nombre .. ")"
end

-- Subclase: Perro
local Perro = setmetatable({}, {__index = Animal})
Perro.__index = Perro

function Perro.new(nombre)
    -- Llamar al constructor del padre
    local self = Animal.new(nombre, "Guau")
    return setmetatable(self, Perro)
end

-- Método específico de Perro
function Perro:buscarPalo()
    self.energia = self.energia - 10
    print(self.nombre .. " busca el palo. Energía: " .. self.energia)
end

-- Sobreescribir método del padre
function Perro:hablar()
    Animal.hablar(self)   -- llamar al método del padre
    print("(mueve la cola)")
end

local rex = Perro.new("Rex")
rex:hablar()         -- Rex dice: Guau n (mueve la cola)
rex:comer(20)        -- Rex come. Energía: 120
rex:buscarPalo()     -- Rex busca el palo. Energía: 110
print(rex.nombre)    -- Rex

El metamétodo __call: tablas que se llaman como funciones

-- Función memoizada usando __call
local Memoize = {}
Memoize.__index = Memoize

function Memoize.new(fn)
    return setmetatable({fn = fn, cache = {}}, Memoize)
end

function Memoize:__call(...)
    local key = table.concat({...}, ",")
    if self.cache[key] == nil then
        self.cache[key] = self.fn(...)
    end
    return self.cache[key]
end

local fibonacci = Memoize.new(function(n)
    if n <= 1 then return n end
    -- OJO: aquí fibonacci ya está en scope gracias al upvalue
    return fibonacci(n-1) + fibonacci(n-2)
end)

print(fibonacci(10))   -- 55
print(fibonacci(30))   -- 832040 (rápido gracias al cache)

Las metatables son la pieza que conecta la simplicidad de las tablas de Lua con patrones sofisticados como la herencia prototipal, los proxies reactivos o los DSL embebidos. Muchos frameworks de Lua, incluidos los de LÖVE y Defold, usan este patrón para sus APIs de objetos de juego. Una vez que se entiende cómo funciona __index, el resto del ecosistema Lua resulta mucho más legible.

Imagen: Pexels / César Gaviria

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP