← Volver al listado de tecnologías

Capítulo 10: Metatablas - El Protocolo de Objetos

Por: Artiko
luametatablasmetamethodsoopprototipos

Capítulo 10: Metatablas - El Protocolo de Objetos

“Los objetos sin clases son como funciones sin nombres: más simples, más honestos.” — Roberto Ierusalimschy

Las metatablas son el mecanismo fundamental de Lua para extender el comportamiento de las tablas. Son el corazón de la orientación a objetos, sobrecarga de operadores, propiedades computadas, y mucho más.

A diferencia de lenguajes con clases (Java, Python), Lua usa prototype-based OOP, similar a JavaScript. Las metatablas son el protocolo que hace esto posible.

¿Qué es una Metatabla?

Una metatabla es una tabla especial que define cómo una tabla se comporta en ciertas operaciones. Puedes establecer la metatabla de cualquier tabla usando setmetatable:

local t = {x = 10}
local mt = {
    __index = function(table, key)
        return "key not found: " .. key
    end
}

setmetatable(t, mt)

-- >>> print(t.x)
-- 10
-- >>> print(t.y)
-- key not found: y

La función getmetatable obtiene la metatabla de una tabla:

-- >>> print(getmetatable(t) == mt)
-- true

Metamétodo __index: Lectura de Campos

__index se invoca cuando accedes a un campo que no existe en la tabla.

__index como Función

local defaults = {
    name = "Anonymous",
    age = 0,
    city = "Unknown"
}

local person = {}
setmetatable(person, {
    __index = function(t, key)
        return defaults[key]
    end
})

-- >>> print(person.name)
-- Anonymous
-- >>> person.name = "Alice"
-- >>> print(person.name)
-- Alice

__index como Tabla (Más Común)

local defaults = {
    name = "Anonymous",
    age = 0,
    city = "Unknown"
}

local person = {}
setmetatable(person, {__index = defaults})

-- >>> print(person.name)
-- Anonymous
-- >>> print(person.age)
-- 0

Este patrón es la base de la herencia por prototipos en Lua.

Metamétodo __newindex: Escritura en Campos

__newindex se invoca cuando intentas asignar a un campo que no existe.

local proxy = {}
local data = {}

setmetatable(proxy, {
    __newindex = function(t, key, value)
        print(string.format("Setting %s = %s", key, tostring(value)))
        data[key] = value
    end,
    __index = function(t, key)
        print(string.format("Getting %s", key))
        return data[key]
    end
})

-- >>> proxy.name = "Alice"
-- Setting name = Alice
-- >>> print(proxy.name)
-- Getting name
-- Alice

Read-Only Tables

local function readonly(t)
    return setmetatable({}, {
        __index = t,
        __newindex = function(table, key, value)
            error("Attempt to modify read-only table")
        end
    })
end

-- >>> local config = readonly({host = "localhost", port = 8080})
-- >>> print(config.host)
-- localhost
-- >>> config.host = "example.com"
-- Error: Attempt to modify read-only table

Metamétodo __call: Tablas Llamables

__call permite llamar a una tabla como si fuera una función.

local Calculator = {}

function Calculator.new(initial)
    local self = {value = initial or 0}

    setmetatable(self, {
        __call = function(t, operation, operand)
            if operation == "add" then
                t.value = t.value + operand
            elseif operation == "sub" then
                t.value = t.value - operand
            elseif operation == "mul" then
                t.value = t.value * operand
            elseif operation == "div" then
                t.value = t.value / operand
            end
            return t.value
        end
    })

    return self
end

-- >>> local calc = Calculator.new(10)
-- >>> print(calc("add", 5))
-- 15
-- >>> print(calc("mul", 2))
-- 30

Constructor Pattern

local Person = {}
setmetatable(Person, {
    __call = function(cls, name, age)
        local self = {name = name, age = age}
        setmetatable(self, {__index = cls})
        return self
    end
})

function Person:greet()
    return "Hello, I'm " .. self.name
end

-- >>> local alice = Person("Alice", 30)
-- >>> print(alice:greet())
-- Hello, I'm Alice

Metamétodo __tostring: Representación en String

__tostring define cómo se convierte la tabla a string con tostring().

