Ir al contenido principal

Programación Orientada a Objetos en Lua

La programación orientada a objetos (POO) es un paradigma fundamental en el desarrollo de software moderno que nos permite organizar el código de manera más intuitiva y reutilizable. Aunque Lua no incluye un sistema de clases incorporado como Java o Python, su flexibilidad nos permite implementar POO de múltiples formas elegantes usando tablas y metatablas. Este enfoque minimalista resulta en un modelo más ligero y versátil que los sistemas de clases tradicionales.

En este artículo aprenderás a implementar clases, objetos, herencia y polimorfismo en Lua. Construiremos sobre los conceptos de metatablas que exploramos anteriormente, demostrando cómo esta característica aparentemente simple es la base de un sistema de objetos poderoso y expresivo. Al final del artículo, serás capaz de diseñar jerarquías de clases completas y aplicar patrones de diseño orientados a objetos en tus proyectos Lua.

Conceptos fundamentales de POO

Antes de implementar clases en Lua, repasemos brevemente los conceptos clave de la programación orientada a objetos:

  • Clase: Una plantilla o modelo que define las propiedades y comportamientos de un tipo de objeto.
  • Objeto: Una instancia concreta de una clase, con sus propios valores de propiedades.
  • Encapsulación: El principio de agrupar datos y métodos relacionados, ocultando detalles internos.
  • Herencia: La capacidad de crear nuevas clases basadas en clases existentes, heredando sus características.
  • Polimorfismo: La habilidad de objetos de diferentes clases de responder al mismo mensaje de formas distintas.

Lua nos permite implementar todos estos conceptos, aunque con una sintaxis diferente a los lenguajes orientados a objetos tradicionales.

Implementación básica de clases

Creando una clase simple

La forma más sencilla de crear una clase en Lua es usando una tabla como prototipo. Veamos un ejemplo con una clase Persona:

-- Definicion de la clase Persona
Persona = {}

-- Constructor de la clase
function Persona:nueva(nombre, edad)
    local objeto = {}
    setmetatable(objeto, self)
    self.__index = self
    
    objeto.nombre = nombre
    objeto.edad = edad
    
    return objeto
end

-- Metodo de instancia
function Persona:saludar()
    print("Hola, soy " .. self.nombre .. " y tengo " .. self.edad .. " anos")
end

-- Metodo de instancia
function Persona:cumplirAnos()
    self.edad = self.edad + 1
    print(self.nombre .. " ahora tiene " .. self.edad .. " anos")
end

-- Crear instancias
local juan = Persona:nueva("Juan", 25)
local maria = Persona:nueva("Maria", 30)

juan:saludar()
maria:saludar()

juan:cumplirAnos()

Ejecuta este código y obtendrás:

Hola, soy Juan y tengo 25 anos
Hola, soy Maria y tengo 30 anos
Juan ahora tiene 26 anos

Analicemos qué está sucediendo:

  1. Persona es una tabla que actúa como clase.
  2. El método nueva funciona como constructor, creando una nueva instancia.
  3. setmetatable establece a Persona como metatabla del nuevo objeto.
  4. self.__index = self hace que las búsquedas de métodos se redirijan a la clase.
  5. Los métodos saludar y cumplirAnos operan sobre la instancia (self).

Entendiendo el operador de dos puntos

El operador : en Lua es azúcar sintáctico que automáticamente pasa la tabla como primer parámetro llamado self. Estas dos llamadas son equivalentes:

-- Con operador de dos puntos
objeto:metodo(arg1, arg2)

-- Sin operador de dos puntos (forma explícita)
objeto.metodo(objeto, arg1, arg2)

Esta característica hace que el código orientado a objetos sea más limpio y natural.

Propiedades y encapsulación

Variables de instancia públicas

En el ejemplo anterior, las propiedades nombre y edad son públicas, accesibles directamente:

local persona = Persona:nueva("Carlos", 28)
print(persona.nombre)  -- Acceso directo
persona.edad = 29      -- Modificacion directa

Simulando propiedades privadas

Aunque Lua no tiene modificadores de acceso como private o public, podemos simular encapsulación usando closures:

