wandres.dev
OBJETOS EN LUA · prototipos con metatablas

El idioma de la clase

Una sola tabla que hace de clase y de metatabla a la vez, __index apuntando a sí misma, el constructor y sus variantes, y la razón exacta por la que este idioma de cinco líneas funciona.

⏱ 15 min

El idioma canónico de clase en Lua ocupa cinco líneas y casi nadie que lo copia sabe explicar por qué funciona. La tabla que representa la clase es también su propia metatabla, y su campo __index apunta a sí misma: una autorreferencia que parece un truco de magia y es, en realidad, la aplicación literal del algoritmo de búsqueda de la máquina virtual. Este nivel disecciona ese algoritmo paso a paso, porque quien lo entiende puede inventar variantes con criterio y quien no, solo puede copiar plantillas y rezar.

🎯 Al terminar esta lección sabrás
  • Escribir el idioma canónico de clase y justificar cada una de sus líneas.
  • Seguir el algoritmo exacto que ejecuta la máquina virtual ante una indexación fallida.
  • Distinguir las variantes de constructor y elegir una por razones técnicas, no por costumbre.
  • Cuantificar lo que este idioma cuesta en memoria y enumerar lo que deliberadamente no ofrece.

El idioma canónico, línea por línea

local Cuenta = {}
Cuenta.__index = Cuenta

function Cuenta.nueva(titular, saldo)
  local instancia = setmetatable({}, Cuenta)
  instancia.titular = titular
  instancia.saldo   = saldo or 0
  return instancia
end

function Cuenta:depositar(monto)
  self.saldo = self.saldo + monto
  return self.saldo
end

function Cuenta:describir()
  return string.format("%s: %d", self.titular, self.saldo)
end

local c = Cuenta.nueva("Ada", 500)
c:depositar(120)
print(c:describir())   --> Ada: 620

Cada línea hace un trabajo distinto y ninguna es decorativa.

La primera crea una tabla vacía que cumplirá tres papeles simultáneos: espacio de nombres donde viven los métodos, metatabla de todas las instancias y objeto público al que el resto del programa se refiere como la clase. Que los tres papeles quepan en una tabla no es economía de líneas, es la consecuencia de que en Lua no existan categorías de tablas: cualquier tabla puede ser metatabla de cualquier otra.

La segunda es la que desconcierta. Cuenta.__index = Cuenta guarda dentro de la tabla una referencia a la propia tabla. No hay recursión infinita porque nadie recorre esa referencia dos veces: el motor la consulta una vez por búsqueda fallida y termina.

Por qué __index a sí misma funciona

La máquina virtual ejecuta un algoritmo fijo cuando evalúa t[k], y todo el sistema de objetos se deduce de él:

  1. Si t es una tabla y contiene la clave k, devuelve el valor. Aquí termina la inmensa mayoría de las búsquedas y, crucialmente, la metatabla ni se consulta.
  2. Si no la contiene, mira si t tiene metatabla. Si no la tiene, devuelve nil.
  3. Si la tiene, busca en ella el campo __index. Si ese campo es nil, devuelve nil.
  4. Si __index es una tabla, repite todo el algoritmo sobre esa tabla —incluida su propia metatabla—. Si es una función, la llama con el receptor y la clave, y devuelve su resultado.

Aplícalo a c:depositar(120). El azúcar lo convierte en c.depositar(c, 120). La instancia c contiene titular y saldo, pero no depositar: paso 1 fallido. Su metatabla es Cuenta: paso 2 superado. El campo Cuenta.__index vale Cuenta: paso 3 resuelto. Se repite la búsqueda sobre Cuenta, que sí contiene depositar: encontrado. La función se llama con c como primer argumento, y por tanto self es la instancia, no la clase.

flowchart TD
A[Buscar depositar en la instancia] --> B[Esta la clave en la parte propia]
B -->|si| C[Devolver el valor y terminar]
B -->|no| D[Tiene metatabla]
D -->|no| E[Devolver nil]
D -->|si| F[Leer el campo __index de la metatabla]
F -->|es tabla| G[Repetir la busqueda alli]
F -->|es funcion| H[Llamarla con receptor y clave]
G --> I[Se encuentra el metodo en la clase]
I --> J[Se invoca con self igual a la instancia]

La consecuencia práctica es que los datos viven en la instancia y el comportamiento en la clase, sin que ninguna declaración lo imponga: simplemente los métodos nunca están en la instancia, así que toda lectura de método cae por el escalón de __index, y todo campo escrito con self.x = v se queda en la instancia, porque la asignación normal no consulta __index sino __newindex, que aquí no existe.

Ese matiz asimétrico es el corazón del idioma. Si escribes c.saldo = 0 sobre una instancia que hasta entonces heredaba un valor por defecto de la clase, no modificas la clase: creas una clave propia en la instancia que oculta la de la clase para ese objeto y solo para él. Es exactamente el comportamiento que se espera de un campo de instancia, obtenido sin una sola línea de código que lo implemente.

El constructor y sus variantes

El constructor no es una construcción del lenguaje: es una función ordinaria cuyo único contrato es devolver una tabla con la metatabla puesta. Por eso hay varias formas en circulación y conviene saber qué se gana y qué se pierde con cada una.

🧬

Función con punto

Cuenta.nueva(...) es la más honesta: no toca self, no finge ser un método y no se rompe si alguien la llama con dos puntos por error. Es la recomendación por defecto cuando la clase no se hereda.

🧪

Método con dos puntos

Cuenta:nueva(...) recibe la clase como self, lo que permite escribir setmetatable(objeto, self) y que las subclases hereden el constructor sin reescribirlo. Es la forma correcta en jerarquías, y la incorrecta si alguien la invoca con punto.

📞

