Alternativas a los objetos
Composición explícita en lugar de herencia, objetos construidos como closures puros, las bibliotecas de clases del ecosistema con sus costes reales, y el criterio para reconocer cuándo no necesitas objetos en absoluto.
Después de cuatro lecciones construyendo un sistema de objetos con metatablas, toca la pregunta incómoda: cuánto de ese sistema necesitas de verdad. Lua es un lenguaje donde la orientación a objetos es una biblioteca que escribes tú, y esa condición tiene una implicación que casi nadie extrae: si es opcional construirla, es igual de legítimo no construirla. La biblioteca estándar del propio lenguaje, escrita por los diseñadores, apenas la usa. Este cierre del nivel examina las tres alternativas serias —composición, closures puros y bibliotecas del ecosistema— y el criterio para elegir la cuarta, que es ninguna.
- Sustituir jerarquías por composición explícita y por mezcla de comportamientos.
- Construir objetos como closures puros y reconocer en qué escenarios ganan.
- Comparar las bibliotecas de clases del ecosistema por lo que cuestan, no por su sintaxis.
- Aplicar un criterio explícito para decidir cuándo el problema no pide objetos.
Composición en lugar de herencia
La herencia resuelve dos problemas a la vez —reutilizar código y establecer una relación de sustitución— y en Lua solo el primero está soportado de verdad, porque no hay sistema de tipos que verifique el segundo. Cuando lo único que quieres es reutilizar, la composición es más barata y más clara.
La forma directa es delegación explícita: el objeto guarda una referencia a su colaborador y le reenvía lo que le corresponde.
local Motor = {}
Motor.__index = Motor
function Motor.nuevo(cv) return setmetatable({ cv = cv, en_marcha = false }, Motor) end
function Motor:arrancar() self.en_marcha = true ; return self end
local Coche = {}
Coche.__index = Coche
function Coche.nuevo(cv)
return setmetatable({ motor = Motor.nuevo(cv) }, Coche)
end
function Coche:arrancar()
self.motor:arrancar() -- delegación visible en el código
return self
end
Cuesta una línea por método reenviado y a cambio elimina toda la cadena de metatablas: la relación entre Coche y Motor está escrita, es rastreable con una búsqueda de texto y puede cambiarse en tiempo de ejecución sustituyendo el colaborador. Un coche puede tener dos motores; una subclase no puede tener dos padres sin la maquinaria de la lección anterior.
La segunda forma es la mezcla de comportamientos: copiar un conjunto de funciones dentro de la clase en el momento de definirla.
local Serializable = {
guardar = function(self) return table.concat(self:campos(), ",") end,
cargar = function(self, cad) return self:asignar(cad) end,
}
local function mezclar(destino, ...)
for _, origen in ipairs({ ... }) do
for k, v in pairs(origen) do destino[k] = v end
end
return destino
end
mezclar(Coche, Serializable)
El coste es una entrada de hash por método y por clase —nunca por instancia— y la resolución vuelve a ser de un solo salto. Las renuncias son dos, y hay que decirlas: las colisiones de nombres se resuelven en silencio por orden de mezcla, y lo copiado queda congelado, de modo que modificar Serializable después no afecta a quien ya lo mezcló. A cambio no hay diamante, no hay linealización que discutir y no hay ambigüedad que un lector tenga que reconstruir mentalmente.
Objetos como closures puros
La lección anterior presentó los closures como mecanismo de privacidad. Vistos como modelo completo son otra cosa: un objeto es simplemente un conjunto de funciones que comparten estado léxico, sin tabla de clase, sin metatabla y sin self.
local function contador(inicial)
local n = inicial or 0
return {
incrementar = function(paso) n = n + (paso or 1) ; return n end,
leer = function() return n end,
reiniciar = function() n = inicial or 0 end,
}
end
Existe una variante todavía más reducida: el objeto es una función, y el primer argumento es el mensaje.
local function celda(valor)
return function(mensaje, arg)
if mensaje == "leer" then return valor
elseif mensaje == "escribir" then valor = arg ; return valor
else error("mensaje desconocido: " .. tostring(mensaje)) end
end
end
local c = celda(10)
c("escribir", 42)
print(c("leer")) --> 42
Este estilo tiene virtudes concretas. La privacidad es absoluta. No existe la clase de errores de la lección 12.1, porque no hay receptor que se pueda perder ni dos puntos que confundir con un punto. Los objetos son inmediatamente aptos como callbacks: puedes pasar objeto.leer a cualquier sitio y seguirá funcionando, algo que un método con self nunca hace. Y encaja de forma natural con el estilo funcional de los iteradores y las corrutinas.
Y tiene costes que ya conoces del nivel anterior —un closure por método y por instancia— más uno nuevo: no hay identidad de clase, así que no puedes preguntar de qué tipo es un objeto ni escribir código genérico que reaccione a ello. La forma de despacho por mensaje añade además el coste de comparar cadenas en cada llamada y pierde toda ayuda de las herramientas: ningún autocompletado sabrá qué mensajes acepta tu objeto.
flowchart TD A[Necesito agrupar estado y comportamiento] --> B[Cuantas instancias voy a crear] B -->|muchisimas| C[Metatabla compartida con guion bajo] B -->|pocas| D[Necesito privacidad estricta] D -->|si| E[Closures puros] D -->|no| C A --> F[Solo transformo datos sin estado propio] F --> G[Modulo de funciones sobre tablas planas] C --> H[Necesito reutilizar comportamiento] H --> I[Composicion o mezcla antes que herencia]
Las bibliotecas del ecosistema
Todas resuelven lo mismo con las técnicas de este nivel. Elegir entre ellas es elegir cuánta maquinaria estás dispuesto a mantener en el proceso.
middleclass
La más completa y la más usada. Aporta Clase:subclass, mezclas con include, comprobación de tipo con isInstanceOf, y metamétodos heredados correctamente. Ronda las trescientas líneas y su coste es una tabla adicional por clase más un diccionario de métodos estáticos.
classic
Poco más de cien líneas, un solo archivo, sin dependencias. Ofrece herencia simple, extend, implement y is. Es el punto dulce cuando quieres una convención compartida sin adoptar un framework.
30log
Diseñada para ser mínima en tamaño de código y en huella de memoria, con herencia y mixins. Su público son entornos con restricciones fuertes, como consolas o sistemas embebidos.
penlight
Su módulo de clases forma parte de una biblioteca de utilidades general. Tiene sentido si ya dependes de penlight por otras razones; adoptarla solo por las clases es traer mucho equipaje.
Tres advertencias antes de añadir cualquiera de ellas a un proyecto. La primera es que ninguna te da lo que un lenguaje con clases nativas te daría: no hay verificación estática, los errores siguen apareciendo en tiempo de ejecución y las trazas se vuelven más largas y difíciles de leer, porque ahora pasan por las capas de la biblioteca. La segunda es la interoperabilidad: dos bibliotecas de clases distintas en el mismo proceso producen objetos que no se reconocen entre sí, y eso es un problema real en un proyecto con muchas dependencias. La tercera es específica de los entornos anfitriones: en Neovim, el código idiomático del propio editor y de sus plugins no usa ninguna de estas bibliotecas, sino tablas de funciones y el idioma canónico de la lección 12.2; introducir un framework de clases allí es nadar contra la corriente del ecosistema.
Cuándo no necesitas objetos en absoluto
El sesgo de este nivel es inevitable: cuatro lecciones enseñando a construir objetos crean la impresión de que hay que usarlos. La biblioteca estándar de Lua opina lo contrario. string, table, math, io y os son módulos de funciones sobre datos planos, y la única concesión a la orientación a objetos —poder escribir cadena:sub(1, 3)— es una metatabla puesta a los literales de texto, no una jerarquía. Cuatro señales indican que el problema no pide objetos.
Cuando el dato no tiene invariantes que proteger: un punto con x e y, un color, un rango de líneas. Una tabla plana y funciones que la reciben es más rápida, más fácil de serializar, comparar y copiar, y no obliga a nadie a aprender tu API.
Cuando solo hay una instancia: la configuración, el registro de eventos, la caché global. Un módulo es un singleton que no necesita constructor, y require ya se encarga de que exista una sola vez.
Cuando el comportamiento no depende del estado: un parser, un formateador, un validador son funciones puras, y envolverlas en un objeto para escribir formateador:formatear(x) en vez de formatear(x) solo añade una indirección y algo que mantener vivo. Y cuando lo que buscas es agrupar funciones bajo un nombre: eso es un módulo, no una clase; una tabla con funciones devuelta por el archivo, sin metatabla ni constructor.
local vec = {} -- estilo de datos planos: sin clase, sin self, sin metatabla
function vec.nuevo(x, y) return { x = x, y = y } end
function vec.sumar(a, b) return { x = a.x + b.x, y = a.y + b.y } end
function vec.norma(a) return math.sqrt(a.x * a.x + a.y * a.y) end
return vec
Esta versión crea vectores de dos entradas de hash y ninguna metatabla, se serializa a JSON sin código adicional, se compara campo a campo con cualquier función genérica y se puede pasar por la frontera de la API C sin ceremonia. Un vector como clase con metatabla y quince metamétodos aritméticos es más agradable de escribir y significativamente más caro de crear en un bucle que produce millones de ellos.
Lo que separa a quien programa Lua con soltura de quien lo escribe como si fuera otro lenguaje no es el dominio de las metatablas, sino la capacidad de preguntarse si hacen falta. En un lenguaje donde la clase es una palabra clave, todo problema se formula desde el principio en términos de clases porque no hay otro vocabulario disponible, y el coste de esa formulación es invisible por universal. En Lua el coste es visible: cada objeto que construyes es una tabla más para el recolector, una metatabla que alguien tendrá que seguir al depurar, una convención que documentar y una frontera que el lenguaje no vigilará por ti. Esa visibilidad es un regalo, porque convierte una decisión automática en una decisión deliberada, y las decisiones deliberadas se pueden revisar. La consecuencia práctica es un criterio, no una ideología: usa objetos cuando haya invariantes que un tipo de dato deba mantener y muchas piezas de código que los manipulen, usa composición cuando solo quieras reutilizar comportamiento, usa closures cuando la privacidad sea un requisito real, y usa tablas planas con funciones el resto del tiempo, que es más veces de lo que la costumbre sugiere. El mecanismo más potente de Lua no es setmetatable; es que puedes mirar cualquier abstracción del programa, ver exactamente de qué está hecha y decidir que no la necesitas.
- Toma una jerarquía de tres niveles de cualquier proyecto tuyo y reescríbela con delegación explícita. Cuenta las líneas de cada versión y anota cuál se entiende antes al leerla en frío.
- Implementa
contadorcomo closure puro y como clase con metatabla. Crea cien mil de cada uno, mide memoria y tiempo de creación, y anota el factor. - Escribe el objeto por despacho de mensajes y añade un mensaje desconocido. Compara el error obtenido con el que produce un método inexistente en el idioma canónico.
- Lee el código fuente de
classicde principio a fin —son cien líneas— e identifica en él cada una de las técnicas de las lecciones 12.1 a 12.4. - Busca en tu código el objeto con menos invariantes y conviértelo a tabla plana con funciones de módulo. Decide después si la conversión mejoró el programa y escribe la razón en una frase.