local Point = {}
Point.__index = Point

function Point.new(x, y)
    local self = {x = x, y = y}
    setmetatable(self, Point)
    return self
end

function Point:__tostring()
    return string.format("Point(%d, %d)", self.x, self.y)
end

-- >>> local p = Point.new(10, 20)
-- >>> print(p)
-- Point(10, 20)
-- >>> print(tostring(p))
-- Point(10, 20)

Metamétodo __len: Operador #

__len personaliza el operador de longitud #.

local Queue = {}
Queue.__index = Queue

function Queue.new()
    return setmetatable({items = {}}, Queue)
end

function Queue:push(value)
    table.insert(self.items, value)
end

function Queue:pop()
    return table.remove(self.items, 1)
end

function Queue:__len()
    return #self.items
end

-- >>> local q = Queue.new()
-- >>> q:push(10)
-- >>> q:push(20)
-- >>> q:push(30)
-- >>> print(#q)
-- 3

Metamétodos Aritméticos

Puedes sobrecargar operadores matemáticos:

local Vector = {}
Vector.__index = Vector

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

function Vector:__add(other)
    return Vector.new(self.x + other.x, self.y + other.y)
end

function Vector:__sub(other)
    return Vector.new(self.x - other.x, self.y - other.y)
end

function Vector:__mul(scalar)
    if type(scalar) == "number" then
        return Vector.new(self.x * scalar, self.y * scalar)
    end
end

function Vector:__unm()
    return Vector.new(-self.x, -self.y)
end

function Vector:__tostring()
    return string.format("Vector(%.2f, %.2f)", self.x, self.y)
end

-- >>> local v1 = Vector.new(3, 4)
-- >>> local v2 = Vector.new(1, 2)
-- >>> print(v1 + v2)
-- Vector(4.00, 6.00)
-- >>> print(v1 - v2)
-- Vector(2.00, 2.00)
-- >>> print(v1 * 2)
-- Vector(6.00, 8.00)
-- >>> print(-v1)
-- Vector(-3.00, -4.00)

Metamétodos de Comparación

local Date = {}
Date.__index = Date

function Date.new(year, month, day)
    return setmetatable({year = year, month = month, day = day}, Date)
end

function Date:to_days()
    -- Simplificado: asume todos los meses tienen 30 días
    return self.year * 365 + self.month * 30 + self.day
end

function Date:__eq(other)
    return self:to_days() == other:to_days()
end

function Date:__lt(other)
    return self:to_days() < other:to_days()
end

function Date:__le(other)
    return self:to_days() <= other:to_days()
end

function Date:__tostring()
    return string.format("%04d-%02d-%02d", self.year, self.month, self.day)
end

-- >>> local d1 = Date.new(2025, 1, 15)
-- >>> local d2 = Date.new(2025, 1, 20)
-- >>> print(d1 < d2)
-- true
-- >>> print(d1 == d2)
-- false
-- >>> print(d1 <= d2)
-- true

Metamétodo __concat: Operador ..

local StringBuilder = {}
StringBuilder.__index = StringBuilder

function StringBuilder.new(str)
    return setmetatable({parts = {str or ""}}, StringBuilder)
end

function StringBuilder:__concat(other)
    local result = StringBuilder.new()
    for _, part in ipairs(self.parts) do
        table.insert(result.parts, part)
    end

    if type(other) == "string" then
        table.insert(result.parts, other)
    elseif getmetatable(other) == StringBuilder then
        for _, part in ipairs(other.parts) do
            table.insert(result.parts, part)
        end
    end

    return result
end

function StringBuilder:__tostring()
    return table.concat(self.parts)
end

-- >>> local sb1 = StringBuilder.new("Hello")
-- >>> local sb2 = StringBuilder.new(" World")
-- >>> local sb3 = sb1 .. sb2 .. "!"
-- >>> print(sb3)
-- Hello World!

Metamétodos de Iteración (Lua 5.2+)

__pairs: Personalizar pairs()

local OrderedMap = {}
OrderedMap.__index = OrderedMap

function OrderedMap.new()
    return setmetatable({
        keys = {},
        values = {}
    }, OrderedMap)
end

function OrderedMap:set(key, value)
    if not self.values[key] then
        table.insert(self.keys, key)
    end
    self.values[key] = value
end

function OrderedMap:__pairs()
    local i = 0
    return function()
        i = i + 1
        local key = self.keys[i]
        if key then
            return key, self.values[key]
        end
    end
end

-- >>> local map = OrderedMap.new()
-- >>> map:set("z", 1)
-- >>> map:set("a", 2)
-- >>> map:set("m", 3)
-- >>> for k, v in pairs(map) do
-- >>>     print(k, v)
-- >>> end
-- z    1
-- a    2
-- m    3
-- (mantiene orden de inserción)

Metamétodo __metatable: Proteger Metatabla

__metatable oculta la metatabla real:

local secret = {x = 10}
setmetatable(secret, {
    __metatable = "This metatable is protected",
    __index = function(t, k)
        return "secret value"
    end
})

-- >>> print(getmetatable(secret))
-- This metatable is protected
-- >>> setmetatable(secret, {})
-- Error: cannot change a protected metatable

DEEP DIVE: Cómo Lua Busca Metamétodos

Cuando ejecutas una operación como a + b, Lua:

  1. Verifica si a tiene metatabla con __add
  2. Si no, verifica si b tiene metatabla con __add
  3. Si alguno lo tiene, llama al metamétodo
  4. Si ninguno lo tiene, lanza error
local v1 = Vector.new(1, 2)
local n = 5

-- >>> print(v1 * n)    -- Funciona: v1 tiene __mul
-- Vector(5.00, 10.00)

-- >>> print(n * v1)    -- Error: number no tiene __mul para Vector
-- Error: attempt to multiply a number with a table

Solución: Manejar Ambos Casos

function Vector:__mul(other)
    if type(other) == "number" then
        return Vector.new(self.x * other, self.y * other)
    elseif type(self) == "number" then
        -- self es el número, other es el vector
        return Vector.new(other.x * self, other.y * self)
    end
end

-- Necesitas también:
Vector.__mul = function(a, b)
    if type(a) == "number" then
        return Vector.new(b.x * a, b.y * a)
    elseif type(b) == "number" then
        return Vector.new(a.x * b, a.y * b)
    end
end

Caso Práctico: Vector2D Completo

local Vector2D = {}
Vector2D.__index = Vector2D

function Vector2D.new(x, y)
    return setmetatable({x = x or 0, y = y or 0}, Vector2D)
end

-- Aritmética
function Vector2D:__add(other)
    return Vector2D.new(self.x + other.x, self.y + other.y)
end

function Vector2D:__sub(other)
    return Vector2D.new(self.x - other.x, self.y - other.y)
end

function Vector2D.__mul(a, b)
    if type(a) == "number" then
        return Vector2D.new(b.x * a, b.y * a)
    elseif type(b) == "number" then
        return Vector2D.new(a.x * b, a.y * b)
    else
        -- Producto punto
        return a.x * b.x + a.y * b.y
    end
end

function Vector2D.__div(a, b)
    if type(b) == "number" then
        return Vector2D.new(a.x / b, a.y / b)
    end
    error("Cannot divide vector by vector")
end

function Vector2D:__unm()
    return Vector2D.new(-self.x, -self.y)
end

-- Comparación
function Vector2D:__eq(other)
    return self.x == other.x and self.y == other.y
end

-- Métodos
function Vector2D:length()
    return math.sqrt(self.x * self.x + self.y * self.y)
end

function Vector2D:normalize()
    local len = self:length()
    if len > 0 then
        return self / len
    end
    return Vector2D.new(0, 0)
end

function Vector2D:dot(other)
    return self.x * other.x + self.y * other.y
end

function Vector2D:__tostring()
    return string.format("Vector2D(%.2f, %.2f)", self.x, self.y)
end

-- >>> local v1 = Vector2D.new(3, 4)
-- >>> local v2 = Vector2D.new(1, 2)
-- >>> print(v1 + v2)
-- Vector2D(4.00, 6.00)
-- >>> print(v1 * 2)
-- Vector2D(6.00, 8.00)
-- >>> print(2 * v1)
-- Vector2D(6.00, 8.00)
-- >>> print(v1:length())
-- 5.0
-- >>> print(v1:normalize())
-- Vector2D(0.60, 0.80)

Caso Práctico: Proxy Table

local function create_proxy(target, handler)
    local proxy = {}

    setmetatable(proxy, {
        __index = function(t, key)
            if handler.get then
                return handler.get(target, key)
            end
            return target[key]
        end,

        __newindex = function(t, key, value)
            if handler.set then
                handler.set(target, key, value)
            else
                target[key] = value
            end
        end
    })

    return proxy
end

-- >>> local data = {name = "Alice", age = 30}
-- >>> local proxy = create_proxy(data, {
-- >>>     get = function(target, key)
-- >>>         print("Reading: " .. key)
-- >>>         return target[key]
-- >>>     end,
-- >>>     set = function(target, key, value)
-- >>>         print(string.format("Writing: %s = %s", key, tostring(value)))
-- >>>         target[key] = value
-- >>>     end
-- >>> })
-- >>> print(proxy.name)
-- Reading: name
-- Alice
-- >>> proxy.age = 31
-- Writing: age = 31

Caso Práctico: Default Values Factory

local function with_defaults(defaults)
    return function(values)
        values = values or {}
        return setmetatable(values, {__index = defaults})
    end
end

-- >>> local create_config = with_defaults({
-- >>>     host = "localhost",
-- >>>     port = 8080,
-- >>>     timeout = 30
-- >>> })

-- >>> local config1 = create_config({host = "example.com"})
-- >>> print(config1.host, config1.port)
-- example.com    8080

-- >>> local config2 = create_config({port = 3000})
-- >>> print(config2.host, config2.port)
-- localhost    3000

Caso Práctico: Auto-Vivification

Crear tablas anidadas automáticamente:

local function autovivify(t)
    return setmetatable(t or {}, {
        __index = function(table, key)
            table[key] = autovivify({})
            return table[key]
        end
    })
end

-- >>> local data = autovivify()
-- >>> data.users.alice.age = 30
-- >>> data.users.alice.city = "Madrid"
-- >>> data.users.bob.age = 25
-- >>> print(data.users.alice.age)
-- 30

SOAPBOX: Metatablas vs Reflexión

En lenguajes como Python, usas __getattr__ y __setattr__. En Ruby, method_missing. En JavaScript, Proxies. Lua eligió un enfoque más simple: una tabla con funciones hook. Es menos mágico, más explícito, más rápido.


Resumen del Capítulo

Metatablas en Lua:

Próximo: Capítulo 11: Coroutines - Concurrencia Cooperativa


Ejercicios

  1. Implementar Stack con Metatablas: Crear una pila con operadores sobrecargados.
local Stack = {}
Stack.__index = Stack

function Stack.new()
    -- Tu código aquí
end

-- Implementar:
-- __len: retornar tamaño
-- __tostring: representación visual
-- push() y pop() como métodos

-- >>> local s = Stack.new()
-- >>> s:push(10)
-- >>> s:push(20)
-- >>> print(#s)
-- 2
-- >>> print(s)
-- Stack[10, 20]
  1. Observable Table: Tabla que notifica cambios.
function observable(t, on_change)
    -- Tu código aquí (usar __index y __newindex)
end

-- >>> local data = observable({}, function(key, old_val, new_val)
-- >>>     print(string.format("%s changed: %s -> %s", key, old_val, new_val))
-- >>> end)
-- >>> data.name = "Alice"
-- name changed: nil -> Alice
-- >>> data.name = "Bob"
-- name changed: Alice -> Bob
  1. Complex Numbers: Implementar números complejos con todas las operaciones.
local Complex = {}
Complex.__index = Complex

function Complex.new(real, imag)
    -- Tu código aquí
end

-- Implementar: __add, __sub, __mul, __div, __eq, __tostring
-- Bonus: módulo y conjugado

-- >>> local c1 = Complex.new(3, 4)
-- >>> local c2 = Complex.new(1, 2)
-- >>> print(c1 + c2)
-- 4+6i
-- >>> print(c1 * c2)
-- -5+10i

Lecturas Adicionales