Metamétodo __call

Dotar a la clase de una metatabla con __call permite escribir Cuenta("Ada", 500). Elegante, pero introduce una segunda metatabla en el sistema —la de la clase— y confunde a quien lea el código sin conocer el truco.

🏭

Fábrica sin clase

Una función que devuelve una tabla ya poblada, sin metatabla alguna. No es orientación a objetos, y en muchos casos es la respuesta correcta. Se examina en la lección de alternativas.

La variante heredable merece verse escrita, porque anticipa el nivel siguiente:

function Cuenta:nueva(titular, saldo)
  local instancia = setmetatable({}, self)   -- self es la clase, no la instancia
  instancia.titular = titular
  instancia.saldo   = saldo or 0
  return instancia
end

La variante con __call requiere una segunda metatabla, esta vez sobre la clase, y conviene verla escrita para juzgar si el azúcar compensa:

setmetatable(Cuenta, { __call = function(clase, ...) return clase.nueva(...) end })

local c = Cuenta("Ada", 500)   -- ahora la clase es invocable

Aquí self es la tabla de la clase sobre la que se invocó el constructor. Si una subclase lo hereda, self será la subclase, y la instancia recibirá la metatabla correcta sin escribir una línea más. La condición para que esto funcione es que toda clase de la jerarquía mantenga su propio __index apuntando a sí misma.

Coste y renuncias del idioma

El coste en memoria es el mínimo posible para un sistema de objetos: una tabla por instancia, con espacio solo para sus campos de datos, más un puntero a la metatabla en la cabecera de esa tabla. Los métodos existen una única vez en el proceso, dentro de la clase, sin importar cuántas instancias haya. Si comparas contra la alternativa de copiar las funciones dentro de cada objeto, la diferencia es de una entrada de hash por método y por instancia, y en la práctica separa un programa que crea cien mil entidades de uno que no puede.

El coste en tiempo es una indirección extra por cada acceso a método: una búsqueda fallida en la instancia más una búsqueda acertada en la clase. Ambas ocurren en C, sin llamadas a Lua, y son extraordinariamente baratas. No optimices esto sin haber medido antes.

Hay una asimetría del motor que sorprende a casi todo el mundo y que conviene conocer antes de la lección de herencia: los metamétodos no se buscan siguiendo la cadena. Cuando la máquina virtual necesita __tostring, __eq, __add o cualquier otro, hace un acceso crudo sobre la metatabla del valor y se detiene ahí. No consulta el __index de esa metatabla.

function Cuenta.__tostring(c) return c.titular .. " tiene " .. c.saldo end
print(tostring(c))   --> funciona: __tostring está en la metatabla directa

Funciona porque la metatabla de la instancia es Cuenta y __tostring está literalmente en Cuenta. En cuanto exista una subclase cuya metatabla sea otra tabla, ese metamétodo dejará de encontrarse aunque el __index lleve hasta él. La solución de todas las bibliotecas del ecosistema es la misma: copiar los metamétodos del padre en cada subclase al crearla.

Las renuncias, en cambio, son reales y hay que enunciarlas sin adornos. No hay privacidad: cualquiera puede leer, escribir o borrar cualquier campo de cualquier instancia, y el nivel 12.4 examina qué cuesta remediarlo. No hay comprobación de tipos: Cuenta.depositar funcionará sobre cualquier tabla que tenga un campo saldo numérico, y fallará con un mensaje poco informativo sobre cualquier otra. No hay constructores ni destructores garantizados: existe el metamétodo __gc, pero exige que el objeto tenga metatabla con ese campo en el momento de la creación. Y no hay una noción de identidad de clase: comprobar si un objeto es una Cuenta significa recorrer manualmente su cadena de metatablas.

La clase es una convención, no una entidad

Lo que hace extraordinario a este idioma no es su brevedad, sino que sea completamente transparente: no existe ninguna estructura de datos oculta, ningún registro global de clases, ningún estado en el intérprete que sepa que estás haciendo orientación a objetos. Hay dos tablas y un campo llamado __index, y todo lo demás es el algoritmo de indexación funcionando como lo haría con cualquier otro dato. Esa transparencia tiene un precio y una recompensa. El precio es que el lenguaje no puede ayudarte: no hay error de compilación que detecte una clase mal construida, ni herramienta que dibuje tu jerarquía sin ejecutar el programa, ni garantía de que dos módulos no rediseñen la clase de formas incompatibles. La recompensa es que puedes inspeccionar y modificar cualquier parte del sistema en tiempo de ejecución con las mismas herramientas con las que manipulas una lista: añadir un método a todas las instancias existentes es una asignación, interceptar todos los accesos es reemplazar __index por una función, y construir un sistema de objetos completamente distinto —con propiedades, con anotaciones, con carga perezosa— no exige extender el lenguaje, solo escribir otras cinco líneas. Ningún lenguaje con class en su gramática te ofrece eso, y ninguno te obliga tampoco a merecerlo.

⚔️ Reconstruye el idioma desde el algoritmo
  1. Escribe la clase Cuenta completa y añade print(rawget(c, "depositar")) antes de llamar al método. Explica el nil que aparece.
  2. Elimina la línea Cuenta.__index = Cuenta y observa el error. Identifica en qué paso del algoritmo de indexación se detiene la búsqueda.
  3. Define un valor por defecto en la clase, léelo desde una instancia, sobrescríbelo con self y comprueba con rawget que la clase permanece intacta.
  4. Convierte el constructor a la forma con dos puntos y setmetatable({}, self). Verifica que sigue funcionando al llamarlo como Cuenta:nueva.
  5. Escribe una función es_instancia_de que recorra la cadena de metatablas con getmetatable hasta encontrar la clase buscada o llegar a nil.