function Persona:nueva(nombre, edad)
    local objeto = {}
    setmetatable(objeto, self)
    self.__index = self
    
    -- Variables "privadas" en el closure
    local _nombre = nombre
    local _edad = edad
    
    -- Getters
    function objeto:obtenerNombre()
        return _nombre
    end
    
    function objeto:obtenerEdad()
        return _edad
    end
    
    -- Setters con validacion
    function objeto:establecerEdad(nuevaEdad)
        if nuevaEdad >= 0 then
            _edad = nuevaEdad
        else
            error("La edad no puede ser negativa")
        end
    end
    
    return objeto
end

-- Uso
local ana = Persona:nueva("Ana", 22)
print(ana:obtenerNombre())  -- Funciona
print(ana:obtenerEdad())    -- Funciona
-- print(ana._edad)         -- No funciona, _edad no es accesible
ana:establecerEdad(23)      -- Funciona con validacion

Esta técnica proporciona verdadera privacidad, ya que las variables locales solo existen dentro del closure y no pueden ser accedidas directamente desde fuera.

Herencia simple

La herencia permite crear clases especializadas a partir de clases más generales. Implementemos una clase Estudiante que herede de Persona:

-- Clase base Persona
Persona = {}

function Persona:nueva(nombre, edad)
    local objeto = {}
    setmetatable(objeto, self)
    self.__index = self
    
    objeto.nombre = nombre
    objeto.edad = edad
    
    return objeto
end

function Persona:saludar()
    print("Hola, soy " .. self.nombre)
end

-- Clase derivada Estudiante
Estudiante = {}
setmetatable(Estudiante, {__index = Persona})

function Estudiante:nueva(nombre, edad, matricula)
    local objeto = Persona:nueva(nombre, edad)
    setmetatable(objeto, self)
    self.__index = self
    
    objeto.matricula = matricula
    objeto.calificaciones = {}
    
    return objeto
end

function Estudiante:agregarCalificacion(materia, nota)
    self.calificaciones[materia] = nota
end

function Estudiante:promedio()
    local suma = 0
    local cantidad = 0
    
    for _, nota in pairs(self.calificaciones) do
        suma = suma + nota
        cantidad = cantidad + 1
    end
    
    if cantidad == 0 then
        return 0
    end
    
    return suma / cantidad
end

function Estudiante:saludar()
    -- Llamar al metodo de la clase base
    Persona.saludar(self)
    print("Mi matricula es: " .. self.matricula)
end

-- Crear un estudiante
local luis = Estudiante:nueva("Luis", 20, "2024001")
luis:saludar()
luis:agregarCalificacion("Matematicas", 8.5)
luis:agregarCalificacion("Fisica", 9.0)
luis:agregarCalificacion("Programacion", 9.5)

print("Promedio: " .. luis:promedio())

Salida:

Hola, soy Luis
Mi matricula es: 2024001
Promedio: 9.0

Observa cómo:

  1. Estudiante hereda de Persona usando setmetatable(Estudiante, {__index = Persona}).
  2. El método saludar es sobrescrito en Estudiante, pero aún puede llamar a la versión de Persona.
  3. Estudiante tiene sus propios métodos adicionales como agregarCalificacion y promedio.

Patrón de clase más robusto

Podemos crear una función auxiliar que simplifique la creación de clases con herencia:

function clase(base)
    local nueva_clase = {}
    
    -- Configurar herencia si hay clase base
    if base then
        setmetatable(nueva_clase, {__index = base})
    end
    
    -- Constructor por defecto
    nueva_clase.__index = nueva_clase
    
    function nueva_clase:nueva(...)
        local instancia = {}
        setmetatable(instancia, nueva_clase)
        
        if instancia.inicializar then
            instancia:inicializar(...)
        end
        
        return instancia
    end
    
    return nueva_clase
end

-- Uso del patron
Animal = clase()

function Animal:inicializar(nombre)
    self.nombre = nombre
end

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

-- Perro hereda de Animal
Perro = clase(Animal)

function Perro:inicializar(nombre, raza)
    Animal.inicializar(self, nombre)
    self.raza = raza
end

