Herencia: encadenar metatablas
Cómo se construye una subclase encadenando metatablas, la forma correcta de llamar al método del padre, la herencia múltiple resuelta con una función de búsqueda y el coste real de una cadena larga.
La herencia en Lua no es una característica: es el mismo mecanismo de la lección anterior aplicado dos veces. Si una instancia delega en su clase porque __index apunta allí, nada impide que una clase delegue en otra por la misma vía. De esa observación trivial sale toda la herencia simple, y de sustituir la tabla por una función sale la herencia múltiple. Lo que no sale gratis es el coste: cada eslabón que añades a la cadena es una búsqueda fallida más en cada acceso a método, y hay un punto —medible, no filosófico— en el que conviene aplanar.
- Construir una subclase encadenando metatablas y verificar la cadena de delegación resultante.
- Invocar el método del padre correctamente y evitar los dos bucles infinitos clásicos.
- Implementar herencia múltiple con
__indexcomo función y comprender por qué Lua no tiene linealización. - Medir el coste de una cadena profunda y decidir con datos cuándo aplanarla o memorizarla.
La cadena de delegación
Una subclase es una clase corriente que además tiene metatabla, y esa metatabla es la clase padre:
local Animal = {}
Animal.__index = Animal
function Animal.nuevo(nombre)
return setmetatable({ nombre = nombre }, Animal)
end
function Animal:describir()
return self.nombre .. " hace " .. self:sonido()
end
function Animal:sonido() return "nada" end
-- La subclase
local Perro = setmetatable({}, { __index = Animal })
Perro.__index = Perro
function Perro.nuevo(nombre, raza)
local p = Animal.nuevo(nombre)
p.raza = raza
return setmetatable(p, Perro)
end
function Perro:sonido() return "guau" end
local p = Perro.nuevo("Rex", "pastor")
print(p:describir()) --> Rex hace guau
Hay dos relaciones distintas en juego y confundirlas es el error más frecuente del nivel. La instancia delega en Perro porque su metatabla es Perro y Perro.__index es Perro. La clase Perro delega en Animal porque su metatabla tiene __index igual a Animal. Son dos aplicaciones del mismo algoritmo en dos objetos distintos: la primera hace que un objeto tenga métodos, la segunda hace que una clase tenga los de otra.
El resultado más elegante está en la última línea del ejemplo. El método describir está definido en Animal y llama a self:sonido(). Como self es la instancia de Perro, la búsqueda de sonido empieza en la instancia, sigue en Perro y encuentra allí la versión redefinida, sin llegar nunca a Animal. Eso es despacho dinámico, y no hay ni una línea de código que lo implemente: es la consecuencia de que la búsqueda siempre parte del receptor real.
flowchart LR I[Instancia con nombre y raza] -->|metatabla| P[Clase Perro] P -->|__index de su metatabla| A[Clase Animal] A -->|sin metatabla| N[Fin de la cadena y resultado nil] P -.->|define sonido| S[Version redefinida] A -.->|define describir| D[Version heredada]
Llamar al método del padre
El escenario habitual es extender el comportamiento heredado en vez de reemplazarlo. La forma correcta y sin sorpresas es invocar la función del padre explícitamente y por su tabla, pasándole el receptor a mano:
function Perro:describir()
return Animal.describir(self) .. " y es de raza " .. self.raza
end
Fíjate en el punto, no en los dos puntos: Animal.describir(self) selecciona la implementación concreta del padre y le entrega la instancia real. Si el cuerpo de esa función vuelve a llamar a self:sonido(), seguirá resolviéndose contra Perro, que es lo que quieres.
Hay dos maneras clásicas de estropear esto, y ambas producen bucles infinitos.
La primera es escribir self.super:describir() guardando super como campo de la clase. Los dos puntos hacen que el receptor pase a ser la tabla padre, no la instancia, con lo que se pierden todos los campos del objeto y el despacho dinámico deja de funcionar. La segunda es más peligrosa: si el padre no redefine el método y hereda a su vez del abuelo, self.super consultado desde el receptor puede resolverse contra la propia clase por herencia, y la llamada se invoca a sí misma hasta agotar la pila. El síntoma es stack overflow en una traza de miles de marcos idénticos.
-- Trampa: super resuelto a través de self puede volver a la misma clase
function Perro:describir()
return self.super.describir(self) -- self.super se hereda: bucle potencial
end
-- Seguro: la referencia al padre es léxica, resuelta en tiempo de definición
local padre = Animal
function Perro:describir()
return padre.describir(self)
end
La regla es que la referencia al padre debe ser léxica, capturada en el ámbito del archivo, y no un campo alcanzable por la propia cadena de herencia que estás intentando saltar.
Herencia múltiple con una función de búsqueda
El campo __index admite una función además de una tabla. Cuando la búsqueda falla en la instancia, la máquina virtual llama a esa función con el receptor y la clave, y toma su valor de retorno como resultado. Eso basta para consultar varias tablas padre:
local function crear_buscador(padres)
return function(t, clave)
for i = 1, #padres do
local v = padres[i][clave]
if v ~= nil then
rawset(t, clave, v) -- memoriza el hallazgo en el propio objeto
return v
end
end
return nil
end
end
local function clase(...)
local c = {}
c.__index = c
setmetatable(c, { __index = crear_buscador({ ... }) })
return c
end
local Nadador = {} ; Nadador.__index = Nadador
function Nadador:nadar() return self.nombre .. " nada" end
local PerroDeAgua = clase(Perro, Nadador)
Obsérvese que la búsqueda en cada padre se escribe padres[i][clave] con indexación normal, no con rawget: eso hace que la consulta sea recursiva y recorra también los ancestros de cada padre. La consecuencia es un recorrido en profundidad y de izquierda a derecha, que no es una linealización en ningún sentido riguroso.
Aquí conviene ser honesto sobre lo que este esquema no resuelve. Lua no tiene un orden de resolución de métodos: si dos padres definen el mismo nombre, gana el que aparezca antes en la lista, y no existe ningún mecanismo que detecte la ambigüedad ni que garantice la coherencia cuando dos ramas comparten un ancestro común. El diamante clásico no se resuelve, simplemente se atraviesa dos veces y se toma lo primero que aparezca. Si tu diseño depende de resolver bien esos casos, el problema no es que te falte una biblioteca: es que la herencia múltiple es la herramienta equivocada, y la composición explícita de la lección 12.5 es la respuesta.
La línea rawset merece atención. Memoriza el resultado en el objeto que lo pidió para que la siguiente búsqueda del mismo nombre lo encuentre en el paso 1 del algoritmo, sin invocar la función. Convierte un coste de búsqueda repetido en un coste único más una entrada de hash. El precio es la invalidación: si después alteras un método en cualquiera de los padres, los objetos que ya memorizaron la versión antigua seguirán usándola. En un sistema con recarga de código en caliente —el caso de Neovim o de un servidor de juego— esto es una fuente de fallos irreproducibles.
El coste de una cadena larga
Cada eslabón añade, para los nombres que no están en el primer nivel, una búsqueda fallida en una tabla más la lectura de un campo de metatabla. Todo ello ocurre dentro del bucle en C de la indexación, sin llamadas a Lua, y por tanto es muy barato en términos absolutos. Pero es lineal en la profundidad, y se paga en cada acceso, no una sola vez.
Tres observaciones sacan esto del terreno de la especulación. Primero: el caso rápido no cuesta nada. Un campo presente en la instancia se resuelve en el paso 1 y no toca la cadena en absoluto, aunque tengas quince niveles; la profundidad solo penaliza métodos y valores heredados.
Segundo: hay un límite duro. La máquina virtual aborta con chain too long, possible loop tras un número fijo de saltos —dos mil en las implementaciones habituales—, lo que existe para cortar ciclos, no para limitar diseños razonables. Si lo alcanzas, tienes un ciclo, no una jerarquía profunda.
Tercero: la solución cuando el perfil lo justifica es aplanar. En el momento de definir la subclase, copiar dentro de ella todos los métodos heredados que no redefine convierte cualquier profundidad en una sola indirección.
local function aplanar(hija, ...)
for _, padre in ipairs({ ... }) do
for k, v in pairs(padre) do
if rawget(hija, k) == nil then rawset(hija, k, v) end
end
end
hija.__index = hija
return hija
end
El intercambio es explícito: una entrada de hash por método heredado y por clase —no por instancia, que sería ruinoso— a cambio de eliminar la cadena. Y la misma renuncia que en la memorización: lo que copies queda congelado en el instante de la copia. Aplana al final del arranque, nunca durante la ejecución, y nunca antes de haber medido.
En los lenguajes con clases nominales, heredar es una afirmación sobre tipos que el compilador verifica y usa para razonar. En Lua, heredar es literalmente configurar dónde continúa una búsqueda cuando falla. Esa diferencia no es de implementación, es de naturaleza, y cambia el criterio con el que se diseña. Una jerarquía deja de ser una taxonomía de lo que las cosas son y pasa a ser una tabla de encaminamiento de lo que las cosas saben hacer, editable en tiempo de ejecución, sin garantías de consistencia y sin nadie que compruebe que una subclase respeta el contrato de su padre. Quien viene de un lenguaje con verificación estática tiende a interpretar esto como una carencia y a intentar reconstruir la garantía perdida con capas de comprobaciones. Es el instinto equivocado. Lo que Lua está diciendo con su silencio es que la relación importante entre dos objetos no es la de ancestro y descendiente sino la de quién sabe responder a qué mensaje, y que esa relación se expresa mejor con delegación explícita, con composición, o con no relacionarlos en absoluto. La herencia profunda en Lua no es cara por su coste de búsqueda, que es despreciable; es cara porque cada eslabón es un acoplamiento que ningún sistema de tipos vigila y que nadie te impedirá romper.
- Implementa
AnimalyPerrocomo en el ejemplo y comprueba congetmetatableque la cadena tiene la forma esperada en los dos niveles. - Redefine
describirenPerrollamando al padre por su tabla. Después reescríbelo usando un camposuperalcanzable por herencia y provoca el desbordamiento de pila. - Escribe el buscador de herencia múltiple y crea un diamante deliberado: dos padres que hereden del mismo abuelo y definan el mismo método. Predice qué versión gana antes de ejecutarlo.
- Añade y quita la línea
rawsetdel buscador y compara tiempos conos.clocksobre un millón de accesos al mismo método. - Construye una jerarquía de diez niveles, mide el acceso a un método del primero, aplánala y vuelve a medir. Anota el porcentaje de mejora y decide si lo habrías notado en tu programa real.