function Perro:hablar()
    print(self.nombre .. " ladra: Guau guau!")
end

function Perro:presentarse()
    print("Soy " .. self.nombre .. ", un " .. self.raza)
end

-- Gato hereda de Animal
Gato = clase(Animal)

function Gato:inicializar(nombre, color)
    Animal.inicializar(self, nombre)
    self.color = color
end

function Gato:hablar()
    print(self.nombre .. " maulla: Miau miau!")
end

-- Crear instancias
local firulais = Perro:nueva("Firulais", "Labrador")
local michi = Gato:nueva("Michi", "naranja")

firulais:presentarse()
firulais:hablar()
michi:hablar()

Salida:

Soy Firulais, un Labrador
Firulais ladra: Guau guau!
Michi maulla: Miau miau!

Este patrón proporciona:

  • Una forma consistente de crear clases
  • Soporte integrado para herencia
  • Un método inicializar que actúa como constructor
  • Código más limpio y mantenible

Polimorfismo

El polimorfismo permite que objetos de diferentes clases respondan al mismo mensaje. Veamos un ejemplo práctico:

function hacerHablarAnimales(animales)
    for _, animal in ipairs(animales) do
        animal:hablar()
    end
end

local animales = {
    Perro:nueva("Rex", "Pastor Aleman"),
    Gato:nueva("Luna", "gris"),
    Perro:nueva("Max", "Beagle"),
    Gato:nueva("Pelusa", "blanco")
}

hacerHablarAnimales(animales)

Salida:

Rex ladra: Guau guau!
Luna maulla: Miau miau!
Max ladra: Guau guau!
Pelusa maulla: Miau miau!

La función hacerHablarAnimales no necesita saber qué tipo específico de animal recibe. Simplemente llama al método hablar, y cada objeto responde según su implementación.

Ejemplo completo: Sistema de formas geométricas

Pongamos todo junto en un ejemplo más completo:

-- Funcion auxiliar para crear clases
function clase(base)
    local nueva_clase = {}
    
    if base then
        setmetatable(nueva_clase, {__index = base})
    end
    
    nueva_clase.__index = nueva_clase
    
    function nueva_clase:nueva(...)
        local instancia = {}
        setmetatable(instancia, nueva_clase)
        
        if instancia.inicializar then
            instancia:inicializar(...)
        end
        
        return instancia
    end
    
    return nueva_clase
end

-- Clase base Forma
Forma = clase()

function Forma:inicializar()
    self.color = "sin color"
end

function Forma:area()
    error("El metodo area() debe ser implementado por la clase derivada")
end

function Forma:perimetro()
    error("El metodo perimetro() debe ser implementado por la clase derivada")
end

function Forma:describir()
    print("Soy una forma de color " .. self.color)
    print("Area: " .. self:area())
    print("Perimetro: " .. self:perimetro())
end

-- Clase Rectangulo
Rectangulo = clase(Forma)

function Rectangulo:inicializar(ancho, alto, color)
    Forma.inicializar(self)
    self.ancho = ancho
    self.alto = alto
    if color then
        self.color = color
    end
end

function Rectangulo:area()
    return self.ancho * self.alto
end

function Rectangulo:perimetro()
    return 2 * (self.ancho + self.alto)
end

-- Clase Circulo
Circulo = clase(Forma)

function Circulo:inicializar(radio, color)
    Forma.inicializar(self)
    self.radio = radio
    if color then
        self.color = color
    end
end

function Circulo:area()
    return math.pi * self.radio * self.radio
end

function Circulo:perimetro()
    return 2 * math.pi * self.radio
end

-- Clase Triangulo
Triangulo = clase(Forma)

function Triangulo:inicializar(lado1, lado2, lado3, color)
    Forma.inicializar(self)
    self.lado1 = lado1
    self.lado2 = lado2
    self.lado3 = lado3
    if color then
        self.color = color
    end
end

function Triangulo:area()
    -- Formula de Heron
    local s = self:perimetro() / 2
    return math.sqrt(s * (s - self.lado1) * (s - self.lado2) * (s - self.lado3))
end

function Triangulo:perimetro()
    return self.lado1 + self.lado2 + self.lado3
end

-- Crear formas
local formas = {
    Rectangulo:nueva(5, 3, "rojo"),
    Circulo:nueva(4, "azul"),
    Triangulo:nueva(3, 4, 5, "verde")
}

-- Procesar todas las formas polimorficamente
print("=== DESCRIPCION DE FORMAS ===\n")
for i, forma in ipairs(formas) do
    print("Forma " .. i .. ":")
    forma:describir()
    print()
end

-- Calcular area total
local areaTotal = 0
for _, forma in ipairs(formas) do
    areaTotal = areaTotal + forma:area()
end

print("Area total de todas las formas: " .. areaTotal)

Este ejemplo demuestra:

  • Una jerarquía de clases clara con una clase base abstracta
  • Métodos que deben ser sobrescritos por las clases derivadas
  • Polimorfismo en acción con diferentes tipos de formas
  • Encapsulación de lógica específica de cada forma

Buenas prácticas en POO con Lua

Convenciones de nomenclatura

  • Clases: Usa PascalCase (MiClase, CuentaBancaria)
  • Métodos: Usa camelCase (obtenerNombre, calcularPromedio)
  • Variables privadas: Prefija con guion bajo (_saldo, _configuracion)

Estructura recomendada

-- Definicion de clase
MiClase = {}

-- Constantes de clase
MiClase.VERSION = "1.0"
MiClase.VALOR_MAXIMO = 100

-- Constructor
function MiClase:nueva(parametros)
    -- Implementacion
end

-- Metodos publicos
function MiClase:metodoPublico()
    -- Implementacion
end

-- Metodos "privados" (por convencion)
function MiClase:_metodoPrivado()
    -- Implementacion
end

Validación en constructores

Siempre valida los parámetros del constructor:

function CuentaBancaria:nueva(titular, saldoInicial)
    if not titular or titular == "" then
        error("El titular es requerido")
    end
    
    if saldoInicial and saldoInicial < 0 then
        error("El saldo inicial no puede ser negativo")
    end
    
    local cuenta = {}
    setmetatable(cuenta, self)
    self.__index = self
    
    cuenta.titular = titular
    cuenta.saldo = saldoInicial or 0
    
    return cuenta
end

Documentación de clases

Documenta tus clases para facilitar su uso:

--[[
Clase: Vehiculo
Descripcion: Representa un vehiculo generico
Parametros del constructor:
    - marca (string): Marca del vehiculo
    - modelo (string): Modelo del vehiculo
    - ano (number): Año de fabricacion
Metodos:
    - acelerar(velocidad): Aumenta la velocidad
    - frenar(): Reduce la velocidad a cero
    - obtenerInfo(): Retorna informacion del vehiculo
]]--
Vehiculo = {}

Comparación con otros lenguajes

Ventajas del modelo de Lua

  • Flexibilidad: No estás limitado a un único modelo de POO
  • Ligereza: El sistema es simple y eficiente
  • Potencia: Las metatablas permiten comportamientos muy sofisticados
  • Claridad: Entiendes exactamente cómo funciona todo

Desventajas

  • Sin soporte nativo: Debes implementar las estructuras tú mismo
  • Verbosidad inicial: Requiere más código de configuración
  • Menos protección: No hay verdadera privacidad sin closures
  • Curva de aprendizaje: El enfoque puede ser confuso al principio

Resumen

En este artículo hemos explorado cómo implementar programación orientada a objetos en Lua:

  • Creamos clases usando tablas y metatablas
  • Implementamos herencia mediante el metamétodo __index
  • Aplicamos polimorfismo permitiendo que diferentes objetos respondan a las mismas llamadas
  • Simulamos encapsulación usando closures para variables privadas
  • Desarrollamos un sistema de clases robusto y reutilizable
  • Aplicamos buenas prácticas para código orientado a objetos mantenible

La POO en Lua puede parecer menos estructurada que en lenguajes como Java o C++, pero esta flexibilidad es una fortaleza. Te permite elegir exactamente el nivel de complejidad que necesitas para cada proyecto, desde objetos simples hasta jerarquías de clases complejas.